feat(report): 检查报告 PDF 导出和归档;项目状态流转(递交 / 归档)
Some checks failed
ci / server (push) Has been cancelled
ci / web (push) Has been cancelled
ci / contract (push) Has been cancelled

- report 模块:导出 → worker 用 Gotenberg 把 HTML 转成 A4 PDF → 按项目数据密钥加密存储 → report
  (不可改、存哈希);report_job 记生成过程(迁移 0009),完成推 report.ready、发通知
- 报告首页:项目信息、投标文件版本、检查依据、规则版本、导出人、检查覆盖范围、汇总、免责声明;
  问题明细带两侧出处和处理人;附录为需人工核对的勾选结果;模板文字不出现「通过 / 合格 / 保证」
- 前端「报告」标签页:导出选项、内嵌预览、历次报告下载;工作台「导出检查报告」直达
- 项目状态流转 POST /projects/{id}/transitions:致命未处理也能递交(确认框说明),
  递交后只读,撤销归档回到归档前的状态,都写审计
- 通知补上检查、报告的标题

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
YANG JIANKUAN
2026-11-04 20:51:43 +08:00
parent 902a130c82
commit e7fb797925
26 changed files with 1276 additions and 13 deletions

View File

@@ -24,6 +24,7 @@ def create_app() -> FastAPI:
from bid_doctor.notification.api import router as notification_router
from bid_doctor.ops.api import router as ops_router
from bid_doctor.project.api import router as project_router
from bid_doctor.report.api import router as report_router
settings = get_settings()
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s %(message)s")
@@ -61,6 +62,7 @@ def create_app() -> FastAPI:
document_router,
checklist_router,
check_router,
report_router,
notification_router,
ops_router,
):

View File

@@ -59,3 +59,16 @@ async def events_for(session: AsyncSession, object_type: str, object_id: UUID) -
.order_by(AuditEvent.created_at.desc(), AuditEvent.id.desc())
)
return list(rows.scalars().all())
async def last_before(session: AsyncSession, action: str, object_id: UUID) -> dict[str, Any] | None:
"""某个对象最近一次某种操作之前的状态(例如撤销归档时回到归档前的项目状态)。"""
row = (
await session.execute(
select(AuditEvent.before)
.where(AuditEvent.action == action, AuditEvent.object_id == object_id)
.order_by(AuditEvent.id.desc())
.limit(1)
)
).scalar_one_or_none()
return row if isinstance(row, dict) else None

View File

