#!/usr/bin/env python3 """ Edge Jetson production counter dashboard. Reads jetson_counter.db + current_batch.json from jetson-counter stack. Default port 5000 (replaces frigate-counter dashboard role). """ import json import os import sqlite3 import time from io import BytesIO from datetime import datetime, timedelta from openpyxl import Workbook from openpyxl.styles import Font, Alignment, PatternFill, Border, Side from flask import Flask, render_template, jsonify, request, Response from werkzeug.serving import WSGIRequestHandler from dotenv import load_dotenv load_dotenv() # Unified config (config.yaml canonical; .env supplies secrets/deployment-only # values: RTSP_URL, dashboard host/ports/secret/site). Env vars still override # file paths when explicitly set (backward compatible with old deployments). from src.config_loader import load_config, set_active_mode _CONFIG_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.yaml") CFG = load_config(_CONFIG_PATH) def _env_or(cfg_value, *env_keys): """Backward compat: explicit env var wins over config.yaml for file paths.""" for k in env_keys: v = os.getenv(k) if v: return v return cfg_value app = Flask(__name__, template_folder="templates") app.config["SECRET_KEY"] = CFG.dashboard.secret_key if os.name == "nt": _DEFAULT_DIR = "d:/Belajar/menghitung karung" DB_PATH = f"{_DEFAULT_DIR}/jetson_counter.db" CURRENT_BATCH_PATH = f"{_DEFAULT_DIR}/current_batch.json" BATCH_MODE_PATH = f"{_DEFAULT_DIR}/batch_mode.json" LIVE_STREAM_FRAME_PATH = f"{_DEFAULT_DIR}/live_frame.jpg" LIVE_STATUS_FILE = f"{_DEFAULT_DIR}/live_status.json" else: _DEFAULT_DIR = CFG.output.dir DB_PATH = _env_or(os.path.join(_DEFAULT_DIR, CFG.output.db_name), "DB_PATH") CURRENT_BATCH_PATH = _env_or( os.path.join(_DEFAULT_DIR, CFG.output.state_file), "STATE_FILE", "CURRENT_BATCH_PATH") BATCH_MODE_PATH = _env_or( os.path.join(_DEFAULT_DIR, CFG.output.batch_mode_file), "BATCH_MODE_FILE") LIVE_STREAM_FRAME_PATH = _env_or(CFG.output.live_frame_path, "LIVE_STREAM_FRAME_PATH") LIVE_STATUS_FILE = _env_or(CFG.output.live_status_path, "LIVE_STATUS_FILE") CUTOFF_TIME = _env_or(CFG.batch.daily_cutoff_time, "CUTOFF_TIME", "DAILY_CUTOFF_TIME") SITE_NAME = CFG.dashboard.site_name DASHBOARD_PORT = CFG.dashboard.port DASHBOARD_HOST = CFG.dashboard.host FLASK_DEBUG = CFG.dashboard.debug from src.do_batch import ( empty_do_settings, group_dos_by_plate, is_valid_batch_mode, is_valid_ocr_engine, net_counts, normalize_batch_mode, OCR_ENGINES, retention_cutoff_date, should_discard_batch, should_purge_counting_date, start_gate_errors, ) # Runtime DO settings (require_plate/do + ocr_engine) — separate from config.yaml seed. if os.name == "nt": DO_SETTINGS_PATH = _env_or(f"{_DEFAULT_DIR}/do_settings.json", "DO_SETTINGS_PATH") DO_PHOTO_ROOT = os.path.join(_DEFAULT_DIR, CFG.do.photo_dir) else: DO_SETTINGS_PATH = _env_or(os.path.join(_DEFAULT_DIR, "do_settings.json"), "DO_SETTINGS_PATH") DO_PHOTO_ROOT = _env_or(os.path.join(CFG.output.dir, CFG.do.photo_dir), "DO_PHOTO_ROOT") @app.route("/api/live-video") def api_live_video(): if not os.path.isfile(LIVE_STREAM_FRAME_PATH): return jsonify({"success": False, "error": "Live stream frame not available yet"}), 503 def generate(): consecutive_fails = 0 MAX_FAILS = 30 while True: try: with open(LIVE_STREAM_FRAME_PATH, "rb") as f: jpeg = f.read() consecutive_fails = 0 yield (b"--frame\r\n" b"Content-Type: image/jpeg\r\n\r\n" + jpeg + b"\r\n") except FileNotFoundError: consecutive_fails += 1 if consecutive_fails >= MAX_FAILS: return time.sleep(1.0) continue except Exception: consecutive_fails += 1 if consecutive_fails >= MAX_FAILS: return time.sleep(0.5) continue time.sleep(0.05) return Response(generate(), mimetype="multipart/x-mixed-replace; boundary=frame") def _ensure_db(): conn = sqlite3.connect(DB_PATH) cur = conn.cursor() cur.execute( """ CREATE TABLE IF NOT EXISTS batches ( id INTEGER PRIMARY KEY AUTOINCREMENT, counting_date TEXT NOT NULL, batch_number INTEGER NOT NULL, camera_name TEXT NOT NULL, object_label TEXT NOT NULL, count INTEGER NOT NULL, start_time TEXT NOT NULL, end_time TEXT NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, UNIQUE(counting_date, batch_number, camera_name, object_label) ) """ ) cur.execute( """ CREATE TABLE IF NOT EXISTS daily_summaries ( id INTEGER PRIMARY KEY AUTOINCREMENT, counting_date TEXT NOT NULL, camera_name TEXT NOT NULL, object_label TEXT NOT NULL, total_count INTEGER NOT NULL DEFAULT 0, total_batches INTEGER NOT NULL DEFAULT 0, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, UNIQUE(counting_date, camera_name, object_label) ) """ ) # Additive migration for box counting + model mode (idempotent). cur.execute("PRAGMA table_info(batches)") _cols = {r[1] for r in cur.fetchall()} for _col, _typ in ( ("box_loading", "INTEGER NOT NULL DEFAULT 0"), ("box_unloading", "INTEGER NOT NULL DEFAULT 0"), ("model_mode", "TEXT NOT NULL DEFAULT 'A'"), ("plate", "TEXT NOT NULL DEFAULT ''"), ("do_numbers", "TEXT NOT NULL DEFAULT '[]'"), ("expected_sack", "INTEGER NOT NULL DEFAULT 0"), ("expected_box", "INTEGER NOT NULL DEFAULT 0"), ("net_sack", "INTEGER NOT NULL DEFAULT 0"), ("net_box", "INTEGER NOT NULL DEFAULT 0"), ): if _col not in _cols: cur.execute(f"ALTER TABLE batches ADD COLUMN {_col} {_typ}") # Delivery orders (DO-gated manual batch). cur.execute( """ CREATE TABLE IF NOT EXISTS delivery_orders ( id INTEGER PRIMARY KEY AUTOINCREMENT, counting_date TEXT NOT NULL, photo_path TEXT NOT NULL, no_do TEXT NOT NULL DEFAULT '', plate TEXT NOT NULL DEFAULT '', expected_sack INTEGER NOT NULL DEFAULT 0, expected_box INTEGER NOT NULL DEFAULT 0, ocr_raw TEXT, status TEXT NOT NULL DEFAULT 'draft', batch_id INTEGER, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) """ ) cur.execute( "CREATE INDEX IF NOT EXISTS idx_do_date_status " "ON delivery_orders(counting_date, status)" ) conn.commit() conn.close() _ensure_db() def get_db(): conn = sqlite3.connect(DB_PATH) conn.row_factory = sqlite3.Row return conn def _batches_box_select(): """Box/model/DO select exprs — real columns when migrated, literals otherwise.""" try: conn = get_db() cur = conn.cursor() cur.execute("PRAGMA table_info(batches)") cols = {r[1] for r in cur.fetchall()} conn.close() except Exception: cols = set() if {"box_loading", "box_unloading", "model_mode"} <= cols: expr = "box_loading, box_unloading, model_mode" else: expr = "0 AS box_loading, 0 AS box_unloading, 'A' AS model_mode" do_cols = { "plate": "'' AS plate", "do_numbers": "'[]' AS do_numbers", "expected_sack": "0 AS expected_sack", "expected_box": "0 AS expected_box", "net_sack": "0 AS net_sack", "net_box": "0 AS net_box", } for name, lit in do_cols.items(): if name in cols: expr += f", {name}" else: expr += f", {lit}" return expr def get_counting_date(dt=None, cutoff_str=CUTOFF_TIME): if dt is None: dt = datetime.now() cutoff = datetime.strptime(cutoff_str, "%H:%M").time() if cutoff.hour == 0 and cutoff.minute == 0: return dt.date().isoformat() if dt.time() < cutoff: return (dt.date() - timedelta(days=1)).isoformat() return dt.date().isoformat() CAMERA_NAME = _env_or(CFG.camera.name, "CAMERA_NAME") OBJECT_LABEL = _env_or(CFG.camera.object_label, "OBJECT_LABEL") OFFICE_PORT = CFG.dashboard.office_port def is_office_request(): """Check if request comes from office port.""" server_port = request.environ.get("SERVER_PORT", str(DASHBOARD_PORT)) # Also check Host header if port is in Host (e.g. 192.168.192.96:5721) host_header = request.headers.get("Host", "") if f":{OFFICE_PORT}" in host_header or str(server_port) == str(OFFICE_PORT): return True return False def _batch_active() -> bool: try: if not os.path.exists(CURRENT_BATCH_PATH): return False with open(CURRENT_BATCH_PATH, "r") as f: curr = json.load(f) return bool(curr and curr.get("batch_number")) except Exception: return False def _read_do_settings() -> dict: """Effective DO settings: config.yaml seed + do_settings.json overrides.""" data = empty_do_settings({ "require_plate": CFG.do.require_plate, "require_do": CFG.do.require_do, "ocr_engine": CFG.do.ocr.engine, }) data["editable"] = is_office_request() if os.path.exists(DO_SETTINGS_PATH): try: with open(DO_SETTINGS_PATH, "r", encoding="utf-8") as f: stored = json.load(f) if "require_plate" in stored: data["require_plate"] = bool(stored["require_plate"]) if "require_do" in stored: data["require_do"] = bool(stored["require_do"]) if "ocr_engine" in stored and is_valid_ocr_engine(stored["ocr_engine"]): data["ocr_engine"] = str(stored["ocr_engine"]).lower() except Exception: pass return data def _write_do_settings(data: dict) -> None: os.makedirs(os.path.dirname(DO_SETTINGS_PATH) or ".", exist_ok=True) payload = { "require_plate": bool(data.get("require_plate", False)), "require_do": bool(data.get("require_do", True)), "ocr_engine": str(data.get("ocr_engine", "rapid")).lower(), "updated_at": datetime.now().isoformat(), } tmp = DO_SETTINGS_PATH + ".tmp" with open(tmp, "w", encoding="utf-8") as f: json.dump(payload, f, indent=2) os.replace(tmp, DO_SETTINGS_PATH) def _effective_ocr_engine() -> str: return _read_do_settings().get("ocr_engine", CFG.do.ocr.engine) @app.route("/") @app.route("/monitoring") def index(): if is_office_request(): return render_template("monitoring.html", site_name=SITE_NAME, show_nav=True) # Port 5000 (Operator) return render_template("operator.html", site_name=SITE_NAME, show_nav=False) @app.route("/operator") def operator_page(): return render_template("operator.html", site_name=SITE_NAME, show_nav=False) @app.route("/history") def history_page(): if not is_office_request(): return render_template("operator.html", site_name=SITE_NAME, show_nav=False) return render_template("history.html", site_name=SITE_NAME, show_nav=True) @app.route("/analytics") def analytics_page(): if not is_office_request(): return render_template("operator.html", site_name=SITE_NAME, show_nav=False) return render_template("analytics.html", site_name=SITE_NAME, show_nav=True) def get_next_batch_number(counting_date): try: conn = get_db() cur = conn.cursor() cur.execute( """ SELECT COALESCE(MAX(batch_number), 0) FROM batches WHERE counting_date = ? AND camera_name = ? AND object_label = ? """, (counting_date, CAMERA_NAME, OBJECT_LABEL), ) row = cur.fetchone() conn.close() return row[0] + 1 except Exception as e: print(f"[DB Error] Gagal mendapatkan batch_number: {e}") return 1 @app.route("/api/batch/start", methods=["POST"]) def api_batch_start(): try: mode = normalize_batch_mode( _read_batch_mode_file()["mode"], CFG.batch.default_mode) if mode == "auto": return jsonify({ "success": False, "reason": "wrong_mode", "error": "Mode otomatis — batch dibuka/ditutup oleh AI. " "Alihkan mode dari monitoring.", }), 409 if _batch_active(): try: with open(CURRENT_BATCH_PATH, "r") as f: curr = json.load(f) return jsonify({ "success": True, "message": "Batch already active", "batch_number": curr.get("batch_number"), "counting_date": curr.get("counting_date"), "start_time": curr.get("start_time"), }) except Exception: pass settings = _read_do_settings() plate = "" do_ids: list = [] do_numbers: list = [] expected_sack = 0 expected_box = 0 if mode == "do_manual": if not CFG.do.enabled: return jsonify({ "success": False, "reason": "do_disabled", "error": "DO flow disabled (do.enabled=false)", }), 400 req_data = request.get_json(silent=True) or request.form or {} try: raw_ids = req_data.get("do_ids", []) if isinstance(raw_ids, str): raw_ids = [x.strip() for x in raw_ids.split(",") if x.strip()] do_ids = [int(x) for x in raw_ids] except (TypeError, ValueError): return jsonify({ "success": False, "reason": "bad_do_ids", "error": "do_ids must be list of integers", }), 400 if not do_ids: return jsonify({ "success": False, "reason": "missing_do", "error": "Pilih minimal satu DO sebelum mulai batch.", }), 400 conn = get_db() cur = conn.cursor() placeholders = ",".join("?" * len(do_ids)) cur.execute( f"SELECT id, no_do, plate, expected_sack, expected_box, status " f"FROM delivery_orders WHERE id IN ({placeholders})", do_ids, ) rows = cur.fetchall() conn.close() found = {r["id"]: r for r in rows} missing = [i for i in do_ids if i not in found] if missing: return jsonify({ "success": False, "reason": "missing_do", "error": f"DO id tidak ditemukan: {missing}", }), 400 dos = [dict(found[i]) for i in do_ids] gate = start_gate_errors( mode, dos, do_enabled=CFG.do.enabled, require_do=settings.get("require_do", CFG.do.require_do), require_plate=settings.get("require_plate", CFG.do.require_plate), batch_active=_batch_active(), ) # batch_active already handled; strip if present from early return gate = [g for g in gate if g != "batch_active"] if gate: msg_map = { "wrong_mode": "Mode batch bukan do_manual.", "missing_do": "Butuh minimal satu DO.", "missing_no_do": "Semua DO wajib punya No. DO.", "missing_plate": "Semua DO wajib punya plat nomor.", "mixed_plates": "Plat nomor DO berbeda — satu batch = satu plat.", "do_disabled": "Alur DO dinonaktifkan.", } return jsonify({ "success": False, "reason": gate[0], "errors": gate, "error": msg_map.get(gate[0], "; ".join(gate)), }), 400 try: plate, dos = group_dos_by_plate(dos) except ValueError as ve: return jsonify({ "success": False, "reason": "mixed_plates", "error": str(ve), }), 400 do_numbers = [str(d.get("no_do") or "") for d in dos] expected_sack = sum(int(d.get("expected_sack") or 0) for d in dos) expected_box = sum(int(d.get("expected_box") or 0) for d in dos) counting_date = get_counting_date() batch_num = get_next_batch_number(counting_date) now_iso = datetime.now().isoformat() batch_state = { "counting_date": counting_date, "batch_number": batch_num, "count": 0, "box_count": 0, "box_unloading": 0, "unloading": 0, "model_mode": _read_batch_mode_file()["model_mode"], "start_time": now_iso, "last_detection_time": now_iso, "manual_control": True, "batch_mode": mode, "plate": plate, "do_ids": do_ids, "do_numbers": do_numbers, "expected_sack": expected_sack, "expected_box": expected_box, } os.makedirs(os.path.dirname(CURRENT_BATCH_PATH), exist_ok=True) tmp_file = f"{CURRENT_BATCH_PATH}.tmp" with open(tmp_file, "w", encoding="utf-8") as f: json.dump(batch_state, f, indent=2) os.replace(tmp_file, CURRENT_BATCH_PATH) if do_ids: try: conn = get_db() cur = conn.cursor() for i in do_ids: cur.execute( "UPDATE delivery_orders SET status='attached', " "updated_at=CURRENT_TIMESTAMP WHERE id=?", (i,), ) conn.commit() conn.close() except Exception: pass return jsonify({ "success": True, "message": f"Batch #{batch_num} started", "batch_number": batch_num, "counting_date": counting_date, "start_time": now_iso, "plate": plate, "do_numbers": do_numbers, "expected_sack": expected_sack, "expected_box": expected_box, }) except Exception as e: return jsonify({"success": False, "error": str(e)}), 500 @app.route("/api/batch/stop-preview") def api_batch_stop_preview(): """Zone activity hint for stop soft-warn (do_manual / manual).""" mode = normalize_batch_mode(_read_batch_mode_file()["mode"], CFG.batch.default_mode) active = _batch_active() sacks_in_zone = 0 boxes_in_zone = 0 last_det = None zone_busy = False try: if os.path.exists(CURRENT_BATCH_PATH): with open(CURRENT_BATCH_PATH, "r") as f: data = json.load(f) last_det = data.get("last_detection_time") sacks_in_zone = int(data.get("count") or 0) boxes_in_zone = int(data.get("box_count") or 0) if active and last_det and sacks_in_zone > 0: try: last = datetime.fromisoformat(last_det) age = (datetime.now() - last).total_seconds() zone_busy = age <= float(CFG.do.zone_warn_seconds) except Exception: zone_busy = False except Exception: pass return jsonify({ "success": True, "mode": mode, "active": active, "zone_busy": zone_busy, "sacks_in_zone": sacks_in_zone, "boxes_in_zone": boxes_in_zone, "zone_warn_seconds": CFG.do.zone_warn_seconds, }) @app.route("/api/batch/stop", methods=["POST"]) def api_batch_stop(): try: mode = normalize_batch_mode( _read_batch_mode_file()["mode"], CFG.batch.default_mode) if mode == "auto": return jsonify({ "success": False, "reason": "wrong_mode", "error": "Mode otomatis — stop manual tidak berlaku.", }), 409 if not os.path.exists(CURRENT_BATCH_PATH): return jsonify({"success": False, "error": "No active batch to stop"}), 400 with open(CURRENT_BATCH_PATH, "r") as f: curr = json.load(f) if not curr or not curr.get("batch_number"): return jsonify({"success": False, "error": "No active batch data"}), 400 counting_date = curr["counting_date"] batch_num = curr["batch_number"] final_count = curr.get("count", 0) box_final = curr.get("box_count", 0) box_unloading = curr.get("box_unloading", 0) unloading = curr.get("unloading", 0) model_mode = curr.get("model_mode", _read_batch_mode_file()["model_mode"]) start_time_iso = curr.get("start_time", datetime.now().isoformat()) end_time_iso = datetime.now().isoformat() plate = curr.get("plate", "") or "" do_ids = curr.get("do_ids") or [] do_numbers = curr.get("do_numbers") or [] expected_sack = int(curr.get("expected_sack", 0) or 0) expected_box = int(curr.get("expected_box", 0) or 0) net_sack, net_box = net_counts( final_count, unloading, box_final, box_unloading) discarded = should_discard_batch(final_count, box_final) if not discarded: conn = get_db() cur = conn.cursor() cur.execute( """ INSERT OR REPLACE INTO batches (counting_date, batch_number, camera_name, object_label, count, start_time, end_time) VALUES (?, ?, ?, ?, ?, ?, ?) """, (counting_date, batch_num, CAMERA_NAME, OBJECT_LABEL, final_count, start_time_iso, end_time_iso), ) try: cur.execute( """ UPDATE batches SET box_loading = ?, box_unloading = ?, model_mode = ?, plate = ?, do_numbers = ?, expected_sack = ?, expected_box = ?, net_sack = ?, net_box = ? WHERE counting_date = ? AND batch_number = ? AND camera_name = ? AND object_label = ? """, (box_final, box_unloading, model_mode, plate, json.dumps(do_numbers, ensure_ascii=False), expected_sack, expected_box, net_sack, net_box, counting_date, batch_num, CAMERA_NAME, OBJECT_LABEL), ) except Exception: pass if do_ids: try: cur.execute( "SELECT id FROM batches WHERE counting_date=? AND batch_number=? " "AND camera_name=? AND object_label=?", (counting_date, batch_num, CAMERA_NAME, OBJECT_LABEL)) brow = cur.fetchone() if brow: for do_id in do_ids: cur.execute( "UPDATE delivery_orders SET batch_id=?, status='attached', " "updated_at=CURRENT_TIMESTAMP WHERE id=?", (brow[0], do_id)) except Exception: pass cur.execute( """ SELECT SUM(count), COUNT(id) FROM batches WHERE counting_date = ? AND camera_name = ? AND object_label = ? """, (counting_date, CAMERA_NAME, OBJECT_LABEL), ) sum_row = cur.fetchone() tot_count = sum_row[0] if sum_row[0] is not None else 0 tot_batches = sum_row[1] if sum_row[1] is not None else 0 cur.execute( """ INSERT OR REPLACE INTO daily_summaries (counting_date, camera_name, object_label, total_count, total_batches, updated_at) VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP) """, (counting_date, CAMERA_NAME, OBJECT_LABEL, tot_count, tot_batches), ) conn.commit() conn.close() else: try: conn = get_db() cur = conn.cursor() for do_id in do_ids: cur.execute( "UPDATE delivery_orders SET status='staged', batch_id=NULL, " "updated_at=CURRENT_TIMESTAMP WHERE id=?", (do_id,), ) conn.commit() conn.close() except Exception: pass try: os.remove(CURRENT_BATCH_PATH) except Exception: pass return jsonify({ "success": True, "message": f"Batch #{batch_num} stopped" + ( " (discard: net 0)" if discarded else ""), "batch_number": batch_num, "final_count": final_count, "box_final_count": box_final, "net_sack": net_sack, "net_box": net_box, "discarded": discarded, "start_time": start_time_iso, "end_time": end_time_iso }) except Exception as e: return jsonify({"success": False, "error": str(e)}), 500 # Model modes are DATA in config.yaml models.modes — derived here so future # modes (E, F, ...) appear automatically. batch_mode.json keeps ONLY the # manual/auto batch "mode"; model_mode lives in config.yaml now. MODEL_MODE_CHOICES = tuple(CFG.models.modes.keys()) MODEL_MODE_DESCRIPTIONS = { m: preset.description for m, preset in CFG.models.modes.items() } def _active_model_mode() -> str: """Canonical model mode: config.yaml models.active_mode (validated).""" try: return load_config(_CONFIG_PATH).models.active_mode except Exception: return CFG.models.active_mode def _read_batch_mode_file(): default_mode = normalize_batch_mode(CFG.batch.default_mode, "auto") data = {"mode": default_mode, "model_mode": _active_model_mode()} if os.path.exists(BATCH_MODE_PATH): try: with open(BATCH_MODE_PATH, "r") as f: stored = json.load(f) data["mode"] = normalize_batch_mode(stored.get("mode"), default_mode) # Legacy model_mode in batch_mode.json is IGNORED (config.yaml wins). legacy = (stored.get("model_mode") or "").upper() if legacy and legacy != data["model_mode"]: print(f"[WARN] batch_mode.json model_mode={legacy!r} diabaikan — " f"config.yaml active_mode={data['model_mode']!r} yang berlaku.") except Exception: pass return data @app.route("/api/batch/mode", methods=["GET", "POST"]) def api_batch_mode(): if request.method == "POST": try: req_data = request.get_json(silent=True) or request.form or {} # Office-only writes for mode / model_mode (operator port → 403). has_mode = "mode" in req_data has_model = "model_mode" in req_data if (has_mode or has_model) and not is_office_request(): return jsonify({ "success": False, "error": "Mode/model_mode hanya bisa diubah dari port kantor", }), 403 stored = _read_batch_mode_file() if has_mode: mode = str(req_data.get("mode", "")).lower() if not is_valid_batch_mode(mode): return jsonify({ "success": False, "error": "Invalid mode. Use 'auto', 'do_manual' or 'manual'", }), 400 if _batch_active(): return jsonify({ "success": False, "reason": "batch_active", "error": "Batch sedang berjalan — hentikan dulu sebelum ganti mode.", }), 409 stored["mode"] = mode if has_model: mmode = str(req_data.get("model_mode", "")).upper() try: # Validates against config.yaml models.modes, then persists # atomically (tmp+replace, comments preserved). Manual # `karung-counter` restart still required to apply. set_active_mode(_CONFIG_PATH, mmode) except (ValueError, RuntimeError) as ve: return jsonify({ "success": False, "error": f"Invalid model_mode. Use one of {sorted(MODEL_MODE_CHOICES)}: {ve}", }), 400 stored["model_mode"] = mmode stored["updated_at"] = datetime.now().isoformat() # batch_mode.json keeps ONLY the batch flow mode now. batch_state = {"mode": stored["mode"], "updated_at": stored["updated_at"]} os.makedirs(os.path.dirname(BATCH_MODE_PATH) or ".", exist_ok=True) tmp_path = BATCH_MODE_PATH + ".tmp" with open(tmp_path, "w", encoding="utf-8") as f: json.dump(batch_state, f, indent=2) os.replace(tmp_path, BATCH_MODE_PATH) office = is_office_request() return jsonify({"success": True, "mode": stored["mode"], "model_mode": stored["model_mode"], "mode_editable": office, "model_mode_editable": office, "message": f"Batch mode={stored['mode']}, model_mode={stored['model_mode']} " f"(model_mode applies on next karung-counter restart)"}) except Exception as e: return jsonify({"success": False, "error": str(e)}), 500 # GET method stored = _read_batch_mode_file() office = is_office_request() return jsonify({"success": True, "mode": stored["mode"], "model_mode": stored["model_mode"], "mode_editable": office, "model_mode_editable": office}) @app.route("/api/model-modes") def api_model_modes(): stored = _read_batch_mode_file() office = is_office_request() return jsonify({ "success": True, "active": stored["model_mode"], "editable": office, "modes": [{"id": m, "description": d} for m, d in MODEL_MODE_DESCRIPTIONS.items()], }) @app.route("/api/do/settings", methods=["GET", "POST"]) def api_do_settings(): if request.method == "POST": try: req_data = request.get_json(silent=True) or request.form or {} current = _read_do_settings() office = is_office_request() wrote = False # require_plate / require_do / ocr_engine: office only gated_keys = ("require_plate", "require_do", "ocr_engine") if any(k in req_data for k in gated_keys): if not office: return jsonify({ "success": False, "error": "require_plate/require_do/ocr_engine hanya dari port kantor", }), 403 if "ocr_engine" in req_data: eng = str(req_data.get("ocr_engine", "")).lower() if not is_valid_ocr_engine(eng): return jsonify({ "success": False, "error": f"Invalid ocr_engine. Use one of {list(OCR_ENGINES)}", }), 400 current["ocr_engine"] = eng wrote = True if "require_plate" in req_data: current["require_plate"] = bool( str(req_data.get("require_plate")).lower() in ("1", "true", "yes", "on")) if "require_do" in req_data: current["require_do"] = bool( str(req_data.get("require_do")).lower() in ("1", "true", "yes", "on")) if any(k in req_data for k in ("require_plate", "require_do")): wrote = True if not wrote: return jsonify({"success": False, "error": "No known settings fields"}), 400 _write_do_settings(current) current = _read_do_settings() current["editable"] = office return jsonify({"success": True, **current}) except Exception as e: return jsonify({"success": False, "error": str(e)}), 500 data = _read_do_settings() return jsonify({"success": True, **data}) def _downscale_image(src_path: str, max_edge: int = 2048) -> str: """Downscale large phone photos before store/OCR. Returns path to use.""" try: from PIL import Image # type: ignore except ImportError: try: import cv2 # type: ignore except ImportError: return src_path img = cv2.imread(src_path) if img is None: return src_path h, w = img.shape[:2] edge = max(h, w) if edge <= max_edge: return src_path scale = max_edge / float(edge) img = cv2.resize(img, (int(w * scale), int(h * scale)), interpolation=cv2.INTER_AREA) cv2.imwrite(src_path, img, [int(cv2.IMWRITE_JPEG_QUALITY), 88]) return src_path try: img = Image.open(src_path) edge = max(img.size) if edge <= max_edge: return src_path scale = max_edge / float(edge) img = img.resize((int(img.width * scale), int(img.height * scale)), Image.LANCZOS) if img.mode not in ("RGB", "L"): img = img.convert("RGB") img.save(src_path, "JPEG", quality=88) return src_path except Exception: return src_path @app.route("/api/do/upload", methods=["POST"]) def api_do_upload(): """Smartphone multi-photo upload. Only when mode == do_manual.""" try: mode = normalize_batch_mode( _read_batch_mode_file()["mode"], CFG.batch.default_mode) if mode != "do_manual": return jsonify({ "success": False, "reason": "wrong_mode", "error": "Upload DO hanya di mode do_manual.", }), 409 files = request.files.getlist("photos") files = [f for f in (files or []) if f and f.filename] if not files: return jsonify({"success": False, "error": "No photos uploaded"}), 400 if len(files) > int(CFG.do.max_photos_per_batch): return jsonify({ "success": False, "error": f"Maksimal {CFG.do.max_photos_per_batch} foto per batch", }), 400 allowed = {".jpg", ".jpeg", ".png", ".webp"} counting_date = get_counting_date() day_dir = os.path.join(DO_PHOTO_ROOT, counting_date) os.makedirs(day_dir, exist_ok=True) engine = _effective_ocr_engine() try: from src.do_ocr import extract_do_fields except Exception: extract_do_fields = None items = [] conn = get_db() cur = conn.cursor() for f in files: ext = os.path.splitext(f.filename)[1].lower() or ".jpg" if ext not in allowed: continue import uuid fname = f"do_{uuid.uuid4().hex}{ext}" rel = os.path.join(CFG.do.photo_dir, counting_date, fname) abs_path = os.path.join(day_dir, fname) f.save(abs_path) _downscale_image(abs_path, max_edge=2048) ocr = { "no_do": "", "plate": "", "expected_sack": 0, "expected_box": 0, "ocr_text": "", "engine": engine, "ocr_ok": False, "ocr_error": "skipped", } if extract_do_fields is not None and engine != "none": try: ocr = extract_do_fields(abs_path, engine) except Exception as e: ocr["ocr_error"] = str(e) elif engine == "none": ocr["ocr_error"] = "disabled" ocr_raw = json.dumps(ocr, ensure_ascii=False) cur.execute( """ INSERT INTO delivery_orders (counting_date, photo_path, no_do, plate, expected_sack, expected_box, ocr_raw, status) VALUES (?, ?, ?, ?, ?, ?, ?, 'draft') """, (counting_date, rel, ocr.get("no_do", "") or "", ocr.get("plate", "") or "", int(ocr.get("expected_sack") or 0), int(ocr.get("expected_box") or 0), ocr_raw), ) do_id = cur.lastrowid items.append({ "id": do_id, "photo_url": f"/api/do/photo/{do_id}", "no_do": ocr.get("no_do", "") or "", "plate": ocr.get("plate", "") or "", "expected_sack": int(ocr.get("expected_sack") or 0), "expected_box": int(ocr.get("expected_box") or 0), "ocr_text": ocr.get("ocr_text", "") or "", "ocr_ok": bool(ocr.get("ocr_ok")), "ocr_error": ocr.get("ocr_error", "") or "", "status": "draft", }) conn.commit() conn.close() if not items: return jsonify({"success": False, "error": "No valid image files"}), 400 return jsonify({"success": True, "items": items, "ocr_engine": engine}) except Exception as e: return jsonify({"success": False, "error": str(e)}), 500 @app.route("/api/do/photo/") def api_do_photo(do_id): try: conn = get_db() cur = conn.cursor() cur.execute("SELECT photo_path FROM delivery_orders WHERE id=?", (do_id,)) row = cur.fetchone() conn.close() if not row or not row["photo_path"]: return jsonify({"success": False, "error": "Not found"}), 404 rel = row["photo_path"] # resolve under output.dir; reject traversal base = os.path.abspath(CFG.output.dir if os.name != "nt" else _DEFAULT_DIR) full = os.path.abspath(os.path.join(base, rel)) if not full.startswith(base + os.sep) and full != base: return jsonify({"success": False, "error": "Bad path"}), 400 if not os.path.isfile(full): # try photo root relative full2 = os.path.abspath(os.path.join(DO_PHOTO_ROOT, os.path.basename(os.path.dirname(rel)), os.path.basename(rel))) if os.path.isfile(full2) and full2.startswith(os.path.abspath(DO_PHOTO_ROOT)): full = full2 else: return jsonify({"success": False, "error": "File missing"}), 404 with open(full, "rb") as fh: data = fh.read() return Response(data, mimetype="image/jpeg") except Exception as e: return jsonify({"success": False, "error": str(e)}), 500 @app.route("/api/do/staged") def api_do_staged(): try: date = get_counting_date() conn = get_db() cur = conn.cursor() cur.execute( """ SELECT id, counting_date, photo_path, no_do, plate, expected_sack, expected_box, ocr_raw, status, batch_id FROM delivery_orders WHERE counting_date = ? AND status IN ('draft', 'staged') ORDER BY id ASC """, (date,), ) rows = [] for r in cur.fetchall(): ocr = {} try: ocr = json.loads(r["ocr_raw"] or "{}") except Exception: pass rows.append({ "id": r["id"], "photo_url": f"/api/do/photo/{r['id']}", "no_do": r["no_do"] or ocr.get("no_do", ""), "plate": r["plate"] or ocr.get("plate", ""), "expected_sack": r["expected_sack"], "expected_box": r["expected_box"], "status": r["status"], "ocr_ok": bool(ocr.get("ocr_ok")), "ocr_error": ocr.get("ocr_error", ""), "ocr_text": ocr.get("ocr_text", ""), }) conn.close() return jsonify({"success": True, "items": rows}) except Exception as e: return jsonify({"success": False, "error": str(e)}), 500 @app.route("/api/do/", methods=["PUT", "DELETE"]) def api_do_item(do_id): try: conn = get_db() cur = conn.cursor() if request.method == "DELETE": cur.execute("SELECT photo_path, status FROM delivery_orders WHERE id=?", (do_id,)) row = cur.fetchone() if not row: conn.close() return jsonify({"success": False, "error": "Not found"}), 404 if row["status"] == "attached" and _batch_active(): conn.close() return jsonify({ "success": False, "error": "DO terpasang pada batch aktif", }), 409 base = os.path.abspath(CFG.output.dir if os.name != "nt" else _DEFAULT_DIR) full = os.path.abspath(os.path.join(base, row["photo_path"] or "")) if full.startswith(base) and os.path.isfile(full): try: os.remove(full) except Exception: pass cur.execute("DELETE FROM delivery_orders WHERE id=?", (do_id,)) conn.commit() conn.close() return jsonify({"success": True, "id": do_id}) req = request.get_json(silent=True) or {} no_do = str(req.get("no_do", "")).strip() plate = str(req.get("plate", "")).strip().upper() try: expected_sack = int(req.get("expected_sack", 0) or 0) expected_box = int(req.get("expected_box", 0) or 0) except (TypeError, ValueError): conn.close() return jsonify({"success": False, "error": "expected_* must be int"}), 400 cur.execute( """ UPDATE delivery_orders SET no_do=?, plate=?, expected_sack=?, expected_box=?, status='staged', updated_at=CURRENT_TIMESTAMP WHERE id=? """, (no_do, plate, expected_sack, expected_box, do_id), ) if cur.rowcount == 0: conn.close() return jsonify({"success": False, "error": "Not found"}), 404 conn.commit() conn.close() return jsonify({ "success": True, "id": do_id, "no_do": no_do, "plate": plate, "expected_sack": expected_sack, "expected_box": expected_box, "status": "staged", }) except Exception as e: return jsonify({"success": False, "error": str(e)}), 500 def _run_do_retention() -> dict: """Purge DO rows + photos older than do.retention_days.""" cutoff = retention_cutoff_date(CFG.do.retention_days) removed_rows = 0 removed_dirs = 0 try: conn = get_db() cur = conn.cursor() cur.execute( "SELECT id, photo_path FROM delivery_orders WHERE counting_date < ? " "AND status != 'attached'", (cutoff,), ) for r in cur.fetchall(): base = os.path.abspath(CFG.output.dir if os.name != "nt" else _DEFAULT_DIR) if r["photo_path"]: full = os.path.abspath(os.path.join(base, r["photo_path"])) if full.startswith(base) and os.path.isfile(full): try: os.remove(full) except Exception: pass cur.execute("UPDATE delivery_orders SET photo_path='' WHERE id=?", (r["id"],)) removed_rows += 1 # clear photo_path on attached old rows after file delete attempt cur.execute( "SELECT id, photo_path FROM delivery_orders WHERE counting_date < ? " "AND status = 'attached' AND photo_path != ''", (cutoff,), ) for r in cur.fetchall(): base = os.path.abspath(CFG.output.dir if os.name != "nt" else _DEFAULT_DIR) full = os.path.abspath(os.path.join(base, r["photo_path"])) if full.startswith(base) and os.path.isfile(full): try: os.remove(full) except Exception: pass cur.execute("UPDATE delivery_orders SET photo_path='' WHERE id=?", (r["id"],)) # delete fully purged old draft/staged leftovers cur.execute( "DELETE FROM delivery_orders WHERE counting_date < ? " "AND status IN ('draft', 'staged')", (cutoff,), ) removed_rows += cur.rowcount if cur.rowcount and cur.rowcount > 0 else 0 conn.commit() conn.close() except Exception as e: print(f"[DO Retention] row purge error: {e}") try: if os.path.isdir(DO_PHOTO_ROOT): for name in os.listdir(DO_PHOTO_ROOT): if should_purge_counting_date(name, cutoff): p = os.path.join(DO_PHOTO_ROOT, name) if os.path.isdir(p): import shutil shutil.rmtree(p, ignore_errors=True) removed_dirs += 1 except Exception as e: print(f"[DO Retention] photo dir error: {e}") return {"cutoff": cutoff, "rows": removed_rows, "dirs": removed_dirs} @app.route("/api/do/retention", methods=["POST"]) def api_do_retention(): if not is_office_request(): return jsonify({"success": False, "error": "Office only"}), 403 try: return jsonify({"success": True, **_run_do_retention()}) except Exception as e: return jsonify({"success": False, "error": str(e)}), 500 def _start_do_retention_timer(): import threading def _loop(): while True: try: res = _run_do_retention() if res.get("rows") or res.get("dirs"): print(f"[DO Retention] purged rows={res['rows']} dirs={res['dirs']}") except Exception as e: print(f"[DO Retention] {e}") threading.Event().wait(3600.0) try: _run_do_retention() except Exception: pass threading.Thread(target=_loop, daemon=True).start() @app.route("/api/current-batch") def api_current_batch(): fps_val = 0.0 status_file = LIVE_STATUS_FILE try: if os.path.exists(status_file): with open(status_file, "r") as sf: sdata = json.load(sf) fps_val = sdata.get("fps", 0.0) except Exception: pass try: with open(CURRENT_BATCH_PATH, "r") as f: data = json.load(f) stored = _read_batch_mode_file() loading = int(data.get("count", 0) or 0) unloading = int(data.get("unloading", 0) or 0) box_loading = int(data.get("box_count", 0) or 0) box_unloading = int(data.get("box_unloading", 0) or 0) net_sack, net_box = net_counts(loading, unloading, box_loading, box_unloading) return jsonify( { "success": True, "counting_date": data.get("counting_date"), "batch_number": data.get("batch_number"), "count": data.get("count", 0), "box_count": data.get("box_count", 0), "box_unloading": data.get("box_unloading", 0), "unloading": data.get("unloading", 0), "net_sack": net_sack, "net_box": net_box, "plate": data.get("plate", "") or "", "do_numbers": data.get("do_numbers") or [], "expected_sack": int(data.get("expected_sack", 0) or 0), "expected_box": int(data.get("expected_box", 0) or 0), "model_mode": data.get("model_mode", stored["model_mode"]), "mode": stored["mode"], "batch_mode": data.get("batch_mode", stored["mode"]), "start_time": data.get("start_time"), "last_detection_time": data.get("last_detection_time"), "fps": fps_val } ) except FileNotFoundError: stored = _read_batch_mode_file() return jsonify( { "success": False, "error": "No active batch", "count": 0, "box_count": 0, "net_sack": 0, "net_box": 0, "plate": "", "do_numbers": [], "expected_sack": 0, "expected_box": 0, "model_mode": stored["model_mode"], "mode": stored["mode"], "batch_number": None, "counting_date": None, "fps": fps_val } ), 200 except Exception as e: return jsonify( { "success": False, "error": str(e), "count": 0, "box_count": 0, "batch_number": None, "counting_date": None, "fps": fps_val } ), 500 @app.route("/api/previous-batch") def api_previous_batch(): try: conn = get_db() cur = conn.cursor() cur.execute( f""" SELECT counting_date, batch_number, count, start_time, end_time, ROUND((julianday(end_time) - julianday(start_time)) * 24 * 60, 1) as duration_minutes, {_batches_box_select()} FROM batches ORDER BY end_time DESC LIMIT 1 """ ) row = cur.fetchone() conn.close() if row: try: do_numbers = json.loads(row["do_numbers"] or "[]") except Exception: do_numbers = [] return jsonify( { "success": True, "date": row["counting_date"], "batch_number": row["batch_number"], "count": row["count"], "box_loading": row["box_loading"], "model_mode": row["model_mode"], "plate": row["plate"] if "plate" in row.keys() else "", "do_numbers": do_numbers, "expected_sack": row["expected_sack"] if "expected_sack" in row.keys() else 0, "expected_box": row["expected_box"] if "expected_box" in row.keys() else 0, "net_sack": row["net_sack"] if "net_sack" in row.keys() else row["count"], "net_box": row["net_box"] if "net_box" in row.keys() else row["box_loading"], "start_time": row["start_time"], "end_time": row["end_time"], "duration_minutes": row["duration_minutes"], } ) return jsonify({"success": False, "error": "No previous batch"}), 200 except sqlite3.OperationalError as e: return jsonify({"success": False, "error": f"Database unavailable: {e}"}), 200 except Exception as e: return jsonify({"success": False, "error": str(e)}), 500 @app.route("/api/summary") def api_summary(): try: conn = get_db() cur = conn.cursor() today = get_counting_date() cur.execute( """ SELECT COALESCE(total_count, 0) as total_count, COALESCE(total_batches, 0) as total_batches FROM daily_summaries WHERE counting_date = ? """, (today,), ) today_row = cur.fetchone() yesterday = (datetime.strptime(today, "%Y-%m-%d").date() - timedelta(days=1)).isoformat() cur.execute( """ SELECT COALESCE(total_count, 0) as total_count, COALESCE(total_batches, 0) as total_batches FROM daily_summaries WHERE counting_date = ? """, (yesterday,), ) yesterday_row = cur.fetchone() cur.execute( """ SELECT COALESCE(SUM(total_count), 0) as grand_total, COALESCE(SUM(total_batches), 0) as grand_batches, COUNT(DISTINCT counting_date) as total_days FROM daily_summaries """ ) all_time = cur.fetchone() cur.execute("SELECT ROUND(AVG(total_count), 1) as avg_per_day FROM daily_summaries") avg = cur.fetchone() cur.execute( """ SELECT counting_date, total_count FROM daily_summaries ORDER BY total_count DESC LIMIT 1 """ ) best = cur.fetchone() conn.close() return jsonify( { "today": { "date": today, "total_count": today_row["total_count"] if today_row else 0, "total_batches": today_row["total_batches"] if today_row else 0, }, "yesterday": { "date": yesterday, "total_count": yesterday_row["total_count"] if yesterday_row else 0, "total_batches": yesterday_row["total_batches"] if yesterday_row else 0, }, "all_time": { "grand_total": all_time["grand_total"], "grand_batches": all_time["grand_batches"], "total_days": all_time["total_days"], }, "average_per_day": avg["avg_per_day"] or 0, "best_day": { "date": best["counting_date"] if best else None, "count": best["total_count"] if best else 0, }, } ) except sqlite3.OperationalError as e: return jsonify({"success": False, "error": f"Database unavailable: {e}", "today": {"date": datetime.now().date().isoformat(), "total_count": 0, "total_batches": 0}, "yesterday": {"date": "", "total_count": 0, "total_batches": 0}, "all_time": {"grand_total": 0, "grand_batches": 0, "total_days": 0}, "average_per_day": 0, "best_day": {"date": None, "count": 0}}), 200 except Exception as e: return jsonify({"success": False, "error": str(e)}), 500 @app.route("/api/daily-data") def api_daily_data(): try: days = request.args.get("days", 30, type=int) date_from = (datetime.now() - timedelta(days=days)).date().isoformat() conn = get_db() cur = conn.cursor() cur.execute( """ SELECT counting_date, total_count, total_batches, ROUND(CAST(total_count AS FLOAT) / total_batches, 1) as avg_per_batch FROM daily_summaries WHERE counting_date >= ? ORDER BY counting_date ASC """, (date_from,), ) daily_data = [ { "date": row["counting_date"], "total_count": row["total_count"], "total_batches": row["total_batches"], "avg_per_batch": row["avg_per_batch"] or 0, } for row in cur.fetchall() ] conn.close() return jsonify(daily_data) except sqlite3.OperationalError as e: return jsonify([]), 200 except Exception as e: return jsonify({"success": False, "error": str(e)}), 500 @app.route("/api/day-detail/") def api_day_detail(date): try: conn = get_db() cur = conn.cursor() cur.execute( f""" SELECT batch_number, count, start_time, end_time, ROUND((julianday(end_time) - julianday(start_time)) * 24 * 60, 1) as duration_minutes, {_batches_box_select()} FROM batches WHERE counting_date = ? ORDER BY batch_number ASC """, (date,), ) batches = [] total_duration = 0 total_boxes = 0 for row in cur.fetchall(): duration = row["duration_minutes"] or 0 total_duration += duration total_boxes += row["box_loading"] or 0 try: do_numbers = json.loads(row["do_numbers"] or "[]") except Exception: do_numbers = [] batches.append( { "batch_number": row["batch_number"], "count": row["count"], "box_loading": row["box_loading"], "model_mode": row["model_mode"], "plate": row["plate"] if "plate" in row.keys() else "", "do_numbers": do_numbers, "expected_sack": row["expected_sack"] if "expected_sack" in row.keys() else 0, "expected_box": row["expected_box"] if "expected_box" in row.keys() else 0, "net_sack": row["net_sack"] if "net_sack" in row.keys() else row["count"], "net_box": row["net_box"] if "net_box" in row.keys() else row["box_loading"], "start_time": row["start_time"], "end_time": row["end_time"], "duration_minutes": duration, } ) cur.execute( """ SELECT total_count, total_batches FROM daily_summaries WHERE counting_date = ? """, (date,), ) summary = cur.fetchone() conn.close() return jsonify( { "date": date, "total_count": summary["total_count"] if summary else 0, "total_batches": summary["total_batches"] if summary else 0, "total_boxes": total_boxes, "total_duration_minutes": round(total_duration, 1), "avg_duration_minutes": round(total_duration / len(batches), 1) if batches else 0, "batches": batches, } ) except sqlite3.OperationalError as e: return jsonify({"date": date, "total_count": 0, "total_batches": 0, "total_duration_minutes": 0, "avg_duration_minutes": 0, "batches": [], "error": f"Database unavailable: {e}"}), 200 except Exception as e: return jsonify({"success": False, "error": str(e)}), 500 @app.route("/api/recent-batches") def api_recent_batches(): try: limit = request.args.get("limit", 10, type=int) conn = get_db() cur = conn.cursor() cur.execute( f""" SELECT counting_date, batch_number, count, start_time, end_time, ROUND((julianday(end_time) - julianday(start_time)) * 24 * 60, 1) as duration_minutes, {_batches_box_select()} FROM batches ORDER BY end_time DESC LIMIT ? """, (limit,), ) batches = [ { "date": row["counting_date"], "batch_number": row["batch_number"], "count": row["count"], "box_loading": row["box_loading"], "model_mode": row["model_mode"], "start_time": row["start_time"], "end_time": row["end_time"], "duration_minutes": row["duration_minutes"] or 0, } for row in cur.fetchall() ] conn.close() return jsonify(batches) except sqlite3.OperationalError as e: return jsonify([]), 200 except Exception as e: return jsonify({"success": False, "error": str(e)}), 500 @app.route("/api/available-dates") def api_available_dates(): try: conn = get_db() cur = conn.cursor() cur.execute( """ SELECT counting_date, total_count, total_batches FROM daily_summaries ORDER BY counting_date DESC """ ) dates = [ { "date": row["counting_date"], "total_count": row["total_count"], "total_batches": row["total_batches"], } for row in cur.fetchall() ] conn.close() return jsonify(dates) except sqlite3.OperationalError as e: return jsonify([]), 200 except Exception as e: return jsonify({"success": False, "error": str(e)}), 500 def _excel_response(wb, filename): output = BytesIO() wb.save(output) output.seek(0) return Response( output.getvalue(), mimetype="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", headers={"Content-Disposition": f"attachment; filename={filename}"}, ) def _style_header(ws, cols): header_font = Font(bold=True, color="FFFFFF", size=11) header_fill = PatternFill(start_color="2F5496", end_color="2F5496", fill_type="solid") thin_border = Border( left=Side(style="thin"), right=Side(style="thin"), top=Side(style="thin"), bottom=Side(style="thin"), ) for col_idx, (col_letter, text) in enumerate(cols, 1): cell = ws.cell(row=1, column=col_idx, value=text) cell.font = header_font cell.fill = header_fill cell.alignment = Alignment(horizontal="center") cell.border = thin_border ws.freeze_panes = "A2" def _auto_width(ws): for col in ws.columns: max_len = 0 col_letter = col[0].column_letter for cell in col: if cell.value is not None: max_len = max(max_len, len(str(cell.value))) ws.column_dimensions[col_letter].width = max_len + 4 @app.route("/api/export-daily-csv") def export_daily_xlsx(): try: days = request.args.get("days", 30, type=int) date_from = (datetime.now() - timedelta(days=days)).date().isoformat() conn = get_db() cur = conn.cursor() cur.execute( """ SELECT counting_date, batch_number, count, start_time, end_time, ROUND((julianday(end_time) - julianday(start_time)) * 24 * 60, 1) as duration_minutes FROM batches WHERE counting_date >= ? ORDER BY counting_date ASC, batch_number ASC """, (date_from,), ) rows = cur.fetchall() conn.close() except sqlite3.OperationalError as e: return jsonify({"success": False, "error": f"Database unavailable: {e}"}), 503 except Exception as e: return jsonify({"success": False, "error": str(e)}), 500 wb = Workbook() ws = wb.active ws.title = "Batch Details" _style_header(ws, [("A", "Date"), ("B", "Batch #"), ("C", "Count"), ("D", "Start Time"), ("E", "End Time"), ("F", "Duration (min)")]) for r_idx, row in enumerate(rows, 2): ws.cell(row=r_idx, column=1, value=row["counting_date"]) ws.cell(row=r_idx, column=2, value=row["batch_number"]) ws.cell(row=r_idx, column=3, value=row["count"]) ws.cell(row=r_idx, column=4, value=row["start_time"]) ws.cell(row=r_idx, column=5, value=row["end_time"]) ws.cell(row=r_idx, column=6, value=row["duration_minutes"] or 0) _auto_width(ws) filename = f"{SITE_NAME}_daily_records_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx" return _excel_response(wb, filename) @app.route("/api/export-day-csv/") def export_day_xlsx(date): try: conn = get_db() cur = conn.cursor() cur.execute( f""" SELECT batch_number, count, start_time, end_time, ROUND((julianday(end_time) - julianday(start_time)) * 24 * 60, 1) as duration_minutes, {_batches_box_select()} FROM batches WHERE counting_date = ? ORDER BY batch_number ASC """, (date,), ) rows = cur.fetchall() conn.close() except sqlite3.OperationalError as e: return jsonify({"success": False, "error": f"Database unavailable: {e}"}), 503 except Exception as e: return jsonify({"success": False, "error": str(e)}), 500 wb = Workbook() ws = wb.active ws.title = f"Day {date}" _style_header(ws, [ ("A", "Batch Number"), ("B", "Sacks"), ("C", "Boxes"), ("D", "Plate"), ("E", "DO"), ("F", "DO Sacks"), ("G", "DO Boxes"), ("H", "Start Time"), ("I", "End Time"), ("J", "Duration (min)"), ]) for r_idx, row in enumerate(rows, 2): try: do_numbers = json.loads(row["do_numbers"] or "[]") except Exception: do_numbers = [] ws.cell(row=r_idx, column=1, value=row["batch_number"]) ws.cell(row=r_idx, column=2, value=row["count"]) ws.cell(row=r_idx, column=3, value=row["box_loading"]) ws.cell(row=r_idx, column=4, value=row["plate"] if "plate" in row.keys() else "") ws.cell(row=r_idx, column=5, value=", ".join(do_numbers)) ws.cell(row=r_idx, column=6, value=row["expected_sack"] if "expected_sack" in row.keys() else 0) ws.cell(row=r_idx, column=7, value=row["expected_box"] if "expected_box" in row.keys() else 0) ws.cell(row=r_idx, column=8, value=row["start_time"]) ws.cell(row=r_idx, column=9, value=row["end_time"]) ws.cell(row=r_idx, column=10, value=row["duration_minutes"] or 0) _auto_width(ws) filename = f"{SITE_NAME}_day_detail_{date}.xlsx" return _excel_response(wb, filename) if __name__ == "__main__": import threading from werkzeug.serving import make_server WSGIRequestHandler.protocol_version = "HTTP/1.1" try: _start_do_retention_timer() except Exception as e: print(f"[DO Retention] start failed: {e}") print("=" * 60) print(f"[*] JETSON BATCH CONTROL & DASHBOARD SERVER STARTED") print(f"[*] Port Operator (Panel Tombol): http://{DASHBOARD_HOST}:{DASHBOARD_PORT}") print(f"[*] Port Kantor (Monitoring AI): http://{DASHBOARD_HOST}:{OFFICE_PORT}") print(f"[*] DB Path: {DB_PATH}") print(f"[*] State : {CURRENT_BATCH_PATH}") print(f"[*] DO photo: {DO_PHOTO_ROOT} (retention {CFG.do.retention_days}d)") print("=" * 60) # Server 1: Operator Port (default 5000) server_operator = make_server(DASHBOARD_HOST, DASHBOARD_PORT, app, threaded=True) t_op = threading.Thread(target=server_operator.serve_forever, daemon=True) t_op.start() # Server 2: Office Monitoring Port (default 5721) server_office = make_server(DASHBOARD_HOST, OFFICE_PORT, app, threaded=True) try: server_office.serve_forever() except KeyboardInterrupt: print("\nStopping servers...") server_operator.shutdown() server_office.shutdown()