const Dinero = require("dinero.js"); const queries = require("../graphql-client/queries"); //const client = require("../graphql-client/graphql-client").client; const _ = require("lodash"); const GraphQLClient = require("graphql-request").GraphQLClient; // Dinero.defaultCurrency = "USD"; // Dinero.globalLocale = "en-CA"; Dinero.globalRoundingMode = "HALF_EVEN"; async function JobCosting(req, res) { const { jobid } = req.body; console.time("Query for Data"); const BearerToken = req.headers.authorization; const client = new GraphQLClient(process.env.GRAPHQL_ENDPOINT, { headers: { Authorization: BearerToken, }, }); try { const resp = await client .setHeaders({ Authorization: BearerToken }) .request(queries.QUERY_JOB_COSTING_DETAILS, { id: jobid, }); console.timeEnd("querydata"); console.time(`generatecostingdata-${resp.jobs_by_pk.id}`); const ret = GenerateCostingData(resp.jobs_by_pk); console.timeEnd(`generatecostingdata-${resp.jobs_by_pk.id}`); res.status(200).json(ret); } catch (error) { console.log("error", error); res.status(400).send(JSON.stringify(error)); } } async function JobCostingMulti(req, res) { const { jobids } = req.body; const BearerToken = req.headers.authorization; console.time("JobCostingMultiQueryExecution"); const client = new GraphQLClient(process.env.GRAPHQL_ENDPOINT, { headers: { Authorization: BearerToken, }, }); 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 }), totalSales: Dinero({ amount: 0 }), totalLaborCost: Dinero({ amount: 0 }), totalPartsCost: Dinero({ amount: 0 }), totalCost: Dinero({ amount: 0 }), gpdollars: Dinero({ amount: 0 }), gppercent: null, gppercentFormatted: null, }, }; const ret = {}; resp.jobs.map((job) => { console.time(`CostingData-${job.id}`); const costingData = GenerateCostingData(job); ret[job.id] = costingData; console.timeEnd(`CostingData-${job.id}`); console.time(`SummaryOfCostingData-${job.id}`); //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), 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), 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.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.totalCost = multiSummary.summaryData.totalCost.add( costingData.summaryData.totalCost ); multiSummary.summaryData.gpdollars = multiSummary.summaryData.gpdollars.add( costingData.summaryData.gpdollars ); console.timeEnd(`SummaryOfCostingData-${job.id}`); //Take the summary data & add it to total summary data. }); //For each center, recalculate and toFormat() the values. multiSummary.summaryData.gpdollars; multiSummary.summaryData.gppercent = ( (multiSummary.summaryData.gpdollars.getAmount() / multiSummary.summaryData.totalSales.getAmount()) * 100 ).toFixed(2); 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(), sales: c.sale_labor_dinero.add(c.sale_parts_dinero).toFormat(), cost_parts: c.cost_parts_dinero && c.cost_parts_dinero.toFormat(), cost_labor: c.cost_labor_dinero && c.cost_labor_dinero.toFormat(), costs: c.cost_parts_dinero.add(c.cost_labor_dinero).toFormat(), gpdollars: c.gpdollars_dinero.toFormat(), gppercent: formatGpPercent( ( (c.gpdollars_dinero.getAmount() / c.sales_dinero.getAmount()) * 100 ).toFixed(2) ), }; }); //Calculate thte total gross profit percentages. console.timeEnd("JobCostingMultiQueryExecution"); res.status(200).json({ allCostCenterData: finalCostingdata, allSummaryData: multiSummary.summaryData, data: ret, }); } catch (error) { console.log("error", error); res.status(400).send(JSON.stringify(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) ); const materialsHours = { mapaHrs: 0, mashHrs: 0 }; //Massage the data. const jobLineTotalsByProfitCenter = job && job.joblines.reduce( (acc, val) => { if (val.mod_lbr_ty) { const laborProfitCenter = val.profitcenter_labor || defaultProfits[val.mod_lbr_ty] || "?"; if (laborProfitCenter === "?") console.log("Unknown type", val.mod_lbr_ty); const rateName = `rate_${(val.mod_lbr_ty || "").toLowerCase()}`; const laborAmount = Dinero({ amount: Math.round((job[rateName] || 0) * 100), }).multiply(val.mod_lb_hrs || 0); if (!acc.labor[laborProfitCenter]) acc.labor[laborProfitCenter] = Dinero(); acc.labor[laborProfitCenter] = acc.labor[laborProfitCenter].add(laborAmount); if (val.mod_lbr_ty === "LAR") { if (!acc.labor[defaultProfits["MAPA"]]) acc.labor[defaultProfits["MAPA"]] = Dinero(); materialsHours.mapaHrs += val.mod_lb_hrs || 0; acc.labor[defaultProfits["MAPA"]] = acc.labor[ defaultProfits["MAPA"] ].add( Dinero({ amount: Math.round((job.rate_mapa || 0) * 100), }).multiply(val.mod_lb_hrs || 0) ); } if (!acc.labor[defaultProfits["MASH"]]) acc.labor[defaultProfits["MASH"]] = Dinero(); if (val.mod_lbr_ty !== "LAR") { acc.labor[defaultProfits["MASH"]] = acc.labor[ defaultProfits["MASH"] ].add( Dinero({ amount: Math.round((job.rate_mash || 0) * 100), }).multiply(val.mod_lb_hrs || 0) ); materialsHours.mashHrs += val.mod_lb_hrs || 0; } //If labor line, add to paint and shop materials. } if (val.part_type && val.part_type !== "PAE") { const partsProfitCenter = val.profitcenter_part || defaultProfits[val.part_type] || "?"; if (partsProfitCenter === "?") console.log("Unknown type", val.part_type); if (!partsProfitCenter) console.log( "Unknown cost/profit center mapping for parts.", val.part_type ); const partsAmount = Dinero({ amount: Math.round((val.act_price || 0) * 100), }) .multiply(val.part_qty || 1) .add( Dinero({ amount: Math.round((val.act_price || 0) * 100), }) .multiply(val.part_qty || 0) .percentage(val.prt_dsmk_p || 0) ); if (!acc.parts[partsProfitCenter]) acc.parts[partsProfitCenter] = Dinero(); acc.parts[partsProfitCenter] = acc.parts[partsProfitCenter].add(partsAmount); } //To deal with additional costs. if (!val.part_type && !val.mod_lbr_ty) { //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) || "?"; if (partsProfitCenter === "?") { console.log("Unknown type", val.part_type); } else { const partsAmount = Dinero({ amount: Math.round((val.act_price || 0) * 100), }) .multiply(val.part_qty || 1) .add( Dinero({ amount: Math.round((val.act_price || 0) * 100), }) .multiply(val.part_qty || 0) .percentage(val.prt_dsmk_p || 0) ); if (!acc.parts[partsProfitCenter]) acc.parts[partsProfitCenter] = Dinero(); acc.parts[partsProfitCenter] = acc.parts[partsProfitCenter].add(partsAmount); } } return acc; }, { parts: {}, labor: {} } ); const billTotalsByCostCenters = job.bills.reduce((bill_acc, bill_val) => { //At the bill level. bill_val.billlines.map((line_val) => { //At the bill line level. //console.log("JobCostingPartsTable -> line_val", line_val); 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; }, {}); //If the hourly rates for job costing are set, add them in. if (job.bodyshop.jc_hourly_rates && job.bodyshop.jc_hourly_rates.mapa) { if ( !billTotalsByCostCenters[ job.bodyshop.md_responsibility_centers.defaults.costs.MAPA ] ) billTotalsByCostCenters[ job.bodyshop.md_responsibility_centers.defaults.costs.MAPA ] = Dinero(); billTotalsByCostCenters[ job.bodyshop.md_responsibility_centers.defaults.costs.MAPA ] = billTotalsByCostCenters[ job.bodyshop.md_responsibility_centers.defaults.costs.MAPA ].add( Dinero({ amount: (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[ job.bodyshop.md_responsibility_centers.defaults.costs.MASH ] ) billTotalsByCostCenters[ job.bodyshop.md_responsibility_centers.defaults.costs.MASH ] = Dinero(); billTotalsByCostCenters[ job.bodyshop.md_responsibility_centers.defaults.costs.MASH ] = billTotalsByCostCenters[ job.bodyshop.md_responsibility_centers.defaults.costs.MASH ].add( Dinero({ amount: (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 (!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.actualhrs || ticket_val.productivehrs || 0) ); return ticket_acc; }, {} ); const summaryData = { totalLaborSales: Dinero({ amount: 0 }), totalPartsSales: Dinero({ amount: 0 }), totalSales: Dinero({ amount: 0 }), totalLaborCost: Dinero({ amount: 0 }), totalPartsCost: Dinero({ amount: 0 }), totalCost: Dinero({ amount: 0 }), gpdollars: Dinero({ amount: 0 }), 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 cost_labor = ticketTotalsByCostCenter[ccVal] || Dinero({ amount: 0 }); const cost_parts = billTotalsByCostCenters[ccVal] || Dinero({ amount: 0 }); const costs = (billTotalsByCostCenters[ccVal] || Dinero({ amount: 0 })).add( ticketTotalsByCostCenter[ccVal] || Dinero({ amount: 0 }) ); const totalSales = sale_labor.add(sale_parts); const gpdollars = totalSales.subtract(costs); const gppercent = ( (gpdollars.getAmount() / totalSales.getAmount()) * 100 ).toFixed(2); //Push summary data to avoid extra loop. summaryData.totalLaborSales = summaryData.totalLaborSales.add(sale_labor); summaryData.totalPartsSales = summaryData.totalPartsSales.add(sale_parts); summaryData.totalSales = summaryData.totalSales .add(sale_labor) .add(sale_parts); summaryData.totalLaborCost = summaryData.totalLaborCost.add(cost_labor); summaryData.totalPartsCost = summaryData.totalPartsCost.add(cost_parts); 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, sales: sale_labor.add(sale_parts).toFormat(), sales_dinero: sale_labor.add(sale_parts), cost_parts: cost_parts && cost_parts.toFormat(), cost_parts_dinero: cost_parts, cost_labor: cost_labor && cost_labor.toFormat(), cost_labor_dinero: cost_labor, costs: cost_parts.add(cost_labor).toFormat(), costs_dinero: cost_parts.add(cost_labor), 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: job.adjustment_bottom_line * -100 }); //Need to invert, since this is being assigned as a cost. summaryData.totalLaborCost = summaryData.totalLaborCost.add(Adjustment); summaryData.totalCost = summaryData.totalCost.add(Adjustment); //Add to lines. costCenterData.push({ id: "Adj", cost_center: "Adjustment", sale_labor: Dinero().toFormat(), sale_labor_dinero: Dinero(), sale_parts: Dinero().toFormat(), sale_parts_dinero: Dinero(), sales: Dinero().toFormat(), sales_dinero: Dinero(), cost_parts: Dinero().toFormat(), cost_parts_dinero: Dinero(), cost_labor: Adjustment.toFormat(), cost_labor_dinero: Adjustment, costs: Adjustment.toFormat(), costs_dinero: Adjustment, gpdollars_dinero: Dinero(), gpdollars: Dinero().toFormat(), gppercent: formatGpPercent(0), }); } //Final summary data massaging. summaryData.gpdollars = summaryData.totalSales.subtract( summaryData.totalCost ); summaryData.gppercent = ( (summaryData.gpdollars.getAmount() / summaryData.totalSales.getAmount()) * 100 ).toFixed(2); 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) => { console.log("Checking additional cost center", jl.line_desc); if (!jl.part_type && !jl.mod_lbr_ty) { const lineDesc = jl.line_desc.toLowerCase(); //This logic is covered prior and assigned based on the labor type of the lines // if (lineDesc.includes("shop materials")) { // return profitCenters["MASH"]; // } else if (lineDesc.includes("paint/materials")) { // return profitCenters["MAPA"]; // } else //End covered logic if (lineDesc.includes("ats amount")) { return profitCenters["ATS"]; } else { return null; } } };