930 lines
38 KiB
JavaScript
930 lines
38 KiB
JavaScript
const _ = require("lodash");
|
|
const Dinero = require("dinero.js");
|
|
const queries = require("../graphql-client/queries");
|
|
const logger = require("../utils/logger");
|
|
const InstanceManager = require("../utils/instanceMgr").default;
|
|
const { DiscountNotAlreadyCounted } = InstanceManager({
|
|
imex: require("../job/job-totals"),
|
|
rome: require("../job/job-totals-USA")
|
|
});
|
|
|
|
// Dinero.defaultCurrency = "USD";
|
|
// Dinero.globalLocale = "en-CA";
|
|
Dinero.globalRoundingMode = "HALF_EVEN";
|
|
|
|
async function JobCosting(req, res) {
|
|
const { jobid } = req.body;
|
|
|
|
const BearerToken = req.BearerToken;
|
|
const client = req.userGraphQLClient;
|
|
|
|
//Uncomment for further testing
|
|
// logger.log("job-costing-start", "DEBUG", req.user.email, jobid, null);
|
|
|
|
try {
|
|
const resp = await client.setHeaders({ Authorization: BearerToken }).request(queries.QUERY_JOB_COSTING_DETAILS, {
|
|
id: jobid
|
|
});
|
|
|
|
const ret = GenerateCostingData(resp.jobs_by_pk);
|
|
|
|
res.status(200).json(ret);
|
|
} catch (error) {
|
|
logger.log("job-costing-error", "ERROR", req.user.email, jobid, {
|
|
message: error.message,
|
|
stack: error.stack
|
|
});
|
|
|
|
res.status(400).send(JSON.stringify(error));
|
|
}
|
|
}
|
|
|
|
async function JobCostingMulti(req, res) {
|
|
const { jobids } = req.body;
|
|
|
|
const logger = req.logger;
|
|
const BearerToken = req.BearerToken;
|
|
const client = req.userGraphQLClient;
|
|
|
|
//Uncomment for further testing
|
|
// logger.log("job-costing-multi-start", "DEBUG", req?.user?.email, null, {
|
|
// jobids
|
|
// });
|
|
|
|
try {
|
|
const resp = await client
|
|
.setHeaders({ Authorization: BearerToken })
|
|
.request(queries.QUERY_JOB_COSTING_DETAILS_MULTI, {
|
|
ids: jobids
|
|
});
|
|
|
|
const multiSummary = {
|
|
costCenterData: [],
|
|
summaryData: {
|
|
totalLaborSales: Dinero({ amount: 0 }),
|
|
totalPartsSales: Dinero({ amount: 0 }),
|
|
totalAdditionalSales: Dinero({ amount: 0 }),
|
|
totalSubletSales: Dinero({ amount: 0 }),
|
|
totalSales: Dinero({ amount: 0 }),
|
|
totalLaborCost: Dinero({ amount: 0 }),
|
|
totalPartsCost: Dinero({ amount: 0 }),
|
|
totalAdditionalCost: Dinero({ amount: 0 }),
|
|
totalSubletCost: Dinero({ amount: 0 }),
|
|
totalCost: Dinero({ amount: 0 }),
|
|
gpdollars: Dinero({ amount: 0 }),
|
|
gppercent: null,
|
|
gppercentFormatted: null,
|
|
totalLaborGp: Dinero({ amount: 0 }),
|
|
totalPartsGp: Dinero({ amount: 0 }),
|
|
totalAdditionalGp: Dinero({ amount: 0 }),
|
|
totalSubletGp: Dinero({ amount: 0 }),
|
|
totalLaborGppercent: null,
|
|
totalLaborGppercentFormatted: null,
|
|
totalPartsGppercent: null,
|
|
totalPartsGppercentFormatted: null,
|
|
totalAdditionalGppercent: null,
|
|
totalAdditionalGppercentFormatted: null,
|
|
totalSubletGppercent: null,
|
|
totalSubletGppercentFormatted: null
|
|
}
|
|
};
|
|
|
|
const ret = {};
|
|
resp.jobs.map((job) => {
|
|
const costingData = GenerateCostingData(job);
|
|
ret[job.id] = costingData;
|
|
|
|
//Merge on a cost center basis.
|
|
|
|
costingData.costCenterData.forEach((c) => {
|
|
//Find the Cost Center if it exists.
|
|
|
|
const CostCenterIndex = multiSummary.costCenterData.findIndex((x) => x.cost_center === c.cost_center);
|
|
|
|
if (CostCenterIndex >= 0) {
|
|
//Add it in place
|
|
multiSummary.costCenterData[CostCenterIndex] = {
|
|
...multiSummary.costCenterData[CostCenterIndex],
|
|
sale_labor_dinero: multiSummary.costCenterData[CostCenterIndex].sale_labor_dinero.add(c.sale_labor_dinero),
|
|
sale_parts_dinero: multiSummary.costCenterData[CostCenterIndex].sale_parts_dinero.add(c.sale_parts_dinero),
|
|
sale_additional_dinero: multiSummary.costCenterData[CostCenterIndex].sale_additional_dinero.add(
|
|
c.sale_additional_dinero
|
|
),
|
|
sale_sublet_dinero: multiSummary.costCenterData[CostCenterIndex].sale_sublet_dinero.add(
|
|
c.sale_sublet_dinero
|
|
),
|
|
cost_labor_dinero: multiSummary.costCenterData[CostCenterIndex].cost_labor_dinero.add(c.cost_labor_dinero),
|
|
cost_parts_dinero: multiSummary.costCenterData[CostCenterIndex].cost_parts_dinero.add(c.cost_parts_dinero),
|
|
cost_additional_dinero: multiSummary.costCenterData[CostCenterIndex].cost_additional_dinero.add(
|
|
c.cost_additional_dinero
|
|
),
|
|
cost_sublet_dinero: multiSummary.costCenterData[CostCenterIndex].cost_sublet_dinero.add(
|
|
c.cost_sublet_dinero
|
|
),
|
|
gpdollars_dinero: multiSummary.costCenterData[CostCenterIndex].gpdollars_dinero.add(c.gpdollars_dinero),
|
|
costs_dinero: multiSummary.costCenterData[CostCenterIndex].costs_dinero.add(c.costs_dinero),
|
|
sales_dinero: multiSummary.costCenterData[CostCenterIndex].sales_dinero.add(c.sales_dinero)
|
|
};
|
|
} else {
|
|
//Add it to the list instead.
|
|
multiSummary.costCenterData.push(c);
|
|
}
|
|
});
|
|
|
|
//Add all summary data.
|
|
multiSummary.summaryData.totalPartsSales = multiSummary.summaryData.totalPartsSales.add(
|
|
costingData.summaryData.totalPartsSales
|
|
);
|
|
multiSummary.summaryData.totalAdditionalSales = multiSummary.summaryData.totalAdditionalSales.add(
|
|
costingData.summaryData.totalAdditionalSales
|
|
);
|
|
multiSummary.summaryData.totalSubletSales = multiSummary.summaryData.totalSubletSales.add(
|
|
costingData.summaryData.totalSubletSales
|
|
);
|
|
multiSummary.summaryData.totalSales = multiSummary.summaryData.totalSales.add(costingData.summaryData.totalSales);
|
|
multiSummary.summaryData.totalLaborCost = multiSummary.summaryData.totalLaborCost.add(
|
|
costingData.summaryData.totalLaborCost
|
|
);
|
|
multiSummary.summaryData.totalLaborSales = multiSummary.summaryData.totalLaborSales.add(
|
|
costingData.summaryData.totalLaborSales
|
|
);
|
|
multiSummary.summaryData.totalPartsCost = multiSummary.summaryData.totalPartsCost.add(
|
|
costingData.summaryData.totalPartsCost
|
|
);
|
|
multiSummary.summaryData.totalAdditionalCost = multiSummary.summaryData.totalAdditionalCost.add(
|
|
costingData.summaryData.totalAdditionalCost
|
|
);
|
|
multiSummary.summaryData.totalSubletCost = multiSummary.summaryData.totalSubletCost.add(
|
|
costingData.summaryData.totalSubletCost
|
|
);
|
|
multiSummary.summaryData.totalCost = multiSummary.summaryData.totalCost.add(costingData.summaryData.totalCost);
|
|
multiSummary.summaryData.gpdollars = multiSummary.summaryData.gpdollars.add(costingData.summaryData.gpdollars);
|
|
|
|
multiSummary.summaryData.totalLaborGp = multiSummary.summaryData.totalLaborGp.add(
|
|
costingData.summaryData.totalLaborGp
|
|
);
|
|
multiSummary.summaryData.totalPartsGp = multiSummary.summaryData.totalPartsGp.add(
|
|
costingData.summaryData.totalPartsGp
|
|
);
|
|
multiSummary.summaryData.totalAdditionalGp = multiSummary.summaryData.totalAdditionalGp.add(
|
|
costingData.summaryData.totalAdditionalGp
|
|
);
|
|
multiSummary.summaryData.totalSubletGp = multiSummary.summaryData.totalSubletGp.add(
|
|
costingData.summaryData.totalSubletGp
|
|
);
|
|
|
|
//Take the summary data & add it to total summary data.
|
|
});
|
|
|
|
//For each center, recalculate and toFormat() the values.
|
|
|
|
multiSummary.summaryData.totalLaborGppercent = (
|
|
(multiSummary.summaryData.totalLaborGp.getAmount() / multiSummary.summaryData.totalLaborSales.getAmount()) *
|
|
100
|
|
).toFixed(1);
|
|
multiSummary.summaryData.totalLaborGppercentFormatted = formatGpPercent(
|
|
multiSummary.summaryData.totalLaborGppercent
|
|
);
|
|
|
|
multiSummary.summaryData.totalPartsGppercent = (
|
|
(multiSummary.summaryData.totalPartsGp.getAmount() / multiSummary.summaryData.totalPartsSales.getAmount()) *
|
|
100
|
|
).toFixed(1);
|
|
|
|
multiSummary.summaryData.totalPartsGppercentFormatted = formatGpPercent(
|
|
multiSummary.summaryData.totalPartsGppercent
|
|
);
|
|
|
|
multiSummary.summaryData.totalAdditionalGppercent = (
|
|
(multiSummary.summaryData.totalAdditionalGp.getAmount() /
|
|
multiSummary.summaryData.totalAdditionalSales.getAmount()) *
|
|
100
|
|
).toFixed(1);
|
|
|
|
multiSummary.summaryData.totalAdditionalGppercentFormatted = formatGpPercent(
|
|
multiSummary.summaryData.totalAdditionalGppercent
|
|
);
|
|
|
|
multiSummary.summaryData.totalSubletGppercent = (
|
|
(multiSummary.summaryData.totalSubletGp.getAmount() / multiSummary.summaryData.totalSubletSales.getAmount()) *
|
|
100
|
|
).toFixed(1);
|
|
|
|
multiSummary.summaryData.totalSubletGppercentFormatted = formatGpPercent(
|
|
multiSummary.summaryData.totalSubletGppercent
|
|
);
|
|
|
|
multiSummary.summaryData.gppercent = (
|
|
(multiSummary.summaryData.gpdollars.getAmount() / multiSummary.summaryData.totalSales.getAmount()) *
|
|
100
|
|
).toFixed(1);
|
|
|
|
multiSummary.summaryData.gppercentFormatted = formatGpPercent(multiSummary.summaryData.gppercent);
|
|
|
|
const finalCostingdata = multiSummary.costCenterData.map((c) => {
|
|
return {
|
|
...c,
|
|
sale_labor: c.sale_labor_dinero && c.sale_labor_dinero.toFormat(),
|
|
sale_parts: c.sale_parts_dinero && c.sale_parts_dinero.toFormat(),
|
|
sale_additional: c.sale_additional_dinero && c.sale_additional_dinero.toFormat(),
|
|
sale_sublet: c.sale_sublet_dinero && c.sale_sublet_dinero.toFormat(),
|
|
sales: c.sales_dinero.toFormat(),
|
|
cost_parts: c.cost_parts_dinero && c.cost_parts_dinero.toFormat(),
|
|
cost_labor: c.cost_labor_dinero && c.cost_labor_dinero.toFormat(),
|
|
cost_additional: c.cost_additional_dinero && c.cost_additional_dinero.toFormat(),
|
|
cost_sublet: c.cost_sublet_dinero && c.cost_sublet_dinero.toFormat(),
|
|
costs: c.costs_dinero.toFormat(),
|
|
gpdollars: c.gpdollars_dinero.toFormat(),
|
|
gppercent: formatGpPercent(((c.gpdollars_dinero.getAmount() / c.sales_dinero.getAmount()) * 100).toFixed(1))
|
|
};
|
|
});
|
|
|
|
//Calculate thte total gross profit percentages.
|
|
|
|
res.status(200).json({
|
|
allCostCenterData: finalCostingdata,
|
|
allSummaryData: multiSummary.summaryData,
|
|
data: ret
|
|
});
|
|
} catch (error) {
|
|
logger.log("job-costing-multi-error", "ERROR", req?.user?.email, null, {
|
|
jobids,
|
|
message: error.message,
|
|
stack: error.stack
|
|
});
|
|
res.status(400).send(error);
|
|
}
|
|
}
|
|
|
|
function GenerateCostingData(job) {
|
|
const defaultProfits = job.bodyshop.md_responsibility_centers.defaults.profits;
|
|
const allCenters = _.union(
|
|
job.bodyshop.md_responsibility_centers.profits.map((p) => p.name),
|
|
job.bodyshop.md_responsibility_centers.costs.map((p) => p.name),
|
|
["Unknown"]
|
|
);
|
|
|
|
const materialsHours = { mapaHrs: 0, mashHrs: 0 };
|
|
let hasMapaLine = false;
|
|
let hasMashLine = false;
|
|
|
|
//Massage the data.
|
|
const jobLineTotalsByProfitCenter =
|
|
job &&
|
|
job.joblines.reduce(
|
|
(acc, val) => {
|
|
//Shop or Paint Material Flags
|
|
if (val.db_ref === "936008") {
|
|
//If either of these DB REFs change, they also need to change in job-totals/job-costing calculations.
|
|
hasMapaLine = true;
|
|
}
|
|
if (val.db_ref === "936007") {
|
|
hasMashLine = true;
|
|
}
|
|
|
|
//Labor Profit Center
|
|
if (val.mod_lbr_ty) {
|
|
const laborProfitCenter = val.profitcenter_labor || defaultProfits[val.mod_lbr_ty] || "Unknown";
|
|
|
|
//Uncomment for further testing
|
|
// if (laborProfitCenter === "Unknown") {
|
|
// logger.log("job-costing unknown type", "debug", null, null, {
|
|
// line_desc: val.line_desc,
|
|
// mod_lbr_ty: val.mod_lbr_ty
|
|
// });
|
|
// }
|
|
|
|
const rateName = `rate_${(val.mod_lbr_ty || "").toLowerCase()}`;
|
|
|
|
let laborAmount = Dinero();
|
|
laborAmount = Dinero({
|
|
amount: Math.round((job[rateName] || 0) * 100)
|
|
}).multiply(val.mod_lb_hrs || 0);
|
|
|
|
if (
|
|
job.cieca_pfl &&
|
|
job.cieca_pfl[val.mod_lbr_ty.toUpperCase()] &&
|
|
job.cieca_pfl[val.mod_lbr_ty.toUpperCase()].lbr_adjp !== 0
|
|
) {
|
|
let adjp = 0;
|
|
if (
|
|
val.mod_lbr_ty === "la1" ||
|
|
val.mod_lbr_ty === "la2" ||
|
|
val.mod_lbr_ty === "la3" ||
|
|
val.mod_lbr_ty === "la4"
|
|
) {
|
|
adjp =
|
|
Math.abs(job.cieca_pfl["LAU"].lbr_adjp) > 1
|
|
? job.cieca_pfl["LAU"].lbr_adjp
|
|
: job.cieca_pfl["LAU"].lbr_adjp * 100; //Adjust lbr_adjp to whole number
|
|
} else {
|
|
if (job.cieca_pfl[val.mod_lbr_ty.toUpperCase()]) {
|
|
adjp =
|
|
Math.abs(job.cieca_pfl[val.mod_lbr_ty.toUpperCase()].lbr_adjp) > 1
|
|
? job.cieca_pfl[val.mod_lbr_ty.toUpperCase()].lbr_adjp
|
|
: job.cieca_pfl[val.mod_lbr_ty.toUpperCase()].lbr_adjp * 100; //Adjust lbr_adjp to whole number
|
|
} else {
|
|
adjp =
|
|
Math.abs(job.cieca_pfl["LAB"].lbr_adjp) > 1
|
|
? job.cieca_pfl["LAB"].lbr_adjp
|
|
: job.cieca_pfl["LAB"].lbr_adjp * 100; //Adjust lbr_adjp to whole number
|
|
}
|
|
}
|
|
laborAmount = laborAmount.add(
|
|
laborAmount.percentage(adjp < 0 ? adjp * -1 : adjp).multiply(adjp < 0 ? -1 : 1)
|
|
);
|
|
}
|
|
|
|
if (!acc.labor[laborProfitCenter]) acc.labor[laborProfitCenter] = Dinero();
|
|
acc.labor[laborProfitCenter] = acc.labor[laborProfitCenter].add(laborAmount);
|
|
|
|
if (val.mod_lb_hrs === 0 && val.act_price > 0 && val.lbr_op === "OP14") {
|
|
//Scenario where SGI may pay out hours using a part price.
|
|
acc.labor[laborProfitCenter] = acc.labor[laborProfitCenter].add(
|
|
Dinero({
|
|
amount: Math.round((val.act_price || 0) * 100)
|
|
}).multiply(val.part_qty)
|
|
);
|
|
}
|
|
|
|
if (val.mod_lbr_ty === "LAR") {
|
|
materialsHours.mapaHrs += val.mod_lb_hrs || 0;
|
|
}
|
|
if (val.mod_lbr_ty !== "LAR") {
|
|
materialsHours.mashHrs += val.mod_lb_hrs || 0;
|
|
}
|
|
}
|
|
|
|
// Part Profit Center
|
|
if (val.part_type && val.part_type !== "PAE" && val.part_type !== "PAS" && val.part_type !== "PASL") {
|
|
const partsProfitCenter = val.profitcenter_part || defaultProfits[val.part_type] || "Unknown";
|
|
|
|
//Uncomment for further testing
|
|
// if (partsProfitCenter === "Unknown" || !partsProfitCenter) {
|
|
// logger.log(
|
|
// partsProfitCenter === "Unknown"
|
|
// ? "job-costing unknown type"
|
|
// : "Unknown cost/profit center mapping for parts.",
|
|
// "debug",
|
|
// null,
|
|
// null,
|
|
// {
|
|
// line_desc: val.line_desc,
|
|
// part_type: val.part_type
|
|
// }
|
|
// );
|
|
// }
|
|
|
|
let partsAmount = Dinero({
|
|
amount: val.act_price_before_ppc
|
|
? Math.round(val.act_price_before_ppc * 100)
|
|
: Math.round(val.act_price * 100)
|
|
})
|
|
.multiply(val.part_qty || 1)
|
|
.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: val.act_price_before_ppc
|
|
? Math.round(val.act_price_before_ppc * 100)
|
|
: 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()
|
|
);
|
|
|
|
// Profile Discount for Parts
|
|
if (job.parts_tax_rates && job.parts_tax_rates[val.part_type.toUpperCase()]) {
|
|
if (
|
|
job.parts_tax_rates[val.part_type.toUpperCase()].prt_discp !== undefined &&
|
|
job.parts_tax_rates[val.part_type.toUpperCase()].prt_discp >= 0
|
|
) {
|
|
const discountRate =
|
|
Math.abs(job.parts_tax_rates[val.part_type.toUpperCase()].prt_discp) > 1
|
|
? job.parts_tax_rates_rates[val.part_type.toUpperCase()].prt_discp
|
|
: job.parts_tax_rates[val.part_type.toUpperCase()].prt_discp * 100;
|
|
const disc = partsAmount.percentage(discountRate).multiply(-1);
|
|
partsAmount = partsAmount.add(disc);
|
|
}
|
|
if (
|
|
job.parts_tax_rates[val.part_type.toUpperCase()].prt_mkupp !== undefined &&
|
|
job.parts_tax_rates[val.part_type.toUpperCase()].prt_mkupp >= 0
|
|
) {
|
|
const markupRate =
|
|
Math.abs(job.parts_tax_rates[val.part_type.toUpperCase()].prt_mkupp) > 1
|
|
? job.parts_tax_rates[val.part_type.toUpperCase()].prt_mkupp
|
|
: job.parts_tax_rates[val.part_type.toUpperCase()].prt_mkupp * 100;
|
|
const markup = partsAmount.percentage(markupRate);
|
|
partsAmount = partsAmount.add(markup);
|
|
}
|
|
}
|
|
|
|
if (!acc.parts[partsProfitCenter]) acc.parts[partsProfitCenter] = Dinero();
|
|
acc.parts[partsProfitCenter] = acc.parts[partsProfitCenter].add(partsAmount);
|
|
}
|
|
|
|
//Sublet Profit Center
|
|
if (val.part_type && val.part_type !== "PAE" && (val.part_type === "PAS" || val.part_type === "PASL")) {
|
|
const partsProfitCenter = val.profitcenter_part || defaultProfits[val.part_type] || "Unknown";
|
|
|
|
//Uncomment for further testing
|
|
// if (partsProfitCenter === "Unknown" || !partsProfitCenter) {
|
|
// logger.log(
|
|
// partsProfitCenter === "Unknown"
|
|
// ? "job-costing unknown type"
|
|
// : "job-costing Unknown cost/profit center mapping for sublet",
|
|
// "debug",
|
|
// null,
|
|
// null,
|
|
// {
|
|
// line_desc: val.line_desc,
|
|
// part_type: val.part_type
|
|
// }
|
|
// );
|
|
// }
|
|
|
|
const partsAmount = Dinero({
|
|
amount: Math.round((val.act_price || 0) * 100)
|
|
})
|
|
.multiply(val.part_qty || 1)
|
|
.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()
|
|
);
|
|
if (!acc.sublet[partsProfitCenter]) acc.sublet[partsProfitCenter] = Dinero();
|
|
acc.sublet[partsProfitCenter] = acc.sublet[partsProfitCenter].add(partsAmount);
|
|
}
|
|
|
|
//Additional Profit Center
|
|
if (
|
|
(!val.part_type && !val.mod_lbr_ty) ||
|
|
(!val.part_type && val.mod_lbr_ty && val.act_price > 0 && val.lbr_op !== "OP14")
|
|
) {
|
|
//Does it already have a defined profit center?
|
|
//If so, use it, otherwise try to use the same from the auto-allocate logic in IO app jobs-close-auto-allocate.
|
|
const partsProfitCenter = val.profitcenter_part || getAdditionalCostCenter(val, defaultProfits) || "Unknown";
|
|
|
|
//Uncomment for further testing
|
|
// if (partsProfitCenter === "Unknown") {
|
|
// logger.log("job-costing unknown type", "debug", null, null, {
|
|
// line_desc: val.line_desc,
|
|
// part_type: val.part_type
|
|
// });
|
|
// }
|
|
|
|
const partsAmount = Dinero({
|
|
amount: Math.round((val.act_price || 0) * 100)
|
|
})
|
|
.multiply(val.part_qty || 1)
|
|
.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()
|
|
);
|
|
|
|
if (!acc.additional[partsProfitCenter]) acc.additional[partsProfitCenter] = Dinero();
|
|
acc.additional[partsProfitCenter] = acc.additional[partsProfitCenter].add(partsAmount);
|
|
}
|
|
|
|
return acc;
|
|
},
|
|
{ parts: {}, labor: {}, additional: {}, sublet: {} }
|
|
);
|
|
|
|
if (!hasMapaLine) {
|
|
if (!jobLineTotalsByProfitCenter.additional[defaultProfits["MAPA"]])
|
|
jobLineTotalsByProfitCenter.additional[defaultProfits["MAPA"]] = Dinero();
|
|
|
|
jobLineTotalsByProfitCenter.additional[defaultProfits["MAPA"]] = jobLineTotalsByProfitCenter.additional[
|
|
defaultProfits["MAPA"]
|
|
].add(
|
|
Dinero({
|
|
amount: Math.round((job.rate_mapa || 0) * 100)
|
|
}).multiply(materialsHours.mapaHrs || 0)
|
|
);
|
|
let adjp = 0;
|
|
if (job.materials["MAPA"] && job.materials["MAPA"].mat_adjp) {
|
|
adjp =
|
|
Math.abs(job.materials["MAPA"].mat_adjp) > 1
|
|
? job.materials["MAPA"].mat_adjp
|
|
: job.materials["MAPA"].mat_adjp * 100; //Adjust mat_adjp to whole number
|
|
}
|
|
|
|
jobLineTotalsByProfitCenter.additional[defaultProfits["MAPA"]] = jobLineTotalsByProfitCenter.additional[
|
|
defaultProfits["MAPA"]
|
|
].add(
|
|
jobLineTotalsByProfitCenter.additional[defaultProfits["MAPA"]]
|
|
.percentage(adjp < 0 ? adjp * -1 : adjp)
|
|
.multiply(adjp < 0 ? -1 : 1)
|
|
);
|
|
}
|
|
if (!hasMashLine) {
|
|
if (!jobLineTotalsByProfitCenter.additional[defaultProfits["MASH"]])
|
|
jobLineTotalsByProfitCenter.additional[defaultProfits["MASH"]] = Dinero();
|
|
|
|
jobLineTotalsByProfitCenter.additional[defaultProfits["MASH"]] = jobLineTotalsByProfitCenter.additional[
|
|
defaultProfits["MASH"]
|
|
].add(
|
|
Dinero({
|
|
amount: Math.round((job.rate_mash || 0) * 100)
|
|
}).multiply(materialsHours.mashHrs || 0)
|
|
);
|
|
let adjp = 0;
|
|
if (job.materials["MASH"] && job.materials["MASH"].mat_adjp) {
|
|
adjp =
|
|
Math.abs(job.materials["MASH"].mat_adjp) > 1
|
|
? job.materials["MASH"].mat_adjp
|
|
: job.materials["MASH"].mat_adjp * 100; //Adjust mat_adjp to whole number
|
|
}
|
|
|
|
jobLineTotalsByProfitCenter.additional[defaultProfits["MASH"]] = jobLineTotalsByProfitCenter.additional[
|
|
defaultProfits["MASH"]
|
|
].add(
|
|
jobLineTotalsByProfitCenter.additional[defaultProfits["MASH"]]
|
|
.percentage(adjp < 0 ? adjp * -1 : adjp)
|
|
.multiply(adjp < 0 ? -1 : 1)
|
|
);
|
|
}
|
|
|
|
//Is it a DMS Setup?
|
|
const selectedDmsAllocationConfig =
|
|
(job.bodyshop.md_responsibility_centers.dms_defaults &&
|
|
job.bodyshop.md_responsibility_centers.dms_defaults.find((d) => d.name === job.dms_allocation)) ||
|
|
job.bodyshop.md_responsibility_centers.defaults;
|
|
|
|
const billTotalsByCostCenters = job.bills.reduce(
|
|
(bill_acc, bill_val) => {
|
|
//At the bill level.
|
|
bill_val.billlines.map((line_val) => {
|
|
//At the bill line level.
|
|
if (job.bodyshop.pbs_serialnumber || job.bodyshop.cdk_dealerid) {
|
|
if (!bill_acc[selectedDmsAllocationConfig.costs[line_val.cost_center]])
|
|
bill_acc[selectedDmsAllocationConfig.costs[line_val.cost_center]] = Dinero();
|
|
|
|
bill_acc[selectedDmsAllocationConfig.costs[line_val.cost_center]] = bill_acc[
|
|
selectedDmsAllocationConfig.costs[line_val.cost_center]
|
|
].add(
|
|
Dinero({
|
|
amount: Math.round((line_val.actual_cost || 0) * 100)
|
|
})
|
|
.multiply(line_val.quantity)
|
|
.multiply(bill_val.is_credit_memo ? -1 : 1)
|
|
);
|
|
} else {
|
|
const isSubletCostCenter =
|
|
line_val.cost_center === job.bodyshop.md_responsibility_centers.defaults.costs.PAS ||
|
|
line_val.cost_center === job.bodyshop.md_responsibility_centers.defaults.costs.PASL;
|
|
|
|
const isAdditionalCostCenter =
|
|
// line_val.cost_center ===
|
|
// job.bodyshop.md_responsibility_centers.defaults.costs.PAS ||
|
|
// line_val.cost_center ===
|
|
// job.bodyshop.md_responsibility_centers.defaults.costs.PASL ||
|
|
line_val.cost_center === job.bodyshop.md_responsibility_centers.defaults.costs.TOW ||
|
|
line_val.cost_center === job.bodyshop.md_responsibility_centers.defaults.costs.MAPA ||
|
|
line_val.cost_center === job.bodyshop.md_responsibility_centers.defaults.costs.MASH;
|
|
|
|
if (isAdditionalCostCenter) {
|
|
if (!bill_acc.additionalCosts[line_val.cost_center])
|
|
bill_acc.additionalCosts[line_val.cost_center] = Dinero();
|
|
|
|
bill_acc.additionalCosts[line_val.cost_center] = bill_acc.additionalCosts[line_val.cost_center].add(
|
|
Dinero({
|
|
amount: Math.round((line_val.actual_cost || 0) * 100)
|
|
})
|
|
.multiply(line_val.quantity)
|
|
.multiply(bill_val.is_credit_memo ? -1 : 1)
|
|
);
|
|
} else if (isSubletCostCenter) {
|
|
if (!bill_acc.subletCosts[line_val.cost_center]) bill_acc.subletCosts[line_val.cost_center] = Dinero();
|
|
|
|
bill_acc.subletCosts[line_val.cost_center] = bill_acc.subletCosts[line_val.cost_center].add(
|
|
Dinero({
|
|
amount: Math.round((line_val.actual_cost || 0) * 100)
|
|
})
|
|
.multiply(line_val.quantity)
|
|
.multiply(bill_val.is_credit_memo ? -1 : 1)
|
|
);
|
|
} else {
|
|
if (!bill_acc[line_val.cost_center]) bill_acc[line_val.cost_center] = Dinero();
|
|
|
|
bill_acc[line_val.cost_center] = bill_acc[line_val.cost_center].add(
|
|
Dinero({
|
|
amount: Math.round((line_val.actual_cost || 0) * 100)
|
|
})
|
|
.multiply(line_val.quantity)
|
|
.multiply(bill_val.is_credit_memo ? -1 : 1)
|
|
);
|
|
}
|
|
}
|
|
|
|
return null;
|
|
});
|
|
return bill_acc;
|
|
},
|
|
{ additionalCosts: {}, subletCosts: {} }
|
|
);
|
|
|
|
//If the hourly rates for job costing are set, add them in.
|
|
|
|
if (
|
|
job.bodyshop.jc_hourly_rates &&
|
|
(job.bodyshop.jc_hourly_rates.mapa ||
|
|
typeof job.bodyshop.jc_hourly_rates.mapa === "number" ||
|
|
isNaN(job.bodyshop.jc_hourly_rates.mapa) === false)
|
|
) {
|
|
if (!billTotalsByCostCenters.additionalCosts[job.bodyshop.md_responsibility_centers.defaults.costs.MAPA])
|
|
billTotalsByCostCenters.additionalCosts[job.bodyshop.md_responsibility_centers.defaults.costs.MAPA] = Dinero();
|
|
if (job.bodyshop.use_paint_scale_data === true) {
|
|
if (job.mixdata.length > 0) {
|
|
billTotalsByCostCenters.additionalCosts[job.bodyshop.md_responsibility_centers.defaults.costs.MAPA] = Dinero({
|
|
amount: Math.round(((job.mixdata[0] && job.mixdata[0].totalliquidcost) || 0) * 100)
|
|
});
|
|
} else {
|
|
billTotalsByCostCenters.additionalCosts[job.bodyshop.md_responsibility_centers.defaults.costs.MAPA] =
|
|
billTotalsByCostCenters.additionalCosts[job.bodyshop.md_responsibility_centers.defaults.costs.MAPA].add(
|
|
Dinero({
|
|
amount: Math.round((job.bodyshop.jc_hourly_rates && job.bodyshop.jc_hourly_rates.mapa * 100) || 0)
|
|
}).multiply(materialsHours.mapaHrs)
|
|
);
|
|
}
|
|
} else {
|
|
billTotalsByCostCenters.additionalCosts[job.bodyshop.md_responsibility_centers.defaults.costs.MAPA] =
|
|
billTotalsByCostCenters.additionalCosts[job.bodyshop.md_responsibility_centers.defaults.costs.MAPA].add(
|
|
Dinero({
|
|
amount: Math.round((job.bodyshop.jc_hourly_rates && job.bodyshop.jc_hourly_rates.mapa * 100) || 0)
|
|
}).multiply(materialsHours.mapaHrs)
|
|
);
|
|
}
|
|
}
|
|
|
|
if (job.bodyshop.jc_hourly_rates && job.bodyshop.jc_hourly_rates.mash) {
|
|
if (!billTotalsByCostCenters.additionalCosts[job.bodyshop.md_responsibility_centers.defaults.costs.MASH])
|
|
billTotalsByCostCenters.additionalCosts[job.bodyshop.md_responsibility_centers.defaults.costs.MASH] = Dinero();
|
|
billTotalsByCostCenters.additionalCosts[job.bodyshop.md_responsibility_centers.defaults.costs.MASH] =
|
|
billTotalsByCostCenters.additionalCosts[job.bodyshop.md_responsibility_centers.defaults.costs.MASH].add(
|
|
Dinero({
|
|
amount: Math.round((job.bodyshop.jc_hourly_rates && job.bodyshop.jc_hourly_rates.mash * 100) || 0)
|
|
}).multiply(materialsHours.mashHrs)
|
|
);
|
|
}
|
|
|
|
const ticketTotalsByCostCenter = job.timetickets.reduce((ticket_acc, ticket_val) => {
|
|
//At the invoice level.
|
|
|
|
if (job.bodyshop.pbs_serialnumber || job.bodyshop.cdk_dealerid) {
|
|
if (!ticket_acc[selectedDmsAllocationConfig.costs[ticket_val.ciecacode]])
|
|
ticket_acc[selectedDmsAllocationConfig.costs[ticket_val.ciecacode]] = Dinero();
|
|
|
|
ticket_acc[selectedDmsAllocationConfig.costs[ticket_val.ciecacode]] = ticket_acc[
|
|
selectedDmsAllocationConfig.costs[ticket_val.ciecacode]
|
|
].add(
|
|
Dinero({
|
|
amount: Math.round((ticket_val.rate || 0) * 100)
|
|
}).multiply(
|
|
ticket_val.flat_rate
|
|
? ticket_val.productivehrs || ticket_val.actualhrs || 0
|
|
: ticket_val.actualhrs || ticket_val.productivehrs || 0
|
|
) //Should base this on the employee.
|
|
);
|
|
} else {
|
|
if (!ticket_acc[ticket_val.cost_center]) ticket_acc[ticket_val.cost_center] = Dinero();
|
|
|
|
ticket_acc[ticket_val.cost_center] = ticket_acc[ticket_val.cost_center].add(
|
|
Dinero({
|
|
amount: Math.round((ticket_val.rate || 0) * 100)
|
|
}).multiply(
|
|
ticket_val.flat_rate
|
|
? ticket_val.productivehrs || ticket_val.actualhrs || 0
|
|
: ticket_val.actualhrs || ticket_val.productivehrs || 0
|
|
) //Should base this on the employee.
|
|
);
|
|
}
|
|
|
|
return ticket_acc;
|
|
}, {});
|
|
|
|
const summaryData = {
|
|
totalLaborSales: Dinero({ amount: 0 }),
|
|
totalPartsSales: Dinero({ amount: 0 }),
|
|
totalAdditionalSales: Dinero({ amount: 0 }),
|
|
totalSubletSales: Dinero({ amount: 0 }),
|
|
totalSales: Dinero({ amount: 0 }),
|
|
totalLaborCost: Dinero({ amount: 0 }),
|
|
totalPartsCost: Dinero({ amount: 0 }),
|
|
totalAdditionalCost: Dinero({ amount: 0 }),
|
|
totalSubletCost: Dinero({ amount: 0 }),
|
|
totalCost: Dinero({ amount: 0 }),
|
|
totalLaborGp: Dinero({ amount: 0 }),
|
|
totalPartsGp: Dinero({ amount: 0 }),
|
|
totalAdditionalGp: Dinero({ amount: 0 }),
|
|
totalSubletGp: Dinero({ amount: 0 }),
|
|
gpdollars: Dinero({ amount: 0 }),
|
|
totalLaborGppercent: null,
|
|
totalLaborGppercentFormatted: null,
|
|
totalPartsGppercent: null,
|
|
totalPartsGppercentFormatted: null,
|
|
totalAdditionalGppercent: null,
|
|
totalAdditionalGppercentFormatted: null,
|
|
totalSubletGppercent: null,
|
|
totalSubletGppercentFormatted: null,
|
|
gppercent: null,
|
|
gppercentFormatted: null
|
|
};
|
|
|
|
const costCenterData = allCenters.map((key, idx) => {
|
|
const ccVal = key; // defaultProfits[key];
|
|
const sale_labor = jobLineTotalsByProfitCenter.labor[ccVal] || Dinero({ amount: 0 });
|
|
const sale_parts = jobLineTotalsByProfitCenter.parts[ccVal] || Dinero({ amount: 0 });
|
|
const sale_additional = jobLineTotalsByProfitCenter.additional[ccVal] || Dinero({ amount: 0 });
|
|
const sale_sublet = jobLineTotalsByProfitCenter.sublet[ccVal] || Dinero({ amount: 0 });
|
|
|
|
const cost_labor = ticketTotalsByCostCenter[ccVal] || Dinero({ amount: 0 });
|
|
const cost_parts = billTotalsByCostCenters[ccVal] || Dinero({ amount: 0 });
|
|
const cost_additional = billTotalsByCostCenters.additionalCosts[ccVal] || Dinero({ amount: 0 });
|
|
const cost_sublet = billTotalsByCostCenters.subletCosts[ccVal] || Dinero({ amount: 0 });
|
|
|
|
const costs = cost_labor.add(cost_parts).add(cost_additional).add(cost_sublet);
|
|
const totalSales = sale_labor.add(sale_parts).add(sale_additional).add(sale_sublet);
|
|
const gpdollars = totalSales.subtract(costs);
|
|
const gppercent = ((gpdollars.getAmount() / Math.abs(totalSales.getAmount())) * 100).toFixed(1);
|
|
|
|
//Push summary data to avoid extra loop.
|
|
summaryData.totalLaborSales = summaryData.totalLaborSales.add(sale_labor);
|
|
summaryData.totalPartsSales = summaryData.totalPartsSales.add(sale_parts);
|
|
summaryData.totalAdditionalSales = summaryData.totalAdditionalSales.add(sale_additional);
|
|
summaryData.totalSubletSales = summaryData.totalSubletSales.add(sale_sublet);
|
|
summaryData.totalSales = summaryData.totalSales.add(totalSales);
|
|
summaryData.totalLaborCost = summaryData.totalLaborCost.add(cost_labor);
|
|
summaryData.totalPartsCost = summaryData.totalPartsCost.add(cost_parts);
|
|
summaryData.totalAdditionalCost = summaryData.totalAdditionalCost.add(cost_additional);
|
|
summaryData.totalSubletCost = summaryData.totalSubletCost.add(cost_sublet);
|
|
summaryData.totalCost = summaryData.totalCost.add(costs);
|
|
|
|
return {
|
|
id: idx,
|
|
cost_center: ccVal,
|
|
sale_labor: sale_labor && sale_labor.toFormat(),
|
|
sale_labor_dinero: sale_labor,
|
|
sale_parts: sale_parts && sale_parts.toFormat(),
|
|
sale_parts_dinero: sale_parts,
|
|
sale_additional: sale_additional && sale_additional.toFormat(),
|
|
sale_additional_dinero: sale_additional,
|
|
sale_sublet: sale_sublet && sale_sublet.toFormat(),
|
|
sale_sublet_dinero: sale_sublet,
|
|
sales: totalSales.toFormat(),
|
|
sales_dinero: totalSales,
|
|
cost_parts: cost_parts && cost_parts.toFormat(),
|
|
cost_parts_dinero: cost_parts,
|
|
cost_labor: cost_labor && cost_labor.toFormat(),
|
|
cost_labor_dinero: cost_labor,
|
|
cost_additional: cost_additional && cost_additional.toFormat(),
|
|
cost_additional_dinero: cost_additional,
|
|
cost_sublet: cost_sublet && cost_sublet.toFormat(),
|
|
cost_sublet_dinero: cost_sublet,
|
|
costs: costs.toFormat(),
|
|
costs_dinero: costs,
|
|
gpdollars_dinero: gpdollars,
|
|
gpdollars: gpdollars.toFormat(),
|
|
gppercent: formatGpPercent(gppercent)
|
|
};
|
|
});
|
|
|
|
//Push adjustments to bottom line.
|
|
if (job.adjustment_bottom_line) {
|
|
//Add to totals.
|
|
const Adjustment = Dinero({
|
|
amount: Math.round(job.adjustment_bottom_line * 100)
|
|
}); //Need to invert, since this is being assigned as a cost.
|
|
summaryData.totalLaborSales = summaryData.totalLaborSales.add(Adjustment);
|
|
summaryData.totalSales = summaryData.totalSales.add(Adjustment);
|
|
//Add to lines.
|
|
costCenterData.push({
|
|
id: "Adj",
|
|
cost_center: "Adjustment",
|
|
sale_labor: Adjustment.toFormat(),
|
|
sale_labor_dinero: Adjustment,
|
|
sale_parts: Dinero().toFormat(),
|
|
sale_parts_dinero: Dinero(),
|
|
sale_additional: Dinero(),
|
|
sale_additional_dinero: Dinero(),
|
|
sale_sublet: Dinero(),
|
|
sale_sublet_dinero: Dinero(),
|
|
sales: Adjustment.toFormat(),
|
|
sales_dinero: Adjustment,
|
|
cost_parts: Dinero().toFormat(),
|
|
cost_parts_dinero: Dinero(),
|
|
cost_labor: Dinero().toFormat(), //Adjustment.toFormat(),
|
|
cost_labor_dinero: Dinero(), // Adjustment,
|
|
cost_additional: Dinero(),
|
|
cost_additional_dinero: Dinero(),
|
|
cost_sublet: Dinero(),
|
|
cost_sublet_dinero: Dinero(),
|
|
costs: Dinero().toFormat(),
|
|
costs_dinero: Dinero(),
|
|
gpdollars_dinero: Dinero(),
|
|
gpdollars: Dinero().toFormat(),
|
|
gppercent: formatGpPercent(0)
|
|
});
|
|
}
|
|
|
|
//Final summary data massaging.
|
|
|
|
summaryData.totalLaborGp = summaryData.totalLaborSales.subtract(summaryData.totalLaborCost);
|
|
summaryData.totalLaborGppercent = (
|
|
(summaryData.totalLaborGp.getAmount() / summaryData.totalLaborSales.getAmount()) *
|
|
100
|
|
).toFixed(1);
|
|
summaryData.totalLaborGppercentFormatted = formatGpPercent(summaryData.totalLaborGppercent);
|
|
|
|
summaryData.totalPartsGp = summaryData.totalPartsSales.subtract(summaryData.totalPartsCost);
|
|
summaryData.totalPartsGppercent = (
|
|
(summaryData.totalPartsGp.getAmount() / summaryData.totalPartsSales.getAmount()) *
|
|
100
|
|
).toFixed(1);
|
|
summaryData.totalPartsGppercentFormatted = formatGpPercent(summaryData.totalPartsGppercent);
|
|
summaryData.totalAdditionalGp = summaryData.totalAdditionalSales.subtract(summaryData.totalAdditionalCost);
|
|
summaryData.totalAdditionalGppercent = (
|
|
(summaryData.totalAdditionalGp.getAmount() / summaryData.totalAdditionalSales.getAmount()) *
|
|
100
|
|
).toFixed(1);
|
|
summaryData.totalAdditionalGppercentFormatted = formatGpPercent(summaryData.totalAdditionalGppercent);
|
|
summaryData.totalSubletGp = summaryData.totalSubletSales.subtract(summaryData.totalSubletCost);
|
|
summaryData.totalSubletGppercent = (
|
|
(summaryData.totalSubletGp.getAmount() / summaryData.totalSubletSales.getAmount()) *
|
|
100
|
|
).toFixed(1);
|
|
summaryData.totalSubletGppercentFormatted = formatGpPercent(summaryData.totalSubletGppercent);
|
|
|
|
summaryData.gpdollars = summaryData.totalSales.subtract(summaryData.totalCost);
|
|
summaryData.gppercent = (
|
|
(summaryData.gpdollars.getAmount() / Math.abs(summaryData.totalSales.getAmount())) *
|
|
100
|
|
).toFixed(1);
|
|
|
|
if (isNaN(summaryData.gppercent)) summaryData.gppercentFormatted = 0;
|
|
else if (!isFinite(summaryData.gppercent)) summaryData.gppercentFormatted = "- ∞";
|
|
else {
|
|
summaryData.gppercentFormatted = `${summaryData.gppercent}%`;
|
|
}
|
|
|
|
return { summaryData, costCenterData };
|
|
}
|
|
|
|
exports.JobCosting = JobCosting;
|
|
exports.JobCostingMulti = JobCostingMulti;
|
|
|
|
const formatGpPercent = (gppercent) => {
|
|
let gppercentFormatted;
|
|
if (isNaN(gppercent)) gppercentFormatted = "0%";
|
|
else if (!isFinite(gppercent)) gppercentFormatted = "- ∞";
|
|
else {
|
|
gppercentFormatted = `${gppercent}%`;
|
|
}
|
|
|
|
return gppercentFormatted;
|
|
};
|
|
|
|
//Verify that this stays in line with jobs-close-auto-allocate logic from the application.
|
|
const getAdditionalCostCenter = (jl, profitCenters) => {
|
|
if (!jl.part_type && !jl.mod_lbr_ty) {
|
|
const lineDesc = jl.line_desc ? jl.line_desc.toLowerCase() : "";
|
|
|
|
if (lineDesc.includes("shop mat")) {
|
|
return profitCenters["MASH"];
|
|
} else if (lineDesc.includes("paint/mat")) {
|
|
return profitCenters["MAPA"];
|
|
} else if (lineDesc.includes("ats amount")) {
|
|
return profitCenters["ATS"];
|
|
} else if (lineDesc.includes("towing")) {
|
|
return profitCenters["TOW"];
|
|
} else if (jl.act_price > 0) {
|
|
//TODO:AIO Ensure that this is tested.
|
|
return profitCenters["PAO"];
|
|
} else {
|
|
return null;
|
|
}
|
|
}
|
|
};
|