feature/IO-3357-Reynolds-and-Reynolds-DMS-API-Integration - Checkpoint
This commit is contained in:
@@ -40,6 +40,7 @@ export function DmsAllocationsSummary({ socket, bodyshop, jobId, title }) {
|
||||
// ✅ RR takes precedence over Fortellis
|
||||
if (dms === "rr") {
|
||||
wsssocket.emit("rr-calculate-allocations", jobId, (ack) => {
|
||||
console.dir({ ack });
|
||||
setAllocationsSummary(ack);
|
||||
socket.allocationsSummary = ack;
|
||||
});
|
||||
|
||||
@@ -83,7 +83,7 @@ export function DmsCustomerSelector({ bodyshop, jobid }) {
|
||||
const onUseSelected = () => {
|
||||
setOpen(false);
|
||||
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") {
|
||||
wsssocket.emit("fortellis-selected-customer", { selectedCustomerId: selectedCustomer, jobid });
|
||||
} else {
|
||||
@@ -95,9 +95,11 @@ export function DmsCustomerSelector({ bodyshop, jobid }) {
|
||||
const onUseGeneric = () => {
|
||||
setOpen(false);
|
||||
const generic = bodyshop.cdk_configuration?.generic_customer_number || null;
|
||||
|
||||
console.dir({ bodyshop, generic });
|
||||
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") {
|
||||
wsssocket.emit("fortellis-selected-customer", { selectedCustomerId: generic, jobid });
|
||||
} else {
|
||||
@@ -109,7 +111,8 @@ export function DmsCustomerSelector({ bodyshop, jobid }) {
|
||||
const onCreateNew = () => {
|
||||
setOpen(false);
|
||||
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") {
|
||||
wsssocket.emit("fortellis-selected-customer", { selectedCustomerId: null, jobid });
|
||||
} else {
|
||||
@@ -201,36 +204,13 @@ export function DmsCustomerSelector({ bodyshop, jobid }) {
|
||||
}
|
||||
];
|
||||
|
||||
// NEW: RR columns (aligned with RR CombinedSearch-style payloads; falls back gracefully)
|
||||
const rrColumns = [
|
||||
{
|
||||
title: t("jobs.fields.dms.id"),
|
||||
dataIndex: "CustomerId",
|
||||
key: "CustomerId"
|
||||
},
|
||||
{ title: t("jobs.fields.dms.id"), dataIndex: "custNo", key: "custNo" },
|
||||
{
|
||||
title: t("jobs.fields.dms.name1"),
|
||||
key: "CustomerName",
|
||||
sorter: (a, b) =>
|
||||
alphaSort(
|
||||
(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();
|
||||
}
|
||||
dataIndex: "name",
|
||||
key: "name",
|
||||
sorter: (a, b) => alphaSort(a?.name, b?.name)
|
||||
}
|
||||
];
|
||||
|
||||
@@ -247,7 +227,7 @@ export function DmsCustomerSelector({ bodyshop, jobid }) {
|
||||
|
||||
const rowKeyFn =
|
||||
dmsType === "rr"
|
||||
? (record) => record.CustomerId || record.customerId
|
||||
? (record) => record.custNo
|
||||
: dmsType === "cdk"
|
||||
? (record) => record.id?.value || record.customerId
|
||||
: (record) => record.ContactId;
|
||||
@@ -274,7 +254,7 @@ export function DmsCustomerSelector({ bodyshop, jobid }) {
|
||||
onSelect: (record) => {
|
||||
const key =
|
||||
dmsType === "rr"
|
||||
? record.CustomerId || record.customerId
|
||||
? record.custNo
|
||||
: dmsType === "cdk"
|
||||
? record.id?.value || record.customerId
|
||||
: record.ContactId;
|
||||
|
||||
@@ -75,7 +75,7 @@ export function DmsPostForm({ bodyshop, socket, job, logsRef }) {
|
||||
if (dms === "rr") {
|
||||
wsssocket.emit("rr-export-job", {
|
||||
bodyshopId: bodyshop?.id || bodyshop?.bodyshopid || bodyshop?.uuid,
|
||||
jobid: job.id,
|
||||
jobId: job.id,
|
||||
job,
|
||||
txEnvelope: values
|
||||
});
|
||||
|
||||
@@ -111,10 +111,11 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse
|
||||
};
|
||||
|
||||
const handleLogEvent = (payload) => setLogs((prev) => [...prev, 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({
|
||||
jobid: payload,
|
||||
jobid: jobId,
|
||||
operation: AuditTrailMapping.jobexported(),
|
||||
type: "jobexported"
|
||||
});
|
||||
@@ -128,6 +129,7 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse
|
||||
|
||||
// RR channels (over wss)
|
||||
wsssocket.on("rr-log-event", handleLogEvent);
|
||||
wsssocket.on("RR:LOG", handleLogEvent);
|
||||
wsssocket.on("export-success", handleExportSuccess);
|
||||
wsssocket.on("rr-export-job:result", handleRrExportResult);
|
||||
|
||||
|
||||
@@ -123,7 +123,6 @@ const applyRoutes = ({ app }) => {
|
||||
app.use("/payroll", require("./server/routes/payrollRoutes"));
|
||||
app.use("/sso", require("./server/routes/ssoRoutes"));
|
||||
app.use("/integrations", require("./server/routes/intergrationRoutes"));
|
||||
app.use("/rr", require("./server/rr"));
|
||||
|
||||
// Default route for forbidden access
|
||||
app.get("/", (req, res) => {
|
||||
|
||||
@@ -2209,16 +2209,18 @@ exports.UPDATE_OLD_TRANSITION = `mutation UPDATE_OLD_TRANSITION($jobid: uuid!, $
|
||||
|
||||
exports.INSERT_NEW_TRANSITION = (
|
||||
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) {
|
||||
id
|
||||
}
|
||||
${includeOldTransition
|
||||
? `update_transitions(where: {id: {_eq: $oldTransitionId}}, _set: {duration: $duration}) {
|
||||
${
|
||||
includeOldTransition
|
||||
? `update_transitions(where: {id: {_eq: $oldTransitionId}}, _set: {duration: $duration}) {
|
||||
affected_rows
|
||||
}`
|
||||
: ""
|
||||
: ""
|
||||
}
|
||||
}`;
|
||||
|
||||
@@ -2908,6 +2910,8 @@ exports.GET_BODYSHOP_BY_ID = `
|
||||
state
|
||||
notification_followers
|
||||
timezone
|
||||
rr_dealerid
|
||||
rr_configuration
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
module.exports = require("./rrRoutes");
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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.
|
||||
* Expected table fields (adapt if your schema differs):
|
||||
* - bodyshops.id = $bodyshopId
|
||||
* - bodyshops.rr_dealerid (string)
|
||||
* - bodyshops.rr_configuration JSON { storeNumber?, branchNumber? }
|
||||
* Extract RR connection + routing from a bodyshop record (preferred)
|
||||
* Falls back to process.env for any missing bits.
|
||||
*
|
||||
* Requires env:
|
||||
* HASURA_GRAPHQL_ENDPOINT, HASURA_ADMIN_SECRET
|
||||
* 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
|
||||
*/
|
||||
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;
|
||||
const HASURA_SECRET = process.env.HASURA_ADMIN_SECRET || process.env.HASURA_GRAPHQL_ADMIN_SECRET;
|
||||
// NOTE: your schema uses rr_dealerid and rr_configuration JSON
|
||||
const dealerNumber = bodyshop?.rr_dealerid ?? process.env.RR_DEALER_NUMBER;
|
||||
|
||||
if (!HASURA_URL || !HASURA_SECRET) {
|
||||
// Warn loudly at startup; you can hard fail if you prefer
|
||||
console.warn("[RR] HASURA env not set (HASURA_GRAPHQL_ENDPOINT / HASURA_ADMIN_SECRET).");
|
||||
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;
|
||||
|
||||
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
|
||||
? 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 };
|
||||
module.exports = { getRRConfigFromBodyshop };
|
||||
|
||||
@@ -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
112
server/rr/rr-customers.js
Normal 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 client’s 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
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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 }) {
|
||||
return withClient(bodyshopId, logger, async (client, routing) => {
|
||||
// 1) Upsert Customer
|
||||
const custPayload = mapJobToCustomer(job);
|
||||
const custRes = job.customer?.nameRecId
|
||||
? await client.updateCustomer(custPayload, { routing })
|
||||
: await client.insertCustomer(custPayload, { routing });
|
||||
async function exportJobToRR(args) {
|
||||
const { bodyshop, job, selectedCustomer, advisorNo, existing } = args;
|
||||
|
||||
const customerNo = custRes?.data?.dmsRecKey || job.customer?.customerNo;
|
||||
if (!customerNo) throw new Error("Failed to resolve customerNo from RR response.");
|
||||
// Build client + opts (opts carries routing)
|
||||
const { client, opts } = buildClientAndOpts(bodyshop);
|
||||
|
||||
// 2) Ensure Service Vehicle (optional, if VIN present)
|
||||
if (job?.vehicle?.vin) {
|
||||
await client.insertServiceVehicle(
|
||||
{
|
||||
vin: job.vehicle.vin,
|
||||
vehicleServInfo: { customerNo }
|
||||
},
|
||||
{ routing }
|
||||
);
|
||||
}
|
||||
const payload = buildRRRepairOrderPayload({ job, selectedCustomer, advisorNo });
|
||||
|
||||
// 3) Create RO
|
||||
const roHeader = {
|
||||
customerNo,
|
||||
departmentType: "B",
|
||||
vin: job?.vehicle?.vin,
|
||||
outsdRoNo: job?.roExternal || job?.id,
|
||||
advisorNo: job?.advisorNo,
|
||||
mileageIn: job?.mileageIn
|
||||
};
|
||||
let rrRes;
|
||||
if (existing?.dmsRepairOrderId) {
|
||||
rrRes = await client.updateRepairOrder({ ...payload, dmsRepairOrderId: existing.dmsRepairOrderId }, opts);
|
||||
} else {
|
||||
rrRes = await client.createRepairOrder(payload, opts);
|
||||
}
|
||||
|
||||
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 {
|
||||
nameRecId: c.nameRecId,
|
||||
firstName: c.firstName || c.given_name,
|
||||
lastName: c.lastName || c.family_name,
|
||||
phone: c.phone || c.mobile,
|
||||
email: c.email,
|
||||
address: {
|
||||
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: {...}
|
||||
success: rrRes?.success === true,
|
||||
data: rrRes?.data || null,
|
||||
roStatus: rrRes?.data?.roStatus || null,
|
||||
statusBlocks: rrRes?.statusBlocks || [],
|
||||
xml: rrRes?.xml,
|
||||
parsed: rrRes?.parsed
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,54 +1,76 @@
|
||||
// server/rr/rr-job-helpers.js
|
||||
const { GraphQLClient } = require("graphql-request");
|
||||
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.
|
||||
* Requires the caller's token (we read from the socket handshake).
|
||||
* Query job + related entities.
|
||||
* Supports { socket } (GraphQL) and/or { redisHelpers } (cache/fetch).
|
||||
*/
|
||||
async function QueryJobData({ socket, jobid }) {
|
||||
const endpoint = process.env.GRAPHQL_ENDPOINT || process.env.HASURA_GRAPHQL_ENDPOINT || process.env.HASURA_URL;
|
||||
if (!endpoint) throw new Error("GRAPHQL endpoint not configured");
|
||||
async function QueryJobData(ctx = {}, jobId) {
|
||||
if (!jobId) throw new Error("jobId required");
|
||||
|
||||
const client = new GraphQLClient(endpoint, {});
|
||||
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 { redisHelpers, socket } = ctx;
|
||||
|
||||
const res = await client
|
||||
.setHeaders({ Authorization: `Bearer ${token}` })
|
||||
.request(queries.QUERY_JOBS_FOR_CDK_EXPORT, { id: jobid });
|
||||
if (redisHelpers) {
|
||||
if (typeof redisHelpers.getJobFromCache === "function") {
|
||||
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,
|
||||
* so we approximate with CombinedSearch by VIN and return the first hit.
|
||||
* Build RR create/update RO payload.
|
||||
* Prefers selectedVehicle.vin (if present) over job VIN.
|
||||
*/
|
||||
async function QueryDMSVehicleById({ bodyshopId, vin }) {
|
||||
if (!vin) return null;
|
||||
const res = await combinedSearch({ bodyshopId, kind: "vin", vin });
|
||||
// RR lib returns { success, data: CombinedSearchBlock[] }
|
||||
const blocks = res?.data || res || [];
|
||||
const first = Array.isArray(blocks) && blocks.length ? blocks[0] : null;
|
||||
if (!first) return null;
|
||||
function buildRRRepairOrderPayload({ job, selectedCustomer, selectedVehicle, advisorNo }) {
|
||||
const custNo =
|
||||
selectedCustomer?.custNo ||
|
||||
selectedCustomer?.customerNo ||
|
||||
selectedCustomer?.CustNo ||
|
||||
selectedCustomer?.CustomerNo;
|
||||
|
||||
const vehicle = first?.Vehicle || first?.vehicle || first?.Veh || null;
|
||||
const customer = first?.Customer || first?.customer || first?.Cust || null;
|
||||
if (!custNo) throw new Error("No RR customer selected (custNo missing)");
|
||||
|
||||
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 {
|
||||
repairOrderNumber: String(job?.job_number || job?.id),
|
||||
deptType: "B",
|
||||
vin,
|
||||
vehicle,
|
||||
customer
|
||||
custNo,
|
||||
advNo: advisorNo || undefined
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
QueryJobData,
|
||||
QueryDMSVehicleById,
|
||||
getTransactionType
|
||||
buildRRRepairOrderPayload
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
return function log(level = "info", message = "", ctx = {}) {
|
||||
// Console
|
||||
const fn = logger.logger[level] || logger.log;
|
||||
fn(`[RR] ${new Date().toISOString()} [${level.toUpperCase()}] ${message}`, ctx);
|
||||
"use strict";
|
||||
|
||||
const util = require("util");
|
||||
const appLogger = require("../utils/logger");
|
||||
|
||||
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 {
|
||||
socket?.emit?.("RR:LOG", { level, message, ctx, ts: Date.now() });
|
||||
return JSON.stringify(v);
|
||||
} 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 */
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 }) {
|
||||
return withClient(bodyshopId, async (client, routing) => {
|
||||
const res = await client.getAdvisors(criteria, { routing });
|
||||
return res;
|
||||
const { RRClient } = require("./lib/index.cjs");
|
||||
const { getRRConfigFromBodyshop } = require("./rr-config");
|
||||
|
||||
/**
|
||||
* 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) => {
|
||||
const res = await client.getParts(criteria, { routing });
|
||||
return res;
|
||||
});
|
||||
/**
|
||||
* 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 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 builder’s 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) => {
|
||||
const res = await client.combinedSearch(query, { routing });
|
||||
return res;
|
||||
});
|
||||
/**
|
||||
* 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 { 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
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
@@ -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 };
|
||||
@@ -1,16 +1,12 @@
|
||||
const { admin } = require("../firebase/firebase-handler");
|
||||
const FortellisLogger = require("../fortellis/fortellis-logger");
|
||||
const RRLogger = require("../rr/rr-logger");
|
||||
const { FortellisJobExport, FortellisSelectedCustomer } = require("../fortellis/fortellis");
|
||||
const CdkCalculateAllocations = require("../cdk/cdk-calculate-allocations").default;
|
||||
const registerRREvents = require("./rr-register-socket-events");
|
||||
|
||||
const lookupApi = require("../rr/rr-lookup");
|
||||
const { SelectedCustomer } = require("../rr/rr-selected-customer");
|
||||
const { QueryJobData } = require("../rr/rr-job-helpers");
|
||||
|
||||
const redisSocketEvents = ({
|
||||
io,
|
||||
redisHelpers: {
|
||||
const redisSocketEvents = ({ io, redisHelpers, ioHelpers, logger }) => {
|
||||
// Destructure helpers locally, but keep full objects available for downstream modules
|
||||
const {
|
||||
setSessionData,
|
||||
getSessionData,
|
||||
addUserSocketMapping,
|
||||
@@ -20,10 +16,10 @@ const redisSocketEvents = ({
|
||||
setSessionTransactionData,
|
||||
getSessionTransactionData,
|
||||
clearSessionTransactionData
|
||||
},
|
||||
ioHelpers: { getBodyshopRoom, getBodyshopConversationRoom },
|
||||
logger
|
||||
}) => {
|
||||
} = redisHelpers;
|
||||
|
||||
const { getBodyshopRoom, getBodyshopConversationRoom } = ioHelpers;
|
||||
|
||||
// Logging helper functions
|
||||
const createLogEvent = (socket, level, 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.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);
|
||||
next();
|
||||
} catch (error) {
|
||||
@@ -91,6 +96,15 @@ const redisSocketEvents = ({
|
||||
socket.handshake.auth.token = token;
|
||||
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);
|
||||
socket.emit("token-updated", { success: true });
|
||||
} catch (error) {
|
||||
@@ -147,6 +161,10 @@ const redisSocketEvents = ({
|
||||
if (socket.user?.email) {
|
||||
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)
|
||||
const rooms = Array.from(socket.rooms).filter((room) => room !== socket.id);
|
||||
for (const room of rooms) {
|
||||
@@ -251,7 +269,7 @@ const redisSocketEvents = ({
|
||||
});
|
||||
};
|
||||
|
||||
//Fortellis/CDK Handlers
|
||||
// Fortellis/CDK Handlers
|
||||
const registerFortellisEvents = (socket) => {
|
||||
socket.on("fortellis-export-job", async ({ jobid, txEnvelope }) => {
|
||||
try {
|
||||
@@ -280,6 +298,7 @@ const redisSocketEvents = ({
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("fortellis-selected-customer", async ({ jobid, selectedCustomerId }) => {
|
||||
try {
|
||||
await FortellisSelectedCustomer({
|
||||
@@ -307,6 +326,7 @@ const redisSocketEvents = ({
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("fortellis-calculate-allocations", async (jobid, callback) => {
|
||||
try {
|
||||
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
|
||||
registerRoomAndBroadcastEvents(socket);
|
||||
registerUpdateEvents(socket);
|
||||
@@ -438,7 +370,14 @@ const redisSocketEvents = ({
|
||||
registerSyncEvents(socket);
|
||||
registerTaskEvents(socket);
|
||||
registerFortellisEvents(socket);
|
||||
registerRREvents(socket);
|
||||
|
||||
// Reynolds & Reynolds socket handlers
|
||||
registerRREvents({
|
||||
socket,
|
||||
redisHelpers,
|
||||
ioHelpers,
|
||||
logger
|
||||
});
|
||||
};
|
||||
|
||||
// Associate Middleware and Handlers
|
||||
|
||||
305
server/web-sockets/rr-register-socket-events.js
Normal file
305
server/web-sockets/rr-register-socket-events.js
Normal 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;
|
||||
Reference in New Issue
Block a user