manual-deploy 2026-06-07 19:13
13
models.py
@ -7332,3 +7332,16 @@ class BidWatch(Base):
|
||||
decided_by = Column(Integer, nullable=True)
|
||||
decided_at = Column(DateTime, nullable=True)
|
||||
collected_at = Column(DateTime, default=func.now())
|
||||
|
||||
|
||||
class BidWatchAssignee(Base):
|
||||
"""입찰워처 알림 담당자 — 신규 SI/SM 입찰 수집 시 메일 발송 대상 (관리시스템에서 지정·관리)."""
|
||||
__tablename__ = "tb_bid_watch_assignee"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
tenant_code = Column(String(50), index=True)
|
||||
name = Column(String(100))
|
||||
email = Column(String(200))
|
||||
active = Column(Boolean, default=True)
|
||||
created_by = Column(Integer, nullable=True)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
|
||||
@ -23,7 +23,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from core.auth import get_current_user
|
||||
from database import get_db
|
||||
from models import User, BidWatch, AuditLog
|
||||
from models import User, BidWatch, BidWatchAssignee, AuditLog
|
||||
|
||||
router = APIRouter(prefix="/api/bid-watcher", tags=["나라장터 입찰워처"])
|
||||
|
||||
@ -270,6 +270,26 @@ class CrawlRunOut(BaseModel):
|
||||
_VALID_STATUSES = {"NEW", "JOIN", "HOLD", "DELETED"}
|
||||
|
||||
|
||||
class AssigneeOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: int
|
||||
name: str
|
||||
email: str
|
||||
active: bool
|
||||
created_at: Optional[datetime]
|
||||
|
||||
|
||||
class AssigneeCreateIn(BaseModel):
|
||||
name: str
|
||||
email: str
|
||||
|
||||
|
||||
class AssigneeUpdateIn(BaseModel):
|
||||
name: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
active: Optional[bool] = None
|
||||
|
||||
|
||||
# ── 엔드포인트 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/crawl/run", response_model=CrawlRunOut)
|
||||
@ -284,6 +304,7 @@ async def run_crawl(
|
||||
|
||||
new_saved = 0
|
||||
updated = 0
|
||||
newly_collected: List[BidWatch] = []
|
||||
for b in filtered:
|
||||
r = await db.execute(select(BidWatch).where(BidWatch.bid_no == b["bid_no"]))
|
||||
existing = r.scalars().first()
|
||||
@ -300,7 +321,7 @@ async def run_crawl(
|
||||
# status/memo/decided_* 는 보존 (운영자 의사결정 유지)
|
||||
updated += 1
|
||||
else:
|
||||
db.add(BidWatch(
|
||||
new_bid = BidWatch(
|
||||
bid_no=b["bid_no"],
|
||||
title=b["title"],
|
||||
category=b.get("category"),
|
||||
@ -312,7 +333,9 @@ async def run_crawl(
|
||||
attachments=atts,
|
||||
status="NEW",
|
||||
collected_at=datetime.utcnow(),
|
||||
))
|
||||
)
|
||||
db.add(new_bid)
|
||||
newly_collected.append(new_bid)
|
||||
new_saved += 1
|
||||
|
||||
db.add(AuditLog(
|
||||
@ -324,6 +347,9 @@ async def run_crawl(
|
||||
))
|
||||
await db.commit()
|
||||
|
||||
if newly_collected:
|
||||
await _notify_new_bids(db, user, newly_collected)
|
||||
|
||||
return CrawlRunOut(
|
||||
collected=len(raw_total),
|
||||
skipped_non_si_sm=skipped,
|
||||
@ -515,3 +541,150 @@ async def get_stats(
|
||||
"total": sum(by_status.values()),
|
||||
"network_mode": "open" if _OPEN else "closed",
|
||||
}
|
||||
|
||||
|
||||
# ── 알림 담당자 관리 (관리시스템에서 지정) ──────────────────────────────────────
|
||||
|
||||
@router.get("/assignees", response_model=List[AssigneeOut])
|
||||
async def list_assignees(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""신규 SI/SM 입찰 수집 시 메일을 받을 담당자 목록 (현재 기관 소속만)."""
|
||||
r = await db.execute(
|
||||
select(BidWatchAssignee)
|
||||
.where(BidWatchAssignee.tenant_code == _tenant(user))
|
||||
.order_by(BidWatchAssignee.id)
|
||||
)
|
||||
return r.scalars().all()
|
||||
|
||||
|
||||
@router.post("/assignees", response_model=AssigneeOut)
|
||||
async def create_assignee(
|
||||
body: AssigneeCreateIn,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""알림 담당자 등록 — 관리시스템(Manager)에서 호출."""
|
||||
assignee = BidWatchAssignee(
|
||||
tenant_code=_tenant(user),
|
||||
name=body.name,
|
||||
email=body.email,
|
||||
active=True,
|
||||
created_by=user.id,
|
||||
)
|
||||
db.add(assignee)
|
||||
db.add(AuditLog(
|
||||
actor=user.username if hasattr(user, "username") else str(user.id),
|
||||
action="BID_WATCH_ASSIGNEE_CREATE",
|
||||
detail=f"입찰워처 알림 담당자 등록: {body.name} <{body.email}>",
|
||||
entity_type="BID_WATCH",
|
||||
severity="INFO",
|
||||
))
|
||||
await db.commit()
|
||||
await db.refresh(assignee)
|
||||
return assignee
|
||||
|
||||
|
||||
async def _get_assignee_for_user(db: AsyncSession, assignee_id: int, user: User) -> BidWatchAssignee:
|
||||
assignee = await db.get(BidWatchAssignee, assignee_id)
|
||||
if not assignee or assignee.tenant_code != _tenant(user):
|
||||
raise HTTPException(404, "담당자를 찾을 수 없습니다.")
|
||||
return assignee
|
||||
|
||||
|
||||
@router.patch("/assignees/{assignee_id}", response_model=AssigneeOut)
|
||||
async def update_assignee(
|
||||
assignee_id: int,
|
||||
body: AssigneeUpdateIn,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""알림 담당자 수정 (이름/이메일/활성 여부)."""
|
||||
assignee = await _get_assignee_for_user(db, assignee_id, user)
|
||||
if body.name is not None:
|
||||
assignee.name = body.name
|
||||
if body.email is not None:
|
||||
assignee.email = body.email
|
||||
if body.active is not None:
|
||||
assignee.active = body.active
|
||||
|
||||
db.add(AuditLog(
|
||||
actor=user.username if hasattr(user, "username") else str(user.id),
|
||||
action="BID_WATCH_ASSIGNEE_UPDATE",
|
||||
detail=f"입찰워처 알림 담당자 수정: {assignee.name} <{assignee.email}> (활성={assignee.active})",
|
||||
entity_type="BID_WATCH",
|
||||
severity="INFO",
|
||||
))
|
||||
await db.commit()
|
||||
await db.refresh(assignee)
|
||||
return assignee
|
||||
|
||||
|
||||
@router.delete("/assignees/{assignee_id}")
|
||||
async def delete_assignee(
|
||||
assignee_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""알림 담당자 삭제."""
|
||||
assignee = await _get_assignee_for_user(db, assignee_id, user)
|
||||
name, email = assignee.name, assignee.email
|
||||
await db.delete(assignee)
|
||||
db.add(AuditLog(
|
||||
actor=user.username if hasattr(user, "username") else str(user.id),
|
||||
action="BID_WATCH_ASSIGNEE_DELETE",
|
||||
detail=f"입찰워처 알림 담당자 삭제: {name} <{email}>",
|
||||
entity_type="BID_WATCH",
|
||||
severity="WARNING",
|
||||
))
|
||||
await db.commit()
|
||||
return {"deleted": True, "id": assignee_id}
|
||||
|
||||
|
||||
async def _notify_new_bids(db: AsyncSession, user: User, new_bids: List[BidWatch]) -> None:
|
||||
"""신규 SI/SM 입찰 수집 시 활성 담당자 전원에게 메일 발송 (실패해도 크롤링 결과에는 영향 없음)."""
|
||||
tenant = _tenant(user)
|
||||
r = await db.execute(
|
||||
select(BidWatchAssignee).where(
|
||||
BidWatchAssignee.tenant_code == tenant,
|
||||
BidWatchAssignee.active == True, # noqa: E712
|
||||
)
|
||||
)
|
||||
assignees = r.scalars().all()
|
||||
to_addrs = [a.email for a in assignees if a.email]
|
||||
if not to_addrs:
|
||||
return
|
||||
|
||||
from core.notify import send_email
|
||||
|
||||
rows_html = "".join(
|
||||
f"<tr><td>{b.bid_no}</td><td>{b.title}</td><td>{b.institution or '-'}</td>"
|
||||
f"<td>{b.budget or '-'}</td>"
|
||||
f"<td>{b.deadline_date.isoformat() if b.deadline_date else '-'}</td></tr>"
|
||||
for b in new_bids
|
||||
)
|
||||
html_body = (
|
||||
f"<p>오늘 신규로 수집된 SI/SM 입찰공고 {len(new_bids)}건이 있습니다.</p>"
|
||||
f"<table border='1' cellpadding='6' cellspacing='0' style='border-collapse:collapse'>"
|
||||
f"<tr><th>공고번호</th><th>공고명</th><th>발주기관</th><th>예산</th><th>마감일</th></tr>"
|
||||
f"{rows_html}"
|
||||
f"</table>"
|
||||
f"<p>GUARDiA 관리시스템 > 입찰워처에서 참가/보류/삭제 의사결정과 RFP 등 첨부문서 확인을 진행해주세요.</p>"
|
||||
)
|
||||
ok, err = await send_email(
|
||||
to_addrs=to_addrs,
|
||||
subject=f"[GUARDiA 입찰워처] 신규 SI/SM 입찰공고 {len(new_bids)}건",
|
||||
html_body=html_body,
|
||||
)
|
||||
db.add(AuditLog(
|
||||
actor="system",
|
||||
action="BID_WATCH_NOTIFY_EMAIL",
|
||||
detail=(
|
||||
f"신규 입찰 {len(new_bids)}건 알림 메일 — 수신 {len(to_addrs)}명"
|
||||
+ ("" if ok else f" (발송 실패: {err})")
|
||||
),
|
||||
entity_type="BID_WATCH",
|
||||
severity="INFO" if ok else "WARNING",
|
||||
))
|
||||
await db.commit()
|
||||
|
||||
@ -30,12 +30,9 @@ 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
|
||||
@ -50,13 +47,6 @@ from models import (
|
||||
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 구조
|
||||
|
||||
BIN
static/icons/guardia/brand-1/original_16.png
Normal file
|
After Width: | Height: | Size: 910 B |
BIN
static/icons/guardia/brand-1/original_24.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
static/icons/guardia/brand-1/original_32.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
static/icons/guardia/brand-1/original_48.png
Normal file
|
After Width: | Height: | Size: 4.8 KiB |
BIN
static/icons/guardia/brand-1/original_64.png
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
BIN
static/icons/guardia/brand-2/original_16.png
Normal file
|
After Width: | Height: | Size: 440 B |
BIN
static/icons/guardia/brand-2/original_24.png
Normal file
|
After Width: | Height: | Size: 725 B |
BIN
static/icons/guardia/brand-2/original_32.png
Normal file
|
After Width: | Height: | Size: 993 B |
BIN
static/icons/guardia/brand-2/original_48.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
static/icons/guardia/brand-2/original_64.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
static/icons/guardia/brand-3/original_16.png
Normal file
|
After Width: | Height: | Size: 474 B |
BIN
static/icons/guardia/brand-3/original_24.png
Normal file
|
After Width: | Height: | Size: 878 B |
BIN
static/icons/guardia/brand-3/original_32.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
static/icons/guardia/brand-3/original_48.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
static/icons/guardia/brand-3/original_64.png
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
BIN
static/icons/guardia/brand-4/original_16.png
Normal file
|
After Width: | Height: | Size: 973 B |
BIN
static/icons/guardia/brand-4/original_24.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
static/icons/guardia/brand-4/original_32.png
Normal file
|
After Width: | Height: | Size: 3.0 KiB |
BIN
static/icons/guardia/brand-4/original_48.png
Normal file
|
After Width: | Height: | Size: 5.8 KiB |
BIN
static/icons/guardia/brand-4/original_64.png
Normal file
|
After Width: | Height: | Size: 9.3 KiB |
BIN
static/icons/guardia/brand-5/original_16.png
Normal file
|
After Width: | Height: | Size: 489 B |
BIN
static/icons/guardia/brand-5/original_24.png
Normal file
|
After Width: | Height: | Size: 882 B |
BIN
static/icons/guardia/brand-5/original_32.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
static/icons/guardia/brand-5/original_48.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
static/icons/guardia/brand-5/original_64.png
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
BIN
static/icons/guardia/brand-6/original_16.png
Normal file
|
After Width: | Height: | Size: 689 B |
BIN
static/icons/guardia/brand-6/original_24.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
static/icons/guardia/brand-6/original_32.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
static/icons/guardia/brand-6/original_48.png
Normal file
|
After Width: | Height: | Size: 3.2 KiB |
BIN
static/icons/guardia/brand-6/original_64.png
Normal file
|
After Width: | Height: | Size: 4.8 KiB |
BIN
static/icons/guardia/brand-7/original_16.png
Normal file
|
After Width: | Height: | Size: 629 B |
BIN
static/icons/guardia/brand-7/original_24.png
Normal file
|
After Width: | Height: | Size: 1.0 KiB |
BIN
static/icons/guardia/brand-7/original_32.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
static/icons/guardia/brand-7/original_48.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
static/icons/guardia/brand-7/original_64.png
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
static/icons/guardia/brand-8/original_16.png
Normal file
|
After Width: | Height: | Size: 531 B |
BIN
static/icons/guardia/brand-8/original_24.png
Normal file
|
After Width: | Height: | Size: 920 B |
BIN
static/icons/guardia/brand-8/original_32.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
static/icons/guardia/brand-8/original_48.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
static/icons/guardia/brand-8/original_64.png
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
BIN
static/icons/guardia/home/white_16.png
Normal file
|
After Width: | Height: | Size: 444 B |
BIN
static/icons/guardia/home/white_24.png
Normal file
|
After Width: | Height: | Size: 700 B |
BIN
static/icons/guardia/home/white_32.png
Normal file
|
After Width: | Height: | Size: 934 B |
BIN
static/icons/guardia/home/white_48.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
static/icons/guardia/home/white_64.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
static/icons/guardia/icon-1/white_16.png
Normal file
|
After Width: | Height: | Size: 557 B |
BIN
static/icons/guardia/icon-1/white_24.png
Normal file
|
After Width: | Height: | Size: 999 B |
BIN
static/icons/guardia/icon-1/white_32.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
static/icons/guardia/icon-1/white_48.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
static/icons/guardia/icon-1/white_64.png
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
BIN
static/icons/guardia/icon-10/white_16.png
Normal file
|
After Width: | Height: | Size: 567 B |
BIN
static/icons/guardia/icon-10/white_24.png
Normal file
|
After Width: | Height: | Size: 996 B |
BIN
static/icons/guardia/icon-10/white_32.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
static/icons/guardia/icon-10/white_48.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
static/icons/guardia/icon-10/white_64.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
static/icons/guardia/icon-11/white_16.png
Normal file
|
After Width: | Height: | Size: 502 B |
BIN
static/icons/guardia/icon-11/white_24.png
Normal file
|
After Width: | Height: | Size: 796 B |
BIN
static/icons/guardia/icon-11/white_32.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
static/icons/guardia/icon-11/white_48.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
static/icons/guardia/icon-11/white_64.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
static/icons/guardia/icon-12/white_16.png
Normal file
|
After Width: | Height: | Size: 526 B |
BIN
static/icons/guardia/icon-12/white_24.png
Normal file
|
After Width: | Height: | Size: 878 B |
BIN
static/icons/guardia/icon-12/white_32.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
static/icons/guardia/icon-12/white_48.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
static/icons/guardia/icon-12/white_64.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
static/icons/guardia/icon-13/white_16.png
Normal file
|
After Width: | Height: | Size: 496 B |
BIN
static/icons/guardia/icon-13/white_24.png
Normal file
|
After Width: | Height: | Size: 862 B |
BIN
static/icons/guardia/icon-13/white_32.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
static/icons/guardia/icon-13/white_48.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
static/icons/guardia/icon-13/white_64.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
static/icons/guardia/icon-14/white_16.png
Normal file
|
After Width: | Height: | Size: 473 B |
BIN
static/icons/guardia/icon-14/white_24.png
Normal file
|
After Width: | Height: | Size: 788 B |
BIN
static/icons/guardia/icon-14/white_32.png
Normal file
|
After Width: | Height: | Size: 1.0 KiB |
BIN
static/icons/guardia/icon-14/white_48.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
static/icons/guardia/icon-14/white_64.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
static/icons/guardia/icon-15/white_16.png
Normal file
|
After Width: | Height: | Size: 482 B |
BIN
static/icons/guardia/icon-15/white_24.png
Normal file
|
After Width: | Height: | Size: 811 B |
BIN
static/icons/guardia/icon-15/white_32.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
static/icons/guardia/icon-15/white_48.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
static/icons/guardia/icon-15/white_64.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
static/icons/guardia/icon-16/white_16.png
Normal file
|
After Width: | Height: | Size: 425 B |
BIN
static/icons/guardia/icon-16/white_24.png
Normal file
|
After Width: | Height: | Size: 748 B |
BIN
static/icons/guardia/icon-16/white_32.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
static/icons/guardia/icon-16/white_48.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
static/icons/guardia/icon-16/white_64.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
static/icons/guardia/icon-17/white_16.png
Normal file
|
After Width: | Height: | Size: 402 B |
BIN
static/icons/guardia/icon-17/white_24.png
Normal file
|
After Width: | Height: | Size: 688 B |
BIN
static/icons/guardia/icon-17/white_32.png
Normal file
|
After Width: | Height: | Size: 930 B |
BIN
static/icons/guardia/icon-17/white_48.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
static/icons/guardia/icon-17/white_64.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
static/icons/guardia/icon-18/white_16.png
Normal file
|
After Width: | Height: | Size: 442 B |
BIN
static/icons/guardia/icon-18/white_24.png
Normal file
|
After Width: | Height: | Size: 733 B |
BIN
static/icons/guardia/icon-18/white_32.png
Normal file
|
After Width: | Height: | Size: 1.0 KiB |
BIN
static/icons/guardia/icon-18/white_48.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
static/icons/guardia/icon-18/white_64.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
static/icons/guardia/icon-19/white_16.png
Normal file
|
After Width: | Height: | Size: 492 B |
BIN
static/icons/guardia/icon-19/white_24.png
Normal file
|
After Width: | Height: | Size: 843 B |