- 招标文件解析后可以追加补遗 / 澄清:单独解析、扣 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>
110 lines
6.5 KiB
Python
110 lines
6.5 KiB
Python
"""补遗追加(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")
|