From 6bda497d8c506b6b2a8607cd0c92f511cc5f16b7 Mon Sep 17 00:00:00 2001 From: Dave Date: Thu, 9 Apr 2026 13:54:48 -0400 Subject: [PATCH] feature/IO-3647-Reynolds-Integration-Phase-2 - Enhance early RO with meaningful amounts. --- .../rr-dms-allocations-summary.component.jsx | 47 +++--- server/rr/rr-calculate-allocations.js | 14 ++ server/rr/rr-job-export.js | 96 +++++------- server/rr/rr-job-helpers.js | 142 +++++++++++++++++- server/rr/rr-job-helpers.test.js | 118 +++++++++++++++ 5 files changed, 337 insertions(+), 80 deletions(-) create mode 100644 server/rr/rr-job-helpers.test.js diff --git a/client/src/components/dms-allocations-summary/rr-dms-allocations-summary.component.jsx b/client/src/components/dms-allocations-summary/rr-dms-allocations-summary.component.jsx index 4cffcfd85..a367caee6 100644 --- a/client/src/components/dms-allocations-summary/rr-dms-allocations-summary.component.jsx +++ b/client/src/components/dms-allocations-summary/rr-dms-allocations-summary.component.jsx @@ -64,7 +64,7 @@ function normalizeJobAllocations(ack) { * RR-specific DMS Allocations Summary * Focused on what we actually send to RR: * - ROGOG (split by taxable / non-taxable segments) - * - ROLABOR shell + * - ROLABOR labor rows with bill hours / rates * * The heavy lifting (ROGOG/ROLABOR split, cost allocation, tax flags) * is now done on the backend via buildRogogFromAllocations/buildRolaborFromRogog. @@ -181,21 +181,30 @@ export function RrAllocationsSummary({ socket, bodyshop, jobId, title, onAllocat const rolaborRows = useMemo(() => { if (!rolaborPreview || !Array.isArray(rolaborPreview.ops)) return []; - return rolaborPreview.ops.map((op, idx) => { - const rowOpCode = opCode || op.opCode; + return rolaborPreview.ops + .filter((op) => + [op.bill?.jobTotalHrs, op.bill?.billTime, op.bill?.billRate, op.amount?.custPrice, op.amount?.totalAmt] + .map((value) => Number.parseFloat(value ?? "0")) + .some((value) => !Number.isNaN(value) && value !== 0) + ) + .map((op, idx) => { + const rowOpCode = opCode || op.opCode; - return { - key: `${op.jobNo}-${idx}`, - opCode: rowOpCode, - jobNo: op.jobNo, - custPayTypeFlag: op.custPayTypeFlag, - custTxblNtxblFlag: op.custTxblNtxblFlag, - payType: op.bill?.payType, - amtType: op.amount?.amtType, - custPrice: op.amount?.custPrice, - totalAmt: op.amount?.totalAmt - }; - }); + return { + key: `${op.jobNo}-${idx}`, + opCode: rowOpCode, + jobNo: op.jobNo, + custPayTypeFlag: op.custPayTypeFlag, + custTxblNtxblFlag: op.custTxblNtxblFlag, + payType: op.bill?.payType, + jobTotalHrs: op.bill?.jobTotalHrs, + billTime: op.bill?.billTime, + billRate: op.bill?.billRate, + amtType: op.amount?.amtType, + custPrice: op.amount?.custPrice, + totalAmt: op.amount?.totalAmt + }; + }); }, [rolaborPreview, opCode]); // Totals for ROGOG (sum custPrice + dlrCost over all lines) @@ -245,6 +254,9 @@ export function RrAllocationsSummary({ socket, bodyshop, jobId, title, onAllocat { title: "CustPayType", dataIndex: "custPayTypeFlag", key: "custPayTypeFlag" }, { title: "CustTxblFlag", dataIndex: "custTxblNtxblFlag", key: "custTxblNtxblFlag" }, { title: "PayType", dataIndex: "payType", key: "payType" }, + { title: "JobTotalHrs", dataIndex: "jobTotalHrs", key: "jobTotalHrs" }, + { title: "BillTime", dataIndex: "billTime", key: "billTime" }, + { title: "BillRate", dataIndex: "billRate", key: "billRate" }, { title: "AmtType", dataIndex: "amtType", key: "amtType" }, { title: "CustPrice", dataIndex: "custPrice", key: "custPrice" }, { title: "TotalAmt", dataIndex: "totalAmt", key: "totalAmt" } @@ -317,12 +329,13 @@ export function RrAllocationsSummary({ socket, bodyshop, jobId, title, onAllocat children: ( <> - This mirrors the shell that would be sent for ROLABOR when all financials are carried in GOG. + This mirrors the labor rows RR will receive, including weighted bill hours and rates derived from the + job's labor lines. cost: summarizeMoney(a.cost) })); +const toFiniteNumber = (value) => { + const parsed = Number.parseFloat(value); + return Number.isFinite(parsed) ? parsed : 0; +}; + /** * Internal per-center bucket shape for *sales*. * We keep separate buckets for RR so we can split @@ -62,6 +67,8 @@ function emptyCenterBucket() { // Labor laborTaxableSale: zero, // labor that should be taxed in RR laborNonTaxableSale: zero, // labor that should NOT be taxed in RR + laborTaxableHours: 0, + laborNonTaxableHours: 0, // Extras (MAPA/MASH/towing/PAO/etc) extrasSale: zero, // total extras (taxable + non-taxable) @@ -453,6 +460,7 @@ function buildProfitCenterHash(job, debugLog, taxContext) { const rateKey = `rate_${val.mod_lbr_ty.toLowerCase()}`; const rate = job[rateKey]; + const lineHours = toFiniteNumber(val.mod_lb_hrs); const laborAmount = Dinero({ amount: Math.round(rate * 100) @@ -460,8 +468,10 @@ function buildProfitCenterHash(job, debugLog, taxContext) { if (isLaborTaxable(val, taxContext)) { bucket.laborTaxableSale = bucket.laborTaxableSale.add(laborAmount); + bucket.laborTaxableHours += lineHours; } else { bucket.laborNonTaxableSale = bucket.laborNonTaxableSale.add(laborAmount); + bucket.laborNonTaxableHours += lineHours; } } @@ -478,6 +488,8 @@ function buildProfitCenterHash(job, debugLog, taxContext) { partsNonTaxable: summarizeMoney(b.partsNonTaxableSale), laborTaxable: summarizeMoney(b.laborTaxableSale), laborNonTaxable: summarizeMoney(b.laborNonTaxableSale), + laborTaxableHours: b.laborTaxableHours, + laborNonTaxableHours: b.laborNonTaxableHours, extras: summarizeMoney(b.extrasSale), extrasTaxable: summarizeMoney(b.extrasTaxableSale), extrasNonTaxable: summarizeMoney(b.extrasNonTaxableSale) @@ -916,6 +928,8 @@ function buildJobAllocations(bodyshop, profitCenterHash, costCenterHash, debugLo // Labor laborTaxableSale: bucket.laborTaxableSale, laborNonTaxableSale: bucket.laborNonTaxableSale, + laborTaxableHours: bucket.laborTaxableHours, + laborNonTaxableHours: bucket.laborNonTaxableHours, // Extras extrasSale, diff --git a/server/rr/rr-job-export.js b/server/rr/rr-job-export.js index 5eefbf19a..15e2f2a34 100644 --- a/server/rr/rr-job-export.js +++ b/server/rr/rr-job-export.js @@ -1,4 +1,4 @@ -const { buildRRRepairOrderPayload } = require("./rr-job-helpers"); +const { buildRRRepairOrderPayload, buildMinimalRolaborFromJob } = require("./rr-job-helpers"); const { buildClientAndOpts } = require("./rr-lookup"); const CreateRRLogEvent = require("./rr-logger-event"); const { withRRRequestXml } = require("./rr-log-xml"); @@ -56,6 +56,27 @@ const deriveRRStatus = (rrRes = {}) => { }; }; +const resolveRROpCode = (bodyshop, txEnvelope = {}) => { + const resolvedBaseOpCode = resolveRROpCodeFromBodyshop(bodyshop); + let opCodeOverride = txEnvelope?.opCode || txEnvelope?.opcode || txEnvelope?.op_code || null; + + if (!opCodeOverride) { + const opPrefix = txEnvelope?.opPrefix ?? txEnvelope?.op_prefix ?? null; + const opBase = txEnvelope?.opBase ?? txEnvelope?.op_base ?? null; + const opSuffix = txEnvelope?.opSuffix ?? txEnvelope?.op_suffix ?? null; + + if (opPrefix || opBase || opSuffix) { + const combined = `${opPrefix || ""}${opBase || ""}${opSuffix || ""}`.trim(); + if (combined) { + opCodeOverride = combined; + } + } + } + + if (!opCodeOverride && !resolvedBaseOpCode) return null; + return String(opCodeOverride || resolvedBaseOpCode).trim() || null; +}; + /** * Early RO Creation: Create a minimal RR Repair Order with basic info (customer, advisor, mileage, story). * Used when creating RO from convert button or admin page before full job export. @@ -93,7 +114,9 @@ const createMinimalRRRepairOrder = async (args) => { const story = txEnvelope?.story ? String(txEnvelope.story).trim() : null; const makeOverride = txEnvelope?.makeOverride ? String(txEnvelope.makeOverride).trim() : null; - // Build minimal RO payload - just header, no allocations/parts/labor + // Build minimal RO payload for early review mode. + // We keep it lightweight, but include a single labor row when we can so Ignite + // exposes the labor subsection for editing. const cleanVin = (job?.v_vin || "") .toString() @@ -116,6 +139,12 @@ const createMinimalRRRepairOrder = async (args) => { resolvedMileageIn: mileageIn }); + const earlyRoOpCode = resolveRROpCode(bodyshop, txEnvelope); + const earlyRoLabor = buildMinimalRolaborFromJob(job, { + opCode: earlyRoOpCode, + payType: "Cust" + }); + const payload = { customerNo: String(selected), advisorNo: String(advisorNo), @@ -141,9 +170,14 @@ const createMinimalRRRepairOrder = async (args) => { if (makeOverride) { payload.makeOverride = makeOverride; } + if (earlyRoLabor) { + payload.rolabor = earlyRoLabor; + } CreateRRLogEvent(socket, "INFO", "Creating minimal RR Repair Order (early creation)", { - payload + payload, + earlyRoOpCode, + hasRolabor: !!earlyRoLabor }); const response = await client.createRepairOrder(payload, finalOpts); @@ -221,15 +255,10 @@ const updateRRRepairOrderWithFullData = async (args) => { const story = txEnvelope?.story ? String(txEnvelope.story).trim() : null; const makeOverride = txEnvelope?.makeOverride ? String(txEnvelope.makeOverride).trim() : null; - // Optional RR OpCode segments coming from the FE (RRPostForm) - const opPrefix = txEnvelope?.opPrefix ?? txEnvelope?.op_prefix ?? null; - const opBase = txEnvelope?.opBase ?? txEnvelope?.op_base ?? null; - const opSuffix = txEnvelope?.opSuffix ?? txEnvelope?.op_suffix ?? null; - // RR-only extras let rrCentersConfig = null; let allocations = null; - let opCode = null; + const opCode = resolveRROpCode(bodyshop, txEnvelope); // 1) Responsibility center config (for visibility / debugging) try { @@ -280,28 +309,9 @@ const updateRRRepairOrderWithFullData = async (args) => { allocations = []; } - const resolvedBaseOpCode = resolveRROpCodeFromBodyshop(bodyshop); - - let opCodeOverride = txEnvelope?.opCode || txEnvelope?.opcode || txEnvelope?.op_code || null; - - // If the FE only sends segments, combine them here. - if (!opCodeOverride && (opPrefix || opBase || opSuffix)) { - const combined = `${opPrefix || ""}${opBase || ""}${opSuffix || ""}`.trim(); - if (combined) { - opCodeOverride = combined; - } - } - - if (opCodeOverride || resolvedBaseOpCode) { - opCode = String(opCodeOverride || resolvedBaseOpCode).trim() || null; - } - CreateRRLogEvent(socket, "SILLY", "RR OP config resolved", { opCode, - baseFromConfig: resolvedBaseOpCode, - opPrefix, - opBase, - opSuffix + baseFromConfig: resolveRROpCodeFromBodyshop(bodyshop) }); // Build full RO payload for update with allocations @@ -426,15 +436,10 @@ const exportJobToRR = async (args) => { const story = txEnvelope?.story ? String(txEnvelope.story).trim() : null; const makeOverride = txEnvelope?.makeOverride ? String(txEnvelope.makeOverride).trim() : null; - // Optional RR OpCode segments coming from the FE (RRPostForm) - const opPrefix = txEnvelope?.opPrefix ?? txEnvelope?.op_prefix ?? null; - const opBase = txEnvelope?.opBase ?? txEnvelope?.op_base ?? null; - const opSuffix = txEnvelope?.opSuffix ?? txEnvelope?.op_suffix ?? null; - // RR-only extras let rrCentersConfig = null; let allocations = null; - let opCode = null; + const opCode = resolveRROpCode(bodyshop, txEnvelope); // 1) Responsibility center config (for visibility / debugging) try { @@ -477,28 +482,9 @@ const exportJobToRR = async (args) => { allocations = []; } - const resolvedBaseOpCode = resolveRROpCodeFromBodyshop(bodyshop); - - let opCodeOverride = txEnvelope?.opCode || txEnvelope?.opcode || txEnvelope?.op_code || null; - - // If the FE only sends segments, combine them here. - if (!opCodeOverride && (opPrefix || opBase || opSuffix)) { - const combined = `${opPrefix || ""}${opBase || ""}${opSuffix || ""}`.trim(); - if (combined) { - opCodeOverride = combined; - } - } - - if (opCodeOverride || resolvedBaseOpCode) { - opCode = String(opCodeOverride || resolvedBaseOpCode).trim() || null; - } - CreateRRLogEvent(socket, "SILLY", "RR OP config resolved", { opCode, - baseFromConfig: resolvedBaseOpCode, - opPrefix, - opBase, - opSuffix + baseFromConfig: resolveRROpCodeFromBodyshop(bodyshop) }); // Build RO payload for create. diff --git a/server/rr/rr-job-helpers.js b/server/rr/rr-job-helpers.js index 13ca83fac..23d003ccf 100644 --- a/server/rr/rr-job-helpers.js +++ b/server/rr/rr-job-helpers.js @@ -52,6 +52,19 @@ const asN2 = (dineroLike) => { return amount.toFixed(2); }; +const toFiniteNumber = (value) => { + if (typeof value === "number") { + return Number.isFinite(value) ? value : 0; + } + + if (typeof value === "string") { + const parsed = Number.parseFloat(value); + return Number.isFinite(parsed) ? parsed : 0; + } + + return 0; +}; + /** * Normalize various "money-like" shapes to integer cents. * Supports: @@ -100,6 +113,100 @@ const toMoneyCents = (value) => { const asN2FromCents = (cents) => asN2({ amount: Number.isFinite(cents) ? cents : 0, precision: 2 }); +const formatDecimal = (value, maxDecimals = 2) => { + const factor = Math.pow(10, maxDecimals); + const rounded = Math.round(Math.max(0, toFiniteNumber(value)) * factor) / factor; + if (!Number.isFinite(rounded)) return "0"; + return rounded.toFixed(maxDecimals).replace(/\.?0+$/, "") || "0"; +}; + +const buildRolaborBillFields = ({ amountUnits = 0, hours = 0, rate = 0 } = {}) => { + const normalizedAmount = toFiniteNumber(amountUnits); + + if (normalizedAmount <= 0) { + return { + jobTotalHrs: "0", + billTime: "0", + billRate: "0" + }; + } + + let resolvedHours = toFiniteNumber(hours); + let resolvedRate = toFiniteNumber(rate); + + if (resolvedHours > 0 && resolvedRate <= 0) { + resolvedRate = normalizedAmount / resolvedHours; + } else if (resolvedRate > 0 && resolvedHours <= 0) { + resolvedHours = normalizedAmount / resolvedRate; + } else if (resolvedHours <= 0 && resolvedRate <= 0) { + // Keep the math internally consistent even if the source job has dollars but no usable hours. + resolvedHours = 1; + resolvedRate = normalizedAmount; + } + + return { + jobTotalHrs: formatDecimal(resolvedHours), + billTime: formatDecimal(resolvedHours), + billRate: resolvedRate.toFixed(2) + }; +}; + +const buildMinimalRolaborFromJob = (job, { opCode, payType = "Cust" } = {}) => { + const trimmedOpCode = opCode != null ? String(opCode).trim() : ""; + if (!job || !trimmedOpCode) return null; + + let totalHours = 0; + let totalAmountUnits = 0; + + for (const line of job?.joblines || []) { + const laborType = typeof line?.mod_lbr_ty === "string" ? line.mod_lbr_ty.trim() : ""; + if (!laborType) continue; + + const lineHours = toFiniteNumber(line?.mod_lb_hrs ?? line?.db_hrs); + const configuredRate = toFiniteNumber(job?.[`rate_${laborType.toLowerCase()}`]); + let lineAmountUnits = toFiniteNumber(line?.lbr_amt); + + if (lineAmountUnits <= 0 && lineHours > 0 && configuredRate > 0) { + lineAmountUnits = lineHours * configuredRate; + } + + if (lineAmountUnits <= 0 && lineHours <= 0) continue; + + totalHours += lineHours; + totalAmountUnits += lineAmountUnits; + } + + if (totalAmountUnits <= 0 && totalHours <= 0) return null; + + const bill = buildRolaborBillFields({ + amountUnits: totalAmountUnits, + hours: totalHours, + rate: totalHours > 0 ? totalAmountUnits / totalHours : 0 + }); + const formattedAmount = totalAmountUnits.toFixed(2); + + return { + ops: [ + { + opCode: trimmedOpCode, + jobNo: "1", + custPayTypeFlag: "C", + custTxblNtxblFlag: toFiniteNumber(job?.tax_lbr_rt) > 0 ? "T" : "N", + bill: { + payType, + ...bill + }, + amount: { + payType, + amtType: "Job", + custPrice: formattedAmount, + totalAmt: formattedAmount + } + } + ] + }; +}; + /** * Build RR estimate block from allocation totals. * @param {Array} allocations @@ -326,6 +433,13 @@ const buildRogogFromAllocations = (allocations, { opCode, payType = "Cust", roNo // Each segment becomes its own op / JobNo with a single line segments.forEach((seg, idx) => { const jobNo = String(ops.length + 1); // global, 1-based JobNo across all centers/segments + const isLaborSegment = seg.kind === "laborTaxable" || seg.kind === "laborNonTaxable"; + const segmentHours = isLaborSegment + ? seg.kind === "laborTaxable" + ? toFiniteNumber(alloc.laborTaxableHours) + : toFiniteNumber(alloc.laborNonTaxableHours) + : 0; + const segmentBillRate = isLaborSegment && segmentHours > 0 ? seg.saleCents / 100 / segmentHours : 0; const line = { breakOut, @@ -349,7 +463,9 @@ const buildRogogFromAllocations = (allocations, { opCode, payType = "Cust", roNo // Extra metadata for UI / debugging segmentKind: seg.kind, segmentIndex: idx, - segmentCount + segmentCount, + segmentHours, + segmentBillRate }); }); } @@ -368,9 +484,9 @@ const buildRogogFromAllocations = (allocations, { opCode, payType = "Cust", roNo * * We still keep a 1:1 mapping with GOG ops: each op gets a corresponding * OpCodeLaborInfo entry using the same JobNo and the same tax flag as its - * GOG line. Labor-specific hours/rate remain zeroed out, but actual labor - * sale amounts are mirrored into ROLABOR for labor segments so RR receives - * the expected labor pricing on updates. Non-labor ops remain zeroed. + * GOG line. Labor sale amounts are mirrored into ROLABOR and, when hours + * are available from allocations, weighted bill hours/rates are also + * populated so the labor subsection is editable in Ignite. * * @param {Object} rogg - result of buildRogogFromAllocations * @param {Object} opts @@ -391,6 +507,17 @@ const buildRolaborFromRogog = (rogg, { payType = "Cust" } = {}) => { const linePayType = firstLine.custPayTypeFlag || "C"; const isLaborSegment = op.segmentKind === "laborTaxable" || op.segmentKind === "laborNonTaxable"; const laborAmount = isLaborSegment ? String(firstLine?.amount?.custPrice ?? "0") : "0"; + const laborBill = isLaborSegment + ? buildRolaborBillFields({ + amountUnits: laborAmount, + hours: op.segmentHours, + rate: op.segmentBillRate + }) + : { + jobTotalHrs: "0", + billTime: "0", + billRate: "0" + }; return { opCode: op.opCode, @@ -399,9 +526,7 @@ const buildRolaborFromRogog = (rogg, { payType = "Cust" } = {}) => { custTxblNtxblFlag: txFlag, bill: { payType, - jobTotalHrs: "0", - billTime: "0", - billRate: "0" + ...laborBill }, amount: { payType, @@ -686,5 +811,6 @@ module.exports = { normalizeCustomerCandidates, normalizeVehicleCandidates, buildRogogFromAllocations, - buildRolaborFromRogog + buildRolaborFromRogog, + buildMinimalRolaborFromJob }; diff --git a/server/rr/rr-job-helpers.test.js b/server/rr/rr-job-helpers.test.js new file mode 100644 index 000000000..c3a6ee5f3 --- /dev/null +++ b/server/rr/rr-job-helpers.test.js @@ -0,0 +1,118 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { createRequire } from "node:module"; + +const require = createRequire(import.meta.url); +const mock = require("mock-require"); + +const graphClientModuleId = require.resolve("../graphql-client/graphql-client"); +const queriesModuleId = require.resolve("../graphql-client/queries"); +const helpersModuleId = require.resolve("./rr-job-helpers"); + +const loadHelpers = () => { + mock.stopAll(); + mock(graphClientModuleId, { client: { request: async () => ({}) } }); + mock(queriesModuleId, { GET_JOB_BY_PK: "GET_JOB_BY_PK" }); + delete require.cache[helpersModuleId]; + return require(helpersModuleId); +}; + +afterEach(() => { + mock.stopAll(); + delete require.cache[helpersModuleId]; +}); + +describe("server/rr/rr-job-helpers", () => { + it("builds a single early-RO labor row from aggregated job labor", () => { + const { buildMinimalRolaborFromJob } = loadHelpers(); + + const rolabor = buildMinimalRolaborFromJob( + { + tax_lbr_rt: 13, + joblines: [ + { mod_lbr_ty: "LAB", mod_lb_hrs: 2, lbr_amt: 200 }, + { mod_lbr_ty: "LAD", mod_lb_hrs: 1.5, lbr_amt: 180 } + ] + }, + { opCode: "51DOZ" } + ); + + expect(rolabor).toEqual({ + ops: [ + { + opCode: "51DOZ", + jobNo: "1", + custPayTypeFlag: "C", + custTxblNtxblFlag: "T", + bill: { + payType: "Cust", + jobTotalHrs: "3.5", + billTime: "3.5", + billRate: "108.57" + }, + amount: { + payType: "Cust", + amtType: "Job", + custPrice: "380.00", + totalAmt: "380.00" + } + } + ] + }); + }); + + it("populates labor bill fields from allocation hours on the full RR payload", () => { + const { buildRRRepairOrderPayload } = loadHelpers(); + + const payload = buildRRRepairOrderPayload({ + job: { + id: "job-1", + ro_number: "RO-123", + v_vin: "1HGBH41JXMN109186" + }, + selectedCustomer: { customerNo: "1134485" }, + advisorNo: "70754", + allocations: [ + { + center: "Body Labor", + partsSale: { amount: 0, precision: 2 }, + laborTaxableSale: { amount: 24000, precision: 2 }, + laborNonTaxableSale: { amount: 0, precision: 2 }, + extrasSale: { amount: 0, precision: 2 }, + totalSale: { amount: 24000, precision: 2 }, + cost: { amount: 12000, precision: 2 }, + laborTaxableHours: 2, + laborNonTaxableHours: 0, + profitCenter: { + rr_gogcode: "BL", + rr_item_type: "G", + accountdesc: "BODY LABOR" + } + } + ], + opCode: "51DOZ" + }); + + expect(payload.rolabor).toEqual({ + ops: [ + { + opCode: "51DOZ", + jobNo: "1", + custPayTypeFlag: "C", + custTxblNtxblFlag: "T", + bill: { + payType: "Cust", + jobTotalHrs: "2", + billTime: "2", + billRate: "120.00" + }, + amount: { + payType: "Cust", + amtType: "Job", + custPrice: "240.00", + totalAmt: "240.00" + } + } + ] + }); + }); +});