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