From 8878303cac321d425e253c4b6ac86a627656ec52 Mon Sep 17 00:00:00 2001 From: haneulai Date: Thu, 22 Jan 2026 14:22:47 +0900 Subject: [PATCH 01/27] Initial commit: FastAPI middleware server for solar power monitoring --- .gitignore | 34 ++++++++++ app/__init__.py | 1 + app/core/__init__.py | 1 + app/core/config.py | 33 ++++++++++ app/core/database.py | 28 ++++++++ app/main.py | 75 +++++++++++++++++++++ app/routers/__init__.py | 1 + app/routers/auth.py | 18 ++++++ app/routers/plants.py | 140 ++++++++++++++++++++++++++++++++++++++++ app/schemas/__init__.py | 2 + app/schemas/plant.py | 46 +++++++++++++ requirements.txt | 6 ++ 12 files changed, 385 insertions(+) create mode 100644 .gitignore create mode 100644 app/__init__.py create mode 100644 app/core/__init__.py create mode 100644 app/core/config.py create mode 100644 app/core/database.py create mode 100644 app/main.py create mode 100644 app/routers/__init__.py create mode 100644 app/routers/auth.py create mode 100644 app/routers/plants.py create mode 100644 app/schemas/__init__.py create mode 100644 app/schemas/plant.py create mode 100644 requirements.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f50cc15 --- /dev/null +++ b/.gitignore @@ -0,0 +1,34 @@ +# Environment +.env +.env.local +.env.*.local + +# Virtual Environment +venv/ +.venv/ +env/ +ENV/ + +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Distribution / Build +dist/ +build/ +*.egg-info/ + +# Logs +*.log diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e32e435 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1 @@ +# Solar Power Monitoring API Server diff --git a/app/core/__init__.py b/app/core/__init__.py new file mode 100644 index 0000000..d4f041b --- /dev/null +++ b/app/core/__init__.py @@ -0,0 +1 @@ +# Core configuration module diff --git a/app/core/config.py b/app/core/config.py new file mode 100644 index 0000000..885e960 --- /dev/null +++ b/app/core/config.py @@ -0,0 +1,33 @@ +""" +환경변수 설정 모듈 +- python-dotenv를 사용하여 .env 파일에서 환경변수 로드 +""" + +from pydantic_settings import BaseSettings +from functools import lru_cache + + +class Settings(BaseSettings): + """애플리케이션 설정""" + + # Supabase 설정 + SUPABASE_URL: str + SUPABASE_KEY: str + + # 앱 설정 + APP_NAME: str = "Solar Power Monitoring API" + APP_VERSION: str = "1.0.0" + DEBUG: bool = False + + class Config: + env_file = ".env" + env_file_encoding = "utf-8" + + +@lru_cache() +def get_settings() -> Settings: + """ + 설정 싱글톤 인스턴스 반환 + lru_cache를 사용하여 한 번만 로드 + """ + return Settings() diff --git a/app/core/database.py b/app/core/database.py new file mode 100644 index 0000000..3f24f31 --- /dev/null +++ b/app/core/database.py @@ -0,0 +1,28 @@ +""" +Supabase 데이터베이스 클라이언트 모듈 +- 싱글톤 패턴으로 클라이언트 인스턴스 관리 +""" + +from supabase import create_client, Client +from functools import lru_cache +from .config import get_settings + + +@lru_cache() +def get_supabase_client() -> Client: + """ + Supabase 클라이언트 싱글톤 인스턴스 반환 + lru_cache를 사용하여 한 번만 생성 + """ + settings = get_settings() + return create_client( + settings.SUPABASE_URL, + settings.SUPABASE_KEY + ) + + +def get_db() -> Client: + """ + Supabase 클라이언트 반환 (의존성 주입용) + """ + return get_supabase_client() diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..63f244a --- /dev/null +++ b/app/main.py @@ -0,0 +1,75 @@ +""" +FastAPI 애플리케이션 진입점 +- 태양광 발전 관제 시스템 미들웨어 서버 +""" + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from app.core.config import get_settings +from app.routers import plants + +# 설정 로드 +settings = get_settings() + +# FastAPI 앱 생성 +app = FastAPI( + title=settings.APP_NAME, + version=settings.APP_VERSION, + description="태양광 발전 관제 시스템을 위한 미들웨어 API 서버", + docs_url="/docs", + redoc_url="/redoc" +) + +# CORS 미들웨어 설정 (모든 도메인 허용 - 개발용) +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# 라우터 등록 +app.include_router(plants.router) + + +@app.get("/", tags=["Health"]) +async def health_check() -> dict: + """ + 서버 상태 확인 (Health Check) + + Returns: + 서버 상태 및 버전 정보 + """ + return { + "status": "healthy", + "app_name": settings.APP_NAME, + "version": settings.APP_VERSION + } + + +@app.get("/health", tags=["Health"]) +async def detailed_health_check() -> dict: + """ + 상세 서버 상태 확인 + + Returns: + 서버 상태 및 연결 정보 + """ + return { + "status": "healthy", + "app_name": settings.APP_NAME, + "version": settings.APP_VERSION, + "supabase_connected": bool(settings.SUPABASE_URL) + } + + +if __name__ == "__main__": + import uvicorn + uvicorn.run( + "app.main:app", + host="0.0.0.0", + port=8000, + reload=settings.DEBUG + ) diff --git a/app/routers/__init__.py b/app/routers/__init__.py new file mode 100644 index 0000000..6904e18 --- /dev/null +++ b/app/routers/__init__.py @@ -0,0 +1 @@ +# API Routers module diff --git a/app/routers/auth.py b/app/routers/auth.py new file mode 100644 index 0000000..e0a49ad --- /dev/null +++ b/app/routers/auth.py @@ -0,0 +1,18 @@ +""" +인증 관련 API 엔드포인트 (추후 예정) +- 로그인/로그아웃 +- 토큰 관리 +""" + +from fastapi import APIRouter + +router = APIRouter( + prefix="/auth", + tags=["Authentication"] +) + + +# TODO: 추후 구현 예정 +# - POST /auth/login +# - POST /auth/logout +# - POST /auth/refresh diff --git a/app/routers/plants.py b/app/routers/plants.py new file mode 100644 index 0000000..e9a7362 --- /dev/null +++ b/app/routers/plants.py @@ -0,0 +1,140 @@ +""" +발전소 관련 API 엔드포인트 +- 발전소 목록 조회 +- 발전 현황 조회 +""" + +from fastapi import APIRouter, HTTPException, Depends +from supabase import Client +from typing import List + +from app.core.database import get_db +from app.schemas.plant import PlantsListResponse, PlantWithLatestLog, SolarLogBase + +router = APIRouter( + prefix="/plants", + tags=["Plants"] +) + + +@router.get("/{company_id}", response_model=PlantsListResponse) +async def get_plants_by_company( + company_id: int, + db: Client = Depends(get_db) +) -> PlantsListResponse: + """ + 특정 업체의 모든 발전소 목록과 최신 발전 현황을 조회합니다. + + Args: + company_id: 업체 ID + + Returns: + 발전소 목록 및 각 발전소의 최신 발전 로그 + """ + try: + # 1. 해당 업체의 모든 발전소 조회 + plants_response = db.table("plants") \ + .select("*") \ + .eq("company_id", company_id) \ + .execute() + + plants = plants_response.data + + if not plants: + return PlantsListResponse( + status="success", + data=[], + total_count=0 + ) + + # 2. 각 발전소의 최신 발전 로그 조회 + result: List[PlantWithLatestLog] = [] + + for plant in plants: + # 해당 발전소의 최신 로그 1건 조회 + log_response = db.table("solar_logs") \ + .select("*") \ + .eq("plant_id", plant["id"]) \ + .order("timestamp", desc=True) \ + .limit(1) \ + .execute() + + latest_log = None + if log_response.data: + latest_log = SolarLogBase(**log_response.data[0]) + + plant_with_log = PlantWithLatestLog( + **plant, + latest_log=latest_log + ) + result.append(plant_with_log) + + return PlantsListResponse( + status="success", + data=result, + total_count=len(result) + ) + + except Exception as e: + raise HTTPException( + status_code=500, + detail=f"데이터베이스 조회 중 오류가 발생했습니다: {str(e)}" + ) + + +@router.get("/{company_id}/{plant_id}", response_model=dict) +async def get_plant_detail( + company_id: int, + plant_id: int, + db: Client = Depends(get_db) +) -> dict: + """ + 특정 발전소의 상세 정보를 조회합니다. + + Args: + company_id: 업체 ID + plant_id: 발전소 ID + + Returns: + 발전소 상세 정보 및 최근 발전 로그 + """ + try: + # 발전소 조회 + plant_response = db.table("plants") \ + .select("*") \ + .eq("id", plant_id) \ + .eq("company_id", company_id) \ + .single() \ + .execute() + + if not plant_response.data: + raise HTTPException( + status_code=404, + detail="발전소를 찾을 수 없습니다." + ) + + plant = plant_response.data + + # 최근 발전 로그 10건 조회 + logs_response = db.table("solar_logs") \ + .select("*") \ + .eq("plant_id", plant_id) \ + .order("timestamp", desc=True) \ + .limit(10) \ + .execute() + + return { + "status": "success", + "data": { + "plant": plant, + "recent_logs": logs_response.data or [] + } + } + + except HTTPException: + raise + except Exception as e: + raise HTTPException( + status_code=500, + detail=f"데이터베이스 조회 중 오류가 발생했습니다: {str(e)}" + ) diff --git a/app/schemas/__init__.py b/app/schemas/__init__.py new file mode 100644 index 0000000..3102422 --- /dev/null +++ b/app/schemas/__init__.py @@ -0,0 +1,2 @@ +# Pydantic Schemas module +from .plant import PlantBase, PlantResponse, PlantWithLatestLog, PlantsListResponse diff --git a/app/schemas/plant.py b/app/schemas/plant.py new file mode 100644 index 0000000..5aaea15 --- /dev/null +++ b/app/schemas/plant.py @@ -0,0 +1,46 @@ +""" +발전소 관련 Pydantic 스키마 +- 요청/응답 데이터 검증 +""" + +from pydantic import BaseModel, Field +from typing import Optional, List +from datetime import datetime + + +class PlantBase(BaseModel): + """발전소 기본 정보 스키마""" + id: int + company_id: int + name: str + capacity: Optional[float] = Field(None, description="발전 용량 (kW)") + location: Optional[str] = Field(None, description="발전소 위치") + created_at: Optional[datetime] = None + + +class SolarLogBase(BaseModel): + """발전 로그 기본 정보 스키마""" + id: int + plant_id: int + timestamp: datetime + power_output: Optional[float] = Field(None, description="현재 발전량 (kW)") + daily_generation: Optional[float] = Field(None, description="일일 발전량 (kWh)") + total_generation: Optional[float] = Field(None, description="누적 발전량 (kWh)") + + +class PlantWithLatestLog(PlantBase): + """발전소 정보 + 최신 발전 현황""" + latest_log: Optional[SolarLogBase] = Field(None, description="최신 발전 로그") + + +class PlantResponse(BaseModel): + """단일 발전소 응답 스키마""" + status: str = "success" + data: PlantWithLatestLog + + +class PlantsListResponse(BaseModel): + """발전소 목록 응답 스키마""" + status: str = "success" + data: List[PlantWithLatestLog] + total_count: int = Field(description="총 발전소 수") diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..f60e478 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +fastapi>=0.104.0 +uvicorn[standard]>=0.24.0 +python-dotenv>=1.0.0 +supabase>=2.0.0 +pydantic>=2.0.0 +pydantic-settings>=2.0.0 From f826028161194baa350678a05fdf84fe1030c593 Mon Sep 17 00:00:00 2001 From: haneulai Date: Thu, 22 Jan 2026 15:18:04 +0900 Subject: [PATCH 02/27] Fix: change timestamp column name to created_at --- app/routers/plants.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/routers/plants.py b/app/routers/plants.py index e9a7362..02c1cd6 100644 --- a/app/routers/plants.py +++ b/app/routers/plants.py @@ -55,7 +55,7 @@ async def get_plants_by_company( log_response = db.table("solar_logs") \ .select("*") \ .eq("plant_id", plant["id"]) \ - .order("timestamp", desc=True) \ + .order("created_at", desc=True) \ .limit(1) \ .execute() @@ -119,7 +119,7 @@ async def get_plant_detail( logs_response = db.table("solar_logs") \ .select("*") \ .eq("plant_id", plant_id) \ - .order("timestamp", desc=True) \ + .order("created_at", desc=True) \ .limit(10) \ .execute() From 903d2cfb0b1360259b8ca1ff252334a827a611c0 Mon Sep 17 00:00:00 2001 From: haneulai Date: Thu, 22 Jan 2026 15:22:22 +0900 Subject: [PATCH 03/27] fix: change plant_id to str, timestamp to created_at for DB schema match --- app/schemas/plant.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/schemas/plant.py b/app/schemas/plant.py index 5aaea15..ffdcbc4 100644 --- a/app/schemas/plant.py +++ b/app/schemas/plant.py @@ -20,9 +20,11 @@ class PlantBase(BaseModel): class SolarLogBase(BaseModel): """발전 로그 기본 정보 스키마""" + model_config = {"from_attributes": True} + id: int - plant_id: int - timestamp: datetime + plant_id: str # DB에서 'nrems-01' 같은 문자열 형식 사용 + created_at: datetime # DB 컬럼명과 일치 power_output: Optional[float] = Field(None, description="현재 발전량 (kW)") daily_generation: Optional[float] = Field(None, description="일일 발전량 (kWh)") total_generation: Optional[float] = Field(None, description="누적 발전량 (kWh)") From 6ae032291809bd306140f68e919ccec220f9e329 Mon Sep 17 00:00:00 2001 From: haneulai Date: Thu, 22 Jan 2026 15:24:26 +0900 Subject: [PATCH 04/27] fix: change PlantBase id and company_id to str type --- app/schemas/plant.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/schemas/plant.py b/app/schemas/plant.py index ffdcbc4..1c3da23 100644 --- a/app/schemas/plant.py +++ b/app/schemas/plant.py @@ -10,8 +10,10 @@ from datetime import datetime class PlantBase(BaseModel): """발전소 기본 정보 스키마""" - id: int - company_id: int + model_config = {"from_attributes": True} + + id: str # DB에서 문자열 형식 사용 + company_id: str # DB에서 문자열 형식 사용 name: str capacity: Optional[float] = Field(None, description="발전 용량 (kW)") location: Optional[str] = Field(None, description="발전소 위치") From 126ede33ab1081a491f9586b571b6be111af5402 Mon Sep 17 00:00:00 2001 From: haneulai Date: Thu, 22 Jan 2026 15:25:52 +0900 Subject: [PATCH 05/27] fix: revert company_id back to int type --- app/schemas/plant.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/schemas/plant.py b/app/schemas/plant.py index 1c3da23..c912cec 100644 --- a/app/schemas/plant.py +++ b/app/schemas/plant.py @@ -13,7 +13,7 @@ class PlantBase(BaseModel): model_config = {"from_attributes": True} id: str # DB에서 문자열 형식 사용 - company_id: str # DB에서 문자열 형식 사용 + company_id: int # DB에서 정수형 사용 name: str capacity: Optional[float] = Field(None, description="발전 용량 (kW)") location: Optional[str] = Field(None, description="발전소 위치") From ac3d64ed7fa09a052a29cba4b030c8a4834b9c95 Mon Sep 17 00:00:00 2001 From: haneulai Date: Thu, 22 Jan 2026 15:44:36 +0900 Subject: [PATCH 06/27] fix: update SolarLogBase schema to match DB columns (current_kw, today_kwh, status) --- app/schemas/plant.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/schemas/plant.py b/app/schemas/plant.py index c912cec..43437f8 100644 --- a/app/schemas/plant.py +++ b/app/schemas/plant.py @@ -26,10 +26,10 @@ class SolarLogBase(BaseModel): id: int plant_id: str # DB에서 'nrems-01' 같은 문자열 형식 사용 + current_kw: Optional[float] = Field(None, description="현재 발전량 (kW)") + today_kwh: Optional[float] = Field(None, description="금일 발전량 (kWh)") + status: Optional[str] = Field(None, description="상태") created_at: datetime # DB 컬럼명과 일치 - power_output: Optional[float] = Field(None, description="현재 발전량 (kW)") - daily_generation: Optional[float] = Field(None, description="일일 발전량 (kWh)") - total_generation: Optional[float] = Field(None, description="누적 발전량 (kWh)") class PlantWithLatestLog(PlantBase): From 1b76ad61edf926664ae74a3cb5b061af305f93f1 Mon Sep 17 00:00:00 2001 From: haneulai Date: Fri, 23 Jan 2026 11:27:56 +0900 Subject: [PATCH 07/27] =?UTF-8?q?feat:=20=EC=97=91=EC=85=80=20=EC=97=85?= =?UTF-8?q?=EB=A1=9C=EB=93=9C=20API=20=EC=B6=94=EA=B0=80=20(POST=20/upload?= =?UTF-8?q?/history)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/main.py | 3 +- app/routers/upload.py | 165 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 167 insertions(+), 1 deletion(-) create mode 100644 app/routers/upload.py diff --git a/app/main.py b/app/main.py index 63f244a..248a102 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 +from app.routers import plants, upload # 설정 로드 settings = get_settings() @@ -32,6 +32,7 @@ app.add_middleware( # 라우터 등록 app.include_router(plants.router) +app.include_router(upload.router) @app.get("/", tags=["Health"]) diff --git a/app/routers/upload.py b/app/routers/upload.py new file mode 100644 index 0000000..b1c1e08 --- /dev/null +++ b/app/routers/upload.py @@ -0,0 +1,165 @@ +""" +엑셀 파일 업로드 API +- 과거 발전 데이터(Excel)를 업로드하여 daily_stats 테이블에 저장 +""" + +import io +from fastapi import APIRouter, UploadFile, File, Form, HTTPException, Depends +from supabase import Client +import pandas as pd + +from app.core.database import get_db + +router = APIRouter( + prefix="/upload", + tags=["Upload"] +) + + +@router.post("/history") +async def upload_history( + file: UploadFile = File(..., description="엑셀 파일 (.xlsx, .xls)"), + plant_id: str = Form(..., description="발전소 ID (예: nrems-01)"), + db: Client = Depends(get_db) +) -> dict: + """ + 과거 발전 데이터 엑셀 업로드 + + 엑셀 컬럼 형식: + - date: 날짜 (YYYY-MM-DD) + - generation: 발전량 (kWh) + + Args: + file: 엑셀 파일 + plant_id: 발전소 ID + + Returns: + 저장 결과 메시지 + """ + # 1. 파일 확장자 검증 + if not file.filename.endswith(('.xlsx', '.xls')): + raise HTTPException( + status_code=400, + detail="엑셀 파일(.xlsx, .xls)만 업로드 가능합니다." + ) + + # 2. 발전소 정보 조회 (capacity 필요) + try: + plant_response = db.table("plants") \ + .select("id, capacity") \ + .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) + if capacity <= 0: + capacity = 99.0 # 기본값 + + 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)) + + # 컬럼 확인 + required_columns = ['date', 'generation'] + missing_columns = [col for col in required_columns if col not in df.columns] + if missing_columns: + raise HTTPException( + status_code=400, + detail=f"필수 컬럼이 없습니다: {missing_columns}. 엑셀에는 '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): + errors.append(f"행 {idx+2}: 날짜가 비어있습니다.") + continue + + if isinstance(date_val, str): + date_str = date_val.strip() + else: + 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 계산 + generation_hours = round(generation / capacity, 2) if capacity > 0 else 0.0 + + record = { + 'plant_id': plant_id, + 'date': date_str, + 'total_generation': round(generation, 2), + 'peak_kw': 0.0, # 과거 데이터에서는 알 수 없음 + 'generation_hours': generation_hours + } + records.append(record) + + except Exception as e: + errors.append(f"행 {idx+2}: {str(e)}") + + if not records: + raise HTTPException( + status_code=400, + detail=f"저장할 유효한 데이터가 없습니다. 오류: {errors[:5]}" + ) + + # 5. DB Upsert + try: + result = db.table("daily_stats").upsert( + records, + on_conflict="plant_id,date" + ).execute() + + response_msg = f"총 {len(records)}건의 데이터가 저장되었습니다." + if errors: + response_msg += f" (오류 {len(errors)}건 스킵)" + + return { + "status": "success", + "message": response_msg, + "saved_count": len(records), + "error_count": len(errors), + "errors": errors[:10] if errors else [] # 최대 10개 오류만 반환 + } + + except Exception as e: + raise HTTPException( + status_code=500, + detail=f"DB 저장 실패: {str(e)}" + ) From a891891a6eb07ce8fe500d931b1adbea93155fc6 Mon Sep 17 00:00:00 2001 From: haneulai Date: Fri, 23 Jan 2026 11:39:31 +0900 Subject: [PATCH 08/27] chore: add pandas, openpyxl, python-multipart dependencies --- requirements.txt | 74 ++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 68 insertions(+), 6 deletions(-) diff --git a/requirements.txt b/requirements.txt index f60e478..d2b4704 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,68 @@ -fastapi>=0.104.0 -uvicorn[standard]>=0.24.0 -python-dotenv>=1.0.0 -supabase>=2.0.0 -pydantic>=2.0.0 -pydantic-settings>=2.0.0 +annotated-doc==0.0.4 +annotated-types==0.7.0 +anyio==4.12.1 +cachetools==6.2.4 +certifi==2026.1.4 +cffi==2.0.0 +charset-normalizer==3.4.4 +click==8.3.1 +cryptography==46.0.3 +deprecation==2.1.0 +et_xmlfile==2.0.0 +exceptiongroup==1.3.1 +fastapi==0.128.0 +fsspec==2026.1.0 +h11==0.16.0 +h2==4.3.0 +hpack==4.1.0 +httpcore==1.0.9 +httptools==0.7.1 +httpx==0.28.1 +hyperframe==6.1.0 +idna==3.11 +markdown-it-py==4.0.0 +mdurl==0.1.2 +mmh3==5.2.0 +multidict==6.7.0 +numpy==2.2.6 +openpyxl==3.1.5 +packaging==26.0 +pandas==2.3.3 +postgrest==2.27.2 +propcache==0.4.1 +pycparser==3.0 +pydantic==2.12.5 +pydantic-settings==2.12.0 +pydantic_core==2.41.5 +Pygments==2.19.2 +pyiceberg==0.10.0 +PyJWT==2.10.1 +pyparsing==3.3.2 +pyroaring==1.0.3 +python-dateutil==2.9.0.post0 +python-dotenv==1.2.1 +python-multipart==0.0.21 +pytz==2025.2 +PyYAML==6.0.3 +realtime==2.27.2 +requests==2.32.5 +rich==14.2.0 +six==1.17.0 +sortedcontainers==2.4.0 +starlette==0.50.0 +storage3==2.27.2 +StrEnum==0.4.15 +strictyaml==1.7.3 +supabase==2.27.2 +supabase-auth==2.27.2 +supabase-functions==2.27.2 +tenacity==9.1.2 +typing-inspection==0.4.2 +typing_extensions==4.15.0 +tzdata==2025.3 +urllib3==2.6.3 +uvicorn==0.40.0 +uvloop==0.22.1 +watchfiles==1.1.1 +websockets==15.0.1 +yarl==1.22.0 From 5c43b6e43b001672147d79824efaf88d202f6e0e Mon Sep 17 00:00:00 2001 From: haneulai Date: Fri, 23 Jan 2026 15:42:40 +0900 Subject: [PATCH 09/27] feat: Add stats and upload APIs for plant detail screen --- app/main.py | 3 +- app/routers/stats.py | 119 +++++++++++++++++++++++++++++++++++++ app/routers/upload.py | 135 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 256 insertions(+), 1 deletion(-) create mode 100644 app/routers/stats.py 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)}" + ) From f4e7e8b03eedacf0e4935af4df60f075957aa4a0 Mon Sep 17 00:00:00 2001 From: haneulai Date: Fri, 23 Jan 2026 16:24:53 +0900 Subject: [PATCH 10/27] fix: Reorder router registration to fix route matching --- app/main.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/main.py b/app/main.py index fd76c7a..3999c36 100644 --- a/app/main.py +++ b/app/main.py @@ -30,10 +30,10 @@ app.add_middleware( allow_headers=["*"], ) -# 라우터 등록 -app.include_router(plants.router) -app.include_router(upload.router) -app.include_router(stats.router) +# 라우터 등록 (순서 중요: 더 구체적인 경로를 먼저 등록) +app.include_router(stats.router) # /plants/{plant_id}/stats +app.include_router(upload.router) # /plants/{plant_id}/upload +app.include_router(plants.router) # /plants/{company_id} (가장 일반적인 경로) @app.get("/", tags=["Health"]) From a7a83e87922e2a93713361a9805f9d242bf4f380 Mon Sep 17 00:00:00 2001 From: haneulai Date: Fri, 23 Jan 2026 16:45:37 +0900 Subject: [PATCH 11/27] feat: Update stats API to include real-time crawled data --- app/routers/stats.py | 139 +++++++++++++++++++++++++------------------ 1 file changed, 81 insertions(+), 58 deletions(-) diff --git a/app/routers/stats.py b/app/routers/stats.py index 069e84f..b87ec98 100644 --- a/app/routers/stats.py +++ b/app/routers/stats.py @@ -23,7 +23,10 @@ async def get_plant_stats( db: Client = Depends(get_db) ) -> dict: """ - 발전소 통계 조회 + 발전소 통계 조회 (Hybrid 방식) + 1. daily_stats: 과거 데이터 조회 + 2. solar_logs: 오늘 실시간 데이터 조회 + 3. 병합: 오늘 날짜 데이터는 실시간 데이터 우선 사용 Args: plant_id: 발전소 ID @@ -34,80 +37,100 @@ async def get_plant_stats( """ try: today = datetime.now().date() + today_str = today.isoformat() + + # 1. 과거 데이터 조회 (daily_stats) + if period == "day": + start_date = today - timedelta(days=30) + date_filter = start_date.isoformat() + elif period == "month": + start_date = today.replace(day=1) - timedelta(days=365) + date_filter = start_date.isoformat() + else: # year + start_date = datetime(today.year - 5, 1, 1).date() + date_filter = start_date.strftime("%Y-%m-%d") + + stats_query = db.table("daily_stats") \ + .select("date, total_generation") \ + .eq("plant_id", plant_id) \ + .gte("date", date_filter) \ + .lte("date", today_str) \ + .order("date", desc=False) + + stats_result = stats_query.execute() + + # 데이터 맵핑 {날짜: 발전량} + data_map = {row["date"]: row["total_generation"] or 0 for row in stats_result.data} + + # 2. 오늘 실시간 데이터 조회 (solar_logs) + # 오늘의 가장 마지막 기록 1건만 조회 (성능 최적화) + logs_result = db.table("solar_logs") \ + .select("today_kwh, created_at") \ + .eq("plant_id", plant_id) \ + .gte("created_at", f"{today_str}T00:00:00") \ + .order("created_at", desc=True) \ + .limit(1) \ + .execute() + + today_generation = 0.0 + if logs_result.data: + today_generation = logs_result.data[0].get("today_kwh", 0.0) + + # 3. 데이터 병합 (오늘 데이터 갱신/추가) + # solar_logs 값이 있으면 무조건 daily_stats 값보다 우선 (실시간성) + if today_generation > 0: + data_map[today_str] = today_generation + + # 4. 포맷팅 및 집계 + data = [] 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 - ] - + # 최근 30일 일별 데이터 생성 (누락된 날짜는 0으로 채움) + current = start_date + while current <= today: + d_str = current.isoformat() + data.append({ + "label": d_str, + "value": round(data_map.get(d_str, 0), 2) + }) + current += timedelta(days=1) + 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 - + # daily_stats + solar_logs(오늘) 데이터로 집계 + for date_str, val in data_map.items(): + month_key = date_str[:7] + monthly[month_key] = monthly.get(month_key, 0) + val + + sorted_keys = sorted(monthly.keys()) data = [ - {"label": month, "value": round(value, 2)} - for month, value in sorted(monthly.items()) - ][-12:] # 최근 12개월만 + {"label": k, "value": round(monthly[k], 2)} + for k in sorted_keys + if k >= start_date.strftime("%Y-%m") + ] 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 - + for date_str, val in data_map.items(): + year_key = date_str[:4] + yearly[year_key] = yearly.get(year_key, 0) + val + + sorted_keys = sorted(yearly.keys()) data = [ - {"label": year, "value": round(value, 2)} - for year, value in sorted(yearly.items()) + {"label": k, "value": round(yearly[k], 2)} + for k in sorted_keys + if k >= str(start_date.year) ] - else: - raise HTTPException(status_code=400, detail="Invalid period parameter") - + return { "status": "success", "plant_id": plant_id, "period": period, "data": data, - "count": len(data) + "count": len(data), + "today_realtime_kwh": today_generation # 디버깅용 } except HTTPException: From 1aa4448f9fd2b7d9ebbe04e84277477d34990322 Mon Sep 17 00:00:00 2001 From: haneulai Date: Mon, 26 Jan 2026 10:49:49 +0900 Subject: [PATCH 12/27] feat: Add hourly stats endpoint for today chart --- app/routers/stats.py | 65 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/app/routers/stats.py b/app/routers/stats.py index b87ec98..c12981d 100644 --- a/app/routers/stats.py +++ b/app/routers/stats.py @@ -140,3 +140,68 @@ async def get_plant_stats( status_code=500, detail=f"통계 조회 실패: {str(e)}" ) + + +@router.get("/{plant_id}/stats/today") +async def get_plant_hourly_stats( + plant_id: str, + db: Client = Depends(get_db) +) -> dict: + """ + 오늘 시간별 발전 데이터 조회 (solar_logs 기반) + + Args: + plant_id: 발전소 ID + + Returns: + 시간별 데이터 [{"hour": 0, "current_kw": ..., "today_kwh": ...}, ...] + """ + try: + today = datetime.now().date() + today_str = today.isoformat() + + # 오늘의 모든 solar_logs 조회 + logs_result = db.table("solar_logs") \ + .select("current_kw, today_kwh, created_at") \ + .eq("plant_id", plant_id) \ + .gte("created_at", f"{today_str}T00:00:00") \ + .order("created_at", desc=False) \ + .execute() + + # 시간별로 그룹화 (각 시간의 마지막 데이터 사용) + hourly_data = {} + for log in logs_result.data: + created_at = log.get("created_at", "") + if created_at: + # ISO 형식에서 시간 추출 + hour = int(created_at[11:13]) if len(created_at) > 13 else 0 + hourly_data[hour] = { + "current_kw": log.get("current_kw", 0) or 0, + "today_kwh": log.get("today_kwh", 0) or 0, + } + + # 0시~23시 전체 배열 생성 + 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 { + "status": "success", + "plant_id": plant_id, + "date": today_str, + "data": result, + "count": len([d for d in result if d["has_data"]]) + } + + except Exception as e: + raise HTTPException( + status_code=500, + detail=f"시간별 통계 조회 실패: {str(e)}" + ) From 15e611ebb6b0bc0070d5775898901bd9e1660c5e Mon Sep 17 00:00:00 2001 From: haneulai Date: Mon, 26 Jan 2026 10:56:19 +0900 Subject: [PATCH 13/27] fix: stats timezone issue (UTC to KST) --- app/routers/stats.py | 45 +++++++++++++++++++++++++++++++++----------- 1 file changed, 34 insertions(+), 11 deletions(-) diff --git a/app/routers/stats.py b/app/routers/stats.py index c12981d..601e66c 100644 --- a/app/routers/stats.py +++ b/app/routers/stats.py @@ -6,7 +6,7 @@ from fastapi import APIRouter, HTTPException, Depends, Query from supabase import Client from typing import List, Literal -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from app.core.database import get_db @@ -157,14 +157,23 @@ async def get_plant_hourly_stats( 시간별 데이터 [{"hour": 0, "current_kw": ..., "today_kwh": ...}, ...] """ try: - today = datetime.now().date() - today_str = today.isoformat() + # KST (UTC+9) 시간대 설정 + kst_timezone = timezone(timedelta(hours=9)) + today_kst = datetime.now(kst_timezone).date() + today_str = today_kst.isoformat() + + # 오늘의 모든 solar_logs 조회 (UTC 기준으로는 전날 15:00부터일 수 있으므로 넉넉하게 조회) + # 하지만 간단하게 '오늘' 날짜 문자열 필터링이 가장 안전 (DB가 UTC라면 보정 필요) + # 여기서는 created_at이 timestamptz라고 가정하고 단순 문자열 비교보다는 날짜 필터를 사용 + + # KST 00:00은 UTC로 전날 15:00 + start_dt_kst = datetime.combine(today_kst, datetime.min.time()).replace(tzinfo=kst_timezone) + start_dt_utc = start_dt_kst.astimezone(timezone.utc) - # 오늘의 모든 solar_logs 조회 logs_result = db.table("solar_logs") \ .select("current_kw, today_kwh, created_at") \ .eq("plant_id", plant_id) \ - .gte("created_at", f"{today_str}T00:00:00") \ + .gte("created_at", start_dt_utc.isoformat()) \ .order("created_at", desc=False) \ .execute() @@ -173,12 +182,26 @@ async def get_plant_hourly_stats( for log in logs_result.data: created_at = log.get("created_at", "") if created_at: - # ISO 형식에서 시간 추출 - hour = int(created_at[11:13]) if len(created_at) > 13 else 0 - hourly_data[hour] = { - "current_kw": log.get("current_kw", 0) or 0, - "today_kwh": log.get("today_kwh", 0) or 0, - } + try: + # ISO 형식 파싱 (DB에서 UTC로 넘어온다고 가정) + dt = datetime.fromisoformat(created_at.replace('Z', '+00:00')) + + # UTC -> KST 변환 + dt_kst = dt.astimezone(kst_timezone) + + # 오늘 날짜인지 확인 + if dt_kst.date() != today_kst: + continue + + hour = dt_kst.hour + + # 해당 시간대의 마지막 데이터로 갱신 (order가 오름차순이므로 덮어쓰면 됨) + hourly_data[hour] = { + "current_kw": log.get("current_kw", 0) or 0, + "today_kwh": log.get("today_kwh", 0) or 0, + } + except ValueError: + continue # 0시~23시 전체 배열 생성 result = [] From 060c32d1051278c9f180e8a427b0d594b4eb6bb4 Mon Sep 17 00:00:00 2001 From: haneulai Date: Mon, 26 Jan 2026 11:48:18 +0900 Subject: [PATCH 14/27] feat: change stats period to current month and fix label logic --- app/routers/stats.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/routers/stats.py b/app/routers/stats.py index 601e66c..76ab501 100644 --- a/app/routers/stats.py +++ b/app/routers/stats.py @@ -39,9 +39,11 @@ async def get_plant_stats( today = datetime.now().date() today_str = today.isoformat() + # 1. 과거 데이터 조회 (daily_stats) # 1. 과거 데이터 조회 (daily_stats) if period == "day": - start_date = today - timedelta(days=30) + # 이번 달 1일부터 오늘까지 + start_date = today.replace(day=1) date_filter = start_date.isoformat() elif period == "month": start_date = today.replace(day=1) - timedelta(days=365) From 39c792889585116e261c458e3b06da44a23e9a2a Mon Sep 17 00:00:00 2001 From: haneulai Date: Tue, 27 Jan 2026 15:34:20 +0900 Subject: [PATCH 15/27] feat: Add year parameter to stats API and extend year range --- app/routers/stats.py | 72 +++++++++++++++++++++++++++++--------------- 1 file changed, 48 insertions(+), 24 deletions(-) diff --git a/app/routers/stats.py b/app/routers/stats.py index 76ab501..137e70e 100644 --- a/app/routers/stats.py +++ b/app/routers/stats.py @@ -20,6 +20,7 @@ router = APIRouter( async def get_plant_stats( plant_id: str, period: Literal["day", "month", "year"] = Query("day", description="통계 기간"), + year: int = Query(None, description="특정 연도 (월별/연도별 조회 시)"), db: Client = Depends(get_db) ) -> dict: """ @@ -30,7 +31,8 @@ async def get_plant_stats( Args: plant_id: 발전소 ID - period: 'day' (최근 30일), 'month' (최근 12개월), 'year' (최근 5년) + period: 'day' (최근 30일), 'month' (최근 12개월 또는 특정 연도), 'year' (최근 연도들) + year: 특정 연도 (옵션, period='month' 시 해당 연도의 월별 데이터 반환) Returns: 차트 라이브러리 친화적 포맷 [{"label": "...", "value": ...}, ...] @@ -46,37 +48,54 @@ async def get_plant_stats( start_date = today.replace(day=1) date_filter = start_date.isoformat() elif period == "month": - start_date = today.replace(day=1) - timedelta(days=365) + # year 파라미터가 있으면 해당 연도 1월~12월, 없으면 올해 + target_year = year if year else today.year + start_date = datetime(target_year, 1, 1).date() + # 종료일: 해당 연도 12월 31일 또는 오늘 중 작은 값 + end_date = min(datetime(target_year, 12, 31).date(), today) date_filter = start_date.isoformat() else: # year - start_date = datetime(today.year - 5, 1, 1).date() + # year 파라미터가 있으면 해당 연도부터, 없으면 최근 10년 + if year: + start_year = year + else: + start_year = today.year - 9 # 10년치 데이터 (올해 포함) + start_date = datetime(start_year, 1, 1).date() + end_date = today date_filter = start_date.strftime("%Y-%m-%d") stats_query = db.table("daily_stats") \ .select("date, total_generation") \ .eq("plant_id", plant_id) \ - .gte("date", date_filter) \ - .lte("date", today_str) \ - .order("date", desc=False) + .gte("date", date_filter) + + # period별 종료일 필터 추가 + if period in ["month", "year"]: + stats_query = stats_query.lte("date", end_date.isoformat() if period == "month" else today_str) + else: + stats_query = stats_query.lte("date", today_str) + + stats_query = stats_query.order("date", desc=False) stats_result = stats_query.execute() # 데이터 맵핑 {날짜: 발전량} data_map = {row["date"]: row["total_generation"] or 0 for row in stats_result.data} - # 2. 오늘 실시간 데이터 조회 (solar_logs) - # 오늘의 가장 마지막 기록 1건만 조회 (성능 최적화) - logs_result = db.table("solar_logs") \ - .select("today_kwh, created_at") \ - .eq("plant_id", plant_id) \ - .gte("created_at", f"{today_str}T00:00:00") \ - .order("created_at", desc=True) \ - .limit(1) \ - .execute() - + # 2. 오늘 실시간 데이터 조회 (solar_logs) - 오늘이 조회 범위에 포함될 때만 today_generation = 0.0 - if logs_result.data: - today_generation = logs_result.data[0].get("today_kwh", 0.0) + if period == "day" or (period == "month" and (not year or year == today.year)) or period == "year": + # 오늘의 가장 마지막 기록 1건만 조회 (성능 최적화) + logs_result = db.table("solar_logs") \ + .select("today_kwh, created_at") \ + .eq("plant_id", plant_id) \ + .gte("created_at", f"{today_str}T00:00:00") \ + .order("created_at", desc=True) \ + .limit(1) \ + .execute() + + if logs_result.data: + today_generation = logs_result.data[0].get("today_kwh", 0.0) # 3. 데이터 병합 (오늘 데이터 갱신/추가) # solar_logs 값이 있으면 무조건 daily_stats 값보다 우선 (실시간성) @@ -105,12 +124,17 @@ async def get_plant_stats( month_key = date_str[:7] monthly[month_key] = monthly.get(month_key, 0) + val - sorted_keys = sorted(monthly.keys()) - data = [ - {"label": k, "value": round(monthly[k], 2)} - for k in sorted_keys - if k >= start_date.strftime("%Y-%m") - ] + # 특정 연도의 1~12월 데이터 생성 (누락된 월은 0) + target_year = year if year else today.year + for month in range(1, 13): + month_key = f"{target_year}-{month:02d}" + # 미래 월은 제외 + if datetime.strptime(month_key, "%Y-%m").date() > today.replace(day=1): + break + data.append({ + "label": month_key, + "value": round(monthly.get(month_key, 0), 2) + }) elif period == "year": # 연도별 집계 From 3b9403b6547d47626f9e44b50831432f536cdfef Mon Sep 17 00:00:00 2001 From: haneulai Date: Tue, 27 Jan 2026 15:40:40 +0900 Subject: [PATCH 16/27] fix: Ensure current year is included in yearly stats even if no data --- app/routers/stats.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/app/routers/stats.py b/app/routers/stats.py index 137e70e..a2d7106 100644 --- a/app/routers/stats.py +++ b/app/routers/stats.py @@ -143,12 +143,17 @@ async def get_plant_stats( year_key = date_str[:4] yearly[year_key] = yearly.get(year_key, 0) + val - sorted_keys = sorted(yearly.keys()) - data = [ - {"label": k, "value": round(yearly[k], 2)} - for k in sorted_keys - if k >= str(start_date.year) - ] + # 최근 10년 (또는 지정된 기간) 연도별 데이터 생성 (데이터 없으면 0) + data = [] + target_start_year = start_date.year + current_year = today.year + + for y in range(target_start_year, current_year + 1): + y_str = str(y) + data.append({ + "label": y_str, + "value": round(yearly.get(y_str, 0), 2) + }) return { "status": "success", From cfb807980907d95338bedebbfaa3da4d76e58f7b Mon Sep 17 00:00:00 2001 From: haneulai Date: Tue, 27 Jan 2026 16:45:59 +0900 Subject: [PATCH 17/27] fix: Increase Supabase query limit to fetch all historical stats --- app/routers/stats.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/routers/stats.py b/app/routers/stats.py index a2d7106..a09f646 100644 --- a/app/routers/stats.py +++ b/app/routers/stats.py @@ -76,8 +76,9 @@ async def get_plant_stats( stats_query = stats_query.lte("date", today_str) stats_query = stats_query.order("date", desc=False) - - stats_result = stats_query.execute() + + # Supabase 기본 limit이 1000이므로 충분히 늘려줌 (10년치 = 약 3650일) + stats_result = stats_query.limit(10000).execute() # 데이터 맵핑 {날짜: 발전량} data_map = {row["date"]: row["total_generation"] or 0 for row in stats_result.data} From c1223c1f143ea55d47167cb0ccc1541b7cb0eed2 Mon Sep 17 00:00:00 2001 From: haneulai Date: Tue, 27 Jan 2026 16:58:48 +0900 Subject: [PATCH 18/27] fix: Implement pagination to bypass Supabase 1000 row hard limit --- app/routers/stats.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/app/routers/stats.py b/app/routers/stats.py index a09f646..f801f5e 100644 --- a/app/routers/stats.py +++ b/app/routers/stats.py @@ -77,11 +77,23 @@ async def get_plant_stats( stats_query = stats_query.order("date", desc=False) - # Supabase 기본 limit이 1000이므로 충분히 늘려줌 (10년치 = 약 3650일) - stats_result = stats_query.limit(10000).execute() + # Supabase API Limit(1000) 우회를 위한 페이지네이션 + all_stats_data = [] + start = 0 + batch_size = 1000 + while True: + # range는 inclusive index (Start, End) + result = stats_query.range(start, start + batch_size - 1).execute() + batch = result.data + all_stats_data.extend(batch) + + if len(batch) < batch_size: + break + start += batch_size + # 데이터 맵핑 {날짜: 발전량} - data_map = {row["date"]: row["total_generation"] or 0 for row in stats_result.data} + data_map = {row["date"]: row["total_generation"] or 0 for row in all_stats_data} # 2. 오늘 실시간 데이터 조회 (solar_logs) - 오늘이 조회 범위에 포함될 때만 today_generation = 0.0 From 02a9149f551cb4789e32a9577ac159896122eb0b Mon Sep 17 00:00:00 2001 From: haneulai Date: Tue, 27 Jan 2026 17:12:56 +0900 Subject: [PATCH 19/27] feat: Switch to monthly_stats table for month/year stats aggregation --- app/routers/stats.py | 124 ++++++++++++++++++++++++------------------- 1 file changed, 69 insertions(+), 55 deletions(-) diff --git a/app/routers/stats.py b/app/routers/stats.py index f801f5e..86dda24 100644 --- a/app/routers/stats.py +++ b/app/routers/stats.py @@ -42,65 +42,74 @@ async def get_plant_stats( today_str = today.isoformat() # 1. 과거 데이터 조회 (daily_stats) - # 1. 과거 데이터 조회 (daily_stats) + # 1. 과거 데이터 조회 (period에 따라 테이블 분기) + stats_data_raw = [] + is_monthly_source = False + if period == "day": + # [일별 조회] daily_stats 테이블 사용 # 이번 달 1일부터 오늘까지 start_date = today.replace(day=1) date_filter = start_date.isoformat() - elif period == "month": - # year 파라미터가 있으면 해당 연도 1월~12월, 없으면 올해 - target_year = year if year else today.year - start_date = datetime(target_year, 1, 1).date() - # 종료일: 해당 연도 12월 31일 또는 오늘 중 작은 값 - end_date = min(datetime(target_year, 12, 31).date(), today) - date_filter = start_date.isoformat() - else: # year - # year 파라미터가 있으면 해당 연도부터, 없으면 최근 10년 - if year: - start_year = year - else: - start_year = today.year - 9 # 10년치 데이터 (올해 포함) - start_date = datetime(start_year, 1, 1).date() - end_date = today - date_filter = start_date.strftime("%Y-%m-%d") - - stats_query = db.table("daily_stats") \ - .select("date, total_generation") \ - .eq("plant_id", plant_id) \ - .gte("date", date_filter) - # period별 종료일 필터 추가 - if period in ["month", "year"]: - stats_query = stats_query.lte("date", end_date.isoformat() if period == "month" else today_str) + stats_query = db.table("daily_stats") \ + .select("date, total_generation") \ + .eq("plant_id", plant_id) \ + .gte("date", date_filter) \ + .lte("date", today_str) \ + .order("date", desc=False) + + # 페이지네이션 등 없이 심플하게 (일별은 데이터 적음) + stats_data_raw = stats_query.execute().data + else: - stats_query = stats_query.lte("date", today_str) + # [월별/연도별 조회] monthly_stats 테이블 사용 + is_monthly_source = True - stats_query = stats_query.order("date", desc=False) - - # Supabase API Limit(1000) 우회를 위한 페이지네이션 - all_stats_data = [] - start = 0 - batch_size = 1000 - - while True: - # range는 inclusive index (Start, End) - result = stats_query.range(start, start + batch_size - 1).execute() - batch = result.data - all_stats_data.extend(batch) - - if len(batch) < batch_size: - break - start += batch_size - - # 데이터 맵핑 {날짜: 발전량} - data_map = {row["date"]: row["total_generation"] or 0 for row in all_stats_data} + 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" # 문자열 비교라 12월도 포함됨 + else: # year + # year 파라미터가 있으면 해당 연도부터 + if year: + start_year = year + else: + start_year = today.year - 9 # 10년치 + start_month = f"{start_year}-01" + end_month = f"{today.year}-12" - # 2. 오늘 실시간 데이터 조회 (solar_logs) - 오늘이 조회 범위에 포함될 때만 + 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 + + # 데이터 맵핑 {날짜키: 발전량} + # daily_stats: key='date' (YYYY-MM-DD) + # monthly_stats: key='month' (YYYY-MM) + 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'일 때만 합산 (monthly/year는 monthly_stats가 이미 갱신되었다고 가정하거나, 필요시 로직 추가) + # 하지만 monthly_stats가 '어제까지'의 합계일 수 있으므로, '이번 달' 데이터에는 오늘 발전량을 더해주는 게 안전함. + # 그러나 로직 복잡성을 피하기 위해, 크롤러가 실시간으로 monthly_stats를 갱신한다고 가정하고 여기선 생략 가능. + # 기존 로직 유지: 'day'일 때는 무조건 덮어쓰기. + today_generation = 0.0 - if period == "day" or (period == "month" and (not year or year == today.year)) or period == "year": - # 오늘의 가장 마지막 기록 1건만 조회 (성능 최적화) + # 일별 조회 시 오늘 데이터 덮어쓰기 + if period == "day": logs_result = db.table("solar_logs") \ - .select("today_kwh, created_at") \ + .select("today_kwh") \ .eq("plant_id", plant_id) \ .gte("created_at", f"{today_str}T00:00:00") \ .order("created_at", desc=True) \ @@ -109,11 +118,13 @@ async def get_plant_stats( if logs_result.data: today_generation = logs_result.data[0].get("today_kwh", 0.0) - - # 3. 데이터 병합 (오늘 데이터 갱신/추가) - # solar_logs 값이 있으면 무조건 daily_stats 값보다 우선 (실시간성) - if today_generation > 0: - data_map[today_str] = today_generation + if today_generation > 0: + data_map[today_str] = today_generation + + # 월별/연도별 조회 시: '이번 달' 키에 오늘 발전량을 더해야 하는가? + # 마이그레이션 스크립트는 daily_stats의 합을 넣었으므로 오늘 데이터도 포함됨. + # 크롤러도 실시간으로 daily 넣으면서 monthly upsert 할 예정. + # 따라서 별도 합산 불필요. # 4. 포맷팅 및 집계 data = [] @@ -158,7 +169,10 @@ async def get_plant_stats( # 최근 10년 (또는 지정된 기간) 연도별 데이터 생성 (데이터 없으면 0) data = [] - target_start_year = start_date.year + if year: + target_start_year = year + else: + target_start_year = today.year - 9 current_year = today.year for y in range(target_start_year, current_year + 1): From d9de2e939bcf635f89801876c1bf1c83b5fbe217 Mon Sep 17 00:00:00 2001 From: haneulai Date: Tue, 27 Jan 2026 17:44:09 +0900 Subject: [PATCH 20/27] feat: Add monthly stats excel upload endpoint --- app/routers/upload.py | 132 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 132 insertions(+) diff --git a/app/routers/upload.py b/app/routers/upload.py index 5fdbb44..544d7a3 100644 --- a/app/routers/upload.py +++ b/app/routers/upload.py @@ -298,3 +298,135 @@ async def upload_plant_data( status_code=500, detail=f"DB 저장 실패: {str(e)}" ) + +@router.post("/plants/{plant_id}/upload/monthly") +async def upload_plant_monthly_data( + plant_id: str, + file: UploadFile = File(..., description="월간 발전량 엑셀 파일 (year, month, kwh)"), + db: Client = Depends(get_db) +) -> dict: + """ + 월간 발전 데이터 엑셀 업로드 + + 엑셀 컬럼 형식: + - year: 연도 (2014년 등, 병합된 셀 지원) + - month: 월 (1월, 2월, 합계, 평균 등) + - kwh: 발전량 (1,234.56) + + 기능: + - '합계', '평균' 행은 자동으로 무시 + - 'year' 컬럼이 비어있으면 이전 행의 값 사용 (ffill) + - 'month', 'kwh'의 특수문자(월, 콤마 등) 제거 및 숫자 변환 + - monthly_stats 테이블에 저장 + """ + # 1. 파일 확장자 검증 + if not file.filename.endswith(('.xlsx', '.xls')): + raise HTTPException( + status_code=400, + detail="엑셀 파일(.xlsx, .xls)만 업로드 가능합니다." + ) + + # 2. 발전소 존재 확인 + try: + plant_check = db.table("plants").select("id").eq("id", plant_id).single().execute() + if not plant_check.data: + raise HTTPException(status_code=404, detail="발전소를 찾을 수 없습니다.") + except Exception as e: + raise HTTPException(status_code=500, detail=f"발전소 확인 실패: {e}") + + # 3. 엑셀 파싱 및 전처리 + try: + contents = await file.read() + df = pd.read_excel(io.BytesIO(contents)) + + # 컬럼명 정규화 (소문자, 공백제거) + df.columns = [str(col).lower().strip() for col in df.columns] + + # 필수 컬럼 체크 + # 사용자가 제공한 엑셀은 header가 없을 수도 있고, 1행이 header일 수도 있음. + # 일단 year, month, kwh가 포함되어 있다고 가정하거나, 첫 3열을 사용해야 할 수도 있음. + # 이미지에서는 header가 명확함 (year, month, kwh). + + required = {'year', 'month', 'kwh'} + if not required.issubset(df.columns): + # 혹시 한글 헤더일 경우 매핑 시도 + rename_map = {'년도': 'year', '월': 'month', '발전량': 'kwh', '발전량(kwh)': 'kwh'} + df.rename(columns=rename_map, inplace=True) + + if not required.issubset(df.columns): + raise HTTPException( + status_code=400, + detail=f"필수 컬럼(year, month, kwh)이 누락되었습니다. 현재 컬럼: {list(df.columns)}" + ) + + # A열(year) 병합된 셀 처리 (Forward Fill) + df['year'] = df['year'].fillna(method='ffill') + + records = [] + errors = [] + + from datetime import datetime + + for idx, row in df.iterrows(): + try: + # 1. Year 파싱 + y_raw = str(row['year']).replace('년', '').strip() + if not y_raw or y_raw.lower() == 'nan': + continue + year_val = int(float(y_raw)) # 2014.0 -> 2014 + + # 2. Month 파싱 + m_raw = str(row['month']).strip() + if m_raw in ['합계', '평균', 'nan', 'None']: + continue + + # '1월' -> 1 + month_val_str = m_raw.replace('월', '').strip() + if not month_val_str.isdigit(): + continue # '합계' 등이 걸러지지 않은 경우 대비 + + month_val = int(month_val_str) + if not (1 <= month_val <= 12): + continue + + # 3. Kwh 파싱 + k_raw = str(row['kwh']).replace(',', '').strip() + if not k_raw or k_raw.lower() == 'nan': + kwh_val = 0.0 + else: + kwh_val = float(k_raw) + + # 포맷: YYYY-MM + month_key = f"{year_val}-{month_val:02d}" + + records.append({ + "plant_id": plant_id, + "month": month_key, + "total_generation": kwh_val, + "updated_at": datetime.now().isoformat() + }) + + except Exception as e: + errors.append(f"Row {idx}: {e}") + + if not records: + raise HTTPException(status_code=400, detail="유효한 데이터가 없습니다.") + + # 4. DB Upsert + # monthly_stats 테이블 생성 여부 확인이 필요하지만, 이미 되어있다고 가정 + res = db.table("monthly_stats").upsert( + records, + on_conflict="plant_id, month" + ).execute() + + return { + "status": "success", + "message": f"총 {len(records)}건의 월간 데이터가 업로드되었습니다.", + "saved_count": len(records), + "errors": errors[:5] + } + + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=f"처리 중 오류: {str(e)}") From d56a8f7ae173b2a175a2d8a2098035fe3053fa10 Mon Sep 17 00:00:00 2001 From: haneulai Date: Tue, 27 Jan 2026 17:48:17 +0900 Subject: [PATCH 21/27] refactor: Remove /upload prefix from upload router for better URL structure --- app/routers/upload.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/routers/upload.py b/app/routers/upload.py index 544d7a3..562ce4a 100644 --- a/app/routers/upload.py +++ b/app/routers/upload.py @@ -10,13 +10,13 @@ import pandas as pd from app.core.database import get_db + router = APIRouter( - prefix="/upload", tags=["Upload"] ) -@router.post("/history") +@router.post("/upload/history") async def upload_history( file: UploadFile = File(..., description="엑셀 파일 (.xlsx, .xls)"), plant_id: str = Form(..., description="발전소 ID (예: nrems-01)"), From 4a5762b938f088ce19108bf18d75c371e6357d2d Mon Sep 17 00:00:00 2001 From: haneulai Date: Tue, 27 Jan 2026 18:10:46 +0900 Subject: [PATCH 22/27] fix: Improve Excel parsing and error messages for monthly upload --- app/routers/upload.py | 80 ++++++++++++++++++++++++++++++------------- 1 file changed, 56 insertions(+), 24 deletions(-) diff --git a/app/routers/upload.py b/app/routers/upload.py index 562ce4a..8aa7f58 100644 --- a/app/routers/upload.py +++ b/app/routers/upload.py @@ -337,27 +337,45 @@ async def upload_plant_monthly_data( # 3. 엑셀 파싱 및 전처리 try: contents = await file.read() - df = pd.read_excel(io.BytesIO(contents)) + # engine='openpyxl' 명시 (xlsx) + try: + df = pd.read_excel(io.BytesIO(contents), engine='openpyxl') + except: + # xls fallback + df = pd.read_excel(io.BytesIO(contents)) - # 컬럼명 정규화 (소문자, 공백제거) + # 컬럼명 정규화 (모두 소문자, 앞뒤 공백 제거) df.columns = [str(col).lower().strip() for col in df.columns] - # 필수 컬럼 체크 - # 사용자가 제공한 엑셀은 header가 없을 수도 있고, 1행이 header일 수도 있음. - # 일단 year, month, kwh가 포함되어 있다고 가정하거나, 첫 3열을 사용해야 할 수도 있음. - # 이미지에서는 header가 명확함 (year, month, kwh). + # 필수 컬럼 검사 (별칭 지원) + # year: year, 년도, 연도 + # month: month, 월 + # kwh: kwh, generation, 발전량, 발전량(kwh) + + col_map = {} + for col in df.columns: + if col in ['year', '년도', '연도']: + col_map['year'] = col + elif col in ['month', '월']: + col_map['month'] = col + elif col in ['kwh', 'generation', '발전량', '발전량(kwh)']: + col_map['kwh'] = col + + # 매핑된 컬럼으로 이름 변경 + rename_dict = {} + if 'year' in col_map: rename_dict[col_map['year']] = 'year' + if 'month' in col_map: rename_dict[col_map['month']] = 'month' + if 'kwh' in col_map: rename_dict[col_map['kwh']] = 'kwh' + + df.rename(columns=rename_dict, inplace=True) required = {'year', 'month', 'kwh'} if not required.issubset(df.columns): - # 혹시 한글 헤더일 경우 매핑 시도 - rename_map = {'년도': 'year', '월': 'month', '발전량': 'kwh', '발전량(kwh)': 'kwh'} - df.rename(columns=rename_map, inplace=True) - - if not required.issubset(df.columns): - raise HTTPException( - status_code=400, - detail=f"필수 컬럼(year, month, kwh)이 누락되었습니다. 현재 컬럼: {list(df.columns)}" - ) + found_cols = list(df.columns) + raise HTTPException( + status_code=400, + detail=f"필수 컬럼이 누락되었습니다. (필요: year, month, kwh). 현재 인식된 컬럼: {found_cols}. 1행에 헤더가 있는지 확인해주세요." + ) # A열(year) 병합된 셀 처리 (Forward Fill) df['year'] = df['year'].fillna(method='ffill') @@ -371,21 +389,28 @@ async def upload_plant_monthly_data( try: # 1. Year 파싱 y_raw = str(row['year']).replace('년', '').strip() + # '2014.0' 같은 실수형 문자열 처리 if not y_raw or y_raw.lower() == 'nan': + continue + + try: + year_val = int(float(y_raw)) + except: continue - year_val = int(float(y_raw)) # 2014.0 -> 2014 # 2. Month 파싱 m_raw = str(row['month']).strip() - if m_raw in ['합계', '평균', 'nan', 'None']: + if m_raw in ['합계', '평균', 'nan', 'None', '', 'nan']: continue - # '1월' -> 1 + # '1월' -> 1, '01' -> 1 month_val_str = m_raw.replace('월', '').strip() - if not month_val_str.isdigit(): - continue # '합계' 등이 걸러지지 않은 경우 대비 + + # 숫자가 아닌 경우(합계 등) skip + if not month_val_str.replace('.', '').isdigit(): + continue - month_val = int(month_val_str) + month_val = int(float(month_val_str)) if not (1 <= month_val <= 12): continue @@ -394,7 +419,10 @@ async def upload_plant_monthly_data( if not k_raw or k_raw.lower() == 'nan': kwh_val = 0.0 else: - kwh_val = float(k_raw) + try: + kwh_val = float(k_raw) + except: + kwh_val = 0.0 # 포맷: YYYY-MM month_key = f"{year_val}-{month_val:02d}" @@ -407,10 +435,14 @@ async def upload_plant_monthly_data( }) except Exception as e: - errors.append(f"Row {idx}: {e}") + errors.append(f"Row {idx+2}: {e}") if not records: - raise HTTPException(status_code=400, detail="유효한 데이터가 없습니다.") + raise HTTPException( + status_code=400, + detail=f"유효한 데이터가 없습니다. 파싱 에러 예시: {errors[:3] if errors else '없음'}" + ) + # 4. DB Upsert # monthly_stats 테이블 생성 여부 확인이 필요하지만, 이미 되어있다고 가정 From 665ff4914e35b6f0295a4b550803153216c86634 Mon Sep 17 00:00:00 2001 From: haneulai Date: Wed, 28 Jan 2026 10:16:48 +0900 Subject: [PATCH 23/27] feat: enhance stats API for date navigation and support new excel upload format --- app/routers/stats.py | 169 ++++++++++++++++++++---------------------- app/routers/upload.py | 60 ++++++++++++++- 2 files changed, 136 insertions(+), 93 deletions(-) diff --git a/app/routers/stats.py b/app/routers/stats.py index 86dda24..c7f679d 100644 --- a/app/routers/stats.py +++ b/app/routers/stats.py @@ -20,46 +20,41 @@ router = APIRouter( async def get_plant_stats( plant_id: str, period: Literal["day", "month", "year"] = Query("day", description="통계 기간"), - year: int = Query(None, description="특정 연도 (월별/연도별 조회 시)"), + year: int = Query(None, description="특정 연도"), + month: int = Query(None, description="특정 월 (period='day' 시 필수)"), db: Client = Depends(get_db) ) -> dict: """ 발전소 통계 조회 (Hybrid 방식) - 1. daily_stats: 과거 데이터 조회 - 2. solar_logs: 오늘 실시간 데이터 조회 - 3. 병합: 오늘 날짜 데이터는 실시간 데이터 우선 사용 - - Args: - plant_id: 발전소 ID - period: 'day' (최근 30일), 'month' (최근 12개월 또는 특정 연도), 'year' (최근 연도들) - year: 특정 연도 (옵션, period='month' 시 해당 연도의 월별 데이터 반환) - - Returns: - 차트 라이브러리 친화적 포맷 [{"label": "...", "value": ...}, ...] """ try: today = datetime.now().date() today_str = today.isoformat() - # 1. 과거 데이터 조회 (daily_stats) # 1. 과거 데이터 조회 (period에 따라 테이블 분기) stats_data_raw = [] is_monthly_source = False if period == "day": # [일별 조회] daily_stats 테이블 사용 - # 이번 달 1일부터 오늘까지 - start_date = today.replace(day=1) - date_filter = start_date.isoformat() + # 특정 연/월의 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", date_filter) \ - .lte("date", today_str) \ + .gte("date", start_date_str) \ + .lte("date", end_date_str) \ .order("date", desc=False) - # 페이지네이션 등 없이 심플하게 (일별은 데이터 적음) stats_data_raw = stats_query.execute().data else: @@ -70,7 +65,7 @@ async def get_plant_stats( # year 파라미터가 있으면 해당 연도 1월~12월, 없으면 올해 target_year = year if year else today.year start_month = f"{target_year}-01" - end_month = f"{target_year}-12" # 문자열 비교라 12월도 포함됨 + end_month = f"{target_year}-12" else: # year # year 파라미터가 있으면 해당 연도부터 if year: @@ -90,8 +85,6 @@ async def get_plant_stats( stats_data_raw = stats_query.execute().data # 데이터 맵핑 {날짜키: 발전량} - # daily_stats: key='date' (YYYY-MM-DD) - # monthly_stats: key='month' (YYYY-MM) data_map = {} for row in stats_data_raw: key = row.get("month") if is_monthly_source else row.get("date") @@ -99,62 +92,61 @@ async def get_plant_stats( if key: data_map[key] = val - # 2. 오늘 실시간 데이터 조회 (solar_logs) - # period='day'일 때만 합산 (monthly/year는 monthly_stats가 이미 갱신되었다고 가정하거나, 필요시 로직 추가) - # 하지만 monthly_stats가 '어제까지'의 합계일 수 있으므로, '이번 달' 데이터에는 오늘 발전량을 더해주는 게 안전함. - # 그러나 로직 복잡성을 피하기 위해, 크롤러가 실시간으로 monthly_stats를 갱신한다고 가정하고 여기선 생략 가능. - # 기존 로직 유지: 'day'일 때는 무조건 덮어쓰기. - - today_generation = 0.0 - # 일별 조회 시 오늘 데이터 덮어쓰기 + # 2. 오늘 실시간 데이터 조회 (solar_logs) -> period='day'이고 '오늘'이 포함된 달이면 현재값 업데이트 + # (생략 가능하지만, 오늘 날짜가 조회 범위에 포함되면 최신값 반영) if period == "day": - logs_result = db.table("solar_logs") \ - .select("today_kwh") \ - .eq("plant_id", plant_id) \ - .gte("created_at", f"{today_str}T00:00:00") \ - .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 - - # 월별/연도별 조회 시: '이번 달' 키에 오늘 발전량을 더해야 하는가? - # 마이그레이션 스크립트는 daily_stats의 합을 넣었으므로 오늘 데이터도 포함됨. - # 크롤러도 실시간으로 daily 넣으면서 monthly upsert 할 예정. - # 따라서 별도 합산 불필요. + # 조회 중인 달이 이번 달인지 확인 + if (not year or year == today.year) and (not month or month == today.month): + logs_result = db.table("solar_logs") \ + .select("today_kwh") \ + .eq("plant_id", plant_id) \ + .gte("created_at", f"{today_str}T00:00:00") \ + .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": - # 최근 30일 일별 데이터 생성 (누락된 날짜는 0으로 채움) - current = start_date - while current <= today: - d_str = current.isoformat() + # 해당 월의 모든 날짜 생성 + 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) }) - current += timedelta(days=1) elif period == "month": # 월별 집계 monthly = {} - # daily_stats + solar_logs(오늘) 데이터로 집계 for date_str, val in data_map.items(): - month_key = date_str[:7] + # 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월 데이터 생성 (누락된 월은 0) + # 특정 연도의 1~12월 데이터 생성 target_year = year if year else today.year - for month in range(1, 13): - month_key = f"{target_year}-{month:02d}" - # 미래 월은 제외 - if datetime.strptime(month_key, "%Y-%m").date() > today.replace(day=1): - break + 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) @@ -167,8 +159,6 @@ async def get_plant_stats( year_key = date_str[:4] yearly[year_key] = yearly.get(year_key, 0) + val - # 최근 10년 (또는 지정된 기간) 연도별 데이터 생성 (데이터 없으면 0) - data = [] if year: target_start_year = year else: @@ -187,8 +177,7 @@ async def get_plant_stats( "plant_id": plant_id, "period": period, "data": data, - "count": len(data), - "today_realtime_kwh": today_generation # 디버깅용 + "count": len(data) } except HTTPException: @@ -203,57 +192,58 @@ async def get_plant_stats( @router.get("/{plant_id}/stats/today") async def get_plant_hourly_stats( plant_id: str, + date: str = Query(None, description="조회 날짜 (YYYY-MM-DD)"), db: Client = Depends(get_db) ) -> dict: """ - 오늘 시간별 발전 데이터 조회 (solar_logs 기반) - - Args: - plant_id: 발전소 ID - - Returns: - 시간별 데이터 [{"hour": 0, "current_kw": ..., "today_kwh": ...}, ...] + 특정 날짜의 시간별 발전 데이터 조회 (solar_logs 기반) + Defaults to today if date is not provided. """ try: # KST (UTC+9) 시간대 설정 kst_timezone = timezone(timedelta(hours=9)) - today_kst = datetime.now(kst_timezone).date() - today_str = today_kst.isoformat() - # 오늘의 모든 solar_logs 조회 (UTC 기준으로는 전날 15:00부터일 수 있으므로 넉넉하게 조회) - # 하지만 간단하게 '오늘' 날짜 문자열 필터링이 가장 안전 (DB가 UTC라면 보정 필요) - # 여기서는 created_at이 timestamptz라고 가정하고 단순 문자열 비교보다는 날짜 필터를 사용 + if date: + try: + target_date = datetime.strptime(date, "%Y-%m-%d").date() + except ValueError: + raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD") + else: + target_date = datetime.now(kst_timezone).date() + + target_date_str = target_date.isoformat() - # KST 00:00은 UTC로 전날 15:00 - start_dt_kst = datetime.combine(today_kst, datetime.min.time()).replace(tzinfo=kst_timezone) - start_dt_utc = start_dt_kst.astimezone(timezone.utc) + # 조회 범위: 해당 일 00:00:00 ~ 23:59:59 (KST) + from_dt = datetime.combine(target_date, datetime.min.time()).replace(tzinfo=kst_timezone) + to_dt = datetime.combine(target_date, datetime.max.time()).replace(tzinfo=kst_timezone) + + # 전조치: UTC로 변환하여 쿼리 (Supabase DB가 UTC라고 가정) + # 하지만 solar_logs는 created_at이 timestamptz로 저장되어 있을 것임. + # 안전하게는 UTC 시간으로 필터링 + from_utc = from_dt.astimezone(timezone.utc) + to_utc = to_dt.astimezone(timezone.utc) logs_result = db.table("solar_logs") \ .select("current_kw, today_kwh, created_at") \ .eq("plant_id", plant_id) \ - .gte("created_at", start_dt_utc.isoformat()) \ + .gte("created_at", from_utc.isoformat()) \ + .lte("created_at", to_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: - # ISO 형식 파싱 (DB에서 UTC로 넘어온다고 가정) dt = datetime.fromisoformat(created_at.replace('Z', '+00:00')) - - # UTC -> KST 변환 dt_kst = dt.astimezone(kst_timezone) - # 오늘 날짜인지 확인 - if dt_kst.date() != today_kst: + if dt_kst.date() != target_date: continue hour = dt_kst.hour - - # 해당 시간대의 마지막 데이터로 갱신 (order가 오름차순이므로 덮어쓰면 됨) hourly_data[hour] = { "current_kw": log.get("current_kw", 0) or 0, "today_kwh": log.get("today_kwh", 0) or 0, @@ -261,7 +251,6 @@ async def get_plant_hourly_stats( except ValueError: continue - # 0시~23시 전체 배열 생성 result = [] for hour in range(24): data = hourly_data.get(hour, {"current_kw": 0, "today_kwh": 0}) @@ -276,7 +265,7 @@ async def get_plant_hourly_stats( return { "status": "success", "plant_id": plant_id, - "date": today_str, + "date": target_date_str, "data": result, "count": len([d for d in result if d["has_data"]]) } diff --git a/app/routers/upload.py b/app/routers/upload.py index 8aa7f58..ea569c2 100644 --- a/app/routers/upload.py +++ b/app/routers/upload.py @@ -225,13 +225,67 @@ async def upload_plant_data( # 컬럼명 소문자 변환 df.columns = [str(c).lower().strip() for c in df.columns] - # 필수 컬럼 확인 - if 'date' not in df.columns or 'generation' not in df.columns: + # 컬럼 매핑 (별칭 지원) + # date: date, 일자, 날짜 + # generation: generation, kwh, 발전량, 발전량(kwh) + # year/month/day: year, month, day, 년, 월, 일, 연도 + + col_map = {} + for col in df.columns: + if col in ['date', '일자', '날짜']: + col_map['date'] = col + elif col in ['generation', 'kwh', '발전량', '발전량(kwh)']: + col_map['generation'] = col + elif col in ['year', '년', '년도', '연도']: + col_map['year'] = col + elif col in ['month', '월']: + col_map['month'] = col + elif col in ['day', '일']: + col_map['day'] = col + + # 전략 1: date, generation 컬럼이 있는 경우 + if 'date' in col_map and 'generation' in col_map: + df.rename(columns={col_map['date']: 'date', col_map['generation']: 'generation'}, inplace=True) + + # 전략 2: year, month, day, generation(kwh) 컬럼이 있는 경우 (New Strategy) + elif all(k in col_map for k in ['year', 'month', 'day', 'generation']): + rename_dict = { + col_map['year']: 'year', + col_map['month']: 'month', + col_map['day']: 'day', + col_map['generation']: 'generation' + } + df.rename(columns=rename_dict, inplace=True) + + # 병합된 셀 처리 (ffill) + df['year'] = df['year'].fillna(method='ffill') + df['month'] = df['month'].fillna(method='ffill') + + # 날짜 생성 함수 + def make_date(row): + try: + y = int(float(str(row['year']).replace('년','').strip())) + m = int(float(str(row['month']).replace('월','').strip())) + d = int(float(str(row['day']).replace('일','').strip())) + return f"{y:04d}-{m:02d}-{d:02d}" + except: + return None + + df['date'] = df.apply(make_date, axis=1) + + else: raise HTTPException( status_code=400, - detail="필수 컬럼 'date', 'generation'이 없습니다." + detail="필수 컬럼이 없습니다. (date, generation) 또는 (year, month, day, kwh) 형식이 필요합니다." ) + # date 컬럼 유효성 재확인 + if 'date' not in df.columns: + raise HTTPException( + status_code=400, + detail="날짜 정보를 파싱할 수 없습니다." + ) + if df.empty: raise HTTPException( status_code=400, From 0663458fc73619a7f8d742fd4147e7210a5b1fb8 Mon Sep 17 00:00:00 2001 From: haneulai Date: Wed, 28 Jan 2026 10:28:17 +0900 Subject: [PATCH 24/27] =?UTF-8?q?=EC=97=B0=EA=B0=84=20=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20=EA=B8=B0=EB=B3=B8=205=EA=B0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/routers/stats.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/app/routers/stats.py b/app/routers/stats.py index c7f679d..27d46e2 100644 --- a/app/routers/stats.py +++ b/app/routers/stats.py @@ -67,9 +67,9 @@ async def get_plant_stats( start_month = f"{target_year}-01" end_month = f"{target_year}-12" else: # year - # year 파라미터가 있으면 해당 연도부터 + # year 파라미터가 있으면 해당 연도 포함 최근 5년치 조회 if year: - start_year = year + start_year = year - 4 else: start_year = today.year - 9 # 10년치 start_month = f"{start_year}-01" @@ -159,13 +159,15 @@ async def get_plant_stats( year_key = date_str[:4] yearly[year_key] = yearly.get(year_key, 0) + val + # year 파라미터가 있으면 해당 연도 기준 최근 5년 표시 (예: 2026 선택 -> 2022~2026) if year: - target_start_year = year + end_year = year + start_year = year - 4 else: - target_start_year = today.year - 9 - current_year = today.year + end_year = today.year + start_year = today.year - 9 - for y in range(target_start_year, current_year + 1): + for y in range(start_year, end_year + 1): y_str = str(y) data.append({ "label": y_str, From f139b2abb43c1de95dbdcf5d5bb7c0cafe8a134b Mon Sep 17 00:00:00 2001 From: haneulai Date: Thu, 12 Feb 2026 10:38:22 +0900 Subject: [PATCH 25/27] feat: add comparison stats api (/plants/stats/comparison) --- app/routers/stats.py | 163 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 162 insertions(+), 1 deletion(-) diff --git a/app/routers/stats.py b/app/routers/stats.py index 27d46e2..7baeda8 100644 --- a/app/routers/stats.py +++ b/app/routers/stats.py @@ -5,8 +5,10 @@ from fastapi import APIRouter, HTTPException, Depends, Query from supabase import Client -from typing import List, Literal +from typing import List, Literal, Optional from datetime import datetime, timedelta, timezone +import calendar +import re from app.core.database import get_db @@ -16,6 +18,165 @@ router = APIRouter( ) +@router.get("/stats/comparison") +async 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, description="연도"), + month: Optional[int] = Query(None, description="월"), + db: Client = Depends(get_db) +) -> dict: + """ + 전체 발전소 발전량 비교 통계 조회 + """ + try: + # 1. 모든 발전소 기본 정보 조회 (이름, 용량) + plants_res = db.table("plants").select("id, name, capacity").execute() + plants = {p['id']: p for p in plants_res.data} + + # 결과 초기화 + result_data = [] + + # 날짜 파라미터 처리 + # KST 시간대 고려 + kst_timezone = timezone(timedelta(hours=9)) + now_kst = datetime.now(kst_timezone) + today = now_kst.date() + + target_date = None + if date: + try: + target_date = datetime.strptime(date, "%Y-%m-%d").date() + except ValueError: + target_date = today + else: + target_date = today + + target_year = year if year else target_date.year + target_month = month if month else target_date.month + + # 데이터 조회 로직 + stats_map = {} # plant_id -> generation + + if period == "day": + date_str = target_date.isoformat() + + # (A) 오늘 날짜인 경우: solar_logs 최신값 (실시간) + # 서버 시간대와 클라이언트 요청 날짜 일치 여부 확인 + if target_date == today: + # 오늘 00:00:00 (KST) 이후 데이터 조회 + start_dt = f"{date_str}T00:00:00" + + # solar_logs에서 오늘 생성된 데이터 조회 + logs_res = db.table("solar_logs") \ + .select("plant_id, today_kwh") \ + .gte("created_at", start_dt) \ + .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}" + + 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 + + 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": + # 연간 일평균 발전시간 (/365) + gen_hours = (gen / cap) / 365 + + 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 { + "status": "success", + "period": period, + "target_date": target_date.isoformat(), + "data": result_data, + "count": len(result_data) + } + + except Exception as e: + raise HTTPException( + status_code=500, + detail=f"전체 통계 조회 실패: {str(e)}" + ) + + @router.get("/{plant_id}/stats") async def get_plant_stats( plant_id: str, From d8a3efab065be0112603d857d5d52b1dcce15fd3 Mon Sep 17 00:00:00 2001 From: haneulai Date: Thu, 12 Feb 2026 10:50:40 +0900 Subject: [PATCH 26/27] fix: fallback to daily_stats summation for current month stats --- app/routers/stats.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/app/routers/stats.py b/app/routers/stats.py index 7baeda8..39dc3e5 100644 --- a/app/routers/stats.py +++ b/app/routers/stats.py @@ -99,6 +99,7 @@ async def get_all_plants_comparison( # 월간: 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) \ @@ -108,6 +109,33 @@ async def get_all_plants_comparison( 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 조회 (해당 연도 전체 합산) From cd684eae2200b96f62db0f0244ce54a13485ec6e Mon Sep 17 00:00:00 2001 From: haneulai Date: Tue, 14 Apr 2026 16:40:47 +0900 Subject: [PATCH 27/27] feat: Add alerts_enabled to plant schemas and endpoints --- app/routers/plants.py | 39 ++++++++++++++++++++++++++++++++++++++- app/schemas/plant.py | 5 +++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/app/routers/plants.py b/app/routers/plants.py index 02c1cd6..eb1569e 100644 --- a/app/routers/plants.py +++ b/app/routers/plants.py @@ -9,7 +9,7 @@ from supabase import Client from typing import List from app.core.database import get_db -from app.schemas.plant import PlantsListResponse, PlantWithLatestLog, SolarLogBase +from app.schemas.plant import PlantsListResponse, PlantWithLatestLog, SolarLogBase, PlantAlertUpdateRequest router = APIRouter( prefix="/plants", @@ -138,3 +138,40 @@ async def get_plant_detail( status_code=500, detail=f"데이터베이스 조회 중 오류가 발생했습니다: {str(e)}" ) + + +@router.patch("/{company_id}/{plant_id}/alerts") +async def update_plant_alerts( + company_id: int, + plant_id: str, + alert_update: PlantAlertUpdateRequest, + db: Client = Depends(get_db) +) -> dict: + """ + 특정 발전소의 알림 활성화 상태를 변경합니다. + """ + try: + response = db.table("plants") \ + .update({"alerts_enabled": alert_update.alerts_enabled}) \ + .eq("id", plant_id) \ + .eq("company_id", company_id) \ + .execute() + + if not response.data: + raise HTTPException( + status_code=404, + detail="발전소를 찾을 수 없거나 업데이트에 실패했습니다." + ) + + return { + "status": "success", + "message": "알림 설정이 변경되었습니다.", + "data": response.data[0] + } + except HTTPException: + raise + except Exception as e: + raise HTTPException( + status_code=500, + detail=f"알림 설정 변경 중 오류가 발생했습니다: {str(e)}" + ) diff --git a/app/schemas/plant.py b/app/schemas/plant.py index 43437f8..4343e75 100644 --- a/app/schemas/plant.py +++ b/app/schemas/plant.py @@ -17,8 +17,13 @@ class PlantBase(BaseModel): name: str capacity: Optional[float] = Field(None, description="발전 용량 (kW)") location: Optional[str] = Field(None, description="발전소 위치") + alerts_enabled: Optional[bool] = Field(True, description="이상 알림 활성화 여부") created_at: Optional[datetime] = None +class PlantAlertUpdateRequest(BaseModel): + alerts_enabled: bool + + class SolarLogBase(BaseModel): """발전 로그 기본 정보 스키마"""