"""查标第 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