manual-deploy 2026-06-07 19:13

This commit is contained in:
DESKTOP-TKLFCPR\ython 2026-06-07 19:13:43 +09:00
parent d14369a7e5
commit ea0e7ef9b6
284 changed files with 359 additions and 13 deletions

View File

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

View File

@ -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 관리시스템 &gt; 입찰워처에서 참가/보류/삭제 의사결정과 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()

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 910 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 440 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 725 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 993 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 474 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 878 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 973 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 489 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 882 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 689 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 629 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 531 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 920 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 444 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 700 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 934 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 557 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 999 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 567 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 996 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 502 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 796 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 526 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 878 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 496 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 473 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 788 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 482 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 811 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 425 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 748 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 402 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 688 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 930 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 442 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 733 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 492 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 843 B

Some files were not shown because too many files have changed in this diff Show More