- 字段修改:人工值优先、撤销恢复 AI 值、按字段校验(金额可写「286万」,按元存)、 冗余列同步、审计留痕、只读项目 409;关键日期显示改后的投标截止时间 - 分包:增删改、选包(至少一个)、一键「不分包」;删包时条目归为通用,删到一个不剩回到不分包 - 清单列表 lot_scope 默认只列通用 + 所投的包,响应带 hidden_count - 检查记下当时用的项目依据(截止时间、限价、所投的包、法律体系):报告按它写, 之后改了信息检查结果接口给 basis_changed,查标页提示重新检查 - 前端:概览标签页(项目要点、采购包、关键日期)、字段修改对话框、选包页和手工调整; 解析完识别到多个包时进选包页;读标页清单范围和所属采购包;查标页写明查哪几个包 - 决策 PI1–PI7;方案和落地记录 docs/architecture/project-info-plan.md Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
350 lines
15 KiB
TypeScript
350 lines
15 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);
|
||
|
||
/* ---------- 项目基本信息(FR-M2-03) ---------- */
|
||
export const FIELD_LABEL: Dict<Schemas["ProjectFieldKey"]> = {
|
||
name: "项目名称",
|
||
code: "项目编号",
|
||
purchaser: "采购人 / 招标人",
|
||
agency: "代理机构",
|
||
procurement_method: "采购方式",
|
||
legal_regime: "法律体系",
|
||
budget_total: "预算金额",
|
||
ceiling_total: "最高限价",
|
||
eval_method: "评标方法",
|
||
accept_consortium: "接受联合体",
|
||
accept_imported: "接受进口产品",
|
||
sme_only: "专门面向中小企业",
|
||
deadline_at: "投标截止时间",
|
||
};
|
||
export const FIELD_ORDER = Object.keys(FIELD_LABEL) as Schemas["ProjectFieldKey"][];
|
||
export const MONEY_FIELDS: ReadonlySet<string> = new Set(["budget_total", "ceiling_total"]);
|
||
export const BOOL_FIELDS: ReadonlySet<string> = new Set(["accept_consortium", "accept_imported", "sme_only"]);
|
||
|
||
/** 金额(元,字符串)显示成「286.00 万元」;不到一万的写元。数字来自招标文件原文,不是报价建议(红线 6) */
|
||
export function fmtYuan(v: string | null | undefined): string {
|
||
if (v == null || v === "") return "";
|
||
const n = Number(v);
|
||
if (!Number.isFinite(n)) return v;
|
||
return n >= 10000 ? `${(n / 10000).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 })} 万元` : `${n.toLocaleString("zh-CN")} 元`;
|
||
}
|
||
|
||
/** 字段值的显示文字;没有值时为空串 */
|
||
export function fieldText(key: string, v: unknown): string {
|
||
if (v == null || v === "") return "";
|
||
if (MONEY_FIELDS.has(key)) return fmtYuan(String(v));
|
||
if (BOOL_FIELDS.has(key)) return v ? "是" : "否";
|
||
if (key === "legal_regime") return legalRegime(String(v));
|
||
if (key === "deadline_at") return fmtDateTimeFull(String(v));
|
||
return String(v);
|
||
}
|
||
|
||
/* ---------- 时间 ---------- */
|
||
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)}`;
|
||
}
|
||
/** 完整的日期时间:「2026年11月20日 09:30」(北京时间) */
|
||
export function fmtDateTimeFull(iso: string): string {
|
||
const d = new Date(iso);
|
||
const parts = new Intl.DateTimeFormat("zh-CN", {
|
||
timeZone: "Asia/Shanghai",
|
||
year: "numeric",
|
||
month: "numeric",
|
||
day: "numeric",
|
||
hour: "2-digit",
|
||
minute: "2-digit",
|
||
hour12: false,
|
||
}).formatToParts(d);
|
||
const get = (t: string) => parts.find((x) => x.type === t)?.value ?? "";
|
||
return `${get("year")}年${get("month")}月${get("day")}日 ${get("hour")}:${get("minute")}`;
|
||
}
|
||
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;
|
||
}
|