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