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

- 资质库(FR-M6-08):增删改、证书扫描件(PDF/JPG/PNG,企业级密钥加密、签名下载)、
  late_for 按进行中项目的投标截止日提醒、审计;前端资质库页和对话框,侧栏入口
- 资质匹配(FR-M3-05):证书类别词表(16 类,产品证书、人员证书不进匹配),
  QUAL.EXPIRED / LEVEL_LOW / NOT_COVERED;投标文字层按清单项定级,只有资质库记录的降一级,
  出处显示「依据:资质库记录」(查标页、报告模板 v2);一类证书一个问题;评分项不逐条找响应的也查
- 响应错位规则不再把资质表的证书名称格当成照抄的要求
- 评测:qual_defects.py 派生资质情况表,D12 3/3、D18 2/2、规矩的资质表 0 误报;开发集不退化
- 决策 QL1–QL7;方案和落地记录 docs/architecture/qualification-plan.md,评测 check-eval.md §13

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
YANG JIANKUAN
2026-11-04 23:22:50 +08:00
parent e7fb797925
commit 08e3fe7eff
43 changed files with 2643 additions and 49 deletions

View File

@@ -220,4 +220,5 @@ samples/
- 2026-11-04:查标第 3 步冻结(`a60d16e`),跑留出集一次:S03、S06、S09 各一份风格 B 规矩 + 风格 C 马虎(风格 C 由封存一侧的会话实现),96 个注入问题 Fable 复核通过 79,用户抽查 19 条全部同意。留出集召回 54%(开发集 74%)、致命级 56%(开发集 78%)、负偏离未说明 25%(开发集 92%)、规则问题 13 / 20;S03 的规矩投标文件因响应方式识别过度触发报了 1094 个提示,人工确认响应方式后降到 45 个。开发集分数有相当一部分是调出来的;要修的 5 处见 `docs/architecture/check-eval.md` §11。
- 2026-11-04:按用户决定(E2)先修留出集暴露的 5 处(响应方式识别、偏离栏按「是不是明确满足」判断、另报价 / 附加条件按结构判断、空白栏、异体字),规则集 2026.11-r3。开发集不退化(召回 75%、规矩投标文件问题数不变);留出集参考数字召回 54% → 69%、规矩投标文件问题 1134 → 82(已看过,正式成绩仍以冻结代码那次为准)。见 `docs/architecture/check-eval.md` §12。
- 2026-11-04:查标第 5 步检查报告:导出 → 后台用 Gotenberg 生成 A4 PDF(首页覆盖范围和免责声明、问题明细、需人工核对附录,页眉 logo、页脚免责一句和页码)→ 加密归档,可重复下载;前端加「报告」标签页(选项、导出、内嵌预览、历次下载)。补了项目状态流转(递交 / 撤销递交 / 归档 / 撤销归档,致命未处理也能递交,递交后只读,都留痕)。产品可以从上传招标文件一路走到导出报告、标记递交。见 `docs/architecture/m3-plan.md` 进度。
- 下一步:国动真实投标文件(用户晚些提供,到时先读标、请用户看清单,再查标一次);其余可做:第 4 步资质库和资质匹配、第 6 步前端补齐(键盘操作、批量处理、判断有误反馈)。
- 2026-11-04:查标第 4 步资质库和资质匹配(`docs/architecture/qualification-plan.md`,QL1–QL7 全部按建议确认)。资质库页可增删改、挂证书扫描件(企业级密钥加密),到期按进行中项目的投标截止日提醒;查标按证书类别词表(16 类,产品证书、人员证书不进匹配)核对有效期、等级、有没有覆盖,投标文字层按清单项定级,只有资质库记录的降一级、出处显示「依据:资质库记录」,规则集 2026.11-r4。开发集不退化;派生的资质情况表上 D12 3 / 3、D18 2 / 2、规矩的资质表 0 误报,但表和词表是对着同一批样本写的,泛化要等国动真实文件(`check-eval.md` §13)。
- 下一步:国动真实投标文件(用户晚些提供,到时先读标、请用户看清单,再查标一次;请用户按公司真实证书录资质库);其余可做:第 6 步前端补齐(键盘操作、批量处理、判断有误反馈、日期选择器的日历弹层)、M6 剩下的 P0(额度流水、套餐开通、数据删除、运营后台)。

View File

