Files
bid-doctor/server/tests/test_report_flow.py
YANG JIANKUAN 08e3fe7eff
Some checks failed
ci / server (push) Has been cancelled
ci / web (push) Has been cancelled
ci / contract (push) Has been cancelled
feat(qualification): 资质库和资质匹配(查标第 4 步,规则集 2026.11-r4)
- 资质库(FR-M6-08):增删改、证书扫描件(PDF/JPG/PNG,企业级密钥加密、签名下载)、
  late_for 按进行中项目的投标截止日提醒、审计;前端资质库页和对话框,侧栏入口
- 资质匹配(FR-M3-05):证书类别词表(16 类,产品证书、人员证书不进匹配),
  QUAL.EXPIRED / LEVEL_LOW / NOT_COVERED;投标文字层按清单项定级,只有资质库记录的降一级,
  出处显示「依据:资质库记录」(查标页、报告模板 v2);一类证书一个问题;评分项不逐条找响应的也查
- 响应错位规则不再把资质表的证书名称格当成照抄的要求
- 评测:qual_defects.py 派生资质情况表,D12 3/3、D18 2/2、规矩的资质表 0 误报;开发集不退化
- 决策 QL1–QL7;方案和落地记录 docs/architecture/qualification-plan.md,评测 check-eval.md §13

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 23:22:50 +08:00

159 lines
7.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""检查报告(查标第 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"
def test_template_shows_library_basis_for_qualification_findings() -> None:
"""只按资质库记录判断的资质问题,问题明细里写出依据的那条记录(QL2)。"""
f = {
"type": "qualification",
"detail": {
"issues": [
{
"source": "library",
"library": {"name": "安全生产许可证", "cert_no": "X-1", "valid_to": "2026-10-31"},
},
{"source": "bid_text"},
]
},
}
assert template._library(f) == ["<div>依据:资质库记录《安全生产许可证》 · 编号 X-1 · 有效期至 2026-10-31</div>"] # pyright: ignore[reportPrivateUsage]
assert template._library({"type": "unresponded", "detail": {}}) == [] # pyright: ignore[reportPrivateUsage]