feature/Reynolds-and-Reynolds-DMS-API-Integration -Expand

This commit is contained in:
Dave
2025-10-01 17:11:34 -04:00
parent 42027f0858
commit 24f017bfd2
11 changed files with 744 additions and 333 deletions

View File

@@ -1,43 +1,55 @@
// server/rr/rr-mappers.js
// -----------------------------------------------------------------------------
// Centralized mapping & normalization for Reynolds & Reynolds (RR)
//
// NOTE: This is scaffolding intended to be completed against the RR XML/JSON
// schemas in the Rome RR specs you dropped (Customer Insert/Update, Repair Order,
// Service Vehicle, etc.). Field names below are placeholders where noted.
// Fill the TODOs with the exact RR element/attribute names.
// This is scaffolding aligned to the Rome RR PDFs you provided:
//
// Usage expectation from calling code (example you gave):
// const { mapCustomerInsert, mapCustomerUpdate } = require("./rr-mappers");
// const body = mapCustomerInsert(JobData);
// const body = mapCustomerUpdate(existingCustomer, patch);
// - Rome Customer Insert Specification 1.2.pdf
// - Rome Customer Update Specification 1.2.pdf
// - Rome Insert Service Vehicle Interface Specification.pdf
// - Rome Create Body Shop Management Repair Order Interface Specification.pdf
// - Rome Update Body Shop Management Repair Order Interface Specification.pdf
// - Rome Get Advisors Specification.pdf
// - Rome Get Part Specification.pdf
// - Rome Search Customer Service Vehicle Combined Specification.pdf
//
// Replace all TODO:RR with exact element/attribute names and enumerations from
// the PDFs above. The shapes here are intentionally close to other providers
// so you can reuse upstream plumbing without surprises.
// -----------------------------------------------------------------------------
const _ = require("lodash");
const InstanceMgr = require("../utils/instanceMgr").default;
// Keep this consistent with other providers
const replaceSpecialRegex = /[^a-zA-Z0-9 .,\n #]+/g;
// ---------- Generic helpers --------------------------------------------------
function sanitize(value) {
if (value === null || value === undefined) return value;
return String(value).replace(replaceSpecialRegex, "").trim();
}
function normalizePostal(raw) {
// Match Fortellis/CDK behavior for CA vs US formatting
// (Use InstanceMgr profile detection already present in your codebase)
return InstanceMgr({
imex: raw && String(raw).toUpperCase().replace(/\W/g, "").replace(/(...)/, "$1 "),
rome: raw
});
}
function asStringOrNull(value) {
const s = sanitize(value);
return s && s.length > 0 ? s : null;
}
function toUpperOrNull(value) {
const s = asStringOrNull(value);
return s ? s.toUpperCase() : null;
}
/**
* Normalize postal/zip minimally; keep simple and provider-agnostic for now.
* TODO:RR — If RR enforces specific postal formatting by country, implement it here.
*/
function normalizePostal(raw) {
if (!raw) return null;
return String(raw).trim();
}
function mapPhones({ ph1, ph2, mobile }) {
// TODO: Update to RRs final phone structure and codes/types when wiring
// TODO:RR — Replace "HOME|WORK|MOBILE" with RR's phone type codes + any flags (preferred, sms ok).
const out = [];
if (ph1) out.push({ number: sanitize(ph1), type: "HOME" });
if (ph2) out.push({ number: sanitize(ph2), type: "WORK" });
@@ -46,22 +58,24 @@ function mapPhones({ ph1, ph2, mobile }) {
}
function mapEmails({ email }) {
// RR often supports multiple emails; start with one.
// TODO: Update per RR schema (email flags, preferred, etc.)
// TODO:RR — If RR supports multiple with flags, expand (preferred, statement, etc.).
if (!email) return [];
return [{ address: sanitize(email), type: "PERSONAL" }];
}
// ---------- Address/Contact from Rome JobData --------------------------------
function mapPostalAddressFromJob(job) {
// Rome job-level owner fields (aligning to prior provider scaffolds)
// Rome job-level owner fields (aligned with other providers)
// TODO:RR — Confirm exact element names (e.g., AddressLine vs Street1, State vs Province).
return {
addressLine1: asStringOrNull(job.ownr_addr1),
addressLine2: asStringOrNull(job.ownr_addr2),
city: asStringOrNull(job.ownr_city),
state: asStringOrNull(job.ownr_st || job.ownr_state),
province: asStringOrNull(job.ownr_province), // keep both for CA use-cases if distinct in RR
postalCode: normalizePostal(job.ownr_zip),
country: asStringOrNull(job.ownr_ctry) || "USA", // default, adjust as needed
province: asStringOrNull(job.ownr_st) // keep both state/province fields for CA cases
country: asStringOrNull(job.ownr_ctry) || "USA"
};
}
@@ -77,93 +91,97 @@ function mapEmailsFromJob(job) {
return mapEmails({ email: job.ownr_ea });
}
// ---------- Customer mappers --------------------------------------------------
/**
* Customer Insert
* Matches your call site:
* const body = mapCustomerInsert(JobData)
* Matches call-site: const body = mapCustomerInsert(JobData);
*
* Return shape intentionally mirrors Fortellis scaffolding so the same
* MakeRRCall pipeline can be reused. Replace placeholders with the RR specs
* request envelope/element names (e.g., CustomerInsertRq, CustomerRq, etc.).
* TODO:RR — Replace envelope and field names with exact RR schema:
* e.g., CustomerInsertRq.Customer (Organization vs Person), name blocks, ids, codes, etc.
*/
function mapCustomerInsert(job) {
const isCompany = Boolean(job.ownr_co_nm && job.ownr_co_nm.trim() !== "");
const isCompany = Boolean(job?.ownr_co_nm && job.ownr_co_nm.trim() !== "");
// Skeleton payload — replace keys under CustomerInsertRq with the actual RR names
return {
// Example envelope — rename to match the PDF (e.g., "CustomerInsertRq")
CustomerInsertRq: {
// TODO: Confirm RR element/attribute names from spec PDFs
// High-level type — confirm the exact enum RR expects.
customerType: isCompany ? "ORGANIZATION" : "INDIVIDUAL",
// Name block — ensure RR's exact element names and casing.
customerName: {
companyName: asStringOrNull(job.ownr_co_nm)?.toUpperCase() || null,
firstName: isCompany ? null : asStringOrNull(job.ownr_fn)?.toUpperCase(),
lastName: isCompany ? null : asStringOrNull(job.ownr_ln)?.toUpperCase()
companyName: isCompany ? toUpperOrNull(job.ownr_co_nm) : null,
firstName: isCompany ? null : toUpperOrNull(job.ownr_fn),
lastName: isCompany ? null : toUpperOrNull(job.ownr_ln)
},
// Mailing address
postalAddress: mapPostalAddressFromJob(job),
// Contacts
contactMethods: {
phones: mapPhonesFromJob(job),
emailAddresses: mapEmailsFromJob(job)
}
// Optional / placeholders for future fields in RR spec
// TODO:RR — Common optional fields: tax/resale codes, pricing flags, AR terms, source codes, etc.
// taxCode: null,
// groupCode: null,
// dealerFields: []
// termsCode: null,
// marketingOptIn: null,
// dealerSpecificFields: []
}
};
}
/**
* Customer Update
* Matches your call site:
* const body = mapCustomerUpdate(existingCustomer, patch)
* Matches call-site: const body = mapCustomerUpdate(existingCustomer, patch);
*
* - existingCustomer: prior RR customer payload/shape (from RR Read/Query)
* - patch: minimal delta from UI/Job selections to overlay onto the RR model
* - existingCustomer: RR's current representation (from Read/Query)
* - patch: a thin delta from UI/Job selection
*
* We return a merged/normalized payload for RR Update.
* TODO:RR — Swap envelope/fields for RR's specific Update schema.
*/
function mapCustomerUpdate(existingCustomer, patch = {}) {
// NOTE:
// 1) We assume existingCustomer already resembles RRs stored shape.
// 2) We overlay patch fields into that shape, then project to the
// RR Update request envelope.
// 3) Replace inner keys with exact RR Update schema element names.
const merged = _.merge({}, existingCustomer || {}, patch || {});
const id = merged?.customerId || merged?.id || merged?.CustomerId || merged?.customer?.id || null;
// Derive a normalized name object from merged data (handles org/person)
const isCompany = Boolean(merged?.customerName?.companyName) || Boolean(merged?.companyName) || false;
const isCompany = Boolean(merged?.customerName?.companyName) || Boolean(merged?.companyName);
const normalizedName = {
companyName: asStringOrNull(merged?.customerName?.companyName) || asStringOrNull(merged?.companyName),
firstName: isCompany ? null : asStringOrNull(merged?.customerName?.firstName) || asStringOrNull(merged?.firstName),
lastName: isCompany ? null : asStringOrNull(merged?.customerName?.lastName) || asStringOrNull(merged?.lastName)
companyName: asStringOrNull(merged?.customerName?.companyName) || asStringOrNull(merged?.companyName) || null,
firstName: isCompany
? null
: asStringOrNull(merged?.customerName?.firstName) || asStringOrNull(merged?.firstName) || null,
lastName: isCompany
? null
: asStringOrNull(merged?.customerName?.lastName) || asStringOrNull(merged?.lastName) || null
};
const normalizedAddress = {
addressLine1: asStringOrNull(merged?.postalAddress?.addressLine1) || asStringOrNull(merged?.addressLine1),
addressLine2: asStringOrNull(merged?.postalAddress?.addressLine2) || asStringOrNull(merged?.addressLine2),
city: asStringOrNull(merged?.postalAddress?.city) || asStringOrNull(merged?.city),
addressLine1: asStringOrNull(merged?.postalAddress?.addressLine1) || asStringOrNull(merged?.addressLine1) || null,
addressLine2: asStringOrNull(merged?.postalAddress?.addressLine2) || asStringOrNull(merged?.addressLine2) || null,
city: asStringOrNull(merged?.postalAddress?.city) || asStringOrNull(merged?.city) || null,
state:
asStringOrNull(merged?.postalAddress?.state) ||
asStringOrNull(merged?.state) ||
asStringOrNull(merged?.stateOrProvince) ||
asStringOrNull(merged?.province),
asStringOrNull(merged?.province) ||
null,
province: asStringOrNull(merged?.postalAddress?.province) || asStringOrNull(merged?.province) || null,
postalCode: normalizePostal(merged?.postalAddress?.postalCode || merged?.postalCode),
country: asStringOrNull(merged?.postalAddress?.country) || asStringOrNull(merged?.country) || "USA",
province: asStringOrNull(merged?.postalAddress?.province) || asStringOrNull(merged?.province)
country: asStringOrNull(merged?.postalAddress?.country) || asStringOrNull(merged?.country) || "USA"
};
// Phones
// Contacts (reuse existing unless patch supplied a new structure upstream)
const normalizedPhones = merged?.contactMethods?.phones || merged?.phones || [];
// Emails
const normalizedEmails = merged?.contactMethods?.emailAddresses || merged?.emailAddresses || [];
return {
// Example envelope — rename to match the PDF (e.g., "CustomerUpdateRq")
CustomerUpdateRq: {
// TODO: Confirm exact RR element/attribute names for update
customerId: id,
customerType: normalizedName.companyName ? "ORGANIZATION" : "INDIVIDUAL",
customerName: normalizedName,
@@ -172,77 +190,144 @@ function mapCustomerUpdate(existingCustomer, patch = {}) {
phones: normalizedPhones,
emailAddresses: normalizedEmails
}
// Optional change tracking fields, timestamps, etc., per RR spec can go here
// TODO:RR — include fields that RR requires for update (version, hash, lastUpdatedTs, etc.)
}
};
}
/* ===== Additional mappers (scaffolding for upcoming work) ===== */
// ---------- Vehicle mappers ---------------------------------------------------
/**
* Vehicle Insert from JobData
* Called (or call-able) by InsertVehicle.
*
* TODO:RR — Replace envelope/field names with the exact RR vehicle schema.
*/
function mapVehicleInsertFromJob(job, txEnvelope = {}) {
// TODO: Replace with RR Service Vehicle Insert schema
return {
ServiceVehicleInsertRq: {
vin: asStringOrNull(job.v_vin),
// Year/make/model — validate source fields vs RR required fields
year: job.v_model_yr || null,
make: txEnvelope.dms_make || asStringOrNull(job.v_make),
model: txEnvelope.dms_model || asStringOrNull(job.v_model),
odometer: txEnvelope.kmout || null,
licensePlate: job.plate_no && /\w/.test(job.plate_no) ? asStringOrNull(job.plate_no).toUpperCase() : null
make: toUpperOrNull(txEnvelope.dms_make || job.v_make),
model: toUpperOrNull(txEnvelope.dms_model || job.v_model),
// Mileage/odometer — confirm units/element names
odometer: txEnvelope.kmout || txEnvelope.miout || null,
// Plate — uppercase and sanitize
licensePlate: job.plate_no ? toUpperOrNull(job.plate_no) : null
// TODO:RR — owner/customer link, color, trim, fuel, DRIVETRAIN, etc.
}
};
}
function mapRepairOrderAddFromJob(job) {
// TODO: Replace with RR RepairOrder Add schema (headers, lines, taxes)
// ---------- Repair Order mappers ---------------------------------------------
/**
* Create Repair Order
* Matches call-site: mapRepairOrderCreate({ JobData, txEnvelope })
*
* TODO:RR — Use the exact request envelope/fields for Create RO from the PDF:
* Header (customer/vehicle/ro-no/dates), lines/labors/parts/taxes, totals.
*/
function mapRepairOrderCreate({ JobData, txEnvelope }) {
return {
RepairOrderAddRq: {
customerId: job.customer?.id || null,
vehicleId: job.vehicle?.id || null,
referenceNumber: asStringOrNull(job.ro_number),
openedAt: job.actual_in || null,
closedAt: job.invoice_date || null
// lines: job.joblines?.map(mapJobLineToRRLine),
// taxes: mapTaxes(job),
// payments: mapPayments(job)
RepairOrderCreateRq: {
// Header
referenceNumber: asStringOrNull(JobData.ro_number),
customerId: JobData?.customer?.id || null, // supply from previous step or selection
vehicleId: JobData?.vehicle?.id || null, // supply from previous step
openedAt: JobData?.actual_in || null, // confirm expected datetime format
promisedAt: JobData?.promise_date || null,
advisorId: txEnvelope?.advisorId || null,
// Lines (placeholder)
lines: Array.isArray(JobData?.joblines) ? JobData.joblines.map(mapJobLineToRRLine) : [],
// Taxes (placeholder)
taxes: mapTaxes(JobData),
// Payments (placeholder)
payments: mapPayments(txEnvelope)
// TODO:RR — add required flags, shop supplies, labor matrix, discounts, etc.
}
};
}
function mapRepairOrderChangeFromJob(job) {
// TODO: Replace with RR RepairOrder Update schema
/**
* Update Repair Order
* Matches call-site: mapRepairOrderUpdate({ JobData, txEnvelope })
*
* TODO:RR — RR may want delta format (change set) vs full replace.
* Add versioning/concurrency tokens if specified in the PDF.
*/
function mapRepairOrderUpdate({ JobData, txEnvelope }) {
return {
RepairOrderChgRq: {
repairOrderId: job.id,
referenceNumber: asStringOrNull(job.ro_number)
// delta lines, amounts, status, etc.
RepairOrderUpdateRq: {
repairOrderId: JobData?.id || txEnvelope?.repairOrderId || null,
referenceNumber: asStringOrNull(JobData?.ro_number),
// Example: only pass changed lines (you may need your diff before mapping)
// For scaffolding, we pass what we have; replace with proper deltas later.
lines: Array.isArray(JobData?.joblines) ? JobData.joblines.map(mapJobLineToRRLine) : [],
taxes: mapTaxes(JobData),
payments: mapPayments(txEnvelope)
// TODO:RR — include RO status transitions, close/invoice flags, etc.
}
};
}
/* Example line mapper (placeholder) */
/* ----- Line/Tax/Payment helpers (placeholders) ----------------------------- */
function mapJobLineToRRLine(line) {
// TODO:RR — Replace with RR RO line schema (labor/part/misc line types, op-code, flags).
return {
// TODO: set RR fields
seq: line.sequence || null,
opCode: line.opCode || null,
description: asStringOrNull(line.description),
qty: line.part_qty || null,
price: line.price || null
lineType: line?.type || "LABOR", // e.g., LABOR | PART | MISC
sequence: line?.sequence || null,
opCode: line?.opCode || line?.opcode || null,
description: asStringOrNull(line?.description || line?.descr),
quantity: line?.part_qty || line?.qty || 1,
unitPrice: line?.price || line?.unitPrice || null,
extendedAmount: line?.ext || null
};
}
function mapTaxes(job) {
// TODO:RR — Implement per RR tax structure (rates by jurisdiction, taxable flags, rounding rules).
// Return empty array as scaffolding.
return [];
}
function mapPayments(txEnvelope = {}) {
// TODO:RR — Implement per RR payment shape (payer types, amounts, reference ids)
// For Fortellis/CDK parity, txEnvelope.payers often exists; adapt to RR fields.
if (!Array.isArray(txEnvelope?.payers)) return [];
return txEnvelope.payers.map((p) => ({
payerType: p.type || "INSURER", // e.g., CUSTOMER | INSURER | WARRANTY
reference: asStringOrNull(p.controlnumber || p.ref),
amount: p.amount != null ? Number(p.amount) : null
}));
}
// ---------- Exports -----------------------------------------------------------
module.exports = {
// Required by your current calling code:
// Used by current call-sites:
mapCustomerInsert,
mapCustomerUpdate,
mapRepairOrderCreate,
mapRepairOrderUpdate,
// Extra scaffolds well likely use right after:
// Extra scaffolds youll likely use soon:
mapVehicleInsertFromJob,
mapRepairOrderAddFromJob,
mapRepairOrderChangeFromJob,
mapJobLineToRRLine,
mapTaxes,
mapPayments,
// low-level utils (export if you want to reuse in tests)
// Low-level utils (handy in tests)
_sanitize: sanitize,
_normalizePostal: normalizePostal
_normalizePostal: normalizePostal,
_toUpperOrNull: toUpperOrNull
};