Files
bodyshop/server/rr/rr-calculate-allocations.js

1237 lines
39 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* THIS IS A COPY of CDKCalculateAllocations, modified to:
* - Only calculate allocations needed for Reynolds & RR exports
* - Keep sales broken down into buckets (parts, taxable / non-taxable parts, taxable labor, non-taxable labor, extras)
* - Add extra logging for easier debugging
*
* Original comments follow.
*/
const { GraphQLClient } = require("graphql-request");
const Dinero = require("dinero.js");
const _ = require("lodash");
const queries = require("../graphql-client/queries");
const CreateRRLogEvent = require("./rr-logger-event");
const InstanceManager = require("../utils/instanceMgr").default;
const { DiscountNotAlreadyCounted } = InstanceManager({
imex: require("../job/job-totals"),
rome: require("../job/job-totals-USA")
});
/**
* ============================
* Helpers / Summarizers
* ============================
*/
const summarizeMoney = (dinero) => {
if (!dinero || typeof dinero.getAmount !== "function") return { cents: null };
return { cents: dinero.getAmount() };
};
const summarizeTaxAllocations = (tax) =>
Object.entries(tax || {}).map(([key, entry]) => ({
key,
sale: summarizeMoney(entry?.sale),
cost: summarizeMoney(entry?.cost)
}));
const summarizeAllocationsArray = (arr) =>
(arr || []).map((a) => ({
center: a.center || a.tax || null,
tax: a.tax || null,
sale: summarizeMoney(a.sale || a.totalSale || Dinero()),
cost: summarizeMoney(a.cost)
}));
const toFiniteNumber = (value) => {
const parsed = Number.parseFloat(value);
return Number.isFinite(parsed) ? parsed : 0;
};
/**
* Internal per-center bucket shape for *sales*.
* We keep separate buckets for RR so we can split
* taxable vs non-taxable parts and labor lines later.
*/
function emptyCenterBucket() {
const zero = Dinero();
return {
// Parts
partsSale: zero, // total parts (taxable + non-taxable)
partsTaxableSale: zero, // parts that should be taxed in RR
partsNonTaxableSale: zero, // parts that should NOT be taxed in RR
// Labor
laborTaxableSale: zero, // labor that should be taxed in RR
laborNonTaxableSale: zero, // labor that should NOT be taxed in RR
laborTaxableHours: 0,
laborNonTaxableHours: 0,
// Extras (MAPA/MASH/towing/PAO/etc)
extrasSale: zero, // total extras (taxable + non-taxable)
extrasTaxableSale: zero,
extrasNonTaxableSale: zero
};
}
function ensureCenterBucket(hash, center) {
if (!hash[center]) hash[center] = emptyCenterBucket();
return hash[center];
}
/**
* Thin logger wrapper: always uses CreateRRLogEvent,
* with structured data passed via meta arg.
*/
function createDebugLogger(connectionData) {
return (msg, meta, level = "DEBUG") => {
const baseMsg = "rr-calculate-allocations " + msg;
CreateRRLogEvent(connectionData, level, baseMsg, meta !== undefined ? meta : undefined);
};
}
/**
* Query job data for allocations.
*/
async function QueryJobData(connectionData, token, jobid) {
CreateRRLogEvent(connectionData, "DEBUG", "Querying job data for allocations", { jobid });
const client = new GraphQLClient(process.env.GRAPHQL_ENDPOINT, {});
const result = await client.setHeaders({ Authorization: token }).request(queries.GET_CDK_ALLOCATIONS, { id: jobid });
return result.jobs_by_pk;
}
/**
* Build tax allocation object depending on environment (imex vs rome).
* This matches the original logic, just split into its own helper.
*/
function buildTaxAllocations(bodyshop, job) {
return InstanceManager({
executeFunction: true,
deubg: true,
args: [],
imex: () => ({
state: {
center: bodyshop.md_responsibility_centers.taxes.state.name,
sale: Dinero(job.job_totals.totals.state_tax),
cost: Dinero(),
profitCenter: bodyshop.md_responsibility_centers.taxes.state,
costCenter: bodyshop.md_responsibility_centers.taxes.state
},
federal: {
center: bodyshop.md_responsibility_centers.taxes.federal.name,
sale: Dinero(job.job_totals.totals.federal_tax),
cost: Dinero(),
profitCenter: bodyshop.md_responsibility_centers.taxes.federal,
costCenter: bodyshop.md_responsibility_centers.taxes.federal
}
}),
rome: () => ({
tax_ty1: {
center: bodyshop.md_responsibility_centers.taxes.tax_ty1.name,
sale: Dinero(job.job_totals.totals.us_sales_tax_breakdown.ty1Tax),
cost: Dinero(),
profitCenter: bodyshop.md_responsibility_centers.taxes.tax_ty1,
costCenter: bodyshop.md_responsibility_centers.taxes.tax_ty1
},
tax_ty2: {
center: bodyshop.md_responsibility_centers.taxes.tax_ty2.name,
sale: Dinero(job.job_totals.totals.us_sales_tax_breakdown.ty2Tax),
cost: Dinero(),
profitCenter: bodyshop.md_responsibility_centers.taxes.tax_ty2,
costCenter: bodyshop.md_responsibility_centers.taxes.tax_ty2
},
tax_ty3: {
center: bodyshop.md_responsibility_centers.taxes.tax_ty3.name,
sale: Dinero(job.job_totals.totals.us_sales_tax_breakdown.ty3Tax),
cost: Dinero(),
profitCenter: bodyshop.md_responsibility_centers.taxes.tax_ty3,
costCenter: bodyshop.md_responsibility_centers.taxes.tax_ty3
},
tax_ty4: {
center: bodyshop.md_responsibility_centers.taxes.tax_ty4.name,
sale: Dinero(job.job_totals.totals.us_sales_tax_breakdown.ty4Tax),
cost: Dinero(),
profitCenter: bodyshop.md_responsibility_centers.taxes.tax_ty4,
costCenter: bodyshop.md_responsibility_centers.taxes.tax_ty4
},
tax_ty5: {
center: bodyshop.md_responsibility_centers.taxes.tax_ty5.name,
sale: Dinero(job.job_totals.totals.us_sales_tax_breakdown.ty5Tax),
cost: Dinero(),
profitCenter: bodyshop.md_responsibility_centers.taxes.tax_ty5,
costCenter: bodyshop.md_responsibility_centers.taxes.tax_ty5
}
})
});
}
/**
* ============================
* Tax Context & Helpers
* ============================
*/
/**
* Build a small "tax context" object from the job + current instance.
*
* This centralises all of the "is this category taxable?" logic so that
* the rest of the allocation code just asks simple yes/no questions.
*
* IMPORTANT: we are **not** calculating any tax **amounts** here that is
* still handled by job-costing. We only need to know if a given sale bucket
* should be treated as taxable vs non-taxable for RR (CustTxblNTxblFlag).
*/
function buildTaxContext(job = {}) {
const isImex = !!InstanceManager({ imex: true }); // Canada
const isRome = !!InstanceManager({ rome: true }); // US
const toNumber = (v) => (v == null ? 0 : Number(v) || 0);
const federalTaxRate = toNumber(job.federal_tax_rate);
const stateTaxRate = toNumber(job.state_tax_rate);
const localTaxRate = toNumber(job.local_tax_rate);
const hasFederalRate = federalTaxRate > 0;
const hasState = stateTaxRate > 0;
const hasLocal = localTaxRate > 0;
// "hasFederal" kept for backwards compatibility / logging (Canada only)
const hasFederal = isImex && hasFederalRate;
// Canada: if ANY of federal / state / local > 0, treat the job as
// "everything taxable by default", then let line-level flags override
// for parts where applicable.
const globalAllTaxCanada = isImex && (hasFederalRate || hasState || hasLocal);
const hasAnySalesTax = hasFederalRate || hasState || hasLocal;
// Parts tax rate map (PAA/PAC/…)
let partTaxRates = job.part_tax_rates || job.parts_tax_rates || {};
if (typeof partTaxRates === "string") {
try {
partTaxRates = JSON.parse(partTaxRates);
} catch {
partTaxRates = {};
}
}
if (!partTaxRates || typeof partTaxRates !== "object") {
partTaxRates = {};
}
const tax_lbr_rt = toNumber(job.tax_lbr_rt); // labour
const tax_paint_mat_rt = toNumber(job.tax_paint_mat_rt || job.tax_paint_mt_rate); // MAPA
const tax_shop_mat_rt = toNumber(job.tax_shop_mat_rt); // MASH
const tax_tow_rt = toNumber(job.tax_tow_rt); // towing
const tax_sub_rt = toNumber(job.tax_sub_rt); // sublet (rarely used directly)
const hasAnyPartsWithTax = Object.values(partTaxRates).some(
(entry) => entry && entry.prt_tax_in && toNumber(entry.prt_tax_rt) > 0
);
const hasAnyTax =
hasAnySalesTax ||
tax_lbr_rt > 0 ||
tax_paint_mat_rt > 0 ||
tax_shop_mat_rt > 0 ||
tax_tow_rt > 0 ||
tax_sub_rt > 0 ||
hasAnyPartsWithTax;
return {
isImex,
isRome,
federalTaxRate,
stateTaxRate,
localTaxRate,
hasFederal,
hasState,
hasLocal,
hasAnySalesTax,
globalAllTaxCanada,
partTaxRates,
tax_lbr_rt,
tax_paint_mat_rt,
tax_shop_mat_rt,
tax_tow_rt,
tax_sub_rt,
hasAnyPartsWithTax,
hasAnyTax
};
}
/**
* Resolve the "PA" / part-type code (PAA/PAC/…) from a job line.
*/
function resolvePartType(line = {}) {
return line.part_type || line.partType || line.pa_code || line.pa || null;
}
/**
* Decide if a *part* line is taxable vs non-taxable for RR.
*
* Rules:
* - Canada (IMEX):
* - If ANY of federal_tax_rate / state_tax_rate / local_tax_rate > 0
* => everything is taxable by default (globalAllTaxCanada),
* unless tax_part is explicitly false.
* - Otherwise, use part_tax_rates[part_type] (prt_tax_in && prt_tax_rt > 0),
* with tax_part as final override.
* - US (ROME):
* - Use part_tax_rates[part_type] (prt_tax_in && prt_tax_rt > 0),
* with tax_part as final override.
*
* - line.tax_part is treated as the *final* check:
* - tax_part === false => always non-taxable.
* - tax_part === true => always taxable, even if we have no table entry.
*/
function isPartTaxable(line = {}, taxCtx) {
if (!taxCtx) return !!line.tax_part;
const { globalAllTaxCanada, partTaxRates } = taxCtx;
// Explicit per-line override to *not* tax.
if (typeof line.tax_part === "boolean" && line.tax_part === false) {
return false;
}
// Canada: any federal/state/local tax rate set => all parts taxable,
// unless explicitly turned off above.
if (globalAllTaxCanada) {
return true;
}
let taxable = false;
const partType = resolvePartType(line);
if (partType && partTaxRates && partTaxRates[partType]) {
const entry = partTaxRates[partType];
const rate = Number(entry?.prt_tax_rt || 0);
const indicator = !!entry?.prt_tax_in;
taxable = indicator && rate > 0;
}
// tax_part === true is treated as "final yes" even if we didn't find
// a matching part_tax_rate entry.
if (typeof line.tax_part === "boolean" && line.tax_part === true) {
taxable = true;
}
return taxable;
}
/**
* Decide if *labour* for this job is taxable.
*
* - Canada (IMEX):
* - If ANY of federal_tax_rate / state_tax_rate / local_tax_rate > 0
* (globalAllTaxCanada) => all labour is taxable.
* - Else if tax_lbr_rt > 0 => labour taxable.
* - Else => non-taxable.
* - US (ROME):
* - tax_lbr_rt > 0 => labour taxable, otherwise not.
*/
function isLaborTaxable(_line, taxCtx) {
if (!taxCtx) return false;
const { isImex, globalAllTaxCanada, tax_lbr_rt } = taxCtx;
if (isImex && globalAllTaxCanada) return true;
return tax_lbr_rt > 0;
}
/**
* Taxability helpers for "extras" buckets.
* These are all job-level decisions; there are no per-line flags for them
* in the data we currently work with.
*
* Canada: if globalAllTaxCanada is true, we treat these as taxable.
*/
function isMapaTaxable(taxCtx) {
if (!taxCtx) return false;
const { isImex, globalAllTaxCanada, tax_paint_mat_rt } = taxCtx;
if (isImex && globalAllTaxCanada) return true;
return tax_paint_mat_rt > 0;
}
function isMashTaxable(taxCtx) {
if (!taxCtx) return false;
const { isImex, globalAllTaxCanada, tax_shop_mat_rt } = taxCtx;
if (isImex && globalAllTaxCanada) return true;
return tax_shop_mat_rt > 0;
}
function isTowTaxable(taxCtx) {
if (!taxCtx) return false;
const { isImex, globalAllTaxCanada, tax_tow_rt } = taxCtx;
if (isImex && globalAllTaxCanada) return true;
return tax_tow_rt > 0;
}
/**
* Helper to push an "extra" (MAPA/MASH/towing/PAO/etc) amount into the
* appropriate taxable / non-taxable buckets for a given center.
*/
function addExtras(bucket, dineroAmount, isTaxable) {
if (!bucket || !dineroAmount || typeof dineroAmount.add !== "function") return;
bucket.extrasSale = bucket.extrasSale.add(dineroAmount);
if (isTaxable) {
bucket.extrasTaxableSale = bucket.extrasTaxableSale.add(dineroAmount);
} else {
bucket.extrasNonTaxableSale = bucket.extrasNonTaxableSale.add(dineroAmount);
}
}
/**
* Build profitCenterHash from joblines (parts + labor) and detect MAPA/MASH presence.
* Now stores *buckets* instead of a single Dinero per center.
*/
function buildProfitCenterHash(job, debugLog, taxContext) {
let hasMapaLine = false;
let hasMashLine = false;
const profitCenterHash = job.joblines.reduce((acc, val) => {
// MAPA line?
if (val.db_ref === "936008") {
if (!hasMapaLine) {
debugLog("Detected existing MAPA line in joblines", {
joblineId: val.id,
db_ref: val.db_ref
});
}
hasMapaLine = true;
}
// MASH line?
if (val.db_ref === "936007") {
if (!hasMashLine) {
debugLog("Detected existing MASH line in joblines", {
joblineId: val.id,
db_ref: val.db_ref
});
}
hasMashLine = true;
}
// Parts
if (val.profitcenter_part) {
const bucket = ensureCenterBucket(acc, val.profitcenter_part);
let amount = Dinero({
amount: Math.round(val.act_price * 100)
}).multiply(val.part_qty || 1);
const hasDiscount = (val.prt_dsmk_m && val.prt_dsmk_m !== 0) || (val.prt_dsmk_p && val.prt_dsmk_p !== 0);
if (hasDiscount && DiscountNotAlreadyCounted(val, job.joblines)) {
const discount = val.prt_dsmk_m
? Dinero({ amount: Math.round(val.prt_dsmk_m * 100) })
: Dinero({
amount: Math.round(val.act_price * 100)
})
.multiply(val.part_qty || 0)
.percentage(Math.abs(val.prt_dsmk_p || 0))
.multiply(val.prt_dsmk_p > 0 ? 1 : -1);
amount = amount.add(discount);
}
const taxable = isPartTaxable(val, taxContext);
if (taxable) {
bucket.partsTaxableSale = bucket.partsTaxableSale.add(amount);
} else {
bucket.partsNonTaxableSale = bucket.partsNonTaxableSale.add(amount);
}
// Keep total parts for compatibility / convenience
bucket.partsSale = bucket.partsSale.add(amount);
}
// Labor
if (val.profitcenter_labor && val.mod_lbr_ty) {
const bucket = ensureCenterBucket(acc, val.profitcenter_labor);
const rateKey = `rate_${val.mod_lbr_ty.toLowerCase()}`;
const rate = job[rateKey];
const lineHours = toFiniteNumber(val.mod_lb_hrs);
const laborAmount = Dinero({
amount: Math.round(rate * 100)
}).multiply(val.mod_lb_hrs);
if (isLaborTaxable(val, taxContext)) {
bucket.laborTaxableSale = bucket.laborTaxableSale.add(laborAmount);
bucket.laborTaxableHours += lineHours;
} else {
bucket.laborNonTaxableSale = bucket.laborNonTaxableSale.add(laborAmount);
bucket.laborNonTaxableHours += lineHours;
}
}
return acc;
}, {});
debugLog("profitCenterHash after joblines", {
hasMapaLine,
hasMashLine,
centers: Object.entries(profitCenterHash).map(([center, b]) => ({
center,
parts: summarizeMoney(b.partsSale),
partsTaxable: summarizeMoney(b.partsTaxableSale),
partsNonTaxable: summarizeMoney(b.partsNonTaxableSale),
laborTaxable: summarizeMoney(b.laborTaxableSale),
laborNonTaxable: summarizeMoney(b.laborNonTaxableSale),
laborTaxableHours: b.laborTaxableHours,
laborNonTaxableHours: b.laborNonTaxableHours,
extras: summarizeMoney(b.extrasSale),
extrasTaxable: summarizeMoney(b.extrasTaxableSale),
extrasNonTaxable: summarizeMoney(b.extrasNonTaxableSale)
}))
});
return { profitCenterHash, hasMapaLine, hasMashLine };
}
/**
* Build costCenterHash from bills and timetickets.
*/
function buildCostCenterHash(job, selectedDmsAllocationConfig, disablebillwip, debugLog) {
let costCenterHash = {};
// 1) Bills -> costs
debugLog("disablebillwip flag", { disablebillwip });
if (!disablebillwip) {
costCenterHash = job.bills.reduce((billAcc, bill) => {
bill.billlines.forEach((line) => {
const targetCenter = selectedDmsAllocationConfig.costs[line.cost_center];
if (!targetCenter) return;
if (!billAcc[targetCenter]) billAcc[targetCenter] = Dinero();
const lineDinero = Dinero({
amount: Math.round((line.actual_cost || 0) * 100)
})
.multiply(line.quantity)
.multiply(bill.is_credit_memo ? -1 : 1);
billAcc[targetCenter] = billAcc[targetCenter].add(lineDinero);
});
return billAcc;
}, {});
}
debugLog("costCenterHash after bills (pre-timetickets)", {
centers: Object.entries(costCenterHash || {}).map(([center, dinero]) => ({
center,
...summarizeMoney(dinero)
}))
});
// 2) Timetickets -> costs
job.timetickets.forEach((ticket) => {
const effectiveHours =
ticket.employee && ticket.employee.flat_rate ? ticket.productivehrs || 0 : ticket.actualhrs || 0;
const ticketTotal = Dinero({
amount: Math.round(ticket.rate * effectiveHours * 100)
});
const targetCenter = selectedDmsAllocationConfig.costs[ticket.ciecacode];
if (!targetCenter) return;
if (!costCenterHash[targetCenter]) costCenterHash[targetCenter] = Dinero();
costCenterHash[targetCenter] = costCenterHash[targetCenter].add(ticketTotal);
});
debugLog("costCenterHash after timetickets", {
centers: Object.entries(costCenterHash || {}).map(([center, dinero]) => ({
center,
...summarizeMoney(dinero)
}))
});
return costCenterHash;
}
/**
* Add manual MAPA / MASH sales where needed (into extrasSale bucket).
*/
function applyMapaMashManualLines({
job,
selectedDmsAllocationConfig,
bodyshop,
profitCenterHash,
hasMapaLine,
hasMashLine,
debugLog,
taxContext
}) {
// MAPA (paint materials)
if (!hasMapaLine && job.job_totals.rates.mapa.total.amount > 0) {
const mapaAccountName = selectedDmsAllocationConfig.profits.MAPA;
const mapaAccount = bodyshop.md_responsibility_centers.profits.find((c) => c.name === mapaAccountName);
if (mapaAccount) {
const amount = Dinero(job.job_totals.rates.mapa.total);
const taxable = isMapaTaxable(taxContext);
debugLog("Adding MAPA Line Manually", {
mapaAccountName,
amount: summarizeMoney(amount),
taxable
});
const bucket = ensureCenterBucket(profitCenterHash, mapaAccountName);
addExtras(bucket, amount, taxable);
} else {
debugLog("NO MAPA ACCOUNT FOUND!!", { mapaAccountName });
}
}
// MASH (shop materials)
if (!hasMashLine && job.job_totals.rates.mash.total.amount > 0) {
const mashAccountName = selectedDmsAllocationConfig.profits.MASH;
const mashAccount = bodyshop.md_responsibility_centers.profits.find((c) => c.name === mashAccountName);
if (mashAccount) {
const amount = Dinero(job.job_totals.rates.mash.total);
const taxable = isMashTaxable(taxContext);
debugLog("Adding MASH Line Manually", {
mashAccountName,
amount: summarizeMoney(amount),
taxable
});
const bucket = ensureCenterBucket(profitCenterHash, mashAccountName);
addExtras(bucket, amount, taxable);
} else {
debugLog("NO MASH ACCOUNT FOUND!!", { mashAccountName });
}
}
return profitCenterHash;
}
/**
* Apply materials costing (MAPA/MASH cost side) when configured.
*/
function applyMaterialsCosting({ job, bodyshop, selectedDmsAllocationConfig, costCenterHash, debugLog }) {
const { cdk_configuration } = bodyshop || {};
if (!cdk_configuration?.sendmaterialscosting) return costCenterHash;
debugLog("sendmaterialscosting enabled", {
sendmaterialscosting: cdk_configuration.sendmaterialscosting,
use_paint_scale_data: job.bodyshop.use_paint_scale_data,
mixdataLength: job.mixdata?.length || 0
});
const percent = cdk_configuration.sendmaterialscosting;
// Paint Mat (MAPA)
const mapaAccountName = selectedDmsAllocationConfig.costs.MAPA;
const mapaAccount = bodyshop.md_responsibility_centers.costs.find((c) => c.name === mapaAccountName);
if (mapaAccount) {
if (!costCenterHash[mapaAccountName]) costCenterHash[mapaAccountName] = Dinero();
if (job.bodyshop.use_paint_scale_data === true) {
if (job.mixdata && job.mixdata.length > 0) {
debugLog("Using mixdata for MAPA cost", {
mapaAccountName,
totalliquidcost: job.mixdata[0] && job.mixdata[0].totalliquidcost
});
costCenterHash[mapaAccountName] = costCenterHash[mapaAccountName].add(
Dinero({
amount: Math.round(((job.mixdata[0] && job.mixdata[0].totalliquidcost) || 0) * 100)
})
);
} else {
debugLog("Using percentage of MAPA total (no mixdata)", { mapaAccountName });
costCenterHash[mapaAccountName] = costCenterHash[mapaAccountName].add(
Dinero(job.job_totals.rates.mapa.total).percentage(percent)
);
}
} else {
debugLog("Using percentage of MAPA total (no paint scale data)", { mapaAccountName });
costCenterHash[mapaAccountName] = costCenterHash[mapaAccountName].add(
Dinero(job.job_totals.rates.mapa.total).percentage(percent)
);
}
} else {
debugLog("NO MAPA ACCOUNT FOUND (costs)!!", { mapaAccountName });
}
// Shop Mat (MASH)
const mashAccountName = selectedDmsAllocationConfig.costs.MASH;
const mashAccount = bodyshop.md_responsibility_centers.costs.find((c) => c.name === mashAccountName);
if (mashAccount) {
debugLog("Adding MASH material costing", { mashAccountName });
if (!costCenterHash[mashAccountName]) costCenterHash[mashAccountName] = Dinero();
costCenterHash[mashAccountName] = costCenterHash[mashAccountName].add(
Dinero(job.job_totals.rates.mash.total).percentage(percent)
);
} else {
debugLog("NO MASH ACCOUNT FOUND (costs)!!", { mashAccountName });
}
return costCenterHash;
}
/**
* Apply non-tax extras (PVRT, towing, storage, PAO).
* Extras go into the extrasSale bucket (split taxable / non-taxable).
*/
function applyExtras({
job,
bodyshop,
selectedDmsAllocationConfig,
profitCenterHash,
taxAllocations,
debugLog,
taxContext
}) {
const { ca_bc_pvrt } = job;
// BC PVRT -> state tax
if (ca_bc_pvrt) {
debugLog("Adding PVRT to state tax allocation", { ca_bc_pvrt });
taxAllocations.state.sale = taxAllocations.state.sale.add(Dinero({ amount: Math.round((ca_bc_pvrt || 0) * 100) }));
}
// Towing
if (job.towing_payable && job.towing_payable !== 0) {
const towAccountName = selectedDmsAllocationConfig.profits.TOW;
const towAccount = bodyshop.md_responsibility_centers.profits.find((c) => c.name === towAccountName);
if (towAccount) {
const amount = Dinero({
amount: Math.round((job.towing_payable || 0) * 100)
});
const taxable = isTowTaxable(taxContext);
debugLog("Adding towing_payable to TOW account", {
towAccountName,
towing_payable: job.towing_payable,
taxable
});
const bucket = ensureCenterBucket(profitCenterHash, towAccountName);
addExtras(bucket, amount, taxable);
} else {
debugLog("NO TOW ACCOUNT FOUND!!", { towAccountName });
}
}
// Storage (shares TOW account)
if (job.storage_payable && job.storage_payable !== 0) {
const storageAccountName = selectedDmsAllocationConfig.profits.TOW;
const towAccount = bodyshop.md_responsibility_centers.profits.find((c) => c.name === storageAccountName);
if (towAccount) {
const amount = Dinero({
amount: Math.round((job.storage_payable || 0) * 100)
});
const taxable = isTowTaxable(taxContext);
debugLog("Adding storage_payable to TOW account", {
storageAccountName,
storage_payable: job.storage_payable,
taxable
});
const bucket = ensureCenterBucket(profitCenterHash, storageAccountName);
addExtras(bucket, amount, taxable);
} else {
debugLog("NO STORAGE/TOW ACCOUNT FOUND!!", { storageAccountName });
}
}
// Bottom line adjustment -> PAO
if (job.adjustment_bottom_line && job.adjustment_bottom_line !== 0) {
const otherAccountName = selectedDmsAllocationConfig.profits.PAO;
const otherAccount = bodyshop.md_responsibility_centers.profits.find((c) => c.name === otherAccountName);
if (otherAccount) {
const amount = Dinero({
amount: Math.round((job.adjustment_bottom_line || 0) * 100)
});
const taxable = !!(taxContext && taxContext.hasAnyTax);
debugLog("Adding adjustment_bottom_line to PAO", {
otherAccountName,
adjustment_bottom_line: job.adjustment_bottom_line,
taxable
});
const bucket = ensureCenterBucket(profitCenterHash, otherAccountName);
addExtras(bucket, amount, taxable);
} else {
debugLog("NO PAO ACCOUNT FOUND!!", { otherAccountName });
}
}
return { profitCenterHash, taxAllocations };
}
/**
* Apply Rome-specific profile adjustments (parts + rates).
* These also feed into the *sales* buckets.
*/
function applyRomeProfileAdjustments({
job,
bodyshop,
selectedDmsAllocationConfig,
profitCenterHash,
debugLog,
connectionData,
taxContext
}) {
// Only relevant for Rome instances
if (!InstanceManager({ rome: true })) return profitCenterHash;
if (!selectedDmsAllocationConfig || !selectedDmsAllocationConfig.profits) {
debugLog("ROME profile adjustments skipped (no selectedDmsAllocationConfig.profits)");
return profitCenterHash;
}
const partsAdjustments = job?.job_totals?.parts?.adjustments || {};
const rateMap = job?.job_totals?.rates || {};
debugLog("ROME profile adjustments block entered", {
partAdjustmentKeys: Object.keys(partsAdjustments),
rateKeys: Object.keys(rateMap)
});
const extrasTaxable = !!(taxContext && taxContext.hasAnyTax);
// Parts adjustments
Object.keys(partsAdjustments).forEach((key) => {
const accountName = selectedDmsAllocationConfig.profits[key];
const otherAccount = bodyshop.md_responsibility_centers.profits.find((c) => c.name === accountName);
if (otherAccount) {
const bucket = ensureCenterBucket(profitCenterHash, accountName);
const adjMoney = Dinero(partsAdjustments[key]);
addExtras(bucket, adjMoney, extrasTaxable);
debugLog("Added parts adjustment", {
key,
accountName,
adjustment: summarizeMoney(adjMoney),
taxable: extrasTaxable
});
} else {
CreateRRLogEvent(
connectionData,
"ERROR",
"Error encountered in rr-calculate-allocations. Unable to find parts adjustment account.",
{ accountName, key }
);
debugLog("Missing parts adjustment account", { key, accountName });
}
});
// Labor / materials adjustments (match CDK semantics: check `adjustment`, add `adjustments`)
Object.keys(rateMap).forEach((key) => {
const rate = rateMap[key];
if (!rate || !rate.adjustment) return;
const checkMoney = Dinero(rate.adjustment);
if (checkMoney.isZero()) return;
const accountName = selectedDmsAllocationConfig.profits[key.toUpperCase()];
const otherAccount = bodyshop.md_responsibility_centers.profits.find((c) => c.name === accountName);
if (otherAccount) {
const bucket = ensureCenterBucket(profitCenterHash, accountName);
// Note: we intentionally use `rate.adjustments` here to mirror CDK behaviour
const adjMoney = Dinero(rate.adjustments);
addExtras(bucket, adjMoney, extrasTaxable);
debugLog("Added rate adjustment", {
key,
accountName,
adjustment: summarizeMoney(adjMoney),
taxable: extrasTaxable
});
} else {
CreateRRLogEvent(
connectionData,
"ERROR",
"Error encountered in rr-calculate-allocations. Unable to find rate adjustment account.",
{ accountName, key }
);
debugLog("Missing rate adjustment account", { key, accountName });
}
});
return profitCenterHash;
}
/**
* Build job-level profit/cost allocations for each center.
* PUBLIC SHAPE (for RR):
* {
* center,
* partsSale,
* partsTaxableSale,
* partsNonTaxableSale,
* laborTaxableSale,
* laborNonTaxableSale,
* extrasSale,
* extrasTaxableSale,
* extrasNonTaxableSale,
* totalSale,
* cost,
* profitCenter,
* costCenter
* }
*/
function buildJobAllocations(bodyshop, profitCenterHash, costCenterHash, debugLog) {
const centers = _.union(Object.keys(profitCenterHash), Object.keys(costCenterHash));
const jobAllocations = centers.map((center) => {
const bucket = profitCenterHash[center] || emptyCenterBucket();
const extrasSale = bucket.extrasSale;
const totalSale = bucket.partsSale.add(bucket.laborTaxableSale).add(bucket.laborNonTaxableSale).add(extrasSale);
const profitCenter = bodyshop.md_responsibility_centers.profits.find((c) => c.name === center);
const costCenter = bodyshop.md_responsibility_centers.costs.find((c) => c.name === center);
return {
center,
// Parts
partsSale: bucket.partsSale,
partsTaxableSale: bucket.partsTaxableSale,
partsNonTaxableSale: bucket.partsNonTaxableSale,
// Labor
laborTaxableSale: bucket.laborTaxableSale,
laborNonTaxableSale: bucket.laborNonTaxableSale,
laborTaxableHours: bucket.laborTaxableHours,
laborNonTaxableHours: bucket.laborNonTaxableHours,
// Extras
extrasSale,
extrasTaxableSale: bucket.extrasTaxableSale,
extrasNonTaxableSale: bucket.extrasNonTaxableSale,
totalSale,
cost: costCenterHash[center] || Dinero(),
profitCenter,
costCenter
};
});
debugLog(
"jobAllocations built",
jobAllocations.map((row) => ({
center: row.center,
parts: summarizeMoney(row.partsSale),
partsTaxable: summarizeMoney(row.partsTaxableSale),
partsNonTaxable: summarizeMoney(row.partsNonTaxableSale),
laborTaxable: summarizeMoney(row.laborTaxableSale),
laborNonTaxable: summarizeMoney(row.laborNonTaxableSale),
extras: summarizeMoney(row.extrasSale),
extrasTaxable: summarizeMoney(row.extrasTaxableSale),
extrasNonTaxable: summarizeMoney(row.extrasNonTaxableSale),
totalSale: summarizeMoney(row.totalSale),
cost: summarizeMoney(row.cost)
}))
);
return jobAllocations;
}
/**
* Build tax allocations array from taxAllocations hash.
* Shape is unchanged from original (except extra logging).
*/
function buildTaxAllocArray(taxAllocations, selectedDmsAllocationConfig, debugLog) {
const taxAllocArray = Object.keys(taxAllocations)
.filter((key) => taxAllocations[key].sale.getAmount() > 0 || taxAllocations[key].cost.getAmount() > 0)
.map((key) => {
if (
key === "federal" &&
selectedDmsAllocationConfig.gst_override &&
selectedDmsAllocationConfig.gst_override !== ""
) {
const ret = { ...taxAllocations[key], tax: key };
ret.costCenter.dms_acctnumber = selectedDmsAllocationConfig.gst_override;
ret.profitCenter.dms_acctnumber = selectedDmsAllocationConfig.gst_override;
return ret;
}
return { ...taxAllocations[key], tax: key };
});
debugLog("taxAllocArray built", summarizeAllocationsArray(taxAllocArray));
return taxAllocArray;
}
/**
* Build adjustment allocations (ttl_adjustment + ttl_tax_adjustment).
*/
function buildAdjustmentAllocations(job, bodyshop, debugLog) {
const ttlAdjArray = job.job_totals.totals.ttl_adjustment
? [
{
center: "SUB ADJ",
sale: Dinero(job.job_totals.totals.ttl_adjustment),
cost: Dinero(),
profitCenter: {
name: "SUB ADJ",
accountdesc: "SUB ADJ",
accountitem: "SUB ADJ",
accountname: "SUB ADJ",
dms_acctnumber: bodyshop.md_responsibility_centers.ttl_adjustment.dms_acctnumber
},
costCenter: {}
}
]
: [];
const ttlTaxAdjArray = job.job_totals.totals.ttl_tax_adjustment
? [
{
center: "TAX ADJ",
sale: Dinero(job.job_totals.totals.ttl_tax_adjustment),
cost: Dinero(),
profitCenter: {
name: "TAX ADJ",
accountdesc: "TAX ADJ",
accountitem: "TAX ADJ",
accountname: "TAX ADJ",
dms_acctnumber: bodyshop.md_responsibility_centers.ttl_tax_adjustment.dms_acctnumber
},
costCenter: {}
}
]
: [];
if (ttlAdjArray.length) {
debugLog("ttl_adjustment allocation added", summarizeAllocationsArray(ttlAdjArray));
}
if (ttlTaxAdjArray.length) {
debugLog("ttl_tax_adjustment allocation added", summarizeAllocationsArray(ttlTaxAdjArray));
}
return { ttlAdjArray, ttlTaxAdjArray };
}
/**
* Core allocation calculation RR-only, with bucketed sales.
*
* RETURN SHAPE:
* {
* jobAllocations, // per-center buckets (see buildJobAllocations)
* taxAllocArray, // tax allocations
* ttlAdjArray, // ttl_adjustment allocations
* ttlTaxAdjArray // ttl_tax_adjustment allocations
* }
*/
function calculateAllocations(connectionData, job) {
const { bodyshop } = job;
const debugLog = createDebugLogger(connectionData);
debugLog("ENTER", {
bodyshopId: bodyshop?.id,
bodyshopName: bodyshop?.name,
dms_allocation: job.dms_allocation,
hasBills: Array.isArray(job.bills) ? job.bills.length : 0,
joblines: Array.isArray(job.joblines) ? job.joblines.length : 0,
timetickets: Array.isArray(job.timetickets) ? job.timetickets.length : 0
});
const taxContext = buildTaxContext(job);
debugLog("Tax context initialised", {
isImex: taxContext.isImex,
isRome: taxContext.isRome,
federalTaxRate: taxContext.federalTaxRate,
stateTaxRate: taxContext.stateTaxRate,
localTaxRate: taxContext.localTaxRate,
hasFederal: taxContext.hasFederal,
hasState: taxContext.hasState,
hasLocal: taxContext.hasLocal,
globalAllTaxCanada: taxContext.globalAllTaxCanada,
tax_lbr_rt: taxContext.tax_lbr_rt,
tax_paint_mat_rt: taxContext.tax_paint_mat_rt,
tax_shop_mat_rt: taxContext.tax_shop_mat_rt,
tax_tow_rt: taxContext.tax_tow_rt,
hasAnyPartsWithTax: taxContext.hasAnyPartsWithTax,
hasAnyTax: taxContext.hasAnyTax
});
// 1) Tax allocations
let taxAllocations = buildTaxAllocations(bodyshop, job);
debugLog("Initial taxAllocations", summarizeTaxAllocations(taxAllocations));
// 2) Profit centers from job lines + MAPA/MASH detection
const {
profitCenterHash: initialProfitHash,
hasMapaLine,
hasMashLine
} = buildProfitCenterHash(job, debugLog, taxContext);
// 3) DMS allocation config
const selectedDmsAllocationConfig =
bodyshop.md_responsibility_centers.dms_defaults.find((d) => d.name === job.dms_allocation) || null;
CreateRRLogEvent(connectionData, "DEBUG", "Using DMS Allocation for cost export", {
allocationName: selectedDmsAllocationConfig && selectedDmsAllocationConfig.name
});
debugLog("Selected DMS allocation config", {
name: selectedDmsAllocationConfig && selectedDmsAllocationConfig.name
});
// 4) Cost centers from bills and timetickets
const disablebillwip = !!bodyshop?.pbs_configuration?.disablebillwip;
let costCenterHash = buildCostCenterHash(job, selectedDmsAllocationConfig, disablebillwip, debugLog);
// 5) Manual MAPA/MASH sales (when needed)
let profitCenterHash = applyMapaMashManualLines({
job,
selectedDmsAllocationConfig,
bodyshop,
profitCenterHash: initialProfitHash,
hasMapaLine,
hasMashLine,
debugLog,
taxContext
});
// 6) Materials costing (MAPA/MASH cost side)
costCenterHash = applyMaterialsCosting({
job,
bodyshop,
selectedDmsAllocationConfig,
costCenterHash,
debugLog
});
// 7) PVRT / towing / storage / PAO extras
({ profitCenterHash, taxAllocations } = applyExtras({
job,
bodyshop,
selectedDmsAllocationConfig,
profitCenterHash,
taxAllocations,
debugLog,
taxContext
}));
// 8) Rome-only profile-level adjustments
profitCenterHash = applyRomeProfileAdjustments({
job,
bodyshop,
selectedDmsAllocationConfig,
profitCenterHash,
debugLog,
connectionData,
taxContext
});
debugLog("profitCenterHash before jobAllocations build", {
centers: Object.entries(profitCenterHash || {}).map(([center, b]) => ({
center,
parts: summarizeMoney(b.partsSale),
partsTaxable: summarizeMoney(b.partsTaxableSale),
partsNonTaxable: summarizeMoney(b.partsNonTaxableSale),
laborTaxable: summarizeMoney(b.laborTaxableSale),
laborNonTaxable: summarizeMoney(b.laborNonTaxableSale),
extras: summarizeMoney(b.extrasSale),
extrasTaxable: summarizeMoney(b.extrasTaxableSale),
extrasNonTaxable: summarizeMoney(b.extrasNonTaxableSale)
}))
});
debugLog("costCenterHash before jobAllocations build", {
centers: Object.entries(costCenterHash || {}).map(([center, dinero]) => ({
center,
...summarizeMoney(dinero)
}))
});
// 9) Build job-level allocations & tax allocations
const jobAllocations = buildJobAllocations(bodyshop, profitCenterHash, costCenterHash, debugLog);
const taxAllocArray = buildTaxAllocArray(taxAllocations, selectedDmsAllocationConfig, debugLog);
const { ttlAdjArray, ttlTaxAdjArray } = buildAdjustmentAllocations(job, bodyshop, debugLog);
const result = {
jobAllocations,
taxAllocArray,
ttlAdjArray,
ttlTaxAdjArray
};
debugLog("FINAL allocations summary", {
jobAllocationsCount: jobAllocations.length,
taxAllocCount: taxAllocArray.length,
ttlAdjCount: ttlAdjArray.length,
ttlTaxAdjCount: ttlTaxAdjArray.length
});
debugLog("EXIT");
return result;
}
/**
* HTTP route wrapper (kept for compatibility; still logs via RR logger).
* Responds with { data: { jobAllocations, taxAllocArray, ttlAdjArray, ttlTaxAdjArray } }
*/
exports.defaultRoute = async function (req, res) {
try {
CreateRRLogEvent(req, "DEBUG", "Received request to calculate allocations", { jobid: req.body.jobid });
const jobData = await QueryJobData(req, req.BearerToken, req.body.jobid);
const data = calculateAllocations(req, jobData);
return res.status(200).json({ data });
} catch (error) {
CreateRRLogEvent(req, "ERROR", "Error encountered in rr-calculate-allocations.", {
message: error?.message || String(error),
stack: error?.stack
});
res.status(500).json({ error: `Error encountered in rr-calculate-allocations. ${error}` });
}
};
/**
* Socket entry point (what rr-job-export & rr-register-socket-events call).
* Reynolds-only: WSS + RR logger.
*
* Returns the same object as calculateAllocations().
*/
exports.default = async function (socket, jobid) {
try {
const token = `Bearer ${socket.handshake.auth.token}`;
const jobData = await QueryJobData(socket, token, jobid);
return calculateAllocations(socket, jobData);
} catch (error) {
CreateRRLogEvent(socket, "ERROR", "Error encountered in rr-calculate-allocations.", {
message: error?.message || String(error),
stack: error?.stack
});
return null;
}
};