feat: DO-gated manual batch mode (auto default, office-only switches)
ci / smoke (push) Canceled after 0s

This commit is contained in:
andrew committed 2026-09-24 15:45:12 +07:00
1 parent 735e0c7594
commit 0efcfa45b8
20 files changed
+3128 -209

No files matched your search

+12 -7
View File
@@ -27,14 +27,20 @@ Indonesian (`karung`=sack, `truk`=truck); YOLO class names are English
## Gotchas ## 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 output/camera via `src/config_loader.py`); `.env` holds secrets + deployment
only (`RTSP_URL`, dashboard host/ports/secret/site); `zones.json` holds only (`RTSP_URL`, dashboard host/ports/secret/site); `zones.json` holds
geometry (polygons + left/right limits — knob keys there are ignored, warned); geometry (polygons + left/right limits — knob keys there are ignored, warned);
`cfg/tracker.yaml` holds tracker hyperparams. `src/config.py` (v3 keys like `cfg/tracker.yaml` holds tracker hyperparams. `src/config.py` (v3 keys like
`LOCAL_RTSP`) is deprecated — don't add keys there. Dashboard mode switches `LOCAL_RTSP`) is deprecated — don't add keys there. Dashboard mode switches
write `config.yaml` `models.active_mode` (atomic, manual restart to apply); 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`/ - **Counting filters by class name, not ID**: `SackDetector`/`BoxDetector`/
`TruckDetector` filter via `BaseDetector(class_filter)` (`src/detection.py`); `TruckDetector` filter via `BaseDetector(class_filter)` (`src/detection.py`);
tracker keeps `("sack", "truck", "box")` (`src/tracking.py`); counting uses 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 - 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. (`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`, - **Tests:** `python -m pytest tests/ -q` (smoke tests for `counting`, `batch`,
`config` — pure-Python, no cv2/ultralytics needed). CI (`.github/workflows/ci.yml`) `config`, `do_batch` — pure-Python; Flask API tests skip if flask/openpyxl
installs only `numpy python-dotenv pytest` + runs pytest + `compileall`. missing). Deps: `requirements.txt` (unpinned; **never** pip-install torch from
Deps: `requirements.txt` (unpinned; **never** pip-install torch from PyPI on the PyPI on the Jetson — use the NVIDIA wheels already on device). For visual checks
Jetson — use the NVIDIA wheels already on device). For visual checks run on a video run on a video file (`--source`), not the live RTSP.
file (`--source`), not the live RTSP.
- **Don't commit state**: `.gitignore` excludes `*.engine` (rebuildable), - **Don't commit state**: `.gitignore` excludes `*.engine` (rebuildable),
`*.mp4/*.jpg/*.png`, `*.db`, `.env`, `batch_history_folder/`. Videos and local `*.mp4/*.jpg/*.png`, `*.db`, `.env`, `batch_history_folder/`. Videos and local
DBs already sit untracked in the working tree — leave them alone. DBs already sit untracked in the working tree — leave them alone.
+24 -1
View File
@@ -6,7 +6,30 @@ version tags are cut in this repo — POC stage, `main` is the release line).
## [Unreleased] ## [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 ## 2026-09-18 — Config tuning: Sack confidence and batch timeout
+23 -5
View File
@@ -82,11 +82,27 @@ See [`docs/configuration.md`](docs/configuration.md) for every variable.
## Dashboard (`counter_dashboard.py`, port 5000 / office 5721) ## Dashboard (`counter_dashboard.py`, port 5000 / office 5721)
Pages in `templates/`: `monitoring.html`, `operator.html` (manual batch start/stop), Pages in `templates/`: `monitoring.html`, `operator.html` (batch start/stop;
`history.html`, `analytics.html`. JSON APIs under `/api/*` (live video MJPEG, current/ DO panel in `do_manual`), `history.html`, `analytics.html`. JSON APIs under
previous batch, summary, daily data, CSV/Excel export). Data source: `/api/*` (live video MJPEG, current/previous batch, summary, daily data,
`jetson_counter.db` + `current_batch.json` (+ `batch_mode.json` for the batch mode, DO upload/staged/settings, CSV/Excel export). Data source:
manual/auto batch switch; the model mode lives in `config.yaml`). `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://<jetson>: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 ## 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/models.md`](docs/models.md) — model inventory & detection-mode support matrix
- [`docs/configuration.md`](docs/configuration.md) — all config files/variables - [`docs/configuration.md`](docs/configuration.md) — all config files/variables
- [`docs/deployment.md`](docs/deployment.md) — Jetson services, TensorRT, deploy flow - [`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 - [`docs/scripts.md`](docs/scripts.md) — entry points & utility scripts
- [`CHANGELOG.md`](CHANGELOG.md) — release history - [`CHANGELOG.md`](CHANGELOG.md) — release history
+15
View File
@@ -119,6 +119,21 @@ batch:
timeout_seconds: 20.0 timeout_seconds: 20.0
merge_threshold_seconds: 300 # deprecated by timeout_seconds and detecting sacks within the ROI merge_threshold_seconds: 300 # deprecated by timeout_seconds and detecting sacks within the ROI
daily_cutoff_time: "06:00" 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: output:
dir: "/opt/jetson-counter" dir: "/opt/jetson-counter"
+880 -70
View File
File diff suppressed because it is too large. Load diff
+24 -1
View File
@@ -105,9 +105,13 @@ stats panel (Loading / Unloading / Net / last-3-batch history), and a bottom sta
### Config & logging ### Config & logging
- `src/config_loader.py` — canonical loader: `config.yaml` (stream, models, - `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 `zones.json` polygons + `cfg/tracker.yaml` reference. Model modes are data
(`models.modes`); missing file falls back to `.env` + legacy defaults. (`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); - `src/config.py` — **deprecated** frozen v3 `Config` (different key names);
do not add keys here. do not add keys here.
- `src/logger.py` — `CSVLogger` appends `batch_summary.csv` - `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`, are dead code — persistence goes through `finalize_batch`/`save_active_batch_state`,
dashboard integration is file-based.) 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) ## Retired (`archive/`, not imported)
- **`predict_new.py`** — duo-model state machine (`WAITING_FOR_TRUCK → COUNTING_SACKS → - **`predict_new.py`** — duo-model state machine (`WAITING_FOR_TRUCK → COUNTING_SACKS →
+28 -4
View File
@@ -23,9 +23,31 @@ endpoint lists them automatically.
Mode switch: dashboard `POST /api/batch/mode {"model_mode": "X"}` validates Mode switch: dashboard `POST /api/batch/mode {"model_mode": "X"}` validates
against `config.yaml` and persists atomically (tmp+replace, comments preserved) against `config.yaml` and persists atomically (tmp+replace, comments preserved)
to `models.active_mode`. **Manual `karung-counter` restart still required** to `models.active_mode`. **Manual `karung-counter` restart still required**
(models load once at startup). `batch_mode.json` keeps only the manual/auto (models load once at startup). `batch_mode.json` keeps only the batch flow
batch `mode`; its legacy `model_mode` key is ignored (warned). `MODEL_MODE` `mode` (`auto` | `do_manual` | `manual`); its legacy `model_mode` key is
env var still overrides for one run but is deprecated (warned). 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). Template: `.env.example`. Production values live in `.env` (git-ignored).
Missing `config.yaml` falls back to `.env` + built-in defaults with a warning 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 | | `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 | | `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 | | `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 | | `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 | | `CAMERA_NAME` | `CC1` | Camera tag stored per batch |
| `OBJECT_LABEL` | `karung-pakan` | Object tag stored per batch | | `OBJECT_LABEL` | `karung-pakan` | Object tag stored per batch |
+38 -9
View File
@@ -35,9 +35,12 @@ loads `.engine` only — see `models/modelREADME.md` for which weights each mode
## Runtime data files ## Runtime data files
- SQLite `jetson_counter.db`: `batches(counting_date, batch_number, camera_name, - SQLite `jetson_counter.db`: `batches(counting_date, batch_number, camera_name,
object_label, count, start/end_time)`, `daily_summaries(...)`. object_label, count, start/end_time, box_*, plate, do_numbers, expected_*,
- `current_batch.json` (crash recovery), `batch_mode.json` (manual/auto batch net_sack, net_box)`, `daily_summaries(...)`, `delivery_orders(...)` (DO photos).
mode only — the model mode lives in `config.yaml`), - `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_<ts>.json` + `hasil_perhitungan.json` (per-batch reports). `batch_history_folder/batch_<ts>.json` + `hasil_perhitungan.json` (per-batch reports).
- Live frame: `/dev/shm/jetson-counter/live_frame.jpg` (written every 2nd frame, - Live frame: `/dev/shm/jetson-counter/live_frame.jpg` (written every 2nd frame,
consumed by `/api/live-video` MJPEG). 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`, (`backup.py`, `dump_db.py`, `migrate_jetson_db.py`, `merge_batches_*.py`,
`update_batches.py`, `diagnose_truck_jetson.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://<jetson>: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`) ## Dashboard (`counter_dashboard.py`)
Pages: `/` + `/monitoring`, `/operator` (manual start/stop, mode switch), Pages: `/` + `/monitoring`, `/operator` (manual start/stop; DO panel in
`/history`, `/analytics`. Key APIs: `/api/live-video`, `/api/current-batch`, `do_manual`), `/history`, `/analytics`. Key APIs: `/api/live-video`,
`/api/previous-batch`, `/api/batch/{start,stop,mode}`, `/api/model-modes` `/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), (mode list is derived from `config.yaml`, so future modes appear automatically),
`/api/summary`, `/api/daily-data`, `/api/day-detail/<date>`, `/api/do/{upload,photo/<id>,staged,settings,retention}` +
`/api/recent-batches`, `/api/available-dates`, `/api/export-daily-csv`, `PUT/DELETE /api/do/<id>`, `/api/summary`, `/api/daily-data`,
`/api/export-day-csv/<date>` (CSV + Excel via openpyxl). `/api/day-detail/<date>`, `/api/recent-batches`, `/api/available-dates`,
`/api/export-daily-csv`, `/api/export-day-csv/<date>` (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://<host>:5000/operator` for camera capture.
+684
View File
@@ -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/<id> (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_<uuid>.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/<id>` | 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/<id>` | operator | Edit fields; set `status=staged` when saved |
| `DELETE` | `/api/do/<id>` | 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)** — `<input type="file" accept="image/*" capture="environment" multiple>`.
- 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.
+81
View File
@@ -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_<uuid>.jpg` | source photos (7-day retention) |
Schema DDL: `counter_dashboard.py` `_ensure_db()` (additive PRAGMA migrations
on `batches` + `CREATE TABLE delivery_orders`).
+45 -11
View File
@@ -221,16 +221,20 @@ def save_active_batch_state():
print(f"[DB Error] Gagal menulis {STATE_FILE}: {e}") print(f"[DB Error] Gagal menulis {STATE_FILE}: {e}")
def finalize_batch(final_count, start_time_iso, end_time_iso, 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 global active_batch_info
if active_batch_info is None: if active_batch_info is None:
return 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: if NO_DB:
print(f"[DB Info] --no-db: batch #{active_batch_info.get('batch_number', 0)} " print(f"[DB Info] --no-db: batch #{active_batch_info.get('batch_number', 0)} "
f"({final_count} karung, {box_final_count} box) tidak disimpan.") f"({final_count} karung, {box_final_count} box) tidak disimpan.")
active_batch_info = None active_batch_info = None
save_active_batch_state()
return 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).") print(f"[BATCH] Batch #{active_batch_info.get('batch_number', 0)} bernilai 0 diabaikan (tidak disimpan ke database).")
active_batch_info = None active_batch_info = None
save_active_batch_state() 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)) """, (counting_date, CAMERA_NAME, OBJECT_LABEL, tot_count, tot_batches))
conn.commit() 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: try:
cur2 = conn.cursor() 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(""" 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 = ? WHERE counting_date = ? AND batch_number = ? AND camera_name = ? AND object_label = ?
""", (box_final_count, box_unloading_count, model_mode, """, (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)) counting_date, batch_num, CAMERA_NAME, OBJECT_LABEL))
conn.commit() conn.commit()
except Exception: except Exception:
pass 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() conn.close()
print(f"[DB Info] Sesi batch #{batch_num} disimpan ke database SQLite: " 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: except Exception as e:
print(f"[DB Error] Gagal menyimpan batch ke database: {e}") print(f"[DB Error] Gagal menyimpan batch ke database: {e}")
active_batch_info = None 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) # 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 truck_in_area = len(raw_tracked_trucks) > 0
# Cek Mode Batch: 'auto' vs 'manual' (default: 'manual') # Cek Mode Batch: auto | do_manual | manual (default: auto)
current_batch_mode = "manual" current_batch_mode = "auto"
if os.path.exists(BATCH_MODE_FILE): if os.path.exists(BATCH_MODE_FILE):
try: try:
with open(BATCH_MODE_FILE, "r") as bmf: with open(BATCH_MODE_FILE, "r") as bmf:
bm_data = json.load(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: except Exception:
pass pass
roi = static_roi 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 manual_batch_exists = False
if os.path.exists(STATE_FILE): if os.path.exists(STATE_FILE):
try: try:
@@ -1640,6 +1671,7 @@ def run_prediction(model_path, source_path,
active_batch_info["count"] = counter.loading_count active_batch_info["count"] = counter.loading_count
active_batch_info["box_count"] = counter.box_loading_count active_batch_info["box_count"] = counter.box_loading_count
active_batch_info["box_unloading"] = counter.box_unloading_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() active_batch_info["last_detection_time"] = datetime.now().isoformat()
save_active_batch_state() save_active_batch_state()
else: else:
@@ -1691,6 +1723,7 @@ def run_prediction(model_path, source_path,
active_batch_info["count"] = counter.loading_count active_batch_info["count"] = counter.loading_count
active_batch_info["box_count"] = counter.box_loading_count active_batch_info["box_count"] = counter.box_loading_count
active_batch_info["box_unloading"] = counter.box_unloading_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() active_batch_info["last_detection_time"] = datetime.now().isoformat()
save_active_batch_state() save_active_batch_state()
@@ -1721,7 +1754,8 @@ def run_prediction(model_path, source_path,
finalize_batch(final_count, start_iso, end_iso, finalize_batch(final_count, start_iso, end_iso,
box_final_count=counter.box_loading_count, box_final_count=counter.box_loading_count,
box_unloading_count=counter.box_unloading_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. " print(f"[BATCH] Truk pergi. Sesi batch #{batch_num} selesai secara otomatis. "
f"Total karung: {final_count}, box: {counter.box_loading_count}.") f"Total karung: {final_count}, box: {counter.box_loading_count}.")
system_state = STATE_WAITING_FOR_TRUCK system_state = STATE_WAITING_FOR_TRUCK
+1
View File
@@ -10,6 +10,7 @@ python-dotenv
pyyaml # also pulled by ultralytics; used directly by src/config_loader.py pyyaml # also pulled by ultralytics; used directly by src/config_loader.py
openpyxl openpyxl
paramiko # deploy_to_jetson.py only paramiko # deploy_to_jetson.py only
pytesseract # DO OCR (Phase 2); system tesseract-ocr package also required on Jetson
# Test-only (dev/CI): # Test-only (dev/CI):
# pytest # pytest
+46
View File
@@ -104,6 +104,26 @@ class BatchConfig:
timeout_seconds: float = 30.0 timeout_seconds: float = 30.0
merge_threshold_seconds: int = 300 merge_threshold_seconds: int = 300
daily_cutoff_time: str = "06:00" 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 @dataclass
@@ -142,6 +162,7 @@ class Config:
tracker: TrackerConfig = field(default_factory=TrackerConfig) tracker: TrackerConfig = field(default_factory=TrackerConfig)
counting: CountingConfig = field(default_factory=CountingConfig) counting: CountingConfig = field(default_factory=CountingConfig)
batch: BatchConfig = field(default_factory=BatchConfig) batch: BatchConfig = field(default_factory=BatchConfig)
do: DoConfig = field(default_factory=DoConfig)
output: OutputConfig = field(default_factory=OutputConfig) output: OutputConfig = field(default_factory=OutputConfig)
dashboard: DashboardConfig = field(default_factory=DashboardConfig) dashboard: DashboardConfig = field(default_factory=DashboardConfig)
camera: CameraConfig = field(default_factory=CameraConfig) camera: CameraConfig = field(default_factory=CameraConfig)
@@ -216,6 +237,24 @@ def _parse_detection_params(raw: Dict[str, Any]) -> Dict[str, DetectionParams]:
return out 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: def _parse_models(raw: Dict[str, Any]) -> ModelsConfig:
raw = raw or {} raw = raw or {}
modes: Dict[str, ModelMode] = {} modes: Dict[str, ModelMode] = {}
@@ -349,7 +388,9 @@ def _defaults_for_platform() -> Config:
timeout_seconds=30.0, timeout_seconds=30.0,
merge_threshold_seconds=int(os.getenv("BATCH_MERGE_THRESHOLD_SECONDS", "300")), merge_threshold_seconds=int(os.getenv("BATCH_MERGE_THRESHOLD_SECONDS", "300")),
daily_cutoff_time=os.getenv("DAILY_CUTOFF_TIME", "06:00"), 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(), dashboard=_dashboard_from_env(),
) )
return cfg return cfg
@@ -376,6 +417,7 @@ def load_config(path: str | Path = "config.yaml") -> Config:
tracker_raw = raw.get("tracker") or {} tracker_raw = raw.get("tracker") or {}
counting_raw = raw.get("counting") or {} counting_raw = raw.get("counting") or {}
batch_raw = raw.get("batch") or {} batch_raw = raw.get("batch") or {}
do_raw = raw.get("do") or {}
output_raw = raw.get("output") or {} output_raw = raw.get("output") or {}
camera_raw = raw.get("camera") 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=str(os.getenv(
"DAILY_CUTOFF_TIME", "DAILY_CUTOFF_TIME",
batch_raw.get("daily_cutoff_time", "06:00"))), 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, output=output,
dashboard=_dashboard_from_env(), dashboard=_dashboard_from_env(),
camera=CameraConfig( 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"))), 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) validate_config(cfg)
return cfg return cfg
+153
View File
@@ -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
+158
View File
@@ -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)
+29 -11
View File
@@ -261,8 +261,12 @@
<thead> <thead>
<tr> <tr>
<th>Batch #</th> <th>Batch #</th>
<th>Sacks Counted</th> <th>Plate</th>
<th>Boxes Counted</th> <th>No. DO</th>
<th>Sacks</th>
<th>Boxes</th>
<th>Expected</th>
<th>Net</th>
<th>Start Time</th> <th>Start Time</th>
<th>End Time</th> <th>End Time</th>
<th>Duration (Min)</th> <th>Duration (Min)</th>
@@ -270,7 +274,7 @@
</thead> </thead>
<tbody id="batchesTableBody"> <tbody id="batchesTableBody">
<tr> <tr>
<td colspan="6" class="no-data"> <td colspan="10" class="no-data">
<i class="fa-solid fa-arrow-left"></i> <i class="fa-solid fa-arrow-left"></i>
Silakan pilih tanggal dari daftar sebelah kiri untuk memuat detail batch. Silakan pilih tanggal dari daftar sebelah kiri untuk memuat detail batch.
</td> </td>
@@ -354,7 +358,7 @@
const tableBody = document.getElementById('batchesTableBody'); const tableBody = document.getElementById('batchesTableBody');
tableBody.innerHTML = ` tableBody.innerHTML = `
<tr> <tr>
<td colspan="6" class="no-data"> <td colspan="10" class="no-data">
<i class="fa-solid fa-circle-notch fa-spin"></i> <i class="fa-solid fa-circle-notch fa-spin"></i>
Memuat data batch untuk tanggal ${date}... Memuat data batch untuk tanggal ${date}...
</td> </td>
@@ -364,15 +368,15 @@
try { try {
const res = await fetch(`/api/day-detail/${date}`); const res = await fetch(`/api/day-detail/${date}`);
const data = await res.json(); const data = await res.json();
tableBody.innerHTML = ''; tableBody.innerHTML = '';
// Show stats row // Show stats row
document.getElementById('dateStatsRow').style.display = 'grid'; document.getElementById('dateStatsRow').style.display = 'grid';
document.getElementById('statTotalCount').textContent = (data.total_count || 0).toLocaleString(); document.getElementById('statTotalCount').textContent = (data.total_count || 0).toLocaleString();
document.getElementById('statTotalBoxes').textContent = (data.total_boxes || 0).toLocaleString(); document.getElementById('statTotalBoxes').textContent = (data.total_boxes || 0).toLocaleString();
document.getElementById('statTotalBatches').textContent = data.total_batches || 0; document.getElementById('statTotalBatches').textContent = data.total_batches || 0;
const avgSacks = data.total_batches > 0 ? (data.total_count / data.total_batches).toFixed(1) : '0'; const avgSacks = data.total_batches > 0 ? (data.total_count / data.total_batches).toFixed(1) : '0';
document.getElementById('statAvgSacks').textContent = avgSacks; document.getElementById('statAvgSacks').textContent = avgSacks;
document.getElementById('statAvgDuration').textContent = `${data.avg_duration_minutes || 0} min`; document.getElementById('statAvgDuration').textContent = `${data.avg_duration_minutes || 0} min`;
@@ -380,7 +384,7 @@
if (!data.batches || data.batches.length === 0) { if (!data.batches || data.batches.length === 0) {
tableBody.innerHTML = ` tableBody.innerHTML = `
<tr> <tr>
<td colspan="6" class="no-data"> <td colspan="10" class="no-data">
<i class="fa-solid fa-inbox"></i> <i class="fa-solid fa-inbox"></i>
Tidak ada batch tercatat pada tanggal ${date}. Tidak ada batch tercatat pada tanggal ${date}.
</td> </td>
@@ -391,14 +395,22 @@
data.batches.forEach(b => { data.batches.forEach(b => {
const tr = document.createElement('tr'); const tr = document.createElement('tr');
const startStr = b.start_time ? new Date(b.start_time).toLocaleTimeString('en-US', { hour12: false }) : '--:--:--'; 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 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 = ` tr.innerHTML = `
<td style="font-weight: 600; color: var(--accent-blue);">Batch #${b.batch_number}</td> <td style="font-weight: 600; color: var(--accent-blue);">Batch #${b.batch_number}</td>
<td>${escapeHtmlHist(plate)}</td>
<td style="font-size:12px;">${escapeHtmlHist(dos)}</td>
<td style="font-weight: 500;">${b.count.toLocaleString()}</td> <td style="font-weight: 500;">${b.count.toLocaleString()}</td>
<td style="font-weight: 500;">${(b.box_loading || 0).toLocaleString()}</td> <td style="font-weight: 500;">${(b.box_loading || 0).toLocaleString()}</td>
<td style="font-size:12px;">${expected}</td>
<td style="font-size:12px;">${net}</td>
<td>${startStr}</td> <td>${startStr}</td>
<td>${endStr}</td> <td>${endStr}</td>
<td>${b.duration_minutes || 0} min</td> <td>${b.duration_minutes || 0} min</td>
@@ -410,7 +422,7 @@
console.error('Failed to load date details:', err); console.error('Failed to load date details:', err);
tableBody.innerHTML = ` tableBody.innerHTML = `
<tr> <tr>
<td colspan="6" class="no-data" style="color: var(--accent-red);"> <td colspan="10" class="no-data" style="color: var(--accent-red);">
<i class="fa-solid fa-circle-exclamation"></i> <i class="fa-solid fa-circle-exclamation"></i>
Gagal memuat detail data: ${err.message} Gagal memuat detail data: ${err.message}
</td> </td>
@@ -419,6 +431,12 @@
} }
} }
function escapeHtmlHist(s) {
return String(s).replace(/[&<>"']/g, c => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
})[c]);
}
document.addEventListener('DOMContentLoaded', loadDates); document.addEventListener('DOMContentLoaded', loadDates);
</script> </script>
{% endblock %} {% endblock %}
+100 -24
View File
@@ -305,14 +305,17 @@
<div class="active-batch-card"> <div class="active-batch-card">
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px;"> <div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px;">
<h3 class="card-label" style="margin-bottom: 0;">Active Batch</h3> <h3 class="card-label" style="margin-bottom: 0;">Active Batch</h3>
<!-- Mode Switcher --> <!-- Mode Switcher: auto | do_manual | manual -->
<div style="display: inline-flex; background: var(--bg-base); border: 1px solid var(--border-color); border-radius: 20px; padding: 2px;"> <div style="display: inline-flex; background: var(--bg-base); border: 1px solid var(--border-color); border-radius: 20px; padding: 2px;">
<button id="btnModeManual" class="btn btn-sm" style="padding: 4px 10px; font-size: 11px; border-radius: 18px; border: none; background: var(--accent-blue); color: #fff; font-weight: 600;" onclick="switchBatchMode('manual')">
<i class="fa-solid fa-hand"></i> Manual
</button>
<button id="btnModeAuto" class="btn btn-sm" style="padding: 4px 10px; font-size: 11px; border-radius: 18px; border: none; background: transparent; color: var(--text-secondary);" onclick="switchBatchMode('auto')"> <button id="btnModeAuto" class="btn btn-sm" style="padding: 4px 10px; font-size: 11px; border-radius: 18px; border: none; background: transparent; color: var(--text-secondary);" onclick="switchBatchMode('auto')">
<i class="fa-solid fa-robot"></i> Otomatis <i class="fa-solid fa-robot"></i> Otomatis
</button> </button>
<button id="btnModeDoManual" class="btn btn-sm" style="padding: 4px 10px; font-size: 11px; border-radius: 18px; border: none; background: transparent; color: var(--text-secondary);" onclick="switchBatchMode('do_manual')">
<i class="fa-solid fa-file-lines"></i> DO Manual
</button>
<button id="btnModeManual" class="btn btn-sm" style="padding: 4px 10px; font-size: 11px; border-radius: 18px; border: none; background: transparent; color: var(--text-secondary);" onclick="switchBatchMode('manual')">
<i class="fa-solid fa-hand"></i> Manual
</button>
</div> </div>
</div> </div>
@@ -347,6 +350,24 @@
<span class="meta-key">Pipeline Speed</span> <span class="meta-key">Pipeline Speed</span>
<span class="meta-value"><span id="liveFps">--</span> FPS</span> <span class="meta-value"><span id="liveFps">--</span> FPS</span>
</div> </div>
<div class="meta-item">
<span class="meta-key">OCR Engine</span>
<span class="meta-value">
<select id="monOcrEngine" style="padding:4px 8px;border-radius:8px;border:1px solid var(--border-color);background:var(--bg-base);color:var(--text-primary);font-size:12px;" onchange="setOcrEngine(this.value)">
<option value="tesseract">tesseract</option>
<option value="paddle">paddle</option>
<option value="none">none</option>
</select>
</span>
</div>
<div class="meta-item">
<span class="meta-key">Require plat</span>
<span class="meta-value">
<label style="font-weight:400;">
<input type="checkbox" id="monRequirePlate" onchange="setRequirePlate(this.checked)"> wajib
</label>
</span>
</div>
</div> </div>
<!-- Manual Batch Control Buttons for Office Dashboard (Hidden when in Auto Mode) --> <!-- Manual Batch Control Buttons for Office Dashboard (Hidden when in Auto Mode) -->
@@ -613,7 +634,7 @@
} }
} }
let currentBatchMode = 'manual'; let currentBatchMode = 'auto';
async function switchBatchMode(mode) { async function switchBatchMode(mode) {
try { try {
@@ -625,6 +646,12 @@
const data = await res.json(); const data = await res.json();
if (data.success) { if (data.success) {
updateModeUI(data.mode); 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) { } catch (e) {
console.error('Failed to switch batch mode:', e); console.error('Failed to switch batch mode:', e);
@@ -647,46 +674,95 @@
currentBatchMode = mode; currentBatchMode = mode;
const btnManual = document.getElementById('btnModeManual'); const btnManual = document.getElementById('btnModeManual');
const btnAuto = document.getElementById('btnModeAuto'); const btnAuto = document.getElementById('btnModeAuto');
const btnDo = document.getElementById('btnModeDoManual');
const modeText = document.getElementById('liveBatchModeText'); const modeText = document.getElementById('liveBatchModeText');
const manualSection = document.getElementById('manualControlSection'); const manualSection = document.getElementById('manualControlSection');
const autoHint = document.getElementById('autoModeHint'); const autoHint = document.getElementById('autoModeHint');
if (mode === 'auto') { const setActive = (el, on) => {
btnAuto.style.background = 'var(--accent-blue)'; if (!el) return;
btnAuto.style.color = '#fff'; if (on) {
btnAuto.style.fontWeight = '600'; el.style.background = 'var(--accent-blue)';
btnManual.style.background = 'transparent'; el.style.color = '#fff';
btnManual.style.color = 'var(--text-secondary)'; el.style.fontWeight = '600';
btnManual.style.fontWeight = 'normal'; } else {
if (modeText) { 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.textContent = 'Otomatis (AI Truk)';
modeText.style.color = 'var(--accent-orange)'; modeText.style.color = 'var(--accent-orange)';
} } else if (mode === 'do_manual') {
if (manualSection) manualSection.style.display = 'none'; modeText.textContent = 'DO Manual (Scan Surat)';
if (autoHint) autoHint.style.display = 'block'; modeText.style.color = 'var(--accent-blue)';
} else { } 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) {
modeText.textContent = 'Manual (Tombol)'; modeText.textContent = 'Manual (Tombol)';
modeText.style.color = 'var(--accent-blue)'; 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 (manualSection) manualSection.style.display = 'flex';
if (autoHint) autoHint.style.display = 'none'; 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 // Start interval pollings
setInterval(pollCurrentBatch, 2000); setInterval(pollCurrentBatch, 2000);
setInterval(pollFinalizedBatch, 3000); setInterval(pollFinalizedBatch, 3000);
setInterval(checkBatchMode, 5000); setInterval(checkBatchMode, 5000);
setInterval(loadDoSettings, 8000);
// Initial first load // Initial first load
checkBatchMode(); checkBatchMode();
loadDoSettings();
pollCurrentBatch(); pollCurrentBatch();
pollFinalizedBatch(); pollFinalizedBatch();
</script> </script>
+469 -66
View File
@@ -224,26 +224,136 @@
color: var(--text-primary); color: var(--text-primary);
font-size: 13px; 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); }
</style> </style>
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<!-- Non-blocking manual-mode prompt: status/counts stay visible below --> <!-- Non-blocking mode info (office owns mode switch) -->
<div id="modeBanner" class="mode-banner"> <div id="modeBanner" class="mode-banner">
<span>🟡 Mode OTOMATIS aktif. Alihkan ke MANUAL untuk kontrol tombol batch?</span> <span id="modeBannerText">Mode diatur dari monitoring/kantor</span>
<button class="btn btn-yes" onclick="switchToManual()">Ya, Manual</button> <button class="btn btn-no" onclick="dismissModeBanner()">Tutup</button>
<button class="btn btn-no" onclick="dismissModeBanner()">Tidak, Tetap Otomatis</button>
</div> </div>
<div class="operator-container"> <div class="operator-container">
<div class="operator-card"> <div class="operator-card">
<h1 class="operator-title"><i class="fa-solid fa-boxes-packing"></i> Kontrol Pemuatan Karung</h1> <h1 class="operator-title"><i class="fa-solid fa-boxes-packing"></i> Kontrol Pemuatan Karung</h1>
<p class="operator-subtitle">Tekan tombol saat proses muat truk dimulai dan selesai</p> <p class="operator-subtitle" id="opSubtitle">Tekan tombol saat proses muat truk dimulai dan selesai</p>
<!-- Status Indicator --> <!-- Status Indicator -->
<div id="statusBadge" class="status-badge-box status-idle"> <div id="statusBadge" class="status-badge-box status-idle">
<i id="statusIcon" class="fa-solid fa-circle-pause"></i> <i id="statusIcon" class="fa-solid fa-circle-pause"></i>
<span id="statusText">Status: Standby (Tidak Memuat)</span> <span id="statusText">Status: Standby (Tidak Memuat)</span>
</div> </div>
<div><span id="modeBadge" class="mode-badge">Mode: --</span></div>
<!-- Info Detail --> <!-- Info Detail -->
<div class="batch-info-box"> <div class="batch-info-box">
@@ -263,6 +373,18 @@
<div class="info-label">Box Masuk</div> <div class="info-label">Box Masuk</div>
<div class="info-val" id="opBoxCount">0</div> <div class="info-val" id="opBoxCount">0</div>
</div> </div>
<div class="info-item" id="opNetSackItem" style="display:none">
<div class="info-label">Net Karung / Target</div>
<div class="info-val" id="opNetSack">--</div>
</div>
<div class="info-item" id="opNetBoxItem" style="display:none">
<div class="info-label">Net Box / Target</div>
<div class="info-val" id="opNetBox">--</div>
</div>
<div class="info-item" id="opPlateItem" style="display:none; grid-column: 1 / -1;">
<div class="info-label">Plat / No. DO</div>
<div class="info-val" id="opPlateDos" style="font-size:13px;">--</div>
</div>
</div> </div>
<!-- Action Button --> <!-- Action Button -->
@@ -275,15 +397,44 @@
</button> </button>
</div> </div>
<!-- Model mode --> <!-- DO panel (only do_manual) -->
<div id="doPanel" class="do-panel">
<h4>
<span><i class="fa-solid fa-file-lines"></i> Foto DO (Smartphone)</span>
<span id="doEngineBadge" style="font-size:11px;font-weight:600;color:var(--text-secondary);"></span>
</h4>
<input type="file" id="doPhotoInput" accept="image/*" capture="environment" multiple
style="display:none" onchange="uploadDoPhotos(this.files)">
<button class="btn btn-primary btn-sm" style="width:100%;margin-bottom:8px;"
onclick="document.getElementById('doPhotoInput').click()">
<i class="fa-solid fa-camera"></i> Ambil / Pilih Foto DO
</button>
<div id="doStrip" class="do-strip"></div>
<div id="doCards"></div>
<div id="doGroupPreview"></div>
<div id="doWarn" class="do-warn" style="display:none;"></div>
<div class="ocr-row">
<span>OCR:</span>
<select id="ocrEngineSelect" onchange="setOcrEngine(this.value)">
<option value="tesseract">Tesseract</option>
<option value="paddle">PaddleOCR</option>
<option value="none">Off (manual)</option>
</select>
<span id="ocrEngineHint"></span>
</div>
<div id="doRequirePlateRow" class="ocr-row" style="display:none;">
<label><input type="checkbox" id="requirePlateChk" onchange="setRequirePlate(this.checked)"> Wajib plat nomor</label>
</div>
</div>
<!-- Model mode (read-only badge) -->
<div class="model-row"> <div class="model-row">
<span>Model:</span> <span>Model:</span>
<strong id="opModelMode">--</strong> <strong id="opModelMode">--</strong>
<select id="modelModeSelect" onchange="setModelMode(this.value)">
<option value="">Ganti mode…</option>
</select>
</div> </div>
<p class="operator-subtitle" style="margin-top:6px;font-size:12px">Ganti mode model berlaku setelah service direstart.</p> <p class="operator-subtitle" style="margin-top:6px;font-size:12px">
Mode batch & model diatur dari monitoring/kantor.
</p>
</div> </div>
</div> </div>
@@ -291,7 +442,8 @@
<div id="modalStart" class="modal-overlay"> <div id="modalStart" class="modal-overlay">
<div class="modal-box"> <div class="modal-box">
<h3 class="modal-title"><i class="fa-solid fa-play" style="color: var(--accent-green);"></i> Mulai Batch Baru?</h3> <h3 class="modal-title"><i class="fa-solid fa-play" style="color: var(--accent-green);"></i> Mulai Batch Baru?</h3>
<p class="modal-desc">Pastikan truk sudah siap di posisi pemuatan karung.</p> <p class="modal-desc" id="startModalDesc">Pastikan truk sudah siap di posisi pemuatan karung.</p>
<div id="startGateList" class="modal-desc" style="display:none;color:var(--accent-red);"></div>
<div class="modal-actions"> <div class="modal-actions">
<button class="btn btn-secondary" onclick="closeModal('modalStart')">Batal</button> <button class="btn btn-secondary" onclick="closeModal('modalStart')">Batal</button>
<button class="btn btn-primary" style="background-color: var(--accent-green);" onclick="executeStartBatch()">Ya, Mulai</button> <button class="btn btn-primary" style="background-color: var(--accent-green);" onclick="executeStartBatch()">Ya, Mulai</button>
@@ -303,10 +455,15 @@
<div id="modalStop" class="modal-overlay"> <div id="modalStop" class="modal-overlay">
<div class="modal-box"> <div class="modal-box">
<h3 class="modal-title"><i class="fa-solid fa-square" style="color: var(--accent-red);"></i> Selesai Batch?</h3> <h3 class="modal-title"><i class="fa-solid fa-square" style="color: var(--accent-red);"></i> Selesai Batch?</h3>
<p class="modal-desc">Pemuatan untuk batch ini akan ditutup dan data akan disimpan.</p> <p class="modal-desc" id="stopModalDesc">Pemuatan untuk batch ini akan ditutup dan data akan disimpan.</p>
<div id="zoneBusyWarn" class="modal-desc" style="display:none;color:var(--accent-orange);">
Masih ada aktivitas di zona — akhir paksa?
</div>
<div class="modal-actions"> <div class="modal-actions">
<button class="btn btn-secondary" onclick="closeModal('modalStop')">Batal</button> <button class="btn btn-secondary" onclick="closeModal('modalStop')">Batal</button>
<button class="btn btn-primary" style="background-color: var(--accent-red);" onclick="executeStopBatch()">Ya, Selesai</button> <button class="btn btn-primary" style="background-color: var(--accent-red);" onclick="executeStopBatch(false)">Ya, Selesai</button>
<button id="btnForceStop" class="btn btn-primary" style="background-color: var(--accent-red); display:none;"
onclick="executeStopBatch(true)">Akhir Paksa</button>
</div> </div>
</div> </div>
</div> </div>
@@ -315,18 +472,27 @@
{% block extra_js %} {% block extra_js %}
<script> <script>
let isBatchActive = false; let isBatchActive = false;
let currentMode = 'auto';
let stagedDos = [];
let settings = { require_plate: false, require_do: true, ocr_engine: 'tesseract', editable: false };
function closeModal(id) { function closeModal(id) {
document.getElementById(id).style.display = 'none'; document.getElementById(id).style.display = 'none';
} }
// --- Non-blocking manual-mode prompt (banner, status stays visible) ---
async function loadModeBanner() { async function loadModeBanner() {
try { try {
const res = await fetch('/api/batch/mode'); const res = await fetch('/api/batch/mode');
const data = await res.json(); const data = await res.json();
if (data.mode === 'auto' && !sessionStorage.getItem('mode_banner_dismissed')) { updateModeUI(data.mode || 'auto');
document.getElementById('modeBanner').style.display = 'flex'; if (data.mode === 'auto') {
document.getElementById('modeBannerText').textContent =
'Mode OTOMATIS aktif. Mode diatur dari monitoring/kantor.';
if (!sessionStorage.getItem('mode_banner_dismissed')) {
document.getElementById('modeBanner').style.display = 'flex';
}
} else {
document.getElementById('modeBanner').style.display = 'none';
} }
} catch (e) { } catch (e) {
console.error('Mode load error:', e); console.error('Mode load error:', e);
@@ -338,73 +504,277 @@
document.getElementById('modeBanner').style.display = 'none'; document.getElementById('modeBanner').style.display = 'none';
} }
async function switchToManual() { function updateModeUI(mode) {
try { currentMode = mode;
const res = await fetch('/api/batch/mode', { const badge = document.getElementById('modeBadge');
method: 'POST', const panel = document.getElementById('doPanel');
headers: { 'Content-Type': 'application/json' }, const sub = document.getElementById('opSubtitle');
body: JSON.stringify({ mode: 'manual' }) if (badge) {
}); badge.textContent = 'Mode: ' + mode;
const data = await res.json(); badge.className = 'mode-badge ' + mode;
if (data.success) { }
dismissModeBanner(); if (panel) panel.classList.toggle('visible', mode === 'do_manual');
} else { if (mode === 'do_manual') {
alert('Gagal alih mode: ' + (data.error || 'Terjadi kesalahan')); if (sub) sub.textContent = 'Foto DO → review → mulai batch';
} loadStagedDos();
} catch (e) { loadDoSettings();
alert('Gagal terhubung ke server'); } else if (mode === 'manual') {
if (sub) sub.textContent = 'Tekan tombol saat proses muat truk dimulai dan selesai';
} else {
if (sub) sub.textContent = 'Mode otomatis — batch dikontrol AI truk';
} }
} }
// --- Model mode selector --- async function loadDoSettings() {
try {
const res = await fetch('/api/do/settings');
const data = await res.json();
if (!data.success) return;
settings = data;
const sel = document.getElementById('ocrEngineSelect');
if (sel && data.ocr_engine) sel.value = data.ocr_engine;
const hint = document.getElementById('ocrEngineHint');
if (hint) hint.textContent = 'engine: ' + (data.ocr_engine || 'tesseract');
const badge = document.getElementById('doEngineBadge');
if (badge) badge.textContent = (data.ocr_engine || 'tesseract');
const chk = document.getElementById('requirePlateChk');
const row = document.getElementById('doRequirePlateRow');
if (chk) chk.checked = !!data.require_plate;
if (row) row.style.display = data.editable ? 'flex' : 'none';
} 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('Gagal set OCR: ' + (data.error || res.status));
loadDoSettings();
return;
}
loadDoSettings();
} catch (e) {
alert('Gagal terhubung ke server');
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 (403 = office only)');
loadDoSettings();
} else {
loadDoSettings();
loadStagedDos();
}
} catch (e) { alert('Gagal terhubung ke server'); loadDoSettings(); }
}
async function loadModelModes() { async function loadModelModes() {
try { try {
const res = await fetch('/api/model-modes'); const res = await fetch('/api/model-modes');
const data = await res.json(); const data = await res.json();
if (!data.success) return; if (!data.success) return;
const sel = document.getElementById('modelModeSelect'); const el = document.getElementById('opModelMode');
data.modes.forEach(m => { if (el) el.textContent = data.active || '--';
const opt = document.createElement('option');
opt.value = m.id;
opt.textContent = m.id + ' — ' + m.description;
sel.appendChild(opt);
});
} catch (e) { } catch (e) {
console.error('Model modes load error:', e); console.error('Model modes load error:', e);
} }
} }
async function setModelMode(mode) { async function uploadDoPhotos(fileList) {
if (!mode) return; if (!fileList || !fileList.length) return;
if (!confirm('Ganti mode model ke ' + mode + '? Berlaku setelah service karung-counter direstart.')) { const fd = new FormData();
document.getElementById('modelModeSelect').value = ''; for (const f of fileList) fd.append('photos', f);
return;
}
try { try {
const res = await fetch('/api/batch/mode', { const res = await fetch('/api/do/upload', { method: 'POST', body: fd });
method: 'POST', const data = await res.json();
if (!data.success) {
alert('Upload gagal: ' + (data.error || res.status));
return;
}
await loadStagedDos();
await loadDoSettings();
} catch (e) {
alert('Upload gagal: tidak terhubung ke server');
} finally {
document.getElementById('doPhotoInput').value = '';
}
}
async function loadStagedDos() {
if (currentMode !== 'do_manual') return;
try {
const res = await fetch('/api/do/staged');
const data = await res.json();
if (!data.success) return;
stagedDos = data.items || [];
renderDoPanel();
} catch (e) { console.error(e); }
}
function renderDoPanel() {
const strip = document.getElementById('doStrip');
const cards = document.getElementById('doCards');
const group = document.getElementById('doGroupPreview');
const warn = document.getElementById('doWarn');
if (!strip) return;
strip.innerHTML = '';
cards.innerHTML = '';
stagedDos.forEach(d => {
const th = document.createElement('div');
th.className = 'do-thumb';
th.innerHTML = `<img src="${d.photo_url}" alt=""><div class="cap">${d.no_do || 'DO?'}</div>`;
strip.appendChild(th);
const card = document.createElement('div');
card.className = 'do-card';
card.innerHTML = `
<div class="full"><label>No. DO</label>
<input data-id="${d.id}" data-k="no_do" value="${escapeHtml(d.no_do || '')}"></div>
<div><label>Plat</label>
<input data-id="${d.id}" data-k="plate" value="${escapeHtml(d.plate || '')}"></div>
<div><label>Ekspektasi karung</label>
<input data-id="${d.id}" data-k="expected_sack" type="number" min="0" value="${d.expected_sack || 0}"></div>
<div><label>Ekspektasi box</label>
<input data-id="${d.id}" data-k="expected_box" type="number" min="0" value="${d.expected_box || 0}"></div>
<div class="row-actions">
<button class="btn btn-primary btn-sm" data-act="save" data-id="${d.id}">Simpan</button>
<button class="btn btn-secondary btn-sm" data-act="del" data-id="${d.id}">Hapus</button>
<span style="font-size:11px;color:${d.ocr_ok ? 'var(--accent-green)' : 'var(--accent-orange)'};">
${d.ocr_ok ? 'OCR ok' : ('OCR: ' + (d.ocr_error || 'manual'))}
</span>
</div>`;
cards.appendChild(card);
});
cards.querySelectorAll('button[data-act="save"]').forEach(btn => {
btn.onclick = () => saveDo(parseInt(btn.dataset.id, 10));
});
cards.querySelectorAll('button[data-act="del"]').forEach(btn => {
btn.onclick = () => deleteDo(parseInt(btn.dataset.id, 10));
});
// group preview by plate
const byPlate = {};
stagedDos.forEach(d => {
const p = (d.plate || '').trim().toUpperCase() || 'tanpa plat';
if (!byPlate[p]) byPlate[p] = { n: 0, sack: 0, box: 0, nos: [] };
byPlate[p].n++;
byPlate[p].sack += d.expected_sack || 0;
byPlate[p].box += d.expected_box || 0;
if (d.no_do) byPlate[p].nos.push(d.no_do);
});
group.innerHTML = Object.entries(byPlate).map(([p, g]) =>
`<span class="do-chip">${escapeHtml(p)} · ${g.n} DO · ${g.sack} karung · ${g.box} box</span>`
).join('');
const plates = Object.keys(byPlate).filter(p => p !== 'tanpa plat');
let w = '';
if (plates.length > 1) w = 'Plat nomor berbeda — start diblokir.';
if (settings.require_do && stagedDos.length === 0 && currentMode === 'do_manual')
w = 'Butuh minimal satu foto DO sebelum mulai batch.';
if (warn) {
warn.textContent = w;
warn.style.display = w ? 'block' : 'none';
}
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, c => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
})[c]);
}
async function saveDo(id) {
const fields = {};
document.querySelectorAll(`.do-card input[data-id="${id}"]`).forEach(inp => {
fields[inp.dataset.k] = inp.dataset.k.startsWith('expected')
? parseInt(inp.value || '0', 10) : inp.value;
});
try {
const res = await fetch('/api/do/' + id, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model_mode: mode }) body: JSON.stringify(fields)
}); });
const data = await res.json(); const data = await res.json();
if (data.success) { if (!data.success) alert(data.error || 'Gagal simpan');
document.getElementById('opModelMode').textContent = data.model_mode; await loadStagedDos();
alert('Mode model tersimpan: ' + data.model_mode + '. Restart service untuk berlaku.'); } catch (e) { alert('Gagal simpan'); }
} else { }
alert('Gagal ganti mode: ' + (data.error || 'Terjadi kesalahan'));
} async function deleteDo(id) {
} catch (e) { if (!confirm('Hapus DO ini?')) return;
alert('Gagal terhubung ke server'); try {
} finally { const res = await fetch('/api/do/' + id, { method: 'DELETE' });
document.getElementById('modelModeSelect').value = ''; const data = await res.json();
} if (!data.success) alert(data.error || 'Gagal hapus');
await loadStagedDos();
} catch (e) { alert('Gagal hapus'); }
}
async function fetchStagedForStart() {
const res = await fetch('/api/do/staged');
const data = await res.json();
return (data && data.items) || [];
} }
function confirmStartBatch() { function confirmStartBatch() {
const gate = document.getElementById('startGateList');
const desc = document.getElementById('startModalDesc');
if (currentMode === 'do_manual') {
fetchStagedForStart().then(items => {
stagedDos = items;
const problems = [];
if (settings.require_do && items.length === 0) problems.push('Butuh foto DO.');
if (items.some(d => !(d.no_do || '').trim())) problems.push('Ada DO tanpa No. DO.');
if (settings.require_plate && items.some(d => !(d.plate || '').trim()))
problems.push('Ada DO tanpa plat.');
const plates = new Set(items.map(d => (d.plate || '').trim().toUpperCase()).filter(Boolean));
if (plates.size > 1) problems.push('Plat DO berbeda.');
if (problems.length) {
gate.style.display = 'block';
gate.innerHTML = problems.map(p => '• ' + escapeHtml(p)).join('<br>');
document.getElementById('modalStart').style.display = 'flex';
return;
}
gate.style.display = 'none';
desc.textContent = items.length + ' DO akan disiapkan untuk batch ini.';
document.getElementById('modalStart').style.display = 'flex';
renderDoPanel();
}).catch(() => {
document.getElementById('modalStart').style.display = 'flex';
});
return;
}
gate.style.display = 'none';
desc.textContent = 'Pastikan truk sudah siap di posisi pemuatan karung.';
document.getElementById('modalStart').style.display = 'flex'; document.getElementById('modalStart').style.display = 'flex';
} }
function confirmStopBatch() { async function confirmStopBatch() {
document.getElementById('zoneBusyWarn').style.display = 'none';
document.getElementById('btnForceStop').style.display = 'none';
try {
const res = await fetch('/api/batch/stop-preview');
const data = await res.json();
if (data.success && data.zone_busy) {
document.getElementById('zoneBusyWarn').style.display = 'block';
document.getElementById('btnForceStop').style.display = 'flex';
}
} catch (e) { /* soft */ }
document.getElementById('modalStop').style.display = 'flex'; document.getElementById('modalStop').style.display = 'flex';
} }
@@ -413,12 +783,21 @@
const btn = document.getElementById('btnStartBatch'); const btn = document.getElementById('btnStartBatch');
btn.classList.add('btn-disabled'); btn.classList.add('btn-disabled');
btn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Memulai...'; btn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Memulai...';
try { try {
const res = await fetch('/api/batch/start', { method: 'POST' }); let body = null;
if (currentMode === 'do_manual') {
const items = await fetchStagedForStart();
body = JSON.stringify({ do_ids: items.map(d => d.id) });
}
const res = await fetch('/api/batch/start', {
method: 'POST',
headers: body ? { 'Content-Type': 'application/json' } : {},
body: body
});
const data = await res.json(); const data = await res.json();
if (data.success) { if (data.success) {
await pollStatus(); await pollStatus();
await loadStagedDos();
} else { } else {
alert('Gagal memulai batch: ' + (data.error || 'Terjadi kesalahan')); alert('Gagal memulai batch: ' + (data.error || 'Terjadi kesalahan'));
} }
@@ -430,17 +809,21 @@
} }
} }
async function executeStopBatch() { async function executeStopBatch(force) {
closeModal('modalStop'); closeModal('modalStop');
const btn = document.getElementById('btnStopBatch'); const btn = document.getElementById('btnStopBatch');
btn.classList.add('btn-disabled'); btn.classList.add('btn-disabled');
btn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Mengakhiri...'; btn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Mengakhiri...';
try { try {
const res = await fetch('/api/batch/stop', { method: 'POST' }); const res = await fetch('/api/batch/stop', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ force: !!force })
});
const data = await res.json(); const data = await res.json();
if (data.success) { if (data.success) {
await pollStatus(); await pollStatus();
await loadStagedDos();
} else { } else {
alert('Gagal mengakhiri batch: ' + (data.error || 'Terjadi kesalahan')); alert('Gagal mengakhiri batch: ' + (data.error || 'Terjadi kesalahan'));
} }
@@ -456,7 +839,7 @@
try { try {
const res = await fetch('/api/current-batch'); const res = await fetch('/api/current-batch');
const data = await res.json(); const data = await res.json();
const badge = document.getElementById('statusBadge'); const badge = document.getElementById('statusBadge');
const icon = document.getElementById('statusIcon'); const icon = document.getElementById('statusIcon');
const text = document.getElementById('statusText'); const text = document.getElementById('statusText');
@@ -469,9 +852,27 @@
const btnStop = document.getElementById('btnStopBatch'); const btnStop = document.getElementById('btnStopBatch');
if (data.model_mode) modelMode.textContent = data.model_mode; if (data.model_mode) modelMode.textContent = data.model_mode;
if (data.mode) updateModeUI(data.mode);
sackCount.textContent = data.count || 0; sackCount.textContent = data.count || 0;
boxCount.textContent = data.box_count || 0; boxCount.textContent = data.box_count || 0;
const netSack = document.getElementById('opNetSack');
const netBox = document.getElementById('opNetBox');
const netSackItem = document.getElementById('opNetSackItem');
const netBoxItem = document.getElementById('opNetBoxItem');
const plateItem = document.getElementById('opPlateItem');
const plateDos = document.getElementById('opPlateDos');
const showNet = currentMode === 'do_manual' && data.success && data.batch_number;
if (netSackItem) netSackItem.style.display = showNet ? 'block' : 'none';
if (netBoxItem) netBoxItem.style.display = showNet ? 'block' : 'none';
if (plateItem) plateItem.style.display = showNet ? 'block' : 'none';
if (showNet) {
netSack.textContent = `${data.net_sack ?? data.count} / ${data.expected_sack ?? '-'}`;
netBox.textContent = `${data.net_box ?? data.box_count} / ${data.expected_box ?? '-'}`;
plateDos.textContent = (data.plate || 'tanpa plat') +
(data.do_numbers && data.do_numbers.length ? ' · ' + data.do_numbers.join(', ') : '');
}
if (data.success && data.batch_number) { if (data.success && data.batch_number) {
isBatchActive = true; isBatchActive = true;
badge.className = 'status-badge-box status-active'; badge.className = 'status-badge-box status-active';
@@ -506,6 +907,8 @@
} }
setInterval(pollStatus, 2000); setInterval(pollStatus, 2000);
setInterval(loadModeBanner, 5000);
setInterval(() => { if (currentMode === 'do_manual') { loadStagedDos(); loadDoSettings(); } }, 4000);
pollStatus(); pollStatus();
loadModeBanner(); loadModeBanner();
loadModelModes(); loadModelModes();
+35
View File
@@ -39,6 +39,41 @@ def test_repo_config_loads_and_validates(repo_config_path):
assert dp.min_bbox_area >= 0 assert dp.min_bbox_area >= 0
def test_do_block_and_batch_default_mode(repo_config_path):
cfg = load_config(repo_config_path)
assert cfg.batch.default_mode == "auto"
assert cfg.do.enabled is True
assert cfg.do.require_do is True
assert cfg.do.require_plate is False
assert cfg.do.retention_days == 7
assert cfg.do.photo_dir == "do_photos"
assert cfg.do.max_photos_per_batch == 8
assert cfg.do.zone_warn_seconds == 3
assert cfg.do.ocr.engine == "tesseract"
def test_do_block_missing_uses_defaults(tmp_path):
p = tmp_path / "config.yaml"
p.write_text("models:\n active_mode: C\n")
# minimal — will fail validate without modes; copy repo and strip do
import shutil
shutil.copy(repo_config_path_holder(), p)
raw = yaml.safe_load(p.read_text())
del raw["do"]
raw["batch"].pop("default_mode", None)
p.write_text(yaml.safe_dump(raw))
cfg = load_config(p)
assert cfg.batch.default_mode == "auto"
assert cfg.do.enabled is True
assert cfg.do.ocr.engine == "tesseract"
assert cfg.do.retention_days == 7
def repo_config_path_holder():
import os
return os.path.join(os.path.dirname(os.path.dirname(__file__)), "config.yaml")
def test_counting_knobs_match_production_defaults(repo_config_path): def test_counting_knobs_match_production_defaults(repo_config_path):
"""All live counting knobs in predict.py must come from config.yaml.""" """All live counting knobs in predict.py must come from config.yaml."""
cfg = load_config(repo_config_path) cfg = load_config(repo_config_path)
+283
View File
@@ -0,0 +1,283 @@
"""Smoke tests for DO-gated manual batch helpers (src/do_batch.py). Stdlib only."""
import json
from datetime import datetime, timedelta
import pytest
from src.do_batch import (
BATCH_MODES,
OCR_ENGINES,
classify_unit_token,
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,
)
from src.do_ocr import parse_fields_from_text
def test_normalize_batch_mode_defaults_auto():
assert normalize_batch_mode(None) == "auto"
assert normalize_batch_mode("bogus") == "auto"
assert normalize_batch_mode("do_manual") == "do_manual"
assert normalize_batch_mode("MANUAL") == "manual"
assert normalize_batch_mode("auto", "manual") == "auto"
for m in BATCH_MODES:
assert is_valid_batch_mode(m)
assert not is_valid_batch_mode("DO_ONLY")
def test_net_counts_and_discard():
assert net_counts(10, 2, 5, 1) == (8, 4)
assert should_discard_batch(0, 0) is True
assert should_discard_batch(1, 0) is False
assert should_discard_batch(0, 1) is False
assert should_discard_batch(-1, 0) is False
def test_classify_unit_tokens():
assert classify_unit_token("KRG") == "sack"
assert classify_unit_token("karung") == "sack"
assert classify_unit_token("DUS") == "box"
assert classify_unit_token("kardus") == "box"
assert classify_unit_token("BOX") == "box"
assert classify_unit_token("CTN") == "box"
assert classify_unit_token("kg") == "unknown"
def test_group_dos_same_plate():
dos = [
{"no_do": "DO-1", "plate": "b 1234 xyz"},
{"no_do": "DO-2", "plate": "B 1234 XYZ"},
]
plate, out = group_dos_by_plate(dos)
assert plate == "B 1234 XYZ"
assert [d["no_do"] for d in out] == ["DO-1", "DO-2"]
def test_group_dos_mixed_plates_raises():
dos = [{"no_do": "A", "plate": "B 1"}, {"no_do": "B", "plate": "B 2"}]
with pytest.raises(ValueError, match="mixed_plates"):
group_dos_by_plate(dos)
def test_group_dos_empty_plates_ok():
dos = [{"no_do": "A", "plate": ""}, {"no_do": "B", "plate": ""}]
plate, out = group_dos_by_plate(dos)
assert plate == ""
assert len(out) == 2
def test_start_gate_errors_matrix():
good = {"no_do": "DO-1", "plate": "B 1234 XYZ"}
# auto wrong mode
assert "wrong_mode" in start_gate_errors("auto", [good])
assert "wrong_mode" in start_gate_errors("manual", [good])
# empty when do_manual + ok
assert start_gate_errors("do_manual", [good]) == []
# missing DO
assert "missing_do" in start_gate_errors("do_manual", [], require_do=True)
# missing no_do
assert "missing_no_do" in start_gate_errors(
"do_manual", [{"no_do": "", "plate": "B 1"}])
# plate optional: empty plate ok
assert start_gate_errors(
"do_manual", [{"no_do": "D", "plate": ""}], require_plate=False) == []
# plate required
assert "missing_plate" in start_gate_errors(
"do_manual", [{"no_do": "D", "plate": ""}], require_plate=True)
# mixed
assert "mixed_plates" in start_gate_errors(
"do_manual",
[{"no_do": "A", "plate": "B 1"}, {"no_do": "B", "plate": "B 2"}],
)
# batch active
assert "batch_active" in start_gate_errors(
"do_manual", [good], batch_active=True)
def test_retention_cutoff_and_purge():
today = datetime(2026, 9, 24)
cutoff = retention_cutoff_date(7, today)
assert cutoff == "2026-09-17"
assert should_purge_counting_date("2026-09-16", cutoff) is True
assert should_purge_counting_date("2026-09-17", cutoff) is False # equal kept
assert should_purge_counting_date("2026-09-18", cutoff) is False
def test_ocr_engine_validation():
for e in OCR_ENGINES:
assert is_valid_ocr_engine(e)
assert not is_valid_ocr_engine("easyocr")
def test_empty_do_settings_defaults():
s = empty_do_settings({"require_plate": False, "require_do": True, "ocr_engine": "tesseract"})
assert s["ocr_engine"] == "tesseract"
assert s["require_do"] is True
bad = empty_do_settings({"ocr_engine": "nope"})
assert bad["ocr_engine"] == "tesseract"
def test_parse_fields_from_text():
text = "No. DO: DO-001\nPlat: B 1234 XYZ\n80 KRG\n10 DUS\n"
fields = parse_fields_from_text(text)
assert fields["no_do"] == "DO-001"
assert fields["plate"].startswith("B 1234")
assert fields["expected_sack"] == 80
assert fields["expected_box"] == 10
def test_extract_do_fields_none_engine(tmp_path):
from src.do_ocr import extract_do_fields
# no real image needed for none
out = extract_do_fields(str(tmp_path / "x.jpg"), "none")
assert out["ocr_ok"] is False
assert out["ocr_error"] == "disabled"
assert out["engine"] == "none"
# ---------------------------------------------------------------------------
# Flask API auth tests — skipped when flask/openpyxl not installed (CI)
# ---------------------------------------------------------------------------
@pytest.fixture()
def dash_client(tmp_path, monkeypatch):
pytest.importorskip("flask")
pytest.importorskip("openpyxl")
import counter_dashboard as cd
# isolate state files into tmp
mode_path = tmp_path / "batch_mode.json"
state_path = tmp_path / "current_batch.json"
settings_path = tmp_path / "do_settings.json"
monkeypatch.setattr(cd, "BATCH_MODE_PATH", str(mode_path))
monkeypatch.setattr(cd, "CURRENT_BATCH_PATH", str(state_path))
monkeypatch.setattr(cd, "DO_SETTINGS_PATH", str(settings_path))
client = cd.app.test_client()
return client, mode_path, state_path, settings_path
def _office(client, **kw):
return client.post("/api/batch/mode", json=kw,
headers={"Host": f"localhost:{cd_office_port()}"})
def cd_office_port():
import counter_dashboard as cd
return cd.OFFICE_PORT
def test_mode_post_operator_403(dash_client):
client, mode_path, _, _ = dash_client
res = client.post("/api/batch/mode", json={"mode": "do_manual"},
headers={"Host": "localhost:5000"})
assert res.status_code == 403
def test_mode_post_office_200_and_invalid_400(dash_client):
client, mode_path, _, _ = dash_client
office = {"Host": f"localhost:{cd_office_port()}"}
res = client.post("/api/batch/mode", json={"mode": "do_manual"}, headers=office)
assert res.status_code == 200
assert res.get_json()["mode"] == "do_manual"
assert mode_path.exists()
assert json.loads(mode_path.read_text())["mode"] == "do_manual"
res = client.post("/api/batch/mode", json={"mode": "nope"}, headers=office)
assert res.status_code == 400
def test_mode_post_409_when_batch_active(dash_client):
client, _, state_path, _ = dash_client
office = {"Host": f"localhost:{cd_office_port()}"}
state_path.write_text(json.dumps({
"counting_date": "2026-09-24", "batch_number": 1,
"count": 1, "start_time": "x",
}))
res = client.post("/api/batch/mode", json={"mode": "auto"}, headers=office)
assert res.status_code == 409
def test_model_mode_post_operator_403(dash_client):
client, _, _, _ = dash_client
res = client.post("/api/batch/mode", json={"model_mode": "B"},
headers={"Host": "localhost:5000"})
assert res.status_code == 403
def test_ocr_engine_post_both_ports(dash_client):
client, _, _, settings_path = dash_client
res = client.post("/api/do/settings", json={"ocr_engine": "paddle"},
headers={"Host": "localhost:5000"})
assert res.status_code == 200
assert res.get_json()["ocr_engine"] == "paddle"
res = client.post("/api/do/settings", json={"ocr_engine": "tesseract"},
headers={"Host": f"localhost:{cd_office_port()}"})
assert res.status_code == 200
res = client.post("/api/do/settings", json={"ocr_engine": "bogus"},
headers={"Host": "localhost:5000"})
assert res.status_code == 400
def test_require_plate_post_operator_403(dash_client):
client, _, _, _ = dash_client
res = client.post("/api/do/settings", json={"require_plate": True},
headers={"Host": "localhost:5000"})
assert res.status_code == 403
office = {"Host": f"localhost:{cd_office_port()}"}
res = client.post("/api/do/settings", json={"require_plate": True}, headers=office)
assert res.status_code == 200
assert res.get_json()["require_plate"] is True
def test_start_stop_auto_409(dash_client):
client, mode_path, _, _ = dash_client
office = {"Host": f"localhost:{cd_office_port()}"}
client.post("/api/batch/mode", json={"mode": "auto"}, headers=office)
res = client.post("/api/batch/start", headers={"Host": "localhost:5000"})
assert res.status_code == 409
res = client.post("/api/batch/stop", headers={"Host": "localhost:5000"})
assert res.status_code == 409
def test_stop_discard_both_nets_zero(dash_client):
client, mode_path, state_path, _ = dash_client
office = {"Host": f"localhost:{cd_office_port()}"}
client.post("/api/batch/mode", json={"mode": "manual"}, headers=office)
state_path.write_text(json.dumps({
"counting_date": "2026-09-24", "batch_number": 99,
"count": 0, "box_count": 0, "box_unloading": 0, "unloading": 0,
"start_time": "2026-09-24T00:00:00",
"model_mode": "C",
}))
res = client.post("/api/batch/stop", headers={"Host": "localhost:5000"})
body = res.get_json()
assert res.status_code == 200
assert body["discarded"] is True
assert body["net_sack"] == 0 and body["net_box"] == 0
assert not state_path.exists()
def test_stop_keeps_box_only_batch(dash_client):
client, mode_path, state_path, _ = dash_client
office = {"Host": f"localhost:{cd_office_port()}"}
client.post("/api/batch/mode", json={"mode": "manual"}, headers=office)
state_path.write_text(json.dumps({
"counting_date": "2026-09-24", "batch_number": 98,
"count": 0, "box_count": 5, "box_unloading": 1, "unloading": 0,
"start_time": "2026-09-24T00:00:00",
"model_mode": "C",
}))
res = client.post("/api/batch/stop", headers={"Host": "localhost:5000"})
body = res.get_json()
assert res.status_code == 200
assert body["discarded"] is False
assert body["net_box"] == 4