feature/IO-3672-Reynolds-Adjustments-V3 - Make sure there is never a scenario where a ROGOG does not have a ROLABOR

This commit is contained in:
Dave
2026-05-04 16:31:41 -04:00
parent de6038038a
commit 32e67b14b6
3 changed files with 171 additions and 13 deletions

View File

@@ -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;

View File

@@ -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,

View File

@@ -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"
}
}
]
});
});
});