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

@@ -222,4 +222,5 @@ samples/
- 2026-11-04:查标第 5 步检查报告:导出 → 后台用 Gotenberg 生成 A4 PDF(首页覆盖范围和免责声明、问题明细、需人工核对附录,页眉 logo、页脚免责一句和页码)→ 加密归档,可重复下载;前端加「报告」标签页(选项、导出、内嵌预览、历次下载)。补了项目状态流转(递交 / 撤销递交 / 归档 / 撤销归档,致命未处理也能递交,递交后只读,都留痕)。产品可以从上传招标文件一路走到导出报告、标记递交。见 `docs/architecture/m3-plan.md` 进度。 - 2026-11-04:查标第 5 步检查报告:导出 → 后台用 Gotenberg 生成 A4 PDF(首页覆盖范围和免责声明、问题明细、需人工核对附录,页眉 logo、页脚免责一句和页码)→ 加密归档,可重复下载;前端加「报告」标签页(选项、导出、内嵌预览、历次下载)。补了项目状态流转(递交 / 撤销递交 / 归档 / 撤销归档,致命未处理也能递交,递交后只读,都留痕)。产品可以从上传招标文件一路走到导出报告、标记递交。见 `docs/architecture/m3-plan.md` 进度。
- 2026-11-04:查标第 4 步资质库和资质匹配(`docs/architecture/qualification-plan.md`,QL1–QL7 全部按建议确认)。资质库页可增删改、挂证书扫描件(企业级密钥加密),到期按进行中项目的投标截止日提醒;查标按证书类别词表(16 类,产品证书、人员证书不进匹配)核对有效期、等级、有没有覆盖,投标文字层按清单项定级,只有资质库记录的降一级、出处显示「依据:资质库记录」,规则集 2026.11-r4。开发集不退化;派生的资质情况表上 D12 3 / 3、D18 2 / 2、规矩的资质表 0 误报,但表和词表是对着同一批样本写的,泛化要等国动真实文件(`check-eval.md` §13)。 - 2026-11-04:查标第 4 步资质库和资质匹配(`docs/architecture/qualification-plan.md`,QL1–QL7 全部按建议确认)。资质库页可增删改、挂证书扫描件(企业级密钥加密),到期按进行中项目的投标截止日提醒;查标按证书类别词表(16 类,产品证书、人员证书不进匹配)核对有效期、等级、有没有覆盖,投标文字层按清单项定级,只有资质库记录的降一级、出处显示「依据:资质库记录」,规则集 2026.11-r4。开发集不退化;派生的资质情况表上 D12 3 / 3、D18 2 / 2、规矩的资质表 0 误报,但表和词表是对着同一批样本写的,泛化要等国动真实文件(`check-eval.md` §13)。
- 2026-11-04:项目基本信息修改和分包选择(FR-M2-03、04,`docs/architecture/project-info-plan.md`,PI1–PI7 全部按建议确认)。新增「概览」标签页(项目要点可改、带出处、可恢复 AI 值;采购包;关键日期)和选包页(手工调整、一键「不分包」);清单默认只列通用 + 所投的包;检查记下当时用的截止时间、限价、所投的包、法律体系,报告按它写,之后改了信息查标页提示重新检查。样本里分包识别对的只有 S10(S06 把分项认成 5 个包,S01、S08 单包写了包号),改进分包抽取记进读标专项。 - 2026-11-04:项目基本信息修改和分包选择(FR-M2-03、04,`docs/architecture/project-info-plan.md`,PI1–PI7 全部按建议确认)。新增「概览」标签页(项目要点可改、带出处、可恢复 AI 值;采购包;关键日期)和选包页(手工调整、一键「不分包」);清单默认只列通用 + 所投的包;检查记下当时用的截止时间、限价、所投的包、法律体系,报告按它写,之后改了信息查标页提示重新检查。样本里分包识别对的只有 S10(S06 把分项认成 5 个包,S01、S08 单包写了包号),改进分包抽取记进读标专项。
- 下一步:国动真实投标文件(用户晚些提供,到时先读标、请用户看清单,再查标一次;请用户按公司真实证书录资质库);其余可做:补遗追加(FR-M2-10,含补遗带来的新值确认)、第 6 步前端补齐(键盘操作、批量处理、判断有误反馈、日期选择器的日历弹层、概览页的检查记录和文件卡)、M6 剩下的 P0(额度流水、套餐开通、数据删除、运营后台)。 - 2026-11-05:补遗追加(FR-M2-10,`docs/architecture/addendum-plan.md`,AD1–AD6 全部按建议确认)。招标文件解析后可以追加补遗 / 澄清(单独解析、扣 1 次),新条目标「补遗 N」进正式清单、清单回到草稿;同一种日期时间不同的旧条目标「已被补遗 N 更新」,项目字段的新值在概览「采用 / 不采用」,采用截止时间时日期条目一起确认,没确认前查标用旧值、检查预览提示。补遗不改项目名称、编号、采购人等(更正公告标题会被抽成项目名称)。真实模型对虚构更正公告抽得对;还没有真实补遗样本。
- 下一步:国动真实投标文件(用户晚些提供,到时先读标、请用户看清单,再查标一次;请用户按公司真实证书录资质库;有更正公告一起要);其余可做:第 6 步前端补齐(键盘操作、批量处理、判断有误反馈、日期选择器的日历弹层、概览页的检查记录和文件卡)、M6 剩下的 P0(额度流水、套餐开通、数据删除、运营后台)。

View File

@@ -33,12 +33,14 @@ const facts = computed(() => specFacts(props.item.spec));
<div class="t">{{ item.title }}</div> <div class="t">{{ item.title }}</div>
<div class="meta"> <div class="meta">
<span v-if="item.origin === 'ai'" class="tag ai">AI</span> <span v-if="item.origin === 'ai'" class="tag ai">AI</span>
<span v-else class="tag">{{ item.origin_label || (item.origin === "human" ? "人工添加" : "规则") }}</span> <span v-else-if="item.origin === 'human' || !item.origin_document || item.origin_document.label === '招标文件'" class="tag">{{ item.origin === "human" ? "人工添加" : "规则" }}</span>
<span v-if="item.origin !== 'human' && item.origin_document && item.origin_document.label !== '招标文件'" class="tag">{{ item.origin_document.label }}</span>
<span v-for="c in item.categories" :key="c" class="tag">{{ category(c) }}</span> <span v-for="c in item.categories" :key="c" class="tag">{{ category(c) }}</span>
<span class="tag">{{ item.lot_no ? `采购包 ${item.lot_no}` : "通用" }}</span> <span class="tag">{{ item.lot_no ? `采购包 ${item.lot_no}` : "通用" }}</span>
<span v-if="pending && item.pending_reason" class="flag">{{ [pendingReason(item.pending_reason), failureText(item.ai_values)].filter(Boolean).join(" · ") }}</span> <span v-if="pending && item.pending_reason" class="flag">{{ [pendingReason(item.pending_reason), failureText(item.ai_values)].filter(Boolean).join(" · ") }}</span>
<span v-if="item.no_citation_ack" class="flag">人工确认、无出处</span> <span v-if="item.no_citation_ack" class="flag">人工确认、无出处</span>
<span v-if="item.edited_fields.length" class="flag">已修改</span> <span v-if="item.edited_fields.length" class="flag">已修改</span>
<span v-if="item.superseded_by && !item.superseded_by.confirmed" class="flag">已被{{ item.superseded_by.document_label }} 更新,待确认</span>
</div> </div>
<p v-if="facts.length" class="facts"> <p v-if="facts.length" class="facts">
<span v-for="f in facts" :key="f">{{ f }}</span> <span v-for="f in facts" :key="f">{{ f }}</span>

View File

