""" 통계 조회 API - 일별/월별/연도별 발전량 통계 """ from fastapi import APIRouter, HTTPException, Depends, Query from supabase import Client from typing import Literal, Optional from datetime import date as date_type import calendar import re from app.core.database import get_db from app.core.time_utils import ( kst_day_bounds_utc, parse_db_timestamp_kst, today_kst, ) from app.schemas.stats import ( ComparisonStatsResponse, HourlyStatsResponse, PlantStatsResponse, ) router = APIRouter( prefix="/plants", tags=["Stats"] ) MIN_STATS_YEAR = 2000 MAX_STATS_YEAR = 2100 def parse_iso_date(value: str) -> date_type: """YYYY-MM-DD만 허용하고 잘못된 날짜를 명확한 400으로 반환한다.""" if not re.fullmatch(r"\d{4}-\d{2}-\d{2}", value): raise HTTPException( status_code=400, detail="날짜 형식은 YYYY-MM-DD여야 합니다.", ) try: return date_type.fromisoformat(value) except ValueError as exc: raise HTTPException( status_code=400, detail="유효하지 않은 날짜입니다.", ) from exc def ensure_plant_exists(db: Client, plant_id: str) -> None: response = db.table("plants") \ .select("id") \ .eq("id", plant_id) \ .limit(1) \ .execute() if not response.data: raise HTTPException(status_code=404, detail="발전소를 찾을 수 없습니다.") @router.get("/stats/comparison", response_model=ComparisonStatsResponse) def get_all_plants_comparison( period: Literal["day", "month", "year"] = Query("day", description="통계 기간"), date: Optional[str] = Query(None, description="날짜 (YYYY-MM-DD)"), year: Optional[int] = Query(None, ge=MIN_STATS_YEAR, le=MAX_STATS_YEAR, description="연도"), month: Optional[int] = Query(None, ge=1, le=12, description="월"), db: Client = Depends(get_db) ) -> ComparisonStatsResponse: """ 전체 발전소 발전량 비교 통계 조회 """ try: # 날짜 파라미터 처리 (서버 OS 시간대와 무관하게 KST 기준) today = today_kst() target_date = parse_iso_date(date) if date else today target_year = year if year is not None else target_date.year target_month = month if month is not None else target_date.month # 1. 모든 발전소 기본 정보 조회 (이름, 용량) plants_res = db.table("plants").select("id, name, capacity").execute() plants = {p['id']: p for p in plants_res.data} # 결과 초기화 result_data = [] # 데이터 조회 로직 stats_map = {} # plant_id -> generation if period == "day": date_str = target_date.isoformat() # (A) 오늘 날짜인 경우: solar_logs 최신값 (실시간) # 서버 시간대와 클라이언트 요청 날짜 일치 여부 확인 if target_date == today: start_utc, next_start_utc = kst_day_bounds_utc(target_date) # solar_logs에서 오늘 생성된 데이터 조회 logs_res = db.table("solar_logs") \ .select("plant_id, today_kwh") \ .gte("created_at", start_utc.isoformat()) \ .lt("created_at", next_start_utc.isoformat()) \ .order("created_at", desc=True) \ .execute() # plant_id별로 첫 번째(최신) 값만 취함 seen_plants = set() for log in logs_res.data: pid = log['plant_id'] if pid not in seen_plants: val = log.get('today_kwh', 0) if val is None: val = 0 stats_map[pid] = val seen_plants.add(pid) else: # 과거: daily_stats 조회 daily_res = db.table("daily_stats") \ .select("plant_id, total_generation") \ .eq("date", date_str) \ .execute() for row in daily_res.data: val = row.get('total_generation', 0) if val is None: val = 0 stats_map[row['plant_id']] = val elif period == "month": # 월간: monthly_stats 조회 (해당 월) month_str = f"{target_year}-{target_month:02d}" # 1. monthly_stats 조회 monthly_res = db.table("monthly_stats") \ .select("plant_id, total_generation") \ .eq("month", month_str) \ .execute() for row in monthly_res.data: val = row.get('total_generation', 0) if val is None: val = 0 stats_map[row['plant_id']] = val # 2. 이번 달이거나 데이터가 부족하면 daily_stats 합산 시도 is_current_month = (target_year == today.year and target_month == today.month) has_missing_data = not stats_map or all(v == 0 for v in stats_map.values()) if is_current_month or has_missing_data: last_day = calendar.monthrange(target_year, target_month)[1] start_date = f"{target_year}-{target_month:02d}-01" end_date = f"{target_year}-{target_month:02d}-{last_day}" daily_res = db.table("daily_stats") \ .select("plant_id, total_generation") \ .gte("date", start_date) \ .lte("date", end_date) \ .execute() temp_map = {} for row in daily_res.data: pid = row['plant_id'] val = row.get('total_generation', 0) if val is None: val = 0 temp_map[pid] = temp_map.get(pid, 0) + val # 합산된 값이 있으면 업데이트 for pid, val in temp_map.items(): if val > stats_map.get(pid, 0): stats_map[pid] = val elif period == "year": # 연간: monthly_stats 조회 (해당 연도 전체 합산) start_month = f"{target_year}-01" end_month = f"{target_year}-12" monthly_res = db.table("monthly_stats") \ .select("plant_id, total_generation") \ .gte("month", start_month) \ .lte("month", end_month) \ .execute() # 합산 for row in monthly_res.data: pid = row['plant_id'] val = row.get('total_generation', 0) if val is None: val = 0 stats_map[pid] = stats_map.get(pid, 0) + val # 결과 조합 for pid, p_info in plants.items(): gen = stats_map.get(pid, 0) or 0 cap = p_info.get('capacity', 0) or 0 # 발전시간 계산 gen_hours = 0 if cap > 0: if period == "day": gen_hours = gen / cap elif period == "month": # 해당 월의 일수 (일평균 발전시간) days_in_month = calendar.monthrange(target_year, target_month)[1] gen_hours = (gen / cap) / days_in_month elif period == "year": # 윤년을 포함한 연간 일평균 발전시간 days_in_year = 366 if calendar.isleap(target_year) else 365 gen_hours = (gen / cap) / days_in_year result_data.append({ "plant_id": pid, "plant_name": p_info['name'], "capacity": cap, "generation": round(gen, 2), "generation_hours": round(gen_hours, 2) }) # 발전소 이름 순 정렬 (호기 추출) def sort_key(item): match = re.search(r'(\d+)호기', item['plant_name']) if match: return int(match.group(1)) return 999 result_data.sort(key=sort_key) return ComparisonStatsResponse(**{ "status": "success", "period": period, "target_date": target_date.isoformat(), "data": result_data, "count": len(result_data) }) except HTTPException: raise except Exception as e: raise HTTPException( status_code=500, detail=f"전체 통계 조회 실패: {str(e)}" ) @router.get("/{plant_id}/stats", response_model=PlantStatsResponse) def get_plant_stats( plant_id: str, period: Literal["day", "month", "year"] = Query("day", description="통계 기간"), year: Optional[int] = Query(None, ge=MIN_STATS_YEAR, le=MAX_STATS_YEAR, description="특정 연도"), month: Optional[int] = Query(None, ge=1, le=12, description="특정 월"), db: Client = Depends(get_db) ) -> PlantStatsResponse: """ 발전소 통계 조회 (Hybrid 방식) """ try: today = today_kst() today_str = today.isoformat() ensure_plant_exists(db, plant_id) # 1. 과거 데이터 조회 (period에 따라 테이블 분기) stats_data_raw = [] is_monthly_source = False if period == "day": # [일별 조회] daily_stats 테이블 사용 # 특정 연/월의 1일 ~ 말일 조회 target_year = year if year else today.year target_month = month if month else today.month # 해당 월의 시작일과 마지막일 계산 import calendar last_day = calendar.monthrange(target_year, target_month)[1] start_date_str = f"{target_year}-{target_month:02d}-01" end_date_str = f"{target_year}-{target_month:02d}-{last_day}" stats_query = db.table("daily_stats") \ .select("date, total_generation") \ .eq("plant_id", plant_id) \ .gte("date", start_date_str) \ .lte("date", end_date_str) \ .order("date", desc=False) stats_data_raw = stats_query.execute().data else: # [월별/연도별 조회] monthly_stats 테이블 사용 is_monthly_source = True if period == "month": # year 파라미터가 있으면 해당 연도 1월~12월, 없으면 올해 target_year = year if year else today.year start_month = f"{target_year}-01" end_month = f"{target_year}-12" else: # year # year 파라미터가 있으면 해당 연도 포함 최근 5년치 조회 if year: start_year = year - 4 end_year = year else: start_year = today.year - 9 # 10년치 end_year = today.year start_month = f"{start_year}-01" end_month = f"{end_year}-12" stats_query = db.table("monthly_stats") \ .select("month, total_generation") \ .eq("plant_id", plant_id) \ .gte("month", start_month) \ .lte("month", end_month) \ .order("month", desc=False) stats_data_raw = stats_query.execute().data # 데이터 맵핑 {날짜키: 발전량} data_map = {} for row in stats_data_raw: key = row.get("month") if is_monthly_source else row.get("date") val = row.get("total_generation") or 0 if key: data_map[key] = val # 2. 오늘 실시간 데이터 조회 (solar_logs) -> period='day'이고 '오늘'이 포함된 달이면 현재값 업데이트 # (생략 가능하지만, 오늘 날짜가 조회 범위에 포함되면 최신값 반영) if period == "day": # 조회 중인 달이 이번 달인지 확인 if (not year or year == today.year) and (not month or month == today.month): start_utc, next_start_utc = kst_day_bounds_utc(today) logs_result = db.table("solar_logs") \ .select("today_kwh") \ .eq("plant_id", plant_id) \ .gte("created_at", start_utc.isoformat()) \ .lt("created_at", next_start_utc.isoformat()) \ .order("created_at", desc=True) \ .limit(1) \ .execute() if logs_result.data: today_generation = logs_result.data[0].get("today_kwh", 0.0) if today_generation > 0: data_map[today_str] = today_generation # 4. 포맷팅 및 집계 data = [] if period == "day": # 해당 월의 모든 날짜 생성 target_year = year if year else today.year target_month = month if month else today.month last_day = calendar.monthrange(target_year, target_month)[1] # 미래 날짜는 제외해야 하나? UI에서 처리하거나 null로? # 일단 0으로 채움. for d in range(1, last_day + 1): d_str = f"{target_year}-{target_month:02d}-{d:02d}" # 미래 날짜 처리: 오늘 이후라면 데이터가 없을 것임 (0). # 하지만 '오늘'까지는 표시. data.append({ "label": d_str, "value": round(data_map.get(d_str, 0), 2) }) elif period == "month": # 월별 집계 monthly = {} for date_str, val in data_map.items(): # date_str이 'YYYY-MM-DD' 형식이면 YYYY-MM 추출 # 근데 monthly_stats 사용 시 이미 YYYY-MM if is_monthly_source: month_key = date_str else: month_key = date_str[:7] monthly[month_key] = monthly.get(month_key, 0) + val # 특정 연도의 1~12월 데이터 생성 target_year = year if year else today.year for m in range(1, 13): month_key = f"{target_year}-{m:02d}" data.append({ "label": month_key, "value": round(monthly.get(month_key, 0), 2) }) elif period == "year": # 연도별 집계 yearly = {} for date_str, val in data_map.items(): year_key = date_str[:4] yearly[year_key] = yearly.get(year_key, 0) + val # year 파라미터가 있으면 해당 연도 기준 최근 5년 표시 (예: 2026 선택 -> 2022~2026) if year: end_year = year start_year = year - 4 else: end_year = today.year start_year = today.year - 9 for y in range(start_year, end_year + 1): y_str = str(y) data.append({ "label": y_str, "value": round(yearly.get(y_str, 0), 2) }) return PlantStatsResponse(**{ "status": "success", "plant_id": plant_id, "period": period, "data": data, "count": len(data) }) except HTTPException: raise except Exception as e: raise HTTPException( status_code=500, detail=f"통계 조회 실패: {str(e)}" ) @router.get("/{plant_id}/stats/today", response_model=HourlyStatsResponse) def get_plant_hourly_stats( plant_id: str, date: str = Query(None, description="조회 날짜 (YYYY-MM-DD)"), db: Client = Depends(get_db) ) -> HourlyStatsResponse: """ 특정 날짜의 시간별 발전 데이터 조회 (solar_logs 기반) Defaults to today if date is not provided. """ try: if date: target_date = parse_iso_date(date) else: target_date = today_kst() ensure_plant_exists(db, plant_id) target_date_str = target_date.isoformat() # 조회 범위: KST 하루를 UTC 반개구간으로 변환한다. from_utc, next_start_utc = kst_day_bounds_utc(target_date) logs_result = db.table("solar_logs") \ .select("current_kw, today_kwh, created_at") \ .eq("plant_id", plant_id) \ .gte("created_at", from_utc.isoformat()) \ .lt("created_at", next_start_utc.isoformat()) \ .order("created_at", desc=False) \ .execute() # 시간별로 그룹화 hourly_data = {} for log in logs_result.data: created_at = log.get("created_at", "") if created_at: try: dt_kst = parse_db_timestamp_kst(created_at) if dt_kst.date() != target_date: continue hour = dt_kst.hour hourly_data[hour] = { "current_kw": log.get("current_kw", 0) or 0, "today_kwh": log.get("today_kwh", 0) or 0, } except (TypeError, ValueError): continue result = [] for hour in range(24): data = hourly_data.get(hour, {"current_kw": 0, "today_kwh": 0}) result.append({ "hour": hour, "label": f"{hour}시", "current_kw": round(data["current_kw"], 2), "today_kwh": round(data["today_kwh"], 2), "has_data": hour in hourly_data }) return HourlyStatsResponse(**{ "status": "success", "plant_id": plant_id, "date": target_date_str, "data": result, "count": len([d for d in result if d["has_data"]]) }) except HTTPException: raise except Exception as e: raise HTTPException( status_code=500, detail=f"시간별 통계 조회 실패: {str(e)}" )