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
|