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

View File

@@ -83,11 +83,11 @@ type = "forbidden"
source_modules = ["bid_doctor.core"]
forbidden_modules = ["bid_doctor.identity", "bid_doctor.billing", "bid_doctor.project", "bid_doctor.document",
"bid_doctor.checklist", "bid_doctor.check", "bid_doctor.ai", "bid_doctor.audit",
"bid_doctor.notification", "bid_doctor.ops"]
"bid_doctor.notification", "bid_doctor.ops", "bid_doctor.qualification"]
[[tool.importlinter.contracts]]
name = "业务模块不依赖 api 层的组合模块"
type = "forbidden"
source_modules = ["bid_doctor.document", "bid_doctor.checklist", "bid_doctor.check", "bid_doctor.billing",
"bid_doctor.identity.service", "bid_doctor.ops"]
"bid_doctor.identity.service", "bid_doctor.ops", "bid_doctor.qualification"]
forbidden_modules = ["bid_doctor.project.workbench", "bid_doctor.app"]

View File

@@ -24,6 +24,7 @@ def create_app() -> FastAPI:
from bid_doctor.notification.api import router as notification_router
from bid_doctor.ops.api import router as ops_router
from bid_doctor.project.api import router as project_router
from bid_doctor.qualification.api import router as qualification_router
from bid_doctor.report.api import router as report_router
settings = get_settings()
@@ -63,6 +64,7 @@ def create_app() -> FastAPI:
checklist_router,
check_router,
report_router,
qualification_router,
notification_router,
ops_router,
):

View File

