1765 lines
67 KiB
Python
1765 lines
67 KiB
Python
#!/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,
|
|
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", "tesseract")).lower(),
|
|
"updated_at": datetime.now().isoformat(),
|
|
}
|
|
tmp = DO_SETTINGS_PATH + ".tmp"
|
|
with open(tmp, "w", encoding="utf-8") as f:
|
|
json.dump(payload, f, indent=2)
|
|
os.replace(tmp, DO_SETTINGS_PATH)
|
|
|
|
|
|
def _effective_ocr_engine() -> str:
|
|
return _read_do_settings().get("ocr_engine", CFG.do.ocr.engine)
|
|
|
|
|
|
@app.route("/")
|
|
@app.route("/monitoring")
|
|
def index():
|
|
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(net_sack, net_box)
|
|
|
|
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
|
|
# ocr_engine: both ports allowed
|
|
if "ocr_engine" in req_data:
|
|
eng = str(req_data.get("ocr_engine", "")).lower()
|
|
if not is_valid_ocr_engine(eng):
|
|
return jsonify({
|
|
"success": False,
|
|
"error": f"Invalid ocr_engine. Use one of {list(('tesseract','paddle','none'))}",
|
|
}), 400
|
|
current["ocr_engine"] = eng
|
|
wrote = True
|
|
# require_plate / require_do: office only
|
|
plate_keys = ("require_plate", "require_do")
|
|
if any(k in req_data for k in plate_keys):
|
|
if not office:
|
|
return jsonify({
|
|
"success": False,
|
|
"error": "require_plate/require_do hanya dari port kantor",
|
|
}), 403
|
|
if "require_plate" in req_data:
|
|
current["require_plate"] = bool(
|
|
str(req_data.get("require_plate")).lower() in
|
|
("1", "true", "yes", "on"))
|
|
if "require_do" in req_data:
|
|
current["require_do"] = bool(
|
|
str(req_data.get("require_do")).lower() in
|
|
("1", "true", "yes", "on"))
|
|
wrote = True
|
|
if not wrote:
|
|
return jsonify({"success": False, "error": "No known settings fields"}), 400
|
|
_write_do_settings(current)
|
|
current = _read_do_settings()
|
|
current["editable"] = office
|
|
return jsonify({"success": True, **current})
|
|
except Exception as e:
|
|
return jsonify({"success": False, "error": str(e)}), 500
|
|
|
|
data = _read_do_settings()
|
|
return jsonify({"success": True, **data})
|
|
|
|
|
|
def _downscale_image(src_path: str, max_edge: int = 2048) -> str:
|
|
"""Downscale large phone photos before store/OCR. Returns path to use."""
|
|
try:
|
|
from PIL import Image # type: ignore
|
|
except ImportError:
|
|
try:
|
|
import cv2 # type: ignore
|
|
except ImportError:
|
|
return src_path
|
|
img = cv2.imread(src_path)
|
|
if img is None:
|
|
return src_path
|
|
h, w = img.shape[:2]
|
|
edge = max(h, w)
|
|
if edge <= max_edge:
|
|
return src_path
|
|
scale = max_edge / float(edge)
|
|
img = cv2.resize(img, (int(w * scale), int(h * scale)), interpolation=cv2.INTER_AREA)
|
|
cv2.imwrite(src_path, img, [int(cv2.IMWRITE_JPEG_QUALITY), 88])
|
|
return src_path
|
|
try:
|
|
img = Image.open(src_path)
|
|
edge = max(img.size)
|
|
if edge <= max_edge:
|
|
return src_path
|
|
scale = max_edge / float(edge)
|
|
img = img.resize((int(img.width * scale), int(img.height * scale)), Image.LANCZOS)
|
|
if img.mode not in ("RGB", "L"):
|
|
img = img.convert("RGB")
|
|
img.save(src_path, "JPEG", quality=88)
|
|
return src_path
|
|
except Exception:
|
|
return src_path
|
|
|
|
|
|
@app.route("/api/do/upload", methods=["POST"])
|
|
def api_do_upload():
|
|
"""Smartphone multi-photo upload. Only when mode == do_manual."""
|
|
try:
|
|
mode = normalize_batch_mode(
|
|
_read_batch_mode_file()["mode"], CFG.batch.default_mode)
|
|
if mode != "do_manual":
|
|
return jsonify({
|
|
"success": False, "reason": "wrong_mode",
|
|
"error": "Upload DO hanya di mode do_manual.",
|
|
}), 409
|
|
files = request.files.getlist("photos")
|
|
files = [f for f in (files or []) if f and f.filename]
|
|
if not files:
|
|
return jsonify({"success": False, "error": "No photos uploaded"}), 400
|
|
if len(files) > int(CFG.do.max_photos_per_batch):
|
|
return jsonify({
|
|
"success": False,
|
|
"error": f"Maksimal {CFG.do.max_photos_per_batch} foto per batch",
|
|
}), 400
|
|
allowed = {".jpg", ".jpeg", ".png", ".webp"}
|
|
counting_date = get_counting_date()
|
|
day_dir = os.path.join(DO_PHOTO_ROOT, counting_date)
|
|
os.makedirs(day_dir, exist_ok=True)
|
|
engine = _effective_ocr_engine()
|
|
try:
|
|
from src.do_ocr import extract_do_fields
|
|
except Exception:
|
|
extract_do_fields = None
|
|
items = []
|
|
conn = get_db()
|
|
cur = conn.cursor()
|
|
for f in files:
|
|
ext = os.path.splitext(f.filename)[1].lower() or ".jpg"
|
|
if ext not in allowed:
|
|
continue
|
|
import uuid
|
|
fname = f"do_{uuid.uuid4().hex}{ext}"
|
|
rel = os.path.join(CFG.do.photo_dir, counting_date, fname)
|
|
abs_path = os.path.join(day_dir, fname)
|
|
f.save(abs_path)
|
|
_downscale_image(abs_path, max_edge=2048)
|
|
ocr = {
|
|
"no_do": "", "plate": "", "expected_sack": 0, "expected_box": 0,
|
|
"ocr_text": "", "engine": engine, "ocr_ok": False, "ocr_error": "skipped",
|
|
}
|
|
if extract_do_fields is not None and engine != "none":
|
|
try:
|
|
ocr = extract_do_fields(abs_path, engine)
|
|
except Exception as e:
|
|
ocr["ocr_error"] = str(e)
|
|
elif engine == "none":
|
|
ocr["ocr_error"] = "disabled"
|
|
ocr_raw = json.dumps(ocr, ensure_ascii=False)
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO delivery_orders
|
|
(counting_date, photo_path, no_do, plate, expected_sack, expected_box, ocr_raw, status)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, 'draft')
|
|
""",
|
|
(counting_date, rel,
|
|
ocr.get("no_do", "") or "",
|
|
ocr.get("plate", "") or "",
|
|
int(ocr.get("expected_sack") or 0),
|
|
int(ocr.get("expected_box") or 0),
|
|
ocr_raw),
|
|
)
|
|
do_id = cur.lastrowid
|
|
items.append({
|
|
"id": do_id,
|
|
"photo_url": f"/api/do/photo/{do_id}",
|
|
"no_do": ocr.get("no_do", "") or "",
|
|
"plate": ocr.get("plate", "") or "",
|
|
"expected_sack": int(ocr.get("expected_sack") or 0),
|
|
"expected_box": int(ocr.get("expected_box") or 0),
|
|
"ocr_text": ocr.get("ocr_text", "") or "",
|
|
"ocr_ok": bool(ocr.get("ocr_ok")),
|
|
"ocr_error": ocr.get("ocr_error", "") or "",
|
|
"status": "draft",
|
|
})
|
|
conn.commit()
|
|
conn.close()
|
|
if not items:
|
|
return jsonify({"success": False, "error": "No valid image files"}), 400
|
|
return jsonify({"success": True, "items": items, "ocr_engine": engine})
|
|
except Exception as e:
|
|
return jsonify({"success": False, "error": str(e)}), 500
|
|
|
|
|
|
@app.route("/api/do/photo/<int:do_id>")
|
|
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/<int:do_id>", 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/<date>")
|
|
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/<date>")
|
|
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()
|