Add 'api_server/' from commit 'cd684eae2200b96f62db0f0244ce54a13485ec6e'

git-subtree-dir: api_server
git-subtree-mainline: 5c3b20522b
git-subtree-split: cd684eae22
This commit is contained in:
haneulai 2026-06-15 12:53:33 +09:00
commit 8991b92ac8
14 changed files with 1481 additions and 0 deletions

34
api_server/.gitignore vendored Normal file
View 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

View File

@ -0,0 +1 @@
# Solar Power Monitoring API Server

View File

@ -0,0 +1 @@
# Core configuration module

View 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()

View 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
View 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
)

View File

@ -0,0 +1 @@
# API Routers module

View 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

View 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)}"
)

View 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)}"
)

View 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)}")

View File

@ -0,0 +1,2 @@
# Pydantic Schemas module
from .plant import PlantBase, PlantResponse, PlantWithLatestLog, PlantsListResponse

View 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="총 발전소 수")

View 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