Compare commits

..

17 Commits

Author SHA1 Message Date
Patrick Fic
faf5878bdf Revert "Revert "Release/2026 02 27 (pull request #3070)" (pull request #3080)" 2026-03-04 17:41:10 +00:00
Patrick Fic
5a55798d2d Merged in release/revert-pr-3070-2026-03-04 (pull request #3080)
Revert "Release/2026 02 27 (pull request #3070)"
2026-03-04 16:20:15 +00:00
Patrick Fic
c9e41ba72a Revert "Release/2026 02 27 (pull request #3070)" 2026-03-04 16:18:44 +00:00
Dave Richer
522f2b9e26 Merged in release/2026-02-27 (pull request #3070)
Release/2026 02 27
2026-03-04 01:41:53 +00:00
Allan Carr
be9267ddd4 Merged in feature/IO-3594-Kaizen-Datapump-Enhancement (pull request #3076)
Feature/IO-3594 Kaizen Datapump Enhancement

Approved-by: Dave Richer
2026-03-04 00:51:11 +00:00
Patrick Fic
e4a79b51c7 Merged in feature/IO-3515-ocr-bill-posting (pull request #3077)
Feature/IO-3515 ocr bill posting
2026-03-03 22:56:52 +00:00
Patrick Fic
47a9a963fa IO-3515 Minor improvements to Bill AI. 2026-03-03 14:56:28 -08:00
Allan Carr
f3c7a831a1 IO-3594 Kaizen Datapump Enhancement
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-03-03 13:18:16 -08:00
Allan Carr
6ac9310e81 IO-3594 Kaizen Datapump Enhancement
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-03-03 13:17:56 -08:00
Dave
b91e65be0e release/2026-02-27 - Add gating 2026-03-03 15:25:13 -05:00
Dave
3f2358e30c Merge remote-tracking branch 'origin/hotfix/2026-03-03' into release/2026-02-27 2026-03-03 13:08:31 -05:00
Dave Richer
ce02d90c3c Merged in hotfix/2026-03-03-RR-logging-Posting-Enhancements (pull request #3072)
hotfix/2026-03-03-RR-logging-Posting-Enhancements - Implement
2026-03-03 18:05:06 +00:00
Allan Carr
95a71bea6e Merged in feature/IO-3585-Fortellis-Insert-and-Update-Vehicle (pull request #3071)
IO-3585 Fortellis Insert and Update Vehicle Info

Approved-by: Dave Richer
2026-03-03 18:03:41 +00:00
Dave
3b27120d77 hotfix/2026-03-03-RR-logging-Posting-Enhancements - Implement 2026-03-03 13:03:02 -05:00
Dave Richer
f350163056 Merged in feature/IO-3554-Form-Row-Layout (pull request #3068)
feature/IO-3554-Form-Row-Layout - dial in tables
2026-03-02 17:00:21 +00:00
Dave Richer
57cfecb7b8 Merged in feature/IO-3554-Form-Row-Layout (pull request #3066)
feature/IO-3554-Form-Row-Layout - Modify how truncation works on responsive tables.
2026-03-02 16:29:45 +00:00
Patrick Fic
5f8a08b0a7 IO-3515 Limit logging. 2026-02-23 11:55:22 -08:00
13 changed files with 780 additions and 139 deletions

View File

@@ -443,6 +443,30 @@
flex-direction: column;
}
/* DMS top panels: prevent card/table overflow into adjacent column at desktop+zoom */
.dms-top-panel-col {
min-width: 0;
}
.dms-top-panel-col > .ant-card {
width: 100%;
min-width: 0;
max-width: 100%;
}
.dms-top-panel-col > .ant-card .ant-card-body {
min-width: 0;
max-width: 100%;
}
.dms-top-panel-col .ant-table-wrapper,
.dms-top-panel-col .ant-tabs,
.dms-top-panel-col .ant-tabs-content,
.dms-top-panel-col .ant-tabs-tabpane {
min-width: 0;
max-width: 100%;
}
//.rbc-time-header-gutter {
// padding: 0;
//}

View File

@@ -108,7 +108,7 @@ function BillEnterAiScan({
setIsAiScan(true);
const formdata = new FormData();
formdata.append("billScan", file);
formdata.append("jobid", billEnterModal.context.job?.id);
formdata.append("jobid", form.getFieldValue("jobid") || billEnterModal.context.job?.id);
formdata.append("bodyshopid", bodyshop.id);
formdata.append("partsorderid", billEnterModal.context.parts_order?.id);

View File

@@ -4,6 +4,7 @@ import dayjs from "../../utils/day";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { selectDarkMode } from "../../redux/application/application.selectors.js";
import { useTranslation } from "react-i18next";
const mapStateToProps = createStructuredSelector({
isDarkMode: selectDarkMode
@@ -19,25 +20,35 @@ export function DmsLogEvents({
detailsNonce,
isDarkMode,
colorizeJson = false,
showDetails = true
showDetails = true,
allowXmlPayload = true
}) {
const { t } = useTranslation();
const [openSet, setOpenSet] = useState(() => new Set());
const [copiedKey, setCopiedKey] = useState(null);
// Inject JSON highlight styles once (only when colorize is enabled)
useEffect(() => {
if (!colorizeJson) return;
if (typeof document === "undefined") return;
if (document.getElementById("json-highlight-styles")) return;
const style = document.createElement("style");
style.id = "json-highlight-styles";
let style = document.getElementById("json-highlight-styles");
if (!style) {
style = document.createElement("style");
style.id = "json-highlight-styles";
document.head.appendChild(style);
}
style.textContent = `
.json-key { color: #fa8c16; }
.json-string { color: #52c41a; }
.json-number { color: #722ed1; }
.json-boolean { color: #1890ff; }
.json-null { color: #faad14; }
.xml-tag { color: #1677ff; }
.xml-attr { color: #d46b08; }
.xml-value { color: #389e0d; }
.xml-decl { color: #7c3aed; }
.xml-comment { color: #8c8c8c; }
`;
document.head.appendChild(style);
}, [colorizeJson]);
// Trim openSet if logs shrink
@@ -65,6 +76,13 @@ export function DmsLogEvents({
// Only treat meta as "present" when we are allowed to show details
const hasMeta = !isEmpty(meta) && showDetails;
const isOpen = hasMeta && openSet.has(idx);
const xml = hasMeta && allowXmlPayload ? extractXmlFromMeta(meta) : { request: null, response: null };
const hasRequestXml = !!xml.request;
const hasResponseXml = !!xml.response;
const copyPayload = hasMeta ? getCopyPayload(meta) : null;
const copyPayloadKey = `copy-${idx}`;
const copyReqKey = `copy-req-${idx}`;
const copyResKey = `copy-res-${idx}`;
return {
key: idx,
@@ -92,10 +110,42 @@ export function DmsLogEvents({
return next;
})
}
style={{ cursor: "pointer", userSelect: "none" }}
style={{ cursor: "pointer", userSelect: "none", fontSize: 11 }}
>
{isOpen ? "Hide details" : "Details"}
{isOpen ? t("dms.labels.hide_details") : t("dms.labels.details")}
</a>
<Divider orientation="vertical" />
<a
role="button"
onClick={() => handleCopyAction(copyPayloadKey, copyPayload, setCopiedKey)}
style={{ cursor: "pointer", userSelect: "none", fontSize: 11 }}
>
{copiedKey === copyPayloadKey ? t("dms.labels.copied") : t("dms.labels.copy")}
</a>
{hasRequestXml && (
<>
<Divider orientation="vertical" />
<a
role="button"
onClick={() => handleCopyAction(copyReqKey, xml.request, setCopiedKey)}
style={{ cursor: "pointer", userSelect: "none", fontSize: 11 }}
>
{copiedKey === copyReqKey ? t("dms.labels.copied") : t("dms.labels.copy_request")}
</a>
</>
)}
{hasResponseXml && (
<>
<Divider orientation="vertical" />
<a
role="button"
onClick={() => handleCopyAction(copyResKey, xml.response, setCopiedKey)}
style={{ cursor: "pointer", userSelect: "none", fontSize: 11 }}
>
{copiedKey === copyResKey ? t("dms.labels.copied") : t("dms.labels.copy_response")}
</a>
</>
)}
</>
)}
</Space>
@@ -103,14 +153,30 @@ export function DmsLogEvents({
{/* Row 2: details body (only when open) */}
{hasMeta && isOpen && (
<div style={{ marginLeft: 6 }}>
<JsonBlock isDarkMode={isDarkMode} data={meta} colorize={colorizeJson} />
<JsonBlock isDarkMode={isDarkMode} data={removeXmlFromMeta(meta)} colorize={colorizeJson} />
{hasRequestXml && (
<XmlBlock
isDarkMode={isDarkMode}
title={t("dms.labels.request_xml")}
xmlText={xml.request}
colorize={colorizeJson}
/>
)}
{hasResponseXml && (
<XmlBlock
isDarkMode={isDarkMode}
title={t("dms.labels.response_xml")}
xmlText={xml.response}
colorize={colorizeJson}
/>
)}
</div>
)}
</Space>
)
};
}),
[logs, openSet, colorizeJson, isDarkMode, showDetails]
[logs, openSet, colorizeJson, copiedKey, isDarkMode, showDetails, allowXmlPayload, t]
);
return <Timeline reverse items={items} />;
@@ -179,6 +245,121 @@ const safeStringify = (obj, spaces = 2) => {
}
};
/**
* Get request/response XML from various Reynolds log meta shapes.
* @param meta
* @returns {{request: string|null, response: string|null}}
*/
const extractXmlFromMeta = (meta) => {
const request =
firstString(meta?.requestXml) ||
firstString(meta?.xml?.request) ||
firstString(meta?.response?.xml?.request) ||
firstString(meta?.response?.requestXml);
const response =
firstString(meta?.responseXml) || firstString(meta?.xml?.response) || firstString(meta?.response?.xml?.response);
return { request, response };
};
/**
* Return the value to copy when clicking the "Copy" action.
* @param meta
* @returns {*}
*/
const getCopyPayload = (meta) => {
if (meta?.payload != null) return meta.payload;
return meta;
};
/**
* Remove bulky XML fields from object shown in JSON block (XML is rendered separately).
* @param meta
* @returns {*}
*/
const removeXmlFromMeta = (meta) => {
if (meta == null || typeof meta !== "object") return meta;
const cloned = safeClone(meta);
if (cloned == null || typeof cloned !== "object") return meta;
if (typeof cloned.requestXml === "string") delete cloned.requestXml;
if (typeof cloned.responseXml === "string") delete cloned.responseXml;
if (cloned.xml && typeof cloned.xml === "object") {
if (typeof cloned.xml.request === "string") delete cloned.xml.request;
if (typeof cloned.xml.response === "string") delete cloned.xml.response;
if (isEmpty(cloned.xml)) delete cloned.xml;
}
if (cloned.response?.xml && typeof cloned.response.xml === "object") {
if (typeof cloned.response.xml.request === "string") delete cloned.response.xml.request;
if (typeof cloned.response.xml.response === "string") delete cloned.response.xml.response;
if (isEmpty(cloned.response.xml)) delete cloned.response.xml;
}
return cloned;
};
/**
* Safe deep clone for plain JSON structures.
* @param value
* @returns {*}
*/
const safeClone = (value) => {
try {
return JSON.parse(JSON.stringify(value));
} catch {
return value;
}
};
/**
* First non-empty string helper.
* @param value
* @returns {string|null}
*/
const firstString = (value) => {
if (typeof value !== "string") return null;
const trimmed = value.trim();
return trimmed ? trimmed : null;
};
/**
* Copy arbitrary text/object to clipboard.
* @param key
* @param value
* @param setCopied
* @returns {Promise<void>}
*/
const handleCopyAction = async (key, value, setCopied) => {
const text = typeof value === "string" ? value : safeStringify(value, 2);
if (!text) return;
const copied = await copyTextToClipboard(text);
if (!copied) return;
setCopied(key);
setTimeout(() => {
setCopied((prev) => (prev === key ? null : prev));
}, 1200);
};
/**
* Clipboard helper (modern async Clipboard API).
* @param text
* @returns {Promise<boolean>}
*/
const copyTextToClipboard = async (text) => {
if (typeof navigator === "undefined" || !navigator.clipboard?.writeText) {
return false;
}
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
return false;
}
};
/**
* JSON display block with optional syntax highlighting.
* @param data
@@ -210,6 +391,105 @@ const JsonBlock = ({ data, colorize, isDarkMode }) => {
return <pre style={preStyle}>{jsonText}</pre>;
};
/**
* XML display block with normalized indentation.
* @param title
* @param xmlText
* @param isDarkMode
* @returns {JSX.Element}
* @constructor
*/
const XmlBlock = ({ title, xmlText, isDarkMode, colorize = false }) => {
const base = {
margin: "8px 0 0",
maxWidth: 720,
overflowX: "auto",
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
fontSize: 12,
lineHeight: 1.45,
padding: 8,
borderRadius: 6,
background: isDarkMode ? "rgba(255,255,255,0.1)" : "rgba(0,0,0,0.04)",
border: isDarkMode ? "1px solid rgba(255,255,255,0.12)" : "1px solid rgba(0,0,0,0.08)",
color: isDarkMode ? "var(--card-text-fallback)" : "#141414",
whiteSpace: "pre"
};
return (
<div style={{ marginTop: 6 }}>
<div style={{ fontSize: 11, fontWeight: 600 }}>{title}</div>
{colorize ? (
<pre style={base} dangerouslySetInnerHTML={{ __html: highlightXml(formatXml(xmlText)) }} />
) : (
<pre style={base}>{formatXml(xmlText)}</pre>
)}
</div>
);
};
/**
* Basic XML pretty-printer.
* @param xml
* @returns {string}
*/
const formatXml = (xml) => {
if (typeof xml !== "string") return "";
const normalized = xml.replace(/\r\n/g, "\n").replace(/>\s*</g, ">\n<").trim();
const lines = normalized.split("\n");
let indent = 0;
const out = [];
for (const rawLine of lines) {
const line = rawLine.trim();
if (!line) continue;
if (/^<\/[^>]+>/.test(line)) indent = Math.max(indent - 1, 0);
out.push(`${" ".repeat(indent)}${line}`);
const opens = (line.match(/<[^/!?][^>]*>/g) || []).length;
const closes = (line.match(/<\/[^>]+>/g) || []).length;
const selfClosing = (line.match(/<[^>]+\/>/g) || []).length;
const declaration = /^<\?xml/.test(line) ? 1 : 0;
indent += opens - closes - selfClosing - declaration;
if (indent < 0) indent = 0;
}
return out.join("\n");
};
/**
* Syntax highlight pretty-printed XML text for HTML display.
* @param xmlText
* @returns {string}
*/
const highlightXml = (xmlText) => {
const esc = String(xmlText || "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
const lines = esc.split("\n");
return lines
.map((line) => {
let out = line;
out = out.replace(/(&lt;!--[\s\S]*?--&gt;)/g, '<span class="xml-comment">$1</span>');
out = out.replace(/(&lt;\?xml[\s\S]*?\?&gt;)/g, '<span class="xml-decl">$1</span>');
out = out.replace(/(&lt;\/?)([A-Za-z_][\w:.-]*)([\s\S]*?)(\/?&gt;)/g, (_m, open, tag, attrs, close) => {
const coloredAttrs = attrs.replace(
/([A-Za-z_][\w:.-]*)(=)("[^"]*"|'[^']*'|&quot;[\s\S]*?&quot;|&apos;[\s\S]*?&apos;)/g,
'<span class="xml-attr">$1</span>$2<span class="xml-value">$3</span>'
);
return `${open}<span class="xml-tag">${tag}</span>${coloredAttrs}${close}`;
});
return out;
})
.join("\n");
};
/**
* Syntax highlight JSON text for HTML display.
* @param jsonText

View File

@@ -11,7 +11,7 @@ import { useSocket } from "../../contexts/SocketIO/useSocket.js";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import { QUERY_JOB_EXPORT_DMS } from "../../graphql/jobs.queries";
import { selectBodyshop } from "../../redux/user/user.selectors";
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
import { insertAuditTrail, setBreadcrumbs, setSelectedHeader } from "../../redux/application/application.actions";
import InstanceRenderManager from "../../utils/instanceRenderMgr";
@@ -29,7 +29,8 @@ import DmsAllocationsSummary from "../../components/dms-allocations-summary/dms-
import RrAllocationsSummary from "../../components/dms-allocations-summary/rr-dms-allocations-summary.component";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop
bodyshop: selectBodyshop,
currentUser: selectCurrentUser
});
const mapDispatchToProps = (dispatch) => ({
@@ -65,7 +66,41 @@ const DMS_SOCKET_EVENTS = {
}
};
export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, insertAuditTrail }) {
const stripRrXmlFromPayload = (input) => {
if (input == null || typeof input !== "object") return input;
let target = null;
try {
target = JSON.parse(JSON.stringify(input));
} catch {
// Fallback to in-place scrub if cloning fails.
target = input;
}
const scrub = (node) => {
if (node == null || typeof node !== "object") return;
if (Array.isArray(node)) {
node.forEach(scrub);
return;
}
delete node.requestXml;
delete node.responseXml;
if (node.xml && typeof node.xml === "object") {
delete node.xml.request;
delete node.xml.response;
if (Object.keys(node.xml).length === 0) delete node.xml;
}
Object.values(node).forEach(scrub);
};
scrub(target);
return target;
};
export function DmsContainer({ bodyshop, currentUser, setBreadcrumbs, setSelectedHeader, insertAuditTrail }) {
const {
treatments: { Fortellis }
} = useTreatmentsWithConfig({
@@ -79,6 +114,15 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse
const [allocationsSummary, setAllocationsSummary] = useState(null);
const [reconnectNonce, setReconnectNonce] = useState(0);
const isDevEnv = import.meta.env.DEV;
const isProdEnv = import.meta.env.PROD;
const userEmail = (currentUser?.email || "").toLowerCase();
const devEmails = ["imex.dev", "rome.dev"];
const prodEmails = ["imex.prod", "rome.prod", "imex.test", "rome.test"];
const hasValidEmail = (emails) => emails.some((email) => userEmail.endsWith(email));
const canViewSensitiveRrXml = (isDevEnv && hasValidEmail(devEmails)) || (isProdEnv && hasValidEmail(prodEmails));
// Compute a single normalized mode and pick the proper socket
const mode = getDmsMode(bodyshop, Fortellis.treatment); // "rr" | "fortellis" | "cdk" | "pbs" | "none"
@@ -164,19 +208,21 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse
const providerLabel = useMemo(
() =>
({
[DMS_MAP.reynolds]: "Reynolds",
[DMS_MAP.fortellis]: "Fortellis",
[DMS_MAP.cdk]: "CDK",
[DMS_MAP.pbs]: "PBS"
})[mode] || "DMS",
[mode]
[DMS_MAP.reynolds]: t("dms.labels.provider_reynolds"),
[DMS_MAP.fortellis]: t("dms.labels.provider_fortellis"),
[DMS_MAP.cdk]: t("dms.labels.provider_cdk"),
[DMS_MAP.pbs]: t("dms.labels.provider_pbs")
})[mode] || t("dms.labels.provider_dms"),
[mode, t]
);
const transportLabel = isWssMode(mode) ? "(WSS)" : "(WS)";
const transportLabel = isWssMode(mode) ? t("dms.labels.transport_wss") : t("dms.labels.transport_ws");
const bannerMessage = `Posting to ${providerLabel} | ${transportLabel} | ${
isConnected ? "Connected" : "Disconnected"
}`;
const bannerMessage = t("dms.labels.banner_message", {
provider: providerLabel,
transport: transportLabel,
status: isConnected ? t("dms.labels.banner_status_connected") : t("dms.labels.banner_status_disconnected")
});
const resetKey = useMemo(() => `${mode || "none"}-${jobId || "none"}`, [mode, jobId]);
const customerSelectorKey = useMemo(() => `${resetKey}-${reconnectNonce}`, [resetKey, reconnectNonce]);
@@ -239,6 +285,7 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse
}, [jobId, mode, activeSocket]);
const handleExportFailed = (payload = {}) => {
const safePayload = canViewSensitiveRrXml ? payload : stripRrXmlFromPayload(payload);
const { title, friendlyMessage, error: errText, severity, errorCode, vendorStatusCode } = payload;
const msg =
@@ -246,7 +293,7 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse
errText ||
t("dms.errors.exportfailedgeneric", "We couldn't complete the export. Please try again.");
const vendorTitle = title || (isRrMode ? "Reynolds" : "DMS");
const vendorTitle = title || (isRrMode ? t("dms.labels.provider_reynolds") : t("dms.labels.provider_dms"));
const isRrOpenRoLimit =
isRrMode &&
@@ -269,7 +316,7 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse
timestamp: new Date(),
level: (sev || "error").toUpperCase(),
message: `${vendorTitle}: ${msg}`,
meta: { errorCode, vendorStatusCode, raw: payload, blockedByOpenRoLimit: !!isRrOpenRoLimit }
meta: { errorCode, vendorStatusCode, raw: safePayload, blockedByOpenRoLimit: !!isRrOpenRoLimit }
}
]);
};
@@ -321,7 +368,9 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse
{
timestamp: new Date(),
level: "warn",
message: `Reconnected to ${isRrMode ? "RR" : mode === DMS_MAP.fortellis ? "Fortellis" : "DMS"} Export Service`
message: t("dms.labels.reconnected_export_service", {
provider: isRrMode ? t("dms.labels.provider_reynolds") : providerLabel
})
}
]);
};
@@ -340,11 +389,16 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse
// Logs
const onLog = isRrMode
? (payload = {}) => {
const safePayload = canViewSensitiveRrXml ? payload : stripRrXmlFromPayload(payload);
const normalized = {
timestamp: payload.timestamp ? new Date(payload.timestamp) : payload.ts ? new Date(payload.ts) : new Date(),
level: (payload.level || "INFO").toUpperCase(),
message: payload.message || payload.msg || "",
meta: payload.meta ?? payload.ctx ?? payload.details ?? null
timestamp: safePayload.timestamp
? new Date(safePayload.timestamp)
: safePayload.ts
? new Date(safePayload.ts)
: new Date(),
level: (safePayload.level || "INFO").toUpperCase(),
message: safePayload.message || safePayload.msg || "",
meta: safePayload.meta ?? safePayload.ctx ?? safePayload.details ?? null
};
setLogs((prev) => [...prev, normalized]);
}
@@ -380,14 +434,12 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse
{
timestamp: new Date(),
level: "INFO",
message:
"Repair Order created in Reynolds. Complete validation in Reynolds, then click Finished/Close to finalize."
message: t("dms.labels.rr_validation_message")
}
]);
notification.info({
title: "Reynolds RO created",
description:
"Complete validation in Reynolds, then click Finished/Close to finalize and mark this export complete.",
title: t("dms.labels.rr_validation_notice_title"),
description: t("dms.labels.rr_validation_notice_description"),
duration: 8
});
};
@@ -399,8 +451,7 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse
{
timestamp: new Date(),
level: "INFO",
message:
"Repair Order created in Reynolds. Complete validation in Reynolds, then click Finished/Close to finalize.",
message: t("dms.labels.rr_validation_message"),
meta: { payload }
}
]);
@@ -428,7 +479,19 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse
activeSocket.disconnect();
}
};
}, [mode, activeSocket, channels, logLevel, notification, t, insertAuditTrail, history]);
}, [
mode,
activeSocket,
channels,
logLevel,
notification,
t,
insertAuditTrail,
history,
isRrMode,
providerLabel,
canViewSensitiveRrXml
]);
// RR finalize callback (unchanged public behavior)
const handleRrValidationFinished = () => {
@@ -471,7 +534,7 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse
<AlertComponent style={{ marginBottom: 10 }} title={bannerMessage} type="warning" showIcon closable />
<Row gutter={[16, 16]}>
<Col md={24} lg={10} className="dms-equal-height-col">
<Col xs={24} xxl={10} className="dms-equal-height-col dms-top-panel-col">
{!isRrMode ? (
<DmsAllocationsSummary
key={resetKey}
@@ -511,7 +574,7 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse
)}
</Col>
<Col md={24} lg={14} className="dms-equal-height-col">
<Col xs={24} xxl={14} className="dms-equal-height-col dms-top-panel-col">
<DmsPostForm
key={resetKey}
socket={activeSocket}
@@ -550,15 +613,17 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse
<Switch
checked={colorizeJson}
onChange={setColorizeJson}
checkedChildren="Color JSON"
unCheckedChildren="Plain JSON"
checkedChildren={t("dms.labels.color_json")}
unCheckedChildren={t("dms.labels.plain_json")}
/>
<Button onClick={toggleDetailsAll}>{detailsOpen ? "Collapse All" : "Expand All"}</Button>
<Button onClick={toggleDetailsAll}>
{detailsOpen ? t("dms.labels.collapse_all") : t("dms.labels.expand_all")}
</Button>
</>
)}
<Select
placeholder="Log Level"
placeholder={t("dms.labels.log_level")}
value={logLevel}
onChange={(value) => {
setLogLevel(value);
@@ -572,8 +637,8 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse
{ key: "ERROR", value: "ERROR", label: "ERROR" }
]}
/>
<Button onClick={() => setLogs([])}>Clear Logs</Button>
<Button onClick={handleReconnectClick}>Reconnect</Button>
<Button onClick={() => setLogs([])}>{t("dms.labels.clear_logs")}</Button>
<Button onClick={handleReconnectClick}> {t("dms.labels.reconnect")}</Button>
</Space>
}
>
@@ -585,6 +650,7 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse
detailsNonce={detailsNonce}
colorizeJson={isRrMode ? colorizeJson : false}
showDetails={isRrMode}
allowXmlPayload={canViewSensitiveRrXml}
/>
</Card>
</div>

View File

@@ -1074,7 +1074,36 @@
"earlyrorequired.message": "This job requires an early Repair Order to be created before posting to Reynolds. Please use the admin panel to create the early RO first."
},
"labels": {
"refreshallocations": "Refresh to see DMS Allocations."
"refreshallocations": "Refresh to see DMS Allocations.",
"provider_reynolds": "Reynolds",
"provider_fortellis": "Fortellis",
"provider_cdk": "CDK",
"provider_pbs": "PBS",
"provider_dms": "DMS",
"transport_wss": "(WSS)",
"transport_ws": "(WS)",
"banner_status_connected": "Connected",
"banner_status_disconnected": "Disconnected",
"banner_message": "Posting to {{provider}} | {{transport}} | {{status}}",
"reconnected_export_service": "Reconnected to {{provider}} Export Service",
"rr_validation_message": "Repair Order created in Reynolds. Complete validation in Reynolds, then click Finished/Close to finalize.",
"rr_validation_notice_title": "Reynolds RO created",
"rr_validation_notice_description": "Complete validation in Reynolds, then click Finished/Close to finalize and mark this export complete.",
"color_json": "Color JSON",
"plain_json": "Plain JSON",
"collapse_all": "Collapse All",
"expand_all": "Expand All",
"log_level": "Log Level",
"clear_logs": "Clear Logs",
"reconnect": "Reconnect",
"details": "Details",
"hide_details": "Hide details",
"copy": "Copy",
"copied": "Copied",
"copy_request": "Copy Request",
"copy_response": "Copy Response",
"request_xml": "Request XML",
"response_xml": "Response XML"
}
},
"documents": {

View File

@@ -1074,7 +1074,36 @@
"earlyrorequired.message": ""
},
"labels": {
"refreshallocations": ""
"refreshallocations": "",
"provider_reynolds": "",
"provider_fortellis": "",
"provider_cdk": "",
"provider_pbs": "",
"provider_dms": "",
"transport_wss": "",
"transport_ws": "",
"banner_status_connected": "",
"banner_status_disconnected": "",
"banner_message": "",
"reconnected_export_service": "",
"rr_validation_message": "",
"rr_validation_notice_title": "",
"rr_validation_notice_description": "",
"color_json": "",
"plain_json": "",
"collapse_all": "",
"expand_all": "",
"log_level": "",
"clear_logs": "",
"reconnect": "",
"details": "",
"hide_details": "",
"copy": "",
"copied": "",
"copy_request": "",
"copy_response": "",
"request_xml": "",
"response_xml": ""
}
},
"documents": {

View File

@@ -1074,7 +1074,36 @@
"earlyrorequired.message": ""
},
"labels": {
"refreshallocations": ""
"refreshallocations": "",
"provider_reynolds": "",
"provider_fortellis": "",
"provider_cdk": "",
"provider_pbs": "",
"provider_dms": "",
"transport_wss": "",
"transport_ws": "",
"banner_status_connected": "",
"banner_status_disconnected": "",
"banner_message": "",
"reconnected_export_service": "",
"rr_validation_message": "",
"rr_validation_notice_title": "",
"rr_validation_notice_description": "",
"color_json": "",
"plain_json": "",
"collapse_all": "",
"expand_all": "",
"log_level": "",
"clear_logs": "",
"reconnect": "",
"details": "",
"hide_details": "",
"copy": "",
"copied": "",
"copy_request": "",
"copy_response": "",
"request_xml": "",
"response_xml": ""
}
},
"documents": {

View File

@@ -1,12 +1,12 @@
const Fuse = require('fuse.js');
const { has } = require("lodash");
const { standardizedFieldsnames } = require('./bill-ocr-normalize');
const InstanceManager = require("../../utils/instanceMgr").default;
const PRICE_PERCENT_MARGIN_TOLERANCE = 0.5; //Used to make sure prices and costs are likely.
const PRICE_QUANTITY_MARGIN_TOLERANCE = 0.03; //Used to make sure that if there is a quantity, the price is likely a unit price.
// Helper function to normalize fields
const normalizePartNumber = (str) => {
return str.replace(/[^a-zA-Z0-9]/g, '').toUpperCase();
@@ -17,7 +17,38 @@ const normalizeText = (str) => {
};
const normalizePrice = (str) => {
if (typeof str !== 'string') return str;
return str.replace(/[^0-9.-]+/g, "");
let value = str.trim();
// Handle European-style decimal comma like "292,37".
// Only treat the *last* comma as a decimal separator when:
// - there's no '.' anywhere (so we don't fight normal US formatting like "1,234.56")
// - and the suffix after the last comma is 1-2 digits (so "1,234" stays 1234)
if (!value.includes('.') && value.includes(',')) {
const lastCommaIndex = value.lastIndexOf(',');
const decimalSuffix = value.slice(lastCommaIndex + 1).trim();
if (/^\d{1,2}$/.test(decimalSuffix)) {
const before = value.slice(0, lastCommaIndex).replace(/,/g, '');
value = `${before}.${decimalSuffix}`;
} else {
// Treat commas as thousands separators (or noise) and drop them.
value = value.replace(/,/g, '');
}
}
return value.replace(/[^0-9.-]+/g, "");
};
const roundToIncrement = (value, increment) => {
if (typeof value !== 'number' || !isFinite(value) || typeof increment !== 'number' || !isFinite(increment) || increment <= 0) {
return value;
}
const rounded = Math.round((value + Number.EPSILON) / increment) * increment;
// Prevent float artifacts (e.g. 0.20500000000000002)
const decimals = Math.max(0, Math.ceil(-Math.log10(increment)));
return parseFloat(rounded.toFixed(decimals));
};
//More complex function. Not necessary at the moment, keeping for reference.
@@ -134,6 +165,7 @@ const calculateTextractConfidence = (textractLineItem) => {
const hasActualCost = Object.values(textractLineItem).some(field => field.normalizedLabel === standardizedFieldsnames.actual_cost);
const hasActualPrice = Object.values(textractLineItem).some(field => field.normalizedLabel === standardizedFieldsnames.actual_price);
const hasLineDesc = Object.values(textractLineItem).some(field => field.normalizedLabel === standardizedFieldsnames.line_desc);
const hasQuantity = textractLineItem?.QUANTITY?.value; //We don't normalize quantity, we just use what textract gives us.
// Calculate weighted average, giving more weight to important fields
// If we can identify key fields (ITEM, PRODUCT_CODE, PRICE), weight them higher
@@ -173,10 +205,11 @@ const calculateTextractConfidence = (textractLineItem) => {
if (!hasActualCost) missingCount++;
if (!hasActualPrice) missingCount++;
if (!hasLineDesc) missingCount++;
if (!hasQuantity) missingCount++;
// Each missing field reduces confidence by 15%
// Each missing field reduces confidence by 20%
if (missingCount > 0) {
missingFieldsPenalty = 1.0 - (missingCount * 0.15);
missingFieldsPenalty = 1.0 - (missingCount * 0.20);
}
avgConfidence = avgConfidence * missingFieldsPenalty;
@@ -361,16 +394,16 @@ async function generateBillFormData({ processedData, jobid: jobidFromProps, body
const joblineMatches = joblineFuzzySearch({ fuseToSearch: jobLineDescFuse, processedData });
const vendorFuse = new Fuse(
jobData.vendors,
jobData.vendors.map(v => ({ ...v, name_normalized: normalizeText(v.name) })),
{
keys: ['name'],
threshold: 0.4, //Adjust as needed for matching sensitivity,
keys: [{ name: "name", weight: 3 }, { name: 'name_normalized', weight: 2 }],
threshold: 0.4,
includeScore: true,
},
}
);
const vendorMatches = vendorFuse.search(processedData.summary?.VENDOR_NAME?.value || processedData.summary?.NAME?.value);
const vendorMatches = vendorFuse.search(normalizeText(processedData.summary?.VENDOR_NAME?.value || processedData.summary?.NAME?.value));
let vendorid;
if (vendorMatches.length > 0) {
@@ -381,6 +414,21 @@ async function generateBillFormData({ processedData, jobid: jobidFromProps, body
throw new Error('Job not found for bill form data generation.');
}
//Is there a subtotal level discount? If there is, we need to figure out what the percentage is, and apply that to the actual cost as a reduction
const subtotalDiscountValueRaw = processedData.summary?.DISCOUNT?.value || processedData.summary?.SUBTOTAL_DISCOUNT?.value || 0;
let discountPercentageDecimal = 0;
if (subtotalDiscountValueRaw) {
const subtotal = parseFloat(normalizePrice(processedData.summary?.SUBTOTAL?.value || 0)) || 0;
const subtotalDiscountValue = parseFloat(normalizePrice(subtotalDiscountValueRaw)) || 0;
if (subtotal > 0 && subtotalDiscountValue) {
// Store discount percentage as a decimal (e.g. 20.5% => 0.205),
// but only allow half-percent increments (0.005 steps).
discountPercentageDecimal = Math.abs(subtotalDiscountValue / subtotal);
discountPercentageDecimal = roundToIncrement(discountPercentageDecimal, 0.005);
}
}
//TODO: How do we handle freight lines and core charges?
//Create the form data structure for the bill posting screen.
const billFormData = {
@@ -448,6 +496,31 @@ async function generateBillFormData({ processedData, jobid: jobidFromProps, body
}
}
//If there's nothing, just fall back to seeing if there's a price object from textract.
if (!actualPrice && textractLineItem.PRICE) {
actualPrice = textractLineItem.PRICE.value;
}
if (!actualCost && textractLineItem.PRICE) {
actualCost = textractLineItem.PRICE.value;
}
//If quantity greater than 1, check if the actual cost is a multiple of the actual price, if so, divide it out to get the unit price.
const quantity = parseInt(textractLineItem?.QUANTITY?.value);
if (quantity && quantity > 1) {
if (actualPrice && quantity && Math.abs((actualPrice / quantity) - (parseFloat(matchToUse?.item?.act_price) || 0)) / ((parseFloat(matchToUse?.item?.act_price) || 1)) < PRICE_QUANTITY_MARGIN_TOLERANCE) {
actualPrice = actualPrice / quantity;
}
if (actualCost && quantity && Math.abs((actualCost / quantity) - (parseFloat(matchToUse?.item?.act_price) || 0)) / ((parseFloat(matchToUse?.item?.act_price) || 1)) < PRICE_QUANTITY_MARGIN_TOLERANCE) {
actualCost = actualCost / quantity;
}
}
if (discountPercentageDecimal > 0) {
actualCost = actualCost * (1 - discountPercentageDecimal);
}
const responsibilityCenters = job.bodyshop.md_responsibility_centers
//TODO: Do we need to verify the lines to see if it is a unit price or total price (i.e. quantity * price)
const lineObject = {
@@ -714,5 +787,6 @@ const bodyshopHasDmsKey = (bodyshop) =>
module.exports = {
generateBillFormData
generateBillFormData,
normalizePrice
}

View File

@@ -50,10 +50,12 @@ function normalizeLabelName(labelText) {
'unit_price': standardizedFieldsnames.actual_price,
'list': standardizedFieldsnames.actual_price,
'retail_price': standardizedFieldsnames.actual_price,
'retail': standardizedFieldsnames.actual_price,
'net': standardizedFieldsnames.actual_cost,
'selling_price': standardizedFieldsnames.actual_cost,
'net_price': standardizedFieldsnames.actual_cost,
'net_cost': standardizedFieldsnames.actual_cost,
'total': standardizedFieldsnames.actual_cost,
'po_no': standardizedFieldsnames.ro_number,
'customer_po_no': standardizedFieldsnames.ro_number,
'customer_po_no_': standardizedFieldsnames.ro_number

View File

@@ -6,6 +6,7 @@ const { getTextractJobKey, setTextractJob, getTextractJob, getFileType, getPdfPa
const { extractInvoiceData, processScanData } = require("./bill-ocr-normalize");
const { generateBillFormData } = require("./bill-ocr-generator");
const logger = require("../../utils/logger");
const _ = require("lodash");
// Initialize AWS clients
const awsConfig = {
@@ -66,7 +67,7 @@ async function handleBillOcr(req, res) {
if (fileType === 'image') {
const processedData = await processSinglePageDocument(uploadedFile.buffer);
const billForm = await generateBillFormData({ processedData: processedData, jobid, bodyshopid, partsorderid, req: req });
logger.log("bill-ocr-single-complete", "DEBUG", req.user.email, jobid, { ...processedData, billForm });
logger.log("bill-ocr-single-complete", "DEBUG", req.user.email, jobid, { ..._.omit(processedData, "originalTextractResponse"), billForm });
return res.status(200).json({
success: true,
@@ -82,7 +83,7 @@ async function handleBillOcr(req, res) {
// Process synchronously for single-page documents
const processedData = await processSinglePageDocument(uploadedFile.buffer);
const billForm = await generateBillFormData({ processedData: processedData, jobid, bodyshopid, partsorderid, req: req });
logger.log("bill-ocr-single-complete", "DEBUG", req.user.email, jobid, { ...processedData, billForm });
logger.log("bill-ocr-single-complete", "DEBUG", req.user.email, jobid, { ..._.omit(processedData, "originalTextractResponse"), billForm });
return res.status(200).json({
success: true,
status: 'COMPLETED',

View File

@@ -250,6 +250,8 @@ const CreateRepairOrderTag = (job, errorCallback) => {
},
InsuranceCompany: job.ins_co_nm || "",
Claim: job.clm_no || "",
Deductible: job.ded_amt || 0,
PolicyNo: job.policy_no || "",
DMSAllocation: job.dms_allocation || "",
Contacts: {
CSR: job.employee_csr_rel

View File

@@ -1285,6 +1285,7 @@ exports.KAIZEN_QUERY = `query KAIZEN_EXPORT($start: timestamptz, $bodyshopid: uu
date_repairstarted
date_void
dms_allocation
ded_amt
employee_body_rel {
first_name
last_name
@@ -1380,6 +1381,7 @@ exports.KAIZEN_QUERY = `query KAIZEN_EXPORT($start: timestamptz, $bodyshopid: uu
}
parts_tax_rates
plate_no
policy_no
rate_la1
rate_la2
rate_la3

View File

@@ -48,6 +48,46 @@ const resolveJobId = (explicit, payload, job) => explicit || payload?.jobId || j
*/
const resolveVin = ({ tx, job }) => tx?.jobData?.vin || job?.v_vin || null;
/**
* Extract request/response XML from RR response/result shapes.
* @param rrObj
* @returns {{requestXml: string|null, responseXml: string|null}}
*/
const extractRRXmlPair = (rrObj) => {
const xml = rrObj?.xml;
let requestXml = null;
let responseXml = null;
if (typeof xml === "string") {
requestXml = xml;
} else {
if (typeof xml?.request === "string") requestXml = xml.request;
else if (typeof xml?.req === "string") requestXml = xml.req;
else if (typeof xml?.starXml === "string") requestXml = xml.starXml;
if (typeof xml?.response === "string") responseXml = xml.response;
}
if (!requestXml && typeof rrObj?.requestXml === "string") requestXml = rrObj.requestXml;
if (!responseXml && typeof rrObj?.responseXml === "string") responseXml = rrObj.responseXml;
return { requestXml, responseXml };
};
/**
* Add Reynolds request/response XML to RR log metadata when available.
* @param rrObj
* @param meta
* @returns {*}
*/
const withRRRequestXml = (rrObj, meta = {}) => {
const { requestXml, responseXml } = extractRRXmlPair(rrObj);
const xmlMeta = {};
if (requestXml) xmlMeta.requestXml = requestXml;
if (responseXml) xmlMeta.responseXml = responseXml;
return Object.keys(xmlMeta).length ? { ...meta, ...xmlMeta } : meta;
};
/**
* Sort vehicle owners first in the list, preserving original order otherwise.
* @param list
@@ -154,15 +194,13 @@ const setJobDmsIdForSocket = async ({ socket, jobId, dmsId, dmsCustomerId, dmsAd
if (!token) throw new Error("Missing auth token for setJobDmsIdForSocket");
const client = new GraphQLClient(endpoint, {});
await client
.setHeaders({ Authorization: `Bearer ${token}` })
.request(queries.SET_JOB_DMS_ID, {
id: jobId,
dms_id: String(dmsId),
dms_customer_id: dmsCustomerId ? String(dmsCustomerId) : null,
dms_advisor_id: dmsAdvisorId ? String(dmsAdvisorId) : null,
kmin: mileageIn != null && mileageIn > 0 ? parseInt(mileageIn, 10) : null
});
await client.setHeaders({ Authorization: `Bearer ${token}` }).request(queries.SET_JOB_DMS_ID, {
id: jobId,
dms_id: String(dmsId),
dms_customer_id: dmsCustomerId ? String(dmsCustomerId) : null,
dms_advisor_id: dmsAdvisorId ? String(dmsAdvisorId) : null,
kmin: mileageIn != null && mileageIn > 0 ? parseInt(mileageIn, 10) : null
});
CreateRRLogEvent(socket, "INFO", "Linked job.dms_id to RR RO", {
jobId,
@@ -511,7 +549,11 @@ const registerRREvents = ({ socket, redisHelpers }) => {
});
// Filter out invalid values
if (selectedCustNo === "undefined" || selectedCustNo === "null" || (selectedCustNo && selectedCustNo.trim() === "")) {
if (
selectedCustNo === "undefined" ||
selectedCustNo === "null" ||
(selectedCustNo && selectedCustNo.trim() === "")
) {
selectedCustNo = null;
}
@@ -705,42 +747,52 @@ const registerRREvents = ({ socket, redisHelpers }) => {
const outsdRoNo = data?.outsdRoNo ?? job?.ro_number ?? job?.id ?? null;
CreateRRLogEvent(socket, "DEBUG", "Early RO created - checking dmsRoNo", {
dmsRoNo,
resultRoNo: result?.roNo,
dataRoNo: data?.dmsRoNo,
jobId: rid
});
CreateRRLogEvent(
socket,
"DEBUG",
"Early RO created - checking dmsRoNo",
withRRRequestXml(result, {
dmsRoNo,
resultRoNo: result?.roNo,
dataRoNo: data?.dmsRoNo,
jobId: rid
})
);
// ✅ Persist DMS RO number, customer ID, advisor ID, and mileage on the job
if (dmsRoNo) {
const mileageIn = txEnvelope?.kmin ?? null;
CreateRRLogEvent(socket, "DEBUG", "Calling setJobDmsIdForSocket", {
jobId: rid,
CreateRRLogEvent(socket, "DEBUG", "Calling setJobDmsIdForSocket", {
jobId: rid,
dmsId: dmsRoNo,
customerId: effectiveCustNo,
advisorId: String(advisorNo),
mileageIn
});
await setJobDmsIdForSocket({
socket,
jobId: rid,
await setJobDmsIdForSocket({
socket,
jobId: rid,
dmsId: dmsRoNo,
dmsCustomerId: effectiveCustNo,
dmsAdvisorId: String(advisorNo),
mileageIn
});
} else {
CreateRRLogEvent(socket, "WARN", "RR early RO creation succeeded but no DMS RO number was returned", {
jobId: rid,
resultPreview: {
roNo: result?.roNo,
data: {
dmsRoNo: data?.dmsRoNo,
outsdRoNo: data?.outsdRoNo
CreateRRLogEvent(
socket,
"WARN",
"RR early RO creation succeeded but no DMS RO number was returned",
withRRRequestXml(result, {
jobId: rid,
resultPreview: {
roNo: result?.roNo,
data: {
dmsRoNo: data?.dmsRoNo,
outsdRoNo: data?.outsdRoNo
}
}
}
});
})
);
}
await redisHelpers.setSessionTransactionData(
@@ -758,10 +810,15 @@ const registerRREvents = ({ socket, redisHelpers }) => {
defaultRRTTL
);
CreateRRLogEvent(socket, "INFO", `{EARLY-5} Minimal RO created successfully`, {
dmsRoNo: dmsRoNo || null,
outsdRoNo: outsdRoNo || null
});
CreateRRLogEvent(
socket,
"INFO",
`{EARLY-5} Minimal RO created successfully`,
withRRRequestXml(result, {
dmsRoNo: dmsRoNo || null,
outsdRoNo: outsdRoNo || null
})
);
// Mark success in export logs
await markRRExportSuccess({
@@ -810,11 +867,16 @@ const registerRREvents = ({ socket, redisHelpers }) => {
message: vendorMessage
});
CreateRRLogEvent(socket, "ERROR", `Early RO creation failed`, {
roStatus: result?.roStatus,
statusBlocks: result?.statusBlocks,
classification: cls
});
CreateRRLogEvent(
socket,
"ERROR",
`Early RO creation failed`,
withRRRequestXml(result, {
roStatus: result?.roStatus,
statusBlocks: result?.statusBlocks,
classification: cls
})
);
await insertRRFailedExportLog({
socket,
@@ -940,14 +1002,14 @@ const registerRREvents = ({ socket, redisHelpers }) => {
// Check if this job already has an early RO - if so, use stored IDs and skip customer search
const hasEarlyRO = !!job?.dms_id;
if (hasEarlyRO) {
CreateRRLogEvent(socket, "DEBUG", `{2} Early RO exists - using stored customer/advisor`, {
dms_id: job.dms_id,
dms_customer_id: job.dms_customer_id,
dms_advisor_id: job.dms_advisor_id
});
// Cache the stored customer/advisor IDs for the next step
if (job.dms_customer_id) {
await redisHelpers.setSessionTransactionData(
@@ -967,18 +1029,18 @@ const registerRREvents = ({ socket, redisHelpers }) => {
defaultRRTTL
);
}
// Emit empty customer list to frontend (won't show modal)
socket.emit("rr-select-customer", []);
// Continue directly with the export by calling the selected customer handler logic inline
// This is essentially the same as if user selected the stored customer
const selectedCustNo = job.dms_customer_id;
if (!selectedCustNo) {
throw new Error("Early RO exists but no customer ID stored");
}
// Continue with ensureRRServiceVehicle and export (same as rr-selected-customer handler)
const { client, opts } = await buildClientAndOpts(bodyshop);
const routing = opts?.routing || client?.opts?.routing || null;
@@ -1011,7 +1073,12 @@ const registerRREvents = ({ socket, redisHelpers }) => {
redisHelpers
});
const advisorNo = job.dms_advisor_id || readAdvisorNo({ txEnvelope }, await redisHelpers.getSessionTransactionData(socket.id, getTransactionType(rid), RRCacheEnums.AdvisorNo));
const advisorNo =
job.dms_advisor_id ||
readAdvisorNo(
{ txEnvelope },
await redisHelpers.getSessionTransactionData(socket.id, getTransactionType(rid), RRCacheEnums.AdvisorNo)
);
if (!advisorNo) {
throw new Error("Advisor is required (advisorNo).");
@@ -1059,15 +1126,20 @@ const registerRREvents = ({ socket, redisHelpers }) => {
defaultRRTTL
);
CreateRRLogEvent(socket, "INFO", `RR Repair Order updated successfully`, {
dmsRoNo,
jobId: rid
});
CreateRRLogEvent(
socket,
"INFO",
`RR Repair Order updated successfully`,
withRRRequestXml(result, {
dmsRoNo,
jobId: rid
})
);
// For early RO flow, only emit validation-required (not export-job:result)
// since the export is not complete yet - we're just waiting for validation
socket.emit("rr-validation-required", { dmsRoNo, jobId: rid });
return ack?.({ ok: true, skipCustomerSelection: true, dmsRoNo });
}
@@ -1277,25 +1349,25 @@ const registerRREvents = ({ socket, redisHelpers }) => {
// When updating an early RO, use stored customer/advisor IDs
let finalEffectiveCustNo = effectiveCustNo;
let finalAdvisorNo = advisorNo;
if (shouldUpdate && job?.dms_customer_id) {
CreateRRLogEvent(socket, "DEBUG", `Using stored customer ID from early RO`, {
CreateRRLogEvent(socket, "DEBUG", `Using stored customer ID from early RO`, {
storedCustomerId: job.dms_customer_id,
originalCustomerId: effectiveCustNo
originalCustomerId: effectiveCustNo
});
finalEffectiveCustNo = String(job.dms_customer_id);
}
if (shouldUpdate && job?.dms_advisor_id) {
CreateRRLogEvent(socket, "DEBUG", `Using stored advisor ID from early RO`, {
CreateRRLogEvent(socket, "DEBUG", `Using stored advisor ID from early RO`, {
storedAdvisorId: job.dms_advisor_id,
originalAdvisorId: advisorNo
originalAdvisorId: advisorNo
});
finalAdvisorNo = String(job.dms_advisor_id);
}
let result;
if (shouldUpdate) {
// UPDATE existing RO with full data
CreateRRLogEvent(socket, "DEBUG", `{4} Updating existing RR RO with full data`, { dmsRoNo: existingDmsId });
@@ -1344,16 +1416,21 @@ const registerRREvents = ({ socket, redisHelpers }) => {
if (dmsRoNo) {
await setJobDmsIdForSocket({ socket, jobId: rid, dmsId: dmsRoNo });
} else {
CreateRRLogEvent(socket, "WARN", "RR export succeeded but no DMS RO number was returned", {
jobId: rid,
resultPreview: {
roNo: result?.roNo,
data: {
dmsRoNo: data?.dmsRoNo,
outsdRoNo: data?.outsdRoNo
CreateRRLogEvent(
socket,
"WARN",
"RR export succeeded but no DMS RO number was returned",
withRRRequestXml(result, {
jobId: rid,
resultPreview: {
roNo: result?.roNo,
data: {
dmsRoNo: data?.dmsRoNo,
outsdRoNo: data?.outsdRoNo
}
}
}
});
})
);
}
await redisHelpers.setSessionTransactionData(
@@ -1370,10 +1447,15 @@ const registerRREvents = ({ socket, redisHelpers }) => {
defaultRRTTL
);
CreateRRLogEvent(socket, "INFO", `{5} RO created. Waiting for validation.`, {
dmsRoNo: dmsRoNo || null,
outsdRoNo: outsdRoNo || null
});
CreateRRLogEvent(
socket,
"INFO",
`{5} RO created. Waiting for validation.`,
withRRRequestXml(result, {
dmsRoNo: dmsRoNo || null,
outsdRoNo: outsdRoNo || null
})
);
// Tell FE to prompt for "Finished/Close"
socket.emit("rr-validation-required", { jobId: rid, dmsRoNo, outsdRoNo });
@@ -1412,11 +1494,16 @@ const registerRREvents = ({ socket, redisHelpers }) => {
message: vendorMessage
});
CreateRRLogEvent(socket, "ERROR", `Export failed (step 1)`, {
roStatus: result?.roStatus,
statusBlocks: result?.statusBlocks,
classification: cls
});
CreateRRLogEvent(
socket,
"ERROR",
`Export failed (step 1)`,
withRRRequestXml(result, {
roStatus: result?.roStatus,
statusBlocks: result?.statusBlocks,
classification: cls
})
);
await insertRRFailedExportLog({
socket,
@@ -1541,7 +1628,12 @@ const registerRREvents = ({ socket, redisHelpers }) => {
});
if (finalizeResult?.success) {
CreateRRLogEvent(socket, "INFO", `{7} Finalize success; marking exported`, { dmsRoNo, outsdRoNo });
CreateRRLogEvent(
socket,
"INFO",
`{7} Finalize success; marking exported`,
withRRRequestXml(finalizeResult, { dmsRoNo, outsdRoNo })
);
// ✅ Mark exported + success log
await markRRExportSuccess({
@@ -1584,6 +1676,17 @@ const registerRREvents = ({ socket, redisHelpers }) => {
message: vendorMessage
});
CreateRRLogEvent(
socket,
"ERROR",
"Finalize failed",
withRRRequestXml(finalizeResult, {
roStatus: finalizeResult?.roStatus,
statusBlocks: finalizeResult?.statusBlocks,
classification: cls
})
);
await insertRRFailedExportLog({
socket,
jobId: rid,