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

@@ -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
};