feat: wire live data + fail-safe reject booking

Backend (api/routes.py) — replace sample data with live reads/writes:
- GET /overview reads production.temper_tracking (done carts, plant-scoped,
  windowed, newest-first, one row per ttId); each order's 5-station belt is
  derived from planned timestamps vs NOW(), faithful to fastpress
  _temper_aggregates_all. sample:false.
- GET /reasons reads settings.reject_reasons (active=1).
- POST /reject WRITES production.rejects keyed by the ORDER (rejOrdernumber +
  rejPress, press taken from the body) — an un-scoped port of PressV
  send_reject; no rejTtId column needed (ttId is context-only). Failures
  return HTTP 200 + {success:false}, NOT 4xx/5xx.
Belt: the single pre-oven buffer (Wagenzeit) shows under Unterwegs; Wagen=0.

Frontend — never report an unconfirmed booking as success:
- store.submitReject returns the gateway's own {success} and reports failure on
  a transport error instead of faking success.
- store.refresh keeps the last REAL data on a transient poll error (only a cold
  start falls back to demo carts) so a live terminal never shows fabricated carts.
- the failure toast is now red (was always green).

Verified live against cdbserv15 (12 carts, 25 reasons, INSERT round-trip).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-02 13:46:56 +02:00
parent 27edd2664a
commit 83d86dfe90
7 changed files with 482 additions and 114 deletions

4
.gitignore vendored
View File

@ -1,3 +1,7 @@
# Python bytecode (this repo is also a gateway page-module — imported at runtime)
__pycache__/
*.py[cod]
# Dependencies
node_modules/
.pnp

View File

