diff --git a/app/main.py b/app/main.py index 248a102..fd76c7a 100644 --- a/app/main.py +++ b/app/main.py @@ -7,7 +7,7 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from app.core.config import get_settings -from app.routers import plants, upload +from app.routers import plants, upload, stats # 설정 로드 settings = get_settings() @@ -33,6 +33,7 @@ app.add_middleware( # 라우터 등록 app.include_router(plants.router) app.include_router(upload.router) +app.include_router(stats.router) @app.get("/", tags=["Health"]) diff --git a/app/routers/stats.py b/app/routers/stats.py new file mode 100644 index 0000000..069e84f --- /dev/null +++ b/app/routers/stats.py @@ -0,0 +1,119 @@ +""" +통계 조회 API +- 일별/월별/연도별 발전량 통계 +""" + +from fastapi import APIRouter, HTTPException, Depends, Query +from supabase import Client +from typing import List, Literal +from datetime import datetime, timedelta + +from app.core.database import get_db + +router = APIRouter( + prefix="/plants", + tags=["Stats"] +) + + +@router.get("/{plant_id}/stats") +async def get_plant_stats( + plant_id: str, + period: Literal["day", "month", "year"] = Query("day", description="통계 기간"), + db: Client = Depends(get_db) +) -> dict: + """ + 발전소 통계 조회 + + Args: + plant_id: 발전소 ID + period: 'day' (최근 30일), 'month' (최근 12개월), 'year' (최근 5년) + + Returns: + 차트 라이브러리 친화적 포맷 [{"label": "...", "value": ...}, ...] + """ + try: + today = datetime.now().date() + + if period == "day": + # 최근 30일 + start_date = today - timedelta(days=30) + + result = db.table("daily_stats") \ + .select("date, total_generation") \ + .eq("plant_id", plant_id) \ + .gte("date", start_date.isoformat()) \ + .lte("date", today.isoformat()) \ + .order("date", desc=False) \ + .execute() + + data = [ + {"label": row["date"], "value": row["total_generation"] or 0} + for row in result.data + ] + + elif period == "month": + # 최근 12개월 - 월별 합계 + start_date = today.replace(day=1) - timedelta(days=365) + + result = db.table("daily_stats") \ + .select("date, total_generation") \ + .eq("plant_id", plant_id) \ + .gte("date", start_date.isoformat()) \ + .lte("date", today.isoformat()) \ + .execute() + + # 월별 집계 + monthly = {} + for row in result.data: + date_str = row["date"] + month_key = date_str[:7] # YYYY-MM + generation = row["total_generation"] or 0 + monthly[month_key] = monthly.get(month_key, 0) + generation + + data = [ + {"label": month, "value": round(value, 2)} + for month, value in sorted(monthly.items()) + ][-12:] # 최근 12개월만 + + elif period == "year": + # 최근 5년 - 연도별 합계 + start_year = today.year - 5 + + result = db.table("daily_stats") \ + .select("date, total_generation") \ + .eq("plant_id", plant_id) \ + .gte("date", f"{start_year}-01-01") \ + .lte("date", today.isoformat()) \ + .execute() + + # 연도별 집계 + yearly = {} + for row in result.data: + date_str = row["date"] + year_key = date_str[:4] # YYYY + generation = row["total_generation"] or 0 + yearly[year_key] = yearly.get(year_key, 0) + generation + + data = [ + {"label": year, "value": round(value, 2)} + for year, value in sorted(yearly.items()) + ] + else: + raise HTTPException(status_code=400, detail="Invalid period parameter") + + return { + "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)}" + ) diff --git a/app/routers/upload.py b/app/routers/upload.py index b1c1e08..5fdbb44 100644 --- a/app/routers/upload.py +++ b/app/routers/upload.py @@ -163,3 +163,138 @@ async def upload_history( status_code=500, detail=f"DB 저장 실패: {str(e)}" ) + + +@router.post("/plants/{plant_id}/upload") +async def upload_plant_data( + plant_id: str, + file: UploadFile = File(..., description="엑셀 파일 (.xlsx, .xls)"), + db: Client = Depends(get_db) +) -> dict: + """ + 개별 발전소 엑셀 데이터 업로드 + + 엑셀 필수 컬럼: + - date: 날짜 (YYYY-MM-DD) + - generation: 발전량 (kWh) + + Args: + plant_id: 발전소 ID (URL 경로) + file: 엑셀 파일 + + Returns: + 저장 결과 메시지 + """ + # 1. 파일 확장자 검증 + if not file.filename.endswith(('.xlsx', '.xls')): + raise HTTPException( + status_code=400, + detail="엑셀 파일(.xlsx, .xls)만 업로드 가능합니다." + ) + + # 2. 발전소 정보 조회 + try: + plant_response = db.table("plants") \ + .select("id, capacity, name") \ + .eq("id", plant_id) \ + .single() \ + .execute() + + if not plant_response.data: + raise HTTPException( + status_code=404, + detail=f"발전소 '{plant_id}'를 찾을 수 없습니다." + ) + + capacity = plant_response.data.get('capacity', 99.0) or 99.0 + plant_name = plant_response.data.get('name', plant_id) + + except HTTPException: + raise + except Exception as e: + raise HTTPException( + status_code=500, + detail=f"발전소 조회 실패: {str(e)}" + ) + + # 3. 엑셀 파일 읽기 + try: + contents = await file.read() + df = pd.read_excel(io.BytesIO(contents)) + + # 컬럼명 소문자 변환 + df.columns = [str(c).lower().strip() for c in df.columns] + + # 필수 컬럼 확인 + if 'date' not in df.columns or 'generation' not in df.columns: + raise HTTPException( + status_code=400, + detail="필수 컬럼 'date', 'generation'이 없습니다." + ) + + if df.empty: + raise HTTPException( + status_code=400, + detail="엑셀 파일에 데이터가 없습니다." + ) + + except HTTPException: + raise + except Exception as e: + raise HTTPException( + status_code=400, + detail=f"엑셀 파일 읽기 실패: {str(e)}" + ) + + # 4. 데이터 변환 + records = [] + errors = [] + + for idx, row in df.iterrows(): + try: + date_val = row['date'] + if pd.isna(date_val): + continue + + date_str = pd.to_datetime(date_val).strftime('%Y-%m-%d') + generation = float(row['generation']) if pd.notna(row['generation']) else 0.0 + generation_hours = round(generation / capacity, 2) if capacity > 0 else 0.0 + + records.append({ + 'plant_id': plant_id, + 'date': date_str, + 'total_generation': round(generation, 2), + 'peak_kw': 0.0, + 'generation_hours': generation_hours + }) + + except Exception as e: + errors.append(f"행 {idx+2}: {str(e)}") + + if not records: + raise HTTPException( + status_code=400, + detail=f"저장할 유효한 데이터가 없습니다." + ) + + # 5. DB Upsert + try: + db.table("daily_stats").upsert( + records, + on_conflict="plant_id,date" + ).execute() + + return { + "status": "success", + "plant_id": plant_id, + "plant_name": plant_name, + "message": f"{len(records)}건의 데이터가 저장되었습니다.", + "saved_count": len(records), + "error_count": len(errors) + } + + except Exception as e: + raise HTTPException( + status_code=500, + detail=f"DB 저장 실패: {str(e)}" + )