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
};