"""预算管理模块测试 — 预算计划CRUD + 自动分解 + 版本 + 偏差/对比/配置/滚动"""
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, SystemConfig, CashPlan


class TestBudgetPlans:
    """预算计划CRUD测试"""

    BASE = "/api/cma/budget"

    def test_list_plans_empty(self, client: TestClient, db: Session):
        """空列表"""
        create_test_user(db)
        token = get_token_for_user(client)

        resp = client.get(f"{self.BASE}/plans", headers=auth_header(token))
        assert resp.status_code == 200
        assert resp.json()["data"] == []

    def test_create_plan(self, client: TestClient, db: Session):
        """创建预算计划"""
        create_test_user(db)
        token = get_token_for_user(client)
        kpi = create_test_kpi(db, kpi_code="BUDGET_KPI_01")

        resp = client.post(
            f"{self.BASE}/plans",
            headers=auth_header(token),
            json={"kpi_id": kpi.id, "period": "2026-06", "budget_value": 50000.0},
        )
        assert resp.status_code == 200
        assert resp.json()["message"] == "预算已创建"
        assert resp.json()["id"] > 0

    def test_create_plan_duplicate_upsert(self, client: TestClient, db: Session):
        """重复创建同一KPI+期间 → 更新而非新增"""
        create_test_user(db)
        token = get_token_for_user(client)
        kpi = create_test_kpi(db, kpi_code="BUDGET_KPI_02")

        # 创建
        client.post(
            f"{self.BASE}/plans",
            headers=auth_header(token),
            json={"kpi_id": kpi.id, "period": "2026-06", "budget_value": 30000.0},
        )
        # 再次创建（更新）
        resp = client.post(
            f"{self.BASE}/plans",
            headers=auth_header(token),
            json={"kpi_id": kpi.id, "period": "2026-06", "budget_value": 35000.0},
        )
        assert resp.status_code == 200
        assert resp.json()["message"] == "预算已更新"

    def test_create_plan_missing_fields(self, client: TestClient, db: Session):
        """缺少必要参数被拒绝"""
        create_test_user(db)
        token = get_token_for_user(client)

        resp = client.post(
            f"{self.BASE}/plans",
            headers=auth_header(token),
            json={"kpi_id": 1},  # 缺少 period 和 budget_value
        )
        assert resp.status_code == 400

    def test_update_plan(self, client: TestClient, db: Session):
        """更新预算计划"""
        create_test_user(db)
        token = get_token_for_user(client)
        kpi = create_test_kpi(db, kpi_code="BUDGET_KPI_03")

        create_resp = client.post(
            f"{self.BASE}/plans",
            headers=auth_header(token),
            json={"kpi_id": kpi.id, "period": "2026-06", "budget_value": 100.0},
        )
        plan_id = create_resp.json()["id"]

        resp = client.put(
            f"{self.BASE}/plans/{plan_id}",
            headers=auth_header(token),
            json={"budget_value": 200.0, "remark": "已更新"},
        )
        assert resp.status_code == 200

    def test_delete_plan(self, client: TestClient, db: Session):
        """删除预算计划"""
        create_test_user(db)
        token = get_token_for_user(client)
        kpi = create_test_kpi(db, kpi_code="BUDGET_KPI_04")

        create_resp = client.post(
            f"{self.BASE}/plans",
            headers=auth_header(token),
            json={"kpi_id": kpi.id, "period": "2026-06", "budget_value": 100.0},
        )
        plan_id = create_resp.json()["id"]

        resp = client.delete(f"{self.BASE}/plans/{plan_id}", headers=auth_header(token))
        assert resp.status_code == 200
        assert resp.json()["message"] == "预算已删除"

        # 验证已删除
        get_resp = client.get(f"{self.BASE}/plans", headers=auth_header(token))
        ids = [p["id"] for p in get_resp.json()["data"]]
        assert plan_id not in ids


class TestBudgetAutoDecompose:
    """预算自动分解测试"""

    BASE = "/api/cma/budget"

    def test_auto_decompose_single_kpi(self, client: TestClient, db: Session):
        """单KPI自动分解年度预算为月度（均分）"""
        create_test_user(db)
        token = get_token_for_user(client)
        kpi = create_test_kpi(db, kpi_code="BUDGET_DECOMPOSE")

        resp = client.post(
            f"{self.BASE}/auto-decompose",
            headers=auth_header(token),
            json={"kpi_id": kpi.id, "annual_budget": 120000, "year": 2026, "method": "equal"},
        )
        assert resp.status_code == 200
        data = resp.json()
        assert "已分解" in data["message"]
        assert len(data["monthly_budgets"]) == 12
        # 年度预算120000，12个月均分，每月10000
        assert data["monthly_budgets"][0]["value"] == 10000.0

    def test_auto_decompose_missing(self, client: TestClient, db: Session):
        """没有年度预算数据时尝试分解 → 400"""
        create_test_user(db)
        token = get_token_for_user(client)
        create_test_kpi(db, kpi_code="BUDGET_NO_DATA")

        resp = client.post(
            f"{self.BASE}/auto-decompose",
            headers=auth_header(token),
            json={"year": 2026, "method": "equal"},
        )
        assert resp.status_code == 400


class TestBudgetVersions:
    """预算版本管理测试"""

    BASE = "/api/cma/budget"

    def test_create_and_submit_version(self, client: TestClient, db: Session):
        """创建预算后查询版本并提交（版本管理API 2026-08-25恢复）"""
        create_test_user(db)
        token = get_token_for_user(client)
        kpi = create_test_kpi(db, kpi_code="BUDGET_VER_KPI")

        # 创建一条预算
        client.post(
            f"{self.BASE}/plans",
            headers=auth_header(token),
            json={"kpi_id": kpi.id, "period": "2026-06", "budget_value": 10000.0},
        )

        # 版本列表应返回200（2026-08-25新增版本管理API）
        ver_resp = client.get(f"{self.BASE}/versions", headers=auth_header(token))
        assert ver_resp.status_code == 200, "versions端点应存在"
        versions = ver_resp.json()
        assert isinstance(versions, list)
        assert len(versions) >= 1

    def test_approve_version(self, client: TestClient, db: Session):
        """审批通过版本"""
        create_test_user(db)
        token = get_token_for_user(client)
        kpi = create_test_kpi(db, kpi_code="BUDGET_VER_APPROVE")
        client.post(
            f"{self.BASE}/plans",
            headers=auth_header(token),
            json={"kpi_id": kpi.id, "period": "2026-06", "budget_value": 10000.0},
        )

        resp = client.post(
            f"{self.BASE}/versions/approve",
            headers=auth_header(token),
            json={"version": "v1.0", "action": "approved"},
        )
        assert resp.status_code == 200
        assert resp.json()["status"] == "approved"

    def test_reject_version(self, client: TestClient, db: Session):
        """驳回版本"""
        create_test_user(db)
        token = get_token_for_user(client)
        kpi = create_test_kpi(db, kpi_code="BUDGET_VER_REJECT")
        client.post(
            f"{self.BASE}/plans",
            headers=auth_header(token),
            json={"kpi_id": kpi.id, "period": "2026-06", "budget_value": 10000.0},
        )

        resp = client.post(
            f"{self.BASE}/versions/approve",
            headers=auth_header(token),
            json={"version": "v1.0", "action": "rejected"},
        )
        assert resp.status_code == 200
        assert resp.json()["status"] == "rejected"


