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:
parent
10aac9ac5f
commit
ac63988e6d
199
api/routes.py
199
api/routes.py
@ -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,33 +551,44 @@ 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 ""
|
||||||
|
|
||||||
|
# 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(
|
conn.execute(
|
||||||
text(
|
text(
|
||||||
"INSERT INTO production.rejects "
|
"INSERT INTO production.rejects (" + ", ".join(cols) + ") "
|
||||||
"(rejPress, rejOrdernumber, rejArticleDesc, rejReason_INT, rejReason_DE, rejReason_EN, "
|
"VALUES (" + ", ".join(vals) + ")"
|
||||||
"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)"
|
|
||||||
),
|
),
|
||||||
{
|
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,
|
|
||||||
# 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",
|
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
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user