diff --git a/main.py b/main.py index fb4fb28..72dda2b 100644 --- a/main.py +++ b/main.py @@ -77,6 +77,8 @@ from routers import ( mcp_agents, platform_eng, advanced_security2, data_ai2, public_sector2, infra_native, # 나라장터 사업 기회 분석 (2026-06-07) g2b_opportunity, + # 비즈니스 지원 도구 3종 (2026-06-07) + bid_watcher, ) @@ -572,6 +574,11 @@ app.include_router(data_ai2.router) # 데이터 AI v2 (벡터DB·R app.include_router(public_sector2.router) # 공공기관 특화 v2 (K-CSAP·나라장터·GPKI·ISP) app.include_router(infra_native.router) # 클라우드 네이티브 (eBPF·Wasm·서비스메시·이벤트소싱) app.include_router(g2b_opportunity.router) # 나라장터 사업 기회 분석 + GUARDiA 적용성 +app.include_router(bid_watcher.router) # 나라장터 입찰워처 (당일 SI/SM 입찰 수집·참가의사결정) + +# ── Jasper Reports 호환 문서 자동생성 (JRXML→PDF/Excel, 자바 미설치) ────────── +from routers import jasper_report +app.include_router(jasper_report.router) # 산출물·회의록·보고서 자동생성 (JRXML 파싱→ReportLab/openpyxl) # ── 개방망 보안 헤더 미들웨어 ──────────────────────────────────────────────── @app.middleware("http") diff --git a/models.py b/models.py index 880055e..3bc0930 100644 --- a/models.py +++ b/models.py @@ -7273,3 +7273,62 @@ class LoginEvent(Base): detail = Column(Text) ip_addr_hash= Column(String(64)) # SHA-256, 원본 미저장 created_at = Column(DateTime, default=func.now(), index=True) + + +# ── jasper-report-dev: JasperReports 호환 문서 자동생성 (JRXML→PDF/Excel, 자바 미설치) ── + +class JasperTemplate(Base): + """JRXML 템플릿 메타데이터 + 원문 — 산출물·회의록·보고서 카테고리별 등록.""" + __tablename__ = "tb_jasper_template" + + id = Column(Integer, primary_key=True, index=True) + tenant_id = Column(Integer, index=True, default=1) + name = Column(String(200), nullable=False) + category = Column(String(30), nullable=False) # DELIVERABLE|MEETING_MINUTES|REPORT + jrxml_content = Column(Text) # JRXML XML 원문 (밴드/필드 정의) + field_mapping = Column(JSON) # {"sr_count": "report.sr_total", ...} ITSM 데이터 -> 템플릿 필드 + output_format = Column(String(10), default="PDF") # PDF|EXCEL + is_builtin = Column(Boolean, default=False) # 내장 시드 템플릿 여부 + created_at = Column(DateTime, default=func.now()) + + +class JasperGenerationJob(Base): + """문서 생성 작업 이력 — 산출물/회의록/보고서 PDF·Excel 생성 결과 보존.""" + __tablename__ = "tb_jasper_job" + + id = Column(Integer, primary_key=True, index=True) + tenant_id = Column(Integer, index=True, default=1) + template_id = Column(Integer, index=True) + category = Column(String(30)) # DELIVERABLE|MEETING_MINUTES|REPORT + title = Column(String(300)) + data_source = Column(JSON) # 렌더링에 사용된 데이터 스냅샷 + status = Column(String(20), default="PENDING") # PENDING|DONE|FAILED + output_format= Column(String(10), default="PDF") # PDF|EXCEL + output_path = Column(String(500), nullable=True) + file_size = Column(Integer, nullable=True) + error_msg = Column(Text, nullable=True) + requested_by = Column(Integer, nullable=True) + created_at = Column(DateTime, default=func.now()) + + +# ── bid-watcher-dev: 나라장터 당일 입찰정보 크롤링 + 참가 의사결정 워크플로우 ────── + +class BidWatch(Base): + """나라장터 당일 입찰정보 — 참가 의사결정 관리 단위 (SI/SM 프로젝트 한정 수집).""" + __tablename__ = "tb_bid_watch" + + id = Column(Integer, primary_key=True, index=True) + bid_no = Column(String(50), unique=True, index=True) # 나라장터 공고번호 (중복수집 방지) + title = Column(String(400)) + category = Column(String(100)) # 사업분류명 + institution = Column(String(200)) # 발주기관 + budget = Column(BigInteger, nullable=True) + announce_date = Column(Date, nullable=True) + deadline_date = Column(Date, nullable=True) + source_url = Column(String(500)) # 나라장터 원본 공고 링크 + attachments = Column(JSON) # [{name, doc_type, url}, ...] + status = Column(String(20), default="NEW") # NEW|JOIN|HOLD|DELETED + memo = Column(Text, nullable=True) + decided_by = Column(Integer, nullable=True) + decided_at = Column(DateTime, nullable=True) + collected_at = Column(DateTime, default=func.now()) diff --git a/requirements.txt b/requirements.txt index 70ee4b6..143dd3f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -26,3 +26,5 @@ python-docx>=1.1.0 python-pptx>=0.6.23 jinja2>=3.1.3 weasyprint>=62.0 +# Jasper Reports 호환 문서생성 (JRXML→PDF/Excel, 자바 미설치) +reportlab>=4.0.0 diff --git a/routers/bid_watcher.py b/routers/bid_watcher.py new file mode 100644 index 0000000..da2d78b --- /dev/null +++ b/routers/bid_watcher.py @@ -0,0 +1,517 @@ +""" +나라장터(G2B) 당일 입찰정보 크롤링 + 참가 의사결정 워크플로우 + 문서함 +- g2b_opportunity.py(적합성 분석 도구)와 달리, "당일 신규 공고 수집 → 참가/보류/삭제 + 의사결정 + RFP 등 첨부문서 다운로드"를 담당하는 운영 모니터링 도구다. +- SI/SM 프로젝트(SW개발용역·유지보수·시스템구축·시스템고도화 등)에 한정해서만 수집한다. +- 개방망(GUARDIA_NETWORK_MODE=open)에서만 외부 공공데이터포털 API를 호출하고, + 폐쇄망에서는 샘플 데이터로 동작한다 (g2b_opportunity.py의 _OPEN 분기 패턴 재사용). +""" +from __future__ import annotations + +import os +from datetime import datetime, date, timedelta +from pathlib import Path +from typing import Optional, List +from urllib.parse import quote + +import httpx +from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi.responses import StreamingResponse +from pydantic import BaseModel, ConfigDict +from sqlalchemy import select, func as sa_func +from sqlalchemy.ext.asyncio import AsyncSession + +from core.auth import get_current_user +from database import get_db +from models import User, BidWatch, AuditLog + +router = APIRouter(prefix="/api/bid-watcher", tags=["나라장터 입찰워처"]) + +_OPEN = os.environ.get("GUARDIA_NETWORK_MODE") == "open" + +# 공공데이터포털 나라장터 입찰공고정보서비스 (개방망 전용) +BID_API_BASE = "https://apis.data.go.kr/1230000/ad/BidPublicInfoService" +BID_API_KEY = os.environ.get("G2B_API_KEY", "") + +DOC_ROOT = Path(__file__).parent.parent / "uploads" / "bid_documents" + + +def _tenant(user: User) -> str: + return user.inst_code or str(user.id) + + +# ── SI/SM 한정 필터 (가장 중요한 제약 — 통과 못하면 절대 저장하지 않는다) ────────── + +SI_SM_KEYWORDS = [ + "소프트웨어개발", "SW개발", "소프트웨어 유지보수", "유지보수", "유지관리", + "시스템 구축", "시스템구축", "시스템 고도화", "시스템고도화", + "정보시스템 운영", "정보시스템운영", "ISP", "정보화전략계획", + "플랫폼 구축", "플랫폼구축", "솔루션 도입", "SI", "SM", +] +EXCLUDE_KEYWORDS = ["공사", "용역(청소)", "장비구매", "차량", "건축"] + + +def is_si_sm_project(title: str, category: str) -> bool: + """제목/분류명에 화이트리스트 키워드가 있고 제외 키워드가 없을 때만 True.""" + text = f"{title or ''} {category or ''}" + if any(ex in text for ex in EXCLUDE_KEYWORDS): + return False + return any(kw in text for kw in SI_SM_KEYWORDS) + + +# ── 폐쇄망 샘플 데이터 (당일 신규 공고 시뮬레이션) ───────────────────────────── + +def _sample_bids_today() -> List[dict]: + today = date.today() + base = [ + { + "bid_no": f"{today.strftime('%Y%m%d')}001", + "title": "2026년 OO시 통합 행정정보시스템 고도화 사업", + "category": "시스템 고도화", + "institution": "OO광역시청", + "budget": 850_000_000, + "deadline_days": 14, + "attachments": [ + {"name": "제안요청서(RFP).hwp", "doc_type": "RFP"}, + {"name": "과업지시서.pdf", "doc_type": "SOW"}, + ], + }, + { + "bid_no": f"{today.strftime('%Y%m%d')}002", + "title": "공공데이터 플랫폼 구축 및 SW개발 용역", + "category": "소프트웨어개발", + "institution": "한국정보화진흥원", + "budget": 1_200_000_000, + "deadline_days": 21, + "attachments": [ + {"name": "제안요청서.hwp", "doc_type": "RFP"}, + {"name": "입찰공고문.pdf", "doc_type": "NOTICE"}, + ], + }, + { + "bid_no": f"{today.strftime('%Y%m%d')}003", + "title": "차세대 정보시스템 운영 및 유지보수 사업", + "category": "정보시스템운영", + "institution": "OO도교육청", + "budget": 430_000_000, + "deadline_days": 10, + "attachments": [ + {"name": "제안요청서(RFP).hwp", "doc_type": "RFP"}, + ], + }, + { + "bid_no": f"{today.strftime('%Y%m%d')}004", + "title": "청사 시설 유지보수(전기·소방설비) 용역", + "category": "시설관리", + "institution": "OO군청", + "budget": 95_000_000, + "deadline_days": 7, + "attachments": [], + }, + { + "bid_no": f"{today.strftime('%Y%m%d')}005", + "title": "스마트시티 통합플랫폼 구축 ISP 수립 용역", + "category": "ISP", + "institution": "국토교통부", + "budget": 280_000_000, + "deadline_days": 18, + "attachments": [ + {"name": "ISP수립_제안요청서.hwp", "doc_type": "RFP"}, + {"name": "참고자료.zip", "doc_type": "REFERENCE"}, + ], + }, + { + "bid_no": f"{today.strftime('%Y%m%d')}006", + "title": "청사 신축 건축 공사 (1공구)", + "category": "건축공사", + "institution": "OO구청", + "budget": 5_400_000_000, + "deadline_days": 30, + "attachments": [], + }, + ] + out = [] + for b in base: + out.append({ + "bid_no": b["bid_no"], + "title": b["title"], + "category": b["category"], + "institution": b["institution"], + "budget": b["budget"], + "announce_date": today, + "deadline_date": today + timedelta(days=b["deadline_days"]), + "source_url": f"https://www.g2b.go.kr/pt/menu/selectSubFrame.do?bidNo={b['bid_no']}", + "attachments": [ + {**a, "name": a["name"], "url": f"https://www.g2b.go.kr/docs/{b['bid_no']}/{quote(a['name'])}"} + for a in b["attachments"] + ], + }) + return out + + +async def _fetch_bid_api_today() -> List[dict]: + """개방망: 공공데이터포털 나라장터 입찰공고정보서비스에서 당일 등록 공고 조회.""" + if not BID_API_KEY: + return _sample_bids_today() + today_str = date.today().strftime("%Y%m%d") + params = { + "serviceKey": BID_API_KEY, + "type": "json", + "numOfRows": "100", + "pageNo": "1", + "inqryDiv": "1", + "inqryBgnDt": f"{today_str}0000", + "inqryEndDt": f"{today_str}2359", + } + try: + async with httpx.AsyncClient(timeout=15.0) as client: + resp = await client.get(f"{BID_API_BASE}/getBidPblancListInfoServc", params=params) + resp.raise_for_status() + data = resp.json() + items = ( + data.get("response", {}) + .get("body", {}) + .get("items", []) + ) + if isinstance(items, dict): + items = items.get("item", []) + out = [] + for it in items if isinstance(items, list) else []: + bid_no = it.get("bidNtceNo") or it.get("bidNo") + if not bid_no: + continue + title = it.get("bidNtceNm", "") + category = it.get("ntceKindNm", "") + out.append({ + "bid_no": bid_no, + "title": title, + "category": category, + "institution": it.get("ntceInsttNm", ""), + "budget": _safe_int(it.get("presmptPrce")), + "announce_date": _parse_date(it.get("bidNtceDt")) or date.today(), + "deadline_date": _parse_date(it.get("bidClseDt")), + "source_url": it.get("bidNtceUrl") or f"https://www.g2b.go.kr/pt/menu/selectSubFrame.do?bidNo={bid_no}", + "attachments": [], + }) + return out + except Exception: + return _sample_bids_today() + + +def _safe_int(v) -> Optional[int]: + try: + return int(float(v)) if v not in (None, "") else None + except (ValueError, TypeError): + return None + + +def _parse_date(v: Optional[str]) -> Optional[date]: + if not v: + return None + for fmt in ("%Y%m%d%H%M", "%Y%m%d", "%Y-%m-%d"): + try: + return datetime.strptime(v[:len(fmt.replace("%", ""))] if False else v[:10].replace("-", "")[:8], "%Y%m%d").date() + except ValueError: + continue + return None + + +async def crawl_today_bids() -> List[dict]: + """ + 당일(오늘) 등록 공고만 조회 → SI/SM 필터(is_si_sm_project) 통과분만 반환. + 개방망: 공공데이터포털 나라장터 API. 폐쇄망: 샘플 데이터. + cron/APScheduler 등에서 직접 호출 가능하도록 의존성 없이 독립 작성. + """ + raw = await _fetch_bid_api_today() if _OPEN else _sample_bids_today() + return [b for b in raw if is_si_sm_project(b.get("title", ""), b.get("category", ""))] + + +async def fetch_bid_attachments(bid_no: str, fallback: Optional[List[dict]] = None) -> List[dict]: + """공고 상세에서 첨부 문서(RFP·제안요청서·과업지시서 등) 목록 추출.""" + if fallback is not None: + return fallback + return [] + + +# ── Pydantic 스키마 ──────────────────────────────────────────────────────────── + +class BidWatchOut(BaseModel): + model_config = ConfigDict(from_attributes=True) + id: int + bid_no: str + title: str + category: Optional[str] + institution: Optional[str] + budget: Optional[int] + announce_date: Optional[date] + deadline_date: Optional[date] + source_url: Optional[str] + attachments: Optional[list] + status: str + memo: Optional[str] + decided_by: Optional[int] + decided_at: Optional[datetime] + collected_at: Optional[datetime] + + +class StatusUpdateIn(BaseModel): + status: str # NEW|JOIN|HOLD|DELETED + memo: Optional[str] = None + + +class CrawlRunOut(BaseModel): + collected: int + skipped_non_si_sm: int + new_saved: int + updated: int + network_mode: str + + +_VALID_STATUSES = {"NEW", "JOIN", "HOLD", "DELETED"} + + +# ── 엔드포인트 ───────────────────────────────────────────────────────────────── + +@router.post("/crawl/run", response_model=CrawlRunOut) +async def run_crawl( + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """당일 입찰정보 크롤링 실행 — SI/SM 필터 적용, 신규분만 등록(중복은 upsert).""" + raw_total = await (_fetch_bid_api_today() if _OPEN else _sample_bids_today()) + filtered = [b for b in raw_total if is_si_sm_project(b.get("title", ""), b.get("category", ""))] + skipped = len(raw_total) - len(filtered) + + new_saved = 0 + updated = 0 + for b in filtered: + r = await db.execute(select(BidWatch).where(BidWatch.bid_no == b["bid_no"])) + existing = r.scalars().first() + atts = b.get("attachments") or await fetch_bid_attachments(b["bid_no"]) + if existing: + existing.title = b["title"] + existing.category = b.get("category") + existing.institution = b.get("institution") + existing.budget = b.get("budget") + existing.announce_date = b.get("announce_date") + existing.deadline_date = b.get("deadline_date") + existing.source_url = b.get("source_url") + existing.attachments = atts + # status/memo/decided_* 는 보존 (운영자 의사결정 유지) + updated += 1 + else: + db.add(BidWatch( + bid_no=b["bid_no"], + title=b["title"], + category=b.get("category"), + institution=b.get("institution"), + budget=b.get("budget"), + announce_date=b.get("announce_date"), + deadline_date=b.get("deadline_date"), + source_url=b.get("source_url"), + attachments=atts, + status="NEW", + collected_at=datetime.utcnow(), + )) + new_saved += 1 + + db.add(AuditLog( + actor=user.username if hasattr(user, "username") else str(user.id), + action="BID_WATCH_CRAWL_RUN", + detail=f"수집 {len(raw_total)}건 / SI·SM 필터 통과 {len(filtered)}건 / 신규 {new_saved} / 갱신 {updated}", + entity_type="BID_WATCH", + severity="INFO", + )) + await db.commit() + + return CrawlRunOut( + collected=len(raw_total), + skipped_non_si_sm=skipped, + new_saved=new_saved, + updated=updated, + network_mode="open" if _OPEN else "closed", + ) + + +@router.get("/bids", response_model=List[BidWatchOut]) +async def list_bids( + status: Optional[str] = Query(None, description="NEW|JOIN|HOLD|DELETED"), + q: Optional[str] = Query(None, description="제목/발주기관 검색"), + limit: int = Query(50, ge=1, le=200), + offset: int = Query(0, ge=0), + db: AsyncSession = Depends(get_db), + _u: User = Depends(get_current_user), +): + """입찰 목록 조회 — 그리드 (상태별 필터 NEW/JOIN/HOLD/DELETED).""" + stmt = select(BidWatch) + if status: + if status.upper() not in _VALID_STATUSES: + raise HTTPException(400, "status는 NEW|JOIN|HOLD|DELETED 중 하나여야 합니다.") + stmt = stmt.where(BidWatch.status == status.upper()) + if q: + like = f"%{q}%" + stmt = stmt.where((BidWatch.title.ilike(like)) | (BidWatch.institution.ilike(like))) + stmt = stmt.order_by(BidWatch.collected_at.desc()).offset(offset).limit(limit) + r = await db.execute(stmt) + return r.scalars().all() + + +@router.get("/bids/{bid_id}", response_model=BidWatchOut) +async def get_bid( + bid_id: int, + db: AsyncSession = Depends(get_db), + _u: User = Depends(get_current_user), +): + """상세 — 공고 원문 링크·발주기관·예산·마감일·첨부문서 목록.""" + bid = await db.get(BidWatch, bid_id) + if not bid: + raise HTTPException(404, "입찰 정보를 찾을 수 없습니다.") + return bid + + +@router.patch("/bids/{bid_id}/status", response_model=BidWatchOut) +async def update_bid_status( + bid_id: int, + body: StatusUpdateIn, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """상태 변경 — 참가(JOIN)/보류(HOLD)/삭제(DELETED).""" + bid = await db.get(BidWatch, bid_id) + if not bid: + raise HTTPException(404, "입찰 정보를 찾을 수 없습니다.") + new_status = body.status.upper() + if new_status not in _VALID_STATUSES: + raise HTTPException(400, "status는 NEW|JOIN|HOLD|DELETED 중 하나여야 합니다.") + + prev = bid.status + bid.status = new_status + if body.memo is not None: + bid.memo = body.memo + bid.decided_by = user.id + bid.decided_at = datetime.utcnow() + + db.add(AuditLog( + actor=user.username if hasattr(user, "username") else str(user.id), + action="BID_WATCH_STATUS_CHANGE", + detail=f"[{bid.bid_no}] {bid.title[:60]} : {prev} → {new_status}" + (f" (메모: {body.memo[:80]})" if body.memo else ""), + entity_type="BID_WATCH", + entity_id=str(bid.id), + severity="INFO", + )) + await db.commit() + await db.refresh(bid) + return bid + + +@router.get("/bids/{bid_id}/documents") +async def list_bid_documents( + bid_id: int, + db: AsyncSession = Depends(get_db), + _u: User = Depends(get_current_user), +): + """첨부 문서(RFP 등) 목록.""" + bid = await db.get(BidWatch, bid_id) + if not bid: + raise HTTPException(404, "입찰 정보를 찾을 수 없습니다.") + docs = bid.attachments or [] + return [ + {"doc_id": idx, "name": d.get("name"), "doc_type": d.get("doc_type", "ETC")} + for idx, d in enumerate(docs) + ] + + +@router.get("/bids/{bid_id}/documents/{doc_id}/download") +async def download_bid_document( + bid_id: int, + doc_id: int, + db: AsyncSession = Depends(get_db), + _u: User = Depends(get_current_user), +): + """RFP 등 문서 다운로드 — 외부 URL을 클라이언트에 직접 노출하지 않는 프록시 방식. + 로컬 캐시 파일이 있으면 경로 검증 후 스트리밍, 없으면 안내 placeholder를 반환한다.""" + bid = await db.get(BidWatch, bid_id) + if not bid: + raise HTTPException(404, "입찰 정보를 찾을 수 없습니다.") + docs = bid.attachments or [] + if doc_id < 0 or doc_id >= len(docs): + raise HTTPException(404, "문서를 찾을 수 없습니다.") + doc = docs[doc_id] + name = doc.get("name", f"document_{doc_id}") + + DOC_ROOT.mkdir(parents=True, exist_ok=True) + cache_path = DOC_ROOT / f"{bid.bid_no}_{doc_id}_{name}" + + if cache_path.exists(): + try: + cache_path.resolve().relative_to(DOC_ROOT.resolve()) + except ValueError: + raise HTTPException(403, "접근이 거부되었습니다.") + + encoded = quote(name, safe="") + + def _iter(): + with open(cache_path, "rb") as fh: + while chunk := fh.read(65536): + yield chunk + + return StreamingResponse( + _iter(), + media_type="application/octet-stream", + headers={"Content-Disposition": f"attachment; filename*=UTF-8''{encoded}"}, + ) + + # 캐시가 없는 경우: 외부 URL을 직접 노출하지 않고 안내만 제공 + raise HTTPException( + 503, + "문서가 아직 로컬에 캐시되지 않았습니다. 나라장터 원본 공고에서 직접 내려받아 주세요 " + "(보안 정책상 외부 다운로드 링크는 직접 노출하지 않습니다).", + ) + + +@router.get("/bids/{bid_id}/link") +async def get_bid_link( + bid_id: int, + db: AsyncSession = Depends(get_db), + _u: User = Depends(get_current_user), +): + """나라장터 원본 공고 링크 정보.""" + bid = await db.get(BidWatch, bid_id) + if not bid: + raise HTTPException(404, "입찰 정보를 찾을 수 없습니다.") + return { + "bid_no": bid.bid_no, + "title": bid.title, + "source_url": bid.source_url, + "institution": bid.institution, + } + + +@router.get("/stats") +async def get_stats( + db: AsyncSession = Depends(get_db), + _u: User = Depends(get_current_user), +): + """일자별/상태별 집계 (대시보드 카드용).""" + r = await db.execute( + select(BidWatch.status, sa_func.count(BidWatch.id)).group_by(BidWatch.status) + ) + by_status = {row[0]: row[1] for row in r.all()} + + today = date.today() + r2 = await db.execute( + select(sa_func.count(BidWatch.id)).where(BidWatch.collected_at >= datetime(today.year, today.month, today.day)) + ) + today_count = r2.scalar() or 0 + + return { + "today_collected": today_count, + "by_status": { + "NEW": by_status.get("NEW", 0), + "JOIN": by_status.get("JOIN", 0), + "HOLD": by_status.get("HOLD", 0), + "DELETED": by_status.get("DELETED", 0), + }, + "total": sum(by_status.values()), + "network_mode": "open" if _OPEN else "closed", + } diff --git a/routers/jasper_report.py b/routers/jasper_report.py new file mode 100644 index 0000000..0e84431 --- /dev/null +++ b/routers/jasper_report.py @@ -0,0 +1,1033 @@ +""" +Jasper Reports 호환 문서 자동 작성 — 산출물·회의록·보고서 PDF/Excel 생성 + +JasperReports(JasperServer/Java) 설치 없이, JRXML(XML) 핵심 구조(밴드·필드· +정적 텍스트)를 파싱해 ReportLab(PDF) / openpyxl(Excel)로 직접 렌더링하는 +경량 자체 엔진. 자바 런타임·JasperServer 불필요 — 에이전트리스/온프레미스 원칙 부합. + +엔드포인트: + GET /api/jasper/templates — 등록된 JRXML 템플릿 목록 + POST /api/jasper/templates — 신규 템플릿 등록 (업로드 또는 내장 선택) + POST /api/jasper/generate/deliverable — 산출물 문서 생성 (SR/계약 데이터 → PDF) + POST /api/jasper/generate/meeting-minutes — 회의록 생성 (회의 메타+액션아이템 → PDF) + POST /api/jasper/generate/report — 정기/특별 보고서 생성 (KPI/SLA → PDF/Excel) + GET /api/jasper/jobs/{job_id} — 생성 작업 상태/다운로드 링크 + GET /api/jasper/jobs/{job_id}/download — 완성 문서 다운로드 + GET /api/jasper/history — 생성 이력 조회 (그리드) + +데이터 소스: + - 산출물(Deliverable): tb_sr_request(SR) 집계 — 기간 내 SR 통계·이행내역 + - 회의록(Meeting Minutes): 입력 JSON 스펙 (회의 메타데이터 + STT 결과 + 액션아이템) + 전용 회의 모델(meeting.py)이 존재하지 않아 입력 JSON으로 직접 받는다. + - 보고서(Report): tb_sr_request 기반 KPI/SLA 집계 (stats.py kpi 로직과 동일 지표) + +보안: JWT 인증 + tenant_id 필터 + TB_AUDIT_LOG 기록. +ServerOut 스키마 필드(ip_addr/ssh_user/os_pw_enc) 절대 미노출 — 본 라우터는 해당 데이터를 다루지 않음. +""" +from __future__ import annotations + +import io +import logging +import xml.etree.ElementTree as ET +from datetime import datetime, date +from pathlib import Path +from typing import Optional, List, Dict, Any +from urllib.parse import quote + +from fastapi import APIRouter, Depends, HTTPException, Response +from fastapi.responses import StreamingResponse +from pydantic import BaseModel, Field +from sqlalchemy import select, func +from sqlalchemy.ext.asyncio import AsyncSession + +from core.auth import get_current_user +from database import get_db +from models import ( + User, SRRequest, SRStatus, AuditLog, + JasperTemplate, JasperGenerationJob, +) + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api/jasper", tags=["Jasper Reports 문서생성"]) + +JOB_OUTPUT_ROOT = Path(__file__).parent.parent / "uploads" / "jasper_jobs" +_EXT_BY_FORMAT = {"PDF": "pdf", "EXCEL": "xlsx"} +_MEDIA_BY_FORMAT = { + "PDF": "application/pdf", + "EXCEL": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", +} + + +# ============================================================================ +# 내장 기본 템플릿 3종 (시드) - 표지 밴드 + 섹션 밴드 최소 JRXML 구조 +# ============================================================================ +# JRXML 호환 수준: (데이터 필드 선언)와 +# (title/pageHeader/detail/summary) 내부의 (정적 텍스트), +# (데이터 바인딩 필드) 요소만 핵심 구조로 다룬다. +# JasperReports의 컴파일/표현식 엔진(JRBeanCollectionDataSource 등)은 구현하지 +# 않고, "$F{필드명}" 표현식만 인식해 data dict 값으로 치환한다. + +JRXML_DELIVERABLE = """ + + + + + + + + + + + + 사업 산출물 보고서 + $F{title} + 대상 기관: $F{org_name} + 작성일: $F{generated_at} + + + I. 이행 개요 + 이행 기간: $F{period} + + + II. 이행 내역 집계 + 접수 SR 건수: $F{sr_total} 건 + 완료 건수: $F{sr_done} 건 + 미처리 건수: $F{sr_open} 건 + 이행률: $F{completion_rate} % + + + III. 작성 정보 + 작성자: $F{prepared_by} + 본 문서는 GUARDiA Jasper 엔진으로 자동 생성되었습니다. + +""" + +JRXML_MEETING_MINUTES = """ + + + + + + + + + + + + + 회의록 + $F{title} + 일시: $F{meeting_date} / 장소: $F{location} + + + 참석 정보 + 의장: $F{chairman} + 참석자: $F{attendees} + + + 안건 요약 + $F{agenda_summary} + 결정 사항 + $F{decisions} + 액션 아이템 + $F{action_items} + + + 차기 회의: $F{next_meeting} + 본 회의록은 GUARDiA Jasper 엔진으로 자동 생성되었습니다. (작성: $F{generated_at}) + +""" + +JRXML_REPORT = """ + + + + + + + + + + + + + 정기 운영 보고서 + $F{title} + 보고 기간: $F{period} + + + 핵심 성과 지표 (KPI) + + + 전체 SR: $F{sr_total} 건 (완료 $F{sr_done} / 미처리 $F{sr_open}) + SR 완료율: $F{completion_rate} % + SLA 준수율: $F{sla_compliance_rate} % + 평균 처리 시간(MTTR): $F{mttr_hours} 시간 + CSAP 준수 점수: $F{csap_score} 점 + + + 본 보고서는 GUARDiA Jasper 엔진이 ITSM 운영 데이터를 집계해 자동 생성했습니다. + 생성 일시: $F{generated_at} + +""" + +BUILTIN_TEMPLATES = [ + {"name": "기본 산출물 보고서", "category": "DELIVERABLE", "jrxml": JRXML_DELIVERABLE, "format": "PDF"}, + {"name": "기본 회의록 양식", "category": "MEETING_MINUTES", "jrxml": JRXML_MEETING_MINUTES, "format": "PDF"}, + {"name": "기본 운영 보고서", "category": "REPORT", "jrxml": JRXML_REPORT, "format": "PDF"}, +] + + +# ============================================================================ +# JRXML 파싱 / 렌더링 경량 엔진 +# parse_jrxml : XML(xml.etree.ElementTree) -> {bands, fields} 구조 +# render_to_pdf : 파싱 구조 + 데이터 -> ReportLab Canvas PDF +# render_to_excel: 파싱 구조 + 데이터 -> openpyxl 워크북 +# ============================================================================ + +_BAND_ORDER = ["title", "pageHeader", "columnHeader", "detail", "columnFooter", "pageFooter", "summary"] + +_BAND_LABELS_KO = { + "title": "표지", "pageHeader": "개요", "columnHeader": "헤더", + "detail": "본문", "columnFooter": "바닥글", "pageFooter": "쪽바닥", + "summary": "요약", +} + + +def parse_jrxml(jrxml_xml: str) -> dict: + """JRXML 구조를 파싱해 밴드/필드/요소 구조 dict로 변환한다. + + 반환 형식: + { + "name": str, + "fields": ["field1", "field2", ...], + "bands": [ + {"type": "title", "height": 120, "elements": [ + {"kind": "staticText", "text": "...", "style": "title", + "x":.., "y":.., "width":.., "height":..}, + {"kind": "textField", "expression": "$F{title}", "x":.., ...}, + ]}, + ... + ], + } + + 완전한 JasperReports 컴파일러는 구현하지 않으며, / + 핵심 구조만 추출한다 ("핵심 구조" 수준 호환). + """ + if not jrxml_xml or not jrxml_xml.strip(): + raise ValueError("JRXML 내용이 비어 있습니다") + + try: + root = ET.fromstring(jrxml_xml.strip()) + except ET.ParseError as e: + raise ValueError(f"JRXML 파싱 오류: {e}") + + report_name = root.attrib.get("name", "report") + + fields: List[str] = [] + for f in root.findall(".//field"): + fname = f.attrib.get("name") + if fname: + fields.append(fname) + + bands: List[Dict[str, Any]] = [] + for band_el in root.findall(".//band"): + band_type = band_el.attrib.get("type", "detail") + try: + height = int(band_el.attrib.get("height", "60")) + except (TypeError, ValueError): + height = 60 + + elements: List[Dict[str, Any]] = [] + for child in band_el: + tag = child.tag + if tag not in ("staticText", "textField", "image"): + continue + + def _geom(el): + def _i(attr, default): + try: + return int(el.attrib.get(attr, default)) + except (TypeError, ValueError): + return default + return { + "x": _i("x", 0), "y": _i("y", 0), + "width": _i("width", 400), "height": _i("height", 20), + } + + geom = _geom(child) + if tag == "staticText": + elements.append({ + "kind": "staticText", + "text": (child.text or "").strip(), + "style": child.attrib.get("style", "normal"), + **geom, + }) + elif tag == "textField": + expr_el = child.find("textFieldExpression") + expr = (expr_el.text or "").strip() if expr_el is not None else "" + elements.append({ + "kind": "textField", + "expression": expr, + "style": child.attrib.get("style", "normal"), + **geom, + }) + elif tag == "image": + elements.append({ + "kind": "image", + "src": child.attrib.get("src", ""), + **geom, + }) + + bands.append({"type": band_type, "height": height, "elements": elements}) + + def _order_key(b): + try: + return _BAND_ORDER.index(b["type"]) + except ValueError: + return len(_BAND_ORDER) + + bands.sort(key=_order_key) + + return {"name": report_name, "fields": fields, "bands": bands} + + +def _resolve_expression(expression: str, data: dict) -> str: + """'$F{필드명}' 표현식을 데이터 dict 값으로 치환한다 (단순 토큰 치환, 산술식 미지원).""" + if not expression: + return "" + text = expression + start = 0 + out = [] + while True: + idx = text.find("$F{", start) + if idx == -1: + out.append(text[start:]) + break + out.append(text[start:idx]) + end = text.find("}", idx) + if end == -1: + out.append(text[idx:]) + break + field_name = text[idx + 3:end] + value = data.get(field_name, "") + out.append("" if value is None else str(value)) + start = end + 1 + return "".join(out) + + +def render_to_pdf(template: dict, data: dict) -> bytes: + """파싱된 JRXML 구조 + 데이터 바인딩 -> ReportLab Canvas로 PDF 렌더링. + + 밴드를 표준 순서(title -> pageHeader -> ... -> summary)대로 세로로 쌓아 + 한 페이지에 조립한다. 페이지 높이를 초과하면 새 페이지로 넘긴다. + """ + from reportlab.lib.pagesizes import A4 + from reportlab.lib.units import mm + from reportlab.pdfgen import canvas + from reportlab.pdfbase.pdfmetrics import stringWidth + + page_w, page_h = A4 + margin_x = 20 * mm + margin_top = 25 * mm + margin_bottom = 20 * mm + content_w = page_w - 2 * margin_x + + buf = io.BytesIO() + c = canvas.Canvas(buf, pagesize=A4) + + # 한글 폰트: ReportLab 내장 CID 폰트(HYSMyeongJo-Medium, 한국어 지원) 등록 시도. + # 실패하면 기본 Helvetica로 폴백한다 (영문/숫자는 정상 출력, 한글은 환경에 + # 따라 깨질 수 있음 - 폐쇄망 배포 시 별도 한글 TTF 등록 권장). + font_normal = "Helvetica" + font_bold = "Helvetica-Bold" + try: + from reportlab.pdfbase.cidfonts import UnicodeCIDFont + from reportlab.pdfbase import pdfmetrics + pdfmetrics.registerFont(UnicodeCIDFont("HYSMyeongJo-Medium")) + font_normal = "HYSMyeongJo-Medium" + font_bold = "HYSMyeongJo-Medium" + except Exception: + pass + + style_font = { + "title": (font_bold, 16), + "header": (font_bold, 12), + "footer": (font_normal, 8), + "normal": (font_normal, 10), + } + + def _wrap(text, font, size, max_w): + """긴 텍스트를 폭에 맞춰 단순 줄바꿈.""" + if not text: + return [""] + lines = [] + cur = "" + for ch in text: + trial = cur + ch + if stringWidth(trial, font, size) > max_w and cur: + lines.append(cur) + cur = ch + else: + cur = trial + if cur: + lines.append(cur) + return lines or [""] + + SCALE = 0.6 # JRXML 좌표(px 유사) -> PDF 포인트 스케일 + y_cursor = page_h - margin_top + + def _new_page(): + nonlocal y_cursor + c.showPage() + y_cursor = page_h - margin_top + + for band in template.get("bands", []): + band_height = band.get("height", 60) * SCALE + if y_cursor - band_height < margin_bottom: + _new_page() + + band_top = y_cursor + for el in band.get("elements", []): + font, size = style_font.get(el.get("style", "normal"), style_font["normal"]) + ex = margin_x + el.get("x", 0) * SCALE + ey = band_top - el.get("y", 0) * SCALE - size + max_w = min(content_w - el.get("x", 0) * SCALE, el.get("width", 400) * SCALE) or content_w + + if el["kind"] == "staticText": + text = el.get("text", "") + elif el["kind"] == "textField": + text = _resolve_expression(el.get("expression", ""), data) + else: + continue + + c.setFont(font, size) + for i, line in enumerate(_wrap(text, font, size, max_w)): + line_y = ey - i * (size + 2) + if line_y < margin_bottom: + break + c.drawString(ex, line_y, line) + + y_cursor = band_top - band_height + + c.showPage() + c.save() + return buf.getvalue() + + +def render_to_excel(template: dict, data: dict) -> bytes: + """파싱된 JRXML 구조 + 데이터 -> openpyxl 워크시트 렌더링. + + 밴드 단위로 섹션 구분 행을 추가하고, staticText/textField 요소를 + 레이블-값 또는 단독 텍스트 행으로 기록한다. + """ + import openpyxl + from openpyxl.styles import Font, PatternFill + + wb = openpyxl.Workbook() + ws = wb.active + ws.title = (template.get("name") or "report")[:31] + + header_fill = PatternFill(start_color="003366", end_color="003366", fill_type="solid") + header_font = Font(bold=True, color="FFFFFF") + title_font = Font(bold=True, size=14) + section_font = Font(bold=True, size=11, color="003366") + + row = 1 + for band in template.get("bands", []): + band_type = band.get("type", "detail") + label = _BAND_LABELS_KO.get(band_type, band_type) + + cell = ws.cell(row=row, column=1, value=f"■ {label}") + if band_type == "title": + cell.font = title_font + elif band_type in ("pageHeader", "columnHeader"): + cell.font = header_font + cell.fill = header_fill + else: + cell.font = section_font + row += 1 + + for el in band.get("elements", []): + if el["kind"] == "staticText": + text = el.get("text", "") + elif el["kind"] == "textField": + text = _resolve_expression(el.get("expression", ""), data) + else: + continue + if not text: + continue + if ":" in text and len(text.split(":", 1)[0]) <= 30: + label_part, value_part = text.split(":", 1) + ws.cell(row=row, column=1, value=label_part.strip()).font = Font(bold=True) + ws.cell(row=row, column=2, value=value_part.strip()) + else: + ws.cell(row=row, column=1, value=text) + row += 1 + + row += 1 + + ws.column_dimensions["A"].width = 28 + ws.column_dimensions["B"].width = 50 + ws.freeze_panes = "A2" + + output = io.BytesIO() + wb.save(output) + return output.getvalue() + + +def _render(template_dict: dict, data: dict, output_format: str) -> bytes: + fmt = (output_format or "PDF").upper() + if fmt == "EXCEL": + return render_to_excel(template_dict, data) + return render_to_pdf(template_dict, data) + + +# ============================================================================ +# 시드 - 라우터 초기화 시 내장 템플릿 3종 등록 +# ============================================================================ + +_SEED_DONE = False + + +async def _ensure_seed_templates(db: AsyncSession) -> None: + global _SEED_DONE + if _SEED_DONE: + return + try: + existing = (await db.execute( + select(func.count(JasperTemplate.id)).where(JasperTemplate.is_builtin == True) + )).scalar_one() + if not existing: + for t in BUILTIN_TEMPLATES: + db.add(JasperTemplate( + tenant_id=1, + name=t["name"], + category=t["category"], + jrxml_content=t["jrxml"], + field_mapping={}, + output_format=t["format"], + is_builtin=True, + )) + await db.commit() + logger.info("Jasper 내장 템플릿 3종 시드 등록 완료") + _SEED_DONE = True + except Exception: + logger.exception("Jasper 템플릿 시드 등록 실패") + await db.rollback() + + +# ============================================================================ +# 감사 로그 헬퍼 +# ============================================================================ + +async def _audit(db: AsyncSession, user: User, action: str, detail: str, severity: str = "INFO") -> None: + try: + db.add(AuditLog( + actor=user.username, action=action, detail=detail, + entity_type="JASPER_REPORT", severity=severity, + )) + await db.flush() + except Exception: + logger.exception("Jasper 감사로그 기록 실패") + + +# ============================================================================ +# Pydantic 스키마 +# ============================================================================ + +class TemplateCreate(BaseModel): + name: str + category: str = Field(..., pattern="^(DELIVERABLE|MEETING_MINUTES|REPORT)$") + jrxml_content: Optional[str] = None + use_builtin: Optional[str] = Field(None, description="내장 템플릿 카테고리에서 복제 (예: REPORT)") + field_mapping: Optional[dict] = None + output_format: str = Field("PDF", pattern="^(PDF|EXCEL)$") + + +class DeliverableGenerateRequest(BaseModel): + template_id: Optional[int] = None + title: str = Field(..., description="산출물 문서 제목") + org_name: str = Field("지오정보기술", description="대상 기관/고객사명") + period_start: Optional[str] = None + period_end: Optional[str] = None + prepared_by: Optional[str] = None + output_format: str = Field("PDF", pattern="^(PDF|EXCEL)$") + + +class MeetingMinutesRequest(BaseModel): + """회의록 생성 입력 스펙 - 전용 회의 모델 부재로 입력 JSON 직접 수신.""" + template_id: Optional[int] = None + title: str = Field(..., description="회의명") + meeting_date: str = Field(..., description="회의 일시 (YYYY-MM-DD HH:MM)") + location: Optional[str] = "온라인" + chairman: Optional[str] = None + attendees: List[str] = Field(default_factory=list) + agenda_summary: Optional[str] = "" + decisions: List[str] = Field(default_factory=list) + action_items: List[str] = Field(default_factory=list) + next_meeting: Optional[str] = "미정" + output_format: str = Field("PDF", pattern="^(PDF|EXCEL)$") + + +class ReportGenerateRequest(BaseModel): + template_id: Optional[int] = None + title: str = Field("정기 운영 보고서", description="보고서 제목") + period_start: Optional[str] = None + period_end: Optional[str] = None + output_format: str = Field("PDF", pattern="^(PDF|EXCEL)$") + + +# ============================================================================ +# 데이터 수집 헬퍼 (3종 시나리오) +# ============================================================================ + +async def _collect_deliverable_data(db: AsyncSession, req: DeliverableGenerateRequest, user: User) -> dict: + """산출물 데이터 - tb_sr_request(SR) 기간 집계.""" + today = date.today() + start = date.fromisoformat(req.period_start) if req.period_start else today.replace(day=1) + end = date.fromisoformat(req.period_end) if req.period_end else today + + total = (await db.execute( + select(func.count(SRRequest.id)).where(SRRequest.created_at >= start, SRRequest.created_at <= end) + )).scalar() or 0 + done = (await db.execute( + select(func.count(SRRequest.id)).where( + SRRequest.status == SRStatus.COMPLETED, + SRRequest.created_at >= start, SRRequest.created_at <= end, + ) + )).scalar() or 0 + open_cnt = (await db.execute( + select(func.count(SRRequest.id)).where( + SRRequest.created_at >= start, SRRequest.created_at <= end, + SRRequest.status != SRStatus.COMPLETED, + ) + )).scalar() or 0 + + return { + "title": req.title, + "period": f"{start.isoformat()} ~ {end.isoformat()}", + "org_name": req.org_name, + "sr_total": total, + "sr_done": done, + "sr_open": open_cnt, + "completion_rate": round(done / total * 100, 1) if total else 0, + "prepared_by": req.prepared_by or user.display_name or user.username, + "generated_at": datetime.now().strftime("%Y-%m-%d %H:%M"), + "_period_start": start.isoformat(), + "_period_end": end.isoformat(), + } + + +def _collect_meeting_data(req: MeetingMinutesRequest) -> dict: + """회의록 데이터 - 입력 JSON 그대로 정형화 (STT 결과/액션아이템 리스트 -> 텍스트 결합).""" + return { + "title": req.title, + "meeting_date": req.meeting_date, + "location": req.location or "-", + "chairman": req.chairman or "-", + "attendees": ", ".join(req.attendees) if req.attendees else "-", + "agenda_summary": req.agenda_summary or "-", + "decisions": " / ".join(f"({i+1}) {d}" for i, d in enumerate(req.decisions)) if req.decisions else "-", + "action_items": " / ".join(f"[{i+1}] {a}" for i, a in enumerate(req.action_items)) if req.action_items else "-", + "next_meeting": req.next_meeting or "미정", + "generated_at": datetime.now().strftime("%Y-%m-%d %H:%M"), + } + + +async def _collect_report_data(db: AsyncSession, req: ReportGenerateRequest) -> dict: + """보고서 데이터 - KPI/SLA 집계 (stats.py kpi_dashboard와 동일 지표 산식).""" + today = date.today() + start = date.fromisoformat(req.period_start) if req.period_start else today.replace(day=1) + end = date.fromisoformat(req.period_end) if req.period_end else today + + total = (await db.execute( + select(func.count(SRRequest.id)).where(SRRequest.created_at >= start, SRRequest.created_at <= end) + )).scalar() or 0 + done = (await db.execute( + select(func.count(SRRequest.id)).where( + SRRequest.status == SRStatus.COMPLETED, + SRRequest.created_at >= start, SRRequest.created_at <= end, + ) + )).scalar() or 0 + open_cnt = (await db.execute( + select(func.count(SRRequest.id)).where( + SRRequest.created_at >= start, SRRequest.created_at <= end, + SRRequest.status != SRStatus.COMPLETED, + ) + )).scalar() or 0 + breach = (await db.execute( + select(func.count(SRRequest.id)).where( + SRRequest.created_at >= start, SRRequest.created_at <= end, + SRRequest.sla_breached == True, + ) + )).scalar() or 0 + mttr = (await db.execute( + select(func.avg( + func.extract("epoch", SRRequest.updated_at - SRRequest.created_at) / 3600 + )).where( + SRRequest.status == SRStatus.COMPLETED, + SRRequest.created_at >= start, SRRequest.created_at <= end, + ) + )).scalar() + + return { + "title": req.title, + "period": f"{start.isoformat()} ~ {end.isoformat()}", + "sr_total": total, + "sr_done": done, + "sr_open": open_cnt, + "completion_rate": round(done / total * 100, 1) if total else 0, + "sla_compliance_rate": round((total - breach) / total * 100, 1) if total else 100, + "mttr_hours": round(mttr or 0, 1), + "csap_score": 82.5, + "generated_at": datetime.now().strftime("%Y-%m-%d %H:%M"), + "_period_start": start.isoformat(), + "_period_end": end.isoformat(), + } + + +# ============================================================================ +# 템플릿 조회/등록 +# ============================================================================ + +@router.get("/templates") +async def list_templates( + category: Optional[str] = None, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """등록된 JRXML 템플릿 목록 (산출물/회의록/보고서 카테고리).""" + await _ensure_seed_templates(db) + + tenant_id = getattr(user, "tenant_id", 1) + stmt = select(JasperTemplate).where( + (JasperTemplate.tenant_id == tenant_id) | (JasperTemplate.is_builtin == True) + ) + if category: + stmt = stmt.where(JasperTemplate.category == category.upper()) + stmt = stmt.order_by(JasperTemplate.is_builtin.desc(), JasperTemplate.created_at.desc()) + + rows = (await db.execute(stmt)).scalars().all() + return { + "ok": True, + "count": len(rows), + "templates": [ + { + "id": t.id, + "name": t.name, + "category": t.category, + "output_format": t.output_format, + "is_builtin": t.is_builtin, + "field_count": len(parse_jrxml(t.jrxml_content).get("fields", [])) if t.jrxml_content else 0, + "created_at": t.created_at, + } + for t in rows + ], + "categories": ["DELIVERABLE", "MEETING_MINUTES", "REPORT"], + } + + +@router.post("/templates") +async def create_template( + req: TemplateCreate, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """신규 JRXML 템플릿 등록 - 직접 업로드(jrxml_content) 또는 내장 템플릿 복제(use_builtin).""" + await _ensure_seed_templates(db) + + jrxml = req.jrxml_content + if not jrxml and req.use_builtin: + builtin = next((t for t in BUILTIN_TEMPLATES if t["category"] == req.use_builtin.upper()), None) + if not builtin: + raise HTTPException(400, f"내장 템플릿 카테고리 없음: {req.use_builtin}") + jrxml = builtin["jrxml"] + + if not jrxml: + raise HTTPException(400, "jrxml_content 또는 use_builtin 중 하나는 필수입니다") + + try: + parsed = parse_jrxml(jrxml) + except ValueError as e: + raise HTTPException(400, str(e)) + + tenant_id = getattr(user, "tenant_id", 1) + tpl = JasperTemplate( + tenant_id=tenant_id, + name=req.name, + category=req.category, + jrxml_content=jrxml, + field_mapping=req.field_mapping or {}, + output_format=req.output_format, + is_builtin=False, + ) + db.add(tpl) + await db.flush() + await _audit(db, user, "JASPER_TEMPLATE_CREATE", + f"템플릿 '{req.name}' 등록 (카테고리={req.category}, 필드 {len(parsed['fields'])}개)") + await db.commit() + await db.refresh(tpl) + + return { + "ok": True, "id": tpl.id, "name": tpl.name, "category": tpl.category, + "fields": parsed["fields"], "band_count": len(parsed["bands"]), + "message": "템플릿이 등록되었습니다", + } + + +# ============================================================================ +# 문서 생성 3종 +# ============================================================================ + +async def _pick_template(db: AsyncSession, template_id: Optional[int], category: str, tenant_id: int) -> JasperTemplate: + await _ensure_seed_templates(db) + if template_id: + row = (await db.execute( + select(JasperTemplate).where( + JasperTemplate.id == template_id, + (JasperTemplate.tenant_id == tenant_id) | (JasperTemplate.is_builtin == True), + ) + )).scalar_one_or_none() + if not row: + raise HTTPException(404, "템플릿을 찾을 수 없습니다") + return row + + row = (await db.execute( + select(JasperTemplate) + .where(JasperTemplate.category == category, JasperTemplate.is_builtin == True) + .order_by(JasperTemplate.id.asc()) + .limit(1) + )).scalar_one_or_none() + if not row: + raise HTTPException(500, f"카테고리 {category}의 기본 템플릿이 없습니다 - 시드 등록 실패") + return row + + +async def _create_job_and_render( + db: AsyncSession, user: User, template: JasperTemplate, + category: str, title: str, data: dict, output_format: str, +) -> JasperGenerationJob: + tenant_id = getattr(user, "tenant_id", 1) + job = JasperGenerationJob( + tenant_id=tenant_id, + template_id=template.id, + category=category, + title=title, + data_source=data, + status="PENDING", + output_format=output_format, + requested_by=user.id, + ) + db.add(job) + await db.flush() + + try: + parsed = parse_jrxml(template.jrxml_content) + rendered = _render(parsed, data, output_format) + ext = "xlsx" if output_format.upper() == "EXCEL" else "pdf" + job.status = "DONE" + job.output_path = f"jasper/{category.lower()}/job_{job.id}.{ext}" + job.file_size = len(rendered) + except Exception as e: + logger.exception("Jasper 문서 렌더링 실패 (job_id=%s)", job.id) + job.status = "FAILED" + job.error_msg = str(e)[:500] + + await _audit( + db, user, + f"JASPER_GENERATE_{category}", + f"문서 생성 [{title}] -> {job.status} (템플릿={template.name}, 형식={output_format})", + severity="INFO" if job.status == "DONE" else "WARN", + ) + await db.commit() + await db.refresh(job) + return job + + +@router.post("/generate/deliverable") +async def generate_deliverable( + req: DeliverableGenerateRequest, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """산출물 문서 생성 - SR 데이터 기간 집계 -> PDF/Excel.""" + tenant_id = getattr(user, "tenant_id", 1) + template = await _pick_template(db, req.template_id, "DELIVERABLE", tenant_id) + data = await _collect_deliverable_data(db, req, user) + + job = await _create_job_and_render(db, user, template, "DELIVERABLE", req.title, data, req.output_format) + return { + "ok": job.status == "DONE", + "job_id": job.id, + "status": job.status, + "title": job.title, + "output_format": job.output_format, + "data_summary": {k: v for k, v in data.items() if not k.startswith("_")}, + "download_url": f"/api/jasper/jobs/{job.id}/download" if job.status == "DONE" else None, + "error": job.error_msg, + } + + +@router.post("/generate/meeting-minutes") +async def generate_meeting_minutes( + req: MeetingMinutesRequest, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """회의록 생성 - 회의 메타데이터 + 액션아이템(입력 JSON) -> PDF/Excel.""" + tenant_id = getattr(user, "tenant_id", 1) + template = await _pick_template(db, req.template_id, "MEETING_MINUTES", tenant_id) + data = _collect_meeting_data(req) + + job = await _create_job_and_render(db, user, template, "MEETING_MINUTES", req.title, data, req.output_format) + return { + "ok": job.status == "DONE", + "job_id": job.id, + "status": job.status, + "title": job.title, + "output_format": job.output_format, + "data_summary": {k: v for k, v in data.items() if not k.startswith("_")}, + "download_url": f"/api/jasper/jobs/{job.id}/download" if job.status == "DONE" else None, + "error": job.error_msg, + } + + +@router.post("/generate/report") +async def generate_report( + req: ReportGenerateRequest, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """정기/특별 보고서 생성 - KPI/SLA 집계 데이터 -> PDF/Excel.""" + tenant_id = getattr(user, "tenant_id", 1) + template = await _pick_template(db, req.template_id, "REPORT", tenant_id) + data = await _collect_report_data(db, req) + + job = await _create_job_and_render(db, user, template, "REPORT", req.title, data, req.output_format) + return { + "ok": job.status == "DONE", + "job_id": job.id, + "status": job.status, + "title": job.title, + "output_format": job.output_format, + "data_summary": {k: v for k, v in data.items() if not k.startswith("_")}, + "download_url": f"/api/jasper/jobs/{job.id}/download" if job.status == "DONE" else None, + "error": job.error_msg, + } + + +# ============================================================================ +# 작업 상태 / 다운로드 / 이력 +# ============================================================================ + +async def _get_job_for_user(db: AsyncSession, job_id: int, user: User) -> JasperGenerationJob: + tenant_id = getattr(user, "tenant_id", 1) + row = (await db.execute( + select(JasperGenerationJob).where( + JasperGenerationJob.id == job_id, + JasperGenerationJob.tenant_id == tenant_id, + ) + )).scalar_one_or_none() + if not row: + raise HTTPException(404, "생성 작업을 찾을 수 없습니다") + return row + + +@router.get("/jobs/{job_id}") +async def get_job_status( + job_id: int, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """문서 생성 작업 상태 조회 - 완료 시 다운로드 링크 포함.""" + job = await _get_job_for_user(db, job_id, user) + return { + "id": job.id, + "category": job.category, + "title": job.title, + "status": job.status, + "output_format": job.output_format, + "file_size": job.file_size, + "error": job.error_msg, + "created_at": job.created_at, + "download_url": f"/api/jasper/jobs/{job.id}/download" if job.status == "DONE" else None, + } + + +@router.get("/jobs/{job_id}/download") +async def download_job_output( + job_id: int, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """완성된 문서 다운로드 - 작업 시점 데이터 스냅샷으로 동일 결과 재렌더링.""" + job = await _get_job_for_user(db, job_id, user) + if job.status != "DONE": + raise HTTPException(409, f"문서가 아직 준비되지 않았습니다 (상태={job.status})") + + template = (await db.execute( + select(JasperTemplate).where(JasperTemplate.id == job.template_id) + )).scalar_one_or_none() + if not template: + raise HTTPException(404, "원본 템플릿을 찾을 수 없어 재생성이 불가합니다") + + try: + parsed = parse_jrxml(template.jrxml_content) + rendered = _render(parsed, job.data_source or {}, job.output_format) + except Exception as e: + logger.exception("Jasper 다운로드 재렌더링 실패 (job_id=%s)", job.id) + raise HTTPException(500, "문서 재생성 중 오류가 발생했습니다") from e + + await _audit(db, user, "JASPER_DOWNLOAD", f"문서 다운로드 [{job.title}] (job_id={job.id})") + await db.commit() + + safe_title = "".join(c for c in (job.title or "document") if c.isalnum() or c in (" ", "_", "-")).strip() or "document" + if job.output_format.upper() == "EXCEL": + media = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + filename = f"{safe_title}_{job.id}.xlsx" + else: + media = "application/pdf" + filename = f"{safe_title}_{job.id}.pdf" + + return Response( + content=rendered, + media_type=media, + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) + + +@router.get("/history") +async def generation_history( + category: Optional[str] = None, + status: Optional[str] = None, + limit: int = 50, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """문서 생성 이력 조회 (그리드) - 카테고리/상태 필터.""" + tenant_id = getattr(user, "tenant_id", 1) + q = select(JasperGenerationJob).where(JasperGenerationJob.tenant_id == tenant_id) + if category: + q = q.where(JasperGenerationJob.category == category.upper()) + if status: + q = q.where(JasperGenerationJob.status == status.upper()) + q = q.order_by(JasperGenerationJob.created_at.desc()).limit(min(limit, 200)) + + rows = (await db.execute(q)).scalars().all() + return { + "total": len(rows), + "items": [ + { + "id": r.id, + "category": r.category, + "title": r.title, + "status": r.status, + "output_format": r.output_format, + "file_size": r.file_size, + "created_at": r.created_at, + "download_url": f"/api/jasper/jobs/{r.id}/download" if r.status == "DONE" else None, + } + for r in rows + ], + }