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. +
+
+ + + +
+
+ } + /> + + ); + } + 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. +
+ )}
- {/* Advisor + inline Refresh */} -
- - - - { + const value = getAdvisorNumber(a); + if (value == null) return null; + return { value: String(value), label: getAdvisorLabel(a) || String(value) }; + }) + .filter(Boolean)} + notFoundContent={advLoading ? t("general.labels.loading") : t("general.labels.none")} + /> + + - )} - - } - > - - - - - - - - - - - - - + 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 && ( + + )} + + } + > + + + + + + + + + + + + + + )} @@ -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 ( = - ); 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 ( +
+
+
+ + +
+ + ); +} + +/** + * 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. + + + + + + + + + + + + {/* RR OpCode (prefix / base / suffix) */} + + + + + + + + + + + + + + + + + + +
+ + + {showCancelButton && } + +
+ +
+ ); +} 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 */} - */} + + + ({ 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 = ( -
-
{ + console.log("Early RO Success - result:", result); + setEarlyRoCreated(true); // Mark early RO as created + notification.success({ + title: t("jobs.successes.early_ro_created", "Early RO Created"), + message: `RO Number: ${result.roNumber || "N/A"}` + }); + // Delay refetch to keep success message visible for 2 seconds + setTimeout(() => { + refetch?.(); + }, 2000); + }; + + const handleModalClose = () => { + setOpen(false); + }; + + if (job.converted) return <>; + + return ( + <> + - - - -
- ); - if (job.converted) return <>; - - return ( - - - + + + + + + + ); } 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 && ( + + )} @@ -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/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(
+ {/* Hidden field to preserve jobline ID without injecting a div under