@@ -213,6 +213,12 @@ async function start() {
本次查:通用条目 + {{ project.data.value.bidding_lots.map((n) => `采购包 ${n}`).join("、") }} 本次查:通用条目 + {{ project.data.value.bidding_lots.map((n) => `采购包 ${n}`).join("、") }}
<RouterLink class="lnk" :to="{ name: 'lots', params: { projectId } }">更改所投的包</RouterLink> <RouterLink class="lnk" :to="{ name: 'lots', params: { projectId } }">更改所投的包</RouterLink>
</p> </p>
<!-- AD4:补遗带来的新值没确认前查标用旧值;只提示,不阻止检查 -->
<div v-if="pv?.pending_updates" class="alert warn" role="note">
<span class="ico w" aria-hidden="true">!</span>
<p>补遗带来的新值还有 <span class="fig">{{ pv.pending_updates }}</span> 处没确认,这次检查按原来的值。可以先在概览和读标页确认。</p>
<RouterLink class="btn sm" :to="{ name: 'overview', params: { projectId } }">去确认</RouterLink>
</div>
<div v-if="startErr" class="alert warn" role="alert"><span class="ico w" aria-hidden="true">!</span><p>{{ startErr }}</p></div> <div v-if="startErr" class="alert warn" role="alert"><span class="ico w" aria-hidden="true">!</span><p>{{ startErr }}</p></div>
</div> </div>

View File

@@ -11,7 +11,7 @@ import { unwrap } from "@bid-doctor/api-client";
import { FileText, Info, Plus, Search } from "@lucide/vue"; import { FileText, Info, Plus, Search } from "@lucide/vue";
import { useQuery, useQueryClient } from "@tanstack/vue-query"; import { useQuery, useQueryClient } from "@tanstack/vue-query";
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue"; 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 ChecklistItemRow from "@/components/ChecklistItemRow.vue";
import CitationBlock from "@/components/CitationBlock.vue"; import CitationBlock from "@/components/CitationBlock.vue";
import ConfirmDialog from "@/components/ConfirmDialog.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 cat = ref<Cat>("all");
const kw = ref(""); const kw = ref("");
const starOnly = ref(false); 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 deletedQ = useDeletedItems(() => props.projectId, () => cat.value === "deleted");
const deleted = computed(() => deletedQ.data.value ?? []); const deleted = computed(() => deletedQ.data.value ?? []);
const catCount = (c: string) => formal.value.filter((i) => i.categories.includes(c as ChecklistItem["categories"][number])).length; 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 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(() => { const groups = computed(() => {
@@ -79,7 +101,7 @@ const groups = computed(() => {
.filter((g) => g.rows.length); .filter((g) => g.rows.length);
}); });
const listMode = computed(() => (cat.value === "pending" || cat.value === "deleted" ? cat.value : "formal")); 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"); const showPending = computed(() => (cat.value === "all" && showTop.value) || cat.value === "pending");
/** 当前视图里自上而下的条目(J / K 用) */ /** 当前视图里自上而下的条目(J / K 用) */
const visible = computed<ChecklistItem[]>(() => { const visible = computed<ChecklistItem[]>(() => {
@@ -88,6 +110,19 @@ const visible = computed<ChecklistItem[]>(() => {
return cat.value === "pending" ? top : [...top, ...groups.value.flatMap((g) => g.rows)]; 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 { function ruleText(r: MarkingRule): string {
const mk = r.marker === "keyword" ? `「${r.marker_text ?? ""}」字样` : `「${r.marker_text || MARKER[r.marker]}」`; const mk = r.marker === "keyword" ? `「${r.marker_text ?? ""}」字样` : `「${r.marker_text || MARKER[r.marker]}」`;
@@ -289,11 +324,21 @@ onBeforeUnmount(() => window.removeEventListener("keydown", onKey));
</template> </template>
</div> </div>
<div v-if="cl && !readOnly" class="actions"> <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 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> <button v-if="cl.status !== 'confirmed'" type="button" class="btn primary" :disabled="!formal.length" @click="confirmOpen = true">确认清单</button>
</div> </div>
</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="清单范围"> <div v-if="realLots.length > 1" class="lot-scope" aria-label="清单范围">
<span class="cap muted">采购包</span> <span class="cap muted">采购包</span>
<template v-if="lotScope === 'bidding'"> <template v-if="lotScope === 'bidding'">
@@ -368,6 +413,11 @@ onBeforeUnmount(() => window.removeEventListener("keydown", onKey));
<input v-model="kw" class="input" placeholder="搜索清单" aria-label="搜索清单" /> <input v-model="kw" class="input" placeholder="搜索清单" aria-label="搜索清单" />
</label> </label>
<label class="choice cap"><input v-model="starOnly" type="checkbox" class="ck" />只看 ★</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> </div>
<template v-if="showTop"> <template v-if="showTop">
@@ -416,6 +466,10 @@ onBeforeUnmount(() => window.removeEventListener("keydown", onKey));
@open-citation="(g) => { selId = i.id; openGroup(g); }" @open-citation="(g) => { selId = i.id; openGroup(g); }"
> >
<template #actions> <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"> <template v-if="!readOnly">
<button type="button" class="btn sm" @click="openDialog('edit', i)">修改</button> <button type="button" class="btn sm" @click="openDialog('edit', i)">修改</button>
<button type="button" class="btn sm" @click="startPick(i, 'add')">补出处</button> <button type="button" class="btn sm" @click="startPick(i, 'add')">补出处</button>
@@ -485,6 +539,9 @@ onBeforeUnmount(() => window.removeEventListener("keydown", onKey));
</template> </template>
<style scoped> <style scoped>
.lst-tb .src {
width: auto;
}
.lot-scope { .lot-scope {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;

View File

@@ -3,15 +3,17 @@
* 项目概览(原型 project.html,PI1):这一步只做三张卡——项目要点(基本信息字段,带出处,可改、可恢复 AI 值)、 * 项目概览(原型 project.html,PI1):这一步只做三张卡——项目要点(基本信息字段,带出处,可改、可恢复 AI 值)、
* 采购包(所投的包和限价)、关键日期(时间线和倒计时,FR-M2-09)。检查记录、文件、AI 摘要卡留到第 6 步。 * 采购包(所投的包和限价)、关键日期(时间线和倒计时,FR-M2-09)。检查记录、文件、AI 摘要卡留到第 6 步。
* 查标按这里的值核对截止时间、限价、法律体系;改了以后重新检查才生效(PI5)。项目已递交、已归档时只读。 * 查标按这里的值核对截止时间、限价、法律体系;改了以后重新检查才生效(PI5)。项目已递交、已归档时只读。
* 补遗(FR-M2-10):招标文件解析后可以追加;补遗带来的新值在这里「采用 / 不采用」(AD3),没确认前查标用旧值(AD4)。
*/ */
import type { Schemas } from "@bid-doctor/api-client"; import { unwrap, type Schemas } from "@bid-doctor/api-client";
import { Pencil, X } from "@lucide/vue"; import { Pencil, Plus, X } from "@lucide/vue";
import { useQueryClient } from "@tanstack/vue-query"; import { useQueryClient } from "@tanstack/vue-query";
import { computed, ref } from "vue"; import { computed, ref } from "vue";
import { RouterLink } from "vue-router"; import { RouterLink } from "vue-router";
import FieldDialog from "@/components/FieldDialog.vue"; import FieldDialog from "@/components/FieldDialog.vue";
import PdfViewer, { type ViewerTarget } from "@/components/PdfViewer.vue"; import PdfViewer, { type ViewerTarget } from "@/components/PdfViewer.vue";
import ProjectHead from "@/components/ProjectHead.vue"; import ProjectHead from "@/components/ProjectHead.vue";
import { api, errorTitle } from "@/lib/api";
import { useCrumbs } from "@/lib/crumbs"; import { useCrumbs } from "@/lib/crumbs";
import { qk } from "@/lib/keys"; import { qk } from "@/lib/keys";
import { deadline, FIELD_LABEL, FIELD_ORDER, fieldText, fmtDateTimeFull, fmtYuan } from "@/lib/labels"; import { deadline, FIELD_LABEL, FIELD_ORDER, fieldText, fmtDateTimeFull, fmtYuan } from "@/lib/labels";
@@ -40,7 +42,9 @@ const rows = computed(() =>
FIELD_ORDER.map((key) => { FIELD_ORDER.map((key) => {
const f = byKey.value.get(key) ?? null; const f = byKey.value.get(key) ?? null;
const col = COLUMN[key]; 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 }; 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]}`); 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); const viewer = ref<{ doc: { id: string; label: string }; target: ViewerTarget & { id: string } } | null>(null);
function openCitation(c: Citation) { 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 } }; 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"> <div class="card-h">
<h2 id="h-facts">项目要点</h2> <h2 id="h-facts">项目要点</h2>
<span class="cap muted r">查标按这里的值核对截止时间、限价和法律体系</span> <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> </div>
<dl class="facts-list"> <dl class="facts-list">
<div v-for="{ key, f, value } in rows" :key="key" class="frow"> <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-else class="dash">没写明</span>
<span v-if="f?.source === 'human'" class="flag box">已修改</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)"> <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> </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> </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' })"> <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" /> <Pencil class="ic" :stroke-width="1.5" />
@@ -124,6 +154,8 @@ function openCitation(c: Citation) {
<span class="fig when">{{ fmtDateTimeFull(d.at) }}</span> <span class="fig when">{{ fmtDateTimeFull(d.at) }}</span>
<b>{{ d.label }}</b> <b>{{ d.label }}</b>
<span v-if="d.edited" class="flag box">已修改</span> <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> <span v-if="d.due && d.due.cls !== 'due-past'" class="cap" :class="d.due.cls || 'muted'">{{ d.due.main }}</span>
</li> </li>
</ol> </ol>
@@ -199,6 +231,16 @@ function openCitation(c: Citation) {
.warn-text { .warn-text {
color: var(--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, .none,
.note { .note {
margin: 0; margin: 0;

View File

@@ -110,13 +110,15 @@ async function retry() {
} }
const result = computed(() => j.value?.result ?? null); const result = computed(() => j.value?.result ?? null);
/** 补遗 / 澄清(FR-M2-10):结果按补遗写,下一步去读标页核对这份补遗的条目 */
const isAddendum = computed(() => !!doc.data.value && doc.data.value.role !== "tender");
</script> </script>
<template> <template>
<div class="content narrow"> <div class="content narrow">
<div class="page-title"> <div class="page-title">
<div> <div>
<h1>解析招标文件</h1> <h1>{{ isAddendum ? `解析${doc.data.value?.label}` : "解析招标文件" }}</h1>
<p v-if="doc.data.value"> <p v-if="doc.data.value">
{{ doc.data.value.original_filename }}<template v-if="doc.data.value.page_count"> · <span class="fig">{{ doc.data.value.page_count }}</span> 页</template> {{ doc.data.value.original_filename }}<template v-if="doc.data.value.page_count"> · <span class="fig">{{ doc.data.value.page_count }}</span> 页</template>
</p> </p>
@@ -147,7 +149,14 @@ const result = computed(() => j.value?.result ?? null);
· 其中 <span class="fig">{{ result.items_pending }}</span> 条没找到可靠出处或把握不大,放在「待确认」里</template> · 其中 <span class="fig">{{ result.items_pending }}</span> 条没找到可靠出处或把握不大,放在「待确认」里</template>
</dd> </dd>
<template v-if="result?.items_updated"><dt>更新</dt><dd><span class="fig">{{ result.items_updated }}</span> 条</dd></template> <template v-if="result?.items_updated"><dt>更新</dt><dd><span class="fig">{{ result.items_updated }}</span> 条</dd></template>
<template v-if="result?.lots_found"><dt>采购包</dt><dd>识别到 <span class="fig">{{ result.lots_found }}</span> 个</dd></template> <template v-if="isAddendum">
<dt>日期</dt>
<dd v-if="result?.dates_superseded"><span class="fig">{{ result.dates_superseded }}</span> 处日期有新值,旧条目标「已被{{ doc.data.value?.label }} 更新」,等你确认</dd>
<dd v-else>没有改动已有的日期</dd>
<template v-if="result?.fields_pending"><dt>项目信息</dt><dd><span class="fig">{{ result.fields_pending }}</span> 个字段有新值,在概览里采用或不采用</dd></template>
<dt>清单</dt><dd>回到草稿。标博士不判断哪些旧条目被补遗改了或作废了,核对后重新确认</dd>
</template>
<template v-else-if="result?.lots_found"><dt>采购包</dt><dd>识别到 <span class="fig">{{ result.lots_found }}</span> 个</dd></template>
</dl> </dl>
</template> </template>
@@ -191,7 +200,8 @@ const result = computed(() => j.value?.result ?? null);
</template> </template>
<template v-else-if="status === 'succeeded'"> <template v-else-if="status === 'succeeded'">
<!-- 识别到 2 个及以上的包时先选包(FR-M2-04);只有 1 个包按不分包处理(PI4) --> <!-- 识别到 2 个及以上的包时先选包(FR-M2-04);只有 1 个包按不分包处理(PI4) -->
<RouterLink v-if="(result?.lots_found ?? 0) > 1" class="btn primary" :to="{ name: 'lots', params: { projectId } }">选择要投的包</RouterLink> <RouterLink v-if="isAddendum" class="btn primary" :to="{ name: 'checklist', params: { projectId }, query: { source: doc.data.value?.id } }">核对补遗</RouterLink>
<RouterLink v-else-if="(result?.lots_found ?? 0) > 1" class="btn primary" :to="{ name: 'lots', params: { projectId } }">选择要投的包</RouterLink>
<RouterLink v-else class="btn primary" :to="{ name: 'checklist', params: { projectId } }">查看检查清单</RouterLink> <RouterLink v-else class="btn primary" :to="{ name: 'checklist', params: { projectId } }">查看检查清单</RouterLink>
<span class="r"><span class="cost">已结算 <span class="fig">1</span> 次解析 · {{ j.finished_at ? fmtDateTime(j.finished_at) : "" }}</span></span> <span class="r"><span class="cost">已结算 <span class="fig">1</span> 次解析 · {{ j.finished_at ? fmtDateTime(j.finished_at) : "" }}</span></span>
</template> </template>

View File

@@ -4,6 +4,8 @@
* 选好文件就开始分片上传(格式、加密、页数在上传完成时由服务端拦截,不扣点); * 选好文件就开始分片上传(格式、加密、页数在上传完成时由服务端拦截,不扣点);
* 「开始解析」:建项目 → 把文件挂到项目上 → 发起解析(预扣 1 次,带 expected_charge,API4)。 * 「开始解析」:建项目 → 把文件挂到项目上 → 发起解析(预扣 1 次,带 expected_charge,API4)。
* 带 ?project=<id> 时给已有的项目补传招标文件(例如上一次上传失败),不再建新项目。 * 带 ?project=<id> 时给已有的项目补传招标文件(例如上一次上传失败),不再建新项目。
* 再带 &addendum=1 时是追加补遗 / 澄清(FR-M2-10,原型 project-new.html?addendum=1):不填项目名称,文件类型选补遗或澄清答复,
* 单独解析、扣 1 次;新条目标注「补遗 N」并入清单,标博士不判断哪些旧条目已作废,并入后由用户核对。
*/ */
import { ApiError, unwrap, uploadFile, type Schemas } from "@bid-doctor/api-client"; import { ApiError, unwrap, uploadFile, type Schemas } from "@bid-doctor/api-client";
import { Upload, X } from "@lucide/vue"; import { Upload, X } from "@lucide/vue";
@@ -30,7 +32,14 @@ const parse = useQuotaAccount("parse");
const projectId = ref(typeof route.query.project === "string" ? route.query.project : ""); const projectId = ref(typeof route.query.project === "string" ? route.query.project : "");
const existing = useProject(() => (route.query.project ? projectId.value : undefined)); const existing = useProject(() => (route.query.project ? projectId.value : undefined));
useCrumbs(() => [{ label: "工作台", to: { name: "workbench" } }, { label: existing.data.value ? `${existing.data.value.name} · 上传招标文件` : "新建项目" }]); const addendum = computed(() => !!route.query.project && route.query.addendum === "1");
const role = ref<"addendum" | "clarification">("addendum");
const docWord = computed(() => (addendum.value ? "补遗 / 澄清文件" : "招标文件"));
useCrumbs(() =>
addendum.value
? [{ label: "工作台", to: { name: "workbench" } }, { label: existing.data.value?.name ?? "项目", to: { name: "overview", params: { projectId: projectId.value } } }, { label: "追加补遗" }]
: [{ label: "工作台", to: { name: "workbench" } }, { label: existing.data.value ? `${existing.data.value.name} · 上传招标文件` : "新建项目" }],
);
const limits = computed(() => plans.data.value?.items.find((p) => p.code === me.data.value?.plan.code)?.limits); const limits = computed(() => plans.data.value?.items.find((p) => p.code === me.data.value?.plan.code)?.limits);
const name = ref(""); const name = ref("");
@@ -90,7 +99,7 @@ const startErr = ref("");
async function start() { async function start() {
if (up.value.kind !== "done") { if (up.value.kind !== "done") {
fileErr.value = up.value.kind === "uploading" ? "文件还在上传,传完再开始解析" : "请先上传招标文件"; fileErr.value = up.value.kind === "uploading" ? "文件还在上传,传完再开始解析" : `请先上传${docWord.value}`;
return; return;
} }
const uploadId = up.value.result.id; const uploadId = up.value.result.id;
@@ -107,7 +116,7 @@ async function start() {
const d = await unwrap( const d = await unwrap(
api.POST("/projects/{project_id}/documents", { api.POST("/projects/{project_id}/documents", {
params: { path: { project_id: projectId.value } }, params: { path: { project_id: projectId.value } },
body: { upload_id: uploadId, role: "tender" }, body: { upload_id: uploadId, role: addendum.value ? role.value : "tender" },
}), }),
); );
documentId.value = d.id; documentId.value = d.id;
@@ -139,8 +148,9 @@ async function start() {
<div class="content narrow"> <div class="content narrow">
<div class="page-title"> <div class="page-title">
<div> <div>
<h1>{{ existing.data.value ? "上传招标文件" : "新建项目" }}</h1> <h1>{{ addendum ? "追加补遗 / 澄清" : existing.data.value ? "上传招标文件" : "新建项目" }}</h1>
<p>上传招标文件,标博士会逐条提取要求,每一条都带原文出处。</p> <p v-if="addendum">补遗单独解析,新条目标注「补遗 N」并入清单,日期和项目信息的新值等你确认。标博士不判断哪些旧条目已作废,并入后由你核对。</p>
<p v-else>上传招标文件,标博士会逐条提取要求,每一条都带原文出处。</p>
</div> </div>
</div> </div>
@@ -156,9 +166,16 @@ async function start() {
<label>项目</label> <label>项目</label>
<span>{{ existing.data.value.name }}</span> <span>{{ existing.data.value.name }}</span>
</div> </div>
<div v-if="addendum" class="field">
<label>文件类型</label>
<div class="seg" role="group" aria-label="文件类型">
<button type="button" :aria-pressed="role === 'addendum'" :disabled="busy" @click="role = 'addendum'">补遗 / 更正公告</button>
<button type="button" :aria-pressed="role === 'clarification'" :disabled="busy" @click="role = 'clarification'">澄清答复</button>
</div>
</div>
<div class="field" :class="{ err: fileErr }"> <div class="field" :class="{ err: fileErr }">
<label id="l-file">招标文件</label> <label id="l-file">{{ docWord }}</label>
<div <div
v-if="!fileOf" v-if="!fileOf"
class="drop" class="drop"
@@ -216,7 +233,7 @@ async function start() {
<div class="card-f"> <div class="card-f">
<!-- 红线 9:扣点规则写在按钮旁边 --> <!-- 红线 9:扣点规则写在按钮旁边 -->
<span class="cost">将扣 <span class="fig">1</span> 次解析;解析失败、超时或取消不扣点<template v-if="parse"> · 剩 <span class="fig">{{ parse.balance }}</span> 次</template></span> <span class="cost">将扣 <span class="fig">1</span> 次解析;解析失败、超时或取消不扣点<template v-if="parse"> · 剩 <span class="fig">{{ parse.balance }}</span> 次</template></span>
<RouterLink class="btn" :to="{ name: 'workbench' }">取消</RouterLink> <RouterLink class="btn" :to="addendum ? { name: 'overview', params: { projectId } } : { name: 'workbench' }">取消</RouterLink>
<button type="submit" class="btn primary swap" :class="{ 'is-loading': busy }" :aria-busy="busy" :disabled="busy || up.kind !== 'done'"> <button type="submit" class="btn primary swap" :class="{ 'is-loading': busy }" :aria-busy="busy" :disabled="busy || up.kind !== 'done'">
<span class="idle">开始解析</span><span class="busy">正在提交…</span> <span class="idle">开始解析</span><span class="busy">正在提交…</span>
</button> </button>

View File

@@ -0,0 +1,98 @@
# 补遗追加实现方案 v0.1(2026-11-05)
> 范围:FR-M2-10 补遗追加(简化版):追加的补遗 / 澄清文件单独解析、扣 1 次解析,新条目标注来源并入清单;日期类字段以最新文件为准,在旧值上标「已被补遗 N 更新」,由用户确认;不自动判断条目「新增 / 变更 / 作废」(P1)。
> 依据:`03-functional-requirements.md`、`02-user-journeys.md` J2 第 1 步、`01-scope-roles.md`(「追加解析 + 来源标注」,影响分析是 P1)、`data-model.md` §5.2「补遗」、`openapi.yaml`(`/projects/{id}/fields/{key}/pending`、`/checklist-items/{id}/supersession`)、原型 `project-new.html?addendum=1` `parsing.html?addendum=1` `checklist.html` `project.html` 的关键日期卡。
> 状态:AD1–AD6 已全部按建议确认(2026-11-05,`docs/decisions.md`)。
## 0. 结论速览
- **一半已经有了**:后端上传时可以选补遗 / 澄清(编号「补遗 N」「澄清 N」),能单独解析、扣 1 次解析、失败退回;新条目记着来自哪份文件;项目字段的新值和旧值不同时先放进待确认(`pending_value`),不覆盖。
- **缺的**:日期类条目的「已被补遗 N 更新」关联;两个确认接口(字段新值、日期条目);并入后清单回到草稿;前端的追加入口、解析结果、确认界面、来源筛选。
- **按 FR 只做日期类的自动关联**。原型里「★ 参数原为 480G、补遗改成 960G」那种逐条对照属于「变更判断」,是 P1,这次不做(AD2)。
- **补遗的新条目直接进正式清单**,和招标文件的新旧条目都查:宁可多报,不漏报(AD1)。
- **没有补遗样本**:10 份样本里一份都没有。这次用虚构的更正公告测链路;国动项目如果有更正公告,请一起给我,这是第一份真实补遗。
## 1. 现状
| 环节 | 现在 | 要补的 |
|---|---|---|
| 上传、编号 | `role = addendum / clarification`,各自从 1 编号 | 前端入口 |
| 解析、扣点 | 和招标文件同一条流水线,扣 1 次解析,失败、超时、取消退回 | 解析结果页按补遗写(新增几条、哪些日期有新值) |
| 清单项来源 | `origin_document_id` 指向补遗;但接口的 `origin_label` 只有「AI」「人工添加」 | `origin_label` 对补遗来的条目写「补遗 1」;读标页按来源筛选;并入后清单回到草稿 |
| 项目字段 | 新值和旧值不同 → `pending_value`,等确认 | 确认接口和界面;确认后同步冗余列 |
| 日期类条目 | 没有关联(`superseded_by_id` 列已建,没写) | 并入时关联、确认接口、界面 |
| 分包 | 补遗不改已有的分包 | 不变 |
## 2. 并入时做什么(后端)
补遗解析完、并入清单时,在现有的 `checklist.merge` 之后多做三件事:
1. **新条目照常进清单**:和招标文件一样按校验结果进正式清单或待确认区,标「补遗 N」。
2. **关联日期类条目**:补遗里每条有具体时间的「日期与递交」条目(投标截止、开标、提疑截止、保证金截止、投标有效期),在更早的文件(招标文件、编号更小的补遗)里找同一种日期、还在正式清单里的条目,时间不同的把旧条目的 `superseded_by_id` 指向新条目,等用户确认。时间相同的不关联(补遗只是复述)。
3. **清单回到草稿**:已确认的清单改为草稿,历史版本保留,要求重新确认(原型:「补遗 1 并入后待重新确认」)。
解析任务的结果里加:`items_added`(新增几条)、`dates_superseded`(几处日期有新值)、`fields_pending`(几个项目字段有新值),解析页按这三个数写。
## 3. 确认(后端)
按契约实现两个接口:
- **`POST /projects/{id}/fields/{key}/pending`**(`accept: true / false`):
- 采用:补遗的值变成 AI 值(出处换成补遗那一句),**清掉人工值**(以补遗为准;用户之后还可以再改),冗余列同步;
- 不采用:丢掉待确认值,保持原值;
- 采用投标截止时间时,清单里「投标截止」日期条目的更新一起确认(AD3),不用在读标页再点一次;
- 都写审计(`project_field.resolve_pending`)。
- **`POST /checklist-items/{id}/supersession`**(`accept: true / false`,对旧条目调用):
- 采用:旧条目软删除(可以恢复),新条目保留;
- 保留两条:解除关联,两条都在,由用户自己处理;
- 都写审计(`checklist_item.resolve_supersession`)。
接口返回里补上已有字段:清单项的 `superseded_by`(新条目编号、来自哪份补遗)、清单概况的 `superseded_pending`(还有几处没确认)、关键日期的 `superseded_pending`。
**没确认之前查标用旧值**(AD4):项目字段的契约就是「确认后才生效」(`data-model.md` §5.2)。检查预览提示「补遗 1 带来的新值还有 N 处没确认」,不阻止发起检查(红线 4)。确认后截止时间变了,已有的检查结果按 PI5 提示重新检查。
## 4. 前端
- **追加入口**:概览页右上角和读标页清单栏各放一个「追加补遗 / 澄清」,进新建项目页的补遗模式(原型 `project-new.html?addendum=1`)。补遗模式下不填项目名称,文件类型选补遗或澄清答复,说明写「补遗单独解析,新条目标注『补遗 N』并入清单。标博士不判断哪些旧条目已作废,并入后由你核对」,按钮旁写扣 1 次解析。招标文件还没解析完时不显示入口(AD5)。
- **解析页**:标题「解析补遗 1」;完成后写「新增 N 条 · M 处日期有新值 · K 个项目字段有新值 · 清单回到草稿,核对后重新确认」,下一步「核对补遗」进读标页、只看这份补遗的条目。
- **概览页**:
- 项目要点里有待确认值的字段写「补遗 1:新值 → 原值」,带出处,给「采用 / 不采用」;
- 关键日期卡:被更新、还没确认的日期显示「已被补遗 1 更新,待确认」;确认后显示新值并注「已按补遗 1 更新 · 原为 11月18日 09:30」(原型 `project.html`)。
- **读标页**:
- 来源筛选:全部 / 招标文件 / 补遗 1 / ……,每项带条数;
- 被更新的日期条目写「已被补遗 1 更新:新值 ……」,给「确认更新(删除旧条目)」和「保留两条」;
- 并入后顶部提示「补遗 1 新增 N 条。标博士不判断哪些旧条目被它改了或作废了,请核对:改了的旧条目可以修改或删除」,带「只看补遗 1」。
- **查标页**:检查预览里有没确认的新值时出上面那条提示。
## 5. 测试
- 虚构一份更正公告样例(`server/tests/fixtures/addendum-mini.pdf`,内容全虚构):改投标截止时间、开标时间,新增一条 ★ 参数,复述一条没改的日期。
- 集成测试:追加 → 解析 → 扣 1 次 → 新条目标「补遗 1」、清单回到草稿、截止时间进待确认、日期条目关联(复述的不关联);字段采用 / 不采用;采用截止时间时日期条目一起确认;日期条目采用(旧条目软删除、可以恢复)/ 保留两条;检查预览的提示;只读项目 409。
- 假模型能不能从样例里抽出日期要看它的规则;抽不出时测试里用接口建条目。浏览器里走一遍追加到确认的全程。
- 用真实模型对这份虚构样例跑一次解析看看抽取结果,花费几分钱(AD6)。
## 6. 工作量和顺序
1. 后端:并入时关联日期、清单回到草稿、两个确认接口、接口字段补齐、检查预览提示,以及测试样例和测试。约半天。
2. 前端:补遗模式的上传页、解析页的补遗结果、概览页的确认、读标页的来源筛选和日期确认。约半天。
## 7. 待确认事项
| 编号 | 事项 | 建议 |
|---|---|---|
| AD1 | 补遗的新条目进正式清单还是待确认区 | **按招标文件同一套规则进正式清单**(没出处、校验没过的照样进待确认区),标「补遗 N」,读标页给「只看补遗 N」和核对提示,清单回到草稿要求重新确认。新旧条目都在、都查,宁可多报,不漏报;全放待确认区的话,用户不逐条采纳就查不到补遗的新要求 |
| AD2 | 哪些条目自动标「已被补遗更新」 | **只做日期类**(同一种日期、时间不同),按 FR。原型里参数类「原为 480G → 现为 960G」的逐条对照是变更判断,P1;先等真实补遗样本看写法 |
| AD3 | 怎么确认 | **字段在概览页逐个「采用 / 不采用」,日期条目在读标页逐条确认**;采用投标截止时间时清单里的「投标截止」条目一起确认。采用时清掉人工值,以补遗为准 |
| AD4 | 没确认之前查标用哪个值 | **旧值**,确认后生效(数据模型原定);检查预览提示还有几处没确认,不阻止检查 |
| AD5 | 招标文件没解析完能不能追加补遗 | **不能**,界面上招标文件解析成功后才显示入口。没有招标文件时补遗的日期没有可关联的旧值 |
| AD6 | 用真实模型对虚构样例跑一次解析 | **跑一次**(DeepSeek,几分钱),看抽取对补遗写法(「原为……现更正为……」)的表现;结果只记录,这次不按它调提示词 |
## 8. 落地记录(2026-11-05)
- 后端:`checklist/addendum.py`(并入时关联日期条目、确认 / 保留两条、采用截止时间时一起确认「投标截止」条目);`project/edit.resolve_pending`(采用时补遗的值变成 AI 值、出处换成补遗那一句、清掉人工值);清单项接口补 `origin_document`、`superseded_by`,`origin_label` 对补遗来的条目写「补遗 N」;清单概况 `superseded_pending`;关键日期 `replaces`(「已按补遗 1 更新 · 原为……」);解析结果 `dates_superseded`、`fields_pending`;检查预览 `pending_updates`。
- **补遗不改项目是谁**:解析补遗时项目名称、编号、采购人、代理机构、法律体系不进待确认。测试里发现,更正公告的标题会被抽成「项目名称」,和招标文件的不同,每份补遗都会冒出一条没意义的待确认。
- **时间比较按北京时间补时区**:模型给的时间有时带时区、有时不带,补遗复述原时间时不能误判成「有新值」。
- ORM 给 JSONB 列写 `None` 存的是 JSON null,不是 SQL NULL,SQL 里数待确认值时要排除 JSON null。
- 前端:补遗模式的上传页(选补遗 / 澄清答复)、解析页的补遗结果和「核对补遗」、概览页「追加补遗 / 澄清」和字段「采用 / 不采用」、关键日期的待确认和「已按补遗 1 更新」、读标页的来源筛选、核对提示、「只看日期更新」和逐条确认、条目上的「补遗 1」标签、检查预览的提示。
- **AD6 结果**:用 DeepSeek Flash 对虚构的更正公告(推后递交截止时间、新增一条 ★ 参数)跑了一次解析(6 次调用):两处改动都抽对了,★ 参数归为技术参数 + 无效条款、新截止时间 12 月 8 日,出处都是「补遗 1 · 第 1 页」,没有多抽。只是一份很规整的虚构样例,「原为……现更正为……」那种写法这份样例里没有,真实补遗到了再看。
- 浏览器里走过:追加补遗 → 解析结果 → 概览采用新截止时间(项目头、要点、关键日期一起变,旧的「投标截止」条目自动删除)→ 读标页只看补遗 1。

View File

@@ -188,9 +188,9 @@
|---|---|---| |---|---|---|
| 登录与账号 | 发验证码、注册、登录(含挤下线确认)、退出、`GET /me` | 修改显示名、在线会话列表和下线、注销账号 | | 登录与账号 | 发验证码、注册、登录(含挤下线确认)、退出、`GET /me` | 修改显示名、在线会话列表和下线、注销账号 |
| 额度与套餐 | `GET /plans`、`GET /quota` | 额度流水、开通申请 | | 额度与套餐 | `GET /plans`、`GET /quota` | 额度流水、开通申请 |
| 项目 | 列表(不含筛选和分页)、新建、详情(含解析出的基本信息字段和关键日期)、状态变更、基本信息字段修改、分包的增删改、「不分包」和选包 | 补遗确认、删除 | | 项目 | 列表(不含筛选和分页)、新建、详情(含解析出的基本信息字段和关键日期)、状态变更、基本信息字段修改、补遗新值的采用 / 不采用、分包的增删改、「不分包」和选包 | 删除 |
| 上传与文档 | 分片上传全套、挂到项目、文档列表和详情、逐页摘要、一页的句子、签名下载、发起解析、解析进度、取消、人工建出处 | 删除文档 | | 上传与文档 | 分片上传全套、挂到项目、文档列表和详情、逐页摘要、一页的句子、签名下载、发起解析、解析进度、取消、人工建出处 | 删除文档 |
| 清单 | 清单概况(含确认人、删除数、按类别计数)、清单项列表(按区域、类别、所投的包筛选,可含已删除)、清单项详情(ETag)、修改(If-Match 选填)、采纳、删除恢复、人工新增、替换出处、修改记录、确认清单、版本 | 补遗更新确认(等补遗解析) | | 清单 | 清单概况(含确认人、删除数、按类别计数)、清单项列表(按区域、类别、所投的包筛选,可含已删除)、清单项详情(ETag)、修改(If-Match 选填)、采纳、删除恢复、人工新增、替换出处、修改记录、确认清单、版本、「已被补遗 N 更新」的确认 | — |
| 检查 | 检查预览、发起、进度、取消、检查记录、问题列表(含本次已解决)、每条清单项的检查结论(C9,2026-11-04 新增)、处理、撤销、批量处理(只限提示级);投标分册类型和版本、电子投标平台加密格式识别 | 需人工核对(第 2 步) | | 检查 | 检查预览、发起、进度、取消、检查记录、问题列表(含本次已解决)、每条清单项的检查结论(C9,2026-11-04 新增)、处理、撤销、批量处理(只限提示级);投标分册类型和版本、电子投标平台加密格式识别 | 需人工核对(第 2 步) |
| 报告、资质 | 报告:导出、生成进度、历次报告、签名下载(第 5 步);资质库:增删改、按类型 / 关键词筛选、附件(上传后挂上、签名下载、删除)、`late_for` 到期提醒(第 4 步) | — | | 报告、资质 | 报告:导出、生成进度、历次报告、签名下载(第 5 步);资质库:增删改、按类型 / 关键词筛选、附件(上传后挂上、签名下载、删除)、`late_for` 到期提醒(第 4 步) | — |
| 通知与事件 | 事件流(含检查进度)、通知列表、反馈 | 标为已读 | | 通知与事件 | 事件流(含检查进度)、通知列表、反馈 | 标为已读 |

View File

@@ -2260,6 +2260,13 @@ components:
kind: { type: string, enum: [question_deadline, bid_deadline, opening, deposit_deadline, validity, other] } kind: { type: string, enum: [question_deadline, bid_deadline, opening, deposit_deadline, validity, other] }
at: { $ref: '#/components/schemas/Timestamp' } at: { $ref: '#/components/schemas/Timestamp' }
edited: { type: boolean, description: 人工改过(投标截止时间按项目字段的人工值显示) } edited: { type: boolean, description: 人工改过(投标截止时间按项目字段的人工值显示) }
replaces:
type: [object, 'null']
description: 已按补遗确认更新:原来的时间和带来新值的文件(「已按补遗 1 更新 · 原为……」)
required: [at, document_label]
properties:
at: { type: string }
document_label: { type: string }
superseded_pending: { type: boolean, description: 补遗带来了新值、等用户确认 } superseded_pending: { type: boolean, description: 补遗带来了新值、等用户确认 }
latest_check_run_id: latest_check_run_id:
oneOf: oneOf:
@@ -2514,6 +2521,8 @@ components:
items_pending: { type: integer } items_pending: { type: integer }
items_absent: { type: integer, description: 重新解析时没再出现的旧条目 } items_absent: { type: integer, description: 重新解析时没再出现的旧条目 }
lots_found: { type: integer } lots_found: { type: integer }
dates_superseded: { type: integer, description: 补遗:更早文件里有几条日期条目被标「已被补遗 N 更新」(FR-M2-10) }
fields_pending: { type: integer, description: 补遗:项目字段里有几个新值等确认 }
verify_failures: verify_failures:
type: object type: object
description: 没通过校验的原因码和条数(如 quote_too_short、value_unverified),评测和排查用 description: 没通过校验的原因码和条数(如 quote_too_short、value_unverified),评测和排查用
@@ -2691,6 +2700,13 @@ components:
lot_no: { type: [string, 'null'], description: 空表示各包通用 } lot_no: { type: [string, 'null'], description: 空表示各包通用 }
origin: { $ref: '#/components/schemas/Origin' } origin: { $ref: '#/components/schemas/Origin' }
origin_label: { type: string, description: '如「AI」「人工添加」「补遗 1」' } origin_label: { type: string, description: '如「AI」「人工添加」「补遗 1」' }
origin_document:
type: [object, 'null']
description: 来自哪份招标侧文件(招标文件、补遗 N、澄清 N);人工添加的为 null
required: [id, label]
properties:
id: { $ref: '#/components/schemas/Id' }
label: { type: string }
zone: { $ref: '#/components/schemas/Zone' } zone: { $ref: '#/components/schemas/Zone' }
pending_reason: pending_reason:
oneOf: oneOf:
@@ -2772,6 +2788,7 @@ components:
description: 24 小时免扣窗口的结束时间(从上一次扣点的检查算起,P1) description: 24 小时免扣窗口的结束时间(从上一次扣点的检查算起,P1)
checklist_confirmed: { type: boolean, description: 为 false 时提示「清单未确认,检查结果以当前清单为准」 } checklist_confirmed: { type: boolean, description: 为 false 时提示「清单未确认,检查结果以当前清单为准」 }
pending_item_count: { type: integer, description: 待确认区条目不参与检查 } pending_item_count: { type: integer, description: 待确认区条目不参与检查 }
pending_updates: { type: integer, description: 补遗带来的新值、日期更新还没确认的处数;查标用旧值(AD4),只提示、不阻止 }
bid_documents: bid_documents:
type: array type: array
description: 默认用每个分册的最新版本 description: 默认用每个分册的最新版本

View File

@@ -4,6 +4,19 @@
--- ---
## AD1–AD6 · 2026-11-05 · 补遗追加:全部按建议
依据 `docs/architecture/addendum-plan.md` §7。
| # | 决策 | 理由 |
|---|---|---|
| AD1 | 补遗的新条目按招标文件同一套规则进正式清单(没出处、校验没过的进待确认区),标「补遗 N」;并入后清单回到草稿,要求重新确认 | 新旧条目都查,宁可多报,不漏报;全放待确认区的话,不逐条采纳就查不到补遗的新要求 |
| AD2 | 只对日期类条目自动标「已被补遗 N 更新」(同一种日期、时间不同);参数等条目的逐条对照是变更判断,P1 | 按 FR-M2-10;没有补遗样本,先看真实写法 |
| AD3 | 字段的新值在概览页逐个「采用 / 不采用」,日期条目在读标页逐条确认;采用投标截止时间时清单里的「投标截止」条目一起确认;采用时清掉人工值,以补遗为准 | 截止时间两处一次确认;补遗是更新的正式文件 |
| AD4 | 没确认之前查标用旧值,确认后生效;检查预览提示还有几处没确认,不阻止检查 | 数据模型原定「确认后生效」;不阻断用户(红线 4) |
| AD5 | 招标文件解析成功后才显示追加补遗的入口 | 没有招标文件时补遗的日期没有可关联的旧值 |
| AD6 | 用真实模型对虚构的更正公告样例跑一次解析,结果只记录,这次不调提示词 | 看抽取对「原为……现更正为……」写法的表现,花费几分钱 |
## PI1–PI7 · 2026-11-04 · 项目基本信息修改和分包选择:全部按建议 ## PI1–PI7 · 2026-11-04 · 项目基本信息修改和分包选择:全部按建议
依据 `docs/architecture/project-info-plan.md` §8。 依据 `docs/architecture/project-info-plan.md` §8。

View File

@@ -1634,6 +1634,11 @@ export interface components {
at: components["schemas"]["Timestamp"]; at: components["schemas"]["Timestamp"];
/** @description 人工改过(投标截止时间按项目字段的人工值显示) */ /** @description 人工改过(投标截止时间按项目字段的人工值显示) */
edited?: boolean; edited?: boolean;
/** @description 已按补遗确认更新:原来的时间和带来新值的文件(「已按补遗 1 更新 · 原为……」) */
replaces?: {
at: string;
document_label: string;
} | null;
/** @description 补遗带来了新值、等用户确认 */ /** @description 补遗带来了新值、等用户确认 */
superseded_pending?: boolean; superseded_pending?: boolean;
}[]; }[];
@@ -1813,6 +1818,10 @@ export interface components {
/** @description 重新解析时没再出现的旧条目 */ /** @description 重新解析时没再出现的旧条目 */
items_absent?: number; items_absent?: number;
lots_found?: number; lots_found?: number;
/** @description 补遗:更早文件里有几条日期条目被标「已被补遗 N 更新」(FR-M2-10) */
dates_superseded?: number;
/** @description 补遗:项目字段里有几个新值等确认 */
fields_pending?: number;
/** @description 没通过校验的原因码和条数(如 quote_too_short、value_unverified),评测和排查用 */ /** @description 没通过校验的原因码和条数(如 quote_too_short、value_unverified),评测和排查用 */
verify_failures?: { verify_failures?: {
[key: string]: number; [key: string]: number;
@@ -1947,6 +1956,11 @@ export interface components {
origin: components["schemas"]["Origin"]; origin: components["schemas"]["Origin"];
/** @description 如「AI」「人工添加」「补遗 1」 */ /** @description 如「AI」「人工添加」「补遗 1」 */
origin_label?: string; origin_label?: string;
/** @description 来自哪份招标侧文件(招标文件、补遗 N、澄清 N);人工添加的为 null */
origin_document?: {
id: components["schemas"]["Id"];
label: string;
} | null;
zone: components["schemas"]["Zone"]; zone: components["schemas"]["Zone"];
pending_reason?: components["schemas"]["PendingReason"] | null; pending_reason?: components["schemas"]["PendingReason"] | null;
/** @description 人工确认、无出处 */ /** @description 人工确认、无出处 */
@@ -2002,6 +2016,8 @@ export interface components {
checklist_confirmed: boolean; checklist_confirmed: boolean;
/** @description 待确认区条目不参与检查 */ /** @description 待确认区条目不参与检查 */
pending_item_count?: number; pending_item_count?: number;
/** @description 补遗带来的新值、日期更新还没确认的处数;查标用旧值(AD4),只提示、不阻止 */
pending_updates?: number;
/** @description 默认用每个分册的最新版本 */ /** @description 默认用每个分册的最新版本 */
bid_documents: components["schemas"]["Document"][]; bid_documents: components["schemas"]["Document"][];
lot_ids: components["schemas"]["Id"][]; lot_ids: components["schemas"]["Id"][];

View File

@@ -116,6 +116,9 @@ async def preview(s: AsyncSession, ctx: TenantContext, project_id: UUID) -> dict
"waive_until": mode.waive_until, "waive_until": mode.waive_until,
"checklist_confirmed": states.get(project_id) == "confirmed", "checklist_confirmed": states.get(project_id) == "confirmed",
"pending_item_count": (await checklist.pending_counts(s, [project_id])).get(project_id, 0), "pending_item_count": (await checklist.pending_counts(s, [project_id])).get(project_id, 0),
# 补遗带来的新值、日期更新还没确认:查标用旧值(AD4),预览里提示,不阻止检查
"pending_updates": await projects.pending_field_count(s, project_id)
+ await checklist.superseded_pending(s, project_id),
"bid_documents": [await documents.doc_to_api(s, d) for d in bids], "bid_documents": [await documents.doc_to_api(s, d) for d in bids],
"lot_ids": await projects.bidding_lot_ids(s, project_id), "lot_ids": await projects.bidding_lot_ids(s, project_id),
} }

View File

@@ -0,0 +1,141 @@
"""补遗追加(FR-M2-10,addendum-plan §2、§3):日期类条目的「已被补遗 N 更新」和确认。
- 只关联日期类(AD2):补遗里有具体时间的「日期与递交」条目,在更早的文件里找同一种日期、还在正式清单里的条目,
时间不同的把旧条目的 superseded_by_id 指向新条目,等用户确认;时间相同的是复述,不关联;
- 确认:采用 → 旧条目软删除(可以恢复),新条目保留;保留两条 → 解除关联,由用户自己处理;
- 参数等条目的「原为……现为……」逐条对照是变更判断,P1,这里不做。
"""
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
from uuid import UUID
from zoneinfo import ZoneInfo
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from bid_doctor.audit import service as audit
from bid_doctor.checklist import edit, service
from bid_doctor.checklist.models import ChecklistItem
from bid_doctor.core.db import TenantContext
from bid_doctor.core.errors import AppError
from bid_doctor.document.models import SourceDocument
DATE_KINDS = (
"bid_deadline",
"opening",
"question_deadline",
"deposit_deadline",
"validity",
) # 「其他日期」对不上,不关联
TENDER_ROLES = ("tender", "addendum", "clarification")
LOCAL_TZ = ZoneInfo("Asia/Shanghai")
def _when(item: ChecklistItem) -> tuple[str | None, str | None]:
sched = (item.spec or {}).get("schedule") or {}
return sched.get("date_kind"), sched.get("at")
def _same_time(a: str, b: str) -> bool:
"""同一时刻(不带时区的按北京时间算,模型有时给有时不给)。"""
try:
x, y = datetime.fromisoformat(a), datetime.fromisoformat(b)
except ValueError:
return a == b
return x.replace(tzinfo=x.tzinfo or LOCAL_TZ) == y.replace(tzinfo=y.tzinfo or LOCAL_TZ)
async def link_dates(s: AsyncSession, doc: SourceDocument) -> int:
"""补遗并入清单后:把更早文件里同一种日期、时间不同的旧条目标成「已被这份补遗更新」。返回关联了几条。"""
if doc.role not in ("addendum", "clarification"):
return 0
earlier = {
d.id
for d in (
await s.execute(
select(SourceDocument).where(
SourceDocument.project_id == doc.project_id,
SourceDocument.role.in_(TENDER_ROLES),
SourceDocument.id != doc.id,
SourceDocument.created_at < doc.created_at,
)
)
).scalars()
}
live = (
(
await s.execute(
select(ChecklistItem).where(
ChecklistItem.project_id == doc.project_id,
ChecklistItem.zone == "formal",
ChecklistItem.deleted_at.is_(None),
ChecklistItem.categories.contains(["schedule"]),
)
)
)
.scalars()
.all()
)
new = [i for i in live if i.origin_document_id == doc.id and _when(i)[0] in DATE_KINDS and _when(i)[1]]
old = [i for i in live if i.origin_document_id in earlier and i.superseded_by_id is None]
n = 0
for item in new:
kind, at = _when(item)
for o in old:
o_kind, o_at = _when(o)
if o_kind == kind and o_at and at and not _same_time(o_at, at):
o.superseded_by_id = item.id
n += 1
await s.flush()
return n
async def resolve(s: AsyncSession, ctx: TenantContext, item_id: UUID, accept: bool) -> dict[str, Any]:
"""对旧条目确认「已被补遗 N 更新」:采用时旧条目软删除,保留两条时解除关联(契约 resolveSupersession)。"""
item = await service.load(s, item_id)
await edit.editable(s, ctx, item.project_id) # 只读项目 409
if item.superseded_by_id is None or item.deleted_at is not None:
raise AppError("not_superseded")
new_id = item.superseded_by_id
if accept:
item.deleted_at, item.deleted_by = datetime.now(UTC), ctx.user_id
await edit.reopen(s, item.project_id)
else:
item.superseded_by_id = None
await audit.record(
s,
ctx,
"checklist_item.resolve_supersession",
"checklist_item",
item.id,
project_id=item.project_id,
after={"accept": accept, "superseded_by": str(new_id)},
)
await s.flush()
return await service.item(s, item.id)
async def accept_kind(s: AsyncSession, ctx: TenantContext, project_id: UUID, kind: str) -> int:
"""采用补遗的投标截止时间时,清单里同一种日期的更新一起确认(AD3)。返回确认了几条。"""
rows = (
(
await s.execute(
select(ChecklistItem).where(
ChecklistItem.project_id == project_id,
ChecklistItem.superseded_by_id.is_not(None),
ChecklistItem.deleted_at.is_(None),
)
)
)
.scalars()
.all()
)
n = 0
for r in rows:
if _when(r)[0] == kind:
await resolve(s, ctx, r.id, True)
n += 1
return n

View File

@@ -6,7 +6,7 @@ from uuid import UUID
from fastapi import APIRouter, Header, Response from fastapi import APIRouter, Header, Response
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
from bid_doctor.checklist import edit, response_mode, service from bid_doctor.checklist import addendum, edit, response_mode, service
from bid_doctor.core.db import tenant_tx from bid_doctor.core.db import tenant_tx
from bid_doctor.identity.deps import Ctx from bid_doctor.identity.deps import Ctx
from bid_doctor.project import service as projects from bid_doctor.project import service as projects
@@ -75,6 +75,17 @@ async def list_items(
return {"items": shown, "hidden_count": len(rows) - len(shown)} return {"items": shown, "hidden_count": len(rows) - len(shown)}
class SupersessionIn(BaseModel):
accept: bool
@router.post("/checklist-items/{item_id}/supersession")
async def resolve_supersession(item_id: UUID, body: SupersessionIn, ctx: Ctx) -> dict[str, Any]:
"""对旧条目确认「已被补遗 N 更新」:采用时旧条目软删除,保留两条时解除关联(FR-M2-10)。"""
async with tenant_tx(ctx) as s:
return await addendum.resolve(s, ctx, item_id, body.accept)
@router.post("/projects/{project_id}/checklist/items", status_code=201) @router.post("/projects/{project_id}/checklist/items", status_code=201)
async def create_item(project_id: UUID, body: ItemCreate, ctx: Ctx) -> dict[str, Any]: async def create_item(project_id: UUID, body: ItemCreate, ctx: Ctx) -> dict[str, Any]:
values = body.changes() values = body.changes()

View File

@@ -41,11 +41,11 @@ def _json(v: Any) -> Any:
return str(v) if isinstance(v, UUID) else v return str(v) if isinstance(v, UUID) else v
async def _editable(s: AsyncSession, ctx: TenantContext, project_id: UUID) -> None: async def editable(s: AsyncSession, ctx: TenantContext, project_id: UUID) -> None:
projects.ensure_editable(await projects.get(s, ctx, project_id)) projects.ensure_editable(await projects.get(s, ctx, project_id))
async def _reopen(s: AsyncSession, project_id: UUID) -> None: async def reopen(s: AsyncSession, project_id: UUID) -> None:
"""正式清单有变化:已确认的清单回到草稿(FR-M2-08)。""" """正式清单有变化:已确认的清单回到草稿(FR-M2-08)。"""
cl = (await s.execute(select(Checklist).where(Checklist.project_id == project_id))).scalar_one() cl = (await s.execute(select(Checklist).where(Checklist.project_id == project_id))).scalar_one()
if cl.status == "confirmed": if cl.status == "confirmed":
@@ -170,7 +170,7 @@ async def update(
s: AsyncSession, ctx: TenantContext, item_id: UUID, changes: dict[str, Any], if_match: str | None s: AsyncSession, ctx: TenantContext, item_id: UUID, changes: dict[str, Any], if_match: str | None
) -> dict[str, Any]: ) -> dict[str, Any]:
item = await service.load(s, item_id) item = await service.load(s, item_id)
await _editable(s, ctx, item.project_id) await editable(s, ctx, item.project_id)
if if_match and if_match.removeprefix("W/") != service.etag(item): if if_match and if_match.removeprefix("W/") != service.etag(item):
raise AppError("precondition_failed") raise AppError("precondition_failed")
if item.deleted_at is not None: if item.deleted_at is not None:
@@ -189,7 +189,7 @@ async def update(
after=after, after=after,
) )
if item.zone == "formal": if item.zone == "formal":
await _reopen(s, item.project_id) await reopen(s, item.project_id)
return await _done(s, item) return await _done(s, item)
@@ -203,7 +203,7 @@ async def accept(
) -> dict[str, Any]: ) -> dict[str, Any]:
"""从待确认区采纳(FR-M2-07):要么有出处,要么声明「人工确认、无出处」(红线 2);可以同时修改字段。""" """从待确认区采纳(FR-M2-07):要么有出处,要么声明「人工确认、无出处」(红线 2);可以同时修改字段。"""
item = await service.load(s, item_id) item = await service.load(s, item_id)
await _editable(s, ctx, item.project_id) await editable(s, ctx, item.project_id)
if item.zone != "pending" or item.deleted_at is not None: if item.zone != "pending" or item.deleted_at is not None:
raise AppError("item_not_pending") raise AppError("item_not_pending")
before, after = _apply(item, await _check(s, item.project_id, changes)) before, after = _apply(item, await _check(s, item.project_id, changes))
@@ -231,13 +231,13 @@ async def accept(
before=before, before=before,
after=after, after=after,
) )
await _reopen(s, item.project_id) await reopen(s, item.project_id)
return await _done(s, item) return await _done(s, item)
async def set_citations(s: AsyncSession, ctx: TenantContext, item_id: UUID, citation_ids: list[UUID]) -> dict[str, Any]: async def set_citations(s: AsyncSession, ctx: TenantContext, item_id: UUID, citation_ids: list[UUID]) -> dict[str, Any]:
item = await service.load(s, item_id) item = await service.load(s, item_id)
await _editable(s, ctx, item.project_id) await editable(s, ctx, item.project_id)
if not citation_ids and item.zone == "formal" and item.origin != "human" and item.no_citation_ack_by is None: if not citation_ids and item.zone == "formal" and item.origin != "human" and item.no_citation_ack_by is None:
raise AppError("citation_required") # 正式清单里的 AI 条目不能没有出处(红线 2) raise AppError("citation_required") # 正式清单里的 AI 条目不能没有出处(红线 2)
old = await _set_citations(s, item, citation_ids) old = await _set_citations(s, item, citation_ids)
@@ -255,14 +255,14 @@ async def set_citations(s: AsyncSession, ctx: TenantContext, item_id: UUID, cita
after={"citation_ids": [str(c) for c in dict.fromkeys(citation_ids)]}, after={"citation_ids": [str(c) for c in dict.fromkeys(citation_ids)]},
) )
if item.zone == "formal": if item.zone == "formal":
await _reopen(s, item.project_id) await reopen(s, item.project_id)
return await _done(s, item) return await _done(s, item)
async def remove(s: AsyncSession, ctx: TenantContext, item_id: UUID) -> None: async def remove(s: AsyncSession, ctx: TenantContext, item_id: UUID) -> None:
"""软删除;待确认区的条目删除就是「丢弃」。删除的条目可以恢复,重新解析也不会再冒出来。""" """软删除;待确认区的条目删除就是「丢弃」。删除的条目可以恢复,重新解析也不会再冒出来。"""
item = await service.load(s, item_id) item = await service.load(s, item_id)
await _editable(s, ctx, item.project_id) await editable(s, ctx, item.project_id)
if item.deleted_at is not None: if item.deleted_at is not None:
return return
item.deleted_at, item.deleted_by = datetime.now(UTC), ctx.user_id item.deleted_at, item.deleted_by = datetime.now(UTC), ctx.user_id
@@ -271,12 +271,12 @@ async def remove(s: AsyncSession, ctx: TenantContext, item_id: UUID) -> None:
s, ctx, action, "checklist_item", item.id, project_id=item.project_id, before={"zone": item.zone} s, ctx, action, "checklist_item", item.id, project_id=item.project_id, before={"zone": item.zone}
) )
if item.zone == "formal": if item.zone == "formal":
await _reopen(s, item.project_id) await reopen(s, item.project_id)
async def restore(s: AsyncSession, ctx: TenantContext, item_id: UUID) -> dict[str, Any]: async def restore(s: AsyncSession, ctx: TenantContext, item_id: UUID) -> dict[str, Any]:
item = await service.load(s, item_id) item = await service.load(s, item_id)
await _editable(s, ctx, item.project_id) await editable(s, ctx, item.project_id)
if item.deleted_at is not None: if item.deleted_at is not None:
item.deleted_at, item.deleted_by = None, None item.deleted_at, item.deleted_by = None, None
await audit.record( await audit.record(
@@ -289,7 +289,7 @@ async def restore(s: AsyncSession, ctx: TenantContext, item_id: UUID) -> dict[st
after={"zone": item.zone}, after={"zone": item.zone},
) )
if item.zone == "formal": if item.zone == "formal":
await _reopen(s, item.project_id) await reopen(s, item.project_id)
return await _done(s, item) return await _done(s, item)
@@ -297,7 +297,7 @@ async def create(
s: AsyncSession, ctx: TenantContext, project_id: UUID, values: dict[str, Any], citation_ids: list[UUID] s: AsyncSession, ctx: TenantContext, project_id: UUID, values: dict[str, Any], citation_ids: list[UUID]
) -> dict[str, Any]: ) -> dict[str, Any]:
"""人工新增(标注「人工添加」,可以没有出处),直接进正式清单。""" """人工新增(标注「人工添加」,可以没有出处),直接进正式清单。"""
await _editable(s, ctx, project_id) await editable(s, ctx, project_id)
cl = await service.get_or_create(s, ctx, project_id) cl = await service.get_or_create(s, ctx, project_id)
v = await _check(s, project_id, {"importance": "normal", "spec": {}, **values}) v = await _check(s, project_id, {"importance": "normal", "spec": {}, **values})
item = ChecklistItem( item = ChecklistItem(
@@ -327,7 +327,7 @@ async def create(
project_id=project_id, project_id=project_id,
after={**{k: _json(x) for k, x in v.items()}, "citation_ids": [str(c) for c in citation_ids]}, after={**{k: _json(x) for k, x in v.items()}, "citation_ids": [str(c) for c in citation_ids]},
) )
await _reopen(s, project_id) await reopen(s, project_id)
return await _done(s, item) return await _done(s, item)
@@ -446,7 +446,7 @@ async def version_numbers(s: AsyncSession, version_ids: list[UUID]) -> dict[UUID
async def confirm(s: AsyncSession, ctx: TenantContext, project_id: UUID) -> dict[str, Any]: async def confirm(s: AsyncSession, ctx: TenantContext, project_id: UUID) -> dict[str, Any]:
"""确认清单:把正式清单里未删除的条目存成不可变版本;内容和上一版一样就复用上一版(data-model §5)。 """确认清单:把正式清单里未删除的条目存成不可变版本;内容和上一版一样就复用上一版(data-model §5)。
待确认区的条目不进版本,也不参与检查。""" 待确认区的条目不进版本,也不参与检查。"""
await _editable(s, ctx, project_id) await editable(s, ctx, project_id)
cl = await service.get_or_create(s, ctx, project_id) cl = await service.get_or_create(s, ctx, project_id)
rows = await service.items(s, project_id, zone="formal") rows = await service.items(s, project_id, zone="formal")
if not rows: if not rows:

View File

@@ -140,6 +140,19 @@ async def merge(
return stats return stats
async def superseded_pending(s: AsyncSession, project_id: UUID) -> int:
"""「已被补遗 N 更新」还没确认的条数(FR-M2-10)。"""
return (
await s.execute(
select(func.count()).where(
ChecklistItem.project_id == project_id,
ChecklistItem.superseded_by_id.is_not(None),
ChecklistItem.deleted_at.is_(None),
)
)
).scalar_one()
async def overview(s: AsyncSession, project_id: UUID) -> dict[str, Any]: async def overview(s: AsyncSession, project_id: UUID) -> dict[str, Any]:
cl = (await s.execute(select(Checklist).where(Checklist.project_id == project_id))).scalar_one_or_none() cl = (await s.execute(select(Checklist).where(Checklist.project_id == project_id))).scalar_one_or_none()
zone_or_deleted = case((ChecklistItem.deleted_at.is_not(None), "deleted"), else_=ChecklistItem.zone) zone_or_deleted = case((ChecklistItem.deleted_at.is_not(None), "deleted"), else_=ChecklistItem.zone)
@@ -234,6 +247,7 @@ async def overview(s: AsyncSession, project_id: UUID) -> dict[str, Any]:
"formal": counts.get("formal", 0), "formal": counts.get("formal", 0),
"pending": counts.get("pending", 0), "pending": counts.get("pending", 0),
"deleted": counts.get("deleted", 0), "deleted": counts.get("deleted", 0),
"superseded_pending": await superseded_pending(s, project_id),
"by_category": by_category, "by_category": by_category,
}, },
"marking_rules": [ "marking_rules": [
@@ -284,7 +298,24 @@ async def items(
by_item.setdefault(item_id, []).append(citations.to_api(c, citations.document_label(role, seq))) by_item.setdefault(item_id, []).append(citations.to_api(c, citations.document_label(role, seq)))
names = await identity.display_names(s, [r.accepted_by for r in rows if r.accepted_by]) names = await identity.display_names(s, [r.accepted_by for r in rows if r.accepted_by])
lots = await projects.lot_labels(s, project_id) lots = await projects.lot_labels(s, project_id)
return [_item(r, by_item.get(r.id, []), names, lots) for r in rows] # 来源文件(补遗 N)和「已被补遗 N 更新」的新条目来自哪份补遗(FR-M2-10)
newer = dict(
(
await s.execute(
select(ChecklistItem.id, ChecklistItem.origin_document_id).where(
ChecklistItem.id.in_([r.superseded_by_id for r in rows if r.superseded_by_id])
)
)
)
.tuples()
.all()
)
doc_ids = {r.origin_document_id for r in rows if r.origin_document_id} | {d for d in newer.values() if d}
docs = {
d.id: citations.document_label(d.role, d.seq_no)
for d in (await s.execute(select(SourceDocument).where(SourceDocument.id.in_(doc_ids)))).scalars()
}
return [_item(r, by_item.get(r.id, []), names, lots, docs, newer) for r in rows]
async def item(s: AsyncSession, item_id: UUID) -> dict[str, Any]: async def item(s: AsyncSession, item_id: UUID) -> dict[str, Any]:
@@ -310,8 +341,21 @@ ORIGIN_LABEL = {"ai": "AI", "rule": "规则", "human": "人工添加"}
def _item( def _item(
r: ChecklistItem, cites: list[dict[str, Any]], names: dict[UUID, str], lots: dict[UUID, str | None] r: ChecklistItem,
cites: list[dict[str, Any]],
names: dict[UUID, str],
lots: dict[UUID, str | None],
docs: dict[UUID, str],
newer: dict[UUID, UUID | None],
) -> dict[str, Any]: ) -> dict[str, Any]:
source = docs.get(r.origin_document_id) if r.origin_document_id else None
from_addendum = source is not None and source != "招标文件" and r.origin != "human"
by = r.superseded_by_id
superseded = (
{"item_id": by, "document_label": docs.get(newer.get(by) or by, ""), "confirmed": r.deleted_at is not None}
if by
else None
)
return { return {
"id": r.id, "id": r.id,
"categories": r.categories, "categories": r.categories,
@@ -326,7 +370,8 @@ def _item(
"evidence_required": r.evidence_required, "evidence_required": r.evidence_required,
"spec": r.spec, "spec": r.spec,
"origin": r.origin, "origin": r.origin,
"origin_label": ORIGIN_LABEL[r.origin], "origin_label": source if from_addendum else ORIGIN_LABEL[r.origin],
"origin_document": {"id": r.origin_document_id, "label": source} if source else None,
"zone": r.zone, "zone": r.zone,
"pending_reason": r.pending_reason, "pending_reason": r.pending_reason,
"no_citation_ack": r.no_citation_ack_by is not None, "no_citation_ack": r.no_citation_ack_by is not None,
@@ -334,7 +379,7 @@ def _item(
"ai_values": r.ai_values, "ai_values": r.ai_values,
"edited_fields": r.edited_fields, "edited_fields": r.edited_fields,
"confidence": float(r.confidence) if r.confidence is not None else None, "confidence": float(r.confidence) if r.confidence is not None else None,
"superseded_by": None, "superseded_by": superseded,
"accepted_by": {"id": r.accepted_by, "name": names.get(r.accepted_by, "")} if r.accepted_by else None, "accepted_by": {"id": r.accepted_by, "name": names.get(r.accepted_by, "")} if r.accepted_by else None,
"deleted_at": r.deleted_at, "deleted_at": r.deleted_at,
"updated_at": r.updated_at, "updated_at": r.updated_at,
@@ -401,6 +446,29 @@ async def key_dates(s: AsyncSession, project_id: UUID) -> list[dict[str, Any]]:
.scalars() .scalars()
.all() .all()
) )
# 已确认的补遗更新:新条目注「已按补遗 N 更新 · 原为……」(原型 project.html 的关键日期卡)
replaced = {
o.superseded_by_id: (o.spec or {}).get("schedule", {}).get("at")
for o in (
await s.execute(
select(ChecklistItem).where(
ChecklistItem.project_id == project_id,
ChecklistItem.superseded_by_id.in_([r.id for r in rows]),
ChecklistItem.deleted_at.is_not(None),
)
)
).scalars()
}
docs = {
d.id: citations.document_label(d.role, d.seq_no)
for d in (
await s.execute(
select(SourceDocument).where(
SourceDocument.id.in_({r.origin_document_id for r in rows if r.origin_document_id})
)
)
).scalars()
}
out = [] out = []
for r in rows: for r in rows:
sched = (r.spec or {}).get("schedule") or {} sched = (r.spec or {}).get("schedule") or {}
@@ -412,6 +480,9 @@ async def key_dates(s: AsyncSession, project_id: UUID) -> list[dict[str, Any]]:
"kind": sched.get("date_kind", "other"), "kind": sched.get("date_kind", "other"),
"at": sched["at"], "at": sched["at"],
"superseded_pending": r.superseded_by_id is not None, "superseded_pending": r.superseded_by_id is not None,
"replaces": {"at": replaced[r.id], "document_label": docs.get(r.origin_document_id, "")}
if r.id in replaced and r.origin_document_id
else None,
} }
) )
return sorted(out, key=lambda d: d["at"]) return sorted(out, key=lambda d: d["at"])

View File

@@ -26,6 +26,8 @@ CODES: dict[str, tuple[int, str]] = {
"job_already_running": (409, "任务正在进行"), "job_already_running": (409, "任务正在进行"),
"job_finished": (409, "任务已经结束"), "job_finished": (409, "任务已经结束"),
"project_read_only": (409, "项目已递交或已归档,要先撤销才能修改"), "project_read_only": (409, "项目已递交或已归档,要先撤销才能修改"),
"not_superseded": (409, "这一条没有待确认的补遗更新"),
"no_pending_value": (409, "这个字段没有待确认的补遗新值"),
"document_role_not_parsable": (409, "这类文件不需要单独解析"), "document_role_not_parsable": (409, "这类文件不需要单独解析"),
"document_not_ready": (409, "文件还在处理"), "document_not_ready": (409, "文件还在处理"),
"upload_incomplete": (409, "文件还没有上传完"), "upload_incomplete": (409, "文件还没有上传完"),

View File

@@ -18,7 +18,7 @@ from sqlalchemy import select
from bid_doctor.ai.providers.base import TransientError from bid_doctor.ai.providers.base import TransientError
from bid_doctor.billing import service as billing from bid_doctor.billing import service as billing
from bid_doctor.checklist import extract, response_mode from bid_doctor.checklist import addendum, extract, response_mode
from bid_doctor.checklist import service as checklist from bid_doctor.checklist import service as checklist
from bid_doctor.core import events from bid_doctor.core import events
from bid_doctor.core.db import TenantContext, tenant_tx from bid_doctor.core.db import TenantContext, tenant_tx
@@ -140,8 +140,13 @@ async def _stages(ctx: TenantContext, job_id: UUID) -> dict[str, Any]:
stats = await checklist.merge(s, ctx, d, job_id, items, notes, lots) stats = await checklist.merge(s, ctx, d, job_id, items, notes, lots)
cl = await checklist.get_or_create(s, ctx, d.project_id) cl = await checklist.get_or_create(s, ctx, d.project_id)
await response_mode.save(s, ctx, cl, d, body) # 招标文件的响应方式(C12),清单页显示、可改 await response_mode.save(s, ctx, cl, d, body) # 招标文件的响应方式(C12),清单页显示、可改
# 补遗:同一种日期、时间不同的旧条目标「已被补遗 N 更新」;项目字段的新值已在 apply 里放进待确认(FR-M2-10)
superseded = await addendum.link_dates(s, d)
fields_pending = await projects.pending_field_count(s, d.project_id) if d.role != "tender" else 0
return { return {
**stats, **stats,
"dates_superseded": superseded,
"fields_pending": fields_pending,
"lots_found": len([k for k in lots if k != "默认"]), "lots_found": len([k for k in lots if k != "默认"]),
# 质量统计(评测和排查用):没通过校验的原因码条数、对半重跑次数、拆到底仍不完整的分块、项目信息各字段的结论 # 质量统计(评测和排查用):没通过校验的原因码条数、对半重跑次数、拆到底仍不完整的分块、项目信息各字段的结论
"verify_failures": {k.removeprefix("fail:"): n for k, n in run_stats.items() if k.startswith("fail:")}, "verify_failures": {k.removeprefix("fail:"): n for k, n in run_stats.items() if k.startswith("fail:")},

View File

@@ -7,6 +7,7 @@ from fastapi import APIRouter, Header, Query
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from sqlalchemy import select from sqlalchemy import select
from bid_doctor.checklist import addendum
from bid_doctor.core.db import tenant_tx from bid_doctor.core.db import tenant_tx
from bid_doctor.document.models import SourceDocument from bid_doctor.document.models import SourceDocument
from bid_doctor.identity.deps import Ctx from bid_doctor.identity.deps import Ctx
@@ -110,6 +111,20 @@ async def set_project_field(
) )
class PendingIn(BaseModel):
accept: bool
@router.post("/projects/{project_id}/fields/{field_key}/pending")
async def resolve_pending_field(project_id: UUID, field_key: FieldKey, body: PendingIn, ctx: Ctx) -> dict[str, Any]:
"""确认或驳回补遗带来的新值;采用投标截止时间时,清单里「投标截止」日期条目的更新一起确认(AD3)。"""
async with tenant_tx(ctx) as s:
await edit.resolve_pending(s, ctx, project_id, field_key, body.accept)
if body.accept and field_key == "deadline_at":
await addendum.accept_kind(s, ctx, project_id, "bid_deadline")
return next(f for f in await service.fields(s, project_id) if f["key"] == field_key)
@router.get("/projects/{project_id}/lots") @router.get("/projects/{project_id}/lots")
async def list_lots(project_id: UUID, ctx: Ctx) -> dict[str, Any]: async def list_lots(project_id: UUID, ctx: Ctx) -> dict[str, Any]:
async with tenant_tx(ctx) as s: async with tenant_tx(ctx) as s:

View File

@@ -122,6 +122,36 @@ async def set_field(s: AsyncSession, ctx: TenantContext, project_id: UUID, key:
) )
async def resolve_pending(s: AsyncSession, ctx: TenantContext, project_id: UUID, key: str, accept: bool) -> None:
"""补遗带来的新值(FR-M2-10):采用 → 变成 AI 值、出处换成补遗那一句、清掉人工值(以补遗为准,AD3);
不采用 → 丢掉待确认值,保持原值。都写审计。"""
require(ctx, Permission.PROJECT_EDIT)
p = await service.get(s, ctx, project_id)
service.ensure_editable(p)
f = (
await s.execute(select(ProjectField).where(ProjectField.project_id == p.id, ProjectField.field_key == key))
).scalar_one_or_none()
if f is None or f.pending_value is None:
raise AppError("no_pending_value")
before = {"value": f.human_value if f.human_value is not None else f.ai_value, "pending": f.pending_value}
if accept:
f.ai_value, f.ai_citation_id = f.pending_value, f.pending_citation_id
f.human_value, f.human_set_by, f.human_set_at = None, None, None
_sync(p, key, f.ai_value)
f.pending_value, f.pending_citation_id = None, None
await s.flush()
await audit.record(
s,
ctx,
"project_field.resolve_pending",
"project",
p.id,
project_id=p.id,
before={key: before},
after={key: {"accept": accept, "value": f.ai_value if accept else before["value"]}},
)
def _source(f: ProjectField) -> str: def _source(f: ProjectField) -> str:
return "human" if f.human_value is not None else "ai" if f.ai_value is not None else "none" return "human" if f.human_value is not None else "ai" if f.ai_value is not None else "none"

View File

@@ -25,6 +25,8 @@ from bid_doctor.project.models import Lot, Project, ProjectField
MONEY = ("budget_total", "ceiling_total") MONEY = ("budget_total", "ceiling_total")
COLUMNS = ("name", "code", "purchaser", "agency", "procurement_method", "legal_regime") COLUMNS = ("name", "code", "purchaser", "agency", "procurement_method", "legal_regime")
# 更正公告不改项目是谁:补遗、澄清里抽到的这几项(常把公告标题当成项目名称)不进待确认,免得一片噪音(FR-M2-10)
IDENTITY = ("name", "code", "purchaser", "agency", "legal_regime")
async def apply( async def apply(
@@ -49,6 +51,8 @@ async def apply(
if deadline: if deadline:
values["deadline_at"] = deadline values["deadline_at"] = deadline
for key, (value, sentence) in values.items(): for key, (value, sentence) in values.items():
if doc.role in ("addendum", "clarification") and key in IDENTITY:
continue
cite = None cite = None
if sentence is not None: if sentence is not None:
(c,) = await citations.from_sentences(s, ctx, doc, [sentence], "ai", ["id", "quote", "basis"]) (c,) = await citations.from_sentences(s, ctx, doc, [sentence], "ai", ["id", "quote", "basis"])

View File

@@ -125,6 +125,9 @@ async def fields(s: AsyncSession, project_id: UUID) -> list[dict[str, Any]]:
"updated_at": r.human_set_at if human else None, "updated_at": r.human_set_at if human else None,
"pending": { "pending": {
"value": r.pending_value, "value": r.pending_value,
"document_label": (cites.get(r.pending_citation_id) or {}).get("document_label", "补遗")
if r.pending_citation_id
else "补遗",
"citation": cites.get(r.pending_citation_id) if r.pending_citation_id else None, "citation": cites.get(r.pending_citation_id) if r.pending_citation_id else None,
} }
if r.pending_value is not None if r.pending_value is not None
@@ -134,6 +137,19 @@ async def fields(s: AsyncSession, project_id: UUID) -> list[dict[str, Any]]:
return out return out
async def pending_field_count(s: AsyncSession, project_id: UUID) -> int:
"""补遗带来的新值还没确认的字段数(FR-M2-10)。"""
return (
await s.execute(
select(func.count()).where(
ProjectField.project_id == project_id,
ProjectField.pending_value.is_not(None),
func.jsonb_typeof(ProjectField.pending_value) != "null", # ORM 写 None 存的是 JSON null
)
)
).scalar_one()
async def lot_labels(s: AsyncSession, project_id: UUID) -> dict[UUID, str | None]: async def lot_labels(s: AsyncSession, project_id: UUID) -> dict[UUID, str | None]:
"""项目的分包编号(给清单项展示「采购包 N」、校验 lot_id 用);默认包没有编号,表示各包通用。""" """项目的分包编号(给清单项展示「采购包 N」、校验 lot_id 用);默认包没有编号,表示各包通用。"""
rows = await s.execute(select(Lot.id, Lot.lot_no, Lot.is_default).where(Lot.project_id == project_id)) rows = await s.execute(select(Lot.id, Lot.lot_no, Lot.is_default).where(Lot.project_id == project_id))

View File

@@ -48,7 +48,17 @@ def _key_dates(dates: list[dict[str, Any]], fields: list[dict[str, Any]]) -> lis
if f is None: if f is None:
return dates return dates
rest = [d for d in dates if d.get("kind") != "bid_deadline"] rest = [d for d in dates if d.get("kind") != "bid_deadline"]
edited = {"item_id": None, "label": "投标截止", "kind": "bid_deadline", "at": f["value"], "edited": True} pending = any(d.get("superseded_pending") for d in dates if d.get("kind") == "bid_deadline") or bool(
f.get("pending")
)
edited = {
"item_id": None,
"label": "投标截止",
"kind": "bid_deadline",
"at": f["value"],
"edited": True,
"superseded_pending": pending,
}
return sorted([*rest, edited], key=lambda d: str(d["at"])) return sorted([*rest, edited], key=lambda d: str(d["at"]))

BIN
server/tests/fixtures/addendum-mini.pdf vendored Normal file

Binary file not shown.

View File

@@ -6,7 +6,8 @@
- bid-mini-v2.pdf:补上了交换机硬件 BFD → 复查时这个问题「本次已解决」; - bid-mini-v2.pdf:补上了交换机硬件 BFD → 复查时这个问题「本次已解决」;
- bid-mini-forms.pdf:v2 的内容 + 格式文件里的规则问题(大写和小写金额不一致、授权书没填投标人名称、 - bid-mini-forms.pdf:v2 的内容 + 格式文件里的规则问题(大写和小写金额不一致、授权书没填投标人名称、
报价附加条件、另外给了一个优惠报价),措辞故意和评测集的开发词库不同; 报价附加条件、另外给了一个优惠报价),措辞故意和评测集的开发词库不同;
- bid-mini-quals.pdf:v2 的内容 + 企业资质情况表(证书、编号、有效期,全部虚构),资质匹配的端到端测试用。 - bid-mini-quals.pdf:v2 的内容 + 企业资质情况表(证书、编号、有效期,全部虚构),资质匹配的端到端测试用;
- addendum-mini.pdf:tender-full.pdf 的更正公告(补遗,FR-M2-10):递交截止时间推后一周、新增一条 ★ 参数。
""" """
from __future__ import annotations from __future__ import annotations
@@ -62,8 +63,18 @@ QUALS = [
"</table>", "</table>",
] ]
ADDENDUM = [
"<h1>关于某市智慧政务系统集成项目的更正公告(测试样例,内容虚构)</h1>",
"<p>各潜在供应商:本项目采购文件更正如下,其他内容不变。</p>",
"<p>一、响应文件递交截止时间更正为:2026年12月8日9时30分(北京时间)。</p>",
"<p>二、★新增技术要求:存储系统可用容量不低于100TB,并提供产品彩页作为证明材料。</p>",
"<p>本更正公告是采购文件的组成部分,与采购文件具有同等效力。</p>",
"</body></html>",
]
if __name__ == "__main__": if __name__ == "__main__":
render("bid-mini-v1.pdf", [*COMMON, *TAIL]) render("bid-mini-v1.pdf", [*COMMON, *TAIL])
render("bid-mini-v2.pdf", [*COMMON, BFD, *TAIL]) render("bid-mini-v2.pdf", [*COMMON, BFD, *TAIL])
render("bid-mini-forms.pdf", [*COMMON, BFD, *FORMS, *TAIL]) render("bid-mini-forms.pdf", [*COMMON, BFD, *FORMS, *TAIL])
render("bid-mini-quals.pdf", [*COMMON, BFD, *QUALS, *TAIL]) render("bid-mini-quals.pdf", [*COMMON, BFD, *QUALS, *TAIL])
render("addendum-mini.pdf", ADDENDUM)

View File

@@ -0,0 +1,109 @@
"""补遗追加(FR-M2-10,addendum-plan):追加 → 解析扣 1 次 → 新条目标「补遗 1」、清单回到草稿、
截止时间进待确认、日期条目标「已被补遗 1 更新」;采用 / 不采用;采用截止时间时日期条目一起确认;检查预览提示。"""
from __future__ import annotations
from typing import Any
from tests.conftest import FIXTURES, User, balance, project_with_tender, run_parse, upload
async def add_addendum(u: User, project_id: str) -> tuple[str, dict[str, Any]]:
done = await upload(u, "关于某市智慧政务系统集成项目的更正公告.pdf", (FIXTURES / "addendum-mini.pdf").read_bytes())
assert done.status_code == 200, done.text
r = await u.post(f"/projects/{project_id}/documents", json={"upload_id": done.json()["id"], "role": "addendum"})
assert r.status_code == 201, r.text
doc = r.json()
assert doc["label"] == "补遗 1"
job = await run_parse(u, doc["id"])
assert job["status"] == "succeeded", job
return doc["id"], job
def deadline_items(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
return [i for i in items if ((i.get("spec") or {}).get("schedule") or {}).get("date_kind") == "bid_deadline"]
async def parsed_project(u: User) -> str:
project_id, tender_id = await project_with_tender(u, "tender-full.pdf")
assert (await run_parse(u, tender_id))["status"] == "succeeded"
return project_id
async def test_addendum_merge_and_accept(user: User) -> None:
project_id = await parsed_project(user)
before = (await user.get(f"/projects/{project_id}")).json()
assert before["deadline_at"].startswith("2026-12-01T01:30")
await user.put(f"/projects/{project_id}/fields/deadline_at", json={"value": "2026-12-02T09:30:00+08:00"})
assert (await user.post(f"/projects/{project_id}/checklist/confirm")).status_code == 200
quota = await balance(user)
_, job = await add_addendum(user, project_id)
result = job["result"]
assert result["dates_superseded"] == 1 and result["fields_pending"] >= 1 and result["items_added"] >= 2
assert await balance(user) == quota - 1 # 补遗单独扣 1 次解析
overview = (await user.get(f"/projects/{project_id}/checklist")).json()
assert overview["status"] == "draft" and overview["counts"]["superseded_pending"] == 1 # 并入后回到草稿(AD1)
items = (await user.get(f"/projects/{project_id}/checklist/items")).json()["items"]
new_items = [i for i in items if i["origin_label"] == "补遗 1"]
assert any("存储系统" in i["title"] for i in new_items) # 新条目直接进正式清单
assert all(i["origin_document"]["label"] == "补遗 1" for i in new_items)
old, new = sorted(deadline_items(items), key=lambda i: i["origin_label"] != "补遗 1")[::-1]
assert old["superseded_by"] == {"item_id": new["id"], "document_label": "补遗 1", "confirmed": False}
assert new["superseded_by"] is None
# 没确认之前项目用旧值(AD4),概览和检查预览都能看到待确认
detail = (await user.get(f"/projects/{project_id}")).json()
assert detail["deadline_at"].startswith("2026-12-02T01:30")
(f,) = [x for x in detail["fields"] if x["key"] == "deadline_at"]
assert f["source"] == "human" and f["pending"]["value"].startswith("2026-12-08T09:30")
assert f["pending"]["document_label"] == "补遗 1"
assert any(d["superseded_pending"] for d in detail["key_dates"])
preview = (await user.get(f"/projects/{project_id}/check-preview")).json()
assert preview["pending_updates"] >= 2
# 采用截止时间:清掉人工值、以补遗为准;清单里「投标截止」条目的更新一起确认(AD3)
r = await user.post(f"/projects/{project_id}/fields/deadline_at/pending", json={"accept": True})
assert r.status_code == 200, r.text
assert r.json()["source"] == "ai" and r.json()["pending"] is None and r.json()["value"].startswith("2026-12-08")
detail = (await user.get(f"/projects/{project_id}")).json()
assert detail["deadline_at"].startswith("2026-12-08T01:30")
(bid,) = [d for d in detail["key_dates"] if d["label"] == "投标截止"]
assert bid["replaces"]["document_label"] == "补遗 1" and bid["replaces"]["at"].startswith("2026-12-01")
old_now = (await user.get(f"/checklist-items/{old['id']}")).json()
assert old_now["deleted_at"] and old_now["superseded_by"]["confirmed"]
assert (await user.get(f"/projects/{project_id}/checklist")).json()["counts"]["superseded_pending"] == 0
r = await user.post(f"/projects/{project_id}/fields/deadline_at/pending", json={"accept": True})
assert r.status_code == 409 and r.json()["code"] == "no_pending_value"
async def test_addendum_reject_keeps_both(user: User) -> None:
project_id = await parsed_project(user)
await add_addendum(user, project_id)
items = (await user.get(f"/projects/{project_id}/checklist/items")).json()["items"]
(old,) = [i for i in deadline_items(items) if i["superseded_by"]]
r = await user.post(f"/checklist-items/{old['id']}/supersession", json={"accept": False})
assert r.status_code == 200 and r.json()["superseded_by"] is None and r.json()["deleted_at"] is None
assert len(deadline_items((await user.get(f"/projects/{project_id}/checklist/items")).json()["items"])) == 2
r = await user.post(f"/checklist-items/{old['id']}/supersession", json={"accept": True})
assert r.status_code == 409 and r.json()["code"] == "not_superseded"
r = await user.post(f"/projects/{project_id}/fields/deadline_at/pending", json={"accept": False})
assert r.json()["pending"] is None and r.json()["value"].startswith("2026-12-01")
assert (await user.get(f"/projects/{project_id}")).json()["deadline_at"].startswith("2026-12-01T01:30")
assert (await user.get(f"/projects/{project_id}/check-preview")).json()["pending_updates"] == 0
# 已递交的项目只读
assert (await user.post(f"/projects/{project_id}/transitions", json={"action": "submit"})).status_code == 200
r = await user.post(f"/checklist-items/{old['id']}/supersession", json={"accept": True})
assert r.status_code == 409 and r.json()["code"] == "project_read_only"
def test_same_time_ignores_missing_timezone() -> None:
"""补遗只是复述原时间时不关联;模型给的时间有时带时区、有时不带,按北京时间比。"""
from bid_doctor.checklist.addendum import _same_time # pyright: ignore[reportPrivateUsage]
assert _same_time("2026-12-01T09:30", "2026-12-01T09:30:00+08:00")
assert not _same_time("2026-12-01T09:30", "2026-12-08T09:30:00+08:00")