From d9de2e939bcf635f89801876c1bf1c83b5fbe217 Mon Sep 17 00:00:00 2001 From: haneulai Date: Tue, 27 Jan 2026 17:44:09 +0900 Subject: [PATCH] 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)}")