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

429 lines
13 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.
/**
* @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");
const { normalizeRRDealerFields } = require("./rr-constants");
/**
* Utility: formats date/time to R&Rs 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 !== "");
/**
* Pull canonical Dealer/Store/Branch fields from cfg (tolerate snake_case during migration).
* Enforces DB-provided values upstream (no env fallback here).
*/
function getDSB(cfg) {
const { dealerNumber, storeNumber, branchNumber } = normalizeRRDealerFields(cfg || {});
return { dealerNumber, storeNumber, branchNumber };
}
/**
* Normalize an address-like object to the template's <Address> block.
*/
function mapAddress(addr) {
if (!addr) return undefined;
const out = {
Line1: addr.line1,
Line2: addr.line2,
City: addr.city,
State: addr.state,
PostalCode: addr.postal_code || addr.postalCode,
Country: addr.country
};
return hasAny(out) ? out : undefined;
}
//
// ===================== CUSTOMER =====================
//
/**
* Map internal customer record to Rome CustomerInsertRq.
*/
function mapCustomerInsert(src) {
const name = src.company_name?.trim() || [src.first_name, src.last_name].filter(Boolean).join(" ").trim();
return {
CustomerNumber: src.external_id, // optional
CustomerType: src.type === "BUSINESS" ? "BUSINESS" : "RETAIL",
CustomerName: name,
DisplayName: src.display_name || name,
Language: src.language || "EN",
TaxExempt: src.tax_exempt ? "Y" : "N",
Active: src.active ? "Y" : "N",
Addresses: (src.addresses || []).map((a) => ({
Type: a.type || "P",
Line1: a.line1,
Line2: a.line2,
City: a.city,
State: a.state,
PostalCode: a.postal_code,
Country: a.country
})),
Phones: (src.phones || []).map((p) => ({
Type: p.type || "H",
Number: p.number,
Extension: p.extension,
Preferred: p.preferred ? "Y" : "N"
})),
Emails: (src.emails || []).map((e) => ({
Type: e.type || "W",
Address: e.address,
Preferred: e.preferred ? "Y" : "N"
}))
};
}
/**
* Map internal customer record to Rome CustomerUpdateRq.
*/
function mapCustomerUpdate(customer, bodyshopConfig) {
if (!customer) return {};
return {
...mapCustomerInsert(customer, bodyshopConfig),
RequestId: `CUST-UPDATE-${customer.id}`
};
}
//
// ===================== VEHICLE =====================
//
/**
* Map vehicle to Rome ServiceVehicleAddRq.
*/
function mapServiceVehicle(vehicle, ownerCustomer, bodyshopConfig) {
if (!vehicle) return {};
const { dealerNumber, storeNumber, branchNumber } = getDSB(bodyshopConfig);
return {
DealerCode: bodyshopConfig?.dealer_code || "ROME",
DealerNumber: dealerNumber,
StoreNumber: storeNumber,
BranchNumber: branchNumber,
RequestId: `VEH-${vehicle.id}`,
CustomerId: ownerCustomer?.external_id,
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),
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
};
}
//
// ===================== REPAIR ORDER =====================
//
/**
* Map internal job to Rome RepairOrderInsertRq.
* NOTE: The CreateRepairOrder.xml template expects *flat* fields for Customer and ServiceVehicle
* (no {{#Customer}} or {{#ServiceVehicle}} sections). Therefore, we flatten those values here.
*/
function mapRepairOrderCreate(job, bodyshopConfig) {
if (!job) return {};
const { dealerNumber, storeNumber, branchNumber } = getDSB(bodyshopConfig);
const cust = job.customer || {};
const veh = job.vehicle || {};
// Prefer a concrete address on the customer, fall back to job-level
const customerAddress = cust.address || job.customer_address || job.address || undefined;
return {
// Routing/meta we keep available for logging or other templates
DealerCode: bodyshopConfig?.dealer_code || "ROME",
DealerNumber: dealerNumber,
StoreNumber: storeNumber,
BranchNumber: branchNumber,
RequestId: `RO-${job.id}`,
Environment: process.env.NODE_ENV,
// Header fields
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: toBoolStr(!!job.drp_flag) || "false",
// Customer block is FLAT (template does not use {{#Customer}} section)
CustomerId: cust.external_id,
CustomerName: cust.full_name || [cust.first_name, cust.last_name].filter(Boolean).join(" ").trim() || undefined,
PhoneNumber: cust.phone,
EmailAddress: cust.email,
Address: mapAddress(customerAddress),
// ServiceVehicle block is FLAT (template does not use {{#ServiceVehicle}} section)
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,
// Lines
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)
}))
}
: undefined
})),
// Totals
Totals: hasAny({
Currency: job.currency || "CAD",
LaborTotal: job.totals?.labor,
PartsTotal: job.totals?.parts,
MiscTotal: job.totals?.misc,
DiscountTotal: job.totals?.discount,
TaxTotal: job.totals?.tax,
GrandTotal: job.totals?.grand
})
? {
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)
}
: undefined,
// Payments
Payments: job.payments?.length
? {
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
}))
}
: undefined,
// Insurance
Insurance: job.insurance
? {
CompanyName: job.insurance.company,
ClaimNumber: job.insurance.claim_number,
AdjusterName: job.insurance.adjuster_name,
AdjusterPhone: job.insurance.adjuster_phone
}
: undefined,
// Notes
Notes: job.notes?.length ? { Items: job.notes.map((n) => n.text || n) } : undefined
};
}
/**
* Map for repair order updates.
*/
function mapRepairOrderUpdate(job, bodyshopConfig) {
return {
...mapRepairOrderCreate(job, bodyshopConfig),
RequestId: `RO-UPDATE-${job.id}`
};
}
//
// ===================== LOOKUPS =====================
//
function mapAdvisorLookup(criteria, bodyshopConfig) {
const { dealerNumber, storeNumber, branchNumber } = getDSB(bodyshopConfig);
return {
DealerCode: bodyshopConfig?.dealer_code || "ROME",
DealerNumber: dealerNumber,
StoreNumber: storeNumber,
BranchNumber: branchNumber,
RequestId: `LOOKUP-ADVISOR-${Date.now()}`,
SearchCriteria: {
Department: criteria.department || "Body Shop",
Status: criteria.status || "ACTIVE"
}
};
}
function mapPartsLookup(criteria, bodyshopConfig) {
const { dealerNumber, storeNumber, branchNumber } = getDSB(bodyshopConfig);
return {
DealerCode: bodyshopConfig?.dealer_code || "ROME",
DealerNumber: dealerNumber,
StoreNumber: storeNumber,
BranchNumber: branchNumber,
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
}
};
}
function mapCombinedSearch(criteria = {}, bodyshopConfig) {
const { dealerNumber, storeNumber, branchNumber } = getDSB(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 {
DealerCode: bodyshopConfig?.dealer_code || "ROME",
DealerName: bodyshopConfig?.dealer_name,
DealerNumber: dealerNumber,
StoreNumber: storeNumber,
BranchNumber: branchNumber,
RequestId: c.requestId || `COMBINED-${Date.now()}`,
Environment: process.env.NODE_ENV,
// 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,
// 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),
// 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
};
}
module.exports = {
mapCustomerInsert,
mapCustomerUpdate,
mapServiceVehicle,
mapRepairOrderCreate,
mapRepairOrderUpdate,
mapAdvisorLookup,
mapPartsLookup,
mapCombinedSearch
};