class TestBudgetDeviationReport:
    """偏差报告（实际 vs 预算汇总）"""

    BASE = "/api/cma/budget"

    def _seed(self, db: Session):
        kpi = create_test_kpi(db, kpi_code="BUDGET_DEV_KPI")
        db.add(BudgetPlan(kpi_id=kpi.id, period="2026-06", budget_value=100.0,
                          budget_year=2026, budget_month=6, status="active"))
        from app.models import KPIValue
        db.add(KPIValue(kpi_id=kpi.id, period="2026-06", actual_value=120.0))
        db.commit()
        return kpi

    def test_deviation_report_over_budget(self, client: TestClient, db: Session):
        """实际超出预算 → 超支统计"""
        create_test_user(db)
        token = get_token_for_user(client)
        self._seed(db)

        resp = client.get(f"{self.BASE}/deviation-report?year=2026&month=6",
                          headers=auth_header(token))
        assert resp.status_code == 200
        data = resp.json()
        assert data["period"] == "2026-06"
        assert data["summary"]["total_kpis"] == 1
        assert data["summary"]["has_budget"] == 1
        assert data["summary"]["over_budget"] == 1
        # 120 vs 100 → +20%
        assert data["items"][0]["deviation_rate"] == 20.0

    def test_deviation_report_alert_level_filter(self, client: TestClient, db: Session):
        """按预警等级过滤（>20%红 / >10%黄）"""
        create_test_user(db)
        token = get_token_for_user(client)
        self._seed(db)

        resp = client.get(f"{self.BASE}/deviation-report?year=2026&month=6&alert_level=red",
                          headers=auth_header(token))
        assert resp.status_code == 200
        # summary统计全部KPI；items按alert_level过滤
        assert resp.json()["summary"]["total_kpis"] == 1
        assert len(resp.json()["items"]) == 0  # 20% 不是 >20，非red

        resp2 = client.get(f"{self.BASE}/deviation-report?year=2026&month=6&alert_level=yellow",
                           headers=auth_header(token))
        assert resp2.status_code == 200
        assert len(resp2.json()["items"]) == 1


class TestBudgetConfig:
    """预算模式配置"""

    BASE = "/api/cma/budget"

    def test_default_config(self, client: TestClient, db: Session):
        """未配置时默认固定预算"""
        create_test_user(db)
        token = get_token_for_user(client)

        resp = client.get(f"{self.BASE}/config", headers=auth_header(token))
        assert resp.status_code == 200
        assert resp.json()["budget_mode"] == "fixed"
        assert resp.json()["rolling_months"] == 12

    def test_set_config_rolling(self, client: TestClient, db: Session):
        """切换为滚动预算"""
        create_test_user(db)
        token = get_token_for_user(client)

        resp = client.post(f"{self.BASE}/config", headers=auth_header(token),
                           json={"mode": "rolling", "rolling_months": 6})
        assert resp.status_code == 200
        assert resp.json()["budget_mode"] == "rolling"

        get_resp = client.get(f"{self.BASE}/config", headers=auth_header(token))
        cfg = get_resp.json()
        # GET返回存的JSON {mode:...}（前端兼容 budget_mode || mode）
        assert (cfg.get("budget_mode") or cfg.get("mode")) == "rolling"
        assert cfg.get("rolling_months") == 6

    def test_set_config_invalid_mode(self, client: TestClient, db: Session):
        """非法模式 → 400"""
        create_test_user(db)
        token = get_token_for_user(client)

        resp = client.post(f"{self.BASE}/config", headers=auth_header(token),
                           json={"mode": "weird"})
        assert resp.status_code == 400


class TestBudgetRollForward:
    """滚动预算自动延展"""

    BASE = "/api/cma/budget"

    def test_roll_forward_requires_rolling_mode(self, client: TestClient, db: Session):
        """固定预算模式 → 400"""
        create_test_user(db)
        token = get_token_for_user(client)

        resp = client.post(f"{self.BASE}/roll-forward", headers=auth_header(token))
        assert resp.status_code == 400
        assert "未配置" in resp.json()["detail"]

    def test_roll_forward_success(self, client: TestClient, db: Session):
        """滚动模式延展：删除最早月 + 新增未来月"""
        create_test_user(db)
        token = get_token_for_user(client)
        kpi = create_test_kpi(db, kpi_code="BUDGET_ROLL_KPI")

        # 切滚动模式
        client.post(f"{self.BASE}/config", headers=auth_header(token),
                    json={"mode": "rolling", "rolling_months": 12})

        # 造12个月预算
        from app.models import KPIValue
        for m in range(1, 13):
            db.add(BudgetPlan(kpi_id=kpi.id, period=f"2026-{m:02d}",
                              budget_value=1000.0 + m, budget_year=2026,
                              budget_month=m, status="active"))
        db.commit()

        resp = client.post(f"{self.BASE}/roll-forward", headers=auth_header(token))
        assert resp.status_code == 200
        data = resp.json()
        assert "滚动预算已延展" in data["message"]
        assert len(data["rolled_kpis"]) == 1
        # 新增月份 = 当前月(08) + 12 = 明年08
        assert data["rolled_kpis"][0]["added_period"].startswith("2027-")

        # 最早月(2026-01)被删除
        from app.models import BudgetPlan as BP
        periods = [p.period for p in db.query(BP).filter(BP.kpi_id == kpi.id).all()]
        assert "2026-01" not in periods
        assert "2027-08" in periods


