feature/IO-3357-Reynolds-and-Reynolds-DMS-API-Integration -VIN Ownership checks
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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());
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user