diff --git a/server/rr/rr-service-vehicles.js b/server/rr/rr-service-vehicles.js index 58679dd6b..31ccf2e17 100644 --- a/server/rr/rr-service-vehicles.js +++ b/server/rr/rr-service-vehicles.js @@ -46,7 +46,7 @@ async function ensureRRServiceVehicle({ bodyshop, custNo, job, overrides = {}, s // Optional: first try a combined query by VIN to detect existing SV try { const queryRes = await client.combinedSearch( - { vin: job?.v_vin, maxRecs: 1 }, + { vin: job?.v_vin, maxRecs: 1, kind: "vin" }, { ...opts, envelope: { diff --git a/server/web-sockets/rr-register-socket-events.js b/server/web-sockets/rr-register-socket-events.js index f72e199fb..7616c6998 100644 --- a/server/web-sockets/rr-register-socket-events.js +++ b/server/web-sockets/rr-register-socket-events.js @@ -5,17 +5,29 @@ const { exportJobToRR } = require("../rr/rr-job-export"); const CdkCalculateAllocations = require("../cdk/cdk-calculate-allocations").default; const { createRRCustomer } = require("../rr/rr-customers"); -const { buildClientAndOpts } = require("../rr/rr-lookup"); const { GraphQLClient } = require("graphql-request"); const queries = require("../graphql-client/queries"); -// ---------------- utils ---------------- +// Use the same namespacing/TTL approach as Fortellis +const { getTransactionType, defaultFortellisTTL } = require("../fortellis/fortellis-helpers"); +// ---------------- cache keys (RR) ---------------- +const RRCacheEnums = { + txEnvelope: "RR.txEnvelope", + JobData: "RR.JobData", + SelectedCustomer: "RR.SelectedCustomer", + AdvisorNo: "RR.AdvisorNo", + // Vehicle keys reserved for future multi-vehicle UX parity + VINCandidates: "RR.VINCandidates", + SelectedVin: "RR.SelectedVin", + ExportResult: "RR.ExportResult" +}; +const defaultRRTTL = defaultFortellisTTL || 60 * 60; // fallback 1h + +// ---------------- utils ---------------- function resolveJobId(explicit, payload, job) { return explicit || payload?.jobId || payload?.jobid || job?.id || job?.jobId || job?.jobid || null; } - -// ---- local helpers (avoid no-undef) ---- const digitsOnly = (s) => String(s || "").replace(/\D/g, ""); const makeVehicleSearchPayloadFromJob = (job) => { @@ -42,8 +54,8 @@ const makeCustomerSearchPayloadFromJob = (job) => { return null; }; +// Normalize to the RR table shape expected by FE (custNo + name) const normalizeCustomerCandidates = (res) => { - // CombinedSearch may return array or { data: [...] } const blocks = Array.isArray(res?.data) ? res.data : Array.isArray(res) ? res : []; const out = []; for (const blk of blocks) { @@ -58,7 +70,7 @@ const normalizeCustomerCandidates = (res) => { const name = (personal || company || "").trim(); for (const custNo of custNos) { - out.push({ custNo, name: name || `Customer ${custNo}` }); + out.push({ custNo: String(custNo), name: name || `Customer ${custNo}` }); } } const seen = new Set(); @@ -99,32 +111,41 @@ async function getBodyshopForSocket({ bodyshopId, socket }) { return bodyshop; } -// ---------------- register handlers ---------------- +function readAdvisorNo(payload, cached) { + const v = + (payload?.txEnvelope?.advisorNo != null && String(payload.txEnvelope.advisorNo)) || + (payload?.advisorNo != null && String(payload.advisorNo)) || + (payload?.advNo != null && String(payload.advNo)) || + (cached != null && String(cached)) || + null; + return v && v.trim() !== "" ? v : null; +} -function registerRREvents({ socket, redisHelpers }) { - // RRLogger returns a log(level, message, ctx) function +// ---------------- register handlers ---------------- +function registerRREvents({ socket, redisHelpers /*, ioHelpers, logger*/ }) { const log = RRLogger(socket); - // Lookups (mirrors Fortellis shape/flow) + // --------- Lookups (customer search → open table) --------- socket.on("rr-lookup-combined", async ({ jobid, params } = {}, cb) => { try { const { bodyshopId } = await getSessionOrSocket(redisHelpers, socket); const bodyshop = await getBodyshopForSocket({ bodyshopId, socket }); const res = await rrCombinedSearch(bodyshop, params || {}); - const data = res?.data ?? res; + const normalized = normalizeCustomerCandidates(res); - cb?.({ jobid: resolveJobId(jobid, { jobid }, null), data }); + const rid = resolveJobId(jobid, { jobid }, null); + cb?.({ jobid: rid, data: normalized }); - // Push to FE to open the table; keep payload as the raw array (FE maps columns itself) - socket.emit("rr-select-customer", Array.isArray(data) ? data : data?.customers || []); + // FE expects { custNo, name } + socket.emit("rr-select-customer", normalized); } catch (e) { log("error", `RR combined lookup error: ${e.message}`, { jobid }); cb?.({ jobid, error: e.message }); } }); - // Advisors + // --------- Advisors --------- socket.on("rr-get-advisors", async (args = {}, ack) => { try { const { bodyshopId } = await getSessionOrSocket(redisHelpers, socket); @@ -138,7 +159,7 @@ function registerRREvents({ socket, redisHelpers }) { } }); - // Parts + // --------- Parts --------- socket.on("rr-get-parts", async (args = {}, ack) => { try { const { bodyshopId } = await getSessionOrSocket(redisHelpers, socket); @@ -152,31 +173,54 @@ function registerRREvents({ socket, redisHelpers }) { } }); - // Persist customer selection (or flag create-new) - socket.on("rr-selected-customer", async (selected, ack) => { + // --------- Persist customer selection / create-intent (table-first UX) --------- + socket.on("rr-selected-customer", async ({ jobId, custNo, create } = {}, ack) => { + const ns = getTransactionType(jobId || "unknown"); try { - await getSessionOrSocket(redisHelpers, socket); - const tx = (await redisHelpers.getSessionTransactionData(socket.id)) || {}; + const { bodyshopId } = await getSessionOrSocket(redisHelpers, socket); + const bodyshop = await getBodyshopForSocket({ bodyshopId, socket }); + if (!jobId) throw new Error("jobId required"); - // Signal create-new intent - if (!selected || selected?.create === true || selected?.__new === true) { - await redisHelpers.setSessionTransactionData(socket.id, { ...tx, rrCreateCustomer: true }); - log("info", "rr-selected-customer:new-customer-intent"); - socket.emit("rr-customer-create-required"); - return ack?.({ ok: true, action: "create" }); + // If caller passed a selection, just persist it. + if (custNo && create !== true) { + await redisHelpers.setSessionTransactionData( + socket.id, + ns, + RRCacheEnums.SelectedCustomer, + String(custNo), + defaultRRTTL + ); + log("info", "rr-selected-customer", { jobId, custNo: String(custNo) }); + return ack?.({ ok: true }); } - await redisHelpers.setSessionTransactionData(socket.id, { ...tx, rrSelectedCustomer: selected }); - log("info", "rr-selected-customer", { selected }); - ack?.({ ok: true }); + // No custNo (or create: true) => create immediately from JobData (Fortellis parity) + const job = await QueryJobData({ redisHelpers }, jobId); + const created = await createRRCustomer({ bodyshop, job, socket }); + const newCustNo = String( + created?.custNo || created?.customerNo || created?.CustomerNo || created?.dmsRecKey || "" + ); + if (!newCustNo) throw new Error("RR create customer returned no custNo"); + + await redisHelpers.setSessionTransactionData( + socket.id, + ns, + RRCacheEnums.SelectedCustomer, + newCustNo, + defaultRRTTL + ); + + log("info", "rr-create-customer:success", { jobId, custNo: newCustNo }); + return ack?.({ ok: true, custNo: newCustNo }); } catch (err) { - log("error", err?.message || "select customer failed", { err }); - ack?.({ ok: false, error: err?.message || "select customer failed" }); + log("error", err?.message || "select/create customer failed", { err, jobId }); + return ack?.({ ok: false, error: err?.message || "select/create customer failed" }); } }); - // Optional explicit create-customer from UI form + // --------- Optional explicit create-customer (form) --------- socket.on("rr-create-customer", async ({ jobId, fields } = {}, ack) => { + const ns = getTransactionType(jobId || "unknown"); try { const { bodyshopId } = await getSessionOrSocket(redisHelpers, socket); const bodyshop = await getBodyshopForSocket({ bodyshopId, socket }); @@ -184,67 +228,82 @@ function registerRREvents({ socket, redisHelpers }) { if (!jobId) throw new Error("jobId required"); const job = await QueryJobData({ redisHelpers }, jobId); - const { custNo } = await createRRCustomer({ bodyshop, job, overrides: fields || {}, socket }); + const created = await createRRCustomer({ bodyshop, job, overrides: fields || {}, socket }); + const custNo = String(created?.custNo || created?.customerNo || created?.CustomerNo || created?.dmsRecKey || ""); + if (!custNo) throw new Error("RR create customer returned no custNo"); - const tx = (await redisHelpers.getSessionTransactionData(socket.id)) || {}; - await redisHelpers.setSessionTransactionData(socket.id, { - ...tx, - rrSelectedCustomer: { custNo }, - rrCreateCustomer: false - }); + await redisHelpers.setSessionTransactionData(socket.id, ns, RRCacheEnums.SelectedCustomer, custNo, defaultRRTTL); + await redisHelpers.setSessionTransactionData(socket.id, ns, "RR.CreateCustomerIntent", false, defaultRRTTL); - log("info", "rr-create-customer:success", { custNo }); + log("info", "rr-create-customer:success", { jobId, custNo }); socket.emit("rr-customer-created", { custNo }); ack?.({ ok: true, custNo }); } catch (err) { - log("error", err?.message || "create customer failed", { err }); + log("error", err?.message || "create customer failed", { err, jobId }); ack?.({ ok: false, error: err?.message || "create customer failed" }); } }); - // Vehicle selection helpers - socket.on("rr-selected-vehicle", async (selected, ack) => { + // --------- Vehicle selection hooks (reserved for future parity) --------- + socket.on("rr-selected-vehicle", async ({ jobId, vin } = {}, ack) => { + const ns = getTransactionType(jobId || "unknown"); try { await getSessionOrSocket(redisHelpers, socket); - if (!selected?.vin) throw new Error("selected vehicle must include vin"); - const tx = (await redisHelpers.getSessionTransactionData(socket.id)) || {}; - await redisHelpers.setSessionTransactionData(socket.id, { ...tx, rrSelectedVehicle: selected }); - log("info", "rr-selected-vehicle", { vin: selected.vin }); + if (!vin) throw new Error("vin required"); + await redisHelpers.setSessionTransactionData(socket.id, ns, RRCacheEnums.SelectedVin, String(vin), defaultRRTTL); + log("info", "rr-selected-vehicle", { jobId, vin: String(vin) }); ack?.({ ok: true }); } catch (err) { - log("error", err?.message || "select vehicle failed", { err }); + log("error", err?.message || "select vehicle failed", { err, jobId }); ack?.({ ok: false, error: err?.message || "select vehicle failed" }); } }); - socket.on("rr-create-vehicle", async (vehicle, ack) => { + socket.on("rr-create-vehicle", async ({ jobId, vehicle } = {}, ack) => { + const ns = getTransactionType(jobId || "unknown"); try { await getSessionOrSocket(redisHelpers, socket); if (!vehicle?.vin) throw new Error("vehicle.vin required"); - const tx = (await redisHelpers.getSessionTransactionData(socket.id)) || {}; - await redisHelpers.setSessionTransactionData(socket.id, { ...tx, rrSelectedVehicle: vehicle }); - log("info", "rr-create-vehicle", { vin: vehicle.vin }); + await redisHelpers.setSessionTransactionData( + socket.id, + ns, + RRCacheEnums.SelectedVin, + String(vehicle.vin), + defaultRRTTL + ); + log("info", "rr-create-vehicle", { jobId, vin: String(vehicle.vin) }); ack?.({ ok: true }); } catch (err) { - log("error", err?.message || "create vehicle failed", { err }); + log("error", err?.message || "create vehicle failed", { err, jobId }); ack?.({ ok: false, error: err?.message || "create vehicle failed" }); } }); + // --------- Main export (Fortellis-style staging) --------- socket.on("rr-export-job", async (payload = {}) => { - const log = RRLogger(socket, { ns: "rr" }); + const _log = RRLogger(socket, { ns: "rr" }); try { - // -------- 1) Resolve job -------- + // 1) Resolve job let job = payload.job || payload.txEnvelope?.job; const jobId = payload.jobId || payload.jobid || payload.txEnvelope?.jobId || job?.id; - if (!job) { if (!jobId) throw new Error("RR export: job or jobId required"); job = await QueryJobData({ redisHelpers }, jobId); } + const ns = getTransactionType(job.id); - // -------- 2) Resolve bodyshop (+ full row via GraphQL) -------- + // Persist txEnvelope + job + await redisHelpers.setSessionTransactionData( + socket.id, + ns, + RRCacheEnums.txEnvelope, + payload.txEnvelope || {}, + defaultRRTTL + ); + await redisHelpers.setSessionTransactionData(socket.id, ns, RRCacheEnums.JobData, job, defaultRRTTL); + + // 2) Resolve bodyshop let bodyshopId = payload.bodyshopId || payload.bodyshopid || payload.bodyshopUUID || job?.bodyshop?.id; if (!bodyshopId) { const sess = await getSessionOrSocket(redisHelpers, socket); @@ -257,109 +316,116 @@ function registerRREvents({ socket, redisHelpers }) { ? job.bodyshop : await getBodyshopForSocket({ bodyshopId, socket }); - // -------- 3) Resolve advisor number from the posting form -------- - const tx = (await redisHelpers.getSessionTransactionData(socket.id)) || {}; - const advisorNo = payload.advisorNo || payload.advNo || payload.txEnvelope?.advisorNo || tx.rrAdvisorNo || null; - - if (!advisorNo || String(advisorNo).trim() === "") { + // 3) Resolve advisor number (from form or cache) + const cachedAdvisor = await redisHelpers.getSessionTransactionData(socket.id, ns, RRCacheEnums.AdvisorNo); + const advisorNo = readAdvisorNo(payload, cachedAdvisor); + if (!advisorNo) { socket.emit("export-failed", { vendor: "rr", jobId, error: "Advisor is required (advisorNo)." }); return; } - // Persist for subsequent steps if useful - await redisHelpers.setSessionTransactionData(socket.id, { ...tx, rrAdvisorNo: String(advisorNo) }); + await redisHelpers.setSessionTransactionData( + socket.id, + ns, + RRCacheEnums.AdvisorNo, + String(advisorNo), + defaultRRTTL + ); - // -------- 4) Resolve selected customer (payload → tx) -------- - let selectedCustomer = null; + // 4) Resolve selected customer (payload → cache) + let selectedCust = null; - // from payload if (payload.selectedCustomer) { if (typeof payload.selectedCustomer === "object" && payload.selectedCustomer.custNo) { - selectedCustomer = { custNo: String(payload.selectedCustomer.custNo) }; + selectedCust = { custNo: String(payload.selectedCustomer.custNo) }; } else if (typeof payload.selectedCustomer === "string" || typeof payload.selectedCustomer === "number") { - selectedCustomer = { custNo: String(payload.selectedCustomer) }; + selectedCust = { custNo: String(payload.selectedCustomer) }; + } + if (selectedCust?.custNo) { + await redisHelpers.setSessionTransactionData( + socket.id, + ns, + RRCacheEnums.SelectedCustomer, + selectedCust.custNo, + defaultRRTTL + ); } } - // from tx - if (!selectedCustomer && tx.rrSelectedCustomer) { - if (typeof tx.rrSelectedCustomer === "object" && tx.rrSelectedCustomer.custNo) { - selectedCustomer = { custNo: String(tx.rrSelectedCustomer.custNo) }; - } else { - selectedCustomer = { custNo: String(tx.rrSelectedCustomer) }; - } + if (!selectedCust) { + const cached = await redisHelpers.getSessionTransactionData(socket.id, ns, RRCacheEnums.SelectedCustomer); + if (cached) selectedCust = { custNo: String(cached) }; } // Flags - const forceCreate = payload.forceCreate === true || tx.rrCreateCustomer === true; + const forceCreate = payload.forceCreate === true; const autoCreateOnNoMatch = payload.autoCreateOnNoMatch !== false; // default TRUE - // -------- 5) If no selection & not "forceCreate", try auto-search first -------- - if (!selectedCustomer && !forceCreate) { + // 5) If no selection & not "forceCreate", try auto-search + if (!selectedCust && !forceCreate) { const customerQuery = makeCustomerSearchPayloadFromJob(job); const vehicleQuery = makeVehicleSearchPayloadFromJob(job); const query = customerQuery || vehicleQuery; if (query) { - log("info", "rr-export-job:customer-preselect-search", { query, jobId }); + _log("info", "rr-export-job:customer-preselect-search", { query, jobId }); const searchRes = await rrCombinedSearch(bodyshop, query); const candidates = normalizeCustomerCandidates(searchRes); if (candidates.length === 1) { - // auto-pick single hit - selectedCustomer = { custNo: String(candidates[0].custNo) }; - await redisHelpers.setSessionTransactionData(socket.id, { - ...tx, - rrSelectedCustomer: selectedCustomer.custNo, - rrCreateCustomer: false - }); - log("info", "rr-export-job:auto-selected-customer", { jobId, custNo: selectedCustomer.custNo }); + selectedCust = { custNo: String(candidates[0].custNo) }; + await redisHelpers.setSessionTransactionData( + socket.id, + ns, + RRCacheEnums.SelectedCustomer, + selectedCust.custNo, + defaultRRTTL + ); + _log("info", "rr-export-job:auto-selected-customer", { jobId, custNo: selectedCust.custNo }); } else if (candidates.length > 1) { - // multiple matches → ask UI to pick and STOP here - const table = candidates.map((c) => ({ - CustomerId: c.custNo, - customerId: c.custNo, - CustomerName: { FirstName: c.name || String(c.custNo), LastName: "" } - })); - socket.emit("rr-select-customer", table); - socket.emit("rr-log-event", { level: "info", message: "RR: customer selection required", ts: Date.now() }); + // multiple matches → table and stop + socket.emit("rr-select-customer", candidates); // FE expects [{custNo,name}] + socket.emit("rr-log-event", { + level: "info", + message: "RR: customer selection required", + ts: Date.now() + }); return; } else { - // 0 matches → auto-create by default if (autoCreateOnNoMatch) { - const { createRRCustomer } = require("../rr/rr-customers"); const created = await createRRCustomer({ bodyshop, job, socket }); const custNo = created?.custNo || created?.customerNo || created?.CustomerNo || created?.dmsRecKey; if (!custNo) throw new Error("RR create customer returned no custNo"); - selectedCustomer = { custNo: String(custNo) }; - await redisHelpers.setSessionTransactionData(socket.id, { - ...tx, - rrSelectedCustomer: selectedCustomer.custNo, - rrCreateCustomer: false - }); - log("info", "rr-export-job:auto-created-customer", { jobId, custNo: selectedCustomer.custNo }); + selectedCust = { custNo: String(custNo) }; + await redisHelpers.setSessionTransactionData( + socket.id, + ns, + RRCacheEnums.SelectedCustomer, + selectedCust.custNo, + defaultRRTTL + ); + _log("info", "rr-export-job:auto-created-customer", { jobId, custNo: selectedCust.custNo }); } else { - await redisHelpers.setSessionTransactionData(socket.id, { ...tx, rrCreateCustomer: true }); socket.emit("rr-customer-create-required"); socket.emit("rr-log-event", { level: "info", message: "RR: create customer required", ts: Date.now() }); return; } } } else { - // no usable query → fall back to create or UI prompt + // no usable query → create or prompt if (autoCreateOnNoMatch) { - const { createRRCustomer } = require("../rr/rr-customers"); const created = await createRRCustomer({ bodyshop, job, socket }); const custNo = created?.custNo || created?.customerNo || created?.CustomerNo || created?.dmsRecKey; if (!custNo) throw new Error("RR create customer returned no custNo"); - selectedCustomer = { custNo: String(custNo) }; - await redisHelpers.setSessionTransactionData(socket.id, { - ...tx, - rrSelectedCustomer: selectedCustomer.custNo, - rrCreateCustomer: false - }); - log("info", "rr-export-job:auto-created-customer(no-query)", { jobId, custNo: selectedCustomer.custNo }); + selectedCust = { custNo: String(custNo) }; + await redisHelpers.setSessionTransactionData( + socket.id, + ns, + RRCacheEnums.SelectedCustomer, + selectedCust.custNo, + defaultRRTTL + ); + _log("info", "rr-export-job:auto-created-customer(no-query)", { jobId, custNo: selectedCust.custNo }); } else { - await redisHelpers.setSessionTransactionData(socket.id, { ...tx, rrCreateCustomer: true }); socket.emit("rr-customer-create-required"); socket.emit("rr-log-event", { level: "info", message: "RR: create customer required", ts: Date.now() }); return; @@ -367,29 +433,14 @@ function registerRREvents({ socket, redisHelpers }) { } } - // -------- 6) If still not selected & creation is allowed, create now -------- - if (!selectedCustomer && (forceCreate || autoCreateOnNoMatch)) { - const { createRRCustomer } = require("../rr/rr-customers"); - const created = await createRRCustomer({ bodyshop, job, socket }); - const custNo = created?.custNo || created?.customerNo || created?.CustomerNo || created?.dmsRecKey; - if (!custNo) throw new Error("RR create customer returned no custNo"); - selectedCustomer = { custNo: String(custNo) }; - await redisHelpers.setSessionTransactionData(socket.id, { - ...tx, - rrSelectedCustomer: selectedCustomer.custNo, - rrCreateCustomer: false - }); - log("info", "rr-export-job:customer-created", { jobId, custNo: selectedCustomer.custNo }); - } + if (!selectedCust?.custNo) throw new Error("RR export: selected customer missing custNo"); - if (!selectedCustomer?.custNo) throw new Error("RR export: selected customer missing custNo"); - - // -------- 7) Perform export (ensure SV + create/update RO) -------- + // 6) Perform export (ensure SV + create/update RO inside exportJobToRR) const result = await exportJobToRR({ bodyshop, job, - selectedCustomer, - advisorNo, + selectedCustomer: selectedCust, + advisorNo: String(advisorNo), existing: payload.existing, socket }); @@ -405,10 +456,17 @@ function registerRREvents({ socket, redisHelpers }) { }); } + await redisHelpers.setSessionTransactionData( + socket.id, + ns, + RRCacheEnums.ExportResult, + result || {}, + defaultRRTTL + ); socket.emit("rr-export-job:result", { jobId, bodyshopId, result }); } catch (error) { const jobId = payload.jobId || payload.jobid || payload.txEnvelope?.jobId || payload?.job?.id; - log("error", `Error during RR export: ${error.message}`, { jobId, stack: error.stack }); + _log("error", `Error during RR export: ${error.message}`, { jobId, stack: error.stack }); try { socket.emit("export-failed", { vendor: "rr", jobId, error: error.message }); } catch { @@ -417,7 +475,7 @@ function registerRREvents({ socket, redisHelpers }) { } }); - // Allocations (RR reuses CDK calculator) + // --------- Allocations (reuse CDK calculator) --------- socket.on("rr-calculate-allocations", async (jobid, cb) => { try { const allocations = await CdkCalculateAllocations(socket, jobid);