- 资质库(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>
190 lines
9.2 KiB
Python
190 lines
9.2 KiB
Python
"""资质库(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,
|
||
}
|