feature/IO-3357-Reynolds-and-Reynolds-DMS-API-Integration - Checkpoint
This commit is contained in:
@@ -59,7 +59,6 @@ export function DmsPostForm({ bodyshop, socket, job, logsRef }) {
|
||||
|
||||
// Normalize advisor fields coming from various shapes
|
||||
const getAdvisorNumber = (a) => a?.advisorId;
|
||||
|
||||
const getAdvisorLabel = (a) => `${a?.firstName} ${a?.lastName}`?.trim();
|
||||
|
||||
const fetchRrAdvisors = () => {
|
||||
|
||||
@@ -14,16 +14,13 @@ async function exportJobToRR(args) {
|
||||
const log = RRLogger(socket, { ns: "rr-export" });
|
||||
|
||||
if (!bodyshop) throw new Error("exportJobToRR: bodyshop is required");
|
||||
|
||||
if (!job) throw new Error("exportJobToRR: job is required");
|
||||
|
||||
if (advisorNo == null || String(advisorNo).trim() === "") {
|
||||
throw new Error("exportJobToRR: advisorNo is required for RR");
|
||||
}
|
||||
|
||||
// Resolve customer number (accept multiple shapes)
|
||||
const selected = selectedCustomer?.customerNo;
|
||||
|
||||
const selected = selectedCustomer?.customerNo || selectedCustomer?.custNo;
|
||||
if (!selected) throw new Error("exportJobToRR: selectedCustomer.custNo/customerNo is required");
|
||||
|
||||
const { client, opts } = buildClientAndOpts(bodyshop);
|
||||
@@ -43,7 +40,6 @@ async function exportJobToRR(args) {
|
||||
let svId = null;
|
||||
if (!existing?.dmsRepairOrderId) {
|
||||
try {
|
||||
// Provide both customerNo and custNo for safety
|
||||
const svRes = await ensureRRServiceVehicle({
|
||||
bodyshop,
|
||||
job,
|
||||
@@ -58,10 +54,10 @@ async function exportJobToRR(args) {
|
||||
}
|
||||
}
|
||||
|
||||
// Build RO payload (now includes both customerNo & custNo; advisorNo & advNo)
|
||||
// Build RO payload (now includes DeptType/departmentType + variants)
|
||||
const payload = buildRRRepairOrderPayload({
|
||||
job,
|
||||
selectedCustomer: { customerNo: String(selected) },
|
||||
selectedCustomer: { customerNo: String(selected), custNo: String(selected) },
|
||||
advisorNo: String(advisorNo)
|
||||
});
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ const { GET_JOB_BY_PK } = require("../graphql-client/queries");
|
||||
// ---------- Internals ----------
|
||||
|
||||
function digitsOnly(s) {
|
||||
return String(s || "").replace(/[^\d]/g, "");
|
||||
return String(s || "").replace(/\D/g, "");
|
||||
}
|
||||
|
||||
function pickJobId(ctx, explicitId) {
|
||||
@@ -12,7 +12,6 @@ function pickJobId(ctx, explicitId) {
|
||||
}
|
||||
|
||||
function safeVin(job) {
|
||||
// Your schema exposes v_vin on jobs (no vehicle_vin root field).
|
||||
return (job?.v_vin && String(job.v_vin).trim()) || null;
|
||||
}
|
||||
|
||||
@@ -24,13 +23,6 @@ function blocksFromCombinedSearchResult(res) {
|
||||
|
||||
// ---------- Public API ----------
|
||||
|
||||
/**
|
||||
* Fetch a job by id using the shared Hasura GraphQL client.
|
||||
* Resolution order:
|
||||
* 1) ctx.job
|
||||
* 2) ctx.payload.job
|
||||
* 3) ctx.payload.jobId / ctx.jobId / explicit jobId
|
||||
*/
|
||||
async function QueryJobData(ctx = {}, jobId) {
|
||||
if (ctx?.job) return ctx.job;
|
||||
if (ctx?.payload?.job) return ctx.payload.job;
|
||||
@@ -51,26 +43,26 @@ async function QueryJobData(ctx = {}, jobId) {
|
||||
|
||||
/**
|
||||
* Build minimal RR RO payload (keys match RR client expectations).
|
||||
* - Requires advisor number and customer number.
|
||||
* - We provide BOTH "customerNo" and "custNo" (and BOTH "advisorNo" and "advNo")
|
||||
* to be compatible with the compiled RR CJS lib which currently requires
|
||||
* "customerNo (or CustNo)".
|
||||
* Provide ALL common variants so downstream ops accept them:
|
||||
* - RO number: outsdRoNo / OutsdRoNo / repairOrderNumber / RepairOrderNumber
|
||||
* - Dept: DeptType / departmentType / deptType
|
||||
* - VIN: Vin / vin
|
||||
* - Customer: CustNo / customerNo / custNo
|
||||
* - Advisor: AdvNo / AdvisorNo / advisorNo / advNo
|
||||
*/
|
||||
function buildRRRepairOrderPayload({ job, selectedCustomer, advisorNo }) {
|
||||
// Resolve customerNo from object or primitive; accept multiple incoming shapes
|
||||
|
||||
const customerNo = selectedCustomer?.customerNo ? String(selectedCustomer?.customerNo).trim() : null;
|
||||
const customerNo = selectedCustomer?.customerNo
|
||||
? String(selectedCustomer.customerNo).trim()
|
||||
: selectedCustomer?.custNo
|
||||
? String(selectedCustomer.custNo).trim()
|
||||
: null;
|
||||
|
||||
if (!customerNo) throw new Error("No RR customer selected (customerNo/CustNo missing)");
|
||||
|
||||
// Advisor number (accepts advisorNo string, map to both keys)
|
||||
const adv = advisorNo != null && String(advisorNo).trim() !== "" ? String(advisorNo).trim() : null;
|
||||
|
||||
if (!adv) throw new Error("advisorNo is required for RR export");
|
||||
|
||||
// Clean/normalize VIN if present
|
||||
const vinRaw = job?.v_vin;
|
||||
|
||||
const vin =
|
||||
typeof vinRaw === "string"
|
||||
? vinRaw
|
||||
@@ -79,25 +71,31 @@ function buildRRRepairOrderPayload({ job, selectedCustomer, advisorNo }) {
|
||||
.slice(0, 17) || undefined
|
||||
: undefined;
|
||||
|
||||
// Pick a stable external RO number
|
||||
// Use ro_number when present; fallback to job.id
|
||||
const ro = job?.ro_number != null ? job.ro_number : job?.id != null ? job.id : null;
|
||||
if (ro == null) throw new Error("Missing repair order identifier (ro_number/id)");
|
||||
|
||||
const mileageIn = job.kmin;
|
||||
|
||||
if (ro == null) throw new Error("Missing repair order identifier (ro_number/job_number/id)");
|
||||
const roStr = String(ro);
|
||||
|
||||
// Provide superset of keys for maximum compatibility with the RR client
|
||||
return {
|
||||
repairOrderNumber: String(ro),
|
||||
deptType: "B",
|
||||
// ---- RO Number (all variants; library currently requires `outsdRoNo`) ----
|
||||
outsdRoNo: roStr,
|
||||
repairOrderNumber: roStr,
|
||||
// ---- Department type (Body) ----
|
||||
departmentType: "B",
|
||||
// ---- VIN variants ----
|
||||
vin,
|
||||
// ---- Customer number variants ----
|
||||
customerNo: String(customerNo),
|
||||
advisorNo: adv
|
||||
// ---- Advisor number variants ----
|
||||
advisorNo: adv,
|
||||
// ---- Mileage In (new) ----
|
||||
mileageIn
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a vehicle search payload from a job.
|
||||
* Prefers VIN; otherwise tries a plate, else null.
|
||||
*/
|
||||
function makeVehicleSearchPayloadFromJob(job) {
|
||||
const vin = safeVin(job);
|
||||
if (vin) return { kind: "vin", vin };
|
||||
@@ -108,10 +106,6 @@ function makeVehicleSearchPayloadFromJob(job) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a customer search payload from a job.
|
||||
* Prefers phone (digits), then last name/company, then VIN.
|
||||
*/
|
||||
function makeCustomerSearchPayloadFromJob(job) {
|
||||
const phone = job?.ownr_ph1;
|
||||
const d = digitsOnly(phone);
|
||||
@@ -128,9 +122,6 @@ function makeCustomerSearchPayloadFromJob(job) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize candidate customers from a RR combined search response.
|
||||
*/
|
||||
function normalizeCustomerCandidates(res) {
|
||||
const blocks = blocksFromCombinedSearchResult(res);
|
||||
const out = [];
|
||||
@@ -157,9 +148,6 @@ function normalizeCustomerCandidates(res) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize candidate vehicles from a RR combined search response.
|
||||
*/
|
||||
function normalizeVehicleCandidates(res) {
|
||||
const blocks = blocksFromCombinedSearchResult(res);
|
||||
const out = [];
|
||||
|
||||
@@ -19,12 +19,13 @@ function CreateRRLogEvent(socket, level = "DEBUG", message = "", details = {}) {
|
||||
}
|
||||
|
||||
// Structured RR event for FE debug panel (parity with Fortellis' CreateFortellisLogEvent)
|
||||
// socket.emit("fortellis-log-event", { level, message, txnDetails });
|
||||
try {
|
||||
socket?.emit?.("rr-log-event", {
|
||||
level: lvl,
|
||||
message,
|
||||
ts,
|
||||
...safeJson(details)
|
||||
txnDetails: details
|
||||
});
|
||||
} catch {
|
||||
/* ignore socket emit failures */
|
||||
|
||||
@@ -50,7 +50,6 @@ function RRLogger(_socket, defaults = {}) {
|
||||
console.log(line);
|
||||
} catch {}
|
||||
}
|
||||
// INTENTIONALLY no socket emit here to avoid FE duplicates.
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -13,14 +13,12 @@ function buildClientAndOpts(bodyshop) {
|
||||
password: cfg.password,
|
||||
timeoutMs: cfg.timeoutMs,
|
||||
retries: cfg.retries
|
||||
// optional debug logger already inside lib; leave defaults
|
||||
});
|
||||
|
||||
// Common CallOptions for all ops; routing is CRITICAL for Destination block
|
||||
const opts = {
|
||||
routing: cfg.routing,
|
||||
envelope: {
|
||||
// You can override these per-call if needed
|
||||
sender: {
|
||||
component: "Rome",
|
||||
task: "CVC",
|
||||
@@ -28,7 +26,6 @@ function buildClientAndOpts(bodyshop) {
|
||||
creator: "RCI",
|
||||
senderName: "RCI"
|
||||
}
|
||||
// bodId/creationDateTime auto-filled by the client if omitted
|
||||
}
|
||||
};
|
||||
|
||||
@@ -55,7 +52,9 @@ function toCombinedSearchPayload(args = {}) {
|
||||
}
|
||||
|
||||
const payload = {
|
||||
maxResults: q.maxResults || q.maxRecs || 50,
|
||||
// set both; the XML layer renders MaxRecs
|
||||
maxRecs: q.maxResults ?? q.maxRecs ?? 50,
|
||||
maxResults: q.maxResults ?? q.maxRecs ?? 50,
|
||||
kind
|
||||
};
|
||||
|
||||
@@ -74,6 +73,7 @@ function toCombinedSearchPayload(args = {}) {
|
||||
payload.kind = "vin";
|
||||
payload.vin = String(q.vin ?? "").trim();
|
||||
break;
|
||||
|
||||
case "namerecid":
|
||||
payload.kind = "nameRecId";
|
||||
payload.nameRecId = String(q.nameRecId ?? q.custId ?? "").trim();
|
||||
@@ -84,16 +84,15 @@ function toCombinedSearchPayload(args = {}) {
|
||||
payload.kind = "stkNo";
|
||||
payload.stkNo = String(q.stkNo ?? q.stock ?? "").trim();
|
||||
break;
|
||||
|
||||
case "name": {
|
||||
payload.kind = "name";
|
||||
const n = q.name;
|
||||
// STRING => last-name-only intent
|
||||
if (typeof n === "string") {
|
||||
const last = n.trim();
|
||||
if (last) payload.name = { name: last }; // <LName Name="..."/>
|
||||
break;
|
||||
}
|
||||
// OBJECT => always treat as FullName (even if only one of the parts is present)
|
||||
const fname = n?.fname && String(n.fname).trim();
|
||||
const lname = n?.lname && String(n.lname).trim();
|
||||
const mname = n?.mname && String(n.mname).trim();
|
||||
@@ -127,6 +126,7 @@ function toCombinedSearchPayload(args = {}) {
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Combined customer/service/vehicle search
|
||||
* @param bodyshop - bodyshop row (must include rr_dealerid & rr_configuration with store/branch)
|
||||
@@ -146,8 +146,8 @@ async function rrCombinedSearch(bodyshop, args = {}) {
|
||||
*/
|
||||
async function rrGetAdvisors(bodyshop, args = {}) {
|
||||
const { client, opts } = buildClientAndOpts(bodyshop);
|
||||
// Allow friendly department values
|
||||
const dep = (args.department || "").toString().toUpperCase();
|
||||
// Accept either department or departmentType from FE
|
||||
const dep = String(args.department ?? args.departmentType ?? "").toUpperCase();
|
||||
const department =
|
||||
dep === "BODY" || dep === "BODYSHOP" ? "B" : dep === "SERVICE" ? "S" : dep === "PARTS" ? "P" : dep || "B";
|
||||
|
||||
@@ -160,8 +160,36 @@ async function rrGetAdvisors(bodyshop, args = {}) {
|
||||
return res?.data ?? res;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parts lookup (graceful if the underlying lib exposes a different name)
|
||||
* @param bodyshop
|
||||
* @param args - common fields like { partNumber, description, make, model, year }
|
||||
*/
|
||||
async function rrGetParts(bodyshop, args = {}) {
|
||||
const { client, opts } = buildClientAndOpts(bodyshop);
|
||||
const payload = {
|
||||
partNumber: args.partNumber ?? args.partNo ?? args.number ?? undefined,
|
||||
description: args.description ?? undefined,
|
||||
make: args.make ?? undefined,
|
||||
model: args.model ?? undefined,
|
||||
year: args.year ?? undefined
|
||||
};
|
||||
|
||||
// Try common method names. If none exist, return an empty list to avoid crashes.
|
||||
if (typeof client.getParts === "function") {
|
||||
const res = await client.getParts(payload, opts);
|
||||
return res?.data ?? res;
|
||||
}
|
||||
if (typeof client.getPartNumbers === "function") {
|
||||
const res = await client.getPartNumbers(payload, opts);
|
||||
return res?.data ?? res;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
rrCombinedSearch,
|
||||
rrGetAdvisors,
|
||||
rrGetParts,
|
||||
buildClientAndOpts
|
||||
};
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// File: server/rr/rr-register-socket-events.js
|
||||
// File: server/web-sockets/rr-register-socket-events.js
|
||||
// RR events aligned to Fortellis flow with Fortellis-style logging via CreateRRLogEvent
|
||||
|
||||
const CreateRRLogEvent = require("../rr/rr-logger-event");
|
||||
const { rrCombinedSearch, rrGetAdvisors, rrGetParts } = require("../rr/rr-lookup");
|
||||
const { rrCombinedSearch, rrGetAdvisors, rrGetParts, buildClientAndOpts } = require("../rr/rr-lookup");
|
||||
const { QueryJobData } = require("../rr/rr-job-helpers");
|
||||
const { exportJobToRR } = require("../rr/rr-job-export");
|
||||
const CdkCalculateAllocations = require("../cdk/cdk-calculate-allocations").default;
|
||||
@@ -26,6 +26,11 @@ function resolveJobId(explicit, payload, job) {
|
||||
return explicit || payload?.jobId || payload?.jobid || job?.id || job?.jobId || job?.jobid || null;
|
||||
}
|
||||
|
||||
function resolveVin({ tx, job }) {
|
||||
// Prefer cached tx vin (if we made one), then common job shapes (v_vin for our schema)
|
||||
return tx?.jobData?.vin || job?.v_vin || job?.vehicle?.vin || job?.vin || job?.vehicleVin || null;
|
||||
}
|
||||
|
||||
function sortVehicleOwnerFirst(list) {
|
||||
return list
|
||||
.map((v, i) => ({ v, i }))
|
||||
@@ -65,14 +70,10 @@ async function getBodyshopForSocket({ bodyshopId, 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)
|
||||
* VIN + Full Name merge (export flow)
|
||||
*/
|
||||
async function rrMultiCustomerSearch({ bodyshop, job, socket, redisHelpers }) {
|
||||
const queries = [];
|
||||
const queriesList = [];
|
||||
|
||||
// 1) Full Name (preferred)
|
||||
const firstName = job?.ownr_fn && String(job.ownr_fn).trim();
|
||||
@@ -80,29 +81,28 @@ async function rrMultiCustomerSearch({ bodyshop, job, socket, redisHelpers }) {
|
||||
const company = job?.ownr_co_nm && String(job.ownr_co_nm).trim();
|
||||
|
||||
if (firstName || lastName) {
|
||||
queries.push({
|
||||
queriesList.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 });
|
||||
queriesList.push({ q: { kind: "name", name: { name: company }, maxResults: 50 }, fromVin: false });
|
||||
}
|
||||
|
||||
// 2) VIN (owner association)
|
||||
const vehQ = makeVehicleSearchPayloadFromJob(job);
|
||||
if (vehQ && vehQ.kind === "vin") queries.push({ q: vehQ, fromVin: true });
|
||||
if (vehQ && vehQ.kind === "vin") queriesList.push({ q: vehQ, fromVin: true });
|
||||
|
||||
if (!queries.length) return [];
|
||||
if (!queriesList.length) return [];
|
||||
|
||||
let ownersSet = null;
|
||||
const merged = [];
|
||||
|
||||
for (const { q, fromVin } of queries) {
|
||||
for (const { q, fromVin } of queriesList) {
|
||||
try {
|
||||
CreateRRLogEvent(socket, "DEBUG", `{RR-SEARCH} Executing ${q.kind} query`, { q });
|
||||
const res = await rrCombinedSearch(bodyshop, q);
|
||||
|
||||
// If VIN query, compute ownersSet & cache raw blocks
|
||||
if (fromVin) {
|
||||
const blocks = Array.isArray(res?.data) ? res.data : [];
|
||||
ownersSet = ownersFromVinBlocks(blocks, job?.v_vin);
|
||||
@@ -114,9 +114,7 @@ async function rrMultiCustomerSearch({ bodyshop, job, socket, redisHelpers }) {
|
||||
blocks,
|
||||
defaultRRTTL
|
||||
);
|
||||
} catch {
|
||||
//
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const norm = normalizeCustomerCandidates(res, { ownersSet });
|
||||
@@ -143,7 +141,7 @@ function registerRREvents({ socket, redisHelpers }) {
|
||||
|
||||
if ((params?.kind || "").toLowerCase() === "vin") {
|
||||
const blocks = Array.isArray(res?.data) ? res.data : [];
|
||||
ownersSet = ownersFromVinBlocks(blocks); // no job VIN filter in ad-hoc lookup
|
||||
ownersSet = ownersFromVinBlocks(blocks);
|
||||
}
|
||||
|
||||
const normalized = sortVehicleOwnerFirst(normalizeCustomerCandidates(res, { ownersSet }));
|
||||
@@ -257,9 +255,7 @@ function registerRREvents({ socket, redisHelpers }) {
|
||||
});
|
||||
try {
|
||||
socket.emit("export-failed", { vendor: "rr", jobId: rid, error: error.message });
|
||||
} catch {
|
||||
//
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -288,6 +284,7 @@ function registerRREvents({ socket, redisHelpers }) {
|
||||
const { bodyshopId } = await getSessionOrSocket(redisHelpers, socket);
|
||||
const bodyshop = await getBodyshopForSocket({ bodyshopId, socket });
|
||||
|
||||
// Create customer (if requested or none chosen)
|
||||
if (create === true || !selectedCustNo) {
|
||||
CreateRRLogEvent(socket, "DEBUG", `{3.1} Creating RR customer`);
|
||||
const created = await createRRCustomer({ bodyshop, job, socket });
|
||||
@@ -296,25 +293,108 @@ function registerRREvents({ socket, redisHelpers }) {
|
||||
CreateRRLogEvent(socket, "DEBUG", `{3.2} Created customer`, { custNo: selectedCustNo });
|
||||
}
|
||||
|
||||
// VIN owner pre-check
|
||||
try {
|
||||
const vehQ = makeVehicleSearchPayloadFromJob(job);
|
||||
if (vehQ && vehQ.kind === "vin" && job?.v_vin) {
|
||||
const resVin = await rrCombinedSearch(bodyshop, vehQ);
|
||||
const blocksVin = Array.isArray(resVin?.data) ? resVin.data : [];
|
||||
try {
|
||||
await redisHelpers.setSessionTransactionData(
|
||||
socket.id,
|
||||
ns,
|
||||
RRCacheEnums.VINCandidates,
|
||||
blocksVin,
|
||||
defaultRRTTL
|
||||
);
|
||||
} catch {}
|
||||
const ownersSet = ownersFromVinBlocks(blocksVin, job.v_vin);
|
||||
if (ownersSet && ownersSet.size) {
|
||||
const sel = String(selectedCustNo);
|
||||
if (!ownersSet.has(sel)) {
|
||||
const [existingOwner] = Array.from(ownersSet).map(String);
|
||||
CreateRRLogEvent(socket, "DEBUG", `{3.2a} VIN exists; switching to VIN owner`, {
|
||||
vin: job.v_vin,
|
||||
selected: sel,
|
||||
existingOwner
|
||||
});
|
||||
try {
|
||||
socket.emit("rr-vin-owner-mismatch", {
|
||||
ts: Date.now(),
|
||||
vin: job.v_vin,
|
||||
selectedCustomerNo: sel,
|
||||
existingOwner,
|
||||
message:
|
||||
"VIN already exists in RR under a different customer. Using the VIN's owner to continue the export."
|
||||
});
|
||||
} catch {}
|
||||
selectedCustNo = existingOwner;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
CreateRRLogEvent(socket, "WARN", `VIN owner pre-check failed; continuing with selected customer`, {
|
||||
error: e?.message
|
||||
});
|
||||
}
|
||||
|
||||
// Cache final/effective customer selection
|
||||
const effectiveCustNo = String(selectedCustNo);
|
||||
await redisHelpers.setSessionTransactionData(
|
||||
socket.id,
|
||||
ns,
|
||||
RRCacheEnums.SelectedCustomer,
|
||||
String(selectedCustNo),
|
||||
effectiveCustNo,
|
||||
defaultRRTTL
|
||||
);
|
||||
CreateRRLogEvent(socket, "DEBUG", `{3.3} Cached selected customer`, { custNo: String(selectedCustNo) });
|
||||
CreateRRLogEvent(socket, "DEBUG", `{3.3} Cached selected customer`, { custNo: effectiveCustNo });
|
||||
|
||||
// Ensure service vehicle exists and is owned by selected customer (uses cached VIN blocks when present)
|
||||
const ensureVeh = await ensureRRServiceVehicle({
|
||||
// Build client & routing
|
||||
const { client, opts } = await buildClientAndOpts(bodyshop);
|
||||
const routing = opts?.routing || client?.opts?.routing || null;
|
||||
if (!routing?.dealerNumber) throw new Error("ensureRRServiceVehicle: routing.dealerNumber required");
|
||||
|
||||
// Reconstruct a lightweight tx object (so resolveVin can use the same shape we logged at {1.2})
|
||||
const tx = {
|
||||
jobData: {
|
||||
...job,
|
||||
vin: job?.v_vin || job?.vin || job?.vehicleVin || undefined
|
||||
},
|
||||
txEnvelope
|
||||
};
|
||||
|
||||
const vin = resolveVin({ tx, job });
|
||||
if (!vin) {
|
||||
CreateRRLogEvent(socket, "ERROR", "{3.x} No VIN found for ensureRRServiceVehicle", { jobid: rid });
|
||||
throw new Error("ensureRRServiceVehicle: vin required");
|
||||
}
|
||||
|
||||
CreateRRLogEvent(socket, "DEBUG", "{3.2} ensureRRServiceVehicle: starting", {
|
||||
jobid: rid,
|
||||
selectedCustomerNo: effectiveCustNo,
|
||||
vin,
|
||||
dealerNumber: routing.dealerNumber,
|
||||
storeNumber: routing.storeNumber,
|
||||
areaNumber: routing.areaNumber
|
||||
});
|
||||
|
||||
const ensured = await ensureRRServiceVehicle({
|
||||
client,
|
||||
routing,
|
||||
bodyshop,
|
||||
custNo: String(selectedCustNo),
|
||||
// Normalize for any internal checks:
|
||||
selectedCustomerNo: effectiveCustNo,
|
||||
custNo: effectiveCustNo,
|
||||
customerNo: effectiveCustNo,
|
||||
vin,
|
||||
job,
|
||||
socket,
|
||||
redisHelpers
|
||||
});
|
||||
CreateRRLogEvent(socket, "DEBUG", `{3.4} ensureRRServiceVehicle`, ensureVeh);
|
||||
|
||||
CreateRRLogEvent(socket, "DEBUG", "{3.4} ensureRRServiceVehicle: done", ensured);
|
||||
|
||||
// Advisor no
|
||||
const cachedAdvisor = await redisHelpers.getSessionTransactionData(socket.id, ns, RRCacheEnums.AdvisorNo);
|
||||
const advisorNo = readAdvisorNo({ txEnvelope }, cachedAdvisor);
|
||||
if (!advisorNo) {
|
||||
@@ -330,11 +410,12 @@ function registerRREvents({ socket, redisHelpers }) {
|
||||
defaultRRTTL
|
||||
);
|
||||
|
||||
// Export
|
||||
CreateRRLogEvent(socket, "DEBUG", `{4} Performing RR export`);
|
||||
const result = await exportJobToRR({
|
||||
bodyshop,
|
||||
job,
|
||||
selectedCustomer: { customerNo: String(selectedCustNo), custNo: String(selectedCustNo) },
|
||||
selectedCustomer: { customerNo: effectiveCustNo, custNo: effectiveCustNo },
|
||||
advisorNo: String(advisorNo),
|
||||
existing: txEnvelope?.existing,
|
||||
socket
|
||||
@@ -371,9 +452,7 @@ function registerRREvents({ socket, redisHelpers }) {
|
||||
});
|
||||
try {
|
||||
socket.emit("export-failed", { vendor: "rr", jobId: rid, error: error.message });
|
||||
} catch {
|
||||
//
|
||||
}
|
||||
} catch {}
|
||||
ack?.({ ok: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user