556 lines
19 KiB
JavaScript
556 lines
19 KiB
JavaScript
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;
|
|
}
|
|
}
|
|
};
|