From 556cd993b95945f3f713d998ec4bb03a5e74d500 Mon Sep 17 00:00:00 2001 From: Dave Date: Wed, 12 Nov 2025 16:13:23 -0500 Subject: [PATCH] feature/IO-3357-Reynolds-and-Reynolds-DMS-API-Integration - Cashiering Checkpoint --- .../dms-customer-selector.component.jsx | 55 +++- client/src/pages/dms/dms.container.jsx | 74 +++++- server/rr/rr-customers.js | 86 ++++++- server/rr/rr-job-export.js | 110 +++++++- server/rr/rr-register-socket-events.js | 241 +++++++++++++++--- server/rr/rr-utils.js | 35 +-- 6 files changed, 510 insertions(+), 91 deletions(-) diff --git a/client/src/components/dms-customer-selector/dms-customer-selector.component.jsx b/client/src/components/dms-customer-selector/dms-customer-selector.component.jsx index 225332756..92b62d95b 100644 --- a/client/src/components/dms-customer-selector/dms-customer-selector.component.jsx +++ b/client/src/components/dms-customer-selector/dms-customer-selector.component.jsx @@ -1,5 +1,5 @@ import { useSplitTreatments } from "@splitsoftware/splitio-react"; -import { Alert, Button, Checkbox, Col, message, Table } from "antd"; +import { Alert, Button, Checkbox, Col, message, Space, Table } from "antd"; import { useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { connect } from "react-redux"; @@ -58,7 +58,14 @@ function rrAddressToString(addr) { return parts.join(", "); } -export function DmsCustomerSelector({ bodyshop, jobid, rrOpenRoLimit = false, onRrOpenRoFinished }) { +export function DmsCustomerSelector({ + bodyshop, + jobid, + rrOpenRoLimit = false, + onRrOpenRoFinished, + rrCashierPending = false, + onRrCashierFinished +}) { const { t } = useTranslation(); const [customerList, setcustomerList] = useState([]); const [open, setOpen] = useState(false); @@ -87,6 +94,14 @@ export function DmsCustomerSelector({ bodyshop, jobid, rrOpenRoLimit = false, on }, [customerList]); const rrHasVinOwner = rrOwnerSet.size > 0; + // If cashiering is pending, surface this banner by opening selector + useEffect(() => { + if (dms === "rr" && rrCashierPending) { + setOpen(true); + setDmsType("rr"); + } + }, [dms, rrCashierPending]); + useEffect(() => { if (dms === "rr") { const handleRrSelectCustomer = (list) => { @@ -160,10 +175,11 @@ export function DmsCustomerSelector({ bodyshop, jobid, rrOpenRoLimit = false, on } if (dmsType === "rr") { - wsssocket.emit("rr-selected-customer", { jobId: jobid, custNo: String(selectedCustomer) }, (ack) => { + // Keep the selector open; server will raise rr-cashiering-required + wsssocket.emit("rr-selected-customer", { jobId: jobid, create: true }, (ack) => { if (ack?.ok) { - setOpen(false); - setSelectedCustomer(null); + message.success(t("dms.messages.customerCreated")); + // Keep dialog open; cashiering banner will appear via `rr-cashiering-required` } else if (ack?.error) { message.error(ack.error); } @@ -200,10 +216,10 @@ export function DmsCustomerSelector({ bodyshop, jobid, rrOpenRoLimit = false, on if (dmsType === "rr" && rrHasVinOwner) return; if (dmsType === "rr") { + // Keep open; server will raise rr-cashiering-required wsssocket.emit("rr-selected-customer", { jobId: jobid, create: true }, (ack) => { if (ack?.ok) { if (ack.custNo) setSelectedCustomer(String(ack.custNo)); - setOpen(false); message.success(t("dms.messages.customerCreated")); } else if (ack?.error) { message.error(ack.error); @@ -388,6 +404,31 @@ export function DmsCustomerSelector({ bodyshop, jobid, rrOpenRoLimit = false, on /> )} + {/* NEW: Cashiering required banner */} + {dmsType === "rr" && rrCashierPending && ( + +
+ We created the Repair Order in Reynolds. Please complete the cashiering/closeout steps in + Reynolds. When done, click Finished/Close to finalize and mark this export as + complete. +
+
+ + + +
+ + } + /> + )} +
- {/* NEW: VIN ownership enforced with Refresh */} + {/* VIN ownership enforced with Refresh */} {dmsType === "rr" && rrHasVinOwner && ( setRrOpenRoLimit(false); + // NEW: RR “cashiering required” UX hold + const [rrCashierPending, setRrCashierPending] = useState(false); + const handleExportFailed = (payload = {}) => { const { title, friendlyMessage, error, severity, errorCode, vendorStatusCode } = payload; @@ -148,7 +151,7 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse }, [t, setBreadcrumbs, setSelectedHeader]); useEffect(() => { - // ✅ RR uses the new wss socket and takes precedence over Fortellis flag + // ✅ RR path uses WSS and has two-step flow if (dms === "rr") { // set log level on connect and immediately wsssocket.emit("set-log-level", logLevel); @@ -165,9 +168,11 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse const handleLogEvent = (payload) => setLogs((prev) => [...prev, payload]); + // FINAL step only (emitted by server after rr-finalize-repair-order) const handleExportSuccess = (payload) => { const jobId = payload?.jobId ?? payload; // RR sends object; legacy sends raw id notification.success({ message: t("jobs.successes.exported") }); + setRrCashierPending(false); insertAuditTrail({ jobid: jobId, operation: AuditTrailMapping.jobexported(), @@ -175,7 +180,43 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse }); history("/manage/accounting/receivables"); }; - const handleRrExportResult = (payload) => handleExportSuccess(payload); + + // STEP 1 result (RO created) – DO NOT navigate; wait for cashiering + const handleRrExportResult = () => { + // Be defensive: if the server didn't already set the banner yet, make it obvious + setRrCashierPending(true); + setLogs((prev) => [ + ...prev, + { + timestamp: new Date(), + level: "INFO", + message: + "Repair Order created in Reynolds. Complete cashiering in Reynolds, then click Finished/Close to finalize." + } + ]); + notification.info({ + message: "Reynolds RO created", + description: + "Complete cashiering in Reynolds, then click Finished/Close to finalize and mark this export complete.", + duration: 8 + }); + // No routing here — we remain on the page for step 2 + }; + + // NEW: cashier step required (after create, before finalize) + const handleCashieringRequired = (payload) => { + setRrCashierPending(true); + setLogs((prev) => [ + ...prev, + { + timestamp: new Date(), + level: "INFO", + message: + "Repair Order created in Reynolds. Complete cashiering in Reynolds, then click Finished/Close to finalize.", + meta: { payload } + } + ]); + }; wsssocket.on("connect", handleConnect); wsssocket.on("reconnect", handleReconnect); @@ -188,6 +229,9 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse wsssocket.on("export-success", handleExportSuccess); wsssocket.on("export-failed", handleExportFailed); + // NEW + wsssocket.on("rr-cashiering-required", handleCashieringRequired); + return () => { wsssocket.off("connect", handleConnect); wsssocket.off("reconnect", handleReconnect); @@ -198,6 +242,8 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse wsssocket.off("export-success", handleExportSuccess); wsssocket.off("export-failed", handleExportFailed); + + wsssocket.off("rr-cashiering-required", handleCashieringRequired); }; } @@ -260,6 +306,20 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse } }, [dms, Fortellis?.treatment, logLevel, history, insertAuditTrail, notification, t, wsssocket]); + // NEW: finalize button callback—emit finalize event + const handleRrCashierFinished = () => { + if (!jobId) return; + wsssocket.emit("rr-finalize-repair-order", { jobId }, (ack) => { + if (ack?.ok) { + // success path handled by export-success listener + return; + } + if (ack?.error) { + notification.error({ message: ack.error }); + } + }); + }; + if (loading) return ; if (error) return ; @@ -299,7 +359,15 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse - + {/* NEW props for two-step RR flow banners */} +
{ + const country = toCountry2(a.country); + return { + ...a, + country, + state: String(a.state || "") + .toUpperCase() + .slice(0, 2), + postalCode: normalizePostal(a.postalCode, country) + }; + }); + + out.phones = (payload.phones || []).map((p) => ({ + ...p, + number: normalizePhone(p.number) + })); + + // trim names defensively (RR has various max lengths by site config) + if (out.firstName) out.firstName = String(out.firstName).trim().slice(0, 30); + if (out.lastName) out.lastName = String(out.lastName).trim().slice(0, 30); + + return out; +} + +/** + * Build an RR client + common opts from a bodyshop row + * @param bodyshop + * @returns {{client: *, opts: {routing: {dealerNumber: *, storeNumber: *, areaNumber: *}, envelope: {sender: {component: string, task: string, referenceId: string, creator: string, senderName: string}}}}} + */ function buildClientAndOpts(bodyshop) { const cfg = getRRConfigFromBodyshop(bodyshop); const client = new RRClient({ @@ -29,19 +92,29 @@ function buildClientAndOpts(bodyshop) { return { client, opts }; } +/** + * Strip all non-digit characters from a string + * @param s + * @returns {string} + */ function digitsOnly(s) { return String(s || "").replace(/\D/g, ""); } +/** + * Return a new array with only unique values from the input array + * @param arr + * @returns {any[]} + */ function uniq(arr) { return Array.from(new Set(arr)); } /** - * Build a payload that matches the RR client expectations for insert/update: - * - ibFlag: 'I' (individual) or 'B' (business). If we have a first name, default to 'I', else 'B' if company present. - * - Must include lastName OR customerName. - * - addresses[] / phones[] / emails[] per the library’s toView() contract. + * Build RR customer payload from job.ownr_* fields, with optional overrides. + * @param job + * @param overrides + * @returns {{ibFlag: string, firstName, lastName, customerName, createdBy, customerType, addresses: [{type, line1: *, line2, city, state, postalCode, country}], phones: {number: *}[], emails: [{address: string}]}} */ function buildCustomerPayloadFromJob(job, overrides = {}) { // Pull ONLY from job.ownr_* fields (no job.customer.*) @@ -114,7 +187,8 @@ async function createRRCustomer({ bodyshop, job, overrides = {}, socket }) { let res; try { - res = await client.insertCustomer(payload, opts); + const safePayload = sanitizeRRCustomerPayload(payload); + res = await client.insertCustomer(safePayload, opts); } catch (e) { log("error", "RR insertCustomer transport error", { message: e?.message, stack: e?.stack, payload }); throw e; diff --git a/server/rr/rr-job-export.js b/server/rr/rr-job-export.js index e4531d230..d9a16fbb7 100644 --- a/server/rr/rr-job-export.js +++ b/server/rr/rr-job-export.js @@ -1,4 +1,3 @@ -// server/rr/rr-job-export.js const { buildRRRepairOrderPayload } = require("./rr-job-helpers"); const { buildClientAndOpts } = require("./rr-lookup"); const { ensureRRServiceVehicle } = require("./rr-service-vehicles"); @@ -8,6 +7,9 @@ const RRLogger = require("./rr-logger"); * Orchestrate an RR export (assumes custNo already resolved): * - Ensure service vehicle (create flows) * - Create or update the Repair Order + * + * NOTE: This function performs the create/update step and returns the RO data. + * "Mark exported" is handled later by the finalize step after cashiering. */ async function exportJobToRR(args) { const { bodyshop, job, advisorNo, selectedCustomer, existing, socket } = args || {}; @@ -31,14 +33,15 @@ async function exportJobToRR(args) { sender: { ...(opts?.envelope?.sender || {}), task: "BSMRO", - referenceId: existing?.dmsRepairOrderId ? "Update" : "Insert" + // If we have an existing RO number we'll be updating, otherwise inserting + referenceId: existing?.roNo || existing?.dmsRoNo || existing?.dmsRepairOrderId ? "Update" : "Insert" } } }; // Ensure service vehicle for create flows (best-effort) let svId = null; - if (!existing?.dmsRepairOrderId) { + if (!(existing?.roNo || existing?.dmsRoNo || existing?.dmsRepairOrderId)) { try { const svRes = await ensureRRServiceVehicle({ bodyshop, @@ -54,19 +57,33 @@ async function exportJobToRR(args) { } } - // Build RO payload (now includes DeptType/departmentType + variants) + // Build RO payload for create/update const payload = buildRRRepairOrderPayload({ job, selectedCustomer: { customerNo: String(selected), custNo: String(selected) }, advisorNo: String(advisorNo) }); - const rrRes = existing?.dmsRepairOrderId - ? await client.updateRepairOrder({ ...payload, dmsRepairOrderId: existing.dmsRepairOrderId }, finalOpts) + // Canonical update key is "roNo" (prefer DMS RO number); accept fallbacks from "existing" + const roNoForUpdate = existing?.roNo || existing?.dmsRoNo || existing?.dmsRepairOrderId || null; + + const rrRes = roNoForUpdate + ? await client.updateRepairOrder({ ...payload, roNo: String(roNoForUpdate) }, finalOpts) // ✅ use roNo on update : await client.createRepairOrder(payload, finalOpts); const data = rrRes?.data || null; const roStatus = data?.roStatus || null; + + // Extract canonical roNo you'll need for finalize step + const roNo = + data?.dmsRoNo ?? + data?.outsdRoNo ?? + roStatus?.dmsRoNo ?? + roStatus?.DMSRoNo ?? + roStatus?.outsdRoNo ?? + roStatus?.OutsdRoNo ?? + null; + return { success: rrRes?.success === true || roStatus?.status === "Success", data, @@ -75,8 +92,85 @@ async function exportJobToRR(args) { xml: rrRes?.xml, parsed: rrRes?.parsed, customerNo: String(selected), - svId + svId, + roNo }; } -module.exports = { exportJobToRR }; +/** + * Finalize an RR Repair Order by sending finalUpdate: "Y". + * The caller should pass the canonical `roNo` if available (prefer DMS RO #). + * If not provided, we *safely* fall back to the external (Outsd) RO number. + */ +async function finalizeRRRepairOrder(args) { + const { bodyshop, job, advisorNo, customerNo, roNo, vin, socket } = args || {}; + const log = RRLogger(socket, { ns: "rr-finalize" }); + + if (!bodyshop) throw new Error("finalizeRRRepairOrder: bodyshop is required"); + if (!job) throw new Error("finalizeRRRepairOrder: job is required"); + if (!advisorNo) throw new Error("finalizeRRRepairOrder: advisorNo is required"); + if (!customerNo) throw new Error("finalizeRRRepairOrder: customerNo is required"); + + // 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"); + + // Prefer DMS RO for update; fall back to external when DMS RO isn't known + const roNoToSend = roNo ? String(roNo) : String(externalRo); + + const { client, opts } = buildClientAndOpts(bodyshop); + const finalOpts = { + ...opts, + envelope: { + ...(opts?.envelope || {}), + sender: { + ...(opts?.envelope?.sender || {}), + task: "BSMRO", + referenceId: "Update" + } + } + }; + + const cleanVin = + (job?.v_vin || vin || "") + .toString() + .replace(/[^A-Za-z0-9]/g, "") + .toUpperCase() + .slice(0, 17) || undefined; + + // IMPORTANT: include "roNo" on updates (RR requires it). Also send outsdRoNo for correlation. + const payload = { + roNo: roNoToSend, // ✅ REQUIRED BY RR on update + outsdRoNo: String(externalRo), + finalUpdate: "Y", + departmentType: "B", + customerNo: String(customerNo), + advisorNo: String(advisorNo), + vin: cleanVin, + mileageIn: job?.kmin, + mileageOut: job?.kmout, + estimate: { estimateType: "Final" } + }; + + log("info", "RR finalize updateRepairOrder", { + roNo: roNoToSend, + outsdRoNo: String(externalRo), + customerNo: String(customerNo), + advisorNo: String(advisorNo) + }); + + const rrRes = await client.updateRepairOrder(payload, finalOpts); + const data = rrRes?.data || null; + const roStatus = data?.roStatus || null; + + return { + success: rrRes?.success === true || roStatus?.status === "Success", + data, + roStatus, + statusBlocks: rrRes?.statusBlocks || [], + xml: rrRes?.xml, + parsed: rrRes?.parsed + }; +} + +module.exports = { exportJobToRR, finalizeRRRepairOrder }; diff --git a/server/rr/rr-register-socket-events.js b/server/rr/rr-register-socket-events.js index a0f78e386..158c19029 100644 --- a/server/rr/rr-register-socket-events.js +++ b/server/rr/rr-register-socket-events.js @@ -1,7 +1,7 @@ const CreateRRLogEvent = require("./rr-logger-event"); const { rrCombinedSearch, rrGetAdvisors, buildClientAndOpts } = require("./rr-lookup"); const { QueryJobData } = require("./rr-job-helpers"); -const { exportJobToRR } = require("./rr-job-export"); +const { exportJobToRR, finalizeRRRepairOrder } = require("./rr-job-export"); const CdkCalculateAllocations = require("../cdk/cdk-calculate-allocations").default; const { createRRCustomer } = require("./rr-customers"); const { ensureRRServiceVehicle } = require("./rr-service-vehicles"); @@ -317,7 +317,7 @@ function registerRREvents({ socket, redisHelpers }) { } }); - // ================= Fortellis-style two-step export ================= + // ================= Fortellis-style two-step export (RR only) ================= // 1) Stage export -> search (Full Name + VIN) -> emit rr-select-customer socket.on("rr-export-job", async ({ jobid, jobId, txEnvelope } = {}) => { const rid = resolveJobId(jobid || jobId, { jobId, jobid }, null); @@ -385,7 +385,7 @@ function registerRREvents({ socket, redisHelpers }) { } }); - // 2) Selection (or create) -> ensure vehicle -> export + // 2) Selection (or create) -> ensure vehicle -> CREATE RO (do not mark exported) socket.on("rr-selected-customer", async ({ jobid, jobId, selectedCustomerId, custNo, create } = {}, ack) => { const rid = resolveJobId(jobid || jobId, { jobid, jobId }, null); let bodyshop = null; @@ -515,7 +515,6 @@ function registerRREvents({ socket, redisHelpers }) { client, routing, bodyshop, - // Normalize for any internal checks: selectedCustomerNo: effectiveCustNo, custNo: effectiveCustNo, customerNo: effectiveCustNo, @@ -532,7 +531,6 @@ function registerRREvents({ socket, redisHelpers }) { const advisorNo = readAdvisorNo({ txEnvelope }, cachedAdvisor); if (!advisorNo) { CreateRRLogEvent(socket, "ERROR", `Advisor is required (advisorNo)`); - // Failure log (no advisor) await insertRRFailedExportLog({ socket, jobId: rid, @@ -552,8 +550,8 @@ function registerRREvents({ socket, redisHelpers }) { defaultRRTTL ); - // Export - CreateRRLogEvent(socket, "DEBUG", `{4} Performing RR export`); + // CREATE/UPDATE (first step only) + CreateRRLogEvent(socket, "DEBUG", `{4} Performing RR create/update (step 1)`); const result = await exportJobToRR({ bodyshop, job, @@ -563,22 +561,60 @@ function registerRREvents({ socket, redisHelpers }) { socket }); - if (result?.success) { - CreateRRLogEvent(socket, "DEBUG", `{5} Export success`, { roStatus: result.roStatus }); + // Cache raw export result + pending RO number for finalize + await redisHelpers.setSessionTransactionData( + socket.id, + ns, + RRCacheEnums.ExportResult, + result || {}, + defaultRRTTL + ); - // ✅ Mark exported + success log (with metadata) - await markRRExportSuccess({ - socket, - jobId: rid, - job, - bodyshop, - result + if (result?.success) { + const data = result?.data || {}; + + // Prefer explicit return from export function; then fall back to fields + const dmsRoNo = + result?.roNo ?? data?.dmsRoNo ?? data?.DMSRoNo ?? data?.roStatus?.dmsRoNo ?? data?.roStatus?.DMSRoNo ?? null; + + const outsdRoNo = + data?.outsdRoNo ?? + data?.OutsdRoNo ?? + data?.roStatus?.outsdRoNo ?? + data?.roStatus?.OutsdRoNo ?? + job?.ro_number ?? + job?.id ?? + null; + + await redisHelpers.setSessionTransactionData( + socket.id, + ns, + RRCacheEnums.PendingRO, + { + outsdRoNo, + dmsRoNo, + customerNo: String(effectiveCustNo), + advisorNo: String(advisorNo), + vin: job?.v_vin || null + }, + defaultRRTTL + ); + + CreateRRLogEvent(socket, "INFO", `{5} RO created. Waiting for cashiering.`, { + dmsRoNo: dmsRoNo || null, + outsdRoNo: outsdRoNo || null }); - socket.emit("export-success", { vendor: "rr", jobId: rid, roStatus: result.roStatus }); - ack?.({ ok: true, result }); + // Tell FE to prompt for "Finished/Close" + socket.emit("rr-cashiering-required", { jobId: rid, dmsRoNo, outsdRoNo }); + + // Still emit info result if you want + socket.emit("rr-export-job:result", { jobId: rid, bodyshopId: bodyshop?.id, result }); + + // ACK but indicate it's pending finalize + ack?.({ ok: true, pendingFinalize: true, dmsRoNo, outsdRoNo, result }); } else { - // NEW: classify vendor status for a friendly FE message + // classify & fail (no finalize) const vendorStatusCode = Number( result?.roStatus?.statusCode ?? result?.roStatus?.StatusCode ?? result?.statusBlocks?.transaction?.statusCode ); @@ -587,12 +623,11 @@ function registerRREvents({ socket, redisHelpers }) { message: result?.roStatus?.message ?? result?.roStatus?.Message ?? result?.error ?? "RR export failed" }); - CreateRRLogEvent(socket, "ERROR", `Export failed`, { + CreateRRLogEvent(socket, "ERROR", `Export failed (step 1)`, { roStatus: result?.roStatus, classification: cls }); - // ❌ Failure log (with classification + bits of response) await insertRRFailedExportLog({ socket, jobId: rid, @@ -609,8 +644,6 @@ function registerRREvents({ socket, redisHelpers }) { error: cls?.friendlyMessage || result?.error || "RR export failed", ...cls }); - // Optional: a user-focused channel if you want to show inline banners - socket.emit("rr-user-notice", { jobId: rid, ...cls }); ack?.({ ok: false, @@ -619,15 +652,6 @@ function registerRREvents({ socket, redisHelpers }) { classification: cls }); } - - await redisHelpers.setSessionTransactionData( - socket.id, - ns, - RRCacheEnums.ExportResult, - result || {}, - defaultRRTTL - ); - socket.emit("rr-export-job:result", { jobId: rid, bodyshopId: bodyshop?.id, result }); } catch (error) { const cls = classifyRRVendorError(error); @@ -640,9 +664,7 @@ function registerRREvents({ socket, redisHelpers }) { jobid: rid }); - // ❌ Failure log for thrown error path try { - // Load bodyshop/job if not loaded yet (best-effort) if (!bodyshop || !job) { const { bodyshopId } = await getSessionOrSocket(redisHelpers, socket); bodyshop = bodyshop || (await getBodyshopForSocket({ bodyshopId, socket })); @@ -651,7 +673,7 @@ function registerRREvents({ socket, redisHelpers }) { (await redisHelpers.getSessionTransactionData(socket.id, getTransactionType(rid), RRCacheEnums.JobData)); } } catch { - // ignore + // } await insertRRFailedExportLog({ @@ -670,10 +692,155 @@ function registerRREvents({ socket, redisHelpers }) { error: error.message, ...cls }); - // Optional UX hook for inline banners/toasts socket.emit("rr-user-notice", { jobId: rid, ...cls }); } catch { - /* ignore */ + // + } + + ack?.({ ok: false, error: cls.friendlyMessage || error.message, classification: cls }); + } + }); + + // 3) Finalize -> updateRepairOrder(finalUpdate: "Y") -> mark exported + socket.on("rr-finalize-repair-order", async ({ jobid, jobId } = {}, ack) => { + const rid = resolveJobId(jobid || jobId, { jobid, jobId }, null); + let bodyshop = null; + let job = null; + + try { + if (!rid) throw new Error("jobid required for finalize"); + + const ns = getTransactionType(rid); + const { bodyshopId } = await getSessionOrSocket(redisHelpers, socket); + bodyshop = await getBodyshopForSocket({ bodyshopId, socket }); + + job = await redisHelpers.getSessionTransactionData(socket.id, ns, RRCacheEnums.JobData); + if (!job) job = await QueryJobData({ redisHelpers }, rid); + + const pending = await redisHelpers.getSessionTransactionData(socket.id, ns, RRCacheEnums.PendingRO); + const advisorNo = await redisHelpers.getSessionTransactionData(socket.id, ns, RRCacheEnums.AdvisorNo); + const selectedCustomerNo = await redisHelpers.getSessionTransactionData( + socket.id, + ns, + RRCacheEnums.SelectedCustomer + ); + + if (!advisorNo) throw new Error("Advisor missing in session"); + if (!selectedCustomerNo) throw new Error("Customer number missing in session"); + + // Prefer cached outsdRoNo; fall back to our deterministic external number + const outsdRoNo = pending?.outsdRoNo ?? job?.ro_number ?? job?.id ?? null; + // Prefer DMS RO for update, but finalize() will safely fall back to Outsd if missing + const dmsRoNo = pending?.dmsRoNo ?? pending?.roNo ?? null; + + CreateRRLogEvent(socket, "DEBUG", `{6} Finalizing RR RO`, { + jobId: rid, + outsdRoNo, + dmsRoNo, + advisorNo, + customerNo: selectedCustomerNo + }); + + const finalizeResult = await finalizeRRRepairOrder({ + bodyshop, + job, + advisorNo: String(advisorNo), + customerNo: String(selectedCustomerNo), + roNo: dmsRoNo, // ✅ RR requires roNo; finalize() will fall back to outsdRoNo if this is absent + vin: pending?.vin, + socket + }); + + if (finalizeResult?.success) { + CreateRRLogEvent(socket, "INFO", `{7} Finalize success; marking exported`, { dmsRoNo, outsdRoNo }); + + // ✅ Mark exported + success log + await markRRExportSuccess({ + socket, + jobId: rid, + job, + bodyshop, + result: finalizeResult + }); + + // Clean pending key + try { + await redisHelpers.setSessionTransactionData(socket.id, ns, RRCacheEnums.PendingRO, null, 1); + } catch { + // + } + + socket.emit("export-success", { vendor: "rr", jobId: rid, roStatus: finalizeResult?.roStatus }); + ack?.({ ok: true, result: finalizeResult }); + } else { + const vendorStatusCode = Number( + finalizeResult?.roStatus?.statusCode ?? + finalizeResult?.roStatus?.StatusCode ?? + finalizeResult?.statusBlocks?.transaction?.statusCode + ); + const cls = classifyRRVendorError({ + code: vendorStatusCode, + message: + finalizeResult?.roStatus?.message ?? + finalizeResult?.roStatus?.Message ?? + finalizeResult?.error ?? + "RR finalize failed" + }); + + await insertRRFailedExportLog({ + socket, + jobId: rid, + job, + bodyshop, + error: new Error(cls.friendlyMessage || finalizeResult?.error || "RR finalize failed"), + classification: cls, + result: finalizeResult + }); + + socket.emit("export-failed", { + vendor: "rr", + jobId: rid, + error: cls?.friendlyMessage || finalizeResult?.error || "RR finalize failed", + ...cls + }); + ack?.({ ok: false, error: cls.friendlyMessage || "RR finalize failed", classification: cls }); + } + } catch (error) { + const cls = classifyRRVendorError(error); + CreateRRLogEvent(socket, "ERROR", `Error during RR finalize`, { + error: error.message, + vendorStatusCode: cls.vendorStatusCode, + code: cls.errorCode, + friendly: cls.friendlyMessage, + stack: error.stack, + jobid: rid + }); + + try { + if (!bodyshop || !job) { + const { bodyshopId } = await getSessionOrSocket(redisHelpers, socket); + bodyshop = bodyshop || (await getBodyshopForSocket({ bodyshopId, socket })); + job = + job || + (await redisHelpers.getSessionTransactionData(socket.id, getTransactionType(rid), RRCacheEnums.JobData)); + } + } catch { + // + } + + await insertRRFailedExportLog({ + socket, + jobId: rid, + job, + bodyshop, + error, + classification: cls + }); + + try { + socket.emit("export-failed", { vendor: "rr", jobId: rid, error: error.message, ...cls }); + } catch { + // } ack?.({ ok: false, error: cls.friendlyMessage || error.message, classification: cls }); diff --git a/server/rr/rr-utils.js b/server/rr/rr-utils.js index 10faf8d8c..7ae801d49 100644 --- a/server/rr/rr-utils.js +++ b/server/rr/rr-utils.js @@ -70,31 +70,6 @@ const normalizeCustomerCandidates = (res, { ownersSet = null } = {}) => { const country = chosen?.Country ?? chosen?.CountryCode ?? chosen?.country ?? undefined; const county = chosen?.County ?? chosen?.county ?? undefined; // << added - // instrumentation (kept minimal; County is now expected) - if ((process.env.RR_DEBUG_ADDR ?? "1") !== "0") { - const allowed = new Set([ - "Type", - "Addr1", - "AddressLine1", - "Line1", - "Street1", - "Addr2", - "AddressLine2", - "Line2", - "Street2", - "City", - "State", - "StateOrProvince", - "Zip", - "PostalCode", - "Country", - "CountryCode", - "County" - ]); - const unknown = Object.keys(chosen || {}).filter((k) => !allowed.has(k)); - if (unknown.length) console.log("[RR:normCandidates] Unexpected address keys seen:", unknown); - } - if (!line1 && !city && !state && !postalCode && !country && !county) return null; return { line1, line2, city, state, postalCode, country, county }; }; @@ -113,7 +88,7 @@ const normalizeCustomerCandidates = (res, { ownersSet = null } = {}) => { const address = pickAddr(nci?.Address); - // NEW: fallback to NameRecId when no ServVehicle/CustomerNo exists (e.g., pure name search) + // fallback to NameRecId when no ServVehicle/CustomerNo exists (e.g., pure name search) const nameRecIdRaw = nci?.NameId?.NameRecId; const nameRecId = nameRecIdRaw != null ? String(nameRecIdRaw).trim() : ""; @@ -136,14 +111,13 @@ const normalizeCustomerCandidates = (res, { ownersSet = null } = {}) => { out.push(item); } } else if (nameRecId) { - // Use NameRecId as the identifier; this is what the RR "name" search provides + // Use NameRecId as the identifier const cno = nameRecId; const item = { custNo: cno, name: name || `Customer ${cno}`, address: address || undefined }; - // owner flag cannot be inferred without a VIN owner set out.push(item); } } @@ -186,7 +160,7 @@ const readAdvisorNo = (payload, cached) => { /** * Cache enum keys for RR session transaction data - * @type {{txEnvelope: string, JobData: string, SelectedCustomer: string, AdvisorNo: string, VINCandidates: string, SelectedVin: string, ExportResult: string}} + * @type {{txEnvelope: string, JobData: string, SelectedCustomer: string, AdvisorNo: string, VINCandidates: string, SelectedVin: string, ExportResult: string, PendingRO: string}} */ const RRCacheEnums = { txEnvelope: "RR.txEnvelope", @@ -195,7 +169,8 @@ const RRCacheEnums = { AdvisorNo: "RR.AdvisorNo", VINCandidates: "RR.VINCandidates", SelectedVin: "RR.SelectedVin", - ExportResult: "RR.ExportResult" + ExportResult: "RR.ExportResult", + PendingRO: "RR.PendingRO" // NEW: cache created RO to finalize later }; /**