292 lines
9.9 KiB
Python
292 lines
9.9 KiB
Python
"""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)))
|