class TestBudgetComparison:
    """实际 vs 预测对比"""

    BASE = "/api/cma/budget"

    def _seed(self, db: Session):
        kpi = create_test_kpi(db, kpi_code="BUDGET_CMP_KPI")
        db.add(BudgetPlan(kpi_id=kpi.id, period="2026-06", budget_value=100.0,
                          budget_year=2026, budget_month=6, status="active"))
        from app.models import KPIValue
        db.add(KPIValue(kpi_id=kpi.id, period="2026-06", actual_value=80.0))
        db.commit()
        return kpi

    def test_comparison(self, client: TestClient, db: Session):
        """全KPI对比（固定预算12个月）"""
        create_test_user(db)
        token = get_token_for_user(client)
        self._seed(db)

        resp = client.get(f"{self.BASE}/comparison?year=2026", headers=auth_header(token))
        assert resp.status_code == 200
        data = resp.json()
        assert data["budget_mode"] == "fixed"
        assert len(data["periods"]) == 12
        # 6月有预算+实际
        june = [m for m in data["months_data"] if m["period"] == "2026-06"][0]
        assert june["budget_total"] == 100.0
        assert june["actual_total"] == 80.0
        assert june["deviation_rate"] == -20.0

    def test_comparison_kpi_detail(self, client: TestClient, db: Session):
        """单KPI对比"""
        create_test_user(db)
        token = get_token_for_user(client)
        kpi = self._seed(db)

        resp = client.get(f"{self.BASE}/comparison/kpi/{kpi.id}?year=2026",
                          headers=auth_header(token))
        assert resp.status_code == 200
        data = resp.json()
        assert data["kpi_code"] == "BUDGET_CMP_KPI"
        june = [d for d in data["data_points"] if d["period"] == "2026-06"][0]
        assert june["budget_value"] == 100.0
        assert june["actual_value"] == 80.0
        assert june["deviation_rate"] == -20.0

    def test_comparison_kpi_not_found(self, client: TestClient, db: Session):
        """KPI不存在 → 404"""
        create_test_user(db)
        token = get_token_for_user(client)
        resp = client.get(f"{self.BASE}/comparison/kpi/99999?year=2026",
                          headers=auth_header(token))
        assert resp.status_code == 404


class TestBudgetDeviationCheck:
    """预算偏差自动预警"""

    BASE = "/api/cma/budget"

    def _seed(self, db: Session):
        kpi = create_test_kpi(db, kpi_code="BUDGET_ALERT_KPI")
        db.add(BudgetPlan(kpi_id=kpi.id, period="2026-06", budget_value=100.0,
                          budget_year=2026, budget_month=6, status="active"))
        from app.models import KPIValue
        db.add(KPIValue(kpi_id=kpi.id, period="2026-06", actual_value=150.0))  # +50%
        db.commit()
        return kpi

    def test_deviation_check_generates_alert(self, client: TestClient, db: Session):
        """超阈值生成预警"""
        create_test_user(db)
        token = get_token_for_user(client)
        kpi = self._seed(db)

        resp = client.post(f"{self.BASE}/deviation-check", headers=auth_header(token),
                           json={"period": "2026-06", "threshold": 20})
        assert resp.status_code == 200
        data = resp.json()
        assert data["alerts_generated"] == 1
        assert data["alerts"][0]["deviation_rate"] == 50.0
        # 50% 不 >50，为 warning；>50% 才是 critical
        assert data["alerts"][0]["alert_level"] == "warning"

    def test_deviation_check_no_budget(self, client: TestClient, db: Session):
        """无预算数据 → 不生成预警"""
        create_test_user(db)
        token = get_token_for_user(client)

        resp = client.post(f"{self.BASE}/deviation-check", headers=auth_header(token),
                           json={"period": "2026-01"})
        assert resp.status_code == 200
        assert resp.json()["alerts_generated"] == 0

    def test_deviation_check_under_threshold(self, client: TestClient, db: Session):
        """未超阈值不生成预警"""
        create_test_user(db)
        token = get_token_for_user(client)
        kpi = create_test_kpi(db, kpi_code="BUDGET_ALERT_OK")
        db.add(BudgetPlan(kpi_id=kpi.id, period="2026-06", budget_value=100.0,
                          budget_year=2026, budget_month=6, status="active"))
        from app.models import KPIValue
        db.add(KPIValue(kpi_id=kpi.id, period="2026-06", actual_value=105.0))  # +5%
        db.commit()

        resp = client.post(f"{self.BASE}/deviation-check", headers=auth_header(token),
                           json={"period": "2026-06", "threshold": 20})
        assert resp.json()["alerts_generated"] == 0


class TestBudgetDeviationAlerts:
    """偏差预警记录查询/更新"""

    BASE = "/api/cma/budget"

    def _seed_alert(self, client, db: Session, token: str):
        kpi = create_test_kpi(db, kpi_code="BUDGET_DEV_ALERT")
        db.add(BudgetPlan(kpi_id=kpi.id, period="2026-06", budget_value=100.0,
                          budget_year=2026, budget_month=6, status="active"))
        from app.models import KPIValue
        db.add(KPIValue(kpi_id=kpi.id, period="2026-06", actual_value=200.0))
        db.commit()
        client.post(f"{self.BASE}/deviation-check", headers=auth_header(token),
                    json={"period": "2026-06"})
        return kpi

    def test_list_alerts(self, client: TestClient, db: Session):
        """预警列表"""
        create_test_user(db)
        token = get_token_for_user(client)
        self._seed_alert(client, db, token)

        resp = client.get(f"{self.BASE}/deviation-alerts", headers=auth_header(token))
        assert resp.status_code == 200
        assert resp.json()["total"] == 1
        alert = resp.json()["data"][0]
        assert alert["status"] == "open"
        assert alert["kpi_code"] == "BUDGET_DEV_ALERT"
        assert alert["alert_level"] == "critical"

    def test_list_alerts_filters(self, client: TestClient, db: Session):
        """按状态/等级过滤"""
        create_test_user(db)
        token = get_token_for_user(client)
        self._seed_alert(client, db, token)

        resp = client.get(f"{self.BASE}/deviation-alerts?status=open&alert_level=critical",
                          headers=auth_header(token))
        assert resp.json()["total"] == 1

        resp2 = client.get(f"{self.BASE}/deviation-alerts?status=resolved",
                           headers=auth_header(token))
        assert resp2.json()["total"] == 0

    def test_update_alert_resolve(self, client: TestClient, db: Session):
        """标记预警已解决"""
        create_test_user(db)
        token = get_token_for_user(client)
        self._seed_alert(client, db, token)

        alert_id = client.get(f"{self.BASE}/deviation-alerts",
                              headers=auth_header(token)).json()["data"][0]["id"]
        resp = client.put(f"{self.BASE}/deviation-alerts/{alert_id}",
                          headers=auth_header(token), json={"status": "resolved"})
        assert resp.status_code == 200

        list_resp = client.get(f"{self.BASE}/deviation-alerts",
                               headers=auth_header(token))
        assert list_resp.json()["data"][0]["status"] == "resolved"

    def test_update_alert_not_found(self, client: TestClient, db: Session):
        """更新不存在的预警 → 404"""
        create_test_user(db)
        token = get_token_for_user(client)
        resp = client.put(f"{self.BASE}/deviation-alerts/99999",
                          headers=auth_header(token), json={"status": "resolved"})
        assert resp.status_code == 404


