Files
bid-doctor/server/tests/test_check_flow.py
YANG JIANKUAN e499de7486 feat: 查标第 1 步——上传投标分册 → 检查 → 逐条处理 → 复查沿用
后端
- check 模块:检查预览、发起、进度、取消、记录;六个阶段(准备投标文件、响应匹配、规则、证据校验、
  状态沿用、汇总);问题列表、处理、撤销、批量处理(只限提示级)。
- 每条清单项的检查结论 check_item_result(C9,迁移 0005):「已响应」也带投标原文出处;
  没有校验通过的证据不能判已响应(C3)。级别从清单项算,不让模型定;说明不写整改建议。
- 响应匹配先只有第三层:内存 BM25 检索候选句 + check.match v1 提示词,检索不到不调模型,
  漏答记 missing_answer,模型临时错误只影响那一批。
- 计费:免费版不记流水;专业版 24 小时内复查免扣(记一行 0);expected_charge 不一致时拒绝不扣点;
  失败、取消退回;投标文件读不出文字按输入问题失败、不扣点。
- 投标分册:类型(volume_kind)和版本;电子投标平台加密格式在上传时拦下。
- 解析流水线前几个阶段拆到 document/pipeline/normalize.py,解析和检查共用。
- ops 模块:反馈接口;致命问题确认无误的说明自动进误报反馈(U4)。
- SK1:Valkey 客户端短超时、SSE 连接池上限。SK2:CI 加 Gotenberg、直接跑 init-roles.sh。

前端
- 上传投标文件并检查页、查标页(进度、覆盖范围、按级别筛选、逐条处理和行内说明、撤销、原文抽屉、
  已处理、本次已解决);项目头抽成公共组件,加「查标」标签页;工作台「下一步」覆盖查标阶段。

契约、文档:openapi.yaml 加 item-results、volume_kind、coverage 字段;data-model、api-design §10、
架构 §8 / §13(SK14)、AGENTS.md 同步。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 11:48:10 +08:00

254 lines
13 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.

