Files
PacketStation/server/packetstation/feed/materials.py
T

292 lines
9.9 KiB
Python
Raw Normal View History

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