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

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

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

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
2026-10-16 14:45:38 -06:00
co-authored by Claude Fable 5.1
parent 16950055e3
commit 591fe90076
103 changed files with 16063 additions and 151 deletions
+241
View File
@@ -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",
]
+343
View File
@@ -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
+351
View File
@@ -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")
+438
View File
@@ -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
+507
View File
@@ -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"
+215
View File
@@ -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
+112
View File
@@ -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")
+166
View File
@@ -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
+315
View File
@@ -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"
+99 -1
View File
@@ -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"],),
)