feature/IO-3357-Reynolds-and-Reynolds-DMS-API-Integration - Checkpoint
This commit is contained in:
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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 : [];
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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
136
server/rr/rr-utils.js
Normal 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
|
||||
};
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user