from __future__ import annotations from datetime import UTC, date, datetime, timedelta from decimal import Decimal import pytest from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker from sqlalchemy.pool import StaticPool from app.db.base import Base from app.models.approval_task import ApprovalTask from app.models.financial_record import ExpenseClaim, ExpenseClaimItem from app.models.risk_disposition import RiskDisposition from app.models.risk_observation import RiskObservation from app.services.approval_task_projection import ApprovalTaskProjectionService from app.services.approval_task_projection_refresh import ( ApprovalTaskProjectionRefreshService, ) from app.services.risk_observations import RiskObservationService def _claim( *, claim_id: str = "claim-projection-1", claim_no: str = "RE-PROJECTION-1", amount: str = "888.00", invoice_count: int = 1, risk_flags: list[dict] | None = None, ) -> ExpenseClaim: occurred_at = datetime(2026, 7, 15, 9, 0, tzinfo=UTC) claim = ExpenseClaim( id=claim_id, claim_no=claim_no, employee_name="张三", department_name="市场部", project_code="PRJ-PROJECTION", expense_type="application" if claim_no.startswith("AP-") else "travel", reason="客户现场差旅", location="上海", amount=Decimal(amount), currency="CNY", invoice_count=invoice_count, occurred_at=occurred_at, submitted_at=occurred_at + timedelta(hours=1), status="submitted", approval_stage="直属领导审批", risk_flags_json=list(risk_flags or []), created_at=occurred_at, updated_at=occurred_at + timedelta(hours=1), ) claim.items = [ ExpenseClaimItem( id=f"item-{claim_id}", claim_id=claim_id, item_date=date(2026, 7, 15), item_type="hotel", item_reason="住宿", item_location="上海", item_note="", item_amount=Decimal(amount), invoice_id="INV-PROJECTION" if invoice_count else None, created_at=occurred_at, updated_at=occurred_at, ) ] return claim def test_projection_materializes_explainable_priority_and_safe_batch_blocks() -> None: now = datetime(2026, 7, 16, 12, 0, tzinfo=UTC) claim = _claim( amount="60000.00", risk_flags=[ { "source": "ai_pre_review", "severity": "high", "disposition": "review", "route_decision": {"budget_result": {"metrics": {"after_usage_rate": "0.96"}}}, } ], ) projection = ApprovalTaskProjectionService(None).build( # type: ignore[arg-type] claim, tenant_id="tenant-a", entered_at=now - timedelta(hours=26), now=now, observation_rows=[], ) reason_codes = {item["code"] for item in projection.priority_reasons_json} assert projection.priority_score >= 85 assert projection.priority_tier == "urgent" assert projection.risk_level == "high" assert projection.open_risk_count == 1 assert projection.due_at == now - timedelta(hours=2) assert projection.next_escalation_at == projection.due_at assert reason_codes >= { "open_risk", "sla_overdue", "budget_pressure", "large_amount", } assert projection.batch_eligible is False assert set(projection.batch_block_reasons_json) >= { "open_risk", "amount_requires_individual_review", "budget_pressure", "sla_overdue", } def test_resolved_materialized_risk_suppresses_stale_raw_flag_and_application_invoice() -> None: now = datetime(2026, 7, 16, 12, 0, tzinfo=UTC) claim = _claim( claim_id="claim-projection-resolved", claim_no="AP-PROJECTION-1", invoice_count=0, risk_flags=[ { "severity": "critical", "triggered": True, "observation_key": "risk:projection:resolved", } ], ) observation = RiskObservation( id="observation-projection-resolved", tenant_id="tenant-a", observation_key="risk:projection:resolved", subject_type="expense_claim", subject_key=f"claim:{claim.id}", subject_label=claim.claim_no, claim_id=claim.id, claim_no=claim.claim_no, risk_type="duplicate_invoice", risk_signal="duplicate_invoice", title="重复票据", description="已人工复核完成。", risk_score=98, risk_level="critical", confidence_score=0.99, control_stage="expense_application", control_mode="risk_observation", automation_mode="manual", source="financial_risk_graph", algorithm_version="v1", status="resolved", feedback_status="confirmed", ) disposition = RiskDisposition( id="disposition-projection-resolved", tenant_id="tenant-a", observation_id=observation.id, adjudication="confirmed", lifecycle_status="resolved", version=2, ) projection = ApprovalTaskProjectionService(None).build( # type: ignore[arg-type] claim, tenant_id="tenant-a", entered_at=now - timedelta(hours=1), now=now, observation_rows=[(observation, disposition)], ) assert projection.risk_level == "low" assert projection.open_risk_count == 0 assert projection.evidence_completeness == Decimal("1.0000") assert projection.priority_score == 0 assert projection.priority_reasons_json[0]["code"] == "routine" assert projection.batch_eligible is True assert projection.batch_block_reasons_json == () def test_projection_marks_missing_evidence_and_apply_updates_task_without_persistence() -> None: now = datetime(2026, 7, 16, 12, 0, tzinfo=UTC) claim = _claim(invoice_count=0) claim.location = "" claim.items = [] projection = ApprovalTaskProjectionService(None).build( # type: ignore[arg-type] claim, tenant_id="tenant-a", entered_at=now - timedelta(hours=1), now=now, observation_rows=[], ) task = ApprovalTask() returned = ApprovalTaskProjectionService.apply(task, projection) assert returned is task assert task.evidence_completeness == Decimal("0.2500") assert task.batch_eligible is False assert "evidence_incomplete" in task.batch_block_reasons_json assert {item["code"] for item in task.priority_reasons_json} >= {"evidence_gap"} def test_projection_rejects_ambiguous_time_and_invalid_sla() -> None: claim = _claim() service = ApprovalTaskProjectionService(None) # type: ignore[arg-type] with pytest.raises(ValueError, match="entered_at.*timezone"): service.build( claim, tenant_id="tenant-a", entered_at=datetime(2026, 7, 16, 10, 0), observation_rows=[], ) with pytest.raises(ValueError, match="sla_hours"): service.build( claim, tenant_id="tenant-a", entered_at=datetime(2026, 7, 16, 10, 0, tzinfo=UTC), sla_hours=0, observation_rows=[], ) def test_apply_convenience_form_uses_claim_session_for_tenant_risks() -> None: engine = create_engine( "sqlite+pysqlite:///:memory:", connect_args={"check_same_thread": False}, poolclass=StaticPool, ) Base.metadata.create_all(bind=engine) db_factory = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False) now = datetime(2026, 7, 16, 12, 0, tzinfo=UTC) with db_factory() as db: claim = _claim(claim_id="claim-projection-session") observation = RiskObservation( id="observation-projection-session", tenant_id="tenant-a", observation_key="risk:projection:session", subject_type="expense_claim", subject_key=f"claim:{claim.id}", subject_label=claim.claim_no, claim_id=claim.id, claim_no=claim.claim_no, risk_type="policy_violation", risk_signal="policy_violation", title="政策风险", description="等待人工处置。", risk_score=80, risk_level="high", confidence_score=0.9, control_stage="reimbursement", control_mode="risk_observation", automation_mode="manual", source="financial_risk_graph", algorithm_version="v1", status="pending_review", feedback_status="unreviewed", ) db.add_all([claim, observation]) db.flush() task = ApprovalTask( tenant_id="tenant-a", entered_at=now - timedelta(hours=1), sla_hours_snapshot=12, ) returned = ApprovalTaskProjectionService.apply(task, claim=claim, now=now) assert returned is task assert task.risk_level == "high" assert task.open_risk_count == 1 assert task.due_at == now + timedelta(hours=11) def test_projection_filters_other_business_stage_and_only_honors_active_waiver() -> None: now = datetime(2026, 7, 16, 12, 0, tzinfo=UTC) claim = _claim( claim_id="claim-projection-waiver", claim_no="AP-PROJECTION-WAIVER", risk_flags=[ { "severity": "critical", "triggered": True, "business_stage": "reimbursement", } ], ) other_stage = RiskObservation( id="observation-other-stage", tenant_id="tenant-a", observation_key="risk:projection:other-stage", subject_type="expense_claim", subject_key=f"claim:{claim.id}", subject_label=claim.claim_no, claim_id=claim.id, claim_no=claim.claim_no, risk_type="invoice", risk_signal="invoice", risk_level="critical", control_stage="reimbursement", status="pending_review", feedback_status="unreviewed", ) waived = RiskObservation( id="observation-active-waiver", tenant_id="tenant-a", observation_key="risk:projection:active-waiver", subject_type="expense_claim", subject_key=f"claim:{claim.id}", subject_label=claim.claim_no, claim_id=claim.id, claim_no=claim.claim_no, risk_type="policy", risk_signal="policy", risk_level="high", control_stage="expense_application", status="pending_review", feedback_status="confirmed", ) disposition = RiskDisposition( tenant_id="tenant-a", observation_id=waived.id, adjudication="confirmed", lifecycle_status="waived", waiver_expires_at=now + timedelta(days=1), waiver_decision="approved", ) service = ApprovalTaskProjectionService(None) # type: ignore[arg-type] active = service.build( claim, tenant_id="tenant-a", entered_at=now - timedelta(hours=1), now=now, observation_rows=[(other_stage, None), (waived, disposition)], ) disposition.waiver_expires_at = now - timedelta(seconds=1) expired = service.build( claim, tenant_id="tenant-a", entered_at=now - timedelta(hours=1), now=now, observation_rows=[(other_stage, None), (waived, disposition)], ) assert active.risk_level == "low" assert active.open_risk_count == 0 assert active.batch_eligible is True assert expired.risk_level == "high" assert expired.open_risk_count == 1 assert expired.batch_eligible is False def test_risk_upsert_refreshes_open_task_projection_without_resetting_sla_window() -> None: engine = create_engine( "sqlite+pysqlite:///:memory:", connect_args={"check_same_thread": False}, poolclass=StaticPool, ) Base.metadata.create_all(bind=engine) db_factory = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False) now = datetime(2026, 7, 16, 12, 0, tzinfo=UTC) with db_factory() as db: claim = _claim(claim_id="claim-projection-refresh") task = ApprovalTask( id="task-projection-refresh", tenant_id="default", claim_id=claim.id, expense_case_id=None, node_instance_id="node-projection-refresh", node_entry_key="node-entry-projection-refresh", parent_task_id=None, task_kind="root", node_key="direct_manager", node_label="直属领导审批", node_sequence=1, sequence_order=0, coordination_mode="single", owner_kind="role", owner_key="manager", owner_name="审批经理", assignee_kind="role", assignee_key="manager", assignee_name="审批经理", status="pending", version=1, claim_status_snapshot="submitted", claim_stage_snapshot="直属领导审批", entered_at=now - timedelta(hours=1), entered_at_source="workflow_event", activated_at=now - timedelta(hours=1), sla_hours_snapshot=24, due_at=now + timedelta(hours=23), escalation_level=1, next_escalation_at=now + timedelta(hours=2), priority_score=10, priority_tier="normal", priority_reasons_json=[ { "code": "sla_escalated_l1", "label": "审批超时已升级至 L1", "weight": 10, "tone": "danger", } ], risk_level="low", open_risk_count=0, evidence_completeness=Decimal("1.0000"), batch_eligible=True, batch_block_reasons_json=[], projection_updated_at=now, created_at=now, updated_at=now, ) db.add_all([claim, task]) db.commit() observation = RiskObservationService(db).upsert_observation( { "observation_key": "risk:projection:refresh", "subject_type": "expense_claim", "subject_key": f"claim:{claim.id}", "subject_label": claim.claim_no, "claim_id": claim.id, "claim_no": claim.claim_no, "risk_type": "policy_violation", "risk_signal": "policy_violation", "title": "政策风险", "description": "等待人工处置。", "risk_score": 90, "risk_level": "high", "control_stage": "reimbursement", "status": "pending_review", }, tenant_id="default", ) db.refresh(task) assert task.risk_level == "high" assert task.open_risk_count == 1 assert task.batch_eligible is False next_escalation_at = task.next_escalation_at assert next_escalation_at is not None if next_escalation_at.tzinfo is None: next_escalation_at = next_escalation_at.replace(tzinfo=UTC) assert next_escalation_at == now + timedelta(hours=2) assert task.escalation_level == 1 assert any( reason["code"] == "sla_escalated_l1" for reason in task.priority_reasons_json ) db.add( RiskDisposition( tenant_id="default", observation_id=observation.id, adjudication="confirmed", lifecycle_status="waived", waiver_requester_id="requester-projection-refresh", waiver_requester_name="风险申请人", waiver_requested_at=now - timedelta(hours=1), waiver_reason="业务连续性需要", waiver_scope="本次报销单", waiver_decision="approved", waiver_expires_at=now + timedelta(days=1), waiver_conditions_json=["补充主管确认"], waiver_decider_id="finance-projection-refresh", waiver_decider_name="财务复核人", waiver_decided_at=now, waiver_decision_reason="风险受控且有补偿措施", version=1, ) ) db.flush() ApprovalTaskProjectionRefreshService(db).refresh_claim( tenant_id="default", claim_id=claim.id, now=now, ) assert task.risk_level == "low" assert task.open_risk_count == 0 assert task.batch_eligible is True next_escalation_at = task.next_escalation_at assert next_escalation_at is not None if next_escalation_at.tzinfo is None: next_escalation_at = next_escalation_at.replace(tzinfo=UTC) assert next_escalation_at == now + timedelta(hours=2)