投标文件 · 未找到
找过:{{ (f.search_scope.documents ?? []).join("、") || "全部分册" }}
@@ -478,6 +497,22 @@ const errorText = computed(() => {
margin: 0;
color: var(--text-secondary);
}
+/* 资质库是用户自己录的数据:墨色(人工),不用 AI 出处的蓝铅笔色 */
+.cite.lib {
+ border-left-color: var(--c-ink);
+}
+.cite.lib .cite-src {
+ color: var(--text-primary);
+}
+.cite.lib p {
+ margin: 2px 0 0;
+ font-size: var(--fs-caption);
+ line-height: var(--lh-caption);
+ color: var(--text-secondary);
+}
+.cite.lib p b {
+ color: var(--text-primary);
+}
.group-h {
display: flex;
align-items: center;
diff --git a/apps/web/src/router.ts b/apps/web/src/router.ts
index 51323da..28ca9d0 100644
--- a/apps/web/src/router.ts
+++ b/apps/web/src/router.ts
@@ -24,6 +24,7 @@ export const router = createRouter({
children: [
{ path: "", name: "workbench", component: () => import("@/pages/WorkbenchPage.vue") },
{ path: "projects/new", name: "project-new", component: () => import("@/pages/ProjectNewPage.vue") },
+ { path: "qualifications", name: "qualifications", component: () => import("@/pages/QualificationsPage.vue") },
{
path: "projects/:projectId/parse/:jobId",
name: "parse",
diff --git a/apps/web/src/styles/app.css b/apps/web/src/styles/app.css
index 777383c..1ec6cde 100644
--- a/apps/web/src/styles/app.css
+++ b/apps/web/src/styles/app.css
@@ -272,3 +272,23 @@ textarea.input{height:auto;min-height:80px;padding:var(--sp-2) var(--sp-3);resiz
.toast .ok{display:inline-grid;place-items:center;width:16px;height:16px;border-radius:50%;background:var(--c-green);color:var(--c-white);font-size:11px;line-height:1}
.toast button.lnk{color:var(--text-on-dark);text-decoration-color:currentColor}
.toast .sep{width:1px;align-self:stretch;margin:2px 0;background:var(--on-dark-line)}
+
+/* =====================================================================
+ * 4. 资质库用到的补充(照搬原型 docs/prototype/assets/app.css 的菜单、开关)
+ * ===================================================================== */
+/* 下拉菜单:列表行的「更多」。定位交给 Reka UI 的 DropdownMenu,这里只管外观 */
+.menu{position:absolute;z-index:45;top:calc(100% + 4px);left:0;min-width:100%;max-height:300px;overflow:auto;margin:0;padding:6px;list-style:none;background:var(--bg-surface);border:var(--line) solid var(--border-subtle);border-radius:var(--radius-card);box-shadow:var(--shadow-overlay)}
+.menu.popper{position:relative;top:auto;left:auto;min-width:160px}
+.menu [role=option],.menu [role=menuitem]{display:flex;align-items:center;gap:var(--sp-2);width:100%;min-height:34px;padding:6px 10px;border:0;border-radius:var(--radius-tag);background:none;font:var(--fs-body)/var(--lh-body) var(--font-sans);color:var(--text-primary);text-align:left;cursor:pointer;white-space:nowrap}
+.menu [role=option]:hover,.menu [role=menuitem]:hover,.menu .is-active,.menu [role=menuitem]:focus-visible,.menu [data-highlighted]{background:var(--c-n100);outline:0}
+.menu hr{border:0;border-top:var(--line) solid var(--border-subtle);margin:6px 0}
+/* 开关(长期有效等) */
+.switch{display:inline-flex;align-items:center;gap:var(--sp-2);padding:0;border:0;background:none;font:var(--fs-body) var(--font-sans);color:var(--text-primary);cursor:pointer}
+.switch .track{position:relative;flex:none;width:34px;height:20px;border:var(--line) solid var(--border-control);border-radius:var(--radius-tag);background:var(--bg-surface);transition:background-color var(--dur-fast) var(--ease),border-color var(--dur-fast) var(--ease)}
+.switch .track::after{content:"";position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:var(--radius-xs);background:var(--c-muted);transition:transform var(--dur-fast) var(--ease),background-color var(--dur-fast) var(--ease)}
+.switch:hover .track{border-color:var(--c-ink)}
+.switch[aria-checked="true"] .track{background:var(--c-ink);border-color:var(--c-ink)}
+.switch[aria-checked="true"] .track::after{transform:translateX(14px);background:var(--c-white)}
+.switch .st{min-width:2em;font-size:var(--fs-caption);color:var(--text-secondary)}
+.switch[aria-checked="true"] .st{color:var(--text-primary)}
+.switch[disabled]{color:var(--text-disabled);cursor:not-allowed}
diff --git a/docs/architecture/api-design.md b/docs/architecture/api-design.md
index 5e1455f..617d2b3 100644
--- a/docs/architecture/api-design.md
+++ b/docs/architecture/api-design.md
@@ -192,7 +192,7 @@
| 上传与文档 | 分片上传全套、挂到项目、文档列表和详情、逐页摘要、一页的句子、签名下载、发起解析、解析进度、取消、人工建出处 | 删除文档 |
| 清单 | 清单概况(含确认人、删除数、按类别计数)、清单项列表(按区域、类别筛选,可含已删除)、清单项详情(ETag)、修改(If-Match 选填)、采纳、删除恢复、人工新增、替换出处、修改记录、确认清单、版本 | 补遗更新确认(等补遗解析);列表的 `lot_scope`(等选包) |
| 检查 | 检查预览、发起、进度、取消、检查记录、问题列表(含本次已解决)、每条清单项的检查结论(C9,2026-11-04 新增)、处理、撤销、批量处理(只限提示级);投标分册类型和版本、电子投标平台加密格式识别 | 需人工核对(第 2 步) |
-| 报告、资质 | — | 全部(查标第 4、5 步) |
+| 报告、资质 | 报告:导出、生成进度、历次报告、签名下载(第 5 步);资质库:增删改、按类型 / 关键词筛选、附件(上传后挂上、签名下载、删除)、`late_for` 到期提醒(第 4 步) | — |
| 通知与事件 | 事件流(含检查进度)、通知列表、反馈 | 标为已读 |
读标修改类接口的实现约定(2026-11-03):
diff --git a/docs/architecture/check-eval.md b/docs/architecture/check-eval.md
index 200d8b2..70d74a3 100644
--- a/docs/architecture/check-eval.md
+++ b/docs/architecture/check-eval.md
@@ -276,7 +276,40 @@
留出集的正式成绩仍以 §11(冻结代码)为准:修的时候看过留出集的漏报,修后的数字有过拟合成分。
-## 13. 下一步
+## 13. 资质匹配(查标第 4 步,2026-11-04)
+
+规则集 `2026.11-r4`,方案 `qualification-plan.md`(决策 QL1–QL7)。只改规则,开发集上的模型结论全部走缓存。
+
+**1. 开发集不退化**:17 份投标文件(空资质库)的全部指标和 §12 修后一致:召回 112 / 149(75%)、致命级 25 / 32、规矩的投标文件报出问题 239、规则问题 57 / 57。资质问题 0 个:开发集的证书全在扫描件页上,空资质库什么都不报(QL3)。
+
+**2. 类别识别**(S01–S10 读标结果里资格条件、评分项中带「证书 / 资质 / 认证」的条目):认出类别 14 条,产品证书、人员证书排除 9 条(节能 / 环境标志产品认证、项目经理和技术人员资质),没认出 2 条(S03「认证证书(3 分)」、S05「数据库兼容证书」,都是产品类,正确地不判)。营业执照、法人证书、联合体规则不算资质要求。读标留出集的 S03、S06、S09 里没有可核对有效期和等级的企业资质。
+
+**3. 模拟资质库的噪音**(开发集 17 份,`run_check.py --library`):
+
+| 资质库 | 资质问题 | 说明 |
+|---|---|---|
+| 空 | 0 | |
+| 每类一条、有效、等级最高 | 4 个提示 | 都是 S01 的「CCRC 服务类别」:要求写了「安全集成」,模拟记录的名称里没写 |
+| 只有 ISO 9001 | 16 个重要 | S01 两份的资格条件和评分项「资质库里没有这类资质」,每份 8 个(含同一要求抽成两条的重复,一条评分项里两类证书各一个) |
+| 每类一条、2020 年过期 | 29 个重要 | S01(每份 8)、S04(每份 3 类 ISO)、S07(每份 ITSS、CS 两类)「按资质库记录已过期」 |
+
+真实用户的资质库介于「全有效」和「缺几类」之间:规矩的投标文件上,资质库录全了几乎不出问题;录了一部分时,没录的那几类在扫描件条目上会出「未覆盖」(重要)。
+
+**4. 派生的资质情况表**(`evals/check/qual_defects.py`):在 S01-B、S04-B、S07-B 的资格证明一章开头插一张文字的「企业资质情况表」(虚构证书,写法故意和词表别名不完全一样),q00 全部有效、q01 注入 D12(有效期早于截止日)和 D18(等级低一级)。
+
+| 指标 | 结果 |
+|---|---|
+| D12 有效期早于截止日 | 3 / 3(S01 资格条件致命,S04、S07 评分项重要) |
+| D18 等级低于要求 | 2 / 2 |
+| q00 规矩的资质表上的资质问题 | 0(空资质库、全有效资质库都是 0) |
+
+调试中修的两处:一条评分项写了两类证书时合成一个问题,规则编号只能取一个(S07 的 CS2 被 ITSS 过期吞掉)→ 改为一类证书一个问题;证书编号「CS3-3201-…」排在等级栏前面,整行读成了 CS3 → 先看短的等级格。同一要求在清单里抽成两条时各报一个,评分按「同规则 + 同出处」算一个。
+
+插表以后,响应匹配的候选句变了,13 + 8 + 12 次模型调用没有命中缓存:S01 有 5 条扫描件兜底的「请人工核对」因为文字表里能找到而消失(应当如此);S07 有 3 条条目的结论变了(1 条新的「请人工核对」、1 条从「请人工核对」变成「疑似不满足」、1 条新的「疑似不满足」),是模型看到资质表后的判断,没有逐条复核。
+
+**局限**:资质情况表和词表是同一个人对着同一批样本写的,分数说明不了泛化;真实投标文件的资质表写法、扫描件比例都没测。国动真实投标文件到了以后,请用户按公司的真实证书录资质库再跑一次(`qualification-plan.md` §5)。
+
+## 14. 下一步
1. 分项报价表和采购需求清单逐项对照(P13)**暂缓**:在开发集 7 份招标文件上试读「名称 + 数量」的采购清单表,S02、S08(服务类)没有这种表;S04 把「是否进口」一栏读成了名称;S10 采购包 2 的表跨页、第二页没有表头,漏了大半;S01 9 行里能和投标文件的分项对上名称的只有 7 行。P13 是致命级,按这个抽取质量在真实文件上会大量误报「缺项」,评测集里也没有注入过,先不上,覆盖范围里照写「还没有上线」。散文写法的单价上限同样暂缓。
2. 第 3 步冻结前:确认各项改动的单元测试都有开发集以外的反例;冻结后跑留出集(S03、S06、S09,风格 B + C)和国动真实投标文件,各一次。
diff --git a/docs/architecture/m3-plan.md b/docs/architecture/m3-plan.md
index 1615a4c..4085b82 100644
--- a/docs/architecture/m3-plan.md
+++ b/docs/architecture/m3-plan.md
@@ -12,7 +12,7 @@
| 第 1 步 链路打通 | ✅ 2026-11-04 | 见下方「第 1 步落地记录」 |
| 第 2 步 规则 | 基本完成 | 响应方式(C12)、文件要在、扫描件兜底、规则检查 `rules.py`(报价形式、形式要件、响应表)、需人工核对(`manual.py`、`manual-checks` 接口)、「报价」类清单项和分项上限(C7)。还差分项和采购需求逐项对照(P13)、散文写法的单价上限。见 `check-eval.md` §4–§7 |
| 第 3 步 AI 响应匹配 | 进行中 | 整行上下文、提示词 v2、偏离栏直读、响应错位、复查复用、硬预算;送给模型的同一行按单元格、重复条目只合并显示(C5 修订);致命项重问按数据暂不做。见 `check-eval.md` §8–§9 |
-| 第 4 步 资质库 | 未开始 | |
+| 第 4 步 资质库 | ✅ 2026-11-04 | 方案和落地记录见 `qualification-plan.md`(决策 QL1–QL7)。`qualification` 模块(增删改、附件按企业级密钥加密、`late_for` 到期提醒、审计)和前端资质库页;`check/qualification.py`:证书类别词表(16 类)、规则 `QUAL.EXPIRED / LEVEL_LOW / NOT_COVERED`,投标文字层按清单项定级、只有资质库记录的降一级,规则集 2026.11-r4。评测见 `check-eval.md` §13 |
| 第 5 步 检查报告 | ✅ 2026-11-04 | `report` 模块:导出 → worker 用 Gotenberg(Chromium)把 HTML 转成 A4 PDF → 按项目数据密钥加密存对象存储 → `report`(不可改,存哈希);`report_job` 记生成过程(迁移 0009),完成推 `report.ready`、发通知。首页有项目信息、投标文件版本、检查依据、规则版本、导出人、检查覆盖范围、汇总、免责声明;问题明细带两侧出处和处理人;附录是需人工核对的勾选结果;页眉 logo、页脚「本报告为辅助检查结果,仅供投标前自查参考」+ 页码;测试检查模板文字不出现「通过 / 合格 / 保证」。前端「报告」标签页:选项、导出、内嵌预览、历次报告下载。「证据索引」页没有单独做(出处已在问题明细里逐条列出)。同时补了项目状态流转(递交 / 撤销递交 / 归档 / 撤销归档,FR-M6-02):致命问题未处理也能递交,确认框说明;递交后只读,撤销归档回到归档前的状态,都写审计 |
| 第 6 步 前端补齐 | 部分 | 需人工核对、重复问题折叠、报告页已做;键盘操作、批量处理提示、判断有误反馈还没做 |
diff --git a/docs/architecture/openapi.yaml b/docs/architecture/openapi.yaml
index e32b860..e222054 100644
--- a/docs/architecture/openapi.yaml
+++ b/docs/architecture/openapi.yaml
@@ -624,7 +624,7 @@ paths:
filename: { type: string, maxLength: 255 }
size_bytes: { type: integer, minimum: 1 }
sha256: { type: string, pattern: '^[0-9a-f]{64}$', description: 选填;提供时完成上传时校验 }
- purpose: { type: string, enum: [project_document, qualification_file] }
+ purpose: { type: string, enum: [project_document, qualification_file], description: '资质附件只收 PDF、JPG、PNG,单个不超过 20MB' }
responses:
'201':
description: OK
@@ -1593,10 +1593,15 @@ paths:
tags: [qualifications]
operationId: listQualifications
summary: 资质库
+ description: 按「有效期至」从近到远排,长期有效的在最后。
parameters:
- name: kind
in: query
schema: { $ref: '#/components/schemas/QualificationKind' }
+ - name: q
+ in: query
+ description: 按名称、证书编号、发证机构搜索
+ schema: { type: string, maxLength: 100 }
responses:
'200':
description: OK
@@ -1944,7 +1949,7 @@ components:
ProjectStatus: { type: string, enum: [tracking, drafting, checked, submitted, archived] }
LegalRegime: { type: string, enum: [gov_procurement, tendering, unknown] }
DocumentRole: { type: string, enum: [tender, addendum, clarification, bid_volume] }
- DetectedFormat: { type: string, enum: [pdf, docx, doc, wps, ofd, encrypted_bid, unknown] }
+ DetectedFormat: { type: string, enum: [pdf, docx, doc, wps, ofd, encrypted_bid, jpeg, png, unknown], description: jpeg、png 只出现在资质附件(purpose = qualification_file)上 }
JobStatus: { type: string, enum: [queued, running, succeeded, failed, cancelled] }
Category: { type: string, enum: [qualification, invalidity, scoring, schedule, tech_param, format_doc, price] }
Importance:
@@ -2866,12 +2871,13 @@ components:
items: { $ref: '#/components/schemas/Category' }
rule:
type: [object, 'null']
+ description: 规则问题的规则编号和说明(detector = rule 时有);资质匹配的问题同时挂清单项
properties:
code: { type: string, examples: [PRICE.CAPITAL_MISMATCH] }
description: { type: string }
detail:
type: object
- description: 按类型的补充信息:参数的要求值和响应值、资质预警种类(expired / insufficient_level / not_covered)、从投标文件读到的金额
+ description: 按类型的补充信息:参数的要求值和响应值、从投标文件读到的金额;资质问题(type = qualification)有 kind(expired / level_low / not_covered)、source(bid_text / library)、deadline,issues[] 里 source = library 的带 library(资质库记录的快照:id、name、kind、level、cert_no、valid_to),界面显示为「依据:资质库记录」
tender_citations:
type: array
items: { $ref: '#/components/schemas/Citation' }
@@ -3016,16 +3022,16 @@ components:
properties:
name: { type: string, minLength: 1, maxLength: 200 }
kind: { $ref: '#/components/schemas/QualificationKind' }
- level: { type: [string, 'null'] }
- cert_no: { type: [string, 'null'] }
- issuer: { type: [string, 'null'] }
+ level: { type: [string, 'null'], maxLength: 40 }
+ cert_no: { type: [string, 'null'], maxLength: 80, description: 界面上必填(查标时用它核对附件),接口可空,以后批量导入时不一定读得全 }
+ issuer: { type: [string, 'null'], maxLength: 120 }
valid_from: { type: [string, 'null'], format: date }
- valid_to: { type: [string, 'null'], format: date, description: 空表示长期有效 }
+ valid_to: { type: [string, 'null'], format: date, description: 空表示长期有效;要晚于 valid_from }
Qualification:
allOf:
- $ref: '#/components/schemas/QualificationInput'
- type: object
- required: [id, files, created_at, updated_at]
+ required: [id, files, late_for, created_at, updated_at]
properties:
id: { $ref: '#/components/schemas/Id' }
files:
@@ -3037,6 +3043,16 @@ components:
id: { $ref: '#/components/schemas/Id' }
filename: { type: string }
size_bytes: { type: integer }
+ late_for:
+ type: array
+ description: 进行中的项目(跟进中、编制中、已检查,截止日不早于今天)里,投标截止日晚于「有效期至」的项目。到期按每个项目的截止日判断,不按今天
+ items:
+ type: object
+ required: [project_id, name, deadline]
+ properties:
+ project_id: { $ref: '#/components/schemas/Id' }
+ name: { type: string }
+ deadline: { type: string, format: date }
created_at: { $ref: '#/components/schemas/Timestamp' }
updated_at: { $ref: '#/components/schemas/Timestamp' }
diff --git a/docs/architecture/qualification-plan.md b/docs/architecture/qualification-plan.md
new file mode 100644
index 0000000..5b8c9ee
--- /dev/null
+++ b/docs/architecture/qualification-plan.md
@@ -0,0 +1,189 @@
+# 资质库和资质匹配实现方案 v0.1(2026-11-04)
+
+> 范围:查标实现计划(`m3-plan.md`)第 4 步,即 FR-M6-08 最小资质库 + FR-M3-05 资质匹配检查。
+> 依据:`03-functional-requirements.md`、`05-it-integration-domain.md` R07 / R08、决策 G4 / C10 / DM4、`data-model.md`(`qualification` `qualification_file`)、`openapi.yaml`(`/qualifications`)、原型 `qualifications.html` 和 `results.html` 的 f6、评测规范 `evals/check/GUIDELINES.md` D11 / D12 / D14 / D15。
+> 状态:QL1–QL7 已全部按建议确认(2026-11-04,`docs/decisions.md`)。
+
+## 0. 结论速览
+
+- **表、接口、原型都已经有了**。基线迁移里已建 `qualification` 和 `qualification_file` 两张表;`openapi.yaml` 里已定义 7 个操作;原型资质库页的交互也已定稿。资质库本身是一个普通的增删改模块,工作量主要在前端页面和附件。
+- **难点在匹配,不在资质库**。从样本看(§1),企业资质在政府采购项目里多数是**评分项**,不是资格条件;而且大量「证书」要求是**产品证书**(节能认证、CCC、商用密码)或**人员证书**,资质库管不了,必须先排除。
+- **匹配不调模型**:按 IT 集成常见证书建一张类别词表,招标侧的要求、投标文字层、资质库三方都映射到类别再比较。认不出类别的,不做判断,写进检查覆盖范围。
+- **证据有两个来源,可信度不同**:投标文字层(比如投标文件里的「资质情况表」)上读到的有效期和等级,有原文出处,按清单项定级;只有资质库记录、投标文件那边是扫描件或没写日期时,**降一级**,说明里写明「按资质库记录」。
+- **资质库是空的就不做资质库核对**,只在覆盖范围里写一句。不能因为用户没录,就报一片「未覆盖」。
+
+## 1. 样本里的资质要求长什么样
+
+在 S01–S10 读标结果里筛出和证书有关的清单项(`evals/out/runs/fix3`),分成四种:
+
+| 种类 | 例子 | 清单类别 | 资质库能不能管 |
+|---|---|---|---|
+| 企业资质(资格条件) | 电子与智能化工程专业承包资质、《安全生产许可证》(S01) | 资格条件 + 无效 | **能**,致命 |
+| 企业资质(评分项) | ISO 27001 / 9001 / 20000(S09)、CCRC 信息安全服务资质一级、CCIA 一级(S01) | 评分 | **能**,重要 |
+| 软件著作权(评分项) | 「虚拟化云平台软件计算机软件著作权登记证书(1 分)」、「软件著作权证书(3 分)」 | 评分 | 部分:软著长期有效、没有等级,只能看有没有 |
+| 产品证书 | 节能产品认证、CCC、商用密码产品认证、IPv6 Ready、网络安全专用产品检测证书 | 技术参数 / 无效 | **不能**:证书是厂商的、跟着产品走 |
+| 人员证书 | 「虚拟化维护人员具有虚拟化认证证书」 | 技术参数 | **不能**:人员库是 V1 |
+
+结论:
+
+1. 政府采购项目不能把和项目无关的资质设为资格条件,所以企业资质出现在资格条件里的少(10 份里只有 S01),多数在评分项里。这样一来,资质问题大多是「重要」,不是「致命」。
+2. 产品证书和人员证书必须先排除,否则资质库会对它们报「未覆盖」,造成大量误报。
+3. 真实投标文件里的证书基本都是盖章扫描件。不少 IT 投标文件另附一张文字的「企业资质情况表」,列出证书名称、编号和有效期,这是投标文字层上唯一能读到有效期的地方。
+
+## 2. 资质库(FR-M6-08)
+
+### 2.1 数据
+
+用基线里已有的两张表,**不加列、不加迁移**:
+
+- `qualification`:名称、类型(资质证书 / 体系认证 / 软件著作权 / 其他)、等级、证书编号、发证机构、有效期起止(「有效期至」为空表示长期有效)。
+- `qualification_file`:附件。对象键 `t/
/q//`,用**企业级数据密钥**加密(DM4)。
+
+证书类别(§3.1)在用到时从名称和类型算出来,不存库。词表改了以后,旧记录不用迁移。
+
+### 2.2 接口
+
+按 `openapi.yaml` 现有的 7 个操作实现:列表(可按类型筛)、新增、修改、删除(连同附件)、挂附件(走现有的分片上传,`purpose = qualification_file`)、附件下载地址、删除附件。只加两处:
+
+- `Qualification` 响应加一个字段 `late_for`:进行中的项目里,投标截止日晚于这张证书「有效期至」的项目(项目编号、名称、截止日)。原型里的「N 张证书会在进行中项目的投标截止前到期」提醒就靠这个字段。「进行中」指状态是跟进中、编制中、已检查,且截止日不早于今天。
+- 列表接口加关键词参数 `q`,按名称、编号、发证机构搜索。
+
+权限点用已有的 `qualification.edit`。增删改和附件操作都写审计:`qualification.create / update / delete`、`qualification_file.attach / delete`,修改记录前后的值。不扣额度。
+
+### 2.3 校验
+
+- 名称必填;「有效期至」必须晚于「有效期起」。
+- 证书编号、「有效期至」:原型要求必填(长期有效的打开开关),**界面按原型必填,接口保持可空**,因为以后从投标文件批量导入时不一定读得全。
+- 附件:PDF / JPG / PNG,单个不超过 20 MB,一条资质可以挂多个附件。
+
+### 2.4 删除
+
+删除是硬删:同时删行、删对象、写审计。原型在确认框之外还有「撤销」提示,**这一步不做撤销**(QL6):要撤销就得软删除,而软删除和「彻底删除」(G10)冲突;确认框已经够了。已导出的报告不受影响,因为报告里存的是生成时的快照。
+
+企业注销时按 `data-model.md` §6.1 的顺序删资质库,企业级密钥一并销毁。
+
+### 2.5 前端
+
+照原型 `qualifications.html` 实现:侧栏「资质库」(图标 `award`)、类型分段筛选、关键词搜索、按「有效期至」排序(长期有效的排最后)、到期提醒条、新增 / 编辑对话框(日期选择器上标出进行中项目的截止日)、附件上传和预览、删除确认。页面底部注明「业绩、人员、历史标书等资产库在 V1 提供」。
+
+## 3. 资质匹配(FR-M3-05)
+
+### 3.1 证书类别词表
+
+`check/qualification.py` 里放一张词表,规则集版本跟着升(2026.11-r4)。每个类别包括:别名(含国标等同写法)、等级写法,以及等级怎么比(数字越大越高,还是「一级」最高)。初版覆盖 IT 集成常见的企业资质:
+
+| 类别 | 别名例子 | 等级 |
+|---|---|---|
+| ISO 9001 质量 | GB/T 19001 | 无 |
+| ISO 14001 环境 | GB/T 24001 | 无 |
+| ISO 45001 职业健康安全 | OHSAS 18001、GB/T 45001、GB/T 28001 | 无 |
+| ISO 27001 信息安全 | GB/T 22080 | 无 |
+| ISO 20000 IT 服务 | GB/T 24405 | 无 |
+| ISO 22301 业务连续性 | GB/T 30146 | 无 |
+| CMMI | 能力成熟度模型集成 | 1–5,大为高 |
+| 信息系统建设和服务能力 | CS、CS1–CS5 | 1–5,大为高 |
+| ITSS 运维 | 信息技术服务标准、运行维护 | 一级最高 |
+| CCRC 信息安全服务资质 | 中国网络安全审查技术与认证中心;子类:安全集成、风险评估、应急处理、软件安全开发等 | 一级最高 |
+| CCIA 信息系统业务安全服务资质 | 中国网络安全产业联盟 | 一级最高 |
+| 电子与智能化工程专业承包 | 建筑业企业资质 | 一级 > 二级 |
+| 建筑智能化系统设计专项 | 工程设计资质 | 甲 > 乙 |
+| 涉密信息系统集成 | 涉密资质 | 甲 > 乙 |
+| 安全生产许可证 | | 无 |
+| 软件著作权 | 计算机软件著作权登记证书、软著 | 无,长期有效 |
+
+**排除词**:要求里出现「产品」「设备」「所投」「厂商」「制造商」「节能」「环境标志」「CCC」「3C」「商用密码产品」「人员」「工程师」「项目经理」这类主体时,判定为产品证书或人员证书,不进资质匹配,仍按现在的流程处理(找响应、扫描件兜底)。
+
+认不出类别、但清单类别是资格条件或评分项、文字里又有「证书 / 资质 / 认证」字样的,计入覆盖范围:「N 条资质要求没认出是哪类证书,有效期和等级没有核对」。
+
+### 3.2 三方怎么对上
+
+对每条进入资质匹配的清单项(类别是资格条件或评分项,且认出了证书类别):
+
+1. **招标侧**:类别、要求的等级(「CS3 级及以上」「一级」)、子类(CCRC 的「安全集成」)。要求原文就是招标侧出处。
+2. **投标文字层**:在响应匹配找到的证据句附近,以及投标文件里写着同一类别的表格行中,读证书编号、等级、有效期(「有效期至」「有效期:A 至 B」「截止日期」)。日期解析复用 `rules.py` 里的写法。只取**和这个类别同一行、或同一段落里只有这一张证书**的日期;附近出现两张以上证书、日期归属不清时不读。
+3. **资质库**:同一类别的记录。同类有几条(旧证书和换发后的新证书)时取最有利的一条:有效期最晚、等级最高。
+
+有效期比较用项目基本信息里的**投标截止日**(人工改过的为准),不用今天。「有效期至」当天仍算有效,即 `有效期至 < 截止日` 才算过期。项目没有截止日时,不比有效期,写进覆盖范围。
+
+### 3.3 规则和定级
+
+「按清单项定级」:资格条件 → 致命,评分项 → 重要(沿用 `findings.severity`)。
+
+| 规则 | 什么时候报 | 证据来源 | 级别 |
+|---|---|---|---|
+| `QUAL.EXPIRED` | 证书在投标截止日已过期 | 投标文字层 | 按清单项 |
+| | | 只有资质库 | 降一级(致命 → 重要,重要不变) |
+| `QUAL.LEVEL_LOW` | 等级能比较,且低于要求 | 投标文字层 | 按清单项 |
+| | | 只有资质库 | 降一级 |
+| `QUAL.NOT_COVERED` | 投标文字层没找到(结论是「未找到」或「扫描件」),而且资质库**有记录但没有这一类** | 资质库 | 重要(不给致命:资质库可能没录全) |
+| (不报问题) | 资质库里有、而且有效、等级够 | | 扫描件兜底那条「请人工核对」保留,说明里补一句:资质库里有《X》(编号…,有效期至…),请核对扫描件里附的是这一张 |
+
+规定:
+
+- **投标文字层和资质库冲突时,以投标文件为准**。投标文件里的日期已过期、资质库里有换发后的新证书时,仍按投标文字层报 `QUAL.EXPIRED`,说明里写「资质库里有有效期至 … 的新证书,投标文件附的可能是旧证书」。评审看的是投标文件。
+- **一条清单项只出一个资质问题**。资质问题比扫描件兜底的「请人工核对」更具体,有资质问题时代替那一条;投标文件是文字版且确实没找到(「未找到响应」,致命)时,保留原问题,只在说明里补资质库的情况(「资质库里有这张证书,可能忘了附」)。
+- **软件著作权**不查有效期和等级,只在扫描件兜底说明里补「资质库里有 N 件软件著作权」。要求里写了具体软件名的,也不按名称硬配:软件名的写法太散。
+- **CCRC 子类**:要求写了子类(安全集成),资质库记录的名称里没有这个子类,按 `QUAL.NOT_COVERED` 报,说明写「资质库里的 CCRC 证书没写服务类别,请核对是不是『安全集成』」,级别是提示。
+- **资质库是空的**:所有资质库规则都不跑,覆盖范围写「资质库还没有录入资质,有效期和等级没有按资质库核对」。查标页在覆盖范围旁边放一个去资质库的链接。
+- 说明只写事实,不写整改建议。原型 f6 里「请上传换发后的证书」这一句去掉,改成「按资质库记录,有效期至 2026年11月15日,早于投标截止日 11月20日」。
+
+### 3.4 问题的存法和出处
+
+- `finding.type = qualification`,`detector = rule`,`rule_code` 是上面的编号,`checklist_item_id` 填对应清单项,`match_key = rule:QUAL.EXPIRED:<清单项>`,复查沿用按现有机制走。
+- `detail` 里存:`kind`(过期 / 等级不足 / 未覆盖)、`source`(`bid_text` / `library`)、截止日、要求的等级,以及用到的资质库记录的**快照**(编号、名称、等级、有效期、记录 id)。资质库记录以后改了、删了,旧检查的问题和报告不变。
+- 出处:招标侧照常挂清单项的原文出处;投标文字层来源的挂投标侧句子;资质库来源的**没有投标侧出处**,界面和报告显示「依据:资质库记录」块(名称、编号、有效期,能点开那条记录和附件)。这一点要你确认(QL2)。
+- 覆盖范围加一节「资质核对」:资质库有几条、本次核对了几条资质要求、几条按资质库判断、几条没认出类别、产品证书和人员证书各排除了几条。
+
+### 3.5 在流水线里的位置
+
+放在 `runner.py` 的响应匹配和扫描件兜底之后、`rules.run` 之前,读一次本企业的资质库,不调模型、不计费。复查时重新算:资质库可能改过,结果不复用。
+
+## 4. 不在这一步做的
+
+| 不做 | 原因 |
+|---|---|
+| D14 证书主体和投标人不一致(子公司、曾用名) | 不在 FR-M3-05 的三种预警里;证书主体只在扫描件上;资质库挂在企业主体下,主体本来就一致。等 OCR(S5)后再看 |
+| D15 业绩 / 人员证明的时间窗口 | 业绩库、人员库是 V1 |
+| 产品证书核对 | 厂商的证书,不是用户的资质(红线 5:不从外部补数据) |
+| 从投标文件或附件里自动识别、批量导入资质 | 要 OCR / 多模态;MVP 只手工录入(01-scope-roles:「手工录入 + 有效期」) |
+| 读标清单上显示「资质库:ISO 9001 有效期至 …」 | 并入第 6 步前端补齐 |
+| 软著按软件名称配对、按件数核对评分 | 写法太散,认不准的不报 |
+
+## 5. 评测
+
+- **类别识别**:把 S01–S10 读标结果里和证书有关的清单项全部人工标注(企业资质 / 软著 / 产品证书 / 人员证书 / 无关,再加类别和等级),约几十条,作为词表的单元测试。读标留出集的 S03、S06、S09 不在开发里看,词表写完后跑一次,只用来报数字。
+- **规则**:每条规则配正例和反例单元测试:截止日当天到期不报;同类有新证书也有旧证书取新的;等级写法认不出时不比较;产品证书不进匹配;资质库空不报。
+- **评测集**:生成器现在不生成证书文字。给风格 A / B 的规矩投标文件加一张「企业资质情况表」(虚构的证书、编号、有效期),注入 D12(有效期早于截止日;整张表的证书都过期)和「等级不足」两类问题,按 GUIDELINES 的流程请 Fable 复核、你抽查。资质库那一侧在评测里用虚构的资质库模拟,分「空 / 全 / 缺一类 / 有过期」四档。
+- **局限**:评测集里的证书表是看着词表生成的,分数说明不了泛化。国动的真实投标文件到了以后,请你按自己公司的真实证书录资质库,再跑一次,这才是这一步真正的检验。
+
+## 6. 工作量和顺序
+
+1. 后端 `qualification` 模块:增删改、附件、`late_for`、审计,集成测试(含行级安全、企业级密钥)。约半天。
+2. 前端资质库页。约半天。
+3. 类别词表、标注样本、`check/qualification.py`、接入流水线、覆盖范围、问题渲染和报告。约 1 天。
+4. 评测集加证书表和注入,复核。约半天。
+
+## 7. 待确认事项
+
+| 编号 | 事项 | 建议 |
+|---|---|---|
+| QL1 | 匹配方式 | **规则 + 证书类别词表,不调模型**。认不出的不判断、写进覆盖范围。理由:资质名称是有限集合,规则可解释,不花钱 |
+| QL2 | 只有资质库记录、没有投标侧出处时能不能出问题 | **能出,降一级**,出处显示「依据:资质库记录」。资质库是用户自己录的数据,不是 AI 结论,和红线 2「AI 结论要能回溯原文」不冲突;如果不出,扫描件为主的真实投标文件就基本查不出过期 |
+| QL3 | 「未覆盖」怎么报 | 资质库**有记录但没有这一类**时报「重要」;资质库是空的不报,只写覆盖范围 |
+| QL4 | 投标文件和资质库冲突时 | **以投标文件为准**,说明里提一句资质库里有新证书 |
+| QL5 | 免费版能不能用资质库 | **能用**。01-scope-roles 把资质库写在专业版下,但它不花额度、没有成本;免费版只有 1 个项目,用来试用完整的查标链路,缺了资质库体验就不完整 |
+| QL6 | 删除要不要撤销 | **不要**,只留确认框;撤销需要软删除,和「彻底删除」冲突 |
+| QL7 | D14(主体不一致)、D15(时间窗口) | **不在这一步做**,评测规范里这两类的实现阶段改为「OCR 之后 / V1」 |
+
+## 8. 落地记录(2026-11-04)
+
+和上文设计不一致、或实现时补的,记在这里:
+
+- **一类证书一个问题**(改 §3.3「一条清单项只出一个资质问题」):一条评分项常写几类证书(S07「ITSS 三级得 1 分;CS3 及以上得 2 分」),一张过期、一张等级不够是两件事,合成一条时规则编号只能取一个。改为每条清单项里每类证书出一个问题,标题写证书名(「证书等级低于要求:信息系统建设和服务能力等级」),`match_key = rule:QUAL.*:<清单项>:<证书类别>`。
+- **评分项不逐条找响应的也查**:applicability 把没写「提供……证书」的评分项跳过了(「评分标准本身」),而企业资质多数就在评分项里。资质匹配改为看所投包里的全部清单项;这类条目没有响应结论,投标文字层和资质库的过期、等级照常提示,「未覆盖」不报(不知道投标人报不报这一项分)。
+- **等级按证书各读各的**:要求里属于这一类证书的那一段(从它的名称到下一类证书的名称)里读等级;投标侧先看短的格子(等级栏),证书编号里常带等级字样(「CS3-3201-…」);大写数字(「贰级」)也认。
+- **响应错位规则收窄**(`check/deviation.py`):资质情况表的名称格(「安全生产许可证」,比要求短、以证书名结尾)不算「照抄的要求格」,右边的编号、有效期不算「响应对不上」。端到端测试里发现的。
+- **营业执照、法人证书、联合体的资质认定规则**不算「没认出类别的资质要求」,不进覆盖范围的计数。
+- **出处**:资质问题挂清单项自己的招标侧出处(`Hit.tender_refs`);问题接口的 `rule` 改为规则问题都带(原来挂了清单项就不带);报告模板 v2 在问题明细里写「依据:资质库记录《…》」。
+- **前端**:资质库页和对话框(日期可直接键入,接受 2026/11/20、2026年11月20日等写法;日历弹层留到第 6 步);查标页资质问题显示「依据:资质库记录」块,墨色(用户录入的数据,不用 AI 出处的蓝铅笔色)。
+- 评测见 `check-eval.md` §13。
diff --git a/docs/decisions.md b/docs/decisions.md
index 3c3318c..0d4158a 100644
--- a/docs/decisions.md
+++ b/docs/decisions.md
@@ -4,6 +4,22 @@
---
+## QL1–QL7 · 2026-11-04 · 资质库和资质匹配:全部按建议
+
+依据 `docs/architecture/qualification-plan.md` §7。
+
+| # | 决策 | 理由 |
+|---|---|---|
+| QL1 | 资质匹配用规则 + 证书类别词表,不调模型;认不出类别的不判断,写进检查覆盖范围 | 资质名称是有限集合,规则可解释、不花钱 |
+| QL2 | 投标侧没有出处、只有资质库记录时也出问题,级别降一级,出处显示「依据:资质库记录」 | 资质库是用户自己录的数据,不是 AI 结论,不违反红线 2;真实投标的证书多是扫描件,不出就基本查不出过期 |
+| QL3 | 「未覆盖」只在资质库有记录、但没有这一类时报,级别「重要」;资质库是空的不报,只写覆盖范围 | 用户没录不等于没有;空库报一片未覆盖是误报 |
+| QL4 | 投标文字层和资质库冲突时以投标文件为准,说明里提资质库里有新证书 | 评审看的是投标文件 |
+| QL5 | 免费版也能用资质库 | 不花额度、没有成本;免费版用来试用完整的查标链路 |
+| QL6 | 删除资质不做撤销,只留确认框 | 撤销要软删除,和「彻底删除」(G10)冲突 |
+| QL7 | D14(证书主体不一致)、D15(业绩 / 人员时间窗口)不在第 4 步做 | 不在 FR-M3-05 的三种预警里;证书主体只在扫描件上;业绩、人员库是 V1 |
+
+- **影响**:资质库不加迁移;`Qualification` 响应加 `late_for`、列表加 `q`;规则集升到 2026.11-r4;评测规范 D14、D15 的实现阶段改为「OCR 之后 / V1」;原型 f6 的「请上传换发后的证书」不进产品(说明不写整改建议)。
+
## E2 · 2026-11-04 · 先修留出集暴露的 5 处,国动真实投标文件之后再跑
- **决策**:留出集跑完后(`check-eval.md` §11),先修暴露的 5 处(响应方式识别过度触发、偏离栏只认开发集写法、附加条件 / 另报价按措辞枚举、「精确到年月日」误报、PDF 异体字),国动真实投标文件用修好以后的代码跑,而不是冻结代码。
diff --git a/evals/check/GUIDELINES.md b/evals/check/GUIDELINES.md
index be1ce55..32e6948 100644
--- a/evals/check/GUIDELINES.md
+++ b/evals/check/GUIDELINES.md
@@ -61,11 +61,12 @@
| D09 | 响应对象错位 | `unresponded` | 按要求项 | 写的是另一台设备的同名参数;另一个采购包;整章是另一个型号 | 第 3 步 |
| D10 | 符合性条件响应表填「未完全响应」或空着 | `negative_deviation` | 致命 | 空着;「未完全响应」;「部分响应」 | 第 3 步 |
| D11 | 资格证明材料缺失 | `unresponded` | 致命 | 目录列了、正文没有;整份缺失;给的是别的证书 | 第 3、4 步 |
-| D12 | 资格证书过期(按投标截止日) | `qualification`(过期) | 致命 | 早于截止日;整本证书过期 | 第 4 步 |
+| D12 | 资格证书过期(按投标截止日) | `qualification`(过期) | 资格致命、评分重要 | 早于截止日;整本证书过期 | 第 4 步(`qual_defects.py` 派生,规则问题口径评分) |
| D13 | 评分项要的证明材料缺失 | `unresponded` | 重要 | 业绩合同缺;人员证书缺;社保缺 | 第 3 步 |
-| D14 | 证书主体和投标人不一致 | `qualification` | 致命 | 子公司;分公司;曾用名 | 第 4 步 |
-| D15 | 业绩 / 人员证明的时间窗口不符 | `qualification` / `unresponded` | 资格致命、评分重要 | 业绩合同在近三年之外;社保月份不在要求区间 | 第 4 步 |
+| D14 | 证书主体和投标人不一致 | `qualification` | 致命 | 子公司;分公司;曾用名 | OCR 之后(决策 QL7) |
+| D15 | 业绩 / 人员证明的时间窗口不符 | `qualification` / `unresponded` | 资格致命、评分重要 | 业绩合同在近三年之外;社保月份不在要求区间 | V1(业绩、人员库,决策 QL7) |
| D16 | 授权代表的社保证明缺失(文件要求时) | `unresponded` | 致命 | 没附;附的是别人的;月份不对 | 第 3 步 |
+| D18 | 资质证书等级低于要求 | `qualification`(等级不足) | 按要求项 | 低一级;证书编号里的等级和等级栏不一致 | 第 4 步 |
| D17 | 多包:采购包号空着或写错 | `formality` | 重要 | 偏离表包号空;授权书包号写错;分项表包号写错 | 第 2 步 |
(v0.1 的 D04「换算后不满足」并入 D03 当变体。)
diff --git a/evals/check/qual_defects.py b/evals/check/qual_defects.py
new file mode 100644
index 0000000..2baf66b
--- /dev/null
+++ b/evals/check/qual_defects.py
@@ -0,0 +1,97 @@
+"""从规矩的投标文件派生带「企业资质情况表」的投标文件(qualification-plan §5):q00 证书都有效、等级够;
+q01 注入 D12(有效期早于投标截止日)和 D18(等级低于要求)。
+
+ cd server && ../.venv/bin/python ../evals/check/qual_defects.py <规矩的投标文件> <输出>
+ 然后 ../.venv/bin/python ../evals/check/render.py <输出>
+
+- 资质情况表是文字表格(证书原件在后面的扫描件页上,和规矩的那份一样),插在资格证明材料那一章的开头;
+- 证书名称、编号、日期都是虚构的,写法故意和词表的别名不完全一样(国标号在前、括注英文缩写、日期几种写法);
+- 响应清单照抄规矩的那份(单元都是 expect=ok),rule_defects 记注入的问题;不挂标注单元,不需要复核(E1 只管单元);
+- 局限:表是对着 check/qualification.py 的词表写的,分数说明不了泛化(qualification-plan §5)。
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import pathlib
+from typing import Any
+
+ROOT = pathlib.Path(__file__).resolve().parent
+OUT = ROOT.parent / "out" / "check"
+HEADER = ["序号", "证书名称", "证书编号", "等级", "发证机构", "有效期"]
+
+# 招标文件 → 资质情况表的行(名称、编号、等级、发证机构、有效期),全部虚构
+CERTS: dict[str, list[list[str]]] = {
+ "S01": [
+ ["建筑业企业资质证书(电子与智能化工程专业承包)", "D232000001", "贰级", "某省住房和城乡建设厅(虚构)", "2024年5月21日至2029年5月20日"],
+ ["安全生产许可证", "(苏)JZ安许证字〔2023〕000016", "—", "某省住房和城乡建设厅(虚构)", "2023年11月16日至2027年11月15日"],
+ ["信息系统业务安全服务资质证书(CCIA)", "CCIA-2025-000231", "一级", "某网络安全行业组织(虚构)", "2025-02-01至2028-01-31"],
+ ["信息安全服务资质认证证书(安全集成)", "CCRC-2024-ISV-000188", "一级", "某认证中心(虚构)", "有效期至2027.06.30"],
+ ],
+ "S04": [
+ ["GB/T 19001-2016/ISO 9001:2015 质量管理体系认证证书", "00125Q30001R1M", "—", "某认证有限公司(虚构)", "2025年3月10日至2028年3月9日"],
+ ["环境管理体系认证证书(ISO 14001)", "00125E30002R1M", "—", "某认证有限公司(虚构)", "2025年3月10日至2028年3月9日"],
+ ["职业健康安全管理体系认证证书 GB/T 45001-2020", "00125S30003R1M", "—", "某认证有限公司(虚构)", "2025年3月10日至2028年3月9日"],
+ ],
+ "S07": [
+ ["信息技术服务标准(ITSS)运行维护符合性证书", "ITSS-YW-2-320020250001", "二级", "某行业协会(虚构)", "2025年6月1日至2028年5月31日"],
+ ["信息系统建设和服务能力等级证书", "CS3-3201-2024000077", "CS3", "某行业联合会(虚构)", "2024-12-31至2028-12-30"],
+ ],
+}
+# 招标文件 → 注入的问题:(行号, 列号, 新值, 问题编号, 写法)
+DEFECTS: dict[str, list[tuple[int, int, str, str, str]]] = {
+ "S01": [
+ (1, 5, "2023年11月16日至2026年11月15日", "D12", "资格条件的证书早于截止日到期"),
+ (2, 3, "二级", "D18", "评分项要求一级,写的是二级"),
+ ],
+ "S04": [(1, 5, "2023年3月10日至2026年3月9日", "D12", "评分项的证书已过期")],
+ "S07": [
+ (0, 5, "2023年6月1日至2026年5月31日", "D12", "评分项的证书已过期"),
+ (1, 3, "CS2", "D18", "评分项要求 CS3 及以上,写的是 CS2"),
+ ],
+}
+
+
+def table(rows: list[list[str]], vol: str) -> list[dict[str, Any]]:
+ return [
+ {"type": "para", "text": "企业资质情况表(证书复印件附后)", "vol": vol},
+ {
+ "type": "table",
+ "header": HEADER,
+ "rows": [{"cells": [str(i + 1), *r]} for i, r in enumerate(rows)],
+ "vol": vol,
+ },
+ ]
+
+
+def main() -> None:
+ ap = argparse.ArgumentParser()
+ ap.add_argument("clean")
+ ap.add_argument("out")
+ ap.add_argument("kind", choices=["q00", "q01"])
+ a = ap.parse_args()
+ src = OUT / a.clean
+ spec = json.loads((src / "spec.json").read_text())
+ manifest = json.loads((src / "manifest.json").read_text())
+ tender = spec["tender"]
+ rows = [list(r) for r in CERTS[tender]]
+ defects = []
+ if a.kind == "q01":
+ for n, (row, col, value, code, variant) in enumerate(DEFECTS[tender], start=1):
+ rows[row][col - 1] = value
+ defects.append({"id": f"{code}-{n}", "type": code, "variant": variant, "t": value})
+ vol = spec["volumes"][0]
+ at = next(i for i, b in enumerate(vol["blocks"]) if b.get("type") == "heading" and b.get("form") == "evidence")
+ vol["blocks"][at + 1 : at + 1] = table(rows, vol["blocks"][at].get("vol") or "v1")
+ out = OUT / a.out
+ out.mkdir(parents=True, exist_ok=True)
+ spec["bid"] = a.out
+ manifest.update(bid=a.out, kind="clean" if a.kind == "q00" else "sloppy", density="qualification", rule_defects=defects)
+ (out / "spec.json").write_text(json.dumps(spec, ensure_ascii=False, indent=1))
+ (out / "manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=1))
+ print(f"{a.out}:{a.kind},资质情况表 {len(rows)} 行,注入 {[d['id'] for d in defects]}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/evals/check/run_check.py b/evals/check/run_check.py
index d85b833..87a1add 100644
--- a/evals/check/run_check.py
+++ b/evals/check/run_check.py
@@ -25,13 +25,14 @@ from bid_doctor.ai import prompts
from bid_doctor.ai import service as ai_service
from bid_doctor.ai.providers.base import near_limit
from bid_doctor.ai.service import OutputIncomplete, provider
-from bid_doctor.check import applicability, deviation, findings, match, rules
+from bid_doctor.check import applicability, deviation, findings, match, qualification, rules
from bid_doctor.checklist import response_mode
from bid_doctor.checklist import price
from bid_doctor.checklist.extract import amount_yuan
from bid_doctor.core.config import get_settings
from bid_doctor.core.db import TenantContext
from bid_doctor.document.pipeline import text_layer
+from bid_doctor.qualification.service import Record
EVALS = pathlib.Path(__file__).resolve().parents[1]
sys.path.insert(0, str(EVALS))
@@ -175,6 +176,19 @@ def facts(tender: str, run: str, items: dict[UUID, dict[str, Any]]) -> rules.Fac
)
+def library(kind: str) -> list[Record]:
+ """模拟的资质库(qualification-plan §5):empty 空库;valid 每类一条、有效、等级最高;partial 只有 ISO 9001;
+ expired 每类一条、2020 年就过期。软著不进模拟(不查有效期)。"""
+ best = {"asc": "5级", "cn": "一级", "jia": "甲级"}
+ fams = [f for f in qualification.FAMILIES if f.key != "softcopy"]
+ if kind == "partial":
+ fams = [qualification.BY_KEY["iso9001"]]
+ if kind == "empty":
+ return []
+ to = dt.date(2020, 1, 1) if kind == "expired" else dt.date(2030, 12, 31)
+ return [Record(uuid4(), f.label, "certificate", best.get(f.levels or ""), "SIM-001", to) for f in fams]
+
+
async def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("bid")
@@ -187,6 +201,7 @@ async def main() -> None:
ap.add_argument("--no-direct", action="store_true", help="不做偏离栏直读(对比用)")
ap.add_argument("--no-mismatch", action="store_true", help="不做响应错位判断(对比用)")
ap.add_argument("--modes", help="人工确认后的响应方式,如 technical=implicit,commercial=implicit")
+ ap.add_argument("--library", default="empty", choices=["empty", "valid", "partial", "expired"], help="模拟的资质库")
args = ap.parse_args()
d = OUT / args.bid
manifest = json.loads((d / "manifest.json").read_text())
@@ -204,9 +219,8 @@ async def main() -> None:
[s for p in pages for s in p.sentences if s.location == "header_footer"],
)
)
- hits, rules_cov = rules.run(
- facts(manifest["tender"], args.checklist, items), docs, gold.sentences(manifest["tender"])
- )
+ project_facts = facts(manifest["tender"], args.checklist, items)
+ hits, rules_cov = rules.run(project_facts, docs, gold.sentences(manifest["tender"]))
def rule_rows(covered: set[tuple[str, int, str]]) -> list[dict[str, Any]]:
return [
@@ -261,6 +275,7 @@ async def main() -> None:
if decisions:
match.apply_modes(results, items, decisions)
match.scanned_fallback(results, items, decisions, docs)
+ qual_hits, qual_cov = qualification.run(all_items, results, docs, library(args.library), project_facts.deadline)
rows = []
for item_id, item in all_items.items():
if item_id not in items:
@@ -308,7 +323,24 @@ async def main() -> None:
}
(out / "results.json").write_text(
json.dumps(
- {"summary": summary, "items": rows, "rules": rule_rows(covered), "rules_coverage": rules_cov},
+ {
+ "summary": summary,
+ "items": rows,
+ "rules": rule_rows(covered),
+ "rules_coverage": rules_cov,
+ "qualification": [
+ {
+ "code": h.code,
+ "severity": h.severity,
+ "title": h.title,
+ "explanation": h.explanation,
+ "source": h.detail["source"],
+ "evidence": [{"volume": doc.label, "p": s.page_no, "text": s.text} for doc, s in h.evidence],
+ }
+ for h in qual_hits
+ ],
+ "qualification_coverage": {"library": args.library, **qual_cov.to_api()},
+ },
ensure_ascii=False,
indent=1,
)
diff --git a/evals/check/score.py b/evals/check/score.py
index c0ced67..c12cba6 100644
--- a/evals/check/score.py
+++ b/evals/check/score.py
@@ -58,6 +58,8 @@ RULE_CODES = {
"P11": "FORM.MISSING_DOC",
"P12": "PRICE.MULTIPLE_QUOTES",
"P13": "PRICE.ITEM_MISSING",
+ "D12": "QUAL.EXPIRED", # 资质匹配(qualification-plan §5):按资质情况表那一行的出处对照
+ "D18": "QUAL.LEVEL_LOW",
}
@@ -68,7 +70,7 @@ def squash(t: str) -> str:
def score_rules(manifest: dict[str, Any], res: dict[str, Any]) -> dict[str, Any]:
"""规则问题(P 类):规则编号对上、投标侧出处里有注入的那段原文才算查出;没对上任何注入问题的规则问题算误报。
D10(符合性条件响应表)按表级问题记在 rule_defects 里,它由响应匹配查(negative_deviation),这里一起对照。"""
- hits = list(res.get("rules") or [])
+ hits = list(res.get("rules") or []) + list(res.get("qualification") or [])
used: set[int] = set()
found: Counter[str] = Counter()
total: Counter[str] = Counter()
@@ -104,6 +106,9 @@ def score_rules(manifest: dict[str, Any], res: dict[str, Any]) -> dict[str, Any]
found[kind] += 1
if ok >= 0:
used.add(ok)
+ # 清单里同一要求抽成几条时,规则(资质匹配)对每条各报一个、出处相同:算同一个,不算误报(同响应问题的按单元去重)
+ same = (hits[ok]["code"], tuple(e["text"] for e in hits[ok]["evidence"]))
+ used |= {i for i, h in enumerate(hits) if (h["code"], tuple(e["text"] for e in h["evidence"])) == same}
# 响应表规则报的「负偏离」对上了响应问题(D05 / D06)的注入位置:算那个单元查出(rule_units),不算规则误报
rule_units: set[str] = set()
for i, h in enumerate(hits):
diff --git a/packages/api-client/src/schema.d.ts b/packages/api-client/src/schema.d.ts
index d094e61..d880902 100644
--- a/packages/api-client/src/schema.d.ts
+++ b/packages/api-client/src/schema.d.ts
@@ -1156,7 +1156,10 @@ export interface paths {
path?: never;
cookie?: never;
};
- /** 资质库 */
+ /**
+ * 资质库
+ * @description 按「有效期至」从近到远排,长期有效的在最后。
+ */
get: operations["listQualifications"];
put?: never;
/** 新增资质 */
@@ -1356,8 +1359,11 @@ export interface components {
LegalRegime: "gov_procurement" | "tendering" | "unknown";
/** @enum {string} */
DocumentRole: "tender" | "addendum" | "clarification" | "bid_volume";
- /** @enum {string} */
- DetectedFormat: "pdf" | "docx" | "doc" | "wps" | "ofd" | "encrypted_bid" | "unknown";
+ /**
+ * @description jpeg、png 只出现在资质附件(purpose = qualification_file)上
+ * @enum {string}
+ */
+ DetectedFormat: "pdf" | "docx" | "doc" | "wps" | "ofd" | "encrypted_bid" | "jpeg" | "png" | "unknown";
/** @enum {string} */
JobStatus: "queued" | "running" | "succeeded" | "failed" | "cancelled";
/** @enum {string} */
@@ -2060,11 +2066,12 @@ export interface components {
importance?: components["schemas"]["Importance"];
categories?: components["schemas"]["Category"][];
} | null;
+ /** @description 规则问题的规则编号和说明(detector = rule 时有);资质匹配的问题同时挂清单项 */
rule?: {
code?: string;
description?: string;
} | null;
- /** @description 按类型的补充信息:参数的要求值和响应值、资质预警种类(expired / insufficient_level / not_covered)、从投标文件读到的金额 */
+ /** @description 按类型的补充信息:参数的要求值和响应值、从投标文件读到的金额;资质问题(type = qualification)有 kind(expired / level_low / not_covered)、source(bid_text / library)、deadline,issues[] 里 source = library 的带 library(资质库记录的快照:id、name、kind、level、cert_no、valid_to),界面显示为「依据:资质库记录」 */
detail?: Record;
tender_citations: components["schemas"]["Citation"][];
bid_citations: components["schemas"]["Citation"][];
@@ -2168,13 +2175,14 @@ export interface components {
name: string;
kind: components["schemas"]["QualificationKind"];
level?: string | null;
+ /** @description 界面上必填(查标时用它核对附件),接口可空,以后批量导入时不一定读得全 */
cert_no?: string | null;
issuer?: string | null;
/** Format: date */
valid_from?: string | null;
/**
* Format: date
- * @description 空表示长期有效
+ * @description 空表示长期有效;要晚于 valid_from
*/
valid_to?: string | null;
};
@@ -2185,6 +2193,13 @@ export interface components {
filename: string;
size_bytes: number;
}[];
+ /** @description 进行中的项目(跟进中、编制中、已检查,截止日不早于今天)里,投标截止日晚于「有效期至」的项目。到期按每个项目的截止日判断,不按今天 */
+ late_for: {
+ project_id: components["schemas"]["Id"];
+ name: string;
+ /** Format: date */
+ deadline: string;
+ }[];
created_at: components["schemas"]["Timestamp"];
updated_at: components["schemas"]["Timestamp"];
};
@@ -3062,7 +3077,10 @@ export interface operations {
size_bytes: number;
/** @description 选填;提供时完成上传时校验 */
sha256?: string;
- /** @enum {string} */
+ /**
+ * @description 资质附件只收 PDF、JPG、PNG,单个不超过 20MB
+ * @enum {string}
+ */
purpose: "project_document" | "qualification_file";
};
};
@@ -4430,6 +4448,8 @@ export interface operations {
parameters: {
query?: {
kind?: components["schemas"]["QualificationKind"];
+ /** @description 按名称、证书编号、发证机构搜索 */
+ q?: string;
};
header?: never;
path?: never;
diff --git a/server/pyproject.toml b/server/pyproject.toml
index ca28261..c71fd34 100644
--- a/server/pyproject.toml
+++ b/server/pyproject.toml
@@ -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"]
diff --git a/server/src/bid_doctor/app.py b/server/src/bid_doctor/app.py
index e014adb..570b9dd 100644
--- a/server/src/bid_doctor/app.py
+++ b/server/src/bid_doctor/app.py
@@ -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,
):
diff --git a/server/src/bid_doctor/check/deviation.py b/server/src/bid_doctor/check/deviation.py
index 22e4201..7706ff9 100644
--- a/server/src/bid_doctor/check/deviation.py
+++ b/server/src/bid_doctor/check/deviation.py
@@ -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:
diff --git a/server/src/bid_doctor/check/findings.py b/server/src/bid_doctor/check/findings.py
index c84af01..df1e83f 100644
--- a/server/src/bid_doctor/check/findings.py
+++ b/server/src/bid_doctor/check/findings.py
@@ -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}
diff --git a/server/src/bid_doctor/check/qualification.py b/server/src/bid_doctor/check/qualification.py
new file mode 100644
index 0000000..5e2baa8
--- /dev/null
+++ b/server/src/bid_doctor/check/qualification.py
@@ -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|(? 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
diff --git a/server/src/bid_doctor/check/rules.py b/server/src/bid_doctor/check/rules.py
index caf0b9c..13e5adf 100644
--- a/server/src/bid_doctor/check/rules.py
+++ b/server/src/bid_doctor/check/rules.py
@@ -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。"""
diff --git a/server/src/bid_doctor/check/runner.py b/server/src/bid_doctor/check/runner.py
index 06ef69a..6d32fe4 100644
--- a/server/src/bid_doctor/check/runner.py
+++ b/server/src/bid_doctor/check/runner.py
@@ -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,
diff --git a/server/src/bid_doctor/check/service.py b/server/src/bid_doctor/check/service.py
index 576a6e8..a3b772d 100644
--- a/server/src/bid_doctor/check/service.py
+++ b/server/src/bid_doctor/check/service.py
@@ -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],
diff --git a/server/src/bid_doctor/core/storage.py b/server/src/bid_doctor/core/storage.py
index 3463e19..972a933 100644
--- a/server/src/bid_doctor/core/storage.py
+++ b/server/src/bid_doctor/core/storage.py
@@ -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}"
diff --git a/server/src/bid_doctor/document/formats.py b/server/src/bid_doctor/document/formats.py
index bc67992..0bfce1c 100644
--- a/server/src/bid_doctor/document/formats.py
+++ b/server/src/bid_doctor/document/formats.py
@@ -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)
diff --git a/server/src/bid_doctor/document/uploads.py b/server/src/bid_doctor/document/uploads.py
index b78098a..7b24d1f 100644
--- a/server/src/bid_doctor/document/uploads.py
+++ b/server/src/bid_doctor/document/uploads.py
@@ -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): # 分片用完即删
diff --git a/server/src/bid_doctor/qualification/__init__.py b/server/src/bid_doctor/qualification/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/server/src/bid_doctor/qualification/api.py b/server/src/bid_doctor/qualification/api.py
new file mode 100644
index 0000000..5994d05
--- /dev/null
+++ b/server/src/bid_doctor/qualification/api.py
@@ -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)}",
+ },
+ )
diff --git a/server/src/bid_doctor/qualification/models.py b/server/src/bid_doctor/qualification/models.py
new file mode 100644
index 0000000..f3a66bf
--- /dev/null
+++ b/server/src/bid_doctor/qualification/models.py
@@ -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]
diff --git a/server/src/bid_doctor/qualification/service.py b/server/src/bid_doctor/qualification/service.py
new file mode 100644
index 0000000..1eccde4
--- /dev/null
+++ b/server/src/bid_doctor/qualification/service.py
@@ -0,0 +1,246 @@
+"""最小资质库(FR-M6-08,qualification-plan §2)。
+
+- 只有用户自己录入的资质(红线 5);不扣额度;增删改和附件操作都写审计;
+- 附件按企业级数据密钥加密(DM4),对象键 t//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
diff --git a/server/src/bid_doctor/report/template.py b/server/src/bid_doctor/report/template.py
index d8d3ff6..df90c81 100644
--- a/server/src/bid_doctor/report/template.py
+++ b/server/src/bid_doctor/report/template.py
@@ -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"依据:资质库记录《{escape(r['name'])}》{no} · {to}
")
+ 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"招标文件:{_cite(c)}
" for c in f.get("tender_citations") or []]
parts += [f"投标文件:{_cite(c)}
" for c in f.get("bid_citations") or []]
+ parts += _library(f)
cites = f"{''.join(parts)}
" if parts else ""
carried = "(沿用上次检查)" if f.get("presence") == "carried" else ""
dup = f"(同一要求在清单里出现 {len(f['duplicate_ids']) + 1} 处)" if f.get("duplicate_ids") else ""
diff --git a/server/tests/fixtures/bid-mini-quals.pdf b/server/tests/fixtures/bid-mini-quals.pdf
new file mode 100644
index 0000000..ab1c585
Binary files /dev/null and b/server/tests/fixtures/bid-mini-quals.pdf differ
diff --git a/server/tests/fixtures/make_bid_fixtures.py b/server/tests/fixtures/make_bid_fixtures.py
index 18be895..b947253 100644
--- a/server/tests/fixtures/make_bid_fixtures.py
+++ b/server/tests/fixtures/make_bid_fixtures.py
@@ -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 = [
"七、本次报价未包含设备运输费用,运输费用由采购人另行承担。
",
]
+QUALS = [
+ "五、企业资质情况表
",
+ "| 证书名称 | 证书编号 | 等级 | 有效期 |
"
+ "| 安全生产许可证 | (测)JZ安许证字〔2023〕000001 | — | 2023年11月16日至2026年11月15日 |
"
+ "| ISO9001质量管理体系认证证书 | 00000Q00001 | — | 2025年3月10日至2028年3月9日 |
"
+ "
",
+]
+
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])
diff --git a/server/tests/test_check_flow.py b/server/tests/test_check_flow.py
index 99fb3f4..3a45a4b 100644
--- a/server/tests/test_check_flow.py
+++ b/server/tests/test_check_flow.py
@@ -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"]])
diff --git a/server/tests/test_check_qualification.py b/server/tests/test_check_qualification.py
new file mode 100644
index 0000000..4e06262
--- /dev/null
+++ b/server/tests/test_check_qualification.py
@@ -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
diff --git a/server/tests/test_qualification_flow.py b/server/tests/test_qualification_flow.py
new file mode 100644
index 0000000..872468d
--- /dev/null
+++ b/server/tests/test_qualification_flow.py
@@ -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,
+ }
diff --git a/server/tests/test_report_flow.py b/server/tests/test_report_flow.py
index 6e2e54d..d2249da 100644
--- a/server/tests/test_report_flow.py
+++ b/server/tests/test_report_flow.py
@@ -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) == ["依据:资质库记录《安全生产许可证》 · 编号 X-1 · 有效期至 2026-10-31
"] # pyright: ignore[reportPrivateUsage]
+ assert template._library({"type": "unresponded", "detail": {}}) == [] # pyright: ignore[reportPrivateUsage]