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:
Codex Review Integration 2026-09-16 11:43:33 -03:00
parent fc3e7591dc
commit 09636ef590
6 changed files with 309 additions and 5 deletions

View file

@ -8,6 +8,7 @@ import {
} from "@/domain/work-orders/use-cases/use-work-order-detail";
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 { shouldCompleteAfterCompletionDoc } from "@/domain/work-orders/utils/completion-doc-auto-complete";
import {
patchWorkOrderAsCanceled,
patchWorkOrderAsCompleted,
@ -112,7 +113,17 @@ export function useWorkOrdersListPage() {
generatedAt: data.generatedAt,
};
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 {

View file

@ -134,11 +134,17 @@ export function useWorkOrderTableMutations(
mutationFn: ({
id,
persistPatch,
baseRow,
}: {
id: string | number;
patch: 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 }) => {
onPatch(id, persistPatch);
},
@ -182,9 +188,13 @@ export function useWorkOrderTableMutations(
);
};
const patchFieldAsync = (id: string | number, patch: WorkOrderTablePatch) => {
const persistPatch = resolvePatch(id, patch);
return patchMutation.mutateAsync({ id, patch, persistPatch });
const patchFieldAsync = (
id: string | number,
patch: WorkOrderTablePatch,
baseRow?: WorkOrderTableRow,
) => {
const persistPatch = baseRow ? mergeAutoSchedulePatch(baseRow, patch) : resolvePatch(id, patch);
return patchMutation.mutateAsync({ id, patch, persistPatch, baseRow });
};
return {

View 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;
}

View file

@ -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);
});
});

View file

@ -224,6 +224,35 @@ describe("useWorkOrderTableMutations completion-doc persistence", () => {
expect(serverState.scheduledDate).toBe(BASE_SERVER.scheduledDate);
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", () => {

View file

@ -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);
});
});