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:
sinmb79
2026-03-24 22:02:29 +09:00
parent 48f1027f08
commit 5a044a3882
17 changed files with 1350 additions and 8 deletions

View File

@@ -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

View 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,
}

View 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}"},
)

View File

@@ -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

162
backend/app/api/portal.py Normal file
View 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],
}

View File

@@ -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,

View File

@@ -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")

View 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

View 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

View 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": ""}},
...
],
"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"
)

View File

@@ -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))

View File

@@ -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

View File

@@ -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",

View 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>

View 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>

View 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>

View 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>