feature/IO-3357-Reynolds-and-Reynolds-DMS-API-Integration -VIN Ownership checks

This commit is contained in:
Dave
2025-11-07 14:36:57 -05:00
parent 9ce022b5e8
commit 5a8a5bf7ab
3 changed files with 114 additions and 34 deletions

View File

@@ -1,5 +1,5 @@
import { useSplitTreatments } from "@splitsoftware/splitio-react"; import { useSplitTreatments } from "@splitsoftware/splitio-react";
import { Button, Checkbox, Col, message, Table } from "antd"; import { Alert, Button, Checkbox, Col, message, 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 { connect } from "react-redux"; import { connect } from "react-redux";
@@ -26,7 +26,9 @@ function normalizeRrList(list) {
row.name || row.name ||
[row.CustomerName?.FirstName, row.CustomerName?.LastName].filter(Boolean).join(" ").trim() || [row.CustomerName?.FirstName, row.CustomerName?.LastName].filter(Boolean).join(" ").trim() ||
(custNo ? String(custNo) : ""); (custNo ? String(custNo) : "");
return custNo ? { custNo: String(custNo), name } : null; if (!custNo) return null;
const vinOwner = !!(row.vinOwner ?? row.isVehicleOwner);
return { custNo: String(custNo), name, vinOwner };
}) })
.filter(Boolean); .filter(Boolean);
} }
@@ -49,14 +51,29 @@ export function DmsCustomerSelector({ bodyshop, jobid }) {
const { socket: wsssocket } = useSocket(); const { socket: wsssocket } = useSocket();
const dms = useMemo(() => determineDmsType(bodyshop), [bodyshop]); const dms = useMemo(() => determineDmsType(bodyshop), [bodyshop]);
// --- owner set (RR only) ---
const rrOwnerSet = useMemo(() => {
return new Set(
(Array.isArray(customerList) ? customerList : [])
.filter((c) => c?.vinOwner || c?.isVehicleOwner)
.map((c) => String(c.custNo))
);
}, [customerList]);
const rrHasVinOwner = rrOwnerSet.size > 0;
useEffect(() => { useEffect(() => {
// RR takes precedence // RR takes precedence
if (dms === "rr") { if (dms === "rr") {
const handleRrSelectCustomer = (list) => { const handleRrSelectCustomer = (list) => {
const normalized = normalizeRrList(list);
setOpen(true); setOpen(true);
setDmsType("rr"); setDmsType("rr");
setcustomerList(normalizeRrList(list)); setcustomerList(normalized);
setSelectedCustomer(null);
// PRESELECT VIN OWNER (first one if multiple)
const firstOwner = normalized.find((r) => r.vinOwner)?.custNo;
setSelectedCustomer(firstOwner ? String(firstOwner) : null);
}; };
wsssocket.on("rr-select-customer", handleRrSelectCustomer); wsssocket.on("rr-select-customer", handleRrSelectCustomer);
@@ -98,14 +115,30 @@ export function DmsCustomerSelector({ bodyshop, jobid }) {
} }
}, [dms, Fortellis?.treatment, wsssocket]); }, [dms, Fortellis?.treatment, wsssocket]);
// Safety: if owner info arrives later or list changes, keep the owner preselected.
useEffect(() => {
if (dmsType !== "rr" || !rrHasVinOwner) return;
const firstOwner = (customerList.find((c) => c.vinOwner) || {}).custNo;
if (firstOwner && String(selectedCustomer) !== String(firstOwner)) {
setSelectedCustomer(String(firstOwner));
}
}, [dmsType, rrHasVinOwner, customerList]);
const onUseSelected = () => { const onUseSelected = () => {
if (!selectedCustomer) { if (!selectedCustomer) {
message.warning(t("general.actions.select")); message.warning(t("general.actions.select"));
return; return;
} }
// If there is a VIN owner, only allow owner selection
if (dmsType === "rr" && rrHasVinOwner && !rrOwnerSet.has(String(selectedCustomer))) {
message.warning(
"This VIN is already assigned in Reynolds. Only the VIN owner can be selected. To choose a different customer, change ownership in Reynolds first."
);
return;
}
if (dmsType === "rr") { if (dmsType === "rr") {
// RR now mirrors others: send selection and close
wsssocket.emit("rr-selected-customer", { jobId: jobid, custNo: String(selectedCustomer) }, (ack) => { wsssocket.emit("rr-selected-customer", { jobId: jobid, custNo: String(selectedCustomer) }, (ack) => {
if (ack?.ok) { if (ack?.ok) {
setOpen(false); setOpen(false);
@@ -127,6 +160,7 @@ export function DmsCustomerSelector({ bodyshop, jobid }) {
}; };
const onUseGeneric = () => { const onUseGeneric = () => {
if (dmsType === "rr" && rrHasVinOwner) return;
const generic = bodyshop.cdk_configuration?.generic_customer_number || null; const generic = bodyshop.cdk_configuration?.generic_customer_number || null;
if (dmsType === "rr") { if (dmsType === "rr") {
if (generic) { if (generic) {
@@ -146,11 +180,11 @@ export function DmsCustomerSelector({ bodyshop, jobid }) {
}; };
const onCreateNew = () => { const onCreateNew = () => {
// Exact parity with Fortellis: ask server to create immediately if (dmsType === "rr" && rrHasVinOwner) return;
if (dmsType === "rr") { if (dmsType === "rr") {
wsssocket.emit("rr-selected-customer", { jobId: jobid, create: true }, (ack) => { wsssocket.emit("rr-selected-customer", { jobId: jobid, create: true }, (ack) => {
if (ack?.ok) { if (ack?.ok) {
// Optionally preselect returned custNo
if (ack.custNo) setSelectedCustomer(String(ack.custNo)); if (ack.custNo) setSelectedCustomer(String(ack.custNo));
setOpen(false); setOpen(false);
message.success(t("dms.messages.customerCreated")); message.success(t("dms.messages.customerCreated"));
@@ -161,7 +195,6 @@ export function DmsCustomerSelector({ bodyshop, jobid }) {
return; return;
} }
// Non-RR unchanged
setOpen(false); setOpen(false);
if (Fortellis.treatment === "on") { if (Fortellis.treatment === "on") {
wsssocket.emit("fortellis-selected-customer", { selectedCustomerId: null, jobid }); wsssocket.emit("fortellis-selected-customer", { selectedCustomerId: null, jobid });
@@ -245,7 +278,18 @@ export function DmsCustomerSelector({ bodyshop, jobid }) {
const rrColumns = [ const rrColumns = [
{ title: t("jobs.fields.dms.id"), dataIndex: "custNo", key: "custNo" }, { title: t("jobs.fields.dms.id"), dataIndex: "custNo", key: "custNo" },
{ title: t("jobs.fields.dms.name1"), dataIndex: "name", key: "name", sorter: (a, b) => alphaSort(a?.name, b?.name) } {
title: t("jobs.fields.dms.name1"),
dataIndex: "name",
key: "name",
sorter: (a, b) => alphaSort(a?.name, b?.name)
},
{
title: t("jobs.fields.dms.vinowner"),
dataIndex: "vinOwner",
key: "vinOwner",
render: (_t, r) => <Checkbox disabled checked={!!(r.vinOwner ?? r.isVehicleOwner)} />
}
]; ];
if (!open) return null; if (!open) return null;
@@ -266,18 +310,40 @@ export function DmsCustomerSelector({ bodyshop, jobid }) {
? (record) => record.id?.value || record.customerId ? (record) => record.id?.value || record.customerId
: (record) => record.ContactId; : (record) => record.ContactId;
const rrDisableRow = (record) => {
if (dmsType !== "rr") return false;
if (!rrHasVinOwner) return false;
return !rrOwnerSet.has(String(record.custNo));
};
return ( return (
<Col span={24}> <Col span={24}>
<Table <Table
title={() => ( title={() => (
<div> <div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<Button onClick={onUseSelected} disabled={!selectedCustomer}> <div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
{t("jobs.actions.dms.useselected")} <Button onClick={onUseSelected} disabled={!selectedCustomer}>
</Button> {t("jobs.actions.dms.useselected")}
<Button onClick={onUseGeneric} disabled={!bodyshop.cdk_configuration?.generic_customer_number}> </Button>
{t("jobs.actions.dms.usegeneric")} <Button
</Button> onClick={onUseGeneric}
<Button onClick={onCreateNew}>{t("jobs.actions.dms.createnewcustomer")}</Button> disabled={dmsType === "rr" ? rrHasVinOwner : !bodyshop.cdk_configuration?.generic_customer_number}
>
{t("jobs.actions.dms.usegeneric")}
</Button>
<Button onClick={onCreateNew} disabled={dmsType === "rr" ? rrHasVinOwner : false}>
{t("jobs.actions.dms.createnewcustomer")}
</Button>
</div>
{dmsType === "rr" && rrHasVinOwner && (
<Alert
type="warning"
showIcon
message="VIN ownership enforced"
description="This VIN is already assigned in Reynolds. Only the VIN owner is selectable here. To use a different customer, please change the vehicle ownership in Reynolds first, then return to complete the export."
/>
)}
</div> </div>
)} )}
pagination={{ position: "top" }} pagination={{ position: "top" }}
@@ -295,7 +361,10 @@ export function DmsCustomerSelector({ bodyshop, jobid }) {
setSelectedCustomer(key ? String(key) : null); setSelectedCustomer(key ? String(key) : null);
}, },
type: "radio", type: "radio",
selectedRowKeys: selectedCustomer ? [selectedCustomer] : [] selectedRowKeys: selectedCustomer ? [selectedCustomer] : [],
getCheckboxProps: (record) => ({
disabled: rrDisableRow(record)
})
}} }}
/> />
</Col> </Col>

View File

@@ -46,6 +46,7 @@ const makeVehicleSearchPayloadFromJob = (job) => {
/** /**
* Normalize customer candidates from VIN blocks * Normalize customer candidates from VIN blocks
* Adds `vinOwner` (and keeps `isVehicleOwner` for backward compat).
* @param res * @param res
* @param ownersSet * @param ownersSet
* @returns {any[]} * @returns {any[]}
@@ -66,19 +67,27 @@ const normalizeCustomerCandidates = (res, { ownersSet = null } = {}) => {
for (const custNo of custNos) { for (const custNo of custNos) {
const cno = String(custNo).trim(); const cno = String(custNo).trim();
const item = { custNo: cno, name: name || `Customer ${cno}` }; const isOwner = !!(ownersSet && ownersSet.has(cno));
if (ownersSet && ownersSet.has(cno)) item.isVehicleOwner = true; const item = {
custNo: cno,
name: name || `Customer ${cno}`,
vinOwner: isOwner,
isVehicleOwner: isOwner // legacy key kept for any older FE code
};
out.push(item); out.push(item);
} }
} }
// Dedup by custNo, keep isVehicleOwner if any // Dedup by custNo, keep vinOwner/isVehicleOwner if any
const seen = new Map(); const seen = new Map();
for (const c of out) { for (const c of out) {
const key = (c.custNo || "").trim(); const key = (c.custNo || "").trim();
if (!key) continue; if (!key) continue;
const prev = seen.get(key); const prev = seen.get(key);
if (!prev) seen.set(key, c); if (!prev) {
else if (c.isVehicleOwner && !prev.isVehicleOwner) seen.set(key, { ...prev, isVehicleOwner: true }); seen.set(key, c);
} else if ((c.vinOwner || c.isVehicleOwner) && !(prev.vinOwner || prev.isVehicleOwner)) {
seen.set(key, { ...prev, vinOwner: true, isVehicleOwner: true });
}
} }
return Array.from(seen.values()); return Array.from(seen.values());
}; };

View File

@@ -119,9 +119,8 @@ async function rrMultiCustomerSearch({ bodyshop, job, socket, redisHelpers }) {
try { try {
CreateRRLogEvent(socket, "DEBUG", `{RR-SEARCH} Executing ${q.kind} query`, { q }); CreateRRLogEvent(socket, "DEBUG", `{RR-SEARCH} Executing ${q.kind} query`, { q });
const res = await rrCombinedSearch(bodyshop, q); const res = await rrCombinedSearch(bodyshop, q);
if (fromVin) { if (fromVin) {
const blocks = Array.isArray(res?.data) ? res.data : []; const blocks = Array.isArray(res?.data) ? res.data : Array.isArray(res) ? res : [];
ownersSet = ownersFromVinBlocks(blocks, job?.v_vin); ownersSet = ownersFromVinBlocks(blocks, job?.v_vin);
try { try {
await redisHelpers.setSessionTransactionData( await redisHelpers.setSessionTransactionData(
@@ -132,7 +131,7 @@ async function rrMultiCustomerSearch({ bodyshop, job, socket, redisHelpers }) {
defaultRRTTL defaultRRTTL
); );
} catch { } catch {
// /* ignore cache write issues */
} }
} }
@@ -159,17 +158,19 @@ function registerRREvents({ socket, redisHelpers }) {
let ownersSet = null; let ownersSet = null;
if ((params?.kind || "").toLowerCase() === "vin") { if ((params?.kind || "").toLowerCase() === "vin") {
const blocks = Array.isArray(res?.data) ? res.data : []; const blocks = Array.isArray(res?.data) ? res.data : Array.isArray(res) ? res : [];
ownersSet = ownersFromVinBlocks(blocks); ownersSet = ownersFromVinBlocks(blocks);
} }
const normalized = sortVehicleOwnerFirst(normalizeCustomerCandidates(res, { ownersSet })); const normalized = sortVehicleOwnerFirst(normalizeCustomerCandidates(res, { ownersSet }));
const rid = resolveJobId(jobid, { jobid }, null); const rid = resolveJobId(jobid, { jobid }, null);
cb?.({ jobid: rid, data: normalized }); const decorated = normalized.map((c) => (c.vinOwner != null ? c : { ...c, vinOwner: !!c.isVehicleOwner }));
socket.emit("rr-select-customer", normalized);
cb?.({ jobid: rid, data: decorated });
socket.emit("rr-select-customer", decorated);
CreateRRLogEvent(socket, "DEBUG", "rr-lookup-combined: emitted rr-select-customer", { CreateRRLogEvent(socket, "DEBUG", "rr-lookup-combined: emitted rr-select-customer", {
count: normalized.length count: decorated.length
}); });
} catch (e) { } catch (e) {
CreateRRLogEvent(socket, "ERROR", "RR combined lookup error", { error: e.message, jobid }); CreateRRLogEvent(socket, "ERROR", "RR combined lookup error", { error: e.message, jobid });
@@ -318,10 +319,11 @@ function registerRREvents({ socket, redisHelpers }) {
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 });
socket.emit("rr-select-customer", candidates); const decorated = candidates.map((c) => (c.vinOwner != null ? c : { ...c, vinOwner: !!c.isVehicleOwner }));
socket.emit("rr-select-customer", decorated);
CreateRRLogEvent(socket, "DEBUG", `{2.1} Emitted rr-select-customer`, { CreateRRLogEvent(socket, "DEBUG", `{2.1} Emitted rr-select-customer`, {
count: candidates.length, count: decorated.length,
anyOwner: candidates.some((c) => c.isVehicleOwner) anyOwner: decorated.some((c) => c.vinOwner || c.isVehicleOwner)
}); });
} catch (error) { } catch (error) {
CreateRRLogEvent(socket, "ERROR", `Error during RR export (prepare)`, { CreateRRLogEvent(socket, "ERROR", `Error during RR export (prepare)`, {
@@ -376,7 +378,7 @@ function registerRREvents({ socket, redisHelpers }) {
const vehQ = makeVehicleSearchPayloadFromJob(job); const vehQ = makeVehicleSearchPayloadFromJob(job);
if (vehQ && vehQ.kind === "vin" && job?.v_vin) { if (vehQ && vehQ.kind === "vin" && job?.v_vin) {
const resVin = await rrCombinedSearch(bodyshop, vehQ); const resVin = await rrCombinedSearch(bodyshop, vehQ);
const blocksVin = Array.isArray(resVin?.data) ? resVin.data : []; const blocksVin = Array.isArray(resVin?.data) ? resVin.data : Array.isArray(resVin) ? resVin : [];
try { try {
await redisHelpers.setSessionTransactionData( await redisHelpers.setSessionTransactionData(
socket.id, socket.id,