Merged in feature/IO-3647-Reynolds-Integration-Phase-2 (pull request #3191)

feature/IO-3647-Reynolds-Integration-Phase-2 - Enhance early RO with meaningful amounts.
This commit is contained in:
Dave Richer
2026-04-13 15:00:38 +00:00
5 changed files with 337 additions and 80 deletions

View File

@@ -64,7 +64,7 @@ function normalizeJobAllocations(ack) {
* RR-specific DMS Allocations Summary * RR-specific DMS Allocations Summary
* Focused on what we actually send to RR: * Focused on what we actually send to RR:
* - ROGOG (split by taxable / non-taxable segments) * - 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) * The heavy lifting (ROGOG/ROLABOR split, cost allocation, tax flags)
* is now done on the backend via buildRogogFromAllocations/buildRolaborFromRogog. * is now done on the backend via buildRogogFromAllocations/buildRolaborFromRogog.
@@ -181,21 +181,30 @@ export function RrAllocationsSummary({ socket, bodyshop, jobId, title, onAllocat
const rolaborRows = useMemo(() => { const rolaborRows = useMemo(() => {
if (!rolaborPreview || !Array.isArray(rolaborPreview.ops)) return []; if (!rolaborPreview || !Array.isArray(rolaborPreview.ops)) return [];
return rolaborPreview.ops.map((op, idx) => { return rolaborPreview.ops
const rowOpCode = opCode || op.opCode; .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 { return {
key: `${op.jobNo}-${idx}`, key: `${op.jobNo}-${idx}`,
opCode: rowOpCode, opCode: rowOpCode,
jobNo: op.jobNo, jobNo: op.jobNo,
custPayTypeFlag: op.custPayTypeFlag, custPayTypeFlag: op.custPayTypeFlag,
custTxblNtxblFlag: op.custTxblNtxblFlag, custTxblNtxblFlag: op.custTxblNtxblFlag,
payType: op.bill?.payType, payType: op.bill?.payType,
amtType: op.amount?.amtType, jobTotalHrs: op.bill?.jobTotalHrs,
custPrice: op.amount?.custPrice, billTime: op.bill?.billTime,
totalAmt: op.amount?.totalAmt billRate: op.bill?.billRate,
}; amtType: op.amount?.amtType,
}); custPrice: op.amount?.custPrice,
totalAmt: op.amount?.totalAmt
};
});
}, [rolaborPreview, opCode]); }, [rolaborPreview, opCode]);
// Totals for ROGOG (sum custPrice + dlrCost over all lines) // 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: "CustPayType", dataIndex: "custPayTypeFlag", key: "custPayTypeFlag" },
{ title: "CustTxblFlag", dataIndex: "custTxblNtxblFlag", key: "custTxblNtxblFlag" }, { title: "CustTxblFlag", dataIndex: "custTxblNtxblFlag", key: "custTxblNtxblFlag" },
{ title: "PayType", dataIndex: "payType", key: "payType" }, { 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: "AmtType", dataIndex: "amtType", key: "amtType" },
{ title: "CustPrice", dataIndex: "custPrice", key: "custPrice" }, { title: "CustPrice", dataIndex: "custPrice", key: "custPrice" },
{ title: "TotalAmt", dataIndex: "totalAmt", key: "totalAmt" } { title: "TotalAmt", dataIndex: "totalAmt", key: "totalAmt" }
@@ -317,12 +329,13 @@ export function RrAllocationsSummary({ socket, bodyshop, jobId, title, onAllocat
children: ( children: (
<> <>
<Typography.Paragraph type="secondary" style={{ marginBottom: 8 }}> <Typography.Paragraph type="secondary" style={{ marginBottom: 8 }}>
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&apos;s labor lines.
</Typography.Paragraph> </Typography.Paragraph>
<ResponsiveTable <ResponsiveTable
pagination={false} pagination={false}
columns={rolaborColumns} columns={rolaborColumns}
mobileColumnKeys={["jobNo", "opCode", "breakOut", "itemType"]} mobileColumnKeys={["jobNo", "opCode", "billRate", "custPrice"]}
rowKey="key" rowKey="key"
dataSource={rolaborRows} dataSource={rolaborRows}
locale={{ emptyText: "No ROLABOR lines would be generated." }} locale={{ emptyText: "No ROLABOR lines would be generated." }}

View File

@@ -46,6 +46,11 @@ const summarizeAllocationsArray = (arr) =>
cost: summarizeMoney(a.cost) 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*. * Internal per-center bucket shape for *sales*.
* We keep separate buckets for RR so we can split * We keep separate buckets for RR so we can split
@@ -62,6 +67,8 @@ function emptyCenterBucket() {
// Labor // Labor
laborTaxableSale: zero, // labor that should be taxed in RR laborTaxableSale: zero, // labor that should be taxed in RR
laborNonTaxableSale: zero, // labor that should NOT be taxed in RR laborNonTaxableSale: zero, // labor that should NOT be taxed in RR
laborTaxableHours: 0,
laborNonTaxableHours: 0,
// Extras (MAPA/MASH/towing/PAO/etc) // Extras (MAPA/MASH/towing/PAO/etc)
extrasSale: zero, // total extras (taxable + non-taxable) 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 rateKey = `rate_${val.mod_lbr_ty.toLowerCase()}`;
const rate = job[rateKey]; const rate = job[rateKey];
const lineHours = toFiniteNumber(val.mod_lb_hrs);
const laborAmount = Dinero({ const laborAmount = Dinero({
amount: Math.round(rate * 100) amount: Math.round(rate * 100)
@@ -460,8 +468,10 @@ function buildProfitCenterHash(job, debugLog, taxContext) {
if (isLaborTaxable(val, taxContext)) { if (isLaborTaxable(val, taxContext)) {
bucket.laborTaxableSale = bucket.laborTaxableSale.add(laborAmount); bucket.laborTaxableSale = bucket.laborTaxableSale.add(laborAmount);
bucket.laborTaxableHours += lineHours;
} else { } else {
bucket.laborNonTaxableSale = bucket.laborNonTaxableSale.add(laborAmount); bucket.laborNonTaxableSale = bucket.laborNonTaxableSale.add(laborAmount);
bucket.laborNonTaxableHours += lineHours;
} }
} }
@@ -478,6 +488,8 @@ function buildProfitCenterHash(job, debugLog, taxContext) {
partsNonTaxable: summarizeMoney(b.partsNonTaxableSale), partsNonTaxable: summarizeMoney(b.partsNonTaxableSale),
laborTaxable: summarizeMoney(b.laborTaxableSale), laborTaxable: summarizeMoney(b.laborTaxableSale),
laborNonTaxable: summarizeMoney(b.laborNonTaxableSale), laborNonTaxable: summarizeMoney(b.laborNonTaxableSale),
laborTaxableHours: b.laborTaxableHours,
laborNonTaxableHours: b.laborNonTaxableHours,
extras: summarizeMoney(b.extrasSale), extras: summarizeMoney(b.extrasSale),
extrasTaxable: summarizeMoney(b.extrasTaxableSale), extrasTaxable: summarizeMoney(b.extrasTaxableSale),
extrasNonTaxable: summarizeMoney(b.extrasNonTaxableSale) extrasNonTaxable: summarizeMoney(b.extrasNonTaxableSale)
@@ -916,6 +928,8 @@ function buildJobAllocations(bodyshop, profitCenterHash, costCenterHash, debugLo
// Labor // Labor
laborTaxableSale: bucket.laborTaxableSale, laborTaxableSale: bucket.laborTaxableSale,
laborNonTaxableSale: bucket.laborNonTaxableSale, laborNonTaxableSale: bucket.laborNonTaxableSale,
laborTaxableHours: bucket.laborTaxableHours,
laborNonTaxableHours: bucket.laborNonTaxableHours,
// Extras // Extras
extrasSale, extrasSale,

View File

@@ -1,4 +1,4 @@
const { buildRRRepairOrderPayload } = require("./rr-job-helpers"); const { buildRRRepairOrderPayload, buildMinimalRolaborFromJob } = require("./rr-job-helpers");
const { buildClientAndOpts } = require("./rr-lookup"); const { buildClientAndOpts } = require("./rr-lookup");
const CreateRRLogEvent = require("./rr-logger-event"); const CreateRRLogEvent = require("./rr-logger-event");
const { withRRRequestXml } = require("./rr-log-xml"); 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). * 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. * 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 story = txEnvelope?.story ? String(txEnvelope.story).trim() : null;
const makeOverride = txEnvelope?.makeOverride ? String(txEnvelope.makeOverride).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 = const cleanVin =
(job?.v_vin || "") (job?.v_vin || "")
.toString() .toString()
@@ -116,6 +139,12 @@ const createMinimalRRRepairOrder = async (args) => {
resolvedMileageIn: mileageIn resolvedMileageIn: mileageIn
}); });
const earlyRoOpCode = resolveRROpCode(bodyshop, txEnvelope);
const earlyRoLabor = buildMinimalRolaborFromJob(job, {
opCode: earlyRoOpCode,
payType: "Cust"
});
const payload = { const payload = {
customerNo: String(selected), customerNo: String(selected),
advisorNo: String(advisorNo), advisorNo: String(advisorNo),
@@ -141,9 +170,14 @@ const createMinimalRRRepairOrder = async (args) => {
if (makeOverride) { if (makeOverride) {
payload.makeOverride = makeOverride; payload.makeOverride = makeOverride;
} }
if (earlyRoLabor) {
payload.rolabor = earlyRoLabor;
}
CreateRRLogEvent(socket, "INFO", "Creating minimal RR Repair Order (early creation)", { CreateRRLogEvent(socket, "INFO", "Creating minimal RR Repair Order (early creation)", {
payload payload,
earlyRoOpCode,
hasRolabor: !!earlyRoLabor
}); });
const response = await client.createRepairOrder(payload, finalOpts); 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 story = txEnvelope?.story ? String(txEnvelope.story).trim() : null;
const makeOverride = txEnvelope?.makeOverride ? String(txEnvelope.makeOverride).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 // RR-only extras
let rrCentersConfig = null; let rrCentersConfig = null;
let allocations = null; let allocations = null;
let opCode = null; const opCode = resolveRROpCode(bodyshop, txEnvelope);
// 1) Responsibility center config (for visibility / debugging) // 1) Responsibility center config (for visibility / debugging)
try { try {
@@ -280,28 +309,9 @@ const updateRRRepairOrderWithFullData = async (args) => {
allocations = []; 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", { CreateRRLogEvent(socket, "SILLY", "RR OP config resolved", {
opCode, opCode,
baseFromConfig: resolvedBaseOpCode, baseFromConfig: resolveRROpCodeFromBodyshop(bodyshop)
opPrefix,
opBase,
opSuffix
}); });
// Build full RO payload for update with allocations // 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 story = txEnvelope?.story ? String(txEnvelope.story).trim() : null;
const makeOverride = txEnvelope?.makeOverride ? String(txEnvelope.makeOverride).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 // RR-only extras
let rrCentersConfig = null; let rrCentersConfig = null;
let allocations = null; let allocations = null;
let opCode = null; const opCode = resolveRROpCode(bodyshop, txEnvelope);
// 1) Responsibility center config (for visibility / debugging) // 1) Responsibility center config (for visibility / debugging)
try { try {
@@ -477,28 +482,9 @@ const exportJobToRR = async (args) => {
allocations = []; 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", { CreateRRLogEvent(socket, "SILLY", "RR OP config resolved", {
opCode, opCode,
baseFromConfig: resolvedBaseOpCode, baseFromConfig: resolveRROpCodeFromBodyshop(bodyshop)
opPrefix,
opBase,
opSuffix
}); });
// Build RO payload for create. // Build RO payload for create.

View File

@@ -52,6 +52,19 @@ const asN2 = (dineroLike) => {
return amount.toFixed(2); 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. * Normalize various "money-like" shapes to integer cents.
* Supports: * Supports:
@@ -100,6 +113,100 @@ const toMoneyCents = (value) => {
const asN2FromCents = (cents) => asN2({ amount: Number.isFinite(cents) ? cents : 0, precision: 2 }); 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. * Build RR estimate block from allocation totals.
* @param {Array} allocations * @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 // Each segment becomes its own op / JobNo with a single line
segments.forEach((seg, idx) => { segments.forEach((seg, idx) => {
const jobNo = String(ops.length + 1); // global, 1-based JobNo across all centers/segments 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 = { const line = {
breakOut, breakOut,
@@ -349,7 +463,9 @@ const buildRogogFromAllocations = (allocations, { opCode, payType = "Cust", roNo
// Extra metadata for UI / debugging // Extra metadata for UI / debugging
segmentKind: seg.kind, segmentKind: seg.kind,
segmentIndex: idx, 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 * 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 * 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 * GOG line. Labor sale amounts are mirrored into ROLABOR and, when hours
* sale amounts are mirrored into ROLABOR for labor segments so RR receives * are available from allocations, weighted bill hours/rates are also
* the expected labor pricing on updates. Non-labor ops remain zeroed. * populated so the labor subsection is editable in Ignite.
* *
* @param {Object} rogg - result of buildRogogFromAllocations * @param {Object} rogg - result of buildRogogFromAllocations
* @param {Object} opts * @param {Object} opts
@@ -391,6 +507,17 @@ const buildRolaborFromRogog = (rogg, { payType = "Cust" } = {}) => {
const linePayType = firstLine.custPayTypeFlag || "C"; const linePayType = firstLine.custPayTypeFlag || "C";
const isLaborSegment = op.segmentKind === "laborTaxable" || op.segmentKind === "laborNonTaxable"; const isLaborSegment = op.segmentKind === "laborTaxable" || op.segmentKind === "laborNonTaxable";
const laborAmount = isLaborSegment ? String(firstLine?.amount?.custPrice ?? "0") : "0"; 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 { return {
opCode: op.opCode, opCode: op.opCode,
@@ -399,9 +526,7 @@ const buildRolaborFromRogog = (rogg, { payType = "Cust" } = {}) => {
custTxblNtxblFlag: txFlag, custTxblNtxblFlag: txFlag,
bill: { bill: {
payType, payType,
jobTotalHrs: "0", ...laborBill
billTime: "0",
billRate: "0"
}, },
amount: { amount: {
payType, payType,
@@ -686,5 +811,6 @@ module.exports = {
normalizeCustomerCandidates, normalizeCustomerCandidates,
normalizeVehicleCandidates, normalizeVehicleCandidates,
buildRogogFromAllocations, buildRogogFromAllocations,
buildRolaborFromRogog buildRolaborFromRogog,
buildMinimalRolaborFromJob
}; };

View File

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