@@ -3,7 +3,7 @@
* 登录后的页面框架(设计规范 v0.2 §5.3):96px 墨色渐变侧栏 + 透明顶栏(滚动后半透明白)+ 内容区。
* 挂载期间保持一条事件流连接,把服务端的变化推进查询缓存。
*/
import { House, LogOut } from "@lucide/vue";
import { Award, House, LogOut } from "@lucide/vue";
import { useQueryClient } from "@tanstack/vue-query";
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import { RouterLink, RouterView, useRoute, useRouter } from "vue-router";
@@ -71,6 +71,9 @@ async function logout() {
<RouterLink :to="{ name: 'workbench' }" :aria-current="onWorkbench ? 'page' : undefined">
<House class="ic" :stroke-width="1.5" /><span>工作台</span>
</RouterLink>
<RouterLink :to="{ name: 'qualifications' }" :aria-current="route.name === 'qualifications' ? 'page' : undefined">
<Award class="ic" :stroke-width="1.5" /><span>资质库</span>
</RouterLink>
</nav>
<div class="me">
<span class="avatar" aria-hidden="true">{{ name.slice(0, 1) }}</span>

View File

@@ -0,0 +1,326 @@
<script setup lang="ts">
/**
* 资质的添加 / 编辑对话框(宽 640,原型 qualifications.html、ui-demo forms.html「资质录入」)。
* - 名称、证书编号、有效期至必填(长期有效打开开关);证书编号界面必填、接口可空(qualification-plan §2.3);
* - 有效期至早于进行中项目的投标截止日:出赭色提醒小字,只提醒、不拦截保存;
* - 附件可以稍后补录。新增时选的文件在保存资质后再上传挂上。
*/
import { unwrap, uploadFile } from "@bid-doctor/api-client";
import { X } from "@lucide/vue";
import { DialogClose, DialogContent, DialogOverlay, DialogPortal, DialogRoot, DialogTitle } from "reka-ui";
import { computed, ref, watch } from "vue";
import { api, apiOptions, errorTitle } from "@/lib/api";
import { extOf, fmtMonthDay, fmtSize, parseDay, QUALIFICATION_KIND, QUALIFICATION_KIND_ORDER } from "@/lib/labels";
import type { Qualification } from "@/lib/queries";
export interface ActiveDeadline {
name: string;
/** YYYY-MM-DD(北京时间) */
day: string;
}
const props = defineProps<{ item: Qualification | null; deadlines: ActiveDeadline[]; focusExpiry?: boolean }>();
const open = defineModel<boolean>("open", { required: true });
/** saved:created / updated 是点了「保存资质」,files 是在对话框里加删附件 */
const emit = defineEmits<{ saved: [q: Qualification, how: "created" | "updated" | "files"] }>();
type Kind = Qualification["kind"];
const name = ref("");
const kind = ref<Kind>("certificate");
const level = ref("");
const certNo = ref("");
const issuer = ref("");
const from = ref("");
const to = ref("");
const longTerm = ref(false);
const files = ref<Qualification["files"]>([]);
const pending = ref<File | null>(null);
const errs = ref<Record<string, string>>({});
const err = ref("");
const busy = ref(false);
const fileBusy = ref(false);
const fileInput = ref<HTMLInputElement | null>(null);
const toInput = ref<HTMLInputElement | null>(null);
watch(open, (v) => {
if (!v) return;
const q = props.item;
name.value = q?.name ?? "";
kind.value = q?.kind ?? "certificate";
level.value = q?.level ?? "";
certNo.value = q?.cert_no ?? "";
issuer.value = q?.issuer ?? "";
from.value = q?.valid_from ?? "";
to.value = q?.valid_to ?? "";
longTerm.value = !!q && !q.valid_to;
files.value = q?.files ?? [];
pending.value = null;
errs.value = {};
err.value = "";
if (props.focusExpiry) setTimeout(() => toInput.value?.select());
});
/** 有效期至早于哪些进行中项目的投标截止日(截止日当天到期仍算有效) */
const late = computed(() => {
const day = longTerm.value ? "" : parseDay(to.value);
return day ? props.deadlines.filter((d) => day < d.day) : [];
});
function tidy(field: "from" | "to") {
const r = field === "from" ? from : to;
const day = parseDay(r.value);
if (day) r.value = day;
}
function validate(): boolean {
const e: Record<string, string> = {};
if (!name.value.trim()) e.name = "请填写资质名称,和证书上的写法一致";
if (!certNo.value.trim()) e.certNo = "请填写证书编号,查标时用它核对";
const f = parseDay(from.value);
if (f === null) e.from = "日期写法不对,例如:2023-11-16";
if (!longTerm.value) {
const t = parseDay(to.value);
if (t === null) e.to = "日期写法不对,例如:2026-11-20";
else if (!t) e.to = "请填写有效期至;长期有效请打开下方开关";
else if (f && t <= f) e.to = "有效期至要晚于有效期起,请核对";
}
errs.value = e;
return !Object.keys(e).length;
}
async function attach(qid: string, file: File): Promise<Qualification> {
const done = await uploadFile(api, apiOptions, file, "qualification_file");
return unwrap(
api.POST("/qualifications/{qualification_id}/files", { params: { path: { qualification_id: qid } }, body: { upload_id: done.id } }),
);
}
async function submit() {
if (!validate() || busy.value) return;
busy.value = true;
err.value = "";
const body = {
name: name.value.trim(),
kind: kind.value,
level: level.value.trim() || null,
cert_no: certNo.value.trim() || null,
issuer: issuer.value.trim() || null,
valid_from: parseDay(from.value) || null,
valid_to: longTerm.value ? null : parseDay(to.value) || null,
};
const how = props.item ? "updated" : "created";
try {
let q = props.item
? await unwrap(api.PUT("/qualifications/{qualification_id}", { params: { path: { qualification_id: props.item.id } }, body }))
: await unwrap(api.POST("/qualifications", { body }));
if (pending.value) {
try {
q = await attach(q.id, pending.value);
} catch (e) {
// 资质已经存好了(再点保存是修改,不会重复添加);附件没挂上时关掉对话框会让人以为都好了,所以留在对话框里
emit("saved", q, how);
err.value = `资质已保存,附件没有上传成功:${errorTitle(e)}。可以稍后在列表里补录。`;
return;
}
}
emit("saved", q, how);
open.value = false;
} catch (e) {
err.value = errorTitle(e);
} finally {
busy.value = false;
}
}
function pick() {
fileInput.value?.click();
}
async function onFile(ev: Event) {
const input = ev.target as HTMLInputElement;
const f = input.files?.[0];
input.value = "";
if (!f) return;
if (!props.item) {
pending.value = f;
return;
}
fileBusy.value = true;
err.value = "";
try {
const q = await attach(props.item.id, f);
files.value = q.files;
emit("saved", q, "files");
} catch (e) {
err.value = errorTitle(e);
} finally {
fileBusy.value = false;
}
}
async function view(fileId: string) {
if (!props.item) return;
try {
const link = await unwrap(
api.GET("/qualifications/{qualification_id}/files/{file_id}", { params: { path: { qualification_id: props.item.id, file_id: fileId } } }),
);
window.open(link.url, "_blank", "noopener");
} catch (e) {
err.value = errorTitle(e);
}
}
async function removeFile(fileId: string) {
if (!props.item) return;
fileBusy.value = true;
try {
await unwrap(
api.DELETE("/qualifications/{qualification_id}/files/{file_id}", { params: { path: { qualification_id: props.item.id, file_id: fileId } } }),
);
files.value = files.value.filter((f) => f.id !== fileId);
emit("saved", { ...props.item, files: files.value }, "files");
} catch (e) {
err.value = errorTitle(e);
} finally {
fileBusy.value = false;
}
}
</script>
<template>
<DialogRoot v-model:open="open">
<DialogPortal>
<DialogOverlay class="scrim">
<DialogContent class="dialog wide" :aria-describedby="undefined">
<DialogTitle as="h2">{{ item ? "编辑资质" : "添加资质" }}</DialogTitle>
<DialogClose class="icon-btn sm x" aria-label="关闭"><X class="ic" :stroke-width="1.5" /></DialogClose>
<form class="body" novalidate @submit.prevent="submit">
<div class="form">
<div class="field span2" :class="{ err: errs.name }">
<label for="qd-name">名称 <small>(必填)</small></label>
<input id="qd-name" v-model="name" class="input" maxlength="200" placeholder="和证书上的写法一致" autocomplete="off" />
<span v-if="errs.name" class="msg"><span class="ico w" aria-hidden="true">!</span>{{ errs.name }}</span>
</div>
<div class="field">
<label for="qd-kind">类型 <small>(必填)</small></label>
<select id="qd-kind" v-model="kind" class="input">
<option v-for="k in QUALIFICATION_KIND_ORDER" :key="k" :value="k">{{ QUALIFICATION_KIND[k] }}</option>
</select>
</div>
<div class="field">
<label for="qd-level">等级 <small>(选填)</small></label>
<input id="qd-level" v-model="level" class="input" maxlength="40" placeholder="例如:CS3、二级" autocomplete="off" />
</div>
<div class="field" :class="{ err: errs.certNo }">
<label for="qd-no">证书编号 <small>(必填)</small></label>
<input id="qd-no" v-model="certNo" class="input num" maxlength="80" autocomplete="off" />
<span v-if="errs.certNo" class="msg"><span class="ico w" aria-hidden="true">!</span>{{ errs.certNo }}</span>
</div>
<div class="field">
<label for="qd-issuer">发证机构</label>
<input id="qd-issuer" v-model="issuer" class="input" maxlength="120" autocomplete="off" />
</div>
<div class="field" :class="{ err: errs.from }">
<label for="qd-from">有效期起</label>
<input id="qd-from" v-model="from" class="input num" placeholder="例如:2023-11-16" autocomplete="off" @blur="tidy('from')" />
<span v-if="errs.from" class="msg"><span class="ico w" aria-hidden="true">!</span>{{ errs.from }}</span>
</div>
<div class="field" :class="{ err: errs.to }">
<label for="qd-to">有效期至 <small v-if="!longTerm">(必填)</small></label>
<input
id="qd-to"
ref="toInput"
v-model="to"
class="input num"
:placeholder="longTerm ? '长期有效' : '例如:2026-11-20'"
:disabled="longTerm"
autocomplete="off"
@blur="tidy('to')"
/>
<span v-if="errs.to" class="msg"><span class="ico w" aria-hidden="true">!</span>{{ errs.to }}</span>
<span v-else-if="late.length" class="hint-warn">
<span class="ico w" aria-hidden="true">!</span>
<span>早于「{{ late[0]!.name }}」的投标截止 {{ fmtMonthDay(late[0]!.day) }}<template v-if="late.length > 1"> 等 <span class="fig">{{ late.length }}</span> 个项目</template></span>
</span>
</div>
<div class="field span2">
<button
type="button"
class="switch"
role="switch"
:aria-checked="longTerm"
@click="
longTerm = !longTerm;
if (longTerm) to = '';
"
>
<span class="track" aria-hidden="true"></span>长期有效
</button>
</div>
<div class="field span2">
<label>证书扫描件 <small>(可以稍后补录)</small></label>
<div v-for="f in files" :key="f.id" class="file-row">
<span class="ext">{{ extOf(f.filename) }}</span>
<div><b>{{ f.filename }}</b><span class="cap muted">{{ fmtSize(f.size_bytes) }}</span></div>
<span class="acts">
<button type="button" class="btn sm" @click="view(f.id)">查看</button>
<button type="button" class="btn sm" :disabled="fileBusy" @click="removeFile(f.id)">删除</button>
</span>
</div>
<div v-if="pending" class="file-row">
<span class="ext">{{ extOf(pending.name) }}</span>
<div><b>{{ pending.name }}</b><span class="cap muted">{{ fmtSize(pending.size) }} · 保存资质后上传</span></div>
<span class="acts"><button type="button" class="btn sm" @click="pending = null">移除</button></span>
</div>
<button v-if="!pending" type="button" class="drop" :aria-disabled="fileBusy" @click="pick">
<span>{{ fileBusy ? "正在上传…" : files.length ? "再上传一个附件(PDF / JPG / PNG)" : "点击上传 PDF / JPG / PNG,单个不超过 20MB" }}</span>
</button>
<input ref="fileInput" type="file" accept=".pdf,.jpg,.jpeg,.png,application/pdf,image/jpeg,image/png" hidden @change="onFile" />
</div>
</div>
<p v-if="err" class="hint-warn" role="alert"><span class="ico w" aria-hidden="true">!</span>{{ err }}</p>
<div class="foot">
<span class="cap muted left">不扣额度</span>
<DialogClose class="btn" type="button" :disabled="busy">取消</DialogClose>
<button type="submit" class="btn primary swap" :class="{ 'is-loading': busy }" :disabled="busy">
<span class="idle">保存资质</span><span class="busy">正在保存…</span>
</button>
</div>
</form>
</DialogContent>
</DialogOverlay>
</DialogPortal>
</DialogRoot>
</template>
<style scoped>
/* 编号、日期用数字字体(同原型 .dt > .input) */
.input.num {
font-family: var(--font-figures);
font-variant-numeric: tabular-nums;
font-size: var(--fs-figure-sm);
}
.input.num::placeholder {
font-family: var(--font-sans);
font-size: var(--fs-body);
}
.file-row + .file-row,
.file-row + .drop {
margin-top: var(--sp-2);
}
.drop {
width: 100%;
padding: var(--sp-4);
font: inherit;
cursor: pointer;
}
.file-row .cap {
display: block;
}
.hint-warn {
margin: var(--sp-4) 0 0;
}
.field .hint-warn {
margin: var(--sp-1) 0 0;
}
</style>

View File

@@ -17,4 +17,5 @@ export const qk = {
manualChecks: (projectId: string) => ["manual-checks", projectId] as const,
reports: (projectId: string) => ["reports", projectId] as const,
itemResults: (runId: string) => ["item-results", runId] as const,
qualifications: ["qualifications"] as const,
};

View File

@@ -187,6 +187,15 @@ export const FINDING_ACTION: Dict<Schemas["FindingStatus"]> = {
export const READY_TIP = "只表示致命问题都已处理,不代表投标文件没有问题。";
export const QUALIFICATION_KIND_ORDER: readonly Schemas["QualificationKind"][] = ["certificate", "system_cert", "software_copyright", "other"];
export const QUALIFICATION_KIND: Dict<Schemas["QualificationKind"]> = {
certificate: "资质证书",
system_cert: "体系认证",
software_copyright: "软件著作权",
other: "其他",
};
export const qualificationKind = (s: string) => pick(QUALIFICATION_KIND, s);
/* ---------- 时间 ---------- */
const pad = (n: number) => String(n).padStart(2, "0");
export const fmtDate = (d: Date) => `${d.getMonth() + 1}月${d.getDate()}日`;
@@ -195,6 +204,30 @@ export const fmtDateTime = (iso: string) => {
const d = new Date(iso);
return `${fmtDate(d)} ${fmtTime(d)}`;
};
/** 日期(YYYY-MM-DD,无时区)显示成「2026年11月15日」 */
export const fmtDay = (day: string) => {
const [y, m, d] = day.split("-").map(Number);
return `${y}年${m}月${d}日`;
};
/** 近期的日期(投标截止日)省掉年份,显示成「11月20日」 */
export const fmtMonthDay = (day: string) => {
const [, m, d] = day.split("-").map(Number);
return `${m}月${d}日`;
};
/**
* 键入的日期整理成 YYYY-MM-DD(设计规范 Q10:可直接键入)。接受 2026-11-20、2026/11/20、2026.11.20、20261120、
* 2026年11月20日;空串返回 "",写错或不存在的日期返回 null。
*/
export function parseDay(text: string): string | null {
const t = text.trim();
if (!t) return "";
const m = /^(\d{4})(?:[-/.年]\s*(\d{1,2})[-/.月]\s*(\d{1,2})日?|(\d{2})(\d{2}))$/.exec(t);
if (!m) return null;
const [y, mo, d] = [Number(m[1]), Number(m[2] ?? m[4]), Number(m[3] ?? m[5])];
const dt = new Date(Date.UTC(y, mo - 1, d));
if (dt.getUTCFullYear() !== y || dt.getUTCMonth() !== mo - 1 || dt.getUTCDate() !== d) return null;
return `${y}-${pad(mo)}-${pad(d)}`;
}
export const fmtDuration = (ms: number) => {
const s = Math.max(0, Math.round(ms / 1000));
return { min: Math.floor(s / 60), sec: pad(s % 60) };

View File

@@ -26,6 +26,7 @@ export type CheckRun = Schemas["CheckRun"];
export type Finding = Schemas["Finding"];
export type CheckItemResult = Schemas["CheckItemResult"];
export type ManualCheck = Schemas["ManualCheck"];
export type Qualification = Schemas["Qualification"];
export const fetchMe = () => unwrap(api.GET("/me"));
@@ -141,3 +142,8 @@ export function useFindings(runId: MaybeRefOrGetter<string>, enabled: MaybeRefOr
enabled: computed(() => toValue(enabled)),
});
}
/** 资质库:一次取全部(最小资质库条目不多),类型筛选和搜索在前端做 */
export function useQualifications() {
return useQuery({ queryKey: qk.qualifications, queryFn: () => unwrap(api.GET("/qualifications")) });
}

View File

@@ -0,0 +1,288 @@
<script setup lang="ts">
/**
* 资质库(FR-M6-08,原型 qualifications.html):列表 + 添加 / 编辑对话框 + 删除。
* - 到期按每个进行中项目的投标截止日判断,不按今天(服务端给 late_for);只提醒,不拦截;
* - 没有附件显示「待补录」,不生成内容(红线 5);
* - 删除只有确认框,没有撤销(QL6)。
*/
import { unwrap } from "@bid-doctor/api-client";
import { MoreHorizontal, Plus, Search } from "@lucide/vue";
import { useQueryClient } from "@tanstack/vue-query";
import { DropdownMenuContent, DropdownMenuItem, DropdownMenuPortal, DropdownMenuRoot, DropdownMenuSeparator, DropdownMenuTrigger } from "reka-ui";
import { computed, ref } from "vue";
import ConfirmDialog from "@/components/ConfirmDialog.vue";
import QualificationDialog, { type ActiveDeadline } from "@/components/QualificationDialog.vue";
import { api, errorTitle } from "@/lib/api";
import { useCrumbs } from "@/lib/crumbs";
import { qk } from "@/lib/keys";
import { fmtDay, fmtMonthDay, QUALIFICATION_KIND, QUALIFICATION_KIND_ORDER } from "@/lib/labels";
import { useProjects, useQualifications, type Qualification } from "@/lib/queries";
import { toast } from "@/lib/toast";
useCrumbs(() => [{ label: "资质库" }]);
const qc = useQueryClient();
const quals = useQualifications();
const projects = useProjects();
const all = computed(() => quals.data.value?.items ?? []);
const kind = ref<Qualification["kind"] | "">("");
const kw = ref("");
const segs = computed(() => [
{ v: "" as const, label: "全部", n: all.value.length },
...QUALIFICATION_KIND_ORDER.map((k) => ({ v: k, label: QUALIFICATION_KIND[k], n: all.value.filter((q) => q.kind === k).length })).filter((s) => s.n),
]);
const rows = computed(() => {
const k = kw.value.trim().toLowerCase();
return all.value.filter(
(q) => (!kind.value || q.kind === kind.value) && (!k || [q.name, q.cert_no, q.issuer].some((x) => x?.toLowerCase().includes(k))),
);
});
const lateOnes = computed(() => all.value.filter((q) => q.late_for.length));
/** 进行中项目的投标截止日(北京时间的日期),给对话框做到期提醒 */
const deadlines = computed<ActiveDeadline[]>(() => {
const fmt = new Intl.DateTimeFormat("en-CA", { timeZone: "Asia/Shanghai" });
const today = fmt.format(new Date());
return (projects.data.value?.items ?? [])
.filter((p) => ["tracking", "drafting", "checked"].includes(p.status) && p.deadline_at)
.map((p) => ({ name: p.name, day: fmt.format(new Date(p.deadline_at!)) }))
.filter((d) => d.day >= today)
.sort((a, b) => (a.day < b.day ? -1 : 1));
});
const lateText = (q: Qualification) => {
const first = q.late_for[0]!;
return `早于「${first.name}」的投标截止 ${fmtMonthDay(first.deadline)}${q.late_for.length > 1 ? ` 等 ${q.late_for.length} 个项目` : ""}`;
};
const editing = ref<Qualification | null>(null);
const dialogOpen = ref(false);
const focusExpiry = ref(false);
function edit(q: Qualification | null, expiry = false) {
editing.value = q;
focusExpiry.value = expiry;
dialogOpen.value = true;
}
function onSaved(q: Qualification, how: "created" | "updated" | "files") {
void qc.invalidateQueries({ queryKey: qk.qualifications });
editing.value = q; // 新增后对话框还开着(附件没传上)时,再保存就是修改
if (how === "created") toast(`已添加「${q.name}」`);
else if (how === "updated") toast(`已保存「${q.name}」`);
}
/** 打开第一个附件(短时签名地址) */
async function view(q: Qualification) {
try {
const link = await unwrap(
api.GET("/qualifications/{qualification_id}/files/{file_id}", { params: { path: { qualification_id: q.id, file_id: q.files[0]!.id } } }),
);
window.open(link.url, "_blank", "noopener");
} catch (e) {
toast(errorTitle(e), { ok: false });
}
}
const removing = ref<Qualification | null>(null);
const removeOpen = ref(false);
const removeBusy = ref(false);
function askRemove(q: Qualification) {
removing.value = q;
removeOpen.value = true;
}
async function remove() {
const q = removing.value;
if (!q) return;
removeBusy.value = true;
try {
await unwrap(api.DELETE("/qualifications/{qualification_id}", { params: { path: { qualification_id: q.id } } }));
removeOpen.value = false;
await qc.invalidateQueries({ queryKey: qk.qualifications });
toast(`已删除「${q.name}」`);
} catch (e) {
toast(errorTitle(e), { ok: false });
} finally {
removeBusy.value = false;
}
}
</script>
<template>
<div class="content">
<div class="page-title">
<div>
<h1>资质库</h1>
<p>查标时按每个项目的投标截止日核对有效期和等级。只用你录入的资质。</p>
</div>
<div class="actions">
<button class="btn primary" type="button" @click="edit(null)"><Plus class="ic" :stroke-width="1.5" />添加资质</button>
</div>
</div>
<div v-if="lateOnes.length" class="alert warn" role="note">
<span class="ico w" aria-hidden="true">!</span>
<p><b>{{ lateOnes.length }} 张证书会在进行中项目的投标截止前到期。</b>到期按每个项目的投标截止日判断,不按今天。</p>
<button v-if="lateOnes.length === 1" class="btn sm" type="button" @click="edit(lateOnes[0]!, true)">更新有效期</button>
</div>
<section class="card flush" aria-labelledby="h-quals">
<div class="card-h">
<h2 id="h-quals">资质</h2>
<div v-if="all.length" class="seg" role="group" aria-label="按类型筛选">
<button v-for="s in segs" :key="s.v" type="button" :aria-pressed="kind === s.v" @click="kind = s.v">
{{ s.label }}<span class="fig">{{ s.n }}</span>
</button>
</div>
<div v-if="all.length" class="r">
<label class="search"><Search class="ic" :stroke-width="1.5" /><input v-model="kw" class="input" type="search" placeholder="名称、证书编号" aria-label="搜索资质" /></label>
</div>
</div>
<div v-if="quals.isPending.value" class="card-b"><span class="skel t"></span><span class="skel"></span><span class="skel"></span></div>
<div v-else-if="quals.isError.value" class="card-b">
<div class="alert warn" role="alert">
<span class="ico w" aria-hidden="true">!</span>
<p>{{ errorTitle(quals.error.value) }}</p>
<button class="btn sm" type="button" @click="quals.refetch()">重试</button>
</div>
</div>
<div v-else-if="!all.length" class="empty">
<b>还没有录入资质</b>
<p>录入资质证书、体系认证和软件著作权后,查标会按每个项目的投标截止日核对有效期和等级。</p>
<button class="btn primary" type="button" @click="edit(null)"><Plus class="ic" :stroke-width="1.5" />添加资质</button>
</div>
<table v-else class="data hover quals">
<thead>
<tr><th>名称</th><th>类型</th><th>等级</th><th>证书编号</th><th>发证机构</th><th>有效期至</th><th>附件</th><th><span class="vh">更多</span></th></tr>
</thead>
<tbody>
<tr v-for="q in rows" :key="q.id" @click="edit(q)">
<td><button type="button" class="nm" @click.stop="edit(q)">{{ q.name }}</button></td>
<td>{{ QUALIFICATION_KIND[q.kind] }}</td>
<td><template v-if="q.level">{{ q.level }}</template><span v-else class="dash">—</span></td>
<td class="num">{{ q.cert_no }}</td>
<td><template v-if="q.issuer">{{ q.issuer }}</template><span v-else class="dash">—</span></td>
<td class="exp">
<span>{{ q.valid_to ? fmtDay(q.valid_to) : "长期有效" }}</span>
<span v-if="q.late_for.length" class="warn">{{ lateText(q) }}</span>
</td>
<td>
<template v-if="q.files.length">
<button type="button" class="lnk" @click.stop="view(q)">查看</button>
<span v-if="q.files.length > 1" class="cap muted"> 等 <span class="fig">{{ q.files.length }}</span> 个</span>
</template>
<span v-else class="tag status">待补录</span>
</td>
<td class="more-cell" @click.stop>
<DropdownMenuRoot :modal="false">
<DropdownMenuTrigger class="icon-btn sm" aria-label="更多操作"><MoreHorizontal class="ic" :stroke-width="1.5" /></DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent class="menu popper" align="end" :side-offset="4">
<DropdownMenuItem @select="edit(q)">编辑</DropdownMenuItem>
<DropdownMenuItem @select="edit(q)">{{ q.files.length ? "管理附件" : "上传附件" }}</DropdownMenuItem>
<DropdownMenuSeparator as="hr" />
<DropdownMenuItem @select="askRemove(q)">删除</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenuRoot>
</td>
</tr>
<tr v-if="!rows.length">
<td colspan="8" class="empty-cell">
<div class="empty sm">
<b>没有找到匹配的资质</b>
<p>换个关键词,或清除筛选。</p>
<button class="btn sm" type="button" @click="(kind = ''), (kw = '')">清除筛选</button>
</div>
</td>
</tr>
</tbody>
</table>
</section>
<p class="note below">业绩、人员、历史标书等资产库在 V1 提供。</p>
<QualificationDialog v-model:open="dialogOpen" :item="editing" :deadlines="deadlines" :focus-expiry="focusExpiry" @saved="onSaved" />
<ConfirmDialog v-model:open="removeOpen" :title="`删除资质「${removing?.name ?? ''}」?`" ok-text="删除" :busy="removeBusy" busy-text="正在删除…" @ok="remove">
<p>删除后,查标不再用它核对资质,附件一并删除,不能恢复。已导出的检查报告不受影响。</p>
</ConfirmDialog>
</div>
</template>
<style scoped>
.alert {
margin-bottom: var(--gap-card);
}
.card-h .seg {
margin-left: var(--sp-2);
}
.card-h .r {
margin-left: auto;
}
.card-h .search {
width: 240px;
}
.card-h .search .input {
height: var(--control-h-sm);
}
.card-h .search > .ic {
top: 8px;
}
.seg .fig {
margin-left: 2px;
}
table.quals {
cursor: default;
}
table.quals tbody tr {
cursor: pointer;
}
table.quals td:first-child {
white-space: normal;
min-width: 220px;
}
table.quals td {
vertical-align: middle;
}
table.quals .nm {
display: inline;
padding: 0;
border: 0;
background: none;
font: inherit;
font-weight: var(--fw-bold);
color: var(--text-primary);
text-align: left;
cursor: pointer;
}
table.quals .nm:hover {
text-decoration: underline;
text-decoration-color: var(--c-ink);
text-underline-offset: 3px;
}
table.quals td.more-cell {
width: 1%;
padding-left: 0;
}
table.quals .icon-btn {
color: var(--text-secondary);
}
table.quals tr:hover .icon-btn,
table.quals .icon-btn[aria-expanded="true"] {
color: var(--text-primary);
}
table.quals .exp .warn {
display: block;
color: var(--warn-text);
font-size: var(--fs-caption);
line-height: var(--lh-caption);
white-space: nowrap;
}
table.data td.empty-cell {
height: auto;
padding: 0;
}
.note.below {
margin: var(--sp-3) 0 0;
}
</style>

View File

@@ -21,7 +21,7 @@ import ProjectHead from "@/components/ProjectHead.vue";
import { api, errorTitle, isApiError } from "@/lib/api";
import { useCrumbs } from "@/lib/crumbs";
import { qk } from "@/lib/keys";
import { CHECK_STEPS, checkStepIndex, FINDING_ACTION, fmtDateTime, SEVERITY, SEVERITY_CLASS } from "@/lib/labels";
import { CHECK_STEPS, checkStepIndex, FINDING_ACTION, fmtDateTime, fmtDay, SEVERITY, SEVERITY_CLASS } from "@/lib/labels";
import { TERMINAL_JOB, useCheckRun, useFindings, useManualChecks, useProject, type Citation, type Finding, type ManualCheck } from "@/lib/queries";
import { toast } from "@/lib/toast";
@@ -132,6 +132,21 @@ function refresh() {
void qc.invalidateQueries({ queryKey: qk.projects });
}
/* ---------- 资质问题:只按资质库记录判断的,出处是资质库那条记录(QL2,check/qualification.py 存的快照) ---------- */
interface LibrarySnapshot {
name: string;
cert_no: string | null;
level: string | null;
valid_to: string | null;
}
function librarySnapshots(f: Finding): LibrarySnapshot[] {
if (f.type !== "qualification") return [];
const issues = (f.detail as { issues?: { source?: string; library?: LibrarySnapshot }[] }).issues ?? [];
return issues.filter((x) => x.source === "library" && x.library).map((x) => x.library!);
}
const libLine = (r: LibrarySnapshot) =>
[r.cert_no && `编号 ${r.cert_no}`, r.level && `等级 ${r.level}`, r.valid_to ? `有效期至 ${fmtDay(r.valid_to)}` : "长期有效"].filter(Boolean).join(" · ");
/* ---------- 需人工核对 ---------- */
const manualQ = useManualChecks(() => props.projectId);
const manual = computed(() => manualQ.data.value?.items ?? []);
@@ -274,6 +289,10 @@ const errorText = computed(() => {
<aside>
<CitationBlock v-for="c in f.tender_citations" :key="c.id" :citation="c" :active="viewer?.target.id === c.id" @open="openCitation(c)" />
<CitationBlock v-for="c in f.bid_citations" :key="c.id" :citation="c" :active="viewer?.target.id === c.id" @open="openCitation(c)" />
<div v-for="(r, i) in librarySnapshots(f)" :key="`lib-${i}`" class="cite lib">
<RouterLink class="cite-src" :to="{ name: 'qualifications' }">依据:资质库记录</RouterLink>
<p><b>{{ r.name }}</b><br />{{ libLine(r) }}</p>
</div>
<div v-if="f.type === 'unresponded' && f.search_scope" class="cite missing">
<span class="cite-src">投标文件 · 未找到</span>
<p>找过:{{ (f.search_scope.documents ?? []).join("、") || "全部分册" }}</p>
@@ -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;

View File

@@ -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",

View File

@@ -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}

View File

@@ -192,7 +192,7 @@
| 上传与文档 | 分片上传全套、挂到项目、文档列表和详情、逐页摘要、一页的句子、签名下载、发起解析、解析进度、取消、人工建出处 | 删除文档 |
| 清单 | 清单概况(含确认人、删除数、按类别计数)、清单项列表(按区域、类别筛选,可含已删除)、清单项详情(ETag)、修改(If-Match 选填)、采纳、删除恢复、人工新增、替换出处、修改记录、确认清单、版本 | 补遗更新确认(等补遗解析);列表的 `lot_scope`(等选包) |
| 检查 | 检查预览、发起、进度、取消、检查记录、问题列表(含本次已解决)、每条清单项的检查结论(C9,2026-11-04 新增)、处理、撤销、批量处理(只限提示级);投标分册类型和版本、电子投标平台加密格式识别 | 需人工核对(第 2 步) |
| 报告、资质 | — | 全部(查标第 4、5 步) |
| 报告、资质 | 报告:导出、生成进度、历次报告、签名下载(第 5 步);资质库:增删改、按类型 / 关键词筛选、附件(上传后挂上、签名下载、删除)、`late_for` 到期提醒(第 4 步) | — |
| 通知与事件 | 事件流(含检查进度)、通知列表、反馈 | 标为已读 |
读标修改类接口的实现约定(2026-11-03):

View File

@@ -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)和国动真实投标文件,各一次。

View File

@@ -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 步 前端补齐 | 部分 | 需人工核对、重复问题折叠、报告页已做;键盘操作、批量处理提示、判断有误反馈还没做 |

View File

@@ -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' }

View File

@@ -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/<tenant>/q/<qualification>/<file>`,用**企业级数据密钥**加密(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。

View File

@@ -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 异体字),国动真实投标文件用修好以后的代码跑,而不是冻结代码。

View File

@@ -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 当变体。)

View File

@@ -0,0 +1,97 @@
"""从规矩的投标文件派生带「企业资质情况表」的投标文件(qualification-plan §5):q00 证书都有效、等级够;
q01 注入 D12(有效期早于投标截止日)和 D18(等级低于要求)。
cd server && ../.venv/bin/python ../evals/check/qual_defects.py <规矩的投标文件> <输出> <q00|q01>
然后 ../.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()

View File

@@ -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,
)

View File

@@ -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):

View File

@@ -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<string, never>;
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;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

Binary file not shown.

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,189 @@
"""资质库(FR-M6-08,qualification-plan §2):增删改、附件(企业级密钥加密、签名下载)、到期提醒按进行中项目的
投标截止日算、审计、企业之间隔离。"""
from __future__ import annotations
from datetime import UTC, date, datetime, timedelta
from urllib.parse import urlsplit
from uuid import UUID
import httpx
from tests.conftest import User, register
PNG = b"\x89PNG\r\n\x1a\n" + b"\x00" * 64
async def attach(u: User, qid: str, filename: str, data: bytes) -> httpx.Response:
r = await u.post("/uploads", json={"filename": filename, "size_bytes": len(data), "purpose": "qualification_file"})
assert r.status_code == 201, r.text
up = r.json()
assert (await u.put(f"/uploads/{up['id']}/parts/1", content=data)).status_code == 204
done = await u.post(f"/uploads/{up['id']}/complete")
if done.status_code != 200:
return done
return await u.post(f"/qualifications/{qid}/files", json={"upload_id": up["id"]})
async def set_deadline(u: User, project_id: str, at: datetime) -> None:
from sqlalchemy import update
from bid_doctor.core.db import TenantContext, tenant_tx
from bid_doctor.project.models import Project
ctx = TenantContext(tenant_id=UUID(u.tenant_id), user_id=UUID(str(u.me["user"]["id"])))
async with tenant_tx(ctx) as s:
await s.execute(update(Project).where(Project.id == UUID(project_id)).values(deadline_at=at))
async def test_qualification_crud_files_and_late_for(user: User) -> None:
body = {
"name": "ISO/IEC 27001 信息安全管理体系认证",
"kind": "system_cert",
"cert_no": " 01226IS0458 ",
"issuer": "",
"valid_from": "2023-11-16",
"valid_to": "2026-11-15",
}
r = await user.post("/qualifications", json=body)
assert r.status_code == 201, r.text
q = r.json()
assert q["cert_no"] == "01226IS0458" and q["issuer"] is None and q["files"] == [] and q["late_for"] == []
bad = await user.put(f"/qualifications/{q['id']}", json={**body, "valid_to": "2023-01-01"})
assert bad.status_code == 422
# 进行中项目的截止日晚于有效期至 → late_for;已递交的项目不算
today = datetime.now(UTC).date()
soon = today + timedelta(days=20)
late_project = (await user.post("/projects", json={})).json()["id"]
await set_deadline(user, late_project, datetime(soon.year, soon.month, soon.day, 1, 30, tzinfo=UTC))
r = await user.put(f"/qualifications/{q['id']}", json={**body, "valid_to": (soon - timedelta(days=1)).isoformat()})
assert r.status_code == 200, r.text
assert [x["project_id"] for x in r.json()["late_for"]] == [late_project]
r = await user.put(f"/qualifications/{q['id']}", json={**body, "valid_to": soon.isoformat()})
assert r.json()["late_for"] == [] # 截止日当天到期仍算有效
long_term = (
await user.post("/qualifications", json={"name": "政务大厅排队叫号系统 V3.0", "kind": "software_copyright"})
).json()
items = (await user.get("/qualifications")).json()["items"]
assert [x["id"] for x in items] == [q["id"], long_term["id"]] # 长期有效的排最后
assert [x["id"] for x in (await user.get("/qualifications", params={"q": "IS0458"})).json()["items"]] == [q["id"]]
kind = (await user.get("/qualifications", params={"kind": "software_copyright"})).json()["items"]
assert [x["id"] for x in kind] == [long_term["id"]]
# 附件:PNG 可以,Word 不行;下载回来是原文件
r = await attach(user, q["id"], "ISO27001 证书.png", PNG)
assert r.status_code == 201, r.text
(f,) = r.json()["files"]
assert f["filename"] == "ISO27001 证书.png" and f["size_bytes"] == len(PNG)
rejected = await attach(user, q["id"], "证书.docx", b"PK\x03\x04" + b"\x00" * 30)
assert rejected.status_code == 422
link = (await user.get(f"/qualifications/{q['id']}/files/{f['id']}")).json()
parts = urlsplit(link["url"])
got = await user.client.get(parts.path.removeprefix("/api/v1") + "?" + parts.query)
assert got.status_code == 200 and got.content == PNG and got.headers["content-type"] == "image/png"
tampered = await user.client.get(parts.path.removeprefix("/api/v1") + "?" + parts.query.replace("sig=", "sig=0"))
assert tampered.status_code == 404
# 删附件、删资质:对象一并删除,写审计
assert (await user.delete(f"/qualifications/{q['id']}/files/{f['id']}")).status_code == 204
r = await attach(user, q["id"], "ISO27001 证书.png", PNG)
key = await object_key(user, r.json()["files"][0]["id"])
from bid_doctor.core.storage import storage
assert storage().exists(key)
assert (await user.delete(f"/qualifications/{q['id']}")).status_code == 204
assert not storage().exists(key)
assert [x["id"] for x in (await user.get("/qualifications")).json()["items"]] == [long_term["id"]]
assert await audit_actions(user) >= {
"qualification.create",
"qualification.update",
"qualification.delete",
"qualification_file.attach",
"qualification_file.delete",
}
async def object_key(u: User, file_id: str) -> str:
from sqlalchemy import select
from bid_doctor.core.db import TenantContext, tenant_tx
from bid_doctor.qualification.models import QualificationFile
ctx = TenantContext(tenant_id=UUID(u.tenant_id), user_id=UUID(int=0))
async with tenant_tx(ctx) as s:
return (
await s.execute(select(QualificationFile.object_key).where(QualificationFile.id == UUID(file_id)))
).scalar_one()
async def audit_actions(u: User) -> set[str]:
from sqlalchemy import select
from bid_doctor.audit.models import AuditEvent
from bid_doctor.core.db import TenantContext, tenant_tx
ctx = TenantContext(tenant_id=UUID(u.tenant_id), user_id=UUID(int=0))
async with tenant_tx(ctx) as s:
return set((await s.execute(select(AuditEvent.action))).scalars())
async def test_qualifications_are_isolated(app_client: httpx.AsyncClient) -> None:
a = await register(app_client, "甲公司")
b = await register(app_client, "乙公司")
q = (
await a.post(
"/qualifications", json={"name": "ISO 9001", "kind": "system_cert", "valid_to": str(date(2028, 3, 9))}
)
).json()
assert (await b.get("/qualifications")).json()["items"] == []
assert (await b.put(f"/qualifications/{q['id']}", json={"name": "x", "kind": "other"})).status_code == 404
assert (await b.delete(f"/qualifications/{q['id']}")).status_code == 404
async def test_check_reports_qualification_issues(user: User) -> None:
"""端到端:投标文件的资质情况表里,安全生产许可证早于投标截止日到期 → 致命「证书在投标截止日已过期」,挂投标原文;
ISO 27001 是评分项(不逐条找响应),投标文件里没写、资质库里那张早于截止日 → 按资质库记录提示,降为重要。
资质情况表的名称格不能被当成「照抄的要求、响应对不上」。"""
from tests.conftest import project_with_tender, run_parse
from tests.test_check_flow import findings, run_check, upload_bid
project_id, tender_id = await project_with_tender(user)
assert (await run_parse(user, tender_id))["status"] == "succeeded"
await set_deadline(user, project_id, datetime(2026, 11, 20, 1, 30, tzinfo=UTC))
for title, cats in (
("投标人须具有有效的安全生产许可证", ["qualification"]),
("具有 ISO 27001 信息安全管理体系认证证书(2 分)", ["scoring"]),
):
r = await user.post(f"/projects/{project_id}/checklist/items", json={"categories": cats, "title": title})
assert r.status_code == 201, r.text
r = await user.post(
"/qualifications",
json={"name": "ISO/IEC 27001 信息安全管理体系认证", "kind": "system_cert", "valid_to": "2026-10-31"},
)
assert r.status_code == 201
bid = await upload_bid(user, project_id, "bid-mini-quals.pdf")
run = await run_check(user, project_id, [bid["id"]])
assert run["status"] == "succeeded", run
items = await findings(user, run["id"])
quals = {f["checklist_item"]["title"]: f for f in items if f["type"] == "qualification"}
safety = quals["投标人须具有有效的安全生产许可证"]
assert safety["rule"] == {"code": "QUAL.EXPIRED", "description": "证书有效期(按投标截止日)"}
assert safety["severity"] == "fatal" and safety["detail"]["source"] == "bid_text"
assert "2026年11月15日" in safety["explanation"] and "2026年11月20日" in safety["explanation"]
assert any("2026年11月15日" in c["quote"] for c in safety["bid_citations"])
iso = quals["具有 ISO 27001 信息安全管理体系认证证书(2 分)"]
assert iso["severity"] == "important" and iso["detail"]["source"] == "library" and iso["bid_citations"] == []
assert iso["detail"]["issues"][0]["library"]["valid_to"] == "2026-10-31"
assert not any(f["title"].startswith("响应可能对不上") for f in items)
assert any(x.startswith("资质核对") for x in run["coverage"]["checked"])
assert run["coverage"]["qualification"] == {
"library": 1,
"checked": 2,
"by_library": 1,
"unrecognized": 0,
"excluded": 0,
}

View File

@@ -138,3 +138,21 @@ async def test_project_submit_archive_and_undo(user: User) -> None:
assert r.json()["status"] == "submitted" # 回到归档前
r = await user.post(f"/projects/{project_id}/transitions", json={"action": "unsubmit"})
assert r.json()["status"] == "checked"
def test_template_shows_library_basis_for_qualification_findings() -> None:
"""只按资质库记录判断的资质问题,问题明细里写出依据的那条记录(QL2)。"""
f = {
"type": "qualification",
"detail": {
"issues": [
{
"source": "library",
"library": {"name": "安全生产许可证", "cert_no": "X-1", "valid_to": "2026-10-31"},
},
{"source": "bid_text"},
]
},
}
assert template._library(f) == ["<div>依据:资质库记录《安全生产许可证》 · 编号 X-1 · 有效期至 2026-10-31</div>"] # pyright: ignore[reportPrivateUsage]
assert template._library({"type": "unresponded", "detail": {}}) == [] # pyright: ignore[reportPrivateUsage]