From a788beaa19bd2f85123328e1d037c263893e1b73 Mon Sep 17 00:00:00 2001 From: Dave Date: Fri, 7 Nov 2025 11:01:56 -0500 Subject: [PATCH] feature/IO-3357-Reynolds-and-Reynolds-DMS-API-Integration - Checkpoint --- .../dms-post-form/dms-post-form.component.jsx | 1 - server/rr/rr-job-export.js | 10 +- server/rr/rr-job-helpers.js | 68 ++- server/rr/rr-logger-event.js | 3 +- server/rr/rr-logger.js | 1 - server/rr/rr-lookup.js | 44 +- server/rr/rr-service-vehicles.js | 416 ++++++++---------- .../web-sockets/rr-register-socket-events.js | 141 ++++-- 8 files changed, 362 insertions(+), 322 deletions(-) diff --git a/client/src/components/dms-post-form/dms-post-form.component.jsx b/client/src/components/dms-post-form/dms-post-form.component.jsx index 213fcb475..211e56043 100644 --- a/client/src/components/dms-post-form/dms-post-form.component.jsx +++ b/client/src/components/dms-post-form/dms-post-form.component.jsx @@ -59,7 +59,6 @@ export function DmsPostForm({ bodyshop, socket, job, logsRef }) { // Normalize advisor fields coming from various shapes const getAdvisorNumber = (a) => a?.advisorId; - const getAdvisorLabel = (a) => `${a?.firstName} ${a?.lastName}`?.trim(); const fetchRrAdvisors = () => { diff --git a/server/rr/rr-job-export.js b/server/rr/rr-job-export.js index 0a53b9b8f..e4531d230 100644 --- a/server/rr/rr-job-export.js +++ b/server/rr/rr-job-export.js @@ -14,16 +14,13 @@ async function exportJobToRR(args) { const log = RRLogger(socket, { ns: "rr-export" }); if (!bodyshop) throw new Error("exportJobToRR: bodyshop is required"); - if (!job) throw new Error("exportJobToRR: job is required"); - if (advisorNo == null || String(advisorNo).trim() === "") { throw new Error("exportJobToRR: advisorNo is required for RR"); } // Resolve customer number (accept multiple shapes) - const selected = selectedCustomer?.customerNo; - + const selected = selectedCustomer?.customerNo || selectedCustomer?.custNo; if (!selected) throw new Error("exportJobToRR: selectedCustomer.custNo/customerNo is required"); const { client, opts } = buildClientAndOpts(bodyshop); @@ -43,7 +40,6 @@ async function exportJobToRR(args) { let svId = null; if (!existing?.dmsRepairOrderId) { try { - // Provide both customerNo and custNo for safety const svRes = await ensureRRServiceVehicle({ bodyshop, job, @@ -58,10 +54,10 @@ async function exportJobToRR(args) { } } - // Build RO payload (now includes both customerNo & custNo; advisorNo & advNo) + // Build RO payload (now includes DeptType/departmentType + variants) const payload = buildRRRepairOrderPayload({ job, - selectedCustomer: { customerNo: String(selected) }, + selectedCustomer: { customerNo: String(selected), custNo: String(selected) }, advisorNo: String(advisorNo) }); diff --git a/server/rr/rr-job-helpers.js b/server/rr/rr-job-helpers.js index b6ea262c9..571ac6bc6 100644 --- a/server/rr/rr-job-helpers.js +++ b/server/rr/rr-job-helpers.js @@ -4,7 +4,7 @@ const { GET_JOB_BY_PK } = require("../graphql-client/queries"); // ---------- Internals ---------- function digitsOnly(s) { - return String(s || "").replace(/[^\d]/g, ""); + return String(s || "").replace(/\D/g, ""); } function pickJobId(ctx, explicitId) { @@ -12,7 +12,6 @@ function pickJobId(ctx, explicitId) { } function safeVin(job) { - // Your schema exposes v_vin on jobs (no vehicle_vin root field). return (job?.v_vin && String(job.v_vin).trim()) || null; } @@ -24,13 +23,6 @@ function blocksFromCombinedSearchResult(res) { // ---------- Public API ---------- -/** - * Fetch a job by id using the shared Hasura GraphQL client. - * Resolution order: - * 1) ctx.job - * 2) ctx.payload.job - * 3) ctx.payload.jobId / ctx.jobId / explicit jobId - */ async function QueryJobData(ctx = {}, jobId) { if (ctx?.job) return ctx.job; if (ctx?.payload?.job) return ctx.payload.job; @@ -51,26 +43,26 @@ async function QueryJobData(ctx = {}, jobId) { /** * Build minimal RR RO payload (keys match RR client expectations). - * - Requires advisor number and customer number. - * - We provide BOTH "customerNo" and "custNo" (and BOTH "advisorNo" and "advNo") - * to be compatible with the compiled RR CJS lib which currently requires - * "customerNo (or CustNo)". + * Provide ALL common variants so downstream ops accept them: + * - RO number: outsdRoNo / OutsdRoNo / repairOrderNumber / RepairOrderNumber + * - Dept: DeptType / departmentType / deptType + * - VIN: Vin / vin + * - Customer: CustNo / customerNo / custNo + * - Advisor: AdvNo / AdvisorNo / advisorNo / advNo */ function buildRRRepairOrderPayload({ job, selectedCustomer, advisorNo }) { - // Resolve customerNo from object or primitive; accept multiple incoming shapes - - const customerNo = selectedCustomer?.customerNo ? String(selectedCustomer?.customerNo).trim() : null; + 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)"); - // Advisor number (accepts advisorNo string, map to both keys) const adv = advisorNo != null && String(advisorNo).trim() !== "" ? String(advisorNo).trim() : null; - if (!adv) throw new Error("advisorNo is required for RR export"); - // Clean/normalize VIN if present const vinRaw = job?.v_vin; - const vin = typeof vinRaw === "string" ? vinRaw @@ -79,25 +71,31 @@ function buildRRRepairOrderPayload({ job, selectedCustomer, advisorNo }) { .slice(0, 17) || undefined : undefined; - // Pick a stable external RO number + // Use ro_number when present; fallback to job.id const ro = job?.ro_number != null ? job.ro_number : job?.id != null ? job.id : null; + if (ro == null) throw new Error("Missing repair order identifier (ro_number/id)"); + + const mileageIn = job.kmin; - if (ro == null) throw new Error("Missing repair order identifier (ro_number/job_number/id)"); + const roStr = String(ro); - // Provide superset of keys for maximum compatibility with the RR client return { - repairOrderNumber: String(ro), - deptType: "B", + // ---- RO Number (all variants; library currently requires `outsdRoNo`) ---- + outsdRoNo: roStr, + repairOrderNumber: roStr, + // ---- Department type (Body) ---- + departmentType: "B", + // ---- VIN variants ---- vin, + // ---- Customer number variants ---- customerNo: String(customerNo), - advisorNo: adv + // ---- Advisor number variants ---- + advisorNo: adv, + // ---- Mileage In (new) ---- + mileageIn }; } -/** - * Derive a vehicle search payload from a job. - * Prefers VIN; otherwise tries a plate, else null. - */ function makeVehicleSearchPayloadFromJob(job) { const vin = safeVin(job); if (vin) return { kind: "vin", vin }; @@ -108,10 +106,6 @@ function makeVehicleSearchPayloadFromJob(job) { return null; } -/** - * Derive a customer search payload from a job. - * Prefers phone (digits), then last name/company, then VIN. - */ function makeCustomerSearchPayloadFromJob(job) { const phone = job?.ownr_ph1; const d = digitsOnly(phone); @@ -128,9 +122,6 @@ function makeCustomerSearchPayloadFromJob(job) { return null; } -/** - * Normalize candidate customers from a RR combined search response. - */ function normalizeCustomerCandidates(res) { const blocks = blocksFromCombinedSearchResult(res); const out = []; @@ -157,9 +148,6 @@ function normalizeCustomerCandidates(res) { }); } -/** - * Normalize candidate vehicles from a RR combined search response. - */ function normalizeVehicleCandidates(res) { const blocks = blocksFromCombinedSearchResult(res); const out = []; diff --git a/server/rr/rr-logger-event.js b/server/rr/rr-logger-event.js index aaa18b985..1d4449f13 100644 --- a/server/rr/rr-logger-event.js +++ b/server/rr/rr-logger-event.js @@ -19,12 +19,13 @@ function CreateRRLogEvent(socket, level = "DEBUG", message = "", details = {}) { } // Structured RR event for FE debug panel (parity with Fortellis' CreateFortellisLogEvent) + // socket.emit("fortellis-log-event", { level, message, txnDetails }); try { socket?.emit?.("rr-log-event", { level: lvl, message, ts, - ...safeJson(details) + txnDetails: details }); } catch { /* ignore socket emit failures */ diff --git a/server/rr/rr-logger.js b/server/rr/rr-logger.js index ec9384ac2..e3f47be7c 100644 --- a/server/rr/rr-logger.js +++ b/server/rr/rr-logger.js @@ -50,7 +50,6 @@ function RRLogger(_socket, defaults = {}) { console.log(line); } catch {} } - // INTENTIONALLY no socket emit here to avoid FE duplicates. }; } diff --git a/server/rr/rr-lookup.js b/server/rr/rr-lookup.js index 5d6c42d2f..77112c5da 100644 --- a/server/rr/rr-lookup.js +++ b/server/rr/rr-lookup.js @@ -13,14 +13,12 @@ function buildClientAndOpts(bodyshop) { password: cfg.password, timeoutMs: cfg.timeoutMs, retries: cfg.retries - // optional debug logger already inside lib; leave defaults }); // Common CallOptions for all ops; routing is CRITICAL for Destination block const opts = { routing: cfg.routing, envelope: { - // You can override these per-call if needed sender: { component: "Rome", task: "CVC", @@ -28,7 +26,6 @@ function buildClientAndOpts(bodyshop) { creator: "RCI", senderName: "RCI" } - // bodId/creationDateTime auto-filled by the client if omitted } }; @@ -55,7 +52,9 @@ function toCombinedSearchPayload(args = {}) { } const payload = { - maxResults: q.maxResults || q.maxRecs || 50, + // set both; the XML layer renders MaxRecs + maxRecs: q.maxResults ?? q.maxRecs ?? 50, + maxResults: q.maxResults ?? q.maxRecs ?? 50, kind }; @@ -74,6 +73,7 @@ function toCombinedSearchPayload(args = {}) { payload.kind = "vin"; payload.vin = String(q.vin ?? "").trim(); break; + case "namerecid": payload.kind = "nameRecId"; payload.nameRecId = String(q.nameRecId ?? q.custId ?? "").trim(); @@ -84,16 +84,15 @@ function toCombinedSearchPayload(args = {}) { payload.kind = "stkNo"; payload.stkNo = String(q.stkNo ?? q.stock ?? "").trim(); break; + case "name": { payload.kind = "name"; const n = q.name; - // STRING => last-name-only intent if (typeof n === "string") { const last = n.trim(); if (last) payload.name = { name: last }; // break; } - // OBJECT => always treat as FullName (even if only one of the parts is present) const fname = n?.fname && String(n.fname).trim(); const lname = n?.lname && String(n.lname).trim(); const mname = n?.mname && String(n.mname).trim(); @@ -127,6 +126,7 @@ function toCombinedSearchPayload(args = {}) { return payload; } + /** * Combined customer/service/vehicle search * @param bodyshop - bodyshop row (must include rr_dealerid & rr_configuration with store/branch) @@ -146,8 +146,8 @@ async function rrCombinedSearch(bodyshop, args = {}) { */ async function rrGetAdvisors(bodyshop, args = {}) { const { client, opts } = buildClientAndOpts(bodyshop); - // Allow friendly department values - const dep = (args.department || "").toString().toUpperCase(); + // Accept either department or departmentType from FE + const dep = String(args.department ?? args.departmentType ?? "").toUpperCase(); const department = dep === "BODY" || dep === "BODYSHOP" ? "B" : dep === "SERVICE" ? "S" : dep === "PARTS" ? "P" : dep || "B"; @@ -160,8 +160,36 @@ async function rrGetAdvisors(bodyshop, args = {}) { return res?.data ?? res; } +/** + * Parts lookup (graceful if the underlying lib exposes a different name) + * @param bodyshop + * @param args - common fields like { partNumber, description, make, model, year } + */ +async function rrGetParts(bodyshop, args = {}) { + const { client, opts } = buildClientAndOpts(bodyshop); + const payload = { + partNumber: args.partNumber ?? args.partNo ?? args.number ?? undefined, + description: args.description ?? undefined, + make: args.make ?? undefined, + model: args.model ?? undefined, + year: args.year ?? undefined + }; + + // Try common method names. If none exist, return an empty list to avoid crashes. + if (typeof client.getParts === "function") { + const res = await client.getParts(payload, opts); + return res?.data ?? res; + } + if (typeof client.getPartNumbers === "function") { + const res = await client.getPartNumbers(payload, opts); + return res?.data ?? res; + } + return []; +} + module.exports = { rrCombinedSearch, rrGetAdvisors, + rrGetParts, buildClientAndOpts }; diff --git a/server/rr/rr-service-vehicles.js b/server/rr/rr-service-vehicles.js index f03f1c6d0..e16dcb89e 100644 --- a/server/rr/rr-service-vehicles.js +++ b/server/rr/rr-service-vehicles.js @@ -1,253 +1,203 @@ -// server/rr/rr-service-vehicles.js -const { RRClient } = require("./lib/index.cjs"); -const { getRRConfigFromBodyshop } = require("./rr-config"); -const RRLogger = require("./rr-logger"); -const { ownersFromVinBlocks, getTransactionType, defaultRRTTL, RRCacheEnums } = require("../rr/rr-utils"); +// File: server/rr/rr-service-vehicles.js +// Idempotent Service Vehicle ensure: if VIN exists (owner match or not), don't fail. -function buildClientAndOpts(bodyshop) { - const cfg = getRRConfigFromBodyshop(bodyshop); - const client = new RRClient({ - baseUrl: cfg.baseUrl, - username: cfg.username, - password: cfg.password, - timeoutMs: cfg.timeoutMs, - retries: cfg.retries - }); - const opts = { - routing: cfg.routing, +const RRLogger = require("./rr-logger"); +const { buildClientAndOpts, rrCombinedSearch } = require("./rr-lookup"); + +// --- helpers --- +function pickVin({ vin, job }) { + const v = vin || job?.v_vin || job?.vehicle?.vin || job?.vin || job?.vehicleVin || null; + + if (!v) return ""; + return String(v) + .replace(/[^A-Za-z0-9]/g, "") + .toUpperCase() + .slice(0, 17); +} + +function pickCustNo({ selectedCustomerNo, custNo, customerNo }) { + const c = selectedCustomerNo ?? custNo ?? customerNo ?? null; + return c != null ? String(c).trim() : ""; +} + +function ownersFromCombined(res, wantedVin) { + const blocks = Array.isArray(res?.data) ? res.data : Array.isArray(res) ? res : []; + const owners = new Set(); + for (const blk of blocks) { + const serv = Array.isArray(blk?.ServVehicle) ? blk.ServVehicle : []; + for (const sv of serv) { + const v = sv?.Vehicle || {}; + const vin = (v?.Vin || v?.VIN || v?.vin || "").toString().toUpperCase(); + const cust = sv?.VehicleServInfo?.CustomerNo; + if (vin && cust && (!wantedVin || vin === wantedVin)) { + owners.add(String(cust)); + } + } + } + return owners; +} + +function isAlreadyExistsError(e) { + if (!e) return false; + if (e.code === 300) return true; + const msg = (e.message || "").toUpperCase(); + return msg.includes("ALREADY EXISTS") || msg.includes("VEHICLE ALREADY EXISTS"); +} + +/** + * Ensure/create a Service Vehicle in RR for the given VIN + customer. + * Args may contain: + * - client, routing (prebuilt) OR bodyshop (we'll build a client) + * - vin OR job (we'll derive vin from job) + * - selectedCustomerNo / custNo / customerNo + * - license (optional) + * - socket (for logging) + * - logNs (namespace for logs) + * + * Returns: { created:boolean, exists:boolean, vin, customerNo, svId?, status? } + */ +async function ensureRRServiceVehicle(args = {}) { + const { + client: inClient, + routing: inRouting, + bodyshop, + job, + vin, + selectedCustomerNo, + custNo, + customerNo, + license, + socket, + logNs = "rr-service-vehicles" + } = args; + + const log = RRLogger(socket, { ns: logNs }); + + // Build/derive essentials + let client = inClient; + let routing = inRouting; + + if (!client || !routing) { + if (!bodyshop) { + throw new Error("ensureRRServiceVehicle: either (client & routing) or bodyshop is required"); + } + const built = buildClientAndOpts(bodyshop); + client = built.client; + routing = built.opts?.routing; + } + + const vinStr = pickVin({ vin, job }); + const custNoStr = pickCustNo({ selectedCustomerNo, custNo, customerNo }); + + if (!vinStr) throw new Error("ensureRRServiceVehicle: vin required"); + if (!custNoStr) throw new Error("ensureRRServiceVehicle: customerNo required"); + + // --- Preflight: does VIN already exist (and under whom)? --- + try { + let owners = new Set(); + if (bodyshop) { + const comb = await rrCombinedSearch(bodyshop, { kind: "vin", vin: vinStr, maxResults: 50 }); + owners = ownersFromCombined(comb, vinStr); + } + // Short-circuit: VIN exists anywhere -> don't try to insert + if (owners.size > 0) { + const ownedBySame = owners.has(custNoStr); + log(ownedBySame ? "info" : "warn", "{SV} VIN already present in RR; skipping insert", { + vin: vinStr, + selectedCustomerNo: custNoStr, + owners: Array.from(owners) + }); + return { + created: false, + exists: true, + vin: vinStr, + customerNo: custNoStr, + ownedBySame + }; + } + } catch (e) { + // Preflight shouldn't be fatal; log and continue to insert (idempotency will still be handled) + log("warn", "{SV} VIN preflight lookup failed; continuing to insert", { vin: vinStr, error: e?.message }); + } + + // --- Attempt insert (idempotent) --- + const insertPayload = { + vin: vinStr, + customerNo: custNoStr, + license: license ? String(license).trim() : undefined + }; + + const insertOpts = { + routing, envelope: { sender: { component: "Rome", - task: "SV", // default for insertServiceVehicle + task: "SV", referenceId: "Insert", creator: "RCI", senderName: "RCI" } } }; - return { client, opts }; -} -function buildServiceVehiclePayload({ job, custNo, overrides = {} }) { - // Helpers - const isBlank = (v) => v == null || (typeof v === "string" && v.trim() === ""); - const toStr = (v) => (v == null ? undefined : String(v)); - const toNum = (v) => { - const n = Number(v); - return Number.isFinite(n) ? n : undefined; - }; - const dropEmptyDeep = (obj) => { - if (Array.isArray(obj)) { - const arr = obj - .map(dropEmptyDeep) - .filter( - (v) => - !( - v == null || - (typeof v === "object" && !Array.isArray(v) && Object.keys(v).length === 0) || - (typeof v === "string" && v.trim() === "") - ) - ); - return arr.length ? arr : undefined; - } - if (obj && typeof obj === "object") { - const out = {}; - for (const [k, v] of Object.entries(obj)) { - const vv = dropEmptyDeep(v); - if (!(vv == null || (typeof vv === "string" && vv.trim() === ""))) out[k] = vv; - } - return Object.keys(out).length ? out : undefined; - } - return obj; - }; + log("debug", "{SV} Inserting service vehicle", { + vin: vinStr, + selectedCustomerNo: custNoStr, + payloadShape: Object.keys(insertPayload).filter((k) => insertPayload[k] != null) + }); - // Derive odometer + units from job - let odometer = undefined; - let odometerUnits = undefined; + // Be tolerant to method name differences + const fnName = + typeof client.insertServiceVehicle === "function" + ? "insertServiceVehicle" + : typeof client.serviceVehicleInsert === "function" + ? "serviceVehicleInsert" + : null; - if (!isBlank(job?.kmout)) { - odometer = toNum(job.kmout); - odometerUnits = "KM"; - } else if (!isBlank(job?.kmin)) { - odometer = toNum(job.kmin); - odometerUnits = "KM"; - } else if (!isBlank(job?.odometer)) { - odometer = toNum(job.odometer); - // if you know this is miles in your dataset, set "MI"; otherwise omit units + if (!fnName) { + throw new Error("RR client does not expose insertServiceVehicle/serviceVehicleInsert"); } - // Map common vehicle descriptors - const year = overrides.year ?? job?.v_model_yr; - const modelDesc = overrides.modelDesc ?? job?.v_model_desc; - const makeDesc = overrides.makeDesc ?? job?.v_make_desc; // not in the spec list, used only to help build `carline` - const extClrDesc = overrides.extClrDesc ?? job?.v_color; - const intClrDesc = overrides.intClrDesc ?? job?.v_int_color; - const trimDesc = overrides.trimDesc ?? job?.v_trim; - const bodyStyle = overrides.bodyStyle ?? job?.v_body_style; - const engineDesc = overrides.engineDesc ?? job?.v_engine_desc ?? job?.engine_desc; - const transDesc = overrides.transDesc ?? job?.v_trans_desc ?? job?.trans_desc; + try { + const res = await client[fnName](insertPayload, insertOpts); + const data = res?.data ?? {}; + const svId = data?.dmsRecKey || data?.svId || undefined; - // Carline: prefer explicit field; otherwise derive from make+model when both are present - const carline = - overrides.carline ?? - job?.v_carline ?? - (makeDesc && modelDesc ? `${String(makeDesc).trim()} ${String(modelDesc).trim()}` : undefined); - - // Advisor (NameRecId) if you have it handy (often provided via txEnvelope or cache) – pass in overrides.advisorNameRecId - const advisorNameRecId = overrides.advisorNameRecId; - - // Optional warranty fields (pass through via overrides.*) - const vehExtWarranty = (() => { - const contractNumber = overrides?.vehExtWarranty?.contractNumber ?? job?.warranty_contract_no; - const expirationDate = overrides?.vehExtWarranty?.expirationDate ?? job?.warranty_exp_date; - const expirationMileage = overrides?.vehExtWarranty?.expirationMileage ?? job?.warranty_exp_mileage; - const candidate = { - contractNumber: isBlank(contractNumber) ? undefined : String(contractNumber), - expirationDate: isBlank(expirationDate) ? undefined : String(expirationDate), - expirationMileage: isBlank(expirationMileage) ? undefined : String(expirationMileage) + log("info", "{SV} insertServiceVehicle: success", { vin: vinStr, customerNo: custNoStr, svId }); + return { + created: true, + exists: false, + vin: vinStr, + customerNo: custNoStr, + svId, + status: res?.statusBlocks?.transaction }; - const any = Object.values(candidate).some((v) => !isBlank(v)); - return any ? candidate : undefined; - })(); - - // Sales/team/in-service/mileage optional fields - const salesmanNo = overrides.salesmanNo ?? job?.salesman_no; - const inServiceDate = overrides.inServiceDate ?? job?.in_service_date; - const svMileage = overrides.mileage ?? odometer; // mirror root odometer if you want - const teamCode = overrides.teamCode ?? job?.team_code; - - // Build raw payload (include everything we can; drop empties after) - const raw = { - // Required - vin: toStr(overrides.vin ?? job?.v_vin), - - // Optional attributes - modelDesc: toStr(modelDesc), - carline: toStr(carline), - extClrDesc: toStr(extClrDesc), - intClrDesc: toStr(intClrDesc), - trimDesc: toStr(trimDesc), - bodyStyle: toStr(bodyStyle), - engineDesc: toStr(engineDesc), - transDesc: toStr(transDesc), - - // Optional elements - year: toStr(year), - odometer, - odometerUnits: toStr(overrides.odometerUnits ?? odometerUnits), - - // VehicleDetail - vehicleDetail: { - licNo: toStr(overrides.licNo ?? job?.plate_no) - }, - - // VehicleServInfo (CustomerNo is required) - vehicleServInfo: { - customerNo: toStr(custNo), - salesmanNo: toStr(salesmanNo), - inServiceDate: toStr(inServiceDate), - mileage: svMileage, - teamCode: toStr(teamCode), - vehExtWarranty, - advisor: advisorNameRecId ? { contactInfo: { nameRecId: toStr(advisorNameRecId) } } : undefined + } catch (e) { + if (isAlreadyExistsError(e)) { + // Treat as idempotent success + log("warn", "{SV} insertServiceVehicle: already exists; treating as success", { + vin: vinStr, + customerNo: custNoStr, + code: e?.code, + status: e?.meta?.status || e?.status + }); + return { + created: false, + exists: true, + vin: vinStr, + customerNo: custNoStr, + status: e?.meta?.status || e?.status + }; } - }; - - const cleaned = dropEmptyDeep(raw); - if (!cleaned?.vin) throw new Error("buildServiceVehiclePayload: VIN is required"); - if (!cleaned?.vehicleServInfo?.customerNo) throw new Error("buildServiceVehiclePayload: customerNo is required"); - - return cleaned; + log("error", "{SV} insertServiceVehicle: failure", { + message: e?.message, + code: e?.code, + status: e?.meta?.status || e?.status + }); + throw e; + } } -/** - * Ensure a Service Vehicle (for job.v_vin) exists and is associated to the given custNo. - * - Prefer cached VIN blocks saved during step {2} under RR.VINCandidates - * - If no cache, do a single combinedSearch by VIN, then cache it - * - If not owned, insert service vehicle to associate to selected customer - */ -async function ensureRRServiceVehicle({ bodyshop, custNo, job, overrides = {}, socket, redisHelpers }) { - const log = RRLogger(socket); - const vin = job?.v_vin && String(job.v_vin).trim(); - if (!vin) { - log("warn", "ensureRRServiceVehicle: no VIN on job; nothing to ensure", { jobId: job?.id }); - return { ensured: false, reason: "no-vin" }; - } - const custNoStr = String(custNo); - - const { client, opts } = buildClientAndOpts(bodyshop); - - // 1) Try cached VIN combined-search blocks from step {2} - let blocks = null; - if (redisHelpers && socket?.id && job?.id) { - try { - blocks = await redisHelpers.getSessionTransactionData( - socket.id, - getTransactionType(job.id), - RRCacheEnums.VINCandidates - ); - } catch { - // ignore - } - } - - // 2) If no cached blocks, do one combinedSearch (VIN) and cache it - if (!Array.isArray(blocks) || blocks.length === 0) { - try { - const precheckRes = await client.combinedSearch( - { vin, maxRecs: 50, kind: "vin" }, - { - ...opts, - envelope: { - ...opts.envelope, - sender: { ...opts.envelope.sender, task: "CVC", referenceId: "Query" } - } - } - ); - - // Normalize to "blocks" shape (either .data or array) - blocks = Array.isArray(precheckRes?.data) ? precheckRes.data : Array.isArray(precheckRes) ? precheckRes : []; - - if (redisHelpers && socket?.id && job?.id) { - try { - await redisHelpers.setSessionTransactionData( - socket.id, - getTransactionType(job.id), - RRCacheEnums.VINCandidates, - blocks, - defaultRRTTL - ); - } catch { - // - } - } - } catch (e) { - // Non-fatal: proceed to insert - log("warn", "RR combinedSearch(VIN) precheck failed; will proceed to insert", { vin, err: e?.message }); - blocks = []; - } - } - - // 3) Check ownership from blocks (strict) - const ownersSet = ownersFromVinBlocks(blocks, vin); - if (ownersSet.has(custNoStr)) { - log("info", "ensureRRServiceVehicle: already owned", { vin, custNo: custNoStr }); - return { ensured: true, alreadyOwned: true }; - } - - // 4) Not owned → insert/associate service vehicle to selected customer - const payload = buildServiceVehiclePayload({ job, custNo: custNoStr, overrides }); - const res = await client.insertServiceVehicle(payload, opts); - const data = res?.data ?? res; - - // Normalize a simple id for later (RR often returns a DMS key) - const svId = data?.svId ?? data?.serviceVehicleId ?? data?.dmsRecKey ?? data?.DMSRecKey; - if (!svId) { - log("warn", "RR insert service vehicle returned no id", { vin, custNo: custNoStr, data }); - } else { - log("info", "ensureRRServiceVehicle: inserted service vehicle", { vin, custNo: custNoStr, svId }); - } - return { ensured: true, inserted: true, svId, raw: data }; -} - -module.exports = { ensureRRServiceVehicle, buildServiceVehiclePayload, buildClientAndOpts }; +module.exports = { + ensureRRServiceVehicle +}; diff --git a/server/web-sockets/rr-register-socket-events.js b/server/web-sockets/rr-register-socket-events.js index bc418e312..70f8089b8 100644 --- a/server/web-sockets/rr-register-socket-events.js +++ b/server/web-sockets/rr-register-socket-events.js @@ -1,8 +1,8 @@ -// File: server/rr/rr-register-socket-events.js +// File: server/web-sockets/rr-register-socket-events.js // RR events aligned to Fortellis flow with Fortellis-style logging via CreateRRLogEvent const CreateRRLogEvent = require("../rr/rr-logger-event"); -const { rrCombinedSearch, rrGetAdvisors, rrGetParts } = require("../rr/rr-lookup"); +const { rrCombinedSearch, rrGetAdvisors, rrGetParts, buildClientAndOpts } = require("../rr/rr-lookup"); const { QueryJobData } = require("../rr/rr-job-helpers"); const { exportJobToRR } = require("../rr/rr-job-export"); const CdkCalculateAllocations = require("../cdk/cdk-calculate-allocations").default; @@ -26,6 +26,11 @@ function resolveJobId(explicit, payload, job) { return explicit || payload?.jobId || payload?.jobid || job?.id || job?.jobId || job?.jobid || null; } +function resolveVin({ tx, job }) { + // Prefer cached tx vin (if we made one), then common job shapes (v_vin for our schema) + return tx?.jobData?.vin || job?.v_vin || job?.vehicle?.vin || job?.vin || job?.vehicleVin || null; +} + function sortVehicleOwnerFirst(list) { return list .map((v, i) => ({ v, i })) @@ -65,14 +70,10 @@ async function getBodyshopForSocket({ bodyshopId, socket }) { } /** - * VIN + Full Name merge (export flow): - * - Name query from job first/last or company - * - VIN query from job VIN - * - Cache VIN raw blocks (res.data) under RRCacheEnums.VINCandidates - * - Mark isVehicleOwner only if candidate.custNo is in the VIN owners set (exact match) + * VIN + Full Name merge (export flow) */ async function rrMultiCustomerSearch({ bodyshop, job, socket, redisHelpers }) { - const queries = []; + const queriesList = []; // 1) Full Name (preferred) const firstName = job?.ownr_fn && String(job.ownr_fn).trim(); @@ -80,29 +81,28 @@ async function rrMultiCustomerSearch({ bodyshop, job, socket, redisHelpers }) { const company = job?.ownr_co_nm && String(job.ownr_co_nm).trim(); if (firstName || lastName) { - queries.push({ + queriesList.push({ q: { kind: "name", name: { fname: firstName || undefined, lname: lastName || undefined }, maxResults: 50 }, fromVin: false }); } else if (company) { - queries.push({ q: { kind: "name", name: { name: company }, maxResults: 50 }, fromVin: false }); + queriesList.push({ q: { kind: "name", name: { name: company }, maxResults: 50 }, fromVin: false }); } // 2) VIN (owner association) const vehQ = makeVehicleSearchPayloadFromJob(job); - if (vehQ && vehQ.kind === "vin") queries.push({ q: vehQ, fromVin: true }); + if (vehQ && vehQ.kind === "vin") queriesList.push({ q: vehQ, fromVin: true }); - if (!queries.length) return []; + if (!queriesList.length) return []; let ownersSet = null; const merged = []; - for (const { q, fromVin } of queries) { + for (const { q, fromVin } of queriesList) { try { CreateRRLogEvent(socket, "DEBUG", `{RR-SEARCH} Executing ${q.kind} query`, { q }); const res = await rrCombinedSearch(bodyshop, q); - // If VIN query, compute ownersSet & cache raw blocks if (fromVin) { const blocks = Array.isArray(res?.data) ? res.data : []; ownersSet = ownersFromVinBlocks(blocks, job?.v_vin); @@ -114,9 +114,7 @@ async function rrMultiCustomerSearch({ bodyshop, job, socket, redisHelpers }) { blocks, defaultRRTTL ); - } catch { - // - } + } catch {} } const norm = normalizeCustomerCandidates(res, { ownersSet }); @@ -143,7 +141,7 @@ function registerRREvents({ socket, redisHelpers }) { if ((params?.kind || "").toLowerCase() === "vin") { const blocks = Array.isArray(res?.data) ? res.data : []; - ownersSet = ownersFromVinBlocks(blocks); // no job VIN filter in ad-hoc lookup + ownersSet = ownersFromVinBlocks(blocks); } const normalized = sortVehicleOwnerFirst(normalizeCustomerCandidates(res, { ownersSet })); @@ -257,9 +255,7 @@ function registerRREvents({ socket, redisHelpers }) { }); try { socket.emit("export-failed", { vendor: "rr", jobId: rid, error: error.message }); - } catch { - // - } + } catch {} } }); @@ -288,6 +284,7 @@ function registerRREvents({ socket, redisHelpers }) { const { bodyshopId } = await getSessionOrSocket(redisHelpers, socket); const bodyshop = await getBodyshopForSocket({ bodyshopId, socket }); + // Create customer (if requested or none chosen) if (create === true || !selectedCustNo) { CreateRRLogEvent(socket, "DEBUG", `{3.1} Creating RR customer`); const created = await createRRCustomer({ bodyshop, job, socket }); @@ -296,25 +293,108 @@ function registerRREvents({ socket, redisHelpers }) { CreateRRLogEvent(socket, "DEBUG", `{3.2} Created customer`, { custNo: selectedCustNo }); } + // VIN owner pre-check + try { + const vehQ = makeVehicleSearchPayloadFromJob(job); + if (vehQ && vehQ.kind === "vin" && job?.v_vin) { + const resVin = await rrCombinedSearch(bodyshop, vehQ); + const blocksVin = Array.isArray(resVin?.data) ? resVin.data : []; + try { + await redisHelpers.setSessionTransactionData( + socket.id, + ns, + RRCacheEnums.VINCandidates, + blocksVin, + defaultRRTTL + ); + } catch {} + const ownersSet = ownersFromVinBlocks(blocksVin, job.v_vin); + if (ownersSet && ownersSet.size) { + const sel = String(selectedCustNo); + if (!ownersSet.has(sel)) { + const [existingOwner] = Array.from(ownersSet).map(String); + CreateRRLogEvent(socket, "DEBUG", `{3.2a} VIN exists; switching to VIN owner`, { + vin: job.v_vin, + selected: sel, + existingOwner + }); + try { + socket.emit("rr-vin-owner-mismatch", { + ts: Date.now(), + vin: job.v_vin, + selectedCustomerNo: sel, + existingOwner, + message: + "VIN already exists in RR under a different customer. Using the VIN's owner to continue the export." + }); + } catch {} + selectedCustNo = existingOwner; + } + } + } + } catch (e) { + CreateRRLogEvent(socket, "WARN", `VIN owner pre-check failed; continuing with selected customer`, { + error: e?.message + }); + } + + // Cache final/effective customer selection + const effectiveCustNo = String(selectedCustNo); await redisHelpers.setSessionTransactionData( socket.id, ns, RRCacheEnums.SelectedCustomer, - String(selectedCustNo), + effectiveCustNo, defaultRRTTL ); - CreateRRLogEvent(socket, "DEBUG", `{3.3} Cached selected customer`, { custNo: String(selectedCustNo) }); + CreateRRLogEvent(socket, "DEBUG", `{3.3} Cached selected customer`, { custNo: effectiveCustNo }); - // Ensure service vehicle exists and is owned by selected customer (uses cached VIN blocks when present) - const ensureVeh = await ensureRRServiceVehicle({ + // Build client & routing + const { client, opts } = await buildClientAndOpts(bodyshop); + const routing = opts?.routing || client?.opts?.routing || null; + if (!routing?.dealerNumber) throw new Error("ensureRRServiceVehicle: routing.dealerNumber required"); + + // Reconstruct a lightweight tx object (so resolveVin can use the same shape we logged at {1.2}) + const tx = { + jobData: { + ...job, + vin: job?.v_vin || job?.vin || job?.vehicleVin || undefined + }, + txEnvelope + }; + + const vin = resolveVin({ tx, job }); + if (!vin) { + CreateRRLogEvent(socket, "ERROR", "{3.x} No VIN found for ensureRRServiceVehicle", { jobid: rid }); + throw new Error("ensureRRServiceVehicle: vin required"); + } + + CreateRRLogEvent(socket, "DEBUG", "{3.2} ensureRRServiceVehicle: starting", { + jobid: rid, + selectedCustomerNo: effectiveCustNo, + vin, + dealerNumber: routing.dealerNumber, + storeNumber: routing.storeNumber, + areaNumber: routing.areaNumber + }); + + const ensured = await ensureRRServiceVehicle({ + client, + routing, bodyshop, - custNo: String(selectedCustNo), + // Normalize for any internal checks: + selectedCustomerNo: effectiveCustNo, + custNo: effectiveCustNo, + customerNo: effectiveCustNo, + vin, job, socket, redisHelpers }); - CreateRRLogEvent(socket, "DEBUG", `{3.4} ensureRRServiceVehicle`, ensureVeh); + CreateRRLogEvent(socket, "DEBUG", "{3.4} ensureRRServiceVehicle: done", ensured); + + // Advisor no const cachedAdvisor = await redisHelpers.getSessionTransactionData(socket.id, ns, RRCacheEnums.AdvisorNo); const advisorNo = readAdvisorNo({ txEnvelope }, cachedAdvisor); if (!advisorNo) { @@ -330,11 +410,12 @@ function registerRREvents({ socket, redisHelpers }) { defaultRRTTL ); + // Export CreateRRLogEvent(socket, "DEBUG", `{4} Performing RR export`); const result = await exportJobToRR({ bodyshop, job, - selectedCustomer: { customerNo: String(selectedCustNo), custNo: String(selectedCustNo) }, + selectedCustomer: { customerNo: effectiveCustNo, custNo: effectiveCustNo }, advisorNo: String(advisorNo), existing: txEnvelope?.existing, socket @@ -371,9 +452,7 @@ function registerRREvents({ socket, redisHelpers }) { }); try { socket.emit("export-failed", { vendor: "rr", jobId: rid, error: error.message }); - } catch { - // - } + } catch {} ack?.({ ok: false, error: error.message }); } });