feat(addendum): 补遗追加(FR-M2-10)
Some checks failed
ci / server (push) Has been cancelled
ci / web (push) Has been cancelled
ci / contract (push) Has been cancelled

- 招标文件解析后可以追加补遗 / 澄清:单独解析、扣 1 次;新条目标「补遗 N」进正式清单,清单回到草稿(AD1)
- 同一种日期、时间不同的旧条目标「已被补遗 N 更新」,逐条确认更新或保留两条(AD2、AD3)
- 项目字段的新值在概览「采用 / 不采用」:采用时以补遗为准、清掉人工值;采用截止时间时日期条目一起确认;
  没确认前查标用旧值,检查预览提示(AD4)
- 补遗不改项目名称、编号、采购人、代理机构、法律体系(更正公告标题会被抽成项目名称);时间比较按北京时间补时区
- 接口:fields/{key}/pending、checklist-items/{id}/supersession;清单项 origin_document、superseded_by;
  关键日期 replaces;解析结果 dates_superseded、fields_pending;检查预览 pending_updates
- 前端:补遗模式的上传页、解析页结果、概览确认、读标页来源筛选和日期确认、检查预览提示
- 决策 AD1–AD6;方案和落地记录 docs/architecture/addendum-plan.md

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
YANG JIANKUAN
2026-11-05 00:27:21 +08:00
parent ed6a2a907d
commit 6e0d4c1dd6
27 changed files with 752 additions and 45 deletions

View File

