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:
@@ -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'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." }}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
};
|
};
|
||||||
|
|||||||
118
server/rr/rr-job-helpers.test.js
Normal file
118
server/rr/rr-job-helpers.test.js
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user