Phase 2: core domain, policy engine, packet API, Clerical screen

Migration 0003: projects, aliases, renumbers, units, packets and every child table,
change orders, assets, documents, ITPs, station limits, department weld flag.
Policy package: Principal, PacketState, Action rules, custody, preconditions,
CO hold, order of checks, can/blocked/holder on reads. docs/POLICY.md with the
tightenings table for Seth. Write pipeline: FOR UPDATE, CAS on status and dept,
If-Match row versions, server-assigned numbers, field CAS, version bump, packet
log, audit, NOTIFY, signatures, IT reasons, filed lock and HMAC seal.
API: /api/packets (header, transitions, work orders, pages, docs, move, remove,
BOM, shorts, welds, heat tags, heat table, stage, checklist, clock, warehouse
stage, Q.C., change orders, log, seal), /api/projects, /api/units, /api/assets,
/_blob. Frontend: Clerical home, projects and units, packet page with pdf.js
and QR codes, hand draft, heat map, send to warehouse, department or Q.C.
Tests: policy matrix, API, concurrency, schema, projects; Playwright Clerical
draft scenario. Verified on docker-1 with the CI stack and the Samba DC.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
2026-10-16 09:31:45 -06:00
co-authored by Claude Fable 5.1
parent 97b0973130
commit 37d9a0cb50
83 changed files with 16490 additions and 55 deletions
+151
View File
@@ -0,0 +1,151 @@
import { mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { expect, test, type Page } from "@playwright/test";
import { dcUser, signInPassword } from "./helpers";
// Phase 2 Clerical scenario on the CI stack. Runs after signin.spec.ts on the one worker.
// clerk.clara sets up project L999 with unit 001, makes a hand draft, attaches a drawing with a
// signature, answers the heat map question and sends it to the warehouse. wh.wade then sees a
// Clerical draft as view only, and the warehouse packet without the Clerical steps.
/** A minimal valid one-page PDF, built from raw bytes with a correct xref table. */
function onePagePdf(): Buffer {
const content = "0 0 m 612 792 l S";
const objects = [
"<< /Type /Catalog /Pages 2 0 R >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << >> >>",
`<< /Length ${content.length} >>\nstream\n${content}\nendstream`,
];
let out = "%PDF-1.4\n";
const offsets: number[] = [];
objects.forEach((body, i) => {
offsets.push(out.length);
out += `${i + 1} 0 obj\n${body}\nendobj\n`;
});
const xref = out.length;
out += `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n`;
out += offsets.map((o) => `${String(o).padStart(10, "0")} 00000 n \n`).join("");
out += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF\n`;
return Buffer.from(out, "latin1");
}
function writePdf(): string {
const dir = mkdtempSync(join(tmpdir(), "ps-e2e-"));
const file = join(dir, "CI-DWG-1.pdf");
writeFileSync(file, onePagePdf());
return file;
}
/** From the unit page: New packet sheet, filled and saved. Returns the packet page URL. */
async function newDraft(page: Page, d: { op: string; title: string; drawing: string; rev: string; wo?: [string, string] }): Promise<string> {
await page.getByRole("button", { name: "Add packet" }).click();
const sheet = page.getByRole("dialog", { name: "New packet" });
await expect(sheet).toBeVisible();
// The unit comes from the unit page. Wait until the picker shows it.
await expect(sheet.getByLabel("Unit", { exact: true })).toHaveValue(/\d+/);
await sheet.getByLabel("First department").selectOption("03");
await sheet.getByLabel("Operation", { exact: true }).fill(d.op);
await sheet.getByLabel("Title", { exact: true }).fill(d.title);
await sheet.getByLabel("Drawing no.", { exact: true }).fill(d.drawing);
await sheet.getByLabel("Rev", { exact: true }).fill(d.rev);
if (d.wo) {
await sheet.getByLabel("Work order 1", { exact: true }).fill(d.wo[0]);
await sheet.getByLabel("Work order 1 lot", { exact: true }).fill(d.wo[1]);
}
await sheet.getByRole("button", { name: "Save packet as draft" }).click();
await expect(page).toHaveURL(/\/packets\/\d+$/);
await expect(page.getByRole("heading", { name: `L999001 · ${d.op}` })).toBeVisible();
return page.url();
}
let unitUrl = "";
let warehousePacketUrl = "";
let warehousePacketId = 0;
test.describe.serial("clerical hand draft", () => {
test("clerk.clara makes a hand draft and sends it to the warehouse", async ({ page }) => {
await signInPassword(page, "clerk.clara");
await expect(page).toHaveURL(/\/clerical/);
// Project L999 with one unit, lot 001.
await page.getByRole("button", { name: "New project" }).click();
const proj = page.getByRole("dialog", { name: "New project" });
await proj.getByLabel("Project number").fill("L999");
await proj.getByLabel("Project name").fill("CI project");
await proj.getByRole("button", { name: "Create project" }).click();
const units = page.getByRole("dialog", { name: "Add units" });
await expect(units).toBeVisible();
await units.getByLabel("Lot", { exact: true }).fill("001");
await units.getByRole("button", { name: "Add", exact: true }).click();
await expect(page).toHaveURL(/\/clerical\/projects\/\d+$/);
await expect(page.getByRole("heading", { name: "L999 · CI project" })).toBeVisible();
// Open the unit.
await page.getByRole("row", { name: /L999001/ }).click();
await expect(page).toHaveURL(/\/clerical\/units\/\d+$/);
unitUrl = page.url();
// The hand draft.
warehousePacketUrl = await newDraft(page, { op: "WELD-PART", title: "CI spool", drawing: "CI-DWG-1", rev: "0", wo: ["L999001", "001"] });
warehousePacketId = Number(new URL(warehousePacketUrl).pathname.split("/").pop());
await expect(page.getByTestId("status-pill")).toHaveText("Ready to release");
// Not sendable yet: no heat map answer and no drawing.
await expect(page.getByRole("button", { name: "Send to warehouse" })).toBeDisabled();
// Attach the drawing, signed with Clara's password.
await page.getByLabel("Attach drawing (PDF)").setInputFiles(writePdf());
const sign = page.getByRole("dialog", { name: "Sign" });
await expect(sign).toBeVisible();
await expect(sign.getByText("You are signing as Clara Clerk.")).toBeVisible();
await sign.getByLabel("Password").fill(dcUser("clerk.clara").password);
await sign.getByRole("button", { name: "Sign", exact: true }).click();
await expect(sign).toBeHidden();
await expect(page.getByText(/Drawing attached/)).toBeVisible();
// Heat map: Yes.
const hm = page.getByRole("group", { name: "Heat map required?" });
await hm.getByRole("button", { name: "Yes" }).click();
await expect(hm.getByRole("button", { name: "Yes" })).toHaveAttribute("aria-pressed", "true");
// Send it on.
const send = page.getByRole("button", { name: "Send to warehouse" });
await expect(send).toBeEnabled();
await send.click();
await expect(page.getByTestId("status-pill")).toHaveText("With warehouse");
const res = await page.request.get("/api/packets?status=warehouse");
expect(res.ok()).toBeTruthy();
const body = (await res.json()) as { packets: Array<{ id: number; status: string }> };
expect(body.packets.some((p) => p.id === warehousePacketId && p.status === "warehouse")).toBeTruthy();
});
test("wh.wade sees a Clerical draft as view only, and no Send button", async ({ browser }) => {
// A second draft that stays with Clerical.
const clara = await browser.newContext();
const cp = await clara.newPage();
await signInPassword(cp, "clerk.clara");
await cp.goto(unitUrl);
const draftUrl = await newDraft(cp, { op: "FIT-UP", title: "CI view only", drawing: "CI-DWG-2", rev: "0" });
await clara.close();
const wade = await browser.newContext();
const page = await wade.newPage();
await signInPassword(page, "wh.wade");
await page.goto(draftUrl);
await expect(page.getByTestId("status-pill")).toHaveText("Ready to release");
await expect(page.getByText("View only.")).toBeVisible();
await expect(page.locator(".viewonly")).toContainText("This packet is with Clerical");
await expect(page.getByRole("button", { name: /^Send/ })).toHaveCount(0);
await expect(page.getByRole("group", { name: "Heat map required?" })).toHaveCount(0);
// The packet in the warehouse shows its status, and none of the Clerical steps.
await page.goto(warehousePacketUrl);
await expect(page.getByTestId("status-pill")).toHaveText("With warehouse");
await expect(page.getByRole("button", { name: "Send to warehouse" })).toHaveCount(0);
await wade.close();
});
});