@@ -210,6 +210,15 @@ def _similar(a: str, b: str) -> float:
return 2 * len(x & y) / (len(x) + len(y)) if x and y else 0.0
CERT_NAME = re.compile(r"(证书|许可证|资质|认证)$")
def _cert_name(text: str, item: dict[str, object]) -> bool:
"""资质情况表的名称格(「安全生产许可证」):比要求短、以证书名结尾,右边是编号、有效期,不是照抄的要求格。"""
t = norm(text)
return bool(CERT_NAME.search(t)) and len(t) < len(norm(str(item.get("title") or "")))
def mismatch_read(results: dict[UUID, Result], items: dict[UUID, dict[str, object]], docs: list[BidDoc]) -> int:
"""响应对象错位(D09):模型判「已响应」,但引的证据全是照抄要求的那一格,同一行右边的响应格写的是别的内容
(「满足,不额外收费」「显示器升降行程:150mm」),和要求一个字对都不重合 → 改判「未找到响应」,说明里写出
@@ -233,7 +242,7 @@ def mismatch_read(results: dict[UUID, Result], items: dict[UUID, dict[str, objec
if MARKER.match(norm(s.text)):
continue # 证据里带的「无偏离」「满足」标记格:跳过
row = rows.get(id(s), [])
if POSITIVE.match(norm(s.text)) or _similar(s.text, req) < 0.5 or not s.cell:
if POSITIVE.match(norm(s.text)) or _similar(s.text, req) < 0.5 or not s.cell or _cert_name(s.text, item):
break # 证据里有响应格本身,或不是照抄的要求格:不判
right = [c for c in row if c.cell and c.cell[0] >= s.cell[2] - 1 and not MARKER.match(norm(c.text))]
if not right:

View File

@@ -50,6 +50,16 @@ def _pages(pages: list[int]) -> str:
def spec(result: Result, item: dict[str, Any]) -> Spec | None:
out = _spec(result, item)
note = result.detail.get("qualification_note")
if out is None or not note:
return out
return Spec(out.type, out.severity, out.title, f"{out.explanation}{note}", out.detail)
def _spec(result: Result, item: dict[str, Any]) -> Spec | None:
if result.detail.get("superseded_by"):
return None # 资质匹配出了更具体的问题(check/qualification.py),扫描件兜底那条不再单独出
sev = severity(item)
name = str(item.get("title") or "")
base = {"importance_basis": item.get("importance_basis"), **result.detail}

View File

@@ -0,0 +1,488 @@
"""资质匹配(FR-M3-05,qualification-plan §3):证书在投标截止日有没有过期、等级够不够、有没有覆盖,不调模型(QL1)。
- 证书类别词表:IT 集成常见的企业资质,别名含国标等同写法;等级按类别比较(数字越大越高,或「一级」最高);
- 产品证书(节能认证、CCC、商用密码产品)和人员证书不进资质匹配:厂商、人员的证书不是用户的资质(红线 5);
- 证据两个来源:投标文字层(资质情况表那一行、写着有效期的那一句)按清单项定级;只有资质库记录的降一级(QL2);
- 投标文件和资质库冲突时以投标文件为准(QL4);资质库有记录但缺这一类才报「未覆盖」,空库不报(QL3);
- 一条清单项里每类证书出一个问题(过期和等级不足分开报);它比扫描件兜底的「请人工核对」更具体,有资质问题时代替那一条;
- 说明只写事实,不写整改建议;认不出类别、日期归属不清的不判断,写进覆盖范围。
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from datetime import date
from typing import Any
from uuid import UUID
from bid_doctor.check.findings import severity as item_severity
from bid_doctor.check.match import BidDoc, Result
from bid_doctor.check.rules import Hit, dates_in
from bid_doctor.document.pipeline import layout
from bid_doctor.document.pipeline.text_layer import Sentence, norm
from bid_doctor.qualification.service import Record
LABEL = "资质核对(证书有效期、等级、有没有覆盖)"
@dataclass(frozen=True, slots=True)
class Family:
key: str
label: str
pattern: re.Pattern[str] # 在 norm() 后转大写的文本上匹配
levels: str | None = None # asc:数字越大越高;cn:「一级」最高;jia:甲 > 乙 > 丙
prefix: str | None = None # 等级写法的前缀(CS3、CMMI3)
subtypes: tuple[str, ...] = () # 服务类别(CCRC 的安全集成、风险评估……)
def _f(key: str, label: str, pattern: str, **kw: Any) -> Family:
return Family(key, label, re.compile(pattern), **kw)
FAMILIES = (
_f("iso9001", "ISO 9001 质量管理体系认证", r"ISO(?:/IEC)?/?9001|GB/?T19001|(?<!医疗器械)质量管理体系认证"),
_f("iso14001", "ISO 14001 环境管理体系认证", r"ISO/?14001|GB/?T24001|环境管理体系认证"),
_f(
"iso45001",
"ISO 45001 职业健康安全管理体系认证",
r"ISO/?45001|OHSAS/?18001|GB/?T45001|GB/?T28001|职业健康安全管理体系认证",
),
_f("iso27001", "ISO 27001 信息安全管理体系认证", r"ISO(?:/IEC)?/?27001|GB/?T22080|信息安全管理体系认证"),
_f(
"iso20000",
"ISO 20000 信息技术服务管理体系认证",
r"ISO(?:/IEC)?/?20000|GB/?T24405|(?:信息技术|IT)服务管理体系认证",
),
_f("iso22301", "ISO 22301 业务连续性管理体系认证", r"ISO/?22301|GB/?T30146|业务连续性管理体系认证"),
_f("cmmi", "CMMI 能力成熟度模型集成", r"CMMI|能力成熟度模型集成", levels="asc", prefix="CMMI|ML"),
_f(
"cs",
"信息系统建设和服务能力等级",
r"信息系统建设和服务能力|(?<![A-Z])CS[1-5](?![0-9])",
levels="asc",
prefix="CS",
),
_f(
"itss",
"ITSS 信息技术服务运行维护标准符合性证书",
r"ITSS|信息技术服务标准|运行维护服务能力|运维服务能力",
levels="cn",
),
_f("ccia", "CCIA 信息系统业务安全服务资质", r"CCIA|信息系统业务安全服务资质|网络安全产业联盟", levels="cn"),
_f(
"ccrc",
"CCRC 信息安全服务资质",
r"CCRC|(?<!业务)信息安全服务资质|网络安全审查技术与认证中心",
levels="cn",
subtypes=(
"安全集成",
"风险评估",
"应急处理",
"软件安全开发",
"安全运维",
"灾难备份",
"网络安全审计",
"数据安全",
),
),
_f("eie", "电子与智能化工程专业承包资质", r"电子与智能化工程专业承包", levels="cn"),
_f("intel_design", "建筑智能化系统设计专项资质", r"建筑智能化系统设计专项|建筑智能化.{0,4}工程设计", levels="jia"),
_f("secret", "涉密信息系统集成资质", r"涉密信息系统集成", levels="jia"),
_f("safety", "安全生产许可证", r"安全生产许可证"),
_f("softcopy", "计算机软件著作权登记证书", r"软件著作权|著作权登记|软著"),
)
BY_KEY = {f.key: f for f in FAMILIES}
# 产品证书、人员证书:跟着产品或人走,不是投标人自己的资质,不进资质匹配
EXCLUDE = re.compile(
r"产品|设备|所投|厂商|厂家|制造商|原厂|节能|环境标志|CCC|3C|商用密码|医疗器械|IPV6|检测报告|"
r"人员|工程师|项目经理|负责人|技术员|持证|个人|兼容"
)
CERT_WORDS = re.compile(r"证书|资质|认证")
# 不是证书类资质的要求(营业执照、法人证书、联合体的资质认定规则):不算「没认出类别」
NOT_CERT = re.compile(r"营业执照|法人证书|联合体")
# 照抄要求的写法:投标人在响应表里抄一遍要求,不是读到的证书事实
REQUIREMENT_LIKE = re.compile(r"须|应|要求|及以上|以上|不低于|得\d|\d分|分)|分\)")
VALIDITY_LABEL = re.compile(r"有效期|到期|截止")
LONG_TERM = re.compile(r"长期有效|长期")
CN_NUM = {"特": 0, "一": 1, "二": 2, "三": 3, "四": 4, "五": 5, "壹": 1, "贰": 2, "叁": 3, "肆": 4, "伍": 5}
JIA = {"甲": 3, "乙": 2, "丙": 1}
HEADER_LOOKBACK = 15 # 表头最多往上找这么多行
CELL_SEP = "|" # 拼一行的格子时用的分隔,免得「CS2」和下一格的「2024-01-01」连成一串
COPY_RATIO = 0.8
LEVEL_CELL_MAX = 8 # 等级那一栏的字数上限(「CS3」「二级」「甲级」) # 句子占要求原文的这么大比例以上,算照抄的要求
def text_of(s: str) -> str:
return norm(s).upper()
def families_in(text: str) -> list[Family]:
t = text_of(text)
return [f for f in FAMILIES if f.pattern.search(t)]
def family_of_record(r: Record) -> Family | None:
if r.kind == "software_copyright":
return BY_KEY["softcopy"]
found = [f for f in families_in(r.name) if f.key != "softcopy"]
return found[0] if found else None
def level_rank(fam: Family, text: str) -> tuple[int, str] | None:
"""文字里写的等级 → (名次, 原文),名次越大越高;认不出返回 None。"""
t = text_of(text)
if fam.levels == "asc":
m = re.search(rf"(?:{fam.prefix})\s*-?\s*([1-5])(?![0-9])", t) or re.search(r"([一二三四五壹贰叁肆伍1-5])级", t)
if m:
g = m.group(1)
return (int(g) if g.isdigit() else CN_NUM[g]), m.group(0)
elif fam.levels == "cn":
if m := re.search(r"([特一二三四壹贰叁肆1-4])级", t):
g = m.group(1)
return 10 - (int(g) if g.isdigit() else CN_NUM[g]), m.group(0)
elif fam.levels == "jia":
if m := re.search(r"([甲乙丙])级", t):
return JIA[m.group(1)], m.group(0)
return None
def segment(fam: Family, text: str) -> str:
"""要求里属于这一类证书的那一段:从它的名称起,到下一类证书的名称为止(「ITSS 三级得 1 分;CS3 及以上得 2 分」
里,ITSS 的等级不能读成 CS 的)。没有别的证书时就是全文。"""
t = text_of(text)
m = fam.pattern.search(t)
if not m:
return t
others = [o.start() for f in FAMILIES if f.key != fam.key for o in f.pattern.finditer(t) if o.start() > m.start()]
return t[m.start() : min(others, default=len(t))]
def subtype_of(fam: Family, text: str) -> str | None:
t = text_of(text)
return next((x for x in fam.subtypes if x in t), None)
@dataclass(slots=True)
class BidFact:
"""投标文字层上读到的一张证书:在哪一句(和同一行的格子),有效期、等级。"""
evidence: list[tuple[BidDoc, Sentence]]
valid_to: date | None = None
long_term: bool = False
level: tuple[int, str] | None = None
@dataclass(slots=True)
class DocIndex:
"""一份投标文件的版面索引,每次检查建一次:句子 → 所在行、每页的行、规范化后的文字。"""
doc: BidDoc
rows: dict[int, list[Sentence]]
page_rows: dict[int, list[list[Sentence]]]
texts: list[tuple[Sentence, str]]
@classmethod
def build(cls, doc: BidDoc) -> DocIndex:
page_rows = {no: layout.rows(page) for no, page in layout.pages(doc.sentences).items()}
rows = {id(c): row for page in page_rows.values() for row in page for c in row}
return cls(doc, rows, page_rows, [(s, text_of(s.text)) for s in doc.sentences])
def row_level(fam: Family, row: list[Sentence], row_text: str) -> tuple[int, str] | None:
"""一行里的等级:先看短的格子(等级那一栏),证书编号里常带等级字样(「CS3-3201-…」),整行读会读错。"""
short = [lv for x in row if len(norm(x.text)) <= LEVEL_CELL_MAX and (lv := level_rank(fam, x.text))]
return short[0] if short else level_rank(fam, row_text)
def bid_facts(fam: Family, docs: list[DocIndex], requirement: str) -> list[BidFact]:
"""投标文字层上写着这一类证书的句子:取同一行(表格)或同一句里的有效期、等级。
只认这一行 / 这一句里只有这一类证书的;照抄要求的句子不算。"""
req = text_of(requirement)
out: list[BidFact] = []
for ix in docs:
seen: set[int] = set()
for s, t in ix.texts:
if not fam.pattern.search(t) or REQUIREMENT_LIKE.search(t):
continue
if req and (req in t or (t in req and len(t) >= COPY_RATIO * len(req))):
continue # 整句就是要求原文(证书名称只是要求的一部分时不算)
row = ix.rows.get(id(s)) or [s]
if id(row[0]) in seen:
continue
seen.add(id(row[0]))
row_text = CELL_SEP.join(x.text for x in row)
if {f.key for f in families_in(row_text)} - {fam.key, "softcopy"}:
continue # 一行里有两类证书,日期归属不清
evidence = [(ix.doc, x) for x in row if x is s or dates_in(x.text) or level_rank(fam, x.text)]
fact = BidFact(evidence=evidence, level=row_level(fam, row, row_text))
labeled = VALIDITY_LABEL.search(row_text) or _header_has_validity(ix.page_rows.get(s.page_no, []), row)
ds = dates_in(row_text)
if LONG_TERM.search(row_text) and labeled:
fact.long_term = True
elif ds and labeled:
fact.valid_to = max(ds) # 起止日期都在这一行时,晚的那个是「有效期至」
out.append(fact)
return out
def _header_has_validity(rows: list[list[Sentence]], row: list[Sentence]) -> bool:
"""表头(同一页往上几行)里有没有「有效期」这一栏。"""
try:
i = next(k for k, r in enumerate(rows) if r and row and r[0] is row[0])
except StopIteration:
return False
return any(VALIDITY_LABEL.search(CELL_SEP.join(x.text for x in r)) for r in rows[max(0, i - HEADER_LOOKBACK) : i])
@dataclass(slots=True)
class Coverage:
library: int = 0
checked: int = 0
by_library: int = 0
unrecognized: int = 0
excluded: int = 0
no_deadline: bool = False
def to_api(self) -> dict[str, Any]:
lines: list[str] = []
if self.library == 0:
lines.append("资质库还没有录入资质,有效期和等级没有按资质库核对(只看投标文件文字部分)")
if self.no_deadline:
lines.append("项目信息里没有投标截止时间,证书有效期没有比对")
if self.unrecognized:
lines.append(f"{self.unrecognized} 条资质要求没认出是哪类证书,有效期和等级没有核对")
lines.append("证书主体和投标人是否一致:还没有上线")
checked = f"{LABEL}:{self.checked} 条资质要求"
if self.by_library:
checked += f",其中 {self.by_library} 条按资质库记录判断"
if self.excluded:
checked += f";产品证书、人员证书 {self.excluded} 条不在资质匹配范围内"
return {
"checked": checked,
"not_checked": lines,
"detail": {
"library": self.library,
"checked": self.checked,
"by_library": self.by_library,
"unrecognized": self.unrecognized,
"excluded": self.excluded,
},
}
@dataclass(slots=True)
class Issue:
kind: str # expired / level_low / not_covered
source: str # bid_text / library
family: Family
text: str
severity: str
evidence: list[tuple[BidDoc, Sentence]] = field(default_factory=list[tuple[BidDoc, Sentence]])
detail: dict[str, Any] = field(default_factory=dict[str, Any])
# 投标文件里有(已响应)、可能在扫描件里、或评分项没逐条找响应(None):资质库的过期、等级照常提示;
# 文字版投标文件确实没找到的,保留「未找到响应」,资质库的情况只补进说明
SEEN = ("responded", "scanned", None)
CODES = {"expired": "QUAL.EXPIRED", "level_low": "QUAL.LEVEL_LOW", "not_covered": "QUAL.NOT_COVERED"}
TITLES = {"expired": "证书在投标截止日已过期", "level_low": "证书等级低于要求", "not_covered": "资质库里没有这类资质"}
ORDER = {"expired": 0, "level_low": 1, "not_covered": 2}
def _d(x: date) -> str:
return f"{x.year}年{x.month}月{x.day}日"
def _lower(sev: str) -> str:
return "important" if sev == "fatal" else sev
def _best_valid(records: list[Record]) -> Record | None:
"""同一类有几条(旧证书和换发后的新证书)时取有效期最晚的;长期有效最好。"""
return max(records, key=lambda r: date.max if r.valid_to is None else r.valid_to) if records else None
def _rec(r: Record) -> str:
no = f",编号 {r.cert_no}" if r.cert_no else ""
to = f"有效期至 {_d(r.valid_to)}" if r.valid_to else "长期有效"
return f"《{r.name}》({to}{no})"
def check_item(
item: dict[str, Any],
result: Result | None,
fams: list[Family],
docs: list[DocIndex],
library: list[Record],
by_family: dict[str, list[Record]],
deadline: date | None,
cov: Coverage,
) -> tuple[list[Issue], list[str]]:
"""一条清单项:返回资质问题和给原有问题补的说明(资质库的情况)。"""
requirement = " ".join(str(item.get(k) or "") for k in ("title", "requirement_text", "evidence_required"))
full = item_severity(item)
verdict = result.verdict if result else None
issues: list[Issue] = []
notes: list[str] = []
used_library = False
for fam in fams:
facts = bid_facts(fam, docs, requirement)
recs = by_family.get(fam.key, [])
if fam.key == "softcopy": # 软著长期有效、没有等级:只给扫描件兜底补一句
if recs and verdict in ("scanned", "not_found"):
notes.append(f"资质库里有 {len(recs)} 件软件著作权")
continue
best = _best_valid(recs)
# 有效期:投标文字层读到日期就以它为准(QL4),读不到再看资质库
dated = [x for x in facts if x.valid_to or x.long_term]
if deadline and dated:
if not any(x.long_term or (x.valid_to and x.valid_to >= deadline) for x in dated):
latest = max(x.valid_to for x in dated if x.valid_to)
text = f"投标文件里写的有效期至 {_d(latest)},早于投标截止日 {_d(deadline)}。"
if best and (best.valid_to is None or best.valid_to >= deadline):
text += f"资质库里有{_rec(best)},投标文件附的可能是旧证书。"
issues.append(
Issue(
"expired",
"bid_text",
fam,
text,
full,
[e for x in dated for e in x.evidence],
{"bid_valid_to": latest.isoformat(), **({"library": best.snapshot()} if best else {})},
)
)
elif deadline and best and best.valid_to and best.valid_to < deadline and verdict in SEEN:
used_library = True
text = f"按资质库记录,{_rec(best)}早于投标截止日 {_d(deadline)}。投标文件文字部分没有写有效期。"
issues.append(Issue("expired", "library", fam, text, _lower(full), [], {"library": best.snapshot()}))
elif deadline and best and best.valid_to and best.valid_to < deadline and verdict == "not_found":
notes.append(f"资质库里的{_rec(best)}也早于投标截止日")
# 等级:能比较时才比
need = level_rank(fam, segment(fam, requirement)) if fam.levels else None
if need:
got = [x.level for x in facts if x.level]
if got:
top = max(got)
if top[0] < need[0]:
text = f"要求「{need[1]}」,投标文件里写的是「{top[1]}」。"
issues.append(
Issue(
"level_low",
"bid_text",
fam,
text,
full,
[e for x in facts if x.level for e in x.evidence],
{"required_level": need[1], "bid_level": top[1]},
)
)
elif recs and verdict in SEEN:
ranked = [(level_rank(fam, r.level or ""), r) for r in recs]
comparable = [(lv, r) for lv, r in ranked if lv]
if comparable and len(comparable) == len(ranked) and max(lv[0] for lv, _ in comparable) < need[0]:
used_library = True
r = max(comparable, key=lambda x: x[0][0])[1]
text = f"要求「{need[1]}」,按资质库记录,{_rec(r)}的等级是「{r.level}」。"
issues.append(
Issue(
"level_low",
"library",
fam,
text,
_lower(full),
[],
{"required_level": need[1], "library": r.snapshot()},
)
)
# 服务类别(CCRC):要求写了类别,资质库的记录名称里都没写
sub = subtype_of(fam, segment(fam, requirement))
if sub and recs and not any(sub in text_of(r.name) for r in recs) and not facts:
used_library = True
text = f"要求的是「{sub}」类,资质库里的{fam.label}记录没写服务类别,请核对是不是「{sub}」。"
issues.append(Issue("not_covered", "library", fam, text, "hint", [], {"subtype": sub}))
# 未覆盖:投标文字层没找到、资质库有记录但没有这一类(QL3)
if library and not recs and not facts and verdict in ("scanned", "not_found"):
if verdict == "scanned":
used_library = True
text = (
f"投标文件文字部分没有找到{fam.label},资质库里也没有这类资质(资质库有 {len(library)} 条记录)。"
)
issues.append(Issue("not_covered", "library", fam, text, "important", [], {}))
else:
notes.append(f"资质库里也没有{fam.label}")
elif best and verdict == "scanned" and not issues:
notes.append(f"资质库里有{_rec(best)},请核对扫描件里附的是这一张")
if used_library:
cov.by_library += 1
return issues, notes
def run(
items: dict[UUID, dict[str, Any]],
results: dict[UUID, Result],
docs: list[BidDoc],
library: list[Record],
deadline: date | None,
) -> tuple[list[Hit], Coverage]:
"""资质匹配:对资格条件、评分项里认出证书类别的清单项逐条核对。有资质问题的条目,扫描件兜底那条不再单独出;
资质库的情况补进原有问题的说明(results[...].detail["qualification_note"])。"""
cov = Coverage(library=len(library), no_deadline=deadline is None)
index = [DocIndex.build(d) for d in docs]
by_family: dict[str, list[Record]] = {}
for r in library:
if fam := family_of_record(r):
by_family.setdefault(fam.key, []).append(r)
hits: list[Hit] = []
for item_id, item in items.items():
cats = set(item.get("categories") or [])
if not cats & {"qualification", "scoring"}:
continue
requirement = " ".join(str(item.get(k) or "") for k in ("title", "requirement_text", "evidence_required"))
t = text_of(requirement)
fams = families_in(requirement)
if EXCLUDE.search(t):
if fams or CERT_WORDS.search(t):
cov.excluded += 1
continue
if not fams:
if CERT_WORDS.search(t) and not NOT_CERT.search(t):
cov.unrecognized += 1
continue
cov.checked += 1
result = results.get(item_id)
issues, notes = check_item(item, result, fams, index, library, by_family, deadline, cov)
if result is not None and notes:
result.detail = {**result.detail, "qualification_note": ";".join(notes) + "。"}
if not issues:
continue
if result is not None and result.verdict == "scanned":
result.detail = {**result.detail, "superseded_by": "QUAL"}
for fam in dict.fromkeys(x.family.key for x in issues): # 一类证书一个问题:过期和等级不足是两件事
mine = sorted(
(x for x in issues if x.family.key == fam), key=lambda x: (ORDER[x.kind], x.source != "bid_text")
)
top = mine[0]
hits.append(
Hit(
code=CODES[top.kind],
type="qualification",
severity=min((x.severity for x in mine), key=["fatal", "important", "hint"].index),
title=f"{TITLES[top.kind]}:{top.family.label}",
explanation=" ".join(x.text for x in mine),
evidence=[e for x in mine for e in x.evidence],
target=f"{item_id}:{fam}",
item=item_id,
detail={
"kind": top.kind,
"source": top.source,
"family": fam,
"deadline": deadline.isoformat() if deadline else None,
"importance_basis": item.get("importance_basis"),
"issues": [{"kind": x.kind, "source": x.source, **x.detail} for x in mine],
},
tender_refs=[UUID(c) for c in item.get("citation_ids") or []],
)
)
return hits, cov

