"""Phase 2 Service 层测试 — 财务校验/协议监控/弱信号关联/健康度/及时性追踪。""" import uuid as uuid_mod import pytest from sqlalchemy.ext.asyncio import AsyncSession from app.services.financial_validator import validate_financial_data from app.services.agreement_monitor import check_clause_triggers from app.services.signal_correlator import correlate_signals from app.services.health_calculator import calculate_health_score, determine_trend from app.services.report_tracker import compute_timeliness from app.models.financial_data import FinancialData from app.models.agreement import InvestmentAgreement from app.models.report import MonthlyReport from app.models.company import Company from app.models.user import User from app.models.tenant import Tenant from tests.conftest import test_session_factory @pytest.fixture async def db_session(): """创建数据库会话。""" async with test_session_factory() as session: yield session await session.rollback() @pytest.fixture async def seed_tenant_company(db_session: AsyncSession): """创建测试租户和企业,返回 (tenant_id, company_id, user_id)。 每次调用使用唯一邮箱避免唯一约束冲突。 """ unique = uuid_mod.uuid4().hex[:8] tenant = Tenant(name=f"service测试机构_{unique}") db_session.add(tenant) await db_session.flush() user = User( email=f"svc_{unique}@example.com", name="测试用户", role="investor", tenant_id=tenant.id, password_hash="fake_hash", is_active=True, ) db_session.add(user) await db_session.flush() company = Company( name=f"service测试公司_{unique}", industry="AI", tenant_id=tenant.id, ) db_session.add(company) await db_session.flush() await db_session.commit() return str(tenant.id), str(company.id), str(user.id) class TestFinancialValidator: """财务数据校验服务测试。""" @pytest.mark.asyncio async def test_validate_no_data(self, db_session: AsyncSession): """无财务数据时应返回可信度 0 和提示。""" result = await validate_financial_data(db_session, "nonexistent-id", 2025, 6) assert result["credibility_score"] == 0.0 assert "无财务数据" in result["issues"] @pytest.mark.asyncio async def test_validate_balance_sheet_balanced(self, db_session: AsyncSession, seed_tenant_company): """资产负债表平衡时应通过校验。""" _, company_id, _ = seed_tenant_company fd = FinancialData( company_id=company_id, statement_type="balance_sheet", period_year=2025, period_month=6, data_json={"total_assets": 1000000, "total_liabilities": 600000, "total_equity": 400000}, ) db_session.add(fd) await db_session.commit() result = await validate_financial_data(db_session, company_id, 2025, 6) assert result["checks_total"] >= 1 assert result["checks_passed"] >= 1 assert result["credibility_score"] > 0 @pytest.mark.asyncio async def test_validate_balance_sheet_unbalanced(self, db_session: AsyncSession, seed_tenant_company): """资产负债表不平时应报告问题。""" _, company_id, _ = seed_tenant_company fd = FinancialData( company_id=company_id, statement_type="balance_sheet", period_year=2025, period_month=6, data_json={"total_assets": 1000000, "total_liabilities": 700000, "total_equity": 200000}, ) db_session.add(fd) await db_session.commit() result = await validate_financial_data(db_session, company_id, 2025, 6) assert any("不平" in issue for issue in result["issues"]) @pytest.mark.asyncio async def test_validate_negative_revenue(self, db_session: AsyncSession, seed_tenant_company): """收入为负数时应报告异常。""" _, company_id, _ = seed_tenant_company fd = FinancialData( company_id=company_id, statement_type="income", period_year=2025, period_month=6, data_json={"revenue": -50000}, ) db_session.add(fd) await db_session.commit() result = await validate_financial_data(db_session, company_id, 2025, 6) assert any("负数" in issue for issue in result["issues"]) class TestAgreementMonitor: """协议条款监控服务测试。""" @pytest.mark.asyncio async def test_check_no_agreements(self, db_session: AsyncSession, seed_tenant_company): """无协议时应返回空列表。""" _, company_id, _ = seed_tenant_company alerts = await check_clause_triggers(db_session, company_id) assert alerts == [] @pytest.mark.asyncio async def test_check_agreement_with_rules(self, db_session: AsyncSession, seed_tenant_company): """有监控规则的协议应生成预警。""" _, company_id, _ = seed_tenant_company agreement = InvestmentAgreement( company_id=company_id, title="A轮投资协议", status="active", monitoring_rules=[ {"rule": "营收低于 100 万", "metric": "revenue", "threshold": 1000000}, ], ) db_session.add(agreement) await db_session.commit() alerts = await check_clause_triggers(db_session, company_id) assert len(alerts) == 1 assert alerts[0]["agreement_title"] == "A轮投资协议" assert "营收" in alerts[0]["rule"] @pytest.mark.asyncio async def test_check_inactive_agreement_ignored(self, db_session: AsyncSession, seed_tenant_company): """非 active 状态的协议不应生成预警。""" _, company_id, _ = seed_tenant_company agreement = InvestmentAgreement( company_id=company_id, title="已终止协议", status="terminated", monitoring_rules=[{"rule": "test", "metric": "revenue", "threshold": 100}], ) db_session.add(agreement) await db_session.commit() alerts = await check_clause_triggers(db_session, company_id) assert alerts == [] class TestSignalCorrelator: """弱信号关联引擎测试。""" @pytest.mark.asyncio async def test_single_signal_no_correlation(self): """单个信号不应产生关联。""" result = await correlate_signals([{"signal_type": "tech", "confidence": 0.8}]) assert result == [] @pytest.mark.asyncio async def test_empty_signals(self): """空信号列表不应产生关联。""" result = await correlate_signals([]) assert result == [] @pytest.mark.asyncio async def test_cross_dimension_correlation(self): """不同类型信号应产生跨维度关联。""" signals = [ {"id": "s1", "signal_type": "tech", "confidence": 0.7}, {"id": "s2", "signal_type": "market", "confidence": 0.6}, {"id": "s3", "signal_type": "org", "confidence": 0.5}, ] result = await correlate_signals(signals) assert len(result) >= 1 assert result[0]["correlation_type"] == "cross_dimension" assert result[0]["risk_probability"] > 0 assert result[0]["risk_probability"] <= 0.95 @pytest.mark.asyncio async def test_same_type_no_cross_dimension(self): """同类型信号不应产生跨维度关联。""" signals = [ {"id": "s1", "signal_type": "tech", "confidence": 0.7}, {"id": "s2", "signal_type": "tech", "confidence": 0.6}, ] result = await correlate_signals(signals) assert result == [] class TestHealthCalculator: """健康度评分计算测试(补充已有 test_health_calculator.py 的边界用例)。""" def test_empty_data_returns_zeros(self): """空数据应返回全 0。""" result = calculate_health_score({}) assert result["total_score"] == 0.0 assert result["financial_score"] == 0.0 def test_healthy_company_high_score(self): """健康企业应获得高分。""" data = { "cash_balance": {"runway_months": 18}, "revenue": {"yoy_change": "+25%"}, "burn_rate": {"trend": "down"}, "headcount": {"new_hires": 10, "departures": 2}, "key_metrics": [ {"name": "AI 推理量", "change": "+30%"}, {"name": "推理成本", "change": "-15%"}, ], } result = calculate_health_score(data) assert result["total_score"] > 70 assert result["financial_score"] > 80 def test_unhealthy_company_low_score(self): """不健康企业应获得低分。""" data = { "cash_balance": {"runway_months": 2}, "revenue": {"yoy_change": "-30%"}, "burn_rate": {"trend": "up"}, "headcount": {"new_hires": 0, "departures": 8}, } result = calculate_health_score(data) assert result["total_score"] < 50 assert result["financial_score"] < 40 def test_score_range_0_to_100(self): """所有维度分数应在 0-100 范围内。""" data = { "cash_balance": {"runway_months": 0}, "revenue": {"yoy_change": "-100%"}, "burn_rate": {"trend": "up"}, "headcount": {"new_hires": 0, "departures": 100}, } result = calculate_health_score(data) for v in result.values(): assert 0 <= v <= 100 def test_determine_trend_up(self): """评分上升 >5 应为 up。""" assert determine_trend(80, 70) == "up" def test_determine_trend_down(self): """评分下降 >5 应为 down。""" assert determine_trend(60, 70) == "down" def test_determine_trend_stable(self): """评分变化 <=5 应为 stable。""" assert determine_trend(72, 70) == "stable" def test_determine_trend_no_previous(self): """无上期评分应为 stable。""" assert determine_trend(75, None) == "stable" class TestReportTracker: """月报提交及时性追踪测试。""" @pytest.mark.asyncio async def test_compute_timeliness_no_reports(self, db_session: AsyncSession, seed_tenant_company): """无月报时应返回空列表。""" tenant_id, _, _ = seed_tenant_company result = await compute_timeliness(db_session, tenant_id) assert result == [] @pytest.mark.asyncio async def test_compute_timeliness_on_time(self, db_session: AsyncSession, seed_tenant_company): """按时提交应延迟 0 天。""" tenant_id, company_id, user_id = seed_tenant_company # 2025年6月月报,应提交日期为 2025-07-10 report = MonthlyReport( company_id=company_id, period_year=2025, period_month=6, status="submitted", raw_content="6月月报", submitted_at=__import__("datetime").datetime(2025, 7, 10, tzinfo=__import__("datetime").timezone.utc), structured_data={"revenue": 100, "cash_balance": {}, "burn_rate": {}, "headcount": {}, "key_metrics": [], "highlights": "", "concerns": ""}, ) db_session.add(report) await db_session.commit() result = await compute_timeliness(db_session, tenant_id) assert len(result) == 1 assert result[0]["delay_days"] == 0 @pytest.mark.asyncio async def test_compute_timeliness_late(self, db_session: AsyncSession, seed_tenant_company): """延迟提交应 delay_days > 0。""" tenant_id, company_id, _ = seed_tenant_company report = MonthlyReport( company_id=company_id, period_year=2025, period_month=6, status="submitted", raw_content="6月月报", submitted_at=__import__("datetime").datetime(2025, 7, 20, tzinfo=__import__("datetime").timezone.utc), structured_data={"revenue": 100}, ) db_session.add(report) await db_session.commit() result = await compute_timeliness(db_session, tenant_id) assert len(result) == 1 assert result[0]["delay_days"] == 10 @pytest.mark.asyncio async def test_compute_timeliness_quality_score(self, db_session: AsyncSession, seed_tenant_company): """数据质量评分应基于结构化字段完整度。""" tenant_id, company_id, _ = seed_tenant_company # 只填了 1/8 个字段 report = MonthlyReport( company_id=company_id, period_year=2025, period_month=6, status="submitted", raw_content="6月月报", submitted_at=__import__("datetime").datetime(2025, 7, 10, tzinfo=__import__("datetime").timezone.utc), structured_data={"revenue": 100}, ) db_session.add(report) await db_session.commit() result = await compute_timeliness(db_session, tenant_id) assert len(result) == 1 # 1/8 = 12.5% assert result[0]["quality_score"] == 12.5