class TestBudgetMethodComparison:
    """预算方法三选一对比"""

    BASE = "/api/cma/budget"

    def test_method_comparison_defaults(self, client: TestClient, db: Session):
        """默认参数返回三种方法"""
        create_test_user(db)
        token = get_token_for_user(client)

        resp = client.post(f"{self.BASE}/method-comparison", headers=auth_header(token),
                           json={})
        assert resp.status_code == 200
        data = resp.json()
        assert len(data["methods"]) == 3
        ids = {m["id"] for m in data["methods"]}
        assert ids == {"incremental", "zero_based", "flexible"}
        assert data["recommended"] == "zero_based"

    def test_method_comparison_custom(self, client: TestClient, db: Session):
        """自定义参数：增量预算结果 = 上月×(1+增幅)"""
        create_test_user(db)
        token = get_token_for_user(client)

        resp = client.post(f"{self.BASE}/method-comparison", headers=auth_header(token),
                           json={"entity": "bohai", "last_month_budget": 100,
                                 "current_revenue": 200, "increment_rate": 0.1})
        assert resp.status_code == 200
        data = resp.json()
        assert data["entity_name"] == "陕西博海科技（IT服务）"
        incremental = [m for m in data["methods"] if m["id"] == "incremental"][0]
        assert incremental["result_value"] == 110.0  # 100 × 1.1


class TestBudgetPermissions:
    """权限边界：business角色无访问权限"""

    BASE = "/api/cma/budget"

    def test_business_role_denied(self, client: TestClient, db: Session):
        """business用户访问预算 → 403"""
        import hashlib
        from app.models import User
        business = User(
            username="business_budget",
            password_hash=hashlib.sha256("pass123".encode()).hexdigest(),
            name="业务员",
            role="business",
        )
        db.add(business)
        db.commit()
        token = get_token_for_user(client, username="business_budget", password="pass123")

        resp = client.get(f"{self.BASE}/plans", headers=auth_header(token))
        assert resp.status_code == 403


class TestBudgetContract20260825:
    """2026-08-25修复后的接口契约测试
    覆盖今天发现的前后端契约缺口：
    1. plans返回dimension/unit字段（前端表格依赖）
    2. plans支持keyword搜索（前端搜索框依赖）
    3. versions列表返回完整字段
    4. 批量分解幂等性（重复执行不产生重复记录）
    """

    BASE = "/api/cma/budget"

    def test_plans_returns_dimension_and_unit(self, client: TestClient, db: Session):
        """契约：plans必须返回dimension/unit字段（否则前端维度列/单位列空白）"""
        create_test_user(db)
        token = get_token_for_user(client)
        kpi = create_test_kpi(db, kpi_code="CONTRACT_DIM_KPI", dimension="customer", unit="%")

        client.post(
            f"{self.BASE}/plans",
            headers=auth_header(token),
            json={"kpi_id": kpi.id, "period": "2026-06", "budget_value": 100.0},
        )

        resp = client.get(f"{self.BASE}/plans", headers=auth_header(token))
        assert resp.status_code == 200
        items = resp.json()["data"]
        assert len(items) >= 1
        # 关键：每条必须有dimension和unit
        for item in items:
            assert "dimension" in item, f"plans缺少dimension字段: {item}"
            assert "unit" in item, f"plans缺少unit字段: {item}"

    def test_plans_keyword_search(self, client: TestClient, db: Session):
        """契约：plans支持keyword按KPI名称模糊搜索（前端搜索框依赖）"""
        create_test_user(db)
        token = get_token_for_user(client)
        kpi1 = create_test_kpi(db, kpi_code="CONTRACT_KW_AAA", kpi_name="营业收入AAA")
        kpi2 = create_test_kpi(db, kpi_code="CONTRACT_KW_BBB", kpi_name="毛利率BBB")

        client.post(f"{self.BASE}/plans", headers=auth_header(token),
                    json={"kpi_id": kpi1.id, "period": "2026-06", "budget_value": 100.0})
        client.post(f"{self.BASE}/plans", headers=auth_header(token),
                    json={"kpi_id": kpi2.id, "period": "2026-06", "budget_value": 200.0})

        resp = client.get(f"{self.BASE}/plans", headers=auth_header(token), params={"keyword": "营业收入AAA"})
        assert resp.status_code == 200
        items = resp.json()["data"]
        assert len(items) == 1
        assert items[0]["kpi_code"] == "CONTRACT_KW_AAA"

    def test_versions_fields(self, client: TestClient, db: Session):
        """契约：versions列表返回version/status/kpi_count/total_budget字段"""
        create_test_user(db)
        token = get_token_for_user(client)
        kpi = create_test_kpi(db, kpi_code="CONTRACT_VER_FIELDS")

        client.post(f"{self.BASE}/plans", headers=auth_header(token),
                    json={"kpi_id": kpi.id, "period": "2026-06", "budget_value": 500.0})

        resp = client.get(f"{self.BASE}/versions", headers=auth_header(token))
        assert resp.status_code == 200
        versions = resp.json()
        assert len(versions) >= 1
        for v in versions:
            assert "version" in v
            assert "status" in v
            assert "kpi_count" in v
            assert "total_budget" in v

    def test_batch_decompose_idempotent(self, client: TestClient, db: Session):
        """契约：批量分解幂等 — 重复执行不产生重复记录（同KPI+期间+版本唯一）"""
        create_test_user(db)
        token = get_token_for_user(client)
        kpi = create_test_kpi(db, kpi_code="CONTRACT_DECOMP")

        # 先创建年度预算（period=YYYY-00 年度行，批量分解只取年度行 — 幂等契约）
        client.post(f"{self.BASE}/plans", headers=auth_header(token),
                    json={"kpi_id": kpi.id, "period": "2026-00", "budget_value": 12000.0, "budget_year": 2026, "budget_month": 0})

        # 第一次批量分解
        resp1 = client.post(f"{self.BASE}/auto-decompose", headers=auth_header(token),
                            json={"year": 2026, "method": "equal"})
        assert resp1.status_code == 200

        # 第二次批量分解（幂等：应该更新而非新增）
        resp2 = client.post(f"{self.BASE}/auto-decompose", headers=auth_header(token),
                            json={"year": 2026, "method": "equal"})
        assert resp2.status_code == 200

        # 检查无重复（同KPI+期间+版本）
        from sqlalchemy import func
        rows = db.query(BudgetPlan.kpi_id, BudgetPlan.period, BudgetPlan.version,
                        func.count().label("cnt")).group_by(
            BudgetPlan.kpi_id, BudgetPlan.period, BudgetPlan.version).having(
            func.count() > 1).all()
        assert len(rows) == 0, f"存在重复预算记录: {rows}"


