feat: Phase 1 잔여 기능 구현 완료

- 품질시험 API: schemas/quality.py + api/quality.py (CRUD, 합격률 요약, 자동 합불 판정)
- PDF 생성: WeasyPrint + Jinja2 (작업일보/검측요청서/보고서 템플릿 + /pdf 다운로드 엔드포인트)
- RAG 시드 스크립트: scripts/seed_rag.py (PDF/TXT 청킹, 배치 임베딩, CLI)
- APScheduler: 날씨 3시간 주기 자동 수집 + 경보 평가

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
sinmb79
2026-03-24 21:39:05 +09:00
parent 2a4950d8a0
commit 0156d8ca4f
13 changed files with 990 additions and 3 deletions

View File

@@ -0,0 +1,143 @@
"""
APScheduler 날씨 자동 수집 배치
- 3시간마다 활성 프로젝트의 날씨 데이터를 수집
- 수집 후 날씨 경보 평가
"""
import logging
from datetime import date, datetime
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.interval import IntervalTrigger
from sqlalchemy import select
from app.core.database import AsyncSessionLocal
from app.models.project import Project
from app.models.weather import WeatherData, WeatherAlert
from app.services.weather_service import fetch_short_term_forecast, evaluate_alerts
logger = logging.getLogger(__name__)
_scheduler: AsyncIOScheduler | None = None
async def _collect_weather_for_all_projects():
"""활성 프로젝트 전체의 날씨를 수집하고 경보를 평가합니다."""
async with AsyncSessionLocal() as db:
result = await db.execute(
select(Project).where(Project.status == "active")
)
projects = result.scalars().all()
if not projects:
return
logger.info(f"날씨 수집 시작: {len(projects)}개 프로젝트")
for project in projects:
# 프로젝트에 KMA 격자 좌표가 없으면 스킵
if not project.kma_nx or not project.kma_ny:
continue
try:
forecasts = await fetch_short_term_forecast(project.kma_nx, project.kma_ny)
today = date.today().isoformat()
today_forecasts = [f for f in forecasts if f.get("date") == today]
if today_forecasts:
# 오늘 날씨 데이터 upsert (중복 저장 방지)
existing = await db.execute(
select(WeatherData).where(
WeatherData.project_id == project.id,
WeatherData.forecast_date == date.today(),
)
)
weather_row = existing.scalar_one_or_none()
if not weather_row:
# 최고/최저 기온 계산
temps = [f.get("temperature") for f in today_forecasts if f.get("temperature") is not None]
precips = [f.get("precipitation_mm", 0) or 0 for f in today_forecasts]
wind_speeds = [f.get("wind_speed", 0) or 0 for f in today_forecasts]
weather_row = WeatherData(
project_id=project.id,
forecast_date=date.today(),
temperature_max=max(temps) if temps else None,
temperature_min=min(temps) if temps else None,
precipitation_mm=sum(precips),
wind_speed_max=max(wind_speeds) if wind_speeds else None,
sky_condition=today_forecasts[0].get("sky_condition"),
raw_forecast=today_forecasts,
)
db.add(weather_row)
await db.flush()
# 날씨 경보 평가 (활성 태스크 기반)
from app.models.task import Task
tasks_result = await db.execute(
select(Task).where(
Task.project_id == project.id,
Task.status.in_(["not_started", "in_progress"]),
)
)
tasks = tasks_result.scalars().all()
# 오늘 이미 생성된 경보 확인
existing_alerts = await db.execute(
select(WeatherAlert).where(
WeatherAlert.project_id == project.id,
WeatherAlert.alert_date == date.today(),
)
)
already_alerted = existing_alerts.scalars().all()
alerted_types = {a.alert_type for a in already_alerted}
new_alerts = evaluate_alerts(
forecasts=today_forecasts,
tasks=tasks,
existing_alert_types=alerted_types,
)
for alert_data in new_alerts:
alert = WeatherAlert(
project_id=project.id,
alert_date=date.today(),
**alert_data,
)
db.add(alert)
except Exception as e:
logger.error(f"프로젝트 {project.id} 날씨 수집 실패: {e}")
continue
await db.commit()
logger.info("날씨 수집 완료")
def start_scheduler():
"""FastAPI 시작 시 스케줄러를 초기화하고 시작합니다."""
global _scheduler
_scheduler = AsyncIOScheduler(timezone="Asia/Seoul")
# 3시간마다 날씨 수집
_scheduler.add_job(
_collect_weather_for_all_projects,
trigger=IntervalTrigger(hours=3),
id="weather_collect",
name="날씨 데이터 자동 수집",
replace_existing=True,
misfire_grace_time=300, # 5분 내 누락 허용
)
_scheduler.start()
logger.info("APScheduler 시작: 날씨 수집 3시간 주기")
return _scheduler
def stop_scheduler():
"""FastAPI 종료 시 스케줄러를 중지합니다."""
global _scheduler
if _scheduler and _scheduler.running:
_scheduler.shutdown(wait=False)
logger.info("APScheduler 종료")