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 0cd115c..18f2833 100644 Binary files a/dashboard_settings.db and b/dashboard_settings.db differ diff --git a/karung_masuk.db b/karung_masuk.db index 56a600b..d155d67 100644 Binary files a/karung_masuk.db and b/karung_masuk.db differ 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 c1c9b17..c551bec 100644 Binary files a/karung_tuang.db and b/karung_tuang.db differ 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.

-
- + {% for option in kandang_options %} + + {% endfor %} + +
+
+ + +
+
+ +
- +
Active cycle

Cycle Command Center

-

Active cycle data is loaded from the configured source and shown read-only.

-
+ {% if selected_cycle %} +
- Cycle Name - {{ dashboard_settings.cycle_name }} + Kandang + + {% if selected_cycle.k_index %}K{{ selected_cycle.k_index }}{% endif %}{% if selected_cycle.kandang_name %} · {{ selected_cycle.kandang_name }}{% endif %} +
- Range - {{ dashboard_settings.cycle_start or 'awal data' }} - {{ dashboard_settings.cycle_end or 'akhir data' }} -
-
- Saldo Awal - {{ dashboard_settings.saldo_awal }} + Status + {{ selected_cycle.status or '-' }}
Timezone @@ -1241,13 +1421,16 @@ {{ dashboard_settings.masuk_cutoff_time }}
+ {% else %} +

Tidak ada data siklus kandang yang tersedia.

+ {% endif %}
Cycle Name -
{{ dashboard_settings.cycle_name }}
+
{{ selected_cycle.cycle_name if selected_cycle else dashboard_settings.cycle_name }}
@@ -1262,22 +1445,22 @@
Total Tuang Siklus -
{{ tuang_db_total }}
+
{{ tuang_db_total }}
Total Masuk Siklus -
{{ masuk_db_total }}
+
{{ masuk_db_total }}
Saldo Awal -
{{ dashboard_settings.saldo_awal }}
+
{{ display_saldo_awal }}
Saldo Akhir -
{{ saldo_akhir }}
+
{{ saldo_akhir }}
@@ -1318,6 +1501,33 @@

Tidak ada data karung masuk untuk hari ini

+ +
+

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 @@