From 5a044a3882a8dacf229d906e2a30300de3f13875 Mon Sep 17 00:00:00 2001 From: sinmb79 Date: Tue, 24 Mar 2026 22:02:29 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20Phase=203=20=EA=B5=AC=ED=98=84=20?= =?UTF-8?q?=E2=80=94=20=EC=99=84=EC=A0=84=20=EC=9E=90=EB=8F=99=ED=99=94,?= =?UTF-8?q?=20=EC=A4=80=EA=B3=B5=EB=8F=84=EC=84=9C,=20Vision=20L3,=20?= =?UTF-8?q?=EB=B0=9C=EC=A3=BC=EC=B2=98=20=ED=8F=AC=ED=84=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/app/api/agents.py | 27 +++ backend/app/api/completion.py | 113 ++++++++++++ backend/app/api/documents.py | 102 +++++++++++ backend/app/api/evms.py | 60 ++++++- backend/app/api/portal.py | 162 ++++++++++++++++++ backend/app/api/vision.py | 39 ++++- backend/app/main.py | 5 +- backend/app/services/agents/collaboration.py | 113 ++++++++++++ backend/app/services/completion_service.py | 142 +++++++++++++++ backend/app/services/document_parser.py | 133 ++++++++++++++ backend/app/services/evms_service.py | 62 ++++++- backend/app/services/scheduler.py | 108 +++++++++++- backend/app/services/vision_service.py | 57 ++++++ backend/app/templates/completion_summary.html | 97 +++++++++++ backend/app/templates/inspection_list.html | 47 +++++ backend/app/templates/permit_list.html | 44 +++++ backend/app/templates/quality_list.html | 47 +++++ 17 files changed, 1350 insertions(+), 8 deletions(-) create mode 100644 backend/app/api/completion.py create mode 100644 backend/app/api/documents.py create mode 100644 backend/app/api/portal.py create mode 100644 backend/app/services/agents/collaboration.py create mode 100644 backend/app/services/completion_service.py create mode 100644 backend/app/services/document_parser.py create mode 100644 backend/app/templates/completion_summary.html create mode 100644 backend/app/templates/inspection_list.html create mode 100644 backend/app/templates/permit_list.html create mode 100644 backend/app/templates/quality_list.html diff --git a/backend/app/api/agents.py b/backend/app/api/agents.py index 509ce11..e2631b5 100644 --- a/backend/app/api/agents.py +++ b/backend/app/api/agents.py @@ -16,6 +16,7 @@ from app.deps import CurrentUser, DB from app.models.agent import AgentConversation, AgentMessage, AgentType, ConversationStatus from app.models.project import Project 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 에이전트"]) @@ -267,6 +268,32 @@ async def morning_briefing( 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) async def delete_conversation( project_id: uuid.UUID, conv_id: uuid.UUID, db: DB, current_user: CurrentUser diff --git a/backend/app/api/completion.py b/backend/app/api/completion.py new file mode 100644 index 0000000..7e6fa6d --- /dev/null +++ b/backend/app/api/completion.py @@ -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, + } diff --git a/backend/app/api/documents.py b/backend/app/api/documents.py new file mode 100644 index 0000000..18b9036 --- /dev/null +++ b/backend/app/api/documents.py @@ -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}"}, + ) diff --git a/backend/app/api/evms.py b/backend/app/api/evms.py index 40bfc52..3cc99ac 100644 --- a/backend/app/api/evms.py +++ b/backend/app/api/evms.py @@ -9,7 +9,7 @@ from sqlalchemy.orm import selectinload from app.deps import CurrentUser, DB from app.models.evms import EVMSSnapshot 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"]) @@ -117,6 +117,64 @@ async def list_snapshots( 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) async def latest_snapshot( project_id: uuid.UUID, db: DB, current_user: CurrentUser diff --git a/backend/app/api/portal.py b/backend/app/api/portal.py new file mode 100644 index 0000000..ccbec72 --- /dev/null +++ b/backend/app/api/portal.py @@ -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], + } diff --git a/backend/app/api/vision.py b/backend/app/api/vision.py index 8cb91f9..c307065 100644 --- a/backend/app/api/vision.py +++ b/backend/app/api/vision.py @@ -9,10 +9,12 @@ from sqlalchemy import select from app.deps import CurrentUser, DB from app.models.project import Project 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"]) +COMPARISON_TYPES = {"rebar": "철근 배근", "formwork": "거푸집", "general": "일반 비교"} + ALLOWED_TYPES = {"image/jpeg", "image/png", "image/webp", "image/heic"} MAX_SIZE_MB = 10 @@ -91,6 +93,41 @@ async def classify_field_photo( 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") async def safety_check( project_id: uuid.UUID, diff --git a/backend/app/main.py b/backend/app/main.py index 28d02e0..148097b 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -5,7 +5,7 @@ from app.config import settings from app.api import ( auth, projects, tasks, daily_reports, reports, inspections, 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 @@ -53,6 +53,9 @@ def create_app() -> FastAPI: app.include_router(evms.router, prefix=api_prefix) app.include_router(vision.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.get("/health") diff --git a/backend/app/services/agents/collaboration.py b/backend/app/services/agents/collaboration.py new file mode 100644 index 0000000..7fac471 --- /dev/null +++ b/backend/app/services/agents/collaboration.py @@ -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 diff --git a/backend/app/services/completion_service.py b/backend/app/services/completion_service.py new file mode 100644 index 0000000..102c416 --- /dev/null +++ b/backend/app/services/completion_service.py @@ -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 diff --git a/backend/app/services/document_parser.py b/backend/app/services/document_parser.py new file mode 100644 index 0000000..2209ba7 --- /dev/null +++ b/backend/app/services/document_parser.py @@ -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" + ) diff --git a/backend/app/services/evms_service.py b/backend/app/services/evms_service.py index 017458f..4eede65 100644 --- a/backend/app/services/evms_service.py +++ b/backend/app/services/evms_service.py @@ -1,8 +1,8 @@ """ -EVMS (Earned Value Management System) 계산 서비스 -PV, EV, AC, SPI, CPI 산출 +EVMS (Earned Value Management System) — Phase 3 완전 자동화 +PV, EV, AC, SPI, CPI 산출 + 공정 지연 예측 AI + 기성청구 자동 알림 """ -from datetime import date +from datetime import date, timedelta from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -10,6 +10,62 @@ from app.models.task import Task 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: return max(lo, min(hi, v)) diff --git a/backend/app/services/scheduler.py b/backend/app/services/scheduler.py index dd03dd4..2df3b80 100644 --- a/backend/app/services/scheduler.py +++ b/backend/app/services/scheduler.py @@ -8,6 +8,7 @@ from datetime import date, datetime from apscheduler.schedulers.asyncio import AsyncIOScheduler from apscheduler.triggers.interval import IntervalTrigger +from apscheduler.triggers.cron import CronTrigger from sqlalchemy import select from app.core.database import AsyncSessionLocal @@ -115,6 +116,89 @@ async def _collect_weather_for_all_projects(): 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(): """FastAPI 시작 시 스케줄러를 초기화하고 시작합니다.""" global _scheduler @@ -127,11 +211,31 @@ def start_scheduler(): id="weather_collect", name="날씨 데이터 자동 수집", 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() - logger.info("APScheduler 시작: 날씨 수집 3시간 주기") + logger.info("APScheduler 시작: 날씨(3h), EVMS 스냅샷(00:05), 아침 브리핑(07:00)") return _scheduler diff --git a/backend/app/services/vision_service.py b/backend/app/services/vision_service.py index 6baa6c1..e5a4e2c 100644 --- a/backend/app/services/vision_service.py +++ b/backend/app/services/vision_service.py @@ -114,6 +114,63 @@ async def classify_photo( 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( image_data: bytes, media_type: str = "image/jpeg", diff --git a/backend/app/templates/completion_summary.html b/backend/app/templates/completion_summary.html new file mode 100644 index 0000000..d9b9888 --- /dev/null +++ b/backend/app/templates/completion_summary.html @@ -0,0 +1,97 @@ + + + + + + + +

준 공 도 서

+
{{ project.name }}
+ + + + + + + + + + +
공사명{{ project.name }}
공사 기간{{ project.start_date or '-' }} ~ {{ project.end_date or '-' }}공사 금액{{ "{:,.0f}".format(project.contract_amount) if project.contract_amount else '-' }}원
생성일{{ today }}
+ +

▶ 공사 실적 요약

+
+
+
{{ total_dr }}
+
작업일보 (건)
+
+
+
{{ total_qt }}
+
품질시험 (건)
+
+
+
{{ pass_rate }}%
+
품질 합격률
+
+
+
{{ total_insp }}
+
검측 (건)
+
+
+ +

▶ 인허가 취득 현황

+ + + + + + + + +
구분전체취득 완료취득률
인허가 항목{{ total_permits }}{{ approved_permits }} + {{ "%.0f"|format(approved_permits / total_permits * 100) if total_permits else 0 }}% +
+ + {% if quality_tests %} +

▶ 최근 품질시험 ({{ quality_tests|length }}건)

+ + + {% for qt in quality_tests %} + + + + + + + {% endfor %} +
일자시험 항목측정값결과
{{ qt.test_date }}{{ qt.test_type }}{{ qt.measured_value }} {{ qt.unit }} + {% if qt.result.value == 'pass' %} + 합격 + {% else %} + 불합격 + {% endif %} +
+ {% endif %} + + + + diff --git a/backend/app/templates/inspection_list.html b/backend/app/templates/inspection_list.html new file mode 100644 index 0000000..eb8cb02 --- /dev/null +++ b/backend/app/templates/inspection_list.html @@ -0,0 +1,47 @@ + + + + + + + +

검측 이력

+
{{ project.name }} | 생성일: {{ today }} | 총 {{ inspections|length }}건
+ + + {% for insp in inspections %} + + + + + + + + + + {% endfor %} +
No.요청일검측 항목위치상태결과검측자
{{ loop.index }}{{ insp.requested_date }}{{ insp.inspection_type }}{{ insp.location_detail or '-' }} + {% if insp.status.value == 'completed' %}완료 + {% elif insp.status.value == 'sent' %}발송됨 + {% else %}초안{% endif %} + + {% if insp.result %} + {% if insp.result.value == 'pass' %}합격 + {% elif insp.result.value == 'fail' %}불합격 + {% else %}조건부{% endif %} + {% else %}-{% endif %} + {{ insp.inspector_name or '-' }}
+ + + diff --git a/backend/app/templates/permit_list.html b/backend/app/templates/permit_list.html new file mode 100644 index 0000000..8e72dcd --- /dev/null +++ b/backend/app/templates/permit_list.html @@ -0,0 +1,44 @@ + + + + + + + +

인허가 취득 현황

+
{{ project.name }} | 생성일: {{ today }} | 총 {{ permits|length }}건
+ + + {% for p in permits %} + + + + + + + + + + {% endfor %} +
No.인허가 항목관할 기관기한제출일취득일상태
{{ loop.index }}{{ p.permit_type }}{{ p.authority or '-' }}{{ p.deadline or '-' }}{{ p.submitted_date or '-' }}{{ p.approved_date or '-' }} + {% if p.status.value == 'approved' %}취득 완료 + {% elif p.status.value == 'submitted' %}제출됨 + {% elif p.status.value == 'in_progress' %}진행중 + {% else %}대기{% endif %} +
+ + + diff --git a/backend/app/templates/quality_list.html b/backend/app/templates/quality_list.html new file mode 100644 index 0000000..cac42df --- /dev/null +++ b/backend/app/templates/quality_list.html @@ -0,0 +1,47 @@ + + + + + + + +

품질시험 목록

+
{{ project.name }} | 생성일: {{ today }} | 총 {{ quality_tests|length }}건
+ + + + + + {% for qt in quality_tests %} + + + + + + + + + + + + {% endfor %} +
No.시험 일자시험 항목위치기준값측정값단위결과시험기관
{{ loop.index }}{{ qt.test_date }}{{ qt.test_type }}{{ qt.location_detail or '-' }}{{ qt.design_value or '-' }}{{ qt.measured_value }}{{ qt.unit }} + {% if qt.result.value == 'pass' %}합격 + {% else %}불합격{% endif %} + {{ qt.lab_name or '-' }}
+ + +