"""预测模拟模块测试 — CVP/投资决策/敏感性/情景/现金流/实物期权/增长质量 覆盖 predict.py 主要端点(纯计算 + 少量DB): cvp / investment / sensitivity / scenario / cvp-detailed / cash-forecast / cash-forecast/history / accuracy / scenario-suggestions / scenario-suggestion/generate / real-option / growth-quality """ import pytest from fastapi.testclient import TestClient from sqlalchemy.orm import Session from app.models import CashForecast, ForecastAccuracy from tests.conftest import create_test_user, get_token_for_user, auth_header BASE = "/api/cma/predict" class TestCvp: def test_cvp_basic(self, client: TestClient, db: Session): """本量利分析""" resp = client.post(f"{BASE}/cvp", json={ "unit_price": 100, "unit_variable_cost": 60, "fixed_cost": 100000, }) assert resp.status_code == 200 data = resp.json() assert data["contribution_margin"] == 40 assert data["bep_units"] == 2500 # 100000/40 assert data["bep_revenue"] == 250000.0 def test_cvp_with_target_profit(self, client: TestClient, db: Session): """含目标利润""" resp = client.post(f"{BASE}/cvp", json={ "unit_price": 100, "unit_variable_cost": 60, "fixed_cost": 100000, "target_profit": 20000, }) assert resp.status_code == 200 # 目标销量 = (100000+20000)/40 = 3000 assert resp.json()["target_units"] == 3000 def test_cvp_bad_input(self, client: TestClient, db: Session): """非法输入 → 400""" resp = client.post(f"{BASE}/cvp", json={"unit_price": "abc"}) assert resp.status_code == 400 class TestInvestment: def test_investment_npv_irr(self, client: TestClient, db: Session): """NPV/IRR计算""" resp = client.post(f"{BASE}/investment", json={ "initial_investment": 10000, "discount_rate": 10, "cash_flows": [3000, 3000, 3000, 3000, 3000], }) assert resp.status_code == 200 data = resp.json() assert "npv_analysis" in data assert "irr_analysis" in data def test_investment_empty_cashflows(self, client: TestClient, db: Session): """空现金流 → 400""" resp = client.post(f"{BASE}/investment", json={"initial_investment": 100}) assert resp.status_code == 400 class TestSensitivityScenario: def test_sensitivity(self, client: TestClient, db: Session): """敏感性分析""" resp = client.post(f"{BASE}/sensitivity", json={ "base_revenue": 1000, "base_cost": 700, }) assert resp.status_code == 200 data = resp.json() assert data["base_profit"] == 300.0 assert len(data["factors"]) > 0 assert "revenue_sensitivity" in data["factors"][0] def test_scenario(self, client: TestClient, db: Session): """情景模拟""" resp = client.post(f"{BASE}/scenario", json={ "optimistic": {"revenue": 120, "cost": 60}, "pessimistic": {"revenue": 80, "cost": 70}, "base": {"revenue": 100, "cost": 65}, }) assert resp.status_code == 200 data = resp.json() scenarios = {s["scenario"] for s in data["scenarios"]} assert scenarios == {"乐观", "中性", "悲观"} def test_scenario_missing(self, client: TestClient, db: Session): """缺情景 → 400""" resp = client.post(f"{BASE}/scenario", json={"base": {"revenue": 100}}) assert resp.status_code == 400 def test_cvp_detailed(self, client: TestClient, db: Session): """CVP详细分析(含保本图/方案推演)""" resp = client.post(f"{BASE}/cvp-detailed", json={ "fixed_cost": 617, "variable_cost_rate": 0.4862, "unit_price": 228, "current_volume": 5300, }) assert resp.status_code == 200 data = resp.json() assert data["contribution_margin_rate"] == 51.38 assert len(data["scenarios"]) == 3 assert len(data["chart_data"]) > 0 assert data["breakeven_units"] > 0 class TestCashForecast: def test_cash_forecast_no_data(self, client: TestClient, db: Session): """无历史KPI时仍返回预测(退化处理)""" resp = client.post(f"{BASE}/cash-forecast", json={ "entity_id": 1, "days": 10, "current_cash": 50.0, }) assert resp.status_code == 200 data = resp.json() assert "daily" in data or "forecast" in data or "days" in data def test_cash_forecast_history_empty(self, client: TestClient, db: Session): """历史为空""" create_test_user(db) token = get_token_for_user(client) resp = client.get(f"{BASE}/cash-forecast/history", headers=auth_header(token)) assert resp.status_code == 200 assert resp.json()["data"] == [] def test_accuracy_empty(self, client: TestClient, db: Session): """准确率为空""" create_test_user(db) token = get_token_for_user(client) resp = client.get(f"{BASE}/accuracy", headers=auth_header(token)) assert resp.status_code == 200 data = resp.json() assert data["data"] == [] assert data["summary"]["total_periods"] == 0 class TestScenarioSuggestions: def test_suggestions_all(self, client: TestClient, db: Session): """获取全部情景建议""" resp = client.get(f"{BASE}/scenario-suggestions") assert resp.status_code == 200 types = {s["alert_type"] for s in resp.json()["data"]} assert types == {"cash_low", "cash_critical", "cost_high", "revenue_drop"} def test_suggestions_filter(self, client: TestClient, db: Session): """按类型过滤""" resp = client.get(f"{BASE}/scenario-suggestions?alert_type=cash_low") assert resp.status_code == 200 data = resp.json()["data"] assert len(data) == 1 assert data[0]["alert_type"] == "cash_low" def test_generate_suggestion(self, client: TestClient, db: Session): """动态生成建议""" resp = client.post(f"{BASE}/scenario-suggestion/generate", json={ "alert_type": "cost_high", "kpi_name": "销售费用率", }) assert resp.status_code == 200 assert "title" in resp.json() or "description" in resp.json() class TestRealOption: def test_bs_call_expansion(self, client: TestClient, db: Session): """BSM看涨(扩张期权)""" resp = client.post(f"{BASE}/real-option", json={ "opt_type": "expansion", "model": "bs", "S0": 100, "X": 80, "t": 3, "r": 0.0174, "sigma": 0.30, }) assert resp.status_code == 200 data = resp.json() assert data["option_value"] > 0 assert "intermediate" in data assert "sensitivity" in data def test_bs_put_abandon(self, client: TestClient, db: Session): """BSM看跌(放弃期权)""" resp = client.post(f"{BASE}/real-option", json={ "opt_type": "abandon", "model": "bs", "S0": 100, "X": 120, "t": 2, "r": 0.05, "sigma": 0.40, }) assert resp.status_code == 200 assert resp.json()["option_value"] >= 0 def test_binomial_delay(self, client: TestClient, db: Session): """二叉树(延迟期权)""" resp = client.post(f"{BASE}/real-option", json={ "opt_type": "delay", "model": "binomial", "S0": 100, "X": 80, "t": 3, "r": 0.05, "sigma": 0.30, "n_steps": 50, }) assert resp.status_code == 200 assert resp.json()["option_value"] > 0 def test_binomial_american_put(self, client: TestClient, db: Session): """二叉树美式看跌(放弃期权)""" resp = client.post(f"{BASE}/real-option", json={ "opt_type": "abandon", "model": "binomial", "S0": 100, "X": 120, "t": 2, "r": 0.05, "sigma": 0.40, "n_steps": 30, }) assert resp.status_code == 200 assert resp.json()["option_value"] >= 0 def test_real_option_invalid_params(self, client: TestClient, db: Session): """非法参数 → 400""" resp = client.post(f"{BASE}/real-option", json={"S0": -5}) assert resp.status_code == 400 def test_real_option_unsupported_comb(self, client: TestClient, db: Session): """不支持的组合 → 400""" resp = client.post(f"{BASE}/real-option", json={"opt_type": "weird", "model": "bs"}) assert resp.status_code == 400 class TestGrowthQuality: def test_growth_quality_entity1(self, client: TestClient, db: Session): """酣客增长质量""" resp = client.post(f"{BASE}/growth-quality", json={"entity_id": 1}) assert resp.status_code == 200 data = resp.json() assert "overall" in data or "level" in data or "score" in data def test_growth_quality_entity2(self, client: TestClient, db: Session): """博海增长质量""" resp = client.post(f"{BASE}/growth-quality", json={"entity_id": 2}) assert resp.status_code == 200 data = resp.json() assert data["entity_name"] == "陕西博海网络科技" def test_growth_quality_custom(self, client: TestClient, db: Session): """自定义企业数据(低分场景)""" resp = client.post(f"{BASE}/growth-quality", json={ "entity_id": 99, "name": "测试企业", "rebateRate": 90, "trueGrossMargin": 5, "cashRatio": 3, "expenseGrowthRate": 3, "revenueGrowthRate": 1, "mgmtRatio": 500, }) assert resp.status_code == 200 data = resp.json() assert data["entity_name"] == "测试企业" assert data["overall"] < 2 assert data["level_type"] == "danger" # 低分诊断 assert "越增长越重" in data["diagnosis"]