Phase 3: VISUAL feed and M-Files drawings in the worker
Migration 0004 (VISUAL and drawing caches, go-live ignored list, resource map, job tables, order-derived columns with provenance; work order key includes the operation sequence). The real worker on APScheduler with settings re-read each fire, pause, run now, advisory locks, job_runs and a heartbeat. The visual-feed job (five VisualKit reads, COPY and MERGE cache, suspect-batch guard, the apply in one transaction: drafts, change orders, retire-moved, work order gone, closed and cancelled observed, projects and units from order links, auto-release) and the fingerprint. The drawing-lookup job (queue, budget, download on version change, sha256 into assets, decision table, staging for rev approval, drawing-origin change orders). Clerical From VISUAL panel, flags, rev approval, hand uploads, change-order review; Admin Feed settings and resource map. Synthetic fixture generator and a fixture-driven worker in CI with a Playwright scenario. Docs updated. Verified: 3249 server tests and 13 Playwright scenarios on the docker-1 CI stack, 76 frontend tests, doctor at 0004; one feed and one drawing run against VISUAL DEV and the vault from the developer machine (counts in the changelog). Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
@@ -0,0 +1,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),
|
||||
)
|
||||
Reference in New Issue
Block a user