feature/IO-3558-Reynolds-Part-2 - Initial

This commit is contained in:
Dave
2026-02-11 15:33:59 -05:00
parent 2333067e02
commit c46804cfdf
23 changed files with 1928 additions and 277 deletions

View File

@@ -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: {

View File

@@ -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 (
<div style={{ marginBottom: 16 }}>
<Alert
type="info"
showIcon
title="Complete Validation in Reynolds"
description={
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<div>
We created the Repair Order. Please validate the totals and taxes in the DMS system. When done,
click <strong>Finished</strong> to finalize and mark this export as complete.
</div>
<div>
<Space>
<Button type="primary" onClick={onValidationFinished}>
Finished
</Button>
</Space>
</div>
</div>
}
/>
</div>
);
}
return null;
}
return (
<Col span={24}>
<Modal
open={open}
onCancel={handleClose}
footer={null}
width={800}
title={t("dms.selectCustomer")}
>
<Table
title={() => (
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
@@ -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 && (
<Alert
type="info"
showIcon
@@ -262,6 +313,6 @@ export default function RRCustomerSelector({
getCheckboxProps: (record) => ({ disabled: rrDisableRow(record) })
}}
/>
</Col>
</Modal>
);
}

View File

@@ -69,7 +69,7 @@ export function DmsLogEvents({
return {
key: idx,
color: logLevelColor(level),
children: (
content: (
<Space orientation="vertical" size={4} style={{ display: "flex" }}>
{/* Row 1: summary + inline "Details" toggle */}
<Space wrap align="start">
@@ -113,7 +113,7 @@ export function DmsLogEvents({
[logs, openSet, colorizeJson, isDarkMode, showDetails]
);
return <Timeline pending reverse items={items} />;
return <Timeline reverse items={items} />;
}
/**

View File

@@ -208,8 +208,18 @@ export default function RRPostForm({
});
};
// Check if early RO was created (job has dms_id)
const hasEarlyRO = !!job?.dms_id;
return (
<Card title={t("jobs.labels.dms.postingform")}>
{hasEarlyRO && (
<Typography.Paragraph type="success" strong style={{ marginBottom: 16 }}>
Early RO Created: {job.dms_id}
<br />
<Typography.Text type="secondary">This will update the existing RO with full job data.</Typography.Text>
</Typography.Paragraph>
)}
<Form
form={form}
layout="vertical"
@@ -218,96 +228,96 @@ export default function RRPostForm({
initialValues={initialValues}
>
<Row gutter={[16, 12]} align="bottom">
{/* Advisor + inline Refresh */}
<Col xs={24} sm={24} md={12} lg={8}>
<Form.Item label={t("jobs.fields.dms.advisor")} required>
<Space.Compact block>
<Form.Item
name="advisorNo"
noStyle
rules={[{ required: true, message: t("general.validation.required") }]}
>
<Select
style={{ flex: 1 }}
loading={advLoading}
allowClear
placeholder={t("general.actions.select", "Select...")}
popupMatchSelectWidth
options={advisors
.map((a) => {
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")}
/>
</Form.Item>
<Tooltip title={t("general.actions.refresh")}>
<Button
aria-label={t("general.actions.refresh")}
icon={<ReloadOutlined />}
onClick={() => fetchRrAdvisors(true)}
loading={advLoading}
/>
</Tooltip>
</Space.Compact>
</Form.Item>
</Col>
{/* RR OpCode (prefix / base / suffix) */}
<Col xs={24} sm={12} md={12} lg={8}>
<Form.Item
required
label={
<Space size="small" align="center">
{t("jobs.fields.dms.rr_opcode", "RR OpCode")}
{isCustomOpCode && (
{/* Advisor + inline Refresh - Only show if no early RO */}
{!hasEarlyRO && (
<Col xs={24} sm={24} md={12} lg={8}>
<Form.Item label={t("jobs.fields.dms.advisor")} required>
<Space.Compact block>
<Form.Item
name="advisorNo"
noStyle
rules={[{ required: true, message: t("general.validation.required") }]}
>
<Select
style={{ flex: 1 }}
loading={advLoading}
allowClear
placeholder={t("general.actions.select", "Select...")}
popupMatchSelectWidth
options={advisors
.map((a) => {
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")}
/>
</Form.Item>
<Tooltip title={t("general.actions.refresh")}>
<Button
type="link"
size="small"
icon={<RollbackOutlined />}
onClick={handleResetOpCode}
style={{ padding: 0 }}
>
{t("jobs.fields.dms.rr_opcode_reset", "Reset")}
</Button>
)}
</Space>
}
>
<Space.Compact block>
<Form.Item name="opPrefix" noStyle>
<Input
allowClear
maxLength={4}
style={{ width: "30%" }}
placeholder={t("jobs.fields.dms.rr_opcode_prefix", "Prefix")}
/>
</Form.Item>
<Form.Item
name="opBase"
noStyle
rules={[{ required: true, message: t("general.validation.required") }]}
>
<Input
allowClear
maxLength={10}
style={{ width: "40%" }}
placeholder={t("jobs.fields.dms.rr_opcode_base", "Base")}
/>
</Form.Item>
<Form.Item name="opSuffix" noStyle>
<Input
allowClear
maxLength={4}
style={{ width: "30%" }}
placeholder={t("jobs.fields.dms.rr_opcode_suffix", "Suffix")}
/>
</Form.Item>
</Space.Compact>
</Form.Item>
</Col>
aria-label={t("general.actions.refresh")}
icon={<ReloadOutlined />}
onClick={() => fetchRrAdvisors(true)}
loading={advLoading}
/>
</Tooltip>
</Space.Compact>
</Form.Item>
</Col>
)}
{/* RR OpCode (prefix / base / suffix) - Only show if no early RO */}
{!hasEarlyRO && (
<Col xs={24} sm={12} md={12} lg={8}>
<Form.Item
required
label={
<Space size="small" align="center">
{t("jobs.fields.dms.rr_opcode", "RR OpCode")}
{isCustomOpCode && (
<Button
type="link"
size="small"
icon={<RollbackOutlined />}
onClick={handleResetOpCode}
style={{ padding: 0 }}
>
{t("jobs.fields.dms.rr_opcode_reset", "Reset")}
</Button>
)}
</Space>
}
>
<Space.Compact block>
<Form.Item name="opPrefix" noStyle>
<Input
allowClear
maxLength={4}
style={{ width: "30%" }}
placeholder={t("jobs.fields.dms.rr_opcode_prefix", "Prefix")}
/>
</Form.Item>
<Form.Item name="opBase" noStyle rules={[{ required: true }]}>
<Input
allowClear
maxLength={10}
style={{ width: "40%" }}
placeholder={t("jobs.fields.dms.rr_opcode_base", "Base")}
/>
</Form.Item>
<Form.Item name="opSuffix" noStyle>
<Input
allowClear
maxLength={4}
style={{ width: "30%" }}
placeholder={t("jobs.fields.dms.rr_opcode_suffix", "Suffix")}
/>
</Form.Item>
</Space.Compact>
</Form.Item>
</Col>
)}
<Col xs={12} sm={8} md={6} lg={4}>
<Form.Item name="kmin" label={t("jobs.fields.kmin")} initialValue={job?.kmin} rules={[{ required: true }]}>
@@ -355,13 +365,14 @@ export default function RRPostForm({
{/* Validation */}
<Form.Item shouldUpdate>
{() => {
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 (
<Space size="large" wrap align="center">
<Statistic title={t("jobs.labels.subtotal")} value={totals.totalSale.toFormat()} />
<Typography.Title>=</Typography.Title>
<Button disabled={!advisorOk} htmlType="submit">
{t("jobs.actions.dms.post")}
<Button disabled={!advisorOk} htmlType="submit" type={hasEarlyRO ? "default" : "primary"}>
{hasEarlyRO ? t("jobs.actions.dms.update_ro", "Update RO") : t("jobs.actions.dms.post")}
</Button>
</Space>
);

View File

@@ -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) => (
<Radio checked={selectedCustNo === record.custNo} onChange={() => 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 (
<div>
<Table columns={columns} dataSource={customers} rowKey="custNo" pagination={false} size="small" />
<div style={{ marginTop: 16, display: "flex", gap: 8 }}>
<Button
type="primary"
onClick={() => onSelect(selectedCustNo, false)}
disabled={!selectedCustNo || isSubmitting}
loading={isSubmitting}
>
Use Selected Customer
</Button>
<Button onClick={() => onSelect(null, true)} disabled={isSubmitting} loading={isSubmitting}>
Create New Customer
</Button>
</div>
</div>
);
}
/**
* 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 (
<Alert
title="Early Reynolds RO Created"
description={`RO Number: ${createdRoNumber || "N/A"} - You can now convert the job.`}
type="success"
showIcon
style={{ marginBottom: 16 }}
/>
);
}
// If showing customer selector, render modal
if (showCustomerSelector) {
return (
<>
<Typography.Title level={5}>Create Early Reynolds RO</Typography.Title>
<Typography.Paragraph type="secondary">Waiting for customer selection...</Typography.Paragraph>
<Modal
title="Select Customer for Early RO"
open={true}
width={800}
footer={null}
onCancel={() => {
setShowCustomerSelector(false);
setIsSubmitting(false);
}}
>
<CustomerSelectorTable
customers={customerCandidates}
onSelect={handleCustomerSelected}
isSubmitting={isSubmitting}
/>
</Modal>
</>
);
}
// 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 (
<div style={{ marginTop: 16 }}>
<Typography.Title level={5}>Create Early Reynolds RO</Typography.Title>
<Typography.Paragraph type="secondary" style={{ fontSize: "12px" }}>
Complete this section to create a minimal RO in Reynolds before converting the job.
</Typography.Paragraph>
<Form form={form} layout="vertical" component={false} initialValues={initialValues}>
<Form.Item name="advisorNo" label="Advisor" rules={[{ required: true, message: "Please select an advisor" }]}>
<Select
showSearch={{
optionFilterProp: "children",
filterOption: (input, option) => (option?.children?.toLowerCase() ?? "").includes(input.toLowerCase())
}}
loading={advLoading}
placeholder="Select advisor..."
popupRender={(menu) => (
<>
{menu}
<Button
type="link"
icon={<ReloadOutlined />}
onClick={() => fetchRrAdvisors(true)}
style={{ width: "100%", textAlign: "left" }}
>
Refresh Advisors
</Button>
</>
)}
>
{advisors.map((adv) => (
<Select.Option key={getAdvisorNumber(adv)} value={getAdvisorNumber(adv)}>
{getAdvisorLabel(adv)}
</Select.Option>
))}
</Select>
</Form.Item>
<Form.Item
name="kmin"
label="Mileage In"
rules={[
{ required: true, message: "Please enter initial mileage" },
{ type: "number", min: 1, message: "Mileage must be greater than 0" }
]}
>
<InputNumber min={1} style={{ width: "100%" }} />
</Form.Item>
{/* RR OpCode (prefix / base / suffix) */}
<Form.Item required label="RR OpCode">
<Space.Compact block>
<Form.Item name="opPrefix" noStyle>
<Input allowClear maxLength={4} style={{ width: "30%" }} placeholder="Prefix" />
</Form.Item>
<Form.Item name="opBase" noStyle rules={[{ required: true, message: "Base Required" }]}>
<Input allowClear maxLength={10} style={{ width: "40%" }} placeholder="Base" />
</Form.Item>
<Form.Item name="opSuffix" noStyle>
<Input allowClear maxLength={4} style={{ width: "30%" }} placeholder="Suffix" />
</Form.Item>
</Space.Compact>
</Form.Item>
<Form.Item name="story" label="Comments / Story (Optional)">
<Input.TextArea rows={2} maxLength={240} showCount placeholder="Enter comments or story..." />
</Form.Item>
<div style={{ marginTop: 16 }}>
<Space>
<Button type="primary" onClick={handleManualSubmit} loading={isSubmitting} disabled={advLoading}>
Create Early RO
</Button>
{showCancelButton && <Button onClick={onCancel}>Cancel</Button>}
</Space>
</div>
</Form>
</div>
);
}

View File

@@ -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 (
<Modal
open={open}
onCancel={onClose}
footer={null}
width={700}
destroyOnHidden
title="Create Reynolds Repair Order"
>
<RREarlyROForm bodyshop={bodyshop} socket={socket} job={job} onSuccess={handleSuccess} onCancel={onClose} />
</Modal>
);
}

View File

@@ -42,11 +42,11 @@ export function JobsCloseLines({ bodyshop, job, jobRO }) {
<tbody>
{fields.map((field, index) => (
<tr key={field.key}>
{/* Hidden field to preserve jobline ID */}
<Form.Item hidden name={[field.name, "id"]}>
<input />
</Form.Item>
<td>
{/* Hidden field to preserve jobline ID without injecting a div under <tr> */}
<Form.Item noStyle name={[field.name, "id"]}>
<input type="hidden" />
</Form.Item>
<Form.Item
// label={t("joblines.fields.line_desc")}
key={`${index}line_desc`}

View File

@@ -1,18 +1,22 @@
import { useMutation } from "@apollo/client/react";
import { Button, Form, Input, Popover, Select, Space, Switch } from "antd";
import { Button, Divider, Form, Input, Modal, Select, Space, Switch } from "antd";
import axios from "axios";
import { some } from "lodash";
import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { CONVERT_JOB_TO_RO } from "../../graphql/jobs.queries";
import { insertAuditTrail } from "../../redux/application/application.actions";
import { selectJobReadOnly } from "../../redux/application/application.selectors";
import { selectBodyshop } from "../../redux/user/user.selectors";
import AuditTrailMapping from "../../utils/AuditTrailMappings";
import InstanceRenderManager from "../../utils/instanceRenderMgr";
import { DMS_MAP, getDmsMode } from "../../utils/dmsUtils";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
import RREarlyROForm from "../dms-post-form/rr-early-ro-form";
const mapStateToProps = createStructuredSelector({
//currentUser: selectCurrentUser
@@ -33,11 +37,29 @@ const mapDispatchToProps = (dispatch) => ({
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,216 @@ export function JobsConvertButton({ bodyshop, job, refetch, jobRO, insertAuditTr
const submitDisabled = useCallback(() => some(allFormValues, (v) => v === undefined), [allFormValues]);
const popMenu = (
<div>
<Form
layout="vertical"
form={form}
onFinish={handleConvert}
initialValues={{
driveable: true,
towin: job.towin,
ca_gst_registrant: job.ca_gst_registrant,
employee_csr: job.employee_csr,
category: job.category,
referral_source: job.referral_source,
referral_source_extra: job.referral_source_extra ?? ""
const handleEarlyROSuccess = (result) => {
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"}`
});
// Don't close the modal - just refetch so the form updates
refetch?.();
};
if (job.converted) return <></>;
return (
<>
<Button
key="convert"
type="primary"
danger
disabled={job.converted || jobRO}
loading={loading}
onClick={() => {
setEarlyRoCreated(!!job?.dms_id); // Initialize state based on current job
setOpen(true);
}}
>
<Form.Item
name={["ins_co_nm"]}
label={t("jobs.fields.ins_co_nm")}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
{t("jobs.actions.convert")}
</Button>
{/* Convert Job Modal */}
<Modal
open={open}
onCancel={() => setOpen(false)}
title={t("jobs.actions.convert")}
footer={null}
width={700}
destroyOnHidden
>
{/* Standard Convert Form */}
<Form
layout="vertical"
form={form}
onFinish={handleConvert}
initialValues={{
driveable: true,
towin: job.towin,
ca_gst_registrant: job.ca_gst_registrant,
employee_csr: job.employee_csr,
category: job.category,
referral_source: job.referral_source,
referral_source_extra: job.referral_source_extra ?? ""
}}
>
<Select showSearch>
{bodyshop.md_ins_cos.map((s, i) => (
<Select.Option key={i} value={s.name}>
{s.name}
</Select.Option>
))}
</Select>
</Form.Item>
{bodyshop.enforce_class && (
<Form.Item
name={"class"}
label={t("jobs.fields.class")}
name={["ins_co_nm"]}
label={t("jobs.fields.ins_co_nm")}
rules={[
{
required: bodyshop.enforce_class
required: true
//message: t("general.validation.required"),
}
]}
>
<Select>
{bodyshop.md_classes.map((s) => (
<Select.Option key={s} value={s}>
{s}
<Select showSearch>
{bodyshop.md_ins_cos.map((s, i) => (
<Select.Option key={i} value={s.name}>
{s.name}
</Select.Option>
))}
</Select>
</Form.Item>
)}
{bodyshop.enforce_referral && (
<>
{bodyshop.enforce_class && (
<Form.Item
name={"referral_source"}
label={t("jobs.fields.referralsource")}
name={"class"}
label={t("jobs.fields.class")}
rules={[
{
required: bodyshop.enforce_referral
required: bodyshop.enforce_class
//message: t("general.validation.required"),
}
]}
>
<Select>
{bodyshop.md_referral_sources.map((s) => (
{bodyshop.md_classes.map((s) => (
<Select.Option key={s} value={s}>
{s}
</Select.Option>
))}
</Select>
</Form.Item>
<Form.Item label={t("jobs.fields.referral_source_extra")} name="referral_source_extra">
<Input />
</Form.Item>
</>
)}
{bodyshop.enforce_conversion_csr && (
<Form.Item
name={"employee_csr"}
label={t(
InstanceRenderManager({
imex: "jobs.fields.employee_csr",
rome: "jobs.fields.employee_csr_writer"
})
)}
rules={[
{
required: bodyshop.enforce_conversion_csr
//message: t("general.validation.required"),
}
]}
>
<Select
showSearch={{
optionFilterProp: "children",
filterOption: (input, option) => option.props.children.toLowerCase().indexOf(input.toLowerCase()) >= 0
}}
style={{ width: 200 }}
)}
{bodyshop.enforce_referral && (
<>
<Form.Item
name={"referral_source"}
label={t("jobs.fields.referralsource")}
rules={[
{
required: bodyshop.enforce_referral
//message: t("general.validation.required"),
}
]}
>
<Select>
{bodyshop.md_referral_sources.map((s) => (
<Select.Option key={s} value={s}>
{s}
</Select.Option>
))}
</Select>
</Form.Item>
<Form.Item label={t("jobs.fields.referral_source_extra")} name="referral_source_extra">
<Input />
</Form.Item>
</>
)}
{bodyshop.enforce_conversion_csr && (
<Form.Item
name={"employee_csr"}
label={t(
InstanceRenderManager({
imex: "jobs.fields.employee_csr",
rome: "jobs.fields.employee_csr_writer"
})
)}
rules={[
{
required: bodyshop.enforce_conversion_csr
//message: t("general.validation.required"),
}
]}
>
{bodyshop.employees
.filter((emp) => emp.active)
.map((emp) => (
<Select.Option value={emp.id} key={emp.id} name={`${emp.first_name} ${emp.last_name}`}>
{`${emp.first_name} ${emp.last_name}`}
<Select
showSearch={{
optionFilterProp: "children",
filterOption: (input, option) => option.props.children.toLowerCase().indexOf(input.toLowerCase()) >= 0
}}
style={{ width: 200 }}
>
{bodyshop.employees
.filter((emp) => emp.active)
.map((emp) => (
<Select.Option value={emp.id} key={emp.id} name={`${emp.first_name} ${emp.last_name}`}>
{`${emp.first_name} ${emp.last_name}`}
</Select.Option>
))}
</Select>
</Form.Item>
)}
{bodyshop.enforce_conversion_category && (
<Form.Item
name={"category"}
label={t("jobs.fields.category")}
rules={[
{
required: bodyshop.enforce_conversion_category
//message: t("general.validation.required"),
}
]}
>
<Select allowClear>
{bodyshop.md_categories.map((s) => (
<Select.Option key={s} value={s}>
{s}
</Select.Option>
))}
</Select>
</Form.Item>
)}
{bodyshop.enforce_conversion_category && (
<Form.Item
name={"category"}
label={t("jobs.fields.category")}
rules={[
{
required: bodyshop.enforce_conversion_category
//message: t("general.validation.required"),
}
]}
>
<Select allowClear>
{bodyshop.md_categories.map((s) => (
<Select.Option key={s} value={s}>
{s}
</Select.Option>
))}
</Select>
</Form.Item>
)}
{bodyshop.region_config.toLowerCase().startsWith("ca") && (
<Form.Item label={t("jobs.fields.ca_gst_registrant")} name="ca_gst_registrant" valuePropName="checked">
</Select>
</Form.Item>
)}
{bodyshop.region_config.toLowerCase().startsWith("ca") && (
<Form.Item label={t("jobs.fields.ca_gst_registrant")} name="ca_gst_registrant" valuePropName="checked">
<Switch />
</Form.Item>
)}
<Form.Item label={t("jobs.fields.driveable")} name="driveable" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item label={t("jobs.fields.towin")} name="towin" valuePropName="checked">
<Switch />
</Form.Item>
)}
<Form.Item label={t("jobs.fields.driveable")} name="driveable" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item label={t("jobs.fields.towin")} name="towin" valuePropName="checked">
<Switch />
</Form.Item>
<Space wrap>
<Button disabled={submitDisabled()} type="primary" danger onClick={() => form.submit()} loading={loading}>
{t("jobs.actions.convert")}
</Button>
<Button onClick={() => setOpen(false)}>{t("general.actions.close")}</Button>
</Space>
</Form>
</div>
);
if (job.converted) return <></>;
{/* Show Reynolds Early RO section if applicable */}
{isReynoldsMode && !job.dms_id && !earlyRoCreated && (
<>
<Divider />
<RREarlyROForm
bodyshop={bodyshop}
socket={socket}
job={job}
onSuccess={handleEarlyROSuccess}
showCancelButton={false}
/>
</>
)}
return (
<Popover open={open} content={popMenu}>
<Button
key="convert"
type="primary"
danger
// style={{ display: job.converted ? "none" : "" }}
disabled={job.converted || jobRO}
loading={loading}
onClick={() => {
setOpen(true);
}}
>
{t("jobs.actions.convert")}
</Button>
</Popover>
<Space wrap style={{ marginTop: 16 }}>
<Button
disabled={submitDisabled() || (isReynoldsMode && !job.dms_id && !earlyRoCreated)}
type="primary"
danger
onClick={() => form.submit()}
loading={loading}
>
{t("jobs.actions.convert")}
</Button>
<Button onClick={() => setOpen(false)}>{t("general.actions.close")}</Button>
</Space>
</Form>
</Modal>
</>
);
}

View File

@@ -2216,6 +2216,9 @@ export const QUERY_JOB_EXPORT_DMS = gql`
plate_no
plate_st
ownr_co_nm
dms_id
dms_customer_id
dms_advisor_id
}
}
`;

View File

@@ -486,6 +486,7 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse
<DmsCustomerSelector
jobid={jobId}
job={data?.jobs_by_pk}
bodyshop={bodyshop}
socket={activeSocket}
mode={mode}

View File

@@ -1,9 +1,10 @@
import { useQuery } from "@apollo/client/react";
import { Card, Col, Result, Row, Space, Typography } from "antd";
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { useParams } from "react-router-dom";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import AlertComponent from "../../components/alert/alert.component";
import JobCalculateTotals from "../../components/job-calculate-totals/job-calculate-totals.component";
import ScoreboardAddButton from "../../components/job-scoreboard-add-button/job-scoreboard-add-button.component";
@@ -19,10 +20,20 @@ import JobsAdminRemoveAR from "../../components/jobs-admin-remove-ar/jobs-admin-
import LoadingSpinner from "../../components/loading-spinner/loading-spinner.component";
import NotFound from "../../components/not-found/not-found.component";
import RbacWrapper from "../../components/rbac-wrapper/rbac-wrapper.component";
import RREarlyROModal from "../../components/dms-post-form/rr-early-ro-modal";
import { GET_JOB_BY_PK } from "../../graphql/jobs.queries";
import { setBreadcrumbs, setSelectedHeader } from "../../redux/application/application.actions";
import { selectBodyshop } from "../../redux/user/user.selectors";
import { createStructuredSelector } from "reselect";
import { useSocket } from "../../contexts/SocketIO/useSocket";
import { useNotification } from "../../contexts/Notifications/notificationContext";
import { DMS_MAP, getDmsMode } from "../../utils/dmsUtils";
import InstanceRenderManager from "../../utils/instanceRenderMgr";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop
});
const mapDispatchToProps = (dispatch) => ({
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 <LoadingSpinner />;
if (error) return <AlertComponent title={error.message} type="error" />;
if (!data.jobs_by_pk) return <NotFound />;
@@ -99,6 +136,15 @@ export function JobsCloseContainer({ setBreadcrumbs, setSelectedHeader }) {
<JobsAdminUnvoid job={data ? data.jobs_by_pk : {}} />
<JobsAdminStatus job={data ? data.jobs_by_pk : {}} />
<JobsAdminRemoveAR job={data ? data.jobs_by_pk : {}} />
{isReynoldsMode && !job?.dms_id && job?.converted && (
<button
className="ant-btn ant-btn-default"
disabled={!!job?.dms_id}
onClick={() => setShowEarlyROModal(true)}
>
Create RR RO
</button>
)}
</Space>
</Card>
</Col>
@@ -124,8 +170,18 @@ export function JobsCloseContainer({ setBreadcrumbs, setSelectedHeader }) {
</Card>
</Col>
</Row>
{/* Early RO Modal */}
<RREarlyROModal
open={showEarlyROModal}
onClose={() => setShowEarlyROModal(false)}
onSuccess={handleEarlyROSuccess}
bodyshop={bodyshop}
socket={socket}
job={job}
/>
</RbacWrapper>
);
}
export default connect(null, mapDispatchToProps)(JobsCloseContainer);
export default connect(mapStateToProps, mapDispatchToProps)(JobsCloseContainer);

View File

@@ -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",

View File

@@ -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": "",

View File

@@ -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": "",

View File

@@ -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

View File

@@ -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;

View File

@@ -0,0 +1,2 @@
alter table "public"."jobs" add column "dms_customer_id" text
null;

View File

@@ -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;

View File

@@ -0,0 +1,2 @@
alter table "public"."jobs" add column "dms_advisor_id" text
null;

View File

@@ -1612,6 +1612,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
@@ -3203,9 +3206,11 @@ 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) {
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 }) {
id
dms_id
dms_customer_id
dms_advisor_id
}
}`;

View File

@@ -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,

View File

@@ -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
};

View File

@@ -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<void>}
*/
const setJobDmsIdForSocket = async ({ socket, jobId, dmsId }) => {
const setJobDmsIdForSocket = async ({ socket, jobId, dmsId, dmsCustomerId, dmsAdvisorId }) => {
if (!jobId || !dmsId) {
CreateRRLogEvent(socket, "WARN", "setJobDmsIdForSocket called without jobId or dmsId", {
jobId,
@@ -149,16 +156,25 @@ 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
});
CreateRRLogEvent(socket, "INFO", "Linked job.dms_id to RR RO", {
jobId,
dmsId: String(dmsId)
dmsId: String(dmsId),
dmsCustomerId,
dmsAdvisorId
});
} catch (err) {
CreateRRLogEvent(socket, "ERROR", "Failed to set job.dms_id after RR create/update", {
jobId,
dmsId,
dmsCustomerId,
dmsAdvisorId,
message: err?.message || String(err),
stack: err?.stack
});
@@ -373,7 +389,501 @@ 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, and advisor ID on the job
if (dmsRoNo) {
CreateRRLogEvent(socket, "DEBUG", "Calling setJobDmsIdForSocket", {
jobId: rid,
dmsId: dmsRoNo,
customerId: effectiveCustNo,
advisorId: String(advisorNo)
});
await setJobDmsIdForSocket({
socket,
jobId: rid,
dmsId: dmsRoNo,
dmsCustomerId: effectiveCustNo,
dmsAdvisorId: String(advisorNo)
});
} 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 +932,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 +1263,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(