Files
conai/backend/app/api/projects.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

162 lines
6.4 KiB
Python

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