""" 문서 추출 템플릿 관리 내장 7종 + 커스텀 템플릿 CRUD. 엔드포인트: GET /api/doctemplate/ — 템플릿 목록 POST /api/doctemplate/ — 커스텀 템플릿 생성 GET /api/doctemplate/{id} — 템플릿 상세 PUT /api/doctemplate/{id} — 수정 DELETE /api/doctemplate/{id} — 삭제 GET /api/doctemplate/builtin — 내장 템플릿 목록 POST /api/doctemplate/apply-builtin — 내장 템플릿 테넌트 적용 """ from __future__ import annotations import json from datetime import datetime from typing import Optional from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel, Field from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from core.auth import get_current_user, require_admin_role from database import get_db from models import User, DocTemplate router = APIRouter(prefix="/api/doctemplate", tags=["문서 템플릿"]) BUILTIN_TEMPLATES = { "narasajang_contract": { "name": "나라장터 계약서", "description": "조달청 나라장터 계약서에서 계약정보를 자동 추출", "workflow": "contract", "schema": { "contract_no": "계약번호", "contract_name": "계약품명/서비스명", "supplier": "공급사명", "supplier_biz_no":"공급사 사업자번호", "amount": "계약금액(원)", "vat": "부가세액", "start_date": "계약시작일(YYYY-MM-DD)", "end_date": "계약종료일(YYYY-MM-DD)", "institution": "발주기관명", "manager": "담당자명", "payment_terms": "납부/지급 조건", } }, "server_delivery": { "name": "서버 납품 명세서", "description": "서버·장비 납품명세서에서 사양을 추출하여 CMDB에 자동 등록", "workflow": "server_spec", "schema": { "hostname": "호스트명/서버명", "manufacturer": "제조사", "model_no": "모델번호", "serial_no": "시리얼번호", "cpu_model": "CPU 모델명", "cpu_cores": "CPU 코어 수", "memory_gb": "메모리 용량(GB)", "disk_config": "스토리지 구성(예: SSD 1TB×2)", "os": "운영체제", "ip_addr": "IP주소", "rack_location": "랙/위치", "warranty_until": "보증기간 만료일", "delivery_date": "납품일", } }, "brand_contract": { "name": "기업 브랜드 계약서", "description": "현대백화점·롯데 등 유통/브랜드 계약서 자동 처리", "workflow": "brand_contract", "schema": { "contract_title": "계약서 제목", "party_a": "갑(발주사/브랜드사)", "party_a_biz_no": "갑 사업자번호", "party_b": "을(수주사/입점사)", "party_b_biz_no": "을 사업자번호", "contract_amount": "계약금액", "currency": "통화(KRW/USD/기타)", "effective_date": "계약체결일", "expiry_date": "계약만료일", "auto_renewal": "자동갱신여부(Y/N)", "payment_terms": "대금 지급조건", "contract_items": "계약 품목/서비스", "royalty_rate": "수수료율/로열티율", "territory": "적용지역/매장", "exclusive": "독점여부(Y/N)", "termination": "계약 해지 조건", "penalty_clause": "위약금 조항", "contact_a": "갑 담당자", "contact_b": "을 담당자", "special_terms": "특약사항", } }, "invoice": { "name": "세금계산서/청구서", "description": "세금계산서·청구서에서 금액·공급자 정보 자동 추출", "workflow": "invoice", "schema": { "invoice_no": "세금계산서번호/청구번호", "issue_date": "발행일", "supplier_name": "공급자 상호", "supplier_biz_no": "공급자 사업자번호", "buyer_name": "공급받는자 상호", "buyer_biz_no": "공급받는자 사업자번호", "supply_amount": "공급가액", "vat_amount": "세액", "total_amount": "합계금액", "items": "품목/내역(쉼표 구분)", "payment_due": "결제기한", } }, "incident_report": { "name": "장애 보고서", "description": "장애보고서 이미지/PDF에서 에러 내용 추출 → SR 자동 생성", "workflow": "incident_report", "schema": { "incident_date": "발생일시", "incident_type": "장애유형(H/W·S/W·네트워크·기타)", "affected_system": "영향 시스템/서비스", "error_message": "오류 메시지/에러코드", "root_cause": "근본원인", "impact_scope": "영향 범위(사용자 수/서비스)", "resolution": "조치사항", "downtime_minutes": "다운타임(분)", "reporter": "보고자/담당자", "severity": "심각도(P1/P2/P3/P4)", } }, "csap_report": { "name": "CSAP/ISMS 점검 보고서", "description": "공공기관 보안 점검 보고서 자동 분석 → CSAP 준수율 업데이트", "workflow": "audit_report", "schema": { "institution": "기관명", "check_date": "점검일", "auditor": "점검자/감사기관", "total_items": "총 점검항목 수", "passed_items": "적합(통과) 항목 수", "failed_items": "부적합 항목 수", "na_items": "해당없음 항목 수", "compliance_rate": "준수율(%)", "major_findings": "주요 발견사항", "recommendations": "권고사항", "next_check_date": "차기 점검 예정일", } }, "meeting_minutes": { "name": "회의록", "description": "회의록에서 결정사항·액션아이템 자동 추출 → SR/작업 생성", "workflow": "meeting_minutes", "schema": { "meeting_date": "회의일시", "meeting_place": "장소(오프라인/화상)", "chairman": "의장/주관자", "participants": "참석자 목록", "agenda": "회의 안건", "decisions": "결정사항(쉼표 구분)", "action_items": "액션아이템(담당자/기한 포함)", "next_meeting": "차기 회의 일정", "notes": "기타 특이사항", } }, } class TemplateCreate(BaseModel): name: str = Field(..., max_length=200) description: Optional[str] = None schema_json: dict = Field(..., description="추출 스키마 {필드명: 설명}") workflow: Optional[str] = Field(None, description="연동 워크플로우") class ApplyBuiltinRequest(BaseModel): template_keys: list[str] @router.get("/builtin") async def list_builtin_templates(_: User = Depends(get_current_user)): return [ { "key": k, "name": v["name"], "description": v["description"], "workflow": v["workflow"], "field_count": len(v["schema"]), "fields": list(v["schema"].keys()), } for k, v in BUILTIN_TEMPLATES.items() ] @router.post("/apply-builtin") async def apply_builtin_templates( req: ApplyBuiltinRequest, db: AsyncSession = Depends(get_db), user: User = Depends(require_admin_role), ): """내장 템플릿을 현재 테넌트에 적용.""" created = [] for key in req.template_keys: tpl = BUILTIN_TEMPLATES.get(key) if not tpl: continue existing = await db.execute( select(DocTemplate).where( DocTemplate.tenant_id == user.tenant_id, DocTemplate.builtin_key == key, ) ) if existing.scalar_one_or_none(): continue tmpl = DocTemplate( tenant_id=user.tenant_id, name=tpl["name"], description=tpl["description"], schema_json=json.dumps(tpl["schema"], ensure_ascii=False), workflow=tpl["workflow"], builtin_key=key, is_builtin=True, is_active=True, created_at=datetime.utcnow(), ) db.add(tmpl) created.append(tpl["name"]) await db.commit() return {"ok": True, "created": created, "count": len(created)} @router.get("/") async def list_templates( db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user), ): rows = await db.execute( select(DocTemplate).where( DocTemplate.tenant_id == user.tenant_id, DocTemplate.is_active == True, ).order_by(DocTemplate.is_builtin.desc(), DocTemplate.name) ) templates = rows.scalars().all() return [ { "id": t.id, "name": t.name, "description": t.description, "workflow": t.workflow, "is_builtin": t.is_builtin, "field_count": len(json.loads(t.schema_json or "{}")), "created_at": t.created_at, } for t in templates ] @router.post("/") async def create_template( req: TemplateCreate, db: AsyncSession = Depends(get_db), user: User = Depends(require_admin_role), ): tmpl = DocTemplate( tenant_id=user.tenant_id, name=req.name, description=req.description, schema_json=json.dumps(req.schema_json, ensure_ascii=False), workflow=req.workflow, is_builtin=False, is_active=True, created_at=datetime.utcnow(), ) db.add(tmpl) await db.commit() await db.refresh(tmpl) return {"ok": True, "id": tmpl.id} @router.get("/{template_id}") async def get_template( template_id: int, db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user), ): row = await db.execute( select(DocTemplate).where( DocTemplate.id == template_id, DocTemplate.tenant_id == user.tenant_id, ) ) t = row.scalar_one_or_none() if not t: raise HTTPException(404) return { "id": t.id, "name": t.name, "description": t.description, "schema": json.loads(t.schema_json or "{}"), "workflow": t.workflow, "is_builtin": t.is_builtin, } @router.put("/{template_id}") async def update_template( template_id: int, req: TemplateCreate, db: AsyncSession = Depends(get_db), user: User = Depends(require_admin_role), ): row = await db.execute( select(DocTemplate).where( DocTemplate.id == template_id, DocTemplate.tenant_id == user.tenant_id, ) ) t = row.scalar_one_or_none() if not t: raise HTTPException(404) if t.is_builtin: raise HTTPException(400, "내장 템플릿은 수정할 수 없습니다. 복제 후 수정하세요.") t.name = req.name; t.description = req.description t.schema_json = json.dumps(req.schema_json, ensure_ascii=False) t.workflow = req.workflow await db.commit() return {"ok": True} @router.delete("/{template_id}") async def delete_template( template_id: int, db: AsyncSession = Depends(get_db), user: User = Depends(require_admin_role), ): row = await db.execute( select(DocTemplate).where( DocTemplate.id == template_id, DocTemplate.tenant_id == user.tenant_id, ) ) t = row.scalar_one_or_none() if not t: raise HTTPException(404) if t.is_builtin: raise HTTPException(400, "내장 템플릿은 삭제할 수 없습니다.") t.is_active = False await db.commit() return {"ok": True}