diff --git a/AGENTS.md b/AGENTS.md index 083dc76..8694f97 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,14 +27,20 @@ Indonesian (`karung`=sack, `truk`=truck); YOLO class names are English ## Gotchas -- **Unified config**: `config.yaml` is canonical (stream/models/counting/batch/ +- **Unified config**: `config.yaml` is canonical (stream/models/counting/batch/do/ output/camera via `src/config_loader.py`); `.env` holds secrets + deployment only (`RTSP_URL`, dashboard host/ports/secret/site); `zones.json` holds geometry (polygons + left/right limits — knob keys there are ignored, warned); `cfg/tracker.yaml` holds tracker hyperparams. `src/config.py` (v3 keys like `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 manual/auto batch mode. + `batch_mode.json` keeps only batch flow mode (`auto`|`do_manual`|`manual`, + default seed `batch.default_mode: auto`). + **Port split:** `mode` / `model_mode` / `require_plate` / `require_do` POSTs → + office `:5721` only (403 on `:5000`); **`ocr_engine` POST both ports** (synced + via `GET /api/do/settings`). Discard batch only if **both** `net_sack==0` and + `net_box==0`. DO helpers: `src/do_batch.py`, OCR: `src/do_ocr.py`; + ERD: `docs/do-erd.md`. - **Counting filters by class name, not ID**: `SackDetector`/`BoxDetector`/ `TruckDetector` filter via `BaseDetector(class_filter)` (`src/detection.py`); tracker keeps `("sack", "truck", "box")` (`src/tracking.py`); counting uses @@ -51,11 +57,10 @@ Indonesian (`karung`=sack, `truk`=truck); YOLO class names are English - Counting trigger is the bbox **top edge (y1)** vs a truck-anchored line band (`src/counting.py`, `src/truck_roi.py`); truck detect runs every 15th frame only. - **Tests:** `python -m pytest tests/ -q` (smoke tests for `counting`, `batch`, - `config` — pure-Python, no cv2/ultralytics needed). CI (`.github/workflows/ci.yml`) - installs only `numpy python-dotenv pytest` + runs pytest + `compileall`. - Deps: `requirements.txt` (unpinned; **never** pip-install torch from PyPI on the - Jetson — use the NVIDIA wheels already on device). For visual checks run on a video - file (`--source`), not the live RTSP. + `config`, `do_batch` — pure-Python; Flask API tests skip if flask/openpyxl + missing). Deps: `requirements.txt` (unpinned; **never** pip-install torch from + PyPI on the Jetson — use the NVIDIA wheels already on device). For visual checks + run on a video file (`--source`), not the live RTSP. - **Don't commit state**: `.gitignore` excludes `*.engine` (rebuildable), `*.mp4/*.jpg/*.png`, `*.db`, `.env`, `batch_history_folder/`. Videos and local DBs already sit untracked in the working tree — leave them alone. diff --git a/CHANGELOG.md b/CHANGELOG.md index b1cc18f..407acc8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,30 @@ version tags are cut in this repo — POC stage, `main` is the release line). ## [Unreleased] -Nothing pending. +### Added +- **DO-gated manual batch mode** (`auto` default · `do_manual` · legacy `manual`): + smartphone photo capture → OCR draft → plate-grouped start gates → net + expected/counted on live panel → stop soft-warn + force → discard only when + **both** `net_sack` and `net_box` are 0. +- `config.yaml` `batch.default_mode` + `do:` block; `DoConfig` in + `src/config_loader.py`; runtime `$OUTPUT_DIR/do_settings.json`. +- `delivery_orders` table + additive `batches` columns (`plate`, `do_numbers`, + `expected_*`, `net_*`); photo tree `do_photos/YYYY-MM-DD/` (7-day retention). +- APIs: `POST /api/batch/stop-preview`, DO CRUD + `POST /api/do/upload`, + `GET|POST /api/do/settings` (office-only plate/do flags; **ocr_engine both + ports**), office-only mode/model_mode POST (403 on operator; 409 when batch active). +- `src/do_batch.py` (pure gates/nets/units), `src/do_ocr.py` + (`extract_do_fields` tesseract|paddle|none). +- Operator DO panel + OCR toggle; monitoring 3-way mode switch + model mode + + OCR/require-plate controls; history/export plate, DO, expected, net columns. +- `docs/do-erd.md` (Mermaid ERD); tests `tests/test_do_batch.py`. + +### Changed +- Default batch mode seed **`auto`** (was hard-coded `manual` when + `batch_mode.json` missing); `predict.py` accepts `do_manual` as operator-driven + and persists sack `unloading` for net-at-stop. +- Finalize discard rule (dashboard stop **and** `predict.py` `finalize_batch`): + keep row if **either** net ≠ 0 (box-only batches no longer dropped). ## 2026-09-18 — Config tuning: Sack confidence and batch timeout diff --git a/README.md b/README.md index d6f5425..3f2e7b8 100644 --- a/README.md +++ b/README.md @@ -82,11 +82,27 @@ See [`docs/configuration.md`](docs/configuration.md) for every variable. ## Dashboard (`counter_dashboard.py`, port 5000 / office 5721) -Pages in `templates/`: `monitoring.html`, `operator.html` (manual batch start/stop), -`history.html`, `analytics.html`. JSON APIs under `/api/*` (live video MJPEG, current/ -previous batch, summary, daily data, CSV/Excel export). Data source: -`jetson_counter.db` + `current_batch.json` (+ `batch_mode.json` for the -manual/auto batch switch; the model mode lives in `config.yaml`). +Pages in `templates/`: `monitoring.html`, `operator.html` (batch start/stop; +DO panel in `do_manual`), `history.html`, `analytics.html`. JSON APIs under +`/api/*` (live video MJPEG, current/previous batch, summary, daily data, +batch mode, DO upload/staged/settings, CSV/Excel export). Data source: +`jetson_counter.db` + `current_batch.json` + `batch_mode.json` +(batch flow: `auto` | `do_manual` | `manual`; model mode in `config.yaml`). + +### Batch modes + +| Mode | Who opens/closes batch | DO gate | +|---|---|---| +| `auto` (default) | AI truck FSM | — | +| `do_manual` | Operator + DO photo scan | yes | +| `manual` | Operator buttons (legacy) | no | + +Office (`:5721`) switches mode / model mode / require_plate (403 on operator +POST). **DO manual flow:** open `http://:5000/operator` on a phone → +Ambil Foto DO → review/edit OCR fields → Mulai Batch → count → Selesai +(soft-warn if zone busy). Discard only when both sack and box nets are 0. +ERD: [`docs/do-erd.md`](docs/do-erd.md). +Plan: [`docs/do-batch-implementation-plan.md`](docs/do-batch-implementation-plan.md). ## Repository layout @@ -116,5 +132,7 @@ Script-by-script reference: [`docs/scripts.md`](docs/scripts.md). - [`docs/models.md`](docs/models.md) — model inventory & detection-mode support matrix - [`docs/configuration.md`](docs/configuration.md) — all config files/variables - [`docs/deployment.md`](docs/deployment.md) — Jetson services, TensorRT, deploy flow +- [`docs/do-erd.md`](docs/do-erd.md) — delivery-order / batch ERD (Mermaid) +- [`docs/do-batch-implementation-plan.md`](docs/do-batch-implementation-plan.md) — DO manual batch plan - [`docs/scripts.md`](docs/scripts.md) — entry points & utility scripts - [`CHANGELOG.md`](CHANGELOG.md) — release history diff --git a/config.yaml b/config.yaml index 8dd3c19..1d34a73 100644 --- a/config.yaml +++ b/config.yaml @@ -119,6 +119,21 @@ batch: timeout_seconds: 20.0 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) + +# do = Delivery Order (surat jalan). Gated manual-batch scan flow. +# Used when batch mode == do_manual (mode itself lives in batch_mode.json). +do: + enabled: true + require_do: true # start needs ≥1 staged DO (do_manual mode) + require_plate: false # default; office UI can flip at runtime + retention_days: 7 + photo_dir: "do_photos" # relative to output.dir + max_photos_per_batch: 8 + zone_warn_seconds: 3 # stop soft-warn: recent count activity + ocr: + engine: "tesseract" # tesseract | paddle | none — YAML seed only + # Runtime override: do_settings.json ocr_engine (UI toggle, both ports). output: dir: "/opt/jetson-counter" diff --git a/counter_dashboard.py b/counter_dashboard.py index fff7ed1..ea0b275 100644 --- a/counter_dashboard.py +++ b/counter_dashboard.py @@ -68,6 +68,27 @@ DASHBOARD_PORT = CFG.dashboard.port DASHBOARD_HOST = CFG.dashboard.host FLASK_DEBUG = CFG.dashboard.debug +from src.do_batch import ( + empty_do_settings, + group_dos_by_plate, + is_valid_batch_mode, + is_valid_ocr_engine, + net_counts, + normalize_batch_mode, + retention_cutoff_date, + should_discard_batch, + should_purge_counting_date, + start_gate_errors, +) + +# Runtime DO settings (require_plate/do + ocr_engine) — separate from config.yaml seed. +if os.name == "nt": + DO_SETTINGS_PATH = _env_or(f"{_DEFAULT_DIR}/do_settings.json", "DO_SETTINGS_PATH") + DO_PHOTO_ROOT = os.path.join(_DEFAULT_DIR, CFG.do.photo_dir) +else: + DO_SETTINGS_PATH = _env_or(os.path.join(_DEFAULT_DIR, "do_settings.json"), "DO_SETTINGS_PATH") + DO_PHOTO_ROOT = _env_or(os.path.join(CFG.output.dir, CFG.do.photo_dir), "DO_PHOTO_ROOT") + @app.route("/api/live-video") def api_live_video(): if not os.path.isfile(LIVE_STREAM_FRAME_PATH): @@ -139,9 +160,38 @@ def _ensure_db(): ("box_loading", "INTEGER NOT NULL DEFAULT 0"), ("box_unloading", "INTEGER NOT NULL DEFAULT 0"), ("model_mode", "TEXT NOT NULL DEFAULT 'A'"), + ("plate", "TEXT NOT NULL DEFAULT ''"), + ("do_numbers", "TEXT NOT NULL DEFAULT '[]'"), + ("expected_sack", "INTEGER NOT NULL DEFAULT 0"), + ("expected_box", "INTEGER NOT NULL DEFAULT 0"), + ("net_sack", "INTEGER NOT NULL DEFAULT 0"), + ("net_box", "INTEGER NOT NULL DEFAULT 0"), ): if _col not in _cols: cur.execute(f"ALTER TABLE batches ADD COLUMN {_col} {_typ}") + # Delivery orders (DO-gated manual batch). + cur.execute( + """ + CREATE TABLE IF NOT EXISTS delivery_orders ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + counting_date TEXT NOT NULL, + photo_path TEXT NOT NULL, + no_do TEXT NOT NULL DEFAULT '', + plate TEXT NOT NULL DEFAULT '', + expected_sack INTEGER NOT NULL DEFAULT 0, + expected_box INTEGER NOT NULL DEFAULT 0, + ocr_raw TEXT, + status TEXT NOT NULL DEFAULT 'draft', + batch_id INTEGER, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """ + ) + cur.execute( + "CREATE INDEX IF NOT EXISTS idx_do_date_status " + "ON delivery_orders(counting_date, status)" + ) conn.commit() conn.close() @@ -155,7 +205,7 @@ def get_db(): def _batches_box_select(): - """Box/model select exprs — real columns when migrated, literals otherwise.""" + """Box/model/DO select exprs — real columns when migrated, literals otherwise.""" try: conn = get_db() cur = conn.cursor() @@ -165,8 +215,23 @@ def _batches_box_select(): except Exception: cols = set() if {"box_loading", "box_unloading", "model_mode"} <= cols: - return "box_loading, box_unloading, model_mode" - return "0 AS box_loading, 0 AS box_unloading, 'A' AS model_mode" + expr = "box_loading, box_unloading, model_mode" + else: + expr = "0 AS box_loading, 0 AS box_unloading, 'A' AS model_mode" + do_cols = { + "plate": "'' AS plate", + "do_numbers": "'[]' AS do_numbers", + "expected_sack": "0 AS expected_sack", + "expected_box": "0 AS expected_box", + "net_sack": "0 AS net_sack", + "net_box": "0 AS net_box", + } + for name, lit in do_cols.items(): + if name in cols: + expr += f", {name}" + else: + expr += f", {lit}" + return expr def get_counting_date(dt=None, cutoff_str=CUTOFF_TIME): @@ -195,6 +260,59 @@ def is_office_request(): return True return False + +def _batch_active() -> bool: + try: + if not os.path.exists(CURRENT_BATCH_PATH): + return False + with open(CURRENT_BATCH_PATH, "r") as f: + curr = json.load(f) + return bool(curr and curr.get("batch_number")) + except Exception: + return False + + +def _read_do_settings() -> dict: + """Effective DO settings: config.yaml seed + do_settings.json overrides.""" + data = empty_do_settings({ + "require_plate": CFG.do.require_plate, + "require_do": CFG.do.require_do, + "ocr_engine": CFG.do.ocr.engine, + }) + data["editable"] = is_office_request() + if os.path.exists(DO_SETTINGS_PATH): + try: + with open(DO_SETTINGS_PATH, "r", encoding="utf-8") as f: + stored = json.load(f) + if "require_plate" in stored: + data["require_plate"] = bool(stored["require_plate"]) + if "require_do" in stored: + data["require_do"] = bool(stored["require_do"]) + if "ocr_engine" in stored and is_valid_ocr_engine(stored["ocr_engine"]): + data["ocr_engine"] = str(stored["ocr_engine"]).lower() + except Exception: + pass + return data + + +def _write_do_settings(data: dict) -> None: + os.makedirs(os.path.dirname(DO_SETTINGS_PATH) or ".", exist_ok=True) + payload = { + "require_plate": bool(data.get("require_plate", False)), + "require_do": bool(data.get("require_do", True)), + "ocr_engine": str(data.get("ocr_engine", "tesseract")).lower(), + "updated_at": datetime.now().isoformat(), + } + tmp = DO_SETTINGS_PATH + ".tmp" + with open(tmp, "w", encoding="utf-8") as f: + json.dump(payload, f, indent=2) + os.replace(tmp, DO_SETTINGS_PATH) + + +def _effective_ocr_engine() -> str: + return _read_do_settings().get("ocr_engine", CFG.do.ocr.engine) + + @app.route("/") @app.route("/monitoring") def index(): @@ -246,22 +364,111 @@ def get_next_batch_number(counting_date): @app.route("/api/batch/start", methods=["POST"]) def api_batch_start(): try: - # Check if already active - if os.path.exists(CURRENT_BATCH_PATH): + mode = normalize_batch_mode( + _read_batch_mode_file()["mode"], CFG.batch.default_mode) + if mode == "auto": + return jsonify({ + "success": False, + "reason": "wrong_mode", + "error": "Mode otomatis — batch dibuka/ditutup oleh AI. " + "Alihkan mode dari monitoring.", + }), 409 + + if _batch_active(): try: with open(CURRENT_BATCH_PATH, "r") as f: curr = json.load(f) - if curr and curr.get("batch_number"): - return jsonify({ - "success": True, - "message": "Batch already active", - "batch_number": curr.get("batch_number"), - "counting_date": curr.get("counting_date"), - "start_time": curr.get("start_time") - }) + return jsonify({ + "success": True, + "message": "Batch already active", + "batch_number": curr.get("batch_number"), + "counting_date": curr.get("counting_date"), + "start_time": curr.get("start_time"), + }) except Exception: pass + settings = _read_do_settings() + plate = "" + do_ids: list = [] + do_numbers: list = [] + expected_sack = 0 + expected_box = 0 + + if mode == "do_manual": + if not CFG.do.enabled: + return jsonify({ + "success": False, "reason": "do_disabled", + "error": "DO flow disabled (do.enabled=false)", + }), 400 + req_data = request.get_json(silent=True) or request.form or {} + try: + raw_ids = req_data.get("do_ids", []) + if isinstance(raw_ids, str): + raw_ids = [x.strip() for x in raw_ids.split(",") if x.strip()] + do_ids = [int(x) for x in raw_ids] + except (TypeError, ValueError): + return jsonify({ + "success": False, "reason": "bad_do_ids", + "error": "do_ids must be list of integers", + }), 400 + if not do_ids: + return jsonify({ + "success": False, "reason": "missing_do", + "error": "Pilih minimal satu DO sebelum mulai batch.", + }), 400 + conn = get_db() + cur = conn.cursor() + placeholders = ",".join("?" * len(do_ids)) + cur.execute( + f"SELECT id, no_do, plate, expected_sack, expected_box, status " + f"FROM delivery_orders WHERE id IN ({placeholders})", + do_ids, + ) + rows = cur.fetchall() + conn.close() + found = {r["id"]: r for r in rows} + missing = [i for i in do_ids if i not in found] + if missing: + return jsonify({ + "success": False, "reason": "missing_do", + "error": f"DO id tidak ditemukan: {missing}", + }), 400 + dos = [dict(found[i]) for i in do_ids] + gate = start_gate_errors( + mode, dos, + do_enabled=CFG.do.enabled, + require_do=settings.get("require_do", CFG.do.require_do), + require_plate=settings.get("require_plate", CFG.do.require_plate), + batch_active=_batch_active(), + ) + # batch_active already handled; strip if present from early return + gate = [g for g in gate if g != "batch_active"] + if gate: + msg_map = { + "wrong_mode": "Mode batch bukan do_manual.", + "missing_do": "Butuh minimal satu DO.", + "missing_no_do": "Semua DO wajib punya No. DO.", + "missing_plate": "Semua DO wajib punya plat nomor.", + "mixed_plates": "Plat nomor DO berbeda — satu batch = satu plat.", + "do_disabled": "Alur DO dinonaktifkan.", + } + return jsonify({ + "success": False, + "reason": gate[0], + "errors": gate, + "error": msg_map.get(gate[0], "; ".join(gate)), + }), 400 + try: + plate, dos = group_dos_by_plate(dos) + except ValueError as ve: + return jsonify({ + "success": False, "reason": "mixed_plates", "error": str(ve), + }), 400 + do_numbers = [str(d.get("no_do") or "") for d in dos] + expected_sack = sum(int(d.get("expected_sack") or 0) for d in dos) + expected_box = sum(int(d.get("expected_box") or 0) for d in dos) + counting_date = get_counting_date() batch_num = get_next_batch_number(counting_date) now_iso = datetime.now().isoformat() @@ -272,10 +479,17 @@ def api_batch_start(): "count": 0, "box_count": 0, "box_unloading": 0, + "unloading": 0, "model_mode": _read_batch_mode_file()["model_mode"], "start_time": now_iso, "last_detection_time": now_iso, - "manual_control": True + "manual_control": True, + "batch_mode": mode, + "plate": plate, + "do_ids": do_ids, + "do_numbers": do_numbers, + "expected_sack": expected_sack, + "expected_box": expected_box, } os.makedirs(os.path.dirname(CURRENT_BATCH_PATH), exist_ok=True) @@ -284,20 +498,82 @@ def api_batch_start(): json.dump(batch_state, f, indent=2) os.replace(tmp_file, CURRENT_BATCH_PATH) + if do_ids: + try: + conn = get_db() + cur = conn.cursor() + for i in do_ids: + cur.execute( + "UPDATE delivery_orders SET status='attached', " + "updated_at=CURRENT_TIMESTAMP WHERE id=?", + (i,), + ) + conn.commit() + conn.close() + except Exception: + pass + return jsonify({ "success": True, "message": f"Batch #{batch_num} started", "batch_number": batch_num, "counting_date": counting_date, - "start_time": now_iso + "start_time": now_iso, + "plate": plate, + "do_numbers": do_numbers, + "expected_sack": expected_sack, + "expected_box": expected_box, }) except Exception as e: return jsonify({"success": False, "error": str(e)}), 500 +@app.route("/api/batch/stop-preview") +def api_batch_stop_preview(): + """Zone activity hint for stop soft-warn (do_manual / manual).""" + mode = normalize_batch_mode(_read_batch_mode_file()["mode"], CFG.batch.default_mode) + active = _batch_active() + sacks_in_zone = 0 + boxes_in_zone = 0 + last_det = None + zone_busy = False + try: + if os.path.exists(CURRENT_BATCH_PATH): + with open(CURRENT_BATCH_PATH, "r") as f: + data = json.load(f) + last_det = data.get("last_detection_time") + sacks_in_zone = int(data.get("count") or 0) + boxes_in_zone = int(data.get("box_count") or 0) + if active and last_det and sacks_in_zone > 0: + try: + last = datetime.fromisoformat(last_det) + age = (datetime.now() - last).total_seconds() + zone_busy = age <= float(CFG.do.zone_warn_seconds) + except Exception: + zone_busy = False + except Exception: + pass + return jsonify({ + "success": True, + "mode": mode, + "active": active, + "zone_busy": zone_busy, + "sacks_in_zone": sacks_in_zone, + "boxes_in_zone": boxes_in_zone, + "zone_warn_seconds": CFG.do.zone_warn_seconds, + }) + + @app.route("/api/batch/stop", methods=["POST"]) def api_batch_stop(): try: + mode = normalize_batch_mode( + _read_batch_mode_file()["mode"], CFG.batch.default_mode) + if mode == "auto": + return jsonify({ + "success": False, "reason": "wrong_mode", + "error": "Mode otomatis — stop manual tidak berlaku.", + }), 409 if not os.path.exists(CURRENT_BATCH_PATH): return jsonify({"success": False, "error": "No active batch to stop"}), 400 @@ -312,11 +588,21 @@ def api_batch_stop(): final_count = curr.get("count", 0) box_final = curr.get("box_count", 0) box_unloading = curr.get("box_unloading", 0) + unloading = curr.get("unloading", 0) model_mode = curr.get("model_mode", _read_batch_mode_file()["model_mode"]) start_time_iso = curr.get("start_time", datetime.now().isoformat()) end_time_iso = datetime.now().isoformat() + plate = curr.get("plate", "") or "" + do_ids = curr.get("do_ids") or [] + do_numbers = curr.get("do_numbers") or [] + expected_sack = int(curr.get("expected_sack", 0) or 0) + expected_box = int(curr.get("expected_box", 0) or 0) - if final_count > 0: + net_sack, net_box = net_counts( + final_count, unloading, box_final, box_unloading) + discarded = should_discard_batch(net_sack, net_box) + + if not discarded: conn = get_db() cur = conn.cursor() cur.execute( @@ -325,20 +611,41 @@ def api_batch_stop(): (counting_date, batch_number, camera_name, object_label, count, start_time, end_time) VALUES (?, ?, ?, ?, ?, ?, ?) """, - (counting_date, batch_num, CAMERA_NAME, OBJECT_LABEL, final_count, start_time_iso, end_time_iso), + (counting_date, batch_num, CAMERA_NAME, OBJECT_LABEL, + final_count, start_time_iso, end_time_iso), ) try: cur.execute( """ - UPDATE batches SET box_loading = ?, box_unloading = ?, model_mode = ? + UPDATE batches SET box_loading = ?, box_unloading = ?, model_mode = ?, + plate = ?, do_numbers = ?, expected_sack = ?, expected_box = ?, + net_sack = ?, net_box = ? WHERE counting_date = ? AND batch_number = ? AND camera_name = ? AND object_label = ? """, (box_final, box_unloading, model_mode, + plate, json.dumps(do_numbers, ensure_ascii=False), + expected_sack, expected_box, net_sack, net_box, counting_date, batch_num, CAMERA_NAME, OBJECT_LABEL), ) except Exception: pass + if do_ids: + try: + cur.execute( + "SELECT id FROM batches WHERE counting_date=? AND batch_number=? " + "AND camera_name=? AND object_label=?", + (counting_date, batch_num, CAMERA_NAME, OBJECT_LABEL)) + brow = cur.fetchone() + if brow: + for do_id in do_ids: + cur.execute( + "UPDATE delivery_orders SET batch_id=?, status='attached', " + "updated_at=CURRENT_TIMESTAMP WHERE id=?", + (brow[0], do_id)) + except Exception: + pass + cur.execute( """ SELECT SUM(count), COUNT(id) @@ -361,8 +668,21 @@ def api_batch_stop(): ) conn.commit() conn.close() + else: + try: + conn = get_db() + cur = conn.cursor() + for do_id in do_ids: + cur.execute( + "UPDATE delivery_orders SET status='staged', batch_id=NULL, " + "updated_at=CURRENT_TIMESTAMP WHERE id=?", + (do_id,), + ) + conn.commit() + conn.close() + except Exception: + pass - # Remove active batch state try: os.remove(CURRENT_BATCH_PATH) except Exception: @@ -370,10 +690,14 @@ def api_batch_stop(): return jsonify({ "success": True, - "message": f"Batch #{batch_num} stopped", + "message": f"Batch #{batch_num} stopped" + ( + " (discard: net 0)" if discarded else ""), "batch_number": batch_num, "final_count": final_count, "box_final_count": box_final, + "net_sack": net_sack, + "net_box": net_box, + "discarded": discarded, "start_time": start_time_iso, "end_time": end_time_iso }) @@ -399,12 +723,13 @@ def _active_model_mode() -> str: def _read_batch_mode_file(): - data = {"mode": "manual", "model_mode": _active_model_mode()} + default_mode = normalize_batch_mode(CFG.batch.default_mode, "auto") + data = {"mode": default_mode, "model_mode": _active_model_mode()} if os.path.exists(BATCH_MODE_PATH): try: with open(BATCH_MODE_PATH, "r") as f: stored = json.load(f) - data["mode"] = stored.get("mode", "manual") + data["mode"] = normalize_batch_mode(stored.get("mode"), default_mode) # Legacy model_mode in batch_mode.json is IGNORED (config.yaml wins). legacy = (stored.get("model_mode") or "").upper() if legacy and legacy != data["model_mode"]: @@ -419,14 +744,31 @@ def _read_batch_mode_file(): def api_batch_mode(): if request.method == "POST": try: - req_data = request.get_json(silent=True) or request.form + req_data = request.get_json(silent=True) or request.form or {} + # Office-only writes for mode / model_mode (operator port → 403). + has_mode = "mode" in req_data + has_model = "model_mode" in req_data + if (has_mode or has_model) and not is_office_request(): + return jsonify({ + "success": False, + "error": "Mode/model_mode hanya bisa diubah dari port kantor", + }), 403 stored = _read_batch_mode_file() - if "mode" in req_data: - mode = str(req_data.get("mode", "manual")).lower() - if mode not in ["auto", "manual"]: - return jsonify({"success": False, "error": "Invalid mode. Use 'auto' or 'manual'"}), 400 + if has_mode: + mode = str(req_data.get("mode", "")).lower() + if not is_valid_batch_mode(mode): + return jsonify({ + "success": False, + "error": "Invalid mode. Use 'auto', 'do_manual' or 'manual'", + }), 400 + if _batch_active(): + return jsonify({ + "success": False, + "reason": "batch_active", + "error": "Batch sedang berjalan — hentikan dulu sebelum ganti mode.", + }), 409 stored["mode"] = mode - if "model_mode" in req_data: + if has_model: mmode = str(req_data.get("model_mode", "")).upper() try: # Validates against config.yaml models.modes, then persists @@ -441,7 +783,7 @@ def api_batch_mode(): stored["model_mode"] = mmode stored["updated_at"] = datetime.now().isoformat() - # batch_mode.json keeps ONLY the manual/auto batch mode now. + # batch_mode.json keeps ONLY the batch flow mode now. batch_state = {"mode": stored["mode"], "updated_at": stored["updated_at"]} os.makedirs(os.path.dirname(BATCH_MODE_PATH) or ".", exist_ok=True) tmp_path = BATCH_MODE_PATH + ".tmp" @@ -449,8 +791,11 @@ def api_batch_mode(): json.dump(batch_state, f, indent=2) os.replace(tmp_path, BATCH_MODE_PATH) + office = is_office_request() return jsonify({"success": True, "mode": stored["mode"], "model_mode": stored["model_mode"], + "mode_editable": office, + "model_mode_editable": office, "message": f"Batch mode={stored['mode']}, model_mode={stored['model_mode']} " f"(model_mode applies on next karung-counter restart)"}) except Exception as e: @@ -458,21 +803,424 @@ def api_batch_mode(): # GET method stored = _read_batch_mode_file() + office = is_office_request() return jsonify({"success": True, "mode": stored["mode"], - "model_mode": stored["model_mode"]}) + "model_mode": stored["model_mode"], + "mode_editable": office, + "model_mode_editable": office}) @app.route("/api/model-modes") def api_model_modes(): stored = _read_batch_mode_file() + office = is_office_request() return jsonify({ "success": True, "active": stored["model_mode"], + "editable": office, "modes": [{"id": m, "description": d} for m, d in MODEL_MODE_DESCRIPTIONS.items()], }) +@app.route("/api/do/settings", methods=["GET", "POST"]) +def api_do_settings(): + if request.method == "POST": + try: + req_data = request.get_json(silent=True) or request.form or {} + current = _read_do_settings() + office = is_office_request() + wrote = False + # ocr_engine: both ports allowed + if "ocr_engine" in req_data: + eng = str(req_data.get("ocr_engine", "")).lower() + if not is_valid_ocr_engine(eng): + return jsonify({ + "success": False, + "error": f"Invalid ocr_engine. Use one of {list(('tesseract','paddle','none'))}", + }), 400 + current["ocr_engine"] = eng + wrote = True + # require_plate / require_do: office only + plate_keys = ("require_plate", "require_do") + if any(k in req_data for k in plate_keys): + if not office: + return jsonify({ + "success": False, + "error": "require_plate/require_do hanya dari port kantor", + }), 403 + if "require_plate" in req_data: + current["require_plate"] = bool( + str(req_data.get("require_plate")).lower() in + ("1", "true", "yes", "on")) + if "require_do" in req_data: + current["require_do"] = bool( + str(req_data.get("require_do")).lower() in + ("1", "true", "yes", "on")) + wrote = True + if not wrote: + return jsonify({"success": False, "error": "No known settings fields"}), 400 + _write_do_settings(current) + current = _read_do_settings() + current["editable"] = office + return jsonify({"success": True, **current}) + except Exception as e: + return jsonify({"success": False, "error": str(e)}), 500 + + data = _read_do_settings() + return jsonify({"success": True, **data}) + + +def _downscale_image(src_path: str, max_edge: int = 2048) -> str: + """Downscale large phone photos before store/OCR. Returns path to use.""" + try: + from PIL import Image # type: ignore + except ImportError: + try: + import cv2 # type: ignore + except ImportError: + return src_path + img = cv2.imread(src_path) + if img is None: + return src_path + h, w = img.shape[:2] + edge = max(h, w) + if edge <= max_edge: + return src_path + scale = max_edge / float(edge) + img = cv2.resize(img, (int(w * scale), int(h * scale)), interpolation=cv2.INTER_AREA) + cv2.imwrite(src_path, img, [int(cv2.IMWRITE_JPEG_QUALITY), 88]) + return src_path + try: + img = Image.open(src_path) + edge = max(img.size) + if edge <= max_edge: + return src_path + scale = max_edge / float(edge) + img = img.resize((int(img.width * scale), int(img.height * scale)), Image.LANCZOS) + if img.mode not in ("RGB", "L"): + img = img.convert("RGB") + img.save(src_path, "JPEG", quality=88) + return src_path + except Exception: + return src_path + + +@app.route("/api/do/upload", methods=["POST"]) +def api_do_upload(): + """Smartphone multi-photo upload. Only when mode == do_manual.""" + try: + mode = normalize_batch_mode( + _read_batch_mode_file()["mode"], CFG.batch.default_mode) + if mode != "do_manual": + return jsonify({ + "success": False, "reason": "wrong_mode", + "error": "Upload DO hanya di mode do_manual.", + }), 409 + files = request.files.getlist("photos") + files = [f for f in (files or []) if f and f.filename] + if not files: + return jsonify({"success": False, "error": "No photos uploaded"}), 400 + if len(files) > int(CFG.do.max_photos_per_batch): + return jsonify({ + "success": False, + "error": f"Maksimal {CFG.do.max_photos_per_batch} foto per batch", + }), 400 + allowed = {".jpg", ".jpeg", ".png", ".webp"} + counting_date = get_counting_date() + day_dir = os.path.join(DO_PHOTO_ROOT, counting_date) + os.makedirs(day_dir, exist_ok=True) + engine = _effective_ocr_engine() + try: + from src.do_ocr import extract_do_fields + except Exception: + extract_do_fields = None + items = [] + conn = get_db() + cur = conn.cursor() + for f in files: + ext = os.path.splitext(f.filename)[1].lower() or ".jpg" + if ext not in allowed: + continue + import uuid + fname = f"do_{uuid.uuid4().hex}{ext}" + rel = os.path.join(CFG.do.photo_dir, counting_date, fname) + abs_path = os.path.join(day_dir, fname) + f.save(abs_path) + _downscale_image(abs_path, max_edge=2048) + ocr = { + "no_do": "", "plate": "", "expected_sack": 0, "expected_box": 0, + "ocr_text": "", "engine": engine, "ocr_ok": False, "ocr_error": "skipped", + } + if extract_do_fields is not None and engine != "none": + try: + ocr = extract_do_fields(abs_path, engine) + except Exception as e: + ocr["ocr_error"] = str(e) + elif engine == "none": + ocr["ocr_error"] = "disabled" + ocr_raw = json.dumps(ocr, ensure_ascii=False) + cur.execute( + """ + INSERT INTO delivery_orders + (counting_date, photo_path, no_do, plate, expected_sack, expected_box, ocr_raw, status) + VALUES (?, ?, ?, ?, ?, ?, ?, 'draft') + """, + (counting_date, rel, + ocr.get("no_do", "") or "", + ocr.get("plate", "") or "", + int(ocr.get("expected_sack") or 0), + int(ocr.get("expected_box") or 0), + ocr_raw), + ) + do_id = cur.lastrowid + items.append({ + "id": do_id, + "photo_url": f"/api/do/photo/{do_id}", + "no_do": ocr.get("no_do", "") or "", + "plate": ocr.get("plate", "") or "", + "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 "", + "ocr_ok": bool(ocr.get("ocr_ok")), + "ocr_error": ocr.get("ocr_error", "") or "", + "status": "draft", + }) + conn.commit() + conn.close() + if not items: + return jsonify({"success": False, "error": "No valid image files"}), 400 + return jsonify({"success": True, "items": items, "ocr_engine": engine}) + except Exception as e: + return jsonify({"success": False, "error": str(e)}), 500 + + +@app.route("/api/do/photo/") +def api_do_photo(do_id): + try: + conn = get_db() + cur = conn.cursor() + cur.execute("SELECT photo_path FROM delivery_orders WHERE id=?", (do_id,)) + row = cur.fetchone() + conn.close() + if not row or not row["photo_path"]: + return jsonify({"success": False, "error": "Not found"}), 404 + rel = row["photo_path"] + # resolve under output.dir; reject traversal + base = os.path.abspath(CFG.output.dir if os.name != "nt" else _DEFAULT_DIR) + full = os.path.abspath(os.path.join(base, rel)) + if not full.startswith(base + os.sep) and full != base: + return jsonify({"success": False, "error": "Bad path"}), 400 + if not os.path.isfile(full): + # try photo root relative + full2 = os.path.abspath(os.path.join(DO_PHOTO_ROOT, os.path.basename(os.path.dirname(rel)), + os.path.basename(rel))) + if os.path.isfile(full2) and full2.startswith(os.path.abspath(DO_PHOTO_ROOT)): + full = full2 + else: + return jsonify({"success": False, "error": "File missing"}), 404 + with open(full, "rb") as fh: + data = fh.read() + return Response(data, mimetype="image/jpeg") + except Exception as e: + return jsonify({"success": False, "error": str(e)}), 500 + + +@app.route("/api/do/staged") +def api_do_staged(): + try: + date = get_counting_date() + conn = get_db() + cur = conn.cursor() + cur.execute( + """ + SELECT id, counting_date, photo_path, no_do, plate, expected_sack, + expected_box, ocr_raw, status, batch_id + FROM delivery_orders + WHERE counting_date = ? AND status IN ('draft', 'staged') + ORDER BY id ASC + """, + (date,), + ) + rows = [] + for r in cur.fetchall(): + ocr = {} + try: + ocr = json.loads(r["ocr_raw"] or "{}") + except Exception: + pass + rows.append({ + "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", ""), + "expected_sack": r["expected_sack"], + "expected_box": r["expected_box"], + "status": r["status"], + "ocr_ok": bool(ocr.get("ocr_ok")), + "ocr_error": ocr.get("ocr_error", ""), + "ocr_text": ocr.get("ocr_text", ""), + }) + conn.close() + return jsonify({"success": True, "items": rows}) + except Exception as e: + return jsonify({"success": False, "error": str(e)}), 500 + + +@app.route("/api/do/", methods=["PUT", "DELETE"]) +def api_do_item(do_id): + try: + conn = get_db() + cur = conn.cursor() + if request.method == "DELETE": + cur.execute("SELECT photo_path, status FROM delivery_orders WHERE id=?", (do_id,)) + row = cur.fetchone() + if not row: + conn.close() + return jsonify({"success": False, "error": "Not found"}), 404 + if row["status"] == "attached" and _batch_active(): + conn.close() + return jsonify({ + "success": False, "error": "DO terpasang pada batch aktif", + }), 409 + base = os.path.abspath(CFG.output.dir if os.name != "nt" else _DEFAULT_DIR) + full = os.path.abspath(os.path.join(base, row["photo_path"] or "")) + if full.startswith(base) and os.path.isfile(full): + try: + os.remove(full) + except Exception: + pass + cur.execute("DELETE FROM delivery_orders WHERE id=?", (do_id,)) + conn.commit() + conn.close() + return jsonify({"success": True, "id": do_id}) + + req = request.get_json(silent=True) or {} + no_do = str(req.get("no_do", "")).strip() + plate = str(req.get("plate", "")).strip().upper() + try: + expected_sack = int(req.get("expected_sack", 0) or 0) + expected_box = int(req.get("expected_box", 0) or 0) + except (TypeError, ValueError): + conn.close() + return jsonify({"success": False, "error": "expected_* must be int"}), 400 + cur.execute( + """ + UPDATE delivery_orders SET + no_do=?, plate=?, expected_sack=?, expected_box=?, + status='staged', updated_at=CURRENT_TIMESTAMP + WHERE id=? + """, + (no_do, plate, expected_sack, expected_box, do_id), + ) + if cur.rowcount == 0: + conn.close() + return jsonify({"success": False, "error": "Not found"}), 404 + conn.commit() + conn.close() + return jsonify({ + "success": True, "id": do_id, "no_do": no_do, "plate": plate, + "expected_sack": expected_sack, "expected_box": expected_box, + "status": "staged", + }) + except Exception as e: + return jsonify({"success": False, "error": str(e)}), 500 + + +def _run_do_retention() -> dict: + """Purge DO rows + photos older than do.retention_days.""" + cutoff = retention_cutoff_date(CFG.do.retention_days) + removed_rows = 0 + removed_dirs = 0 + try: + conn = get_db() + cur = conn.cursor() + cur.execute( + "SELECT id, photo_path FROM delivery_orders WHERE counting_date < ? " + "AND status != 'attached'", + (cutoff,), + ) + for r in cur.fetchall(): + base = os.path.abspath(CFG.output.dir if os.name != "nt" else _DEFAULT_DIR) + if r["photo_path"]: + full = os.path.abspath(os.path.join(base, r["photo_path"])) + if full.startswith(base) and os.path.isfile(full): + try: + os.remove(full) + except Exception: + pass + cur.execute("UPDATE delivery_orders SET photo_path='' WHERE id=?", (r["id"],)) + removed_rows += 1 + # clear photo_path on attached old rows after file delete attempt + cur.execute( + "SELECT id, photo_path FROM delivery_orders WHERE counting_date < ? " + "AND status = 'attached' AND photo_path != ''", + (cutoff,), + ) + for r in cur.fetchall(): + base = os.path.abspath(CFG.output.dir if os.name != "nt" else _DEFAULT_DIR) + full = os.path.abspath(os.path.join(base, r["photo_path"])) + if full.startswith(base) and os.path.isfile(full): + try: + os.remove(full) + except Exception: + pass + cur.execute("UPDATE delivery_orders SET photo_path='' WHERE id=?", (r["id"],)) + # delete fully purged old draft/staged leftovers + cur.execute( + "DELETE FROM delivery_orders WHERE counting_date < ? " + "AND status IN ('draft', 'staged')", + (cutoff,), + ) + removed_rows += cur.rowcount if cur.rowcount and cur.rowcount > 0 else 0 + conn.commit() + conn.close() + except Exception as e: + print(f"[DO Retention] row purge error: {e}") + try: + if os.path.isdir(DO_PHOTO_ROOT): + for name in os.listdir(DO_PHOTO_ROOT): + if should_purge_counting_date(name, cutoff): + p = os.path.join(DO_PHOTO_ROOT, name) + if os.path.isdir(p): + import shutil + shutil.rmtree(p, ignore_errors=True) + removed_dirs += 1 + except Exception as e: + print(f"[DO Retention] photo dir error: {e}") + return {"cutoff": cutoff, "rows": removed_rows, "dirs": removed_dirs} + + +@app.route("/api/do/retention", methods=["POST"]) +def api_do_retention(): + if not is_office_request(): + return jsonify({"success": False, "error": "Office only"}), 403 + try: + return jsonify({"success": True, **_run_do_retention()}) + except Exception as e: + return jsonify({"success": False, "error": str(e)}), 500 + + +def _start_do_retention_timer(): + import threading + + def _loop(): + while True: + try: + res = _run_do_retention() + if res.get("rows") or res.get("dirs"): + print(f"[DO Retention] purged rows={res['rows']} dirs={res['dirs']}") + except Exception as e: + print(f"[DO Retention] {e}") + threading.Event().wait(3600.0) + + try: + _run_do_retention() + except Exception: + pass + threading.Thread(target=_loop, daemon=True).start() + + @app.route("/api/current-batch") def api_current_batch(): fps_val = 0.0 @@ -485,40 +1233,59 @@ def api_current_batch(): except Exception: pass - try: - with open(CURRENT_BATCH_PATH, "r") as f: - data = json.load(f) - stored = _read_batch_mode_file() - return jsonify( - { - "success": True, - "counting_date": data.get("counting_date"), - "batch_number": data.get("batch_number"), - "count": data.get("count", 0), - "box_count": data.get("box_count", 0), - "box_unloading": data.get("box_unloading", 0), - "model_mode": data.get("model_mode", stored["model_mode"]), - "mode": stored["mode"], - "start_time": data.get("start_time"), - "last_detection_time": data.get("last_detection_time"), - "fps": fps_val - } - ) - except FileNotFoundError: - stored = _read_batch_mode_file() - return jsonify( - { - "success": False, - "error": "No active batch", - "count": 0, - "box_count": 0, - "model_mode": stored["model_mode"], - "mode": stored["mode"], - "batch_number": None, - "counting_date": None, - "fps": fps_val - } - ), 200 + try: + with open(CURRENT_BATCH_PATH, "r") as f: + data = json.load(f) + stored = _read_batch_mode_file() + loading = int(data.get("count", 0) or 0) + unloading = int(data.get("unloading", 0) or 0) + box_loading = int(data.get("box_count", 0) or 0) + box_unloading = int(data.get("box_unloading", 0) or 0) + net_sack, net_box = net_counts(loading, unloading, box_loading, box_unloading) + return jsonify( + { + "success": True, + "counting_date": data.get("counting_date"), + "batch_number": data.get("batch_number"), + "count": data.get("count", 0), + "box_count": data.get("box_count", 0), + "box_unloading": data.get("box_unloading", 0), + "unloading": data.get("unloading", 0), + "net_sack": net_sack, + "net_box": net_box, + "plate": data.get("plate", "") or "", + "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), + "model_mode": data.get("model_mode", stored["model_mode"]), + "mode": stored["mode"], + "batch_mode": data.get("batch_mode", stored["mode"]), + "start_time": data.get("start_time"), + "last_detection_time": data.get("last_detection_time"), + "fps": fps_val + } + ) + except FileNotFoundError: + stored = _read_batch_mode_file() + return jsonify( + { + "success": False, + "error": "No active batch", + "count": 0, + "box_count": 0, + "net_sack": 0, + "net_box": 0, + "plate": "", + "do_numbers": [], + "expected_sack": 0, + "expected_box": 0, + "model_mode": stored["model_mode"], + "mode": stored["mode"], + "batch_number": None, + "counting_date": None, + "fps": fps_val + } + ), 200 except Exception as e: return jsonify( { @@ -551,6 +1318,10 @@ def api_previous_batch(): row = cur.fetchone() conn.close() if row: + try: + do_numbers = json.loads(row["do_numbers"] or "[]") + except Exception: + do_numbers = [] return jsonify( { "success": True, @@ -559,6 +1330,12 @@ 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 "", + "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, + "net_sack": row["net_sack"] if "net_sack" in row.keys() else row["count"], + "net_box": row["net_box"] if "net_box" in row.keys() else row["box_loading"], "start_time": row["start_time"], "end_time": row["end_time"], "duration_minutes": row["duration_minutes"], @@ -706,12 +1483,22 @@ def api_day_detail(date): duration = row["duration_minutes"] or 0 total_duration += duration total_boxes += row["box_loading"] or 0 + try: + do_numbers = json.loads(row["do_numbers"] or "[]") + except Exception: + do_numbers = [] batches.append( { "batch_number": row["batch_number"], "count": row["count"], "box_loading": row["box_loading"], "model_mode": row["model_mode"], + "plate": row["plate"] if "plate" in row.keys() else "", + "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, + "net_sack": row["net_sack"] if "net_sack" in row.keys() else row["count"], + "net_box": row["net_box"] if "net_box" in row.keys() else row["box_loading"], "start_time": row["start_time"], "end_time": row["end_time"], "duration_minutes": duration, @@ -895,9 +1682,10 @@ def export_day_xlsx(date): conn = get_db() cur = conn.cursor() cur.execute( - """ + f""" SELECT batch_number, count, start_time, end_time, - ROUND((julianday(end_time) - julianday(start_time)) * 24 * 60, 1) as duration_minutes + ROUND((julianday(end_time) - julianday(start_time)) * 24 * 60, 1) as duration_minutes, + {_batches_box_select()} FROM batches WHERE counting_date = ? ORDER BY batch_number ASC @@ -914,14 +1702,30 @@ def export_day_xlsx(date): wb = Workbook() ws = wb.active ws.title = f"Day {date}" - _style_header(ws, [("A", "Batch Number"), ("B", "Count"), ("C", "Start Time"), ("D", "End Time"), ("E", "Duration (min)")]) + _style_header(ws, [ + ("A", "Batch Number"), ("B", "Count"), ("C", "Box"), ("D", "Net Sack"), + ("E", "Net Box"), ("F", "Plate"), ("G", "DO"), ("H", "Expected Sack"), + ("I", "Expected Box"), ("J", "Start Time"), ("K", "End Time"), + ("L", "Duration (min)"), + ]) for r_idx, row in enumerate(rows, 2): + try: + do_numbers = json.loads(row["do_numbers"] or "[]") + except Exception: + do_numbers = [] 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["start_time"]) - ws.cell(row=r_idx, column=4, value=row["end_time"]) - ws.cell(row=r_idx, column=5, value=row["duration_minutes"] or 0) + ws.cell(row=r_idx, column=3, value=row["box_loading"]) + ws.cell(row=r_idx, column=4, value=row["net_sack"] if "net_sack" in row.keys() else row["count"]) + ws.cell(row=r_idx, column=5, value=row["net_box"] if "net_box" in row.keys() else row["box_loading"]) + ws.cell(row=r_idx, column=6, value=row["plate"] if "plate" in row.keys() else "") + ws.cell(row=r_idx, column=7, value=", ".join(do_numbers)) + ws.cell(row=r_idx, column=8, value=row["expected_sack"] if "expected_sack" in row.keys() else 0) + ws.cell(row=r_idx, column=9, value=row["expected_box"] if "expected_box" in row.keys() else 0) + ws.cell(row=r_idx, column=10, value=row["start_time"]) + ws.cell(row=r_idx, column=11, value=row["end_time"]) + ws.cell(row=r_idx, column=12, value=row["duration_minutes"] or 0) _auto_width(ws) filename = f"{SITE_NAME}_day_detail_{date}.xlsx" @@ -934,12 +1738,18 @@ if __name__ == "__main__": WSGIRequestHandler.protocol_version = "HTTP/1.1" + try: + _start_do_retention_timer() + except Exception as e: + print(f"[DO Retention] start failed: {e}") + print("=" * 60) print(f"[*] JETSON BATCH CONTROL & DASHBOARD SERVER STARTED") print(f"[*] Port Operator (Panel Tombol): http://{DASHBOARD_HOST}:{DASHBOARD_PORT}") print(f"[*] Port Kantor (Monitoring AI): http://{DASHBOARD_HOST}:{OFFICE_PORT}") print(f"[*] DB Path: {DB_PATH}") print(f"[*] State : {CURRENT_BATCH_PATH}") + print(f"[*] DO photo: {DO_PHOTO_ROOT} (retention {CFG.do.retention_days}d)") print("=" * 60) # Server 1: Operator Port (default 5000) diff --git a/docs/architecture.md b/docs/architecture.md index dcc651d..0ab83d8 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -105,9 +105,13 @@ stats panel (Loading / Unloading / Net / last-3-batch history), and a bottom sta ### Config & logging - `src/config_loader.py` — canonical loader: `config.yaml` (stream, models, - counting, batch, output, camera) + `.env` (secrets/deployment only) + + counting, batch, **do**, output, camera) + `.env` (secrets/deployment only) + `zones.json` polygons + `cfg/tracker.yaml` reference. Model modes are data (`models.modes`); missing file falls back to `.env` + legacy defaults. +- `src/do_batch.py` — pure helpers for DO-gated batches (mode validation, plate + grouping, start gates, net/discard both-nets rule, unit classify, retention). +- `src/do_ocr.py` — `extract_do_fields(image, engine)` for `tesseract`|`paddle`|`none` + (dashboard upload only; never in the predict loop). - `src/config.py` — **deprecated** frozen v3 `Config` (different key names); do not add keys here. - `src/logger.py` — `CSVLogger` appends `batch_summary.csv` @@ -126,6 +130,25 @@ repo `src/` modules (imports at `predict.py`). Zero CLI flags = systemd behaviou are dead code — persistence goes through `finalize_batch`/`save_active_batch_state`, dashboard integration is file-based.) +### Batch modes (three-way) + +`batch_mode.json` `mode` (default seed `batch.default_mode: auto`): + +| Mode | Open/close | Notes | +|---|---|---| +| `auto` | existing truck FSM | no DO APIs; start/stop manual → 409 | +| `do_manual` | operator + staged DO photos | DO gates in dashboard start only | +| `manual` | operator buttons (legacy) | no DO gate | + +`predict.py` reads mode every frame: `auto` → FSM branch; any other valid value +→ operator/state-file branch (persists `count`, `box_count`, `box_unloading`, +**`unloading`**). Finalize discard = **both nets 0** +(`net_sack = loading − unloading`, `net_box = box_loading − box_unloading`). + +DO pipeline (dashboard): smartphone upload → OCR draft → edit/stage → start +gates → active batch → stop-preview soft-warn → stop → `batches` + DO columns. +ERD: [`docs/do-erd.md`](do-erd.md). Plan: [`docs/do-batch-implementation-plan.md`](do-batch-implementation-plan.md). + ## Retired (`archive/`, not imported) - **`predict_new.py`** — duo-model state machine (`WAITING_FOR_TRUCK → COUNTING_SACKS → diff --git a/docs/configuration.md b/docs/configuration.md index daef3c6..26a9df0 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -23,9 +23,31 @@ endpoint lists them automatically. Mode switch: dashboard `POST /api/batch/mode {"model_mode": "X"}` validates against `config.yaml` and persists atomically (tmp+replace, comments preserved) to `models.active_mode`. **Manual `karung-counter` restart still required** -(models load once at startup). `batch_mode.json` keeps only the manual/auto -batch `mode`; its legacy `model_mode` key is ignored (warned). `MODEL_MODE` -env var still overrides for one run but is deprecated (warned). +(models load once at startup). `batch_mode.json` keeps only the batch flow +`mode` (`auto` | `do_manual` | `manual`); its legacy `model_mode` key is +ignored (warned). `MODEL_MODE` env var still overrides for one run but is +deprecated (warned). + +**Batch flow modes** (`batch.default_mode` in YAML; runtime `batch_mode.json`): + +| Mode | Who opens/closes | DO gate | +|---|---|---| +| `auto` (default) | AI truck FSM | n/a | +| `do_manual` | Operator start/stop + staged DO photos | yes | +| `manual` | Operator start/stop (legacy) | 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**. +`GET /api/batch/mode` works on both ports (`mode_editable` / `model_mode_editable`). + +**DO block** (`do:` in `config.yaml`): `enabled`, `require_do`, `require_plate` +(default seed), `retention_days: 7`, `photo_dir: do_photos`, +`max_photos_per_batch`, `zone_warn_seconds`, `ocr.engine` (YAML seed only). + +**Runtime DO settings** (`$OUTPUT_DIR/do_settings.json`): +`require_plate` / `require_do` — office-only write; **`ocr_engine`** +(`tesseract`|`paddle`|`none`) — **both ports write**. Sync via +`GET/POST /api/do/settings`. Engine flip applies to the next upload without restart. Template: `.env.example`. Production values live in `.env` (git-ignored). Missing `config.yaml` falls back to `.env` + built-in defaults with a warning @@ -39,7 +61,9 @@ when set. | `OUTPUT_DIR` | `/opt/jetson-counter` | Legacy override of `output.dir` when set | | `DB_PATH` | `$OUTPUT_DIR/jetson_counter.db` | Legacy override of the SQLite path when set | | `STATE_FILE` | `$OUTPUT_DIR/current_batch.json` | Legacy override of the live batch state path when set | -| `BATCH_MODE_FILE` | `$OUTPUT_DIR/batch_mode.json` | Legacy override (file keeps only manual/auto batch mode now) | +| `BATCH_MODE_FILE` | `$OUTPUT_DIR/batch_mode.json` | Legacy override (file keeps only batch flow mode: auto/do_manual/manual) | +| `DO_SETTINGS_PATH` | `$OUTPUT_DIR/do_settings.json` | Runtime DO settings (require_plate/do, ocr_engine) | +| `DO_PHOTO_ROOT` | `$OUTPUT_DIR/do_photos` | DO photo tree root | | `LIVE_STREAM_FRAME_PATH` | `/dev/shm/jetson-counter/live_frame.jpg` | Legacy override of the annotated frame path when set | | `CAMERA_NAME` | `CC1` | Camera tag stored per batch | | `OBJECT_LABEL` | `karung-pakan` | Object tag stored per batch | diff --git a/docs/deployment.md b/docs/deployment.md index 0a3a982..5819af8 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -35,9 +35,12 @@ loads `.engine` only — see `models/modelREADME.md` for which weights each mode ## Runtime data files - SQLite `jetson_counter.db`: `batches(counting_date, batch_number, camera_name, - object_label, count, start/end_time)`, `daily_summaries(...)`. -- `current_batch.json` (crash recovery), `batch_mode.json` (manual/auto batch - mode only — the model mode lives in `config.yaml`), + object_label, count, start/end_time, box_*, plate, do_numbers, expected_*, + net_sack, net_box)`, `daily_summaries(...)`, `delivery_orders(...)` (DO photos). +- `current_batch.json` (crash recovery), `batch_mode.json` (batch flow mode only: + `auto`|`do_manual`|`manual` — model mode lives in `config.yaml`), + `do_settings.json` (require_plate/do + ocr_engine), + `$OUTPUT_DIR/do_photos/YYYY-MM-DD/` (7-day retention), `batch_history_folder/batch_.json` + `hasil_perhitungan.json` (per-batch reports). - Live frame: `/dev/shm/jetson-counter/live_frame.jpg` (written every 2nd frame, consumed by `/api/live-video` MJPEG). @@ -45,11 +48,37 @@ loads `.engine` only — see `models/modelREADME.md` for which weights each mode (`backup.py`, `dump_db.py`, `migrate_jetson_db.py`, `merge_batches_*.py`, `update_batches.py`, `diagnose_truck_jetson.py`). +## DO OCR packages (optional Paddle) +Primary path is system Tesseract (CPU): + +```bash +sudo apt install tesseract-ocr tesseract-ocr-ind +pip install pytesseract +``` + +Backup engine (`ocr_engine: paddle` via office/operator UI toggle — no restart): + +```bash +# optional; heavier — see paddleocr docs for Jetson wheels +pip install paddleocr +``` + +Missing paddle deps → upload returns explicit error; flip engine back to +`tesseract` from either dashboard. Operator page: `http://:5000/operator` +(smartphone camera capture for DO photos). Photo dir under `output.dir` with +7-day retention (hourly purge in dashboard process). + ## Dashboard (`counter_dashboard.py`) -Pages: `/` + `/monitoring`, `/operator` (manual start/stop, mode switch), -`/history`, `/analytics`. Key APIs: `/api/live-video`, `/api/current-batch`, -`/api/previous-batch`, `/api/batch/{start,stop,mode}`, `/api/model-modes` +Pages: `/` + `/monitoring`, `/operator` (manual start/stop; DO panel in +`do_manual`), `/history`, `/analytics`. Key APIs: `/api/live-video`, +`/api/current-batch`, `/api/previous-batch`, +`/api/batch/{start,stop,stop-preview,mode}`, `/api/model-modes` (mode list is derived from `config.yaml`, so future modes appear automatically), -`/api/summary`, `/api/daily-data`, `/api/day-detail/`, -`/api/recent-batches`, `/api/available-dates`, `/api/export-daily-csv`, -`/api/export-day-csv/` (CSV + Excel via openpyxl). +`/api/do/{upload,photo/,staged,settings,retention}` + +`PUT/DELETE /api/do/`, `/api/summary`, `/api/daily-data`, +`/api/day-detail/`, `/api/recent-batches`, `/api/available-dates`, +`/api/export-daily-csv`, `/api/export-day-csv/` (Excel via openpyxl). + +Port split: mode / model_mode / require_plate / require_do POSTs → **office +5721 only** (403 on 5000). `ocr_engine` POST allowed on both ports (synced via +settings GET). Smartphones open `http://:5000/operator` for camera capture. diff --git a/docs/do-batch-implementation-plan.md b/docs/do-batch-implementation-plan.md new file mode 100644 index 0000000..5f22403 --- /dev/null +++ b/docs/do-batch-implementation-plan.md @@ -0,0 +1,684 @@ +# Implementation Plan — DO-Gated Manual Batch Counting + +> Status: **implemented** (Phase 1–5 code + docs + tests; manual device checks + Jetson deploy pending). +> Scope: **karung feedmill counter only**. +> pfm-ocr (`git.proit.id/andrew/pfm-ocr`) is **inspiration only** — no API/DB coupling. + +## 1. Goal + +**Default runtime mode: auto batching** (existing truck/sack FSM — unchanged). +**Office** (port 5721) switches mode to DO-gated manual or legacy manual; operator +(port 5000) only **views** current mode and acts within it. + +DO-gated manual workflow (when selected) — **photos taken with a smartphone** +via the operator dashboard page (browser `capture="environment"` on the phone): + +``` +smartphone photo DO(s) → review OCR → upload/stage → start batch → count +→ truck leaves → operator ends batch (soft warn + force) +``` + +- One truck may carry **multiple DOs**. +- DOs with the **same vehicle plate** belong to **one batch**. +- Same plate may return **more than once per day** — each visit is a new batch + delimited by operator start/stop, not by plate uniqueness. +- Truck detect / ROI / overlay stay **live as today** in all modes. +- Auto mode: Jetson owns truck presence / batch open-close (existing behaviour). + +## 2. Locked decisions + +| # | Topic | Decision | +|---|---|---| +| 1 | Batch discard | Discard only if **both** net karung **and** net kardus are 0 (`net_sack == 0 and net_box == 0`). Keep the row if either side is non-zero. | +| 2 | Expected vs counted | **Net** = `loading − unloading` for sacks and for boxes | +| 3 | OCR | **Light CPU OCR first** (Tesseract); **PaddleOCR-level backup** same `extract_do_fields()` dispatch. **Runtime engine** (`tesseract`\|`paddle`\|`none`) in `do_settings.json` — **toggleable from office (5721) and operator (5000)**; both UIs read/write the same store and stay in sync via `GET /api/do/settings` poll. YAML `do.ocr.engine` = seed only | +| 4 | Photo retention | **7 days** (`do.retention_days`), purge photos + draft/staged DO rows | +| 5 | `require_plate` toggle | **Office port only** (`is_office_request()`, default 5721) | +| 6 | Capture UI | Operator dashboard (`templates/operator.html`, port 5000). **Capture device: smartphone** browser camera (`accept="image/*" capture="environment" multi`); no native app, no desktop webcam required | +| 7 | Grouping | Auto-bucket staged DOs by plate; start **blocks** mixed non-empty plates | +| 8 | Plate gate | Runtime toggle: OFF = plate optional; ON = all attached DOs need plate | +| 9 | DO required to start | `do.require_do: true` (locked OK) — start needs ≥1 staged DO in **do_manual** | +| 10 | Storage | `jetson_counter.db` + local photo files under output dir | +| 11 | **Batch modes** | **3 modes**: **`auto` (default)** · `do_manual` (DO-gated flow) · `manual` (legacy start/stop, no DO gate — dev/fallback). **Mode switch POST: office port only**; GET mode: both ports | +| 11b | **Model mode (A–D)** | **Office only** (`is_office_request()`). **Remove** model-mode selector from `operator.html`; only `monitoring.html` (office) can POST `model_mode` | +| 12 | End-batch guard | Soft warn if activity in counting zone → **force** still allowed (do_manual stop; auto unchanged) | + +### 2.1 Batch mode matrix + +| Mode | Who opens/closes batch | DO gate | Notes | +|---|---|---|---| +| **`auto` (default)** | Existing FSM in `predict.py` / `src/batch.py` | n/a | **No behaviour change** vs today. Operator start/stop buttons hidden or inert. DO panel hidden. | +| **`do_manual`** | Operator start/stop only | Yes (`do.require_do`, plate rules) | New flow in this plan. Truck FSM ignored for open/close; ROI/truck detect still live. | +| **`manual`** | Operator start/stop only | No (legacy) | Existing manual buttons + `current_batch.json`; kept for dev experiments. | + +- Persist mode in **`batch_mode.json`** key `mode` (extend allowed values from + `auto|manual` → `auto|do_manual|manual`). +- **Default when file missing / invalid: `auto`.** +- `GET /api/batch/mode` — both ports (operator UI polls for badge / DO panel). +- `POST /api/batch/mode {"mode": ...}` — **office only** (`is_office_request()`); + operator POST → **403** (blocks `switchToManual()` on port 5000 as-is). +- Allowed values `auto|do_manual|manual`; unknown → 400. +- Mode switch while a batch is active: **409** until batch stopped — + avoids mixing auto-finalize with DO columns mid-batch. +- **`model_mode` (A–D)** on same endpoint: **office only** as well (locked #11b). + Operator must not POST either `mode` or `model_mode`. Office monitoring page + owns both switches; operator UI reads active model mode for badge only. +- `config.yaml` seed: `batch.default_mode: auto` (file-less first boot); runtime + still `batch_mode.json`. + +## 3. Non-goals + +- No pfm-ocr network auth, Postgres, or Flutter dependency. +- No change to YOLO modes A–D, counting line geometry, or batch FSM logic in `src/batch.py`. +- No auto open/close of batches from truck detector while mode is `do_manual` / `manual`. +- No multi-tenant / store accounts. +- No change to default production behaviour beyond **default mode string** + (`auto` remains the out-of-box runtime mode). + +--- + +## 4. Architecture + +Mode selects path; only **`do_manual`** uses the DO pipeline below: + +``` +batch_mode.json mode + ├─ auto (default) ──► predict.py existing FSM (no DO APIs required) + ├─ manual ──────────► operator start/stop, no DO gate (legacy) + └─ do_manual ───────► DO pipeline: + +operator.html (5000) + │ multi-photo capture (panel visible only when mode == do_manual) + ▼ +POST /api/do/upload ──► save JPEG under {output.dir}/do_photos/YYYY-MM-DD/ + │ OCR extract (tesseract|paddle) → draft row + ▼ +review/edit fields ──► PUT /api/do/ (no_do, plate, expected_sack, expected_box) + │ + ▼ +POST /api/batch/start {do_ids} ── validates gates ──► current_batch.json + │ + delivery_orders.status=attached + ▼ +predict.py manual branch (mode != auto) ── updates count/box_count in state file + │ + ▼ +GET /api/batch/stop-preview ── zone busy? ──► modal warn +POST /api/batch/stop ── finalize ──► batches + plate/do_numbers/expected_* + └── net_sack==0 AND net_box==0 → discard (no row) +``` + +### Expected qty (net) + +- Per DO line items (after OCR or manual entry), classify unit as sack-like + vs box-like (matched case-insensitive on the qty/unit token): + + | Class | Terms (add more if DOs use other spellings) | + |---|---| + | sack-like | `KRG`, `KARUNG`, `SACK`, `BG` | + | box-like | `BOX`, `CTN`, `KARTON`, **`DUS`**, **`KARDUS`** | + +- Store `expected_sack`, `expected_box` as **integer outer units**. +- Live variance on operator panel: + - sacks: `(loading_count − unloading_count)` vs `expected_sack` + - boxes: `(box_loading − box_unloading)` vs `expected_box` +- Same net formula written into history/export. + +### Discard rule (stop) + +- Compute `net_sack` and `net_box` at stop (see §7.3). +- **Discard** (no `batches` insert) only when `net_sack == 0` **and** `net_box == 0`. +- If either net is non-zero → **insert** the batch row (DO columns included), even + when the other class is zero (e.g. boxes only, no sacks). + +--- + +## 5. Data model + +### 5.1 New table `delivery_orders` + +```sql +CREATE TABLE IF NOT EXISTS delivery_orders ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + counting_date TEXT NOT NULL, + photo_path TEXT NOT NULL, -- relative to output.dir + no_do TEXT NOT NULL DEFAULT '', + plate TEXT NOT NULL DEFAULT '', + expected_sack INTEGER NOT NULL DEFAULT 0, + expected_box INTEGER NOT NULL DEFAULT 0, + ocr_raw TEXT, -- JSON: engine, raw text, confidence + status TEXT NOT NULL DEFAULT 'draft', -- draft|staged|attached|discarded + batch_id INTEGER, -- set when attached (informational) + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS idx_do_date_status + ON delivery_orders(counting_date, status); +``` + +`counting_date` = same helper as batches (`get_counting_date()`), so photos +taken after cutoff belong to the operational day. + +### 5.2 `batches` additive columns + +```sql +-- same PRAGMA table_info pattern as box_loading/model_mode migration +plate TEXT NOT NULL DEFAULT '', +do_numbers TEXT NOT NULL DEFAULT '[]', -- JSON array of No. DO +expected_sack INTEGER NOT NULL DEFAULT 0, +expected_box INTEGER NOT NULL DEFAULT 0, +net_sack INTEGER NOT NULL DEFAULT 0, -- loading - unloading at stop +net_box INTEGER NOT NULL DEFAULT 0 +``` + +Discard stop only when both nets are 0 → no row. Otherwise expected/net +columns are written with the batch. + +### 5.3 `current_batch.json` extra keys + +```json +{ + "manual_control": true, + "plate": "B 1234 XYZ", + "do_ids": [12, 13], + "do_numbers": ["DO-001", "DO-002"], + "expected_sack": 120, + "expected_box": 10 +} +``` + +`predict.py` already round-trips the whole dict; **no structural change** unless +finalize moves into predict (it does not — stop stays in dashboard). + +### 5.4 Photo tree + +``` +{output.dir}/do_photos/ + 2026-09-24/ + do_.jpg +``` + +Source: **smartphone** camera upload (see §9.1); server downscales before write. +Gitignored via existing `*.jpg` rule. Retention deletes files + rows ≤ now−7d. + +ERD (Mermaid) ships in **Phase 4** → `docs/do-erd.md`. + +--- + +## 6. Configuration + +### 6.1 `config.yaml` + +```yaml +# do = Delivery Order (surat jalan). Gated manual-batch scan flow. +# Used when batch mode == do_manual (mode itself lives in batch_mode.json). +batch: + default_mode: "auto" # auto | do_manual | manual (seed when batch_mode.json missing) + +do: + enabled: true + require_do: true # start needs ≥1 staged DO (do_manual mode) + require_plate: false # default; office UI can flip at runtime + retention_days: 7 + photo_dir: "do_photos" # relative to output.dir + max_photos_per_batch: 8 + zone_warn_seconds: 3 # stop soft-warn: recent count activity + ocr: + engine: "tesseract" # tesseract | paddle | none — YAML seed only + # Runtime override: do_settings.json ocr_engine (UI toggle, both ports). + # paddle = PaddleOCR-level backup (same extract_do_fields dispatch); + # use when tesseract field accuracy fails acceptance. +``` + +Note: existing `batch:` key already holds `timeout_seconds` / `daily_cutoff_time` — +**add** `default_mode` under it; do not duplicate the `batch:` key. + +### 6.2 Runtime settings (not config.yaml) + +| Store | Contents | Writer | +|---|---|---| +| `{output.dir}/batch_mode.json` | `mode`: `auto` \| `do_manual` \| `manual` (**default `auto`**) | **`POST /api/batch/mode` office only** | +| `{output.dir}/do_settings.json` | `require_plate`, `require_do` (office-only write); **`ocr_engine`** (`tesseract`\|`paddle`\|`none`, **both ports write**) | See §7 settings auth | + +- Existing `_read_batch_mode_file()` defaults change: missing file → + `mode = CFG.batch.default_mode` or `"auto"` (not `"manual"`). +- Invalid/legacy values: map unknown → `auto` + warn; keep ignoring legacy + `model_mode` key as today. +- Mode switch API: `mode not in ["auto", "do_manual", "manual"]` → 400; + `not is_office_request()` on POST when `mode` in body → **403**. +- POST with **`mode` and/or `model_mode`**: **office only** (403 on operator port + when either write field is present). GET unchanged on both ports. + +Auth on settings writes: + +- `POST /api/do/settings` **`ocr_engine`**: **both ports allowed** (operator and + office); same file → both UIs stay in sync on next `GET`. +- `POST /api/do/settings` **`require_plate` / `require_do`**: **403** if not + `is_office_request()`. +- `POST /api/batch/mode` with `mode` (and/or `model_mode`): **403** if not + `is_office_request()`. +- Unknown `ocr_engine` value → 400; missing deps for `paddle` still explicit + error on next upload (§8), not on settings write. + +### 6.3 `src/config_loader.py` + +Add `DoConfig` + `DoOcrConfig` dataclasses; parse `do:` block; defaults if key absent +(missing-file behaviour unchanged). + +--- + +## 7. Backend API (`counter_dashboard.py`) + +| Method | Path | Auth / port | Behaviour | +|---|---|---|---| +| `GET` | `/api/batch/mode` | both | Effective mode (`auto` \| `do_manual` \| `manual`) + `mode_editable` | +| `POST` | `/api/batch/mode` | **office only** when body has `mode` and/or `model_mode` | Persist; **403** if operator sends either; **409 if batch active** (for `mode`); 400 if unknown | +| `POST` | `/api/do/upload` | operator 5000 | **Only when mode == `do_manual`** (else 409). `multipart/form-data` field `photos` (1..N); validate type/size; save under dated dir; run OCR engine; insert `draft` rows; return `{items:[{id, photo_url, no_do, plate, expected_sack, expected_box, ocr_text}]}` | +| `GET` | `/api/do/photo/` | operator | Serve photo bytes (path from row; no traversal) | +| `GET` | `/api/do/staged` | operator | Rows `status IN (draft,staged)` for `get_counting_date()` | +| `PUT` | `/api/do/` | operator | Edit fields; set `status=staged` when saved | +| `DELETE` | `/api/do/` | operator | Remove row + photo (or mark discarded) | +| `GET` | `/api/do/settings` | both | Effective flags + **`ocr_engine`** (poll both UIs) | +| `POST` | `/api/do/settings` | **split**: `ocr_engine` → **both ports**; `require_plate`/`require_do` → **office only** (403) | Persist; 400 unknown engine value | +| `POST` | `/api/batch/start` | operator | **Mode-dependent:** `do_manual` → body `{do_ids}` + DO gates; `manual` → legacy no-DO start (existing); `auto` → **409** (FSM owns lifecycle) | +| `GET` | `/api/batch/stop-preview` | operator | `{active, zone_busy, sacks_in_zone, boxes_in_zone}` — used for **do_manual** (and optional **manual**) stop UI | +| `POST` | `/api/batch/stop` | operator | **`do_manual`/`manual` only** (auto: 409). Finalize + DO columns when present; discard only if **both** `net_sack==0` and `net_box==0` | +| `GET` | `/api/current-batch` | both | Extend with plate, do_numbers, expected_*, net so far, `mode` | + +### 7.1 Start gates (`mode == do_manual` + `do.enabled`) + +Reject `400` with machine-readable `reason` + Indonesian `message`: + +1. Batch already active (existing). +2. Mode is not `do_manual` (e.g. `auto` / `manual`) → 409 `wrong_mode`. +3. `require_do` and zero DOs selected/staged. +4. Any selected DO missing `no_do`. +5. If `require_plate`: any selected DO missing `plate`. +6. If ≥2 DOs with **non-empty** plates and plates **disagree** → block (list plates). +7. Empty plates when `require_do` and not `require_plate`: allowed; group shows “tanpa plat”. + +On success: + +- Build `batch_state` as today + `"batch_mode": "do_manual"` + DO fields + (union plate if all equal, else `""`). +- Mark selected DOs `status=attached`; keep `batch_id = NULL` until stop; store + `do_ids` only in state file. +- Atomic write `current_batch.json` (existing tmp+replace). + +**Legacy `manual` mode:** existing start/stop without DO gates (when +`mode == manual`). Start/stop-preview soft-warn optional same as do_manual. + +**`auto` mode:** start/stop manual APIs return 409; FSM only. + +### 7.2 Stop soft-warn + +`stop-preview` sources (lazy OR): + +- `live_status.json` / recent `current_batch` counts changing (fallback: sacks_in_zone unknown → no warn). +- Optional: dashboard reads last annotated frame only for display — **not required**. +- If `sack_count > 0` and `last_detection_time` within `do.zone_warn_seconds` (default 3s) → `zone_busy: true`. + +UI: modal “Masih ada aktivitas di zona (N karung) — akhir paksa?” → second confirm → same `POST /api/batch/stop` with `force=true` (force only skips client-side block; server always allows stop). + +### 7.3 Finalize SQL (stop) + discard + +```sql +-- only when NOT (net_sack == 0 AND net_box == 0): +UPDATE batches SET + plate=?, do_numbers=?, expected_sack=?, expected_box=?, + net_sack=?, net_box=?, + box_loading=?, box_unloading=?, model_mode=? +WHERE counting_date=? AND batch_number=? AND camera_name=? AND object_label=?; +``` + +- `net_sack = count − unloading`, `net_box = box_count − box_unloading` + (values from `current_batch.json` at stop). +- **Discard path (dashboard stop, modes `do_manual`/`manual`):** if + `net_sack == 0` and `net_box == 0` → no `batches` write. Either non-zero → row. +- **`predict.py` `finalize_batch` (auto path) + dashboard stop:** + project counts **sacks and boxes** — discard only when **both** nets are 0 + (`net_sack == 0 and net_box == 0`). Today `finalize_batch` discards on + `final_count == 0` alone (sack only) → **must change** so box-only batches + (auto and do_manual) are kept. Rows always can store sack count **and** + box_loading/box_unloading columns. +- Need sack `unloading` on state file: **add** it in operator-driven branch + alongside `count` (`box_unloading` already written); for auto finalize, pass + net values the same way when updating columns. + +--- + +## 8. OCR design + +Single entry: `extract_do_fields(image_path, engine) -> dict` in `src/do_ocr.py`. +Engine = effective `ocr_engine` from `do_settings.json` (fallback YAML + `do.ocr.engine` / `tesseract`) — **tesseract | paddle | none**. Upload reads + engine once per request so a UI flip applies to the next photo without restart. + Settings `ocr_engine` wins over YAML when present. + +### Primary: Tesseract, CPU + +- Dependency: system `tesseract-ocr` + data (`eng` minimum; `ind` if available), + Python `pytesseract` in `requirements.txt`. +- Preprocess: OpenCV grayscale → Otsu → optional deskew (minAreaRect on text mask). +- Extract with regexes (inspired by pfm-ocr patterns, simplified): + - DO number: `No.\s*DO\s*[:#]?\s*([A-Z0-9\-/]+)` and loose 10-digit fallback. + - Plate: Indonesian plate pattern + uppercase normalize. + - Table lines: token scan for unit words (sack-like / box-like table in §4) + + adjacent integers → sums. +- Response is **draft only** — operator review is mandatory UI step. +- If `tesseract` binary missing (`engine: tesseract`) or `engine: none` → empty + draft, UI banner “OCR tidak aktif — isi manual”; upload still works. + Missing paddle deps with `engine: paddle` → explicit error, no silent fallback. +- Inference off GPU; **must not** run inside `predict.py` loop — dashboard-side + only, on upload request. + +### Backup: PaddleOCR-level (`engine: paddle`) + +- **Available as a first-class runtime value**, not a deferred redesign: + `ocr_engine: paddle` (settings file or YAML seed) selects the same + `extract_do_fields()` path with a PaddleOCR (PP-DocLayout / + PaddleOCR-VL-class) implementation behind it. +- Purpose: higher field accuracy when Tesseract fails acceptance + (≤30 s operator correction budget) or DO print quality is poor. +- Deploy note: heavier install (Python wheels / optional GPU); document in + `docs/deployment.md` as **optional package**, default image stays Tesseract-only. +- Swap = **UI toggle** (office monitoring **and** operator DO panel) writing + `do_settings.json` `ocr_engine` — both ports, same file, next `GET` syncs the + other page. **No process restart; no API contract change.** +- Missing paddle deps with selected engine → explicit error on upload, no + silent fallback; settings write still 200 (so UI can show engine, upload + banner explains missing package). +- Phase 2 ships the dispatch + Tesseract + both-port `ocr_engine` toggle; + Paddle backend module lands when binary/deps validated on Jetson (stub + raises clear error if engine selected but package missing). + +Acceptance for keeping Tesseract: operator completes a real DO in **≤30s** +including corrections. If not → flip engine to paddle from either UI. + +--- + +## 9. UI + +### 9.0 Mode display + switch (split by port) + +| Surface | Mode control | +|---|---| +| **`monitoring.html` (office 5721)** | **3-way switcher** (Otomatis / DO Manual / Manual legacy) → `POST /api/batch/mode {mode}`; handles 403/409 toasts. Primary place to enter `do_manual`. Default UI state **`auto`** (today’s `currentBatchMode = 'manual'` JS default must change). | +| **`operator.html` (5000)** | **Read-only badge** current mode. `GET` only. | +| Operator auto→manual banner | **Remove** `switchToManual()` POST (would 403). Replace with info: “Mode diatur dari monitoring/kantor” + dismiss. | +| Operator start/stop | Per mode: `auto` hidden/disabled; `manual` legacy buttons; `do_manual` DO panel + buttons. | +| Operator model A–D select | **Remove** (locked #11b) — office monitoring only. | +| OCR engine toggle | **Both** monitoring + operator: `POST /api/do/settings {ocr_engine}` allowed both ports; sync via settings poll. | + +- `GET /api/batch/mode` → `{mode, model_mode, mode_editable, model_mode_editable}` + (`*_editable=true` only on office). +- **Operator model-mode selector removed** (locked #11b): no `#modelModeSelect` + / `setModelMode()` POST from `operator.html`; show read-only active model mode + if desired (optional badge from same GET / `/api/model-modes`). +- Office monitoring owns **batch mode + model mode** switches; **both** pages own `ocr_engine` (§9.1.8). +- Switch blocked while batch active (409 → toast) for batch `mode`. + +### 9.1 DO panel (only when operator sees `mode === 'do_manual'`) + +1. **Ambil Foto DO (smartphone)** — ``. + - Primary device: **operator’s phone** opening `http://…:5000/operator` (same LAN + or reachable host). HTML5 camera capture; multiple selects supported where the + mobile browser allows, else sequential single captures into the same strip. + - Landscape preferred for table rows; UI accepts portrait (preview crop hint only). + - Photo size: downscale server-side to max edge ~1600–2048 px before save/OCR + (phone cameras are large; keeps 7-day disk + upload time small). + - Works on mobile Safari/Chrome via the web page — **not** a Flutter/native client. +2. **Thumbnail strip** + per-DO review card: + - No. DO, Plat, Ekspektasi karung, Ekspektasi box + - Save / Delete +3. **Grup preview** — bucket by plate; chips “B 1234 · 2 DO · 80 karung · 10 box”; + warnings: mixed plates, missing plate, missing DO#. +4. **Start modal** — table of DOs to attach + expected totals; disable confirm with reason list. +5. **Active batch card** — plate, DO numbers, live: + - Karung: `net / expected_sack` + - Box: `net_box / expected_box` +6. **Stop modal** — if `stop-preview.zone_busy`: extra force button styling. +7. **Office-only `require_plate`** — show when `GET /api/do/settings` → + `{editable: true}` (office); hide when `editable: false` (403 on POST from 5000). +8. **OCR engine toggle (`ocr_engine`)** — 3-way (Tesseract / Paddle / Off): + - **Both** `operator.html` (DO panel, near capture) **and** + `monitoring.html` (office DO/settings block) get the same control. + - Write: `POST /api/do/settings {"ocr_engine": ...}` — **allowed on both + ports** (unlike `require_plate`). + - Sync: both pages poll `GET /api/do/settings` (existing settings poll or + same DO-panel interval) → flip on one device appears on the other without + reload. No websocket needed. + - Badge/label shows effective engine; upload failure for missing paddle + package shows inline error on whichever page captured the photo. + +History (`history.html`): columns plate, DO list, expected, net, variance when present. + +--- + +## 10. `predict.py` touchpoints (minimal) + +| Location | Change | +|---|---| +| Mode read each frame (`batch_mode.json`) | Default missing/invalid → **`auto`** (was hard-coded `"manual"`). Accept `do_manual` as a third value; treat **any non-`auto`** as “operator-driven” for start/stop file polling (same as today’s manual branch). | +| Manual/do_manual batch active loop (`count` / `box_count`) | Also persist `unloading` (sack) next to `box_unloading` for net-at-stop. Prefer JSON fields. | +| `do_manual` DO gates | **None in predict.py** — gates live in dashboard start API only. | +| Startup load of state file | No change (opaque dict). | +| Auto mode | **No change** to FSM when `mode == auto`, except `finalize_batch` discard aligns to both-net rule (§7.3). | +| Truck detect interval / ROI | **No change** (all modes). | + +If `unloading` already tracked only in memory as `counter.unloading_count`, +mirror it into `active_batch_info` wherever `count` is written (2 places in +operator-driven branch). + +`predict.py` mode check becomes three-way: + +```python +mode = bm_data.get("mode", "auto") # after config default_mode seed +current_batch_mode = mode if mode in ("auto", "do_manual", "manual") else "auto" +# branches: if current_batch_mode == "auto": FSM else: state-file manual/do_manual +``` + +--- + +## 11. Retention + +- On dashboard start + hourly `threading.Timer`/`loop`: + - cutoff = today − `retention_days` (calendar dates under `do_photos/`). + - `DELETE FROM delivery_orders WHERE counting_date < cutoff AND status != 'attached'` + (attached rows keep photo path optional — **also purge photos** for cutoff + dates always; keep DB row for audit with `photo_path` cleared or left dangling → + prefer `UPDATE ... photo_path=''` after file delete). + - `shutil.rmtree` old date folders. +- Log summary counts at `print`/`logging`. +- Config: `do.retention_days: 7`. + +Disk context (this Jetson): root `/` 27G free; photos are small JPEGs — 7 days +negligible. Prefer photos under `/opt/jetson-counter/do_photos` (same volume as DB). + +--- + +## 12. Testing + +CI stays pure-Python (no cv2/tesseract required): + +| Test file | Covers | +|---|---| +| `tests/test_do_batch.py` | start gates: wrong mode, no DO, missing plate when required, mixed plates, plate optional; group-by-plate helper; net formula; discard only when both nets 0 (dashboard stop + `finalize_batch` helper) — **sack and box**; sack/box unit token classify (`dus`/`kardus` → box); retention date cutoff; mode value validation (`auto`/`do_manual`/`manual`, default auto); **mode POST 403 from operator port, 200 from office**; **model_mode POST 403 from operator, 200 from office**; **`ocr_engine` POST 200 from operator *and* office, 400 unknown value**; **`require_plate` POST still 403 from operator** (Flask test client + Host header) | +| `tests/test_config_loader.py` | extend: `do:` block defaults + overrides; `batch.default_mode` | +| Existing counting/batch tests | must stay green (no regression) | + +Manual / device checks (not CI): + +1. Fresh deploy / missing `batch_mode.json` → mode reads **`auto`**; FSM counts; DO panel hidden. +2. **Office** switches to `do_manual` (no active batch) → operator panel shows DO UI after poll. +3. **Operator** `POST /api/batch/mode` → **403**; banner no longer posts (info only). +4. Office switches to `manual` → legacy start/stop, no DO panel. +5. Mode switch while batch active → 409 / blocked in UI. +6. `do_manual`: stage 2 DOs same plate → start → count → stop → row has plate + do_numbers + expected. +7. Different plates → start blocked. +8. Office port toggles `require_plate`; operator port 403. +9. Stop while “zone busy” → force path. +10. Stop with both nets 0 → no batches row; boxes-only (net_box>0, net_sack=0) → row written. +11. Purge: backdate folder → restart → gone. +12. `POST /api/batch/start` in `auto` mode → 409. +13. Operator `POST` `model_mode` from port 5000 → **403**; no selector on operator page; office monitoring can change A–D. +14. **Smartphone** on site Wi‑Fi: open `:5000/operator`, capture ≥2 DO photos, review, stage, start in `do_manual`. +15. After Phase 4–5: `docs/do-erd.md` Mermaid renders; docs/CHANGELOG updated; push to origin visible in `git log`. +16. Toggle `ocr_engine` from operator `:5000` → office monitoring shows same value on next poll (and reverse); `require_plate` still 403 from operator. + +Commands: + +```bash +python -m pytest tests/ -q +python -m compileall predict.py counter_dashboard.py src +``` + +--- + +## 13. Deployment + +- `deploy_to_jetson.py`: already syncs `counter_dashboard.py`, `operator.html`, + `config.yaml` — **also sync** `templates/monitoring.html` (office mode switcher); + no new service. +- Jetson packages (primary): `sudo apt install tesseract-ocr tesseract-ocr-ind` + (document; CI does not install it). +- Optional backup OCR: PaddleOCR stack when UI sets `ocr_engine: paddle` — + separate install notes in `docs/deployment.md`; not required for default deploy. +- Restart: `sudo systemctl restart karung-counter-dashboard` (and counter if + `predict.py` changed). +- `.gitignore`: ensure `do_photos/` under output dir only (already outside repo + when `output.dir=/opt/jetson-counter`). + +--- + +## 14. Phases & file list + +### Phase 1 — usable end-to-end (no OCR accuracy dependency) + +- [x] `src/config_loader.py` — `DoConfig`; `batch.default_mode` default `"auto"` +- [x] `config.yaml` — `do:` block + `batch.default_mode: auto` +- [x] `batch_mode.json` semantics — 3 modes, default **auto**; `POST /api/batch/mode`: + office-only for `mode` **and** `model_mode`, 409-when-active, 400 unknown +- [x] `predict.py` — mode default **auto**; accept `do_manual`; persist `unloading` + in operator-driven state JSON; **`finalize_batch` discard = both nets 0** + (sack **and** box) +- [x] `counter_dashboard.py` — migrations, DO CRUD, start gates (do_manual only), stop-preview, + settings API: office-only `require_plate`/`require_do`, **both-port `ocr_engine`**; + extended start/stop/current-batch, retention job; start/stop 409 in auto; + **403 model_mode POST from operator**; photo accept/resize for **smartphone** uploads +- [x] `templates/monitoring.html` — office 3-way **batch** mode switcher + **model mode A–D** (primary); + **`ocr_engine` toggle** (syncs with operator) +- [x] `templates/operator.html` — read-only mode badge; **remove** `switchToManual` POST + **and** `#modelModeSelect` / `setModelMode()`; DO panel only in do_manual; + **smartphone capture** input + mobile-friendly review strip; + **`ocr_engine` toggle** (same settings poll as office) +- [x] `templates/history.html` + export columns +- [x] `tests/test_do_batch.py` + mode tests +- [ ] Manual check: **real phone** → `:5000/operator` → multi-photo DO flow on Wi‑Fi + +### Phase 2 — OCR assist + +- [x] `src/do_ocr.py` — `extract_do_fields()` + **tesseract** and **paddle** + dispatch (`none` short-circuit); paddle raises clear error if deps missing; + engine from `do_settings.json` → YAML fallback per upload request +- [x] `requirements.txt` — `pytesseract` +- [x] Wire into `/api/do/upload` (read effective `ocr_engine` each request) +- [x] UI: confirm **both** pages’ `ocr_engine` toggle wired to settings POST/GET + (control markup can land Phase 1; behavior verified here) +- [x] Unit tests with fixture image **optional** (skip if no binary) + +### Phase 3 — documentation update + +- [x] `docs/configuration.md` — `do:` / `batch.default_mode`, office-only mode + model_mode, + both-port `ocr_engine` runtime override +- [x] `docs/deployment.md` — tesseract packages, paddle optional, smartphone operator URL, + photo dir + 7-day retention, restart steps +- [x] `docs/architecture.md` — three batch modes; DO pipeline branch vs auto FSM +- [x] `README.md` — short DO manual flow + pointer to plan/ERD +- [x] `AGENTS.md` — mode matrix, office-only POST rules (mode/model_mode/require_*), + both-port `ocr_engine`, discard both-nets +- [x] `CHANGELOG.md` — dated entry (Keep-a-Changelog style) +- [x] `docs/do-batch-implementation-plan.md` — mark phases done / status line when shipping + +### Phase 4 — entity–relationship diagram (Mermaid) + +- [x] Create **`docs/do-erd.md`** (new file) with Mermaid `erDiagram` covering: + - `batches` (+ plate, do_numbers, expected_sack/box, net_sack/box, count, box_*) + - `delivery_orders` (status, photo_path, expected_*, no_do, plate) + - `daily_summaries` + - logical link: `delivery_orders` N—1 batch via `do_numbers` on stop / + state-file `do_ids` while active (document as **soft** FK — no hard FK required) +- [x] Note JSON sidecars: `current_batch.json`, `batch_mode.json`, `do_settings.json`, + photo tree `{output.dir}/do_photos/YYYY-MM-DD/` +- [x] Link ERD from `docs/architecture.md` + plan §5 + +### Phase 5 — commit and push + +- [x] `python -m pytest tests/ -q` green; `python -m compileall` clean +- [ ] Review `git status` / `git diff` — **no** state junk (`.env`, `*.db`, `*.jpg`, + `*.engine`, `batch_mode.json`, photos) — follow `.gitignore` / AGENTS “don’t commit state” +- [ ] Stage only intended files (code, tests, `config.yaml`, templates, docs, plan, ERD) +- [ ] Commit message(s) conventional/repo style, e.g. + `feat: DO-gated manual batch mode (auto default, office-only switches)` + — split docs-only vs feat if cleaner +- [ ] `git pull --rebase` if needed; `git push` to `origin` main + (`https://git.proit.id/andrew/karung-counting-feedmill-semarang`) +- [ ] If deploy follows: `python deploy_to_jetson.py` then systemctl restart + (services listed in §13) — **only** after explicit deploy request + +### Phase 6 — hardening (post-ship) + +- [ ] PaddleOCR backend validated on Jetson (if Tesseract acceptance fails or + field requests it) — flip `ocr_engine: paddle` from either UI +- [ ] Soft-warn polish from field feedback +- [ ] Variance coloring on history/analytics + +**Deferred / not in v1:** DB purge of old `batches` rows, multi-camera, auto +plate from ROI (impossible without ALPR), native mobile app. + +--- + +## 15. Risks & mitigations + +| Risk | Mitigation | +|---|---| +| Tesseract accuracy poor on feedmill DO | Review UI mandatory; flip `ocr_engine: paddle` from office **or** operator UI (shared `do_settings.json`); fields always editable | +| GPU contention | Tesseract path never in `predict.py`; CPU only. Paddle path also dashboard-only | +| Operator skips photos (`require_do` false) | Default `require_do: true` in production YAML (applies only in `do_manual`) | +| Mode left on `do_manual` overnight | Default seed **`auto`**; UI shows current mode badge; office flips back to auto | +| Accidental start/stop in auto | Manual APIs return 409; buttons hidden | +| Operator POSTs mode or model_mode (old UI/scripts) | **403** on operator port; remove `switchToManual()` **and** operator model selector | +| `ocr_engine` flipped wrong / paddle deps missing | Settings write still 200 (UI shows choice); upload returns clear error — no silent fallback | +| Box-only batch dropped by sack-only finalize check | Locked #1/#3: both nets required for discard; counts store sack **and** box | +| Mixed plates forced by mistake | Hard block + clear message; office can temporarily disable plate req | +| Photo disk fill | 7-day purge on `/opt/jetson-counter`; server-side downscale on phone upload | +| Phone camera too large / slow upload | Max-edge resize + JPEG quality cap before store/OCR | +| Both nets 0 drops audit | Accepted decision #1; boxes-only batches **kept**; monitor logs if frequent | +| `unloading` missing at stop | Phase 1 writes it every counter update | + +--- + +## 16. Acceptance criteria + +1. Fresh start with no `batch_mode.json` → mode is **`auto`**; FSM behaves as today. +2. **Office** can switch **auto ↔ do_manual ↔ manual** when no batch is active; + **operator POST mode → 403**; blocked while active (409). +3. In `do_manual`: operator stages ≥1 DO photo, edits fields, starts batch only when gates pass. +4. Two DOs same plate → one batch; both `do_numbers` stored on stop. +5. Two different plates → start rejected. +6. Plate required toggle and model mode A–D change only from office port; + **`ocr_engine` toggle allowed on both ports** and stays in sync on next poll. +7. Live panel shows net sack/box vs expected during batch (`do_manual`). +8. Stop with zone activity asks confirm; force completes. +9. Stop with `net_sack=0` **and** `net_box=0` → no `batches` row; either net + non-zero → row written with DO columns. +10. Photos older than 7 days removed automatically. +11. `manual` (legacy) start/stop works without DO requirements. +12. `auto` mode: manual start/stop APIs 409; DO panel hidden; counting unchanged. +13. Operator **cannot** change model mode A–D (no selector; POST 403); office can. +14. Discard rule same for dashboard stop **and** `predict.py` `finalize_batch` + (auto): keep row if **either** sack net or box net ≠ 0. +15. **Smartphone** can open operator page, capture multi DO photos, review, upload. +16. `docs/do-erd.md` exists with Mermaid ERD; docs/CHANGELOG updated (Phase 3). +17. Changes committed and pushed to origin (Phase 5) after green tests. +18. `python -m pytest tests/ -q` green in CI (no new hard deps). +19. `ocr_engine` POST **200 from operator and office**; other page shows new value on next `GET`; `require_plate` still 403 from operator. diff --git a/docs/do-erd.md b/docs/do-erd.md new file mode 100644 index 0000000..47cde62 --- /dev/null +++ b/docs/do-erd.md @@ -0,0 +1,81 @@ +# ERD — DO-gated manual batch (delivery orders) + +Mermaid ER for karung feedmill counter. Soft FK: no DB foreign keys enforced; +`delivery_orders.batch_id` is informational once a batch row exists. While a +batch is active, membership lives in `current_batch.json` (`do_ids`). + +```mermaid +erDiagram + batches ||--o{ delivery_orders : "soft via do_numbers / batch_id" + batches ||--o{ daily_summaries : "rolls up by counting_date" + + batches { + INTEGER id PK + TEXT counting_date + INTEGER batch_number + TEXT camera_name + TEXT object_label + INTEGER count + TEXT start_time + TEXT end_time + INTEGER box_loading + INTEGER box_unloading + TEXT model_mode + TEXT plate + TEXT do_numbers + INTEGER expected_sack + INTEGER expected_box + INTEGER net_sack + INTEGER net_box + TIMESTAMP created_at + } + + delivery_orders { + INTEGER id PK + TEXT counting_date + TEXT photo_path + TEXT no_do + TEXT plate + INTEGER expected_sack + INTEGER expected_box + TEXT ocr_raw + TEXT status + INTEGER batch_id + TIMESTAMP created_at + TIMESTAMP updated_at + } + + daily_summaries { + INTEGER id PK + TEXT counting_date + TEXT camera_name + TEXT object_label + INTEGER total_count + INTEGER total_batches + TIMESTAMP updated_at + } +``` + +## Relationships (logical) + +| From | To | How | +|---|---|---| +| `delivery_orders` → `batches` | N—1 | On stop: `batches.do_numbers` JSON array of `No. DO`; `delivery_orders.batch_id` set best-effort. Active batch: `current_batch.json.do_ids`. | +| `batches` → `daily_summaries` | N—1 | Same counting_date/camera/label; stop recompute `SUM(count)`. | + +## Status (`delivery_orders.status`) + +`draft` → `staged` (saved/edited) → `attached` (in active/stopped batch) | +`discarded` (deleted). Discarded stop (both nets 0) returns DOs to `staged`. + +## JSON sidecars + photo tree (not in SQLite) + +| Path | Role | +|---|---| +| `$OUTPUT_DIR/batch_mode.json` | `{mode: auto\|do_manual\|manual}` | +| `$OUTPUT_DIR/current_batch.json` | active batch + `do_ids`, plate, expected_*, nets inputs | +| `$OUTPUT_DIR/do_settings.json` | `require_plate`, `require_do`, `ocr_engine` | +| `$OUTPUT_DIR/do_photos/YYYY-MM-DD/do_.jpg` | source photos (7-day retention) | + +Schema DDL: `counter_dashboard.py` `_ensure_db()` (additive PRAGMA migrations +on `batches` + `CREATE TABLE delivery_orders`). diff --git a/predict.py b/predict.py index 415cbc1..e7b3851 100644 --- a/predict.py +++ b/predict.py @@ -221,16 +221,20 @@ def save_active_batch_state(): print(f"[DB Error] Gagal menulis {STATE_FILE}: {e}") def finalize_batch(final_count, start_time_iso, end_time_iso, - box_final_count=0, box_unloading_count=0, model_mode="?"): + box_final_count=0, box_unloading_count=0, model_mode="?", + unloading_count=0): global active_batch_info if active_batch_info is None: return + net_sack = int(final_count) - int(unloading_count or 0) + net_box = int(box_final_count) - int(box_unloading_count or 0) if NO_DB: print(f"[DB Info] --no-db: batch #{active_batch_info.get('batch_number', 0)} " f"({final_count} karung, {box_final_count} box) tidak disimpan.") active_batch_info = None + save_active_batch_state() return - if final_count == 0: + if net_sack == 0 and net_box == 0: print(f"[BATCH] Batch #{active_batch_info.get('batch_number', 0)} bernilai 0 diabaikan (tidak disimpan ke database).") active_batch_info = None save_active_batch_state() @@ -265,20 +269,45 @@ def finalize_batch(final_count, start_time_iso, end_time_iso, """, (counting_date, CAMERA_NAME, OBJECT_LABEL, tot_count, tot_batches)) conn.commit() - # Box columns may not exist on pre-migration DBs — tolerant update. + # Box/DO columns may not exist on pre-migration DBs — tolerant update. try: cur2 = conn.cursor() + plate = active_batch_info.get("plate", "") or "" + do_numbers = json.dumps(active_batch_info.get("do_numbers", []) or [], ensure_ascii=False) + expected_sack = int(active_batch_info.get("expected_sack", 0) or 0) + expected_box = int(active_batch_info.get("expected_box", 0) or 0) cur2.execute(""" - UPDATE batches SET box_loading = ?, box_unloading = ?, model_mode = ? + UPDATE batches SET box_loading = ?, box_unloading = ?, model_mode = ?, + plate = ?, do_numbers = ?, expected_sack = ?, expected_box = ?, + net_sack = ?, net_box = ? WHERE counting_date = ? AND batch_number = ? AND camera_name = ? AND object_label = ? """, (box_final_count, box_unloading_count, model_mode, + plate, do_numbers, expected_sack, expected_box, + net_sack, net_box, counting_date, batch_num, CAMERA_NAME, OBJECT_LABEL)) conn.commit() except Exception: pass + # Attach DO rows when batch_id known (best-effort). + try: + cur.execute( + "SELECT id FROM batches WHERE counting_date=? AND batch_number=? " + "AND camera_name=? AND object_label=?", + (counting_date, batch_num, CAMERA_NAME, OBJECT_LABEL)) + row = cur.fetchone() + if row: + for do_id in (active_batch_info.get("do_ids") or []): + cur.execute( + "UPDATE delivery_orders SET batch_id=?, status='attached', updated_at=CURRENT_TIMESTAMP " + "WHERE id=?", + (row[0], do_id)) + conn.commit() + except Exception: + pass conn.close() print(f"[DB Info] Sesi batch #{batch_num} disimpan ke database SQLite: " - f"{final_count} karung, {box_final_count} box (mode {model_mode}).") + f"{final_count} karung, {box_final_count} box (mode {model_mode}, " + f"net karung={net_sack}, net box={net_box}).") except Exception as e: print(f"[DB Error] Gagal menyimpan batch ke database: {e}") active_batch_info = None @@ -1577,22 +1606,24 @@ def run_prediction(model_path, source_path, # Cek apakah ada truk valid di dalam area deteksi (tepat 1 truk yang 100% di dalam area) truck_in_area = len(raw_tracked_trucks) > 0 - # Cek Mode Batch: 'auto' vs 'manual' (default: 'manual') - current_batch_mode = "manual" + # Cek Mode Batch: auto | do_manual | manual (default: auto) + current_batch_mode = "auto" if os.path.exists(BATCH_MODE_FILE): try: with open(BATCH_MODE_FILE, "r") as bmf: bm_data = json.load(bmf) - current_batch_mode = bm_data.get("mode", "manual") + raw_mode = bm_data.get("mode", "auto") + current_batch_mode = raw_mode if raw_mode in ( + "auto", "do_manual", "manual") else "auto" except Exception: pass roi = static_roi # ================================================================ - # MODE MANUAL (Controlled by Operator Button / Dashboard API) + # MODE OPERATOR (manual / do_manual: controlled by dashboard API) # ================================================================ - if current_batch_mode == "manual": + if current_batch_mode != "auto": manual_batch_exists = False if os.path.exists(STATE_FILE): try: @@ -1640,6 +1671,7 @@ def run_prediction(model_path, source_path, active_batch_info["count"] = counter.loading_count active_batch_info["box_count"] = counter.box_loading_count active_batch_info["box_unloading"] = counter.box_unloading_count + active_batch_info["unloading"] = counter.unloading_count active_batch_info["last_detection_time"] = datetime.now().isoformat() save_active_batch_state() else: @@ -1691,6 +1723,7 @@ def run_prediction(model_path, source_path, active_batch_info["count"] = counter.loading_count active_batch_info["box_count"] = counter.box_loading_count active_batch_info["box_unloading"] = counter.box_unloading_count + active_batch_info["unloading"] = counter.unloading_count active_batch_info["last_detection_time"] = datetime.now().isoformat() save_active_batch_state() @@ -1721,7 +1754,8 @@ def run_prediction(model_path, source_path, finalize_batch(final_count, start_iso, end_iso, box_final_count=counter.box_loading_count, box_unloading_count=counter.box_unloading_count, - model_mode=mode) + model_mode=mode, + unloading_count=counter.unloading_count) print(f"[BATCH] Truk pergi. Sesi batch #{batch_num} selesai secara otomatis. " f"Total karung: {final_count}, box: {counter.box_loading_count}.") system_state = STATE_WAITING_FOR_TRUCK diff --git a/requirements.txt b/requirements.txt index 07052df..8e17cb6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,6 +10,7 @@ python-dotenv pyyaml # also pulled by ultralytics; used directly by src/config_loader.py openpyxl paramiko # deploy_to_jetson.py only +pytesseract # DO OCR (Phase 2); system tesseract-ocr package also required on Jetson # Test-only (dev/CI): # pytest diff --git a/src/config_loader.py b/src/config_loader.py index dda78fa..a759fcd 100644 --- a/src/config_loader.py +++ b/src/config_loader.py @@ -104,6 +104,26 @@ class BatchConfig: timeout_seconds: float = 30.0 merge_threshold_seconds: int = 300 daily_cutoff_time: str = "06:00" + default_mode: str = "auto" # auto | do_manual | manual + + +@dataclass +class DoOcrConfig: + engine: str = "tesseract" # tesseract | paddle | none (YAML seed; runtime in do_settings.json) + + +@dataclass +class DoConfig: + """Delivery Order (surat jalan) gated manual-batch settings.""" + + enabled: bool = True + require_do: bool = True + require_plate: bool = False + retention_days: int = 7 + photo_dir: str = "do_photos" + max_photos_per_batch: int = 8 + zone_warn_seconds: float = 3.0 + ocr: DoOcrConfig = field(default_factory=DoOcrConfig) @dataclass @@ -142,6 +162,7 @@ class Config: tracker: TrackerConfig = field(default_factory=TrackerConfig) counting: CountingConfig = field(default_factory=CountingConfig) batch: BatchConfig = field(default_factory=BatchConfig) + do: DoConfig = field(default_factory=DoConfig) output: OutputConfig = field(default_factory=OutputConfig) dashboard: DashboardConfig = field(default_factory=DashboardConfig) camera: CameraConfig = field(default_factory=CameraConfig) @@ -216,6 +237,24 @@ def _parse_detection_params(raw: Dict[str, Any]) -> Dict[str, DetectionParams]: return out +def _parse_do(raw: Dict[str, Any]) -> DoConfig: + raw = raw or {} + ocr_raw = raw.get("ocr") or {} + engine = str(ocr_raw.get("engine", "tesseract")).lower() + if engine not in ("tesseract", "paddle", "none"): + engine = "tesseract" + return DoConfig( + enabled=bool(raw.get("enabled", True)), + require_do=bool(raw.get("require_do", True)), + require_plate=bool(raw.get("require_plate", False)), + retention_days=int(raw.get("retention_days", 7)), + photo_dir=str(raw.get("photo_dir", "do_photos")), + max_photos_per_batch=int(raw.get("max_photos_per_batch", 8)), + zone_warn_seconds=float(raw.get("zone_warn_seconds", 3.0)), + ocr=DoOcrConfig(engine=engine), + ) + + def _parse_models(raw: Dict[str, Any]) -> ModelsConfig: raw = raw or {} modes: Dict[str, ModelMode] = {} @@ -349,7 +388,9 @@ def _defaults_for_platform() -> Config: timeout_seconds=30.0, merge_threshold_seconds=int(os.getenv("BATCH_MERGE_THRESHOLD_SECONDS", "300")), daily_cutoff_time=os.getenv("DAILY_CUTOFF_TIME", "06:00"), + default_mode=os.getenv("BATCH_DEFAULT_MODE", "auto").lower(), ), + do=DoConfig(), dashboard=_dashboard_from_env(), ) return cfg @@ -376,6 +417,7 @@ def load_config(path: str | Path = "config.yaml") -> Config: tracker_raw = raw.get("tracker") or {} counting_raw = raw.get("counting") or {} batch_raw = raw.get("batch") or {} + do_raw = raw.get("do") or {} output_raw = raw.get("output") or {} camera_raw = raw.get("camera") or {} @@ -441,7 +483,9 @@ def load_config(path: str | Path = "config.yaml") -> Config: daily_cutoff_time=str(os.getenv( "DAILY_CUTOFF_TIME", batch_raw.get("daily_cutoff_time", "06:00"))), + default_mode=str(batch_raw.get("default_mode", "auto")).lower(), ), + do=_parse_do(do_raw), output=output, dashboard=_dashboard_from_env(), camera=CameraConfig( @@ -449,6 +493,8 @@ def load_config(path: str | Path = "config.yaml") -> Config: object_label=str(camera_raw.get("object_label", os.getenv("OBJECT_LABEL", "karung-pakan"))), ), ) + if cfg.batch.default_mode not in ("auto", "do_manual", "manual"): + cfg.batch.default_mode = "auto" validate_config(cfg) return cfg diff --git a/src/do_batch.py b/src/do_batch.py new file mode 100644 index 0000000..d0938e9 --- /dev/null +++ b/src/do_batch.py @@ -0,0 +1,153 @@ +"""Pure helpers for DO-gated manual batch counting. Stdlib only (CI-safe).""" + +from __future__ import annotations + +from datetime import datetime, timedelta +from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple + +BATCH_MODES = ("auto", "do_manual", "manual") +OCR_ENGINES = ("tesseract", "paddle", "none") +SACK_LIKE_UNITS = frozenset({"krg", "karung", "sack", "bg"}) +BOX_LIKE_UNITS = frozenset({"box", "ctn", "karton", "dus", "kardus"}) + + +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() + if mode in BATCH_MODES: + return mode + return default if default in BATCH_MODES else "auto" + + +def is_valid_batch_mode(raw: Any) -> bool: + return str(raw or "").strip().lower() in BATCH_MODES + + +def is_valid_ocr_engine(raw: Any) -> bool: + return str(raw or "").strip().lower() in OCR_ENGINES + + +def net_counts( + loading: int = 0, + unloading: int = 0, + box_loading: int = 0, + box_unloading: int = 0, +) -> Tuple[int, int]: + """Net = loading − unloading for sacks and boxes.""" + return int(loading) - int(unloading), int(box_loading) - int(box_unloading) + + +def should_discard_batch(net_sack: int, net_box: int) -> bool: + """Discard only when both nets are 0. Keep box-only or sack-only rows.""" + return int(net_sack) == 0 and int(net_box) == 0 + + +def classify_unit_token(token: Optional[str]) -> str: + """Return 'sack' | 'box' | 'unknown' for a qty/unit token.""" + t = (token or "").strip().lower().strip(".,;:/") + if not t: + return "unknown" + if t in SACK_LIKE_UNITS: + return "sack" + if t in BOX_LIKE_UNITS: + return "box" + # multi-word or unit attached: scan words + for part in t.replace(",", " ").split(): + part = part.strip(".,;:/") + if part in SACK_LIKE_UNITS: + return "sack" + if part in BOX_LIKE_UNITS: + return "box" + return "unknown" + + +def group_dos_by_plate(dos: Sequence[Dict[str, Any]]) -> Tuple[str, List[Dict[str, Any]]]: + """ + Bucket DOs by plate. Returns (union_plate, ordered_dos). + union_plate = plate if all non-empty plates agree else ''. + 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.discard("") + if len(plates) > 1: + raise ValueError(f"mixed_plates:{','.join(sorted(plates))}") + plate = next(iter(plates), "") + out = [] + for d in items: + d = dict(d) + p = str(d.get("plate") or "").strip().upper() + d["plate"] = p + out.append(d) + out.sort(key=lambda d: (d.get("plate") or "", str(d.get("no_do") or ""))) + return plate, out + + +def start_gate_errors( + mode: str, + dos: Sequence[Dict[str, Any]], + *, + do_enabled: bool = True, + require_do: bool = True, + require_plate: bool = False, + batch_active: bool = False, +) -> List[str]: + """ + Return machine-readable gate error reasons for start (empty = OK). + Order matches plan §7.1. + """ + reasons: List[str] = [] + mode = normalize_batch_mode(mode) + if batch_active: + reasons.append("batch_active") + if mode != "do_manual": + reasons.append("wrong_mode") + if not do_enabled: + # do.enabled false: still allow legacy paths only via wrong_mode above + if mode == "do_manual": + reasons.append("do_disabled") + items = [d for d in (dos or []) if d] + if require_do and not items: + reasons.append("missing_do") + for d in items: + if not str(d.get("no_do") or "").strip(): + reasons.append("missing_no_do") + break + if require_plate: + for d in items: + if not str(d.get("plate") or "").strip(): + reasons.append("missing_plate") + break + try: + group_dos_by_plate(items) + except ValueError as e: + code = str(e).split(":", 1)[0] + reasons.append(code or "mixed_plates") + return reasons + + +def retention_cutoff_date(retention_days: int, today: Optional[datetime] = None) -> str: + """Calendar date strictly older than cutoff is purgeable: today − retention_days.""" + day = (today or datetime.now()).date() + cutoff = day - timedelta(days=max(0, int(retention_days))) + return cutoff.isoformat() + + +def should_purge_counting_date(counting_date: str, cutoff_date: str) -> bool: + """True when counting_date is strictly before cutoff (YYYY-MM-DD string compare).""" + if not counting_date or not cutoff_date: + return False + return counting_date < cutoff_date + + +def empty_do_settings(defaults: Dict[str, Any]) -> Dict[str, Any]: + """Merge runtime do_settings overrides onto config defaults.""" + out = { + "require_plate": bool(defaults.get("require_plate", False)), + "require_do": bool(defaults.get("require_do", True)), + "ocr_engine": str(defaults.get("ocr_engine", "tesseract")).lower(), + "editable": True, + } + if not is_valid_ocr_engine(out["ocr_engine"]): + out["ocr_engine"] = "tesseract" + return out diff --git a/src/do_ocr.py b/src/do_ocr.py new file mode 100644 index 0000000..559877d --- /dev/null +++ b/src/do_ocr.py @@ -0,0 +1,158 @@ +"""DO photo OCR: single entry extract_do_fields(image_path, engine). + +Engines: tesseract (primary CPU), paddle (backup, optional deps), none (empty draft). +Must NOT run inside predict.py loop — dashboard upload path only. +""" + +from __future__ import annotations + +import re +import shutil +import subprocess +from typing import Any, Dict, Optional + +# Re-export unit classify for callers that only import do_ocr +from src.do_batch import classify_unit_token # noqa: F401 + +_DO_NUM_RE = re.compile( + r"No\.?\s*DO\s*[:#]?\s*([A-Z0-9][A-Z0-9\-/]{2,})", re.I +) +_DO_LOOSE_RE = re.compile(r"\b(\d{8,14})\b") +# Indonesian plate: optional province, letter core, optional series +_PLATE_RE = re.compile( + r"\b([A-Z]{1,2}\s?\d{1,4}\s?[A-Z]{0,3})\b" +) +_QTY_LINE_RE = re.compile( + r"(\d+)\s*(KRG|KARUNG|SACK|BG|BOX|CTN|KARTON|DUS|KARDUS)", + re.I, +) + + +def extract_do_fields(image_path: str, engine: str = "tesseract") -> Dict[str, Any]: + """Return draft fields from a DO photo. Always a dict; never raises on OCR fail. + + Keys: no_do, plate, expected_sack, expected_box, ocr_text, engine, ocr_ok, ocr_error + """ + engine = (engine or "tesseract").strip().lower() + base: Dict[str, Any] = { + "no_do": "", + "plate": "", + "expected_sack": 0, + "expected_box": 0, + "ocr_text": "", + "engine": engine, + "ocr_ok": False, + "ocr_error": "", + } + if engine == "none": + base["ocr_error"] = "disabled" + return base + try: + if engine == "paddle": + text = _ocr_paddle(image_path) + else: + text = _ocr_tesseract(image_path) + except Exception as e: + base["ocr_error"] = str(e) + return base + if text is None: + base["ocr_error"] = f"engine_{engine}_unavailable" + return base + base["ocr_text"] = text + base.update(parse_fields_from_text(text)) + base["ocr_ok"] = True + return base + + +def parse_fields_from_text(text: str) -> Dict[str, Any]: + """Regex extract DO number, plate, sack/box expected sums from raw OCR text.""" + out = {"no_do": "", "plate": "", "expected_sack": 0, "expected_box": 0} + if not text: + return out + m = _DO_NUM_RE.search(text) + if m: + out["no_do"] = m.group(1).strip().upper() + else: + m = _DO_LOOSE_RE.search(text) + if m: + out["no_do"] = m.group(1) + # plate: prefer explicit Plat/No. Pol labels + plate = "" + labeled = re.search( + r"(?:plat|no\.?\s*pol(?:isi)?|kendaraan)\s*[:#]?\s*([A-Z0-9 ]{5,12})", + text, re.I, + ) + if labeled: + plate = labeled.group(1) + else: + pm = _PLATE_RE.search(text.upper()) + if pm: + plate = pm.group(1) + out["plate"] = re.sub(r"\s+", " ", plate).strip().upper() + sack = box = 0 + for m in _QTY_LINE_RE.finditer(text): + qty = int(m.group(1)) + kind = classify_unit_token(m.group(2)) + if kind == "sack": + sack += qty + elif kind == "box": + box += qty + out["expected_sack"] = sack + out["expected_box"] = box + return out + + +def _ocr_tesseract(image_path: str) -> Optional[str]: + try: + import pytesseract + from PIL import Image + except ImportError: + # fallback to CLI if present + if shutil.which("tesseract"): + try: + proc = subprocess.run( + ["tesseract", image_path, "stdout", "-l", "eng+ind", "--psm", "6"], + capture_output=True, text=True, timeout=30, + ) + if proc.returncode == 0: + return proc.stdout or "" + except Exception: + return None + return None + if not shutil.which("tesseract") and not getattr(pytesseract, "get_tesseract_version", None): + return None + try: + img = Image.open(image_path) + # light preprocess: grayscale via PIL + gray = img.convert("L") + return pytesseract.image_to_string(gray, lang="eng+ind") or "" + except Exception: + try: + return pytesseract.image_to_string(image_path) or "" + except Exception: + return None + + +def _ocr_paddle(image_path: str) -> Optional[str]: + """PaddleOCR backup. Raises clear error path via extract when deps missing.""" + try: + from paddleocr import PaddleOCR # type: ignore + except ImportError: + raise RuntimeError( + "PaddleOCR not installed — pip install paddleocr (optional package); " + "switch ocr_engine back to tesseract" + ) + ocr = PaddleOCR(use_angle_cls=True, lang="en", show_log=False) + result = ocr.ocr(image_path, cls=True) + lines = [] + if result: + for page in result: + if not page: + continue + for item in page: + # item: [box, (text, conf)] + try: + lines.append(str(item[1][0])) + except (TypeError, IndexError): + continue + return "\n".join(lines) diff --git a/templates/history.html b/templates/history.html index 297e8f8..2de67ce 100644 --- a/templates/history.html +++ b/templates/history.html @@ -261,8 +261,12 @@ Batch # - Sacks Counted - Boxes Counted + Plate + No. DO + Sacks + Boxes + Expected + Net Start Time End Time Duration (Min) @@ -270,7 +274,7 @@ - + Silakan pilih tanggal dari daftar sebelah kiri untuk memuat detail batch. @@ -354,7 +358,7 @@ const tableBody = document.getElementById('batchesTableBody'); tableBody.innerHTML = ` - + Memuat data batch untuk tanggal ${date}... @@ -364,15 +368,15 @@ try { const res = await fetch(`/api/day-detail/${date}`); const data = await res.json(); - + tableBody.innerHTML = ''; - + // Show stats row document.getElementById('dateStatsRow').style.display = 'grid'; document.getElementById('statTotalCount').textContent = (data.total_count || 0).toLocaleString(); document.getElementById('statTotalBoxes').textContent = (data.total_boxes || 0).toLocaleString(); document.getElementById('statTotalBatches').textContent = data.total_batches || 0; - + const avgSacks = data.total_batches > 0 ? (data.total_count / data.total_batches).toFixed(1) : '0'; document.getElementById('statAvgSacks').textContent = avgSacks; document.getElementById('statAvgDuration').textContent = `${data.avg_duration_minutes || 0} min`; @@ -380,7 +384,7 @@ if (!data.batches || data.batches.length === 0) { tableBody.innerHTML = ` - + Tidak ada batch tercatat pada tanggal ${date}. @@ -391,14 +395,22 @@ data.batches.forEach(b => { const tr = document.createElement('tr'); - + const startStr = b.start_time ? new Date(b.start_time).toLocaleTimeString('en-US', { hour12: false }) : '--:--:--'; const endStr = b.end_time ? new Date(b.end_time).toLocaleTimeString('en-US', { hour12: false }) : '--:--:--'; - + const dos = (b.do_numbers || []).join(', ') || '—'; + const plate = b.plate || '—'; + const expected = (b.expected_sack || 0) + ' / ' + (b.expected_box || 0); + const net = (b.net_sack ?? b.count) + ' / ' + (b.net_box ?? b.box_loading ?? 0); + tr.innerHTML = ` Batch #${b.batch_number} + ${escapeHtmlHist(plate)} + ${escapeHtmlHist(dos)} ${b.count.toLocaleString()} ${(b.box_loading || 0).toLocaleString()} + ${expected} + ${net} ${startStr} ${endStr} ${b.duration_minutes || 0} min @@ -410,7 +422,7 @@ console.error('Failed to load date details:', err); tableBody.innerHTML = ` - + Gagal memuat detail data: ${err.message} @@ -419,6 +431,12 @@ } } + function escapeHtmlHist(s) { + return String(s).replace(/[&<>"']/g, c => ({ + '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' + })[c]); + } + document.addEventListener('DOMContentLoaded', loadDates); {% endblock %} diff --git a/templates/monitoring.html b/templates/monitoring.html index 52577f2..43b70e3 100644 --- a/templates/monitoring.html +++ b/templates/monitoring.html @@ -305,14 +305,17 @@

