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

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