const { buildClientAndOpts, rrCombinedSearch } = require("./rr-lookup"); const CreateRRLogEvent = require("./rr-logger-event"); const { withRRRequestXml } = require("./rr-log-xml"); /** * Pick and normalize VIN from inputs * @param vin * @param job * @returns {string} */ const 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); }; /** * Pick and normalize customer number from inputs * @param selectedCustomerNo * @param custNo * @param customerNo * @returns {string|string} */ const pickCustNo = ({ selectedCustomerNo, custNo, customerNo }) => { const c = selectedCustomerNo ?? custNo ?? customerNo ?? null; return c != null ? String(c).trim() : ""; }; /** * Simple length sanitizer for outbound strings * Returns undefined if value is null/undefined/empty after trim. */ const sanitizeLength = (value, maxLen) => { if (value == null) return undefined; let s = String(value).trim(); if (!s) return undefined; if (maxLen && s.length > maxLen) { s = s.slice(0, maxLen); } return s; }; /** * Extract owner customer numbers from combined search results * @param res * @param wantedVin * @returns {Set} */ const ownersFromCombined = (res, wantedVin) => { const blocks = Array.isArray(res?.data) ? res.data : []; 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; }; /** * Determine if error indicates "already exists" * @param e * @returns {boolean} */ const 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"); }; function deriveMakeCode(makeDesc) { if (!makeDesc) return "FR"; // safe default const map = { Ford: "FO", FORD: "FO", Lincoln: "LN", Mercury: "MY", Chevrolet: "CH", GMC: "GM", Cadillac: "CA", Buick: "BU", Pontiac: "PO", Oldsmobile: "OL", Saturn: "SA", Dodge: "DO", Chrysler: "CH", Jeep: "JE", Plymouth: "PL", Toyota: "TY", Lexus: "LX", Honda: "HO", Acura: "AC", Nissan: "NI", Infiniti: "IN", Hyundai: "HY", Kia: "KI", Subaru: "SU", Mazda: "MZ", Volkswagen: "VW", Audi: "AU", BMW: "BM", Mercedes: "MB", "Mercedes-Benz": "MB", Volvo: "VO", Ram: "RA" // post-2010 }; return map[makeDesc.trim().toUpperCase()] || "FR"; // TODO Default set } /** * 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) * * Returns: { created:boolean, exists:boolean, vin, customerNo, svId?, status? } */ const ensureRRServiceVehicle = async (args = {}) => { const { client: inClient, routing: inRouting, bodyshop, job, vin, selectedCustomerNo, custNo, customerNo, license, socket } = args; // 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 combinedSearchResponse = await rrCombinedSearch(bodyshop, { kind: "vin", vin: vinStr, maxResults: 50 }); CreateRRLogEvent( socket, "silly", "{SV} Preflight combined search by VIN: raw response", withRRRequestXml(combinedSearchResponse, { response: combinedSearchResponse }) ); owners = ownersFromCombined(combinedSearchResponse, vinStr); } // Short-circuit: VIN exists anywhere -> don't try to insert if (owners.size > 0) { const ownedBySame = owners.has(custNoStr); CreateRRLogEvent(socket, 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) CreateRRLogEvent( socket, "warn", "{SV} VIN preflight lookup failed; continuing to insert", withRRRequestXml(e, { vin: vinStr, error: e?.message }) ); } // Vendor says: MODEL DESCRIPTION HAS MAXIMUM LENGTH OF 20 const rawModelDesc = job?.v_model_desc; const safeModelDesc = sanitizeLength(rawModelDesc, 20); if (rawModelDesc && safeModelDesc && rawModelDesc.trim() !== safeModelDesc) { CreateRRLogEvent(socket, "warn", "{SV} Truncated model description to 20 chars", { original: rawModelDesc, truncated: safeModelDesc }); } const insertPayload = { vin: vinStr.toUpperCase(), // "1FDWX34Y28EB01395" // 2-character make code (from v_make_desc → known mapping) vehicleMake: deriveMakeCode(job?.v_make_desc), // → "FR" for Ford year: job?.v_model_yr || undefined, // Model description (RR: max length 20) modelDesc: safeModelDesc, // Model number / carline / other optional fields mdlNo: undefined, carline: undefined, extClrDesc: sanitizeLength(job?.v_color, 30), // safe, configurable if vendor complains accentClr: undefined, aircond: undefined, pwrstr: undefined, transm: undefined, turbo: undefined, engineConfig: undefined, trim: undefined, // License plate licNo: sanitizeLength(license ? String(license) : undefined, 20), customerNo: custNoStr, stockId: sanitizeLength(job?.ro_number, 20), // RO as stock#, truncated for safety vehicleServInfo: { customerNo: custNoStr, // REQUIRED — this is what toServiceVehicleView() validates salesmanNo: undefined, inServiceDate: undefined, productionDate: undefined, modelMaintCode: undefined, teamCode: undefined, vehExtWarranty: undefined, advisor: undefined } }; const insertOpts = { routing, envelope: { sender: { component: "Rome", task: "SV", referenceId: "Insert", creator: "RCI", senderName: "RCI" } } }; CreateRRLogEvent(socket, "info", "{SV} Inserting service vehicle", { vin: vinStr, selectedCustomerNo: custNoStr, payloadShape: Object.keys(insertPayload).filter((k) => insertPayload[k] != null) }); try { const res = await client.insertServiceVehicle(insertPayload, insertOpts); CreateRRLogEvent(socket, "silly", "{SV} insertServiceVehicle: raw response", withRRRequestXml(res, { res })); const data = res?.data ?? {}; const svId = data?.dmsRecKey || data?.svId || undefined; CreateRRLogEvent(socket, "info", "{SV} insertServiceVehicle: success", { vin: vinStr, customerNo: custNoStr, svId }); return { created: true, exists: false, vin: vinStr, customerNo: custNoStr, svId, status: res?.statusBlocks?.transaction }; } catch (e) { if (isAlreadyExistsError(e)) { // Treat as idempotent success CreateRRLogEvent(socket, "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 }; } CreateRRLogEvent( socket, "error", "{SV} insertServiceVehicle: failure", withRRRequestXml(e, { message: e?.message, code: e?.code, status: e?.meta?.status || e?.status }) ); throw e; } }; module.exports = { ensureRRServiceVehicle };