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:
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user