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:
@@ -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(额度流水、套餐开通、数据删除、运营后台)。
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
98
docs/architecture/addendum-plan.md
Normal file
98
docs/architecture/addendum-plan.md
Normal 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。
|
||||||
@@ -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 步) | — |
|
||||||
| 通知与事件 | 事件流(含检查进度)、通知列表、反馈 | 标为已读 |
|
| 通知与事件 | 事件流(含检查进度)、通知列表、反馈 | 标为已读 |
|
||||||
|
|||||||
@@ -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: 默认用每个分册的最新版本
|
||||||
|
|||||||
@@ -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。
|
||||||
|
|||||||
16
packages/api-client/src/schema.d.ts
vendored
16
packages/api-client/src/schema.d.ts
vendored
@@ -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"][];
|
||||||
|
|||||||
@@ -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),
|
||||||
}
|
}
|
||||||
|
|||||||
141
server/src/bid_doctor/checklist/addendum.py
Normal file
141
server/src/bid_doctor/checklist/addendum.py
Normal 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
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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"])
|
||||||
|
|||||||
@@ -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, "文件还没有上传完"),
|
||||||
|
|||||||
@@ -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:")},
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -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"])
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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
BIN
server/tests/fixtures/addendum-mini.pdf
vendored
Normal file
Binary file not shown.
13
server/tests/fixtures/make_bid_fixtures.py
vendored
13
server/tests/fixtures/make_bid_fixtures.py
vendored
@@ -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)
|
||||||
|
|||||||
109
server/tests/test_addendum_flow.py
Normal file
109
server/tests/test_addendum_flow.py
Normal 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")
|
||||||
Reference in New Issue
Block a user