mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-04 19:32:06 +00:00
fix(work-orders): complete the work order when its completion document is generated (SH-341)
Generating or uploading the completion document now moves a closable work order to Completed without the manual confirmation, as SH-115 requires. The SH-114 closability gate is evaluated on the row the server returned after the document save; Save draft, Not required, Past Due, Completed and Canceled rows are never auto-completed. The follow-up status patch uses that row's rowVersion so it does not conflict with the document save.
This commit is contained in:
parent
fc3e7591dc
commit
09636ef590
6 changed files with 309 additions and 5 deletions
|
|
@ -8,6 +8,7 @@ import {
|
||||||
} from "@/domain/work-orders/use-cases/use-work-order-detail";
|
} from "@/domain/work-orders/use-cases/use-work-order-detail";
|
||||||
import { useWorkOrderUsers } from "@/domain/work-orders/use-cases/use-work-orders-list";
|
import { useWorkOrderUsers } from "@/domain/work-orders/use-cases/use-work-orders-list";
|
||||||
import { useWorkOrderTableMutations } from "@/domain/work-orders/use-cases/use-work-order-table-mutations";
|
import { useWorkOrderTableMutations } from "@/domain/work-orders/use-cases/use-work-order-table-mutations";
|
||||||
|
import { shouldCompleteAfterCompletionDoc } from "@/domain/work-orders/utils/completion-doc-auto-complete";
|
||||||
import {
|
import {
|
||||||
patchWorkOrderAsCanceled,
|
patchWorkOrderAsCanceled,
|
||||||
patchWorkOrderAsCompleted,
|
patchWorkOrderAsCompleted,
|
||||||
|
|
@ -112,7 +113,17 @@ export function useWorkOrdersListPage() {
|
||||||
generatedAt: data.generatedAt,
|
generatedAt: data.generatedAt,
|
||||||
};
|
};
|
||||||
setCompletionDocs((prev) => ({ ...prev, [String(row.id)]: nextData }));
|
setCompletionDocs((prev) => ({ ...prev, [String(row.id)]: nextData }));
|
||||||
await patchFieldAsync(row.id, { docStatus: status });
|
const saved = await patchFieldAsync(row.id, { docStatus: status });
|
||||||
|
if (saved.kind !== "board" || !shouldCompleteAfterCompletionDoc(status, saved.row)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
// SH-341: generation is the one automatic completion — no confirmation dialog.
|
||||||
|
await patchFieldAsync(row.id, { status: "Completed" }, saved.row);
|
||||||
|
} catch {
|
||||||
|
// The document is saved; the table mutation already surfaced the status error and
|
||||||
|
// refetched, so the WO stays in its prior status for a manual retry.
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -134,11 +134,17 @@ export function useWorkOrderTableMutations(
|
||||||
mutationFn: ({
|
mutationFn: ({
|
||||||
id,
|
id,
|
||||||
persistPatch,
|
persistPatch,
|
||||||
|
baseRow,
|
||||||
}: {
|
}: {
|
||||||
id: string | number;
|
id: string | number;
|
||||||
patch: WorkOrderTablePatch;
|
patch: WorkOrderTablePatch;
|
||||||
persistPatch: WorkOrderTablePatch;
|
persistPatch: WorkOrderTablePatch;
|
||||||
}) => enqueuePatch(id, () => runWorkOrderTablePatch(id, persistPatch, getRow)),
|
/** Server row from a just-settled patch; its rowVersion is fresher than the table snapshot. */
|
||||||
|
baseRow?: WorkOrderTableRow;
|
||||||
|
}) =>
|
||||||
|
enqueuePatch(id, () =>
|
||||||
|
runWorkOrderTablePatch(id, persistPatch, baseRow ? () => baseRow : getRow),
|
||||||
|
),
|
||||||
onMutate: async ({ id, persistPatch }) => {
|
onMutate: async ({ id, persistPatch }) => {
|
||||||
onPatch(id, persistPatch);
|
onPatch(id, persistPatch);
|
||||||
},
|
},
|
||||||
|
|
@ -182,9 +188,13 @@ export function useWorkOrderTableMutations(
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const patchFieldAsync = (id: string | number, patch: WorkOrderTablePatch) => {
|
const patchFieldAsync = (
|
||||||
const persistPatch = resolvePatch(id, patch);
|
id: string | number,
|
||||||
return patchMutation.mutateAsync({ id, patch, persistPatch });
|
patch: WorkOrderTablePatch,
|
||||||
|
baseRow?: WorkOrderTableRow,
|
||||||
|
) => {
|
||||||
|
const persistPatch = baseRow ? mergeAutoSchedulePatch(baseRow, patch) : resolvePatch(id, patch);
|
||||||
|
return patchMutation.mutateAsync({ id, patch, persistPatch, baseRow });
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
25
src/domain/work-orders/utils/completion-doc-auto-complete.ts
Normal file
25
src/domain/work-orders/utils/completion-doc-auto-complete.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
import type { CompDocSaveStatus } from "@/domain/work-orders/types/completion-doc";
|
||||||
|
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
import {
|
||||||
|
getClosabilityGaps,
|
||||||
|
tableRowToClosabilityInput,
|
||||||
|
} from "@/domain/work-orders/utils/wo-closability";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SH-115 / SH-341: completion is manual with confirmation, except that generating the
|
||||||
|
* completion document completes the WO — but only when the SH-114 closability gate passes
|
||||||
|
* on the persisted row. Not-required (NN) and drafts never complete; Past Due rows must be
|
||||||
|
* rescheduled before any status change, so they are left for the dispatcher.
|
||||||
|
*/
|
||||||
|
export function shouldCompleteAfterCompletionDoc(
|
||||||
|
status: CompDocSaveStatus,
|
||||||
|
row: WorkOrderTableRow,
|
||||||
|
): boolean {
|
||||||
|
if (status !== "Yes" || row.docStatus !== "Yes") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (row.status === "Completed" || row.status === "Canceled" || row.isPastDue) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return getClosabilityGaps(tableRowToClosabilityInput(row)).length === 0;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,162 @@
|
||||||
|
import { renderHook } from "@testing-library/react";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { EMPTY_ADVANCED_FILTERS } from "@/domain/work-orders/types/work-order-advanced-filters";
|
||||||
|
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
|
||||||
|
const patchField = vi.hoisted(() => vi.fn());
|
||||||
|
const patchFieldAsync = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
|
vi.mock("react-toastify", () => ({
|
||||||
|
toast: {
|
||||||
|
warning: vi.fn(),
|
||||||
|
success: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/app/(protected)/workorders/_hooks/use-work-orders-list-filters", () => ({
|
||||||
|
useWorkOrdersListFilters: () => ({
|
||||||
|
search: "",
|
||||||
|
handleSearchChange: vi.fn(),
|
||||||
|
advOpen: false,
|
||||||
|
setAdvOpen: vi.fn(),
|
||||||
|
advApplied: null,
|
||||||
|
advInitial: EMPTY_ADVANCED_FILTERS,
|
||||||
|
applyAdvancedFilters: vi.fn(),
|
||||||
|
clearAdvancedFilters: vi.fn(),
|
||||||
|
clearAdvancedFilterChip: vi.fn(),
|
||||||
|
activeFilterCount: 0,
|
||||||
|
weekMonday: "2026-07-20",
|
||||||
|
selectedDayIso: "2026-07-20",
|
||||||
|
setWeekMonday: vi.fn(),
|
||||||
|
setSelectedDayIso: vi.fn(),
|
||||||
|
navigateToDay: vi.fn(),
|
||||||
|
dispatcherIds: new Set<string>(),
|
||||||
|
setDispatcherIds: vi.fn(),
|
||||||
|
types: new Set(),
|
||||||
|
setTypes: vi.fn(),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/app/(protected)/workorders/_hooks/use-work-orders-table-data", () => ({
|
||||||
|
useWorkOrdersTableData: () => ({
|
||||||
|
rows: [],
|
||||||
|
totalCount: 0,
|
||||||
|
scopeTotalCount: 0,
|
||||||
|
isFetching: false,
|
||||||
|
isLoading: false,
|
||||||
|
setSelectedId: vi.fn(),
|
||||||
|
patchRow: vi.fn(),
|
||||||
|
clearRowPatch: vi.fn(),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/domain/work-orders/use-cases/use-work-order-table-mutations", () => ({
|
||||||
|
useWorkOrderTableMutations: () => ({
|
||||||
|
patchField,
|
||||||
|
patchFieldAsync,
|
||||||
|
isPending: false,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/domain/work-orders/use-cases/use-work-orders-list", () => ({
|
||||||
|
useWorkOrderUsers: () => ({ data: [], isLoading: false }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/domain/work-orders/use-cases/use-work-order-detail", () => ({
|
||||||
|
useWorkOrderLocations: () => ({ data: [] }),
|
||||||
|
useWorkOrderVendors: () => ({ data: [] }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category", () => ({
|
||||||
|
useDropdownOptionsByCategory: () => ({ data: [] }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { useWorkOrdersListPage } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-page";
|
||||||
|
|
||||||
|
function closableRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow {
|
||||||
|
return {
|
||||||
|
id: 77,
|
||||||
|
woNumber: "24866",
|
||||||
|
type: "PM",
|
||||||
|
dispatcherId: "u1",
|
||||||
|
dispatcherName: "Dispatcher",
|
||||||
|
company: "Vendor Co",
|
||||||
|
vendorId: "7",
|
||||||
|
tech: "Tech One",
|
||||||
|
completedDate: "2026-09-15",
|
||||||
|
pm: "HVAC",
|
||||||
|
status: "In Progress",
|
||||||
|
docStatus: "No",
|
||||||
|
mediaCount: 0,
|
||||||
|
isPastDue: false,
|
||||||
|
rowVersion: "v1",
|
||||||
|
upliftSummary: { hasUplift: false, pendingCount: 0 },
|
||||||
|
...overrides,
|
||||||
|
} as WorkOrderTableRow;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("useWorkOrdersListPage completion document (SH-341)", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("completes the WO without confirmation once Generate persists the document", async () => {
|
||||||
|
const saved = closableRow({ docStatus: "Yes", rowVersion: "v2" });
|
||||||
|
patchFieldAsync.mockResolvedValueOnce({ kind: "board", row: saved });
|
||||||
|
patchFieldAsync.mockResolvedValueOnce({
|
||||||
|
kind: "board",
|
||||||
|
row: { ...saved, status: "Completed" },
|
||||||
|
});
|
||||||
|
const { result } = renderHook(() => useWorkOrdersListPage());
|
||||||
|
|
||||||
|
await result.current.saveCompletionDoc(closableRow(), { notes: "", generatedAt: "now" }, "Yes");
|
||||||
|
|
||||||
|
expect(patchFieldAsync).toHaveBeenNthCalledWith(1, 77, { docStatus: "Yes" });
|
||||||
|
expect(patchFieldAsync).toHaveBeenNthCalledWith(2, 77, { status: "Completed" }, saved);
|
||||||
|
expect(patchField).not.toHaveBeenCalled();
|
||||||
|
expect(result.current.confirmComplete).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not complete on Save draft or Not required", async () => {
|
||||||
|
const { result } = renderHook(() => useWorkOrdersListPage());
|
||||||
|
|
||||||
|
patchFieldAsync.mockResolvedValueOnce({ kind: "board", row: closableRow({ docStatus: "No" }) });
|
||||||
|
await result.current.saveCompletionDoc(closableRow(), { notes: "", generatedAt: "" }, "No");
|
||||||
|
patchFieldAsync.mockResolvedValueOnce({ kind: "board", row: closableRow({ docStatus: "NN" }) });
|
||||||
|
await result.current.saveCompletionDoc(closableRow(), { notes: "", generatedAt: "" }, "NN");
|
||||||
|
|
||||||
|
expect(patchFieldAsync).toHaveBeenCalledTimes(2);
|
||||||
|
expect(patchFieldAsync).not.toHaveBeenCalledWith(
|
||||||
|
77,
|
||||||
|
{ status: "Completed" },
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the closability gate: a missing service leaves the status unchanged", async () => {
|
||||||
|
patchFieldAsync.mockResolvedValueOnce({
|
||||||
|
kind: "board",
|
||||||
|
row: closableRow({ docStatus: "Yes", pm: "" }),
|
||||||
|
});
|
||||||
|
const { result } = renderHook(() => useWorkOrdersListPage());
|
||||||
|
|
||||||
|
await result.current.saveCompletionDoc(closableRow(), { notes: "", generatedAt: "now" }, "Yes");
|
||||||
|
|
||||||
|
expect(patchFieldAsync).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the generated document when the automatic completion is rejected", async () => {
|
||||||
|
patchFieldAsync.mockResolvedValueOnce({
|
||||||
|
kind: "board",
|
||||||
|
row: closableRow({ docStatus: "Yes" }),
|
||||||
|
});
|
||||||
|
patchFieldAsync.mockRejectedValueOnce(new Error("Work order was modified."));
|
||||||
|
const { result } = renderHook(() => useWorkOrdersListPage());
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
result.current.saveCompletionDoc(closableRow(), { notes: "", generatedAt: "now" }, "Yes"),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
expect(patchFieldAsync).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -224,6 +224,35 @@ describe("useWorkOrderTableMutations completion-doc persistence", () => {
|
||||||
expect(serverState.scheduledDate).toBe(BASE_SERVER.scheduledDate);
|
expect(serverState.scheduledDate).toBe(BASE_SERVER.scheduledDate);
|
||||||
expect(serverState.status).toBe(BASE_SERVER.status);
|
expect(serverState.status).toBe(BASE_SERVER.status);
|
||||||
});
|
});
|
||||||
|
it("sends the follow-up status patch with the rowVersion of the just-saved row (SH-341)", async () => {
|
||||||
|
const staleTableRow = { ...BASE_ROW };
|
||||||
|
const { result } = renderHook(
|
||||||
|
() =>
|
||||||
|
useWorkOrderTableMutations({
|
||||||
|
onPatch: vi.fn(),
|
||||||
|
clearPatch: () => undefined,
|
||||||
|
getRow: () => staleTableRow,
|
||||||
|
}),
|
||||||
|
{ wrapper: makeWrapper() },
|
||||||
|
);
|
||||||
|
|
||||||
|
let saved: Awaited<ReturnType<typeof result.current.patchFieldAsync>> | undefined;
|
||||||
|
await act(async () => {
|
||||||
|
saved = await result.current.patchFieldAsync(42, { docStatus: "Yes" });
|
||||||
|
});
|
||||||
|
expect(saved?.kind).toBe("board");
|
||||||
|
const savedRow = saved?.kind === "board" ? saved.row : undefined;
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.patchFieldAsync(42, { status: "Completed" }, savedRow);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(patchBoardField).toHaveBeenLastCalledWith(42, {
|
||||||
|
field: "lifecycleStatus",
|
||||||
|
value: "Completed",
|
||||||
|
workOrderVersion: "AAAAAAA=-next",
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("maybeAutoSchedule on board patch", () => {
|
describe("maybeAutoSchedule on board patch", () => {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,67 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
import { shouldCompleteAfterCompletionDoc } from "@/domain/work-orders/utils/completion-doc-auto-complete";
|
||||||
|
|
||||||
|
function closablePmRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow {
|
||||||
|
return {
|
||||||
|
id: 42,
|
||||||
|
woNumber: "24866",
|
||||||
|
type: "PM",
|
||||||
|
dispatcherId: "u1",
|
||||||
|
dispatcherName: "Dispatcher",
|
||||||
|
company: "Vendor Co",
|
||||||
|
vendorId: "7",
|
||||||
|
tech: "Tech One",
|
||||||
|
completedDate: "2026-09-15",
|
||||||
|
pm: "HVAC",
|
||||||
|
status: "In Progress",
|
||||||
|
docStatus: "Yes",
|
||||||
|
mediaCount: 0,
|
||||||
|
isPastDue: false,
|
||||||
|
upliftSummary: { hasUplift: false, pendingCount: 0 },
|
||||||
|
...overrides,
|
||||||
|
} as WorkOrderTableRow;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("shouldCompleteAfterCompletionDoc (SH-341)", () => {
|
||||||
|
it("completes a closable PM once the generated document is persisted", () => {
|
||||||
|
expect(shouldCompleteAfterCompletionDoc("Yes", closablePmRow())).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never completes on Save draft or Not required", () => {
|
||||||
|
expect(shouldCompleteAfterCompletionDoc("No", closablePmRow({ docStatus: "No" }))).toBe(false);
|
||||||
|
expect(shouldCompleteAfterCompletionDoc("NN", closablePmRow({ docStatus: "NN" }))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not complete when the server did not persist the document as generated", () => {
|
||||||
|
expect(shouldCompleteAfterCompletionDoc("Yes", closablePmRow({ docStatus: "No" }))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["dispatcher", { dispatcherId: "", dispatcherName: "" }],
|
||||||
|
["vendor", { company: "", vendorId: "" }],
|
||||||
|
["completed date", { completedDate: "" }],
|
||||||
|
["real WO number", { woNumber: "SH02137" }],
|
||||||
|
["service", { pm: "" }],
|
||||||
|
["pending uplift", { upliftSummary: { hasUplift: true, pendingCount: 1 } }],
|
||||||
|
["photo on a Reactive WO", { type: "Reactive" as const, mediaCount: 0 }],
|
||||||
|
])("keeps the SH-114 closability gate: missing %s blocks completion", (_label, overrides) => {
|
||||||
|
expect(shouldCompleteAfterCompletionDoc("Yes", closablePmRow(overrides))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("completes a Reactive WO that has its completion photo", () => {
|
||||||
|
expect(
|
||||||
|
shouldCompleteAfterCompletionDoc("Yes", closablePmRow({ type: "Reactive", mediaCount: 1 })),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves Completed, Canceled, and Past Due rows unchanged", () => {
|
||||||
|
expect(shouldCompleteAfterCompletionDoc("Yes", closablePmRow({ status: "Completed" }))).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
expect(shouldCompleteAfterCompletionDoc("Yes", closablePmRow({ status: "Canceled" }))).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
expect(shouldCompleteAfterCompletionDoc("Yes", closablePmRow({ isPastDue: true }))).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Reference in a new issue