# -*- coding: utf-8 -*- """预算状态机口径归一 + 缺失 4 环最小骨架 —— 用例(施工单 CMA-20261003) 覆盖: 1) 生效面口径:approved(已批准) 行进入执行/对比面(改前被排除) 2) 选行优先级:同键 active(已下达) > approved 3) 审批 fail-closed:未提交版本审批 → 400(新契约锁定) 4) 下达:approved → active(幂等;非 approved → 400) 5) 调整:派生新版本(draft)、原版本零改动、同名版本 → 400、无 reason → 400 6) 控制 / 考核端点形状 """ import pytest from fastapi.testclient import TestClient from sqlalchemy.orm import Session from tests.conftest import create_test_user, get_token_for_user, auth_header, create_test_kpi from app.models.budget_plan import BudgetPlan from app.models import KPIValue BASE = "/api/cma/budget" def _create_plan(client, token, kpi_id, value=10000.0, version="v1.0", period="2026-06"): r = client.post(f"{BASE}/plans", headers=auth_header(token), json={"kpi_id": kpi_id, "period": period, "budget_value": value, "version": version}) assert r.status_code == 200, r.text return r def _submit_and_approve(client, token, version="v1.0", action="approved"): s = client.post(f"{BASE}/versions/submit", headers=auth_header(token), json={"version": version}) assert s.status_code == 200, s.text a = client.post(f"{BASE}/versions/approve", headers=auth_header(token), json={"version": version, "action": action}) assert a.status_code == 200, a.text return a class TestEffectiveSurface: """生效面口径:approved ∪ active""" def test_approved_plan_enters_comparison(self, client: TestClient, db: Session): """已批准预算必须出现在 /comparison 执行面(改前只读 active ⇒ 消失)""" create_test_user(db) token = get_token_for_user(client) kpi = create_test_kpi(db, kpi_code="BUDGET_EFF_APPROVED") _create_plan(client, token, kpi.id, value=1234.0) _submit_and_approve(client, token) resp = client.get(f"{BASE}/comparison?year=2026", headers=auth_header(token)) assert resp.status_code == 200 months = {m["period"]: m for m in resp.json()["months_data"]} assert months["2026-06"]["budget_total"] == 1234.0, "已批准版本应进入执行面" def test_kpi_comparison_reads_approved(self, client: TestClient, db: Session): """单KPI对比同样读生效面(approved)""" create_test_user(db) token = get_token_for_user(client) kpi = create_test_kpi(db, kpi_code="BUDGET_EFF_KPI") _create_plan(client, token, kpi.id, value=777.0) _submit_and_approve(client, token) resp = client.get(f"{BASE}/comparison/kpi/{kpi.id}?year=2026", headers=auth_header(token)) assert resp.status_code == 200 points = {p["period"]: p for p in resp.json()["data_points"]} assert points["2026-06"]["budget_value"] == 777.0 def test_draft_not_in_surface(self, client: TestClient, db: Session): """草稿(submitted) 不入执行面:提交后 /comparison 该 KPI 无预算值""" create_test_user(db) token = get_token_for_user(client) kpi = create_test_kpi(db, kpi_code="BUDGET_EFF_DRAFT") _create_plan(client, token, kpi.id, value=555.0) client.post(f"{BASE}/versions/submit", headers=auth_header(token), json={"version": "v1.0"}) resp = client.get(f"{BASE}/comparison?year=2026", headers=auth_header(token)) months = {m["period"]: m for m in resp.json()["months_data"]} assert months["2026-06"]["budget_total"] == 0, "submitted 不应作为执行基准" def test_effective_pick_priority_active_over_approved(self, db: Session): """选行优先级:active(已下达) > approved > updated_at desc(纯函数,零 DB)""" from app.utils.budget_status import pick_effective from datetime import datetime, timedelta class R: def __init__(self, status, ts, val): self.status, self.updated_at, self.budget_value = status, ts, val now = datetime(2026, 10, 3, 12, 0, 0) approved_newer = R("approved", now, 1.0) active_older = R("active", now - timedelta(days=10), 2.0) assert pick_effective([approved_newer, active_older]).budget_value == 2.0, "已下达优先" a = R("approved", now, 3.0) b = R("approved", now - timedelta(days=1), 4.0) assert pick_effective([b, a]).budget_value == 3.0, "同级取 updated_at 最新" assert pick_effective([]) is None def test_approval_status_mapping(self): """UI 兼容映射:active/draft → draft,approved 原样""" from app.utils.budget_status import approval_status_of assert approval_status_of("active") == "draft" assert approval_status_of("draft") == "draft" assert approval_status_of("approved") == "approved" assert approval_status_of("submitted") == "submitted" assert approval_status_of(None) == "draft" class TestVersionReleaseAndGuards: """下达(缺失环)与审批 fail-closed""" def test_approve_without_submit_400(self, client: TestClient, db: Session): """新契约负例:未提交版本直接审批 → 400""" create_test_user(db) token = get_token_for_user(client) kpi = create_test_kpi(db, kpi_code="BUDGET_GUARD_APPROVE") _create_plan(client, token, kpi.id) resp = client.post(f"{BASE}/versions/approve", headers=auth_header(token), json={"version": "v1.0", "action": "approved"}) assert resp.status_code == 400 assert "submitted" in resp.json()["detail"] def test_release_requires_approved(self, client: TestClient, db: Session): """未批准版本不可下达 → 400""" create_test_user(db) token = get_token_for_user(client) kpi = create_test_kpi(db, kpi_code="BUDGET_REL_400") _create_plan(client, token, kpi.id) resp = client.post(f"{BASE}/versions/release", headers=auth_header(token), json={"version": "v1.0"}) assert resp.status_code == 400 def test_release_then_idempotent(self, client: TestClient, db: Session): """下达:approved → active;重复下达幂等 already=True""" create_test_user(db) token = get_token_for_user(client) kpi = create_test_kpi(db, kpi_code="BUDGET_REL_OK") _create_plan(client, token, kpi.id) _submit_and_approve(client, token) r1 = client.post(f"{BASE}/versions/release", headers=auth_header(token), json={"version": "v1.0"}) assert r1.status_code == 200 assert r1.json()["status"] == "active" and r1.json()["count"] == 1 and r1.json()["already"] is False r2 = client.post(f"{BASE}/versions/release", headers=auth_header(token), json={"version": "v1.0"}) assert r2.status_code == 200 assert r2.json()["already"] is True and r2.json()["count"] == 0 row = db.query(BudgetPlan).filter(BudgetPlan.kpi_id == kpi.id).first() db.refresh(row) assert row.status == "active" def test_version_list_flags(self, client: TestClient, db: Session): """版本列表新增 is_effective / can_release 标记""" create_test_user(db) token = get_token_for_user(client) kpi = create_test_kpi(db, kpi_code="BUDGET_VER_FLAG") _create_plan(client, token, kpi.id) _submit_and_approve(client, token) versions = client.get(f"{BASE}/versions", headers=auth_header(token)).json() row = [v for v in versions if v["version"] == "v1.0"][0] assert row["status"] == "approved" assert row["approval_status"] == "approved" assert row["is_effective"] is True assert row["can_release"] is True class TestBudgetAdjustment: """调整(缺失环):派生新版本""" def test_adjustment_creates_draft_and_keeps_base(self, client: TestClient, db: Session): create_test_user(db) token = get_token_for_user(client) kpi = create_test_kpi(db, kpi_code="BUDGET_ADJ_OK") _create_plan(client, token, kpi.id, value=1000.0) _submit_and_approve(client, token) resp = client.post(f"{BASE}/adjustments", headers=auth_header(token), json={ "base_version": "v1.0", "new_version": "v1.0-adj-20261003", "reason": "追加渠道投入", "items": [{"kpi_id": kpi.id, "period": "2026-06", "budget_value": 1500.0}], }) assert resp.status_code == 200, resp.text body = resp.json() assert body["created"] == 1 and body["applied_overrides"] == 1 and body["unmatched_overrides"] == 0 base_row = db.query(BudgetPlan).filter( BudgetPlan.kpi_id == kpi.id, BudgetPlan.version == "v1.0").first() db.refresh(base_row) assert base_row.budget_value == 1000.0, "基线版本数值不得被调整单改动" adj_row = db.query(BudgetPlan).filter( BudgetPlan.kpi_id == kpi.id, BudgetPlan.version == "v1.0-adj-20261003").first() assert adj_row is not None and adj_row.status == "draft" and adj_row.budget_value == 1500.0 assert "调整单:追加渠道投入" in (adj_row.remark or "") def test_adjustment_existing_version_400(self, client: TestClient, db: Session): create_test_user(db) token = get_token_for_user(client) kpi = create_test_kpi(db, kpi_code="BUDGET_ADJ_DUP") _create_plan(client, token, kpi.id) resp = client.post(f"{BASE}/adjustments", headers=auth_header(token), json={ "base_version": "v1.0", "new_version": "v1.0", "reason": "x"}) assert resp.status_code == 400 def test_adjustment_requires_reason(self, client: TestClient, db: Session): create_test_user(db) token = get_token_for_user(client) kpi = create_test_kpi(db, kpi_code="BUDGET_ADJ_NOREASON") _create_plan(client, token, kpi.id) resp = client.post(f"{BASE}/adjustments", headers=auth_header(token), json={ "base_version": "v1.0", "new_version": "v1.1", "reason": ""}) assert resp.status_code == 400 def test_adjustment_unknown_base_404(self, client: TestClient, db: Session): create_test_user(db) token = get_token_for_user(client) resp = client.post(f"{BASE}/adjustments", headers=auth_header(token), json={ "base_version": "v9.9", "new_version": "v9.9-adj", "reason": "x"}) assert resp.status_code == 404 class TestControlAndAssessment: """控制 / 考核端点(最小骨架)""" def test_control_status_shape_and_levels(self, client: TestClient, db: Session): create_test_user(db) token = get_token_for_user(client) kpi = create_test_kpi(db, kpi_code="BUDGET_CTRL_KPI", kpi_name="渠道费用", unit="万元") _create_plan(client, token, kpi.id, value=100.0) _submit_and_approve(client, token) db.add(KPIValue(kpi_id=kpi.id, period="2026-06", actual_value=150.0)) db.commit() resp = client.get(f"{BASE}/control/status?year=2026", headers=auth_header(token)) assert resp.status_code == 200, resp.text body = resp.json() assert set(["total", "ok", "warn", "over", "no_actual"]).issubset(body["summary"].keys()) row = [i for i in body["items"] if i["kpi_id"] == kpi.id and i["period"] == "2026-06"][0] assert row["basis_version"] == "v1.0" assert row["execution_rate"] == 150.0 # 费用型(非 higher_better)= 越低越好 ⇒ 超支 50% > 阈值 20% ⇒ over assert row["control_level"] == "over" def test_control_excludes_draft(self, client: TestClient, db: Session): """草稿版本不入控制面""" create_test_user(db) token = get_token_for_user(client) kpi = create_test_kpi(db, kpi_code="BUDGET_CTRL_DRAFT") _create_plan(client, token, kpi.id, value=100.0) client.post(f"{BASE}/versions/submit", headers=auth_header(token), json={"version": "v1.0"}) resp = client.get(f"{BASE}/control/status?year=2026", headers=auth_header(token)) ids = {i["kpi_id"] for i in resp.json()["items"]} assert kpi.id not in ids def test_assessment_shape_and_grade(self, client: TestClient, db: Session): create_test_user(db) token = get_token_for_user(client) kpi = create_test_kpi(db, kpi_code="SALES_TOTAL", kpi_name="营业收入", unit="万元") _create_plan(client, token, kpi.id, value=100.0) _submit_and_approve(client, token) db.add(KPIValue(kpi_id=kpi.id, period="2026-06", actual_value=95.0)) db.commit() resp = client.get(f"{BASE}/assessment?year=2026", headers=auth_header(token)) assert resp.status_code == 200, resp.text body = resp.json() assert body["year"] == 2026 and "summary" in body row = [i for i in body["items"] if i["kpi_id"] == kpi.id][0] assert row["budget_total"] == 100.0 assert row["actual_total"] == 95.0 assert row["achieve_rate"] == 95.0 assert row["grade"] == "B"