feat(project): 项目基本信息修改和分包选择(FR-M2-03、04)
Some checks failed
ci / server (push) Has been cancelled
ci / web (push) Has been cancelled
ci / contract (push) Has been cancelled

- 字段修改:人工值优先、撤销恢复 AI 值、按字段校验(金额可写「286万」,按元存)、
  冗余列同步、审计留痕、只读项目 409;关键日期显示改后的投标截止时间
- 分包:增删改、选包(至少一个)、一键「不分包」;删包时条目归为通用,删到一个不剩回到不分包
- 清单列表 lot_scope 默认只列通用 + 所投的包,响应带 hidden_count
- 检查记下当时用的项目依据(截止时间、限价、所投的包、法律体系):报告按它写,
  之后改了信息检查结果接口给 basis_changed,查标页提示重新检查
- 前端:概览标签页(项目要点、采购包、关键日期)、字段修改对话框、选包页和手工调整;
  解析完识别到多个包时进选包页;读标页清单范围和所属采购包;查标页写明查哪几个包
- 决策 PI1–PI7;方案和落地记录 docs/architecture/project-info-plan.md

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
YANG JIANKUAN
2026-11-04 23:46:07 +08:00
parent 08e3fe7eff
commit ed6a2a907d
29 changed files with 1834 additions and 43 deletions

View File

