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

This commit is contained in:
Dave
2025-10-14 13:23:32 -04:00
parent 6bab792b5e
commit 5a9381ebdb
11 changed files with 754 additions and 911 deletions

View File

@@ -1,381 +0,0 @@
// server/rr/rr-calculate-allocations.js
const { GraphQLClient } = require("graphql-request");
const queries = require("../graphql-client/queries");
const RRLogger = require("./rr-logger");
const Dinero = require("dinero.js");
const _ = require("lodash");
const WsLogger = require("../web-sockets/createLogEvent").default || require("../web-sockets/createLogEvent");
// InstanceManager wiring (same as CDK file)
const InstanceManager = require("../utils/instanceMgr").default;
const { DiscountNotAlreadyCounted } = InstanceManager({
imex: require("../job/job-totals"),
rome: require("../job/job-totals-USA")
});
/**
* HTTP route version (parity with CDK file)
*/
exports.defaultRoute = async function rrAllocationsHttp(req, res) {
try {
WsLogger.createLogEvent(req, "DEBUG", `RR: calculate allocations request for ${req.body.jobid}`);
const jobData = await queryJobData(req, req.BearerToken, req.body.jobid);
return res.status(200).json({ data: calculateAllocations(req, jobData) });
} catch (error) {
WsLogger.createLogEvent(req, "ERROR", `RR CalculateAllocations error. ${error}`);
res.status(500).json({ error: `RR CalculateAllocations error. ${error}` });
}
};
/**
* Socket version (parity with CDK file)
* @param {Socket} socket
* @param {string} jobid
* @returns {Promise<Array>} allocations
*/
exports.default = async function rrCalculateAllocations(socket, jobid) {
try {
const token = "Bearer " + socket.handshake.auth.token;
const jobData = await queryJobData(socket, token, jobid, /* isFortellis */ false);
return calculateAllocations(socket, jobData);
} catch (error) {
RRLogger(socket, "ERROR", `RR CalculateAllocations error. ${error}`);
return [];
}
};
async function queryJobData(connectionData, token, jobid /* , isFortellis */) {
WsLogger.createLogEvent(connectionData, "DEBUG", `RR: querying job data for id ${jobid}`);
const client = new GraphQLClient(process.env.GRAPHQL_ENDPOINT, {});
const result = await client.setHeaders({ Authorization: token }).request(queries.GET_CDK_ALLOCATIONS, { id: jobid });
WsLogger.createLogEvent(connectionData, "DEBUG", `RR: job data query result ${JSON.stringify(result, null, 2)}`);
return result.jobs_by_pk;
}
/**
* Core allocation logic mirrors CDK version, but logs as RR
*/
function calculateAllocations(connectionData, job) {
const { bodyshop } = job;
// Build tax allocation maps for US (Rome) and IMEX (Canada) contexts
const taxAllocations = InstanceManager({
executeFunction: true,
deubg: true,
args: [],
imex: () => ({
state: {
center: bodyshop.md_responsibility_centers.taxes.state.name,
sale: Dinero(job.job_totals.totals.state_tax),
cost: Dinero(),
profitCenter: bodyshop.md_responsibility_centers.taxes.state,
costCenter: bodyshop.md_responsibility_centers.taxes.state
},
federal: {
center: bodyshop.md_responsibility_centers.taxes.federal.name,
sale: Dinero(job.job_totals.totals.federal_tax),
cost: Dinero(),
profitCenter: bodyshop.md_responsibility_centers.taxes.federal,
costCenter: bodyshop.md_responsibility_centers.taxes.federal
}
}),
rome: () => ({
tax_ty1: {
center: bodyshop.md_responsibility_centers.taxes[`tax_ty1`].name,
sale: Dinero(job.job_totals.totals.us_sales_tax_breakdown[`ty1Tax`]),
cost: Dinero(),
profitCenter: bodyshop.md_responsibility_centers.taxes[`tax_ty1`],
costCenter: bodyshop.md_responsibility_centers.taxes[`tax_ty1`]
},
tax_ty2: {
center: bodyshop.md_responsibility_centers.taxes[`tax_ty2`].name,
sale: Dinero(job.job_totals.totals.us_sales_tax_breakdown[`ty2Tax`]),
cost: Dinero(),
profitCenter: bodyshop.md_responsibility_centers.taxes[`tax_ty2`],
costCenter: bodyshop.md_responsibility_centers.taxes[`tax_ty2`]
},
tax_ty3: {
center: bodyshop.md_responsibility_centers.taxes[`tax_ty3`].name,
sale: Dinero(job.job_totals.totals.us_sales_tax_breakdown[`ty3Tax`]),
cost: Dinero(),
profitCenter: bodyshop.md_responsibility_centers.taxes[`tax_ty3`],
costCenter: bodyshop.md_responsibility_centers.taxes[`tax_ty3`]
},
tax_ty4: {
center: bodyshop.md_responsibility_centers.taxes[`tax_ty4`].name,
sale: Dinero(job.job_totals.totals.us_sales_tax_breakdown[`ty4Tax`]),
cost: Dinero(),
profitCenter: bodyshop.md_responsibility_centers.taxes[`tax_ty4`],
costCenter: bodyshop.md_responsibility_centers.taxes[`tax_ty4`]
},
tax_ty5: {
center: bodyshop.md_responsibility_centers.taxes[`tax_ty5`].name,
sale: Dinero(job.job_totals.totals.us_sales_tax_breakdown[`ty5Tax`]),
cost: Dinero(),
profitCenter: bodyshop.md_responsibility_centers.taxes[`tax_ty5`],
costCenter: bodyshop.md_responsibility_centers.taxes[`tax_ty5`]
}
})
});
// Detect existing MAPA/MASH (Mitchell) lines so we dont double count
let hasMapaLine = false;
let hasMashLine = false;
const profitCenterHash = job.joblines.reduce((acc, val) => {
if (val.db_ref === "936008") hasMapaLine = true; // paint materials (MAPA)
if (val.db_ref === "936007") hasMashLine = true; // shop supplies (MASH)
if (val.profitcenter_part) {
if (!acc[val.profitcenter_part]) acc[val.profitcenter_part] = Dinero();
let dineroAmount = Dinero({ amount: Math.round(val.act_price * 100) }).multiply(val.part_qty || 1);
// Conditional discount add-on if not already counted elsewhere
dineroAmount = dineroAmount.add(
((val.prt_dsmk_m && val.prt_dsmk_m !== 0) || (val.prt_dsmk_p && val.prt_dsmk_p !== 0)) &&
DiscountNotAlreadyCounted(val, job.joblines)
? val.prt_dsmk_m
? Dinero({ amount: Math.round(val.prt_dsmk_m * 100) })
: Dinero({ amount: Math.round(val.act_price * 100) })
.multiply(val.part_qty || 0)
.percentage(Math.abs(val.prt_dsmk_p || 0))
.multiply(val.prt_dsmk_p > 0 ? 1 : -1)
: Dinero()
);
acc[val.profitcenter_part] = acc[val.profitcenter_part].add(dineroAmount);
}
if (val.profitcenter_labor && val.mod_lbr_ty) {
if (!acc[val.profitcenter_labor]) acc[val.profitcenter_labor] = Dinero();
acc[val.profitcenter_labor] = acc[val.profitcenter_labor].add(
Dinero({ amount: Math.round(job[`rate_${val.mod_lbr_ty.toLowerCase()}`] * 100) }).multiply(val.mod_lb_hrs)
);
}
return acc;
}, {});
const selectedDmsAllocationConfig = bodyshop.md_responsibility_centers.dms_defaults.find(
(d) => d.name === job.dms_allocation
);
WsLogger.createLogEvent(
connectionData,
"DEBUG",
`RR: Using DMS Allocation ${selectedDmsAllocationConfig && selectedDmsAllocationConfig.name} for cost export.`
);
// Build cost center totals from bills and time tickets
let costCenterHash = {};
const disableBillWip = !!bodyshop?.pbs_configuration?.disablebillwip;
if (!disableBillWip) {
costCenterHash = job.bills.reduce((billAcc, bill) => {
bill.billlines.forEach((line) => {
const target = selectedDmsAllocationConfig.costs[line.cost_center];
if (!billAcc[target]) billAcc[target] = Dinero();
let lineDinero = Dinero({ amount: Math.round((line.actual_cost || 0) * 100) })
.multiply(line.quantity)
.multiply(bill.is_credit_memo ? -1 : 1);
billAcc[target] = billAcc[target].add(lineDinero);
});
return billAcc;
}, {});
}
job.timetickets.forEach((ticket) => {
const ticketTotal = Dinero({
amount: Math.round(
ticket.rate *
(ticket.employee && ticket.employee.flat_rate ? ticket.productivehrs || 0 : ticket.actualhrs || 0) *
100
)
});
const target = selectedDmsAllocationConfig.costs[ticket.ciecacode];
if (!costCenterHash[target]) costCenterHash[target] = Dinero();
costCenterHash[target] = costCenterHash[target].add(ticketTotal);
});
// Add MAPA/MASH lines when not explicitly present
if (!hasMapaLine && job.job_totals.rates.mapa.total.amount > 0) {
const accountName = selectedDmsAllocationConfig.profits.MAPA;
const account = bodyshop.md_responsibility_centers.profits.find((c) => c.name === accountName);
if (account) {
if (!profitCenterHash[accountName]) profitCenterHash[accountName] = Dinero();
profitCenterHash[accountName] = profitCenterHash[accountName].add(Dinero(job.job_totals.rates.mapa.total));
}
}
if (!hasMashLine && job.job_totals.rates.mash.total.amount > 0) {
const accountName = selectedDmsAllocationConfig.profits.MASH;
const account = bodyshop.md_responsibility_centers.profits.find((c) => c.name === accountName);
if (account) {
if (!profitCenterHash[accountName]) profitCenterHash[accountName] = Dinero();
profitCenterHash[accountName] = profitCenterHash[accountName].add(Dinero(job.job_totals.rates.mash.total));
}
}
// Optional materials costing (CDK setting reused by RR sites if configured)
if (bodyshop?.cdk_configuration?.sendmaterialscosting) {
const percent = bodyshop.cdk_configuration.sendmaterialscosting;
// Paint Mat
const mapaCostName = selectedDmsAllocationConfig.costs.MAPA;
const mapaCost = bodyshop.md_responsibility_centers.costs.find((c) => c.name === mapaCostName);
if (mapaCost) {
if (!costCenterHash[mapaCostName]) costCenterHash[mapaCostName] = Dinero();
if (job.bodyshop.use_paint_scale_data === true && job.mixdata.length > 0) {
costCenterHash[mapaCostName] = costCenterHash[mapaCostName].add(
Dinero({ amount: Math.round(((job.mixdata[0] && job.mixdata[0].totalliquidcost) || 0) * 100) })
);
} else {
costCenterHash[mapaCostName] = costCenterHash[mapaCostName].add(
Dinero(job.job_totals.rates.mapa.total).percentage(percent)
);
}
}
// Shop Mat
const mashCostName = selectedDmsAllocationConfig.costs.MASH;
const mashCost = bodyshop.md_responsibility_centers.costs.find((c) => c.name === mashCostName);
if (mashCost) {
if (!costCenterHash[mashCostName]) costCenterHash[mashCostName] = Dinero();
costCenterHash[mashCostName] = costCenterHash[mashCostName].add(
Dinero(job.job_totals.rates.mash.total).percentage(percent)
);
}
}
// Provinical PVRT roll-in (Canada only)
const { ca_bc_pvrt } = job;
if (ca_bc_pvrt) {
taxAllocations.state.sale = taxAllocations.state.sale.add(Dinero({ amount: Math.round((ca_bc_pvrt || 0) * 100) }));
}
// Towing / Storage / Other adjustments
if (job.towing_payable && job.towing_payable !== 0) {
const name = selectedDmsAllocationConfig.profits.TOW;
const acct = bodyshop.md_responsibility_centers.profits.find((c) => c.name === name);
if (acct) {
if (!profitCenterHash[name]) profitCenterHash[name] = Dinero();
profitCenterHash[name] = profitCenterHash[name].add(
Dinero({ amount: Math.round((job.towing_payable || 0) * 100) })
);
}
}
if (job.storage_payable && job.storage_payable !== 0) {
const name = selectedDmsAllocationConfig.profits.TOW;
const acct = bodyshop.md_responsibility_centers.profits.find((c) => c.name === name);
if (acct) {
if (!profitCenterHash[name]) profitCenterHash[name] = Dinero();
profitCenterHash[name] = profitCenterHash[name].add(
Dinero({ amount: Math.round((job.storage_payable || 0) * 100) })
);
}
}
if (job.adjustment_bottom_line && job.adjustment_bottom_line !== 0) {
const name = selectedDmsAllocationConfig.profits.PAO;
const acct = bodyshop.md_responsibility_centers.profits.find((c) => c.name === name);
if (acct) {
if (!profitCenterHash[name]) profitCenterHash[name] = Dinero();
profitCenterHash[name] = profitCenterHash[name].add(
Dinero({ amount: Math.round((job.adjustment_bottom_line || 0) * 100) })
);
}
}
// Rome profile-level adjustments for parts / labor / materials
if (InstanceManager({ rome: true })) {
Object.keys(job.job_totals.parts.adjustments).forEach((key) => {
const name = selectedDmsAllocationConfig.profits[key];
const acct = bodyshop.md_responsibility_centers.profits.find((c) => c.name === name);
if (acct) {
if (!profitCenterHash[name]) profitCenterHash[name] = Dinero();
profitCenterHash[name] = profitCenterHash[name].add(Dinero(job.job_totals.parts.adjustments[key]));
} else {
WsLogger.createLogEvent(connectionData, "ERROR", `RR CalculateAllocations: missing parts adj account: ${name}`);
}
});
Object.keys(job.job_totals.rates).forEach((key) => {
const rate = job.job_totals.rates[key];
if (rate && rate.adjustment && Dinero(rate.adjustment).isZero() === false) {
const name = selectedDmsAllocationConfig.profits[key.toUpperCase()];
const acct = bodyshop.md_responsibility_centers.profits.find((c) => c.name === name);
if (acct) {
if (!profitCenterHash[name]) profitCenterHash[name] = Dinero();
// NOTE: the original code had rate.adjustments (plural). If thats a bug upstream, fix there.
profitCenterHash[name] = profitCenterHash[name].add(Dinero(rate.adjustments || rate.adjustment));
} else {
WsLogger.createLogEvent(
connectionData,
"ERROR",
`RR CalculateAllocations: missing rate adj account: ${name}`
);
}
}
});
}
// Merge profit & cost centers
const jobAllocations = _.union(Object.keys(profitCenterHash), Object.keys(costCenterHash)).map((key) => {
const profitCenter = bodyshop.md_responsibility_centers.profits.find((c) => c.name === key);
const costCenter = bodyshop.md_responsibility_centers.costs.find((c) => c.name === key);
return {
center: key,
sale: profitCenterHash[key] ? profitCenterHash[key] : Dinero(),
cost: costCenterHash[key] ? costCenterHash[key] : Dinero(),
profitCenter,
costCenter
};
});
// Add tax centers (non-zero only)
const taxRows = Object.keys(taxAllocations)
.filter((k) => taxAllocations[k].sale.getAmount() > 0 || taxAllocations[k].cost.getAmount() > 0)
.map((k) => {
const base = { ...taxAllocations[k], tax: k };
// Optional GST override preserved from CDK logic
const override = selectedDmsAllocationConfig.gst_override;
if (k === "federal" && override) {
base.costCenter.dms_acctnumber = override;
base.profitCenter.dms_acctnumber = override;
}
return base;
});
// Totals adjustments centers
const extra = [];
if (job.job_totals.totals.ttl_adjustment) {
extra.push({
center: "SUB ADJ",
sale: Dinero(job.job_totals.totals.ttl_adjustment),
cost: Dinero(),
profitCenter: {
name: "SUB ADJ",
accountdesc: "SUB ADJ",
accountitem: "SUB ADJ",
accountname: "SUB ADJ",
dms_acctnumber: bodyshop.md_responsibility_centers.ttl_adjustment.dms_acctnumber
},
costCenter: {}
});
}
if (job.job_totals.totals.ttl_tax_adjustment) {
extra.push({
center: "TAX ADJ",
sale: Dinero(job.job_totals.totals.ttl_tax_adjustment),
cost: Dinero(),
profitCenter: {
name: "TAX ADJ",
accountdesc: "TAX ADJ",
accountitem: "TAX ADJ",
accountname: "TAX ADJ",
dms_acctnumber: bodyshop.md_responsibility_centers.ttl_tax_adjustment.dms_acctnumber
},
costCenter: {}
});
}
return [...jobAllocations, ...taxRows, ...extra];
}

