diff --git a/server/rr/rr-job-export.js b/server/rr/rr-job-export.js index 15e2f2a34..175780f6b 100644 --- a/server/rr/rr-job-export.js +++ b/server/rr/rr-job-export.js @@ -331,16 +331,12 @@ const updateRRRepairOrderWithFullData = async (args) => { payload.roNo = String(roNo); payload.outsdRoNo = job?.ro_number || job?.id || undefined; - // RR update rejects placeholder non-labor ROLABOR rows with zero labor prices. - // Keep only the actual labor jobs in ROLABOR and let ROGOG carry parts/extras. + // RR update needs a ROLABOR row for every ROGOG JobNo, but rejects zero-price placeholders. + // buildRolaborFromRogog mirrors the GOG price into each row, so keep the full 1:1 set. if (payload.rolabor?.ops?.length && payload.rogg?.ops?.length) { - const laborJobNos = new Set( - payload.rogg.ops - .filter((op) => op?.segmentKind === "laborTaxable" || op?.segmentKind === "laborNonTaxable") - .map((op) => String(op.jobNo)) - ); + const roggJobNos = new Set(payload.rogg.ops.map((op) => String(op.jobNo))); - payload.rolabor.ops = payload.rolabor.ops.filter((op) => laborJobNos.has(String(op?.jobNo))); + payload.rolabor.ops = payload.rolabor.ops.filter((op) => roggJobNos.has(String(op?.jobNo))); if (!payload.rolabor.ops.length) { delete payload.rolabor; diff --git a/server/rr/rr-job-helpers.js b/server/rr/rr-job-helpers.js index 23d003ccf..560e75039 100644 --- a/server/rr/rr-job-helpers.js +++ b/server/rr/rr-job-helpers.js @@ -120,6 +120,22 @@ const formatDecimal = (value, maxDecimals = 2) => { return rounded.toFixed(maxDecimals).replace(/\.?0+$/, "") || "0"; }; +const isLaborSideProfitCenter = (alloc = {}) => { + const pc = alloc?.profitCenter || {}; + + if ( + pc.rr_requires_rolabor || + pc.rr_force_rolabor || + pc.rr_labor_side || + pc.rr_is_labor || + pc.is_labor + ) { + return true; + } + + return [alloc.center, pc.name, pc.accountdesc, pc.accountname].some((value) => /\blabou?r\b/i.test(String(value || ""))); +}; + const buildRolaborBillFields = ({ amountUnits = 0, hours = 0, rate = 0 } = {}) => { const normalizedAmount = toFiniteNumber(amountUnits); @@ -335,6 +351,7 @@ const buildRogogFromAllocations = (allocations, { opCode, payType = "Cust", roNo const pc = alloc?.profitCenter || {}; const breakOut = pc.rr_gogcode; const itemType = pc.rr_item_type; + const laborSideProfitCenter = isLaborSideProfitCenter(alloc); // Only centers configured for RR GOG are included if (!breakOut || !itemType) continue; @@ -434,6 +451,8 @@ const buildRogogFromAllocations = (allocations, { opCode, payType = "Cust", roNo 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 isPartsSegment = seg.kind === "partsTaxable" || seg.kind === "partsNonTaxable"; + const rolaborRequired = isLaborSegment || (isPartsSegment && laborSideProfitCenter); const segmentHours = isLaborSegment ? seg.kind === "laborTaxable" ? toFiniteNumber(alloc.laborTaxableHours) @@ -465,7 +484,8 @@ const buildRogogFromAllocations = (allocations, { opCode, payType = "Cust", roNo segmentIndex: idx, segmentCount, segmentHours, - segmentBillRate + segmentBillRate, + rolaborRequired }); }); } @@ -484,9 +504,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 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. + * GOG line. Sale amounts are mirrored into ROLABOR so Reynolds has a + * non-zero job anchor for every ROGOG JobNo; when labor hours are available + * from allocations, weighted bill hours/rates are also populated. * * @param {Object} rogg - result of buildRogogFromAllocations * @param {Object} opts @@ -506,7 +526,7 @@ 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 laborAmount = String(firstLine?.amount?.custPrice ?? "0"); const laborBill = isLaborSegment ? buildRolaborBillFields({ amountUnits: laborAmount, diff --git a/server/rr/rr-job-helpers.test.js b/server/rr/rr-job-helpers.test.js index c3a6ee5f3..483831ce2 100644 --- a/server/rr/rr-job-helpers.test.js +++ b/server/rr/rr-job-helpers.test.js @@ -115,4 +115,146 @@ describe("server/rr/rr-job-helpers", () => { ] }); }); + + it("mirrors parts assigned to a labor-side RR profit center into ROLABOR", () => { + const { buildRRRepairOrderPayload } = loadHelpers(); + + const payload = buildRRRepairOrderPayload({ + job: { + id: "job-2", + ro_number: "RO-456", + v_vin: "3GCUKHEL3TG292014" + }, + selectedCustomer: { customerNo: "411588" }, + advisorNo: "70754", + allocations: [ + { + center: "Customer Pay CV Labor", + partsSale: { amount: 15000, precision: 2 }, + partsTaxableSale: { amount: 0, precision: 2 }, + partsNonTaxableSale: { amount: 15000, precision: 2 }, + laborTaxableSale: { amount: 0, precision: 2 }, + laborNonTaxableSale: { amount: 0, precision: 2 }, + extrasSale: { amount: 0, precision: 2 }, + extrasTaxableSale: { amount: 0, precision: 2 }, + extrasNonTaxableSale: { amount: 0, precision: 2 }, + totalSale: { amount: 15000, precision: 2 }, + cost: { amount: 0, precision: 2 }, + profitCenter: { + rr_gogcode: "VL", + rr_item_type: "P", + accountdesc: "Customer Pay CV Labor" + } + } + ], + opCode: "30CVZBDY" + }); + + expect(payload.rogg.ops[0]).toMatchObject({ + opCode: "30CVZBDY", + jobNo: "1", + segmentKind: "partsNonTaxable", + rolaborRequired: true, + lines: [ + { + breakOut: "VL", + itemType: "P", + itemDesc: "Customer Pay CV Labor", + custTxblNtxblFlag: "N", + amount: { + custPrice: "150.00", + dlrCost: "0.00" + } + } + ] + }); + + expect(payload.rolabor).toEqual({ + ops: [ + { + opCode: "30CVZBDY", + jobNo: "1", + custPayTypeFlag: "C", + custTxblNtxblFlag: "N", + bill: { + payType: "Cust", + jobTotalHrs: "0", + billTime: "0", + billRate: "0" + }, + amount: { + payType: "Cust", + amtType: "Job", + custPrice: "150.00", + totalAmt: "150.00" + } + } + ] + }); + }); + + it("mirrors regular ROGOG parts into ROLABOR so Reynolds can find the JobNo", () => { + const { buildRRRepairOrderPayload } = loadHelpers(); + + const payload = buildRRRepairOrderPayload({ + job: { + id: "job-3", + ro_number: "CDK10131", + v_vin: "3TMLU4EN1AM044343" + }, + selectedCustomer: { customerNo: "69158" }, + advisorNo: "6224", + allocations: [ + { + center: "B/S PARTS", + partsSale: { amount: 15000, precision: 2 }, + partsTaxableSale: { amount: 15000, precision: 2 }, + partsNonTaxableSale: { amount: 0, precision: 2 }, + laborTaxableSale: { amount: 0, precision: 2 }, + laborNonTaxableSale: { amount: 0, precision: 2 }, + extrasSale: { amount: 0, precision: 2 }, + extrasTaxableSale: { amount: 0, precision: 2 }, + extrasNonTaxableSale: { amount: 0, precision: 2 }, + totalSale: { amount: 15000, precision: 2 }, + cost: { amount: 0, precision: 2 }, + profitCenter: { + rr_gogcode: "FR", + rr_item_type: "G", + accountdesc: "B/S PARTS" + } + } + ], + opCode: "60GMZ" + }); + + expect(payload.rogg.ops[0]).toMatchObject({ + opCode: "60GMZ", + jobNo: "1", + segmentKind: "partsTaxable", + rolaborRequired: false + }); + + expect(payload.rolabor).toEqual({ + ops: [ + { + opCode: "60GMZ", + jobNo: "1", + custPayTypeFlag: "C", + custTxblNtxblFlag: "T", + bill: { + payType: "Cust", + jobTotalHrs: "0", + billTime: "0", + billRate: "0" + }, + amount: { + payType: "Cust", + amtType: "Job", + custPrice: "150.00", + totalAmt: "150.00" + } + } + ] + }); + }); });