feature/IO-3357-Reynolds-and-Reynolds-DMS-API-Integration - Expanded Logs / Formatting change

This commit is contained in:
Dave
2025-11-12 17:01:54 -05:00
parent 556cd993b9
commit 90f653c0b7
13 changed files with 444 additions and 254 deletions

View File

@@ -1,4 +1,5 @@
import { Divider, Space, Tag, Timeline } from "antd"; import { Divider, Space, Tag, Timeline } from "antd";
import { useEffect, useMemo, useState } from "react";
import dayjs from "../../utils/day"; import dayjs from "../../utils/day";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { createStructuredSelector } from "reselect"; import { createStructuredSelector } from "reselect";
@@ -13,38 +14,116 @@ const mapDispatchToProps = (dispatch) => ({
export default connect(mapStateToProps, mapDispatchToProps)(DmsLogEvents); export default connect(mapStateToProps, mapDispatchToProps)(DmsLogEvents);
export function DmsLogEvents({ logs }) { export function DmsLogEvents({ logs, detailsOpen, detailsNonce }) {
return ( const [openSet, setOpenSet] = useState(() => new Set());
<Timeline
pending // Trim openSet if logs shrink
reverse={true} useEffect(() => {
items={logs.map((log, idx) => ({ const len = (logs || []).length;
key: idx, setOpenSet((prev) => {
color: LogLevelHierarchy(log.level), const next = new Set();
children: ( for (let i = 0; i < len; i++) if (prev.has(i)) next.add(i);
<Space wrap align="start" style={{}}> return next;
<Tag color={LogLevelHierarchy(log.level)}>{log.level}</Tag> });
<span>{dayjs(log.timestamp).format("MM/DD/YYYY HH:mm:ss")}</span> }, [logs?.length]);
<Divider type="vertical" />
<span>{log.message}</span> // Respond to global toggle button
</Space> useEffect(() => {
) if (detailsNonce == null) return; // prop optional for compatibility
}))} const len = (logs || []).length;
/> if (detailsOpen) {
setOpenSet(new Set(Array.from({ length: len }, (_, i) => i))); // expand all
} else {
setOpenSet(new Set()); // collapse all
}
}, [detailsNonce, detailsOpen, logs?.length]);
const items = useMemo(
() =>
(logs || []).map((raw, idx) => {
const { level, message, timestamp, meta } = normalizeLog(raw);
return {
key: idx,
color: logLevelColor(level),
children: (
<Space direction="vertical" size={4} style={{ display: "flex" }}>
{/* Row 1: summary */}
<Space wrap align="start">
<Tag color={logLevelColor(level)}>{level}</Tag>
<span>{dayjs(timestamp).format("MM/DD/YYYY HH:mm:ss")}</span>
<Divider type="vertical" />
<span>{message}</span>
</Space>
{/* Row 2: details on a new line */}
{!isEmpty(meta) && (
<div style={{ marginLeft: 6 }}>
<details
open={openSet.has(idx)}
onToggle={(e) => {
const isOpen = e.currentTarget.open;
setOpenSet((prev) => {
const next = new Set(prev);
if (isOpen) next.add(idx);
else next.delete(idx);
return next;
});
}}
>
<summary>Details</summary>
<pre style={{ margin: "6px 0 0", maxWidth: 720, overflowX: "auto" }}>{safeStringify(meta, 2)}</pre>
</details>
</div>
)}
</Space>
)
};
}),
[logs, openSet]
); );
return <Timeline pending reverse items={items} />;
} }
function LogLevelHierarchy(level) { /** Accepts both legacy shape and new "normalized" shape */
switch (level) { function normalizeLog(input) {
const n = input?.normalized || input || {};
const level = (n.level || input?.level || "INFO").toString().toUpperCase();
const message = n.message ?? input?.message ?? "";
const meta = input?.meta != null ? input.meta : n.meta != null ? n.meta : undefined;
const tsRaw = input?.timestamp ?? n.timestamp ?? input?.ts ?? Date.now();
const timestamp = typeof tsRaw === "number" ? new Date(tsRaw) : new Date(tsRaw);
return { level, message, timestamp, meta };
}
function logLevelColor(level) {
switch ((level || "").toUpperCase()) {
case "DEBUG": case "DEBUG":
return "orange"; return "orange";
case "INFO": case "INFO":
return "blue"; return "blue";
case "WARN": case "WARN":
case "WARNING":
return "yellow"; return "yellow";
case "ERROR": case "ERROR":
return "red"; return "red";
default: default:
return 0; return "default";
}
}
function isEmpty(v) {
if (v == null) return true;
if (Array.isArray(v)) return v.length === 0;
if (typeof v === "object") return Object.keys(v).length === 0;
return false;
}
function safeStringify(obj, spaces = 2) {
try {
return JSON.stringify(obj, null, spaces);
} catch {
return String(obj);
} }
} }

View File

@@ -1,4 +1,3 @@
// DmsContainer updated
import { useQuery } from "@apollo/client"; import { useQuery } from "@apollo/client";
import { Button, Card, Col, Result, Row, Select, Space } from "antd"; import { Button, Card, Col, Result, Row, Select, Space } from "antd";
import queryString from "query-string"; import queryString from "query-string";
@@ -55,6 +54,9 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse
const history = useNavigate(); const history = useNavigate();
const [logs, setLogs] = useState([]); const [logs, setLogs] = useState([]);
const search = queryString.parse(useLocation().search); const search = queryString.parse(useLocation().search);
const [detailsOpen, setDetailsOpen] = useState(false); // false => button shows "Expand All"
const [detailsNonce, setDetailsNonce] = useState(0); // forces child to react to toggles
const { jobId } = search; const { jobId } = search;
const notification = useNotification(); const notification = useNotification();
const { const {
@@ -77,9 +79,14 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse
fetchPolicy: "network-only", fetchPolicy: "network-only",
nextFetchPolicy: "network-only" nextFetchPolicy: "network-only"
}); });
const logsRef = useRef(null); const logsRef = useRef(null);
// NEW: RR “open RO limit” UX hold const toggleDetailsAll = () => {
setDetailsOpen((v) => !v);
setDetailsNonce((n) => n + 1);
};
const [rrOpenRoLimit, setRrOpenRoLimit] = useState(false); const [rrOpenRoLimit, setRrOpenRoLimit] = useState(false);
const clearRrOpenRoLimit = () => setRrOpenRoLimit(false); const clearRrOpenRoLimit = () => setRrOpenRoLimit(false);
@@ -166,7 +173,16 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse
notification.error({ message: err.message }); notification.error({ message: err.message });
}; };
const handleLogEvent = (payload) => setLogs((prev) => [...prev, payload]); const handleLogEvent = (payload = {}) => {
const normalized = {
timestamp: payload.timestamp ? new Date(payload.timestamp) : payload.ts ? new Date(payload.ts) : new Date(),
level: (payload.level || "INFO").toUpperCase(),
message: payload.message || payload.msg || "",
// show details regardless of property name
meta: payload.meta ?? payload.ctx ?? payload.details ?? null
};
setLogs((prev) => [...prev, normalized]);
};
// FINAL step only (emitted by server after rr-finalize-repair-order) // FINAL step only (emitted by server after rr-finalize-repair-order)
const handleExportSuccess = (payload) => { const handleExportSuccess = (payload) => {
@@ -379,7 +395,6 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse
value={logLevel} value={logLevel}
onChange={(value) => { onChange={(value) => {
setLogLevel(value); setLogLevel(value);
// Send to the active socket type
if (dms === "rr" || Fortellis.treatment === "on") { if (dms === "rr" || Fortellis.treatment === "on") {
wsssocket.emit("set-log-level", value); wsssocket.emit("set-log-level", value);
} else { } else {
@@ -392,6 +407,7 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse
<Select.Option key="WARN">WARN</Select.Option> <Select.Option key="WARN">WARN</Select.Option>
<Select.Option key="ERROR">ERROR</Select.Option> <Select.Option key="ERROR">ERROR</Select.Option>
</Select> </Select>
<Button onClick={toggleDetailsAll}>{detailsOpen ? "Collapse All" : "Expand All"}</Button>
<Button onClick={() => setLogs([])}>Clear Logs</Button> <Button onClick={() => setLogs([])}>Clear Logs</Button>
<Button <Button
onClick={() => { onClick={() => {
@@ -409,7 +425,7 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse
</Space> </Space>
} }
> >
<DmsLogEvents socket={socket} logs={logs} /> <DmsLogEvents logs={logs} detailsOpen={detailsOpen} detailsNonce={detailsNonce} />
</Card> </Card>
</div> </div>
</Col> </Col>

View File

@@ -1,45 +1,33 @@
// server/rr/rr-config.js /**
// Build RR client configuration from bodyshop settings or env * Ensure a value is a non-empty string, else throw
* @param v
function requireString(v, name) { * @param name
* @returns {string}
*/
const requireString = (v, name) => {
const s = (v ?? "").toString().trim(); const s = (v ?? "").toString().trim();
if (!s || s.toLowerCase() === "undefined" || s.toLowerCase() === "null") { if (!s || s.toLowerCase() === "undefined" || s.toLowerCase() === "null") {
throw new Error(`RR config missing: ${name}`); throw new Error(`RR config missing: ${name}`);
} }
return s; return s;
} };
/** /**
* Extract RR connection + routing from a bodyshop record (preferred) * Get RR config from bodyshop record, with env fallbacks
* Falls back to process.env for any missing bits. * @param bodyshop
* * @returns {{baseUrl: string, username: string, password: string, routing: {dealerNumber: string, storeNumber: string, areaNumber: string}, timeoutMs: number, retries: {max: number}}}
* Bodyshop fields expected:
* - rr_dealerid -> dealerNumber
* - rr_configuration: { storeNumber, branchNumber } -> storeNumber, areaNumber
*
* Env fallbacks:
* RR_BASE_URL, RR_USERNAME, RR_PASSWORD,
* RR_DEALER_NUMBER, RR_STORE_NUMBER, RR_BRANCH_NUMBER
*/ */
function getRRConfigFromBodyshop(bodyshop) { const getRRConfigFromBodyshop = (bodyshop) => {
const baseUrl = process.env.RR_BASE_URL; const baseUrl = process.env.RR_BASE_URL;
const username = process.env.RR_USERNAME; const username = process.env.RR_USERNAME;
const password = process.env.RR_PASSWORD; const password = process.env.RR_PASSWORD;
// NOTE: your schema uses rr_dealerid and rr_configuration JSON
const dealerNumber = bodyshop?.rr_dealerid ?? process.env.RR_DEALER_NUMBER; const dealerNumber = bodyshop?.rr_dealerid ?? process.env.RR_DEALER_NUMBER;
const bsCfg = bodyshop?.rr_configuration || {}; const bsCfg = bodyshop?.rr_configuration || {};
const storeNumber =
bsCfg?.storeNumber ??
bodyshop?.rr_store_number ?? // legacy fallback if present
process.env.RR_STORE_NUMBER;
const areaNumber = const storeNumber = bsCfg?.storeNumber;
bsCfg?.branchNumber ?? const areaNumber = bsCfg?.branchNumber ?? bsCfg?.areaNumber;
bsCfg?.areaNumber ?? // accept either key
bodyshop?.rr_branch_number ?? // legacy fallback if present
process.env.RR_BRANCH_NUMBER;
return { return {
baseUrl: requireString(baseUrl, "baseUrl"), baseUrl: requireString(baseUrl, "baseUrl"),
@@ -54,6 +42,6 @@ function getRRConfigFromBodyshop(bodyshop) {
timeoutMs: Number(process.env.RR_TIMEOUT_MS || 30000), timeoutMs: Number(process.env.RR_TIMEOUT_MS || 30000),
retries: { max: Number(process.env.RR_RETRIES_MAX || 2) } retries: { max: Number(process.env.RR_RETRIES_MAX || 2) }
}; };
} };
module.exports = { getRRConfigFromBodyshop }; module.exports = { getRRConfigFromBodyshop };

View File

@@ -1,8 +1,11 @@
// File: server/rr/rr-customers.js
const { RRClient } = require("./lib/index.cjs"); const { RRClient } = require("./lib/index.cjs");
const { getRRConfigFromBodyshop } = require("./rr-config"); const { getRRConfigFromBodyshop } = require("./rr-config");
const RRLogger = require("./rr-logger"); const RRLogger = require("./rr-logger");
/**
* Country code map for normalization
* @type {{US: string, USA: string, "UNITED STATES": string, CA: string, CAN: string, CANADA: string}}
*/
const COUNTRY_MAP = { const COUNTRY_MAP = {
US: "US", US: "US",
USA: "US", USA: "US",
@@ -12,7 +15,12 @@ const COUNTRY_MAP = {
CANADA: "CA" CANADA: "CA"
}; };
function toCountry2(v) { /**
* Normalize country input to 2-char code
* @param v
* @returns {*|string}
*/
const toCountry2 = (v) => {
const s = String(v || "") const s = String(v || "")
.trim() .trim()
.toUpperCase(); .toUpperCase();
@@ -20,22 +28,38 @@ function toCountry2(v) {
if (COUNTRY_MAP[s]) return COUNTRY_MAP[s]; if (COUNTRY_MAP[s]) return COUNTRY_MAP[s];
// fallbacks: prefer 2-char; last resort: take first 2 // fallbacks: prefer 2-char; last resort: take first 2
return s.length === 2 ? s : s.slice(0, 2); return s.length === 2 ? s : s.slice(0, 2);
} };
function normalizePhone(num) { /**
* Normalize phone number to 10-digit string
* @param num
* @returns {string}
*/
const normalizePhone = (num) => {
const d = String(num || "").replace(/\D/g, ""); const d = String(num || "").replace(/\D/g, "");
const n = d.length === 11 && d.startsWith("1") ? d.slice(1) : d; const n = d.length === 11 && d.startsWith("1") ? d.slice(1) : d;
return n.slice(0, 10); return n.slice(0, 10);
} };
function normalizePostal(pc, country) { /**
* Normalize postal code based on country
* @param pc
* @param country
* @returns {string}
*/
const normalizePostal = (pc, country) => {
const s = String(pc || "").trim(); const s = String(pc || "").trim();
if (country === "US") return s.replace(/[^0-9]/g, "").slice(0, 5); if (country === "US") return s.replace(/[^0-9]/g, "").slice(0, 5);
if (country === "CA") return s.toUpperCase().replace(/\s+/g, "").slice(0, 6); if (country === "CA") return s.toUpperCase().replace(/\s+/g, "").slice(0, 6);
return s; return s;
} };
function sanitizeRRCustomerPayload(payload = {}) { /**
* Sanitize RR customer payload (addresses, phones, names)
* @param payload
* @returns {{}}
*/
const sanitizeRRCustomerPayload = (payload = {}) => {
const out = { ...payload }; const out = { ...payload };
out.addresses = (payload.addresses || []).map((a) => { out.addresses = (payload.addresses || []).map((a) => {
@@ -60,14 +84,14 @@ function sanitizeRRCustomerPayload(payload = {}) {
if (out.lastName) out.lastName = String(out.lastName).trim().slice(0, 30); if (out.lastName) out.lastName = String(out.lastName).trim().slice(0, 30);
return out; return out;
} };
/** /**
* Build an RR client + common opts from a bodyshop row * Build an RR client + common opts from a bodyshop row
* @param bodyshop * @param bodyshop
* @returns {{client: *, opts: {routing: {dealerNumber: *, storeNumber: *, areaNumber: *}, envelope: {sender: {component: string, task: string, referenceId: string, creator: string, senderName: string}}}}} * @returns {{client: *, opts: {routing: {dealerNumber: *, storeNumber: *, areaNumber: *}, envelope: {sender: {component: string, task: string, referenceId: string, creator: string, senderName: string}}}}}
*/ */
function buildClientAndOpts(bodyshop) { const buildClientAndOpts = (bodyshop) => {
const cfg = getRRConfigFromBodyshop(bodyshop); const cfg = getRRConfigFromBodyshop(bodyshop);
const client = new RRClient({ const client = new RRClient({
baseUrl: cfg.baseUrl, baseUrl: cfg.baseUrl,
@@ -90,25 +114,25 @@ function buildClientAndOpts(bodyshop) {
} }
}; };
return { client, opts }; return { client, opts };
} };
/** /**
* Strip all non-digit characters from a string * Strip all non-digit characters from a string
* @param s * @param s
* @returns {string} * @returns {string}
*/ */
function digitsOnly(s) { const digitsOnly = (s) => {
return String(s || "").replace(/\D/g, ""); return String(s || "").replace(/\D/g, "");
} };
/** /**
* Return a new array with only unique values from the input array * Return a new array with only unique values from the input array
* @param arr * @param arr
* @returns {any[]} * @returns {any[]}
*/ */
function uniq(arr) { const uniq = (arr) => {
return Array.from(new Set(arr)); return Array.from(new Set(arr));
} };
/** /**
* Build RR customer payload from job.ownr_* fields, with optional overrides. * Build RR customer payload from job.ownr_* fields, with optional overrides.
@@ -116,7 +140,7 @@ function uniq(arr) {
* @param overrides * @param overrides
* @returns {{ibFlag: string, firstName, lastName, customerName, createdBy, customerType, addresses: [{type, line1: *, line2, city, state, postalCode, country}], phones: {number: *}[], emails: [{address: string}]}} * @returns {{ibFlag: string, firstName, lastName, customerName, createdBy, customerType, addresses: [{type, line1: *, line2, city, state, postalCode, country}], phones: {number: *}[], emails: [{address: string}]}}
*/ */
function buildCustomerPayloadFromJob(job, overrides = {}) { const buildCustomerPayloadFromJob = (job, overrides = {}) => {
// Pull ONLY from job.ownr_* fields (no job.customer.*) // Pull ONLY from job.ownr_* fields (no job.customer.*)
const firstName = overrides.firstName ?? job?.ownr_fn ?? undefined; const firstName = overrides.firstName ?? job?.ownr_fn ?? undefined;
const lastName = overrides.lastName ?? job?.ownr_ln ?? undefined; const lastName = overrides.lastName ?? job?.ownr_ln ?? undefined;
@@ -174,13 +198,13 @@ function buildCustomerPayloadFromJob(job, overrides = {}) {
Object.keys(payload).forEach((k) => payload[k] === undefined && delete payload[k]); Object.keys(payload).forEach((k) => payload[k] === undefined && delete payload[k]);
return payload; return payload;
} };
/** /**
* Create a customer in RR and return { customerNo, raw }. * Create a customer in RR and return { customerNo, raw }.
* Maps data.dmsRecKey -> customerNo for compatibility with existing callers. * Maps data.dmsRecKey -> customerNo for compatibility with existing callers.
*/ */
async function createRRCustomer({ bodyshop, job, overrides = {}, socket }) { const createRRCustomer = async ({ bodyshop, job, overrides = {}, socket }) => {
const log = RRLogger(socket, { ns: "rr" }); const log = RRLogger(socket, { ns: "rr" });
const { client, opts } = buildClientAndOpts(bodyshop); const { client, opts } = buildClientAndOpts(bodyshop);
const payload = buildCustomerPayloadFromJob(job, overrides); const payload = buildCustomerPayloadFromJob(job, overrides);
@@ -213,7 +237,7 @@ async function createRRCustomer({ bodyshop, job, overrides = {}, socket }) {
} }
return { customerNo: String(customerNo), raw: data }; return { customerNo: String(customerNo), raw: data };
} };
module.exports = { module.exports = {
createRRCustomer createRRCustomer

View File

@@ -1,6 +1,9 @@
// Map RR vendor/status failures into user-friendly messages the FE can show. /**
* Parse vendor status code from various possible locations in the error object.
function parseVendorStatusCode(err) { * @param err
* @returns {number|null}
*/
const parseVendorStatusCode = (err) => {
// Prefer explicit numeric props when available // Prefer explicit numeric props when available
const codeProp = err?.code ?? err?.statusCode ?? err?.meta?.status?.StatusCode ?? err?.status?.StatusCode; const codeProp = err?.code ?? err?.statusCode ?? err?.meta?.status?.StatusCode ?? err?.status?.StatusCode;
const num = Number(codeProp); const num = Number(codeProp);
@@ -9,14 +12,14 @@ function parseVendorStatusCode(err) {
// Fallback: parse from message text (e.g., "... 507 CANNOT EXCEED ...") // Fallback: parse from message text (e.g., "... 507 CANNOT EXCEED ...")
const m = String(err?.message || "").match(/\b(\d{3})\b/); const m = String(err?.message || "").match(/\b(\d{3})\b/);
return m ? Number(m[1]) : null; return m ? Number(m[1]) : null;
} };
/** /**
* Classify RR vendor errors into a small set of stable codes/messages for the FE. * Classify RR vendor errors into a small set of stable codes/messages for the FE.
* @param {any} err * @param {any} err
* @returns {{vendorStatusCode:number|null, errorCode:string, title:string, friendlyMessage:string, severity:'info'|'warning'|'error', canRetry:boolean}} * @returns {{vendorStatusCode:number|null, errorCode:string, title:string, friendlyMessage:string, severity:'info'|'warning'|'error', canRetry:boolean}}
*/ */
function classifyRRVendorError(err) { const classifyRRVendorError = (err) => {
const code = parseVendorStatusCode(err); const code = parseVendorStatusCode(err);
const rawMsg = String(err?.meta?.status?.Message || err?.status?.Message || err?.message || "").toUpperCase(); const rawMsg = String(err?.meta?.status?.Message || err?.status?.Message || err?.message || "").toUpperCase();
@@ -43,6 +46,6 @@ function classifyRRVendorError(err) {
severity: "error", severity: "error",
canRetry: true canRetry: true
}; };
} };
module.exports = { classifyRRVendorError }; module.exports = { classifyRRVendorError };

View File

@@ -3,12 +3,11 @@ const queries = require("../graphql-client/queries");
const CreateRRLogEvent = require("./rr-logger-event"); const CreateRRLogEvent = require("./rr-logger-event");
/** Get bearer token from the socket (same approach used elsewhere) */ /** Get bearer token from the socket (same approach used elsewhere) */
function getAuthToken(socket) { const getAuthToken = (socket) =>
return (socket?.data && socket.data.authToken) || (socket?.handshake?.auth && socket.handshake.auth.token) || null; (socket?.data && socket.data.authToken) || (socket?.handshake?.auth && socket.handshake.auth.token) || null;
}
/** Compact metadata for RR */ /** Compact metadata for RR */
function buildRRExportMeta({ result, extra = {} }) { const buildRRExportMeta = ({ result, extra = {} }) => {
const roStatus = result?.roStatus || result?.data?.roStatus || null; const roStatus = result?.roStatus || result?.data?.roStatus || null;
return { return {
provider: "rr", provider: "rr",
@@ -25,10 +24,10 @@ function buildRRExportMeta({ result, extra = {} }) {
parsed: result?.parsed, parsed: result?.parsed,
...extra ...extra
}; };
} };
/** Build a stringified JSON array for the `message` text column */ /** Build a stringified JSON array for the `message` text column */
function buildMessageJSONString({ error, classification, result, fallback }) { const buildMessageJSONString = ({ error, classification, result, fallback }) => {
const msgs = []; const msgs = [];
const clean = (v) => { const clean = (v) => {
@@ -63,13 +62,13 @@ function buildMessageJSONString({ error, classification, result, fallback }) {
const arr = msgs.length ? msgs : ["RR export failed"]; const arr = msgs.length ? msgs : ["RR export failed"];
return JSON.stringify(arr); return JSON.stringify(arr);
} };
/** /**
* Success: mark job exported + (optionally) insert a success log. * Success: mark job exported + (optionally) insert a success log.
* Uses queries.MARK_JOB_EXPORTED (same shape as Fortellis/PBS). * Uses queries.MARK_JOB_EXPORTED (same shape as Fortellis/PBS).
*/ */
async function markRRExportSuccess({ socket, jobId, job, bodyshop, result, metaExtra = {} }) { const markRRExportSuccess = async ({ socket, jobId, job, bodyshop, result, metaExtra = {} }) => {
const endpoint = process.env.GRAPHQL_ENDPOINT; const endpoint = process.env.GRAPHQL_ENDPOINT;
if (!endpoint) throw new Error("GRAPHQL_ENDPOINT not configured"); if (!endpoint) throw new Error("GRAPHQL_ENDPOINT not configured");
const token = getAuthToken(socket); const token = getAuthToken(socket);
@@ -114,13 +113,13 @@ async function markRRExportSuccess({ socket, jobId, job, bodyshop, result, metaE
error: e?.message error: e?.message
}); });
} }
} };
/** /**
* Failure: insert failure ExportsLog with `message` as JSON **string** (text column). * Failure: insert failure ExportsLog with `message` as JSON **string** (text column).
* Uses queries.INSERT_EXPORT_LOG($logs: [exportlog_insert_input!]!). * Uses queries.INSERT_EXPORT_LOG($logs: [exportlog_insert_input!]!).
*/ */
async function insertRRFailedExportLog({ socket, jobId, job, bodyshop, error, classification, result }) { const insertRRFailedExportLog = async ({ socket, jobId, job, bodyshop, error, classification, result }) => {
const endpoint = process.env.GRAPHQL_ENDPOINT; const endpoint = process.env.GRAPHQL_ENDPOINT;
if (!endpoint) throw new Error("GRAPHQL_ENDPOINT not configured"); if (!endpoint) throw new Error("GRAPHQL_ENDPOINT not configured");
const token = getAuthToken(socket); const token = getAuthToken(socket);
@@ -163,7 +162,7 @@ async function insertRRFailedExportLog({ socket, jobId, job, bodyshop, error, cl
error: e?.message error: e?.message
}); });
} }
} };
module.exports = { module.exports = {
markRRExportSuccess, markRRExportSuccess,

View File

@@ -4,14 +4,11 @@ const { ensureRRServiceVehicle } = require("./rr-service-vehicles");
const RRLogger = require("./rr-logger"); const RRLogger = require("./rr-logger");
/** /**
* Orchestrate an RR export (assumes custNo already resolved): * Export a job to Reynolds & Reynolds as a Repair Order (create or update).
* - Ensure service vehicle (create flows) * @param args
* - Create or update the Repair Order * @returns {Promise<{success, data: *, roStatus: *, statusBlocks, xml: *, parsed: any, customerNo: string, svId: null, roNo: *}>}
*
* NOTE: This function performs the create/update step and returns the RO data.
* "Mark exported" is handled later by the finalize step after cashiering.
*/ */
async function exportJobToRR(args) { const exportJobToRR = async (args) => {
const { bodyshop, job, advisorNo, selectedCustomer, existing, socket } = args || {}; const { bodyshop, job, advisorNo, selectedCustomer, existing, socket } = args || {};
const log = RRLogger(socket, { ns: "rr-export" }); const log = RRLogger(socket, { ns: "rr-export" });
@@ -95,14 +92,14 @@ async function exportJobToRR(args) {
svId, svId,
roNo roNo
}; };
} };
/** /**
* Finalize an RR Repair Order by sending finalUpdate: "Y". * Finalize an RR Repair Order by sending finalUpdate: "Y".
* The caller should pass the canonical `roNo` if available (prefer DMS RO #). * @param args
* If not provided, we *safely* fall back to the external (Outsd) RO number. * @returns {Promise<{success, data: *, roStatus: *, statusBlocks, xml: *, parsed: any}>}
*/ */
async function finalizeRRRepairOrder(args) { const finalizeRRRepairOrder = async (args) => {
const { bodyshop, job, advisorNo, customerNo, roNo, vin, socket } = args || {}; const { bodyshop, job, advisorNo, customerNo, roNo, vin, socket } = args || {};
const log = RRLogger(socket, { ns: "rr-finalize" }); const log = RRLogger(socket, { ns: "rr-finalize" });
@@ -171,6 +168,6 @@ async function finalizeRRRepairOrder(args) {
xml: rrRes?.xml, xml: rrRes?.xml,
parsed: rrRes?.parsed parsed: rrRes?.parsed
}; };
} };
module.exports = { exportJobToRR, finalizeRRRepairOrder }; module.exports = { exportJobToRR, finalizeRRRepairOrder };

View File

@@ -1,29 +1,49 @@
const client = require("../graphql-client/graphql-client").client; const client = require("../graphql-client/graphql-client").client;
const { GET_JOB_BY_PK } = require("../graphql-client/queries"); const { GET_JOB_BY_PK } = require("../graphql-client/queries");
// ---------- Internals ---------- /**
* Remove all non-digit characters from a string.
* @param s
* @returns {string}
*/
const digitsOnly = (s) => String(s || "").replace(/\D/g, "");
function digitsOnly(s) { /**
return String(s || "").replace(/\D/g, ""); * Pick job ID from various possible locations.
} * @param ctx
* @param explicitId
* @returns {*|null}
*/
const pickJobId = (ctx, explicitId) =>
explicitId || ctx?.job?.id || ctx?.payload?.job?.id || ctx?.payload?.jobId || ctx?.jobId || null;
function pickJobId(ctx, explicitId) { /**
return explicitId || ctx?.job?.id || ctx?.payload?.job?.id || ctx?.payload?.jobId || ctx?.jobId || null; * Safely get VIN from job object.
} * @param job
* @returns {*|string|null}
*/
const safeVin = (job) => (job?.v_vin && String(job.v_vin).trim()) || null;
function safeVin(job) { /**
return (job?.v_vin && String(job.v_vin).trim()) || null; * Extract blocks array from combined search result.
} * @param res
* @returns {any[]|*[]}
// Combined search helpers expect array-like blocks */
function blocksFromCombinedSearchResult(res) { const blocksFromCombinedSearchResult = (res) => {
const data = Array.isArray(res?.data) ? res.data : Array.isArray(res) ? res : []; const data = Array.isArray(res?.data) ? res.data : Array.isArray(res) ? res : [];
return Array.isArray(data) ? data : []; return Array.isArray(data) ? data : [];
} };
// ---------- Public API ---------- // ---------- Public API ----------
async function QueryJobData(ctx = {}, jobId) { /**
* Query job data by ID from GraphQL API.
* @param ctx
* @param jobId
* @returns {Promise<*>}
* @constructor
*/
const QueryJobData = async (ctx = {}, jobId) => {
if (ctx?.job) return ctx.job; if (ctx?.job) return ctx.job;
if (ctx?.payload?.job) return ctx.payload.job; if (ctx?.payload?.job) return ctx.payload.job;
@@ -39,18 +59,16 @@ async function QueryJobData(ctx = {}, jobId) {
const msg = e?.response?.errors?.[0]?.message || e.message || "unknown"; const msg = e?.response?.errors?.[0]?.message || e.message || "unknown";
throw new Error(`QueryJobData failed: ${msg}`); throw new Error(`QueryJobData failed: ${msg}`);
} }
} };
/** /**
* Build minimal RR RO payload (keys match RR client expectations). * Build RR Repair Order payload from job and customer data.
* Provide ALL common variants so downstream ops accept them: * @param job
* - RO number: outsdRoNo / OutsdRoNo / repairOrderNumber / RepairOrderNumber * @param selectedCustomer
* - Dept: DeptType / departmentType / deptType * @param advisorNo
* - VIN: Vin / vin * @returns {{outsdRoNo: string, repairOrderNumber: string, departmentType: string, vin: string, customerNo: string, advisorNo: string, mileageIn: *|null}}
* - Customer: CustNo / customerNo / custNo
* - Advisor: AdvNo / AdvisorNo / advisorNo / advNo
*/ */
function buildRRRepairOrderPayload({ job, selectedCustomer, advisorNo }) { const buildRRRepairOrderPayload = ({ job, selectedCustomer, advisorNo }) => {
const customerNo = selectedCustomer?.customerNo const customerNo = selectedCustomer?.customerNo
? String(selectedCustomer.customerNo).trim() ? String(selectedCustomer.customerNo).trim()
: selectedCustomer?.custNo : selectedCustomer?.custNo
@@ -94,9 +112,14 @@ function buildRRRepairOrderPayload({ job, selectedCustomer, advisorNo }) {
// ---- Mileage In (new) ---- // ---- Mileage In (new) ----
mileageIn mileageIn
}; };
} };
function makeVehicleSearchPayloadFromJob(job) { /**
* Make vehicle search payload from job data
* @param job
* @returns {{kind: string, license: string}|null|{kind: string, vin: *|string}}
*/
const makeVehicleSearchPayloadFromJob = (job) => {
const vin = safeVin(job); const vin = safeVin(job);
if (vin) return { kind: "vin", vin }; if (vin) return { kind: "vin", vin };
@@ -104,9 +127,14 @@ function makeVehicleSearchPayloadFromJob(job) {
if (plate) return { kind: "license", license: String(plate).trim() }; if (plate) return { kind: "license", license: String(plate).trim() };
return null; return null;
} };
function makeCustomerSearchPayloadFromJob(job) { /**
* Make customer search payload from job data
* @param job
* @returns {{kind: string, vin: *|string}|{kind: string, name: {name: string}}|{kind: string, phone: string}|null}
*/
const makeCustomerSearchPayloadFromJob = (job) => {
const phone = job?.ownr_ph1; const phone = job?.ownr_ph1;
const d = digitsOnly(phone); const d = digitsOnly(phone);
if (d.length >= 7) return { kind: "phone", phone: d }; if (d.length >= 7) return { kind: "phone", phone: d };
@@ -120,9 +148,14 @@ function makeCustomerSearchPayloadFromJob(job) {
if (vin) return { kind: "vin", vin }; if (vin) return { kind: "vin", vin };
return null; return null;
} };
function normalizeCustomerCandidates(res) { /**
* Normalize customer candidates from combined search result.
* @param res
* @returns {*[]}
*/
const normalizeCustomerCandidates = (res) => {
const blocks = blocksFromCombinedSearchResult(res); const blocks = blocksFromCombinedSearchResult(res);
const out = []; const out = [];
for (const blk of blocks) { for (const blk of blocks) {
@@ -146,9 +179,14 @@ function normalizeCustomerCandidates(res) {
seen.add(c.custNo); seen.add(c.custNo);
return true; return true;
}); });
} };
function normalizeVehicleCandidates(res) { /**
* Normalize vehicle candidates from combined search result.
* @param res
* @returns {*[]}
*/
const normalizeVehicleCandidates = (res) => {
const blocks = blocksFromCombinedSearchResult(res); const blocks = blocksFromCombinedSearchResult(res);
const out = []; const out = [];
for (const blk of blocks) { for (const blk of blocks) {
@@ -170,7 +208,7 @@ function normalizeVehicleCandidates(res) {
seen.add(v.vin); seen.add(v.vin);
return true; return true;
}); });
} };
module.exports = { module.exports = {
QueryJobData, QueryJobData,

View File

@@ -1,44 +1,75 @@
// File: server/rr/rr-logger-event.js const logger = require("../utils/logger");
// Fortellis-style log helper for RR flows.
// Usage: CreateRRLogEvent(socket, "DEBUG"|"INFO"|"WARN"|"ERROR", message, details?)
const RRLogger = require("../rr/rr-logger"); /**
* Convert an Error object to a plain object for serialization.
* @param err
* @returns {{[p: string]: unknown, name: *, message: *, stack: *}|null}
*/
const toPlainError = (err) => {
if (!err) return null;
return {
name: err.name,
message: err.message,
stack: err.stack,
...Object.fromEntries(Object.entries(err).filter(([k]) => !["stack"].includes(k)))
};
};
// Normalize level to upper + provide console + socket event with coherent payload /**
function CreateRRLogEvent(socket, level = "DEBUG", message = "", details = {}) { * Safely serialize meta information for logging.
const lvl = String(level || "DEBUG").toUpperCase(); * @param meta
* @returns {{note: string}|any|{[p: string]: *, name: *, message: *, stack: *}|null}
*/
const safeMeta = (meta) => {
try {
if (meta instanceof Error) return toPlainError(meta);
if (meta && typeof meta === "object") {
// JSON-safe clone w/ BigInt -> string
return JSON.parse(JSON.stringify(meta, (_k, v) => (typeof v === "bigint" ? v.toString() : v)));
}
return meta ?? null;
} catch {
return { note: "meta not serializable" };
}
};
/**
* Create and emit a Reynolds log event.
* @param socket
* @param level
* @param message
* @param meta
* @returns {{timestamp: number, level: string, message: string|string, meta: {note: string}|*|{[p: string]: *, name: *, message: *, stack: *}|null}}
* @constructor
*/
const CreateRRLogEvent = (socket, level = "INFO", message = "", meta = null) => {
const ts = Date.now(); const ts = Date.now();
const lvl = String(level || "INFO").toUpperCase();
const msg = typeof message === "string" ? message : (message?.toString?.() ?? JSON.stringify(message));
// Console (uses existing RRLogger, which also emits "RR:LOG" to sockets for live tail) const payload = {
timestamp: ts,
level: lvl,
message: msg,
meta: safeMeta(meta)
};
// Console
try { try {
const log = RRLogger(socket); const fn = logger?.logger?.[lvl.toLowerCase()] ?? logger?.logger?.info ?? console.log;
const fn = lvl === "ERROR" ? "error" : lvl === "WARN" ? "warn" : lvl === "INFO" ? "info" : "debug"; fn(`[RR] ${new Date(ts).toISOString()} | ${lvl} | ${msg}`, payload.meta);
log(fn, message, { ts, ...safeJson(details) });
} catch { } catch {
/* ignore console/log failures */ // ignore console failures
} }
// Structured RR event for FE debug panel (parity with Fortellis' CreateFortellisLogEvent) // Socket
// socket.emit("fortellis-log-event", { level, message, txnDetails });
try { try {
socket?.emit?.("rr-log-event", { socket?.emit?.("rr-log-event", payload);
level: lvl,
message,
ts,
txnDetails: details
});
} catch { } catch {
/* ignore socket emit failures */ // ignore socket failures
} }
}
// Best-effort ensure details are JSON-safe (avoid circular / BigInt) return payload;
function safeJson(obj) { };
try {
return JSON.parse(JSON.stringify(obj ?? {}));
} catch {
return { _unsafe: true };
}
}
module.exports = CreateRRLogEvent; module.exports = CreateRRLogEvent;

View File

@@ -1,35 +1,32 @@
// File: server/rr/rr-logger.js
// Console logger for RR flows with safe JSON. No socket emission by default.
const baseLogger = require("../utils/logger"); const baseLogger = require("../utils/logger");
function RRLogger(_socket, defaults = {}) { const safeSerialize = (value) => {
function safeSerialize(value) { try {
try { const seen = new WeakSet();
const seen = new WeakSet(); return JSON.stringify(value, (key, val) => {
return JSON.stringify(value, (key, val) => { if (typeof val === "bigint") return val.toString();
if (typeof val === "bigint") return val.toString(); if (val instanceof Error) return { name: val.name, message: val.message, stack: val.stack };
if (val instanceof Error) return { name: val.name, message: val.message, stack: val.stack }; if (typeof val === "function") return undefined;
if (typeof val === "function") return undefined; if (typeof val === "object" && val !== null) {
if (typeof val === "object" && val !== null) { if (seen.has(val)) return "[Circular]";
if (seen.has(val)) return "[Circular]"; seen.add(val);
seen.add(val); if (val instanceof Date) return val.toISOString();
if (val instanceof Date) return val.toISOString(); if (val instanceof Map) return Object.fromEntries(val);
if (val instanceof Map) return Object.fromEntries(val); if (val instanceof Set) return Array.from(val);
if (val instanceof Set) return Array.from(val); if (typeof Buffer !== "undefined" && Buffer.isBuffer?.(val)) return `<Buffer len=${val.length}>`;
if (typeof Buffer !== "undefined" && Buffer.isBuffer?.(val)) return `<Buffer len=${val.length}>`;
}
return val;
});
} catch {
try {
return String(value);
} catch {
return "[Unserializable]";
} }
return val;
});
} catch {
try {
return String(value);
} catch {
return "[Unserializable]";
} }
} }
};
const RRLogger = (_socket, defaults = {}) => {
return function log(level = "info", message = "", ctx = {}) { return function log(level = "info", message = "", ctx = {}) {
const lvl = String(level || "info").toLowerCase(); const lvl = String(level || "info").toLowerCase();
const iso = new Date().toISOString(); const iso = new Date().toISOString();
@@ -48,9 +45,11 @@ function RRLogger(_socket, defaults = {}) {
} catch { } catch {
try { try {
console.log(line); console.log(line);
} catch {} } catch {
// swallow
}
} }
}; };
} };
module.exports = RRLogger; module.exports = RRLogger;

View File

@@ -4,7 +4,7 @@ const { getRRConfigFromBodyshop } = require("./rr-config");
/** /**
* Build an RR client + common opts from a bodyshop row * Build an RR client + common opts from a bodyshop row
*/ */
function buildClientAndOpts(bodyshop) { const buildClientAndOpts = (bodyshop) => {
const cfg = getRRConfigFromBodyshop(bodyshop); const cfg = getRRConfigFromBodyshop(bodyshop);
const client = new RRClient({ const client = new RRClient({
@@ -30,13 +30,13 @@ function buildClientAndOpts(bodyshop) {
}; };
return { client, opts }; return { client, opts };
} };
/** /**
* Normalize the combined-search arguments into the RR shape. * Normalize the combined-search arguments into the RR shape.
* We infer `kind` if not provided, based on the first detectable field. * We infer `kind` if not provided, based on the first detectable field.
*/ */
function toCombinedSearchPayload(args = {}) { const toCombinedSearchPayload = (args = {}) => {
const q = { ...args }; const q = { ...args };
// Decide kind if not provided // Decide kind if not provided
@@ -125,26 +125,26 @@ function toCombinedSearchPayload(args = {}) {
if (q.license && payload.kind !== "license") payload.license = String(q.license).trim(); if (q.license && payload.kind !== "license") payload.license = String(q.license).trim();
return payload; return payload;
} };
/** /**
* Combined customer/service/vehicle search * Combined customer/service/vehicle search
* @param bodyshop - bodyshop row (must include rr_dealerid & rr_configuration with store/branch) * @param bodyshop - bodyshop row (must include rr_dealerid & rr_configuration with store/branch)
* @param args - search inputs (phone | license | vin | nameRecId | name | stkNo) * @param args - search inputs (phone | license | vin | nameRecId | name | stkNo)
*/ */
async function rrCombinedSearch(bodyshop, args = {}) { const rrCombinedSearch = async (bodyshop, args = {}) => {
const { client, opts } = buildClientAndOpts(bodyshop); const { client, opts } = buildClientAndOpts(bodyshop);
const payload = toCombinedSearchPayload(args); const payload = toCombinedSearchPayload(args);
const res = await client.combinedSearch(payload, opts); const res = await client.combinedSearch(payload, opts);
return res?.data ?? res; // lib returns { success, data, ... } return res?.data ?? res; // lib returns { success, data, ... }
} };
/** /**
* Advisors lookup * Advisors lookup
* @param bodyshop * @param bodyshop
* @param args - { department: 'B'|'S'|'P'|string, advisorNumber?: string } * @param args - { department: 'B'|'S'|'P'|string, advisorNumber?: string }
*/ */
async function rrGetAdvisors(bodyshop, args = {}) { const rrGetAdvisors = async (bodyshop, args = {}) => {
const { client, opts } = buildClientAndOpts(bodyshop); const { client, opts } = buildClientAndOpts(bodyshop);
// Accept either department or departmentType from FE // Accept either department or departmentType from FE
const dep = String(args.department ?? args.departmentType ?? "").toUpperCase(); const dep = String(args.department ?? args.departmentType ?? "").toUpperCase();
@@ -158,7 +158,7 @@ async function rrGetAdvisors(bodyshop, args = {}) {
const res = await client.getAdvisors(payload, opts); const res = await client.getAdvisors(payload, opts);
return res?.data ?? res; return res?.data ?? res;
} };
module.exports = { module.exports = {
rrCombinedSearch, rrCombinedSearch,

View File

@@ -6,10 +6,7 @@ const CdkCalculateAllocations = require("../cdk/cdk-calculate-allocations").defa
const { createRRCustomer } = require("./rr-customers"); const { createRRCustomer } = require("./rr-customers");
const { ensureRRServiceVehicle } = require("./rr-service-vehicles"); const { ensureRRServiceVehicle } = require("./rr-service-vehicles");
const { classifyRRVendorError } = require("./rr-errors"); const { classifyRRVendorError } = require("./rr-errors");
// NEW: export logs (success/failure) parity with Fortellis/PBS
const { markRRExportSuccess, insertRRFailedExportLog } = require("./rr-export-logs"); const { markRRExportSuccess, insertRRFailedExportLog } = require("./rr-export-logs");
const { const {
makeVehicleSearchPayloadFromJob, makeVehicleSearchPayloadFromJob,
ownersFromVinBlocks, ownersFromVinBlocks,
@@ -19,7 +16,6 @@ const {
defaultRRTTL, defaultRRTTL,
RRCacheEnums RRCacheEnums
} = require("./rr-utils"); } = require("./rr-utils");
const { GraphQLClient } = require("graphql-request"); const { GraphQLClient } = require("graphql-request");
const queries = require("../graphql-client/queries"); const queries = require("../graphql-client/queries");
@@ -36,9 +32,8 @@ const ADVISORS_CACHE_TTL = 7 * 24 * 60 * 60; // seconds
* @param job * @param job
* @returns {*|null} * @returns {*|null}
*/ */
function resolveJobId(explicit, payload, job) { const resolveJobId = (explicit, payload, job) =>
return explicit || payload?.jobId || payload?.jobid || job?.id || job?.jobId || job?.jobid || null; explicit || payload?.jobId || payload?.jobid || job?.id || job?.jobId || job?.jobid || null;
}
/** /**
* Resolve VIN from tx/job shapes * Resolve VIN from tx/job shapes
@@ -46,18 +41,15 @@ function resolveJobId(explicit, payload, job) {
* @param job * @param job
* @returns {*|null} * @returns {*|null}
*/ */
function resolveVin({ tx, job }) { const resolveVin = ({ tx, job }) => tx?.jobData?.vin || job?.v_vin || null;
// Prefer cached tx vin (if we made one), then common job shapes (v_vin for our schema)
return tx?.jobData?.vin || job?.v_vin || null;
}
/** /**
* Sort vehicle owners first in the list, preserving original order otherwise. * Sort vehicle owners first in the list, preserving original order otherwise.
* @param list * @param list
* @returns {*} * @returns {*}
*/ */
function sortVehicleOwnerFirst(list) { const sortVehicleOwnerFirst = (list) =>
return list list
.map((v, i) => ({ v, i })) .map((v, i) => ({ v, i }))
.sort((a, b) => { .sort((a, b) => {
const ao = a.v?.isVehicleOwner ? 1 : 0; const ao = a.v?.isVehicleOwner ? 1 : 0;
@@ -66,15 +58,13 @@ function sortVehicleOwnerFirst(list) {
return a.i - b.i; return a.i - b.i;
}) })
.map(({ v }) => v); .map(({ v }) => v);
}
/** /**
* NEW: merge candidates coming from multiple queries (name + vin) by custNo. * Merge customer candidates by custNo, combining isVehicleOwner flags and filling missing fields.
* - keeps first non-empty name * @param items
* - preserves/ORs vinOwner/isVehicleOwner * @returns {any[]}
* - keeps first non-empty address
*/ */
function mergeByCustNo(items = []) { const mergeByCustNo = (items = []) => {
const byId = new Map(); const byId = new Map();
for (const c of items) { for (const c of items) {
const id = (c?.custNo || "").trim(); const id = (c?.custNo || "").trim();
@@ -93,7 +83,7 @@ function mergeByCustNo(items = []) {
} }
} }
return Array.from(byId.values()); return Array.from(byId.values());
} };
/** /**
* Get session data or socket fallback * Get session data or socket fallback
@@ -101,7 +91,7 @@ function mergeByCustNo(items = []) {
* @param socket * @param socket
* @returns {Promise<{bodyshopId: *, email: *, sess: null}>} * @returns {Promise<{bodyshopId: *, email: *, sess: null}>}
*/ */
async function getSessionOrSocket(redisHelpers, socket) { const getSessionOrSocket = async (redisHelpers, socket) => {
let sess = null; let sess = null;
try { try {
sess = await redisHelpers.getSessionData(socket.id); sess = await redisHelpers.getSessionData(socket.id);
@@ -112,7 +102,7 @@ async function getSessionOrSocket(redisHelpers, socket) {
const email = sess?.email ?? socket.user?.email; const email = sess?.email ?? socket.user?.email;
if (!bodyshopId) throw new Error("No bodyshopId (session/socket)"); if (!bodyshopId) throw new Error("No bodyshopId (session/socket)");
return { bodyshopId, email, sess }; return { bodyshopId, email, sess };
} };
/** /**
* Fetch bodyshop data for socket * Fetch bodyshop data for socket
@@ -120,7 +110,7 @@ async function getSessionOrSocket(redisHelpers, socket) {
* @param socket * @param socket
* @returns {Promise<{id: string, intellipay_config: {payment_map: {amex: string}}}|{id: string, intellipay_config: null}|*>} * @returns {Promise<{id: string, intellipay_config: {payment_map: {amex: string}}}|{id: string, intellipay_config: null}|*>}
*/ */
async function getBodyshopForSocket({ bodyshopId, socket }) { const getBodyshopForSocket = async ({ bodyshopId, socket }) => {
const endpoint = process.env.GRAPHQL_ENDPOINT; const endpoint = process.env.GRAPHQL_ENDPOINT;
if (!endpoint) throw new Error("GRAPHQL_ENDPOINT not configured"); if (!endpoint) throw new Error("GRAPHQL_ENDPOINT not configured");
const token = (socket?.data && socket.data.authToken) || (socket?.handshake?.auth && socket.handshake.auth.token); const token = (socket?.data && socket.data.authToken) || (socket?.handshake?.auth && socket.handshake.auth.token);
@@ -131,12 +121,16 @@ async function getBodyshopForSocket({ bodyshopId, socket }) {
const bodyshop = res?.bodyshops_by_pk; const bodyshop = res?.bodyshops_by_pk;
if (!bodyshop) throw new Error(`Bodyshop not found: ${bodyshopId}`); if (!bodyshop) throw new Error(`Bodyshop not found: ${bodyshopId}`);
return bodyshop; return bodyshop;
} };
/** /**
* Build advisors cache namespace + field (per bodyshop + routing + department) * Build advisors cache namespace and field
* @param bodyshopId
* @param routing
* @param departmentType
* @returns {{ns: string, field: string}}
*/ */
function buildAdvisorsCacheNS({ bodyshopId, routing, departmentType = "B" }) { const buildAdvisorsCacheNS = ({ bodyshopId, routing, departmentType = "B" }) => {
const dealer = routing?.dealerNumber || "unknown"; const dealer = routing?.dealerNumber || "unknown";
const store = routing?.storeNumber || "none"; const store = routing?.storeNumber || "none";
const area = routing?.areaNumber || "none"; const area = routing?.areaNumber || "none";
@@ -145,12 +139,17 @@ function buildAdvisorsCacheNS({ bodyshopId, routing, departmentType = "B" }) {
ns: `rr:advisors:${bodyshopId}:${dealer}:${store}:${area}`, ns: `rr:advisors:${bodyshopId}:${dealer}:${store}:${area}`,
field: `dept:${dept}` field: `dept:${dept}`
}; };
} };
/** /**
* VIN + Full Name merge (export flow) * Run multi-query customer search (Full Name + VIN) and merge results.
* @param bodyshop
* @param job
* @param socket
* @param redisHelpers
* @returns {Promise<*|*[]>}
*/ */
async function rrMultiCustomerSearch({ bodyshop, job, socket, redisHelpers }) { const rrMultiCustomerSearch = async ({ bodyshop, job, socket, redisHelpers }) => {
const queriesList = []; const queriesList = [];
// 1) Full Name (preferred) // 1) Full Name (preferred)
@@ -207,11 +206,14 @@ async function rrMultiCustomerSearch({ bodyshop, job, socket, redisHelpers }) {
const deduped = mergeByCustNo(merged); const deduped = mergeByCustNo(merged);
return sortVehicleOwnerFirst(deduped); return sortVehicleOwnerFirst(deduped);
} };
// ---------------- register handlers ---------------- /**
function registerRREvents({ socket, redisHelpers }) { * Register RR socket events
// ---------- Lookup passthrough ---------- * @param socket
* @param redisHelpers
*/
const registerRREvents = ({ socket, redisHelpers }) => {
socket.on("rr-lookup-combined", async ({ jobid, params } = {}, cb) => { socket.on("rr-lookup-combined", async ({ jobid, params } = {}, cb) => {
try { try {
const { bodyshopId } = await getSessionOrSocket(redisHelpers, socket); const { bodyshopId } = await getSessionOrSocket(redisHelpers, socket);
@@ -242,7 +244,6 @@ function registerRREvents({ socket, redisHelpers }) {
} }
}); });
// ---------- Advisors (cached) ----------
socket.on("rr-get-advisors", async (args = {}, ack) => { socket.on("rr-get-advisors", async (args = {}, ack) => {
const refresh = !!args?.refresh; const refresh = !!args?.refresh;
const requestedDept = (args?.departmentType || "B").toUpperCase(); const requestedDept = (args?.departmentType || "B").toUpperCase();
@@ -317,8 +318,6 @@ function registerRREvents({ socket, redisHelpers }) {
} }
}); });
// ================= Fortellis-style two-step export (RR only) =================
// 1) Stage export -> search (Full Name + VIN) -> emit rr-select-customer
socket.on("rr-export-job", async ({ jobid, jobId, txEnvelope } = {}) => { socket.on("rr-export-job", async ({ jobid, jobId, txEnvelope } = {}) => {
const rid = resolveJobId(jobid || jobId, { jobId, jobid }, null); const rid = resolveJobId(jobid || jobId, { jobId, jobid }, null);
try { try {
@@ -385,7 +384,6 @@ function registerRREvents({ socket, redisHelpers }) {
} }
}); });
// 2) Selection (or create) -> ensure vehicle -> CREATE RO (do not mark exported)
socket.on("rr-selected-customer", async ({ jobid, jobId, selectedCustomerId, custNo, create } = {}, ack) => { socket.on("rr-selected-customer", async ({ jobid, jobId, selectedCustomerId, custNo, create } = {}, ack) => {
const rid = resolveJobId(jobid || jobId, { jobid, jobId }, null); const rid = resolveJobId(jobid || jobId, { jobid, jobId }, null);
let bodyshop = null; let bodyshop = null;
@@ -701,7 +699,6 @@ function registerRREvents({ socket, redisHelpers }) {
} }
}); });
// 3) Finalize -> updateRepairOrder(finalUpdate: "Y") -> mark exported
socket.on("rr-finalize-repair-order", async ({ jobid, jobId } = {}, ack) => { socket.on("rr-finalize-repair-order", async ({ jobid, jobId } = {}, ack) => {
const rid = resolveJobId(jobid || jobId, { jobid, jobId }, null); const rid = resolveJobId(jobid || jobId, { jobid, jobId }, null);
let bodyshop = null; let bodyshop = null;
@@ -847,7 +844,6 @@ function registerRREvents({ socket, redisHelpers }) {
} }
}); });
// ---------- Allocations (parity) ----------
socket.on("rr-calculate-allocations", async (jobid, cb) => { socket.on("rr-calculate-allocations", async (jobid, cb) => {
try { try {
CreateRRLogEvent(socket, "DEBUG", "rr-calculate-allocations: begin", { jobid }); CreateRRLogEvent(socket, "DEBUG", "rr-calculate-allocations: begin", { jobid });
@@ -860,6 +856,6 @@ function registerRREvents({ socket, redisHelpers }) {
cb?.({ ok: false, error: e.message }); cb?.({ ok: false, error: e.message });
} }
}); });
} };
module.exports = registerRREvents; module.exports = registerRREvents;

View File

@@ -1,11 +1,13 @@
// File: server/rr/rr-service-vehicles.js
// Idempotent Service Vehicle ensure: if VIN exists (owner match or not), don't fail.
const RRLogger = require("./rr-logger"); const RRLogger = require("./rr-logger");
const { buildClientAndOpts, rrCombinedSearch } = require("./rr-lookup"); const { buildClientAndOpts, rrCombinedSearch } = require("./rr-lookup");
// --- helpers --- /**
function pickVin({ vin, job }) { * 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; const v = vin || job?.v_vin || job?.vehicle?.vin || job?.vin || job?.vehicleVin || null;
if (!v) return ""; if (!v) return "";
@@ -13,14 +15,27 @@ function pickVin({ vin, job }) {
.replace(/[^A-Za-z0-9]/g, "") .replace(/[^A-Za-z0-9]/g, "")
.toUpperCase() .toUpperCase()
.slice(0, 17); .slice(0, 17);
} };
function pickCustNo({ selectedCustomerNo, custNo, customerNo }) { /**
* 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; const c = selectedCustomerNo ?? custNo ?? customerNo ?? null;
return c != null ? String(c).trim() : ""; return c != null ? String(c).trim() : "";
} };
function ownersFromCombined(res, wantedVin) { /**
* Extract owner customer numbers from combined search results
* @param res
* @param wantedVin
* @returns {Set<any>}
*/
const ownersFromCombined = (res, wantedVin) => {
const blocks = Array.isArray(res?.data) ? res.data : Array.isArray(res) ? res : []; const blocks = Array.isArray(res?.data) ? res.data : Array.isArray(res) ? res : [];
const owners = new Set(); const owners = new Set();
for (const blk of blocks) { for (const blk of blocks) {
@@ -35,14 +50,19 @@ function ownersFromCombined(res, wantedVin) {
} }
} }
return owners; return owners;
} };
function isAlreadyExistsError(e) { /**
* Determine if error indicates "already exists"
* @param e
* @returns {boolean}
*/
const isAlreadyExistsError = (e) => {
if (!e) return false; if (!e) return false;
if (e.code === 300) return true; if (e.code === 300) return true;
const msg = (e.message || "").toUpperCase(); const msg = (e.message || "").toUpperCase();
return msg.includes("ALREADY EXISTS") || msg.includes("VEHICLE ALREADY EXISTS"); return msg.includes("ALREADY EXISTS") || msg.includes("VEHICLE ALREADY EXISTS");
} };
/** /**
* Ensure/create a Service Vehicle in RR for the given VIN + customer. * Ensure/create a Service Vehicle in RR for the given VIN + customer.
@@ -56,7 +76,7 @@ function isAlreadyExistsError(e) {
* *
* Returns: { created:boolean, exists:boolean, vin, customerNo, svId?, status? } * Returns: { created:boolean, exists:boolean, vin, customerNo, svId?, status? }
*/ */
async function ensureRRServiceVehicle(args = {}) { const ensureRRServiceVehicle = async (args = {}) => {
const { const {
client: inClient, client: inClient,
routing: inRouting, routing: inRouting,
@@ -199,7 +219,7 @@ async function ensureRRServiceVehicle(args = {}) {
}); });
throw e; throw e;
} }
} };
module.exports = { module.exports = {
ensureRRServiceVehicle ensureRRServiceVehicle