Files
bodyshop/server/rr/rr-job-export.js

568 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// -----------------------------------------------------------------------------
// 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
//
// Whats 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
};