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();
|
||||
});
|
||||
});
|
||||
Vendored
+67
@@ -0,0 +1,67 @@
|
||||
# Synthetic VISUAL and M-Files fixtures
|
||||
|
||||
Everything here is made up. Real job data never goes in git: recordings from DEV stay in
|
||||
`tests/fixtures/local/` (ignored).
|
||||
|
||||
```
|
||||
python -m tests.fixtures.generator --out tests/fixtures/generated
|
||||
```
|
||||
|
||||
writes `feed.1.json`, `feed.2.json`, `drawings.1.json`, `drawings.2.json`, the one-page PDFs the drawing files
|
||||
name, and `state` (`1`). `tests/fixtures/generated/` is ignored by git. CI runs the command before
|
||||
`compose up` and mounts the folder at `/fixtures` in the worker (read-only) and Playwright (read-write).
|
||||
With `FEED_SOURCE=fixture` and `DRAWING_SOURCE=fixture` the worker reads `FIXTURE_DIR/state` and the
|
||||
matching files instead of VISUAL and M-Files. Write `2` to `state` to switch.
|
||||
|
||||
The feed files have the shape of VisualKit's `examples/packet_station_feed.py`: rows carry the status as a
|
||||
word (Firmed, Released, Unreleased, Closed, Cancelled), materials carry the status code, numbers are JSON
|
||||
numbers, dates are ISO, lot, split and sub are text, seq and piece are integers. An extra `labor` list holds
|
||||
hours per operation.
|
||||
|
||||
Dates are offsets from the day the generator runs (`--today` overrides), so the window rows sit inside the
|
||||
default window. The output is the same for the same day.
|
||||
|
||||
## State 1
|
||||
|
||||
Project L900, units L900001 and L900002, plus L901001 under project L901.
|
||||
|
||||
| Work order | Lot | What it covers |
|
||||
| --- | --- | --- |
|
||||
| L900-01 | 1 | LAYOUT and WELD-PART on drawing L900-0101 rev 1 (two packets), MFG and MAT_PIPE fold, FORKLIFT ignored; split-item header (piece 10 with 11 and 12), MAIN warehouse line, WIP sub-assembly, a line on another drawing (dropped) |
|
||||
| L900-01 split 1 | 1 | Unreleased (only on a packet when tracked) |
|
||||
| L900-02 | 1 | two WELD-CODE operations on one drawing: two legs on one packet; VISUAL rev 2, M-Files rev 3 (rev mismatch) |
|
||||
| L900-03 | 1 | no drawing (`(no drawing)` key) |
|
||||
| L900-04, L900-08-1 | 2 | one packet, rev `A` (bad rev), piece 1 on both legs (items `1` and `1-1`), a base linked to order C24-0901 |
|
||||
| L900-05 | 2 | drawing in drafting (not released) |
|
||||
| L900-06 | 2 | Released, with materials: only a packet when tracked |
|
||||
| L900-07 | 2 | drawing not in M-Files |
|
||||
| L900-09, L900-10 | 1 | close and cancel in state 2 |
|
||||
| L900-11, L900-12 | 1 | Closed and Cancelled, only when tracked |
|
||||
| L900-13 | 2 | released 10 days back: ignored when the tests set go-live 5 days back |
|
||||
| L900-14 | 7A | a lettered lot: no unit, no packet |
|
||||
| L900-15 | 2 | resource NEWRES, not in the map |
|
||||
| L900-16 | 2 | moves to drawing L900-1602 in state 2 |
|
||||
| L901-01 | 1 | project L901, only a closed order |
|
||||
|
||||
Orders: L900 has two open orders. C24-0901 (Acme Refining) names top job L900002 and is linked to base
|
||||
L900-04. C24-0902 (Bayou Chemical) names L900001, so the unit rule picks it for L900001. L901 has one closed
|
||||
order (Delta Gas).
|
||||
|
||||
## State 2
|
||||
|
||||
- L900-01 rev 1 to 2 (a change order on an in-shop packet; the LAYOUT draft updates in place).
|
||||
- L900-02 qty 2 to 3 (a draft updated in place).
|
||||
- L900-07 target hours only (a minor change order once past draft).
|
||||
- L900-05 gone from VISUAL (flagged after two runs).
|
||||
- L900-16 on drawing L900-1602 (the old draft retires, a new one is made).
|
||||
- L900-09 Closed, L900-10 Cancelled.
|
||||
- NEWRES still unmapped.
|
||||
|
||||
## Drawings
|
||||
|
||||
`drawings.1.json`: L900-0101 rev 1 (attach), L900-0201 rev 3 (VISUAL calls 2), L900-0401 rev 1 (VISUAL calls
|
||||
A), L900-0501 in drafting, L900-0701 and L900-1601 missing (not found), the rest at rev 1.
|
||||
`drawings.2.json`: the same with L900-0101 at rev 2 (a new file, so the change order gets staged) and L900-1602.
|
||||
|
||||
`generate(out_dir)`, `feed_state(n)` and `drawings_state(n)` are importable; `tests/server/feedhelpers.py`
|
||||
uses them.
|
||||
Vendored
+672
@@ -0,0 +1,672 @@
|
||||
"""Synthetic VISUAL feed and M-Files drawing fixtures for tests and CI. No real job data.
|
||||
|
||||
python -m tests.fixtures.generator --out tests/fixtures/generated
|
||||
|
||||
Writes feed.1.json, feed.2.json, drawings.1.json, drawings.2.json, the one-page PDFs the drawing files name, and
|
||||
`state` = "1". The feed files have the exact shape of VisualKit's examples/packet_station_feed.py: rows carry the
|
||||
status as a word, materials carry the status code, numbers are JSON numbers, dates are ISO, lot/split/sub are text,
|
||||
seq and piece are ints. An optional `labor` list follows the FixtureSource contract.
|
||||
|
||||
Dates are offsets from `today` (default: the date the generator runs), so the window rows stay inside the feed's
|
||||
default window (14 days back, 30 ahead) on the day the files are made. Everything else is fixed: the output is the
|
||||
same for the same `today`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import io
|
||||
import json
|
||||
import sys
|
||||
from datetime import date, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
SITE = "PESCO"
|
||||
STATUS_WORDS = {"F": "Firmed", "R": "Released", "U": "Unreleased", "C": "Closed", "X": "Cancelled"}
|
||||
|
||||
# The part of VISUAL's resource map the fixtures use (the seed in migration 0004 has these too). NEWRES is unmapped.
|
||||
RESOURCE_MAP: dict[str, str] = {
|
||||
"LAYOUT": "01",
|
||||
"WELD-PART": "03",
|
||||
"WELD-CODE": "06",
|
||||
"MFG": "fold",
|
||||
"FORKLIFT": "ignore",
|
||||
}
|
||||
|
||||
# Go-live: the tests set feed settings golive_date = today + GOLIVE_OFFSET. Work order L900-13 is released before it.
|
||||
GOLIVE_OFFSET = -5
|
||||
|
||||
# Names the tests and the e2e scenario refer to.
|
||||
PROJECT = "L900"
|
||||
PROJECT_CLOSED_ORDER = "L901"
|
||||
UNIT1, UNIT2 = "L900001", "L900002"
|
||||
KEY_WELD_PART = "L900001|WELD-PART|L900-0101" # rev 1 -> 2 in state 2; the in-shop packet in the scenario
|
||||
KEY_LAYOUT_A = "L900001|LAYOUT|L900-0101" # same work order, another resource: a second packet
|
||||
KEY_TWO_OPS = "L900001|WELD-CODE|L900-0201" # two operations, same resource and drawing; qty change in state 2
|
||||
KEY_NO_DWG = "L900001|WELD-PART|(no drawing)"
|
||||
KEY_REV_A = "L900002|WELD-PART|L900-0401" # non-numeric rev; two legs with duplicate pieces
|
||||
KEY_DRAFTING = "L900002|LAYOUT|L900-0501" # drawing in drafting; this leg is gone from VISUAL in state 2
|
||||
KEY_NOT_FOUND = "L900002|WELD-PART|L900-0701" # drawing not in M-Files; tgt-only change in state 2 (minor)
|
||||
KEY_CLOSES = "L900001|WELD-CODE|L900-0901" # closed in VISUAL in state 2
|
||||
KEY_CANCELS = "L900001|WELD-CODE|L900-1001" # cancelled in VISUAL in state 2
|
||||
KEY_MOVES = "L900002|WELD-CODE|L900-1601" # moves to drawing L900-1602 in state 2 (retire-moved)
|
||||
KEY_MOVED_TO = "L900002|WELD-CODE|L900-1602"
|
||||
KEY_GOLIVE = "L900002|WELD-PART|L900-1301" # released before go-live when the tests set it
|
||||
KEY_L901 = "L901001|WELD-PART|L901-0101"
|
||||
KEY_RELEASED = "L900002|WELD-CODE|L900-0601" # Released (not Firmed): only a candidate when tracked
|
||||
DWG_WELD_PART = "L900-0101"
|
||||
|
||||
# Every candidate state 1 makes on the first run (no tracked keys, no go-live).
|
||||
STATE1_KEYS = frozenset(
|
||||
{
|
||||
KEY_WELD_PART,
|
||||
KEY_LAYOUT_A,
|
||||
KEY_TWO_OPS,
|
||||
KEY_NO_DWG,
|
||||
KEY_REV_A,
|
||||
KEY_DRAFTING,
|
||||
KEY_NOT_FOUND,
|
||||
KEY_CLOSES,
|
||||
KEY_CANCELS,
|
||||
KEY_MOVES,
|
||||
KEY_GOLIVE,
|
||||
KEY_L901,
|
||||
}
|
||||
)
|
||||
|
||||
ORDER_ACME = "C24-0901" # open, top job L900002, also linked to base L900-04
|
||||
ORDER_BAYOU = "C24-0902" # open, top job L900001 (the unit rule must pick it for L900001)
|
||||
ORDER_L901 = "C23-0450" # closed, the only order of L901
|
||||
|
||||
|
||||
def _d(today: date, days: int | None) -> str | None:
|
||||
return None if days is None else (today + timedelta(days=days)).isoformat()
|
||||
|
||||
|
||||
def _num(x: float | int) -> int | float:
|
||||
"""A JSON number as the feed writes it: integers without '.0'."""
|
||||
return int(x) if float(x).is_integer() else float(x)
|
||||
|
||||
|
||||
class _Wo:
|
||||
"""One work order leg with its operations, as the generator describes it."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base: str,
|
||||
lot: str,
|
||||
ops: list[tuple[int, str, float]],
|
||||
*,
|
||||
dwg: str,
|
||||
rev: str,
|
||||
st: str,
|
||||
rel: int,
|
||||
want: int,
|
||||
qty: float,
|
||||
desc: str,
|
||||
split: str = "0",
|
||||
sub: str = "0",
|
||||
part: str = "",
|
||||
ps: dict[int, str] | None = None,
|
||||
) -> None:
|
||||
self.base, self.lot, self.split, self.sub = base, lot, split, sub
|
||||
self.ops = ops # (seq, resource, run hours per piece)
|
||||
self.dwg, self.rev, self.st, self.rel, self.want, self.qty = dwg, rev, st, rel, want, qty
|
||||
self.desc, self.part, self.ps = desc, part or f"FG-{base}", ps or {}
|
||||
|
||||
def rows(self, today: date) -> list[dict[str, Any]]:
|
||||
out = []
|
||||
for seq, res, run in self.ops:
|
||||
out.append(
|
||||
{
|
||||
"wo": self.base,
|
||||
"lot": self.lot,
|
||||
"split": self.split,
|
||||
"sub": self.sub,
|
||||
"seq": seq,
|
||||
"res": res,
|
||||
"dwg": self.dwg,
|
||||
"rev": self.rev,
|
||||
"ps": self.ps.get(seq, ""),
|
||||
"desc": self.desc,
|
||||
"qty": _num(self.qty),
|
||||
"tgt": _num(round(run * self.qty, 4)),
|
||||
"rel": _d(today, self.rel),
|
||||
"want": _d(today, self.want),
|
||||
"st": STATUS_WORDS[self.st],
|
||||
"barcode": f"*%2{self.base}${self.lot}${self.split}${self.sub}${seq}%*",
|
||||
"part": self.part,
|
||||
"site": SITE,
|
||||
"order": None,
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def _mat(
|
||||
wo: _Wo,
|
||||
seq: int,
|
||||
piece: int,
|
||||
part: str | None,
|
||||
qty: float,
|
||||
*,
|
||||
issued: float = 0,
|
||||
whs: str = "SHOP",
|
||||
loc: str = "R1",
|
||||
comm: str = "PIPE",
|
||||
cons: str = "N",
|
||||
desc: str | None = None,
|
||||
dwg: str | None = None,
|
||||
st: str | None = None,
|
||||
closed: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
calc = qty
|
||||
if issued > calc:
|
||||
istat = 3
|
||||
elif closed:
|
||||
istat = 0
|
||||
elif issued == 0:
|
||||
istat = 2
|
||||
else:
|
||||
istat = 1
|
||||
return {
|
||||
"wo": wo.base,
|
||||
"lot": wo.lot,
|
||||
"split": wo.split,
|
||||
"sub": wo.sub,
|
||||
"seq": seq,
|
||||
"piece": piece,
|
||||
"part": part,
|
||||
"qty": _num(qty),
|
||||
"issued": _num(issued),
|
||||
"whs": whs if part else "",
|
||||
"loc": loc if part else "",
|
||||
"st": st or wo.st,
|
||||
"istat": istat,
|
||||
"dwg": wo.dwg if dwg is None else dwg,
|
||||
"desc": desc if desc is not None else (f"Part {part}" if part else "Split item"),
|
||||
"comm": comm if part else "",
|
||||
"cons": cons if part else "",
|
||||
}
|
||||
|
||||
|
||||
def _work_orders() -> dict[str, _Wo]:
|
||||
"""State 1's work orders by a short name."""
|
||||
w: dict[str, _Wo] = {}
|
||||
# A: two operations at different resources on one drawing (two packets, one BOM each), plus fold and ignore rows
|
||||
w["A"] = _Wo(
|
||||
"L900-01",
|
||||
"1",
|
||||
[(10, "LAYOUT", 0.5), (20, "WELD-PART", 1.5), (30, "MFG", 0.25), (40, "FORKLIFT", 0.1), (50, "MAT_PIPE", 0)],
|
||||
dwg="L900-0101",
|
||||
rev="1",
|
||||
st="F",
|
||||
rel=-3,
|
||||
want=20,
|
||||
qty=2,
|
||||
desc="Inlet spool 6 in",
|
||||
ps={10: "Cut to length per sheet 1", 20: "Weld per WPS-11\r\nBack purge", 30: "Weld per WPS-11\r\nBack purge"},
|
||||
)
|
||||
# A2: an Unreleased split of A's base (only on a packet when tracked)
|
||||
w["A2"] = _Wo(
|
||||
"L900-01",
|
||||
"1",
|
||||
[(20, "WELD-PART", 1.5)],
|
||||
dwg="L900-0101",
|
||||
rev="1",
|
||||
st="U",
|
||||
rel=-3,
|
||||
want=25,
|
||||
qty=1,
|
||||
desc="Inlet spool 6 in",
|
||||
split="1",
|
||||
)
|
||||
# B: two operations at the same resource and drawing (two legs with different seq on one packet)
|
||||
w["B"] = _Wo(
|
||||
"L900-02",
|
||||
"1",
|
||||
[(10, "WELD-CODE", 2), (20, "WELD-CODE", 1)],
|
||||
dwg="L900-0201",
|
||||
rev="2",
|
||||
st="F",
|
||||
rel=-2,
|
||||
want=21,
|
||||
qty=2,
|
||||
desc="Header box",
|
||||
)
|
||||
# C: no drawing
|
||||
w["C"] = _Wo(
|
||||
"L900-03", "1", [(10, "WELD-PART", 0.75)], dwg="", rev="", st="F", rel=-1, want=18, qty=4, desc="Pipe supports"
|
||||
)
|
||||
# D and H: one candidate (same unit, resource, drawing) with a non-numeric rev; duplicate piece 1 across the legs
|
||||
w["D"] = _Wo(
|
||||
"L900-04",
|
||||
"2",
|
||||
[(10, "WELD-PART", 1)],
|
||||
dwg="L900-0401",
|
||||
rev="A",
|
||||
st="F",
|
||||
rel=-4,
|
||||
want=22,
|
||||
qty=1,
|
||||
desc="Outlet nozzle",
|
||||
)
|
||||
w["H"] = _Wo(
|
||||
"L900-08-1",
|
||||
"2",
|
||||
[(10, "WELD-PART", 0.5)],
|
||||
dwg="L900-0401",
|
||||
rev="",
|
||||
st="F",
|
||||
rel=-2,
|
||||
want=24,
|
||||
qty=1,
|
||||
desc="Outlet nozzle",
|
||||
)
|
||||
# E: drawing in drafting; gone from VISUAL in state 2
|
||||
w["E"] = _Wo(
|
||||
"L900-05", "2", [(10, "LAYOUT", 1)], dwg="L900-0501", rev="1", st="F", rel=-1, want=19, qty=3, desc="Skid frame"
|
||||
)
|
||||
# F: Released, with materials (R legs count as mat_known); not in the window set
|
||||
w["F"] = _Wo(
|
||||
"L900-06",
|
||||
"2",
|
||||
[(10, "WELD-CODE", 1)],
|
||||
dwg="L900-0601",
|
||||
rev="1",
|
||||
st="R",
|
||||
rel=-2,
|
||||
want=20,
|
||||
qty=1,
|
||||
desc="Drain leg",
|
||||
)
|
||||
# G: drawing not in M-Files; tgt-only change in state 2
|
||||
w["G"] = _Wo(
|
||||
"L900-07",
|
||||
"2",
|
||||
[(10, "WELD-PART", 2)],
|
||||
dwg="L900-0701",
|
||||
rev="1",
|
||||
st="F",
|
||||
rel=-3,
|
||||
want=23,
|
||||
qty=2,
|
||||
desc="Vent stack",
|
||||
)
|
||||
# J closes in state 2, K is cancelled in state 2
|
||||
w["J"] = _Wo(
|
||||
"L900-09",
|
||||
"1",
|
||||
[(10, "WELD-CODE", 1)],
|
||||
dwg="L900-0901",
|
||||
rev="1",
|
||||
st="F",
|
||||
rel=-5,
|
||||
want=15,
|
||||
qty=1,
|
||||
desc="Gauge board",
|
||||
)
|
||||
w["K"] = _Wo(
|
||||
"L900-10",
|
||||
"1",
|
||||
[(10, "WELD-CODE", 1)],
|
||||
dwg="L900-1001",
|
||||
rev="1",
|
||||
st="F",
|
||||
rel=-5,
|
||||
want=15,
|
||||
qty=1,
|
||||
desc="Sample cooler",
|
||||
)
|
||||
# A Closed and a Cancelled leg outside the window set (only on a packet when tracked)
|
||||
w["L"] = _Wo(
|
||||
"L900-11",
|
||||
"1",
|
||||
[(10, "WELD-CODE", 1)],
|
||||
dwg="L900-1101",
|
||||
rev="1",
|
||||
st="C",
|
||||
rel=-6,
|
||||
want=10,
|
||||
qty=1,
|
||||
desc="Closed spool",
|
||||
)
|
||||
w["X"] = _Wo(
|
||||
"L900-12",
|
||||
"1",
|
||||
[(10, "WELD-CODE", 1)],
|
||||
dwg="L900-1201",
|
||||
rev="1",
|
||||
st="X",
|
||||
rel=-6,
|
||||
want=10,
|
||||
qty=1,
|
||||
desc="Cancelled spool",
|
||||
)
|
||||
# Go-live: released before golive_date = today + GOLIVE_OFFSET (still inside the window)
|
||||
w["P"] = _Wo(
|
||||
"L900-13",
|
||||
"2",
|
||||
[(10, "WELD-PART", 1)],
|
||||
dwg="L900-1301",
|
||||
rev="1",
|
||||
st="F",
|
||||
rel=-10,
|
||||
want=12,
|
||||
qty=1,
|
||||
desc="Old release",
|
||||
)
|
||||
# A non-numeric lot: no unit number, no candidate
|
||||
w["Q"] = _Wo(
|
||||
"L900-14",
|
||||
"7A",
|
||||
[(10, "WELD-PART", 1)],
|
||||
dwg="L900-1401",
|
||||
rev="1",
|
||||
st="F",
|
||||
rel=-2,
|
||||
want=20,
|
||||
qty=1,
|
||||
desc="Lettered lot",
|
||||
)
|
||||
# An unmapped resource
|
||||
w["N"] = _Wo(
|
||||
"L900-15",
|
||||
"2",
|
||||
[(10, "NEWRES", 1), (20, "NEWRES", 1)],
|
||||
dwg="L900-1501",
|
||||
rev="1",
|
||||
st="F",
|
||||
rel=-2,
|
||||
want=20,
|
||||
qty=1,
|
||||
desc="New resource work",
|
||||
)
|
||||
# M: moves to drawing L900-1602 in state 2
|
||||
w["M"] = _Wo(
|
||||
"L900-16",
|
||||
"2",
|
||||
[(10, "WELD-CODE", 1)],
|
||||
dwg="L900-1601",
|
||||
rev="1",
|
||||
st="F",
|
||||
rel=-2,
|
||||
want=26,
|
||||
qty=1,
|
||||
desc="Bypass loop",
|
||||
)
|
||||
# Project L901: only a closed order
|
||||
w["Z"] = _Wo(
|
||||
"L901-01",
|
||||
"1",
|
||||
[(10, "WELD-PART", 1)],
|
||||
dwg="L901-0101",
|
||||
rev="1",
|
||||
st="F",
|
||||
rel=-2,
|
||||
want=28,
|
||||
qty=1,
|
||||
desc="Closed-order spool",
|
||||
)
|
||||
return w
|
||||
|
||||
|
||||
def _materials(w: dict[str, _Wo]) -> list[dict[str, Any]]:
|
||||
a, d, h, f, b = w["A"], w["D"], w["H"], w["F"], w["B"]
|
||||
lines = [
|
||||
# A seq 10 (LAYOUT): a split-item header (piece 10, no part) with lines 11 and 12
|
||||
_mat(a, 10, 10, None, 0, desc="Split item: flange set"),
|
||||
_mat(a, 10, 11, "FLG-6-150", 2, issued=1),
|
||||
_mat(a, 10, 12, "GSK-6-150", 2, cons="Y", comm="GASKET"),
|
||||
# A seq 20 (WELD-PART): a MAIN warehouse line, a WIP sub-assembly, a line on another drawing (dropped)
|
||||
_mat(a, 20, 1, "PIPE-6-SCH40", 12.5, issued=12.5, whs="MAIN", loc="A-01"),
|
||||
_mat(a, 20, 2, "L900-01-SUB", 1, whs="ASY", loc="", comm="WIP"),
|
||||
_mat(a, 20, 3, "ELB-6-90", 2, dwg="L900-9999"),
|
||||
_mat(a, 20, 4, "WOL-1-6", 1, dwg=""), # blank drawing on the line: it belongs
|
||||
# B (two operations)
|
||||
_mat(b, 10, 1, "PLT-0.5-A36", 4),
|
||||
_mat(b, 20, 2, "CPL-2-3000", 2, issued=3),
|
||||
# D and H: piece 1 on both legs (the second becomes item 1-1)
|
||||
_mat(d, 10, 1, "NOZ-4-300", 1),
|
||||
_mat(h, 10, 1, "NOZ-4-300", 1),
|
||||
_mat(h, 10, 2, "PAD-4", 1, closed=True, issued=1),
|
||||
# F: a Released leg's line
|
||||
_mat(f, 10, 1, "PIPE-1-SCH80", 3, st="R"),
|
||||
# G
|
||||
_mat(w["G"], 10, 1, "PIPE-8-SCH40", 6),
|
||||
# Unreleased split of A: not F or R, so not a material leg
|
||||
_mat(w["A2"], 20, 1, "PIPE-6-SCH40", 6, st="U"),
|
||||
]
|
||||
return lines
|
||||
|
||||
|
||||
def _orders(today: date) -> tuple[dict[str, list[dict[str, Any]]], dict[str, list[dict[str, Any]]]]:
|
||||
acme = {
|
||||
"cust_order_id": ORDER_ACME,
|
||||
"lines": [1],
|
||||
"top_jobs": ["L900002"],
|
||||
"status": "R",
|
||||
"customer_id": "ACME01",
|
||||
"customer_name": "Acme Refining",
|
||||
"customer_po_ref": "PO-1001",
|
||||
"order_date": _d(today, -40),
|
||||
"desired_ship_date": _d(today, 60),
|
||||
"promise_date": _d(today, 55),
|
||||
}
|
||||
bayou = {
|
||||
"cust_order_id": ORDER_BAYOU,
|
||||
"lines": [1, 2],
|
||||
"top_jobs": ["L900001"],
|
||||
"status": "F",
|
||||
"customer_id": "BAYOU01",
|
||||
"customer_name": "Bayou Chemical",
|
||||
"customer_po_ref": "PO-2002",
|
||||
"order_date": _d(today, -45),
|
||||
"desired_ship_date": _d(today, 70),
|
||||
"promise_date": None,
|
||||
}
|
||||
closed = {
|
||||
"cust_order_id": ORDER_L901,
|
||||
"lines": [1],
|
||||
"top_jobs": ["L901001"],
|
||||
"status": "C",
|
||||
"customer_id": "DELTA01",
|
||||
"customer_name": "Delta Gas",
|
||||
"customer_po_ref": "PO-3003",
|
||||
"order_date": _d(today, -200),
|
||||
"desired_ship_date": _d(today, -100),
|
||||
"promise_date": _d(today, -105),
|
||||
}
|
||||
base_entry = {k: v for k, v in acme.items() if k not in ("top_jobs", "order_date")}
|
||||
by_base = {"L900-04": [base_entry]}
|
||||
by_project = {PROJECT: [acme, bayou], PROJECT_CLOSED_ORDER: [closed]}
|
||||
return by_base, by_project
|
||||
|
||||
|
||||
def _labor(today: date) -> list[dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"wo": "L900-01",
|
||||
"lot": "1",
|
||||
"split": "0",
|
||||
"sub": "0",
|
||||
"seq": 20,
|
||||
"hours": 1.25,
|
||||
"tickets": 2,
|
||||
"last_ticket": _d(today, -1),
|
||||
},
|
||||
{
|
||||
"wo": "L900-02",
|
||||
"lot": "1",
|
||||
"split": "0",
|
||||
"sub": "0",
|
||||
"seq": 10,
|
||||
"hours": 0.5,
|
||||
"tickets": 1,
|
||||
"last_ticket": _d(today, -1),
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def _meta(today: date, state: int) -> dict[str, Any]:
|
||||
return {
|
||||
"source": "fixture",
|
||||
"version": "synthetic",
|
||||
"server": "fixture",
|
||||
"database": f"state {state}",
|
||||
"site": SITE,
|
||||
"since": _d(today, -14),
|
||||
"until": _d(today, 30),
|
||||
"statuses": ["F", "R", "U", "C"],
|
||||
"generated_at": f"{today.isoformat()}T06:00:00",
|
||||
}
|
||||
|
||||
|
||||
def feed_state(n: int, today: date | None = None) -> dict[str, Any]:
|
||||
"""The feed file for state 1 or 2 as a dict (the contract shape plus `labor`)."""
|
||||
if n not in (1, 2):
|
||||
raise ValueError("state is 1 or 2")
|
||||
today = today or date.today()
|
||||
w = _work_orders()
|
||||
if n == 2:
|
||||
w["A"].rev = "2" # rev 1 -> 2 on the WELD-PART drawing (and A's LAYOUT draft)
|
||||
w["A2"].rev = "2"
|
||||
w["B"].qty = 3 # qty change (also scales tgt)
|
||||
w["G"].ops = [(10, "WELD-PART", 2.5)] # tgt-only change: minor
|
||||
del w["E"] # gone from VISUAL
|
||||
w["M"].dwg = "L900-1602" # moved to another drawing
|
||||
w["J"].st = "C"
|
||||
w["K"].st = "X"
|
||||
rows = [r for wo in w.values() for r in wo.rows(today)]
|
||||
mats = _materials(w) if n == 1 else _materials_state2(w)
|
||||
by_base, by_project = _orders(today)
|
||||
return {
|
||||
"meta": _meta(today, n),
|
||||
"rows": rows,
|
||||
"materials": mats,
|
||||
"orders_by_base": by_base,
|
||||
"orders_by_project": by_project,
|
||||
"labor": _labor(today),
|
||||
}
|
||||
|
||||
|
||||
def _materials_state2(w: dict[str, _Wo]) -> list[dict[str, Any]]:
|
||||
# Same lines as state 1 on the state 2 work orders (A's drawing moved with it, E is gone)
|
||||
full = _work_orders()
|
||||
full.update(w)
|
||||
lines = _materials(full)
|
||||
return [m for m in lines if m["wo"] != "L900-05"]
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------------ drawings
|
||||
|
||||
|
||||
def _dwg(
|
||||
rev: int | None,
|
||||
file: str | None,
|
||||
*,
|
||||
n: int,
|
||||
version: int = 1,
|
||||
draft_rev: int | None = None,
|
||||
draft_where: str = "",
|
||||
in_drafting: bool = False,
|
||||
base: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"rev": rev,
|
||||
"file": file,
|
||||
"name": file[:-4] if file else None,
|
||||
"draft_rev": draft_rev,
|
||||
"draft_where": draft_where,
|
||||
"in_drafting": in_drafting,
|
||||
"ok": True,
|
||||
"note": "",
|
||||
"base": base,
|
||||
"obj_id": n,
|
||||
"obj_version": version,
|
||||
"file_id": 100 + n,
|
||||
"file_version": version,
|
||||
}
|
||||
|
||||
|
||||
def drawings_state(n: int) -> dict[str, Any]:
|
||||
"""drawings.<n>.json as a dict. A drawing missing from it is not in M-Files."""
|
||||
if n not in (1, 2):
|
||||
raise ValueError("state is 1 or 2")
|
||||
d: dict[str, Any] = {
|
||||
"L900-0101": _dwg(1, "L900-0101 r1.pdf", n=1),
|
||||
"L900-0201": _dwg(3, "L900-0201 r3.pdf", n=2), # VISUAL calls rev 2: rev mismatch
|
||||
"L900-0401": _dwg(1, "L900-0401 r1.pdf", n=4), # VISUAL calls rev A: bad rev
|
||||
"L900-0501": _dwg(
|
||||
None, None, n=5, draft_rev=1, draft_where="in Drafting review", in_drafting=True, base="L900-05"
|
||||
), # not released
|
||||
# L900-0701 is missing: not found. L900-1601 is missing too (so its draft has no pages and can retire).
|
||||
"L900-0601": _dwg(1, "L900-0601 r1.pdf", n=6),
|
||||
"L900-0901": _dwg(1, "L900-0901 r1.pdf", n=9),
|
||||
"L900-1001": _dwg(1, "L900-1001 r1.pdf", n=10),
|
||||
"L900-1301": _dwg(1, "L900-1301 r1.pdf", n=13),
|
||||
"L900-1401": _dwg(1, "L900-1401 r1.pdf", n=14),
|
||||
"L901-0101": _dwg(1, "L901-0101 r1.pdf", n=21),
|
||||
}
|
||||
if n == 2:
|
||||
d["L900-0101"] = _dwg(2, "L900-0101 r2.pdf", n=1, version=2) # the CO gets staged
|
||||
d["L900-1602"] = _dwg(1, "L900-1602 r1.pdf", n=16)
|
||||
return d
|
||||
|
||||
|
||||
def pdf_bytes(label: str) -> bytes:
|
||||
"""A one-page PDF. The label goes into the document info so each file has its own sha256."""
|
||||
from pypdf import PdfWriter
|
||||
|
||||
w = PdfWriter()
|
||||
w.add_blank_page(width=792, height=612)
|
||||
w.add_metadata({"/Title": label, "/Producer": "Packet Station fixtures"})
|
||||
buf = io.BytesIO()
|
||||
w.write(buf)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def write_state(out_dir: Path, n: int) -> None:
|
||||
"""Point the fixture sources at state n (what `packetstation fixtures use N` does)."""
|
||||
(Path(out_dir) / "state").write_text(str(n), encoding="utf-8")
|
||||
|
||||
|
||||
def generate(out_dir: Path | str, today: date | None = None) -> Path:
|
||||
"""Write every fixture file into out_dir and set the state to 1. Returns out_dir."""
|
||||
out = Path(out_dir)
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
files: set[str] = set()
|
||||
for n in (1, 2):
|
||||
feed = feed_state(n, today)
|
||||
(out / f"feed.{n}.json").write_text(json.dumps(feed, indent=1, ensure_ascii=False), encoding="utf-8")
|
||||
dwgs = drawings_state(n)
|
||||
(out / f"drawings.{n}.json").write_text(json.dumps(dwgs, indent=1, ensure_ascii=False), encoding="utf-8")
|
||||
files.update(v["file"] for v in dwgs.values() if v.get("file"))
|
||||
for name in sorted(files):
|
||||
(out / name).write_bytes(pdf_bytes(name))
|
||||
write_state(out, 1)
|
||||
return out
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
ap = argparse.ArgumentParser(description="Write Packet Station's synthetic VISUAL and M-Files fixtures")
|
||||
ap.add_argument("--out", type=Path, required=True, help="directory to write into")
|
||||
ap.add_argument("--today", type=date.fromisoformat, default=None, help="date the offsets count from")
|
||||
args = ap.parse_args(argv)
|
||||
out = generate(args.out, args.today)
|
||||
print(f"Wrote fixtures to {out} (state 1)", file=sys.stderr)
|
||||
return 0
|
||||
|
||||
|
||||
__all__ = [
|
||||
"RESOURCE_MAP",
|
||||
"drawings_state",
|
||||
"feed_state",
|
||||
"generate",
|
||||
"pdf_bytes",
|
||||
"write_state",
|
||||
]
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,241 @@
|
||||
"""Helpers for the feed and drawing tests: the generator, fixture dirs, feed settings and one job run.
|
||||
|
||||
`run_feed(settings, state)` and `run_drawings(settings)` run the real job functions (visual-feed, drawing-lookup)
|
||||
once, the way the worker would, over a connection as the worker role, against the fixture files in
|
||||
`settings.fixture_dir`. Never VISUAL or M-Files.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import sys
|
||||
from datetime import date, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from pshelpers import make_settings
|
||||
from psycopg.types.json import Jsonb
|
||||
|
||||
from packetstation.settings import Settings
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
gen = importlib.import_module("tests.fixtures.generator")
|
||||
|
||||
FEED_JOB = "visual-feed"
|
||||
DRAWING_JOB = "drawing-lookup"
|
||||
FEED_COUNT_KEYS = (
|
||||
"wos_window",
|
||||
"wos_tracked",
|
||||
"rows",
|
||||
"materials",
|
||||
"orders",
|
||||
"labor_legs",
|
||||
"new_drafts",
|
||||
"drafts_updated",
|
||||
"cos_opened",
|
||||
"cos_reaimed",
|
||||
"cos_withdrawn",
|
||||
"retired",
|
||||
"wo_gone_flagged",
|
||||
"closed_observed",
|
||||
"ignored_golive",
|
||||
"projects_created",
|
||||
"units_created",
|
||||
"auto_released",
|
||||
"unmapped_resources",
|
||||
"skipped_removed",
|
||||
"skipped_closed",
|
||||
"suspect",
|
||||
)
|
||||
# The counts that are zero when VISUAL did not change between two runs.
|
||||
CHANGE_KEYS = (
|
||||
"new_drafts",
|
||||
"drafts_updated",
|
||||
"cos_opened",
|
||||
"cos_reaimed",
|
||||
"cos_withdrawn",
|
||||
"retired",
|
||||
"wo_gone_flagged",
|
||||
"closed_observed",
|
||||
"projects_created",
|
||||
"units_created",
|
||||
"auto_released",
|
||||
)
|
||||
|
||||
|
||||
def fixture_dir(tmp: Path, today: date | None = None) -> Path:
|
||||
"""Generate the fixtures into tmp/fixtures (state 1)."""
|
||||
out: Path = gen.generate(tmp / "fixtures", today)
|
||||
return out
|
||||
|
||||
|
||||
def switch_state(directory: Path, n: int | str) -> None:
|
||||
(Path(directory) / "state").write_text(str(n), encoding="utf-8")
|
||||
|
||||
|
||||
def feed_settings_over(base: Settings, tmp: Path) -> Settings:
|
||||
"""`base` (a pg_settings) with the fixture sources pointed at freshly generated files under tmp."""
|
||||
d = fixture_dir(tmp)
|
||||
return base.model_copy(update={"feed_source": "fixture", "drawing_source": "fixture", "fixture_dir": d})
|
||||
|
||||
|
||||
def fixture_settings(tmp: Path, **over: Any) -> Settings:
|
||||
"""Plain test settings (no database) with fixture sources."""
|
||||
return make_settings(tmp, feed_source="fixture", drawing_source="fixture", fixture_dir=tmp / "fixtures", **over)
|
||||
|
||||
|
||||
def set_feed(settings: Settings, **values: Any) -> None:
|
||||
"""Merge values into the `feed` settings row by SQL (no audit: test setup)."""
|
||||
from pshelpers import app_conn
|
||||
|
||||
from packetstation.feed import config
|
||||
|
||||
with app_conn(settings) as conn:
|
||||
cur = config.load(conn).model_dump(mode="json")
|
||||
cur.update({k: (v.isoformat() if isinstance(v, date) else v) for k, v in values.items()})
|
||||
conn.execute(
|
||||
"INSERT INTO settings (key, value, version, updated_at) VALUES ('feed', %s, 1, now())"
|
||||
" ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, version = settings.version + 1",
|
||||
(Jsonb(cur),),
|
||||
)
|
||||
|
||||
|
||||
def golive_today_offset() -> date:
|
||||
return date.today() + timedelta(days=gen.GOLIVE_OFFSET)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- one job run
|
||||
|
||||
|
||||
def scheduler(settings: Settings) -> Any:
|
||||
"""An unstarted worker Scheduler: what runs.run_job needs, without APScheduler or the listener."""
|
||||
from packetstation.worker.scheduler import Scheduler
|
||||
|
||||
return Scheduler(settings, build="pytest")
|
||||
|
||||
|
||||
def run_job(settings: Settings, job: str, *, requested: bool = False, allow_fail: bool = False) -> dict[str, Any]:
|
||||
"""Run one registered job once through runs.run_job, as the worker does. Returns the job_runs row.
|
||||
|
||||
`requested` inserts a job_requests row first, so the run is a "run now". A failed run raises AssertionError
|
||||
with its errors unless allow_fail.
|
||||
"""
|
||||
from packetstation import db
|
||||
from packetstation.worker import runs
|
||||
|
||||
if requested:
|
||||
with db.connect_as("worker", settings, autocommit=True) as conn:
|
||||
conn.execute("INSERT INTO job_requests (job, note) VALUES (%s, 'pytest')", (job,))
|
||||
row = runs.run_job(scheduler(settings), job, "schedule")
|
||||
if not allow_fail:
|
||||
assert row["status"] != "failed", row.get("errors")
|
||||
return dict(row)
|
||||
|
||||
|
||||
def _counts(row: dict[str, Any]) -> dict[str, Any]:
|
||||
counts = dict(row.get("counts") or {})
|
||||
counts.update(
|
||||
{"_status": row["status"], "_errors": row.get("errors") or [], "_run_id": row["id"], "_trigger": row["trigger"]}
|
||||
)
|
||||
return counts
|
||||
|
||||
|
||||
def run_feed(
|
||||
settings: Settings, state: int | None = None, *, requested: bool = False, allow_fail: bool = False
|
||||
) -> dict[str, Any]:
|
||||
"""Point the fixtures at `state` (when given) and run the visual-feed job once. Returns the run's counts with
|
||||
`_status`, `_errors`, `_run_id` and `_trigger` added."""
|
||||
if state is not None:
|
||||
switch_state(settings.fixture_dir, state)
|
||||
return _counts(run_job(settings, FEED_JOB, requested=requested, allow_fail=allow_fail))
|
||||
|
||||
|
||||
def run_drawings(settings: Settings, state: int | None = None, *, requested: bool = False) -> dict[str, Any]:
|
||||
if state is not None:
|
||||
switch_state(settings.fixture_dir, state)
|
||||
return _counts(run_job(settings, DRAWING_JOB, requested=requested))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- reading back
|
||||
|
||||
|
||||
def packet_by_key(conn: Any, key: str, *, live: bool = True) -> dict[str, Any] | None:
|
||||
from psycopg.rows import dict_row
|
||||
|
||||
with conn.cursor(row_factory=dict_row) as cur:
|
||||
live_sql = "SELECT * FROM packets WHERE visual_key = %s AND removed_at IS NULL ORDER BY id DESC LIMIT 1"
|
||||
any_sql = "SELECT * FROM packets WHERE visual_key = %s ORDER BY id DESC LIMIT 1"
|
||||
row = cur.execute(live_sql if live else any_sql, (key,)).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
def packet_id(conn: Any, key: str) -> int:
|
||||
p = packet_by_key(conn, key)
|
||||
assert p is not None, key
|
||||
return int(p["id"])
|
||||
|
||||
|
||||
def legs_of(conn: Any, pid: int) -> list[tuple[Any, ...]]:
|
||||
return [
|
||||
tuple(r)
|
||||
for r in conn.execute(
|
||||
"SELECT wo_base, lot, split, sub, seq, qty, target_hrs, visual_status, missing_runs, gone_at IS NOT NULL"
|
||||
" FROM packet_work_orders WHERE packet_id = %s ORDER BY wo_base, lot, split, sub, seq",
|
||||
(pid,),
|
||||
).fetchall()
|
||||
]
|
||||
|
||||
|
||||
def cos_of(conn: Any, pid: int) -> list[dict[str, Any]]:
|
||||
from psycopg.rows import dict_row
|
||||
|
||||
with conn.cursor(row_factory=dict_row) as cur:
|
||||
return [
|
||||
dict(r)
|
||||
for r in cur.execute("SELECT * FROM change_orders WHERE packet_id = %s ORDER BY co_no", (pid,)).fetchall()
|
||||
]
|
||||
|
||||
|
||||
def move_status(conn: Any, pid: int, status: str) -> None:
|
||||
"""Move a packet past draft by SQL (test setup: the transitions are tested elsewhere)."""
|
||||
conn.execute(
|
||||
"UPDATE packets SET status = %s::packet_status, status_at = now(), heat_map = coalesce(heat_map, true),"
|
||||
" version = version + 1 WHERE id = %s",
|
||||
(status, pid),
|
||||
)
|
||||
|
||||
|
||||
def system_audit(conn: Any, action: str) -> list[tuple[Any, ...]]:
|
||||
return [
|
||||
tuple(r)
|
||||
for r in conn.execute(
|
||||
"SELECT role_snapshot, user_id, subject_type, subject_id, packet_id FROM audit_events WHERE action = %s"
|
||||
" ORDER BY id",
|
||||
(action,),
|
||||
).fetchall()
|
||||
]
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CHANGE_KEYS",
|
||||
"FEED_COUNT_KEYS",
|
||||
"cos_of",
|
||||
"feed_settings_over",
|
||||
"fixture_dir",
|
||||
"fixture_settings",
|
||||
"gen",
|
||||
"golive_today_offset",
|
||||
"legs_of",
|
||||
"move_status",
|
||||
"packet_by_key",
|
||||
"packet_id",
|
||||
"run_drawings",
|
||||
"run_feed",
|
||||
"run_job",
|
||||
"set_feed",
|
||||
"switch_state",
|
||||
"system_audit",
|
||||
]
|
||||
@@ -0,0 +1,343 @@
|
||||
"""Candidates from the generator's state 1: packet keys, routing, go-live, tracked keys, revs and the BOM rules."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, timedelta
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from feedhelpers import gen
|
||||
|
||||
from packetstation.feed import candidates, orders
|
||||
from packetstation.feed.materials import index
|
||||
from packetstation.feed.types import Material, WoKey, parse_material, parse_orders, parse_row
|
||||
|
||||
TODAY = date.today()
|
||||
WINDOW = (TODAY - timedelta(days=14), TODAY + timedelta(days=30))
|
||||
|
||||
|
||||
def k(base: str, lot: str, split: str = "0", sub: str = "0") -> WoKey:
|
||||
return WoKey(base, lot, split, sub)
|
||||
|
||||
|
||||
def state(n: int = 1) -> dict[str, Any]:
|
||||
return gen.feed_state(n, TODAY)
|
||||
|
||||
|
||||
def material_legs(feed: dict[str, Any]) -> set[WoKey]:
|
||||
return {k(m["wo"], m["lot"], m["split"], m["sub"]) for m in feed["materials"] if m["st"] in ("F", "R")}
|
||||
|
||||
|
||||
def build(
|
||||
n: int = 1,
|
||||
*,
|
||||
tracked: set[WoKey] | None = None,
|
||||
golive: date | None = None,
|
||||
departments: tuple[str, ...] = (),
|
||||
mats: bool = True,
|
||||
) -> candidates.Built:
|
||||
feed = state(n)
|
||||
rows = [parse_row(r) for r in feed["rows"]]
|
||||
materials = [parse_material(m) for m in feed["materials"]] if mats else None
|
||||
return candidates.build(
|
||||
rows,
|
||||
materials,
|
||||
material_legs(feed) if mats else None,
|
||||
resource_map=gen.RESOURCE_MAP,
|
||||
window=WINDOW,
|
||||
golive=golive,
|
||||
departments=departments,
|
||||
tracked_keys=tracked or set(),
|
||||
today=TODAY,
|
||||
)
|
||||
|
||||
|
||||
def test_feed_file_shape() -> None:
|
||||
feed = state(1)
|
||||
assert set(feed) >= {"meta", "rows", "materials", "orders_by_base", "orders_by_project"}
|
||||
row = feed["rows"][0]
|
||||
assert set(row) >= {
|
||||
"wo",
|
||||
"lot",
|
||||
"split",
|
||||
"sub",
|
||||
"seq",
|
||||
"res",
|
||||
"dwg",
|
||||
"rev",
|
||||
"ps",
|
||||
"desc",
|
||||
"qty",
|
||||
"tgt",
|
||||
"rel",
|
||||
"want",
|
||||
"st",
|
||||
"barcode",
|
||||
"part",
|
||||
}
|
||||
words = {r["st"] for r in feed["rows"]}
|
||||
assert words <= {"Firmed", "Released", "Unreleased", "Closed", "Cancelled"}
|
||||
assert {m["st"] for m in feed["materials"]} <= {"F", "R", "U", "C", "X"}
|
||||
for r in feed["rows"]:
|
||||
assert isinstance(r["seq"], int) and isinstance(r["lot"], str) and isinstance(r["split"], str)
|
||||
assert isinstance(r["qty"], int | float) and not isinstance(r["qty"], bool)
|
||||
assert r["rel"] is None or date.fromisoformat(r["rel"])
|
||||
for m in feed["materials"]:
|
||||
assert isinstance(m["piece"], int) and (m["part"] is None or isinstance(m["part"], str))
|
||||
assert any(r["lot"] == "7A" for r in feed["rows"])
|
||||
assert any(m["part"] is None for m in feed["materials"])
|
||||
|
||||
|
||||
def test_generator_is_deterministic(tmp_path: Any) -> None:
|
||||
a, b = tmp_path / "a", tmp_path / "b"
|
||||
gen.generate(a, TODAY)
|
||||
gen.generate(b, TODAY)
|
||||
for name in ("feed.1.json", "feed.2.json", "drawings.1.json", "drawings.2.json", "state"):
|
||||
assert (a / name).read_bytes() == (b / name).read_bytes(), name
|
||||
assert (a / "state").read_text() == "1"
|
||||
assert (a / "L900-0101 r1.pdf").read_bytes().startswith(b"%PDF-")
|
||||
assert (a / "L900-0101 r1.pdf").read_bytes() != (a / "L900-0101 r2.pdf").read_bytes()
|
||||
|
||||
|
||||
def test_packet_keys() -> None:
|
||||
built = build()
|
||||
assert set(built.candidates) == set(gen.STATE1_KEYS)
|
||||
c = built.candidates[gen.KEY_WELD_PART]
|
||||
assert (c.project, c.unit, c.lot, c.dept, c.op, c.drawing) == (
|
||||
"L900",
|
||||
"L900001",
|
||||
"001",
|
||||
"03",
|
||||
"WELD-PART",
|
||||
"L900-0101",
|
||||
)
|
||||
assert c.base == "L900-01" and c.in_window and c.title == "Inlet spool 6 in"
|
||||
assert built.candidates[gen.KEY_LAYOUT_A].dept == "01"
|
||||
nd = built.candidates[gen.KEY_NO_DWG]
|
||||
assert nd.drawing == "" and nd.key.endswith("|(no drawing)")
|
||||
|
||||
|
||||
def test_two_operations_at_one_resource_are_two_legs() -> None:
|
||||
c = build().candidates[gen.KEY_TWO_OPS]
|
||||
assert [(lg.key, lg.seq) for lg in c.legs] == [(k("L900-02", "1"), 10), (k("L900-02", "1"), 20)]
|
||||
assert [lg.tgt for lg in c.legs] == [Decimal(4), Decimal(2)]
|
||||
|
||||
|
||||
def test_fold_and_ignore() -> None:
|
||||
c = build().candidates[gen.KEY_WELD_PART]
|
||||
folded = {(f.seq, f.op) for f in c.folded}
|
||||
assert folded == {(30, "MFG"), (50, "MAT_PIPE")}
|
||||
assert all(lg.op not in ("MFG", "FORKLIFT", "MAT_PIPE") for cand in build().candidates.values() for lg in cand.legs)
|
||||
# the LAYOUT packet of the same work order and lot folds the same operations
|
||||
assert {f.seq for f in build().candidates[gen.KEY_LAYOUT_A].folded} == {30, 50}
|
||||
assert candidates.route("MAT_STEEL", {}) == "fold" and candidates.route("FORKLIFT", gen.RESOURCE_MAP) == "ignore"
|
||||
assert candidates.route("NEWRES", gen.RESOURCE_MAP) is None
|
||||
|
||||
|
||||
def test_partspecs_distinct_in_first_seen_order() -> None:
|
||||
c = build().candidates[gen.KEY_WELD_PART]
|
||||
assert c.partspecs == ["Cut to length per sheet 1", "Weld per WPS-11\nBack purge"]
|
||||
|
||||
|
||||
def test_unmapped_counts() -> None:
|
||||
assert build().unmapped == {"NEWRES": 2}
|
||||
|
||||
|
||||
def test_lettered_lot_makes_no_candidate() -> None:
|
||||
built = build()
|
||||
assert built.skipped_lots == {"L900-14/7A": 1}
|
||||
assert candidates.unit_number("L900", "7A") is None
|
||||
assert candidates.unit_number("L900", "1") == "L900001"
|
||||
assert candidates.unit_number("L900", "012") == "L900012"
|
||||
|
||||
|
||||
def test_golive_ignores_old_releases() -> None:
|
||||
golive = TODAY + timedelta(days=gen.GOLIVE_OFFSET)
|
||||
built = build(golive=golive)
|
||||
assert gen.KEY_GOLIVE not in built.candidates
|
||||
assert [(r.wo, r.lot, r.seq) for r in built.ignored] == [("L900-13", "2", 10)]
|
||||
# tracked keys come through whatever their release date
|
||||
built = build(golive=golive, tracked={k("L900-13", "2")})
|
||||
assert gen.KEY_GOLIVE in built.candidates and built.ignored == []
|
||||
|
||||
|
||||
def test_departments_filter() -> None:
|
||||
built = build(departments=("06",))
|
||||
assert built.candidates and {c.dept for c in built.candidates.values()} == {"06"}
|
||||
|
||||
|
||||
def test_tracked_keys_outside_the_window_and_status() -> None:
|
||||
assert gen.KEY_RELEASED not in build().candidates # Released: not in the window set
|
||||
tracked = {k("L900-06", "2"), k("L900-01", "1", "1"), k("L900-11", "1"), k("L900-12", "1")}
|
||||
built = build(tracked=tracked)
|
||||
rel = built.candidates[gen.KEY_RELEASED]
|
||||
assert not rel.in_window and rel.statuses == {k("L900-06", "2"): "R"}
|
||||
wp = built.candidates[gen.KEY_WELD_PART]
|
||||
assert wp.statuses == {k("L900-01", "1"): "F", k("L900-01", "1", "1"): "U"}
|
||||
assert {(lg.key.split, lg.seq) for lg in wp.legs} == {("0", 20), ("1", 20)}
|
||||
assert built.candidates["L900001|WELD-CODE|L900-1101"].statuses == {k("L900-11", "1"): "C"}
|
||||
assert built.candidates["L900001|WELD-CODE|L900-1201"].statuses == {k("L900-12", "1"): "X"}
|
||||
|
||||
|
||||
def test_rel_min_want_max() -> None:
|
||||
c = build().candidates[gen.KEY_REV_A] # legs L900-04 (rel -4, want 22) and L900-08-1 (rel -2, want 24)
|
||||
assert c.rel == TODAY - timedelta(days=4) and c.want == TODAY + timedelta(days=24)
|
||||
|
||||
|
||||
def test_rev_highest_numeric() -> None:
|
||||
c = build().candidates[gen.KEY_REV_A]
|
||||
assert c.rev == "A" # the non-numeric rev holds against an empty rev
|
||||
built = build(2, tracked={k("L900-01", "1", "1")})
|
||||
assert built.candidates[gen.KEY_WELD_PART].rev == "2"
|
||||
|
||||
|
||||
def _row(seq: int, rev: str) -> Any:
|
||||
return parse_row(
|
||||
{
|
||||
"wo": "L900-20",
|
||||
"lot": "1",
|
||||
"split": "0",
|
||||
"sub": "0",
|
||||
"seq": seq,
|
||||
"res": "WELD-PART",
|
||||
"dwg": "D-1",
|
||||
"rev": rev,
|
||||
"qty": 1,
|
||||
"tgt": 1,
|
||||
"rel": TODAY.isoformat(),
|
||||
"st": "Firmed",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"revs,expect",
|
||||
[(["1", "3", "2"], "3"), (["", "2"], "2"), (["2", "A"], "2"), (["A", "2"], "A"), (["10", "9"], "10")],
|
||||
)
|
||||
def test_rev_rule(revs: list[str], expect: str) -> None:
|
||||
rows = [_row(10 * (i + 1), r) for i, r in enumerate(revs)]
|
||||
built = candidates.build(
|
||||
rows,
|
||||
None,
|
||||
None,
|
||||
resource_map=gen.RESOURCE_MAP,
|
||||
window=WINDOW,
|
||||
golive=None,
|
||||
departments=(),
|
||||
tracked_keys=set(),
|
||||
today=TODAY,
|
||||
)
|
||||
assert built.candidates["L900001|WELD-PART|D-1"].rev == expect
|
||||
|
||||
|
||||
def _bom(key: str, n: int = 1, tracked: set[WoKey] | None = None) -> dict[str, Any]:
|
||||
c = build(n, tracked=tracked).candidates[key]
|
||||
assert c.bom is not None
|
||||
return {b.item: b for b in c.bom}
|
||||
|
||||
|
||||
def test_bom_split_items() -> None:
|
||||
bom = _bom(gen.KEY_LAYOUT_A)
|
||||
assert set(bom) == {"11", "12"} # the piece 10 header has no part: not a line
|
||||
assert bom["11"].grp == "10" and bom["12"].grp == "10"
|
||||
assert bom["12"].cons is True and bom["11"].cons is False
|
||||
assert bom["11"].vkey == "L900-01|1|0|10|11"
|
||||
|
||||
|
||||
def test_bom_main_loc_wip_sub_and_dwg_filter() -> None:
|
||||
bom = _bom(gen.KEY_WELD_PART)
|
||||
assert set(bom) == {"1", "2", "4"} # piece 3 is on another drawing: dropped; piece 4 has a blank drawing
|
||||
assert bom["1"].loc == "A-01" and bom["1"].whs == "MAIN" # MAIN is left out of loc
|
||||
assert bom["4"].loc == "SHOP R1"
|
||||
assert bom["2"].sub is True and bom["2"].na is True
|
||||
assert bom["1"].sub is False and bom["1"].issued == Decimal("12.5") and bom["1"].qty == Decimal("12.5")
|
||||
assert all(b.grp is None for b in bom.values())
|
||||
|
||||
|
||||
def test_bom_duplicate_items() -> None:
|
||||
bom = _bom(gen.KEY_REV_A)
|
||||
assert set(bom) == {"1", "1-1", "2"}
|
||||
assert bom["1"].vkey.startswith("L900-04|") and bom["1-1"].vkey.startswith("L900-08-1|")
|
||||
assert bom["2"].istat == 0
|
||||
|
||||
|
||||
def test_bom_two_operations() -> None:
|
||||
bom = _bom(gen.KEY_TWO_OPS)
|
||||
assert set(bom) == {"1", "2"} and bom["2"].istat == 3
|
||||
|
||||
|
||||
def test_released_leg_counts_as_known() -> None:
|
||||
bom = _bom(gen.KEY_RELEASED, tracked={k("L900-06", "2")})
|
||||
assert set(bom) == {"1"}
|
||||
|
||||
|
||||
def test_unreleased_leg_alone_is_unknown() -> None:
|
||||
# A2 (Unreleased) is not a material leg; on the WELD-PART packet the firmed leg still makes it known
|
||||
built = build(tracked={k("L900-01", "1", "1")})
|
||||
assert built.candidates[gen.KEY_WELD_PART].bom is not None
|
||||
|
||||
|
||||
def test_mat_known_none() -> None:
|
||||
built = build()
|
||||
assert built.candidates[gen.KEY_NO_DWG].bom is None # C has no material lines: not a material leg
|
||||
assert all(c.bom is None for c in build(mats=False).candidates.values())
|
||||
|
||||
|
||||
def test_best_status_per_piece() -> None:
|
||||
key = k("L900-30", "1")
|
||||
|
||||
def m(st: str, qty: int) -> Material:
|
||||
return parse_material(
|
||||
{
|
||||
"wo": "L900-30",
|
||||
"lot": "1",
|
||||
"split": "0",
|
||||
"sub": "0",
|
||||
"seq": 10,
|
||||
"piece": 1,
|
||||
"part": "P-1",
|
||||
"qty": qty,
|
||||
"issued": 0,
|
||||
"st": st,
|
||||
"dwg": "D-9",
|
||||
}
|
||||
)
|
||||
|
||||
leg = candidates.Leg(
|
||||
key=key, seq=10, op="WELD-PART", qty=Decimal(1), tgt=Decimal(1), dwg="D-9", rev="1", partspecs="", st="F"
|
||||
)
|
||||
from packetstation.feed.materials import bom_for
|
||||
|
||||
for lines, qty in (
|
||||
([m("U", 3), m("F", 2), m("R", 1)], 1),
|
||||
([m("U", 3), m("F", 2)], 2),
|
||||
([m("C", 4), m("U", 3)], 3),
|
||||
):
|
||||
bom = bom_for([leg], index(lines))
|
||||
assert [(b.item, b.qty) for b in bom] == [("1", Decimal(qty))]
|
||||
|
||||
|
||||
def test_orders_unit_rule_on_fixture_orders() -> None:
|
||||
feed = state(1)
|
||||
by_base, by_project = parse_orders(feed["orders_by_base"]), parse_orders(feed["orders_by_project"])
|
||||
assert by_base is not None and by_project is not None
|
||||
|
||||
def pick(unit: str, base: str, project: str = "L900", pin: str | None = None) -> tuple[str, str] | None:
|
||||
ch = orders.choose(
|
||||
unit_number=unit, base=base, by_base=by_base, by_project=by_project, project=project, pin=pin
|
||||
)
|
||||
return None if ch is None else (ch.order.cust_order_id, ch.via)
|
||||
|
||||
assert pick("L900001", "L900-01") == (gen.ORDER_BAYOU, "unit_top_job")
|
||||
assert pick("L900002", "L900-04") == (gen.ORDER_ACME, "base")
|
||||
assert pick("L900002", "L900-07") == (gen.ORDER_ACME, "unit_top_job")
|
||||
assert pick("L900003", "L900-99") == (gen.ORDER_ACME, "project_open")
|
||||
assert pick("L901001", "L901-01", "L901") == (gen.ORDER_L901, "unit_top_job")
|
||||
assert pick("L901002", "L901-02", "L901") == (gen.ORDER_L901, "project_newest")
|
||||
assert pick("L900001", "L900-01", pin=gen.ORDER_ACME) == (gen.ORDER_ACME, "pin")
|
||||
assert pick("L900001", "L900-01", pin="C99-0000") == (gen.ORDER_BAYOU, "unit_top_job") # not the project's
|
||||
assert pick("L902001", "L902-01", "L902") is None
|
||||
d = orders.derive(by_project["L900"][1])
|
||||
assert d["customer"] == "Bayou Chemical" and d["po"] == "PO-2002"
|
||||
assert d["ship_date"] == TODAY + timedelta(days=70) # no promise date: the desired ship date
|
||||
@@ -0,0 +1,351 @@
|
||||
"""The drawing decision table (pure), then the drawing job against the fixture lookup (PostgreSQL)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from feedhelpers import (
|
||||
cos_of,
|
||||
feed_settings_over,
|
||||
gen,
|
||||
move_status,
|
||||
packet_by_key,
|
||||
packet_id,
|
||||
run_drawings,
|
||||
run_feed,
|
||||
system_audit,
|
||||
)
|
||||
from pshelpers import app_conn, needs_pg
|
||||
|
||||
from packetstation.drawings.decide import Decision, Target, decide
|
||||
from packetstation.drawings.fake import FixtureLookup
|
||||
from packetstation.drawings.lookup import Found
|
||||
from packetstation.settings import Settings
|
||||
|
||||
DRAWING_FLAGS = {"drawing_needed", "rev_mismatch", "not_released", "not_found", "lookup_failed", "bad_rev"}
|
||||
|
||||
|
||||
def target(**over: Any) -> Target:
|
||||
values: dict[str, Any] = {
|
||||
"dwg": "L900-0101",
|
||||
"visual_rev": "1",
|
||||
"attached_rev": None,
|
||||
"attached_sha": None,
|
||||
"status": "draft",
|
||||
"drawing_state": "needed",
|
||||
"has_hand_drawing": False,
|
||||
"rev_ok": None,
|
||||
}
|
||||
values.update(over)
|
||||
return Target(**values)
|
||||
|
||||
|
||||
def found(**over: Any) -> Found:
|
||||
values: dict[str, Any] = {
|
||||
"dwg": "L900-0101",
|
||||
"ok": True,
|
||||
"rev": 1,
|
||||
"name": "L900-0101 r1",
|
||||
"obj_id": 1,
|
||||
"obj_version": 1,
|
||||
"file_id": 101,
|
||||
"file_version": 1,
|
||||
"file_size": 900,
|
||||
"base": None,
|
||||
"draft_rev": None,
|
||||
"draft_where": "",
|
||||
"in_drafting": False,
|
||||
"note": "",
|
||||
}
|
||||
values.update(over)
|
||||
return Found(**values)
|
||||
|
||||
|
||||
def d(t: Target, f: Found, same_file: bool = False) -> Decision:
|
||||
return decide(t, f, same_file=same_file)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- the table
|
||||
|
||||
|
||||
def test_lookup_failed_on_a_needed_drawing() -> None:
|
||||
r = d(target(), found(ok=False, rev=None, note="M-Files lookup failed: timeout"))
|
||||
assert (r.kind, r.flag, r.drawing_state) == ("lookup_failed", "lookup_failed", "lookup_failed")
|
||||
assert "timeout" in r.note
|
||||
|
||||
|
||||
def test_lookup_failed_keeps_what_is_attached() -> None:
|
||||
r = d(target(drawing_state="attached", attached_rev="1"), found(ok=False, rev=None, note="503"))
|
||||
assert (r.kind, r.flag, r.drawing_state) == ("lookup_failed", "lookup_failed", "attached")
|
||||
|
||||
|
||||
def test_not_released() -> None:
|
||||
r = d(target(), found(rev=None, draft_rev=1, draft_where="in Drafting review", in_drafting=True, base="L900-05"))
|
||||
assert (r.kind, r.flag, r.drawing_state) == ("not_released", "not_released", "not_released")
|
||||
assert r.note == "Not released in M-Files yet (sheet of L900-05) - drafting status: rev 1 in Drafting review"
|
||||
|
||||
|
||||
def test_not_released_in_drafting_without_a_rev() -> None:
|
||||
r = d(target(), found(rev=None, in_drafting=True))
|
||||
assert r.kind == "not_released" and r.note.endswith("in drafting, no released PDF yet")
|
||||
|
||||
|
||||
def test_not_found() -> None:
|
||||
r = d(target(), found(rev=None, name=None))
|
||||
assert (r.kind, r.flag, r.drawing_state) == ("not_found", "not_found", "not_found")
|
||||
assert r.note == "Drawing not in M-Files - check the drawing number in VISUAL"
|
||||
|
||||
|
||||
def test_bad_rev() -> None:
|
||||
r = d(target(visual_rev="A"), found(rev=1))
|
||||
assert (r.kind, r.flag) == ("bad_rev", "bad_rev")
|
||||
assert "A" in r.note and "M-Files has rev 1" in r.note and "not attached" in r.note
|
||||
|
||||
|
||||
def test_rev_mismatch() -> None:
|
||||
r = d(target(visual_rev="2"), found(rev=3))
|
||||
assert (r.kind, r.flag, r.drawing_state) == ("rev_mismatch", "rev_mismatch", "rev_mismatch")
|
||||
assert r.note == "Rev mismatch: VISUAL calls rev 2, M-Files has rev 3"
|
||||
|
||||
|
||||
def test_rev_mismatch_with_a_newer_draft() -> None:
|
||||
r = d(target(visual_rev="2"), found(rev=3, draft_rev=4, draft_where="in Drafting"))
|
||||
assert r.note == "Rev mismatch: VISUAL calls rev 2, M-Files has rev 3; drafting status: rev 4 in Drafting"
|
||||
|
||||
|
||||
def test_rev_mismatch_on_an_in_shop_packet_opens_nothing() -> None:
|
||||
r = d(target(visual_rev="2", status="ready", drawing_state="attached", attached_rev="2"), found(rev=3))
|
||||
assert r.kind == "rev_mismatch" and r.drawing_state == "attached"
|
||||
|
||||
|
||||
def test_approved_rev_attaches() -> None:
|
||||
r = d(target(visual_rev="2", rev_ok="3"), found(rev=3))
|
||||
assert (r.kind, r.flag, r.drawing_state) == ("attach", None, "attached")
|
||||
|
||||
|
||||
def test_approved_other_rev_still_mismatches() -> None:
|
||||
assert d(target(visual_rev="2", rev_ok="4"), found(rev=3)).kind == "rev_mismatch"
|
||||
|
||||
|
||||
def test_match_on_a_draft_attaches() -> None:
|
||||
r = d(target(), found())
|
||||
assert (r.kind, r.flag, r.drawing_state) == ("attach", None, "attached")
|
||||
|
||||
|
||||
def test_attach_names_the_sheet() -> None:
|
||||
r = d(target(dwg="L900-0101H2"), found(dwg="L900-0101H2", base="L900-0101"))
|
||||
assert r.kind == "attach" and r.note == "sheet of drawing L900-0101"
|
||||
|
||||
|
||||
def test_hand_drawing_at_the_same_rev_stays() -> None:
|
||||
r = d(target(has_hand_drawing=True, drawing_state="hand"), found())
|
||||
assert r.kind == "unchanged" and r.drawing_state == "hand"
|
||||
|
||||
|
||||
def test_past_draft_same_file_unchanged() -> None:
|
||||
r = d(target(status="ready", drawing_state="attached", attached_rev="1", attached_sha=b"\x01"), found(), True)
|
||||
assert r.kind == "unchanged" and r.flag is None
|
||||
|
||||
|
||||
def test_past_draft_new_file_same_rev_opens_a_co() -> None:
|
||||
r = d(target(status="in_progress", drawing_state="attached", attached_rev="1", attached_sha=b"\x01"), found())
|
||||
assert r.kind == "open_co"
|
||||
assert r.note == "M-Files re-released rev 1 of L900-0101 (new file)"
|
||||
|
||||
|
||||
def test_past_draft_higher_rev_opens_a_co() -> None:
|
||||
t = target(status="ready", visual_rev="2", drawing_state="attached", attached_rev="1", attached_sha=b"\x01")
|
||||
r = d(t, found(rev=2))
|
||||
assert r.kind == "open_co"
|
||||
assert r.note == "M-Files released rev 2 of L900-0101; this packet has rev 1"
|
||||
|
||||
|
||||
def test_past_draft_waiting_co_gets_staged() -> None:
|
||||
t = target(
|
||||
status="ready",
|
||||
visual_rev="2",
|
||||
drawing_state="attached",
|
||||
attached_rev="1",
|
||||
attached_sha=b"\x01",
|
||||
co_waiting=("L900-0101", "2"),
|
||||
co_pending=True,
|
||||
)
|
||||
assert d(t, found(rev=2)).kind == "stage_for_co"
|
||||
|
||||
|
||||
def test_complete_packet_still_gets_a_co() -> None:
|
||||
t = target(status="complete", drawing_state="attached", attached_rev="1", attached_sha=b"\x01")
|
||||
assert d(t, found()).kind == "open_co"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- the fixture lookup
|
||||
|
||||
|
||||
def test_fixture_lookup_states(tmp_path: Path) -> None:
|
||||
gen.generate(tmp_path)
|
||||
one = FixtureLookup(tmp_path)
|
||||
assert one.status("L900-0101").rev == 1
|
||||
assert one.status("L900-0201").rev == 3
|
||||
nr = one.status("L900-0501")
|
||||
assert nr.rev is None and nr.in_drafting and nr.draft_rev == 1
|
||||
assert one.status("L900-0701").rev is None and one.status("L900-0701").ok
|
||||
dest = tmp_path / "out" / "x.pdf"
|
||||
assert one.download(one.status("L900-0101"), dest) > 0 and dest.read_bytes().startswith(b"%PDF-")
|
||||
(tmp_path / "state").write_text("2")
|
||||
two = FixtureLookup(tmp_path)
|
||||
f2 = two.status("L900-0101")
|
||||
assert f2.rev == 2 and f2.identity != one.status("L900-0101").identity
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- the job (PostgreSQL)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fx(pg_settings: Settings, tmp_path: Path) -> Settings:
|
||||
return feed_settings_over(pg_settings, tmp_path)
|
||||
|
||||
|
||||
def _drawing_pages(conn: Any, pid: int) -> list[tuple[Any, ...]]:
|
||||
return [
|
||||
tuple(r)
|
||||
for r in conn.execute(
|
||||
"SELECT n, pn, dwg, rev, asset_id IS NOT NULL, void_at IS NOT NULL FROM packet_pages"
|
||||
" WHERE packet_id = %s AND type = 'drawing' ORDER BY n",
|
||||
(pid,),
|
||||
).fetchall()
|
||||
]
|
||||
|
||||
|
||||
@needs_pg
|
||||
def test_job_attaches_and_flags(fx: Settings) -> None:
|
||||
run_feed(fx, 1)
|
||||
counts = run_drawings(fx)
|
||||
assert counts["_status"] in ("ok", "degraded"), counts
|
||||
assert counts["checked"] >= 8 and counts["attached"] >= 1
|
||||
with app_conn(fx) as conn:
|
||||
wp = packet_by_key(conn, gen.KEY_WELD_PART)
|
||||
assert wp is not None
|
||||
assert wp["drawing_state"] == "attached"
|
||||
assert not set(wp["flags"]) & DRAWING_FLAGS
|
||||
assert wp["visual"].get("rev_confirmed") is True
|
||||
pages = _drawing_pages(conn, wp["id"])
|
||||
assert pages and all(p[2] == "L900-0101" and p[3] == "1" and p[4] for p in pages)
|
||||
lk = conn.execute(
|
||||
"SELECT ok, rev, asset_id IS NOT NULL, sha256 IS NOT NULL, checked_at IS NOT NULL FROM drawing_lookups"
|
||||
" WHERE dwg = 'L900-0101'"
|
||||
).fetchone()
|
||||
assert lk == (True, 1, True, True, True)
|
||||
# the LAYOUT packet shares the drawing: attached from the same asset
|
||||
lay = packet_by_key(conn, gen.KEY_LAYOUT_A)
|
||||
assert lay is not None and lay["drawing_state"] == "attached"
|
||||
|
||||
mm = packet_by_key(conn, gen.KEY_TWO_OPS) # VISUAL rev 2, M-Files rev 3
|
||||
assert mm is not None and "rev_mismatch" in mm["flags"] and mm["drawing_state"] == "rev_mismatch"
|
||||
assert mm["visual"]["appr"]["rev"] == 3
|
||||
staged = conn.execute(
|
||||
"SELECT asset_id IS NOT NULL, rev FROM drawing_lookups WHERE dwg = 'L900-0201'"
|
||||
).fetchone()
|
||||
assert staged == (True, 3) # staged for Clerical Supervisor approval
|
||||
assert _drawing_pages(conn, mm["id"]) == []
|
||||
|
||||
nr = packet_by_key(conn, gen.KEY_DRAFTING)
|
||||
assert nr is not None and "not_released" in nr["flags"] and nr["drawing_state"] == "not_released"
|
||||
nf = packet_by_key(conn, gen.KEY_NOT_FOUND)
|
||||
assert nf is not None and "not_found" in nf["flags"] and nf["drawing_state"] == "not_found"
|
||||
br = packet_by_key(conn, gen.KEY_REV_A)
|
||||
assert br is not None and "bad_rev" in br["flags"]
|
||||
nd = packet_by_key(conn, gen.KEY_NO_DWG)
|
||||
assert nd is not None and nd["drawing_state"] == "none"
|
||||
log = conn.execute(
|
||||
"SELECT by, by_name, role FROM packet_log WHERE packet_id = %s"
|
||||
" AND action = 'Drawing attached from M-Files'",
|
||||
(wp["id"],),
|
||||
).fetchall()
|
||||
assert len(log) == 1 and log[0][0] is None and log[0][2] == "system"
|
||||
assert all(r[0] == "system" and r[1] is None for r in system_audit(conn, "packet.vis_update_draft"))
|
||||
assert all(r[0] == "system" for r in system_audit(conn, "drawing.fetched"))
|
||||
assert system_audit(conn, "drawing.fetched")
|
||||
|
||||
|
||||
@needs_pg
|
||||
def test_job_second_run_changes_nothing(fx: Settings) -> None:
|
||||
run_feed(fx, 1)
|
||||
run_drawings(fx)
|
||||
with app_conn(fx) as conn:
|
||||
conn.execute(
|
||||
"UPDATE drawing_lookups SET next_check_at = now() - interval '1 minute',"
|
||||
" checked_at = now() - interval '1 day'"
|
||||
)
|
||||
before = conn.execute("SELECT count(*) FROM packet_pages").fetchone()[0]
|
||||
counts = run_drawings(fx)
|
||||
assert counts.get("attached", 0) == 0 and counts.get("fetched", 0) == 0, counts
|
||||
with app_conn(fx) as conn:
|
||||
assert conn.execute("SELECT count(*) FROM packet_pages").fetchone()[0] == before
|
||||
|
||||
|
||||
@needs_pg
|
||||
def test_stage_for_co_after_state_2(fx: Settings) -> None:
|
||||
run_feed(fx, 1)
|
||||
run_drawings(fx)
|
||||
with app_conn(fx) as conn:
|
||||
pid = packet_id(conn, gen.KEY_WELD_PART)
|
||||
move_status(conn, pid, "ready")
|
||||
counts = run_feed(fx, 2)
|
||||
assert counts["cos_opened"] >= 1, counts
|
||||
with app_conn(fx) as conn:
|
||||
co = [c for c in cos_of(conn, pid) if c["review"] == "pending"]
|
||||
assert len(co) == 1 and co[0]["origin"] == "visual"
|
||||
assert co[0]["waiting"] is not None and co[0]["waiting"]["rev"] == "2"
|
||||
assert co[0]["staged"] is None
|
||||
counts = run_drawings(fx)
|
||||
assert counts.get("staged", 0) >= 1, counts
|
||||
with app_conn(fx) as conn:
|
||||
co = [c for c in cos_of(conn, pid) if c["review"] == "pending"]
|
||||
assert len(co) == 1
|
||||
assert co[0]["waiting"] is None
|
||||
staged = co[0]["staged"]
|
||||
assert staged["rev"] == "2" and staged["dwg"] == "L900-0101" and staged["asset_id"]
|
||||
# the in-shop packet keeps its rev 1 pages until the CO is approved
|
||||
assert all(p[3] == "1" for p in _drawing_pages(conn, pid))
|
||||
assert (
|
||||
conn.execute(
|
||||
"SELECT count(*) FROM packet_log WHERE packet_id = %s AND action LIKE 'Drawing for change order%%'",
|
||||
(pid,),
|
||||
).fetchone()[0]
|
||||
== 1
|
||||
)
|
||||
|
||||
|
||||
@needs_pg
|
||||
def test_new_file_on_an_in_shop_packet_opens_a_drawing_co(fx: Settings) -> None:
|
||||
run_feed(fx, 1)
|
||||
run_drawings(fx)
|
||||
with app_conn(fx) as conn:
|
||||
pid = packet_id(conn, gen.KEY_CLOSES) # L900-0901 rev 1, attached
|
||||
assert _drawing_pages(conn, pid)
|
||||
move_status(conn, pid, "in_progress")
|
||||
conn.execute(
|
||||
"UPDATE drawing_lookups SET checked_at = now() - interval '2 days', next_check_at = now()"
|
||||
" WHERE dwg = 'L900-0901'"
|
||||
)
|
||||
# M-Files re-releases rev 1 of L900-0901 as a new file (same rev, new version and bytes)
|
||||
d1 = fx.fixture_dir / "drawings.1.json"
|
||||
import json
|
||||
|
||||
data = json.loads(d1.read_text())
|
||||
data["L900-0901"].update({"file": "L900-0901 r1b.pdf", "obj_version": 2, "file_version": 2})
|
||||
d1.write_text(json.dumps(data))
|
||||
(fx.fixture_dir / "L900-0901 r1b.pdf").write_bytes(gen.pdf_bytes("L900-0901 r1b"))
|
||||
counts = run_drawings(fx)
|
||||
assert counts.get("cos_opened", 0) == 1, counts
|
||||
with app_conn(fx) as conn:
|
||||
cos = cos_of(conn, pid)
|
||||
assert len(cos) == 1
|
||||
co = cos[0]
|
||||
assert (co["origin"], co["review"], co["ecn"], co["issued_by"]) == ("drawing", "pending", "M-FILES", None)
|
||||
assert co["reason"] == "M-Files re-released rev 1 of L900-0901 (new file)"
|
||||
assert co["staged"]["asset_id"] and co["vis"]["dwg_change"] is True
|
||||
assert all(r[0] == "system" for r in system_audit(conn, "packet.vis_open_co"))
|
||||
assert system_audit(conn, "packet.vis_open_co")
|
||||
@@ -0,0 +1,438 @@
|
||||
"""The Clerical feed API (/api/feed), rev approval, hand uploads and feed change order approval, on the generator's
|
||||
fixture files run through the real feed and drawing jobs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from fakedir import FAKE_PASSWORD, FakeDirectory
|
||||
from fastapi.testclient import TestClient
|
||||
from feedhelpers import (
|
||||
cos_of,
|
||||
feed_settings_over,
|
||||
gen,
|
||||
golive_today_offset,
|
||||
move_status,
|
||||
packet_id,
|
||||
run_drawings,
|
||||
run_feed,
|
||||
set_feed,
|
||||
)
|
||||
from pkthelpers import SIGN, seed_users, tiny_pdf, upload
|
||||
from pshelpers import ORIGIN_HEADERS, app_conn, needs_pg, sign_in
|
||||
|
||||
from packetstation import db
|
||||
from packetstation.settings import Settings
|
||||
|
||||
pytestmark = needs_pg
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fx(pg_settings: Settings, tmp_path: Path) -> Settings:
|
||||
s = feed_settings_over(pg_settings, tmp_path)
|
||||
set_feed(s, auto_release=False)
|
||||
return s
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(fx: Settings, fake_dir: FakeDirectory, dev_visual: None) -> Iterator[TestClient]:
|
||||
from packetstation.main import create_app
|
||||
|
||||
with TestClient(create_app(fx, directory=fake_dir), headers=ORIGIN_HEADERS) as c:
|
||||
seed_users(c, ("admin.ann", "clerk.clara", "clerk.leo", "wh.wade"))
|
||||
yield c
|
||||
|
||||
|
||||
def as_user(c: TestClient, sam: str) -> None:
|
||||
assert sign_in(c, sam).status_code == 200, sam
|
||||
|
||||
|
||||
def drawing_pages(conn: Any, pid: int) -> list[tuple[Any, ...]]:
|
||||
return [
|
||||
tuple(r)
|
||||
for r in conn.execute(
|
||||
"SELECT n, rev, co_id IS NOT NULL, void_at IS NOT NULL, void_co_id IS NOT NULL FROM packet_pages"
|
||||
" WHERE packet_id = %s AND type = 'drawing' ORDER BY n",
|
||||
(pid,),
|
||||
).fetchall()
|
||||
]
|
||||
|
||||
|
||||
def packet_row(conn: Any, pid: int) -> dict[str, Any]:
|
||||
from psycopg.rows import dict_row
|
||||
|
||||
with conn.cursor(row_factory=dict_row) as cur:
|
||||
r = cur.execute("SELECT * FROM packets WHERE id = %s", (pid,)).fetchone()
|
||||
assert r is not None
|
||||
return dict(r)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- the From VISUAL panel
|
||||
|
||||
|
||||
def test_overview_shape(client: TestClient, fx: Settings) -> None:
|
||||
set_feed(fx, golive_date=golive_today_offset())
|
||||
run_feed(fx, 1)
|
||||
run_drawings(fx)
|
||||
as_user(client, "clerk.clara")
|
||||
r = client.get("/api/feed/overview")
|
||||
assert r.status_code == 200, r.text
|
||||
o = r.json()
|
||||
assert set(o) >= {
|
||||
"status",
|
||||
"last_run",
|
||||
"runs",
|
||||
"drawing_runs",
|
||||
"ignored",
|
||||
"unmapped",
|
||||
"projects_without_names",
|
||||
"golive_date",
|
||||
"departments",
|
||||
}
|
||||
assert o["status"]["paused"] is False
|
||||
assert o["status"]["worker"] is None or o["status"]["worker"]["stale"] is True
|
||||
assert o["last_run"]["status"] == "ok" and o["last_run"]["counts"]["new_drafts"] == len(gen.STATE1_KEYS) - 1
|
||||
assert len(o["runs"]) == 1 and len(o["drawing_runs"]) == 1
|
||||
assert o["unmapped"] == [{"resource": "NEWRES", "rows": 2}]
|
||||
assert [(i["wo_base"], i["lot"]) for i in o["ignored"]] == [("L900-13", "2")]
|
||||
assert {p["number"] for p in o["projects_without_names"]} == {"L900", "L901"}
|
||||
l900 = next(p for p in o["projects_without_names"] if p["number"] == "L900")
|
||||
assert l900["units"] == 2 and l900["packets"] == len(gen.STATE1_KEYS) - 2
|
||||
assert o["golive_date"] == golive_today_offset().isoformat() and o["departments"] == []
|
||||
|
||||
|
||||
def test_feed_api_is_clerical_only(client: TestClient) -> None:
|
||||
as_user(client, "wh.wade")
|
||||
assert client.get("/api/feed/overview").status_code == 403
|
||||
assert client.post("/api/feed/run", json={}).status_code == 403
|
||||
assert client.get("/api/feed/batches").status_code == 403
|
||||
|
||||
|
||||
def test_run_now_inserts_a_request_and_notifies(client: TestClient, fx: Settings) -> None:
|
||||
listener = db.connect_as("worker", fx, autocommit=True)
|
||||
listener.execute("LISTEN ps_job_request")
|
||||
try:
|
||||
as_user(client, "clerk.clara")
|
||||
r = client.post("/api/feed/run", json={})
|
||||
assert r.status_code == 200 and r.json()["requested"] is True and r.json()["pending"] == 1, r.text
|
||||
got = list(listener.notifies(timeout=3, stop_after=1))
|
||||
assert got and json.loads(got[0].payload) == {"job": "visual-feed"}
|
||||
r = client.post("/api/feed/run", json={"job": "drawing-lookup"})
|
||||
assert r.status_code == 200 and r.json()["job"] == "drawing-lookup"
|
||||
assert client.post("/api/feed/run", json={"job": "housekeeping"}).status_code == 422
|
||||
finally:
|
||||
listener.close()
|
||||
with app_conn(fx) as conn:
|
||||
rows = conn.execute("SELECT job FROM job_requests WHERE claimed_at IS NULL ORDER BY id").fetchall()
|
||||
assert [r[0] for r in rows] == ["visual-feed", "drawing-lookup"]
|
||||
assert conn.execute("SELECT count(*) FROM audit_events WHERE action = 'feed.run_requested'").fetchone()[0] == 2
|
||||
# the worker's next run claims the request and records the trigger
|
||||
c = run_feed(fx, 1)
|
||||
assert c["_trigger"] == "request"
|
||||
assert client.get("/api/feed/overview").json()["last_run"]["trigger"] == "request"
|
||||
|
||||
|
||||
def test_pull_in(client: TestClient, fx: Settings) -> None:
|
||||
set_feed(fx, golive_date=golive_today_offset())
|
||||
run_feed(fx, 1)
|
||||
as_user(client, "clerk.clara")
|
||||
ig = client.get("/api/feed/overview").json()["ignored"][0]
|
||||
r = client.post(f"/api/feed/ignored/{ig['id']}/pull-in", json={"reason": ""})
|
||||
assert r.status_code == 400 and r.json()["code"] == "reason_required"
|
||||
r = client.post(f"/api/feed/ignored/{ig['id']}/pull-in", json={"reason": "Customer still wants it"})
|
||||
assert r.status_code == 200 and r.json()["pulled_in_at"], r.text
|
||||
r = client.post(f"/api/feed/ignored/{ig['id']}/pull-in", json={"reason": "again please"})
|
||||
assert r.status_code == 409
|
||||
assert client.post("/api/feed/ignored/999999/pull-in", json={"reason": "nothing"}).status_code == 404
|
||||
with app_conn(fx) as conn:
|
||||
assert conn.execute("SELECT count(*) FROM job_requests WHERE claimed_at IS NULL").fetchone()[0] == 1
|
||||
assert conn.execute("SELECT count(*) FROM audit_events WHERE action = 'feed.pulled_in'").fetchone()[0] == 1
|
||||
c = run_feed(fx, 1)
|
||||
assert c["new_drafts"] == 1 and c["_trigger"] == "request"
|
||||
assert client.get("/api/feed/overview").json()["ignored"] == []
|
||||
|
||||
|
||||
def test_batches_and_changes(client: TestClient, fx: Settings) -> None:
|
||||
run_feed(fx, 1)
|
||||
run_feed(fx, 2)
|
||||
as_user(client, "clerk.clara")
|
||||
batches = client.get("/api/feed/batches").json()
|
||||
assert len(batches) == 2 and batches[0]["id"] > batches[1]["id"]
|
||||
ch = client.get("/api/feed/changes").json()
|
||||
assert ch["batch"]["id"] == batches[0]["id"]
|
||||
kinds = {(r["wo_base"], r["kind"]) for r in ch["rows"]}
|
||||
assert ("L900-01", "changed") in kinds and ("L900-05", "gone") in kinds
|
||||
first = client.get(f"/api/feed/changes?batch_id={batches[1]['id']}").json()
|
||||
assert all(r["kind"] == "added" for r in first["rows"]) and first["rows"]
|
||||
assert client.get("/api/feed/changes?batch_id=999999").status_code == 404
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- rev approval
|
||||
|
||||
|
||||
def test_rev_approve(client: TestClient, fx: Settings) -> None:
|
||||
run_feed(fx, 1)
|
||||
run_drawings(fx)
|
||||
with app_conn(fx) as conn:
|
||||
pid = packet_id(conn, gen.KEY_TWO_OPS) # VISUAL rev 2, M-Files rev 3: staged
|
||||
nf = packet_id(conn, gen.KEY_NOT_FOUND)
|
||||
body = {"reason": "Engineering confirmed rev 3 is right", "sign": SIGN}
|
||||
as_user(client, "clerk.clara")
|
||||
r = client.post(f"/api/packets/{pid}/rev-approve", json=body)
|
||||
assert r.status_code == 403, r.text
|
||||
as_user(client, "clerk.leo")
|
||||
r = client.post(f"/api/packets/{pid}/rev-approve", json={"reason": "no signature"})
|
||||
assert r.status_code == 400 and r.json()["code"] == "signature_required", r.text
|
||||
r = client.post(f"/api/packets/{nf}/rev-approve", json=body)
|
||||
assert r.status_code == 409 and r.json()["code"] == "nothing_staged", r.text
|
||||
r = client.post(f"/api/packets/{pid}/rev-approve", json=body)
|
||||
assert r.status_code == 200, r.text
|
||||
with app_conn(fx) as conn:
|
||||
p = packet_row(conn, pid)
|
||||
assert p["drawing_state"] == "attached" and "rev_mismatch" not in p["flags"]
|
||||
assert p["visual"]["rev_ok"]["rev"] == "3" and p["visual"]["rev_ok"]["vis_rev"] == "2"
|
||||
assert p["visual"]["rev_confirmed"] is True
|
||||
pages = drawing_pages(conn, pid)
|
||||
assert pages and all(pg[1] == "3" for pg in pages)
|
||||
assert (
|
||||
conn.execute(
|
||||
"SELECT count(*) FROM packet_log WHERE packet_id = %s AND action = 'Different rev approved by hand'",
|
||||
(pid,),
|
||||
).fetchone()[0]
|
||||
== 1
|
||||
)
|
||||
assert (
|
||||
conn.execute(
|
||||
"SELECT count(*) FROM audit_events WHERE action = 'packet.rev_approve' AND packet_id = %s", (pid,)
|
||||
).fetchone()[0]
|
||||
== 1
|
||||
)
|
||||
# the drawing job leaves the approved rev alone
|
||||
with app_conn(fx) as conn:
|
||||
conn.execute("UPDATE drawing_lookups SET checked_at = now() - interval '2 days', next_check_at = now()")
|
||||
run_drawings(fx)
|
||||
with app_conn(fx) as conn:
|
||||
p = packet_row(conn, pid)
|
||||
assert p["drawing_state"] == "attached" and "rev_mismatch" not in p["flags"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- hand uploads
|
||||
|
||||
|
||||
def test_hand_drawing(client: TestClient, fx: Settings) -> None:
|
||||
run_feed(fx, 1)
|
||||
run_drawings(fx)
|
||||
with app_conn(fx) as conn:
|
||||
nf = packet_id(conn, gen.KEY_NOT_FOUND)
|
||||
wp = packet_id(conn, gen.KEY_WELD_PART)
|
||||
as_user(client, "clerk.clara")
|
||||
a = upload(client, tiny_pdf(2), "L900-0701 hand.pdf")
|
||||
assert a.status_code == 201, a.text
|
||||
asset = a.json()["id"]
|
||||
r = client.post(f"/api/packets/{wp}/hand/drawing", json={"asset_id": asset, "reason": "Have it", "sign": SIGN})
|
||||
assert r.status_code == 409 and r.json()["code"] == "not_flagged", r.text
|
||||
r = client.post(f"/api/packets/{nf}/hand/drawing", json={"asset_id": asset, "reason": "From drafting"})
|
||||
assert r.status_code == 400 and r.json()["code"] == "signature_required", r.text
|
||||
r = client.post(
|
||||
f"/api/packets/{nf}/hand/drawing", json={"asset_id": asset, "reason": "From drafting by email", "sign": SIGN}
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
with app_conn(fx) as conn:
|
||||
p = packet_row(conn, nf)
|
||||
assert p["drawing_state"] == "hand" and "hand_drawing" in p["flags"] and "not_found" not in p["flags"]
|
||||
assert p["visual"]["manual_dwg"]["reason"] == "From drafting by email"
|
||||
assert len(drawing_pages(conn, nf)) == 2
|
||||
assert (
|
||||
conn.execute(
|
||||
"SELECT count(*) FROM packet_log WHERE packet_id = %s AND action = 'Drawing uploaded by hand'", (nf,)
|
||||
).fetchone()[0]
|
||||
== 1
|
||||
)
|
||||
# a hand flag keeps it off auto-release, and the drawing job does not take it away
|
||||
run_drawings(fx)
|
||||
with app_conn(fx) as conn:
|
||||
assert packet_row(conn, nf)["drawing_state"] == "hand"
|
||||
|
||||
|
||||
def _xlsx(rows: list[list[Any]]) -> bytes:
|
||||
from openpyxl import Workbook
|
||||
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
assert ws is not None
|
||||
for r in rows:
|
||||
ws.append(r)
|
||||
buf = io.BytesIO()
|
||||
wb.save(buf)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def _hand_bom(client: TestClient, pid: int, data: bytes, name: str, **form: str) -> Any:
|
||||
fields = {"reason": "Engineering sent the BOM", "sign_factor": "password", "sign_secret": FAKE_PASSWORD}
|
||||
fields.update(form)
|
||||
return client.post(
|
||||
f"/api/packets/{pid}/hand/bom", files={"file": (name, data, "application/octet-stream")}, data=fields
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind", ["xlsx", "csv"])
|
||||
def test_hand_bom(client: TestClient, fx: Settings, kind: str) -> None:
|
||||
run_feed(fx, 1)
|
||||
run_drawings(fx)
|
||||
with app_conn(fx) as conn:
|
||||
mm = packet_id(conn, gen.KEY_TWO_OPS) # rev mismatch: flagged
|
||||
wp = packet_id(conn, gen.KEY_WELD_PART) # attached, no flags
|
||||
visual_lines = conn.execute("SELECT count(*) FROM bom_lines WHERE packet_id = %s", (mm,)).fetchone()[0]
|
||||
if kind == "xlsx":
|
||||
data, name = (
|
||||
_xlsx(
|
||||
[
|
||||
["Item", "Qty", "Part ID", "Description"],
|
||||
[1, 4, "PLT-0.5-A36", "Plate"],
|
||||
[2, 2, "CPL-2-3000", "Coupling"],
|
||||
[3, 1, None, "Gusset"],
|
||||
]
|
||||
),
|
||||
"bom.xlsx",
|
||||
)
|
||||
else:
|
||||
data, name = (
|
||||
b"Item,Qty,Part ID,Description\r\n1,4,PLT-0.5-A36,Plate\r\n2,2,CPL-2-3000,Coupling\r\n3,1,,Gusset\r\n",
|
||||
"bom.csv",
|
||||
)
|
||||
as_user(client, "clerk.clara")
|
||||
r = _hand_bom(client, wp, data, name)
|
||||
assert r.status_code == 409 and r.json()["code"] == "not_flagged", r.text
|
||||
r = _hand_bom(client, mm, data, name, sign_secret="wrong password")
|
||||
assert r.status_code in (400, 401, 403), r.text
|
||||
r = _hand_bom(client, mm, b"Name,Colour\nPipe,Red\n", "bad.csv")
|
||||
assert r.status_code == 422 and r.json()["code"] == "bad_file", r.text
|
||||
r = _hand_bom(client, mm, data, name)
|
||||
assert r.status_code == 200, r.text
|
||||
with app_conn(fx) as conn:
|
||||
lines = conn.execute(
|
||||
"SELECT item, qty, part_id, description, src FROM bom_lines WHERE packet_id = %s ORDER BY item", (mm,)
|
||||
).fetchall()
|
||||
assert [(ln[0], ln[2], ln[4]) for ln in lines] == [
|
||||
(1, "PLT-0.5-A36", "hand"),
|
||||
(2, "CPL-2-3000", "hand"),
|
||||
(3, None, "hand"),
|
||||
]
|
||||
p = packet_row(conn, mm)
|
||||
assert "hand_bom" in p["flags"] and p["visual"]["manual_bom"]["lines"] == 3
|
||||
assert (
|
||||
conn.execute(
|
||||
"SELECT count(*) FROM packet_log WHERE packet_id = %s AND action = 'BOM uploaded by hand'", (mm,)
|
||||
).fetchone()[0]
|
||||
== 1
|
||||
)
|
||||
# the feed keeps a hand BOM on a draft
|
||||
run_feed(fx, 2)
|
||||
with app_conn(fx) as conn:
|
||||
assert (
|
||||
conn.execute("SELECT count(*) FROM bom_lines WHERE packet_id = %s AND src = 'hand'", (mm,)).fetchone()[0]
|
||||
== 3
|
||||
)
|
||||
# back to VISUAL's BOM
|
||||
r = client.request("DELETE", f"/api/packets/{mm}/hand/bom", json={"sign": SIGN})
|
||||
assert r.status_code == 200, r.text
|
||||
with app_conn(fx) as conn:
|
||||
src = conn.execute("SELECT DISTINCT src FROM bom_lines WHERE packet_id = %s", (mm,)).fetchall()
|
||||
assert src == [("visual",)]
|
||||
assert conn.execute("SELECT count(*) FROM bom_lines WHERE packet_id = %s", (mm,)).fetchone()[0] == visual_lines
|
||||
p = packet_row(conn, mm)
|
||||
assert "hand_bom" not in p["flags"] and "manual_bom" not in p["visual"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- feed change orders
|
||||
|
||||
|
||||
def test_co_approve_applies_vis_and_voids_pages(client: TestClient, fx: Settings) -> None:
|
||||
run_feed(fx, 1)
|
||||
run_drawings(fx)
|
||||
with app_conn(fx) as conn:
|
||||
wp = packet_id(conn, gen.KEY_WELD_PART)
|
||||
assert packet_row(conn, wp)["drawing_state"] == "attached"
|
||||
move_status(conn, wp, "ready")
|
||||
old_pages = drawing_pages(conn, wp)
|
||||
assert old_pages and all(pg[1] == "1" for pg in old_pages)
|
||||
run_feed(fx, 2)
|
||||
with app_conn(fx) as conn:
|
||||
co = cos_of(conn, wp)[0]
|
||||
assert co["waiting"] is not None and co["staged"] is None
|
||||
as_user(client, "clerk.clara")
|
||||
r = client.post(f"/api/packets/{wp}/change-orders/{co['co_no']}/approve", json={"sign": SIGN})
|
||||
assert r.status_code == 409 and r.json()["code"] == "co_not_staged", r.text
|
||||
assert "not been fetched" in r.json()["detail"]
|
||||
r = client.get(f"/api/packets/{wp}")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["status"] == "ready"
|
||||
run_drawings(fx, 2)
|
||||
with app_conn(fx) as conn:
|
||||
co = cos_of(conn, wp)[0]
|
||||
assert co["staged"] is not None and co["staged"]["rev"] == "2" and co["waiting"] is None
|
||||
r = client.post(f"/api/packets/{wp}/change-orders/{co['co_no']}/approve", json={})
|
||||
assert r.status_code == 400 and r.json()["code"] == "signature_required", r.text
|
||||
r = client.post(f"/api/packets/{wp}/change-orders/{co['co_no']}/approve", json={"sign": SIGN})
|
||||
assert r.status_code == 200, r.text
|
||||
with app_conn(fx) as conn:
|
||||
p = packet_row(conn, wp)
|
||||
co = cos_of(conn, wp)[0]
|
||||
assert co["review"] == "approved" and co["approved_by"] is not None
|
||||
assert p["rev"] == "2" and p["status"] == "ready"
|
||||
assert p["visual_fp_applied"] == co["target_fp"]
|
||||
assert p["visual"]["fp_parts"] == co["vis"]["fp_parts"]
|
||||
pages = drawing_pages(conn, wp)
|
||||
void = [pg for pg in pages if pg[3]]
|
||||
live = [pg for pg in pages if not pg[3]]
|
||||
assert len(void) == len(old_pages) and all(pg[4] for pg in void) # VOID by this CO
|
||||
assert live and all(pg[1] == "2" and pg[2] for pg in live)
|
||||
items = conn.execute("SELECT old_rev, new_rev FROM co_items WHERE co_id = %s", (co["id"],)).fetchall()
|
||||
assert ("1", "2") in [tuple(i) for i in items]
|
||||
required = {
|
||||
r[0]
|
||||
for r in conn.execute("SELECT dept_code FROM co_required_depts WHERE co_id = %s", (co["id"],)).fetchall()
|
||||
}
|
||||
assert "03" in required
|
||||
# the next feed run sees the packet matches VISUAL: no new change order
|
||||
c = run_feed(fx, 2)
|
||||
assert c["cos_opened"] == 0, c
|
||||
with app_conn(fx) as conn:
|
||||
assert len(cos_of(conn, wp)) == 1
|
||||
|
||||
|
||||
def test_minor_co_approve_needs_no_departments(client: TestClient, fx: Settings) -> None:
|
||||
run_feed(fx, 1)
|
||||
with app_conn(fx) as conn:
|
||||
g = packet_id(conn, gen.KEY_NOT_FOUND)
|
||||
move_status(conn, g, "warehouse")
|
||||
run_feed(fx, 2)
|
||||
with app_conn(fx) as conn:
|
||||
co = cos_of(conn, g)[0]
|
||||
assert co["minor"] is True
|
||||
as_user(client, "clerk.clara")
|
||||
r = client.post(f"/api/packets/{g}/change-orders/{co['co_no']}/approve", json={"sign": SIGN})
|
||||
assert r.status_code == 200, r.text
|
||||
with app_conn(fx) as conn:
|
||||
assert conn.execute("SELECT count(*) FROM co_required_depts WHERE co_id = %s", (co["id"],)).fetchone()[0] == 0
|
||||
assert conn.execute("SELECT target_hrs FROM packet_work_orders WHERE packet_id = %s", (g,)).fetchone()[0] == 5
|
||||
|
||||
|
||||
def test_changes_to_approve_filter(client: TestClient, fx: Settings) -> None:
|
||||
run_feed(fx, 1)
|
||||
with app_conn(fx) as conn:
|
||||
wp = packet_id(conn, gen.KEY_WELD_PART)
|
||||
move_status(conn, wp, "ready")
|
||||
run_feed(fx, 2)
|
||||
as_user(client, "clerk.clara")
|
||||
r = client.get("/api/packets?co=pending")
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
items = body["packets"]
|
||||
assert [i["id"] for i in items] == [wp]
|
||||
assert items[0]["co_pending"] is True
|
||||
@@ -0,0 +1,507 @@
|
||||
"""The VISUAL feed end to end on PostgreSQL: the generator's fixture files, the FixtureSource, the cache, the
|
||||
candidates and the apply, through the visual-feed job as the worker runs it."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import date, timedelta
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from feedhelpers import (
|
||||
CHANGE_KEYS,
|
||||
cos_of,
|
||||
feed_settings_over,
|
||||
gen,
|
||||
legs_of,
|
||||
move_status,
|
||||
packet_by_key,
|
||||
packet_id,
|
||||
run_drawings,
|
||||
run_feed,
|
||||
set_feed,
|
||||
switch_state,
|
||||
system_audit,
|
||||
)
|
||||
from pshelpers import app_conn, needs_pg
|
||||
from psycopg.rows import dict_row
|
||||
|
||||
from packetstation.feed import fingerprint
|
||||
from packetstation.settings import Settings
|
||||
|
||||
pytestmark = needs_pg
|
||||
|
||||
TODAY = date.today()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fx(pg_settings: Settings, tmp_path: Path) -> Settings:
|
||||
s = feed_settings_over(pg_settings, tmp_path)
|
||||
set_feed(s, auto_release=False) # most tests look at drafts; auto-release has its own test
|
||||
return s
|
||||
|
||||
|
||||
def row(conn: Any, sql: str, params: Any = None) -> dict[str, Any]:
|
||||
with conn.cursor(row_factory=dict_row) as cur:
|
||||
r = cur.execute(sql, params).fetchone()
|
||||
assert r is not None, sql
|
||||
return dict(r)
|
||||
|
||||
|
||||
def no_human_writes(conn: Any) -> None:
|
||||
"""Every audit row the feed wrote has role_snapshot 'system' and no user."""
|
||||
bad = conn.execute(
|
||||
"SELECT action, role_snapshot FROM audit_events WHERE user_id IS NULL"
|
||||
" AND (action LIKE 'packet.%' OR action LIKE 'feed.%' OR action LIKE 'project.%' OR action LIKE 'unit.%'"
|
||||
" OR action LIKE 'drawing.%') AND role_snapshot IS DISTINCT FROM 'system'"
|
||||
).fetchall()
|
||||
assert bad == []
|
||||
logs = conn.execute("SELECT DISTINCT by_name, role FROM packet_log WHERE by IS NULL ORDER BY 1").fetchall()
|
||||
assert all(r[1] == "system" for r in logs), logs
|
||||
|
||||
|
||||
def window_firmed_wos(state: int = 1) -> int:
|
||||
"""Firmed work order legs released inside the default window (what visual_batches.window_rows counts)."""
|
||||
since, until = TODAY - timedelta(days=14), TODAY + timedelta(days=30)
|
||||
return len(
|
||||
{
|
||||
(r["wo"], r["lot"], r["split"], r["sub"])
|
||||
for r in gen.feed_state(state, TODAY)["rows"]
|
||||
if r["st"] == "Firmed" and r["rel"] and since <= date.fromisoformat(r["rel"]) <= until
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def window_firmed_rows(state: int = 1) -> int:
|
||||
since, until = TODAY - timedelta(days=14), TODAY + timedelta(days=30)
|
||||
return sum(
|
||||
1
|
||||
for r in gen.feed_state(state, TODAY)["rows"]
|
||||
if r["st"] == "Firmed" and r["rel"] and since <= date.fromisoformat(r["rel"]) <= until
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- state 1
|
||||
|
||||
|
||||
def test_state_1_creates_projects_units_and_drafts(fx: Settings) -> None:
|
||||
c = run_feed(fx, 1)
|
||||
assert c["_status"] == "ok", c
|
||||
assert c["new_drafts"] == len(gen.STATE1_KEYS)
|
||||
assert c["projects_created"] == 2 and c["units_created"] == 3
|
||||
assert c["unmapped_resources"] >= 1 and c["unmapped"] == {"NEWRES": 2}
|
||||
assert c["suspect"] == 0 and c["rows"] >= window_firmed_rows()
|
||||
with app_conn(fx) as conn:
|
||||
keys = {r[0] for r in conn.execute("SELECT visual_key FROM packets WHERE removed_at IS NULL").fetchall()}
|
||||
assert keys == set(gen.STATE1_KEYS)
|
||||
|
||||
p = row(conn, "SELECT * FROM projects WHERE number = 'L900'")
|
||||
assert p["source"] == "visual" and p["needs_details"] is True and p["name"] is None
|
||||
assert p["created_by"] is None
|
||||
assert (p["customer"], p["customer_src"], p["customer_visual"]) == ("Acme Refining", "visual", "Acme Refining")
|
||||
assert p["po"] == "PO-1001" and p["order_ref"]["cust_order_id"] == gen.ORDER_ACME
|
||||
|
||||
u1 = row(
|
||||
conn,
|
||||
"SELECT u.* FROM units u JOIN projects p ON p.id = u.project_id WHERE p.number = 'L900' AND u.lot = '001'",
|
||||
)
|
||||
assert u1["customer"] == "Bayou Chemical" and u1["po"] == "PO-2002" and u1["customer_src"] == "visual"
|
||||
assert u1["order_ref"]["cust_order_id"] == gen.ORDER_BAYOU and u1["order_ref"]["via"] == "unit_top_job"
|
||||
assert u1["ship_date"] == TODAY + timedelta(days=70) and u1["ship_date_visual"] == TODAY + timedelta(days=70)
|
||||
u2 = row(
|
||||
conn,
|
||||
"SELECT u.* FROM units u JOIN projects p ON p.id = u.project_id WHERE p.number = 'L900' AND u.lot = '002'",
|
||||
)
|
||||
assert u2["customer"] == "Acme Refining" and u2["order_ref"]["cust_order_id"] == gen.ORDER_ACME
|
||||
z = row(conn, "SELECT u.* FROM units u JOIN projects p ON p.id = u.project_id WHERE p.number = 'L901'")
|
||||
assert z["customer"] == "Delta Gas" and z["order_ref"]["status"] == "C"
|
||||
assert row(conn, "SELECT order_ref FROM projects WHERE number = 'L901'")["order_ref"]["via"] in (
|
||||
"project_newest",
|
||||
"unit_top_job",
|
||||
"base",
|
||||
)
|
||||
aliases = {r[0] for r in conn.execute("SELECT alias FROM project_aliases WHERE kind = 'import'").fetchall()}
|
||||
assert {"L900", "L900001", "L900002", "L901", "L901001"} <= aliases
|
||||
assert conn.execute("SELECT count(*) FROM units WHERE lot = '7A'").fetchone()[0] == 0
|
||||
|
||||
wp = packet_by_key(conn, gen.KEY_WELD_PART)
|
||||
assert wp is not None
|
||||
assert (wp["status"], wp["source_kind"], wp["dept_code"], wp["op"]) == ("draft", "visual", "03", "WELD-PART")
|
||||
assert (wp["drawing"], wp["rev"], wp["drawing_state"]) == ("L900-0101", "1", "needed")
|
||||
assert wp["flags"] == ["drawing_needed"] and wp["created_by"] is None
|
||||
assert wp["title"] == "Inlet spool 6 in" and wp["customer"] == "Acme Refining"
|
||||
assert wp["visual_rel"] == TODAY - timedelta(days=3) and wp["visual_want"] == TODAY + timedelta(days=20)
|
||||
assert wp["visual_fp_applied"] and set(wp["visual"]["fp_parts"]) >= {"legs", "qty", "rev", "drawing", "bom"}
|
||||
assert wp["visual_fp_applied"] == fingerprint._full(wp["visual"]["fp_parts"])
|
||||
assert {f["seq"] for f in wp["visual"]["folded"]} == {30, 50}
|
||||
assert wp["visual"]["partspecs"] == ["Cut to length per sheet 1", "Weld per WPS-11\nBack purge"]
|
||||
assert wp["source"]["kind"] == "visual"
|
||||
legs = legs_of(conn, wp["id"])
|
||||
assert len(legs) == 1 and legs[0][:5] == ("L900-01", "1", "0", "0", 20)
|
||||
assert (legs[0][5], legs[0][6], legs[0][7]) == (Decimal(2), Decimal(3), "F")
|
||||
wo = row(conn, "SELECT * FROM packet_work_orders WHERE packet_id = %s", (wp["id"],))
|
||||
assert (wo["dwg"], wo["rev"], wo["op"]) == ("L900-0101", "1", "WELD-PART")
|
||||
bom = conn.execute(
|
||||
"SELECT coalesce(cl_item, item::text), part_id, qty, loc, sub, na, src, vkey FROM bom_lines"
|
||||
" WHERE packet_id = %s ORDER BY sort",
|
||||
(wp["id"],),
|
||||
).fetchall()
|
||||
assert {b[1] for b in bom} == {"PIPE-6-SCH40", "L900-01-SUB", "WOL-1-6"}
|
||||
assert all(b[6] == "visual" for b in bom)
|
||||
sub = next(b for b in bom if b[1] == "L900-01-SUB")
|
||||
assert sub[5] is True
|
||||
main = next(b for b in bom if b[1] == "PIPE-6-SCH40")
|
||||
assert main[3] == "A-01" and main[7] == "L900-01|1|0|20|1"
|
||||
vs = row(conn, "SELECT * FROM packet_visual_state WHERE packet_id = %s", (wp["id"],))
|
||||
assert vs["ticket_hours"] == Decimal("1.25") and vs["observed_at"] is not None
|
||||
assert {(lg["seq"], lg["st"]) for lg in vs["legs"]} == {(20, "F")}
|
||||
|
||||
two = packet_by_key(conn, gen.KEY_TWO_OPS)
|
||||
assert two is not None
|
||||
assert [lg[4] for lg in legs_of(conn, two["id"])] == [10, 20] # two operations, one packet
|
||||
|
||||
nd = packet_by_key(conn, gen.KEY_NO_DWG)
|
||||
assert nd is not None and nd["drawing_state"] == "none" and "no_drawing" in nd["flags"]
|
||||
assert nd["drawing"] in (None, "")
|
||||
|
||||
rev_a = packet_by_key(conn, gen.KEY_REV_A)
|
||||
assert rev_a is not None and rev_a["rev"] == "A"
|
||||
items = [
|
||||
r[0]
|
||||
for r in conn.execute(
|
||||
"SELECT coalesce(cl_item, item::text) FROM bom_lines WHERE packet_id = %s ORDER BY sort", (rev_a["id"],)
|
||||
).fetchall()
|
||||
]
|
||||
assert sorted(items) == ["1", "1-1", "2"]
|
||||
|
||||
log = conn.execute(
|
||||
"SELECT by, by_name, role FROM packet_log WHERE packet_id = %s AND action = 'Packet created from VISUAL'",
|
||||
(wp["id"],),
|
||||
).fetchall()
|
||||
assert log == [(None, "VISUAL feed", "system")]
|
||||
created = system_audit(conn, "packet.created")
|
||||
assert len(created) == len(gen.STATE1_KEYS) and all(r[0] == "system" and r[1] is None for r in created)
|
||||
assert all(r[0] == "system" for r in system_audit(conn, "project.created"))
|
||||
assert len(system_audit(conn, "project.created")) == 2
|
||||
assert len(system_audit(conn, "unit.created")) == 3
|
||||
assert len(system_audit(conn, "feed.run")) == 1
|
||||
no_human_writes(conn)
|
||||
|
||||
batch = row(conn, "SELECT * FROM visual_batches ORDER BY id DESC LIMIT 1")
|
||||
assert batch["suspect"] is False and batch["window_rows"] == window_firmed_wos()
|
||||
assert conn.execute("SELECT count(*) FROM visual_rows WHERE gone_batch IS NULL").fetchone()[0] >= (
|
||||
window_firmed_rows()
|
||||
)
|
||||
assert conn.execute("SELECT count(*) FROM visual_orders").fetchone()[0] == 3
|
||||
assert conn.execute("SELECT count(*) FROM visual_rows WHERE lot = '7A'").fetchone()[0] >= 1
|
||||
assert conn.execute("SELECT count(*) FROM visual_labor").fetchone()[0] == 2
|
||||
|
||||
|
||||
def test_second_run_changes_nothing(fx: Settings) -> None:
|
||||
run_feed(fx, 1)
|
||||
with app_conn(fx) as conn:
|
||||
before = conn.execute("SELECT id, version FROM packets ORDER BY id").fetchall()
|
||||
logs = conn.execute("SELECT count(*) FROM packet_log").fetchone()[0]
|
||||
c = run_feed(fx, 1)
|
||||
assert c["_status"] == "ok"
|
||||
assert {k: c.get(k, 0) for k in CHANGE_KEYS} == dict.fromkeys(CHANGE_KEYS, 0)
|
||||
with app_conn(fx) as conn:
|
||||
assert conn.execute("SELECT id, version FROM packets ORDER BY id").fetchall() == before
|
||||
assert conn.execute("SELECT count(*) FROM packet_log").fetchone()[0] == logs
|
||||
assert conn.execute("SELECT count(*) FROM visual_row_changes WHERE kind <> 'added'").fetchone()[0] == 0
|
||||
|
||||
|
||||
def test_hand_customer_is_never_overwritten(fx: Settings) -> None:
|
||||
run_feed(fx, 1)
|
||||
with app_conn(fx) as conn:
|
||||
conn.execute(
|
||||
"UPDATE units SET customer = 'Hand Co', customer_src = 'hand' WHERE lot = '001'"
|
||||
" AND project_id = (SELECT id FROM projects WHERE number = 'L900')"
|
||||
)
|
||||
run_feed(fx, 1)
|
||||
with app_conn(fx) as conn:
|
||||
u = row(
|
||||
conn,
|
||||
"SELECT u.* FROM units u JOIN projects p ON p.id = u.project_id WHERE p.number = 'L900' AND u.lot = '001'",
|
||||
)
|
||||
assert (u["customer"], u["customer_src"], u["customer_visual"]) == ("Hand Co", "hand", "Bayou Chemical")
|
||||
|
||||
|
||||
def test_order_pin_wins(fx: Settings) -> None:
|
||||
run_feed(fx, 1)
|
||||
with app_conn(fx) as conn:
|
||||
conn.execute(
|
||||
"UPDATE units SET order_pin = %s, order_pin_reason = 'Spool is for Acme' WHERE lot = '001'"
|
||||
" AND project_id = (SELECT id FROM projects WHERE number = 'L900')",
|
||||
(gen.ORDER_ACME,),
|
||||
)
|
||||
run_feed(fx, 1)
|
||||
with app_conn(fx) as conn:
|
||||
u = row(
|
||||
conn,
|
||||
"SELECT u.* FROM units u JOIN projects p ON p.id = u.project_id WHERE p.number = 'L900' AND u.lot = '001'",
|
||||
)
|
||||
assert u["order_ref"]["cust_order_id"] == gen.ORDER_ACME and u["order_ref"]["via"] == "pin"
|
||||
assert u["customer"] == "Acme Refining"
|
||||
|
||||
|
||||
def test_golive_ignores_and_pull_in(fx: Settings) -> None:
|
||||
set_feed(fx, golive_date=TODAY + timedelta(days=gen.GOLIVE_OFFSET))
|
||||
c = run_feed(fx, 1)
|
||||
assert c["ignored_golive"] == 1 and c["new_drafts"] == len(gen.STATE1_KEYS) - 1
|
||||
with app_conn(fx) as conn:
|
||||
assert packet_by_key(conn, gen.KEY_GOLIVE, live=False) is None
|
||||
ig = row(conn, "SELECT * FROM visual_ignored_wos")
|
||||
assert (ig["wo_base"], ig["lot"], ig["split"], ig["sub"]) == ("L900-13", "2", "0", "0")
|
||||
assert ig["pulled_in_at"] is None and ig["rel"] == TODAY - timedelta(days=10)
|
||||
conn.execute(
|
||||
"UPDATE visual_ignored_wos SET pulled_in_at = now(), pulled_in_reason = 'needed' WHERE id = %s", (ig["id"],)
|
||||
)
|
||||
c = run_feed(fx, 1)
|
||||
assert c["new_drafts"] == 1
|
||||
with app_conn(fx) as conn:
|
||||
assert packet_by_key(conn, gen.KEY_GOLIVE) is not None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- state 2
|
||||
|
||||
|
||||
def _shop(fx: Settings) -> tuple[int, int]:
|
||||
"""Run state 1, then put the WELD-PART packet in the shop (ready) and the not-found one at the warehouse."""
|
||||
run_feed(fx, 1)
|
||||
with app_conn(fx) as conn:
|
||||
wp, g = packet_id(conn, gen.KEY_WELD_PART), packet_id(conn, gen.KEY_NOT_FOUND)
|
||||
move_status(conn, wp, "ready")
|
||||
move_status(conn, g, "warehouse")
|
||||
return wp, g
|
||||
|
||||
|
||||
def test_state_2_change_orders_updates_gone_and_moved(fx: Settings) -> None:
|
||||
wp, g = _shop(fx)
|
||||
with app_conn(fx) as conn:
|
||||
fp1 = row(conn, "SELECT visual_fp_applied FROM packets WHERE id = %s", (wp,))["visual_fp_applied"]
|
||||
two_id = packet_id(conn, gen.KEY_TWO_OPS)
|
||||
moves_id = packet_id(conn, gen.KEY_MOVES)
|
||||
c = run_feed(fx, 2)
|
||||
assert c["_status"] == "ok", c
|
||||
assert c["cos_opened"] == 2 and c["drafts_updated"] >= 2 and c["retired"] == 1, c
|
||||
assert c["wo_gone_flagged"] == 0 # the first miss only counts
|
||||
with app_conn(fx) as conn:
|
||||
# rev 1 -> 2 on an in-shop packet: a change order waiting for the rev 2 drawing; the packet stays as it was
|
||||
p = row(conn, "SELECT * FROM packets WHERE id = %s", (wp,))
|
||||
assert (p["status"], p["rev"], p["visual_fp_applied"]) == ("ready", "1", fp1)
|
||||
cos = cos_of(conn, wp)
|
||||
assert len(cos) == 1
|
||||
co = cos[0]
|
||||
assert (co["origin"], co["review"], co["minor"], co["ecn"], co["issued_by"]) == (
|
||||
"visual",
|
||||
"pending",
|
||||
False,
|
||||
"VISUAL",
|
||||
None,
|
||||
)
|
||||
assert co["target_fp"] and co["target_fp"] != fp1
|
||||
assert co["vis"]["to_rev"] == "2" and co["vis"]["from_rev"] == "1" and co["vis"]["fp_parts"]
|
||||
assert co["waiting"]["dwg"] == "L900-0101" and co["waiting"]["rev"] == "2"
|
||||
assert co["staged"] is None
|
||||
|
||||
# tgt only: a minor change order with nothing to wait for
|
||||
gco = cos_of(conn, g)
|
||||
assert len(gco) == 1 and gco[0]["minor"] is True and gco[0]["waiting"] is None
|
||||
|
||||
# qty change on a draft: updated in place, both operations
|
||||
two = row(conn, "SELECT * FROM packets WHERE id = %s", (two_id,))
|
||||
assert two["status"] == "draft" and cos_of(conn, two_id) == []
|
||||
assert [lg[5] for lg in legs_of(conn, two_id)] == [Decimal(3), Decimal(3)]
|
||||
assert (
|
||||
conn.execute(
|
||||
"SELECT count(*) FROM packet_log WHERE packet_id = %s AND action = 'Updated from VISUAL'", (two_id,)
|
||||
).fetchone()[0]
|
||||
== 1
|
||||
)
|
||||
|
||||
# the LAYOUT draft shares the drawing: rev moved in place
|
||||
lay = packet_by_key(conn, gen.KEY_LAYOUT_A)
|
||||
assert lay is not None and lay["rev"] == "2" and lay["drawing_state"] == "needed"
|
||||
|
||||
# retire-moved
|
||||
old = row(conn, "SELECT * FROM packets WHERE id = %s", (moves_id,))
|
||||
assert old["removed_at"] is not None and old["removed_by"] is None and old["removed_reason"] == "moved"
|
||||
assert old["visual"]["moved"]["to"]
|
||||
assert packet_by_key(conn, gen.KEY_MOVED_TO) is not None
|
||||
|
||||
# gone: one miss counted, no flag yet
|
||||
e = packet_id(conn, gen.KEY_DRAFTING)
|
||||
assert [lg[8] for lg in legs_of(conn, e)] == [1]
|
||||
assert "wo_gone" not in row(conn, "SELECT flags FROM packets WHERE id = %s", (e,))["flags"]
|
||||
|
||||
# closed and cancelled observed
|
||||
j = row(conn, "SELECT * FROM packets WHERE visual_key = %s", (gen.KEY_CLOSES,))
|
||||
assert j["visual_closed_at"] is not None and legs_of(conn, j["id"])[0][7] == "C"
|
||||
k = row(conn, "SELECT * FROM packets WHERE visual_key = %s", (gen.KEY_CANCELS,))
|
||||
assert "cancelled" in k["flags"] and k["visual_cancelled_at"] is not None and k["removed_at"] is None
|
||||
assert all(r[0] == "system" for r in system_audit(conn, "packet.vis_open_co"))
|
||||
assert all(r[0] == "system" for r in system_audit(conn, "packet.vis_update_draft"))
|
||||
assert len(system_audit(conn, "packet.vis_retire_draft")) == 1
|
||||
no_human_writes(conn)
|
||||
|
||||
# the second miss flags the work order gone; nothing else moves
|
||||
c = run_feed(fx, 2)
|
||||
assert c["wo_gone_flagged"] == 1 and c["cos_opened"] == 0 and c["cos_reaimed"] == 0, c
|
||||
assert c["drafts_updated"] == 0 and c["retired"] == 0 and c["new_drafts"] == 0
|
||||
with app_conn(fx) as conn:
|
||||
e = row(conn, "SELECT * FROM packets WHERE visual_key = %s AND removed_at IS NULL", (gen.KEY_DRAFTING,))
|
||||
assert "wo_gone" in e["flags"] and e["wo_gone_at"] is not None
|
||||
assert [lg[8:] for lg in legs_of(conn, e["id"])] == [(2, True)]
|
||||
assert len(cos_of(conn, wp)) == 1 # the same CO, not a second one
|
||||
assert (
|
||||
conn.execute(
|
||||
"SELECT count(*) FROM packet_log WHERE packet_id = %s"
|
||||
" AND action LIKE 'Work order%%no longer in VISUAL'",
|
||||
(e["id"],),
|
||||
).fetchone()[0]
|
||||
== 1
|
||||
)
|
||||
|
||||
# VISUAL reverts: the pending feed change orders are withdrawn and the gone work order is back
|
||||
c = run_feed(fx, 1)
|
||||
assert c["cos_withdrawn"] == 2, c
|
||||
with app_conn(fx) as conn:
|
||||
co = cos_of(conn, wp)[0]
|
||||
assert co["review"] == "rejected" and co["rejected_by"] is None and co["rejected_reason"]
|
||||
e = row(conn, "SELECT * FROM packets WHERE visual_key = %s AND removed_at IS NULL", (gen.KEY_DRAFTING,))
|
||||
assert "wo_gone" not in e["flags"] and e["wo_gone_at"] is None
|
||||
assert [lg[8:] for lg in legs_of(conn, e["id"])] == [(0, False)]
|
||||
k = row(conn, "SELECT * FROM packets WHERE visual_key = %s", (gen.KEY_CANCELS,))
|
||||
assert "cancelled" not in k["flags"]
|
||||
# the retired key is never made again
|
||||
assert (
|
||||
conn.execute(
|
||||
"SELECT count(*) FROM packets WHERE visual_key = %s AND removed_at IS NULL", (gen.KEY_MOVES,)
|
||||
).fetchone()[0]
|
||||
== 0
|
||||
)
|
||||
assert c["skipped_removed"] >= 1
|
||||
|
||||
|
||||
def test_rejected_target_is_not_reopened(fx: Settings) -> None:
|
||||
wp, _ = _shop(fx)
|
||||
run_feed(fx, 2)
|
||||
with app_conn(fx) as conn:
|
||||
conn.execute(
|
||||
"UPDATE change_orders SET review = 'rejected', rejected_at = now(), rejected_reason = 'not this one',"
|
||||
" row_version = row_version + 1 WHERE packet_id = %s",
|
||||
(wp,),
|
||||
)
|
||||
c = run_feed(fx, 2)
|
||||
assert c["cos_opened"] == 0, c
|
||||
with app_conn(fx) as conn:
|
||||
assert len(cos_of(conn, wp)) == 1
|
||||
|
||||
|
||||
def test_complete_packet_gets_a_change_order(fx: Settings) -> None:
|
||||
run_feed(fx, 1)
|
||||
with app_conn(fx) as conn:
|
||||
wp = packet_id(conn, gen.KEY_WELD_PART)
|
||||
move_status(conn, wp, "complete")
|
||||
fl = packet_id(conn, gen.KEY_TWO_OPS)
|
||||
move_status(conn, fl, "filed")
|
||||
c = run_feed(fx, 2)
|
||||
with app_conn(fx) as conn:
|
||||
assert len(cos_of(conn, wp)) == 1
|
||||
assert cos_of(conn, fl) == [] # filed: nothing
|
||||
assert [lg[5] for lg in legs_of(conn, fl)] == [Decimal(2), Decimal(2)]
|
||||
assert c["cos_opened"] >= 1
|
||||
|
||||
|
||||
def test_removed_packet_key_is_never_recreated(fx: Settings) -> None:
|
||||
run_feed(fx, 1)
|
||||
with app_conn(fx) as conn:
|
||||
pid = packet_id(conn, gen.KEY_NO_DWG)
|
||||
conn.execute("UPDATE packets SET removed_at = now(), removed_reason = 'not needed' WHERE id = %s", (pid,))
|
||||
c = run_feed(fx, 1)
|
||||
assert c["new_drafts"] == 0 and c["skipped_removed"] >= 1
|
||||
with app_conn(fx) as conn:
|
||||
assert packet_by_key(conn, gen.KEY_NO_DWG) is None
|
||||
|
||||
|
||||
def test_closed_unit_is_skipped(fx: Settings) -> None:
|
||||
run_feed(fx, 1)
|
||||
with app_conn(fx) as conn:
|
||||
conn.execute(
|
||||
"UPDATE units SET status = 'closed' WHERE project_id = (SELECT id FROM projects WHERE number = 'L901')"
|
||||
)
|
||||
c = run_feed(fx, 2)
|
||||
assert c["skipped_closed"] >= 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- pause, suspect, auto-release
|
||||
|
||||
|
||||
def test_paused_skips_a_scheduled_run_but_a_request_runs(fx: Settings) -> None:
|
||||
set_feed(fx, paused=True)
|
||||
c = run_feed(fx, 1)
|
||||
assert c["_status"] == "skipped" and c["reason"] == "paused"
|
||||
with app_conn(fx) as conn:
|
||||
assert conn.execute("SELECT count(*) FROM packets").fetchone()[0] == 0
|
||||
c = run_feed(fx, 1, requested=True)
|
||||
assert c["_status"] == "ok" and c["_trigger"] == "request" and c["new_drafts"] == len(gen.STATE1_KEYS)
|
||||
|
||||
|
||||
def test_suspect_batch_marks_nothing_gone(fx: Settings) -> None:
|
||||
run_feed(fx, 1)
|
||||
empty = gen.feed_state(1, TODAY)
|
||||
empty.update({"rows": [], "materials": [], "labor": []})
|
||||
(fx.fixture_dir / "feed.3.json").write_text(json.dumps(empty), encoding="utf-8")
|
||||
c = run_feed(fx, 3)
|
||||
assert c["_status"] == "degraded" and c["suspect"] == 1, c
|
||||
assert c["_errors"]
|
||||
with app_conn(fx) as conn:
|
||||
assert conn.execute("SELECT count(*) FROM visual_rows WHERE gone_batch IS NOT NULL").fetchone()[0] == 0
|
||||
assert conn.execute("SELECT max(missing_runs) FROM packet_work_orders").fetchone()[0] == 0
|
||||
assert row(conn, "SELECT suspect FROM visual_batches ORDER BY id DESC LIMIT 1")["suspect"] is True
|
||||
assert conn.execute("SELECT count(*) FROM packets WHERE removed_at IS NOT NULL").fetchone()[0] == 0
|
||||
# back to normal: nothing was lost
|
||||
c = run_feed(fx, 1)
|
||||
assert c["suspect"] == 0 and c["new_drafts"] == 0
|
||||
|
||||
|
||||
def test_auto_release(fx: Settings) -> None:
|
||||
run_feed(fx, 1)
|
||||
run_drawings(fx)
|
||||
set_feed(fx, auto_release=True, auto_release_max=1)
|
||||
c = run_feed(fx, 1)
|
||||
assert c["auto_released"] == 1, c
|
||||
set_feed(fx, auto_release_max=25)
|
||||
c = run_feed(fx, 1)
|
||||
assert c["auto_released"] >= 1
|
||||
with app_conn(fx) as conn:
|
||||
wp = packet_by_key(conn, gen.KEY_WELD_PART)
|
||||
assert wp is not None and wp["status"] == "warehouse" and wp["heat_map"] is True
|
||||
wh = row(conn, "SELECT * FROM packet_wh WHERE packet_id = %s", (wp["id"],))
|
||||
assert wh["stage"] == "incoming" and wh["by"] is None
|
||||
tr = row(conn, "SELECT * FROM packet_transitions WHERE packet_id = %s", (wp["id"],))
|
||||
assert tr["action"] == "vis_auto_release" and tr["by"] is None
|
||||
# flagged drafts stay with Clerical
|
||||
for key in (gen.KEY_TWO_OPS, gen.KEY_NOT_FOUND, gen.KEY_DRAFTING, gen.KEY_NO_DWG):
|
||||
p = packet_by_key(conn, key)
|
||||
assert p is not None and p["status"] == "draft", key
|
||||
assert all(r[0] == "system" for r in system_audit(conn, "packet.vis_auto_release"))
|
||||
no_human_writes(conn)
|
||||
|
||||
|
||||
def test_paused_stops_auto_release(fx: Settings) -> None:
|
||||
run_feed(fx, 1)
|
||||
run_drawings(fx)
|
||||
set_feed(fx, auto_release=True, paused=True)
|
||||
c = run_feed(fx, 1, requested=True)
|
||||
assert c.get("auto_released", 0) == 0
|
||||
with app_conn(fx) as conn:
|
||||
assert conn.execute("SELECT count(*) FROM packets WHERE status = 'warehouse'").fetchone()[0] == 0
|
||||
|
||||
|
||||
def test_switch_state_file(fx: Settings) -> None:
|
||||
switch_state(fx.fixture_dir, 2)
|
||||
assert (fx.fixture_dir / "state").read_text() == "2"
|
||||
@@ -0,0 +1,215 @@
|
||||
"""The candidate fingerprint: which changes are major, minor or nothing, and what never counts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
from collections.abc import Callable
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from packetstation.feed import fingerprint as fp
|
||||
from packetstation.feed.candidates import BomLine, Candidate, Folded, Leg
|
||||
from packetstation.feed.types import WoKey
|
||||
|
||||
K1 = WoKey("L900-01", "1", "0", "0")
|
||||
K2 = WoKey("L900-02", "1", "0", "0")
|
||||
|
||||
|
||||
def leg(key: WoKey = K1, seq: int = 20, qty: Any = "2", tgt: Any = "3", **over: Any) -> Leg:
|
||||
values: dict[str, Any] = {
|
||||
"key": key,
|
||||
"seq": seq,
|
||||
"op": "WELD-PART",
|
||||
"qty": Decimal(str(qty)),
|
||||
"tgt": Decimal(str(tgt)),
|
||||
"dwg": "L900-0101",
|
||||
"rev": "1",
|
||||
"partspecs": "",
|
||||
"st": "F",
|
||||
}
|
||||
values.update(over)
|
||||
return Leg(**values)
|
||||
|
||||
|
||||
def line(item: str = "1", part: str | None = "PIPE-6", qty: Any = "2", **over: Any) -> BomLine:
|
||||
values: dict[str, Any] = {
|
||||
"item": item,
|
||||
"qty": Decimal(str(qty)),
|
||||
"part_id": part or "",
|
||||
"desc": "Pipe",
|
||||
"loc": "R1",
|
||||
"whs": "SHOP",
|
||||
"comm": "PIPE",
|
||||
"cons": False,
|
||||
"sub": False,
|
||||
"na": False,
|
||||
"grp": None,
|
||||
"vkey": f"L900-01|1|0|20|{item}",
|
||||
"issued": Decimal(0),
|
||||
"istat": 2,
|
||||
}
|
||||
values.update(over)
|
||||
return BomLine(**values)
|
||||
|
||||
|
||||
def cand(**over: Any) -> Candidate:
|
||||
values: dict[str, Any] = {
|
||||
"key": "L900001|WELD-PART|L900-0101",
|
||||
"project": "L900",
|
||||
"unit": "L900001",
|
||||
"lot": "1",
|
||||
"dept": "03",
|
||||
"op": "WELD-PART",
|
||||
"drawing": "L900-0101",
|
||||
"rev": "1",
|
||||
"title": "Inlet spool",
|
||||
"legs": [leg()],
|
||||
"folded": [],
|
||||
"partspecs": [],
|
||||
"rel": date(2026, 10, 10),
|
||||
"want": date(2026, 11, 5),
|
||||
"bom": [line("1"), line("2", "ELB-6", "1")],
|
||||
"statuses": {K1: "F"},
|
||||
"base": "L900-01",
|
||||
"in_window": True,
|
||||
}
|
||||
values.update(over)
|
||||
return Candidate(**values)
|
||||
|
||||
|
||||
def classify(before: Candidate, after: Candidate) -> fp.Change:
|
||||
return fp.classify(fp.compute(before).parts, fp.compute(after).parts)
|
||||
|
||||
|
||||
MAJOR_EDITS: dict[str, tuple[str, Callable[[], dict[str, Any]]]] = {
|
||||
"leg added": ("legs", lambda: {"legs": [leg(), leg(K2, 10)]}),
|
||||
"leg removed": ("legs", lambda: {"legs": []}),
|
||||
"leg seq": ("legs", lambda: {"legs": [leg(seq=30)]}),
|
||||
"qty": ("qty", lambda: {"legs": [leg(qty="3")]}),
|
||||
"rev": ("rev", lambda: {"rev": "2"}),
|
||||
"drawing": ("drawing", lambda: {"drawing": "L900-0102"}),
|
||||
"bom item": ("bom", lambda: {"bom": [line("1"), line("3", "ELB-6", "1")]}),
|
||||
"bom part": ("bom", lambda: {"bom": [line("1"), line("2", "ELB-6-SR", "1")]}),
|
||||
"bom qty": ("bom", lambda: {"bom": [line("1"), line("2", "ELB-6", "2")]}),
|
||||
"bom line added": ("bom", lambda: {"bom": [line("1"), line("2", "ELB-6", "1"), line("3", "TEE-6", "1")]}),
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", list(MAJOR_EDITS))
|
||||
def test_major_change(name: str) -> None:
|
||||
part, edit = MAJOR_EDITS[name]
|
||||
ch = classify(cand(), cand(**edit()))
|
||||
assert ch.changed and not ch.is_minor
|
||||
assert part in ch.major
|
||||
|
||||
|
||||
MINOR_EDITS: dict[str, tuple[str, Callable[[], dict[str, Any]]]] = {
|
||||
"tgt": ("tgt", lambda: {"legs": [leg(tgt="3.5")]}),
|
||||
"want": ("want", lambda: {"want": date(2026, 11, 12)}),
|
||||
"want cleared": ("want", lambda: {"want": None}),
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", list(MINOR_EDITS))
|
||||
def test_minor_change(name: str) -> None:
|
||||
part, edit = MINOR_EDITS[name]
|
||||
ch = classify(cand(), cand(**edit()))
|
||||
assert ch.changed and ch.is_minor
|
||||
assert ch.major == frozenset() and ch.minor == frozenset({part})
|
||||
|
||||
|
||||
def test_minor_and_major_together_is_major() -> None:
|
||||
ch = classify(cand(), cand(rev="2", want=date(2026, 12, 1)))
|
||||
assert ch.major == {"rev"} and ch.minor == {"want"} and not ch.is_minor
|
||||
|
||||
|
||||
NEVER_EDITS: dict[str, Callable[[], dict[str, Any]]] = {
|
||||
"rel": lambda: {"rel": date(2026, 10, 1)},
|
||||
"folded": lambda: {"folded": [Folded(K1, 30, "MFG", Decimal("0.5"))]},
|
||||
"partspecs": lambda: {"partspecs": ["Weld per WPS-11"]},
|
||||
"title": lambda: {"title": "Another title"},
|
||||
"statuses": lambda: {"statuses": {K1: "R"}},
|
||||
"issued": lambda: {"bom": [line("1", issued=Decimal(2)), line("2", "ELB-6", "1")]},
|
||||
"whs": lambda: {"bom": [line("1", whs="MAIN"), line("2", "ELB-6", "1")]},
|
||||
"loc": lambda: {"bom": [line("1", loc="A-01"), line("2", "ELB-6", "1")]},
|
||||
"dept and op": lambda: {"dept": "06", "op": "WELD-CODE"},
|
||||
"leg status": lambda: {"legs": [leg(st="C")]},
|
||||
"leg partspecs": lambda: {"legs": [leg(partspecs="Back purge")]},
|
||||
"in_window": lambda: {"in_window": False},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", list(NEVER_EDITS))
|
||||
def test_never_in_the_fingerprint(name: str) -> None:
|
||||
before, after = cand(), cand(**NEVER_EDITS[name]())
|
||||
assert not classify(before, after).changed
|
||||
assert fp.compute(before).full == fp.compute(after).full
|
||||
|
||||
|
||||
INVARIANT: dict[str, tuple[Callable[[], dict[str, Any]], Callable[[], dict[str, Any]]]] = {
|
||||
"2 vs 2.0": (lambda: {"legs": [leg(qty="2")]}, lambda: {"legs": [leg(qty="2.0")]}),
|
||||
"trailing zeros": (lambda: {"legs": [leg(tgt="1.5")]}, lambda: {"legs": [leg(tgt="1.500")]}),
|
||||
"empty vs None rev": (lambda: {"rev": ""}, lambda: {"rev": None}),
|
||||
"list order": (
|
||||
lambda: {"legs": [leg(), leg(K2, 10)], "bom": [line("1"), line("2", "ELB-6", "1")]},
|
||||
lambda: {"legs": [leg(K2, 10), leg()], "bom": [line("2", "ELB-6", "1"), line("1")]},
|
||||
),
|
||||
"drawing case and spaces": (lambda: {"drawing": "m00543"}, lambda: {"drawing": " M00 543 "}),
|
||||
"part id case": (
|
||||
lambda: {"bom": [line("1", "m00543")]},
|
||||
lambda: {"bom": [line("1", "M00543")]},
|
||||
),
|
||||
"CRLF": (lambda: {"rev": "1\r\n"}, lambda: {"rev": "1\n"}),
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", list(INVARIANT))
|
||||
def test_invariance(name: str) -> None:
|
||||
a, b = INVARIANT[name]
|
||||
assert fp.compute(cand(**a())).full == fp.compute(cand(**b())).full
|
||||
|
||||
|
||||
def test_lot_7a_stays_text() -> None:
|
||||
k = WoKey("L900-14", "7A", "0", "0")
|
||||
c = cand(legs=[leg(k, 10)], statuses={k: "F"})
|
||||
values = fp.part_values(c)
|
||||
assert values["legs"] == [["L900-14", "7A", "0", "0", 10]]
|
||||
# A lot with leading zeros is not a number either
|
||||
k0 = WoKey("L900-14", "007", "0", "0")
|
||||
other = fp.part_values(cand(legs=[leg(k0, 10)]))
|
||||
assert other["legs"][0][1] == "007"
|
||||
assert fp.compute(c).full != fp.compute(cand(legs=[leg(k0, 10)])).full
|
||||
|
||||
|
||||
def test_canonical_is_stable_json() -> None:
|
||||
assert fp.canonical({"b": Decimal("2.0"), "a": ["x\r\n", None, ""]}) == b'{"a":["x",null,null],"b":"2"}'
|
||||
assert fp.canonical({"d": date(2026, 10, 16)}) == b'{"d":"2026-10-16"}'
|
||||
assert fp.canonical("é") == fp.canonical("é") # NFC
|
||||
|
||||
|
||||
def test_parts_and_full() -> None:
|
||||
f = fp.compute(cand())
|
||||
assert set(f.parts) == set(fp.MAJOR) | set(fp.MINOR)
|
||||
assert all(len(v) == 64 for v in f.parts.values()) and len(f.full) == 64
|
||||
assert fp.compute(cand()) == f # deterministic
|
||||
|
||||
|
||||
def test_no_before_is_all_changed() -> None:
|
||||
ch = fp.classify(None, fp.compute(cand()).parts)
|
||||
assert ch.major == frozenset(fp.MAJOR) and ch.minor == frozenset(fp.MINOR)
|
||||
|
||||
|
||||
def test_unknown_materials_are_not_a_bom_change() -> None:
|
||||
before = fp.compute(cand())
|
||||
after = fp.compute(cand(bom=None))
|
||||
assert "bom" not in classify(cand(), cand(bom=None)).major
|
||||
merged = fp.merge(before.parts, after)
|
||||
assert merged.parts["bom"] == before.parts["bom"] and merged.full == before.full
|
||||
|
||||
|
||||
def test_dataclass_copy_is_unchanged() -> None:
|
||||
c = cand()
|
||||
assert fp.compute(dataclasses.replace(c)).full == fp.compute(c).full
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Hand-uploaded BOM files: header detection, the column patterns, skipped rows and bad files."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from packetstation.domain import handbom
|
||||
|
||||
|
||||
def xlsx(rows: list[list[Any]]) -> bytes:
|
||||
from openpyxl import Workbook
|
||||
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
assert ws is not None
|
||||
for r in rows:
|
||||
ws.append(r)
|
||||
buf = io.BytesIO()
|
||||
wb.save(buf)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def csv_bytes(text: str, bom: bool = True) -> bytes:
|
||||
return (b"\xef\xbb\xbf" if bom else b"") + text.encode("utf-8")
|
||||
|
||||
|
||||
def test_csv_header_after_title_rows() -> None:
|
||||
data = csv_bytes(
|
||||
"Bill of material,,,\n"
|
||||
"Job L900-01,,,\n"
|
||||
"\n"
|
||||
"Item,Qty,Part No.,Description,Length,Material,Dwg Ref\n"
|
||||
"1,2,PIPE-6-SCH40,Pipe 6 in sch 40,12 ft,A106-B,L900-0101\n"
|
||||
"2,4,,Gusset 1/4 plate,,A36,\n"
|
||||
)
|
||||
lines = handbom.parse(data, "bom.csv")
|
||||
assert [ln["item"] for ln in lines] == [1, 2]
|
||||
first = lines[0]
|
||||
assert first["qty"] == Decimal(2) and first["part_id"] == "PIPE-6-SCH40"
|
||||
assert first["description"] == "Pipe 6 in sch 40" and first["length"] == "12 ft"
|
||||
assert first["material"] == "A106-B" and first["dwg_ref"] == "L900-0101"
|
||||
assert lines[1]["part_id"] is None and lines[1]["description"] == "Gusset 1/4 plate"
|
||||
|
||||
|
||||
def test_xlsx_with_quantity_and_part_number() -> None:
|
||||
data = xlsx(
|
||||
[
|
||||
["PESCO", None, None],
|
||||
["Find", "Quantity", "PESCO Part Number", "Desc"],
|
||||
[10, 1, "FLG-6-150", "Flange"],
|
||||
[None, None, None, None],
|
||||
[11, 2.5, "GSK-6-150", "Gasket"],
|
||||
]
|
||||
)
|
||||
lines = handbom.parse(data, "bom.xlsx")
|
||||
assert [(ln["item"], ln["qty"], ln["part_id"]) for ln in lines] == [
|
||||
(10, Decimal(1), "FLG-6-150"),
|
||||
(11, Decimal("2.5"), "GSK-6-150"),
|
||||
]
|
||||
|
||||
|
||||
def test_rows_without_description_or_part_are_skipped() -> None:
|
||||
data = csv_bytes("Qty,Description\n2,Pipe\n,\n5,\nTotal,\n1,Cap\n")
|
||||
lines = handbom.parse(data, "bom.csv")
|
||||
assert [ln["description"] for ln in lines] == ["Pipe", "Cap"]
|
||||
|
||||
|
||||
def test_items_default_to_the_row_number_and_skip_repeats() -> None:
|
||||
data = csv_bytes("Item,Qty,Description\nA,1,One\n,1,Two\n1,1,Three\n")
|
||||
lines = handbom.parse(data, "bom.csv")
|
||||
items = [ln["item"] for ln in lines]
|
||||
assert len(set(items)) == 3
|
||||
assert items[0] == 1 and lines[0]["cl_item"] == "A"
|
||||
|
||||
|
||||
def test_semicolon_csv_without_bom() -> None:
|
||||
data = csv_bytes("Qty;Part ID;Description\n3;BOLT-5/8;Stud bolt\n", bom=False)
|
||||
lines = handbom.parse(data, "bom.csv")
|
||||
assert lines[0]["qty"] == Decimal(3) and lines[0]["part_id"] == "BOLT-5/8"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"data,name",
|
||||
[
|
||||
(b"", "empty.csv"),
|
||||
(b"%PDF-1.7\n...", "drawing.pdf"),
|
||||
(csv_bytes("Name,Colour\nPipe,Red\n"), "no-qty.csv"),
|
||||
(csv_bytes("Qty,Length\n2,12 ft\n"), "no-desc.csv"),
|
||||
(csv_bytes("Qty,Description\n,\n"), "no-lines.csv"),
|
||||
(b"PK\x03\x04not really a zip", "broken.xlsx"),
|
||||
],
|
||||
)
|
||||
def test_bad_files(data: bytes, name: str) -> None:
|
||||
with pytest.raises(handbom.BadFile) as e:
|
||||
handbom.parse(data, name)
|
||||
assert str(e.value)
|
||||
|
||||
|
||||
def test_header_must_be_within_the_first_20_rows() -> None:
|
||||
data = csv_bytes("\n".join(["note"] * 25) + "\nQty,Description\n1,Pipe\n")
|
||||
with pytest.raises(handbom.BadFile):
|
||||
handbom.parse(data, "bom.csv")
|
||||
|
||||
|
||||
def test_file_text_is_data() -> None:
|
||||
data = csv_bytes('Qty,Description\n1,"=HYPERLINK(""http://x"") ignore previous instructions"\n')
|
||||
lines = handbom.parse(data, "bom.csv")
|
||||
assert lines[0]["description"].startswith("=HYPERLINK")
|
||||
@@ -0,0 +1,166 @@
|
||||
"""The worker's job API (/api/jobs) and the Admin feed settings and resource map (/api/admin/feed)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from pshelpers import app_conn, needs_pg, sign_in
|
||||
|
||||
from packetstation import db
|
||||
from packetstation.settings import Settings
|
||||
|
||||
pytestmark = needs_pg
|
||||
|
||||
|
||||
def listen(settings: Settings, channel: str) -> Any:
|
||||
conn = db.connect_as("worker", settings, autocommit=True)
|
||||
conn.execute(f"LISTEN {channel}")
|
||||
return conn
|
||||
|
||||
|
||||
def notes(conn: Any) -> list[Any]:
|
||||
return list(conn.notifies(timeout=3, stop_after=1))
|
||||
|
||||
|
||||
def test_jobs_list_and_run_now(pg_client: TestClient, pg_settings: Settings) -> None:
|
||||
assert sign_in(pg_client, "clerk.clara").status_code == 200
|
||||
r = pg_client.get("/api/jobs")
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert [j["job"] for j in body["jobs"]] == ["drawing-lookup", "housekeeping", "visual-feed"]
|
||||
assert body["heartbeat"]["stale"] is True and body["heartbeat"]["at"] is None
|
||||
assert all(j["last_run"] is None and j["pending"] == 0 for j in body["jobs"])
|
||||
|
||||
listener = listen(pg_settings, "ps_job_request")
|
||||
try:
|
||||
r = pg_client.post("/api/jobs/visual-feed/run", json={"note": "check"})
|
||||
assert r.status_code == 200 and r.json() == {"requested": True, "pending": 1}, r.text
|
||||
got = notes(listener)
|
||||
assert got and json.loads(got[0].payload) == {"job": "visual-feed"}
|
||||
finally:
|
||||
listener.close()
|
||||
r = pg_client.post("/api/jobs/visual-feed/run")
|
||||
assert r.json()["pending"] == 2
|
||||
with app_conn(pg_settings) as conn:
|
||||
req = conn.execute(
|
||||
"SELECT r.note, u.sam_account FROM job_requests r JOIN users u ON u.id = r.requested_by ORDER BY r.id"
|
||||
).fetchall()
|
||||
assert req == [("check", "clerk.clara"), (None, "clerk.clara")]
|
||||
audit = conn.execute(
|
||||
"SELECT role_snapshot, subject_id FROM audit_events WHERE action = 'job.run_requested'"
|
||||
).fetchall()
|
||||
assert len(audit) == 2 and audit[0][1] == "visual-feed"
|
||||
assert [j["pending"] for j in pg_client.get("/api/jobs").json()["jobs"] if j["job"] == "visual-feed"] == [2]
|
||||
|
||||
|
||||
def test_jobs_unknown_and_refused(pg_client: TestClient) -> None:
|
||||
assert sign_in(pg_client, "clerk.clara").status_code == 200
|
||||
assert pg_client.post("/api/jobs/no-such/run").status_code == 404
|
||||
assert pg_client.get("/api/jobs/no-such/runs").status_code == 404
|
||||
assert pg_client.post("/api/jobs/DROP TABLE/run").status_code == 404
|
||||
assert sign_in(pg_client, "wh.wade").status_code == 200
|
||||
assert pg_client.get("/api/jobs").status_code == 403
|
||||
assert pg_client.post("/api/jobs/visual-feed/run").status_code == 403
|
||||
|
||||
|
||||
def test_job_runs_listing(pg_client: TestClient, pg_settings: Settings) -> None:
|
||||
with app_conn(pg_settings) as conn:
|
||||
for i in range(12):
|
||||
conn.execute(
|
||||
"INSERT INTO job_runs (job, trigger, status, counts, finished_at) VALUES"
|
||||
" ('visual-feed', 'schedule', 'ok', %s, now())",
|
||||
(json.dumps({"new_drafts": i}),),
|
||||
)
|
||||
assert sign_in(pg_client, "admin.ann").status_code == 200
|
||||
runs = pg_client.get("/api/jobs/visual-feed/runs").json()
|
||||
assert len(runs) == 10 and runs[0]["counts"] == {"new_drafts": 11}
|
||||
assert len(pg_client.get("/api/jobs/visual-feed/runs?limit=3").json()) == 3
|
||||
last = next(j for j in pg_client.get("/api/jobs").json()["jobs"] if j["job"] == "visual-feed")["last_run"]
|
||||
assert last["status"] == "ok" and last["counts"] == {"new_drafts": 11}
|
||||
|
||||
|
||||
def test_feed_settings_patch_is_audited_and_notifies(pg_client: TestClient, pg_settings: Settings) -> None:
|
||||
assert sign_in(pg_client, "clerk.clara").status_code == 200
|
||||
assert pg_client.get("/api/admin/feed/settings").status_code == 403
|
||||
assert pg_client.patch("/api/admin/feed/settings", json={"paused": True}).status_code == 403
|
||||
|
||||
assert sign_in(pg_client, "admin.ann").status_code == 200
|
||||
r = pg_client.get("/api/admin/feed/settings")
|
||||
assert r.status_code == 200, r.text
|
||||
s = r.json()
|
||||
assert s["interval_shop_minutes"] == 5 and s["paused"] is False and s["auto_release_max"] == 25
|
||||
assert "runs" in s and "next_run_at" in s
|
||||
|
||||
listener = listen(pg_settings, "ps_settings")
|
||||
try:
|
||||
r = pg_client.patch(
|
||||
"/api/admin/feed/settings",
|
||||
json={"interval_shop_minutes": 7, "golive_date": "2026-11-02", "departments": ["03", "06"]},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["interval_shop_minutes"] == 7 and r.json()["golive_date"] == "2026-11-02"
|
||||
assert notes(listener)
|
||||
finally:
|
||||
listener.close()
|
||||
with app_conn(pg_settings) as conn:
|
||||
detail, role = conn.execute(
|
||||
"SELECT detail, role_snapshot FROM audit_events WHERE action = 'feed.settings_changed' ORDER BY id DESC"
|
||||
).fetchone()
|
||||
assert detail["changed"]["interval_shop_minutes"] == {"from": 5, "to": 7}
|
||||
assert detail["changed"]["departments"] == {"from": [], "to": ["03", "06"]}
|
||||
assert "paused" not in detail["changed"] and "admin" in role
|
||||
assert conn.execute("SELECT value->>'interval_shop_minutes' FROM settings WHERE key = 'feed'").fetchone() == (
|
||||
"7",
|
||||
)
|
||||
|
||||
for bad in (
|
||||
{"interval_shop_minutes": 0},
|
||||
{"shop_start": "6am"},
|
||||
{"nope": 1},
|
||||
{"departments": ["99"]},
|
||||
{"window_back_days": 500},
|
||||
):
|
||||
r = pg_client.patch("/api/admin/feed/settings", json=bad)
|
||||
assert r.status_code == 422 and r.json()["code"] == "bad_settings", (bad, r.text)
|
||||
|
||||
|
||||
def test_resource_map(pg_client: TestClient, pg_settings: Settings) -> None:
|
||||
assert sign_in(pg_client, "clerk.clara").status_code == 200
|
||||
assert pg_client.get("/api/admin/feed/resource-map").status_code == 403
|
||||
assert sign_in(pg_client, "admin.ann").status_code == 200
|
||||
rows = {r["resource"]: r for r in pg_client.get("/api/admin/feed/resource-map").json()}
|
||||
assert rows["WELD-PART"]["target"] == "03" and rows["WELD-PART"]["confirmed"] is False
|
||||
assert rows["MFG"]["target"] == "fold" and rows["FORKLIFT"]["target"] == "ignore"
|
||||
assert rows["Electical"]["target"] == "14"
|
||||
|
||||
r = pg_client.put("/api/admin/feed/resource-map/NEWRES", json={"target": "03"})
|
||||
assert r.status_code == 200 and r.json()["target"] == "03" and r.json()["confirmed"] is True, r.text
|
||||
r = pg_client.put("/api/admin/feed/resource-map/NEWRES", json={"target": "Fold"})
|
||||
assert r.status_code == 200 and r.json()["target"] == "fold"
|
||||
for bad in ("77", "WH", "", "fold; drop"):
|
||||
r = pg_client.put("/api/admin/feed/resource-map/NEWRES", json={"target": bad})
|
||||
assert r.status_code == 422, (bad, r.text)
|
||||
assert pg_client.delete("/api/admin/feed/resource-map/NEWRES").status_code == 204
|
||||
assert pg_client.delete("/api/admin/feed/resource-map/NEWRES").status_code == 404
|
||||
with app_conn(pg_settings) as conn:
|
||||
acts = conn.execute(
|
||||
"SELECT action, detail FROM audit_events WHERE subject_type = 'visual_resource' ORDER BY id"
|
||||
).fetchall()
|
||||
assert [a[0] for a in acts] == ["feed.resource_mapped", "feed.resource_mapped", "feed.resource_unmapped"]
|
||||
assert acts[1][1]["before"]["target"] == "03" and acts[1][1]["after"]["target"] == "fold"
|
||||
assert acts[2][1]["after"] is None
|
||||
|
||||
|
||||
def test_resource_map_lists_unmapped_from_the_last_batch(
|
||||
pg_client: TestClient, pg_settings: Settings, tmp_path: Any
|
||||
) -> None:
|
||||
from feedhelpers import feed_settings_over, run_feed
|
||||
|
||||
fx = feed_settings_over(pg_settings, tmp_path)
|
||||
run_feed(fx, 1)
|
||||
assert sign_in(pg_client, "admin.ann").status_code == 200
|
||||
rows = {r["resource"]: r for r in pg_client.get("/api/admin/feed/resource-map").json()}
|
||||
assert rows["NEWRES"]["target"] is None and rows["NEWRES"]["rows_last_run"] == 2
|
||||
assert rows["WELD-PART"]["rows_last_run"] >= 1
|
||||
@@ -0,0 +1,315 @@
|
||||
"""The worker's timing, pause, redaction and run bookkeeping (run_job against PostgreSQL with a fake job)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from datetime import date, datetime, timedelta
|
||||
from typing import Any
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import pytest
|
||||
from pshelpers import app_conn, needs_pg
|
||||
from psycopg.types.json import Jsonb
|
||||
|
||||
from packetstation.feed import config
|
||||
from packetstation.feed.config import FeedSettings
|
||||
from packetstation.settings import Settings
|
||||
from packetstation.worker import runs
|
||||
from packetstation.worker.jobs import JobContext, JobSpec, load_jobs, seconds_until
|
||||
from packetstation.worker.redaction import clip, redact
|
||||
from packetstation.worker.scheduler import Scheduler, next_due
|
||||
|
||||
TZ = ZoneInfo("America/Chicago")
|
||||
FRI = date(2026, 10, 16) # a Friday
|
||||
SAT = date(2026, 10, 17)
|
||||
|
||||
|
||||
def at(d: date, hh: int, mm: int = 0) -> datetime:
|
||||
return datetime(d.year, d.month, d.day, hh, mm, tzinfo=TZ)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- cadence (pure)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"when,inside",
|
||||
[
|
||||
(at(FRI, 6, 0), True),
|
||||
(at(FRI, 12, 30), True),
|
||||
(at(FRI, 17, 59), True),
|
||||
(at(FRI, 18, 0), False),
|
||||
(at(FRI, 5, 59), False),
|
||||
(at(SAT, 12, 0), False),
|
||||
(at(date(2026, 10, 18), 12, 0), False), # Sunday
|
||||
(at(date(2026, 10, 19), 7, 0), True), # Monday
|
||||
],
|
||||
)
|
||||
def test_in_shop_hours(when: datetime, inside: bool) -> None:
|
||||
fs = FeedSettings()
|
||||
assert config.in_shop_hours(fs, when) is inside
|
||||
assert config.interval_seconds(fs, when) == (300 if inside else 1800)
|
||||
|
||||
|
||||
def test_interval_follows_settings() -> None:
|
||||
fs = FeedSettings(
|
||||
interval_shop_minutes=2, interval_off_minutes=45, shop_days=[5], shop_start="08:00", shop_end="12:00"
|
||||
)
|
||||
assert config.interval_seconds(fs, at(SAT, 9)) == 120
|
||||
assert config.interval_seconds(fs, at(FRI, 9)) == 45 * 60
|
||||
assert config.interval_seconds(fs, at(SAT, 12)) == 45 * 60
|
||||
|
||||
|
||||
def test_overnight_shift_belongs_to_the_day_it_started() -> None:
|
||||
fs = FeedSettings(shop_start="22:00", shop_end="06:00", shop_days=[4])
|
||||
assert config.in_shop_hours(fs, at(FRI, 23))
|
||||
assert config.in_shop_hours(fs, at(SAT, 3)) # Friday's shift
|
||||
assert not config.in_shop_hours(fs, at(SAT, 23))
|
||||
|
||||
|
||||
def test_window() -> None:
|
||||
fs = FeedSettings(window_back_days=14, window_ahead_days=30)
|
||||
assert config.window(fs, FRI) == (FRI - timedelta(days=14), FRI + timedelta(days=30))
|
||||
|
||||
|
||||
def test_settings_validation() -> None:
|
||||
with pytest.raises(config.SettingsError):
|
||||
config.validate_patch(FeedSettings(), {"interval_shop_minutes": 0})
|
||||
with pytest.raises(config.SettingsError):
|
||||
config.validate_patch(FeedSettings(), {"shop_start": "25:00"})
|
||||
with pytest.raises(config.SettingsError):
|
||||
config.validate_patch(FeedSettings(), {"no_such_key": 1})
|
||||
assert config.from_value({"interval_shop_minutes": 7, "unknown": 1}).interval_shop_minutes == 7
|
||||
assert config.from_value({"interval_shop_minutes": -3, "paused": True}).paused is True # good fields kept
|
||||
|
||||
|
||||
def _spec(name: str = "fake", run: Any = None, pausable: bool = True, cadence: Any = None) -> JobSpec:
|
||||
return JobSpec(
|
||||
name=name,
|
||||
run=run or (lambda ctx: {"done": 1}),
|
||||
cadence=cadence or (lambda fs, now: config.interval_seconds(fs, now)),
|
||||
pausable=pausable,
|
||||
)
|
||||
|
||||
|
||||
def test_registered_jobs_and_cadences() -> None:
|
||||
jobs = load_jobs()
|
||||
assert {"housekeeping", "visual-feed", "drawing-lookup"} <= set(jobs)
|
||||
fs = FeedSettings(drawing_interval_minutes=20)
|
||||
assert jobs["visual-feed"].cadence(fs, at(FRI, 10)) == 300
|
||||
assert jobs["visual-feed"].cadence(fs, at(SAT, 10)) == 1800
|
||||
assert jobs["drawing-lookup"].cadence(fs, at(FRI, 10)) == 1200
|
||||
assert "drawing-lookup" in jobs["visual-feed"].chain
|
||||
assert jobs["visual-feed"].pausable and jobs["drawing-lookup"].pausable
|
||||
hk = jobs["housekeeping"].cadence(fs, at(FRI, 3, 0))
|
||||
assert hk == 30 * 60 # 03:30 site time
|
||||
|
||||
|
||||
def test_next_due() -> None:
|
||||
spec = _spec()
|
||||
fs = FeedSettings()
|
||||
now = at(FRI, 10)
|
||||
assert next_due(spec, fs, now) == now + timedelta(minutes=5)
|
||||
assert next_due(spec, fs, now, now - timedelta(minutes=2)) == now + timedelta(minutes=3)
|
||||
assert next_due(spec, fs, now, now - timedelta(hours=1)) == now # overdue: now, never in the past
|
||||
assert next_due(_spec(cadence=lambda fs, n: None), fs, now) is None
|
||||
|
||||
|
||||
def test_seconds_until() -> None:
|
||||
assert seconds_until(at(FRI, 3, 0), datetime.min.time().replace(hour=3, minute=30)) == 1800
|
||||
assert seconds_until(at(FRI, 4, 0), datetime.min.time().replace(hour=3, minute=30)) == 23 * 3600 + 1800
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- redaction (pure)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"text,secret",
|
||||
[
|
||||
("connect failed: password=Hunter2xyz host=vsql", "Hunter2xyz"),
|
||||
("odbc PWD=S3cret!!;UID=visualkit_ro", "S3cret!!"),
|
||||
("Authorization: Bearer abc.def.ghi", "abc.def.ghi"),
|
||||
("X-Authentication: tok123456", "tok123456"),
|
||||
("postgresql://ps_app:pa55word@db/ps", "pa55word"),
|
||||
],
|
||||
)
|
||||
def test_redact_patterns(text: str, secret: str) -> None:
|
||||
out = redact(text)
|
||||
assert secret not in out and "[redacted]" in out
|
||||
|
||||
|
||||
def test_redact_environment_passwords(monkeypatch: pytest.MonkeyPatch, tmp_path: Any) -> None:
|
||||
monkeypatch.setenv("MFILES_PASSWORD", "vault-pass-0001")
|
||||
f = tmp_path / "visual_pw"
|
||||
f.write_text("visual-pass-0002\n")
|
||||
monkeypatch.setenv("VISUAL_PASSWORD_FILE", str(f))
|
||||
out = redact("login vault-pass-0001 failed; then visual-pass-0002 failed")
|
||||
assert "vault-pass-0001" not in out and "visual-pass-0002" not in out
|
||||
assert runs.redact is redact or "vault-pass-0001" not in runs.redact("x vault-pass-0001")
|
||||
|
||||
|
||||
def test_clip() -> None:
|
||||
out = clip([f"error {i} " + "x" * 600 for i in range(30)])
|
||||
assert len(out) == 20 and all(len(e) <= 500 for e in out)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- run_job (PostgreSQL)
|
||||
|
||||
|
||||
def _sched(settings: Settings, spec: JobSpec) -> Scheduler:
|
||||
return Scheduler(settings, build="pytest", jobs={spec.name: spec})
|
||||
|
||||
|
||||
def _runs(settings: Settings, job: str = "fake") -> list[tuple[Any, ...]]:
|
||||
with app_conn(settings) as conn:
|
||||
return [
|
||||
tuple(r)
|
||||
for r in conn.execute(
|
||||
"SELECT status, trigger, counts, errors, finished_at IS NOT NULL, build_version FROM job_runs"
|
||||
" WHERE job = %s ORDER BY id",
|
||||
(job,),
|
||||
).fetchall()
|
||||
]
|
||||
|
||||
|
||||
@needs_pg
|
||||
def test_run_job_records_a_run(pg_settings: Settings) -> None:
|
||||
seen: list[JobContext] = []
|
||||
|
||||
def run(ctx: JobContext) -> dict[str, Any]:
|
||||
seen.append(ctx)
|
||||
assert ctx.conn.execute("SELECT current_setting('application_name')").fetchone()[0] == "ps-worker"
|
||||
return {"done": 3}
|
||||
|
||||
row = runs.run_job(_sched(pg_settings, _spec(run=run)), "fake", "schedule")
|
||||
assert row["status"] == "ok" and row["counts"] == {"done": 3} and row["trigger"] == "schedule"
|
||||
assert seen and seen[0].run_id == row["id"] and seen[0].trigger == "schedule"
|
||||
assert _runs(pg_settings) == [("ok", "schedule", {"done": 3}, [], True, "pytest")]
|
||||
|
||||
|
||||
@needs_pg
|
||||
def test_run_job_degraded_and_failed(pg_settings: Settings) -> None:
|
||||
def degraded(ctx: JobContext) -> dict[str, Any]:
|
||||
ctx.error("orders read failed: password=hunter22")
|
||||
return {"n": 1}
|
||||
|
||||
def boom(ctx: JobContext) -> dict[str, Any]:
|
||||
raise RuntimeError("PWD=topsecret9 broke")
|
||||
|
||||
row = runs.run_job(_sched(pg_settings, _spec(run=degraded)), "fake", "schedule")
|
||||
assert row["status"] == "degraded" and row["errors"] and "hunter22" not in row["errors"][0]
|
||||
row = runs.run_job(_sched(pg_settings, _spec(run=boom)), "fake", "schedule")
|
||||
assert row["status"] == "failed" and len(row["errors"]) == 1 and "topsecret9" not in row["errors"][0]
|
||||
assert row["finished_at"] is not None
|
||||
|
||||
|
||||
@needs_pg
|
||||
def test_ten_requests_one_run(pg_settings: Settings) -> None:
|
||||
with app_conn(pg_settings) as conn:
|
||||
for _ in range(10):
|
||||
conn.execute("INSERT INTO job_requests (job) VALUES ('fake')")
|
||||
calls: list[int] = []
|
||||
row = runs.run_job(_sched(pg_settings, _spec(run=lambda ctx: calls.append(1) or {})), "fake", "schedule")
|
||||
assert calls == [1] and row["trigger"] == "request"
|
||||
with app_conn(pg_settings) as conn:
|
||||
claimed = conn.execute(
|
||||
"SELECT count(*), count(DISTINCT claimed_run_id) FROM job_requests WHERE claimed_at IS NOT NULL"
|
||||
).fetchone()
|
||||
assert claimed == (10, 1)
|
||||
assert conn.execute("SELECT count(*) FROM job_requests WHERE claimed_at IS NULL").fetchone()[0] == 0
|
||||
assert len(_runs(pg_settings)) == 1
|
||||
|
||||
|
||||
@needs_pg
|
||||
def test_skips_when_the_lock_is_held(pg_settings: Settings) -> None:
|
||||
from packetstation import db
|
||||
|
||||
calls: list[int] = []
|
||||
with db.connect_as("worker", pg_settings, autocommit=True) as holder:
|
||||
assert holder.execute("SELECT pg_try_advisory_lock(7310, hashtext('fake'))").fetchone()[0]
|
||||
row = runs.run_job(_sched(pg_settings, _spec(run=lambda ctx: calls.append(1) or {})), "fake", "schedule")
|
||||
assert calls == [] and row["status"] == "skipped" and row["counts"] == {"reason": "already running"}
|
||||
row = runs.run_job(_sched(pg_settings, _spec(run=lambda ctx: calls.append(1) or {})), "fake", "schedule")
|
||||
assert calls == [1] and row["status"] == "ok"
|
||||
|
||||
|
||||
@needs_pg
|
||||
def test_concurrent_runs_one_skips(pg_settings: Settings) -> None:
|
||||
started, release = threading.Event(), threading.Event()
|
||||
|
||||
def slow(ctx: JobContext) -> dict[str, Any]:
|
||||
started.set()
|
||||
release.wait(10)
|
||||
return {}
|
||||
|
||||
sch = _sched(pg_settings, _spec(run=slow))
|
||||
t = threading.Thread(target=runs.run_job, args=(sch, "fake", "schedule"))
|
||||
t.start()
|
||||
try:
|
||||
assert started.wait(10)
|
||||
assert sch.running() == ["fake"]
|
||||
row = runs.run_job(_sched(pg_settings, _spec(run=lambda ctx: {})), "fake", "schedule")
|
||||
assert row["status"] == "skipped"
|
||||
finally:
|
||||
release.set()
|
||||
t.join(10)
|
||||
assert sorted(r[0] for r in _runs(pg_settings)) == ["ok", "skipped"]
|
||||
|
||||
|
||||
def _pause(settings: Settings, paused: bool) -> None:
|
||||
with app_conn(settings) as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO settings (key, value) VALUES ('feed', %s)"
|
||||
" ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value",
|
||||
(Jsonb({"paused": paused}),),
|
||||
)
|
||||
|
||||
|
||||
@needs_pg
|
||||
def test_pause_skips_scheduled_fires_but_requests_run(pg_settings: Settings) -> None:
|
||||
_pause(pg_settings, True)
|
||||
calls: list[str] = []
|
||||
spec = _spec(run=lambda ctx: calls.append(ctx.trigger) or {})
|
||||
row = runs.run_job(_sched(pg_settings, spec), "fake", "schedule")
|
||||
assert row["status"] == "skipped" and row["counts"] == {"reason": "paused"} and calls == []
|
||||
with app_conn(pg_settings) as conn:
|
||||
conn.execute("INSERT INTO job_requests (job) VALUES ('fake')")
|
||||
row = runs.run_job(_sched(pg_settings, spec), "fake", "schedule")
|
||||
assert row["status"] == "ok" and calls == ["request"]
|
||||
# a job that is not pausable runs on schedule while paused
|
||||
row = runs.run_job(
|
||||
_sched(pg_settings, _spec(run=lambda ctx: calls.append("np") or {}, pausable=False)), "fake", "schedule"
|
||||
)
|
||||
assert row["status"] == "ok" and calls[-1] == "np"
|
||||
_pause(pg_settings, False)
|
||||
row = runs.run_job(_sched(pg_settings, spec), "fake", "schedule")
|
||||
assert row["status"] == "ok" and calls[-1] == "schedule"
|
||||
|
||||
|
||||
@needs_pg
|
||||
def test_chain_after_a_run(pg_settings: Settings) -> None:
|
||||
feed = JobSpec(name="a", run=lambda ctx: {}, cadence=lambda fs, n: 60, pausable=True, chain=("b",))
|
||||
other = JobSpec(name="b", run=lambda ctx: {}, cadence=lambda fs, n: 60, pausable=True)
|
||||
sch = Scheduler(pg_settings, build="pytest", jobs={"a": feed, "b": other})
|
||||
runs.run_job(sch, "a", "schedule")
|
||||
assert sch._next_trigger.get("b") == "chain" # moved to now with trigger chain
|
||||
|
||||
|
||||
@needs_pg
|
||||
def test_settings_save_audits_and_notifies(pg_settings: Settings) -> None:
|
||||
from packetstation import db
|
||||
from packetstation.domain.projects import Who
|
||||
|
||||
with db.connect_as("worker", pg_settings, autocommit=True) as listener:
|
||||
listener.execute("LISTEN ps_settings")
|
||||
with app_conn(pg_settings) as conn:
|
||||
who = Who(user_id=None, session_id=None, role_snapshot="admin", ip=None)
|
||||
new = config.save(conn, who=who, patch={"interval_shop_minutes": 7})
|
||||
assert new.interval_shop_minutes == 7
|
||||
row = conn.execute("SELECT value, version FROM settings WHERE key = 'feed'").fetchone()
|
||||
assert row[0]["interval_shop_minutes"] == 7
|
||||
audit = conn.execute(
|
||||
"SELECT detail FROM audit_events WHERE action = 'feed.settings_changed' ORDER BY id DESC LIMIT 1"
|
||||
).fetchone()
|
||||
assert audit[0]["changed"]["interval_shop_minutes"] == {"from": 5, "to": 7}
|
||||
notes = list(listener.notifies(timeout=5, stop_after=1))
|
||||
assert notes and notes[0].channel == "ps_settings"
|
||||
@@ -247,7 +247,7 @@ def test_v_open_cos(pg_settings: Settings) -> None:
|
||||
conn.execute("INSERT INTO co_required_depts (co_id, dept_code) VALUES (%s, 'WH'), (%s, '03')", (co, co))
|
||||
conn.execute(verify, (co, "WH", ids["user"], ids["sig"]))
|
||||
held = new_co(conn, pk, 2, "visual", "pending", hold=True)
|
||||
new_co(conn, pk, 3, "drawing", "pending", hold=False)
|
||||
new_co(conn, pk, 3, "manual", "pending", hold=False) # 0004: one pending feed CO, visual or drawing
|
||||
rejected = new_co(conn, pk, 4, "manual", "rejected", hold=True)
|
||||
conn.execute("INSERT INTO co_required_depts (co_id, dept_code) VALUES (%s, '03')", (rejected,))
|
||||
rows = conn.execute(
|
||||
@@ -276,6 +276,8 @@ def test_one_pending_visual_co_per_packet(pg_settings: Settings) -> None:
|
||||
new_co(conn, ids["packet"], 3, "visual", "approved")
|
||||
with pytest.raises(psycopg.errors.UniqueViolation):
|
||||
new_co(conn, ids["packet"], 4, "visual", "pending")
|
||||
with pytest.raises(psycopg.errors.UniqueViolation):
|
||||
new_co(conn, ids["packet"], 4, "drawing", "pending") # 0004: drawing-origin counts too
|
||||
with pytest.raises(psycopg.errors.UniqueViolation):
|
||||
new_co(conn, ids["packet"], 2, "manual", "approved")
|
||||
|
||||
@@ -367,3 +369,99 @@ def test_app_can_write_every_table_and_read_every_view(pg_settings: Settings) ->
|
||||
for priv in ("SELECT", "INSERT", "UPDATE", "DELETE"):
|
||||
allowed = one(conn, "SELECT has_table_privilege('ps_app', %s, %s)", (view, priv))
|
||||
assert allowed is (priv == "SELECT"), (view, priv)
|
||||
|
||||
|
||||
FEED_TABLES = [
|
||||
"visual_batches",
|
||||
"visual_rows",
|
||||
"visual_materials",
|
||||
"visual_orders",
|
||||
"visual_order_links",
|
||||
"visual_labor",
|
||||
"visual_row_changes",
|
||||
"visual_material_changes",
|
||||
"visual_ignored_wos",
|
||||
"visual_resource_map",
|
||||
"drawing_lookups",
|
||||
"job_requests",
|
||||
"job_runs",
|
||||
"job_schedule",
|
||||
"worker_heartbeat",
|
||||
]
|
||||
|
||||
|
||||
def test_feed_tables_and_app_grants(pg_settings: Settings) -> None:
|
||||
with db.connect_as("migrate", pg_settings, autocommit=True) as conn:
|
||||
names = [r[0] for r in conn.execute("SELECT name FROM schema_migrations ORDER BY name").fetchall()]
|
||||
assert "0004_feed.sql" in names
|
||||
with app_conn(pg_settings) as conn:
|
||||
for table in FEED_TABLES:
|
||||
for priv in ("SELECT", "INSERT", "UPDATE", "DELETE"):
|
||||
assert one(conn, "SELECT has_table_privilege('ps_app', %s, %s)", (table, priv)) is True, (table, priv)
|
||||
jobs = [r[0] for r in conn.execute("SELECT job FROM job_schedule ORDER BY job").fetchall()]
|
||||
assert jobs == ["drawing-lookup", "housekeeping", "visual-feed"]
|
||||
assert one(conn, "SELECT target FROM visual_resource_map WHERE resource = 'WELD-PART'") == "03"
|
||||
assert one(conn, "SELECT target FROM visual_resource_map WHERE resource = 'Electical'") == "14"
|
||||
assert one(conn, "SELECT count(*) FROM visual_resource_map WHERE confirmed") == 0
|
||||
with pytest.raises(psycopg.errors.CheckViolation):
|
||||
conn.execute("INSERT INTO visual_resource_map (resource, target) VALUES ('X', 'nowhere')")
|
||||
|
||||
|
||||
def test_feed_columns_are_text_ids(pg_settings: Settings) -> None:
|
||||
with app_conn(pg_settings) as conn:
|
||||
cols = dict(
|
||||
conn.execute(
|
||||
"SELECT table_name || '.' || column_name, data_type FROM information_schema.columns"
|
||||
" WHERE table_schema = 'public' AND column_name IN ('wo_base', 'lot', 'split', 'sub', 'seq', 'piece',"
|
||||
" 'part', 'cust_order_id', 'dwg')"
|
||||
).fetchall()
|
||||
)
|
||||
for name, kind in cols.items():
|
||||
col = name.split(".")[1]
|
||||
if col in ("seq", "piece"):
|
||||
assert kind == "integer", name
|
||||
else:
|
||||
assert kind == "text", name
|
||||
for t, c in (
|
||||
("packets", "visual_cancelled_at"),
|
||||
("packets", "wo_gone_at"),
|
||||
("packet_work_orders", "rev"),
|
||||
("projects", "order_ref"),
|
||||
("units", "customer_visual"),
|
||||
("units", "order_pin"),
|
||||
):
|
||||
assert (
|
||||
one(
|
||||
conn,
|
||||
"SELECT count(*) FROM information_schema.columns WHERE table_name = %s AND column_name = %s",
|
||||
(t, c),
|
||||
)
|
||||
== 1
|
||||
), (t, c)
|
||||
|
||||
|
||||
def test_two_operations_of_one_work_order_on_one_packet(pg_settings: Settings) -> None:
|
||||
"""Amendment 1: the key includes seq; hand rows (seq NULL) stay one per key."""
|
||||
with app_conn(pg_settings) as conn:
|
||||
ids = seed(conn)
|
||||
for seq in (10, 20):
|
||||
conn.execute(
|
||||
"INSERT INTO packet_work_orders (packet_id, wo_base, lot, split, sub, seq) "
|
||||
"VALUES (%s, 'L900-02', '1', '0', '0', %s)",
|
||||
(ids["packet"], seq),
|
||||
)
|
||||
with pytest.raises(psycopg.errors.UniqueViolation):
|
||||
conn.execute(
|
||||
"INSERT INTO packet_work_orders (packet_id, wo_base, lot, split, sub, seq) "
|
||||
"VALUES (%s, 'L900-02', '1', '0', '0', 20)",
|
||||
(ids["packet"],),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO packet_work_orders (packet_id, wo_base, lot, split, sub) VALUES (%s, 'H1', '1', '0', '0')",
|
||||
(ids["packet"],),
|
||||
)
|
||||
with pytest.raises(psycopg.errors.UniqueViolation):
|
||||
conn.execute(
|
||||
"INSERT INTO packet_work_orders (packet_id, wo_base, lot, split, sub) VALUES (%s, 'H1', '1', '0', '0')",
|
||||
(ids["packet"],),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user