The station's /reject now uses the booked-against cart's stored cycle interval (temper_tracking ttCycleFirstId/LastId/Count, migration 009): body.ttId — until now only logged — resolves the interval's MEDIAN cycle for rejCycleID/rejToolNo/ rejCycletimeAct/rejArticleID instead of the press's latest cycle (MAX(id)), and writes rejTtId (audit link). Self-contained probes/resolver (no fastpress import); falls back to MAX(id) pre-009 / when no ttId or interval. Returns CY.<ttId>:<a>-<b>. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
597 lines
28 KiB
Python
597 lines
28 KiB
Python
"""No-auth JSON API for the Ausschuss-Station (central reject-booking terminal).
|
||
|
||
Served same-origin under ``/module/temper_rejects``. Three routes:
|
||
* GET /module/temper_rejects/api/overview?window=7T → tempered carts (per ttId)
|
||
* GET /module/temper_rejects/api/reasons → active reject reasons
|
||
* POST /module/temper_rejects/api/reject → book one Ausschuss
|
||
|
||
No ``verify_token`` dependency: this is a central login-free terminal (no device
|
||
context). ``press`` + ``orderNo`` come from the request BODY, unlike the device-scoped
|
||
``POST /press/rejects/send`` in fastpress (which derives the press from the JWT). The
|
||
booking is recorded against the ORDER (``rejOrdernumber`` + ``rejPress``) — the same
|
||
key PressV has always used — so it needs NO schema change (the cart ``ttId`` is shown
|
||
for context only, never written).
|
||
|
||
── LIVE DATA ─────────────────────────────────────────────────────────────────────
|
||
Reads come from the same tables + the same time-derived temper status as
|
||
fastpress (``modules/fastpress/press/temper.py`` / ``rejects.py``), re-implemented
|
||
here so the module stays self-contained (it ships as its own repo and must NOT
|
||
import the sibling fastpress package). The SQL is faithful to those sources:
|
||
|
||
* OVERVIEW: ``production.temper_tracking`` across ALL presses of the plant
|
||
(``PLANT_NAME``), one row per ``ttId``, DONE carts (``NOW() >= ttOutOfOvenAt``)
|
||
within the window, newest first. Each entry carries its order's 5-station
|
||
temper distribution (belt), derived from the planned timestamps vs ``NOW()``.
|
||
* REASONS: ``SELECT id, reason_de, reason_en FROM settings.reject_reasons WHERE active=1``.
|
||
* REJECT: resolve latest cycle + article + reason texts, then one INSERT into
|
||
``production.rejects`` inside a ``transaction()`` — identical shape to fastpress
|
||
``send_reject``, but ``press`` comes from the body (login-free central terminal).
|
||
|
||
Belt mapping (5 UI nodes, 4 real quantities): the live model tracks ONE pre-oven
|
||
quantity — the buffer/Wagenzeit before ``ttIntoOvenAt`` (there is no scan data to
|
||
split "sitting on the cart" from "moving to the oven"). Per the design decision it
|
||
is shown under **Unterwegs** (amber); **Wagen** (cyan) stays 0. See
|
||
``.claude/memory/ausschuss-station.md``.
|
||
|
||
Security posture of the login-free write: network / reverse-proxy restriction only
|
||
(no token guard) — consistent with the rest of this module. Revisit if the station
|
||
is ever exposed beyond the shop-floor LAN.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
import os
|
||
from collections import defaultdict
|
||
from datetime import datetime
|
||
from typing import Any, Optional
|
||
|
||
from fastapi import APIRouter, Query
|
||
from fastapi.responses import JSONResponse
|
||
from pydantic import BaseModel, ConfigDict
|
||
from sqlalchemy import text
|
||
from starlette import status
|
||
|
||
from .._db import run_select_query, transaction
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
router = APIRouter(prefix="/module/temper_rejects/api", tags=["temper_rejects"])
|
||
|
||
# The plant this station serves. Un-scoped across presses, scoped to ONE plant —
|
||
# same per-deployment constant fastpress reads (``_identity.PLANT_NAME``). The
|
||
# generic kernel never looks at it.
|
||
_PLANT = os.getenv("PLANT_NAME", "Cadolzburg")
|
||
|
||
# window code → minutes (max 30 days). Scopes which finished carts are LISTED
|
||
# (the belt aggregates the whole order regardless of the window).
|
||
_WINDOW_MINUTES: dict[str, int] = {"12h": 720, "1T": 1440, "7T": 10080, "14T": 20160, "30T": 43200}
|
||
_DEFAULT_WINDOW = "7T"
|
||
|
||
# Cap the listed carts so a wide window can't return an unbounded payload (the
|
||
# frontend renders one card per entry). Newest-first, so the cap drops the oldest.
|
||
_MAX_ENTRIES = 500
|
||
|
||
|
||
def _iso_aware(v: Any) -> Any:
|
||
"""Serialise a naive DB ``DATETIME`` as a timezone-AWARE ISO string.
|
||
|
||
The temper times are stored as the gateway's local wall clock in naive
|
||
``DATETIME`` columns; emitting them naive makes the browser guess the zone
|
||
(it assumes UTC → the Protokoll shifts by the local offset). ``astimezone()``
|
||
stamps the server's actual offset so the frontend converts back correctly.
|
||
Mirrors ``fastpress temper._iso_aware``."""
|
||
return v.astimezone().isoformat() if isinstance(v, datetime) else v
|
||
|
||
|
||
# ── press filter list ───────────────────────────────────────────────────────────
|
||
|
||
|
||
def _press_list(plant: str, entry_presses: set[str]) -> list[str]:
|
||
"""The presses shown as filter checkboxes (default all).
|
||
|
||
Canonical registry (``press_settings.presses`` for the plant) UNIONed with any
|
||
press that actually has entries — the union is REQUIRED: the frontend defaults
|
||
the checked set to ``payload.presses`` and hides any entry whose press is not in
|
||
it, so every entry's press must be present. Degrades to just the entry presses
|
||
if the registry is unavailable. Numeric press ids sort numerically."""
|
||
presses: set[str] = set(entry_presses)
|
||
try:
|
||
rows = run_select_query(
|
||
"SELECT psPress AS p FROM press_settings.presses WHERE psPlant = :plant",
|
||
{"plant": plant},
|
||
)
|
||
presses.update(str(r["p"]) for r in rows if r.get("p") is not None)
|
||
except Exception as e: # noqa: BLE001 — registry is optional polish; entries still render
|
||
logger.warning("press registry unavailable (using entry presses only): %s", e)
|
||
|
||
def _key(p: str) -> tuple[int, Any]:
|
||
return (0, int(p)) if p.isdigit() else (1, p)
|
||
|
||
return sorted(presses, key=_key)
|
||
|
||
|
||
# ── belt: order-level 5-station temper distribution ───────────────────────────────
|
||
|
||
|
||
def _temper_agg_map(plant: str, pairs: set[tuple[str, str]]) -> dict[tuple[str, str], dict[str, int]]:
|
||
"""Per ``(press, orderno)`` temper aggregate, over ALL that order's carts (any
|
||
status — the belt shows the whole order, not just the windowed carts).
|
||
|
||
Same live-oven derivation as fastpress ``_temper_aggregates_all`` (planned
|
||
timestamps vs ``NOW()``): ``sent`` = all parts on carts; ``on_way`` = still in
|
||
the buffer (``NOW() < ttIntoOvenAt``); ``in_oven`` / ``finished`` split by
|
||
``ttOutOfOvenAt``. Degrades to ``{}`` on any DB error."""
|
||
if not pairs:
|
||
return {}
|
||
presses = sorted({p for p, _ in pairs})
|
||
orders = sorted({o for _, o in pairs})
|
||
binds: dict[str, Any] = {"plant": plant}
|
||
for i, p in enumerate(presses):
|
||
binds[f"p{i}"] = p
|
||
for i, o in enumerate(orders):
|
||
binds[f"o{i}"] = o
|
||
pin = ", ".join(f":p{i}" for i in range(len(presses)))
|
||
oin = ", ".join(f":o{i}" for i in range(len(orders)))
|
||
try:
|
||
rows = run_select_query(
|
||
"SELECT ttPress AS press, ttOrdernumber AS o, "
|
||
" COALESCE(SUM(ttPartsGood),0) AS sent, "
|
||
" COALESCE(SUM(CASE WHEN NOW() < ttIntoOvenAt THEN ttPartsGood ELSE 0 END),0) AS on_way, "
|
||
" COALESCE(SUM(CASE WHEN NOW() >= ttIntoOvenAt AND NOW() < ttOutOfOvenAt THEN ttPartsGood ELSE 0 END),0) AS in_oven, "
|
||
" COALESCE(SUM(CASE WHEN NOW() >= ttOutOfOvenAt THEN ttPartsGood ELSE 0 END),0) AS finished "
|
||
"FROM production.temper_tracking "
|
||
f"WHERE ttPlant = :plant AND ttPress IN ({pin}) AND ttOrdernumber IN ({oin}) "
|
||
"GROUP BY ttPress, ttOrdernumber",
|
||
binds,
|
||
)
|
||
except Exception as e: # noqa: BLE001
|
||
logger.warning("temper aggregate unavailable: %s", e)
|
||
return {}
|
||
out: dict[tuple[str, str], dict[str, int]] = {}
|
||
for r in rows:
|
||
out[(str(r["press"]), str(r["o"]))] = {
|
||
"sent": int(r["sent"] or 0),
|
||
"on_way": int(r["on_way"] or 0),
|
||
"in_oven": int(r["in_oven"] or 0),
|
||
"finished": int(r["finished"] or 0),
|
||
}
|
||
return out
|
||
|
||
|
||
def _good_parts_map(plant: str, pairs: set[tuple[str, str]]) -> dict[tuple[str, str], int]:
|
||
"""Per ``(press, orderno)`` good parts PRODUCED at the press (cycles × cavities −
|
||
rejects), for the belt's ``notYetSent`` = produced − sent-to-temper.
|
||
|
||
Faithful (batched) port of fastpress ``_all_order_stats``: one cycle scan +
|
||
one reject scan per press, matched back to orders in Python (``Joborder`` is a
|
||
plain order number OR a JSON array of them). Only ACTIVE orders are counted;
|
||
an order that has finished (moved to ``finished_orders``) is absent → the
|
||
caller treats ``notYetSent`` as 0 (nothing left at the press). Degrades to
|
||
``{}`` per-press on any DB error."""
|
||
if not pairs:
|
||
return {}
|
||
import json
|
||
|
||
by_press: dict[str, list[str]] = defaultdict(list)
|
||
for p, o in pairs:
|
||
by_press[p].append(o)
|
||
|
||
result: dict[tuple[str, str], int] = {}
|
||
for press, orders in by_press.items():
|
||
try:
|
||
obinds: dict[str, Any] = {"plant": plant, "press": press}
|
||
for i, o in enumerate(orders):
|
||
obinds[f"o{i}"] = o
|
||
oin = ", ".join(f":o{i}" for i in range(len(orders)))
|
||
|
||
# 1) cavities + target per active order (article/qty snapshot).
|
||
active = run_select_query(
|
||
"SELECT aoOrdernumber AS o, aoCavities AS cav FROM production.active_orders "
|
||
f"WHERE aoPlant = :plant AND aoPress = :press AND aoOrdernumber IN ({oin})",
|
||
obinds,
|
||
)
|
||
cav_by_order = {str(r["o"]): int(r["cav"] or 0) for r in active}
|
||
if not cav_by_order:
|
||
continue # none of these orders are active → notYetSent stays 0
|
||
|
||
# 2) cycle counts grouped by Joborder (one scan for the press).
|
||
cyc_groups = [
|
||
(cr["jo"], int(cr["c"] or 0))
|
||
for cr in run_select_query(
|
||
"SELECT Joborder AS jo, COUNT(*) AS c FROM production.cycles_new "
|
||
"WHERE Press = :press GROUP BY Joborder",
|
||
{"press": press},
|
||
)
|
||
]
|
||
|
||
# 3) rejects summed per order (one grouped query).
|
||
rej_by_order: dict[str, int] = {}
|
||
for rr in run_select_query(
|
||
"SELECT rejOrdernumber AS o, COALESCE(SUM(rejQuantity),0) AS total "
|
||
"FROM production.rejects WHERE rejPress = :press "
|
||
f"AND rejOrdernumber IN ({oin}) GROUP BY rejOrdernumber",
|
||
obinds,
|
||
):
|
||
rej_by_order[str(rr["o"])] = int(rr["total"] or 0)
|
||
|
||
def _cycles_for(orderno: str) -> int:
|
||
total = 0
|
||
for jo, c in cyc_groups:
|
||
if jo is None:
|
||
continue
|
||
if jo == orderno:
|
||
total += c
|
||
elif isinstance(jo, str) and orderno in jo:
|
||
try:
|
||
arr = json.loads(jo)
|
||
except Exception: # noqa: BLE001
|
||
arr = None
|
||
if isinstance(arr, list) and orderno in arr:
|
||
total += c
|
||
return total
|
||
|
||
for orderno, cav in cav_by_order.items():
|
||
produced = _cycles_for(orderno) * cav
|
||
good = max(0, produced - rej_by_order.get(orderno, 0))
|
||
result[(press, orderno)] = good
|
||
except Exception as e: # noqa: BLE001 — degrade this press to no notYetSent
|
||
logger.warning("good-parts derivation failed for press %s: %s", press, e)
|
||
continue
|
||
return result
|
||
|
||
|
||
def _flow(not_sent: int, wagen: int, unterwegs: int, ofen: int, fertig: int) -> dict[str, int]:
|
||
"""Order-level 5-station distribution shown on the belt (frontend TemperBelt):
|
||
notYetSent=violet, wagen=cyan, unterwegs=amber, ofen=orange, fertig=emerald."""
|
||
return {"notYetSent": not_sent, "wagen": wagen, "unterwegs": unterwegs, "ofen": ofen, "fertig": fertig}
|
||
|
||
|
||
def _flow_for(
|
||
press: str,
|
||
orderno: str,
|
||
agg_map: dict[tuple[str, str], dict[str, int]],
|
||
good_map: dict[tuple[str, str], int],
|
||
) -> dict[str, int]:
|
||
"""Assemble one order's belt distribution from the aggregates.
|
||
|
||
The live model has ONE pre-oven quantity (``on_way`` = buffer/Wagenzeit) → it
|
||
goes under **Unterwegs**; **Wagen** stays 0 (no scan data to split them).
|
||
``notYetSent`` = produced good − sent-to-temper (0 when the order isn't active)."""
|
||
agg = agg_map.get((press, orderno), {"sent": 0, "on_way": 0, "in_oven": 0, "finished": 0})
|
||
good = good_map.get((press, orderno))
|
||
not_sent = max(0, good - agg["sent"]) if good is not None else 0
|
||
return _flow(not_sent, 0, agg["on_way"], agg["in_oven"], agg["finished"])
|
||
|
||
|
||
# ── cart<->cycle attribution (migration 009) ────────────────────────────────────
|
||
# A tempered cart's parts were made hours earlier across a RANGE of press cycles, so a
|
||
# reject booked against a cart must attribute to a cycle that actually produced a part
|
||
# on it — not the press's latest cycle (MAX(id)). fastpress print_label snapshots each
|
||
# cart's cycle interval onto its temper_tracking row; here we read it back and pick the
|
||
# interval's MEDIAN cycle. Self-contained copies of the fastpress rejects.py logic (this
|
||
# module ships as its own repo and MUST NOT import the sibling fastpress package).
|
||
|
||
_CART_CYCLE_COLUMNS: Optional[bool] = None
|
||
_REJ_TTID_COLUMN: Optional[bool] = None
|
||
|
||
|
||
def _has_cart_cycle_columns() -> bool:
|
||
"""Whether ``temper_tracking.ttCycleFirstId`` exists yet (migration 009). Cached.
|
||
Absent (pre-009) → reject attribution falls back to the legacy ``MAX(id)`` cycle."""
|
||
global _CART_CYCLE_COLUMNS
|
||
if _CART_CYCLE_COLUMNS is not None:
|
||
return _CART_CYCLE_COLUMNS
|
||
try:
|
||
rows = run_select_query(
|
||
"SELECT COUNT(*) AS c FROM information_schema.COLUMNS "
|
||
"WHERE TABLE_SCHEMA = 'production' AND TABLE_NAME = 'temper_tracking' "
|
||
"AND COLUMN_NAME = 'ttCycleFirstId'",
|
||
{},
|
||
)
|
||
_CART_CYCLE_COLUMNS = bool(rows and int(rows[0]["c"]) > 0)
|
||
return _CART_CYCLE_COLUMNS
|
||
except Exception as e: # noqa: BLE001 — transient probe failure: do NOT cache, retry next call
|
||
logger.warning("cart-cycle column probe failed (assuming absent, will retry): %s", e)
|
||
return False
|
||
|
||
|
||
def _has_rej_ttid_column() -> bool:
|
||
"""Whether ``production.rejects.rejTtId`` exists yet (migration 009). Cached.
|
||
Present → the INSERT records the booked-against cart ttId; absent → column omitted."""
|
||
global _REJ_TTID_COLUMN
|
||
if _REJ_TTID_COLUMN is not None:
|
||
return _REJ_TTID_COLUMN
|
||
try:
|
||
rows = run_select_query(
|
||
"SELECT COUNT(*) AS c FROM information_schema.COLUMNS "
|
||
"WHERE TABLE_SCHEMA = 'production' AND TABLE_NAME = 'rejects' "
|
||
"AND COLUMN_NAME = 'rejTtId'",
|
||
{},
|
||
)
|
||
_REJ_TTID_COLUMN = bool(rows and int(rows[0]["c"]) > 0)
|
||
return _REJ_TTID_COLUMN
|
||
except Exception as e: # noqa: BLE001 — transient probe failure: do NOT cache, retry next call
|
||
logger.warning("rejTtId column probe failed (assuming absent, will retry): %s", e)
|
||
return False
|
||
|
||
|
||
def _resolve_reject_cycle(conn, press: str, ordernumber: str, tt_id, has_cycle_cols: bool):
|
||
"""Resolve the ONE cycle a reject references → ``(toolNo, cycletimeAct, articleId,
|
||
cycleId, intervalStr|None)``. Faithful copy of fastpress ``rejects._resolve_reject_cycle``.
|
||
|
||
Cart booking (``tt_id`` given, migrated box, cart has a stored interval): attribute to
|
||
the interval's MEDIAN cycle (``ORDER BY id LIMIT 1 OFFSET (count-1)//2``). The cart
|
||
row's ``ttOrdernumber`` is authoritative for the cycle predicate. Otherwise fall back
|
||
to the legacy latest cycle (``MAX(id)``) — verbatim as before."""
|
||
import json
|
||
|
||
order_pred = "(Joborder = :orderno OR JSON_CONTAINS(Joborder, :orderjson))"
|
||
interval_str = None
|
||
rep = None
|
||
if has_cycle_cols and tt_id is not None:
|
||
snap = conn.execute(
|
||
text(
|
||
"SELECT ttOrdernumber, ttCycleFirstId, ttCycleLastId, ttCycleCount "
|
||
"FROM production.temper_tracking WHERE ttId = :ttId"
|
||
),
|
||
{"ttId": tt_id},
|
||
).fetchone()
|
||
if snap is not None and snap[1] is not None and snap[2] is not None:
|
||
snap_order = snap[0] or ordernumber
|
||
first_id, last_id = int(snap[1]), int(snap[2])
|
||
cnt = int(snap[3]) if snap[3] is not None else 1
|
||
off = max(0, (cnt - 1) // 2) # median cycle of the cart's interval
|
||
rep = conn.execute(
|
||
text(
|
||
"SELECT ToolNo, T_Cycle_Act, ArticleID, CycleID FROM production.cycles_new "
|
||
f"WHERE Press = :press AND {order_pred} AND id BETWEEN :first AND :last "
|
||
"ORDER BY id ASC LIMIT 1 OFFSET :off"
|
||
),
|
||
{
|
||
"press": press, "orderno": snap_order, "orderjson": json.dumps(snap_order),
|
||
"first": first_id, "last": last_id, "off": off,
|
||
},
|
||
).fetchone()
|
||
if rep is not None:
|
||
interval_str = f"CY.{tt_id}:{first_id}-{last_id}"
|
||
if rep is None:
|
||
rep = conn.execute(
|
||
text(
|
||
"SELECT ToolNo, T_Cycle_Act, ArticleID, CycleID "
|
||
"FROM production.cycles_new WHERE id = ("
|
||
" SELECT MAX(id) FROM production.cycles_new "
|
||
f" WHERE Press = :press AND {order_pred}"
|
||
") LIMIT 1"
|
||
),
|
||
{"press": press, "orderno": ordernumber, "orderjson": json.dumps(ordernumber)},
|
||
).fetchone()
|
||
mold = rep[0] if rep else None
|
||
cycletime = rep[1] if rep else None
|
||
raw_art_id = rep[2] if rep else None
|
||
cycle_id = rep[3] if rep else None
|
||
try:
|
||
art_id = int(raw_art_id) if raw_art_id is not None else None
|
||
except (ValueError, TypeError):
|
||
art_id = None
|
||
return mold, cycletime, art_id, cycle_id, interval_str
|
||
|
||
|
||
# ── request schema ────────────────────────────────────────────────────────────
|
||
|
||
|
||
class RejectBody(BaseModel):
|
||
# permissive like fastpress's reject schema; validated in the handler. ``ttId``
|
||
# is accepted (the frontend sends the selected cart) but only logged — the
|
||
# booking is recorded against the ORDER, not the cart.
|
||
model_config = ConfigDict(extra="allow")
|
||
press: str | None = None
|
||
orderNo: str | None = None
|
||
ttId: int | None = None
|
||
count: int | None = None
|
||
reason: int | None = None
|
||
|
||
|
||
# ── endpoints ─────────────────────────────────────────────────────────────────
|
||
|
||
|
||
@router.get("/overview", summary="Tempered carts per ttId, newest first (live)")
|
||
def overview(window: str = Query(_DEFAULT_WINDOW, description="12h|1T|7T|14T|30T")) -> dict[str, Any]:
|
||
code = window if window in _WINDOW_MINUTES else _DEFAULT_WINDOW
|
||
limit_min = _WINDOW_MINUTES[code]
|
||
|
||
try:
|
||
rows = run_select_query(
|
||
"SELECT ttId, ttCartId, ttPress, ttOrdernumber, ttArticleDesc, "
|
||
"ttPartsGood, ttScrap, ttOutOfOvenAt "
|
||
"FROM production.temper_tracking "
|
||
"WHERE ttPlant = :plant "
|
||
" AND ttReprintOf IS NULL " # a reprint is not a new label
|
||
" AND ttOutOfOvenAt IS NOT NULL "
|
||
" AND NOW() >= ttOutOfOvenAt " # fertig getempert
|
||
" AND ttOutOfOvenAt >= (NOW() - INTERVAL :mins MINUTE) "
|
||
"ORDER BY ttOutOfOvenAt DESC, ttId DESC "
|
||
"LIMIT :lim",
|
||
{"plant": _PLANT, "mins": limit_min, "lim": _MAX_ENTRIES + 1},
|
||
)
|
||
except Exception as e:
|
||
logger.error("temper_rejects overview query failed: %s", e, exc_info=True)
|
||
return JSONResponse(
|
||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||
content={"success": False, "error": "database_error", "message": str(e)},
|
||
)
|
||
|
||
truncated = len(rows) > _MAX_ENTRIES
|
||
if truncated:
|
||
rows = rows[:_MAX_ENTRIES]
|
||
logger.warning("temper_rejects overview truncated to %d carts (window=%s)", _MAX_ENTRIES, code)
|
||
|
||
pairs = {(str(r["ttPress"]), str(r["ttOrdernumber"])) for r in rows}
|
||
agg_map = _temper_agg_map(_PLANT, pairs)
|
||
good_map = _good_parts_map(_PLANT, pairs)
|
||
|
||
entries = [
|
||
{
|
||
"ttId": int(r["ttId"]),
|
||
"cartId": r.get("ttCartId") or "",
|
||
"press": str(r["ttPress"]),
|
||
"orderNo": r["ttOrdernumber"],
|
||
"article": r.get("ttArticleDesc") or "",
|
||
"partsGood": int(r["ttPartsGood"] or 0),
|
||
"scrap": int(r["ttScrap"] or 0),
|
||
"doneAt": _iso_aware(r["ttOutOfOvenAt"]),
|
||
"flow": _flow_for(str(r["ttPress"]), r["ttOrdernumber"], agg_map, good_map),
|
||
}
|
||
for r in rows
|
||
]
|
||
entry_presses = {e["press"] for e in entries}
|
||
|
||
return {
|
||
"window": code,
|
||
"generatedAt": datetime.now().astimezone().isoformat(),
|
||
"sample": False, # live data → frontend shows "Live", not "Demo-Daten"
|
||
"truncated": truncated,
|
||
"presses": _press_list(_PLANT, entry_presses),
|
||
"entries": entries,
|
||
}
|
||
|
||
|
||
@router.get("/reasons", summary="Active reject reasons")
|
||
def reasons() -> dict[str, Any]:
|
||
"""Active reject reasons — same query as fastpress ``get_reject_reasons``.
|
||
Global (not press-scoped). Read-only."""
|
||
try:
|
||
rows = run_select_query(
|
||
"SELECT id, reason_de, reason_en FROM settings.reject_reasons "
|
||
"WHERE active = 1 ORDER BY id",
|
||
{},
|
||
)
|
||
except Exception as e:
|
||
logger.error("temper_rejects reasons query failed: %s", e, exc_info=True)
|
||
return JSONResponse(
|
||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||
content={"success": False, "error": "database_error", "message": str(e)},
|
||
)
|
||
return {"reasons": rows, "sample": False}
|
||
|
||
|
||
@router.post("/reject", summary="Book one Ausschuss against an order (writes production.rejects)")
|
||
def reject(body: RejectBody) -> dict[str, Any]:
|
||
"""Record one reject against the ORDER. Un-scoped port of fastpress
|
||
``send_reject``: resolve the latest cycle (``MAX(id)`` in ``cycles_new`` for the
|
||
press + joborder, incl. ``JSON_CONTAINS``), the article desc / target cycletime
|
||
(``active_orders`` → ``finished_orders`` fallback), and the reason texts
|
||
(``settings.reject_reasons``), then INSERT ``production.rejects`` — all in one
|
||
transaction.
|
||
|
||
Unlike fastpress, ``press`` comes from the BODY (login-free central terminal, no
|
||
device JWT). Keyed by ``rejOrdernumber`` + ``rejPress`` → no cart reference / no
|
||
schema change. ``ttId`` is accepted for context but not written."""
|
||
# IMPORTANT — booking outcomes (validation refusals AND write failures) are
|
||
# returned as HTTP 200 + {"success": False, ...}, NOT 4xx/5xx. The frontend store
|
||
# (src/stores/temperRejects.ts submitReject) treats ANY non-2xx as a transport
|
||
# failure and swallows it into a FAKE success ("Ausschuss gebucht (Demo — kein
|
||
# Backend)"). On this live, login-free terminal that would make a refused/failed
|
||
# booking read as booked. A 200 body keeps the store's success path in control so
|
||
# it surfaces the real error; its catch stays reserved for a truly unreachable
|
||
# backend. Do NOT "fix" these back to 400/500. (/overview + /reasons DO use 500 —
|
||
# there a DB error is not swallowed into a false success.)
|
||
press = str(body.press or "").strip()
|
||
ordernumber = str(body.orderNo or "").strip()
|
||
try:
|
||
count = int(body.count or 0)
|
||
reason_id = int(body.reason or 0)
|
||
except (ValueError, TypeError):
|
||
return {"success": False, "error": "bad_request", "message": "count and reason must be integers"}
|
||
if not press or not ordernumber:
|
||
return {"success": False, "error": "bad_request", "message": "press and orderNo are required"}
|
||
if count <= 0 or reason_id <= 0:
|
||
return {"success": False, "error": "bad_request", "message": "count and reason must be positive"}
|
||
|
||
has_cycle_cols = _has_cart_cycle_columns()
|
||
has_rej_ttid = _has_rej_ttid_column()
|
||
try:
|
||
with transaction() as conn:
|
||
# Resolve the ONE cycle this reject references: the cart's interval median
|
||
# when booked against a tempered cart (body.ttId), else the legacy latest
|
||
# cycle (MAX(id)). See _resolve_reject_cycle.
|
||
mold, cycletime, art_id, cycle_id, interval_str = _resolve_reject_cycle(
|
||
conn, press, ordernumber, body.ttId, has_cycle_cols
|
||
)
|
||
|
||
# Article desc / target cycletime — active first, finished as fallback.
|
||
part = target_cycletime = None
|
||
prod_row = conn.execute(
|
||
text(
|
||
"SELECT aoArticleDesc, aoTargetCycletime FROM production.active_orders "
|
||
"WHERE aoOrdernumber = :orderno AND aoPress = :press LIMIT 1"
|
||
),
|
||
{"orderno": ordernumber, "press": press},
|
||
).fetchone()
|
||
if not prod_row:
|
||
fo_row = conn.execute(
|
||
text(
|
||
"SELECT foArticleDesc, foTargetCycletime FROM production.finished_orders "
|
||
"WHERE foOrderNumber = :orderno AND foPress = :press "
|
||
"ORDER BY foDT_End DESC LIMIT 1"
|
||
),
|
||
{"orderno": ordernumber, "press": press},
|
||
).fetchone()
|
||
if fo_row:
|
||
part = fo_row[0]
|
||
target_cycletime = fo_row[1]
|
||
else:
|
||
part = prod_row[0]
|
||
target_cycletime = prod_row[1]
|
||
|
||
# Reason texts (settings schema, fully-qualified).
|
||
reason_row = conn.execute(
|
||
text("SELECT reason_de, reason_en FROM settings.reject_reasons WHERE id = :reason_id"),
|
||
{"reason_id": reason_id},
|
||
).fetchone()
|
||
reason_de = reason_row[0] if reason_row else ""
|
||
reason_en = reason_row[1] if reason_row else ""
|
||
|
||
# rejCycletimeAct is NOT NULL — coalesce a missing cycle to 0.0. rejTtId
|
||
# (migration 009) records the booked-against cart for a durable reject→cart
|
||
# audit link, appended only when the column exists (pre-009 boxes omit it).
|
||
cols = ["rejPress", "rejOrdernumber", "rejArticleDesc", "rejReason_INT",
|
||
"rejReason_DE", "rejReason_EN", "rejQuantity", "rejTargetCycletime",
|
||
"rejCycletimeAct", "rejToolNo", "rejCycleID", "rejArticleID"]
|
||
vals = [":press", ":orderno", ":part", ":reason_int", ":reason_de", ":reason_en",
|
||
":quantity", ":target_cycletime", ":cycletime_act", ":toolno",
|
||
":cycle_id", ":article_id"]
|
||
binds = {
|
||
"press": press,
|
||
"orderno": ordernumber,
|
||
"part": part,
|
||
"reason_int": reason_id,
|
||
"reason_de": reason_de,
|
||
"reason_en": reason_en,
|
||
"quantity": count,
|
||
"target_cycletime": target_cycletime,
|
||
"cycletime_act": cycletime if cycletime is not None else 0.0,
|
||
"toolno": mold,
|
||
"cycle_id": cycle_id,
|
||
"article_id": art_id,
|
||
}
|
||
if has_rej_ttid and body.ttId is not None:
|
||
cols.append("rejTtId")
|
||
vals.append(":rej_ttid")
|
||
binds["rej_ttid"] = body.ttId
|
||
|
||
conn.execute(
|
||
text(
|
||
"INSERT INTO production.rejects (" + ", ".join(cols) + ") "
|
||
"VALUES (" + ", ".join(vals) + ")"
|
||
),
|
||
binds,
|
||
)
|
||
logger.info("temper_rejects booked press=%s order=%s ttId=%s count=%s reason=%s cycles=%s",
|
||
press, ordernumber, body.ttId, count, reason_id, interval_str or "MAX(id)")
|
||
return {"success": True, "message": "Ausschuss gebucht", "cycles": interval_str}
|
||
except Exception as e: # transaction() already rolled back on exit
|
||
logger.error("temper_rejects reject write failed press=%s order=%s: %s", press, ordernumber, e, exc_info=True)
|
||
# 200 + success:False on purpose (see the note at the top of this handler) so
|
||
# the operator sees the real failure instead of a swallowed false success.
|
||
return {"success": False, "error": "database_error", "message": str(e)}
|