feat(project): 项目基本信息修改和分包选择(FR-M2-03、04)
Some checks failed
ci / server (push) Has been cancelled
ci / web (push) Has been cancelled
ci / contract (push) Has been cancelled

- 字段修改:人工值优先、撤销恢复 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>
This commit is contained in:
YANG JIANKUAN
2026-11-04 23:46:07 +08:00
parent 08e3fe7eff
commit ed6a2a907d
29 changed files with 1834 additions and 43 deletions

View File

@@ -196,6 +196,44 @@ export const QUALIFICATION_KIND: Dict<Schemas["QualificationKind"]> = {
};
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()}日`;
@@ -228,6 +266,21 @@ export function parseDay(text: string): string | null {
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) };