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[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) ); } if (InstanceManager({ imex: false, rome: true })) { const stlTowing = job.cieca_stl?.data.find((c) => c.ttl_type === "OTTW"); const stlStorage = job.cieca_stl?.data.find((c) => c.ttl_type === "OTST"); if (!jobLineTotalsByProfitCenter.additional[defaultProfits["TOW"]]) jobLineTotalsByProfitCenter.additional[defaultProfits["TOW"]] = Dinero(); jobLineTotalsByProfitCenter.additional[defaultProfits["TOW"]] = stlTowing ? Dinero({ amount: Math.round(stlTowing.ttl_amt * 100) }) : Dinero({ amount: Math.round((job.towing_payable || 0) * 100) }); if (!jobLineTotalsByProfitCenter.additional[defaultProfits["STO"]]) jobLineTotalsByProfitCenter.additional[defaultProfits["STO"]] = Dinero(); jobLineTotalsByProfitCenter.additional[defaultProfits["STO"]] = stlStorage ? Dinero({ amount: Math.round(stlStorage.ttl_amt * 100) }) : Dinero({ amount: Math.round((job.storage_payable || 0) * 100) }); } //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) }); } //Push adjustments to bottom line. if (job.job_totals?.totals?.ttl_adjustment) { //Add to totals. const Adjustment = Dinero(job.job_totals.totals.ttl_adjustment); //Need to invert, since this is being assigned as a cost. summaryData.totalAdditionalSales = summaryData.totalAdditionalSales.add(Adjustment); summaryData.totalSales = summaryData.totalSales.add(Adjustment); //Add to lines. costCenterData.push({ id: "AdjEst", cost_center: "Adjustment (Est. Match)", sale_labor: Dinero().toFormat(), sale_labor_dinero: Dinero(), sale_parts: Dinero().toFormat(), sale_parts_dinero: Dinero(), sale_additional: Adjustment.toFormat(), sale_additional_dinero: Adjustment, 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; } } };