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

382 lines
15 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.
// server/rr/rr-calculate-allocations.js
const { GraphQLClient } = require("graphql-request");
const queries = require("../graphql-client/queries");
const RRLogger = require("./rr-logger");
const Dinero = require("dinero.js");
const _ = require("lodash");
const WsLogger = require("../web-sockets/createLogEvent").default || require("../web-sockets/createLogEvent");
// InstanceManager wiring (same as CDK file)
const InstanceManager = require("../utils/instanceMgr").default;
const { DiscountNotAlreadyCounted } = InstanceManager({
imex: require("../job/job-totals"),
rome: require("../job/job-totals-USA")
});
/**
* HTTP route version (parity with CDK file)
*/
exports.defaultRoute = async function rrAllocationsHttp(req, res) {
try {
WsLogger.createLogEvent(req, "DEBUG", `RR: calculate allocations request for ${req.body.jobid}`);
const jobData = await queryJobData(req, req.BearerToken, req.body.jobid);
return res.status(200).json({ data: calculateAllocations(req, jobData) });
} catch (error) {
WsLogger.createLogEvent(req, "ERROR", `RR CalculateAllocations error. ${error}`);
res.status(500).json({ error: `RR CalculateAllocations error. ${error}` });
}
};
/**
* Socket version (parity with CDK file)
* @param {Socket} socket
* @param {string} jobid
* @returns {Promise<Array>} allocations
*/
exports.default = async function rrCalculateAllocations(socket, jobid) {
try {
const token = "Bearer " + socket.handshake.auth.token;
const jobData = await queryJobData(socket, token, jobid, /* isFortellis */ false);
return calculateAllocations(socket, jobData);
} catch (error) {
RRLogger(socket, "ERROR", `RR CalculateAllocations error. ${error}`);
return [];
}
};
async function queryJobData(connectionData, token, jobid /* , isFortellis */) {
WsLogger.createLogEvent(connectionData, "DEBUG", `RR: querying job data for id ${jobid}`);
const client = new GraphQLClient(process.env.GRAPHQL_ENDPOINT, {});
const result = await client.setHeaders({ Authorization: token }).request(queries.GET_CDK_ALLOCATIONS, { id: jobid });
WsLogger.createLogEvent(connectionData, "DEBUG", `RR: job data query result ${JSON.stringify(result, null, 2)}`);
return result.jobs_by_pk;
}
/**
* Core allocation logic mirrors CDK version, but logs as RR
*/
function calculateAllocations(connectionData, job) {
const { bodyshop } = job;
// Build tax allocation maps for US (Rome) and IMEX (Canada) contexts
const taxAllocations = 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`]
}
})
});
// Detect existing MAPA/MASH (Mitchell) lines so we dont double count
let hasMapaLine = false;
let hasMashLine = false;
const profitCenterHash = job.joblines.reduce((acc, val) => {
if (val.db_ref === "936008") hasMapaLine = true; // paint materials (MAPA)
if (val.db_ref === "936007") hasMashLine = true; // shop supplies (MASH)
if (val.profitcenter_part) {
if (!acc[val.profitcenter_part]) acc[val.profitcenter_part] = Dinero();
let dineroAmount = Dinero({ amount: Math.round(val.act_price * 100) }).multiply(val.part_qty || 1);
// Conditional discount add-on if not already counted elsewhere
dineroAmount = dineroAmount.add(
((val.prt_dsmk_m && val.prt_dsmk_m !== 0) || (val.prt_dsmk_p && val.prt_dsmk_p !== 0)) &&
DiscountNotAlreadyCounted(val, job.joblines)
? 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)
: Dinero()
);
acc[val.profitcenter_part] = acc[val.profitcenter_part].add(dineroAmount);
}
if (val.profitcenter_labor && val.mod_lbr_ty) {
if (!acc[val.profitcenter_labor]) acc[val.profitcenter_labor] = Dinero();
acc[val.profitcenter_labor] = acc[val.profitcenter_labor].add(
Dinero({ amount: Math.round(job[`rate_${val.mod_lbr_ty.toLowerCase()}`] * 100) }).multiply(val.mod_lb_hrs)
);
}
return acc;
}, {});
const selectedDmsAllocationConfig = bodyshop.md_responsibility_centers.dms_defaults.find(
(d) => d.name === job.dms_allocation
);
WsLogger.createLogEvent(
connectionData,
"DEBUG",
`RR: Using DMS Allocation ${selectedDmsAllocationConfig && selectedDmsAllocationConfig.name} for cost export.`
);
// Build cost center totals from bills and time tickets
let costCenterHash = {};
const disableBillWip = !!bodyshop?.pbs_configuration?.disablebillwip;
if (!disableBillWip) {
costCenterHash = job.bills.reduce((billAcc, bill) => {
bill.billlines.forEach((line) => {
const target = selectedDmsAllocationConfig.costs[line.cost_center];
if (!billAcc[target]) billAcc[target] = Dinero();
let lineDinero = Dinero({ amount: Math.round((line.actual_cost || 0) * 100) })
.multiply(line.quantity)
.multiply(bill.is_credit_memo ? -1 : 1);
billAcc[target] = billAcc[target].add(lineDinero);
});
return billAcc;
}, {});
}
job.timetickets.forEach((ticket) => {
const ticketTotal = Dinero({
amount: Math.round(
ticket.rate *
(ticket.employee && ticket.employee.flat_rate ? ticket.productivehrs || 0 : ticket.actualhrs || 0) *
100
)
});
const target = selectedDmsAllocationConfig.costs[ticket.ciecacode];
if (!costCenterHash[target]) costCenterHash[target] = Dinero();
costCenterHash[target] = costCenterHash[target].add(ticketTotal);
});
// Add MAPA/MASH lines when not explicitly present
if (!hasMapaLine && job.job_totals.rates.mapa.total.amount > 0) {
const accountName = selectedDmsAllocationConfig.profits.MAPA;
const account = bodyshop.md_responsibility_centers.profits.find((c) => c.name === accountName);
if (account) {
if (!profitCenterHash[accountName]) profitCenterHash[accountName] = Dinero();
profitCenterHash[accountName] = profitCenterHash[accountName].add(Dinero(job.job_totals.rates.mapa.total));
}
}
if (!hasMashLine && job.job_totals.rates.mash.total.amount > 0) {
const accountName = selectedDmsAllocationConfig.profits.MASH;
const account = bodyshop.md_responsibility_centers.profits.find((c) => c.name === accountName);
if (account) {
if (!profitCenterHash[accountName]) profitCenterHash[accountName] = Dinero();
profitCenterHash[accountName] = profitCenterHash[accountName].add(Dinero(job.job_totals.rates.mash.total));
}
}
// Optional materials costing (CDK setting reused by RR sites if configured)
if (bodyshop?.cdk_configuration?.sendmaterialscosting) {
const percent = bodyshop.cdk_configuration.sendmaterialscosting;
// Paint Mat
const mapaCostName = selectedDmsAllocationConfig.costs.MAPA;
const mapaCost = bodyshop.md_responsibility_centers.costs.find((c) => c.name === mapaCostName);
if (mapaCost) {
if (!costCenterHash[mapaCostName]) costCenterHash[mapaCostName] = Dinero();
if (job.bodyshop.use_paint_scale_data === true && job.mixdata.length > 0) {
costCenterHash[mapaCostName] = costCenterHash[mapaCostName].add(
Dinero({ amount: Math.round(((job.mixdata[0] && job.mixdata[0].totalliquidcost) || 0) * 100) })
);
} else {
costCenterHash[mapaCostName] = costCenterHash[mapaCostName].add(
Dinero(job.job_totals.rates.mapa.total).percentage(percent)
);
}
}
// Shop Mat
const mashCostName = selectedDmsAllocationConfig.costs.MASH;
const mashCost = bodyshop.md_responsibility_centers.costs.find((c) => c.name === mashCostName);
if (mashCost) {
if (!costCenterHash[mashCostName]) costCenterHash[mashCostName] = Dinero();
costCenterHash[mashCostName] = costCenterHash[mashCostName].add(
Dinero(job.job_totals.rates.mash.total).percentage(percent)
);
}
}
// Provinical PVRT roll-in (Canada only)
const { ca_bc_pvrt } = job;
if (ca_bc_pvrt) {
taxAllocations.state.sale = taxAllocations.state.sale.add(Dinero({ amount: Math.round((ca_bc_pvrt || 0) * 100) }));
}
// Towing / Storage / Other adjustments
if (job.towing_payable && job.towing_payable !== 0) {
const name = selectedDmsAllocationConfig.profits.TOW;
const acct = bodyshop.md_responsibility_centers.profits.find((c) => c.name === name);
if (acct) {
if (!profitCenterHash[name]) profitCenterHash[name] = Dinero();
profitCenterHash[name] = profitCenterHash[name].add(
Dinero({ amount: Math.round((job.towing_payable || 0) * 100) })
);
}
}
if (job.storage_payable && job.storage_payable !== 0) {
const name = selectedDmsAllocationConfig.profits.TOW;
const acct = bodyshop.md_responsibility_centers.profits.find((c) => c.name === name);
if (acct) {
if (!profitCenterHash[name]) profitCenterHash[name] = Dinero();
profitCenterHash[name] = profitCenterHash[name].add(
Dinero({ amount: Math.round((job.storage_payable || 0) * 100) })
);
}
}
if (job.adjustment_bottom_line && job.adjustment_bottom_line !== 0) {
const name = selectedDmsAllocationConfig.profits.PAO;
const acct = bodyshop.md_responsibility_centers.profits.find((c) => c.name === name);
if (acct) {
if (!profitCenterHash[name]) profitCenterHash[name] = Dinero();
profitCenterHash[name] = profitCenterHash[name].add(
Dinero({ amount: Math.round((job.adjustment_bottom_line || 0) * 100) })
);
}
}
// Rome profile-level adjustments for parts / labor / materials
if (InstanceManager({ rome: true })) {
Object.keys(job.job_totals.parts.adjustments).forEach((key) => {
const name = selectedDmsAllocationConfig.profits[key];
const acct = bodyshop.md_responsibility_centers.profits.find((c) => c.name === name);
if (acct) {
if (!profitCenterHash[name]) profitCenterHash[name] = Dinero();
profitCenterHash[name] = profitCenterHash[name].add(Dinero(job.job_totals.parts.adjustments[key]));
} else {
WsLogger.createLogEvent(connectionData, "ERROR", `RR CalculateAllocations: missing parts adj account: ${name}`);
}
});
Object.keys(job.job_totals.rates).forEach((key) => {
const rate = job.job_totals.rates[key];
if (rate && rate.adjustment && Dinero(rate.adjustment).isZero() === false) {
const name = selectedDmsAllocationConfig.profits[key.toUpperCase()];
const acct = bodyshop.md_responsibility_centers.profits.find((c) => c.name === name);
if (acct) {
if (!profitCenterHash[name]) profitCenterHash[name] = Dinero();
// NOTE: the original code had rate.adjustments (plural). If thats a bug upstream, fix there.
profitCenterHash[name] = profitCenterHash[name].add(Dinero(rate.adjustments || rate.adjustment));
} else {
WsLogger.createLogEvent(
connectionData,
"ERROR",
`RR CalculateAllocations: missing rate adj account: ${name}`
);
}
}
});
}
// Merge profit & cost centers
const jobAllocations = _.union(Object.keys(profitCenterHash), Object.keys(costCenterHash)).map((key) => {
const profitCenter = bodyshop.md_responsibility_centers.profits.find((c) => c.name === key);
const costCenter = bodyshop.md_responsibility_centers.costs.find((c) => c.name === key);
return {
center: key,
sale: profitCenterHash[key] ? profitCenterHash[key] : Dinero(),
cost: costCenterHash[key] ? costCenterHash[key] : Dinero(),
profitCenter,
costCenter
};
});
// Add tax centers (non-zero only)
const taxRows = Object.keys(taxAllocations)
.filter((k) => taxAllocations[k].sale.getAmount() > 0 || taxAllocations[k].cost.getAmount() > 0)
.map((k) => {
const base = { ...taxAllocations[k], tax: k };
// Optional GST override preserved from CDK logic
const override = selectedDmsAllocationConfig.gst_override;
if (k === "federal" && override) {
base.costCenter.dms_acctnumber = override;
base.profitCenter.dms_acctnumber = override;
}
return base;
});
// Totals adjustments centers
const extra = [];
if (job.job_totals.totals.ttl_adjustment) {
extra.push({
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: {}
});
}
if (job.job_totals.totals.ttl_tax_adjustment) {
extra.push({
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: {}
});
}
return [...jobAllocations, ...taxRows, ...extra];
}