diff --git a/client/src/components/dms-customer-selector/dms-customer-selector.component.jsx b/client/src/components/dms-customer-selector/dms-customer-selector.component.jsx
index 205ceb414..86e08e803 100644
--- a/client/src/components/dms-customer-selector/dms-customer-selector.component.jsx
+++ b/client/src/components/dms-customer-selector/dms-customer-selector.component.jsx
@@ -23,13 +23,13 @@ export default connect(mapStateToProps, mapDispatchToProps)(DmsCustomerSelector)
* @constructor
*/
export function DmsCustomerSelector(props) {
- const { bodyshop, jobid, socket, rrOptions = {} } = props;
+ const { bodyshop, jobid, job, socket, rrOptions = {} } = props;
// Centralized "mode" (provider + transport)
const mode = props.mode;
// Stable base props for children
- const base = useMemo(() => ({ bodyshop, jobid, socket }), [bodyshop, jobid, socket]);
+ const base = useMemo(() => ({ bodyshop, jobid, job, socket }), [bodyshop, jobid, job, socket]);
switch (mode) {
case DMS_MAP.reynolds: {
diff --git a/client/src/components/dms-customer-selector/rr-customer-selector.jsx b/client/src/components/dms-customer-selector/rr-customer-selector.jsx
index 3622f61f1..ad5fbd4bf 100644
--- a/client/src/components/dms-customer-selector/rr-customer-selector.jsx
+++ b/client/src/components/dms-customer-selector/rr-customer-selector.jsx
@@ -1,4 +1,4 @@
-import { Alert, Button, Checkbox, Col, message, Space, Table } from "antd";
+import { Alert, Button, Checkbox, message, Modal, Space, Table } from "antd";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { alphaSort } from "../../utils/sorters";
@@ -47,6 +47,7 @@ const rrAddressToString = (addr) => {
export default function RRCustomerSelector({
jobid,
socket,
+ job,
rrOpenRoLimit = false,
onRrOpenRoFinished,
rrValidationPending = false,
@@ -59,15 +60,26 @@ export default function RRCustomerSelector({
const [refreshing, setRefreshing] = useState(false);
// Show dialog automatically when validation is pending
+ // BUT: skip this for early RO flow (job already has dms_id)
useEffect(() => {
- if (rrValidationPending) setOpen(true);
- }, [rrValidationPending]);
+ if (rrValidationPending && !job?.dms_id) {
+ setOpen(true);
+ }
+ }, [rrValidationPending, job?.dms_id]);
// Listen for RR customer selection list
useEffect(() => {
if (!socket) return;
const handleRrSelectCustomer = (list) => {
const normalized = normalizeRrList(list);
+
+ // If list is empty, it means early RO exists and customer selection should be skipped
+ // Don't open the modal in this case
+ if (normalized.length === 0) {
+ setRefreshing(false);
+ return;
+ }
+
setOpen(true);
setCustomerList(normalized);
const firstOwner = normalized.find((r) => r.vinOwner)?.custNo;
@@ -127,6 +139,10 @@ export default function RRCustomerSelector({
});
};
+ const handleClose = () => {
+ setOpen(false);
+ };
+
const refreshRrSearch = () => {
setRefreshing(true);
const to = setTimeout(() => setRefreshing(false), 12000);
@@ -141,8 +157,6 @@ export default function RRCustomerSelector({
socket.emit("rr-export-job", { jobId: jobid });
};
- if (!open) return null;
-
const columns = [
{ title: t("jobs.fields.dms.id"), dataIndex: "custNo", key: "custNo" },
{
@@ -169,8 +183,45 @@ export default function RRCustomerSelector({
return !rrOwnerSet.has(String(record.custNo));
};
+ // For early RO flow: show validation banner even when modal is closed
+ if (!open) {
+ if (rrValidationPending && job?.dms_id) {
+ return (
+
+
+
+ We created the Repair Order. Please validate the totals and taxes in the DMS system. When done,
+ click Finished to finalize and mark this export as complete.
+
+
+
+
+ Finished
+
+
+
+
+ }
+ />
+
+ );
+ }
+ return null;
+ }
+
return (
-
+
(
@@ -196,8 +247,8 @@ export default function RRCustomerSelector({
/>
)}
- {/* Validation step banner */}
- {rrValidationPending && (
+ {/* Validation step banner - only show for NON-early RO flow (legacy) */}
+ {rrValidationPending && !job?.dms_id && (
({ disabled: rrDisableRow(record) })
}}
/>
-
+
);
}
diff --git a/client/src/components/dms-log-events/dms-log-events.component.jsx b/client/src/components/dms-log-events/dms-log-events.component.jsx
index 25f8c27ff..d90623df6 100644
--- a/client/src/components/dms-log-events/dms-log-events.component.jsx
+++ b/client/src/components/dms-log-events/dms-log-events.component.jsx
@@ -69,7 +69,7 @@ export function DmsLogEvents({
return {
key: idx,
color: logLevelColor(level),
- children: (
+ content: (
{/* Row 1: summary + inline "Details" toggle */}
@@ -113,7 +113,7 @@ export function DmsLogEvents({
[logs, openSet, colorizeJson, isDarkMode, showDetails]
);
- return ;
+ return ;
}
/**
diff --git a/client/src/components/dms-post-form/rr-dms-post-form.jsx b/client/src/components/dms-post-form/rr-dms-post-form.jsx
index 6cdea243e..a1d8154e6 100644
--- a/client/src/components/dms-post-form/rr-dms-post-form.jsx
+++ b/client/src/components/dms-post-form/rr-dms-post-form.jsx
@@ -208,8 +208,18 @@ export default function RRPostForm({
});
};
+ // Check if early RO was created (job has dms_id)
+ const hasEarlyRO = !!job?.dms_id;
+
return (
+ {hasEarlyRO && (
+
+ ✅ Early RO Created: {job.dms_id}
+
+ This will update the existing RO with full job data.
+
+ )}
- }
- >
-
-
-
-
-
-
-
-
-
-
-
-
-
+ aria-label={t("general.actions.refresh")}
+ icon={ }
+ onClick={() => fetchRrAdvisors(true)}
+ loading={advLoading}
+ />
+
+
+
+
+ )}
+
+ {/* RR OpCode (prefix / base / suffix) - Only show if no early RO */}
+ {!hasEarlyRO && (
+
+
+ {t("jobs.fields.dms.rr_opcode", "RR OpCode")}
+ {isCustomOpCode && (
+ }
+ onClick={handleResetOpCode}
+ style={{ padding: 0 }}
+ >
+ {t("jobs.fields.dms.rr_opcode_reset", "Reset")}
+
+ )}
+
+ }
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )}
@@ -355,13 +365,14 @@ export default function RRPostForm({
{/* Validation */}
{() => {
- const advisorOk = !!form.getFieldValue("advisorNo");
+ // When early RO exists, advisor is already set, so we don't need to validate it
+ const advisorOk = hasEarlyRO ? true : !!form.getFieldValue("advisorNo");
return (
=
-
- {t("jobs.actions.dms.post")}
+
+ {hasEarlyRO ? t("jobs.actions.dms.update_ro", "Update RO") : t("jobs.actions.dms.post")}
);
diff --git a/client/src/components/dms-post-form/rr-early-ro-form.jsx b/client/src/components/dms-post-form/rr-early-ro-form.jsx
new file mode 100644
index 000000000..cda20e543
--- /dev/null
+++ b/client/src/components/dms-post-form/rr-early-ro-form.jsx
@@ -0,0 +1,367 @@
+import { ReloadOutlined } from "@ant-design/icons";
+import { Alert, Button, Form, Input, InputNumber, Modal, Radio, Select, Space, Table, Typography } from "antd";
+import { useEffect, useMemo, useState } from "react";
+
+// Simple customer selector table
+function CustomerSelectorTable({ customers, onSelect, isSubmitting }) {
+ const [selectedCustNo, setSelectedCustNo] = useState(null);
+
+ const columns = [
+ {
+ title: "Select",
+ key: "select",
+ width: 80,
+ render: (_, record) => (
+ setSelectedCustNo(record.custNo)} />
+ )
+ },
+ { title: "Customer ID", dataIndex: "custNo", key: "custNo" },
+ { title: "Name", dataIndex: "name", key: "name" },
+ {
+ title: "VIN Owner",
+ key: "vinOwner",
+ render: (_, record) => (record.vinOwner || record.isVehicleOwner ? "Yes" : "No")
+ }
+ ];
+
+ return (
+
+
+
+ onSelect(selectedCustNo, false)}
+ disabled={!selectedCustNo || isSubmitting}
+ loading={isSubmitting}
+ >
+ Use Selected Customer
+
+ onSelect(null, true)} disabled={isSubmitting} loading={isSubmitting}>
+ Create New Customer
+
+
+
+ );
+}
+
+/**
+ * RR Early RO Creation Form
+ * Used from convert button or admin page to create minimal RO before full export
+ * @param bodyshop
+ * @param socket
+ * @param job
+ * @param onSuccess - callback when RO is created successfully
+ * @param onCancel - callback to close modal
+ * @param showCancelButton - whether to show cancel button
+ * @returns {JSX.Element}
+ * @constructor
+ */
+export default function RREarlyROForm({ bodyshop, socket, job, onSuccess, onCancel, showCancelButton = true }) {
+ const [form] = Form.useForm();
+
+ // Advisors
+ const [advisors, setAdvisors] = useState([]);
+ const [advLoading, setAdvLoading] = useState(false);
+
+ // Customer selection
+ const [customerCandidates, setCustomerCandidates] = useState([]);
+ const [showCustomerSelector, setShowCustomerSelector] = useState(false);
+
+ // Loading and success states
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const [earlyRoCreated, setEarlyRoCreated] = useState(!!job?.dms_id);
+ const [createdRoNumber, setCreatedRoNumber] = useState(job?.dms_id || null);
+
+ // Derive default OpCode parts from bodyshop config (matching dms.container.jsx logic)
+ const initialValues = useMemo(() => {
+ const cfg = bodyshop?.rr_configuration || {};
+ const defaults =
+ cfg.opCodeDefault ||
+ cfg.op_code_default ||
+ cfg.op_codes?.default ||
+ cfg.defaults?.opCode ||
+ cfg.defaults ||
+ cfg.default ||
+ {};
+
+ const prefix = defaults.prefix ?? defaults.opCodePrefix ?? "";
+ const base = defaults.base ?? defaults.opCodeBase ?? "";
+ const suffix = defaults.suffix ?? defaults.opCodeSuffix ?? "";
+
+ return {
+ kmin: job?.kmin || 0,
+ opPrefix: prefix,
+ opBase: base,
+ opSuffix: suffix
+ };
+ }, [bodyshop, job]);
+
+ const getAdvisorNumber = (a) => a?.advisorId;
+ const getAdvisorLabel = (a) => `${a?.firstName || ""} ${a?.lastName || ""}`.trim();
+
+ const fetchRrAdvisors = (refresh = false) => {
+ if (!socket) return;
+ setAdvLoading(true);
+
+ const onResult = (payload) => {
+ try {
+ const list = payload?.result ?? payload ?? [];
+ setAdvisors(Array.isArray(list) ? list : []);
+ } finally {
+ setAdvLoading(false);
+ socket.off("rr-get-advisors:result", onResult);
+ }
+ };
+
+ socket.once("rr-get-advisors:result", onResult);
+ socket.emit("rr-get-advisors", { departmentType: "B", refresh }, (ack) => {
+ if (ack?.ok) {
+ const list = ack.result ?? [];
+ setAdvisors(Array.isArray(list) ? list : []);
+ } else if (ack) {
+ console.error("Error fetching RR Advisors:", ack.error);
+ }
+ setAdvLoading(false);
+ socket.off("rr-get-advisors:result", onResult);
+ });
+ };
+
+ useEffect(() => {
+ fetchRrAdvisors(false);
+ }, [bodyshop?.id, socket]);
+
+ const handleStartEarlyRO = async (values) => {
+ if (!socket) {
+ console.error("Socket not available");
+ return;
+ }
+
+ setIsSubmitting(true);
+
+ const txEnvelope = {
+ advisorNo: values.advisorNo,
+ story: values.story || "",
+ kmin: values.kmin || job?.kmin || 0,
+ opPrefix: values.opPrefix || "",
+ opBase: values.opBase || "",
+ opSuffix: values.opSuffix || ""
+ };
+
+ // Emit the early RO creation request
+ socket.emit("rr-create-early-ro", {
+ jobId: job.id,
+ txEnvelope
+ });
+
+ // Wait for customer selection
+ const customerListener = (candidates) => {
+ console.log("Received rr-select-customer event with candidates:", candidates);
+ setCustomerCandidates(candidates || []);
+ setShowCustomerSelector(true);
+ setIsSubmitting(false);
+ socket.off("rr-select-customer", customerListener);
+ };
+
+ socket.once("rr-select-customer", customerListener);
+
+ // Handle failures
+ const failureListener = (payload) => {
+ if (payload?.jobId === job.id) {
+ console.error("Early RO creation failed:", payload.error);
+ alert(`Failed to create early RO: ${payload.error}`);
+ setIsSubmitting(false);
+ setShowCustomerSelector(false);
+ socket.off("export-failed", failureListener);
+ socket.off("rr-select-customer", customerListener);
+ }
+ };
+
+ socket.once("export-failed", failureListener);
+ };
+
+ const handleCustomerSelected = (custNo, createNew = false) => {
+ if (!socket) return;
+
+ console.log("handleCustomerSelected called:", { custNo, createNew, custNoType: typeof custNo });
+
+ setIsSubmitting(true);
+ setShowCustomerSelector(false);
+
+ const payload = {
+ jobId: job.id,
+ custNo: createNew ? null : custNo,
+ create: createNew
+ };
+
+ console.log("Emitting rr-early-customer-selected:", payload);
+
+ // Emit customer selection
+ socket.emit("rr-early-customer-selected", payload, (ack) => {
+ console.log("Received ack from rr-early-customer-selected:", ack);
+ setIsSubmitting(false);
+
+ if (ack?.ok) {
+ const roNumber = ack.dmsRoNo || ack.outsdRoNo;
+ setEarlyRoCreated(true);
+ setCreatedRoNumber(roNumber);
+ onSuccess?.({ roNumber, ...ack });
+ } else {
+ alert(`Failed to create early RO: ${ack?.error || "Unknown error"}`);
+ }
+ });
+
+ // Also listen for socket events
+ const successListener = (payload) => {
+ if (payload?.jobId === job.id) {
+ const roNumber = payload.dmsRoNo || payload.outsdRoNo;
+ console.log("Early RO created:", roNumber);
+ socket.off("rr-early-ro-created", successListener);
+ socket.off("export-failed", failureListener);
+ }
+ };
+
+ const failureListener = (payload) => {
+ if (payload?.jobId === job.id) {
+ console.error("Early RO creation failed:", payload.error);
+ setIsSubmitting(false);
+ setEarlyRoCreated(false);
+ socket.off("rr-early-ro-created", successListener);
+ socket.off("export-failed", failureListener);
+ }
+ };
+
+ socket.once("rr-early-ro-created", successListener);
+ socket.once("export-failed", failureListener);
+ };
+
+ // If early RO already created, show success message
+ if (earlyRoCreated) {
+ return (
+
+ );
+ }
+
+ // If showing customer selector, render modal
+ if (showCustomerSelector) {
+ return (
+ <>
+ Create Early Reynolds RO
+ Waiting for customer selection...
+
+ {
+ setShowCustomerSelector(false);
+ setIsSubmitting(false);
+ }}
+ >
+
+
+ >
+ );
+ }
+
+ // Handle manual submit (since we can't nest forms)
+ const handleManualSubmit = async () => {
+ try {
+ const values = await form.validateFields();
+ handleStartEarlyRO(values);
+ } catch (error) {
+ console.error("Validation failed:", error);
+ }
+ };
+
+ // Show the form
+ return (
+
+
Create Early Reynolds RO
+
+ Complete this section to create a minimal RO in Reynolds before converting the job.
+
+
+
+ (option?.children?.toLowerCase() ?? "").includes(input.toLowerCase())
+ }}
+ loading={advLoading}
+ placeholder="Select advisor..."
+ popupRender={(menu) => (
+ <>
+ {menu}
+ }
+ onClick={() => fetchRrAdvisors(true)}
+ style={{ width: "100%", textAlign: "left" }}
+ >
+ Refresh Advisors
+
+ >
+ )}
+ >
+ {advisors.map((adv) => (
+
+ {getAdvisorLabel(adv)}
+
+ ))}
+
+
+
+
+
+
+
+ {/* RR OpCode (prefix / base / suffix) */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Create Early RO
+
+ {showCancelButton && Cancel }
+
+
+
+
+ );
+}
diff --git a/client/src/components/dms-post-form/rr-early-ro-modal.jsx b/client/src/components/dms-post-form/rr-early-ro-modal.jsx
new file mode 100644
index 000000000..ffb1b7f00
--- /dev/null
+++ b/client/src/components/dms-post-form/rr-early-ro-modal.jsx
@@ -0,0 +1,33 @@
+import { Modal } from "antd";
+import RREarlyROForm from "./rr-early-ro-form";
+
+/**
+ * Modal wrapper for RR Early RO Creation Form
+ * @param open - boolean to control modal visibility
+ * @param onClose - callback when modal is closed
+ * @param onSuccess - callback when RO is created successfully
+ * @param bodyshop - bodyshop object
+ * @param socket - socket.io connection
+ * @param job - job object
+ * @returns {JSX.Element}
+ * @constructor
+ */
+export default function RREarlyROModal({ open, onClose, onSuccess, bodyshop, socket, job }) {
+ const handleSuccess = (result) => {
+ onSuccess?.(result);
+ onClose?.();
+ };
+
+ return (
+
+
+
+ );
+}
diff --git a/client/src/components/jobs-close-lines/jobs-close-lines.component.jsx b/client/src/components/jobs-close-lines/jobs-close-lines.component.jsx
index e6bdc5321..a6ad151fb 100644
--- a/client/src/components/jobs-close-lines/jobs-close-lines.component.jsx
+++ b/client/src/components/jobs-close-lines/jobs-close-lines.component.jsx
@@ -42,11 +42,11 @@ export function JobsCloseLines({ bodyshop, job, jobRO }) {
{fields.map((field, index) => (
- {/* Hidden field to preserve jobline ID */}
-
-
-
+ {/* Hidden field to preserve jobline ID without injecting a div under */}
+
+
+
({
export function JobsConvertButton({ bodyshop, job, refetch, jobRO, insertAuditTrail, parentFormIsFieldsTouched }) {
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
+ const [earlyRoCreated, setEarlyRoCreated] = useState(!!job?.dms_id); // Track early RO creation state
const [mutationConvertJob] = useMutation(CONVERT_JOB_TO_RO);
const { t } = useTranslation();
const [form] = Form.useForm();
const notification = useNotification();
const allFormValues = Form.useWatch([], form);
+ const { socket } = useSocket(); // Extract socket from context
+
+ // Get Fortellis treatment for proper DMS mode detection
+ const {
+ treatments: { Fortellis }
+ } = useTreatmentsWithConfig({
+ attributes: {},
+ names: ["Fortellis"],
+ splitKey: bodyshop?.imexshopid
+ });
+
+ // Check if bodyshop has Reynolds integration using the proper getDmsMode function
+ const dmsMode = getDmsMode(bodyshop, Fortellis.treatment);
+ const isReynoldsMode = dmsMode === DMS_MAP.reynolds;
+
+ console.log(`2309-829038721093820938290382903`);
+ console.log(isReynoldsMode);
const handleConvert = async ({ employee_csr, category, ...values }) => {
if (parentFormIsFieldsTouched()) {
@@ -82,177 +104,229 @@ export function JobsConvertButton({ bodyshop, job, refetch, jobRO, insertAuditTr
const submitDisabled = useCallback(() => some(allFormValues, (v) => v === undefined), [allFormValues]);
- const popMenu = (
-
- );
- if (job.converted) return <>>;
-
- return (
-
- {
- setOpen(true);
- }}
- >
- {t("jobs.actions.convert")}
-
-
+
+ form.submit()}
+ loading={loading}
+ >
+ {t("jobs.actions.convert")}
+
+
+ {t("general.actions.close")}
+
+
+
+
+ >
);
}
diff --git a/client/src/graphql/jobs.queries.js b/client/src/graphql/jobs.queries.js
index e32163b1a..98b349e8a 100644
--- a/client/src/graphql/jobs.queries.js
+++ b/client/src/graphql/jobs.queries.js
@@ -470,6 +470,9 @@ export const GET_JOB_BY_PK = gql`
clm_total
comment
converted
+ dms_id
+ dms_customer_id
+ dms_advisor_id
csiinvites {
completedon
id
@@ -2216,6 +2219,9 @@ export const QUERY_JOB_EXPORT_DMS = gql`
plate_no
plate_st
ownr_co_nm
+ dms_id
+ dms_customer_id
+ dms_advisor_id
}
}
`;
diff --git a/client/src/pages/dms/dms.container.jsx b/client/src/pages/dms/dms.container.jsx
index c4d5c8190..d43f22a08 100644
--- a/client/src/pages/dms/dms.container.jsx
+++ b/client/src/pages/dms/dms.container.jsx
@@ -486,6 +486,7 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse
({
setBreadcrumbs: (breadcrumbs) => dispatch(setBreadcrumbs(breadcrumbs)),
setSelectedHeader: (key) => dispatch(setSelectedHeader(key))
@@ -39,14 +50,31 @@ const cardStyle = {
height: "100%"
};
-export function JobsCloseContainer({ setBreadcrumbs, setSelectedHeader }) {
+export function JobsCloseContainer({ setBreadcrumbs, setSelectedHeader, bodyshop }) {
const { jobId } = useParams();
- const { loading, error, data } = useQuery(GET_JOB_BY_PK, {
+ const { loading, error, data, refetch } = useQuery(GET_JOB_BY_PK, {
variables: { id: jobId },
fetchPolicy: "network-only",
nextFetchPolicy: "network-only"
});
const { t } = useTranslation();
+ const { socket } = useSocket(); // Extract socket from context
+ const notification = useNotification();
+ const [showEarlyROModal, setShowEarlyROModal] = useState(false);
+
+ // Get Fortellis treatment for proper DMS mode detection
+ const {
+ treatments: { Fortellis }
+ } = useTreatmentsWithConfig({
+ attributes: {},
+ names: ["Fortellis"],
+ splitKey: bodyshop?.imexshopid
+ });
+
+ // Check if bodyshop has Reynolds integration using the proper getDmsMode function
+ const dmsMode = getDmsMode(bodyshop, Fortellis.treatment);
+ const isReynoldsMode = dmsMode === DMS_MAP.reynolds;
+ const job = data?.jobs_by_pk;
useEffect(() => {
setSelectedHeader("activejobs");
document.title = t("titles.jobs-admin", {
@@ -75,6 +103,15 @@ export function JobsCloseContainer({ setBreadcrumbs, setSelectedHeader }) {
]);
}, [setBreadcrumbs, t, jobId, data, setSelectedHeader]);
+ const handleEarlyROSuccess = (result) => {
+ notification.success({
+ title: t("jobs.successes.early_ro_created", "Early RO Created"),
+ message: `RO Number: ${result.roNumber || "N/A"}`
+ });
+ setShowEarlyROModal(false);
+ refetch?.();
+ };
+
if (loading) return ;
if (error) return ;
if (!data.jobs_by_pk) return ;
@@ -99,6 +136,11 @@ export function JobsCloseContainer({ setBreadcrumbs, setSelectedHeader }) {
+ {isReynoldsMode && job?.converted && !job?.dms_id && !job?.dms_customer_id && !job?.dms_advisor_id && (
+ setShowEarlyROModal(true)}>
+ Create RR RO
+
+ )}
@@ -124,8 +166,18 @@ export function JobsCloseContainer({ setBreadcrumbs, setSelectedHeader }) {
+
+ {/* Early RO Modal */}
+ setShowEarlyROModal(false)}
+ onSuccess={handleEarlyROSuccess}
+ bodyshop={bodyshop}
+ socket={socket}
+ job={job}
+ />
);
}
-export default connect(null, mapDispatchToProps)(JobsCloseContainer);
+export default connect(mapStateToProps, mapDispatchToProps)(JobsCloseContainer);
diff --git a/client/src/translations/en_us/common.json b/client/src/translations/en_us/common.json
index af14320db..5f5c5514f 100644
--- a/client/src/translations/en_us/common.json
+++ b/client/src/translations/en_us/common.json
@@ -1818,7 +1818,11 @@
"sale": "Sale",
"sale_dms_acctnumber": "Sale DMS Acct #",
"story": "Story",
- "vinowner": "VIN Owner"
+ "vinowner": "VIN Owner",
+ "rr_opcode": "RR OpCode",
+ "rr_opcode_prefix": "Prefix",
+ "rr_opcode_suffix": "Suffix",
+ "rr_opcode_base": "Base"
},
"dms_allocation": "DMS Allocation",
"driveable": "Driveable",
diff --git a/client/src/translations/es/common.json b/client/src/translations/es/common.json
index 5c7a31350..6559e1038 100644
--- a/client/src/translations/es/common.json
+++ b/client/src/translations/es/common.json
@@ -1818,7 +1818,11 @@
"sale": "",
"sale_dms_acctnumber": "",
"story": "",
- "vinowner": ""
+ "vinowner": "",
+ "rr_opcode": "",
+ "rr_opcode_prefix": "",
+ "rr_opcode_suffix": "",
+ "rr_opcode_base": ""
},
"dms_allocation": "",
"driveable": "",
diff --git a/client/src/translations/fr/common.json b/client/src/translations/fr/common.json
index e74e06bf0..00af4c6aa 100644
--- a/client/src/translations/fr/common.json
+++ b/client/src/translations/fr/common.json
@@ -1818,7 +1818,11 @@
"sale": "",
"sale_dms_acctnumber": "",
"story": "",
- "vinowner": ""
+ "vinowner": "",
+ "rr_opcode": "",
+ "rr_opcode_prefix": "",
+ "rr_opcode_suffix": "",
+ "rr_opcode_base": ""
},
"dms_allocation": "",
"driveable": "",
diff --git a/hasura/metadata/tables.yaml b/hasura/metadata/tables.yaml
index 2f29ac26c..5d3d3eb85 100644
--- a/hasura/metadata/tables.yaml
+++ b/hasura/metadata/tables.yaml
@@ -3704,7 +3704,9 @@
- ded_status
- deliverchecklist
- depreciation_taxes
+ - dms_advisor_id
- dms_allocation
+ - dms_customer_id
- dms_id
- driveable
- employee_body
@@ -3985,7 +3987,9 @@
- ded_status
- deliverchecklist
- depreciation_taxes
+ - dms_advisor_id
- dms_allocation
+ - dms_customer_id
- dms_id
- driveable
- employee_body
@@ -4278,7 +4282,9 @@
- ded_status
- deliverchecklist
- depreciation_taxes
+ - dms_advisor_id
- dms_allocation
+ - dms_customer_id
- dms_id
- driveable
- employee_body
diff --git a/hasura/migrations/1770837989352_alter_table_public_jobs_add_column_dms_customer_id/down.sql b/hasura/migrations/1770837989352_alter_table_public_jobs_add_column_dms_customer_id/down.sql
new file mode 100644
index 000000000..68a9ffd86
--- /dev/null
+++ b/hasura/migrations/1770837989352_alter_table_public_jobs_add_column_dms_customer_id/down.sql
@@ -0,0 +1,4 @@
+-- Could not auto-generate a down migration.
+-- Please write an appropriate down migration for the SQL below:
+-- alter table "public"."jobs" add column "dms_customer_id" text
+-- null;
diff --git a/hasura/migrations/1770837989352_alter_table_public_jobs_add_column_dms_customer_id/up.sql b/hasura/migrations/1770837989352_alter_table_public_jobs_add_column_dms_customer_id/up.sql
new file mode 100644
index 000000000..9f63afe7f
--- /dev/null
+++ b/hasura/migrations/1770837989352_alter_table_public_jobs_add_column_dms_customer_id/up.sql
@@ -0,0 +1,2 @@
+alter table "public"."jobs" add column "dms_customer_id" text
+ null;
diff --git a/hasura/migrations/1770838205706_alter_table_public_jobs_add_column_dms_advisor_id/down.sql b/hasura/migrations/1770838205706_alter_table_public_jobs_add_column_dms_advisor_id/down.sql
new file mode 100644
index 000000000..f58388cff
--- /dev/null
+++ b/hasura/migrations/1770838205706_alter_table_public_jobs_add_column_dms_advisor_id/down.sql
@@ -0,0 +1,4 @@
+-- Could not auto-generate a down migration.
+-- Please write an appropriate down migration for the SQL below:
+-- alter table "public"."jobs" add column "dms_advisor_id" text
+-- null;
diff --git a/hasura/migrations/1770838205706_alter_table_public_jobs_add_column_dms_advisor_id/up.sql b/hasura/migrations/1770838205706_alter_table_public_jobs_add_column_dms_advisor_id/up.sql
new file mode 100644
index 000000000..0a81aec8f
--- /dev/null
+++ b/hasura/migrations/1770838205706_alter_table_public_jobs_add_column_dms_advisor_id/up.sql
@@ -0,0 +1,2 @@
+alter table "public"."jobs" add column "dms_advisor_id" text
+ null;
diff --git a/server/chatter/chatter-client.js b/server/chatter/chatter-client.js
index a25f0c812..047e94459 100644
--- a/server/chatter/chatter-client.js
+++ b/server/chatter/chatter-client.js
@@ -61,6 +61,8 @@ class ChatterApiClient {
const err = new Error(`Chatter API error ${res.status} | ${data?.message}`);
err.status = res.status;
err.data = data;
+ const retryAfterMs = parseRetryAfterMs(res.headers.get("retry-after"));
+ if (retryAfterMs != null) err.retryAfterMs = retryAfterMs;
throw err;
}
return data;
@@ -78,6 +80,17 @@ function safeJson(text) {
}
}
+function parseRetryAfterMs(value) {
+ if (!value) return null;
+
+ const sec = Number(value);
+ if (Number.isFinite(sec) && sec >= 0) return Math.ceil(sec * 1000);
+
+ const dateMs = Date.parse(value);
+ if (!Number.isFinite(dateMs)) return null;
+ return Math.max(0, dateMs - Date.now());
+}
+
/**
* Fetches Chatter API token from AWS Secrets Manager
* SecretId format: CHATTER_COMPANY_KEY_
diff --git a/server/data/chatter-api.js b/server/data/chatter-api.js
index 89f29b8ae..caee129f0 100644
--- a/server/data/chatter-api.js
+++ b/server/data/chatter-api.js
@@ -1,3 +1,39 @@
+/**
+ * Environment variables used by this file
+ * Chatter integration
+ * - CHATTER_API_CONCURRENCY
+ * - Maximum number of jobs/interactions posted concurrently *per shop* (within a single shop's batch).
+ * - Default: 5
+ * - Used by: createConcurrencyLimit(MAX_CONCURRENCY)
+ *
+ * - CHATTER_API_REQUESTS_PER_SECOND
+ * - Per-company outbound request rate (token bucket refill rate).
+ * - Default: 3
+ * - Must be a positive number; otherwise falls back to default.
+ * - Used by: createTokenBucketRateLimiter({ refillPerSecond })
+ *
+ * - CHATTER_API_BURST_CAPACITY
+ * - Per-company token bucket capacity (maximum burst size).
+ * - Default: equals CHATTER_API_REQUESTS_PER_SECOND (i.e., 3 unless overridden)
+ * - Must be a positive number; otherwise falls back to default.
+ * - Used by: createTokenBucketRateLimiter({ capacity })
+ *
+ * - CHATTER_API_MAX_RETRIES
+ * - Maximum number of attempts for posting an interaction before giving up.
+ * - Default: 6
+ * - Must be a positive integer; otherwise falls back to default.
+ * - Used by: postInteractionWithPolicy()
+ *
+ * - CHATTER_API_TOKEN
+ * - Optional override token for emergency/dev scenarios.
+ * - If set, bypasses Secrets Manager/Redis token retrieval and uses this value for all companies.
+ * - Used by: getChatterApiTokenCached()
+ *
+ * Notes
+ * - Per-company API tokens are otherwise fetched via getChatterApiToken(companyId) (Secrets Manager)
+ * and may be cached via `sessionUtils.getChatterToken/setChatterToken` (Redis-backed).
+ */
+
const queries = require("../graphql-client/queries");
const moment = require("moment-timezone");
const logger = require("../utils/logger");
@@ -6,12 +42,16 @@ const { ChatterApiClient, getChatterApiToken, CHATTER_BASE_URL } = require("../c
const client = require("../graphql-client/graphql-client").client;
const { sendServerEmail } = require("../email/sendemail");
-const CHATTER_EVENT = process.env.CHATTER_SOLICITATION_EVENT || "delivery";
+const CHATTER_EVENT = process.env.NODE_ENV === "production" ? "delivery" : "TEST_INTEGRATION";
const MAX_CONCURRENCY = Number(process.env.CHATTER_API_CONCURRENCY || 5);
+const CHATTER_REQUESTS_PER_SECOND = getPositiveNumber(process.env.CHATTER_API_REQUESTS_PER_SECOND, 3);
+const CHATTER_BURST_CAPACITY = getPositiveNumber(process.env.CHATTER_API_BURST_CAPACITY, CHATTER_REQUESTS_PER_SECOND);
+const CHATTER_MAX_RETRIES = getPositiveInteger(process.env.CHATTER_API_MAX_RETRIES, 6);
// Client caching (in-memory) - tokens are now cached in Redis
const clientCache = new Map(); // companyId -> ChatterApiClient
const tokenInFlight = new Map(); // companyId -> Promise (for in-flight deduplication)
+const companyRateLimiters = new Map(); // companyId -> rate limiter
exports.default = async (req, res) => {
if (process.env.NODE_ENV !== "production") return res.sendStatus(403);
@@ -19,7 +59,7 @@ exports.default = async (req, res) => {
res.status(202).json({
success: true,
- message: "Processing request ...",
+ message: "Processing Chatter-API Cron request ...",
timestamp: new Date().toISOString()
});
@@ -149,7 +189,11 @@ async function processBatchApi({ shopsToProcess, start, end, skipUpload, allShop
const failures = results
.filter((r) => r && r.ok === false)
.slice(0, 25)
- .map((r) => ({ status: r.status, error: r.error }));
+ .map((r) => ({
+ status: r.status,
+ error: r.error,
+ context: r.context
+ }));
if (failures.length) {
summary.ok = false;
@@ -184,12 +228,23 @@ function buildInteractionPayload(bodyshop, j) {
const isCompany = Boolean(j.ownr_co_nm);
const locationIdentifier = `${bodyshop.chatter_company_id}-${bodyshop.id}`;
+ const timestamp = formatChatterTimestamp(j.actual_delivery, bodyshop.timezone);
+
+ if (j.actual_delivery && !timestamp) {
+ logger.log("chatter-api-invalid-delivery-timestamp", "WARN", "api", bodyshop.id, {
+ bodyshopId: bodyshop.id,
+ jobId: j.id,
+ timezone: bodyshop.timezone,
+ actualDelivery: j.actual_delivery
+ });
+ }
return {
locationIdentifier: locationIdentifier,
event: CHATTER_EVENT,
+ consent: "true",
transactionId: j.ro_number != null ? String(j.ro_number) : undefined,
- timestamp: j.actual_delivery ? moment(j.actual_delivery).tz(bodyshop.timezone).toISOString() : undefined,
+ timestamp,
firstName: isCompany ? null : j.ownr_fn || null,
lastName: isCompany ? j.ownr_co_nm : j.ownr_ln || null,
emailAddress: j.ownr_ea || undefined,
@@ -203,7 +258,19 @@ function buildInteractionPayload(bodyshop, j) {
}
async function postInteractionWithPolicy(chatterApi, companyId, payload) {
- for (let attempt = 0; attempt < 6; attempt++) {
+ const limiter = getCompanyRateLimiter(companyId);
+ const requestContext = {
+ companyId,
+ locationIdentifier: payload?.locationIdentifier,
+ transactionId: payload?.transactionId,
+ timestamp: payload?.timestamp ?? null,
+ bodyshopId: payload?.metadata?.bodyshopId ?? null,
+ jobId: payload?.metadata?.jobId ?? null
+ };
+
+ for (let attempt = 0; attempt < CHATTER_MAX_RETRIES; attempt++) {
+ await limiter.acquire();
+
try {
await chatterApi.postInteraction(companyId, payload);
return { ok: true };
@@ -213,14 +280,40 @@ async function postInteractionWithPolicy(chatterApi, companyId, payload) {
// rate limited -> backoff + retry
if (e.status === 429) {
- await sleep(backoffMs(attempt));
+ const retryDelayMs = retryDelayMsForError(e, attempt);
+ limiter.pause(retryDelayMs);
+ logger.log("chatter-api-request-rate-limited", "WARN", "api", requestContext.bodyshopId, {
+ ...requestContext,
+ attempt: attempt + 1,
+ maxAttempts: CHATTER_MAX_RETRIES,
+ status: e.status,
+ retryAfterMs: e.retryAfterMs,
+ retryDelayMs,
+ error: e.data ?? e.message
+ });
+ await sleep(retryDelayMs);
continue;
}
- return { ok: false, status: e.status, error: e.data ?? e.message };
+ logger.log("chatter-api-request-failed", "ERROR", "api", requestContext.bodyshopId, {
+ ...requestContext,
+ attempt: attempt + 1,
+ maxAttempts: CHATTER_MAX_RETRIES,
+ status: e.status,
+ error: e.data ?? e.message
+ });
+ return { ok: false, status: e.status, error: e.data ?? e.message, context: requestContext };
}
}
- return { ok: false, status: 429, error: "rate limit retry exhausted" };
+
+ logger.log("chatter-api-request-failed", "ERROR", "api", requestContext.bodyshopId, {
+ ...requestContext,
+ maxAttempts: CHATTER_MAX_RETRIES,
+ status: 429,
+ error: "rate limit retry exhausted"
+ });
+
+ return { ok: false, status: 429, error: "rate limit retry exhausted", context: requestContext };
}
function parseCompanyId(val) {
@@ -251,6 +344,26 @@ function backoffMs(attempt) {
return base + jitter;
}
+function retryDelayMsForError(error, attempt) {
+ const retryAfterMs = Number(error?.retryAfterMs);
+ if (Number.isFinite(retryAfterMs) && retryAfterMs > 0) {
+ const jitter = Math.floor(Math.random() * 250);
+ return Math.min(60_000, retryAfterMs + jitter);
+ }
+ return backoffMs(attempt);
+}
+
+function formatChatterTimestamp(value, timezone) {
+ if (!value) return undefined;
+
+ const hasValidTimezone = Boolean(timezone && moment.tz.zone(timezone));
+ const parsed = hasValidTimezone ? moment(value).tz(timezone) : moment(value);
+ if (!parsed.isValid()) return undefined;
+
+ // Keep a strict, Chatter-friendly timestamp without fractional seconds.
+ return parsed.utc().format("YYYY-MM-DD HH:mm:ss[Z]");
+}
+
function createConcurrencyLimit(max) {
let active = 0;
const queue = [];
@@ -281,6 +394,77 @@ function createConcurrencyLimit(max) {
});
}
+function getCompanyRateLimiter(companyId) {
+ const key = String(companyId);
+ const existing = companyRateLimiters.get(key);
+ if (existing) return existing;
+
+ const limiter = createTokenBucketRateLimiter({
+ refillPerSecond: CHATTER_REQUESTS_PER_SECOND,
+ capacity: CHATTER_BURST_CAPACITY
+ });
+
+ companyRateLimiters.set(key, limiter);
+ return limiter;
+}
+
+function createTokenBucketRateLimiter({ refillPerSecond, capacity }) {
+ let tokens = capacity;
+ let lastRefillAt = Date.now();
+ let pauseUntil = 0;
+ let chain = Promise.resolve();
+
+ const refill = () => {
+ const now = Date.now();
+ const elapsedSec = (now - lastRefillAt) / 1000;
+ if (elapsedSec <= 0) return;
+ tokens = Math.min(capacity, tokens + elapsedSec * refillPerSecond);
+ lastRefillAt = now;
+ };
+
+ const waitForPermit = async () => {
+ for (;;) {
+ const now = Date.now();
+ if (pauseUntil > now) {
+ await sleep(pauseUntil - now);
+ continue;
+ }
+
+ refill();
+ if (tokens >= 1) {
+ tokens -= 1;
+ return;
+ }
+
+ const missing = 1 - tokens;
+ const waitMs = Math.max(25, Math.ceil((missing / refillPerSecond) * 1000));
+ await sleep(waitMs);
+ }
+ };
+
+ return {
+ acquire() {
+ chain = chain.then(waitForPermit, waitForPermit);
+ return chain;
+ },
+ pause(ms) {
+ const n = Number(ms);
+ if (!Number.isFinite(n) || n <= 0) return;
+ pauseUntil = Math.max(pauseUntil, Date.now() + n);
+ }
+ };
+}
+
+function getPositiveNumber(value, fallback) {
+ const n = Number(value);
+ return Number.isFinite(n) && n > 0 ? n : fallback;
+}
+
+function getPositiveInteger(value, fallback) {
+ const n = Number(value);
+ return Number.isInteger(n) && n > 0 ? n : fallback;
+}
+
/**
* Returns a per-company Chatter API client, caching both the token and the client.
*/
diff --git a/server/graphql-client/queries.js b/server/graphql-client/queries.js
index 08b087e37..40e99d174 100644
--- a/server/graphql-client/queries.js
+++ b/server/graphql-client/queries.js
@@ -1620,6 +1620,9 @@ exports.GET_JOB_BY_PK = `query GET_JOB_BY_PK($id: uuid!) {
rate_ats
flat_rate_ats
rate_ats_flat
+ dms_id
+ dms_customer_id
+ dms_advisor_id
joblines(where: { removed: { _eq: false } }){
id
line_no
@@ -3236,9 +3239,12 @@ exports.UPDATE_USER_FCM_TOKENS_BY_EMAIL = /* GraphQL */ `
}
`;
-exports.SET_JOB_DMS_ID = `mutation SetJobDmsId($id: uuid!, $dms_id: String!) {
- update_jobs_by_pk(pk_columns: { id: $id }, _set: { dms_id: $dms_id }) {
+exports.SET_JOB_DMS_ID = `mutation SetJobDmsId($id: uuid!, $dms_id: String!, $dms_customer_id: String, $dms_advisor_id: String, $kmin: Int) {
+ update_jobs_by_pk(pk_columns: { id: $id }, _set: { dms_id: $dms_id, dms_customer_id: $dms_customer_id, dms_advisor_id: $dms_advisor_id, kmin: $kmin }) {
id
dms_id
+ dms_customer_id
+ dms_advisor_id
+ kmin
}
}`;
diff --git a/server/rr/rr-export-logs.js b/server/rr/rr-export-logs.js
index 27acca0e3..184d54a4f 100644
--- a/server/rr/rr-export-logs.js
+++ b/server/rr/rr-export-logs.js
@@ -86,8 +86,9 @@ const buildMessageJSONString = ({ error, classification, result, fallback }) =>
/**
* Success: mark job exported + (optionally) insert a success log.
* Uses queries.MARK_JOB_EXPORTED (same shape as Fortellis/PBS).
+ * @param {boolean} isEarlyRo - If true, only logs success but does NOT change job status (for early RO creation)
*/
-const markRRExportSuccess = async ({ socket, jobId, job, bodyshop, result, metaExtra = {} }) => {
+const markRRExportSuccess = async ({ socket, jobId, job, bodyshop, result, metaExtra = {}, isEarlyRo = false }) => {
const endpoint = process.env.GRAPHQL_ENDPOINT;
if (!endpoint) throw new Error("GRAPHQL_ENDPOINT not configured");
const token = getAuthToken(socket);
@@ -96,11 +97,40 @@ const markRRExportSuccess = async ({ socket, jobId, job, bodyshop, result, metaE
const client = new GraphQLClient(endpoint, {});
client.setHeaders({ Authorization: `Bearer ${token}` });
+ const meta = buildRRExportMeta({ result, extra: metaExtra });
+
+ // For early RO, we only insert a log but do NOT change job status or mark as exported
+ if (isEarlyRo) {
+ try {
+ await client.request(queries.INSERT_EXPORT_LOG, {
+ logs: [
+ {
+ bodyshopid: bodyshop?.id || job?.bodyshop?.id,
+ jobid: jobId,
+ successful: true,
+ useremail: socket?.user?.email || null,
+ metadata: meta,
+ message: buildMessageJSONString({ result, fallback: "RR early RO created" })
+ }
+ ]
+ });
+
+ CreateRRLogEvent(socket, "INFO", "RR early RO: success log inserted (job status unchanged)", {
+ jobId
+ });
+ } catch (e) {
+ CreateRRLogEvent(socket, "ERROR", "RR early RO: failed to insert success log", {
+ jobId,
+ error: e?.message
+ });
+ }
+ return;
+ }
+
+ // Full export: mark job as exported and insert success log
const exportedStatus =
job?.bodyshop?.md_ro_statuses?.default_exported || bodyshop?.md_ro_statuses?.default_exported || "Exported*";
- const meta = buildRRExportMeta({ result, extra: metaExtra });
-
try {
await client.request(queries.MARK_JOB_EXPORTED, {
jobId,
diff --git a/server/rr/rr-job-export.js b/server/rr/rr-job-export.js
index 534ef61d3..e211041d7 100644
--- a/server/rr/rr-job-export.js
+++ b/server/rr/rr-job-export.js
@@ -56,7 +56,319 @@ const deriveRRStatus = (rrRes = {}) => {
};
/**
- * Step 1: Export a job to RR as a new Repair Order.
+ * Early RO Creation: Create a minimal RR Repair Order with basic info (customer, advisor, mileage, story).
+ * Used when creating RO from convert button or admin page before full job export.
+ * @param args
+ * @returns {Promise<{success: boolean, data: *, roStatus: {status: *, statusCode: *|undefined, message}, statusBlocks: *|{}, customerNo: string, svId: *, roNo: *, xml: *}>}
+ */
+const createMinimalRRRepairOrder = async (args) => {
+ const { bodyshop, job, advisorNo, selectedCustomer, txEnvelope, socket, svId } = args || {};
+
+ if (!bodyshop) throw new Error("createMinimalRRRepairOrder: bodyshop is required");
+ if (!job) throw new Error("createMinimalRRRepairOrder: job is required");
+ if (advisorNo == null || String(advisorNo).trim() === "") {
+ throw new Error("createMinimalRRRepairOrder: advisorNo is required for RR");
+ }
+
+ // Resolve customer number (accept multiple shapes)
+ const selected = selectedCustomer?.customerNo || selectedCustomer?.custNo;
+ if (!selected) throw new Error("createMinimalRRRepairOrder: selectedCustomer.custNo/customerNo is required");
+
+ const { client, opts } = buildClientAndOpts(bodyshop);
+
+ // For early RO creation we always "Insert" (create minimal RO)
+ const finalOpts = {
+ ...opts,
+ envelope: {
+ ...(opts?.envelope || {}),
+ sender: {
+ ...(opts?.envelope?.sender || {}),
+ task: "BSMRO",
+ referenceId: "Insert"
+ }
+ }
+ };
+
+ const story = txEnvelope?.story ? String(txEnvelope.story).trim() : null;
+ const makeOverride = txEnvelope?.makeOverride ? String(txEnvelope.makeOverride).trim() : null;
+
+ // Build minimal RO payload - just header, no allocations/parts/labor
+ const cleanVin =
+ (job?.v_vin || "")
+ .toString()
+ .replace(/[^A-Za-z0-9]/g, "")
+ .toUpperCase()
+ .slice(0, 17) || undefined;
+
+ // Resolve mileage - must be a positive number
+ let mileageIn = txEnvelope?.kmin ?? job?.kmin ?? null;
+ if (mileageIn != null) {
+ mileageIn = parseInt(mileageIn, 10);
+ if (isNaN(mileageIn) || mileageIn < 0) {
+ mileageIn = null;
+ }
+ }
+
+ CreateRRLogEvent(socket, "DEBUG", "Resolved mileage for early RO", {
+ txEnvelopeKmin: txEnvelope?.kmin,
+ jobKmin: job?.kmin,
+ resolvedMileageIn: mileageIn
+ });
+
+ const payload = {
+ customerNo: String(selected),
+ advisorNo: String(advisorNo),
+ vin: cleanVin,
+ departmentType: "B",
+ outsdRoNo: job?.ro_number || job?.id || undefined
+ };
+
+ // Only add mileageIn if we have a valid value
+ if (mileageIn != null && mileageIn >= 0) {
+ payload.mileageIn = mileageIn;
+ }
+
+ // Add optional fields if present
+ if (story) {
+ payload.roComment = story;
+ }
+ if (makeOverride) {
+ payload.makeOverride = makeOverride;
+ }
+
+ CreateRRLogEvent(socket, "INFO", "Creating minimal RR Repair Order (early creation)", {
+ payload
+ });
+
+ const response = await client.createRepairOrder(payload, finalOpts);
+
+ CreateRRLogEvent(socket, "INFO", "RR minimal Repair Order created", {
+ payload,
+ response
+ });
+
+ const data = response?.data || null;
+ const statusBlocks = response?.statusBlocks || {};
+ const roStatus = deriveRRStatus(response);
+
+ const statusUpper = roStatus?.status ? String(roStatus.status).toUpperCase() : null;
+
+ let success = false;
+
+ if (statusUpper) {
+ // Treat explicit FAILURE / ERROR as hard failures
+ success = !["FAILURE", "ERROR"].includes(statusUpper);
+ } else if (typeof response?.success === "boolean") {
+ // Fallback to library boolean if no explicit status
+ success = response.success;
+ } else if (roStatus?.status) {
+ success = String(roStatus.status).toUpperCase() === "SUCCESS";
+ }
+
+ // Extract canonical roNo for later updates
+ const roNo = data?.dmsRoNo ?? data?.outsdRoNo ?? roStatus?.dmsRoNo ?? null;
+
+ return {
+ success,
+ data,
+ roStatus,
+ statusBlocks,
+ customerNo: String(selected),
+ svId,
+ roNo,
+ xml: response?.xml // expose XML for logging/diagnostics
+ };
+};
+
+/**
+ * Full Data Update: Update an existing RR Repair Order with complete job data (allocations, parts, labor).
+ * Used during DMS post form when an early RO was already created.
+ * @param args
+ * @returns {Promise<{success: boolean, data: *, roStatus: {status: *, statusCode: *|undefined, message}, statusBlocks: *|{}, customerNo: string, svId: *, roNo: *, xml: *}>}
+ */
+const updateRRRepairOrderWithFullData = async (args) => {
+ const { bodyshop, job, advisorNo, selectedCustomer, txEnvelope, socket, svId, roNo } = args || {};
+
+ if (!bodyshop) throw new Error("updateRRRepairOrderWithFullData: bodyshop is required");
+ if (!job) throw new Error("updateRRRepairOrderWithFullData: job is required");
+ if (advisorNo == null || String(advisorNo).trim() === "") {
+ throw new Error("updateRRRepairOrderWithFullData: advisorNo is required for RR");
+ }
+ if (!roNo) throw new Error("updateRRRepairOrderWithFullData: roNo is required for update");
+
+ // Resolve customer number (accept multiple shapes)
+ const selected = selectedCustomer?.customerNo || selectedCustomer?.custNo;
+ if (!selected) throw new Error("updateRRRepairOrderWithFullData: selectedCustomer.custNo/customerNo is required");
+
+ const { client, opts } = buildClientAndOpts(bodyshop);
+
+ // For full data update after early RO, we still use "Insert" referenceId
+ // because we're inserting the job operations for the first time
+ const finalOpts = {
+ ...opts,
+ envelope: {
+ ...(opts?.envelope || {}),
+ sender: {
+ ...(opts?.envelope?.sender || {}),
+ task: "BSMRO",
+ referenceId: "Insert"
+ }
+ }
+ };
+
+ const story = txEnvelope?.story ? String(txEnvelope.story).trim() : null;
+ const makeOverride = txEnvelope?.makeOverride ? String(txEnvelope.makeOverride).trim() : null;
+
+ // Optional RR OpCode segments coming from the FE (RRPostForm)
+ const opPrefix = txEnvelope?.opPrefix ?? txEnvelope?.op_prefix ?? null;
+ const opBase = txEnvelope?.opBase ?? txEnvelope?.op_base ?? null;
+ const opSuffix = txEnvelope?.opSuffix ?? txEnvelope?.op_suffix ?? null;
+
+ // RR-only extras
+ let rrCentersConfig = null;
+ let allocations = null;
+ let opCode = null;
+
+ // 1) Responsibility center config (for visibility / debugging)
+ try {
+ rrCentersConfig = extractRrResponsibilityCenters(bodyshop);
+
+ CreateRRLogEvent(socket, "SILLY", "RR responsibility centers resolved", {
+ hasCenters: !!bodyshop.md_responsibility_centers,
+ profitCenters: Object.keys(rrCentersConfig?.profitsByName || {}),
+ costCenters: Object.keys(rrCentersConfig?.costsByName || {}),
+ dmsCostDefaults: rrCentersConfig?.dmsCostDefaults || {},
+ dmsProfitDefaults: rrCentersConfig?.dmsProfitDefaults || {}
+ });
+ } catch (e) {
+ CreateRRLogEvent(socket, "ERROR", "Failed to resolve RR responsibility centers", {
+ message: e?.message,
+ stack: e?.stack
+ });
+ }
+
+ // 2) Allocations (sales + cost by center, with rr_* metadata already attached)
+ try {
+ const allocResult = await CdkCalculateAllocations(socket, job.id);
+
+ // We only need the per-center job allocations for RO.GOG / ROLABOR.
+ allocations = Array.isArray(allocResult?.jobAllocations) ? allocResult.jobAllocations : [];
+
+ CreateRRLogEvent(socket, "INFO", "RR allocations resolved for update", {
+ hasAllocations: allocations.length > 0,
+ count: allocations.length,
+ allocationsPreview: allocations.slice(0, 2).map(a => ({
+ type: a?.type,
+ code: a?.code,
+ laborSale: a?.laborSale,
+ laborCost: a?.laborCost,
+ partsSale: a?.partsSale,
+ partsCost: a?.partsCost
+ })),
+ taxAllocCount: Array.isArray(allocResult?.taxAllocArray) ? allocResult.taxAllocArray.length : 0,
+ ttlAdjCount: Array.isArray(allocResult?.ttlAdjArray) ? allocResult.ttlAdjArray.length : 0,
+ ttlTaxAdjCount: Array.isArray(allocResult?.ttlTaxAdjArray) ? allocResult.ttlTaxAdjArray.length : 0
+ });
+ } catch (e) {
+ CreateRRLogEvent(socket, "ERROR", "Failed to calculate RR allocations", {
+ message: e?.message,
+ stack: e?.stack
+ });
+ // Proceed with a header-only update if allocations fail.
+ allocations = [];
+ }
+
+ const resolvedBaseOpCode = resolveRROpCodeFromBodyshop(bodyshop);
+
+ let opCodeOverride = txEnvelope?.opCode || txEnvelope?.opcode || txEnvelope?.op_code || null;
+
+ // If the FE only sends segments, combine them here.
+ if (!opCodeOverride && (opPrefix || opBase || opSuffix)) {
+ const combined = `${opPrefix || ""}${opBase || ""}${opSuffix || ""}`.trim();
+ if (combined) {
+ opCodeOverride = combined;
+ }
+ }
+
+ if (opCodeOverride || resolvedBaseOpCode) {
+ opCode = String(opCodeOverride || resolvedBaseOpCode).trim() || null;
+ }
+
+ CreateRRLogEvent(socket, "SILLY", "RR OP config resolved", {
+ opCode,
+ baseFromConfig: resolvedBaseOpCode,
+ opPrefix,
+ opBase,
+ opSuffix
+ });
+
+ // Build full RO payload for update with allocations
+ const payload = buildRRRepairOrderPayload({
+ bodyshop,
+ job,
+ selectedCustomer: { customerNo: String(selected), custNo: String(selected) },
+ advisorNo: String(advisorNo),
+ story,
+ makeOverride,
+ allocations,
+ opCode
+ });
+
+ // Add roNo for linking to existing RO
+ payload.roNo = String(roNo);
+ payload.outsdRoNo = job?.ro_number || job?.id || undefined;
+
+ // Keep rolabor - it's needed to register the job/OpCode accounts in Reynolds
+ // Without this, Reynolds won't recognize the OpCode when we send rogg operations
+ // The rolabor section tells Reynolds "these jobs exist" even with minimal data
+
+ CreateRRLogEvent(socket, "INFO", "Sending full data for early RO (using create with roNo)", {
+ roNo: String(roNo),
+ hasRolabor: !!payload.rolabor,
+ hasRogg: !!payload.rogg,
+ payload
+ });
+
+ // Use createRepairOrder (not update) with the roNo to link to the existing early RO
+ // Reynolds will merge this with the existing RO header
+ const response = await client.createRepairOrder(payload, finalOpts);
+
+ CreateRRLogEvent(socket, "INFO", "RR Repair Order full data sent", {
+ payload,
+ response
+ });
+
+ const data = response?.data || null;
+ const statusBlocks = response?.statusBlocks || {};
+ const roStatus = deriveRRStatus(response);
+
+ const statusUpper = roStatus?.status ? String(roStatus.status).toUpperCase() : null;
+
+ let success = false;
+
+ if (statusUpper) {
+ success = !["FAILURE", "ERROR"].includes(statusUpper);
+ } else if (typeof response?.success === "boolean") {
+ success = response.success;
+ } else if (roStatus?.status) {
+ success = String(roStatus.status).toUpperCase() === "SUCCESS";
+ }
+
+ return {
+ success,
+ data,
+ roStatus,
+ statusBlocks,
+ customerNo: String(selected),
+ svId,
+ roNo: String(roNo),
+ xml: response?.xml
+ };
+};
+
+/**
+ * LEGACY: Step 1: Export a job to RR as a new Repair Order with full data.
+ * This is the original function - kept for backward compatibility if shops don't use early RO creation.
* @param args
* @returns {Promise<{success: boolean, data: *, roStatus: {status: *, statusCode: *|undefined, message}, statusBlocks: *|{}, customerNo: string, svId: *, roNo: *, xml: *}>}
*/
@@ -315,4 +627,10 @@ const finalizeRRRepairOrder = async (args) => {
};
};
-module.exports = { exportJobToRR, finalizeRRRepairOrder, deriveRRStatus };
+module.exports = {
+ exportJobToRR,
+ createMinimalRRRepairOrder,
+ updateRRRepairOrderWithFullData,
+ finalizeRRRepairOrder,
+ deriveRRStatus
+};
diff --git a/server/rr/rr-register-socket-events.js b/server/rr/rr-register-socket-events.js
index a039760f5..98897eaa7 100644
--- a/server/rr/rr-register-socket-events.js
+++ b/server/rr/rr-register-socket-events.js
@@ -1,7 +1,12 @@
const CreateRRLogEvent = require("./rr-logger-event");
const { rrCombinedSearch, rrGetAdvisors, buildClientAndOpts } = require("./rr-lookup");
const { QueryJobData, buildRogogFromAllocations, buildRolaborFromRogog } = require("./rr-job-helpers");
-const { exportJobToRR, finalizeRRRepairOrder } = require("./rr-job-export");
+const {
+ exportJobToRR,
+ createMinimalRRRepairOrder,
+ updateRRRepairOrderWithFullData,
+ finalizeRRRepairOrder
+} = require("./rr-job-export");
const RRCalculateAllocations = require("./rr-calculate-allocations").default;
const { createRRCustomer } = require("./rr-customers");
const { ensureRRServiceVehicle } = require("./rr-service-vehicles");
@@ -124,13 +129,15 @@ const getBodyshopForSocket = async ({ bodyshopId, socket }) => {
};
/**
- * GraphQL mutation to set job.dms_id
+ * GraphQL mutation to set job.dms_id, dms_customer_id, and dms_advisor_id
* @param socket
* @param jobId
* @param dmsId
+ * @param dmsCustomerId
+ * @param dmsAdvisorId
* @returns {Promise}
*/
-const setJobDmsIdForSocket = async ({ socket, jobId, dmsId }) => {
+const setJobDmsIdForSocket = async ({ socket, jobId, dmsId, dmsCustomerId, dmsAdvisorId, mileageIn }) => {
if (!jobId || !dmsId) {
CreateRRLogEvent(socket, "WARN", "setJobDmsIdForSocket called without jobId or dmsId", {
jobId,
@@ -149,16 +156,28 @@ const setJobDmsIdForSocket = async ({ socket, jobId, dmsId }) => {
const client = new GraphQLClient(endpoint, {});
await client
.setHeaders({ Authorization: `Bearer ${token}` })
- .request(queries.SET_JOB_DMS_ID, { id: jobId, dms_id: String(dmsId) });
+ .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,
- dmsId: String(dmsId)
+ dmsId: String(dmsId),
+ dmsCustomerId,
+ dmsAdvisorId,
+ mileageIn
});
} catch (err) {
CreateRRLogEvent(socket, "ERROR", "Failed to set job.dms_id after RR create/update", {
jobId,
dmsId,
+ dmsCustomerId,
+ dmsAdvisorId,
+ mileageIn,
message: err?.message || String(err),
stack: err?.stack
});
@@ -373,7 +392,504 @@ const registerRREvents = ({ socket, redisHelpers }) => {
}
});
- socket.on("rr-export-job", async ({ jobid, jobId, txEnvelope } = {}) => {
+ /**
+ * NEW: Early RO Creation Event
+ * Creates a minimal RO from convert button or admin page with customer selection,
+ * advisor, mileage, and optional story/overrides.
+ */
+ socket.on("rr-create-early-ro", async ({ jobid, jobId, txEnvelope } = {}) => {
+ const rid = resolveJobId(jobid || jobId, { jobId, jobid }, null);
+
+ try {
+ if (!rid) throw new Error("RR early create: jobid required");
+
+ CreateRRLogEvent(socket, "DEBUG", `{EARLY-1} Received RR early RO creation request`, { jobid: rid });
+
+ // Cache txEnvelope (contains advisor, mileage, story, overrides)
+ await redisHelpers.setSessionTransactionData(
+ socket.id,
+ getTransactionType(rid),
+ RRCacheEnums.txEnvelope,
+ txEnvelope || {},
+ defaultRRTTL
+ );
+
+ CreateRRLogEvent(socket, "DEBUG", `{EARLY-1.1} Cached txEnvelope`, { hasTxEnvelope: !!txEnvelope });
+
+ const job = await QueryJobData({ redisHelpers }, rid);
+ await redisHelpers.setSessionTransactionData(
+ socket.id,
+ getTransactionType(rid),
+ RRCacheEnums.JobData,
+ job,
+ defaultRRTTL
+ );
+
+ CreateRRLogEvent(socket, "DEBUG", `{EARLY-1.2} Cached JobData`, { vin: job?.v_vin, ro: job?.ro_number });
+
+ const adv = readAdvisorNo(
+ { txEnvelope },
+ await redisHelpers.getSessionTransactionData(socket.id, getTransactionType(rid), RRCacheEnums.AdvisorNo)
+ );
+
+ if (adv) {
+ await redisHelpers.setSessionTransactionData(
+ socket.id,
+ getTransactionType(rid),
+ RRCacheEnums.AdvisorNo,
+ String(adv),
+ defaultRRTTL
+ );
+
+ CreateRRLogEvent(socket, "DEBUG", `{EARLY-1.3} Cached advisorNo`, { advisorNo: String(adv) });
+ }
+
+ const { bodyshopId } = await getSessionOrSocket(redisHelpers, socket);
+ const bodyshop = await getBodyshopForSocket({ bodyshopId, socket });
+
+ CreateRRLogEvent(socket, "DEBUG", `{EARLY-2} Running multi-search (Full Name + VIN)`);
+
+ const candidates = await rrMultiCustomerSearch({ bodyshop, job, socket, redisHelpers });
+ const decorated = candidates.map((c) => (c.vinOwner != null ? c : { ...c, vinOwner: !!c.isVehicleOwner }));
+
+ socket.emit("rr-select-customer", decorated);
+ CreateRRLogEvent(socket, "DEBUG", `{EARLY-2.1} Emitted rr-select-customer for early RO`, {
+ count: decorated.length,
+ anyOwner: decorated.some((c) => c.vinOwner || c.isVehicleOwner)
+ });
+ } catch (error) {
+ CreateRRLogEvent(socket, "ERROR", `Error during RR early RO creation (prepare)`, {
+ error: error.message,
+ stack: error.stack,
+ jobid: rid
+ });
+
+ try {
+ socket.emit("export-failed", { vendor: "rr", jobId: rid, error: error.message });
+ } catch {
+ //
+ }
+ }
+ });
+
+ /**
+ * NEW: Early RO Customer Selected Event
+ * Handles customer selection for early RO creation and creates minimal RO.
+ */
+ socket.on("rr-early-customer-selected", async ({ jobid, jobId, selectedCustomerId, custNo, create } = {}, ack) => {
+ const rid = resolveJobId(jobid || jobId, { jobid, jobId }, null);
+ let bodyshop = null;
+ let job = null;
+ let createdCustomer = false;
+
+ try {
+ if (!rid) throw new Error("jobid required");
+ CreateRRLogEvent(socket, "DEBUG", `{EARLY-3} rr-early-customer-selected`, {
+ jobid: rid,
+ custNo,
+ selectedCustomerId,
+ create: !!create
+ });
+
+ const ns = getTransactionType(rid);
+
+ CreateRRLogEvent(socket, "DEBUG", `{EARLY-3.0a} Raw parameters received`, {
+ custNo: custNo,
+ custNoType: typeof custNo,
+ selectedCustomerId: selectedCustomerId,
+ create: create
+ });
+
+ let selectedCustNo =
+ (custNo && String(custNo)) ||
+ (selectedCustomerId && String(selectedCustomerId)) ||
+ (await redisHelpers.getSessionTransactionData(socket.id, ns, RRCacheEnums.SelectedCustomer));
+
+ CreateRRLogEvent(socket, "DEBUG", `{EARLY-3.0b} After initial resolution`, {
+ selectedCustNo,
+ selectedCustNoType: typeof selectedCustNo
+ });
+
+ // Filter out invalid values
+ if (selectedCustNo === "undefined" || selectedCustNo === "null" || (selectedCustNo && selectedCustNo.trim() === "")) {
+ selectedCustNo = null;
+ }
+
+ CreateRRLogEvent(socket, "DEBUG", `{EARLY-3.0} Resolved customer selection`, {
+ selectedCustNo,
+ willCreateNew: create === true || !selectedCustNo
+ });
+
+ job = await redisHelpers.getSessionTransactionData(socket.id, ns, RRCacheEnums.JobData);
+
+ const txEnvelope = (await redisHelpers.getSessionTransactionData(socket.id, ns, RRCacheEnums.txEnvelope)) || {};
+
+ if (!job) throw new Error("Staged JobData not found (run rr-create-early-ro first).");
+
+ const { bodyshopId } = await getSessionOrSocket(redisHelpers, socket);
+
+ bodyshop = await getBodyshopForSocket({ bodyshopId, socket });
+
+ // Create customer (if requested or none chosen)
+ if (create === true || !selectedCustNo) {
+ CreateRRLogEvent(socket, "DEBUG", `{EARLY-3.1} Creating RR customer`);
+
+ const created = await createRRCustomer({ bodyshop, job, socket });
+ selectedCustNo = String(created?.customerNo || "");
+
+ CreateRRLogEvent(socket, "DEBUG", `{EARLY-3.2} Created customer`, {
+ custNo: selectedCustNo,
+ createdCustomerNo: created?.customerNo
+ });
+
+ if (!selectedCustNo || selectedCustNo === "undefined" || selectedCustNo.trim() === "") {
+ throw new Error("RR create customer returned no valid custNo");
+ }
+
+ createdCustomer = true;
+ }
+
+ // VIN owner pre-check
+ try {
+ const vehQ = makeVehicleSearchPayloadFromJob(job);
+ if (vehQ && vehQ.kind === "vin" && job?.v_vin) {
+ const vinResponse = await rrCombinedSearch(bodyshop, vehQ);
+
+ CreateRRLogEvent(socket, "SILLY", `VIN owner pre-check response (early RO)`, { response: vinResponse });
+
+ const vinBlocks = Array.isArray(vinResponse?.data) ? vinResponse.data : [];
+
+ try {
+ await redisHelpers.setSessionTransactionData(
+ socket.id,
+ ns,
+ RRCacheEnums.VINCandidates,
+ vinBlocks,
+ defaultRRTTL
+ );
+ } catch {
+ //
+ }
+
+ const ownersSet = ownersFromVinBlocks(vinBlocks, job.v_vin);
+
+ if (ownersSet?.size) {
+ const sel = String(selectedCustNo);
+
+ if (!ownersSet.has(sel)) {
+ const [existingOwner] = Array.from(ownersSet).map(String);
+ CreateRRLogEvent(socket, "DEBUG", `{EARLY-3.2a} VIN exists; switching to VIN owner`, {
+ vin: job.v_vin,
+ selected: sel,
+ existingOwner
+ });
+ selectedCustNo = existingOwner;
+ }
+ }
+ }
+ } catch (e) {
+ CreateRRLogEvent(socket, "WARN", `VIN owner pre-check failed; continuing with selected customer (early RO)`, {
+ error: e?.message
+ });
+ }
+
+ // Cache final/effective customer selection
+ const effectiveCustNo = String(selectedCustNo);
+
+ await redisHelpers.setSessionTransactionData(
+ socket.id,
+ ns,
+ RRCacheEnums.SelectedCustomer,
+ effectiveCustNo,
+ defaultRRTTL
+ );
+
+ CreateRRLogEvent(socket, "DEBUG", `{EARLY-3.3} Cached selected customer`, { custNo: effectiveCustNo });
+
+ // Build client & routing
+ const { client, opts } = await buildClientAndOpts(bodyshop);
+ const routing = opts?.routing || client?.opts?.routing || null;
+ if (!routing?.dealerNumber) throw new Error("ensureRRServiceVehicle: routing.dealerNumber required");
+
+ // Reconstruct a lightweight tx object
+ const tx = {
+ jobData: {
+ ...job,
+ vin: job?.v_vin
+ },
+ txEnvelope
+ };
+
+ const vin = resolveVin({ tx, job });
+
+ if (!vin) {
+ CreateRRLogEvent(socket, "ERROR", "{EARLY-3.x} No VIN found for ensureRRServiceVehicle", { jobid: rid });
+ throw new Error("ensureRRServiceVehicle: vin required");
+ }
+
+ CreateRRLogEvent(socket, "DEBUG", "{EARLY-3.4} ensureRRServiceVehicle: starting", {
+ jobid: rid,
+ selectedCustomerNo: effectiveCustNo,
+ vin,
+ dealerNumber: routing.dealerNumber,
+ storeNumber: routing.storeNumber,
+ areaNumber: routing.areaNumber
+ });
+
+ const ensured = await ensureRRServiceVehicle({
+ client,
+ routing,
+ bodyshop,
+ selectedCustomerNo: effectiveCustNo,
+ custNo: effectiveCustNo,
+ customerNo: effectiveCustNo,
+ vin,
+ job,
+ socket,
+ redisHelpers
+ });
+
+ CreateRRLogEvent(socket, "DEBUG", "{EARLY-3.5} ensureRRServiceVehicle: done", ensured);
+
+ const cachedAdvisor = await redisHelpers.getSessionTransactionData(socket.id, ns, RRCacheEnums.AdvisorNo);
+ const advisorNo = readAdvisorNo({ txEnvelope }, cachedAdvisor);
+
+ if (!advisorNo) {
+ CreateRRLogEvent(socket, "ERROR", `Advisor is required (advisorNo) for early RO`);
+ await insertRRFailedExportLog({
+ socket,
+ jobId: rid,
+ job,
+ bodyshop,
+ error: new Error("Advisor is required (advisorNo)."),
+ classification: { errorCode: "RR_MISSING_ADVISOR", friendlyMessage: "Advisor is required." }
+ });
+ socket.emit("export-failed", { vendor: "rr", jobId: rid, error: "Advisor is required (advisorNo)." });
+ return ack?.({ ok: false, error: "Advisor is required (advisorNo)." });
+ }
+
+ await redisHelpers.setSessionTransactionData(
+ socket.id,
+ ns,
+ RRCacheEnums.AdvisorNo,
+ String(advisorNo),
+ defaultRRTTL
+ );
+
+ // CREATE MINIMAL RO (early creation)
+ CreateRRLogEvent(socket, "DEBUG", `{EARLY-4} Creating minimal RR RO`);
+ const result = await createMinimalRRRepairOrder({
+ bodyshop,
+ job,
+ selectedCustomer: { customerNo: effectiveCustNo, custNo: effectiveCustNo },
+ advisorNo: String(advisorNo),
+ txEnvelope,
+ socket,
+ svId: ensured?.svId || null
+ });
+
+ // Cache raw export result + pending RO number
+ await redisHelpers.setSessionTransactionData(
+ socket.id,
+ ns,
+ RRCacheEnums.ExportResult,
+ result || {},
+ defaultRRTTL
+ );
+
+ if (result?.success) {
+ const data = result?.data || {};
+
+ // Prefer explicit return from export function; then fall back to fields
+ const dmsRoNo = result?.roNo ?? data?.dmsRoNo ?? null;
+
+ 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
+ });
+
+ // ✅ 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,
+ dmsId: dmsRoNo,
+ customerId: effectiveCustNo,
+ advisorId: String(advisorNo),
+ mileageIn
+ });
+ 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
+ }
+ }
+ });
+ }
+
+ await redisHelpers.setSessionTransactionData(
+ socket.id,
+ ns,
+ RRCacheEnums.PendingRO,
+ {
+ outsdRoNo,
+ dmsRoNo,
+ customerNo: String(effectiveCustNo),
+ advisorNo: String(advisorNo),
+ vin: job?.v_vin || null,
+ earlyRoCreated: true // Flag to indicate this was an early RO
+ },
+ defaultRRTTL
+ );
+
+ CreateRRLogEvent(socket, "INFO", `{EARLY-5} Minimal RO created successfully`, {
+ dmsRoNo: dmsRoNo || null,
+ outsdRoNo: outsdRoNo || null
+ });
+
+ // Mark success in export logs
+ await markRRExportSuccess({
+ socket,
+ jobId: rid,
+ job,
+ bodyshop,
+ result,
+ isEarlyRo: true
+ });
+
+ // Tell FE that early RO was created
+ socket.emit("rr-early-ro-created", { jobId: rid, dmsRoNo, outsdRoNo });
+
+ // Emit result
+ socket.emit("rr-create-early-ro:result", { jobId: rid, bodyshopId: bodyshop?.id, result });
+
+ // ACK with RO details
+ ack?.({
+ ok: true,
+ dmsRoNo,
+ outsdRoNo,
+ result,
+ custNo: String(effectiveCustNo),
+ createdCustomer,
+ earlyRoCreated: true
+ });
+ } else {
+ // classify & fail
+ const tx = result?.statusBlocks?.transaction;
+
+ const vendorStatusCode = Number(
+ result?.roStatus?.statusCode ?? result?.roStatus?.StatusCode ?? tx?.statusCode ?? tx?.StatusCode
+ );
+
+ const vendorMessage =
+ result?.roStatus?.message ??
+ result?.roStatus?.Message ??
+ tx?.message ??
+ tx?.Message ??
+ result?.error ??
+ "RR early RO creation failed";
+
+ const cls = classifyRRVendorError({
+ code: vendorStatusCode,
+ message: vendorMessage
+ });
+
+ CreateRRLogEvent(socket, "ERROR", `Early RO creation failed`, {
+ roStatus: result?.roStatus,
+ statusBlocks: result?.statusBlocks,
+ classification: cls
+ });
+
+ await insertRRFailedExportLog({
+ socket,
+ jobId: rid,
+ job,
+ bodyshop,
+ error: new Error(cls.friendlyMessage || result?.error || "RR early RO creation failed"),
+ classification: cls,
+ result
+ });
+
+ socket.emit("export-failed", {
+ vendor: "rr",
+ jobId: rid,
+ error: cls?.friendlyMessage || result?.error || "RR early RO creation failed",
+ ...cls
+ });
+
+ ack?.({
+ ok: false,
+ error: cls.friendlyMessage || result?.error || "RR early RO creation failed",
+ result,
+ classification: cls
+ });
+ }
+ } catch (error) {
+ const cls = classifyRRVendorError(error);
+
+ CreateRRLogEvent(socket, "ERROR", `Error during RR early RO creation (customer-selected)`, {
+ error: error.message,
+ vendorStatusCode: cls.vendorStatusCode,
+ code: cls.errorCode,
+ friendly: cls.friendlyMessage,
+ stack: error.stack,
+ jobid: rid
+ });
+
+ try {
+ if (!bodyshop || !job) {
+ const { bodyshopId } = await getSessionOrSocket(redisHelpers, socket);
+ bodyshop = bodyshop || (await getBodyshopForSocket({ bodyshopId, socket }));
+ job =
+ job ||
+ (await redisHelpers.getSessionTransactionData(socket.id, getTransactionType(rid), RRCacheEnums.JobData));
+ }
+ } catch {
+ //
+ }
+
+ await insertRRFailedExportLog({
+ socket,
+ jobId: rid,
+ job,
+ bodyshop,
+ error,
+ classification: cls
+ });
+
+ try {
+ socket.emit("export-failed", {
+ vendor: "rr",
+ jobId: rid,
+ error: error.message,
+ ...cls
+ });
+ socket.emit("rr-user-notice", { jobId: rid, ...cls });
+ } catch {
+ //
+ }
+
+ ack?.({ ok: false, error: cls.friendlyMessage || error.message, classification: cls });
+ }
+ });
+
+ socket.on("rr-export-job", async ({ jobid, jobId, txEnvelope } = {}, ack) => {
const rid = resolveJobId(jobid || jobId, { jobId, jobid }, null);
try {
@@ -422,6 +938,139 @@ const registerRREvents = ({ socket, redisHelpers }) => {
const { bodyshopId } = await getSessionOrSocket(redisHelpers, socket);
const bodyshop = await getBodyshopForSocket({ bodyshopId, socket });
+ // 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(
+ socket.id,
+ getTransactionType(rid),
+ RRCacheEnums.SelectedCustomer,
+ String(job.dms_customer_id),
+ defaultRRTTL
+ );
+ }
+ if (job.dms_advisor_id) {
+ await redisHelpers.setSessionTransactionData(
+ socket.id,
+ getTransactionType(rid),
+ RRCacheEnums.AdvisorNo,
+ String(job.dms_advisor_id),
+ 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;
+ if (!routing?.dealerNumber) throw new Error("ensureRRServiceVehicle: routing.dealerNumber required");
+
+ const tx = {
+ jobData: {
+ ...job,
+ vin: job?.v_vin
+ },
+ txEnvelope
+ };
+
+ const vin = resolveVin({ tx, job });
+ if (!vin) {
+ CreateRRLogEvent(socket, "ERROR", "{3.x} No VIN found for ensureRRServiceVehicle", { jobid: rid });
+ throw new Error("ensureRRServiceVehicle: vin required");
+ }
+
+ const ensured = await ensureRRServiceVehicle({
+ client,
+ routing,
+ bodyshop,
+ selectedCustomerNo: String(selectedCustNo),
+ custNo: String(selectedCustNo),
+ customerNo: String(selectedCustNo),
+ vin,
+ job,
+ socket,
+ redisHelpers
+ });
+
+ 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).");
+ }
+
+ // UPDATE existing RO with full data
+ CreateRRLogEvent(socket, "DEBUG", `{4} Updating existing RR RO with full data`, { dmsRoNo: job.dms_id });
+ const result = await updateRRRepairOrderWithFullData({
+ bodyshop,
+ job,
+ selectedCustomer: { customerNo: String(selectedCustNo), custNo: String(selectedCustNo) },
+ advisorNo: String(advisorNo),
+ txEnvelope,
+ socket,
+ svId: ensured?.svId || null,
+ roNo: job.dms_id
+ });
+
+ if (!result?.success) {
+ throw new Error(result?.roStatus?.message || "Failed to update RR Repair Order");
+ }
+
+ const dmsRoNo = result?.roNo ?? result?.data?.dmsRoNo ?? job.dms_id;
+
+ await redisHelpers.setSessionTransactionData(
+ socket.id,
+ getTransactionType(rid),
+ RRCacheEnums.ExportResult,
+ result || {},
+ defaultRRTTL
+ );
+
+ await redisHelpers.setSessionTransactionData(
+ socket.id,
+ getTransactionType(rid),
+ RRCacheEnums.PendingRO,
+ {
+ outsdRoNo: result?.data?.outsdRoNo ?? job?.ro_number ?? job?.id ?? null,
+ dmsRoNo,
+ customerNo: String(selectedCustNo),
+ advisorNo: String(advisorNo),
+ vin: job?.v_vin || null,
+ isUpdate: true
+ },
+ defaultRRTTL
+ );
+
+ CreateRRLogEvent(socket, "INFO", `RR Repair Order updated successfully`, {
+ 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 });
+ }
+
CreateRRLogEvent(socket, "DEBUG", `{2} Running multi-search (Full Name + VIN)`);
const candidates = await rrMultiCustomerSearch({ bodyshop, job, socket, redisHelpers });
@@ -620,17 +1269,59 @@ const registerRREvents = ({ socket, redisHelpers }) => {
defaultRRTTL
);
- // CREATE/UPDATE (first step only)
- CreateRRLogEvent(socket, "DEBUG", `{4} Performing RR create/update (step 1)`);
- const result = await exportJobToRR({
- bodyshop,
- job,
- selectedCustomer: { customerNo: effectiveCustNo, custNo: effectiveCustNo },
- advisorNo: String(advisorNo),
- txEnvelope,
- socket,
- svId: ensured?.svId || null
- });
+ // Check if this job already has an early RO created (check job.dms_id)
+ // If so, we'll use stored customer/advisor IDs and do a full data UPDATE instead of CREATE
+ const existingDmsId = job?.dms_id || null;
+ const shouldUpdate = !!existingDmsId;
+
+ // 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`, {
+ storedCustomerId: job.dms_customer_id,
+ originalCustomerId: effectiveCustNo
+ });
+ finalEffectiveCustNo = String(job.dms_customer_id);
+ }
+
+ if (shouldUpdate && job?.dms_advisor_id) {
+ CreateRRLogEvent(socket, "DEBUG", `Using stored advisor ID from early RO`, {
+ storedAdvisorId: job.dms_advisor_id,
+ 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 });
+ result = await updateRRRepairOrderWithFullData({
+ bodyshop,
+ job,
+ selectedCustomer: { customerNo: finalEffectiveCustNo, custNo: finalEffectiveCustNo },
+ advisorNo: String(finalAdvisorNo),
+ txEnvelope,
+ socket,
+ svId: ensured?.svId || null,
+ roNo: existingDmsId
+ });
+ } else {
+ // CREATE new RO (legacy flow - full data on first create)
+ CreateRRLogEvent(socket, "DEBUG", `{4} Performing RR create (step 1 - full data)`);
+ result = await exportJobToRR({
+ bodyshop,
+ job,
+ selectedCustomer: { customerNo: finalEffectiveCustNo, custNo: finalEffectiveCustNo },
+ advisorNo: String(finalAdvisorNo),
+ txEnvelope,
+ socket,
+ svId: ensured?.svId || null
+ });
+ }
// Cache raw export result + pending RO number for finalize
await redisHelpers.setSessionTransactionData(