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

This commit is contained in:
Dave
2025-11-07 11:01:56 -05:00
parent d6b295855d
commit a788beaa19
8 changed files with 362 additions and 322 deletions

View File

@@ -1,253 +1,203 @@
// server/rr/rr-service-vehicles.js
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");
// File: server/rr/rr-service-vehicles.js
// Idempotent Service Vehicle ensure: if VIN exists (owner match or not), don't fail.
function buildClientAndOpts(bodyshop) {
const cfg = getRRConfigFromBodyshop(bodyshop);
const client = new RRClient({
baseUrl: cfg.baseUrl,
username: cfg.username,
password: cfg.password,
timeoutMs: cfg.timeoutMs,
retries: cfg.retries
});
const opts = {
routing: cfg.routing,
const RRLogger = require("./rr-logger");
const { buildClientAndOpts, rrCombinedSearch } = require("./rr-lookup");
// --- helpers ---
function pickVin({ vin, job }) {
const v = vin || job?.v_vin || job?.vehicle?.vin || job?.vin || job?.vehicleVin || null;
if (!v) return "";
return String(v)
.replace(/[^A-Za-z0-9]/g, "")
.toUpperCase()
.slice(0, 17);
}
function pickCustNo({ selectedCustomerNo, custNo, customerNo }) {
const c = selectedCustomerNo ?? custNo ?? customerNo ?? null;
return c != null ? String(c).trim() : "";
}
function ownersFromCombined(res, wantedVin) {
const blocks = Array.isArray(res?.data) ? res.data : Array.isArray(res) ? res : [];
const owners = new Set();
for (const blk of blocks) {
const serv = Array.isArray(blk?.ServVehicle) ? blk.ServVehicle : [];
for (const sv of serv) {
const v = sv?.Vehicle || {};
const vin = (v?.Vin || v?.VIN || v?.vin || "").toString().toUpperCase();
const cust = sv?.VehicleServInfo?.CustomerNo;
if (vin && cust && (!wantedVin || vin === wantedVin)) {
owners.add(String(cust));
}
}
}
return owners;
}
function isAlreadyExistsError(e) {
if (!e) return false;
if (e.code === 300) return true;
const msg = (e.message || "").toUpperCase();
return msg.includes("ALREADY EXISTS") || msg.includes("VEHICLE ALREADY EXISTS");
}
/**
* Ensure/create a Service Vehicle in RR for the given VIN + customer.
* Args may contain:
* - client, routing (prebuilt) OR bodyshop (we'll build a client)
* - vin OR job (we'll derive vin from job)
* - selectedCustomerNo / custNo / customerNo
* - license (optional)
* - socket (for logging)
* - logNs (namespace for logs)
*
* Returns: { created:boolean, exists:boolean, vin, customerNo, svId?, status? }
*/
async function ensureRRServiceVehicle(args = {}) {
const {
client: inClient,
routing: inRouting,
bodyshop,
job,
vin,
selectedCustomerNo,
custNo,
customerNo,
license,
socket,
logNs = "rr-service-vehicles"
} = args;
const log = RRLogger(socket, { ns: logNs });
// Build/derive essentials
let client = inClient;
let routing = inRouting;
if (!client || !routing) {
if (!bodyshop) {
throw new Error("ensureRRServiceVehicle: either (client & routing) or bodyshop is required");
}
const built = buildClientAndOpts(bodyshop);
client = built.client;
routing = built.opts?.routing;
}
const vinStr = pickVin({ vin, job });
const custNoStr = pickCustNo({ selectedCustomerNo, custNo, customerNo });
if (!vinStr) throw new Error("ensureRRServiceVehicle: vin required");
if (!custNoStr) throw new Error("ensureRRServiceVehicle: customerNo required");
// --- Preflight: does VIN already exist (and under whom)? ---
try {
let owners = new Set();
if (bodyshop) {
const comb = await rrCombinedSearch(bodyshop, { kind: "vin", vin: vinStr, maxResults: 50 });
owners = ownersFromCombined(comb, vinStr);
}
// Short-circuit: VIN exists anywhere -> don't try to insert
if (owners.size > 0) {
const ownedBySame = owners.has(custNoStr);
log(ownedBySame ? "info" : "warn", "{SV} VIN already present in RR; skipping insert", {
vin: vinStr,
selectedCustomerNo: custNoStr,
owners: Array.from(owners)
});
return {
created: false,
exists: true,
vin: vinStr,
customerNo: custNoStr,
ownedBySame
};
}
} catch (e) {
// Preflight shouldn't be fatal; log and continue to insert (idempotency will still be handled)
log("warn", "{SV} VIN preflight lookup failed; continuing to insert", { vin: vinStr, error: e?.message });
}
// --- Attempt insert (idempotent) ---
const insertPayload = {
vin: vinStr,
customerNo: custNoStr,
license: license ? String(license).trim() : undefined
};
const insertOpts = {
routing,
envelope: {
sender: {
component: "Rome",
task: "SV", // default for insertServiceVehicle
task: "SV",
referenceId: "Insert",
creator: "RCI",
senderName: "RCI"
}
}
};
return { client, opts };
}
function buildServiceVehiclePayload({ job, custNo, overrides = {} }) {
// 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;
};
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;
}
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;
};
log("debug", "{SV} Inserting service vehicle", {
vin: vinStr,
selectedCustomerNo: custNoStr,
payloadShape: Object.keys(insertPayload).filter((k) => insertPayload[k] != null)
});
// Derive odometer + units from job
let odometer = undefined;
let odometerUnits = undefined;
// Be tolerant to method name differences
const fnName =
typeof client.insertServiceVehicle === "function"
? "insertServiceVehicle"
: typeof client.serviceVehicleInsert === "function"
? "serviceVehicleInsert"
: null;
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
if (!fnName) {
throw new Error("RR client does not expose insertServiceVehicle/serviceVehicleInsert");
}
// 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;
try {
const res = await client[fnName](insertPayload, insertOpts);
const data = res?.data ?? {};
const svId = data?.dmsRecKey || data?.svId || undefined;
// 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)
log("info", "{SV} insertServiceVehicle: success", { vin: vinStr, customerNo: custNoStr, svId });
return {
created: true,
exists: false,
vin: vinStr,
customerNo: custNoStr,
svId,
status: res?.statusBlocks?.transaction
};
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
} catch (e) {
if (isAlreadyExistsError(e)) {
// Treat as idempotent success
log("warn", "{SV} insertServiceVehicle: already exists; treating as success", {
vin: vinStr,
customerNo: custNoStr,
code: e?.code,
status: e?.meta?.status || e?.status
});
return {
created: false,
exists: true,
vin: vinStr,
customerNo: custNoStr,
status: e?.meta?.status || e?.status
};
}
};
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;
log("error", "{SV} insertServiceVehicle: failure", {
message: e?.message,
code: e?.code,
status: e?.meta?.status || e?.status
});
throw e;
}
}
/**
* 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 a DMS key)
const svId = data?.svId ?? data?.serviceVehicleId ?? data?.dmsRecKey ?? data?.DMSRecKey;
if (!svId) {
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 { ensured: true, inserted: true, svId, raw: data };
}
module.exports = { ensureRRServiceVehicle, buildServiceVehiclePayload, buildClientAndOpts };
module.exports = {
ensureRRServiceVehicle
};