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

@@ -0,0 +1,140 @@
"""检查报告(查标第 5 步,FR-M3-09):导出 → 后台生成 PDF → 列表 → 下载;致命问题未处理也能导出(红线 4);
模板文字不出现「通过 / 合格 / 保证」(设计规范 §7.17)。需要 Gotenberg。"""
from __future__ import annotations
import hashlib
import io
from datetime import UTC, datetime
from urllib.parse import urlsplit
from uuid import UUID
import pypdfium2 as pdfium
from bid_doctor.report import template
from tests.conftest import User
from tests.test_check_flow import checked_project
FORBIDDEN = ("通过", "合格", "保证")
async def export(u: User, run_id: str, **body: bool) -> dict[str, object]:
from bid_doctor.report import runner
r = await u.post(f"/check-runs/{run_id}/reports", json=body or None)
assert r.status_code == 202, r.text
job = r.json()
assert job["status"] == "queued" and job["report_id"] is None
await runner.run(UUID(u.tenant_id), UUID(job["id"]))
return (await u.get(f"/report-jobs/{job['id']}")).json()
def pdf_text(data: bytes) -> str:
pdf = pdfium.PdfDocument(io.BytesIO(data))
return "".join(pdf[i].get_textpage().get_text_range() for i in range(len(pdf)))
async def test_export_report_and_download(user: User) -> None:
project_id, _, run = await checked_project(user)
assert run["counts"]["open_fatal"] == 1
job = await export(user, run["id"])
assert job["status"] == "succeeded" and job["report_id"], job
(rep,) = (await user.get(f"/projects/{project_id}/reports")).json()["items"]
assert rep["id"] == job["report_id"] and rep["run_no"] == 1 and rep["page_count"] >= 2
assert rep["open_fatal_at_export"] == 1 and rep["template_version"] == template.TEMPLATE_VERSION
link = (await user.get(f"/reports/{rep['id']}/file")).json()
parts = urlsplit(link["url"])
got = await user.client.get(parts.path.removeprefix("/api/v1") + "?" + parts.query)
assert got.status_code == 200 and got.headers["content-type"] == "application/pdf"
assert hashlib.sha256(got.content).hexdigest() == rep["sha256"] == link["sha256"]
text = pdf_text(got.content)
for want in ("检查覆盖范围", "免责声明", "问题明细", template.FOOTER, "交换机支持硬件BFD"):
assert want in text.replace("\r", "").replace("\n", ""), want
# 重复导出就是新的一份,旧的还在(随项目归档)
await export(user, run["id"], citations=False, manual_checks=False)
assert len((await user.get(f"/projects/{project_id}/reports")).json()["items"]) == 2
notes = (await user.get("/notifications")).json()["items"]
assert notes[0]["kind"] == "report_succeeded"
async def test_cannot_export_unfinished_run_or_other_tenant(user: User, app_client: object) -> None:
from sqlalchemy import select
from bid_doctor.check.models import CheckRun
from bid_doctor.core.db import TenantContext, tenant_tx
project_id, _, run = await checked_project(user)
ctx = TenantContext(tenant_id=UUID(user.tenant_id), user_id=UUID(str(user.me["user"]["id"])))
async with tenant_tx(ctx) as s:
r = (await s.execute(select(CheckRun).where(CheckRun.id == UUID(run["id"])))).scalar_one()
r.status = "failed"
resp = await user.post(f"/check-runs/{run['id']}/reports")
assert resp.status_code == 409 and resp.json()["code"] == "run_not_succeeded"
del project_id, app_client
def test_template_text_has_no_conclusion_words() -> None:
"""模板自己的文字(标题、页脚、免责声明、表头、提示)不出现「通过 / 合格 / 保证」;原文引用不在此列。"""
data = {
"project": {
"name": "某市数据中心扩容项目",
"code": "ZC-1",
"purchaser": "某市大数据局",
"legal_regime": "gov_procurement",
"procurement_method": "公开招标",
"deadline_at": datetime(2026, 12, 1, 9, 30, tzinfo=UTC),
"lots": [],
},
"run": {
"run_no": 2,
"created_at": datetime.now(UTC),
"finished_at": datetime.now(UTC),
"rule_set_version": "r",
"checklist_version_no": 1,
"checklist_confirmed": False,
"coverage": {"checked": [], "not_checked": []},
"documents": [{"label": "技术分册", "filename": "a.pdf"}],
},
"summary": {
s: {"total": 0, "pending": 0, "confirmed_ok": 0, "modified": 0, "ignored": 0}
for s in ("fatal", "important", "hint")
},
"resolved_this_run": 0,
"findings": [],
"manual": [],
"options": {},
"exported_by": "张工",
"exported_at": datetime.now(UTC),
}
html = template.page(data) + template.header() + template.footer()
for w in FORBIDDEN:
assert w not in html, w
async def test_project_submit_archive_and_undo(user: User) -> None:
"""项目状态流(FR-M6-02):致命问题未处理也能递交(红线 4),递交后只读、可撤销;
归档后撤销回到归档前的状态;都留痕。"""
project_id, _, run = await checked_project(user)
assert run["counts"]["open_fatal"] == 1
r = await user.post(f"/projects/{project_id}/transitions", json={"action": "submit"})
assert r.status_code == 200 and r.json()["status"] == "submitted", r.text
# 只读:不能再发起检查、勾选需人工核对
blocked = await user.post(
f"/projects/{project_id}/checklist/items", json={"categories": ["schedule"], "title": "x"}
)
assert blocked.status_code == 409 and blocked.json()["code"] == "project_read_only"
# 报告照样能导出(随项目归档)
assert (await export(user, run["id"]))["status"] == "succeeded"
assert (await user.post(f"/projects/{project_id}/transitions", json={"action": "submit"})).status_code == 409
r = await user.post(f"/projects/{project_id}/transitions", json={"action": "archive"})
assert r.json()["status"] == "archived"
r = await user.post(f"/projects/{project_id}/transitions", json={"action": "unarchive"})
assert r.json()["status"] == "submitted" # 回到归档前
r = await user.post(f"/projects/{project_id}/transitions", json={"action": "unsubmit"})
assert r.json()["status"] == "checked"