// 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: // - UpdateCustomer.xml: // - InsertServiceVehicle.xml: // - CreateRepairOrder.xml: // - UpdateRepairOrder.xml: // // 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 };