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 @@
|
||||
"""The VISUAL feed: settings, the read, the cache, candidates, fingerprints and the apply."""
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,564 @@
|
||||
"""The VISUAL cache in PostgreSQL: one batch per read, the rows and material lines as VISUAL last showed them,
|
||||
the orders and their links, labor per operation, and a change row for everything VISUAL added, changed, dropped
|
||||
or brought back.
|
||||
|
||||
`write_batch` runs in one transaction. Rows and materials go in through COPY into a temp table, then an upsert
|
||||
keyed on the typed key (wo_base, lot, split, sub, seq[, piece]), all text but seq and piece. Rows in play that this
|
||||
read did not return get `gone_batch`, unless the batch is suspect: no window rows where the previous batch had
|
||||
some, or more than 5 tracked keys missing and more than 10 % of them.
|
||||
|
||||
The apply reads back from here (`rows_for_apply`, `orders`, `labor_hours`), so it works on one consistent snapshot
|
||||
and keeps the last good orders when this run's order read failed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from collections.abc import Collection, Iterable, Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from psycopg.rows import dict_row
|
||||
from psycopg.types.json import Jsonb
|
||||
|
||||
from .fingerprint import canonical, norm_decimal
|
||||
from .materials import material_from_row
|
||||
from .types import FeedData, Material, Order, Row, WoKey, num
|
||||
|
||||
ROW_COLS = (
|
||||
"wo_base",
|
||||
"lot",
|
||||
"split",
|
||||
"sub",
|
||||
"seq",
|
||||
"res",
|
||||
"dwg",
|
||||
"rev",
|
||||
"ps",
|
||||
"descr",
|
||||
"qty",
|
||||
"tgt",
|
||||
"rel",
|
||||
"want",
|
||||
"st",
|
||||
"part",
|
||||
"barcode",
|
||||
)
|
||||
MAT_COLS = (
|
||||
"wo_base",
|
||||
"lot",
|
||||
"split",
|
||||
"sub",
|
||||
"seq",
|
||||
"piece",
|
||||
"part",
|
||||
"qty",
|
||||
"issued",
|
||||
"whs",
|
||||
"loc",
|
||||
"st",
|
||||
"istat",
|
||||
"dwg",
|
||||
"descr",
|
||||
"comm",
|
||||
"cons",
|
||||
)
|
||||
SUSPECT_MISSING_MIN = 5
|
||||
SUSPECT_MISSING_SHARE = Decimal("0.10")
|
||||
|
||||
|
||||
@dataclass
|
||||
class Batch:
|
||||
id: int
|
||||
suspect: bool
|
||||
counts: dict[str, Any] = field(default_factory=dict)
|
||||
reason: str = ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- row dicts and hashes
|
||||
|
||||
|
||||
def _d(v: Any) -> str | None:
|
||||
return v.isoformat() if isinstance(v, date) else None
|
||||
|
||||
|
||||
def row_doc(r: Row) -> dict[str, Any]:
|
||||
"""The row as a change-row dict: text values, numbers as text, dates ISO."""
|
||||
return {
|
||||
"wo": r.wo,
|
||||
"lot": r.lot,
|
||||
"split": r.split,
|
||||
"sub": r.sub,
|
||||
"seq": r.seq,
|
||||
"res": r.res,
|
||||
"dwg": r.dwg,
|
||||
"rev": r.rev,
|
||||
"ps": r.ps,
|
||||
"desc": r.desc,
|
||||
"qty": norm_decimal(r.qty),
|
||||
"tgt": norm_decimal(r.tgt),
|
||||
"rel": _d(r.rel),
|
||||
"want": _d(r.want),
|
||||
"st": r.st,
|
||||
"part": r.part,
|
||||
"barcode": r.barcode,
|
||||
}
|
||||
|
||||
|
||||
def mat_doc(m: Material) -> dict[str, Any]:
|
||||
return {
|
||||
"wo": m.wo,
|
||||
"lot": m.lot,
|
||||
"split": m.split,
|
||||
"sub": m.sub,
|
||||
"seq": m.seq,
|
||||
"piece": m.piece,
|
||||
"part": m.part,
|
||||
"qty": norm_decimal(m.qty),
|
||||
"issued": norm_decimal(m.issued),
|
||||
"whs": m.whs,
|
||||
"loc": m.loc,
|
||||
"st": m.st,
|
||||
"istat": m.istat,
|
||||
"dwg": m.dwg,
|
||||
"desc": m.desc,
|
||||
"comm": m.comm,
|
||||
"cons": m.cons,
|
||||
}
|
||||
|
||||
|
||||
def doc_hash(doc: Mapping[str, Any]) -> bytes:
|
||||
return hashlib.sha256(canonical(dict(doc))).digest()
|
||||
|
||||
|
||||
# SQL that rebuilds the same dict from a cached row, for the change rows' `before`.
|
||||
_ROW_BEFORE = (
|
||||
"jsonb_build_object('wo', v.wo_base, 'lot', v.lot, 'split', v.split, 'sub', v.sub, 'seq', v.seq, 'res', v.res,"
|
||||
" 'dwg', v.dwg, 'rev', v.rev, 'ps', v.ps, 'desc', v.descr, 'qty', v.qty::text, 'tgt', v.tgt::text,"
|
||||
" 'rel', v.rel, 'want', v.want, 'st', v.st, 'part', v.part, 'barcode', v.barcode)"
|
||||
)
|
||||
_MAT_BEFORE = (
|
||||
"jsonb_build_object('wo', v.wo_base, 'lot', v.lot, 'split', v.split, 'sub', v.sub, 'seq', v.seq,"
|
||||
" 'piece', v.piece, 'part', v.part, 'qty', v.qty::text, 'issued', v.issued::text, 'whs', v.whs, 'loc', v.loc,"
|
||||
" 'st', v.st, 'istat', v.istat, 'dwg', v.dwg, 'desc', v.descr, 'comm', v.comm, 'cons', v.cons)"
|
||||
)
|
||||
|
||||
|
||||
def _key_arrays(keys: Iterable[WoKey]) -> tuple[list[str], list[str], list[str], list[str]]:
|
||||
ks = list(dict.fromkeys(keys))
|
||||
return [k.base for k in ks], [k.lot for k in ks], [k.split for k in ks], [k.sub for k in ks]
|
||||
|
||||
|
||||
_KEYS_UNNEST = "unnest(%s::text[], %s::text[], %s::text[], %s::text[]) AS k(b, l, sp, su)"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- write
|
||||
|
||||
|
||||
def is_suspect(window_rows: int, prev_window_rows: int | None, missing: int, tracked: int) -> str:
|
||||
"""The reason a batch is suspect, or ''."""
|
||||
if window_rows == 0 and (prev_window_rows or 0) > 0:
|
||||
return f"the window read returned nothing (the previous read had {prev_window_rows})"
|
||||
if missing > SUSPECT_MISSING_MIN and Decimal(missing) > SUSPECT_MISSING_SHARE * tracked:
|
||||
return f"{missing} of {tracked} tracked work orders missing"
|
||||
return ""
|
||||
|
||||
|
||||
def write_batch(conn: Any, run_id: int | None, data: FeedData, source_desc: str, window: tuple[date, date]) -> Batch:
|
||||
"""Record one read: the batch row, rows, materials, orders, labor and the change rows. One transaction."""
|
||||
since, until = window
|
||||
with conn.transaction():
|
||||
prev = conn.execute("SELECT window_rows FROM visual_batches ORDER BY id DESC LIMIT 1").fetchone()
|
||||
missing = len(set(data.tracked_missing))
|
||||
tracked_n = len(set(data.tracked)) or data.tracked_count
|
||||
reason = is_suspect(data.window_count, prev[0] if prev else None, missing, tracked_n)
|
||||
suspect = bool(reason)
|
||||
server, _, database = source_desc.partition(" / ")
|
||||
bid = int(
|
||||
conn.execute(
|
||||
"INSERT INTO visual_batches (run_id, server, database, site, since, until, window_rows, tracked_keys,"
|
||||
" missing_keys, suspect) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) RETURNING id",
|
||||
(
|
||||
run_id,
|
||||
str(data.meta.get("server") or server),
|
||||
str(data.meta.get("database") or database) or None,
|
||||
data.meta.get("site"),
|
||||
since,
|
||||
until,
|
||||
data.window_count,
|
||||
tracked_n,
|
||||
missing,
|
||||
suspect,
|
||||
),
|
||||
).fetchone()[0]
|
||||
)
|
||||
counts: dict[str, Any] = {"rows": len(data.rows), "suspect": int(suspect)}
|
||||
if reason:
|
||||
counts["suspect_reason"] = reason
|
||||
counts.update(_write_rows(conn, bid, data, window, suspect))
|
||||
if data.materials is not None:
|
||||
counts.update(_write_materials(conn, bid, data, suspect))
|
||||
counts["materials"] = len(data.materials)
|
||||
if data.orders_by_base is not None and data.orders_by_project is not None:
|
||||
counts["orders"] = _write_orders(conn, bid, data)
|
||||
if data.labor is not None:
|
||||
counts["labor_legs"] = _write_labor(conn, data)
|
||||
counts["labor_read"] = 1
|
||||
conn.execute("UPDATE visual_batches SET counts = %s WHERE id = %s", (Jsonb(counts), bid))
|
||||
return Batch(id=bid, suspect=suspect, counts=counts, reason=reason)
|
||||
|
||||
|
||||
def _write_rows(conn: Any, bid: int, data: FeedData, window: tuple[date, date], suspect: bool) -> dict[str, int]:
|
||||
uniq: dict[tuple[WoKey, int], Row] = {}
|
||||
for r in data.rows:
|
||||
uniq[(r.key, r.seq)] = r
|
||||
conn.execute(
|
||||
"CREATE TEMP TABLE t_rows (wo_base text, lot text, split text, sub text, seq int, res text, dwg text,"
|
||||
" rev text, ps text, descr text, qty numeric, tgt numeric, rel date, want date, st text, part text,"
|
||||
" barcode text, row_hash bytea, doc jsonb) ON COMMIT DROP"
|
||||
)
|
||||
with (
|
||||
conn.cursor() as cur,
|
||||
cur.copy(
|
||||
"COPY t_rows (wo_base, lot, split, sub, seq, res, dwg, rev, ps, descr, qty, tgt, rel, want, st, part,"
|
||||
" barcode, row_hash, doc) FROM STDIN"
|
||||
) as cp,
|
||||
):
|
||||
for r in uniq.values():
|
||||
doc = row_doc(r)
|
||||
cp.write_row(
|
||||
(
|
||||
r.wo,
|
||||
r.lot,
|
||||
r.split,
|
||||
r.sub,
|
||||
r.seq,
|
||||
r.res,
|
||||
r.dwg,
|
||||
r.rev,
|
||||
r.ps,
|
||||
r.desc,
|
||||
r.qty,
|
||||
r.tgt,
|
||||
r.rel,
|
||||
r.want,
|
||||
r.st,
|
||||
r.part,
|
||||
r.barcode or None,
|
||||
doc_hash(doc),
|
||||
Jsonb(doc),
|
||||
)
|
||||
)
|
||||
out: dict[str, int] = {}
|
||||
join = (
|
||||
"visual_rows v ON v.wo_base = t.wo_base AND v.lot = t.lot AND v.split = t.split AND v.sub = t.sub"
|
||||
" AND v.seq = t.seq"
|
||||
)
|
||||
out["rows_added"] = conn.execute(
|
||||
"INSERT INTO visual_row_changes (batch_id, wo_base, lot, split, sub, seq, kind, after)" # noqa: S608
|
||||
f" SELECT %s, t.wo_base, t.lot, t.split, t.sub, t.seq, 'added', t.doc FROM t_rows t LEFT JOIN {join}"
|
||||
" WHERE v.id IS NULL",
|
||||
(bid,),
|
||||
).rowcount
|
||||
out["rows_returned"] = conn.execute(
|
||||
"INSERT INTO visual_row_changes (batch_id, wo_base, lot, split, sub, seq, kind, before, after)" # noqa: S608
|
||||
f" SELECT %s, t.wo_base, t.lot, t.split, t.sub, t.seq, 'returned', {_ROW_BEFORE}, t.doc FROM t_rows t"
|
||||
f" JOIN {join} WHERE v.gone_batch IS NOT NULL",
|
||||
(bid,),
|
||||
).rowcount
|
||||
out["rows_changed"] = conn.execute(
|
||||
"INSERT INTO visual_row_changes (batch_id, wo_base, lot, split, sub, seq, kind, before, after)" # noqa: S608
|
||||
f" SELECT %s, t.wo_base, t.lot, t.split, t.sub, t.seq, 'changed', {_ROW_BEFORE}, t.doc FROM t_rows t"
|
||||
f" JOIN {join} WHERE v.gone_batch IS NULL AND v.row_hash <> t.row_hash",
|
||||
(bid,),
|
||||
).rowcount
|
||||
conn.execute(
|
||||
"INSERT INTO visual_rows (wo_base, lot, split, sub, seq, res, dwg, rev, ps, descr, qty, tgt, rel, want, st,"
|
||||
" part, barcode, row_hash, first_batch, last_batch)"
|
||||
" SELECT wo_base, lot, split, sub, seq, res, dwg, rev, ps, descr, qty, tgt, rel, want, st, part, barcode,"
|
||||
" row_hash, %s, %s FROM t_rows"
|
||||
" ON CONFLICT (wo_base, lot, split, sub, seq) DO UPDATE SET res = EXCLUDED.res, dwg = EXCLUDED.dwg,"
|
||||
" rev = EXCLUDED.rev, ps = EXCLUDED.ps, descr = EXCLUDED.descr, qty = EXCLUDED.qty, tgt = EXCLUDED.tgt,"
|
||||
" rel = EXCLUDED.rel, want = EXCLUDED.want, st = EXCLUDED.st, part = EXCLUDED.part,"
|
||||
" barcode = EXCLUDED.barcode, row_hash = EXCLUDED.row_hash, last_batch = EXCLUDED.last_batch,"
|
||||
" gone_batch = NULL",
|
||||
(bid, bid),
|
||||
)
|
||||
out["rows_gone"] = 0
|
||||
if not suspect:
|
||||
b, lot, sp, su = _key_arrays(data.tracked)
|
||||
out["rows_gone"] = conn.execute(
|
||||
"WITH gone AS (UPDATE visual_rows v SET gone_batch = %s" # noqa: S608
|
||||
" WHERE v.gone_batch IS NULL AND v.last_batch <> %s"
|
||||
f" AND (EXISTS (SELECT 1 FROM {_KEYS_UNNEST} WHERE k.b = v.wo_base AND k.l = v.lot AND k.sp = v.split"
|
||||
" AND k.su = v.sub) OR (v.st = 'F' AND v.rel BETWEEN %s AND %s)) RETURNING v.*)"
|
||||
" INSERT INTO visual_row_changes (batch_id, wo_base, lot, split, sub, seq, kind, before)"
|
||||
f" SELECT %s, v.wo_base, v.lot, v.split, v.sub, v.seq, 'gone', {_ROW_BEFORE} FROM gone v",
|
||||
(bid, bid, b, lot, sp, su, window[0], window[1], bid),
|
||||
).rowcount
|
||||
return out
|
||||
|
||||
|
||||
def _write_materials(conn: Any, bid: int, data: FeedData, suspect: bool) -> dict[str, int]:
|
||||
uniq: dict[tuple[WoKey, int, int], Material] = {}
|
||||
for m in data.materials or []:
|
||||
uniq[(m.key, m.seq, m.piece)] = m
|
||||
conn.execute(
|
||||
"CREATE TEMP TABLE t_mats (wo_base text, lot text, split text, sub text, seq int, piece int, part text,"
|
||||
" qty numeric, issued numeric, whs text, loc text, st text, istat int, dwg text, descr text, comm text,"
|
||||
" cons text, mat_hash bytea, doc jsonb) ON COMMIT DROP"
|
||||
)
|
||||
with (
|
||||
conn.cursor() as cur,
|
||||
cur.copy(
|
||||
"COPY t_mats (wo_base, lot, split, sub, seq, piece, part, qty, issued, whs, loc, st, istat, dwg, descr,"
|
||||
" comm, cons, mat_hash, doc) FROM STDIN"
|
||||
) as cp,
|
||||
):
|
||||
for m in uniq.values():
|
||||
doc = mat_doc(m)
|
||||
cp.write_row(
|
||||
(
|
||||
m.wo,
|
||||
m.lot,
|
||||
m.split,
|
||||
m.sub,
|
||||
m.seq,
|
||||
m.piece,
|
||||
m.part,
|
||||
m.qty,
|
||||
m.issued,
|
||||
m.whs,
|
||||
m.loc,
|
||||
m.st,
|
||||
m.istat,
|
||||
m.dwg,
|
||||
m.desc,
|
||||
m.comm,
|
||||
m.cons,
|
||||
doc_hash(doc),
|
||||
Jsonb(doc),
|
||||
)
|
||||
)
|
||||
join = (
|
||||
"visual_materials v ON v.wo_base = t.wo_base AND v.lot = t.lot AND v.split = t.split AND v.sub = t.sub"
|
||||
" AND v.seq = t.seq AND v.piece = t.piece"
|
||||
)
|
||||
cols = "batch_id, wo_base, lot, split, sub, seq, piece, kind"
|
||||
out: dict[str, int] = {}
|
||||
out["mats_added"] = conn.execute(
|
||||
f"INSERT INTO visual_material_changes ({cols}, after)" # noqa: S608
|
||||
f" SELECT %s, t.wo_base, t.lot, t.split, t.sub, t.seq, t.piece, 'added', t.doc FROM t_mats t LEFT JOIN {join}"
|
||||
" WHERE v.id IS NULL",
|
||||
(bid,),
|
||||
).rowcount
|
||||
out["mats_returned"] = conn.execute(
|
||||
f"INSERT INTO visual_material_changes ({cols}, before, after)" # noqa: S608
|
||||
f" SELECT %s, t.wo_base, t.lot, t.split, t.sub, t.seq, t.piece, 'returned', {_MAT_BEFORE}, t.doc"
|
||||
f" FROM t_mats t JOIN {join} WHERE v.gone_batch IS NOT NULL",
|
||||
(bid,),
|
||||
).rowcount
|
||||
out["mats_changed"] = conn.execute(
|
||||
f"INSERT INTO visual_material_changes ({cols}, before, after)" # noqa: S608
|
||||
f" SELECT %s, t.wo_base, t.lot, t.split, t.sub, t.seq, t.piece, 'changed', {_MAT_BEFORE}, t.doc"
|
||||
f" FROM t_mats t JOIN {join} WHERE v.gone_batch IS NULL AND v.mat_hash <> t.mat_hash",
|
||||
(bid,),
|
||||
).rowcount
|
||||
conn.execute(
|
||||
"INSERT INTO visual_materials (wo_base, lot, split, sub, seq, piece, part, qty, issued, whs, loc, st, istat,"
|
||||
" dwg, descr, comm, cons, mat_hash, first_batch, last_batch)"
|
||||
" SELECT wo_base, lot, split, sub, seq, piece, part, qty, issued, whs, loc, st, istat, dwg, descr, comm,"
|
||||
" cons, mat_hash, %s, %s FROM t_mats"
|
||||
" ON CONFLICT (wo_base, lot, split, sub, seq, piece) DO UPDATE SET part = EXCLUDED.part, qty = EXCLUDED.qty,"
|
||||
" issued = EXCLUDED.issued, whs = EXCLUDED.whs, loc = EXCLUDED.loc, st = EXCLUDED.st,"
|
||||
" istat = EXCLUDED.istat, dwg = EXCLUDED.dwg, descr = EXCLUDED.descr, comm = EXCLUDED.comm,"
|
||||
" cons = EXCLUDED.cons, mat_hash = EXCLUDED.mat_hash, last_batch = EXCLUDED.last_batch, gone_batch = NULL",
|
||||
(bid, bid),
|
||||
)
|
||||
out["mats_gone"] = 0
|
||||
if not suspect:
|
||||
bases = sorted({r.wo for r in data.rows} | {m.wo for m in data.materials or []})
|
||||
out["mats_gone"] = conn.execute(
|
||||
"WITH gone AS (UPDATE visual_materials v SET gone_batch = %s WHERE v.gone_batch IS NULL" # noqa: S608
|
||||
" AND v.last_batch <> %s AND v.wo_base = ANY(%s) RETURNING v.*)"
|
||||
f" INSERT INTO visual_material_changes ({cols}, before)"
|
||||
f" SELECT %s, v.wo_base, v.lot, v.split, v.sub, v.seq, v.piece, 'gone', {_MAT_BEFORE} FROM gone v",
|
||||
(bid, bid, bases, bid),
|
||||
).rowcount
|
||||
return out
|
||||
|
||||
|
||||
def _write_orders(conn: Any, bid: int, data: FeedData) -> int:
|
||||
by_base = data.orders_by_base or {}
|
||||
by_project = data.orders_by_project or {}
|
||||
every: dict[str, Order] = {}
|
||||
for lists in (by_base, by_project):
|
||||
for orders in lists.values():
|
||||
for o in orders:
|
||||
every.setdefault(o.cust_order_id, o)
|
||||
for o in every.values():
|
||||
conn.execute(
|
||||
"INSERT INTO visual_orders (cust_order_id, status, customer_id, customer_name, customer_po_ref,"
|
||||
" order_date, desired_ship_date, promise_date, last_batch) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)"
|
||||
" ON CONFLICT (cust_order_id) DO UPDATE SET status = EXCLUDED.status, customer_id = EXCLUDED.customer_id,"
|
||||
" customer_name = EXCLUDED.customer_name, customer_po_ref = EXCLUDED.customer_po_ref,"
|
||||
" order_date = coalesce(EXCLUDED.order_date, visual_orders.order_date),"
|
||||
" desired_ship_date = EXCLUDED.desired_ship_date, promise_date = EXCLUDED.promise_date,"
|
||||
" last_batch = EXCLUDED.last_batch",
|
||||
(
|
||||
o.cust_order_id,
|
||||
o.status,
|
||||
o.customer_id,
|
||||
o.customer_name,
|
||||
o.customer_po_ref,
|
||||
o.order_date,
|
||||
o.desired_ship_date,
|
||||
o.promise_date,
|
||||
bid,
|
||||
),
|
||||
)
|
||||
bases = sorted({r.wo for r in data.rows} | set(by_base))
|
||||
projects = sorted({r.wo.partition("-")[0] or r.wo for r in data.rows} | set(by_project))
|
||||
conn.execute(
|
||||
"DELETE FROM visual_order_links WHERE (kind = 'base' AND key = ANY(%s))"
|
||||
" OR (kind = 'project' AND key = ANY(%s))",
|
||||
(bases, projects),
|
||||
)
|
||||
n = 0
|
||||
for kind, lists in (("base", by_base), ("project", by_project)):
|
||||
for key, orders in lists.items():
|
||||
seen: set[str] = set()
|
||||
for rank, o in enumerate(orders):
|
||||
if o.cust_order_id in seen:
|
||||
continue
|
||||
seen.add(o.cust_order_id)
|
||||
conn.execute(
|
||||
"INSERT INTO visual_order_links (kind, key, cust_order_id, rank, lines, top_jobs, last_batch)"
|
||||
" VALUES (%s, %s, %s, %s, %s, %s, %s)",
|
||||
(kind, key, o.cust_order_id, rank, o.lines, o.top_jobs, bid),
|
||||
)
|
||||
n += 1
|
||||
return len(every)
|
||||
|
||||
|
||||
def _write_labor(conn: Any, data: FeedData) -> int:
|
||||
n = 0
|
||||
for lg in data.labor or []:
|
||||
conn.execute(
|
||||
"INSERT INTO visual_labor (wo_base, lot, split, sub, seq, hours, tickets, last_ticket, observed_at)"
|
||||
" VALUES (%s, %s, %s, %s, %s, %s, %s, %s, now())"
|
||||
" ON CONFLICT (wo_base, lot, split, sub, seq) DO UPDATE SET hours = EXCLUDED.hours,"
|
||||
" tickets = EXCLUDED.tickets, last_ticket = EXCLUDED.last_ticket, observed_at = now()",
|
||||
(lg.wo, lg.lot, lg.split, lg.sub, lg.seq, lg.hours, lg.tickets, lg.last_ticket),
|
||||
)
|
||||
n += 1
|
||||
return n
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- read back
|
||||
|
||||
|
||||
def row_from_cache(r: Mapping[str, Any]) -> Row:
|
||||
return Row(
|
||||
wo=r["wo_base"],
|
||||
lot=r["lot"],
|
||||
split=r["split"],
|
||||
sub=r["sub"],
|
||||
seq=int(r["seq"]),
|
||||
res=r["res"],
|
||||
dwg=r["dwg"],
|
||||
rev=r["rev"],
|
||||
ps=r["ps"],
|
||||
desc=r["descr"],
|
||||
qty=num(r["qty"]),
|
||||
tgt=num(r["tgt"]),
|
||||
rel=r["rel"],
|
||||
want=r["want"],
|
||||
st=r["st"],
|
||||
part=r["part"],
|
||||
barcode=r["barcode"] or "",
|
||||
)
|
||||
|
||||
|
||||
def rows_for_apply(conn: Any, keys_in_play: Collection[WoKey]) -> tuple[list[Row], list[Material], set[WoKey]]:
|
||||
"""Live cached rows and material lines of every work order (base and lot) among these keys, so folded
|
||||
operations and planner instructions of the same work order come along. material_legs = legs with live
|
||||
material lines whose status is F or R."""
|
||||
pairs = sorted({(k.base, k.lot) for k in keys_in_play})
|
||||
if not pairs:
|
||||
return [], [], set()
|
||||
bases = [p[0] for p in pairs]
|
||||
lots = [p[1] for p in pairs]
|
||||
sql_join = "JOIN unnest(%s::text[], %s::text[]) AS k(b, l) ON v.wo_base = k.b AND v.lot = k.l"
|
||||
with conn.cursor(row_factory=dict_row) as cur:
|
||||
rows = cur.execute(
|
||||
f"SELECT v.* FROM visual_rows v {sql_join} WHERE v.gone_batch IS NULL" # noqa: S608
|
||||
" ORDER BY v.wo_base, v.lot, v.split, v.sub, v.seq",
|
||||
(bases, lots),
|
||||
).fetchall()
|
||||
mats = cur.execute(
|
||||
f"SELECT v.* FROM visual_materials v {sql_join} WHERE v.gone_batch IS NULL" # noqa: S608
|
||||
" ORDER BY v.wo_base, v.lot, v.split, v.sub, v.seq, v.piece",
|
||||
(bases, lots),
|
||||
).fetchall()
|
||||
materials = [material_from_row(m) for m in mats]
|
||||
legs = {m.key for m in materials if m.st in ("F", "R")}
|
||||
return [row_from_cache(r) for r in rows], materials, legs
|
||||
|
||||
|
||||
def orders(
|
||||
conn: Any, *, bases: Iterable[str] | None = None, projects: Iterable[str] | None = None
|
||||
) -> tuple[dict[str, list[Order]], dict[str, list[Order]]]:
|
||||
"""The cached order links as (by_base, by_project), in rank order. Without filters: every link."""
|
||||
params: list[Any] = []
|
||||
where = ""
|
||||
if bases is not None or projects is not None:
|
||||
where = " WHERE (l.kind = 'base' AND l.key = ANY(%s)) OR (l.kind = 'project' AND l.key = ANY(%s))"
|
||||
params = [sorted(set(bases or ())), sorted(set(projects or ()))]
|
||||
with conn.cursor(row_factory=dict_row) as cur:
|
||||
rows = cur.execute(
|
||||
"SELECT l.kind, l.key, l.rank, l.lines, l.top_jobs, o.* FROM visual_order_links l" # noqa: S608
|
||||
f" JOIN visual_orders o ON o.cust_order_id = l.cust_order_id{where} ORDER BY l.kind, l.key, l.rank",
|
||||
params,
|
||||
).fetchall()
|
||||
by_base: dict[str, list[Order]] = {}
|
||||
by_project: dict[str, list[Order]] = {}
|
||||
for r in rows:
|
||||
o = Order(
|
||||
cust_order_id=r["cust_order_id"],
|
||||
status=r["status"],
|
||||
customer_id=r["customer_id"],
|
||||
customer_name=r["customer_name"],
|
||||
customer_po_ref=r["customer_po_ref"],
|
||||
order_date=r["order_date"],
|
||||
desired_ship_date=r["desired_ship_date"],
|
||||
promise_date=r["promise_date"],
|
||||
lines=list(r["lines"] or []),
|
||||
top_jobs=list(r["top_jobs"] or []),
|
||||
)
|
||||
(by_base if r["kind"] == "base" else by_project).setdefault(r["key"], []).append(o)
|
||||
return by_base, by_project
|
||||
|
||||
|
||||
def labor_hours(conn: Any) -> dict[tuple[WoKey, int], Decimal]:
|
||||
"""Cached ticket hours per (leg, seq)."""
|
||||
out: dict[tuple[WoKey, int], Decimal] = {}
|
||||
for r in conn.execute("SELECT wo_base, lot, split, sub, seq, hours FROM visual_labor"):
|
||||
out[(WoKey(r[0], r[1], r[2], r[3]), int(r[4]))] = num(r[5])
|
||||
return out
|
||||
|
||||
|
||||
def last_labor_read(conn: Any) -> Any:
|
||||
"""When labor was last read (a batch that carried labor), or None."""
|
||||
r = conn.execute("SELECT max(started_at) FROM visual_batches WHERE counts ? 'labor_read'").fetchone()
|
||||
return r[0] if r else None
|
||||
|
||||
|
||||
def finish_batch(conn: Any, batch_id: int, counts: Mapping[str, Any]) -> None:
|
||||
"""Merge the apply's counts into the batch row and set finished_at."""
|
||||
conn.execute(
|
||||
"UPDATE visual_batches SET finished_at = now(), counts = counts || %s WHERE id = %s",
|
||||
(Jsonb(dict(counts)), batch_id),
|
||||
)
|
||||
@@ -0,0 +1,243 @@
|
||||
"""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)
|
||||
@@ -0,0 +1,382 @@
|
||||
"""Change orders opened by the feed (origin visual) and by the drawing job (origin drawing). The old visCoNeed,
|
||||
coAutoChanges and visDiff.
|
||||
|
||||
At most one pending feed change order per packet, whatever its origin (visual or drawing): when one is pending
|
||||
it is re-aimed (reason, vis, waiting, staged, target, minor) instead of a second one being opened. A drawing
|
||||
re-release that meets a pending visual change order stages the new drawing on it and adds its line to the reason.
|
||||
The required departments are filled at approval (the API's co_touched), not here.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, date, datetime
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from psycopg.rows import dict_row
|
||||
from psycopg.types.json import Jsonb
|
||||
|
||||
from ..domain.pipeline import next_counter
|
||||
from ..text import LIMITS, clean_text
|
||||
from .fingerprint import bom_sig, drawing_key, leg_key, norm_decimal
|
||||
from .writes import SysWrite, packets_jsonable
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .candidates import Candidate
|
||||
|
||||
FEED_ORIGINS = ("visual", "drawing")
|
||||
ECN = {"visual": "VISUAL", "drawing": "M-FILES"}
|
||||
|
||||
|
||||
@dataclass
|
||||
class CoResult:
|
||||
co_id: int
|
||||
co_no: int
|
||||
fresh: bool
|
||||
reaimed: bool
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now(UTC).isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def pending_visual(conn: Any, packet_id: int, *, lock: bool = False) -> dict[str, Any] | None:
|
||||
"""The single pending change order with origin visual or drawing, or None."""
|
||||
sql = (
|
||||
"SELECT *, origin::text AS origin, review::text AS review FROM change_orders"
|
||||
" WHERE packet_id = %s AND origin IN ('visual', 'drawing') AND review = 'pending' ORDER BY co_no DESC LIMIT 1"
|
||||
)
|
||||
if lock:
|
||||
sql += " FOR UPDATE"
|
||||
with conn.cursor(row_factory=dict_row) as cur:
|
||||
r = cur.execute(sql, (packet_id,)).fetchone()
|
||||
return dict(r) if r else None
|
||||
|
||||
|
||||
def last_feed_co(conn: Any, packet_id: int) -> dict[str, Any] | None:
|
||||
"""The newest change order with origin visual, any review."""
|
||||
with conn.cursor(row_factory=dict_row) as cur:
|
||||
r = cur.execute(
|
||||
"SELECT *, origin::text AS origin, review::text AS review FROM change_orders"
|
||||
" WHERE packet_id = %s AND origin = 'visual' ORDER BY co_no DESC LIMIT 1",
|
||||
(packet_id,),
|
||||
).fetchone()
|
||||
return dict(r) if r else None
|
||||
|
||||
|
||||
def _j(value: Any) -> Any:
|
||||
return None if value is None else Jsonb(packets_jsonable(value))
|
||||
|
||||
|
||||
def _same_target(old_vis: Mapping[str, Any] | None, vis: Mapping[str, Any]) -> bool:
|
||||
o = old_vis or {}
|
||||
return drawing_key(o.get("to_dwg")) == drawing_key(vis.get("to_dwg")) and str(o.get("to_rev") or "") == str(
|
||||
vis.get("to_rev") or ""
|
||||
)
|
||||
|
||||
|
||||
def open_or_reaim(
|
||||
sw: SysWrite,
|
||||
*,
|
||||
origin: str,
|
||||
reason: str,
|
||||
vis: dict[str, Any],
|
||||
waiting: dict[str, Any] | None,
|
||||
staged: dict[str, Any] | None,
|
||||
target_fp: str | None,
|
||||
minor: bool,
|
||||
required: list[str] | None = None,
|
||||
) -> CoResult:
|
||||
"""Open the packet's feed change order, or re-aim the pending one. Call it inside `system_write`."""
|
||||
del required # filled at approval by the API (co_touched)
|
||||
if origin not in FEED_ORIGINS:
|
||||
raise ValueError(f"origin must be visual or drawing, not {origin!r}")
|
||||
conn = sw.conn
|
||||
reason = clean_text(reason, LIMITS["reason"])
|
||||
pend = pending_visual(conn, sw.id, lock=True)
|
||||
if pend is not None:
|
||||
old_vis = dict(pend.get("vis") or {})
|
||||
moved = list(old_vis.get("moved") or [])
|
||||
if origin == "drawing" and pend["origin"] == "visual":
|
||||
# A drawing re-release meets a pending VISUAL change: stage the drawing on it, keep its content.
|
||||
new_vis = old_vis
|
||||
new_vis["moved"] = [*moved, {"at": _now(), "rev": vis.get("to_rev"), "dwg": vis.get("to_dwg")}]
|
||||
old_reason = str(pend.get("reason") or "")
|
||||
new_reason = (
|
||||
old_reason if reason in old_reason else clean_text(f"{old_reason} · {reason}", LIMITS["reason"])
|
||||
)
|
||||
conn.execute(
|
||||
"UPDATE change_orders SET reason = %s, vis = %s, staged = coalesce(%s, staged),"
|
||||
" waiting = CASE WHEN %s::jsonb IS NULL THEN waiting ELSE NULL END, row_version = row_version + 1"
|
||||
" WHERE id = %s",
|
||||
(new_reason, _j(new_vis), _j(staged), _j(staged), pend["id"]),
|
||||
)
|
||||
label = f"Change order {pend['co_no']}: drawing from M-Files staged"
|
||||
else:
|
||||
new_vis = dict(vis)
|
||||
new_vis["moved"] = [*moved, {"at": _now(), "rev": vis.get("to_rev"), "dwg": vis.get("to_dwg")}]
|
||||
keep = staged is None and pend.get("staged") is not None and _same_target(old_vis, vis)
|
||||
new_staged = staged if staged is not None else (pend.get("staged") if keep else None)
|
||||
new_waiting = None if (keep or staged is not None) else waiting
|
||||
conn.execute(
|
||||
"UPDATE change_orders SET origin = %s::co_origin, reason = %s, vis = %s, waiting = %s, staged = %s,"
|
||||
" target_fp = %s, minor = %s, ecn = %s, row_version = row_version + 1 WHERE id = %s",
|
||||
(
|
||||
origin,
|
||||
reason,
|
||||
_j(new_vis),
|
||||
_j(new_waiting),
|
||||
_j(new_staged),
|
||||
target_fp,
|
||||
minor,
|
||||
ECN[origin],
|
||||
pend["id"],
|
||||
),
|
||||
)
|
||||
label = (
|
||||
f"Change order {pend['co_no']} updated: VISUAL changed again before Clerical approved"
|
||||
if origin == "visual"
|
||||
else f"Change order {pend['co_no']} updated: new drawing from M-Files"
|
||||
)
|
||||
sw.log(label, reason)
|
||||
sw.changed("change_orders")
|
||||
sw.detail.update({"co_no": pend["co_no"], "co_id": pend["id"], "reaimed": True})
|
||||
return CoResult(co_id=int(pend["id"]), co_no=int(pend["co_no"]), fresh=False, reaimed=True)
|
||||
|
||||
co_no = next_counter(conn, sw.id, "co")
|
||||
row = conn.execute(
|
||||
"INSERT INTO change_orders (packet_id, co_no, origin, review, minor, ecn, reason, issued_by, vis, waiting,"
|
||||
" staged, target_fp) VALUES (%s, %s, %s::co_origin, 'pending', %s, %s, %s, NULL, %s, %s, %s, %s) RETURNING id",
|
||||
(sw.id, co_no, origin, minor, ECN[origin], reason, _j(vis), _j(waiting), _j(staged), target_fp),
|
||||
).fetchone()
|
||||
label = (
|
||||
f"Change order {co_no}: change from VISUAL waiting on Clerical approval"
|
||||
if origin == "visual"
|
||||
else f"Change order {co_no}: new drawing from M-Files waiting on Clerical approval"
|
||||
)
|
||||
sw.log(label, reason)
|
||||
sw.changed("change_orders")
|
||||
sw.detail.update({"co_no": co_no, "co_id": int(row[0]), "fresh": True})
|
||||
return CoResult(co_id=int(row[0]), co_no=co_no, fresh=True, reaimed=False)
|
||||
|
||||
|
||||
def withdraw(sw: SysWrite, co_id: int, reason: str) -> None:
|
||||
"""A pending feed change order VISUAL no longer asks for: review -> rejected by nobody, with the reason."""
|
||||
reason = clean_text(reason, LIMITS["reason"])
|
||||
r = sw.conn.execute(
|
||||
"UPDATE change_orders SET review = 'rejected', rejected_at = now(), rejected_by = NULL, rejected_reason = %s,"
|
||||
" row_version = row_version + 1 WHERE id = %s AND packet_id = %s AND review = 'pending' RETURNING co_no",
|
||||
(reason, co_id, sw.id),
|
||||
).fetchone()
|
||||
if r is None:
|
||||
sw.skip()
|
||||
return
|
||||
sw.log(f"Change order {r[0]} withdrawn", reason)
|
||||
sw.changed("change_orders")
|
||||
sw.detail.update({"co_no": int(r[0]), "co_id": co_id, "withdrawn": True})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- the human diff (the old visDiff + matDiff)
|
||||
|
||||
|
||||
def packet_view(conn: Any, packet_id: int) -> dict[str, Any]:
|
||||
"""What the packet holds now, for `diff_text`: legs, rev, drawing, want, the VISUAL BOM and manual_bom."""
|
||||
with conn.cursor(row_factory=dict_row) as cur:
|
||||
p = cur.execute(
|
||||
"SELECT rev, drawing, visual_want, visual, status::text AS status FROM packets WHERE id = %s",
|
||||
(packet_id,),
|
||||
).fetchone()
|
||||
wos = cur.execute(
|
||||
"SELECT wo_base, lot, split, sub, seq, qty, target_hrs FROM packet_work_orders WHERE packet_id = %s"
|
||||
" ORDER BY sort, id",
|
||||
(packet_id,),
|
||||
).fetchall()
|
||||
bom = cur.execute(
|
||||
"SELECT coalesce(cl_item, item::text) AS item, part_id, qty, src FROM bom_lines WHERE packet_id = %s"
|
||||
" ORDER BY sort, item",
|
||||
(packet_id,),
|
||||
).fetchall()
|
||||
p = dict(p or {})
|
||||
visual = p.get("visual") or {}
|
||||
return {
|
||||
"status": p.get("status"),
|
||||
"rev": p.get("rev"),
|
||||
"drawing": p.get("drawing"),
|
||||
"want": p.get("visual_want"),
|
||||
"legs": [dict(w) for w in wos],
|
||||
"bom": [dict(b) for b in bom],
|
||||
"manual_bom": bool(visual.get("manual_bom")),
|
||||
}
|
||||
|
||||
|
||||
def _n(v: Any) -> str:
|
||||
return norm_decimal(v) or "0"
|
||||
|
||||
|
||||
def _items(items: Sequence[str]) -> str:
|
||||
head = ", ".join(items[:8])
|
||||
return head + ("..." if len(items) > 8 else "")
|
||||
|
||||
|
||||
def mat_diff(old: Sequence[Mapping[str, Any]], new: Sequence[Any]) -> list[str]:
|
||||
"""The old matDiff: materials added, removed and changed, item by item."""
|
||||
o = {str(b["item"]): b for b in old if b.get("src") == "visual"}
|
||||
n = {b.item: b for b in new}
|
||||
hand = sum(1 for b in old if b.get("src") != "visual")
|
||||
if not o:
|
||||
if not (hand or n):
|
||||
return []
|
||||
if hand:
|
||||
return [f"BOM by hand ({hand} line{'' if hand == 1 else 's'}) replaced by VISUAL ({len(n)} lines)"]
|
||||
return [f"materials: {len(n)} line{'' if len(n) == 1 else 's'} from VISUAL"]
|
||||
add = [k for k in n if k not in o]
|
||||
rem = [k for k in o if k not in n]
|
||||
chg = [
|
||||
k
|
||||
for k in n
|
||||
if k in o and (str(o[k].get("part_id") or "") != n[k].part_id or _n(o[k].get("qty")) != _n(n[k].qty))
|
||||
]
|
||||
d: list[str] = []
|
||||
if add:
|
||||
d.append(f"materials added: item{'s' if len(add) > 1 else ''} {_items(add)}")
|
||||
if rem:
|
||||
d.append(f"materials removed in VISUAL: item{'s' if len(rem) > 1 else ''} {_items(rem)}")
|
||||
for k in chg[:8]:
|
||||
a, b = o[k], n[k]
|
||||
if str(a.get("part_id") or "") != b.part_id:
|
||||
d.append(f"item {k}: {a.get('part_id') or '-'} -> {b.part_id or '-'}")
|
||||
else:
|
||||
d.append(f"item {k}: qty {_n(a.get('qty'))} -> {_n(b.qty)}")
|
||||
if len(chg) > 8:
|
||||
more = len(chg) - 8
|
||||
d.append(f"{more} more material change{'' if more == 1 else 's'}")
|
||||
return d
|
||||
|
||||
|
||||
def diff_text(before: Mapping[str, Any], c: Candidate) -> list[str]:
|
||||
"""Human lines for what VISUAL changed on a packet (`before` is `packet_view`)."""
|
||||
d: list[str] = []
|
||||
old = {
|
||||
leg_key(w["wo_base"], w["lot"], w["split"], w["sub"], w["seq"]): w
|
||||
for w in before.get("legs") or []
|
||||
if w.get("seq") is not None
|
||||
}
|
||||
new_keys: set[str] = set()
|
||||
for lg in c.legs:
|
||||
k = leg_key(lg.key.base, lg.key.lot, lg.key.split, lg.key.sub, lg.seq)
|
||||
new_keys.add(k)
|
||||
o = old.get(k)
|
||||
if o is None:
|
||||
d.append(f"work order {lg.key.base} added")
|
||||
continue
|
||||
if _n(o.get("target_hrs")) != _n(lg.tgt):
|
||||
d.append(f"{lg.key.base} target {_n(o.get('target_hrs'))} h -> {_n(lg.tgt)} h")
|
||||
if _n(o.get("qty")) != _n(lg.qty):
|
||||
d.append(f"{lg.key.base} qty {_n(o.get('qty'))} -> {_n(lg.qty)}")
|
||||
for k, o in old.items():
|
||||
if k not in new_keys:
|
||||
d.append(f"work order {o['wo_base']} no longer in VISUAL")
|
||||
if drawing_key(before.get("drawing")) != drawing_key(c.drawing) and c.drawing:
|
||||
d.append(f"drawing {before.get('drawing') or '-'} -> {c.drawing}")
|
||||
if str(before.get("rev") or "") != str(c.rev or ""):
|
||||
d.append(f"drawing rev {before.get('rev') or '-'} -> {c.rev or '-'}")
|
||||
want_old = before.get("want")
|
||||
want_old_s = want_old.isoformat() if isinstance(want_old, date) else str(want_old or "")
|
||||
want_new_s = c.want.isoformat() if c.want else ""
|
||||
if want_old_s != want_new_s:
|
||||
d.append(f"want date {want_old_s or '-'} -> {want_new_s or '-'}")
|
||||
hand = bool(before.get("manual_bom")) and (before.get("status") == "draft" or not c.bom)
|
||||
if c.bom is not None and not hand and bom_sig(view_bom(before)) != bom_sig(c.bom):
|
||||
d.extend(mat_diff(before.get("bom") or [], c.bom))
|
||||
return d
|
||||
|
||||
|
||||
def view_bom(before: Mapping[str, Any]) -> list[dict[str, Any]]:
|
||||
"""The packet's BOM in matSig shape. Hand lines get a prefix so they always differ from VISUAL's."""
|
||||
out = []
|
||||
for b in before.get("bom") or []:
|
||||
item = str(b.get("item"))
|
||||
out.append(
|
||||
{
|
||||
"item": item if b.get("src") == "visual" else f"dwg:{item}",
|
||||
"part_id": b.get("part_id"),
|
||||
"qty": b.get("qty"),
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def vis_for(
|
||||
c: Candidate, fp_parts: Mapping[str, str], before: Mapping[str, Any], changes: list[str], target_fp: str
|
||||
) -> dict[str, Any]:
|
||||
"""The change order `vis` payload that approval applies."""
|
||||
from_dwg = str(before.get("drawing") or "")
|
||||
from_rev = str(before.get("rev") or "")
|
||||
to_dwg = c.drawing or from_dwg
|
||||
to_rev = c.rev or ""
|
||||
dwg_change = (bool(to_rev) and to_rev != from_rev) or (
|
||||
bool(c.drawing) and drawing_key(c.drawing) != drawing_key(from_dwg)
|
||||
)
|
||||
return {
|
||||
"from_dwg": from_dwg,
|
||||
"from_rev": from_rev,
|
||||
"to_dwg": to_dwg,
|
||||
"to_rev": to_rev,
|
||||
"dwg_change": dwg_change,
|
||||
"changes": [x for x in changes if not x.startswith("drawing rev ")],
|
||||
"sig": target_fp,
|
||||
"legs": legs_json(c),
|
||||
"bom": [b.as_dict() for b in c.bom] if c.bom is not None else None,
|
||||
"want": c.want.isoformat() if c.want else None,
|
||||
"rel": c.rel.isoformat() if c.rel else None,
|
||||
"folded": folded_json(c),
|
||||
"partspecs": list(c.partspecs),
|
||||
"fp_parts": dict(fp_parts),
|
||||
}
|
||||
|
||||
|
||||
def co_reason(vis: Mapping[str, Any]) -> str:
|
||||
"""The change order reason, as the old coAutoChanges wrote it."""
|
||||
if vis.get("dwg_change"):
|
||||
fd, td = str(vis.get("from_dwg") or ""), str(vis.get("to_dwg") or "")
|
||||
what = f"{fd} -> {td}" if fd and drawing_key(fd) != drawing_key(td) else td
|
||||
return f"VISUAL changed the drawing: {what} Rev {vis.get('from_rev') or '?'} -> Rev {vis.get('to_rev')}"
|
||||
other = list(vis.get("changes") or [])
|
||||
more = f" (+{len(other) - 3} more)" if len(other) > 3 else ""
|
||||
return f"VISUAL changed the packet: {'; '.join(other[:3])}{more}"
|
||||
|
||||
|
||||
def legs_json(c: Candidate) -> list[dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"wo_base": lg.key.base,
|
||||
"lot": lg.key.lot,
|
||||
"split": lg.key.split,
|
||||
"sub": lg.key.sub,
|
||||
"seq": lg.seq,
|
||||
"op": lg.op,
|
||||
"qty": str(lg.qty),
|
||||
"target_hrs": str(lg.tgt),
|
||||
"dwg": lg.dwg,
|
||||
"rev": lg.rev,
|
||||
"partspecs": lg.partspecs,
|
||||
"st": lg.st,
|
||||
}
|
||||
for lg in c.legs
|
||||
]
|
||||
|
||||
|
||||
def folded_json(c: Candidate) -> list[dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"wo_base": f.key.base,
|
||||
"lot": f.key.lot,
|
||||
"split": f.key.split,
|
||||
"sub": f.key.sub,
|
||||
"seq": f.seq,
|
||||
"op": f.op,
|
||||
"target_hrs": str(f.tgt),
|
||||
}
|
||||
for f in c.folded
|
||||
]
|
||||
@@ -0,0 +1,226 @@
|
||||
"""Feed settings: one `settings` row with key `feed`, a JSON object read by the worker on every fire.
|
||||
|
||||
Unknown keys are ignored on load and missing keys take the default, so the row may be partial. A save
|
||||
validates, bumps the row version, writes an audit row and NOTIFYs `ps_settings` so the worker reschedules.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from datetime import date, datetime, time, timedelta
|
||||
from typing import Any
|
||||
|
||||
from psycopg.types.json import Jsonb
|
||||
from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator, model_validator
|
||||
|
||||
from .. import audit
|
||||
|
||||
KEY = "feed"
|
||||
SETTINGS_CHANNEL = "ps_settings"
|
||||
_HHMM = re.compile(r"^([01][0-9]|2[0-3]):[0-5][0-9]$")
|
||||
_DEPT = re.compile(r"^[A-Za-z0-9]{1,10}$")
|
||||
|
||||
|
||||
class SettingsError(ValueError):
|
||||
"""A patch the model refuses. `errors` holds one short line per problem."""
|
||||
|
||||
def __init__(self, errors: list[str]):
|
||||
super().__init__("; ".join(errors))
|
||||
self.errors = errors
|
||||
|
||||
|
||||
class FeedSettings(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
interval_shop_minutes: int = Field(default=5, ge=1, le=1440)
|
||||
interval_off_minutes: int = Field(default=30, ge=1, le=1440)
|
||||
shop_days: list[int] = Field(default_factory=lambda: [0, 1, 2, 3, 4]) # Monday = 0, site time
|
||||
shop_start: str = "06:00" # HH:MM site time
|
||||
shop_end: str = "18:00"
|
||||
window_back_days: int = Field(default=14, ge=0, le=120)
|
||||
window_ahead_days: int = Field(default=30, ge=0, le=365)
|
||||
golive_date: date | None = None # work orders released before this date are ignored
|
||||
departments: list[str] = Field(default_factory=list) # shop codes; empty = every department the map routes to
|
||||
drawing_recheck_hours: int = Field(default=4, ge=1, le=168)
|
||||
drawing_budget: int = Field(default=60, ge=1, le=500)
|
||||
drawing_interval_minutes: int = Field(default=30, ge=1, le=1440)
|
||||
labor_tickets: bool = True # read labor tickets (at most every 30 minutes)
|
||||
auto_release: bool = True
|
||||
auto_release_max: int = Field(default=25, ge=0, le=500)
|
||||
paused: bool = False # stops scheduled feed, drawing and auto-release runs; "run now" still works
|
||||
|
||||
@field_validator("shop_days")
|
||||
@classmethod
|
||||
def _days(cls, v: list[int]) -> list[int]:
|
||||
if any(d < 0 or d > 6 for d in v):
|
||||
raise ValueError("shop days are 0 (Monday) to 6 (Sunday)")
|
||||
return sorted(set(v))
|
||||
|
||||
@field_validator("shop_start", "shop_end")
|
||||
@classmethod
|
||||
def _hhmm(cls, v: str) -> str:
|
||||
v = v.strip()
|
||||
if not _HHMM.match(v):
|
||||
raise ValueError("a time is HH:MM, 00:00 to 23:59")
|
||||
return v
|
||||
|
||||
@field_validator("departments")
|
||||
@classmethod
|
||||
def _depts(cls, v: list[str]) -> list[str]:
|
||||
out: list[str] = []
|
||||
for d in v:
|
||||
d = str(d).strip()
|
||||
if not _DEPT.match(d):
|
||||
raise ValueError("a department is its short code, like 03")
|
||||
if d not in out:
|
||||
out.append(d)
|
||||
return out
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _hours(self) -> FeedSettings:
|
||||
if self.shop_start == self.shop_end:
|
||||
raise ValueError("shop_start and shop_end must differ")
|
||||
return self
|
||||
|
||||
|
||||
FIELDS = tuple(FeedSettings.model_fields)
|
||||
|
||||
|
||||
def _clock(hhmm: str) -> time:
|
||||
h, m = hhmm.split(":")
|
||||
return time(int(h), int(m))
|
||||
|
||||
|
||||
def _errors(e: ValidationError) -> list[str]:
|
||||
out = []
|
||||
for err in e.errors():
|
||||
loc = ".".join(str(x) for x in err.get("loc", ()))
|
||||
msg = str(err.get("msg", "invalid")).removeprefix("Value error, ")
|
||||
out.append(f"{loc}: {msg}" if loc else msg)
|
||||
return out
|
||||
|
||||
|
||||
def from_value(value: Any) -> FeedSettings:
|
||||
"""The model from a stored value. A bad stored value keeps its good fields; the rest take the default."""
|
||||
if not isinstance(value, dict):
|
||||
return FeedSettings()
|
||||
try:
|
||||
return FeedSettings.model_validate(value)
|
||||
except ValidationError:
|
||||
good: dict[str, Any] = {}
|
||||
for k in FIELDS:
|
||||
if k not in value:
|
||||
continue
|
||||
try:
|
||||
FeedSettings.model_validate({**good, k: value[k]})
|
||||
except ValidationError:
|
||||
continue
|
||||
good[k] = value[k]
|
||||
return FeedSettings.model_validate(good)
|
||||
|
||||
|
||||
def load(conn: Any) -> FeedSettings:
|
||||
"""The current feed settings; the defaults when the row is missing."""
|
||||
row = conn.execute("SELECT value FROM settings WHERE key = %s", (KEY,)).fetchone()
|
||||
return from_value(row[0] if row else None)
|
||||
|
||||
|
||||
def load_versioned(conn: Any) -> tuple[FeedSettings, int, Any, Any]:
|
||||
"""(settings, version, updated_at, updated_by); version 0 when the row is missing."""
|
||||
row = conn.execute("SELECT value, version, updated_at, updated_by FROM settings WHERE key = %s", (KEY,)).fetchone()
|
||||
if row is None:
|
||||
return FeedSettings(), 0, None, None
|
||||
return from_value(row[0]), int(row[1]), row[2], row[3]
|
||||
|
||||
|
||||
def seed(conn: Any) -> bool:
|
||||
"""Write the defaults when the row is missing. True when it wrote."""
|
||||
cur = conn.execute(
|
||||
"INSERT INTO settings (key, value, version, updated_at) VALUES (%s, %s, 1, now()) ON CONFLICT (key) DO NOTHING",
|
||||
(KEY, Jsonb(FeedSettings().model_dump(mode="json"))),
|
||||
)
|
||||
return bool(cur.rowcount)
|
||||
|
||||
|
||||
def validate_patch(current: FeedSettings, patch: dict[str, Any]) -> FeedSettings:
|
||||
"""The settings after a partial change. Raises SettingsError, also for a key that is not a setting."""
|
||||
unknown = sorted(k for k in patch if k not in FIELDS)
|
||||
if unknown:
|
||||
raise SettingsError([f"{k}: not a feed setting" for k in unknown])
|
||||
try:
|
||||
return FeedSettings.model_validate({**current.model_dump(mode="json"), **patch})
|
||||
except ValidationError as e:
|
||||
raise SettingsError(_errors(e)) from None
|
||||
|
||||
|
||||
def save(conn: Any, *, who: Any, patch: dict[str, Any]) -> FeedSettings:
|
||||
"""Validate and store a partial change. Raises SettingsError. A patch that changes nothing writes nothing.
|
||||
|
||||
`who` has user_id, session_id, role_snapshot, ip and it_reason (domain.projects.Who).
|
||||
"""
|
||||
with conn.transaction():
|
||||
row = conn.execute("SELECT value FROM settings WHERE key = %s FOR UPDATE", (KEY,)).fetchone()
|
||||
current = from_value(row[0] if row else None)
|
||||
new = validate_patch(current, patch)
|
||||
before, after = current.model_dump(mode="json"), new.model_dump(mode="json")
|
||||
changed = {k: {"from": before[k], "to": after[k]} for k in FIELDS if before[k] != after[k]}
|
||||
if not changed and row is not None:
|
||||
return new
|
||||
version = conn.execute(
|
||||
"INSERT INTO settings (key, value, version, updated_at, updated_by) VALUES (%s, %s, 1, now(), %s)"
|
||||
" ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, version = settings.version + 1,"
|
||||
" updated_at = now(), updated_by = EXCLUDED.updated_by RETURNING version",
|
||||
(KEY, Jsonb(after), who.user_id),
|
||||
).fetchone()[0]
|
||||
audit.append(
|
||||
conn,
|
||||
action="feed.settings_changed",
|
||||
user_id=who.user_id,
|
||||
session_id=getattr(who, "session_id", None),
|
||||
role_snapshot=getattr(who, "role_snapshot", None),
|
||||
subject_type="settings",
|
||||
subject_id=KEY,
|
||||
detail={"changed": changed},
|
||||
it_reason=getattr(who, "it_reason", None),
|
||||
ip=getattr(who, "ip", None),
|
||||
)
|
||||
notify_settings(conn, int(version))
|
||||
return new
|
||||
|
||||
|
||||
def notify_settings(conn: Any, version: int | None = None) -> None:
|
||||
"""Tell the worker the settings changed. Inside a transaction it is delivered on commit."""
|
||||
payload = json.dumps({"key": KEY, "version": version}, separators=(",", ":"))
|
||||
conn.execute("SELECT ps_notify(%s, %s)", (SETTINGS_CHANNEL, payload))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- derived values (pure)
|
||||
|
||||
|
||||
def window(settings: FeedSettings, today: date) -> tuple[date, date]:
|
||||
"""(since, until) for the released read, from the site-local date."""
|
||||
return today - timedelta(days=settings.window_back_days), today + timedelta(days=settings.window_ahead_days)
|
||||
|
||||
|
||||
def in_shop_hours(settings: FeedSettings, now_site: datetime) -> bool:
|
||||
"""True on a shop day from shop_start up to shop_end, site time.
|
||||
|
||||
An end before the start spans midnight; the hours after midnight belong to the day the shift started.
|
||||
"""
|
||||
start, end = _clock(settings.shop_start), _clock(settings.shop_end)
|
||||
t = now_site.time().replace(tzinfo=None)
|
||||
day = now_site.weekday()
|
||||
if start < end:
|
||||
return day in settings.shop_days and start <= t < end
|
||||
if t >= start:
|
||||
return day in settings.shop_days
|
||||
if t < end:
|
||||
return (day - 1) % 7 in settings.shop_days
|
||||
return False
|
||||
|
||||
|
||||
def interval_seconds(settings: FeedSettings, now_site: datetime) -> int:
|
||||
"""The feed's cadence now: the shop interval inside shop hours, the off interval outside."""
|
||||
minutes = settings.interval_shop_minutes if in_shop_hours(settings, now_site) else settings.interval_off_minutes
|
||||
return minutes * 60
|
||||
@@ -0,0 +1,210 @@
|
||||
"""The candidate fingerprint: what VISUAL says about a packet, hashed per part, to decide update, CO or nothing.
|
||||
|
||||
Parts (each the sha256 hex of its canonical JSON):
|
||||
|
||||
- major: `legs` (the sorted leg keys), `qty` (per leg), `rev`, `drawing` (upper-cased, spaces removed), `bom`
|
||||
(sorted `[item, part_id, qty]`, the old matSig);
|
||||
- minor: `tgt` (target hours per leg), `want` (the want date).
|
||||
|
||||
Never in it: rel, folded, partspecs, title, statuses, issued, whs, loc, orders, labor, dept, op. A candidate whose
|
||||
materials are unknown (`bom is None`) has no `bom` part; `classify` skips a part the new side lacks and `merge`
|
||||
carries the old one forward, so a failed materials read never looks like a BOM change.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import unicodedata
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .candidates import Candidate
|
||||
|
||||
MAJOR = ("legs", "qty", "rev", "drawing", "bom")
|
||||
MINOR = ("tgt", "want")
|
||||
PARTS = MAJOR + MINOR
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- normalisation
|
||||
|
||||
|
||||
def norm_text(value: Any) -> str | None:
|
||||
"""NFC, CRLF and CR to LF, trimmed; '' and None both None."""
|
||||
if value is None:
|
||||
return None
|
||||
s = value if isinstance(value, str) else str(value)
|
||||
s = unicodedata.normalize("NFC", s.replace("\r\n", "\n").replace("\r", "\n")).strip()
|
||||
return s or None
|
||||
|
||||
|
||||
def norm_id(value: Any) -> str | None:
|
||||
"""An ID (base, lot, split, sub, part, drawing): normalised text, upper-cased. Stays text."""
|
||||
s = norm_text(value)
|
||||
return s.upper() if s is not None else None
|
||||
|
||||
|
||||
def norm_decimal(value: Any) -> str | None:
|
||||
"""A number as text with no exponent and no trailing zeros: 2, 2.0 and Decimal('2.00') are all '2'."""
|
||||
if value is None or value == "":
|
||||
return None
|
||||
d = value if isinstance(value, Decimal) else Decimal(str(value))
|
||||
if d.is_nan() or d.is_infinite():
|
||||
return None
|
||||
if d == 0:
|
||||
return "0"
|
||||
s = format(d.normalize(), "f")
|
||||
if "." in s:
|
||||
s = s.rstrip("0").rstrip(".")
|
||||
return s
|
||||
|
||||
|
||||
def normalise(value: Any) -> Any:
|
||||
"""The canonical form of a JSON-like value."""
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
return norm_text(value)
|
||||
if isinstance(value, int | float | Decimal):
|
||||
return norm_decimal(value)
|
||||
if isinstance(value, datetime):
|
||||
return value.isoformat()
|
||||
if isinstance(value, date):
|
||||
return value.isoformat()
|
||||
if isinstance(value, Mapping):
|
||||
return {str(k): normalise(v) for k, v in value.items()}
|
||||
if isinstance(value, list | tuple | set | frozenset):
|
||||
return [normalise(v) for v in value]
|
||||
return norm_text(str(value))
|
||||
|
||||
|
||||
def canonical(value: Any) -> bytes:
|
||||
"""json.dumps(sort_keys, compact separators, ensure_ascii False) of the normalised value, UTF-8."""
|
||||
return json.dumps(normalise(value), sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
|
||||
|
||||
|
||||
def sha256_hex(value: Any) -> str:
|
||||
return hashlib.sha256(canonical(value)).hexdigest()
|
||||
|
||||
|
||||
def drawing_key(dwg: Any) -> str | None:
|
||||
"""A drawing number for comparison: upper-cased, every whitespace removed."""
|
||||
s = norm_text(dwg)
|
||||
if s is None:
|
||||
return None
|
||||
s = "".join(s.split()).upper()
|
||||
return s or None
|
||||
|
||||
|
||||
def leg_key(base: Any, lot: Any, split: Any, sub: Any, seq: Any) -> str:
|
||||
"""'BASE|LOT|SPLIT|SUB|SEQ' with the ID parts upper-cased (as text) and seq as an integer."""
|
||||
parts = [norm_id(x) or "" for x in (base, lot, split, sub)]
|
||||
return "|".join([*parts, str(int(seq)) if seq is not None and seq != "" else ""])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- the fingerprint
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Fingerprint:
|
||||
full: str
|
||||
parts: dict[str, str]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Change:
|
||||
major: frozenset[str]
|
||||
minor: frozenset[str]
|
||||
|
||||
@property
|
||||
def changed(self) -> bool:
|
||||
return bool(self.major or self.minor)
|
||||
|
||||
@property
|
||||
def is_minor(self) -> bool:
|
||||
return bool(self.minor) and not self.major
|
||||
|
||||
|
||||
def part_values(c: Candidate) -> dict[str, Any]:
|
||||
"""The normalised value of every part, before hashing (tests and the diff text read these)."""
|
||||
legs = sorted(
|
||||
(
|
||||
[
|
||||
norm_id(lg.key.base) or "",
|
||||
norm_id(lg.key.lot) or "",
|
||||
norm_id(lg.key.split) or "",
|
||||
norm_id(lg.key.sub) or "",
|
||||
int(lg.seq),
|
||||
]
|
||||
for lg in c.legs
|
||||
),
|
||||
key=lambda x: (x[0], x[1], x[2], x[3], x[4]),
|
||||
)
|
||||
qty: dict[str, Any] = {}
|
||||
tgt: dict[str, Any] = {}
|
||||
for lg in c.legs:
|
||||
k = leg_key(lg.key.base, lg.key.lot, lg.key.split, lg.key.sub, lg.seq)
|
||||
qty[k] = norm_decimal(lg.qty)
|
||||
tgt[k] = norm_decimal(lg.tgt)
|
||||
values: dict[str, Any] = {
|
||||
"legs": legs,
|
||||
"qty": qty,
|
||||
"rev": norm_text(c.rev),
|
||||
"drawing": drawing_key(c.drawing),
|
||||
"tgt": tgt,
|
||||
"want": c.want.isoformat() if c.want else None,
|
||||
}
|
||||
if c.bom is not None:
|
||||
values["bom"] = bom_sig(c.bom)
|
||||
return values
|
||||
|
||||
|
||||
def bom_sig(bom: Any) -> list[list[Any]]:
|
||||
"""The old matSig: sorted [item, part_id, qty] of every line (BomLine objects or dicts)."""
|
||||
out: list[list[Any]] = []
|
||||
for b in bom or []:
|
||||
get = b.get if isinstance(b, Mapping) else lambda k, _b=b: getattr(_b, k, None)
|
||||
out.append([norm_text(get("item")) or "", norm_id(get("part_id")) or "", norm_decimal(get("qty"))])
|
||||
out.sort(key=lambda x: (x[0], x[1], x[2] or ""))
|
||||
return out
|
||||
|
||||
|
||||
def _full(parts: Mapping[str, str]) -> str:
|
||||
return sha256_hex(dict(parts))
|
||||
|
||||
|
||||
def compute(c: Candidate) -> Fingerprint:
|
||||
parts = {name: sha256_hex(v) for name, v in part_values(c).items()}
|
||||
return Fingerprint(full=_full(parts), parts=parts)
|
||||
|
||||
|
||||
def merge(before: Mapping[str, str] | None, after: Fingerprint) -> Fingerprint:
|
||||
"""Carry forward parts the new side does not know (the BOM when materials are unknown this run)."""
|
||||
if not before:
|
||||
return after
|
||||
parts = dict(after.parts)
|
||||
for name in PARTS:
|
||||
if name not in parts and name in before:
|
||||
parts[name] = str(before[name])
|
||||
if parts == after.parts:
|
||||
return after
|
||||
return Fingerprint(full=_full(parts), parts=parts)
|
||||
|
||||
|
||||
def classify(before: Mapping[str, str] | None, after: Mapping[str, str]) -> Change:
|
||||
"""Which parts changed. A part the new side lacks (unknown this run) is skipped. No `before`: all changed."""
|
||||
major: set[str] = set()
|
||||
minor: set[str] = set()
|
||||
for name in PARTS:
|
||||
if name not in after:
|
||||
continue
|
||||
if before is not None and before.get(name) == after[name]:
|
||||
continue
|
||||
(major if name in MAJOR else minor).add(name)
|
||||
return Change(frozenset(major), frozenset(minor))
|
||||
@@ -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)))
|
||||
@@ -0,0 +1,104 @@
|
||||
"""Which customer order a project or unit takes its customer, PO and dates from, when VISUAL has several.
|
||||
|
||||
The one rule, in order:
|
||||
|
||||
1. a Clerical pin that names one of the project's orders (`pin`);
|
||||
2. the base's own linked order, the first of `by_base[base]` (`base`);
|
||||
3. the project order whose top jobs name the unit, open orders first (`unit_top_job`);
|
||||
4. the project's first open order, status F, H or R (`project_open`);
|
||||
5. the project's first order, the newest (`project_newest`).
|
||||
|
||||
`derive` maps the chosen order to the fields a project or unit keeps: customer, po, order_date, ship_date.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, datetime
|
||||
from typing import Any
|
||||
|
||||
from .types import Order
|
||||
|
||||
OPEN_STATUSES = ("F", "H", "R")
|
||||
VIA = ("pin", "base", "unit_top_job", "project_open", "project_newest")
|
||||
FIELDS = ("customer", "po", "order_date", "ship_date")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Chosen:
|
||||
order: Order
|
||||
via: str # pin | base | unit_top_job | project_open | project_newest
|
||||
|
||||
|
||||
def _is_open(o: Order) -> bool:
|
||||
return (o.status or "").strip().upper() in OPEN_STATUSES
|
||||
|
||||
|
||||
def choose(
|
||||
*,
|
||||
unit_number: str,
|
||||
base: str,
|
||||
by_base: Mapping[str, list[Order]],
|
||||
by_project: Mapping[str, list[Order]],
|
||||
project: str,
|
||||
pin: str | None,
|
||||
) -> Chosen | None:
|
||||
proj = list(by_project.get(project) or [])
|
||||
own = list(by_base.get(base) or []) if base else []
|
||||
if pin:
|
||||
p = pin.strip().upper()
|
||||
for o in [*proj, *own]:
|
||||
if o.cust_order_id.strip().upper() == p:
|
||||
return Chosen(o, "pin")
|
||||
if own:
|
||||
return Chosen(own[0], "base")
|
||||
if unit_number:
|
||||
u = unit_number.upper()
|
||||
hits = [o for o in proj if any(t.upper() == u for t in o.top_jobs)]
|
||||
hits.sort(key=lambda o: not _is_open(o)) # stable: open first, the list's own order within
|
||||
if hits:
|
||||
return Chosen(hits[0], "unit_top_job")
|
||||
for o in proj:
|
||||
if _is_open(o):
|
||||
return Chosen(o, "project_open")
|
||||
if proj:
|
||||
return Chosen(proj[0], "project_newest")
|
||||
return None
|
||||
|
||||
|
||||
def derive(order: Order) -> dict[str, Any]:
|
||||
"""customer <- customer_name, po <- customer_po_ref, order_date, ship_date <- promise or desired ship date."""
|
||||
return {
|
||||
"customer": order.customer_name,
|
||||
"po": order.customer_po_ref,
|
||||
"order_date": order.order_date,
|
||||
"ship_date": order.promise_date or order.desired_ship_date,
|
||||
}
|
||||
|
||||
|
||||
def _iso(v: date | None) -> str | None:
|
||||
return v.isoformat() if v else None
|
||||
|
||||
|
||||
def order_ref(chosen: Chosen, at: datetime) -> dict[str, Any]:
|
||||
"""The jsonb kept on projects.order_ref and units.order_ref."""
|
||||
o = chosen.order
|
||||
return {
|
||||
"cust_order_id": o.cust_order_id,
|
||||
"via": chosen.via,
|
||||
"status": o.status,
|
||||
"customer_name": o.customer_name,
|
||||
"customer_po_ref": o.customer_po_ref,
|
||||
"order_date": _iso(o.order_date),
|
||||
"desired_ship_date": _iso(o.desired_ship_date),
|
||||
"promise_date": _iso(o.promise_date),
|
||||
"chosen_at": at.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
def same_ref(a: Mapping[str, Any] | None, b: Mapping[str, Any] | None) -> bool:
|
||||
"""Two order_refs are the same when everything but chosen_at matches."""
|
||||
if a is None or b is None:
|
||||
return a is None and b is None
|
||||
return {k: v for k, v in a.items() if k != "chosen_at"} == {k: v for k, v in b.items() if k != "chosen_at"}
|
||||
@@ -0,0 +1,491 @@
|
||||
"""Where the feed reads VISUAL from: VisualKit (DEV unless the stack says otherwise) or a synthetic fixture file.
|
||||
|
||||
`VisualKitSource` copies the read calls and output shapes of VisualKit's examples/packet_station_feed.py: the
|
||||
window read, the tracked keys by key, operations, operation text and parts for rows; for_bases, requirements,
|
||||
requirement text, parts and part sites for materials; DEMAND_SUPPLY_LINK orders by base and by project; labor
|
||||
tickets summed per operation. Read-only: VisualKit enforces it. No cost, price, amount or pay column is read.
|
||||
|
||||
`FixtureSource` reads `<dir>/state` and `<dir>/feed.<state>.json` in the same contract shape (CI and tests).
|
||||
|
||||
Every value goes through `types.parse_row` / `parse_material` / `parse_order`, so both sources give identical
|
||||
FeedData.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from collections.abc import Iterable, Sequence
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
from typing import Any, Protocol
|
||||
|
||||
from .types import (
|
||||
FeedData,
|
||||
LaborLeg,
|
||||
Material,
|
||||
Order,
|
||||
Row,
|
||||
WoKey,
|
||||
parse_labor,
|
||||
parse_material,
|
||||
parse_order,
|
||||
parse_orders,
|
||||
parse_row,
|
||||
status_code,
|
||||
text,
|
||||
)
|
||||
|
||||
STATUS_WORDS = {"F": "Firmed", "R": "Released", "U": "Unreleased", "C": "Closed", "X": "Cancelled"}
|
||||
MATERIAL_STATUSES = ("F", "R")
|
||||
OPEN_ORDER_STATUSES = ("F", "H", "R")
|
||||
QTY_EMPLOYEE = "9999"
|
||||
|
||||
_SECRETISH = re.compile(r"(?i)(password|pwd|secret|token)\s*[=:]\s*\S+")
|
||||
|
||||
|
||||
def redact(message: object) -> str:
|
||||
"""An exception as one short line with anything password-like removed."""
|
||||
s = _SECRETISH.sub(
|
||||
r"\1=***", f"{type(message).__name__}: {message}" if isinstance(message, BaseException) else str(message)
|
||||
)
|
||||
return s.replace("\r", " ").replace("\n", " ")[:500]
|
||||
|
||||
|
||||
class FeedSource(Protocol):
|
||||
def read(
|
||||
self,
|
||||
*,
|
||||
site: str,
|
||||
since: date,
|
||||
until: date,
|
||||
tracked: Sequence[WoKey],
|
||||
with_materials: bool,
|
||||
with_orders: bool,
|
||||
with_labor: bool,
|
||||
labor_since: date | None,
|
||||
) -> FeedData: ...
|
||||
|
||||
def describe(self) -> str: ...
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- value helpers (as the VisualKit example)
|
||||
|
||||
|
||||
def _to_number(v: Any) -> Any:
|
||||
if v is None or isinstance(v, bool):
|
||||
return v
|
||||
if isinstance(v, Decimal):
|
||||
return v
|
||||
return Decimal(str(v))
|
||||
|
||||
|
||||
def _fmt_date(v: date | datetime | None) -> str | None:
|
||||
if v is None:
|
||||
return None
|
||||
if isinstance(v, datetime):
|
||||
v = v.date()
|
||||
return v.isoformat()
|
||||
|
||||
|
||||
def _clean(s: str | None) -> str:
|
||||
if not s:
|
||||
return ""
|
||||
return s.replace("\r\n", "\n").replace("\r", "\n")
|
||||
|
||||
|
||||
def _istat(calc_qty: Any, issued_qty: Any, line_status: str | None) -> int:
|
||||
"""Job Pull's issue state: 3 over-issued, 0 line closed, 2 nothing issued, 1 partly."""
|
||||
calc = Decimal(str(calc_qty or 0))
|
||||
issued = Decimal(str(issued_qty or 0))
|
||||
if issued > calc:
|
||||
return 3
|
||||
if (line_status or "").strip().upper() == "C":
|
||||
return 0
|
||||
if issued == 0:
|
||||
return 2
|
||||
return 1
|
||||
|
||||
|
||||
def _yes_no(flag: bool | None) -> str:
|
||||
return "" if flag is None else ("Y" if flag else "N")
|
||||
|
||||
|
||||
def project_of(base: str) -> str:
|
||||
"""VisualKit's project_of: the base before its first dash, or the whole base."""
|
||||
head = base.partition("-")[0]
|
||||
return head or base
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- VisualKit
|
||||
|
||||
|
||||
class VisualKitSource:
|
||||
"""The five reads through one `visualkit.Visual` (made by the job per run, closed after)."""
|
||||
|
||||
def __init__(self, visual: Any) -> None:
|
||||
self.v = visual
|
||||
|
||||
def describe(self) -> str:
|
||||
s = self.v.settings
|
||||
return f"{s.server} / {s.database}"
|
||||
|
||||
def _key(self, k: WoKey) -> Any:
|
||||
from visualkit import WorkOrderKey
|
||||
|
||||
return WorkOrderKey(k.base, k.lot, k.split, k.sub, "W")
|
||||
|
||||
def _load_parts(self, part_ids: Iterable[str | None], cache: dict[str, Any]) -> None:
|
||||
missing = sorted({p for p in part_ids if p and p not in cache})
|
||||
if missing:
|
||||
cache.update(self.v.parts.get_many(missing))
|
||||
|
||||
def read(
|
||||
self,
|
||||
*,
|
||||
site: str,
|
||||
since: date,
|
||||
until: date,
|
||||
tracked: Sequence[WoKey],
|
||||
with_materials: bool,
|
||||
with_orders: bool,
|
||||
with_labor: bool,
|
||||
labor_since: date | None,
|
||||
) -> FeedData:
|
||||
v = self.v
|
||||
s = v.settings
|
||||
meta: dict[str, Any] = {
|
||||
"source": "visualkit",
|
||||
"server": s.server,
|
||||
"database": s.database,
|
||||
"site": site,
|
||||
"since": since.isoformat(),
|
||||
"until": until.isoformat(),
|
||||
"generated_at": datetime.now().isoformat(timespec="seconds"),
|
||||
}
|
||||
data = FeedData(meta=meta, rows=[], tracked=list(tracked), tracked_count=len(tracked))
|
||||
parts: dict[str, Any] = {}
|
||||
|
||||
# 1. The window: firmed TYPE W work orders released in [since, until], paged.
|
||||
wos: list[Any] = []
|
||||
offset: int | None = 0
|
||||
while offset is not None:
|
||||
page = v.work_orders.released(site, since, until, status=("F",), offset=offset)
|
||||
wos.extend(page.items)
|
||||
offset = page.next_offset
|
||||
data.window_count = len(wos)
|
||||
|
||||
# 2. The tracked keys, whatever their status or date. Keys VISUAL lacks are missing.
|
||||
asked = {self._key(k): k for k in dict.fromkeys(tracked)}
|
||||
found = v.work_orders.get_many(list(asked)) if asked else {}
|
||||
data.tracked_missing = [k for vk, k in asked.items() if vk not in found]
|
||||
have = {wo.key for wo in wos}
|
||||
for wo in found.values():
|
||||
if wo.key not in have:
|
||||
wos.append(wo)
|
||||
have.add(wo.key)
|
||||
|
||||
# 3. Rows for the union.
|
||||
keys = [wo.key for wo in wos]
|
||||
ops = v.work_orders.operations_for(keys) if keys else {}
|
||||
specs = v.work_orders.operation_text_for(keys) if keys else {}
|
||||
self._load_parts((wo.part_id for wo in wos), parts)
|
||||
for wo in wos:
|
||||
part = parts.get(wo.part_id) if wo.part_id else None
|
||||
desc = _clean(part.description if part else None)
|
||||
qty = wo.desired_qty or Decimal(0)
|
||||
for op in ops.get(wo.key, []):
|
||||
data.rows.append(
|
||||
parse_row(
|
||||
{
|
||||
"wo": wo.base_id,
|
||||
"lot": wo.lot_id,
|
||||
"split": wo.split_id,
|
||||
"sub": wo.sub_id,
|
||||
"seq": op.sequence_no,
|
||||
"res": op.resource_id or "",
|
||||
"dwg": wo.drawing_id or "",
|
||||
"rev": wo.drawing_rev_no or "",
|
||||
"ps": specs.get((wo.key, op.sequence_no), ""),
|
||||
"desc": desc,
|
||||
"qty": _to_number(qty),
|
||||
"tgt": _to_number((op.run or Decimal(0)) * qty),
|
||||
"rel": _fmt_date(wo.desired_rls_date),
|
||||
"want": _fmt_date(wo.desired_want_date),
|
||||
"st": STATUS_WORDS.get((wo.status or "").strip().upper(), wo.status or ""),
|
||||
"barcode": op.barcode(),
|
||||
"part": wo.part_id or "",
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
bases = sorted({wo.base_id for wo in wos})
|
||||
if with_materials:
|
||||
try:
|
||||
data.materials, data.material_legs = self._materials(bases, site, parts)
|
||||
except Exception as e: # a failed materials read keeps the BOMs as they are
|
||||
data.materials, data.material_legs = None, None
|
||||
data.errors.append("materials: " + redact(e))
|
||||
if with_orders:
|
||||
try:
|
||||
data.orders_by_base = self._orders_by_base(bases)
|
||||
data.orders_by_project = self._orders_by_project({project_of(b) for b in bases})
|
||||
except Exception as e: # the apply keeps the last cached orders
|
||||
data.orders_by_base, data.orders_by_project = None, None
|
||||
data.errors.append("orders: " + redact(e))
|
||||
if with_labor:
|
||||
try:
|
||||
data.labor = self._labor(list(asked) or keys)
|
||||
except Exception as e:
|
||||
data.labor = None
|
||||
data.errors.append("labor: " + redact(e))
|
||||
return data
|
||||
|
||||
def _materials(self, bases: Sequence[str], site: str, parts: dict[str, Any]) -> tuple[list[Material], set[WoKey]]:
|
||||
v = self.v
|
||||
if not bases:
|
||||
return [], set()
|
||||
legs = v.work_orders.for_bases(bases, type="W", status=MATERIAL_STATUSES, site_id=site)
|
||||
keys = [wo.key for wo in legs]
|
||||
reqs = v.work_orders.requirements_for(keys) if keys else {}
|
||||
text_keys = [k for k in keys if any(not r.part_id for r in reqs.get(k, []))]
|
||||
texts = v.work_orders.requirement_text_for(text_keys) if text_keys else {}
|
||||
part_ids = {r.part_id for rs in reqs.values() for r in rs if r.part_id}
|
||||
self._load_parts(part_ids, parts)
|
||||
sites = v.parts.sites_for(sorted(part_ids), site) if part_ids else {}
|
||||
out: list[Material] = []
|
||||
covered: set[WoKey] = set()
|
||||
for wo in legs:
|
||||
covered.add(WoKey.of(wo.base_id, wo.lot_id, wo.split_id, wo.sub_id))
|
||||
for r in reqs.get(wo.key, []):
|
||||
part = parts.get(r.part_id) if r.part_id else None
|
||||
ps = sites.get(r.part_id) if r.part_id else None
|
||||
desc = (
|
||||
_clean(part.description if part else None)
|
||||
if r.part_id
|
||||
else _clean(texts.get((wo.key, r.operation_seq_no, r.piece_no)))
|
||||
)
|
||||
out.append(
|
||||
parse_material(
|
||||
{
|
||||
"wo": wo.base_id,
|
||||
"lot": wo.lot_id,
|
||||
"split": wo.split_id,
|
||||
"sub": wo.sub_id,
|
||||
"seq": r.operation_seq_no,
|
||||
"piece": r.piece_no,
|
||||
"part": r.part_id or None,
|
||||
"qty": _to_number(r.calc_qty or Decimal(0)),
|
||||
"issued": _to_number(r.issued_qty or Decimal(0)),
|
||||
"whs": (ps.primary_whs_id or "").upper() if ps else "",
|
||||
"loc": (ps.primary_loc_id or "").upper() if ps else "",
|
||||
"st": wo.status,
|
||||
"istat": _istat(r.calc_qty, r.issued_qty, r.status),
|
||||
"dwg": wo.drawing_id or "",
|
||||
"desc": desc,
|
||||
"comm": (part.commodity_code or "") if part else "",
|
||||
"cons": _yes_no(part.consumable) if part else "",
|
||||
}
|
||||
)
|
||||
)
|
||||
return out, covered
|
||||
|
||||
def _orders_by_base(self, bases: Sequence[str]) -> dict[str, list[Order]]:
|
||||
v = self.v
|
||||
if not bases:
|
||||
return {}
|
||||
pairs = v.links.orders_for_bases(list(bases))
|
||||
order_ids = sorted({oid for ps in pairs.values() for oid, _ in ps})
|
||||
orders = v.orders.get_many(order_ids) if order_ids else {}
|
||||
customers = v.orders.customers(sorted({o.customer_id for o in orders.values()})) if orders else {}
|
||||
out: dict[str, list[Order]] = {}
|
||||
for base in bases:
|
||||
lines_by_order: dict[str, list[int]] = {}
|
||||
for oid, line_no in pairs.get(base, []):
|
||||
lines_by_order.setdefault(oid, [])
|
||||
if line_no is not None:
|
||||
lines_by_order[oid].append(line_no)
|
||||
if not lines_by_order:
|
||||
continue
|
||||
out[base] = []
|
||||
for oid, lines in sorted(lines_by_order.items()):
|
||||
o = orders.get(oid)
|
||||
cust = customers.get(o.customer_id) if o else None
|
||||
out[base].append(
|
||||
parse_order(
|
||||
{
|
||||
"cust_order_id": oid,
|
||||
"lines": sorted(set(lines)),
|
||||
"status": o.status if o else None,
|
||||
"customer_id": o.customer_id if o else None,
|
||||
"customer_name": cust.name if cust else None,
|
||||
"customer_po_ref": o.customer_po_ref if o else None,
|
||||
"order_date": _fmt_date(getattr(o, "order_date", None)) if o else None,
|
||||
"desired_ship_date": _fmt_date(o.desired_ship_date) if o else None,
|
||||
"promise_date": _fmt_date(o.promise_date) if o else None,
|
||||
}
|
||||
)
|
||||
)
|
||||
return out
|
||||
|
||||
def _orders_by_project(self, projects: Iterable[str]) -> dict[str, list[Order]]:
|
||||
v = self.v
|
||||
wanted = sorted(set(projects))
|
||||
if not wanted:
|
||||
return {}
|
||||
found = v.links.orders_for_projects(wanted)
|
||||
cust_ids = sorted({po.customer_id for pos in found.values() for po in pos if po.customer_id})
|
||||
customers = v.orders.customers(cust_ids) if cust_ids else {}
|
||||
out: dict[str, list[Order]] = {}
|
||||
for project in wanted:
|
||||
entries: dict[str, dict[str, Any]] = {}
|
||||
for po in found.get(project, []):
|
||||
e = entries.get(po.cust_order_id)
|
||||
if e is None:
|
||||
cust = customers.get(po.customer_id) if po.customer_id else None
|
||||
e = entries[po.cust_order_id] = {
|
||||
"cust_order_id": po.cust_order_id,
|
||||
"lines": [],
|
||||
"top_jobs": [],
|
||||
"status": po.status,
|
||||
"customer_id": po.customer_id,
|
||||
"customer_name": cust.name if cust else None,
|
||||
"customer_po_ref": po.customer_po_ref,
|
||||
"order_date": _fmt_date(po.order_date),
|
||||
"desired_ship_date": _fmt_date(po.desired_ship_date),
|
||||
"promise_date": _fmt_date(po.promise_date),
|
||||
}
|
||||
if po.line_no is not None and po.line_no not in e["lines"]:
|
||||
e["lines"].append(po.line_no)
|
||||
if po.supply_base_id not in e["top_jobs"]:
|
||||
e["top_jobs"].append(po.supply_base_id)
|
||||
if entries:
|
||||
for e in entries.values():
|
||||
e["lines"].sort()
|
||||
e["top_jobs"].sort()
|
||||
ordered = sorted(entries.values(), key=lambda e: e["status"] not in OPEN_ORDER_STATUSES)
|
||||
out[project] = [parse_order(e) for e in ordered]
|
||||
return out
|
||||
|
||||
def _labor(self, keys: Sequence[Any]) -> list[LaborLeg]:
|
||||
"""Hours and ticket counts per (leg, seq). Employee 9999's quantity-only tickets and indirect tickets
|
||||
are left out. Hours only: no cost or pay column is read. VisualKit has no date filter on this call, so
|
||||
every ticket of the legs is read and summed (the totals need them all)."""
|
||||
if not keys:
|
||||
return []
|
||||
tickets = self.v.labor.tickets_for_work_orders(list(keys))
|
||||
sums: dict[tuple[WoKey, int], LaborLeg] = {}
|
||||
for vk, ts in tickets.items():
|
||||
k = WoKey.of(vk.base_id, vk.lot_id, vk.split_id, vk.sub_id)
|
||||
for t in ts:
|
||||
if t.operation_seq_no is None or t.is_quantity_only or t.is_indirect:
|
||||
continue
|
||||
seq = int(t.operation_seq_no)
|
||||
leg = sums.get((k, seq))
|
||||
if leg is None:
|
||||
leg = sums[(k, seq)] = LaborLeg(k.base, k.lot, k.split, k.sub, seq, Decimal(0), 0, None)
|
||||
leg.hours += Decimal(str(t.hours_worked or 0))
|
||||
leg.tickets += 1
|
||||
d = t.transaction_date.date() if isinstance(t.transaction_date, datetime) else t.transaction_date
|
||||
if d is not None and (leg.last_ticket is None or d > leg.last_ticket):
|
||||
leg.last_ticket = d
|
||||
return list(sums.values())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- fixtures
|
||||
|
||||
|
||||
class FixtureSource:
|
||||
"""Synthetic VISUAL: `<dir>/state` (default "1") picks `<dir>/feed.<state>.json`."""
|
||||
|
||||
def __init__(self, directory: Path) -> None:
|
||||
self.dir = Path(directory)
|
||||
|
||||
def state(self) -> str:
|
||||
try:
|
||||
s = (self.dir / "state").read_text(encoding="utf-8").strip()
|
||||
except OSError:
|
||||
s = ""
|
||||
return s or "1"
|
||||
|
||||
def describe(self) -> str:
|
||||
return f"fixture state {self.state()}"
|
||||
|
||||
def read(
|
||||
self,
|
||||
*,
|
||||
site: str,
|
||||
since: date,
|
||||
until: date,
|
||||
tracked: Sequence[WoKey],
|
||||
with_materials: bool,
|
||||
with_orders: bool,
|
||||
with_labor: bool,
|
||||
labor_since: date | None,
|
||||
) -> FeedData:
|
||||
state = self.state()
|
||||
raw = json.loads((self.dir / f"feed.{state}.json").read_text(encoding="utf-8"))
|
||||
all_rows: list[Row] = [parse_row(d) for d in raw.get("rows") or []]
|
||||
tracked_set = set(tracked)
|
||||
window_keys = {r.key for r in all_rows if r.st == "F" and r.rel is not None and since <= r.rel <= until}
|
||||
keep = window_keys | (tracked_set & {r.key for r in all_rows})
|
||||
rows = [r for r in all_rows if r.key in keep]
|
||||
present = {r.key for r in all_rows}
|
||||
meta = dict(raw.get("meta") or {})
|
||||
meta.update(
|
||||
{"source": "fixture", "state": state, "site": site, "since": since.isoformat(), "until": until.isoformat()}
|
||||
)
|
||||
data = FeedData(
|
||||
meta=meta,
|
||||
rows=rows,
|
||||
tracked=list(tracked),
|
||||
tracked_missing=[k for k in dict.fromkeys(tracked) if k not in present],
|
||||
window_count=len(window_keys),
|
||||
tracked_count=len(tracked),
|
||||
)
|
||||
bases = {r.wo for r in rows}
|
||||
if with_materials:
|
||||
if raw.get("materials") is None:
|
||||
data.materials, data.material_legs = None, None
|
||||
else:
|
||||
lines = [parse_material(d) for d in raw.get("materials") or []]
|
||||
data.materials = [m for m in lines if m.wo in bases]
|
||||
data.material_legs = {m.key for m in data.materials if m.st in MATERIAL_STATUSES}
|
||||
# A leg the file lists with status F or R counts as covered even when it has no lines.
|
||||
for r in all_rows:
|
||||
if r.wo in bases and r.st in MATERIAL_STATUSES:
|
||||
data.material_legs.add(r.key)
|
||||
if with_orders:
|
||||
obb = parse_orders(raw.get("orders_by_base"))
|
||||
obp = parse_orders(raw.get("orders_by_project"))
|
||||
if obb is None or obp is None:
|
||||
data.orders_by_base, data.orders_by_project = None, None
|
||||
else:
|
||||
projects = {project_of(b) for b in bases}
|
||||
data.orders_by_base = {b: v for b, v in obb.items() if b in bases}
|
||||
data.orders_by_project = {p: v for p, v in obp.items() if p in projects}
|
||||
if with_labor and raw.get("labor") is not None:
|
||||
data.labor = [parse_labor(d) for d in raw.get("labor") or []]
|
||||
return data
|
||||
|
||||
|
||||
def make(settings: Any, visual: Any = None) -> FeedSource:
|
||||
"""The source the settings name. `visualkit` needs the open `visual` the job made."""
|
||||
if settings.feed_source == "fixture":
|
||||
return FixtureSource(Path(settings.fixture_dir))
|
||||
if visual is None:
|
||||
raise ValueError("the visualkit feed source needs an open visualkit.Visual")
|
||||
return VisualKitSource(visual)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"FeedSource",
|
||||
"FixtureSource",
|
||||
"VisualKitSource",
|
||||
"make",
|
||||
"project_of",
|
||||
"redact",
|
||||
"status_code",
|
||||
"text",
|
||||
]
|
||||
@@ -0,0 +1,306 @@
|
||||
"""The feed's typed records: one VISUAL operation row, one material line, one customer order, labor per leg.
|
||||
|
||||
Both feed sources (VisualKit and the fixture files) build these through `parse_row` and `parse_material`, so they
|
||||
produce identical data. Every ID part (work order base, lot, split, sub, part id, order id) is text and is never
|
||||
cast to a number: leading zeros stay and lot `7A` occurs. `seq` and `piece` are int. Quantities are Decimal.
|
||||
`None` and `""` are the same for text.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from typing import Any
|
||||
|
||||
STATUS_CODES = {
|
||||
"FIRMED": "F",
|
||||
"RELEASED": "R",
|
||||
"UNRELEASED": "U",
|
||||
"CLOSED": "C",
|
||||
"CANCELLED": "X",
|
||||
"CANCELED": "X",
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class WoKey:
|
||||
"""A work order leg key, text exactly as VISUAL has it ('' never None)."""
|
||||
|
||||
base: str
|
||||
lot: str
|
||||
split: str
|
||||
sub: str
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.base}/{self.lot}/{self.split}/{self.sub}"
|
||||
|
||||
def as_tuple(self) -> tuple[str, str, str, str]:
|
||||
return (self.base, self.lot, self.split, self.sub)
|
||||
|
||||
@classmethod
|
||||
def of(cls, base: Any, lot: Any, split: Any, sub: Any) -> WoKey:
|
||||
return cls(text(base), text(lot), text(split), text(sub))
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class Row:
|
||||
"""One TYPE W operation, as Job Release Information carries it."""
|
||||
|
||||
wo: str
|
||||
lot: str
|
||||
split: str
|
||||
sub: str
|
||||
seq: int
|
||||
res: str
|
||||
dwg: str
|
||||
rev: str
|
||||
ps: str
|
||||
desc: str
|
||||
qty: Decimal
|
||||
tgt: Decimal
|
||||
rel: date | None
|
||||
want: date | None
|
||||
st: str # the status CODE: F R U C X, or ''
|
||||
part: str = ""
|
||||
barcode: str = ""
|
||||
|
||||
@property
|
||||
def key(self) -> WoKey:
|
||||
return WoKey(self.wo, self.lot, self.split, self.sub)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class Material:
|
||||
"""One requirement line, as Job Pull carries it. part is None for a line with no part (a group header)."""
|
||||
|
||||
wo: str
|
||||
lot: str
|
||||
split: str
|
||||
sub: str
|
||||
seq: int
|
||||
piece: int
|
||||
part: str | None
|
||||
qty: Decimal
|
||||
issued: Decimal
|
||||
whs: str
|
||||
loc: str
|
||||
st: str
|
||||
istat: int | None
|
||||
dwg: str
|
||||
desc: str
|
||||
comm: str
|
||||
cons: str
|
||||
|
||||
@property
|
||||
def key(self) -> WoKey:
|
||||
return WoKey(self.wo, self.lot, self.split, self.sub)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class Order:
|
||||
cust_order_id: str
|
||||
status: str | None
|
||||
customer_id: str | None
|
||||
customer_name: str | None
|
||||
customer_po_ref: str | None
|
||||
order_date: date | None
|
||||
desired_ship_date: date | None
|
||||
promise_date: date | None
|
||||
lines: list[int] = field(default_factory=list)
|
||||
top_jobs: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class LaborLeg:
|
||||
wo: str
|
||||
lot: str
|
||||
split: str
|
||||
sub: str
|
||||
seq: int
|
||||
hours: Decimal
|
||||
tickets: int
|
||||
last_ticket: date | None
|
||||
|
||||
@property
|
||||
def key(self) -> WoKey:
|
||||
return WoKey(self.wo, self.lot, self.split, self.sub)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class FeedData:
|
||||
"""One read of VISUAL (or of a fixture file). None on a section means that read failed or was not asked for."""
|
||||
|
||||
meta: dict[str, Any]
|
||||
rows: list[Row]
|
||||
materials: list[Material] | None = None
|
||||
material_legs: set[WoKey] | None = None # legs the materials read covered (header status F or R)
|
||||
orders_by_base: dict[str, list[Order]] | None = None
|
||||
orders_by_project: dict[str, list[Order]] | None = None
|
||||
labor: list[LaborLeg] | None = None
|
||||
tracked: list[WoKey] = field(default_factory=list) # the tracked keys this read was asked for
|
||||
tracked_missing: list[WoKey] = field(default_factory=list)
|
||||
window_count: int = 0 # work orders the window read returned
|
||||
tracked_count: int = 0
|
||||
errors: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- value helpers
|
||||
|
||||
|
||||
def text(value: Any) -> str:
|
||||
"""Text as stored: None and '' alike become ''. CRLF and CR become LF. Never a number cast."""
|
||||
if value is None:
|
||||
return ""
|
||||
s = value if isinstance(value, str) else str(value)
|
||||
return s.replace("\r\n", "\n").replace("\r", "\n").strip()
|
||||
|
||||
|
||||
def opt_text(value: Any) -> str | None:
|
||||
s = text(value)
|
||||
return s or None
|
||||
|
||||
|
||||
def num(value: Any) -> Decimal:
|
||||
"""A quantity as Decimal, through str so 0.1 stays 0.1. None, '' and junk become 0."""
|
||||
if value is None or value == "" or isinstance(value, bool):
|
||||
return Decimal(0)
|
||||
if isinstance(value, Decimal):
|
||||
return value
|
||||
try:
|
||||
return Decimal(str(value))
|
||||
except (InvalidOperation, ValueError):
|
||||
return Decimal(0)
|
||||
|
||||
|
||||
def opt_num(value: Any) -> Decimal | None:
|
||||
if value is None or value == "":
|
||||
return None
|
||||
return num(value)
|
||||
|
||||
|
||||
def day(value: Any) -> date | None:
|
||||
"""A date from a date, datetime or ISO text (the first 10 characters). Anything else is None."""
|
||||
if value is None or value == "":
|
||||
return None
|
||||
if isinstance(value, datetime):
|
||||
return value.date()
|
||||
if isinstance(value, date):
|
||||
return value
|
||||
try:
|
||||
return date.fromisoformat(str(value).strip()[:10])
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def integer(value: Any, default: int = 0) -> int:
|
||||
"""seq and piece: whole numbers. 10, 10.0, '10' and Decimal('10') are all 10."""
|
||||
if value is None or value == "" or isinstance(value, bool):
|
||||
return default
|
||||
if isinstance(value, int):
|
||||
return value
|
||||
try:
|
||||
return int(Decimal(str(value).strip()))
|
||||
except (InvalidOperation, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def opt_int(value: Any) -> int | None:
|
||||
if value is None or value == "":
|
||||
return None
|
||||
return integer(value)
|
||||
|
||||
|
||||
def status_code(word_or_code: Any) -> str:
|
||||
"""'Firmed' or 'F' -> 'F', and so on. Unknown -> its first letter upper-cased. None and '' -> ''."""
|
||||
s = text(word_or_code).upper()
|
||||
if not s:
|
||||
return ""
|
||||
if s in STATUS_CODES:
|
||||
return STATUS_CODES[s]
|
||||
return s[0]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- parsing contract dicts
|
||||
|
||||
|
||||
def parse_row(d: Mapping[str, Any]) -> Row:
|
||||
"""A Row from a feed-contract dict (examples/packet_station_feed.py `rows`)."""
|
||||
return Row(
|
||||
wo=text(d.get("wo")),
|
||||
lot=text(d.get("lot")),
|
||||
split=text(d.get("split")),
|
||||
sub=text(d.get("sub")),
|
||||
seq=integer(d.get("seq")),
|
||||
res=text(d.get("res")),
|
||||
dwg=text(d.get("dwg")),
|
||||
rev=text(d.get("rev")),
|
||||
ps=text(d.get("ps")),
|
||||
desc=text(d.get("desc")),
|
||||
qty=num(d.get("qty")),
|
||||
tgt=num(d.get("tgt")),
|
||||
rel=day(d.get("rel")),
|
||||
want=day(d.get("want")),
|
||||
st=status_code(d.get("st")),
|
||||
part=text(d.get("part")),
|
||||
barcode=text(d.get("barcode")),
|
||||
)
|
||||
|
||||
|
||||
def parse_material(d: Mapping[str, Any]) -> Material:
|
||||
"""A Material from a feed-contract dict (`materials`). A missing part stays None."""
|
||||
return Material(
|
||||
wo=text(d.get("wo")),
|
||||
lot=text(d.get("lot")),
|
||||
split=text(d.get("split")),
|
||||
sub=text(d.get("sub")),
|
||||
seq=integer(d.get("seq")),
|
||||
piece=integer(d.get("piece")),
|
||||
part=opt_text(d.get("part")),
|
||||
qty=num(d.get("qty")),
|
||||
issued=num(d.get("issued")),
|
||||
whs=text(d.get("whs")),
|
||||
loc=text(d.get("loc")),
|
||||
st=status_code(d.get("st")),
|
||||
istat=opt_int(d.get("istat")),
|
||||
dwg=text(d.get("dwg")),
|
||||
desc=text(d.get("desc")),
|
||||
comm=text(d.get("comm")),
|
||||
cons=text(d.get("cons")),
|
||||
)
|
||||
|
||||
|
||||
def parse_order(d: Mapping[str, Any]) -> Order:
|
||||
"""An Order from an orders_by_base or orders_by_project entry."""
|
||||
return Order(
|
||||
cust_order_id=text(d.get("cust_order_id")),
|
||||
status=opt_text(d.get("status")),
|
||||
customer_id=opt_text(d.get("customer_id")),
|
||||
customer_name=opt_text(d.get("customer_name")),
|
||||
customer_po_ref=opt_text(d.get("customer_po_ref")),
|
||||
order_date=day(d.get("order_date")),
|
||||
desired_ship_date=day(d.get("desired_ship_date")),
|
||||
promise_date=day(d.get("promise_date")),
|
||||
lines=[integer(x) for x in (d.get("lines") or []) if x is not None and x != ""],
|
||||
top_jobs=[text(x) for x in (d.get("top_jobs") or []) if text(x)],
|
||||
)
|
||||
|
||||
|
||||
def parse_orders(m: Mapping[str, Any] | None) -> dict[str, list[Order]] | None:
|
||||
if m is None:
|
||||
return None
|
||||
return {text(k): [parse_order(e) for e in (v or [])] for k, v in m.items() if text(k)}
|
||||
|
||||
|
||||
def parse_labor(d: Mapping[str, Any]) -> LaborLeg:
|
||||
return LaborLeg(
|
||||
wo=text(d.get("wo")),
|
||||
lot=text(d.get("lot")),
|
||||
split=text(d.get("split")),
|
||||
sub=text(d.get("sub")),
|
||||
seq=integer(d.get("seq")),
|
||||
hours=num(d.get("hours")),
|
||||
tickets=integer(d.get("tickets")),
|
||||
last_ticket=day(d.get("last_ticket")),
|
||||
)
|
||||
@@ -0,0 +1,216 @@
|
||||
"""Packet writes by the worker: the pipeline's packet_write for the system principal, with no session.
|
||||
|
||||
`system_write` locks the packet row (FOR UPDATE SKIP LOCKED: a row a person is writing right now is skipped and
|
||||
retried next run, `Busy`), builds the policy's PacketState from the same child rows the API uses, asks the policy
|
||||
as `Principal(user_id=None, system="visual-feed")` (`Refused` when it says no), yields a `SysWrite`, then bumps
|
||||
the version, writes the packet_log row (by NULL, by_name 'VISUAL feed', role 'system'), the audit row
|
||||
(`packet.<action>`, role_snapshot 'system') and the NOTIFY. Everything runs in a savepoint (or a transaction when
|
||||
none is open), so a refusal or an error inside the body leaves nothing behind.
|
||||
|
||||
VIS_AUTO_RELEASE carries the SEND_TO_WAREHOUSE side effects here, locally: status warehouse, status_at,
|
||||
wh_skipped false, wh_sent_at, a packet_wh incoming row (by NULL, auto true) and a packet_transitions row (by NULL,
|
||||
action 'vis_auto_release').
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator, Mapping
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from psycopg.rows import dict_row
|
||||
|
||||
from .. import audit, events, policy
|
||||
from ..domain import packets
|
||||
from ..text import LIMITS, clean_opt
|
||||
|
||||
A = policy.Action
|
||||
SYSTEM = policy.Principal(user_id=None, system="visual-feed")
|
||||
BY_NAME = "VISUAL feed"
|
||||
ROLE = "system"
|
||||
|
||||
LABELS: dict[policy.Action, str] = {
|
||||
A.VIS_UPDATE_DRAFT: "Updated from VISUAL",
|
||||
A.VIS_OPEN_CO: "Change from VISUAL",
|
||||
A.VIS_AUTO_RELEASE: "Sent to warehouse automatically",
|
||||
A.VIS_RETIRE_DRAFT: "Retired: work orders moved to another packet",
|
||||
}
|
||||
|
||||
_SELECT = (
|
||||
"SELECT *, status::text AS status, source_kind::text AS source_kind, routed_to::text AS routed_to,"
|
||||
" drawing_state::text AS drawing_state FROM packets WHERE id = %s FOR UPDATE SKIP LOCKED"
|
||||
)
|
||||
|
||||
EMPTY: Mapping[str, Any] = {}
|
||||
|
||||
|
||||
class Busy(Exception):
|
||||
"""Someone else holds the packet row right now. Retry next run."""
|
||||
|
||||
|
||||
class Missing(LookupError):
|
||||
"""No such packet."""
|
||||
|
||||
|
||||
class Refused(Exception):
|
||||
"""The policy said no."""
|
||||
|
||||
def __init__(self, decision: policy.Decision) -> None:
|
||||
super().__init__(f"{decision.code}: {decision.why}")
|
||||
self.decision = decision
|
||||
|
||||
|
||||
@dataclass
|
||||
class SysWrite:
|
||||
conn: Any
|
||||
packet: dict[str, Any] # the packets row, locked
|
||||
state: policy.PacketState
|
||||
action: policy.Action
|
||||
label: str
|
||||
note: str | None = None
|
||||
detail: dict[str, Any] = field(default_factory=dict)
|
||||
fields: list[str] = field(default_factory=list)
|
||||
noop: bool = False
|
||||
version: int = 0
|
||||
transition_id: int | None = None
|
||||
|
||||
@property
|
||||
def id(self) -> int:
|
||||
return int(self.packet["id"])
|
||||
|
||||
def changed(self, *names: str) -> None:
|
||||
for n in names:
|
||||
if n not in self.fields:
|
||||
self.fields.append(n)
|
||||
|
||||
def log(self, label: str, note: str | None = None) -> None:
|
||||
"""Override the packet_log label (and note)."""
|
||||
self.label = label
|
||||
if note is not None:
|
||||
self.note = note
|
||||
|
||||
def skip(self) -> None:
|
||||
"""Nothing changed: no version bump, no log, audit or event."""
|
||||
self.noop = True
|
||||
|
||||
|
||||
def lock(conn: Any, packet_id: int) -> dict[str, Any]:
|
||||
"""The packets row locked FOR UPDATE SKIP LOCKED. Busy when another transaction holds it."""
|
||||
with conn.cursor(row_factory=dict_row) as cur:
|
||||
row = cur.execute(_SELECT, (packet_id,)).fetchone()
|
||||
if row is not None:
|
||||
return dict(row)
|
||||
if conn.execute("SELECT 1 FROM packets WHERE id = %s", (packet_id,)).fetchone() is None:
|
||||
raise Missing(packet_id)
|
||||
raise Busy(packet_id)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def system_write(
|
||||
conn: Any,
|
||||
packet_id: int,
|
||||
action: policy.Action,
|
||||
*,
|
||||
label: str | None = None,
|
||||
note: str | None = None,
|
||||
detail: dict[str, Any] | None = None,
|
||||
extra: Mapping[str, Any] = EMPTY,
|
||||
) -> Iterator[SysWrite]:
|
||||
with conn.transaction():
|
||||
row = lock(conn, packet_id)
|
||||
ch = packets.load_children(conn, row, full=False)
|
||||
state = packets.state_from(row, ch)
|
||||
decision = policy.check(SYSTEM, state, action, extra=extra)
|
||||
if not decision.ok:
|
||||
raise Refused(decision)
|
||||
sw = SysWrite(
|
||||
conn=conn,
|
||||
packet=row,
|
||||
state=state,
|
||||
action=action,
|
||||
label=label or LABELS.get(action, str(action)),
|
||||
note=note,
|
||||
detail=dict(detail or {}),
|
||||
version=int(row["version"]),
|
||||
)
|
||||
yield sw
|
||||
_finish(sw)
|
||||
|
||||
|
||||
def _finish(sw: SysWrite) -> None:
|
||||
if sw.noop:
|
||||
return
|
||||
conn = sw.conn
|
||||
from_status = str(sw.packet["status"])
|
||||
from_dept = str(sw.packet["dept_code"])
|
||||
if sw.action is A.VIS_AUTO_RELEASE:
|
||||
_auto_release(sw)
|
||||
sw.version = int(
|
||||
conn.execute("UPDATE packets SET version = version + 1 WHERE id = %s RETURNING version", (sw.id,)).fetchone()[0]
|
||||
)
|
||||
after = conn.execute("SELECT status::text, dept_code FROM packets WHERE id = %s", (sw.id,)).fetchone()
|
||||
status, dept = str(after[0]), str(after[1])
|
||||
note = clean_opt(sw.note, LIMITS["note"]) if sw.note else None
|
||||
conn.execute(
|
||||
"INSERT INTO packet_log (packet_id, by, by_name, role, action, note, transition_id)"
|
||||
" VALUES (%s, NULL, %s, %s, %s, %s, %s)",
|
||||
(sw.id, BY_NAME, ROLE, sw.label, note, sw.transition_id),
|
||||
)
|
||||
detail: dict[str, Any] = {"from": from_status, "to": status, "dept": dept}
|
||||
if from_dept != dept:
|
||||
detail["from_dept"] = from_dept
|
||||
if sw.fields:
|
||||
detail["fields"] = sw.fields
|
||||
if note:
|
||||
detail["note"] = note
|
||||
detail.update(sw.detail)
|
||||
audit.append(
|
||||
conn,
|
||||
action=f"packet.{sw.action}",
|
||||
role_snapshot=ROLE,
|
||||
subject_type="packet",
|
||||
subject_id=sw.id,
|
||||
packet_id=sw.id,
|
||||
detail=packets_jsonable(detail),
|
||||
)
|
||||
events.publish(
|
||||
conn, {"event": "changed", "packet_id": sw.id, "version": sw.version, "status": status, "dept": dept}
|
||||
)
|
||||
|
||||
|
||||
def _auto_release(sw: SysWrite) -> None:
|
||||
conn = sw.conn
|
||||
conn.execute(
|
||||
"UPDATE packets SET status = 'warehouse', status_at = now(), wh_skipped = false, wh_sent_at = now(),"
|
||||
" return_note = NULL WHERE id = %s",
|
||||
(sw.id,),
|
||||
)
|
||||
conn.execute("UPDATE packet_wh SET closed_at = now() WHERE packet_id = %s AND closed_at IS NULL", (sw.id,))
|
||||
conn.execute(
|
||||
"INSERT INTO packet_wh (packet_id, stage, by, note, auto) VALUES (%s, 'incoming', NULL, %s, true)",
|
||||
(sw.id, "Sent automatically: no flags"),
|
||||
)
|
||||
dept = str(sw.packet["dept_code"])
|
||||
tid = conn.execute(
|
||||
"INSERT INTO packet_transitions (packet_id, by, action, from_status, to_status, from_dept, to_dept, reason)"
|
||||
" VALUES (%s, NULL, 'vis_auto_release', %s::packet_status, 'warehouse', %s, %s, %s) RETURNING id",
|
||||
(sw.id, str(sw.packet["status"]), dept, dept, sw.note),
|
||||
).fetchone()
|
||||
sw.transition_id = int(tid[0])
|
||||
sw.changed("status")
|
||||
|
||||
|
||||
def packets_jsonable(value: Any) -> Any:
|
||||
"""Audit detail as plain JSON (dates and Decimals as text)."""
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
|
||||
return jsonable_encoder(value)
|
||||
|
||||
|
||||
def log_only(conn: Any, packet_id: int, label: str, note: str | None = None) -> None:
|
||||
"""A packet_log row by the feed with no version bump (for observations that change nothing a user edits)."""
|
||||
conn.execute(
|
||||
"INSERT INTO packet_log (packet_id, by, by_name, role, action, note) VALUES (%s, NULL, %s, %s, %s, %s)",
|
||||
(packet_id, BY_NAME, ROLE, label, clean_opt(note, LIMITS["note"]) if note else None),
|
||||
)
|
||||
Reference in New Issue
Block a user