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:
parent
27edd2664a
commit
83d86dfe90
4
.gitignore
vendored
4
.gitignore
vendored
@ -1,3 +1,7 @@
|
||||
# Python bytecode (this repo is also a gateway page-module — imported at runtime)
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
|
||||
# Dependencies
|
||||
node_modules/
|
||||
.pnp
|
||||
|
||||
503
api/routes.py
503
api/routes.py
@ -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
4
dist/index.html
vendored
@ -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>
|
||||
|
||||
@ -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) {
|
||||
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
|
||||
error.value = e instanceof Error ? e.message : 'Overview API unreachable — showing sample data'
|
||||
}
|
||||
} 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' }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user