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

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

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

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

Binary file not shown.

View File

@@ -6,7 +6,8 @@
- bid-mini-v2.pdf:补上了交换机硬件 BFD → 复查时这个问题「本次已解决」;
- bid-mini-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
@@ -62,8 +63,18 @@ QUALS = [
"</table>",
]
ADDENDUM = [
"<h1>关于某市智慧政务系统集成项目的更正公告(测试样例,内容虚构)</h1>",
"<p>各潜在供应商:本项目采购文件更正如下,其他内容不变。</p>",
"<p>一、响应文件递交截止时间更正为:2026年12月8日9时30分(北京时间)。</p>",
"<p>二、★新增技术要求:存储系统可用容量不低于100TB,并提供产品彩页作为证明材料。</p>",
"<p>本更正公告是采购文件的组成部分,与采购文件具有同等效力。</p>",
"</body></html>",
]
if __name__ == "__main__":
render("bid-mini-v1.pdf", [*COMMON, *TAIL])
render("bid-mini-v2.pdf", [*COMMON, BFD, *TAIL])
render("bid-mini-forms.pdf", [*COMMON, BFD, *FORMS, *TAIL])
render("bid-mini-quals.pdf", [*COMMON, BFD, *QUALS, *TAIL])
render("addendum-mini.pdf", ADDENDUM)

View File

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