feat(addendum): 补遗追加(FR-M2-10)
- 招标文件解析后可以追加补遗 / 澄清:单独解析、扣 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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user