Phase 7: close, file, PDFs and prints
Clerical close and file: the VISUAL observed state beside the close confirm, a 409 with a second confirm when the feed still shows work orders open, filing with a signature and the HMAC seal, the data book built on the server and stored on the seal in the filing transaction, admin reopen keeping the broken seal with its data book. Server PDFs with reportlab, pypdf and pypdfium2: the manufacturing data book page for page after the old build (cover, index, drawings with VOID stamps, traceability and heat map rasters, weld maps and welding records, Q.C. inspections, Q.C. and test documents, MTRs, record history), the job traveler with work order QR codes, the ITP PDF filed automatically at sign-off, every print kind with the PRINTED COPY stamp, the controlled-copy stamp signed with the seal key, PDF metadata, the traceability CSV with the formula guard, and the append-only print_log with an audit row for every print and export (migration 0008). Frontend: the close and file panel, the print menu and its entry points, the printed copies list on the History tab, the ITP PDF buttons. Tests: server (close, file, reopen, seal, the builders, prints, CSV, stamps), frontend, and the Playwright close and print scenarios. Docs updated; SECURITY closes M2, S6, S7 and S8. Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
@@ -0,0 +1,269 @@
|
||||
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { PacketRead, PacketSeal, SealView, VcloseCheck } from "../api/types";
|
||||
import { DEPARTMENTS, makeMe, renderApp } from "../test/auth";
|
||||
import { callsTo, mockFetch, type Route } from "../test/fetchMock";
|
||||
import { makePacket } from "../test/packets";
|
||||
|
||||
const CLARA = { id: "11111111-1111-1111-1111-111111111111", display_name: "Clara Clerk" };
|
||||
const ANN = { id: "22222222-2222-2222-2222-222222222222", display_name: "Ann Admin" };
|
||||
|
||||
function check(over: Partial<VcloseCheck> = {}): VcloseCheck {
|
||||
return { observed_at: "2026-10-17T07:00:00Z", closed_at: null, open_wos: [], agrees: true, ...over };
|
||||
}
|
||||
|
||||
function seal(over: Partial<PacketSeal> = {}): PacketSeal {
|
||||
return {
|
||||
id: 4,
|
||||
sealed_at: "2026-10-17T10:00:00Z",
|
||||
sealed_by: CLARA,
|
||||
key_id: "k1",
|
||||
signed: true,
|
||||
broken_at: null,
|
||||
broken_by: null,
|
||||
broken_reason: null,
|
||||
open_wos: null,
|
||||
databook: { asset_id: "a-db", url: "/_blob/a-db", download_url: "/_blob/a-db?download=1", pages: 14, size: 90000, file_name: "L999-LotL999001_CI-DWG-1_Manufacturing_Data_Book.pdf" },
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
/** Complete, routed to the data book, not confirmed closed in VISUAL. */
|
||||
function toClose(over: Partial<PacketRead> = {}): PacketRead {
|
||||
return makePacket({
|
||||
status: "complete",
|
||||
routed_to: "databook",
|
||||
holder: "Clerical",
|
||||
source_kind: "visual",
|
||||
can: ["set_vclosed"],
|
||||
blocked: { file: ["Close the job in VISUAL first."] },
|
||||
vclose_check: check(),
|
||||
...over,
|
||||
});
|
||||
}
|
||||
|
||||
/** Complete and confirmed closed: ready for the data book. */
|
||||
function toFile(over: Partial<PacketRead> = {}): PacketRead {
|
||||
return toClose({ vclosed: { at: "2026-10-17T09:00:00Z", by: CLARA }, can: ["file", "set_vclosed"], blocked: {}, ...over });
|
||||
}
|
||||
|
||||
function filed(over: Partial<PacketRead> = {}): PacketRead {
|
||||
return toFile({ status: "filed", version: 9, databook_at: "2026-10-17T10:00:00Z", seal: seal(), seals: 1, can: [], blocked: {}, ...over });
|
||||
}
|
||||
|
||||
function base(packet: PacketRead, extra: Record<string, Route> = {}, roles = ["clerical"]) {
|
||||
return {
|
||||
"/api/auth/me": { body: makeMe({ roles }) },
|
||||
"/api/departments": { body: DEPARTMENTS },
|
||||
"/api/packets/1": { body: packet },
|
||||
"/api/packets/1/log": { body: [] },
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
function sealView(valid: boolean | null, s: PacketSeal = seal()): SealView {
|
||||
return { seal: s, valid, seals: [s] };
|
||||
}
|
||||
|
||||
async function sign(secret = "pw-1") {
|
||||
const sheet = await screen.findByRole("dialog", { name: "Sign" });
|
||||
fireEvent.change(within(sheet).getByLabelText("Password"), { target: { value: secret } });
|
||||
fireEvent.click(within(sheet).getByRole("button", { name: "Sign" }));
|
||||
await waitFor(() => expect(screen.queryByRole("dialog", { name: "Sign" })).not.toBeInTheDocument());
|
||||
}
|
||||
|
||||
const panel = () => screen.findByRole("region", { name: "Close and file" });
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("Close and file: ready to close", () => {
|
||||
it("shows the state, the exports, and confirms the close", async () => {
|
||||
const open = vi.spyOn(window, "open").mockImplementation(() => null);
|
||||
const fetch = mockFetch(base(toClose(), { "POST /api/packets/1/vclosed": { body: toFile({ version: 4 }) } }));
|
||||
renderApp("/packets/1");
|
||||
const box = await panel();
|
||||
expect(box).toHaveTextContent("Back from the shop, ready to close.");
|
||||
const file = within(box).getByRole("button", { name: "File in the data book" });
|
||||
expect(file).toBeDisabled();
|
||||
expect(within(box).getByText("Close the job in VISUAL first.")).toHaveClass("why");
|
||||
|
||||
fireEvent.click(within(box).getByRole("button", { name: "Data book (PDF)" }));
|
||||
expect(open).toHaveBeenCalledWith("/api/packets/1/pdf/databook", "_blank", "noopener");
|
||||
expect(within(box).getByRole("link", { name: "Download" })).toHaveAttribute("href", "/api/packets/1/pdf/databook?download=1");
|
||||
expect(within(box).getByRole("link", { name: "Download" })).toHaveAttribute("download");
|
||||
expect(within(box).getByRole("link", { name: "Traceability (CSV)" })).toHaveAttribute("href", "/api/packets/1/export/traceability.csv");
|
||||
fireEvent.click(within(box).getByRole("button", { name: "Print…" }));
|
||||
expect(await screen.findByRole("dialog", { name: "Print" })).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
|
||||
fireEvent.click(within(box).getByRole("button", { name: "Job closed in VISUAL" }));
|
||||
await waitFor(() => expect(screen.getByRole("region", { name: "Close and file" })).toHaveTextContent("Ready for the data book."));
|
||||
expect(callsTo(fetch, "POST", "/api/packets/1/vclosed")).toEqual([{ closed: true }]);
|
||||
expect(screen.getByRole("region", { name: "Close and file" })).toHaveTextContent(/Job closed in VISUAL .* by Clara Clerk\./);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["the feed saw the job closed", check({ closed_at: "2026-10-17T06:00:00Z", agrees: false }), /^VISUAL shows the job closed since .+\.$/],
|
||||
["work orders still open", check({ open_wos: ["L999001/001", "L999001/002"] }), /^VISUAL still shows 2 work orders open: L999001\/001, L999001\/002\.$/],
|
||||
["one work order open", check({ open_wos: ["L999001/001"] }), /^VISUAL still shows 1 work order open: L999001\/001\.$/],
|
||||
["the feed has not looked", check({ observed_at: null }), /^The feed has not checked VISUAL yet\.$/],
|
||||
])("the VISUAL line when %s", async (_name, c, text) => {
|
||||
mockFetch(base(toClose({ vclose_check: c })));
|
||||
renderApp("/packets/1");
|
||||
const box = await panel();
|
||||
expect(within(box).getByTestId("visual-line")).toHaveTextContent(text);
|
||||
});
|
||||
|
||||
it("a hand packet has no VISUAL line", async () => {
|
||||
mockFetch(base(toClose({ source_kind: "hand", vclose_check: null })));
|
||||
renderApp("/packets/1");
|
||||
const box = await panel();
|
||||
expect(box).toHaveTextContent("Back from the shop, ready to close.");
|
||||
expect(within(box).queryByTestId("visual-line")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("is not shown before complete or to a role with nothing to do", async () => {
|
||||
mockFetch(base(toClose({ can: [], blocked: {} }), {}, ["warehouse"]));
|
||||
renderApp("/packets/1");
|
||||
await screen.findByRole("tab", { name: /^Materials/ });
|
||||
expect(screen.queryByRole("region", { name: "Close and file" })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Close and file: ready for the data book", () => {
|
||||
it("files with the signature", async () => {
|
||||
const fetch = mockFetch(
|
||||
base(toFile(), {
|
||||
"POST /api/packets/1/transitions": (req) =>
|
||||
(req.body as { sign?: unknown }).sign
|
||||
? { body: filed() }
|
||||
: { status: 400, body: { code: "signature_required", detail: "This step needs your signature.", meaning: "File in the data book" } },
|
||||
"/api/packets/1/seal": { body: sealView(true) },
|
||||
}),
|
||||
);
|
||||
renderApp("/packets/1");
|
||||
const box = await panel();
|
||||
expect(box).toHaveTextContent("Ready for the data book.");
|
||||
expect(within(box).getByRole("button", { name: "Not closed in VISUAL, move back" })).toBeEnabled();
|
||||
fireEvent.click(within(box).getByRole("button", { name: "File in the data book" }));
|
||||
expect(await within(await screen.findByRole("dialog", { name: "Sign" })).findByText("File in the data book")).toBeInTheDocument();
|
||||
await sign();
|
||||
await waitFor(() => expect(screen.getByRole("region", { name: "Close and file" })).toHaveTextContent("Filed in the data book"));
|
||||
expect(callsTo(fetch, "POST", "/api/packets/1/transitions")).toEqual([
|
||||
{ action: "file", expect: { status: "complete", dept: "03" } },
|
||||
{ action: "file", expect: { status: "complete", dept: "03" }, sign: { factor: "password", secret: "pw-1" } },
|
||||
]);
|
||||
});
|
||||
|
||||
it("warns in a callout when VISUAL still shows work orders open", async () => {
|
||||
mockFetch(base(toFile({ vclose_check: check({ open_wos: ["L999001/001"], agrees: false }) })));
|
||||
renderApp("/packets/1");
|
||||
const box = await panel();
|
||||
const line = within(box).getByTestId("visual-line");
|
||||
expect(line).toHaveClass("callout", "warn");
|
||||
expect(line).toHaveTextContent("VISUAL still shows 1 work order open: L999001/001.");
|
||||
});
|
||||
|
||||
it("asks on 409 open_work_orders and sends it again with confirm_open_wos", async () => {
|
||||
const msg = "VISUAL still shows 1 work order open on this packet: L999001/001. File anyway?";
|
||||
const fetch = mockFetch(
|
||||
base(toFile({ vclose_check: check({ open_wos: ["L999001/001"], agrees: false }) }), {
|
||||
"POST /api/packets/1/transitions": (req) => {
|
||||
const b = req.body as { sign?: unknown; confirm_open_wos?: boolean };
|
||||
if (!b.confirm_open_wos) return { status: 409, body: { code: "open_work_orders", detail: msg, work_orders: ["L999001/001"] } };
|
||||
if (!b.sign) return { status: 400, body: { code: "signature_required", detail: "This step needs your signature.", meaning: "File in the data book" } };
|
||||
return { body: filed({ seal: seal({ open_wos: ["L999001/001"] }) }) };
|
||||
},
|
||||
"/api/packets/1/seal": { body: sealView(true, seal({ open_wos: ["L999001/001"] })) },
|
||||
}),
|
||||
);
|
||||
renderApp("/packets/1");
|
||||
fireEvent.click(within(await panel()).getByRole("button", { name: "File in the data book" }));
|
||||
const sheet = await screen.findByRole("dialog", { name: "File with open work orders?" });
|
||||
expect(within(sheet).getByText(msg)).toBeInTheDocument();
|
||||
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
|
||||
fireEvent.click(within(sheet).getByRole("button", { name: "File anyway" }));
|
||||
await sign();
|
||||
await waitFor(() => expect(screen.getByRole("region", { name: "Close and file" })).toHaveTextContent("Filed in the data book"));
|
||||
expect(callsTo(fetch, "POST", "/api/packets/1/transitions")).toEqual([
|
||||
{ action: "file", expect: { status: "complete", dept: "03" } },
|
||||
{ action: "file", expect: { status: "complete", dept: "03" }, confirm_open_wos: true },
|
||||
{ action: "file", expect: { status: "complete", dept: "03" }, confirm_open_wos: true, sign: { factor: "password", secret: "pw-1" } },
|
||||
]);
|
||||
expect(screen.getByRole("region", { name: "Close and file" })).toHaveTextContent("Filed with work orders open in VISUAL: L999001/001.");
|
||||
});
|
||||
|
||||
it("Cancel on the confirm sends nothing more", async () => {
|
||||
const fetch = mockFetch(
|
||||
base(toFile(), {
|
||||
"POST /api/packets/1/transitions": { status: 409, body: { code: "open_work_orders", detail: "VISUAL still shows 1 work order open.", work_orders: ["L999001/001"] } },
|
||||
}),
|
||||
);
|
||||
renderApp("/packets/1");
|
||||
fireEvent.click(within(await panel()).getByRole("button", { name: "File in the data book" }));
|
||||
const sheet = await screen.findByRole("dialog", { name: "File with open work orders?" });
|
||||
fireEvent.click(within(sheet).getByRole("button", { name: "Cancel" }));
|
||||
await waitFor(() => expect(screen.queryByRole("dialog", { name: "File with open work orders?" })).not.toBeInTheDocument());
|
||||
expect(callsTo(fetch, "POST", "/api/packets/1/transitions")).toHaveLength(1);
|
||||
expect(screen.getByRole("region", { name: "Close and file" })).toHaveTextContent("Ready for the data book.");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Close and file: filed", () => {
|
||||
it("shows the seal intact and opens the filed data book", async () => {
|
||||
const open = vi.spyOn(window, "open").mockImplementation(() => null);
|
||||
mockFetch(base(filed(), { "/api/packets/1/seal": { body: sealView(true) } }));
|
||||
renderApp("/packets/1");
|
||||
const box = await panel();
|
||||
expect(box).toHaveTextContent(/Filed in the data book .* by Clara Clerk\. The data book has 14 pages\./);
|
||||
expect(await within(box).findByText("Seal intact (key k1)")).toHaveClass("pill", "st-ok");
|
||||
fireEvent.click(within(box).getByRole("button", { name: "Data book (PDF)" }));
|
||||
expect(open).toHaveBeenCalledWith("/_blob/a-db", "_blank", "noopener");
|
||||
expect(within(box).getByRole("link", { name: "Download" })).toHaveAttribute("href", "/_blob/a-db?download=1");
|
||||
expect(within(box).getByRole("link", { name: "Traceability (CSV)" })).toBeInTheDocument();
|
||||
expect(within(box).getByRole("button", { name: "Print…" })).toBeInTheDocument();
|
||||
expect(within(box).queryByRole("button", { name: "Reopen (IT)" })).not.toBeInTheDocument();
|
||||
expect(within(box).queryByRole("button", { name: "File in the data book" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("says when the seal does not match", async () => {
|
||||
mockFetch(base(filed(), { "/api/packets/1/seal": { body: sealView(false) } }));
|
||||
renderApp("/packets/1");
|
||||
const box = await panel();
|
||||
const state = await within(box).findByTestId("seal-state");
|
||||
expect(state).toHaveClass("callout", "warn");
|
||||
expect(state).toHaveTextContent("Seal does not match the record.");
|
||||
});
|
||||
|
||||
it("says when the seal cannot be checked", async () => {
|
||||
mockFetch(base(filed(), { "/api/packets/1/seal": { body: sealView(null) } }));
|
||||
renderApp("/packets/1");
|
||||
const box = await panel();
|
||||
expect(await within(box).findByTestId("seal-state")).toHaveTextContent("Seal unknown (key not available)");
|
||||
});
|
||||
|
||||
it("IT reopens with the reason and the signature; the panel keeps the broken seal line", async () => {
|
||||
const reopened = toFile({
|
||||
version: 10,
|
||||
can: ["admin_reopen"],
|
||||
seal: seal({ broken_at: "2026-10-17T11:00:00Z", broken_by: ANN, broken_reason: "wrong MTR filed" }),
|
||||
seals: 1,
|
||||
});
|
||||
const fetch = mockFetch(
|
||||
base(filed({ can: ["admin_reopen"] }), { "/api/packets/1/seal": { body: sealView(true) }, "POST /api/packets/1/reopen": { body: reopened } }, ["admin"]),
|
||||
);
|
||||
renderApp("/packets/1");
|
||||
const box = await panel();
|
||||
fireEvent.click(within(box).getByRole("button", { name: "Reopen (IT)" }));
|
||||
const sheet = await screen.findByRole("dialog", { name: "Reopen a filed packet" });
|
||||
fireEvent.change(within(sheet).getByLabelText("Reason"), { target: { value: "wrong MTR filed" } });
|
||||
fireEvent.click(within(sheet).getByRole("button", { name: "Reopen" }));
|
||||
expect(await within(await screen.findByRole("dialog", { name: "Sign" })).findByText("Reopen a filed packet")).toBeInTheDocument();
|
||||
await sign();
|
||||
await waitFor(() => expect(screen.getByTestId("reopened")).toHaveTextContent(/^Reopened by Ann Admin .+: wrong MTR filed\. The earlier seal is kept as broken\.$/));
|
||||
expect(callsTo(fetch, "POST", "/api/packets/1/reopen")).toEqual([{ reason: "wrong MTR filed", sign: { factor: "password", secret: "pw-1" } }]);
|
||||
expect(screen.getByRole("region", { name: "Close and file" })).toHaveTextContent("Ready for the data book.");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user