@ -6,93 +6,270 @@ Served same-origin under ``/module/temper_rejects``. Three routes:
* POST /module/temper_rejects/api/reject book one Ausschuss
No ``verify_token`` dependency: this is a central login-free terminal (no device
context). ``press`` + ``ttId`` come from the request BODY, unlike the device-scoped
``POST /press/rejects/send`` in fastpress.
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).
STATUS: BASE SCAFFOLD
Returns realistic SAMPLE data shaped EXACTLY like the frontend contract
(``src/types/station.ts``); POST /reject validates + echoes success WITHOUT writing.
This lets the terminal render + exercise the booking flow with no database.
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:
TODO wire real data (see ``.claude/memory/ausschuss-station.md`` in the gateway):
* ``from .._db import run_select_query, transaction``
* OVERVIEW: read ``production.temper_tracking`` across ALL presses (un-scoped),
one row per ``ttId``, newest first by ``ttOutOfOvenAt`` (status='done'); join
``active_orders``/``finished_orders`` for article. Per entry, add the ORDER's
5-station distribution (reuse fastpress ``temper._temper_aggregates_all``:
notYetSent / sentToTemper / onWayToOven / inOven / finishedTempering).
* 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: one INSERT into ``production.rejects`` inside a ``transaction()`` (same
shape as fastpress ``rejects.send_reject``). Booking references the cart
needs the additive nullable ``rejTtId`` column (DBA applies it; runtime account
has no ALTER). Confirm the security posture (network-only vs. a static token)
before exposing the write.
* 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
from datetime import datetime, timedelta
from typing import Any
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"])
# window code → minutes (max 30 days). Scopes which finished carts are listed.
# 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"
# All presses in the plant — drives the left-hand filter checkboxes (default all).
_PRESSES = ["P1", "P2", "P3", "P4", "P5", "P6", "P7", "P8", "P9"]
# 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
_REASONS = [
{"id": 1, "reason_de": "Maßabweichung", "reason_en": "Dimensional deviation"},
{"id": 2, "reason_de": "Lunker", "reason_en": "Voids"},
{"id": 3, "reason_de": "Bindenaht", "reason_en": "Weld line"},
{"id": 4, "reason_de": "Verbrennung", "reason_en": "Burn mark"},
{"id": 5, "reason_de": "Verzug", "reason_en": "Warping"},
{"id": 6, "reason_de": "Sonstiges", "reason_en": "Other"},
]
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 (see the frontend
TemperBelt colours: nichtGesendet=violet, wagen=cyan, unterwegs=amber,
ofen=orange, fertig=emerald)."""
"""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}
# (ttId, cartId, press, orderNo, article, partsGood, scrap, ageMin, flow)
_ENTRIES_RAW: list[tuple[int, str, str, str, str, int, int, int, dict[str, int]]] = [
(10231, "00A17226", "P5", "O-4455", "Gehäuse 12 mm", 60, 2, 90, _flow(120, 60, 40, 72, 260)),
(10230, "00917226", "P8", "O-4489", "Halter Typ A", 80, 1, 150, _flow(40, 30, 36, 80, 210)),
(10229, "00817226", "P3", "O-4471", "Deckel rund", 54, 4, 320, _flow(180, 54, 30, 88, 300)),
(10228, "00717226", "P1", "O-4471", "Deckel rund", 64, 0, 500, _flow(180, 54, 30, 88, 300)),
(10227, "00617226", "P2", "O-4460", "Klammer klein", 50, 3, 900, _flow(90, 42, 0, 60, 190)),
(10226, "00517226", "P5", "O-4455", "Gehäuse 12 mm", 72, 5, 1400, _flow(120, 60, 40, 72, 260)),
(10225, "00417226", "P6", "O-4460", "Klammer klein", 44, 1, 3000, _flow(90, 42, 0, 60, 190)),
(10224, "00317226", "P8", "O-4489", "Halter Typ A", 58, 2, 8000, _flow(40, 30, 36, 80, 210)),
(10223, "00217226", "P9", "O-4471", "Deckel rund", 40, 0, 22000, _flow(180, 54, 30, 88, 300)),
]
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"])
def _entry(row: tuple, now: datetime) -> dict[str, Any]:
tt_id, cart_id, press, order_no, article, good, scrap, age_min, flow = row
return {
"ttId": tt_id,
"cartId": cart_id,
"press": press,
"orderNo": order_no,
"article": article,
"partsGood": good,
"scrap": scrap,
"doneAt": (now - timedelta(minutes=age_min)).astimezone().isoformat(),
"flow": flow,
}
# ── request schema ────────────────────────────────────────────────────────────
class RejectBody(BaseModel):
# permissive like fastpress's reject schema; validated in the handler.
# 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
@ -101,44 +278,208 @@ class RejectBody(BaseModel):
reason: int | None = None
@router.get("/overview", summary="Tempered carts per ttId, newest first (SAMPLE data)")
# ── 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]
now = datetime.now()
entries = [_entry(r, now) for r in _ENTRIES_RAW if r[7] <= limit_min] # r[7] = ageMin
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": now.astimezone().isoformat(),
"sample": True, # frontend shows a "Demo-Daten" hint while set
"presses": _PRESSES,
"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]:
return {"reasons": _REASONS, "sample": True}
"""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 a ttId (SAMPLE — no write)")
@router.post("/reject", summary="Book one Ausschuss against an order (writes production.rejects)")
def reject(body: RejectBody) -> dict[str, Any]:
# Validation mirrors fastpress send_reject (positive count + reason). The base
# scaffold does NOT write — it echoes success so the terminal's booking flow is
# exercisable end-to-end. See the module TODO for the real INSERT.
"""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)
tt_id = int(body.ttId or 0)
except (ValueError, TypeError):
return {"success": False, "error": "bad_request", "message": "count, reason, ttId must be integers"}
if not body.press or tt_id <= 0:
return {"success": False, "error": "bad_request", "message": "press and ttId are required"}
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"}
return {
"success": True,
"sample": True,
"message": "Ausschuss gebucht (Demo — nicht gespeichert)",
"booked": {"press": body.press, "orderNo": body.orderNo, "ttId": tt_id, "count": count, "reason": reason_id},
}
import json
try:
with transaction() as conn:
# Latest cycle for this press + joborder (direct match or JSON list).
cycle_row = conn.execute(
text(
"SELECT ToolNo, T_Cycle_Act, ArticleID, CycleID "
"FROM production.cycles_new WHERE id = ("
" SELECT MAX(id) FROM production.cycles_new "
" WHERE Press = :press AND (Joborder = :orderno OR JSON_CONTAINS(Joborder, :orderjson))"
") LIMIT 1"
),
{"press": press, "orderno": ordernumber, "orderjson": json.dumps(ordernumber)},
).fetchone()
mold = cycle_row[0] if cycle_row else None
cycletime = cycle_row[1] if cycle_row else None
raw_art_id = cycle_row[2] if cycle_row else None
cycle_id = cycle_row[3] if cycle_row else None
try:
art_id = int(raw_art_id) if raw_art_id is not None else None
except (ValueError, TypeError):
art_id = None
# 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 ""
conn.execute(
text(
"INSERT INTO production.rejects "
"(rejPress, rejOrdernumber, rejArticleDesc, rejReason_INT, rejReason_DE, rejReason_EN, "
"rejQuantity, rejTargetCycletime, rejCycletimeAct, rejToolNo, rejCycleID, rejArticleID) "
"VALUES (:press, :orderno, :part, :reason_int, :reason_de, :reason_en, "
":quantity, :target_cycletime, :cycletime_act, :toolno, :cycle_id, :article_id)"
),
{
"press": press,
"orderno": ordernumber,
"part": part,
"reason_int": reason_id,
"reason_de": reason_de,
"reason_en": reason_en,
"quantity": count,
"target_cycletime": target_cycletime,
# rejCycletimeAct is NOT NULL — coalesce a missing cycle to 0.0.
"cycletime_act": cycletime if cycletime is not None else 0.0,
"toolno": mold,
"cycle_id": cycle_id,
"article_id": art_id,
},
)
logger.info("temper_rejects booked press=%s order=%s ttId=%s count=%s reason=%s",
press, ordernumber, body.ttId, count, reason_id)
return {"success": True, "message": "Ausschuss gebucht"}
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)}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

