feature/IO-3357-Reynolds-and-Reynolds-DMS-API-Integration - Checkpoint

This commit is contained in:
Dave
2025-10-08 13:57:34 -04:00
parent 2ffc4b81f4
commit de02b34a63
28 changed files with 2550 additions and 2443 deletions

View File

@@ -1,133 +1,158 @@
/**
* @file rr-job-export.js
* @description Orchestrates the full Reynolds & Reynolds DMS export flow.
* Creates/updates customers, vehicles, and repair orders according to Rome specs.
* @description End-to-end export of a Hasura "job" to Reynolds & Reynolds (Rome).
* Orchestrates Customer (insert/update), optional Vehicle insert, and RO (create/update),
* mirroring behavior of PBS/Fortellis exporters for parity.
*/
const { RrCustomerInsert, RrCustomerUpdate } = require("./rr-customer");
const { CreateRepairOrder, UpdateRepairOrder } = require("./rr-repair-orders");
const { MakeRRCall, RRActions, getDealerConfig } = require("./rr-helpers");
const { assertRrOkXml, extractRrResponseData } = require("./rr-error");
const RRLogger = require("./rr-logger");
const { mapServiceVehicleInsert } = require("./rr-mappers");
const { RrApiError } = require("./rr-error");
const customerApi = require("./rr-customer");
const roApi = require("./rr-repair-orders");
const { MakeRRCall } = require("./rr-helpers"); // for optional vehicle insert
const { mapServiceVehicle } = require("./rr-mappers");
/**
* Inserts a service vehicle record for the repair order.
* Follows the "Rome Insert Service Vehicle Interface Specification" via SOAP/XML.
* Decide if we should CREATE or UPDATE an entity in Rome based on external IDs
*/
async function RrServiceVehicleInsert({ socket, redisHelpers, JobData, dealerConfig }) {
try {
RRLogger(socket, "info", "RR Insert Service Vehicle started", { jobid: JobData?.id });
function decideAction({ customer, vehicle, job }) {
const hasCustId = !!(customer?.external_id || customer?.rr_customer_id);
const hasVehId = !!(vehicle?.external_id || vehicle?.rr_vehicle_id);
const hasRoId = !!(job?.external_id || job?.rr_repair_order_id || job?.dms_repair_order_id);
// Build Mustache variables for server/rr/xml-templates/InsertServiceVehicle.xml
const variables = mapServiceVehicleInsert(JobData, dealerConfig);
const xml = await MakeRRCall({
action: RRActions.InsertServiceVehicle,
body: { template: "InsertServiceVehicle", data: variables },
redisHelpers,
socket,
jobid: JobData.id,
dealerConfig
});
const ok = assertRrOkXml(xml, { apiName: "RR Insert Service Vehicle" });
const normalized = extractRrResponseData(ok, { action: "InsertServiceVehicle" });
RRLogger(socket, "debug", "RR Insert Service Vehicle success", {
jobid: JobData?.id,
vehicleId: normalized?.VehicleId || normalized?.vehicleId
});
return normalized;
} catch (error) {
RRLogger(socket, "error", `RR Insert Service Vehicle failed: ${error.message}`, { jobid: JobData?.id });
throw error;
}
return {
customerAction: hasCustId ? "update" : "insert",
vehicleAction: hasVehId ? "skip" : "insert", // Rome often generates vehicle IDs on RO create; we insert only if we have enough data and no id
repairOrderAction: hasRoId ? "update" : "create"
};
}
/**
* Full DMS export sequence for Reynolds & Reynolds.
*
* 1. Ensure customer exists (insert or update)
* 2. Ensure vehicle exists/linked
* 3. Create or update repair order
* Normalize a stage result to a consistent structure.
*/
async function ExportJobToRR({ socket, redisHelpers, JobData }) {
const jobid = JobData?.id;
const bodyshopId = socket?.bodyshopId || JobData?.bodyshopid;
function stageOk(name, extra = {}) {
return { stage: name, success: true, ...extra };
}
function stageFail(name, error) {
return { stage: name, success: false, error: error?.message || String(error) };
}
RRLogger(socket, "info", "Starting RR job export", { jobid, bodyshopId });
/**
* Export a job into Rome (Customer → Vehicle → RepairOrder).
* @param {Socket} socket - logging context (may be null in batch)
* @param {Object} job - Hasura job object (must include customer, vehicle, lines, totals)
* @param {Object} bodyshopConfig - per-shop RR config (dealer/store/branch + creds)
* @param {Object} options - { insertVehicleIfMissing: boolean }
* @returns {Promise<Object>} normalized result
*/
async function exportJobToRome(socket, job, bodyshopConfig, options = {}) {
const { customer = {}, vehicle = {} } = job || {};
const { insertVehicleIfMissing = true } = options;
const actions = decideAction({ customer, vehicle, job });
const stages = [];
const summary = {
dms: "Rome",
jobid: job?.id,
ro_action: actions.repairOrderAction,
customer_action: actions.customerAction,
vehicle_action: insertVehicleIfMissing ? actions.vehicleAction : "skip"
};
RRLogger(socket, "info", `RR Export start`, summary);
// ---- 1) Customer ----
try {
// Pull dealer-level overrides once (DB), env/platform secrets come from rr-helpers internally.
const dealerConfig = bodyshopId ? await getDealerConfig(bodyshopId) : {};
//
// STEP 1: CUSTOMER
//
RRLogger(socket, "info", "RR Step 1: Customer check/insert", { jobid });
let rrCustomerResult;
if (JobData?.rr_customer_id) {
rrCustomerResult = await RrCustomerUpdate({
socket,
redisHelpers,
JobData,
existingCustomer: { CustomerId: JobData.rr_customer_id },
patch: JobData.customer_patch
});
if (actions.customerAction === "insert") {
const res = await customerApi.insertCustomer(socket, customer, bodyshopConfig);
stages.push(stageOk("customer.insert"));
summary.customer_xml = res.xml;
} else {
rrCustomerResult = await RrCustomerInsert({ socket, redisHelpers, JobData });
const res = await customerApi.updateCustomer(socket, customer, bodyshopConfig);
stages.push(stageOk("customer.update"));
summary.customer_xml = res.xml;
}
//
// STEP 2: VEHICLE
//
RRLogger(socket, "info", "RR Step 2: Vehicle insert", { jobid });
const rrVehicleResult = await RrServiceVehicleInsert({ socket, redisHelpers, JobData, dealerConfig });
//
// STEP 3: REPAIR ORDER
//
RRLogger(socket, "info", "RR Step 3: Repair Order create/update", { jobid });
let rrRepairOrderResult;
if (JobData?.rr_ro_id) {
rrRepairOrderResult = await UpdateRepairOrder({ socket, redisHelpers, JobData });
} else {
rrRepairOrderResult = await CreateRepairOrder({ socket, redisHelpers, JobData });
}
//
// FINALIZE
//
RRLogger(socket, "info", "RR Export completed successfully", {
jobid,
rr_customer_id: rrCustomerResult?.CustomerId || rrCustomerResult?.customerId,
rr_vehicle_id: rrVehicleResult?.VehicleId || rrVehicleResult?.vehicleId,
rr_ro_id: rrRepairOrderResult?.RepairOrderId || rrRepairOrderResult?.repairOrderId
});
return {
success: true,
data: {
customer: rrCustomerResult,
vehicle: rrVehicleResult,
repairOrder: rrRepairOrderResult
}
};
} catch (error) {
RRLogger(socket, "error", `RR job export failed: ${error.message}`, { jobid });
return {
success: false,
error: error.message,
stack: error.stack
};
stages.push(stageFail(`customer.${actions.customerAction}`, error));
RRLogger(socket, "error", `RR customer ${actions.customerAction} failed`, {
jobid: job?.id,
error: error.message
});
throw new RrApiError(`Customer ${actions.customerAction} failed: ${error.message}`, "RR_CUSTOMER_ERROR");
}
// ---- 2) Vehicle (optional explicit insert) ----
if (insertVehicleIfMissing && actions.vehicleAction === "insert") {
try {
// Only insert when we have at least VIN or plate+state/year
const hasMinimumIdentity = !!(vehicle?.vin || (vehicle?.license_plate && vehicle?.license_state));
if (hasMinimumIdentity) {
const data = mapServiceVehicle(vehicle, customer, bodyshopConfig);
const xml = await MakeRRCall({
action: "InsertServiceVehicle",
body: { template: "InsertServiceVehicle", data },
socket,
dealerConfig: bodyshopConfig,
jobid: job?.id
});
stages.push(stageOk("vehicle.insert"));
summary.vehicle_xml = xml;
} else {
stages.push(stageOk("vehicle.skip", { reason: "insufficient_identity" }));
}
} catch (error) {
stages.push(stageFail("vehicle.insert", error));
RRLogger(socket, "error", `RR vehicle insert failed`, {
jobid: job?.id,
error: error.message
});
// Non-fatal for the overall export — many flows let RO creation create/associate vehicle.
}
} else {
stages.push(stageOk("vehicle.skip", { reason: actions.vehicleAction === "skip" ? "already_has_id" : "disabled" }));
}
// ---- 3) Repair Order ----
try {
let res;
if (actions.repairOrderAction === "create") {
res = await roApi.createRepairOrder(socket, job, bodyshopConfig);
stages.push(stageOk("ro.create"));
} else {
res = await roApi.updateRepairOrder(socket, job, bodyshopConfig);
stages.push(stageOk("ro.update"));
}
summary.ro_xml = res.xml;
} catch (error) {
stages.push(stageFail(`ro.${actions.repairOrderAction}`, error));
RRLogger(socket, "error", `RR RO ${actions.repairOrderAction} failed`, {
jobid: job?.id,
error: error.message
});
throw new RrApiError(`RepairOrder ${actions.repairOrderAction} failed: ${error.message}`, "RR_RO_ERROR");
}
const result = {
success: true,
...summary,
stages
};
RRLogger(socket, "info", `RR Export finished`, {
jobid: job?.id,
result: {
success: result.success,
customer_action: summary.customer_action,
vehicle_action: summary.vehicle_action,
ro_action: summary.ro_action
}
});
return result;
}
module.exports = {
ExportJobToRR,
RrServiceVehicleInsert
exportJobToRome
};