- Traveler: each work-order line shrinks, then clips, to fit its own box; 18 pt gap between boxes - Work orders tab: card text wraps inside the card, smaller work order, wider cards - Packet tabs: Materials before Heat map - Sessions: SESSION_DESK_MINUTES (60) for Admin and Clerical lock and sign-out; /me answers lock_minutes and the browser's idle lock follows it Co-Authored-By: Claude Opus 5.5 <[email protected]>
350 lines
13 KiB
Python
350 lines
13 KiB
Python
"""The job traveler, letter portrait, after the old buildTraveler: job info, the work orders with QR codes for
|
|
clocking on at the labor kiosk, the BOM and heat numbers, the checklist with blanks to stamp, the welds, the weld
|
|
record, the sign-offs and a notes box.
|
|
|
|
The QR text is exactly `*%2{wo_base}${lot}${split or "0"}${sub or "0"}${seq}%*`, built from the text columns as they
|
|
are (never cast).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import io
|
|
from collections.abc import Mapping
|
|
from datetime import datetime
|
|
from typing import Any
|
|
|
|
from pypdf import PdfReader, PdfWriter
|
|
from reportlab.graphics import renderPDF
|
|
from reportlab.graphics.barcode.qr import QrCodeWidget
|
|
from reportlab.graphics.shapes import Drawing
|
|
from reportlab.pdfbase.pdfmetrics import stringWidth
|
|
|
|
from . import parts, stamps
|
|
from .databook import heats_for
|
|
from .doc import Doc, font_of
|
|
from .style import CW, GREY, LINE, Built, L, PdfError, R, WhoRef, clean, fmt_d, fmt_dt, fmt_num, site_tz, utc_now
|
|
|
|
BLANK = "________"
|
|
BLANK_LONG = "________________"
|
|
QR_BOX_H = 96.0
|
|
QR_SIZE = 84.0
|
|
QR_GAP = 18.0 # between work-order boxes: a quiet zone so the kiosk scanner reads one code at a time
|
|
|
|
|
|
def fit_line(s: Any, width: float, size: float, *, bold: bool = False) -> tuple[str, float]:
|
|
"""One line that fits `width` (issue #42): shrink down to 70% of `size`, then cut with "..."."""
|
|
text, font, z = clean(s), font_of(bold), size
|
|
while z - 0.5 >= size * 0.7 and stringWidth(text, font, z) > width:
|
|
z -= 0.5
|
|
if stringWidth(text, font, z) > width:
|
|
while len(text) > 1 and stringWidth(text + "...", font, z) > width:
|
|
text = text[:-1]
|
|
text += "..."
|
|
return text, z
|
|
|
|
|
|
def qr_text(wo: Mapping[str, Any]) -> str:
|
|
"""The labor kiosk code of a work order (the old qrText)."""
|
|
split = str(wo.get("split") or "") or "0"
|
|
sub = str(wo.get("sub") or "") or "0"
|
|
seq = "" if wo.get("seq") is None else str(wo.get("seq"))
|
|
return f"*%2{wo.get('wo_base') or ''}${wo.get('lot') or ''}${split}${sub}${seq}%*"
|
|
|
|
|
|
def draw_qr(d: Doc, text: str, x: float, y_top: float, size: float) -> None:
|
|
"""A QR code (error correction M) `size` points square with its top-left corner at (x, y_top)."""
|
|
w = QrCodeWidget(text, barLevel="M", barBorder=0)
|
|
x0, y0, x1, y1 = w.getBounds()
|
|
bw, bh = x1 - x0, y1 - y0
|
|
drawing = Drawing(size, size, transform=[size / bw, 0, 0, size / bh, -x0 * size / bw, -y0 * size / bh])
|
|
drawing.add(w)
|
|
renderPDF.draw(drawing, d.c, x, d.Y(y_top + size))
|
|
|
|
|
|
def build(conn: Any, settings: Any, packet_id: int, *, who: WhoRef, now: datetime | None = None) -> Built:
|
|
"""The traveler with the controlled-copy stamp (mode traveler) and page numbers."""
|
|
now = utc_now(now)
|
|
p = parts.load_packet(conn, packet_id)
|
|
data, pages = pages_of(conn, settings, p)
|
|
warnings: list[str] = []
|
|
text = stamps.controlled_stamp(who, now, mode="traveler")
|
|
stamp, sig = stamps.signed_stamp(
|
|
settings, text, packet_id=p["id"], kind="traveler", seal_hash_hex=None, who=who, now=now, warnings=warnings
|
|
)
|
|
data = finish(data, p, stamp, now, box=False)
|
|
return Built(bytes=data, pages=pages, warnings=warnings, stamp=stamp, stamp_sig=sig, what="Job traveler")
|
|
|
|
|
|
def finish(data: bytes, p: Mapping[str, Any], stamp: str, now: datetime, *, box: bool) -> bytes:
|
|
"""Page numbers, the stamp (the line, or the PRINTED COPY box) and the metadata."""
|
|
project, lot = parts.tag_of(p)
|
|
writer = PdfWriter(clone_from=PdfReader(io.BytesIO(data)))
|
|
n = len(writer.pages)
|
|
|
|
def draw(c: Any, i: int, w: float, h: float) -> None:
|
|
del h
|
|
c.setFont("Helvetica", 7)
|
|
c.setFillColorRGB(GREY[0] / 255, GREY[1] / 255, GREY[2] / 255)
|
|
c.drawRightString(R, 20, clean(f"{project} Lot {lot} - Page {i + 1} of {n}"))
|
|
if not stamp:
|
|
return
|
|
if box:
|
|
stamps.draw_box_stamp(c, w, stamp)
|
|
else:
|
|
stamps.draw_line_stamp(c, w, stamp, own=True)
|
|
|
|
stamps.overlay_writer(writer, draw)
|
|
meta = stamps.metadata(
|
|
f"{project} Lot {lot} Job Traveler",
|
|
f"Job traveler, drawing {p.get('drawing') or ''} R{p.get('rev') or ''}",
|
|
", ".join(x for x in (project, f"Lot {lot}", str(p.get("drawing") or ""), "traveler") if x),
|
|
"Packet Station - controlled copy",
|
|
now=now,
|
|
)
|
|
return stamps.write(writer, meta)
|
|
|
|
|
|
def pages_of(conn: Any, settings: Any, p: Mapping[str, Any]) -> tuple[bytes, int]:
|
|
"""The traveler pages without page numbers or stamp, and their count."""
|
|
try:
|
|
d = _draw(conn, settings, p)
|
|
return d.finish(), d.pages
|
|
except PdfError:
|
|
raise
|
|
except Exception as e:
|
|
raise PdfError(f"The traveler could not be built: {type(e).__name__}.") from e
|
|
|
|
|
|
def _draw(conn: Any, settings: Any, p: Mapping[str, Any]) -> Doc:
|
|
tz = site_tz(settings)
|
|
project, lot = parts.tag_of(p)
|
|
proj = parts.project_of(p)
|
|
unit = p.get("unit") or {}
|
|
depts = parts.departments(conn)
|
|
dept_code = (p.get("dept") or {}).get("code")
|
|
bom = p.get("bom") or []
|
|
stage = p.get("stage") or ((p.get("stages") or [None])[-1] if p.get("stages") else None) or {}
|
|
d = Doc(
|
|
(
|
|
"Job Traveler",
|
|
f"{project} Lot {lot} - {p.get('op') or ''} - drawing {p.get('drawing') or ''} R{p.get('rev') or ''}",
|
|
)
|
|
)
|
|
y = 124.0
|
|
welder = p.get("welder") or {}
|
|
station = (p.get("station") or {}).get("name") if p.get("station") else ""
|
|
worker = [
|
|
str(station or ""),
|
|
str(welder.get("display_name") or ""),
|
|
f"stamp {welder['stamp']}" if welder.get("stamp") else "",
|
|
]
|
|
rows: list[tuple[str, Any]] = [
|
|
("Job / lot", f"{project} · Lot {lot}"),
|
|
("Project", f"{proj.get('number') or ''}" + (f" - {proj['name']}" if proj.get("name") else "")),
|
|
("Unit", f"{unit.get('number') or ''}" + (f" - {unit['name']}" if unit.get("name") else "")),
|
|
]
|
|
if proj.get("po"):
|
|
rows.append(("Customer PO", proj["po"]))
|
|
if unit.get("due_effective"):
|
|
rows.append(("Due / ship date", fmt_d(unit["due_effective"], tz)))
|
|
rows += [
|
|
("Operation", p.get("op")),
|
|
("Title", p.get("title")),
|
|
("Customer", p.get("customer")),
|
|
("Drawing", f"{p.get('drawing') or ''} Rev {p.get('rev') or ''}"),
|
|
("WPS", p.get("wps")),
|
|
("Department", (depts.get(str(dept_code)) or {}).get("label") or dept_code),
|
|
("Station / worker", " · ".join(x for x in worker if x)),
|
|
("Status", parts.STATUS_LABEL.get(str(p.get("status")), str(p.get("status") or ""))),
|
|
("Target hours", fmt_num(p.get("target_hrs") or 0)),
|
|
]
|
|
y = d.kv_table(rows, y)
|
|
|
|
# work orders with QR codes
|
|
y += 16
|
|
y = d.section_label("Work orders - scan at the labor kiosk to clock on in Visual", y)
|
|
wos = p.get("work_orders") or []
|
|
bw = (CW - QR_GAP) / 2
|
|
for i, w in enumerate(wos):
|
|
col = i % 2
|
|
if col == 0:
|
|
y = d.ensure_space(y, QR_BOX_H + QR_GAP)
|
|
x = L + col * (bw + QR_GAP)
|
|
d.rect(x, y, bw, QR_BOX_H, stroke=LINE, width=0.8)
|
|
draw_qr(d, qr_text(w), x + 6, y + 6, QR_SIZE)
|
|
tx0, tw = x + QR_BOX_H + 2, bw - QR_BOX_H - 8 # text stays inside its own box
|
|
lines: list[tuple[str, float, float, bool, Any]] = [
|
|
(w.get("wo_base") or "", 20, 12, True, None),
|
|
(
|
|
f"Lot {w.get('lot') or ''} Seq {'' if w.get('seq') is None else w.get('seq')} {w.get('op') or ''}",
|
|
36,
|
|
8.5,
|
|
False,
|
|
None,
|
|
),
|
|
(f"Target {fmt_num(w.get('target_hrs'))} h Qty {fmt_num(w.get('qty'))}", 50, 8.5, False, None),
|
|
(
|
|
f"Drawing {w.get('dwg') or p.get('drawing') or ''} Rev {w.get('rev') or p.get('rev') or ''}",
|
|
64,
|
|
7.5,
|
|
False,
|
|
GREY,
|
|
),
|
|
]
|
|
for s, dy, size, bold, color in lines:
|
|
text, z = fit_line(s, tw, size, bold=bold)
|
|
d.tx(text, tx0, y + dy, size=z, bold=bold, **({"color": color} if color else {}))
|
|
if col == 1 or i == len(wos) - 1:
|
|
y += QR_BOX_H + QR_GAP
|
|
if not wos:
|
|
y = d.note_box("No work orders were read from this packet.", y, True)
|
|
|
|
# BOM, the job pull
|
|
y += 10
|
|
y = d.section_label("Bill of materials and heat numbers (job pull)", y)
|
|
body = []
|
|
for b in bom:
|
|
short = b.get("short") or {}
|
|
body.append(
|
|
[
|
|
str(b.get("item")),
|
|
fmt_num(b.get("qty")),
|
|
fmt_num(b.get("issued")),
|
|
b.get("loc") or "",
|
|
(f"{b['part_id']}\n" if b.get("part_id") else "") + str(b.get("description") or ""),
|
|
b.get("material") or "",
|
|
str(b.get("heat") or "").strip() or ("N/A" if b.get("na") else BLANK),
|
|
"Yes" if b.get("mtr_asset_id") else "",
|
|
fmt_num(short.get("qty_open")) if short else "",
|
|
]
|
|
)
|
|
y = d.table(
|
|
["ITEM", "QTY", "ISSUED", "LOC", "PART ID / DESCRIPTION", "MATERIAL", "HEAT NO.", "MTR", "SHORT"],
|
|
body,
|
|
y + 4,
|
|
{
|
|
0: {"w": 28, "align": "center"},
|
|
1: {"w": 28, "align": "center"},
|
|
2: {"w": 34, "align": "center"},
|
|
3: {"w": 34},
|
|
5: {"w": 66},
|
|
6: {"w": 78, "bold": True},
|
|
7: {"w": 28},
|
|
8: {"w": 32, "align": "center"},
|
|
},
|
|
)
|
|
|
|
# checklist of the current department
|
|
y += 16
|
|
y = d.section_label("Checklist - stamp each line when done", y)
|
|
ck = stage.get("checklist") or {}
|
|
body = []
|
|
for it in p.get("checklist_items") or []:
|
|
e = ck.get(it.get("key"))
|
|
e = e if isinstance(e, Mapping) else ({"done": True} if e is True else {})
|
|
done = bool(e.get("done"))
|
|
body.append(
|
|
[
|
|
it.get("text") or "",
|
|
"YES" if done else "[ ]",
|
|
(e.get("stamp") or "") if done else BLANK,
|
|
fmt_d(e.get("at"), tz, empty="") if done else BLANK,
|
|
]
|
|
)
|
|
y = d.table(
|
|
["CHECK", "DONE", "STAMP", "DATE"], body, y + 4, {1: {"w": 40, "align": "center"}, 2: {"w": 70}, 3: {"w": 70}}
|
|
)
|
|
|
|
# welds
|
|
welds = p.get("welds") or []
|
|
if welds:
|
|
y += 16
|
|
y = d.section_label("Weld log", y)
|
|
body = [
|
|
[
|
|
f"W{w.get('weld_no')}",
|
|
" + ".join(str(i) for i in w.get("items") or []),
|
|
heats_for(bom, w.get("items")),
|
|
f"{w.get('type') or ''} {w.get('size') or ''}".strip(),
|
|
w.get("process") or "",
|
|
w.get("stamp") or "",
|
|
]
|
|
for w in welds
|
|
]
|
|
y = d.table(
|
|
["WELD", "JOINS ITEMS", "HEATS", "TYPE / SIZE", "PROCESS", "STAMP"],
|
|
body,
|
|
y + 4,
|
|
{0: {"w": 38, "bold": True}},
|
|
)
|
|
|
|
# filler and preheat
|
|
y += 16
|
|
y = d.section_label("Weld record", y)
|
|
y = d.kv_table(
|
|
[
|
|
("Filler metal", stage.get("filler") or BLANK_LONG),
|
|
("Filler lot / heat", stage.get("filler_lot") or BLANK_LONG),
|
|
("Preheat", stage.get("preheat") or BLANK_LONG),
|
|
],
|
|
y + 4,
|
|
)
|
|
|
|
# sign-offs
|
|
y += 16
|
|
y = d.section_label("Sign-offs", y)
|
|
signed = [e for e in parts.log_rows(conn, int(p["id"])) if e.get("signature_id") is not None]
|
|
if signed:
|
|
body = [
|
|
[
|
|
fmt_dt(e.get("at"), tz),
|
|
e.get("action") or "",
|
|
f"{e.get('sig_name') or e.get('by_name') or ''} ({e.get('sig_emp') or e.get('emp_no') or '?'})",
|
|
e.get("sig_stamp") or "",
|
|
]
|
|
for e in signed
|
|
]
|
|
y = d.table(["WHEN", "STEP", "SIGNED BY", "STAMP"], body, y + 4, {0: {"w": 96}, 3: {"w": 50}})
|
|
else:
|
|
body = [
|
|
[s, "____________________", BLANK]
|
|
for s in ("Work complete (stall)", "Supervisor review", "Q.C. inspection")
|
|
]
|
|
y = d.table(["STEP", "SIGNED BY", "DATE"], body, y + 4)
|
|
q = _last_qc(p)
|
|
if q:
|
|
y += 10
|
|
text = f"Q.C. {str(q['result']).upper()} {fmt_dt(q.get('at'), tz)}"
|
|
if q.get("inspector"):
|
|
text += f" by {q['inspector']}"
|
|
if q.get("note"):
|
|
text += f" - {q['note']}"
|
|
y = d.note_box(text, y, q["result"] != "accepted")
|
|
|
|
# notes box for the shop floor
|
|
y += 12
|
|
y = d.ensure_space(y, 130)
|
|
d.lbl("Shop notes", L, y)
|
|
y += 8
|
|
d.rect(L, y, CW, 110, stroke=LINE, width=0.8)
|
|
for k in range(1, 5):
|
|
d.line(L + 8, y + k * 22, R - 8, y + k * 22, LINE, 0.8)
|
|
return d
|
|
|
|
|
|
def _last_qc(p: Mapping[str, Any]) -> dict[str, Any] | None:
|
|
"""The open inspection when it has a result, else the newest closed one: {result, at, inspector, note}."""
|
|
cur = p.get("qc")
|
|
if cur and cur.get("result"):
|
|
return {
|
|
"result": cur["result"],
|
|
"at": cur.get("result_at"),
|
|
"inspector": cur.get("inspector"),
|
|
"note": cur.get("result_note"),
|
|
}
|
|
hist = [h for h in p.get("qc_history") or [] if h.get("result")]
|
|
if hist:
|
|
h = hist[-1]
|
|
return {"result": h.get("result"), "at": h.get("at"), "inspector": h.get("inspector"), "note": h.get("note")}
|
|
return None
|