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

924 lines
29 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 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)
}));
/**
* Internal per-center bucket shape for *sales*.
* We keep separate buckets for RR so we can split
* taxable vs non-taxable labor lines later.
*/
function emptyCenterBucket() {
const zero = Dinero();
return {
partsSale: zero, // parts sale
laborTaxableSale: zero, // labor that should be taxed in RR
laborNonTaxableSale: zero, // labor that should NOT be taxed in RR
extrasSale: zero // MAPA/MASH/towing/storage/PAO/etc
};
}
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
}
})
});
}
/**
* Decide if a labor line is taxable vs non-taxable for RR.
*/
function isLaborTaxable(line) {
return line.tax_part;
}
/**
* 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) {
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);
}
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 laborAmount = Dinero({
amount: Math.round(rate * 100)
}).multiply(val.mod_lb_hrs);
if (isLaborTaxable(val)) {
bucket.laborTaxableSale = bucket.laborTaxableSale.add(laborAmount);
} else {
bucket.laborNonTaxableSale = bucket.laborNonTaxableSale.add(laborAmount);
}
}
return acc;
}, {});
debugLog("profitCenterHash after joblines", {
hasMapaLine,
hasMashLine,
centers: Object.entries(profitCenterHash).map(([center, b]) => ({
center,
parts: summarizeMoney(b.partsSale),
laborTaxable: summarizeMoney(b.laborTaxableSale),
laborNonTaxable: summarizeMoney(b.laborNonTaxableSale),
extras: summarizeMoney(b.extrasSale)
}))
});
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
}) {
// MAPA
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) {
debugLog("Adding MAPA Line Manually", {
mapaAccountName,
amount: summarizeMoney(Dinero(job.job_totals.rates.mapa.total))
});
const bucket = ensureCenterBucket(profitCenterHash, mapaAccountName);
bucket.extrasSale = bucket.extrasSale.add(Dinero(job.job_totals.rates.mapa.total));
} else {
debugLog("NO MAPA ACCOUNT FOUND!!", { mapaAccountName });
}
}
// MASH
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) {
debugLog("Adding MASH Line Manually", {
mashAccountName,
amount: summarizeMoney(Dinero(job.job_totals.rates.mash.total))
});
const bucket = ensureCenterBucket(profitCenterHash, mashAccountName);
bucket.extrasSale = bucket.extrasSale.add(Dinero(job.job_totals.rates.mash.total));
} 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.
*/
function applyExtras({ job, bodyshop, selectedDmsAllocationConfig, profitCenterHash, taxAllocations, debugLog }) {
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) {
debugLog("Adding towing_payable to TOW account", {
towAccountName,
towing_payable: job.towing_payable
});
const bucket = ensureCenterBucket(profitCenterHash, towAccountName);
bucket.extrasSale = bucket.extrasSale.add(
Dinero({
amount: Math.round((job.towing_payable || 0) * 100)
})
);
} 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) {
debugLog("Adding storage_payable to TOW account", {
storageAccountName,
storage_payable: job.storage_payable
});
const bucket = ensureCenterBucket(profitCenterHash, storageAccountName);
bucket.extrasSale = bucket.extrasSale.add(
Dinero({
amount: Math.round((job.storage_payable || 0) * 100)
})
);
} 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) {
debugLog("Adding adjustment_bottom_line to PAO", {
otherAccountName,
adjustment_bottom_line: job.adjustment_bottom_line
});
const bucket = ensureCenterBucket(profitCenterHash, otherAccountName);
bucket.extrasSale = bucket.extrasSale.add(
Dinero({
amount: Math.round((job.adjustment_bottom_line || 0) * 100)
})
);
} else {
debugLog("NO PAO ACCOUNT FOUND!!", { otherAccountName });
}
}
return { profitCenterHash, taxAllocations };
}
/**
* Apply Rome-specific profile adjustments (parts + rates).
* These also feed into the *sales* buckets.
*/
/**
* Apply Rome-specific profile adjustments (parts + rates).
* These also feed into the *sales* buckets.
*/
/**
* Apply Rome-specific profile adjustments (parts + rates).
* These also feed into the *sales* buckets.
*/
function applyRomeProfileAdjustments({
job,
bodyshop,
selectedDmsAllocationConfig,
profitCenterHash,
debugLog,
connectionData
}) {
// 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)
});
// 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]);
bucket.extrasSale = bucket.extrasSale.add(adjMoney);
debugLog("Added parts adjustment", {
key,
accountName,
adjustment: summarizeMoney(adjMoney)
});
} 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);
bucket.extrasSale = bucket.extrasSale.add(adjMoney);
debugLog("Added rate adjustment", {
key,
accountName,
adjustment: summarizeMoney(adjMoney)
});
} 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,
* laborTaxableSale,
* laborNonTaxableSale,
* extrasSale,
* 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 totalSale = bucket.partsSale
.add(bucket.laborTaxableSale)
.add(bucket.laborNonTaxableSale)
.add(bucket.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,
partsSale: bucket.partsSale,
laborTaxableSale: bucket.laborTaxableSale,
laborNonTaxableSale: bucket.laborNonTaxableSale,
extrasSale: bucket.extrasSale,
totalSale,
cost: costCenterHash[center] || Dinero(),
profitCenter,
costCenter
};
});
debugLog(
"jobAllocations built",
jobAllocations.map((row) => ({
center: row.center,
parts: summarizeMoney(row.partsSale),
laborTaxable: summarizeMoney(row.laborTaxableSale),
laborNonTaxable: summarizeMoney(row.laborNonTaxableSale),
extras: summarizeMoney(row.extrasSale),
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
});
// 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);
// 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
});
// 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
}));
// 8) Rome-only profile-level adjustments
profitCenterHash = applyRomeProfileAdjustments({
job,
bodyshop,
selectedDmsAllocationConfig,
profitCenterHash,
debugLog,
connectionData
});
debugLog("profitCenterHash before jobAllocations build", {
centers: Object.entries(profitCenterHash || {}).map(([center, b]) => ({
center,
parts: summarizeMoney(b.partsSale),
laborTaxable: summarizeMoney(b.laborTaxableSale),
laborNonTaxable: summarizeMoney(b.laborNonTaxableSale),
extras: summarizeMoney(b.extrasSale)
}))
});
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;
}
};