4
dist/index.html vendored
View File

@ -9,8 +9,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Ausschuss-Station</title>
<script type="module" crossorigin src="/module/temper_rejects/assets/index-DCD9Lyh2.js"></script>
<link rel="stylesheet" crossorigin href="/module/temper_rejects/assets/index-Dw_xKaUy.css">
<script type="module" crossorigin src="/module/temper_rejects/assets/index-D0flZxiB.js"></script>
<link rel="stylesheet" crossorigin href="/module/temper_rejects/assets/index-mkbUZDoP.css">
</head>
<body>
<div id="app"></div>

View File

@ -1,5 +1,6 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import axios from 'axios'
import api from '@/api'
import { sampleStation, SAMPLE_REASONS } from '@/data/sample'
import type { StationPayload, Reason, RejectRequest, RejectResult, WindowCode } from '@/types/station'
@ -76,9 +77,15 @@ export const useTemperRejectsStore = defineStore('temperRejects', () => {
payload.value = res.data
usingSample.value = res.data.sample === true
} catch (e) {
payload.value = sampleStation(window.value)
usingSample.value = true
error.value = e instanceof Error ? e.message : 'Overview API unreachable — showing sample data'
error.value = e instanceof Error ? e.message : 'Overview API unreachable'
// Keep the last known REAL data on a transient failure (stale > fabricated): never
// replace live carts with bundled demo carts on a live terminal, or an operator
// could book against a fabricated entry. Only fall back to sample on a COLD start
// with nothing to show (dev standalone, where there is no gateway).
if (!payload.value || usingSample.value) {
payload.value = sampleStation(window.value)
usingSample.value = true
}
} finally {
// Default the filter to ALL presses on first load (Alle). "Keine" stays a
// valid explicit state afterwards, so only auto-fill once.
@ -122,7 +129,8 @@ export const useTemperRejectsStore = defineStore('temperRejects', () => {
saveCache()
}
/** Book one reject. Real gateway writes a row; dev/no-backend simulates success. */
/** Book one reject. Returns the gateway's own {success} verdict. NEVER fakes success:
* on this live, login-free terminal an unconfirmed write must not read as booked. */
async function submitReject(req: RejectRequest): Promise<RejectResult> {
try {
const res = await api.post<RejectResult>('/reject', req)
@ -130,9 +138,17 @@ export const useTemperRejectsStore = defineStore('temperRejects', () => {
if (res.data.success) await refresh()
return res.data
}
throw new Error('bad response')
} catch {
return { success: true, sample: true, message: 'Ausschuss gebucht (Demo — kein Backend)' }
// Reachable but unexpected shape (e.g. a dev SPA fallback serving index.html) →
// treat as NOT booked.
return { success: false, error: 'bad_response' }
} catch (e) {
// The gateway returns HTTP 200 + {success:false} for refusals/write failures, so a
// throw here is a genuine transport failure (backend unreachable / timeout) or a
// non-2xx carrying a body. Prefer the server's own {success:false}; otherwise report
// a transport failure. Either way, do NOT report success for an unconfirmed write.
const data = axios.isAxiosError(e) ? (e.response?.data as RejectResult | undefined) : undefined
if (data && typeof data === 'object' && 'success' in data) return data
return { success: false, error: 'unreachable' }
}
}

View File

@ -25,10 +25,12 @@ const { window, reasons, allPresses, checkedPresses, pressCounts, filteredEntrie
const bookingEntry = ref<RejectEntry | null>(null)
const submitting = ref(false)
const toast = ref<string | null>(null)
const toastOk = ref(true)
let toastTimer: ReturnType<typeof setTimeout> | null = null
function flashToast(msg: string) {
function flashToast(msg: string, ok = true) {
toast.value = msg
toastOk.value = ok
if (toastTimer) clearTimeout(toastTimer)
toastTimer = setTimeout(() => (toast.value = null), 3200)
}
@ -39,9 +41,10 @@ async function onSubmit(req: RejectRequest) {
submitting.value = false
if (res.success) {
bookingEntry.value = null
flashToast(res.message || t('dialog.success'))
flashToast(res.message || t('dialog.success'), true)
} else {
flashToast(res.message || t('dialog.failed'))
// keep the dialog open so the operator can retry a booking that did NOT go through
flashToast(res.message || t('dialog.failed'), false)
}
}
@ -81,7 +84,7 @@ onUnmounted(() => {
<RejectDialog :entry="bookingEntry" :reasons="reasons" :submitting="submitting" @close="bookingEntry = null" @submit="onSubmit" />
<transition name="fade">
<div v-if="toast" class="toast">{{ toast }}</div>
<div v-if="toast" class="toast" :class="{ err: !toastOk }">{{ toast }}</div>
</transition>
</div>
</template>
@ -131,6 +134,10 @@ onUnmounted(() => {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
z-index: 100;
}
.toast.err {
background: var(--ausschuss);
color: #fff;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;