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:
@@ -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
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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}")
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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()
|
||||
@@ -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}
|
||||
@@ -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()
|
||||
@@ -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": "경보가 확인 처리되었습니다"}
|
||||
Reference in New Issue
Block a user