Files
bid-doctor/server/tests/test_check_flow.py
YANG JIANKUAN 47c6a1258e feat(check): 文件要在、扫描件兜底(C10);响应方式存进清单并可改(C12)
- check/presence.py:「提供《授权委托书》」「须提交投标函」这类要求按标题里有没有这个文件名判断,不调模型。
- 扫描件兜底:要附证明材料的要求没找到、投标文件里有扫描件页时,改为提示级「可能在扫描件里,请人工核对」。
- 响应方式解析时识别、带依据句子存进 checklist.response_modes(迁移 0008);清单页显示、可改(写审计,
  重新解析不覆盖人工改过的);查标用存下来的。新接口 PUT /projects/{id}/checklist/response-modes/{category}。
- check_item_result.layer 加 presence(迁移 0007);契约同步默认接受的两种结论。

开发集 17 份:规矩投标文件的致命问题 120 → 51,致命级召回不变(22 / 32)(docs/architecture/check-eval.md §5)。

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

275 lines
15 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
async def test_response_modes_are_stored_editable_and_used(user: User) -> None:
"""响应方式(C12):解析时存进清单;用户改成「默认接受」后,查标对没复述的一般要求不再报问题。"""
project_id, tender_id = await project_with_tender(user)
await run_parse(user, tender_id)
modes = {m["category"]: m for m in (await user.get(f"/projects/{project_id}/checklist")).json()["response_modes"]}
assert set(modes) == {"technical", "commercial"}
assert modes["technical"]["mode"] == "explicit" and modes["technical"]["status"] == "unknown"
r = await user.put(f"/projects/{project_id}/checklist/response-modes/technical", json={"mode": "implicit"})
assert r.status_code == 200, r.text
tech = next(m for m in r.json()["response_modes"] if m["category"] == "technical")
assert tech["origin"] == "human" and tech["mode"] == "implicit" and tech["set_by"]["name"] == "张工"
bid = await upload_bid(user, project_id, "bid-mini-v1.pdf")
run = await run_check(user, project_id, [bid["id"]])
# 漏掉的 BFD 是 ★ 条款:默认接受下不报致命,改为提示级「请人工核对」
(f,) = await findings(user, run["id"])
assert f["type"] == "manual_review" and f["severity"] == "hint"
assert run["counts"]["fatal"] == 0