383 lines
15 KiB
Python
383 lines
15 KiB
Python
"""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
|
||
|
|
]
|