"""查标第 1 步(m3-plan §5):上传投标文件 → 检查 → 处理问题 → 复查后状态沿用;以及检查的计费(红线 9)。
假模型按字面重合判断有没有响应,结论没有参考价值;这里验证的是链路、证据、状态沿用和计费。
"""
from __future__ import annotations
from typing import Any
from uuid import UUID
import httpx
from sqlalchemy import text
from tests.conftest import FIXTURES, User, balance, project_with_tender, register, run_parse, upload
async def upload_bid(u: User, project_id: str, fixture: str, replaces: str | None = None) -> dict[str, Any]:
done = await upload(u, fixture, (FIXTURES / fixture).read_bytes())
assert done.status_code == 200, done.text
body: dict[str, Any] = {"upload_id": done.json()["id"], "role": "bid_volume", "volume_kind": "technical"}
if replaces:
body["replaces_document_id"] = replaces
r = await u.post(f"/projects/{project_id}/documents", json=body)
assert r.status_code == 201, r.text
return r.json()
async def run_check(u: User, project_id: str, doc_ids: list[str], expected: int = 0) -> dict[str, Any]:
"""发起检查,并在测试进程里直接跑流水线(不经过 worker)。"""
from bid_doctor.check import runner
r = await u.post(f"/projects/{project_id}/check-runs", json={"document_ids": doc_ids, "expected_charge": expected})
assert r.status_code == 201, r.text
await runner.run(UUID(u.tenant_id), UUID(r.json()["id"]))
return (await u.get(f"/check-runs/{r.json()['id']}")).json()
async def checked_project(u: User) -> tuple[str, dict[str, Any], dict[str, Any]]:
project_id, tender_id = await project_with_tender(u)
assert (await run_parse(u, tender_id))["status"] == "succeeded"
bid = await upload_bid(u, project_id, "bid-mini-v1.pdf")
run = await run_check(u, project_id, [bid["id"]])
assert run["status"] == "succeeded", run
return project_id, bid, run
async def findings(u: User, run_id: str) -> list[dict[str, Any]]:
return (await u.get(f"/check-runs/{run_id}/findings")).json()["items"]
async def use_pro_plan(u: User) -> None:
from bid_doctor.core.db import TenantContext, tenant_tx
ctx = TenantContext(tenant_id=UUID(u.tenant_id), user_id=UUID(str(u.me["user"]["id"])))
async with tenant_tx(ctx) as s:
await s.execute(text("UPDATE subscription SET plan_code = 'pro' WHERE tenant_id = :t"), {"t": u.tenant_id})
async def test_check_flow(user: User) -> None:
project_id, tender_id = await project_with_tender(user)
await run_parse(user, tender_id)
preview = (await user.get(f"/projects/{project_id}/check-preview")).json()
assert preview["blockers"] == ["no_bid_documents"] and not preview["can_start"]
bid = await upload_bid(user, project_id, "bid-mini-v1.pdf")
assert bid["label"] == "技术分册" and bid["volume_kind"] == "technical" and bid["is_latest_version"]
preview = (await user.get(f"/projects/{project_id}/check-preview")).json()
assert preview["can_start"] and preview["billing"] == "free_plan" and preview["charge"] == 0
assert preview["checklist_confirmed"] is False and [d["id"] for d in preview["bid_documents"]] == [bid["id"]]
run = await run_check(user, project_id, [bid["id"]])
assert run["status"] == "succeeded" and run["is_latest"] and run["checklist_confirmed"] is False
assert run["counts"] == {"fatal": 1, "important": 0, "hint": 0, "open_fatal": 1, "resolved_this_run": 0, "new": 1}
assert "清单未确认,检查结果以当前清单为准" in run["coverage"]["not_checked"]
assert run["coverage"]["items"]["checked"] == 3
(f,) = await findings(user, run["id"])
assert f["type"] == "unresponded" and f["severity"] == "fatal" and f["presence"] == "new"
assert "BFD" in f["title"] and f["detector"] == "ai" and f["checklist_item"]["importance"] == "substantive"
assert f["tender_citations"][0]["quote"].startswith("★交换机") and f["bid_citations"] == []
assert f["search_scope"]["documents"] == ["技术分册"]
assert "建议" not in f["explanation"] # 只写事实和出处,不写整改建议
# 「已响应」也留结论和出处(C9)
results = (await user.get(f"/check-runs/{run['id']}/item-results")).json()["items"]
responded = [r for r in results if r["verdict"] == "responded"]
assert len(results) == 3 and len(responded) == 2
for r in responded:
(c,) = r["bid_citations"]
assert r["evidence_status"] == "verified" and c["document_label"] == "技术分册" and c["quads"]
project = (await user.get(f"/projects/{project_id}")).json()
assert project["status"] == "checked" and project["readiness"] == {
"status": "checked",
"open_fatal_count": 1,
"ready": False,
}
notes = (await user.get("/notifications")).json()["items"]
assert notes[0]["kind"] == "check_succeeded"
async def test_resolve_and_reopen(user: User) -> None:
project_id, _, run = await checked_project(user)
(f,) = await findings(user, run["id"])
r = await user.post(f"/findings/{f['id']}/resolve", json={"status": "confirmed_ok"})
assert r.status_code == 422 and r.json()["code"] == "note_required" # 致命问题确认无误也要写说明(U4)
r = await user.post(f"/findings/{f['id']}/resolve", json={"status": "ignored", "note": " "})
assert r.status_code == 422 and r.json()["code"] == "note_required"
r = await user.post(f"/findings/{f['id']}/resolve", json={"status": "confirmed_ok", "note": "附件 5 第 3 页有说明"})
assert r.status_code == 200, r.text
body = r.json()
assert body["finding"]["status"] == "confirmed_ok" and body["finding"]["resolved_by"]["name"] == "张工"
assert body["project"] == {"status": "checked", "open_fatal_count": 0, "ready": True}
from bid_doctor.core.db import TenantContext, tenant_tx
ctx = TenantContext(tenant_id=UUID(user.tenant_id), user_id=UUID(str(user.me["user"]["id"])))
async with tenant_tx(ctx) as s:
fb = (await s.execute(text("SELECT object_type, kind, source, note FROM feedback"))).all()
assert [tuple(x) for x in fb] == [("finding", "false_positive", "fatal_confirm", "附件 5 第 3 页有说明")]
r = await user.post(f"/findings/{f['id']}/reopen")
assert r.json()["finding"]["status"] == "pending" and r.json()["project"]["open_fatal_count"] == 1
r = await user.post(
f"/check-runs/{run['id']}/findings/bulk-resolve",
json={"finding_ids": [f["id"]], "status": "ignored", "note": "x"},
)
assert r.status_code == 422 and r.json()["code"] == "bulk_only_hint" and r.json()["finding_ids"] == [f["id"]]
assert (await user.get(f"/projects/{project_id}")).json()["readiness"]["open_fatal_count"] == 1
async def test_recheck_carries_status_and_resolves(user: User) -> None:
project_id, bid, first = await checked_project(user)
(f,) = await findings(user, first["id"])
await user.post(f"/findings/{f['id']}/resolve", json={"status": "ignored", "note": "BFD 写在附件里"})
# 同一份投标文件再查:问题还在,沿用上次的处理状态
second = await run_check(user, project_id, [bid["id"]])
(g,) = await findings(user, second["id"])
assert g["presence"] == "carried" and g["status"] == "ignored" and g["carried_status"] == "ignored"
assert g["resolution_note"] == "BFD 写在附件里" and second["counts"]["open_fatal"] == 0
# 历史批次只读
r = await user.post(f"/findings/{f['id']}/reopen")
assert r.status_code == 409 and r.json()["code"] == "run_superseded"
# 上次「已修改」又查出来:回到待处理
await user.post(f"/findings/{g['id']}/resolve", json={"status": "modified"})
third = await run_check(user, project_id, [bid["id"]])
(h,) = await findings(user, third["id"])
assert h["presence"] == "carried" and h["status"] == "pending" and h["carried_status"] == "modified"
# 换上补了 BFD 的新版本:问题本次已解决
v2 = await upload_bid(user, project_id, "bid-mini-v2.pdf", replaces=bid["id"])
assert v2["label"] == "技术分册 v2" and v2["lineage_id"] == bid["lineage_id"]
assert not (await user.get(f"/documents/{bid['id']}")).json()["is_latest_version"]
preview = (await user.get(f"/projects/{project_id}/check-preview")).json()
assert [d["id"] for d in preview["bid_documents"]] == [v2["id"]]
fourth = await run_check(user, project_id, [v2["id"]])
(k,) = await findings(user, fourth["id"])
assert k["presence"] == "resolved" and fourth["counts"]["resolved_this_run"] == 1
assert fourth["counts"]["fatal"] == 0 and fourth["counts"]["open_fatal"] == 0
r = await user.post(f"/findings/{k['id']}/resolve", json={"status": "ignored", "note": "x"})
assert r.status_code == 409 and r.json()["code"] == "finding_resolved_this_run"
project = (await user.get(f"/projects/{project_id}")).json()
assert project["readiness"]["ready"] is True and project["latest_check_run_id"] == fourth["id"]
async def test_pro_plan_charges_then_waives_recheck(app_client: httpx.AsyncClient) -> None:
u = await register(app_client)
await use_pro_plan(u)
project_id, tender_id = await project_with_tender(u)
await run_parse(u, tender_id)
bid = await upload_bid(u, project_id, "bid-mini-v1.pdf")
assert await balance(u) == 2
preview = (await u.get(f"/projects/{project_id}/check-preview")).json()
assert preview["billing"] == "charged" and preview["charge"] == 1 and preview["waive_until"] is None
r = await u.post(f"/projects/{project_id}/check-runs", json={"document_ids": [bid["id"]], "expected_charge": 0})
assert r.status_code == 409 and r.json()["code"] == "billing_changed" and r.json()["preview"]["charge"] == 1
assert await balance(u) == 2 # 扣点说明和实际不一致:拒绝,不扣点(API4)
first = await run_check(u, project_id, [bid["id"]], expected=1)
assert first["billing"] == "charged" and await balance(u) == 1
preview = (await u.get(f"/projects/{project_id}/check-preview")).json()
assert preview["billing"] == "waived_recheck" and preview["charge"] == 0 and preview["waive_until"]
second = await run_check(u, project_id, [bid["id"]], expected=0)
assert second["billing"] == "waived_recheck" and await balance(u) == 1
from bid_doctor.core.db import TenantContext, tenant_tx
ctx = TenantContext(tenant_id=UUID(u.tenant_id), user_id=UUID(str(u.me["user"]["id"])))
async with tenant_tx(ctx) as s:
rows = (
await s.execute(
text("SELECT kind, amount, reason FROM quota_entry WHERE ref_type = 'check_run' ORDER BY created_at")
)
).all()
assert [tuple(x) for x in rows] == [("hold", -1, None), ("settle", 0, None), ("waive", 0, "recheck_24h")]
async def test_unreadable_bid_fails_and_refunds(app_client: httpx.AsyncClient) -> None:
"""投标文件读不出文字(扫描件):按输入问题失败,退回额度,不生成问题。"""
u = await register(app_client)
await use_pro_plan(u)
project_id, tender_id = await project_with_tender(u)
await run_parse(u, tender_id)
bid = await upload_bid(u, project_id, "blank.pdf")
run = await run_check(u, project_id, [bid["id"]], expected=1)
assert run["status"] == "failed" and run["error"]["code"] == "bid_text_missing" and run["error"]["refunded"]
assert await balance(u) == 2 and await findings(u, run["id"]) == []
project = (await u.get(f"/projects/{project_id}")).json()
assert project["status"] == "drafting" and project["latest_check_run_id"] is None
async def test_duplicate_check_is_rejected(user: User) -> None:
project_id, tender_id = await project_with_tender(user)
await run_parse(user, tender_id)
bid = await upload_bid(user, project_id, "bid-mini-v1.pdf")
body = {"document_ids": [bid["id"]], "expected_charge": 0}
assert (await user.post(f"/projects/{project_id}/check-runs", json=body)).status_code == 201
r = await user.post(f"/projects/{project_id}/check-runs", json=body)
assert r.status_code == 409 and r.json()["code"] == "job_already_running" and r.json()["job_id"]
preview = (await user.get(f"/projects/{project_id}/check-preview")).json()
assert "job_already_running" in preview["blockers"]
async def test_platform_encrypted_bid_is_rejected(user: User) -> None:
done = await upload(user, "投标文件.kedt", b"PK\x03\x04not-really-a-zip")
assert done.status_code == 422 and done.json()["code"] == "bid_file_platform_encrypted"
assert "苏采云" in done.json()["detail"]
async def test_other_tenant_cannot_see_check(app_client: httpx.AsyncClient) -> None:
a = await register(app_client, "甲公司")
b = await register(app_client, "乙公司")
project_id, _, run = await checked_project(a)
(f,) = await findings(a, run["id"])
for path in (
f"/projects/{project_id}/check-preview",
f"/projects/{project_id}/check-runs",
f"/check-runs/{run['id']}",
f"/check-runs/{run['id']}/item-results",
):
r = await b.get(path)
assert r.status_code == 404, (path, r.status_code, r.text)
assert (await b.get(f"/check-runs/{run['id']}/findings")).status_code == 404
assert (await b.post(f"/findings/{f['id']}/resolve", json={"status": "modified"})).status_code == 404
assert (await b.post(f"/findings/{f['id']}/reopen")).status_code == 404
assert (await b.post(f"/check-runs/{run['id']}/cancel")).status_code == 404