Phase 3: VISUAL feed and M-Files drawings in the worker
ci / frontend (push) Successful in 30s
ci / server (push) Successful in 4m12s
ci / image (push) Successful in 46s
ci / e2e (push) Successful in 1m24s

Migration 0004 (VISUAL and drawing caches, go-live ignored list, resource
map, job tables, order-derived columns with provenance; work order key
includes the operation sequence). The real worker on APScheduler with
settings re-read each fire, pause, run now, advisory locks, job_runs and a
heartbeat. The visual-feed job (five VisualKit reads, COPY and MERGE cache,
suspect-batch guard, the apply in one transaction: drafts, change orders,
retire-moved, work order gone, closed and cancelled observed, projects and
units from order links, auto-release) and the fingerprint. The drawing-lookup
job (queue, budget, download on version change, sha256 into assets, decision
table, staging for rev approval, drawing-origin change orders). Clerical From
VISUAL panel, flags, rev approval, hand uploads, change-order review; Admin
Feed settings and resource map. Synthetic fixture generator and a
fixture-driven worker in CI with a Playwright scenario. Docs updated.

Verified: 3249 server tests and 13 Playwright scenarios on the docker-1 CI
stack, 76 frontend tests, doctor at 0004; one feed and one drawing run
against VISUAL DEV and the vault from the developer machine (counts in the
changelog).

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
2026-10-16 14:45:38 -06:00
co-authored by Claude Fable 5.1
parent 16950055e3
commit 591fe90076
103 changed files with 16063 additions and 151 deletions
+1 -1
View File
@@ -38,7 +38,7 @@ test("3. a user with no PS group sees the no-role message", async ({ page }) =>
test("4. clerk.clara lands on Clerical with no role switcher", async ({ page }) => {
await signInPassword(page, "clerk.clara");
await expect(page).toHaveURL(/\/clerical/);
await expect(page.getByText(/Arrives in phase/i)).toBeVisible();
await expect(page.getByRole("heading", { name: /^Changes to approve/ })).toBeVisible();
await expect(page.locator(".roles")).toHaveCount(0);
await expect(topBar(page).getByRole("button", { name: /^Supervisor$/ })).toHaveCount(0);
});
+211
View File
@@ -0,0 +1,211 @@
import { writeFileSync } from "node:fs";
import { join } from "node:path";
import { expect, test, type APIRequestContext, type Page } from "@playwright/test";
import { dcUser, signInPassword } from "./helpers";
// Phase 3 VISUAL feed scenario on the CI stack. The worker reads the synthetic fixture files
// (tests/fixtures/generator.py) mounted at /fixtures; this container mounts them read-write so the
// scenario can switch the state. clerk.clara presses Run now, the feed makes project L900 and its
// drafts, the chained drawing run attaches the WELD-PART drawing, the packet goes to the warehouse and
// wh.wade releases it to the shop. State 2 moves that drawing to rev 2: the next run opens a change
// order, the drawing run stages the rev 2 PDF, and clara approves it with her password.
const FIXTURE_DIR = process.env.FIXTURE_DIR ?? "/fixtures";
const KEY_WELD_PART = "L900001|WELD-PART|L900-0101";
const POLL_MS = 60_000;
type Json = Record<string, any>;
function writeHeaders(page: Page): Record<string, string> {
const base = new URL(page.url()).origin;
return { Origin: base, "X-Requested-With": "PacketStation" };
}
async function getJson(req: APIRequestContext, url: string): Promise<Json> {
const r = await req.get(url);
expect(r.status(), `${url}: ${await r.text()}`).toBe(200);
return (await r.json()) as Json;
}
async function lastRunId(page: Page): Promise<number> {
const o = await getJson(page.request, "/api/feed/overview");
return o.last_run ? Number(o.last_run.id) : 0;
}
/** Wait until a visual-feed run newer than `after` has finished ok (or degraded). Returns it. */
async function waitForRun(page: Page, after: number): Promise<Json> {
let run: Json | null = null;
await expect
.poll(
async () => {
const o = await getJson(page.request, "/api/feed/overview");
run = (o.runs as Json[]).find((r) => Number(r.id) > after && r.status !== "running") ?? null;
return run ? run.status : "waiting";
},
{ timeout: POLL_MS, intervals: [1_000, 2_000] },
)
.toMatch(/^(ok|degraded)$/);
return run as unknown as Json;
}
async function pressRunNow(page: Page): Promise<void> {
const button = page.getByRole("button", { name: /run now/i }).first();
await expect(button).toBeEnabled({ timeout: POLL_MS });
await button.click();
}
/** The live packet for a candidate key "<unit>|<op>|<drawing>", found by its card fields. */
async function packetByKey(page: Page, key: string): Promise<Json> {
const [unit, op, drawing] = key.split("|");
const list = await getJson(page.request, "/api/packets?limit=2000");
const card = (list.packets as Json[]).find(
(p) => p.unit?.number === unit && p.op === op && (p.drawing ?? "") === drawing && p.source_kind === "visual",
);
if (!card) throw new Error(`no packet for ${key}`);
return getJson(page.request, `/api/packets/${card.id}`);
}
async function transition(page: Page, pkt: Json, action: string, extra: Json = {}) {
return page.request.post(`/api/packets/${pkt.id}/transitions`, {
headers: writeHeaders(page),
data: { action, expect: { status: pkt.status, dept: pkt.dept?.code ?? pkt.dept_code }, ...extra },
});
}
let packetId = 0;
test.describe.serial("VISUAL feed", () => {
test.setTimeout(300_000);
test("Run now makes project L900, its drafts and attaches the WELD-PART drawing", async ({ page }) => {
writeFileSync(join(FIXTURE_DIR, "state"), "1");
await signInPassword(page, "clerk.clara");
await expect(page).toHaveURL(/\/clerical/);
await expect(page.getByText(/from visual/i).first()).toBeVisible();
const before = await lastRunId(page);
await pressRunNow(page);
const run = await waitForRun(page, before);
expect(run.counts.new_drafts).toBeGreaterThan(0);
const projects = await getJson(page.request, "/api/projects");
const list = projects as unknown as Json[];
const l900 = list.find((p) => p.number === "L900");
expect(l900, "project L900").toBeTruthy();
const proj = await getJson(page.request, `/api/projects/${l900!.id}`);
const unitNumbers = ((proj.units ?? []) as Json[]).map((u) => u.number);
expect(unitNumbers).toEqual(expect.arrayContaining(["L900001", "L900002"]));
let pkt = await packetByKey(page, KEY_WELD_PART);
packetId = Number(pkt.id);
expect(pkt.status).toBe("draft");
// Either still waiting for its drawing, or the chained drawing job already attached it
const flags = pkt.flags as string[];
expect(flags.includes("drawing_needed") || pkt.drawing_state === "attached").toBe(true);
// The drawing job runs right after the feed (chained)
await expect
.poll(async () => (await getJson(page.request, `/api/packets/${packetId}`)).drawing_state, {
timeout: POLL_MS,
intervals: [1_000, 2_000],
})
.toBe("attached");
await page.goto(`/packets/${packetId}`);
// The Drawing tab shows the pages the feed attached from M-Files (the fixture vault)
const full = await getJson(page.request, `/api/packets/${packetId}`);
expect(((full.pages ?? []) as Json[]).length).toBeGreaterThan(0);
await expect(page.getByRole("tab", { name: /^Drawing/ }).or(page.getByRole("button", { name: /^Drawing/ })).first()).toBeVisible();
// Send it on, unless the feed auto-released it already
pkt = await getJson(page.request, `/api/packets/${packetId}`);
if (pkt.status === "draft") {
if (pkt.heat_map === null || pkt.heat_map === undefined) {
const r = await page.request.patch(`/api/packets/${packetId}`, {
headers: writeHeaders(page),
data: { set: { heat_map: true }, base: {} },
});
expect(r.status(), await r.text()).toBe(200);
pkt = await getJson(page.request, `/api/packets/${packetId}`);
}
const r = await transition(page, pkt, "send_to_warehouse");
if (r.status() !== 200) {
// the worker may have auto-released it between the two reads
pkt = await getJson(page.request, `/api/packets/${packetId}`);
expect(pkt.status, await r.text()).toBe("warehouse");
}
}
pkt = await getJson(page.request, `/api/packets/${packetId}`);
expect(pkt.status).toBe("warehouse");
});
test("wh.wade releases it to the shop", async ({ page }) => {
await signInPassword(page, "wh.wade");
let pkt = await getJson(page.request, `/api/packets/${packetId}`);
// Release needs every material line pulled or short: pull each one, as the warehouse would
for (const line of (pkt.bom ?? []) as Json[]) {
if (line.pulled || line.na) continue;
const pr = await page.request.patch(`/api/packets/${packetId}/bom/${line.item}`, {
headers: { ...writeHeaders(page), "If-Match": String(line.row_version) },
data: { pulled: true },
});
expect(pr.status(), await pr.text()).toBe(200);
}
pkt = await getJson(page.request, `/api/packets/${packetId}`);
const r = await transition(page, pkt, "wh_release");
expect(r.status(), await r.text()).toBe(200);
expect((await r.json()).status).toBe("ready");
});
test("VISUAL moves the drawing to rev 2: a change order, staged, approved", async ({ page }) => {
await signInPassword(page, "clerk.clara");
await expect(page).toHaveURL(/\/clerical/);
writeFileSync(join(FIXTURE_DIR, "state"), "2");
const before = await lastRunId(page);
await pressRunNow(page);
const run = await waitForRun(page, before);
expect(run.counts.cos_opened).toBeGreaterThan(0);
const cos = await getJson(page.request, `/api/packets/${packetId}/change-orders`);
const list = cos as unknown as Json[];
const co = list.find((c) => c.review === "pending");
expect(co, "a pending change order").toBeTruthy();
// The Changes tab on the packet and the Clerical home both show it
await page.goto(`/packets/${packetId}`);
await page.getByRole("tab", { name: /changes/i }).or(page.getByRole("button", { name: /^changes/i })).first().click();
await expect(page.getByText(/waiting on clerical/i).first()).toBeVisible();
await page.goto("/clerical");
await expect(page.getByText(/changes to approve/i).first()).toBeVisible();
await expect(page.getByText("L900001").first()).toBeVisible();
// The chained drawing run stages the rev 2 PDF
await expect
.poll(
async () => {
const all = await getJson(page.request, `/api/packets/${packetId}/change-orders`);
const c = (all as unknown as Json[]).find((x) => x.co_no === co!.co_no);
return c?.staged ? "staged" : "waiting";
},
{ timeout: POLL_MS, intervals: [1_000, 2_000] },
)
.toBe("staged");
const clara = dcUser("clerk.clara");
const r = await page.request.post(`/api/packets/${packetId}/change-orders/${co!.co_no}/approve`, {
headers: writeHeaders(page),
data: { sign: { factor: "password", secret: clara.password } },
});
expect(r.status(), await r.text()).toBe(200);
const pkt = await getJson(page.request, `/api/packets/${packetId}`);
expect(pkt.rev).toBe("2");
// The old rev 1 pages are kept and voided; the rev 2 pages carry the change order
const pages = (pkt.pages ?? []) as Json[];
expect(pages.some((pg) => pg.void)).toBe(true);
expect(pages.some((pg) => !pg.void && pg.rev === "2")).toBe(true);
await page.goto(`/packets/${packetId}`);
await expect(page.getByText(`${pkt.drawing} R2`, { exact: true }).first()).toBeVisible();
// The Drawing tab lists the voided rev 1 page beside the new rev 2 page
await expect(page.getByRole("button", { name: /\bvoid\b/i }).first()).toBeVisible();
});
});