class TestBudgetPlansEdges:
    """预算计划CRUD异常路径与列表过滤（补齐未覆盖端点）"""

    BASE = "/api/cma/budget"

    def test_create_plan_kpi_not_found(self, client: TestClient, db: Session):
        """KPI不存在 → 404"""
        create_test_user(db)
        token = get_token_for_user(client)

        resp = client.post(f"{self.BASE}/plans", headers=auth_header(token),
                           json={"kpi_id": 99999, "period": "2026-06", "budget_value": 100.0})
        assert resp.status_code == 404

    def test_update_plan_not_found(self, client: TestClient, db: Session):
        """更新不存在的计划 → 404"""
        create_test_user(db)
        token = get_token_for_user(client)

        resp = client.put(f"{self.BASE}/plans/99999", headers=auth_header(token),
                          json={"budget_value": 100.0})
        assert resp.status_code == 404

    def test_delete_plan_not_found(self, client: TestClient, db: Session):
        """删除不存在的计划 → 404"""
        create_test_user(db)
        token = get_token_for_user(client)

        resp = client.delete(f"{self.BASE}/plans/99999", headers=auth_header(token))
        assert resp.status_code == 404

    def test_list_plans_filters(self, client: TestClient, db: Session):
        """列表按 year/period/version 过滤"""
        create_test_user(db)
        token = get_token_for_user(client)
        kpi = create_test_kpi(db, kpi_code="PLAN_FILTER_KPI")

        client.post(f"{self.BASE}/plans", headers=auth_header(token),
                    json={"kpi_id": kpi.id, "period": "2026-06", "budget_value": 100.0})

        assert client.get(f"{self.BASE}/plans", headers=auth_header(token),
                          params={"year": 2026}).json()["total"] == 1
        assert client.get(f"{self.BASE}/plans", headers=auth_header(token),
                          params={"year": 2025}).json()["total"] == 0
        assert client.get(f"{self.BASE}/plans", headers=auth_header(token),
                          params={"period": "2026-06"}).json()["total"] == 1
        assert client.get(f"{self.BASE}/plans", headers=auth_header(token),
                          params={"version": "v1.0"}).json()["total"] == 1


class TestBudgetAutoDecomposeEdges:
    """自动分解异常路径与加权模式"""

    BASE = "/api/cma/budget"

    def test_auto_decompose_single_missing_budget(self, client: TestClient, db: Session):
        """单KPI模式缺 annual_budget → 400"""
        create_test_user(db)
        token = get_token_for_user(client)
        kpi = create_test_kpi(db, kpi_code="BUDGET_DECOMP_MISSING_BUDGET")

        resp = client.post(f"{self.BASE}/auto-decompose", headers=auth_header(token),
                           json={"kpi_id": kpi.id, "year": 2026})
        assert resp.status_code == 400

    def test_auto_decompose_kpi_not_found(self, client: TestClient, db: Session):
        """单KPI模式KPI不存在 → 404"""
        create_test_user(db)
        token = get_token_for_user(client)

        resp = client.post(f"{self.BASE}/auto-decompose", headers=auth_header(token),
                           json={"kpi_id": 99999, "annual_budget": 120000, "year": 2026})
        assert resp.status_code == 404

    def test_auto_decompose_single_weighted(self, client: TestClient, db: Session):
        """单KPI加权分解（去年各月实际值作为权重）"""
        create_test_user(db)
        token = get_token_for_user(client)
        kpi = create_test_kpi(db, kpi_code="BUDGET_DECOMP_WEIGHTED")
        db.add(KPIValue(kpi_id=kpi.id, period="2025-01", actual_value=10.0))
        db.add(KPIValue(kpi_id=kpi.id, period="2025-02", actual_value=20.0))
        db.commit()

        resp = client.post(f"{self.BASE}/auto-decompose", headers=auth_header(token),
                           json={"kpi_id": kpi.id, "annual_budget": 120000, "year": 2026,
                                 "method": "weighted"})
        assert resp.status_code == 200
        data = resp.json()
        assert data["method"] == "weighted"
        assert len(data["monthly_budgets"]) == 12


