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

This commit is contained in:
Dave
2025-11-04 11:19:20 -05:00
parent 65e26ed5c9
commit 3d24d44274
23 changed files with 812 additions and 701 deletions

View File

@@ -40,6 +40,7 @@ export function DmsAllocationsSummary({ socket, bodyshop, jobId, title }) {
// ✅ RR takes precedence over Fortellis // ✅ RR takes precedence over Fortellis
if (dms === "rr") { if (dms === "rr") {
wsssocket.emit("rr-calculate-allocations", jobId, (ack) => { wsssocket.emit("rr-calculate-allocations", jobId, (ack) => {
console.dir({ ack });
setAllocationsSummary(ack); setAllocationsSummary(ack);
socket.allocationsSummary = ack; socket.allocationsSummary = ack;
}); });

View File

@@ -83,7 +83,7 @@ export function DmsCustomerSelector({ bodyshop, jobid }) {
const onUseSelected = () => { const onUseSelected = () => {
setOpen(false); setOpen(false);
if (dmsType === "rr") { if (dmsType === "rr") {
wsssocket.emit("rr-selected-customer", { bodyshopId, selectedCustomerId: selectedCustomer, jobid }); wsssocket.emit("rr-selected-customer", { bodyshopId, custNo: selectedCustomer, jobId: jobid });
} else if (Fortellis.treatment === "on") { } else if (Fortellis.treatment === "on") {
wsssocket.emit("fortellis-selected-customer", { selectedCustomerId: selectedCustomer, jobid }); wsssocket.emit("fortellis-selected-customer", { selectedCustomerId: selectedCustomer, jobid });
} else { } else {
@@ -95,9 +95,11 @@ export function DmsCustomerSelector({ bodyshop, jobid }) {
const onUseGeneric = () => { const onUseGeneric = () => {
setOpen(false); setOpen(false);
const generic = bodyshop.cdk_configuration?.generic_customer_number || null; const generic = bodyshop.cdk_configuration?.generic_customer_number || null;
console.dir({ bodyshop, generic });
if (dmsType === "rr") { if (dmsType === "rr") {
wsssocket.emit("rr-selected-customer", { bodyshopId, selectedCustomerId: generic, jobid }); if (generic) {
wsssocket.emit("rr-selected-customer", { bodyshopId, custNo: generic, jobId: jobid });
}
} else if (Fortellis.treatment === "on") { } else if (Fortellis.treatment === "on") {
wsssocket.emit("fortellis-selected-customer", { selectedCustomerId: generic, jobid }); wsssocket.emit("fortellis-selected-customer", { selectedCustomerId: generic, jobid });
} else { } else {
@@ -109,7 +111,8 @@ export function DmsCustomerSelector({ bodyshop, jobid }) {
const onCreateNew = () => { const onCreateNew = () => {
setOpen(false); setOpen(false);
if (dmsType === "rr") { if (dmsType === "rr") {
wsssocket.emit("rr-selected-customer", { bodyshopId, selectedCustomerId: null, jobid }); // RR equivalent: signal create intent explicitly
wsssocket.emit("rr-selected-customer", { bodyshopId, create: true, jobId: jobid });
} else if (Fortellis.treatment === "on") { } else if (Fortellis.treatment === "on") {
wsssocket.emit("fortellis-selected-customer", { selectedCustomerId: null, jobid }); wsssocket.emit("fortellis-selected-customer", { selectedCustomerId: null, jobid });
} else { } else {
@@ -201,36 +204,13 @@ export function DmsCustomerSelector({ bodyshop, jobid }) {
} }
]; ];
// NEW: RR columns (aligned with RR CombinedSearch-style payloads; falls back gracefully)
const rrColumns = [ const rrColumns = [
{ { title: t("jobs.fields.dms.id"), dataIndex: "custNo", key: "custNo" },
title: t("jobs.fields.dms.id"),
dataIndex: "CustomerId",
key: "CustomerId"
},
{ {
title: t("jobs.fields.dms.name1"), title: t("jobs.fields.dms.name1"),
key: "CustomerName", dataIndex: "name",
sorter: (a, b) => key: "name",
alphaSort( sorter: (a, b) => alphaSort(a?.name, b?.name)
(a.CustomerName?.FirstName || "") + " " + (a.CustomerName?.LastName || ""),
(b.CustomerName?.FirstName || "") + " " + (b.CustomerName?.LastName || "")
),
render: (record) => `${record.CustomerName?.FirstName || ""} ${record.CustomerName?.LastName || ""}`.trim()
},
{
title: t("jobs.fields.dms.address"),
key: "Address",
render: (record) => {
const a = record.PostalAddress || record.Address || {};
const l1 = a.AddressLine1 || a.Line1 || "";
const l2 = a.AddressLine2 || a.Line2 || "";
const city = a.City || "";
const st = a.State || a.StateProvince || "";
const pc = a.PostalCode || "";
const ctry = a.Country || "";
return `${l1}${l2 ? `, ${l2}` : ""}, ${city} ${st} ${pc} ${ctry}`.trim();
}
} }
]; ];
@@ -247,7 +227,7 @@ export function DmsCustomerSelector({ bodyshop, jobid }) {
const rowKeyFn = const rowKeyFn =
dmsType === "rr" dmsType === "rr"
? (record) => record.CustomerId || record.customerId ? (record) => record.custNo
: dmsType === "cdk" : dmsType === "cdk"
? (record) => record.id?.value || record.customerId ? (record) => record.id?.value || record.customerId
: (record) => record.ContactId; : (record) => record.ContactId;
@@ -274,7 +254,7 @@ export function DmsCustomerSelector({ bodyshop, jobid }) {
onSelect: (record) => { onSelect: (record) => {
const key = const key =
dmsType === "rr" dmsType === "rr"
? record.CustomerId || record.customerId ? record.custNo
: dmsType === "cdk" : dmsType === "cdk"
? record.id?.value || record.customerId ? record.id?.value || record.customerId
: record.ContactId; : record.ContactId;

View File

@@ -75,7 +75,7 @@ export function DmsPostForm({ bodyshop, socket, job, logsRef }) {
if (dms === "rr") { if (dms === "rr") {
wsssocket.emit("rr-export-job", { wsssocket.emit("rr-export-job", {
bodyshopId: bodyshop?.id || bodyshop?.bodyshopid || bodyshop?.uuid, bodyshopId: bodyshop?.id || bodyshop?.bodyshopid || bodyshop?.uuid,
jobid: job.id, jobId: job.id,
job, job,
txEnvelope: values txEnvelope: values
}); });

View File

@@ -111,10 +111,11 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse
}; };
const handleLogEvent = (payload) => setLogs((prev) => [...prev, payload]); const handleLogEvent = (payload) => setLogs((prev) => [...prev, payload]);
const handleExportSuccess = (payload) => { const handleExportSuccess = (payload) => {
notification.success({ message: t("jobs.successes.exported") }); const jobId = payload?.jobId ?? payload; // RR sends object; legacy sends raw id notification.success({ message: t("jobs.successes.exported") });
insertAuditTrail({ insertAuditTrail({
jobid: payload, jobid: jobId,
operation: AuditTrailMapping.jobexported(), operation: AuditTrailMapping.jobexported(),
type: "jobexported" type: "jobexported"
}); });
@@ -128,6 +129,7 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse
// RR channels (over wss) // RR channels (over wss)
wsssocket.on("rr-log-event", handleLogEvent); wsssocket.on("rr-log-event", handleLogEvent);
wsssocket.on("RR:LOG", handleLogEvent);
wsssocket.on("export-success", handleExportSuccess); wsssocket.on("export-success", handleExportSuccess);
wsssocket.on("rr-export-job:result", handleRrExportResult); wsssocket.on("rr-export-job:result", handleRrExportResult);

View File

@@ -123,7 +123,6 @@ const applyRoutes = ({ app }) => {
app.use("/payroll", require("./server/routes/payrollRoutes")); app.use("/payroll", require("./server/routes/payrollRoutes"));
app.use("/sso", require("./server/routes/ssoRoutes")); app.use("/sso", require("./server/routes/ssoRoutes"));
app.use("/integrations", require("./server/routes/intergrationRoutes")); app.use("/integrations", require("./server/routes/intergrationRoutes"));
app.use("/rr", require("./server/rr"));
// Default route for forbidden access // Default route for forbidden access
app.get("/", (req, res) => { app.get("/", (req, res) => {

View File

@@ -2209,16 +2209,18 @@ exports.UPDATE_OLD_TRANSITION = `mutation UPDATE_OLD_TRANSITION($jobid: uuid!, $
exports.INSERT_NEW_TRANSITION = ( exports.INSERT_NEW_TRANSITION = (
includeOldTransition includeOldTransition
) => `mutation INSERT_NEW_TRANSITION($newTransition: transitions_insert_input!, ${includeOldTransition ? `$oldTransitionId: uuid!, $duration: numeric` : "" ) => `mutation INSERT_NEW_TRANSITION($newTransition: transitions_insert_input!, ${
}) { includeOldTransition ? `$oldTransitionId: uuid!, $duration: numeric` : ""
}) {
insert_transitions_one(object: $newTransition) { insert_transitions_one(object: $newTransition) {
id id
} }
${includeOldTransition ${
? `update_transitions(where: {id: {_eq: $oldTransitionId}}, _set: {duration: $duration}) { includeOldTransition
? `update_transitions(where: {id: {_eq: $oldTransitionId}}, _set: {duration: $duration}) {
affected_rows affected_rows
}` }`
: "" : ""
} }
}`; }`;
@@ -2908,6 +2910,8 @@ exports.GET_BODYSHOP_BY_ID = `
state state
notification_followers notification_followers
timezone timezone
rr_dealerid
rr_configuration
} }
} }
`; `;

View File

@@ -1 +0,0 @@
module.exports = require("./rrRoutes");

View File

@@ -1,32 +0,0 @@
const { getRRConfigForBodyshop } = require("./rr-config");
const { RrApiError } = require("./rr-error");
/**
* Extracts bodyshopId from body, job, or header and loads RR config.
* @returns {Promise<{ bodyshopId: string, config: any }>}
*/
async function resolveRRConfigHttp(req) {
const body = req?.body || {};
const fromBody = body.bodyshopId;
const fromJob = body.job && (body.job.shopid || body.job.bodyshopId);
const fromHeader = typeof req.get === "function" ? req.get("x-bodyshop-id") : undefined;
const bodyshopId = fromBody || fromJob || fromHeader;
if (!bodyshopId) {
throw new RrApiError(
"Missing bodyshopId (expected in body.bodyshopId, body.job.shopid/bodyshopId, or x-bodyshop-id header)",
"BAD_REQUEST"
);
}
const config = await getRRConfigForBodyshop(bodyshopId);
if (!config?.dealerNumber) {
throw new RrApiError(`RR config not found for bodyshopId=${bodyshopId} (missing dealerNumber)`, "NOT_CONFIGURED");
}
return { bodyshopId, config };
}
module.exports = { resolveRRConfigHttp };

View File

@@ -1,25 +0,0 @@
const { RRClient } = require("./lib/index.cjs");
/**
* Build an RR client using env credentials.
* @param {{logger?: {debug?:Function,info?:Function,warn?:Function,error?:Function}}} opts
*/
function makeRRClient({ logger } = {}) {
const baseUrl = process.env.RR_BASE_URL;
const username = process.env.RR_USERNAME;
const password = process.env.RR_PASSWORD;
if (!baseUrl || !username || !password) {
throw new Error("RR creds missing (RR_BASE_URL, RR_USERNAME, RR_PASSWORD).");
}
return new RRClient({
baseUrl,
username,
password,
logger
// retries: { max: 3 }, // optional: override retry policy
});
}
module.exports = { makeRRClient };

View File

@@ -1,63 +1,59 @@
// server/rr/rr-config.js
// Build RR client configuration from bodyshop settings or env
function 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;
}
/** /**
* Loads per-bodyshop RR routing from Hasura. * Extract RR connection + routing from a bodyshop record (preferred)
* Expected table fields (adapt if your schema differs): * Falls back to process.env for any missing bits.
* - bodyshops.id = $bodyshopId
* - bodyshops.rr_dealerid (string)
* - bodyshops.rr_configuration JSON { storeNumber?, branchNumber? }
* *
* Requires env: * Bodyshop fields expected:
* HASURA_GRAPHQL_ENDPOINT, HASURA_ADMIN_SECRET * - 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
*/ */
const { GraphQLClient, gql } = require("graphql-request"); function getRRConfigFromBodyshop(bodyshop) {
const baseUrl = process.env.RR_BASE_URL;
const username = process.env.RR_USERNAME;
const password = process.env.RR_PASSWORD;
const HASURA_URL = process.env.HASURA_GRAPHQL_ENDPOINT || process.env.HASURA_URL; // NOTE: your schema uses rr_dealerid and rr_configuration JSON
const HASURA_SECRET = process.env.HASURA_ADMIN_SECRET || process.env.HASURA_GRAPHQL_ADMIN_SECRET; const dealerNumber = bodyshop?.rr_dealerid ?? process.env.RR_DEALER_NUMBER;
if (!HASURA_URL || !HASURA_SECRET) { const bsCfg = bodyshop?.rr_configuration || {};
// Warn loudly at startup; you can hard fail if you prefer const storeNumber =
console.warn("[RR] HASURA env not set (HASURA_GRAPHQL_ENDPOINT / HASURA_ADMIN_SECRET)."); 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;
return {
baseUrl: requireString(baseUrl, "baseUrl"),
username: requireString(username, "username"),
password: requireString(password, "password"),
routing: {
dealerNumber: requireString(String(dealerNumber), "routing.dealerNumber"),
storeNumber: requireString(String(storeNumber), "routing.storeNumber"),
areaNumber: requireString(String(areaNumber), "routing.areaNumber")
},
// timeouts / retries can be adjusted here
timeoutMs: Number(process.env.RR_TIMEOUT_MS || 30000),
retries: { max: Number(process.env.RR_RETRIES_MAX || 2) }
};
} }
const client = HASURA_URL module.exports = { getRRConfigFromBodyshop };
? new GraphQLClient(HASURA_URL, {
headers: { "x-hasura-admin-secret": HASURA_SECRET }
})
: null;
const Q_BODYSHOP_RR = gql`
query RR_Config($id: uuid!) {
bodyshops_by_pk(id: $id) {
id
rr_dealerid
rr_configuration
}
}
`;
/**
* @param {string} bodyshopId
* @returns {Promise<{dealerNumber:string, storeNumber?:string, areaNumber?:string}>}
*/
async function getRRConfigForBodyshop(bodyshopId) {
if (!client) throw new Error("Hasura client not configured.");
const data = await client.request(Q_BODYSHOP_RR, { id: bodyshopId });
const row = data?.bodyshops_by_pk;
if (!row) throw new Error(`Bodyshop not found: ${bodyshopId}`);
const dealerNumber = row.rr_dealerid;
const cfg = row.rr_configuration || {};
if (!dealerNumber) {
throw new Error(`RR not configured for bodyshop ${bodyshopId} (missing rr_dealerid).`);
}
// The RR client expects "areaNumber" (Rome "branch")
const storeNumber = cfg.storeNumber || cfg.store_no || cfg.store || null;
const areaNumber = cfg.branchNumber || cfg.branch_no || cfg.branch || null;
return { dealerNumber, storeNumber, areaNumber };
}
module.exports = { getRRConfigForBodyshop };

View File

@@ -1,17 +0,0 @@
const { withClient } = require("../rr/withClient");
async function insertCustomer({ bodyshopId, payload }) {
return withClient(bodyshopId, async (client, routing) => {
const res = await client.insertCustomer(payload, { routing });
return res;
});
}
async function updateCustomer({ bodyshopId, payload }) {
return withClient(bodyshopId, async (client, routing) => {
const res = await client.updateCustomer(payload, { routing });
return res;
});
}
module.exports = { insertCustomer, updateCustomer };

112
server/rr/rr-customers.js Normal file
View File

@@ -0,0 +1,112 @@
// server/rr/rr-customers.js
// Minimal RR customer create helper
const { RRClient } = require("./lib/index.cjs");
const { getRRConfigFromBodyshop } = require("./rr-config");
const RRLogger = require("./rr-logger");
// Build client + opts from bodyshop
function buildClientAndOpts(bodyshop) {
const cfg = getRRConfigFromBodyshop(bodyshop);
const client = new RRClient({
baseUrl: cfg.baseUrl,
username: cfg.username,
password: cfg.password,
timeoutMs: cfg.timeoutMs,
retries: cfg.retries
});
const opts = {
routing: cfg.routing,
envelope: {
sender: {
component: "Rome",
task: "CVC",
referenceId: "CreateCustomer",
creator: "RCI",
senderName: "RCI"
}
}
};
return { client, opts };
}
// minimal field extraction
function digitsOnly(s) {
return String(s || "").replace(/[^\d]/g, "");
}
function buildCustomerPayloadFromJob(job, overrides = {}) {
const firstName = overrides.firstName ?? job?.ownr_fn ?? job?.customer?.first_name ?? "";
const lastName = overrides.lastName ?? job?.ownr_ln ?? job?.customer?.last_name ?? "";
const company = overrides.company ?? job?.ownr_co_nm ?? job?.customer?.company_name ?? "";
// Prefer owner phone; fall back to customer phones
const phone =
overrides.phone ??
job?.ownr_ph1 ??
job?.customer?.mobile ??
job?.customer?.home_phone ??
job?.customer?.phone ??
"";
const payload = {
// These keys follow the RR clients customers op conventions (the lib normalizes case)
firstName: firstName || undefined,
lastName: lastName || undefined,
companyName: company || undefined,
phone: digitsOnly(phone) || undefined,
email: overrides.email || job?.ownr_ea || job?.customer?.email || undefined,
address: {
line1: overrides.addressLine1 ?? job?.ownr_addr1 ?? job?.customer?.address_line1 ?? undefined,
line2: overrides.addressLine2 ?? job?.ownr_addr2 ?? job?.customer?.address_line2 ?? undefined,
city: overrides.city ?? job?.ownr_city ?? job?.customer?.city ?? undefined,
state: overrides.state ?? job?.ownr_st ?? job?.customer?.state ?? job?.customer?.province ?? undefined,
postalCode: overrides.postalCode ?? job?.ownr_zip ?? job?.customer?.postal_code ?? undefined,
country: overrides.country ?? job?.ownr_ctry ?? job?.customer?.country ?? "CA"
}
};
return payload;
}
/**
* Create a customer in RR and return { custNo, raw }.
* Tries common op names to stay compatible with the generated client.
*/
async function createRRCustomer({ bodyshop, job, overrides = {}, socket }) {
const log = RRLogger(socket, { ns: "rr" });
const { client, opts } = buildClientAndOpts(bodyshop);
const payload = buildCustomerPayloadFromJob(job, overrides);
let res;
// Try common method names; your lib exposes one of these.
if (typeof client.createCustomer === "function") {
res = await client.createCustomer(payload, opts);
} else if (typeof client.insertCustomer === "function") {
res = await client.insertCustomer(payload, opts);
} else if (client.customers && typeof client.customers.create === "function") {
res = await client.customers.create(payload, opts);
} else {
throw new Error("RR customer create operation not found in client");
}
const data = res?.data ?? res;
const custNo =
data?.custNo ??
data?.CustNo ??
data?.customerNo ??
data?.CustomerNo ??
data?.customer?.custNo ??
data?.Customer?.CustNo;
if (!custNo) {
log("error", "RR create customer returned no custNo", { data });
throw new Error("RR create customer returned no custNo");
}
return { custNo, raw: data };
}
module.exports = {
createRRCustomer
};

View File

@@ -1,11 +0,0 @@
class RrApiError extends Error {
constructor(message, code, meta) {
super(message);
this.name = "RrApiError";
this.code = code || "RR_API_ERROR";
if (meta) this.meta = meta;
Error.captureStackTrace?.(this, RrApiError);
}
}
module.exports = { RrApiError };

View File

@@ -1,67 +1,28 @@
const { withClient } = require("./withClient"); const { buildRRRepairOrderPayload } = require("./rr-job-helpers");
const { buildClientAndOpts } = require("./rr-lookup");
async function exportJobToRR({ bodyshopId, job, logger }) { async function exportJobToRR(args) {
return withClient(bodyshopId, logger, async (client, routing) => { const { bodyshop, job, selectedCustomer, advisorNo, existing } = args;
// 1) Upsert Customer
const custPayload = mapJobToCustomer(job);
const custRes = job.customer?.nameRecId
? await client.updateCustomer(custPayload, { routing })
: await client.insertCustomer(custPayload, { routing });
const customerNo = custRes?.data?.dmsRecKey || job.customer?.customerNo; // Build client + opts (opts carries routing)
if (!customerNo) throw new Error("Failed to resolve customerNo from RR response."); const { client, opts } = buildClientAndOpts(bodyshop);
// 2) Ensure Service Vehicle (optional, if VIN present) const payload = buildRRRepairOrderPayload({ job, selectedCustomer, advisorNo });
if (job?.vehicle?.vin) {
await client.insertServiceVehicle(
{
vin: job.vehicle.vin,
vehicleServInfo: { customerNo }
},
{ routing }
);
}
// 3) Create RO let rrRes;
const roHeader = { if (existing?.dmsRepairOrderId) {
customerNo, rrRes = await client.updateRepairOrder({ ...payload, dmsRepairOrderId: existing.dmsRepairOrderId }, opts);
departmentType: "B", } else {
vin: job?.vehicle?.vin, rrRes = await client.createRepairOrder(payload, opts);
outsdRoNo: job?.roExternal || job?.id, }
advisorNo: job?.advisorNo,
mileageIn: job?.mileageIn
};
const roBody = mapJobToRO(job); // extend if you want lines/tax/etc
const roRes = await client.createRepairOrder({ ...roHeader, ...roBody }, { routing });
return roRes?.data;
});
}
function mapJobToCustomer(job) {
const c = job?.customer || {};
return { return {
nameRecId: c.nameRecId, success: rrRes?.success === true,
firstName: c.firstName || c.given_name, data: rrRes?.data || null,
lastName: c.lastName || c.family_name, roStatus: rrRes?.data?.roStatus || null,
phone: c.phone || c.mobile, statusBlocks: rrRes?.statusBlocks || [],
email: c.email, xml: rrRes?.xml,
address: { parsed: rrRes?.parsed
line1: c.address1,
line2: c.address2,
city: c.city,
state: c.province || c.state,
postalCode: c.postal || c.zip
}
};
}
function mapJobToRO(job) {
return {
// rolabor: [...],
// roparts: [...],
// estimate: {...},
// tax: {...}
}; };
} }

View File

@@ -1,54 +1,76 @@
// server/rr/rr-job-helpers.js
const { GraphQLClient } = require("graphql-request"); const { GraphQLClient } = require("graphql-request");
const queries = require("../graphql-client/queries"); const queries = require("../graphql-client/queries");
const { combinedSearch } = require("./rr-lookup");
/** Namespace the job transaction data for RR */
const getTransactionType = (jobid) => `rr:${jobid}`;
/** /**
* QueryJobData — mirrors your Fortellis/CDK helpers; fetches job with all export details. * Query job + related entities.
* Requires the caller's token (we read from the socket handshake). * Supports { socket } (GraphQL) and/or { redisHelpers } (cache/fetch).
*/ */
async function QueryJobData({ socket, jobid }) { async function QueryJobData(ctx = {}, jobId) {
const endpoint = process.env.GRAPHQL_ENDPOINT || process.env.HASURA_GRAPHQL_ENDPOINT || process.env.HASURA_URL; if (!jobId) throw new Error("jobId required");
if (!endpoint) throw new Error("GRAPHQL endpoint not configured");
const client = new GraphQLClient(endpoint, {}); const { redisHelpers, socket } = ctx;
const token = (socket?.data && socket.data.authToken) || (socket?.handshake?.auth && socket.handshake.auth.token);
if (!token) throw new Error("Missing auth token for QueryJobData");
const res = await client if (redisHelpers) {
.setHeaders({ Authorization: `Bearer ${token}` }) if (typeof redisHelpers.getJobFromCache === "function") {
.request(queries.QUERY_JOBS_FOR_CDK_EXPORT, { id: jobid }); try {
const hit = await redisHelpers.getJobFromCache(jobId);
if (hit) return hit;
} catch {}
}
if (typeof redisHelpers.fetchJobById === "function") {
const full = await redisHelpers.fetchJobById(jobId);
if (full) return full;
}
}
return res?.jobs_by_pk || null; if (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);
if (!token) throw new Error("Missing bearer token on socket for GraphQL fetch");
const client = new GraphQLClient(endpoint, {});
const resp = await client
.setHeaders({ Authorization: `Bearer ${token}` })
.request(queries.QUERY_JOBS_FOR_CDK_EXPORT, { id: jobId });
const job = resp?.jobs_by_pk;
if (job) return job;
throw new Error("QueryJobData: job not found via GraphQL");
}
throw new Error("QueryJobData: no available method to load job (need socket or redisHelpers)");
} }
/** /**
* QueryDMSVehicleById — for RR we don't have a direct "vehicle by id" read, * Build RR create/update RO payload.
* so we approximate with CombinedSearch by VIN and return the first hit. * Prefers selectedVehicle.vin (if present) over job VIN.
*/ */
async function QueryDMSVehicleById({ bodyshopId, vin }) { function buildRRRepairOrderPayload({ job, selectedCustomer, selectedVehicle, advisorNo }) {
if (!vin) return null; const custNo =
const res = await combinedSearch({ bodyshopId, kind: "vin", vin }); selectedCustomer?.custNo ||
// RR lib returns { success, data: CombinedSearchBlock[] } selectedCustomer?.customerNo ||
const blocks = res?.data || res || []; selectedCustomer?.CustNo ||
const first = Array.isArray(blocks) && blocks.length ? blocks[0] : null; selectedCustomer?.CustomerNo;
if (!first) return null;
const vehicle = first?.Vehicle || first?.vehicle || first?.Veh || null; if (!custNo) throw new Error("No RR customer selected (custNo missing)");
const customer = first?.Customer || first?.customer || first?.Cust || null;
const vin = selectedVehicle?.vin || job?.vehicle?.vin || job?.v_vin || job?.vehicle_vin;
if (!vin) throw new Error("No VIN available (select or create a vehicle)");
return { return {
repairOrderNumber: String(job?.job_number || job?.id),
deptType: "B",
vin, vin,
vehicle, custNo,
customer advNo: advisorNo || undefined
}; };
} }
module.exports = { module.exports = {
QueryJobData, QueryJobData,
QueryDMSVehicleById, buildRRRepairOrderPayload
getTransactionType
}; };

View File

@@ -1,14 +1,70 @@
const logger = require("../utils/logger"); // server/rr/rr-logger.js
// Robust socket + server logger for RR flows (no more [object Object] in UI)
function RRLogger(socket) { "use strict";
return function log(level = "info", message = "", ctx = {}) {
// Console const util = require("util");
const fn = logger.logger[level] || logger.log; const appLogger = require("../utils/logger");
fn(`[RR] ${new Date().toISOString()} [${level.toUpperCase()}] ${message}`, ctx);
function RRLogger(socket, baseCtx = {}) {
const levels = new Set(["error", "warn", "info", "http", "verbose", "debug", "silly"]);
const safeString = (v) => {
if (v instanceof Error) return v.message;
if (typeof v === "string") return v;
try { try {
socket?.emit?.("RR:LOG", { level, message, ctx, ts: Date.now() }); return JSON.stringify(v);
} catch { } catch {
/* ignore */ return util.inspect(v, { depth: 2, maxArrayLength: 50 });
}
};
return function log(levelOrMsg, msgOrCtx, ctx) {
let level = "info";
let message = undefined;
let meta = {};
if (typeof levelOrMsg === "string" && levels.has(levelOrMsg)) {
level = levelOrMsg;
message = msgOrCtx;
meta = ctx || {};
} else {
message = levelOrMsg;
meta = msgOrCtx || {};
}
// Prepare console line + metadata
const emitError = message instanceof Error;
if (emitError) {
meta.err = {
name: message.name,
message: message.message,
stack: message.stack
};
message = message.message;
if (level === "info") level = "error";
}
const messageString = safeString(message);
const line = `[RR] ${new Date().toISOString()} [${String(level).toUpperCase()}] ${messageString}`;
const loggerFn = appLogger?.logger?.[level] || appLogger?.logger?.info || ((...args) => console.log(...args));
loggerFn(line, { ...baseCtx, ...meta });
// Always emit a STRING for `message` to sockets to avoid React crashes
// If the original message was an object, include it in `details`
const details = message && typeof message !== "string" && !emitError ? message : undefined;
try {
socket?.emit?.("rr-log-event", {
level,
message: messageString, // <-- normalized string for UI
ctx: { ...baseCtx, ...meta },
...(details ? { details } : {}),
ts: Date.now()
});
} catch {
/* ignore socket emission errors */
} }
}; };
} }

View File

@@ -1,24 +1,145 @@
const { withClient } = require("./withClient"); // server/rr/rr-lookup.js
// Reynolds & Reynolds lookup helpers that adapt our bodyshop record to the RR client
async function getAdvisors({ bodyshopId, ...criteria }) { const { RRClient } = require("./lib/index.cjs");
return withClient(bodyshopId, async (client, routing) => { const { getRRConfigFromBodyshop } = require("./rr-config");
const res = await client.getAdvisors(criteria, { routing });
return res; /**
* Build an RR client + common opts from a bodyshop row
*/
function buildClientAndOpts(bodyshop) {
const cfg = getRRConfigFromBodyshop(bodyshop);
const client = new RRClient({
baseUrl: cfg.baseUrl,
username: cfg.username,
password: cfg.password,
timeoutMs: cfg.timeoutMs,
retries: cfg.retries
// optional debug logger already inside lib; leave defaults
}); });
// Common CallOptions for all ops; routing is CRITICAL for Destination block
const opts = {
routing: cfg.routing,
envelope: {
// You can override these per-call if needed
sender: {
component: "Rome",
task: "CVC",
referenceId: "Query",
creator: "RCI",
senderName: "RCI"
}
// bodId/creationDateTime auto-filled by the client if omitted
}
};
return { client, opts };
} }
async function getParts({ bodyshopId, ...criteria }) { /**
return withClient(bodyshopId, async (client, routing) => { * Normalize the combined-search arguments into the RR shape.
const res = await client.getParts(criteria, { routing }); * We infer `kind` if not provided, based on the first detectable field.
return res; */
}); function toCombinedSearchPayload(args = {}) {
const q = { ...args };
let kind = (q.kind || "").toString().trim().toLowerCase();
if (!kind) {
if (q.phone) kind = "phone";
else if (q.license) kind = "license";
else if (q.vin) kind = "vin";
else if (q.nameRecId || q.custId) kind = "nameRecId";
else if (q.name && (q.name.fname || q.name.lname || q.name.mname || q.name.name)) kind = "name";
else if (q.stkNo || q.stock) kind = "stkNo";
}
// Map loose aliases into the RR builders expected fields
const payload = { maxResults: q.maxResults || q.maxRecs || 50, kind };
switch (kind) {
case "phone":
payload.phone = q.phone;
break;
case "license":
payload.license = q.license;
break;
case "vin":
payload.vin = q.vin;
break;
case "namerecid":
payload.nameRecId = q.nameRecId || q.custId;
break;
case "name":
payload.name = q.name; // { fname, lname, mname } or { name }
break;
case "stkno":
payload.stkNo = q.stkNo || q.stock;
break;
default:
// Let the RR builder throw the canonical “Unsupported CombinedSearch kind”
payload.kind = q.kind; // may be undefined; RR lib will validate
}
// Optional vehicle narrowing; the RR builder defaults to ANY/ANY/ANY if omitted
if (q.make || q.model || q.year) {
payload.make = q.make;
payload.model = q.model;
payload.year = q.year;
}
return payload;
} }
async function combinedSearch({ bodyshopId, ...query }) { /**
return withClient(bodyshopId, async (client, routing) => { * Combined customer/service/vehicle search
const res = await client.combinedSearch(query, { routing }); * @param bodyshop - bodyshop row (must include rr_dealerid & rr_configuration with store/branch)
return res; * @param args - search inputs (phone | license | vin | nameRecId | name | stkNo)
}); */
async function rrCombinedSearch(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, ... }
} }
module.exports = { getAdvisors, getParts, combinedSearch }; /**
* Advisors lookup
* @param bodyshop
* @param args - { department: 'B'|'S'|'P'|string, advisorNumber?: string }
*/
async function rrGetAdvisors(bodyshop, args = {}) {
const { client, opts } = buildClientAndOpts(bodyshop);
// Allow friendly department values
const dep = (args.department || "").toString().toUpperCase();
const department =
dep === "BODY" || dep === "BODYSHOP" ? "B" : dep === "SERVICE" ? "S" : dep === "PARTS" ? "P" : dep || "B";
const payload = {
department,
advisorNumber: args.advisorNumber ? String(args.advisorNumber) : undefined
};
const res = await client.getAdvisors(payload, opts);
return res?.data ?? res;
}
/**
* Parts on an internal RO
* @param bodyshop
* @param args - { roNumber: string } (ERA/DMS internal RO number)
*/
async function rrGetParts(bodyshop, args = {}) {
const { client, opts } = buildClientAndOpts(bodyshop);
const payload = { roNumber: String(args.roNumber || "").trim() };
const res = await client.getParts(payload, opts);
return res?.data ?? res;
}
module.exports = {
rrCombinedSearch,
rrGetAdvisors,
rrGetParts,
buildClientAndOpts
};

View File

@@ -1,17 +0,0 @@
const { withClient } = require("./withClient");
async function createRepairOrder({ bodyshopId, payload }) {
return withClient(bodyshopId, async (client, routing) => {
const res = await client.createRepairOrder(payload, { routing });
return res;
});
}
async function updateRepairOrder({ bodyshopId, payload }) {
return withClient(bodyshopId, async (client, routing) => {
const res = await client.updateRepairOrder(payload, { routing });
return res;
});
}
module.exports = { createRepairOrder, updateRepairOrder };

View File

@@ -1,100 +0,0 @@
// server/rr/rr-selected-customer.js
const RRLogger = require("./rr-logger");
const { insertCustomer, updateCustomer } = require("./rr-customer");
const { withClient } = require("./withClient");
const { QueryJobData, getTransactionType } = require("./rr-job-helpers");
/**
* Selects/creates/updates the RR customer for a job and persists the choice in the session tx.
* - If selectedCustomerId is given: cache and return.
* - Else: upsert from the job's current customer data, cache resulting dmsRecKey.
*/
async function SelectedCustomer({ socket, jobid, bodyshopId, selectedCustomerId, redisHelpers }) {
const log = RRLogger(socket);
// 1) Load JobData (we'll also use it for bodyshopId if missing)
const JobData = await QueryJobData({ socket, jobid });
const resolvedBodyshopId = bodyshopId || JobData?.bodyshop?.id;
if (!resolvedBodyshopId) throw new Error("Unable to resolve bodyshopId for RR SelectedCustomer");
const txKey = getTransactionType(jobid);
const { setSessionTransactionData, getSessionTransactionData } = redisHelpers || {};
const current = (await getSessionTransactionData?.(txKey)) || {};
// 2) If the UI already chose a DMS customer, just persist it
if (selectedCustomerId) {
await setSessionTransactionData?.(txKey, {
...current,
selectedCustomerId
});
log("info", "RR SelectedCustomer: using provided selectedCustomerId", { selectedCustomerId, jobid });
return { selectedCustomerId };
}
// 3) Otherwise, upsert based on job's customer info (fallback), and cache the new id.
const j = JobData || {};
const c = j.customer || {};
if (!c && !j?.vehicle?.vin) {
log("warn", "RR SelectedCustomer: no customer on job and no VIN; nothing to do", { jobid });
return { selectedCustomerId: null };
}
// Upsert customer: prefer update if we have a NameRecId; else insert.
const customerPayload = mapJobCustomerToRR(c);
const upsert = c?.nameRecId
? await updateCustomer({ bodyshopId: resolvedBodyshopId, payload: customerPayload })
: await insertCustomer({ bodyshopId: resolvedBodyshopId, payload: customerPayload });
const dmsRecKey =
upsert?.data?.dmsRecKey ||
upsert?.data?.DMSRecKey ||
upsert?.data?.dmsRecKeyId ||
c?.customerNo ||
c?.nameRecId ||
null;
// Optionally ensure a ServiceVehicle record exists when VIN present (best effort).
if (j?.vehicle?.vin) {
try {
await withClient(resolvedBodyshopId, async (client, routing) => {
await client.insertServiceVehicle(
{ vin: j.vehicle.vin, vehicleServInfo: { customerNo: dmsRecKey } },
{ routing }
);
});
log("info", "RR SelectedCustomer: ensured ServiceVehicle for VIN", { vin: j.vehicle.vin, dmsRecKey });
} catch (e) {
log("warn", `RR SelectedCustomer: insertServiceVehicle skipped (${e.message})`, { vin: j?.vehicle?.vin });
}
}
// Save in session tx
await setSessionTransactionData?.(txKey, {
...current,
selectedCustomerId: dmsRecKey
});
log("info", "RR SelectedCustomer: upsert complete", { dmsRecKey, jobid });
return { selectedCustomerId: dmsRecKey };
}
function mapJobCustomerToRR(c = {}) {
// Mirrors the mapping used by rr-job-export (kept local to avoid cross-module exports)
return {
nameRecId: c.nameRecId, // for update path
firstName: c.firstName || c.given_name,
lastName: c.lastName || c.family_name || c.last_name,
phone: c.phone || c.mobile,
email: c.email,
address: {
line1: c.address1 || c.address || c.street,
line2: c.address2 || "",
city: c.city,
state: c.province || c.state,
postalCode: c.postal || c.zip
}
};
}
module.exports = { SelectedCustomer };

View File

@@ -1,173 +0,0 @@
// server/rr/rrRoutes.js
const express = require("express");
const router = express.Router();
const RRLogger = require("./rr-logger");
const { RrApiError } = require("./rr-error");
const { getRRConfigForBodyshop } = require("./rr-config");
const lookupApi = require("./rr-lookup");
const { insertCustomer, updateCustomer } = require("./rr-customer");
const { exportJobToRR } = require("./rr-job-export");
const { SelectedCustomer } = require("./rr-selected-customer");
const { QueryJobData } = require("./rr-job-helpers");
// --- helpers & middleware (kept local for this router) ---
function socketOf(req) {
// attach a minimal "socket-like" emitter for logger compatibility
return {
emit: () => {
//
},
handshake: { auth: { token: req?.headers?.authorization?.replace(/^Bearer\s+/i, "") } },
user: req?.user
};
}
function ok(res, payload) {
return res.status(200).json(payload || { ok: true });
}
function fail(res, e) {
const code = e?.code === "BAD_REQUEST" ? 400 : e?.code === "NOT_CONFIGURED" ? 412 : 500;
return res.status(code).json({ error: e?.message || String(e), code: e?.code || "RR_API_ERROR" });
}
function requireBodyshopId(req) {
const body = req?.body || {};
const fromBody = body.bodyshopId;
const fromJob = body.job && (body.job.shopid || body.job.bodyshopId);
const fromHeader = typeof req.get === "function" ? req.get("x-bodyshop-id") : undefined;
const bodyshopId = fromBody || fromJob || fromHeader;
if (!bodyshopId) {
throw new RrApiError(
"Missing bodyshopId (expected in body.bodyshopId, body.job.shopid/bodyshopId, or x-bodyshop-id header)",
"BAD_REQUEST"
);
}
return bodyshopId;
}
// --- sanity/config checks ---
router.get("/rr/config", async (req, res) => {
try {
const bodyshopId = requireBodyshopId(req);
const cfg = await getRRConfigForBodyshop(bodyshopId);
return ok(res, { data: cfg });
} catch (e) {
return fail(res, e);
}
});
// --- lookups ---
router.post("/rr/lookup/advisors", async (req, res) => {
try {
const bodyshopId = requireBodyshopId(req);
const data = await lookupApi.getAdvisors({ bodyshopId, ...(req.body || {}) });
return ok(res, { data });
} catch (e) {
return fail(res, e);
}
});
router.post("/rr/lookup/parts", async (req, res) => {
try {
const bodyshopId = requireBodyshopId(req);
const data = await lookupApi.getParts({ bodyshopId, ...(req.body || {}) });
return ok(res, { data });
} catch (e) {
return fail(res, e);
}
});
router.post("/rr/combined-search", async (req, res) => {
try {
const bodyshopId = requireBodyshopId(req);
const data = await lookupApi.combinedSearch({ bodyshopId, ...(req.body || {}) });
return ok(res, { data });
} catch (e) {
return fail(res, e);
}
});
// --- customers (basic insert/update) ---
router.post("/rr/customer/insert", async (req, res) => {
try {
const bodyshopId = requireBodyshopId(req);
const data = await insertCustomer({ bodyshopId, payload: req.body });
return ok(res, { data });
} catch (e) {
return fail(res, e);
}
});
router.post("/rr/customer/update", async (req, res) => {
try {
const bodyshopId = requireBodyshopId(req);
const data = await updateCustomer({ bodyshopId, payload: req.body });
return ok(res, { data });
} catch (e) {
return fail(res, e);
}
});
/**
* NEW: set or create the selected RR customer for a given job
* body: { jobid: uuid, selectedCustomerId?: string, bodyshopId?: uuid }
*/
router.post("/rr/customer/selected", async (req, res) => {
const socket = socketOf(req);
const logger = (level, message, ctx) => RRLogger(socket)(level, message, ctx);
try {
const { jobid, selectedCustomerId } = req.body || {};
if (!jobid) throw new RrApiError("Missing 'jobid' in body", "BAD_REQUEST");
// We allow bodyshopId in the body, but will resolve from JobData if not present.
const bodyshopId = req.body?.bodyshopId || null;
const result = await SelectedCustomer({
socket,
jobid,
bodyshopId,
selectedCustomerId,
redisHelpers: req.redisHelpers
});
logger("info", "RR /rr/customer/selected success", { jobid, selectedCustomerId: result.selectedCustomerId });
return ok(res, { data: result });
} catch (e) {
RRLogger(socket)("error", "RR /rr/customer/selected failed", { error: e.message });
return fail(res, e);
}
});
/**
* NEW: fetch canonical JobData used for DMS exports (mirrors Fortellis/CDK QueryJobData)
* body: { jobid: uuid }
*/
router.post("/rr/job/query", async (req, res) => {
try {
const { jobid } = req.body || {};
if (!jobid) throw new RrApiError("Missing 'jobid' in body", "BAD_REQUEST");
const data = await QueryJobData({ socket: socketOf(req), jobid });
return ok(res, { data });
} catch (e) {
return fail(res, e);
}
});
// --- export orchestrator ---
router.post("/rr/export/job", async (req, res) => {
const socket = socketOf(req);
const logger = (level, message, ctx) => RRLogger(socket)(level, message, ctx);
try {
const bodyshopId = requireBodyshopId(req);
const { job, options = {} } = req.body || {};
if (!job) throw new RrApiError("Missing 'job' in request body", "BAD_REQUEST");
const data = await exportJobToRR({ bodyshopId, job, logger, ...options });
return ok(res, { data });
} catch (e) {
RRLogger(socket)("error", "RR /rr/export/job failed", { error: e.message });
return fail(res, e);
}
});
module.exports = router;

View File

@@ -1,11 +0,0 @@
const { getRRConfigForBodyshop } = require("./rr-config");
const { makeRRClient } = require("./rr-client");
const logger = require("../utils/logger");
async function withClient(bodyshopId, fn) {
const routing = await getRRConfigForBodyshop(bodyshopId);
const client = makeRRClient({ logger });
return fn(client, routing);
}
module.exports = { withClient };

View File

@@ -1,16 +1,12 @@
const { admin } = require("../firebase/firebase-handler"); const { admin } = require("../firebase/firebase-handler");
const FortellisLogger = require("../fortellis/fortellis-logger"); const FortellisLogger = require("../fortellis/fortellis-logger");
const RRLogger = require("../rr/rr-logger");
const { FortellisJobExport, FortellisSelectedCustomer } = require("../fortellis/fortellis"); const { FortellisJobExport, FortellisSelectedCustomer } = require("../fortellis/fortellis");
const CdkCalculateAllocations = require("../cdk/cdk-calculate-allocations").default; const CdkCalculateAllocations = require("../cdk/cdk-calculate-allocations").default;
const registerRREvents = require("./rr-register-socket-events");
const lookupApi = require("../rr/rr-lookup"); const redisSocketEvents = ({ io, redisHelpers, ioHelpers, logger }) => {
const { SelectedCustomer } = require("../rr/rr-selected-customer"); // Destructure helpers locally, but keep full objects available for downstream modules
const { QueryJobData } = require("../rr/rr-job-helpers"); const {
const redisSocketEvents = ({
io,
redisHelpers: {
setSessionData, setSessionData,
getSessionData, getSessionData,
addUserSocketMapping, addUserSocketMapping,
@@ -20,10 +16,10 @@ const redisSocketEvents = ({
setSessionTransactionData, setSessionTransactionData,
getSessionTransactionData, getSessionTransactionData,
clearSessionTransactionData clearSessionTransactionData
}, } = redisHelpers;
ioHelpers: { getBodyshopRoom, getBodyshopConversationRoom },
logger const { getBodyshopRoom, getBodyshopConversationRoom } = ioHelpers;
}) => {
// Logging helper functions // Logging helper functions
const createLogEvent = (socket, level, message) => { const createLogEvent = (socket, level, message) => {
logger.log("ioredis-log-event", level, socket?.user?.email, null, { wsmessage: message }); logger.log("ioredis-log-event", level, socket?.user?.email, null, { wsmessage: message });
@@ -53,6 +49,15 @@ const redisSocketEvents = ({
socket.handshake.auth.token = token; socket.handshake.auth.token = token;
socket.handshake.auth.bodyshopId = bodyshopId; socket.handshake.auth.bodyshopId = bodyshopId;
} }
// NEW: seed a base session for this socket so downstream handlers can read it
await setSessionData(socket.id, {
bodyshopId,
email: user.email,
uid: user.user_id || user.uid,
seededAt: Date.now()
});
await addUserSocketMapping(user.email, socket.id, bodyshopId); await addUserSocketMapping(user.email, socket.id, bodyshopId);
next(); next();
} catch (error) { } catch (error) {
@@ -91,6 +96,15 @@ const redisSocketEvents = ({
socket.handshake.auth.token = token; socket.handshake.auth.token = token;
socket.handshake.auth.bodyshopId = bodyshopId; socket.handshake.auth.bodyshopId = bodyshopId;
} }
// NEW: refresh (or create) the base session with the latest info
await setSessionData(socket.id, {
bodyshopId,
email: user.email,
uid: user.user_id || user.uid,
refreshedAt: Date.now()
});
await refreshUserSocketTTL(user.email, bodyshopId); await refreshUserSocketTTL(user.email, bodyshopId);
socket.emit("token-updated", { success: true }); socket.emit("token-updated", { success: true });
} catch (error) { } catch (error) {
@@ -147,6 +161,10 @@ const redisSocketEvents = ({
if (socket.user?.email) { if (socket.user?.email) {
await removeUserSocketMapping(socket.user.email, socket.id); await removeUserSocketMapping(socket.user.email, socket.id);
} }
// Optional: clear transactional session
try {
await clearSessionTransactionData(socket.id);
} catch {}
// Leave all rooms except the default room (socket.id) // Leave all rooms except the default room (socket.id)
const rooms = Array.from(socket.rooms).filter((room) => room !== socket.id); const rooms = Array.from(socket.rooms).filter((room) => room !== socket.id);
for (const room of rooms) { for (const room of rooms) {
@@ -251,7 +269,7 @@ const redisSocketEvents = ({
}); });
}; };
//Fortellis/CDK Handlers // Fortellis/CDK Handlers
const registerFortellisEvents = (socket) => { const registerFortellisEvents = (socket) => {
socket.on("fortellis-export-job", async ({ jobid, txEnvelope }) => { socket.on("fortellis-export-job", async ({ jobid, txEnvelope }) => {
try { try {
@@ -280,6 +298,7 @@ const redisSocketEvents = ({
}); });
} }
}); });
socket.on("fortellis-selected-customer", async ({ jobid, selectedCustomerId }) => { socket.on("fortellis-selected-customer", async ({ jobid, selectedCustomerId }) => {
try { try {
await FortellisSelectedCustomer({ await FortellisSelectedCustomer({
@@ -307,6 +326,7 @@ const redisSocketEvents = ({
}); });
} }
}); });
socket.on("fortellis-calculate-allocations", async (jobid, callback) => { socket.on("fortellis-calculate-allocations", async (jobid, callback) => {
try { try {
const allocations = await CdkCalculateAllocations(socket, jobid); const allocations = await CdkCalculateAllocations(socket, jobid);
@@ -342,94 +362,6 @@ const redisSocketEvents = ({
}); });
}; };
// Reynolds & Reynolds socket events (uses new client-backed ops)
function registerRREvents(socket) {
const logger = require("../utils/logger");
const log = RRLogger(socket);
const {
redisHelpers // { setSessionData, getSessionData, ... setSessionTransactionData, getSessionTransactionData }
} = require("../utils/ioHelpers").getHelpers?.() || { redisHelpers: {} };
const resolveJobId = (maybeId, packet, fallback) => maybeId || packet?.jobid || fallback;
// Lookups
socket.on("rr-get-advisors", async (params = {}, cb) => {
try {
const bodyshopId = params.bodyshopId || socket?.user?.bodyshopid;
const res = await lookupApi.getAdvisors({ bodyshopId, ...(params || {}) });
cb?.({ data: res?.data ?? res });
} catch (e) {
log("error", `RR get advisors error: ${e.message}`);
cb?.({ error: e.message });
}
});
socket.on("rr-get-parts", async (params = {}, cb) => {
try {
const bodyshopId = params.bodyshopId || socket?.user?.bodyshopid;
const res = await lookupApi.getParts({ bodyshopId, ...(params || {}) });
cb?.({ data: res?.data ?? res });
} catch (e) {
log("error", `RR get parts error: ${e.message}`);
cb?.({ error: e.message });
}
});
/**
* NEW: QueryJobData — return the canonical job payload used for DMS exports
* payload: { jobid }
*/
socket.on("rr-query-job-data", async ({ jobid } = {}, cb) => {
try {
const resolvedJobId = resolveJobId(jobid, { jobid }, null);
const job = await QueryJobData({ socket, jobid: resolvedJobId });
cb?.({ jobid: resolvedJobId, job });
} catch (e) {
log("error", `RR query job data error: ${e.message}`, { jobid });
cb?.({ jobid, error: e.message });
}
});
/**
* NEW: Selected Customer — cache the chosen DMS customer (or upsert from job if not provided)
* payload: { jobid, selectedCustomerId?, bodyshopId? }
*/
socket.on("rr-selected-customer", async ({ jobid, selectedCustomerId, bodyshopId } = {}, cb) => {
try {
const resolvedJobId = resolveJobId(jobid, { jobid }, null);
const result = await SelectedCustomer({
socket,
jobid: resolvedJobId,
bodyshopId,
selectedCustomerId,
redisHelpers
});
cb?.({ jobid: resolvedJobId, selectedCustomerId: result.selectedCustomerId });
} catch (e) {
log("error", `RR selected customer error: ${e.message}`, { jobid });
cb?.({ jobid, error: e.message });
}
});
// Calculate allocations (unchanged)
socket.on("rr-calculate-allocations", async (jobid, callback) => {
try {
const resolvedJobId = resolveJobId(jobid, { jobid }, null);
const allocations = await CdkCalculateAllocations(socket, resolvedJobId);
callback?.({ jobid: resolvedJobId, allocations });
} catch (error) {
log("error", `Error during RR calculate allocations: ${error.message}`, { jobid, stack: error.stack });
logger.log("rr-calc-allocations-error", "error", null, null, {
jobid,
message: error.message,
stack: error.stack
});
callback?.({ jobid, error: error.message });
}
});
}
module.exports = { registerRREvents };
// Call Handlers // Call Handlers
registerRoomAndBroadcastEvents(socket); registerRoomAndBroadcastEvents(socket);
registerUpdateEvents(socket); registerUpdateEvents(socket);
@@ -438,7 +370,14 @@ const redisSocketEvents = ({
registerSyncEvents(socket); registerSyncEvents(socket);
registerTaskEvents(socket); registerTaskEvents(socket);
registerFortellisEvents(socket); registerFortellisEvents(socket);
registerRREvents(socket);
// Reynolds & Reynolds socket handlers
registerRREvents({
socket,
redisHelpers,
ioHelpers,
logger
});
}; };
// Associate Middleware and Handlers // Associate Middleware and Handlers

View File

@@ -0,0 +1,305 @@
// server/rr/rr-register-socket-events.js
const RRLogger = require("../rr/rr-logger");
const { rrCombinedSearch, rrGetAdvisors, rrGetParts } = require("../rr/rr-lookup");
const { QueryJobData } = require("../rr/rr-job-helpers");
const { exportJobToRR } = require("../rr/rr-job-export");
const CdkCalculateAllocations = require("../cdk/cdk-calculate-allocations").default;
const { createRRCustomer } = require("../rr/rr-customers");
const { GraphQLClient } = require("graphql-request");
const queries = require("../graphql-client/queries");
// ---------------- utils ----------------
function resolveJobId(explicit, payload, job) {
return explicit || payload?.jobId || payload?.jobid || job?.id || job?.jobId || job?.jobid || null;
}
async function getSessionOrSocket(redisHelpers, socket) {
let sess = null;
try {
sess = await redisHelpers.getSessionData(socket.id);
} catch {
/* ignore */
}
const bodyshopId = sess?.bodyshopId ?? socket.bodyshopId;
const email = sess?.email ?? socket.user?.email;
if (!bodyshopId) throw new Error("No bodyshopId (session/socket)");
return { bodyshopId, email, sess };
}
async function getBodyshopForSocket({ 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);
const client = new GraphQLClient(endpoint, {});
const res = await client
.setHeaders({ Authorization: `Bearer ${token}` })
.request(queries.GET_BODYSHOP_BY_ID, { id: bodyshopId });
const bodyshop = res?.bodyshops_by_pk;
if (!bodyshop) throw new Error(`Bodyshop not found: ${bodyshopId}`);
return bodyshop;
}
// ---------------- register handlers ----------------
function registerRREvents({ socket, redisHelpers }) {
// RRLogger returns a log(level, message, ctx) function
const log = RRLogger(socket);
// Lookups (mirrors Fortellis shape/flow)
socket.on("rr-lookup-combined", async ({ jobid, params } = {}, cb) => {
try {
const { bodyshopId } = await getSessionOrSocket(redisHelpers, socket);
const bodyshop = await getBodyshopForSocket({ bodyshopId, socket });
const res = await rrCombinedSearch(bodyshop, params || {});
const data = res?.data ?? res;
cb?.({ jobid: resolveJobId(jobid, { jobid }, null), data });
// Push to FE to open the table; keep payload as the raw array (FE maps columns itself)
socket.emit("rr-select-customer", Array.isArray(data) ? data : data?.customers || []);
} catch (e) {
log("error", `RR combined lookup error: ${e.message}`, { jobid });
cb?.({ jobid, error: e.message });
}
});
// Advisors
socket.on("rr-get-advisors", async (args = {}, ack) => {
try {
const { bodyshopId } = await getSessionOrSocket(redisHelpers, socket);
const bodyshop = await getBodyshopForSocket({ bodyshopId, socket });
const res = await rrGetAdvisors(bodyshop, args);
ack?.({ ok: true, result: res });
socket.emit("rr-get-advisors:result", res);
} catch (err) {
log("error", err?.message || "get advisors failed", { err });
ack?.({ ok: false, error: err?.message || "get advisors failed" });
}
});
// Parts
socket.on("rr-get-parts", async (args = {}, ack) => {
try {
const { bodyshopId } = await getSessionOrSocket(redisHelpers, socket);
const bodyshop = await getBodyshopForSocket({ bodyshopId, socket });
const res = await rrGetParts(bodyshop, args);
ack?.({ ok: true, result: res });
socket.emit("rr-get-parts:result", res);
} catch (err) {
log("error", err?.message || "get parts failed", { err });
ack?.({ ok: false, error: err?.message || "get parts failed" });
}
});
// Persist customer selection (or flag create-new)
socket.on("rr-selected-customer", async (selected, ack) => {
try {
await getSessionOrSocket(redisHelpers, socket);
const tx = (await redisHelpers.getSessionTransactionData(socket.id)) || {};
// Signal create-new intent
if (!selected || selected?.create === true || selected?.__new === true) {
await redisHelpers.setSessionTransactionData(socket.id, { ...tx, rrCreateCustomer: true });
log("info", "rr-selected-customer:new-customer-intent");
socket.emit("rr-customer-create-required");
return ack?.({ ok: true, action: "create" });
}
await redisHelpers.setSessionTransactionData(socket.id, { ...tx, rrSelectedCustomer: selected });
log("info", "rr-selected-customer", { selected });
ack?.({ ok: true });
} catch (err) {
log("error", err?.message || "select customer failed", { err });
ack?.({ ok: false, error: err?.message || "select customer failed" });
}
});
// Optional explicit create-customer from UI form
socket.on("rr-create-customer", async ({ jobId, fields } = {}, ack) => {
try {
const { bodyshopId } = await getSessionOrSocket(redisHelpers, socket);
const bodyshop = await getBodyshopForSocket({ bodyshopId, socket });
if (!jobId) throw new Error("jobId required");
const job = await QueryJobData({ redisHelpers }, jobId);
const { custNo } = await createRRCustomer({ bodyshop, job, overrides: fields || {}, socket });
const tx = (await redisHelpers.getSessionTransactionData(socket.id)) || {};
await redisHelpers.setSessionTransactionData(socket.id, {
...tx,
rrSelectedCustomer: { custNo },
rrCreateCustomer: false
});
log("info", "rr-create-customer:success", { custNo });
socket.emit("rr-customer-created", { custNo });
ack?.({ ok: true, custNo });
} catch (err) {
log("error", err?.message || "create customer failed", { err });
ack?.({ ok: false, error: err?.message || "create customer failed" });
}
});
// Vehicle selection helpers
socket.on("rr-selected-vehicle", async (selected, ack) => {
try {
await getSessionOrSocket(redisHelpers, socket);
if (!selected?.vin) throw new Error("selected vehicle must include vin");
const tx = (await redisHelpers.getSessionTransactionData(socket.id)) || {};
await redisHelpers.setSessionTransactionData(socket.id, { ...tx, rrSelectedVehicle: selected });
log("info", "rr-selected-vehicle", { vin: selected.vin });
ack?.({ ok: true });
} catch (err) {
log("error", err?.message || "select vehicle failed", { err });
ack?.({ ok: false, error: err?.message || "select vehicle failed" });
}
});
socket.on("rr-create-vehicle", async (vehicle, ack) => {
try {
await getSessionOrSocket(redisHelpers, socket);
if (!vehicle?.vin) throw new Error("vehicle.vin required");
const tx = (await redisHelpers.getSessionTransactionData(socket.id)) || {};
await redisHelpers.setSessionTransactionData(socket.id, { ...tx, rrSelectedVehicle: vehicle });
log("info", "rr-create-vehicle", { vin: vehicle.vin });
ack?.({ ok: true });
} catch (err) {
log("error", err?.message || "create vehicle failed", { err });
ack?.({ ok: false, error: err?.message || "create vehicle failed" });
}
});
// Export flow
// Export flow
socket.on("rr-export-job", async (payload = {}) => {
try {
// Extract job / ids
let job = payload.job || payload.txEnvelope?.job;
const jobId = payload.jobId || payload.jobid || payload.txEnvelope?.jobId || job?.id;
if (!job) {
if (!jobId) throw new Error("RR export: job or jobId required");
// Fetch full job when only jobId is provided
job = await QueryJobData({ redisHelpers }, jobId);
}
// Resolve bodyshop id
let bodyshopId = payload.bodyshopId || payload.bodyshopid || payload.bodyshopUUID || job?.bodyshop?.id;
if (!bodyshopId) {
const { bodyshopId: sid } = await getSessionOrSocket(redisHelpers, socket);
bodyshopId = sid;
}
if (!bodyshopId) throw new Error("RR export: bodyshopId required");
// Load authoritative bodyshop row (so rr-config can read routing)
let bodyshop = job?.bodyshop || (await getBodyshopForSocket({ bodyshopId, socket })) || { id: bodyshopId };
// Optional FE routing override (safe: routing only)
const feRouting = payload.rrRouting;
if (feRouting) {
const cfg = bodyshop.rr_configuration || {};
bodyshop = {
...bodyshop,
rr_dealerid: feRouting.dealerNumber ?? bodyshop.rr_dealerid,
rr_configuration: {
...cfg,
storeNumber: feRouting.storeNumber ?? cfg.storeNumber,
branchNumber: feRouting.areaNumber ?? cfg.branchNumber,
areaNumber: feRouting.areaNumber ?? cfg.areaNumber
}
};
}
// Selected customer resolution (tx → payload → create)
const tx = (await redisHelpers.getSessionTransactionData(socket.id)) || {};
let selectedCustomer = null;
// from payload
if (payload.selectedCustomer) {
if (typeof payload.selectedCustomer === "object" && payload.selectedCustomer.custNo) {
selectedCustomer = { custNo: payload.selectedCustomer.custNo };
} else if (typeof payload.selectedCustomer === "string") {
selectedCustomer = { custNo: payload.selectedCustomer };
}
}
// from tx if still not set
if (!selectedCustomer && tx.rrSelectedCustomer) {
if (typeof tx.rrSelectedCustomer === "object" && tx.rrSelectedCustomer.custNo) {
selectedCustomer = { custNo: tx.rrSelectedCustomer.custNo };
} else {
selectedCustomer = { custNo: tx.rrSelectedCustomer };
}
}
// create on demand (flagged or missing)
if (!selectedCustomer || tx.rrCreateCustomer === true) {
const created = await createRRCustomer({ bodyshop, job, socket });
selectedCustomer = { custNo: created.custNo };
await redisHelpers.setSessionTransactionData(socket.id, {
...tx,
rrSelectedCustomer: created.custNo,
rrCreateCustomer: false
});
log("info", "rr-export-job:customer-created", { jobId, custNo: created.custNo });
}
const advisorNo = payload.advisorNo || payload.advNo || tx.rrAdvisorNo;
const options = payload.options || payload.txEnvelope?.options || {};
const result = await exportJobToRR({
bodyshop,
job,
selectedCustomer,
advisorNo,
existing: payload.existing,
logger: log,
...options
});
if (result?.success) {
socket.emit("export-success", { vendor: "rr", jobId, roStatus: result.roStatus });
} else {
socket.emit("export-failed", {
vendor: "rr",
jobId,
roStatus: result?.roStatus,
error: result?.error || "RR export failed"
});
}
socket.emit("rr-export-job:result", { jobId, bodyshopId, result });
} catch (error) {
const jobId = payload.jobId || payload.jobid || payload.txEnvelope?.jobId || payload?.job?.id;
log("error", `Error during RR export: ${error.message}`, { jobId, stack: error.stack });
try {
socket.emit("export-failed", { vendor: "rr", jobId, error: error.message });
} catch {
// ignore
}
}
});
// Allocations (RR reuses CDK calculator)
socket.on("rr-calculate-allocations", async (jobid, cb) => {
try {
const allocations = await CdkCalculateAllocations(socket, jobid);
cb?.(allocations);
socket.emit("rr-calculate-allocations:result", allocations);
} catch (e) {
log("error", `RR allocations error: ${e.message}`, { jobid });
cb?.({ ok: false, error: e.message });
}
});
}
module.exports = registerRREvents;