Phase 3: VISUAL feed and M-Files drawings in the worker
ci / frontend (push) Successful in 30s
ci / server (push) Successful in 4m12s
ci / image (push) Successful in 46s
ci / e2e (push) Successful in 1m24s

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:
2026-10-16 14:45:38 -06:00
co-authored by Claude Fable 5.1
parent 16950055e3
commit 591fe90076
103 changed files with 16063 additions and 151 deletions
+1
View File
@@ -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
+564
View File
@@ -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),
)
+243
View File
@@ -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)
+382
View File
@@ -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
]
+226
View File
@@ -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
+210
View File
@@ -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))
+291
View File
@@ -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)))
+104
View File
@@ -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"}
+491
View File
@@ -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",
]
+306
View File
@@ -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")),
)
+216
View File
@@ -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),
)