/** * @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&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 !== ""); /** * 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
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