Phase 3: VISUAL feed and M-Files drawings in the worker
Migration 0004 (VISUAL and drawing caches, go-live ignored list, resource map, job tables, order-derived columns with provenance; work order key includes the operation sequence). The real worker on APScheduler with settings re-read each fire, pause, run now, advisory locks, job_runs and a heartbeat. The visual-feed job (five VisualKit reads, COPY and MERGE cache, suspect-batch guard, the apply in one transaction: drafts, change orders, retire-moved, work order gone, closed and cancelled observed, projects and units from order links, auto-release) and the fingerprint. The drawing-lookup job (queue, budget, download on version change, sha256 into assets, decision table, staging for rev approval, drawing-origin change orders). Clerical From VISUAL panel, flags, rev approval, hand uploads, change-order review; Admin Feed settings and resource map. Synthetic fixture generator and a fixture-driven worker in CI with a Playwright scenario. Docs updated. Verified: 3249 server tests and 13 Playwright scenarios on the docker-1 CI stack, 76 frontend tests, doctor at 0004; one feed and one drawing run against VISUAL DEV and the vault from the developer machine (counts in the changelog). Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
@@ -0,0 +1,291 @@
|
||||
"""A packet's BOM from VISUAL's material lines: the old matBom, typed.
|
||||
|
||||
Rules (Seth, 2026-10-08, as the old build):
|
||||
|
||||
- a line belongs to a leg when its work order key and operation seq match the leg and its drawing is blank or
|
||||
equals the leg's drawing ignoring spaces and case;
|
||||
- the same piece can come back more than once (one per header status): keep the best, R before F before U before
|
||||
anything else;
|
||||
- lines without a part are not BOM lines; a part-less line whose piece is a multiple of 10 is the group header of
|
||||
the next nine pieces of the same work order (`grp`);
|
||||
- the item is the piece number, suffixed `-<last dash part of the work order>` when the piece repeats;
|
||||
- WIP sub-assemblies (commodity WIP or warehouse ASY) are listed, marked sub and n/a (no heat needed);
|
||||
- `loc` is the warehouse (unless MAIN) and the location, distinct and non-empty, joined by a space.
|
||||
|
||||
`bom_lines.item` is an int in the database while the BOM item label is text ("10", "10-5GA"). `db_items` gives
|
||||
the int per line: the piece number when the label is a plain number used once, else the next free number above
|
||||
every piece. The text label goes into `cl_item`. `insert_bom` writes lines that way; agent D's "use VISUAL's BOM
|
||||
again" calls `bom_for_packet` and `insert_bom` so both paths store the same thing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable, Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from psycopg.rows import dict_row
|
||||
|
||||
from .types import Material, WoKey, integer, num, text
|
||||
|
||||
STATUS_RANK = {"R": 0, "F": 1, "U": 2}
|
||||
|
||||
MatIndex = dict[tuple[WoKey, int], list[Material]]
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class BomLine:
|
||||
item: str
|
||||
qty: Decimal
|
||||
part_id: str
|
||||
desc: str
|
||||
loc: str
|
||||
whs: str
|
||||
comm: str
|
||||
cons: bool
|
||||
sub: bool
|
||||
na: bool
|
||||
grp: str | None
|
||||
vkey: str
|
||||
issued: Decimal
|
||||
istat: int | None
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
"""JSON-ready (numbers as text) for change order `vis.bom`."""
|
||||
return {
|
||||
"item": self.item,
|
||||
"qty": str(self.qty),
|
||||
"part_id": self.part_id,
|
||||
"desc": self.desc,
|
||||
"loc": self.loc,
|
||||
"whs": self.whs,
|
||||
"comm": self.comm,
|
||||
"cons": self.cons,
|
||||
"sub": self.sub,
|
||||
"na": self.na,
|
||||
"grp": self.grp,
|
||||
"vkey": self.vkey,
|
||||
"issued": str(self.issued),
|
||||
"istat": self.istat,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: Mapping[str, Any]) -> BomLine:
|
||||
return cls(
|
||||
item=text(d.get("item")),
|
||||
qty=num(d.get("qty")),
|
||||
part_id=text(d.get("part_id")),
|
||||
desc=text(d.get("desc")),
|
||||
loc=text(d.get("loc")),
|
||||
whs=text(d.get("whs")),
|
||||
comm=text(d.get("comm")),
|
||||
cons=bool(d.get("cons")),
|
||||
sub=bool(d.get("sub")),
|
||||
na=bool(d.get("na")),
|
||||
grp=text(d.get("grp")) or None,
|
||||
vkey=text(d.get("vkey")),
|
||||
issued=num(d.get("issued")),
|
||||
istat=None if d.get("istat") is None else integer(d.get("istat")),
|
||||
)
|
||||
|
||||
|
||||
def dwg_norm(dwg: Any) -> str:
|
||||
return "".join(text(dwg).split()).upper()
|
||||
|
||||
|
||||
def index(materials: Iterable[Material] | None) -> MatIndex:
|
||||
"""Material lines by (leg key, seq)."""
|
||||
idx: MatIndex = {}
|
||||
for m in materials or ():
|
||||
idx.setdefault((m.key, m.seq), []).append(m)
|
||||
return idx
|
||||
|
||||
|
||||
def _leg_parts(leg: Any) -> tuple[WoKey, int, str]:
|
||||
"""(key, seq, dwg) from a candidates.Leg, or from a packet_work_orders row dict (wo_base, lot, split, sub, seq,
|
||||
dwg)."""
|
||||
if isinstance(leg, Mapping):
|
||||
key = WoKey.of(leg.get("wo_base"), leg.get("lot"), leg.get("split"), leg.get("sub"))
|
||||
return key, integer(leg.get("seq")), text(leg.get("dwg"))
|
||||
return leg.key, int(leg.seq), text(leg.dwg)
|
||||
|
||||
|
||||
def _rank(m: Material) -> int:
|
||||
return STATUS_RANK.get(text(m.st).upper()[:1], 3)
|
||||
|
||||
|
||||
def bom_for(legs: Sequence[Any], idx: MatIndex) -> list[BomLine]:
|
||||
"""The BOM of these legs (candidates.Leg objects or packet_work_orders dicts) from the material index."""
|
||||
lines: list[Material] = []
|
||||
seen: set[tuple[WoKey, int]] = set()
|
||||
for leg in legs:
|
||||
key, seq, dwg = _leg_parts(leg)
|
||||
k = (key, seq)
|
||||
if k in seen:
|
||||
continue
|
||||
seen.add(k)
|
||||
best: dict[int, Material] = {}
|
||||
for m in idx.get(k, []):
|
||||
if m.dwg and dwg and dwg_norm(m.dwg) != dwg_norm(dwg):
|
||||
continue
|
||||
o = best.get(m.piece)
|
||||
if o is None or _rank(m) < _rank(o):
|
||||
best[m.piece] = m
|
||||
lines.extend(best.values())
|
||||
lines.sort(key=lambda m: (m.wo, m.piece)) # stable: ties keep leg order
|
||||
out: list[BomLine] = []
|
||||
used: set[str] = set()
|
||||
for m in lines:
|
||||
if not m.part:
|
||||
continue
|
||||
grp = next(
|
||||
(
|
||||
h
|
||||
for h in lines
|
||||
if not h.part and h.wo == m.wo and h.piece % 10 == 0 and h.piece < m.piece < h.piece + 10
|
||||
),
|
||||
None,
|
||||
)
|
||||
item = str(m.piece)
|
||||
if item in used:
|
||||
item = f"{item}-{m.wo.split('-')[-1]}"
|
||||
used.add(item)
|
||||
sub = m.comm.upper() == "WIP" or m.whs.upper() == "ASY"
|
||||
locs: list[str] = []
|
||||
for part in ("" if m.whs.upper() == "MAIN" else m.whs, m.loc):
|
||||
if part and part not in locs:
|
||||
locs.append(part)
|
||||
out.append(
|
||||
BomLine(
|
||||
item=item,
|
||||
qty=m.qty,
|
||||
part_id=m.part,
|
||||
desc=m.desc,
|
||||
loc=" ".join(locs),
|
||||
whs=m.whs,
|
||||
comm=m.comm,
|
||||
cons=m.cons.upper() == "Y",
|
||||
sub=sub,
|
||||
na=sub,
|
||||
grp=str(grp.piece) if grp is not None else None,
|
||||
vkey=f"{m.wo}|{m.lot}|{m.sub}|{m.seq}|{m.piece}",
|
||||
issued=m.issued,
|
||||
istat=m.istat,
|
||||
)
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def db_items(bom: Sequence[BomLine]) -> list[int]:
|
||||
"""The int `bom_lines.item` per line: the piece when the label is a plain number used once, else the next
|
||||
free number above every piece number."""
|
||||
plain = [b.item for b in bom if b.item.isdigit()]
|
||||
taken: set[int] = set()
|
||||
out: list[int | None] = []
|
||||
for b in bom:
|
||||
if b.item.isdigit() and plain.count(b.item) == 1 and int(b.item) not in taken:
|
||||
n = int(b.item)
|
||||
taken.add(n)
|
||||
out.append(n)
|
||||
else:
|
||||
out.append(None)
|
||||
nxt = max(taken, default=0)
|
||||
for b in bom:
|
||||
head = b.item.split("-", 1)[0]
|
||||
if head.isdigit():
|
||||
nxt = max(nxt, int(head))
|
||||
result: list[int] = []
|
||||
for slot in out:
|
||||
if slot is not None:
|
||||
result.append(slot)
|
||||
continue
|
||||
nxt += 1
|
||||
while nxt in taken:
|
||||
nxt += 1
|
||||
taken.add(nxt)
|
||||
result.append(nxt)
|
||||
return result
|
||||
|
||||
|
||||
def insert_bom(conn: Any, packet_id: int, bom: Sequence[BomLine]) -> int:
|
||||
"""Insert VISUAL BOM lines (src 'visual'). cons is stored 'Y'/'N', sub 'Y' or NULL. Returns the count."""
|
||||
items = db_items(bom)
|
||||
for sort, (b, item) in enumerate(zip(bom, items, strict=True)):
|
||||
conn.execute(
|
||||
"INSERT INTO bom_lines (packet_id, item, cl_item, qty, part_id, description, loc, whs, comm, cons, sub,"
|
||||
" grp, vkey, na, issued, src, sort) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s,"
|
||||
" 'visual', %s)",
|
||||
(
|
||||
packet_id,
|
||||
item,
|
||||
b.item,
|
||||
b.qty,
|
||||
b.part_id[:60],
|
||||
b.desc[:240] or None,
|
||||
b.loc[:120] or None,
|
||||
b.whs or None,
|
||||
b.comm or None,
|
||||
"Y" if b.cons else "N",
|
||||
"Y" if b.sub else None,
|
||||
b.grp,
|
||||
b.vkey,
|
||||
b.na,
|
||||
b.issued,
|
||||
sort,
|
||||
),
|
||||
)
|
||||
return len(bom)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- from the cache
|
||||
|
||||
|
||||
def materials_for(conn: Any, keys: Iterable[WoKey]) -> list[Material]:
|
||||
"""Live cached material lines (gone_batch NULL) of these leg keys."""
|
||||
ks = list(dict.fromkeys(keys))
|
||||
if not ks:
|
||||
return []
|
||||
with conn.cursor(row_factory=dict_row) as cur:
|
||||
rows = cur.execute(
|
||||
"SELECT m.* FROM visual_materials m"
|
||||
" JOIN unnest(%s::text[], %s::text[], %s::text[], %s::text[]) AS k(b, l, sp, su)"
|
||||
" ON m.wo_base = k.b AND m.lot = k.l AND m.split = k.sp AND m.sub = k.su"
|
||||
" WHERE m.gone_batch IS NULL ORDER BY m.wo_base, m.lot, m.split, m.sub, m.seq, m.piece",
|
||||
([k.base for k in ks], [k.lot for k in ks], [k.split for k in ks], [k.sub for k in ks]),
|
||||
).fetchall()
|
||||
return [material_from_row(r) for r in rows]
|
||||
|
||||
|
||||
def material_from_row(r: Mapping[str, Any]) -> Material:
|
||||
return Material(
|
||||
wo=r["wo_base"],
|
||||
lot=r["lot"],
|
||||
split=r["split"],
|
||||
sub=r["sub"],
|
||||
seq=int(r["seq"]),
|
||||
piece=int(r["piece"]),
|
||||
part=r["part"],
|
||||
qty=num(r["qty"]),
|
||||
issued=num(r["issued"]),
|
||||
whs=r["whs"],
|
||||
loc=r["loc"],
|
||||
st=r["st"],
|
||||
istat=r["istat"],
|
||||
dwg=r["dwg"],
|
||||
desc=r["descr"],
|
||||
comm=r["comm"],
|
||||
cons=r["cons"],
|
||||
)
|
||||
|
||||
|
||||
def bom_for_packet(conn: Any, packet_id: int) -> list[BomLine]:
|
||||
"""VISUAL's BOM for a packet's legs, rebuilt from the cache (for "use VISUAL's BOM again")."""
|
||||
with conn.cursor(row_factory=dict_row) as cur:
|
||||
legs = cur.execute(
|
||||
"SELECT wo_base, lot, split, sub, seq, coalesce(dwg, '') AS dwg FROM packet_work_orders"
|
||||
" WHERE packet_id = %s AND seq IS NOT NULL ORDER BY sort, id",
|
||||
(packet_id,),
|
||||
).fetchall()
|
||||
keys = [WoKey.of(r["wo_base"], r["lot"], r["split"], r["sub"]) for r in legs]
|
||||
return bom_for(legs, index(materials_for(conn, keys)))
|
||||
Reference in New Issue
Block a user