diff --git a/server/rr/rr-job-export.js b/server/rr/rr-job-export.js index 13a3daaf6..b58e8889e 100644 --- a/server/rr/rr-job-export.js +++ b/server/rr/rr-job-export.js @@ -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); diff --git a/server/rr/rr-job-helpers.js b/server/rr/rr-job-helpers.js index 42323711b..9e9d34f17 100644 --- a/server/rr/rr-job-helpers.js +++ b/server/rr/rr-job-helpers.js @@ -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 + * @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 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 }; diff --git a/server/rr/rr-responsibility-centers.js b/server/rr/rr-responsibility-centers.js new file mode 100644 index 000000000..f34237b98 --- /dev/null +++ b/server/rr/rr-responsibility-centers.js @@ -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} 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 +};