from __future__ import annotations from datetime import UTC, datetime, timedelta from decimal import Decimal from sqlalchemy import create_engine from sqlalchemy.orm import Session, sessionmaker from sqlalchemy.pool import StaticPool from app.db.base import Base from app.models.agent_run import AgentRun from app.models.budget import BudgetAllocation, BudgetTransaction from app.models.financial_record import ExpenseClaim from app.models.risk_observation import RiskObservation from app.services.finance_dashboard import FinanceDashboardService from app.services.finance_dashboard_snapshot import FinanceDashboardSnapshotService def build_session() -> Session: engine = create_engine( "sqlite+pysqlite:///:memory:", connect_args={"check_same_thread": False}, poolclass=StaticPool, ) Base.metadata.create_all(bind=engine) session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False) return session_factory() def test_finance_dashboard_service_aggregates_claim_budget_and_payment_data() -> None: now = datetime.now(UTC) with build_session() as db: db.add_all( [ ExpenseClaim( claim_no="CLM-DASH-001", employee_name="陈雨晴", department_name="财务部", expense_type="travel", reason="项目差旅", location="广州", amount=Decimal("1200.00"), invoice_count=2, occurred_at=now - timedelta(hours=4), submitted_at=now - timedelta(hours=3), status="submitted", approval_stage="finance_review", risk_flags_json=[], hermes_risk_flag=False, created_at=now - timedelta(hours=4), updated_at=now - timedelta(hours=3), ), ExpenseClaim( claim_no="CLM-DASH-002", employee_name="顾成宇", department_name="研发中心", expense_type="meal", reason="客户招待", location="深圳", amount=Decimal("800.00"), invoice_count=1, occurred_at=now - timedelta(days=1, hours=2), submitted_at=now - timedelta(days=1, hours=1), status="paid", approval_stage="payment", risk_flags_json=[{"label": "招待费超标"}], hermes_risk_flag=False, created_at=now - timedelta(days=1, hours=2), updated_at=now - timedelta(days=1), ), ExpenseClaim( claim_no="CLM-DASH-003", employee_name="李文静", department_name="行政部", expense_type="office", reason="办公用品", location="珠海", amount=Decimal("5000.00"), invoice_count=3, occurred_at=now - timedelta(hours=1), submitted_at=None, status="draft", approval_stage=None, risk_flags_json=[], hermes_risk_flag=False, created_at=now - timedelta(hours=1), updated_at=now - timedelta(hours=1), ), ExpenseClaim( claim_no="AP-DASH-ADMIN-001", employee_name="admin", department_name="Finance", expense_type="travel_application", reason="admin pre-approval should not enter reimbursement metrics", location="Shanghai", amount=Decimal("999999.00"), invoice_count=1, occurred_at=now - timedelta(minutes=20), submitted_at=now - timedelta(minutes=10), status="paid", approval_stage="payment", risk_flags_json=[], hermes_risk_flag=False, created_at=now - timedelta(minutes=20), updated_at=now - timedelta(minutes=10), ), ] ) db.add( RiskObservation( observation_key="risk-dashboard-001", subject_type="expense_claim", subject_key="CLM-DASH-002", subject_label="CLM-DASH-002", claim_no="CLM-DASH-002", risk_type="policy", risk_signal="amount_outlier", title="金额异常", risk_level="high", status="pending_review", created_at=now - timedelta(hours=2), updated_at=now - timedelta(hours=2), ) ) allocation = BudgetAllocation( budget_no="BUD-DASH-001", fiscal_year=now.year, period_type="year", period_key=f"{now.year}", department_name="财务部", subject_code="travel", subject_name="差旅费", original_amount=Decimal("10000.00"), adjusted_amount=Decimal("0.00"), status="active", warning_threshold=Decimal("80.00"), control_action="warn", ) db.add(allocation) db.flush() db.add( BudgetTransaction( transaction_no="BTX-DASH-001", allocation_id=allocation.id, source_type="expense_claim", source_id="CLM-DASH-002", source_no="CLM-DASH-002", transaction_type="consume", amount=Decimal("4000.00"), before_available_amount=Decimal("10000.00"), after_available_amount=Decimal("6000.00"), operator="finance", reason="测试消耗", created_at=now - timedelta(hours=1), ) ) db.commit() dashboard = FinanceDashboardService(db).build_dashboard( range_key="近10日", trend_range="近7天", department_range="本月", ) assert dashboard.has_real_data is True assert dashboard.totals["reimbursementCount"] == 2 assert dashboard.totals["reimbursementAmount"] == 2000.0 assert dashboard.totals["pendingPaymentAmount"] == 0.0 assert sum(dashboard.trend["applications"]) >= 1 assert "AP-DASH-ADMIN-001" not in str(dashboard.trend) assert dashboard.spend_by_category[0]["value"] == 1200.0 assert dashboard.department_ranking[0]["name"] == "财务部" assert dashboard.department_ranking[0]["amount"] == 1200.0 assert dashboard.department_ranking[0]["employeeCount"] == 1 assert dashboard.department_employee_mix[0]["name"] == "财务部 · 陈雨晴" assert dashboard.department_employee_mix[0]["amount"] == 1200.0 assert dashboard.employee_ranking[0]["name"] == "陈雨晴" assert dashboard.employee_ranking[0]["count"] == 1 assert dashboard.top_claims[0]["claimNo"] == "CLM-DASH-001" assert "AP-DASH-ADMIN-001" not in str(dashboard.top_claims) assert dashboard.budget_summary["ratio"] == 40.0 assert dashboard.budget_summary["used"] == "¥4,000" metric_labels = {item["label"] for item in dashboard.budget_metrics} assert {"预算池数量", "总预算", "已用预算", "可用预算", "预警预算池"}.issubset( metric_labels ) def test_finance_dashboard_uses_financial_terms_instead_of_approval_terms() -> None: now = datetime.now(UTC) with build_session() as db: db.add_all( [ ExpenseClaim( claim_no="CLM-DASH-LABEL-001", employee_name="林嘉宁", department_name="市场部", expense_type="travel_application", reason="客户拜访差旅", location="上海", amount=Decimal("700.00"), invoice_count=1, occurred_at=now - timedelta(hours=2), submitted_at=now - timedelta(hours=1), status="submitted", approval_stage="finance_review", risk_flags_json=[{"type": "budget_pressure"}], hermes_risk_flag=False, created_at=now - timedelta(hours=2), updated_at=now - timedelta(hours=1), ), ExpenseClaim( claim_no="CLM-DASH-LABEL-002", employee_name="周思远", department_name="财务部", expense_type="meal", reason="客户沟通", location="杭州", amount=Decimal("300.00"), invoice_count=1, occurred_at=now - timedelta(days=1), submitted_at=now - timedelta(days=1), status="paid", approval_stage="payment", risk_flags_json=[], hermes_risk_flag=False, created_at=now - timedelta(days=1), updated_at=now - timedelta(days=1), ), ExpenseClaim( claim_no="CLM-DASH-LABEL-003", employee_name="reimbursement-user", department_name="Market", expense_type="travel", reason="real travel reimbursement", location="Shanghai", amount=Decimal("700.00"), invoice_count=1, occurred_at=now - timedelta(hours=2), submitted_at=now - timedelta(hours=1), status="submitted", approval_stage="finance_review", risk_flags_json=[], hermes_risk_flag=False, created_at=now - timedelta(hours=2), updated_at=now - timedelta(hours=1), ), ] ) db.add_all( [ RiskObservation( observation_key="risk-dashboard-label-001", subject_type="expense_claim", subject_key="CLM-DASH-LABEL-001", subject_label="CLM-DASH-LABEL-001", claim_no="CLM-DASH-LABEL-001", risk_type="policy", risk_signal="missing_material", title="材料不完整", risk_level="medium", status="pending_review", created_at=now - timedelta(minutes=30), updated_at=now - timedelta(minutes=30), ), RiskObservation( observation_key="risk-dashboard-label-002", subject_type="expense_claim", subject_key="CLM-DASH-LABEL-001", subject_label="CLM-DASH-LABEL-001", claim_no="CLM-DASH-LABEL-001", risk_type="budget", risk_signal="budget_pressure", title="预算压力偏高", risk_level="high", status="pending_review", created_at=now - timedelta(minutes=20), updated_at=now - timedelta(minutes=20), ), ] ) allocation = BudgetAllocation( budget_no="BUD-DASH-LABEL-001", fiscal_year=now.year, period_type="year", period_key=f"{now.year}", department_name="市场部", subject_code="travel", subject_name="差旅费", original_amount=Decimal("1000.00"), adjusted_amount=Decimal("0.00"), status="active", warning_threshold=Decimal("80.00"), control_action="warn", ) db.add(allocation) db.flush() db.add( BudgetTransaction( transaction_no="BTX-DASH-LABEL-001", allocation_id=allocation.id, source_type="expense_claim", source_id="CLM-DASH-LABEL-003", source_no="CLM-DASH-LABEL-003", transaction_type="consume", amount=Decimal("1250.00"), before_available_amount=Decimal("1000.00"), after_available_amount=Decimal("-250.00"), operator="finance", reason="测试超支", created_at=now - timedelta(minutes=10), ) ) db.commit() dashboard = FinanceDashboardService(db).build_dashboard( range_key="近10日", trend_range="近7天", department_range="本月", ) spend_names = {item["name"] for item in dashboard.spend_by_category} focus_names = {item["name"] for item in dashboard.bottlenecks} assert "差旅" in spend_names assert "travel_application" not in str(dashboard.spend_by_category) assert "风险" not in str(dashboard.exception_mix) assert "异常" not in str(dashboard.exception_mix) assert "missing material" not in str(dashboard.exception_mix).lower() assert "budget pressure" not in str(dashboard.exception_mix).lower() assert dashboard.trend["claimCount"][-1] == 1 assert dashboard.trend["claimAmount"][-1] == 700.0 assert sum(series["data"][-1] for series in dashboard.trend["categoryAmountSeries"]) == 700.0 assert "travel_application" not in str(dashboard.trend["categoryAmountSeries"]) assert dashboard.trend["applications"] == dashboard.trend["claimCount"] assert dashboard.department_ranking[0]["name"] == "Market" assert dashboard.department_ranking[0]["amount"] == 700.0 assert {"预算超支", "待付款", "高额单据"}.issubset(focus_names) assert "风险金额" not in focus_names assert "材料待补" not in focus_names assert all(item["role"] != "审批节点" for item in dashboard.bottlenecks) assert len(dashboard.budget_metrics) == 6 def test_finance_dashboard_ranking_range_supports_year_and_all_scope() -> None: now = datetime.now(UTC) previous_year_time = now - timedelta(days=420) with build_session() as db: db.add_all( [ ExpenseClaim( claim_no="CLM-RANGE-CURRENT-001", employee_name="王明", department_name="销售部", expense_type="travel", reason="本年差旅", location="北京", amount=Decimal("1000.00"), invoice_count=1, occurred_at=now - timedelta(days=5), submitted_at=now - timedelta(days=5), status="paid", approval_stage="payment", risk_flags_json=[], hermes_risk_flag=False, created_at=now - timedelta(days=5), updated_at=now - timedelta(days=4), ), ExpenseClaim( claim_no="CLM-RANGE-CURRENT-002", employee_name="赵琳", department_name="销售部", expense_type="meal", reason="本年招待", location="上海", amount=Decimal("500.00"), invoice_count=1, occurred_at=now - timedelta(days=8), submitted_at=now - timedelta(days=8), status="paid", approval_stage="payment", risk_flags_json=[], hermes_risk_flag=False, created_at=now - timedelta(days=8), updated_at=now - timedelta(days=7), ), ExpenseClaim( claim_no="CLM-RANGE-OLD-001", employee_name="钱远", department_name="销售部", expense_type="office", reason="历史办公", location="广州", amount=Decimal("9000.00"), invoice_count=1, occurred_at=previous_year_time, submitted_at=previous_year_time, status="paid", approval_stage="payment", risk_flags_json=[], hermes_risk_flag=False, created_at=previous_year_time, updated_at=previous_year_time, ), ] ) db.commit() year_dashboard = FinanceDashboardService(db).build_dashboard( range_key="近10日", trend_range="近7天", department_range="本年", ) all_dashboard = FinanceDashboardService(db).build_dashboard( range_key="近10日", trend_range="近7天", department_range="全部", ) assert year_dashboard.department_ranking[0]["amount"] == 1500.0 assert year_dashboard.department_ranking[0]["employeeCount"] == 2 assert "CLM-RANGE-OLD-001" not in str(year_dashboard.top_claims) assert {item["employee"] for item in year_dashboard.department_employee_mix} == { "王明", "赵琳", } assert all_dashboard.department_ranking[0]["amount"] == 10500.0 assert all_dashboard.department_ranking[0]["employeeCount"] == 3 assert all_dashboard.top_claims[0]["claimNo"] == "CLM-RANGE-OLD-001" assert all_dashboard.department_employee_mix[0]["employee"] == "钱远" def test_finance_dashboard_snapshot_service_persists_digital_employee_snapshot() -> None: now = datetime.now(UTC) with build_session() as db: db.add( ExpenseClaim( claim_no="CLM-SNAPSHOT-001", employee_name="snapshot-user", department_name="Finance", expense_type="travel", reason="snapshot test", location="Shanghai", amount=Decimal("880.00"), invoice_count=1, occurred_at=now - timedelta(hours=1), submitted_at=now - timedelta(minutes=50), status="paid", approval_stage="payment", risk_flags_json=[], hermes_risk_flag=False, created_at=now - timedelta(hours=1), updated_at=now - timedelta(minutes=40), ) ) db.commit() service = FinanceDashboardSnapshotService(db) first = service.build_dashboard( range_key="近30日", trend_range="近12天", department_range="本月", ) second = service.build_dashboard( range_key="近30日", trend_range="近12天", department_range="本月", ) runs = [ run for run in db.query(AgentRun).filter(AgentRun.agent == "hermes").all() if (run.route_json or {}).get("task_type") == "finance_dashboard_snapshot" ] assert first.totals["reimbursementCount"] == 1 assert second.generated_at == first.generated_at assert len(runs) == 1 assert runs[0].status == "succeeded" assert runs[0].route_json["task_type"] == "finance_dashboard_snapshot" assert runs[0].route_json["snapshot_payload"]["totals"]["reimbursementAmount"] == 880.0