feature/IO-3357-Reynolds-and-Reynolds-DMS-API-Integration -Full Flow verified

This commit is contained in:
Dave
2025-11-20 14:52:39 -05:00
parent 34f45379a6
commit c3bc29fa9b
7 changed files with 298 additions and 222 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -8,10 +8,24 @@ const getAuthToken = (socket) =>
/** Compact metadata for RR */
const buildRRExportMeta = ({ result, extra = {} }) => {
const roStatus = result?.roStatus || result?.data?.roStatus || null;
const tx = result?.statusBlocks?.transaction;
const rawRoStatus = result?.roStatus || result?.data?.roStatus || null;
const roStatus =
rawRoStatus ||
(tx
? {
status: tx.status ?? tx.Status,
statusCode: tx.statusCode ?? tx.StatusCode,
message: tx.message ?? tx.Message
}
: null);
return {
provider: "rr",
success: Boolean(result?.success || roStatus?.status === "Success"),
success: Boolean(
result?.success || (roStatus && String(roStatus.status || roStatus.Status).toUpperCase() === "SUCCESS")
),
customerNo: result?.customerNo,
svId: result?.svId,
roStatus: roStatus && {
@@ -55,7 +69,12 @@ const buildMessageJSONString = ({ error, classification, result, fallback }) =>
else if (error?.message) push(error.message);
// RR status message
push(result?.roStatus?.message ?? result?.roStatus?.Message);
push(
result?.roStatus?.message ??
result?.roStatus?.Message ??
result?.statusBlocks?.transaction?.message ??
result?.statusBlocks?.transaction?.Message
);
// Fallback
push(fallback || "RR export failed");

View File

@@ -1,5 +1,3 @@
// server/rr/rr-job-exports.js
const { buildRRRepairOrderPayload } = require("./rr-job-helpers");
const { buildClientAndOpts } = require("./rr-lookup");
const CreateRRLogEvent = require("./rr-logger-event");
@@ -7,26 +5,59 @@ 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 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)
*
* Derive RR status information from response object.
* @param rrRes
* @returns {{status: *, statusCode: *|undefined, message}|null}
*/
const deriveRRStatus = (rrRes = {}) => {
const data = rrRes.data || {};
const tx = rrRes.statusBlocks && rrRes.statusBlocks.transaction;
const pick = (obj, ...keys) => {
if (!obj) return undefined;
for (const k of keys) {
if (obj[k] != null) return obj[k];
}
return undefined;
};
let status =
pick(data.roStatus, "status", "Status") || pick(data, "status", "Status") || pick(tx, "status", "Status");
let statusCode =
pick(data.roStatus, "statusCode", "StatusCode") ||
pick(data, "statusCode", "StatusCode") ||
pick(tx, "statusCode", "StatusCode");
let message = pick(data.roStatus, "message", "Message") || data.errorMessage || pick(tx, "message", "Message");
// Last resort: parse from XML if present
if ((!status || !message) && typeof rrRes.xml === "string") {
const m = rrRes.xml.match(/<(?:GenTransStatus|TransStatus)\b([^>]*)>([^<]*)<\/(?:GenTransStatus|TransStatus)>/i);
if (m) {
const attrs = m[1] || "";
const body = (m[2] || "").trim();
const statusMatch = attrs.match(/\bStatus="([^"]*)"/i);
const codeMatch = attrs.match(/\bStatusCode="([^"]*)"/i);
if (!status && statusMatch) status = statusMatch[1];
if (!statusCode && codeMatch) statusCode = codeMatch[1];
if (!message && body) message = body;
}
}
if (!status && !statusCode && !message) return null;
return {
status,
statusCode: statusCode != null && statusCode !== "" ? statusCode : undefined,
message: message || undefined
};
};
/**
* Step 1: Export a job to RR as a new Repair Order.
* @param args
* @returns {Promise<{success, data: *, roStatus: *, statusBlocks, customerNo: string, svId: string|null, roNo: *}>}
* @returns {Promise<{success: boolean, data: *, roStatus: {status: *, statusCode: *|undefined, message}, statusBlocks: *|{}, customerNo: string, svId: *, roNo: *, xml: *}>}
*/
const exportJobToRR = async (args) => {
const { bodyshop, job, advisorNo, selectedCustomer, txEnvelope, socket, svId } = args || {};
@@ -63,77 +94,57 @@ const exportJobToRR = async (args) => {
let rrCentersConfig = null;
let allocations = null;
let opCode = null;
let taxCode = null;
// let taxCode = null;
if (bodyshop.rr_dealerid) {
// 1) Responsibility center config (for visibility / debugging)
try {
rrCentersConfig = extractRrResponsibilityCenters(bodyshop);
// 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
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_configuration?.baseOpCode || "28TOZ"; // 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;
}
CreateRRLogEvent(socket, "SILLY", "RR OP config resolved", {
opCode
});
// Build RO payload for create.
//
// NOTE:
@@ -147,8 +158,7 @@ const exportJobToRR = async (args) => {
story,
makeOverride,
allocations,
opCode,
taxCode
opCode
});
const response = await client.createRepairOrder(payload, finalOpts);
@@ -159,32 +169,42 @@ const exportJobToRR = async (args) => {
});
const data = response?.data || null;
const roStatus = data?.roStatus || null;
const statusBlocks = response?.statusBlocks || {};
const roStatus = deriveRRStatus(response);
const statusUpper = roStatus?.status ? String(roStatus.status).toUpperCase() : null;
let success = false;
if (statusUpper) {
// Treat explicit FAILURE / ERROR as hard failures
success = !["FAILURE", "ERROR"].includes(statusUpper);
} else if (typeof response?.success === "boolean") {
// Fallback to library boolean if no explicit status
success = response.success;
} else if (roStatus?.status) {
success = String(roStatus.status).toUpperCase() === "SUCCESS";
}
// Extract canonical roNo you'll need for finalize step
const roNo = data?.dmsRoNo ?? data?.outsdRoNo ?? roStatus?.dmsRoNo ?? null;
return {
success: response?.success === true || roStatus?.status === "Success",
success,
data,
roStatus,
statusBlocks: response?.statusBlocks || [],
statusBlocks,
customerNo: String(selected),
// svId comes from the earlier ensureRRServiceVehicle call (if the caller passes it)
svId,
roNo
roNo,
xml: response?.xml // expose XML for logging/diagnostics
};
};
/**
* 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).
*
* Step 2: Finalize an existing RR Repair Order (previously created).
* @param args
* @returns {Promise<{success, data: *, roStatus: *, statusBlocks}>}
* @returns {Promise<{success: boolean, data: *, roStatus: {status: *, statusCode: *|undefined, message}, statusBlocks: *|{}, xml: *}>}
*/
const finalizeRRRepairOrder = async (args) => {
const { bodyshop, job, advisorNo, customerNo, roNo, vin, socket } = args || {};
@@ -251,14 +271,27 @@ const finalizeRRRepairOrder = async (args) => {
});
const data = rrRes?.data || null;
const roStatus = data?.roStatus || null;
const statusBlocks = rrRes?.statusBlocks || {};
const roStatus = deriveRRStatus(rrRes);
const statusUpper = roStatus?.status ? String(roStatus.status).toUpperCase() : null;
let success = false;
if (statusUpper) {
success = !["FAILURE", "ERROR"].includes(statusUpper);
} else if (typeof rrRes?.success === "boolean") {
success = rrRes.success;
} else if (roStatus?.status) {
success = String(roStatus.status).toUpperCase() === "SUCCESS";
}
return {
success: rrRes?.success === true || roStatus?.status === "Success",
success,
data,
roStatus,
statusBlocks: rrRes?.statusBlocks || []
statusBlocks,
xml: rrRes?.xml
};
};
module.exports = { exportJobToRR, finalizeRRRepairOrder };
module.exports = { exportJobToRR, finalizeRRRepairOrder, deriveRRStatus };