@@ -0,0 +1,160 @@
"""项目基本信息修改和分包(FR-M2-03、04,project-info-plan):人工值优先、撤销恢复 AI 值、校验、只读、留痕;
分包增删改、默认包建回、「不分包」、删包时条目归通用、选包;清单按所投的包筛选;检查记下当时的项目依据(PI5)。"""
from __future__ import annotations
from typing import Any
from uuid import UUID
from sqlalchemy import select
from tests.conftest import User, project_with_tender, run_parse
from tests.test_check_flow import run_check, upload_bid
async def audit_actions(u: User) -> list[str]:
from bid_doctor.audit.models import AuditEvent
from bid_doctor.core.db import TenantContext, tenant_tx
ctx = TenantContext(tenant_id=UUID(u.tenant_id), user_id=UUID(int=0))
async with tenant_tx(ctx) as s:
return list((await s.execute(select(AuditEvent.action).order_by(AuditEvent.created_at))).scalars())
def field(detail: dict[str, Any], key: str) -> dict[str, Any] | None:
return next((f for f in detail["fields"] if f["key"] == key), None)
async def test_fields_human_value_reset_and_validation(user: User) -> None:
project_id, tender_id = await project_with_tender(user)
assert (await run_parse(user, tender_id))["status"] == "succeeded"
before = (await user.get(f"/projects/{project_id}")).json()
r = await user.put(f"/projects/{project_id}/fields/deadline_at", json={"value": "2026-12-01T09:30:00+08:00"})
assert r.status_code == 200, r.text
f = r.json()
assert f["source"] == "human" and f["value"] == "2026-12-01T09:30:00+08:00" and f["updated_by"]["name"] == "张工"
detail = (await user.get(f"/projects/{project_id}")).json()
assert detail["deadline_at"].startswith("2026-12-01T01:30") # project 表上冗余的列跟着改
(bid,) = [d for d in detail["key_dates"] if d["label"] == "投标截止"]
assert bid["edited"] and bid["item_id"] is None and bid["at"] == "2026-12-01T09:30:00+08:00"
r = await user.put(f"/projects/{project_id}/fields/ceiling_total", json={"value": "286万"})
assert r.json()["value"] == "2860000.00" # PI7:按元存
r = await user.put(f"/projects/{project_id}/fields/accept_consortium", json={"value": False})
assert r.json() == {**r.json(), "value": False, "source": "human"}
r = await user.put(f"/projects/{project_id}/fields/name", json={"value": " 某市政务云扩容项目 "})
assert (await user.get(f"/projects/{project_id}")).json()["name"] == "某市政务云扩容项目"
for key, value in (
("name", " "),
("ceiling_total", "-1"),
("ceiling_total", "两百万"),
("deadline_at", "明天"),
("legal_regime", "civil"),
("accept_imported", "是"),
):
r = await user.put(f"/projects/{project_id}/fields/{key}", json={"value": value})
assert r.status_code == 422, (key, value, r.text)
# 撤销人工值:有 AI 值的恢复 AI 值,冗余的列也恢复;名称没有 AI 值时保持现在的名称
await user.put(f"/projects/{project_id}/fields/legal_regime", json={"value": "gov_procurement"})
assert (await user.get(f"/projects/{project_id}")).json()["legal_regime"] == "gov_procurement"
r = await user.put(f"/projects/{project_id}/fields/legal_regime", json={"value": None})
assert r.json()["source"] == "ai" and r.json()["value"] == "tendering"
assert (await user.get(f"/projects/{project_id}")).json()["legal_regime"] == "tendering"
r = await user.put(f"/projects/{project_id}/fields/name", json={"value": None})
assert r.json()["source"] == "none"
assert (await user.get(f"/projects/{project_id}")).json()["name"] == "某市政务云扩容项目"
r = await user.put(f"/projects/{project_id}/fields/deadline_at", json={"value": None})
after = (await user.get(f"/projects/{project_id}")).json()
assert after["deadline_at"] == before["deadline_at"]
assert not any(d.get("edited") for d in after["key_dates"])
assert (await audit_actions(user)).count("project_field.set") == 8
# 已递交的项目只读
assert (await user.post(f"/projects/{project_id}/transitions", json={"action": "submit"})).status_code == 200
r = await user.put(f"/projects/{project_id}/fields/code", json={"value": "X-1"})
assert r.status_code == 409 and r.json()["code"] == "project_read_only"
async def test_lots_default_clear_and_checklist_scope(user: User) -> None:
project_id, tender_id = await project_with_tender(user)
await run_parse(user, tender_id)
(default,) = (await user.get(f"/projects/{project_id}/lots")).json()["items"]
assert default["is_default"]
r = await user.post(
f"/projects/{project_id}/lots", json={"lot_no": "1", "name": "核心网络", "ceiling_amount": "120万"}
)
assert r.status_code == 201, r.text
lot1 = r.json()
assert lot1["ceiling_amount"] == "1200000.00" and lot1["origin"] == "human"
lot2 = (await user.post(f"/projects/{project_id}/lots", json={"lot_no": "2", "name": "机房配套"})).json()
lots = (await user.get(f"/projects/{project_id}/lots")).json()["items"]
assert [x["lot_no"] for x in lots] == ["1", "2"] # 手工分包:默认包不要了
assert (await user.post(f"/projects/{project_id}/lots", json={"lot_no": "1"})).status_code == 422
assert (await user.patch(f"/lots/{lot2['id']}", json={"lot_no": "1"})).status_code == 422
r = await user.patch(f"/lots/{lot2['id']}", json={"budget_amount": "80,000"})
assert r.json()["budget_amount"] == "80000.00"
# 条目归属:包 2 的条目不在所投的包里时不列出
created = []
for title, lot in (("包1:交换机须支持硬件BFD", lot1["id"]), ("包2:精密空调制冷量不低于40kW", lot2["id"])):
r = await user.post(
f"/projects/{project_id}/checklist/items",
json={"categories": ["tech_param"], "title": title, "lot_id": lot},
)
assert r.status_code == 201, r.text
created.append(r.json()["id"])
everything = (await user.get(f"/projects/{project_id}/checklist/items", params={"lot_scope": "all"})).json()
assert (await user.put(f"/projects/{project_id}/bidding-lots", json={"lot_ids": []})).status_code == 422
assert (
await user.put(f"/projects/{project_id}/bidding-lots", json={"lot_ids": [default["id"]]})
).status_code == 422
r = await user.put(f"/projects/{project_id}/bidding-lots", json={"lot_ids": [lot1["id"]]})
assert [x["is_bidding"] for x in r.json()["items"]] == [True, False]
shown = (await user.get(f"/projects/{project_id}/checklist/items")).json()
assert shown["hidden_count"] == 1 and len(shown["items"]) == len(everything["items"]) - 1
assert created[1] not in {i["id"] for i in shown["items"]}
assert (await user.get(f"/projects/{project_id}")).json()["bidding_lots"] == ["1"]
# 删包:原属这个包的条目归为通用(PI3)
assert (await user.delete(f"/lots/{lot2['id']}")).status_code == 204
item = (await user.get(f"/checklist-items/{created[1]}")).json()
assert item["lot_id"] is None
# 删掉最后一个包:回到不分包(PI2),条目都列出
assert (await user.delete(f"/lots/{lot1['id']}")).status_code == 204
(back,) = (await user.get(f"/projects/{project_id}/lots")).json()["items"]
assert back["is_default"]
assert (await user.get(f"/projects/{project_id}/checklist/items")).json()["hidden_count"] == 0
# 一键「不分包」
for no in ("A", "B", "C"):
await user.post(f"/projects/{project_id}/lots", json={"lot_no": no})
r = await user.post(f"/projects/{project_id}/lots/clear")
assert [x["is_default"] for x in r.json()["items"]] == [True]
actions = await audit_actions(user)
assert {"lot.create", "lot.update", "lot.delete", "lot.clear", "project.bidding_lots"} <= set(actions)
async def test_check_records_basis_and_flags_changes(user: User) -> None:
project_id, tender_id = await project_with_tender(user)
await run_parse(user, tender_id)
await user.put(f"/projects/{project_id}/fields/deadline_at", json={"value": "2026-12-01T09:30:00+08:00"})
bid = await upload_bid(user, project_id, "bid-mini-v1.pdf")
run = await run_check(user, project_id, [bid["id"]])
basis = run["coverage"]["basis"]
assert basis["deadline_at"] == {"value": "2026-12-01T01:30:00+00:00", "source": "human"}
assert basis["lots"] == [] and not run["basis_changed"]
await user.put(f"/projects/{project_id}/fields/deadline_at", json={"value": "2026-12-08T09:30:00+08:00"})
assert (await user.get(f"/check-runs/{run['id']}")).json()["basis_changed"]
# 报告按检查时的截止时间写
from bid_doctor.report.service import _deadline # pyright: ignore[reportPrivateUsage]
class Run:
coverage = run["coverage"]
assert _deadline(Run()).isoformat() == "2026-12-01T01:30:00+00:00" # type: ignore[arg-type]