Some checks are pending
CI / Crawler (Python ${{ matrix.python-version }}) (3.10) (push) Waiting to run
CI / Crawler (Python ${{ matrix.python-version }}) (3.11) (push) Waiting to run
CI / API (Python 3.11) (push) Waiting to run
CI / Database migration (push) Waiting to run
CI / App web build (Node 20) (push) Waiting to run
327 lines
12 KiB
Python
327 lines
12 KiB
Python
# ==========================================
|
|
# 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
|