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"])
|
||||
|
||||
|
||||
# ── 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 ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@ -394,29 +507,16 @@ def reject(body: RejectBody) -> dict[str, Any]:
|
||||
if count <= 0 or reason_id <= 0:
|
||||
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:
|
||||
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
|
||||
# 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
|
||||
@ -451,33 +551,44 @@ def reject(body: RejectBody) -> dict[str, Any]:
|
||||
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 "
|
||||
"(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)"
|
||||
"INSERT INTO production.rejects (" + ", ".join(cols) + ") "
|
||||
"VALUES (" + ", ".join(vals) + ")"
|
||||
),
|
||||
{
|
||||
"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,
|
||||
},
|
||||
binds,
|
||||
)
|
||||
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"}
|
||||
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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user