소형 건설업체(100억 미만)를 위한 AI 기반 토목공사 통합관리 플랫폼 Backend (FastAPI): - SQLAlchemy 모델 13개 (users, projects, wbs, tasks, daily_reports, reports, inspections, quality, weather, permits, rag, settings) - API 라우터 11개 (auth, projects, tasks, daily_reports, reports, inspections, weather, rag, kakao, permits, settings) - Services: Claude AI 래퍼, CPM Gantt 계산, 기상청 API, RAG(pgvector), 카카오 Skill API - Alembic 마이그레이션 (pgvector 포함) - pytest 테스트 (CPM, 날씨 경보) Frontend (Next.js 15): - 11개 페이지 (대시보드, 프로젝트, Gantt, 일보, 검측, 품질, 날씨, 인허가, RAG, 설정) - TanStack Query + Zustand + Tailwind CSS 인프라: - Docker Compose (PostgreSQL pgvector + backend + frontend) - 한국어 README 및 설치 가이드 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
137 lines
5.6 KiB
Python
137 lines
5.6 KiB
Python
import uuid
|
|
from datetime import datetime, timezone
|
|
from fastapi import APIRouter, HTTPException
|
|
from sqlalchemy import select
|
|
from app.deps import CurrentUser, DB
|
|
from app.models.project import Project
|
|
from app.models.weather import WeatherData, WeatherAlert, ForecastType
|
|
from app.models.task import Task
|
|
from app.schemas.weather import WeatherDataResponse, WeatherAlertResponse, WeatherForecastSummary
|
|
from app.services.weather_service import fetch_short_term_forecast, evaluate_weather_alerts
|
|
|
|
router = APIRouter(prefix="/projects/{project_id}/weather", tags=["날씨 연동"])
|
|
|
|
|
|
async def _get_project_or_404(project_id: uuid.UUID, db: DB) -> Project:
|
|
result = await db.execute(select(Project).where(Project.id == project_id))
|
|
p = result.scalar_one_or_none()
|
|
if not p:
|
|
raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다")
|
|
return p
|
|
|
|
|
|
@router.get("", response_model=WeatherForecastSummary)
|
|
async def get_weather(project_id: uuid.UUID, db: DB, current_user: CurrentUser):
|
|
"""Get weather forecast and active alerts for a project."""
|
|
from datetime import date
|
|
today = date.today()
|
|
|
|
forecast_result = await db.execute(
|
|
select(WeatherData)
|
|
.where(WeatherData.project_id == project_id, WeatherData.forecast_date >= today)
|
|
.order_by(WeatherData.forecast_date)
|
|
)
|
|
forecast = forecast_result.scalars().all()
|
|
|
|
alerts_result = await db.execute(
|
|
select(WeatherAlert)
|
|
.where(WeatherAlert.project_id == project_id, WeatherAlert.alert_date >= today, WeatherAlert.is_acknowledged == False)
|
|
.order_by(WeatherAlert.alert_date)
|
|
)
|
|
alerts = alerts_result.scalars().all()
|
|
|
|
return WeatherForecastSummary(
|
|
forecast=[WeatherDataResponse.model_validate(f) for f in forecast],
|
|
active_alerts=[WeatherAlertResponse.model_validate(a) for a in alerts],
|
|
)
|
|
|
|
|
|
@router.post("/refresh")
|
|
async def refresh_weather(project_id: uuid.UUID, db: DB, current_user: CurrentUser):
|
|
"""Fetch fresh weather data from KMA and evaluate alerts."""
|
|
project = await _get_project_or_404(project_id, db)
|
|
|
|
if not project.weather_grid_x or not project.weather_grid_y:
|
|
raise HTTPException(status_code=400, detail="프로젝트에 위치 정보(위경도)가 설정되지 않았습니다")
|
|
|
|
forecasts = await fetch_short_term_forecast(project.weather_grid_x, project.weather_grid_y)
|
|
|
|
# Save/update weather data
|
|
for fc in forecasts:
|
|
from datetime import date
|
|
fc_date = date.fromisoformat(fc["date"])
|
|
|
|
existing = await db.execute(
|
|
select(WeatherData).where(
|
|
WeatherData.project_id == project_id,
|
|
WeatherData.forecast_date == fc_date,
|
|
WeatherData.forecast_type == ForecastType.SHORT_TERM,
|
|
)
|
|
)
|
|
wd = existing.scalar_one_or_none()
|
|
if not wd:
|
|
wd = WeatherData(project_id=project_id, forecast_type=ForecastType.SHORT_TERM)
|
|
db.add(wd)
|
|
|
|
wd.forecast_date = fc_date
|
|
wd.temperature_high = fc.get("temperature_high")
|
|
wd.temperature_low = fc.get("temperature_low")
|
|
wd.precipitation_mm = fc.get("precipitation_mm")
|
|
wd.wind_speed_ms = fc.get("wind_speed_ms")
|
|
wd.weather_code = fc.get("weather_code")
|
|
wd.raw_data = fc
|
|
wd.fetched_at = datetime.now(timezone.utc)
|
|
|
|
# Get tasks in upcoming forecast period
|
|
from datetime import timedelta
|
|
start_date = date.today()
|
|
end_date = start_date + timedelta(days=len(forecasts))
|
|
tasks_result = await db.execute(
|
|
select(Task).where(
|
|
Task.project_id == project_id,
|
|
Task.planned_start >= start_date,
|
|
Task.planned_start <= end_date,
|
|
)
|
|
)
|
|
upcoming_tasks = tasks_result.scalars().all()
|
|
|
|
# Evaluate and save alerts
|
|
for fc in forecasts:
|
|
from datetime import date as date_type
|
|
fc_date_obj = date_type.fromisoformat(fc["date"])
|
|
tasks_on_date = [t for t in upcoming_tasks if t.planned_start and t.planned_start <= fc_date_obj <= (t.planned_end or fc_date_obj)]
|
|
new_alerts = evaluate_weather_alerts(fc, tasks_on_date)
|
|
|
|
for alert_data in new_alerts:
|
|
existing_alert = await db.execute(
|
|
select(WeatherAlert).where(
|
|
WeatherAlert.project_id == project_id,
|
|
WeatherAlert.alert_date == fc_date_obj,
|
|
WeatherAlert.alert_type == alert_data["alert_type"],
|
|
)
|
|
)
|
|
if not existing_alert.scalar_one_or_none():
|
|
alert = WeatherAlert(
|
|
project_id=project_id,
|
|
task_id=uuid.UUID(alert_data["task_id"]) if alert_data.get("task_id") else None,
|
|
alert_date=fc_date_obj,
|
|
alert_type=alert_data["alert_type"],
|
|
severity=alert_data["severity"],
|
|
message=alert_data["message"],
|
|
)
|
|
db.add(alert)
|
|
|
|
await db.commit()
|
|
return {"message": f"날씨 정보가 업데이트되었습니다 ({len(forecasts)}일치)"}
|
|
|
|
|
|
@router.put("/alerts/{alert_id}/acknowledge")
|
|
async def acknowledge_alert(project_id: uuid.UUID, alert_id: uuid.UUID, db: DB, current_user: CurrentUser):
|
|
result = await db.execute(select(WeatherAlert).where(WeatherAlert.id == alert_id, WeatherAlert.project_id == project_id))
|
|
alert = result.scalar_one_or_none()
|
|
if not alert:
|
|
raise HTTPException(status_code=404, detail="경보를 찾을 수 없습니다")
|
|
alert.is_acknowledged = True
|
|
await db.commit()
|
|
return {"message": "경보가 확인 처리되었습니다"}
|