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

@@ -11,7 +11,7 @@ import { unwrap } from "@bid-doctor/api-client";
import { FileText, Info, Plus, Search } from "@lucide/vue";
import { useQuery, useQueryClient } from "@tanstack/vue-query";
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { RouterLink } from "vue-router";
import { RouterLink, useRoute } from "vue-router";
import ChecklistItemRow from "@/components/ChecklistItemRow.vue";
import CitationBlock from "@/components/CitationBlock.vue";
import ConfirmDialog from "@/components/ConfirmDialog.vue";
@@ -58,13 +58,35 @@ type Cat = "all" | "pending" | "deleted" | ChecklistItem["categories"][number];
const cat = ref<Cat>("all");
const kw = ref("");
const starOnly = ref(false);
/* 补遗(FR-M2-10):按来源文件筛选(解析页「核对补遗」带 ?source=<文档>),只看待确认的日期更新 */
const route = useRoute();
const source = ref(typeof route.query.source === "string" ? route.query.source : "");
const supOnly = ref(false);
const sources = computed(() => {
const m = new Map<string, { id: string; label: string; n: number }>();
for (const i of formal.value) {
const d = i.origin_document;
if (!d) continue;
const e = m.get(d.id) ?? { id: d.id, label: d.label, n: 0 };
e.n++;
m.set(d.id, e);
}
return [...m.values()].sort((a, b) => (a.label === "招标文件" ? -1 : b.label === "招标文件" ? 1 : a.label.localeCompare(b.label, "zh-CN")));
});
const addenda = computed(() => sources.value.filter((x) => x.label !== "招标文件"));
const supPending = (i: ChecklistItem) => !!i.superseded_by && !i.superseded_by.confirmed;
const supCount = computed(() => formal.value.filter(supPending).length);
const deletedQ = useDeletedItems(() => props.projectId, () => cat.value === "deleted");
const deleted = computed(() => deletedQ.data.value ?? []);
const catCount = (c: string) => formal.value.filter((i) => i.categories.includes(c as ChecklistItem["categories"][number])).length;
const cats = computed(() => CATEGORY_ORDER.filter((c) => catCount(c) > 0));
const match = (i: ChecklistItem) => (!starOnly.value || i.importance === "substantive") && (!kw.value || i.title.includes(kw.value.trim()));
const match = (i: ChecklistItem) =>
(!starOnly.value || i.importance === "substantive") &&
(!kw.value || i.title.includes(kw.value.trim())) &&
(!source.value || i.origin_document?.id === source.value) &&
(!supOnly.value || supPending(i));
/** 「全部」视图下每条只出现一次,放在它的第一个分类里(一条要求可以属于多个分类) */
const groups = computed(() => {
@@ -79,7 +101,7 @@ const groups = computed(() => {
.filter((g) => g.rows.length);
});
const listMode = computed(() => (cat.value === "pending" || cat.value === "deleted" ? cat.value : "formal"));
const showTop = computed(() => cat.value === "all" && !kw.value && !starOnly.value);
const showTop = computed(() => cat.value === "all" && !kw.value && !starOnly.value && !source.value && !supOnly.value);
const showPending = computed(() => (cat.value === "all" && showTop.value) || cat.value === "pending");
/** 当前视图里自上而下的条目(J / K 用) */
const visible = computed<ChecklistItem[]>(() => {
@@ -88,6 +110,19 @@ const visible = computed<ChecklistItem[]>(() => {
return cat.value === "pending" ? top : [...top, ...groups.value.flatMap((g) => g.rows)];
});
/** 确认「已被补遗 N 更新」:采用时旧条目删除(可以恢复),保留两条时解除关联(AD3) */
async function resolveSupersession(i: ChecklistItem, accept: boolean) {
try {
await unwrap(api.POST("/checklist-items/{item_id}/supersession", { params: { path: { item_id: i.id } }, body: { accept } }));
void qc.invalidateQueries({ queryKey: qk.checklistItems(props.projectId) });
void qc.invalidateQueries({ queryKey: qk.checklist(props.projectId) });
void qc.invalidateQueries({ queryKey: qk.project(props.projectId) });
toast(accept ? "已按补遗更新,旧条目已删除(可以在「已删除」里恢复)" : "两条都保留,请自己核对");
} catch (e) {
toast(errorTitle(e), { ok: false });
}
}
/* ---------- 标注规则(如「打 ★ 的为实质性要求」)和数量核对 ---------- */
function ruleText(r: MarkingRule): string {
const mk = r.marker === "keyword" ? `「${r.marker_text ?? ""}」字样` : `「${r.marker_text || MARKER[r.marker]}」`;
@@ -289,11 +324,21 @@ onBeforeUnmount(() => window.removeEventListener("keydown", onKey));
</template>
</div>
<div v-if="cl && !readOnly" class="actions">
<RouterLink v-if="tender" class="btn" :to="{ name: 'project-new', query: { project: projectId, addendum: '1' } }"><Plus class="ic" :stroke-width="1.5" />追加补遗</RouterLink>
<button type="button" class="btn" :disabled="!all.length" @click="openDialog('create')"><Plus class="ic" :stroke-width="1.5" />新增条目</button>
<button v-if="cl.status !== 'confirmed'" type="button" class="btn primary" :disabled="!formal.length" @click="confirmOpen = true">确认清单</button>
</div>
</div>
<div v-if="addenda.length" class="alert warn warn-row" role="note">
<span class="ico w" aria-hidden="true">!</span>
<p>
{{ addenda.map((x) => `${x.label} 带来 ${x.n} 条`).join(",") }}<template v-if="supCount">,{{ supCount }} 处日期等你确认</template>。标博士不判断哪些旧条目被补遗改了或作废了,请核对:改了的旧条目可以修改或删除。
</p>
<button v-for="x in addenda" :key="x.id" type="button" class="btn sm" @click="(source = x.id), (cat = 'all')">只看{{ x.label }}</button>
<button v-if="supCount" type="button" class="btn sm" @click="(supOnly = true), (cat = 'all')">看日期更新</button>
</div>
<div v-if="realLots.length > 1" class="lot-scope" aria-label="清单范围">
<span class="cap muted">采购包</span>
<template v-if="lotScope === 'bidding'">
@@ -368,6 +413,11 @@ onBeforeUnmount(() => window.removeEventListener("keydown", onKey));
<input v-model="kw" class="input" placeholder="搜索清单" aria-label="搜索清单" />
</label>
<label class="choice cap"><input v-model="starOnly" type="checkbox" class="ck" />只看 ★</label>
<select v-if="sources.length > 1" v-model="source" class="input sm src" aria-label="来源">
<option value="">全部来源</option>
<option v-for="x in sources" :key="x.id" :value="x.id">{{ x.label }}({{ x.n }})</option>
</select>
<label v-if="supCount" class="choice cap"><input v-model="supOnly" type="checkbox" class="ck" />只看日期更新({{ supCount }})</label>
</div>
<template v-if="showTop">
@@ -416,6 +466,10 @@ onBeforeUnmount(() => window.removeEventListener("keydown", onKey));
@open-citation="(g) => { selId = i.id; openGroup(g); }"
>
<template #actions>
<template v-if="!readOnly && supPending(i)">
<button type="button" class="btn sm" @click="resolveSupersession(i, true)">确认更新(删除旧条目)</button>
<button type="button" class="btn sm text" @click="resolveSupersession(i, false)">保留两条</button>
</template>
<template v-if="!readOnly">
<button type="button" class="btn sm" @click="openDialog('edit', i)">修改</button>
<button type="button" class="btn sm" @click="startPick(i, 'add')">补出处</button>
@@ -485,6 +539,9 @@ onBeforeUnmount(() => window.removeEventListener("keydown", onKey));
</template>
<style scoped>
.lst-tb .src {
width: auto;
}
.lot-scope {
display: flex;
flex-wrap: wrap;