class TestBudgetVersionSubmitDiff:
    """版本提交 + 版本差异对比（补齐未覆盖端点）"""

    BASE = "/api/cma/budget"

    def test_submit_version(self, client: TestClient, db: Session):
        """提交版本审批：active → submitted"""
        create_test_user(db)
        token = get_token_for_user(client)
        kpi = create_test_kpi(db, kpi_code="VER_SUBMIT_KPI")
        client.post(f"{self.BASE}/plans", headers=auth_header(token),
                    json={"kpi_id": kpi.id, "period": "2026-06", "budget_value": 10000.0})

        resp = client.post(f"{self.BASE}/versions/submit", headers=auth_header(token),
                           json={"version": "v1.0"})
        assert resp.status_code == 200
        data = resp.json()
        assert data["success"] is True
        assert data["status"] == "submitted"
        assert data["count"] == 1

    def test_submit_version_not_found(self, client: TestClient, db: Session):
        """提交不存在的版本 → 404"""
        create_test_user(db)
        token = get_token_for_user(client)

        resp = client.post(f"{self.BASE}/versions/submit", headers=auth_header(token),
                           json={"version": "v9.9"})
        assert resp.status_code == 404

    def test_approve_version_not_found(self, client: TestClient, db: Session):
        """审批不存在的版本 → 404"""
        create_test_user(db)
        token = get_token_for_user(client)

        resp = client.post(f"{self.BASE}/versions/approve", headers=auth_header(token),
                           json={"version": "v9.9", "action": "approved"})
        assert resp.status_code == 404

    def test_diff_versions(self, client: TestClient, db: Session):
        """版本差异对比：v1.0 vs v2.0 逐KPI差异"""
        create_test_user(db)
        token = get_token_for_user(client)
        kpi = create_test_kpi(db, kpi_code="VER_DIFF_KPI")

        client.post(f"{self.BASE}/plans", headers=auth_header(token),
                    json={"kpi_id": kpi.id, "period": "2026-06", "budget_value": 100.0,
                          "version": "v1.0"})
        client.post(f"{self.BASE}/plans", headers=auth_header(token),
                    json={"kpi_id": kpi.id, "period": "2026-06", "budget_value": 150.0,
                          "version": "v2.0"})

        resp = client.post(f"{self.BASE}/versions/diff", headers=auth_header(token),
                           json={"version_a": "v1.0", "version_b": "v2.0", "year": 2026})
        assert resp.status_code == 200
        data = resp.json()
        assert data["summary"]["changed_count"] == 1
        assert data["summary"]["total_a"] == 100.0
        assert data["summary"]["total_b"] == 150.0
        assert data["diffs"][0]["version_a"] == 100.0
        assert data["diffs"][0]["version_b"] == 150.0

    def test_diff_versions_missing_params(self, client: TestClient, db: Session):
        """缺少 version_a/version_b → 400"""
        create_test_user(db)
        token = get_token_for_user(client)

        resp = client.post(f"{self.BASE}/versions/diff", headers=auth_header(token),
                           json={"year": 2026})
        assert resp.status_code == 400

    def test_versions_year_filter(self, client: TestClient, db: Session):
        """版本列表按年份过滤"""
        create_test_user(db)
        token = get_token_for_user(client)
        kpi = create_test_kpi(db, kpi_code="VER_YEAR_KPI")
        client.post(f"{self.BASE}/plans", headers=auth_header(token),
                    json={"kpi_id": kpi.id, "period": "2026-06", "budget_value": 10000.0})

        assert len(client.get(f"{self.BASE}/versions", headers=auth_header(token),
                              params={"year": 2026}).json()) >= 1
        assert client.get(f"{self.BASE}/versions", headers=auth_header(token),
                          params={"year": 2030}).json() == []


class TestBudgetApplyMethod:
    """预算方法落地 apply-method（补齐未覆盖端点）"""

    BASE = "/api/cma/budget"

    def _seed_core_kpis(self, db: Session):
        for code in ["F_REVENUE", "F_NET_PROFIT", "F_COST_RATIO", "F_GROSS_MARGIN"]:
            create_test_kpi(db, kpi_code=code, kpi_name=code)

    def test_apply_method_zero_based(self, client: TestClient, db: Session):
        """零基预算方法落地到年度预算"""
        create_test_user(db)
        token = get_token_for_user(client)
        self._seed_core_kpis(db)

        resp = client.post(f"{self.BASE}/apply-method", headers=auth_header(token),
                           json={"method": "zero_based", "year": 2026})
        assert resp.status_code == 200
        data = resp.json()
        assert data["method"] == "zero_based"
        assert data["total_budget"] == 71.4
        assert len(data["applied"]) == 4
        codes = {a["kpi_code"] for a in data["applied"]}
        assert codes == {"F_REVENUE", "F_NET_PROFIT", "F_COST_RATIO", "F_GROSS_MARGIN"}
        revenue = [a for a in data["applied"] if a["kpi_code"] == "F_REVENUE"][0]
        assert revenue["budget_value"] == 71.4

    def test_apply_method_unknown(self, client: TestClient, db: Session):
        """未知预算方法 → 400"""
        create_test_user(db)
        token = get_token_for_user(client)

        resp = client.post(f"{self.BASE}/apply-method", headers=auth_header(token),
                           json={"method": "bogus"})
        assert resp.status_code == 400

    def test_apply_method_no_kpis(self, client: TestClient, db: Session):
        """无核心KPI可应用 → 400"""
        create_test_user(db)
        token = get_token_for_user(client)

        resp = client.post(f"{self.BASE}/apply-method", headers=auth_header(token),
                           json={"method": "zero_based", "year": 2026})
        assert resp.status_code == 400
        assert "未找到" in resp.json()["detail"]


class TestBudgetSyncCashPlans:
    """预算→现金流联动（补齐未覆盖端点）"""

    BASE = "/api/cma/budget"

    def test_sync_cash_plans_create_and_update(self, client: TestClient, db: Session):
        """按预算KPI生成/更新收付款计划（upsert幂等）"""
        create_test_user(db)
        token = get_token_for_user(client)
        kpi_rev = create_test_kpi(db, kpi_code="SYNC_REV_KPI", kpi_name="营业收入")
        kpi_pay = create_test_kpi(db, kpi_code="SYNC_PAY_KPI", kpi_name="费用总额")

        client.post(f"{self.BASE}/plans", headers=auth_header(token),
                    json={"kpi_id": kpi_rev.id, "period": "2026-06", "budget_value": 1000.0})
        client.post(f"{self.BASE}/plans", headers=auth_header(token),
                    json={"kpi_id": kpi_pay.id, "period": "2026-06", "budget_value": 500.0})

        resp = client.post(f"{self.BASE}/sync-cash-plans", headers=auth_header(token))
        assert resp.status_code == 200
        data = resp.json()
        assert data["created"] == 2
        assert data["updated"] == 0

        # 再次执行 → 更新而非新增
        resp2 = client.post(f"{self.BASE}/sync-cash-plans", headers=auth_header(token))
        assert resp2.status_code == 200
        assert resp2.json()["created"] == 0
        assert resp2.json()["updated"] == 2

        plans = db.query(CashPlan).all()
        types = {p.plan_type for p in plans}
        assert types == {"receive", "pay"}


