From 2b1836d45091b1779df9064aa748c208cdcf701a Mon Sep 17 00:00:00 2001 From: Dave Date: Tue, 25 Nov 2025 14:13:10 -0500 Subject: [PATCH] feature/IO-3357-Reynolds-and-Reynolds-DMS-API-Integration / RRScratch2 / Checkpoint --- .../rr-dms-allocations-summary.component.jsx | 278 ++---------------- server/rr/rr-calculate-allocations.js | 9 +- server/rr/rr-job-helpers.js | 111 ++++--- server/rr/rr-register-socket-events.js | 75 ++++- 4 files changed, 172 insertions(+), 301 deletions(-) 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 bfc388678..96ad3f9a3 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 @@ -4,7 +4,6 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { connect } from "react-redux"; import { createStructuredSelector } from "reselect"; -import Dinero from "dinero.js"; import { selectBodyshop } from "../../redux/user/user.selectors"; @@ -15,54 +14,6 @@ const mapDispatchToProps = () => ({}); export default connect(mapStateToProps, mapDispatchToProps)(RrAllocationsSummary); -function normalizeDineroJson(d) { - if (!d) return null; - - // If it's already a Dinero instance, leave it to the caller - if (typeof d.toUnit === "function") return d; - - // New server shape: { cents: 54144, currency?: "USD" } - if (typeof d.cents === "number") { - return { - amount: d.cents, - precision: 2, - currency: d.currency || "USD" - }; - } - - // Classic Dinero JSON: { amount, precision, currency } - if (typeof d.amount === "number") { - return { - amount: d.amount, - precision: d.precision ?? 2, - currency: d.currency || "USD" - }; - } - - return null; -} - -/** - * Convert a Dinero-like object into an "N2" string ("123.45"). - * Works with real Dinero instances or plain JSON objects - * that have { amount, precision }. - */ -function dineroToN2(dineroLike) { - if (!dineroLike) return "0.00"; - - // If it's an actual Dinero instance - if (typeof dineroLike.toUnit === "function") { - return dineroLike.toUnit().toFixed(2); - } - - const normalized = normalizeDineroJson(dineroLike); - if (!normalized) return "0.00"; - - const { amount, precision = 2 } = normalized; - const value = amount / Math.pow(10, precision); - return value.toFixed(2); -} - /** * Normalize job allocations into a flat list for display / preview building. * @param ack @@ -89,209 +40,20 @@ function normalizeJobAllocations(ack) { })); } -/** - * Build a minimal ROGOG preview from job allocation rows. - * Mirrors the backend buildRogogFromAllocations logic: - * - parts+extras segment - * - taxable labor segment - * - non-taxable labor segment - * Each segment becomes its *own* JobNo with a single GOG line. - */ -function buildRogogPreviewFromJobRows(jobRows, opCode) { - if (!Array.isArray(jobRows) || !jobRows.length || !opCode) return null; - - const ops = []; - - const toDinero = (d) => { - if (!d) return Dinero(); - if (typeof d.toUnit === "function") return d; - - const normalized = normalizeDineroJson(d); - return normalized ? Dinero(normalized) : Dinero(); - }; - - const cents = (d) => toDinero(d).getAmount(); - - const segmentLabelMap = { - partsExtras: "Parts/Extras", - laborTaxable: "Taxable Labor", - laborNonTaxable: "Non-Taxable Labor" - }; - - for (const row of jobRows) { - const pc = row.profitCenter || {}; - const breakOut = pc.rr_gogcode; - const itemType = pc.rr_item_type; - - // Only centers configured for RR GOG should appear - if (!breakOut || !itemType) continue; - - // Bucketed sales (Dinero-like objects coming from the backend) - const partsSale = toDinero(row.partsSale); - const extrasSale = toDinero(row.extrasSale); - const laborTaxableSale = toDinero(row.laborTaxableSale); - const laborNonTaxableSale = toDinero(row.laborNonTaxableSale); - const costMoney = toDinero(row.cost); - - const partsExtrasSale = partsSale.add(extrasSale); - - const segments = []; - - // 1) Parts + extras segment (uses center's default tax flag) - if (!partsExtrasSale.isZero()) { - segments.push({ - kind: "partsExtras", - sale: partsExtrasSale, - txFlag: pc.rr_cust_txbl_flag || "T" - }); - } - - // 2) Taxable labor -> always "T" - if (!laborTaxableSale.isZero()) { - segments.push({ - kind: "laborTaxable", - sale: laborTaxableSale, - txFlag: "T" - }); - } - - // 3) Non-taxable labor -> always "N" - if (!laborNonTaxableSale.isZero()) { - segments.push({ - kind: "laborNonTaxable", - sale: laborNonTaxableSale, - txFlag: "N" - }); - } - - if (!segments.length) continue; - - // Proportionally split cost across segments (same logic as backend) - const totalCostCents = cents(costMoney); - const totalSaleCents = segments.reduce((sum, seg) => sum + cents(seg.sale), 0); - - let remainingCostCents = totalCostCents; - - segments.forEach((seg, idx) => { - let costCents = 0; - - if (totalCostCents > 0 && totalSaleCents > 0) { - if (idx === segments.length - 1) { - // Last segment gets the remainder to avoid rounding drift - costCents = remainingCostCents; - } else { - const segSaleCents = cents(seg.sale); - costCents = Math.round((segSaleCents / totalSaleCents) * totalCostCents); - remainingCostCents -= costCents; - } - } - - seg.costCents = costCents; - }); - - const itemDescBase = pc.accountdesc || pc.accountname || row.center || ""; - - // ๐Ÿ”‘ Each segment becomes its own JobNo with a single GOG line - segments.forEach((seg, segIndex) => { - const jobNo = String(ops.length + 1); // 1-based, global across all centers - const segmentCount = segments.length; - const segmentKind = seg.kind; - const segmentLabel = segmentLabelMap[segmentKind] || segmentKind; - - // If there is a split, annotate the description so itโ€™s obvious which segment this is - const displayDesc = segmentCount > 1 ? `${itemDescBase} (${segmentLabel})` : itemDescBase; - - const saleN2 = dineroToN2(seg.sale); - const costN2 = dineroToN2({ - amount: seg.costCents || 0, - precision: 2 - }); - - ops.push({ - opCode, - jobNo, - segmentKind, - segmentIndex: segIndex, - segmentCount, - lines: [ - { - breakOut, - itemType, - itemDesc: displayDesc, - custQty: "1.0", - custPayTypeFlag: "C", - // canonical property name used on the server - custTxblNTxblFlag: seg.txFlag || "T", - // legacy alias used by the table - custTxblNtxblFlag: seg.txFlag || "T", - amount: { - payType: "Cust", - amtType: "Unit", - custPrice: saleN2, - dlrCost: costN2 - } - } - ] - }); - }); - } - - if (!ops.length) return null; - - return { - roNo: null, // preview only - ops - }; -} - -/** - * Build a minimal ROLABOR preview from a ROGOG preview. - * Mirrors server-side buildRolaborFromRogog. - */ -function buildRolaborPreviewFromRogog(rogg) { - if (!rogg || !Array.isArray(rogg.ops)) return null; - - const ops = rogg.ops.map((op) => { - const firstLine = op.lines?.[0] || {}; - - // Prefer the server-side property name, but fall back to legacy - const txFlag = firstLine.custTxblNTxblFlag ?? firstLine.custTxblNtxblFlag ?? "N"; - const payFlag = firstLine.custPayTypeFlag || "C"; - - return { - opCode: op.opCode, - jobNo: op.jobNo, - custPayTypeFlag: payFlag, - // this is what the table uses - custTxblNtxblFlag: txFlag, - bill: { - payType: "Cust", - jobTotalHrs: "0", - billTime: "0", - billRate: "0" - }, - amount: { - payType: "Cust", - amtType: "Job", - custPrice: "0", - totalAmt: "0" - } - }; - }); - - if (!ops.length) return null; - return { ops }; -} - /** * RR-specific DMS Allocations Summary * Focused on what we actually send to RR: * - ROGOG (split by taxable / non-taxable segments) * - ROLABOR shell + * + * The heavy lifting (ROGOG/ROLABOR split, cost allocation, tax flags) + * is now done on the backend via buildRogogFromAllocations/buildRolaborFromRogog. + * This component just renders the preview from `ack.rogg` / `ack.rolabor`. */ export function RrAllocationsSummary({ socket, bodyshop, jobId, title }) { const { t } = useTranslation(); - const [jobRows, setJobRows] = useState([]); + const [roggPreview, setRoggPreview] = useState(null); + const [rolaborPreview, setRolaborPreview] = useState(null); const [error, setError] = useState(null); const fetchAllocations = useCallback(() => { @@ -300,7 +62,8 @@ export function RrAllocationsSummary({ socket, bodyshop, jobId, title }) { try { socket.emit("rr-calculate-allocations", jobId, (ack) => { if (ack && ack.ok === false) { - setJobRows([]); + setRoggPreview(null); + setRolaborPreview(null); setError(ack.error || t("dms.labels.allocations_error")); if (socket) { socket.allocationsSummary = []; @@ -311,7 +74,8 @@ export function RrAllocationsSummary({ socket, bodyshop, jobId, title }) { const jobAllocRows = normalizeJobAllocations(ack); - setJobRows(jobAllocRows); + setRoggPreview(ack?.rogg || null); + setRolaborPreview(ack?.rolabor || null); setError(null); if (socket) { @@ -320,7 +84,8 @@ export function RrAllocationsSummary({ socket, bodyshop, jobId, title }) { } }); } catch { - setJobRows([]); + setRoggPreview(null); + setRolaborPreview(null); setError(t("dms.labels.allocations_error")); if (socket) { socket.allocationsSummary = []; @@ -334,29 +99,38 @@ export function RrAllocationsSummary({ socket, bodyshop, jobId, title }) { const opCode = bodyshop?.rr_configuration?.baseOpCode || "28TOZ"; - const roggPreview = useMemo(() => buildRogogPreviewFromJobRows(jobRows, opCode), [jobRows, opCode]); - const rolaborPreview = useMemo(() => buildRolaborPreviewFromRogog(roggPreview), [roggPreview]); + const segmentLabelMap = { + partsExtras: "Parts/Extras", + laborTaxable: "Taxable Labor", + laborNonTaxable: "Non-Taxable Labor" + }; const roggRows = useMemo(() => { if (!roggPreview || !Array.isArray(roggPreview.ops)) return []; const rows = []; roggPreview.ops.forEach((op) => { (op.lines || []).forEach((line, idx) => { + const baseDesc = line.itemDesc; + const segmentKind = op.segmentKind; + const segmentCount = op.segmentCount || 0; + const segmentLabel = segmentLabelMap[segmentKind] || segmentKind; + const displayDesc = segmentCount > 1 && segmentLabel ? `${baseDesc} (${segmentLabel})` : baseDesc; + rows.push({ key: `${op.jobNo}-${idx}`, opCode: op.opCode, jobNo: op.jobNo, breakOut: line.breakOut, itemType: line.itemType, - itemDesc: line.itemDesc, + itemDesc: displayDesc, custQty: line.custQty, custPayTypeFlag: line.custPayTypeFlag, custTxblNtxblFlag: line.custTxblNtxblFlag, custPrice: line.amount?.custPrice, dlrCost: line.amount?.dlrCost, // segment metadata for visual styling - segmentKind: op.segmentKind, - segmentCount: op.segmentCount + segmentKind, + segmentCount }); }); }); diff --git a/server/rr/rr-calculate-allocations.js b/server/rr/rr-calculate-allocations.js index 28e954af3..fac0d8c9d 100644 --- a/server/rr/rr-calculate-allocations.js +++ b/server/rr/rr-calculate-allocations.js @@ -1,4 +1,11 @@ -// server/rr/rr-calculate-allocations.js +/** + * THIS IS A COPY of CDKCalculateAllocations, modified to: + * - Only calculate allocations needed for Reynolds & RR exports + * - Keep sales broken down into buckets (parts, taxable labor, non-taxable labor, extras) + * - Add extra logging for easier debugging + * + * Original comments follow. + */ const { GraphQLClient } = require("graphql-request"); const Dinero = require("dinero.js"); diff --git a/server/rr/rr-job-helpers.js b/server/rr/rr-job-helpers.js index f5db45fd2..c85e50ea0 100644 --- a/server/rr/rr-job-helpers.js +++ b/server/rr/rr-job-helpers.js @@ -81,6 +81,9 @@ const asN2 = (dineroLike) => { * - (ROLABOR) * match 1:1, and ensures taxable/non-taxable flags line up by JobNo. * + * We now also attach segmentKind/segmentIndex/segmentCount metadata on each op + * for UI/debug purposes. The XML templates can safely ignore these. + * * @param {Array} allocations * @param {Object} opts * @param {string} opts.opCode - RR OpCode for the job (global, overridable) @@ -93,10 +96,41 @@ const buildRogogFromAllocations = (allocations, { opCode, payType = "Cust", roNo const ops = []; - const cents = (money) => { - if (!money) return 0; - if (typeof money.getAmount === "function") return money.getAmount(); - if (typeof money === "object" && typeof money.amount === "number") return money.amount; + /** + * Normalize various "money-like" shapes to integer cents. + * Supports: + * - Dinero instances (getAmount / toUnit) + * - { cents } + * - { amount, precision } + * - plain numbers (treated as units, e.g. dollars) + */ + const toCents = (value) => { + if (!value) return 0; + + if (typeof value.getAmount === "function") { + return value.getAmount(); + } + + if (typeof value.toUnit === "function") { + const unit = value.toUnit(); + return Number.isFinite(unit) ? Math.round(unit * 100) : 0; + } + + if (typeof value.cents === "number") { + return value.cents; + } + + if (typeof value.amount === "number") { + const precision = typeof value.precision === "number" ? value.precision : 2; + if (precision === 2) return value.amount; + const factor = Math.pow(10, 2 - precision); + return Math.round(value.amount * factor); + } + + if (typeof value === "number") { + return Math.round(value * 100); + } + return 0; }; @@ -105,16 +139,6 @@ const buildRogogFromAllocations = (allocations, { opCode, payType = "Cust", roNo precision: 2 }); - const addMoney = (...ms) => { - let acc = null; - for (const m of ms) { - if (!m) continue; - if (!acc) acc = m; - else if (typeof acc.add === "function") acc = acc.add(m); - } - return acc; - }; - for (const alloc of allocations) { const pc = alloc?.profitCenter || {}; const breakOut = pc.rr_gogcode; @@ -123,40 +147,40 @@ const buildRogogFromAllocations = (allocations, { opCode, payType = "Cust", roNo // Only centers configured for RR GOG are included if (!breakOut || !itemType) continue; - const partsSale = alloc.partsSale || null; - const extrasSale = alloc.extrasSale || null; - const laborTaxableSale = alloc.laborTaxableSale || null; - const laborNonTaxableSale = alloc.laborNonTaxableSale || null; - const costMoney = alloc.cost || null; + const partsCents = toCents(alloc.partsSale); + const extrasCents = toCents(alloc.extrasSale); + const laborTaxableCents = toCents(alloc.laborTaxableSale); + const laborNonTaxableCents = toCents(alloc.laborNonTaxableSale); + const costCents = toCents(alloc.cost); // Parts + extras share a single segment - const partsExtrasSale = addMoney(partsSale, extrasSale); + const partsExtrasCents = partsCents + extrasCents; const segments = []; // 1) Parts + extras segment (respect center's default tax flag) - if (partsExtrasSale && typeof partsExtrasSale.isZero === "function" && !partsExtrasSale.isZero()) { + if (partsExtrasCents !== 0) { segments.push({ kind: "partsExtras", - sale: partsExtrasSale, - txFlag: pc.rr_cust_txbl_flag || "T" + saleCents: partsExtrasCents, + txFlag: pc.rr_cust_txbl_flag || "N" }); } // 2) Taxable labor segment -> "T" - if (laborTaxableSale && typeof laborTaxableSale.isZero === "function" && !laborTaxableSale.isZero()) { + if (laborTaxableCents !== 0) { segments.push({ kind: "laborTaxable", - sale: laborTaxableSale, + saleCents: laborTaxableCents, txFlag: "T" }); } // 3) Non-taxable labor segment -> "N" - if (laborNonTaxableSale && typeof laborNonTaxableSale.isZero === "function" && !laborNonTaxableSale.isZero()) { + if (laborNonTaxableCents !== 0) { segments.push({ kind: "laborNonTaxable", - sale: laborNonTaxableSale, + saleCents: laborNonTaxableCents, txFlag: "N" }); } @@ -164,32 +188,32 @@ const buildRogogFromAllocations = (allocations, { opCode, payType = "Cust", roNo if (!segments.length) continue; // Proportionally split cost across segments based on their sale amounts - const totalCostCents = cents(costMoney); - const totalSaleCents = segments.reduce((sum, seg) => sum + cents(seg.sale), 0); + const totalCostCents = costCents; + const totalSaleCents = segments.reduce((sum, seg) => sum + seg.saleCents, 0); let remainingCostCents = totalCostCents; segments.forEach((seg, idx) => { - let costCents = 0; + let segCost = 0; if (totalCostCents > 0 && totalSaleCents > 0) { if (idx === segments.length - 1) { // Last segment gets the remainder to avoid rounding drift - costCents = remainingCostCents; + segCost = remainingCostCents; } else { - const segSaleCents = cents(seg.sale); - costCents = Math.round((segSaleCents / totalSaleCents) * totalCostCents); - remainingCostCents -= costCents; + segCost = Math.round((seg.saleCents / totalSaleCents) * totalCostCents); + remainingCostCents -= segCost; } } - seg.costCents = costCents; + seg.costCents = segCost; }); const itemDescBase = pc.accountdesc || pc.accountname || alloc.center || ""; + const segmentCount = segments.length; - // NEW: each segment becomes its own op / JobNo with a single line - segments.forEach((seg) => { + // 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 line = { @@ -198,11 +222,11 @@ const buildRogogFromAllocations = (allocations, { opCode, payType = "Cust", roNo itemDesc: itemDescBase, custQty: "1.0", custPayTypeFlag: "C", - custTxblNTxblFlag: seg.txFlag || "T", + custTxblNtxblFlag: seg.txFlag || "N", amount: { payType, amtType: "Unit", - custPrice: asN2(seg.sale), + custPrice: asN2(asMoneyLike(seg.saleCents)), dlrCost: asN2(asMoneyLike(seg.costCents)) } }; @@ -210,7 +234,11 @@ const buildRogogFromAllocations = (allocations, { opCode, payType = "Cust", roNo ops.push({ opCode, jobNo, - lines: [line] // exactly one AllGogLineItmInfo per AllGogOpCodeInfo + lines: [line], // exactly one AllGogLineItmInfo per AllGogOpCodeInfo + // Extra metadata for UI / debugging + segmentKind: seg.kind, + segmentIndex: idx, + segmentCount }); }); } @@ -240,7 +268,7 @@ const buildRolaborFromRogog = (rogg, { payType = "Cust" } = {}) => { // Pull tax flag from the GOG line. // Prefer the property we set in buildRogogFromAllocations (custTxblNTxblFlag), // but also accept custTxblNtxblFlag in case we ever change naming. - const txFlag = firstLine.custTxblNTxblFlag ?? firstLine.custTxblNtxblFlag ?? "N"; + const txFlag = firstLine.custTxblNtxblFlag ?? "N"; const linePayType = firstLine.custPayTypeFlag || "C"; @@ -248,7 +276,6 @@ const buildRolaborFromRogog = (rogg, { payType = "Cust" } = {}) => { opCode: op.opCode, jobNo: op.jobNo, custPayTypeFlag: linePayType, - // This is the property the Mustache template uses for custTxblNtxblFlag: txFlag, bill: { payType, diff --git a/server/rr/rr-register-socket-events.js b/server/rr/rr-register-socket-events.js index 2aff26a64..556fdb6d5 100644 --- a/server/rr/rr-register-socket-events.js +++ b/server/rr/rr-register-socket-events.js @@ -1,8 +1,8 @@ const CreateRRLogEvent = require("./rr-logger-event"); const { rrCombinedSearch, rrGetAdvisors, buildClientAndOpts } = require("./rr-lookup"); -const { QueryJobData } = require("./rr-job-helpers"); +const { QueryJobData, buildRogogFromAllocations, buildRolaborFromRogog } = require("./rr-job-helpers"); const { exportJobToRR, finalizeRRRepairOrder } = require("./rr-job-export"); -const CdkCalculateAllocations = require("./rr-calculate-allocations").default; +const RRCalculateAllocations = require("./rr-calculate-allocations").default; const { createRRCustomer } = require("./rr-customers"); const { ensureRRServiceVehicle } = require("./rr-service-vehicles"); const { classifyRRVendorError } = require("./rr-errors"); @@ -898,10 +898,73 @@ const registerRREvents = ({ socket, redisHelpers }) => { socket.on("rr-calculate-allocations", async (jobid, cb) => { try { CreateRRLogEvent(socket, "DEBUG", "rr-calculate-allocations: begin", { jobid }); - const allocations = await CdkCalculateAllocations(socket, jobid); - cb?.(allocations); - socket.emit("rr-calculate-allocations:result", allocations); - CreateRRLogEvent(socket, "DEBUG", "rr-calculate-allocations: success", { items: allocations?.length }); + + const raw = await RRCalculateAllocations(socket, jobid); + + // If the helper returns an explicit error shape, just pass it through. + if (raw && raw.ok === false) { + cb?.(raw); + socket.emit("rr-calculate-allocations:result", raw); + CreateRRLogEvent(socket, "DEBUG", "rr-calculate-allocations: helper returned error", { + jobid, + error: raw.error + }); + return; + } + + let ack; + let jobAllocations; + + if (Array.isArray(raw)) { + // Legacy shape: plain allocations array + jobAllocations = raw; + ack = { jobAllocations: raw }; + } else { + ack = raw || {}; + jobAllocations = Array.isArray(ack.jobAllocations) ? ack.jobAllocations : []; + } + + // Try to derive OpCode from bodyshop; fall back to default + let opCode = "28TOZ"; + + try { + const { bodyshopId } = await getSessionOrSocket(redisHelpers, socket); + const bodyshop = await getBodyshopForSocket({ bodyshopId, socket }); + opCode = bodyshop?.rr_configuration?.baseOpCode || opCode; + } catch (e) { + CreateRRLogEvent(socket, "WARN", "rr-calculate-allocations: bodyshop lookup failed, using default OpCode", { + error: e.message + }); + } + + let rogg = null; + let rolabor = null; + + try { + rogg = buildRogogFromAllocations(jobAllocations, { opCode, payType: "Cust" }); + if (rogg) { + rolabor = buildRolaborFromRogog(rogg, { payType: "Cust" }); + } + } catch (e) { + CreateRRLogEvent(socket, "WARN", "rr-calculate-allocations: failed to build ROGOG/ROLABOR preview", { + error: e.message + }); + } + + const enriched = { + ...ack, + rogg, + rolabor + }; + + cb?.(enriched); + socket.emit("rr-calculate-allocations:result", enriched); + CreateRRLogEvent(socket, "DEBUG", "rr-calculate-allocations: success", { + jobid, + jobAllocations: jobAllocations.length, + hasRogg: !!rogg, + hasRolabor: !!rolabor + }); } catch (e) { CreateRRLogEvent(socket, "ERROR", "rr-calculate-allocations: failed", { error: e.message, jobid }); cb?.({ ok: false, error: e.message });