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>
This commit is contained in:
sinmb79
2026-03-24 20:06:36 +09:00
commit 2a4950d8a0
99 changed files with 7447 additions and 0 deletions
View File
+74
View File
@@ -0,0 +1,74 @@
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from typing import Annotated
from app.core.database import get_db
from app.core.security import verify_password, get_password_hash, create_access_token, create_refresh_token, decode_token
from app.models.user import User
from app.schemas.user import UserCreate, UserResponse, TokenResponse
from app.deps import CurrentUser
router = APIRouter(prefix="/auth", tags=["인증"])
@router.post("/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
async def register(data: UserCreate, db: Annotated[AsyncSession, Depends(get_db)]):
result = await db.execute(select(User).where(User.email == data.email))
if result.scalar_one_or_none():
raise HTTPException(status_code=400, detail="이미 등록된 이메일입니다")
user = User(
email=data.email,
hashed_password=get_password_hash(data.password),
name=data.name,
role=data.role,
phone=data.phone,
)
db.add(user)
await db.commit()
await db.refresh(user)
return user
@router.post("/login", response_model=TokenResponse)
async def login(
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
db: Annotated[AsyncSession, Depends(get_db)],
):
result = await db.execute(select(User).where(User.email == form_data.username, User.is_active == True))
user = result.scalar_one_or_none()
if not user or not verify_password(form_data.password, user.hashed_password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="이메일 또는 비밀번호가 올바르지 않습니다",
headers={"WWW-Authenticate": "Bearer"},
)
return TokenResponse(
access_token=create_access_token(str(user.id)),
refresh_token=create_refresh_token(str(user.id)),
user=UserResponse.model_validate(user),
)
@router.post("/refresh", response_model=TokenResponse)
async def refresh_token(refresh_token_str: str, db: Annotated[AsyncSession, Depends(get_db)]):
payload = decode_token(refresh_token_str)
if not payload or payload.get("type") != "refresh":
raise HTTPException(status_code=401, detail="유효하지 않은 리프레시 토큰입니다")
result = await db.execute(select(User).where(User.id == payload["sub"], User.is_active == True))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=401, detail="사용자를 찾을 수 없습니다")
return TokenResponse(
access_token=create_access_token(str(user.id)),
refresh_token=create_refresh_token(str(user.id)),
user=UserResponse.model_validate(user),
)
@router.get("/me", response_model=UserResponse)
async def get_me(current_user: CurrentUser):
return current_user
+107
View File
@@ -0,0 +1,107 @@
import uuid
from datetime import date
from fastapi import APIRouter, HTTPException, status
from sqlalchemy import select
from app.deps import CurrentUser, DB
from app.models.daily_report import DailyReport, InputSource
from app.models.project import Project
from app.schemas.daily_report import (
DailyReportCreate, DailyReportUpdate, DailyReportGenerateRequest, DailyReportResponse
)
from app.services.daily_report_gen import generate_work_content
router = APIRouter(prefix="/projects/{project_id}/daily-reports", 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=list[DailyReportResponse])
async def list_reports(project_id: uuid.UUID, db: DB, current_user: CurrentUser):
result = await db.execute(
select(DailyReport)
.where(DailyReport.project_id == project_id)
.order_by(DailyReport.report_date.desc())
)
return result.scalars().all()
@router.post("", response_model=DailyReportResponse, status_code=status.HTTP_201_CREATED)
async def create_report(project_id: uuid.UUID, data: DailyReportCreate, db: DB, current_user: CurrentUser):
await _get_project_or_404(project_id, db)
report = DailyReport(**data.model_dump(), project_id=project_id, input_source=InputSource.WEB)
db.add(report)
await db.commit()
await db.refresh(report)
return report
@router.post("/generate", response_model=DailyReportResponse, status_code=status.HTTP_201_CREATED)
async def generate_report(project_id: uuid.UUID, data: DailyReportGenerateRequest, db: DB, current_user: CurrentUser):
"""AI-generate daily report content from structured input."""
project = await _get_project_or_404(project_id, db)
work_content = await generate_work_content(
project_name=project.name,
report_date=str(data.report_date),
weather_summary="맑음", # Will be filled from weather data
temperature_high=None,
temperature_low=None,
workers_count=data.workers_count,
equipment_list=data.equipment_list or [],
work_items=data.work_items,
issues=data.issues,
)
report = DailyReport(
project_id=project_id,
report_date=data.report_date,
workers_count=data.workers_count,
equipment_list=data.equipment_list,
work_content=work_content,
issues=data.issues,
input_source=InputSource.WEB,
ai_generated=True,
)
db.add(report)
await db.commit()
await db.refresh(report)
return report
@router.get("/{report_id}", response_model=DailyReportResponse)
async def get_report(project_id: uuid.UUID, report_id: uuid.UUID, db: DB, current_user: CurrentUser):
result = await db.execute(select(DailyReport).where(DailyReport.id == report_id, DailyReport.project_id == project_id))
report = result.scalar_one_or_none()
if not report:
raise HTTPException(status_code=404, detail="일보를 찾을 수 없습니다")
return report
@router.put("/{report_id}", response_model=DailyReportResponse)
async def update_report(project_id: uuid.UUID, report_id: uuid.UUID, data: DailyReportUpdate, db: DB, current_user: CurrentUser):
result = await db.execute(select(DailyReport).where(DailyReport.id == report_id, DailyReport.project_id == project_id))
report = result.scalar_one_or_none()
if not report:
raise HTTPException(status_code=404, detail="일보를 찾을 수 없습니다")
for field, value in data.model_dump(exclude_none=True).items():
setattr(report, field, value)
await db.commit()
await db.refresh(report)
return report
@router.delete("/{report_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_report(project_id: uuid.UUID, report_id: uuid.UUID, db: DB, current_user: CurrentUser):
result = await db.execute(select(DailyReport).where(DailyReport.id == report_id, DailyReport.project_id == project_id))
report = result.scalar_one_or_none()
if not report:
raise HTTPException(status_code=404, detail="일보를 찾을 수 없습니다")
await db.delete(report)
await db.commit()
+106
View File
@@ -0,0 +1,106 @@
import uuid
from fastapi import APIRouter, HTTPException, status
from sqlalchemy import select
from app.deps import CurrentUser, DB
from app.models.inspection import InspectionRequest
from app.models.project import Project, WBSItem
from app.schemas.inspection import InspectionCreate, InspectionUpdate, InspectionGenerateRequest, InspectionResponse
from app.services.inspection_gen import generate_checklist
router = APIRouter(prefix="/projects/{project_id}/inspections", 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=list[InspectionResponse])
async def list_inspections(project_id: uuid.UUID, db: DB, current_user: CurrentUser):
result = await db.execute(
select(InspectionRequest)
.where(InspectionRequest.project_id == project_id)
.order_by(InspectionRequest.requested_date.desc())
)
return result.scalars().all()
@router.post("", response_model=InspectionResponse, status_code=status.HTTP_201_CREATED)
async def create_inspection(project_id: uuid.UUID, data: InspectionCreate, db: DB, current_user: CurrentUser):
await _get_project_or_404(project_id, db)
inspection = InspectionRequest(**data.model_dump(), project_id=project_id)
db.add(inspection)
await db.commit()
await db.refresh(inspection)
return inspection
@router.post("/generate", response_model=InspectionResponse, status_code=status.HTTP_201_CREATED)
async def generate_inspection(project_id: uuid.UUID, data: InspectionGenerateRequest, db: DB, current_user: CurrentUser):
"""AI-generate inspection request checklist."""
project = await _get_project_or_404(project_id, db)
# Get WBS item name if provided
wbs_name = None
if data.wbs_item_id:
wbs_result = await db.execute(select(WBSItem).where(WBSItem.id == data.wbs_item_id))
wbs = wbs_result.scalar_one_or_none()
if wbs:
wbs_name = wbs.name
checklist = await generate_checklist(
project_name=project.name,
inspection_type=data.inspection_type,
location_detail=data.location_detail,
requested_date=str(data.requested_date),
wbs_name=wbs_name,
)
inspection = InspectionRequest(
project_id=project_id,
wbs_item_id=data.wbs_item_id,
inspection_type=data.inspection_type,
requested_date=data.requested_date,
location_detail=data.location_detail,
checklist_items=checklist,
ai_generated=True,
)
db.add(inspection)
await db.commit()
await db.refresh(inspection)
return inspection
@router.get("/{inspection_id}", response_model=InspectionResponse)
async def get_inspection(project_id: uuid.UUID, inspection_id: uuid.UUID, db: DB, current_user: CurrentUser):
result = await db.execute(select(InspectionRequest).where(InspectionRequest.id == inspection_id, InspectionRequest.project_id == project_id))
insp = result.scalar_one_or_none()
if not insp:
raise HTTPException(status_code=404, detail="검측요청서를 찾을 수 없습니다")
return insp
@router.put("/{inspection_id}", response_model=InspectionResponse)
async def update_inspection(project_id: uuid.UUID, inspection_id: uuid.UUID, data: InspectionUpdate, db: DB, current_user: CurrentUser):
result = await db.execute(select(InspectionRequest).where(InspectionRequest.id == inspection_id, InspectionRequest.project_id == project_id))
insp = result.scalar_one_or_none()
if not insp:
raise HTTPException(status_code=404, detail="검측요청서를 찾을 수 없습니다")
for field, value in data.model_dump(exclude_none=True).items():
setattr(insp, field, value)
await db.commit()
await db.refresh(insp)
return insp
@router.delete("/{inspection_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_inspection(project_id: uuid.UUID, inspection_id: uuid.UUID, db: DB, current_user: CurrentUser):
result = await db.execute(select(InspectionRequest).where(InspectionRequest.id == inspection_id, InspectionRequest.project_id == project_id))
insp = result.scalar_one_or_none()
if not insp:
raise HTTPException(status_code=404, detail="검측요청서를 찾을 수 없습니다")
await db.delete(insp)
await db.commit()
+154
View File
@@ -0,0 +1,154 @@
"""
Kakao Chatbot Skill API webhook endpoints.
"""
from fastapi import APIRouter, Request, HTTPException
from sqlalchemy import select
from app.deps import DB
from app.models.user import User
from app.models.project import Project
from app.models.daily_report import DailyReport, InputSource
from app.services.kakao_service import (
detect_intent, parse_daily_report_input, make_help_response,
simple_text, basic_card, KakaoIntent,
)
from app.services.daily_report_gen import generate_work_content
from app.services.rag_service import ask as rag_ask
router = APIRouter(prefix="/kakao", tags=["카카오 챗봇"])
@router.post("/webhook")
async def kakao_webhook(request: Request, db: DB):
"""Main Kakao Skill webhook. Routes to appropriate handler."""
body = await request.json()
# Extract user info and utterance
user_request = body.get("userRequest", {})
utterance = user_request.get("utterance", "")
user_key = user_request.get("user", {}).get("id", "")
# Find linked user
user = None
if user_key:
result = await db.execute(select(User).where(User.kakao_user_key == user_key, User.is_active == True))
user = result.scalar_one_or_none()
if not user:
return simple_text(
"안녕하세요! CONAI 현장 관리 시스템입니다.\n"
"서비스를 이용하시려면 웹에서 계정을 연결해주세요.\n"
"📱 conai.app에서 카카오 연동 설정"
)
intent = detect_intent(utterance)
if intent == KakaoIntent.DAILY_REPORT:
return await _handle_daily_report(utterance, user, db)
elif intent == KakaoIntent.RAG_QUESTION:
return await _handle_rag_question(utterance, user, db)
elif intent == KakaoIntent.WEATHER:
return await _handle_weather(user, db)
elif intent == KakaoIntent.HELP:
return make_help_response()
else:
return make_help_response()
async def _handle_daily_report(utterance: str, user: User, db: DB) -> dict:
"""Parse utterance and generate/save daily report."""
# Get user's active project
project_result = await db.execute(
select(Project).where(Project.owner_id == user.id, Project.status == "active").limit(1)
)
project = project_result.scalar_one_or_none()
if not project:
return simple_text("현재 진행 중인 현장이 없습니다. CONAI 웹에서 현장을 등록해주세요.")
parsed = parse_daily_report_input(utterance)
if not parsed.get("work_items"):
return simple_text(
"작업 내용을 입력해주세요.\n\n"
"예시:\n"
"일보: 콘크리트 5명, 철근 3명\n"
"- 관로매설 50m 완료\n"
"- 되메우기 작업"
)
try:
work_content = await generate_work_content(
project_name=project.name,
report_date=parsed["report_date"],
weather_summary="맑음",
temperature_high=None,
temperature_low=None,
workers_count=parsed["workers_count"],
equipment_list=[],
work_items=parsed["work_items"],
issues=parsed.get("issues"),
)
except Exception as e:
work_content = "\n".join(parsed["work_items"])
from datetime import date
report = DailyReport(
project_id=project.id,
report_date=date.fromisoformat(parsed["report_date"]),
workers_count=parsed.get("workers_count"),
work_content=work_content,
issues=parsed.get("issues"),
input_source=InputSource.KAKAO,
raw_kakao_input=utterance,
ai_generated=True,
)
db.add(report)
await db.commit()
workers_text = ", ".join([f"{k} {v}" for k, v in (parsed.get("workers_count") or {}).items()])
return basic_card(
title=f"📋 {parsed['report_date']} 작업일보 생성완료",
description=f"현장: {project.name}\n투입인원: {workers_text or '미기입'}\n\n{work_content[:200]}...",
buttons=[{"action": "webLink", "label": "일보 확인/수정", "webLinkUrl": f"https://conai.app/projects/{project.id}/reports"}],
)
async def _handle_rag_question(utterance: str, user: User, db: DB) -> dict:
"""Handle RAG Q&A from Kakao."""
question = utterance.replace("질문:", "").replace("질문 ", "").strip()
if not question:
return simple_text("질문 내용을 입력해주세요.\n예: 질문: 굴착 5m 흙막이 기준은?")
try:
result = await rag_ask(db, question, top_k=3)
answer = result.get("answer", "답변을 생성할 수 없습니다")
# Truncate for Kakao (2000 char limit)
if len(answer) > 900:
answer = answer[:900] + "...\n\n[전체 답변은 CONAI 웹에서 확인하세요]"
return simple_text(f"📚 {question}\n\n{answer}\n\n⚠️ 이 답변은 참고용이며 법률 자문이 아닙니다.")
except Exception as e:
return simple_text("현재 Q&A 서비스를 이용할 수 없습니다. 잠시 후 다시 시도해주세요.")
async def _handle_weather(user: User, db: DB) -> dict:
"""Return weather summary for user's active project."""
project_result = await db.execute(
select(Project).where(Project.owner_id == user.id, Project.status == "active").limit(1)
)
project = project_result.scalar_one_or_none()
if not project:
return simple_text("진행 중인 현장이 없습니다.")
from app.models.weather import WeatherAlert
from datetime import date
alerts_result = await db.execute(
select(WeatherAlert)
.where(WeatherAlert.project_id == project.id, WeatherAlert.alert_date >= date.today(), WeatherAlert.is_acknowledged == False)
.limit(5)
)
alerts = alerts_result.scalars().all()
if not alerts:
return simple_text(f"🌤 {project.name}\n\n현재 날씨 경보가 없습니다.")
alert_text = "\n".join([f"⚠️ {a.alert_date}: {a.message}" for a in alerts])
return simple_text(f"🌦 {project.name} 날씨 경보\n\n{alert_text}")
+94
View File
@@ -0,0 +1,94 @@
import uuid
from fastapi import APIRouter, HTTPException, status
from sqlalchemy import select
from app.deps import CurrentUser, DB
from app.models.permit import PermitItem, PermitStatus
from app.models.project import Project
from pydantic import BaseModel
from datetime import date, datetime
class PermitCreate(BaseModel):
permit_type: str
authority: str | None = None
required: bool = True
deadline: date | None = None
notes: str | None = None
sort_order: int = 0
class PermitUpdate(BaseModel):
status: PermitStatus | None = None
submitted_date: date | None = None
approved_date: date | None = None
notes: str | None = None
deadline: date | None = None
class PermitResponse(BaseModel):
id: uuid.UUID
project_id: uuid.UUID
permit_type: str
authority: str | None
required: bool
deadline: date | None
status: PermitStatus
submitted_date: date | None
approved_date: date | None
notes: str | None
sort_order: int
created_at: datetime
model_config = {"from_attributes": True}
router = APIRouter(prefix="/projects/{project_id}/permits", 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=list[PermitResponse])
async def list_permits(project_id: uuid.UUID, db: DB, current_user: CurrentUser):
result = await db.execute(
select(PermitItem).where(PermitItem.project_id == project_id).order_by(PermitItem.sort_order)
)
return result.scalars().all()
@router.post("", response_model=PermitResponse, status_code=status.HTTP_201_CREATED)
async def create_permit(project_id: uuid.UUID, data: PermitCreate, db: DB, current_user: CurrentUser):
await _get_project_or_404(project_id, db)
permit = PermitItem(**data.model_dump(), project_id=project_id)
db.add(permit)
await db.commit()
await db.refresh(permit)
return permit
@router.put("/{permit_id}", response_model=PermitResponse)
async def update_permit(project_id: uuid.UUID, permit_id: uuid.UUID, data: PermitUpdate, db: DB, current_user: CurrentUser):
result = await db.execute(select(PermitItem).where(PermitItem.id == permit_id, PermitItem.project_id == project_id))
permit = result.scalar_one_or_none()
if not permit:
raise HTTPException(status_code=404, detail="인허가 항목을 찾을 수 없습니다")
for field, value in data.model_dump(exclude_none=True).items():
setattr(permit, field, value)
await db.commit()
await db.refresh(permit)
return permit
@router.delete("/{permit_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_permit(project_id: uuid.UUID, permit_id: uuid.UUID, db: DB, current_user: CurrentUser):
result = await db.execute(select(PermitItem).where(PermitItem.id == permit_id, PermitItem.project_id == project_id))
permit = result.scalar_one_or_none()
if not permit:
raise HTTPException(status_code=404, detail="인허가 항목을 찾을 수 없습니다")
await db.delete(permit)
await db.commit()
+161
View File
@@ -0,0 +1,161 @@
import uuid
from fastapi import APIRouter, HTTPException, status
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from app.deps import CurrentUser, DB
from app.models.project import Project, WBSItem
from app.schemas.project import ProjectCreate, ProjectUpdate, ProjectResponse, WBSItemCreate, WBSItemResponse
router = APIRouter(prefix="/projects", tags=["프로젝트"])
@router.get("", response_model=list[ProjectResponse])
async def list_projects(db: DB, current_user: CurrentUser):
result = await db.execute(select(Project).order_by(Project.created_at.desc()))
return result.scalars().all()
@router.post("", response_model=ProjectResponse, status_code=status.HTTP_201_CREATED)
async def create_project(data: ProjectCreate, db: DB, current_user: CurrentUser):
# Check for duplicate code
existing = await db.execute(select(Project).where(Project.code == data.code))
if existing.scalar_one_or_none():
raise HTTPException(status_code=400, detail=f"프로젝트 코드 '{data.code}'가 이미 존재합니다")
project = Project(**data.model_dump(), owner_id=current_user.id)
# Auto-compute KMA grid from lat/lng
if data.location_lat and data.location_lng:
grid_x, grid_y = _latlon_to_kma_grid(data.location_lat, data.location_lng)
project.weather_grid_x = grid_x
project.weather_grid_y = grid_y
db.add(project)
await db.commit()
await db.refresh(project)
return project
@router.get("/{project_id}", response_model=ProjectResponse)
async def get_project(project_id: uuid.UUID, db: DB, current_user: CurrentUser):
result = await db.execute(select(Project).where(Project.id == project_id))
project = result.scalar_one_or_none()
if not project:
raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다")
return project
@router.put("/{project_id}", response_model=ProjectResponse)
async def update_project(project_id: uuid.UUID, data: ProjectUpdate, db: DB, current_user: CurrentUser):
result = await db.execute(select(Project).where(Project.id == project_id))
project = result.scalar_one_or_none()
if not project:
raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다")
for field, value in data.model_dump(exclude_none=True).items():
setattr(project, field, value)
# Recompute grid if location changed
if (data.location_lat or data.location_lng) and project.location_lat and project.location_lng:
grid_x, grid_y = _latlon_to_kma_grid(project.location_lat, project.location_lng)
project.weather_grid_x = grid_x
project.weather_grid_y = grid_y
await db.commit()
await db.refresh(project)
return project
@router.delete("/{project_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_project(project_id: uuid.UUID, db: DB, current_user: CurrentUser):
result = await db.execute(select(Project).where(Project.id == project_id))
project = result.scalar_one_or_none()
if not project:
raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다")
await db.delete(project)
await db.commit()
# WBS endpoints
@router.get("/{project_id}/wbs", response_model=list[WBSItemResponse])
async def get_wbs(project_id: uuid.UUID, db: DB, current_user: CurrentUser):
"""Return WBS tree (top-level items with nested children)."""
result = await db.execute(
select(WBSItem)
.where(WBSItem.project_id == project_id, WBSItem.parent_id == None)
.options(selectinload(WBSItem.children).selectinload(WBSItem.children))
.order_by(WBSItem.sort_order)
)
return result.scalars().all()
@router.post("/{project_id}/wbs", response_model=WBSItemResponse, status_code=status.HTTP_201_CREATED)
async def create_wbs_item(project_id: uuid.UUID, data: WBSItemCreate, db: DB, current_user: CurrentUser):
item = WBSItem(**data.model_dump(), project_id=project_id)
db.add(item)
await db.commit()
await db.refresh(item)
return item
@router.put("/{project_id}/wbs/{item_id}", response_model=WBSItemResponse)
async def update_wbs_item(project_id: uuid.UUID, item_id: uuid.UUID, data: WBSItemCreate, db: DB, current_user: CurrentUser):
result = await db.execute(select(WBSItem).where(WBSItem.id == item_id, WBSItem.project_id == project_id))
item = result.scalar_one_or_none()
if not item:
raise HTTPException(status_code=404, detail="WBS 항목을 찾을 수 없습니다")
for field, value in data.model_dump(exclude_none=True).items():
setattr(item, field, value)
await db.commit()
await db.refresh(item)
return item
@router.delete("/{project_id}/wbs/{item_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_wbs_item(project_id: uuid.UUID, item_id: uuid.UUID, db: DB, current_user: CurrentUser):
result = await db.execute(select(WBSItem).where(WBSItem.id == item_id, WBSItem.project_id == project_id))
item = result.scalar_one_or_none()
if not item:
raise HTTPException(status_code=404, detail="WBS 항목을 찾을 수 없습니다")
await db.delete(item)
await db.commit()
def _latlon_to_kma_grid(lat: float, lng: float) -> tuple[int, int]:
"""Convert latitude/longitude to KMA forecast grid coordinates (Lambert Conformal Conic)."""
import math
RE = 6371.00877 # Earth radius (km)
GRID = 5.0 # Grid spacing (km)
SLAT1 = 30.0 # Standard latitude 1
SLAT2 = 60.0 # Standard latitude 2
OLON = 126.0 # Reference longitude
OLAT = 38.0 # Reference latitude
XO = 43 # Reference X
YO = 136 # Reference Y
DEGRAD = math.pi / 180.0
re = RE / GRID
slat1 = SLAT1 * DEGRAD
slat2 = SLAT2 * DEGRAD
olon = OLON * DEGRAD
olat = OLAT * DEGRAD
sn = math.tan(math.pi * 0.25 + slat2 * 0.5) / math.tan(math.pi * 0.25 + slat1 * 0.5)
sn = math.log(math.cos(slat1) / math.cos(slat2)) / math.log(sn)
sf = math.tan(math.pi * 0.25 + slat1 * 0.5)
sf = (sf ** sn) * math.cos(slat1) / sn
ro = math.tan(math.pi * 0.25 + olat * 0.5)
ro = re * sf / (ro ** sn)
ra = math.tan(math.pi * 0.25 + lat * DEGRAD * 0.5)
ra = re * sf / (ra ** sn)
theta = lng * DEGRAD - olon
if theta > math.pi:
theta -= 2.0 * math.pi
if theta < -math.pi:
theta += 2.0 * math.pi
theta *= sn
x = int(ra * math.sin(theta) + XO + 0.5)
y = int(ro - ra * math.cos(theta) + YO + 0.5)
return x, y
+73
View File
@@ -0,0 +1,73 @@
import uuid
from fastapi import APIRouter, HTTPException, status, UploadFile, File
from sqlalchemy import select, func
from app.deps import CurrentUser, DB
from app.models.rag import RagSource, RagChunk
from app.schemas.rag import RagAskRequest, RagAskResponse, RagSourceCreate, RagSourceResponse, RagSource as RagSourceSchema
from app.services.rag_service import ask
router = APIRouter(prefix="/rag", tags=["법규/시방서 Q&A (RAG)"])
@router.post("/ask", response_model=RagAskResponse)
async def ask_question(data: RagAskRequest, db: DB, current_user: CurrentUser):
"""Ask a question about construction laws and specifications."""
source_types = [st.value for st in data.source_types] if data.source_types else None
result = await ask(db, data.question, data.top_k, source_types)
sources = [
RagSourceSchema(
id=uuid.UUID(s["id"]),
title=s["title"],
source_type=s["source_type"],
chunk_content=s["content"][:500], # Truncate for response
relevance_score=s["relevance_score"],
)
for s in result.get("sources", [])
]
return RagAskResponse(
question=result["question"],
answer=result["answer"],
sources=sources,
)
@router.get("/sources", response_model=list[RagSourceResponse])
async def list_sources(db: DB, current_user: CurrentUser):
"""List all indexed RAG sources with chunk counts."""
result = await db.execute(
select(RagSource, func.count(RagChunk.id).label("chunk_count"))
.outerjoin(RagChunk, RagChunk.source_id == RagSource.id)
.group_by(RagSource.id)
.order_by(RagSource.created_at.desc())
)
rows = result.fetchall()
return [
RagSourceResponse(
id=row.RagSource.id,
title=row.RagSource.title,
source_type=row.RagSource.source_type,
source_url=row.RagSource.source_url,
chunk_count=row.chunk_count,
created_at=row.RagSource.created_at,
)
for row in rows
]
@router.post("/sources", response_model=RagSourceResponse, status_code=status.HTTP_201_CREATED)
async def create_source(data: RagSourceCreate, db: DB, current_user: CurrentUser):
"""Register a new RAG source (metadata only; content indexed separately)."""
source = RagSource(**data.model_dump())
db.add(source)
await db.commit()
await db.refresh(source)
return RagSourceResponse(
id=source.id,
title=source.title,
source_type=source.source_type,
source_url=source.source_url,
chunk_count=0,
created_at=source.created_at,
)
+144
View File
@@ -0,0 +1,144 @@
import uuid
from datetime import date
from fastapi import APIRouter, HTTPException, status
from sqlalchemy import select, func
from app.deps import CurrentUser, DB
from app.models.report import Report, ReportType
from app.models.daily_report import DailyReport
from app.models.weather import WeatherAlert
from app.models.project import Project
from app.schemas.report import ReportGenerateRequest, ReportResponse
from app.services.report_gen import generate_weekly_report, generate_monthly_report
router = APIRouter(prefix="/projects/{project_id}/reports", tags=["공정보고서"])
# Report schemas (inline for simplicity)
from pydantic import BaseModel
from app.models.report import ReportType, ReportStatus
class ReportGenerateRequest(BaseModel):
report_type: ReportType
period_start: date
period_end: date
class ReportResponse(BaseModel):
id: uuid.UUID
project_id: uuid.UUID
report_type: ReportType
period_start: date
period_end: date
ai_draft_text: str | None
status: ReportStatus
pdf_s3_key: str | None
model_config = {"from_attributes": True}
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
def _compute_overall_progress(tasks) -> float:
if not tasks:
return 0.0
total = sum(t.progress_pct for t in tasks)
return total / len(tasks)
@router.get("", response_model=list[ReportResponse])
async def list_reports(project_id: uuid.UUID, db: DB, current_user: CurrentUser):
result = await db.execute(
select(Report)
.where(Report.project_id == project_id)
.order_by(Report.period_start.desc())
)
return result.scalars().all()
@router.post("/generate", response_model=ReportResponse, status_code=status.HTTP_201_CREATED)
async def generate_report(project_id: uuid.UUID, data: ReportGenerateRequest, db: DB, current_user: CurrentUser):
"""AI-generate weekly or monthly report draft."""
project = await _get_project_or_404(project_id, db)
# Get daily reports in period
daily_result = await db.execute(
select(DailyReport).where(
DailyReport.project_id == project_id,
DailyReport.report_date >= data.period_start,
DailyReport.report_date <= data.period_end,
).order_by(DailyReport.report_date)
)
daily_reports = daily_result.scalars().all()
# Get tasks for progress
from app.models.task import Task
tasks_result = await db.execute(select(Task).where(Task.project_id == project_id))
tasks = tasks_result.scalars().all()
overall_progress = _compute_overall_progress(tasks)
if data.report_type == ReportType.WEEKLY:
# Get weather alerts in period
alerts_result = await db.execute(
select(WeatherAlert).where(
WeatherAlert.project_id == project_id,
WeatherAlert.alert_date >= data.period_start,
WeatherAlert.alert_date <= data.period_end,
)
)
weather_alerts = alerts_result.scalars().all()
ai_text, content_json = await generate_weekly_report(
project_name=project.name,
period_start=str(data.period_start),
period_end=str(data.period_end),
daily_reports=daily_reports,
overall_progress_pct=overall_progress,
weather_alerts=weather_alerts,
)
else:
ai_text, content_json = await generate_monthly_report(
project_name=project.name,
period_start=str(data.period_start),
period_end=str(data.period_end),
daily_reports=daily_reports,
overall_progress_pct=overall_progress,
)
report = Report(
project_id=project_id,
report_type=data.report_type,
period_start=data.period_start,
period_end=data.period_end,
content_json=content_json,
ai_draft_text=ai_text,
)
db.add(report)
await db.commit()
await db.refresh(report)
return report
@router.get("/{report_id}", response_model=ReportResponse)
async def get_report(project_id: uuid.UUID, report_id: uuid.UUID, db: DB, current_user: CurrentUser):
result = await db.execute(select(Report).where(Report.id == report_id, Report.project_id == project_id))
report = result.scalar_one_or_none()
if not report:
raise HTTPException(status_code=404, detail="보고서를 찾을 수 없습니다")
return report
@router.delete("/{report_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_report(project_id: uuid.UUID, report_id: uuid.UUID, db: DB, current_user: CurrentUser):
result = await db.execute(select(Report).where(Report.id == report_id, Report.project_id == project_id))
report = result.scalar_one_or_none()
if not report:
raise HTTPException(status_code=404, detail="보고서를 찾을 수 없습니다")
await db.delete(report)
await db.commit()
+145
View File
@@ -0,0 +1,145 @@
import uuid
from datetime import datetime, timezone
from fastapi import APIRouter, HTTPException, status
from sqlalchemy import select
from app.deps import CurrentUser, DB
from app.models.settings import ClientProfile, AlertRule, WorkTypeLibrary
from app.schemas.settings import (
ClientProfileCreate, ClientProfileResponse,
WorkTypeCreate, WorkTypeResponse,
AlertRuleCreate, AlertRuleResponse,
SettingsExport,
)
router = APIRouter(prefix="/settings", tags=["커스텀 설정"])
# Client Profiles
@router.get("/client-profiles", response_model=list[ClientProfileResponse])
async def list_profiles(db: DB, current_user: CurrentUser):
result = await db.execute(select(ClientProfile).order_by(ClientProfile.name))
return result.scalars().all()
@router.post("/client-profiles", response_model=ClientProfileResponse, status_code=status.HTTP_201_CREATED)
async def create_profile(data: ClientProfileCreate, db: DB, current_user: CurrentUser):
profile = ClientProfile(**data.model_dump())
db.add(profile)
await db.commit()
await db.refresh(profile)
return profile
@router.put("/client-profiles/{profile_id}", response_model=ClientProfileResponse)
async def update_profile(profile_id: uuid.UUID, data: ClientProfileCreate, db: DB, current_user: CurrentUser):
result = await db.execute(select(ClientProfile).where(ClientProfile.id == profile_id))
profile = result.scalar_one_or_none()
if not profile:
raise HTTPException(status_code=404, detail="발주처 프로파일을 찾을 수 없습니다")
for field, value in data.model_dump(exclude_none=True).items():
setattr(profile, field, value)
await db.commit()
await db.refresh(profile)
return profile
@router.delete("/client-profiles/{profile_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_profile(profile_id: uuid.UUID, db: DB, current_user: CurrentUser):
result = await db.execute(select(ClientProfile).where(ClientProfile.id == profile_id))
profile = result.scalar_one_or_none()
if not profile:
raise HTTPException(status_code=404, detail="발주처 프로파일을 찾을 수 없습니다")
await db.delete(profile)
await db.commit()
# Work Types
@router.get("/work-types", response_model=list[WorkTypeResponse])
async def list_work_types(db: DB, current_user: CurrentUser):
result = await db.execute(select(WorkTypeLibrary).order_by(WorkTypeLibrary.category, WorkTypeLibrary.name))
return result.scalars().all()
@router.post("/work-types", response_model=WorkTypeResponse, status_code=status.HTTP_201_CREATED)
async def create_work_type(data: WorkTypeCreate, db: DB, current_user: CurrentUser):
wt = WorkTypeLibrary(**data.model_dump(), is_system=False)
db.add(wt)
await db.commit()
await db.refresh(wt)
return wt
# Alert Rules
@router.get("/alert-rules", response_model=list[AlertRuleResponse])
async def list_alert_rules(db: DB, current_user: CurrentUser):
result = await db.execute(select(AlertRule).order_by(AlertRule.created_at.desc()))
return result.scalars().all()
@router.post("/alert-rules", response_model=AlertRuleResponse, status_code=status.HTTP_201_CREATED)
async def create_alert_rule(data: AlertRuleCreate, db: DB, current_user: CurrentUser):
rule = AlertRule(**data.model_dump())
db.add(rule)
await db.commit()
await db.refresh(rule)
return rule
@router.put("/alert-rules/{rule_id}", response_model=AlertRuleResponse)
async def update_alert_rule(rule_id: uuid.UUID, data: AlertRuleCreate, db: DB, current_user: CurrentUser):
result = await db.execute(select(AlertRule).where(AlertRule.id == rule_id))
rule = result.scalar_one_or_none()
if not rule:
raise HTTPException(status_code=404, detail="알림 규칙을 찾을 수 없습니다")
for field, value in data.model_dump(exclude_none=True).items():
setattr(rule, field, value)
await db.commit()
await db.refresh(rule)
return rule
# JSON Export / Import
@router.get("/export", response_model=SettingsExport)
async def export_settings(db: DB, current_user: CurrentUser):
profiles_result = await db.execute(select(ClientProfile))
work_types_result = await db.execute(select(WorkTypeLibrary))
rules_result = await db.execute(select(AlertRule))
return SettingsExport(
client_profiles=[ClientProfileResponse.model_validate(p) for p in profiles_result.scalars().all()],
work_types=[WorkTypeResponse.model_validate(wt) for wt in work_types_result.scalars().all()],
alert_rules=[AlertRuleResponse.model_validate(r) for r in rules_result.scalars().all()],
exported_at=datetime.now(timezone.utc),
)
@router.post("/import", status_code=status.HTTP_200_OK)
async def import_settings(data: SettingsExport, db: DB, current_user: CurrentUser):
"""Import settings from JSON. Does NOT overwrite existing records."""
imported = {"client_profiles": 0, "work_types": 0, "alert_rules": 0}
for profile in data.client_profiles:
existing = await db.execute(select(ClientProfile).where(ClientProfile.name == profile.name))
if not existing.scalar_one_or_none():
db.add(ClientProfile(
name=profile.name,
report_frequency=profile.report_frequency,
template_config=profile.template_config,
contact_info=profile.contact_info,
is_default=profile.is_default,
))
imported["client_profiles"] += 1
for wt in data.work_types:
existing = await db.execute(select(WorkTypeLibrary).where(WorkTypeLibrary.code == wt.code))
if not existing.scalar_one_or_none():
db.add(WorkTypeLibrary(
code=wt.code, name=wt.name, category=wt.category,
weather_constraints=wt.weather_constraints,
default_checklist=wt.default_checklist,
is_system=False,
))
imported["work_types"] += 1
await db.commit()
return {"message": "설정을 가져왔습니다", "imported": imported}
+125
View File
@@ -0,0 +1,125 @@
import uuid
from fastapi import APIRouter, HTTPException, status
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from app.deps import CurrentUser, DB
from app.models.task import Task, TaskDependency
from app.models.project import Project
from app.schemas.task import TaskCreate, TaskUpdate, TaskResponse, TaskDependencyCreate, GanttData
from app.services.gantt import compute_cpm
router = APIRouter(prefix="/projects/{project_id}/tasks", tags=["공정관리 (Gantt)"])
async def _get_project_or_404(project_id: uuid.UUID, db: DB):
result = await db.execute(select(Project).where(Project.id == project_id))
project = result.scalar_one_or_none()
if not project:
raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다")
return project
@router.get("", response_model=list[TaskResponse])
async def list_tasks(project_id: uuid.UUID, db: DB, current_user: CurrentUser):
result = await db.execute(select(Task).where(Task.project_id == project_id).order_by(Task.sort_order))
return result.scalars().all()
@router.post("", response_model=TaskResponse, status_code=status.HTTP_201_CREATED)
async def create_task(project_id: uuid.UUID, data: TaskCreate, db: DB, current_user: CurrentUser):
await _get_project_or_404(project_id, db)
task = Task(**data.model_dump(), project_id=project_id)
db.add(task)
await db.commit()
await db.refresh(task)
return task
@router.get("/gantt", response_model=GanttData)
async def get_gantt(project_id: uuid.UUID, db: DB, current_user: CurrentUser):
"""Returns tasks with CPM computed values."""
tasks_result = await db.execute(select(Task).where(Task.project_id == project_id).order_by(Task.sort_order))
tasks = tasks_result.scalars().all()
deps_result = await db.execute(
select(TaskDependency).where(
TaskDependency.predecessor_id.in_([t.id for t in tasks])
)
)
deps = deps_result.scalars().all()
# Run CPM
cpm_result = compute_cpm(tasks, deps)
if cpm_result and isinstance(cpm_result, tuple):
cpm_data, project_duration = cpm_result
else:
cpm_data, project_duration = {}, None
# Update tasks with CPM results
critical_ids = []
for task in tasks:
if task.id in cpm_data:
data = cpm_data[task.id]
task.early_start = data["early_start"]
task.early_finish = data["early_finish"]
task.late_start = data["late_start"]
task.late_finish = data["late_finish"]
task.total_float = data["total_float"]
task.is_critical = data["is_critical"]
if data["is_critical"]:
critical_ids.append(task.id)
await db.commit()
return GanttData(
tasks=[TaskResponse.model_validate(t) for t in tasks],
critical_path=critical_ids,
project_duration_days=project_duration,
)
@router.put("/{task_id}", response_model=TaskResponse)
async def update_task(project_id: uuid.UUID, task_id: uuid.UUID, data: TaskUpdate, db: DB, current_user: CurrentUser):
result = await db.execute(select(Task).where(Task.id == task_id, Task.project_id == project_id))
task = result.scalar_one_or_none()
if not task:
raise HTTPException(status_code=404, detail="태스크를 찾을 수 없습니다")
for field, value in data.model_dump(exclude_none=True).items():
setattr(task, field, value)
await db.commit()
await db.refresh(task)
return task
@router.delete("/{task_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_task(project_id: uuid.UUID, task_id: uuid.UUID, db: DB, current_user: CurrentUser):
result = await db.execute(select(Task).where(Task.id == task_id, Task.project_id == project_id))
task = result.scalar_one_or_none()
if not task:
raise HTTPException(status_code=404, detail="태스크를 찾을 수 없습니다")
await db.delete(task)
await db.commit()
@router.post("/{task_id}/dependencies", response_model=dict, status_code=status.HTTP_201_CREATED)
async def add_dependency(project_id: uuid.UUID, task_id: uuid.UUID, data: TaskDependencyCreate, db: DB, current_user: CurrentUser):
dep = TaskDependency(
predecessor_id=data.predecessor_id,
successor_id=data.successor_id,
dependency_type=data.dependency_type,
lag_days=data.lag_days,
)
db.add(dep)
await db.commit()
return {"message": "의존관계가 추가되었습니다"}
@router.delete("/{task_id}/dependencies/{dep_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_dependency(project_id: uuid.UUID, task_id: uuid.UUID, dep_id: uuid.UUID, db: DB, current_user: CurrentUser):
result = await db.execute(select(TaskDependency).where(TaskDependency.id == dep_id))
dep = result.scalar_one_or_none()
if not dep:
raise HTTPException(status_code=404, detail="의존관계를 찾을 수 없습니다")
await db.delete(dep)
await db.commit()
+136
View File
@@ -0,0 +1,136 @@
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": "경보가 확인 처리되었습니다"}