class TestBudgetGenerateCandidates:
    """KPI→预算候选列表（补齐未覆盖端点）"""

    BASE = "/api/cma/budget"

    def test_kpi_budget_candidates(self, client: TestClient, db: Session):
        """按类型分类返回预算建议（降本/增收/能力/系统）"""
        create_test_user(db)
        token = get_token_for_user(client)

        kpi_cost = create_test_kpi(db, kpi_code="CAND_COST", category="cost_control", target_value=50.0)
        db.add(KPIValue(kpi_id=kpi_cost.id, period="2026-06", actual_value=80.0))
        create_test_kpi(db, kpi_code="CAND_REV", category="revenue_growth", target_value=100.0)
        create_test_kpi(db, kpi_code="CAND_CAP", category="talent_pipeline", target_value=100.0)
        create_test_kpi(db, kpi_code="CAND_SYS", category="supply_chain", target_value=100.0)
        db.commit()

        resp = client.get(f"{self.BASE}/kpi-budget-candidates", headers=auth_header(token),
                          params={"year": 2026})
        assert resp.status_code == 200
        data = resp.json()["data"]
        assert set(data.keys()) == {"cost_reduction", "revenue_growth", "capability", "system"}

        assert len(data["cost_reduction"]) == 1
        assert data["cost_reduction"][0]["suggested_budget"] == 9.0  # (80-50)*0.3
        assert data["revenue_growth"][0]["suggested_budget"] == 20.0  # 100*0.2
        assert data["capability"][0]["suggested_budget"] == 20000.0  # 2000*10
        assert data["system"][0]["suggested_budget"] == 15.0  # 100*0.15

    def test_generate_from_kpis(self, client: TestClient, db: Session):
        """从选中KPI生成预算科目（修复source_type/source_kpi_id/calc_logic字段后完整流程）"""
        create_test_user(db)
        token = get_token_for_user(client)
        kpi = create_test_kpi(db, kpi_code="GEN_KPI_01")

        resp = client.post(
            f"{self.BASE}/generate-from-kpis",
            headers=auth_header(token),
            json={
                "year": 2026,
                "month": 7,
                "version": "v1.0",
                "items": [
                    {"kpi_id": kpi.id, "budget_amount": 30000.0,
                     "calc_logic": "目标值100000×0.3=30000", "calc_type": "增收类"}
                ],
            },
        )
        assert resp.status_code == 200
        body = resp.json()
        assert body["total_amount"] == 30000.0
        assert body["items"][0]["kpi_code"] == "GEN_KPI_01"

        # 验证落库字段
        plan = db.query(BudgetPlan).filter(BudgetPlan.kpi_id == kpi.id).first()
        assert plan is not None
        assert plan.source_type == "kpi_generated"
        assert plan.source_kpi_id == kpi.id
        assert plan.calc_logic == "目标值100000×0.3=30000"
        assert "增收类" in plan.remark

    def test_generate_from_kpis_empty_items(self, client: TestClient, db: Session):
        """未选择KPI → 400"""
        create_test_user(db)
        token = get_token_for_user(client)
        resp = client.post(
            f"{self.BASE}/generate-from-kpis",
            headers=auth_header(token),
            json={"year": 2026, "month": 7, "items": []},
        )
        assert resp.status_code == 400

    def test_generate_from_kpis_updates_existing(self, client: TestClient, db: Session):
        """同一KPI+期间+版本已存在 → 更新预算值而非新增"""
        create_test_user(db)
        token = get_token_for_user(client)
        kpi = create_test_kpi(db, kpi_code="GEN_KPI_02")

        # 先创建一条预算
        resp1 = client.post(
            f"{self.BASE}/generate-from-kpis",
            headers=auth_header(token),
            json={"year": 2026, "month": 7, "version": "v1.0",
                  "items": [{"kpi_id": kpi.id, "budget_amount": 10000.0}]},
        )
        assert resp1.status_code == 200
        first_id = resp1.json()["items"][0]["plan_id"]

        # 再次生成 → 更新而非新增
        resp2 = client.post(
            f"{self.BASE}/generate-from-kpis",
            headers=auth_header(token),
            json={"year": 2026, "month": 7, "version": "v1.0",
                  "items": [{"kpi_id": kpi.id, "budget_amount": 25000.0}]},
        )
        assert resp2.status_code == 200
        assert resp2.json()["items"][0]["plan_id"] == first_id

        plans = db.query(BudgetPlan).filter(BudgetPlan.kpi_id == kpi.id).all()
        assert len(plans) == 1
        assert plans[0].budget_value == 25000.0


class TestBudgetDeviationReportEdges:
    """偏差报告边界：under_budget / 维度过滤"""

    BASE = "/api/cma/budget"

    def test_deviation_report_under_budget(self, client: TestClient, db: Session):
        """实际低于预算 → 结余统计"""
        create_test_user(db)
        token = get_token_for_user(client)
        kpi = create_test_kpi(db, kpi_code="BUDGET_DEV_UNDER")
        db.add(BudgetPlan(kpi_id=kpi.id, period="2026-06", budget_value=100.0,
                          budget_year=2026, budget_month=6, status="active"))
        db.add(KPIValue(kpi_id=kpi.id, period="2026-06", actual_value=80.0))
        db.commit()

        resp = client.get(f"{self.BASE}/deviation-report?year=2026&month=6",
                          headers=auth_header(token))
        assert resp.status_code == 200
        summary = resp.json()["summary"]
        assert summary["over_budget"] == 0
        assert summary["under_budget"] == 1

    def test_deviation_report_dimension_filter(self, client: TestClient, db: Session):
        """按维度过滤KPI"""
        create_test_user(db)
        token = get_token_for_user(client)
        kpi = create_test_kpi(db, kpi_code="BUDGET_DEV_DIM", dimension="customer")
        db.add(BudgetPlan(kpi_id=kpi.id, period="2026-06", budget_value=100.0,
                          budget_year=2026, budget_month=6, status="active"))
        db.add(KPIValue(kpi_id=kpi.id, period="2026-06", actual_value=120.0))
        db.commit()

        resp = client.get(f"{self.BASE}/deviation-report?year=2026&month=6&dimension=customer",
                          headers=auth_header(token))
        assert resp.json()["summary"]["total_kpis"] == 1

        resp2 = client.get(f"{self.BASE}/deviation-report?year=2026&month=6&dimension=finance",
                           headers=auth_header(token))
        assert resp2.json()["summary"]["total_kpis"] == 0


