- 读标页:每条清单项写最近一次检查的结论,点投标侧出处看投标原文;
「已响应」也能反馈判断有误(漏报);证书要求写资质库里的情况
(新接口 GET /projects/{id}/qualification-matches,资质库空时不写)
- 概览:检查记录卡(历次检查、各级问题数、报告下载)、文件卡
- 工作台:「致命」列、统计可点筛选、待处理的致命问题卡(?f= 定位到问题)
- 日期输入加日历弹层(DateInput,Reka UI Calendar)
- 查标页:? 键显示快捷键说明;反馈对话框抽成 FeedbackDialog 共用
- 扫描件提示去掉多余空格
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
310 lines
17 KiB
Python
310 lines
17 KiB
Python
"""资质匹配(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
|
||
|
||
|
||
def test_library_matches_for_checklist_page() -> None:
|
||
"""读标清单上的资质库情况:认出类别的资质 / 评分条目给出有效期最晚的那条,按截止日判断过期;
|
||
产品证书、技术参数不给;资质库空的什么都不给(QL3)。"""
|
||
iso_old = Record(uuid4(), "ISO9001质量管理体系认证证书", "system_cert", None, "Q-1", date(2025, 6, 30))
|
||
iso_new = Record(uuid4(), "质量管理体系认证证书(GB/T 19001)", "system_cert", None, "Q-2", date(2028, 6, 30))
|
||
itss = Record(uuid4(), "ITSS 信息技术服务标准符合性证书", "certificate", "三级", "ITSS-9", date(2026, 3, 1))
|
||
items: list[dict[str, Any]] = [
|
||
{"id": "a", "categories": ["qualification"], "title": "具有有效的 ISO9001 质量管理体系认证证书"},
|
||
{"id": "b", "categories": ["scoring"], "title": "具有 ITSS 运行维护三级及以上证书得 2 分"},
|
||
{"id": "c", "categories": ["scoring"], "title": "具有 CMMI 三级及以上证书得 1 分"},
|
||
{"id": "d", "categories": ["scoring"], "title": "所投产品具有节能产品认证证书"},
|
||
{"id": "e", "categories": ["tech_param"], "title": "支持 ISO9001 质量管理体系认证的流程模板"},
|
||
]
|
||
got = qualification.library_matches(items, [iso_old, iso_new, itss], date(2026, 11, 20))
|
||
assert set(got) == {"a", "b", "c"}
|
||
(a,) = got["a"]
|
||
assert (a["state"], a["count"], a["record"]["cert_no"]) == ("valid", 2, "Q-2")
|
||
assert got["b"][0]["state"] == "expired"
|
||
assert (got["c"][0]["state"], got["c"][0]["record"]) == ("missing", None)
|
||
assert qualification.library_matches(items, [], date(2026, 11, 20)) == {}
|