feature/IO-3357-Reynolds-and-Reynolds-DMS-API-Integration / RRScratch2 / Checkpoint

This commit is contained in:
Dave
2025-11-24 17:21:33 -05:00
parent b2184a2d11
commit ae7d150a6c
8 changed files with 1052 additions and 225 deletions

View File

@@ -54,7 +54,33 @@ const asN2 = (dineroLike) => {
/**
* Build RO.GOG structure for the reynolds-rome-client `createRepairOrder` payload
* from CDK allocations.
* from allocations.
*
* Supports the new allocation shape:
* {
* center,
* partsSale,
* laborTaxableSale,
* laborNonTaxableSale,
* extrasSale,
* totalSale,
* cost,
* profitCenter,
* costCenter
* }
*
* For each center, we can emit up to 3 GOG *segments*:
* - parts+extras (uses profitCenter.rr_cust_txbl_flag)
* - taxable labor (CustTxblNTxblFlag="T")
* - non-tax labor (CustTxblNTxblFlag="N")
*
* IMPORTANT CHANGE:
* Each segment becomes its OWN JobNo / AllGogOpCodeInfo, with exactly one
* AllGogLineItmInfo inside. This makes the count of:
* - <AllGogOpCodeInfo> (ROGOG)
* - <OpCodeLaborInfo> (ROLABOR)
* match 1:1, and ensures taxable/non-taxable flags line up by JobNo.
*
* @param {Array} allocations
* @param {Object} opts
* @param {string} opts.opCode - RR OpCode for the job (global, overridable)
@@ -67,45 +93,125 @@ 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;
return 0;
};
const asMoneyLike = (amountCents) => ({
amount: amountCents || 0,
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;
const itemType = pc.rr_item_type;
// Only centers that have been configured for RR GOG are included
// Only centers configured for RR GOG are included
if (!breakOut || !itemType) continue;
const saleN2 = asN2(alloc.sale);
const costN2 = asN2(alloc.cost);
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 itemDesc = pc.accountdesc || pc.accountname || alloc.center || "";
const jobNo = String(ops.length + 1); // 1-based JobNo
// Parts + extras share a single segment
const partsExtrasSale = addMoney(partsSale, extrasSale);
ops.push({
opCode,
jobNo,
lines: [
{
breakOut,
itemType,
itemDesc,
custQty: "1.0",
// warrQty: "0.0",
// intrQty: "0.0",
custPayTypeFlag: "C",
// warrPayTypeFlag: "W",
// intrPayTypeFlag: "I",
custTxblNtxblFlag: pc.rr_cust_txbl_flag || "T",
// warrTxblNtxblFlag: "N",
// intrTxblNtxblFlag: "N",
amount: {
payType,
amtType: "Unit",
custPrice: saleN2,
dlrCost: costN2
}
const segments = [];
// 1) Parts + extras segment (respect center's default tax flag)
if (partsExtrasSale && typeof partsExtrasSale.isZero === "function" && !partsExtrasSale.isZero()) {
segments.push({
kind: "partsExtras",
sale: partsExtrasSale,
txFlag: pc.rr_cust_txbl_flag || "T"
});
}
// 2) Taxable labor segment -> "T"
if (laborTaxableSale && typeof laborTaxableSale.isZero === "function" && !laborTaxableSale.isZero()) {
segments.push({
kind: "laborTaxable",
sale: laborTaxableSale,
txFlag: "T"
});
}
// 3) Non-taxable labor segment -> "N"
if (laborNonTaxableSale && typeof laborNonTaxableSale.isZero === "function" && !laborNonTaxableSale.isZero()) {
segments.push({
kind: "laborNonTaxable",
sale: laborNonTaxableSale,
txFlag: "N"
});
}
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);
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 || alloc.center || "";
// NEW: each segment becomes its own op / JobNo with a single line
segments.forEach((seg) => {
const jobNo = String(ops.length + 1); // global, 1-based JobNo across all centers/segments
const line = {
breakOut,
itemType,
itemDesc: itemDescBase,
custQty: "1.0",
custPayTypeFlag: "C",
custTxblNTxblFlag: seg.txFlag || "T",
amount: {
payType,
amtType: "Unit",
custPrice: asN2(seg.sale),
dlrCost: asN2(asMoneyLike(seg.costCents))
}
};
ops.push({
opCode,
jobNo,
lines: [line] // exactly one AllGogLineItmInfo per AllGogOpCodeInfo
});
});
}
@@ -131,16 +237,19 @@ const buildRolaborFromRogog = (rogg, { payType = "Cust" } = {}) => {
const ops = rogg.ops.map((op) => {
const firstLine = op.lines?.[0] || {};
// 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 linePayType = firstLine.custPayTypeFlag || "C";
return {
opCode: op.opCode,
jobNo: op.jobNo,
custPayTypeFlag: firstLine.custPayTypeFlag || "C",
// warrPayTypeFlag: firstLine.warrPayTypeFlag || "W",
// intrPayTypeFlag: firstLine.intrPayTypeFlag || "I",
custTxblNtxblFlag: firstLine.custTxblNtxblFlag || "N",
// warrTxblNtxblFlag: firstLine.warrTxblNtxblFlag || "N",
// intrTxblNtxblFlag: firstLine.intrTxblNtxblFlag || "N",
// vlrCode: undefined,
custPayTypeFlag: linePayType,
// This is the property the Mustache template uses for <CustTxblNTxblFlag>
custTxblNtxblFlag: txFlag,
bill: {
payType,
jobTotalHrs: "0",
@@ -277,24 +386,6 @@ const buildRRRepairOrderPayload = ({
}
}
}
// --- TAX HEADER TEMPORARILY DISABLED ---
// We intentionally do NOT attach payload.tax right now so that the Mustache
// section that renders <TaxCodeInfo> stays false and no TaxCodeInfo is sent.
//
// Keeping this commented-out for future enablement once RR confirms header
// tax handling behaviour.
//
// if (effectiveTaxCode) {
// const taxInfo = buildTaxFromAllocations(allocations, {
// taxCode: effectiveTaxCode,
// payType: "Cust"
// });
//
// if (taxInfo) {
// payload.tax = taxInfo;
// }
// }
}
return payload;
@@ -400,23 +491,49 @@ const normalizeVehicleCandidates = (res) => {
};
/**
* Build a minimal Rolabor structure in the new normalized shape.
*
* Useful for tests or for scenarios where you want a single zero-dollar
* Rolabor op but don't have GOG data. Shape matches payload.rolabor for the
* reynolds-rome-client builders.
*
* @param {Object} opts
* @param {string} opts.opCode
* @param {number|string} [opts.jobNo=1]
* @param {string} [opts.payType="Cust"]
* @returns {null|{ops: Array}}
* Build split labor lines from job allocations.
* @param jobAllocations
* @returns {*[]}
*/
const buildSplitLaborLinesFromAllocations = (jobAllocations) => {
const lines = [];
for (const alloc of jobAllocations || []) {
const { center, laborTaxableSale, laborNonTaxableSale, profitCenter, costCenter } = alloc;
// Taxable labor
if (laborTaxableSale && !laborTaxableSale.isZero()) {
lines.push({
centerName: center,
profitCenter,
costCenter,
amount: laborTaxableSale,
isTaxable: true,
source: "labor"
});
}
// Non-taxable labor
if (laborNonTaxableSale && !laborNonTaxableSale.isZero()) {
lines.push({
centerName: center,
profitCenter,
costCenter,
amount: laborNonTaxableSale,
isTaxable: false,
source: "labor"
});
}
}
return lines;
};
module.exports = {
QueryJobData,
buildRRRepairOrderPayload,
makeCustomerSearchPayloadFromJob,
buildSplitLaborLinesFromAllocations,
makeVehicleSearchPayloadFromJob,
normalizeCustomerCandidates,
normalizeVehicleCandidates,