/** * 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; } };