This commit is contained in:
2026-06-02 15:22:08 +07:00
parent 2e2ba624bf
commit 86fcd7b49b
12 changed files with 1503 additions and 2 deletions
+1
View File
@@ -0,0 +1 @@
{}
-2
View File
@@ -1,2 +0,0 @@
# karunng-web-test
+583
View File
@@ -0,0 +1,583 @@
from flask import Flask, abort, redirect, render_template, request, url_for
import sqlite3
import json
from datetime import datetime
import os
from pathlib import Path
app = Flask(__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')
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')
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,
}
def get_db_data(db_path, table_name):
"""Fetch all data from database"""
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()
data = []
for row in rows:
data.append({
'id': row[0],
'camera_name': row[1],
'date': row[2],
'counter_value': row[3]
})
return data
def load_dashboard_settings():
ensure_settings_db()
with sqlite3.connect(SETTINGS_DB) as conn:
row = conn.execute(
"""
SELECT id, cycle_name, cycle_start, cycle_end, saldo_awal
FROM cycle_settings
ORDER BY is_active DESC, id DESC
LIMIT 1
"""
).fetchone()
if not row:
return DEFAULT_DASHBOARD_SETTINGS.copy()
return {
'id': row[0],
'cycle_name': row[1],
'cycle_start': row[2],
'cycle_end': row[3],
'saldo_awal': row[4],
}
def save_dashboard_settings(settings):
ensure_settings_db()
with sqlite3.connect(SETTINGS_DB) as conn:
conn.execute('UPDATE cycle_settings SET is_active = 0')
conn.execute(
"""
INSERT INTO cycle_settings (
cycle_name,
cycle_start,
cycle_end,
saldo_awal,
is_active,
updated_at
)
VALUES (?, ?, ?, ?, 1, ?)
""",
(
settings['cycle_name'],
settings['cycle_start'],
settings['cycle_end'],
settings['saldo_awal'],
datetime.now().isoformat(timespec='seconds')
)
)
def get_cycle_options():
ensure_settings_db()
with sqlite3.connect(SETTINGS_DB) as conn:
rows = conn.execute(
"""
SELECT latest_cycle.id,
latest_cycle.cycle_name,
latest_cycle.cycle_start,
latest_cycle.cycle_end,
latest_cycle.saldo_awal
FROM cycle_settings AS latest_cycle
INNER JOIN (
SELECT cycle_name, MAX(id) AS latest_id
FROM cycle_settings
GROUP BY cycle_name
) AS grouped_cycle
ON latest_cycle.id = grouped_cycle.latest_id
ORDER BY latest_cycle.cycle_name COLLATE NOCASE
"""
).fetchall()
return [
{
'id': row[0],
'cycle_name': row[1],
'cycle_start': row[2],
'cycle_end': row[3],
'saldo_awal': row[4],
}
for row in rows
]
def select_cycle_by_name(cycle_name):
ensure_settings_db()
with sqlite3.connect(SETTINGS_DB) as conn:
row = conn.execute(
"""
SELECT id
FROM cycle_settings
WHERE cycle_name = ?
ORDER BY id DESC
LIMIT 1
""",
(cycle_name,)
).fetchone()
if not row:
abort(404, description='Cycle name was not found.')
conn.execute('UPDATE cycle_settings SET is_active = 0')
conn.execute(
"""
UPDATE cycle_settings
SET is_active = 1,
updated_at = ?
WHERE id = ?
""",
(datetime.now().isoformat(timespec='seconds'), row[0])
)
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,
is_active,
updated_at
)
SELECT cycle_name,
cycle_start,
cycle_end,
saldo_awal,
0,
updated_at
FROM cycle_settings_old
ORDER BY id
"""
)
conn.execute('DROP TABLE cycle_settings_old')
else:
create_cycle_settings_table(conn)
ensure_settings_active_column(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,
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'],
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,
cycle_name TEXT NOT NULL,
cycle_start TEXT NOT NULL DEFAULT '',
cycle_end TEXT NOT NULL DEFAULT '',
saldo_awal INTEGER NOT NULL DEFAULT 0,
is_active INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL
)
"""
)
def ensure_settings_active_column(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
"""
)
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):
date_text = 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_date_picker_value(date_text):
parsed_date = parse_cycle_date(date_text, 'Cycle date')
if not parsed_date:
return ''
return parsed_date.strftime('%Y-%m-%d')
def filter_data_by_cycle(data, cycle_start, cycle_end):
if not cycle_start or not cycle_end:
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 item_date > cycle_end:
continue
filtered_data.append(item)
return filtered_data
def is_masuk_out_item(item):
return str(item['camera_name']).lower().endswith('_out')
def get_settings_from_form():
cycle_name = request.form.get('cycle_name', '').strip()
cycle_start_raw = request.form.get('cycle_start', '').strip()
cycle_end_raw = request.form.get('cycle_end', '').strip()
saldo_awal_raw = request.form.get('saldo_awal', '0').strip()
if not cycle_name:
abort(400, description='Cycle name is required.')
if not cycle_start_raw or not cycle_end_raw:
abort(400, description='Cycle start and cycle end are required to filter historical data.')
cycle_start = parse_cycle_date(cycle_start_raw, 'Cycle start')
cycle_end = parse_cycle_date(cycle_end_raw, 'Cycle end')
if cycle_start and cycle_end and cycle_start > cycle_end:
abort(400, description='Cycle start must be before or equal to cycle end.')
try:
saldo_awal = int(saldo_awal_raw or 0)
except ValueError:
abort(400, description='Saldo awal must be a whole number.')
if saldo_awal < 0:
abort(400, description='Saldo awal cannot be negative.')
return {
'cycle_name': cycle_name,
'cycle_start': cycle_start.strftime(DISPLAY_DATE_FORMAT) if cycle_start else '',
'cycle_end': cycle_end.strftime(DISPLAY_DATE_FORMAT) if cycle_end else '',
'saldo_awal': saldo_awal,
}
def ensure_db_writable(db_path):
db_path = Path(db_path)
if not db_path.exists():
abort(404, description=f'Database file not found: {db_path}')
if not os.access(db_path, os.W_OK):
abort(403, description=f'Cannot save jumlah karung because database is not writable: {db_path}')
if not os.access(db_path.parent, os.W_OK):
abort(403, description=f'Cannot save jumlah karung because database directory is not writable: {db_path.parent}')
def update_db_data(db_path, table_name, row_id, counter_value):
"""Update one database row by primary key."""
ensure_db_writable(db_path)
try:
with sqlite3.connect(db_path) as conn:
cursor = conn.cursor()
cursor.execute(
f"""
UPDATE {table_name}
SET counter_value = ?
WHERE id = ?
""",
(counter_value, row_id)
)
updated_count = cursor.rowcount
except sqlite3.OperationalError as error:
abort(403, description=f'Cannot save jumlah karung to database {db_path}: {error}')
if updated_count == 0:
abort(404, description='No database row was found for the requested edit.')
def update_db_rows(db_path, table_name, row_values):
ensure_db_writable(db_path)
try:
with sqlite3.connect(db_path) as conn:
cursor = conn.cursor()
for row_id, counter_value in row_values.items():
cursor.execute(
f"""
UPDATE {table_name}
SET counter_value = ?
WHERE id = ?
""",
(counter_value, row_id)
)
if cursor.rowcount == 0:
abort(404, description='No database row was found for one of the requested edits.')
except sqlite3.OperationalError as error:
abort(403, description=f'Cannot save jumlah karung to database {db_path}: {error}')
def get_counter_value_from_form(counter_value_raw):
if not counter_value_raw:
abort(400, description='Jumlah karung is required to edit database data.')
try:
counter_value = int(counter_value_raw)
except ValueError:
abort(400, description='Jumlah karung must be a whole number.')
if counter_value < 0:
abort(400, description='Jumlah karung cannot be negative.')
return counter_value
def get_bulk_counter_values_from_form():
row_values = {}
for field_name, field_value in request.form.items():
if not field_name.startswith('counter_value_'):
continue
row_id_raw = field_name.removeprefix('counter_value_')
try:
row_id = int(row_id_raw)
except ValueError:
abort(400, description='Invalid database row id in edit form.')
row_values[row_id] = get_counter_value_from_form(field_value.strip())
if not row_values:
abort(400, description='No jumlah karung values were submitted.')
return row_values
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 {}
@app.route('/database/<db_name>/<int:row_id>/edit', methods=['POST'])
def edit_db_row(db_name, row_id):
db_config = DB_CONFIGS.get(db_name)
if not db_config:
abort(404, description='Unknown database. Use tuang or masuk.')
counter_value = get_counter_value_from_form(request.form.get('counter_value', '').strip())
update_db_data(
db_config['path'],
db_config['table'],
row_id,
counter_value
)
return redirect(url_for('index'))
@app.route('/database/<db_name>/edit', methods=['POST'])
def edit_db_rows(db_name):
db_config = DB_CONFIGS.get(db_name)
if not db_config:
abort(404, description='Unknown database. Use tuang or masuk.')
update_db_rows(
db_config['path'],
db_config['table'],
get_bulk_counter_values_from_form()
)
return redirect(url_for('index'))
@app.route('/settings', methods=['POST'])
def update_dashboard_settings():
settings = get_settings_from_form()
save_dashboard_settings(settings)
return redirect(url_for('index'))
@app.route('/settings/select', methods=['POST'])
def select_dashboard_cycle():
cycle_name = request.form.get('cycle_name', '').strip()
if not cycle_name:
abort(400, description='Cycle name is required to select a cycle.')
select_cycle_by_name(cycle_name)
return redirect(url_for('index'))
@app.route('/')
def index():
dashboard_settings = load_dashboard_settings()
cycle_options = get_cycle_options()
cycle_start = parse_cycle_date(dashboard_settings['cycle_start'], 'Cycle start')
cycle_end = parse_cycle_date(dashboard_settings['cycle_end'], 'Cycle end')
cycle_start_picker_value = format_date_picker_value(dashboard_settings['cycle_start'])
cycle_end_picker_value = format_date_picker_value(dashboard_settings['cycle_end'])
# 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'])
# Get current date data from JSON files
tuang_json_data = get_json_data('/etc/frigate-counter/karung-tuang-2/karung_tuang.json')
masuk_json_data = get_json_data('/etc/frigate-counter/karung-masuk/karung_masuk.json')
# Calculate totals from JSON (current date)
tuang_json_total = sum(item.get('karung', 0) for item in tuang_json_data.values())
masuk_json_total = sum(item.get('karung', 0) for item in masuk_json_data.values())
# 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
)
saldo_akhir = dashboard_settings['saldo_awal'] + masuk_db_total - tuang_db_total
current_date = datetime.now().strftime(DISPLAY_DATE_FORMAT)
return render_template('index.html',
dashboard_settings=dashboard_settings,
cycle_options=cycle_options,
cycle_start_picker_value=cycle_start_picker_value,
cycle_end_picker_value=cycle_end_picker_value,
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)
if __name__ == '__main__':
app.run(debug=True, host='0.0.0.0', port=8080)
+1
View File
@@ -0,0 +1 @@
COUNTER_UUID=9f556d2a-af5c-41b0-bcd0-6cea0dfa0fed
Binary file not shown.
BIN
View File
Binary file not shown.
+8
View File
@@ -0,0 +1,8 @@
{
"kandang_1_karung_masuk": {
"karung": 0
},
"kandang_1_karung_masuk_out": {
"karung": 0
}
}
BIN
View File
Binary file not shown.
+8
View File
@@ -0,0 +1,8 @@
{
"kandang_atas_feeder_kiri": {
"karung": 0
},
"kandang_bawah_feeder_kanan": {
"karung": 0
}
}
+324
View File
@@ -0,0 +1,324 @@
#!/usr/bin/env python3
"""
Sync counter data from SQLite databases to API.
karung_tuang:
- 17:00-23:59: Syncs CURRENT date
- 00:00-16:59: Syncs PREVIOUS date (only if not already synced)
karung_masuk: Syncs PREVIOUS date from 00:00 onwards, only if data differs
(skips if already successfully synced for that date).
"""
import os
import sqlite3
import json
import urllib.request
import urllib.error
from datetime import datetime, timedelta
def get_db_path(db_name):
"""Get database path in same directory as script."""
script_dir = os.path.dirname(os.path.abspath(__file__))
return os.path.join(script_dir, db_name)
def get_previous_date():
"""Get yesterday's date in YYYY-MM-DD format."""
return (datetime.now() - timedelta(days=1)).strftime("%Y-%m-%d")
def get_sync_state_file():
"""Get path to sync state file."""
script_dir = os.path.dirname(os.path.abspath(__file__))
return os.path.join(script_dir, ".sync_state.json")
def load_sync_state():
"""Load sync state from file."""
state_file = get_sync_state_file()
if os.path.exists(state_file):
try:
with open(state_file, "r") as f:
return json.load(f)
except (json.JSONDecodeError, IOError):
return {}
return {}
def save_sync_state(state):
"""Save sync state to file."""
state_file = get_sync_state_file()
try:
with open(state_file, "w") as f:
json.dump(state, f)
except IOError as e:
print(f"Warning: Could not save sync state: {e}")
def is_already_synced(state, db_name, date):
"""Check if database was already synced for given date."""
return state.get(db_name, {}).get("last_synced_date") == date
def mark_synced(state, db_name, date):
"""Mark database as synced for given date."""
if db_name not in state:
state[db_name] = {}
state[db_name]["last_synced_date"] = date
def get_current_date():
"""Get today's date in YYYY-MM-DD format."""
return datetime.now().strftime("%Y-%m-%d")
def get_local_data(db_path, table_name, date):
"""Get counter data from SQLite database for a specific date."""
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
if table_name == "karung_counts":
cursor.execute(
"SELECT camera_name, date, counter_value FROM karung_counts WHERE date = ?",
(date,),
)
else:
cursor.execute(
"SELECT camera_name, date(date) as d, counter_value FROM counter_data WHERE date(date) = ?",
(date,),
)
results = cursor.fetchall()
conn.close()
data = {}
for camera_name, date_val, counter_value in results:
key = f"{camera_name}_{date_val}"
data[key] = {
"camera_name": camera_name,
"date": date_val,
"value": counter_value,
}
return data
def fetch_api_data(uuid):
"""Fetch counter data from API."""
url = f"https://dashboard.cpsp.id/api/cpsp/counter/{uuid}/"
req = urllib.request.Request(url, method="GET")
req.add_header("Accept", "application/json")
try:
with urllib.request.urlopen(req, timeout=30) as response:
result = json.loads(response.read().decode("utf-8"))
if result.get("success") and "data" in result:
return result["data"].get("camera_counter", [])
except urllib.error.HTTPError as e:
print(f"HTTP Error {e.code}: {e.reason}")
except urllib.error.URLError as e:
print(f"URL Error: {e.reason}")
except json.JSONDecodeError as e:
print(f"JSON Decode Error: {e}")
return []
def update_api(uuid, camera_name, date, value):
"""Update counter data via API (dummy endpoint)."""
# Dummy endpoint - replace with actual endpoint when available
url = f"https://dashboard.cpsp.id/api/cpsp/counter/{uuid}/update/"
payload = {"camera_name": camera_name, "date": date, "value": value}
data = json.dumps(payload).encode("utf-8")
req = urllib.request.Request(url, data=data, method="POST")
req.add_header("Content-Type", "application/json")
req.add_header("Accept", "application/json")
try:
with urllib.request.urlopen(req, timeout=30) as response:
result = json.loads(response.read().decode("utf-8"))
print(f"Updated {camera_name} for {date}: {value}")
return result
except urllib.error.HTTPError as e:
print(f"HTTP Error {e.code} updating {camera_name}: {e.reason}")
# For dummy endpoint, just log the intended update
print(f"[DUMMY] Would update {camera_name} for {date} with value {value}")
return None
except urllib.error.URLError as e:
print(f"URL Error: {e.reason}")
print(f"[DUMMY] Would update {camera_name} for {date} with value {value}")
return None
def sync_database(db_config, date, uuid, state=None, skip_if_synced=False):
"""Sync a single database with API.
Args:
db_config: Database configuration dict
date: Date string to sync
uuid: API UUID
state: Optional sync state dict
skip_if_synced: If True, skip sync if already marked as synced for this date
"""
db_name = db_config.get("name", "unknown")
# Check if already synced (for karung_masuk to avoid redundant syncs)
if skip_if_synced and state and is_already_synced(state, db_name, date):
print(f"Skipping {db_name} - already synced for {date}")
return True
db_path = get_db_path(db_config["db_file"])
if not os.path.exists(db_path):
print(f"Database not found: {db_path}")
return False
# Get local data
local_data = get_local_data(db_path, db_config["table"], date)
if not local_data:
print(f"No local data found for {date} in {db_config['db_file']}")
return False
# Get API data
api_data = fetch_api_data(uuid)
# Build API data map
api_map = {}
for item in api_data:
key = f"{item['camera_name']}_{item['date']}"
api_map[key] = item["value"]
has_mismatch = False
# Compare and sync
for key, local_record in local_data.items():
camera_name = local_record["camera_name"]
local_value = local_record["value"]
if key not in api_map:
print(f"New record: {camera_name} for {date} = {local_value}")
update_api(uuid, camera_name, date, local_value)
has_mismatch = True
elif api_map[key] != local_value:
print(
f"Mismatch for {camera_name} on {date}: API={api_map[key]}, Local={local_value}"
)
update_api(uuid, camera_name, date, local_value)
has_mismatch = True
else:
print(f"Synced: {camera_name} for {date} = {local_value}")
# Mark as synced if no mismatches found (all data matches)
if not has_mismatch and state:
mark_synced(state, db_name, date)
return True
def main():
"""Main sync function.
karung_tuang:
- 17:00-23:59: Sync CURRENT date
- 00:00-16:59: Sync PREVIOUS date (only if not already synced)
karung_masuk: Sync PREVIOUS date from 00:00 onwards, only if data differs.
"""
uuid = os.environ.get("COUNTER_UUID")
if not uuid:
print("Error: COUNTER_UUID environment variable not set")
return
now = datetime.now()
hour = now.hour
# Load sync state to track which dates have been synced
sync_state = load_sync_state()
# Database configurations
db_configs = {
"karung_tuang": {
"name": "karung_tuang",
"db_file": "karung_tuang.db",
"table": "counter_data",
# 17:00-23:59: sync current date
# 00:00-16:59: sync previous date (if not already synced)
"evening_start": 17,
"use_current_date_evening": True,
"use_current_date_morning": False,
"skip_if_synced_morning": True,
},
"karung_masuk": {
"name": "karung_masuk",
"db_file": "karung_masuk.db",
"table": "karung_counts",
"start_hour": 0, # Sync starts at 00:00
"use_current_date": False, # Sync previous date
"skip_if_synced": True, # Skip if already synced for this date
},
}
# Get dates
prev_date = get_previous_date()
curr_date = get_current_date()
print(f"Current hour: {hour}:00")
print(f"Previous date: {prev_date}")
print(f"Current date: {curr_date}")
# Sync databases based on current hour
for name, config in db_configs.items():
# Handle karung_tuang with morning/evening windows
if "evening_start" in config:
evening_start = config["evening_start"]
if hour >= evening_start:
# Evening window (17:00-23:59): sync current date
sync_date = curr_date
skip_if_synced = False
print(f"\nSyncing {name} (evening window {evening_start:02d}:00-23:59) for date {sync_date}...")
else:
# Morning window (00:00-16:59): sync previous date, skip if already synced
sync_date = prev_date
skip_if_synced = config.get("skip_if_synced_morning", True)
print(f"\nSyncing {name} (morning window 00:00-{evening_start:02d}:00) for date {sync_date}...")
sync_database(
config,
sync_date,
uuid,
state=sync_state,
skip_if_synced=skip_if_synced,
)
else:
# Standard behavior for other databases (karung_masuk)
start_hour = config["start_hour"]
end_hour = config.get("end_hour", 24)
if hour >= start_hour and hour < end_hour:
sync_date = curr_date if config.get("use_current_date", False) else prev_date
print(f"\nSyncing {name} for date {sync_date}...")
sync_database(
config,
sync_date,
uuid,
state=sync_state,
skip_if_synced=config.get("skip_if_synced", False),
)
else:
print(f"\nSkipping {name} - starts at {start_hour:02d}:00")
# Save sync state
save_sync_state(sync_state)
print("\nSync complete.")
if __name__ == "__main__":
main()
+578
View File
@@ -0,0 +1,578 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Karung Counter Dashboard</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background-color: #f5f5f5;
padding: 20px;
}
h1 {
text-align: center;
color: #333;
margin-bottom: 30px;
}
.container {
max-width: 1400px;
margin: 0 auto;
}
.selected-cycle {
background: linear-gradient(135deg, #2c3e50 0%, #3498db 100%);
color: white;
border-radius: 10px;
padding: 18px 20px;
margin-bottom: 30px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.selected-cycle h2 {
margin-bottom: 12px;
font-size: 20px;
}
.selected-cycle-details {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 12px;
}
.selected-cycle-label {
display: block;
font-size: 12px;
opacity: 0.8;
margin-bottom: 4px;
}
.selected-cycle-value {
font-size: 18px;
font-weight: 700;
word-break: break-word;
}
.selected-cycle-form {
display: flex;
gap: 8px;
}
.selected-cycle-select {
flex: 1;
min-width: 0;
padding: 8px;
border: none;
border-radius: 5px;
font-size: 16px;
}
.selected-cycle-button {
background-color: #27ae60;
color: white;
border: none;
border-radius: 5px;
padding: 8px 12px;
cursor: pointer;
font-weight: 700;
}
.selected-cycle-button:hover {
background-color: #219653;
}
.section {
background: white;
border-radius: 10px;
padding: 20px;
margin-bottom: 30px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.section h2 {
color: #2c3e50;
margin-bottom: 20px;
padding-bottom: 10px;
border-bottom: 3px solid #3498db;
}
.section h2.tuang {
border-bottom-color: #e74c3c;
}
.section h2.masuk {
border-bottom-color: #27ae60;
}
.historical-summary {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
cursor: pointer;
list-style: none;
}
.historical-summary::-webkit-details-marker {
display: none;
}
.historical-summary h2 {
margin-bottom: 0;
padding-bottom: 0;
border-bottom: none;
}
.historical-toggle-label {
background-color: #3498db;
color: white;
border-radius: 5px;
padding: 8px 12px;
font-weight: 700;
white-space: nowrap;
}
.historical-toggle-label::after {
content: "Expand";
}
.historical-section[open] .historical-toggle-label::after {
content: "Collapse";
}
.historical-content {
margin-top: 20px;
}
.cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.settings-form {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 15px;
align-items: end;
}
.form-group {
display: flex;
flex-direction: column;
gap: 6px;
}
.form-group label {
color: #2c3e50;
font-weight: 600;
}
.settings-input {
padding: 10px;
border: 1px solid #ccc;
border-radius: 5px;
font-size: 14px;
}
.settings-button {
background-color: #2c3e50;
color: white;
border: none;
border-radius: 5px;
padding: 11px 16px;
cursor: pointer;
font-weight: 600;
}
.settings-button:hover {
background-color: #1f2d3a;
}
.settings-hint {
color: #7f8c8d;
margin-top: 12px;
font-size: 14px;
}
.card {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 20px;
border-radius: 10px;
text-align: center;
}
.card.tuang {
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
}
.card.masuk {
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
}
.card.balance {
background: linear-gradient(135deg, #43e97b 0%, #38f9d7 100%);
}
.card h3 {
font-size: 14px;
margin-bottom: 10px;
opacity: 0.9;
}
.card .value {
font-size: 36px;
font-weight: bold;
}
.card .text-value {
font-size: 24px;
word-break: break-word;
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 20px;
}
th, td {
padding: 12px 15px;
text-align: left;
border-bottom: 1px solid #ddd;
}
th {
background-color: #34495e;
color: white;
font-weight: 600;
}
tr:hover {
background-color: #f8f9fa;
}
.badge {
display: inline-block;
padding: 5px 10px;
border-radius: 5px;
font-size: 12px;
font-weight: bold;
}
.badge-tuang {
background-color: #ffeaa7;
color: #d63031;
}
.badge-masuk {
background-color: #a8e6cf;
color: #00b894;
}
.json-section {
background-color: #f8f9fa;
border-left: 4px solid #3498db;
padding: 15px;
margin-bottom: 20px;
border-radius: 5px;
}
.json-section h3 {
margin-bottom: 15px;
color: #2c3e50;
}
.camera-list {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 15px;
}
.camera-item {
background: white;
padding: 15px;
border-radius: 8px;
border: 2px solid #e0e0e0;
}
.camera-item .camera-name {
font-size: 12px;
color: #7f8c8d;
margin-bottom: 5px;
word-break: break-all;
}
.camera-item .karung-count {
font-size: 24px;
font-weight: bold;
color: #2c3e50;
}
.no-data {
text-align: center;
color: #7f8c8d;
padding: 30px;
font-style: italic;
}
.filter-note {
color: #7f8c8d;
margin-bottom: 15px;
}
.date-badge {
display: inline-block;
background-color: #3498db;
color: white;
padding: 5px 15px;
border-radius: 20px;
font-size: 14px;
margin-bottom: 15px;
}
.edit-input {
width: 100%;
min-width: 120px;
padding: 8px;
border: 1px solid #ccc;
border-radius: 5px;
}
.edit-input:disabled {
background-color: #f3f4f6;
color: #555;
}
.edit-input.count {
min-width: 80px;
}
.action-buttons {
display: flex;
gap: 8px;
}
.table-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 15px;
}
.edit-button {
background-color: #3498db;
color: white;
border: none;
border-radius: 5px;
padding: 8px 12px;
cursor: pointer;
font-weight: 600;
}
.edit-button:hover {
background-color: #2980b9;
}
.save-button {
background-color: #27ae60;
}
.save-button:hover {
background-color: #219653;
}
.save-button:disabled {
background-color: #bdc3c7;
cursor: not-allowed;
}
.save-button:disabled:hover {
background-color: #bdc3c7;
}
</style>
</head>
<body>
<div class="container">
<h1>📊 Karung Counter Dashboard</h1>
<div class="selected-cycle">
<h2>Selected Cycle</h2>
<div class="selected-cycle-details">
<div>
<span class="selected-cycle-label">Cycle Name</span>
<form class="selected-cycle-form" action="{{ url_for('select_dashboard_cycle') }}" method="post">
<select class="selected-cycle-select" name="cycle_name" required>
{% for cycle in cycle_options %}
<option value="{{ cycle.cycle_name }}" {% if cycle.cycle_name == dashboard_settings.cycle_name %}selected{% endif %}>{{ cycle.cycle_name }}</option>
{% endfor %}
</select>
<button class="selected-cycle-button" type="submit">Pilih</button>
</form>
</div>
<div>
<span class="selected-cycle-label">Range</span>
<span class="selected-cycle-value">{{ dashboard_settings.cycle_start or 'awal data' }} - {{ dashboard_settings.cycle_end or 'akhir data' }}</span>
</div>
<div>
<span class="selected-cycle-label">Saldo Awal</span>
<span class="selected-cycle-value">{{ dashboard_settings.saldo_awal }}</span>
</div>
</div>
</div>
<!-- Summary Cards -->
<div class="cards">
<div class="card balance">
<h3>Cycle Name</h3>
<div class="value text-value">{{ dashboard_settings.cycle_name }}</div>
</div>
<div class="card tuang">
<h3>Karung Tuang Hari Ini (JSON)</h3>
<div class="value">{{ tuang_json_total }}</div>
</div>
<div class="card masuk">
<h3>Karung Masuk Hari Ini (JSON)</h3>
<div class="value">{{ masuk_json_total }}</div>
</div>
<div class="card tuang">
<h3>Total Karung Tuang (Siklus)</h3>
<div class="value">{{ tuang_db_total }}</div>
</div>
<div class="card masuk">
<h3>Total Karung Masuk (Siklus)</h3>
<div class="value">{{ masuk_db_total }}</div>
</div>
<div class="card balance">
<h3>Saldo Awal</h3>
<div class="value">{{ dashboard_settings.saldo_awal }}</div>
</div>
<div class="card balance">
<h3>Saldo Akhir</h3>
<div class="value">{{ saldo_akhir }}</div>
</div>
</div>
<!-- Current Date Data from JSON -->
<div class="section">
<h2>📅 Data Hari Ini ({{ current_date }}) - Dari JSON</h2>
<h3>Karung Tuang</h3>
{% if tuang_json_data %}
<div class="camera-list">
{% for camera, data in tuang_json_data.items() %}
<div class="camera-item">
<div class="camera-name">{{ camera }}</div>
<div class="karung-count">{{ data.karung|default(0) }} karung</div>
</div>
{% endfor %}
</div>
{% else %}
<p class="no-data">Tidak ada data karung tuang untuk hari ini</p>
{% endif %}
<h3 style="margin-top: 30px;">Karung Masuk</h3>
{% if masuk_json_data %}
<div class="camera-list">
{% for camera, data in masuk_json_data.items() %}
<div class="camera-item">
<div class="camera-name">{{ camera }}</div>
<div class="karung-count">{{ data.karung|default(0) }} karung</div>
</div>
{% endfor %}
</div>
{% else %}
<p class="no-data">Tidak ada data karung masuk untuk hari ini</p>
{% endif %}
</div>
<!-- Historical Data from Database -->
<details class="section historical-section" open>
<summary class="historical-summary">
<h2>🗄️ Data Historis - Dari Database</h2>
<span class="historical-toggle-label"></span>
</summary>
<div class="historical-content">
<p class="filter-note">
Siklus aktif:
{{ dashboard_settings.cycle_name }}.
Rentang:
{{ dashboard_settings.cycle_start }}
sampai
{{ dashboard_settings.cycle_end }}
</p>
<div class="section">
<div class="table-header">
<h3 class="tuang">Karung Tuang</h3>
{% if tuang_db_data %}
<div class="action-buttons">
<button class="edit-button" type="button" onclick="enableTableEditor('tuang')">Edit</button>
<button id="tuang-save" class="edit-button save-button" form="tuang-edit-form" type="submit" disabled>Simpan</button>
</div>
{% endif %}
</div>
{% if tuang_db_data %}
<form id="tuang-edit-form" action="{{ url_for('edit_db_rows', db_name='tuang') }}" method="post">
<table>
<thead>
<tr>
<th>Camera Name</th>
<th>Tanggal</th>
<th>Jumlah Karung</th>
</tr>
</thead>
<tbody>
{% for item in tuang_db_data %}
<tr>
<td>
<span class="badge badge-tuang">TUANG</span> {{ item.camera_name }}
</td>
<td>
{{ item.display_date }}
</td>
<td>
<input class="edit-input count tuang-counter-input" type="number" name="counter_value_{{ item.id }}" value="{{ item.counter_value }}" min="0" required disabled>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</form>
{% else %}
<p class="no-data">Tidak ada data historis karung tuang</p>
{% endif %}
</div>
<div class="section">
<div class="table-header">
<h3 class="masuk">Karung Masuk</h3>
{% if masuk_db_data %}
<div class="action-buttons">
<button class="edit-button" type="button" onclick="enableTableEditor('masuk')">Edit</button>
<button id="masuk-save" class="edit-button save-button" form="masuk-edit-form" type="submit" disabled>Simpan</button>
</div>
{% endif %}
</div>
{% if masuk_db_data %}
<form id="masuk-edit-form" action="{{ url_for('edit_db_rows', db_name='masuk') }}" method="post">
<table>
<thead>
<tr>
<th>Camera Name</th>
<th>Tanggal</th>
<th>Jumlah Karung</th>
</tr>
</thead>
<tbody>
{% for item in masuk_db_data %}
<tr>
<td>
<span class="badge badge-masuk">MASUK</span> {{ item.camera_name }}
</td>
<td>
{{ item.date }}
</td>
<td>
<input class="edit-input count masuk-counter-input" type="number" name="counter_value_{{ item.id }}" value="{{ item.counter_value }}" min="0" required disabled>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</form>
{% else %}
<p class="no-data">Tidak ada data historis karung masuk</p>
{% endif %}
</div>
</div>
</details>
<!-- Cycle Settings -->
<div class="section">
<h2>⚙️ Pengaturan Siklus</h2>
<form class="settings-form" action="{{ url_for('update_dashboard_settings') }}" method="post">
<div class="form-group">
<label for="cycle_name">Cycle Name</label>
<input id="cycle_name" class="settings-input" type="text" name="cycle_name" value="{{ dashboard_settings.cycle_name }}" required>
</div>
<div class="form-group">
<label for="cycle_start">Cycle Start</label>
<input id="cycle_start" class="settings-input" type="date" name="cycle_start" value="{{ cycle_start_picker_value }}" required>
</div>
<div class="form-group">
<label for="cycle_end">Cycle End</label>
<input id="cycle_end" class="settings-input" type="date" name="cycle_end" value="{{ cycle_end_picker_value }}" required>
</div>
<div class="form-group">
<label for="saldo_awal">Saldo Awal</label>
<input id="saldo_awal" class="settings-input" type="number" name="saldo_awal" value="{{ dashboard_settings.saldo_awal }}" min="0" step="1" required>
</div>
<button class="settings-button" type="submit">Simpan Pengaturan</button>
</form>
<p class="settings-hint">Data historis hanya menampilkan data dalam rentang siklus.</p>
</div>
</div>
<script>
function enableTableEditor(tableKey) {
const counterInputs = document.querySelectorAll(`.${tableKey}-counter-input`);
const saveButton = document.getElementById(`${tableKey}-save`);
counterInputs.forEach((counterInput) => {
counterInput.disabled = false;
});
saveButton.disabled = false;
if (counterInputs.length > 0) {
counterInputs[0].focus();
counterInputs[0].select();
}
}
</script>
</body>
</html>
Binary file not shown.