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
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:
parent
b834cc4218
commit
a716dbef96
97
.github/workflows/ci.yml
vendored
Normal file
97
.github/workflows/ci.yml
vendored
Normal 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
2
.gitignore
vendored
|
|
@ -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
|
||||
|
||||
|
|
|
|||
34
api_server/app/core/stats_storage.py
Normal file
34
api_server/app/core/stats_storage.py
Normal 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 []
|
||||
32
api_server/app/core/time_utils.py
Normal file
32
api_server/app/core/time_utils.py
Normal 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)
|
||||
|
|
@ -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__":
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
15
api_server/app/schemas/health.py
Normal file
15
api_server/app/schemas/health.py
Normal 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
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
50
api_server/app/schemas/stats.py
Normal file
50
api_server/app/schemas/stats.py
Normal 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
|
||||
15
api_server/app/schemas/upload.py
Normal file
15
api_server/app/schemas/upload.py
Normal 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
|
||||
8
api_server/requirements.in
Normal file
8
api_server/requirements.in
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
100
api_server/tests/test_api_contract.py
Normal file
100
api_server/tests/test_api_contract.py
Normal 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()
|
||||
153
api_server/tests/test_plants.py
Normal file
153
api_server/tests/test_plants.py
Normal 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()
|
||||
170
api_server/tests/test_stats_timezone.py
Normal file
170
api_server/tests/test_stats_timezone.py
Normal 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()
|
||||
180
api_server/tests/test_upload.py
Normal file
180
api_server/tests/test_upload.py
Normal 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
1
app/.gitignore
vendored
|
|
@ -11,7 +11,6 @@ node_modules/
|
|||
|
||||
# npm
|
||||
npm-debug.log
|
||||
package-lock.json
|
||||
yarn-error.log
|
||||
|
||||
# Mac
|
||||
|
|
|
|||
|
|
@ -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
11802
app/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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'):
|
||||
"""
|
||||
데이터 품질 검증
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
8
crawler/requirements.in
Normal 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
59
crawler/requirements.txt
Normal 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
|
||||
30
crawler/tests/check_alert_setting.py
Normal file
30
crawler/tests/check_alert_setting.py
Normal 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()
|
||||
79
crawler/tests/check_history_source.py
Normal file
79
crawler/tests/check_history_source.py
Normal 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()
|
||||
146
crawler/tests/check_stats_consistency.py
Normal file
146
crawler/tests/check_stats_consistency.py
Normal 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())
|
||||
9
crawler/tests/fixtures/cmsolar_daily.html
vendored
Normal file
9
crawler/tests/fixtures/cmsolar_daily.html
vendored
Normal 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>
|
||||
13
crawler/tests/fixtures/hyundai_daily.json
vendored
Normal file
13
crawler/tests/fixtures/hyundai_daily.json
vendored
Normal 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
10
crawler/tests/fixtures/kremc_daily.json
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"data": {
|
||||
"userByTimeDataResultDtoList": [
|
||||
{
|
||||
"gathDtm": "2026-08-05 00:00:00",
|
||||
"dayEnergy": "53.25"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
3
crawler/tests/fixtures/kremc_login.json
vendored
Normal file
3
crawler/tests/fixtures/kremc_login.json
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"data": "fixture-auth-token"
|
||||
}
|
||||
8
crawler/tests/fixtures/nrems_daily.json
vendored
Normal file
8
crawler/tests/fixtures/nrems_daily.json
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"pdata": [
|
||||
{
|
||||
"DATE": "08-05",
|
||||
"INV": "121.50"
|
||||
}
|
||||
]
|
||||
}
|
||||
9
crawler/tests/fixtures/sun_wms_daily.html
vendored
Normal file
9
crawler/tests/fixtures/sun_wms_daily.html
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
<html>
|
||||
<body>
|
||||
<table>
|
||||
<tbody>
|
||||
<tr><td>2026-08-05</td><td>52.59</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
127
crawler/tests/test_alert_manager.py
Normal file
127
crawler/tests/test_alert_manager.py
Normal 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()
|
||||
162
crawler/tests/test_backward_backfill.py
Normal file
162
crawler/tests/test_backward_backfill.py
Normal 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()
|
||||
128
crawler/tests/test_daily_summary_timezone.py
Normal file
128
crawler/tests/test_daily_summary_timezone.py
Normal 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()
|
||||
171
crawler/tests/test_database_history.py
Normal file
171
crawler/tests/test_database_history.py
Normal 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()
|
||||
99
crawler/tests/test_history_fetch_contract.py
Normal file
99
crawler/tests/test_history_fetch_contract.py
Normal 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()
|
||||
155
crawler/tests/test_history_response_fixtures.py
Normal file
155
crawler/tests/test_history_response_fixtures.py
Normal 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()
|
||||
84
crawler/tests/test_time_utils.py
Normal file
84
crawler/tests/test_time_utils.py
Normal 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
44
crawler/time_utils.py
Normal 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)
|
||||
37
deploy/nginx/solorpower.conf
Normal file
37
deploy/nginx/solorpower.conf
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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,9 +36,17 @@ 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()"
|
||||
```
|
||||
|
||||
* **리눅스(서버 터미널)에서 실행 시:**
|
||||
```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()\""
|
||||
```
|
||||
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -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은 매시간 무작업 실행 중입니다. 비활성화는 운영 스케줄 변경이므로 별도 결정 후 수행합니다.
|
||||
|
|
|
|||
76
docs/development_and_testing.md
Normal file
76
docs/development_and_testing.md
Normal 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 전송은 배포 후 별도의 운영 확인 절차로 검증한다.
|
||||
743
docs/project_improvement_plan.md
Normal file
743
docs/project_improvement_plan.md
Normal 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 오탐은 발생하지 않았다.
|
||||
|
|
@ -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 경로 동기화)*
|
||||
|
|
|
|||
303
supabase/migrations/20260807000001_stats_write_consistency.sql
Normal file
303
supabase/migrations/20260807000001_stats_write_consistency.sql
Normal 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;
|
||||
$$;
|
||||
153
supabase/tests/stats_write_consistency_test.sql
Normal file
153
supabase/tests/stats_write_consistency_test.sql
Normal 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;
|
||||
Loading…
Reference in New Issue
Block a user