Files
bodyshop/server/rr/rr-mappers.js

334 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// -----------------------------------------------------------------------------
// Centralized mapping & normalization for Reynolds & Reynolds (RR)
//
// This is scaffolding aligned to the Rome RR PDFs you provided:
//
// - 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");
// 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 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: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" });
if (mobile) out.push({ number: sanitize(mobile), type: "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" }];
}
// ---------- Address/Contact from Rome JobData --------------------------------
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: []
}
};
}
/**
* 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 = {}) {
const merged = _.merge({}, existingCustomer || {}, patch || {});
const id = merged?.customerId || merged?.id || merged?.CustomerId || merged?.customer?.id || null;
const isCompany = Boolean(merged?.customerName?.companyName) || Boolean(merged?.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 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"
};
// Contacts (reuse existing unless patch supplied a new structure upstream)
const normalizedPhones = merged?.contactMethods?.phones || merged?.phones || [];
const normalizedEmails = merged?.contactMethods?.emailAddresses || merged?.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.)
}
};
}
// ---------- 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 = {}) {
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.
}
};
}
// ---------- 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 {
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.
}
};
}
/**
* 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),
// 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
}));
}
// ---------- Exports -----------------------------------------------------------
module.exports = {
// Used by current call-sites:
mapCustomerInsert,
mapCustomerUpdate,
mapRepairOrderCreate,
mapRepairOrderUpdate,
// Extra scaffolds youll likely use soon:
mapVehicleInsertFromJob,
mapJobLineToRRLine,
mapTaxes,
mapPayments,
// Low-level utils (handy in tests)
_sanitize: sanitize,
_normalizePostal: normalizePostal,
_toUpperOrNull: toUpperOrNull
};