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

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

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-10-16 14:45:38 -06:00

344 lines
12 KiB
Python

"""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