// ----------------------------------------------------------------------------- // Reynolds & Reynolds (RR) Job Export flow (scaffold). // // Parity with Fortellis/CDK export shape so the UI + socket flows remain // consistent: // // - RRJobExport: initial VIN/customer discovery & prompt for customer select // - RRSelectedCustomer: create/update customer, insert/read vehicle, // post WIP batch, post history, mark success/failure, notify client // // What’s still missing (fill in from Rome/RR PDFs you provided): // - Exact request/response envelopes for each RR operation // (Customer Insert/Update, Vehicle Insert/Read, WIP APIs, Service History). // - Final success/error conditions for assertRrOk (we currently use heuristics). // - Precise field mappings inside CreateCustomer, InsertVehicle, // StartWip/TransBatchWip/PostBatchWip, InsertServiceVehicleHistory. // ----------------------------------------------------------------------------- const { GraphQLClient } = require("graphql-request"); const moment = require("moment-timezone"); const CalculateAllocations = require("../cdk/cdk-calculate-allocations").default; // reuse allocations const CreateRRLogEvent = require("./rr-logger"); const queries = require("../graphql-client/queries"); const { MakeRRCall, RRActions, getTransactionType, defaultRRTTL, RRCacheEnums } = require("./rr-helpers"); // ----------------------------------------------------------------------------- // Public entry points (wired in redisSocketEvents.js) // ----------------------------------------------------------------------------- /** * Seed export: cache txEnvelope + JobData, discover VIN->VehicleId + owner, * search by customer name, and prompt client to select/create a customer. */ async function RRJobExport({ socket, redisHelpers, txEnvelope, jobid }) { const { setSessionTransactionData } = redisHelpers; try { CreateRRLogEvent(socket, "DEBUG", `[RR] Received Job export request`, { jobid }); // cache txEnvelope for this job session await setSessionTransactionData( socket.id, getTransactionType(jobid), RRCacheEnums.txEnvelope, txEnvelope, defaultRRTTL ); const JobData = await QueryJobData({ socket, jobid }); await setSessionTransactionData(socket.id, getTransactionType(jobid), RRCacheEnums.JobData, JobData, defaultRRTTL); CreateRRLogEvent(socket, "DEBUG", `[RR] Get Vehicle Id via VIN`, { vin: JobData.v_vin }); const DMSVid = await GetVehicleId({ socket, redisHelpers, JobData }); await setSessionTransactionData(socket.id, getTransactionType(jobid), RRCacheEnums.DMSVid, DMSVid, defaultRRTTL); let DMSVehCustomer; if (!DMSVid?.newId) { // existing vehicle, load details const DMSVeh = await ReadVehicleById({ socket, redisHelpers, JobData, vehicleId: DMSVid.vehiclesVehId }); await setSessionTransactionData(socket.id, getTransactionType(jobid), RRCacheEnums.DMSVeh, DMSVeh, defaultRRTTL); // Try to read the CURRENT owner (shape TBD per RR) const owner = DMSVeh?.owners && DMSVeh.owners.find((o) => o.id?.assigningPartyId === "CURRENT"); if (owner?.id?.value) { DMSVehCustomer = await ReadCustomerById({ socket, redisHelpers, JobData, customerId: owner.id.value }); await setSessionTransactionData( socket.id, getTransactionType(jobid), RRCacheEnums.DMSVehCustomer, DMSVehCustomer, defaultRRTTL ); } } // Search customers by job owner name (param names TBD per RR) const DMSCustList = await SearchCustomerByName({ socket, redisHelpers, JobData }); await setSessionTransactionData( socket.id, getTransactionType(jobid), RRCacheEnums.DMSCustList, DMSCustList, defaultRRTTL ); // Emit choices: (VIN owner first if present) + search results socket.emit("rr-select-customer", [ ...(DMSVehCustomer ? [{ ...DMSVehCustomer, vinOwner: true }] : []), ...(Array.isArray(DMSCustList) ? DMSCustList : []) ]); } catch (error) { CreateRRLogEvent(socket, "ERROR", `[RR] RRJobExport failed: ${error.message}`, { stack: error.stack }); } } /** * After client selects a customer (or requests create): * - Read or create the customer * - Insert vehicle if needed (or read existing) * - StartWip -> TransBatchWip -> PostBatchWip -> Mark exported * - Optionally insert service history */ async function RRSelectedCustomer({ socket, redisHelpers, selectedCustomerId, jobid }) { const { setSessionTransactionData, getSessionTransactionData } = redisHelpers; try { await setSessionTransactionData( socket.id, getTransactionType(jobid), RRCacheEnums.selectedCustomerId, selectedCustomerId, defaultRRTTL ); const JobData = await getSessionTransactionData(socket.id, getTransactionType(jobid), RRCacheEnums.JobData); const txEnvelope = await getSessionTransactionData(socket.id, getTransactionType(jobid), RRCacheEnums.txEnvelope); const DMSVid = await getSessionTransactionData(socket.id, getTransactionType(jobid), RRCacheEnums.DMSVid); // Ensure we have a customer to use let DMSCust; if (selectedCustomerId) { DMSCust = await ReadCustomerById({ socket, redisHelpers, JobData, customerId: selectedCustomerId }); } else { const createRes = await CreateCustomer({ socket, redisHelpers, JobData }); DMSCust = { customerId: createRes?.data || createRes?.customerId || createRes?.id }; } await setSessionTransactionData(socket.id, getTransactionType(jobid), RRCacheEnums.DMSCust, DMSCust, defaultRRTTL); // Ensure the vehicle exists (ownership model TBD per RR) let DMSVeh; if (DMSVid?.newId) { DMSVeh = await InsertVehicle({ socket, redisHelpers, JobData, txEnvelope, DMSVid, DMSCust }); } else { DMSVeh = await ReadVehicleById({ socket, redisHelpers, JobData, vehicleId: DMSVid.vehiclesVehId }); // TODO: If RR supports “UpdateVehicle” to change ownership, add it here. } await setSessionTransactionData(socket.id, getTransactionType(jobid), RRCacheEnums.DMSVeh, DMSVeh, defaultRRTTL); // Start WIP header const DMSTransHeader = await StartWip({ socket, redisHelpers, JobData, txEnvelope }); await setSessionTransactionData( socket.id, getTransactionType(jobid), RRCacheEnums.DMSTransHeader, DMSTransHeader, defaultRRTTL ); // Post lines const DMSBatchTxn = await TransBatchWip({ socket, redisHelpers, JobData }); await setSessionTransactionData( socket.id, getTransactionType(jobid), RRCacheEnums.DMSBatchTxn, DMSBatchTxn, defaultRRTTL ); // Decide success from envelope (heuristic until exact spec confirmed) if (String(DMSBatchTxn?.rtnCode || "0") === "0") { const DmsBatchTxnPost = await PostBatchWip({ socket, redisHelpers, JobData }); await setSessionTransactionData( socket.id, getTransactionType(jobid), RRCacheEnums.DmsBatchTxnPost, DmsBatchTxnPost, defaultRRTTL ); if (String(DmsBatchTxnPost?.rtnCode || "0") === "0") { await MarkJobExported({ socket, jobid: JobData.id, redisHelpers }); // Optional service history write (non-blocking) try { const DMSVehHistory = await InsertServiceVehicleHistory({ socket, redisHelpers, JobData }); await setSessionTransactionData( socket.id, getTransactionType(jobid), RRCacheEnums.DMSVehHistory, DMSVehHistory, defaultRRTTL ); } catch (e) { CreateRRLogEvent(socket, "WARN", `[RR] ServiceVehicleHistory optional step failed: ${e.message}`); } socket.emit("export-success", JobData.id); } else { await HandlePostingError({ socket, redisHelpers, JobData, DMSTransHeader }); } } else { await InsertFailedExportLog({ socket, JobData, error: `RR DMSBatchTxn not successful: ${JSON.stringify(DMSBatchTxn)}` }); } } catch (error) { CreateRRLogEvent(socket, "ERROR", `[RR] RRSelectedCustomer failed: ${error.message}`, { stack: error.stack }); const JobData = await redisHelpers.getSessionTransactionData( socket.id, getTransactionType(jobid), RRCacheEnums.JobData ); if (JobData) await InsertFailedExportLog({ socket, JobData, error }); } } // ----------------------------------------------------------------------------- // GraphQL job fetch // ----------------------------------------------------------------------------- async function QueryJobData({ socket, jobid }) { const client = new GraphQLClient(process.env.GRAPHQL_ENDPOINT, {}); const currentToken = (socket?.data && socket.data.authToken) || (socket?.handshake?.auth && socket.handshake.auth.token); const result = await client .setHeaders({ Authorization: `Bearer ${currentToken}` }) .request(queries.QUERY_JOBS_FOR_CDK_EXPORT, { id: jobid }); return result.jobs_by_pk; } // ----------------------------------------------------------------------------- // RR API step stubs (wire to MakeRRCall). Replace request payloads once the // exact RR/Rome schemas are confirmed from the PDFs. // ----------------------------------------------------------------------------- async function GetVehicleId({ socket, redisHelpers, JobData }) { return await MakeRRCall({ ...RRActions.GetVehicleId, requestPathParams: JobData.v_vin, redisHelpers, socket, jobid: JobData.id }); } async function ReadVehicleById({ socket, redisHelpers, JobData, vehicleId }) { return await MakeRRCall({ ...RRActions.ReadVehicle, requestPathParams: vehicleId, redisHelpers, socket, jobid: JobData.id }); } async function ReadCustomerById({ socket, redisHelpers, JobData, customerId }) { return await MakeRRCall({ ...RRActions.ReadCustomer, requestPathParams: customerId, redisHelpers, socket, jobid: JobData.id }); } async function SearchCustomerByName({ socket, redisHelpers, JobData }) { // TODO: Confirm exact query param names from the RR search spec const ownerNameParams = JobData.ownr_co_nm && JobData.ownr_co_nm.trim() !== "" ? [["lastName", JobData.ownr_co_nm]] // placeholder: business search : [ ["firstName", JobData.ownr_fn], ["lastName", JobData.ownr_ln] ]; return await MakeRRCall({ ...RRActions.QueryCustomerByName, // ✅ use action defined in rr-helpers requestSearchParams: ownerNameParams, redisHelpers, socket, jobid: JobData.id }); } async function CreateCustomer({ socket, redisHelpers, JobData }) { // TODO: Replace with exact RR Customer Insert envelope & fields const body = { customerType: JobData.ownr_co_nm ? "BUSINESS" : "INDIVIDUAL" }; return await MakeRRCall({ ...RRActions.CreateCustomer, body, redisHelpers, socket, jobid: JobData.id }); } async function InsertVehicle({ socket, redisHelpers, JobData /*, txEnvelope, DMSVid, DMSCust*/ }) { // TODO: Replace with exact RR Service Vehicle Insert mapping const body = { vin: JobData.v_vin // owners, make/model, odometer, etc… }; return await MakeRRCall({ ...RRActions.InsertVehicle, body, redisHelpers, socket, jobid: JobData.id }); } async function StartWip({ socket, redisHelpers, JobData, txEnvelope }) { // TODO: Replace body fields with RR WIP header schema const body = { acctgDate: moment().tz(JobData.bodyshop.timezone).format("YYYY-MM-DD"), desc: txEnvelope?.story || "", docType: "10", m13Flag: "0", refer: JobData.ro_number, srcCo: JobData.bodyshop?.cdk_configuration?.srcco || "00", // placeholder from CDK config; RR equivalent TBD srcJrnl: txEnvelope?.journal, userID: "BSMS" }; return await MakeRRCall({ ...RRActions.StartWip, body, redisHelpers, socket, jobid: JobData.id }); } async function TransBatchWip({ socket, redisHelpers, JobData }) { const wips = await GenerateTransWips({ socket, redisHelpers, JobData }); // TODO: Ensure this body shape matches RR batch transaction schema return await MakeRRCall({ ...RRActions.TranBatchWip, body: wips, redisHelpers, socket, jobid: JobData.id }); } async function PostBatchWip({ socket, redisHelpers, JobData }) { const DMSTransHeader = await redisHelpers.getSessionTransactionData( socket.id, getTransactionType(JobData.id), RRCacheEnums.DMSTransHeader ); // TODO: Confirm final field names for “post” operation in RR const body = { opCode: "P", transID: DMSTransHeader?.transID }; return await MakeRRCall({ ...RRActions.PostBatchWip, body, redisHelpers, socket, jobid: JobData.id }); } async function QueryErrWip({ socket, redisHelpers, JobData }) { const DMSTransHeader = await redisHelpers.getSessionTransactionData( socket.id, getTransactionType(JobData.id), RRCacheEnums.DMSTransHeader ); return await MakeRRCall({ ...RRActions.QueryErrorWip, requestPathParams: DMSTransHeader?.transID, redisHelpers, socket, jobid: JobData.id }); } async function DeleteWip({ socket, redisHelpers, JobData }) { const DMSTransHeader = await redisHelpers.getSessionTransactionData( socket.id, getTransactionType(JobData.id), RRCacheEnums.DMSTransHeader ); // TODO: Confirm if RR uses the same endpoint with opCode=D to delete/void const body = { opCode: "D", transID: DMSTransHeader?.transID }; return await MakeRRCall({ ...RRActions.PostBatchWip, body, redisHelpers, socket, jobid: JobData.id }); } async function InsertServiceVehicleHistory({ socket, redisHelpers, JobData }) { const txEnvelope = await redisHelpers.getSessionTransactionData( socket.id, getTransactionType(JobData.id), RRCacheEnums.txEnvelope ); // TODO: Replace with RR Service Vehicle History schema const body = { comments: txEnvelope?.story || "" }; return await MakeRRCall({ ...RRActions.ServiceHistoryInsert, body, redisHelpers, socket, jobid: JobData.id }); } async function HandlePostingError({ socket, redisHelpers, JobData /*, DMSTransHeader*/ }) { const DmsError = await QueryErrWip({ socket, redisHelpers, JobData }); await DeleteWip({ socket, redisHelpers, JobData }); const errString = DmsError?.errMsg || JSON.stringify(DmsError); errString?.split("|")?.forEach((e) => e && CreateRRLogEvent(socket, "ERROR", `[RR] Post error: ${e}`)); await InsertFailedExportLog({ socket, JobData, error: errString }); } /** * Convert app allocations to RR WIP lines. * Re-uses existing CalculateAllocations to keep parity with CDK/Fortellis. * * TODO: Confirm exact RR posting model (accounts, control numbers, company ids, * and whether amounts are signed or need separate debit/credit flags). */ async function GenerateTransWips({ socket, redisHelpers, JobData }) { const allocations = await CalculateAllocations(socket, JobData.id, true); // true==verbose logging const DMSTransHeader = await redisHelpers.getSessionTransactionData( socket.id, getTransactionType(JobData.id), RRCacheEnums.DMSTransHeader ); const wips = []; allocations.forEach((alloc) => { if (alloc.sale.getAmount() > 0 && !alloc.tax) { wips.push({ acct: alloc.profitCenter.dms_acctnumber, cntl: alloc.profitCenter.dms_control_override || JobData.ro_number, postAmt: alloc.sale.multiply(-1).getAmount(), // sale is a credit in many GLs; confirm RR sign transID: DMSTransHeader?.transID, trgtCoID: JobData.bodyshop?.cdk_configuration?.srcco // RR equivalent TBD }); } if (alloc.cost.getAmount() > 0 && !alloc.tax) { wips.push({ acct: alloc.costCenter.dms_acctnumber, cntl: alloc.costCenter.dms_control_override || JobData.ro_number, postAmt: alloc.cost.getAmount(), transID: DMSTransHeader?.transID, trgtCoID: JobData.bodyshop?.cdk_configuration?.srcco }); wips.push({ acct: alloc.costCenter.dms_wip_acctnumber, cntl: alloc.costCenter.dms_control_override || JobData.ro_number, postAmt: alloc.cost.multiply(-1).getAmount(), transID: DMSTransHeader?.transID, trgtCoID: JobData.bodyshop?.cdk_configuration?.srcco }); } if (alloc.tax && alloc.sale.getAmount() > 0) { wips.push({ acct: alloc.profitCenter.dms_acctnumber, cntl: alloc.profitCenter.dms_control_override || JobData.ro_number, postAmt: alloc.sale.multiply(-1).getAmount(), transID: DMSTransHeader?.transID, trgtCoID: JobData.bodyshop?.cdk_configuration?.srcco }); } }); const txEnvelope = await redisHelpers.getSessionTransactionData( socket.id, getTransactionType(JobData.id), RRCacheEnums.txEnvelope ); txEnvelope?.payers?.forEach((payer) => { wips.push({ acct: payer.dms_acctnumber, cntl: payer.controlnumber, postAmt: Math.round(payer.amount * 100), // assuming cents (confirm RR units) transID: DMSTransHeader?.transID, trgtCoID: JobData.bodyshop?.cdk_configuration?.srcco }); }); await redisHelpers.setSessionTransactionData( socket.id, getTransactionType(JobData.id), RRCacheEnums.transWips, wips, defaultRRTTL ); return wips; } // ----------------------------------------------------------------------------- // DB logging mirrors Fortellis (status + export log) // ----------------------------------------------------------------------------- async function MarkJobExported({ socket, jobid, redisHelpers }) { const client = new GraphQLClient(process.env.GRAPHQL_ENDPOINT, {}); const currentToken = (socket?.data && socket.data.authToken) || (socket?.handshake?.auth && socket.handshake.auth.token); // Pull JobData from the session to get bodyshop info + default statuses const JobData = (await redisHelpers.getSessionTransactionData(socket.id, getTransactionType(jobid), RRCacheEnums.JobData)) || {}; const transWips = await redisHelpers.getSessionTransactionData( socket.id, getTransactionType(jobid), RRCacheEnums.transWips ); return client.setHeaders({ Authorization: `Bearer ${currentToken}` }).request(queries.MARK_JOB_EXPORTED, { jobId: jobid, job: { status: JobData?.bodyshop?.md_ro_statuses?.default_exported || "Exported*", date_exported: new Date() }, log: { bodyshopid: JobData?.bodyshop?.id, jobid, successful: true, useremail: socket.user?.email, metadata: transWips }, bill: { exported: true, exported_at: new Date() } }); } async function InsertFailedExportLog({ socket, JobData, error }) { try { const client = new GraphQLClient(process.env.GRAPHQL_ENDPOINT, {}); const currentToken = (socket?.data && socket.data.authToken) || (socket?.handshake?.auth && socket.handshake.auth.token); return await client.setHeaders({ Authorization: `Bearer ${currentToken}` }).request(queries.INSERT_EXPORT_LOG, { log: { bodyshopid: JobData.bodyshop.id, jobid: JobData.id, successful: false, message: typeof error === "string" ? error : JSON.stringify(error), useremail: socket.user?.email } }); } catch (error2) { CreateRRLogEvent(socket, "ERROR", `Error in InsertFailedExportLog - ${error2.message}`, { stack: error2.stack }); } } module.exports = { RRJobExport, RRSelectedCustomer };