feat(qualification): 资质库和资质匹配(查标第 4 步,规则集 2026.11-r4)
- 资质库(FR-M6-08):增删改、证书扫描件(PDF/JPG/PNG,企业级密钥加密、签名下载)、 late_for 按进行中项目的投标截止日提醒、审计;前端资质库页和对话框,侧栏入口 - 资质匹配(FR-M3-05):证书类别词表(16 类,产品证书、人员证书不进匹配), QUAL.EXPIRED / LEVEL_LOW / NOT_COVERED;投标文字层按清单项定级,只有资质库记录的降一级, 出处显示「依据:资质库记录」(查标页、报告模板 v2);一类证书一个问题;评分项不逐条找响应的也查 - 响应错位规则不再把资质表的证书名称格当成照抄的要求 - 评测:qual_defects.py 派生资质情况表,D12 3/3、D18 2/2、规矩的资质表 0 误报;开发集不退化 - 决策 QL1–QL7;方案和落地记录 docs/architecture/qualification-plan.md,评测 check-eval.md §13 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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"]
|
||||
|
||||
@@ -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,
|
||||
):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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}
|
||||
|
||||
488
server/src/bid_doctor/check/qualification.py
Normal file
488
server/src/bid_doctor/check/qualification.py
Normal 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
|
||||
@@ -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。"""
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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): # 分片用完即删
|
||||
|
||||
0
server/src/bid_doctor/qualification/__init__.py
Normal file
0
server/src/bid_doctor/qualification/__init__.py
Normal file
101
server/src/bid_doctor/qualification/api.py
Normal file
101
server/src/bid_doctor/qualification/api.py
Normal 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)}",
|
||||
},
|
||||
)
|
||||
40
server/src/bid_doctor/qualification/models.py
Normal file
40
server/src/bid_doctor/qualification/models.py
Normal 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]
|
||||
246
server/src/bid_doctor/qualification/service.py
Normal file
246
server/src/bid_doctor/qualification/service.py
Normal 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
|
||||
@@ -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
BIN
server/tests/fixtures/bid-mini-quals.pdf
vendored
Normal file
Binary file not shown.
12
server/tests/fixtures/make_bid_fixtures.py
vendored
12
server/tests/fixtures/make_bid_fixtures.py
vendored
@@ -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])
|
||||
|
||||
@@ -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"]])
|
||||
|
||||
287
server/tests/test_check_qualification.py
Normal file
287
server/tests/test_check_qualification.py
Normal 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
|
||||
189
server/tests/test_qualification_flow.py
Normal file
189
server/tests/test_qualification_flow.py
Normal 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,
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user