View File

@@ -161,100 +161,6 @@ const buildRolaborFromRogog = (rogg, { payType = "Cust" } = {}) => {
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 ----------
/**
* Query job data by ID from GraphQL API.
* @param ctx
@@ -301,8 +207,8 @@ const buildRRRepairOrderPayload = ({
story,
makeOverride,
allocations,
opCode,
taxCode
opCode
// taxCode
} = {}) => {
const customerNo = selectedCustomer?.customerNo
? String(selectedCustomer.customerNo).trim()
@@ -353,7 +259,7 @@ const buildRRRepairOrderPayload = ({
if (haveAllocations) {
const effectiveOpCode = (opCode && String(opCode).trim()) || null;
const effectiveTaxCode = (taxCode && String(taxCode).trim()) || null;
// const effectiveTaxCode = (taxCode && String(taxCode).trim()) || null;
if (effectiveOpCode) {
// Build RO.GOG and RO.LABOR in the new normalized shape
@@ -493,6 +399,20 @@ 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}}
*/
module.exports = {
QueryJobData,
buildRRRepairOrderPayload,
@@ -500,9 +420,6 @@ module.exports = {
makeVehicleSearchPayloadFromJob,
normalizeCustomerCandidates,
normalizeVehicleCandidates,
// exporting these so you can unit-test them directly if you want
buildRogogFromAllocations,
buildTaxFromAllocations,
buildRolaborSkeleton,
buildRolaborFromRogog
};

View File

@@ -644,17 +644,28 @@ const registerRREvents = ({ socket, redisHelpers }) => {
});
} else {
// classify & fail (no finalize)
const tx = result?.statusBlocks?.transaction;
const vendorStatusCode = Number(
result?.roStatus?.statusCode ?? result?.roStatus?.StatusCode ?? result?.statusBlocks?.transaction?.statusCode
result?.roStatus?.statusCode ?? result?.roStatus?.StatusCode ?? tx?.statusCode ?? tx?.StatusCode
);
const vendorMessage =
result?.roStatus?.message ??
result?.roStatus?.Message ??
tx?.message ??
tx?.Message ??
result?.error ??
"RR export failed";
const cls = classifyRRVendorError({
code: vendorStatusCode,
message: result?.roStatus?.message ?? result?.roStatus?.Message ?? result?.error ?? "RR export failed"
message: vendorMessage
});
CreateRRLogEvent(socket, "ERROR", `Export failed (step 1)`, {
roStatus: result?.roStatus,
statusBlocks: result?.statusBlocks,
classification: cls
});
@@ -802,18 +813,26 @@ const registerRREvents = ({ socket, redisHelpers }) => {
socket.emit("export-success", { vendor: "rr", jobId: rid, roStatus: finalizeResult?.roStatus });
ack?.({ ok: true, result: finalizeResult });
} else {
const tx = finalizeResult?.statusBlocks?.transaction;
const vendorStatusCode = Number(
finalizeResult?.roStatus?.statusCode ??
finalizeResult?.roStatus?.StatusCode ??
finalizeResult?.statusBlocks?.transaction?.statusCode
tx?.statusCode ??
tx?.StatusCode
);
const vendorMessage =
finalizeResult?.roStatus?.message ??
finalizeResult?.roStatus?.Message ??
tx?.message ??
tx?.Message ??
finalizeResult?.error ??
"RR finalize failed";
const cls = classifyRRVendorError({
code: vendorStatusCode,
message:
finalizeResult?.roStatus?.message ??
finalizeResult?.roStatus?.Message ??
finalizeResult?.error ??
"RR finalize failed"
message: vendorMessage
});
await insertRRFailedExportLog({

View File

@@ -64,6 +64,47 @@ const isAlreadyExistsError = (e) => {
return msg.includes("ALREADY EXISTS") || msg.includes("VEHICLE ALREADY EXISTS");
};
function deriveMakeCode(makeDesc) {
if (!makeDesc) return "FR"; // safe default
const map = {
Ford: "FO",
FORD: "FO",
Lincoln: "LN",
Mercury: "MY",
Chevrolet: "CH",
GMC: "GM",
Cadillac: "CA",
Buick: "BU",
Pontiac: "PO",
Oldsmobile: "OL",
Saturn: "SA",
Dodge: "DO",
Chrysler: "CH",
Jeep: "JE",
Plymouth: "PL",
Toyota: "TY",
Lexus: "LX",
Honda: "HO",
Acura: "AC",
Nissan: "NI",
Infiniti: "IN",
Hyundai: "HY",
Kia: "KI",
Subaru: "SU",
Mazda: "MZ",
Volkswagen: "VW",
Audi: "AU",
BMW: "BM",
Mercedes: "MB",
"Mercedes-Benz": "MB",
Volvo: "VO",
Ram: "RA" // post-2010
};
return map[makeDesc.trim().toUpperCase()] || "FR"; // TODO Default set
}
/**
* Ensure/create a Service Vehicle in RR for the given VIN + customer.
* Args may contain:
@@ -150,10 +191,57 @@ const ensureRRServiceVehicle = async (args = {}) => {
// IMPORTANT: The current RR lib build validates `vehicleServInfo.customerNo`.
// To be future-proof, we also include top-level `customerNo`.
const insertPayload = {
vin: vinStr,
customerNo: custNoStr, // fallback form (some builds accept this)
vehicleServInfo: { customerNo: custNoStr }, // primary form expected by the lib
vehicleDetail: license ? { licNo: String(license).trim() } : undefined
// === Core Vehicle Identity (MANDATORY for success) ===
vin: vinStr.toUpperCase(), // "1FDWX34Y28EB01395"
// Required: 2-character make code (from v_make_desc → known mapping)
vehicleMake: deriveMakeCode(job.v_make_desc), // → "FR" for Ford
// Required: 2-digit year (last 2 digits of v_model_yr)
year: job?.v_model_yr || undefined,
// Required: Model number — fallback strategy per ERA behavior
// Most Ford trucks use "T" = Truck. Some systems accept actual code.
// CAN BE (P)assenger , (T)ruck, (O)ther
mdlNo: undefined,
// === Descriptive Fields (highly recommended) ===
modelDesc: job?.v_model_desc?.trim() || undefined, // "F-350 SD"
carline: job?.v_model_desc?.trim() || undefined, // Series line
extClrDesc: job?.v_color?.trim() || undefined, // "Red"
// Optional but helpful
accentClr: undefined,
// === VehicleDetail Flags (CRITICAL — cause silent fails or error 303 if missing) ===
aircond: undefined, // "Y", // Nearly all modern vehicles have A/C
pwrstr: undefined, // "Y", // Power steering = yes on 99% of vehicles post-1990
transm: undefined, // "A", // Default to Automatic — change to "M" only if known manual
turbo: undefined, //"N", // 2008 F-350 6.4L Power Stroke has turbo, but field is optional
engineConfig: undefined, //"V8", // or "6.4L Diesel" — optional but nice
trim: undefined, //"XLT", // You don't have this — safe to omit or guess
// License plate
licNo: license ? String(license).trim() : undefined,
// === VehicleServInfo (attributes on the element) ===
customerNo: custNoStr, // fallback (some builds read this)
stockId: job.ro_number || undefined, // Use RO as stock# — common pattern
vehicleServInfo: {
customerNo: custNoStr, // REQUIRED — this is what toServiceVehicleView() validates
// Optional but increases success rate
salesmanNo: undefined, // You don't have advisor yet — omit
inServiceDate: undefined,
// Optional — safe to include if you want
productionDate: undefined,
modelMaintCode: undefined,
teamCode: undefined,
// Extended warranty — omit unless you sell contracts
vehExtWarranty: undefined,
// Advisor — omit unless you know who the service advisor is
advisor: undefined
}
};
const insertOpts = {