81
server/rr/rr-config.js Normal file
View File

@@ -0,0 +1,81 @@
// server/rr/rr-config.js
const { GraphQLClient, gql } = require("graphql-request");
/**
* Fetch the bodyshop row (dealer + per-shop RR json).
* No fallback to env for dealer/store/branch.
*/
async function fetchBodyshopRRRow(bodyshopId) {
if (!bodyshopId) throw new Error("Missing bodyshopId for RR config.");
const endpoint = process.env.GRAPHQL_ENDPOINT;
if (!endpoint) throw new Error("GRAPHQL_ENDPOINT env var is required.");
const headers = {};
if (process.env.HASURA_ADMIN_SECRET) {
headers["x-hasura-admin-secret"] = process.env.HASURA_ADMIN_SECRET;
} else if (process.env.GRAPHQL_BEARER) {
headers["authorization"] = `Bearer ${process.env.GRAPHQL_BEARER}`;
}
const client = new GraphQLClient(endpoint, { headers });
const Q = gql`
query BodyshopRR($id: uuid!) {
bodyshops_by_pk(id: $id) {
rr_dealerid
rr_configuration
}
}
`;
const { bodyshops_by_pk: bs } = await client.request(Q, { id: bodyshopId });
if (!bs) throw new Error("Bodyshop not found.");
if (!bs.rr_dealerid) throw new Error("Bodyshop is not configured for RR (missing rr_dealerid).");
let cfgJson = bs.rr_configuration || {};
if (typeof cfgJson === "string") {
try {
cfgJson = JSON.parse(cfgJson);
} catch {
cfgJson = {};
}
}
return {
dealerNumber: bs.rr_dealerid,
storeNumber: cfgJson.storeNumber,
branchNumber: cfgJson.branchNumber
};
}
/**
* Build the full RR config object used by downstream code.
* - Dealer/Store/Branch: from DB (required)
* - Transport/BaseURL/creds/PPSysId: from env (deployment-wide)
*/
async function getRRConfigForBodyshop(bodyshopId) {
const { dealerNumber, storeNumber, branchNumber } = await fetchBodyshopRRRow(bodyshopId);
const rrTransport = (process.env.RR_TRANSPORT || "STAR").toUpperCase();
return {
// Per-bodyshop (DB)
dealerNumber,
storeNumber,
branchNumber,
// Duplicate snake_case for legacy call-sites that expect it
dealer_number: dealerNumber,
store_number: storeNumber,
branch_number: branchNumber,
// Deployment-wide (env)
baseUrl: process.env.RR_BASE_URL,
username: process.env.RR_USERNAME,
password: process.env.RR_PASSWORD,
ppsysId: process.env.RR_PPSYSID,
rrTransport
};
}
module.exports = { getRRConfigForBodyshop };

