"""M6 剩下的 P0(m6-plan):额度流水、80% / 100% 提醒、开通和续费、到期只读、删除项目、注销账号、通知已读。""" from __future__ import annotations from datetime import datetime, timedelta from typing import Any from uuid import UUID, uuid4 import httpx import pytest from sqlalchemy import text from bid_doctor.billing import service as billing from bid_doctor.billing import tasks from bid_doctor.core.db import TenantContext, tenant_tx from tests.conftest import User, balance, project_with_tender, register, run_parse OPS = uuid4() # 运营后台还没做:直接调 service,记一个运营编号 INVOICE = { "title": "测试集成公司", "tax_no": "91320100MA1XXXXX0Y", "invoice_type": "general", "contact_name": "张工", "contact_phone": "13800000000", } def ctx_of(u: User) -> TenantContext: return TenantContext(tenant_id=UUID(u.tenant_id), user_id=UUID(int=0), actor_name="系统") async def activate(u: User, request_id: str) -> None: async with tenant_tx(ctx_of(u)) as s: await billing.activate(s, ctx_of(u), UUID(request_id), ops_id=OPS) async def entries(u: User, **params: Any) -> list[dict[str, Any]]: r = await u.get("/quota/entries", params=params) assert r.status_code == 200, r.text return r.json()["items"] async def test_ledger_alerts_and_pages(user: User) -> None: """流水:任务能对上项目和用途,余额逐条可核;用到 80%、100% 各提醒一次(免费版 3 次)。""" project_id, doc_id = await project_with_tender(user) assert (await run_parse(user, doc_id))["status"] == "succeeded" rows = await entries(user) assert [(r["kind"], r["amount"], r["balance_after"]) for r in rows] == [ ("settle", 0, 2), ("hold", -1, 2), ("grant", 3, 3), ] hold = rows[1] assert hold["project"] == {"id": project_id, "name": hold["project"]["name"], "deleted": False} assert hold["ref"]["type"] == "parse_job" and hold["ref"]["label"] == "招标文件" assert rows[0]["hold_entry_id"] == hold["id"] and rows[2]["reason"] == "注册赠送" # 分页:每页 1 条,接着上一页往后 first = (await user.get("/quota/entries", params={"limit": 1})).json() second = (await user.get("/quota/entries", params={"limit": 1, "cursor": first["next_cursor"]})).json() assert first["items"][0]["kind"] == "settle" and second["items"][0]["kind"] == "hold" # 再解析两次:第 3 次时余额 0,80% 和 100% 一起提醒(每档只一次) for _ in range(2): r = await user.post(f"/documents/{doc_id}/parse-jobs", json={"expected_charge": 1}) from bid_doctor.document.pipeline import runner await runner.run(UUID(user.tenant_id), UUID(r.json()["id"])) assert await balance(user) == 0 kinds = [n["kind"] for n in (await user.get("/notifications")).json()["items"]] assert kinds.count("quota_80") == 1 and kinds.count("quota_100") == 1 q = (await user.get("/quota")).json() assert q["usage"]["tender"] == 3 and q["accounts"][0]["period_used"] == 3 async def test_activation_renewal_expiry_read_only(app_client: httpx.AsyncClient) -> None: u = await register(app_client, "开通测试公司") bad = await u.post("/activation-requests", json={"invoice_info": {**INVOICE, "tax_no": "123"}}) assert bad.status_code == 422 special = await u.post("/activation-requests", json={"invoice_info": {**INVOICE, "invoice_type": "special"}}) assert special.status_code == 422 and special.json()["code"] == "validation_failed" r = await u.post("/activation-requests", json={"invoice_info": INVOICE}) assert r.status_code == 201, r.text req = r.json() assert req["status"] == "submitted" and req["request_no"].startswith("KT-") and req["payment"]["payee_name"] assert (await u.post("/activation-requests", json={"invoice_info": INVOICE})).json()["code"] == "activation_pending" assert (await u.get("/quota")).json()["pending_request"]["id"] == req["id"] # 运营开通:免费版剩下的 3 次清零,发放专业版 500 次 await activate(u, req["id"]) me = (await u.get("/me")).json() assert me["plan"]["code"] == "pro" and me["plan"]["read_only"] is False rows = await entries(u) assert [(x["kind"], x["amount"]) for x in rows[:2]] == [("grant", 500), ("expire", -3)] assert await balance(u) == 500 ends = datetime.fromisoformat(me["plan"]["ends_at"]) notes = [n["kind"] for n in (await u.get("/notifications")).json()["items"]] assert "subscription_activated" in notes # 续费:当前一期还没到期,新一期排队,从旧一期结束时开始 r2 = (await u.post("/activation-requests", json={"invoice_info": INVOICE})).json() await activate(u, r2["id"]) q = (await u.get("/quota")).json() assert q["renewal"] and datetime.fromisoformat(q["renewal"]["starts_at"]) == ends and await balance(u) == 500 # 到期前 30 天、7 天提醒各一次;重复跑不重复提醒(续过费的不提醒,先把排队的一期撤掉) async with tenant_tx(ctx_of(u)) as s: await s.execute(text("UPDATE subscription SET status = 'cancelled' WHERE status = 'scheduled'")) for days in (25, 25, 5): await tasks.tick(ends - timedelta(days=days)) reminders = [n for n in (await u.get("/notifications")).json()["items"] if n["kind"] == "subscription_expiring"] assert [n["title"] for n in reminders] == ["专业版还有 5 天到期", "专业版还有 25 天到期"] # 到期:余额清零,只读——新建项目被拒,开通申请、通知已读照常 await tasks.tick(ends + timedelta(minutes=1)) me = (await u.get("/me")).json() assert me["plan"]["read_only"] is True and await balance(u) == 0 assert (await u.post("/projects", json={})).json()["code"] == "plan_expired" assert (await u.post("/notifications/read", json={})).status_code == 204 assert (await u.get("/notifications", params={"unread": True})).json()["unread_count"] == 0 r3 = await u.post("/activation-requests", json={"invoice_info": INVOICE}) assert r3.status_code == 201 await activate(u, r3.json()["id"]) # 过期后续费:马上开始新一期 assert (await u.get("/me")).json()["plan"]["read_only"] is False and await balance(u) == 500 async def test_delete_project(user: User) -> None: project_id, doc_id = await project_with_tender(user) queued = await user.post(f"/documents/{doc_id}/parse-jobs", json={"expected_charge": 1}) assert queued.status_code == 201 blocked = await user.delete(f"/projects/{project_id}") assert blocked.status_code == 409 and blocked.json()["code"] == "job_already_running" assert (await user.post(f"/parse-jobs/{queued.json()['id']}/cancel")).status_code in (200, 202) from bid_doctor.document.pipeline import runner await runner.run(UUID(user.tenant_id), UUID(queued.json()["id"])) # 取消:预扣退回 assert (await run_parse(user, doc_id))["status"] == "succeeded" r = await user.delete(f"/projects/{project_id}") assert r.status_code == 204, r.text assert (await user.get(f"/projects/{project_id}")).status_code == 404 # 流水还在,项目标「已删除」;没有净扣点的任务(取消的那次)照样对得上 rows = [x for x in await entries(user) if x["project"]] assert rows and all(x["project"]["deleted"] and x["project"]["name"] is None for x in rows) assert sum(x["amount"] for x in rows) == -1 async with tenant_tx(ctx_of(user)) as s: key = (await s.execute(text("SELECT wrapped_key FROM data_key WHERE project_id = :p"), {"p": project_id})).one() audit = ( await s.execute( text( "SELECT count(*) FILTER (WHERE before IS NOT NULL OR after IS NOT NULL), count(*)" " FROM audit_event WHERE project_id = :p" ), {"p": project_id}, ) ).one() from bid_doctor.core import keys from bid_doctor.core.errors import AppError with pytest.raises(AppError) as err: # 删除后不再建新密钥(还在跑的任务写不回文件) await keys.data_key(s, ctx_of(user), UUID(project_id)) assert err.value.code == "not_found" assert key[0] is None and audit[0] == 0 and audit[1] >= 2 async def test_close_tenant(app_client: httpx.AsyncClient) -> None: u = await register(app_client, "注销测试公司") project_id, doc_id = await project_with_tender(u) assert (await run_parse(u, doc_id))["status"] == "succeeded" from bid_doctor.core.crypto import phone_lookup_key from bid_doctor.core.valkey import valkey await valkey().delete(f"sms:rate:{phone_lookup_key(u.phone)}") # 注册时刚发过一条,60 秒内不能再发 assert (await u.post("/me/sms-codes", json={"purpose": "close_tenant"})).status_code == 202 wrong = await u.post("/tenant/close", json={"sms_code": "000000", "confirm_tenant_name": "别的公司"}) assert wrong.status_code == 422 and wrong.json()["code"] == "tenant_name_mismatch" bad_code = await u.post("/tenant/close", json={"sms_code": "111111", "confirm_tenant_name": "注销测试公司"}) assert bad_code.json()["code"] == "sms_code_invalid" assert (await u.post("/me/sms-codes", json={"purpose": "close_tenant"})).status_code in (202, 429) r = await u.post("/tenant/close", json={"sms_code": "000000", "confirm_tenant_name": "注销测试公司"}) assert r.status_code == 202, r.text assert (await u.get("/me")).status_code == 401 from bid_doctor.identity import closure await closure.finish(UUID(u.tenant_id)) await closure.finish(UUID(u.tenant_id)) # 重复执行没事 async with tenant_tx(ctx_of(u)) as s: left = ( await s.execute( text( "SELECT (SELECT count(*) FROM project)," " (SELECT count(*) FROM data_key WHERE wrapped_key IS NOT NULL)," " (SELECT count(*) FROM quota_entry), (SELECT status FROM tenant)," " (SELECT count(*) FROM audit_event WHERE actor_name IS NOT NULL OR after IS NOT NULL)" ) ) ).one() assert left[0] == 0 and left[1] == 0 and left[2] > 0 and left[3] == "closed" and left[4] == 0 # 手机号可以重新注册(新企业) assert (await app_client.post("/auth/sms-codes", json={"phone": u.phone, "purpose": "register"})).status_code in ( 202, 429, ) del project_id