const Dinero = require("dinero.js"); const queries = require("../graphql-client/queries"); const logger = require("../utils/logger"); //****************************************************** */ //****************************************************** */ //****************************************************** */ //****************************************************** */ //****************************************************** */ //THIS IS THE CANADIAN/IMEX REQUIRED JOB TOTALS CALCULATION. //****************************************************** */ //****************************************************** */ //****************************************************** */ //****************************************************** */ // Dinero.defaultCurrency = "USD"; // Dinero.globalLocale = "en-CA"; Dinero.globalRoundingMode = "HALF_EVEN"; exports.totalsSsu = async function (req, res) { const { id } = req.body; const BearerToken = req.BearerToken; const client = req.userGraphQLClient; logger.log("job-totals-ssu", "DEBUG", req.user.email, id, null); try { const job = await client.setHeaders({ Authorization: BearerToken }).request(queries.GET_JOB_BY_PK, { id: id }); // Capture the output of TotalsServerSide const newTotals = await TotalsServerSide({ body: { job: job.jobs_by_pk, client: client } }, res, true); const result = await client.setHeaders({ Authorization: BearerToken }).request(queries.UPDATE_JOB, { jobId: id, job: { clm_total: newTotals.totals.total_repairs.toFormat("0.00"), owner_owing: newTotals.totals.custPayable.total.toFormat("0.00"), job_totals: newTotals //queued_for_parts: true, } }); if (!result) { throw new Error("Failed to update job totals"); } res.status(200).send(); } catch (error) { logger.log("job-totals-ssu-error", "ERROR", req.user.email, id, { jobid: id, error: error.message, stack: error.stack }); res.status(503).send(); } }; //IMPORTANT*** These two functions MUST be mirrored. async function TotalsServerSide(req, res) { const { job, client } = req.body; await AtsAdjustmentsIfRequired({ job: job, client: client, user: req?.user }); try { let ret = { parts: CalculatePartsTotals(job.joblines), rates: CalculateRatesTotals(job), additional: CalculateAdditional(job) }; ret.totals = CalculateTaxesTotals(job, ret); return ret; } catch (error) { logger.log("job-totals-ssu-error", "ERROR", req?.user?.email, job.id, { jobid: job.id, error: error.message, stack: error.stack }); res.status(400).send(JSON.stringify(error)); } } // Exported for testing purposes exports.TotalsServerSide = TotalsServerSide; async function Totals(req, res) { const { job, id } = req.body; const logger = req.logger; const client = req.userGraphQLClient; logger.log("job-totals-ssu", "DEBUG", req.user.email, job.id, { jobid: job.id, id: id }); await AtsAdjustmentsIfRequired({ job, client, user: req.user }); try { let ret = { parts: CalculatePartsTotals(job.joblines), rates: CalculateRatesTotals(job), additional: CalculateAdditional(job) }; ret.totals = CalculateTaxesTotals(job, ret); res.status(200).json(ret); } catch (error) { logger.log("job-totals-ssu-error", "ERROR", req.user.email, job.id, { jobid: job.id, error: error.message, stack: error.stack }); res.status(400).send(JSON.stringify(error)); } } async function AtsAdjustmentsIfRequired({ job, client, user }) { if (job.auto_add_ats || job.flat_rate_ats) { let atsAmount = 0; let atsLineIndex = null; //Check if ATS should be automatically added. if (job.auto_add_ats) { const excludedLaborTypes = new Set(["LAA", "LAG", "LAS", "LAU", "LA1", "LA2", "LA3", "LA4"]); //Get the total sum of hours that should be the ATS amount. //Check to see if an ATS line exists. const atsHours = job.joblines.reduce((acc, val, index) => { if (val.line_desc?.toLowerCase() === "ats amount") { atsLineIndex = index; } if (!excludedLaborTypes.has(val.mod_lbr_ty)) { acc = acc + val.mod_lb_hrs; } return acc; }, 0); atsAmount = atsHours * (job.rate_ats || 0); } //Check if a Flat Rate ATS should be added. if (job.flat_rate_ats) { atsLineIndex = ((i) => (i === -1 ? null : i))( job.joblines.findIndex((line) => line.line_desc?.toLowerCase() === "ats amount") ); atsAmount = job.rate_ats_flat || 0; } //If it does not, create one for local calculations and insert it. if (atsLineIndex === null) { const newAtsLine = { jobid: job.id, alt_partm: null, unq_seq: 0, line_ind: "E", line_desc: "ATS Amount", line_ref: 0.0, part_type: null, oem_partno: null, db_price: 0.0, act_price: atsAmount, part_qty: 1, mod_lbr_ty: null, db_hrs: 0.0, mod_lb_hrs: 0.0, lbr_op: "OP13", lbr_amt: 0.0, op_code_desc: "ADDITIONAL COSTS", status: null, location: null, tax_part: true, db_ref: null, manual_line: true, prt_dsmk_p: 0.0, prt_dsmk_m: 0.0 }; try { const result = await client.request(queries.INSERT_NEW_JOB_LINE, { lineInput: [newAtsLine] }); if (result) { job.joblines.push(newAtsLine); } } catch (error) { logger.log("job-totals-ssu-ats-error", "ERROR", user?.email, job.id, { jobid: job.id, error: error.message, stack: error.stack }); } } //If it does, update it in place, and make sure it is updated for local calculations. else { try { const result = await client.request(queries.UPDATE_JOB_LINE, { line: { act_price: atsAmount }, lineId: job.joblines[atsLineIndex].id }); if (result) { job.joblines[atsLineIndex].act_price = atsAmount; } } catch (error) { logger.log("job-totals-ssu-ats-error", "ERROR", user?.email, job.id, { jobid: job.id, atsLineIndex: atsLineIndex, atsAmount: atsAmount, jobline: job.joblines[atsLineIndex], error: error.message, stack: error.stack }); } } } } function CalculateRatesTotals(ratesList) { const jobLines = ratesList.joblines.filter((jl) => !jl.removed); let ret = { la1: { hours: 0, rate: ratesList.rate_la1 || 0 }, la2: { hours: 0, rate: ratesList.rate_la2 || 0 }, la3: { rate: ratesList.rate_la3 || 0, hours: 0 }, la4: { rate: ratesList.rate_la4 || 0, hours: 0 }, laa: { rate: ratesList.rate_laa || 0, hours: 0 }, lab: { rate: ratesList.rate_lab || 0, hours: 0 }, lad: { rate: ratesList.rate_lad || 0, hours: 0 }, lae: { rate: ratesList.rate_lae || 0, hours: 0 }, laf: { rate: ratesList.rate_laf || 0, hours: 0 }, lag: { rate: ratesList.rate_lag || 0, hours: 0 }, lam: { rate: ratesList.rate_lam || 0, hours: 0 }, lar: { rate: ratesList.rate_lar || 0, hours: 0 }, las: { rate: ratesList.rate_las || 0, hours: 0 }, lau: { rate: ratesList.rate_lau || 0, hours: 0 }, mapa: { rate: ratesList.rate_mapa || 0, hours: 0 }, mash: { rate: ratesList.rate_mash || 0, hours: 0 } }; //Determine if there are MAPA and MASH lines already on the estimate. //If there are, don't do anything extra (mitchell estimate) //Otherwise, calculate them and add them to the default MAPA and MASH centers. let hasMapaLine = false; let hasMashLine = false; jobLines.forEach((item) => { //IO-1317 Use the lines on the estimate if they exist instead. if (item.db_ref === "936008") { //If either of these DB REFs change, they also need to change in job-totals/job-costing calculations. hasMapaLine = true; ret["mapa"].total = Dinero({ amount: Math.round((item.act_price || 0) * 100) }); } if (item.db_ref === "936007") { hasMashLine = true; ret["mash"].total = Dinero({ amount: Math.round((item.act_price || 0) * 100) }); } if (item.mod_lbr_ty) { //Check to see if it has 0 hours and a price instead. if (item.mod_lb_hrs === 0 && item.act_price > 0 && item.lbr_op === "OP14") { //Scenario where SGI may pay out hours using a part price. if (!ret[item.mod_lbr_ty.toLowerCase()].total) { ret[item.mod_lbr_ty.toLowerCase()].total = Dinero(); } ret[item.mod_lbr_ty.toLowerCase()].total = ret[item.mod_lbr_ty.toLowerCase()].total.add( Dinero({ amount: Math.round((item.act_price || 0) * 100) }).multiply(item.part_qty) ); } //There's a labor type, assign the hours. ret[item.mod_lbr_ty.toLowerCase()].hours = ret[item.mod_lbr_ty.toLowerCase()].hours + item.mod_lb_hrs; if (item.mod_lbr_ty === "LAR") { ret.mapa.hours = ret.mapa.hours + item.mod_lb_hrs; } else { ret.mash.hours = ret.mash.hours + item.mod_lb_hrs; //Apparently there may be an exclusion for glass hours in BC. } } }); let subtotal = Dinero({ amount: 0 }); let rates_subtotal = Dinero({ amount: 0 }); for (const property in ret) { //Skip calculating mapa and mash if we got the amounts. if (!((property === "mapa" && hasMapaLine) || (property === "mash" && hasMashLine))) { if (!ret[property].total) { ret[property].total = Dinero(); } let threshold; //Check if there is a max for this type. if (ratesList.materials && ratesList.materials[property]) { // if (ratesList.materials[property].cal_maxdlr && ratesList.materials[property].cal_maxdlr > 0) { //It has an upper threshhold. threshold = Dinero({ amount: Math.round(ratesList.materials[property].cal_maxdlr * 100) }); } } const total = Dinero({ amount: Math.round((ret[property].rate || 0) * 100) }).multiply(ret[property].hours); if (threshold && total.greaterThanOrEqual(threshold)) { ret[property].total = ret[property].total.add(threshold); } else { ret[property].total = ret[property].total.add(total); } } subtotal = subtotal.add(ret[property].total); if (property !== "mapa" && property !== "mash") rates_subtotal = rates_subtotal.add(ret[property].total); } ret.subtotal = subtotal; ret.rates_subtotal = rates_subtotal; return ret; } function CalculatePartsTotals(jobLines) { const jl = jobLines.filter((jl) => !jl.removed); const ret = jl.reduce( (acc, value) => { switch (value.part_type) { case "PAS": case "PASL": return { ...acc, sublets: { ...acc.sublets, subtotal: acc.sublets.subtotal.add( Dinero({ amount: Math.round(value.act_price * 100) }) .multiply(value.part_qty || 0) .add( ((value.prt_dsmk_m && value.prt_dsmk_m !== 0) || (value.prt_dsmk_p && value.prt_dsmk_p !== 0)) && DiscountNotAlreadyCounted(value, jl) ? value.prt_dsmk_m ? Dinero({ amount: Math.round(value.prt_dsmk_m * 100) }) : Dinero({ amount: Math.round(value.act_price * 100) }) .multiply(value.part_qty || 0) .percentage(Math.abs(value.prt_dsmk_p || 0)) .multiply(value.prt_dsmk_p > 0 ? 1 : -1) : Dinero() ) ) } }; default: if (!value.part_type && value.db_ref !== "900510" && value.db_ref !== "900511") return acc; return { ...acc, parts: { ...acc.parts, prt_dsmk_total: acc.parts.prt_dsmk_total.add( ((value.prt_dsmk_m && value.prt_dsmk_m !== 0) || (value.prt_dsmk_p && value.prt_dsmk_p !== 0)) && DiscountNotAlreadyCounted(value, jl) ? value.prt_dsmk_m ? Dinero({ amount: Math.round(value.prt_dsmk_m * 100) }) : Dinero({ amount: Math.round(value.act_price * 100) }) .multiply(value.part_qty || 0) .percentage(Math.abs(value.prt_dsmk_p || 0)) .multiply(value.prt_dsmk_p > 0 ? 1 : -1) : Dinero() ), ...(value.part_type ? { list: { ...acc.parts.list, [value.part_type]: acc.parts.list[value.part_type] && acc.parts.list[value.part_type].total ? { total: acc.parts.list[value.part_type].total.add( Dinero({ amount: Math.round((value.act_price || 0) * 100) }).multiply(value.part_qty || 0) ) } : { total: Dinero({ amount: Math.round((value.act_price || 0) * 100) }).multiply(value.part_qty || 0) } } } : {}), subtotal: acc.parts.subtotal .add( Dinero({ amount: Math.round(value.act_price * 100) }).multiply(value.part_qty || 0) ) .add( ((value.prt_dsmk_m && value.prt_dsmk_m !== 0) || (value.prt_dsmk_p && value.prt_dsmk_p !== 0)) && DiscountNotAlreadyCounted(value, jl) ? value.prt_dsmk_m ? Dinero({ amount: Math.round(value.prt_dsmk_m * 100) }) : Dinero({ amount: Math.round(value.act_price * 100) }) .multiply(value.part_qty || 0) .percentage(Math.abs(value.prt_dsmk_p || 0)) .multiply(value.prt_dsmk_p > 0 ? 1 : -1) : Dinero() ) } }; } }, { parts: { list: {}, prt_dsmk_total: Dinero(), subtotal: Dinero({ amount: 0 }), total: Dinero({ amount: 0 }) }, sublets: { subtotal: Dinero({ amount: 0 }), total: Dinero({ amount: 0 }) } } ); return { parts: { ...ret.parts, total: ret.parts.subtotal }, sublets: { ...ret.sublets, total: ret.sublets.subtotal } }; } function IsAdditionalCost(jobLine) { //May be able to use db_ref here to help. //936012 is Haz Waste Dispoal //936008 is Paint/Materials //936007 is Shop/Materials //Remove paint and shop mat lines. They're calculated under rates. const isPaintOrShopMat = jobLine.db_ref === "936008" || jobLine.db_ref === "936007"; return ( (jobLine.lbr_op === "OP13" || //Added to resolve manual job lines coming into other totals because they have no reference. (jobLine.db_ref && jobLine.db_ref.startsWith("9360"))) && !isPaintOrShopMat ); } function CalculateAdditional(job) { let ret = { additionalCosts: null, additionalCostItems: [], adjustments: null, towing: Dinero(), shipping: Dinero(), storage: null, pvrt: null, total: null }; ret.towing = Dinero({ amount: Math.round((job.towing_payable || 0) * 100) }); ret.additionalCosts = job.joblines .filter((jl) => !jl.removed && IsAdditionalCost(jl)) .reduce((acc, val) => { const lineValue = Dinero({ amount: Math.round((val.act_price || 0) * 100) }).multiply(val.part_qty || 1); if (val.db_ref === "936004") { //Shipping line IO-1921. ret.shipping = ret.shipping.add(lineValue); } if (val.line_desc.toLowerCase().includes("towing")) { ret.towing = ret.towing.add(lineValue); return acc; } else { ret.additionalCostItems.push({ key: val.line_desc, total: lineValue }); return acc.add(lineValue); } }, Dinero()); ret.adjustments = Dinero({ amount: Math.round((job.adjustment_bottom_line || 0) * 100) }); ret.storage = Dinero({ amount: Math.round((job.storage_payable || 0) * 100) }); ret.pvrt = Dinero({ amount: Math.round((job.ca_bc_pvrt || 0) * 100) }); ret.total = ret.additionalCosts .add(ret.adjustments) //IO-813 Adjustment takes care of GST & PST at labor rate. .add(ret.towing) .add(ret.storage); //.add(ret.pvrt); return ret; } function CalculateTaxesTotals(job, otherTotals) { const subtotal = otherTotals.parts.parts.subtotal .add(otherTotals.parts.sublets.subtotal) .add(otherTotals.rates.subtotal) //No longer using just rates subtotal to include mapa/mash. .add(otherTotals.additional.total); // .add(Dinero({ amount: (job.towing_payable || 0) * 100 })) // .add(Dinero({ amount: (job.storage_payable || 0) * 100 })); //Potential issue here with Sublet Calculation. Sublets are calculated under labor in Mitchell, but it's done in IO //Under the parts rates. let statePartsTax = Dinero(); let additionalItemsTax = Dinero(); //Audatex sends additional glass part types. IO-774 const BackupGlassTax = job.parts_tax_rates && (job.parts_tax_rates.PAGD || job.parts_tax_rates.PAGF || job.parts_tax_rates.PAGP || job.parts_tax_rates.PAGQ || job.parts_tax_rates.PAGR); job.joblines .filter((jl) => !jl.removed) .forEach((val) => { if (!val.tax_part) return; if (!val.part_type && IsAdditionalCost(val)) { additionalItemsTax = additionalItemsTax.add( Dinero({ amount: Math.round((val.act_price || 0) * 100) }) .multiply(val.part_qty || 0) .percentage( ((job.parts_tax_rates && job.parts_tax_rates["PAN"] && job.parts_tax_rates["PAN"].prt_tax_rt) || 0) * 100 ) ); } else { statePartsTax = statePartsTax.add( Dinero({ amount: Math.round((val.act_price || 0) * 100) }) .multiply(val.part_qty || 0) .add( val.prt_dsmk_m && val.prt_dsmk_m !== 0 && DiscountNotAlreadyCounted(val, job.joblines) ? 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) ) .percentage( ((job.parts_tax_rates && job.parts_tax_rates[val.part_type] && job.parts_tax_rates[val.part_type].prt_tax_rt) || (val.part_type && val.part_type.startsWith("PAG") && BackupGlassTax && BackupGlassTax.prt_tax_rt) || (!val.part_type && val.db_ref === "900510" && job.parts_tax_rates["PAN"] && job.parts_tax_rates["PAN"].prt_tax_rt) || 0) * 100 ) ); } }); let ret = { subtotal: subtotal, federal_tax: subtotal .percentage((job.federal_tax_rate || 0) * 100) .add(otherTotals.additional.pvrt.percentage((job.federal_tax_rate || 0) * 100)), statePartsTax, state_tax: statePartsTax .add( otherTotals.rates.subtotal.percentage((job.tax_lbr_rt || 0) * 100) // THis is currently using the lbr tax rate from PFH not PFL. ) .add(otherTotals.additional.adjustments.percentage((job.tax_lbr_rt || 0) * 100)) .add(otherTotals.additional.towing.percentage((job.tax_tow_rt || 0) * 100)) .add(otherTotals.additional.storage.percentage((job.tax_str_rt || 0) * 100)) .add(additionalItemsTax), // .add(otherTotals.additional.pvrt), local_tax: subtotal.percentage((job.local_tax_rate || 0) * 100) }; ret.total_repairs = ret.subtotal .add(ret.federal_tax) .add(ret.state_tax) .add(ret.local_tax) .add(otherTotals.additional.pvrt); ret.custPayable = { deductible: Dinero({ amount: Math.round((job.ded_amt || 0) * 100) }) || 0, federal_tax: job.ca_gst_registrant ? job.ca_customer_gst === 0 || job.ca_customer_gst === null ? ret.federal_tax : Dinero({ amount: Math.round(job.ca_customer_gst * 100) }) : Dinero(), other_customer_amount: Dinero({ amount: Math.round((job.other_amount_payable || 0) * 100) }), dep_taxes: Dinero({ amount: Math.round((job.depreciation_taxes || 0) * 100) }) }; ret.custPayable.total = ret.custPayable.deductible .add(ret.custPayable.federal_tax) .add(ret.custPayable.other_customer_amount) .add(ret.custPayable.dep_taxes); ret.net_repairs = ret.total_repairs.subtract(ret.custPayable.total); return ret; } exports.default = Totals; function DiscountNotAlreadyCounted(jobline, joblines) { if ( //If it's not a discount line, then it definitely hasn't been counted yet. jobline.db_ref !== "900510" && jobline.db_ref !== "900511" ) return true; const ParentLine = joblines.find((j) => j.unq_seq === jobline.line_ref); return ParentLine && !(ParentLine.prt_dsmk_m && ParentLine.prt_dsmk_m !== 0); } exports.DiscountNotAlreadyCounted = DiscountNotAlreadyCounted;