class TestBudgetRollForwardEdges:
    """滚动延展边界：固定模式 / 配置异常"""

    BASE = "/api/cma/budget"

    def test_roll_forward_fixed_mode(self, client: TestClient, db: Session):
        """已配置固定预算模式 → 400"""
        create_test_user(db)
        token = get_token_for_user(client)
        client.post(f"{self.BASE}/config", headers=auth_header(token),
                    json={"mode": "fixed"})

        resp = client.post(f"{self.BASE}/roll-forward", headers=auth_header(token))
        assert resp.status_code == 400
        assert "固定预算模式" in resp.json()["detail"]

    def test_roll_forward_bad_config(self, client: TestClient, db: Session):
        """预算模式配置为非法JSON → 400"""
        create_test_user(db)
        token = get_token_for_user(client)
        db.add(SystemConfig(config_key="budget_mode", config_value="not-json"))
        db.commit()

        resp = client.post(f"{self.BASE}/roll-forward", headers=auth_header(token))
        assert resp.status_code == 400
        assert "配置异常" in resp.json()["detail"]


class TestBudgetComparisonRolling:
    """滚动预算下的实际vs预测对比"""

    BASE = "/api/cma/budget"

    def test_comparison_rolling_mode(self, client: TestClient, db: Session):
        """滚动模式下只返回 rolling_months 个月份"""
        create_test_user(db)
        token = get_token_for_user(client)
        client.post(f"{self.BASE}/config", headers=auth_header(token),
                    json={"mode": "rolling", "rolling_months": 3})

        resp = client.get(f"{self.BASE}/comparison", headers=auth_header(token))
        assert resp.status_code == 200
        data = resp.json()
        assert data["budget_mode"] == "rolling"
        assert len(data["periods"]) == 3


class TestBudgetDeviationCheckEdges:
    """偏差预警边界：critical / 幂等 / 零预算跳过"""

    BASE = "/api/cma/budget"

    def test_deviation_check_critical(self, client: TestClient, db: Session):
        """偏差超过50% → critical"""
        create_test_user(db)
        token = get_token_for_user(client)
        kpi = create_test_kpi(db, kpi_code="BUDGET_ALERT_CRIT")
        db.add(BudgetPlan(kpi_id=kpi.id, period="2026-06", budget_value=100.0,
                          budget_year=2026, budget_month=6, status="active"))
        db.add(KPIValue(kpi_id=kpi.id, period="2026-06", actual_value=200.0))  # +100%
        db.commit()

        resp = client.post(f"{self.BASE}/deviation-check", headers=auth_header(token),
                           json={"period": "2026-06", "threshold": 20})
        assert resp.status_code == 200
        assert resp.json()["alerts_generated"] == 1
        assert resp.json()["alerts"][0]["alert_level"] == "critical"

    def test_deviation_check_idempotent(self, client: TestClient, db: Session):
        """重复检查不重复生成预警"""
        create_test_user(db)
        token = get_token_for_user(client)
        kpi = create_test_kpi(db, kpi_code="BUDGET_ALERT_IDEM")
        db.add(BudgetPlan(kpi_id=kpi.id, period="2026-06", budget_value=100.0,
                          budget_year=2026, budget_month=6, status="active"))
        db.add(KPIValue(kpi_id=kpi.id, period="2026-06", actual_value=150.0))
        db.commit()

        resp1 = client.post(f"{self.BASE}/deviation-check", headers=auth_header(token),
                            json={"period": "2026-06", "threshold": 20})
        assert resp1.json()["alerts_generated"] == 1

        resp2 = client.post(f"{self.BASE}/deviation-check", headers=auth_header(token),
                            json={"period": "2026-06", "threshold": 20})
        assert resp2.json()["alerts_generated"] == 0

    def test_deviation_check_zero_budget_skip(self, client: TestClient, db: Session):
        """预算为0 → 跳过不预警"""
        create_test_user(db)
        token = get_token_for_user(client)
        kpi = create_test_kpi(db, kpi_code="BUDGET_ALERT_ZERO")
        db.add(BudgetPlan(kpi_id=kpi.id, period="2026-06", budget_value=0.0,
                          budget_year=2026, budget_month=6, status="active"))
        db.add(KPIValue(kpi_id=kpi.id, period="2026-06", actual_value=50.0))
        db.commit()

        resp = client.post(f"{self.BASE}/deviation-check", headers=auth_header(token),
                           json={"period": "2026-06", "threshold": 20})
        assert resp.json()["alerts_generated"] == 0


class TestBudgetDeviationAlertsEdges:
    """偏差预警列表按 kpi_id/period 过滤"""

    BASE = "/api/cma/budget"

    def test_list_alerts_kpi_period_filter(self, client: TestClient, db: Session):
        """按KPI与期间过滤预警"""
        create_test_user(db)
        token = get_token_for_user(client)
        kpi = create_test_kpi(db, kpi_code="BUDGET_DEV_ALERT_FLT")
        db.add(BudgetPlan(kpi_id=kpi.id, period="2026-06", budget_value=100.0,
                          budget_year=2026, budget_month=6, status="active"))
        db.add(KPIValue(kpi_id=kpi.id, period="2026-06", actual_value=200.0))
        db.commit()
        client.post(f"{self.BASE}/deviation-check", headers=auth_header(token),
                    json={"period": "2026-06"})

        resp = client.get(f"{self.BASE}/deviation-alerts", headers=auth_header(token),
                          params={"kpi_id": kpi.id, "period": "2026-06"})
        assert resp.json()["total"] == 1

        resp2 = client.get(f"{self.BASE}/deviation-alerts", headers=auth_header(token),
                           params={"kpi_id": kpi.id, "period": "2026-01"})
        assert resp2.json()["total"] == 0
