Checkpoint

This commit is contained in:
Dave
2025-11-17 17:30:52 -05:00
parent 43dc760c95
commit e20ef4374c
3 changed files with 533 additions and 14 deletions

View File

@@ -1,14 +1,30 @@
// server/rr/rr-job-exports.js
const { buildRRRepairOrderPayload } = require("./rr-job-helpers");
const { buildClientAndOpts } = require("./rr-lookup");
const CreateRRLogEvent = require("./rr-logger-event");
const { extractRrResponsibilityCenters } = require("./rr-responsibility-centers");
const CdkCalculateAllocations = require("../cdk/cdk-calculate-allocations").default;
/**
* Step 1: Export a job to Reynolds & Reynolds as a *new* Repair Order.
*
* This is the "create" phase only:
* - We always call createRepairOrder
* - We always call client.createRepairOrder(payload, opts)
* - Any follow-up / finalUpdate is handled by finalizeRRRepairOrder
*
* When in RR mode (bodyshop.rr_dealerid truthy), we also:
* - Extract responsibility center config (for logging / debugging)
* - Run CdkCalculateAllocations to produce the allocations array
* (with profitCenter/costCenter + rr_gogcode / rr_item_type / rr_cust_txbl_flag)
* - Derive a default RR OpCode + TaxCode (if configured)
* - Pass bodyshop, allocations, opCode, taxCode into buildRRRepairOrderPayload
* so it can create the payload fields used by reynolds-rome-client:
* - header fields (CustNo, AdvNo, DeptType, Vin, etc.)
* - RO.rolabor (ops[])
* - RO.rogg (ops[])
* - RO.tax (TaxCodeInfo)
*
* @param args
* @returns {Promise<{success, data: *, roStatus: *, statusBlocks, customerNo: string, svId: string|null, roNo: *}>}
*/
@@ -43,13 +59,96 @@ const exportJobToRR = async (args) => {
const story = txEnvelope?.story ? String(txEnvelope.story).trim() : null;
const makeOverride = txEnvelope?.makeOverride ? String(txEnvelope.makeOverride).trim() : null;
// Build RO payload for create
// RR-only extras
let rrCentersConfig = null;
let allocations = null;
let opCode = null;
let taxCode = null;
if (bodyshop.rr_dealerid) {
// 1) Responsibility center config (for visibility / debugging)
try {
rrCentersConfig = extractRrResponsibilityCenters(bodyshop);
CreateRRLogEvent(socket, "SILLY", "RR responsibility centers resolved", {
hasCenters: !!bodyshop.md_responsibility_centers,
profitCenters: Object.keys(rrCentersConfig?.profitsByName || {}),
costCenters: Object.keys(rrCentersConfig?.costsByName || {}),
dmsCostDefaults: rrCentersConfig?.dmsCostDefaults || {},
dmsProfitDefaults: rrCentersConfig?.dmsProfitDefaults || {}
});
} catch (e) {
CreateRRLogEvent(socket, "ERROR", "Failed to resolve RR responsibility centers", {
message: e?.message,
stack: e?.stack
});
}
// 2) Allocations (sales + cost by center, with rr_* metadata already attached)
try {
allocations = await CdkCalculateAllocations(socket, job.id);
CreateRRLogEvent(socket, "SILLY", "RR allocations resolved", {
hasAllocations: Array.isArray(allocations),
count: Array.isArray(allocations) ? allocations.length : 0
});
} catch (e) {
CreateRRLogEvent(socket, "ERROR", "Failed to calculate RR allocations", {
message: e?.message,
stack: e?.stack
});
allocations = null; // We still proceed with a header-only RO if this fails.
}
// 3) OpCode (global, but overridable)
// - baseOpCode can come from bodyshop or rrCentersConfig (you'll map it in onboarding)
// - txEnvelope can carry an explicit override field (opCode/opcode/op_code)
const baseOpCode =
bodyshop.rr_default_opcode ||
bodyshop.rr_opcode ||
rrCentersConfig?.defaultOpCode ||
rrCentersConfig?.rrDefaultOpCode ||
"51DOZ"; // TODO Change / implement default handling policy
const opCodeOverride = txEnvelope?.opCode || txEnvelope?.opcode || txEnvelope?.op_code || null;
if (opCodeOverride || baseOpCode) {
opCode = String(opCodeOverride || baseOpCode).trim() || null;
}
// 4) TaxCode (for header-level tax, e.g. state/prov tax)
const baseTaxCode =
bodyshop.rr_default_taxcode ||
bodyshop.rr_tax_code ||
rrCentersConfig?.defaultTaxCode ||
rrCentersConfig?.rrDefaultTaxCode ||
"TEST"; // TODO Change / implement default handling policy
if (baseTaxCode) {
taxCode = String(baseTaxCode).trim() || null;
}
CreateRRLogEvent(socket, "SILLY", "RR op/tax config resolved", {
opCode,
taxCode
});
}
// Build RO payload for create.
//
// NOTE:
// - bodyshop + allocations + opCode + taxCode are used only to build the
// value object expected by reynolds-rome-client (header + rogg + rolabor + tax).
const payload = buildRRRepairOrderPayload({
bodyshop,
job,
selectedCustomer: { customerNo: String(selected), custNo: String(selected) },
advisorNo: String(advisorNo),
story,
makeOverride
makeOverride,
allocations,
opCode,
taxCode
});
const response = await client.createRepairOrder(payload, finalOpts);
@@ -81,6 +180,9 @@ const exportJobToRR = async (args) => {
* Step 2: Finalize an RR Repair Order by sending finalUpdate: "Y".
* This is the *update* phase.
*
* We intentionally do NOT send Rogog/Rolabor here — all of that is pushed on
* create; finalize is just a header-level update (FinalUpdate + estimateType).
*
* @param args
* @returns {Promise<{success, data: *, roStatus: *, statusBlocks}>}
*/
@@ -94,7 +196,9 @@ const finalizeRRRepairOrder = async (args) => {
// The external (Outsd) RO is our deterministic fallback and correlation id.
const externalRo = job?.ro_number ?? job?.id;
if (externalRo == null) throw new Error("finalizeRRRepairOrder: outsdRoNo (job.ro_number/id) is required");
if (externalRo == null) {
throw new Error("finalizeRRRepairOrder: outsdRoNo (job.ro_number/id) is required");
}
// Prefer DMS RO for update; fall back to external when DMS RO isn't known
const roNoToSend = roNo ? String(roNo) : String(externalRo);

View File

@@ -34,6 +34,225 @@ const blocksFromCombinedSearchResult = (res) => {
return Array.isArray(data) ? data : [];
};
/**
* Convert a Dinero.js object or number into an "N2" string ("123.45").
* @param value
* @returns {string}
*/
const asN2 = (dineroLike) => {
if (!dineroLike) return "0.00";
// Handle Dinero v1/v2-ish or raw objects
if (typeof dineroLike.toUnit === "function") {
return dineroLike.toUnit().toFixed(2);
}
const precision = dineroLike.precision ?? 2;
const amount = (dineroLike.amount ?? 0) / Math.pow(10, precision);
return amount.toFixed(2);
};
/**
* Build RO.GOG structure for the reynolds-rome-client `createRepairOrder` payload
* from CDK allocations.
* @param {Array} allocations
* @param {Object} opts
* @param {string} opts.opCode - RR OpCode for the job (global, overridable)
* @param {string} [opts.payType="Cust"] - PayType (always "Cust" per Marc)
* @param {string} [opts.roNo] - Optional RoNo to echo on <Rogog RoNo="">
* @returns {null|{roNo?: string, ops: Array}}
*/
const buildRogogFromAllocations = (allocations, { opCode, payType = "Cust", roNo } = {}) => {
if (!Array.isArray(allocations) || !allocations.length || !opCode) return null;
const ops = [];
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
if (!breakOut || !itemType) continue;
const saleN2 = asN2(alloc.sale);
const costN2 = asN2(alloc.cost);
const itemDesc = pc.accountdesc || pc.accountname || alloc.center || "";
const jobNo = String(ops.length + 1); // 1-based JobNo
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
}
}
]
});
}
if (!ops.length) return null;
return {
roNo,
ops
};
};
/**
* Build RO.ROLABOR structure for the reynolds-rome-client `createRepairOrder` payload
* from an already-built RO.GOG structure.
* @param {Object} rogg - result of buildRogogFromAllocations
* @param {Object} opts
* @param {string} [opts.payType="Cust"]
* @returns {null|{ops: Array}}
*/
const buildRolaborFromRogog = (rogg, { payType = "Cust" } = {}) => {
if (!rogg || !Array.isArray(rogg.ops)) return null;
const ops = rogg.ops.map((op) => {
const firstLine = op.lines?.[0] || {};
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,
bill: {
payType,
jobTotalHrs: "0",
billTime: "0",
billRate: "0"
},
amount: {
payType,
amtType: "Job",
custPrice: "0",
totalAmt: "0"
}
};
});
if (!ops.length) return null;
return { ops };
};
/**
* Build a header-level TaxCodeInfo payload from allocations (e.g. PROVINCIAL SALES TAX line).
*
* Shape returned matches what `buildCreateRepairOrder` expects for:
*
* payload.tax = {
* payType,
* taxCode,
* txblGrossAmt,
* grossTaxAmt
* }
*
* NOTE: We are currently NOT wiring this into the payload (see buildRRRepairOrderPayload)
* so that TaxCodeInfo is suppressed in the XML, but we keep this helper around for
* future use.
*
* @param {Array} allocations
* @param {Object} opts
* @param {string} opts.taxCode - RR tax code (configured per dealer)
* @param {string} [opts.payType="Cust"]
* @returns {null|{payType, taxCode, txblGrossAmt, grossTaxAmt}}
*/
const buildTaxFromAllocations = (allocations, { taxCode, payType = "Cust" } = {}) => {
if (!taxCode || !Array.isArray(allocations) || !allocations.length) return null;
const taxAlloc = allocations.find((a) => a && a.tax);
if (!taxAlloc || !taxAlloc.sale) return null;
const grossTaxNum = parseFloat(asN2(taxAlloc.sale));
if (!Number.isFinite(grossTaxNum)) return null;
const rate = typeof taxAlloc.profitCenter?.rate === "number" ? taxAlloc.profitCenter.rate : null;
let taxableGrossNum = grossTaxNum;
if (rate && rate > 0) {
const r = rate / 100;
taxableGrossNum = grossTaxNum / r;
}
return {
payType,
taxCode,
txblGrossAmt: taxableGrossNum.toFixed(2),
grossTaxAmt: grossTaxNum.toFixed(2)
};
};
/**
* 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}}
*/
const buildRolaborSkeleton = ({ opCode, jobNo = 1, payType = "Cust" } = {}) => {
if (!opCode) return null;
return {
ops: [
{
opCode,
jobNo: String(jobNo),
custPayTypeFlag: "C",
warrPayTypeFlag: "W",
intrPayTypeFlag: "I",
custTxblNtxblFlag: "N",
warrTxblNtxblFlag: "N",
intrTxblNtxblFlag: "N",
vlrCode: undefined,
bill: {
payType,
jobTotalHrs: "0",
billTime: "0",
billRate: "0"
},
amount: {
payType,
amtType: "Job",
custPrice: "0",
totalAmt: "0"
}
}
]
};
};
// ---------- Public API ----------
/**
@@ -63,25 +282,38 @@ const QueryJobData = async (ctx = {}, jobId) => {
/**
* Build Repair Order payload for RR from job and customer data.
* @param {Object} args
* @param job
* @param selectedCustomer
* @param advisorNo
* @param story
* @param makeOverride
* @returns {{outsdRoNo: string, repairOrderNumber: string, departmentType: string, vin: string, customerNo: string, advisorNo: string, mileageIn: *|null}}
* @param bodyshop
* @param allocations
* @param {string} [opCode] - RR OpCode for this RO (global default / override)
* @param {string} [taxCode] - RR tax code for header tax (e.g. state/prov code)
* @returns {Object}
*/
const buildRRRepairOrderPayload = ({ job, selectedCustomer, advisorNo, story }) => {
const buildRRRepairOrderPayload = ({
job,
selectedCustomer,
advisorNo,
story,
makeOverride,
allocations,
opCode,
taxCode
} = {}) => {
const customerNo = selectedCustomer?.customerNo
? String(selectedCustomer.customerNo).trim()
: selectedCustomer?.custNo
? String(selectedCustomer.custNo).trim()
: null;
if (!customerNo) throw new Error("No RR customer selected (customerNo/CustNo missing)");
if (!customerNo) throw new Error("No RR customer selected (customerNo/custNo missing)");
const adv = advisorNo != null && String(advisorNo).trim() !== "" ? String(advisorNo).trim() : null;
if (!adv) throw new Error("advisorNo is required for RR export");
if (!adv) throw new Error("advisorNo is required for RR export");
const vinRaw = job?.v_vin;
const vin =
@@ -98,9 +330,9 @@ const buildRRRepairOrderPayload = ({ job, selectedCustomer, advisorNo, story })
const roStr = String(ro);
const output = {
// Base payload shape expected by reynolds-rome-client (buildCreateRepairOrder)
const payload = {
outsdRoNo: roStr,
repairOrderNumber: roStr,
departmentType: "B",
vin,
customerNo: String(customerNo),
@@ -109,10 +341,57 @@ const buildRRRepairOrderPayload = ({ job, selectedCustomer, advisorNo, story })
};
if (story) {
output.roComment = String(story).trim();
payload.roComment = String(story).trim();
}
return output;
if (makeOverride) {
// Passed through so the template can override DMS Make if needed
payload.makeOverride = String(makeOverride).trim();
}
const haveAllocations = Array.isArray(allocations) && allocations.length > 0;
if (haveAllocations) {
const effectiveOpCode = (opCode && String(opCode).trim()) || null;
const effectiveTaxCode = (taxCode && String(taxCode).trim()) || null;
if (effectiveOpCode) {
// Build RO.GOG and RO.LABOR in the new normalized shape
const rogg = buildRogogFromAllocations(allocations, {
opCode: effectiveOpCode,
payType: "Cust"
});
if (rogg) {
payload.rogg = rogg;
const rolabor = buildRolaborFromRogog(rogg, { payType: "Cust" });
if (rolabor) {
payload.rolabor = rolabor;
}
}
}
// --- 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;
};
/**
@@ -220,5 +499,10 @@ module.exports = {
makeCustomerSearchPayloadFromJob,
makeVehicleSearchPayloadFromJob,
normalizeCustomerCandidates,
normalizeVehicleCandidates
normalizeVehicleCandidates,
// exporting these so you can unit-test them directly if you want
buildRogogFromAllocations,
buildTaxFromAllocations,
buildRolaborSkeleton,
buildRolaborFromRogog
};

View File

@@ -0,0 +1,131 @@
// Helpers to use md_responsibility_centers (including RR GOG metadata)
// to build the data needed for ROLABOR + ROGOG lines.
/**
* Extracts responsibility center configuration into a convenient structure.
*
* Expects bodyshop.md_responsibility_centers to look like:
*
* {
* costs: [{ name, ... }],
* profits: [{ name, rr_gogcode, rr_item_type, rr_cust_txbl_flag, ... }],
* dms_defaults: {
* costs: { LAB: "Body Labor Cost", ... },
* profits: { LAB: "Body Labor Sales", ... }
* },
* defaults: {
* costs: { LAB: "Some Cost Center", ... },
* profits: { LAB: "Some Profit Center", ... }
* }
* }
*/
function extractRrResponsibilityCenters(bodyshop = {}) {
const centers = bodyshop.md_responsibility_centers || {};
const { costs = [], profits = [], dms_defaults = {}, defaults = {} } = centers;
const indexByName = (arr = []) =>
(arr || []).reduce((acc, center) => {
if (center && center.name) {
acc[center.name] = center;
}
return acc;
}, {});
return {
costsByName: indexByName(costs),
profitsByName: indexByName(profits),
dmsCostDefaults: (dms_defaults && dms_defaults.costs) || {},
dmsProfitDefaults: (dms_defaults && dms_defaults.profits) || {},
defaultCosts: defaults.costs || {},
defaultProfits: defaults.profits || {}
};
}
/**
* Resolve cost + profit centers for a given internal code (ATS/LAB/etc.).
* Prioritizes dms_defaults, then defaults.
*
* @param {string} code
* @param {ReturnType<typeof extractRrResponsibilityCenters>} cfg
* @returns {{ costCenter: object|null, profitCenter: object|null }}
*/
function resolveCentersForCode(code, cfg) {
if (!code || !cfg) return { costCenter: null, profitCenter: null };
const costCenterName = cfg.dmsCostDefaults[code] || cfg.defaultCosts[code] || null;
const profitCenterName = cfg.dmsProfitDefaults[code] || cfg.defaultProfits[code] || null;
const costCenter = costCenterName && cfg.costsByName[costCenterName] ? cfg.costsByName[costCenterName] : null;
const profitCenter =
profitCenterName && cfg.profitsByName[profitCenterName] ? cfg.profitsByName[profitCenterName] : null;
return { costCenter, profitCenter };
}
/**
* Build a single RR GOG line payload from a jobline + code.
*
* NOTE: This returns a neutral JS object. You still need to map it into
* the exact Mustache / XML shape used for AllGogLineItmInfo.
*
* @param {Object} params
* @param {Object} params.jobline // jobline record from Hasura
* @param {string} params.code // e.g. "LAB", "ATS", ...
* @param {object} params.centersConfig // result of extractRrResponsibilityCenters()
* @param {number} params.sale // CustPrice
* @param {number} params.cost // DollarCost
*/
function buildRrGogLine({ jobline, code, centersConfig, sale, cost }) {
const { profitCenter } = resolveCentersForCode(code, centersConfig);
if (!profitCenter || !profitCenter.rr_gogcode) {
throw new Error('RR: missing rr_gogcode for profit center mapping of code "' + code + '"');
}
return {
// For your own reference:
code,
joblineId: jobline && jobline.id,
// Fields you ultimately care about for ROGOG:
BreakOut: profitCenter.rr_gogcode, // GOG code / BreakOut
ItemType: profitCenter.rr_item_type || "G", // P / G / F / ...
CustTxblNTxblFlag: profitCenter.rr_cust_txbl_flag || "T", // T/N
CustPrice: Number(sale || 0),
DollarCost: Number(cost || 0),
// Surface full profit center in case templates / logs need it:
profitCenter
};
}
/**
* Build a minimal RR labor (ROLABOR) line "shell".
* Amounts are zero if all financials are in GOG.
*
* @param {Object} params
* @param {Object} params.job // job record (for RO header context)
* @param {string} params.opCode // global opcode (already resolved)
* @param {Object} [params.profitCenter] // optional profit center for context
*/
function buildRrLaborLine({ job, opCode, profitCenter }) {
if (!opCode) {
throw new Error("RR: opCode is required to build ROLABOR line");
}
return {
jobId: job && job.id,
OpCode: opCode,
PayType: "Cust", // always Cust per your notes
CustPrice: 0,
DollarCost: 0,
profitCenter
};
}
module.exports = {
extractRrResponsibilityCenters,
resolveCentersForCode,
buildRrGogLine,
buildRrLaborLine
};