feature/Reynolds-and-Reynolds-DMS-API-Integration -Expand
This commit is contained in:
@@ -1,109 +1,156 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// RR (Reynolds & Reynolds) HTTP routes
|
||||
// - Mirrors /cdk shape so the UI can switch providers with minimal changes
|
||||
// - Uses validateFirebaseIdTokenMiddleware + withUserGraphQLClientMiddleware
|
||||
// - Calls into rr/* modules which wrap MakeRRCall from rr-helpers
|
||||
//
|
||||
// TODO:RR — As you wire the real RR endpoints + schemas, adjust the request
|
||||
// bodies, query params, and response normalization inside rr/* files.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
const express = require("express");
|
||||
const router = express.Router();
|
||||
|
||||
const validateFirebaseIdTokenMiddleware = require("../middleware/validateFirebaseIdTokenMiddleware");
|
||||
const withUserGraphQLClientMiddleware = require("../middleware/withUserGraphQLClientMiddleware");
|
||||
|
||||
const { RrCombinedSearch, RrGetAdvisors, RrGetParts } = require("../rr/rr-lookup");
|
||||
const { RrCustomerInsert, RrCustomerUpdate } = require("../rr/rr-customer");
|
||||
const { CreateRepairOrder, UpdateRepairOrder } = require("../rr/rr-repair-order");
|
||||
// NOTE: correct filename is rr-repair-orders.js (plural)
|
||||
const { CreateRepairOrder, UpdateRepairOrder } = require("../rr/rr-repair-orders");
|
||||
|
||||
// NOTE: keep parity with /cdk endpoints so UI can flip provider with minimal diff
|
||||
// Require auth on all RR routes (keep parity with /cdk)
|
||||
router.use(validateFirebaseIdTokenMiddleware);
|
||||
|
||||
// Placeholder endpoints — implement as needed:
|
||||
// -----------------------------------------------------------------------------
|
||||
// Accounting parity / scaffolding
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
// Reuse CDK allocations for now; keep the endpoint name identical to /cdk
|
||||
router.post("/calculate-allocations", withUserGraphQLClientMiddleware, async (req, res) => {
|
||||
try {
|
||||
const Calc = require("../cdk/cdk-calculate-allocations").default; // reuse for now
|
||||
const result = await Calc(req, req.body.jobid, true); // true->verbose style like Fortellis
|
||||
const CalculateAllocations = require("../cdk/cdk-calculate-allocations").default;
|
||||
const result = await CalculateAllocations(req, req.body.jobid, true); // verbose=true (like Fortellis flow)
|
||||
res.status(200).json({ data: result });
|
||||
} catch (e) {
|
||||
req.logger?.log("rr-calc-allocations-route", "ERROR", "api", "rr", { message: e.message, stack: e.stack });
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Example: load RR makes/models someday
|
||||
router.post("/getvehicles", withUserGraphQLClientMiddleware, async (req, res) => {
|
||||
// Placeholder for a future RR "get vehicles" endpoint to match /cdk/getvehicles
|
||||
router.post("/getvehicles", withUserGraphQLClientMiddleware, async (_req, res) => {
|
||||
res.status(501).json({ error: "RR getvehicles not implemented yet" });
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Lookup endpoints
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
// GET /rr/lookup/combined?vin=...&lastName=...
|
||||
router.get("/lookup/combined", async (req, res) => {
|
||||
try {
|
||||
const params = Object.entries(req.query);
|
||||
const params = Object.entries(req.query); // [["vin","..."], ["lastName","..."]]
|
||||
const data = await RrCombinedSearch({ socket: req, redisHelpers: req.sessionUtils, jobid: "ad-hoc", params });
|
||||
res.status(200).json({ data });
|
||||
} catch (e) {
|
||||
req.logger?.log("rr-lookup-combined", "ERROR", "api", "rr", { message: e.message, stack: e.stack });
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /rr/advisors?locationId=...
|
||||
router.get("/advisors", async (req, res) => {
|
||||
try {
|
||||
const params = Object.entries(req.query);
|
||||
const data = await RrGetAdvisors({ socket: req, redisHelpers: req.sessionUtils, jobid: "ad-hoc", params });
|
||||
res.status(200).json({ data });
|
||||
} catch (e) {
|
||||
req.logger?.log("rr-get-advisors", "ERROR", "api", "rr", { message: e.message, stack: e.stack });
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /rr/parts?partNumber=...&make=...
|
||||
router.get("/parts", async (req, res) => {
|
||||
try {
|
||||
const params = Object.entries(req.query);
|
||||
const data = await RrGetParts({ socket: req, redisHelpers: req.sessionUtils, jobid: "ad-hoc", params });
|
||||
res.status(200).json({ data });
|
||||
} catch (e) {
|
||||
req.logger?.log("rr-get-parts", "ERROR", "api", "rr", { message: e.message, stack: e.stack });
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Customer endpoints
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
// POST /rr/customer/insert
|
||||
// Body: { ...JobData-like shape used by rr-mappers }
|
||||
router.post("/customer/insert", async (req, res) => {
|
||||
try {
|
||||
const data = await RrCustomerInsert({ socket: req, redisHelpers: req.sessionUtils, JobData: req.body });
|
||||
res.status(200).json({ data });
|
||||
} catch (e) {
|
||||
req.logger?.log("rr-customer-insert", "ERROR", "api", "rr", { message: e.message, stack: e.stack });
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /rr/customer/update/:id
|
||||
// Body: { JobData, existingCustomer, patch }
|
||||
router.put("/customer/update/:id", async (req, res) => {
|
||||
try {
|
||||
const data = await RrCustomerUpdate({
|
||||
socket: req,
|
||||
redisHelpers: req.sessionUtils,
|
||||
JobData: req.body.JobData,
|
||||
existingCustomer: req.body.existingCustomer,
|
||||
patch: req.body.patch
|
||||
JobData: req.body?.JobData,
|
||||
existingCustomer: req.body?.existingCustomer,
|
||||
patch: req.body?.patch
|
||||
});
|
||||
res.status(200).json({ data });
|
||||
} catch (e) {
|
||||
req.logger?.log("rr-customer-update", "ERROR", "api", "rr", { message: e.message, stack: e.stack });
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Repair Order endpoints
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
// POST /rr/repair-order/create
|
||||
// Body: { JobData, txEnvelope }
|
||||
router.post("/repair-order/create", async (req, res) => {
|
||||
try {
|
||||
const data = await CreateRepairOrder({
|
||||
socket: req,
|
||||
redisHelpers: req.sessionUtils,
|
||||
JobData: req.body.JobData,
|
||||
txEnvelope: req.body.txEnvelope
|
||||
JobData: req.body?.JobData,
|
||||
txEnvelope: req.body?.txEnvelope
|
||||
});
|
||||
res.status(200).json({ data });
|
||||
} catch (e) {
|
||||
req.logger?.log("rr-ro-create", "ERROR", "api", "rr", { message: e.message, stack: e.stack });
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /rr/repair-order/update/:id
|
||||
// Body: { JobData, txEnvelope }
|
||||
router.put("/repair-order/update/:id", async (req, res) => {
|
||||
try {
|
||||
const data = await UpdateRepairOrder({
|
||||
socket: req,
|
||||
redisHelpers: req.sessionUtils,
|
||||
JobData: req.body.JobData,
|
||||
txEnvelope: req.body.txEnvelope
|
||||
JobData: req.body?.JobData,
|
||||
txEnvelope: req.body?.txEnvelope
|
||||
});
|
||||
res.status(200).json({ data });
|
||||
} catch (e) {
|
||||
req.logger?.log("rr-ro-update", "ERROR", "api", "rr", { message: e.message, stack: e.stack });
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,28 +1,65 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// RR Customer endpoints (create/update) wired through MakeRRCall.
|
||||
// Shapes are mapped via rr-mappers.js and validated via rr-error.js.
|
||||
//
|
||||
// What’s still missing (complete when you wire to the PDFs):
|
||||
// - Final request envelopes & field names in rr-mappers.js
|
||||
// - Definitive success/error envelope checks in rr-error.js
|
||||
// - Any RR-specific headers (dealer/tenant/site) once known
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
const { MakeRRCall, RRActions } = require("./rr-helpers");
|
||||
const { assertRrOk } = require("./rr-error");
|
||||
const { mapCustomerInsert, mapCustomerUpdate } = require("./rr-mappers");
|
||||
|
||||
/**
|
||||
* Create a customer in RR.
|
||||
*
|
||||
* @param {Object} deps
|
||||
* @param {Socket|ExpressRequest} deps.socket
|
||||
* @param {Object} deps.redisHelpers - redisHelpers API (not used here directly)
|
||||
* @param {Object} deps.JobData - Rome Job data used to build the payload
|
||||
* @returns {Promise<any>} RR response (envelope TBD)
|
||||
*/
|
||||
async function RrCustomerInsert({ socket, redisHelpers, JobData }) {
|
||||
// Map JobData -> RR "Customer Insert" request body
|
||||
const body = mapCustomerInsert(JobData);
|
||||
|
||||
const data = await MakeRRCall({
|
||||
...RRActions.CreateCustomer,
|
||||
...RRActions.CreateCustomer, // POST /customer/v1/
|
||||
body,
|
||||
redisHelpers,
|
||||
socket,
|
||||
jobid: JobData.id
|
||||
jobid: JobData?.id
|
||||
});
|
||||
|
||||
// TODO: assertRrOk should be updated once RR’s success envelope is finalized
|
||||
return assertRrOk(data, { apiName: "RR Create Customer" });
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing customer in RR.
|
||||
*
|
||||
* @param {Object} deps
|
||||
* @param {Socket|ExpressRequest} deps.socket
|
||||
* @param {Object} deps.redisHelpers
|
||||
* @param {Object} deps.JobData - context only (job id for correlation)
|
||||
* @param {Object} deps.existingCustomer - Current RR customer record
|
||||
* @param {Object} deps.patch - Minimal delta from UI to apply onto existingCustomer
|
||||
* @returns {Promise<any>} RR response
|
||||
*/
|
||||
async function RrCustomerUpdate({ socket, redisHelpers, JobData, existingCustomer, patch }) {
|
||||
// Build a merged/normalized payload for RR Update
|
||||
const body = mapCustomerUpdate(existingCustomer, patch);
|
||||
|
||||
const data = await MakeRRCall({
|
||||
...RRActions.UpdateCustomer, // add to RRActions
|
||||
...RRActions.UpdateCustomer, // PUT /customer/v1/ (append id inside body/path per final spec)
|
||||
body,
|
||||
redisHelpers,
|
||||
socket,
|
||||
jobid: JobData.id
|
||||
jobid: JobData?.id
|
||||
});
|
||||
|
||||
return assertRrOk(data, { apiName: "RR Update Customer" });
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,25 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// Error handling utilities for Reynolds & Reynolds (RR) API calls.
|
||||
// This mirrors Fortellis/CDK error helpers so the call pipeline stays uniform.
|
||||
//
|
||||
// TODO:RR — Replace the heuristics in assertRrOk with the *actual* envelope and
|
||||
// status semantics from the Rome RR specs. Examples in the PDFs may show:
|
||||
// - <Status code="0" severity="INFO">Success</Status>
|
||||
// - <Status code="123" severity="ERROR">Some message</Status>
|
||||
// - or a SuccessFlag/ReturnCode element in the JSON/XML response.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
class RrApiError extends Error {
|
||||
/**
|
||||
* @param {string} message - Human-readable message
|
||||
* @param {object} opts
|
||||
* @param {string} [opts.reqId] - Internal request identifier
|
||||
* @param {string} [opts.url] - Target URL of the API call
|
||||
* @param {string} [opts.apiName] - Which API was invoked (for context)
|
||||
* @param {object} [opts.errorData] - Raw error payload from RR
|
||||
* @param {number} [opts.status] - HTTP status code
|
||||
* @param {string} [opts.statusText] - HTTP status text
|
||||
*/
|
||||
constructor(message, { reqId, url, apiName, errorData, status, statusText } = {}) {
|
||||
super(message);
|
||||
this.name = "RrApiError";
|
||||
@@ -11,15 +32,35 @@ class RrApiError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
// Match Rome/RR envelope once you confirm it; keep this central.
|
||||
/**
|
||||
* Assert that an RR API response is considered "OK".
|
||||
* Throws RrApiError otherwise.
|
||||
*
|
||||
* @param {*} data - Parsed response object from MakeRRCall
|
||||
* @param {object} opts
|
||||
* @param {string} opts.apiName - Which API we're checking (for error messages)
|
||||
* @param {boolean} [opts.allowEmpty=false] - If true, allow null/empty results
|
||||
* @returns {*} - The same data if valid
|
||||
*/
|
||||
function assertRrOk(data, { apiName, allowEmpty = false } = {}) {
|
||||
// Example heuristics — update to exact envelope from the PDF:
|
||||
// - successFlag === true, or
|
||||
// - code === "0", or
|
||||
// - !error / !errors length, etc.
|
||||
if (!allowEmpty && (data == null || data.error || data.errors?.length)) {
|
||||
throw new RrApiError(`${apiName} returned an error`, { errorData: data });
|
||||
// TODO:RR — Update logic to exactly match RR's success envelope.
|
||||
// Possible patterns to confirm from PDFs:
|
||||
// - data.Status?.code === "0"
|
||||
// - data.Return?.successFlag === true
|
||||
// - data.Errors is missing or empty
|
||||
//
|
||||
// For now, we use a simple heuristic fallback.
|
||||
|
||||
const hasErrors =
|
||||
data == null ||
|
||||
data.error ||
|
||||
(Array.isArray(data.errors) && data.errors.length > 0) ||
|
||||
(data.Status && data.Status.severity === "ERROR");
|
||||
|
||||
if (!allowEmpty && hasErrors) {
|
||||
throw new RrApiError(`${apiName} returned an error`, { errorData: data, apiName });
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,28 @@
|
||||
/**
|
||||
* RR (Reynolds & Reynolds) helper module
|
||||
* - Loads env (.env.{NODE_ENV})
|
||||
* - Provides token retrieval + simple Redis-backed caching
|
||||
* -----------------------------------------------------------------------------
|
||||
* Responsibilities
|
||||
* - Load env (.env.{NODE_ENV})
|
||||
* - Provide token retrieval with simple Redis-backed caching
|
||||
* - Normalized HTTP caller (MakeRRCall) with request-id + idempotency key
|
||||
* - URL constructor w/ path + query params
|
||||
* - Optional delayed/batch polling stub (DelayedCallback)
|
||||
* - Central action registry (RRActions) with prod/uat base URLs
|
||||
* - Exports everything needed by rr-* feature files
|
||||
* - Central action registry (RRActions) with prod/uat base URLs (PLACEHOLDERS)
|
||||
* - Common cache enums + TTL + transaction-type helper (parity with Fortellis)
|
||||
*
|
||||
* What’s missing / TODOs to make this “real” (per RR/Rome PDFs you provided):
|
||||
* - Implement the actual RR auth/token flow inside getRRToken()
|
||||
* - Replace all RRActions URLs with the final endpoints from the RR spec
|
||||
* - Confirm final header names (e.g., X-Request-Id, Idempotency-Key)
|
||||
* - If RR uses async “batch/status/result”, adapt DelayedCallback() to spec
|
||||
* - Confirm success/error envelope and centralize in rr-error.js
|
||||
*/
|
||||
|
||||
const path = require("path");
|
||||
require("dotenv").config({
|
||||
path: path.resolve(process.cwd(), `.env.${process.env.NODE_ENV || "development"}`)
|
||||
});
|
||||
|
||||
const uuid = require("uuid").v4;
|
||||
const AxiosLib = require("axios").default;
|
||||
const axios = AxiosLib.create();
|
||||
@@ -17,29 +31,71 @@ const axiosCurlirize = require("axios-curlirize").default;
|
||||
const logger = require("../utils/logger");
|
||||
const { RrApiError } = require("./rr-error");
|
||||
|
||||
// Optional curl logging (handy while scaffolding)
|
||||
// Emit curl equivalents for dev troubleshooting (safe to disable in prod)
|
||||
axiosCurlirize(axios, (result /*, err */) => {
|
||||
const { command } = result;
|
||||
// Disable or pipe to your logger if you prefer:
|
||||
// logger.log("rr-axios-curl", "DEBUG", "api", null, { command });
|
||||
// Keeping a console for local scaffolding/bring-up:
|
||||
console.log("*** rr axios (curl):", command);
|
||||
try {
|
||||
const { command } = result;
|
||||
// Pipe to your centralized logger if preferred:
|
||||
// logger.log("rr-axios-curl", "DEBUG", "api", null, { command });
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
console.log("*** rr axios (curl):", command);
|
||||
}
|
||||
} catch {
|
||||
// Best-effort only
|
||||
}
|
||||
});
|
||||
|
||||
const isProduction = process.env.NODE_ENV === "production";
|
||||
|
||||
/**
|
||||
* Simple provider-level token cache using existing session helpers.
|
||||
* We re-use setSessionData/getSessionData with a synthetic "socketId"
|
||||
* key (so we don't need pubClient here).
|
||||
* Transaction key namespace (mirrors Fortellis' getTransactionType)
|
||||
* Used to partition per-job Redis session hashes.
|
||||
*/
|
||||
const RR_PROVIDER_TOKEN_BUCKET = "rr:provider-token";
|
||||
const getTransactionType = (jobid) => `rr:${jobid}`;
|
||||
|
||||
/**
|
||||
* Default per-transaction TTL for RR data cached in Redis (seconds).
|
||||
* Keep parity with the Fortellis helper to avoid drift.
|
||||
*/
|
||||
const defaultRRTTL = 60 * 60; // 1 hour
|
||||
|
||||
/**
|
||||
* Namespaced keys stored under each transaction hash (parity with Fortellis)
|
||||
* These are referenced across rr-job-export.js (and friends).
|
||||
*/
|
||||
const RRCacheEnums = {
|
||||
txEnvelope: "txEnvelope",
|
||||
DMSBatchTxn: "DMSBatchTxn",
|
||||
SubscriptionMeta: "SubscriptionMeta", // kept for parity; not used yet for RR
|
||||
DepartmentId: "DepartmentId", // kept for parity; not used yet for RR
|
||||
JobData: "JobData",
|
||||
DMSVid: "DMSVid",
|
||||
DMSVeh: "DMSVeh",
|
||||
DMSVehCustomer: "DMSVehCustomer",
|
||||
DMSCustList: "DMSCustList",
|
||||
DMSCust: "DMSCust",
|
||||
selectedCustomerId: "selectedCustomerId",
|
||||
DMSTransHeader: "DMSTransHeader",
|
||||
transWips: "transWips",
|
||||
DmsBatchTxnPost: "DmsBatchTxnPost",
|
||||
DMSVehHistory: "DMSVehHistory"
|
||||
};
|
||||
|
||||
/**
|
||||
* Provider-level token cache.
|
||||
* We reuse redisHelpers.setSessionData/getSessionData with a synthetic "socketId"
|
||||
* so we don’t need direct access to the Redis client here.
|
||||
*/
|
||||
const RR_PROVIDER_TOKEN_BUCKET = "rr:provider-token"; // becomes key: "socket:rr:provider-token"
|
||||
const RR_PROVIDER_TOKEN_FIELD = "token";
|
||||
|
||||
/**
|
||||
* Fetch an RR access token. Replace with the real auth call when available.
|
||||
* Fetch an RR access token.
|
||||
* TODO: Implement the *actual* RR auth flow per the spec (client credentials
|
||||
* or whatever RCI requires). This stub uses an env or a fixed dev token.
|
||||
*
|
||||
* @param {Object} deps
|
||||
* @param {Object} deps.redisHelpers - your redisHelpers api
|
||||
* @param {Object} deps.redisHelpers - Your redisHelpers API
|
||||
* @returns {Promise<string>} accessToken
|
||||
*/
|
||||
async function getRRToken({ redisHelpers }) {
|
||||
@@ -50,10 +106,9 @@ async function getRRToken({ redisHelpers }) {
|
||||
return cached.accessToken;
|
||||
}
|
||||
|
||||
// TODO: Implement real RR auth flow here.
|
||||
// Stub: use env var or a fixed dev token
|
||||
// TODO: Replace with real RR auth call. For now, fallback to env.
|
||||
const accessToken = process.env.RR_FAKE_TOKEN || "rr-dev-token";
|
||||
// Set an artificial 55-minute expiry (adjust to real value)
|
||||
// Artificial ~55m expiry (adjust to actual token TTL)
|
||||
const expiresAt = Date.now() + 55 * 60 * 1000;
|
||||
|
||||
await redisHelpers.setSessionData(
|
||||
@@ -69,41 +124,44 @@ async function getRRToken({ redisHelpers }) {
|
||||
message: error?.message,
|
||||
stack: error?.stack
|
||||
});
|
||||
// In absolute worst case, return a stub so dev environments keep moving
|
||||
// Keep local dev moving even if cache errors
|
||||
return process.env.RR_FAKE_TOKEN || "rr-dev-token";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Construct a full URL including optional path segment and query params.
|
||||
* Matches the function signature used elsewhere in the codebase.
|
||||
*
|
||||
* @param {Object} args
|
||||
* @param {string} args.url - base URL (may or may not end with "/")
|
||||
* @param {string} [args.pathParams] - string to append to URL as path (no leading slash needed)
|
||||
* @param {Array<[string,string]>} [args.requestSearchParams] - tuples of [key, value] for query
|
||||
* @param {string} [args.pathParams] - string appended to URL (no leading slash)
|
||||
* @param {Array<[string,string]>} [args.requestSearchParams] - pairs converted to query params
|
||||
* @returns {string}
|
||||
*/
|
||||
function constructFullUrl({ url, pathParams = "", requestSearchParams = [] }) {
|
||||
// normalize single trailing slash
|
||||
// normalize: ensure exactly one trailing slash on base
|
||||
url = url.replace(/\/+$/, "/");
|
||||
const fullPath = pathParams ? `${url}${pathParams}` : url;
|
||||
const searchParams = new URLSearchParams(requestSearchParams).toString();
|
||||
return searchParams ? `${fullPath}?${searchParams}` : fullPath;
|
||||
const query = new URLSearchParams(requestSearchParams).toString();
|
||||
return query ? `${fullPath}?${query}` : fullPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional delayed/batch polling flow (placeholder).
|
||||
* If RR returns a "check later" envelope, use this to poll until "complete".
|
||||
* Adjust the header names and result shapes once you have the real spec.
|
||||
*
|
||||
* @param {Object} args
|
||||
* @param {Object} args.delayMeta
|
||||
* @param {string} args.access_token
|
||||
* @param {string} args.reqId
|
||||
* @param {Object} args.delayMeta - body returned from initial RR call with status link(s)
|
||||
* @param {string} args.access_token - token to reuse for polling
|
||||
* @param {string} args.reqId - correlation id
|
||||
* @returns {Promise<any>}
|
||||
*/
|
||||
async function DelayedCallback({ delayMeta, access_token, reqId }) {
|
||||
// Stub example — adapt to RR if they do a batch/status-result pattern
|
||||
for (let attempt = 0; attempt < 5; attempt++) {
|
||||
await sleep((delayMeta.checkStatusAfterSeconds || 2) * 1000);
|
||||
await sleep((delayMeta?.checkStatusAfterSeconds || 2) * 1000);
|
||||
|
||||
const statusUrl = delayMeta?._links?.status?.href;
|
||||
if (!statusUrl) {
|
||||
@@ -137,7 +195,7 @@ function sleep(ms) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Core caller. Mirrors Fortellis' MakeFortellisCall shape so you can reuse flow.
|
||||
* Core caller. Mirrors Fortellis' MakeFortellisCall shape so we can reuse flow.
|
||||
*
|
||||
* @param {Object} args
|
||||
* @param {string} args.apiName - logical name (used in logs/errors)
|
||||
@@ -147,7 +205,7 @@ function sleep(ms) {
|
||||
* @param {"get"|"post"|"put"|"delete"} [args.type="post"]
|
||||
* @param {boolean} [args.debug=true]
|
||||
* @param {string} [args.requestPathParams] - path segment to append to url
|
||||
* @param {Array<[string,string]>} [args.requestSearchParams=[]] - tuples of [key, val] added as query params
|
||||
* @param {Array<[string,string]>} [args.requestSearchParams=[]] - tuples of [key, val] for query params
|
||||
* @param {string|number} [args.jobid] - used for logger correlation (optional)
|
||||
* @param {Object} args.redisHelpers - your redisHelpers api (for token cache)
|
||||
* @param {Object} [args.socket] - pass-through so we can pull user/email if needed
|
||||
@@ -169,7 +227,6 @@ async function MakeRRCall({
|
||||
const fullUrl = constructFullUrl({ url, pathParams: requestPathParams, requestSearchParams });
|
||||
const reqId = uuid();
|
||||
const idempotencyKey = uuid();
|
||||
|
||||
const access_token = await getRRToken({ redisHelpers });
|
||||
|
||||
if (debug) {
|
||||
@@ -179,12 +236,11 @@ async function MakeRRCall({
|
||||
url: fullUrl,
|
||||
jobid,
|
||||
reqId,
|
||||
body
|
||||
body: safeLogJson(body)
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
let resp;
|
||||
const baseHeaders = {
|
||||
Authorization: `Bearer ${access_token}`,
|
||||
"X-Request-Id": reqId,
|
||||
@@ -192,6 +248,7 @@ async function MakeRRCall({
|
||||
...headers
|
||||
};
|
||||
|
||||
let resp;
|
||||
switch ((type || "post").toLowerCase()) {
|
||||
case "get":
|
||||
resp = await axios.get(fullUrl, { headers: baseHeaders });
|
||||
@@ -200,6 +257,7 @@ async function MakeRRCall({
|
||||
resp = await axios.put(fullUrl, body, { headers: baseHeaders });
|
||||
break;
|
||||
case "delete":
|
||||
// Some APIs require body with DELETE; axios supports { data } for that
|
||||
resp = await axios.delete(fullUrl, { headers: baseHeaders, data: body });
|
||||
break;
|
||||
case "post":
|
||||
@@ -268,9 +326,9 @@ async function MakeRRCall({
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep action registry centralized so upstream modules can import a single map.
|
||||
* Replace the base URLs with real RR/Rome endpoints as you finalize the integration.
|
||||
* You can also split this into per-domain registries once you know the layout.
|
||||
* Central action registry.
|
||||
* TODO: Replace ALL URLs with real RR endpoints from the Rome/RR specs.
|
||||
* You can later split into domain-specific registries if it grows large.
|
||||
*/
|
||||
const RRActions = {
|
||||
// Vehicles
|
||||
@@ -312,7 +370,7 @@ const RRActions = {
|
||||
UpdateCustomer: {
|
||||
apiName: "RR Update Customer",
|
||||
url: isProduction
|
||||
? "https://rr.example.com/customer/v1/" // append /{id} if required
|
||||
? "https://rr.example.com/customer/v1/" // append /{id} if required by spec
|
||||
: "https://rr-uat.example.com/customer/v1/",
|
||||
type: "put"
|
||||
},
|
||||
@@ -323,11 +381,12 @@ const RRActions = {
|
||||
: "https://rr-uat.example.com/customer/v1/",
|
||||
type: "get"
|
||||
},
|
||||
QueryCustomerByName: {
|
||||
SearchCustomer: {
|
||||
apiName: "RR Query Customer By Name",
|
||||
url: isProduction ? "https://rr.example.com/customer/v1/search" : "https://rr-uat.example.com/customer/v1/search",
|
||||
type: "get"
|
||||
},
|
||||
|
||||
// Combined search (customer + vehicle)
|
||||
CombinedSearch: {
|
||||
apiName: "RR Combined Search (Customer + Vehicle)",
|
||||
@@ -336,12 +395,14 @@ const RRActions = {
|
||||
: "https://rr-uat.example.com/search/v1/customer-vehicle",
|
||||
type: "get"
|
||||
},
|
||||
|
||||
// Advisors
|
||||
GetAdvisors: {
|
||||
apiName: "RR Get Advisors",
|
||||
url: isProduction ? "https://rr.example.com/advisors/v1" : "https://rr-uat.example.com/advisors/v1",
|
||||
type: "get"
|
||||
},
|
||||
|
||||
// Parts
|
||||
GetParts: {
|
||||
apiName: "RR Get Parts",
|
||||
@@ -413,9 +474,15 @@ function safeLogJson(data) {
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
// core helpers
|
||||
MakeRRCall,
|
||||
RRActions,
|
||||
getRRToken,
|
||||
constructFullUrl,
|
||||
DelayedCallback
|
||||
DelayedCallback,
|
||||
|
||||
// parity exports required by other RR modules
|
||||
getTransactionType,
|
||||
defaultRRTTL,
|
||||
RRCacheEnums
|
||||
};
|
||||
|
||||
@@ -1,4 +1,22 @@
|
||||
const GraphQLClient = require("graphql-request").GraphQLClient;
|
||||
// -----------------------------------------------------------------------------
|
||||
// Reynolds & Reynolds (RR) Job Export flow (scaffold).
|
||||
//
|
||||
// Parity with Fortellis/CDK export shape so the UI + socket flows remain
|
||||
// consistent:
|
||||
//
|
||||
// - RRJobExport: initial VIN/customer discovery & prompt for customer select
|
||||
// - RRSelectedCustomer: create/update customer, insert/read vehicle,
|
||||
// post WIP batch, post history, mark success/failure, notify client
|
||||
//
|
||||
// What’s still missing (fill in from Rome/RR PDFs you provided):
|
||||
// - Exact request/response envelopes for each RR operation
|
||||
// (Customer Insert/Update, Vehicle Insert/Read, WIP APIs, Service History).
|
||||
// - Final success/error conditions for assertRrOk (we currently use heuristics).
|
||||
// - Precise field mappings inside CreateCustomer, InsertVehicle,
|
||||
// StartWip/TransBatchWip/PostBatchWip, InsertServiceVehicleHistory.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
const { GraphQLClient } = require("graphql-request");
|
||||
const moment = require("moment-timezone");
|
||||
|
||||
const CalculateAllocations = require("../cdk/cdk-calculate-allocations").default; // reuse allocations
|
||||
@@ -6,13 +24,21 @@ const CreateRRLogEvent = require("./rr-logger");
|
||||
const queries = require("../graphql-client/queries");
|
||||
const { MakeRRCall, RRActions, getTransactionType, defaultRRTTL, RRCacheEnums } = require("./rr-helpers");
|
||||
|
||||
// --- Public entry points (similar to Fortellis)
|
||||
// -----------------------------------------------------------------------------
|
||||
// Public entry points (wired in redisSocketEvents.js)
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Seed export: cache txEnvelope + JobData, discover VIN->VehicleId + owner,
|
||||
* search by customer name, and prompt client to select/create a customer.
|
||||
*/
|
||||
async function RRJobExport({ socket, redisHelpers, txEnvelope, jobid }) {
|
||||
const { setSessionTransactionData } = redisHelpers;
|
||||
|
||||
try {
|
||||
CreateRRLogEvent(socket, "DEBUG", `[RR] Received Job export request`, { jobid });
|
||||
|
||||
// cache txEnvelope for this job session
|
||||
await setSessionTransactionData(
|
||||
socket.id,
|
||||
getTransactionType(jobid),
|
||||
@@ -31,10 +57,12 @@ async function RRJobExport({ socket, redisHelpers, txEnvelope, jobid }) {
|
||||
|
||||
let DMSVehCustomer;
|
||||
if (!DMSVid?.newId) {
|
||||
// existing vehicle, load details
|
||||
const DMSVeh = await ReadVehicleById({ socket, redisHelpers, JobData, vehicleId: DMSVid.vehiclesVehId });
|
||||
await setSessionTransactionData(socket.id, getTransactionType(jobid), RRCacheEnums.DMSVeh, DMSVeh, defaultRRTTL);
|
||||
|
||||
const owner = DMSVeh?.owners && DMSVeh.owners.find((o) => o.id.assigningPartyId === "CURRENT");
|
||||
// Try to read the CURRENT owner (shape TBD per RR)
|
||||
const owner = DMSVeh?.owners && DMSVeh.owners.find((o) => o.id?.assigningPartyId === "CURRENT");
|
||||
if (owner?.id?.value) {
|
||||
DMSVehCustomer = await ReadCustomerById({ socket, redisHelpers, JobData, customerId: owner.id.value });
|
||||
await setSessionTransactionData(
|
||||
@@ -47,6 +75,7 @@ async function RRJobExport({ socket, redisHelpers, txEnvelope, jobid }) {
|
||||
}
|
||||
}
|
||||
|
||||
// Search customers by job owner name (param names TBD per RR)
|
||||
const DMSCustList = await SearchCustomerByName({ socket, redisHelpers, JobData });
|
||||
await setSessionTransactionData(
|
||||
socket.id,
|
||||
@@ -56,6 +85,7 @@ async function RRJobExport({ socket, redisHelpers, txEnvelope, jobid }) {
|
||||
defaultRRTTL
|
||||
);
|
||||
|
||||
// Emit choices: (VIN owner first if present) + search results
|
||||
socket.emit("rr-select-customer", [
|
||||
...(DMSVehCustomer ? [{ ...DMSVehCustomer, vinOwner: true }] : []),
|
||||
...(Array.isArray(DMSCustList) ? DMSCustList : [])
|
||||
@@ -65,6 +95,13 @@ async function RRJobExport({ socket, redisHelpers, txEnvelope, jobid }) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* After client selects a customer (or requests create):
|
||||
* - Read or create the customer
|
||||
* - Insert vehicle if needed (or read existing)
|
||||
* - StartWip -> TransBatchWip -> PostBatchWip -> Mark exported
|
||||
* - Optionally insert service history
|
||||
*/
|
||||
async function RRSelectedCustomer({ socket, redisHelpers, selectedCustomerId, jobid }) {
|
||||
const { setSessionTransactionData, getSessionTransactionData } = redisHelpers;
|
||||
|
||||
@@ -81,6 +118,7 @@ async function RRSelectedCustomer({ socket, redisHelpers, selectedCustomerId, jo
|
||||
const txEnvelope = await getSessionTransactionData(socket.id, getTransactionType(jobid), RRCacheEnums.txEnvelope);
|
||||
const DMSVid = await getSessionTransactionData(socket.id, getTransactionType(jobid), RRCacheEnums.DMSVid);
|
||||
|
||||
// Ensure we have a customer to use
|
||||
let DMSCust;
|
||||
if (selectedCustomerId) {
|
||||
DMSCust = await ReadCustomerById({ socket, redisHelpers, JobData, customerId: selectedCustomerId });
|
||||
@@ -90,16 +128,17 @@ async function RRSelectedCustomer({ socket, redisHelpers, selectedCustomerId, jo
|
||||
}
|
||||
await setSessionTransactionData(socket.id, getTransactionType(jobid), RRCacheEnums.DMSCust, DMSCust, defaultRRTTL);
|
||||
|
||||
// Ensure the vehicle exists (ownership model TBD per RR)
|
||||
let DMSVeh;
|
||||
if (DMSVid?.newId) {
|
||||
DMSVeh = await InsertVehicle({ socket, redisHelpers, JobData, txEnvelope, DMSVid, DMSCust });
|
||||
} else {
|
||||
DMSVeh = await ReadVehicleById({ socket, redisHelpers, JobData, vehicleId: DMSVid.vehiclesVehId });
|
||||
// TODO: implement UpdateVehicle if RR supports updating ownership
|
||||
// DMSVeh = await UpdateVehicle({ ... })
|
||||
// TODO: If RR supports “UpdateVehicle” to change ownership, add it here.
|
||||
}
|
||||
await setSessionTransactionData(socket.id, getTransactionType(jobid), RRCacheEnums.DMSVeh, DMSVeh, defaultRRTTL);
|
||||
|
||||
// Start WIP header
|
||||
const DMSTransHeader = await StartWip({ socket, redisHelpers, JobData, txEnvelope });
|
||||
await setSessionTransactionData(
|
||||
socket.id,
|
||||
@@ -109,6 +148,7 @@ async function RRSelectedCustomer({ socket, redisHelpers, selectedCustomerId, jo
|
||||
defaultRRTTL
|
||||
);
|
||||
|
||||
// Post lines
|
||||
const DMSBatchTxn = await TransBatchWip({ socket, redisHelpers, JobData });
|
||||
await setSessionTransactionData(
|
||||
socket.id,
|
||||
@@ -118,7 +158,7 @@ async function RRSelectedCustomer({ socket, redisHelpers, selectedCustomerId, jo
|
||||
defaultRRTTL
|
||||
);
|
||||
|
||||
// decide success/err format later; keep parity with Fortellis shape
|
||||
// Decide success from envelope (heuristic until exact spec confirmed)
|
||||
if (String(DMSBatchTxn?.rtnCode || "0") === "0") {
|
||||
const DmsBatchTxnPost = await PostBatchWip({ socket, redisHelpers, JobData });
|
||||
await setSessionTransactionData(
|
||||
@@ -132,7 +172,7 @@ async function RRSelectedCustomer({ socket, redisHelpers, selectedCustomerId, jo
|
||||
if (String(DmsBatchTxnPost?.rtnCode || "0") === "0") {
|
||||
await MarkJobExported({ socket, jobid: JobData.id, redisHelpers });
|
||||
|
||||
// Optional service history write
|
||||
// Optional service history write (non-blocking)
|
||||
try {
|
||||
const DMSVehHistory = await InsertServiceVehicleHistory({ socket, redisHelpers, JobData });
|
||||
await setSessionTransactionData(
|
||||
@@ -168,7 +208,10 @@ async function RRSelectedCustomer({ socket, redisHelpers, selectedCustomerId, jo
|
||||
}
|
||||
}
|
||||
|
||||
// --- GraphQL job fetch
|
||||
// -----------------------------------------------------------------------------
|
||||
// GraphQL job fetch
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
async function QueryJobData({ socket, jobid }) {
|
||||
const client = new GraphQLClient(process.env.GRAPHQL_ENDPOINT, {});
|
||||
const currentToken =
|
||||
@@ -177,10 +220,14 @@ async function QueryJobData({ socket, jobid }) {
|
||||
const result = await client
|
||||
.setHeaders({ Authorization: `Bearer ${currentToken}` })
|
||||
.request(queries.QUERY_JOBS_FOR_CDK_EXPORT, { id: jobid });
|
||||
|
||||
return result.jobs_by_pk;
|
||||
}
|
||||
|
||||
// --- RR API step stubs (wire to MakeRRCall) -------------------------
|
||||
// -----------------------------------------------------------------------------
|
||||
// RR API step stubs (wire to MakeRRCall). Replace request payloads once the
|
||||
// exact RR/Rome schemas are confirmed from the PDFs.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
async function GetVehicleId({ socket, redisHelpers, JobData }) {
|
||||
return await MakeRRCall({
|
||||
@@ -213,17 +260,17 @@ async function ReadCustomerById({ socket, redisHelpers, JobData, customerId }) {
|
||||
}
|
||||
|
||||
async function SearchCustomerByName({ socket, redisHelpers, JobData }) {
|
||||
// align with Rome Search spec later
|
||||
// TODO: Confirm exact query param names from the RR search spec
|
||||
const ownerNameParams =
|
||||
JobData.ownr_co_nm && JobData.ownr_co_nm.trim() !== ""
|
||||
? [["lastName", JobData.ownr_co_nm]]
|
||||
? [["lastName", JobData.ownr_co_nm]] // placeholder: business search
|
||||
: [
|
||||
["firstName", JobData.ownr_fn],
|
||||
["lastName", JobData.ownr_ln]
|
||||
];
|
||||
|
||||
return await MakeRRCall({
|
||||
...RRActions.SearchCustomer,
|
||||
...RRActions.QueryCustomerByName, // ✅ use action defined in rr-helpers
|
||||
requestSearchParams: ownerNameParams,
|
||||
redisHelpers,
|
||||
socket,
|
||||
@@ -232,10 +279,9 @@ async function SearchCustomerByName({ socket, redisHelpers, JobData }) {
|
||||
}
|
||||
|
||||
async function CreateCustomer({ socket, redisHelpers, JobData }) {
|
||||
// shape per Rome Customer Insert spec
|
||||
// TODO: Replace with exact RR Customer Insert envelope & fields
|
||||
const body = {
|
||||
customerType: JobData.ownr_co_nm ? "BUSINESS" : "INDIVIDUAL"
|
||||
// fill minimal required fields later
|
||||
};
|
||||
return await MakeRRCall({
|
||||
...RRActions.CreateCustomer,
|
||||
@@ -246,9 +292,9 @@ async function CreateCustomer({ socket, redisHelpers, JobData }) {
|
||||
});
|
||||
}
|
||||
|
||||
async function InsertVehicle({ socket, redisHelpers, JobData, txEnvelope, DMSVid, DMSCust }) {
|
||||
async function InsertVehicle({ socket, redisHelpers, JobData /*, txEnvelope, DMSVid, DMSCust*/ }) {
|
||||
// TODO: Replace with exact RR Service Vehicle Insert mapping
|
||||
const body = {
|
||||
// map fields per Rome Insert Service Vehicle spec
|
||||
vin: JobData.v_vin
|
||||
// owners, make/model, odometer, etc…
|
||||
};
|
||||
@@ -262,14 +308,15 @@ async function InsertVehicle({ socket, redisHelpers, JobData, txEnvelope, DMSVid
|
||||
}
|
||||
|
||||
async function StartWip({ socket, redisHelpers, JobData, txEnvelope }) {
|
||||
// TODO: Replace body fields with RR WIP header schema
|
||||
const body = {
|
||||
acctgDate: moment().tz(JobData.bodyshop.timezone).format("YYYY-MM-DD"),
|
||||
desc: txEnvelope.story || "",
|
||||
desc: txEnvelope?.story || "",
|
||||
docType: "10",
|
||||
m13Flag: "0",
|
||||
refer: JobData.ro_number,
|
||||
srcCo: JobData.bodyshop?.cdk_configuration?.srcco || "00", // placeholder
|
||||
srcJrnl: txEnvelope.journal,
|
||||
srcCo: JobData.bodyshop?.cdk_configuration?.srcco || "00", // placeholder from CDK config; RR equivalent TBD
|
||||
srcJrnl: txEnvelope?.journal,
|
||||
userID: "BSMS"
|
||||
};
|
||||
return await MakeRRCall({
|
||||
@@ -283,9 +330,11 @@ async function StartWip({ socket, redisHelpers, JobData, txEnvelope }) {
|
||||
|
||||
async function TransBatchWip({ socket, redisHelpers, JobData }) {
|
||||
const wips = await GenerateTransWips({ socket, redisHelpers, JobData });
|
||||
|
||||
// TODO: Ensure this body shape matches RR batch transaction schema
|
||||
return await MakeRRCall({
|
||||
...RRActions.TranBatchWip,
|
||||
body: wips, // shape per Rome spec
|
||||
body: wips,
|
||||
redisHelpers,
|
||||
socket,
|
||||
jobid: JobData.id
|
||||
@@ -299,6 +348,7 @@ async function PostBatchWip({ socket, redisHelpers, JobData }) {
|
||||
RRCacheEnums.DMSTransHeader
|
||||
);
|
||||
|
||||
// TODO: Confirm final field names for “post” operation in RR
|
||||
const body = {
|
||||
opCode: "P",
|
||||
transID: DMSTransHeader?.transID
|
||||
@@ -334,7 +384,10 @@ async function DeleteWip({ socket, redisHelpers, JobData }) {
|
||||
getTransactionType(JobData.id),
|
||||
RRCacheEnums.DMSTransHeader
|
||||
);
|
||||
|
||||
// TODO: Confirm if RR uses the same endpoint with opCode=D to delete/void
|
||||
const body = { opCode: "D", transID: DMSTransHeader?.transID };
|
||||
|
||||
return await MakeRRCall({
|
||||
...RRActions.PostBatchWip,
|
||||
body,
|
||||
@@ -351,8 +404,8 @@ async function InsertServiceVehicleHistory({ socket, redisHelpers, JobData }) {
|
||||
RRCacheEnums.txEnvelope
|
||||
);
|
||||
|
||||
// TODO: Replace with RR Service Vehicle History schema
|
||||
const body = {
|
||||
// map to Rome “Service Vehicle History Insert” spec
|
||||
comments: txEnvelope?.story || ""
|
||||
};
|
||||
return await MakeRRCall({
|
||||
@@ -364,7 +417,7 @@ async function InsertServiceVehicleHistory({ socket, redisHelpers, JobData }) {
|
||||
});
|
||||
}
|
||||
|
||||
async function HandlePostingError({ socket, redisHelpers, JobData, DMSTransHeader }) {
|
||||
async function HandlePostingError({ socket, redisHelpers, JobData /*, DMSTransHeader*/ }) {
|
||||
const DmsError = await QueryErrWip({ socket, redisHelpers, JobData });
|
||||
await DeleteWip({ socket, redisHelpers, JobData });
|
||||
|
||||
@@ -373,25 +426,30 @@ async function HandlePostingError({ socket, redisHelpers, JobData, DMSTransHeade
|
||||
await InsertFailedExportLog({ socket, JobData, error: errString });
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert app allocations to RR WIP lines.
|
||||
* Re-uses existing CalculateAllocations to keep parity with CDK/Fortellis.
|
||||
*
|
||||
* TODO: Confirm exact RR posting model (accounts, control numbers, company ids,
|
||||
* and whether amounts are signed or need separate debit/credit flags).
|
||||
*/
|
||||
async function GenerateTransWips({ socket, redisHelpers, JobData }) {
|
||||
// reuse the existing allocator
|
||||
const allocations = await CalculateAllocations(socket, JobData.id, true); // true==enable verbose logging
|
||||
const allocations = await CalculateAllocations(socket, JobData.id, true); // true==verbose logging
|
||||
const DMSTransHeader = await redisHelpers.getSessionTransactionData(
|
||||
socket.id,
|
||||
getTransactionType(JobData.id),
|
||||
RRCacheEnums.DMSTransHeader
|
||||
);
|
||||
|
||||
// Translate allocations -> RR WIP line shape later. For now: keep parity with Fortellis skeleton
|
||||
const wips = [];
|
||||
allocations.forEach((alloc) => {
|
||||
if (alloc.sale.getAmount() > 0 && !alloc.tax) {
|
||||
wips.push({
|
||||
acct: alloc.profitCenter.dms_acctnumber,
|
||||
cntl: alloc.profitCenter.dms_control_override || JobData.ro_number,
|
||||
postAmt: alloc.sale.multiply(-1).getAmount(),
|
||||
postAmt: alloc.sale.multiply(-1).getAmount(), // sale is a credit in many GLs; confirm RR sign
|
||||
transID: DMSTransHeader?.transID,
|
||||
trgtCoID: JobData.bodyshop?.cdk_configuration?.srcco
|
||||
trgtCoID: JobData.bodyshop?.cdk_configuration?.srcco // RR equivalent TBD
|
||||
});
|
||||
}
|
||||
if (alloc.cost.getAmount() > 0 && !alloc.tax) {
|
||||
@@ -426,11 +484,12 @@ async function GenerateTransWips({ socket, redisHelpers, JobData }) {
|
||||
getTransactionType(JobData.id),
|
||||
RRCacheEnums.txEnvelope
|
||||
);
|
||||
|
||||
txEnvelope?.payers?.forEach((payer) => {
|
||||
wips.push({
|
||||
acct: payer.dms_acctnumber,
|
||||
cntl: payer.controlnumber,
|
||||
postAmt: Math.round(payer.amount * 100),
|
||||
postAmt: Math.round(payer.amount * 100), // assuming cents (confirm RR units)
|
||||
transID: DMSTransHeader?.transID,
|
||||
trgtCoID: JobData.bodyshop?.cdk_configuration?.srcco
|
||||
});
|
||||
@@ -446,28 +505,37 @@ async function GenerateTransWips({ socket, redisHelpers, JobData }) {
|
||||
return wips;
|
||||
}
|
||||
|
||||
// --- DB logging mirrors Fortellis
|
||||
// -----------------------------------------------------------------------------
|
||||
// DB logging mirrors Fortellis (status + export log)
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
async function MarkJobExported({ socket, jobid, redisHelpers }) {
|
||||
const client = new GraphQLClient(process.env.GRAPHQL_ENDPOINT, {});
|
||||
const currentToken =
|
||||
(socket?.data && socket.data.authToken) || (socket?.handshake?.auth && socket.handshake.auth.token);
|
||||
|
||||
// Pull JobData from the session to get bodyshop info + default statuses
|
||||
const JobData =
|
||||
(await redisHelpers.getSessionTransactionData(socket.id, getTransactionType(jobid), RRCacheEnums.JobData)) || {};
|
||||
|
||||
const transWips = await redisHelpers.getSessionTransactionData(
|
||||
socket.id,
|
||||
getTransactionType(jobid),
|
||||
RRCacheEnums.transWips
|
||||
);
|
||||
|
||||
return client.setHeaders({ Authorization: `Bearer ${currentToken}` }).request(queries.MARK_JOB_EXPORTED, {
|
||||
jobId: jobid,
|
||||
job: {
|
||||
status: socket.JobData?.bodyshop?.md_ro_statuses?.default_exported || "Exported*",
|
||||
status: JobData?.bodyshop?.md_ro_statuses?.default_exported || "Exported*",
|
||||
date_exported: new Date()
|
||||
},
|
||||
log: {
|
||||
bodyshopid: socket.JobData?.bodyshop?.id,
|
||||
bodyshopid: JobData?.bodyshop?.id,
|
||||
jobid,
|
||||
successful: true,
|
||||
useremail: socket.user?.email,
|
||||
metadata: await redisHelpers.getSessionTransactionData(
|
||||
socket.id,
|
||||
getTransactionType(jobid),
|
||||
RRCacheEnums.transWips
|
||||
)
|
||||
metadata: transWips
|
||||
},
|
||||
bill: { exported: true, exported_at: new Date() }
|
||||
});
|
||||
@@ -478,6 +546,7 @@ async function InsertFailedExportLog({ socket, JobData, error }) {
|
||||
const client = new GraphQLClient(process.env.GRAPHQL_ENDPOINT, {});
|
||||
const currentToken =
|
||||
(socket?.data && socket.data.authToken) || (socket?.handshake?.auth && socket.handshake.auth.token);
|
||||
|
||||
return await client.setHeaders({ Authorization: `Bearer ${currentToken}` }).request(queries.INSERT_EXPORT_LOG, {
|
||||
log: {
|
||||
bodyshopid: JobData.bodyshop.id,
|
||||
|
||||
@@ -1,8 +1,49 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// Thin wrapper around the shared logger + a socket emitter,
|
||||
// mirroring the Fortellis logger shape for parity.
|
||||
//
|
||||
// Emits: "rr-log-event" { level, message, txnDetails }
|
||||
//
|
||||
// NOTE: Keep this lightweight and side-effect free—downstream flows rely on it
|
||||
// for both developer troubleshooting and UI progress messages.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
const logger = require("../utils/logger");
|
||||
|
||||
const CreateRRLogEvent = (socket, level, message, txnDetails) => {
|
||||
logger.log("rr-log-event", level, socket?.user?.email, null, { wsmessage: message, txnDetails });
|
||||
socket.emit("rr-log-event", { level, message, txnDetails });
|
||||
};
|
||||
/**
|
||||
* Emit a structured RR log event to both the central logger and (if present)
|
||||
* over the current socket connection for real-time UI visibility.
|
||||
*
|
||||
* @param {Object} socket - A Socket.IO socket OR an Express req (when reused in REST).
|
||||
* Expected fields if present:
|
||||
* - socket.user?.email (used as the "actor" for audit trails)
|
||||
* - socket.emit(...) method in live socket contexts
|
||||
* @param {"SILLY"|"DEBUG"|"INFO"|"WARN"|"ERROR"} level - Log severity
|
||||
* @param {string} message - Human readable message
|
||||
* @param {Object} [txnDetails] - Optional structured metadata (ids, payload snippets, timings)
|
||||
*/
|
||||
function CreateRRLogEvent(socket, level, message, txnDetails) {
|
||||
const userEmail = socket?.user?.email || "unknown";
|
||||
|
||||
// 1) Centralized app logger (goes to your sinks: console, Datadog, etc.)
|
||||
// Namespace: "rr-log-event" to keep provider logs grouped.
|
||||
try {
|
||||
logger.log("rr-log-event", level, userEmail, null, {
|
||||
wsmessage: message,
|
||||
txnDetails
|
||||
});
|
||||
} catch {
|
||||
// Best-effort: never throw from logging
|
||||
}
|
||||
|
||||
// 2) Realtime push to the UI if we're in a socket context
|
||||
try {
|
||||
if (typeof socket?.emit === "function") {
|
||||
socket.emit("rr-log-event", { level, message, txnDetails });
|
||||
}
|
||||
} catch {
|
||||
// Best-effort: never throw from logging
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = CreateRRLogEvent;
|
||||
|
||||
@@ -1,21 +1,58 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// Reynolds & Reynolds (RR) lookup helpers.
|
||||
// Uses MakeRRCall + RRActions from rr-helpers, and shared response validation
|
||||
// from rr-error.
|
||||
//
|
||||
// What’s still missing / to confirm against the Rome/RR PDFs:
|
||||
// - Final query param names and value formats for the “combined search”
|
||||
// (customer + vehicle), advisors directory, and parts lookup.
|
||||
// - Any RR-required headers (dealer/site/location ids) — add in rr-helpers
|
||||
// via the MakeRRCall default headers if needed.
|
||||
// - Final success envelope checks in assertRrOk.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
const { MakeRRCall, RRActions } = require("./rr-helpers");
|
||||
const { assertRrOk } = require("./rr-error");
|
||||
|
||||
/**
|
||||
* RR Combined Search (Customer + Vehicle).
|
||||
*
|
||||
* @param {Object} deps
|
||||
* @param {Socket|ExpressRequest} deps.socket
|
||||
* @param {Object} deps.redisHelpers
|
||||
* @param {string|number} deps.jobid - for correlation/logging only
|
||||
* @param {Array<[string,string]>} [deps.params=[]]
|
||||
* Example: [["vin", "1HGBH41JXMN109186"], ["lastName","DOE"]]
|
||||
* @returns {Promise<any>} RR response (envelope TBD)
|
||||
*/
|
||||
async function RrCombinedSearch({ socket, redisHelpers, jobid, params = [] }) {
|
||||
const data = await MakeRRCall({
|
||||
...RRActions.CombinedSearch, // add to RRActions
|
||||
requestSearchParams: params, // e.g., [["vin", "XXXX"], ["lastName","DOE"]]
|
||||
...RRActions.CombinedSearch, // GET /search/v1/customer-vehicle
|
||||
requestSearchParams: params,
|
||||
type: "get",
|
||||
redisHelpers,
|
||||
socket,
|
||||
jobid
|
||||
});
|
||||
|
||||
// allowEmpty=true because searches may legitimately return 0 rows
|
||||
return assertRrOk(data, { apiName: "RR Combined Search", allowEmpty: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* RR Get Advisors.
|
||||
*
|
||||
* @param {Object} deps
|
||||
* @param {Socket|ExpressRequest} deps.socket
|
||||
* @param {Object} deps.redisHelpers
|
||||
* @param {string|number} deps.jobid
|
||||
* @param {Array<[string,string]>} [deps.params=[]]
|
||||
* Example: [["active","true"]]
|
||||
* @returns {Promise<any>} RR response (envelope TBD)
|
||||
*/
|
||||
async function RrGetAdvisors({ socket, redisHelpers, jobid, params = [] }) {
|
||||
const data = await MakeRRCall({
|
||||
...RRActions.GetAdvisors, // add
|
||||
...RRActions.GetAdvisors, // GET /advisors/v1
|
||||
requestSearchParams: params,
|
||||
type: "get",
|
||||
redisHelpers,
|
||||
@@ -25,9 +62,20 @@ async function RrGetAdvisors({ socket, redisHelpers, jobid, params = [] }) {
|
||||
return assertRrOk(data, { apiName: "RR Get Advisors", allowEmpty: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* RR Get Parts.
|
||||
*
|
||||
* @param {Object} deps
|
||||
* @param {Socket|ExpressRequest} deps.socket
|
||||
* @param {Object} deps.redisHelpers
|
||||
* @param {string|number} deps.jobid
|
||||
* @param {Array<[string,string]>} [deps.params=[]]
|
||||
* Example: [["sku","ABC123"], ["page","1"], ["pageSize","50"]]
|
||||
* @returns {Promise<any>} RR response (envelope TBD)
|
||||
*/
|
||||
async function RrGetParts({ socket, redisHelpers, jobid, params = [] }) {
|
||||
const data = await MakeRRCall({
|
||||
...RRActions.GetParts, // add
|
||||
...RRActions.GetParts, // GET /parts/v1
|
||||
requestSearchParams: params,
|
||||
type: "get",
|
||||
redisHelpers,
|
||||
|
||||
@@ -1,43 +1,55 @@
|
||||
// server/rr/rr-mappers.js
|
||||
// -----------------------------------------------------------------------------
|
||||
// Centralized mapping & normalization for Reynolds & Reynolds (RR)
|
||||
//
|
||||
// NOTE: This is scaffolding intended to be completed against the RR XML/JSON
|
||||
// schemas in the Rome RR specs you dropped (Customer Insert/Update, Repair Order,
|
||||
// Service Vehicle, etc.). Field names below are placeholders where noted.
|
||||
// Fill the TODOs with the exact RR element/attribute names.
|
||||
// This is scaffolding aligned to the Rome RR PDFs you provided:
|
||||
//
|
||||
// Usage expectation from calling code (example you gave):
|
||||
// const { mapCustomerInsert, mapCustomerUpdate } = require("./rr-mappers");
|
||||
// const body = mapCustomerInsert(JobData);
|
||||
// const body = mapCustomerUpdate(existingCustomer, patch);
|
||||
// - Rome Customer Insert Specification 1.2.pdf
|
||||
// - Rome Customer Update Specification 1.2.pdf
|
||||
// - Rome Insert Service Vehicle Interface Specification.pdf
|
||||
// - Rome Create Body Shop Management Repair Order Interface Specification.pdf
|
||||
// - Rome Update Body Shop Management Repair Order Interface Specification.pdf
|
||||
// - Rome Get Advisors Specification.pdf
|
||||
// - Rome Get Part Specification.pdf
|
||||
// - Rome Search Customer Service Vehicle Combined Specification.pdf
|
||||
//
|
||||
// Replace all TODO:RR with exact element/attribute names and enumerations from
|
||||
// the PDFs above. The shapes here are intentionally close to other providers
|
||||
// so you can reuse upstream plumbing without surprises.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
const _ = require("lodash");
|
||||
const InstanceMgr = require("../utils/instanceMgr").default;
|
||||
|
||||
// Keep this consistent with other providers
|
||||
const replaceSpecialRegex = /[^a-zA-Z0-9 .,\n #]+/g;
|
||||
|
||||
// ---------- Generic helpers --------------------------------------------------
|
||||
|
||||
function sanitize(value) {
|
||||
if (value === null || value === undefined) return value;
|
||||
return String(value).replace(replaceSpecialRegex, "").trim();
|
||||
}
|
||||
|
||||
function normalizePostal(raw) {
|
||||
// Match Fortellis/CDK behavior for CA vs US formatting
|
||||
// (Use InstanceMgr profile detection already present in your codebase)
|
||||
return InstanceMgr({
|
||||
imex: raw && String(raw).toUpperCase().replace(/\W/g, "").replace(/(...)/, "$1 "),
|
||||
rome: raw
|
||||
});
|
||||
}
|
||||
|
||||
function asStringOrNull(value) {
|
||||
const s = sanitize(value);
|
||||
return s && s.length > 0 ? s : null;
|
||||
}
|
||||
|
||||
function toUpperOrNull(value) {
|
||||
const s = asStringOrNull(value);
|
||||
return s ? s.toUpperCase() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize postal/zip minimally; keep simple and provider-agnostic for now.
|
||||
* TODO:RR — If RR enforces specific postal formatting by country, implement it here.
|
||||
*/
|
||||
function normalizePostal(raw) {
|
||||
if (!raw) return null;
|
||||
return String(raw).trim();
|
||||
}
|
||||
|
||||
function mapPhones({ ph1, ph2, mobile }) {
|
||||
// TODO: Update to RR’s final phone structure and codes/types when wiring
|
||||
// TODO:RR — Replace "HOME|WORK|MOBILE" with RR's phone type codes + any flags (preferred, sms ok).
|
||||
const out = [];
|
||||
if (ph1) out.push({ number: sanitize(ph1), type: "HOME" });
|
||||
if (ph2) out.push({ number: sanitize(ph2), type: "WORK" });
|
||||
@@ -46,22 +58,24 @@ function mapPhones({ ph1, ph2, mobile }) {
|
||||
}
|
||||
|
||||
function mapEmails({ email }) {
|
||||
// RR often supports multiple emails; start with one.
|
||||
// TODO: Update per RR schema (email flags, preferred, etc.)
|
||||
// TODO:RR — If RR supports multiple with flags, expand (preferred, statement, etc.).
|
||||
if (!email) return [];
|
||||
return [{ address: sanitize(email), type: "PERSONAL" }];
|
||||
}
|
||||
|
||||
// ---------- Address/Contact from Rome JobData --------------------------------
|
||||
|
||||
function mapPostalAddressFromJob(job) {
|
||||
// Rome job-level owner fields (aligning to prior provider scaffolds)
|
||||
// Rome job-level owner fields (aligned with other providers)
|
||||
// TODO:RR — Confirm exact element names (e.g., AddressLine vs Street1, State vs Province).
|
||||
return {
|
||||
addressLine1: asStringOrNull(job.ownr_addr1),
|
||||
addressLine2: asStringOrNull(job.ownr_addr2),
|
||||
city: asStringOrNull(job.ownr_city),
|
||||
state: asStringOrNull(job.ownr_st || job.ownr_state),
|
||||
province: asStringOrNull(job.ownr_province), // keep both for CA use-cases if distinct in RR
|
||||
postalCode: normalizePostal(job.ownr_zip),
|
||||
country: asStringOrNull(job.ownr_ctry) || "USA", // default, adjust as needed
|
||||
province: asStringOrNull(job.ownr_st) // keep both state/province fields for CA cases
|
||||
country: asStringOrNull(job.ownr_ctry) || "USA"
|
||||
};
|
||||
}
|
||||
|
||||
@@ -77,93 +91,97 @@ function mapEmailsFromJob(job) {
|
||||
return mapEmails({ email: job.ownr_ea });
|
||||
}
|
||||
|
||||
// ---------- Customer mappers --------------------------------------------------
|
||||
|
||||
/**
|
||||
* Customer Insert
|
||||
* Matches your call site:
|
||||
* const body = mapCustomerInsert(JobData)
|
||||
* Matches call-site: const body = mapCustomerInsert(JobData);
|
||||
*
|
||||
* Return shape intentionally mirrors Fortellis scaffolding so the same
|
||||
* MakeRRCall pipeline can be reused. Replace placeholders with the RR spec’s
|
||||
* request envelope/element names (e.g., CustomerInsertRq, CustomerRq, etc.).
|
||||
* TODO:RR — Replace envelope and field names with exact RR schema:
|
||||
* e.g., CustomerInsertRq.Customer (Organization vs Person), name blocks, ids, codes, etc.
|
||||
*/
|
||||
function mapCustomerInsert(job) {
|
||||
const isCompany = Boolean(job.ownr_co_nm && job.ownr_co_nm.trim() !== "");
|
||||
const isCompany = Boolean(job?.ownr_co_nm && job.ownr_co_nm.trim() !== "");
|
||||
|
||||
// Skeleton payload — replace keys under CustomerInsertRq with the actual RR names
|
||||
return {
|
||||
// Example envelope — rename to match the PDF (e.g., "CustomerInsertRq")
|
||||
CustomerInsertRq: {
|
||||
// TODO: Confirm RR element/attribute names from spec PDFs
|
||||
// High-level type — confirm the exact enum RR expects.
|
||||
customerType: isCompany ? "ORGANIZATION" : "INDIVIDUAL",
|
||||
|
||||
// Name block — ensure RR's exact element names and casing.
|
||||
customerName: {
|
||||
companyName: asStringOrNull(job.ownr_co_nm)?.toUpperCase() || null,
|
||||
firstName: isCompany ? null : asStringOrNull(job.ownr_fn)?.toUpperCase(),
|
||||
lastName: isCompany ? null : asStringOrNull(job.ownr_ln)?.toUpperCase()
|
||||
companyName: isCompany ? toUpperOrNull(job.ownr_co_nm) : null,
|
||||
firstName: isCompany ? null : toUpperOrNull(job.ownr_fn),
|
||||
lastName: isCompany ? null : toUpperOrNull(job.ownr_ln)
|
||||
},
|
||||
|
||||
// Mailing address
|
||||
postalAddress: mapPostalAddressFromJob(job),
|
||||
|
||||
// Contacts
|
||||
contactMethods: {
|
||||
phones: mapPhonesFromJob(job),
|
||||
emailAddresses: mapEmailsFromJob(job)
|
||||
}
|
||||
// Optional / placeholders for future fields in RR spec
|
||||
|
||||
// TODO:RR — Common optional fields: tax/resale codes, pricing flags, AR terms, source codes, etc.
|
||||
// taxCode: null,
|
||||
// groupCode: null,
|
||||
// dealerFields: []
|
||||
// termsCode: null,
|
||||
// marketingOptIn: null,
|
||||
// dealerSpecificFields: []
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Customer Update
|
||||
* Matches your call site:
|
||||
* const body = mapCustomerUpdate(existingCustomer, patch)
|
||||
* Matches call-site: const body = mapCustomerUpdate(existingCustomer, patch);
|
||||
*
|
||||
* - existingCustomer: prior RR customer payload/shape (from RR Read/Query)
|
||||
* - patch: minimal delta from UI/Job selections to overlay onto the RR model
|
||||
* - existingCustomer: RR's current representation (from Read/Query)
|
||||
* - patch: a thin delta from UI/Job selection
|
||||
*
|
||||
* We return a merged/normalized payload for RR Update.
|
||||
* TODO:RR — Swap envelope/fields for RR's specific Update schema.
|
||||
*/
|
||||
function mapCustomerUpdate(existingCustomer, patch = {}) {
|
||||
// NOTE:
|
||||
// 1) We assume existingCustomer already resembles RR’s stored shape.
|
||||
// 2) We overlay patch fields into that shape, then project to the
|
||||
// RR Update request envelope.
|
||||
// 3) Replace inner keys with exact RR Update schema element names.
|
||||
|
||||
const merged = _.merge({}, existingCustomer || {}, patch || {});
|
||||
const id = merged?.customerId || merged?.id || merged?.CustomerId || merged?.customer?.id || null;
|
||||
|
||||
// Derive a normalized name object from merged data (handles org/person)
|
||||
const isCompany = Boolean(merged?.customerName?.companyName) || Boolean(merged?.companyName) || false;
|
||||
const isCompany = Boolean(merged?.customerName?.companyName) || Boolean(merged?.companyName);
|
||||
|
||||
const normalizedName = {
|
||||
companyName: asStringOrNull(merged?.customerName?.companyName) || asStringOrNull(merged?.companyName),
|
||||
firstName: isCompany ? null : asStringOrNull(merged?.customerName?.firstName) || asStringOrNull(merged?.firstName),
|
||||
lastName: isCompany ? null : asStringOrNull(merged?.customerName?.lastName) || asStringOrNull(merged?.lastName)
|
||||
companyName: asStringOrNull(merged?.customerName?.companyName) || asStringOrNull(merged?.companyName) || null,
|
||||
firstName: isCompany
|
||||
? null
|
||||
: asStringOrNull(merged?.customerName?.firstName) || asStringOrNull(merged?.firstName) || null,
|
||||
lastName: isCompany
|
||||
? null
|
||||
: asStringOrNull(merged?.customerName?.lastName) || asStringOrNull(merged?.lastName) || null
|
||||
};
|
||||
|
||||
const normalizedAddress = {
|
||||
addressLine1: asStringOrNull(merged?.postalAddress?.addressLine1) || asStringOrNull(merged?.addressLine1),
|
||||
addressLine2: asStringOrNull(merged?.postalAddress?.addressLine2) || asStringOrNull(merged?.addressLine2),
|
||||
city: asStringOrNull(merged?.postalAddress?.city) || asStringOrNull(merged?.city),
|
||||
addressLine1: asStringOrNull(merged?.postalAddress?.addressLine1) || asStringOrNull(merged?.addressLine1) || null,
|
||||
addressLine2: asStringOrNull(merged?.postalAddress?.addressLine2) || asStringOrNull(merged?.addressLine2) || null,
|
||||
city: asStringOrNull(merged?.postalAddress?.city) || asStringOrNull(merged?.city) || null,
|
||||
state:
|
||||
asStringOrNull(merged?.postalAddress?.state) ||
|
||||
asStringOrNull(merged?.state) ||
|
||||
asStringOrNull(merged?.stateOrProvince) ||
|
||||
asStringOrNull(merged?.province),
|
||||
asStringOrNull(merged?.province) ||
|
||||
null,
|
||||
province: asStringOrNull(merged?.postalAddress?.province) || asStringOrNull(merged?.province) || null,
|
||||
postalCode: normalizePostal(merged?.postalAddress?.postalCode || merged?.postalCode),
|
||||
country: asStringOrNull(merged?.postalAddress?.country) || asStringOrNull(merged?.country) || "USA",
|
||||
province: asStringOrNull(merged?.postalAddress?.province) || asStringOrNull(merged?.province)
|
||||
country: asStringOrNull(merged?.postalAddress?.country) || asStringOrNull(merged?.country) || "USA"
|
||||
};
|
||||
|
||||
// Phones
|
||||
// Contacts (reuse existing unless patch supplied a new structure upstream)
|
||||
const normalizedPhones = merged?.contactMethods?.phones || merged?.phones || [];
|
||||
|
||||
// Emails
|
||||
const normalizedEmails = merged?.contactMethods?.emailAddresses || merged?.emailAddresses || [];
|
||||
|
||||
return {
|
||||
// Example envelope — rename to match the PDF (e.g., "CustomerUpdateRq")
|
||||
CustomerUpdateRq: {
|
||||
// TODO: Confirm exact RR element/attribute names for update
|
||||
customerId: id,
|
||||
customerType: normalizedName.companyName ? "ORGANIZATION" : "INDIVIDUAL",
|
||||
customerName: normalizedName,
|
||||
@@ -172,77 +190,144 @@ function mapCustomerUpdate(existingCustomer, patch = {}) {
|
||||
phones: normalizedPhones,
|
||||
emailAddresses: normalizedEmails
|
||||
}
|
||||
// Optional change tracking fields, timestamps, etc., per RR spec can go here
|
||||
// TODO:RR — include fields that RR requires for update (version, hash, lastUpdatedTs, etc.)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/* ===== Additional mappers (scaffolding for upcoming work) ===== */
|
||||
// ---------- Vehicle mappers ---------------------------------------------------
|
||||
|
||||
/**
|
||||
* Vehicle Insert from JobData
|
||||
* Called (or call-able) by InsertVehicle.
|
||||
*
|
||||
* TODO:RR — Replace envelope/field names with the exact RR vehicle schema.
|
||||
*/
|
||||
function mapVehicleInsertFromJob(job, txEnvelope = {}) {
|
||||
// TODO: Replace with RR Service Vehicle Insert schema
|
||||
return {
|
||||
ServiceVehicleInsertRq: {
|
||||
vin: asStringOrNull(job.v_vin),
|
||||
// Year/make/model — validate source fields vs RR required fields
|
||||
year: job.v_model_yr || null,
|
||||
make: txEnvelope.dms_make || asStringOrNull(job.v_make),
|
||||
model: txEnvelope.dms_model || asStringOrNull(job.v_model),
|
||||
odometer: txEnvelope.kmout || null,
|
||||
licensePlate: job.plate_no && /\w/.test(job.plate_no) ? asStringOrNull(job.plate_no).toUpperCase() : null
|
||||
make: toUpperOrNull(txEnvelope.dms_make || job.v_make),
|
||||
model: toUpperOrNull(txEnvelope.dms_model || job.v_model),
|
||||
// Mileage/odometer — confirm units/element names
|
||||
odometer: txEnvelope.kmout || txEnvelope.miout || null,
|
||||
// Plate — uppercase and sanitize
|
||||
licensePlate: job.plate_no ? toUpperOrNull(job.plate_no) : null
|
||||
// TODO:RR — owner/customer link, color, trim, fuel, DRIVETRAIN, etc.
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function mapRepairOrderAddFromJob(job) {
|
||||
// TODO: Replace with RR RepairOrder Add schema (headers, lines, taxes)
|
||||
// ---------- Repair Order mappers ---------------------------------------------
|
||||
|
||||
/**
|
||||
* Create Repair Order
|
||||
* Matches call-site: mapRepairOrderCreate({ JobData, txEnvelope })
|
||||
*
|
||||
* TODO:RR — Use the exact request envelope/fields for Create RO from the PDF:
|
||||
* Header (customer/vehicle/ro-no/dates), lines/labors/parts/taxes, totals.
|
||||
*/
|
||||
function mapRepairOrderCreate({ JobData, txEnvelope }) {
|
||||
return {
|
||||
RepairOrderAddRq: {
|
||||
customerId: job.customer?.id || null,
|
||||
vehicleId: job.vehicle?.id || null,
|
||||
referenceNumber: asStringOrNull(job.ro_number),
|
||||
openedAt: job.actual_in || null,
|
||||
closedAt: job.invoice_date || null
|
||||
// lines: job.joblines?.map(mapJobLineToRRLine),
|
||||
// taxes: mapTaxes(job),
|
||||
// payments: mapPayments(job)
|
||||
RepairOrderCreateRq: {
|
||||
// Header
|
||||
referenceNumber: asStringOrNull(JobData.ro_number),
|
||||
customerId: JobData?.customer?.id || null, // supply from previous step or selection
|
||||
vehicleId: JobData?.vehicle?.id || null, // supply from previous step
|
||||
openedAt: JobData?.actual_in || null, // confirm expected datetime format
|
||||
promisedAt: JobData?.promise_date || null,
|
||||
advisorId: txEnvelope?.advisorId || null,
|
||||
|
||||
// Lines (placeholder)
|
||||
lines: Array.isArray(JobData?.joblines) ? JobData.joblines.map(mapJobLineToRRLine) : [],
|
||||
|
||||
// Taxes (placeholder)
|
||||
taxes: mapTaxes(JobData),
|
||||
|
||||
// Payments (placeholder)
|
||||
payments: mapPayments(txEnvelope)
|
||||
|
||||
// TODO:RR — add required flags, shop supplies, labor matrix, discounts, etc.
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function mapRepairOrderChangeFromJob(job) {
|
||||
// TODO: Replace with RR RepairOrder Update schema
|
||||
/**
|
||||
* Update Repair Order
|
||||
* Matches call-site: mapRepairOrderUpdate({ JobData, txEnvelope })
|
||||
*
|
||||
* TODO:RR — RR may want delta format (change set) vs full replace.
|
||||
* Add versioning/concurrency tokens if specified in the PDF.
|
||||
*/
|
||||
function mapRepairOrderUpdate({ JobData, txEnvelope }) {
|
||||
return {
|
||||
RepairOrderChgRq: {
|
||||
repairOrderId: job.id,
|
||||
referenceNumber: asStringOrNull(job.ro_number)
|
||||
// delta lines, amounts, status, etc.
|
||||
RepairOrderUpdateRq: {
|
||||
repairOrderId: JobData?.id || txEnvelope?.repairOrderId || null,
|
||||
referenceNumber: asStringOrNull(JobData?.ro_number),
|
||||
|
||||
// Example: only pass changed lines (you may need your diff before mapping)
|
||||
// For scaffolding, we pass what we have; replace with proper deltas later.
|
||||
lines: Array.isArray(JobData?.joblines) ? JobData.joblines.map(mapJobLineToRRLine) : [],
|
||||
|
||||
taxes: mapTaxes(JobData),
|
||||
payments: mapPayments(txEnvelope)
|
||||
|
||||
// TODO:RR — include RO status transitions, close/invoice flags, etc.
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/* Example line mapper (placeholder) */
|
||||
/* ----- Line/Tax/Payment helpers (placeholders) ----------------------------- */
|
||||
|
||||
function mapJobLineToRRLine(line) {
|
||||
// TODO:RR — Replace with RR RO line schema (labor/part/misc line types, op-code, flags).
|
||||
return {
|
||||
// TODO: set RR fields
|
||||
seq: line.sequence || null,
|
||||
opCode: line.opCode || null,
|
||||
description: asStringOrNull(line.description),
|
||||
qty: line.part_qty || null,
|
||||
price: line.price || null
|
||||
lineType: line?.type || "LABOR", // e.g., LABOR | PART | MISC
|
||||
sequence: line?.sequence || null,
|
||||
opCode: line?.opCode || line?.opcode || null,
|
||||
description: asStringOrNull(line?.description || line?.descr),
|
||||
quantity: line?.part_qty || line?.qty || 1,
|
||||
unitPrice: line?.price || line?.unitPrice || null,
|
||||
extendedAmount: line?.ext || null
|
||||
};
|
||||
}
|
||||
|
||||
function mapTaxes(job) {
|
||||
// TODO:RR — Implement per RR tax structure (rates by jurisdiction, taxable flags, rounding rules).
|
||||
// Return empty array as scaffolding.
|
||||
return [];
|
||||
}
|
||||
|
||||
function mapPayments(txEnvelope = {}) {
|
||||
// TODO:RR — Implement per RR payment shape (payer types, amounts, reference ids)
|
||||
// For Fortellis/CDK parity, txEnvelope.payers often exists; adapt to RR fields.
|
||||
if (!Array.isArray(txEnvelope?.payers)) return [];
|
||||
return txEnvelope.payers.map((p) => ({
|
||||
payerType: p.type || "INSURER", // e.g., CUSTOMER | INSURER | WARRANTY
|
||||
reference: asStringOrNull(p.controlnumber || p.ref),
|
||||
amount: p.amount != null ? Number(p.amount) : null
|
||||
}));
|
||||
}
|
||||
|
||||
// ---------- Exports -----------------------------------------------------------
|
||||
|
||||
module.exports = {
|
||||
// Required by your current calling code:
|
||||
// Used by current call-sites:
|
||||
mapCustomerInsert,
|
||||
mapCustomerUpdate,
|
||||
mapRepairOrderCreate,
|
||||
mapRepairOrderUpdate,
|
||||
|
||||
// Extra scaffolds we’ll likely use right after:
|
||||
// Extra scaffolds you’ll likely use soon:
|
||||
mapVehicleInsertFromJob,
|
||||
mapRepairOrderAddFromJob,
|
||||
mapRepairOrderChangeFromJob,
|
||||
mapJobLineToRRLine,
|
||||
mapTaxes,
|
||||
mapPayments,
|
||||
|
||||
// low-level utils (export if you want to reuse in tests)
|
||||
// Low-level utils (handy in tests)
|
||||
_sanitize: sanitize,
|
||||
_normalizePostal: normalizePostal
|
||||
_normalizePostal: normalizePostal,
|
||||
_toUpperOrNull: toUpperOrNull
|
||||
};
|
||||
|
||||
@@ -1,28 +1,75 @@
|
||||
// server/rr/rr-repair-orders.js
|
||||
// -----------------------------------------------------------------------------
|
||||
// RR Repair Order create/update wired through MakeRRCall.
|
||||
// Mapping comes from rr-mappers.js and response validation via rr-error.js.
|
||||
//
|
||||
// What’s still missing (complete when you wire to the PDFs):
|
||||
// - Final RR request envelopes & field names in rr-mappers.js
|
||||
// (Create: “RepairOrderAddRq”, Update: “RepairOrderChgRq”, etc.)
|
||||
// - Definitive success/error envelope checks in rr-error.js (assertRrOk)
|
||||
// - Any RR-required headers (dealer/tenant/site/location ids) in rr-helpers
|
||||
// - If RR requires path params for update (e.g., /repair-orders/{id}),
|
||||
// either add requestPathParams here or move id into RRActions.UpdateRepairOrder
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
const { MakeRRCall, RRActions } = require("./rr-helpers");
|
||||
const { assertRrOk } = require("./rr-error");
|
||||
const { mapRepairOrderCreate, mapRepairOrderUpdate } = require("./rr-mappers");
|
||||
const { mapRepairOrderAddFromJob, mapRepairOrderChangeFromJob } = require("./rr-mappers");
|
||||
|
||||
/**
|
||||
* Create a Repair Order in RR.
|
||||
*
|
||||
* @param {Object} deps
|
||||
* @param {Socket|ExpressRequest} deps.socket
|
||||
* @param {Object} deps.redisHelpers
|
||||
* @param {Object} deps.JobData - Rome job (used for mapping)
|
||||
* @param {Object} deps.txEnvelope - Posting/GL context if needed in mapping
|
||||
* @returns {Promise<any>} - RR response (envelope TBD)
|
||||
*/
|
||||
async function CreateRepairOrder({ socket, redisHelpers, JobData, txEnvelope }) {
|
||||
const body = mapRepairOrderCreate({ JobData, txEnvelope });
|
||||
// Map JobData (+ optional txEnvelope) -> RR "Repair Order Add" request body
|
||||
const body = mapRepairOrderAddFromJob({ ...JobData, txEnvelope });
|
||||
|
||||
const data = await MakeRRCall({
|
||||
...RRActions.CreateRepairOrder, // add this entry to RRActions (POST /repair-orders)
|
||||
...RRActions.CreateRepairOrder, // POST /repair-orders/v1
|
||||
body,
|
||||
redisHelpers,
|
||||
socket,
|
||||
jobid: JobData.id
|
||||
jobid: JobData?.id
|
||||
});
|
||||
|
||||
// TODO: Update assertRrOk once RR’s success envelope is finalized
|
||||
return assertRrOk(data, { apiName: "RR Create Repair Order" });
|
||||
}
|
||||
|
||||
async function UpdateRepairOrder({ socket, redisHelpers, JobData, txEnvelope }) {
|
||||
const body = mapRepairOrderUpdate({ JobData, txEnvelope });
|
||||
/**
|
||||
* Update a Repair Order in RR.
|
||||
*
|
||||
* NOTE: If RR requires the repair order id in the URL (PUT /repair-orders/{id}),
|
||||
* pass it via requestPathParams here once you have it:
|
||||
* requestPathParams: repairOrderId
|
||||
* and ensure RRActions.UpdateRepairOrder.url ends with a trailing slash.
|
||||
*
|
||||
* @param {Object} deps
|
||||
* @param {Socket|ExpressRequest} deps.socket
|
||||
* @param {Object} deps.redisHelpers
|
||||
* @param {Object} deps.JobData - Rome job (used for mapping)
|
||||
* @param {Object} deps.txEnvelope - Posting/GL context if needed in mapping
|
||||
* @param {string|number} [deps.repairOrderId] - If RR expects a path param
|
||||
* @returns {Promise<any>} - RR response (envelope TBD)
|
||||
*/
|
||||
async function UpdateRepairOrder({ socket, redisHelpers, JobData, txEnvelope, repairOrderId }) {
|
||||
const body = mapRepairOrderChangeFromJob({ ...JobData, txEnvelope });
|
||||
|
||||
const data = await MakeRRCall({
|
||||
...RRActions.UpdateRepairOrder, // add this entry (PUT /repair-orders/{id})
|
||||
...RRActions.UpdateRepairOrder, // PUT /repair-orders/v1 (or /v1/{id})
|
||||
...(repairOrderId ? { requestPathParams: String(repairOrderId) } : {}),
|
||||
body,
|
||||
redisHelpers,
|
||||
socket,
|
||||
jobid: JobData.id
|
||||
jobid: JobData?.id
|
||||
});
|
||||
|
||||
return assertRrOk(data, { apiName: "RR Update Repair Order" });
|
||||
}
|
||||
|
||||
|
||||
@@ -321,111 +321,9 @@ const applyRedisHelpers = ({ pubClient, app, logger }) => {
|
||||
}
|
||||
};
|
||||
|
||||
// NOTE: The following code was written for an abandoned branch and things have changes since the,
|
||||
// Leaving it here for demonstration purposes, commenting it out so it does not get used
|
||||
const setProviderCache = (ns, field, value, ttl) => setSessionData(`${ns}:provider`, field, value, ttl);
|
||||
|
||||
// Store multiple session data in Redis
|
||||
// const setMultipleSessionData = async (socketId, keyValues) => {
|
||||
// try {
|
||||
// // keyValues is expected to be an object { key1: value1, key2: value2, ... }
|
||||
// const entries = Object.entries(keyValues).map(([key, value]) => [key, JSON.stringify(value)]);
|
||||
// await pubClient.hset(`socket:${socketId}`, ...entries.flat());
|
||||
// } catch (error) {
|
||||
// logger.log(`Error Setting Multiple Session Data for socket ${socketId}: ${error}`, "ERROR", "redis");
|
||||
// }
|
||||
// };
|
||||
|
||||
// Retrieve multiple session data from Redis
|
||||
// const getMultipleSessionData = async (socketId, keys) => {
|
||||
// try {
|
||||
// const data = await pubClient.hmget(`socket:${socketId}`, keys);
|
||||
// // Redis returns an object with null values for missing keys, so we parse the non-null ones
|
||||
// return Object.fromEntries(keys.map((key, index) => [key, data[index] ? JSON.parse(data[index]) : null]));
|
||||
// } catch (error) {
|
||||
// logger.log(`Error Getting Multiple Session Data for socket ${socketId}: ${error}`, "ERROR", "redis");
|
||||
// }
|
||||
// };
|
||||
|
||||
// const setMultipleFromArraySessionData = async (socketId, keyValueArray) => {
|
||||
// try {
|
||||
// // Use Redis multi/pipeline to batch the commands
|
||||
// const multi = pubClient.multi();
|
||||
// keyValueArray.forEach(([key, value]) => {
|
||||
// multi.hset(`socket:${socketId}`, key, JSON.stringify(value));
|
||||
// });
|
||||
// await multi.exec(); // Execute all queued commands
|
||||
// } catch (error) {
|
||||
// logger.log(`Error Setting Multiple Session Data for socket ${socketId}: ${error}`, "ERROR", "redis");
|
||||
// }
|
||||
// };
|
||||
|
||||
// Helper function to add an item to the end of the Redis list
|
||||
// const addItemToEndOfList = async (socketId, key, newItem) => {
|
||||
// try {
|
||||
// await pubClient.rpush(`socket:${socketId}:${key}`, JSON.stringify(newItem));
|
||||
// } catch (error) {
|
||||
// let userEmail = "unknown";
|
||||
// let socketMappings = {};
|
||||
// try {
|
||||
// const userData = await getSessionData(socketId, "user");
|
||||
// if (userData && userData.email) {
|
||||
// userEmail = userData.email;
|
||||
// socketMappings = await getUserSocketMapping(userEmail);
|
||||
// }
|
||||
// } catch (sessionError) {
|
||||
// logger.log(`Failed to fetch session data for socket ${socketId}: ${sessionError}`, "ERROR", "redis");
|
||||
// }
|
||||
// const mappingString = JSON.stringify(socketMappings, null, 2);
|
||||
// const errorMessage = `Error adding item to the end of the list for socket ${socketId}: ${error}. User: ${userEmail}, Socket Mappings: ${mappingString}`;
|
||||
// logger.log(errorMessage, "ERROR", "redis");
|
||||
// }
|
||||
// };
|
||||
|
||||
// Helper function to add an item to the beginning of the Redis list
|
||||
// const addItemToBeginningOfList = async (socketId, key, newItem) => {
|
||||
// try {
|
||||
// await pubClient.lpush(`socket:${socketId}:${key}`, JSON.stringify(newItem));
|
||||
// } catch (error) {
|
||||
// logger.log(`Error adding item to the beginning of the list for socket ${socketId}: ${error}`, "ERROR", "redis");
|
||||
// }
|
||||
// };
|
||||
|
||||
// Helper function to clear a list in Redis
|
||||
// const clearList = async (socketId, key) => {
|
||||
// try {
|
||||
// await pubClient.del(`socket:${socketId}:${key}`);
|
||||
// } catch (error) {
|
||||
// logger.log(`Error clearing list for socket ${socketId}: ${error}`, "ERROR", "redis");
|
||||
// }
|
||||
// };
|
||||
|
||||
// Add methods to manage room users
|
||||
// const addUserToRoom = async (room, user) => {
|
||||
// try {
|
||||
// await pubClient.sadd(room, JSON.stringify(user));
|
||||
// } catch (error) {
|
||||
// logger.log(`Error adding user to room ${room}: ${error}`, "ERROR", "redis");
|
||||
// }
|
||||
// };
|
||||
|
||||
// Remove users from room
|
||||
// const removeUserFromRoom = async (room, user) => {
|
||||
// try {
|
||||
// await pubClient.srem(room, JSON.stringify(user));
|
||||
// } catch (error) {
|
||||
// logger.log(`Error removing user to room ${room}: ${error}`, "ERROR", "redis");
|
||||
// }
|
||||
// };
|
||||
|
||||
// Get Users in room
|
||||
// const getUsersInRoom = async (room) => {
|
||||
// try {
|
||||
// const users = await pubClient.smembers(room);
|
||||
// return users.map((user) => JSON.parse(user));
|
||||
// } catch (error) {
|
||||
// logger.log(`Error getting users in room ${room}: ${error}`, "ERROR", "redis");
|
||||
// }
|
||||
// };
|
||||
const getProviderCache = (ns, field) => getSessionData(`${ns}:provider`, field);
|
||||
|
||||
const api = {
|
||||
getUserSocketMappingKey,
|
||||
@@ -442,16 +340,9 @@ const applyRedisHelpers = ({ pubClient, app, logger }) => {
|
||||
updateOrInvalidateBodyshopFromRedis,
|
||||
setSessionTransactionData,
|
||||
getSessionTransactionData,
|
||||
clearSessionTransactionData
|
||||
// setMultipleSessionData,
|
||||
// getMultipleSessionData,
|
||||
// setMultipleFromArraySessionData,
|
||||
// addItemToEndOfList,
|
||||
// addItemToBeginningOfList,
|
||||
// clearList,
|
||||
// addUserToRoom,
|
||||
// removeUserFromRoom,
|
||||
// getUsersInRoom,
|
||||
clearSessionTransactionData,
|
||||
setProviderCache,
|
||||
getProviderCache
|
||||
};
|
||||
|
||||
Object.assign(module.exports, api);
|
||||
|
||||
@@ -44,6 +44,12 @@ const redisSocketEvents = ({
|
||||
socket.bodyshopId = bodyshopId;
|
||||
socket.data = socket.data || {};
|
||||
socket.data.authToken = token;
|
||||
|
||||
// Used to update legacy sockets
|
||||
if (socket.handshake?.auth) {
|
||||
socket.handshake.auth.token = token;
|
||||
socket.handshake.auth.bodyshopId = bodyshopId;
|
||||
}
|
||||
await addUserSocketMapping(user.email, socket.id, bodyshopId);
|
||||
next();
|
||||
} catch (error) {
|
||||
@@ -418,6 +424,38 @@ const redisSocketEvents = ({
|
||||
socket.on("rr-get-parts", async ({ jobid, params }, cb) => {
|
||||
// similar pattern using RrGetParts
|
||||
});
|
||||
|
||||
socket.on("rr-get-advisors", async ({ jobid, params }, cb) => {
|
||||
try {
|
||||
const { RrGetAdvisors } = require("../rr/rr-lookup");
|
||||
const data = await RrGetAdvisors({
|
||||
socket,
|
||||
redisHelpers: { setSessionTransactionData, getSessionTransactionData },
|
||||
jobid,
|
||||
params
|
||||
});
|
||||
cb?.(data);
|
||||
} catch (e) {
|
||||
RRLogger(socket, "error", `RR get advisors error: ${e.message}`);
|
||||
cb?.(null);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("rr-get-parts", async ({ jobid, params }, cb) => {
|
||||
try {
|
||||
const { RrGetParts } = require("../rr/rr-lookup");
|
||||
const data = await RrGetParts({
|
||||
socket,
|
||||
redisHelpers: { setSessionTransactionData, getSessionTransactionData },
|
||||
jobid,
|
||||
params
|
||||
});
|
||||
cb?.(data);
|
||||
} catch (e) {
|
||||
RRLogger(socket, "error", `RR get parts error: ${e.message}`);
|
||||
cb?.(null);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Call Handlers
|
||||
|
||||
Reference in New Issue
Block a user