forked from ariska/karung-web-admin
1543 lines
51 KiB
Python
1543 lines
51 KiB
Python
from flask import Flask, abort, jsonify, render_template, request, url_for
|
|
import sqlite3
|
|
import json
|
|
from datetime import datetime, timedelta, timezone
|
|
import os
|
|
from pathlib import Path
|
|
import uuid
|
|
import logging
|
|
|
|
from counter_time import (
|
|
apply_env_counter_settings,
|
|
default_counter_settings,
|
|
get_cutoff_metadata,
|
|
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'
|
|
|
|
def load_env_file(env_path):
|
|
if not env_path.exists():
|
|
return
|
|
|
|
with env_path.open() as env_file:
|
|
for line in env_file:
|
|
stripped_line = line.strip()
|
|
if not stripped_line or stripped_line.startswith('#') or '=' not in stripped_line:
|
|
continue
|
|
|
|
key, value = stripped_line.split('=', 1)
|
|
os.environ.setdefault(key.strip(), value.strip().strip('"').strip("'"))
|
|
|
|
def get_configured_db_path(env_name, default_file_name):
|
|
db_path = Path(os.environ.get(env_name, default_file_name)).expanduser()
|
|
if not db_path.is_absolute():
|
|
db_path = BASE_DIR / db_path
|
|
|
|
return db_path
|
|
|
|
load_env_file(BASE_DIR / '.env')
|
|
|
|
COUNTER_SETTING_DEFAULTS = default_counter_settings()
|
|
|
|
KARUNG_TUANG_DB = get_configured_db_path('KARUNG_TUANG_DB', 'karung_tuang.db')
|
|
KARUNG_MASUK_DB = get_configured_db_path('KARUNG_MASUK_DB', 'karung_masuk.db')
|
|
KARUNG_TUANG_JSON = get_configured_db_path('KARUNG_TUANG_JSON', 'karung_tuang.json')
|
|
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.')
|
|
|
|
DB_CONFIGS = {
|
|
'tuang': {
|
|
'path': KARUNG_TUANG_DB,
|
|
'table': 'counter_data',
|
|
},
|
|
'masuk': {
|
|
'path': KARUNG_MASUK_DB,
|
|
'table': 'karung_counts',
|
|
},
|
|
}
|
|
|
|
DEFAULT_DASHBOARD_SETTINGS = {
|
|
'id': '',
|
|
'cycle_name': 'Siklus Aktif',
|
|
'cycle_start': '',
|
|
'cycle_end': '',
|
|
'saldo_awal': 0,
|
|
'tuang_cutoff_time': COUNTER_SETTING_DEFAULTS['tuang_cutoff_time'],
|
|
'masuk_cutoff_time': COUNTER_SETTING_DEFAULTS['masuk_cutoff_time'],
|
|
'local_timezone': COUNTER_SETTING_DEFAULTS['local_timezone'],
|
|
}
|
|
|
|
def get_db_data(db_path, table_name):
|
|
"""Fetch all data from database"""
|
|
db_path = Path(db_path)
|
|
if not db_path.exists():
|
|
return []
|
|
|
|
try:
|
|
conn = sqlite3.connect(db_path)
|
|
cursor = conn.cursor()
|
|
cursor.execute(f"SELECT id, camera_name, date, counter_value FROM {table_name} ORDER BY date DESC")
|
|
rows = cursor.fetchall()
|
|
conn.close()
|
|
except sqlite3.OperationalError:
|
|
return []
|
|
|
|
data = []
|
|
for row in rows:
|
|
data.append({
|
|
'id': row[0],
|
|
'source': row[1],
|
|
'date': row[2],
|
|
'counter_value': row[3]
|
|
})
|
|
return data
|
|
|
|
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:
|
|
row = conn.execute(
|
|
"""
|
|
SELECT id,
|
|
cycle_name,
|
|
cycle_start,
|
|
cycle_end,
|
|
saldo_awal,
|
|
tuang_cutoff_time,
|
|
masuk_cutoff_time,
|
|
local_timezone
|
|
FROM cycle_settings
|
|
ORDER BY is_active DESC, id DESC
|
|
LIMIT 1
|
|
"""
|
|
).fetchone()
|
|
|
|
if not row:
|
|
return apply_env_counter_settings(DEFAULT_DASHBOARD_SETTINGS.copy())
|
|
|
|
return apply_env_counter_settings({
|
|
'id': row[0],
|
|
'cycle_name': row[1],
|
|
'cycle_start': row[2],
|
|
'cycle_end': row[3],
|
|
'saldo_awal': row[4],
|
|
'tuang_cutoff_time': row[5],
|
|
'masuk_cutoff_time': row[6],
|
|
'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(
|
|
"""
|
|
SELECT sql
|
|
FROM sqlite_master
|
|
WHERE type = 'table'
|
|
AND name = 'cycle_settings'
|
|
"""
|
|
).fetchone()
|
|
|
|
if existing_schema and 'CHECK (id = 1)' in existing_schema[0]:
|
|
conn.execute('ALTER TABLE cycle_settings RENAME TO cycle_settings_old')
|
|
create_cycle_settings_table(conn)
|
|
conn.execute(
|
|
"""
|
|
INSERT INTO cycle_settings (
|
|
cycle_name,
|
|
cycle_start,
|
|
cycle_end,
|
|
saldo_awal,
|
|
tuang_cutoff_time,
|
|
masuk_cutoff_time,
|
|
local_timezone,
|
|
is_active,
|
|
updated_at
|
|
)
|
|
SELECT cycle_name,
|
|
cycle_start,
|
|
cycle_end,
|
|
saldo_awal,
|
|
?,
|
|
?,
|
|
?,
|
|
0,
|
|
updated_at
|
|
FROM cycle_settings_old
|
|
ORDER BY id
|
|
"""
|
|
,
|
|
(
|
|
DEFAULT_DASHBOARD_SETTINGS['tuang_cutoff_time'],
|
|
DEFAULT_DASHBOARD_SETTINGS['masuk_cutoff_time'],
|
|
DEFAULT_DASHBOARD_SETTINGS['local_timezone'],
|
|
)
|
|
)
|
|
conn.execute('DROP TABLE cycle_settings_old')
|
|
else:
|
|
create_cycle_settings_table(conn)
|
|
ensure_settings_columns(conn)
|
|
|
|
settings_count = conn.execute('SELECT COUNT(*) FROM cycle_settings').fetchone()[0]
|
|
if settings_count:
|
|
ensure_active_cycle(conn)
|
|
return
|
|
|
|
conn.execute(
|
|
"""
|
|
INSERT OR IGNORE INTO cycle_settings (
|
|
cycle_name,
|
|
cycle_start,
|
|
cycle_end,
|
|
saldo_awal,
|
|
tuang_cutoff_time,
|
|
masuk_cutoff_time,
|
|
local_timezone,
|
|
is_active,
|
|
updated_at
|
|
)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?)
|
|
""",
|
|
(
|
|
DEFAULT_DASHBOARD_SETTINGS['cycle_name'],
|
|
DEFAULT_DASHBOARD_SETTINGS['cycle_start'],
|
|
DEFAULT_DASHBOARD_SETTINGS['cycle_end'],
|
|
DEFAULT_DASHBOARD_SETTINGS['saldo_awal'],
|
|
DEFAULT_DASHBOARD_SETTINGS['tuang_cutoff_time'],
|
|
DEFAULT_DASHBOARD_SETTINGS['masuk_cutoff_time'],
|
|
DEFAULT_DASHBOARD_SETTINGS['local_timezone'],
|
|
datetime.now().isoformat(timespec='seconds')
|
|
)
|
|
)
|
|
|
|
def create_cycle_settings_table(conn):
|
|
conn.execute(
|
|
"""
|
|
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',
|
|
is_active INTEGER NOT NULL DEFAULT 0,
|
|
updated_at TEXT NOT NULL
|
|
)
|
|
"""
|
|
)
|
|
|
|
def ensure_settings_columns(conn):
|
|
columns = {
|
|
row[1]
|
|
for row in conn.execute('PRAGMA table_info(cycle_settings)').fetchall()
|
|
}
|
|
if 'is_active' not in columns:
|
|
conn.execute(
|
|
"""
|
|
ALTER TABLE cycle_settings
|
|
ADD COLUMN is_active INTEGER NOT NULL DEFAULT 0
|
|
"""
|
|
)
|
|
if 'tuang_cutoff_time' not in columns:
|
|
conn.execute(
|
|
"""
|
|
ALTER TABLE cycle_settings
|
|
ADD COLUMN tuang_cutoff_time TEXT NOT NULL DEFAULT '17:00:00'
|
|
"""
|
|
)
|
|
if 'masuk_cutoff_time' not in columns:
|
|
conn.execute(
|
|
"""
|
|
ALTER TABLE cycle_settings
|
|
ADD COLUMN masuk_cutoff_time TEXT NOT NULL DEFAULT '24:00:00'
|
|
"""
|
|
)
|
|
if 'local_timezone' not in columns:
|
|
conn.execute(
|
|
"""
|
|
ALTER TABLE cycle_settings
|
|
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(
|
|
'SELECT COUNT(*) FROM cycle_settings WHERE is_active = 1'
|
|
).fetchone()[0]
|
|
if active_count:
|
|
return
|
|
|
|
latest_id = conn.execute(
|
|
'SELECT id FROM cycle_settings ORDER BY id DESC LIMIT 1'
|
|
).fetchone()[0]
|
|
conn.execute('UPDATE cycle_settings SET is_active = 1 WHERE id = ?', (latest_id,))
|
|
|
|
def parse_cycle_date(date_text, field_name):
|
|
if not date_text:
|
|
return None
|
|
date_text = str(date_text).strip()
|
|
if not date_text:
|
|
return None
|
|
|
|
for date_format in (DISPLAY_DATE_FORMAT, '%Y-%m-%d'):
|
|
try:
|
|
return datetime.strptime(date_text, date_format).date()
|
|
except ValueError:
|
|
continue
|
|
|
|
abort(400, description=f'{field_name} must use DD-MM-YYYY or YYYY-MM-DD format.')
|
|
|
|
def parse_data_date(date_text):
|
|
date_text = str(date_text).strip()
|
|
if not date_text:
|
|
return None
|
|
|
|
try:
|
|
return datetime.fromisoformat(date_text.replace('Z', '+00:00')).date()
|
|
except ValueError:
|
|
pass
|
|
|
|
for date_format in (DISPLAY_DATE_FORMAT, '%d/%m/%Y'):
|
|
try:
|
|
return datetime.strptime(date_text, date_format).date()
|
|
except ValueError:
|
|
continue
|
|
|
|
return None
|
|
|
|
def format_history_date(date_text):
|
|
parsed_date = parse_data_date(date_text)
|
|
if not parsed_date:
|
|
return 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:
|
|
return []
|
|
|
|
filtered_data = []
|
|
for item in data:
|
|
item_date = parse_data_date(item['date'])
|
|
if not item_date:
|
|
continue
|
|
if item_date < cycle_start:
|
|
continue
|
|
if cycle_end and item_date > cycle_end:
|
|
continue
|
|
|
|
filtered_data.append(item)
|
|
|
|
return filtered_data
|
|
|
|
def is_masuk_out_item(item):
|
|
source_name = str(item.get('source', '')).lower().strip()
|
|
return (
|
|
source_name.endswith('_out')
|
|
or source_name.endswith('-out')
|
|
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(
|
|
'local_timezone',
|
|
DEFAULT_DASHBOARD_SETTINGS['local_timezone']
|
|
),
|
|
'tuang_cutoff_time': dashboard_settings.get(
|
|
'tuang_cutoff_time',
|
|
DEFAULT_DASHBOARD_SETTINGS['tuang_cutoff_time']
|
|
),
|
|
'masuk_cutoff_time': dashboard_settings.get(
|
|
'masuk_cutoff_time',
|
|
DEFAULT_DASHBOARD_SETTINGS['masuk_cutoff_time']
|
|
),
|
|
})
|
|
|
|
def get_json_data(json_path):
|
|
"""Fetch current data from JSON file"""
|
|
try:
|
|
with open(json_path, 'r') as f:
|
|
return json.load(f)
|
|
except (FileNotFoundError, json.JSONDecodeError):
|
|
return {}
|
|
|
|
def get_utc_timestamp():
|
|
return datetime.now(timezone.utc).isoformat(timespec='seconds').replace('+00:00', 'Z')
|
|
|
|
def get_request_id():
|
|
return request.headers.get('X-Request-ID') or str(uuid.uuid4())
|
|
|
|
def api_success_response(data, status_code=200):
|
|
request_id = get_request_id()
|
|
response = jsonify({
|
|
'data': data,
|
|
'meta': {
|
|
'timestamp': get_utc_timestamp(),
|
|
'path': request.path,
|
|
'requestId': request_id,
|
|
},
|
|
})
|
|
response.status_code = status_code
|
|
response.headers['X-Request-ID'] = request_id
|
|
return response
|
|
|
|
def api_error_response(error, status_code, error_code):
|
|
request_id = get_request_id()
|
|
response = jsonify({
|
|
'error': {
|
|
'code': error_code,
|
|
'message': error,
|
|
'details': [],
|
|
'timestamp': get_utc_timestamp(),
|
|
'path': request.path,
|
|
'requestId': request_id,
|
|
},
|
|
})
|
|
response.status_code = status_code
|
|
response.headers['X-Request-ID'] = request_id
|
|
return response
|
|
|
|
def is_api_request():
|
|
return request.path.startswith('/api/')
|
|
|
|
def is_date_in_active_cycle(target_date, cycle_start, cycle_end):
|
|
if not target_date or not cycle_start:
|
|
return False
|
|
|
|
if cycle_end:
|
|
return cycle_start <= target_date <= cycle_end
|
|
|
|
return target_date >= cycle_start
|
|
|
|
def filter_data_by_date(data, target_date):
|
|
filtered_data = []
|
|
for item in data:
|
|
item_date = parse_data_date(item['date'])
|
|
if item_date == target_date:
|
|
filtered_data.append(item)
|
|
|
|
return filtered_data
|
|
|
|
def get_json_counter_items(json_data):
|
|
items = []
|
|
for source_name, source_data in json_data.items():
|
|
karung_count = 0
|
|
if isinstance(source_data, dict):
|
|
karung_count = source_data.get('karung', 0)
|
|
|
|
items.append({
|
|
'source': source_name,
|
|
'karung': karung_count,
|
|
})
|
|
|
|
return items
|
|
|
|
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_display_date(item['date'], counter_type, counter_settings),
|
|
'counter_value': item['counter_value'],
|
|
}
|
|
for item in data
|
|
]
|
|
|
|
def load_pull_status():
|
|
if not PULL_STATUS_FILE.exists():
|
|
return {
|
|
'ktc': {'status': 'unknown'},
|
|
'kpc': {'status': 'unknown'},
|
|
}
|
|
|
|
try:
|
|
with PULL_STATUS_FILE.open(encoding='utf-8') as handle:
|
|
data = json.load(handle)
|
|
except (OSError, json.JSONDecodeError):
|
|
return {
|
|
'ktc': {'status': 'unknown'},
|
|
'kpc': {'status': 'unknown'},
|
|
}
|
|
|
|
if not isinstance(data, dict):
|
|
return {
|
|
'ktc': {'status': 'unknown'},
|
|
'kpc': {'status': 'unknown'},
|
|
}
|
|
|
|
return {
|
|
'ktc': data.get('ktc') or {'status': 'unknown'},
|
|
'kpc': data.get('kpc') or {'status': 'unknown'},
|
|
}
|
|
|
|
def filter_history_by_days(data, days):
|
|
if days is None or days < 1:
|
|
return data
|
|
|
|
cutoff_date = datetime.now().date() - timedelta(days=days - 1)
|
|
filtered = []
|
|
for item in data:
|
|
item_date = parse_data_date(item['date'])
|
|
if item_date and item_date >= cutoff_date:
|
|
filtered.append(item)
|
|
return filtered
|
|
|
|
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(
|
|
item['counter_value'] if not is_masuk_out_item(item) else -item['counter_value']
|
|
for item in history_items
|
|
)
|
|
else:
|
|
history_total = sum(item['counter_value'] for item in history_items)
|
|
|
|
return {
|
|
'source': source_name,
|
|
'today': today_items,
|
|
'history': format_history_items(
|
|
history_items,
|
|
'masuk' if is_masuk else 'tuang',
|
|
counter_settings,
|
|
),
|
|
'totals': {
|
|
'today': today_total,
|
|
'history': history_total,
|
|
},
|
|
}
|
|
|
|
def get_active_cycle_dates(dashboard_settings):
|
|
cycle_start = parse_cycle_date(dashboard_settings['cycle_start'], 'Cycle start')
|
|
cycle_end = parse_cycle_date(dashboard_settings['cycle_end'], 'Cycle end')
|
|
return cycle_start, cycle_end
|
|
|
|
def format_display_date_from_iso(iso_date):
|
|
return datetime.strptime(iso_date, '%Y-%m-%d').strftime(DISPLAY_DATE_FORMAT)
|
|
|
|
def format_business_date_label(tuang_date, masuk_date):
|
|
if tuang_date == masuk_date:
|
|
return format_display_date_from_iso(tuang_date)
|
|
|
|
return (
|
|
f"Tuang {format_display_date_from_iso(tuang_date)} / "
|
|
f"Masuk {format_display_date_from_iso(masuk_date)}"
|
|
)
|
|
|
|
def get_counter_periods(cutoff_metadata):
|
|
return cutoff_metadata['counters']
|
|
|
|
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')
|
|
|
|
tuang_items = []
|
|
masuk_items = []
|
|
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'],
|
|
'date_label': format_business_date_label(
|
|
counter_periods['tuang']['business_date'],
|
|
counter_periods['masuk']['business_date']
|
|
),
|
|
'source': 'json',
|
|
'is_within_active_cycle': bool(tuang_items or masuk_items),
|
|
'business_dates': {
|
|
'tuang': counter_periods['tuang']['business_date'],
|
|
'masuk': counter_periods['masuk']['business_date'],
|
|
},
|
|
'periods': counter_periods,
|
|
'totals': {
|
|
'tuang': sum(item['karung'] for item in tuang_items),
|
|
'masuk': sum(item['karung'] for item in masuk_items),
|
|
},
|
|
'tuang': tuang_items,
|
|
'masuk': masuk_items,
|
|
}
|
|
|
|
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)
|
|
|
|
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': bool(tuang_data or masuk_data),
|
|
'business_dates': {
|
|
'tuang': tuang_date.isoformat(),
|
|
'masuk': masuk_date.isoformat(),
|
|
},
|
|
'totals': {
|
|
'tuang': sum(item['counter_value'] for item in tuang_data),
|
|
'masuk': sum(
|
|
item['counter_value'] if not is_masuk_out_item(item) else -item['counter_value']
|
|
for item in masuk_data
|
|
),
|
|
},
|
|
'tuang': format_history_items(tuang_data, 'tuang', counter_settings),
|
|
'masuk': format_history_items(masuk_data, 'masuk', counter_settings),
|
|
}
|
|
|
|
@app.errorhandler(HTTPException)
|
|
def handle_http_exception(error):
|
|
if not is_api_request():
|
|
return error
|
|
|
|
return api_error_response(
|
|
error.description,
|
|
error.code,
|
|
error.name.upper().replace(' ', '_')
|
|
)
|
|
|
|
@app.errorhandler(Exception)
|
|
def handle_unexpected_exception(error):
|
|
if not is_api_request():
|
|
raise error
|
|
|
|
app.logger.exception('Unhandled API error')
|
|
return api_error_response(
|
|
'An unexpected error occurred while processing the request.',
|
|
500,
|
|
'INTERNAL_SERVER_ERROR'
|
|
)
|
|
|
|
@app.route('/api/v1/cycles/active/sections', methods=['GET'])
|
|
def get_active_cycle_sections():
|
|
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': 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_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'],
|
|
},
|
|
})
|
|
|
|
@app.route('/api/combined', methods=['GET'])
|
|
def get_combined():
|
|
history_days_raw = request.args.get('history_days', DEFAULT_COMBINED_HISTORY_DAYS)
|
|
try:
|
|
history_days = int(history_days_raw)
|
|
except (TypeError, ValueError):
|
|
abort(400, description='history_days must be a whole number.')
|
|
|
|
if history_days < 1:
|
|
abort(400, description='history_days must be at least 1.')
|
|
|
|
tuang_today = get_json_counter_items(get_json_data(KARUNG_TUANG_JSON))
|
|
masuk_today = get_json_counter_items(get_json_data(KARUNG_MASUK_JSON))
|
|
|
|
tuang_history = filter_history_by_days(
|
|
get_db_data(DB_CONFIGS['tuang']['path'], DB_CONFIGS['tuang']['table']),
|
|
history_days,
|
|
)
|
|
masuk_history = filter_history_by_days(
|
|
get_db_data(DB_CONFIGS['masuk']['path'], DB_CONFIGS['masuk']['table']),
|
|
history_days,
|
|
)
|
|
|
|
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({
|
|
'tuang': tuang_payload,
|
|
'masuk': masuk_payload,
|
|
'totals': {
|
|
'tuang': tuang_payload['totals']['today'],
|
|
'masuk': sum(
|
|
item['karung'] if not is_masuk_out_item(item) else -item['karung']
|
|
for item in masuk_today
|
|
),
|
|
},
|
|
})
|
|
payload = response.get_json()
|
|
payload['meta']['sources'] = {
|
|
'ktc': pull_status.get('ktc') or {'status': 'unknown'},
|
|
'kpc': pull_status.get('kpc') or {'status': 'unknown'},
|
|
}
|
|
payload['meta']['history_days'] = history_days
|
|
response = jsonify(payload)
|
|
response.status_code = 200
|
|
response.headers['X-Request-ID'] = payload['meta']['requestId']
|
|
return response
|
|
|
|
@app.route('/')
|
|
def index():
|
|
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)
|
|
k_index = parse_optional_k_index(request.args.get('k_index'))
|
|
cycle_id = parse_optional_cycle_id(request.args.get('cycle_id'))
|
|
|
|
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')
|
|
|
|
view_payload = build_dashboard_view_payload(
|
|
cycle_context,
|
|
counter_settings,
|
|
cutoff_metadata,
|
|
k_index=k_index,
|
|
cycle_id=cycle_id,
|
|
)
|
|
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=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)
|