Files
bid-doctor/server/tests/test_addendum_flow.py
YANG JIANKUAN 6e0d4c1dd6
Some checks are pending
ci / server (push) Waiting to run
ci / web (push) Waiting to run
ci / contract (push) Waiting to run
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>
2026-11-05 00:27:21 +08:00

110 lines
6.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""补遗追加(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")