feat(qualification): 资质库和资质匹配(查标第 4 步,规则集 2026.11-r4)
Some checks failed
ci / server (push) Has been cancelled
ci / web (push) Has been cancelled
ci / contract (push) Has been cancelled

- 资质库(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>
This commit is contained in:
YANG JIANKUAN
2026-11-04 23:22:50 +08:00
parent e7fb797925
commit 08e3fe7eff
43 changed files with 2643 additions and 49 deletions

BIN
server/tests/fixtures/bid-mini-quals.pdf vendored Normal file

Binary file not shown.

View File

@@ -5,7 +5,8 @@
- bid-mini-v1.pdf:响应了资质证书和服务器内存,漏了交换机硬件 BFD → 1 个致命问题「未找到响应」;
- bid-mini-v2.pdf:补上了交换机硬件 BFD → 复查时这个问题「本次已解决」;
- bid-mini-forms.pdf:v2 的内容 + 格式文件里的规则问题(大写和小写金额不一致、授权书没填投标人名称、
报价附加条件、另外给了一个优惠报价),措辞故意和评测集的开发词库不同。
报价附加条件、另外给了一个优惠报价),措辞故意和评测集的开发词库不同;
- bid-mini-quals.pdf:v2 的内容 + 企业资质情况表(证书、编号、有效期,全部虚构),资质匹配的端到端测试用。
"""
from __future__ import annotations
@@ -53,7 +54,16 @@ FORMS = [
"<p>七、本次报价未包含设备运输费用,运输费用由采购人另行承担。</p>",
]
QUALS = [
"<p>五、企业资质情况表</p>",
"<table><tr><th>证书名称</th><th>证书编号</th><th>等级</th><th>有效期</th></tr>"
"<tr><td>安全生产许可证</td><td>(测)JZ安许证字〔2023〕000001</td><td>—</td><td>2023年11月16日至2026年11月15日</td></tr>"
"<tr><td>ISO9001质量管理体系认证证书</td><td>00000Q00001</td><td>—</td><td>2025年3月10日至2028年3月9日</td></tr>"
"</table>",
]
if __name__ == "__main__":
render("bid-mini-v1.pdf", [*COMMON, *TAIL])
render("bid-mini-v2.pdf", [*COMMON, BFD, *TAIL])
render("bid-mini-forms.pdf", [*COMMON, BFD, *FORMS, *TAIL])
render("bid-mini-quals.pdf", [*COMMON, BFD, *QUALS, *TAIL])

View File

@@ -11,6 +11,7 @@ from uuid import UUID
import httpx
from sqlalchemy import text
from bid_doctor.check import service
from tests.conftest import FIXTURES, User, balance, project_with_tender, register, run_parse, upload
@@ -297,7 +298,7 @@ async def test_rule_findings_are_written_with_citations_and_carried(user: User)
# 「让利后总价」和投标总价不同:多半另报了一个价,但不是「备选方案」那种明确的两个报价,按重要
assert rule["PRICE.MULTIPLE_QUOTES"]["severity"] == "important" and rule["FORM.BLANK_FIELD"]["severity"] == "hint"
assert any("报价形式" in x for x in run["coverage"]["checked"])
assert run["coverage"]["rule_set_version"] == "2026.11-r3"
assert run["coverage"]["rule_set_version"] == service.RULE_SET_VERSION
await user.post(f"/findings/{cap['id']}/resolve", json={"status": "ignored", "note": "大写栏已手工更正"})
again = await run_check(user, project_id, [bid["id"]])

View File

@@ -0,0 +1,287 @@
"""资质匹配(check/qualification.py,qualification-plan §3):纯函数,投标文件和资质库都是虚构的。
要求的写法按招标文件的通用说法改写,不照抄样本原文;每条规则至少配一个反例(不该报的写法)。
"""
from __future__ import annotations
from datetime import date
from typing import Any
from uuid import UUID, uuid4
from bid_doctor.check import findings, match, qualification
from bid_doctor.check.qualification import BY_KEY, EXCLUDE, families_in, level_rank, text_of
from bid_doctor.qualification.service import Record
from tests.test_check_rules import cell, doc, para
DEADLINE = date(2026, 11, 20)
def keys(text: str) -> list[str]:
return [f.key for f in families_in(text)]
def test_families_levels_and_exclusions() -> None:
assert keys("投标人须具有有效的电子与智能化工程专业承包二级及以上资质") == ["eie"]
assert keys("具有 GB/T 19001 质量管理体系认证证书得 2 分") == ["iso9001"]
assert keys("提供 ISO/IEC 27001:2022 信息安全管理体系认证证书") == ["iso27001"]
assert keys("具备信息技术服务管理体系认证(ISO20000)") == ["iso20000"]
assert keys("具有 CS3 级及以上信息系统建设和服务能力等级证书") == ["cs"]
assert keys("信息安全服务资质(安全集成类)一级") == ["ccrc"]
assert keys("信息系统业务安全服务资质(CCIA)二级") == ["ccia"] # 不能当成 CCRC
assert keys("医疗器械质量管理体系认证证书") == [] # 产品体系,不是 ISO 9001
assert keys("具有类似项目业绩") == []
cs, cmmi, eie, ccrc, secret = (BY_KEY[k] for k in ("cs", "cmmi", "eie", "ccrc", "secret"))
assert level_rank(cs, "CS3级及以上") == (3, "CS3")
assert level_rank(cmmi, "CMMI 三级或以上")[0] == 3 # type: ignore[index]
assert level_rank(eie, "二级及以上")[0] < level_rank(eie, "一级")[0] # type: ignore[index]
assert level_rank(ccrc, "一级(安全集成)")[0] > level_rank(ccrc, "三级")[0] # type: ignore[index]
assert level_rank(secret, "乙级")[0] < level_rank(secret, "甲级")[0] # type: ignore[index]
assert level_rank(cs, "等级证书") is None
for t in ("所投产品须具有节能产品认证证书", "项目经理须具有信息系统项目管理师证书", "提供原厂 CCC 认证证书"):
assert EXCLUDE.search(text_of(t)), t
def item(title: str, cats: list[str], importance: str = "normal") -> dict[str, Any]:
return {"title": title, "requirement_text": title, "categories": cats, "importance": importance, "citation_ids": []}
def result(verdict: str) -> match.Result:
return match.Result(verdict=verdict, layer="model")
def rec(name: str, valid_to: date | None, level: str | None = None, kind: str = "system_cert") -> Record:
return Record(uuid4(), name, kind, level, "NO-001", valid_to)
def cert_table(name: str, level: str, valid: str) -> list[qualification.Sentence]:
"""虚构的「企业资质情况表」:表头一行 + 一行证书。"""
return [
cell(1, "证书名称", 80, 100, w=120),
cell(2, "等级", 210, 100, w=40),
cell(3, "有效期", 260, 100, w=160),
cell(4, name, 80, 124, w=120),
cell(5, level, 210, 124, w=40),
cell(6, valid, 260, 124, w=160),
]
def run(
items: dict[UUID, dict[str, Any]],
results: dict[UUID, match.Result],
sents: list[qualification.Sentence],
library: list[Record],
deadline: date | None = DEADLINE,
) -> tuple[list[qualification.Hit], qualification.Coverage]:
return qualification.run(items, results, [doc(sents)] if sents else [], library, deadline)
def test_expired_from_bid_text_uses_deadline_not_today() -> None:
a, b = uuid4(), uuid4()
items = {
a: item("投标人须具有有效的安全生产许可证", ["qualification", "invalidity"]),
b: item("具有 ISO 9001 质量管理体系认证(2 分)", ["scoring"]),
}
sents = [
*cert_table("安全生产许可证", "—", "2023年11月16日至2026年11月15日"),
cell(7, "ISO9001质量管理体系认证证书", 80, 148, w=120),
cell(8, "—", 210, 148, w=40),
cell(9, "2025-03-10至2026-11-20", 260, 148, w=160), # 截止日当天到期仍算有效
]
hits, cov = run(items, {a: result("responded"), b: result("responded")}, sents, [])
(h,) = hits
assert h.code == "QUAL.EXPIRED" and h.item == a and h.severity == "fatal" and h.type == "qualification"
assert h.detail["source"] == "bid_text" and h.detail["issues"][0]["bid_valid_to"] == "2026-11-15"
assert "2026年11月15日" in h.explanation and "2026年11月20日" in h.explanation
assert {s.text for _, s in h.evidence} >= {"安全生产许可证", "2023年11月16日至2026年11月15日"}
assert "请" not in h.explanation # 只写事实,不写整改建议
assert cov.checked == 2 and cov.by_library == 0
assert "资质库还没有录入资质" in cov.to_api()["not_checked"][0]
# 没有截止日就不比有效期
hits, cov = run(items, {a: result("responded"), b: result("responded")}, sents, [], deadline=None)
assert hits == [] and cov.no_deadline
def test_library_only_lowers_severity_and_replaces_scanned_fallback() -> None:
a = uuid4()
items = {a: item("投标人须具有有效的安全生产许可证", ["qualification"])}
res = {a: result("scanned")}
hits, cov = run(
items,
res,
[para(1, "附件:资格证明文件(扫描件)")],
[rec("安全生产许可证", date(2026, 10, 31), kind="certificate")],
)
(h,) = hits
assert h.code == "QUAL.EXPIRED" and h.severity == "important" and h.detail["source"] == "library"
assert h.evidence == [] and h.detail["issues"][0]["library"]["valid_to"] == "2026-10-31"
assert "按资质库记录" in h.explanation
assert findings.spec(res[a], items[a]) is None # 扫描件兜底那条不再单独出
assert cov.by_library == 1
# 投标文件是文字版、确实没找到:保留「未找到响应」,只补一句资质库的情况
res = {a: result("not_found")}
hits, _ = run(items, res, [para(1, "目录")], [rec("安全生产许可证", date(2026, 10, 31), kind="certificate")])
spec = findings.spec(res[a], items[a])
assert hits == [] and spec is not None and spec.type == "unresponded" and "资质库里的" in spec.explanation
def test_bid_text_wins_over_library() -> None:
a = uuid4()
items = {a: item("具有 ISO 27001 信息安全管理体系认证证书(2 分)", ["scoring"])}
sents = cert_table("ISO27001信息安全管理体系认证", "—", "有效期至2026年11月15日")
hits, _ = run(
items, {a: result("responded")}, sents, [rec("ISO/IEC 27001 信息安全管理体系认证", date(2029, 11, 15))]
)
(h,) = hits
assert h.detail["source"] == "bid_text" and h.severity == "important" and "可能是旧证书" in h.explanation
# 投标文件里的有效期没过期:资质库里的旧记录不影响
sents = cert_table("ISO27001信息安全管理体系认证", "—", "2025-01-01至2028-01-01")
hits, _ = run(items, {a: result("responded")}, sents, [rec("ISO 27001", date(2025, 1, 1))])
assert hits == []
def test_level_low_and_uncomparable_levels() -> None:
a = uuid4()
items = {a: item("具有 CS3 级及以上信息系统建设和服务能力等级证书", ["qualification"])}
sents = cert_table("信息系统建设和服务能力等级证书", "CS2", "2024-01-01至2028-01-01")
(h,) = run(items, {a: result("responded")}, sents, [])[0]
assert h.code == "QUAL.LEVEL_LOW" and h.severity == "fatal" and "CS2" in h.explanation
sents = cert_table("信息系统建设和服务能力等级证书", "CS4", "2024-01-01至2028-01-01")
assert run(items, {a: result("responded")}, sents, [])[0] == []
# 资质库的等级写法认不出:不比较
lib = [rec("信息系统建设和服务能力等级证书", date(2028, 1, 1), level="良好", kind="certificate")]
assert run(items, {a: result("scanned")}, [para(1, "资格证明")], lib)[0] == []
lib = [rec("信息系统建设和服务能力等级证书", date(2028, 1, 1), level="CS2", kind="certificate")]
(h,) = run(items, {a: result("scanned")}, [para(1, "资格证明")], lib)[0]
assert h.code == "QUAL.LEVEL_LOW" and h.severity == "important" and h.detail["source"] == "library"
def test_not_covered_only_with_nonempty_library() -> None:
a = uuid4()
items = {a: item("具有 ISO 20000 信息技术服务管理体系认证(2 分)", ["scoring"])}
lib = [rec("ISO 9001 质量管理体系认证", date(2028, 1, 1))]
res = {a: result("scanned")}
(h,) = run(items, res, [para(1, "资格证明")], lib)[0]
assert h.code == "QUAL.NOT_COVERED" and h.severity == "important" and findings.spec(res[a], items[a]) is None
# 资质库是空的:不报,只写覆盖范围(QL3)
res = {a: result("scanned")}
hits, cov = run(items, res, [para(1, "资格证明")], [])
assert hits == [] and cov.library == 0
assert findings.spec(res[a], items[a]) is not None # 扫描件兜底照常出
# 资质库里有、有效:兜底说明里补一句是哪一张
res = {a: result("scanned")}
lib = [rec("ISO/IEC 20000-1 信息技术服务管理体系认证", date(2028, 1, 1))]
assert run(items, res, [para(1, "资格证明")], lib)[0] == []
spec = findings.spec(res[a], items[a])
assert spec is not None and "资质库里有《ISO/IEC 20000-1" in spec.explanation
def test_exclusions_requirement_copies_and_ambiguous_rows() -> None:
a, b, c = uuid4(), uuid4(), uuid4()
items = {
a: item("所投产品须具有节能产品认证证书", ["qualification", "invalidity"]),
b: item("项目负责人须持有 ISO 27001 审核员证书", ["scoring"]),
c: item("具有有效的 ISO 9001 质量管理体系认证证书(2 分)", ["scoring"]),
}
lib = [rec("ISO 20000", date(2028, 1, 1))]
# 响应表里照抄的要求不算证书事实;一行里两类证书时日期归属不清,不读
sents = [
para(1, "具有有效的ISO9001质量管理体系认证证书(2分),有效期至2025年1月1日"),
*cert_table("ISO9001、ISO27001认证证书", "—", "有效期至2025年1月1日"),
]
hits, cov = run(items, {a: result("scanned"), b: result("scanned"), c: result("responded")}, sents, lib)
assert hits == [] and cov.excluded == 2 and cov.checked == 1
def test_software_copyright_and_ccrc_subtype() -> None:
a, b = uuid4(), uuid4()
items = {
a: item("提供计算机软件著作权登记证书(每件 1 分)", ["scoring"]),
b: item("具有信息安全服务资质(安全集成)一级证书", ["scoring"]),
}
lib = [
rec("某平台软件 V2.0", None, kind="software_copyright"),
rec("CCRC 信息安全服务资质一级", date(2028, 1, 1), level="一级", kind="certificate"),
]
res = {a: result("scanned"), b: result("responded")}
(h,) = run(items, res, [para(1, "资质证明文件")], lib)[0]
assert h.item == b and h.code == "QUAL.NOT_COVERED" and h.severity == "hint" and "安全集成" in h.explanation
spec = findings.spec(res[a], items[a])
assert spec is not None and "1 件软件著作权" in spec.explanation # 软著不查有效期,只补说明
def test_coverage_counts_unrecognized_certificates_only() -> None:
a, b, c = uuid4(), uuid4(), uuid4()
items = {
a: item("提供有效的营业执照副本或事业单位法人证书", ["qualification"]),
b: item("具有某行业协会颁发的服务能力认证证书(2 分)", ["scoring"]),
c: item("具有类似项目业绩(每个 1 分)", ["scoring"]),
}
_, cov = run(items, {}, [], [])
assert (cov.unrecognized, cov.checked, cov.excluded) == (1, 0, 0)
assert any("1 条资质要求没认出" in x for x in cov.to_api()["not_checked"])
def test_scoring_items_not_searched_still_get_library_checks() -> None:
"""评分项没逐条找响应(results 里没有):资质库的过期照常提示;「未覆盖」不报(不知道投标人报不报这一项分)。"""
a, b = uuid4(), uuid4()
items = {
a: item("具有 ISO 27001 信息安全管理体系认证(2 分)", ["scoring"]),
b: item("具有 ISO 22301 业务连续性管理体系认证(1 分)", ["scoring"]),
}
lib = [rec("ISO/IEC 27001 信息安全管理体系认证", date(2026, 10, 31))]
(h,) = run(items, {}, [para(1, "商务部分")], lib)[0]
assert h.item == a and h.code == "QUAL.EXPIRED" and h.severity == "important" and h.detail["source"] == "library"
def test_levels_read_per_certificate_segment() -> None:
"""一条评分项里写了几类证书时,各自的等级从各自那一段读;大写数字的等级(贰级)也认。"""
itss, cs, eie = BY_KEY["itss"], BY_KEY["cs"], BY_KEY["eie"]
req = "企业资信:具有 ITSS 运行维护标准符合性证书的,三级得 1 分,二级及以上得 2 分;具有 CMMI 5 级证书的得 1 分"
assert level_rank(itss, qualification.segment(itss, req)) == (7, "三级")
assert qualification.segment(cs, req) == text_of(req) # 没有这一类:全文
assert level_rank(eie, "电子与智能化工程专业承包贰级")[0] == level_rank(eie, "二级")[0] # type: ignore[index]
def test_one_issue_per_certificate_type() -> None:
"""一条评分项里两类证书:一个过期、一个等级不够,分成两个问题,标题写证书名。"""
a = uuid4()
items = {a: item("企业资信:ITSS 运维证书三级及以上得 1 分;CS3 及以上得 2 分", ["scoring"])}
sents = [
*cert_table("信息技术服务标准(ITSS)运行维护符合性证书", "二级", "2023-06-01至2026-05-31"),
cell(7, "信息系统建设和服务能力等级证书", 80, 148, w=120),
cell(8, "CS2", 210, 148, w=40),
cell(9, "2024-12-31至2028-12-30", 260, 148, w=160),
]
hits, _ = run(items, {a: result("responded")}, sents, [])
assert sorted((h.code, h.target) for h in hits) == [("QUAL.EXPIRED", f"{a}:itss"), ("QUAL.LEVEL_LOW", f"{a}:cs")]
assert {h.title for h in hits} == {
"证书在投标截止日已过期:ITSS 信息技术服务运行维护标准符合性证书",
"证书等级低于要求:信息系统建设和服务能力等级",
}
def test_level_column_wins_over_certificate_number() -> None:
a = uuid4()
items = {a: item("具有 CS3 级及以上信息系统建设和服务能力等级证书", ["qualification"])}
sents = [
cell(1, "证书名称", 80, 100, w=120),
cell(2, "证书编号", 210, 100, w=100),
cell(3, "等级", 320, 100, w=40),
cell(4, "有效期", 370, 100, w=120),
cell(5, "信息系统建设和服务能力等级证书", 80, 124, w=120),
cell(6, "CS3-3201-2024000077", 210, 124, w=100),
cell(7, "CS2", 320, 124, w=40),
cell(8, "2024-12-31至2028-12-30", 370, 124, w=120),
]
(h,) = run(items, {a: result("responded")}, sents, [])[0]
assert h.code == "QUAL.LEVEL_LOW" and "「CS2」" in h.explanation

View File

@@ -0,0 +1,189 @@
"""资质库(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,
}

View File

@@ -138,3 +138,21 @@ async def test_project_submit_archive_and_undo(user: User) -> None:
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]