425 lines
15 KiB
JavaScript
425 lines
15 KiB
JavaScript
// server/rr/rr-mappers.js
|
|
// -----------------------------------------------------------------------------
|
|
// 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.
|
|
//
|
|
// 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.
|
|
//
|
|
// 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/>
|
|
//
|
|
// All map* functions below return a plain object shaped for Mustache rendering.
|
|
// -----------------------------------------------------------------------------
|
|
|
|
const _ = require("lodash");
|
|
|
|
// Keep this consistent with other providers (sanitize strings for XML)
|
|
const REPLACE_SPECIAL = /[^a-zA-Z0-9 .,\n#\-()/]+/g;
|
|
|
|
function sanitize(v) {
|
|
if (v === null || v === undefined) return null;
|
|
return String(v).replace(REPLACE_SPECIAL, "").trim();
|
|
}
|
|
|
|
function upper(v) {
|
|
const s = sanitize(v);
|
|
return s ? s.toUpperCase() : null;
|
|
}
|
|
|
|
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 (spec): adjust PhoneType enumerations if RR requires strict codes.
|
|
const out = [];
|
|
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 }) {
|
|
if (!email) return [];
|
|
// TODO (spec): include EmailType (e.g., PERSONAL/WORK) if RR mandates it.
|
|
return [{ EmailAddress: sanitize(email), EmailType: "PERSONAL" }];
|
|
}
|
|
|
|
/* -------------------------------- Addresses -------------------------------- */
|
|
|
|
function mapPostalAddressFromJob(job) {
|
|
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 }
|
|
};
|
|
}
|
|
|
|
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?.customerId ||
|
|
merged?.id ||
|
|
merged?.customer?.id ||
|
|
patch?.CustomerId ||
|
|
patch?.customerId ||
|
|
null;
|
|
|
|
// Derive company vs individual
|
|
const isCompany = Boolean(merged?.CompanyName || merged?.customerName?.companyName);
|
|
|
|
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
|
|
};
|
|
|
|
// 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);
|
|
|
|
// Phones & Emails
|
|
const phones = merged?.Phones || merged?.contactMethods?.phones || [];
|
|
const emails = merged?.Emails || merged?.contactMethods?.emailAddresses || [];
|
|
|
|
return {
|
|
...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 --------------------------------- */
|
|
|
|
function mapVehicleInsertFromJob(job, dealerCfg = {}, opts = {}) {
|
|
// opts: { customerId }
|
|
const dealer = buildDealerVars(dealerCfg);
|
|
|
|
return {
|
|
...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 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)
|
|
};
|
|
|
|
return {
|
|
...dealer,
|
|
RequestId: job?.id || null,
|
|
Environment: process.env.NODE_ENV || "development",
|
|
|
|
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",
|
|
|
|
CustomerId: customerVars.CustomerId,
|
|
CustomerName: customerVars.CustomerName,
|
|
PhoneNumber: customerVars.PhoneNumber,
|
|
EmailAddress: customerVars.EmailAddress,
|
|
|
|
VIN: vehicleVars.VIN,
|
|
LicensePlate: vehicleVars.LicensePlate,
|
|
Year: vehicleVars.Year,
|
|
Make: vehicleVars.Make,
|
|
Model: vehicleVars.Model,
|
|
Odometer: vehicleVars.Odometer,
|
|
Color: vehicleVars.Color,
|
|
|
|
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
|
|
};
|
|
}
|
|
|
|
function mapRepairOrderChangeFromJob(current, delta = {}, dealerCfg = {}) {
|
|
// current: existing RO (our cached shape)
|
|
// delta: patch object describing header fields and line changes
|
|
const dealer = buildDealerVars(dealerCfg);
|
|
|
|
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
|
|
};
|
|
}
|
|
|
|
/* ------------------------------- 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 = {
|
|
// Customer
|
|
mapCustomerInsert,
|
|
mapCustomerUpdate,
|
|
|
|
// Vehicle
|
|
mapVehicleInsertFromJob,
|
|
|
|
// Repair orders
|
|
mapRepairOrderAddFromJob,
|
|
mapRepairOrderChangeFromJob,
|
|
mapJobLineToRRLine,
|
|
|
|
// shared utils (handy in tests)
|
|
buildDealerVars,
|
|
_sanitize: sanitize,
|
|
_upper: upper,
|
|
_normalizePostal: normalizePostal
|
|
};
|