Add 'api_server/' from commit 'cd684eae2200b96f62db0f0244ce54a13485ec6e'
git-subtree-dir: api_server git-subtree-mainline:5c3b20522bgit-subtree-split:cd684eae22
This commit is contained in:
commit
8991b92ac8
34
api_server/.gitignore
vendored
Normal file
34
api_server/.gitignore
vendored
Normal file
|
|
@ -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
|
||||||
1
api_server/app/__init__.py
Normal file
1
api_server/app/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
# Solar Power Monitoring API Server
|
||||||
1
api_server/app/core/__init__.py
Normal file
1
api_server/app/core/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
# Core configuration module
|
||||||
33
api_server/app/core/config.py
Normal file
33
api_server/app/core/config.py
Normal file
|
|
@ -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()
|
||||||
28
api_server/app/core/database.py
Normal file
28
api_server/app/core/database.py
Normal file
|
|
@ -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()
|
||||||
77
api_server/app/main.py
Normal file
77
api_server/app/main.py
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
"""
|
||||||
|
FastAPI 애플리케이션 진입점
|
||||||
|
- 태양광 발전 관제 시스템 미들웨어 서버
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
|
from app.core.config import get_settings
|
||||||
|
from app.routers import plants, upload, stats
|
||||||
|
|
||||||
|
# 설정 로드
|
||||||
|
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(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"])
|
||||||
|
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
|
||||||
|
)
|
||||||
1
api_server/app/routers/__init__.py
Normal file
1
api_server/app/routers/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
# API Routers module
|
||||||
18
api_server/app/routers/auth.py
Normal file
18
api_server/app/routers/auth.py
Normal file
|
|
@ -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
|
||||||
177
api_server/app/routers/plants.py
Normal file
177
api_server/app/routers/plants.py
Normal file
|
|
@ -0,0 +1,177 @@
|
||||||
|
"""
|
||||||
|
발전소 관련 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, PlantAlertUpdateRequest
|
||||||
|
|
||||||
|
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("created_at", 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("created_at", 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)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@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)}"
|
||||||
|
)
|
||||||
468
api_server/app/routers/stats.py
Normal file
468
api_server/app/routers/stats.py
Normal file
|
|
@ -0,0 +1,468 @@
|
||||||
|
"""
|
||||||
|
통계 조회 API
|
||||||
|
- 일별/월별/연도별 발전량 통계
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Depends, Query
|
||||||
|
from supabase import Client
|
||||||
|
from typing import List, Literal, Optional
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
import calendar
|
||||||
|
import re
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
|
||||||
|
router = APIRouter(
|
||||||
|
prefix="/plants",
|
||||||
|
tags=["Stats"]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@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}"
|
||||||
|
|
||||||
|
# 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":
|
||||||
|
# 연간 일평균 발전시간 (/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,
|
||||||
|
period: Literal["day", "month", "year"] = Query("day", description="통계 기간"),
|
||||||
|
year: int = Query(None, description="특정 연도"),
|
||||||
|
month: int = Query(None, description="특정 월 (period='day' 시 필수)"),
|
||||||
|
db: Client = Depends(get_db)
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
발전소 통계 조회 (Hybrid 방식)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
today = datetime.now().date()
|
||||||
|
today_str = today.isoformat()
|
||||||
|
|
||||||
|
# 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
|
||||||
|
else:
|
||||||
|
start_year = today.year - 9 # 10년치
|
||||||
|
start_month = f"{start_year}-01"
|
||||||
|
end_month = f"{today.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):
|
||||||
|
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":
|
||||||
|
# 해당 월의 모든 날짜 생성
|
||||||
|
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 {
|
||||||
|
"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")
|
||||||
|
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 기반)
|
||||||
|
Defaults to today if date is not provided.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# KST (UTC+9) 시간대 설정
|
||||||
|
kst_timezone = timezone(timedelta(hours=9))
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
# 조회 범위: 해당 일 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", 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:
|
||||||
|
dt = datetime.fromisoformat(created_at.replace('Z', '+00:00'))
|
||||||
|
dt_kst = dt.astimezone(kst_timezone)
|
||||||
|
|
||||||
|
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 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 {
|
||||||
|
"status": "success",
|
||||||
|
"plant_id": plant_id,
|
||||||
|
"date": target_date_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)}"
|
||||||
|
)
|
||||||
518
api_server/app/routers/upload.py
Normal file
518
api_server/app/routers/upload.py
Normal file
|
|
@ -0,0 +1,518 @@
|
||||||
|
"""
|
||||||
|
엑셀 파일 업로드 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(
|
||||||
|
tags=["Upload"]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/upload/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)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@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]
|
||||||
|
|
||||||
|
# 컬럼 매핑 (별칭 지원)
|
||||||
|
# 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) 또는 (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,
|
||||||
|
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)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
@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()
|
||||||
|
# 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]
|
||||||
|
|
||||||
|
# 필수 컬럼 검사 (별칭 지원)
|
||||||
|
# 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):
|
||||||
|
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')
|
||||||
|
|
||||||
|
records = []
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
for idx, row in df.iterrows():
|
||||||
|
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
|
||||||
|
|
||||||
|
# 2. Month 파싱
|
||||||
|
m_raw = str(row['month']).strip()
|
||||||
|
if m_raw in ['합계', '평균', 'nan', 'None', '', 'nan']:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# '1월' -> 1, '01' -> 1
|
||||||
|
month_val_str = m_raw.replace('월', '').strip()
|
||||||
|
|
||||||
|
# 숫자가 아닌 경우(합계 등) skip
|
||||||
|
if not month_val_str.replace('.', '').isdigit():
|
||||||
|
continue
|
||||||
|
|
||||||
|
month_val = int(float(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:
|
||||||
|
try:
|
||||||
|
kwh_val = float(k_raw)
|
||||||
|
except:
|
||||||
|
kwh_val = 0.0
|
||||||
|
|
||||||
|
# 포맷: 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+2}: {e}")
|
||||||
|
|
||||||
|
if not records:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"유효한 데이터가 없습니다. 파싱 에러 예시: {errors[:3] if errors else '없음'}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# 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)}")
|
||||||
2
api_server/app/schemas/__init__.py
Normal file
2
api_server/app/schemas/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
# Pydantic Schemas module
|
||||||
|
from .plant import PlantBase, PlantResponse, PlantWithLatestLog, PlantsListResponse
|
||||||
55
api_server/app/schemas/plant.py
Normal file
55
api_server/app/schemas/plant.py
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
"""
|
||||||
|
발전소 관련 Pydantic 스키마
|
||||||
|
- 요청/응답 데이터 검증
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from typing import Optional, List
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
class PlantBase(BaseModel):
|
||||||
|
"""발전소 기본 정보 스키마"""
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
id: str # DB에서 문자열 형식 사용
|
||||||
|
company_id: int # DB에서 정수형 사용
|
||||||
|
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):
|
||||||
|
"""발전 로그 기본 정보 스키마"""
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
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 컬럼명과 일치
|
||||||
|
|
||||||
|
|
||||||
|
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="총 발전소 수")
|
||||||
68
api_server/requirements.txt
Normal file
68
api_server/requirements.txt
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
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
|
||||||
Loading…
Reference in New Issue
Block a user