feat: Phase 3 구현 — 완전 자동화, 준공도서, Vision L3, 발주처 포털
EVMS 완전 자동화: - 공기 지연 AI 예측 (SPI 기반 준공일 예측) - 기성청구 가능 금액 자동 산출 - 매일 자정 EVMS 스냅샷 자동 생성 (APScheduler) - 매일 07:00 GONGSA 아침 브리핑 자동 생성 준공도서 패키지: - 준공 요약 + 품질시험 목록 + 검측 이력 + 인허가 현황 → ZIP 번들 - 준공 준비 체크리스트 API - 4종 HTML 템플릿 (WeasyPrint PDF 출력) Vision AI Level 3: - 설계 도면 vs 현장 사진 비교 보조 판독 (Claude Vision) - 철근 배근, 거푸집 치수 1차 분석 설계도서 파싱: - PDF 이미지/텍스트에서 공종·수량·규격 자동 추출 - Pandoc HWP 출력 지원 발주처 전용 포털: - 토큰 기반 읽기 전용 API - 공사 현황 대시보드, 공정률 추이 차트 에이전트 협업 고도화: - 협업 시나리오 (concrete_pour, excavation, weekly_report) - GONGSA→PUMJIL→ANJEON 순차 처리 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -16,6 +16,7 @@ from app.deps import CurrentUser, DB
|
|||||||
from app.models.agent import AgentConversation, AgentMessage, AgentType, ConversationStatus
|
from app.models.agent import AgentConversation, AgentMessage, AgentType, ConversationStatus
|
||||||
from app.models.project import Project
|
from app.models.project import Project
|
||||||
from app.services.agents.router import get_agent, route_by_keyword
|
from app.services.agents.router import get_agent, route_by_keyword
|
||||||
|
from app.services.agents.collaboration import run_scenario, SCENARIO_AGENTS
|
||||||
|
|
||||||
router = APIRouter(prefix="/projects/{project_id}/agents", tags=["AI 에이전트"])
|
router = APIRouter(prefix="/projects/{project_id}/agents", tags=["AI 에이전트"])
|
||||||
|
|
||||||
@@ -267,6 +268,32 @@ async def morning_briefing(
|
|||||||
return msg
|
return msg
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/scenario/{scenario_name}")
|
||||||
|
async def run_collaboration_scenario(
|
||||||
|
project_id: uuid.UUID,
|
||||||
|
scenario_name: str,
|
||||||
|
db: DB,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
에이전트 협업 시나리오 실행 (Phase 3).
|
||||||
|
복수 에이전트가 순차적으로 하나의 현장 상황을 처리합니다.
|
||||||
|
|
||||||
|
사용 가능 시나리오:
|
||||||
|
- concrete_pour: 콘크리트 타설 (GONGSA → PUMJIL → ANJEON)
|
||||||
|
- excavation: 굴착 작업 (GONGSA → ANJEON → PUMJIL)
|
||||||
|
- weekly_report: 주간 보고 (GONGSA → PUMJIL → GUMU)
|
||||||
|
"""
|
||||||
|
if scenario_name not in SCENARIO_AGENTS:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"알 수 없는 시나리오. 가능한 값: {list(SCENARIO_AGENTS.keys())}",
|
||||||
|
)
|
||||||
|
await _get_project_or_404(project_id, db)
|
||||||
|
results = await run_scenario(db, project_id, current_user.id, scenario_name)
|
||||||
|
return {"scenario": scenario_name, "steps": results}
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{conv_id}", status_code=status.HTTP_204_NO_CONTENT)
|
@router.delete("/{conv_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
async def delete_conversation(
|
async def delete_conversation(
|
||||||
project_id: uuid.UUID, conv_id: uuid.UUID, db: DB, current_user: CurrentUser
|
project_id: uuid.UUID, conv_id: uuid.UUID, db: DB, current_user: CurrentUser
|
||||||
|
|||||||
113
backend/app/api/completion.py
Normal file
113
backend/app/api/completion.py
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
"""준공도서 패키지 API"""
|
||||||
|
import uuid
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
import io
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.deps import CurrentUser, DB
|
||||||
|
from app.models.project import Project
|
||||||
|
from app.services.completion_service import build_completion_package
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/projects/{project_id}/completion", tags=["준공도서"])
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_project_or_404(project_id: uuid.UUID, db: DB) -> Project:
|
||||||
|
r = await db.execute(select(Project).where(Project.id == project_id))
|
||||||
|
p = r.scalar_one_or_none()
|
||||||
|
if not p:
|
||||||
|
raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다")
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/download")
|
||||||
|
async def download_completion_package(
|
||||||
|
project_id: uuid.UUID,
|
||||||
|
db: DB,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
준공도서 ZIP 패키지 다운로드
|
||||||
|
포함 문서:
|
||||||
|
- 준공 요약 (전체 실적)
|
||||||
|
- 품질시험 목록 (전체)
|
||||||
|
- 검측 이력 (전체)
|
||||||
|
- 인허가 현황 (전체)
|
||||||
|
"""
|
||||||
|
await _get_project_or_404(project_id, db)
|
||||||
|
zip_bytes, filename = await build_completion_package(db, project_id)
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
io.BytesIO(zip_bytes),
|
||||||
|
media_type="application/zip",
|
||||||
|
headers={"Content-Disposition": f"attachment; filename*=UTF-8''{filename}"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/checklist")
|
||||||
|
async def completion_checklist(
|
||||||
|
project_id: uuid.UUID,
|
||||||
|
db: DB,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
준공 준비 체크리스트 — 부족한 서류/데이터 현황 반환
|
||||||
|
"""
|
||||||
|
from app.models.daily_report import DailyReport
|
||||||
|
from app.models.quality import QualityTest
|
||||||
|
from app.models.inspection import InspectionRequest, InspectionStatus
|
||||||
|
from app.models.permit import PermitItem, PermitStatus
|
||||||
|
from sqlalchemy import func
|
||||||
|
|
||||||
|
await _get_project_or_404(project_id, db)
|
||||||
|
|
||||||
|
async def count(model, where):
|
||||||
|
r = await db.execute(select(func.count()).where(*where))
|
||||||
|
return r.scalar() or 0
|
||||||
|
|
||||||
|
total_dr = await count(DailyReport, [DailyReport.project_id == project_id])
|
||||||
|
total_qt = await count(QualityTest, [QualityTest.project_id == project_id])
|
||||||
|
total_insp = await count(InspectionRequest, [InspectionRequest.project_id == project_id])
|
||||||
|
done_insp = await count(InspectionRequest, [
|
||||||
|
InspectionRequest.project_id == project_id,
|
||||||
|
InspectionRequest.status == InspectionStatus.COMPLETED,
|
||||||
|
])
|
||||||
|
total_per = await count(PermitItem, [PermitItem.project_id == project_id])
|
||||||
|
approved_per = await count(PermitItem, [
|
||||||
|
PermitItem.project_id == project_id,
|
||||||
|
PermitItem.status == PermitStatus.APPROVED,
|
||||||
|
])
|
||||||
|
|
||||||
|
checks = [
|
||||||
|
{
|
||||||
|
"item": "작업일보",
|
||||||
|
"count": total_dr,
|
||||||
|
"status": "준비완료" if total_dr > 0 else "누락",
|
||||||
|
"ok": total_dr > 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"item": "품질시험 기록",
|
||||||
|
"count": total_qt,
|
||||||
|
"status": "준비완료" if total_qt > 0 else "누락",
|
||||||
|
"ok": total_qt > 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"item": "검측 완료",
|
||||||
|
"count": f"{done_insp}/{total_insp}",
|
||||||
|
"status": "완료" if total_insp > 0 and done_insp == total_insp else f"미완료 {total_insp - done_insp}건",
|
||||||
|
"ok": total_insp > 0 and done_insp == total_insp,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"item": "인허가 취득",
|
||||||
|
"count": f"{approved_per}/{total_per}",
|
||||||
|
"status": "완료" if total_per > 0 and approved_per == total_per else f"미취득 {total_per - approved_per}건",
|
||||||
|
"ok": total_per > 0 and approved_per == total_per,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
all_ok = all(c["ok"] for c in checks)
|
||||||
|
return {
|
||||||
|
"ready": all_ok,
|
||||||
|
"summary": "준공 준비 완료" if all_ok else "준공 서류 미비 항목이 있습니다",
|
||||||
|
"checks": checks,
|
||||||
|
}
|
||||||
102
backend/app/api/documents.py
Normal file
102
backend/app/api/documents.py
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
"""
|
||||||
|
설계도서 파싱 + HWP 출력 API
|
||||||
|
"""
|
||||||
|
import uuid
|
||||||
|
from fastapi import APIRouter, UploadFile, File, HTTPException, Form
|
||||||
|
from fastapi.responses import Response
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.deps import CurrentUser, DB
|
||||||
|
from app.models.project import Project
|
||||||
|
from app.services.document_parser import (
|
||||||
|
parse_design_document_text,
|
||||||
|
parse_design_document_image,
|
||||||
|
convert_to_hwp,
|
||||||
|
)
|
||||||
|
from app.services.pdf_service import _render_html, _html_to_pdf
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/projects/{project_id}/documents", tags=["설계도서"])
|
||||||
|
|
||||||
|
ALLOWED_IMAGE_TYPES = {"image/jpeg", "image/png", "image/webp"}
|
||||||
|
MAX_SIZE_MB = 20
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_project_or_404(project_id: uuid.UUID, db: DB) -> Project:
|
||||||
|
r = await db.execute(select(Project).where(Project.id == project_id))
|
||||||
|
p = r.scalar_one_or_none()
|
||||||
|
if not p:
|
||||||
|
raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다")
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/parse-image")
|
||||||
|
async def parse_document_image(
|
||||||
|
project_id: uuid.UUID,
|
||||||
|
db: DB,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
file: UploadFile = File(..., description="도면 이미지 (JPG/PNG)"),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
설계 도면 이미지 → 공종/수량/규격 자동 추출 (Claude Vision)
|
||||||
|
"""
|
||||||
|
await _get_project_or_404(project_id, db)
|
||||||
|
|
||||||
|
content_type = file.content_type or "image/jpeg"
|
||||||
|
if content_type not in ALLOWED_IMAGE_TYPES:
|
||||||
|
raise HTTPException(status_code=400, detail="JPG, PNG, WEBP 이미지만 지원합니다")
|
||||||
|
|
||||||
|
image_data = await file.read()
|
||||||
|
if len(image_data) > MAX_SIZE_MB * 1024 * 1024:
|
||||||
|
raise HTTPException(status_code=400, detail=f"파일 크기가 {MAX_SIZE_MB}MB를 초과합니다")
|
||||||
|
|
||||||
|
result = await parse_design_document_image(image_data, content_type)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/parse-text")
|
||||||
|
async def parse_document_text(
|
||||||
|
project_id: uuid.UUID,
|
||||||
|
db: DB,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
file: UploadFile = File(..., description="설계도서 텍스트 파일 (TXT/MD)"),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
설계도서 텍스트 파일 → 공종/수량/규격 자동 추출
|
||||||
|
"""
|
||||||
|
await _get_project_or_404(project_id, db)
|
||||||
|
|
||||||
|
content = await file.read()
|
||||||
|
text = content.decode("utf-8", errors="replace")
|
||||||
|
result = await parse_design_document_text(text)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
class HWPRequest(BaseModel):
|
||||||
|
html_content: str
|
||||||
|
filename: str = "document.hwp"
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/export-hwp")
|
||||||
|
async def export_hwp(
|
||||||
|
project_id: uuid.UUID,
|
||||||
|
data: HWPRequest,
|
||||||
|
db: DB,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
HTML → HWP 변환 (Pandoc 필요)
|
||||||
|
보고서나 일보를 HWP 형식으로 내보냅니다.
|
||||||
|
"""
|
||||||
|
await _get_project_or_404(project_id, db)
|
||||||
|
|
||||||
|
try:
|
||||||
|
hwp_bytes = convert_to_hwp(data.html_content)
|
||||||
|
except RuntimeError as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
content=hwp_bytes,
|
||||||
|
media_type="application/octet-stream",
|
||||||
|
headers={"Content-Disposition": f"attachment; filename*=UTF-8''{data.filename}"},
|
||||||
|
)
|
||||||
@@ -9,7 +9,7 @@ from sqlalchemy.orm import selectinload
|
|||||||
from app.deps import CurrentUser, DB
|
from app.deps import CurrentUser, DB
|
||||||
from app.models.evms import EVMSSnapshot
|
from app.models.evms import EVMSSnapshot
|
||||||
from app.models.project import Project
|
from app.models.project import Project
|
||||||
from app.services.evms_service import compute_evms
|
from app.services.evms_service import compute_evms, predict_delay, compute_progress_claim
|
||||||
|
|
||||||
router = APIRouter(prefix="/projects/{project_id}/evms", tags=["EVMS"])
|
router = APIRouter(prefix="/projects/{project_id}/evms", tags=["EVMS"])
|
||||||
|
|
||||||
@@ -117,6 +117,64 @@ async def list_snapshots(
|
|||||||
return r.scalars().all()
|
return r.scalars().all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/delay-forecast")
|
||||||
|
async def delay_forecast(
|
||||||
|
project_id: uuid.UUID, db: DB, current_user: CurrentUser
|
||||||
|
):
|
||||||
|
"""공기 지연 AI 예측 (최근 EVMS 스냅샷 기반)"""
|
||||||
|
r = await db.execute(
|
||||||
|
select(EVMSSnapshot)
|
||||||
|
.where(EVMSSnapshot.project_id == project_id)
|
||||||
|
.order_by(EVMSSnapshot.snapshot_date.desc())
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
snap = r.scalar_one_or_none()
|
||||||
|
if not snap:
|
||||||
|
raise HTTPException(status_code=404, detail="EVMS 스냅샷이 없습니다")
|
||||||
|
|
||||||
|
project = await _get_project_or_404(project_id, db)
|
||||||
|
planned_end = project.end_date
|
||||||
|
if planned_end and not isinstance(planned_end, date):
|
||||||
|
from datetime import date as ddate
|
||||||
|
planned_end = ddate.fromisoformat(str(planned_end))
|
||||||
|
|
||||||
|
forecast = await predict_delay(
|
||||||
|
db, project_id,
|
||||||
|
spi=snap.spi,
|
||||||
|
planned_end=planned_end,
|
||||||
|
snapshot_date=snap.snapshot_date,
|
||||||
|
)
|
||||||
|
forecast["spi"] = snap.spi
|
||||||
|
forecast["cpi"] = snap.cpi
|
||||||
|
forecast["snapshot_date"] = str(snap.snapshot_date)
|
||||||
|
return forecast
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/progress-claim")
|
||||||
|
async def progress_claim(
|
||||||
|
project_id: uuid.UUID,
|
||||||
|
already_claimed_pct: float = 0.0,
|
||||||
|
db: DB = None,
|
||||||
|
current_user: CurrentUser = None,
|
||||||
|
):
|
||||||
|
"""기성청구 가능 금액 산출"""
|
||||||
|
r = await db.execute(
|
||||||
|
select(EVMSSnapshot)
|
||||||
|
.where(EVMSSnapshot.project_id == project_id)
|
||||||
|
.order_by(EVMSSnapshot.snapshot_date.desc())
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
snap = r.scalar_one_or_none()
|
||||||
|
if not snap:
|
||||||
|
raise HTTPException(status_code=404, detail="EVMS 스냅샷이 없습니다. /compute 먼저 실행하세요.")
|
||||||
|
|
||||||
|
return await compute_progress_claim(
|
||||||
|
total_budget=snap.total_budget or 0,
|
||||||
|
actual_progress=snap.actual_progress or 0,
|
||||||
|
already_claimed_pct=already_claimed_pct,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/latest", response_model=EVMSResponse)
|
@router.get("/latest", response_model=EVMSResponse)
|
||||||
async def latest_snapshot(
|
async def latest_snapshot(
|
||||||
project_id: uuid.UUID, db: DB, current_user: CurrentUser
|
project_id: uuid.UUID, db: DB, current_user: CurrentUser
|
||||||
|
|||||||
162
backend/app/api/portal.py
Normal file
162
backend/app/api/portal.py
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
"""
|
||||||
|
발주처 전용 포털 API (읽기 전용)
|
||||||
|
토큰 기반 인증으로 발주처가 공사 현황을 실시간 확인합니다.
|
||||||
|
- 로그인 없이 토큰만으로 접근 가능
|
||||||
|
- 쓰기 권한 없음
|
||||||
|
- 민감 정보 제외 (계약 단가 등)
|
||||||
|
"""
|
||||||
|
import uuid
|
||||||
|
import secrets
|
||||||
|
from datetime import date, datetime, timedelta
|
||||||
|
from fastapi import APIRouter, HTTPException, Depends
|
||||||
|
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||||
|
from sqlalchemy import select, func
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from app.core.database import AsyncSessionLocal
|
||||||
|
from app.deps import DB
|
||||||
|
from app.models.project import Project
|
||||||
|
from app.models.daily_report import DailyReport, ReportStatus
|
||||||
|
from app.models.quality import QualityTest, QualityResult
|
||||||
|
from app.models.inspection import InspectionRequest
|
||||||
|
from app.models.weather import WeatherAlert
|
||||||
|
from app.models.evms import EVMSSnapshot
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/portal", tags=["발주처 포털"])
|
||||||
|
_bearer = HTTPBearer(auto_error=False)
|
||||||
|
|
||||||
|
# 간단한 인메모리 토큰 저장소 (운영에서는 DB/Redis로 교체)
|
||||||
|
_portal_tokens: dict[str, dict] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _verify_portal_token(credentials: HTTPAuthorizationCredentials = Depends(_bearer)) -> dict:
|
||||||
|
if not credentials:
|
||||||
|
raise HTTPException(status_code=401, detail="포털 토큰이 필요합니다")
|
||||||
|
token = credentials.credentials
|
||||||
|
info = _portal_tokens.get(token)
|
||||||
|
if not info:
|
||||||
|
raise HTTPException(status_code=401, detail="유효하지 않은 포털 토큰입니다")
|
||||||
|
if datetime.fromisoformat(info["expires_at"]) < datetime.now():
|
||||||
|
del _portal_tokens[token]
|
||||||
|
raise HTTPException(status_code=401, detail="포털 토큰이 만료되었습니다")
|
||||||
|
return info
|
||||||
|
|
||||||
|
|
||||||
|
PortalAuth = Depends(_verify_portal_token)
|
||||||
|
|
||||||
|
|
||||||
|
class TokenCreateRequest(BaseModel):
|
||||||
|
project_id: uuid.UUID
|
||||||
|
expires_days: int = 30
|
||||||
|
label: str = "발주처"
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/tokens", summary="포털 접근 토큰 발급 (관리자용)")
|
||||||
|
async def create_portal_token(data: TokenCreateRequest, db: DB):
|
||||||
|
"""
|
||||||
|
발주처에게 공유할 읽기 전용 토큰을 발급합니다.
|
||||||
|
이 엔드포인트는 현장 관리자만 호출해야 합니다 (별도 인증 추가 권장).
|
||||||
|
"""
|
||||||
|
r = await db.execute(select(Project).where(Project.id == data.project_id))
|
||||||
|
project = r.scalar_one_or_none()
|
||||||
|
if not project:
|
||||||
|
raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다")
|
||||||
|
|
||||||
|
token = secrets.token_urlsafe(32)
|
||||||
|
expires_at = (datetime.now() + timedelta(days=data.expires_days)).isoformat()
|
||||||
|
_portal_tokens[token] = {
|
||||||
|
"project_id": str(data.project_id),
|
||||||
|
"project_name": project.name,
|
||||||
|
"label": data.label,
|
||||||
|
"expires_at": expires_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"token": token,
|
||||||
|
"project_name": project.name,
|
||||||
|
"expires_at": expires_at,
|
||||||
|
"portal_url": f"/portal/dashboard (Authorization: Bearer {token[:8]}...)",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/dashboard", summary="발주처 공사 현황 대시보드")
|
||||||
|
async def portal_dashboard(auth: dict = PortalAuth, db: DB = None):
|
||||||
|
"""발주처용 공사 현황 요약 (읽기 전용)"""
|
||||||
|
project_id = uuid.UUID(auth["project_id"])
|
||||||
|
today = date.today()
|
||||||
|
|
||||||
|
# 프로젝트 기본 정보
|
||||||
|
proj_r = await db.execute(select(Project).where(Project.id == project_id))
|
||||||
|
project = proj_r.scalar_one_or_none()
|
||||||
|
|
||||||
|
# 최근 작업일보 (3건)
|
||||||
|
dr_r = await db.execute(
|
||||||
|
select(DailyReport)
|
||||||
|
.where(DailyReport.project_id == project_id, DailyReport.status == ReportStatus.CONFIRMED)
|
||||||
|
.order_by(DailyReport.report_date.desc())
|
||||||
|
.limit(3)
|
||||||
|
)
|
||||||
|
recent_reports = [
|
||||||
|
{"date": str(r.report_date), "weather": r.weather_summary, "work": r.work_content[:100] if r.work_content else ""}
|
||||||
|
for r in dr_r.scalars().all()
|
||||||
|
]
|
||||||
|
|
||||||
|
# 품질시험 합격률
|
||||||
|
total_qt_r = await db.execute(select(func.count()).where(QualityTest.project_id == project_id))
|
||||||
|
total_qt = total_qt_r.scalar() or 0
|
||||||
|
pass_qt_r = await db.execute(select(func.count()).where(QualityTest.project_id == project_id, QualityTest.result == QualityResult.PASS))
|
||||||
|
pass_qt = pass_qt_r.scalar() or 0
|
||||||
|
|
||||||
|
# EVMS 최신
|
||||||
|
evms_r = await db.execute(
|
||||||
|
select(EVMSSnapshot).where(EVMSSnapshot.project_id == project_id).order_by(EVMSSnapshot.snapshot_date.desc()).limit(1)
|
||||||
|
)
|
||||||
|
evms = evms_r.scalar_one_or_none()
|
||||||
|
|
||||||
|
# 활성 날씨 경보
|
||||||
|
alert_r = await db.execute(
|
||||||
|
select(WeatherAlert).where(WeatherAlert.project_id == project_id, WeatherAlert.alert_date == today, WeatherAlert.acknowledged == False)
|
||||||
|
)
|
||||||
|
active_alerts = [{"type": a.alert_type, "message": a.message} for a in alert_r.scalars().all()]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"project": {
|
||||||
|
"name": project.name if project else "-",
|
||||||
|
"start_date": str(project.start_date) if project and project.start_date else None,
|
||||||
|
"end_date": str(project.end_date) if project and project.end_date else None,
|
||||||
|
"status": project.status.value if project else "-",
|
||||||
|
},
|
||||||
|
"progress": {
|
||||||
|
"planned": evms.planned_progress if evms else None,
|
||||||
|
"actual": evms.actual_progress if evms else None,
|
||||||
|
"spi": evms.spi if evms else None,
|
||||||
|
"snapshot_date": str(evms.snapshot_date) if evms else None,
|
||||||
|
},
|
||||||
|
"quality": {
|
||||||
|
"total_tests": total_qt,
|
||||||
|
"pass_rate": round(pass_qt / total_qt * 100, 1) if total_qt else None,
|
||||||
|
},
|
||||||
|
"recent_reports": recent_reports,
|
||||||
|
"active_alerts": active_alerts,
|
||||||
|
"generated_at": datetime.now().isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/progress-chart", summary="공정률 추이 데이터")
|
||||||
|
async def portal_progress_chart(auth: dict = PortalAuth, db: DB = None):
|
||||||
|
"""발주처용 공정률 추이 차트 데이터"""
|
||||||
|
project_id = uuid.UUID(auth["project_id"])
|
||||||
|
r = await db.execute(
|
||||||
|
select(EVMSSnapshot)
|
||||||
|
.where(EVMSSnapshot.project_id == project_id)
|
||||||
|
.order_by(EVMSSnapshot.snapshot_date)
|
||||||
|
.limit(90) # 최근 90일
|
||||||
|
)
|
||||||
|
snapshots = r.scalars().all()
|
||||||
|
return {
|
||||||
|
"labels": [str(s.snapshot_date) for s in snapshots],
|
||||||
|
"planned": [s.planned_progress for s in snapshots],
|
||||||
|
"actual": [s.actual_progress for s in snapshots],
|
||||||
|
"spi": [s.spi for s in snapshots],
|
||||||
|
}
|
||||||
@@ -9,10 +9,12 @@ from sqlalchemy import select
|
|||||||
from app.deps import CurrentUser, DB
|
from app.deps import CurrentUser, DB
|
||||||
from app.models.project import Project
|
from app.models.project import Project
|
||||||
from app.models.daily_report import DailyReport, DailyReportPhoto
|
from app.models.daily_report import DailyReport, DailyReportPhoto
|
||||||
from app.services.vision_service import classify_photo, analyze_safety
|
from app.services.vision_service import classify_photo, analyze_safety, compare_with_drawing
|
||||||
|
|
||||||
router = APIRouter(prefix="/projects/{project_id}/vision", tags=["Vision AI"])
|
router = APIRouter(prefix="/projects/{project_id}/vision", tags=["Vision AI"])
|
||||||
|
|
||||||
|
COMPARISON_TYPES = {"rebar": "철근 배근", "formwork": "거푸집", "general": "일반 비교"}
|
||||||
|
|
||||||
ALLOWED_TYPES = {"image/jpeg", "image/png", "image/webp", "image/heic"}
|
ALLOWED_TYPES = {"image/jpeg", "image/png", "image/webp", "image/heic"}
|
||||||
MAX_SIZE_MB = 10
|
MAX_SIZE_MB = 10
|
||||||
|
|
||||||
@@ -91,6 +93,41 @@ async def classify_field_photo(
|
|||||||
return JSONResponse(content=result)
|
return JSONResponse(content=result)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/compare-drawing")
|
||||||
|
async def compare_drawing(
|
||||||
|
project_id: uuid.UUID,
|
||||||
|
db: DB,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
field_photo: UploadFile = File(..., description="현장 사진"),
|
||||||
|
drawing: UploadFile = File(..., description="설계 도면 이미지"),
|
||||||
|
comparison_type: str = Form("rebar", description="rebar/formwork/general"),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Vision AI Level 3 — 설계 도면 vs 현장 사진 비교 보조 판독
|
||||||
|
철근 배근, 거푸집 치수 등을 도면과 1차 비교합니다.
|
||||||
|
⚠️ 최종 합격/불합격 판정은 현장 책임자가 합니다.
|
||||||
|
"""
|
||||||
|
await _get_project_or_404(project_id, db)
|
||||||
|
|
||||||
|
if comparison_type not in COMPARISON_TYPES:
|
||||||
|
raise HTTPException(status_code=400, detail=f"comparison_type은 {list(COMPARISON_TYPES.keys())} 중 하나여야 합니다")
|
||||||
|
|
||||||
|
field_data = await field_photo.read()
|
||||||
|
drawing_data = await drawing.read()
|
||||||
|
|
||||||
|
if len(field_data) > MAX_SIZE_MB * 1024 * 1024 or len(drawing_data) > MAX_SIZE_MB * 1024 * 1024:
|
||||||
|
raise HTTPException(status_code=400, detail=f"파일 크기가 {MAX_SIZE_MB}MB를 초과합니다")
|
||||||
|
|
||||||
|
result = await compare_with_drawing(
|
||||||
|
field_photo=field_data,
|
||||||
|
drawing_image=drawing_data,
|
||||||
|
comparison_type=comparison_type,
|
||||||
|
field_media_type=field_photo.content_type or "image/jpeg",
|
||||||
|
drawing_media_type=drawing.content_type or "image/jpeg",
|
||||||
|
)
|
||||||
|
return JSONResponse(content=result)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/safety-check")
|
@router.post("/safety-check")
|
||||||
async def safety_check(
|
async def safety_check(
|
||||||
project_id: uuid.UUID,
|
project_id: uuid.UUID,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from app.config import settings
|
|||||||
from app.api import (
|
from app.api import (
|
||||||
auth, projects, tasks, daily_reports, reports, inspections,
|
auth, projects, tasks, daily_reports, reports, inspections,
|
||||||
weather, rag, kakao, permits, quality, settings as settings_router,
|
weather, rag, kakao, permits, quality, settings as settings_router,
|
||||||
agents, evms, vision, geofence,
|
agents, evms, vision, geofence, completion, documents, portal,
|
||||||
)
|
)
|
||||||
from app.services.scheduler import start_scheduler, stop_scheduler
|
from app.services.scheduler import start_scheduler, stop_scheduler
|
||||||
|
|
||||||
@@ -53,6 +53,9 @@ def create_app() -> FastAPI:
|
|||||||
app.include_router(evms.router, prefix=api_prefix)
|
app.include_router(evms.router, prefix=api_prefix)
|
||||||
app.include_router(vision.router, prefix=api_prefix)
|
app.include_router(vision.router, prefix=api_prefix)
|
||||||
app.include_router(geofence.router, prefix=api_prefix)
|
app.include_router(geofence.router, prefix=api_prefix)
|
||||||
|
app.include_router(completion.router, prefix=api_prefix)
|
||||||
|
app.include_router(documents.router, prefix=api_prefix)
|
||||||
|
app.include_router(portal.router, prefix=api_prefix)
|
||||||
app.include_router(settings_router.router, prefix=api_prefix)
|
app.include_router(settings_router.router, prefix=api_prefix)
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
|
|||||||
113
backend/app/services/agents/collaboration.py
Normal file
113
backend/app/services/agents/collaboration.py
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
"""
|
||||||
|
에이전트 협업 고도화 — Phase 3
|
||||||
|
복수 에이전트가 순차적으로 하나의 시나리오를 처리합니다.
|
||||||
|
|
||||||
|
예시: 콘크리트 타설 당일 시나리오
|
||||||
|
07:00 GONGSA → 공정 브리핑 + 날씨 체크
|
||||||
|
07:05 PUMJIL → 타설 전 품질 체크리스트
|
||||||
|
07:10 ANJEON → TBM 자료 + 안전 체크
|
||||||
|
"""
|
||||||
|
from datetime import date
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.models.agent import AgentConversation, AgentMessage, AgentType
|
||||||
|
from .gongsa import gongsa_agent
|
||||||
|
from .pumjil import pumjil_agent
|
||||||
|
from .anjeon import anjeon_agent
|
||||||
|
from .gumu import gumu_agent
|
||||||
|
from .base import BaseAgent
|
||||||
|
|
||||||
|
|
||||||
|
SCENARIO_AGENTS = {
|
||||||
|
"concrete_pour": [
|
||||||
|
(AgentType.GONGSA, "콘크리트 타설 예정입니다. 날씨와 공정 현황을 브리핑해주세요."),
|
||||||
|
(AgentType.PUMJIL, "오늘 콘크리트 타설 전 품질 체크리스트를 발송해주세요."),
|
||||||
|
(AgentType.ANJEON, "콘크리트 타설 작업 TBM 자료를 작성해주세요."),
|
||||||
|
],
|
||||||
|
"excavation": [
|
||||||
|
(AgentType.GONGSA, "굴착 작업 예정입니다. 공정 현황을 브리핑해주세요."),
|
||||||
|
(AgentType.ANJEON, "굴착 작업 안전 사전 경보와 TBM 자료를 작성해주세요."),
|
||||||
|
(AgentType.PUMJIL, "굴착 작업 품질 관리 체크리스트를 발송해주세요."),
|
||||||
|
],
|
||||||
|
"weekly_report": [
|
||||||
|
(AgentType.GONGSA, "이번 주 공정 현황을 요약해주세요."),
|
||||||
|
(AgentType.PUMJIL, "이번 주 품질시험 및 검측 현황을 요약해주세요."),
|
||||||
|
(AgentType.GUMU, "이번 주 행정 처리 현황과 다음 주 예정 사항을 정리해주세요."),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def run_scenario(
|
||||||
|
db: AsyncSession,
|
||||||
|
project_id,
|
||||||
|
user_id,
|
||||||
|
scenario: str,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""
|
||||||
|
협업 시나리오 실행.
|
||||||
|
반환: [{"agent": "gongsa", "content": "...", "message_id": "..."}, ...]
|
||||||
|
"""
|
||||||
|
import uuid
|
||||||
|
pid = uuid.UUID(str(project_id))
|
||||||
|
|
||||||
|
steps = SCENARIO_AGENTS.get(scenario)
|
||||||
|
if not steps:
|
||||||
|
raise ValueError(f"알 수 없는 시나리오: {scenario}. 가능한 값: {list(SCENARIO_AGENTS.keys())}")
|
||||||
|
|
||||||
|
results = []
|
||||||
|
agent_map: dict[AgentType, BaseAgent] = {
|
||||||
|
AgentType.GONGSA: gongsa_agent,
|
||||||
|
AgentType.PUMJIL: pumjil_agent,
|
||||||
|
AgentType.ANJEON: anjeon_agent,
|
||||||
|
AgentType.GUMU: gumu_agent,
|
||||||
|
}
|
||||||
|
|
||||||
|
# 이전 에이전트 응답을 컨텍스트에 누적
|
||||||
|
prev_responses: list[str] = []
|
||||||
|
|
||||||
|
for agent_type, prompt_base in steps:
|
||||||
|
agent = agent_map[agent_type]
|
||||||
|
context = await agent.build_context(db, str(pid))
|
||||||
|
|
||||||
|
# 이전 에이전트 응답을 프롬프트에 추가
|
||||||
|
prompt = prompt_base
|
||||||
|
if prev_responses:
|
||||||
|
prompt += "\n\n이전 에이전트 응답 요약:\n" + "\n---\n".join(prev_responses[-2:])
|
||||||
|
|
||||||
|
# 대화 세션 생성
|
||||||
|
conv = AgentConversation(
|
||||||
|
project_id=pid,
|
||||||
|
user_id=uuid.UUID(str(user_id)),
|
||||||
|
agent_type=agent_type,
|
||||||
|
title=f"{scenario} 협업 시나리오 ({date.today()})",
|
||||||
|
)
|
||||||
|
db.add(conv)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
reply = await agent.chat(
|
||||||
|
messages=[{"role": "user", "content": prompt}],
|
||||||
|
context=context,
|
||||||
|
)
|
||||||
|
|
||||||
|
msg = AgentMessage(
|
||||||
|
conversation_id=conv.id,
|
||||||
|
role="assistant",
|
||||||
|
content=reply,
|
||||||
|
is_proactive=True,
|
||||||
|
metadata={"scenario": scenario, "step": agent_type.value},
|
||||||
|
)
|
||||||
|
db.add(msg)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
prev_responses.append(f"[{agent_type.value.upper()}] {reply[:300]}")
|
||||||
|
results.append({
|
||||||
|
"agent": agent_type.value,
|
||||||
|
"agent_name": agent.name_ko,
|
||||||
|
"content": reply,
|
||||||
|
"conversation_id": str(conv.id),
|
||||||
|
"message_id": str(msg.id),
|
||||||
|
})
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
return results
|
||||||
142
backend/app/services/completion_service.py
Normal file
142
backend/app/services/completion_service.py
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
"""
|
||||||
|
준공도서 패키지 자동화 서비스
|
||||||
|
작업일보, 품질시험, 검측이력, 사진대장, 인허가 현황을 종합하여
|
||||||
|
준공도서 PDF 번들을 생성합니다.
|
||||||
|
"""
|
||||||
|
import io
|
||||||
|
import zipfile
|
||||||
|
from datetime import date
|
||||||
|
from pathlib import Path
|
||||||
|
from jinja2 import Environment, FileSystemLoader
|
||||||
|
from sqlalchemy import select, func
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.project import Project
|
||||||
|
from app.models.daily_report import DailyReport
|
||||||
|
from app.models.quality import QualityTest, QualityResult
|
||||||
|
from app.models.inspection import InspectionRequest, InspectionStatus
|
||||||
|
from app.models.permit import PermitItem, PermitStatus
|
||||||
|
|
||||||
|
TEMPLATES_DIR = Path(__file__).parent.parent / "templates"
|
||||||
|
_jinja = Environment(loader=FileSystemLoader(str(TEMPLATES_DIR)), autoescape=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _html_to_pdf(html: str) -> bytes:
|
||||||
|
try:
|
||||||
|
from weasyprint import HTML
|
||||||
|
return HTML(string=html).write_pdf()
|
||||||
|
except ImportError:
|
||||||
|
raise RuntimeError("WeasyPrint가 설치되지 않았습니다. `pip install weasyprint`")
|
||||||
|
|
||||||
|
|
||||||
|
async def build_completion_package(
|
||||||
|
db: AsyncSession,
|
||||||
|
project_id,
|
||||||
|
) -> tuple[bytes, str]:
|
||||||
|
"""
|
||||||
|
준공도서 ZIP 패키지 생성.
|
||||||
|
반환: (zip_bytes, filename)
|
||||||
|
"""
|
||||||
|
import uuid
|
||||||
|
pid = uuid.UUID(str(project_id))
|
||||||
|
today = date.today()
|
||||||
|
|
||||||
|
# 프로젝트 정보
|
||||||
|
proj_r = await db.execute(select(Project).where(Project.id == pid))
|
||||||
|
project = proj_r.scalar_one_or_none()
|
||||||
|
if not project:
|
||||||
|
raise ValueError("프로젝트를 찾을 수 없습니다")
|
||||||
|
|
||||||
|
# 데이터 수집
|
||||||
|
dr_r = await db.execute(
|
||||||
|
select(DailyReport).where(DailyReport.project_id == pid).order_by(DailyReport.report_date)
|
||||||
|
)
|
||||||
|
daily_reports = dr_r.scalars().all()
|
||||||
|
|
||||||
|
qt_r = await db.execute(
|
||||||
|
select(QualityTest).where(QualityTest.project_id == pid).order_by(QualityTest.test_date)
|
||||||
|
)
|
||||||
|
quality_tests = qt_r.scalars().all()
|
||||||
|
|
||||||
|
insp_r = await db.execute(
|
||||||
|
select(InspectionRequest).where(InspectionRequest.project_id == pid).order_by(InspectionRequest.requested_date)
|
||||||
|
)
|
||||||
|
inspections = insp_r.scalars().all()
|
||||||
|
|
||||||
|
permit_r = await db.execute(
|
||||||
|
select(PermitItem).where(PermitItem.project_id == pid).order_by(PermitItem.sort_order)
|
||||||
|
)
|
||||||
|
permits = permit_r.scalars().all()
|
||||||
|
|
||||||
|
# 통계
|
||||||
|
total_dr = len(daily_reports)
|
||||||
|
total_qt = len(quality_tests)
|
||||||
|
pass_qt = sum(1 for q in quality_tests if q.result == QualityResult.PASS)
|
||||||
|
total_insp = len(inspections)
|
||||||
|
completed_insp = sum(1 for i in inspections if i.status == InspectionStatus.COMPLETED)
|
||||||
|
total_permits = len(permits)
|
||||||
|
approved_permits = sum(1 for p in permits if p.status == PermitStatus.APPROVED)
|
||||||
|
|
||||||
|
# 1. 준공 요약 PDF
|
||||||
|
summary_html = _jinja.get_template("completion_summary.html").render(
|
||||||
|
project=project,
|
||||||
|
today=today,
|
||||||
|
total_dr=total_dr,
|
||||||
|
total_qt=total_qt,
|
||||||
|
pass_qt=pass_qt,
|
||||||
|
pass_rate=round(pass_qt / total_qt * 100, 1) if total_qt else 0,
|
||||||
|
total_insp=total_insp,
|
||||||
|
completed_insp=completed_insp,
|
||||||
|
total_permits=total_permits,
|
||||||
|
approved_permits=approved_permits,
|
||||||
|
daily_reports=daily_reports[:5], # 최근 5개 미리보기
|
||||||
|
quality_tests=quality_tests[-10:], # 마지막 10개 미리보기
|
||||||
|
)
|
||||||
|
summary_pdf = _html_to_pdf(summary_html)
|
||||||
|
|
||||||
|
# 2. 품질시험 목록 PDF
|
||||||
|
qt_html = _jinja.get_template("quality_list.html").render(
|
||||||
|
project=project,
|
||||||
|
quality_tests=quality_tests,
|
||||||
|
today=today,
|
||||||
|
)
|
||||||
|
qt_pdf = _html_to_pdf(qt_html)
|
||||||
|
|
||||||
|
# 3. 검측 이력 PDF
|
||||||
|
insp_html = _jinja.get_template("inspection_list.html").render(
|
||||||
|
project=project,
|
||||||
|
inspections=inspections,
|
||||||
|
today=today,
|
||||||
|
)
|
||||||
|
insp_pdf = _html_to_pdf(insp_html)
|
||||||
|
|
||||||
|
# 4. 인허가 현황 PDF
|
||||||
|
permit_html = _jinja.get_template("permit_list.html").render(
|
||||||
|
project=project,
|
||||||
|
permits=permits,
|
||||||
|
today=today,
|
||||||
|
)
|
||||||
|
permit_pdf = _html_to_pdf(permit_html)
|
||||||
|
|
||||||
|
# ZIP 번들 생성
|
||||||
|
zip_buffer = io.BytesIO()
|
||||||
|
project_code = getattr(project, "code", str(pid)[:8])
|
||||||
|
with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||||
|
zf.writestr(f"01_준공요약_{project_code}.pdf", summary_pdf)
|
||||||
|
zf.writestr(f"02_품질시험목록_{project_code}.pdf", qt_pdf)
|
||||||
|
zf.writestr(f"03_검측이력_{project_code}.pdf", insp_pdf)
|
||||||
|
zf.writestr(f"04_인허가현황_{project_code}.pdf", permit_pdf)
|
||||||
|
zf.writestr("README.txt", (
|
||||||
|
f"준공도서 패키지\n"
|
||||||
|
f"프로젝트: {project.name}\n"
|
||||||
|
f"생성일: {today}\n\n"
|
||||||
|
f"포함 문서:\n"
|
||||||
|
f" 01_준공요약 — 전체 공사 실적 요약\n"
|
||||||
|
f" 02_품질시험목록 — 전체 품질시험 기록 ({total_qt}건)\n"
|
||||||
|
f" 03_검측이력 — 검측 요청·완료 이력 ({total_insp}건)\n"
|
||||||
|
f" 04_인허가현황 — 인허가 취득 현황 ({total_permits}건)\n"
|
||||||
|
).encode("utf-8"))
|
||||||
|
|
||||||
|
zip_buffer.seek(0)
|
||||||
|
filename = f"completion_{project_code}_{today}.zip"
|
||||||
|
return zip_buffer.read(), filename
|
||||||
133
backend/app/services/document_parser.py
Normal file
133
backend/app/services/document_parser.py
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
"""
|
||||||
|
설계도서 파싱 서비스 (Phase 3)
|
||||||
|
PDF 설계도서에서 공종·수량·규격을 AI로 자동 추출합니다.
|
||||||
|
HWP 출력은 Pandoc을 통해 변환합니다.
|
||||||
|
"""
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from anthropic import AsyncAnthropic
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
_client = AsyncAnthropic(api_key=settings.ANTHROPIC_API_KEY)
|
||||||
|
|
||||||
|
_PARSE_SYSTEM = """당신은 건설 설계도서 분석 전문가입니다.
|
||||||
|
제공된 설계도서 내용에서 다음 정보를 추출하세요:
|
||||||
|
1. 공종 목록 (work_types): 주요 공종과 세부 공종
|
||||||
|
2. 수량 목록 (quantities): 각 공종별 수량과 단위
|
||||||
|
3. 규격 (specifications): 재료 규격, 강도, 등급
|
||||||
|
4. 특기 사항 (notes): 시공 시 주의사항, 특수 조건
|
||||||
|
|
||||||
|
JSON 형식으로만 반환하세요."""
|
||||||
|
|
||||||
|
|
||||||
|
async def parse_design_document_text(text: str) -> dict:
|
||||||
|
"""
|
||||||
|
설계도서 텍스트에서 공종/수량/규격 추출 (Claude API).
|
||||||
|
RAG 시드 스크립트로 읽은 텍스트를 직접 전달하는 용도.
|
||||||
|
"""
|
||||||
|
prompt = f"""다음 설계도서 내용을 분석해주세요:
|
||||||
|
|
||||||
|
{text[:8000]}
|
||||||
|
|
||||||
|
JSON 형식으로만 반환하세요:
|
||||||
|
{{
|
||||||
|
"work_types": ["터파기", "철근콘크리트", ...],
|
||||||
|
"quantities": [
|
||||||
|
{{"work_type": "터파기", "quantity": 500, "unit": "m³"}},
|
||||||
|
...
|
||||||
|
],
|
||||||
|
"specifications": [
|
||||||
|
{{"item": "콘크리트", "spec": "fck=24MPa", "notes": ""}},
|
||||||
|
...
|
||||||
|
],
|
||||||
|
"notes": ["주의사항1", ...]
|
||||||
|
}}"""
|
||||||
|
|
||||||
|
response = await _client.messages.create(
|
||||||
|
model=settings.CLAUDE_MODEL,
|
||||||
|
max_tokens=2048,
|
||||||
|
system=_PARSE_SYSTEM,
|
||||||
|
messages=[{"role": "user", "content": prompt}],
|
||||||
|
)
|
||||||
|
|
||||||
|
raw = response.content[0].text.strip()
|
||||||
|
match = re.search(r"\{.*\}", raw, re.DOTALL)
|
||||||
|
if match:
|
||||||
|
return json.loads(match.group())
|
||||||
|
return {"error": "파싱 실패", "raw": raw[:500]}
|
||||||
|
|
||||||
|
|
||||||
|
async def parse_design_document_image(image_data: bytes, media_type: str = "image/jpeg") -> dict:
|
||||||
|
"""
|
||||||
|
설계 도면 이미지에서 공종/수량/규격 추출 (Claude Vision).
|
||||||
|
도면 스캔 이미지나 PDF 페이지를 직접 분석합니다.
|
||||||
|
"""
|
||||||
|
image_b64 = base64.standard_b64encode(image_data).decode()
|
||||||
|
|
||||||
|
response = await _client.messages.create(
|
||||||
|
model=settings.CLAUDE_MODEL,
|
||||||
|
max_tokens=2048,
|
||||||
|
system=_PARSE_SYSTEM,
|
||||||
|
messages=[{
|
||||||
|
"role": "user",
|
||||||
|
"content": [
|
||||||
|
{"type": "image", "source": {"type": "base64", "media_type": media_type, "data": image_b64}},
|
||||||
|
{"type": "text", "text": "이 설계 도면/도서에서 공종, 수량, 규격을 추출해주세요. JSON으로만 반환하세요."},
|
||||||
|
],
|
||||||
|
}],
|
||||||
|
)
|
||||||
|
|
||||||
|
raw = response.content[0].text.strip()
|
||||||
|
match = re.search(r"\{.*\}", raw, re.DOTALL)
|
||||||
|
if match:
|
||||||
|
return json.loads(match.group())
|
||||||
|
return {"error": "파싱 실패", "raw": raw[:500]}
|
||||||
|
|
||||||
|
|
||||||
|
def convert_to_hwp(html_content: str, output_path: str | None = None) -> bytes | str:
|
||||||
|
"""
|
||||||
|
HTML → HWP 변환 (Pandoc 필요).
|
||||||
|
output_path 미지정 시 바이트 반환.
|
||||||
|
|
||||||
|
사전 요구사항: pandoc 설치 필요
|
||||||
|
설치: https://pandoc.org/installing.html
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with tempfile.NamedTemporaryFile(suffix=".html", delete=False, mode="w", encoding="utf-8") as f:
|
||||||
|
f.write(html_content)
|
||||||
|
html_path = f.name
|
||||||
|
|
||||||
|
if output_path:
|
||||||
|
out = output_path
|
||||||
|
else:
|
||||||
|
out = html_path.replace(".html", ".hwp")
|
||||||
|
|
||||||
|
result = subprocess.run(
|
||||||
|
["pandoc", html_path, "-o", out, "--from=html"],
|
||||||
|
capture_output=True, text=True, timeout=30,
|
||||||
|
)
|
||||||
|
|
||||||
|
os.unlink(html_path)
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise RuntimeError(f"Pandoc 변환 실패: {result.stderr}")
|
||||||
|
|
||||||
|
if output_path:
|
||||||
|
return output_path
|
||||||
|
else:
|
||||||
|
with open(out, "rb") as f:
|
||||||
|
data = f.read()
|
||||||
|
os.unlink(out)
|
||||||
|
return data
|
||||||
|
|
||||||
|
except FileNotFoundError:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Pandoc이 설치되지 않았습니다.\n"
|
||||||
|
"설치: https://pandoc.org/installing.html\n"
|
||||||
|
"Windows: winget install JohnMacFarlane.Pandoc"
|
||||||
|
)
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
"""
|
"""
|
||||||
EVMS (Earned Value Management System) 계산 서비스
|
EVMS (Earned Value Management System) — Phase 3 완전 자동화
|
||||||
PV, EV, AC, SPI, CPI 산출
|
PV, EV, AC, SPI, CPI 산출 + 공정 지연 예측 AI + 기성청구 자동 알림
|
||||||
"""
|
"""
|
||||||
from datetime import date
|
from datetime import date, timedelta
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
@@ -10,6 +10,62 @@ from app.models.task import Task
|
|||||||
from app.models.project import Project
|
from app.models.project import Project
|
||||||
|
|
||||||
|
|
||||||
|
async def predict_delay(
|
||||||
|
db: AsyncSession,
|
||||||
|
project_id,
|
||||||
|
spi: float,
|
||||||
|
planned_end: date | None,
|
||||||
|
snapshot_date: date,
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
SPI 기반 공기 지연 예측.
|
||||||
|
spi < 1 이면 지연, 남은 기간을 SPI로 나눠 예상 준공일 계산.
|
||||||
|
"""
|
||||||
|
if not planned_end or spi is None or spi <= 0:
|
||||||
|
return {"delay_days": None, "predicted_end": None, "status": "예측 불가"}
|
||||||
|
|
||||||
|
remaining_days = (planned_end - snapshot_date).days
|
||||||
|
if remaining_days <= 0:
|
||||||
|
return {"delay_days": 0, "predicted_end": str(planned_end), "status": "준공 예정일 경과"}
|
||||||
|
|
||||||
|
predicted_remaining = remaining_days / spi
|
||||||
|
predicted_end = snapshot_date + timedelta(days=int(predicted_remaining))
|
||||||
|
delay_days = (predicted_end - planned_end).days
|
||||||
|
|
||||||
|
if delay_days > 0:
|
||||||
|
status = f"{delay_days}일 지연 예상"
|
||||||
|
elif delay_days < -3:
|
||||||
|
status = f"{abs(delay_days)}일 조기 준공 예상"
|
||||||
|
else:
|
||||||
|
status = "정상 진행"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"delay_days": delay_days,
|
||||||
|
"predicted_end": str(predicted_end),
|
||||||
|
"status": status,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def compute_progress_claim(
|
||||||
|
total_budget: float,
|
||||||
|
actual_progress: float,
|
||||||
|
already_claimed_pct: float = 0.0,
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
기성청구 가능 금액 산출.
|
||||||
|
기성청구 가능 금액 = 총예산 × (실제 공정률 - 기청구 공정률)
|
||||||
|
"""
|
||||||
|
claimable_pct = max(0.0, actual_progress - already_claimed_pct)
|
||||||
|
claimable_amount = total_budget * (claimable_pct / 100)
|
||||||
|
return {
|
||||||
|
"actual_progress": actual_progress,
|
||||||
|
"already_claimed_pct": already_claimed_pct,
|
||||||
|
"claimable_pct": round(claimable_pct, 1),
|
||||||
|
"claimable_amount": round(claimable_amount, 0),
|
||||||
|
"claimable_amount_formatted": f"{claimable_amount:,.0f}원",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _clamp(v: float, lo: float, hi: float) -> float:
|
def _clamp(v: float, lo: float, hi: float) -> float:
|
||||||
return max(lo, min(hi, v))
|
return max(lo, min(hi, v))
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from datetime import date, datetime
|
|||||||
|
|
||||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||||
from apscheduler.triggers.interval import IntervalTrigger
|
from apscheduler.triggers.interval import IntervalTrigger
|
||||||
|
from apscheduler.triggers.cron import CronTrigger
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
from app.core.database import AsyncSessionLocal
|
from app.core.database import AsyncSessionLocal
|
||||||
@@ -115,6 +116,89 @@ async def _collect_weather_for_all_projects():
|
|||||||
logger.info("날씨 수집 완료")
|
logger.info("날씨 수집 완료")
|
||||||
|
|
||||||
|
|
||||||
|
async def _daily_evms_snapshot():
|
||||||
|
"""매일 자정 활성 프로젝트 EVMS 스냅샷 자동 저장"""
|
||||||
|
from app.services.evms_service import compute_evms
|
||||||
|
from app.models.evms import EVMSSnapshot
|
||||||
|
|
||||||
|
today = date.today()
|
||||||
|
async with AsyncSessionLocal() as db:
|
||||||
|
result = await db.execute(select(Project).where(Project.status == "active"))
|
||||||
|
projects = result.scalars().all()
|
||||||
|
for project in projects:
|
||||||
|
try:
|
||||||
|
data = await compute_evms(db, project.id, today)
|
||||||
|
snap = EVMSSnapshot(
|
||||||
|
project_id=project.id,
|
||||||
|
snapshot_date=today,
|
||||||
|
total_budget=data["total_budget"],
|
||||||
|
planned_progress=data["planned_progress"],
|
||||||
|
actual_progress=data["actual_progress"],
|
||||||
|
pv=data["pv"], ev=data["ev"], ac=data["ac"],
|
||||||
|
spi=data["spi"], cpi=data["cpi"],
|
||||||
|
eac=data["eac"], etc=data["etc"],
|
||||||
|
detail_json={"tasks": data["detail"]},
|
||||||
|
)
|
||||||
|
db.add(snap)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"EVMS 스냅샷 실패 [{project.id}]: {e}")
|
||||||
|
await db.commit()
|
||||||
|
logger.info(f"EVMS 일일 스냅샷 완료: {len(projects)}개 프로젝트")
|
||||||
|
|
||||||
|
|
||||||
|
async def _morning_agent_briefings():
|
||||||
|
"""매일 오전 7시 GONGSA 아침 브리핑 자동 생성"""
|
||||||
|
from app.models.agent import AgentConversation, AgentMessage, AgentType
|
||||||
|
from app.models.user import User
|
||||||
|
from app.services.agents.gongsa import gongsa_agent
|
||||||
|
|
||||||
|
async with AsyncSessionLocal() as db:
|
||||||
|
result = await db.execute(select(Project).where(Project.status == "active"))
|
||||||
|
projects = result.scalars().all()
|
||||||
|
|
||||||
|
for project in projects:
|
||||||
|
try:
|
||||||
|
# 프로젝트 관리자 조회 (첫 번째 admin)
|
||||||
|
user_r = await db.execute(
|
||||||
|
select(User).where(User.role == "site_manager").limit(1)
|
||||||
|
)
|
||||||
|
user = user_r.scalar_one_or_none()
|
||||||
|
if not user:
|
||||||
|
continue
|
||||||
|
|
||||||
|
context = await gongsa_agent.build_context(db, str(project.id))
|
||||||
|
prompt = (
|
||||||
|
f"오늘({context.get('today', str(date.today()))}) 아침 공정 브리핑을 작성해주세요. "
|
||||||
|
"날씨, 오늘 예정 공종, 주의사항을 포함해주세요."
|
||||||
|
)
|
||||||
|
reply = await gongsa_agent.chat(
|
||||||
|
messages=[{"role": "user", "content": prompt}],
|
||||||
|
context=context,
|
||||||
|
)
|
||||||
|
|
||||||
|
conv = AgentConversation(
|
||||||
|
project_id=project.id,
|
||||||
|
user_id=user.id,
|
||||||
|
agent_type=AgentType.GONGSA,
|
||||||
|
title=f"{date.today()} 아침 브리핑 (자동)",
|
||||||
|
)
|
||||||
|
db.add(conv)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
msg = AgentMessage(
|
||||||
|
conversation_id=conv.id,
|
||||||
|
role="assistant",
|
||||||
|
content=reply,
|
||||||
|
is_proactive=True,
|
||||||
|
)
|
||||||
|
db.add(msg)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"아침 브리핑 실패 [{project.id}]: {e}")
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
logger.info("아침 브리핑 자동 생성 완료")
|
||||||
|
|
||||||
|
|
||||||
def start_scheduler():
|
def start_scheduler():
|
||||||
"""FastAPI 시작 시 스케줄러를 초기화하고 시작합니다."""
|
"""FastAPI 시작 시 스케줄러를 초기화하고 시작합니다."""
|
||||||
global _scheduler
|
global _scheduler
|
||||||
@@ -127,11 +211,31 @@ def start_scheduler():
|
|||||||
id="weather_collect",
|
id="weather_collect",
|
||||||
name="날씨 데이터 자동 수집",
|
name="날씨 데이터 자동 수집",
|
||||||
replace_existing=True,
|
replace_existing=True,
|
||||||
misfire_grace_time=300, # 5분 내 누락 허용
|
misfire_grace_time=300,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 매일 자정 EVMS 스냅샷
|
||||||
|
_scheduler.add_job(
|
||||||
|
_daily_evms_snapshot,
|
||||||
|
trigger=CronTrigger(hour=0, minute=5),
|
||||||
|
id="evms_daily",
|
||||||
|
name="EVMS 일일 스냅샷",
|
||||||
|
replace_existing=True,
|
||||||
|
misfire_grace_time=600,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 매일 오전 7시 아침 브리핑
|
||||||
|
_scheduler.add_job(
|
||||||
|
_morning_agent_briefings,
|
||||||
|
trigger=CronTrigger(hour=7, minute=0),
|
||||||
|
id="morning_briefing",
|
||||||
|
name="GONGSA 아침 브리핑 자동 생성",
|
||||||
|
replace_existing=True,
|
||||||
|
misfire_grace_time=600,
|
||||||
)
|
)
|
||||||
|
|
||||||
_scheduler.start()
|
_scheduler.start()
|
||||||
logger.info("APScheduler 시작: 날씨 수집 3시간 주기")
|
logger.info("APScheduler 시작: 날씨(3h), EVMS 스냅샷(00:05), 아침 브리핑(07:00)")
|
||||||
return _scheduler
|
return _scheduler
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -114,6 +114,63 @@ async def classify_photo(
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
async def compare_with_drawing(
|
||||||
|
field_photo: bytes,
|
||||||
|
drawing_image: bytes,
|
||||||
|
comparison_type: str = "rebar",
|
||||||
|
field_media_type: str = "image/jpeg",
|
||||||
|
drawing_media_type: str = "image/jpeg",
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
Vision AI Level 3 — 설계 도면 vs 현장 사진 비교 (고난도 보조 판독)
|
||||||
|
comparison_type: rebar(철근배근), formwork(거푸집), general(일반)
|
||||||
|
최종 합격/불합격 판정은 현장 책임자가 합니다.
|
||||||
|
"""
|
||||||
|
field_b64 = base64.standard_b64encode(field_photo).decode()
|
||||||
|
drawing_b64 = base64.standard_b64encode(drawing_image).decode()
|
||||||
|
|
||||||
|
type_prompts = {
|
||||||
|
"rebar": "철근 배근 간격·직경·이음 방법을 도면과 비교해주세요.",
|
||||||
|
"formwork": "거푸집 치수·형태·지지 방법을 도면과 비교해주세요.",
|
||||||
|
"general": "현장 시공 상태를 도면과 전반적으로 비교해주세요.",
|
||||||
|
}
|
||||||
|
specific = type_prompts.get(comparison_type, type_prompts["general"])
|
||||||
|
|
||||||
|
prompt = f"""왼쪽은 설계 도면, 오른쪽은 현장 사진입니다.
|
||||||
|
{specific}
|
||||||
|
|
||||||
|
다음 JSON 형식으로만 응답하세요:
|
||||||
|
{{
|
||||||
|
"comparison_type": "{comparison_type}",
|
||||||
|
"conformances": ["도면과 일치하는 항목"],
|
||||||
|
"discrepancies": ["불일치 또는 확인 필요 항목"],
|
||||||
|
"risk_level": "저/중/고",
|
||||||
|
"recommendation": "현장 책임자에게 전달할 권고사항",
|
||||||
|
"confidence": 0.7,
|
||||||
|
"disclaimer": "이 결과는 AI 1차 보조 판독이며 최종 판정은 현장 책임자가 합니다."
|
||||||
|
}}"""
|
||||||
|
|
||||||
|
response = await _client.messages.create(
|
||||||
|
model=settings.CLAUDE_MODEL,
|
||||||
|
max_tokens=1024,
|
||||||
|
messages=[{
|
||||||
|
"role": "user",
|
||||||
|
"content": [
|
||||||
|
{"type": "image", "source": {"type": "base64", "media_type": drawing_media_type, "data": drawing_b64}},
|
||||||
|
{"type": "image", "source": {"type": "base64", "media_type": field_media_type, "data": field_b64}},
|
||||||
|
{"type": "text", "text": prompt},
|
||||||
|
],
|
||||||
|
}],
|
||||||
|
)
|
||||||
|
|
||||||
|
raw = response.content[0].text.strip()
|
||||||
|
import json, re
|
||||||
|
json_match = re.search(r"\{.*\}", raw, re.DOTALL)
|
||||||
|
if json_match:
|
||||||
|
return json.loads(json_match.group())
|
||||||
|
return {"error": "분석 실패", "raw": raw[:200]}
|
||||||
|
|
||||||
|
|
||||||
async def analyze_safety(
|
async def analyze_safety(
|
||||||
image_data: bytes,
|
image_data: bytes,
|
||||||
media_type: str = "image/jpeg",
|
media_type: str = "image/jpeg",
|
||||||
|
|||||||
97
backend/app/templates/completion_summary.html
Normal file
97
backend/app/templates/completion_summary.html
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<style>
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;700&display=swap');
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { font-family: 'Noto Sans KR', sans-serif; font-size: 11pt; color: #111; padding: 20mm; }
|
||||||
|
h1 { text-align: center; font-size: 20pt; font-weight: 700; margin-bottom: 2mm; }
|
||||||
|
h2 { font-size: 14pt; font-weight: 700; background: #1e3a5f; color: white; padding: 3mm 5mm; margin: 6mm 0 3mm; }
|
||||||
|
.subtitle { text-align: center; font-size: 12pt; color: #444; margin-bottom: 10mm; }
|
||||||
|
table { width: 100%; border-collapse: collapse; margin-bottom: 5mm; }
|
||||||
|
th, td { border: 1px solid #888; padding: 3mm 4mm; }
|
||||||
|
th { background: #f0f0f0; font-weight: 700; text-align: center; }
|
||||||
|
.stat-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 4mm; margin-bottom: 6mm; }
|
||||||
|
.stat-card { border: 2px solid #1e3a5f; border-radius: 4px; padding: 4mm; text-align: center; }
|
||||||
|
.stat-num { font-size: 22pt; font-weight: 700; color: #1e3a5f; }
|
||||||
|
.stat-label { font-size: 9pt; color: #666; margin-top: 1mm; }
|
||||||
|
.badge-pass { color: #065f46; background: #d1fae5; padding: 1mm 3mm; border-radius: 3px; font-size: 9pt; }
|
||||||
|
.badge-fail { color: #991b1b; background: #fee2e2; padding: 1mm 3mm; border-radius: 3px; font-size: 9pt; }
|
||||||
|
.footer { margin-top: 10mm; text-align: right; font-size: 10pt; color: #777; border-top: 1px solid #ccc; padding-top: 3mm; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>준 공 도 서</h1>
|
||||||
|
<div class="subtitle">{{ project.name }}</div>
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<tr><th>공사명</th><td colspan="3">{{ project.name }}</td></tr>
|
||||||
|
<tr>
|
||||||
|
<th>공사 기간</th>
|
||||||
|
<td>{{ project.start_date or '-' }} ~ {{ project.end_date or '-' }}</td>
|
||||||
|
<th>공사 금액</th>
|
||||||
|
<td>{{ "{:,.0f}".format(project.contract_amount) if project.contract_amount else '-' }}원</td>
|
||||||
|
</tr>
|
||||||
|
<tr><th>생성일</th><td colspan="3">{{ today }}</td></tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2>▶ 공사 실적 요약</h2>
|
||||||
|
<div class="stat-grid">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-num">{{ total_dr }}</div>
|
||||||
|
<div class="stat-label">작업일보 (건)</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-num">{{ total_qt }}</div>
|
||||||
|
<div class="stat-label">품질시험 (건)</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-num">{{ pass_rate }}%</div>
|
||||||
|
<div class="stat-label">품질 합격률</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-num">{{ total_insp }}</div>
|
||||||
|
<div class="stat-label">검측 (건)</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>▶ 인허가 취득 현황</h2>
|
||||||
|
<table>
|
||||||
|
<tr><th>구분</th><th>전체</th><th>취득 완료</th><th>취득률</th></tr>
|
||||||
|
<tr>
|
||||||
|
<td>인허가 항목</td>
|
||||||
|
<td style="text-align:center">{{ total_permits }}</td>
|
||||||
|
<td style="text-align:center">{{ approved_permits }}</td>
|
||||||
|
<td style="text-align:center">
|
||||||
|
{{ "%.0f"|format(approved_permits / total_permits * 100) if total_permits else 0 }}%
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{% if quality_tests %}
|
||||||
|
<h2>▶ 최근 품질시험 ({{ quality_tests|length }}건)</h2>
|
||||||
|
<table>
|
||||||
|
<tr><th>일자</th><th>시험 항목</th><th>측정값</th><th>결과</th></tr>
|
||||||
|
{% for qt in quality_tests %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ qt.test_date }}</td>
|
||||||
|
<td>{{ qt.test_type }}</td>
|
||||||
|
<td style="text-align:right">{{ qt.measured_value }} {{ qt.unit }}</td>
|
||||||
|
<td style="text-align:center">
|
||||||
|
{% if qt.result.value == 'pass' %}
|
||||||
|
<span class="badge-pass">합격</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge-fail">불합격</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</table>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
본 준공도서는 CONAI 시스템에서 자동 생성되었습니다. | 생성일시: {{ today }}
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
47
backend/app/templates/inspection_list.html
Normal file
47
backend/app/templates/inspection_list.html
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<style>
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;700&display=swap');
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { font-family: 'Noto Sans KR', sans-serif; font-size: 10pt; color: #111; padding: 15mm; }
|
||||||
|
h1 { text-align: center; font-size: 16pt; font-weight: 700; margin-bottom: 6mm; }
|
||||||
|
.meta { text-align: center; color: #555; margin-bottom: 6mm; }
|
||||||
|
table { width: 100%; border-collapse: collapse; }
|
||||||
|
th, td { border: 1px solid #999; padding: 2mm 3mm; font-size: 9pt; }
|
||||||
|
th { background: #1e3a5f; color: white; text-align: center; }
|
||||||
|
tr:nth-child(even) td { background: #f8f8f8; }
|
||||||
|
.footer { margin-top: 6mm; text-align: right; font-size: 9pt; color: #777; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>검측 이력</h1>
|
||||||
|
<div class="meta">{{ project.name }} | 생성일: {{ today }} | 총 {{ inspections|length }}건</div>
|
||||||
|
<table>
|
||||||
|
<tr><th>No.</th><th>요청일</th><th>검측 항목</th><th>위치</th><th>상태</th><th>결과</th><th>검측자</th></tr>
|
||||||
|
{% for insp in inspections %}
|
||||||
|
<tr>
|
||||||
|
<td style="text-align:center">{{ loop.index }}</td>
|
||||||
|
<td>{{ insp.requested_date }}</td>
|
||||||
|
<td>{{ insp.inspection_type }}</td>
|
||||||
|
<td>{{ insp.location_detail or '-' }}</td>
|
||||||
|
<td style="text-align:center">
|
||||||
|
{% if insp.status.value == 'completed' %}완료
|
||||||
|
{% elif insp.status.value == 'sent' %}발송됨
|
||||||
|
{% else %}초안{% endif %}
|
||||||
|
</td>
|
||||||
|
<td style="text-align:center">
|
||||||
|
{% if insp.result %}
|
||||||
|
{% if insp.result.value == 'pass' %}합격
|
||||||
|
{% elif insp.result.value == 'fail' %}불합격
|
||||||
|
{% else %}조건부{% endif %}
|
||||||
|
{% else %}-{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>{{ insp.inspector_name or '-' }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</table>
|
||||||
|
<div class="footer">CONAI 자동 생성</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
44
backend/app/templates/permit_list.html
Normal file
44
backend/app/templates/permit_list.html
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<style>
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;700&display=swap');
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { font-family: 'Noto Sans KR', sans-serif; font-size: 10pt; color: #111; padding: 15mm; }
|
||||||
|
h1 { text-align: center; font-size: 16pt; font-weight: 700; margin-bottom: 6mm; }
|
||||||
|
.meta { text-align: center; color: #555; margin-bottom: 6mm; }
|
||||||
|
table { width: 100%; border-collapse: collapse; }
|
||||||
|
th, td { border: 1px solid #999; padding: 2mm 3mm; font-size: 9pt; }
|
||||||
|
th { background: #1e3a5f; color: white; text-align: center; }
|
||||||
|
tr:nth-child(even) td { background: #f8f8f8; }
|
||||||
|
.approved { color: #065f46; font-weight: 700; }
|
||||||
|
.pending { color: #92400e; }
|
||||||
|
.footer { margin-top: 6mm; text-align: right; font-size: 9pt; color: #777; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>인허가 취득 현황</h1>
|
||||||
|
<div class="meta">{{ project.name }} | 생성일: {{ today }} | 총 {{ permits|length }}건</div>
|
||||||
|
<table>
|
||||||
|
<tr><th>No.</th><th>인허가 항목</th><th>관할 기관</th><th>기한</th><th>제출일</th><th>취득일</th><th>상태</th></tr>
|
||||||
|
{% for p in permits %}
|
||||||
|
<tr>
|
||||||
|
<td style="text-align:center">{{ loop.index }}</td>
|
||||||
|
<td>{{ p.permit_type }}</td>
|
||||||
|
<td>{{ p.authority or '-' }}</td>
|
||||||
|
<td>{{ p.deadline or '-' }}</td>
|
||||||
|
<td>{{ p.submitted_date or '-' }}</td>
|
||||||
|
<td>{{ p.approved_date or '-' }}</td>
|
||||||
|
<td style="text-align:center">
|
||||||
|
{% if p.status.value == 'approved' %}<span class="approved">취득 완료</span>
|
||||||
|
{% elif p.status.value == 'submitted' %}<span class="pending">제출됨</span>
|
||||||
|
{% elif p.status.value == 'in_progress' %}<span class="pending">진행중</span>
|
||||||
|
{% else %}대기{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</table>
|
||||||
|
<div class="footer">CONAI 자동 생성</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
47
backend/app/templates/quality_list.html
Normal file
47
backend/app/templates/quality_list.html
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<style>
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;700&display=swap');
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { font-family: 'Noto Sans KR', sans-serif; font-size: 10pt; color: #111; padding: 15mm; }
|
||||||
|
h1 { text-align: center; font-size: 16pt; font-weight: 700; margin-bottom: 6mm; }
|
||||||
|
.meta { text-align: center; color: #555; margin-bottom: 6mm; }
|
||||||
|
table { width: 100%; border-collapse: collapse; }
|
||||||
|
th, td { border: 1px solid #999; padding: 2mm 3mm; font-size: 9pt; }
|
||||||
|
th { background: #1e3a5f; color: white; text-align: center; }
|
||||||
|
tr:nth-child(even) td { background: #f8f8f8; }
|
||||||
|
.pass { color: #065f46; font-weight: 700; }
|
||||||
|
.fail { color: #991b1b; font-weight: 700; }
|
||||||
|
.footer { margin-top: 6mm; text-align: right; font-size: 9pt; color: #777; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>품질시험 목록</h1>
|
||||||
|
<div class="meta">{{ project.name }} | 생성일: {{ today }} | 총 {{ quality_tests|length }}건</div>
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<th>No.</th><th>시험 일자</th><th>시험 항목</th><th>위치</th>
|
||||||
|
<th>기준값</th><th>측정값</th><th>단위</th><th>결과</th><th>시험기관</th>
|
||||||
|
</tr>
|
||||||
|
{% for qt in quality_tests %}
|
||||||
|
<tr>
|
||||||
|
<td style="text-align:center">{{ loop.index }}</td>
|
||||||
|
<td>{{ qt.test_date }}</td>
|
||||||
|
<td>{{ qt.test_type }}</td>
|
||||||
|
<td>{{ qt.location_detail or '-' }}</td>
|
||||||
|
<td style="text-align:right">{{ qt.design_value or '-' }}</td>
|
||||||
|
<td style="text-align:right">{{ qt.measured_value }}</td>
|
||||||
|
<td style="text-align:center">{{ qt.unit }}</td>
|
||||||
|
<td style="text-align:center">
|
||||||
|
{% if qt.result.value == 'pass' %}<span class="pass">합격</span>
|
||||||
|
{% else %}<span class="fail">불합격</span>{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>{{ qt.lab_name or '-' }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</table>
|
||||||
|
<div class="footer">CONAI 자동 생성</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user