feature/IO-3357-Reynolds-and-Reynolds-DMS-API-Integration - Checkpoint
This commit is contained in:
@@ -1,424 +1,412 @@
|
||||
// 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.
|
||||
/**
|
||||
* @file rr-mappers.js
|
||||
* @description Maps internal ImEX (Hasura) entities into Rome (Reynolds & Reynolds) XML structures.
|
||||
* Each function returns a plain JS object that matches Mustache templates in xml-templates/.
|
||||
*/
|
||||
|
||||
const dayjs = require("dayjs");
|
||||
|
||||
/**
|
||||
* Utility: formats date/time to R&R’s preferred format (ISO or yyyy-MM-dd).
|
||||
*/
|
||||
const formatDate = (val) => {
|
||||
if (!val) return undefined;
|
||||
return dayjs(val).format("YYYY-MM-DD");
|
||||
};
|
||||
|
||||
/**
|
||||
* Utility: safely pick numeric values and stringify for XML.
|
||||
*/
|
||||
const num = (val) => (val != null ? String(val) : undefined);
|
||||
|
||||
const toBoolStr = (v) => (v === true ? "true" : v === false ? "false" : undefined);
|
||||
const hasAny = (obj) => !!obj && Object.values(obj).some((v) => v !== undefined && v !== null && v !== "");
|
||||
|
||||
//
|
||||
// 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.
|
||||
// ===================== CUSTOMER =====================
|
||||
//
|
||||
// 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");
|
||||
/**
|
||||
* Map internal customer record to Rome CustomerInsertRq.
|
||||
*/
|
||||
function mapCustomerInsert(customer, bodyshopConfig) {
|
||||
if (!customer) return {};
|
||||
|
||||
// Keep this consistent with other providers (sanitize strings for XML)
|
||||
const REPLACE_SPECIAL = /[^a-zA-Z0-9 .,\n#\-()/]+/g;
|
||||
return {
|
||||
DealerCode: bodyshopConfig?.dealer_code || "ROME",
|
||||
DealerNumber: bodyshopConfig?.dealer_number,
|
||||
StoreNumber: bodyshopConfig?.store_number,
|
||||
BranchNumber: bodyshopConfig?.branch_number,
|
||||
RequestId: `CUST-INSERT-${customer.id}`,
|
||||
Environment: process.env.NODE_ENV,
|
||||
|
||||
function sanitize(v) {
|
||||
if (v === null || v === undefined) return null;
|
||||
return String(v).replace(REPLACE_SPECIAL, "").trim();
|
||||
}
|
||||
CustomerId: customer.external_id || undefined,
|
||||
CustomerType: customer.type || "RETAIL",
|
||||
CompanyName: customer.company_name,
|
||||
FirstName: customer.first_name,
|
||||
MiddleName: customer.middle_name,
|
||||
LastName: customer.last_name,
|
||||
PreferredName: customer.display_name || customer.first_name,
|
||||
ActiveFlag: customer.active ? "true" : "false",
|
||||
|
||||
function upper(v) {
|
||||
const s = sanitize(v);
|
||||
return s ? s.toUpperCase() : null;
|
||||
}
|
||||
CustomerGroup: customer.group_name,
|
||||
TaxExempt: customer.tax_exempt ? "true" : "false",
|
||||
DiscountLevel: num(customer.discount_level),
|
||||
PreferredLanguage: customer.language || "EN",
|
||||
|
||||
function asNumberOrNull(v) {
|
||||
if (v === null || v === undefined || v === "") return null;
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
Addresses: (customer.addresses || []).map((a) => ({
|
||||
AddressType: a.type || "BILLING",
|
||||
AddressLine1: a.line1,
|
||||
AddressLine2: a.line2,
|
||||
City: a.city,
|
||||
State: a.state,
|
||||
PostalCode: a.postal_code,
|
||||
Country: a.country || "US"
|
||||
})),
|
||||
|
||||
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);
|
||||
Phones: (customer.phones || []).map((p) => ({
|
||||
PhoneNumber: p.number,
|
||||
PhoneType: p.type || "MOBILE",
|
||||
Preferred: p.preferred ? "true" : "false"
|
||||
})),
|
||||
|
||||
Emails: (customer.emails || []).map((e) => ({
|
||||
EmailAddress: e.address,
|
||||
EmailType: e.type || "WORK",
|
||||
Preferred: e.preferred ? "true" : "false"
|
||||
})),
|
||||
|
||||
Insurance: customer.insurance
|
||||
? {
|
||||
CompanyName: customer.insurance.company,
|
||||
PolicyNumber: customer.insurance.policy,
|
||||
ExpirationDate: formatDate(customer.insurance.expiration_date),
|
||||
ContactName: customer.insurance.contact_name,
|
||||
ContactPhone: customer.insurance.contact_phone
|
||||
}
|
||||
: undefined,
|
||||
|
||||
LinkedAccounts: (customer.linked_accounts || []).map((a) => ({
|
||||
Type: a.type,
|
||||
AccountNumber: a.account_number,
|
||||
CreditLimit: num(a.credit_limit)
|
||||
})),
|
||||
|
||||
Notes: customer.notes?.length ? { Items: customer.notes.map((n) => n.text || n) } : undefined
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose the dealer section used by every template.
|
||||
* We prefer dealer-level rr_configuration first; fallback to env.
|
||||
* Map internal customer record to Rome CustomerUpdateRq.
|
||||
*/
|
||||
function buildDealerVars(dealerCfg = {}) {
|
||||
function mapCustomerUpdate(customer, bodyshopConfig) {
|
||||
if (!customer) return {};
|
||||
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
|
||||
...mapCustomerInsert(customer, bodyshopConfig),
|
||||
RequestId: `CUST-UPDATE-${customer.id}`
|
||||
};
|
||||
}
|
||||
|
||||
/* ------------------------------- Phones/Emails ------------------------------- */
|
||||
//
|
||||
// ===================== VEHICLE =====================
|
||||
//
|
||||
|
||||
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() !== "");
|
||||
/**
|
||||
* Map vehicle to Rome ServiceVehicleAddRq.
|
||||
*/
|
||||
function mapServiceVehicle(vehicle, ownerCustomer, bodyshopConfig) {
|
||||
if (!vehicle) return {};
|
||||
|
||||
return {
|
||||
...dealer,
|
||||
// Envelope metadata (optional)
|
||||
RequestId: job?.id || null,
|
||||
Environment: process.env.NODE_ENV || "development",
|
||||
DealerCode: bodyshopConfig?.dealer_code || "ROME",
|
||||
DealerNumber: bodyshopConfig?.dealer_number,
|
||||
StoreNumber: bodyshopConfig?.store_number,
|
||||
BranchNumber: bodyshopConfig?.branch_number,
|
||||
RequestId: `VEH-${vehicle.id}`,
|
||||
|
||||
// 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",
|
||||
CustomerId: ownerCustomer?.external_id,
|
||||
|
||||
Addresses: mapPostalAddressFromJob(job),
|
||||
Phones: mapPhones({ ph1: job.ownr_ph1, ph2: job.ownr_ph2, mobile: job.ownr_mobile }),
|
||||
Emails: mapEmails({ email: job.ownr_ea }),
|
||||
VIN: vehicle.vin,
|
||||
UnitNumber: vehicle.unit_number,
|
||||
StockNumber: vehicle.stock_number,
|
||||
Year: num(vehicle.year),
|
||||
Make: vehicle.make,
|
||||
Model: vehicle.model,
|
||||
Trim: vehicle.trim,
|
||||
BodyStyle: vehicle.body_style,
|
||||
Transmission: vehicle.transmission,
|
||||
Engine: vehicle.engine,
|
||||
FuelType: vehicle.fuel_type,
|
||||
DriveType: vehicle.drive_type,
|
||||
Color: vehicle.color,
|
||||
LicensePlate: vehicle.license_plate,
|
||||
LicenseState: vehicle.license_state,
|
||||
Odometer: num(vehicle.odometer),
|
||||
OdometerUnits: vehicle.odometer_units || "KM",
|
||||
InServiceDate: formatDate(vehicle.in_service_date),
|
||||
|
||||
// Optional blocks (keep null unless you truly have values)
|
||||
DriverLicense: null, // { LicenseNumber, LicenseState, ExpirationDate }
|
||||
Insurance: null, // { CompanyName, PolicyNumber, ExpirationDate }
|
||||
Notes: null // { Note }
|
||||
Insurance: vehicle.insurance
|
||||
? {
|
||||
CompanyName: vehicle.insurance.company,
|
||||
PolicyNumber: vehicle.insurance.policy,
|
||||
ExpirationDate: formatDate(vehicle.insurance.expiration_date)
|
||||
}
|
||||
: undefined,
|
||||
|
||||
Warranty: vehicle.warranty
|
||||
? {
|
||||
WarrantyCompany: vehicle.warranty.company,
|
||||
WarrantyNumber: vehicle.warranty.number,
|
||||
WarrantyType: vehicle.warranty.type,
|
||||
ExpirationDate: formatDate(vehicle.warranty.expiration_date)
|
||||
}
|
||||
: undefined,
|
||||
|
||||
VehicleNotes: vehicle.notes?.length ? { Items: vehicle.notes.map((n) => n.text || n) } : undefined
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
//
|
||||
// ===================== REPAIR ORDER =====================
|
||||
//
|
||||
|
||||
// Derive company vs individual
|
||||
const isCompany = Boolean(merged?.CompanyName || merged?.customerName?.companyName);
|
||||
/**
|
||||
* Map internal job to Rome RepairOrderInsertRq.
|
||||
*/
|
||||
function mapRepairOrderCreate(job, bodyshopConfig) {
|
||||
if (!job) return {};
|
||||
|
||||
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 cust = job.customer || {};
|
||||
const veh = job.vehicle || {};
|
||||
|
||||
// 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"
|
||||
return {
|
||||
DealerCode: bodyshopConfig?.dealer_code || "ROME",
|
||||
DealerNumber: bodyshopConfig?.dealer_number,
|
||||
StoreNumber: bodyshopConfig?.store_number,
|
||||
BranchNumber: bodyshopConfig?.branch_number,
|
||||
RequestId: `RO-${job.id}`,
|
||||
Environment: process.env.NODE_ENV,
|
||||
|
||||
RepairOrderNumber: job.ro_number,
|
||||
DmsRepairOrderId: job.external_id,
|
||||
|
||||
OpenDate: formatDate(job.open_date),
|
||||
PromisedDate: formatDate(job.promised_date),
|
||||
CloseDate: formatDate(job.close_date),
|
||||
|
||||
ServiceAdvisorId: job.advisor_id,
|
||||
TechnicianId: job.technician_id,
|
||||
Department: job.department,
|
||||
ProfitCenter: job.profit_center,
|
||||
|
||||
ROType: job.ro_type,
|
||||
Status: job.status,
|
||||
IsBodyShop: "true",
|
||||
DRPFlag: job.drp_flag ? "true" : "false",
|
||||
|
||||
Customer: {
|
||||
CustomerId: cust.external_id,
|
||||
CustomerName: cust.full_name,
|
||||
PhoneNumber: cust.phone,
|
||||
EmailAddress: cust.email
|
||||
},
|
||||
|
||||
Vehicle: {
|
||||
VehicleId: veh.external_id,
|
||||
VIN: veh.vin,
|
||||
LicensePlate: veh.license_plate,
|
||||
Year: num(veh.year),
|
||||
Make: veh.make,
|
||||
Model: veh.model,
|
||||
Odometer: num(veh.odometer),
|
||||
Color: veh.color
|
||||
},
|
||||
|
||||
JobLines: (job.joblines || []).map((l, i) => ({
|
||||
Sequence: i + 1,
|
||||
ParentSequence: l.parent_sequence,
|
||||
LineType: l.line_type,
|
||||
Category: l.category,
|
||||
OpCode: l.op_code,
|
||||
Description: l.description,
|
||||
LaborHours: num(l.labor_hours),
|
||||
LaborRate: num(l.labor_rate),
|
||||
PartNumber: l.part_number,
|
||||
PartDescription: l.part_description,
|
||||
Quantity: num(l.quantity),
|
||||
UnitPrice: num(l.unit_price),
|
||||
ExtendedPrice: num(l.extended_price),
|
||||
DiscountAmount: num(l.discount_amount),
|
||||
TaxCode: l.tax_code,
|
||||
GLAccount: l.gl_account,
|
||||
ControlNumber: l.control_number,
|
||||
Taxes: l.taxes?.length
|
||||
? {
|
||||
Items: l.taxes.map((t) => ({
|
||||
Code: t.code,
|
||||
Amount: num(t.amount),
|
||||
Rate: num(t.rate)
|
||||
}))
|
||||
}
|
||||
]
|
||||
: 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"
|
||||
: undefined
|
||||
})),
|
||||
|
||||
// Optional
|
||||
DriverLicense: merged?.DriverLicense || null,
|
||||
Insurance: merged?.Insurance || null,
|
||||
Notes: merged?.Notes || null
|
||||
};
|
||||
}
|
||||
Totals: {
|
||||
Currency: job.currency || "CAD",
|
||||
LaborTotal: num(job.totals?.labor),
|
||||
PartsTotal: num(job.totals?.parts),
|
||||
MiscTotal: num(job.totals?.misc),
|
||||
DiscountTotal: num(job.totals?.discount),
|
||||
TaxTotal: num(job.totals?.tax),
|
||||
GrandTotal: num(job.totals?.grand)
|
||||
},
|
||||
|
||||
/* --------------------------------- 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
|
||||
Payments: job.payments?.length
|
||||
? {
|
||||
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)
|
||||
Items: job.payments.map((p) => ({
|
||||
PayerType: p.payer_type,
|
||||
PayerName: p.payer_name,
|
||||
Amount: num(p.amount),
|
||||
Method: p.method,
|
||||
Reference: p.reference,
|
||||
ControlNumber: p.control_number
|
||||
}))
|
||||
}
|
||||
: null,
|
||||
: undefined,
|
||||
|
||||
Insurance: txEnvelope?.insurance
|
||||
Insurance: job.insurance
|
||||
? {
|
||||
CompanyName: upper(txEnvelope.insurance.company),
|
||||
ClaimNumber: sanitize(txEnvelope.insurance.claim),
|
||||
AdjusterName: upper(txEnvelope.insurance.adjuster),
|
||||
AdjusterPhone: sanitize(txEnvelope.insurance.phone)
|
||||
CompanyName: job.insurance.company,
|
||||
ClaimNumber: job.insurance.claim_number,
|
||||
AdjusterName: job.insurance.adjuster_name,
|
||||
AdjusterPhone: job.insurance.adjuster_phone
|
||||
}
|
||||
: null,
|
||||
: undefined,
|
||||
|
||||
Notes: txEnvelope?.story ? { Note: sanitize(txEnvelope.story) } : null
|
||||
Notes: job.notes?.length ? { Items: job.notes.map((n) => n.text || n) } : undefined
|
||||
};
|
||||
}
|
||||
|
||||
function mapRepairOrderChangeFromJob(current, delta = {}, dealerCfg = {}) {
|
||||
// current: existing RO (our cached shape)
|
||||
// delta: patch object describing header fields and line changes
|
||||
const dealer = buildDealerVars(dealerCfg);
|
||||
/**
|
||||
* Map for repair order updates.
|
||||
*/
|
||||
function mapRepairOrderUpdate(job, bodyshopConfig) {
|
||||
return {
|
||||
...mapRepairOrderCreate(job, bodyshopConfig),
|
||||
RequestId: `RO-UPDATE-${job.id}`
|
||||
};
|
||||
}
|
||||
|
||||
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
|
||||
}));
|
||||
//
|
||||
// ===================== LOOKUPS =====================
|
||||
//
|
||||
|
||||
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;
|
||||
function mapAdvisorLookup(criteria, bodyshopConfig) {
|
||||
return {
|
||||
DealerCode: bodyshopConfig?.dealer_code || "ROME",
|
||||
DealerNumber: bodyshopConfig?.dealer_number,
|
||||
StoreNumber: bodyshopConfig?.store_number,
|
||||
BranchNumber: bodyshopConfig?.branch_number,
|
||||
RequestId: `LOOKUP-ADVISOR-${Date.now()}`,
|
||||
SearchCriteria: {
|
||||
Department: criteria.department || "Body Shop",
|
||||
Status: criteria.status || "ACTIVE"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
function mapPartsLookup(criteria, bodyshopConfig) {
|
||||
return {
|
||||
DealerCode: bodyshopConfig?.dealer_code || "ROME",
|
||||
DealerNumber: bodyshopConfig?.dealer_number,
|
||||
StoreNumber: bodyshopConfig?.store_number,
|
||||
BranchNumber: bodyshopConfig?.branch_number,
|
||||
RequestId: `LOOKUP-PART-${Date.now()}`,
|
||||
SearchCriteria: {
|
||||
PartNumber: criteria.part_number,
|
||||
Description: criteria.description,
|
||||
Make: criteria.make,
|
||||
Model: criteria.model,
|
||||
Year: num(criteria.year),
|
||||
Category: criteria.category,
|
||||
MaxResults: criteria.max_results || 25
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const notes =
|
||||
Array.isArray(delta?.notes) && delta.notes.length
|
||||
? { Items: delta.notes.map((n) => sanitize(n)).filter(Boolean) }
|
||||
: null;
|
||||
function mapCombinedSearch(criteria = {}, bodyshopConfig) {
|
||||
// accept nested or flat input
|
||||
const c = criteria || {};
|
||||
const cust = c.customer || c.Customer || {};
|
||||
const veh = c.vehicle || c.Vehicle || {};
|
||||
const comp = c.company || c.Company || {};
|
||||
|
||||
// build optional blocks only if they have at least one value
|
||||
const customerBlock = {
|
||||
FirstName: cust.firstName || cust.FirstName || c.firstName,
|
||||
LastName: cust.lastName || cust.LastName || c.lastName,
|
||||
PhoneNumber: cust.phoneNumber || cust.PhoneNumber || c.phoneNumber || c.phone,
|
||||
EmailAddress: cust.email || cust.EmailAddress || c.email,
|
||||
CompanyName: cust.companyName || cust.CompanyName || c.companyName,
|
||||
CustomerId: cust.customerId || cust.CustomerId || c.customerId
|
||||
};
|
||||
const vehicleBlock = {
|
||||
VIN: veh.vin || veh.VIN || c.vin,
|
||||
LicensePlate: veh.licensePlate || veh.LicensePlate || c.licensePlate,
|
||||
Make: veh.make || veh.Make || c.make,
|
||||
Model: veh.model || veh.Model || c.model,
|
||||
Year: veh.year != null ? String(veh.year) : c.year != null ? String(c.year) : undefined,
|
||||
VehicleId: veh.vehicleId || veh.VehicleId || c.vehicleId
|
||||
};
|
||||
const companyBlock = {
|
||||
Name: comp.name || comp.Name || c.companyName,
|
||||
Phone: comp.phone || comp.Phone || c.companyPhone
|
||||
};
|
||||
|
||||
return {
|
||||
...dealer,
|
||||
RequestId: delta?.RequestId || current?.RequestId || null,
|
||||
Environment: process.env.NODE_ENV || "development",
|
||||
// Dealer / routing (aligns with your other mappers)
|
||||
STAR_NS: require("./rr-constants").RR_NS.STAR,
|
||||
MaxRecs: criteria.maxResults || criteria.MaxResults || 50,
|
||||
DealerCode: bodyshopConfig?.dealer_code || "ROME",
|
||||
DealerName: bodyshopConfig?.dealer_name,
|
||||
DealerNumber: bodyshopConfig?.dealer_number,
|
||||
StoreNumber: bodyshopConfig?.store_number,
|
||||
BranchNumber: bodyshopConfig?.branch_number,
|
||||
|
||||
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,
|
||||
RequestId: c.requestId || `COMBINED-${Date.now()}`,
|
||||
Environment: process.env.NODE_ENV,
|
||||
|
||||
// Optional customer/vehicle patches
|
||||
Customer: delta?.Customer || null,
|
||||
Vehicle: delta?.Vehicle || null,
|
||||
// Only include these blocks when they have content; Mustache {{#Block}} respects undefined
|
||||
Customer: hasAny(customerBlock) ? customerBlock : undefined,
|
||||
Vehicle: hasAny(vehicleBlock) ? vehicleBlock : undefined, // template wraps as <rr:ServiceVehicle>…</rr:ServiceVehicle>
|
||||
Company: hasAny(companyBlock) ? companyBlock : undefined,
|
||||
|
||||
// Line changes
|
||||
AddedJobLines: added.length ? added : null,
|
||||
UpdatedJobLines: updated.length ? updated : null,
|
||||
RemovedJobLines: removed.length ? removed : null,
|
||||
// Search behavior flags
|
||||
SearchMode: c.searchMode || c.SearchMode, // EXACT | PARTIAL
|
||||
ExactMatch: toBoolStr(c.exactMatch ?? c.ExactMatch),
|
||||
PartialMatch: toBoolStr(c.partialMatch ?? c.PartialMatch),
|
||||
CaseInsensitive: toBoolStr(c.caseInsensitive ?? c.CaseInsensitive),
|
||||
|
||||
Totals: totals,
|
||||
Insurance: insurance,
|
||||
Notes: notes
|
||||
// Result shaping (default to true when unspecified)
|
||||
ReturnCustomers: toBoolStr(c.returnCustomers ?? c.ReturnCustomers ?? true),
|
||||
ReturnVehicles: toBoolStr(c.returnVehicles ?? c.ReturnVehicles ?? true),
|
||||
ReturnCompanies: toBoolStr(c.returnCompanies ?? c.ReturnCompanies ?? true),
|
||||
|
||||
// Paging / sorting
|
||||
MaxResults: c.maxResults ?? c.MaxResults,
|
||||
PageNumber: c.pageNumber ?? c.PageNumber,
|
||||
SortBy: c.sortBy ?? c.SortBy, // e.g., NAME, VIN, PARTNUMBER
|
||||
SortDirection: c.sortDirection ?? c.SortDirection // ASC | DESC
|
||||
};
|
||||
}
|
||||
|
||||
/* ------------------------------- 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
|
||||
mapServiceVehicle,
|
||||
mapRepairOrderCreate,
|
||||
mapRepairOrderUpdate,
|
||||
mapAdvisorLookup,
|
||||
mapPartsLookup,
|
||||
mapCombinedSearch
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user