View File

@@ -43,6 +43,9 @@ LABELS = {
"FORM.VALIDITY_SHORT": "投标有效期",
"FORM.CONDITION_ADDED": "附加条件",
"FORM.RESPONSE_NEGATIVE": "响应表里的未响应、负偏离",
"QUAL.EXPIRED": "证书有效期(按投标截止日)",
"QUAL.LEVEL_LOW": "证书等级",
"QUAL.NOT_COVERED": "资质有没有覆盖",
}
RULES_NOT_YET = (
"分项报价表和采购需求清单逐项对照、格式文件固定文字被改、"
@@ -103,6 +106,7 @@ class Hit:
basis: str | None = None # Facts.refs 的键:招标侧出处
item: UUID | None = None # 对应的清单项(有的话)
detail: dict[str, Any] = field(default_factory=dict[str, Any])
tender_refs: list[UUID] = field(default_factory=list[UUID]) # 招标侧出处(资质问题挂清单项自己的出处)
def target(text: str) -> str:
@@ -825,6 +829,22 @@ def _cn_year(s: str) -> int:
return int("".join(str(CN.get(c, 0)) for c in s.replace("○", "〇")))
def dates_in(text: str) -> list[date]:
"""文字里的全部日期(阿拉伯数字和汉字两种写法),写错的跳过。"""
out: list[date] = []
for m in DATE.finditer(text):
try:
out.append(date(*(int(x) for x in m.groups())))
except ValueError:
continue
for m in CN_DATE.finditer(text):
try:
out.append(date(_cn_year(m.group(1)), cn_int(m.group(2)) or 0, cn_int(m.group(3)) or 0))
except ValueError:
continue
return out
def _sign_dates(text: str) -> list[tuple[str, date | None]]:
"""落款日期:「日期:2026年11月17日」「日 期:二〇二六年十一月十七日」(前面紧挨着的是「签署 / 落款 / 投标」
或不是汉字;「发证日期」「截止日期」不算)。写错的日期返回 None。"""

View File

@@ -22,7 +22,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from bid_doctor.ai.models import AiCall
from bid_doctor.ai.providers.base import TransientError
from bid_doctor.billing import service as billing
from bid_doctor.check import applicability, deviation, findings, manual, match, reuse, rules
from bid_doctor.check import applicability, deviation, findings, manual, match, qualification, reuse, rules
from bid_doctor.check.models import CheckItemResult, CheckRun, CheckRunDocument, Finding, FindingCitation
from bid_doctor.check.service import RULE_SET_VERSION, open_fatal
from bid_doctor.checklist import edit as checklist_edit
@@ -41,6 +41,7 @@ from bid_doctor.document.pipeline.text_layer import norm
from bid_doctor.notification.models import Notification
from bid_doctor.project import service as projects
from bid_doctor.project.models import Lot, Project, ProjectField
from bid_doctor.qualification import service as qualifications
log = logging.getLogger(__name__)
PROGRESS = {"prepare_bid": 5, "match": 30, "rules": 85, "verify": 90, "carry_over": 93, "summarize": 96}
@@ -171,6 +172,7 @@ async def _stages(ctx: TenantContext, run_id: UUID) -> None:
]
facts = await _facts(s, ctx, r, snapshot)
prior = await _prior(s, r)
library = await qualifications.records(s)
# 招标文件按类别的响应方式(C12):用清单里存的(人工改过的为准);解析早于这项功能的项目临时识别
modes = response_mode.from_stored(stored_modes) if stored_modes else response_mode.detect(tender_sents)
bidding = set(r.lot_ids)
@@ -186,6 +188,7 @@ async def _stages(ctx: TenantContext, run_id: UUID) -> None:
for d in decisions.values():
if not d.match and d.skip:
skipped[d.skip] = skipped.get(d.skip, 0) + 1
lot_items = items # 资质匹配也看没逐条找响应的评分项(证书写在评分标准里最常见)
items = {i: p for i, p in items.items() if decisions[i].match} # 不需要投标人回答的条目不逐条找响应
last = [PROGRESS["match"]]
@@ -201,6 +204,7 @@ async def _stages(ctx: TenantContext, run_id: UUID) -> None:
deviation.mismatch_read(results, items, bid_docs) # 证据只是照抄的要求格、响应格写的是别的:改判未找到
match.apply_modes(results, items, decisions)
match.scanned_fallback(results, items, decisions, bid_docs)
qual_hits, qual_cov = qualification.run(lot_items, results, bid_docs, library, facts.deadline)
await _stage(ctx, run_id, "rules")
hits, rule_cov = rules.run(facts, bid_docs, tender_sents)
@@ -210,7 +214,7 @@ async def _stages(ctx: TenantContext, run_id: UUID) -> None:
if res.verdict == "negative_deviation"
for d, x in res.evidence
}
hits = rules.drop_covered(hits, covered)
hits = rules.drop_covered(hits, covered) + qual_hits
n_manual = len(manual.topics(list(snapshot.values())))
unseen = "份数、密封、递交、CA 签章加密、解密、保证金等文件里看不出来的要求"
rule_cov["not_checked"].append(
@@ -239,7 +243,7 @@ async def _stages(ctx: TenantContext, run_id: UUID) -> None:
).one()
r.counts = await _counts(s, run_id)
r.coverage = _coverage(
r, pages_info, items, results, pending, other_lot, stats, tokens, skipped, modes, rule_cov
r, pages_info, items, results, pending, other_lot, stats, tokens, skipped, modes, rule_cov, qual_cov
)
await _publish(ctx, r, "running", "summarize", PROGRESS["summarize"])
@@ -306,7 +310,8 @@ async def _write_rules(
facts: rules.Facts,
by_doc: dict[UUID, SourceDocument],
) -> None:
"""规则问题:挂投标原文的句子;有依据的(限价、有效期要求、项目名称)再挂招标侧出处。"""
"""规则问题:挂投标原文的句子;有依据的(限价、有效期要求、项目名称)再挂招标侧出处,资质问题挂清单项自己的出处。
只按资质库记录判断的资质问题没有投标侧出处,界面显示「依据:资质库记录」(detail 里存记录快照,QL2)。"""
for h in hits:
bid_cites = []
for doc_id in dict.fromkeys(d.id for d, _ in h.evidence):
@@ -331,7 +336,7 @@ async def _write_rules(
)
s.add(f)
await s.flush()
for pos, cid in enumerate(facts.refs.get(h.basis or "", [])):
for pos, cid in enumerate(h.tender_refs or facts.refs.get(h.basis or "", [])):
s.add(
FindingCitation(tenant_id=ctx.tenant_id, finding_id=f.id, citation_id=cid, side="tender", position=pos)
)
@@ -536,6 +541,7 @@ def _coverage(
skipped: dict[str, int],
modes: dict[str, response_mode.Mode],
rule_cov: dict[str, Any],
qual_cov: qualification.Coverage,
) -> dict[str, Any]:
"""检查覆盖范围:报告首页、查标页顶部都读这里(FR-M3-09、m3-plan §2.5)。"""
over_budget = sum(x.reason_code == "over_budget" for x in results.values())
@@ -572,7 +578,9 @@ def _coverage(
checked.append(f"其中 {implicit} 条按招标文件「未列入偏离的视为接受」处理,没有复述不算问题")
checked.append(rule_cov["checked"])
not_checked += rule_cov["not_checked"]
not_checked.append("资质匹配(证书有效期、主体一致):还没有上线")
qual = qual_cov.to_api()
checked.append(qual["checked"])
not_checked += qual["not_checked"]
if not r.checklist_confirmed:
not_checked.append("清单未确认,检查结果以当前清单为准")
return {
@@ -600,6 +608,7 @@ def _coverage(
],
"legal_regime": {"value": r.legal_regime, "rules_verified": r.legal_regime in REGIMES_VERIFIED},
"rule_set_version": RULE_SET_VERSION,
"qualification": qual["detail"],
"prompt_versions": r.prompt_versions,
"tokens": {"in": int(tokens[0]), "out": int(tokens[1])},
"model_calls": stats.calls,

View File

@@ -37,7 +37,8 @@ from bid_doctor.project import service as projects
from bid_doctor.project.models import Project
CHECK_TASK = "check_project"
RULE_SET_VERSION = "2026.11-r3" # 规则集有版本,检查批次记下当时用的版本(架构 §3.7);r3:留出集暴露的 5 处
# 规则集有版本,检查批次记下当时用的版本(架构 §3.7);r3:留出集暴露的 5 处;r4:资质匹配
RULE_SET_VERSION = "2026.11-r4"
WAIVE_WINDOW = timedelta(hours=24)
ERROR_MESSAGES = {
"bid_text_missing": "投标文件里几乎读不出文字(可能都是扫描件),扫描件识别还没有上线",
@@ -317,9 +318,8 @@ async def _to_api_many(s: AsyncSession, run: CheckRun, rows: list[Finding]) -> l
}
if item
else None,
"rule": {"code": f.rule_code, "description": rules.LABELS.get(f.rule_code, "")}
if f.rule_code and not item
else None,
# 规则问题都带规则编号;挂了清单项的规则问题(资质匹配)两个都有
"rule": {"code": f.rule_code, "description": rules.LABELS.get(f.rule_code, "")} if f.rule_code else None,
"detail": f.detail,
"tender_citations": [cites[x.citation_id] for x in ls if x.side == "tender" and x.citation_id in cites],
"bid_citations": [cites[x.citation_id] for x in ls if x.side == "bid" and x.citation_id in cites],

View File

@@ -65,3 +65,7 @@ def project_prefix(tenant_id: object, project_id: object) -> str:
def upload_prefix(tenant_id: object, upload_id: object) -> str:
return f"t/{tenant_id}/uploads/{upload_id}"
def qualification_prefix(tenant_id: object, qualification_id: object) -> str:
return f"t/{tenant_id}/q/{qualification_id}"

View File

@@ -18,7 +18,7 @@ PLATFORM_FORMATS = {"kedt": "苏采云(江苏政府采购)"}
@dataclass(frozen=True, slots=True)
class Detected:
format: str # pdf / docx / doc / ofd
format: str # pdf / docx / doc / ofd;资质附件另有 jpeg / png
page_count: int | None # 只有 PDF 能直接读出页数
extension_mismatch: bool
@@ -43,6 +43,23 @@ def detect(data: bytes, filename: str) -> Detected:
return Detected(format=fmt, page_count=pages, extension_mismatch=ext not in EXTENSIONS.get(fmt, set()))
ATTACHMENT_EXTENSIONS = {"pdf": {"pdf"}, "jpeg": {"jpg", "jpeg"}, "png": {"png"}}
def detect_attachment(data: bytes, filename: str) -> Detected:
"""资质附件(证书扫描件):PDF、JPG、PNG,同样按文件头认。"""
ext = filename.rsplit(".", 1)[-1].lower() if "." in filename else ""
if data.startswith(b"%PDF"):
fmt, pages = "pdf", _pdf_pages(data)
elif data.startswith(b"\xff\xd8\xff"):
fmt, pages = "jpeg", 1
elif data.startswith(b"\x89PNG\r\n\x1a\n"):
fmt, pages = "png", 1
else:
raise AppError("file_format_unsupported", "证书附件支持 PDF、JPG、PNG")
return Detected(format=fmt, page_count=pages, extension_mismatch=ext not in ATTACHMENT_EXTENSIONS[fmt])
def _pdf_pages(data: bytes) -> int:
try:
doc = pdfium.PdfDocument(data)

View File

@@ -22,9 +22,10 @@ from bid_doctor.core.errors import AppError
from bid_doctor.core.ids import uuid7
from bid_doctor.core.storage import storage, upload_prefix
from bid_doctor.core.valkey import valkey
from bid_doctor.document.formats import detect
from bid_doctor.document.formats import detect, detect_attachment
TTL_S = 24 * 3600
ATTACHMENT_MAX_BYTES = 20 * 1024 * 1024 # 资质附件(qualification-plan §2.3)
@dataclass(slots=True)
@@ -55,6 +56,8 @@ async def _limits(ctx: TenantContext) -> dict[str, Any]:
async def create(
ctx: TenantContext, filename: str, size_bytes: int, purpose: str, sha256: str | None
) -> dict[str, Any]:
if purpose == "qualification_file" and size_bytes > ATTACHMENT_MAX_BYTES:
raise AppError("validation_failed", "证书附件最大 20MB")
limits = await _limits(ctx)
if size_bytes > limits["max_file_bytes"]:
raise AppError("plan_limit_exceeded", f"单个文件最大 {limits['max_file_bytes'] // (1024 * 1024)}MB")
@@ -121,9 +124,11 @@ async def complete(ctx: TenantContext, upload_id: UUID) -> dict[str, Any]:
digest = hashlib.sha256(data).hexdigest()
if meta.sha256 and meta.sha256 != digest:
raise AppError("upload_checksum_mismatch")
d = detect(data, meta.filename) # 格式、加密、损坏都在这里拦下,不建任务、不扣点
# 格式、加密、损坏都在这里拦下,不建任务、不扣点
attachment = meta.purpose == "qualification_file"
d = detect_attachment(data, meta.filename) if attachment else detect(data, meta.filename)
limits = await _limits(ctx)
if d.page_count and d.page_count > limits["max_pages"]:
if not attachment and d.page_count and d.page_count > limits["max_pages"]:
raise AppError("plan_limit_exceeded", f"单个文件最多 {limits['max_pages']} 页,这份有 {d.page_count} 页")
storage().put(f"{prefix}/file", seal_object(dk, data, f"{prefix}/file"))
for n in range(1, meta.part_count + 1): # 分片用完即删

View File

@@ -0,0 +1,101 @@
from __future__ import annotations
from datetime import date
from typing import Annotated, Any, Literal
from urllib.parse import quote
from uuid import UUID
from fastapi import APIRouter, Query, Response
from pydantic import BaseModel, Field
from bid_doctor.core.crypto import verify_download
from bid_doctor.core.db import TenantContext, tenant_tx
from bid_doctor.core.errors import AppError
from bid_doctor.identity.deps import Ctx
from bid_doctor.qualification import service
router = APIRouter(tags=["qualifications"])
Kind = Literal["certificate", "system_cert", "software_copyright", "other"]
class QualificationIn(BaseModel):
name: str = Field(min_length=1, max_length=200)
kind: Kind
level: str | None = Field(default=None, max_length=40)
cert_no: str | None = Field(default=None, max_length=80)
issuer: str | None = Field(default=None, max_length=120)
valid_from: date | None = None
valid_to: date | None = None # 空表示长期有效
def data(self) -> dict[str, Any]:
d = self.model_dump()
for k in ("level", "cert_no", "issuer"):
d[k] = (d[k] or "").strip() or None
d["name"] = d["name"].strip()
return d
class AttachIn(BaseModel):
upload_id: UUID
@router.get("/qualifications")
async def list_qualifications(
ctx: Ctx, kind: Kind | None = None, q: Annotated[str | None, Query(max_length=100)] = None
) -> dict[str, Any]:
async with tenant_tx(ctx) as s:
return {"items": await service.list_api(s, kind, (q or "").strip() or None)}
@router.post("/qualifications", status_code=201)
async def create_qualification(body: QualificationIn, ctx: Ctx) -> dict[str, Any]:
async with tenant_tx(ctx) as s:
return await service.to_api(s, await service.create(s, ctx, body.data()))
@router.put("/qualifications/{qualification_id}")
async def update_qualification(qualification_id: UUID, body: QualificationIn, ctx: Ctx) -> dict[str, Any]:
async with tenant_tx(ctx) as s:
return await service.to_api(s, await service.update(s, ctx, qualification_id, body.data()))
@router.delete("/qualifications/{qualification_id}", status_code=204)
async def delete_qualification(qualification_id: UUID, ctx: Ctx) -> None:
async with tenant_tx(ctx) as s:
await service.delete(s, ctx, qualification_id)
@router.post("/qualifications/{qualification_id}/files", status_code=201)
async def attach_qualification_file(qualification_id: UUID, body: AttachIn, ctx: Ctx) -> dict[str, Any]:
return await service.attach(ctx, qualification_id, body.upload_id)
@router.get("/qualifications/{qualification_id}/files/{file_id}")
async def get_qualification_file_url(qualification_id: UUID, file_id: UUID, ctx: Ctx) -> dict[str, Any]:
async with tenant_tx(ctx) as s:
return await service.file_url(s, ctx, qualification_id, file_id)
@router.delete("/qualifications/{qualification_id}/files/{file_id}", status_code=204)
async def delete_qualification_file(qualification_id: UUID, file_id: UUID, ctx: Ctx) -> None:
async with tenant_tx(ctx) as s:
await service.delete_file(s, ctx, qualification_id, file_id)
@router.get("/qualification-files/{tenant_id}/{file_id}", include_in_schema=False)
async def download(tenant_id: UUID, file_id: UUID, exp: int, sig: str) -> Response:
"""签名下载(本地磁盘驱动用)。不带鉴权头,靠短时签名。"""
if not verify_download(f"{tenant_id}:{file_id}:qualification_file", exp, sig):
raise AppError("not_found")
ctx = TenantContext(tenant_id=tenant_id, user_id=UUID(int=0))
async with tenant_tx(ctx) as s:
f, data, media = await service.file_content(s, ctx, file_id)
return Response(
data,
media_type=media,
headers={
"Cache-Control": "private, max-age=300",
"Content-Disposition": f"inline; filename*=UTF-8''{quote(f.filename)}",
},
)

View File

@@ -0,0 +1,40 @@
from __future__ import annotations
from datetime import date
from sqlalchemy.orm import Mapped
from bid_doctor.core.columns import PK, Bytes, DbTs, UuidCol
from bid_doctor.core.db import Base
class Qualification(Base):
"""最小资质库(FR-M6-08):只有用户自己录入的资质,查标按它核对有效期和等级(FR-M3-05)。"""
__tablename__ = "qualification"
id: Mapped[PK]
tenant_id: Mapped[UuidCol]
name: Mapped[str]
kind: Mapped[str]
level: Mapped[str | None]
cert_no: Mapped[str | None]
issuer: Mapped[str | None]
valid_from: Mapped[date | None]
valid_to: Mapped[date | None] # 空 = 长期有效
created_by: Mapped[UuidCol]
created_at: Mapped[DbTs]
updated_at: Mapped[DbTs]
class QualificationFile(Base):
"""资质附件(证书扫描件),按企业级数据密钥加密(DM4)。"""
__tablename__ = "qualification_file"
id: Mapped[PK]
tenant_id: Mapped[UuidCol]
qualification_id: Mapped[UuidCol]
object_key: Mapped[str]
filename: Mapped[str]
sha256: Mapped[Bytes]
size_bytes: Mapped[int]
created_at: Mapped[DbTs]

View File

@@ -0,0 +1,246 @@
"""最小资质库(FR-M6-08,qualification-plan §2)。
- 只有用户自己录入的资质(红线 5);不扣额度;增删改和附件操作都写审计;
- 附件按企业级数据密钥加密(DM4),对象键 t/<tenant>/q/<资质>/<附件>;
- 删除是硬删,连同附件对象一起删,不做撤销(QL6:撤销要软删除,和「彻底删除」冲突);
- late_for:进行中的项目里,投标截止日晚于「有效期至」的项目,到期按每个项目的截止日判断,不按今天。
"""
from __future__ import annotations
import hashlib
from dataclasses import dataclass
from datetime import UTC, date, datetime
from typing import Any
from uuid import UUID
from zoneinfo import ZoneInfo
from sqlalchemy import or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from bid_doctor.audit import service as audit
from bid_doctor.core import keys
from bid_doctor.core.authz import Permission, require
from bid_doctor.core.config import get_settings
from bid_doctor.core.crypto import open_object, seal_object, sign_download
from bid_doctor.core.db import TenantContext, tenant_tx
from bid_doctor.core.errors import AppError
from bid_doctor.core.ids import uuid7
from bid_doctor.core.storage import qualification_prefix, storage
from bid_doctor.document import uploads
from bid_doctor.project.models import Project
from bid_doctor.qualification.models import Qualification, QualificationFile
LOCAL_TZ = ZoneInfo("Asia/Shanghai")
ACTIVE_STATUSES = ("tracking", "drafting", "checked") # 进行中的项目:还没递交、没归档
FIELDS = ("name", "kind", "level", "cert_no", "issuer", "valid_from", "valid_to")
MEDIA_TYPES = {b"%PDF": "application/pdf", b"\xff\xd8\xff": "image/jpeg", b"\x89PNG": "image/png"}
@dataclass(frozen=True, slots=True)
class Record:
"""查标用的资质记录(check/qualification.py 按它比较,并把它存进问题的快照)。"""
id: UUID
name: str
kind: str
level: str | None
cert_no: str | None
valid_to: date | None
def snapshot(self) -> dict[str, Any]:
return {
"id": str(self.id),
"name": self.name,
"kind": self.kind,
"level": self.level,
"cert_no": self.cert_no,
"valid_to": self.valid_to.isoformat() if self.valid_to else None,
}
def _values(q: Qualification) -> dict[str, Any]:
return {f: (v.isoformat() if isinstance(v := getattr(q, f), date) else v) for f in FIELDS}
def _check(data: dict[str, Any]) -> None:
if data.get("valid_from") and data.get("valid_to") and data["valid_to"] <= data["valid_from"]:
raise AppError("validation_failed", "有效期至要晚于有效期起")
async def get(s: AsyncSession, qualification_id: UUID) -> Qualification:
q = (await s.execute(select(Qualification).where(Qualification.id == qualification_id))).scalar_one_or_none()
if q is None:
raise AppError("not_found")
return q
async def _active_deadlines(s: AsyncSession) -> list[tuple[UUID, str, date]]:
today = datetime.now(LOCAL_TZ).date()
rows = (
await s.execute(
select(Project.id, Project.name, Project.deadline_at)
.where(Project.status.in_(ACTIVE_STATUSES), Project.deadline_at.is_not(None))
.order_by(Project.deadline_at)
)
).all()
out = [(pid, name, at.astimezone(LOCAL_TZ).date()) for pid, name, at in rows if at is not None]
return [x for x in out if x[2] >= today]
async def to_api(
s: AsyncSession, q: Qualification, deadlines: list[tuple[UUID, str, date]] | None = None
) -> dict[str, Any]:
if deadlines is None:
deadlines = await _active_deadlines(s)
files = (
await s.execute(
select(QualificationFile)
.where(QualificationFile.qualification_id == q.id)
.order_by(QualificationFile.created_at)
)
).scalars()
late = [
{"project_id": pid, "name": name, "deadline": d.isoformat()}
for pid, name, d in deadlines
if q.valid_to is not None and q.valid_to < d
]
return {
"id": q.id,
**{f: getattr(q, f) for f in FIELDS},
"files": [{"id": f.id, "filename": f.filename, "size_bytes": f.size_bytes} for f in files],
"late_for": late,
"created_at": q.created_at,
"updated_at": q.updated_at,
}
async def list_api(s: AsyncSession, kind: str | None, keyword: str | None) -> list[dict[str, Any]]:
stmt = select(Qualification)
if kind:
stmt = stmt.where(Qualification.kind == kind)
if keyword:
like = f"%{keyword}%"
stmt = stmt.where(
or_(Qualification.name.ilike(like), Qualification.cert_no.ilike(like), Qualification.issuer.ilike(like))
)
# 有效期近的在上,长期有效的在最后(原型 qualifications.html)
rows = (await s.execute(stmt.order_by(Qualification.valid_to.asc().nulls_last(), Qualification.name))).scalars()
deadlines = await _active_deadlines(s)
return [await to_api(s, q, deadlines) for q in rows]
async def records(s: AsyncSession) -> list[Record]:
rows = (await s.execute(select(Qualification))).scalars()
return [Record(q.id, q.name, q.kind, q.level, q.cert_no, q.valid_to) for q in rows]
async def create(s: AsyncSession, ctx: TenantContext, data: dict[str, Any]) -> Qualification:
require(ctx, Permission.QUALIFICATION_EDIT)
_check(data)
q = Qualification(id=uuid7(), tenant_id=ctx.tenant_id, created_by=ctx.user_id, **data)
s.add(q)
await s.flush()
await audit.record(s, ctx, "qualification.create", "qualification", q.id, after=_values(q))
await s.refresh(q)
return q
async def update(s: AsyncSession, ctx: TenantContext, qualification_id: UUID, data: dict[str, Any]) -> Qualification:
require(ctx, Permission.QUALIFICATION_EDIT)
_check(data)
q = await get(s, qualification_id)
before = _values(q)
for k, v in data.items():
setattr(q, k, v)
await s.flush()
await audit.record(s, ctx, "qualification.update", "qualification", q.id, before=before, after=_values(q))
await s.refresh(q)
return q
async def delete(s: AsyncSession, ctx: TenantContext, qualification_id: UUID) -> None:
require(ctx, Permission.QUALIFICATION_EDIT)
q = await get(s, qualification_id)
before = _values(q)
await s.delete(q) # 附件行由外键级联删除
await s.flush()
storage().delete_prefix(qualification_prefix(ctx.tenant_id, q.id))
await audit.record(s, ctx, "qualification.delete", "qualification", q.id, before=before)
async def attach(ctx: TenantContext, qualification_id: UUID, upload_id: UUID) -> dict[str, Any]:
"""把一个已完成的上传挂成附件:明文从暂存取出,用企业级密钥按新的对象键重新加密。"""
require(ctx, Permission.QUALIFICATION_EDIT)
meta, data = await uploads.take(ctx, upload_id)
if meta.purpose != "qualification_file":
raise AppError("validation_failed", "这个上传不是资质附件")
async with tenant_tx(ctx) as s:
q = await get(s, qualification_id)
dk = await keys.data_key(s, ctx)
f = QualificationFile(
id=uuid7(),
tenant_id=ctx.tenant_id,
qualification_id=q.id,
object_key="",
filename=meta.filename,
sha256=hashlib.sha256(data).digest(),
size_bytes=len(data),
)
f.object_key = f"{qualification_prefix(ctx.tenant_id, q.id)}/{f.id}"
storage().put(f.object_key, seal_object(dk, data, f.object_key))
s.add(f)
await s.flush()
await audit.record(
s, ctx, "qualification_file.attach", "qualification", q.id, after={"file": str(f.id), "name": f.filename}
)
out = await to_api(s, q)
await uploads.discard(ctx, upload_id)
return out
async def _file(s: AsyncSession, qualification_id: UUID, file_id: UUID) -> QualificationFile:
f = (
await s.execute(
select(QualificationFile).where(
QualificationFile.id == file_id, QualificationFile.qualification_id == qualification_id
)
)
).scalar_one_or_none()
if f is None:
raise AppError("not_found")
return f
async def delete_file(s: AsyncSession, ctx: TenantContext, qualification_id: UUID, file_id: UUID) -> None:
require(ctx, Permission.QUALIFICATION_EDIT)
f = await _file(s, qualification_id, file_id)
await s.delete(f)
await s.flush()
storage().delete_prefix(f.object_key)
await audit.record(
s,
ctx,
"qualification_file.delete",
"qualification",
qualification_id,
before={"file": str(f.id), "name": f.filename},
)
async def file_url(s: AsyncSession, ctx: TenantContext, qualification_id: UUID, file_id: UUID) -> dict[str, Any]:
f = await _file(s, qualification_id, file_id)
sig, exp = sign_download(f"{ctx.tenant_id}:{f.id}:qualification_file", get_settings().download_url_ttl_s)
base = get_settings().public_base_url
url = f"{base}/api/v1/qualification-files/{ctx.tenant_id}/{f.id}?exp={exp}&sig={sig}"
return {"url": url, "expires_at": datetime.fromtimestamp(exp, UTC), "sha256": f.sha256.hex()}
async def file_content(s: AsyncSession, ctx: TenantContext, file_id: UUID) -> tuple[QualificationFile, bytes, str]:
f = (await s.execute(select(QualificationFile).where(QualificationFile.id == file_id))).scalar_one_or_none()
if f is None:
raise AppError("not_found")
dk = await keys.data_key(s, ctx)
data = open_object(dk, storage().get(f.object_key), f.object_key)
media = next((t for magic, t in MEDIA_TYPES.items() if data.startswith(magic)), "application/octet-stream")
return f, data, media

View File

@@ -19,7 +19,7 @@ from pathlib import Path
from typing import Any
from zoneinfo import ZoneInfo
TEMPLATE_VERSION = "2026.11-v1"
TEMPLATE_VERSION = "2026.11-v2" # v2:资质问题的「依据:资质库记录」
TITLE = "投标文件辅助检查报告"
FOOTER = "本报告为辅助检查结果,仅供投标前自查参考"
DISCLAIMER = (
@@ -96,6 +96,20 @@ def _cite(c: dict[str, Any]) -> str:
)
def _library(f: dict[str, Any]) -> list[str]:
"""只按资质库记录判断的资质问题:依据是资质库那条记录的快照(QL2)。"""
if f.get("type") != "qualification":
return []
out = []
for x in (f.get("detail") or {}).get("issues") or []:
r = x.get("library") if x.get("source") == "library" else None
if r:
to = f"有效期至 {r['valid_to']}" if r.get("valid_to") else "长期有效"
no = f" · 编号 {escape(r['cert_no'])}" if r.get("cert_no") else ""
out.append(f"<div>依据:资质库记录《{escape(r['name'])}》{no} · {to}</div>")
return out
def _status(f: dict[str, Any]) -> str:
st = f.get("status") or "pending"
if st == "pending":
@@ -170,6 +184,7 @@ def findings(d: dict[str, Any]) -> str:
if d["options"].get("citations", True):
parts = [f"<div>招标文件:{_cite(c)}</div>" for c in f.get("tender_citations") or []]
parts += [f"<div>投标文件:{_cite(c)}</div>" for c in f.get("bid_citations") or []]
parts += _library(f)
cites = f"<div class='cites'>{''.join(parts)}</div>" if parts else ""
carried = "(沿用上次检查)" if f.get("presence") == "carried" else ""
dup = f"(同一要求在清单里出现 {len(f['duplicate_ids']) + 1} 处)" if f.get("duplicate_ids") else ""

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]