2026-10-16 09:31:45 -06:00
|
|
|
|
"""The write pipeline: every packet write is one transaction through `packet_write`.
|
|
|
|
|
|
|
|
|
|
|
|
Inside one transaction: lock the packet row (SELECT ... FOR UPDATE), build the policy's PacketState, check the
|
|
|
|
|
|
caller's expected step (409 stale_step), check the packet version when If-Match is given (409 version_conflict),
|
Build Seth's changes C1 to C11 (Gitea issues 1, 8, 18, 23, 24, 25, 27, 31, 32, 33, 36)
2026-10-30 13:33:51 -06:00
|
|
|
|
ask the policy (403, 400 or 422 with the decision's code), take the signature when the rule needs one, hand the caller a
|
2026-10-16 09:31:45 -06:00
|
|
|
|
`Write`, then bump the version, write the packet_log row, the transition row and its side effects, the audit row and
|
|
|
|
|
|
the NOTIFY. The policy decides; a handler never does.
|
|
|
|
|
|
|
|
|
|
|
|
A wrong signature secret is the one failure that commits: the step-up failure count and the attempt row must stay,
|
|
|
|
|
|
so the three-strike rule works. Nothing else has been written at that point.
|
|
|
|
|
|
|
|
|
|
|
|
Child-row endpoints build on the same pieces: `check_row_version`, `next_counter`, `apply_field_cas`, `parse_if_match`.
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
2026-10-17 23:49:14 -06:00
|
|
|
|
import contextlib
|
2026-10-16 09:31:45 -06:00
|
|
|
|
import hashlib
|
|
|
|
|
|
import hmac
|
|
|
|
|
|
import logging
|
2026-10-17 23:49:14 -06:00
|
|
|
|
import os
|
|
|
|
|
|
import tempfile
|
2026-10-16 09:31:45 -06:00
|
|
|
|
from collections.abc import Iterator, Mapping, Sequence
|
|
|
|
|
|
from contextlib import contextmanager
|
|
|
|
|
|
from dataclasses import dataclass, field
|
2026-10-17 23:49:14 -06:00
|
|
|
|
from pathlib import Path
|
2026-10-16 09:31:45 -06:00
|
|
|
|
from typing import Any, Literal
|
|
|
|
|
|
|
|
|
|
|
|
from fastapi import Request
|
|
|
|
|
|
from psycopg.types.json import Jsonb
|
|
|
|
|
|
from pydantic import BaseModel, Field
|
|
|
|
|
|
|
|
|
|
|
|
from .. import audit, events, policy
|
|
|
|
|
|
from ..api import deps
|
|
|
|
|
|
from ..api.errors import ApiError
|
|
|
|
|
|
from ..auth import signatures
|
|
|
|
|
|
from ..auth.signatures import Signature
|
|
|
|
|
|
from ..policy.engine import MSG_FILED
|
|
|
|
|
|
from ..settings import SecretFileError
|
|
|
|
|
|
from ..text import LIMITS, clean_opt, reason_ok
|
Build Seth's changes C1 to C11 (Gitea issues 1, 8, 18, 23, 24, 25, 27, 31, 32, 33, 36)
2026-10-30 13:33:51 -06:00
|
|
|
|
from . import checklists, packets
|
2026-10-17 20:08:53 -06:00
|
|
|
|
from . import qc as qc_dom
|
2026-10-16 09:31:45 -06:00
|
|
|
|
|
|
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
|
|
A = policy.Action
|
|
|
|
|
|
|
|
|
|
|
|
MSG_STALE = "Someone else already took this step."
|
|
|
|
|
|
MSG_VERSION = "The packet changed since you opened it. Reload and try again."
|
|
|
|
|
|
MSG_ROW = "Someone else changed this line. Reload and try again."
|
|
|
|
|
|
MSG_FIELD = "Someone else changed this since you opened it."
|
2026-10-17 23:49:14 -06:00
|
|
|
|
MSG_DATABOOK = "The data book could not be built: {reason}. Nothing was filed."
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-16 09:31:45 -06:00
|
|
|
|
# Codes that answer 400 (the UI asks for input and retries); every other refusal answers 403.
|
|
|
|
|
|
ASK_CODES = frozenset({"it_reason_required", "reason_required"})
|
|
|
|
|
|
|
|
|
|
|
|
COUNTERS = {"weld": "next_weld_no", "heat": "next_heat_no", "page": "next_page_no", "co": "next_co_no"}
|
|
|
|
|
|
|
|
|
|
|
|
# The packet_log label per action, when the caller gives none.
|
|
|
|
|
|
LABELS: dict[policy.Action, str] = {
|
|
|
|
|
|
A.SEND_TO_WAREHOUSE: "Sent to warehouse",
|
|
|
|
|
|
A.RELEASE_TO_SHOP: "Sent straight to the shop",
|
|
|
|
|
|
A.SEND_DRAFT_TO_QC: "Sent straight to Q.C.",
|
|
|
|
|
|
A.WH_SEND_BACK: "Sent back to Clerical",
|
|
|
|
|
|
A.RETURN_TO_CLERICAL: "Sent back to Clerical",
|
2026-10-16 17:13:31 -06:00
|
|
|
|
A.WH_RELEASE: "Released to supervisor",
|
2026-10-16 09:31:45 -06:00
|
|
|
|
A.SEND_BACK_TO_WAREHOUSE: "Sent back to the warehouse",
|
|
|
|
|
|
A.ASSIGN: "Sent to a station",
|
|
|
|
|
|
A.UNASSIGN: "Taken off the station",
|
|
|
|
|
|
A.PULL_BACK: "Pulled back from the station",
|
|
|
|
|
|
A.START: "Work started",
|
2026-10-16 22:46:56 -06:00
|
|
|
|
A.SUBMIT: "Submitted to supervisor",
|
2026-10-16 09:31:45 -06:00
|
|
|
|
A.RETURN_TO_STATION: "Returned to the station",
|
|
|
|
|
|
A.SEND_TO_QC: "Sent to Q.C.",
|
|
|
|
|
|
A.ROUTE_FROM_REVIEW: "Routed to another department",
|
|
|
|
|
|
A.QC_REJECT: "Q.C. rejected",
|
|
|
|
|
|
A.QC_ACCEPT: "Q.C. accepted",
|
|
|
|
|
|
A.QC_ACCEPT_AND_ROUTE: "Q.C. accepted and routed",
|
|
|
|
|
|
A.FILE: "Filed in the data book",
|
|
|
|
|
|
A.ADMIN_REOPEN: "Reopened by IT",
|
|
|
|
|
|
A.EDIT_HEADER: "Details changed",
|
|
|
|
|
|
A.EDIT_DRAWING_REF: "Drawing number changed",
|
|
|
|
|
|
A.EDIT_DEPT: "Department changed",
|
|
|
|
|
|
A.MOVE_UNIT: "Moved to another unit",
|
|
|
|
|
|
A.EDIT_WOS: "Work orders changed",
|
|
|
|
|
|
A.EDIT_DOCUMENT_SET: "Drawing attached by hand",
|
|
|
|
|
|
A.DOC_ADD: "Document added",
|
|
|
|
|
|
A.DOC_REMOVE: "Document removed",
|
|
|
|
|
|
A.SET_VCLOSED: "Job closed in VISUAL",
|
|
|
|
|
|
A.SET_DATABOOK_AT: "Data book date set",
|
|
|
|
|
|
A.REMOVE: "Packet removed",
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
AUDIT_ACTIONS: dict[policy.Action, str] = {A.REMOVE: "packet.removed", A.MOVE_UNIT: "packet.moved"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------- request bodies
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class SignBody(BaseModel):
|
|
|
|
|
|
factor: Literal["pin", "password"]
|
|
|
|
|
|
secret: str = Field(max_length=1024)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class Expect(BaseModel):
|
|
|
|
|
|
status: str = Field(max_length=20)
|
|
|
|
|
|
dept: str = Field(max_length=10)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------- the Write object
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
|
class Write:
|
|
|
|
|
|
conn: Any # the request connection, inside one transaction
|
|
|
|
|
|
principal: deps.Principal # the session principal
|
|
|
|
|
|
policy_principal: policy.Principal
|
|
|
|
|
|
packet: dict[str, Any] # the packets row, locked FOR UPDATE
|
|
|
|
|
|
state: policy.PacketState
|
|
|
|
|
|
decision: policy.Decision
|
|
|
|
|
|
signature: Signature | None
|
|
|
|
|
|
version: int # the new version after the bump (set on exit)
|
|
|
|
|
|
action: policy.Action
|
|
|
|
|
|
extra: Mapping[str, Any]
|
|
|
|
|
|
it_reason: str | None # the cleaned IT reason, kept only when the admin bypass was used
|
|
|
|
|
|
request: Request
|
|
|
|
|
|
label: str | None = None
|
|
|
|
|
|
note: str | None = None
|
|
|
|
|
|
fields: list[str] = field(default_factory=list)
|
|
|
|
|
|
detail: dict[str, Any] = field(default_factory=dict)
|
|
|
|
|
|
transitioned: bool = False
|
|
|
|
|
|
transition_id: int | None = None
|
|
|
|
|
|
noop: bool = False
|
|
|
|
|
|
from_status: str = ""
|
|
|
|
|
|
from_dept: str = ""
|
2026-10-16 22:46:56 -06:00
|
|
|
|
children: Any = None # packets.Children loaded under the lock (full=False)
|
|
|
|
|
|
more_logs: list[tuple[str, str | None]] = field(default_factory=list)
|
2026-10-16 09:31:45 -06:00
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
|
def id(self) -> int:
|
|
|
|
|
|
return int(self.packet["id"])
|
|
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
|
def user_id(self) -> Any:
|
|
|
|
|
|
return self.principal.user_id
|
|
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
|
def rule(self) -> policy.Rule:
|
|
|
|
|
|
return policy.RULES[self.action]
|
|
|
|
|
|
|
2026-10-17 23:49:14 -06:00
|
|
|
|
@property
|
|
|
|
|
|
def settings(self) -> Any:
|
|
|
|
|
|
return _settings(self)
|
|
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
|
def actor_ref(self) -> Any:
|
|
|
|
|
|
"""The signed-in person as the PDF builders' WhoRef: id, display name and employee number."""
|
|
|
|
|
|
from . import prints
|
|
|
|
|
|
|
|
|
|
|
|
return prints.who_of(self.principal)
|
|
|
|
|
|
|
2026-10-16 09:31:45 -06:00
|
|
|
|
def log(self, action_label: str, note: str | None = None) -> None:
|
|
|
|
|
|
"""Override the packet_log label (and note) for this write."""
|
|
|
|
|
|
self.label = action_label
|
|
|
|
|
|
if note is not None:
|
|
|
|
|
|
self.note = note
|
|
|
|
|
|
|
2026-10-16 22:46:56 -06:00
|
|
|
|
def log_also(self, action_label: str, note: str | None = None) -> None:
|
|
|
|
|
|
"""One more packet_log line for this write, written after the main one (same user, no signature)."""
|
|
|
|
|
|
self.more_logs.append((action_label, note))
|
|
|
|
|
|
|
2026-10-16 09:31:45 -06:00
|
|
|
|
def changed(self, *names: str) -> None:
|
|
|
|
|
|
"""Record the fields this write changed (they go into the audit detail)."""
|
|
|
|
|
|
for n in names:
|
|
|
|
|
|
if n not in self.fields:
|
|
|
|
|
|
self.fields.append(n)
|
|
|
|
|
|
|
|
|
|
|
|
def skip(self) -> None:
|
|
|
|
|
|
"""Nothing changed: no version bump, no log, audit or event."""
|
|
|
|
|
|
self.noop = True
|
|
|
|
|
|
|
|
|
|
|
|
def check(self, action: policy.Action, extra: Mapping[str, Any] | None = None) -> policy.Decision:
|
|
|
|
|
|
"""Check one more action on the same locked packet (for a write that touches several rules)."""
|
|
|
|
|
|
d = policy.check(self.policy_principal, self.state, action, extra=extra or self.extra, it_reason=self.it_reason)
|
|
|
|
|
|
refuse(d)
|
|
|
|
|
|
return d
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------- helpers for callers
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def refuse(decision: policy.Decision) -> None:
|
|
|
|
|
|
"""Raise the API error for a failed policy decision."""
|
|
|
|
|
|
if decision.ok:
|
|
|
|
|
|
return
|
Build Seth's changes C1 to C11 (Gitea issues 1, 8, 18, 23, 24, 25, 27, 31, 32, 33, 36)
2026-10-30 13:33:51 -06:00
|
|
|
|
status = 400 if decision.code in ASK_CODES else 422 if decision.code == "ncr_required" else 403
|
2026-10-16 09:31:45 -06:00
|
|
|
|
raise ApiError(status, decision.code, decision.why)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def current_of(row: Mapping[str, Any]) -> dict[str, Any]:
|
|
|
|
|
|
return {"status": str(row["status"]), "dept": str(row["dept_code"]), "version": int(row["version"])}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def parse_if_match(request: Request) -> int | None:
|
|
|
|
|
|
"""The If-Match header as a plain integer (quotes and a W/ prefix are tolerated), or None when absent."""
|
|
|
|
|
|
raw = request.headers.get("if-match")
|
|
|
|
|
|
if raw is None:
|
|
|
|
|
|
return None
|
|
|
|
|
|
text = raw.strip().removeprefix("W/").strip().strip('"')
|
|
|
|
|
|
try:
|
|
|
|
|
|
return int(text)
|
|
|
|
|
|
except ValueError:
|
|
|
|
|
|
raise ApiError(400, "bad_if_match", "If-Match must be a version number.") from None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def require_if_match(request: Request) -> int:
|
|
|
|
|
|
v = parse_if_match(request)
|
|
|
|
|
|
if v is None:
|
|
|
|
|
|
raise ApiError(428, "if_match_required", "Send If-Match with the version you started from.")
|
|
|
|
|
|
return v
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def check_row_version(row: Mapping[str, Any] | None, if_match: int | None, *, current: Any = None) -> None:
|
|
|
|
|
|
"""409 row_conflict with the current row when its row_version differs from If-Match.
|
|
|
|
|
|
|
|
|
|
|
|
`current` is what goes into the error (defaults to the row itself, JSON-shaped by the caller if needed).
|
|
|
|
|
|
A missing If-Match is 428 if_match_required.
|
|
|
|
|
|
"""
|
|
|
|
|
|
if row is None:
|
|
|
|
|
|
raise ApiError(404, "not_found", "No such line.")
|
|
|
|
|
|
if if_match is None:
|
|
|
|
|
|
raise ApiError(428, "if_match_required", "Send If-Match with the version you started from.")
|
|
|
|
|
|
if int(row["row_version"]) != int(if_match):
|
|
|
|
|
|
raise ApiError(
|
|
|
|
|
|
409, "row_conflict", MSG_ROW, extra={"current": jsonable(current if current is not None else row)}
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def next_counter(conn: Any, packet_id: int, kind: str, n: int = 1) -> int:
|
|
|
|
|
|
"""Take `n` consecutive numbers from a packet counter (weld, heat, page, co); returns the first one.
|
|
|
|
|
|
|
|
|
|
|
|
Call it inside packet_write: the packet row is locked FOR UPDATE, so concurrent writers serialise on it.
|
|
|
|
|
|
Outside a pipeline the UPDATE itself takes the row lock for the rest of the transaction.
|
|
|
|
|
|
"""
|
|
|
|
|
|
col = COUNTERS.get(kind)
|
|
|
|
|
|
if col is None:
|
|
|
|
|
|
raise ValueError(f"unknown counter {kind!r}")
|
|
|
|
|
|
if n < 1:
|
|
|
|
|
|
raise ValueError("n must be at least 1")
|
|
|
|
|
|
row = conn.execute(
|
|
|
|
|
|
f"UPDATE packets SET {col} = {col} + %s WHERE id = %s RETURNING {col} - %s", # noqa: S608 (col from COUNTERS)
|
|
|
|
|
|
(n, packet_id, n),
|
|
|
|
|
|
).fetchone()
|
|
|
|
|
|
if row is None:
|
|
|
|
|
|
raise ApiError(404, "not_found", "No such packet.")
|
|
|
|
|
|
return int(row[0])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _norm(value: Any) -> Any:
|
|
|
|
|
|
"""Comparable form of a value: uuid and dates as text, numbers as numbers, '' as None."""
|
|
|
|
|
|
if value is None or value == "":
|
|
|
|
|
|
return None
|
|
|
|
|
|
if isinstance(value, bool | int | float | str):
|
|
|
|
|
|
return value
|
|
|
|
|
|
return str(value)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def apply_field_cas(
|
|
|
|
|
|
current: Mapping[str, Any], set_: Mapping[str, Any], base: Mapping[str, Any] | None
|
|
|
|
|
|
) -> dict[str, Any]:
|
|
|
|
|
|
"""Field-level compare-and-set. For every key in set_ that base also has, base must equal current.
|
|
|
|
|
|
|
|
|
|
|
|
Conflicts raise 409 field_conflict with `current: {key: value}` for the conflicting keys. Keys without a base
|
|
|
|
|
|
entry are written without a check (the old merge3: mine wins). Returns the keys whose value actually changes.
|
|
|
|
|
|
"""
|
|
|
|
|
|
base = base or {}
|
|
|
|
|
|
conflicts = {k: current.get(k) for k in set_ if k in base and _norm(base[k]) != _norm(current.get(k))}
|
|
|
|
|
|
if conflicts:
|
|
|
|
|
|
raise ApiError(409, "field_conflict", MSG_FIELD, extra={"current": jsonable(conflicts)})
|
|
|
|
|
|
return {k: v for k, v in set_.items() if _norm(v) != _norm(current.get(k))}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def jsonable(value: Any) -> Any:
|
|
|
|
|
|
from fastapi.encoders import jsonable_encoder
|
|
|
|
|
|
|
|
|
|
|
|
return jsonable_encoder(value)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------- the pipeline
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@contextmanager
|
|
|
|
|
|
def packet_write(
|
|
|
|
|
|
d: deps.Db,
|
|
|
|
|
|
p: deps.Principal,
|
|
|
|
|
|
packet_id: int,
|
|
|
|
|
|
action: policy.Action,
|
|
|
|
|
|
*,
|
|
|
|
|
|
request: Request,
|
|
|
|
|
|
extra: Mapping[str, Any] | None = None,
|
|
|
|
|
|
it_reason: str | None = None,
|
|
|
|
|
|
sign: SignBody | None = None,
|
|
|
|
|
|
expect: Expect | None = None,
|
|
|
|
|
|
sign_content: Any = None,
|
|
|
|
|
|
if_match: int | None = None,
|
|
|
|
|
|
also: Sequence[policy.Action] = (),
|
|
|
|
|
|
require_sign: bool | None = None,
|
|
|
|
|
|
policy_principal: policy.Principal | None = None,
|
|
|
|
|
|
) -> Iterator[Write]:
|
|
|
|
|
|
"""One packet write. See the module docstring for the order of steps.
|
|
|
|
|
|
|
|
|
|
|
|
`also` lists more actions the same write needs (each is checked; the first action is the one logged).
|
|
|
|
|
|
`require_sign` overrides the rule's signature: False skips it (removing a hand-attached drawing), True forces it.
|
|
|
|
|
|
"""
|
|
|
|
|
|
conn = d.conn
|
|
|
|
|
|
extra = dict(extra or {})
|
|
|
|
|
|
pp = policy_principal or packets.policy_principal_for(conn, p)
|
|
|
|
|
|
failure: ApiError | None = None
|
|
|
|
|
|
with conn.transaction():
|
|
|
|
|
|
row = packets.load_row(conn, packet_id, lock=True)
|
|
|
|
|
|
if row is None:
|
|
|
|
|
|
raise ApiError(404, "not_found", "No such packet.")
|
|
|
|
|
|
ch = packets.load_children(conn, row, full=False)
|
|
|
|
|
|
state = packets.state_from(row, ch)
|
|
|
|
|
|
if expect is not None and (expect.status != row["status"] or expect.dept != row["dept_code"]):
|
|
|
|
|
|
raise ApiError(409, "stale_step", MSG_STALE, extra={"current": current_of(row)})
|
|
|
|
|
|
extra.setdefault("holder_name", packets.holder_text(state, ch))
|
|
|
|
|
|
rule = policy.RULES[action]
|
|
|
|
|
|
if not state.removed and state.status == "filed" and not rule.filed_ok:
|
|
|
|
|
|
# Say "filed" rather than "wrong status" or "give an IT reason": nothing but a reopen changes it.
|
|
|
|
|
|
raise ApiError(403, "filed", MSG_FILED)
|
|
|
|
|
|
cleaned_it = reason_ok(it_reason)
|
|
|
|
|
|
decision = policy.check(pp, state, action, extra=extra, it_reason=it_reason)
|
|
|
|
|
|
refuse(decision)
|
|
|
|
|
|
bypass = decision.admin_bypass
|
|
|
|
|
|
for other in also:
|
|
|
|
|
|
dd = policy.check(pp, state, other, extra=extra, it_reason=it_reason)
|
|
|
|
|
|
refuse(dd)
|
|
|
|
|
|
bypass = bypass or dd.admin_bypass
|
|
|
|
|
|
if if_match is not None and int(if_match) != int(row["version"]):
|
|
|
|
|
|
raise ApiError(409, "version_conflict", MSG_VERSION, extra={"current": current_of(row)})
|
|
|
|
|
|
w = Write(
|
|
|
|
|
|
conn=conn,
|
|
|
|
|
|
principal=p,
|
|
|
|
|
|
policy_principal=pp,
|
|
|
|
|
|
packet=row,
|
|
|
|
|
|
state=state,
|
|
|
|
|
|
decision=decision,
|
|
|
|
|
|
signature=None,
|
|
|
|
|
|
version=int(row["version"]),
|
|
|
|
|
|
action=action,
|
|
|
|
|
|
extra=extra,
|
|
|
|
|
|
it_reason=cleaned_it if bypass else None,
|
|
|
|
|
|
request=request,
|
|
|
|
|
|
from_status=str(row["status"]),
|
|
|
|
|
|
from_dept=str(row["dept_code"]),
|
2026-10-16 22:46:56 -06:00
|
|
|
|
children=ch,
|
2026-10-16 09:31:45 -06:00
|
|
|
|
)
|
|
|
|
|
|
meaning = decision.signature
|
|
|
|
|
|
needs = bool(meaning) if require_sign is None else require_sign
|
|
|
|
|
|
if needs:
|
|
|
|
|
|
if sign is None:
|
|
|
|
|
|
raise ApiError(
|
|
|
|
|
|
400,
|
|
|
|
|
|
"signature_required",
|
|
|
|
|
|
"This step needs your signature.",
|
|
|
|
|
|
extra={"meaning": meaning or LABELS.get(action, str(action))},
|
|
|
|
|
|
)
|
|
|
|
|
|
try:
|
|
|
|
|
|
w.signature = signatures.sign(
|
|
|
|
|
|
conn,
|
|
|
|
|
|
deps.directory_of(request),
|
|
|
|
|
|
p.actor,
|
|
|
|
|
|
factor=sign.factor,
|
|
|
|
|
|
secret=sign.secret,
|
|
|
|
|
|
action=f"packet.{action}",
|
|
|
|
|
|
meaning=meaning,
|
|
|
|
|
|
packet_id=packet_id,
|
|
|
|
|
|
subject_type="packet",
|
|
|
|
|
|
subject_id=packet_id,
|
|
|
|
|
|
content=sign_content
|
|
|
|
|
|
if sign_content is not None
|
|
|
|
|
|
else {"action": str(action), "packet_id": packet_id, "version": int(row["version"])},
|
|
|
|
|
|
ip=p.ip,
|
|
|
|
|
|
settings=deps.settings_of(request),
|
|
|
|
|
|
)
|
|
|
|
|
|
except ApiError as e:
|
|
|
|
|
|
failure = e # commit the step-up failure records, then raise below
|
|
|
|
|
|
if failure is None:
|
|
|
|
|
|
yield w
|
|
|
|
|
|
_finish(w)
|
|
|
|
|
|
if failure is not None:
|
|
|
|
|
|
raise failure
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _finish(w: Write) -> None:
|
|
|
|
|
|
if w.noop:
|
|
|
|
|
|
return
|
|
|
|
|
|
if w.rule.transition and not w.transitioned:
|
|
|
|
|
|
apply_transition(w)
|
|
|
|
|
|
conn = w.conn
|
|
|
|
|
|
w.version = int(
|
|
|
|
|
|
conn.execute("UPDATE packets SET version = version + 1 WHERE id = %s RETURNING version", (w.id,)).fetchone()[0]
|
|
|
|
|
|
)
|
|
|
|
|
|
after = conn.execute("SELECT status::text, dept_code FROM packets WHERE id = %s", (w.id,)).fetchone()
|
|
|
|
|
|
status, dept = str(after[0]), str(after[1])
|
|
|
|
|
|
label = w.label or LABELS.get(w.action, str(w.action).replace("_", " ").capitalize())
|
|
|
|
|
|
note = clean_opt(w.note, LIMITS["note"]) if w.note else None
|
|
|
|
|
|
sig_id = w.signature.id if w.signature else None
|
|
|
|
|
|
conn.execute(
|
|
|
|
|
|
"INSERT INTO packet_log (packet_id, by, by_name, role, action, note, it_reason, signature_id, transition_id)"
|
|
|
|
|
|
" VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)",
|
|
|
|
|
|
(
|
|
|
|
|
|
w.id,
|
|
|
|
|
|
w.user_id,
|
|
|
|
|
|
w.principal.user["display_name"],
|
|
|
|
|
|
w.principal.role_snapshot,
|
|
|
|
|
|
label,
|
|
|
|
|
|
note,
|
|
|
|
|
|
w.it_reason,
|
|
|
|
|
|
sig_id,
|
|
|
|
|
|
w.transition_id,
|
|
|
|
|
|
),
|
|
|
|
|
|
)
|
2026-10-16 22:46:56 -06:00
|
|
|
|
for extra_label, extra_note in w.more_logs:
|
|
|
|
|
|
conn.execute(
|
|
|
|
|
|
"INSERT INTO packet_log (packet_id, by, by_name, role, action, note, it_reason)"
|
|
|
|
|
|
" VALUES (%s, %s, %s, %s, %s, %s, %s)",
|
|
|
|
|
|
(
|
|
|
|
|
|
w.id,
|
|
|
|
|
|
w.user_id,
|
|
|
|
|
|
w.principal.user["display_name"],
|
|
|
|
|
|
w.principal.role_snapshot,
|
|
|
|
|
|
extra_label,
|
|
|
|
|
|
clean_opt(extra_note, LIMITS["note"]) if extra_note else None,
|
|
|
|
|
|
w.it_reason,
|
|
|
|
|
|
),
|
|
|
|
|
|
)
|
2026-10-16 09:31:45 -06:00
|
|
|
|
detail: dict[str, Any] = {"from": w.from_status, "to": status, "dept": dept}
|
|
|
|
|
|
if w.from_dept != dept:
|
|
|
|
|
|
detail["from_dept"] = w.from_dept
|
|
|
|
|
|
if w.fields:
|
|
|
|
|
|
detail["fields"] = w.fields
|
|
|
|
|
|
if note:
|
|
|
|
|
|
detail["note"] = note
|
|
|
|
|
|
if sig_id:
|
|
|
|
|
|
detail["signature_id"] = sig_id
|
|
|
|
|
|
detail.update(w.detail)
|
|
|
|
|
|
audit.append(
|
|
|
|
|
|
conn,
|
|
|
|
|
|
action=AUDIT_ACTIONS.get(w.action, f"packet.{w.action}"),
|
|
|
|
|
|
user_id=w.user_id,
|
|
|
|
|
|
session_id=w.principal.session_id,
|
|
|
|
|
|
role_snapshot=w.principal.role_snapshot,
|
|
|
|
|
|
subject_type="packet",
|
|
|
|
|
|
subject_id=w.id,
|
|
|
|
|
|
packet_id=w.id,
|
|
|
|
|
|
detail=detail,
|
|
|
|
|
|
it_reason=w.it_reason,
|
|
|
|
|
|
ip=w.principal.ip,
|
|
|
|
|
|
)
|
|
|
|
|
|
events.publish(conn, {"event": "changed", "packet_id": w.id, "version": w.version, "status": status, "dept": dept})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------- transitions and their side effects
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
CLOCK_STOP = (
|
|
|
|
|
|
"clock_elapsed_ms = clock_elapsed_ms + CASE WHEN clock_running_since IS NULL THEN 0"
|
|
|
|
|
|
" ELSE (extract(epoch FROM (now() - clock_running_since)) * 1000)::bigint END, clock_running_since = NULL"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _reason(w: Write) -> str | None:
|
|
|
|
|
|
return reason_ok(w.extra.get("reason"))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _note(w: Write) -> str | None:
|
|
|
|
|
|
return clean_opt(w.extra.get("note"), LIMITS["note"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _wh_incoming(w: Write) -> None:
|
|
|
|
|
|
w.conn.execute("UPDATE packet_wh SET closed_at = now() WHERE packet_id = %s AND closed_at IS NULL", (w.id,))
|
|
|
|
|
|
w.conn.execute(
|
|
|
|
|
|
"INSERT INTO packet_wh (packet_id, stage, by, note) VALUES (%s, 'incoming', %s, %s)",
|
|
|
|
|
|
(w.id, w.user_id, _note(w)),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _wh_close(w: Write) -> None:
|
|
|
|
|
|
w.conn.execute("UPDATE packet_wh SET closed_at = now() WHERE packet_id = %s AND closed_at IS NULL", (w.id,))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _open_stage(w: Write, dept: str) -> None:
|
|
|
|
|
|
w.conn.execute("INSERT INTO packet_stages (packet_id, dept_code) VALUES (%s, %s)", (w.id, dept))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _close_stage(w: Write, qc_snapshot: Any = None) -> None:
|
|
|
|
|
|
w.conn.execute(
|
|
|
|
|
|
f"UPDATE packet_stages SET {CLOCK_STOP}, closed_at = now(), qc_snapshot = coalesce(%s, qc_snapshot)," # noqa: S608
|
|
|
|
|
|
" row_version = row_version + 1 WHERE packet_id = %s AND closed_at IS NULL",
|
|
|
|
|
|
(None if qc_snapshot is None else Jsonb(jsonable(qc_snapshot)), w.id),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _stage_update(w: Write, assignments: str, params: Sequence[Any] = ()) -> None:
|
|
|
|
|
|
w.conn.execute(
|
|
|
|
|
|
f"UPDATE packet_stages SET {assignments}, row_version = row_version + 1" # noqa: S608 (fixed fragments)
|
|
|
|
|
|
" WHERE packet_id = %s AND closed_at IS NULL",
|
|
|
|
|
|
(*params, w.id),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _open_inspection(w: Write) -> dict[str, Any] | None:
|
|
|
|
|
|
return packets.one(w.conn, "SELECT * FROM qc_inspections WHERE packet_id = %s AND closed_at IS NULL", (w.id,))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _close_inspection(w: Write, insp: Mapping[str, Any] | None, dept: str) -> None:
|
|
|
|
|
|
"""Close the open inspection; when it had a result, keep it in qc_history."""
|
|
|
|
|
|
if insp is None:
|
|
|
|
|
|
return
|
|
|
|
|
|
w.conn.execute(
|
|
|
|
|
|
"UPDATE qc_inspections SET closed_at = now(), row_version = row_version + 1 WHERE id = %s", (insp["id"],)
|
|
|
|
|
|
)
|
|
|
|
|
|
if insp.get("result"):
|
|
|
|
|
|
w.conn.execute(
|
|
|
|
|
|
"INSERT INTO qc_history (packet_id, result, inspector, note, dept_code, snapshot)"
|
|
|
|
|
|
" VALUES (%s, %s, %s, %s, %s, %s)",
|
|
|
|
|
|
(w.id, insp["result"], insp.get("inspector"), insp.get("result_note"), dept, Jsonb(jsonable(dict(insp)))),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _set_result(w: Write, insp: Mapping[str, Any] | None, result: str, note: str | None) -> dict[str, Any] | None:
|
|
|
|
|
|
if insp is None:
|
|
|
|
|
|
return None
|
|
|
|
|
|
return packets.one(
|
|
|
|
|
|
w.conn,
|
|
|
|
|
|
"UPDATE qc_inspections SET result = %s, result_at = now(), result_by = %s, result_note = %s,"
|
|
|
|
|
|
" signature_id = %s, row_version = row_version + 1 WHERE id = %s RETURNING *",
|
|
|
|
|
|
(result, w.user_id, note, w.signature.id if w.signature else None, insp["id"]),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
Build Seth's changes C1 to C11 (Gitea issues 1, 8, 18, 23, 24, 25, 27, 31, 32, 33, 36)
2026-10-30 13:33:51 -06:00
|
|
|
|
def _set_ncr(w: Write, insp: Mapping[str, Any] | None) -> None:
|
|
|
|
|
|
"""The NCR number the step carries (a reject, or the accept after a reject without one), on the inspection."""
|
|
|
|
|
|
ncr = w.extra.get("ncr") or None
|
|
|
|
|
|
if insp is not None and (ncr or w.action is A.QC_REJECT):
|
|
|
|
|
|
w.conn.execute("UPDATE qc_inspections SET ncr = %s WHERE id = %s", (ncr, insp["id"]))
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-16 09:31:45 -06:00
|
|
|
|
def _set_packet(w: Write, assignments: str, params: Sequence[Any] = ()) -> None:
|
|
|
|
|
|
w.conn.execute(f"UPDATE packets SET {assignments} WHERE id = %s", (*params, w.id)) # noqa: S608 (fixed fragments)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _settings(w: Write) -> Any:
|
|
|
|
|
|
return deps.settings_of(w.request)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def seal_key_id(key: bytes) -> str:
|
|
|
|
|
|
return hashlib.sha256(key).hexdigest()[:8]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def seal_hash(key: bytes, content: Any) -> bytes:
|
|
|
|
|
|
return hmac.new(key, signatures.canonical_json(content), hashlib.sha256).digest()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _seal_key(w: Write) -> bytes:
|
|
|
|
|
|
try:
|
|
|
|
|
|
key: bytes = _settings(w).seal_key()
|
|
|
|
|
|
except SecretFileError as e:
|
|
|
|
|
|
log.error("seal key unavailable: %s", e)
|
|
|
|
|
|
raise ApiError(503, "server_misconfigured", "The server is missing its seal key.") from None
|
|
|
|
|
|
return key
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-16 17:13:31 -06:00
|
|
|
|
def qty_text(value: Any) -> str:
|
|
|
|
|
|
"""A quantity without trailing zeros: 4, 2.5."""
|
|
|
|
|
|
if value is None:
|
|
|
|
|
|
return "0"
|
|
|
|
|
|
try:
|
|
|
|
|
|
f = float(value)
|
|
|
|
|
|
except (TypeError, ValueError):
|
|
|
|
|
|
return str(value)
|
|
|
|
|
|
return str(int(f)) if f.is_integer() else f"{f:g}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _release_text(w: Write) -> None:
|
|
|
|
|
|
"""The old release label and note.
|
|
|
|
|
|
|
|
|
|
|
|
Label "Released to supervisor", or "... with {n} short"; note "{heats} heats recorded", plus " · SHORT: ..."
|
|
|
|
|
|
when shorts are open.
|
|
|
|
|
|
"""
|
|
|
|
|
|
lines = packets.rows(
|
|
|
|
|
|
w.conn,
|
|
|
|
|
|
"SELECT b.item, b.heat, s.qty_open, s.reason FROM bom_lines b LEFT JOIN bom_shorts s"
|
|
|
|
|
|
" ON s.bom_line_id = b.id AND s.closed_at IS NULL WHERE b.packet_id = %s ORDER BY b.item",
|
|
|
|
|
|
(w.id,),
|
|
|
|
|
|
)
|
|
|
|
|
|
heats = sum(1 for b in lines if str(b["heat"] or "").strip())
|
|
|
|
|
|
shorts = [b for b in lines if b["qty_open"] is not None]
|
|
|
|
|
|
note = f"{heats} heats recorded"
|
|
|
|
|
|
if shorts:
|
|
|
|
|
|
note += " · SHORT: " + ", ".join(
|
|
|
|
|
|
f"item {b['item']} ×{qty_text(b['qty_open'])} ({b['reason'] or ''})" for b in shorts
|
|
|
|
|
|
)
|
|
|
|
|
|
w.log(f"{LABELS[A.WH_RELEASE]} with {len(shorts)} short", note)
|
|
|
|
|
|
else:
|
|
|
|
|
|
w.log(LABELS[A.WH_RELEASE], note)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-16 22:46:56 -06:00
|
|
|
|
def hours_text(ms: Any) -> str:
|
|
|
|
|
|
"""Stage clock hours with one decimal: 1.5 h."""
|
|
|
|
|
|
return f"{int(ms or 0) / 3600000:.1f} h"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _station_name(w: Write, station_id: Any) -> str | None:
|
|
|
|
|
|
if station_id is None:
|
|
|
|
|
|
return None
|
|
|
|
|
|
ch = w.children
|
|
|
|
|
|
if ch is not None and ch.station and ch.station.get("id") == station_id:
|
|
|
|
|
|
return str(ch.station["name"])
|
|
|
|
|
|
r = w.conn.execute("SELECT name FROM stations WHERE id = %s", (station_id,)).fetchone()
|
|
|
|
|
|
return str(r[0]) if r else None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _dept_text(w: Write, code: str) -> str:
|
|
|
|
|
|
labels = getattr(w.children, "dept_labels", None) or packets.dept_labels(w.conn)
|
|
|
|
|
|
return str(labels.get(code, code))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _shop_texts(w: Write, a: policy.Action, to_dept: str, stamp: str | None = None) -> None:
|
|
|
|
|
|
"""The packet_log label and note of a shop step (the old click handlers' texts)."""
|
|
|
|
|
|
ex = w.extra
|
|
|
|
|
|
here = _station_name(w, w.packet["station_id"]) or "the station"
|
|
|
|
|
|
note = _note(w)
|
|
|
|
|
|
if a is A.ASSIGN:
|
|
|
|
|
|
station = _station_name(w, int(ex["station_id"])) or "a station"
|
|
|
|
|
|
r = w.conn.execute("SELECT display_name FROM users WHERE id = %s", (str(ex["welder_id"]),)).fetchone()
|
|
|
|
|
|
who = str(r[0]) if r else ""
|
|
|
|
|
|
w.log(f"Sent to {station}", f"{who} [{stamp}]" if stamp else who)
|
|
|
|
|
|
elif a is A.UNASSIGN:
|
|
|
|
|
|
w.log(f"Taken off {here}", note)
|
|
|
|
|
|
elif a is A.PULL_BACK:
|
|
|
|
|
|
w.log(f"Pulled back from {here}", note)
|
|
|
|
|
|
elif a is A.START:
|
|
|
|
|
|
w.log(LABELS[A.START], " · ".join(x for x in (here if w.packet["station_id"] else None, note) if x) or None)
|
|
|
|
|
|
elif a is A.SUBMIT:
|
|
|
|
|
|
r = w.conn.execute(
|
|
|
|
|
|
"SELECT clock_elapsed_ms FROM packet_stages WHERE packet_id = %s AND closed_at IS NULL", (w.id,)
|
|
|
|
|
|
).fetchone()
|
|
|
|
|
|
n = packets.dept_welds(w.packet, w.children) if w.children is not None else 0
|
|
|
|
|
|
text = f"{n} welds · {hours_text(r[0] if r else 0)}"
|
|
|
|
|
|
w.log(LABELS[A.SUBMIT], f"{text} · {note}" if note else text)
|
|
|
|
|
|
elif a is A.RETURN_TO_STATION:
|
|
|
|
|
|
w.log(f"Returned to {here}", _reason(w))
|
|
|
|
|
|
elif a is A.SEND_TO_QC:
|
|
|
|
|
|
loc = clean_opt(ex.get("location"), LIMITS["location"])
|
|
|
|
|
|
w.log(LABELS[A.SEND_TO_QC], " · ".join(x for x in (f"Spool at {loc}" if loc else None, note) if x) or None)
|
|
|
|
|
|
elif a is A.ROUTE_FROM_REVIEW:
|
|
|
|
|
|
text = f"Supervisor, Q.C. skipped · from {_dept_text(w, w.from_dept)}"
|
|
|
|
|
|
w.log(f"Routed to {_dept_text(w, to_dept)}", f"{text} · {note}" if note else text)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
SHOP_TEXTS = frozenset(
|
|
|
|
|
|
{A.ASSIGN, A.UNASSIGN, A.PULL_BACK, A.START, A.SUBMIT, A.RETURN_TO_STATION, A.SEND_TO_QC, A.ROUTE_FROM_REVIEW}
|
|
|
|
|
|
)
|
2026-10-17 20:08:53 -06:00
|
|
|
|
QC_TEXTS = frozenset({A.QC_ACCEPT, A.QC_ACCEPT_AND_ROUTE, A.QC_REJECT})
|
2026-10-16 22:46:56 -06:00
|
|
|
|
|
|
|
|
|
|
|
2026-10-16 09:31:45 -06:00
|
|
|
|
def apply_transition(w: Write) -> None:
|
|
|
|
|
|
"""Set status, status_at and dept, write the packet_transitions row and run the action's side effects."""
|
|
|
|
|
|
if w.transitioned:
|
|
|
|
|
|
return
|
|
|
|
|
|
w.transitioned = True
|
|
|
|
|
|
a = w.action
|
|
|
|
|
|
rule = w.rule
|
|
|
|
|
|
to_status = rule.to_status
|
|
|
|
|
|
assert to_status is not None
|
|
|
|
|
|
ex = w.extra
|
|
|
|
|
|
reason = _reason(w)
|
Build Seth's changes C1 to C11 (Gitea issues 1, 8, 18, 23, 24, 25, 27, 31, 32, 33, 36)
2026-10-30 13:33:51 -06:00
|
|
|
|
if a is A.QC_REJECT and reason and ex.get("ncr"):
|
|
|
|
|
|
reason = f"{ex['ncr']} · {reason}" # the supervisor's return note leads with the NCR number
|
2026-10-16 09:31:45 -06:00
|
|
|
|
from_dept = w.from_dept
|
|
|
|
|
|
to_dept = from_dept
|
|
|
|
|
|
if rule.dept_change != "no" and isinstance(ex.get("dept"), str) and ex.get("dept"):
|
|
|
|
|
|
to_dept = str(ex["dept"])
|
|
|
|
|
|
|
2026-10-16 22:46:56 -06:00
|
|
|
|
stamp: str | None = None
|
|
|
|
|
|
if a in SHOP_TEXTS and a is not A.ASSIGN and a is not A.SUBMIT:
|
|
|
|
|
|
_shop_texts(w, a, to_dept) # before the side effects clear the station
|
2026-10-17 20:08:53 -06:00
|
|
|
|
if a in QC_TEXTS:
|
|
|
|
|
|
qc_dom.qc_texts(w, a, to_dept) # before the side effects close the inspection
|
2026-10-16 22:46:56 -06:00
|
|
|
|
|
2026-10-16 09:31:45 -06:00
|
|
|
|
if a in (A.SEND_TO_WAREHOUSE, A.VIS_AUTO_RELEASE):
|
|
|
|
|
|
_set_packet(w, "wh_skipped = false, wh_sent_at = now(), return_note = NULL")
|
|
|
|
|
|
_wh_incoming(w)
|
|
|
|
|
|
elif a is A.RELEASE_TO_SHOP:
|
|
|
|
|
|
_set_packet(
|
|
|
|
|
|
w,
|
|
|
|
|
|
"wh_skipped = true, for_sup_id = %s, return_note = %s",
|
|
|
|
|
|
(ex.get("for_sup_id") or None, _note(w)),
|
|
|
|
|
|
)
|
|
|
|
|
|
_close_stage(w)
|
|
|
|
|
|
_open_stage(w, to_dept)
|
|
|
|
|
|
elif a is A.SEND_DRAFT_TO_QC:
|
|
|
|
|
|
_set_packet(w, "wh_skipped = true")
|
|
|
|
|
|
_close_inspection(w, _open_inspection(w), from_dept)
|
|
|
|
|
|
w.conn.execute(
|
|
|
|
|
|
"INSERT INTO qc_inspections (packet_id, sent_by, sent_note) VALUES (%s, %s, %s)",
|
|
|
|
|
|
(w.id, w.user_id, _note(w)),
|
|
|
|
|
|
)
|
|
|
|
|
|
elif a is A.WH_SEND_BACK:
|
|
|
|
|
|
_set_packet(w, "return_note = %s", (reason,))
|
|
|
|
|
|
_wh_close(w)
|
|
|
|
|
|
elif a is A.RETURN_TO_CLERICAL:
|
|
|
|
|
|
_set_packet(w, "return_note = %s", (reason,))
|
|
|
|
|
|
_close_stage(w)
|
|
|
|
|
|
elif a is A.WH_RELEASE:
|
2026-10-16 17:13:31 -06:00
|
|
|
|
_release_text(w)
|
2026-10-16 09:31:45 -06:00
|
|
|
|
_wh_close(w)
|
|
|
|
|
|
_close_stage(w)
|
|
|
|
|
|
_open_stage(w, to_dept)
|
|
|
|
|
|
elif a is A.SEND_BACK_TO_WAREHOUSE:
|
|
|
|
|
|
_set_packet(w, "return_note = %s, wh_skipped = false", (reason,))
|
|
|
|
|
|
_close_stage(w)
|
|
|
|
|
|
_wh_incoming(w)
|
|
|
|
|
|
elif a is A.ASSIGN:
|
|
|
|
|
|
station_id = int(ex["station_id"])
|
|
|
|
|
|
welder_id = str(ex["welder_id"])
|
|
|
|
|
|
stamp = clean_opt(ex.get("stamp"), LIMITS["stamp"])
|
|
|
|
|
|
if stamp is None:
|
|
|
|
|
|
r = w.conn.execute("SELECT welder_stamp FROM users WHERE id = %s", (welder_id,)).fetchone()
|
|
|
|
|
|
stamp = r[0] if r else None
|
|
|
|
|
|
_set_packet(w, "station_id = %s, welder_id = %s, welder_stamp = %s", (station_id, welder_id, stamp))
|
|
|
|
|
|
if not packets.one(w.conn, "SELECT id FROM packet_stages WHERE packet_id = %s AND closed_at IS NULL", (w.id,)):
|
|
|
|
|
|
_open_stage(w, from_dept)
|
|
|
|
|
|
_stage_update(w, "station_id = %s, welder_id = %s, welder_stamp = %s", (station_id, welder_id, stamp))
|
2026-10-16 22:46:56 -06:00
|
|
|
|
_shop_texts(w, a, to_dept, stamp)
|
2026-10-16 09:31:45 -06:00
|
|
|
|
elif a in (A.UNASSIGN, A.PULL_BACK):
|
|
|
|
|
|
_set_packet(w, "station_id = NULL, welder_id = NULL, welder_stamp = NULL")
|
|
|
|
|
|
_stage_update(w, f"{CLOCK_STOP}, station_id = NULL, welder_id = NULL, welder_stamp = NULL")
|
|
|
|
|
|
elif a is A.START:
|
2026-10-16 22:46:56 -06:00
|
|
|
|
# A clock that is already running keeps its start; only the server sets it.
|
|
|
|
|
|
_stage_update(
|
|
|
|
|
|
w,
|
|
|
|
|
|
"clock_running_since = coalesce(clock_running_since, now()),"
|
|
|
|
|
|
" clock_started_at = coalesce(clock_started_at, now())",
|
|
|
|
|
|
)
|
Build Seth's changes C1 to C11 (Gitea issues 1, 8, 18, 23, 24, 25, 27, 31, 32, 33, 36)
2026-10-30 13:33:51 -06:00
|
|
|
|
# change C8: the stage keeps the checklist it started with
|
|
|
|
|
|
checklists.freeze_on_start(w.conn, w.id)
|
2026-10-16 09:31:45 -06:00
|
|
|
|
elif a is A.SUBMIT:
|
|
|
|
|
|
_set_packet(w, "return_note = NULL")
|
|
|
|
|
|
_stage_update(w, CLOCK_STOP)
|
2026-10-16 22:46:56 -06:00
|
|
|
|
_shop_texts(w, a, to_dept)
|
2026-10-16 09:31:45 -06:00
|
|
|
|
elif a is A.RETURN_TO_STATION:
|
|
|
|
|
|
_set_packet(w, "return_note = %s", (reason,))
|
|
|
|
|
|
elif a is A.SEND_TO_QC:
|
|
|
|
|
|
_close_inspection(w, _open_inspection(w), from_dept)
|
|
|
|
|
|
stage = packets.one(w.conn, "SELECT id FROM packet_stages WHERE packet_id = %s AND closed_at IS NULL", (w.id,))
|
|
|
|
|
|
w.conn.execute(
|
|
|
|
|
|
"INSERT INTO qc_inspections (packet_id, stage_id, sent_by, book, spool, location, sent_note)"
|
|
|
|
|
|
" VALUES (%s, %s, %s, %s, %s, %s, %s)",
|
|
|
|
|
|
(
|
|
|
|
|
|
w.id,
|
|
|
|
|
|
stage["id"] if stage else None,
|
|
|
|
|
|
w.user_id,
|
|
|
|
|
|
ex.get("book") is True,
|
|
|
|
|
|
ex.get("spool") is True,
|
|
|
|
|
|
clean_opt(ex.get("location"), LIMITS["location"]),
|
|
|
|
|
|
_note(w),
|
|
|
|
|
|
),
|
|
|
|
|
|
)
|
|
|
|
|
|
elif a in (A.ROUTE_FROM_REVIEW, A.QC_ACCEPT_AND_ROUTE):
|
|
|
|
|
|
insp = _open_inspection(w)
|
|
|
|
|
|
if a is A.QC_ACCEPT_AND_ROUTE:
|
Build Seth's changes C1 to C11 (Gitea issues 1, 8, 18, 23, 24, 25, 27, 31, 32, 33, 36)
2026-10-30 13:33:51 -06:00
|
|
|
|
_set_ncr(w, insp)
|
2026-10-16 09:31:45 -06:00
|
|
|
|
insp = _set_result(w, insp, "accepted", _note(w))
|
|
|
|
|
|
_close_stage(w, qc_snapshot=insp)
|
|
|
|
|
|
_close_inspection(w, insp, from_dept)
|
|
|
|
|
|
_open_stage(w, to_dept)
|
|
|
|
|
|
_set_packet(w, "station_id = NULL, welder_id = NULL, welder_stamp = NULL, return_note = NULL, routed_to = NULL")
|
|
|
|
|
|
elif a is A.QC_REJECT:
|
2026-10-17 20:08:53 -06:00
|
|
|
|
# The reject closes the inspection into qc_history at once; the next SEND_TO_QC opens a fresh one.
|
|
|
|
|
|
insp = _open_inspection(w)
|
Build Seth's changes C1 to C11 (Gitea issues 1, 8, 18, 23, 24, 25, 27, 31, 32, 33, 36)
2026-10-30 13:33:51 -06:00
|
|
|
|
_set_ncr(w, insp)
|
2026-10-17 20:08:53 -06:00
|
|
|
|
insp = _set_result(w, insp, "rejected", reason)
|
|
|
|
|
|
_close_inspection(w, insp, from_dept)
|
2026-10-16 09:31:45 -06:00
|
|
|
|
_set_packet(w, "return_note = %s", (reason,))
|
|
|
|
|
|
if not packets.one(w.conn, "SELECT id FROM packet_stages WHERE packet_id = %s AND closed_at IS NULL", (w.id,)):
|
|
|
|
|
|
_open_stage(w, from_dept) # a draft sent straight to Q.C. has no stage yet
|
|
|
|
|
|
elif a is A.QC_ACCEPT:
|
Build Seth's changes C1 to C11 (Gitea issues 1, 8, 18, 23, 24, 25, 27, 31, 32, 33, 36)
2026-10-30 13:33:51 -06:00
|
|
|
|
insp = _open_inspection(w)
|
|
|
|
|
|
_set_ncr(w, insp)
|
|
|
|
|
|
insp = _set_result(w, insp, "accepted", _note(w))
|
2026-10-16 09:31:45 -06:00
|
|
|
|
_close_inspection(w, insp, from_dept)
|
|
|
|
|
|
_close_stage(w, qc_snapshot=insp)
|
|
|
|
|
|
_set_packet(w, "routed_to = %s::route_kind", (str(ex["routed_to"]),))
|
|
|
|
|
|
|
|
|
|
|
|
_set_packet(w, "status = %s::packet_status, status_at = now(), dept_code = %s", (to_status, to_dept))
|
|
|
|
|
|
|
|
|
|
|
|
if a is A.FILE:
|
2026-10-17 23:49:14 -06:00
|
|
|
|
_file(w)
|
2026-10-16 09:31:45 -06:00
|
|
|
|
elif a is A.ADMIN_REOPEN:
|
|
|
|
|
|
w.conn.execute(
|
|
|
|
|
|
"UPDATE packet_seals SET broken_at = now(), broken_by = %s, broken_reason = %s"
|
|
|
|
|
|
" WHERE packet_id = %s AND broken_at IS NULL",
|
|
|
|
|
|
(w.user_id, reason, w.id),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
tid = w.conn.execute(
|
|
|
|
|
|
"INSERT INTO packet_transitions (packet_id, by, action, from_status, to_status, from_dept, to_dept, reason,"
|
|
|
|
|
|
" signature_id, it_reason) VALUES (%s, %s, %s, %s::packet_status, %s::packet_status, %s, %s, %s, %s, %s)"
|
|
|
|
|
|
" RETURNING id",
|
|
|
|
|
|
(
|
|
|
|
|
|
w.id,
|
|
|
|
|
|
w.user_id,
|
|
|
|
|
|
str(a),
|
|
|
|
|
|
w.from_status,
|
|
|
|
|
|
to_status,
|
|
|
|
|
|
from_dept,
|
|
|
|
|
|
to_dept,
|
|
|
|
|
|
reason or _note(w),
|
|
|
|
|
|
w.signature.id if w.signature else None,
|
|
|
|
|
|
w.it_reason,
|
|
|
|
|
|
),
|
|
|
|
|
|
).fetchone()
|
|
|
|
|
|
w.transition_id = int(tid[0])
|
|
|
|
|
|
if w.note is None:
|
|
|
|
|
|
w.note = reason or _note(w)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-17 23:49:14 -06:00
|
|
|
|
def _file(w: Write) -> None:
|
|
|
|
|
|
"""FILE: the data book date, the seal, and the data book built and stored on the seal, all in this transaction.
|
|
|
|
|
|
|
2026-11-03 15:52:09 -07:00
|
|
|
|
A data book that cannot be built answers 500 databook_failed and the whole filing rolls back. So does an archive
|
|
|
|
|
|
copy that fails (archive.FilingCopyFailed, answered 503 archive_failed by the transition endpoint)."""
|
2026-10-17 23:49:14 -06:00
|
|
|
|
key = _seal_key(w)
|
|
|
|
|
|
_set_packet(w, "databook_at = now()")
|
|
|
|
|
|
row = packets.load_row(w.conn, w.id)
|
|
|
|
|
|
assert row is not None
|
|
|
|
|
|
content = packets.seal_content(w.conn, row)
|
|
|
|
|
|
digest = seal_hash(key, content)
|
|
|
|
|
|
kid = seal_key_id(key)
|
|
|
|
|
|
seal_id, sealed_at = w.conn.execute(
|
Build Seth's changes C1 to C11 (Gitea issues 1, 8, 18, 23, 24, 25, 27, 31, 32, 33, 36)
2026-10-30 13:33:51 -06:00
|
|
|
|
"INSERT INTO packet_seals (packet_id, hash, key_id, sealed_by, signature_id)"
|
|
|
|
|
|
" VALUES (%s, %s, %s, %s, %s) RETURNING id, sealed_at",
|
|
|
|
|
|
(w.id, digest, kid, w.user_id, w.signature.id if w.signature else None),
|
2026-10-17 23:49:14 -06:00
|
|
|
|
).fetchone()
|
|
|
|
|
|
pages = _file_databook(w, row, {"hash": digest.hex(), "key_id": kid, "sealed_at": sealed_at}, int(seal_id))
|
|
|
|
|
|
w.log_also("Data book filed", f"{pages} pages")
|
|
|
|
|
|
w.detail["seal_id"] = int(seal_id)
|
|
|
|
|
|
w.detail["databook_pages"] = pages
|
2026-11-03 15:52:09 -07:00
|
|
|
|
# The archive copy must succeed before the packet counts as filed (Gitea issue 57). A failure raises
|
|
|
|
|
|
# archive.FilingCopyFailed and the whole filing rolls back; the transition endpoint records it for Admin.
|
2026-10-30 17:50:04 -06:00
|
|
|
|
from . import archive
|
|
|
|
|
|
|
2026-11-03 15:52:09 -07:00
|
|
|
|
copied = archive.copy_at_filing(w.conn, _settings(w), w.id, int(seal_id))
|
|
|
|
|
|
if copied is not None:
|
|
|
|
|
|
w.detail["archive_folder"] = copied["folder"]
|
2026-10-17 23:49:14 -06:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _file_databook(w: Write, row: Mapping[str, Any], seal: dict[str, Any], seal_id: int) -> int:
|
|
|
|
|
|
"""Build the filed data book, store it as an asset and put it on the seal row. Returns its page count."""
|
|
|
|
|
|
from ..pdf import databook
|
|
|
|
|
|
from ..pdf.style import PdfError
|
|
|
|
|
|
from . import assets
|
|
|
|
|
|
from . import prints as prints_dom
|
|
|
|
|
|
|
|
|
|
|
|
settings = _settings(w)
|
|
|
|
|
|
try:
|
|
|
|
|
|
built = databook.build(w.conn, settings, w.id, who=w.actor_ref, seal=seal, mode="filed")
|
|
|
|
|
|
except PdfError as e:
|
|
|
|
|
|
log.warning("data book for packet %s failed: %s", w.id, e)
|
|
|
|
|
|
raise ApiError(500, "databook_failed", MSG_DATABOOK.format(reason=_reason_text(e))) from None
|
|
|
|
|
|
read = packets.read_packet(w.conn, w.id, None, include_children=False)
|
|
|
|
|
|
name = prints_dom.databook_name(read)
|
|
|
|
|
|
tmp_dir = Path(settings.assets_dir) / "tmp"
|
|
|
|
|
|
tmp_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
fd, tmp_name = tempfile.mkstemp(prefix="databook-", suffix=".pdf", dir=tmp_dir)
|
|
|
|
|
|
with os.fdopen(fd, "wb") as f:
|
|
|
|
|
|
f.write(built.bytes)
|
|
|
|
|
|
try:
|
|
|
|
|
|
stored = assets.store_file(
|
|
|
|
|
|
w.conn,
|
|
|
|
|
|
settings,
|
|
|
|
|
|
Path(tmp_name),
|
|
|
|
|
|
mime=assets.PDF,
|
|
|
|
|
|
original_name=name,
|
|
|
|
|
|
source="databook",
|
|
|
|
|
|
source_ref={"packet_id": w.id, "seal_id": seal_id},
|
|
|
|
|
|
uploaded_by=w.user_id,
|
|
|
|
|
|
packet_id=w.id,
|
|
|
|
|
|
)
|
|
|
|
|
|
except ApiError as e:
|
|
|
|
|
|
log.warning("data book for packet %s was not stored: %s", w.id, e.detail)
|
|
|
|
|
|
raise ApiError(500, "databook_failed", MSG_DATABOOK.format(reason=e.detail.rstrip("."))) from None
|
|
|
|
|
|
finally:
|
|
|
|
|
|
with contextlib.suppress(OSError):
|
|
|
|
|
|
Path(tmp_name).unlink()
|
|
|
|
|
|
pages = int(built.pages)
|
|
|
|
|
|
w.conn.execute(
|
|
|
|
|
|
"UPDATE packet_seals SET databook_asset_id = %s, databook_pages = %s WHERE id = %s",
|
|
|
|
|
|
(stored["id"], pages, seal_id),
|
|
|
|
|
|
)
|
|
|
|
|
|
w.detail["databook_asset_id"] = str(stored["id"])
|
|
|
|
|
|
return pages
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _reason_text(e: BaseException) -> str:
|
|
|
|
|
|
text = " ".join(str(e).split()).rstrip(".")
|
|
|
|
|
|
return text[:300] or "an unknown error"
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-16 09:31:45 -06:00
|
|
|
|
def verify_seal(conn: Any, settings: Any, packet_id: int) -> dict[str, Any]:
|
|
|
|
|
|
"""The seal history of a packet and whether the current unbroken seal still matches the packet."""
|
|
|
|
|
|
seals = packets.rows(conn, "SELECT * FROM packet_seals WHERE packet_id = %s ORDER BY id DESC", (packet_id,))
|
|
|
|
|
|
people = packets.People(conn, [s["sealed_by"] for s in seals] + [s["broken_by"] for s in seals])
|
|
|
|
|
|
current = next((s for s in seals if s["broken_at"] is None), None)
|
|
|
|
|
|
valid: bool | None = None
|
|
|
|
|
|
if current is not None:
|
|
|
|
|
|
try:
|
|
|
|
|
|
key = settings.seal_key()
|
|
|
|
|
|
except SecretFileError:
|
|
|
|
|
|
key = None
|
|
|
|
|
|
if key is not None and seal_key_id(key) == current["key_id"]:
|
|
|
|
|
|
row = packets.load_row(conn, packet_id)
|
|
|
|
|
|
assert row is not None
|
|
|
|
|
|
valid = hmac.compare_digest(seal_hash(key, packets.seal_content(conn, row)), bytes(current["hash"]))
|
|
|
|
|
|
|
|
|
|
|
|
def shape(s: Mapping[str, Any]) -> dict[str, Any]:
|
|
|
|
|
|
return {
|
|
|
|
|
|
"id": s["id"],
|
|
|
|
|
|
"sealed_at": s["sealed_at"],
|
|
|
|
|
|
"sealed_by": people.ref(s["sealed_by"]),
|
|
|
|
|
|
"key_id": s["key_id"],
|
|
|
|
|
|
"hash": bytes(s["hash"]).hex(),
|
|
|
|
|
|
"signed": s["signature_id"] is not None,
|
|
|
|
|
|
"broken_at": s["broken_at"],
|
|
|
|
|
|
"broken_by": people.ref(s["broken_by"]),
|
|
|
|
|
|
"broken_reason": s["broken_reason"],
|
2026-10-17 23:49:14 -06:00
|
|
|
|
"open_wos": None if s.get("open_wos") is None else list(s["open_wos"]),
|
|
|
|
|
|
"databook_asset_id": None if s.get("databook_asset_id") is None else str(s["databook_asset_id"]),
|
|
|
|
|
|
"databook_pages": s.get("databook_pages"),
|
2026-10-16 09:31:45 -06:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return {"seal": shape(current) if current else None, "valid": valid, "seals": [shape(s) for s in seals]}
|