Files
PacketStation/server/packetstation/feed/candidates.py
T
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

244 lines
7.5 KiB
Python

"""VISUAL rows to packet candidates: the old visBuild, typed.
One candidate = unit + resource + drawing, holding every work order operation on that drawing. The key is
`f"{unit}|{res}|{dwg or '(no drawing)'}"`, which is `packets.visual_key`. Project = the work order base before its
first dash; unit = project + the lot zero-padded to 3. A non-numeric lot (7A) makes no candidate, as the old
build: it is counted in `Built.skipped_lots`. Resources route through the resource map: a shop department code,
`fold` (rides along on the packet of the same work order and lot), `ignore`, or no entry (counted as unmapped).
`MAT_*` with no map entry folds.
"""
from __future__ import annotations
from collections.abc import Collection, Iterable, Mapping, Sequence
from dataclasses import dataclass, field
from datetime import date
from decimal import Decimal
from ..text import RX_LOT, RX_PROJECT
from . import materials as mats
from .materials import BomLine
from .types import Material, Row, WoKey
FOLD = "fold"
IGNORE = "ignore"
NO_DRAWING = "(no drawing)"
__all__ = [
"FOLD",
"IGNORE",
"NO_DRAWING",
"BomLine",
"Built",
"Candidate",
"Folded",
"Leg",
"build",
"project_of",
"route",
"unit_number",
]
@dataclass(slots=True)
class Leg:
"""One work order operation on the packet."""
key: WoKey
seq: int
op: str
qty: Decimal
tgt: Decimal
dwg: str
rev: str
partspecs: str
st: str
@dataclass(slots=True)
class Folded:
key: WoKey
seq: int
op: str
tgt: Decimal
@dataclass(slots=True)
class Candidate:
key: str # f"{unit}|{res}|{dwg or '(no drawing)'}" == packets.visual_key
project: str
unit: str
lot: str
dept: str
op: str
drawing: str
rev: str
title: str
legs: list[Leg]
folded: list[Folded]
partspecs: list[str]
rel: date | None
want: date | None
bom: list[BomLine] | None # None when materials are unknown for this candidate
statuses: dict[WoKey, str] # header status per leg key
base: str # the first leg's wo base, for the order lookup
in_window: bool # at least one leg came from the window read
@property
def mat_known(self) -> bool:
return self.bom is not None
@property
def leg_keys(self) -> set[WoKey]:
return {lg.key for lg in self.legs}
@dataclass(slots=True)
class Built:
candidates: dict[str, Candidate]
unmapped: dict[str, int]
ignored: list[Row] # rows below the go-live date (not tracked)
skipped_lots: dict[str, int] = field(default_factory=dict) # f"{wo}/{lot}" -> rows (non-numeric lot)
def route(res: str, resource_map: Mapping[str, str]) -> str | None:
"""'fold' | 'ignore' | a dept code | None (no map entry). MAT_* with no entry folds."""
if res in resource_map:
return resource_map[res]
if res.upper().startswith("MAT_"):
return FOLD
return None
def project_of(wo: str) -> str:
"""'L489-05-5GA' -> 'L489' (upper-cased); a base with no dash is its own project."""
head = wo.partition("-")[0].strip()
return (head or wo).upper()
def unit_number(project: str, lot: str) -> str | None:
"""project + lot zero-padded to 3 when the lot is numeric; None for a non-numeric lot (the old unit_no)."""
lot = lot.strip()
if not lot.isdigit():
return None
padded = lot.zfill(3)
if not RX_LOT.match(padded):
return None
return project + padded
def _rev_wins(new: str, cur: str) -> bool:
"""The old rule: a numeric rev higher than the current wins; anything wins over an empty current."""
if not new or new == cur:
return False
if not cur:
return True
try:
return float(new) > float(cur)
except ValueError:
return False
def build(
rows: Sequence[Row],
materials: Iterable[Material] | None,
material_legs: Collection[WoKey] | None,
*,
resource_map: Mapping[str, str],
window: tuple[date, date],
golive: date | None,
departments: Collection[str],
tracked_keys: Collection[WoKey],
today: date | None = None,
) -> Built:
"""Group rows into candidates. See the module docstring and phase3-design "Candidates"."""
del today # kept for the contract signature; the window already carries it
tracked = set(tracked_keys)
depts = set(departments)
by_wo_lot: dict[tuple[str, str], list[Row]] = {}
for r in rows:
by_wo_lot.setdefault((r.wo, r.lot), []).append(r)
cands: dict[str, Candidate] = {}
unmapped: dict[str, int] = {}
ignored: list[Row] = []
skipped_lots: dict[str, int] = {}
leg_seen: dict[str, set[tuple[WoKey, int]]] = {}
fold_seen: dict[str, set[tuple[WoKey, int]]] = {}
for r in rows:
target = route(r.res, resource_map)
if target in (FOLD, IGNORE):
continue
is_tracked = r.key in tracked
in_window = r.st == "F" and r.rel is not None and window[0] <= r.rel <= window[1]
if not is_tracked and not in_window:
continue
if target is None:
unmapped[r.res] = unmapped.get(r.res, 0) + 1
continue
if depts and target not in depts:
continue
if not is_tracked and golive is not None and r.rel is not None and r.rel < golive:
ignored.append(r)
continue
project = project_of(r.wo)
unit = unit_number(project, r.lot) if RX_PROJECT.match(project) else None
if unit is None:
k = f"{r.wo}/{r.lot}"
skipped_lots[k] = skipped_lots.get(k, 0) + 1
continue
key = f"{unit}|{r.res}|{r.dwg or NO_DRAWING}"
c = cands.get(key)
if c is None:
c = Candidate(
key=key,
project=project,
unit=unit,
lot=unit[len(project) :],
dept=target,
op=r.res,
drawing=r.dwg,
rev=r.rev,
title=r.desc,
legs=[],
folded=[],
partspecs=[],
rel=r.rel,
want=r.want,
bom=None,
statuses={},
base=r.wo,
in_window=False,
)
cands[key] = c
leg_seen[key] = set()
fold_seen[key] = set()
c.in_window = c.in_window or in_window
if (r.key, r.seq) not in leg_seen[key]:
leg_seen[key].add((r.key, r.seq))
c.legs.append(
Leg(key=r.key, seq=r.seq, op=r.res, qty=r.qty, tgt=r.tgt, dwg=r.dwg, rev=r.rev, partspecs=r.ps, st=r.st)
)
c.statuses[r.key] = r.st
if r.rel is not None and (c.rel is None or r.rel < c.rel):
c.rel = r.rel
if r.want is not None and (c.want is None or r.want > c.want):
c.want = r.want
if _rev_wins(r.rev, c.rev):
c.rev = r.rev
for o in by_wo_lot.get((r.wo, r.lot), []):
if route(o.res, resource_map) == FOLD and (o.key, o.seq) not in fold_seen[key]:
fold_seen[key].add((o.key, o.seq))
c.folded.append(Folded(key=o.key, seq=o.seq, op=o.res, tgt=o.tgt))
if o.ps and o.ps not in c.partspecs:
c.partspecs.append(o.ps)
if material_legs is not None:
idx = mats.index(materials)
known = set(material_legs)
for c in cands.values():
if any(lg.key in known for lg in c.legs):
c.bom = mats.bom_for(c.legs, idx)
return Built(candidates=cands, unmapped=unmapped, ignored=ignored, skipped_lots=skipped_lots)