feat: Add stats and upload APIs for plant detail screen
This commit is contained in:
parent
a891891a6e
commit
5c43b6e43b
|
|
@ -7,7 +7,7 @@ from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
from app.core.config import get_settings
|
from app.core.config import get_settings
|
||||||
from app.routers import plants, upload
|
from app.routers import plants, upload, stats
|
||||||
|
|
||||||
# 설정 로드
|
# 설정 로드
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
|
@ -33,6 +33,7 @@ app.add_middleware(
|
||||||
# 라우터 등록
|
# 라우터 등록
|
||||||
app.include_router(plants.router)
|
app.include_router(plants.router)
|
||||||
app.include_router(upload.router)
|
app.include_router(upload.router)
|
||||||
|
app.include_router(stats.router)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/", tags=["Health"])
|
@app.get("/", tags=["Health"])
|
||||||
|
|
|
||||||
119
app/routers/stats.py
Normal file
119
app/routers/stats.py
Normal file
|
|
@ -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)}"
|
||||||
|
)
|
||||||
|
|
@ -163,3 +163,138 @@ async def upload_history(
|
||||||
status_code=500,
|
status_code=500,
|
||||||
detail=f"DB 저장 실패: {str(e)}"
|
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)}"
|
||||||
|
)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user