feat(rejects): attribute Ausschuss to the cart's cycle interval (median cycle)

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>
This commit is contained in:
Erik 2026-07-06 08:57:43 +02:00
parent 10aac9ac5f
commit ac63988e6d

View File

@ -263,6 +263,119 @@ def _flow_for(
return _flow(not_sent, 0, agg["on_way"], agg["in_oven"], agg["finished"]) 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 ──────────────────────────────────────────────────────────── # ── request schema ────────────────────────────────────────────────────────────
@ -394,29 +507,16 @@ def reject(body: RejectBody) -> dict[str, Any]:
if count <= 0 or reason_id <= 0: if count <= 0 or reason_id <= 0:
return {"success": False, "error": "bad_request", "message": "count and reason must be positive"} return {"success": False, "error": "bad_request", "message": "count and reason must be positive"}
import json has_cycle_cols = _has_cart_cycle_columns()
has_rej_ttid = _has_rej_ttid_column()
try: try:
with transaction() as conn: with transaction() as conn:
# Latest cycle for this press + joborder (direct match or JSON list). # Resolve the ONE cycle this reject references: the cart's interval median
cycle_row = conn.execute( # when booked against a tempered cart (body.ttId), else the legacy latest
text( # cycle (MAX(id)). See _resolve_reject_cycle.
"SELECT ToolNo, T_Cycle_Act, ArticleID, CycleID " mold, cycletime, art_id, cycle_id, interval_str = _resolve_reject_cycle(
"FROM production.cycles_new WHERE id = (" conn, press, ordernumber, body.ttId, has_cycle_cols
" 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. # Article desc / target cycletime — active first, finished as fallback.
part = target_cycletime = None part = target_cycletime = None
@ -451,15 +551,16 @@ def reject(body: RejectBody) -> dict[str, Any]:
reason_de = reason_row[0] if reason_row else "" reason_de = reason_row[0] if reason_row else ""
reason_en = reason_row[1] if reason_row else "" reason_en = reason_row[1] if reason_row else ""
conn.execute( # rejCycletimeAct is NOT NULL — coalesce a missing cycle to 0.0. rejTtId
text( # (migration 009) records the booked-against cart for a durable reject→cart
"INSERT INTO production.rejects " # audit link, appended only when the column exists (pre-009 boxes omit it).
"(rejPress, rejOrdernumber, rejArticleDesc, rejReason_INT, rejReason_DE, rejReason_EN, " cols = ["rejPress", "rejOrdernumber", "rejArticleDesc", "rejReason_INT",
"rejQuantity, rejTargetCycletime, rejCycletimeAct, rejToolNo, rejCycleID, rejArticleID) " "rejReason_DE", "rejReason_EN", "rejQuantity", "rejTargetCycletime",
"VALUES (:press, :orderno, :part, :reason_int, :reason_de, :reason_en, " "rejCycletimeAct", "rejToolNo", "rejCycleID", "rejArticleID"]
":quantity, :target_cycletime, :cycletime_act, :toolno, :cycle_id, :article_id)" vals = [":press", ":orderno", ":part", ":reason_int", ":reason_de", ":reason_en",
), ":quantity", ":target_cycletime", ":cycletime_act", ":toolno",
{ ":cycle_id", ":article_id"]
binds = {
"press": press, "press": press,
"orderno": ordernumber, "orderno": ordernumber,
"part": part, "part": part,
@ -468,16 +569,26 @@ def reject(body: RejectBody) -> dict[str, Any]:
"reason_en": reason_en, "reason_en": reason_en,
"quantity": count, "quantity": count,
"target_cycletime": target_cycletime, "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, "cycletime_act": cycletime if cycletime is not None else 0.0,
"toolno": mold, "toolno": mold,
"cycle_id": cycle_id, "cycle_id": cycle_id,
"article_id": art_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", logger.info("temper_rejects booked press=%s order=%s ttId=%s count=%s reason=%s cycles=%s",
press, ordernumber, body.ttId, count, reason_id) press, ordernumber, body.ttId, count, reason_id, interval_str or "MAX(id)")
return {"success": True, "message": "Ausschuss gebucht"} return {"success": True, "message": "Ausschuss gebucht", "cycles": interval_str}
except Exception as e: # transaction() already rolled back on exit 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) 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 # 200 + success:False on purpose (see the note at the top of this handler) so