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 * @constructor
*/ */
export function DmsCustomerSelector(props) { export function DmsCustomerSelector(props) {
const { bodyshop, jobid, socket, rrOptions = {} } = props; const { bodyshop, jobid, job, socket, rrOptions = {} } = props;
// Centralized "mode" (provider + transport) // Centralized "mode" (provider + transport)
const mode = props.mode; const mode = props.mode;
// Stable base props for children // 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) { switch (mode) {
case DMS_MAP.reynolds: { 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 { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { alphaSort } from "../../utils/sorters"; import { alphaSort } from "../../utils/sorters";
@@ -47,6 +47,7 @@ const rrAddressToString = (addr) => {
export default function RRCustomerSelector({ export default function RRCustomerSelector({
jobid, jobid,
socket, socket,
job,
rrOpenRoLimit = false, rrOpenRoLimit = false,
onRrOpenRoFinished, onRrOpenRoFinished,
rrValidationPending = false, rrValidationPending = false,
@@ -59,15 +60,26 @@ export default function RRCustomerSelector({
const [refreshing, setRefreshing] = useState(false); const [refreshing, setRefreshing] = useState(false);
// Show dialog automatically when validation is pending // Show dialog automatically when validation is pending
// BUT: skip this for early RO flow (job already has dms_id)
useEffect(() => { useEffect(() => {
if (rrValidationPending) setOpen(true); if (rrValidationPending && !job?.dms_id) {
}, [rrValidationPending]); setOpen(true);
}
}, [rrValidationPending, job?.dms_id]);
// Listen for RR customer selection list // Listen for RR customer selection list
useEffect(() => { useEffect(() => {
if (!socket) return; if (!socket) return;
const handleRrSelectCustomer = (list) => { const handleRrSelectCustomer = (list) => {
const normalized = normalizeRrList(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); setOpen(true);
setCustomerList(normalized); setCustomerList(normalized);
const firstOwner = normalized.find((r) => r.vinOwner)?.custNo; const firstOwner = normalized.find((r) => r.vinOwner)?.custNo;
@@ -127,6 +139,10 @@ export default function RRCustomerSelector({
}); });
}; };
const handleClose = () => {
setOpen(false);
};
const refreshRrSearch = () => { const refreshRrSearch = () => {
setRefreshing(true); setRefreshing(true);
const to = setTimeout(() => setRefreshing(false), 12000); const to = setTimeout(() => setRefreshing(false), 12000);
@@ -141,8 +157,6 @@ export default function RRCustomerSelector({
socket.emit("rr-export-job", { jobId: jobid }); socket.emit("rr-export-job", { jobId: jobid });
}; };
if (!open) return null;
const columns = [ const columns = [
{ title: t("jobs.fields.dms.id"), dataIndex: "custNo", key: "custNo" }, { title: t("jobs.fields.dms.id"), dataIndex: "custNo", key: "custNo" },
{ {
@@ -169,8 +183,45 @@ export default function RRCustomerSelector({
return !rrOwnerSet.has(String(record.custNo)); 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 ( return (
<Col span={24}> <Modal
open={open}
onCancel={handleClose}
footer={null}
width={800}
title={t("dms.selectCustomer")}
>
<Table <Table
title={() => ( title={() => (
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}> <div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
@@ -196,8 +247,8 @@ export default function RRCustomerSelector({
/> />
)} )}
{/* Validation step banner */} {/* Validation step banner - only show for NON-early RO flow (legacy) */}
{rrValidationPending && ( {rrValidationPending && !job?.dms_id && (
<Alert <Alert
type="info" type="info"
showIcon showIcon
@@ -262,6 +313,6 @@ export default function RRCustomerSelector({
getCheckboxProps: (record) => ({ disabled: rrDisableRow(record) }) getCheckboxProps: (record) => ({ disabled: rrDisableRow(record) })
}} }}
/> />
</Col> </Modal>
); );
} }

View File

@@ -69,7 +69,7 @@ export function DmsLogEvents({
return { return {
key: idx, key: idx,
color: logLevelColor(level), color: logLevelColor(level),
children: ( content: (
<Space orientation="vertical" size={4} style={{ display: "flex" }}> <Space orientation="vertical" size={4} style={{ display: "flex" }}>
{/* Row 1: summary + inline "Details" toggle */} {/* Row 1: summary + inline "Details" toggle */}
<Space wrap align="start"> <Space wrap align="start">
@@ -113,7 +113,7 @@ export function DmsLogEvents({
[logs, openSet, colorizeJson, isDarkMode, showDetails] [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 ( return (
<Card title={t("jobs.labels.dms.postingform")}> <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={form} form={form}
layout="vertical" layout="vertical"
@@ -218,96 +228,96 @@ export default function RRPostForm({
initialValues={initialValues} initialValues={initialValues}
> >
<Row gutter={[16, 12]} align="bottom"> <Row gutter={[16, 12]} align="bottom">
{/* Advisor + inline Refresh */} {/* Advisor + inline Refresh - Only show if no early RO */}
<Col xs={24} sm={24} md={12} lg={8}> {!hasEarlyRO && (
<Form.Item label={t("jobs.fields.dms.advisor")} required> <Col xs={24} sm={24} md={12} lg={8}>
<Space.Compact block> <Form.Item label={t("jobs.fields.dms.advisor")} required>
<Form.Item <Space.Compact block>
name="advisorNo" <Form.Item
noStyle name="advisorNo"
rules={[{ required: true, message: t("general.validation.required") }]} noStyle
> rules={[{ required: true, message: t("general.validation.required") }]}
<Select >
style={{ flex: 1 }} <Select
loading={advLoading} style={{ flex: 1 }}
allowClear loading={advLoading}
placeholder={t("general.actions.select", "Select...")} allowClear
popupMatchSelectWidth placeholder={t("general.actions.select", "Select...")}
options={advisors popupMatchSelectWidth
.map((a) => { options={advisors
const value = getAdvisorNumber(a); .map((a) => {
if (value == null) return null; const value = getAdvisorNumber(a);
return { value: String(value), label: getAdvisorLabel(a) || String(value) }; 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")} .filter(Boolean)}
/> notFoundContent={advLoading ? t("general.labels.loading") : t("general.labels.none")}
</Form.Item> />
<Tooltip title={t("general.actions.refresh")}> </Form.Item>
<Button <Tooltip title={t("general.actions.refresh")}>
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 && (
<Button <Button
type="link" aria-label={t("general.actions.refresh")}
size="small" icon={<ReloadOutlined />}
icon={<RollbackOutlined />} onClick={() => fetchRrAdvisors(true)}
onClick={handleResetOpCode} loading={advLoading}
style={{ padding: 0 }} />
> </Tooltip>
{t("jobs.fields.dms.rr_opcode_reset", "Reset")} </Space.Compact>
</Button> </Form.Item>
)} </Col>
</Space> )}
}
> {/* RR OpCode (prefix / base / suffix) - Only show if no early RO */}
<Space.Compact block> {!hasEarlyRO && (
<Form.Item name="opPrefix" noStyle> <Col xs={24} sm={12} md={12} lg={8}>
<Input <Form.Item
allowClear required
maxLength={4} label={
style={{ width: "30%" }} <Space size="small" align="center">
placeholder={t("jobs.fields.dms.rr_opcode_prefix", "Prefix")} {t("jobs.fields.dms.rr_opcode", "RR OpCode")}
/> {isCustomOpCode && (
</Form.Item> <Button
<Form.Item type="link"
name="opBase" size="small"
noStyle icon={<RollbackOutlined />}
rules={[{ required: true, message: t("general.validation.required") }]} onClick={handleResetOpCode}
> style={{ padding: 0 }}
<Input >
allowClear {t("jobs.fields.dms.rr_opcode_reset", "Reset")}
maxLength={10} </Button>
style={{ width: "40%" }} )}
placeholder={t("jobs.fields.dms.rr_opcode_base", "Base")} </Space>
/> }
</Form.Item> >
<Form.Item name="opSuffix" noStyle> <Space.Compact block>
<Input <Form.Item name="opPrefix" noStyle>
allowClear <Input
maxLength={4} allowClear
style={{ width: "30%" }} maxLength={4}
placeholder={t("jobs.fields.dms.rr_opcode_suffix", "Suffix")} style={{ width: "30%" }}
/> placeholder={t("jobs.fields.dms.rr_opcode_prefix", "Prefix")}
</Form.Item> />
</Space.Compact> </Form.Item>
</Form.Item> <Form.Item name="opBase" noStyle rules={[{ required: true }]}>
</Col> <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}> <Col xs={12} sm={8} md={6} lg={4}>
<Form.Item name="kmin" label={t("jobs.fields.kmin")} initialValue={job?.kmin} rules={[{ required: true }]}> <Form.Item name="kmin" label={t("jobs.fields.kmin")} initialValue={job?.kmin} rules={[{ required: true }]}>
@@ -355,13 +365,14 @@ export default function RRPostForm({
{/* Validation */} {/* Validation */}
<Form.Item shouldUpdate> <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 ( return (
<Space size="large" wrap align="center"> <Space size="large" wrap align="center">
<Statistic title={t("jobs.labels.subtotal")} value={totals.totalSale.toFormat()} /> <Statistic title={t("jobs.labels.subtotal")} value={totals.totalSale.toFormat()} />
<Typography.Title>=</Typography.Title> <Typography.Title>=</Typography.Title>
<Button disabled={!advisorOk} htmlType="submit"> <Button disabled={!advisorOk} htmlType="submit" type={hasEarlyRO ? "default" : "primary"}>
{t("jobs.actions.dms.post")} {hasEarlyRO ? t("jobs.actions.dms.update_ro", "Update RO") : t("jobs.actions.dms.post")}
</Button> </Button>
</Space> </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> <tbody>
{fields.map((field, index) => ( {fields.map((field, index) => (
<tr key={field.key}> <tr key={field.key}>
{/* Hidden field to preserve jobline ID */}
<Form.Item hidden name={[field.name, "id"]}>
<input />
</Form.Item>
<td> <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 <Form.Item
// label={t("joblines.fields.line_desc")} // label={t("joblines.fields.line_desc")}
key={`${index}line_desc`} key={`${index}line_desc`}

View File

@@ -1,18 +1,22 @@
import { useMutation } from "@apollo/client/react"; 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 axios from "axios";
import { some } from "lodash"; import { some } from "lodash";
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { createStructuredSelector } from "reselect"; import { createStructuredSelector } from "reselect";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { CONVERT_JOB_TO_RO } from "../../graphql/jobs.queries"; import { CONVERT_JOB_TO_RO } from "../../graphql/jobs.queries";
import { insertAuditTrail } from "../../redux/application/application.actions"; import { insertAuditTrail } from "../../redux/application/application.actions";
import { selectJobReadOnly } from "../../redux/application/application.selectors"; import { selectJobReadOnly } from "../../redux/application/application.selectors";
import { selectBodyshop } from "../../redux/user/user.selectors"; import { selectBodyshop } from "../../redux/user/user.selectors";
import AuditTrailMapping from "../../utils/AuditTrailMappings"; import AuditTrailMapping from "../../utils/AuditTrailMappings";
import InstanceRenderManager from "../../utils/instanceRenderMgr"; import InstanceRenderManager from "../../utils/instanceRenderMgr";
import { DMS_MAP, getDmsMode } from "../../utils/dmsUtils";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx"; 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({ const mapStateToProps = createStructuredSelector({
//currentUser: selectCurrentUser //currentUser: selectCurrentUser
@@ -33,11 +37,29 @@ const mapDispatchToProps = (dispatch) => ({
export function JobsConvertButton({ bodyshop, job, refetch, jobRO, insertAuditTrail, parentFormIsFieldsTouched }) { export function JobsConvertButton({ bodyshop, job, refetch, jobRO, insertAuditTrail, parentFormIsFieldsTouched }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [loading, setLoading] = 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 [mutationConvertJob] = useMutation(CONVERT_JOB_TO_RO);
const { t } = useTranslation(); const { t } = useTranslation();
const [form] = Form.useForm(); const [form] = Form.useForm();
const notification = useNotification(); const notification = useNotification();
const allFormValues = Form.useWatch([], form); 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 }) => { const handleConvert = async ({ employee_csr, category, ...values }) => {
if (parentFormIsFieldsTouched()) { if (parentFormIsFieldsTouched()) {
@@ -82,177 +104,216 @@ export function JobsConvertButton({ bodyshop, job, refetch, jobRO, insertAuditTr
const submitDisabled = useCallback(() => some(allFormValues, (v) => v === undefined), [allFormValues]); const submitDisabled = useCallback(() => some(allFormValues, (v) => v === undefined), [allFormValues]);
const popMenu = ( const handleEarlyROSuccess = (result) => {
<div> console.log("Early RO Success - result:", result);
<Form setEarlyRoCreated(true); // Mark early RO as created
layout="vertical" notification.success({
form={form} title: t("jobs.successes.early_ro_created", "Early RO Created"),
onFinish={handleConvert} message: `RO Number: ${result.roNumber || "N/A"}`
initialValues={{ });
driveable: true, // Don't close the modal - just refetch so the form updates
towin: job.towin, refetch?.();
ca_gst_registrant: job.ca_gst_registrant, };
employee_csr: job.employee_csr,
category: job.category, if (job.converted) return <></>;
referral_source: job.referral_source,
referral_source_extra: job.referral_source_extra ?? "" 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 {t("jobs.actions.convert")}
name={["ins_co_nm"]} </Button>
label={t("jobs.fields.ins_co_nm")}
rules={[ {/* Convert Job Modal */}
{ <Modal
required: true open={open}
//message: t("general.validation.required"), 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 <Form.Item
name={"class"} name={["ins_co_nm"]}
label={t("jobs.fields.class")} label={t("jobs.fields.ins_co_nm")}
rules={[ rules={[
{ {
required: bodyshop.enforce_class required: true
//message: t("general.validation.required"), //message: t("general.validation.required"),
} }
]} ]}
> >
<Select> <Select showSearch>
{bodyshop.md_classes.map((s) => ( {bodyshop.md_ins_cos.map((s, i) => (
<Select.Option key={s} value={s}> <Select.Option key={i} value={s.name}>
{s} {s.name}
</Select.Option> </Select.Option>
))} ))}
</Select> </Select>
</Form.Item> </Form.Item>
)} {bodyshop.enforce_class && (
{bodyshop.enforce_referral && (
<>
<Form.Item <Form.Item
name={"referral_source"} name={"class"}
label={t("jobs.fields.referralsource")} label={t("jobs.fields.class")}
rules={[ rules={[
{ {
required: bodyshop.enforce_referral required: bodyshop.enforce_class
//message: t("general.validation.required"), //message: t("general.validation.required"),
} }
]} ]}
> >
<Select> <Select>
{bodyshop.md_referral_sources.map((s) => ( {bodyshop.md_classes.map((s) => (
<Select.Option key={s} value={s}> <Select.Option key={s} value={s}>
{s} {s}
</Select.Option> </Select.Option>
))} ))}
</Select> </Select>
</Form.Item> </Form.Item>
<Form.Item label={t("jobs.fields.referral_source_extra")} name="referral_source_extra"> )}
<Input /> {bodyshop.enforce_referral && (
</Form.Item> <>
</> <Form.Item
)} name={"referral_source"}
{bodyshop.enforce_conversion_csr && ( label={t("jobs.fields.referralsource")}
<Form.Item rules={[
name={"employee_csr"} {
label={t( required: bodyshop.enforce_referral
InstanceRenderManager({ //message: t("general.validation.required"),
imex: "jobs.fields.employee_csr", }
rome: "jobs.fields.employee_csr_writer" ]}
}) >
)} <Select>
rules={[ {bodyshop.md_referral_sources.map((s) => (
{ <Select.Option key={s} value={s}>
required: bodyshop.enforce_conversion_csr {s}
//message: t("general.validation.required"), </Select.Option>
} ))}
]} </Select>
> </Form.Item>
<Select <Form.Item label={t("jobs.fields.referral_source_extra")} name="referral_source_extra">
showSearch={{ <Input />
optionFilterProp: "children", </Form.Item>
filterOption: (input, option) => option.props.children.toLowerCase().indexOf(input.toLowerCase()) >= 0 </>
}} )}
style={{ width: 200 }} {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 <Select
.filter((emp) => emp.active) showSearch={{
.map((emp) => ( optionFilterProp: "children",
<Select.Option value={emp.id} key={emp.id} name={`${emp.first_name} ${emp.last_name}`}> filterOption: (input, option) => option.props.children.toLowerCase().indexOf(input.toLowerCase()) >= 0
{`${emp.first_name} ${emp.last_name}`} }}
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.Option>
))} ))}
</Select> </Select>
</Form.Item> </Form.Item>
)} )}
{bodyshop.enforce_conversion_category && ( {bodyshop.region_config.toLowerCase().startsWith("ca") && (
<Form.Item <Form.Item label={t("jobs.fields.ca_gst_registrant")} name="ca_gst_registrant" valuePropName="checked">
name={"category"} <Switch />
label={t("jobs.fields.category")} </Form.Item>
rules={[ )}
{ <Form.Item label={t("jobs.fields.driveable")} name="driveable" valuePropName="checked">
required: bodyshop.enforce_conversion_category <Switch />
//message: t("general.validation.required"), </Form.Item>
} <Form.Item label={t("jobs.fields.towin")} name="towin" valuePropName="checked">
]}
>
<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">
<Switch /> <Switch />
</Form.Item> </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 ( <Space wrap style={{ marginTop: 16 }}>
<Popover open={open} content={popMenu}> <Button
<Button disabled={submitDisabled() || (isReynoldsMode && !job.dms_id && !earlyRoCreated)}
key="convert" type="primary"
type="primary" danger
danger onClick={() => form.submit()}
// style={{ display: job.converted ? "none" : "" }} loading={loading}
disabled={job.converted || jobRO} >
loading={loading} {t("jobs.actions.convert")}
onClick={() => { </Button>
setOpen(true); <Button onClick={() => setOpen(false)}>{t("general.actions.close")}</Button>
}} </Space>
> </Form>
{t("jobs.actions.convert")} </Modal>
</Button> </>
</Popover>
); );
} }

View File

@@ -2216,6 +2216,9 @@ export const QUERY_JOB_EXPORT_DMS = gql`
plate_no plate_no
plate_st plate_st
ownr_co_nm 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 <DmsCustomerSelector
jobid={jobId} jobid={jobId}
job={data?.jobs_by_pk}
bodyshop={bodyshop} bodyshop={bodyshop}
socket={activeSocket} socket={activeSocket}
mode={mode} mode={mode}

View File

@@ -1,9 +1,10 @@
import { useQuery } from "@apollo/client/react"; import { useQuery } from "@apollo/client/react";
import { Card, Col, Result, Row, Space, Typography } from "antd"; import { Card, Col, Result, Row, Space, Typography } from "antd";
import { useEffect } from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import AlertComponent from "../../components/alert/alert.component"; import AlertComponent from "../../components/alert/alert.component";
import JobCalculateTotals from "../../components/job-calculate-totals/job-calculate-totals.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"; 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 LoadingSpinner from "../../components/loading-spinner/loading-spinner.component";
import NotFound from "../../components/not-found/not-found.component"; import NotFound from "../../components/not-found/not-found.component";
import RbacWrapper from "../../components/rbac-wrapper/rbac-wrapper.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 { GET_JOB_BY_PK } from "../../graphql/jobs.queries";
import { setBreadcrumbs, setSelectedHeader } from "../../redux/application/application.actions"; 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"; import InstanceRenderManager from "../../utils/instanceRenderMgr";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop
});
const mapDispatchToProps = (dispatch) => ({ const mapDispatchToProps = (dispatch) => ({
setBreadcrumbs: (breadcrumbs) => dispatch(setBreadcrumbs(breadcrumbs)), setBreadcrumbs: (breadcrumbs) => dispatch(setBreadcrumbs(breadcrumbs)),
setSelectedHeader: (key) => dispatch(setSelectedHeader(key)) setSelectedHeader: (key) => dispatch(setSelectedHeader(key))
@@ -39,14 +50,31 @@ const cardStyle = {
height: "100%" height: "100%"
}; };
export function JobsCloseContainer({ setBreadcrumbs, setSelectedHeader }) { export function JobsCloseContainer({ setBreadcrumbs, setSelectedHeader, bodyshop }) {
const { jobId } = useParams(); 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 }, variables: { id: jobId },
fetchPolicy: "network-only", fetchPolicy: "network-only",
nextFetchPolicy: "network-only" nextFetchPolicy: "network-only"
}); });
const { t } = useTranslation(); 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(() => { useEffect(() => {
setSelectedHeader("activejobs"); setSelectedHeader("activejobs");
document.title = t("titles.jobs-admin", { document.title = t("titles.jobs-admin", {
@@ -75,6 +103,15 @@ export function JobsCloseContainer({ setBreadcrumbs, setSelectedHeader }) {
]); ]);
}, [setBreadcrumbs, t, jobId, data, 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 (loading) return <LoadingSpinner />;
if (error) return <AlertComponent title={error.message} type="error" />; if (error) return <AlertComponent title={error.message} type="error" />;
if (!data.jobs_by_pk) return <NotFound />; if (!data.jobs_by_pk) return <NotFound />;
@@ -99,6 +136,15 @@ export function JobsCloseContainer({ setBreadcrumbs, setSelectedHeader }) {
<JobsAdminUnvoid job={data ? data.jobs_by_pk : {}} /> <JobsAdminUnvoid job={data ? data.jobs_by_pk : {}} />
<JobsAdminStatus job={data ? data.jobs_by_pk : {}} /> <JobsAdminStatus job={data ? data.jobs_by_pk : {}} />
<JobsAdminRemoveAR 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> </Space>
</Card> </Card>
</Col> </Col>
@@ -124,8 +170,18 @@ export function JobsCloseContainer({ setBreadcrumbs, setSelectedHeader }) {
</Card> </Card>
</Col> </Col>
</Row> </Row>
{/* Early RO Modal */}
<RREarlyROModal
open={showEarlyROModal}
onClose={() => setShowEarlyROModal(false)}
onSuccess={handleEarlyROSuccess}
bodyshop={bodyshop}
socket={socket}
job={job}
/>
</RbacWrapper> </RbacWrapper>
); );
} }
export default connect(null, mapDispatchToProps)(JobsCloseContainer); export default connect(mapStateToProps, mapDispatchToProps)(JobsCloseContainer);

View File

@@ -1818,7 +1818,11 @@
"sale": "Sale", "sale": "Sale",
"sale_dms_acctnumber": "Sale DMS Acct #", "sale_dms_acctnumber": "Sale DMS Acct #",
"story": "Story", "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", "dms_allocation": "DMS Allocation",
"driveable": "Driveable", "driveable": "Driveable",

View File

@@ -1818,7 +1818,11 @@
"sale": "", "sale": "",
"sale_dms_acctnumber": "", "sale_dms_acctnumber": "",
"story": "", "story": "",
"vinowner": "" "vinowner": "",
"rr_opcode": "",
"rr_opcode_prefix": "",
"rr_opcode_suffix": "",
"rr_opcode_base": ""
}, },
"dms_allocation": "", "dms_allocation": "",
"driveable": "", "driveable": "",

View File

@@ -1818,7 +1818,11 @@
"sale": "", "sale": "",
"sale_dms_acctnumber": "", "sale_dms_acctnumber": "",
"story": "", "story": "",
"vinowner": "" "vinowner": "",
"rr_opcode": "",
"rr_opcode_prefix": "",
"rr_opcode_suffix": "",
"rr_opcode_base": ""
}, },
"dms_allocation": "", "dms_allocation": "",
"driveable": "", "driveable": "",

View File

@@ -3704,7 +3704,9 @@
- ded_status - ded_status
- deliverchecklist - deliverchecklist
- depreciation_taxes - depreciation_taxes
- dms_advisor_id
- dms_allocation - dms_allocation
- dms_customer_id
- dms_id - dms_id
- driveable - driveable
- employee_body - employee_body
@@ -3985,7 +3987,9 @@
- ded_status - ded_status
- deliverchecklist - deliverchecklist
- depreciation_taxes - depreciation_taxes
- dms_advisor_id
- dms_allocation - dms_allocation
- dms_customer_id
- dms_id - dms_id
- driveable - driveable
- employee_body - employee_body
@@ -4278,7 +4282,9 @@
- ded_status - ded_status
- deliverchecklist - deliverchecklist
- depreciation_taxes - depreciation_taxes
- dms_advisor_id
- dms_allocation - dms_allocation
- dms_customer_id
- dms_id - dms_id
- driveable - driveable
- employee_body - 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 rate_ats
flat_rate_ats flat_rate_ats
rate_ats_flat rate_ats_flat
dms_id
dms_customer_id
dms_advisor_id
joblines(where: { removed: { _eq: false } }){ joblines(where: { removed: { _eq: false } }){
id id
line_no 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!) { 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 }) { 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 id
dms_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. * Success: mark job exported + (optionally) insert a success log.
* Uses queries.MARK_JOB_EXPORTED (same shape as Fortellis/PBS). * 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; const endpoint = process.env.GRAPHQL_ENDPOINT;
if (!endpoint) throw new Error("GRAPHQL_ENDPOINT not configured"); if (!endpoint) throw new Error("GRAPHQL_ENDPOINT not configured");
const token = getAuthToken(socket); const token = getAuthToken(socket);
@@ -96,11 +97,40 @@ const markRRExportSuccess = async ({ socket, jobId, job, bodyshop, result, metaE
const client = new GraphQLClient(endpoint, {}); const client = new GraphQLClient(endpoint, {});
client.setHeaders({ Authorization: `Bearer ${token}` }); 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 = const exportedStatus =
job?.bodyshop?.md_ro_statuses?.default_exported || bodyshop?.md_ro_statuses?.default_exported || "Exported*"; job?.bodyshop?.md_ro_statuses?.default_exported || bodyshop?.md_ro_statuses?.default_exported || "Exported*";
const meta = buildRRExportMeta({ result, extra: metaExtra });
try { try {
await client.request(queries.MARK_JOB_EXPORTED, { await client.request(queries.MARK_JOB_EXPORTED, {
jobId, 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 * @param args
* @returns {Promise<{success: boolean, data: *, roStatus: {status: *, statusCode: *|undefined, message}, statusBlocks: *|{}, customerNo: string, svId: *, roNo: *, xml: *}>} * @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 CreateRRLogEvent = require("./rr-logger-event");
const { rrCombinedSearch, rrGetAdvisors, buildClientAndOpts } = require("./rr-lookup"); const { rrCombinedSearch, rrGetAdvisors, buildClientAndOpts } = require("./rr-lookup");
const { QueryJobData, buildRogogFromAllocations, buildRolaborFromRogog } = require("./rr-job-helpers"); 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 RRCalculateAllocations = require("./rr-calculate-allocations").default;
const { createRRCustomer } = require("./rr-customers"); const { createRRCustomer } = require("./rr-customers");
const { ensureRRServiceVehicle } = require("./rr-service-vehicles"); 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 socket
* @param jobId * @param jobId
* @param dmsId * @param dmsId
* @param dmsCustomerId
* @param dmsAdvisorId
* @returns {Promise<void>} * @returns {Promise<void>}
*/ */
const setJobDmsIdForSocket = async ({ socket, jobId, dmsId }) => { const setJobDmsIdForSocket = async ({ socket, jobId, dmsId, dmsCustomerId, dmsAdvisorId }) => {
if (!jobId || !dmsId) { if (!jobId || !dmsId) {
CreateRRLogEvent(socket, "WARN", "setJobDmsIdForSocket called without jobId or dmsId", { CreateRRLogEvent(socket, "WARN", "setJobDmsIdForSocket called without jobId or dmsId", {
jobId, jobId,
@@ -149,16 +156,25 @@ const setJobDmsIdForSocket = async ({ socket, jobId, dmsId }) => {
const client = new GraphQLClient(endpoint, {}); const client = new GraphQLClient(endpoint, {});
await client await client
.setHeaders({ Authorization: `Bearer ${token}` }) .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", { CreateRRLogEvent(socket, "INFO", "Linked job.dms_id to RR RO", {
jobId, jobId,
dmsId: String(dmsId) dmsId: String(dmsId),
dmsCustomerId,
dmsAdvisorId
}); });
} catch (err) { } catch (err) {
CreateRRLogEvent(socket, "ERROR", "Failed to set job.dms_id after RR create/update", { CreateRRLogEvent(socket, "ERROR", "Failed to set job.dms_id after RR create/update", {
jobId, jobId,
dmsId, dmsId,
dmsCustomerId,
dmsAdvisorId,
message: err?.message || String(err), message: err?.message || String(err),
stack: err?.stack 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); const rid = resolveJobId(jobid || jobId, { jobId, jobid }, null);
try { try {
@@ -422,6 +932,139 @@ const registerRREvents = ({ socket, redisHelpers }) => {
const { bodyshopId } = await getSessionOrSocket(redisHelpers, socket); const { bodyshopId } = await getSessionOrSocket(redisHelpers, socket);
const bodyshop = await getBodyshopForSocket({ bodyshopId, 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)`); CreateRRLogEvent(socket, "DEBUG", `{2} Running multi-search (Full Name + VIN)`);
const candidates = await rrMultiCustomerSearch({ bodyshop, job, socket, redisHelpers }); const candidates = await rrMultiCustomerSearch({ bodyshop, job, socket, redisHelpers });
@@ -620,17 +1263,59 @@ const registerRREvents = ({ socket, redisHelpers }) => {
defaultRRTTL defaultRRTTL
); );
// CREATE/UPDATE (first step only) // Check if this job already has an early RO created (check job.dms_id)
CreateRRLogEvent(socket, "DEBUG", `{4} Performing RR create/update (step 1)`); // If so, we'll use stored customer/advisor IDs and do a full data UPDATE instead of CREATE
const result = await exportJobToRR({ const existingDmsId = job?.dms_id || null;
bodyshop, const shouldUpdate = !!existingDmsId;
job,
selectedCustomer: { customerNo: effectiveCustNo, custNo: effectiveCustNo }, // When updating an early RO, use stored customer/advisor IDs
advisorNo: String(advisorNo), let finalEffectiveCustNo = effectiveCustNo;
txEnvelope, let finalAdvisorNo = advisorNo;
socket,
svId: ensured?.svId || null 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 // Cache raw export result + pending RO number for finalize
await redisHelpers.setSessionTransactionData( await redisHelpers.setSessionTransactionData(