const { buildRRRepairOrderPayload } = require("./rr-job-helpers"); const { buildClientAndOpts } = require("./rr-lookup"); const CreateRRLogEvent = require("./rr-logger-event"); const { extractRrResponsibilityCenters } = require("./rr-responsibility-centers"); const CdkCalculateAllocations = require("./rr-calculate-allocations").default; const { resolveRROpCodeFromBodyshop } = require("./rr-utils"); /** * Derive RR status information from response object. * @param rrRes * @returns {{status: *, statusCode: *|undefined, message}|null} */ const deriveRRStatus = (rrRes = {}) => { const data = rrRes.data || {}; const tx = rrRes.statusBlocks && rrRes.statusBlocks.transaction; const pick = (obj, ...keys) => { if (!obj) return undefined; for (const k of keys) { if (obj[k] != null) return obj[k]; } return undefined; }; let status = pick(data.roStatus, "status", "Status") || pick(data, "status", "Status") || pick(tx, "status", "Status"); let statusCode = pick(data.roStatus, "statusCode", "StatusCode") || pick(data, "statusCode", "StatusCode") || pick(tx, "statusCode", "StatusCode"); let message = pick(data.roStatus, "message", "Message") || data.errorMessage || pick(tx, "message", "Message"); // Last resort: parse from XML if present if ((!status || !message) && typeof rrRes.xml === "string") { const m = rrRes.xml.match(/<(?:GenTransStatus|TransStatus)\b([^>]*)>([^<]*)<\/(?:GenTransStatus|TransStatus)>/i); if (m) { const attrs = m[1] || ""; const body = (m[2] || "").trim(); const statusMatch = attrs.match(/\bStatus="([^"]*)"/i); const codeMatch = attrs.match(/\bStatusCode="([^"]*)"/i); if (!status && statusMatch) status = statusMatch[1]; if (!statusCode && codeMatch) statusCode = codeMatch[1]; if (!message && body) message = body; } } if (!status && !statusCode && !message) return null; return { status, statusCode: statusCode != null && statusCode !== "" ? statusCode : undefined, message: message || undefined }; }; /** * Early RO Creation: Create a minimal RR Repair Order with basic info (customer, advisor, mileage, story). * Used when creating RO from convert button or admin page before full job export. * @param args * @returns {Promise<{success: boolean, data: *, roStatus: {status: *, statusCode: *|undefined, message}, statusBlocks: *|{}, customerNo: string, svId: *, roNo: *, xml: *}>} */ const createMinimalRRRepairOrder = async (args) => { const { bodyshop, job, advisorNo, selectedCustomer, txEnvelope, socket, svId } = args || {}; if (!bodyshop) throw new Error("createMinimalRRRepairOrder: bodyshop is required"); if (!job) throw new Error("createMinimalRRRepairOrder: job is required"); if (advisorNo == null || String(advisorNo).trim() === "") { throw new Error("createMinimalRRRepairOrder: advisorNo is required for RR"); } // Resolve customer number (accept multiple shapes) const selected = selectedCustomer?.customerNo || selectedCustomer?.custNo; if (!selected) throw new Error("createMinimalRRRepairOrder: selectedCustomer.custNo/customerNo is required"); const { client, opts } = buildClientAndOpts(bodyshop); // For early RO creation we always "Insert" (create minimal RO) const finalOpts = { ...opts, envelope: { ...(opts?.envelope || {}), sender: { ...(opts?.envelope?.sender || {}), task: "BSMRO", referenceId: "Insert" } } }; const story = txEnvelope?.story ? String(txEnvelope.story).trim() : null; const makeOverride = txEnvelope?.makeOverride ? String(txEnvelope.makeOverride).trim() : null; // Build minimal RO payload - just header, no allocations/parts/labor const cleanVin = (job?.v_vin || "") .toString() .replace(/[^A-Za-z0-9]/g, "") .toUpperCase() .slice(0, 17) || undefined; // Resolve mileage - must be a positive number let mileageIn = txEnvelope?.kmin ?? job?.kmin ?? null; if (mileageIn != null) { mileageIn = parseInt(mileageIn, 10); if (isNaN(mileageIn) || mileageIn < 0) { mileageIn = null; } } CreateRRLogEvent(socket, "DEBUG", "Resolved mileage for early RO", { txEnvelopeKmin: txEnvelope?.kmin, jobKmin: job?.kmin, resolvedMileageIn: mileageIn }); const payload = { customerNo: String(selected), advisorNo: String(advisorNo), vin: cleanVin, departmentType: "B", outsdRoNo: job?.ro_number || job?.id || undefined, estimate: { parts: "0", labor: "0", total: "0.00" } }; // Only add mileageIn if we have a valid value if (mileageIn != null && mileageIn >= 0) { payload.mileageIn = mileageIn; } // Add optional fields if present if (story) { payload.roComment = story; } if (makeOverride) { payload.makeOverride = makeOverride; } CreateRRLogEvent(socket, "INFO", "Creating minimal RR Repair Order (early creation)", { payload }); const response = await client.createRepairOrder(payload, finalOpts); CreateRRLogEvent(socket, "INFO", "RR minimal Repair Order created", { payload, response }); const data = response?.data || null; const statusBlocks = response?.statusBlocks || {}; const roStatus = deriveRRStatus(response); const statusUpper = roStatus?.status ? String(roStatus.status).toUpperCase() : null; let success = false; if (statusUpper) { // Treat explicit FAILURE / ERROR as hard failures success = !["FAILURE", "ERROR"].includes(statusUpper); } else if (typeof response?.success === "boolean") { // Fallback to library boolean if no explicit status success = response.success; } else if (roStatus?.status) { success = String(roStatus.status).toUpperCase() === "SUCCESS"; } // Extract canonical roNo for later updates const roNo = data?.dmsRoNo ?? data?.outsdRoNo ?? roStatus?.dmsRoNo ?? null; return { success, data, roStatus, statusBlocks, customerNo: String(selected), svId, roNo, xml: response?.xml // expose XML for logging/diagnostics }; }; /** * Full Data Update: Update an existing RR Repair Order with complete job data (allocations, parts, labor). * Used during DMS post form when an early RO was already created. * @param args * @returns {Promise<{success: boolean, data: *, roStatus: {status: *, statusCode: *|undefined, message}, statusBlocks: *|{}, customerNo: string, svId: *, roNo: *, xml: *}>} */ const updateRRRepairOrderWithFullData = async (args) => { const { bodyshop, job, advisorNo, selectedCustomer, txEnvelope, socket, svId, roNo } = args || {}; if (!bodyshop) throw new Error("updateRRRepairOrderWithFullData: bodyshop is required"); if (!job) throw new Error("updateRRRepairOrderWithFullData: job is required"); if (advisorNo == null || String(advisorNo).trim() === "") { throw new Error("updateRRRepairOrderWithFullData: advisorNo is required for RR"); } if (!roNo) throw new Error("updateRRRepairOrderWithFullData: roNo is required for update"); // Resolve customer number (accept multiple shapes) const selected = selectedCustomer?.customerNo || selectedCustomer?.custNo; if (!selected) throw new Error("updateRRRepairOrderWithFullData: selectedCustomer.custNo/customerNo is required"); const { client, opts } = buildClientAndOpts(bodyshop); // For full data update after early RO, we still use "Insert" referenceId // because we're inserting the job operations for the first time const finalOpts = { ...opts, envelope: { ...(opts?.envelope || {}), sender: { ...(opts?.envelope?.sender || {}), task: "BSMRO", referenceId: "Insert" } } }; const story = txEnvelope?.story ? String(txEnvelope.story).trim() : null; const makeOverride = txEnvelope?.makeOverride ? String(txEnvelope.makeOverride).trim() : null; // Optional RR OpCode segments coming from the FE (RRPostForm) const opPrefix = txEnvelope?.opPrefix ?? txEnvelope?.op_prefix ?? null; const opBase = txEnvelope?.opBase ?? txEnvelope?.op_base ?? null; const opSuffix = txEnvelope?.opSuffix ?? txEnvelope?.op_suffix ?? null; // RR-only extras let rrCentersConfig = null; let allocations = null; let opCode = null; // 1) Responsibility center config (for visibility / debugging) try { rrCentersConfig = extractRrResponsibilityCenters(bodyshop); CreateRRLogEvent(socket, "SILLY", "RR responsibility centers resolved", { hasCenters: !!bodyshop.md_responsibility_centers, profitCenters: Object.keys(rrCentersConfig?.profitsByName || {}), costCenters: Object.keys(rrCentersConfig?.costsByName || {}), dmsCostDefaults: rrCentersConfig?.dmsCostDefaults || {}, dmsProfitDefaults: rrCentersConfig?.dmsProfitDefaults || {} }); } catch (e) { CreateRRLogEvent(socket, "ERROR", "Failed to resolve RR responsibility centers", { message: e?.message, stack: e?.stack }); } // 2) Allocations (sales + cost by center, with rr_* metadata already attached) try { const allocResult = await CdkCalculateAllocations(socket, job.id); // We only need the per-center job allocations for RO.GOG / ROLABOR. allocations = Array.isArray(allocResult?.jobAllocations) ? allocResult.jobAllocations : []; CreateRRLogEvent(socket, "INFO", "RR allocations resolved for update", { hasAllocations: allocations.length > 0, count: allocations.length, allocationsPreview: allocations.slice(0, 2).map((a) => ({ type: a?.type, code: a?.code, laborSale: a?.laborSale, laborCost: a?.laborCost, partsSale: a?.partsSale, partsCost: a?.partsCost })), taxAllocCount: Array.isArray(allocResult?.taxAllocArray) ? allocResult.taxAllocArray.length : 0, ttlAdjCount: Array.isArray(allocResult?.ttlAdjArray) ? allocResult.ttlAdjArray.length : 0, ttlTaxAdjCount: Array.isArray(allocResult?.ttlTaxAdjArray) ? allocResult.ttlTaxAdjArray.length : 0 }); } catch (e) { CreateRRLogEvent(socket, "ERROR", "Failed to calculate RR allocations", { message: e?.message, stack: e?.stack }); // Proceed with a header-only update if allocations fail. allocations = []; } const resolvedBaseOpCode = resolveRROpCodeFromBodyshop(bodyshop); let opCodeOverride = txEnvelope?.opCode || txEnvelope?.opcode || txEnvelope?.op_code || null; // If the FE only sends segments, combine them here. if (!opCodeOverride && (opPrefix || opBase || opSuffix)) { const combined = `${opPrefix || ""}${opBase || ""}${opSuffix || ""}`.trim(); if (combined) { opCodeOverride = combined; } } if (opCodeOverride || resolvedBaseOpCode) { opCode = String(opCodeOverride || resolvedBaseOpCode).trim() || null; } CreateRRLogEvent(socket, "SILLY", "RR OP config resolved", { opCode, baseFromConfig: resolvedBaseOpCode, opPrefix, opBase, opSuffix }); // Build full RO payload for update with allocations const payload = buildRRRepairOrderPayload({ bodyshop, job, selectedCustomer: { customerNo: String(selected), custNo: String(selected) }, advisorNo: String(advisorNo), story, makeOverride, allocations, opCode }); // Add roNo for linking to existing RO payload.roNo = String(roNo); payload.outsdRoNo = job?.ro_number || job?.id || undefined; // Keep rolabor - it's needed to register the job/OpCode accounts in Reynolds // Without this, Reynolds won't recognize the OpCode when we send rogg operations // The rolabor section tells Reynolds "these jobs exist" even with minimal data CreateRRLogEvent(socket, "INFO", "Sending full data for early RO (using create with roNo)", { roNo: String(roNo), hasRolabor: !!payload.rolabor, hasRogg: !!payload.rogg, payload }); // Use createRepairOrder (not update) with the roNo to link to the existing early RO // Reynolds will merge this with the existing RO header const response = await client.createRepairOrder(payload, finalOpts); CreateRRLogEvent(socket, "INFO", "RR Repair Order full data sent", { payload, response }); const data = response?.data || null; const statusBlocks = response?.statusBlocks || {}; const roStatus = deriveRRStatus(response); const statusUpper = roStatus?.status ? String(roStatus.status).toUpperCase() : null; let success = false; if (statusUpper) { success = !["FAILURE", "ERROR"].includes(statusUpper); } else if (typeof response?.success === "boolean") { success = response.success; } else if (roStatus?.status) { success = String(roStatus.status).toUpperCase() === "SUCCESS"; } return { success, data, roStatus, statusBlocks, customerNo: String(selected), svId, roNo: String(roNo), xml: response?.xml }; }; /** * LEGACY: Step 1: Export a job to RR as a new Repair Order with full data. * This is the original function - kept for backward compatibility if shops don't use early RO creation. * @param args * @returns {Promise<{success: boolean, data: *, roStatus: {status: *, statusCode: *|undefined, message}, statusBlocks: *|{}, customerNo: string, svId: *, roNo: *, xml: *}>} */ const exportJobToRR = async (args) => { const { bodyshop, job, advisorNo, selectedCustomer, txEnvelope, socket, svId } = args || {}; if (!bodyshop) throw new Error("exportJobToRR: bodyshop is required"); if (!job) throw new Error("exportJobToRR: job is required"); if (advisorNo == null || String(advisorNo).trim() === "") { throw new Error("exportJobToRR: advisorNo is required for RR"); } // Resolve customer number (accept multiple shapes) const selected = selectedCustomer?.customerNo || selectedCustomer?.custNo; if (!selected) throw new Error("exportJobToRR: selectedCustomer.custNo/customerNo is required"); const { client, opts } = buildClientAndOpts(bodyshop); // For step 1 we always "Insert" (create). Finalize handles the update. const finalOpts = { ...opts, envelope: { ...(opts?.envelope || {}), sender: { ...(opts?.envelope?.sender || {}), task: "BSMRO", referenceId: "Insert" } } }; const story = txEnvelope?.story ? String(txEnvelope.story).trim() : null; const makeOverride = txEnvelope?.makeOverride ? String(txEnvelope.makeOverride).trim() : null; // Optional RR OpCode segments coming from the FE (RRPostForm) const opPrefix = txEnvelope?.opPrefix ?? txEnvelope?.op_prefix ?? null; const opBase = txEnvelope?.opBase ?? txEnvelope?.op_base ?? null; const opSuffix = txEnvelope?.opSuffix ?? txEnvelope?.op_suffix ?? null; // RR-only extras let rrCentersConfig = null; let allocations = null; let opCode = null; // 1) Responsibility center config (for visibility / debugging) try { rrCentersConfig = extractRrResponsibilityCenters(bodyshop); CreateRRLogEvent(socket, "SILLY", "RR responsibility centers resolved", { hasCenters: !!bodyshop.md_responsibility_centers, profitCenters: Object.keys(rrCentersConfig?.profitsByName || {}), costCenters: Object.keys(rrCentersConfig?.costsByName || {}), dmsCostDefaults: rrCentersConfig?.dmsCostDefaults || {}, dmsProfitDefaults: rrCentersConfig?.dmsProfitDefaults || {} }); } catch (e) { CreateRRLogEvent(socket, "ERROR", "Failed to resolve RR responsibility centers", { message: e?.message, stack: e?.stack }); } // 2) Allocations (sales + cost by center, with rr_* metadata already attached) try { const allocResult = await CdkCalculateAllocations(socket, job.id); // We only need the per-center job allocations for RO.GOG / ROLABOR. allocations = Array.isArray(allocResult?.jobAllocations) ? allocResult.jobAllocations : []; CreateRRLogEvent(socket, "SILLY", "RR allocations resolved", { hasAllocations: allocations.length > 0, count: allocations.length, taxAllocCount: Array.isArray(allocResult?.taxAllocArray) ? allocResult.taxAllocArray.length : 0, ttlAdjCount: Array.isArray(allocResult?.ttlAdjArray) ? allocResult.ttlAdjArray.length : 0, ttlTaxAdjCount: Array.isArray(allocResult?.ttlTaxAdjArray) ? allocResult.ttlTaxAdjArray.length : 0 }); } catch (e) { CreateRRLogEvent(socket, "ERROR", "Failed to calculate RR allocations", { message: e?.message, stack: e?.stack }); // Proceed with a header-only RO if allocations fail. allocations = []; } const resolvedBaseOpCode = resolveRROpCodeFromBodyshop(bodyshop); let opCodeOverride = txEnvelope?.opCode || txEnvelope?.opcode || txEnvelope?.op_code || null; // If the FE only sends segments, combine them here. if (!opCodeOverride && (opPrefix || opBase || opSuffix)) { const combined = `${opPrefix || ""}${opBase || ""}${opSuffix || ""}`.trim(); if (combined) { opCodeOverride = combined; } } if (opCodeOverride || resolvedBaseOpCode) { opCode = String(opCodeOverride || resolvedBaseOpCode).trim() || null; } CreateRRLogEvent(socket, "SILLY", "RR OP config resolved", { opCode, baseFromConfig: resolvedBaseOpCode, opPrefix, opBase, opSuffix }); // Build RO payload for create. // // NOTE: // - bodyshop + allocations + opCode + taxCode are used only to build the // value object expected by reynolds-rome-client (header + rogg + rolabor + tax). const payload = buildRRRepairOrderPayload({ bodyshop, job, selectedCustomer: { customerNo: String(selected), custNo: String(selected) }, advisorNo: String(advisorNo), story, makeOverride, allocations, opCode }); const response = await client.createRepairOrder(payload, finalOpts); CreateRRLogEvent(socket, "INFO", "RR raw Repair Order created", { payload, response }); const data = response?.data || null; const statusBlocks = response?.statusBlocks || {}; const roStatus = deriveRRStatus(response); const statusUpper = roStatus?.status ? String(roStatus.status).toUpperCase() : null; let success = false; if (statusUpper) { // Treat explicit FAILURE / ERROR as hard failures success = !["FAILURE", "ERROR"].includes(statusUpper); } else if (typeof response?.success === "boolean") { // Fallback to library boolean if no explicit status success = response.success; } else if (roStatus?.status) { success = String(roStatus.status).toUpperCase() === "SUCCESS"; } // Extract canonical roNo you'll need for finalize step const roNo = data?.dmsRoNo ?? data?.outsdRoNo ?? roStatus?.dmsRoNo ?? null; return { success, data, roStatus, statusBlocks, customerNo: String(selected), svId, roNo, xml: response?.xml // expose XML for logging/diagnostics }; }; /** * Step 2: Finalize an existing RR Repair Order (previously created). * @param args * @returns {Promise<{success: boolean, data: *, roStatus: {status: *, statusCode: *|undefined, message}, statusBlocks: *|{}, xml: *}>} */ const finalizeRRRepairOrder = async (args) => { const { bodyshop, job, advisorNo, customerNo, roNo, vin, socket } = args || {}; if (!bodyshop) throw new Error("finalizeRRRepairOrder: bodyshop is required"); if (!job) throw new Error("finalizeRRRepairOrder: job is required"); if (!advisorNo) throw new Error("finalizeRRRepairOrder: advisorNo is required"); if (!customerNo) throw new Error("finalizeRRRepairOrder: customerNo is required"); // The external (Outsd) RO is our deterministic fallback and correlation id. const externalRo = job?.ro_number ?? job?.id; if (externalRo == null) { throw new Error("finalizeRRRepairOrder: outsdRoNo (job.ro_number/id) is required"); } // Prefer DMS RO for update; fall back to external when DMS RO isn't known const roNoToSend = roNo ? String(roNo) : String(externalRo); const { client, opts } = buildClientAndOpts(bodyshop); const finalOpts = { ...opts, envelope: { ...(opts?.envelope || {}), sender: { ...(opts?.envelope?.sender || {}), task: "BSMRO", referenceId: "Update" } } }; const cleanVin = (job?.v_vin || vin || "") .toString() .replace(/[^A-Za-z0-9]/g, "") .toUpperCase() .slice(0, 17) || undefined; // IMPORTANT: include "roNo" on updates (RR requires it). Also send outsdRoNo for correlation. const payload = { roNo: roNoToSend, // ✅ REQUIRED BY RR on update outsdRoNo: String(externalRo), finalUpdate: "Y", departmentType: "B", customerNo: String(customerNo), advisorNo: String(advisorNo), vin: cleanVin, mileageOut: job?.kmout, estimate: { estimateType: "Final" } }; CreateRRLogEvent(socket, "INFO", "Finalizing RR Repair Order", { roNo: roNoToSend, outsdRoNo: String(externalRo), customerNo: String(customerNo), advisorNo: String(advisorNo) }); const rrRes = await client.updateRepairOrder(payload, finalOpts); CreateRRLogEvent(socket, "SILLY", "RR Repair Order finalized", { payload, response: rrRes }); const data = rrRes?.data || null; const statusBlocks = rrRes?.statusBlocks || {}; const roStatus = deriveRRStatus(rrRes); const statusUpper = roStatus?.status ? String(roStatus.status).toUpperCase() : null; let success = false; if (statusUpper) { success = !["FAILURE", "ERROR"].includes(statusUpper); } else if (typeof rrRes?.success === "boolean") { success = rrRes.success; } else if (roStatus?.status) { success = String(roStatus.status).toUpperCase() === "SUCCESS"; } return { success, data, roStatus, statusBlocks, xml: rrRes?.xml }; }; module.exports = { exportJobToRR, createMinimalRRRepairOrder, updateRRRepairOrderWithFullData, finalizeRRRepairOrder, deriveRRStatus };