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 { useEffect, useMemo, useState } from "react";
import dayjs from "../../utils/day";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
@@ -13,38 +14,116 @@ const mapDispatchToProps = (dispatch) => ({
export default connect(mapStateToProps, mapDispatchToProps)(DmsLogEvents);
export function DmsLogEvents({ logs }) {
return (
<Timeline
pending
reverse={true}
items={logs.map((log, idx) => ({
key: idx,
color: LogLevelHierarchy(log.level),
children: (
<Space wrap align="start" style={{}}>
<Tag color={LogLevelHierarchy(log.level)}>{log.level}</Tag>
<span>{dayjs(log.timestamp).format("MM/DD/YYYY HH:mm:ss")}</span>
<Divider type="vertical" />
<span>{log.message}</span>
</Space>
)
}))}
/>
export function DmsLogEvents({ logs, detailsOpen, detailsNonce }) {
const [openSet, setOpenSet] = useState(() => new Set());
// Trim openSet if logs shrink
useEffect(() => {
const len = (logs || []).length;
setOpenSet((prev) => {
const next = new Set();
for (let i = 0; i < len; i++) if (prev.has(i)) next.add(i);
return next;
});
}, [logs?.length]);
// Respond to global toggle button
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) {
switch (level) {
/** Accepts both legacy shape and new "normalized" shape */
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":
return "orange";
case "INFO":
return "blue";
case "WARN":
case "WARNING":
return "yellow";
case "ERROR":
return "red";
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 { Button, Card, Col, Result, Row, Select, Space } from "antd";
import queryString from "query-string";
@@ -55,6 +54,9 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse
const history = useNavigate();
const [logs, setLogs] = useState([]);
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 notification = useNotification();
const {
@@ -77,9 +79,14 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse
fetchPolicy: "network-only",
nextFetchPolicy: "network-only"
});
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 clearRrOpenRoLimit = () => setRrOpenRoLimit(false);
@@ -166,7 +173,16 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse
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)
const handleExportSuccess = (payload) => {
@@ -379,7 +395,6 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse
value={logLevel}
onChange={(value) => {
setLogLevel(value);
// Send to the active socket type
if (dms === "rr" || Fortellis.treatment === "on") {
wsssocket.emit("set-log-level", value);
} else {
@@ -392,6 +407,7 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse
<Select.Option key="WARN">WARN</Select.Option>
<Select.Option key="ERROR">ERROR</Select.Option>
</Select>
<Button onClick={toggleDetailsAll}>{detailsOpen ? "Collapse All" : "Expand All"}</Button>
<Button onClick={() => setLogs([])}>Clear Logs</Button>
<Button
onClick={() => {
@@ -409,7 +425,7 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse
</Space>
}
>
<DmsLogEvents socket={socket} logs={logs} />
<DmsLogEvents logs={logs} detailsOpen={detailsOpen} detailsNonce={detailsNonce} />
</Card>
</div>
</Col>

View File

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

View File

@@ -1,8 +1,11 @@
// File: server/rr/rr-customers.js
const { RRClient } = require("./lib/index.cjs");
const { getRRConfigFromBodyshop } = require("./rr-config");
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 = {
US: "US",
USA: "US",
@@ -12,7 +15,12 @@ const COUNTRY_MAP = {
CANADA: "CA"
};
function toCountry2(v) {
/**
* Normalize country input to 2-char code
* @param v
* @returns {*|string}
*/
const toCountry2 = (v) => {
const s = String(v || "")
.trim()
.toUpperCase();
@@ -20,22 +28,38 @@ function toCountry2(v) {
if (COUNTRY_MAP[s]) return COUNTRY_MAP[s];
// fallbacks: prefer 2-char; last resort: take first 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 n = d.length === 11 && d.startsWith("1") ? d.slice(1) : d;
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();
if (country === "US") return s.replace(/[^0-9]/g, "").slice(0, 5);
if (country === "CA") return s.toUpperCase().replace(/\s+/g, "").slice(0, 6);
return s;
}
};
function sanitizeRRCustomerPayload(payload = {}) {
/**
* Sanitize RR customer payload (addresses, phones, names)
* @param payload
* @returns {{}}
*/
const sanitizeRRCustomerPayload = (payload = {}) => {
const out = { ...payload };
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);
return out;
}
};
/**
* Build an RR client + common opts from a bodyshop row
* @param bodyshop
* @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 client = new RRClient({
baseUrl: cfg.baseUrl,
@@ -90,25 +114,25 @@ function buildClientAndOpts(bodyshop) {
}
};
return { client, opts };
}
};
/**
* Strip all non-digit characters from a string
* @param s
* @returns {string}
*/
function digitsOnly(s) {
const digitsOnly = (s) => {
return String(s || "").replace(/\D/g, "");
}
};
/**
* Return a new array with only unique values from the input array
* @param arr
* @returns {any[]}
*/
function uniq(arr) {
const uniq = (arr) => {
return Array.from(new Set(arr));
}
};
/**
* Build RR customer payload from job.ownr_* fields, with optional overrides.
@@ -116,7 +140,7 @@ function uniq(arr) {
* @param overrides
* @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.*)
const firstName = overrides.firstName ?? job?.ownr_fn ?? 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]);
return payload;
}
};
/**
* Create a customer in RR and return { customerNo, raw }.
* 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 { client, opts } = buildClientAndOpts(bodyshop);
const payload = buildCustomerPayloadFromJob(job, overrides);
@@ -213,7 +237,7 @@ async function createRRCustomer({ bodyshop, job, overrides = {}, socket }) {
}
return { customerNo: String(customerNo), raw: data };
}
};
module.exports = {
createRRCustomer

View File

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

View File

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

View File

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

View File

@@ -1,29 +1,49 @@
const client = require("../graphql-client/graphql-client").client;
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;
}
// Combined search helpers expect array-like blocks
function blocksFromCombinedSearchResult(res) {
/**
* Extract blocks array from combined search result.
* @param res
* @returns {any[]|*[]}
*/
const blocksFromCombinedSearchResult = (res) => {
const data = Array.isArray(res?.data) ? res.data : Array.isArray(res) ? res : [];
return Array.isArray(data) ? data : [];
}
};
// ---------- 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?.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";
throw new Error(`QueryJobData failed: ${msg}`);
}
}
};
/**
* Build minimal RR RO payload (keys match RR client expectations).
* 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
* Build RR Repair Order payload from job and customer data.
* @param job
* @param selectedCustomer
* @param advisorNo
* @returns {{outsdRoNo: string, repairOrderNumber: string, departmentType: string, vin: string, customerNo: string, advisorNo: string, mileageIn: *|null}}
*/
function buildRRRepairOrderPayload({ job, selectedCustomer, advisorNo }) {
const buildRRRepairOrderPayload = ({ job, selectedCustomer, advisorNo }) => {
const customerNo = selectedCustomer?.customerNo
? String(selectedCustomer.customerNo).trim()
: selectedCustomer?.custNo
@@ -94,9 +112,14 @@ function buildRRRepairOrderPayload({ job, selectedCustomer, advisorNo }) {
// ---- Mileage In (new) ----
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);
if (vin) return { kind: "vin", vin };
@@ -104,9 +127,14 @@ function makeVehicleSearchPayloadFromJob(job) {
if (plate) return { kind: "license", license: String(plate).trim() };
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 d = digitsOnly(phone);
if (d.length >= 7) return { kind: "phone", phone: d };
@@ -120,9 +148,14 @@ function makeCustomerSearchPayloadFromJob(job) {
if (vin) return { kind: "vin", vin };
return null;
}
};
function normalizeCustomerCandidates(res) {
/**
* Normalize customer candidates from combined search result.
* @param res
* @returns {*[]}
*/
const normalizeCustomerCandidates = (res) => {
const blocks = blocksFromCombinedSearchResult(res);
const out = [];
for (const blk of blocks) {
@@ -146,9 +179,14 @@ function normalizeCustomerCandidates(res) {
seen.add(c.custNo);
return true;
});
}
};
function normalizeVehicleCandidates(res) {
/**
* Normalize vehicle candidates from combined search result.
* @param res
* @returns {*[]}
*/
const normalizeVehicleCandidates = (res) => {
const blocks = blocksFromCombinedSearchResult(res);
const out = [];
for (const blk of blocks) {
@@ -170,7 +208,7 @@ function normalizeVehicleCandidates(res) {
seen.add(v.vin);
return true;
});
}
};
module.exports = {
QueryJobData,

View File

@@ -1,44 +1,75 @@
// File: server/rr/rr-logger-event.js
// Fortellis-style log helper for RR flows.
// Usage: CreateRRLogEvent(socket, "DEBUG"|"INFO"|"WARN"|"ERROR", message, details?)
const logger = require("../utils/logger");
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 = {}) {
const lvl = String(level || "DEBUG").toUpperCase();
/**
* Safely serialize meta information for logging.
* @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 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 {
const log = RRLogger(socket);
const fn = lvl === "ERROR" ? "error" : lvl === "WARN" ? "warn" : lvl === "INFO" ? "info" : "debug";
log(fn, message, { ts, ...safeJson(details) });
const fn = logger?.logger?.[lvl.toLowerCase()] ?? logger?.logger?.info ?? console.log;
fn(`[RR] ${new Date(ts).toISOString()} | ${lvl} | ${msg}`, payload.meta);
} catch {
/* ignore console/log failures */
// ignore console failures
}
// Structured RR event for FE debug panel (parity with Fortellis' CreateFortellisLogEvent)
// socket.emit("fortellis-log-event", { level, message, txnDetails });
// Socket
try {
socket?.emit?.("rr-log-event", {
level: lvl,
message,
ts,
txnDetails: details
});
socket?.emit?.("rr-log-event", payload);
} catch {
/* ignore socket emit failures */
// ignore socket failures
}
}
// Best-effort ensure details are JSON-safe (avoid circular / BigInt)
function safeJson(obj) {
try {
return JSON.parse(JSON.stringify(obj ?? {}));
} catch {
return { _unsafe: true };
}
}
return payload;
};
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");
function RRLogger(_socket, defaults = {}) {
function safeSerialize(value) {
try {
const seen = new WeakSet();
return JSON.stringify(value, (key, val) => {
if (typeof val === "bigint") return val.toString();
if (val instanceof Error) return { name: val.name, message: val.message, stack: val.stack };
if (typeof val === "function") return undefined;
if (typeof val === "object" && val !== null) {
if (seen.has(val)) return "[Circular]";
seen.add(val);
if (val instanceof Date) return val.toISOString();
if (val instanceof Map) return Object.fromEntries(val);
if (val instanceof Set) return Array.from(val);
if (typeof Buffer !== "undefined" && Buffer.isBuffer?.(val)) return `<Buffer len=${val.length}>`;
}
return val;
});
} catch {
try {
return String(value);
} catch {
return "[Unserializable]";
const safeSerialize = (value) => {
try {
const seen = new WeakSet();
return JSON.stringify(value, (key, val) => {
if (typeof val === "bigint") return val.toString();
if (val instanceof Error) return { name: val.name, message: val.message, stack: val.stack };
if (typeof val === "function") return undefined;
if (typeof val === "object" && val !== null) {
if (seen.has(val)) return "[Circular]";
seen.add(val);
if (val instanceof Date) return val.toISOString();
if (val instanceof Map) return Object.fromEntries(val);
if (val instanceof Set) return Array.from(val);
if (typeof Buffer !== "undefined" && Buffer.isBuffer?.(val)) return `<Buffer len=${val.length}>`;
}
return val;
});
} catch {
try {
return String(value);
} catch {
return "[Unserializable]";
}
}
};
const RRLogger = (_socket, defaults = {}) => {
return function log(level = "info", message = "", ctx = {}) {
const lvl = String(level || "info").toLowerCase();
const iso = new Date().toISOString();
@@ -48,9 +45,11 @@ function RRLogger(_socket, defaults = {}) {
} catch {
try {
console.log(line);
} catch {}
} catch {
// swallow
}
}
};
}
};
module.exports = RRLogger;

View File

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

View File

@@ -6,10 +6,7 @@ const CdkCalculateAllocations = require("../cdk/cdk-calculate-allocations").defa
const { createRRCustomer } = require("./rr-customers");
const { ensureRRServiceVehicle } = require("./rr-service-vehicles");
const { classifyRRVendorError } = require("./rr-errors");
// NEW: export logs (success/failure) parity with Fortellis/PBS
const { markRRExportSuccess, insertRRFailedExportLog } = require("./rr-export-logs");
const {
makeVehicleSearchPayloadFromJob,
ownersFromVinBlocks,
@@ -19,7 +16,6 @@ const {
defaultRRTTL,
RRCacheEnums
} = require("./rr-utils");
const { GraphQLClient } = require("graphql-request");
const queries = require("../graphql-client/queries");
@@ -36,9 +32,8 @@ const ADVISORS_CACHE_TTL = 7 * 24 * 60 * 60; // seconds
* @param job
* @returns {*|null}
*/
function resolveJobId(explicit, payload, job) {
return explicit || payload?.jobId || payload?.jobid || job?.id || job?.jobId || job?.jobid || null;
}
const resolveJobId = (explicit, payload, job) =>
explicit || payload?.jobId || payload?.jobid || job?.id || job?.jobId || job?.jobid || null;
/**
* Resolve VIN from tx/job shapes
@@ -46,18 +41,15 @@ function resolveJobId(explicit, payload, job) {
* @param job
* @returns {*|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 || null;
}
const resolveVin = ({ tx, job }) => tx?.jobData?.vin || job?.v_vin || null;
/**
* Sort vehicle owners first in the list, preserving original order otherwise.
* @param list
* @returns {*}
*/
function sortVehicleOwnerFirst(list) {
return list
const sortVehicleOwnerFirst = (list) =>
list
.map((v, i) => ({ v, i }))
.sort((a, b) => {
const ao = a.v?.isVehicleOwner ? 1 : 0;
@@ -66,15 +58,13 @@ function sortVehicleOwnerFirst(list) {
return a.i - b.i;
})
.map(({ v }) => v);
}
/**
* NEW: merge candidates coming from multiple queries (name + vin) by custNo.
* - keeps first non-empty name
* - preserves/ORs vinOwner/isVehicleOwner
* - keeps first non-empty address
* Merge customer candidates by custNo, combining isVehicleOwner flags and filling missing fields.
* @param items
* @returns {any[]}
*/
function mergeByCustNo(items = []) {
const mergeByCustNo = (items = []) => {
const byId = new Map();
for (const c of items) {
const id = (c?.custNo || "").trim();
@@ -93,7 +83,7 @@ function mergeByCustNo(items = []) {
}
}
return Array.from(byId.values());
}
};
/**
* Get session data or socket fallback
@@ -101,7 +91,7 @@ function mergeByCustNo(items = []) {
* @param socket
* @returns {Promise<{bodyshopId: *, email: *, sess: null}>}
*/
async function getSessionOrSocket(redisHelpers, socket) {
const getSessionOrSocket = async (redisHelpers, socket) => {
let sess = null;
try {
sess = await redisHelpers.getSessionData(socket.id);
@@ -112,7 +102,7 @@ async function getSessionOrSocket(redisHelpers, socket) {
const email = sess?.email ?? socket.user?.email;
if (!bodyshopId) throw new Error("No bodyshopId (session/socket)");
return { bodyshopId, email, sess };
}
};
/**
* Fetch bodyshop data for socket
@@ -120,7 +110,7 @@ async function getSessionOrSocket(redisHelpers, socket) {
* @param socket
* @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;
if (!endpoint) throw new Error("GRAPHQL_ENDPOINT not configured");
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;
if (!bodyshop) throw new Error(`Bodyshop not found: ${bodyshopId}`);
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 store = routing?.storeNumber || "none";
const area = routing?.areaNumber || "none";
@@ -145,12 +139,17 @@ function buildAdvisorsCacheNS({ bodyshopId, routing, departmentType = "B" }) {
ns: `rr:advisors:${bodyshopId}:${dealer}:${store}:${area}`,
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 = [];
// 1) Full Name (preferred)
@@ -207,11 +206,14 @@ async function rrMultiCustomerSearch({ bodyshop, job, socket, redisHelpers }) {
const deduped = mergeByCustNo(merged);
return sortVehicleOwnerFirst(deduped);
}
};
// ---------------- register handlers ----------------
function registerRREvents({ socket, redisHelpers }) {
// ---------- Lookup passthrough ----------
/**
* Register RR socket events
* @param socket
* @param redisHelpers
*/
const registerRREvents = ({ socket, redisHelpers }) => {
socket.on("rr-lookup-combined", async ({ jobid, params } = {}, cb) => {
try {
const { bodyshopId } = await getSessionOrSocket(redisHelpers, socket);
@@ -242,7 +244,6 @@ function registerRREvents({ socket, redisHelpers }) {
}
});
// ---------- Advisors (cached) ----------
socket.on("rr-get-advisors", async (args = {}, ack) => {
const refresh = !!args?.refresh;
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 } = {}) => {
const rid = resolveJobId(jobid || jobId, { jobId, jobid }, null);
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) => {
const rid = resolveJobId(jobid || jobId, { jobid, jobId }, 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) => {
const rid = resolveJobId(jobid || jobId, { jobid, jobId }, null);
let bodyshop = null;
@@ -847,7 +844,6 @@ function registerRREvents({ socket, redisHelpers }) {
}
});
// ---------- Allocations (parity) ----------
socket.on("rr-calculate-allocations", async (jobid, cb) => {
try {
CreateRRLogEvent(socket, "DEBUG", "rr-calculate-allocations: begin", { jobid });
@@ -860,6 +856,6 @@ function registerRREvents({ socket, redisHelpers }) {
cb?.({ ok: false, error: e.message });
}
});
}
};
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 { 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;
if (!v) return "";
@@ -13,14 +15,27 @@ function pickVin({ vin, job }) {
.replace(/[^A-Za-z0-9]/g, "")
.toUpperCase()
.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;
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 owners = new Set();
for (const blk of blocks) {
@@ -35,14 +50,19 @@ function ownersFromCombined(res, wantedVin) {
}
}
return owners;
}
};
function isAlreadyExistsError(e) {
/**
* 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");
}
};
/**
* 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? }
*/
async function ensureRRServiceVehicle(args = {}) {
const ensureRRServiceVehicle = async (args = {}) => {
const {
client: inClient,
routing: inRouting,
@@ -199,7 +219,7 @@ async function ensureRRServiceVehicle(args = {}) {
});
throw e;
}
}
};
module.exports = {
ensureRRServiceVehicle