feat(manual-batch): canonical plate + manual mode as default
ci / smoke (push) Waiting to run

- canonical_plate (uppercase alnum, strips space/dot/hyphen) at all plate
  write sites; pretty_plate for render (history, XLSX, operator tile) so
  B 1234 XYZ and B1234XYZ are one plate everywhere
- group_dos_by_plate compares canonical plates -> no false mixed_plates
- warn-only (never blocking) plate format hint in operator + monitoring modals
- batch.default_mode: manual (auto merged truck loads when a sack sat in the
  counting ROI); operator banner explains plate -> start -> stop
- docs + AGENTS known-limitation note; 85 tests pass
This commit is contained in:
andrew committed 2026-10-05 09:36:32 +07:00
1 parent 7ad995d8d1
commit 6c5b1c8b30
11 files changed
+125 -29

No files matched your search

+4 -1
View File
@@ -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
+15 -2
View File
@@ -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.
+2 -2
View File
@@ -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://<jetson>:5000/operator` on a phone →
+1 -1
View File
@@ -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).
+11 -9
View File
@@ -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)
+2 -2
View File
@@ -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**.
+22 -3
View File
@@ -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
+13 -3
View File
@@ -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;">
</div>
<div id="monStartPlateHint" style="display:none;color:var(--accent-orange);font-size:12px;margin-top:4px; text-align:left;">
Format plat tidak umum — cek lagi (mis. B 1234 XYZ).
</div>
<div style="display: flex; gap: 12px;">
<button class="btn btn-secondary" style="flex: 1;" onclick="document.getElementById('modalStart').style.display='none'">Batal</button>
<button id="monBtnStartConfirm" class="btn btn-primary" style="flex: 1; background-color: var(--accent-green);" onclick="executeStartBatch()">Ya, Mulai</button>
@@ -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() {
+23 -5
View File
@@ -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()">
</div>
<div id="startPlateHint" style="display:none;color:var(--accent-orange);font-size:12px;margin-top:4px;">
Format plat tidak umum — cek lagi (mis. B 1234 XYZ).
</div>
<div id="startGateList" class="modal-desc" style="display:none;color:var(--accent-red);"></div>
<div class="modal-actions">
<button class="btn btn-secondary" onclick="closeModal('modalStart')">Batal</button>
@@ -474,14 +477,22 @@
const res = await fetch('/api/batch/mode');
const data = await res.json();
updateModeUI(data.mode || 'auto');
const banner = document.getElementById('modeBanner');
if (data.mode === 'auto') {
document.getElementById('modeBannerText').textContent =
'Mode OTOMATIS aktif. Mode diatur dari monitoring/kantor.';
if (!sessionStorage.getItem('mode_banner_dismissed')) {
document.getElementById('modeBanner').style.display = 'flex';
banner.style.display = 'flex';
}
} else if (data.mode === 'manual') {
document.getElementById('modeBannerText').textContent =
'Mode MANUAL: isi plat → Mulai saat truk siap di posisi muat → Selesai saat truk keluar. ' +
'Satu batch = satu truk. Mode diatur dari monitoring/kantor.';
if (!sessionStorage.getItem('mode_banner_dismissed')) {
banner.style.display = 'flex';
}
} else {
document.getElementById('modeBanner').style.display = 'none';
banner.style.display = 'none';
}
} catch (e) {
console.error('Mode load error:', e);
@@ -508,7 +519,7 @@
loadStagedDos();
loadDoSettings();
} else if (mode === 'manual') {
if (sub) sub.textContent = 'Tekan tombol saat proses muat truk dimulai dan selesai';
if (sub) sub.textContent = 'Isi plat → Mulai saat truk siap → Selesai saat truk keluar';
} else {
if (sub) sub.textContent = 'Mode otomatis — batch dikontrol AI truk';
}
@@ -694,10 +705,17 @@
return (data && data.items) || [];
}
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 onStartPlateInput() {
const plate = document.getElementById('startPlateInput').value.trim();
const raw = document.getElementById('startPlateInput').value;
document.getElementById('startPlateHint').style.display =
plateLooksOff(raw) ? 'block' : 'none';
const btn = document.getElementById('btnStartConfirm');
btn.classList.toggle('btn-disabled', plate.length === 0);
btn.classList.toggle('btn-disabled', raw.trim().length === 0);
}
function confirmStartBatch() {
+1 -1
View File
@@ -41,7 +41,7 @@ def test_repo_config_loads_and_validates(repo_config_path):
def test_do_block_and_batch_default_mode(repo_config_path):
cfg = load_config(repo_config_path)
assert cfg.batch.default_mode == "auto"
assert cfg.batch.default_mode == "manual"
# timeout_seconds = sack-idle (pause); truck_gone_tolerance_seconds = finalize
assert cfg.batch.timeout_seconds == 20.0
assert cfg.batch.truck_gone_tolerance_seconds == 30.0
+31
View File
@@ -8,7 +8,9 @@ import pytest
from src.do_batch import (
BATCH_MODES,
OCR_ENGINES,
canonical_plate,
classify_unit_token,
pretty_plate,
empty_do_settings,
group_dos_by_plate,
is_valid_batch_mode,
@@ -23,6 +25,35 @@ from src.do_batch import (
from src.do_ocr import parse_fields_from_text
def test_canonical_plate_strips_separators():
assert canonical_plate(" b 1234 xyz ") == "B1234XYZ"
assert canonical_plate("B-1234-XYZ") == "B1234XYZ"
assert canonical_plate("B.1234.XYZ") == "B1234XYZ"
assert canonical_plate("b1234xyz") == canonical_plate("B 1234 XYZ")
assert canonical_plate(None) == ""
assert canonical_plate(" ") == ""
def test_pretty_plate_display_form():
assert pretty_plate("B1234XYZ") == "B 1234 XYZ"
assert pretty_plate("b 1234. xyz") == "B 1234 XYZ"
assert pretty_plate("B1") == "B 1"
assert pretty_plate("1234AB") == "1234 AB"
assert pretty_plate("") == ""
assert pretty_plate("!!") == "!!" # not a plate pattern, raw fallback
def test_group_dos_by_plate_ignores_spacing_only_difference():
dos = [
{"no_do": "D1", "plate": "B 1234 XYZ"},
{"no_do": "D2", "plate": "b1234xyz"},
{"no_do": "D3", "plate": "B-1234-XYZ"},
]
plate, out = group_dos_by_plate(dos)
assert plate == "B1234XYZ"
assert all(d["plate"] == "B1234XYZ" for d in out)
def test_normalize_batch_mode_defaults_auto():
assert normalize_batch_mode(None) == "auto"
assert normalize_batch_mode("bogus") == "auto"