From 513fe2870f0c7e0e7d963691a44a32cb4f755863 Mon Sep 17 00:00:00 2001
From: Alberto-Audrix
Date: Wed, 2 Sep 2026 09:06:35 +0700
Subject: [PATCH] update dashboard view to use cycle from dashboard cpsp and
adjust the view for each kandang with selectable cycle for each kandang
---
.env.example | 5 +
.pull_status.json | 111 +++++
app.py | 944 ++++++++++++++++++++++++++++++++++++++----
cycle_api.py | 217 ++++++++++
dashboard_settings.db | Bin 16384 -> 16384 bytes
karung_masuk.db | Bin 12288 -> 12288 bytes
karung_masuk.json | 10 +-
karung_tuang.db | Bin 12288 -> 12288 bytes
karung_tuang.json | 4 +-
pull_sources.py | 18 +-
templates/index.html | 496 +++++++++++++++++++---
11 files changed, 1660 insertions(+), 145 deletions(-)
create mode 100644 .pull_status.json
create mode 100644 cycle_api.py
diff --git a/.env.example b/.env.example
index e134330..370f74c 100644
--- a/.env.example
+++ b/.env.example
@@ -55,5 +55,10 @@ TUANG_CUTOFF_TIME=17:00:00
# When Karung Masuk (In/Out) rolls to the next business day
MASUK_CUTOFF_TIME=24:00:00
+# Cycle API (maps K1 -> Kandang 1, K2 -> Kandang 2, etc.)
+# Base URL without trailing slash; http:// is added automatically if omitted.
+CYCLE_API_BASE_URL=http://192.168.192.106:5002
+CYCLE_API_KEY=test-api-key
+
# Optional path for exported counter settings JSON (used by other services)
# COUNTER_SETTINGS_FILE=counter_settings.json
diff --git a/.pull_status.json b/.pull_status.json
new file mode 100644
index 0000000..1b1165f
--- /dev/null
+++ b/.pull_status.json
@@ -0,0 +1,111 @@
+{
+ "ktc": {
+ "status": "ok",
+ "pulled_at": "2026-09-02T01:00:03Z",
+ "base_url": null,
+ "base_urls": [
+ "http://192.168.192.17:9000",
+ "http://192.168.192.15:9000"
+ ],
+ "source_groups": [
+ [
+ "http://192.168.192.17:9000"
+ ],
+ [
+ "http://192.168.192.15:9000"
+ ]
+ ],
+ "combine": "sum",
+ "hosts": [
+ {
+ "status": "ok",
+ "pulled_at": "2026-09-02T01:00:02Z",
+ "base_url": "http://192.168.192.17:9000",
+ "source_index": 1,
+ "error": null,
+ "live_sources": [
+ "K1 - Use"
+ ],
+ "history_rows": 2
+ },
+ {
+ "status": "ok",
+ "pulled_at": "2026-09-02T01:00:02Z",
+ "base_url": "http://192.168.192.15:9000",
+ "source_index": 2,
+ "error": null,
+ "live_sources": [
+ "K2 - Use"
+ ],
+ "history_rows": 2
+ }
+ ],
+ "error": null,
+ "live_sources": [
+ "K1 - Use",
+ "K2 - Use"
+ ],
+ "history_rows": 4,
+ "hosts_ok": 2,
+ "hosts_total": 2,
+ "groups_total": 2,
+ "source_key": "ktc"
+ },
+ "kpc": {
+ "status": "ok",
+ "pulled_at": "2026-09-02T01:00:03Z",
+ "base_url": null,
+ "base_urls": [
+ "http://192.168.192.17:7000",
+ "http://192.168.192.15:7000"
+ ],
+ "source_groups": [
+ [
+ "http://192.168.192.17:7000"
+ ],
+ [
+ "http://192.168.192.15:7000"
+ ]
+ ],
+ "combine": "sum",
+ "hosts": [
+ {
+ "status": "ok",
+ "pulled_at": "2026-09-02T01:00:03Z",
+ "base_url": "http://192.168.192.17:7000",
+ "source_index": 1,
+ "error": null,
+ "live_sources": [
+ "K1 - In",
+ "K1 - Out"
+ ],
+ "history_rows": 6
+ },
+ {
+ "status": "ok",
+ "pulled_at": "2026-09-02T01:00:03Z",
+ "base_url": "http://192.168.192.15:7000",
+ "source_index": 2,
+ "error": null,
+ "live_sources": [
+ "K2 - In",
+ "K2 - Out"
+ ],
+ "history_rows": 12
+ }
+ ],
+ "error": null,
+ "live_sources": [
+ "K1 - In",
+ "K1 - Out",
+ "K2 - In",
+ "K2 - Out"
+ ],
+ "history_rows": 18,
+ "hosts_ok": 2,
+ "hosts_total": 2,
+ "groups_total": 2,
+ "source_key": "kpc"
+ },
+ "updated_at": "2026-09-02T01:00:03Z"
+}
diff --git a/app.py b/app.py
index 80c11ad..5e6484c 100644
--- a/app.py
+++ b/app.py
@@ -5,6 +5,7 @@ from datetime import datetime, timedelta, timezone
import os
from pathlib import Path
import uuid
+import logging
from counter_time import (
apply_env_counter_settings,
@@ -13,9 +14,17 @@ from counter_time import (
get_business_date,
get_period_window,
)
+from cycle_api import (
+ extract_k_index,
+ format_cycle_name,
+ get_cycle_settings_for_camera as get_api_cycle_settings_for_camera,
+ is_cycle_api_configured,
+ load_kandang_cycle_context,
+)
from werkzeug.exceptions import HTTPException
app = Flask(__name__)
+logger = logging.getLogger(__name__)
BASE_DIR = Path(__file__).resolve().parent
SETTINGS_DB = BASE_DIR / 'dashboard_settings.db'
DISPLAY_DATE_FORMAT = '%d-%m-%Y'
@@ -50,6 +59,11 @@ KARUNG_TUANG_JSON = get_configured_db_path('KARUNG_TUANG_JSON', 'karung_tuang.js
KARUNG_MASUK_JSON = get_configured_db_path('KARUNG_MASUK_JSON', 'karung_masuk.json')
PULL_STATUS_FILE = BASE_DIR / '.pull_status.json'
DEFAULT_COMBINED_HISTORY_DAYS = int(os.environ.get('SOURCE_HISTORY_DAYS', '30'))
+CYCLE_API_CACHE_SECONDS = int(os.environ.get('CYCLE_API_CACHE_SECONDS', '60'))
+_cycle_context_cache = {
+ 'loaded_at': None,
+ 'context': None,
+}
if KARUNG_TUANG_DB.resolve() == KARUNG_MASUK_DB.resolve():
raise RuntimeError('KARUNG_TUANG_DB and KARUNG_MASUK_DB must point to different database files.')
@@ -101,8 +115,8 @@ def get_db_data(db_path, table_name):
})
return data
-def load_dashboard_settings():
- """Load active cycle settings. Will be replaced by external cycle API."""
+def load_dashboard_settings_from_db():
+ """Load cycle settings from the local SQLite fallback store."""
ensure_settings_db()
with sqlite3.connect(SETTINGS_DB) as conn:
@@ -136,6 +150,179 @@ def load_dashboard_settings():
'local_timezone': row[7],
})
+def build_site_dashboard_settings(kandang_cycles):
+ counter_defaults = apply_env_counter_settings(DEFAULT_DASHBOARD_SETTINGS.copy())
+
+ if not kandang_cycles:
+ return counter_defaults
+
+ saldo_awal = sum(cycle['saldo_awal'] for cycle in kandang_cycles)
+ if len(kandang_cycles) == 1:
+ cycle_name = format_cycle_name(
+ kandang_cycles[0].get('cycle_start', ''),
+ kandang_cycles[0].get('cycle_end', ''),
+ )
+ else:
+ cycle_name = ', '.join(
+ format_cycle_name(cycle.get('cycle_start', ''), cycle.get('cycle_end', ''))
+ for cycle in kandang_cycles
+ )
+
+ cycle_starts = [
+ parse_cycle_date(cycle['cycle_start'], 'Cycle start')
+ for cycle in kandang_cycles
+ if cycle.get('cycle_start')
+ ]
+ cycle_ends = [
+ parse_cycle_date(cycle['cycle_end'], 'Cycle end')
+ for cycle in kandang_cycles
+ if cycle.get('cycle_end')
+ ]
+
+ return apply_env_counter_settings({
+ 'id': '',
+ 'cycle_name': cycle_name,
+ 'cycle_start': min(cycle_starts).strftime(DISPLAY_DATE_FORMAT) if cycle_starts else '',
+ 'cycle_end': max(cycle_ends).strftime(DISPLAY_DATE_FORMAT) if cycle_ends else '',
+ 'saldo_awal': saldo_awal,
+ 'tuang_cutoff_time': counter_defaults['tuang_cutoff_time'],
+ 'masuk_cutoff_time': counter_defaults['masuk_cutoff_time'],
+ 'local_timezone': counter_defaults['local_timezone'],
+ })
+
+
+def build_legacy_local_cycle_context(*, source='local'):
+ site_settings = load_dashboard_settings_from_db()
+ kandang_cycles = []
+
+ if site_settings.get('cycle_start') and site_settings.get('cycle_end'):
+ kandang_cycles.append({
+ **site_settings,
+ 'kandang_id': None,
+ 'k_index': None,
+ 'status': 'local',
+ 'flock': None,
+ })
+
+ return {
+ 'source': source,
+ 'kandangs': [],
+ 'kandang_by_k_index': {},
+ 'cycle_by_kandang_id': {},
+ 'kandang_cycles': kandang_cycles,
+ 'all_cycles_by_kandang': {},
+ 'site_settings': site_settings,
+ }
+
+
+def build_cycle_context_from_db(*, source='db'):
+ kandang_cycles = load_kandang_cycles_from_db()
+
+ if kandang_cycles:
+ kandang_by_k_index = {}
+ cycle_by_kandang_id = {}
+ kandangs = []
+
+ for cycle in kandang_cycles:
+ kandang_name = cycle.get('kandang_name') or (
+ f"Kandang {cycle['k_index']}" if cycle.get('k_index') is not None else cycle.get('cycle_name', '')
+ )
+ kandangs.append({
+ 'id': cycle['kandang_id'],
+ 'kandang_name': kandang_name,
+ })
+ if cycle.get('k_index') is not None:
+ kandang_by_k_index[cycle['k_index']] = kandangs[-1]
+ cycle_by_kandang_id[cycle['kandang_id']] = cycle
+
+ return {
+ 'source': source,
+ 'kandangs': kandangs,
+ 'kandang_by_k_index': kandang_by_k_index,
+ 'cycle_by_kandang_id': cycle_by_kandang_id,
+ 'kandang_cycles': kandang_cycles,
+ 'all_cycles_by_kandang': {
+ cycle['kandang_id']: [cycle]
+ for cycle in kandang_cycles
+ if cycle.get('kandang_id') is not None
+ },
+ 'site_settings': build_site_dashboard_settings(kandang_cycles),
+ }
+
+ return build_legacy_local_cycle_context(source=source)
+
+
+def load_cycle_context():
+ now = datetime.now()
+ cached_loaded_at = _cycle_context_cache.get('loaded_at')
+ cached_context = _cycle_context_cache.get('context')
+ if (
+ cached_loaded_at
+ and cached_context
+ and (now - cached_loaded_at).total_seconds() < CYCLE_API_CACHE_SECONDS
+ ):
+ return cached_context
+
+ source = 'db'
+ api_all_cycles = {}
+ if is_cycle_api_configured():
+ try:
+ api_context = load_kandang_cycle_context()
+ sync_api_cycle_context_to_db(api_context)
+ api_all_cycles = api_context.get('all_cycles_by_kandang', {})
+ source = 'api'
+ except RuntimeError as error:
+ logger.warning('Cycle API unavailable, using local database: %s', error)
+
+ context = build_cycle_context_from_db(source=source)
+ if api_all_cycles:
+ context['all_cycles_by_kandang'] = api_all_cycles
+ _cycle_context_cache['loaded_at'] = now
+ _cycle_context_cache['context'] = context
+ return context
+
+
+def get_cycle_settings_for_camera(source_name, cycle_context):
+ if cycle_context.get('kandang_cycles') and any(
+ cycle.get('kandang_id') is not None for cycle in cycle_context['kandang_cycles']
+ ):
+ return get_api_cycle_settings_for_camera(source_name, cycle_context)
+
+ return cycle_context.get('site_settings')
+
+
+def get_cycle_dates_for_camera(source_name, cycle_context):
+ cycle_settings = get_cycle_settings_for_camera(source_name, cycle_context)
+ if not cycle_settings:
+ return None, None
+
+ return (
+ parse_cycle_date(cycle_settings.get('cycle_start', ''), 'Cycle start'),
+ parse_cycle_date(cycle_settings.get('cycle_end', ''), 'Cycle end'),
+ )
+
+
+def is_camera_within_cycle(source_name, target_date, cycle_context):
+ cycle_start, cycle_end = get_cycle_dates_for_camera(source_name, cycle_context)
+ return is_date_in_active_cycle(target_date, cycle_start, cycle_end)
+
+
+def filter_data_by_kandang_cycles(data, cycle_context):
+ filtered_data = []
+
+ for item in data:
+ item_date = parse_data_date(item['date'])
+ if not item_date:
+ continue
+
+ cycle_start, cycle_end = get_cycle_dates_for_camera(item['source'], cycle_context)
+ if not is_date_in_active_cycle(item_date, cycle_start, cycle_end):
+ continue
+
+ filtered_data.append(item)
+
+ return filtered_data
+
def ensure_settings_db():
with sqlite3.connect(SETTINGS_DB) as conn:
existing_schema = conn.execute(
@@ -224,10 +411,16 @@ def create_cycle_settings_table(conn):
"""
CREATE TABLE IF NOT EXISTS cycle_settings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
+ api_cycle_id INTEGER,
+ kandang_id INTEGER,
+ k_index INTEGER,
cycle_name TEXT NOT NULL,
cycle_start TEXT NOT NULL DEFAULT '',
cycle_end TEXT NOT NULL DEFAULT '',
saldo_awal INTEGER NOT NULL DEFAULT 0,
+ feed_initial_balance_date TEXT NOT NULL DEFAULT '',
+ cycle_status TEXT NOT NULL DEFAULT '',
+ flock TEXT,
tuang_cutoff_time TEXT NOT NULL DEFAULT '17:00:00',
masuk_cutoff_time TEXT NOT NULL DEFAULT '24:00:00',
local_timezone TEXT NOT NULL DEFAULT 'Asia/Jakarta',
@@ -270,6 +463,425 @@ def ensure_settings_columns(conn):
ADD COLUMN local_timezone TEXT NOT NULL DEFAULT 'Asia/Jakarta'
"""
)
+ if 'api_cycle_id' not in columns:
+ conn.execute(
+ """
+ ALTER TABLE cycle_settings
+ ADD COLUMN api_cycle_id INTEGER
+ """
+ )
+ if 'kandang_id' not in columns:
+ conn.execute(
+ """
+ ALTER TABLE cycle_settings
+ ADD COLUMN kandang_id INTEGER
+ """
+ )
+ if 'k_index' not in columns:
+ conn.execute(
+ """
+ ALTER TABLE cycle_settings
+ ADD COLUMN k_index INTEGER
+ """
+ )
+ if 'cycle_status' not in columns:
+ conn.execute(
+ """
+ ALTER TABLE cycle_settings
+ ADD COLUMN cycle_status TEXT NOT NULL DEFAULT ''
+ """
+ )
+ if 'flock' not in columns:
+ conn.execute(
+ """
+ ALTER TABLE cycle_settings
+ ADD COLUMN flock TEXT
+ """
+ )
+ if 'feed_initial_balance_date' not in columns:
+ conn.execute(
+ """
+ ALTER TABLE cycle_settings
+ ADD COLUMN feed_initial_balance_date TEXT NOT NULL DEFAULT ''
+ """
+ )
+
+ refreshed_columns = {
+ row[1]
+ for row in conn.execute('PRAGMA table_info(cycle_settings)').fetchall()
+ }
+ if 'api_cycle_id' in refreshed_columns:
+ conn.execute(
+ """
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_cycle_settings_api_cycle_id
+ ON cycle_settings(api_cycle_id)
+ WHERE api_cycle_id IS NOT NULL
+ """
+ )
+
+
+def map_kandang_cycle_row(row):
+ k_index = row[2]
+ return {
+ 'id': row[0],
+ 'kandang_id': row[1],
+ 'k_index': k_index,
+ 'kandang_name': f'Kandang {k_index}' if k_index is not None else '',
+ 'cycle_name': row[3],
+ 'cycle_start': row[4],
+ 'cycle_end': row[5],
+ 'saldo_awal': row[6],
+ 'feed_initial_balance_date': row[7],
+ 'status': row[8],
+ 'flock': row[9],
+ 'tuang_cutoff_time': row[10],
+ 'masuk_cutoff_time': row[11],
+ 'local_timezone': row[12],
+ }
+
+
+def build_feed_initial_balance_items(kandang_cycles, masuk_data=None, counter_settings=None):
+ counter_settings = counter_settings or default_counter_settings()
+ if masuk_data is None:
+ masuk_data = get_db_data(DB_CONFIGS['masuk']['path'], DB_CONFIGS['masuk']['table'])
+
+ items = []
+ for cycle in kandang_cycles:
+ k_index = cycle.get('k_index')
+ if k_index is None:
+ continue
+
+ items.append(build_feed_initial_balance_verification(cycle, masuk_data, counter_settings))
+
+ return items
+
+
+def build_kandang_selector_options(kandang_cycles):
+ options = []
+ for cycle in kandang_cycles:
+ k_index = cycle.get('k_index')
+ if k_index is None:
+ continue
+
+ options.append({
+ 'k_index': k_index,
+ 'kandang_id': cycle.get('kandang_id'),
+ 'label': f"K{k_index} · {cycle.get('kandang_name', f'Kandang {k_index}')}",
+ })
+
+ return options
+
+
+def serialize_cycles_for_client(all_cycles_by_kandang):
+ serialized = {}
+ for kandang_id, cycles in (all_cycles_by_kandang or {}).items():
+ serialized[str(kandang_id)] = [
+ {
+ 'id': cycle.get('id'),
+ 'k_index': cycle.get('k_index'),
+ 'kandang_id': cycle.get('kandang_id'),
+ 'name': cycle.get('cycle_name'),
+ 'cycle_start': cycle.get('cycle_start', ''),
+ 'cycle_end': cycle.get('cycle_end', ''),
+ 'saldo_awal': cycle.get('saldo_awal', 0),
+ 'feed_initial_balance_date': cycle.get('feed_initial_balance_date', ''),
+ 'status': cycle.get('status', ''),
+ }
+ for cycle in cycles
+ ]
+
+ return serialized
+
+
+def find_cycle_settings(cycle_context, k_index=None, cycle_id=None):
+ if k_index is None:
+ return None
+
+ kandang_cycle = next(
+ (cycle for cycle in cycle_context.get('kandang_cycles', []) if cycle.get('k_index') == k_index),
+ None,
+ )
+ if not kandang_cycle:
+ return None
+
+ kandang_id = kandang_cycle.get('kandang_id')
+ if cycle_id is not None:
+ for cycle in cycle_context.get('all_cycles_by_kandang', {}).get(kandang_id, []):
+ if str(cycle.get('id')) == str(cycle_id):
+ return cycle
+
+ return kandang_cycle
+
+
+def filter_items_by_k_index(items, k_index):
+ if k_index is None:
+ return items
+
+ return [
+ item for item in items
+ if extract_k_index(item.get('source')) == k_index
+ ]
+
+
+def filter_data_by_selected_cycle(data, cycle_settings, k_index):
+ if k_index is None or not cycle_settings:
+ return data
+
+ cycle_start = parse_cycle_date(cycle_settings.get('cycle_start', ''), 'Cycle start')
+ cycle_end = parse_cycle_date(cycle_settings.get('cycle_end', ''), 'Cycle end')
+ filtered_data = []
+
+ for item in data:
+ if extract_k_index(item.get('source')) != k_index:
+ continue
+
+ item_date = parse_data_date(item['date'])
+ if not item_date:
+ continue
+
+ if not is_date_in_active_cycle(item_date, cycle_start, cycle_end):
+ continue
+
+ filtered_data.append(item)
+
+ return filtered_data
+
+
+def build_dashboard_view_payload(cycle_context, counter_settings, cutoff_metadata, k_index=None, cycle_id=None):
+ selected_cycle = find_cycle_settings(cycle_context, k_index, cycle_id)
+ today_section = build_today_section(cycle_context, counter_settings, cutoff_metadata)
+
+ tuang_db_data = filter_data_by_selected_cycle(
+ filter_data_by_kandang_cycles(
+ get_db_data(DB_CONFIGS['tuang']['path'], DB_CONFIGS['tuang']['table']),
+ cycle_context,
+ ),
+ selected_cycle,
+ k_index,
+ )
+ masuk_db_data = filter_data_by_selected_cycle(
+ filter_data_by_kandang_cycles(
+ get_db_data(DB_CONFIGS['masuk']['path'], DB_CONFIGS['masuk']['table']),
+ cycle_context,
+ ),
+ selected_cycle,
+ k_index,
+ )
+
+ tuang_items = filter_items_by_k_index(today_section['tuang'], k_index)
+ masuk_items = filter_items_by_k_index(today_section['masuk'], k_index)
+
+ tuang_db_total = sum(item['counter_value'] for item in tuang_db_data)
+ masuk_db_total = sum(
+ item['counter_value'] if not is_masuk_out_item(item) else -item['counter_value']
+ for item in masuk_db_data
+ )
+ saldo_awal = selected_cycle['saldo_awal'] if selected_cycle else cycle_context['site_settings']['saldo_awal']
+ masuk_history_data = get_db_data(DB_CONFIGS['masuk']['path'], DB_CONFIGS['masuk']['table'])
+
+ if k_index is not None and selected_cycle:
+ feed_initial_balances = [
+ build_feed_initial_balance_verification(selected_cycle, masuk_history_data, counter_settings)
+ ]
+ else:
+ feed_initial_balances = build_feed_initial_balance_items(
+ cycle_context['kandang_cycles'],
+ masuk_history_data,
+ counter_settings,
+ )
+
+ return {
+ 'selected_cycle': selected_cycle,
+ 'feed_initial_balances': feed_initial_balances,
+ 'tuang_db_data': [
+ {
+ **item,
+ 'display_date': format_history_display_date(item['date'], 'tuang', counter_settings),
+ }
+ for item in tuang_db_data
+ ],
+ 'masuk_db_data': [
+ {
+ **item,
+ 'display_date': format_history_display_date(item['date'], 'masuk', counter_settings),
+ }
+ for item in masuk_db_data
+ ],
+ 'tuang_json_items': tuang_items,
+ 'masuk_json_items': masuk_items,
+ 'tuang_json_total': sum(item['karung'] for item in tuang_items),
+ 'masuk_json_total': sum(item['karung'] for item in masuk_items),
+ 'tuang_db_total': tuang_db_total,
+ 'masuk_db_total': masuk_db_total,
+ 'saldo_awal': saldo_awal,
+ 'saldo_akhir': saldo_awal + masuk_db_total - tuang_db_total,
+ 'current_date': today_section['date_label'],
+ }
+
+
+def parse_optional_k_index(raw_value):
+ if raw_value in (None, ''):
+ return None
+
+ try:
+ k_index = int(raw_value)
+ except (TypeError, ValueError):
+ abort(400, description='k_index must be a whole number.')
+
+ if k_index < 1:
+ abort(400, description='k_index must be at least 1.')
+
+ return k_index
+
+
+def parse_optional_cycle_id(raw_value):
+ if raw_value in (None, ''):
+ return None
+
+ try:
+ return int(raw_value)
+ except (TypeError, ValueError):
+ abort(400, description='cycle_id must be a whole number.')
+
+
+def sync_api_cycle_context_to_db(api_context):
+ ensure_settings_db()
+ counter_defaults = apply_env_counter_settings(DEFAULT_DASHBOARD_SETTINGS.copy())
+ synced_at = datetime.now().isoformat(timespec='seconds')
+ synced_kandang_ids = []
+
+ with sqlite3.connect(SETTINGS_DB) as conn:
+ for cycle in api_context.get('kandang_cycles', []):
+ kandang_id = cycle.get('kandang_id')
+ if kandang_id is None:
+ continue
+
+ synced_kandang_ids.append(kandang_id)
+ conn.execute(
+ """
+ UPDATE cycle_settings
+ SET is_active = 0
+ WHERE kandang_id = ?
+ AND (api_cycle_id IS NULL OR api_cycle_id != ?)
+ """,
+ (kandang_id, cycle.get('id')),
+ )
+
+ existing_row = conn.execute(
+ """
+ SELECT id
+ FROM cycle_settings
+ WHERE api_cycle_id = ?
+ """,
+ (cycle.get('id'),),
+ ).fetchone()
+
+ values = (
+ kandang_id,
+ cycle.get('k_index'),
+ cycle.get('cycle_name', ''),
+ cycle.get('cycle_start', ''),
+ cycle.get('cycle_end', ''),
+ cycle.get('saldo_awal', 0),
+ cycle.get('feed_initial_balance_date', ''),
+ cycle.get('status', ''),
+ str(cycle.get('flock')) if cycle.get('flock') is not None else None,
+ counter_defaults['tuang_cutoff_time'],
+ counter_defaults['masuk_cutoff_time'],
+ counter_defaults['local_timezone'],
+ 1,
+ synced_at,
+ cycle.get('id'),
+ )
+
+ if existing_row:
+ conn.execute(
+ """
+ UPDATE cycle_settings
+ SET kandang_id = ?,
+ k_index = ?,
+ cycle_name = ?,
+ cycle_start = ?,
+ cycle_end = ?,
+ saldo_awal = ?,
+ feed_initial_balance_date = ?,
+ cycle_status = ?,
+ flock = ?,
+ tuang_cutoff_time = ?,
+ masuk_cutoff_time = ?,
+ local_timezone = ?,
+ is_active = ?,
+ updated_at = ?
+ WHERE api_cycle_id = ?
+ """,
+ values,
+ )
+ else:
+ conn.execute(
+ """
+ INSERT INTO cycle_settings (
+ kandang_id,
+ k_index,
+ cycle_name,
+ cycle_start,
+ cycle_end,
+ saldo_awal,
+ feed_initial_balance_date,
+ cycle_status,
+ flock,
+ tuang_cutoff_time,
+ masuk_cutoff_time,
+ local_timezone,
+ is_active,
+ updated_at,
+ api_cycle_id
+ )
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ """,
+ values,
+ )
+
+ if synced_kandang_ids:
+ placeholders = ', '.join('?' for _ in synced_kandang_ids)
+ conn.execute(
+ f"""
+ UPDATE cycle_settings
+ SET is_active = 0
+ WHERE kandang_id IS NOT NULL
+ AND kandang_id NOT IN ({placeholders})
+ """,
+ synced_kandang_ids,
+ )
+
+
+def load_kandang_cycles_from_db():
+ ensure_settings_db()
+
+ with sqlite3.connect(SETTINGS_DB) as conn:
+ rows = conn.execute(
+ """
+ SELECT api_cycle_id,
+ kandang_id,
+ k_index,
+ cycle_name,
+ cycle_start,
+ cycle_end,
+ saldo_awal,
+ feed_initial_balance_date,
+ cycle_status,
+ flock,
+ tuang_cutoff_time,
+ masuk_cutoff_time,
+ local_timezone
+ FROM cycle_settings
+ WHERE kandang_id IS NOT NULL
+ AND is_active = 1
+ ORDER BY k_index, kandang_id
+ """
+ ).fetchall()
+
+ return [map_kandang_cycle_row(row) for row in rows]
+
def ensure_active_cycle(conn):
active_count = conn.execute(
@@ -321,6 +933,18 @@ def format_history_date(date_text):
return parsed_date.strftime('%Y-%m-%d')
+
+def format_history_display_date(date_text, counter_type, counter_settings):
+ return format_history_date(date_text)
+
+
+def get_masuk_calendar_dates_for_lookup(date_text, counter_settings):
+ stored_date = parse_data_date(date_text)
+ if not stored_date:
+ return set()
+
+ return {stored_date}
+
def filter_data_by_cycle(data, cycle_start, cycle_end):
if not cycle_start or not cycle_end:
return []
@@ -347,6 +971,91 @@ def is_masuk_out_item(item):
or source_name.endswith(' out')
)
+
+def is_masuk_in_item(item):
+ source_name = str(item.get('source', '')).lower().strip()
+ return (
+ source_name.endswith('_in')
+ or source_name.endswith('-in')
+ or source_name.endswith(' in')
+ ) and not is_masuk_out_item(item)
+
+
+def parse_feed_balance_date(balance_date_text):
+ balance_date_text = str(balance_date_text or '').strip()
+ if not balance_date_text:
+ return None
+
+ for date_format in (DISPLAY_DATE_FORMAT, '%Y-%m-%d'):
+ try:
+ return datetime.strptime(balance_date_text, date_format).date()
+ except ValueError:
+ continue
+
+ return parse_data_date(balance_date_text)
+
+
+def get_iot_masuk_in_total_for_date(masuk_data, k_index, balance_date_text, counter_settings=None):
+ balance_dates = get_masuk_calendar_dates_for_lookup(balance_date_text, counter_settings or default_counter_settings())
+ if not balance_dates:
+ return None, []
+
+ matched_items = []
+ for item in masuk_data:
+ if extract_k_index(item.get('source')) != k_index:
+ continue
+ if not is_masuk_in_item(item):
+ continue
+
+ item_date = parse_data_date(item['date'])
+ if item_date not in balance_dates:
+ continue
+
+ matched_items.append(item)
+
+ if not matched_items:
+ return None, []
+
+ return sum(item['counter_value'] for item in matched_items), matched_items
+
+
+def build_feed_initial_balance_verification(cycle, masuk_data, counter_settings=None):
+ counter_settings = counter_settings or default_counter_settings()
+ k_index = cycle.get('k_index')
+ feed_value = cycle.get('saldo_awal', 0) or 0
+ balance_date = cycle.get('feed_initial_balance_date') or ''
+ iot_value, iot_items = get_iot_masuk_in_total_for_date(
+ masuk_data,
+ k_index,
+ balance_date,
+ counter_settings,
+ )
+
+ if not balance_date:
+ verification_status = 'no_date'
+ is_match = None
+ elif iot_value is None:
+ verification_status = 'no_iot_data'
+ is_match = None
+ elif iot_value == feed_value:
+ verification_status = 'match'
+ is_match = True
+ else:
+ verification_status = 'mismatch'
+ is_match = False
+
+ return {
+ 'source': f'K{k_index} - In',
+ 'k_index': k_index,
+ 'kandang_id': cycle.get('kandang_id'),
+ 'feed_initial_balance': feed_value,
+ 'feed_initial_balance_date': balance_date,
+ 'iot_value': iot_value,
+ 'iot_sources': [item['source'] for item in iot_items],
+ 'is_match': is_match,
+ 'verification_status': verification_status,
+ }
+
def get_counter_settings_from_dashboard(dashboard_settings):
return apply_env_counter_settings({
'local_timezone': dashboard_settings.get(
@@ -439,12 +1148,13 @@ def get_json_counter_items(json_data):
return items
-def format_history_items(data):
+def format_history_items(data, counter_type='tuang', counter_settings=None):
+ counter_settings = counter_settings or default_counter_settings()
return [
{
'id': item['id'],
'source': item['source'],
- 'date': format_history_date(item['date']),
+ 'date': format_history_display_date(item['date'], counter_type, counter_settings),
'counter_value': item['counter_value'],
}
for item in data
@@ -489,7 +1199,8 @@ def filter_history_by_days(data, days):
filtered.append(item)
return filtered
-def build_combined_site_payload(source_name, today_items, history_items, is_masuk=False):
+def build_combined_site_payload(source_name, today_items, history_items, is_masuk=False, counter_settings=None):
+ counter_settings = counter_settings or default_counter_settings()
today_total = sum(item['karung'] for item in today_items)
if is_masuk:
history_total = sum(
@@ -502,7 +1213,11 @@ def build_combined_site_payload(source_name, today_items, history_items, is_masu
return {
'source': source_name,
'today': today_items,
- 'history': format_history_items(history_items),
+ 'history': format_history_items(
+ history_items,
+ 'masuk' if is_masuk else 'tuang',
+ counter_settings,
+ ),
'totals': {
'today': today_total,
'history': history_total,
@@ -529,20 +1244,21 @@ def format_business_date_label(tuang_date, masuk_date):
def get_counter_periods(cutoff_metadata):
return cutoff_metadata['counters']
-def build_today_section(cycle_start, cycle_end, counter_settings, cutoff_metadata=None):
+def build_today_section(cycle_context, counter_settings, cutoff_metadata=None):
cutoff_metadata = cutoff_metadata or get_cutoff_metadata(counter_settings)
counter_periods = get_counter_periods(cutoff_metadata)
tuang_date = parse_cycle_date(counter_periods['tuang']['business_date'], 'Tuang business date')
masuk_date = parse_cycle_date(counter_periods['masuk']['business_date'], 'Masuk business date')
- is_tuang_within_active_cycle = is_date_in_active_cycle(tuang_date, cycle_start, cycle_end)
- is_masuk_within_active_cycle = is_date_in_active_cycle(masuk_date, cycle_start, cycle_end)
tuang_items = []
masuk_items = []
- if is_tuang_within_active_cycle:
- tuang_items = get_json_counter_items(get_json_data(KARUNG_TUANG_JSON))
- if is_masuk_within_active_cycle:
- masuk_items = get_json_counter_items(get_json_data(KARUNG_MASUK_JSON))
+ for item in get_json_counter_items(get_json_data(KARUNG_TUANG_JSON)):
+ if is_camera_within_cycle(item['source'], tuang_date, cycle_context):
+ tuang_items.append(item)
+
+ for item in get_json_counter_items(get_json_data(KARUNG_MASUK_JSON)):
+ if is_camera_within_cycle(item['source'], masuk_date, cycle_context):
+ masuk_items.append(item)
return {
'date': counter_periods['tuang']['business_date'],
@@ -551,7 +1267,7 @@ def build_today_section(cycle_start, cycle_end, counter_settings, cutoff_metadat
counter_periods['masuk']['business_date']
),
'source': 'json',
- 'is_within_active_cycle': is_tuang_within_active_cycle or is_masuk_within_active_cycle,
+ 'is_within_active_cycle': bool(tuang_items or masuk_items),
'business_dates': {
'tuang': counter_periods['tuang']['business_date'],
'masuk': counter_periods['masuk']['business_date'],
@@ -565,28 +1281,30 @@ def build_today_section(cycle_start, cycle_end, counter_settings, cutoff_metadat
'masuk': masuk_items,
}
-def build_yesterday_history_section(cycle_start, cycle_end, counter_settings):
+def build_yesterday_history_section(cycle_context, counter_settings):
tuang_date = get_business_date('tuang', counter_settings) - timedelta(days=1)
masuk_date = get_business_date('masuk', counter_settings) - timedelta(days=1)
- is_tuang_within_active_cycle = is_date_in_active_cycle(tuang_date, cycle_start, cycle_end)
- is_masuk_within_active_cycle = is_date_in_active_cycle(masuk_date, cycle_start, cycle_end)
- tuang_data = []
- masuk_data = []
- if is_tuang_within_active_cycle:
- tuang_data = get_db_data(DB_CONFIGS['tuang']['path'], DB_CONFIGS['tuang']['table'])
- tuang_data = filter_data_by_cycle(tuang_data, cycle_start, cycle_end)
- tuang_data = filter_data_by_date(tuang_data, tuang_date)
- if is_masuk_within_active_cycle:
- masuk_data = get_db_data(DB_CONFIGS['masuk']['path'], DB_CONFIGS['masuk']['table'])
- masuk_data = filter_data_by_cycle(masuk_data, cycle_start, cycle_end)
- masuk_data = filter_data_by_date(masuk_data, masuk_date)
+ tuang_data = filter_data_by_date(
+ filter_data_by_kandang_cycles(
+ get_db_data(DB_CONFIGS['tuang']['path'], DB_CONFIGS['tuang']['table']),
+ cycle_context,
+ ),
+ tuang_date,
+ )
+ masuk_data = filter_data_by_date(
+ filter_data_by_kandang_cycles(
+ get_db_data(DB_CONFIGS['masuk']['path'], DB_CONFIGS['masuk']['table']),
+ cycle_context,
+ ),
+ masuk_date,
+ )
return {
'date': tuang_date.isoformat(),
'date_label': format_business_date_label(tuang_date.isoformat(), masuk_date.isoformat()),
'source': 'database',
- 'is_within_active_cycle': is_tuang_within_active_cycle or is_masuk_within_active_cycle,
+ 'is_within_active_cycle': bool(tuang_data or masuk_data),
'business_dates': {
'tuang': tuang_date.isoformat(),
'masuk': masuk_date.isoformat(),
@@ -598,8 +1316,8 @@ def build_yesterday_history_section(cycle_start, cycle_end, counter_settings):
for item in masuk_data
),
},
- 'tuang': format_history_items(tuang_data),
- 'masuk': format_history_items(masuk_data),
+ 'tuang': format_history_items(tuang_data, 'tuang', counter_settings),
+ 'masuk': format_history_items(masuk_data, 'masuk', counter_settings),
}
@app.errorhandler(HTTPException)
@@ -627,26 +1345,85 @@ def handle_unexpected_exception(error):
@app.route('/api/v1/cycles/active/sections', methods=['GET'])
def get_active_cycle_sections():
- dashboard_settings = load_dashboard_settings()
- cycle_start, cycle_end = get_active_cycle_dates(dashboard_settings)
+ cycle_context = load_cycle_context()
+ k_index = parse_optional_k_index(request.args.get('k_index'))
+ cycle_id = parse_optional_cycle_id(request.args.get('cycle_id'))
+ dashboard_settings = cycle_context['site_settings']
counter_settings = get_counter_settings_from_dashboard(dashboard_settings)
cutoff_metadata = get_cutoff_metadata(counter_settings)
+ selected_cycle = find_cycle_settings(cycle_context, k_index, cycle_id)
+ view_payload = build_dashboard_view_payload(
+ cycle_context,
+ counter_settings,
+ cutoff_metadata,
+ k_index=k_index,
+ cycle_id=cycle_id,
+ )
return api_success_response({
+ 'cycle_source': cycle_context['source'],
+ 'kandang_options': build_kandang_selector_options(cycle_context['kandang_cycles']),
+ 'cycles_by_kandang': serialize_cycles_for_client(cycle_context.get('all_cycles_by_kandang')),
+ 'selected': {
+ 'k_index': k_index,
+ 'cycle_id': selected_cycle.get('id') if selected_cycle else None,
+ },
+ 'cycles': [
+ {
+ 'id': cycle['id'],
+ 'kandang_id': cycle.get('kandang_id'),
+ 'k_index': cycle.get('k_index'),
+ 'name': cycle['cycle_name'],
+ 'start_date': parse_cycle_date(cycle['cycle_start'], 'Cycle start').isoformat()
+ if cycle.get('cycle_start') else None,
+ 'end_date': parse_cycle_date(cycle['cycle_end'], 'Cycle end').isoformat()
+ if cycle.get('cycle_end') else None,
+ 'saldo_awal': cycle['saldo_awal'],
+ 'feed_initial_balance_date': cycle.get('feed_initial_balance_date') or None,
+ 'status': cycle.get('status'),
+ }
+ for cycle in cycle_context['kandang_cycles']
+ ],
+ 'feed_initial_balances': view_payload['feed_initial_balances'],
'cycle': {
- 'id': dashboard_settings['id'],
- 'name': dashboard_settings['cycle_name'],
- 'start_date': cycle_start.isoformat() if cycle_start else None,
- 'end_date': cycle_end.isoformat() if cycle_end else None,
- 'saldo_awal': dashboard_settings['saldo_awal'],
+ 'id': selected_cycle.get('id', '') if selected_cycle else dashboard_settings['id'],
+ 'name': selected_cycle['cycle_name'] if selected_cycle else dashboard_settings['cycle_name'],
+ 'start_date': parse_cycle_date(selected_cycle['cycle_start'], 'Cycle start').isoformat()
+ if selected_cycle and selected_cycle.get('cycle_start') else (
+ parse_cycle_date(dashboard_settings['cycle_start'], 'Cycle start').isoformat()
+ if dashboard_settings.get('cycle_start') else None
+ ),
+ 'end_date': parse_cycle_date(selected_cycle['cycle_end'], 'Cycle end').isoformat()
+ if selected_cycle and selected_cycle.get('cycle_end') else (
+ parse_cycle_date(dashboard_settings['cycle_end'], 'Cycle end').isoformat()
+ if dashboard_settings.get('cycle_end') else None
+ ),
+ 'saldo_awal': view_payload['saldo_awal'],
'local_timezone': dashboard_settings['local_timezone'],
'tuang_cutoff_time': dashboard_settings['tuang_cutoff_time'],
'masuk_cutoff_time': dashboard_settings['masuk_cutoff_time'],
},
'cutoff': cutoff_metadata,
'sections': {
- 'today': build_today_section(cycle_start, cycle_end, counter_settings, cutoff_metadata),
- 'yesterday_history': build_yesterday_history_section(cycle_start, cycle_end, counter_settings),
+ 'today': {
+ **build_today_section(cycle_context, counter_settings, cutoff_metadata),
+ 'tuang': view_payload['tuang_json_items'],
+ 'masuk': view_payload['masuk_json_items'],
+ 'totals': {
+ 'tuang': view_payload['tuang_json_total'],
+ 'masuk': view_payload['masuk_json_total'],
+ },
+ 'is_within_active_cycle': bool(
+ view_payload['tuang_json_items'] or view_payload['masuk_json_items']
+ ),
+ },
+ 'yesterday_history': build_yesterday_history_section(cycle_context, counter_settings),
+ },
+ 'totals': {
+ 'tuang_db_total': view_payload['tuang_db_total'],
+ 'masuk_db_total': view_payload['masuk_db_total'],
+ 'saldo_awal': view_payload['saldo_awal'],
+ 'saldo_akhir': view_payload['saldo_akhir'],
},
})
@@ -673,8 +1450,13 @@ def get_combined():
history_days,
)
- tuang_payload = build_combined_site_payload('ktc', tuang_today, tuang_history, is_masuk=False)
- masuk_payload = build_combined_site_payload('kpc', masuk_today, masuk_history, is_masuk=True)
+ counter_settings = default_counter_settings()
+ tuang_payload = build_combined_site_payload(
+ 'ktc', tuang_today, tuang_history, is_masuk=False, counter_settings=counter_settings
+ )
+ masuk_payload = build_combined_site_payload(
+ 'kpc', masuk_today, masuk_history, is_masuk=True, counter_settings=counter_settings
+ )
pull_status = load_pull_status()
response = api_success_response({
@@ -701,55 +1483,55 @@ def get_combined():
@app.route('/')
def index():
- dashboard_settings = load_dashboard_settings()
+ cycle_context = load_cycle_context()
+ dashboard_settings = cycle_context['site_settings']
counter_settings = get_counter_settings_from_dashboard(dashboard_settings)
cutoff_metadata = get_cutoff_metadata(counter_settings)
- cycle_start = parse_cycle_date(dashboard_settings['cycle_start'], 'Cycle start')
- cycle_end = parse_cycle_date(dashboard_settings['cycle_end'], 'Cycle end')
- today_section = build_today_section(cycle_start, cycle_end, counter_settings, cutoff_metadata)
+ k_index = parse_optional_k_index(request.args.get('k_index'))
+ cycle_id = parse_optional_cycle_id(request.args.get('cycle_id'))
- # Get historical data from databases
- tuang_db_data = get_db_data(DB_CONFIGS['tuang']['path'], DB_CONFIGS['tuang']['table'])
- masuk_db_data = get_db_data(DB_CONFIGS['masuk']['path'], DB_CONFIGS['masuk']['table'])
- tuang_db_data = filter_data_by_cycle(tuang_db_data, cycle_start, cycle_end)
- masuk_db_data = filter_data_by_cycle(masuk_db_data, cycle_start, cycle_end)
- for item in tuang_db_data:
- item['display_date'] = format_history_date(item['date'])
+ if k_index is None and cycle_context['kandang_cycles']:
+ first_cycle = cycle_context['kandang_cycles'][0]
+ k_index = first_cycle.get('k_index')
+ cycle_id = first_cycle.get('id')
- tuang_json_data = {
- item['source']: {'karung': item['karung']}
- for item in today_section['tuang']
- }
- masuk_json_data = {
- item['source']: {'karung': item['karung']}
- for item in today_section['masuk']
- }
- tuang_json_total = today_section['totals']['tuang']
- masuk_json_total = today_section['totals']['masuk']
-
- # Calculate totals from database (historical)
- tuang_db_total = sum(item['counter_value'] for item in tuang_db_data)
- masuk_db_total = sum(
- item['counter_value'] if not is_masuk_out_item(item) else -item['counter_value']
- for item in masuk_db_data
+ view_payload = build_dashboard_view_payload(
+ cycle_context,
+ counter_settings,
+ cutoff_metadata,
+ k_index=k_index,
+ cycle_id=cycle_id,
)
- saldo_akhir = dashboard_settings['saldo_awal'] + masuk_db_total - tuang_db_total
-
- current_date = today_section['date_label']
+ selected_cycle = view_payload['selected_cycle']
return render_template('index.html',
dashboard_settings=dashboard_settings,
+ kandang_cycles=cycle_context['kandang_cycles'],
+ kandang_options=build_kandang_selector_options(cycle_context['kandang_cycles']),
+ cycles_by_kandang=serialize_cycles_for_client(cycle_context.get('all_cycles_by_kandang')),
+ selected_k_index=k_index,
+ selected_cycle_id=selected_cycle.get('id') if selected_cycle else None,
+ selected_cycle=selected_cycle,
+ feed_initial_balances=view_payload['feed_initial_balances'],
+ cycle_source=cycle_context['source'],
cutoff_metadata=cutoff_metadata,
- tuang_db_data=tuang_db_data,
- masuk_db_data=masuk_db_data,
- tuang_json_data=tuang_json_data,
- masuk_json_data=masuk_json_data,
- tuang_json_total=tuang_json_total,
- masuk_json_total=masuk_json_total,
- tuang_db_total=tuang_db_total,
- masuk_db_total=masuk_db_total,
- saldo_akhir=saldo_akhir,
- current_date=current_date)
+ tuang_db_data=view_payload['tuang_db_data'],
+ masuk_db_data=view_payload['masuk_db_data'],
+ tuang_json_data={
+ item['source']: {'karung': item['karung']}
+ for item in view_payload['tuang_json_items']
+ },
+ masuk_json_data={
+ item['source']: {'karung': item['karung']}
+ for item in view_payload['masuk_json_items']
+ },
+ tuang_json_total=view_payload['tuang_json_total'],
+ masuk_json_total=view_payload['masuk_json_total'],
+ tuang_db_total=view_payload['tuang_db_total'],
+ masuk_db_total=view_payload['masuk_db_total'],
+ saldo_akhir=view_payload['saldo_akhir'],
+ display_saldo_awal=view_payload['saldo_awal'],
+ current_date=view_payload['current_date'])
if __name__ == '__main__':
app.run(debug=True, host='0.0.0.0', port=8090)
diff --git a/cycle_api.py b/cycle_api.py
new file mode 100644
index 0000000..9521178
--- /dev/null
+++ b/cycle_api.py
@@ -0,0 +1,217 @@
+import json
+import os
+import re
+import urllib.error
+import urllib.parse
+import urllib.request
+from datetime import datetime
+
+DISPLAY_DATE_FORMAT = '%d-%m-%Y'
+K_CAMERA_PATTERN = re.compile(r'^K(\d+)\b', re.IGNORECASE)
+KANDANG_NUMBER_PATTERN = re.compile(r'kandang\s*(\d+)\s*$', re.IGNORECASE)
+
+
+def normalize_base_url(raw_url):
+ url = str(raw_url or '').strip().rstrip('/')
+ if not url:
+ return 'http://localhost:5002'
+ if not url.startswith(('http://', 'https://')):
+ url = f'http://{url}'
+ return url
+
+
+def get_cycle_api_config():
+ base_url = normalize_base_url(os.environ.get('CYCLE_API_BASE_URL', 'http://localhost:5002'))
+ api_key = os.environ.get('CYCLE_API_KEY', '').strip()
+ return base_url, api_key
+
+
+def is_cycle_api_configured():
+ _, api_key = get_cycle_api_config()
+ return bool(api_key)
+
+
+def fetch_paginated_results(path, *, timeout=15):
+ base_url, api_key = get_cycle_api_config()
+ if not api_key:
+ raise RuntimeError('CYCLE_API_KEY is not configured.')
+
+ results = []
+ next_url = urllib.parse.urljoin(f'{base_url}/', path.lstrip('/'))
+
+ while next_url:
+ request = urllib.request.Request(next_url, method='GET')
+ request.add_header('Accept', 'application/json')
+ request.add_header('X-API-Key', api_key)
+
+ try:
+ with urllib.request.urlopen(request, timeout=timeout) as response:
+ payload = json.loads(response.read().decode('utf-8'))
+ except urllib.error.HTTPError as error:
+ body = error.read().decode('utf-8', errors='replace')
+ raise RuntimeError(f'Cycle API request failed ({error.code}): {body}') from error
+ except (urllib.error.URLError, TimeoutError, json.JSONDecodeError) as error:
+ raise RuntimeError(f'Cycle API request failed: {error}') from error
+
+ if isinstance(payload, dict) and 'results' in payload:
+ page_results = payload.get('results') or []
+ if not isinstance(page_results, list):
+ raise RuntimeError('Cycle API paginated response is invalid.')
+ results.extend(page_results)
+ next_url = payload.get('next')
+ continue
+
+ if isinstance(payload, list):
+ results.extend(payload)
+ next_url = None
+ continue
+
+ raise RuntimeError('Cycle API response format is not supported.')
+
+ return results
+
+
+def extract_k_index(source_name):
+ match = K_CAMERA_PATTERN.match(str(source_name or '').strip())
+ if not match:
+ return None
+
+ return int(match.group(1))
+
+
+def extract_kandang_number(kandang_name):
+ match = KANDANG_NUMBER_PATTERN.search(str(kandang_name or '').strip())
+ if not match:
+ return None
+
+ return int(match.group(1))
+
+
+def find_kandang_by_k_index(kandangs, k_index):
+ if k_index is None:
+ return None
+
+ for kandang in kandangs:
+ kandang_name = str(kandang.get('kandang_name', '')).strip()
+ number_match = KANDANG_NUMBER_PATTERN.search(kandang_name)
+ if number_match and int(number_match.group(1)) == k_index:
+ return kandang
+
+ return None
+
+
+def format_dashboard_date(iso_date):
+ if not iso_date:
+ return ''
+
+ return datetime.strptime(iso_date, '%Y-%m-%d').strftime(DISPLAY_DATE_FORMAT)
+
+
+def select_cycle_for_kandang(cycles, kandang_id):
+ kandang_cycles = [
+ cycle for cycle in cycles
+ if cycle.get('kandang') == kandang_id
+ ]
+ if not kandang_cycles:
+ return None
+
+ active_cycles = [cycle for cycle in kandang_cycles if cycle.get('status') == 'active']
+ candidate_cycles = active_cycles or kandang_cycles
+ return max(candidate_cycles, key=lambda cycle: cycle.get('id', 0))
+
+
+def format_cycle_name(cycle_start, cycle_end):
+ start = str(cycle_start or '').strip()
+ end = str(cycle_end or '').strip()
+ if start and end:
+ return f'{start} - {end}'
+ if start:
+ return f'{start} - akhir data'
+ if end:
+ return f'awal data - {end}'
+ return 'Siklus Aktif'
+
+
+def build_cycle_settings(cycle, kandang):
+ kandang_name = kandang.get('kandang_name', f"Kandang {kandang.get('id')}")
+ cycle_start = format_dashboard_date(cycle.get('start_date'))
+ cycle_end = format_dashboard_date(cycle.get('end_date'))
+ return {
+ 'id': cycle.get('id', ''),
+ 'kandang_id': kandang.get('id'),
+ 'k_index': extract_kandang_number(kandang_name),
+ 'kandang_name': kandang_name,
+ 'cycle_name': format_cycle_name(cycle_start, cycle_end),
+ 'cycle_start': cycle_start,
+ 'cycle_end': cycle_end,
+ 'saldo_awal': cycle.get('feed_initial_balance') or 0,
+ 'feed_initial_balance_date': format_dashboard_date(cycle.get('feed_initial_balance_date')),
+ 'status': cycle.get('status', ''),
+ 'flock': cycle.get('flock'),
+ }
+
+
+def build_all_cycles_by_kandang(kandangs, cycles):
+ cycles_by_kandang = {}
+
+ for kandang in kandangs:
+ kandang_id = kandang.get('id')
+ if kandang_id is None:
+ continue
+
+ kandang_cycles = [
+ cycle for cycle in cycles
+ if cycle.get('kandang') == kandang_id
+ ]
+ kandang_cycles.sort(key=lambda cycle: cycle.get('id', 0), reverse=True)
+ cycles_by_kandang[kandang_id] = [
+ build_cycle_settings(cycle, kandang)
+ for cycle in kandang_cycles
+ ]
+
+ return cycles_by_kandang
+
+
+def load_kandang_cycle_context():
+ kandangs = fetch_paginated_results('/api/v1/kandangs/')
+ cycles = fetch_paginated_results('/api/v1/cycles/')
+
+ kandang_by_k_index = {}
+ cycle_by_kandang_id = {}
+ kandang_cycles = []
+
+ for kandang in kandangs:
+ k_index = extract_kandang_number(kandang.get('kandang_name'))
+ if k_index is not None:
+ kandang_by_k_index[k_index] = kandang
+
+ cycle = select_cycle_for_kandang(cycles, kandang.get('id'))
+ if not cycle:
+ continue
+
+ cycle_settings = build_cycle_settings(cycle, kandang)
+ cycle_by_kandang_id[kandang['id']] = cycle_settings
+ kandang_cycles.append(cycle_settings)
+
+ kandang_cycles.sort(
+ key=lambda item: (item.get('k_index') is None, item.get('k_index') or 0, item.get('kandang_id') or 0)
+ )
+ return {
+ 'kandangs': kandangs,
+ 'kandang_by_k_index': kandang_by_k_index,
+ 'cycle_by_kandang_id': cycle_by_kandang_id,
+ 'kandang_cycles': kandang_cycles,
+ 'all_cycles_by_kandang': build_all_cycles_by_kandang(kandangs, cycles),
+ }
+
+
+def get_cycle_settings_for_camera(source_name, cycle_context):
+ k_index = extract_k_index(source_name)
+ if k_index is None:
+ return None
+
+ kandang = find_kandang_by_k_index(cycle_context.get('kandangs', []), k_index)
+ if not kandang:
+ return None
+
+ return cycle_context.get('cycle_by_kandang_id', {}).get(kandang['id'])
diff --git a/dashboard_settings.db b/dashboard_settings.db
index 0cd115c7ed44e9e46652e10beba76fb77425b00e..18f283314ced03dfada5468d0d333a0d24eadfd9 100644
GIT binary patch
literal 16384
zcmeI3&2HO95XYC25?hvNwM7A=D1xjGi7i-aDN-Lcdx`2WscJ=zWeSQ<#73kHLNsMZ
zRGRjd0=eWVax3}>eT19>iP1{C4&rjC6F^
zusw_R22W0zM-}oB5d?CFQbNdW_$1&HmJC#)??TwI;CY*T@$yqqOp$o%9T6+(U*LfS
z2_OL^fCP{L50phAGYcbYOQ0suN~7`TR*5b!OR1#
zspq*FV{^U-RM#E>r3tCaMh!jo#N&t2yV|{4yP?y~&C6z3uDRs+A#==u!Mv-e|}px=;g!9;~Y$^h4bOptfs!Hmvk9a~PZj!^ix$^K)wu@rlf@
zB=cwHedc$lU_k;%00|%gB!C2v01`j~NB{{S0VIF~{<{RO#RVZD`uBL+zsFLEcv4ss
z(?63`${_2{*S9i1Wt!{n#W&*9^d98w-$FAMB!C2v01`j~NB{{S0VIF~mM3s7eoSs=
zvx1VmapRUC2%nMc{y6zZWl1hby9Ibuq;jDs1vS65;x|i0y;xRDB~{)D^OkaHoWG2w
z7!$GBI7KQ~3(`(tVvvqXCX*4#;$@O&A1E206*Si-f~(+T(iap%AFmcHsl|#~sx0zx
zthfG}m^{f)c#&TwMRa7Bi7
z&aL>5>53tOcT`1DmECZ~VKzN#_@0m=w?uJO@mG9g_Z`S~*ZQ8_o3~S5WCzY0DpZ@I0kbb&+?SYnK>a0>4~U%cVtzMPMdmej{*U
nK>|ns2_OL^fCP{L5n%}%m~CB6LpM*r5NGVtEu*~znWv!H-G
z&*Ysvp^RrIHlE~aQDtHm*VJU}VBPG%E6c>FxmnQQHvhy4s$#r+CmGmz%NY0?`84>K
Y^ZW6A+Bme*a
diff --git a/karung_masuk.db b/karung_masuk.db
index 56a600be2eff24110951ea528cf7845a9730fb85..d155d6763b37e2d6fbe26125389fa6fa1501450c 100644
GIT binary patch
literal 12288
zcmeI2!EO>k7{_OpN^9-zw#1|dqnXA7ZD7srl45OQ(v^;euu$ACiFhF^T@?k|!eZjp
zcrd@h|NQN&D$IUZCj7RDR#D))aI3c2W#Q12Jo^dmcRs#CdyP-e5d4yo<2cconn<
z;iOMIAGJDRKMFc0!(hKy=4_wecWSQB3N_CYl^gQSL*FS^XS#36vw3RejOb_jC;heV
z>Nm7s+IQ`P_CmX>>FN*lv-(bbtQsm25Dqke2G9T+Km%w14WI!ufCkXO|79SPN|Ngv
zOSzKG40bSxY}3vg<{iVdCM%nn)~;#pnz_tcl3d%Mf8b(wLRi(watdA@C0qZHoYiHr
zk}FAgI+=WgqBW@Wj$z-1U==7WS+Mj9R5~U(dj%*hS@I3)u^dx655bq=(s>5KOEIOv
z(2WE_$M~V
Jv4K<|xBwIn5I_I`
diff --git a/karung_masuk.json b/karung_masuk.json
index bc67256..c28b2b0 100644
--- a/karung_masuk.json
+++ b/karung_masuk.json
@@ -1,8 +1,14 @@
{
- "K1-L1-in": {
+ "K1 - In": {
+ "karung": 160
+ },
+ "K1 - Out": {
"karung": 0
},
- "K1-L1-out": {
+ "K2 - In": {
+ "karung": 80
+ },
+ "K2 - Out": {
"karung": 0
}
}
diff --git a/karung_tuang.db b/karung_tuang.db
index c1c9b175e1a3e5f676dacf6799053ba9937c7532..c551bec5fa0d455c186a29187918d07a00274bd4 100644
GIT binary patch
delta 209
zcmZojXh@hK&B!!S##xYwL9a`Mmw|zSiEjr3KNsJQ&4L0Re4BUh`7rXZ@Xui2f5v}?
ze-r-w~>ObLTGWSk%5t!u7QQFkp-&+GepJ^Q-%q<9&=_1M(ldb
nnL!R>K3U`T!4d
diff --git a/karung_tuang.json b/karung_tuang.json
index ff22611..e69f70f 100644
--- a/karung_tuang.json
+++ b/karung_tuang.json
@@ -1,8 +1,8 @@
{
- "K1-L1-left": {
+ "K1 - Use": {
"karung": 0
},
- "K1-L1-right": {
+ "K2 - Use": {
"karung": 0
}
}
diff --git a/pull_sources.py b/pull_sources.py
index 2e02a5f..2ea8d88 100644
--- a/pull_sources.py
+++ b/pull_sources.py
@@ -138,6 +138,22 @@ def build_source_camera_name(source_index, camera_suffix):
return f"K{source_index} - {camera_suffix}"
+def extract_history_calendar_date(row):
+ """Use the actual activity date from KPC history, not the forward-looking counting_date."""
+ start_time = row.get("start_time")
+ if start_time:
+ try:
+ parsed = datetime.fromisoformat(str(start_time).replace("Z", "+00:00"))
+ if parsed.tzinfo is not None:
+ parsed = parsed.astimezone()
+ return parsed.date().isoformat()
+ except ValueError:
+ pass
+
+ counting_date = row.get("date") or row.get("counting_date")
+ return str(counting_date) if counting_date else None
+
+
def map_live_payload(payload, field_map, source_index):
"""Map /api/current-counter fields to live JSON source entries."""
mapped = {}
@@ -157,7 +173,7 @@ def map_history_rows(rows, field_map, source_index):
"""Map /api/history data rows to (camera_name, date, counter_value) tuples."""
mapped_rows = []
for row in rows or []:
- counting_date = row.get("date") or row.get("counting_date")
+ counting_date = extract_history_calendar_date(row)
if not counting_date:
continue
totals_by_camera = {}
diff --git a/templates/index.html b/templates/index.html
index 70b7114..1074bcf 100644
--- a/templates/index.html
+++ b/templates/index.html
@@ -300,13 +300,70 @@
.hero-panel {
position: relative;
overflow: hidden;
+ display: block;
+ padding: 0 0 20px;
+ border: 0;
+ background: transparent;
+ box-shadow: none;
+ }
+
+ .hero-content h1 {
+ margin-bottom: 0;
+ }
+
+ .dashboard-toolbar {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: end;
+ gap: 14px 18px;
+ margin: 18px 0 24px;
+ padding: 16px 18px;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+ background: var(--surface-strong);
+ }
+
+ .toolbar-group {
display: grid;
- grid-template-columns: minmax(0, 1fr) 280px;
- gap: 24px;
- align-items: stretch;
- padding: 28px;
- border-radius: var(--radius-lg);
- box-shadow: var(--shadow), var(--glow);
+ gap: 6px;
+ min-width: 180px;
+ flex: 1 1 180px;
+ }
+
+ .toolbar-group-end {
+ margin-left: auto;
+ flex: 0 1 220px;
+ }
+
+ .toolbar-group label {
+ color: var(--muted);
+ font-size: 12px;
+ font-weight: 800;
+ letter-spacing: 0.12em;
+ text-transform: uppercase;
+ }
+
+ .toolbar-select {
+ width: 100%;
+ min-height: 42px;
+ padding: 0 14px;
+ color: var(--text);
+ border: 1px solid var(--border);
+ border-radius: 12px;
+ background: var(--input-bg);
+ outline: none;
+ }
+
+ .toolbar-select:focus {
+ border-color: var(--accent);
+ box-shadow: 0 0 0 3px rgba(56, 189, 248, 0.18);
+ }
+
+ .cycle-summary-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
+ gap: 14px;
+ margin-top: 16px;
}
html[data-theme="futuristic"] .hero-panel {
@@ -408,7 +465,8 @@
z-index: 1;
display: flex;
flex-direction: column;
- justify-content: space-between;
+ justify-content: flex-start;
+ gap: 12px;
min-height: 168px;
padding: 18px;
border: 1px solid var(--border);
@@ -416,6 +474,11 @@
background: var(--surface-strong);
}
+ .selector-stack {
+ display: grid;
+ gap: 12px;
+ }
+
.theme-card label,
.selected-cycle-label,
.metric-label {
@@ -487,6 +550,97 @@
gap: 14px;
}
+ .kandang-cycle-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
+ gap: 14px;
+ }
+
+ .kandang-cycle-card {
+ display: grid;
+ gap: 8px;
+ }
+
+ .kandang-cycle-meta {
+ color: var(--muted);
+ font-size: 13px;
+ line-height: 1.5;
+ }
+
+ .feed-balance-section {
+ margin-top: 22px;
+ padding-top: 22px;
+ border-top: 1px solid var(--border);
+ }
+
+ .feed-balance-section h3 {
+ margin-bottom: 14px;
+ color: var(--heading);
+ font-size: 18px;
+ }
+
+ .feed-balance-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
+ gap: 14px;
+ }
+
+ .feed-balance-card {
+ display: grid;
+ gap: 10px;
+ min-height: 120px;
+ padding: 18px;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+ background: var(--surface-soft);
+ }
+
+ .feed-balance-value {
+ color: var(--heading);
+ font-size: 28px;
+ font-weight: 700;
+ line-height: 1.1;
+ }
+
+ .feed-balance-meta {
+ color: var(--muted);
+ font-size: 13px;
+ line-height: 1.5;
+ }
+
+ .feed-balance-status {
+ display: inline-flex;
+ align-items: center;
+ width: fit-content;
+ padding: 6px 10px;
+ border-radius: 999px;
+ font-size: 12px;
+ font-weight: 700;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+ }
+
+ .feed-balance-status.match {
+ color: #166534;
+ background: rgba(34, 197, 94, 0.16);
+ }
+
+ .feed-balance-status.mismatch {
+ color: #991b1b;
+ background: rgba(239, 68, 68, 0.16);
+ }
+
+ .feed-balance-status.pending {
+ color: #92400e;
+ background: rgba(245, 158, 11, 0.16);
+ }
+
+ .feed-balance-date {
+ color: var(--heading);
+ font-size: 18px;
+ font-weight: 600;
+ }
+
.cycle-stat {
min-height: 104px;
padding: 18px;
@@ -850,6 +1004,35 @@
text-align: center;
}
+ html[data-theme="classic"] .dashboard-toolbar {
+ margin: 0 0 24px;
+ padding: 14px 16px;
+ border: 1px solid #dddddd;
+ border-radius: 8px;
+ background: #f8f9fa;
+ box-shadow: var(--shadow);
+ text-align: left;
+ }
+
+ html[data-theme="classic"] .toolbar-group label {
+ color: #2c3e50;
+ font-size: 12px;
+ font-weight: 600;
+ letter-spacing: 0;
+ text-transform: none;
+ }
+
+ html[data-theme="classic"] .toolbar-select {
+ color: #333333;
+ border: 1px solid #cccccc;
+ border-radius: 5px;
+ background: #ffffff;
+ }
+
+ html[data-theme="classic"] .theme-card {
+ display: none;
+ }
+
html[data-theme="classic"] .hero-panel::after,
html[data-theme="classic"] .eyebrow,
html[data-theme="classic"] .hero-copy {
@@ -858,26 +1041,13 @@
html[data-theme="classic"] h1 {
max-width: none;
- margin-bottom: 30px;
+ margin-bottom: 18px;
color: #333333;
font-size: 32px;
line-height: 1.2;
letter-spacing: 0;
}
- html[data-theme="classic"] .theme-card {
- align-items: flex-start;
- min-height: auto;
- margin: 0 0 30px auto;
- padding: 15px;
- border: 0;
- border-left: 4px solid var(--accent);
- background: #f8f9fa;
- box-shadow: var(--shadow);
- text-align: left;
- }
-
- html[data-theme="classic"] .theme-card label,
html[data-theme="classic"] .selected-cycle-label,
html[data-theme="classic"] .metric-label {
color: #2c3e50;
@@ -887,17 +1057,6 @@
text-transform: none;
}
- html[data-theme="classic"] .theme-hint {
- margin-top: 6px;
- }
-
- html[data-theme="classic"] .theme-select {
- color: #333333;
- border: 1px solid #cccccc;
- border-radius: 5px;
- background: #ffffff;
- }
-
html[data-theme="classic"] .selected-cycle {
display: block;
margin: 0 0 30px;
@@ -919,7 +1078,8 @@
display: none;
}
- html[data-theme="classic"] .selected-cycle-details {
+ html[data-theme="classic"] .selected-cycle-details,
+ html[data-theme="classic"] .cycle-summary-grid {
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
margin-top: 12px;
gap: 12px;
@@ -1149,11 +1309,19 @@
}
.hero-panel,
- .selected-cycle {
+ .selected-cycle,
+ .dashboard-toolbar {
grid-template-columns: 1fr;
padding: 20px;
}
+ .toolbar-group,
+ .toolbar-group-end {
+ min-width: 100%;
+ margin-left: 0;
+ flex-basis: 100%;
+ }
+
.selected-cycle-details,
.realtime-grid {
grid-template-columns: 1fr;
@@ -1194,12 +1362,26 @@
Monitor realtime JSON counters, active cycle totals, balance movement, and database history from one responsive control surface.
-
-
-
-
Choose the dashboard style. Your selection is saved on this browser.
-
-
+
+
+
Saldo Awal Pakan
+
+ {% for item in feed_initial_balances %}
+
+ {{ item.source }}
+ {{ item.feed_initial_balance }} karung
+ {{ item.feed_initial_balance_date or 'belum diatur' }}
+
+ IoT {{ item.source }}:
+ {% if item.iot_value is not none %}{{ item.iot_value }} karung{% else %}belum ada data{% endif %}
+
+ {% if item.verification_status == 'match' %}
+ Cocok dengan IoT
+ {% elif item.verification_status == 'mismatch' %}
+ Tidak cocok dengan IoT
+ {% elif item.verification_status == 'no_date' %}
+ Tanggal belum diatur
+ {% else %}
+ Belum ada data IoT
+ {% endif %}
+
+ {% endfor %}
+
+
Tidak ada data saldo awal pakan untuk kandang ini
+
@@ -1326,7 +1536,7 @@
Database history
Data Historis
- Siklus aktif: {{ dashboard_settings.cycle_name }}. Rentang: {{ dashboard_settings.cycle_start }} sampai {{ dashboard_settings.cycle_end }}.
+ Siklus per kandang (K1 = Kandang 1, K2 = Kandang 2, dst). Data historis difilter sesuai siklus masing-masing kandang.
@@ -1385,7 +1595,7 @@
MASUK {{ item.source }}
|
- {{ item.date }} |
+ {{ item.display_date }} |
{{ item.counter_value }} |
{% endfor %}
@@ -1402,7 +1612,14 @@