diff --git a/AGENTS.md b/AGENTS.md index b2fb7d3..a6ee1bb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,7 +35,10 @@ Indonesian (`karung`=sack, `truk`=truck); YOLO class names are English `LOCAL_RTSP`) is deprecated — don't add keys there. Dashboard mode switches write `config.yaml` `models.active_mode` (atomic, manual restart to apply); `batch_mode.json` keeps only batch flow mode (`auto`|`do_manual`|`manual`, - default seed `batch.default_mode: auto`). + default seed `batch.default_mode: manual`). `manual` start requires plate + (`canonical_plate`: spaces/dots/hyphens stripped, upper; DB stores canonical, + render `pretty_plate`); `auto` merges batches when a sack sits in the counting + ROI — known limitation, fix not landed. **Port split:** `mode` / `model_mode` / `require_plate` / `require_do` POSTs → office `:5721` only (403 on `:5000`); **`ocr_engine` office-only too** (default `rapid`). Discard batch only if both **gross** `count==0` and diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d074b5..b295aff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,15 +6,28 @@ version tags are cut in this repo — POC stage, `main` is the release line). ## [Unreleased] +### Changed +- **Plate canonicalization** (`canonical_plate` in `src/do_batch.py`): plates are + stored uppercase alnum-only (`B 1234 XYZ` / `b-1234.xyz` → `B1234XYZ`), so + spacing/dot/hyphen variants are one plate everywhere — DB, history, XLSX, DO + grouping (no more false `mixed_plates`). Render pretty (`B 1234 XYZ`) via + `pretty_plate`. Applies to manual start, DO plate edit, DO OCR draft. +- `batch.default_mode` = **`manual`** (was `auto`) — auto mode merged many + truck loads into one batch when a sack sat in the counting ROI (2026-10-03: + 2486 sacks / 7h14m in a single row). Operator page gets a manual-mode banner + (plate → start when truck ready → stop when truck leaves). +- Manual start plate field warns (never blocks) when the value doesn't look like + a plate. + ### Added - **Manual mode plate entry**: legacy `manual` batch start is now locked until a plate number is entered — plate input in operator + monitoring start modals (confirm button disabled while empty), `POST /api/batch/start` rejects - manual start with 400 `missing_plate` (strip + uppercase, no format regex); + manual start with 400 `missing_plate` (canonical + non-empty, no format regex); plate flows through `current_batch.json` → `batches.plate` on stop, so history/XLSX show it for manual batches. Operator plate tile now also shows for manual batches (was `do_manual` only). -- **DO-gated manual batch mode** (`auto` default · `do_manual` · legacy `manual`): +- **DO-gated manual batch mode** (`manual` default · `do_manual` · `auto`): smartphone photo capture → OCR draft → plate-grouped start gates → net expected/counted on live panel → stop soft-warn + force → discard only when **both** gross `count` and `box_loading` are 0. diff --git a/README.md b/README.md index b01d2e4..d8c5d8d 100644 --- a/README.md +++ b/README.md @@ -93,9 +93,9 @@ batch mode, DO upload/staged/settings, CSV/Excel export). Data source: | Mode | Who opens/closes batch | DO gate | |---|---|---| -| `auto` (default) | AI truck FSM | — | +| `auto` | AI truck FSM — merges batches when a sack sits in the counting ROI (known) | — | | `do_manual` | Operator + DO photo scan | yes | -| `manual` | Operator buttons (legacy) | no | +| `manual` (default) | Operator buttons + plate number per truck | no | Office (`:5721`) switches mode / model mode / require_plate / ocr_engine (403 on operator POST). **DO manual flow:** open `http://:5000/operator` on a phone → diff --git a/config.yaml b/config.yaml index 1c09839..7aee656 100644 --- a/config.yaml +++ b/config.yaml @@ -123,7 +123,7 @@ batch: truck_gone_tolerance_seconds: 30.0 # truck-gone: in WAITING_FOR_ACTIVITY, N s with no sack crossing + no visible sack + no valid truck signal -> finalize the batch (auto-mode end condition) merge_threshold_seconds: 300 # deprecated by timeout_seconds and detecting sacks within the ROI daily_cutoff_time: "06:00" - default_mode: "auto" # auto | do_manual | manual (seed when batch_mode.json missing) + default_mode: "manual" # auto | do_manual | manual (seed when batch_mode.json missing). manual = operator start/stop + plate per truck (auto merges batches when a sack sits in the counting ROI) # do = Delivery Order (surat jalan). Gated manual-batch scan flow. # Used when batch mode == do_manual (mode itself lives in batch_mode.json). diff --git a/counter_dashboard.py b/counter_dashboard.py index 4022dda..834e892 100644 --- a/counter_dashboard.py +++ b/counter_dashboard.py @@ -71,12 +71,14 @@ DASHBOARD_HOST = CFG.dashboard.host FLASK_DEBUG = CFG.dashboard.debug from src.do_batch import ( + canonical_plate, empty_do_settings, group_dos_by_plate, is_valid_batch_mode, is_valid_ocr_engine, net_counts, normalize_batch_mode, + pretty_plate, OCR_ENGINES, retention_cutoff_date, should_discard_batch, @@ -475,7 +477,7 @@ def api_batch_start(): elif mode == "manual": # Legacy manual mode: batch start locked until plate entered. req_data = request.get_json(silent=True) or request.form or {} - plate = str(req_data.get("plate", "")).strip().upper() + plate = canonical_plate(req_data.get("plate", "")) if not plate: return jsonify({ "success": False, @@ -982,7 +984,7 @@ def api_do_upload(): """, (counting_date, rel, ocr.get("no_do", "") or "", - ocr.get("plate", "") or "", +canonical_plate(ocr.get("plate")), int(ocr.get("expected_sack") or 0), int(ocr.get("expected_box") or 0), ocr_raw), @@ -992,7 +994,7 @@ def api_do_upload(): "id": do_id, "photo_url": f"/api/do/photo/{do_id}", "no_do": ocr.get("no_do", "") or "", - "plate": ocr.get("plate", "") or "", + "plate": pretty_plate(ocr.get("plate")), "expected_sack": int(ocr.get("expected_sack") or 0), "expected_box": int(ocr.get("expected_box") or 0), "ocr_text": ocr.get("ocr_text", "") or "", @@ -1067,7 +1069,7 @@ def api_do_staged(): "id": r["id"], "photo_url": f"/api/do/photo/{r['id']}", "no_do": r["no_do"] or ocr.get("no_do", ""), - "plate": r["plate"] or ocr.get("plate", ""), + "plate": pretty_plate(r["plate"] or ocr.get("plate")), "expected_sack": r["expected_sack"], "expected_box": r["expected_box"], "status": r["status"], @@ -1111,7 +1113,7 @@ def api_do_item(do_id): req = request.get_json(silent=True) or {} no_do = str(req.get("no_do", "")).strip() - plate = str(req.get("plate", "")).strip().upper() + plate = canonical_plate(req.get("plate", "")) try: expected_sack = int(req.get("expected_sack", 0) or 0) expected_box = int(req.get("expected_box", 0) or 0) @@ -1267,7 +1269,7 @@ def api_current_batch(): "unloading": data.get("unloading", 0), "net_sack": net_sack, "net_box": net_box, - "plate": data.get("plate", "") or "", + "plate": pretty_plate(data.get("plate")), "do_numbers": data.get("do_numbers") or [], "expected_sack": int(data.get("expected_sack", 0) or 0), "expected_box": int(data.get("expected_box", 0) or 0), @@ -1344,7 +1346,7 @@ def api_previous_batch(): "count": row["count"], "box_loading": row["box_loading"], "model_mode": row["model_mode"], - "plate": row["plate"] if "plate" in row.keys() else "", + "plate": pretty_plate(row["plate"]), "do_numbers": do_numbers, "expected_sack": row["expected_sack"] if "expected_sack" in row.keys() else 0, "expected_box": row["expected_box"] if "expected_box" in row.keys() else 0, @@ -1507,7 +1509,7 @@ def api_day_detail(date): "count": row["count"], "box_loading": row["box_loading"], "model_mode": row["model_mode"], - "plate": row["plate"] if "plate" in row.keys() else "", + "plate": pretty_plate(row["plate"]), "do_numbers": do_numbers, "expected_sack": row["expected_sack"] if "expected_sack" in row.keys() else 0, "expected_box": row["expected_box"] if "expected_box" in row.keys() else 0, @@ -1990,7 +1992,7 @@ def export_day_xlsx(date): ws.cell(row=r_idx, column=1, value=row["batch_number"]) ws.cell(row=r_idx, column=2, value=row["count"]) ws.cell(row=r_idx, column=3, value=row["box_loading"]) - ws.cell(row=r_idx, column=4, value=row["plate"] if "plate" in row.keys() else "") + ws.cell(row=r_idx, column=4, value=pretty_plate(row["plate"])) ws.cell(row=r_idx, column=5, value=", ".join(do_numbers)) ws.cell(row=r_idx, column=6, value=row["expected_sack"] if "expected_sack" in row.keys() else 0) ws.cell(row=r_idx, column=7, value=row["expected_box"] if "expected_box" in row.keys() else 0) diff --git a/docs/configuration.md b/docs/configuration.md index 52763b4..ac0f260 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -36,9 +36,9 @@ deprecated (warned). | Mode | Who opens/closes | DO gate | |---|---|---| -| `auto` (default) | AI truck FSM | n/a | +| `auto` | AI truck FSM — merges batches when a sack sits in the counting ROI (known) | n/a | | `do_manual` | Operator start/stop + staged DO photos | yes | -| `manual` | Operator start/stop (legacy) — plate required at start | no | +| `manual` (**default**) | Operator start/stop, plate required at start | no | `POST /api/batch/mode` with `mode` and/or `model_mode` is **office port only** (403 on operator `:5000`); mode switch while a batch is active → **409**. diff --git a/src/do_batch.py b/src/do_batch.py index 107b609..74afb5b 100644 --- a/src/do_batch.py +++ b/src/do_batch.py @@ -2,6 +2,7 @@ from __future__ import annotations +import re from datetime import datetime, timedelta from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple @@ -11,6 +12,25 @@ SACK_LIKE_UNITS = frozenset({"krg", "karung", "sack", "bg"}) BOX_LIKE_UNITS = frozenset({"box", "ctn", "karton", "dus", "kardus"}) +def canonical_plate(raw: Any) -> str: + """Plate identity: uppercase alnum only. Spaces/dots/hyphens removed. + + Storage form — `B 1234 XYZ` and `b1234xyz` are the same plate. + """ + return re.sub(r"[\s.\-_]+", "", str(raw or "")).upper() + + +def pretty_plate(raw: Any) -> str: + """Display form of a canonical plate: `B1234XYZ` -> `B 1234 XYZ`.""" + p = canonical_plate(raw) + if not p: + return "" + m = re.match(r"^([A-Z]*)(\d*)([A-Z]*)$", p) + if m and (m.group(1) or m.group(3)): + return " ".join(g for g in m.groups() if g) + return p + + def normalize_batch_mode(raw: Any, default: str = "auto") -> str: """Map unknown/missing mode to default (auto). Validates known values.""" mode = str(raw or "").strip().lower() @@ -68,7 +88,7 @@ def group_dos_by_plate(dos: Sequence[Dict[str, Any]]) -> Tuple[str, List[Dict[st Raises ValueError if non-empty plates disagree (mixed plates). """ items = list(dos or []) - plates = {str(d.get("plate") or "").strip().upper() for d in items} + plates = {canonical_plate(d.get("plate")) for d in items} plates.discard("") if len(plates) > 1: raise ValueError(f"mixed_plates:{','.join(sorted(plates))}") @@ -76,8 +96,7 @@ def group_dos_by_plate(dos: Sequence[Dict[str, Any]]) -> Tuple[str, List[Dict[st out = [] for d in items: d = dict(d) - p = str(d.get("plate") or "").strip().upper() - d["plate"] = p + d["plate"] = canonical_plate(d.get("plate")) out.append(d) out.sort(key=lambda d: (d.get("plate") or "", str(d.get("no_do") or ""))) return plate, out diff --git a/templates/monitoring.html b/templates/monitoring.html index 879e332..63d5cfb 100644 --- a/templates/monitoring.html +++ b/templates/monitoring.html @@ -412,6 +412,9 @@ oninput="onMonStartPlateInput()" style="width:100%;padding:8px 10px;border-radius:var(--radius-md);border:1px solid var(--border-color);background:var(--bg-card);color:var(--text-primary);font-size:14px;text-transform:uppercase;box-sizing:border-box;"> +
@@ -485,11 +488,18 @@ } } + function plateLooksOff(v) { + const c = v.toUpperCase().replace(/[\s.\-_]+/g, ''); + return c.length > 0 && (!/^[A-Z0-9]{4,12}$/.test(c) || !/[A-Z]/.test(c)); + } + function onMonStartPlateInput() { - const plate = document.getElementById('monStartPlateInput').value.trim(); + const raw = document.getElementById('monStartPlateInput').value; + document.getElementById('monStartPlateHint').style.display = + plateLooksOff(raw) ? 'block' : 'none'; const btn = document.getElementById('monBtnStartConfirm'); - btn.style.opacity = plate.length === 0 ? '0.6' : ''; - btn.style.pointerEvents = plate.length === 0 ? 'none' : ''; + btn.style.opacity = raw.trim().length === 0 ? '0.6' : ''; + btn.style.pointerEvents = raw.trim().length === 0 ? 'none' : ''; } function confirmStartBatch() { diff --git a/templates/operator.html b/templates/operator.html index 62fbfe7..454a6bc 100644 --- a/templates/operator.html +++ b/templates/operator.html @@ -432,6 +432,9 @@ style="width:100%;padding:8px 10px;border-radius:var(--radius-md);border:1px solid var(--border-color);background:var(--bg-card);color:var(--text-primary);font-size:14px;text-transform:uppercase;box-sizing:border-box;" oninput="onStartPlateInput()">
+