Clerical close and file: the VISUAL observed state beside the close confirm, a 409 with a second confirm when the feed still shows work orders open, filing with a signature and the HMAC seal, the data book built on the server and stored on the seal in the filing transaction, admin reopen keeping the broken seal with its data book. Server PDFs with reportlab, pypdf and pypdfium2: the manufacturing data book page for page after the old build (cover, index, drawings with VOID stamps, traceability and heat map rasters, weld maps and welding records, Q.C. inspections, Q.C. and test documents, MTRs, record history), the job traveler with work order QR codes, the ITP PDF filed automatically at sign-off, every print kind with the PRINTED COPY stamp, the controlled-copy stamp signed with the seal key, PDF metadata, the traceability CSV with the formula guard, and the append-only print_log with an audit row for every print and export (migration 0008). Frontend: the close and file panel, the print menu and its entry points, the printed copies list on the History tab, the ITP PDF buttons. Tests: server (close, file, reopen, seal, the builders, prints, CSV, stamps), frontend, and the Playwright close and print scenarios. Docs updated; SECURITY closes M2, S6, S7 and S8. Co-Authored-By: Claude Fable 5.1 <[email protected]>
290 lines
9.4 KiB
Python
290 lines
9.4 KiB
Python
"""The traceability CSV: the old exportCsv, row for row, from the packet read model.
|
|
|
|
Every cell goes through `csv_cell`, the formula guard: a value that starts with =, +, -, @, a tab or a CR gets a
|
|
leading apostrophe, so a spreadsheet shows it as text and never runs it. Lines end in CRLF. UTF-8, no BOM.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections.abc import Iterable, Mapping, Sequence
|
|
from datetime import date, datetime
|
|
from typing import Any
|
|
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
|
|
|
from ..domain import packets as packets_dom
|
|
from ..domain.pipeline import qty_text
|
|
|
|
FORMULA_START = ("=", "+", "-", "@", "\t", "\r")
|
|
TITLE = "PESCO weld & heat traceability record"
|
|
BOM_HEAD = (
|
|
"BOM item",
|
|
"Qty",
|
|
"Issued",
|
|
"Location",
|
|
"Part ID",
|
|
"Description",
|
|
"Length",
|
|
"Material",
|
|
"Heat no.",
|
|
"MTR on file",
|
|
"Open short qty",
|
|
"Short reason",
|
|
"Short received",
|
|
)
|
|
WELD_HEAD = (
|
|
"Weld",
|
|
"Work order",
|
|
"Piece",
|
|
"Joins items",
|
|
"Heats joined",
|
|
"Type",
|
|
"Size",
|
|
"Process",
|
|
"WPS",
|
|
"Stamp",
|
|
"Logged",
|
|
)
|
|
CHECK_HEAD = ("Checklist item", "Done", "Stamp", "When")
|
|
|
|
|
|
def csv_cell(value: Any) -> str:
|
|
"""One CSV cell with the formula guard and RFC 4180 quoting."""
|
|
text = "" if value is None else str(value)
|
|
if text.startswith(FORMULA_START):
|
|
text = "'" + text
|
|
if any(c in text for c in ('"', ",", "\n", "\r")):
|
|
text = '"' + text.replace('"', '""') + '"'
|
|
return text
|
|
|
|
|
|
def to_csv(rows: Iterable[Sequence[Any]]) -> str:
|
|
return "\r\n".join(",".join(csv_cell(v) for v in r) for r in rows)
|
|
|
|
|
|
def _zone(tz: str | None) -> ZoneInfo:
|
|
try:
|
|
return ZoneInfo(tz or "America/Denver")
|
|
except (ZoneInfoNotFoundError, ValueError):
|
|
return ZoneInfo("America/Denver")
|
|
|
|
|
|
def _when(value: Any, tz: str | None) -> datetime | date | None:
|
|
if value is None or value == "":
|
|
return None
|
|
if isinstance(value, datetime):
|
|
return value.astimezone(_zone(tz)) if value.tzinfo else value
|
|
if isinstance(value, date):
|
|
return value
|
|
try:
|
|
d = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
|
|
except ValueError:
|
|
return None
|
|
return d.astimezone(_zone(tz)) if d.tzinfo else d
|
|
|
|
|
|
def fmt_dt(value: Any, tz: str | None) -> str:
|
|
"""M/D/YYYY h:mm AM in the site time zone; empty for no value."""
|
|
d = _when(value, tz)
|
|
if d is None:
|
|
return ""
|
|
if not isinstance(d, datetime):
|
|
return f"{d.month}/{d.day}/{d.year}"
|
|
hour = d.hour % 12 or 12
|
|
return f"{d.month}/{d.day}/{d.year} {hour}:{d.minute:02d} {'AM' if d.hour < 12 else 'PM'}"
|
|
|
|
|
|
def fmt_d(value: Any, tz: str | None) -> str:
|
|
"""M/D/YYYY in the site time zone; empty for no value."""
|
|
d = _when(value, tz)
|
|
if d is None:
|
|
return ""
|
|
return f"{d.month}/{d.day}/{d.year}"
|
|
|
|
|
|
def _num(value: Any) -> str:
|
|
return "" if value is None else qty_text(value)
|
|
|
|
|
|
def _done(entry: Any) -> bool:
|
|
return entry is True or (isinstance(entry, Mapping) and entry.get("done") is True)
|
|
|
|
|
|
def _entry(entry: Any) -> Mapping[str, Any]:
|
|
return entry if isinstance(entry, Mapping) else {}
|
|
|
|
|
|
def _shorts(conn: Any, packet_id: int) -> dict[int, dict[str, Any]]:
|
|
"""The newest short per BOM line, open or closed, with its receipts (a closed short still has its record)."""
|
|
out: dict[int, dict[str, Any]] = {}
|
|
for s in packets_dom.rows(conn, "SELECT * FROM bom_shorts WHERE packet_id = %s ORDER BY id", (packet_id,)):
|
|
out[int(s["bom_line_id"])] = {**s, "receipts": []}
|
|
ids = {int(s["id"]): line for line, s in out.items()}
|
|
if ids:
|
|
for r in packets_dom.rows(
|
|
conn, "SELECT * FROM short_receipts WHERE short_id = ANY(%s) ORDER BY id", (list(ids),)
|
|
):
|
|
out[ids[int(r["short_id"])]]["receipts"].append(r)
|
|
return out
|
|
|
|
|
|
def _stage(read: Mapping[str, Any]) -> Mapping[str, Any]:
|
|
"""The open stage, else the last closed one, else nothing."""
|
|
if read.get("stage"):
|
|
return read["stage"]
|
|
stages = read.get("stages") or []
|
|
return stages[-1] if stages else {}
|
|
|
|
|
|
def _inspection(conn: Any, read: Mapping[str, Any]) -> Mapping[str, Any] | None:
|
|
"""The open inspection, else the newest closed one (its snapshot carries every field)."""
|
|
if read.get("qc"):
|
|
return read["qc"]
|
|
r = packets_dom.one(
|
|
conn,
|
|
"SELECT result, inspector, at, snapshot FROM qc_history WHERE packet_id = %s ORDER BY id DESC LIMIT 1",
|
|
(read["id"],),
|
|
)
|
|
if r is None:
|
|
return None
|
|
snap = r["snapshot"] if isinstance(r["snapshot"], Mapping) else {}
|
|
return {
|
|
**snap,
|
|
"result": r["result"] or snap.get("result"),
|
|
"inspector": r["inspector"] or snap.get("inspector"),
|
|
"result_at": snap.get("result_at") or r["at"],
|
|
}
|
|
|
|
|
|
def rows_for(conn: Any, read: Mapping[str, Any], tz: str | None) -> list[list[Any]]:
|
|
"""Every CSV row for one packet read model."""
|
|
unit = read.get("unit") or {}
|
|
project = (unit.get("project") or {}).get("number") or ""
|
|
lot = unit.get("lot") or unit.get("number") or ""
|
|
stage = _stage(read)
|
|
welder = stage.get("welder") or {}
|
|
station = stage.get("station") or {}
|
|
out: list[list[Any]] = [
|
|
[TITLE],
|
|
["Project", project, "Lot", lot, "Operation", read.get("op")],
|
|
[
|
|
"Drawing",
|
|
read.get("drawing"),
|
|
"Rev",
|
|
read.get("rev"),
|
|
"Title",
|
|
read.get("title"),
|
|
"Customer",
|
|
read.get("customer"),
|
|
],
|
|
[
|
|
"WPS",
|
|
read.get("wps"),
|
|
"Station",
|
|
station.get("name"),
|
|
"Welder",
|
|
welder.get("display_name"),
|
|
"Stamp",
|
|
welder.get("stamp"),
|
|
],
|
|
["Filler", stage.get("filler"), "Filler lot", stage.get("filler_lot"), "Preheat", stage.get("preheat")],
|
|
[],
|
|
list(BOM_HEAD),
|
|
]
|
|
bom = read.get("bom") or []
|
|
shorts = _shorts(conn, int(read["id"]))
|
|
for b in bom:
|
|
s = shorts.get(int(b["id"])) if b.get("id") is not None else None
|
|
open_short = b.get("short")
|
|
heat = b.get("heat") or ("N/A" if b.get("na") else "")
|
|
received = "; ".join(
|
|
f"{_num(r['qty'])} pc {r['heat'] or ''} {fmt_d(r['at'], tz)}" for r in (s or {}).get("receipts", [])
|
|
)
|
|
out.append(
|
|
[
|
|
b.get("item"),
|
|
_num(b.get("qty")),
|
|
_num(b.get("issued")),
|
|
b.get("loc") or "",
|
|
b.get("part_id"),
|
|
b.get("description"),
|
|
b.get("length"),
|
|
b.get("material"),
|
|
heat,
|
|
"Yes" if b.get("mtr_asset_id") else "No",
|
|
_num(open_short.get("qty_open")) if open_short else "",
|
|
(s.get("reason") or "") if s else "",
|
|
received,
|
|
]
|
|
)
|
|
out += [[], list(WELD_HEAD)]
|
|
heat_of = {str(b.get("item")): b.get("heat") for b in bom}
|
|
for w in read.get("welds") or []:
|
|
items = [str(i) for i in (w.get("items") or [])]
|
|
out.append(
|
|
[
|
|
f"W{w.get('weld_no')}",
|
|
w.get("wo_base"),
|
|
w.get("piece"),
|
|
" + ".join(items),
|
|
" + ".join((heat_of.get(i) or "-") for i in items),
|
|
w.get("type"),
|
|
w.get("size"),
|
|
w.get("process"),
|
|
read.get("wps"),
|
|
w.get("stamp"),
|
|
fmt_dt(w.get("at"), tz),
|
|
]
|
|
)
|
|
out += [[], list(CHECK_HEAD)]
|
|
checklist = stage.get("checklist") or {}
|
|
for item in read.get("checklist_items") or []:
|
|
c = checklist.get(item["key"])
|
|
e = _entry(c)
|
|
out.append([item["text"], "Yes" if _done(c) else "No", e.get("stamp") or "", fmt_dt(e.get("at"), tz)])
|
|
insp = _inspection(conn, read)
|
|
if insp is not None:
|
|
out += [
|
|
[],
|
|
[
|
|
"Q.C. inspection",
|
|
insp.get("result") or "open",
|
|
"Inspector",
|
|
insp.get("inspector"),
|
|
"Stamp",
|
|
insp.get("stamp"),
|
|
"Date",
|
|
fmt_dt(insp.get("result_at"), tz),
|
|
],
|
|
[
|
|
"NDE",
|
|
insp.get("nde_method"),
|
|
insp.get("nde_result"),
|
|
insp.get("nde_report"),
|
|
"Hardness",
|
|
insp.get("hardness"),
|
|
],
|
|
]
|
|
checks = _entry(insp.get("checks"))
|
|
for item in read.get("qc_items") or []:
|
|
c = checks.get(item["key"])
|
|
e = _entry(c)
|
|
out.append(
|
|
[
|
|
"Q.C.: " + str(item["text"]),
|
|
"Yes" if _done(c) else "No",
|
|
e.get("stamp") or "",
|
|
fmt_dt(e.get("at"), tz),
|
|
]
|
|
)
|
|
exam = _entry(insp.get("weld_exam"))
|
|
for w in read.get("welds") or []:
|
|
v = _entry(exam.get(str(w.get("weld_no"))))
|
|
out.append([f"VT W{w.get('weld_no')}", v.get("result") or "", v.get("note") or ""])
|
|
return out
|
|
|
|
|
|
def build(conn: Any, settings: Any, packet_id: int) -> tuple[str, dict[str, Any]]:
|
|
"""The CSV text and the read model it came from. 404 not_found for an unknown packet."""
|
|
read = packets_dom.read_packet(conn, packet_id, None)
|
|
return to_csv(rows_for(conn, read, getattr(settings, "site_tz", None))), read
|