- 资质库(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>
297 lines
13 KiB
TypeScript
297 lines
13 KiB
TypeScript
/** 枚举的中文显示名由前端维护(api-design §1);不认识的枚举值原样显示,不报错。 */
|
||
import type { Schemas } from "@bid-doctor/api-client";
|
||
|
||
type Dict<K extends string> = Readonly<Record<K, string>>;
|
||
const pick = <K extends string>(d: Dict<K>, k: string | null | undefined): string => (k ? (d as Record<string, string>)[k] ?? k : "");
|
||
|
||
export const PROJECT_STATUS: Dict<Schemas["ProjectStatus"]> = {
|
||
tracking: "跟进中",
|
||
drafting: "编制中",
|
||
checked: "已检查",
|
||
submitted: "已递交",
|
||
archived: "已归档",
|
||
};
|
||
export const projectStatus = (s: string) => pick(PROJECT_STATUS, s);
|
||
|
||
export const LEGAL_REGIME: Dict<Schemas["LegalRegime"]> = {
|
||
gov_procurement: "政府采购",
|
||
tendering: "招标投标",
|
||
unknown: "法律体系待识别",
|
||
};
|
||
export const legalRegime = (s: string) => pick(LEGAL_REGIME, s);
|
||
|
||
export const CATEGORY_ORDER: readonly Schemas["Category"][] = [
|
||
"qualification",
|
||
"invalidity",
|
||
"scoring",
|
||
"schedule",
|
||
"tech_param",
|
||
"format_doc",
|
||
"price",
|
||
];
|
||
export const CATEGORY: Dict<Schemas["Category"]> = {
|
||
qualification: "资格条件",
|
||
invalidity: "无效条款",
|
||
scoring: "评分办法",
|
||
schedule: "日期与递交",
|
||
tech_param: "技术参数",
|
||
format_doc: "格式文件",
|
||
price: "报价",
|
||
};
|
||
export const category = (s: string) => pick(CATEGORY, s);
|
||
|
||
/** 界面显示为 ★ / ▲ / 一般(一般不显示符号) */
|
||
export const IMPORTANCE_MARK: Dict<Schemas["Importance"]> = { substantive: "★", important: "▲", normal: "" };
|
||
export const IMPORTANCE_NAME: Dict<Schemas["Importance"]> = { substantive: "实质性要求", important: "重要", normal: "一般" };
|
||
|
||
export const PENDING_REASON: Dict<Schemas["PendingReason"]> = {
|
||
no_citation: "没找到可靠出处",
|
||
low_confidence: "把握不大",
|
||
verify_failed: "出处核对没通过",
|
||
marker_uncovered: "标注句没被提取覆盖,由规则补出",
|
||
absent_in_rerun: "重新解析时没再出现",
|
||
};
|
||
export const pendingReason = (s: string | null | undefined) => pick(PENDING_REASON, s);
|
||
|
||
/** 没通过校验的具体原因(服务端 checklist/extract.py 的 FAILURES,记在 ai_values.failures) */
|
||
export const FAILURE: Record<string, string> = {
|
||
evidence_missing: "没有给出处",
|
||
sid_format: "句子编号格式不对",
|
||
sid_unknown: "句子编号对不上",
|
||
quote_too_short: "引文太短",
|
||
quote_not_original: "引文和原文不一致",
|
||
basis_unmatched: "原文里找不到实质性条款的依据",
|
||
value_unverified: "取值在原文里找不到",
|
||
points_unverified: "分值在原文里找不到",
|
||
date_unverified: "日期时间在原文里找不到",
|
||
amount_unverified: "金额在原文里找不到",
|
||
placeholder_value: "原文这里是待填写的空白",
|
||
marker_uncovered: "",
|
||
};
|
||
/** 待确认条目的具体原因,最多列两条;和 pending_reason 重复的不再写 */
|
||
export function failureText(aiValues: unknown): string {
|
||
const fs = (aiValues as { failures?: unknown } | null)?.failures;
|
||
if (!Array.isArray(fs)) return "";
|
||
return fs
|
||
.map((f) => FAILURE[String(f)] ?? "")
|
||
.filter(Boolean)
|
||
.slice(0, 2)
|
||
.join("、");
|
||
}
|
||
|
||
/* ---------- 修改记录(审计事件) ---------- */
|
||
export const HISTORY_ACTION: Record<string, string> = {
|
||
"checklist_item.extract": "AI 提取",
|
||
"checklist_item.create": "人工添加",
|
||
"checklist_item.update": "修改",
|
||
"checklist_item.accept": "采纳",
|
||
"checklist_item.set_citations": "更换出处",
|
||
"checklist_item.discard": "丢弃",
|
||
"checklist_item.delete": "删除",
|
||
"checklist_item.restore": "恢复",
|
||
};
|
||
export const historyAction = (s: string) => HISTORY_ACTION[s] ?? s;
|
||
export const HISTORY_FIELD: Record<string, string> = {
|
||
title: "要求",
|
||
categories: "分类",
|
||
importance: "重要性",
|
||
evidence_required: "证明材料",
|
||
requirement_text: "原文",
|
||
lot_id: "分包",
|
||
spec: "取值",
|
||
citation_ids: "出处",
|
||
zone: "区域",
|
||
no_citation_ack: "人工确认、无出处",
|
||
};
|
||
/** 修改记录里一个值的显示:分类、重要性、区域翻成中文,出处只写条数 */
|
||
export function historyValue(field: string, v: unknown): string {
|
||
if (v === null || v === undefined || v === "") return "";
|
||
if (field === "categories" && Array.isArray(v)) return v.map((c) => category(String(c))).join("、");
|
||
if (field === "importance") return pick(IMPORTANCE_NAME, String(v));
|
||
if (field === "zone") return v === "pending" ? "待确认" : "正式清单";
|
||
if (field === "citation_ids" && Array.isArray(v)) return v.length ? `${v.length} 处` : "无";
|
||
if (field === "no_citation_ack") return v ? "是" : "否";
|
||
return typeof v === "string" ? v : JSON.stringify(v);
|
||
}
|
||
|
||
export const MARKER: Dict<Schemas["MarkingRule"]["marker"]> = {
|
||
star: "★",
|
||
asterisk: "*",
|
||
triangle: "▲",
|
||
italic_underline: "斜体加下划线",
|
||
bold: "加粗",
|
||
underline: "下划线",
|
||
keyword: "关键词",
|
||
};
|
||
|
||
export const QUOTA_UNIT: Dict<Schemas["QuotaUnit"]> = { parse: "解析", check: "检查", generate: "生成", dedupe: "查重" };
|
||
export const quotaUnit = (s: string) => pick(QUOTA_UNIT, s);
|
||
|
||
export const PLAN_KEY: Dict<Schemas["PlanCode"]> = { free: "free", pro: "pro", enterprise: "ent" };
|
||
|
||
/** 工作台「下一步」。target 为空表示骨架阶段还没有对应页面,只显示文字。 */
|
||
export const NEXT_ACTION: Dict<Schemas["NextAction"]> = {
|
||
upload_tender: "上传招标文件",
|
||
wait_parse: "查看解析进度",
|
||
select_lots: "选择所投的包",
|
||
confirm_checklist: "确认清单",
|
||
upload_bid: "上传投标文件",
|
||
run_check: "发起检查",
|
||
handle_fatal: "处理致命问题",
|
||
export_report: "导出检查报告",
|
||
archive: "归档项目",
|
||
none: "查看项目",
|
||
};
|
||
export const nextAction = (s: string) => pick(NEXT_ACTION, s);
|
||
|
||
/** 解析阶段(§7.11):服务端的 8 个 stage 合成界面上的 5 步 */
|
||
export const PARSE_STEPS: readonly { label: string; stages: readonly string[] }[] = [
|
||
{ label: "识别文件格式", stages: ["detect", "convert"] },
|
||
{ label: "提取文字", stages: ["text_layer", "ocr"] },
|
||
{ label: "识别章节结构", stages: ["structure"] },
|
||
{ label: "提取清单", stages: ["extract"] },
|
||
{ label: "核对出处", stages: ["verify", "merge"] },
|
||
];
|
||
export const parseStepIndex = (stage: string | null | undefined) =>
|
||
stage ? PARSE_STEPS.findIndex((s) => s.stages.includes(stage)) : -1;
|
||
|
||
/** 检查阶段:服务端的 6 个 stage 合成界面上的 4 步 */
|
||
export const CHECK_STEPS: readonly { label: string; stages: readonly string[] }[] = [
|
||
{ label: "读取投标文件", stages: ["prepare_bid"] },
|
||
{ label: "逐条找响应", stages: ["match"] },
|
||
{ label: "核对出处", stages: ["rules", "verify"] },
|
||
{ label: "沿用处理状态、汇总", stages: ["carry_over", "summarize"] },
|
||
];
|
||
export const checkStepIndex = (stage: string | null | undefined) =>
|
||
stage ? CHECK_STEPS.findIndex((s) => s.stages.includes(stage)) : -1;
|
||
|
||
export const VOLUME_KIND: Dict<Schemas["VolumeKind"]> = {
|
||
whole: "整本",
|
||
qualification: "资格证明",
|
||
commercial: "商务",
|
||
technical: "技术",
|
||
price: "报价",
|
||
other: "其他",
|
||
};
|
||
export const VOLUME_KINDS = Object.keys(VOLUME_KIND) as Schemas["VolumeKind"][];
|
||
|
||
/** 级别(§7.5):样式类沿用设计规范的 fatal / major / hint */
|
||
export const SEVERITY: Dict<Schemas["Severity"]> = { fatal: "致命", important: "重要", hint: "提示" };
|
||
export const SEVERITY_CLASS: Dict<Schemas["Severity"]> = { fatal: "fatal", important: "major", hint: "hint" };
|
||
export const FINDING_ACTION: Dict<Schemas["FindingStatus"]> = {
|
||
pending: "待处理",
|
||
confirmed_ok: "确认无误",
|
||
modified: "已修改",
|
||
ignored: "已忽略",
|
||
};
|
||
|
||
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()}日`;
|
||
export const fmtTime = (d: Date) => `${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||
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) };
|
||
};
|
||
|
||
/** 投标截止的两行显示和颜色类(components.css 的 .due-*) */
|
||
export function deadline(iso: string | null | undefined, now = new Date()): { main: string; sub: string; cls: string } | null {
|
||
if (!iso) return null;
|
||
const d = new Date(iso);
|
||
const ms = d.getTime() - now.getTime();
|
||
const when = `${fmtDate(d)} ${fmtTime(d)}`;
|
||
if (ms < 0) return { main: "已截止", sub: when, cls: "due-past" };
|
||
const hours = Math.floor(ms / 3_600_000);
|
||
const days = Math.floor(hours / 24);
|
||
if (d.toDateString() === now.toDateString()) return { main: `今天 ${fmtTime(d)} 截止`, sub: `剩 ${hours} 小时`, cls: "due-today" };
|
||
if (hours <= 72) return { main: `剩 ${days} 天 ${hours % 24} 小时`, sub: when, cls: "due-soon" };
|
||
return { main: `剩 ${days} 天`, sub: when, cls: "" };
|
||
}
|
||
|
||
export function extOf(name: string): string {
|
||
const m = /\.([a-z0-9]{1,5})$/i.exec(name);
|
||
return m?.[1] ? m[1].toUpperCase() : "文件";
|
||
}
|
||
|
||
export function fmtSize(bytes: number): string {
|
||
if (bytes < 1024 * 1024) return `${Math.max(1, Math.round(bytes / 1024))} KB`;
|
||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||
}
|
||
|
||
/* ---------- 清单项的结构化取值(ItemSpec,openapi.yaml):只把原文里有的值整理出来显示,不推测 ---------- */
|
||
const PART: Record<string, string> = { price: "价格分", technical: "技术分", commercial: "商务分" };
|
||
const QUAL_KIND: Record<string, string> = { general: "通用资格", specific: "特定资格", credit: "信用要求" };
|
||
const OP: Record<string, string> = { ">=": "≥", "<=": "≤", "=": "=", contains: "包含", supports: "支持" };
|
||
const DATE_KIND: Record<string, string> = {
|
||
question_deadline: "提疑截止",
|
||
bid_deadline: "投标截止",
|
||
opening: "开标",
|
||
deposit_deadline: "保证金截止",
|
||
validity: "有效期",
|
||
};
|
||
const SUBMISSION: Record<string, string> = { electronic: "电子递交", paper: "纸质递交", ca_encrypted: "CA 加密", mixed: "电子 + 纸质" };
|
||
|
||
type Spec = Record<string, Record<string, unknown> | undefined>;
|
||
const str = (v: unknown): string => (v === null || v === undefined ? "" : String(v));
|
||
|
||
export function specFacts(spec: unknown): string[] {
|
||
const s = (spec ?? {}) as Spec;
|
||
const out: string[] = [];
|
||
const q = s.qualification;
|
||
if (q && QUAL_KIND[str(q.kind)]) out.push(QUAL_KIND[str(q.kind)]!);
|
||
const sc = s.scoring;
|
||
if (sc) {
|
||
if (sc.points) out.push(`分值 ${str(sc.points)}`);
|
||
if (PART[str(sc.part)]) out.push(PART[str(sc.part)]!);
|
||
}
|
||
const t = s.tech_param;
|
||
if (t && t.value) out.push([str(t.param), OP[str(t.op)] ?? "", `${str(t.value)}${str(t.unit)}`].filter(Boolean).join(" "));
|
||
const d = s.schedule;
|
||
if (d) {
|
||
if (d.at) out.push(`${DATE_KIND[str(d.date_kind)] ?? "时间"} ${fmtDateTime(str(d.at))}`);
|
||
else if (DATE_KIND[str(d.date_kind)]) out.push(DATE_KIND[str(d.date_kind)]!);
|
||
if (d.deposit_amount) out.push(`保证金 ${str(d.deposit_amount)}`);
|
||
if (SUBMISSION[str(d.submission_mode)]) out.push(SUBMISSION[str(d.submission_mode)]!);
|
||
}
|
||
return out;
|
||
}
|