feature/IO-3357-Reynolds-and-Reynolds-DMS-API-Integration - Checkpoint

This commit is contained in:
Dave
2025-11-04 15:18:08 -05:00
parent aa692d4d05
commit c60dfa4319
2 changed files with 196 additions and 138 deletions

View File

@@ -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);