# ========================================== # database.py - Supabase 연동 # ========================================== import os from datetime import datetime from time_utils import ensure_kst, now_kst # 환경 변수에서 Supabase 설정 로드 SUPABASE_URL = os.getenv('SUPABASE_URL', '') SUPABASE_KEY = os.getenv('SUPABASE_KEY', '') print(f"DEBUG: SUPABASE_URL prefix: {SUPABASE_URL[:15] if SUPABASE_URL else 'None'}") _supabase_client = None def get_supabase_client(): """Supabase 클라이언트 싱글턴 반환""" global _supabase_client if _supabase_client is None: if not SUPABASE_URL or not SUPABASE_KEY: print("⚠️ SUPABASE_URL 또는 SUPABASE_KEY가 설정되지 않았습니다.") print(" .env 파일을 확인하거나 환경 변수를 설정하세요.") return None try: from supabase import create_client _supabase_client = create_client(SUPABASE_URL, SUPABASE_KEY) print("✅ Supabase 연결 성공") except ImportError: print("⚠️ supabase 패키지가 설치되지 않았습니다.") print(" pip install supabase 실행하세요.") return None except Exception as e: print(f"⚠️ Supabase 연결 실패: {e}") return None return _supabase_client def upsert_daily_stats(client, records, source, allow_decrease=False): """Store daily stats through the database's single conflict policy. Automated sources preserve the highest daily generation. Only an explicit correction path (currently Excel upload) may pass ``allow_decrease=True``. The database function also derives generation_hours and refreshes the affected monthly aggregate. """ if not records: return [] payload = [] for record in records: normalized = { "plant_id": record["plant_id"], "date": record["date"], "total_generation": float(record["total_generation"]), "peak_kw": float(record.get("peak_kw") or 0), } payload.append(normalized) result = client.rpc( "upsert_daily_stats", { "p_records": payload, "p_source": source, "p_allow_decrease": allow_decrease, }, ).execute() return result.data or [] def save_to_supabase(data_list): """ 수집된 발전 데이터를 Supabase solar_logs 테이블에 저장 Args: data_list: [{'id': 'nrems-01', 'name': '...', 'kw': 10.5, 'today': 100.0, 'status': '...'}] Returns: bool: 저장 성공 여부 """ if not data_list: print("[DB] 저장할 데이터가 없습니다.") return False client = get_supabase_client() if client is None: print("[DB 저장 생략] Supabase 연결 없음") return False try: # 저장할 레코드 생성 records = [] for item in data_list: plant_id = item.get('id', '') # id가 없는 경우 건너뛰기 if not plant_id: print(f" ⚠️ '{item.get('name', 'Unknown')}' ID 없음, 건너뜀") continue # 한국 시간(KST) 타임스탬프 생성 kst_now = now_kst().isoformat() status = item.get('status', '') is_error = '오류' in status # '🔴 오류' 상태 감지 # [보호] 오류 상태 데이터는 solar_logs에는 기록하되 daily_stats는 건드리지 않음 # 단, solar_logs 기록 자체는 이상 이력 추적을 위해 유지 record = { 'plant_id': plant_id, 'current_kw': float(item.get('kw', 0)), 'today_kwh': float(item.get('today', 0)), 'status': status, 'created_at': kst_now # 한국 시간으로 저장 } records.append(record) if not records: print("[DB] 저장할 유효한 레코드가 없습니다.") return False # Supabase에 일괄 삽입 (solar_logs) - 오류 상태 포함 전체 기록 result = client.table("solar_logs").insert(records).execute() print(f"✅ [DB] Supabase 저장 완료: {len(records)}건 (solar_logs)") # daily_stats 갱신은 DB 함수의 공통 최댓값 정책을 사용한다. # 오류/0/야간 값은 호출 전에 제외하고, 충돌과 월간 재집계는 DB가 처리한다. kst_now_dt = now_kst() kst_date_str = kst_now_dt.strftime("%Y-%m-%d") kst_hour = kst_now_dt.hour # 야간 시간대 차단 (21:00 ~ 익일 06:00 KST) is_night = kst_hour >= 21 or kst_hour < 6 if is_night: print(f" ⚠️ [야간 차단] KST {kst_hour:02d}시 → daily_stats 갱신 건너뜀 (일몰 후 잔류값 보호)") else: daily_records = [] for item in data_list: plant_id = item.get('id', '') if not plant_id: continue status = item.get('status', '') is_error = '오류' in status today_val = float(item.get('today', 0)) # 오류 상태이거나 today_kwh가 0이면 daily_stats 갱신 건너뜀 if is_error: print(f" ⚠️ [{plant_id}] 오류 상태 → daily_stats 갱신 건너뜀") continue if today_val == 0: print(f" ⚠️ [{plant_id}] today_kwh=0 → daily_stats 갱신 건너뜀 (새벽/야간 추정)") continue daily_records.append({ "plant_id": plant_id, "date": kst_date_str, "total_generation": today_val, "peak_kw": float(item.get('kw', 0)), }) if daily_records: try: upsert_daily_stats( client, daily_records, source="realtime", allow_decrease=False, ) print(f"✅ [DB] daily_stats 업데이트 완료: {len(daily_records)}건") except Exception as e: print(f"⚠️ [DB] daily_stats 업데이트 실패: {e}") for r in records: print(f" → {r['plant_id']}: {r['current_kw']} kW / {r['today_kwh']} kWh") return True except Exception as e: print(f"❌ [DB] Supabase 저장 실패: {e}") return False def save_to_console(data_list): """콘솔에 데이터 출력""" if not data_list: print("⚠️ 출력할 데이터가 없습니다.") return print("\n" + "=" * 75) print("📊 [실시간 통합 현황판]") print("=" * 75) print(f"{'발전소명':<20} | {'현재출력(kW)':>12} | {'금일발전(kWh)':>12} | {'상태'}") print("-" * 75) total_kw = 0 total_today = 0 for d in data_list: name = d.get('name', 'N/A') kw = d.get('kw', 0) today = d.get('today', 0) status = d.get('status', '') total_kw += kw total_today += today print(f"{name:<20} | {kw:>12.2f} | {today:>12.2f} | {status}") print("-" * 75) print(f"{'합계':<20} | {total_kw:>12.2f} | {total_today:>12.2f} |") print("=" * 75) def save_history(data_list, data_type='hourly'): """ 과거 데이터 저장 (Hourly, Daily, Monthly) Args: data_list: 데이터 리스트 data_type: 'hourly', 'daily', 'monthly' """ if not data_list: return False client = get_supabase_client() if client is None: return False try: table_name = "" records = [] if data_type == 'hourly': table_name = "solar_logs" for item in data_list: # hourly 데이터는 timestamp 키를 가짐 ts = item.get('timestamp') if ts: ts_iso = ts.replace(' ', 'T') # Check if future (simple string comparison works for ISO format if consistent, but datetime is safer) # KST aware comparison current_kst = now_kst() try: # ts example: 2026-01-27 14:00:00. Assume input is local time (KST) # We convert it to aware datetime dt_ts = datetime.fromisoformat(ts_iso) dt_ts = ensure_kst(dt_ts) if dt_ts > current_kst: continue # Skip future data except ValueError: continue # Ensure timezone is sent to Supabase to prevent UTC assumption final_created_at = dt_ts.isoformat() if item.get('current_kw') is not None: current_kw = float(item['current_kw']) else: current_kw = float(item.get('generation_kwh', 0)) records.append({ 'plant_id': item['plant_id'], 'created_at': final_created_at, 'current_kw': current_kw, 'today_kwh': float(item.get('generation_kwh', 0)), 'status': 'History' }) elif data_type == 'daily': table_name = "daily_stats" for item in data_list: generation_kwh = float(item['generation_kwh']) if generation_kwh < 0: raise ValueError( f"daily generation_kwh must be non-negative: " f"{item.get('plant_id')} {item.get('date')}" ) records.append({ 'plant_id': item['plant_id'], 'date': item['date'], 'total_generation': generation_kwh, 'peak_kw': 0.0, }) elif data_type == 'monthly': table_name = "monthly_stats" for item in data_list: records.append({ 'plant_id': item['plant_id'], 'month': item['month'], # YYYY-MM 'total_generation': float(item.get('generation_kwh', 0)), 'updated_at': now_kst().isoformat(), 'source': 'history_monthly', }) if not records: return False # upsert 사용 if data_type == 'hourly': client.table(table_name).insert(records).execute() elif data_type == 'daily': # DB 함수가 최댓값 보호와 월간 통계 갱신을 한 트랜잭션으로 처리한다. upsert_daily_stats( client, records, source="history", allow_decrease=False, ) elif data_type == 'monthly': client.table(table_name).upsert(records, on_conflict="plant_id, month").execute() print(f"✅ [History] {data_type} 데이터 {len(records)}건 저장 완료") return True except Exception as e: print(f"❌ [History] 저장 실패 ({data_type}): {e}") return False