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

This commit is contained in:
Dave
2025-10-07 16:45:06 -04:00
parent c149d457e7
commit 2ffc4b81f4
28 changed files with 2594 additions and 1642 deletions

View File

@@ -14,6 +14,7 @@ import FormListMoveArrows from "../form-list-move-arrows/form-list-move-arrows.c
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
import RbacWrapper from "../rbac-wrapper/rbac-wrapper.component";
import ShopInfoResponsibilitycentersTaxesComponent from "./shop-info.responsibilitycenters.taxes.component";
import ShopInfoRRConfigurationComponent from "./shop-info.rr-configuration.component";
const SelectorDiv = styled.div`
.ant-form-item .ant-select {
@@ -63,6 +64,8 @@ export function ShopInfoResponsibilityCenterComponent({ bodyshop, form }) {
<RbacWrapper action="shop:responsibilitycenter">
{(bodyshop.cdk_dealerid || bodyshop.pbs_serialnumber || bodyshop.rr_dealerid) && (
<>
{bodyshop.rr_dealerid && <ShopInfoRRConfigurationComponent form={form} />}
{bodyshop.cdk_dealerid && (
<DataLabel label={t("bodyshop.labels.dms.cdk_dealerid")}>{form.getFieldValue("cdk_dealerid")}</DataLabel>
)}

View File

@@ -0,0 +1,130 @@
import { Card, Divider, Form, Input, Select, Switch, Tooltip } from "antd";
const { Option } = Select;
/**
* Reynolds & Reynolds Configuration Section
* Stored under bodyshop.rr_configuration (JSONB)
*
* NOTE:
* - Do NOT put credentials/endpoints here. Those live in env secrets.
* - These are dealer/location-specific values that the backend reads from rr_configuration.
* Keys match server usage: dealer_code, dealer_name, location_id, store_number, branch_number, etc.
*/
export default function ShopInfoRRConfigurationComponent() {
return (
<Card title="Reynolds & Reynolds Configuration" variant={false}>
{/* Dealer / Location identifiers (dealer-specific, not secrets) */}
<Form.Item
label="Dealer Code"
name={["rr_configuration", "dealer_code"]}
tooltip="Your RR Dealer Code / Dealer Number provided in the welcome kit."
rules={[{ required: true, message: "Dealer Code is required for RR integration" }]}
>
<Input placeholder="e.g. 12345" />
</Form.Item>
<Form.Item
label="Dealer Name"
name={["rr_configuration", "dealer_name"]}
tooltip="Optional display name for this dealer as it should appear on outbound requests."
>
<Input placeholder="e.g. Rome Collision Centre" />
</Form.Item>
<Form.Item
label="Location ID"
name={["rr_configuration", "location_id"]}
tooltip="If your RR account uses Location/Branch identifiers, enter the Location ID here."
>
<Input placeholder="e.g. 01" />
</Form.Item>
<Form.Item
label="Store Number"
name={["rr_configuration", "store_number"]}
tooltip="Optional: RR Store # (from welcome kit)."
>
<Input placeholder="e.g. 0001" />
</Form.Item>
<Form.Item
label="Branch Number"
name={["rr_configuration", "branch_number"]}
tooltip="Optional: RR Branch # (from welcome kit)."
>
<Input placeholder="e.g. 10" />
</Form.Item>
<Form.Item
label="Default Advisor ID"
name={["rr_configuration", "default_advisor_id"]}
tooltip="Default Service Advisor to assign on RO export (can be overridden per export)."
>
<Input placeholder="e.g. 007" />
</Form.Item>
<Divider />
{/* Feature flags (safe to store in config) */}
<Form.Item
label="Enable RR Integration"
name={["rr_configuration", "enable_rr_integration"]}
valuePropName="checked"
tooltip="Master switch to enable/disable RR export for this shop."
>
<Switch />
</Form.Item>
<Form.Item
label="Sandbox Mode"
name={["rr_configuration", "sandbox_mode"]}
valuePropName="checked"
tooltip="Toggles sandbox behavior on the app side. Credentials and URLs remain in env secrets."
>
<Switch />
</Form.Item>
<Form.Item
label="Log XML Requests/Responses"
name={["rr_configuration", "log_xml"]}
valuePropName="checked"
tooltip="When enabled, request/response XML is logged (masked where applicable)."
>
<Switch />
</Form.Item>
<Divider />
{/* Optional UX/format defaults */}
<Form.Item
label="Default RO Prefix"
name={["rr_configuration", "default_ro_prefix"]}
tooltip="Optional Repair Order prefix used when generating RO numbers."
>
<Input placeholder="e.g. RO" />
</Form.Item>
<Form.Item
label={
<span>
Timezone{" "}
<Tooltip title="Used for date/time fields when building RR payloads.">
<span style={{ cursor: "help", color: "var(--ant-color-text-tertiary)" }}></span>
</Tooltip>
</span>
}
name={["rr_configuration", "timezone"]}
rules={[{ required: true, message: "Timezone is required" }]}
>
<Select showSearch placeholder="Select timezone">
<Option value="America/Toronto">America/Toronto</Option>
<Option value="America/New_York">America/New_York</Option>
<Option value="America/Chicago">America/Chicago</Option>
<Option value="America/Los_Angeles">America/Los_Angeles</Option>
<Option value="UTC">UTC</Option>
</Select>
</Form.Item>
</Card>
);
}

View File

@@ -124,6 +124,7 @@ export const QUERY_BODYSHOP = gql`
md_email_cc
timezone
ss_configuration
rr_configuration
md_from_emails
last_name_first
md_parts_order_comment
@@ -258,6 +259,7 @@ export const UPDATE_SHOP = gql`
md_email_cc
timezone
ss_configuration
rr_configuration
md_from_emails
last_name_first
md_parts_order_comment

77
package-lock.json generated
View File

@@ -36,6 +36,7 @@
"dinero.js": "^1.9.1",
"dotenv": "^17.2.3",
"express": "^4.21.1",
"fast-xml-parser": "^5.3.0",
"firebase-admin": "^13.5.0",
"graphql": "^16.11.0",
"graphql-request": "^6.1.0",
@@ -48,6 +49,7 @@
"moment": "^2.30.1",
"moment-timezone": "^0.6.0",
"multer": "^2.0.2",
"mustache": "^4.2.0",
"node-persist": "^4.0.4",
"nodemailer": "^6.10.0",
"phone": "^3.1.67",
@@ -1221,18 +1223,6 @@
"fxparser": "src/cli/cli.js"
}
},
"node_modules/@aws-sdk/xml-builder/node_modules/strnum": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.1.tgz",
"integrity": "sha512-7ZvoFTiCnGxBtDqJ//Cu6fWtZtc7Y3x+QOirG15wztbdngGSkht27o2pyGWrVy0b4WAy3jbKmnoK6g5VlVNUUw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT"
},
"node_modules/@aws/lambda-invoke-store": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.0.1.tgz",
@@ -1713,6 +1703,38 @@
"node": ">=14"
}
},
"node_modules/@google-cloud/storage/node_modules/fast-xml-parser": {
"version": "4.5.3",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.5.3.tgz",
"integrity": "sha512-RKihhV+SHsIUGXObeVy9AXiBbFwkVk7Syp8XgwN5U3JV416+Gwp/GO9i0JYKmikykgz/UHRrrV4ROuZEo/T0ig==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"optional": true,
"dependencies": {
"strnum": "^1.1.1"
},
"bin": {
"fxparser": "src/cli/cli.js"
}
},
"node_modules/@google-cloud/storage/node_modules/strnum": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/strnum/-/strnum-1.1.2.tgz",
"integrity": "sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"optional": true
},
"node_modules/@google-cloud/storage/node_modules/uuid": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
@@ -5895,23 +5917,18 @@
"license": "MIT"
},
"node_modules/fast-xml-parser": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.4.1.tgz",
"integrity": "sha512-xkjOecfnKGkSsOwtZ5Pz7Us/T6mrbPQrq0nh+aCO5V9nk5NLWmasAHumTKjiPJPWANe+kAZ84Jc8ooJkzZ88Sw==",
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.3.0.tgz",
"integrity": "sha512-gkWGshjYcQCF+6qtlrqBqELqNqnt4CxruY6UVAWWnqb3DQ6qaNFEIKqzYep1XzHLM/QtrHVCxyPOtTk4LTQ7Aw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
},
{
"type": "paypal",
"url": "https://paypal.me/naturalintelligence"
}
],
"license": "MIT",
"optional": true,
"dependencies": {
"strnum": "^1.0.5"
"strnum": "^2.1.0"
},
"bin": {
"fxparser": "src/cli/cli.js"
@@ -8158,6 +8175,15 @@
"node": ">= 10.16.0"
}
},
"node_modules/mustache": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz",
"integrity": "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==",
"license": "MIT",
"bin": {
"mustache": "bin/mustache"
}
},
"node_modules/mutexify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/mutexify/-/mutexify-1.4.0.tgz",
@@ -10327,17 +10353,16 @@
"license": "MIT"
},
"node_modules/strnum": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/strnum/-/strnum-1.1.2.tgz",
"integrity": "sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA==",
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.1.tgz",
"integrity": "sha512-7ZvoFTiCnGxBtDqJ//Cu6fWtZtc7Y3x+QOirG15wztbdngGSkht27o2pyGWrVy0b4WAy3jbKmnoK6g5VlVNUUw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"optional": true
"license": "MIT"
},
"node_modules/stubs": {
"version": "3.0.0",

View File

@@ -45,6 +45,7 @@
"dinero.js": "^1.9.1",
"dotenv": "^17.2.3",
"express": "^4.21.1",
"fast-xml-parser": "^5.3.0",
"firebase-admin": "^13.5.0",
"graphql": "^16.11.0",
"graphql-request": "^6.1.0",
@@ -57,6 +58,7 @@
"moment": "^2.30.1",
"moment-timezone": "^0.6.0",
"multer": "^2.0.2",
"mustache": "^4.2.0",
"node-persist": "^4.0.4",
"nodemailer": "^6.10.0",
"phone": "^3.1.67",

View File

@@ -1,12 +1,8 @@
// -----------------------------------------------------------------------------
// 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.
// -----------------------------------------------------------------------------
/**
* @file rrRoutes.js
* @description Express Router for Reynolds & Reynolds (Rome) DMS integration.
* Provides endpoints for lookup, customer management, repair orders, and full job export.
*/
const express = require("express");
const router = express.Router();
@@ -16,143 +12,197 @@ const withUserGraphQLClientMiddleware = require("../middleware/withUserGraphQLCl
const { RrCombinedSearch, RrGetAdvisors, RrGetParts } = require("../rr/rr-lookup");
const { RrCustomerInsert, RrCustomerUpdate } = require("../rr/rr-customer");
// NOTE: correct filename is rr-repair-orders.js (plural)
const { CreateRepairOrder, UpdateRepairOrder } = require("../rr/rr-repair-orders");
const { ExportJobToRR } = require("../rr/rr-job-export");
const RRLogger = require("../rr/rr-logger");
// Require auth on all RR routes (keep parity with /cdk)
/**
* Apply global middlewares:
* - Firebase token validation (auth)
* - GraphQL client injection (Hasura access)
*/
router.use(validateFirebaseIdTokenMiddleware);
router.use(withUserGraphQLClientMiddleware);
// -----------------------------------------------------------------------------
// Accounting parity / scaffolding
// -----------------------------------------------------------------------------
/**
* Health check / diagnostic route
*/
router.get("/", async (req, res) => {
res.status(200).json({ provider: "Reynolds & Reynolds (Rome)", status: "OK" });
});
// Reuse CDK allocations for now; keep the endpoint name identical to /cdk
router.post("/calculate-allocations", withUserGraphQLClientMiddleware, async (req, res) => {
/**
* Full DMS export for a single job
* POST /rr/job/export
* Body: { JobData: {...} }
*/
router.post("/job/export", async (req, res) => {
try {
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 });
const { JobData } = req.body;
RRLogger(req, "info", "RR /job/export initiated", { jobid: JobData?.id });
const result = await ExportJobToRR({
socket: req,
redisHelpers: req.sessionUtils,
JobData
});
res.status(result.success ? 200 : 500).json(result);
} catch (error) {
RRLogger(req, "error", `RR /job/export failed: ${error.message}`);
res.status(500).json({ error: error.message });
}
});
// 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); // [["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 }
/**
* Customer insert
* POST /rr/customer/insert
*/
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 });
const { JobData } = req.body;
const data = await RrCustomerInsert({
socket: req,
redisHelpers: req.sessionUtils,
JobData
});
res.status(200).json({ success: true, data });
} catch (error) {
RRLogger(req, "error", `RR /customer/insert failed: ${error.message}`);
res.status(500).json({ success: false, error: error.message });
}
});
// PUT /rr/customer/update/:id
// Body: { JobData, existingCustomer, patch }
/**
* Customer update
* PUT /rr/customer/update/:id
*/
router.put("/customer/update/:id", async (req, res) => {
try {
const { JobData, existingCustomer, patch } = req.body;
const data = await RrCustomerUpdate({
socket: req,
redisHelpers: req.sessionUtils,
JobData: req.body?.JobData,
existingCustomer: req.body?.existingCustomer,
patch: req.body?.patch
JobData,
existingCustomer,
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 });
res.status(200).json({ success: true, data });
} catch (error) {
RRLogger(req, "error", `RR /customer/update failed: ${error.message}`);
res.status(500).json({ success: false, error: error.message });
}
});
// -----------------------------------------------------------------------------
// Repair Order endpoints
// -----------------------------------------------------------------------------
// POST /rr/repair-order/create
// Body: { JobData, txEnvelope }
/**
* Create Repair Order
* POST /rr/repair-order/create
*/
router.post("/repair-order/create", async (req, res) => {
try {
const { JobData, txEnvelope } = req.body;
const data = await CreateRepairOrder({
socket: req,
redisHelpers: req.sessionUtils,
JobData: req.body?.JobData,
txEnvelope: req.body?.txEnvelope
JobData,
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 });
res.status(200).json({ success: true, data });
} catch (error) {
RRLogger(req, "error", `RR /repair-order/create failed: ${error.message}`);
res.status(500).json({ success: false, error: error.message });
}
});
// PUT /rr/repair-order/update/:id
// Body: { JobData, txEnvelope }
/**
* Update Repair Order
* PUT /rr/repair-order/update/:id
*/
router.put("/repair-order/update/:id", async (req, res) => {
try {
const { JobData, txEnvelope } = req.body;
const data = await UpdateRepairOrder({
socket: req,
redisHelpers: req.sessionUtils,
JobData: req.body?.JobData,
txEnvelope: req.body?.txEnvelope
JobData,
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 });
res.status(200).json({ success: true, data });
} catch (error) {
RRLogger(req, "error", `RR /repair-order/update failed: ${error.message}`);
res.status(500).json({ success: false, error: error.message });
}
});
/**
* Combined search (customer + service vehicle)
* GET /rr/lookup/combined?vin=XXX&lastname=DOE
*/
router.get("/lookup/combined", async (req, res) => {
try {
const params = Object.entries(req.query);
const data = await RrCombinedSearch({
socket: req,
redisHelpers: req.sessionUtils,
jobid: "ad-hoc",
params
});
res.status(200).json({ success: true, data });
} catch (error) {
RRLogger(req, "error", `RR /lookup/combined failed: ${error.message}`);
res.status(500).json({ success: false, error: error.message });
}
});
/**
* Get Advisors
* GET /rr/advisors
*/
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({ success: true, data });
} catch (error) {
RRLogger(req, "error", `RR /advisors failed: ${error.message}`);
res.status(500).json({ success: false, error: error.message });
}
});
/**
* Get Parts
* GET /rr/parts
*/
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({ success: true, data });
} catch (error) {
RRLogger(req, "error", `RR /parts failed: ${error.message}`);
res.status(500).json({ success: false, error: error.message });
}
});
/**
* Not implemented placeholder (for future expansion)
*/
router.post("/calculate-allocations", async (req, res) => {
res.status(501).json({ error: "RR calculate-allocations not yet implemented" });
});
module.exports = router;

79
server/rr/rr-constants.js Normal file
View File

@@ -0,0 +1,79 @@
/**
* @file rr-constants.js
* @description Central constants and configuration for Reynolds & Reynolds (R&R) integration.
* Platform-level secrets (API base URL, username, password, ppsysId, dealer/store/branch) are loaded from .env
* Dealer-specific values (overrides) come from bodyshop.rr_configuration.
*/
const RR_TIMEOUT_MS = 30000; // 30-second SOAP call timeout
const RR_NAMESPACE_URI = "http://reynoldsandrey.com/";
const RR_DEFAULT_MAX_RESULTS = 25;
/**
* Maps internal operation names to Reynolds & Reynolds SOAP actions.
* soapAction is sent as the SOAPAction header; URL selection happens in rr-helpers.
*/
const RR_ACTIONS = {
GetAdvisors: { soapAction: "GetAdvisors" },
GetParts: { soapAction: "GetParts" },
CombinedSearch: { soapAction: "CombinedSearch" },
InsertCustomer: { soapAction: "CustomerInsert" },
UpdateCustomer: { soapAction: "CustomerUpdate" },
InsertServiceVehicle: { soapAction: "ServiceVehicleInsert" },
CreateRepairOrder: { soapAction: "RepairOrderInsert" },
UpdateRepairOrder: { soapAction: "RepairOrderUpdate" }
};
/**
* Default SOAP HTTP headers. SOAPAction is dynamically set per request.
*/
const RR_SOAP_HEADERS = {
"Content-Type": "text/xml; charset=utf-8",
SOAPAction: ""
};
/**
* Wraps the rendered XML body inside a SOAP envelope.
* @param {string} xmlBody - Inner request XML
* @param {string} [headerXml] - Optional header XML (already namespaced)
*/
const buildSoapEnvelope = (xmlBody, headerXml = "") => `
<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/" xmlns:rr="${RR_NAMESPACE_URI}">
<soapenv:Header>
${headerXml}
</soapenv:Header>
<soapenv:Body>
${xmlBody}
</soapenv:Body>
</soapenv:Envelope>
`;
/**
* Loads base configuration for R&R requests from environment variables.
* Dealer-specific overrides come from bodyshop.rr_configuration in the DB.
*/
const getBaseRRConfig = () => ({
// IMPORTANT: RCI Receive endpoint ends with .ashx
baseUrl: process.env.RR_API_BASE_URL || "https://b2b-test.reyrey.com/Sync/RCI/Rome/Receive.ashx",
username: process.env.RR_API_USER || "",
password: process.env.RR_API_PASS || "",
ppsysId: process.env.RR_PPSYS_ID || "",
// Welcome Kit often provides these (used in SOAP header)
dealerNumber: process.env.RR_DEALER_NUMBER || "",
storeNumber: process.env.RR_STORE_NUMBER || "",
branchNumber: process.env.RR_BRANCH_NUMBER || "",
dealerDefault: process.env.RR_DEFAULT_DEALER || "ROME",
timeout: RR_TIMEOUT_MS
});
module.exports = {
RR_TIMEOUT_MS,
RR_NAMESPACE_URI,
RR_DEFAULT_MAX_RESULTS,
RR_ACTIONS,
RR_SOAP_HEADERS,
buildSoapEnvelope,
getBaseRRConfig
};

View File

@@ -1,66 +1,137 @@
// -----------------------------------------------------------------------------
// RR Customer endpoints (create/update) wired through MakeRRCall.
// Shapes are mapped via rr-mappers.js and validated via rr-error.js.
//
// Whats 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
// -----------------------------------------------------------------------------
/**
* @file rr-customer.js
* @description Reynolds & Reynolds (Rome) Customer Insert/Update integration.
* Builds request payloads using rr-mappers and executes via rr-helpers.
* All dealer-specific data (DealerNumber, LocationId, etc.) is read from the DB (bodyshop.rr_configuration).
*/
const { MakeRRCall, RRActions } = require("./rr-helpers");
const { assertRrOk } = require("./rr-error");
const { mapCustomerInsert, mapCustomerUpdate } = require("./rr-mappers");
const RRLogger = require("./rr-logger");
const { client } = require("../graphql-client/graphql-client");
const { GET_BODYSHOP_BY_ID } = require("../graphql-client/queries");
/**
* Create a customer in RR.
* Fetch rr_configuration for the current bodyshop directly from DB.
* This ensures we always have the latest Dealer/Location mapping.
*/
async function getDealerConfigFromDB(bodyshopId, logger) {
try {
const result = await client.request(GET_BODYSHOP_BY_ID, { id: bodyshopId });
const config = result?.bodyshops_by_pk?.rr_configuration || null;
if (!config) {
throw new Error(`No rr_configuration found for bodyshop ID ${bodyshopId}`);
}
logger?.debug?.(`Fetched rr_configuration for bodyshop ${bodyshopId}`, config);
return config;
} catch (error) {
logger?.log?.("rr-get-dealer-config", "ERROR", "rr", null, {
bodyshopId,
message: error.message,
stack: error.stack
});
throw error;
}
}
/**
* CUSTOMER INSERT (Rome Customer Insert Specification 1.2)
* Creates a new customer record in the DMS.
*
* @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)
* @param {object} options
* @param {object} options.socket - socket.io connection or express req
* @param {object} options.redisHelpers
* @param {object} options.JobData - normalized job record
*/
async function RrCustomerInsert({ socket, redisHelpers, JobData }) {
// Map JobData -> RR "Customer Insert" request body
const body = mapCustomerInsert(JobData);
const bodyshopId = socket?.bodyshopId || JobData?.bodyshopid;
const logger = socket?.logger || console;
const data = await MakeRRCall({
...RRActions.CreateCustomer, // POST /customer/v1/
body,
redisHelpers,
socket,
jobid: JobData?.id
});
try {
RRLogger(socket, "info", "RR Customer Insert started", { jobid: JobData?.id, bodyshopId });
// TODO: assertRrOk should be updated once RRs success envelope is finalized
return assertRrOk(data, { apiName: "RR Create Customer" });
const dealerConfig = await getDealerConfigFromDB(bodyshopId, logger);
// Build Mustache variables for the InsertCustomer.xml template
const vars = mapCustomerInsert(JobData, dealerConfig);
const data = await MakeRRCall({
action: RRActions.CreateCustomer, // resolves to SOAPAction + URL
body: { template: "InsertCustomer", data: vars }, // render server/rr/xml-templates/InsertCustomer.xml
redisHelpers,
socket,
jobid: JobData.id
});
const response = assertRrOk(data, { apiName: "RR Create Customer" });
RRLogger(socket, "debug", "RR Customer Insert success", {
jobid: JobData?.id,
dealer: dealerConfig?.dealerCode || dealerConfig?.dealer_code
});
return response;
} catch (error) {
RRLogger(socket, "error", `RR Customer Insert failed: ${error.message}`, { jobid: JobData?.id });
throw error;
}
}
/**
* Update an existing customer in RR.
* CUSTOMER UPDATE (Rome Customer Update Specification 1.2)
* Updates an existing RR customer record.
*
* @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
* @param {object} options
* @param {object} options.socket
* @param {object} options.redisHelpers
* @param {object} options.JobData
* @param {object} options.existingCustomer - current RR customer record (from Combined Search)
* @param {object} options.patch - updated fields from frontend
*/
async function RrCustomerUpdate({ socket, redisHelpers, JobData, existingCustomer, patch }) {
// Build a merged/normalized payload for RR Update
const body = mapCustomerUpdate(existingCustomer, patch);
const bodyshopId = socket?.bodyshopId || JobData?.bodyshopid;
const logger = socket?.logger || console;
const data = await MakeRRCall({
...RRActions.UpdateCustomer, // PUT /customer/v1/ (append id inside body/path per final spec)
body,
redisHelpers,
socket,
jobid: JobData?.id
});
try {
RRLogger(socket, "info", "RR Customer Update started", {
jobid: JobData?.id,
bodyshopId,
existingCustomerId: existingCustomer?.CustomerId
});
return assertRrOk(data, { apiName: "RR Update Customer" });
const dealerConfig = await getDealerConfigFromDB(bodyshopId, logger);
// Build Mustache variables for the UpdateCustomer.xml template
const vars = mapCustomerUpdate(existingCustomer, patch, dealerConfig);
const data = await MakeRRCall({
action: RRActions.UpdateCustomer, // resolves to SOAPAction + URL
body: { template: "UpdateCustomer", data: vars }, // render server/rr/xml-templates/UpdateCustomer.xml
redisHelpers,
socket,
jobid: JobData.id
});
const response = assertRrOk(data, { apiName: "RR Update Customer" });
RRLogger(socket, "debug", "RR Customer Update success", {
jobid: JobData?.id,
customerId: existingCustomer?.CustomerId
});
return response;
} catch (error) {
RRLogger(socket, "error", `RR Customer Update failed: ${error.message}`, {
jobid: JobData?.id,
customerId: existingCustomer?.CustomerId
});
throw error;
}
}
module.exports = { RrCustomerInsert, RrCustomerUpdate };
module.exports = {
RrCustomerInsert,
RrCustomerUpdate,
getDealerConfigFromDB
};

View File

@@ -1,67 +1,103 @@
// -----------------------------------------------------------------------------
// 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.
// -----------------------------------------------------------------------------
/**
* @file rr-error.js
* @description Centralized error class and assertion logic for Reynolds & Reynolds API calls.
* Provides consistent handling across all RR modules (customer, repair order, lookups, etc.)
*/
/**
* Custom Error type for RR API responses
*/
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";
this.reqId = reqId;
this.url = url;
this.apiName = apiName;
this.errorData = errorData;
this.status = status;
this.statusText = statusText;
this.reqId = reqId || null;
this.url = url || null;
this.apiName = apiName || null;
this.errorData = errorData || null;
this.status = status || null;
this.statusText = statusText || null;
}
}
/**
* Assert that an RR API response is considered "OK".
* Throws RrApiError otherwise.
* Assert that a Reynolds & Reynolds response is successful.
*
* @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
* Expected success structure (based on Rome RR specs):
* {
* "SuccessFlag": true,
* "ErrorCode": "0",
* "ErrorMessage": "",
* "Data": { ... }
* }
*
* Or if SOAP/XML-based:
* {
* "Envelope": {
* "Body": {
* "Response": {
* "SuccessFlag": true,
* ...
* }
* }
* }
* }
*
* This helper unwraps and normalizes the response to detect any error cases.
*/
function assertRrOk(data, { apiName, allowEmpty = false } = {}) {
// 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 });
function assertRrOk(data, { apiName = "RR API Call", allowEmpty = false } = {}) {
if (!data && !allowEmpty) {
throw new RrApiError(`${apiName} returned no data`, { apiName });
}
return data;
// Normalize envelope
const response =
data?.Envelope?.Body?.Response ||
data?.Envelope?.Body?.[Object.keys(data.Envelope?.Body || {})[0]] ||
data?.Response ||
data;
// Handle array of errors or error objects
const errorBlock = response?.Errors || response?.Error || response?.Fault || null;
// Basic success conditions per RR documentation
const success =
response?.SuccessFlag === true ||
response?.ErrorCode === "0" ||
response?.ResultCode === "0" ||
(Array.isArray(errorBlock) && errorBlock.length === 0);
// If success, return normalized response
if (success || allowEmpty) {
return response?.Data || response;
}
// Construct contextual error info
const errorMessage = response?.ErrorMessage || response?.FaultString || response?.Message || "Unknown RR API error";
throw new RrApiError(`${apiName} failed: ${errorMessage}`, {
apiName,
errorData: response,
status: response?.ErrorCode || response?.ResultCode
});
}
module.exports = { RrApiError, assertRrOk };
/**
* Safely unwrap nested RR API responses for consistency across handlers.
*/
function extractRrResponseData(data) {
if (!data) return null;
return (
data?.Envelope?.Body?.Response?.Data ||
data?.Envelope?.Body?.[Object.keys(data.Envelope?.Body || {})[0]]?.Data ||
data?.Data ||
data
);
}
module.exports = {
RrApiError,
assertRrOk,
extractRrResponseData
};

View File

@@ -1,488 +1,257 @@
/**
* RR (Reynolds & Reynolds) helper module
* -----------------------------------------------------------------------------
* 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 (PLACEHOLDERS)
* - Common cache enums + TTL + transaction-type helper (parity with Fortellis)
*
* Whats 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
* @file rr-helpers.js
* @description Core helper functions for Reynolds & Reynolds integration.
* Handles XML rendering, SOAP communication, and configuration merging.
*/
const fs = require("fs/promises");
const path = require("path");
require("dotenv").config({
path: path.resolve(process.cwd(), `.env.${process.env.NODE_ENV || "development"}`)
});
const mustache = require("mustache");
const axios = require("axios");
const { v4: uuidv4 } = require("uuid");
const { RR_SOAP_HEADERS, RR_ACTIONS, getBaseRRConfig } = require("./rr-constants");
const RRLogger = require("./rr-logger");
const { client } = require("../graphql-client/graphql-client");
const { GET_BODYSHOP_BY_ID } = require("../graphql-client/queries");
const uuid = require("uuid").v4;
const AxiosLib = require("axios").default;
const axios = AxiosLib.create();
const axiosCurlirize = require("axios-curlirize").default;
/* ------------------------------------------------------------------------------------------------
* Configuration
* ----------------------------------------------------------------------------------------------*/
const logger = require("../utils/logger");
const { RrApiError } = require("./rr-error");
// Emit curl equivalents for dev troubleshooting (safe to disable in prod)
axiosCurlirize(axios, (result /*, err */) => {
/**
* Loads the rr_configuration JSON for a given bodyshop directly from the database.
* Dealer-level settings only. Platform/secret defaults come from getBaseRRConfig().
* @param {string} bodyshopId
* @returns {Promise<object>} rr_configuration
*/
async function getDealerConfig(bodyshopId) {
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";
/**
* Transaction key namespace (mirrors Fortellis' getTransactionType)
* Used to partition per-job Redis session hashes.
*/
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 dont 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.
* 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
* @returns {Promise<string>} accessToken
*/
async function getRRToken({ redisHelpers }) {
try {
// Try the cache first
const cached = await redisHelpers.getSessionData(RR_PROVIDER_TOKEN_BUCKET, RR_PROVIDER_TOKEN_FIELD);
if (cached?.accessToken && cached?.expiresAt && Date.now() < cached.expiresAt - 5000) {
return cached.accessToken;
}
// TODO: Replace with real RR auth call. For now, fallback to env.
const accessToken = process.env.RR_FAKE_TOKEN || "rr-dev-token";
// Artificial ~55m expiry (adjust to actual token TTL)
const expiresAt = Date.now() + 55 * 60 * 1000;
await redisHelpers.setSessionData(
RR_PROVIDER_TOKEN_BUCKET,
RR_PROVIDER_TOKEN_FIELD,
{ accessToken, expiresAt },
60 * 60 // TTL safety net
);
return accessToken;
} catch (error) {
logger.log("rr-get-token-error", "ERROR", "api", "rr", {
message: error?.message,
stack: error?.stack
});
// Keep local dev moving even if cache errors
return process.env.RR_FAKE_TOKEN || "rr-dev-token";
const result = await client.request(GET_BODYSHOP_BY_ID, { id: bodyshopId });
const cfg = result?.bodyshops_by_pk?.rr_configuration || {};
return cfg;
} catch (err) {
console.error(`[RR] Failed to load rr_configuration for bodyshop ${bodyshopId}:`, err.message);
return {};
}
}
/**
* 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 appended to URL (no leading slash)
* @param {Array<[string,string]>} [args.requestSearchParams] - pairs converted to query params
* @returns {string}
* Helper to retrieve combined configuration (env + dealer) for calls.
* NOTE: This does not hit Redis. DB only (dealer overrides) + env secrets.
* @param {object} socket - Either a real socket or an Express req carrying bodyshopId on .bodyshopId
* @returns {Promise<object>} configuration
*/
function constructFullUrl({ url, pathParams = "", requestSearchParams = [] }) {
// normalize: ensure exactly one trailing slash on base
url = url.replace(/\/+$/, "/");
const fullPath = pathParams ? `${url}${pathParams}` : url;
const query = new URLSearchParams(requestSearchParams).toString();
return query ? `${fullPath}?${query}` : fullPath;
async function resolveRRConfig(socket) {
const bodyshopId = socket?.bodyshopId || socket?.user?.bodyshopid;
const dealerCfg = bodyshopId ? await getDealerConfig(bodyshopId) : {};
return { ...getBaseRRConfig(), ...dealerCfg };
}
/* ------------------------------------------------------------------------------------------------
* Template rendering
* ----------------------------------------------------------------------------------------------*/
/**
* Loads and renders a Mustache XML template with provided data.
* @param {string} templateName - Name of XML file under server/rr/xml-templates/ (without .xml)
* @param {object} data - Template substitution object
* @returns {Promise<string>} Rendered XML string
*/
async function renderXmlTemplate(templateName, data) {
const templatePath = path.join(__dirname, "xml-templates", `${templateName}.xml`);
const xmlTemplate = await fs.readFile(templatePath, "utf8");
return mustache.render(xmlTemplate, data);
}
/**
* 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 - 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>}
* Build a SOAP envelope with a rendered header + body.
* Header comes from xml-templates/_EnvelopeHeader.xml.
* @param {string} renderedBodyXml
* @param {object} headerVars - values for header mustache
* @returns {Promise<string>}
*/
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);
async function buildSoapEnvelopeWithHeader(renderedBodyXml, headerVars) {
const headerXml = await renderXmlTemplate("_EnvelopeHeader", headerVars);
const statusUrl = delayMeta?._links?.status?.href;
if (!statusUrl) {
return { error: "No status URL provided by RR batch envelope." };
}
return `
<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/" xmlns:rr="http://reynoldsandrey.com/">
<soapenv:Header>
${headerXml}
</soapenv:Header>
<soapenv:Body>
${renderedBodyXml}
</soapenv:Body>
</soapenv:Envelope>
`.trim();
}
const statusResult = await axios.get(statusUrl, {
headers: {
Authorization: `Bearer ${access_token}`,
"X-Request-Id": reqId
}
});
/* ------------------------------------------------------------------------------------------------
* Core SOAP caller
* ----------------------------------------------------------------------------------------------*/
if (statusResult?.data?.status === "complete") {
const resultUrl = statusResult?.data?._links?.result?.href;
if (!resultUrl) return statusResult.data;
const batchResult = await axios.get(resultUrl, {
headers: {
Authorization: `Bearer ${access_token}`,
"X-Request-Id": reqId
}
});
return batchResult.data;
}
/**
* Compute the full URL and SOAPAction for a given action spec.
* Allows either:
* - action: a key into RR_ACTIONS (e.g. "GetAdvisors")
* - action: a raw URL/spec
*/
function resolveActionTarget(action, baseUrl) {
if (typeof action === "string" && RR_ACTIONS[action]) {
const spec = RR_ACTIONS[action];
const soapAction = spec.soapAction || spec.action || action;
const cleanedBase = (spec.baseUrl || baseUrl || "").replace(/\/+$/, "");
const url = spec.url || (soapAction ? `${cleanedBase}/${soapAction}` : cleanedBase);
return { url, soapAction };
}
return { error: "Batch result still not complete after max attempts." };
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
if (action && typeof action === "object" && (action.url || action.soapAction || action.action)) {
const soapAction = action.soapAction || action.action || "";
const cleanedBase = (action.baseUrl || baseUrl || "").replace(/\/+$/, "");
const url = action.url || (soapAction ? `${cleanedBase}/${soapAction}` : cleanedBase);
return { url, soapAction };
}
if (typeof action === "string") {
return { url: action, soapAction: "" };
}
throw new Error("Invalid RR action. Must be a known RR_ACTIONS key, an action spec, or a URL string.");
}
/**
* Core caller. Mirrors Fortellis' MakeFortellisCall shape so we can reuse flow.
* Constructs and sends a SOAP call to the Reynolds & Reynolds endpoint.
*
* @param {Object} args
* @param {string} args.apiName - logical name (used in logs/errors)
* @param {string} args.url - base endpoint
* @param {Object} [args.headers] - extra headers to send
* @param {Object} [args.body] - POST/PUT body
* @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] 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
* @returns {Promise<any>}
* Body can be one of:
* - string (already-rendered XML body)
* - { template: "TemplateName", data: {...} } to render server/rr/xml-templates/TemplateName.xml
*
* @param {object} params
* @param {string|object} params.action - RR action key (RR_ACTIONS) or a raw URL/spec
* @param {string|{template:string,data:object}} params.body - Rendered XML or template descriptor
* @param {object} params.socket - The socket or req object for context (used to resolve config & logging)
* @param {object} [params.redisHelpers]
* @param {string|number} [params.jobid]
* @param {object} [params.dealerConfig]
* @param {number} [params.retries=1]
* @returns {Promise<string>} Raw SOAP response text
*/
async function MakeRRCall({
apiName,
url,
headers = {},
body = {},
type = "post",
debug = true,
requestPathParams,
requestSearchParams = [],
action,
body,
socket,
// redisHelpers,
jobid,
redisHelpers,
socket
dealerConfig,
retries = 1
}) {
const fullUrl = constructFullUrl({ url, pathParams: requestPathParams, requestSearchParams });
const reqId = uuid();
const idempotencyKey = uuid();
const access_token = await getRRToken({ redisHelpers });
const correlationId = uuidv4();
if (debug) {
logger.log("rr-call", "DEBUG", socket?.user?.email, null, {
apiName,
type,
url: fullUrl,
jobid,
reqId,
body: safeLogJson(body)
});
const effectiveConfig = dealerConfig || (await resolveRRConfig(socket));
const { url, soapAction } = resolveActionTarget(action, effectiveConfig.baseUrl);
// Render body if given by template descriptor
let renderedBody = body;
if (body && typeof body === "object" && body.template) {
renderedBody = await renderXmlTemplate(body.template, body.data || {});
}
try {
const baseHeaders = {
Authorization: `Bearer ${access_token}`,
"X-Request-Id": reqId,
"Idempotency-Key": idempotencyKey,
...headers
};
// Build header vars (from env + rr_configuration)
const headerVars = {
PPSysId: effectiveConfig.ppsysid || process.env.RR_PPSYSID || process.env.RR_PP_SYS_ID || process.env.RR_PP_SYSID,
DealerNumber: effectiveConfig.dealer_number || effectiveConfig.dealer_id || process.env.RR_DEALER_NUMBER,
StoreNumber: effectiveConfig.store_number || process.env.RR_STORE_NUMBER,
BranchNumber: effectiveConfig.branch_number || process.env.RR_BRANCH_NUMBER,
Username: effectiveConfig.username || process.env.RR_API_USER || process.env.RR_USERNAME,
Password: effectiveConfig.password || process.env.RR_API_PASS || process.env.RR_PASSWORD,
CorrelationId: correlationId
};
let resp;
switch ((type || "post").toLowerCase()) {
case "get":
resp = await axios.get(fullUrl, { headers: baseHeaders });
break;
case "put":
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":
default:
resp = await axios.post(fullUrl, body, { headers: baseHeaders });
break;
}
// Build full SOAP envelope with proper header
const soapEnvelope = await buildSoapEnvelopeWithHeader(renderedBody, headerVars);
if (debug) {
logger.log("rr-response", "DEBUG", socket?.user?.email, null, {
apiName,
reqId,
data: safeLogJson(resp?.data)
});
}
RRLogger(socket, "info", `RR → ${soapAction || "SOAP"} request`, {
jobid,
url,
correlationId
});
// If RR returns a "check later" envelope, route through DelayedCallback
if (resp?.data?.checkStatusAfterSeconds) {
const delayed = await DelayedCallback({
delayMeta: resp.data,
access_token,
reqId
});
return delayed;
}
const headers = {
...RR_SOAP_HEADERS,
SOAPAction: soapAction,
"Content-Type": "text/xml; charset=utf-8",
"X-Request-Id": correlationId
};
return resp?.data;
} catch (error) {
// Handle 429 backoff hint (simple single-retry stub)
if (error?.response?.status === 429) {
const retryAfter = Number(error.response.headers?.["retry-after"] || 1);
await sleep(retryAfter * 1000);
return MakeRRCall({
apiName,
url,
let attempt = 0;
while (attempt <= retries) {
attempt += 1;
try {
const response = await axios.post(url, soapEnvelope, {
headers,
body,
type,
debug,
requestPathParams,
requestSearchParams,
jobid,
redisHelpers,
socket
timeout: effectiveConfig.timeout || 30000,
responseType: "text",
validateStatus: () => true
});
const text = response.data;
if (response.status >= 400) {
RRLogger(socket, "error", `RR HTTP ${response.status} on ${soapAction || url}`, {
status: response.status,
jobid,
correlationId,
snippet: text?.slice?.(0, 512)
});
if (response.status >= 500 && attempt <= retries) {
RRLogger(socket, "warn", `RR transient HTTP error; retrying (${attempt}/${retries})`, {
correlationId
});
continue;
}
throw new Error(`RR HTTP ${response.status}: ${response.statusText}`);
}
RRLogger(socket, "debug", `RR ← ${soapAction || "SOAP"} response`, {
jobid,
correlationId,
bytes: Buffer.byteLength(text || "", "utf8")
});
return text;
} catch (err) {
const transient = /ECONNRESET|ETIMEDOUT|EAI_AGAIN|ENOTFOUND|socket hang up|network error/i.test(
err?.message || ""
);
if (transient && attempt <= retries) {
RRLogger(socket, "warn", `RR transient network error; retrying (${attempt}/${retries})`, {
error: err.message,
correlationId
});
continue;
}
RRLogger(socket, "error", `RR ${soapAction || "SOAP"} failed`, {
error: err.message,
jobid,
correlationId
});
throw err;
}
const errPayload = {
reqId,
url: fullUrl,
apiName,
errorData: error?.response?.data,
status: error?.response?.status,
statusText: error?.response?.statusText
};
// Log and throw a typed error (consistent with Fortellis helpers)
logger.log("rr-call-error", "ERROR", socket?.user?.email, null, {
...errPayload,
message: error?.message,
stack: error?.stack
});
throw new RrApiError(`RR API call failed for ${apiName}: ${error?.message}`, errPayload);
}
}
/**
* 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
GetVehicleId: {
apiName: "RR Get Vehicle Id",
url: isProduction
? "https://rr.example.com/service-vehicle-mgmt/v1/vehicle-ids/" // append VIN
: "https://rr-uat.example.com/service-vehicle-mgmt/v1/vehicle-ids/",
type: "get"
},
ReadVehicle: {
apiName: "RR Read Vehicle",
url: isProduction
? "https://rr.example.com/service-vehicle-mgmt/v1/" // append vehicleId
: "https://rr-uat.example.com/service-vehicle-mgmt/v1/",
type: "get"
},
InsertVehicle: {
apiName: "RR Insert Service Vehicle",
url: isProduction
? "https://rr.example.com/service-vehicle-mgmt/v1/"
: "https://rr-uat.example.com/service-vehicle-mgmt/v1/",
type: "post"
},
UpdateVehicle: {
apiName: "RR Update Service Vehicle",
url: isProduction
? "https://rr.example.com/service-vehicle-mgmt/v1/"
: "https://rr-uat.example.com/service-vehicle-mgmt/v1/",
type: "put"
},
/* ------------------------------------------------------------------------------------------------
* Exports
* ----------------------------------------------------------------------------------------------*/
// Customers
CreateCustomer: {
apiName: "RR Create Customer",
url: isProduction ? "https://rr.example.com/customer/v1/" : "https://rr-uat.example.com/customer/v1/",
type: "post"
},
UpdateCustomer: {
apiName: "RR Update Customer",
url: isProduction
? "https://rr.example.com/customer/v1/" // append /{id} if required by spec
: "https://rr-uat.example.com/customer/v1/",
type: "put"
},
ReadCustomer: {
apiName: "RR Read Customer",
url: isProduction
? "https://rr.example.com/customer/v1/" // append /{id}
: "https://rr-uat.example.com/customer/v1/",
type: "get"
},
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)",
url: isProduction
? "https://rr.example.com/search/v1/customer-vehicle"
: "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",
url: isProduction ? "https://rr.example.com/parts/v1" : "https://rr-uat.example.com/parts/v1",
type: "get"
},
// GL / WIP (mirroring your existing flows; endpoints are placeholders)
StartWip: {
apiName: "RR Start WIP",
url: isProduction ? "https://rr.example.com/glpost/v1/startWIP" : "https://rr-uat.example.com/glpost/v1/startWIP",
type: "post"
},
TranBatchWip: {
apiName: "RR Trans Batch WIP",
url: isProduction
? "https://rr.example.com/glpost/v1/transBatchWIP"
: "https://rr-uat.example.com/glpost/v1/transBatchWIP",
type: "post"
},
PostBatchWip: {
apiName: "RR Post Batch WIP",
url: isProduction
? "https://rr.example.com/glpost/v1/postBatchWIP"
: "https://rr-uat.example.com/glpost/v1/postBatchWIP",
type: "post"
},
QueryErrorWip: {
apiName: "RR Query Error WIP",
url: isProduction ? "https://rr.example.com/glpost/v1/errWIP" : "https://rr-uat.example.com/glpost/v1/errWIP",
type: "get"
},
// Service history (header insert)
ServiceHistoryInsert: {
apiName: "RR Service Vehicle History Insert",
url: isProduction
? "https://rr.example.com/service-vehicle-history-mgmt/v1/"
: "https://rr-uat.example.com/service-vehicle-history-mgmt/v1/",
type: "post"
},
// Repair Orders
CreateRepairOrder: {
apiName: "RR Create Repair Order",
url: isProduction ? "https://rr.example.com/repair-orders/v1" : "https://rr-uat.example.com/repair-orders/v1",
type: "post"
},
UpdateRepairOrder: {
apiName: "RR Update Repair Order",
url: isProduction
? "https://rr.example.com/repair-orders/v1/" // append /{id} if required
: "https://rr-uat.example.com/repair-orders/v1/",
type: "put"
}
};
/**
* Safe JSON logger helper to avoid huge payloads/recursive structures in logs.
*/
function safeLogJson(data) {
try {
const text = JSON.stringify(data);
// cap to ~5k for logs
return text.length > 5000 ? `${text.slice(0, 5000)}… [truncated]` : text;
} catch {
return "[unserializable]";
}
}
const RRActions = RR_ACTIONS;
module.exports = {
// core helpers
MakeRRCall,
RRActions,
getRRToken,
constructFullUrl,
DelayedCallback,
// parity exports required by other RR modules
getTransactionType,
defaultRRTTL,
RRCacheEnums
getDealerConfig,
renderXmlTemplate,
resolveRRConfig,
RRActions
};

View File

@@ -1,567 +1,133 @@
// -----------------------------------------------------------------------------
// 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
//
// Whats 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.
// -----------------------------------------------------------------------------
/**
* @file rr-job-export.js
* @description Orchestrates the full Reynolds & Reynolds DMS export flow.
* Creates/updates customers, vehicles, and repair orders according to Rome specs.
*/
const { GraphQLClient } = require("graphql-request");
const moment = require("moment-timezone");
const CalculateAllocations = require("../cdk/cdk-calculate-allocations").default; // reuse allocations
const CreateRRLogEvent = require("./rr-logger");
const queries = require("../graphql-client/queries");
const { MakeRRCall, RRActions, getTransactionType, defaultRRTTL, RRCacheEnums } = require("./rr-helpers");
// -----------------------------------------------------------------------------
// Public entry points (wired in redisSocketEvents.js)
// -----------------------------------------------------------------------------
const { RrCustomerInsert, RrCustomerUpdate } = require("./rr-customer");
const { CreateRepairOrder, UpdateRepairOrder } = require("./rr-repair-orders");
const { MakeRRCall, RRActions, getDealerConfig } = require("./rr-helpers");
const { assertRrOkXml, extractRrResponseData } = require("./rr-error");
const RRLogger = require("./rr-logger");
const { mapServiceVehicleInsert } = require("./rr-mappers");
/**
* Seed export: cache txEnvelope + JobData, discover VIN->VehicleId + owner,
* search by customer name, and prompt client to select/create a customer.
* Inserts a service vehicle record for the repair order.
* Follows the "Rome Insert Service Vehicle Interface Specification" via SOAP/XML.
*/
async function RRJobExport({ socket, redisHelpers, txEnvelope, jobid }) {
const { setSessionTransactionData } = redisHelpers;
async function RrServiceVehicleInsert({ socket, redisHelpers, JobData, dealerConfig }) {
try {
CreateRRLogEvent(socket, "DEBUG", `[RR] Received Job export request`, { jobid });
RRLogger(socket, "info", "RR Insert Service Vehicle started", { jobid: JobData?.id });
// cache txEnvelope for this job session
await setSessionTransactionData(
socket.id,
getTransactionType(jobid),
RRCacheEnums.txEnvelope,
txEnvelope,
defaultRRTTL
);
// Build Mustache variables for server/rr/xml-templates/InsertServiceVehicle.xml
const variables = mapServiceVehicleInsert(JobData, dealerConfig);
const JobData = await QueryJobData({ socket, jobid });
await setSessionTransactionData(socket.id, getTransactionType(jobid), RRCacheEnums.JobData, JobData, defaultRRTTL);
const xml = await MakeRRCall({
action: RRActions.InsertServiceVehicle,
body: { template: "InsertServiceVehicle", data: variables },
redisHelpers,
socket,
jobid: JobData.id,
dealerConfig
});
CreateRRLogEvent(socket, "DEBUG", `[RR] Get Vehicle Id via VIN`, { vin: JobData.v_vin });
const ok = assertRrOkXml(xml, { apiName: "RR Insert Service Vehicle" });
const normalized = extractRrResponseData(ok, { action: "InsertServiceVehicle" });
const DMSVid = await GetVehicleId({ socket, redisHelpers, JobData });
await setSessionTransactionData(socket.id, getTransactionType(jobid), RRCacheEnums.DMSVid, DMSVid, defaultRRTTL);
RRLogger(socket, "debug", "RR Insert Service Vehicle success", {
jobid: JobData?.id,
vehicleId: normalized?.VehicleId || normalized?.vehicleId
});
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);
// 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(
socket.id,
getTransactionType(jobid),
RRCacheEnums.DMSVehCustomer,
DMSVehCustomer,
defaultRRTTL
);
}
}
// Search customers by job owner name (param names TBD per RR)
const DMSCustList = await SearchCustomerByName({ socket, redisHelpers, JobData });
await setSessionTransactionData(
socket.id,
getTransactionType(jobid),
RRCacheEnums.DMSCustList,
DMSCustList,
defaultRRTTL
);
// Emit choices: (VIN owner first if present) + search results
socket.emit("rr-select-customer", [
...(DMSVehCustomer ? [{ ...DMSVehCustomer, vinOwner: true }] : []),
...(Array.isArray(DMSCustList) ? DMSCustList : [])
]);
return normalized;
} catch (error) {
CreateRRLogEvent(socket, "ERROR", `[RR] RRJobExport failed: ${error.message}`, { stack: error.stack });
RRLogger(socket, "error", `RR Insert Service Vehicle failed: ${error.message}`, { jobid: JobData?.id });
throw error;
}
}
/**
* 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;
try {
await setSessionTransactionData(
socket.id,
getTransactionType(jobid),
RRCacheEnums.selectedCustomerId,
selectedCustomerId,
defaultRRTTL
);
const JobData = await getSessionTransactionData(socket.id, getTransactionType(jobid), RRCacheEnums.JobData);
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 });
} else {
const createRes = await CreateCustomer({ socket, redisHelpers, JobData });
DMSCust = { customerId: createRes?.data || createRes?.customerId || createRes?.id };
}
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: 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,
getTransactionType(jobid),
RRCacheEnums.DMSTransHeader,
DMSTransHeader,
defaultRRTTL
);
// Post lines
const DMSBatchTxn = await TransBatchWip({ socket, redisHelpers, JobData });
await setSessionTransactionData(
socket.id,
getTransactionType(jobid),
RRCacheEnums.DMSBatchTxn,
DMSBatchTxn,
defaultRRTTL
);
// Decide success from envelope (heuristic until exact spec confirmed)
if (String(DMSBatchTxn?.rtnCode || "0") === "0") {
const DmsBatchTxnPost = await PostBatchWip({ socket, redisHelpers, JobData });
await setSessionTransactionData(
socket.id,
getTransactionType(jobid),
RRCacheEnums.DmsBatchTxnPost,
DmsBatchTxnPost,
defaultRRTTL
);
if (String(DmsBatchTxnPost?.rtnCode || "0") === "0") {
await MarkJobExported({ socket, jobid: JobData.id, redisHelpers });
// Optional service history write (non-blocking)
try {
const DMSVehHistory = await InsertServiceVehicleHistory({ socket, redisHelpers, JobData });
await setSessionTransactionData(
socket.id,
getTransactionType(jobid),
RRCacheEnums.DMSVehHistory,
DMSVehHistory,
defaultRRTTL
);
} catch (e) {
CreateRRLogEvent(socket, "WARN", `[RR] ServiceVehicleHistory optional step failed: ${e.message}`);
}
socket.emit("export-success", JobData.id);
} else {
await HandlePostingError({ socket, redisHelpers, JobData, DMSTransHeader });
}
} else {
await InsertFailedExportLog({
socket,
JobData,
error: `RR DMSBatchTxn not successful: ${JSON.stringify(DMSBatchTxn)}`
});
}
} catch (error) {
CreateRRLogEvent(socket, "ERROR", `[RR] RRSelectedCustomer failed: ${error.message}`, { stack: error.stack });
const JobData = await redisHelpers.getSessionTransactionData(
socket.id,
getTransactionType(jobid),
RRCacheEnums.JobData
);
if (JobData) await InsertFailedExportLog({ socket, JobData, error });
}
}
// -----------------------------------------------------------------------------
// GraphQL job fetch
// -----------------------------------------------------------------------------
async function QueryJobData({ socket, jobid }) {
const client = new GraphQLClient(process.env.GRAPHQL_ENDPOINT, {});
const currentToken =
(socket?.data && socket.data.authToken) || (socket?.handshake?.auth && socket.handshake.auth.token);
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). Replace request payloads once the
// exact RR/Rome schemas are confirmed from the PDFs.
// -----------------------------------------------------------------------------
async function GetVehicleId({ socket, redisHelpers, JobData }) {
return await MakeRRCall({
...RRActions.GetVehicleId,
requestPathParams: JobData.v_vin,
redisHelpers,
socket,
jobid: JobData.id
});
}
async function ReadVehicleById({ socket, redisHelpers, JobData, vehicleId }) {
return await MakeRRCall({
...RRActions.ReadVehicle,
requestPathParams: vehicleId,
redisHelpers,
socket,
jobid: JobData.id
});
}
async function ReadCustomerById({ socket, redisHelpers, JobData, customerId }) {
return await MakeRRCall({
...RRActions.ReadCustomer,
requestPathParams: customerId,
redisHelpers,
socket,
jobid: JobData.id
});
}
async function SearchCustomerByName({ socket, redisHelpers, JobData }) {
// 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]] // placeholder: business search
: [
["firstName", JobData.ownr_fn],
["lastName", JobData.ownr_ln]
];
return await MakeRRCall({
...RRActions.QueryCustomerByName, // ✅ use action defined in rr-helpers
requestSearchParams: ownerNameParams,
redisHelpers,
socket,
jobid: JobData.id
});
}
async function CreateCustomer({ socket, redisHelpers, JobData }) {
// TODO: Replace with exact RR Customer Insert envelope & fields
const body = {
customerType: JobData.ownr_co_nm ? "BUSINESS" : "INDIVIDUAL"
};
return await MakeRRCall({
...RRActions.CreateCustomer,
body,
redisHelpers,
socket,
jobid: JobData.id
});
}
async function InsertVehicle({ socket, redisHelpers, JobData /*, txEnvelope, DMSVid, DMSCust*/ }) {
// TODO: Replace with exact RR Service Vehicle Insert mapping
const body = {
vin: JobData.v_vin
// owners, make/model, odometer, etc…
};
return await MakeRRCall({
...RRActions.InsertVehicle,
body,
redisHelpers,
socket,
jobid: JobData.id
});
}
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 || "",
docType: "10",
m13Flag: "0",
refer: JobData.ro_number,
srcCo: JobData.bodyshop?.cdk_configuration?.srcco || "00", // placeholder from CDK config; RR equivalent TBD
srcJrnl: txEnvelope?.journal,
userID: "BSMS"
};
return await MakeRRCall({
...RRActions.StartWip,
body,
redisHelpers,
socket,
jobid: JobData.id
});
}
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,
redisHelpers,
socket,
jobid: JobData.id
});
}
async function PostBatchWip({ socket, redisHelpers, JobData }) {
const DMSTransHeader = await redisHelpers.getSessionTransactionData(
socket.id,
getTransactionType(JobData.id),
RRCacheEnums.DMSTransHeader
);
// TODO: Confirm final field names for “post” operation in RR
const body = {
opCode: "P",
transID: DMSTransHeader?.transID
};
return await MakeRRCall({
...RRActions.PostBatchWip,
body,
redisHelpers,
socket,
jobid: JobData.id
});
}
async function QueryErrWip({ socket, redisHelpers, JobData }) {
const DMSTransHeader = await redisHelpers.getSessionTransactionData(
socket.id,
getTransactionType(JobData.id),
RRCacheEnums.DMSTransHeader
);
return await MakeRRCall({
...RRActions.QueryErrorWip,
requestPathParams: DMSTransHeader?.transID,
redisHelpers,
socket,
jobid: JobData.id
});
}
async function DeleteWip({ socket, redisHelpers, JobData }) {
const DMSTransHeader = await redisHelpers.getSessionTransactionData(
socket.id,
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,
redisHelpers,
socket,
jobid: JobData.id
});
}
async function InsertServiceVehicleHistory({ socket, redisHelpers, JobData }) {
const txEnvelope = await redisHelpers.getSessionTransactionData(
socket.id,
getTransactionType(JobData.id),
RRCacheEnums.txEnvelope
);
// TODO: Replace with RR Service Vehicle History schema
const body = {
comments: txEnvelope?.story || ""
};
return await MakeRRCall({
...RRActions.ServiceHistoryInsert,
body,
redisHelpers,
socket,
jobid: JobData.id
});
}
async function HandlePostingError({ socket, redisHelpers, JobData /*, DMSTransHeader*/ }) {
const DmsError = await QueryErrWip({ socket, redisHelpers, JobData });
await DeleteWip({ socket, redisHelpers, JobData });
const errString = DmsError?.errMsg || JSON.stringify(DmsError);
errString?.split("|")?.forEach((e) => e && CreateRRLogEvent(socket, "ERROR", `[RR] Post error: ${e}`));
await InsertFailedExportLog({ socket, JobData, error: errString });
}
/**
* Convert app allocations to RR WIP lines.
* Re-uses existing CalculateAllocations to keep parity with CDK/Fortellis.
* Full DMS export sequence for Reynolds & Reynolds.
*
* TODO: Confirm exact RR posting model (accounts, control numbers, company ids,
* and whether amounts are signed or need separate debit/credit flags).
* 1. Ensure customer exists (insert or update)
* 2. Ensure vehicle exists/linked
* 3. Create or update repair order
*/
async function GenerateTransWips({ socket, redisHelpers, JobData }) {
const allocations = await CalculateAllocations(socket, JobData.id, true); // true==verbose logging
const DMSTransHeader = await redisHelpers.getSessionTransactionData(
socket.id,
getTransactionType(JobData.id),
RRCacheEnums.DMSTransHeader
);
async function ExportJobToRR({ socket, redisHelpers, JobData }) {
const jobid = JobData?.id;
const bodyshopId = socket?.bodyshopId || JobData?.bodyshopid;
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(), // sale is a credit in many GLs; confirm RR sign
transID: DMSTransHeader?.transID,
trgtCoID: JobData.bodyshop?.cdk_configuration?.srcco // RR equivalent TBD
});
}
if (alloc.cost.getAmount() > 0 && !alloc.tax) {
wips.push({
acct: alloc.costCenter.dms_acctnumber,
cntl: alloc.costCenter.dms_control_override || JobData.ro_number,
postAmt: alloc.cost.getAmount(),
transID: DMSTransHeader?.transID,
trgtCoID: JobData.bodyshop?.cdk_configuration?.srcco
});
wips.push({
acct: alloc.costCenter.dms_wip_acctnumber,
cntl: alloc.costCenter.dms_control_override || JobData.ro_number,
postAmt: alloc.cost.multiply(-1).getAmount(),
transID: DMSTransHeader?.transID,
trgtCoID: JobData.bodyshop?.cdk_configuration?.srcco
});
}
if (alloc.tax && alloc.sale.getAmount() > 0) {
wips.push({
acct: alloc.profitCenter.dms_acctnumber,
cntl: alloc.profitCenter.dms_control_override || JobData.ro_number,
postAmt: alloc.sale.multiply(-1).getAmount(),
transID: DMSTransHeader?.transID,
trgtCoID: JobData.bodyshop?.cdk_configuration?.srcco
});
}
});
RRLogger(socket, "info", "Starting RR job export", { jobid, bodyshopId });
const txEnvelope = await redisHelpers.getSessionTransactionData(
socket.id,
getTransactionType(JobData.id),
RRCacheEnums.txEnvelope
);
txEnvelope?.payers?.forEach((payer) => {
wips.push({
acct: payer.dms_acctnumber,
cntl: payer.controlnumber,
postAmt: Math.round(payer.amount * 100), // assuming cents (confirm RR units)
transID: DMSTransHeader?.transID,
trgtCoID: JobData.bodyshop?.cdk_configuration?.srcco
});
});
await redisHelpers.setSessionTransactionData(
socket.id,
getTransactionType(JobData.id),
RRCacheEnums.transWips,
wips,
defaultRRTTL
);
return wips;
}
// -----------------------------------------------------------------------------
// 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: JobData?.bodyshop?.md_ro_statuses?.default_exported || "Exported*",
date_exported: new Date()
},
log: {
bodyshopid: JobData?.bodyshop?.id,
jobid,
successful: true,
useremail: socket.user?.email,
metadata: transWips
},
bill: { exported: true, exported_at: new Date() }
});
}
async function InsertFailedExportLog({ socket, JobData, error }) {
try {
const client = new GraphQLClient(process.env.GRAPHQL_ENDPOINT, {});
const currentToken =
(socket?.data && socket.data.authToken) || (socket?.handshake?.auth && socket.handshake.auth.token);
// Pull dealer-level overrides once (DB), env/platform secrets come from rr-helpers internally.
const dealerConfig = bodyshopId ? await getDealerConfig(bodyshopId) : {};
return await client.setHeaders({ Authorization: `Bearer ${currentToken}` }).request(queries.INSERT_EXPORT_LOG, {
log: {
bodyshopid: JobData.bodyshop.id,
jobid: JobData.id,
successful: false,
message: typeof error === "string" ? error : JSON.stringify(error),
useremail: socket.user?.email
}
//
// STEP 1: CUSTOMER
//
RRLogger(socket, "info", "RR Step 1: Customer check/insert", { jobid });
let rrCustomerResult;
if (JobData?.rr_customer_id) {
rrCustomerResult = await RrCustomerUpdate({
socket,
redisHelpers,
JobData,
existingCustomer: { CustomerId: JobData.rr_customer_id },
patch: JobData.customer_patch
});
} else {
rrCustomerResult = await RrCustomerInsert({ socket, redisHelpers, JobData });
}
//
// STEP 2: VEHICLE
//
RRLogger(socket, "info", "RR Step 2: Vehicle insert", { jobid });
const rrVehicleResult = await RrServiceVehicleInsert({ socket, redisHelpers, JobData, dealerConfig });
//
// STEP 3: REPAIR ORDER
//
RRLogger(socket, "info", "RR Step 3: Repair Order create/update", { jobid });
let rrRepairOrderResult;
if (JobData?.rr_ro_id) {
rrRepairOrderResult = await UpdateRepairOrder({ socket, redisHelpers, JobData });
} else {
rrRepairOrderResult = await CreateRepairOrder({ socket, redisHelpers, JobData });
}
//
// FINALIZE
//
RRLogger(socket, "info", "RR Export completed successfully", {
jobid,
rr_customer_id: rrCustomerResult?.CustomerId || rrCustomerResult?.customerId,
rr_vehicle_id: rrVehicleResult?.VehicleId || rrVehicleResult?.vehicleId,
rr_ro_id: rrRepairOrderResult?.RepairOrderId || rrRepairOrderResult?.repairOrderId
});
} catch (error2) {
CreateRRLogEvent(socket, "ERROR", `Error in InsertFailedExportLog - ${error2.message}`, { stack: error2.stack });
return {
success: true,
data: {
customer: rrCustomerResult,
vehicle: rrVehicleResult,
repairOrder: rrRepairOrderResult
}
};
} catch (error) {
RRLogger(socket, "error", `RR job export failed: ${error.message}`, { jobid });
return {
success: false,
error: error.message,
stack: error.stack
};
}
}
module.exports = {
RRJobExport,
RRSelectedCustomer
ExportJobToRR,
RrServiceVehicleInsert
};

View File

@@ -1,49 +1,50 @@
// -----------------------------------------------------------------------------
// 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.
// -----------------------------------------------------------------------------
/**
* @file rr-logger.js
* @description Centralized logger for Reynolds & Reynolds (RR) integrations.
* Emits logs to CloudWatch via logger util, and back to client sockets for live visibility.
*/
const logger = require("../utils/logger");
/**
* Emit a structured RR log event to both the central logger and (if present)
* over the current socket connection for real-time UI visibility.
* Create a structured RR log event.
*
* @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)
* @param {object} socket - The socket or Express request (both supported).
* @param {"debug"|"info"|"warn"|"error"} level - Log level.
* @param {string} message - Human-readable log message.
* @param {object} [txnDetails] - Optional additional details (payloads, responses, etc.)
*/
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.
const RRLogger = (socket, level = "info", message, txnDetails = {}) => {
try {
logger.log("rr-log-event", level, userEmail, null, {
// Normalize level to uppercase for CloudWatch
const levelUpper = level.toUpperCase();
// Safe email and job correlation
const userEmail =
socket?.user?.email || socket?.request?.user?.email || socket?.handshake?.auth?.email || "unknown@user";
const jobid = socket?.JobData?.id || txnDetails?.jobid || null;
// Main logging entry (to CloudWatch / file)
logger.log("rr-log-event", levelUpper, userEmail, jobid, {
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 });
// Emit to live Socket.IO client if available
if (typeof socket.emit === "function") {
socket.emit("rr-log-event", {
level: levelUpper,
message,
txnDetails,
timestamp: new Date().toISOString()
});
}
} catch {
// Best-effort: never throw from logging
} catch (err) {
// As a fallback, log directly to console
console.error("RRLogger internal error:", err);
console.error("Original message:", message, txnDetails);
}
}
};
module.exports = CreateRRLogEvent;
module.exports = RRLogger;

View File

@@ -1,88 +1,143 @@
// -----------------------------------------------------------------------------
// Reynolds & Reynolds (RR) lookup helpers.
// Uses MakeRRCall + RRActions from rr-helpers, and shared response validation
// from rr-error.
//
// Whats 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.
// -----------------------------------------------------------------------------
/**
* @file rr-lookup.js
* @description Reynolds & Reynolds lookup operations
* (Combined Search, Get Advisors, Get Parts) via SOAP/XML templates.
*/
const { MakeRRCall, RRActions } = require("./rr-helpers");
const { assertRrOk } = require("./rr-error");
const { MakeRRCall, RRActions, getDealerConfig } = require("./rr-helpers");
const { assertRrOkXml, extractRrResponseData } = require("./rr-error");
const { mapCombinedSearchVars, mapGetAdvisorsVars, mapGetPartsVars } = require("./rr-mappers");
const RRLogger = require("./rr-logger");
/**
* RR Combined Search (Customer + Vehicle).
* Combined Search
* Maps to "Search Customer Service Vehicle Combined" spec (Rome)
*
* @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)
* @param {object} options
* @param {object} options.socket - Socket or Express req (used for auth + bodyshopId)
* @param {object} options.redisHelpers - (unused, kept for parity)
* @param {string} options.jobid - Job reference for correlation
* @param {Array<[string, string]>} [options.params] - e.g. [["VIN","1HG..."],["LastName","DOE"]]
*/
async function RrCombinedSearch({ socket, redisHelpers, jobid, params = [] }) {
const data = await MakeRRCall({
...RRActions.CombinedSearch, // GET /search/v1/customer-vehicle
requestSearchParams: params,
type: "get",
redisHelpers,
socket,
jobid
});
try {
RRLogger(socket, "info", "Starting RR Combined Search", { jobid, params });
// allowEmpty=true because searches may legitimately return 0 rows
return assertRrOk(data, { apiName: "RR Combined Search", allowEmpty: true });
const bodyshopId = socket?.bodyshopId || socket?.user?.bodyshopid;
const dealerConfig = bodyshopId ? await getDealerConfig(bodyshopId) : {};
// Build Mustache variables for server/rr/xml-templates/CombinedSearch.xml
const variables = mapCombinedSearchVars({ params, dealerConfig });
const xml = await MakeRRCall({
action: RRActions.CombinedSearch,
body: { template: "CombinedSearch", data: variables },
redisHelpers,
socket,
jobid
});
// Validate + normalize
const ok = assertRrOkXml(xml, { apiName: "RR Combined Search", allowEmpty: true });
const normalized = extractRrResponseData(ok, { action: "CombinedSearch" });
RRLogger(socket, "debug", "RR Combined Search complete", {
jobid,
count: Array.isArray(normalized) ? normalized.length : 0
});
return normalized;
} catch (error) {
RRLogger(socket, "error", `RR Combined Search failed: ${error.message}`, { jobid });
throw error;
}
}
/**
* RR Get Advisors.
* Get Advisors
* Maps to "Get Advisors Specification" (Rome)
*
* @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)
* @param {object} options
* @param {object} options.socket
* @param {object} options.redisHelpers
* @param {string} options.jobid
* @param {Array<[string, string]>} [options.params]
*/
async function RrGetAdvisors({ socket, redisHelpers, jobid, params = [] }) {
const data = await MakeRRCall({
...RRActions.GetAdvisors, // GET /advisors/v1
requestSearchParams: params,
type: "get",
redisHelpers,
socket,
jobid
});
return assertRrOk(data, { apiName: "RR Get Advisors", allowEmpty: true });
try {
RRLogger(socket, "info", "Starting RR Get Advisors", { jobid, params });
const bodyshopId = socket?.bodyshopId || socket?.user?.bodyshopid;
const dealerConfig = bodyshopId ? await getDealerConfig(bodyshopId) : {};
// Build Mustache variables for server/rr/xml-templates/GetAdvisors.xml
const variables = mapGetAdvisorsVars({ params, dealerConfig });
const xml = await MakeRRCall({
action: RRActions.GetAdvisors,
body: { template: "GetAdvisors", data: variables },
redisHelpers,
socket,
jobid
});
const ok = assertRrOkXml(xml, { apiName: "RR Get Advisors", allowEmpty: true });
const normalized = extractRrResponseData(ok, { action: "GetAdvisors" });
RRLogger(socket, "debug", "RR Get Advisors complete", {
jobid,
count: Array.isArray(normalized) ? normalized.length : 0
});
return normalized;
} catch (error) {
RRLogger(socket, "error", `RR Get Advisors failed: ${error.message}`, { jobid });
throw error;
}
}
/**
* RR Get Parts.
* Get Parts
* Maps to "Get Part Specification" (Rome)
*
* @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)
* @param {object} options
* @param {object} options.socket
* @param {object} options.redisHelpers
* @param {string} options.jobid
* @param {Array<[string, string]>} [options.params]
*/
async function RrGetParts({ socket, redisHelpers, jobid, params = [] }) {
const data = await MakeRRCall({
...RRActions.GetParts, // GET /parts/v1
requestSearchParams: params,
type: "get",
redisHelpers,
socket,
jobid
});
return assertRrOk(data, { apiName: "RR Get Parts", allowEmpty: true });
try {
RRLogger(socket, "info", "Starting RR Get Parts", { jobid, params });
const bodyshopId = socket?.bodyshopId || socket?.user?.bodyshopid;
const dealerConfig = bodyshopId ? await getDealerConfig(bodyshopId) : {};
// Build Mustache variables for server/rr/xml-templates/GetParts.xml
const variables = mapGetPartsVars({ params, dealerConfig });
const xml = await MakeRRCall({
action: RRActions.GetParts,
body: { template: "GetParts", data: variables },
redisHelpers,
socket,
jobid
});
const ok = assertRrOkXml(xml, { apiName: "RR Get Parts", allowEmpty: true });
const normalized = extractRrResponseData(ok, { action: "GetParts" });
RRLogger(socket, "debug", "RR Get Parts complete", {
jobid,
count: Array.isArray(normalized) ? normalized.length : 0
});
return normalized;
} catch (error) {
RRLogger(socket, "error", `RR Get Parts failed: ${error.message}`, { jobid });
throw error;
}
}
module.exports = { RrCombinedSearch, RrGetAdvisors, RrGetParts };
module.exports = {
RrCombinedSearch,
RrGetAdvisors,
RrGetParts
};

View File

@@ -1,333 +1,424 @@
// server/rr/rr-mappers.js
// -----------------------------------------------------------------------------
// Centralized mapping & normalization for Reynolds & Reynolds (RR)
// Centralized mapping for Reynolds & Reynolds (RR) XML templates.
// These functions take our domain objects (JobData, txEnvelope, current/patch)
// and produce the Mustache variable objects expected by the RR XML templates in
// /server/rr/xml-templates.
//
// This is scaffolding aligned to the Rome RR PDFs you provided:
// NOTE: This is still scaffolding. Where “TODO (spec)” appears, fill in the
// exact RR field semantics (type restrictions, enums, required/optional) based
// on the Rome RR PDFs you shared.
//
// - 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
// Templates these map into (variable names must match):
// - InsertCustomer.xml: <rr:CustomerInsertRq/>
// - UpdateCustomer.xml: <rr:CustomerUpdateRq/>
// - InsertServiceVehicle.xml: <rr:ServiceVehicleAddRq/>
// - CreateRepairOrder.xml: <rr:RepairOrderInsertRq/>
// - UpdateRepairOrder.xml: <rr:RepairOrderChgRq/>
//
// 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.
// All map* functions below return a plain object shaped for Mustache rendering.
// -----------------------------------------------------------------------------
const _ = require("lodash");
// Keep this consistent with other providers
const replaceSpecialRegex = /[^a-zA-Z0-9 .,\n #]+/g;
// Keep this consistent with other providers (sanitize strings for XML)
const REPLACE_SPECIAL = /[^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 sanitize(v) {
if (v === null || v === undefined) return null;
return String(v).replace(REPLACE_SPECIAL, "").trim();
}
function asStringOrNull(value) {
const s = sanitize(value);
return s && s.length > 0 ? s : null;
}
function toUpperOrNull(value) {
const s = asStringOrNull(value);
function upper(v) {
const s = sanitize(v);
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 asNumberOrNull(v) {
if (v === null || v === undefined || v === "") return null;
const n = Number(v);
return Number.isFinite(n) ? n : null;
}
function normalizePostal(raw) {
if (!raw) return null;
const s = String(raw).toUpperCase().replace(/\s+/g, "");
// If Canadian format (A1A1A1), keep as-is. Otherwise return raw sanitized.
return s.length === 6 ? `${s.slice(0, 3)} ${s.slice(3)}` : sanitize(raw);
}
/**
* Compose the dealer section used by every template.
* We prefer dealer-level rr_configuration first; fallback to env.
*/
function buildDealerVars(dealerCfg = {}) {
return {
DealerCode: dealerCfg.dealerCode || process.env.RR_DEALER_CODE || null,
DealerName: dealerCfg.dealerName || process.env.RR_DEALER_NAME || null,
DealerNumber: dealerCfg.dealerNumber || process.env.RR_DEALER_NUMBER || null,
StoreNumber: dealerCfg.storeNumber || process.env.RR_STORE_NUMBER || null,
BranchNumber: dealerCfg.branchNumber || process.env.RR_BRANCH_NUMBER || null
};
}
/* ------------------------------- Phones/Emails ------------------------------- */
function mapPhones({ ph1, ph2, mobile }) {
// TODO:RR — Replace "HOME|WORK|MOBILE" with RR's phone type codes + any flags (preferred, sms ok).
// TODO (spec): adjust PhoneType enumerations if RR requires strict codes.
const out = [];
if (ph1) out.push({ number: sanitize(ph1), type: "HOME" });
if (ph2) out.push({ number: sanitize(ph2), type: "WORK" });
if (mobile) out.push({ number: sanitize(mobile), type: "MOBILE" });
if (ph1) out.push({ PhoneNumber: sanitize(ph1), PhoneType: "HOME" });
if (ph2) out.push({ PhoneNumber: sanitize(ph2), PhoneType: "WORK" });
if (mobile) out.push({ PhoneNumber: sanitize(mobile), PhoneType: "MOBILE" });
return out;
}
function mapEmails({ email }) {
// TODO:RR — If RR supports multiple with flags, expand (preferred, statement, etc.).
if (!email) return [];
return [{ address: sanitize(email), type: "PERSONAL" }];
// TODO (spec): include EmailType (e.g., PERSONAL/WORK) if RR mandates it.
return [{ EmailAddress: sanitize(email), EmailType: "PERSONAL" }];
}
// ---------- Address/Contact from Rome JobData --------------------------------
/* -------------------------------- Addresses -------------------------------- */
function mapPostalAddressFromJob(job) {
// 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"
};
}
function mapPhonesFromJob(job) {
return mapPhones({
ph1: job.ownr_ph1,
ph2: job.ownr_ph2,
mobile: job.ownr_mobile
});
}
function mapEmailsFromJob(job) {
return mapEmails({ email: job.ownr_ea });
}
// ---------- Customer mappers --------------------------------------------------
/**
* Customer Insert
* Matches call-site: const body = mapCustomerInsert(JobData);
*
* 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() !== "");
return {
// Example envelope — rename to match the PDF (e.g., "CustomerInsertRq")
CustomerInsertRq: {
// 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: 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)
}
// TODO:RR — Common optional fields: tax/resale codes, pricing flags, AR terms, source codes, etc.
// taxCode: null,
// termsCode: null,
// marketingOptIn: null,
// dealerSpecificFields: []
return [
{
AddressLine1: sanitize(job.ownr_addr1),
AddressLine2: sanitize(job.ownr_addr2),
City: upper(job.ownr_city),
State: upper(job.ownr_st || job.ownr_state),
PostalCode: normalizePostal(job.ownr_zip),
Country: upper(job.ownr_ctry) || "USA"
}
].filter((addr) => Object.values(addr).some(Boolean));
}
/* --------------------------------- Customer -------------------------------- */
function mapCustomerInsert(job, dealerCfg = {}) {
const dealer = buildDealerVars(dealerCfg);
const isCompany = Boolean(job?.ownr_co_nm && String(job.ownr_co_nm).trim() !== "");
return {
...dealer,
// Envelope metadata (optional)
RequestId: job?.id || null,
Environment: process.env.NODE_ENV || "development",
// Customer node (see InsertCustomer.xml)
CustomerType: isCompany ? "ORGANIZATION" : "INDIVIDUAL",
CompanyName: isCompany ? upper(job.ownr_co_nm) : null,
FirstName: !isCompany ? upper(job.ownr_fn) : null,
LastName: !isCompany ? upper(job.ownr_ln) : null,
ActiveFlag: "Y",
Addresses: mapPostalAddressFromJob(job),
Phones: mapPhones({ ph1: job.ownr_ph1, ph2: job.ownr_ph2, mobile: job.ownr_mobile }),
Emails: mapEmails({ email: job.ownr_ea }),
// Optional blocks (keep null unless you truly have values)
DriverLicense: null, // { LicenseNumber, LicenseState, ExpirationDate }
Insurance: null, // { CompanyName, PolicyNumber, ExpirationDate }
Notes: null // { Note }
};
}
/**
* Customer Update
* Matches call-site: const body = mapCustomerUpdate(existingCustomer, patch);
*
* - existingCustomer: RR's current representation (from Read/Query)
* - patch: a thin delta from UI/Job selection
*
* TODO:RR — Swap envelope/fields for RR's specific Update schema.
*/
function mapCustomerUpdate(existingCustomer, patch = {}) {
function mapCustomerUpdate(existingCustomer, patch = {}, dealerCfg = {}) {
const dealer = buildDealerVars(dealerCfg);
// We merge and normalize so callers can pass minimal deltas
const merged = _.merge({}, existingCustomer || {}, patch || {});
const id = merged?.customerId || merged?.id || merged?.CustomerId || merged?.customer?.id || null;
const id =
merged?.CustomerId ||
merged?.customerId ||
merged?.id ||
merged?.customer?.id ||
patch?.CustomerId ||
patch?.customerId ||
null;
const isCompany = Boolean(merged?.customerName?.companyName) || Boolean(merged?.companyName);
// Derive company vs individual
const isCompany = Boolean(merged?.CompanyName || merged?.customerName?.companyName);
const normalizedName = {
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 nameBlock = {
CompanyName: isCompany ? upper(merged?.CompanyName || merged?.customerName?.companyName) : null,
FirstName: !isCompany ? upper(merged?.FirstName || merged?.customerName?.firstName) : null,
LastName: !isCompany ? upper(merged?.LastName || merged?.customerName?.lastName) : null
};
const normalizedAddress = {
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) ||
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"
};
// Addresses
const addr =
merged?.Addresses ||
merged?.postalAddress ||
(merged?.addressLine1 || merged?.addressLine2 || merged?.city
? [
{
AddressLine1: sanitize(merged?.addressLine1),
AddressLine2: sanitize(merged?.addressLine2),
City: upper(merged?.city),
State: upper(merged?.state || merged?.province),
PostalCode: normalizePostal(merged?.postalCode),
Country: upper(merged?.country) || "USA"
}
]
: null);
// Contacts (reuse existing unless patch supplied a new structure upstream)
const normalizedPhones = merged?.contactMethods?.phones || merged?.phones || [];
const normalizedEmails = merged?.contactMethods?.emailAddresses || merged?.emailAddresses || [];
// Phones & Emails
const phones = merged?.Phones || merged?.contactMethods?.phones || [];
const emails = merged?.Emails || merged?.contactMethods?.emailAddresses || [];
return {
// Example envelope — rename to match the PDF (e.g., "CustomerUpdateRq")
CustomerUpdateRq: {
customerId: id,
customerType: normalizedName.companyName ? "ORGANIZATION" : "INDIVIDUAL",
customerName: normalizedName,
postalAddress: normalizedAddress,
contactMethods: {
phones: normalizedPhones,
emailAddresses: normalizedEmails
}
// TODO:RR — include fields that RR requires for update (version, hash, lastUpdatedTs, etc.)
}
...dealer,
RequestId: merged?.RequestId || null,
Environment: process.env.NODE_ENV || "development",
CustomerId: id,
CustomerType: isCompany ? "ORGANIZATION" : "INDIVIDUAL",
...nameBlock,
ActiveFlag: merged?.ActiveFlag || "Y",
Addresses: addr,
Phones: phones.map((p) => ({ PhoneNumber: sanitize(p.PhoneNumber || p.number), PhoneType: p.PhoneType || p.type })),
Emails: emails.map((e) => ({
EmailAddress: sanitize(e.EmailAddress || e.address),
EmailType: e.EmailType || e.type || "PERSONAL"
})),
// Optional
DriverLicense: merged?.DriverLicense || null,
Insurance: merged?.Insurance || null,
Notes: merged?.Notes || null
};
}
// ---------- Vehicle mappers ---------------------------------------------------
/* --------------------------------- Vehicle --------------------------------- */
function mapVehicleInsertFromJob(job, dealerCfg = {}, opts = {}) {
// opts: { customerId }
const dealer = buildDealerVars(dealerCfg);
/**
* 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 = {}) {
return {
ServiceVehicleInsertRq: {
vin: asStringOrNull(job.v_vin),
// Year/make/model — validate source fields vs RR required fields
year: job.v_model_yr || 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.
}
...dealer,
RequestId: job?.id || null,
Environment: process.env.NODE_ENV || "development",
CustomerId: opts?.customerId || null,
VIN: upper(job?.v_vin),
Year: asNumberOrNull(job?.v_model_yr),
Make: upper(job?.v_make),
Model: upper(job?.v_model),
Trim: upper(job?.v_trim),
BodyStyle: upper(job?.v_body),
Transmission: upper(job?.v_transmission),
Engine: upper(job?.v_engine),
FuelType: upper(job?.v_fuel),
Color: upper(job?.v_color),
Odometer: asNumberOrNull(job?.odometer_out || job?.kmout),
LicensePlate: upper(job?.plate_no),
LicenseState: upper(job?.plate_state),
Ownership: null,
Insurance: null,
VehicleNotes: null,
Warranty: null
};
}
// ---------- Repair Order mappers ---------------------------------------------
/* ------------------------------- Repair Orders ------------------------------ */
function mapRepairOrderAddFromJob(job, txEnvelope = {}, dealerCfg = {}) {
const dealer = buildDealerVars(dealerCfg);
const customerVars = {
CustomerId: job?.customer?.id || txEnvelope?.customerId || null,
CustomerName:
upper(job?.ownr_co_nm) || [upper(job?.ownr_fn), upper(job?.ownr_ln)].filter(Boolean).join(" ").trim() || null,
PhoneNumber: sanitize(job?.ownr_ph1 || job?.ownr_mobile || job?.ownr_ph2),
EmailAddress: sanitize(job?.ownr_ea)
};
const vehicleVars = {
VIN: upper(job?.v_vin),
LicensePlate: upper(job?.plate_no),
Year: asNumberOrNull(job?.v_model_yr),
Make: upper(job?.v_make),
Model: upper(job?.v_model),
Odometer: asNumberOrNull(job?.odometer_out || job?.kmout),
Color: upper(job?.v_color)
};
/**
* 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 {
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,
...dealer,
RequestId: job?.id || null,
Environment: process.env.NODE_ENV || "development",
// Lines (placeholder)
lines: Array.isArray(JobData?.joblines) ? JobData.joblines.map(mapJobLineToRRLine) : [],
RepairOrderNumber: sanitize(job?.ro_number) || sanitize(txEnvelope?.reference) || null,
OpenDate: txEnvelope?.openedAt || job?.actual_in || null,
PromisedDate: txEnvelope?.promisedAt || job?.promise_date || null,
CloseDate: txEnvelope?.closedAt || job?.invoice_date || null,
ServiceAdvisorId: txEnvelope?.advisorId || job?.service_advisor_id || null,
TechnicianId: txEnvelope?.technicianId || job?.technician_id || null,
ROType: txEnvelope?.roType || "CUSTOMER_PAY", // TODO (spec): map from our job type(s)
Status: txEnvelope?.status || "OPEN",
// Taxes (placeholder)
taxes: mapTaxes(JobData),
CustomerId: customerVars.CustomerId,
CustomerName: customerVars.CustomerName,
PhoneNumber: customerVars.PhoneNumber,
EmailAddress: customerVars.EmailAddress,
// Payments (placeholder)
payments: mapPayments(txEnvelope)
VIN: vehicleVars.VIN,
LicensePlate: vehicleVars.LicensePlate,
Year: vehicleVars.Year,
Make: vehicleVars.Make,
Model: vehicleVars.Model,
Odometer: vehicleVars.Odometer,
Color: vehicleVars.Color,
// TODO:RR — add required flags, shop supplies, labor matrix, discounts, etc.
}
JobLines: (job?.joblines || txEnvelope?.lines || []).map((ln, idx) => mapJobLineToRRLine(ln, idx + 1)),
Totals: txEnvelope?.totals
? {
LaborTotal: asNumberOrNull(txEnvelope.totals.labor),
PartsTotal: asNumberOrNull(txEnvelope.totals.parts),
MiscTotal: asNumberOrNull(txEnvelope.totals.misc),
TaxTotal: asNumberOrNull(txEnvelope.totals.tax),
GrandTotal: asNumberOrNull(txEnvelope.totals.total)
}
: null,
Insurance: txEnvelope?.insurance
? {
CompanyName: upper(txEnvelope.insurance.company),
ClaimNumber: sanitize(txEnvelope.insurance.claim),
AdjusterName: upper(txEnvelope.insurance.adjuster),
AdjusterPhone: sanitize(txEnvelope.insurance.phone)
}
: null,
Notes: txEnvelope?.story ? { Note: sanitize(txEnvelope.story) } : null
};
}
/**
* 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 {
RepairOrderUpdateRq: {
repairOrderId: JobData?.id || txEnvelope?.repairOrderId || null,
referenceNumber: asStringOrNull(JobData?.ro_number),
function mapRepairOrderChangeFromJob(current, delta = {}, dealerCfg = {}) {
// current: existing RO (our cached shape)
// delta: patch object describing header fields and line changes
const dealer = buildDealerVars(dealerCfg);
// 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.
}
};
}
/* ----- 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 {
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
const added = (delta.addedLines || []).map((ln, i) =>
mapJobLineToRRLine(ln, ln.Sequence || ln.seq || i + 1, { includePayType: true })
);
const updated = (delta.updatedLines || []).map((ln) => ({
...mapJobLineToRRLine(ln, ln.Sequence || ln.seq, { includePayType: true }),
ChangeType: ln.ChangeType || ln.change || null,
LineId: ln.LineId || null
}));
const removed = (delta.removedLines || []).map((ln) => ({
LineId: ln.LineId || null,
Sequence: ln.Sequence || ln.seq || null,
OpCode: upper(ln.OpCode || ln.opCode) || null,
Reason: sanitize(ln.Reason || ln.reason) || null
}));
const totals = delta?.totals
? {
LaborTotal: asNumberOrNull(delta.totals.labor),
PartsTotal: asNumberOrNull(delta.totals.parts),
MiscTotal: asNumberOrNull(delta.totals.misc),
TaxTotal: asNumberOrNull(delta.totals.tax),
GrandTotal: asNumberOrNull(delta.totals.total)
}
: null;
const insurance = delta?.insurance
? {
CompanyName: upper(delta.insurance.company),
ClaimNumber: sanitize(delta.insurance.claim),
AdjusterName: upper(delta.insurance.adjuster),
AdjusterPhone: sanitize(delta.insurance.phone)
}
: null;
const notes =
Array.isArray(delta?.notes) && delta.notes.length
? { Items: delta.notes.map((n) => sanitize(n)).filter(Boolean) }
: null;
return {
...dealer,
RequestId: delta?.RequestId || current?.RequestId || null,
Environment: process.env.NODE_ENV || "development",
RepairOrderId: current?.RepairOrderId || delta?.RepairOrderId || null,
RepairOrderNumber: delta?.RepairOrderNumber || current?.RepairOrderNumber || null,
Status: delta?.Status || null,
ROType: delta?.ROType || null,
OpenDate: delta?.OpenDate || null,
PromisedDate: delta?.PromisedDate || null,
CloseDate: delta?.CloseDate || null,
ServiceAdvisorId: delta?.ServiceAdvisorId || null,
TechnicianId: delta?.TechnicianId || null,
LocationCode: delta?.LocationCode || null,
Department: delta?.Department || null,
PurchaseOrder: delta?.PurchaseOrder || null,
// Optional customer/vehicle patches
Customer: delta?.Customer || null,
Vehicle: delta?.Vehicle || null,
// Line changes
AddedJobLines: added.length ? added : null,
UpdatedJobLines: updated.length ? updated : null,
RemovedJobLines: removed.length ? removed : null,
Totals: totals,
Insurance: insurance,
Notes: notes
};
}
// ---------- Exports -----------------------------------------------------------
/* ------------------------------- Line Mapping ------------------------------- */
function mapJobLineToRRLine(line, sequenceFallback, opts = {}) {
// opts.includePayType => include PayType when present (CUST|INS|WARR|INT)
const qty = asNumberOrNull(line?.Quantity || line?.qty || line?.part_qty || 1);
const unit = asNumberOrNull(line?.UnitPrice || line?.price || line?.unitPrice);
const ext = asNumberOrNull(line?.ExtendedPrice || (qty && unit ? qty * unit : line?.extended));
return {
Sequence: asNumberOrNull(line?.Sequence || line?.seq) || asNumberOrNull(sequenceFallback),
OpCode: upper(line?.OpCode || line?.opCode || line?.opcode),
Description: sanitize(line?.Description || line?.description || line?.desc || line?.story),
LaborHours: asNumberOrNull(line?.LaborHours || line?.laborHours),
LaborRate: asNumberOrNull(line?.LaborRate || line?.laborRate),
PartNumber: upper(line?.PartNumber || line?.partNumber || line?.part_no),
PartDescription: sanitize(line?.PartDescription || line?.partDescription || line?.part_desc),
Quantity: qty,
UnitPrice: unit,
ExtendedPrice: ext,
TaxCode: upper(line?.TaxCode || line?.taxCode) || null,
PayType: opts.includePayType ? upper(line?.PayType || line?.payType) || null : undefined,
Reason: sanitize(line?.Reason || line?.reason) || null
};
}
// -----------------------------------------------------------------------------
module.exports = {
// Used by current call-sites:
// Customer
mapCustomerInsert,
mapCustomerUpdate,
mapRepairOrderCreate,
mapRepairOrderUpdate,
// Extra scaffolds youll likely use soon:
// Vehicle
mapVehicleInsertFromJob,
mapJobLineToRRLine,
mapTaxes,
mapPayments,
// Low-level utils (handy in tests)
// Repair orders
mapRepairOrderAddFromJob,
mapRepairOrderChangeFromJob,
mapJobLineToRRLine,
// shared utils (handy in tests)
buildDealerVars,
_sanitize: sanitize,
_normalizePostal: normalizePostal,
_toUpperOrNull: toUpperOrNull
_upper: upper,
_normalizePostal: normalizePostal
};

View File

@@ -1,76 +1,144 @@
// 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.
//
// Whats 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
// -----------------------------------------------------------------------------
/**
* @file rr-repair-orders.js
* @description Reynolds & Reynolds (Rome) Repair Order Create & Update.
* Implements the "Create Body Shop Management Repair Order" and
* "Update Body Shop Management Repair Order" specifications.
*/
const { MakeRRCall, RRActions } = require("./rr-helpers");
const { assertRrOk } = require("./rr-error");
const { mapRepairOrderAddFromJob, mapRepairOrderChangeFromJob } = require("./rr-mappers");
const { mapRepairOrderCreate, mapRepairOrderUpdate } = require("./rr-mappers");
const RRLogger = require("./rr-logger");
const { client } = require("../graphql-client/graphql-client");
const { GET_BODYSHOP_BY_ID } = require("../graphql-client/queries");
/**
* Create a Repair Order in RR.
* Fetch rr_configuration for the current bodyshop directly from DB.
* Dealer-specific configuration is mandatory for RR operations.
*/
async function getDealerConfigFromDB(bodyshopId, logger) {
try {
const result = await client.request(GET_BODYSHOP_BY_ID, { id: bodyshopId });
const config = result?.bodyshops_by_pk?.rr_configuration || null;
if (!config) {
throw new Error(`No rr_configuration found for bodyshop ID ${bodyshopId}`);
}
logger?.debug?.(`Fetched rr_configuration for bodyshop ${bodyshopId}`, config);
return config;
} catch (error) {
logger?.log?.("rr-get-dealer-config", "ERROR", "rr", null, {
bodyshopId,
message: error.message,
stack: error.stack
});
throw error;
}
}
/**
* CREATE REPAIR ORDER
* Based on "Rome Create Body Shop Management Repair Order Specification"
*
* @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)
* @param {object} options
* @param {object} options.socket - socket or express request
* @param {object} options.redisHelpers
* @param {object} options.JobData - internal job object
* @param {object} [options.txEnvelope] - transaction metadata (advisor, timestamps, etc.)
*/
async function CreateRepairOrder({ socket, redisHelpers, JobData, txEnvelope }) {
// Map JobData (+ optional txEnvelope) -> RR "Repair Order Add" request body
const body = mapRepairOrderAddFromJob({ ...JobData, txEnvelope });
const bodyshopId = socket?.bodyshopId || JobData?.bodyshopid;
const logger = socket?.logger || console;
const data = await MakeRRCall({
...RRActions.CreateRepairOrder, // POST /repair-orders/v1
body,
redisHelpers,
socket,
jobid: JobData?.id
});
try {
RRLogger(socket, "info", "RR Create Repair Order started", {
jobid: JobData?.id,
bodyshopId
});
// TODO: Update assertRrOk once RRs success envelope is finalized
return assertRrOk(data, { apiName: "RR Create Repair Order" });
const dealerConfig = await getDealerConfigFromDB(bodyshopId, logger);
// Build Mustache variables for server/rr/xml-templates/CreateRepairOrder.xml
const vars = mapRepairOrderCreate({ JobData, txEnvelope, dealerConfig });
const data = await MakeRRCall({
action: RRActions.CreateRepairOrder, // resolves SOAPAction+URL
body: { template: "CreateRepairOrder", data: vars }, // render XML template
redisHelpers,
socket,
jobid: JobData.id
});
const response = assertRrOk(data, { apiName: "RR Create Repair Order" });
RRLogger(socket, "debug", "RR Create Repair Order success", {
jobid: JobData?.id,
dealer: dealerConfig?.dealer_code || dealerConfig?.dealerCode
});
return response;
} catch (error) {
RRLogger(socket, "error", `RR Create Repair Order failed: ${error.message}`, {
jobid: JobData?.id
});
throw error;
}
}
/**
* Update a Repair Order in RR.
* UPDATE REPAIR ORDER
* Based on "Rome Update Body Shop Management Repair Order Specification"
*
* 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)
* @param {object} options
* @param {object} options.socket
* @param {object} options.redisHelpers
* @param {object} options.JobData
* @param {object} [options.txEnvelope]
*/
async function UpdateRepairOrder({ socket, redisHelpers, JobData, txEnvelope, repairOrderId }) {
const body = mapRepairOrderChangeFromJob({ ...JobData, txEnvelope });
async function UpdateRepairOrder({ socket, redisHelpers, JobData, txEnvelope }) {
const bodyshopId = socket?.bodyshopId || JobData?.bodyshopid;
const logger = socket?.logger || console;
const data = await MakeRRCall({
...RRActions.UpdateRepairOrder, // PUT /repair-orders/v1 (or /v1/{id})
...(repairOrderId ? { requestPathParams: String(repairOrderId) } : {}),
body,
redisHelpers,
socket,
jobid: JobData?.id
});
try {
RRLogger(socket, "info", "RR Update Repair Order started", {
jobid: JobData?.id,
bodyshopId,
rr_ro_id: JobData?.rr_ro_id
});
return assertRrOk(data, { apiName: "RR Update Repair Order" });
const dealerConfig = await getDealerConfigFromDB(bodyshopId, logger);
// Build Mustache variables for server/rr/xml-templates/UpdateRepairOrder.xml
const vars = mapRepairOrderUpdate({ JobData, txEnvelope, dealerConfig });
const data = await MakeRRCall({
action: RRActions.UpdateRepairOrder, // resolves SOAPAction+URL
body: { template: "UpdateRepairOrder", data: vars }, // render XML template
redisHelpers,
socket,
jobid: JobData.id
});
const response = assertRrOk(data, { apiName: "RR Update Repair Order" });
RRLogger(socket, "debug", "RR Update Repair Order success", {
jobid: JobData?.id,
rr_ro_id: JobData?.rr_ro_id
});
return response;
} catch (error) {
RRLogger(socket, "error", `RR Update Repair Order failed: ${error.message}`, {
jobid: JobData?.id,
rr_ro_id: JobData?.rr_ro_id
});
throw error;
}
}
module.exports = { CreateRepairOrder, UpdateRepairOrder };
module.exports = {
CreateRepairOrder,
UpdateRepairOrder,
getDealerConfigFromDB
};

127
server/rr/rr-test.js Normal file
View File

@@ -0,0 +1,127 @@
// node server/rr/rr-test.js
/**
* @file rr-test.js
* @description Diagnostic test script for Reynolds & Reynolds (R&R) integration.
* Run with: NODE_ENV=development node server/rr/rr-test.js
*/
const path = require("path");
require("dotenv").config({
path: path.resolve(__dirname, "../../", `.env.${process.env.NODE_ENV || "development"}`)
});
const fs = require("fs/promises");
const mustache = require("mustache");
const { getBaseRRConfig } = require("./rr-constants");
const { RRActions, MakeRRCall } = require("./rr-helpers");
const RRLogger = require("./rr-logger");
// --- Mock socket + redis helpers for standalone test
const socket = {
bodyshopId: process.env.TEST_BODYSHOP_ID || null,
user: { email: "test@romeonline.io" },
emit: (event, data) => console.log(`[SOCKET EVENT] ${event}`, data),
logger: console
};
const redisHelpers = {
setSessionData: async () => {},
getSessionData: async () => {},
setSessionTransactionData: async () => {},
getSessionTransactionData: async () => {},
clearSessionTransactionData: async () => {}
};
(async () => {
try {
console.log("=== Reynolds & Reynolds Integration Test ===");
console.log("NODE_ENV:", process.env.NODE_ENV);
const baseCfg = getBaseRRConfig();
console.log("Base R&R Config (from env):", {
baseUrl: baseCfg.baseUrl,
hasUser: !!baseCfg.username || !!process.env.RR_API_USER || !!process.env.RR_USERNAME,
hasPass: !!baseCfg.password || !!process.env.RR_API_PASS || !!process.env.RR_PASSWORD,
timeout: baseCfg.timeout
});
// ---- test variables for GetAdvisors
const templateVars = {
DealerCode: process.env.RR_DEALER_NAME || "ROME",
DealerName: "Rome Collision Test",
SearchCriteria: {
Department: "Body Shop",
Status: "ACTIVE"
}
};
// Dealer/Store/Branch/PPSysId can come from rr_configuration or env; for test we override:
const dealerConfigOverride = {
// baseUrl can also be overridden here if you want
ppsysid: process.env.RR_PPSYSID || process.env.RR_PP_SYS_ID || process.env.RR_PP_SYSID || "TEST-PPSYSID",
dealer_number: process.env.RR_DEALER_NUMBER || "12345",
store_number: process.env.RR_STORE_NUMBER || "01",
branch_number: process.env.RR_BRANCH_NUMBER || "001",
// creds (optional here; MakeRRCall will fallback to env if omitted)
username: process.env.RR_API_USER || process.env.RR_USERNAME || "Rome",
password: process.env.RR_API_PASS || process.env.RR_PASSWORD || "secret"
};
// Show the first ~600 chars of the envelope we will send (by rendering the template + header)
// NOTE: This is just for printing; MakeRRCall will rebuild with proper header internally.
const templatePath = path.join(__dirname, "xml-templates", "GetAdvisors.xml");
const tpl = await fs.readFile(templatePath, "utf8");
const renderedBody = mustache.render(tpl, templateVars);
// Build a preview envelope using the same helper used by MakeRRCall
const { renderXmlTemplate } = require("./rr-helpers");
const headerPreview = await renderXmlTemplate("_EnvelopeHeader", {
PPSysId: dealerConfigOverride.ppsysid,
DealerNumber: dealerConfigOverride.dealer_number,
StoreNumber: dealerConfigOverride.store_number,
BranchNumber: dealerConfigOverride.branch_number,
Username: dealerConfigOverride.username,
Password: dealerConfigOverride.password,
CorrelationId: "preview-correlation"
});
const previewEnvelope = `
<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/" xmlns:rr="http://reynoldsandrey.com/">
<soapenv:Header>
${headerPreview}
</soapenv:Header>
<soapenv:Body>
${renderedBody}
</soapenv:Body>
</soapenv:Envelope>`.trim();
console.log("\n--- Rendered SOAP Envelope (first 600 chars) ---\n");
console.log(previewEnvelope.slice(0, 600));
console.log("... [truncated]\n");
// If we don't have a base URL, skip the live call
if (!baseCfg.baseUrl) {
console.warn("\n⚠ No RR baseUrl defined. Skipping live call.\n");
return;
}
console.log(`--- Sending SOAP Request: ${RRActions.GetAdvisors.action} ---\n`);
const responseXml = await MakeRRCall({
action: "GetAdvisors",
baseUrl: process.env.RR_API_BASE_URL,
body: { template: "GetAdvisors", data: templateVars },
dealerConfig: dealerConfigOverride,
redisHelpers,
socket,
jobid: "test-job",
retries: 1
});
RRLogger(socket, "info", "RR test successful", { bytes: Buffer.byteLength(responseXml, "utf8") });
console.log("\n✅ Test completed successfully.\n");
} catch (error) {
console.error("\n❌ Test failed:", error.message);
console.error(error.stack);
}
})();

97
server/rr/rr-wsdl.js Normal file
View File

@@ -0,0 +1,97 @@
/**
* RR WSDL / SOAP XML Transport Layer (thin wrapper)
* -------------------------------------------------
* Delegates to rr-helpers.MakeRRCall (which handles:
* - fetching dealer config from DB via resolveRRConfig
* - rendering Mustache XML templates
* - building SOAP envelope + headers
* - axios POST + retries
*
* Use this when you prefer the "action + variables" style and (optionally)
* want a parsed Body node back instead of raw XML.
*/
const { XMLParser } = require("fast-xml-parser");
const logger = require("../utils/logger");
const { MakeRRCall, resolveRRConfig, renderXmlTemplate } = require("./rr-helpers");
// Map friendly action names to template filenames (no envelope here; helpers add it)
const RR_ACTION_MAP = {
CustomerInsert: { file: "InsertCustomer.xml" },
CustomerUpdate: { file: "UpdateCustomer.xml" },
ServiceVehicleInsert: { file: "InsertServiceVehicle.xml" },
CombinedSearch: { file: "CombinedSearch.xml" },
GetParts: { file: "GetParts.xml" },
GetAdvisors: { file: "GetAdvisors.xml" },
CreateRepairOrder: { file: "CreateRepairOrder.xml" },
UpdateRepairOrder: { file: "UpdateRepairOrder.xml" }
};
/**
* Optionally render just the body XML for a given action (no SOAP envelope).
* Mostly useful for diagnostics/tests.
*/
async function buildRRXml(action, variables = {}) {
const entry = RR_ACTION_MAP[action];
if (!entry) throw new Error(`Unknown RR action: ${action}`);
const templateName = entry.file.replace(/\.xml$/i, "");
return renderXmlTemplate(templateName, variables);
}
/**
* Send an RR SOAP request using helpers (action + variables).
* @param {object} opts
* @param {string} opts.action One of RR_ACTION_MAP keys (and RR_ACTIONS in rr-constants)
* @param {object} opts.variables Mustache variables for the body template
* @param {object} opts.socket Socket/req for context (bodyshopId + auth)
* @param {boolean} [opts.raw=false] If true, returns raw XML string
* @param {number} [opts.retries=1] Transient retry attempts (5xx/network)
* @returns {Promise<string|object>} Raw XML (raw=true) or parsed Body node
*/
async function sendRRRequest({ action, variables = {}, socket, raw = false, retries = 1 }) {
const entry = RR_ACTION_MAP[action];
if (!entry) throw new Error(`Unknown RR action: ${action}`);
const templateName = entry.file.replace(/\.xml$/i, "");
const dealerConfig = await resolveRRConfig(socket);
// Let MakeRRCall render + envelope + post
const xml = await MakeRRCall({
action,
body: { template: templateName, data: variables },
socket,
dealerConfig,
retries
});
if (raw) return xml;
try {
const parser = new XMLParser({ ignoreAttributes: false });
const parsed = parser.parse(xml);
// Try several common namespace variants for Envelope/Body
const bodyNode =
parsed?.Envelope?.Body ||
parsed?.["soapenv:Envelope"]?.["soapenv:Body"] ||
parsed?.["SOAP-ENV:Envelope"]?.["SOAP-ENV:Body"] ||
parsed?.["S:Envelope"]?.["S:Body"] ||
parsed;
return bodyNode;
} catch (err) {
logger.log("rr-wsdl-parse-error", "ERROR", "RR", null, {
action,
message: err.message,
stack: err.stack
});
// If parsing fails, return raw so caller can inspect
return xml;
}
}
module.exports = {
sendRRRequest,
buildRRXml,
RR_ACTION_MAP
};

View File

@@ -0,0 +1,73 @@
<rr:CombinedSearchRq xmlns:rr="http://reynoldsandrey.com/">
<!-- Optional request metadata -->
{{#RequestId}}
<rr:RequestId>{{RequestId}}</rr:RequestId>
{{/RequestId}}
{{#Environment}}
<rr:Environment>{{Environment}}</rr:Environment>
{{/Environment}}
<rr:Dealer>
<rr:DealerCode>{{DealerCode}}</rr:DealerCode>
{{#DealerName}}
<rr:DealerName>{{DealerName}}</rr:DealerName>
{{/DealerName}}
{{#DealerNumber}}
<rr:DealerNumber>{{DealerNumber}}</rr:DealerNumber>
{{/DealerNumber}}
{{#StoreNumber}}
<rr:StoreNumber>{{StoreNumber}}</rr:StoreNumber>
{{/StoreNumber}}
{{#BranchNumber}}
<rr:BranchNumber>{{BranchNumber}}</rr:BranchNumber>
{{/BranchNumber}}
</rr:Dealer>
<rr:SearchCriteria>
{{#Customer}}
<rr:Customer>
{{#FirstName}}<rr:FirstName>{{FirstName}}</rr:FirstName>{{/FirstName}}
{{#LastName}}<rr:LastName>{{LastName}}</rr:LastName>{{/LastName}}
{{#PhoneNumber}}<rr:PhoneNumber>{{PhoneNumber}}</rr:PhoneNumber>{{/PhoneNumber}}
{{#EmailAddress}}<rr:EmailAddress>{{EmailAddress}}</rr:EmailAddress>{{/EmailAddress}}
{{#CompanyName}}<rr:CompanyName>{{CompanyName}}</rr:CompanyName>{{/CompanyName}}
{{#CustomerId}}<rr:CustomerId>{{CustomerId}}</rr:CustomerId>{{/CustomerId}}
</rr:Customer>
{{/Customer}}
{{#Vehicle}}
<rr:ServiceVehicle>
{{#VIN}}<rr:VIN>{{VIN}}</rr:VIN>{{/VIN}}
{{#LicensePlate}}<rr:LicensePlate>{{LicensePlate}}</rr:LicensePlate>{{/LicensePlate}}
{{#Make}}<rr:Make>{{Make}}</rr:Make>{{/Make}}
{{#Model}}<rr:Model>{{Model}}</rr:Model>{{/Model}}
{{#Year}}<rr:Year>{{Year}}</rr:Year>{{/Year}}
{{#VehicleId}}<rr:VehicleId>{{VehicleId}}</rr:VehicleId>{{/VehicleId}}
</rr:ServiceVehicle>
{{/Vehicle}}
{{#Company}}
<rr:Company>
{{#Name}}<rr:Name>{{Name}}</rr:Name>{{/Name}}
{{#Phone}}<rr:Phone>{{Phone}}</rr:Phone>{{/Phone}}
</rr:Company>
{{/Company}}
<!-- Search behavior flags (all optional) -->
{{#SearchMode}}<rr:SearchMode>{{SearchMode}}</rr:SearchMode>{{/SearchMode}}
{{#ExactMatch}}<rr:ExactMatch>{{ExactMatch}}</rr:ExactMatch>{{/ExactMatch}}
{{#PartialMatch}}<rr:PartialMatch>{{PartialMatch}}</rr:PartialMatch>{{/PartialMatch}}
{{#CaseInsensitive}}<rr:CaseInsensitive>{{CaseInsensitive}}</rr:CaseInsensitive>{{/CaseInsensitive}}
<!-- Result shaping (all optional) -->
{{#ReturnCustomers}}<rr:ReturnCustomers>{{ReturnCustomers}}</rr:ReturnCustomers>{{/ReturnCustomers}}
{{#ReturnVehicles}}<rr:ReturnVehicles>{{ReturnVehicles}}</rr:ReturnVehicles>{{/ReturnVehicles}}
{{#ReturnCompanies}}<rr:ReturnCompanies>{{ReturnCompanies}}</rr:ReturnCompanies>{{/ReturnCompanies}}
<!-- Paging/sorting (all optional) -->
{{#MaxResults}}<rr:MaxResults>{{MaxResults}}</rr:MaxResults>{{/MaxResults}}
{{#PageNumber}}<rr:PageNumber>{{PageNumber}}</rr:PageNumber>{{/PageNumber}}
{{#SortBy}}<rr:SortBy>{{SortBy}}</rr:SortBy>{{/SortBy}}
{{#SortDirection}}<rr:SortDirection>{{SortDirection}}</rr:SortDirection>{{/SortDirection}}
</rr:SearchCriteria>
</rr:CombinedSearchRq>

View File

@@ -0,0 +1,158 @@
<rr:RepairOrderInsertRq xmlns:rr="http://reynoldsandrey.com/">
<!-- Optional request metadata -->
{{#RequestId}}<rr:RequestId>{{RequestId}}</rr:RequestId>{{/RequestId}}
{{#Environment}}<rr:Environment>{{Environment}}</rr:Environment>{{/Environment}}
<rr:Dealer>
<rr:DealerCode>{{DealerCode}}</rr:DealerCode>
{{#DealerName}}<rr:DealerName>{{DealerName}}</rr:DealerName>{{/DealerName}}
{{#DealerNumber}}<rr:DealerNumber>{{DealerNumber}}</rr:DealerNumber>{{/DealerNumber}}
{{#StoreNumber}}<rr:StoreNumber>{{StoreNumber}}</rr:StoreNumber>{{/StoreNumber}}
{{#BranchNumber}}<rr:BranchNumber>{{BranchNumber}}</rr:BranchNumber>{{/BranchNumber}}
</rr:Dealer>
<rr:RepairOrder>
<rr:RepairOrderNumber>{{RepairOrderNumber}}</rr:RepairOrderNumber>
{{#DmsRepairOrderId}}<rr:DmsRepairOrderId>{{DmsRepairOrderId}}</rr:DmsRepairOrderId>{{/DmsRepairOrderId}}
<!-- Core dates -->
{{#OpenDate}}<rr:OpenDate>{{OpenDate}}</rr:OpenDate>{{/OpenDate}}
{{#PromisedDate}}<rr:PromisedDate>{{PromisedDate}}</rr:PromisedDate>{{/PromisedDate}}
{{#CloseDate}}<rr:CloseDate>{{CloseDate}}</rr:CloseDate>{{/CloseDate}}
<!-- People & routing -->
{{#ServiceAdvisorId}}<rr:ServiceAdvisorId>{{ServiceAdvisorId}}</rr:ServiceAdvisorId>{{/ServiceAdvisorId}}
{{#TechnicianId}}<rr:TechnicianId>{{TechnicianId}}</rr:TechnicianId>{{/TechnicianId}}
{{#Department}}<rr:Department>{{Department}}</rr:Department>{{/Department}}
{{#ProfitCenter}}<rr:ProfitCenter>{{ProfitCenter}}</rr:ProfitCenter>{{/ProfitCenter}}
<!-- Type & status -->
{{#ROType}}<rr:ROType>{{ROType}}</rr:ROType>{{/ROType}}
{{#Status}}<rr:Status>{{Status}}</rr:Status>{{/Status}}
{{#IsBodyShop}}<rr:IsBodyShop>{{IsBodyShop}}</rr:IsBodyShop>{{/IsBodyShop}}
{{#DRPFlag}}<rr:DRPFlag>{{DRPFlag}}</rr:DRPFlag>{{/DRPFlag}}
<!-- Customer -->
<rr:Customer>
<rr:CustomerId>{{CustomerId}}</rr:CustomerId>
{{#CustomerName}}<rr:CustomerName>{{CustomerName}}</rr:CustomerName>{{/CustomerName}}
{{#PhoneNumber}}<rr:PhoneNumber>{{PhoneNumber}}</rr:PhoneNumber>{{/PhoneNumber}}
{{#EmailAddress}}<rr:EmailAddress>{{EmailAddress}}</rr:EmailAddress>{{/EmailAddress}}
<!-- Optional address if you have it -->
{{#Address}}
<rr:Address>
{{#Line1}}<rr:Line1>{{Line1}}</rr:Line1>{{/Line1}}
{{#Line2}}<rr:Line2>{{Line2}}</rr:Line2>{{/Line2}}
{{#City}}<rr:City>{{City}}</rr:City>{{/City}}
{{#State}}<rr:State>{{State}}</rr:State>{{/State}}
{{#PostalCode}}<rr:PostalCode>{{PostalCode}}</rr:PostalCode>{{/PostalCode}}
{{#Country}}<rr:Country>{{Country}}</rr:Country>{{/Country}}
</rr:Address>
{{/Address}}
</rr:Customer>
<!-- Vehicle -->
<rr:Vehicle>
{{#VehicleId}}<rr:VehicleId>{{VehicleId}}</rr:VehicleId>{{/VehicleId}}
{{#VIN}}<rr:VIN>{{VIN}}</rr:VIN>{{/VIN}}
{{#LicensePlate}}<rr:LicensePlate>{{LicensePlate}}</rr:LicensePlate>{{/LicensePlate}}
{{#Year}}<rr:Year>{{Year}}</rr:Year>{{/Year}}
{{#Make}}<rr:Make>{{Make}}</rr:Make>{{/Make}}
{{#Model}}<rr:Model>{{Model}}</rr:Model>{{/Model}}
{{#Odometer}}<rr:Odometer>{{Odometer}}</rr:Odometer>{{/Odometer}}
{{#Color}}<rr:Color>{{Color}}</rr:Color>{{/Color}}
</rr:Vehicle>
<!-- Job lines -->
{{#JobLines}}
<rr:JobLine>
<rr:Sequence>{{Sequence}}</rr:Sequence>
{{#ParentSequence}}<rr:ParentSequence>{{ParentSequence}}</rr:ParentSequence>{{/ParentSequence}}
{{#LineType}}<rr:LineType>
{{LineType}}</rr:LineType>{{/LineType}} <!-- LABOR | PART | MISC | FEE | DISCOUNT -->
{{#Category}}<rr:Category>
{{Category}}</rr:Category>{{/Category}} <!-- e.g., BODY, PAINT, GLASS -->
{{#OpCode}}<rr:OpCode>{{OpCode}}</rr:OpCode>{{/OpCode}}
{{#Description}}<rr:Description>{{Description}}</rr:Description>{{/Description}}
<!-- Labor fields -->
{{#LaborHours}}<rr:LaborHours>{{LaborHours}}</rr:LaborHours>{{/LaborHours}}
{{#LaborRate}}<rr:LaborRate>{{LaborRate}}</rr:LaborRate>{{/LaborRate}}
<!-- Part fields -->
{{#PartNumber}}<rr:PartNumber>{{PartNumber}}</rr:PartNumber>{{/PartNumber}}
{{#PartDescription}}<rr:PartDescription>{{PartDescription}}</rr:PartDescription>{{/PartDescription}}
<!-- Amounts -->
{{#Quantity}}<rr:Quantity>{{Quantity}}</rr:Quantity>{{/Quantity}}
{{#UnitPrice}}<rr:UnitPrice>{{UnitPrice}}</rr:UnitPrice>{{/UnitPrice}}
{{#ExtendedPrice}}<rr:ExtendedPrice>{{ExtendedPrice}}</rr:ExtendedPrice>{{/ExtendedPrice}}
{{#DiscountAmount}}<rr:DiscountAmount>{{DiscountAmount}}</rr:DiscountAmount>{{/DiscountAmount}}
{{#TaxCode}}<rr:TaxCode>{{TaxCode}}</rr:TaxCode>{{/TaxCode}}
{{#GLAccount}}<rr:GLAccount>{{GLAccount}}</rr:GLAccount>{{/GLAccount}}
{{#ControlNumber}}<rr:ControlNumber>{{ControlNumber}}</rr:ControlNumber>{{/ControlNumber}}
<!-- Tax details (optional) -->
{{#Taxes}}
<rr:Taxes>
{{#Items}}
<rr:Tax>
<rr:Code>{{Code}}</rr:Code>
<rr:Amount>{{Amount}}</rr:Amount>
{{#Rate}}<rr:Rate>{{Rate}}</rr:Rate>{{/Rate}}
</rr:Tax>
{{/Items}}
</rr:Taxes>
{{/Taxes}}
</rr:JobLine>
{{/JobLines}}
<!-- Totals -->
{{#Totals}}
<rr:Totals>
{{#Currency}}<rr:Currency>{{Currency}}</rr:Currency>{{/Currency}}
{{#LaborTotal}}<rr:LaborTotal>{{LaborTotal}}</rr:LaborTotal>{{/LaborTotal}}
{{#PartsTotal}}<rr:PartsTotal>{{PartsTotal}}</rr:PartsTotal>{{/PartsTotal}}
{{#MiscTotal}}<rr:MiscTotal>{{MiscTotal}}</rr:MiscTotal>{{/MiscTotal}}
{{#DiscountTotal}}<rr:DiscountTotal>{{DiscountTotal}}</rr:DiscountTotal>{{/DiscountTotal}}
{{#TaxTotal}}<rr:TaxTotal>{{TaxTotal}}</rr:TaxTotal>{{/TaxTotal}}
<rr:GrandTotal>{{GrandTotal}}</rr:GrandTotal>
</rr:Totals>
{{/Totals}}
<!-- Payers/Payments (optional) -->
{{#Payments}}
<rr:Payments>
{{#Items}}
<rr:Payment>
<rr:PayerType>{{PayerType}}</rr:PayerType> <!-- CUSTOMER | INSURANCE | WARRANTY | FLEET -->
{{#PayerName}}<rr:PayerName>{{PayerName}}</rr:PayerName>{{/PayerName}}
<rr:Amount>{{Amount}}</rr:Amount>
{{#Method}}<rr:Method>{{Method}}</rr:Method>{{/Method}}
{{#Reference}}<rr:Reference>{{Reference}}</rr:Reference>{{/Reference}}
{{#ControlNumber}}<rr:ControlNumber>{{ControlNumber}}</rr:ControlNumber>{{/ControlNumber}}
</rr:Payment>
{{/Items}}
</rr:Payments>
{{/Payments}}
<!-- Insurance block (optional) -->
{{#Insurance}}
<rr:Insurance>
{{#CompanyName}}<rr:CompanyName>{{CompanyName}}</rr:CompanyName>{{/CompanyName}}
{{#ClaimNumber}}<rr:ClaimNumber>{{ClaimNumber}}</rr:ClaimNumber>{{/ClaimNumber}}
{{#AdjusterName}}<rr:AdjusterName>{{AdjusterName}}</rr:AdjusterName>{{/AdjusterName}}
{{#AdjusterPhone}}<rr:AdjusterPhone>{{AdjusterPhone}}</rr:AdjusterPhone>{{/AdjusterPhone}}
</rr:Insurance>
{{/Insurance}}
<!-- Notes -->
{{#Notes}}
<rr:Notes>
{{#Items}}<rr:Note>{{.}}</rr:Note>{{/Items}}
</rr:Notes>
{{/Notes}}
</rr:RepairOrder>
</rr:RepairOrderInsertRq>

View File

@@ -0,0 +1,34 @@
<rr:GetAdvisorsRq xmlns:rr="http://reynoldsandrey.com/">
<!-- Optional request metadata -->
{{#RequestId}}<rr:RequestId>{{RequestId}}</rr:RequestId>{{/RequestId}}
{{#Environment}}<rr:Environment>{{Environment}}</rr:Environment>{{/Environment}}
<rr:Dealer>
<rr:DealerCode>{{DealerCode}}</rr:DealerCode>
{{#DealerName}}<rr:DealerName>{{DealerName}}</rr:DealerName>{{/DealerName}}
{{#DealerNumber}}<rr:DealerNumber>{{DealerNumber}}</rr:DealerNumber>{{/DealerNumber}}
{{#StoreNumber}}<rr:StoreNumber>{{StoreNumber}}</rr:StoreNumber>{{/StoreNumber}}
{{#BranchNumber}}<rr:BranchNumber>{{BranchNumber}}</rr:BranchNumber>{{/BranchNumber}}
</rr:Dealer>
{{#SearchCriteria}}
<rr:SearchCriteria>
{{#AdvisorId}}<rr:AdvisorId>{{AdvisorId}}</rr:AdvisorId>{{/AdvisorId}}
{{#FirstName}}<rr:FirstName>{{FirstName}}</rr:FirstName>{{/FirstName}}
{{#LastName}}<rr:LastName>{{LastName}}</rr:LastName>{{/LastName}}
{{#Department}}<rr:Department>{{Department}}</rr:Department>{{/Department}}
{{#Status}}<rr:Status>{{Status}}</rr:Status>{{/Status}} <!-- ACTIVE | INACTIVE -->
{{#SearchMode}}<rr:SearchMode>
{{SearchMode}}</rr:SearchMode>{{/SearchMode}} <!-- EXACT | PARTIAL -->
{{#Email}}<rr:Email>{{Email}}</rr:Email>{{/Email}}
{{#Phone}}<rr:Phone>{{Phone}}</rr:Phone>{{/Phone}}
{{#IncludeInactive}}<rr:IncludeInactive>{{IncludeInactive}}</rr:IncludeInactive>{{/IncludeInactive}}
<!-- Optional paging/sorting -->
{{#MaxResults}}<rr:MaxResults>{{MaxResults}}</rr:MaxResults>{{/MaxResults}}
{{#PageNumber}}<rr:PageNumber>{{PageNumber}}</rr:PageNumber>{{/PageNumber}}
{{#SortBy}}<rr:SortBy>{{SortBy}}</rr:SortBy>{{/SortBy}}
{{#SortDirection}}<rr:SortDirection>{{SortDirection}}</rr:SortDirection>{{/SortDirection}}
</rr:SearchCriteria>
{{/SearchCriteria}}
</rr:GetAdvisorsRq>

View File

@@ -0,0 +1,50 @@
<rr:GetPartRq xmlns:rr="http://reynoldsandrey.com/">
<!-- Optional request metadata -->
{{#RequestId}}<rr:RequestId>{{RequestId}}</rr:RequestId>{{/RequestId}}
{{#Environment}}<rr:Environment>{{Environment}}</rr:Environment>{{/Environment}}
<rr:Dealer>
<rr:DealerCode>{{DealerCode}}</rr:DealerCode>
{{#DealerName}}<rr:DealerName>{{DealerName}}</rr:DealerName>{{/DealerName}}
{{#DealerNumber}}<rr:DealerNumber>{{DealerNumber}}</rr:DealerNumber>{{/DealerNumber}}
{{#StoreNumber}}<rr:StoreNumber>{{StoreNumber}}</rr:StoreNumber>{{/StoreNumber}}
{{#BranchNumber}}<rr:BranchNumber>{{BranchNumber}}</rr:BranchNumber>{{/BranchNumber}}
</rr:Dealer>
<rr:SearchCriteria>
{{#PartNumber}}<rr:PartNumber>{{PartNumber}}</rr:PartNumber>{{/PartNumber}}
{{#Description}}<rr:Description>{{Description}}</rr:Description>{{/Description}}
{{#Make}}<rr:Make>{{Make}}</rr:Make>{{/Make}}
{{#Model}}<rr:Model>{{Model}}</rr:Model>{{/Model}}
{{#Year}}<rr:Year>{{Year}}</rr:Year>{{/Year}}
{{#Vendor}}<rr:Vendor>{{Vendor}}</rr:Vendor>{{/Vendor}}
{{#Category}}<rr:Category>{{Category}}</rr:Category>{{/Category}}
<!-- Optional classification flags -->
{{#Brand}}<rr:Brand>{{Brand}}</rr:Brand>{{/Brand}}
{{#IsOEM}}<rr:IsOEM>{{IsOEM}}</rr:IsOEM>{{/IsOEM}} <!-- true | false -->
{{#IsAftermarket}}<rr:IsAftermarket>{{IsAftermarket}}</rr:IsAftermarket>{{/IsAftermarket}}
<!-- Availability / inventory -->
{{#InStock}}<rr:InStock>{{InStock}}</rr:InStock>{{/InStock}} <!-- true | false -->
{{#Warehouse}}<rr:Warehouse>{{Warehouse}}</rr:Warehouse>{{/Warehouse}}
{{#Location}}<rr:Location>{{Location}}</rr:Location>{{/Location}}
<!-- Pricing filters -->
{{#MinPrice}}<rr:MinPrice>{{MinPrice}}</rr:MinPrice>{{/MinPrice}}
{{#MaxPrice}}<rr:MaxPrice>{{MaxPrice}}</rr:MaxPrice>{{/MaxPrice}}
{{#Currency}}<rr:Currency>{{Currency}}</rr:Currency>{{/Currency}}
<!-- Search behavior -->
{{#SearchMode}}<rr:SearchMode>
{{SearchMode}}</rr:SearchMode>{{/SearchMode}} <!-- EXACT | PARTIAL -->
<!-- Paging / sorting -->
{{#MaxResults}}<rr:MaxResults>{{MaxResults}}</rr:MaxResults>{{/MaxResults}}
{{#PageNumber}}<rr:PageNumber>{{PageNumber}}</rr:PageNumber>{{/PageNumber}}
{{#SortBy}}<rr:SortBy>
{{SortBy}}</rr:SortBy>{{/SortBy}} <!-- e.g., PARTNUMBER, DESCRIPTION, PRICE -->
{{#SortDirection}}<rr:SortDirection>
{{SortDirection}}</rr:SortDirection>{{/SortDirection}} <!-- ASC | DESC -->
</rr:SearchCriteria>
</rr:GetPartRq>

View File

@@ -0,0 +1,102 @@
<rr:CustomerInsertRq xmlns:rr="http://reynoldsandrey.com/">
<!-- Optional request metadata -->
{{#RequestId}}<rr:RequestId>{{RequestId}}</rr:RequestId>{{/RequestId}}
{{#Environment}}<rr:Environment>{{Environment}}</rr:Environment>{{/Environment}}
<rr:Dealer>
<rr:DealerCode>{{DealerCode}}</rr:DealerCode>
{{#DealerName}}<rr:DealerName>{{DealerName}}</rr:DealerName>{{/DealerName}}
{{#DealerNumber}}<rr:DealerNumber>{{DealerNumber}}</rr:DealerNumber>{{/DealerNumber}}
{{#StoreNumber}}<rr:StoreNumber>{{StoreNumber}}</rr:StoreNumber>{{/StoreNumber}}
{{#BranchNumber}}<rr:BranchNumber>{{BranchNumber}}</rr:BranchNumber>{{/BranchNumber}}
</rr:Dealer>
<rr:Customer>
{{#CustomerId}}<rr:CustomerId>{{CustomerId}}</rr:CustomerId>{{/CustomerId}}
{{#CustomerType}}<rr:CustomerType>
{{CustomerType}}</rr:CustomerType>{{/CustomerType}} <!-- RETAIL | FLEET | INTERNAL -->
{{#CompanyName}}<rr:CompanyName>{{CompanyName}}</rr:CompanyName>{{/CompanyName}}
{{#FirstName}}<rr:FirstName>{{FirstName}}</rr:FirstName>{{/FirstName}}
{{#MiddleName}}<rr:MiddleName>{{MiddleName}}</rr:MiddleName>{{/MiddleName}}
{{#LastName}}<rr:LastName>{{LastName}}</rr:LastName>{{/LastName}}
{{#PreferredName}}<rr:PreferredName>{{PreferredName}}</rr:PreferredName>{{/PreferredName}}
{{#ActiveFlag}}<rr:ActiveFlag>{{ActiveFlag}}</rr:ActiveFlag>{{/ActiveFlag}}
<!-- Optional customer classification -->
{{#CustomerGroup}}<rr:CustomerGroup>{{CustomerGroup}}</rr:CustomerGroup>{{/CustomerGroup}}
{{#TaxExempt}}<rr:TaxExempt>{{TaxExempt}}</rr:TaxExempt>{{/TaxExempt}}
{{#DiscountLevel}}<rr:DiscountLevel>{{DiscountLevel}}</rr:DiscountLevel>{{/DiscountLevel}}
{{#PreferredLanguage}}<rr:PreferredLanguage>{{PreferredLanguage}}</rr:PreferredLanguage>{{/PreferredLanguage}}
<!-- Addresses -->
{{#Addresses}}
<rr:Address>
{{#AddressType}}<rr:AddressType>
{{AddressType}}</rr:AddressType>{{/AddressType}} <!-- BILLING | MAILING | SHIPPING -->
{{#AddressLine1}}<rr:AddressLine1>{{AddressLine1}}</rr:AddressLine1>{{/AddressLine1}}
{{#AddressLine2}}<rr:AddressLine2>{{AddressLine2}}</rr:AddressLine2>{{/AddressLine2}}
{{#City}}<rr:City>{{City}}</rr:City>{{/City}}
{{#State}}<rr:State>{{State}}</rr:State>{{/State}}
{{#PostalCode}}<rr:PostalCode>{{PostalCode}}</rr:PostalCode>{{/PostalCode}}
{{#Country}}<rr:Country>{{Country}}</rr:Country>{{/Country}}
</rr:Address>
{{/Addresses}}
<!-- Phones -->
{{#Phones}}
<rr:Phone>
<rr:PhoneNumber>{{PhoneNumber}}</rr:PhoneNumber>
{{#PhoneType}}<rr:PhoneType>
{{PhoneType}}</rr:PhoneType>{{/PhoneType}} <!-- MOBILE | HOME | WORK -->
{{#Preferred}}<rr:Preferred>{{Preferred}}</rr:Preferred>{{/Preferred}}
</rr:Phone>
{{/Phones}}
<!-- Emails -->
{{#Emails}}
<rr:Email>
<rr:EmailAddress>{{EmailAddress}}</rr:EmailAddress>
{{#EmailType}}<rr:EmailType>{{EmailType}}</rr:EmailType>{{/EmailType}}
{{#Preferred}}<rr:Preferred>{{Preferred}}</rr:Preferred>{{/Preferred}}
</rr:Email>
{{/Emails}}
<!-- Driver's License -->
{{#DriverLicense}}
<rr:DriverLicense>
{{#LicenseNumber}}<rr:LicenseNumber>{{LicenseNumber}}</rr:LicenseNumber>{{/LicenseNumber}}
{{#LicenseState}}<rr:LicenseState>{{LicenseState}}</rr:LicenseState>{{/LicenseState}}
{{#ExpirationDate}}<rr:ExpirationDate>{{ExpirationDate}}</rr:ExpirationDate>{{/ExpirationDate}}
</rr:DriverLicense>
{{/DriverLicense}}
<!-- Insurance -->
{{#Insurance}}
<rr:Insurance>
{{#CompanyName}}<rr:CompanyName>{{CompanyName}}</rr:CompanyName>{{/CompanyName}}
{{#PolicyNumber}}<rr:PolicyNumber>{{PolicyNumber}}</rr:PolicyNumber>{{/PolicyNumber}}
{{#ExpirationDate}}<rr:ExpirationDate>{{ExpirationDate}}</rr:ExpirationDate>{{/ExpirationDate}}
{{#ContactName}}<rr:ContactName>{{ContactName}}</rr:ContactName>{{/ContactName}}
{{#ContactPhone}}<rr:ContactPhone>{{ContactPhone}}</rr:ContactPhone>{{/ContactPhone}}
</rr:Insurance>
{{/Insurance}}
<!-- Optional linked accounts -->
{{#LinkedAccounts}}
<rr:LinkedAccount>
<rr:Type>{{Type}}</rr:Type> <!-- FLEET | WARRANTY | CORPORATE -->
<rr:AccountNumber>{{AccountNumber}}</rr:AccountNumber>
{{#CreditLimit}}<rr:CreditLimit>{{CreditLimit}}</rr:CreditLimit>{{/CreditLimit}}
</rr:LinkedAccount>
{{/LinkedAccounts}}
<!-- Notes -->
{{#Notes}}
<rr:Notes>
{{#Items}}<rr:Note>{{.}}</rr:Note>{{/Items}}
</rr:Notes>
{{/Notes}}
</rr:Customer>
</rr:CustomerInsertRq>

View File

@@ -0,0 +1,83 @@
<rr:ServiceVehicleAddRq xmlns:rr="http://reynoldsandrey.com/">
<!-- Optional request metadata -->
{{#RequestId}}<rr:RequestId>{{RequestId}}</rr:RequestId>{{/RequestId}}
{{#Environment}}<rr:Environment>{{Environment}}</rr:Environment>{{/Environment}}
<rr:Dealer>
<rr:DealerCode>{{DealerCode}}</rr:DealerCode>
{{#DealerName}}<rr:DealerName>{{DealerName}}</rr:DealerName>{{/DealerName}}
{{#DealerNumber}}<rr:DealerNumber>{{DealerNumber}}</rr:DealerNumber>{{/DealerNumber}}
{{#StoreNumber}}<rr:StoreNumber>{{StoreNumber}}</rr:StoreNumber>{{/StoreNumber}}
{{#BranchNumber}}<rr:BranchNumber>{{BranchNumber}}</rr:BranchNumber>{{/BranchNumber}}
</rr:Dealer>
<rr:ServiceVehicle>
{{#CustomerId}}<rr:CustomerId>{{CustomerId}}</rr:CustomerId>{{/CustomerId}}
<!-- Identity -->
{{#VIN}}<rr:VIN>{{VIN}}</rr:VIN>{{/VIN}}
{{#UnitNumber}}<rr:UnitNumber>{{UnitNumber}}</rr:UnitNumber>{{/UnitNumber}}
{{#StockNumber}}<rr:StockNumber>{{StockNumber}}</rr:StockNumber>{{/StockNumber}}
<!-- Descriptive -->
{{#Year}}<rr:Year>{{Year}}</rr:Year>{{/Year}}
{{#Make}}<rr:Make>{{Make}}</rr:Make>{{/Make}}
{{#Model}}<rr:Model>{{Model}}</rr:Model>{{/Model}}
{{#Trim}}<rr:Trim>{{Trim}}</rr:Trim>{{/Trim}}
{{#BodyStyle}}<rr:BodyStyle>{{BodyStyle}}</rr:BodyStyle>{{/BodyStyle}}
{{#Transmission}}<rr:Transmission>{{Transmission}}</rr:Transmission>{{/Transmission}}
{{#Engine}}<rr:Engine>{{Engine}}</rr:Engine>{{/Engine}}
{{#FuelType}}<rr:FuelType>{{FuelType}}</rr:FuelType>{{/FuelType}}
{{#DriveType}}<rr:DriveType>{{DriveType}}</rr:DriveType>{{/DriveType}}
{{#Color}}<rr:Color>{{Color}}</rr:Color>{{/Color}}
<!-- Registration -->
{{#LicensePlate}}<rr:LicensePlate>{{LicensePlate}}</rr:LicensePlate>{{/LicensePlate}}
{{#LicenseState}}<rr:LicenseState>{{LicenseState}}</rr:LicenseState>{{/LicenseState}}
{{#RegistrationExpiry}}<rr:RegistrationExpiry>{{RegistrationExpiry}}</rr:RegistrationExpiry>{{/RegistrationExpiry}}
<!-- Odometer -->
{{#Odometer}}<rr:Odometer>{{Odometer}}</rr:Odometer>{{/Odometer}}
{{#OdometerUnits}}<rr:OdometerUnits>
{{OdometerUnits}}</rr:OdometerUnits>{{/OdometerUnits}} <!-- MI | KM -->
{{#InServiceDate}}<rr:InServiceDate>{{InServiceDate}}</rr:InServiceDate>{{/InServiceDate}}
<!-- Ownership -->
{{#Ownership}}
<rr:Ownership>
{{#OwnerId}}<rr:OwnerId>{{OwnerId}}</rr:OwnerId>{{/OwnerId}}
{{#OwnerName}}<rr:OwnerName>{{OwnerName}}</rr:OwnerName>{{/OwnerName}}
{{#OwnershipType}}<rr:OwnershipType>
{{OwnershipType}}</rr:OwnershipType>{{/OwnershipType}} <!-- OWNER | LEASED | FLEET -->
</rr:Ownership>
{{/Ownership}}
<!-- Insurance -->
{{#Insurance}}
<rr:Insurance>
{{#CompanyName}}<rr:CompanyName>{{CompanyName}}</rr:CompanyName>{{/CompanyName}}
{{#PolicyNumber}}<rr:PolicyNumber>{{PolicyNumber}}</rr:PolicyNumber>{{/PolicyNumber}}
{{#ExpirationDate}}<rr:ExpirationDate>{{ExpirationDate}}</rr:ExpirationDate>{{/ExpirationDate}}
{{#ContactName}}<rr:ContactName>{{ContactName}}</rr:ContactName>{{/ContactName}}
{{#ContactPhone}}<rr:ContactPhone>{{ContactPhone}}</rr:ContactPhone>{{/ContactPhone}}
</rr:Insurance>
{{/Insurance}}
<!-- Warranty -->
{{#Warranty}}
<rr:Warranty>
{{#WarrantyCompany}}<rr:WarrantyCompany>{{WarrantyCompany}}</rr:WarrantyCompany>{{/WarrantyCompany}}
{{#WarrantyNumber}}<rr:WarrantyNumber>{{WarrantyNumber}}</rr:WarrantyNumber>{{/WarrantyNumber}}
{{#WarrantyType}}<rr:WarrantyType>{{WarrantyType}}</rr:WarrantyType>{{/WarrantyType}}
{{#ExpirationDate}}<rr:ExpirationDate>{{ExpirationDate}}</rr:ExpirationDate>{{/ExpirationDate}}
</rr:Warranty>
{{/Warranty}}
<!-- Notes -->
{{#VehicleNotes}}
<rr:Notes>
{{#Items}}<rr:Note>{{.}}</rr:Note>{{/Items}}
</rr:Notes>
{{/VehicleNotes}}
</rr:ServiceVehicle>
</rr:ServiceVehicleAddRq>

View File

@@ -0,0 +1,107 @@
<rr:CustomerUpdateRq xmlns:rr="http://reynoldsandrey.com/">
<!-- Optional request metadata -->
{{#RequestId}}<rr:RequestId>{{RequestId}}</rr:RequestId>{{/RequestId}}
{{#Environment}}<rr:Environment>{{Environment}}</rr:Environment>{{/Environment}}
<rr:Dealer>
<rr:DealerCode>{{DealerCode}}</rr:DealerCode>
{{#DealerName}}<rr:DealerName>{{DealerName}}</rr:DealerName>{{/DealerName}}
{{#DealerNumber}}<rr:DealerNumber>{{DealerNumber}}</rr:DealerNumber>{{/DealerNumber}}
{{#StoreNumber}}<rr:StoreNumber>{{StoreNumber}}</rr:StoreNumber>{{/StoreNumber}}
{{#BranchNumber}}<rr:BranchNumber>{{BranchNumber}}</rr:BranchNumber>{{/BranchNumber}}
</rr:Dealer>
<rr:Customer>
<rr:CustomerId>{{CustomerId}}</rr:CustomerId>
{{#CustomerType}}<rr:CustomerType>{{CustomerType}}</rr:CustomerType>{{/CustomerType}}
{{#CompanyName}}<rr:CompanyName>{{CompanyName}}</rr:CompanyName>{{/CompanyName}}
{{#FirstName}}<rr:FirstName>{{FirstName}}</rr:FirstName>{{/FirstName}}
{{#MiddleName}}<rr:MiddleName>{{MiddleName}}</rr:MiddleName>{{/MiddleName}}
{{#LastName}}<rr:LastName>{{LastName}}</rr:LastName>{{/LastName}}
{{#PreferredName}}<rr:PreferredName>{{PreferredName}}</rr:PreferredName>{{/PreferredName}}
{{#ActiveFlag}}<rr:ActiveFlag>{{ActiveFlag}}</rr:ActiveFlag>{{/ActiveFlag}}
{{#CustomerGroup}}<rr:CustomerGroup>{{CustomerGroup}}</rr:CustomerGroup>{{/CustomerGroup}}
{{#TaxExempt}}<rr:TaxExempt>{{TaxExempt}}</rr:TaxExempt>{{/TaxExempt}}
{{#DiscountLevel}}<rr:DiscountLevel>{{DiscountLevel}}</rr:DiscountLevel>{{/DiscountLevel}}
{{#PreferredLanguage}}<rr:PreferredLanguage>{{PreferredLanguage}}</rr:PreferredLanguage>{{/PreferredLanguage}}
<!-- Addresses -->
{{#Addresses}}
<rr:Address>
{{#AddressId}}<rr:AddressId>{{AddressId}}</rr:AddressId>{{/AddressId}}
{{#AddressType}}<rr:AddressType>
{{AddressType}}</rr:AddressType>{{/AddressType}} <!-- BILLING | MAILING | SHIPPING -->
{{#AddressLine1}}<rr:AddressLine1>{{AddressLine1}}</rr:AddressLine1>{{/AddressLine1}}
{{#AddressLine2}}<rr:AddressLine2>{{AddressLine2}}</rr:AddressLine2>{{/AddressLine2}}
{{#City}}<rr:City>{{City}}</rr:City>{{/City}}
{{#State}}<rr:State>{{State}}</rr:State>{{/State}}
{{#PostalCode}}<rr:PostalCode>{{PostalCode}}</rr:PostalCode>{{/PostalCode}}
{{#Country}}<rr:Country>{{Country}}</rr:Country>{{/Country}}
{{#IsPrimary}}<rr:IsPrimary>{{IsPrimary}}</rr:IsPrimary>{{/IsPrimary}}
</rr:Address>
{{/Addresses}}
<!-- Phones -->
{{#Phones}}
<rr:Phone>
{{#PhoneId}}<rr:PhoneId>{{PhoneId}}</rr:PhoneId>{{/PhoneId}}
{{#PhoneNumber}}<rr:PhoneNumber>{{PhoneNumber}}</rr:PhoneNumber>{{/PhoneNumber}}
{{#PhoneType}}<rr:PhoneType>
{{PhoneType}}</rr:PhoneType>{{/PhoneType}} <!-- MOBILE | HOME | WORK -->
{{#Preferred}}<rr:Preferred>{{Preferred}}</rr:Preferred>{{/Preferred}}
{{#IsDeleted}}<rr:IsDeleted>
{{IsDeleted}}</rr:IsDeleted>{{/IsDeleted}} <!-- Mark for deletion -->
</rr:Phone>
{{/Phones}}
<!-- Emails -->
{{#Emails}}
<rr:Email>
{{#EmailId}}<rr:EmailId>{{EmailId}}</rr:EmailId>{{/EmailId}}
{{#EmailAddress}}<rr:EmailAddress>{{EmailAddress}}</rr:EmailAddress>{{/EmailAddress}}
{{#EmailType}}<rr:EmailType>{{EmailType}}</rr:EmailType>{{/EmailType}}
{{#Preferred}}<rr:Preferred>{{Preferred}}</rr:Preferred>{{/Preferred}}
{{#IsDeleted}}<rr:IsDeleted>{{IsDeleted}}</rr:IsDeleted>{{/IsDeleted}}
</rr:Email>
{{/Emails}}
<!-- Driver's License -->
{{#DriverLicense}}
<rr:DriverLicense>
{{#LicenseNumber}}<rr:LicenseNumber>{{LicenseNumber}}</rr:LicenseNumber>{{/LicenseNumber}}
{{#LicenseState}}<rr:LicenseState>{{LicenseState}}</rr:LicenseState>{{/LicenseState}}
{{#ExpirationDate}}<rr:ExpirationDate>{{ExpirationDate}}</rr:ExpirationDate>{{/ExpirationDate}}
</rr:DriverLicense>
{{/DriverLicense}}
<!-- Insurance -->
{{#Insurance}}
<rr:Insurance>
{{#CompanyName}}<rr:CompanyName>{{CompanyName}}</rr:CompanyName>{{/CompanyName}}
{{#PolicyNumber}}<rr:PolicyNumber>{{PolicyNumber}}</rr:PolicyNumber>{{/PolicyNumber}}
{{#ExpirationDate}}<rr:ExpirationDate>{{ExpirationDate}}</rr:ExpirationDate>{{/ExpirationDate}}
{{#ContactName}}<rr:ContactName>{{ContactName}}</rr:ContactName>{{/ContactName}}
{{#ContactPhone}}<rr:ContactPhone>{{ContactPhone}}</rr:ContactPhone>{{/ContactPhone}}
</rr:Insurance>
{{/Insurance}}
<!-- Linked Accounts -->
{{#LinkedAccounts}}
<rr:LinkedAccount>
<rr:Type>{{Type}}</rr:Type> <!-- FLEET | WARRANTY | CORPORATE -->
<rr:AccountNumber>{{AccountNumber}}</rr:AccountNumber>
{{#CreditLimit}}<rr:CreditLimit>{{CreditLimit}}</rr:CreditLimit>{{/CreditLimit}}
{{#IsDeleted}}<rr:IsDeleted>{{IsDeleted}}</rr:IsDeleted>{{/IsDeleted}}
</rr:LinkedAccount>
{{/LinkedAccounts}}
<!-- Notes -->
{{#Notes}}
<rr:Notes>
{{#Items}}<rr:Note>{{.}}</rr:Note>{{/Items}}
</rr:Notes>
{{/Notes}}
</rr:Customer>
</rr:CustomerUpdateRq>

View File

@@ -0,0 +1,135 @@
<rr:RepairOrderChgRq xmlns:rr="http://reynoldsandrey.com/">
<!-- Optional request metadata -->
{{#RequestId}}<rr:RequestId>{{RequestId}}</rr:RequestId>{{/RequestId}}
{{#Environment}}<rr:Environment>{{Environment}}</rr:Environment>{{/Environment}}
<rr:Dealer>
<rr:DealerCode>{{DealerCode}}</rr:DealerCode>
{{#DealerName}}<rr:DealerName>{{DealerName}}</rr:DealerName>{{/DealerName}}
{{#DealerNumber}}<rr:DealerNumber>{{DealerNumber}}</rr:DealerNumber>{{/DealerNumber}}
{{#StoreNumber}}<rr:StoreNumber>{{StoreNumber}}</rr:StoreNumber>{{/StoreNumber}}
{{#BranchNumber}}<rr:BranchNumber>{{BranchNumber}}</rr:BranchNumber>{{/BranchNumber}}
</rr:Dealer>
<rr:RepairOrder>
<!-- Identity -->
{{#RepairOrderId}}<rr:RepairOrderId>{{RepairOrderId}}</rr:RepairOrderId>{{/RepairOrderId}}
{{#RepairOrderNumber}}<rr:RepairOrderNumber>{{RepairOrderNumber}}</rr:RepairOrderNumber>{{/RepairOrderNumber}}
<!-- Header fields that may be patched -->
{{#Status}}<rr:Status>
{{Status}}</rr:Status>{{/Status}} <!-- e.g., OPEN|IN_PROGRESS|CLOSED -->
{{#ROType}}<rr:ROType>
{{ROType}}</rr:ROType>{{/ROType}} <!-- e.g., INSURANCE|CUSTOMER_PAY -->
{{#OpenDate}}<rr:OpenDate>{{OpenDate}}</rr:OpenDate>{{/OpenDate}}
{{#PromisedDate}}<rr:PromisedDate>{{PromisedDate}}</rr:PromisedDate>{{/PromisedDate}}
{{#CloseDate}}<rr:CloseDate>{{CloseDate}}</rr:CloseDate>{{/CloseDate}}
{{#ServiceAdvisorId}}<rr:ServiceAdvisorId>{{ServiceAdvisorId}}</rr:ServiceAdvisorId>{{/ServiceAdvisorId}}
{{#TechnicianId}}<rr:TechnicianId>{{TechnicianId}}</rr:TechnicianId>{{/TechnicianId}}
{{#LocationCode}}<rr:LocationCode>{{LocationCode}}</rr:LocationCode>{{/LocationCode}}
{{#Department}}<rr:Department>{{Department}}</rr:Department>{{/Department}}
{{#PurchaseOrder}}<rr:PurchaseOrder>{{PurchaseOrder}}</rr:PurchaseOrder>{{/PurchaseOrder}}
<!-- Optional customer patch -->
{{#Customer}}
<rr:Customer>
{{#CustomerId}}<rr:CustomerId>{{CustomerId}}</rr:CustomerId>{{/CustomerId}}
{{#CustomerName}}<rr:CustomerName>{{CustomerName}}</rr:CustomerName>{{/CustomerName}}
{{#PhoneNumber}}<rr:PhoneNumber>{{PhoneNumber}}</rr:PhoneNumber>{{/PhoneNumber}}
{{#EmailAddress}}<rr:EmailAddress>{{EmailAddress}}</rr:EmailAddress>{{/EmailAddress}}
</rr:Customer>
{{/Customer}}
<!-- Optional vehicle patch -->
{{#Vehicle}}
<rr:Vehicle>
{{#VIN}}<rr:VIN>{{VIN}}</rr:VIN>{{/VIN}}
{{#LicensePlate}}<rr:LicensePlate>{{LicensePlate}}</rr:LicensePlate>{{/LicensePlate}}
{{#Year}}<rr:Year>{{Year}}</rr:Year>{{/Year}}
{{#Make}}<rr:Make>{{Make}}</rr:Make>{{/Make}}
{{#Model}}<rr:Model>{{Model}}</rr:Model>{{/Model}}
{{#Odometer}}<rr:Odometer>{{Odometer}}</rr:Odometer>{{/Odometer}}
{{#Color}}<rr:Color>{{Color}}</rr:Color>{{/Color}}
</rr:Vehicle>
{{/Vehicle}}
<!-- Line changes: use one of AddedJobLines / UpdatedJobLines / RemovedJobLines -->
{{#AddedJobLines}}
<rr:AddedJobLine>
{{#Sequence}}<rr:Sequence>{{Sequence}}</rr:Sequence>{{/Sequence}}
{{#OpCode}}<rr:OpCode>{{OpCode}}</rr:OpCode>{{/OpCode}}
{{#Description}}<rr:Description>{{Description}}</rr:Description>{{/Description}}
{{#LaborHours}}<rr:LaborHours>{{LaborHours}}</rr:LaborHours>{{/LaborHours}}
{{#LaborRate}}<rr:LaborRate>{{LaborRate}}</rr:LaborRate>{{/LaborRate}}
{{#PartNumber}}<rr:PartNumber>{{PartNumber}}</rr:PartNumber>{{/PartNumber}}
{{#PartDescription}}<rr:PartDescription>{{PartDescription}}</rr:PartDescription>{{/PartDescription}}
{{#Quantity}}<rr:Quantity>{{Quantity}}</rr:Quantity>{{/Quantity}}
{{#UnitPrice}}<rr:UnitPrice>{{UnitPrice}}</rr:UnitPrice>{{/UnitPrice}}
{{#ExtendedPrice}}<rr:ExtendedPrice>{{ExtendedPrice}}</rr:ExtendedPrice>{{/ExtendedPrice}}
{{#TaxCode}}<rr:TaxCode>{{TaxCode}}</rr:TaxCode>{{/TaxCode}}
{{#PayType}}<rr:PayType>
{{PayType}}</rr:PayType>{{/PayType}} <!-- CUST|INS|WARR|INT -->
{{#Reason}}<rr:Reason>{{Reason}}</rr:Reason>{{/Reason}}
</rr:AddedJobLine>
{{/AddedJobLines}}
{{#UpdatedJobLines}}
<rr:UpdatedJobLine>
<!-- Identify the existing line either by Sequence or LineId -->
{{#LineId}}<rr:LineId>{{LineId}}</rr:LineId>{{/LineId}}
{{#Sequence}}<rr:Sequence>{{Sequence}}</rr:Sequence>{{/Sequence}}
{{#ChangeType}}<rr:ChangeType>
{{ChangeType}}</rr:ChangeType>{{/ChangeType}} <!-- PRICE|QTY|DESC|OPCODE|PAYTYPE -->
{{#OpCode}}<rr:OpCode>{{OpCode}}</rr:OpCode>{{/OpCode}}
{{#Description}}<rr:Description>{{Description}}</rr:Description>{{/Description}}
{{#LaborHours}}<rr:LaborHours>{{LaborHours}}</rr:LaborHours>{{/LaborHours}}
{{#LaborRate}}<rr:LaborRate>{{LaborRate}}</rr:LaborRate>{{/LaborRate}}
{{#PartNumber}}<rr:PartNumber>{{PartNumber}}</rr:PartNumber>{{/PartNumber}}
{{#PartDescription}}<rr:PartDescription>{{PartDescription}}</rr:PartDescription>{{/PartDescription}}
{{#Quantity}}<rr:Quantity>{{Quantity}}</rr:Quantity>{{/Quantity}}
{{#UnitPrice}}<rr:UnitPrice>{{UnitPrice}}</rr:UnitPrice>{{/UnitPrice}}
{{#ExtendedPrice}}<rr:ExtendedPrice>{{ExtendedPrice}}</rr:ExtendedPrice>{{/ExtendedPrice}}
{{#TaxCode}}<rr:TaxCode>{{TaxCode}}</rr:TaxCode>{{/TaxCode}}
{{#PayType}}<rr:PayType>{{PayType}}</rr:PayType>{{/PayType}}
{{#Reason}}<rr:Reason>{{Reason}}</rr:Reason>{{/Reason}}
</rr:UpdatedJobLine>
{{/UpdatedJobLines}}
{{#RemovedJobLines}}
<rr:RemovedJobLine>
{{#LineId}}<rr:LineId>{{LineId}}</rr:LineId>{{/LineId}}
{{#Sequence}}<rr:Sequence>{{Sequence}}</rr:Sequence>{{/Sequence}}
{{#OpCode}}<rr:OpCode>{{OpCode}}</rr:OpCode>{{/OpCode}}
{{#Reason}}<rr:Reason>{{Reason}}</rr:Reason>{{/Reason}}
</rr:RemovedJobLine>
{{/RemovedJobLines}}
<!-- Totals (optional patch if RR expects header totals on change) -->
{{#Totals}}
<rr:Totals>
{{#LaborTotal}}<rr:LaborTotal>{{LaborTotal}}</rr:LaborTotal>{{/LaborTotal}}
{{#PartsTotal}}<rr:PartsTotal>{{PartsTotal}}</rr:PartsTotal>{{/PartsTotal}}
{{#MiscTotal}}<rr:MiscTotal>{{MiscTotal}}</rr:MiscTotal>{{/MiscTotal}}
{{#TaxTotal}}<rr:TaxTotal>{{TaxTotal}}</rr:TaxTotal>{{/TaxTotal}}
{{#GrandTotal}}<rr:GrandTotal>{{GrandTotal}}</rr:GrandTotal>{{/GrandTotal}}
</rr:Totals>
{{/Totals}}
<!-- Insurance (optional update) -->
{{#Insurance}}
<rr:Insurance>
{{#CompanyName}}<rr:CompanyName>{{CompanyName}}</rr:CompanyName>{{/CompanyName}}
{{#ClaimNumber}}<rr:ClaimNumber>{{ClaimNumber}}</rr:ClaimNumber>{{/ClaimNumber}}
{{#AdjusterName}}<rr:AdjusterName>{{AdjusterName}}</rr:AdjusterName>{{/AdjusterName}}
{{#AdjusterPhone}}<rr:AdjusterPhone>{{AdjusterPhone}}</rr:AdjusterPhone>{{/AdjusterPhone}}
</rr:Insurance>
{{/Insurance}}
<!-- Notes (append or replace depending on RR semantics) -->
{{#Notes}}
<rr:Notes>
{{#Items}}<rr:Note>{{.}}</rr:Note>{{/Items}}
</rr:Notes>
{{/Notes}}
</rr:RepairOrder>
</rr:RepairOrderChgRq>

View File

@@ -0,0 +1,17 @@
<!-- _EnvelopeFooter.xml -->
<rr:Footer xmlns:rr="http://reynoldsandrey.com/">
<!-- Optional system trace or session info -->
{{#SessionId}}
<rr:SessionId>{{SessionId}}</rr:SessionId>
{{/SessionId}}
{{#Checksum}}
<rr:Checksum>{{Checksum}}</rr:Checksum>
{{/Checksum}}
{{#Timestamp}}
<rr:Timestamp>{{Timestamp}}</rr:Timestamp>
{{/Timestamp}}
<!-- Placeholder for any future required footer elements -->
</rr:Footer>

View File

@@ -0,0 +1,29 @@
<!-- _EnvelopeHeader.xml -->
<rr:Authentication xmlns:rr="http://reynoldsandrey.com/">
<!-- Required system identifier -->
{{#PPSysId}}
<rr:PPSysId>{{PPSysId}}</rr:PPSysId>
{{/PPSysId}}
<!-- Dealer / Store / Branch numbers (optional but strongly recommended) -->
{{#DealerNumber}}
<rr:DealerNumber>{{DealerNumber}}</rr:DealerNumber>
{{/DealerNumber}}
{{#StoreNumber}}
<rr:StoreNumber>{{StoreNumber}}</rr:StoreNumber>
{{/StoreNumber}}
{{#BranchNumber}}
<rr:BranchNumber>{{BranchNumber}}</rr:BranchNumber>
{{/BranchNumber}}
<!-- Basic user credentials (always required) -->
<rr:Username>{{Username}}</rr:Username>
<rr:Password>{{Password}}</rr:Password>
<!-- Optional custom correlation token -->
{{#CorrelationId}}
<rr:CorrelationId>{{CorrelationId}}</rr:CorrelationId>
{{/CorrelationId}}
</rr:Authentication>

View File

@@ -416,14 +416,6 @@ const redisSocketEvents = ({
}
});
socket.on("rr-get-advisors", async ({ jobid, params }, cb) => {
// similar pattern using RrGetAdvisors
});
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");