View File

@@ -1,6 +1,11 @@
/**
* STAR-only constants for Reynolds & Reynolds (Rome/RCI)
* Used by rr-helpers.js to build and send SOAP requests.
*
* IMPORTANT:
* - Only rr-test.js should fall back to ENV for dealer/store/branch.
* - All runtime code (sockets/routes/jobs) must pass per-bodyshop
* values from the database (see rr-config.js#getRRConfigForBodyshop).
*/
exports.RR_NS = Object.freeze({
@@ -21,6 +26,8 @@ const RR_SOAP_HEADERS = {
"Content-Type": "text/xml; charset=utf-8",
SOAPAction: RR_STAR_SOAP_ACTION
};
// Export if other modules need default STAR headers
exports.RR_SOAP_HEADERS = RR_SOAP_HEADERS;
// All STAR-supported actions (mapped to Mustache templates)
exports.RR_ACTIONS = Object.freeze({
@@ -34,17 +41,37 @@ exports.RR_ACTIONS = Object.freeze({
UpdateRepairOrder: { template: "UpdateRepairOrder" }
});
// Base config loader (environment-driven)
/**
* Base config loader (environment-driven)
*
* ⚠️ Policy:
* - Only rr-test.js should rely on the ENV values for dealer/store/branch.
* - All other call sites must inject per-bodyshop values from DB.
*/
exports.getBaseRRConfig = function getBaseRRConfig() {
return {
baseUrl: process.env.RR_BASE_URL,
username: process.env.RR_USERNAME,
password: process.env.RR_PASSWORD,
ppsysId: process.env.RR_PPSYSID, // optional legacy identifier
// ❗ These are ONLY for rr-test.js fallback.
dealerNumber: process.env.RR_DEALER_NUMBER,
storeNumber: process.env.RR_STORE_NUMBER,
branchNumber: process.env.RR_BRANCH_NUMBER || "01",
wssePasswordType: process.env.RR_WSSE_PASSWORD_TYPE || "Text",
timeout: Number(process.env.RR_TIMEOUT_MS || 30000)
};
};
/**
* Normalize dealer/store/branch field names (camelCase vs snake_case).
* Safe to use in helpers to tolerate mixed callers during migration.
*/
exports.normalizeRRDealerFields = function normalizeRRDealerFields(cfg = {}) {
const dealerNumber = cfg.dealerNumber ?? cfg.dealer_number;
const storeNumber = cfg.storeNumber ?? cfg.store_number;
const branchNumber = cfg.branchNumber ?? cfg.branch_number;
return { dealerNumber, storeNumber, branchNumber };
};

View File

@@ -12,17 +12,20 @@ const axios = require("axios");
const mustache = require("mustache");
const { XMLParser } = require("fast-xml-parser");
const RRLogger = require("./rr-logger");
const { RR_ACTIONS, RR_SOAP_HEADERS, RR_STAR_SOAP_ACTION, RR_NS, getBaseRRConfig } = require("./rr-constants");
const {
RR_ACTIONS,
RR_SOAP_HEADERS,
RR_SOAP_ACTION,
RR_NS,
getBaseRRConfig,
normalizeRRDealerFields
} = require("./rr-constants");
const { RrApiError } = require("./rr-error");
const xmlFormatter = require("xml-formatter");
/**
* Remove XML decl, collapse inter-tag whitespace, strip empty lines,
* then pretty-print. Safe for XML because we only touch whitespace
* BETWEEN tags, not inside text nodes.
/**
* Collapse Mustache-induced whitespace and pretty print.
* - strips XML decl (inner)
* - strips inner XML decl
* - removes lines that are only whitespace
* - collapses inter-tag whitespace
* - formats with consistent indentation
@@ -69,25 +72,37 @@ async function renderXmlTemplate(templateName, data) {
return rendered.replace(/^\s*<\?xml[^>]*\?>\s*/i, "");
}
// ---------- Config resolution (STAR only) ----------
/**
* Resolve RR config for STAR transport.
*
* Policy:
* - Base (transport) settings (baseUrl, username, password, ppsysId, wssePasswordType, timeout) come from env.
* - Dealer identifiers (dealer/store/branch) MUST be provided by the caller (DB-driven).
* - We DO NOT fall back to env for dealer/store/branch here. Only rr-test.js is allowed to do that.
*/
async function resolveRRConfig(_socket, bodyshopConfig) {
const envCfg = getBaseRRConfig();
const baseEnv = getBaseRRConfig();
if (bodyshopConfig && typeof bodyshopConfig === "object") {
return {
...envCfg,
baseUrl: bodyshopConfig.baseUrl || envCfg.baseUrl,
username: bodyshopConfig.username || envCfg.username,
password: bodyshopConfig.password || envCfg.password,
ppsysId: bodyshopConfig.ppsysId || envCfg.ppsysId,
dealerNumber: bodyshopConfig.dealer_number || envCfg.dealerNumber,
storeNumber: bodyshopConfig.store_number || envCfg.storeNumber,
branchNumber: bodyshopConfig.branch_number || envCfg.branchNumber,
wssePasswordType: bodyshopConfig.wssePasswordType || envCfg.wssePasswordType || "Text",
timeout: envCfg.timeout
};
const { dealerNumber, storeNumber, branchNumber } = normalizeRRDealerFields(bodyshopConfig || {});
if (!dealerNumber || !storeNumber || !branchNumber) {
throw new Error(
"Missing dealer/store/branch in RR config. These must be loaded from the database (no env fallback here)."
);
}
return envCfg;
return {
baseUrl: bodyshopConfig?.baseUrl || baseEnv.baseUrl,
username: bodyshopConfig?.username || baseEnv.username,
password: bodyshopConfig?.password || baseEnv.password,
ppsysId: bodyshopConfig?.ppsysId || baseEnv.ppsysId,
wssePasswordType: bodyshopConfig?.wssePasswordType || baseEnv.wssePasswordType || "Text",
timeout: baseEnv.timeout,
// canonical identifiers (DB-driven only)
dealerNumber,
storeNumber,
branchNumber
};
}
// ---------- Response parsing ----------
@@ -161,8 +176,7 @@ function parseRRResponse(xml) {
// ---------- STAR envelope helpers ----------
function wrapWithApplicationArea(innerXml, { CreationDateTime, BODId, Sender, Destination }) {
// Make sure we inject *inside* the STAR root, not before it.
// 1) Strip any XML declaration just in case (idempotent)
// Strip any inner XML declaration (idempotent)
let xml = innerXml.replace(/^\s*<\?xml[^>]*\?>\s*/i, "");
const appArea = `
@@ -182,8 +196,7 @@ function wrapWithApplicationArea(innerXml, { CreationDateTime, BODId, Sender, De
</Destination>
</ApplicationArea>`.trim();
// Inject right after the opening tag of the root element (skip processing instructions)
// e.g. <rey_RomeGetAdvisorsReq ...> ==> insert ApplicationArea here
// Inject right after the opening tag of the root element
xml = xml.replace(/^(\s*<[^!?][^>]*>)/, `$1\n${appArea}\n`);
return xml;
@@ -229,7 +242,7 @@ async function MakeRRCall({
action,
body,
socket,
dealerConfig, // optional per-shop overrides
dealerConfig, // required in runtime code; rr-test.js can still pass env-inflated cfg
retries = 1,
jobid
}) {
@@ -237,6 +250,7 @@ async function MakeRRCall({
throw new Error(`Invalid RR action: ${action}`);
}
// Prefer explicit dealerConfig from caller; otherwise enforce DB-provided config via resolveRRConfig
const cfg = dealerConfig || (await resolveRRConfig(socket, undefined));
const baseUrl = cfg.baseUrl;
if (!baseUrl) throw new Error("Missing RR base URL");
@@ -246,8 +260,7 @@ async function MakeRRCall({
const renderedBusiness = await renderXmlTemplate(templateName, body?.data || {});
// Build STAR envelope
let envelope = await buildStarEnvelope(renderedBusiness, cfg, body?.appArea);
const envelope = await buildStarEnvelope(renderedBusiness, cfg, body?.appArea);
const formattedEnvelope = prettyPrintXml(envelope);
// Guardrails
@@ -255,11 +268,11 @@ async function MakeRRCall({
throw new Error("STAR envelope malformed: missing ProcessMessage/ApplicationArea");
}
const headers = { ...RR_SOAP_HEADERS, SOAPAction: RR_STAR_SOAP_ACTION };
const headers = { ...RR_SOAP_HEADERS, SOAPAction: RR_SOAP_ACTION };
RRLogger(socket, "debug", `Sending RR SOAP request`, {
action,
soapAction: RR_STAR_SOAP_ACTION,
soapAction: RR_SOAP_ACTION,
endpoint: baseUrl,
jobid,
mode: "STAR"

View File

@@ -5,6 +5,7 @@
*/
const dayjs = require("dayjs");
const { normalizeRRDealerFields } = require("./rr-constants");
/**
* Utility: formats date/time to R&Rs preferred format (ISO or yyyy-MM-dd).
@@ -22,6 +23,15 @@ const num = (val) => (val != null ? String(val) : undefined);
const toBoolStr = (v) => (v === true ? "true" : v === false ? "false" : undefined);
const hasAny = (obj) => !!obj && Object.values(obj).some((v) => v !== undefined && v !== null && v !== "");
/**
* Pull canonical Dealer/Store/Branch fields from cfg (tolerate snake_case during migration).
* Enforces DB-provided values upstream (no env fallback here).
*/
function getDSB(cfg) {
const { dealerNumber, storeNumber, branchNumber } = normalizeRRDealerFields(cfg || {});
return { dealerNumber, storeNumber, branchNumber };
}
//
// ===================== CUSTOMER =====================
//
@@ -31,12 +41,13 @@ const hasAny = (obj) => !!obj && Object.values(obj).some((v) => v !== undefined
*/
function mapCustomerInsert(customer, bodyshopConfig) {
if (!customer) return {};
const { dealerNumber, storeNumber, branchNumber } = getDSB(bodyshopConfig);
return {
DealerCode: bodyshopConfig?.dealer_code || "ROME",
DealerNumber: bodyshopConfig?.dealer_number,
StoreNumber: bodyshopConfig?.store_number,
BranchNumber: bodyshopConfig?.branch_number,
DealerNumber: dealerNumber,
StoreNumber: storeNumber,
BranchNumber: branchNumber,
RequestId: `CUST-INSERT-${customer.id}`,
Environment: process.env.NODE_ENV,
@@ -116,12 +127,13 @@ function mapCustomerUpdate(customer, bodyshopConfig) {
*/
function mapServiceVehicle(vehicle, ownerCustomer, bodyshopConfig) {
if (!vehicle) return {};
const { dealerNumber, storeNumber, branchNumber } = getDSB(bodyshopConfig);
return {
DealerCode: bodyshopConfig?.dealer_code || "ROME",
DealerNumber: bodyshopConfig?.dealer_number,
StoreNumber: bodyshopConfig?.store_number,
BranchNumber: bodyshopConfig?.branch_number,
DealerNumber: dealerNumber,
StoreNumber: storeNumber,
BranchNumber: branchNumber,
RequestId: `VEH-${vehicle.id}`,
CustomerId: ownerCustomer?.external_id,
@@ -175,15 +187,16 @@ function mapServiceVehicle(vehicle, ownerCustomer, bodyshopConfig) {
*/
function mapRepairOrderCreate(job, bodyshopConfig) {
if (!job) return {};
const { dealerNumber, storeNumber, branchNumber } = getDSB(bodyshopConfig);
const cust = job.customer || {};
const veh = job.vehicle || {};
return {
DealerCode: bodyshopConfig?.dealer_code || "ROME",
DealerNumber: bodyshopConfig?.dealer_number,
StoreNumber: bodyshopConfig?.store_number,
BranchNumber: bodyshopConfig?.branch_number,
DealerNumber: dealerNumber,
StoreNumber: storeNumber,
BranchNumber: branchNumber,
RequestId: `RO-${job.id}`,
Environment: process.env.NODE_ENV,
@@ -302,11 +315,12 @@ function mapRepairOrderUpdate(job, bodyshopConfig) {
//
function mapAdvisorLookup(criteria, bodyshopConfig) {
const { dealerNumber, storeNumber, branchNumber } = getDSB(bodyshopConfig);
return {
DealerCode: bodyshopConfig?.dealer_code || "ROME",
DealerNumber: bodyshopConfig?.dealer_number,
StoreNumber: bodyshopConfig?.store_number,
BranchNumber: bodyshopConfig?.branch_number,
DealerNumber: dealerNumber,
StoreNumber: storeNumber,
BranchNumber: branchNumber,
RequestId: `LOOKUP-ADVISOR-${Date.now()}`,
SearchCriteria: {
Department: criteria.department || "Body Shop",
@@ -316,11 +330,12 @@ function mapAdvisorLookup(criteria, bodyshopConfig) {
}
function mapPartsLookup(criteria, bodyshopConfig) {
const { dealerNumber, storeNumber, branchNumber } = getDSB(bodyshopConfig);
return {
DealerCode: bodyshopConfig?.dealer_code || "ROME",
DealerNumber: bodyshopConfig?.dealer_number,
StoreNumber: bodyshopConfig?.store_number,
BranchNumber: bodyshopConfig?.branch_number,
DealerNumber: dealerNumber,
StoreNumber: storeNumber,
BranchNumber: branchNumber,
RequestId: `LOOKUP-PART-${Date.now()}`,
SearchCriteria: {
PartNumber: criteria.part_number,
@@ -335,6 +350,8 @@ function mapPartsLookup(criteria, bodyshopConfig) {
}
function mapCombinedSearch(criteria = {}, bodyshopConfig) {
const { dealerNumber, storeNumber, branchNumber } = getDSB(bodyshopConfig);
// accept nested or flat input
const c = criteria || {};
const cust = c.customer || c.Customer || {};
@@ -364,14 +381,11 @@ function mapCombinedSearch(criteria = {}, bodyshopConfig) {
};
return {
// Dealer / routing (aligns with your other mappers)
STAR_NS: require("./rr-constants").RR_NS.STAR,
MaxRecs: criteria.maxResults || criteria.MaxResults || 50,
DealerCode: bodyshopConfig?.dealer_code || "ROME",
DealerName: bodyshopConfig?.dealer_name,
DealerNumber: bodyshopConfig?.dealer_number,
StoreNumber: bodyshopConfig?.store_number,
BranchNumber: bodyshopConfig?.branch_number,
DealerNumber: dealerNumber,
StoreNumber: storeNumber,
BranchNumber: branchNumber,
RequestId: c.requestId || `COMBINED-${Date.now()}`,
Environment: process.env.NODE_ENV,

View File

@@ -6,6 +6,7 @@
const path = require("path");
const fs = require("fs");
const dotenv = require("dotenv");
const { GraphQLClient, gql } = require("graphql-request");
const { MakeRRCall, renderXmlTemplate, buildStarEnvelope } = require("./rr-helpers");
const { getBaseRRConfig } = require("./rr-constants");
@@ -20,7 +21,7 @@ if (fs.existsSync(defaultEnvPath)) {
}
}
// Parse CLI args
// ---- CLI args parsing ----
const argv = process.argv.slice(2);
const args = { _: [] };
for (let i = 0; i < argv.length; i++) {
@@ -37,13 +38,12 @@ for (let i = 0; i < argv.length; i++) {
const next = argv[i + 1];
if (next && !next.startsWith("-")) {
args[k] = next;
i++; // consume value
i++;
} else {
args[k] = true; // boolean flag
args[k] = true;
}
}
} else if (a.startsWith("-") && a.length > 1) {
// simple short flag handling: -a value
const k = a.slice(1);
const next = argv[i + 1];
if (next && !next.startsWith("-")) {
@@ -62,7 +62,65 @@ function toIntOr(defaultVal, maybe) {
return Number.isFinite(n) ? n : defaultVal;
}
// ✅ fixed guard clause
// ---------------- GraphQL helpers ----------------
function buildGqlClient() {
const endpoint = process.env.GRAPHQL_ENDPOINT;
if (!endpoint) throw new Error("GRAPHQL_ENDPOINT env var is required when using --bodyshopId.");
const headers = {};
if (process.env.HASURA_ADMIN_SECRET) {
headers["x-hasura-admin-secret"] = process.env.HASURA_ADMIN_SECRET;
} else if (process.env.GRAPHQL_BEARER) {
headers["authorization"] = `Bearer ${process.env.GRAPHQL_BEARER}`;
}
return new GraphQLClient(endpoint, { headers });
}
const Q_BODYSHOPS_BY_PK = gql`
query BodyshopRR($id: uuid!) {
bodyshops_by_pk(id: $id) {
rr_dealerid
rr_configuration
}
}
`;
function normalizeConfigRecord(rec) {
if (!rec) return null;
let cfg = rec.rr_configuration || {};
if (typeof cfg === "string") {
try {
cfg = JSON.parse(cfg);
} catch {
cfg = {};
}
}
return {
dealerNumber: rec.rr_dealerid || undefined,
storeNumber: cfg.storeNumber || undefined,
branchNumber: cfg.branchNumber || undefined
};
}
/**
* Load RR config overrides from DB (bodyshops_by_pk only).
*/
async function loadBodyshopRRConfig(bodyshopId) {
if (!bodyshopId) return null;
const client = buildGqlClient();
const { bodyshops_by_pk: bs } = await client.request(Q_BODYSHOPS_BY_PK, { id: bodyshopId });
if (!bs) throw new Error("Bodyshop not found.");
if (!bs.rr_dealerid) throw new Error("Bodyshop is not configured for RR (missing rr_dealerid).");
return normalizeConfigRecord(bs);
}
// ---------------- rr-test logic ----------------
function pickActionName(raw) {
if (!raw || typeof raw !== "string") return "ping";
const x = raw.toLowerCase();
@@ -142,6 +200,8 @@ function buildBodyForAction(action, args, cfg) {
async function main() {
const action = pickActionName(args.action || args.a || args._[0]);
const bodyshopId = args.bodyshopId || args.bodyshop || args.b;
const rrAction =
action === "ping"
? "GetAdvisors"
@@ -153,12 +213,28 @@ async function main() {
? "GetParts"
: action;
// Start with env-based defaults…
const cfg = getBaseRRConfig();
// …then override with per-bodyshop values if provided.
if (bodyshopId) {
try {
const overrides = await loadBodyshopRRConfig(bodyshopId);
if (overrides?.dealerNumber) cfg.dealerNumber = overrides.dealerNumber;
if (overrides?.storeNumber) cfg.storeNumber = overrides.storeNumber;
if (overrides?.branchNumber) cfg.branchNumber = overrides.branchNumber;
console.log(" RR config loaded from DB via: bodyshops_by_pk");
} catch (e) {
console.error("❌ Failed to load per-bodyshop RR config:", e.message);
process.exit(1);
}
}
const body = buildBodyForAction(action, args, cfg);
const templateName = body.template || rrAction;
try {
const xml = await renderXmlTemplate(templateName, body.data);
await renderXmlTemplate(templateName, body.data);
console.log("✅ Templates verified.");
} catch (e) {
console.error("❌ Template verification failed:", e.message);
@@ -186,6 +262,4 @@ async function main() {
}
}
main().catch((e) => {
process.exit(1);
});
main().catch(() => process.exit(1));

View File

@@ -29,32 +29,57 @@ const { exportJobToRome } = require("./rr-job-export"); // orchestrator
// Diagnostics
const { listActions, verifyTemplatesExist } = require("./rr-wsdl");
// Helpers
// DB-driven RR config (no env fallback for dealer/store/branch here)
const { getRRConfigForBodyshop } = require("./rr-config");
// -------------------- Helpers --------------------
function ok(res, payload = {}) {
return res.json({ success: true, ...payload });
}
function fail(res, error, status = 400) {
const message = error?.message || String(error);
return res.status(status).json({ success: false, error: message, code: error?.code });
}
function pickConfig(req) {
// Accept config in either { config } or { bodyshopConfig }
return req.body?.config || req.body?.bodyshopConfig || {};
}
function socketOf(req) {
// If you stash a socket/logging context on the app, grab it; otherwise null
return (req.app && req.app.get && req.app.get("socket")) || null;
}
/**
* Resolve the per-bodyshop RR config strictly from DB.
* Looks for bodyshopId in:
* - req.body.bodyshopId
* - req.body.job?.shopid
* - x-bodyshop-id header
* Throws if not found.
*/
async function resolveRRConfigHttp(req) {
const candidateHeader = req.get && req.get("x-bodyshop-id");
const body = req.body || {};
const bodyshopId = body.bodyshopId || (body.job && (body.job.shopid || body.job.bodyshopId)) || candidateHeader;
if (!bodyshopId) {
throw new RrApiError(
"Missing bodyshopId (expected in body.bodyshopId, body.job.shopid, or x-bodyshop-id header)",
"BAD_REQUEST"
);
}
return getRRConfigForBodyshop(bodyshopId);
}
// -------------------- Customers --------------------
router.post("/rr/customer/insert", async (req, res) => {
const socket = socketOf(req);
const { customer } = req.body || {};
const cfg = pickConfig(req);
try {
if (!customer) throw new RrApiError("Missing 'customer' in request body", "BAD_REQUEST");
const cfg = await resolveRRConfigHttp(req); // DB-driven, required
const result = await customerApi.insertCustomer(socket, customer, cfg);
return ok(res, result);
} catch (err) {
@@ -66,10 +91,10 @@ router.post("/rr/customer/insert", async (req, res) => {
router.post("/rr/customer/update", async (req, res) => {
const socket = socketOf(req);
const { customer } = req.body || {};
const cfg = pickConfig(req);
try {
if (!customer) throw new RrApiError("Missing 'customer' in request body", "BAD_REQUEST");
const cfg = await resolveRRConfigHttp(req);
const result = await customerApi.updateCustomer(socket, customer, cfg);
return ok(res, result);
} catch (err) {
@@ -83,10 +108,10 @@ router.post("/rr/customer/update", async (req, res) => {
router.post("/rr/repair-order/create", async (req, res) => {
const socket = socketOf(req);
const { job } = req.body || {};
const cfg = pickConfig(req);
try {
if (!job) throw new RrApiError("Missing 'job' in request body", "BAD_REQUEST");
const cfg = await resolveRRConfigHttp(req);
const result = await roApi.createRepairOrder(socket, job, cfg);
return ok(res, result);
} catch (err) {
@@ -98,10 +123,10 @@ router.post("/rr/repair-order/create", async (req, res) => {
router.post("/rr/repair-order/update", async (req, res) => {
const socket = socketOf(req);
const { job } = req.body || {};
const cfg = pickConfig(req);
try {
if (!job) throw new RrApiError("Missing 'job' in request body", "BAD_REQUEST");
const cfg = await resolveRRConfigHttp(req);
const result = await roApi.updateRepairOrder(socket, job, cfg);
return ok(res, result);
} catch (err) {
@@ -115,9 +140,9 @@ router.post("/rr/repair-order/update", async (req, res) => {
router.post("/rr/lookup/advisors", async (req, res) => {
const socket = socketOf(req);
const { criteria = {} } = req.body || {};
const cfg = pickConfig(req);
try {
const cfg = await resolveRRConfigHttp(req);
const result = await lookupApi.getAdvisors(socket, criteria, cfg);
return ok(res, result);
} catch (err) {
@@ -129,9 +154,9 @@ router.post("/rr/lookup/advisors", async (req, res) => {
router.post("/rr/lookup/parts", async (req, res) => {
const socket = socketOf(req);
const { criteria = {} } = req.body || {};
const cfg = pickConfig(req);
try {
const cfg = await resolveRRConfigHttp(req);
const result = await lookupApi.getParts(socket, criteria, cfg);
return ok(res, result);
} catch (err) {
@@ -143,9 +168,9 @@ router.post("/rr/lookup/parts", async (req, res) => {
router.post("/rr/lookup/combined-search", async (req, res) => {
const socket = socketOf(req);
const { criteria = {} } = req.body || {};
const cfg = pickConfig(req);
try {
const cfg = await resolveRRConfigHttp(req);
const result = await lookupApi.combinedSearch(socket, criteria, cfg);
return ok(res, result);
} catch (err) {
@@ -159,10 +184,10 @@ router.post("/rr/lookup/combined-search", async (req, res) => {
router.post("/rr/export/job", async (req, res) => {
const socket = socketOf(req);
const { job, options = {} } = req.body || {};
const cfg = pickConfig(req);
try {
if (!job) throw new RrApiError("Missing 'job' in request body", "BAD_REQUEST");
const cfg = await resolveRRConfigHttp(req);
const result = await exportJobToRome(socket, job, cfg, options);
return ok(res, result);
} catch (err) {

View File

@@ -5,21 +5,7 @@ const { FortellisJobExport, FortellisSelectedCustomer } = require("../fortellis/
const CdkCalculateAllocations = require("../cdk/cdk-calculate-allocations").default;
const { exportJobToRome } = require("../rr/rr-job-export");
const lookupApi = require("../rr/rr-lookup");
function resolveRRConfigFrom(payload = {}) {
// Back-compat: allow txEnvelope.config from old callers
const cfg = payload.config || payload.bodyshopConfig || payload.txEnvelope?.config || {};
return {
baseUrl: cfg.baseUrl || process.env.RR_BASE_URL,
username: cfg.username || process.env.RR_USERNAME,
password: cfg.password || process.env.RR_PASSWORD,
ppsysId: cfg.ppsysId || process.env.RR_PPSYSID,
dealer_number: cfg.dealer_number || process.env.RR_DEALER_NUMBER,
store_number: cfg.store_number || process.env.RR_STORE_NUMBER,
branch_number: cfg.branch_number || process.env.RR_BRANCH_NUMBER,
rrTransport: (cfg.rrTransport || process.env.RR_TRANSPORT || "STAR").toUpperCase()
};
}
const { getRRConfigForBodyshop } = require("../rr/rr-config");
const redisSocketEvents = ({
io,
@@ -349,7 +335,7 @@ const redisSocketEvents = ({
});
socket.on("task-deleted", (payload) => {
if (!payload || !payload.id) return;
if (!payload?.id) return;
const room = getBodyshopRoom(socket.bodyshopId);
io.to(room).emit("bodyshop-message", { type: "task-deleted", payload });
});
@@ -359,11 +345,13 @@ const redisSocketEvents = ({
// Orchestrated Export (Customer → Vehicle → Repair Order)
socket.on("rr-export-job", async (payload = {}) => {
try {
// Back-compat: old callers: { jobid, txEnvelope }; new: { job, config, options }
// Prefer direct job/config, otherwise try txEnvelope.{job,config}
// Back-compat: old callers: { jobid, txEnvelope }; new: { job, options }
// Prefer direct job, otherwise try txEnvelope.job
const job = payload.job || payload.txEnvelope?.job;
const options = payload.options || payload.txEnvelope?.options || {};
const cfg = resolveRRConfigFrom(payload);
// Resolve per-bodyshop RR config strictly from DB:
const bodyshopId = payload.bodyshopId || socket.bodyshopId || job?.shopid;
const cfg = await getRRConfigForBodyshop(bodyshopId);
if (!job) {
RRLogger(socket, "error", "RR export missing job payload");
@@ -383,7 +371,7 @@ const redisSocketEvents = ({
// Combined search
socket.on("rr-lookup-combined", async ({ jobid, params } = {}, cb) => {
try {
const cfg = resolveRRConfigFrom({}); // if you want per-call overrides, pass them in the payload and merge here
const cfg = await getRRConfigForBodyshop(socket.bodyshopId);
const data = await lookupApi.combinedSearch(socket, params || {}, cfg);
cb?.(data);
} catch (e) {
@@ -395,7 +383,7 @@ const redisSocketEvents = ({
// Get Advisors
socket.on("rr-get-advisors", async ({ jobid, params } = {}, cb) => {
try {
const cfg = resolveRRConfigFrom({});
const cfg = await getRRConfigForBodyshop(socket.bodyshopId);
const data = await lookupApi.getAdvisors(socket, params || {}, cfg);
cb?.(data);
} catch (e) {
@@ -407,7 +395,7 @@ const redisSocketEvents = ({
// Get Parts
socket.on("rr-get-parts", async ({ jobid, params } = {}, cb) => {
try {
const cfg = resolveRRConfigFrom({});
const cfg = await getRRConfigForBodyshop(socket.bodyshopId);
const data = await lookupApi.getParts(socket, params || {}, cfg);
cb?.(data);
} catch (e) {
@@ -419,9 +407,6 @@ const redisSocketEvents = ({
// (Optional) Selected customer — only keep this if you actually implement it for RR
socket.on("rr-selected-customer", async ({ jobid, selectedCustomerId } = {}) => {
try {
// If you dont have an RRSelectedCustomer implementation now, either:
// 1) no-op with a log, or
// 2) emit a structured event UI can handle as "not supported".
RRLogger(socket, "info", "rr-selected-customer not implemented for RR (no-op)", {
jobid,
selectedCustomerId