Active Batch

- +
- + +
@@ -347,6 +350,24 @@ Pipeline Speed -- FPS
+
+ OCR Engine + + + +
+
+ Require plat + + + +
@@ -613,7 +634,7 @@ } } - let currentBatchMode = 'manual'; + let currentBatchMode = 'auto'; async function switchBatchMode(mode) { try { @@ -625,6 +646,12 @@ const data = await res.json(); if (data.success) { updateModeUI(data.mode); + } else if (res.status === 409) { + alert(data.error || 'Batch sedang berjalan — hentikan dulu.'); + } else if (res.status === 403) { + alert(data.error || 'Hanya dari port kantor.'); + } else { + alert(data.error || 'Gagal ganti mode'); } } catch (e) { console.error('Failed to switch batch mode:', e); @@ -647,46 +674,95 @@ currentBatchMode = mode; const btnManual = document.getElementById('btnModeManual'); const btnAuto = document.getElementById('btnModeAuto'); + const btnDo = document.getElementById('btnModeDoManual'); const modeText = document.getElementById('liveBatchModeText'); const manualSection = document.getElementById('manualControlSection'); const autoHint = document.getElementById('autoModeHint'); - if (mode === 'auto') { - btnAuto.style.background = 'var(--accent-blue)'; - btnAuto.style.color = '#fff'; - btnAuto.style.fontWeight = '600'; - btnManual.style.background = 'transparent'; - btnManual.style.color = 'var(--text-secondary)'; - btnManual.style.fontWeight = 'normal'; - if (modeText) { + const setActive = (el, on) => { + if (!el) return; + if (on) { + el.style.background = 'var(--accent-blue)'; + el.style.color = '#fff'; + el.style.fontWeight = '600'; + } else { + el.style.background = 'transparent'; + el.style.color = 'var(--text-secondary)'; + el.style.fontWeight = 'normal'; + } + }; + setActive(btnAuto, mode === 'auto'); + setActive(btnDo, mode === 'do_manual'); + setActive(btnManual, mode === 'manual'); + + if (modeText) { + if (mode === 'auto') { modeText.textContent = 'Otomatis (AI Truk)'; modeText.style.color = 'var(--accent-orange)'; - } - if (manualSection) manualSection.style.display = 'none'; - if (autoHint) autoHint.style.display = 'block'; - } else { - btnManual.style.background = 'var(--accent-blue)'; - btnManual.style.color = '#fff'; - btnManual.style.fontWeight = '600'; - btnAuto.style.background = 'transparent'; - btnAuto.style.color = 'var(--text-secondary)'; - btnAuto.style.fontWeight = 'normal'; - if (modeText) { + } else if (mode === 'do_manual') { + modeText.textContent = 'DO Manual (Scan Surat)'; + modeText.style.color = 'var(--accent-blue)'; + } else { modeText.textContent = 'Manual (Tombol)'; modeText.style.color = 'var(--accent-blue)'; } + } + if (mode === 'auto') { + if (manualSection) manualSection.style.display = 'none'; + if (autoHint) autoHint.style.display = 'block'; + } else { if (manualSection) manualSection.style.display = 'flex'; if (autoHint) autoHint.style.display = 'none'; } } + async function loadDoSettings() { + try { + const res = await fetch('/api/do/settings'); + const data = await res.json(); + if (!data.success) return; + const sel = document.getElementById('monOcrEngine'); + if (sel && data.ocr_engine) sel.value = data.ocr_engine; + const chk = document.getElementById('monRequirePlate'); + if (chk) chk.checked = !!data.require_plate; + } catch (e) { console.error(e); } + } + + async function setOcrEngine(eng) { + try { + const res = await fetch('/api/do/settings', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ocr_engine: eng }) + }); + const data = await res.json(); + if (!data.success) alert(data.error || 'Gagal set OCR'); + loadDoSettings(); + } catch (e) { alert('Gagal terhubung'); loadDoSettings(); } + } + + async function setRequirePlate(val) { + try { + const res = await fetch('/api/do/settings', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ require_plate: val }) + }); + const data = await res.json(); + if (!data.success) alert(data.error || 'Gagal set require_plate'); + loadDoSettings(); + } catch (e) { alert('Gagal terhubung'); loadDoSettings(); } + } + // Start interval pollings setInterval(pollCurrentBatch, 2000); setInterval(pollFinalizedBatch, 3000); setInterval(checkBatchMode, 5000); + setInterval(loadDoSettings, 8000); // Initial first load checkBatchMode(); + loadDoSettings(); pollCurrentBatch(); pollFinalizedBatch(); diff --git a/templates/operator.html b/templates/operator.html index 28378c8..121f3b4 100644 --- a/templates/operator.html +++ b/templates/operator.html @@ -224,26 +224,136 @@ color: var(--text-primary); font-size: 13px; } + + /* DO panel */ + .do-panel { + display: none; + text-align: left; + margin-top: 20px; + padding-top: 16px; + border-top: 1px solid var(--border-color); + } + .do-panel.visible { display: block; } + .do-panel h4 { + font-size: 14px; + font-weight: 700; + margin: 0 0 8px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + } + .do-strip { + display: flex; + gap: 8px; + overflow-x: auto; + padding-bottom: 6px; + margin-bottom: 10px; + } + .do-thumb { + flex: 0 0 88px; + width: 88px; + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + overflow: hidden; + background: var(--bg-base); + cursor: pointer; + } + .do-thumb img { width: 100%; height: 64px; object-fit: cover; display: block; } + .do-thumb .cap { + font-size: 10px; + padding: 2px 4px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + .do-card { + background: var(--bg-base); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + padding: 10px; + margin-bottom: 8px; + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; + font-size: 12px; + } + .do-card label { display: block; font-size: 11px; color: var(--text-secondary); } + .do-card input { + width: 100%; + padding: 6px 8px; + border-radius: var(--radius-md); + border: 1px solid var(--border-color); + background: var(--bg-card); + color: var(--text-primary); + font-size: 13px; + box-sizing: border-box; + } + .do-card .full { grid-column: 1 / -1; } + .do-card .row-actions { display: flex; gap: 8px; grid-column: 1 / -1; } + .do-chip { + display: inline-block; + background: rgba(0,113,227,0.12); + color: var(--accent-blue); + border-radius: 99px; + padding: 2px 8px; + font-size: 11px; + font-weight: 600; + margin: 2px 4px 2px 0; + } + .do-warn { color: var(--accent-orange); font-size: 12px; margin: 6px 0; } + .ocr-row { + display: flex; + align-items: center; + gap: 8px; + font-size: 12px; + color: var(--text-secondary); + margin-top: 8px; + justify-content: center; + flex-wrap: wrap; + } + .ocr-row select { + padding: 6px 10px; + border-radius: var(--radius-md); + border: 1px solid var(--border-color); + background: var(--bg-base); + color: var(--text-primary); + font-size: 13px; + } + .mode-badge { + display: inline-block; + padding: 4px 12px; + border-radius: 99px; + font-size: 12px; + font-weight: 700; + margin-top: 8px; + background: var(--bg-base); + border: 1px solid var(--border-color); + color: var(--text-secondary); + } + .mode-badge.do_manual { background: rgba(0,113,227,0.12); color: var(--accent-blue); border-color: var(--accent-blue); } + .mode-badge.auto { background: rgba(255,159,10,0.12); color: var(--accent-orange); } + .mode-badge.manual { background: rgba(52,199,89,0.12); color: var(--accent-green); } {% endblock %} {% block content %} - +
- 🟡 Mode OTOMATIS aktif. Alihkan ke MANUAL untuk kontrol tombol batch? - - + Mode diatur dari monitoring/kantor +

Kontrol Pemuatan Karung

-

Tekan tombol saat proses muat truk dimulai dan selesai

+

Tekan tombol saat proses muat truk dimulai dan selesai

Status: Standby (Tidak Memuat)
+
Mode: --
@@ -263,6 +373,18 @@
Box Masuk
0
+ + +
@@ -275,15 +397,44 @@
- + +
+

+ Foto DO (Smartphone) + +

+ + +
+
+
+ +
+ OCR: + + +
+ +
+ +
Model: -- -
-

Ganti mode model berlaku setelah service direstart.

+

+ Mode batch & model diatur dari monitoring/kantor. +

@@ -291,7 +442,8 @@