@@ -33,6 +33,8 @@ CODES: dict[str, tuple[int, str]] = {
"checklist_empty": (409, "正式清单还没有条目"),
"no_parsed_tender": (409, "还没有解析成功的招标文件"),
"no_bid_documents": (409, "还没有上传投标文件"),
"invalid_transition": (409, "项目当前的状态不能做这个操作"),
"run_not_succeeded": (409, "这次检查没有成功完成,不能导出报告"),
"run_superseded": (409, "这是历史检查,只能查看"),
"finding_resolved_this_run": (409, "这个问题本次已解决,不需要处理"),
"precondition_failed": (412, "内容已被修改,请刷新后再改"),

View File

@@ -38,7 +38,14 @@ async def list_notifications(ctx: Ctx, unread: bool | None = None) -> dict[str,
.where(Notification.user_id == ctx.user_id, Notification.read_at.is_(None))
)
).scalar_one()
titles = {"parse_succeeded": "招标文件解析完成", "parse_failed": "招标文件解析失败,额度已退回"}
titles = {
"parse_succeeded": "招标文件解析完成",
"parse_failed": "招标文件解析失败,额度已退回",
"check_succeeded": "投标文件检查完成",
"check_failed": "投标文件检查没有完成,不扣额度",
"report_succeeded": "检查报告已生成",
"report_failed": "检查报告生成失败",
}
return {
"unread_count": n,
"next_cursor": None,

View File

@@ -1,12 +1,14 @@
from __future__ import annotations
from typing import Any
from typing import Any, Literal
from uuid import UUID
from fastapi import APIRouter, Query
from pydantic import BaseModel, Field
from sqlalchemy import select
from bid_doctor.core.db import tenant_tx
from bid_doctor.document.models import SourceDocument
from bid_doctor.identity.deps import Ctx
from bid_doctor.project import service, workbench
@@ -17,6 +19,10 @@ class ProjectIn(BaseModel):
name: str | None = Field(default=None, max_length=200)
class TransitionIn(BaseModel):
action: Literal["submit", "unsubmit", "archive", "unarchive"]
@router.get("/projects")
async def list_projects(ctx: Ctx, limit: int = Query(default=20, ge=1, le=100)) -> dict[str, Any]:
# TODO(工作台筛选):status / deadline / q / 游标分页,按 openapi.yaml 补齐
@@ -35,3 +41,21 @@ async def create_project(body: ProjectIn, ctx: Ctx) -> dict[str, Any]:
async def get_project(project_id: UUID, ctx: Ctx) -> dict[str, Any]:
async with tenant_tx(ctx) as s:
return await workbench.detail(s, ctx, project_id)
@router.post("/projects/{project_id}/transitions")
async def transition_project(project_id: UUID, body: TransitionIn, ctx: Ctx) -> dict[str, Any]:
async with tenant_tx(ctx) as s:
parsed = (
await s.execute(
select(SourceDocument.id)
.where(
SourceDocument.project_id == project_id,
SourceDocument.role == "tender",
SourceDocument.text_layer_key.is_not(None),
)
.limit(1)
)
).scalar_one_or_none() is not None
await service.transition(s, ctx, project_id, body.action, parsed)
return await workbench.detail(s, ctx, project_id)

View File

@@ -150,6 +150,49 @@ def mark_checked(p: Project, run_id: UUID, open_fatal: int) -> None:
p.latest_check_run_id, p.open_fatal_count = run_id, open_fatal
TRANSITIONS = { # 动作 → 允许从哪些状态做(FR-M6-02)
"submit": ("tracking", "drafting", "checked"),
"unsubmit": ("submitted",),
"archive": ("tracking", "drafting", "checked", "submitted"),
"unarchive": ("archived",),
}
def base_status(p: Project, parsed: bool) -> str:
"""不算手动状态时项目该在的状态:检查过 → 已检查;解析过招标文件 → 编制中;否则跟进中。"""
return "checked" if p.latest_check_run_id else "drafting" if parsed else "tracking"
async def transition(s: AsyncSession, ctx: TenantContext, project_id: UUID, action: str, parsed: bool) -> Project:
"""手动变更项目状态(FR-M6-02):递交后只读、可以撤销;归档随时可以撤销,撤销后回到归档前的状态。
致命问题没处理完也可以递交(红线 4),就绪状态由前端提示。每次变更都写审计(留痕)。"""
p = await get(s, ctx, project_id)
if p.status not in TRANSITIONS[action]:
raise AppError("invalid_transition")
before = p.status
if action == "submit":
p.status = "submitted"
elif action == "archive":
p.status = "archived"
elif action == "unsubmit":
p.status = base_status(p, parsed)
else:
prev = (await audit.last_before(s, "project.archive", p.id) or {}).get("status")
p.status = prev if prev in ("submitted", "checked", "drafting", "tracking") else base_status(p, parsed)
await audit.record(
s,
ctx,
f"project.{action}",
"project",
p.id,
project_id=p.id,
before={"status": before},
after={"status": p.status, "open_fatal_count": p.open_fatal_count},
)
await s.flush()
return p
def readiness(p: Project) -> dict[str, Any]:
"""ProjectReadiness:就绪状态只在「已检查」时有意义(G7);未就绪也不阻止导出和递交(红线 4)。"""
return {

View File

View File

@@ -0,0 +1,64 @@
from __future__ import annotations
from typing import Annotated, Any
from uuid import UUID
from fastapi import APIRouter, Body, Response
from pydantic import BaseModel
from bid_doctor.core.crypto import open_object, verify_download
from bid_doctor.core.db import TenantContext, tenant_tx
from bid_doctor.core.errors import AppError
from bid_doctor.core.keys import data_key
from bid_doctor.core.storage import storage
from bid_doctor.identity.deps import Ctx
from bid_doctor.report import service
router = APIRouter(tags=["reports"])
class ReportIn(BaseModel):
citations: bool = True # 问题明细带招标、投标两侧出处
manual_checks: bool = True # 附录:需人工核对
@router.post("/check-runs/{run_id}/reports", status_code=202)
async def generate_report(run_id: UUID, ctx: Ctx, body: Annotated[ReportIn | None, Body()] = None) -> dict[str, Any]:
opts = (body or ReportIn()).model_dump()
async with tenant_tx(ctx) as s:
return service.job_to_api(await service.start(s, ctx, run_id, opts))
@router.get("/report-jobs/{job_id}")
async def get_report_job(job_id: UUID, ctx: Ctx) -> dict[str, Any]:
async with tenant_tx(ctx) as s:
return service.job_to_api(await service.get_job(s, job_id))
@router.get("/projects/{project_id}/reports")
async def list_reports(project_id: UUID, ctx: Ctx) -> dict[str, Any]:
async with tenant_tx(ctx) as s:
return {"items": await service.list_reports(s, ctx, project_id)}
@router.get("/reports/{report_id}/file")
async def get_report_file_url(report_id: UUID, ctx: Ctx) -> dict[str, Any]:
async with tenant_tx(ctx) as s:
return await service.file_url(s, ctx, report_id)
@router.get("/report-files/{tenant_id}/{report_id}", include_in_schema=False)
async def download(tenant_id: UUID, report_id: UUID, exp: int, sig: str) -> Response:
"""签名下载(本地磁盘驱动用)。不带鉴权头,靠短时签名。"""
if not verify_download(f"{tenant_id}:{report_id}:report", exp, sig):
raise AppError("not_found")
ctx = TenantContext(tenant_id=tenant_id, user_id=UUID(int=0))
async with tenant_tx(ctx) as s:
r = await service.get_report(s, report_id)
dk = await data_key(s, ctx, r.project_id)
name = f"report-{r.created_at:%Y%m%d%H%M}.pdf"
return Response(
open_object(dk, storage().get(r.object_key), r.object_key),
media_type="application/pdf",
headers={"Cache-Control": "private, max-age=300", "Content-Disposition": f'inline; filename="{name}"'},
)

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 41 KiB

View File

@@ -0,0 +1,42 @@
from __future__ import annotations
from sqlalchemy import FetchedValue
from sqlalchemy.orm import Mapped, mapped_column
from bid_doctor.core.columns import PK, Bytes, DbTs, Json, Ts, UuidCol
from bid_doctor.core.db import Base
class Report(Base):
"""检查报告(FR-M3-09):生成后不再改(数据库触发器禁止 UPDATE),重新导出就是新的一行。"""
__tablename__ = "report"
id: Mapped[PK]
tenant_id: Mapped[UuidCol]
project_id: Mapped[UuidCol]
check_run_id: Mapped[UuidCol]
object_key: Mapped[str]
sha256: Mapped[Bytes]
page_count: Mapped[int]
template_version: Mapped[str]
open_fatal_at_export: Mapped[int]
options: Mapped[Json] = mapped_column(server_default=FetchedValue())
generated_by: Mapped[UuidCol]
created_at: Mapped[DbTs]
class ReportJob(Base):
"""一次「导出报告」:排队 → 生成 → 成功 / 失败(迁移 0009)。"""
__tablename__ = "report_job"
id: Mapped[PK]
tenant_id: Mapped[UuidCol]
project_id: Mapped[UuidCol]
check_run_id: Mapped[UuidCol]
status: Mapped[str] = mapped_column(server_default=FetchedValue())
options: Mapped[Json] = mapped_column(server_default=FetchedValue())
report_id: Mapped[UuidCol | None]
error_code: Mapped[str | None]
requested_by: Mapped[UuidCol]
created_at: Mapped[DbTs]
finished_at: Mapped[Ts | None]

View File

@@ -0,0 +1,113 @@
"""报告的后台生成(worker):整理数据 → HTML → Gotenberg(Chromium)转 PDF → 按项目数据密钥加密存对象存储 → 写 report。
- 生成后不再改:报告存哈希,重复导出就是新的一份;
- 失败不留半截文件,记 error_code(转换服务不可用 / 生成失败),通知用户;报告不扣额度。
"""
from __future__ import annotations
import io
import logging
from datetime import UTC, datetime
from uuid import UUID
import httpx
import pypdfium2 as pdfium
from sqlalchemy import select
from bid_doctor.core import events
from bid_doctor.core.config import get_settings
from bid_doctor.core.crypto import seal_object, sha256
from bid_doctor.core.db import TenantContext, tenant_tx
from bid_doctor.core.ids import uuid7
from bid_doctor.core.keys import data_key
from bid_doctor.core.storage import project_prefix, storage
from bid_doctor.notification.models import Notification
from bid_doctor.report import service, template
from bid_doctor.report.models import Report, ReportJob
log = logging.getLogger(__name__)
TIMEOUT = httpx.Timeout(120, connect=10)
A4 = {"paperWidth": "8.27", "paperHeight": "11.69"}
# 上下留白放页眉页脚;左右 20mm(设计规范 §7.17)
MARGINS = {"marginTop": "0.95", "marginBottom": "0.85", "marginLeft": "0.79", "marginRight": "0.79"}
async def to_pdf(html: str) -> bytes:
files = {
"index.html": ("index.html", html.encode(), "text/html"),
"header.html": ("header.html", template.header().encode(), "text/html"),
"footer.html": ("footer.html", template.footer().encode(), "text/html"),
}
async with httpx.AsyncClient(base_url=get_settings().gotenberg_url, timeout=TIMEOUT) as c:
r = await c.post(
"/forms/chromium/convert/html",
files=list(files.items()),
data={**A4, **MARGINS, "printBackground": "true", "preferCssPageSize": "false"},
)
r.raise_for_status()
return r.content
async def run(tenant_id: UUID, job_id: UUID) -> None:
ctx = TenantContext(tenant_id=tenant_id, user_id=UUID(int=0))
async with tenant_tx(ctx) as s:
job = (await s.execute(select(ReportJob).where(ReportJob.id == job_id))).scalar_one()
if job.status not in ("queued", "running"):
return # 重复派发
ctx = TenantContext(tenant_id=tenant_id, user_id=job.requested_by, actor_name="系统")
job.status = "running"
d = await service.data(s, ctx, job)
try:
pdf = await to_pdf(template.page(d))
pages = len(pdfium.PdfDocument(io.BytesIO(pdf)))
except httpx.HTTPError as e:
log.warning("report %s converter error: %s", job_id, e)
await _finish(ctx, job_id, None, "converter_unavailable")
return
except Exception:
log.exception("report %s crashed", job_id)
await _finish(ctx, job_id, None, "render_failed")
return
async with tenant_tx(ctx) as s:
job = (await s.execute(select(ReportJob).where(ReportJob.id == job_id))).scalar_one()
report_id = uuid7()
key = f"{project_prefix(tenant_id, job.project_id)}/reports/{report_id}.pdf"
storage().put(key, seal_object(await data_key(s, ctx, job.project_id), pdf, key))
s.add(
Report(
id=report_id,
tenant_id=tenant_id,
project_id=job.project_id,
check_run_id=job.check_run_id,
object_key=key,
sha256=sha256(pdf),
page_count=pages,
template_version=template.TEMPLATE_VERSION,
open_fatal_at_export=d["summary"]["fatal"]["pending"],
options=job.options or {},
generated_by=job.requested_by,
)
)
await s.flush()
await _finish(ctx, job_id, report_id, None)
async def _finish(ctx: TenantContext, job_id: UUID, report_id: UUID | None, error: str | None) -> None:
async with tenant_tx(ctx) as s:
job = (await s.execute(select(ReportJob).where(ReportJob.id == job_id))).scalar_one()
job.status = "succeeded" if report_id else "failed"
job.report_id, job.error_code, job.finished_at = report_id, error, datetime.now(UTC)
s.add(
Notification(
id=uuid7(),
tenant_id=ctx.tenant_id,
user_id=job.requested_by,
kind="report_succeeded" if report_id else "report_failed",
project_id=job.project_id,
payload={"job_id": str(job.id), **({"report_id": str(report_id)} if report_id else {})},
)
)
api = service.job_to_api(job)
project_id = job.project_id
await events.publish(ctx.tenant_id, "report.ready", project_id=str(project_id), job={"kind": "report_job", **api})

View File

@@ -0,0 +1,169 @@
"""检查报告的发起、查询和下载(FR-M3-09,api-design §4.1)。
- 只有成功完成的检查可以导出;致命问题没处理完也可以导出(红线 4),报告首页如实写明还有几项待处理;
- 「排队 → 入队」在同一个事务里(架构 §3.4);生成在 worker 里(runner.py),完成后推 report.ready;
- 报告随项目归档,可以重复下载;下载地址是短时签名,和投标文件下载同一套做法。
"""
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
from uuid import UUID
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from bid_doctor.audit import service as audit
from bid_doctor.check import service as checks
from bid_doctor.check.models import CheckRun
from bid_doctor.core import queue
from bid_doctor.core.config import get_settings
from bid_doctor.core.crypto import sign_download
from bid_doctor.core.db import TenantContext
from bid_doctor.core.errors import AppError
from bid_doctor.core.ids import uuid7
from bid_doctor.identity import service as identity
from bid_doctor.project import service as projects
from bid_doctor.report.models import Report, ReportJob
REPORT_TASK = "generate_report"
STATUSES = ("pending", "confirmed_ok", "modified", "ignored")
ERROR_MESSAGES = {
"render_failed": "报告生成失败,请稍后重试",
"converter_unavailable": "报告转换服务暂时不可用,请稍后重试",
}
async def start(s: AsyncSession, ctx: TenantContext, run_id: UUID, options: dict[str, bool]) -> ReportJob:
run = await checks.get_run(s, run_id)
await projects.get(s, ctx, run.project_id)
if run.status != "succeeded":
raise AppError("run_not_succeeded")
job = ReportJob(
id=uuid7(),
tenant_id=ctx.tenant_id,
project_id=run.project_id,
check_run_id=run.id,
options=options,
requested_by=ctx.user_id,
)
s.add(job)
await s.flush()
await queue.enqueue(s, REPORT_TASK, {"tenant_id": str(ctx.tenant_id), "job_id": str(job.id)})
await audit.record(
s, ctx, "report.export", "check_run", run.id, project_id=run.project_id, after={"job": str(job.id)}
)
return job
async def get_job(s: AsyncSession, job_id: UUID) -> ReportJob:
job = (await s.execute(select(ReportJob).where(ReportJob.id == job_id))).scalar_one_or_none()
if job is None:
raise AppError("not_found")
return job
def job_to_api(job: ReportJob) -> dict[str, Any]:
error = (
{"code": job.error_code, "message": ERROR_MESSAGES.get(job.error_code or "", job.error_code), "refunded": False}
if job.status == "failed" and job.error_code
else None
)
return {
"id": job.id,
"check_run_id": job.check_run_id,
"status": job.status,
"report_id": job.report_id,
"error": error,
}
async def list_reports(s: AsyncSession, ctx: TenantContext, project_id: UUID) -> list[dict[str, Any]]:
await projects.get(s, ctx, project_id)
rows = list(
(
await s.execute(select(Report).where(Report.project_id == project_id).order_by(Report.created_at.desc()))
).scalars()
)
ids = [r.check_run_id for r in rows]
found = (await s.execute(select(CheckRun.id, CheckRun.run_no).where(CheckRun.id.in_(ids)))).all()
run_no = {row[0]: row[1] for row in found}
names = await identity.display_names(s, [r.generated_by for r in rows])
return [
{
"id": r.id,
"check_run_id": r.check_run_id,
"run_no": run_no.get(r.check_run_id, 0),
"page_count": r.page_count,
"sha256": r.sha256.hex(),
"template_version": r.template_version,
"open_fatal_at_export": r.open_fatal_at_export,
"generated_by": {"id": r.generated_by, "name": names.get(r.generated_by, "")},
"created_at": r.created_at,
}
for r in rows
]
async def file_url(s: AsyncSession, ctx: TenantContext, report_id: UUID) -> dict[str, Any]:
r = (await s.execute(select(Report).where(Report.id == report_id))).scalar_one_or_none()
if r is None:
raise AppError("not_found")
sig, exp = sign_download(f"{ctx.tenant_id}:{r.id}:report", get_settings().download_url_ttl_s)
url = f"{get_settings().public_base_url}/api/v1/report-files/{ctx.tenant_id}/{r.id}?exp={exp}&sig={sig}"
return {"url": url, "expires_at": datetime.fromtimestamp(exp, UTC), "sha256": r.sha256.hex()}
async def get_report(s: AsyncSession, report_id: UUID) -> Report:
r = (await s.execute(select(Report).where(Report.id == report_id))).scalar_one_or_none()
if r is None:
raise AppError("not_found")
return r
async def data(s: AsyncSession, ctx: TenantContext, job: ReportJob) -> dict[str, Any]:
"""报告要的数据:都从查标已有的接口函数取(检查批次、问题、需人工核对),和查标页看到的一致。"""
run = await checks.get_run(s, job.check_run_id)
p = await projects.get(s, ctx, run.project_id)
run_api = await checks.run_to_api(s, ctx, run)
every = await checks.findings(s, run.id, severity=None, status=None, presence=None)
items = [f for f in every if f["presence"] != "resolved"]
resolved = sum(1 for f in every if f["presence"] == "resolved")
summary = {
sev: {
"total": sum(f["severity"] == sev for f in items),
**{st: sum(f["severity"] == sev and f["status"] == st for f in items) for st in STATUSES},
}
for sev in ("fatal", "important", "hint")
}
lots = await projects.lot_labels(s, p.id)
names = await identity.display_names(s, [job.requested_by])
return {
"project": {
"name": p.name,
"code": p.code,
"purchaser": p.purchaser,
"legal_regime": run.legal_regime,
"procurement_method": p.procurement_method,
"deadline_at": p.deadline_at,
"lots": [f"采购包 {lots[x]}" for x in run.lot_ids if lots.get(x)],
},
"run": {
"run_no": run.run_no,
"created_at": run.created_at,
"finished_at": run.finished_at,
"rule_set_version": run.rule_set_version,
"checklist_version_no": run_api["checklist_version_no"],
"checklist_confirmed": run.checklist_confirmed,
"coverage": run.coverage,
"documents": [{"label": d["label"], "filename": d["original_filename"]} for d in run_api["documents"]],
},
"summary": summary,
"resolved_this_run": resolved,
"findings": items,
"manual": await checks.manual_checks(s, ctx, p.id),
"options": job.options or {},
"exported_by": names.get(job.requested_by, ""),
"exported_at": datetime.now(UTC),
}

View File

@@ -0,0 +1,243 @@
"""检查报告的 HTML(设计规范 §7.17,原型 report.html):纯函数,输入整理好的数据,输出正文、页眉、页脚三段 HTML,
由 Gotenberg 的 Chromium 转成 PDF(A4 竖版,页边距 20mm,页眉页脚每页重复、页码自动生成)。
- 首页:项目信息、投标文件版本、检查依据、检查时间、规则版本、导出人和时间、**检查覆盖范围**(查了 / 没查)、
汇总、免责声明;
- 问题明细:级别、问题、说明、出处(可选)、处理状态和处理人;
- 附录:需人工核对的条目和勾选结果(可选);
- logo 只在页眉,不放在结论、汇总数字或签名旁边(红线:logo 不能当「通过」章);
- 模板自己的文字不出现「通过」「合格」「保证」(测试检查);引用招标文件、投标文件的原文照录。
"""
from __future__ import annotations
import base64
from datetime import datetime
from functools import cache
from html import escape
from pathlib import Path
from typing import Any
from zoneinfo import ZoneInfo
TEMPLATE_VERSION = "2026.11-v1"
TITLE = "投标文件辅助检查报告"
FOOTER = "本报告为辅助检查结果,仅供投标前自查参考"
DISCLAIMER = (
"本报告由标博士比对招标文件和投标文件生成,经用户逐项处理后导出,供投标前自查使用。检查范围以首页「检查覆盖范围」为准;"
"结果可能有遗漏或误判,不代表资格审查、符合性审查或评标的结论。是否递交、如何修改,由投标人自行决定。"
)
SEVERITY = {"fatal": "致命", "important": "重要", "hint": "提示"}
STATUS = {"pending": "待处理", "confirmed_ok": "已确认无误", "modified": "已修改", "ignored": "已忽略"}
REGIME = {"gov_procurement": "政府采购", "tendering": "招标投标", "unknown": "未识别"}
LOCAL_TZ = ZoneInfo("Asia/Shanghai")
# 页面四周的留白交给 Gotenberg(页眉页脚放在上下留白里);正文只管内容
CSS = """
@page { size: A4; }
* { box-sizing: border-box; }
body { margin: 0; font-family: "Noto Sans CJK SC", "Source Han Sans SC", sans-serif; font-size: 10.5pt;
line-height: 1.6; color: #1D2733; }
h1, h2, h3 { font-family: "Noto Serif CJK SC", "Source Han Serif SC", serif; font-weight: 600; margin: 0; }
h1 { font-size: 18pt; line-height: 1.35; }
h2 { font-size: 13pt; margin: 16pt 0 6pt; break-after: avoid; }
.sub { color: #5C6670; margin: 4pt 0 12pt; }
.fig { font-family: "Barlow Semi Condensed", "Noto Sans CJK SC", sans-serif; font-variant-numeric: tabular-nums; }
dl { display: grid; grid-template-columns: 26mm 1fr; gap: 3pt 8pt; margin: 0 0 6pt; }
dt { color: #5C6670; }
dd { margin: 0; }
ul { margin: 2pt 0 6pt; padding-left: 1.2em; }
table { width: 100%; border-collapse: collapse; margin: 4pt 0 6pt; }
th, td { border-bottom: 0.5pt solid #D6D8DB; padding: 4pt 5pt; text-align: left; vertical-align: top; }
th { color: #5C6670; font-weight: 600; background: #F8F8F9; }
thead { display: table-header-group; }
tr { break-inside: avoid; }
td.num, th.num { text-align: right; }
.sev { display: inline-block; padding: 0 5pt; border-radius: 3pt; font-size: 9pt; font-weight: 700; line-height: 1.6;
white-space: nowrap; }
.sev.fatal { background: #C4122F; color: #fff; }
.sev.important { border: 1pt solid #A66300; color: #8A5200; }
.sev.hint { background: #F1F2F3; color: #5C6670; }
.cites { margin-top: 3pt; color: #5C6670; font-size: 9pt; }
.cites div { margin-top: 1pt; }
.who { display: block; color: #5C6670; font-size: 9pt; }
.note { display: block; font-size: 9pt; }
.todo { color: #C4122F; font-weight: 600; }
.cap { color: #5C6670; font-size: 9pt; }
.disc { margin-top: 12pt; padding: 8pt 10pt; background: #F8F8F9; border-radius: 4pt; font-size: 9.5pt;
break-inside: avoid; }
.disc b { display: block; margin-bottom: 2pt; }
.alert { margin: 6pt 0; padding: 6pt 10pt; border-left: 3pt solid #A66300; background: #FBF0DC; }
"""
@cache
def _logo() -> str:
svg = (Path(__file__).parent / "assets" / "logo-horizontal.svg").read_bytes()
return "data:image/svg+xml;base64," + base64.b64encode(svg).decode()
def when(at: datetime | str | None) -> str:
"""报告和留痕的时间写法:「2026-11-02 14:20」(设计规范 §9.3),北京时间。"""
if at is None:
return "—"
if isinstance(at, str):
at = datetime.fromisoformat(at)
return at.astimezone(LOCAL_TZ).strftime("%Y-%m-%d %H:%M")
def _e(v: Any) -> str:
return escape(str(v)) if v not in (None, "") else "—"
def _cite(c: dict[str, Any]) -> str:
quote = (c.get("quote") or "").strip()
quote = quote[:80] + "……" if len(quote) > 80 else quote
return f'{_e(c.get("document_label"))} · 第 <span class="fig">{c.get("page_no")}</span> 页' + (
f"「{escape(quote)}」" if quote else ""
)
def _status(f: dict[str, Any]) -> str:
st = f.get("status") or "pending"
if st == "pending":
return '<span class="todo">待处理</span>'
by = (f.get("resolved_by") or {}).get("name") or ""
note = f.get("resolution_note")
label = "原因" if st == "ignored" else "说明"
return (
f"{STATUS[st]}<span class='who'>{escape(by)} · <span class='fig'>{when(f.get('resolved_at'))}</span></span>"
+ (f"<span class='note'>{label}:{escape(note)}</span>" if note else "")
)
def cover(d: dict[str, Any]) -> str:
p, r = d["project"], d["run"]
docs = ";".join(f"{escape(x['label'])}({escape(x.get('filename') or '')})" for x in r["documents"]) or "—"
basis = f"清单 v{r['checklist_version_no']}" + (
"" if r["checklist_confirmed"] else "(未确认,以检查时的清单为准)"
)
cov = r.get("coverage") or {}
rows = []
for sev in ("fatal", "important", "hint"):
s = d["summary"][sev]
rows.append(
f"<tr><td><span class='sev {sev}'>{SEVERITY[sev]}</span></td>"
+ "".join(
f"<td class='num fig'>{s[k]}</td>" for k in ("total", "pending", "confirmed_ok", "modified", "ignored")
)
+ "</tr>"
)
open_fatal = d["summary"]["fatal"]["pending"]
regime = REGIME.get(p.get("legal_regime") or "unknown", "未识别")
method = f" · {escape(p['procurement_method'])}" if p.get("procurement_method") else ""
checked_at = f'<span class="fig">{when(r.get("finished_at") or r.get("created_at"))}</span>'
run_no = f'第 <span class="fig">{r["run_no"]}</span> 次检查'
alert = (
f'<div class="alert">导出时还有 <b class="fig">{open_fatal}</b> 项致命问题待处理,报告如实列出。</div>'
if open_fatal
else ""
)
return f"""
<h1>{_e(p["name"])}</h1>
<p class="sub">{TITLE}</p>
<dl>
<dt>项目编号</dt><dd class="fig">{_e(p.get("code"))}</dd>
<dt>采购人</dt><dd>{_e(p.get("purchaser"))}</dd>
<dt>法律体系</dt><dd>{regime}{method}</dd>
<dt>投标截止</dt><dd class="fig">{when(p.get("deadline_at"))}</dd>
<dt>所投采购包</dt><dd>{_e("、".join(p.get("lots") or []) or "整个项目")}</dd>
<dt>投标文件</dt><dd>{docs}</dd>
<dt>检查依据</dt><dd>{basis}</dd>
<dt>检查时间</dt><dd>{checked_at}({run_no})</dd>
<dt>规则版本</dt><dd class="fig">{_e(r.get("rule_set_version"))}</dd>
<dt>导出</dt><dd>{_e(d["exported_by"])} · <span class="fig">{when(d["exported_at"])}</span></dd>
</dl>
{alert}
<h2>检查覆盖范围</h2>
<p><b>查了:</b></p><ul>{"".join(f"<li>{escape(x)}</li>" for x in cov.get("checked") or [])}</ul>
<p><b>没查:</b></p><ul>{"".join(f"<li>{escape(x)}</li>" for x in cov.get("not_checked") or [])}</ul>
<h2>汇总</h2>
<table><thead><tr><th>级别</th><th class="num">问题数</th><th class="num">待处理</th><th class="num">已确认无误</th>
<th class="num">已修改</th><th class="num">已忽略</th></tr></thead><tbody>{"".join(rows)}</tbody></table>
<p class="cap">本次已解决 <span class="fig">{d["resolved_this_run"]}</span> 项(上次检查有、这次没有再发现)。</p>
<div class="disc"><b>免责声明</b>{DISCLAIMER}</div>
"""
def findings(d: dict[str, Any]) -> str:
rows = []
for f in d["findings"]:
cites = ""
if d["options"].get("citations", True):
parts = [f"<div>招标文件:{_cite(c)}</div>" for c in f.get("tender_citations") or []]
parts += [f"<div>投标文件:{_cite(c)}</div>" for c in f.get("bid_citations") or []]
cites = f"<div class='cites'>{''.join(parts)}</div>" if parts else ""
carried = "(沿用上次检查)" if f.get("presence") == "carried" else ""
dup = f"(同一要求在清单里出现 {len(f['duplicate_ids']) + 1} 处)" if f.get("duplicate_ids") else ""
rows.append(
f"<tr><td><span class='sev {f['severity']}'>{SEVERITY[f['severity']]}</span></td>"
f"<td><b>{escape(f['title'])}</b>{carried}{dup}<br>{escape(f['explanation'])}{cites}</td>"
f"<td>{_status(f)}</td></tr>"
)
body = (
"".join(rows)
or "<tr><td colspan='3'>本次检查范围内没有发现问题(不代表投标文件没有问题,没查的内容见首页)。</td></tr>"
)
return f"""
<h2>问题明细</h2>
<table><thead><tr><th style="width:15mm">级别</th><th>问题</th><th style="width:38mm">处理</th></tr></thead>
<tbody>{body}</tbody></table>
"""
def appendix(d: dict[str, Any]) -> str:
if not d["options"].get("manual_checks", True):
return ""
rows = []
for m in d["manual"]:
cite = _cite(m["citation"]) if m.get("citation") else "—"
state = (
f"已核对<span class='who'>{escape((m.get('checked_by') or {}).get('name') or '')} · "
f"<span class='fig'>{when(m.get('checked_at'))}</span></span>"
if m.get("checked")
else "未勾选"
)
rows.append(
f"<tr><td><b>{escape(m['label'])}</b><br><span class='cap'>{_e(m.get('requirement_text'))}</span></td>"
f"<td class='cites'>{cite}</td><td>{state}</td></tr>"
)
body = "".join(rows) or "<tr><td colspan='3'>清单里没有份数、密封、递交这类需人工核对的要求。</td></tr>"
return f"""
<h2>附录 · 需人工核对</h2>
<p class="cap">这些要求在投标文件里看不出来,需要投标人递交前自己核对。
「未勾选」表示导出时还没有人在系统里勾选核对。</p>
<table><thead><tr><th>要求</th><th style="width:48mm">出处</th><th style="width:30mm">核对</th></tr></thead>
<tbody>{body}</tbody></table>
"""
def page(d: dict[str, Any]) -> str:
return (
f'<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><title>{TITLE}</title><style>{CSS}</style>'
f"</head><body>{cover(d)}{findings(d)}{appendix(d)}</body></html>"
)
def _band(left: str, right: str, line: str) -> str:
"""页眉 / 页脚的一条:左右两端各一段。Chromium 渲染页眉页脚时 flex 不撑满宽度,用表格。"""
return f"""<!doctype html><html><head><meta charset="utf-8"><style>
html, body {{ margin: 0; padding: 0; width: 100%; -webkit-print-color-adjust: exact; }}
table {{ width: 100%; border-collapse: collapse; margin: 0 20mm; width: calc(100% - 40mm);
font-family: "Noto Sans CJK SC", sans-serif; font-size: 8pt; color: #5C6670; }}
td {{ padding: 0 0 3pt; {line} }}
td.r {{ text-align: right; }}
img {{ height: 18px; display: block; }}
</style></head><body><table><tr><td>{left}</td><td class="r">{right}</td></tr></table></body></html>"""
def header() -> str:
"""每页的页眉:左侧小号横版 logo(干净版),右侧报告名。logo 只在这里出现。"""
return _band(f'<img src="{_logo()}" alt="标博士">', TITLE, "border-bottom: 0.5pt solid #D6D8DB;")
def footer() -> str:
return _band(FOOTER, '<span class="pageNumber"></span> / <span class="totalPages"></span>', "")

View File

@@ -35,6 +35,13 @@ async def check_project(tenant_id: str, run_id: str) -> None:
await runner.run(UUID(tenant_id), UUID(run_id))
@app.task(name="generate_report", retry=False)
async def generate_report(tenant_id: str, job_id: str) -> None:
from bid_doctor.report import runner
await runner.run(UUID(tenant_id), UUID(job_id))
async def run_worker() -> None:
async with app.open_async():
await app.run_worker_async(concurrency=4, install_signal_handlers=True)