diff --git a/api/routes.py b/api/routes.py index b12c86a..a624072 100644 --- a/api/routes.py +++ b/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