diff --git a/server/rr/rr-helpers.js b/server/rr/rr-helpers.js index 2b16d0f01..32a1d5700 100644 --- a/server/rr/rr-helpers.js +++ b/server/rr/rr-helpers.js @@ -328,7 +328,6 @@ const RRActions = { url: isProduction ? "https://rr.example.com/customer/v1/search" : "https://rr-uat.example.com/customer/v1/search", type: "get" }, - // Combined search (customer + vehicle) CombinedSearch: { apiName: "RR Combined Search (Customer + Vehicle)", @@ -337,14 +336,12 @@ const RRActions = { : "https://rr-uat.example.com/search/v1/customer-vehicle", type: "get" }, - // Advisors GetAdvisors: { apiName: "RR Get Advisors", url: isProduction ? "https://rr.example.com/advisors/v1" : "https://rr-uat.example.com/advisors/v1", type: "get" }, - // Parts GetParts: { apiName: "RR Get Parts", diff --git a/server/rr/rr-mappers.js b/server/rr/rr-mappers.js new file mode 100644 index 000000000..aa66da21f --- /dev/null +++ b/server/rr/rr-mappers.js @@ -0,0 +1,249 @@ +// server/rr/rr-mappers.js +// Centralized mapping & normalization for Reynolds & Reynolds (RR) +// +// NOTE: This is scaffolding intended to be completed against the RR XML/JSON +// schemas in the Rome RR specs you dropped (Customer Insert/Update, Repair Order, +// Service Vehicle, etc.). Field names below are placeholders where noted. +// Fill the TODOs with the exact RR element/attribute names. +// +// Usage expectation from calling code (example you gave): +// const { mapCustomerInsert, mapCustomerUpdate } = require("./rr-mappers"); +// const body = mapCustomerInsert(JobData); +// const body = mapCustomerUpdate(existingCustomer, patch); + +const _ = require("lodash"); +const InstanceMgr = require("../utils/instanceMgr").default; + +// Keep this consistent with other providers +const replaceSpecialRegex = /[^a-zA-Z0-9 .,\n #]+/g; + +function sanitize(value) { + if (value === null || value === undefined) return value; + return String(value).replace(replaceSpecialRegex, "").trim(); +} + +function normalizePostal(raw) { + // Match Fortellis/CDK behavior for CA vs US formatting + // (Use InstanceMgr profile detection already present in your codebase) + return InstanceMgr({ + imex: raw && String(raw).toUpperCase().replace(/\W/g, "").replace(/(...)/, "$1 "), + rome: raw + }); +} + +function asStringOrNull(value) { + const s = sanitize(value); + return s && s.length > 0 ? s : null; +} + +function mapPhones({ ph1, ph2, mobile }) { + // TODO: Update to RR’s final phone structure and codes/types when wiring + const out = []; + if (ph1) out.push({ number: sanitize(ph1), type: "HOME" }); + if (ph2) out.push({ number: sanitize(ph2), type: "WORK" }); + if (mobile) out.push({ number: sanitize(mobile), type: "MOBILE" }); + return out; +} + +function mapEmails({ email }) { + // RR often supports multiple emails; start with one. + // TODO: Update per RR schema (email flags, preferred, etc.) + if (!email) return []; + return [{ address: sanitize(email), type: "PERSONAL" }]; +} + +function mapPostalAddressFromJob(job) { + // Rome job-level owner fields (aligning to prior provider scaffolds) + return { + addressLine1: asStringOrNull(job.ownr_addr1), + addressLine2: asStringOrNull(job.ownr_addr2), + city: asStringOrNull(job.ownr_city), + state: asStringOrNull(job.ownr_st || job.ownr_state), + postalCode: normalizePostal(job.ownr_zip), + country: asStringOrNull(job.ownr_ctry) || "USA", // default, adjust as needed + province: asStringOrNull(job.ownr_st) // keep both state/province fields for CA cases + }; +} + +function mapPhonesFromJob(job) { + return mapPhones({ + ph1: job.ownr_ph1, + ph2: job.ownr_ph2, + mobile: job.ownr_mobile + }); +} + +function mapEmailsFromJob(job) { + return mapEmails({ email: job.ownr_ea }); +} + +/** + * Customer Insert + * Matches your call site: + * const body = mapCustomerInsert(JobData) + * + * Return shape intentionally mirrors Fortellis scaffolding so the same + * MakeRRCall pipeline can be reused. Replace placeholders with the RR spec’s + * request envelope/element names (e.g., CustomerInsertRq, CustomerRq, etc.). + */ +function mapCustomerInsert(job) { + const isCompany = Boolean(job.ownr_co_nm && job.ownr_co_nm.trim() !== ""); + + // Skeleton payload — replace keys under CustomerInsertRq with the actual RR names + return { + CustomerInsertRq: { + // TODO: Confirm RR element/attribute names from spec PDFs + customerType: isCompany ? "ORGANIZATION" : "INDIVIDUAL", + customerName: { + companyName: asStringOrNull(job.ownr_co_nm)?.toUpperCase() || null, + firstName: isCompany ? null : asStringOrNull(job.ownr_fn)?.toUpperCase(), + lastName: isCompany ? null : asStringOrNull(job.ownr_ln)?.toUpperCase() + }, + postalAddress: mapPostalAddressFromJob(job), + contactMethods: { + phones: mapPhonesFromJob(job), + emailAddresses: mapEmailsFromJob(job) + } + // Optional / placeholders for future fields in RR spec + // taxCode: null, + // groupCode: null, + // dealerFields: [] + } + }; +} + +/** + * Customer Update + * Matches your call site: + * const body = mapCustomerUpdate(existingCustomer, patch) + * + * - existingCustomer: prior RR customer payload/shape (from RR Read/Query) + * - patch: minimal delta from UI/Job selections to overlay onto the RR model + * + * We return a merged/normalized payload for RR Update. + */ +function mapCustomerUpdate(existingCustomer, patch = {}) { + // NOTE: + // 1) We assume existingCustomer already resembles RR’s stored shape. + // 2) We overlay patch fields into that shape, then project to the + // RR Update request envelope. + // 3) Replace inner keys with exact RR Update schema element names. + + const merged = _.merge({}, existingCustomer || {}, patch || {}); + const id = merged?.customerId || merged?.id || merged?.CustomerId || merged?.customer?.id || null; + + // Derive a normalized name object from merged data (handles org/person) + const isCompany = Boolean(merged?.customerName?.companyName) || Boolean(merged?.companyName) || false; + + const normalizedName = { + companyName: asStringOrNull(merged?.customerName?.companyName) || asStringOrNull(merged?.companyName), + firstName: isCompany ? null : asStringOrNull(merged?.customerName?.firstName) || asStringOrNull(merged?.firstName), + lastName: isCompany ? null : asStringOrNull(merged?.customerName?.lastName) || asStringOrNull(merged?.lastName) + }; + + const normalizedAddress = { + addressLine1: asStringOrNull(merged?.postalAddress?.addressLine1) || asStringOrNull(merged?.addressLine1), + addressLine2: asStringOrNull(merged?.postalAddress?.addressLine2) || asStringOrNull(merged?.addressLine2), + city: asStringOrNull(merged?.postalAddress?.city) || asStringOrNull(merged?.city), + state: + asStringOrNull(merged?.postalAddress?.state) || + asStringOrNull(merged?.state) || + asStringOrNull(merged?.stateOrProvince) || + asStringOrNull(merged?.province), + postalCode: normalizePostal(merged?.postalAddress?.postalCode || merged?.postalCode), + country: asStringOrNull(merged?.postalAddress?.country) || asStringOrNull(merged?.country) || "USA", + province: asStringOrNull(merged?.postalAddress?.province) || asStringOrNull(merged?.province) + }; + + // Phones + const normalizedPhones = merged?.contactMethods?.phones || merged?.phones || []; + + // Emails + const normalizedEmails = merged?.contactMethods?.emailAddresses || merged?.emailAddresses || []; + + return { + CustomerUpdateRq: { + // TODO: Confirm exact RR element/attribute names for update + customerId: id, + customerType: normalizedName.companyName ? "ORGANIZATION" : "INDIVIDUAL", + customerName: normalizedName, + postalAddress: normalizedAddress, + contactMethods: { + phones: normalizedPhones, + emailAddresses: normalizedEmails + } + // Optional change tracking fields, timestamps, etc., per RR spec can go here + } + }; +} + +/* ===== Additional mappers (scaffolding for upcoming work) ===== */ + +function mapVehicleInsertFromJob(job, txEnvelope = {}) { + // TODO: Replace with RR Service Vehicle Insert schema + return { + ServiceVehicleInsertRq: { + vin: asStringOrNull(job.v_vin), + year: job.v_model_yr || null, + make: txEnvelope.dms_make || asStringOrNull(job.v_make), + model: txEnvelope.dms_model || asStringOrNull(job.v_model), + odometer: txEnvelope.kmout || null, + licensePlate: job.plate_no && /\w/.test(job.plate_no) ? asStringOrNull(job.plate_no).toUpperCase() : null + } + }; +} + +function mapRepairOrderAddFromJob(job) { + // TODO: Replace with RR RepairOrder Add schema (headers, lines, taxes) + return { + RepairOrderAddRq: { + customerId: job.customer?.id || null, + vehicleId: job.vehicle?.id || null, + referenceNumber: asStringOrNull(job.ro_number), + openedAt: job.actual_in || null, + closedAt: job.invoice_date || null + // lines: job.joblines?.map(mapJobLineToRRLine), + // taxes: mapTaxes(job), + // payments: mapPayments(job) + } + }; +} + +function mapRepairOrderChangeFromJob(job) { + // TODO: Replace with RR RepairOrder Update schema + return { + RepairOrderChgRq: { + repairOrderId: job.id, + referenceNumber: asStringOrNull(job.ro_number) + // delta lines, amounts, status, etc. + } + }; +} + +/* Example line mapper (placeholder) */ +function mapJobLineToRRLine(line) { + return { + // TODO: set RR fields + seq: line.sequence || null, + opCode: line.opCode || null, + description: asStringOrNull(line.description), + qty: line.part_qty || null, + price: line.price || null + }; +} + +module.exports = { + // Required by your current calling code: + mapCustomerInsert, + mapCustomerUpdate, + + // Extra scaffolds we’ll likely use right after: + mapVehicleInsertFromJob, + mapRepairOrderAddFromJob, + mapRepairOrderChangeFromJob, + mapJobLineToRRLine, + + // low-level utils (export if you want to reuse in tests) + _sanitize: sanitize, + _normalizePostal: normalizePostal +};