feat: harden solar monitoring through stage 7
Some checks are pending
CI / Crawler (Python ${{ matrix.python-version }}) (3.10) (push) Waiting to run
CI / Crawler (Python ${{ matrix.python-version }}) (3.11) (push) Waiting to run
CI / API (Python 3.11) (push) Waiting to run
CI / Database migration (push) Waiting to run
CI / App web build (Node 20) (push) Waiting to run

This commit is contained in:
haneulai 2026-08-07 14:07:22 +09:00
parent b834cc4218
commit a716dbef96
63 changed files with 16091 additions and 590 deletions

97
.github/workflows/ci.yml vendored Normal file
View File

@ -0,0 +1,97 @@
name: CI
on:
push:
pull_request:
permissions:
contents: read
jobs:
crawler:
name: Crawler (Python ${{ matrix.python-version }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ['3.10', '3.11']
defaults:
run:
working-directory: crawler
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
cache: pip
cache-dependency-path: crawler/requirements.txt
- run: python -m pip install --upgrade pip
- run: python -m pip install -r requirements.txt
- run: python -m unittest discover -s tests -p 'test_*.py' -v
env:
PYTHONDONTWRITEBYTECODE: '1'
PYTHONIOENCODING: utf-8
api:
name: API (Python 3.11)
runs-on: ubuntu-latest
defaults:
run:
working-directory: api_server
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.11'
cache: pip
cache-dependency-path: api_server/requirements.txt
- run: python -m pip install --upgrade pip
- run: python -m pip install -r requirements.txt
- run: python -m unittest discover -s tests -p 'test_*.py' -v
env:
DEBUG: 'false'
PYTHONDONTWRITEBYTECODE: '1'
PYTHONIOENCODING: utf-8
SUPABASE_KEY: test-key
SUPABASE_URL: http://localhost:54321
database:
name: Database migration
runs-on: ubuntu-latest
services:
postgres:
image: postgres:15
env:
POSTGRES_PASSWORD: postgres
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U postgres"
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- name: Run migration contract test
working-directory: supabase/tests
env:
PGPASSWORD: postgres
run: psql -h localhost -U postgres -d postgres -f stats_write_consistency_test.sql
app:
name: App web build (Node 20)
runs-on: ubuntu-latest
defaults:
run:
working-directory: app
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: npm
cache-dependency-path: app/package-lock.json
- run: npm ci
- run: npm run test:ci
env:
CI: '1'

2
.gitignore vendored
View File

@ -12,6 +12,7 @@ __pycache__/
.pytest_cache/
.venv/
venv/
venv_win/
env/
# Database & Credentials
@ -41,4 +42,3 @@ scratch/
# Supabase CLI binaries
supabase.exe
supabase-go.exe

View File

@ -0,0 +1,34 @@
"""Shared database write helpers for derived generation statistics."""
from supabase import Client
def upsert_daily_stats(
db: Client,
records: list[dict],
*,
source: str,
allow_decrease: bool = False,
) -> list[dict]:
"""Apply the database-owned daily conflict and monthly sync policy."""
if not records:
return []
payload = [
{
"plant_id": record["plant_id"],
"date": record["date"],
"total_generation": float(record["total_generation"]),
"peak_kw": float(record.get("peak_kw") or 0),
}
for record in records
]
response = db.rpc(
"upsert_daily_stats",
{
"p_records": payload,
"p_source": source,
"p_allow_decrease": allow_decrease,
},
).execute()
return response.data or []

View File

@ -0,0 +1,32 @@
"""API에서 사용하는 KST 기준 시간 및 UTC 조회 경계."""
from datetime import date, datetime, time, timedelta, timezone
from typing import Tuple
KST = timezone(timedelta(hours=9), name="KST")
UTC = timezone.utc
def now_kst() -> datetime:
"""서버 OS 시간대와 무관한 현재 KST 시각을 반환한다."""
return datetime.now(KST)
def today_kst() -> date:
return now_kst().date()
def kst_day_bounds_utc(target_date: date) -> Tuple[datetime, datetime]:
"""KST 하루의 UTC 반개구간 [start, next_start)을 반환한다."""
start_kst = datetime.combine(target_date, time.min, tzinfo=KST)
next_start_kst = start_kst + timedelta(days=1)
return start_kst.astimezone(UTC), next_start_kst.astimezone(UTC)
def parse_db_timestamp_kst(value: str) -> datetime:
"""Supabase timestamptz 문자열을 KST aware datetime으로 변환한다."""
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=UTC)
return parsed.astimezone(KST)

View File

@ -3,11 +3,14 @@ FastAPI 애플리케이션 진입점
- 태양광 발전 관제 시스템 미들웨어 서버
"""
from fastapi import FastAPI
from fastapi import Depends, FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from supabase import Client
from app.core.config import get_settings
from app.core.database import get_db
from app.routers import plants, upload, stats
from app.schemas.health import DetailedHealthResponse, HealthResponse
# 설정 로드
settings = get_settings()
@ -36,35 +39,45 @@ 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:
@app.get("/", tags=["Health"], response_model=HealthResponse)
def health_check() -> HealthResponse:
"""
서버 상태 확인 (Health Check)
Returns:
서버 상태 버전 정보
"""
return {
"status": "healthy",
"app_name": settings.APP_NAME,
"version": settings.APP_VERSION
}
return HealthResponse(
status="healthy",
app_name=settings.APP_NAME,
version=settings.APP_VERSION,
)
@app.get("/health", tags=["Health"])
async def detailed_health_check() -> dict:
@app.get("/health", tags=["Health"], response_model=DetailedHealthResponse)
def detailed_health_check(
db: Client = Depends(get_db),
) -> DetailedHealthResponse:
"""
상세 서버 상태 확인
Returns:
서버 상태 연결 정보
"""
return {
"status": "healthy",
"app_name": settings.APP_NAME,
"version": settings.APP_VERSION,
"supabase_connected": bool(settings.SUPABASE_URL)
}
try:
db.table("plants").select("id").limit(1).execute()
except Exception as exc:
raise HTTPException(
status_code=503,
detail="데이터베이스 연결 상태를 확인할 수 없습니다.",
) from exc
return DetailedHealthResponse(
status="healthy",
app_name=settings.APP_NAME,
version=settings.APP_VERSION,
supabase_connected=True,
)
if __name__ == "__main__":

View File

@ -9,7 +9,14 @@ from supabase import Client
from typing import List
from app.core.database import get_db
from app.schemas.plant import PlantsListResponse, PlantWithLatestLog, SolarLogBase, PlantAlertUpdateRequest
from app.schemas.plant import (
PlantAlertUpdateRequest,
PlantAlertUpdateResponse,
PlantDetailResponse,
PlantsListResponse,
PlantWithLatestLog,
SolarLogBase,
)
router = APIRouter(
prefix="/plants",
@ -18,7 +25,7 @@ router = APIRouter(
@router.get("/{company_id}", response_model=PlantsListResponse)
async def get_plants_by_company(
def get_plants_by_company(
company_id: int,
db: Client = Depends(get_db)
) -> PlantsListResponse:
@ -32,10 +39,16 @@ async def get_plants_by_company(
발전소 목록 발전소의 최신 발전 로그
"""
try:
# 1. 해당 업체의 모든 발전소 조회
# 발전소와 각 발전소의 최신 로그 1건을 관계 중첩 조회로 한 번에 가져온다.
plants_response = db.table("plants") \
.select("*") \
.select(
"*,solar_logs("
"id,plant_id,current_kw,today_kwh,status,created_at"
")"
) \
.eq("company_id", company_id) \
.order("created_at", desc=True, foreign_table="solar_logs") \
.limit(1, foreign_table="solar_logs") \
.execute()
plants = plants_response.data
@ -47,21 +60,14 @@ async def get_plants_by_company(
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()
for plant_row in plants:
plant = dict(plant_row)
nested_logs = plant.pop("solar_logs", None) or []
latest_log = None
if log_response.data:
latest_log = SolarLogBase(**log_response.data[0])
if nested_logs:
latest_log = SolarLogBase(**nested_logs[0])
plant_with_log = PlantWithLatestLog(
**plant,
@ -82,10 +88,10 @@ async def get_plants_by_company(
)
@router.get("/{company_id}/{plant_id}", response_model=dict)
async def get_plant_detail(
@router.get("/{company_id}/{plant_id}", response_model=PlantDetailResponse)
def get_plant_detail(
company_id: int,
plant_id: int,
plant_id: str,
db: Client = Depends(get_db)
) -> dict:
"""
@ -104,7 +110,7 @@ async def get_plant_detail(
.select("*") \
.eq("id", plant_id) \
.eq("company_id", company_id) \
.single() \
.limit(1) \
.execute()
if not plant_response.data:
@ -113,7 +119,7 @@ async def get_plant_detail(
detail="발전소를 찾을 수 없습니다."
)
plant = plant_response.data
plant = plant_response.data[0]
# 최근 발전 로그 10건 조회
logs_response = db.table("solar_logs") \
@ -140,13 +146,16 @@ async def get_plant_detail(
)
@router.patch("/{company_id}/{plant_id}/alerts")
async def update_plant_alerts(
@router.patch(
"/{company_id}/{plant_id}/alerts",
response_model=PlantAlertUpdateResponse,
)
def update_plant_alerts(
company_id: int,
plant_id: str,
alert_update: PlantAlertUpdateRequest,
db: Client = Depends(get_db)
) -> dict:
) -> PlantAlertUpdateResponse:
"""
특정 발전소의 알림 활성화 상태를 변경합니다.
"""
@ -163,11 +172,11 @@ async def update_plant_alerts(
detail="발전소를 찾을 수 없거나 업데이트에 실패했습니다."
)
return {
"status": "success",
"message": "알림 설정이 변경되었습니다.",
"data": response.data[0]
}
return PlantAlertUpdateResponse(
status="success",
message="알림 설정이 변경되었습니다.",
data=response.data[0],
)
except HTTPException:
raise
except Exception as e:

View File

@ -5,31 +5,76 @@
from fastapi import APIRouter, HTTPException, Depends, Query
from supabase import Client
from typing import List, Literal, Optional
from datetime import datetime, timedelta, timezone
from typing import Literal, Optional
from datetime import date as date_type
import calendar
import re
from app.core.database import get_db
from app.core.time_utils import (
kst_day_bounds_utc,
parse_db_timestamp_kst,
today_kst,
)
from app.schemas.stats import (
ComparisonStatsResponse,
HourlyStatsResponse,
PlantStatsResponse,
)
router = APIRouter(
prefix="/plants",
tags=["Stats"]
)
MIN_STATS_YEAR = 2000
MAX_STATS_YEAR = 2100
@router.get("/stats/comparison")
async def get_all_plants_comparison(
def parse_iso_date(value: str) -> date_type:
"""YYYY-MM-DD만 허용하고 잘못된 날짜를 명확한 400으로 반환한다."""
if not re.fullmatch(r"\d{4}-\d{2}-\d{2}", value):
raise HTTPException(
status_code=400,
detail="날짜 형식은 YYYY-MM-DD여야 합니다.",
)
try:
return date_type.fromisoformat(value)
except ValueError as exc:
raise HTTPException(
status_code=400,
detail="유효하지 않은 날짜입니다.",
) from exc
def ensure_plant_exists(db: Client, plant_id: str) -> None:
response = db.table("plants") \
.select("id") \
.eq("id", plant_id) \
.limit(1) \
.execute()
if not response.data:
raise HTTPException(status_code=404, detail="발전소를 찾을 수 없습니다.")
@router.get("/stats/comparison", response_model=ComparisonStatsResponse)
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=""),
year: Optional[int] = Query(None, ge=MIN_STATS_YEAR, le=MAX_STATS_YEAR, description="연도"),
month: Optional[int] = Query(None, ge=1, le=12, description=""),
db: Client = Depends(get_db)
) -> dict:
) -> ComparisonStatsResponse:
"""
전체 발전소 발전량 비교 통계 조회
"""
try:
# 날짜 파라미터 처리 (서버 OS 시간대와 무관하게 KST 기준)
today = today_kst()
target_date = parse_iso_date(date) if date else today
target_year = year if year is not None else target_date.year
target_month = month if month is not None else target_date.month
# 1. 모든 발전소 기본 정보 조회 (이름, 용량)
plants_res = db.table("plants").select("id, name, capacity").execute()
plants = {p['id']: p for p in plants_res.data}
@ -37,24 +82,6 @@ async def get_all_plants_comparison(
# 결과 초기화
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
@ -64,13 +91,13 @@ async def get_all_plants_comparison(
# (A) 오늘 날짜인 경우: solar_logs 최신값 (실시간)
# 서버 시간대와 클라이언트 요청 날짜 일치 여부 확인
if target_date == today:
# 오늘 00:00:00 (KST) 이후 데이터 조회
start_dt = f"{date_str}T00:00:00"
start_utc, next_start_utc = kst_day_bounds_utc(target_date)
# solar_logs에서 오늘 생성된 데이터 조회
logs_res = db.table("solar_logs") \
.select("plant_id, today_kwh") \
.gte("created_at", start_dt) \
.gte("created_at", start_utc.isoformat()) \
.lt("created_at", next_start_utc.isoformat()) \
.order("created_at", desc=True) \
.execute()
@ -170,8 +197,9 @@ async def get_all_plants_comparison(
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
# 윤년을 포함한 연간 일평균 발전시간
days_in_year = 366 if calendar.isleap(target_year) else 365
gen_hours = (gen / cap) / days_in_year
result_data.append({
"plant_id": pid,
@ -190,14 +218,16 @@ async def get_all_plants_comparison(
result_data.sort(key=sort_key)
return {
return ComparisonStatsResponse(**{
"status": "success",
"period": period,
"target_date": target_date.isoformat(),
"data": result_data,
"count": len(result_data)
}
})
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=500,
@ -205,20 +235,21 @@ async def get_all_plants_comparison(
)
@router.get("/{plant_id}/stats")
async def get_plant_stats(
@router.get("/{plant_id}/stats", response_model=PlantStatsResponse)
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' 시 필수)"),
year: Optional[int] = Query(None, ge=MIN_STATS_YEAR, le=MAX_STATS_YEAR, description="특정 연도"),
month: Optional[int] = Query(None, ge=1, le=12, description="특정 월"),
db: Client = Depends(get_db)
) -> dict:
) -> PlantStatsResponse:
"""
발전소 통계 조회 (Hybrid 방식)
"""
try:
today = datetime.now().date()
today = today_kst()
today_str = today.isoformat()
ensure_plant_exists(db, plant_id)
# 1. 과거 데이터 조회 (period에 따라 테이블 분기)
stats_data_raw = []
@ -259,10 +290,12 @@ async def get_plant_stats(
# year 파라미터가 있으면 해당 연도 포함 최근 5년치 조회
if year:
start_year = year - 4
end_year = year
else:
start_year = today.year - 9 # 10년치
end_year = today.year
start_month = f"{start_year}-01"
end_month = f"{today.year}-12"
end_month = f"{end_year}-12"
stats_query = db.table("monthly_stats") \
.select("month, total_generation") \
@ -286,10 +319,12 @@ async def get_plant_stats(
if period == "day":
# 조회 중인 달이 이번 달인지 확인
if (not year or year == today.year) and (not month or month == today.month):
start_utc, next_start_utc = kst_day_bounds_utc(today)
logs_result = db.table("solar_logs") \
.select("today_kwh") \
.eq("plant_id", plant_id) \
.gte("created_at", f"{today_str}T00:00:00") \
.gte("created_at", start_utc.isoformat()) \
.lt("created_at", next_start_utc.isoformat()) \
.order("created_at", desc=True) \
.limit(1) \
.execute()
@ -363,13 +398,13 @@ async def get_plant_stats(
"value": round(yearly.get(y_str, 0), 2)
})
return {
return PlantStatsResponse(**{
"status": "success",
"plant_id": plant_id,
"period": period,
"data": data,
"count": len(data)
}
})
except HTTPException:
raise
@ -380,45 +415,33 @@ async def get_plant_stats(
)
@router.get("/{plant_id}/stats/today")
async def get_plant_hourly_stats(
@router.get("/{plant_id}/stats/today", response_model=HourlyStatsResponse)
def get_plant_hourly_stats(
plant_id: str,
date: str = Query(None, description="조회 날짜 (YYYY-MM-DD)"),
db: Client = Depends(get_db)
) -> dict:
) -> HourlyStatsResponse:
"""
특정 날짜의 시간별 발전 데이터 조회 (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")
target_date = parse_iso_date(date)
else:
target_date = datetime.now(kst_timezone).date()
target_date = today_kst()
ensure_plant_exists(db, plant_id)
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)
# 조회 범위: KST 하루를 UTC 반개구간으로 변환한다.
from_utc, next_start_utc = kst_day_bounds_utc(target_date)
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()) \
.lt("created_at", next_start_utc.isoformat()) \
.order("created_at", desc=False) \
.execute()
@ -428,8 +451,7 @@ async def get_plant_hourly_stats(
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)
dt_kst = parse_db_timestamp_kst(created_at)
if dt_kst.date() != target_date:
continue
@ -439,7 +461,7 @@ async def get_plant_hourly_stats(
"current_kw": log.get("current_kw", 0) or 0,
"today_kwh": log.get("today_kwh", 0) or 0,
}
except ValueError:
except (TypeError, ValueError):
continue
result = []
@ -453,14 +475,16 @@ async def get_plant_hourly_stats(
"has_data": hour in hourly_data
})
return {
return HourlyStatsResponse(**{
"status": "success",
"plant_id": plant_id,
"date": target_date_str,
"data": result,
"count": len([d for d in result if d["has_data"]])
}
})
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=500,

View File

@ -3,20 +3,63 @@
- 과거 발전 데이터(Excel) 업로드하여 daily_stats 테이블에 저장
"""
import calendar
import io
from fastapi import APIRouter, UploadFile, File, Form, HTTPException, Depends
from fastapi.concurrency import run_in_threadpool
from supabase import Client
import pandas as pd
from app.core.database import get_db
from app.core.stats_storage import upsert_daily_stats
from app.core.time_utils import now_kst
from app.schemas.upload import UploadResponse
router = APIRouter(
tags=["Upload"]
)
ALLOWED_EXCEL_EXTENSIONS = ('.xlsx', '.xls')
MAX_UPLOAD_BYTES = 5 * 1024 * 1024
MAX_UPLOAD_ROWS = 5000
@router.post("/upload/history")
async def read_excel_upload(file: UploadFile) -> pd.DataFrame:
"""업로드 크기와 형식을 검증한 뒤 Excel DataFrame을 반환한다."""
filename = (file.filename or '').strip()
if not filename.lower().endswith(ALLOWED_EXCEL_EXTENSIONS):
raise HTTPException(
status_code=400,
detail="엑셀 파일(.xlsx, .xls)만 업로드 가능합니다."
)
contents = await file.read(MAX_UPLOAD_BYTES + 1)
if not contents:
raise HTTPException(status_code=400, detail="업로드 파일이 비어있습니다.")
if len(contents) > MAX_UPLOAD_BYTES:
raise HTTPException(
status_code=413,
detail=f"파일 크기는 {MAX_UPLOAD_BYTES // (1024 * 1024)}MB 이하여야 합니다."
)
try:
df = await run_in_threadpool(pd.read_excel, io.BytesIO(contents))
except Exception as e:
raise HTTPException(status_code=400, detail=f"엑셀 파일 읽기 실패: {str(e)}") from e
if df.empty:
raise HTTPException(status_code=400, detail="엑셀 파일에 데이터가 없습니다.")
if len(df.index) > MAX_UPLOAD_ROWS:
raise HTTPException(
status_code=400,
detail=f"한 번에 최대 {MAX_UPLOAD_ROWS}행까지 업로드할 수 있습니다."
)
return df
@router.post("/upload/history", response_model=UploadResponse)
async def upload_history(
file: UploadFile = File(..., description="엑셀 파일 (.xlsx, .xls)"),
plant_id: str = Form(..., description="발전소 ID (예: nrems-01)"),
@ -36,20 +79,15 @@ async def upload_history(
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() \
plant_response = await run_in_threadpool(
lambda: db.table("plants")
.select("id, capacity")
.eq("id", plant_id)
.limit(1)
.execute()
)
if not plant_response.data:
raise HTTPException(
@ -57,7 +95,7 @@ async def upload_history(
detail=f"발전소 '{plant_id}'를 찾을 수 없습니다."
)
capacity = plant_response.data.get('capacity', 99.0)
capacity = plant_response.data[0].get('capacity', 99.0)
if capacity <= 0:
capacity = 99.0 # 기본값
@ -71,8 +109,7 @@ async def upload_history(
# 3. 엑셀 파일 읽기
try:
contents = await file.read()
df = pd.read_excel(io.BytesIO(contents))
df = await read_excel_upload(file)
# 컬럼 확인
required_columns = ['date', 'generation']
@ -83,13 +120,6 @@ async def upload_history(
detail=f"필수 컬럼이 없습니다: {missing_columns}. 엑셀에는 'date', 'generation' 컬럼이 필요합니다."
)
# 빈 데이터 체크
if df.empty:
raise HTTPException(
status_code=400,
detail="엑셀 파일에 데이터가 없습니다."
)
except HTTPException:
raise
except Exception as e:
@ -110,13 +140,14 @@ async def upload_history(
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')
date_str = pd.to_datetime(date_val, errors='raise').strftime('%Y-%m-%d')
# 발전량
generation = float(row['generation']) if pd.notna(row['generation']) else 0.0
if pd.isna(row['generation']):
raise ValueError("발전량이 비어있습니다.")
generation = float(row['generation'])
if generation < 0:
raise ValueError("발전량은 0 이상이어야 합니다.")
# generation_hours 계산
generation_hours = round(generation / capacity, 2) if capacity > 0 else 0.0
@ -141,10 +172,14 @@ async def upload_history(
# 5. DB Upsert
try:
result = db.table("daily_stats").upsert(
await run_in_threadpool(
lambda: upsert_daily_stats(
db,
records,
on_conflict="plant_id,date"
).execute()
source="excel_daily",
allow_decrease=True,
)
)
response_msg = f"{len(records)}건의 데이터가 저장되었습니다."
if errors:
@ -165,7 +200,7 @@ async def upload_history(
)
@router.post("/plants/{plant_id}/upload")
@router.post("/plants/{plant_id}/upload", response_model=UploadResponse)
async def upload_plant_data(
plant_id: str,
file: UploadFile = File(..., description="엑셀 파일 (.xlsx, .xls)"),
@ -185,20 +220,15 @@ async def upload_plant_data(
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() \
plant_response = await run_in_threadpool(
lambda: db.table("plants")
.select("id, capacity, name")
.eq("id", plant_id)
.limit(1)
.execute()
)
if not plant_response.data:
raise HTTPException(
@ -206,8 +236,8 @@ async def upload_plant_data(
detail=f"발전소 '{plant_id}'를 찾을 수 없습니다."
)
capacity = plant_response.data.get('capacity', 99.0) or 99.0
plant_name = plant_response.data.get('name', plant_id)
capacity = plant_response.data[0].get('capacity', 99.0) or 99.0
plant_name = plant_response.data[0].get('name', plant_id)
except HTTPException:
raise
@ -219,8 +249,7 @@ async def upload_plant_data(
# 3. 엑셀 파일 읽기
try:
contents = await file.read()
df = pd.read_excel(io.BytesIO(contents))
df = await read_excel_upload(file)
# 컬럼명 소문자 변환
df.columns = [str(c).lower().strip() for c in df.columns]
@ -258,8 +287,8 @@ async def upload_plant_data(
df.rename(columns=rename_dict, inplace=True)
# 병합된 셀 처리 (ffill)
df['year'] = df['year'].fillna(method='ffill')
df['month'] = df['month'].fillna(method='ffill')
df['year'] = df['year'].ffill()
df['month'] = df['month'].ffill()
# 날짜 생성 함수
def make_date(row):
@ -268,7 +297,7 @@ async def upload_plant_data(
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:
except (TypeError, ValueError):
return None
df['date'] = df.apply(make_date, axis=1)
@ -286,12 +315,6 @@ async def upload_plant_data(
detail="날짜 정보를 파싱할 수 없습니다."
)
if df.empty:
raise HTTPException(
status_code=400,
detail="엑셀 파일에 데이터가 없습니다."
)
except HTTPException:
raise
except Exception as e:
@ -311,7 +334,11 @@ async def upload_plant_data(
continue
date_str = pd.to_datetime(date_val).strftime('%Y-%m-%d')
generation = float(row['generation']) if pd.notna(row['generation']) else 0.0
if pd.isna(row['generation']):
raise ValueError("발전량이 비어있습니다.")
generation = float(row['generation'])
if generation < 0:
raise ValueError("발전량은 0 이상이어야 합니다.")
generation_hours = round(generation / capacity, 2) if capacity > 0 else 0.0
records.append({
@ -333,10 +360,14 @@ async def upload_plant_data(
# 5. DB Upsert
try:
db.table("daily_stats").upsert(
await run_in_threadpool(
lambda: upsert_daily_stats(
db,
records,
on_conflict="plant_id,date"
).execute()
source="excel_daily",
allow_decrease=True,
)
)
return {
"status": "success",
@ -353,7 +384,7 @@ async def upload_plant_data(
detail=f"DB 저장 실패: {str(e)}"
)
@router.post("/plants/{plant_id}/upload/monthly")
@router.post("/plants/{plant_id}/upload/monthly", response_model=UploadResponse)
async def upload_plant_monthly_data(
plant_id: str,
file: UploadFile = File(..., description="월간 발전량 엑셀 파일 (year, month, kwh)"),
@ -373,30 +404,25 @@ async def upload_plant_monthly_data(
- '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()
plant_check = await run_in_threadpool(
lambda: db.table("plants")
.select("id")
.eq("id", plant_id)
.limit(1)
.execute()
)
if not plant_check.data:
raise HTTPException(status_code=404, detail="발전소를 찾을 수 없습니다.")
except HTTPException:
raise
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 = await read_excel_upload(file)
# 컬럼명 정규화 (모두 소문자, 앞뒤 공백 제거)
df.columns = [str(col).lower().strip() for col in df.columns]
@ -432,13 +458,11 @@ async def upload_plant_monthly_data(
)
# A열(year) 병합된 셀 처리 (Forward Fill)
df['year'] = df['year'].fillna(method='ffill')
df['year'] = df['year'].ffill()
records = []
errors = []
from datetime import datetime
for idx, row in df.iterrows():
try:
# 1. Year 파싱
@ -449,7 +473,7 @@ async def upload_plant_monthly_data(
try:
year_val = int(float(y_raw))
except:
except (TypeError, ValueError):
continue
# 2. Month 파싱
@ -471,12 +495,17 @@ async def upload_plant_monthly_data(
# 3. Kwh 파싱
k_raw = str(row['kwh']).replace(',', '').strip()
if not k_raw or k_raw.lower() == 'nan':
kwh_val = 0.0
errors.append(f"Row {idx+2}: 발전량이 비어있습니다.")
continue
else:
try:
kwh_val = float(k_raw)
except:
kwh_val = 0.0
except (TypeError, ValueError):
errors.append(f"Row {idx+2}: 발전량 형식이 올바르지 않습니다.")
continue
if kwh_val < 0:
errors.append(f"Row {idx+2}: 발전량은 0 이상이어야 합니다.")
continue
# 포맷: YYYY-MM
month_key = f"{year_val}-{month_val:02d}"
@ -485,7 +514,9 @@ async def upload_plant_monthly_data(
"plant_id": plant_id,
"month": month_key,
"total_generation": kwh_val,
"updated_at": datetime.now().isoformat()
"last_date": f"{month_key}-{calendar.monthrange(year_val, month_val)[1]:02d}",
"updated_at": now_kst().isoformat(),
"source": "excel_monthly",
})
except Exception as e:
@ -500,10 +531,12 @@ async def upload_plant_monthly_data(
# 4. DB Upsert
# monthly_stats 테이블 생성 여부 확인이 필요하지만, 이미 되어있다고 가정
res = db.table("monthly_stats").upsert(
await run_in_threadpool(
lambda: db.table("monthly_stats").upsert(
records,
on_conflict="plant_id, month"
).execute()
)
return {
"status": "success",

View File

@ -1,2 +1,13 @@
# Pydantic Schemas module
from .plant import PlantBase, PlantResponse, PlantWithLatestLog, PlantsListResponse
from .health import DetailedHealthResponse, HealthResponse
from .plant import (
PlantAlertUpdateRequest,
PlantAlertUpdateResponse,
PlantBase,
PlantDetailResponse,
PlantResponse,
PlantsListResponse,
PlantWithLatestLog,
)
from .stats import ComparisonStatsResponse, HourlyStatsResponse, PlantStatsResponse
from .upload import UploadResponse

View File

@ -0,0 +1,15 @@
"""서비스 상태 확인 응답 스키마."""
from typing import Literal
from pydantic import BaseModel
class HealthResponse(BaseModel):
status: Literal["healthy"]
app_name: str
version: str
class DetailedHealthResponse(HealthResponse):
supabase_connected: bool

View File

@ -48,6 +48,22 @@ class PlantResponse(BaseModel):
data: PlantWithLatestLog
class PlantDetailData(BaseModel):
plant: PlantBase
recent_logs: List[SolarLogBase]
class PlantDetailResponse(BaseModel):
status: str = "success"
data: PlantDetailData
class PlantAlertUpdateResponse(BaseModel):
status: str = "success"
message: str
data: PlantBase
class PlantsListResponse(BaseModel):
"""발전소 목록 응답 스키마"""
status: str = "success"

View File

@ -0,0 +1,50 @@
"""통계 API 응답 스키마."""
from typing import List, Literal
from pydantic import BaseModel
class ComparisonStatItem(BaseModel):
plant_id: str
plant_name: str
capacity: float
generation: float
generation_hours: float
class ComparisonStatsResponse(BaseModel):
status: Literal["success"]
period: Literal["day", "month", "year"]
target_date: str
data: List[ComparisonStatItem]
count: int
class StatPoint(BaseModel):
label: str
value: float
class PlantStatsResponse(BaseModel):
status: Literal["success"]
plant_id: str
period: Literal["day", "month", "year"]
data: List[StatPoint]
count: int
class HourlyStatPoint(BaseModel):
hour: int
label: str
current_kw: float
today_kwh: float
has_data: bool
class HourlyStatsResponse(BaseModel):
status: Literal["success"]
plant_id: str
date: str
data: List[HourlyStatPoint]
count: int

View File

@ -0,0 +1,15 @@
"""Excel 업로드 성공 응답 스키마."""
from typing import List, Literal, Optional
from pydantic import BaseModel, Field
class UploadResponse(BaseModel):
status: Literal["success"]
message: str
saved_count: int
error_count: int = 0
errors: List[str] = Field(default_factory=list)
plant_id: Optional[str] = None
plant_name: Optional[str] = None

View File

@ -0,0 +1,8 @@
# Direct API dependencies. api_server/requirements.txt is the deployed lock.
fastapi==0.128.0
openpyxl==3.1.5
pandas==2.3.3
pydantic-settings==2.12.0
python-multipart==0.0.21
supabase==2.27.2
uvicorn[standard]==0.40.0

View File

@ -62,7 +62,7 @@ typing_extensions==4.15.0
tzdata==2025.3
urllib3==2.6.3
uvicorn==0.40.0
uvloop==0.22.1
uvloop==0.22.1; sys_platform != "win32"
watchfiles==1.1.1
websockets==15.0.1
yarl==1.22.0

View File

@ -0,0 +1,100 @@
import unittest
from fastapi.testclient import TestClient
from app.core.database import get_db
from app.main import app
from tests.test_plants import FakeDb
class FailingDb:
def table(self, _table_name):
raise RuntimeError("database unavailable")
class ApiContractTest(unittest.TestCase):
def setUp(self):
self.db = FakeDb()
app.dependency_overrides[get_db] = lambda: self.db
self.client = TestClient(app, raise_server_exceptions=False)
def tearDown(self):
app.dependency_overrides.clear()
def test_health_checks_real_database_query(self):
response = self.client.get("/health")
self.assertEqual(200, response.status_code)
self.assertTrue(response.json()["supabase_connected"])
self.assertEqual(1, len(self.db.queries))
self.assertEqual("plants", self.db.queries[0].table_name)
def test_health_returns_503_when_database_query_fails(self):
app.dependency_overrides[get_db] = lambda: FailingDb()
response = self.client.get("/health")
self.assertEqual(503, response.status_code)
self.assertEqual(
"데이터베이스 연결 상태를 확인할 수 없습니다.",
response.json()["detail"],
)
def test_string_plant_detail_path_returns_200(self):
response = self.client.get("/plants/1/nrems-03")
self.assertEqual(200, response.status_code)
self.assertEqual("nrems-03", response.json()["data"]["plant"]["id"])
def test_invalid_comparison_date_returns_400(self):
response = self.client.get(
"/plants/stats/comparison?period=day&date=2026-8-07"
)
self.assertEqual(400, response.status_code)
def test_invalid_month_returns_422(self):
response = self.client.get(
"/plants/nrems-03/stats?period=day&year=2026&month=13"
)
self.assertEqual(422, response.status_code)
def test_unknown_plant_stats_returns_404(self):
app.dependency_overrides[get_db] = lambda: FakeDb(plant_exists=False)
response = self.client.get(
"/plants/missing/stats?period=day&year=2026&month=8"
)
self.assertEqual(404, response.status_code)
def test_openapi_declares_string_plant_id_and_response_models(self):
schema = self.client.get("/openapi.json").json()
detail_operation = schema["paths"]["/plants/{company_id}/{plant_id}"]["get"]
plant_id_param = next(
item for item in detail_operation["parameters"]
if item["name"] == "plant_id"
)
self.assertEqual("string", plant_id_param["schema"]["type"])
self.assertIn("200", detail_operation["responses"])
self.assertIn(
"PlantDetailResponse",
str(detail_operation["responses"]["200"]),
)
self.assertIn(
"DetailedHealthResponse",
str(schema["paths"]["/health"]["get"]["responses"]["200"]),
)
self.assertIn(
"UploadResponse",
str(
schema["paths"]["/plants/{plant_id}/upload"]
["post"]["responses"]["200"]
),
)
if __name__ == "__main__":
unittest.main()

View File

@ -0,0 +1,153 @@
import unittest
from types import SimpleNamespace
from fastapi import HTTPException
from app.routers import plants
PLANT = {
"id": "nrems-03",
"company_id": 1,
"name": "3호기",
"capacity": 99.0,
"location": None,
"alerts_enabled": True,
"created_at": "2026-01-01T00:00:00+00:00",
}
LOG = {
"id": 10,
"plant_id": "nrems-03",
"current_kw": 20.0,
"today_kwh": 100.0,
"status": "정상",
"created_at": "2026-08-07T03:00:00+00:00",
}
class FakeQuery:
def __init__(self, db, table_name):
self.db = db
self.table_name = table_name
self.columns = None
self.filters = []
self.action = "select"
self.payload = None
self.db.queries.append(self)
def select(self, columns):
self.columns = columns
return self
def eq(self, column, value):
self.filters.append((column, value))
return self
def order(self, _column, desc=False, foreign_table=None):
self.foreign_table = foreign_table
return self
def limit(self, _count, foreign_table=None):
self.limit_foreign_table = foreign_table
return self
def update(self, payload):
self.action = "update"
self.payload = payload
return self
def execute(self):
if self.db.query_failure:
raise RuntimeError("database unavailable")
if self.table_name == "plants":
if not self.db.plant_exists:
return SimpleNamespace(data=[])
if self.action == "update":
updated = dict(PLANT, **self.payload)
return SimpleNamespace(data=[updated])
if "solar_logs(" in self.columns:
return SimpleNamespace(data=[dict(PLANT, solar_logs=[LOG])])
return SimpleNamespace(data=[PLANT])
if self.table_name == "solar_logs":
return SimpleNamespace(data=[LOG])
raise AssertionError(f"unexpected table: {self.table_name}")
class FakeDb:
def __init__(self, plant_exists=True, query_failure=False):
self.plant_exists = plant_exists
self.query_failure = query_failure
self.queries = []
def table(self, table_name):
return FakeQuery(self, table_name)
class PlantsApiTest(unittest.TestCase):
def test_company_list_uses_one_nested_query(self):
db = FakeDb()
result = plants.get_plants_by_company(company_id=1, db=db)
self.assertEqual(1, len(db.queries))
self.assertEqual("plants", db.queries[0].table_name)
self.assertEqual("solar_logs", db.queries[0].foreign_table)
self.assertEqual("solar_logs", db.queries[0].limit_foreign_table)
self.assertEqual("nrems-03", result.data[0].id)
self.assertEqual(10, result.data[0].latest_log.id)
def test_company_without_plants_returns_empty_success(self):
result = plants.get_plants_by_company(
company_id=999,
db=FakeDb(plant_exists=False),
)
self.assertEqual("success", result.status)
self.assertEqual([], result.data)
self.assertEqual(0, result.total_count)
def test_company_query_failure_returns_500(self):
with self.assertRaises(HTTPException) as raised:
plants.get_plants_by_company(
company_id=1,
db=FakeDb(query_failure=True),
)
self.assertEqual(500, raised.exception.status_code)
def test_string_plant_id_is_used_for_detail_lookup(self):
db = FakeDb()
result = plants.get_plant_detail(
company_id=1, plant_id="nrems-03", db=db
)
plant_query = db.queries[0]
self.assertIn(("id", "nrems-03"), plant_query.filters)
self.assertEqual("nrems-03", result["data"]["plant"]["id"])
self.assertEqual(10, result["data"]["recent_logs"][0]["id"])
def test_missing_detail_returns_404(self):
with self.assertRaises(HTTPException) as raised:
plants.get_plant_detail(
company_id=1,
plant_id="missing",
db=FakeDb(plant_exists=False),
)
self.assertEqual(404, raised.exception.status_code)
def test_alert_update_uses_typed_response(self):
result = plants.update_plant_alerts(
company_id=1,
plant_id="nrems-03",
alert_update=plants.PlantAlertUpdateRequest(alerts_enabled=False),
db=FakeDb(),
)
self.assertFalse(result.data.alerts_enabled)
if __name__ == "__main__":
unittest.main()

View File

@ -0,0 +1,170 @@
import unittest
from datetime import date
from unittest.mock import patch
from fastapi import HTTPException
from app.routers import stats
class FakeResponse:
def __init__(self, data=None):
self.data = data or []
class FakeQuery:
def __init__(self, db, table_name):
self.db = db
self.table_name = table_name
self.filters = []
self.db.queries.append(self)
def select(self, _columns):
return self
def eq(self, column, value):
self.filters.append(("eq", column, value))
return self
def gte(self, column, value):
self.filters.append(("gte", column, value))
return self
def lte(self, column, value):
self.filters.append(("lte", column, value))
return self
def lt(self, column, value):
self.filters.append(("lt", column, value))
return self
def order(self, _column, desc=False):
return self
def limit(self, _count):
return self
def execute(self):
if self.table_name == "plants":
if not self.db.plant_exists:
return FakeResponse()
return FakeResponse([{"id": "plant-a", "name": "1호기", "capacity": 100}])
if self.table_name == "solar_logs":
return FakeResponse(self.db.solar_logs)
if self.table_name == "monthly_stats":
return FakeResponse(self.db.monthly_stats)
return FakeResponse()
class FakeDb:
def __init__(self, solar_logs=None, monthly_stats=None, plant_exists=True):
self.solar_logs = solar_logs or []
self.monthly_stats = monthly_stats or []
self.plant_exists = plant_exists
self.queries = []
def table(self, table_name):
return FakeQuery(self, table_name)
def latest_query(self, table_name):
return next(query for query in reversed(self.queries) if query.table_name == table_name)
class StatsTimezoneTest(unittest.TestCase):
def test_comparison_today_uses_kst_utc_half_open_bounds(self):
db = FakeDb([{"plant_id": "plant-a", "today_kwh": 123.4}])
with patch.object(stats, "today_kst", return_value=date(2026, 1, 1)):
result = stats.get_all_plants_comparison(
period="day", date=None, year=None, month=None, db=db
)
query = db.latest_query("solar_logs")
self.assertIn(("gte", "created_at", "2025-12-31T15:00:00+00:00"), query.filters)
self.assertIn(("lt", "created_at", "2026-01-01T15:00:00+00:00"), query.filters)
self.assertEqual(123.4, result.data[0].generation)
def test_plant_today_query_has_upper_bound(self):
db = FakeDb([{"today_kwh": 77.0}])
with patch.object(stats, "today_kst", return_value=date(2026, 8, 6)):
result = stats.get_plant_stats(
plant_id="plant-a", period="day", year=2026, month=8, db=db
)
query = db.latest_query("solar_logs")
self.assertIn(("gte", "created_at", "2026-08-05T15:00:00+00:00"), query.filters)
self.assertIn(("lt", "created_at", "2026-08-06T15:00:00+00:00"), query.filters)
self.assertEqual(77.0, result.data[5].value)
def test_hourly_stats_map_utc_edges_to_kst_hours(self):
db = FakeDb([
{"created_at": "2026-12-30T15:00:00Z", "current_kw": 1, "today_kwh": 1},
{"created_at": "2026-12-31T14:59:59Z", "current_kw": 2, "today_kwh": 2},
{"created_at": "2026-12-31T15:00:00Z", "current_kw": 3, "today_kwh": 3},
])
result = stats.get_plant_hourly_stats(
plant_id="plant-a", date="2026-12-31", db=db
)
query = db.latest_query("solar_logs")
self.assertIn(("gte", "created_at", "2026-12-30T15:00:00+00:00"), query.filters)
self.assertIn(("lt", "created_at", "2026-12-31T15:00:00+00:00"), query.filters)
self.assertTrue(result.data[0].has_data)
self.assertTrue(result.data[23].has_data)
self.assertEqual(2, result.count)
def test_invalid_hourly_date_remains_400(self):
with self.assertRaises(HTTPException) as raised:
stats.get_plant_hourly_stats(
plant_id="plant-a", date="2026-13-01", db=FakeDb()
)
self.assertEqual(400, raised.exception.status_code)
def test_invalid_comparison_date_is_not_replaced_with_today(self):
db = FakeDb()
with self.assertRaises(HTTPException) as raised:
stats.get_all_plants_comparison(
period="day", date="2026-8-07", year=None, month=None, db=db
)
self.assertEqual(400, raised.exception.status_code)
self.assertEqual([], db.queries)
def test_unknown_plant_stats_returns_404(self):
with self.assertRaises(HTTPException) as raised:
stats.get_plant_stats(
plant_id="missing", period="day", year=2026, month=8,
db=FakeDb(plant_exists=False),
)
self.assertEqual(404, raised.exception.status_code)
def test_leap_year_uses_366_days(self):
db = FakeDb(monthly_stats=[{
"plant_id": "plant-a",
"total_generation": 36600,
}])
result = stats.get_all_plants_comparison(
period="year", date="2024-12-31", year=2024, month=None, db=db
)
self.assertEqual(1.0, result.data[0].generation_hours)
def test_selected_year_is_used_as_yearly_query_end(self):
db = FakeDb()
stats.get_plant_stats(
plant_id="plant-a", period="year", year=2024, month=None, db=db
)
query = db.latest_query("monthly_stats")
self.assertIn(("lte", "month", "2024-12"), query.filters)
if __name__ == "__main__":
unittest.main()

View File

@ -0,0 +1,180 @@
import io
import unittest
from unittest.mock import patch
import pandas as pd
from fastapi.testclient import TestClient
from app.core.database import get_db
from app.main import app
from app.routers import upload
class FakeResponse:
def __init__(self, data=None):
self.data = data or []
class FakeRpc:
def __init__(self, db, params):
self.db = db
self.params = params
def execute(self):
self.db.saved["daily_stats"] = self.params["p_records"]
self.db.rpc_params = self.params
return FakeResponse(self.params["p_records"])
class FakeQuery:
def __init__(self, db, table_name):
self.db = db
self.table_name = table_name
self.action = None
self.payload = None
self.filters = {}
def select(self, _columns):
self.action = "select"
return self
def eq(self, column, value):
self.filters[column] = value
return self
def limit(self, _count):
return self
def upsert(self, payload, on_conflict=None):
self.action = "upsert"
self.payload = payload
self.on_conflict = on_conflict
return self
def execute(self):
if self.action == "select" and self.table_name == "plants":
if self.filters.get("id") != "plant-a":
return FakeResponse()
return FakeResponse([{
"id": "plant-a",
"name": "테스트 발전소",
"capacity": 100.0,
}])
if self.action == "upsert":
self.db.saved[self.table_name] = self.payload
return FakeResponse(self.payload)
raise AssertionError(f"unexpected query: {self.table_name} {self.action}")
class FakeDb:
def __init__(self):
self.saved = {}
self.rpc_params = None
def table(self, table_name):
return FakeQuery(self, table_name)
def rpc(self, function_name, params):
if function_name != "upsert_daily_stats":
raise AssertionError(function_name)
return FakeRpc(self, params)
def excel_bytes(rows):
output = io.BytesIO()
pd.DataFrame(rows).to_excel(output, index=False)
return output.getvalue()
class UploadApiTest(unittest.TestCase):
def setUp(self):
self.db = FakeDb()
app.dependency_overrides[get_db] = lambda: self.db
self.client = TestClient(app)
def tearDown(self):
app.dependency_overrides.clear()
def post_daily(self, content, filename="daily.xlsx", plant_id="plant-a"):
return self.client.post(
f"/plants/{plant_id}/upload",
files={"file": (filename, content, "application/octet-stream")},
)
def post_monthly(self, content, filename="monthly.xlsx", plant_id="plant-a"):
return self.client.post(
f"/plants/{plant_id}/upload/monthly",
files={"file": (filename, content, "application/octet-stream")},
)
def test_daily_sample_is_saved(self):
response = self.post_daily(excel_bytes([
{"date": "2026-08-05", "generation": 123.4},
]))
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["saved_count"], 1)
self.assertEqual(self.db.saved["daily_stats"][0]["total_generation"], 123.4)
self.assertEqual(self.db.rpc_params["p_source"], "excel_daily")
self.assertTrue(self.db.rpc_params["p_allow_decrease"])
def test_monthly_sample_with_merged_year_is_saved(self):
response = self.post_monthly(excel_bytes([
{"year": 2026, "month": "1월", "kwh": "1,234.5"},
{"year": None, "month": "2월", "kwh": 987.6},
]))
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["saved_count"], 2)
self.assertEqual(self.db.saved["monthly_stats"][1]["month"], "2026-02")
self.assertEqual(self.db.saved["monthly_stats"][1]["source"], "excel_monthly")
self.assertEqual(self.db.saved["monthly_stats"][1]["last_date"], "2026-02-28")
def test_wrong_extension_returns_400(self):
response = self.post_daily(b"text", filename="daily.csv")
self.assertEqual(response.status_code, 400)
def test_empty_file_returns_400(self):
response = self.post_daily(b"")
self.assertEqual(response.status_code, 400)
def test_missing_columns_returns_400(self):
response = self.post_daily(excel_bytes([{"wrong": 1}]))
self.assertEqual(response.status_code, 400)
def test_invalid_date_returns_400(self):
response = self.post_daily(excel_bytes([
{"date": "not-a-date", "generation": 1},
]))
self.assertEqual(response.status_code, 400)
def test_negative_generation_returns_400(self):
response = self.post_daily(excel_bytes([
{"date": "2026-08-05", "generation": -1},
]))
self.assertEqual(response.status_code, 400)
def test_unknown_plant_returns_404(self):
response = self.post_daily(
excel_bytes([{"date": "2026-08-05", "generation": 1}]),
plant_id="missing",
)
self.assertEqual(response.status_code, 404)
def test_file_size_limit_returns_413(self):
response = self.post_daily(b"x" * (upload.MAX_UPLOAD_BYTES + 1))
self.assertEqual(response.status_code, 413)
def test_row_limit_returns_400(self):
with patch.object(upload, "MAX_UPLOAD_ROWS", 1):
response = self.post_daily(excel_bytes([
{"date": "2026-08-05", "generation": 1},
{"date": "2026-08-06", "generation": 2},
]))
self.assertEqual(response.status_code, 400)
if __name__ == "__main__":
unittest.main()

1
app/.gitignore vendored
View File

@ -11,7 +11,6 @@ node_modules/
# npm
npm-debug.log
package-lock.json
yarn-error.log
# Mac

View File

@ -58,12 +58,17 @@ export default function UploadModal({ visible, onClose, plantId, onUploadSuccess
try {
const formData = new FormData();
// 파일 추가
if (selectedFile.file) {
// Web: 브라우저가 multipart boundary와 파일명을 설정한다.
formData.append('file', selectedFile.file, selectedFile.name);
} else {
// Native: React Native FormData가 요구하는 URI 객체를 사용한다.
formData.append('file', {
uri: selectedFile.uri,
name: selectedFile.name,
type: selectedFile.mimeType || 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
});
}
// uploadType에 따라 엔드포인트 분기
const endpoint = uploadType === 'daily'
@ -76,9 +81,6 @@ export default function UploadModal({ visible, onClose, plantId, onUploadSuccess
const response = await fetch(`${API_URL}${endpoint}`, {
method: 'POST',
body: formData,
headers: {
'Content-Type': 'multipart/form-data',
},
});
const result = await response.json();
@ -180,7 +182,7 @@ export default function UploadModal({ visible, onClose, plantId, onUploadSuccess
</TouchableOpacity>
{/* 선택된 파일 정보 */}
{selectedFile && (
{selectedFile?.size != null && (
<View style={styles.fileInfo}>
<Text style={styles.fileInfoText}>
크기: {(selectedFile.size / 1024).toFixed(1)} KB

11802
app/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -6,13 +6,16 @@
"start": "expo start",
"android": "expo run:android",
"ios": "expo run:ios",
"web": "expo start --web"
"web": "expo start --web",
"build:web": "expo export -p web",
"test:ci": "npm run build:web"
},
"dependencies": {
"@expo/metro-runtime": "~4.0.1",
"@react-navigation/native": "^7.1.28",
"@react-navigation/native-stack": "^7.10.1",
"expo": "~52.0.0",
"expo-asset": "~11.0.5",
"expo-document-picker": "~13.0.3",
"expo-linear-gradient": "~14.0.2",
"expo-status-bar": "~2.0.0",

View File

@ -8,11 +8,10 @@ import {
ScrollView,
Alert,
useWindowDimensions,
Modal,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { BarChart } from 'react-native-gifted-charts';
import * as DocumentPicker from 'expo-document-picker';
import UploadModal from '../components/UploadModal';
const API_BASE_URL = 'https://solorpower.dadot.net';
@ -21,7 +20,7 @@ export default function PlantDetailScreen({ route, navigation }) {
const [period, setPeriod] = useState('today');
const [chartData, setChartData] = useState([]);
const [loading, setLoading] = useState(true);
const [uploading, setUploading] = useState(false);
const [uploadVisible, setUploadVisible] = useState(false);
const [error, setError] = useState(null);
const [todayData, setTodayData] = useState(null);
const [tooltip, setTooltip] = useState({ visible: false, x: 0, y: 0, label: '', value: 0 });
@ -270,59 +269,6 @@ export default function PlantDetailScreen({ route, navigation }) {
return chartData.reduce((sum, item) => sum + (item.value || 0), 0);
};
// 엑셀 업로드 핸들러
const handleUpload = async () => {
try {
const result = await DocumentPicker.getDocumentAsync({
type: [
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/vnd.ms-excel',
],
copyToCacheDirectory: true,
});
if (result.canceled) return;
const file = result.assets[0];
setUploading(true);
const formData = new FormData();
if (file.file) {
// Web: 실제 File 객체 사용
formData.append('file', file.file);
} else {
// Native: URI 객체 사용
formData.append('file', {
uri: file.uri,
type: file.mimeType || 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
name: file.name,
});
}
const response = await fetch(`${API_BASE_URL}/plants/${plant.id}/upload/monthly`, {
method: 'POST',
body: formData,
});
const responseData = await response.json();
if (!response.ok) throw new Error(responseData.detail || '업로드 실패');
Alert.alert(
'업로드 완료',
responseData.message || `${responseData.saved_count}건의 데이터가 저장되었습니다.`,
[{ text: '확인' }]
);
fetchStats();
} catch (err) {
Alert.alert('업로드 실패', err.message, [{ text: '확인' }]);
} finally {
setUploading(false);
}
};
// 탭 렌더링
const renderTabs = () => (
<View style={styles.tabContainer}>
@ -497,21 +443,22 @@ export default function PlantDetailScreen({ route, navigation }) {
{/* 업로드 버튼 */}
<View style={styles.uploadSection}>
<TouchableOpacity
style={[styles.uploadButton, uploading && styles.uploadButtonDisabled]}
onPress={handleUpload}
disabled={uploading}
style={styles.uploadButton}
onPress={() => setUploadVisible(true)}
>
{uploading ? (
<ActivityIndicator size="small" color="#FFFFFF" />
) : (
<Text style={styles.uploadButtonText}>📂 과거 엑셀 데이터 업로드</Text>
)}
</TouchableOpacity>
<Text style={styles.uploadHint}>
* 엑셀 파일에 'date', 'generation' 컬럼이 필요합니다
* 일간 또는 월간 형식을 선택할 있습니다
</Text>
</View>
</ScrollView>
<UploadModal
visible={uploadVisible}
onClose={() => setUploadVisible(false)}
plantId={plant.id}
onUploadSuccess={fetchStats}
/>
</SafeAreaView>
);
}
@ -775,9 +722,6 @@ const styles = StyleSheet.create({
shadowRadius: 8,
elevation: 6,
},
uploadButtonDisabled: {
backgroundColor: '#9CA3AF',
},
uploadButtonText: {
color: '#FFFFFF',
fontSize: 16,

View File

@ -1,9 +1,11 @@
import sqlite3
from contextlib import closing
import requests
from datetime import datetime
from pathlib import Path
from config import TELEGRAM_BOT_TOKEN
from time_utils import ensure_kst, now_kst
class AlertManager:
"""
@ -11,7 +13,7 @@ class AlertManager:
- 상태(정상/이상) DB에 저장하여 중복 알림 방지
"""
def __init__(self, db_path: str = None):
def __init__(self, db_path: str = None, now_provider=None):
"""
DB 연결 테이블 초기화
"""
@ -20,11 +22,15 @@ class AlertManager:
db_path = Path(__file__).parent / "crawler_manager.db"
self.db_path = str(db_path)
self.zero_threshold = 3
self.today_growth_threshold = 0.5
self.now_provider = now_provider or now_kst
self._alerts_enabled_cache = {}
self._init_db()
def _init_db(self):
"""알림 히스토리 테이블 생성"""
with sqlite3.connect(self.db_path) as conn:
with closing(sqlite3.connect(self.db_path)) as conn:
cursor = conn.cursor()
# site_id: 발전소 ID
# alert_status: 'NORMAL' (정상), 'ALERT' (이상 발생 및 알림 전송됨)
@ -33,11 +39,64 @@ class AlertManager:
CREATE TABLE IF NOT EXISTS alert_history (
site_id TEXT PRIMARY KEY,
alert_status TEXT DEFAULT 'NORMAL',
last_alert_time TEXT
last_alert_time TEXT,
zero_count INTEGER DEFAULT 0,
first_zero_today_kwh REAL
)
""")
cursor.execute("PRAGMA table_info(alert_history)")
columns = {row[1] for row in cursor.fetchall()}
if 'zero_count' not in columns:
cursor.execute("ALTER TABLE alert_history ADD COLUMN zero_count INTEGER DEFAULT 0")
if 'first_zero_today_kwh' not in columns:
cursor.execute("ALTER TABLE alert_history ADD COLUMN first_zero_today_kwh REAL")
conn.commit()
def _reset_pending_zero(self, site_id: str):
"""수집 실패가 실제 0kW 연속 판정에 섞이지 않도록 의심 카운트를 초기화한다."""
with closing(sqlite3.connect(self.db_path)) as conn:
cursor = conn.cursor()
cursor.execute("""
UPDATE alert_history
SET zero_count = 0,
first_zero_today_kwh = NULL
WHERE site_id = ?
AND (zero_count != 0 OR first_zero_today_kwh IS NOT NULL)
""", (site_id,))
conn.commit()
def _is_alert_enabled(self, site_id: str, plant_name: str) -> bool:
"""
발전소별 알림 활성화 설정을 조회한다.
plants.id가 전역 고유 기본키이므로 타입이 다른 crawler company_id는 조회 조건에
사용하지 않는다. 일시적인 DB 조회 실패 시에는 마지막 성공 값을 사용하고,
캐시가 없으면 기존 동작과 동일하게 알림 활성 상태로 간주한다.
"""
try:
from database import get_supabase_client
client = get_supabase_client()
if not client:
return self._alerts_enabled_cache.get(site_id, True)
response = client.table("plants") \
.select("alerts_enabled") \
.eq("id", site_id) \
.limit(1) \
.execute()
if response.data:
enabled = response.data[0].get('alerts_enabled') is not False
self._alerts_enabled_cache[site_id] = enabled
return enabled
print(f" ⚠️ [Alert] {plant_name}: 발전소 알림 설정을 찾지 못했습니다.")
except Exception as e:
print(f" ⚠️ [Alert] {plant_name}: 알림 설정 조회 실패, 마지막 설정을 사용합니다: {e}")
return self._alerts_enabled_cache.get(site_id, True)
def send_telegram_message(self, chat_id, message):
"""텔레그램 메시지 전송"""
if not TELEGRAM_BOT_TOKEN:
@ -63,17 +122,20 @@ class AlertManager:
print(f" ❌ 텔레그램 전송 중 에러: {e}")
return False
def check_and_alert(self, plant_info: dict, current_kw: float):
def check_and_alert(
self,
plant_info: dict,
current_kw: float,
today_kwh: float = None,
data_valid: bool = True
):
"""
발전량을 체크하고 필요 알림 전송
- 오전 10 ~ 오후 5시에만 동작
- current_kw 0 3 연속이고 누적 발전량도 정체된 경우만 알림
- 상태 변경 시에만 알림 (중복 방지)
"""
# 1. 시간 체크 (오전 10시 ~ 오후 5시)
now = datetime.now()
if not (10 <= now.hour <= 17):
return
now = ensure_kst(self.now_provider())
site_id = plant_info.get('id')
plant_name = plant_info.get('display_name', plant_info.get('name'))
chat_id = plant_info.get('telegram_chat_id')
@ -81,28 +143,51 @@ class AlertManager:
if not site_id:
return
# 1.5. DB에서 알림 활성화 상태 확인
try:
from database import get_supabase_client
client = get_supabase_client()
if client:
company_id = plant_info.get('company_id', 1)
resp = client.table("plants").select("alerts_enabled").eq("id", site_id).eq("company_id", company_id).execute()
if resp.data and resp.data[0].get('alerts_enabled') is False:
# 수집 오류/미수집은 발전소의 실제 0kW가 아니다.
# 이전 정상 응답에서 시작된 의심 카운트도 끊어 연속 판정에 섞이지 않게 한다.
if not data_valid:
self._reset_pending_zero(site_id)
print(f" [Alert] {plant_name}: 수집 실패 데이터는 0kW 판정에서 제외합니다.")
return
# 알림 전송 시간대: 오전 10시 ~ 오후 5시
if not (10 <= now.hour <= 17):
return
if not self._is_alert_enabled(site_id, plant_name):
print(f" 🔇 [Alert] {plant_name}: 알림이 비활성화되어 있습니다.")
return
except Exception as e:
print(f" ⚠️ 알림 설정 확인 중 오류: {e}")
try:
current_kw = float(current_kw or 0)
except (TypeError, ValueError):
current_kw = 0.0
if today_kwh is not None:
try:
today_kwh = float(today_kwh)
except (TypeError, ValueError):
today_kwh = None
# 2. 현재 DB 상태 확인
current_status = 'NORMAL'
with sqlite3.connect(self.db_path) as conn:
current_last_alert_time = None
zero_count = 0
first_zero_today_kwh = None
with closing(sqlite3.connect(self.db_path)) as conn:
cursor = conn.cursor()
cursor.execute("SELECT alert_status FROM alert_history WHERE site_id = ?", (site_id,))
cursor.execute("""
SELECT alert_status, last_alert_time, zero_count, first_zero_today_kwh
FROM alert_history
WHERE site_id = ?
""", (site_id,))
row = cursor.fetchone()
if row:
current_status = row[0]
current_last_alert_time = row[1]
zero_count = row[2] or 0
first_zero_today_kwh = row[3]
else:
# 초기값 생성
cursor.execute("INSERT INTO alert_history (site_id, alert_status) VALUES (?, ?)", (site_id, 'NORMAL'))
@ -110,22 +195,48 @@ class AlertManager:
# 3. 상태 전이 로직
new_status = current_status
new_last_alert_time = current_last_alert_time
new_zero_count = zero_count
new_first_zero_today_kwh = first_zero_today_kwh
# [Case A] 발전량 0 (이상 감지)
if current_kw == 0:
if current_status == 'NORMAL':
new_zero_count += 1
if new_zero_count == 1 or new_first_zero_today_kwh is None:
new_first_zero_today_kwh = today_kwh
today_delta = None
if today_kwh is not None and new_first_zero_today_kwh is not None:
today_delta = today_kwh - new_first_zero_today_kwh
if today_delta is not None and today_delta > self.today_growth_threshold:
print(
f" ✅ [Alert] {plant_name}: 0kW 감지됐지만 누적 발전량 증가 "
f"({today_delta:.1f}kWh)로 오탐 처리"
)
new_zero_count = 0
new_first_zero_today_kwh = None
if current_status == 'ALERT':
new_status = 'NORMAL'
elif new_zero_count < self.zero_threshold:
print(
f" 🕒 [Alert] {plant_name}: 0kW 의심 "
f"{new_zero_count}/{self.zero_threshold}회, 알림 보류"
)
elif current_status == 'NORMAL':
# NORMAL -> ALERT: 알림 전송
print(f" 🚨 [Alert] {plant_name} 발전량 0kW 감지! 알림 전송 시도...")
print(f" 🚨 [Alert] {plant_name} 발전량 0kW {new_zero_count}회 연속 감지! 알림 전송 시도...")
if chat_id:
message = (
f"🚨 [긴급] 발전소 이상 감지!\n\n"
f"- 발전소: {plant_name}\n"
f"- 상태: 발전량 0kW\n"
f"- 상태: 발전량 0kW {new_zero_count}회 연속\n"
f"- 시간: {now.strftime('%Y-%m-%d %H:%M:%S')}"
)
if self.send_telegram_message(chat_id, message):
new_status = 'ALERT'
new_last_alert_time = now.isoformat()
else:
print(f" ⚠️ {plant_name}: Chat ID 오류로 알림 실패")
# 전송 실패해도 상태를 ALERT로 할 것인가?
@ -133,25 +244,31 @@ class AlertManager:
else:
print(f" ⚠️ {plant_name}: 설정된 Chat ID가 없습니다. (config.py 확인)")
else:
# 이미 ALERT 상태: 중복 알림 생략
pass
# [Case B] 발전량 > 0 (정상 복구)
else:
new_zero_count = 0
new_first_zero_today_kwh = None
if current_status == 'ALERT':
# ALERT -> NORMAL: 상태 리셋
print(f" ✅ [Alert] {plant_name} 정상 복구됨 ({current_kw}kW)")
# 복구 알림은 옵션 (현재는 생략)
new_status = 'NORMAL'
new_last_alert_time = now.isoformat()
# 4. 상태 변경 시 DB 업데이트
if new_status != current_status:
with sqlite3.connect(self.db_path) as conn:
# 4. 상태/의심 카운트 변경 시 DB 업데이트
if (
new_status != current_status
or new_zero_count != zero_count
or new_first_zero_today_kwh != first_zero_today_kwh
):
with closing(sqlite3.connect(self.db_path)) as conn:
cursor = conn.cursor()
cursor.execute("""
UPDATE alert_history
SET alert_status = ?, last_alert_time = ?
SET alert_status = ?,
last_alert_time = ?,
zero_count = ?,
first_zero_today_kwh = ?
WHERE site_id = ?
""", (new_status, now.isoformat(), site_id))
""", (new_status, new_last_alert_time, new_zero_count, new_first_zero_today_kwh, site_id))
conn.commit()

View File

@ -13,6 +13,7 @@ import time
import sqlite3
import argparse
import importlib
from contextlib import closing
from datetime import datetime, timedelta
from dotenv import load_dotenv
@ -30,20 +31,28 @@ sys.path.append(current_dir)
from config import get_all_plants
from database import save_history, get_supabase_client
from time_utils import yesterday_kst
# DB 경로 설정
DB_PATH = os.path.join(current_dir, "crawler_manager.db")
def get_db_connection():
"""SQLite 연결 반환"""
return sqlite3.connect(DB_PATH, timeout=10.0)
"""사용 후 실제 close되는 SQLite 컨텍스트를 반환한다."""
return closing(sqlite3.connect(DB_PATH, timeout=10.0))
def init_backfill_states(yesterday_str: str):
def get_initial_cursor_date(first_target_date_str: str) -> str:
"""첫 수집 대상의 다음 날을 초기 커서로 반환한다."""
first_target_date = datetime.strptime(first_target_date_str, "%Y-%m-%d")
return (first_target_date + timedelta(days=1)).strftime("%Y-%m-%d")
def init_backfill_states(first_target_date_str: str):
"""
발전소의 백필 상태를 SQLite에 초기화합니다.
이미 존재하면 건너뛰고 없으면 신규 등록합니다.
"""
plants = get_all_plants()
initial_cursor_date = get_initial_cursor_date(first_target_date_str)
with get_db_connection() as conn:
cursor = conn.cursor()
@ -75,8 +84,11 @@ def init_backfill_states(yesterday_str: str):
cursor.execute("""
INSERT INTO backfill_state (site_id, last_backfilled_date, consecutive_zero_count, status)
VALUES (?, ?, 0, 'RUNNING')
""", (site_id, yesterday_str))
print(f" 📝 [Backfill Init] {site_id} 상태 등록 완료 (시작일: {yesterday_str})")
""", (site_id, initial_cursor_date))
print(
f" 📝 [Backfill Init] {site_id} 상태 등록 완료 "
f"(첫 수집일: {first_target_date_str}, 커서: {initial_cursor_date})"
)
conn.commit()
def update_backfill_state(site_id: str, last_date: str, zero_count: int, status: str):
@ -178,15 +190,16 @@ def process_backfill(max_days_per_run: int = 7, delay_sec: float = 2.0, dry_run:
current_dt = last_dt
while days_processed < max_days_per_run:
# 하루 이전 날짜 계산
current_dt = current_dt - timedelta(days=1)
target_date_str = current_dt.strftime("%Y-%m-%d")
# last_backfilled_date는 마지막으로 처리가 끝난 날짜를 뜻한다.
# 따라서 다음 수집 대상은 커서의 하루 전이다.
target_dt = current_dt - timedelta(days=1)
target_date_str = target_dt.strftime("%Y-%m-%d")
# 가동개시일 이전이면 종료
if current_dt < start_dt:
if target_dt < start_dt:
print(f" 🏁 가동개시일({start_date_str}) 이전 날짜에 도달했습니다. 백필을 마감합니다.")
if not dry_run:
update_backfill_state(site_id, target_date_str, zero_count, 'COMPLETED')
update_backfill_state(site_id, last_date_str, zero_count, 'COMPLETED')
break
print(f" 📅 [{target_date_str}] 수집 시도 중...")
@ -196,6 +209,7 @@ def process_backfill(max_days_per_run: int = 7, delay_sec: float = 2.0, dry_run:
print(f" [Dry-Run] 크롤링 호출 시뮬레이션: {site_id} @ {target_date_str}")
days_processed += 1
last_date_str = target_date_str
current_dt = target_dt
time.sleep(0.1)
continue
@ -205,6 +219,10 @@ def process_backfill(max_days_per_run: int = 7, delay_sec: float = 2.0, dry_run:
history_data = crawler_fn(plant_config, target_date_str, target_date_str)
time.sleep(delay_sec) # Rate Limit 딜레이
if history_data is None:
print(" ➔ ❌ 수집 요청 또는 파싱 실패. 진행 상태를 유지하고 다음 실행에서 재시도합니다.")
break
matched_val = 0.0
has_data = False
@ -217,42 +235,48 @@ def process_backfill(max_days_per_run: int = 7, delay_sec: float = 2.0, dry_run:
matched = history_data
if matched:
matched_val = matched[0].get('generation_kwh', 0.0)
matched_val = float(matched[0]['generation_kwh'])
has_data = True
if has_data:
print(f" ➔ 🟢 수집 완료: {matched_val:.2f} kWh")
# DB 저장에 성공한 날짜만 진행 상태에 반영한다.
saved = save_history([{
'plant_id': site_id,
'date': target_date_str,
'generation_kwh': matched_val
}], 'daily')
if not saved:
print(" ➔ ❌ DB 저장 실패. 진행 상태를 유지하고 다음 실행에서 재시도합니다.")
break
if matched_val <= 0.0:
zero_count += 1
print(f" ➔ ⚠️ 발전량이 0입니다. (연속 {zero_count}일)")
else:
zero_count = 0 # 발전 확인 시 카운트 리셋
# DB 저장 (Supabase)
# matched list 형태로 save_history에 전달
save_history([{
'plant_id': site_id,
'date': target_date_str,
'generation_kwh': matched_val
}], 'daily')
else:
zero_count += 1
print(f" ➔ ⚠️ 원본 데이터가 존재하지 않습니다. (연속 {zero_count}일)")
# 성공적으로 조회했지만 해당 날짜 레코드가 없는 경우다.
# 실제 0kWh와 구분하며 연속 무발전 종료 조건에는 포함하지 않는다.
zero_count = 0
print(" ➔ ⚪ 원본에 해당 날짜 데이터가 없습니다. 무발전 일수에는 포함하지 않습니다.")
except Exception as e:
zero_count += 1
print(f" ➔ ❌ 크롤링 오류 발생: {e} (연속 {zero_count}일)")
print(f" ➔ ❌ 크롤링 오류 발생: {e}. 진행 상태를 유지하고 다음 실행에서 재시도합니다.")
time.sleep(delay_sec)
break
days_processed += 1
last_date_str = target_date_str
current_dt = target_dt
# 상태 업데이트 (매 날짜 진행 마다 유실 방지 위해 업데이트)
update_backfill_state(site_id, last_date_str, zero_count, 'RUNNING')
# 연속 무발전/데이터 누락 30일 조건 확인
# 성공적으로 조회된 실제 0kWh만 연속 종료 조건에 포함한다.
if zero_count >= 30:
print(f" 🏁 연속 30일 동안 데이터가 없거나 발전량이 0입니다. 백필을 마감합니다.")
print(" 🏁 실제 발전량이 연속 30일 동안 0kWh입니다. 백필을 마감합니다.")
update_backfill_state(site_id, last_date_str, zero_count, 'COMPLETED')
break
@ -273,7 +297,7 @@ def main():
if args.yesterday:
yesterday_str = args.yesterday
else:
yesterday_str = (datetime.now() - timedelta(days=1)).strftime("%Y-%m-%d")
yesterday_str = yesterday_kst().strftime("%Y-%m-%d")
# 2. 백필 상태 초기화
init_backfill_states(yesterday_str)
@ -286,9 +310,12 @@ def main():
UPDATE backfill_state
SET last_backfilled_date = ?, consecutive_zero_count = 0, status = 'RUNNING'
WHERE site_id = ?
""", (yesterday_str, args.reset_site))
""", (get_initial_cursor_date(yesterday_str), args.reset_site))
conn.commit()
print(f"🔄 [{args.reset_site}] 백필 상태가 {yesterday_str} 기준 'RUNNING'으로 재설정되었습니다.")
print(
f"🔄 [{args.reset_site}] 백필 상태가 첫 수집일 {yesterday_str} 기준 "
f"'RUNNING'으로 재설정되었습니다."
)
# 4. 백필 작업 진행
process_backfill(max_days_per_run=args.days, delay_sec=args.delay, dry_run=args.dry_run)

View File

@ -2,11 +2,13 @@
# config.py - 다중 업체(Multi-Tenant) 설정 관리
# ==========================================
import os
# ---------------------------------------------------------
# [프록시 설정 - 클라우드 이전용]
# 오라클 서버 등 외부 망에서 접속할 때 NAS의 인터넷을 빌려 쓰기 위한 설정입니다.
# ---------------------------------------------------------
USE_PROXY = False # True로 변경하면 모든 크롤링이 아래 프록시를 경유합니다.
USE_PROXY = os.getenv('USE_PROXY', 'False').lower() in ('true', '1', 't') # True로 변경하면 모든 크롤링이 아래 프록시를 경유합니다.
PROXY_URL = "http://100.83.7.81:3128"
PROXIES = {
"http": PROXY_URL,

View File

@ -12,9 +12,12 @@
# - 업데이트 패턴 학습은 부가 기능 (로깅용)
import sqlite3
from datetime import datetime, timedelta
from contextlib import closing
from datetime import timedelta
from pathlib import Path
from time_utils import ensure_kst, now_kst, parse_legacy_kst_datetime
class CrawlerManager:
"""
@ -25,7 +28,7 @@ class CrawlerManager:
- analyze_and_optimize: 업데이트 패턴 학습 (로깅/모니터링 목적)
"""
def __init__(self, db_path: str = None):
def __init__(self, db_path: str = None, now_provider=None):
"""
DB 연결 테이블 초기화
@ -36,11 +39,16 @@ class CrawlerManager:
db_path = Path(__file__).parent / "crawler_manager.db"
self.db_path = str(db_path)
self.now_provider = now_provider or now_kst
self._init_db()
def _now(self):
"""테스트 주입값과 운영 시각을 모두 KST aware 값으로 정규화한다."""
return ensure_kst(self.now_provider())
def _init_db(self):
"""테이블이 없으면 생성"""
with sqlite3.connect(self.db_path) as conn:
with closing(sqlite3.connect(self.db_path)) as conn:
cursor = conn.cursor()
cursor.executescript("""
CREATE TABLE IF NOT EXISTS site_rules (
@ -74,16 +82,16 @@ class CrawlerManager:
""")
conn.commit()
def _get_connection(self) -> sqlite3.Connection:
"""SQLite 연결 반환 (타임아웃 설정 추가)"""
return sqlite3.connect(self.db_path, timeout=10.0)
def _get_connection(self):
"""사용 후 실제 close되는 SQLite 컨텍스트를 반환한다."""
return closing(sqlite3.connect(self.db_path, timeout=10.0))
def _cleanup_old_history(self):
"""오래된 히스토리 정리 (30일 이상 지난 데이터 삭제)"""
try:
with self._get_connection() as conn:
cursor = conn.cursor()
limit_date = (datetime.now() - timedelta(days=30)).isoformat()
limit_date = (self._now() - timedelta(days=30)).isoformat()
cursor.execute("DELETE FROM update_history WHERE detected_at < ?", (limit_date,))
conn.commit()
except Exception as e:
@ -106,7 +114,7 @@ class CrawlerManager:
if cursor.fetchone():
return False
today = datetime.now().strftime("%Y-%m-%d")
today = self._now().strftime("%Y-%m-%d")
cursor.execute("""
INSERT INTO site_rules (site_id, status, target_minute, start_date, last_run)
VALUES (?, 'LEARNING', -1, ?, NULL)
@ -132,7 +140,7 @@ class CrawlerManager:
Returns:
bool: 크롤링 실행 여부 (야간이면 False)
"""
now = datetime.now()
now = self._now()
current_hour = now.hour
current_minute = now.minute
@ -172,7 +180,7 @@ class CrawlerManager:
"""
new_kw = float(current_data.get('kw', 0))
new_today = float(current_data.get('today', 0))
now = datetime.now()
now = self._now()
with self._get_connection() as conn:
cursor = conn.cursor()
@ -203,7 +211,7 @@ class CrawlerManager:
# 3. 1시간 이상 저장 없었으면 강제 heartbeat 저장
elif last_updated_at:
try:
last_dt = datetime.fromisoformat(last_updated_at)
last_dt = parse_legacy_kst_datetime(last_updated_at)
if now - last_dt >= timedelta(hours=1):
should_save = True
except (ValueError, TypeError):
@ -238,7 +246,7 @@ class CrawlerManager:
정보는 현재 크롤링 스케줄 제어에는 사용하지 않으며,
향후 분석이나 시각화를 위한 참고 데이터로만 활용.
"""
now = datetime.now()
now = self._now()
current_minute = now.minute
# 히스토리 기록
@ -319,7 +327,7 @@ class CrawlerManager:
Args:
site_id: 사이트 식별자
"""
now_str = datetime.now().isoformat()
now_str = self._now().isoformat()
with self._get_connection() as conn:
cursor = conn.cursor()

View File

@ -63,6 +63,16 @@ def format_result(name, kw, today, plant_id, status=None):
'status': status
}
def is_data_valid(item):
"""크롤러 결과가 실제 발전 상태를 나타내는 유효한 관측값인지 판단한다."""
explicit_value = item.get('data_valid')
if explicit_value is not None:
return bool(explicit_value)
status = str(item.get('status', ''))
return '오류' not in status and 'error' not in status.lower()
def validate_data_quality(data_list, value_key='generation_kwh'):
"""
데이터 품질 검증

View File

@ -238,6 +238,7 @@ def fetch_history_daily(plant_info, start_date, end_date):
from dateutil.relativedelta import relativedelta
results = []
fetch_failed = False
plant_id = plant_info.get('id', 'cmsolar-10')
auth = plant_info.get('auth', {})
system = plant_info.get('system', {})
@ -275,10 +276,10 @@ def fetch_history_daily(plant_info, start_date, end_date):
print(" ✓ Login successful")
else:
print(" ✗ Login failed")
return results
return None
except Exception as e:
print(f" ✗ Login error: {e}")
return results
return None
# 사이트 선택 (필수!)
try:
@ -287,7 +288,7 @@ def fetch_history_daily(plant_info, start_date, end_date):
print(" ✓ Site selected")
except Exception as e:
print(f" ✗ Site selection error: {e}")
return results
return None
# 월 단위로 반복 (type=month는 한 달 치 일별 데이터 반환)
current_date = datetime.strptime(start_date, '%Y-%m-%d')
@ -346,17 +347,20 @@ def fetch_history_daily(plant_info, start_date, end_date):
print(f"{date_str}: {generation_kwh:.2f}kWh")
else:
print(f" ⚠ No tbody found for {month_start[:7]}")
fetch_failed = True
else:
print(f" ✗ HTTP {res.status_code} for {month_start[:7]}")
fetch_failed = True
except Exception as e:
print(f" ✗ Error for {month_start[:7]}: {e}")
fetch_failed = True
# 다음 달로 이동
current_date = (current_date.replace(day=1) + relativedelta(months=1))
print(f"[Total] Collected {len(results)} daily records\n")
return results
return None if fetch_failed else results
def fetch_history_monthly(plant_info, start_month, end_month):

View File

@ -277,6 +277,7 @@ def fetch_history_daily(plant_info, start_date, end_date):
import calendar
results = []
fetch_failed = False
plant_id = plant_info.get('id', 'hyundai-08')
auth = plant_info.get('auth', {})
system = plant_info.get('system', {})
@ -314,14 +315,14 @@ def fetch_history_daily(plant_info, start_date, end_date):
if not auth_token:
print(" ✗ Login failed")
return results
return None
headers['x-auth-token'] = auth_token
headers['X-Mid'] = 'siteWork'
print(" ✓ Login successful")
except Exception as e:
print(f" ✗ Login error: {e}")
return results
return None
# 월 단위 반복
current_month = datetime.strptime(start_date[:7], '%Y-%m') # YYYY-MM-01
@ -376,14 +377,16 @@ def fetch_history_daily(plant_info, start_date, end_date):
print(f" No data")
else:
print(f" HTTP {res.status_code}")
fetch_failed = True
except Exception as e:
print(f" Error: {e}")
fetch_failed = True
current_month += relativedelta(months=1)
print(f"\n[Total] Collected {len(results)} daily records\n")
return results
return None if fetch_failed else results
def fetch_history_monthly(plant_info, start_month, end_month):

View File

@ -287,6 +287,7 @@ def fetch_history_daily(plant_info, start_date, end_date):
import urllib.parse
results = []
fetch_failed = False
plant_id = plant_info.get('id', 'kremc-05')
auth = plant_info.get('auth', {})
system = plant_info.get('system', {})
@ -324,7 +325,7 @@ def fetch_history_daily(plant_info, start_date, end_date):
if login_res.status_code != 200:
print(" ✗ Login failed")
return results
return None
login_json = login_res.json()
data = login_json.get('data')
@ -332,7 +333,7 @@ def fetch_history_daily(plant_info, start_date, end_date):
if not token:
print(" ✗ Token not found")
return results
return None
print(" ✓ Login successful")
@ -411,18 +412,21 @@ def fetch_history_daily(plant_info, start_date, end_date):
print(" No data")
else:
print(f" HTTP {res.status_code}")
fetch_failed = True
except Exception as e:
print(f" Error: {e}")
fetch_failed = True
# 다음 기간 설정 (현재 기간 끝 다음날)
loop_start = loop_end + timedelta(days=1)
except Exception as e:
print(f" ✗ Overall Error: {e}")
return None
print(f"\n[Total] Collected {len(results)} daily records\n")
return results
return None if fetch_failed else results
def fetch_history_monthly(plant_info, start_month, end_month):

View File

@ -5,16 +5,17 @@
import requests
import json
import re
from datetime import datetime
from .base import safe_float, create_session, format_result
from time_utils import today_kst
def _get_inverter_sums(session, pscode, system_config):
"""
1, 2호기 인버터별 일일 발전량 추출 (JSON API 사용)
"""
try:
today_str = datetime.now().strftime('%Y-%m-%d')
month_str = datetime.now().strftime('%Y-%m')
current_date = today_kst()
today_str = current_date.strftime('%Y-%m-%d')
month_str = current_date.strftime('%Y-%m')
headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0 Safari/537.36',
@ -199,7 +200,8 @@ def fetch_data(plant_info):
'name': f'{company_name} {plant_name}',
'kw': 0.0,
'today': 0.0,
'status': '🔴 오류'
'status': '🔴 오류',
'data_valid': False
})
return results
@ -373,6 +375,7 @@ def fetch_history_daily(plant_info, start_date, end_date):
import calendar
results = []
fetch_failed = False
# 설정 추출
plant_id = plant_info.get('id', '')
@ -484,17 +487,20 @@ def fetch_history_daily(plant_info, start_date, end_date):
print(f" No data")
except Exception as json_err:
print(f" JSON Error: {json_err}")
fetch_failed = True
else:
print(f" HTTP {response.status_code}")
fetch_failed = True
except Exception as e:
print(f" Error: {e}")
fetch_failed = True
# 다음 달 1일로 이동
current_dt = (current_dt.replace(day=1) + timedelta(days=32)).replace(day=1)
print(f"\n[Total] Collected {len(results)} daily records\n")
return results
return None if fetch_failed else results
def fetch_history_monthly(plant_info, start_month, end_month):

View File

@ -201,6 +201,7 @@ def fetch_history_daily(plant_info, start_date, end_date):
from .base import safe_float, create_session
results = []
fetch_failed = False
plant_id = plant_info.get('id', 'sunwms-06')
auth = plant_info.get('auth', {})
system = plant_info.get('system', {})
@ -231,10 +232,10 @@ def fetch_history_daily(plant_info, start_date, end_date):
print(" ✓ Login successful")
else:
print(" ✗ Login failed")
return results
return None
except Exception as e:
print(f" ✗ Login error: {e}")
return results
return None
# 월 단위 루프 적용
start_dt = datetime.strptime(start_date, '%Y-%m-%d')
@ -294,17 +295,20 @@ def fetch_history_daily(plant_info, start_date, end_date):
print(" No data")
else:
print(" No tbody")
fetch_failed = True
else:
print(f" HTTP {res.status_code}")
fetch_failed = True
except Exception as e:
print(f" Error: {e}")
fetch_failed = True
# 다음 기간 설정
loop_start = loop_end + timedelta(days=1)
print(f"\n[Total] Collected {len(results)} daily records\n")
return results
return None if fetch_failed else results
def fetch_history_monthly(plant_info, start_month, end_month):

View File

@ -3,7 +3,7 @@
# ==========================================
# solar_logs 데이터를 집계하여 daily_stats 테이블에 저장
from datetime import datetime, timedelta, timezone
from datetime import datetime
try:
from dotenv import load_dotenv
@ -12,8 +12,9 @@ except ImportError:
pass
import pandas as pd
from database import get_supabase_client
from database import get_supabase_client, upsert_daily_stats
from config import get_all_plants
from time_utils import kst_day_bounds_utc, today_kst, yesterday_kst
def get_history_crawler(plant_type: str):
"""발전소 타입별 과거 데이터 크롤러 함수 반환"""
@ -56,8 +57,7 @@ def calculate_daily_stats(date_str: str = None):
date_str: 집계 대상 날짜 (YYYY-MM-DD). 미지정 오늘.
"""
if date_str is None:
kst = timezone(timedelta(hours=9))
date_str = datetime.now(kst).strftime('%Y-%m-%d')
date_str = today_kst().strftime('%Y-%m-%d')
print(f"\n📊 [일일 통계 집계] {date_str}")
print("-" * 60)
@ -82,17 +82,14 @@ def calculate_daily_stats(date_str: str = None):
plant_info_map[p.get('id', '')] = p
# 2. 해당일 로그 조회 (KST 날짜 범위를 UTC로 변환하여 쿼리)
kst = timezone(timedelta(hours=9))
start_kst = datetime.strptime(f"{date_str} 00:00:00", "%Y-%m-%d %H:%M:%S").replace(tzinfo=kst)
end_kst = datetime.strptime(f"{date_str} 23:59:59", "%Y-%m-%d %H:%M:%S").replace(tzinfo=kst)
start_utc = start_kst.astimezone(timezone.utc).isoformat()
end_utc = end_kst.astimezone(timezone.utc).isoformat()
target_date = datetime.strptime(date_str, "%Y-%m-%d").date()
start_utc, next_start_utc = kst_day_bounds_utc(target_date)
try:
result = client.table("solar_logs") \
.select("plant_id, current_kw, today_kwh, created_at") \
.gte("created_at", start_utc) \
.lte("created_at", end_utc) \
.gte("created_at", start_utc.isoformat()) \
.lt("created_at", next_start_utc.isoformat()) \
.order("created_at", desc=False) \
.execute()
@ -135,9 +132,14 @@ def calculate_daily_stats(date_str: str = None):
if matched:
original_val = matched[0].get('generation_kwh', 0)
if original_val > 0:
print(f" ➔ 🟢 보정 성공: 기존 집계 {total_generation:.1f} kWh ➔ 원본 {original_val:.1f} kWh")
if original_val > total_generation:
print(f" ➔ 🟢 원본 상향 보정: 로그 {total_generation:.1f} kWh ➔ 원본 {original_val:.1f} kWh")
total_generation = original_val
elif original_val > 0:
print(
f" ➔ 🛡️ 원본({original_val:.1f})이 로그 집계"
f"({total_generation:.1f})보다 작아 기존 값을 유지합니다."
)
else:
print(f" ➔ ⚠️ 원본 값이 0이므로 보정을 건너뜁니다.")
else:
@ -166,10 +168,12 @@ def calculate_daily_stats(date_str: str = None):
# 4. daily_stats 테이블에 Upsert
if stats_list:
try:
result = client.table("daily_stats").upsert(
upsert_daily_stats(
client,
stats_list,
on_conflict="plant_id,date"
).execute()
source="daily_summary",
allow_decrease=False,
)
print("-" * 60)
print(f"{len(stats_list)}개 발전소 통계 저장 완료")
@ -181,93 +185,17 @@ def calculate_daily_stats(date_str: str = None):
return True
def calculate_monthly_stats(target_month: str):
"""
특정 월의 발전 통계 집계 (일간 데이터 합산)
Args:
target_month: YYYY-MM
"""
print(f"\n📅 [월간 통계 집계] {target_month}")
print("-" * 60)
client = get_supabase_client()
if not client:
return False
try:
# 1. 모든 발전소 ID 조회
plants_res = client.table("plants").select("id").execute()
plant_ids = [p['id'] for p in plants_res.data]
updated_count = 0
for pid in plant_ids:
# 2. 해당 월의 Daily 합계 조회
import calendar
year_str, month_str = target_month.split("-")
last_day = calendar.monthrange(int(year_str), int(month_str))[1]
d_res = client.table("daily_stats").select("total_generation") \
.eq("plant_id", pid) \
.gte("date", f"{target_month}-01") \
.lte("date", f"{target_month}-{last_day:02d}") \
.execute()
if not d_res.data:
continue
total_gen = sum(r.get('total_generation', 0) or 0 for r in d_res.data)
# 3. Monthly Upsert
client.table("monthly_stats").upsert({
"plant_id": pid,
"month": target_month,
"total_generation": round(total_gen, 2),
"updated_at": datetime.now().isoformat()
}, on_conflict="plant_id, month").execute()
print(f" {pid}: {total_gen:.1f}kWh (Month Total)")
updated_count += 1
print("-" * 60)
print(f"{updated_count}개 발전소 월간 통계 갱신 완료")
return True
except Exception as e:
print(f" ❌ 월간 집계 실패: {e}")
return False
if __name__ == "__main__":
import sys
from datetime import timedelta
# 인자로 날짜 지정 가능: python daily_summary.py 2026-01-22
if len(sys.argv) > 1:
target_date = sys.argv[1]
else:
# 인자 없으면 '어제' 날짜를 기본값으로 사용
# (새벽에 실행하여 전날 데이터를 마감하는 시나리오)
yesterday = datetime.now() - timedelta(days=1)
target_date = yesterday.strftime('%Y-%m-%d')
target_date = yesterday_kst().strftime('%Y-%m-%d')
print(f" 날짜 미지정 -> 어제({target_date}) 기준으로 집계합니다.")
# 1. 일간 통계 집계
success = calculate_daily_stats(target_date)
# 2. 월말 체크 및 월간 집계 트리거
# target_date가 해당 월의 마지막 날이면 월간 집계 실행
if success:
try:
current_dt = datetime.strptime(target_date, '%Y-%m-%d')
import calendar
last_day = calendar.monthrange(current_dt.year, current_dt.month)[1]
if current_dt.day == last_day:
target_month = current_dt.strftime('%Y-%m')
print(f"\n🔔 월말({target_date}) 감지 -> {target_month} 월간 집계 실행")
calculate_monthly_stats(target_month)
except Exception as e:
print(f"⚠️ 월간 집계 트리거 오류: {e}")
# daily_stats 변경 트리거가 해당 월의 monthly_stats를 즉시 갱신한다.
calculate_daily_stats(target_date)

View File

@ -5,6 +5,8 @@
import os
from datetime import datetime
from time_utils import ensure_kst, now_kst
# 환경 변수에서 Supabase 설정 로드
SUPABASE_URL = os.getenv('SUPABASE_URL', '')
SUPABASE_KEY = os.getenv('SUPABASE_KEY', '')
@ -38,6 +40,38 @@ def get_supabase_client():
return _supabase_client
def upsert_daily_stats(client, records, source, allow_decrease=False):
"""Store daily stats through the database's single conflict policy.
Automated sources preserve the highest daily generation. Only an explicit
correction path (currently Excel upload) may pass ``allow_decrease=True``.
The database function also derives generation_hours and refreshes the
affected monthly aggregate.
"""
if not records:
return []
payload = []
for record in records:
normalized = {
"plant_id": record["plant_id"],
"date": record["date"],
"total_generation": float(record["total_generation"]),
"peak_kw": float(record.get("peak_kw") or 0),
}
payload.append(normalized)
result = client.rpc(
"upsert_daily_stats",
{
"p_records": payload,
"p_source": source,
"p_allow_decrease": allow_decrease,
},
).execute()
return result.data or []
def save_to_supabase(data_list):
"""
수집된 발전 데이터를 Supabase solar_logs 테이블에 저장
@ -69,9 +103,7 @@ def save_to_supabase(data_list):
continue
# 한국 시간(KST) 타임스탬프 생성
from datetime import timezone, timedelta
kst = timezone(timedelta(hours=9))
kst_now = datetime.now(kst).isoformat()
kst_now = now_kst().isoformat()
status = item.get('status', '')
is_error = '오류' in status # '🔴 오류' 상태 감지
@ -96,14 +128,9 @@ def save_to_supabase(data_list):
print(f"✅ [DB] Supabase 저장 완료: {len(records)}건 (solar_logs)")
# daily_stats 테이블 업데이트 (Upsert)
# [보호 로직]
# 1. 오류 상태(크롤링 실패)인 경우 daily_stats 갱신 금지
# 2. today_kwh == 0인 경우 daily_stats 갱신 금지 (새벽 0 값으로 하루치 덮어쓰기 방지)
# 3. 야간 시간대(21:00~06:00 KST) daily_stats 갱신 금지 (일몰 이후 잔류값 보호)
# 4. DB에 이미 저장된 값보다 작은 경우 갱신 금지 (최댓값 보호)
kst = timezone(timedelta(hours=9))
kst_now_dt = datetime.now(kst)
# daily_stats 갱신은 DB 함수의 공통 최댓값 정책을 사용한다.
# 오류/0/야간 값은 호출 전에 제외하고, 충돌과 월간 재집계는 DB가 처리한다.
kst_now_dt = now_kst()
kst_date_str = kst_now_dt.strftime("%Y-%m-%d")
kst_hour = kst_now_dt.hour
@ -114,18 +141,6 @@ def save_to_supabase(data_list):
else:
daily_records = []
# 기존 daily_stats 값 조회 (MAX 보호용)
try:
existing_res = client.table("daily_stats") \
.select("plant_id, total_generation") \
.eq("date", kst_date_str) \
.execute()
existing_map = {row['plant_id']: float(row.get('total_generation') or 0)
for row in existing_res.data}
except Exception as e:
print(f" ⚠️ [DB] 기존 daily_stats 조회 실패: {e}")
existing_map = {}
for item in data_list:
plant_id = item.get('id', '')
if not plant_id:
@ -143,23 +158,21 @@ def save_to_supabase(data_list):
print(f" ⚠️ [{plant_id}] today_kwh=0 → daily_stats 갱신 건너뜀 (새벽/야간 추정)")
continue
# [MAX 보호] 기존 값보다 작으면 갱신 건너뜀
existing_val = existing_map.get(plant_id, 0)
if today_val <= existing_val:
print(f" ⚠️ [{plant_id}] 신규({today_val:.1f}) ≤ 기존({existing_val:.1f}) → daily_stats 갱신 건너뜀 (최댓값 보호)")
continue
daily_records.append({
"plant_id": plant_id,
"date": kst_date_str,
"total_generation": today_val,
"created_at": kst_now
# updated_at은 자동으로 NOW()로 설정됨 (DB 기본값)
"peak_kw": float(item.get('kw', 0)),
})
if daily_records:
try:
stats_result = client.table("daily_stats").upsert(daily_records, on_conflict="plant_id, date").execute()
upsert_daily_stats(
client,
daily_records,
source="realtime",
allow_decrease=False,
)
print(f"✅ [DB] daily_stats 업데이트 완료: {len(daily_records)}")
except Exception as e:
print(f"⚠️ [DB] daily_stats 업데이트 실패: {e}")
@ -231,21 +244,18 @@ def save_history(data_list, data_type='hourly'):
ts_iso = ts.replace(' ', 'T')
# Check if future (simple string comparison works for ISO format if consistent, but datetime is safer)
# KST aware comparison
from datetime import timezone, timedelta
kst = timezone(timedelta(hours=9))
now_kst = datetime.now(kst)
current_kst = now_kst()
try:
# ts example: 2026-01-27 14:00:00. Assume input is local time (KST)
# We convert it to aware datetime
dt_ts = datetime.fromisoformat(ts_iso)
if dt_ts.tzinfo is None:
dt_ts = dt_ts.replace(tzinfo=kst)
dt_ts = ensure_kst(dt_ts)
if dt_ts > now_kst:
if dt_ts > current_kst:
continue # Skip future data
except ValueError:
pass # robust date parsing needed if format varies
continue
# Ensure timezone is sent to Supabase to prevent UTC assumption
final_created_at = dt_ts.isoformat()
@ -266,11 +276,17 @@ def save_history(data_list, data_type='hourly'):
elif data_type == 'daily':
table_name = "daily_stats"
for item in data_list:
generation_kwh = float(item['generation_kwh'])
if generation_kwh < 0:
raise ValueError(
f"daily generation_kwh must be non-negative: "
f"{item.get('plant_id')} {item.get('date')}"
)
records.append({
'plant_id': item['plant_id'],
'date': item['date'],
'total_generation': float(item.get('generation_kwh', 0))
# 'updated_at': datetime.now().isoformat()
'total_generation': generation_kwh,
'peak_kw': 0.0,
})
elif data_type == 'monthly':
@ -280,7 +296,8 @@ def save_history(data_list, data_type='hourly'):
'plant_id': item['plant_id'],
'month': item['month'], # YYYY-MM
'total_generation': float(item.get('generation_kwh', 0)),
'updated_at': datetime.now().isoformat()
'updated_at': now_kst().isoformat(),
'source': 'history_monthly',
})
if not records:
@ -290,49 +307,13 @@ def save_history(data_list, data_type='hourly'):
if data_type == 'hourly':
client.table(table_name).insert(records).execute()
elif data_type == 'daily':
client.table(table_name).upsert(records, on_conflict="plant_id, date").execute()
# [Auto Update] Daily 데이터 저장 시 Monthly 통계 자동 갱신
# 1. 업데이트된 월 목록 추출
updated_months = set()
for rec in records:
try:
# date: YYYY-MM-DD
month_key = rec['date'][:7]
updated_months.add((rec['plant_id'], month_key))
except:
pass
if updated_months:
monthly_upserts = []
for (pid, m_key) in updated_months:
# 2. 해당 월의 Daily 합계 조회 (DB Aggregation)
import calendar
try:
year, month_int = map(int, m_key.split('-'))
_, last_day = calendar.monthrange(year, month_int)
except:
last_day = 31
d_res = client.table("daily_stats").select("total_generation") \
.eq("plant_id", pid) \
.gte("date", f"{m_key}-01") \
.lte("date", f"{m_key}-{last_day}") \
.execute()
total_gen = sum(r['total_generation'] or 0 for r in d_res.data)
monthly_upserts.append({
"plant_id": pid,
"month": m_key,
"total_generation": round(total_gen, 2),
"updated_at": datetime.now().isoformat()
})
# 3. Monthly Upsert
if monthly_upserts:
client.table("monthly_stats").upsert(monthly_upserts, on_conflict="plant_id, month").execute()
print(f" 🔄 [Sync] {len(monthly_upserts)}개월치 Monthly Stats 자동 갱신 완료")
# DB 함수가 최댓값 보호와 월간 통계 갱신을 한 트랜잭션으로 처리한다.
upsert_daily_stats(
client,
records,
source="history",
allow_decrease=False,
)
elif data_type == 'monthly':
client.table(table_name).upsert(records, on_conflict="plant_id, month").execute()

View File

@ -3,7 +3,8 @@
# ==========================================
import re
from datetime import datetime, timezone, timedelta
from time_utils import now_kst
# 환경 변수 로드 (최상단에서 실행)
try:
@ -16,6 +17,7 @@ except ImportError:
from config import get_all_plants
from database import save_to_supabase, save_to_console
from crawlers import get_crawler
from crawlers.base import is_data_valid
from crawler_manager import CrawlerManager
from alert_manager import AlertManager
@ -38,7 +40,7 @@ def integrated_monitoring(save_to_db=True, company_filter=None, force_run=False)
company_filter: 특정 업체만 필터링 (: 'sunwind')
force_run: True면 스케줄러 무시하고 강제 실행
"""
now_str = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
now_str = now_kst().strftime('%Y-%m-%d %H:%M:%S')
print(f"\n🚀 [통합 관제 시스템] 데이터 수집 시작... ({now_str})")
print("-" * 75)
@ -97,7 +99,12 @@ def integrated_monitoring(save_to_db=True, company_filter=None, force_run=False)
alert_info = plant.copy()
alert_info['id'] = item_id
alert_info['name'] = item.get('name', plant_name)
alert_manager.check_and_alert(alert_info, item.get('kw', 0))
alert_manager.check_and_alert(
alert_info,
item.get('kw', 0),
item.get('today'),
data_valid=is_data_valid(item)
)
if item_id:
# 크롤링 성공 기록 (항상)
@ -148,7 +155,7 @@ def integrated_monitoring(save_to_db=True, company_filter=None, force_run=False)
save_to_supabase(total_results)
# 이상 감지 로직
current_hour = datetime.now().hour
current_hour = now_kst().hour
if 10 <= current_hour <= 17:
issues = [d['name'] for d in total_results if d.get('kw', 0) == 0]
if issues:
@ -168,8 +175,7 @@ def run_daily_close(force=False):
일일 마감 집계 실행 (KST 21:00~21:10 자동 트리거 또는 force=True)
solar_logs 데이터를 집계하여 daily_stats에 당일 최종값을 확정합니다.
"""
kst = timezone(timedelta(hours=9))
kst_now = datetime.now(kst)
kst_now = now_kst()
kst_hour = kst_now.hour
kst_minute = kst_now.minute
@ -200,4 +206,3 @@ if __name__ == "__main__":
# 마감 집계: 21:00~21:10 KST 자동 실행 또는 --close 옵션
run_daily_close(force=force_close)

8
crawler/requirements.in Normal file
View File

@ -0,0 +1,8 @@
# Direct runtime dependencies for crawler, backfill, and daily summary jobs.
# Keep NumPy on the last line that supports the CI/runtime Python 3.10 baseline.
numpy==2.2.6
pandas==2.3.3
python-dateutil==2.9.0.post0
python-dotenv==1.2.1
requests==2.32.5
supabase==2.27.2

59
crawler/requirements.txt Normal file
View File

@ -0,0 +1,59 @@
# Fully resolved crawler dependency lock for Python 3.10/3.11.
# Direct dependencies and compatibility constraints are maintained in requirements.in.
annotated-types==0.8.0
anyio==4.14.2
cachetools==6.2.6
certifi==2026.7.22
cffi==2.1.1
charset-normalizer==3.4.9
click==8.4.2
colorama==0.4.6
cryptography==50.0.0
deprecation==2.1.0
fsspec==2026.7.0
h11==0.16.0
h2==4.4.1
hpack==4.2.0
httpcore==1.0.9
httpx==0.28.1
hyperframe==6.1.0
idna==3.18
markdown-it-py==4.2.0
mdurl==0.1.2
mmh3==5.2.1
multidict==6.7.1
numpy==2.2.6
packaging==26.3
pandas==2.3.3
postgrest==2.27.2
propcache==0.5.2
pycparser==3.0
pydantic==2.13.4
pydantic_core==2.46.4
Pygments==2.20.0
pyiceberg==0.11.1
PyJWT==2.13.0
pyparsing==3.3.2
pyroaring==1.1.0
python-dateutil==2.9.0.post0
python-dotenv==1.2.1
pytz==2026.3.post1
realtime==2.27.2
requests==2.32.5
rich==14.3.4
six==1.17.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.4
typing-inspection==0.4.2
typing_extensions==4.16.0
tzdata==2026.3
urllib3==2.7.0
websockets==15.0.1
yarl==1.24.5
zstandard==0.25.0

View File

@ -0,0 +1,30 @@
#!/usr/bin/env python3
"""운영 Supabase의 발전소 알림 설정을 읽기 전용으로 확인한다."""
import argparse
import os
import sys
from dotenv import load_dotenv
CURRENT_DIR = os.path.dirname(os.path.abspath(__file__))
CRAWLER_DIR = os.path.dirname(CURRENT_DIR)
sys.path.insert(0, CRAWLER_DIR)
load_dotenv(os.path.join(CRAWLER_DIR, ".env"))
from alert_manager import AlertManager
def main():
parser = argparse.ArgumentParser()
parser.add_argument("plant_id")
parser.add_argument("plant_name")
args = parser.parse_args()
enabled = AlertManager()._is_alert_enabled(args.plant_id, args.plant_name)
print(f"ALERTS_ENABLED={str(enabled).lower()}")
if __name__ == "__main__":
main()

View File

@ -0,0 +1,79 @@
#!/usr/bin/env python3
"""원본 사이트의 특정 일별 값을 DB 저장 없이 확인하는 운영 점검 도구."""
import argparse
import importlib
import os
import sys
from dotenv import load_dotenv
CURRENT_DIR = os.path.dirname(os.path.abspath(__file__))
CRAWLER_DIR = os.path.dirname(CURRENT_DIR)
sys.path.insert(0, CRAWLER_DIR)
load_dotenv(os.path.join(CRAWLER_DIR, ".env"))
from config import get_all_plants
def build_plant_map():
plants = {}
for plant in get_all_plants():
if plant.get("options", {}).get("is_split"):
for site_id, split_index in (("nrems-01", 1), ("nrems-02", 2)):
split_plant = plant.copy()
split_plant["id"] = site_id
split_plant["options"] = plant["options"].copy()
split_plant["options"]["split_index"] = split_index
plants[site_id] = split_plant
else:
plants[plant["id"]] = plant
return plants
def main():
parser = argparse.ArgumentParser()
parser.add_argument(
"targets",
nargs="+",
metavar="PLANT_ID=YYYY-MM-DD",
help="확인할 발전소 ID와 날짜",
)
args = parser.parse_args()
plants = build_plant_map()
failed = False
for target in args.targets:
site_id, separator, date = target.partition("=")
if not separator or site_id not in plants:
print(f"INVALID {target}")
failed = True
continue
plant = plants[site_id]
module = importlib.import_module(f"crawlers.{plant['type']}")
data = module.fetch_history_daily(plant, date, date)
if data is None:
print(f"ERROR {site_id} {date}: request or parse failure")
failed = True
continue
matched = [row for row in data if row.get("plant_id") == site_id]
if not matched:
print(f"NO_DATA {site_id} {date}")
continue
for row in matched:
print(
f"OK {site_id} {row.get('date')}: "
f"{float(row.get('generation_kwh', 0)):.2f} kWh"
)
raise SystemExit(1 if failed else 0)
if __name__ == "__main__":
main()

View File

@ -0,0 +1,146 @@
"""Read-only consistency check for daily_stats and monthly_stats.
Usage:
python tests/check_stats_consistency.py --start-month 2026-01 --end-month 2026-08
"""
from __future__ import annotations
import argparse
from collections import defaultdict
from pathlib import Path
import sys
from dotenv import load_dotenv
CRAWLER_DIR = Path(__file__).resolve().parents[1]
load_dotenv(CRAWLER_DIR / ".env")
if str(CRAWLER_DIR) not in sys.path:
sys.path.insert(0, str(CRAWLER_DIR))
from database import get_supabase_client # noqa: E402
PAGE_SIZE = 1000
def fetch_all(query):
"""Fetch all PostgREST rows without silently stopping at its page limit."""
rows = []
offset = 0
while True:
page = query.range(offset, offset + PAGE_SIZE - 1).execute().data or []
rows.extend(page)
if len(page) < PAGE_SIZE:
return rows
offset += PAGE_SIZE
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--start-month", default="2026-01")
parser.add_argument("--end-month", default="2026-08")
parser.add_argument("--limit", type=int, default=100)
args = parser.parse_args()
client = get_supabase_client()
if client is None:
print("Supabase connection is not configured.")
return 2
start_date = f"{args.start_month}-01"
end_date = f"{args.end_month}-31"
daily_query = (
client.table("daily_stats")
.select("plant_id,date,total_generation")
.gte("date", start_date)
.lte("date", end_date)
.order("date")
)
daily_rows = fetch_all(daily_query)
try:
monthly_query = (
client.table("monthly_stats")
.select("plant_id,month,total_generation,last_date,source")
.gte("month", args.start_month)
.lte("month", args.end_month)
.order("month")
)
monthly_rows = fetch_all(monthly_query)
schema_version = "consistent"
except Exception:
monthly_query = (
client.table("monthly_stats")
.select("plant_id,month,total_generation,currnet_last_date")
.gte("month", args.start_month)
.lte("month", args.end_month)
.order("month")
)
monthly_rows = fetch_all(monthly_query)
schema_version = "legacy"
daily_totals = defaultdict(float)
daily_counts = defaultdict(int)
daily_last_dates = {}
for row in daily_rows:
key = (row["plant_id"], str(row["date"])[:7])
daily_totals[key] += float(row.get("total_generation") or 0)
daily_counts[key] += 1
daily_last_dates[key] = max(
daily_last_dates.get(key, ""),
str(row["date"]),
)
monthly_map = {
(row["plant_id"], row["month"]): float(row.get("total_generation") or 0)
for row in monthly_rows
}
all_keys = sorted(set(daily_totals) | set(monthly_map))
mismatches = []
missing_monthly = []
monthly_without_daily = []
for key in all_keys:
if key not in monthly_map:
missing_monthly.append(key)
continue
if key not in daily_totals:
monthly_without_daily.append(key)
continue
difference = round(monthly_map[key] - daily_totals[key], 2)
if abs(difference) > 0.01:
mismatches.append((key, daily_totals[key], monthly_map[key], difference))
invalid_last_date = sum(
1
for row in monthly_rows
if schema_version == "legacy" and row.get("currnet_last_date")
)
print(
"summary "
f"schema={schema_version} "
f"daily_rows={len(daily_rows)} monthly_rows={len(monthly_rows)} "
f"pairs={len(all_keys)} mismatches={len(mismatches)} "
f"missing_monthly={len(missing_monthly)} "
f"monthly_without_daily={len(monthly_without_daily)} "
f"legacy_last_date_non_null={invalid_last_date}"
)
for (plant_id, month), daily_total, monthly_total, difference in mismatches[: args.limit]:
print(
f"mismatch plant={plant_id} month={month} days={daily_counts[(plant_id, month)]} "
f"last_date={daily_last_dates[(plant_id, month)]} "
f"daily={daily_total:.2f} monthly={monthly_total:.2f} diff={difference:+.2f}"
)
for plant_id, month in missing_monthly[: args.limit]:
print(f"missing_monthly plant={plant_id} month={month}")
for plant_id, month in monthly_without_daily[: args.limit]:
print(f"monthly_without_daily plant={plant_id} month={month}")
return 1 if mismatches or missing_monthly else 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,9 @@
<html>
<body>
<table>
<tbody>
<tr class="odd"><td>5</td><td>1,051.25</td><td>fixture</td></tr>
</tbody>
</table>
</body>
</html>

View File

@ -0,0 +1,13 @@
{
"datas": {
"solraMonthWork": {
"runData": [
"10.0",
"20.0",
"30.0",
"40.0",
"133.60"
]
}
}
}

10
crawler/tests/fixtures/kremc_daily.json vendored Normal file
View File

@ -0,0 +1,10 @@
{
"data": {
"userByTimeDataResultDtoList": [
{
"gathDtm": "2026-08-05 00:00:00",
"dayEnergy": "53.25"
}
]
}
}

View File

@ -0,0 +1,3 @@
{
"data": "fixture-auth-token"
}

View File

@ -0,0 +1,8 @@
{
"pdata": [
{
"DATE": "08-05",
"INV": "121.50"
}
]
}

View File

@ -0,0 +1,9 @@
<html>
<body>
<table>
<tbody>
<tr><td>2026-08-05</td><td>52.59</td></tr>
</tbody>
</table>
</body>
</html>

View File

@ -0,0 +1,127 @@
import sqlite3
import sys
import tempfile
import unittest
from contextlib import closing
from datetime import datetime
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import Mock
from unittest.mock import patch
CRAWLER_DIR = Path(__file__).resolve().parents[1]
if str(CRAWLER_DIR) not in sys.path:
sys.path.insert(0, str(CRAWLER_DIR))
from alert_manager import AlertManager
from crawlers.base import is_data_valid
class AlertManagerTest(unittest.TestCase):
def setUp(self):
self.temp_dir = tempfile.TemporaryDirectory()
db_path = Path(self.temp_dir.name) / "alert_test.db"
self.manager = AlertManager(
db_path=db_path,
now_provider=lambda: datetime(2026, 8, 5, 12, 0, 0)
)
self.real_is_alert_enabled = self.manager._is_alert_enabled
self.manager._is_alert_enabled = Mock(return_value=True)
self.manager.send_telegram_message = Mock(return_value=True)
self.plant = {
"id": "nrems-03",
"name": "3호기",
"display_name": "3호기",
"telegram_chat_id": 1234,
}
def tearDown(self):
self.temp_dir.cleanup()
def get_state(self):
with closing(sqlite3.connect(self.manager.db_path)) as conn:
return conn.execute("""
SELECT alert_status, zero_count, first_zero_today_kwh
FROM alert_history
WHERE site_id = ?
""", (self.plant["id"],)).fetchone()
def test_collection_error_breaks_zero_sequence(self):
self.manager.check_and_alert(self.plant, 0, 100)
self.manager.check_and_alert(self.plant, 0, 100)
self.assertEqual(("NORMAL", 2, 100.0), self.get_state())
self.manager.check_and_alert(
self.plant,
0,
0,
data_valid=False
)
self.assertEqual(("NORMAL", 0, None), self.get_state())
self.manager.check_and_alert(self.plant, 0, 100)
self.manager.check_and_alert(self.plant, 0, 100)
self.manager.send_telegram_message.assert_not_called()
self.manager.check_and_alert(self.plant, 0, 100)
self.manager.send_telegram_message.assert_called_once()
self.assertEqual(("ALERT", 3, 100.0), self.get_state())
def test_cumulative_generation_growth_clears_zero_suspicion(self):
self.manager.check_and_alert(self.plant, 0, 100)
self.manager.check_and_alert(self.plant, 0, 101)
self.manager.send_telegram_message.assert_not_called()
self.assertEqual(("NORMAL", 0, None), self.get_state())
def test_disabled_alert_does_not_create_alert_state(self):
self.manager._is_alert_enabled.return_value = False
for _ in range(3):
self.manager.check_and_alert(self.plant, 0, 100)
self.manager.send_telegram_message.assert_not_called()
self.assertIsNone(self.get_state())
def test_positive_generation_recovers_alert_state(self):
for _ in range(3):
self.manager.check_and_alert(self.plant, 0, 100)
self.assertEqual("ALERT", self.get_state()[0])
self.manager.check_and_alert(self.plant, 10, 101)
self.assertEqual(("NORMAL", 0, None), self.get_state())
def test_alert_setting_uses_globally_unique_plant_id(self):
query = Mock()
query.select.return_value = query
query.eq.return_value = query
query.limit.return_value = query
query.execute.return_value = SimpleNamespace(
data=[{"alerts_enabled": False}]
)
client = Mock()
client.table.return_value = query
database_module = SimpleNamespace(
get_supabase_client=Mock(return_value=client)
)
with patch.dict(sys.modules, {"database": database_module}):
enabled = self.real_is_alert_enabled("nrems-03", "3호기")
self.assertFalse(enabled)
client.table.assert_called_once_with("plants")
query.eq.assert_called_once_with("id", "nrems-03")
def test_data_validity_distinguishes_collection_error_from_plant_fault(self):
self.assertTrue(is_data_valid({"status": "🟢 정상"}))
self.assertTrue(is_data_valid({"status": "🔴 점검/고장", "kw": 0}))
self.assertFalse(is_data_valid({"status": "🔴 오류"}))
self.assertFalse(is_data_valid({"status": "ERROR"}))
self.assertFalse(is_data_valid({"status": "🟢 정상", "data_valid": False}))
if __name__ == "__main__":
unittest.main()

View File

@ -0,0 +1,162 @@
import sqlite3
import tempfile
import unittest
from contextlib import closing
from pathlib import Path
from unittest.mock import patch
import backward_backfill
def make_plant(site_id="plant-a", start_date="2026-08-01"):
return {
"id": site_id,
"name": site_id,
"type": "fake",
"start_date": start_date,
"options": {},
}
class BackwardBackfillTest(unittest.TestCase):
def setUp(self):
self.temp_dir = tempfile.TemporaryDirectory()
self.db_path = str(Path(self.temp_dir.name) / "crawler_manager.db")
self.db_path_patch = patch.object(backward_backfill, "DB_PATH", self.db_path)
self.db_path_patch.start()
def tearDown(self):
self.db_path_patch.stop()
self.temp_dir.cleanup()
def read_state(self, site_id):
with closing(sqlite3.connect(self.db_path)) as conn:
return conn.execute(
"""
SELECT last_backfilled_date, consecutive_zero_count, status
FROM backfill_state
WHERE site_id = ?
""",
(site_id,),
).fetchone()
def init_sites(self, plants, first_target="2026-08-05"):
with patch.object(backward_backfill, "get_all_plants", return_value=plants):
backward_backfill.init_backfill_states(first_target)
def run_process(self, plants, crawler, save_result=True, days=1):
with (
patch.object(backward_backfill, "get_all_plants", return_value=plants),
patch.object(backward_backfill, "get_history_crawler", return_value=crawler),
patch.object(backward_backfill, "save_history", return_value=save_result),
):
backward_backfill.process_backfill(
max_days_per_run=days,
delay_sec=0,
)
def test_first_requested_date_is_not_skipped(self):
plant = make_plant()
requested_dates = []
self.init_sites([plant])
self.assertEqual(self.read_state("plant-a")[0], "2026-08-06")
def crawler(_plant, start_date, _end_date):
requested_dates.append(start_date)
return [{
"plant_id": "plant-a",
"date": start_date,
"generation_kwh": 12.0,
}]
self.run_process([plant], crawler)
self.assertEqual(requested_dates, ["2026-08-05"])
self.assertEqual(self.read_state("plant-a"), ("2026-08-05", 0, "RUNNING"))
def test_fetch_failure_keeps_cursor_and_zero_count(self):
plant = make_plant()
self.init_sites([plant])
backward_backfill.update_backfill_state("plant-a", "2026-08-06", 7, "RUNNING")
self.run_process([plant], lambda *_args: None)
self.assertEqual(self.read_state("plant-a"), ("2026-08-06", 7, "RUNNING"))
def test_save_failure_keeps_cursor_and_zero_count(self):
plant = make_plant()
self.init_sites([plant])
def crawler(_plant, start_date, _end_date):
return [{
"plant_id": "plant-a",
"date": start_date,
"generation_kwh": 5.0,
}]
self.run_process([plant], crawler, save_result=False)
self.assertEqual(self.read_state("plant-a"), ("2026-08-06", 0, "RUNNING"))
def test_successful_no_data_advances_without_counting_zero(self):
plant = make_plant()
self.init_sites([plant])
backward_backfill.update_backfill_state("plant-a", "2026-08-06", 4, "RUNNING")
self.run_process([plant], lambda *_args: [])
self.assertEqual(self.read_state("plant-a"), ("2026-08-05", 0, "RUNNING"))
def test_thirtieth_actual_zero_completes_site(self):
plant = make_plant(start_date="2026-01-01")
self.init_sites([plant], first_target="2026-08-30")
backward_backfill.update_backfill_state("plant-a", "2026-08-31", 29, "RUNNING")
def crawler(_plant, start_date, _end_date):
return [{
"plant_id": "plant-a",
"date": start_date,
"generation_kwh": 0.0,
}]
self.run_process([plant], crawler)
self.assertEqual(self.read_state("plant-a"), ("2026-08-30", 30, "COMPLETED"))
def test_one_site_failure_does_not_block_another_site(self):
plants = [make_plant("plant-a"), make_plant("plant-b")]
self.init_sites(plants)
def crawler(plant, start_date, _end_date):
if plant["id"] == "plant-a":
return None
return [{
"plant_id": "plant-b",
"date": start_date,
"generation_kwh": 9.0,
}]
self.run_process(plants, crawler)
self.assertEqual(self.read_state("plant-a")[0], "2026-08-06")
self.assertEqual(self.read_state("plant-b")[0], "2026-08-05")
def test_start_date_is_processed_before_completion(self):
plant = make_plant(start_date="2026-08-05")
self.init_sites([plant])
def crawler(_plant, start_date, _end_date):
return [{
"plant_id": "plant-a",
"date": start_date,
"generation_kwh": 1.0,
}]
self.run_process([plant], crawler, days=2)
self.assertEqual(self.read_state("plant-a"), ("2026-08-05", 0, "COMPLETED"))
if __name__ == "__main__":
unittest.main()

View File

@ -0,0 +1,128 @@
import sys
import unittest
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
CRAWLER_DIR = Path(__file__).resolve().parents[1]
if str(CRAWLER_DIR) not in sys.path:
sys.path.insert(0, str(CRAWLER_DIR))
import daily_summary
class FakeRpc:
def __init__(self, db, params):
self.db = db
self.params = params
def execute(self):
self.db.saved = self.params["p_records"]
self.db.rpc_params = self.params
return SimpleNamespace(data=self.db.saved)
class FakeQuery:
def __init__(self, db, table_name):
self.db = db
self.table_name = table_name
self.filters = []
self.db.queries.append(self)
def select(self, _columns):
return self
def gte(self, column, value):
self.filters.append(("gte", column, value))
return self
def lt(self, column, value):
self.filters.append(("lt", column, value))
return self
def order(self, _column, desc=False):
return self
def upsert(self, payload, on_conflict=None):
self.payload = payload
return self
def execute(self):
if self.table_name == "plants":
return SimpleNamespace(data=[{"id": "plant-a", "capacity": 100}])
if self.table_name == "solar_logs":
return SimpleNamespace(data=[{
"plant_id": "plant-a",
"current_kw": 25,
"today_kwh": 120,
"created_at": "2026-08-05T06:00:00+00:00",
}])
if self.table_name == "daily_stats":
self.db.saved = self.payload
return SimpleNamespace(data=self.payload)
raise AssertionError(f"unexpected table: {self.table_name}")
class FakeDb:
def __init__(self):
self.queries = []
self.saved = None
self.rpc_params = None
def table(self, table_name):
return FakeQuery(self, table_name)
def rpc(self, function_name, params):
if function_name != "upsert_daily_stats":
raise AssertionError(function_name)
return FakeRpc(self, params)
class DailySummaryTimezoneTest(unittest.TestCase):
def test_daily_close_uses_kst_utc_half_open_bounds(self):
db = FakeDb()
with (
patch.object(daily_summary, "get_supabase_client", return_value=db),
patch.object(daily_summary, "get_all_plants", return_value=[]),
):
result = daily_summary.calculate_daily_stats("2026-08-05")
solar_query = next(q for q in db.queries if q.table_name == "solar_logs")
self.assertIn(("gte", "created_at", "2026-08-04T15:00:00+00:00"), solar_query.filters)
self.assertIn(("lt", "created_at", "2026-08-05T15:00:00+00:00"), solar_query.filters)
self.assertTrue(result)
self.assertEqual(120, db.saved[0]["total_generation"])
self.assertEqual("daily_summary", db.rpc_params["p_source"])
self.assertFalse(db.rpc_params["p_allow_decrease"])
def test_lower_original_site_value_does_not_replace_log_maximum(self):
db = FakeDb()
plant = {
"id": "plant-a",
"type": "test",
"options": {},
}
with (
patch.object(daily_summary, "get_supabase_client", return_value=db),
patch.object(daily_summary, "get_all_plants", return_value=[plant]),
patch.object(
daily_summary,
"get_history_crawler",
return_value=lambda *_args: [{
"plant_id": "plant-a",
"date": "2026-08-05",
"generation_kwh": 100,
}],
),
):
result = daily_summary.calculate_daily_stats("2026-08-05")
self.assertTrue(result)
self.assertEqual(120, db.saved[0]["total_generation"])
if __name__ == "__main__":
unittest.main()

View File

@ -0,0 +1,171 @@
import unittest
from unittest.mock import patch
import database
class FakeResponse:
def __init__(self, data=None):
self.data = data or []
class FakeRpc:
def __init__(self, client, params):
self.client = client
self.params = params
def execute(self):
if self.client.fail_rpc:
raise RuntimeError("rpc failed")
saved = []
for row in self.params["p_records"]:
key = (row["plant_id"], row["date"])
existing = self.client.daily.get(key)
incoming = row["total_generation"]
if (
existing is None
or self.params["p_allow_decrease"]
or incoming > existing
):
self.client.daily[key] = incoming
saved.append({**row, "total_generation": self.client.daily[key]})
self.client.rpc_calls.append(self.params)
return FakeResponse(saved)
class FakeQuery:
def __init__(self, client, table_name):
self.client = client
self.table_name = table_name
self.action = None
self.payload = None
self.filters = {}
def select(self, _columns):
self.action = "select"
return self
def upsert(self, payload, on_conflict=None):
self.action = "upsert"
self.payload = payload
self.on_conflict = on_conflict
return self
def eq(self, column, value):
self.filters[column] = ("eq", value)
return self
def in_(self, column, values):
self.filters[column] = ("in", set(values))
return self
def gte(self, column, value):
self.filters[f"{column}_gte"] = ("gte", value)
return self
def lte(self, column, value):
self.filters[f"{column}_lte"] = ("lte", value)
return self
def execute(self):
if self.client.fail_select and self.action == "select":
raise RuntimeError("select failed")
if self.action == "select" and self.table_name == "daily_stats":
rows = []
for (plant_id, date), value in self.client.daily.items():
if "plant_id" in self.filters and plant_id != self.filters["plant_id"][1]:
continue
if "date" in self.filters and date not in self.filters["date"][1]:
continue
if "date_gte" in self.filters and date < self.filters["date_gte"][1]:
continue
if "date_lte" in self.filters and date > self.filters["date_lte"][1]:
continue
rows.append({
"plant_id": plant_id,
"date": date,
"total_generation": value,
})
return FakeResponse(rows)
if self.action == "upsert" and self.table_name == "daily_stats":
for row in self.payload:
self.client.daily[(row["plant_id"], row["date"])] = row["total_generation"]
self.client.daily_upserts.append(self.payload)
return FakeResponse(self.payload)
if self.action == "upsert" and self.table_name == "monthly_stats":
self.client.monthly_upserts.append(self.payload)
return FakeResponse(self.payload)
raise AssertionError(f"unexpected query: {self.table_name} {self.action}")
class FakeClient:
def __init__(self, daily=None, fail_rpc=False):
self.daily = daily or {}
self.fail_select = False
self.fail_rpc = fail_rpc
self.daily_upserts = []
self.monthly_upserts = []
self.rpc_calls = []
def table(self, table_name):
return FakeQuery(self, table_name)
def rpc(self, function_name, params):
if function_name != "upsert_daily_stats":
raise AssertionError(function_name)
return FakeRpc(self, params)
class DatabaseHistoryTest(unittest.TestCase):
def save_daily(self, client, generation):
with patch.object(database, "get_supabase_client", return_value=client):
return database.save_history([{
"plant_id": "plant-a",
"date": "2026-08-05",
"generation_kwh": generation,
}], "daily")
def test_existing_positive_value_is_not_replaced_by_zero(self):
client = FakeClient({("plant-a", "2026-08-05"): 100.0})
result = self.save_daily(client, 0.0)
self.assertTrue(result)
self.assertEqual(client.daily[("plant-a", "2026-08-05")], 100.0)
self.assertEqual(client.daily_upserts, [])
self.assertEqual(client.rpc_calls[0]["p_source"], "history")
self.assertFalse(client.rpc_calls[0]["p_allow_decrease"])
def test_larger_value_updates_existing_daily_stat(self):
client = FakeClient({("plant-a", "2026-08-05"): 100.0})
result = self.save_daily(client, 120.0)
self.assertTrue(result)
self.assertEqual(client.daily[("plant-a", "2026-08-05")], 120.0)
self.assertEqual(len(client.rpc_calls), 1)
def test_rpc_failure_prevents_write(self):
client = FakeClient(fail_rpc=True)
result = self.save_daily(client, 120.0)
self.assertFalse(result)
self.assertEqual(client.rpc_calls, [])
def test_negative_generation_is_rejected(self):
client = FakeClient()
result = self.save_daily(client, -1.0)
self.assertFalse(result)
self.assertEqual(client.rpc_calls, [])
if __name__ == "__main__":
unittest.main()

View File

@ -0,0 +1,99 @@
import unittest
from unittest.mock import patch
from crawlers import cmsolar, hyundai, kremc, nrems, sun_wms
class FakeResponse:
def __init__(self, status_code=500, data=None, headers=None):
self.status_code = status_code
self._data = data or {}
self.headers = headers or {}
self.text = ""
self.encoding = None
def json(self):
return self._data
class FakeSession:
def __init__(self, response):
self.response = response
def post(self, *_args, **_kwargs):
return self.response
def get(self, *_args, **_kwargs):
return self.response
class HistoryFetchContractTest(unittest.TestCase):
def test_nrems_http_failure_returns_none(self):
plant = {
"id": "nrems-03",
"name": "3호기",
"auth": {"pscode": "x"},
"options": {"is_split": False},
}
with patch.object(nrems, "create_session", return_value=FakeSession(FakeResponse())):
self.assertIsNone(nrems.fetch_history_daily(plant, "2026-08-05", "2026-08-05"))
def test_nrems_successful_empty_response_returns_empty_list(self):
plant = {
"id": "nrems-03",
"name": "3호기",
"auth": {"pscode": "x"},
"options": {"is_split": False},
}
response = FakeResponse(status_code=200, data={"pdata": []})
with patch.object(nrems, "create_session", return_value=FakeSession(response)):
self.assertEqual(
nrems.fetch_history_daily(plant, "2026-08-05", "2026-08-05"),
[],
)
def test_kremc_login_failure_returns_none(self):
plant = {
"id": "kremc-05",
"name": "5호기",
"auth": {},
"system": {},
"options": {},
}
with patch.object(kremc, "create_session", return_value=FakeSession(FakeResponse())):
self.assertIsNone(kremc.fetch_history_daily(plant, "2026-08-05", "2026-08-05"))
def test_sun_wms_login_failure_returns_none(self):
plant = {
"id": "sunwms-06",
"name": "6호기",
"auth": {},
"system": {},
}
# 이 함수는 내부에서 base.create_session을 다시 import한다.
with patch("crawlers.base.create_session", return_value=FakeSession(FakeResponse())):
self.assertIsNone(sun_wms.fetch_history_daily(plant, "2026-08-05", "2026-08-05"))
def test_hyundai_login_failure_returns_none(self):
plant = {
"id": "hyundai-08",
"name": "8호기",
"auth": {},
"system": {},
}
with patch.object(hyundai, "create_session", return_value=FakeSession(FakeResponse())):
self.assertIsNone(hyundai.fetch_history_daily(plant, "2026-08-05", "2026-08-05"))
def test_cmsolar_login_failure_returns_none(self):
plant = {
"id": "cmsolar-10",
"name": "10호기",
"auth": {},
"system": {},
}
with patch.object(cmsolar, "create_session", return_value=FakeSession(FakeResponse())):
self.assertIsNone(cmsolar.fetch_history_daily(plant, "2026-08-05", "2026-08-05"))
if __name__ == "__main__":
unittest.main()

View File

@ -0,0 +1,155 @@
import json
from pathlib import Path
import unittest
from unittest.mock import patch
from crawlers import cmsolar, hyundai, kremc, nrems, sun_wms
FIXTURES = Path(__file__).resolve().parent / "fixtures"
def load_json(name):
with (FIXTURES / name).open(encoding="utf-8") as fixture_file:
return json.load(fixture_file)
def load_text(name):
return (FIXTURES / name).read_text(encoding="utf-8")
class FakeResponse:
def __init__(self, *, status_code=200, data=None, text="", headers=None):
self.status_code = status_code
self._data = data or {}
self.text = text
self.headers = headers or {}
self.encoding = None
def json(self):
return self._data
class SequenceSession:
def __init__(self, *, post_responses=None, get_responses=None):
self.post_responses = list(post_responses or [])
self.get_responses = list(get_responses or [])
def post(self, *_args, **_kwargs):
if not self.post_responses:
raise AssertionError("unexpected POST request")
return self.post_responses.pop(0)
def get(self, *_args, **_kwargs):
if not self.get_responses:
raise AssertionError("unexpected GET request")
return self.get_responses.pop(0)
class HistoryResponseFixtureTest(unittest.TestCase):
def test_nrems_daily_json_fixture(self):
session = SequenceSession(post_responses=[
FakeResponse(data=load_json("nrems_daily.json")),
])
plant = {
"id": "nrems-03",
"name": "3호기",
"auth": {"pscode": "fixture"},
"options": {"is_split": False},
}
with patch.object(nrems, "create_session", return_value=session):
result = nrems.fetch_history_daily(plant, "2026-08-05", "2026-08-05")
self.assertEqual(result, [{
"plant_id": "nrems-03",
"date": "2026-08-05",
"generation_kwh": 121.5,
}])
def test_kremc_daily_json_fixture(self):
session = SequenceSession(
post_responses=[FakeResponse(data=load_json("kremc_login.json"))],
get_responses=[FakeResponse(data=load_json("kremc_daily.json"))],
)
plant = {
"id": "kremc-05",
"name": "5호기",
"auth": {"user_id": "fixture", "password": "fixture"},
"system": {"login_url": "https://fixture/login", "api_base": "https://fixture"},
"options": {},
}
with patch.object(kremc, "create_session", return_value=session):
result = kremc.fetch_history_daily(plant, "2026-08-05", "2026-08-05")
self.assertEqual(result[0]["date"], "2026-08-05")
self.assertEqual(result[0]["generation_kwh"], 53.25)
def test_hyundai_daily_json_fixture(self):
session = SequenceSession(
post_responses=[FakeResponse(headers={"x-auth-token": "fixture-token"})],
get_responses=[FakeResponse(data=load_json("hyundai_daily.json"))],
)
plant = {
"id": "hyundai-08",
"name": "8호기",
"auth": {"user_id": "fixture", "password": "fixture", "site_id": "fixture"},
"system": {"base_url": "https://fixture", "login_path": "/login"},
}
with patch.object(hyundai, "create_session", return_value=session):
result = hyundai.fetch_history_daily(plant, "2026-08-05", "2026-08-05")
self.assertEqual(result, [{
"plant_id": "hyundai-08",
"date": "2026-08-05",
"generation_kwh": 133.6,
}])
def test_sun_wms_daily_html_fixture(self):
session = SequenceSession(
post_responses=[FakeResponse()],
get_responses=[FakeResponse(text=load_text("sun_wms_daily.html"))],
)
plant = {
"id": "sunwms-06",
"name": "6호기",
"auth": {"payload_id": "fixture", "payload_pw": "fixture"},
"system": {
"login_url": "https://fixture/login",
"base_url": "https://fixture",
"statics_url": "https://fixture/statics",
},
}
with patch("crawlers.base.create_session", return_value=session):
result = sun_wms.fetch_history_daily(plant, "2026-08-05", "2026-08-05")
self.assertEqual(result[0]["date"], "2026-08-05")
self.assertEqual(result[0]["generation_kwh"], 52.59)
def test_cmsolar_daily_html_fixture(self):
session = SequenceSession(
post_responses=[FakeResponse()],
get_responses=[
FakeResponse(),
FakeResponse(text=load_text("cmsolar_daily.html")),
],
)
plant = {
"id": "cmsolar-10",
"name": "10호기",
"auth": {"login_id": "fixture", "login_pw": "fixture", "site_no": "fixture"},
"system": {"login_url": "https://fixture/login", "api_url": "https://fixture"},
}
with patch.object(cmsolar, "create_session", return_value=session):
result = cmsolar.fetch_history_daily(plant, "2026-08-05", "2026-08-05")
self.assertEqual(result[0]["date"], "2026-08-05")
self.assertEqual(result[0]["generation_kwh"], 1051.25)
if __name__ == "__main__":
unittest.main()

View File

@ -0,0 +1,84 @@
import sqlite3
import sys
import tempfile
import unittest
from contextlib import closing
from datetime import date, datetime, timezone
from pathlib import Path
CRAWLER_DIR = Path(__file__).resolve().parents[1]
if str(CRAWLER_DIR) not in sys.path:
sys.path.insert(0, str(CRAWLER_DIR))
from crawler_manager import CrawlerManager
from time_utils import (
KST,
ensure_kst,
kst_day_bounds_utc,
parse_legacy_kst_datetime,
)
class TimeUtilsTest(unittest.TestCase):
def test_new_year_kst_day_uses_previous_utc_date(self):
start, end = kst_day_bounds_utc(date(2026, 1, 1))
self.assertEqual("2025-12-31T15:00:00+00:00", start.isoformat())
self.assertEqual("2026-01-01T15:00:00+00:00", end.isoformat())
def test_month_end_is_half_open_at_next_kst_midnight(self):
start, end = kst_day_bounds_utc(date(2026, 8, 31))
self.assertEqual("2026-08-30T15:00:00+00:00", start.isoformat())
self.assertEqual("2026-08-31T15:00:00+00:00", end.isoformat())
def test_legacy_naive_sqlite_datetime_is_treated_as_kst(self):
parsed = parse_legacy_kst_datetime("2026-08-06T09:00:00")
self.assertEqual(KST, parsed.tzinfo)
self.assertEqual(9, parsed.hour)
def test_aware_utc_datetime_is_converted_to_kst(self):
parsed = ensure_kst(datetime(2026, 8, 5, 15, 0, tzinfo=timezone.utc))
self.assertEqual(date(2026, 8, 6), parsed.date())
self.assertEqual(0, parsed.hour)
class CrawlerManagerTimezoneTest(unittest.TestCase):
def setUp(self):
self.temp_dir = tempfile.TemporaryDirectory()
self.current = datetime(2026, 8, 5, 19, 59, tzinfo=timezone.utc)
self.manager = CrawlerManager(
db_path=Path(self.temp_dir.name) / "crawler_manager.db",
now_provider=lambda: self.current,
)
def tearDown(self):
self.temp_dir.cleanup()
def test_night_window_uses_kst_when_host_clock_is_utc(self):
# UTC 19:59 == KST 04:59
self.assertFalse(self.manager.should_run("plant-a"))
# UTC 20:00 == KST 05:00
self.current = datetime(2026, 8, 5, 20, 0, tzinfo=timezone.utc)
self.assertTrue(self.manager.should_run("plant-a"))
def test_legacy_naive_heartbeat_value_can_be_compared(self):
self.current = datetime(2026, 8, 6, 1, 0, tzinfo=timezone.utc) # KST 10:00
self.assertTrue(self.manager.should_save("plant-a", {"kw": 10, "today": 20}))
with closing(sqlite3.connect(self.manager.db_path)) as conn:
conn.execute(
"UPDATE site_data SET updated_at = ? WHERE site_id = ?",
("2026-08-06T08:59:59", "plant-a"),
)
conn.commit()
self.assertTrue(self.manager.should_save("plant-a", {"kw": 10, "today": 20}))
if __name__ == "__main__":
unittest.main()

44
crawler/time_utils.py Normal file
View File

@ -0,0 +1,44 @@
"""KST 기준 시간 판단과 Supabase 조회 경계를 제공한다."""
from datetime import date, datetime, time, timedelta, timezone
from typing import Tuple
KST = timezone(timedelta(hours=9), name="KST")
UTC = timezone.utc
def ensure_kst(value: datetime) -> datetime:
"""datetime을 KST aware 값으로 변환한다.
기존 SQLite에 저장된 naive 값은 서버 로컬 시간이 아니라 KST로 기록된
값이므로 KST로 간주한다.
"""
if value.tzinfo is None:
return value.replace(tzinfo=KST)
return value.astimezone(KST)
def now_kst() -> datetime:
"""서버 OS 시간대와 무관한 현재 KST 시각을 반환한다."""
return datetime.now(KST)
def today_kst() -> date:
return now_kst().date()
def yesterday_kst() -> date:
return today_kst() - timedelta(days=1)
def parse_legacy_kst_datetime(value: str) -> datetime:
"""SQLite의 기존 naive ISO 값과 신규 aware ISO 값을 모두 KST로 읽는다."""
return ensure_kst(datetime.fromisoformat(value))
def kst_day_bounds_utc(target_date: date) -> Tuple[datetime, datetime]:
"""KST 하루의 UTC 반개구간 [start, next_start)을 반환한다."""
start_kst = datetime.combine(target_date, time.min, tzinfo=KST)
next_start_kst = start_kst + timedelta(days=1)
return start_kst.astimezone(UTC), next_start_kst.astimezone(UTC)

View File

@ -0,0 +1,37 @@
# HTTP -> HTTPS
server {
listen 80;
server_name solorpower.dadot.net;
if ($host = solorpower.dadot.net) {
return 301 https://$host$request_uri;
}
return 404;
}
# HTTPS application server
server {
listen 443 ssl;
server_name solorpower.dadot.net;
ssl_certificate /etc/letsencrypt/live/solorpower.dadot.net/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/solorpower.dadot.net/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
# FastAPI endpoints. Keep this before the SPA fallback.
location ~ ^/(?:plants(?:/|$)|docs(?:/|$)|redoc(?:/|$)|openapi\.json$|health$) {
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# React Native Web SPA
location / {
root /var/www/html/dist;
index index.html;
try_files $uri $uri/ /index.html;
}
}

View File

@ -6,6 +6,10 @@
## 1. 백필 스케줄 및 인프라 현황
* **서버 이름:** `holdem-server`
* **공인 IP:** `140.245.73.212`
* **접속 계정:** `ubuntu`
* **SSH 키 경로:** `C:\Users\haneu\.ssh\holdem_server.key`
* **배포 위치:** Oracle Cloud 서버 `~/solorpower/crawler/`
* **실행 엔진:** 시스템 파이썬 (`/usr/bin/python3`)
* **스케줄 설정 (Crontab):** 매시간 30분 작동
@ -14,6 +18,12 @@
```
* **1일 수집 속도:** 24회 * 5일 = **하루 약 120일치(4개월 분량)** 역추적 수집 진행
### 💻 서버 접속 명령어
```powershell
ssh -i "C:\Users\haneu\.ssh\holdem_server.key" ubuntu@140.245.73.212
```
---
## 2. 향후 모니터링 및 체크 방법 (서버 터미널용)
@ -26,10 +36,18 @@ tail -n 50 -f ~/solorpower/crawler/backfill_cron.log
### 2-2. SQLite 진행 상태(State) 조회
각 발전소의 마지막 백필 날짜와 연속 무발전 일수, 진행 상태(`RUNNING` 또는 `COMPLETED`)를 데이터베이스에서 덤프합니다.
* **리눅스(서버 터미널)에서 실행 시:**
```bash
/usr/bin/python3 -c "import sqlite3; conn = sqlite3.connect('/home/ubuntu/solorpower/crawler/crawler_manager.db'); cursor = conn.cursor(); cursor.execute('SELECT * FROM backfill_state'); [print(f'발전소: {r[0]:<12} | 마지막날짜: {r[1]} | 연속무발전: {r[2]} | 상태: {r[3]}') for r in cursor.fetchall()]; conn.close()"
```
* **윈도우(로컬 PowerShell)에서 원격으로 실행 시 (따옴표 에러 방지):**
```powershell
ssh -i "C:\Users\haneu\.ssh\holdem_server.key" ubuntu@140.245.73.212 "export PYTHONUTF8=1 && /usr/bin/python3 -c \"import sqlite3; conn = sqlite3.connect('/home/ubuntu/solorpower/crawler/crawler_manager.db'); cursor = conn.cursor(); cursor.execute('SELECT * FROM backfill_state'); [print(f'발전소: {r[0]:<12} | 마지막날짜: {r[1]} | 연속무발전: {r[2]} | 상태: {r[3]}') for r in cursor.fetchall()]; conn.close()\""
```
---
## 3. 내일 아침 실데이터 교차 검증 (샘플링 비교)
@ -68,8 +86,22 @@ cd ~/solorpower/crawler && export PYTHONUTF8=1 && /usr/bin/python3 daily_summary
## 4. 트러블슈팅 및 특이 사항 관리
1. **연속 30일 무발전 마감 (`COMPLETED`)**:
- 만약 특정 발전소의 상태가 너무 빨리 `COMPLETED`로 변경되었다면, 원본 사이트에서 제공하는 과거 이력 조회 가능 일수가 30일 미만으로 제한되어 수집이 마감된 것일 수 있습니다.
- 이때는 수동으로 복구할 수 없으며, 해당 사이트 정책상의 한계입니다.
- 성공적으로 조회된 실제 0kWh만 연속 일수에 포함됩니다.
- 정상 응답에 해당 날짜가 없거나 요청·로그인·파싱이 실패한 경우에는 30일 종료 조건에 포함되지 않습니다.
2. **백필 도중 IP 차단 또는 일시적 네트워크 에러**:
- 크롤러 모듈 실행 에러가 일시적으로 나더라도 스크립트는 해당 일자에 대한 상태를 `RUNNING`으로 유지하며 넘어가거나 에러를 기록합니다.
- 크론 스케줄에 의해 다음 시간에 다시 시도되므로 일시적인 순단 현상은 자동으로 치유됩니다.
- 해당 발전소의 진행 커서와 무발전 횟수를 변경하지 않고 그 발전소의 이번 실행을 중단합니다.
- 다른 발전소는 계속 처리하며, 다음 cron 실행에서 실패한 발전소의 같은 날짜부터 재시도합니다.
3. **Supabase 조회 또는 저장 실패**:
- 진행 커서를 갱신하지 않으므로 다음 실행에서 같은 날짜가 다시 처리됩니다.
- 기존 일간 통계보다 작거나 같은 값은 기존 값을 유지하고, 더 큰 값만 갱신합니다.
---
## 5. 2026-08-06 운영 검증 결과
- 9개 발전소의 `backfill_state`는 모두 `COMPLETED`이며 실행 중인 작업은 없습니다.
- KREMC 원본 첫 데이터는 2018-10-09 86kWh로 DB 최초 날짜와 일치합니다.
- CMSolar 원본 첫 데이터는 2020-09-22 50kWh로 DB 최초 날짜와 일치합니다.
- 2026-08-05 원본 값은 KREMC 160kWh, CMSolar 127kWh로 운영 DB 및 일일 마감 결과와 일치합니다.
- 운영 서버에서 백필·저장 보호·사이트 오류 계약을 포함한 테스트 23개가 통과했습니다.
- 완료된 백필 cron은 매시간 무작업 실행 중입니다. 비활성화는 운영 스케줄 변경이므로 별도 결정 후 수행합니다.

View File

@ -0,0 +1,76 @@
# 개발 환경 및 테스트 가이드
이 문서는 새 개발 환경과 CI에서 SolarPower 프로젝트를 같은 방법으로 설치하고 검증하기 위한 기준이다. 실제 Supabase 자격 증명이나 운영 서버 연결 없이도 기본 회귀 테스트를 실행할 수 있다.
## 지원 환경
- Crawler: Python 3.10 또는 3.11
- API: Python 3.11
- App: Node.js 20, npm
- DB migration test: PostgreSQL 15 (`psql` 또는 Docker 사용)
프로젝트의 `.env` 파일은 설치 및 단위 테스트에 필요하지 않다. 운영 작업을 수행할 때만 별도로 주입한다.
## Crawler
`crawler/requirements.in`에는 crawler, 과거 데이터 복구, 일일 통계 작업의 직접 의존성과 Python 3.10 호환 제약이 기록되어 있다. `crawler/requirements.txt`는 새 환경에서 해석하고 검증한 전체 고정 버전 목록이며 설치와 CI는 이 파일을 사용한다.
```powershell
cd crawler
python -m venv .venv
.\.venv\Scripts\python -m pip install -r requirements.txt
.\.venv\Scripts\python -m unittest discover -s tests -p "test_*.py" -v
```
Linux/macOS에서는 마지막 두 명령의 Python 경로를 `.venv/bin/python`으로 바꾼다. 외부 사이트 응답 파서 테스트는 `crawler/tests/fixtures/`의 고정 응답을 사용하므로 네트워크나 실제 계정에 의존하지 않는다.
## API server
`api_server/requirements.in`은 직접 의존성의 기준이고, `api_server/requirements.txt`는 배포 및 CI가 사용하는 고정 버전 목록이다.
```powershell
cd api_server
python -m venv .venv
.\.venv\Scripts\python -m pip install -r requirements.txt
$env:SUPABASE_URL = "http://localhost:54321"
$env:SUPABASE_KEY = "test-key"
$env:DEBUG = "false"
.\.venv\Scripts\python -m unittest discover -s tests -p "test_*.py" -v
```
위 값은 설정 로딩을 위한 테스트용 값이며 실제 DB 연결에는 사용되지 않는다.
## App
`package-lock.json`을 저장소에 포함하고 CI와 새 환경에서는 `npm ci`를 사용한다.
```powershell
cd app
npm ci
npm run test:ci
```
현재 `test:ci`는 Expo 웹 export를 수행하여 의존성, 번들링, 정적 산출물 생성을 함께 검사한다. 출력 디렉터리는 `app/dist/`이며 Git에서 제외된다.
## DB migration
통계 쓰기 일관성 migration은 독립 PostgreSQL 15 DB에서 다음 계약 테스트로 확인한다.
```powershell
$env:PGPASSWORD = "postgres"
cd supabase/tests
psql -h localhost -U postgres -d postgres -f stats_write_consistency_test.sql
```
테스트 SQL이 필요한 최소 스키마를 만들고 migration을 적용한 뒤 RPC, 일별·월별 집계 trigger와 기존 데이터 보존 조건을 검증한다.
## CI 기준
`.github/workflows/ci.yml`은 push와 pull request마다 다음 작업을 독립 실행한다.
1. Crawler: Python 3.10/3.11 전체 단위·fixture 테스트
2. API: Python 3.11 전체 단위 테스트
3. Database: PostgreSQL 15 migration 계약 테스트
4. App: Node 20에서 `npm ci` 후 Expo 웹 build
CI에는 운영 비밀값을 등록하지 않는다. 실제 외부 사이트, 운영 Supabase, Telegram 전송은 배포 후 별도의 운영 확인 절차로 검증한다.

View File

@ -0,0 +1,743 @@
# SolarPower 개선 작업 계획
작성일: 2026-08-06
## 1. 목적
현재 운영 중인 SolarPower 시스템을 중단 없이 개선하기 위한 순차 작업 계획이다. 데이터 유실 또는 잘못된 집계를 일으킬 수 있는 문제를 먼저 처리하고, 이후 앱과 API의 기능 일관성, 시간대, 유지보수성과 테스트 체계를 개선한다.
인증 토큰 교체, 외부 서비스 계정의 환경변수 이전, Git 이력 정리 등 비밀정보 관련 작업은 현재 저장소를 단독으로 사용하고 있다는 전제하에 후순위로 보류한다. API 인증과 Supabase 접근 정책 강화도 별도 보안 단계에서 함께 검토한다.
## 현재 진행 현황
최종 갱신: 2026-08-07 12:01 KST
| 단계 | 작업 | 상태 | 현재 결과 |
|---:|---|---|---|
| 0 | 운영 서버 연결 및 배포 기준선 점검 | 완료 | 외부 사이트, NAS 프록시, 크롤러, Supabase, FastAPI, Nginx와 웹 연결 경로 확인 |
| 1 | 역방향 백필 데이터 무결성 | 완료·운영 반영 | 실패 시 커서 유지, 실제 0·데이터 없음·조회 오류 분리, 기존 정상값 보호 |
| 2 | 발전소 알림 설정 연동 | 완료·운영 반영 | `alerts_enabled` 연동, 3회 연속 0kW 판정, 수집 오류 오탐 제외 및 실제 설정 ON/OFF 검증 |
| 3 | Excel 업로드 화면과 API 계약 | 완료·운영 반영 | 일간·월간 업로드 통합, 입력 검증과 4xx 응답 정리, Web/API 배포 완료 |
| 4 | KST 시간대 처리 통일 | 완료·운영 반영 | 크롤러·API의 KST 판단 통일, UTC 반개구간 조회와 자정·월말·연말 검증 완료 |
| 5 | API 정확성과 응답 규약 | 완료·운영 반영 | 문자열 ID, 입력 오류, 실제 DB health, 응답 모델, 최신 로그 단일 조회와 동기 호출 처리 완료 |
| 6 | 통계 생성 경로 일관성 | 완료·운영 반영 | 공통 일간 저장 정책, 월간 자동 집계, 출처·갱신 시각과 오타 컬럼 마이그레이션 완료 |
| 7 | 테스트와 재현 가능한 개발 환경 | 완료·운영 반영 | Python/npm 의존성 고정, 5개 사이트 응답 fixture, 4개 CI job과 새 환경 설치 검증 완료 |
| 8 | 저장소 및 코드 구조 정리 | 대기 | 기능·데이터 안정화 후 진행 |
현재 운영 상태:
- 2026-08-05 NREMS 3·4·9호기 알림은 발전소 장애가 아니라 NAS 프록시 타임아웃을 오류성 0kW로 처리한 오탐으로 확인했고, 오류 데이터를 알림 판정에서 제외하도록 수정했다.
- 운영 API와 Nginx는 active 상태이며 공개 health는 실제 Supabase 조회를 포함한 JSON을 반환하고 전체 비교 통계와 시간별 통계도 HTTP 200을 반환한다.
- 2026-08-06 18:00 KST 정기 크롤링에서 9개 발전소 연결이 모두 성공했고 수집 오류나 텔레그램 오탐은 발생하지 않았다.
- 2026-08-07 10:50 KST 정기 크롤링에서 새 통계 저장 함수로 9개 발전소의 실시간·일간 저장이 모두 성공했고, 일·월 통계 72쌍이 전부 일치했다.
- 2026-08-07 12:00 KST 정기 크롤링은 SQLite 연결 종료 수정 반영 후 9개 발전소가 모두 정상 상태였고, 변경된 8개 발전소의 로그·일간 통계를 오류 없이 저장했다.
- 크롤러 시간대·알림·백필·통계·응답 fixture 회귀 테스트 36개가 Windows, Linux 컨테이너와 운영 서버에서 통과했고 API 계약·시간대·업로드 회귀 테스트 31개도 새 Python 환경에서 통과했다.
- 1~6단계 배포 전 운영 파일은 단계별 `deploy_backups/20260806_*`, `deploy_backups/20260807_*` 디렉터리에 보존했다.
- 완료된 백필 cron은 현재도 매시간 무작업 실행 중이며, 비활성화 여부는 별도 운영 결정으로 남겨 두었다.
- 인증 토큰 교체, 환경변수 정리, API 인증과 Supabase 접근 정책 강화는 사용자 결정에 따라 후순위로 보류 중이다.
- 현재 변경은 로컬과 운영 서버의 미커밋 작업 트리에 존재하며 Git 커밋은 아직 생성하지 않았다.
다음 진행 기준:
- 다음 구현 단계는 8단계 `저장소 및 코드 구조 정리`이다.
- 단계별 세부 원인, 수정 내용, 테스트와 배포 기록은 이 문서의 `6. 운영 서버 기준선 점검 결과``7. 변경 기록`에 보존한다.
## 2. 작업 원칙
- 각 단계는 원인 재현, 수정, 로컬 검증, 운영 반영 여부 확인 순서로 진행한다.
- 데이터 변경 로직은 실패 시 진행 상태를 갱신하지 않는 것을 기본 원칙으로 한다.
- 모든 날짜와 시간 판단은 KST 기준을 명시하고, DB 조회 시 UTC로 변환한다.
- 현재 작업 트리의 미커밋 변경을 보존하고 관련 파일을 수정할 때 먼저 diff를 확인한다.
- 한 단계가 검증되기 전에는 다음 단계와 한 커밋에 섞지 않는다.
- 운영 DB를 사용하는 검증은 dry-run 또는 조회 전용 확인을 먼저 수행한다.
## 3. 작업 순서
### 0단계: 운영 서버 연결 및 배포 기준선 점검
상태: 완료
대상:
- Oracle Cloud 운영 서버
- Nginx 및 FastAPI systemd 서비스
- 실시간 크롤러 및 역방향 백필 cron
- Tailscale 및 NAS 프록시 경로
- FastAPI/크롤러의 Supabase 연결
- 운영 웹 배포 디렉터리
검토 항목:
- SSH 접속, 서버 시간대, 호스트 정보와 기본 자원 상태를 확인한다.
- 운영 배포 경로, Git 브랜치, 마지막 커밋과 미커밋 변경을 확인한다.
- `solar-api`, Nginx 서비스 상태와 실제 리슨 포트를 확인한다.
- 로컬 및 공개 주소의 health/API 응답을 각각 확인한다.
- API 데이터 조회를 통해 FastAPI에서 Supabase까지의 실제 연결을 간접 검증한다.
- Nginx의 정적 웹 루트, API 프록시 경로와 TLS 인증서 상태를 확인한다.
- 실시간 크롤러, 일일 집계, 백필 cron 등록 내용과 최근 로그를 확인한다.
- Tailscale 연결 상태와 NAS 프록시 도달 가능 여부를 확인한다.
- 로컬 저장소와 운영 배포본의 커밋 및 미커밋 차이를 기록한다.
- 점검 과정에서 비밀 환경변수의 실제 값은 출력하지 않는다.
완료 기준:
- 앱에서 외부 모니터링 사이트까지 이어지는 각 연결 구간의 성공 또는 실패 상태가 기록된다.
- 현재 운영 중인 코드 버전과 자동 실행 스케줄을 식별한다.
- 서비스 장애 또는 배포 차이를 발견하면 코드 수정 전에 영향도와 복구 방법을 정리한다.
- 운영 상태를 변경하는 명령 없이 읽기 전용 점검을 완료한다.
### 1단계: 역방향 백필 데이터 무결성
상태: 완료
대상:
- `crawler/backward_backfill.py`
- `crawler/database.py`
- `crawler/crawler_manager.py`
- 백필 관련 테스트 및 운영 문서
검토 및 수정 항목:
- 초기 기준일을 첫 수집 전에 하루 차감하여 어제 데이터가 누락되는 커서 의미를 수정한다.
- 원본의 실제 0kWh, 원본 데이터 없음, 네트워크/파싱 오류를 서로 다른 결과로 구분한다.
- 네트워크 오류를 연속 무발전 일수에 포함하지 않는다.
- Supabase 저장 성공을 확인한 경우에만 SQLite 진행 날짜를 갱신한다.
- 기존 양수 데이터를 0 또는 더 작은 값으로 덮어쓰지 않도록 백필 저장 정책을 정의한다.
- 발전소 한 곳의 오류가 다른 발전소의 백필 진행을 막지 않도록 유지한다.
- 재시작, 부분 실패, 가동개시일 도달, 30일 연속 실제 무발전 조건을 테스트한다.
완료 기준:
- 지정한 시작일이 첫 번째 수집 대상이 된다.
- 요청 또는 DB 저장 실패 후 재실행하면 같은 날짜부터 재시도한다.
- 30일 연속 종료 조건은 성공적으로 조회된 실제 0값에만 적용된다.
- 기존 정상 통계가 백필의 오류성 0값으로 덮어써지지 않는다.
### 2단계: 발전소 알림 설정 연동
상태: 완료
대상:
- `crawler/config.py`
- `crawler/sync_plants.py`
- `crawler/alert_manager.py`
- `crawler/main.py`
검토 및 수정 항목:
- 크롤러의 문자열 업체 키(`sunwind`)와 DB의 숫자형 `company_id`를 분리한다.
- 발전소 ID가 DB 기본키이므로 알림 활성화 조회에 업체 조건이 실제로 필요한지 검토한다.
- 앱에서 `alerts_enabled=false`로 변경했을 때 크롤러가 알림을 보내지 않는지 검증한다.
- 현재 미커밋 상태인 3회 연속 0kW 및 누적 발전량 증가 기반 오탐 방지 로직을 상태 전이 테스트로 검증한다.
- Supabase 조회 실패 시 알림을 계속 진행할지 중단할지 명시적인 장애 정책을 정한다.
완료 기준:
- 앱에서 알림을 끈 발전소는 실제 텔레그램 알림 대상에서 제외된다.
- 0kW 단발값은 알림을 발생시키지 않는다.
- 정상 복구 후 다음 장애를 다시 감지할 수 있다.
### 3단계: Excel 업로드 화면과 API 계약 정리
상태: 완료
대상:
- `app/screens/PlantDetailScreen.js`
- `app/components/UploadModal.js`
- `api_server/app/routers/upload.py`
검토 및 수정 항목:
- 상세 화면에서 항상 월간 업로드 API를 호출하는 문제를 수정한다.
- 기존 `UploadModal`을 실제 화면에 연결하거나, 중복 구현을 제거하고 하나의 업로드 흐름으로 통합한다.
- 일간 형식(`date`, `generation`)과 월간 형식(`year`, `month`, `kwh`)을 UI에서 명확히 구분한다.
- Web과 Native의 `FormData` 처리 방식을 각각 검증한다.
- 파일명 누락, 잘못된 확장자, 빈 파일, 필수 열 누락, 잘못된 날짜를 4xx 오류로 반환한다.
- 업로드 파일 크기 및 행 수 제한을 정한다.
- `fillna(method='ffill')` 등 향후 제거 예정인 pandas 사용법을 정리한다.
완료 기준:
- 일간 및 월간 샘플 파일이 각각 올바른 API로 전송된다.
- 화면 안내와 실제 필수 열이 일치한다.
- 잘못된 입력이 서버 500 오류가 아닌 설명 가능한 4xx 응답으로 처리된다.
### 4단계: KST 시간대 처리 통일
상태: 완료
대상:
- `crawler/crawler_manager.py`
- `crawler/alert_manager.py`
- `crawler/main.py`
- `crawler/daily_summary.py`
- `crawler/backward_backfill.py`
- `api_server/app/routers/stats.py`
검토 및 수정 항목:
- 공통 KST 시간대 유틸리티를 만들고 naive `datetime.now()` 사용을 제거한다.
- 야간 크롤링 차단, 알림 시간, 일일 마감, 기본 조회 날짜, 백필의 어제 계산을 모두 KST 기준으로 통일한다.
- Supabase `timestamptz` 조회 범위는 KST 경계를 UTC ISO 문자열로 변환한다.
- KST 자정 전후와 월말/연말 경계를 테스트한다.
완료 기준:
- 서버 OS 시간대가 UTC여도 동일한 수집 및 통계 결과를 낸다.
- KST 날짜의 00:00~23:59 로그만 해당 일 통계에 포함된다.
### 5단계: API 정확성과 응답 규약
상태: 완료
대상:
- `api_server/app/routers/plants.py`
- `api_server/app/routers/stats.py`
- `api_server/app/routers/upload.py`
- `api_server/app/main.py`
- `api_server/app/schemas/`
검토 및 수정 항목:
- 문자열 발전소 ID를 받는 상세 API의 `plant_id: int` 선언을 수정한다.
- 잘못된 날짜와 범위 파라미터를 조용히 오늘로 대체하지 않고 400 또는 422로 반환한다.
- `HTTPException`이 일반 예외 처리에 잡혀 500으로 바뀌는 경로를 정리한다.
- 단순 URL 존재 여부가 아닌 실제 DB 연결 상태를 health check에 반영한다.
- 응답 모델과 오류 형식을 가능한 범위에서 통일한다.
- 발전소별 최신 로그 N+1 조회를 일괄 조회로 바꿀 수 있는지 검토한다.
- 동기 Supabase 호출이 `async` 요청 처리에 미치는 영향을 측정한 뒤 스레드풀 또는 동기 핸들러 적용을 결정한다.
완료 기준:
- 주요 API에 정상, 데이터 없음, 잘못된 입력, DB 오류 테스트가 존재한다.
- 공개된 OpenAPI 스키마와 실제 응답이 일치한다.
### 6단계: 통계 생성 경로 일관성
상태: 완료·운영 반영 (2026-08-07)
대상:
- `crawler/database.py`
- `crawler/daily_summary.py`
- `crawler/fetch_history.py`
- `api_server/app/routers/upload.py`
- DB 스키마 및 마이그레이션
검토 및 수정 항목:
- 실시간 수집, 일일 마감, 과거 백필, Excel 업로드가 동일한 덮어쓰기 정책을 사용하도록 정리한다.
- 실시간 경로에만 적용된 MAX 보호가 다른 저장 경로에도 필요한지 결정한다.
- 원본 사이트 보정값과 로그 기반 집계값 중 어떤 값을 우선할지 기록 가능한 정책으로 만든다.
- `daily_stats` 변경 후 `monthly_stats` 갱신 책임을 한 곳으로 모은다.
- `created_at`, `updated_at`, `peak_kw`, `generation_hours` 갱신 규칙을 통일한다.
- `currnet_last_date` 오타 컬럼의 사용 여부와 제거 또는 마이그레이션 필요성을 확인한다.
완료 기준:
- 동일 날짜를 어떤 경로로 다시 처리해도 더 신뢰도 높은 기존 데이터가 손상되지 않는다.
- 일간 데이터 수정 후 월간 합계가 일관되게 갱신된다.
적용 결과:
- 실시간 수집, 일일 마감, 과거 백필과 일간 Excel 업로드가 `upsert_daily_stats` DB 함수를 공통으로 사용한다.
- 자동 수집 경로는 기존보다 작은 일 발전량으로 덮어쓰지 않고, 사용자가 명시적으로 수행하는 일간 Excel 보정만 하향 수정을 허용한다.
- 일일 마감에서 원본 사이트 값이 로그 최댓값보다 큰 경우만 상향 보정하며, 작은 원본 값은 로그 집계값을 유지한다.
- `daily_stats` 변경 트리거가 영향받은 월의 `monthly_stats`를 즉시 재집계한다. Excel·원본 월간값은 권위 있는 월간 입력으로 표시하여 자동 일간 집계가 덮어쓰지 않는다.
- `daily_stats``updated_at`, `source`를 추가하고 발전시간은 선택된 발전량과 발전소 용량으로 계산한다. 최고 출력은 저장 경로 간 최댓값을 유지한다.
- `monthly_stats.currnet_last_date``last_date date`로 바로잡고 `source`를 추가했다. 기존 과거 월간 총액은 보존하고 현재 월 누락 행만 생성했다.
- 운영 적용 전 2026년 통계를 진단한 결과 1~7월 63쌍은 모두 일치했고 8월 9개 발전소의 월간 행만 누락되어 있었다. 적용 후 1~8월 72쌍의 불일치와 누락은 모두 0건이다.
### 7단계: 테스트와 재현 가능한 개발 환경
상태: 대기
대상:
- 크롤러 및 API 테스트 디렉터리
- `crawler/requirements.txt` 또는 공통 Python 의존성 정의
- `app/package-lock.json`
- CI 설정
검토 및 수정 항목:
- 크롤러별 HTTP 응답 fixture를 사용한 파싱 테스트를 만든다.
- SQLite 임시 DB와 가짜 Supabase 클라이언트를 사용해 스케줄러, 알림, 백필 상태 전이를 테스트한다.
- FastAPI 주요 엔드포인트 테스트를 추가한다.
- 프론트엔드 lockfile을 추적하여 설치 버전을 고정한다.
- 크롤러 실행에 필요한 최소 Python 의존성을 별도로 정의한다.
- 테스트와 기본 정적 검사를 수행하는 CI를 추가한다.
완료 기준:
- 외부 사이트와 운영 DB에 접속하지 않고 핵심 로직을 검증할 수 있다.
- 새 환경에서 문서화된 명령으로 의존성을 재현할 수 있다.
### 8단계: 저장소 및 코드 구조 정리
상태: 대기
대상:
- 추적 중인 `crawler/venv_win/`
- `app/server.log`
- 이전 크롤러 및 백업 파일
- 대형 React Native 화면 컴포넌트
검토 및 수정 항목:
- Git에 추적된 가상환경 실행 파일과 로그 파일을 제거하고 ignore 규칙을 확인한다.
- `*_old.py`, `*.backup`, 일회성 스크립트는 보존 필요성을 확인한 후 archive 또는 Git 이력으로 정리한다.
- API 기본 URL을 환경별 설정으로 이동한다.
- 상세 화면의 API 호출, 업로드, 차트 포맷팅 로직을 hooks와 컴포넌트로 분리한다.
- 현재 단일 업체 ID `1` 하드코딩을 제거하고 향후 다중 업체 확장 경계를 정한다.
완료 기준:
- 저장소에 실행 환경 산출물과 런타임 로그가 추적되지 않는다.
- 화면 컴포넌트와 데이터 접근 로직이 독립적으로 수정 및 테스트 가능하다.
### 9단계: 보안 개선 — 후순위 보류
상태: 보류
후속 검토 항목:
- 텔레그램 토큰과 외부 모니터링 계정 비밀번호 교체
- 모든 비밀정보의 환경변수 또는 비밀 저장소 이전
- 과거 Git 이력에 포함된 비밀정보 처리
- Excel 업로드와 알림 설정 변경 API 인증
- Supabase anon 쓰기 정책 제거 및 최소 권한 재설계
- 운영 CORS 허용 출처 제한
- 요청 크기 제한, rate limiting, 감사 로그
## 4. 권장 커밋 단위
각 단계는 가능하면 다음 단위로 나눈다.
1. 실패를 재현하는 테스트 또는 검증 스크립트
2. 최소 기능 수정
3. 회귀 테스트 및 운영 문서 갱신
긴급 수정이 필요한 경우에도 데이터 로직, UI 변경, 문서 변경은 가능한 한 별도 커밋으로 유지한다.
## 5. 진행 기록
| 단계 | 상태 | 시작일 | 완료일 | 비고 |
|---|---|---|---|---|
| 0. 운영 서버 연결 및 배포 기준선 | 완료 | 2026-08-06 | 2026-08-06 | 아래 기준선 점검 결과 참조 |
| 1. 역방향 백필 무결성 | 완료 | 2026-08-06 | 2026-08-06 | 상태 전이·저장 보호·원본 시작일·운영 dry-run 검증 완료 |
| 2. 알림 설정 연동 | 완료 | 2026-08-06 | 2026-08-06 | 코드·단위 테스트·정기 실행·실제 OFF/ON 토글 검증 완료 |
| 3. Excel 업로드 계약 | 완료 | 2026-08-06 | 2026-08-06 | 공통 모달 연결, 입력 제한, API 테스트, 웹·API 배포 완료 |
| 4. KST 시간대 통일 | 완료 | 2026-08-06 | 2026-08-06 | 공통 KST 유틸리티, UTC 반개구간과 경계 테스트, 운영 cron 검증 완료 |
| 5. API 정확성과 응답 | 완료 | 2026-08-07 | 2026-08-07 | 문자열 ID, 응답 모델, DB health, 단일 최신 로그 조회와 운영 배포 완료 |
| 6. 통계 생성 경로 일관성 | 완료 | 2026-08-07 | 2026-08-07 | 공통 DB 저장 함수·월간 트리거·출처 필드·오타 마이그레이션과 운영 검증 완료 |
| 7. 테스트 및 개발 환경 | 다음 작업 | | | |
| 8. 저장소 및 코드 구조 | 대기 | | | |
| 9. 보안 개선 | 보류 | | | 단독 저장소 사용 중이므로 후순위 |
## 6. 운영 서버 기준선 점검 결과
점검 시각: 2026-08-06 16:40~17:00 KST
점검 방식: Oracle Cloud 운영 서버에 SSH로 접속하여 상태를 변경하지 않는 읽기 전용 명령만 실행했다. 환경변수와 인증정보의 실제 값은 출력하지 않았다.
### 6.1 서버 및 배포 상태
- 호스트: `holdem-server`
- 서버 시간대: `Asia/Seoul`
- NTP 동기화: 정상
- 가동 시간: 약 197일
- 루트 디스크: 45GB 중 13GB 사용, 사용률 28%
- 운영 저장소: `/home/ubuntu/solorpower`
- 브랜치: `main`
- 운영 커밋: `b834cc4` (`docs: add historical backfill operation and verification guide`)
- 로컬 저장소와 운영 서버의 HEAD 커밋은 동일하다.
- 운영 서버에는 `crawler/alert_manager.py`, `crawler/config.py`, `crawler/main.py` 미커밋 변경이 존재한다.
- 서버의 diff는 줄바꿈 차이까지 포함되어 크게 표시되므로 배포 또는 커밋 전에 실제 의미 변경과 CRLF/LF 변경을 분리해야 한다.
### 6.2 API, Nginx 및 TLS
- `solar-api`: active/enabled, 재시작 횟수 0
- `solar-api` 실행 경로: `/home/ubuntu/solorpower/api_server/venv/bin/uvicorn`
- `solar-api` 작업 경로: `/home/ubuntu/solorpower/api_server`
- API 리슨 주소: `127.0.0.1:8000`
- Nginx: active/enabled, `0.0.0.0:80``0.0.0.0:443` 리슨
- 로컬 API `/health`: HTTP 200 및 JSON 응답
- 공개 `/plants/1`: HTTP 200 및 JSON 응답. FastAPI에서 Supabase까지의 실제 조회 경로가 정상임을 확인했다.
- 공개 `/health`: HTTP 200이지만 API JSON이 아닌 SPA `index.html`을 반환한다. 현재 Nginx 프록시 정규식에 `health`가 포함되지 않았기 때문이다.
- TLS 인증서: Let's Encrypt, `solorpower.dadot.net`, 2026-10-18 만료
- 최근 24시간 `solar-api` warning 이상 journal 항목은 없었다.
후속 조치:
- 5단계 API 작업에서 Nginx 프록시 대상에 `/health`와 필요 시 `/redoc`을 추가한다.
- health check가 실제 Supabase 쿼리를 수행하도록 개선한 뒤 외부 모니터링 경로로 사용한다.
### 6.3 웹 배포 상태
- 웹 루트: `/var/www/html/dist`
- 공개 루트: HTTP 200, `text/html`
- 운영 `index.html` 수정 시각: 2026-02-12 10:53 KST
- 운영 번들에는 전체 발전소 비교와 월간 업로드 기능이 포함되어 있다.
- 운영 번들에는 현재 로컬 `App.js``alerts_enabled` 기능이 포함되어 있지 않다.
후속 조치:
- 2단계 알림 설정 연동을 수정하고 검증한 뒤 앱을 새로 빌드하여 운영 웹 배포본을 동기화한다.
### 6.4 자동 실행 및 최근 로그
운영 cron:
```cron
*/10 * * * * cd ~/solorpower/crawler && /usr/bin/python3 main.py >> crawler.log 2>&1
10 0 * * * cd ~/solorpower/crawler && /usr/bin/python3 daily_summary.py >> summary.log 2>&1
30 * * * * cd ~/solorpower/crawler && export PYTHONUTF8=1 && /usr/bin/python3 backward_backfill.py --days 5 --delay 2.0 >> backfill_cron.log 2>&1
```
- 실시간 크롤러 로그는 점검 시각까지 10분마다 갱신되고 있었다.
- 최근 실행마다 9개 발전소의 `solar_logs` 저장 성공을 확인했다.
- 일일 집계 로그에서 최근 대상일의 9개 발전소 저장 성공을 확인했다.
- Supabase URL과 키 환경변수의 설정 여부를 확인했으며 둘 다 설정되어 있다.
- `USE_PROXY=true`가 서버 `.env`에 설정되어 있다.
- 로그에 `company_id="sunwind"`를 bigint와 비교하는 오류가 발전소마다 반복되고 있다. 앱의 알림 비활성화 설정이 크롤러에 반영되지 않는 문제가 운영 환경에서도 재현되었다.
후속 조치:
- 2단계 알림 설정 연동을 데이터 백필 다음 우선순위로 유지한다.
- 로그에서 Supabase URL prefix를 출력하는 디버그 문구를 제거한다.
### 6.5 Tailscale, NAS 프록시 및 외부 사이트
- Oracle 서버 Tailscale 주소: `100.116.0.32`
- NAS 프록시 노드 `100.83.7.81`이 Tailscale peer로 확인된다.
- NAS 프록시 TCP 3128 포트 연결에 성공했다.
- 서버의 `USE_PROXY=true` 설정과 코드의 프록시 주소가 일치한다.
- NAS 프록시를 경유한 비인증 기본 URL 점검 결과 NREMS, KREMC, Sun-WMS, Hyundai, CMSolar 모두 HTTP 200을 반환했다.
- 최근 실시간 크롤러가 9개 결과를 저장했으므로 인증을 포함한 실제 크롤러 경로도 현재 동작 중인 것으로 판단한다.
### 6.6 백필 상태와 운영 데이터 범위
- SQLite `backfill_state`의 9개 발전소가 모두 `COMPLETED` 상태다.
- 완료 이후에도 백필 cron이 매시간 실행되어 `실행 중인 작업이 없습니다`만 기록하고 있다.
- 일부 사이트는 연속 30일 0값 조건으로 완료되었으나 Supabase에는 더 오래된 데이터가 이미 존재하여 SQLite 상태만으로 전체 데이터 완전성을 판단할 수 없다.
운영 `daily_stats` 범위:
| 발전소 | 건수 | 최초 날짜 | 최종 날짜 |
|---|---:|---|---|
| nrems-01 | 4,512 | 2014-03-31 | 2026-08-06 |
| nrems-02 | 4,512 | 2014-03-31 | 2026-08-06 |
| nrems-03 | 3,881 | 2015-12-22 | 2026-08-06 |
| nrems-04 | 3,495 | 2017-01-11 | 2026-08-06 |
| kremc-05 | 2,791 | 2018-10-09 | 2026-08-06 |
| sunwms-06 | 2,412 | 2019-12-30 | 2026-08-06 |
| hyundai-08 | 2,374 | 2020-02-06 | 2026-08-06 |
| nrems-09 | 2,109 | 2020-10-28 | 2026-08-06 |
| cmsolar-10 | 2,115 | 2020-09-22 | 2026-08-06 |
확인 결과:
- `kremc-05`는 원본에서 2018-06-28과 2018-10-08 데이터가 없고, 2018-10-09의 첫 데이터 86kWh가 확인됐다. DB 최초 날짜와 일치하므로 백필 누락이 아니다.
- `cmsolar-10`은 원본에서 2020-08-31과 2020-09-21 데이터가 없고, 2020-09-22의 첫 데이터 50kWh가 확인됐다. DB 최초 날짜와 일치하므로 백필 누락이 아니다.
- 같은 원본 조회 도구로 2026-08-05의 KREMC 160kWh와 CMSolar 127kWh도 재확인했다.
- 백필 작업이 모두 완료되었으므로 데이터 검증 후 cron을 비활성화할지 결정한다. 변경은 별도 승인 후 수행한다.
### 6.7 2026-08-05 NREMS 3·4·9호기 오탐 분석
사용자 확인에 따르면 실제 NREMS 사이트의 발전 상태에는 문제가 없었지만 3·4·9호기 장애 알림이 전송되었다. 운영 로그와 당일 집계 데이터를 대조한 결과 발전소 장애가 아니라 NAS 프록시 연결 장애를 발전소 0kW로 잘못 분류한 오탐으로 확인되었다.
발생 타임라인:
| KST 시각 | 관측 내용 |
|---|---|
| 14:30 | 3호기 71.10kW, 4호기 50.70kW, 9호기 70.72kW로 정상 수집 |
| 14:40 | NAS 프록시 `100.83.7.81:3128` 연결 타임아웃, 각 호기 0kW 의심 1회 |
| 14:50 | 동일 프록시 타임아웃, 0kW 의심 2회 |
| 15:00 | 동일 프록시 타임아웃, 0kW 3회 연속으로 텔레그램 알림 전송 |
| 15:10 | 프록시 타임아웃 지속 |
| 15:20 | 연결 복구. 3호기 61.30kW, 4호기 43.37kW, 9호기 62.76kW 정상 수집 |
같은 시간 NREMS 1·2호기뿐 아니라 KREMC, Sun-WMS, CMSolar도 동일 NAS 프록시 타임아웃을 기록했다. 따라서 특정 발전소 또는 NREMS 원본 사이트 장애가 아닌 공통 프록시 경로 장애다.
3·4·9호기에만 텔레그램 알림이 전송된 이유:
- NREMS 비분할 발전소의 예외 처리 경로는 수집 실패 시 `kw=0`, `today=0`, `status='🔴 오류'`인 결과를 생성한다.
- `main.py`는 결과의 오류 상태를 확인하지 않고 `AlertManager.check_and_alert()`에 0값을 전달한다.
- `AlertManager`는 오류/미수집 상태와 실제 정상 응답의 0kW를 구분하지 않아 세 번의 프록시 오류를 발전소 정지로 판단한다.
- NREMS 1·2호기 분할 경로와 다른 사이트 크롤러는 같은 예외에서 결과를 반환하지 않아 알림 검사 자체가 호출되지 않았다. 이로 인해 크롤러별 실패 처리도 일관되지 않다.
데이터 영향:
- 14:40의 3·4·9호기 오류 레코드는 장애 이력 추적 목적으로 `solar_logs`에 한 번 저장되었다.
- `database.py`의 오류 상태 보호 로직으로 해당 0값은 `daily_stats`를 덮어쓰지 않았다.
- 동일 오류값이 반복된 14:50~15:10에는 중복 저장 방지 로직이 적용되었다.
- 15:20 정상값 수집 후 상태가 복구되었다.
- 8월 6일 00:10 일일 마감에서 로그 최댓값과 원본 사이트 일 통계를 대조했으며 9개 발전소 모두 저장에 성공했다.
2026-08-05 최종 일 발전량:
| 발전소 | 최종 발전량(kWh) | 원본 보정 결과 |
|---|---:|---|
| nrems-01 | 188.0 | 일치 |
| nrems-02 | 186.0 | 일치 |
| nrems-03 | 432.0 | 일치 |
| nrems-04 | 339.0 | 일치 |
| kremc-05 | 160.0 | 일치 |
| sunwms-06 | 287.7 | 일치 |
| hyundai-08 | 389.5 | 일치 |
| nrems-09 | 533.2 | 일치 |
| cmsolar-10 | 127.0 | 일치 |
수정 방향:
1. 크롤러 결과에 `data_valid` 또는 명확한 수집 상태를 추가하여 `오류/미수집`, `정상 0kW`, `정상 발전`을 구분한다.
2. 수집 오류 상태는 발전소 0kW 카운터를 증가시키지 않는다.
3. 공통 프록시 또는 여러 사이트의 동시 실패는 발전소 장애가 아닌 수집 인프라 장애로 한 번만 알린다.
4. 공통 HTTP 세션에 연결 오류 재시도와 backoff를 추가한다.
5. 모든 크롤러가 동일한 실패 결과 규약을 사용하도록 통일한다.
6. 오류 레코드의 `solar_logs` 보존 여부와 별도 수집 장애 로그 테이블 도입 여부를 검토한다.
### 6.8 기준선 결론
현재 연결 구조인 `외부 사이트 → Tailscale/NAS 프록시 → 크롤러 → Supabase → FastAPI → Nginx → 웹 앱`은 동작 중이다. 즉시 서비스 장애는 발견되지 않았다.
코드 수정 전에 해결해야 할 운영상 주요 문제는 다음과 같다.
1. 백필 완료 상태와 실제 데이터 완전성의 불일치 가능성
2. 운영 중 반복되는 알림 설정 `company_id` 타입 오류
3. 공개 `/health`의 Nginx 오라우팅
4. 로컬 소스보다 오래된 운영 웹 번들
5. 완료된 백필 cron의 불필요한 반복 실행
## 7. 변경 기록
### 7.1 알림 오탐 선조치
작업일: 2026-08-06
변경 내용:
- 크롤러 결과의 `data_valid` 값과 오류 상태를 이용해 수집 오류를 실제 발전소 0kW와 분리했다.
- 수집 오류가 발생하면 진행 중인 0kW 의심 카운트를 초기화하고 알림 판정에서 제외한다.
- NREMS 비분할 발전소의 예외 결과에 `data_valid=False`를 명시했다.
- `alerts_enabled` 조회에서 문자열 크롤러 업체 키를 제거하고 전역 고유키인 `plants.id`만 사용한다.
- 알림 설정 조회가 일시적으로 실패하면 마지막 성공 값을 사용하는 메모리 캐시를 추가했다.
- 테스트를 위해 현재 시각 주입이 가능하도록 `AlertManager` 생성자를 확장했다.
검증 결과:
- 알림 상태 전이 단위 테스트 6개 통과
- 변경 Python 파일 5개 AST 구문 검증 통과
- 운영 Supabase에서 9개 발전소 모두 `plants.id` 단독 조건으로 `alerts_enabled` 조회 성공
- 점검 시점의 9개 발전소 알림 설정은 모두 활성 상태
- 운영 서버의 기존 미커밋 변경과 대조하여 이번 변경이 기존 3회 연속 0kW 로직을 보존함을 확인했다.
- 배포 전 파일은 `/home/ubuntu/solorpower/deploy_backups/20260806_alert_data_valid/`에 백업했다.
- `alert_manager.py`, `main.py`, `crawlers/base.py`, `crawlers/nrems.py`와 알림 테스트를 운영 서버에 배포했다.
- 운영 서버에서 Python 구문 검사와 알림 상태 전이 단위 테스트 6개가 모두 통과했다.
- 로컬과 운영 서버의 배포 대상 5개 파일 SHA-256 해시가 모두 일치했다.
- 2026-08-06 17:10 KST 정기 실행에서 외부 사이트 연결과 Supabase 저장이 정상 완료됐다. 6건을 저장하고 변경 없는 3건은 정상적으로 건너뛰었다.
- 배포 후 실행 구간에서 기존 `company_id` bigint 변환 오류와 텔레그램 알림 발송은 발생하지 않았다.
- 운영 PATCH API로 8호기의 `alerts_enabled`를 잠시 `false`로 변경했을 때 크롤러가 실제로 `false`를 읽는 것을 확인했다.
- 같은 검증 흐름에서 8호기 설정을 즉시 `true`로 복구했고, 크롤러에서 최종 `true` 상태를 재확인했다.
- 운영 알림 설정 확인용 읽기 전용 도구 `tests/check_alert_setting.py`를 추가했다.
- 서비스 재시작과 cron 변경은 필요하지 않아 수행하지 않았다.
남은 관찰 항목:
- 배포 직전인 17:00 KST에 NAS 프록시 연결 실패가 재발했고 17:10에는 복구됐다. 다음 실제 수집 오류에서 `수집 실패 데이터는 0kW 판정에서 제외` 로그가 남는지 확인한다.
### 7.2 역방향 백필 무결성
작업일: 2026-08-06
변경 내용:
- 최초 상태 커서를 첫 수집 대상의 다음 날로 저장하여 지정한 시작일을 건너뛰지 않도록 수정했다.
- 일별 원본 조회 계약을 `list=정상 응답`, 빈 `list=정상 응답이지만 해당 날짜 없음`, `None=요청·로그인·파싱 실패`로 구분했다.
- 네트워크·로그인·파싱 실패 시 무발전 일수를 늘리지 않고 커서를 유지하여 다음 실행이 같은 날짜부터 재시도하게 했다.
- Supabase 저장 성공이 확인된 경우에만 SQLite 진행 날짜를 갱신하도록 수정했다.
- 원본에 날짜가 없는 경우는 실제 0kWh와 분리하고 연속 무발전 횟수를 초기화한다.
- 성공적으로 조회된 실제 0kWh만 30일 연속 종료 조건에 포함한다.
- `daily_stats`의 기존 값보다 작거나 같은 백필 값은 upsert하지 않으며, 기존값 조회 실패 시에도 저장하지 않는다.
- 음수 발전량은 잘못된 과거 데이터로 거부한다.
- 일간 저장 후 월 집계 실패가 발생해도 같은 날짜 재시도에서 월간 합계를 다시 계산하도록 했다.
- 운영 원본 값을 DB에 쓰지 않고 확인하는 `tests/check_history_source.py`를 추가했다.
검증 결과:
- 알림 회귀 테스트 6개, 백필 상태 전이 7개, DB 저장 보호 4개, 사이트 조회 계약 6개 등 총 23개 테스트가 로컬과 운영 서버에서 모두 통과했다.
- 운영 서버 대상 Python 파일 구문 검사가 통과했다.
- 배포 전 파일은 `/home/ubuntu/solorpower/deploy_backups/20260806_backfill_integrity/`에 백업했다.
- 원본과 운영 DB의 KREMC·CMSolar 최초 날짜가 일치하며 2026-08-05 값도 일치함을 확인했다.
- 운영 dry-run에서 9개 발전소 모두 완료 상태이며 실행 중인 백필이 없음을 확인했다.
- 백필 cron은 변경하지 않았으며 현재 매시간 무작업 실행만 반복한다.
### 7.3 Excel 업로드 화면과 API 계약
작업일: 2026-08-06
변경 내용:
- 상세 화면의 월간 전용 즉시 업로드 구현을 제거하고 기존 `UploadModal`을 연결했다.
- 사용자가 일간 또는 월간 형식을 선택한 후 파일을 업로드하도록 흐름을 하나로 통합했다.
- Web에서는 DocumentPicker의 실제 `File`을, Native에서는 URI 객체를 `FormData`에 추가한다.
- `multipart/form-data`의 boundary를 런타임이 만들도록 수동 `Content-Type` 헤더를 제거했다.
- API에 `.xlsx`·`.xls` 확장자, 빈 파일, 5MB 파일 크기, 5,000행 제한을 추가했다.
- 일간 업로드의 필수 열, 날짜, 빈 발전량, 음수 발전량을 검증한다.
- 월간 업로드의 필수 열, 월 범위, 빈·비숫자·음수 발전량을 검증한다.
- 존재하지 않는 발전소가 내부 오류 500이 아닌 404를 반환하도록 조회 방식을 정리했다.
- pandas의 `fillna(method='ffill')``ffill()`로 변경했다.
검증 및 배포 결과:
- Expo Web production export가 성공했다.
- 정상 일간·월간 샘플과 잘못된 확장자, 빈 파일, 필수 열 누락, 잘못된 날짜, 음수, 미등록 발전소, 파일 크기, 행 수를 다루는 API 테스트 10개가 운영과 동일한 Python 환경에서 통과했다.
- API 수정 전 파일과 기존 웹 배포본을 `/home/ubuntu/solorpower/deploy_backups/20260806_upload_contract/`에 백업했다.
- 이전 웹 디렉터리는 `/var/www/html/dist.pre_20260806_upload_contract`에도 보존했다.
- `solar-api` 재시작 후 내부 `/health`가 정상이고 Nginx와 API 서비스가 모두 active 상태임을 확인했다.
- 공개 웹과 `/plants/1`은 HTTP 200, 잘못된 확장자의 공개 업로드 요청은 설명 가능한 HTTP 400을 반환했다.
- 운영 웹 번들은 `AppEntry-80396685aa3d044c4ab36bbf91a02696.js`로 갱신됐고 일간·월간 업로드 경로가 포함됐다.
### 7.4 KST 시간대 처리 통일
작업일: 2026-08-06
변경 내용:
- 크롤러와 API의 독립 배포 구조에 맞춰 각각 공통 KST 시간 유틸리티를 추가했다.
- 서버 OS 시간대에 의존하던 크롤러 시작 로그, 야간 실행 차단, 저장 heartbeat, 알림 허용 시간, 일일 마감, 백필 기본 날짜를 KST 기준으로 통일했다.
- 기존 SQLite에 저장된 naive ISO 시각은 과거 기록 방식과의 호환을 위해 KST로 간주하고, 신규 기록은 `+09:00` 오프셋을 포함한다.
- NREMS 1·2호기 인버터 조회의 오늘 날짜와 월도 KST 기준으로 계산한다.
- 실시간 및 과거 이력 저장의 생성·갱신 시각을 공통 KST 함수로 통일했다.
- API의 오늘 비교 통계, 발전소 일별 통계, 시간별 통계와 크롤러 일일 마감 조회 범위를 KST 하루의 UTC 반개구간으로 변경했다.
- 조회 조건은 `KST 00:00 이상, 다음 날 KST 00:00 미만`으로 적용하여 다음 날 자정 데이터가 이전 날짜에 포함되지 않도록 했다.
- 시간별 통계의 잘못된 날짜 형식에서 발생한 `HTTPException`이 일반 예외 처리에 잡혀 500으로 바뀌지 않도록 유지했다.
검증 및 배포 결과:
- UTC 시각 주입 시 KST 04:59에는 크롤링이 차단되고 KST 05:00에는 허용되는 것을 테스트했다.
- KST 자정, 8월 말일, 12월 31일·1월 1일 경계가 UTC 전날 15:00부터 다음 15:00 미만으로 변환되는 것을 테스트했다.
- 크롤러 시간대 및 기존 알림·백필 회귀 테스트 30개가 Linux 컨테이너와 운영 서버에서 통과했다.
- API 시간대 테스트 4개와 Excel 업로드 회귀 테스트 10개 등 운영 서버 API 테스트 14개가 통과했다.
- 운영 배포 전 파일은 `/home/ubuntu/solorpower/deploy_backups/20260806_kst_time/`에 백업했다.
- `solar-api` 재시작 후 서비스와 8000 포트 리슨을 확인했고 내부 `/health`가 HTTP 200을 반환했다.
- 공개 `/health`, 오늘 전체 비교 통계, 2026-08-05 시간별 통계가 모두 HTTP 200을 반환했다.
- API 재시작 직후 약 4초의 기동 구간에는 Nginx가 일시적으로 502를 반환했으며 애플리케이션 시작 완료 후 정상 복구됐다.
- 2026-08-06 18:00 KST 정기 cron 실행에서 9개 발전소 연결이 모두 성공했고, 값이 변경된 8개 로그를 저장했으며 변경 없는 현대 8호기는 정상적으로 건너뛰었다.
- 같은 정기 실행에서 시작 시각과 마감 스킵 판단이 모두 KST 18:00으로 기록됐고 수집 오류나 텔레그램 알림은 발생하지 않았다.
### 7.5 API 정확성과 응답 규약
작업일: 2026-08-07
변경 내용:
- 상세 API의 `plant_id`를 정수형에서 실제 DB 키와 같은 문자열형으로 수정하여 `nrems-03` 같은 요청이 422가 되던 문제를 해결했다.
- 상세 발전소 조회에서 `.single()`이 미등록 데이터를 내부 오류로 바꾸지 않도록 `.limit(1)` 조회와 명시적 404 처리를 적용했다.
- 비교·발전소·시간별 통계에 응답 모델을 추가하고 발전소 상세, 알림 변경, health, Excel 업로드 응답도 Pydantic 모델로 OpenAPI에 명시했다.
- 비교 통계와 시간별 통계 날짜는 `YYYY-MM-DD`만 허용하고 잘못된 형식이나 존재하지 않는 날짜를 400으로 반환한다.
- 통계의 연도는 2000~2100, 월은 1~12로 제한하여 범위를 벗어나면 FastAPI 검증 응답 422를 반환한다.
- 미등록 발전소의 일별·시간별 통계가 0으로 채운 성공 응답을 반환하지 않고 404를 반환하도록 수정했다.
- `HTTPException`을 일반 예외 처리보다 먼저 다시 전달하여 의도한 4xx 상태가 500으로 바뀌지 않게 했다.
- 선택한 연도의 통계 조회 종료 월이 현재 연도로 고정되던 문제를 수정하고 윤년의 연간 발전시간 계산에 366일을 적용했다.
- 발전소 목록의 최신 로그 조회를 발전소 1회와 로그 9회의 N+1 요청에서 관계 중첩 단일 요청으로 변경했다.
- 동기 Supabase 호출을 사용하는 조회 API는 FastAPI 동기 핸들러로 전환하여 threadpool에서 실행되게 했다.
- 비동기 파일 업로드에서는 Excel 파싱과 Supabase 조회·저장을 `run_in_threadpool`로 분리하여 이벤트 루프 차단을 줄였다.
- `/health`가 환경변수 존재 여부가 아니라 실제 `plants` 읽기 요청으로 Supabase 연결을 확인하며 실패 시 503을 반환하도록 변경했다.
- Nginx에 `/health``/redoc`을 포함한 명시적 API 프록시 규칙을 추가하고 재현 가능한 설정을 `deploy/nginx/solorpower.conf`에 보존했다.
검증 및 배포 결과:
- 정상 업로드, 시간대 경계, 문자열 ID, 빈 목록, 날짜·범위 오류, 미등록 발전소, DB 조회 실패, DB health 실패, OpenAPI 모델과 단일 관계 조회를 다루는 API 테스트 31개가 스테이징과 운영 서버에서 모두 통과했다.
- 운영 Supabase 읽기 전용 스테이징 점검에서 health, 발전소 목록·상세, 비교 통계와 시간별 통계가 모두 JSON HTTP 200을 반환했다.
- 공개 발전소 목록 응답 시간은 변경 전 약 2.46초에서 배포 후 약 0.23초로 확인됐다.
- 운영 API 배포 전 파일은 `/home/ubuntu/solorpower/deploy_backups/20260807_api_contract/`에 백업했다.
- 기존 Nginx 설정은 `/etc/nginx/sites-available/solorpower.pre_20260807_api_contract`에 백업했다.
- Nginx 설정 검사가 성공한 경우에만 reload했으며 `solar-api`와 Nginx 모두 active 상태를 유지했다.
- 공개 `/health``application/json``supabase_connected=true`를 반환하고, 문자열 발전소 상세와 OpenAPI는 200, 잘못된 날짜는 400, 잘못된 월은 422를 반환했다.
### 7.6 통계 생성 경로 일관성
작업일: 2026-08-07
변경 전 확인:
- 운영 Supabase에는 최초 원격 스키마 마이그레이션 1건만 적용되어 있었다.
- `daily_stats`에는 값 변경 시각과 입력 출처 컬럼이 없었고, `monthly_stats`에는 사용되지 않는 `currnet_last_date text` 오타 컬럼이 존재했다. 운영 937개 월간 행에서 이 오타 컬럼의 실제 값은 모두 NULL이었다.
- 실시간 저장만 기존 최댓값을 보호했고 조회 실패 시 빈 기존값으로 간주하여 보호가 우회될 수 있었다.
- 과거 백필과 일일 마감은 각각 별도 일간 upsert를 사용했고, 백필과 월말 마감이 별도로 월간 합계를 갱신했다. 일간 Excel 업로드는 월간 합계를 갱신하지 않았다.
- 변경 전 2026-01~2026-08 일·월 통계를 읽기 전용으로 대조한 결과 1~7월 63쌍의 합계 차이는 0건이었으나 8월 9개 발전소의 월간 행이 모두 누락되어 있었다.
저장 정책:
- `realtime`, `daily_summary`, `history`는 자동 경로이며 같은 날짜의 기존 발전량보다 큰 값만 반영한다.
- `excel_daily`는 사용자의 명시적 보정 경로이므로 발전량의 상향·하향 수정을 모두 허용한다.
- 자동 경로가 더 작은 값을 제출하더라도 기존 총발전량과 출처를 유지한다. 최고 출력은 경로와 관계없이 기존값과 신규값 중 큰 값을 유지한다.
- 발전시간은 최종 선택된 총발전량을 `plants.capacity`로 나눠 DB에서 계산한다. `created_at`은 최초 생성 시각을 유지하고, 총발전량 또는 최고 출력의 실제 변경 시에만 `updated_at`을 갱신한다.
- 일일 마감의 원본 사이트 값은 로그 기반 당일 최댓값보다 큰 경우에만 상향 보정한다. 원본 0 또는 더 작은 값은 로그 집계를 낮추지 않는다.
- `derived_daily` 월간 값은 일간 변경 트리거가 합계와 마지막 포함일을 갱신한다. `excel_monthly`, `history_monthly`는 권위 있는 월간 입력으로 보호한다.
- 마이그레이션 당시의 기존 월간 값은 `legacy`로 표시하고 총액을 일괄 재계산하지 않았다. 현재 KST 월만 생성·갱신하여 과거 수동 입력 손상 가능성을 차단했다.
코드 및 스키마 변경:
- `crawler/database.py``api_server/app/core/stats_storage.py`에 공통 RPC 호출 래퍼를 추가했다.
- `crawler/database.py`의 실시간·백필 일간 저장과 `crawler/daily_summary.py`의 마감 저장을 공통 DB 함수로 전환했다.
- 백필과 월말 마감에 중복되어 있던 애플리케이션 월간 재집계 코드를 제거했다.
- 두 일간 Excel API는 `excel_daily`, 월간 Excel API는 `excel_monthly` 출처를 기록한다. 월간 업로드는 해당 월의 말일을 `last_date`로 저장한다.
- `supabase/migrations/20260807000001_stats_write_consistency.sql`에 컬럼·제약조건, `upsert_daily_stats`, `refresh_monthly_stat``daily_stats_sync_monthly` 트리거를 추가했다.
- `currnet_last_date``last_date date`로 이름과 자료형을 바로잡았다.
- `crawler/tests/check_stats_consistency.py`를 추가하여 PostgREST 1,000행 제한을 페이지 처리하면서 일·월 합계, 누락과 스키마 버전을 읽기 전용으로 점검할 수 있게 했다.
검증 및 배포 결과:
- 격리된 Supabase PostgreSQL 15 컨테이너에서 실제 마이그레이션을 적용하고 자동 하향 방지, 자동 상향, Excel 하향 정정, 최고 출력 보호, 발전시간 재계산, 월간 트리거와 권위 월간 보호를 SQL로 검증했다.
- 크롤러 전체 회귀 테스트 31개가 Linux 컨테이너에서 통과했다. Windows 직접 실행에서 보인 15개 오류는 SQLite 연결이 열린 상태의 임시 파일 정리 잠금 문제였으며 Linux 실행에서는 모두 통과했다.
- API 전체 회귀 테스트 31개가 운영과 동일한 서버 Python 환경의 스테이징 및 배포본에서 모두 통과했다.
- Supabase 마이그레이션 적용 후 2026-01~2026-08 통계는 `daily_rows=1970`, `monthly_rows=72`, `mismatches=0`, `missing_monthly=0`으로 확인됐다.
- 운영 배포 전 API·크롤러 파일은 `/home/ubuntu/solorpower/deploy_backups/20260807_stats_consistency/`에 백업했다.
- `solar-api` 재시작 후 active 상태이며 공개 `/health`는 실제 Supabase 연결을 포함한 JSON 200을 반환한다. 공개 `/plants/stats/comparison`의 2026년 8월 월간 및 2026-08-05 일간 요청도 JSON 200을 반환한다.
- 2026-08-07 10:50 KST 실제 cron이 새 저장 함수를 사용하여 9개 발전소의 `solar_logs``daily_stats`를 모두 저장했고 수집·통계 오류 없이 종료했다.
### 7.7 테스트와 재현 가능한 개발 환경
작업일: 2026-08-07
의존성과 실행 기준:
- crawler의 직접 의존성과 Python 3.10 호환 NumPy 제약을 `crawler/requirements.in`에 기록하고, 해석된 전이 의존성 56개 전체를 `crawler/requirements.txt`에 고정했다.
- API의 직접 의존성 기준을 `api_server/requirements.in`에 분리하고 기존 전체 고정 목록인 `requirements.txt`를 배포·CI 기준으로 유지했다.
- Linux 전용 `uvloop``sys_platform != "win32"` 조건을 추가하여 같은 고정 목록이 Windows 개발 환경에서도 설치되도록 수정했다.
- 앱의 `package-lock.json`을 Git 관리 대상으로 전환하고 중국 npm mirror 주소와 버전이 비어 있던 Supabase CLI 선택 패키지 항목을 제거한 공식 npm registry 기반 lockfile로 재생성했다.
- 깨끗한 설치에서 드러난 누락 직접 의존성 `expo-asset ~11.0.5`를 추가했다.
- `npm run build:web`과 CI용 `npm run test:ci` 명령을 추가했다.
테스트 보강:
- NREMS, KREMC, 현대, Sun-WMS, CMSolar의 성공 응답을 개인정보 없는 최소 JSON/HTML fixture로 저장했다.
- 외부 네트워크와 실제 계정 없이 로그인·월간 요청·날짜와 발전량 파싱을 검증하는 성공 경로 테스트 5개를 추가했다.
- Windows 임시 SQLite 파일 잠금의 원인이었던 연결 미종료를 `contextlib.closing`으로 수정했다. 운영 코드의 알림 상태, scheduler, 백필 DB와 관련 테스트 헬퍼가 연결을 명시적으로 닫는다.
CI 및 문서:
- `.github/workflows/ci.yml`에 crawler Python 3.10/3.11, API Python 3.11, PostgreSQL 15 migration 계약, Node 20 Expo 웹 build의 4개 독립 job을 추가했다.
- CI는 테스트용 더미 Supabase 설정만 사용하며 실제 외부 사이트, 운영 DB, Telegram 비밀값을 요구하지 않는다.
- 설치와 실행 명령, 지원 버전, 테스트 범위를 `docs/development_and_testing.md`에 정리했다.
- 워크플로는 현재 미커밋 작업 트리에 있으므로 Git 커밋·push 후 최초로 자동 실행된다.
검증 및 배포 결과:
- 새 Python 3.11 가상환경에 crawler와 API 고정 의존성을 함께 설치했다. Windows에서 Linux 전용 `uvloop`가 정상 제외됐고 `pip install`이 성공했다.
- 같은 새 환경에서 crawler 36개와 API 31개 테스트가 모두 통과했다.
- crawler 36개 테스트는 기존 Linux 컨테이너와 운영 서버 `/usr/bin/python3`에서도 모두 통과했다.
- 공식 registry lockfile만 있는 깨끗한 npm 환경에서 931개 패키지를 `npm ci`로 설치하고 Expo 웹 export를 완료했다.
- 격리된 PostgreSQL 15 컨테이너에서 `stats_write_consistency_test.sql`을 다시 실행하여 migration 계약이 통과했다. 테스트용 컨테이너는 종료 후 자동 제거했다.
- `npm ci` 감사 결과는 17건(중간 10, 높음 6, 치명적 1)을 보고했다. 대부분 현재 Expo 52 의존성 트리의 전이 패키지이며 호환성 검토가 필요한 버전 업그레이드는 8단계 이후 별도 작업으로 남긴다.
- 운영 반영 전 SQLite 관련 파일 3개는 `/home/ubuntu/solorpower/deploy_backups/20260807_test_environment/`에 백업했다.
- 2026-08-07 12:00 KST 실제 cron에서 3호기는 직전 값과 동일하여 정상적으로 저장을 건너뛰었고 나머지 8개 발전소는 저장에 성공했다. SQLite 잠금, 수집 실패, 통계 저장 실패와 Telegram 오탐은 발생하지 않았다.

View File

@ -27,17 +27,21 @@ ssh -i "C:\Users\haneu\.ssh\holdem_server.key" ubuntu@100.116.0.32
서버 내 주요 서비스 설치 경로입니다.
* **`~/solorpower_crawler/`**: 실시간 데이터 수집기(Crawler)가 실행되는 메인 경로
* `main.py`: 10분마다 실행되는 수집 엔트리
* `crawler.log`: 크롤링 실행 로그 (표준 출력/에러 통합)
* `database.py`: Supabase 저장 로직 (최근 0kW 보호 패치 적용됨)
* **`~/solorpower_server/`**: FastAPI 기반의 백엔드 API 서버 경로
* **`~/plant_sync/`**: 발전소 정보 동기화 및 기타 유틸리티
* **`~/solorpower/`**: 모노레포 통합 경로
* `~/solorpower/crawler/`: 실시간 데이터 수집기(Crawler) 및 역추적 백필 프로그램 경로
* `main.py`: 10분마다 실행되는 실시간 수집 엔트리
* `backward_backfill.py`: 과거 백필 스케줄러
* `crawler_manager.db`: SQLite 백필 및 스케줄 상태 DB
* `crawler.log`: 실시간 크롤링 실행 로그
* `backfill_cron.log`: 백필 실행 로그
* `~/solorpower/api_server/`: FastAPI 기반의 백엔드 API 서버 경로
### ⏰ 자동화 스케줄 (Crontab)
서버에서 `crontab -l` 명령어로 확인된 자동화 설정입니다.
* `*/10 * * * *`: 10분마다 데이터 수집 실행 (`main.py`)
* `10 0 * * *`: 매일 0시 10분에 일일 통계 요약 실행 (`daily_summary.py`)
* `*/10 * * * *`: 10분마다 실시간 데이터 수집 실행 (`main.py`)
* `10 0 * * *`: 매일 0시 10분에 일일 통계 요약 및 하이브리드 보정 실행 (`daily_summary.py`)
* `30 * * * *`: 매시간 30분마다 과거 5일치 백필 실행 (`backward_backfill.py --days 5 --delay 2.0`)
---
@ -72,4 +76,4 @@ ssh -i "C:\Users\haneu\.ssh\holdem_server.key" ubuntu@100.116.0.32
4. **DB 확인:** Supabase `solar_logs` 테이블에서 해당 시간대 `status` 확인
---
*마지막 업데이트: 2026-05-14 (0kW 오보 보호 패치 및 인프라 정리)*
*마지막 업데이트: 2026-06-18 (백필 크론 확인, 인프라 및 DB 경로 동기화)*

View File

@ -0,0 +1,303 @@
-- Centralize daily-stat write policy and keep monthly aggregates in sync.
ALTER TABLE public.daily_stats
ADD COLUMN IF NOT EXISTS updated_at timestamp with time zone NOT NULL DEFAULT now(),
ADD COLUMN IF NOT EXISTS source text NOT NULL DEFAULT 'legacy';
UPDATE public.daily_stats
SET updated_at = COALESCE(created_at, now())
WHERE updated_at IS NULL;
ALTER TABLE public.daily_stats
DROP CONSTRAINT IF EXISTS daily_stats_source_check;
ALTER TABLE public.daily_stats
ADD CONSTRAINT daily_stats_source_check CHECK (
source IN ('legacy', 'realtime', 'daily_summary', 'history', 'excel_daily')
);
ALTER TABLE public.monthly_stats
RENAME COLUMN currnet_last_date TO last_date;
ALTER TABLE public.monthly_stats
ALTER COLUMN last_date TYPE date
USING CASE
WHEN last_date IS NULL OR btrim(last_date) = '' THEN NULL
WHEN last_date ~ '^\d{4}-\d{2}-\d{2}$' THEN last_date::date
ELSE NULL
END;
ALTER TABLE public.monthly_stats
ADD COLUMN IF NOT EXISTS source text NOT NULL DEFAULT 'legacy';
ALTER TABLE public.monthly_stats
DROP CONSTRAINT IF EXISTS monthly_stats_source_check;
ALTER TABLE public.monthly_stats
ADD CONSTRAINT monthly_stats_source_check CHECK (
source IN ('legacy', 'derived_daily', 'excel_monthly', 'history_monthly')
);
COMMENT ON COLUMN public.daily_stats.updated_at IS '통계 값이 실제로 변경된 시각';
COMMENT ON COLUMN public.daily_stats.source IS '현재 일 발전량 값을 결정한 저장 경로';
COMMENT ON COLUMN public.monthly_stats.last_date IS '월간 합계에 포함된 마지막 일자';
COMMENT ON COLUMN public.monthly_stats.source IS '월간 값의 생성 경로';
CREATE OR REPLACE FUNCTION public.refresh_monthly_stat(
p_plant_id text,
p_month text
)
RETURNS void
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $$
DECLARE
v_month_start date;
v_next_month date;
v_total double precision;
v_last_date date;
v_count integer;
BEGIN
IF p_month !~ '^\d{4}-(0[1-9]|1[0-2])$' THEN
RAISE EXCEPTION 'invalid month: %', p_month;
END IF;
v_month_start := (p_month || '-01')::date;
v_next_month := (v_month_start + interval '1 month')::date;
SELECT
COALESCE(sum(ds.total_generation), 0)::double precision,
max(ds.date),
count(*)::integer
INTO v_total, v_last_date, v_count
FROM public.daily_stats AS ds
WHERE ds.plant_id = p_plant_id
AND ds.date >= v_month_start
AND ds.date < v_next_month;
IF v_count = 0 THEN
DELETE FROM public.monthly_stats AS ms
WHERE ms.plant_id = p_plant_id
AND ms.month = p_month
AND ms.source NOT IN ('excel_monthly', 'history_monthly');
RETURN;
END IF;
INSERT INTO public.monthly_stats AS ms (
plant_id,
month,
total_generation,
last_date,
updated_at,
source
) VALUES (
p_plant_id,
p_month,
round(v_total::numeric, 2)::double precision,
v_last_date,
now(),
'derived_daily'
)
ON CONFLICT (plant_id, month) DO UPDATE
SET total_generation = EXCLUDED.total_generation,
last_date = EXCLUDED.last_date,
updated_at = CASE
WHEN ms.total_generation IS DISTINCT FROM EXCLUDED.total_generation
OR ms.last_date IS DISTINCT FROM EXCLUDED.last_date
OR ms.source IS DISTINCT FROM EXCLUDED.source
THEN now()
ELSE ms.updated_at
END,
source = EXCLUDED.source
WHERE ms.source NOT IN ('excel_monthly', 'history_monthly');
END;
$$;
CREATE OR REPLACE FUNCTION public.sync_monthly_stat_from_daily()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $$
BEGIN
IF TG_OP = 'DELETE' THEN
PERFORM public.refresh_monthly_stat(OLD.plant_id, to_char(OLD.date, 'YYYY-MM'));
RETURN OLD;
END IF;
IF TG_OP = 'UPDATE'
AND OLD.plant_id IS NOT DISTINCT FROM NEW.plant_id
AND OLD.date IS NOT DISTINCT FROM NEW.date
AND OLD.total_generation IS NOT DISTINCT FROM NEW.total_generation THEN
RETURN NEW;
END IF;
IF TG_OP = 'UPDATE'
AND (OLD.plant_id IS DISTINCT FROM NEW.plant_id OR OLD.date IS DISTINCT FROM NEW.date) THEN
PERFORM public.refresh_monthly_stat(OLD.plant_id, to_char(OLD.date, 'YYYY-MM'));
END IF;
PERFORM public.refresh_monthly_stat(NEW.plant_id, to_char(NEW.date, 'YYYY-MM'));
RETURN NEW;
END;
$$;
DROP TRIGGER IF EXISTS daily_stats_sync_monthly ON public.daily_stats;
CREATE TRIGGER daily_stats_sync_monthly
AFTER INSERT OR UPDATE OR DELETE ON public.daily_stats
FOR EACH ROW EXECUTE FUNCTION public.sync_monthly_stat_from_daily();
CREATE OR REPLACE FUNCTION public.upsert_daily_stats(
p_records jsonb,
p_source text,
p_allow_decrease boolean DEFAULT false
)
RETURNS SETOF public.daily_stats
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $$
DECLARE
v_record jsonb;
v_plant_id text;
v_date date;
v_total double precision;
v_peak double precision;
v_capacity double precision;
v_result public.daily_stats%ROWTYPE;
BEGIN
IF jsonb_typeof(p_records) <> 'array' THEN
RAISE EXCEPTION 'p_records must be a JSON array';
END IF;
IF p_source NOT IN ('realtime', 'daily_summary', 'history', 'excel_daily') THEN
RAISE EXCEPTION 'invalid daily stats source: %', p_source;
END IF;
FOR v_record IN SELECT value FROM jsonb_array_elements(p_records)
LOOP
v_plant_id := NULLIF(btrim(v_record ->> 'plant_id'), '');
v_date := (v_record ->> 'date')::date;
v_total := (v_record ->> 'total_generation')::double precision;
v_peak := COALESCE((v_record ->> 'peak_kw')::double precision, 0);
IF v_plant_id IS NULL THEN
RAISE EXCEPTION 'plant_id is required';
END IF;
IF v_total < 0 OR v_peak < 0 THEN
RAISE EXCEPTION 'generation values must be non-negative: % %', v_plant_id, v_date;
END IF;
SELECT COALESCE(p.capacity, 0)
INTO v_capacity
FROM public.plants AS p
WHERE p.id = v_plant_id;
IF NOT FOUND THEN
RAISE EXCEPTION 'unknown plant_id: %', v_plant_id;
END IF;
INSERT INTO public.daily_stats AS ds (
plant_id,
date,
total_generation,
peak_kw,
generation_hours,
created_at,
updated_at,
source
) VALUES (
v_plant_id,
v_date,
round(v_total::numeric, 2)::double precision,
round(v_peak::numeric, 2)::double precision,
CASE
WHEN v_capacity > 0
THEN round((v_total / v_capacity)::numeric, 2)::double precision
ELSE 0
END,
now(),
now(),
p_source
)
ON CONFLICT (plant_id, date) DO UPDATE
SET total_generation = CASE
WHEN p_allow_decrease OR EXCLUDED.total_generation > ds.total_generation
THEN EXCLUDED.total_generation
ELSE ds.total_generation
END,
peak_kw = GREATEST(COALESCE(ds.peak_kw, 0), EXCLUDED.peak_kw),
generation_hours = CASE
WHEN v_capacity > 0 THEN round((
(CASE
WHEN p_allow_decrease OR EXCLUDED.total_generation > ds.total_generation
THEN EXCLUDED.total_generation
ELSE ds.total_generation
END) / v_capacity
)::numeric, 2)::double precision
ELSE 0
END,
updated_at = CASE
WHEN ds.total_generation IS DISTINCT FROM (
CASE
WHEN p_allow_decrease OR EXCLUDED.total_generation > ds.total_generation
THEN EXCLUDED.total_generation
ELSE ds.total_generation
END
) OR ds.peak_kw IS DISTINCT FROM GREATEST(COALESCE(ds.peak_kw, 0), EXCLUDED.peak_kw)
THEN now()
ELSE ds.updated_at
END,
source = CASE
WHEN ds.total_generation IS DISTINCT FROM EXCLUDED.total_generation
AND (p_allow_decrease OR EXCLUDED.total_generation > ds.total_generation)
THEN p_source
ELSE ds.source
END
RETURNING ds.* INTO v_result;
RETURN NEXT v_result;
END LOOP;
RETURN;
END;
$$;
REVOKE ALL ON FUNCTION public.upsert_daily_stats(jsonb, text, boolean) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION public.upsert_daily_stats(jsonb, text, boolean)
TO anon, authenticated, service_role;
REVOKE ALL ON FUNCTION public.refresh_monthly_stat(text, text) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION public.refresh_monthly_stat(text, text)
TO authenticated, service_role;
-- Preserve legacy monthly totals, but populate their last included daily date.
UPDATE public.monthly_stats AS ms
SET last_date = daily.last_date
FROM (
SELECT plant_id, to_char(date, 'YYYY-MM') AS month, max(date) AS last_date
FROM public.daily_stats
GROUP BY plant_id, to_char(date, 'YYYY-MM')
) AS daily
WHERE ms.plant_id = daily.plant_id
AND ms.month = daily.month
AND ms.last_date IS DISTINCT FROM daily.last_date;
-- Create or refresh only the current KST month. Historical totals remain untouched.
DO $$
DECLARE
v_pair record;
v_current_month text := to_char(timezone('Asia/Seoul', now()), 'YYYY-MM');
BEGIN
FOR v_pair IN
SELECT DISTINCT plant_id
FROM public.daily_stats
WHERE date >= (v_current_month || '-01')::date
AND date < ((v_current_month || '-01')::date + interval '1 month')::date
LOOP
PERFORM public.refresh_monthly_stat(v_pair.plant_id, v_current_month);
END LOOP;
END;
$$;

View File

@ -0,0 +1,153 @@
\set ON_ERROR_STOP on
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'anon') THEN
CREATE ROLE anon NOLOGIN;
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'authenticated') THEN
CREATE ROLE authenticated NOLOGIN;
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'service_role') THEN
CREATE ROLE service_role NOLOGIN;
END IF;
END;
$$;
CREATE TABLE public.plants (
id text PRIMARY KEY,
capacity double precision
);
CREATE TABLE public.daily_stats (
id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
plant_id text NOT NULL REFERENCES public.plants(id),
date date NOT NULL,
total_generation double precision DEFAULT 0,
peak_kw double precision DEFAULT 0,
generation_hours double precision DEFAULT 0,
created_at timestamp with time zone DEFAULT now(),
UNIQUE (plant_id, date)
);
CREATE TABLE public.monthly_stats (
id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
plant_id text NOT NULL REFERENCES public.plants(id),
month text NOT NULL,
total_generation double precision DEFAULT 0,
currnet_last_date text,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
UNIQUE (plant_id, month)
);
INSERT INTO public.plants (id, capacity) VALUES ('plant-a', 100);
INSERT INTO public.monthly_stats (plant_id, month, total_generation)
VALUES ('plant-a', '2026-05', 500);
\ir ../migrations/20260807000001_stats_write_consistency.sql
SELECT public.upsert_daily_stats(
'[{"plant_id":"plant-a","date":"2026-06-01","total_generation":100,"peak_kw":20}]'::jsonb,
'realtime',
false
);
SELECT public.upsert_daily_stats(
'[{"plant_id":"plant-a","date":"2026-06-01","total_generation":80,"peak_kw":15}]'::jsonb,
'history',
false
);
DO $$
DECLARE
v_daily public.daily_stats%ROWTYPE;
v_monthly public.monthly_stats%ROWTYPE;
BEGIN
SELECT * INTO v_daily FROM public.daily_stats
WHERE plant_id = 'plant-a' AND date = '2026-06-01';
IF v_daily.total_generation <> 100
OR v_daily.peak_kw <> 20
OR v_daily.generation_hours <> 1
OR v_daily.source <> 'realtime' THEN
RAISE EXCEPTION 'automated decrease protection failed: %', row_to_json(v_daily);
END IF;
SELECT * INTO v_monthly FROM public.monthly_stats
WHERE plant_id = 'plant-a' AND month = '2026-06';
IF v_monthly.total_generation <> 100
OR v_monthly.last_date <> '2026-06-01'::date
OR v_monthly.source <> 'derived_daily' THEN
RAISE EXCEPTION 'monthly trigger failed: %', row_to_json(v_monthly);
END IF;
END;
$$;
SELECT public.upsert_daily_stats(
'[{"plant_id":"plant-a","date":"2026-06-01","total_generation":120,"peak_kw":25}]'::jsonb,
'history',
false
);
SELECT public.upsert_daily_stats(
'[{"plant_id":"plant-a","date":"2026-06-01","total_generation":90,"peak_kw":0}]'::jsonb,
'excel_daily',
true
);
DO $$
DECLARE
v_daily public.daily_stats%ROWTYPE;
v_monthly public.monthly_stats%ROWTYPE;
BEGIN
SELECT * INTO v_daily FROM public.daily_stats
WHERE plant_id = 'plant-a' AND date = '2026-06-01';
IF v_daily.total_generation <> 90
OR v_daily.peak_kw <> 25
OR v_daily.generation_hours <> 0.9
OR v_daily.source <> 'excel_daily' THEN
RAISE EXCEPTION 'manual correction policy failed: %', row_to_json(v_daily);
END IF;
SELECT * INTO v_monthly FROM public.monthly_stats
WHERE plant_id = 'plant-a' AND month = '2026-06';
IF v_monthly.total_generation <> 90 THEN
RAISE EXCEPTION 'monthly correction sync failed: %', row_to_json(v_monthly);
END IF;
END;
$$;
INSERT INTO public.monthly_stats (
plant_id, month, total_generation, last_date, source
) VALUES (
'plant-a', '2026-07', 777, '2026-07-31', 'excel_monthly'
);
SELECT public.upsert_daily_stats(
'[{"plant_id":"plant-a","date":"2026-07-01","total_generation":10,"peak_kw":1}]'::jsonb,
'history',
false
);
DO $$
DECLARE
v_total double precision;
v_source text;
v_legacy_total double precision;
BEGIN
SELECT total_generation, source INTO v_total, v_source
FROM public.monthly_stats
WHERE plant_id = 'plant-a' AND month = '2026-07';
IF v_total <> 777 OR v_source <> 'excel_monthly' THEN
RAISE EXCEPTION 'authoritative monthly value was overwritten';
END IF;
SELECT total_generation INTO v_legacy_total
FROM public.monthly_stats
WHERE plant_id = 'plant-a' AND month = '2026-05';
IF v_legacy_total <> 500 THEN
RAISE EXCEPTION 'legacy monthly value was changed during migration';
END IF;
END;
$$;
SELECT 'stats_write_consistency_test passed' AS result;