Phase 3: VISUAL feed and M-Files drawings in the worker
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:
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user