Files
conai/backend/app/api/weather.py
sinmb79 2a4950d8a0 feat: CONAI Phase 1 MVP 초기 구현
소형 건설업체(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>
2026-03-24 20:06:36 +09:00

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": "경보가 확인 처리되었습니다"}