@@ -3,15 +3,17 @@
* 项目概览(原型 project.html,PI1):这一步只做三张卡——项目要点(基本信息字段,带出处,可改、可恢复 AI 值)、
* 采购包(所投的包和限价)、关键日期(时间线和倒计时,FR-M2-09)。检查记录、文件、AI 摘要卡留到第 6 步。
* 查标按这里的值核对截止时间、限价、法律体系;改了以后重新检查才生效(PI5)。项目已递交、已归档时只读。
* 补遗(FR-M2-10):招标文件解析后可以追加;补遗带来的新值在这里「采用 / 不采用」(AD3),没确认前查标用旧值(AD4)。
*/
import type { Schemas } from "@bid-doctor/api-client";
import { Pencil, X } from "@lucide/vue";
import { unwrap, type Schemas } from "@bid-doctor/api-client";
import { Pencil, Plus, X } from "@lucide/vue";
import { useQueryClient } from "@tanstack/vue-query";
import { computed, ref } from "vue";
import { RouterLink } from "vue-router";
import FieldDialog from "@/components/FieldDialog.vue";
import PdfViewer, { type ViewerTarget } from "@/components/PdfViewer.vue";
import ProjectHead from "@/components/ProjectHead.vue";
import { api, errorTitle } from "@/lib/api";
import { useCrumbs } from "@/lib/crumbs";
import { qk } from "@/lib/keys";
import { deadline, FIELD_LABEL, FIELD_ORDER, fieldText, fmtDateTimeFull, fmtYuan } from "@/lib/labels";
@@ -40,7 +42,9 @@ const rows = computed(() =>
FIELD_ORDER.map((key) => {
const f = byKey.value.get(key) ?? null;
const col = COLUMN[key];
const value = f && f.value != null ? f.value : col && p.value ? (p.value[col] as unknown) : null;
// 名称:建项目时填的名称优先于 AI 抽到的(解析不覆盖用户填的名称),和项目头一致
const value =
key === "name" && p.value && f?.source !== "human" ? p.value.name : f && f.value != null ? f.value : col && p.value ? (p.value[col] as unknown) : null;
return { key, f, value };
}),
);
@@ -57,6 +61,24 @@ function onSaved(f: ProjectField, restored: boolean) {
toast(restored ? `已恢复${FIELD_LABEL[f.key]}的 AI 值` : `已修改${FIELD_LABEL[f.key]}`);
}
/* ---------- 补遗带来的新值:采用 / 不采用(AD3) ---------- */
const resolving = ref<string | null>(null);
async function resolvePending(key: Schemas["ProjectFieldKey"], accept: boolean) {
resolving.value = key;
try {
await unwrap(api.POST("/projects/{project_id}/fields/{field_key}/pending", { params: { path: { project_id: props.projectId, field_key: key } }, body: { accept } }));
void qc.invalidateQueries({ queryKey: qk.project(props.projectId) });
void qc.invalidateQueries({ queryKey: qk.projects });
void qc.invalidateQueries({ queryKey: qk.checklistItems(props.projectId) });
toast(accept ? `已采用补遗的${FIELD_LABEL[key]}` : `保留原来的${FIELD_LABEL[key]}`);
} catch (e) {
toast(errorTitle(e), { ok: false });
} finally {
resolving.value = null;
}
}
const canAddendum = computed(() => !!p.value && p.value.status !== "tracking" && !readOnly.value);
const viewer = ref<{ doc: { id: string; label: string }; target: ViewerTarget & { id: string } } | null>(null);
function openCitation(c: Citation) {
viewer.value = { doc: { id: c.document_id, label: c.document_label }, target: { id: c.id, page: c.page_no, quads: c.quads, sha: c.normalized_sha256 } };
@@ -70,6 +92,7 @@ function openCitation(c: Citation) {
<div class="card-h">
<h2 id="h-facts">项目要点</h2>
<span class="cap muted r">查标按这里的值核对截止时间、限价和法律体系</span>
<RouterLink v-if="canAddendum" class="btn sm" :to="{ name: 'project-new', query: { project: projectId, addendum: '1' } }"><Plus class="ic" :stroke-width="1.5" />追加补遗 / 澄清</RouterLink>
</div>
<dl class="facts-list">
<div v-for="{ key, f, value } in rows" :key="key" class="frow">
@@ -79,9 +102,16 @@ function openCitation(c: Citation) {
<span v-else class="dash">没写明</span>
<span v-if="f?.source === 'human'" class="flag box">已修改</span>
<button v-if="f?.citation" type="button" class="cite-src inline" @click="openCitation(f.citation)">
第 <span class="fig">{{ f.citation.page_no }}</span> 页
<template v-if="f.citation.document_label && f.citation.document_label !== '招标文件'">{{ f.citation.document_label }} </template>第 <span class="fig">{{ f.citation.page_no }}</span> 页
</button>
<span v-if="f?.pending" class="cap warn-text">补遗带来了新值:{{ fieldText(key, f.pending.value) }}(下一步补遗确认)</span>
<span v-if="f?.pending" class="pend-row">
<span class="cap warn-text">{{ f.pending.document_label }}:{{ fieldText(key, f.pending.value) }}<template v-if="value != null && value !== ''">(原为 {{ fieldText(key, value) }})</template>,没确认前查标用原值</span>
<button v-if="f.pending.citation" type="button" class="cite-src inline" @click="openCitation(f.pending.citation)">{{ f.pending.document_label }} 第 <span class="fig">{{ f.pending.citation.page_no }}</span> 页</button>
<template v-if="!readOnly">
<button type="button" class="btn sm" :disabled="resolving === key" @click="resolvePending(key, true)">采用</button>
<button type="button" class="btn sm text" :disabled="resolving === key" @click="resolvePending(key, false)">不采用</button>
</template>
</span>
</dd>
<button v-if="!readOnly" type="button" class="icon-btn sm" :aria-label="`修改${FIELD_LABEL[key]}`" @click="edit(key, f ?? { key, value: value as never, source: 'none' })">
<Pencil class="ic" :stroke-width="1.5" />
@@ -124,6 +154,8 @@ function openCitation(c: Citation) {
<span class="fig when">{{ fmtDateTimeFull(d.at) }}</span>
<b>{{ d.label }}</b>
<span v-if="d.edited" class="flag box">已修改</span>
<span v-if="d.superseded_pending" class="cap warn-text">已被补遗更新,待确认</span>
<span v-if="d.replaces" class="cap muted rep">已按{{ d.replaces.document_label }} 更新 · 原为 {{ d.replaces.at ? fmtDateTimeFull(d.replaces.at) : "—" }}</span>
<span v-if="d.due && d.due.cls !== 'due-past'" class="cap" :class="d.due.cls || 'muted'">{{ d.due.main }}</span>
</li>
</ol>
@@ -199,6 +231,16 @@ function openCitation(c: Citation) {
.warn-text {
color: var(--warn-text);
}
.pend-row {
display: flex;
flex-basis: 100%;
flex-wrap: wrap;
gap: var(--sp-2);
align-items: baseline;
}
.timeline .rep {
flex-basis: 100%;
}
.none,
.note {
margin: 0;