Files
bid-doctor/server/tests/test_qualification_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

190 lines
9.2 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.

"""资质库(FR-M6-08,qualification-plan §2):增删改、附件(企业级密钥加密、签名下载)、到期提醒按进行中项目的
投标截止日算、审计、企业之间隔离。"""
from __future__ import annotations
from datetime import UTC, date, datetime, timedelta
from urllib.parse import urlsplit
from uuid import UUID
import httpx
from tests.conftest import User, register
PNG = b"\x89PNG\r\n\x1a\n" + b"\x00" * 64
async def attach(u: User, qid: str, filename: str, data: bytes) -> httpx.Response:
r = await u.post("/uploads", json={"filename": filename, "size_bytes": len(data), "purpose": "qualification_file"})
assert r.status_code == 201, r.text
up = r.json()
assert (await u.put(f"/uploads/{up['id']}/parts/1", content=data)).status_code == 204
done = await u.post(f"/uploads/{up['id']}/complete")
if done.status_code != 200:
return done
return await u.post(f"/qualifications/{qid}/files", json={"upload_id": up["id"]})
async def set_deadline(u: User, project_id: str, at: datetime) -> None:
from sqlalchemy import update
from bid_doctor.core.db import TenantContext, tenant_tx
from bid_doctor.project.models import Project
ctx = TenantContext(tenant_id=UUID(u.tenant_id), user_id=UUID(str(u.me["user"]["id"])))
async with tenant_tx(ctx) as s:
await s.execute(update(Project).where(Project.id == UUID(project_id)).values(deadline_at=at))
async def test_qualification_crud_files_and_late_for(user: User) -> None:
body = {
"name": "ISO/IEC 27001 信息安全管理体系认证",
"kind": "system_cert",
"cert_no": " 01226IS0458 ",
"issuer": "",
"valid_from": "2023-11-16",
"valid_to": "2026-11-15",
}
r = await user.post("/qualifications", json=body)
assert r.status_code == 201, r.text
q = r.json()
assert q["cert_no"] == "01226IS0458" and q["issuer"] is None and q["files"] == [] and q["late_for"] == []
bad = await user.put(f"/qualifications/{q['id']}", json={**body, "valid_to": "2023-01-01"})
assert bad.status_code == 422
# 进行中项目的截止日晚于有效期至 → late_for;已递交的项目不算
today = datetime.now(UTC).date()
soon = today + timedelta(days=20)
late_project = (await user.post("/projects", json={})).json()["id"]
await set_deadline(user, late_project, datetime(soon.year, soon.month, soon.day, 1, 30, tzinfo=UTC))
r = await user.put(f"/qualifications/{q['id']}", json={**body, "valid_to": (soon - timedelta(days=1)).isoformat()})
assert r.status_code == 200, r.text
assert [x["project_id"] for x in r.json()["late_for"]] == [late_project]
r = await user.put(f"/qualifications/{q['id']}", json={**body, "valid_to": soon.isoformat()})
assert r.json()["late_for"] == [] # 截止日当天到期仍算有效
long_term = (
await user.post("/qualifications", json={"name": "政务大厅排队叫号系统 V3.0", "kind": "software_copyright"})
).json()
items = (await user.get("/qualifications")).json()["items"]
assert [x["id"] for x in items] == [q["id"], long_term["id"]] # 长期有效的排最后
assert [x["id"] for x in (await user.get("/qualifications", params={"q": "IS0458"})).json()["items"]] == [q["id"]]
kind = (await user.get("/qualifications", params={"kind": "software_copyright"})).json()["items"]
assert [x["id"] for x in kind] == [long_term["id"]]
# 附件:PNG 可以,Word 不行;下载回来是原文件
r = await attach(user, q["id"], "ISO27001 证书.png", PNG)
assert r.status_code == 201, r.text
(f,) = r.json()["files"]
assert f["filename"] == "ISO27001 证书.png" and f["size_bytes"] == len(PNG)
rejected = await attach(user, q["id"], "证书.docx", b"PK\x03\x04" + b"\x00" * 30)
assert rejected.status_code == 422
link = (await user.get(f"/qualifications/{q['id']}/files/{f['id']}")).json()
parts = urlsplit(link["url"])
got = await user.client.get(parts.path.removeprefix("/api/v1") + "?" + parts.query)
assert got.status_code == 200 and got.content == PNG and got.headers["content-type"] == "image/png"
tampered = await user.client.get(parts.path.removeprefix("/api/v1") + "?" + parts.query.replace("sig=", "sig=0"))
assert tampered.status_code == 404
# 删附件、删资质:对象一并删除,写审计
assert (await user.delete(f"/qualifications/{q['id']}/files/{f['id']}")).status_code == 204
r = await attach(user, q["id"], "ISO27001 证书.png", PNG)
key = await object_key(user, r.json()["files"][0]["id"])
from bid_doctor.core.storage import storage
assert storage().exists(key)
assert (await user.delete(f"/qualifications/{q['id']}")).status_code == 204
assert not storage().exists(key)
assert [x["id"] for x in (await user.get("/qualifications")).json()["items"]] == [long_term["id"]]
assert await audit_actions(user) >= {
"qualification.create",
"qualification.update",
"qualification.delete",
"qualification_file.attach",
"qualification_file.delete",
}
async def object_key(u: User, file_id: str) -> str:
from sqlalchemy import select
from bid_doctor.core.db import TenantContext, tenant_tx
from bid_doctor.qualification.models import QualificationFile
ctx = TenantContext(tenant_id=UUID(u.tenant_id), user_id=UUID(int=0))
async with tenant_tx(ctx) as s:
return (
await s.execute(select(QualificationFile.object_key).where(QualificationFile.id == UUID(file_id)))
).scalar_one()
async def audit_actions(u: User) -> set[str]:
from sqlalchemy import select
from bid_doctor.audit.models import AuditEvent
from bid_doctor.core.db import TenantContext, tenant_tx
ctx = TenantContext(tenant_id=UUID(u.tenant_id), user_id=UUID(int=0))
async with tenant_tx(ctx) as s:
return set((await s.execute(select(AuditEvent.action))).scalars())
async def test_qualifications_are_isolated(app_client: httpx.AsyncClient) -> None:
a = await register(app_client, "甲公司")
b = await register(app_client, "乙公司")
q = (
await a.post(
"/qualifications", json={"name": "ISO 9001", "kind": "system_cert", "valid_to": str(date(2028, 3, 9))}
)
).json()
assert (await b.get("/qualifications")).json()["items"] == []
assert (await b.put(f"/qualifications/{q['id']}", json={"name": "x", "kind": "other"})).status_code == 404
assert (await b.delete(f"/qualifications/{q['id']}")).status_code == 404
async def test_check_reports_qualification_issues(user: User) -> None:
"""端到端:投标文件的资质情况表里,安全生产许可证早于投标截止日到期 → 致命「证书在投标截止日已过期」,挂投标原文;
ISO 27001 是评分项(不逐条找响应),投标文件里没写、资质库里那张早于截止日 → 按资质库记录提示,降为重要。
资质情况表的名称格不能被当成「照抄的要求、响应对不上」。"""
from tests.conftest import project_with_tender, run_parse
from tests.test_check_flow import findings, run_check, upload_bid
project_id, tender_id = await project_with_tender(user)
assert (await run_parse(user, tender_id))["status"] == "succeeded"
await set_deadline(user, project_id, datetime(2026, 11, 20, 1, 30, tzinfo=UTC))
for title, cats in (
("投标人须具有有效的安全生产许可证", ["qualification"]),
("具有 ISO 27001 信息安全管理体系认证证书(2 分)", ["scoring"]),
):
r = await user.post(f"/projects/{project_id}/checklist/items", json={"categories": cats, "title": title})
assert r.status_code == 201, r.text
r = await user.post(
"/qualifications",
json={"name": "ISO/IEC 27001 信息安全管理体系认证", "kind": "system_cert", "valid_to": "2026-10-31"},
)
assert r.status_code == 201
bid = await upload_bid(user, project_id, "bid-mini-quals.pdf")
run = await run_check(user, project_id, [bid["id"]])
assert run["status"] == "succeeded", run
items = await findings(user, run["id"])
quals = {f["checklist_item"]["title"]: f for f in items if f["type"] == "qualification"}
safety = quals["投标人须具有有效的安全生产许可证"]
assert safety["rule"] == {"code": "QUAL.EXPIRED", "description": "证书有效期(按投标截止日)"}
assert safety["severity"] == "fatal" and safety["detail"]["source"] == "bid_text"
assert "2026年11月15日" in safety["explanation"] and "2026年11月20日" in safety["explanation"]
assert any("2026年11月15日" in c["quote"] for c in safety["bid_citations"])
iso = quals["具有 ISO 27001 信息安全管理体系认证证书(2 分)"]
assert iso["severity"] == "important" and iso["detail"]["source"] == "library" and iso["bid_citations"] == []
assert iso["detail"]["issues"][0]["library"]["valid_to"] == "2026-10-31"
assert not any(f["title"].startswith("响应可能对不上") for f in items)
assert any(x.startswith("资质核对") for x in run["coverage"]["checked"])
assert run["coverage"]["qualification"] == {
"library": 1,
"checked": 2,
"by_library": 1,
"unrecognized": 0,
"excluded": 0,
}