fix(work-orders): harden duplicate WO number checks [SH-189]

This commit is contained in:
Arthur Bassi 2026-08-10 13:46:01 -03:00
parent 392ac86757
commit db0b87eb8f
7 changed files with 107 additions and 48 deletions

View file

@ -132,7 +132,11 @@ export function EditableWONumberCell({
try {
const duplicate = await resolveDuplicateWoNumber(draft, allOrders, currentId);
if (duplicate) {
onDuplicateFound?.(duplicate);
if (onDuplicateFound) {
onDuplicateFound(duplicate);
} else {
toast.error(`WO #${duplicate.woNumber} is already assigned to another work order.`);
}
setDraft(value);
return;
}

View file

@ -3,7 +3,10 @@ import type {
VendorDropdownItem,
WorkOrderUser,
} from "@/domain/work-orders/types/work-order";
import type { WorkOrderTableDraft } from "@/domain/work-orders/types/work-order-table-row";
import type {
WorkOrderTableDraft,
WorkOrderTableRow,
} from "@/domain/work-orders/types/work-order-table-row";
import { AssignedToCell } from "./cells/assigned-to-cell";
import { ApptTimeCell } from "./cells/appt-time-cell";
import { DateCell } from "./cells/date-cell";
@ -25,6 +28,7 @@ type WoTableInlineRowCellsProps = {
pmTypes: string[];
allRows: { id: string | number; woNumber: string }[];
onPatch: (patch: Partial<WorkOrderTableDraft>) => void;
onDuplicateFound?: (existing: WorkOrderTableRow) => void;
};
export function WoTableInlineRowCells({
@ -37,6 +41,7 @@ export function WoTableInlineRowCells({
pmTypes,
allRows,
onPatch,
onDuplicateFound,
}: WoTableInlineRowCellsProps) {
const draftRow = toInlineDraftRow(draft);
@ -73,6 +78,7 @@ export function WoTableInlineRowCells({
currentId="draft"
allOrders={allRows as never[]}
onSave={(v) => onPatch({ woNumber: v })}
onDuplicateFound={onDuplicateFound}
/>
</td>

View file

@ -6,7 +6,10 @@ import type {
WorkOrderUser,
} from "@/domain/work-orders/types/work-order";
import type { WorkOrderTableDraft } from "@/domain/work-orders/types/work-order-table-row";
import type {
WorkOrderTableDraft,
WorkOrderTableRow,
} from "@/domain/work-orders/types/work-order-table-row";
import { UNSCHEDULED_DAY_KEY } from "@/domain/work-orders/types/work-order-table-row";
@ -16,54 +19,34 @@ import { WoTableInlineRowActions } from "./wo-table-inline-row-actions";
type WoTableInlineRowProps = {
draft: WorkOrderTableDraft;
draftDayKey: string;
users: WorkOrderUser[];
sites: LocationOption[];
sitePlace?: { city: string; state: string };
vendors: VendorDropdownItem[];
pmTypes: string[];
allRows: { id: string | number; woNumber: string }[];
saving?: boolean;
onChange: (draft: WorkOrderTableDraft) => void;
onSave: () => void;
onCancel: () => void;
onDuplicateFound?: (existing: WorkOrderTableRow) => void;
};
export function WoTableInlineRow({
draft,
draftDayKey,
users,
sites,
sitePlace,
vendors,
pmTypes,
allRows,
saving = false,
onChange,
onSave,
onCancel,
onDuplicateFound,
}: WoTableInlineRowProps) {
const weekDefault = draftDayKey === UNSCHEDULED_DAY_KEY;
@ -77,11 +60,8 @@ export function WoTableInlineRow({
aria-busy={saving || undefined}
style={{
height: WO_TABLE_ROW_HEIGHT,
borderBottom: "1px solid var(--color-border)",
borderLeft: "2px solid var(--color-primary)",
background: "var(--color-primary-tint)",
opacity: saving ? 0.7 : 1,
pointerEvents: saving ? "none" : undefined,
@ -103,6 +83,7 @@ export function WoTableInlineRow({
pmTypes={pmTypes}
allRows={allRows}
onPatch={patch}
onDuplicateFound={onDuplicateFound}
/>
<td style={{ padding: "0 4px" }}>

View file

@ -1,4 +1,5 @@
import { useMemo, useState } from "react";
import { toast } from "react-toastify";
import { useLocationSites } from "@/domain/locations/use-cases/use-location-sites";
import type {
LocationOption,
@ -9,7 +10,9 @@ import type {
WorkOrderTablePatch,
WorkOrderTableRow,
} from "@/domain/work-orders/types/work-order-table-row";
import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api";
import { useCreateWorkOrderFromWizard } from "@/domain/work-orders/use-cases/use-create-work-order-from-wizard";
import { findDuplicateWoNumberRow } from "@/domain/work-orders/utils/find-duplicate-wo-number-row";
import { DuplicateWoNumberDialog } from "@/app/(protected)/workorders/_components/list/duplicate-wo-number-dialog";
import { WoTableSkeleton } from "@/app/(protected)/workorders/_components/loading/wo-table-skeleton";
import type { ReturnTypeOfUseWorkOrdersTableData } from "./wo-table-types";
@ -57,6 +60,7 @@ export function WoTable({
}: WoTableProps) {
const [scheduleHighlightId, setScheduleHighlightId] = useState<string | number | null>(null);
const [duplicateRow, setDuplicateRow] = useState<WorkOrderTableRow | null>(null);
const [isCheckingInlineDuplicate, setIsCheckingInlineDuplicate] = useState(false);
const { data: locationSites = [] } = useLocationSites();
const sitePlaceLookups = useMemo(() => buildSitePlaceLookups(locationSites), [locationSites]);
const createMutation = useCreateWorkOrderFromWizard(() => {
@ -75,6 +79,31 @@ export function WoTable({
onDuplicateFound: setDuplicateRow,
});
const handleInlineSave = () => {
void (async () => {
const draftSnapshot = tableData.draft;
const woNumber = draftSnapshot.woNumber.trim();
if (woNumber) {
setIsCheckingInlineDuplicate(true);
try {
const duplicate =
findDuplicateWoNumberRow(woNumber, tableData.rows, "draft") ??
(await workOrdersApi.findByWoNumber(woNumber));
if (duplicate && String(duplicate.id) !== "draft") {
setDuplicateRow(duplicate);
return;
}
} catch {
toast.error("Unable to verify the work order number. Please try again.");
return;
} finally {
setIsCheckingInlineDuplicate(false);
}
}
createMutation.mutate(buildInlineDraftCreateInput(tableData.draft, users, sites));
})();
};
const renderRow = (row: WorkOrderTableRow, forceScheduleHighlight = false) => (
<WoTableRow
key={String(row.id)}
@ -109,12 +138,11 @@ export function WoTable({
vendors={vendors}
pmTypes={pmTypes}
allRows={tableData.rows}
saving={createMutation.isPending}
saving={createMutation.isPending || isCheckingInlineDuplicate}
onChange={tableData.setDraft}
onSave={() =>
createMutation.mutate(buildInlineDraftCreateInput(tableData.draft, users, sites))
}
onSave={handleInlineSave}
onCancel={tableData.cancelDraft}
onDuplicateFound={setDuplicateRow}
/>
);

View file

@ -46,11 +46,10 @@ export function useNewWoWizardState({
const [pocAutoFilledFor, setPocAutoFilledFor] = useState("");
const [duplicateRow, setDuplicateRow] = useState<WorkOrderTableRow | null>(null);
const [isCheckingDuplicate, setIsCheckingDuplicate] = useState(false);
const draftWoNumberRef = useRef(draft.woNumber);
useEffect(() => {
draftWoNumberRef.current = draft.woNumber;
}, [draft.woNumber]);
const openRef = useRef(open);
const draftRef = useRef(draft);
openRef.current = open;
draftRef.current = draft;
const { data: apiLocations = [] } = useWorkOrderLocations();
const { data: apiProblems = [] } = useDropdownOptionsByCategory("Problem");
@ -129,12 +128,23 @@ export function useNewWoWizardState({
};
const handleDuplicateFound = (woNumber: string) => {
void resolveDuplicate(woNumber).then((duplicate) => {
// Compare against the latest draft — ignore stale lookups after the user edits.
if (duplicate && woNumber === draftWoNumberRef.current) {
setDuplicateRow(duplicate);
void (async () => {
try {
const duplicate = await resolveDuplicate(woNumber);
// Ignore stale lookups after dismiss or after the user edits the WO#.
if (!openRef.current || woNumber !== draftRef.current.woNumber) {
return;
}
if (duplicate) {
setDuplicateRow(duplicate);
}
} catch {
if (!openRef.current) {
return;
}
toast.error("Unable to verify the work order number. Please try again.");
}
});
})();
};
const handleCreate = () => {
@ -143,16 +153,19 @@ export function useNewWoWizardState({
setIsCheckingDuplicate(true);
try {
const duplicate = await resolveDuplicate(woNumberAtSubmit);
// Ignore stale lookups if the WO# changed while the request was in flight.
if (woNumberAtSubmit !== draftWoNumberRef.current) {
// Ignore after dismiss or if the WO# changed while the request was in flight.
if (!openRef.current || woNumberAtSubmit !== draftRef.current.woNumber) {
return;
}
if (duplicate) {
setDuplicateRow(duplicate);
return;
}
createMutation.mutate(draft);
createMutation.mutate(draftRef.current);
} catch {
if (!openRef.current) {
return;
}
toast.error("Unable to verify the work order number. Please try again.");
} finally {
setIsCheckingDuplicate(false);

View file

@ -193,17 +193,20 @@ export const workOrdersApi = {
findByWoNumber: async (woNumber: string): Promise<WorkOrderTableRow | undefined> => {
const normalized = woNumber.trim();
if (normalized === "") {
// Skip empty / very short queries — fuzzy board/search would page endlessly for 1–2 chars.
const minLength = 3;
const maxPages = 5;
if (normalized.length < minLength) {
return undefined;
}
// `board/search` is fuzzy and paginated (0-based); exhaust pages until an exact WO# match.
// `board/search` is fuzzy and paginated (0-based); scan a bounded number of pages for an exact WO#.
const pageSize = 100;
let page = 0;
let scanned = 0;
let totalCount = Number.POSITIVE_INFINITY;
while (scanned < totalCount) {
while (scanned < totalCount && page < maxPages) {
const result = await workOrdersApi.searchBoard({
search: normalized,
page,

View file

@ -363,6 +363,30 @@ describe("workOrdersApi.findByWoNumber", () => {
});
expect(result).toMatchObject({ id: 999, woNumber: "10000009999" });
});
it("skips remote search for short WO numbers", async () => {
const result = await workOrdersApi.findByWoNumber("12");
expect(apiGet).not.toHaveBeenCalled();
expect(result).toBeUndefined();
});
it("caps paging when scanning fuzzy board search results", async () => {
apiGet.mockResolvedValue({
items: Array.from({ length: 100 }, (_, index) => ({
id: index + 1,
woNumber: `fuzzy-match-${index}`,
lifecycleStatusLabel: "Incomplete",
})),
totalCount: 10_000,
page: 0,
pageSize: 100,
});
const result = await workOrdersApi.findByWoNumber("10000005555");
expect(apiGet).toHaveBeenCalledTimes(5);
expect(result).toBeUndefined();
});
});
describe("workOrdersApi.patchBoardField", () => {