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

This commit is contained in:
Dave
2025-11-05 16:51:11 -05:00
parent 9341806b0f
commit 286c49deb1
7 changed files with 450 additions and 173 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -162,8 +162,6 @@ function normalizeCustomerCandidates(res) {
*/
function normalizeVehicleCandidates(res) {
const blocks = blocksFromCombinedSearchResult(res);
console.log("Normalized vehicle Candiadates!!!!!!!!!!!!!!!!!!!!!");
console.dir({ res, blocks }, { depth: null });
const out = [];
for (const blk of blocks) {
const serv = Array.isArray(blk?.ServVehicle) ? blk.ServVehicle : [];

View File

@@ -160,21 +160,8 @@ async function rrGetAdvisors(bodyshop, args = {}) {
return res?.data ?? res;
}
/**
* Parts on an internal RO
* @param bodyshop
* @param args - { roNumber: string } (ERA/DMS internal RO number)
*/
async function rrGetParts(bodyshop, args = {}) {
const { client, opts } = buildClientAndOpts(bodyshop);
const payload = { roNumber: String(args.roNumber || "").trim() };
const res = await client.getParts(payload, opts);
return res?.data ?? res;
}
module.exports = {
rrCombinedSearch,
rrGetAdvisors,
rrGetParts,
buildClientAndOpts
};

View File

@@ -2,6 +2,7 @@
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");
function buildClientAndOpts(bodyshop) {
const cfg = getRRConfigFromBodyshop(bodyshop);
@@ -17,7 +18,7 @@ function buildClientAndOpts(bodyshop) {
envelope: {
sender: {
component: "Rome",
task: "SV", // Service Vehicle op code; adjust if your lib expects another
task: "SV", // default for insertServiceVehicle
referenceId: "Insert",
creator: "RCI",
senderName: "RCI"
@@ -28,56 +29,225 @@ function buildClientAndOpts(bodyshop) {
}
function buildServiceVehiclePayload({ job, custNo, overrides = {} }) {
return {
customerNo: String(custNo), // tie SV to customer
vin: overrides.vin ?? job?.v_vin,
year: overrides.year ?? job?.v_model_yr,
make: overrides.make ?? job?.v_make_des,
model: overrides.model ?? job?.v_model_desc,
licensePlate: overrides.plate ?? job?.plate_no
// add other safe keys your RR client supports (color, mileage, etc.)
// 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;
};
}
async function ensureRRServiceVehicle({ bodyshop, custNo, job, overrides = {}, socket }) {
const log = RRLogger(socket, { ns: "rr" });
const { client, opts } = buildClientAndOpts(bodyshop);
// Optional: first try a combined query by VIN to detect existing SV
try {
const queryRes = await client.combinedSearch(
{ vin: job?.v_vin, maxRecs: 1, kind: "vin" },
{
...opts,
envelope: {
...opts.envelope,
sender: { ...opts.envelope.sender, task: "CVC", referenceId: "Query" }
}
}
);
const hasVehicle = Array.isArray(queryRes?.vehicles)
? queryRes.vehicles.length > 0
: Array.isArray(queryRes) && queryRes.length > 0;
if (hasVehicle) {
return { created: false, raw: queryRes };
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;
}
} catch (e) {
// non-fatal, continue to insert
log("warn", "RR combined search failed before SV insert; proceeding to insert", { err: e?.message });
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;
};
// Derive odometer + units from job
let odometer = undefined;
let odometerUnits = undefined;
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
}
const payload = buildServiceVehiclePayload({ job, custNo, overrides });
// 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;
// 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)
};
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
}
};
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;
}
/**
* 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 DMSRecKey on insert)
// 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("error", "RR insert service vehicle returned no id", { data });
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 { created: true, svId, raw: data };
return { ensured: true, inserted: true, svId, raw: data };
}
module.exports = { ensureRRServiceVehicle };
module.exports = { ensureRRServiceVehicle, buildServiceVehiclePayload, buildClientAndOpts };

136
server/rr/rr-utils.js Normal file
View File

@@ -0,0 +1,136 @@
/**
* Get last 8 chars of a string, uppercased
* @param v
* @returns {string}
*/
const last8 = (v) => {
return (String(v || "") || "").slice(-8).toUpperCase();
};
/**
* Extract owner customer numbers from VIN-based blocks
* @param blocks
* @param jobVin
* @returns {Set<any>}
*/
const ownersFromVinBlocks = (blocks = [], jobVin = null) => {
const out = new Set();
const want8 = jobVin ? last8(jobVin) : null;
for (const blk of Array.isArray(blocks) ? blocks : []) {
const serv = Array.isArray(blk?.ServVehicle) ? blk.ServVehicle : [];
for (const sv of serv) {
const svVin = String(sv?.Vehicle?.Vin || "");
if (want8 && last8(svVin) !== want8) continue;
const custNo = sv?.VehicleServInfo?.CustomerNo;
if (custNo != null && String(custNo).trim() !== "") {
out.add(String(custNo).trim());
}
}
}
return out;
};
/**
* Make vehicle search payload from job data
* @param job
* @returns {null|{kind: string, license: string, maxResults: number}|{kind: string, vin: string, maxResults: number}}
*/
const makeVehicleSearchPayloadFromJob = (job) => {
const vin = job?.v_vin;
if (vin) return { kind: "vin", vin: String(vin).trim(), maxResults: 50 };
const plate = job?.plate_no;
if (plate) return { kind: "license", license: String(plate).trim(), maxResults: 50 };
return null;
};
/**
* Normalize customer candidates from VIN blocks
* @param res
* @param ownersSet
* @returns {any[]}
*/
const normalizeCustomerCandidates = (res, { ownersSet = null } = {}) => {
const blocks = Array.isArray(res?.data) ? res.data : Array.isArray(res) ? res : [];
const out = [];
for (const blk of blocks) {
const serv = Array.isArray(blk?.ServVehicle) ? blk.ServVehicle : [];
const custNos = serv.map((sv) => sv?.VehicleServInfo?.CustomerNo).filter(Boolean);
const nci = blk?.NameContactId;
const ind = nci?.NameId?.IndName;
const bus = nci?.NameId?.BusName;
const personal = [ind?.FirstName || ind?.FName, ind?.LastName || ind?.LName].filter(Boolean).join(" ").trim();
const company = bus?.CompanyName || bus?.BName;
const name = (personal || company || "").trim();
for (const custNo of custNos) {
const cno = String(custNo).trim();
const item = { custNo: cno, name: name || `Customer ${cno}` };
if (ownersSet && ownersSet.has(cno)) item.isVehicleOwner = true;
out.push(item);
}
}
// Dedup by custNo, keep isVehicleOwner if any
const seen = new Map();
for (const c of out) {
const key = (c.custNo || "").trim();
if (!key) continue;
const prev = seen.get(key);
if (!prev) seen.set(key, c);
else if (c.isVehicleOwner && !prev.isVehicleOwner) seen.set(key, { ...prev, isVehicleOwner: true });
}
return Array.from(seen.values());
};
/**
* Read advisor number from payload or cached value
* @param payload
* @param cached
* @returns {string|null}
*/
const readAdvisorNo = (payload, cached) => {
const v =
(payload?.txEnvelope?.advisorNo != null && String(payload.txEnvelope.advisorNo)) ||
(payload?.advisorNo != null && String(payload.advisorNo)) ||
(cached != null && String(cached)) ||
null;
return v && v.trim() !== "" ? v : null;
};
/**
* Cache enum keys for RR session transaction data
* @type {{txEnvelope: string, JobData: string, SelectedCustomer: string, AdvisorNo: string, VINCandidates: string, SelectedVin: string, ExportResult: string}}
*/
const RRCacheEnums = {
txEnvelope: "RR.txEnvelope",
JobData: "RR.JobData",
SelectedCustomer: "RR.SelectedCustomer",
AdvisorNo: "RR.AdvisorNo",
VINCandidates: "RR.VINCandidates",
SelectedVin: "RR.SelectedVin",
ExportResult: "RR.ExportResult"
};
/**
* Get transaction type string for job ID
* @param jobid
* @returns {`rr:${string}`}
*/
const getTransactionType = (jobid) => `rr:${jobid}`;
/**
* Default RR TTL (1 hour)
* @type {number}
*/
const defaultRRTTL = 60 * 60;
module.exports = {
RRCacheEnums,
defaultRRTTL,
getTransactionType,
ownersFromVinBlocks,
makeVehicleSearchPayloadFromJob,
normalizeCustomerCandidates,
readAdvisorNo
};

View File

@@ -7,94 +7,36 @@ const { QueryJobData } = require("../rr/rr-job-helpers");
const { exportJobToRR } = require("../rr/rr-job-export");
const CdkCalculateAllocations = require("../cdk/cdk-calculate-allocations").default;
const { createRRCustomer } = require("../rr/rr-customers");
const { ensureRRServiceVehicle } = require("../rr/rr-service-vehicles");
const {
makeVehicleSearchPayloadFromJob,
ownersFromVinBlocks,
readAdvisorNo,
getTransactionType,
normalizeCustomerCandidates,
defaultRRTTL,
RRCacheEnums
} = require("../rr/rr-utils");
const { GraphQLClient } = require("graphql-request");
const queries = require("../graphql-client/queries");
const getTransactionType = (jobid) => `rr:${jobid}`;
const defaultRRTTL = 60 * 60;
// ---------------- cache keys (RR) ----------------
const RRCacheEnums = {
txEnvelope: "RR.txEnvelope",
JobData: "RR.JobData",
SelectedCustomer: "RR.SelectedCustomer",
AdvisorNo: "RR.AdvisorNo",
VINCandidates: "RR.VINCandidates",
SelectedVin: "RR.SelectedVin",
ExportResult: "RR.ExportResult"
};
// ---------------- utils ----------------
function resolveJobId(explicit, payload, job) {
return explicit || payload?.jobId || payload?.jobid || job?.id || job?.jobId || job?.jobid || null;
}
const digitsOnly = (s) => String(s || "").replace(/\D/g, "");
const makeVehicleSearchPayloadFromJob = (job) => {
const vin = job?.v_vin;
if (vin) return { kind: "vin", vin: String(vin).trim(), maxResults: 50 };
const plate = job?.plate_no;
if (plate) return { kind: "license", license: String(plate).trim(), maxResults: 50 };
return null;
};
const makeCustomerSearchPayloadFromJob = (job) => {
const phone = job?.ownr_ph1 || job?.ownr_ph2;
const d = digitsOnly(phone);
if (d.length >= 7) return { kind: "phone", phone: d, maxResults: 50 };
const firstName = job?.ownr_fn;
const lastName = job?.ownr_ln;
const company = job?.ownr_co_nm;
if (firstName || lastName) {
const nameObj = {};
if (firstName) nameObj.fname = String(firstName).trim();
if (lastName) nameObj.lname = String(lastName).trim();
return { kind: "name", name: nameObj, maxResults: 50 };
}
if (company) {
return { kind: "name", name: { name: String(company).trim() }, maxResults: 50 };
}
const vin = job?.v_vin;
if (vin) return { kind: "vin", vin: String(vin).trim(), maxResults: 50 };
return null;
};
// Normalize candidates FE expects: { custNo, name } and flag vinOwner when sourced via VIN
const normalizeCustomerCandidates = (res, { markVinOwner = false } = {}) => {
const blocks = Array.isArray(res?.data) ? res.data : Array.isArray(res) ? res : [];
const out = [];
for (const blk of blocks) {
const serv = Array.isArray(blk?.ServVehicle) ? blk.ServVehicle : [];
const custNos = serv.map((sv) => sv?.VehicleServInfo?.CustomerNo).filter(Boolean);
const nci = blk?.NameContactId;
const ind = nci?.NameId?.IndName;
const bus = nci?.NameId?.BusName;
const personal = [ind?.FirstName || ind?.FName, ind?.LastName || ind?.LName].filter(Boolean).join(" ").trim();
const company = bus?.CompanyName || bus?.BName;
const name = (personal || company || "").trim();
for (const custNo of custNos) {
out.push({
custNo: String(custNo),
name: name || `Customer ${custNo}`,
...(markVinOwner ? { vinOwner: true } : {})
});
}
}
const seen = new Set();
return out.filter((c) => {
const key = String(c.custNo || "").trim();
if (!key || seen.has(key)) return false;
seen.add(key);
return true;
});
};
function sortVehicleOwnerFirst(list) {
return list
.map((v, i) => ({ v, i }))
.sort((a, b) => {
const ao = a.v?.isVehicleOwner ? 1 : 0;
const bo = b.v?.isVehicleOwner ? 1 : 0;
if (ao !== bo) return bo - ao;
return a.i - b.i;
})
.map(({ v }) => v);
}
async function getSessionOrSocket(redisHelpers, socket) {
let sess = null;
@@ -114,54 +56,77 @@ async function getBodyshopForSocket({ bodyshopId, socket }) {
if (!endpoint) throw new Error("GRAPHQL_ENDPOINT not configured");
const token = (socket?.data && socket.data.authToken) || (socket?.handshake?.auth && socket.handshake.auth.token);
const client = new GraphQLClient(endpoint, {});
const res = await client
.setHeaders({ Authorization: `Bearer ${token}` })
.request(queries.GET_BODYSHOP_BY_ID, { id: bodyshopId });
const res = await client.setHeaders({ Authorization: `Bearer ${token}` }).request(queries.GET_BODYSHOP_BY_ID, {
id: bodyshopId
});
const bodyshop = res?.bodyshops_by_pk;
if (!bodyshop) throw new Error(`Bodyshop not found: ${bodyshopId}`);
return bodyshop;
}
function readAdvisorNo(payload, cached) {
const v =
(payload?.txEnvelope?.advisorNo != null && String(payload.txEnvelope.advisorNo)) ||
(payload?.advisorNo != null && String(payload.advisorNo)) ||
(cached != null && String(cached)) ||
null;
return v && v.trim() !== "" ? v : null;
}
// VIN + Name merge; keep vinOwner flag if any source came from VIN
async function rrMultiCustomerSearch(bodyshop, job, 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)
*/
async function rrMultiCustomerSearch({ bodyshop, job, socket, redisHelpers }) {
const queries = [];
// 1) Full Name (preferred)
const firstName = job?.ownr_fn && String(job.ownr_fn).trim();
const lastName = job?.ownr_ln && String(job.ownr_ln).trim();
const company = job?.ownr_co_nm && String(job.ownr_co_nm).trim();
if (firstName || lastName) {
queries.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 });
}
// 2) VIN (owner association)
const vehQ = makeVehicleSearchPayloadFromJob(job);
if (vehQ) queries.push({ q: vehQ, fromVin: vehQ.kind === "vin" });
const custQ = makeCustomerSearchPayloadFromJob(job);
if (custQ) queries.push({ q: custQ, fromVin: false });
if (vehQ && vehQ.kind === "vin") queries.push({ q: vehQ, fromVin: true });
if (!queries.length) return [];
const all = [];
let ownersSet = null;
const merged = [];
for (const { q, fromVin } of queries) {
try {
CreateRRLogEvent(socket, "DEBUG", `{RR-SEARCH} Executing ${q.kind} query`, { q });
const res = await rrCombinedSearch(bodyshop, q);
const norm = normalizeCustomerCandidates(res, { markVinOwner: !!fromVin });
all.push(...norm);
// If VIN query, compute ownersSet & cache raw blocks
if (fromVin) {
const blocks = Array.isArray(res?.data) ? res.data : [];
ownersSet = ownersFromVinBlocks(blocks, job?.v_vin);
try {
await redisHelpers.setSessionTransactionData(
socket.id,
getTransactionType(job.id),
RRCacheEnums.VINCandidates,
blocks,
defaultRRTTL
);
} catch {
//
}
}
const norm = normalizeCustomerCandidates(res, { ownersSet });
merged.push(...norm);
} catch (e) {
CreateRRLogEvent(socket, "WARN", "Multi-search subquery failed", { kind: q.kind, error: e.message });
}
}
const byCust = new Map();
for (const c of all) {
const key = c?.custNo && String(c.custNo).trim();
if (!key) continue;
const prev = byCust.get(key);
if (!prev) byCust.set(key, c);
else if (c.vinOwner && !prev.vinOwner) byCust.set(key, { ...prev, vinOwner: true });
}
return Array.from(byCust.values());
return sortVehicleOwnerFirst(merged);
}
// ---------------- register handlers ----------------
@@ -172,12 +137,23 @@ function registerRREvents({ socket, redisHelpers }) {
const { bodyshopId } = await getSessionOrSocket(redisHelpers, socket);
const bodyshop = await getBodyshopForSocket({ bodyshopId, socket });
CreateRRLogEvent(socket, "DEBUG", "rr-lookup-combined: begin", { jobid, params });
const res = await rrCombinedSearch(bodyshop, params || {});
const normalized = normalizeCustomerCandidates(res);
let ownersSet = null;
if ((params?.kind || "").toLowerCase() === "vin") {
const blocks = Array.isArray(res?.data) ? res.data : [];
ownersSet = ownersFromVinBlocks(blocks); // no job VIN filter in ad-hoc lookup
}
const normalized = sortVehicleOwnerFirst(normalizeCustomerCandidates(res, { ownersSet }));
const rid = resolveJobId(jobid, { jobid }, null);
cb?.({ jobid: rid, data: normalized });
socket.emit("rr-select-customer", normalized);
CreateRRLogEvent(socket, "DEBUG", "rr-lookup-combined: emitted rr-select-customer", { count: normalized.length });
CreateRRLogEvent(socket, "DEBUG", "rr-lookup-combined: emitted rr-select-customer", {
count: normalized.length
});
} catch (e) {
CreateRRLogEvent(socket, "ERROR", "RR combined lookup error", { error: e.message, jobid });
cb?.({ jobid, error: e.message });
@@ -221,7 +197,7 @@ function registerRREvents({ socket, redisHelpers }) {
});
// ================= Fortellis-style two-step export =================
// 1) Stage export -> search -> emit rr-select-customer
// 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);
try {
@@ -265,13 +241,13 @@ function registerRREvents({ socket, redisHelpers }) {
const { bodyshopId } = await getSessionOrSocket(redisHelpers, socket);
const bodyshop = await getBodyshopForSocket({ bodyshopId, socket });
CreateRRLogEvent(socket, "DEBUG", `{2} Running multi-search (VIN + Name)`);
const candidates = await rrMultiCustomerSearch(bodyshop, job, socket);
CreateRRLogEvent(socket, "DEBUG", `{2} Running multi-search (Full Name + VIN)`);
const candidates = await rrMultiCustomerSearch({ bodyshop, job, socket, redisHelpers });
socket.emit("rr-select-customer", candidates);
CreateRRLogEvent(socket, "DEBUG", `{2.1} Emitted rr-select-customer`, {
count: candidates.length,
anyVinOwner: candidates.some((c) => c.vinOwner)
anyOwner: candidates.some((c) => c.isVehicleOwner)
});
} catch (error) {
CreateRRLogEvent(socket, "ERROR", `Error during RR export (prepare)`, {
@@ -287,7 +263,7 @@ function registerRREvents({ socket, redisHelpers }) {
}
});
// 2) Selection (or create) -> export
// 2) Selection (or create) -> ensure vehicle -> export
socket.on("rr-selected-customer", async ({ jobid, jobId, selectedCustomerId, custNo, create } = {}, ack) => {
const rid = resolveJobId(jobid || jobId, { jobid, jobId }, null);
try {
@@ -329,6 +305,16 @@ function registerRREvents({ socket, redisHelpers }) {
);
CreateRRLogEvent(socket, "DEBUG", `{3.3} Cached selected customer`, { custNo: String(selectedCustNo) });
// Ensure service vehicle exists and is owned by selected customer (uses cached VIN blocks when present)
const ensureVeh = await ensureRRServiceVehicle({
bodyshop,
custNo: String(selectedCustNo),
job,
socket,
redisHelpers
});
CreateRRLogEvent(socket, "DEBUG", `{3.4} ensureRRServiceVehicle`, ensureVeh);
const cachedAdvisor = await redisHelpers.getSessionTransactionData(socket.id, ns, RRCacheEnums.AdvisorNo);
const advisorNo = readAdvisorNo({ txEnvelope }, cachedAdvisor);
if (!advisorNo) {