from __future__ import annotations from collections.abc import Generator from dataclasses import dataclass from datetime import UTC, date, datetime, timedelta from decimal import Decimal import pytest from auth_helpers import install_legacy_header_auth_override from fastapi import FastAPI from fastapi.testclient import TestClient from sqlalchemy import create_engine from sqlalchemy.orm import Session, sessionmaker from sqlalchemy.pool import StaticPool from app.api.deps import CurrentUserContext, get_db from app.api.v1.endpoints.risk_observations import router as risk_observations_router from app.db.base import Base from app.models.approval_task import ApprovalTask from app.models.employee import Employee from app.models.financial_record import ExpenseClaim, ExpenseClaimItem from app.models.risk_disposition import RiskDisposition from app.models.risk_observation import RiskObservation from app.schemas.risk_disposition import RiskDispositionActionCreate from app.services.approval_task_projection_refresh import ( ApprovalTaskProjectionRefreshService, ) from app.services.risk_disposition_read_projection import RiskDispositionActionPolicy from app.services.risk_dispositions import RiskDispositionService from app.services.risk_observations import RiskObservationService def test_available_actions_enforce_decider_separation_tenant_state_and_expiry( monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setenv("FEW_SHOT_INJECTION_ENABLED", "false") client, factory = _build_client() client.close() with factory() as db: seeded = _seed_waiver_request(db) policy = RiskDispositionActionPolicy(db) decider_actions = set( policy.evaluate( seeded.observation, seeded.disposition, seeded.decider, ).available_actions ) assert {"approve_waiver", "reject_waiver"} <= decider_actions for user in (seeded.requester, seeded.admin, seeded.inactive_finance): actions = set( policy.evaluate( seeded.observation, seeded.disposition, user, ).available_actions ) assert "approve_waiver" not in actions assert "reject_waiver" not in actions foreign_permission = policy.evaluate( seeded.observation, seeded.disposition, _user(seeded.decider_employee, roles=["finance"], tenant_id="tenant-b"), ) assert foreign_permission.available_actions == () assert "租户" in foreign_permission.read_only_reason expires_at = seeded.disposition.waiver_expires_at assert expires_at is not None expired_actions = set( policy.evaluate( seeded.observation, seeded.disposition, seeded.decider, now=_as_utc(expires_at) + timedelta(seconds=1), ).available_actions ) assert "approve_waiver" not in expired_actions assert "reject_waiver" in expired_actions seeded.disposition.lifecycle_status = "waived" seeded.disposition.waiver_decision = "approved" waived_actions = set( policy.evaluate( seeded.observation, seeded.disposition, seeded.decider, ).available_actions ) assert "approve_waiver" not in waived_actions assert "reject_waiver" not in waived_actions seeded.disposition.lifecycle_status = "open" seeded.disposition.waiver_decision = None seeded.disposition.waiver_requester_id = None open_actions = set( policy.evaluate( seeded.observation, seeded.disposition, seeded.decider, ).available_actions ) assert "request_waiver" in open_actions def test_read_and_mutation_endpoints_return_server_projected_actions( monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setenv("FEW_SHOT_INJECTION_ENABLED", "false") client, factory = _build_client() with factory() as db: seeded = _seed_waiver_request(db) unreviewed = RiskObservationService(db).upsert_observation( _observation_payload("risk:projection:unreviewed") ) db.commit() observation_id = seeded.observation.id unreviewed_id = unreviewed.id decider_headers = _headers(seeded.decider_employee, roles=["finance"]) claim_response = client.get( "/api/v1/risk-observations/claim/claim-risk-projection", headers=decider_headers, ) assert claim_response.status_code == 200 by_id = {item["id"]: item for item in claim_response.json()} requested_item = by_id[observation_id] assert {"approve_waiver", "reject_waiver"} <= set( requested_item["available_actions"] ) assert requested_item["read_only_reason"] == "" assert requested_item["disposition"]["available_actions"] == requested_item[ "available_actions" ] assert requested_item["disposition"]["read_only_reason"] == "" assert by_id[unreviewed_id]["disposition"] is None assert {"confirm", "false_positive"} <= set( by_id[unreviewed_id]["available_actions"] ) for path in ( "/api/v1/risk-observations", f"/api/v1/risk-observations/{observation_id}", f"/api/v1/risk-observations/{observation_id}/disposition", ): response = client.get(path, headers=decider_headers) assert response.status_code == 200 payload = response.json() if path.endswith("/risk-observations"): payload = next(item for item in payload["items"] if item["id"] == observation_id) if not path.endswith("/disposition"): payload = payload["disposition"] assert {"approve_waiver", "reject_waiver"} <= set(payload["available_actions"]) assert payload["read_only_reason"] == "" for employee, roles, is_admin in ( (seeded.requester_employee, ["finance"], False), (seeded.admin_employee, [], True), ): response = client.get( f"/api/v1/risk-observations/{observation_id}/disposition", headers=_headers(employee, roles=roles, is_admin=is_admin), ) assert response.status_code == 200 assert "approve_waiver" not in response.json()["available_actions"] assert "reject_waiver" not in response.json()["available_actions"] mutation_response = client.post( f"/api/v1/risk-observations/{observation_id}/disposition/actions", headers=decider_headers, json={ "action": "reject_waiver", "expected_version": 2, "request_id": "projection-reject-waiver", "comment": "当前证据不足,不同意风险豁免。", }, ) assert mutation_response.status_code == 200 mutation_disposition = mutation_response.json()["disposition"] assert "available_actions" in mutation_disposition assert "read_only_reason" in mutation_disposition assert "approve_waiver" not in mutation_disposition["available_actions"] assert "reject_waiver" not in mutation_disposition["available_actions"] def test_disposition_mutations_refresh_task_risk_without_resetting_sla( monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setenv("FEW_SHOT_INJECTION_ENABLED", "false") client, factory = _build_client() client.close() with factory() as db: owner = _employee("projection-owner") requester_employee = _employee("projection-requester") decider_employee = _employee("projection-decider") claim = _claim(owner.id) observation = RiskObservationService(db).upsert_observation( _observation_payload("risk:projection:task-refresh") ) now = datetime.now(UTC) task = _approval_task(claim, now=now) db.add_all([owner, requester_employee, decider_employee, claim, task]) db.commit() original_sla = ( _as_utc(task.entered_at), task.sla_hours_snapshot, _as_utc(task.due_at), _as_utc(task.next_escalation_at), task.escalation_level, ) requester = _user(requester_employee, roles=["finance"]) decider = _user(decider_employee, roles=["finance"]) service = RiskDispositionService(db) service.execute_action( observation.id, RiskDispositionActionCreate( action="confirm", expected_version=0, request_id="task-refresh-confirm-risk", ), tenant_id="default", actor_id=requester_employee.id, actor_name=requester_employee.name, current_user=requester, ) db.refresh(task) assert task.risk_level == "high" assert task.open_risk_count == 1 assert task.batch_eligible is False assert "open_risk" in task.batch_block_reasons_json _assert_sla_window(task, original_sla) service.execute_action( observation.id, _waiver_request(version=1, request_id="task-refresh-waiver-request-1"), tenant_id="default", actor_id=requester_employee.id, actor_name=requester_employee.name, current_user=requester, ) service.execute_action( observation.id, RiskDispositionActionCreate( action="reject_waiver", expected_version=2, request_id="task-refresh-reject-waiver", comment="补偿措施不足,拒绝豁免。", ), tenant_id="default", actor_id=decider_employee.id, actor_name=decider_employee.name, current_user=decider, ) db.refresh(task) assert task.open_risk_count == 1 assert task.batch_eligible is False _assert_sla_window(task, original_sla) service.execute_action( observation.id, _waiver_request(version=3, request_id="task-refresh-waiver-request-2"), tenant_id="default", actor_id=requester_employee.id, actor_name=requester_employee.name, current_user=requester, ) service.execute_action( observation.id, RiskDispositionActionCreate( action="approve_waiver", expected_version=4, request_id="task-refresh-approve-waiver", comment="补偿措施完整,同意限时豁免。", ), tenant_id="default", actor_id=decider_employee.id, actor_name=decider_employee.name, current_user=decider, ) db.refresh(task) assert task.risk_level == "low" assert task.open_risk_count == 0 assert task.evidence_completeness == Decimal("1.0000") assert task.batch_eligible is True _assert_sla_window(task, original_sla) disposition = service.get_disposition(observation.id, tenant_id="default") assert disposition is not None refresh_at = datetime.now(UTC) disposition.waiver_requested_at = refresh_at - timedelta(days=2) disposition.waiver_expires_at = refresh_at - timedelta(hours=1) db.flush() ApprovalTaskProjectionRefreshService(db).refresh_claim( tenant_id="default", claim_id=claim.id, now=refresh_at, ) db.commit() db.refresh(task) assert task.risk_level == "high" assert task.open_risk_count == 1 assert task.batch_eligible is False _assert_sla_window(task, original_sla) @dataclass(slots=True) class _SeededWaiver: observation: RiskObservation disposition: RiskDisposition requester_employee: Employee decider_employee: Employee admin_employee: Employee requester: CurrentUserContext decider: CurrentUserContext admin: CurrentUserContext inactive_finance: CurrentUserContext def _seed_waiver_request(db: Session) -> _SeededWaiver: owner = _employee("owner") requester_employee = _employee("requester") decider_employee = _employee("decider") admin_employee = _employee("admin") inactive_employee = _employee("inactive", employment_status="离职") db.add_all( [ owner, requester_employee, decider_employee, admin_employee, inactive_employee, _claim(owner.id), ] ) observation = RiskObservationService(db).upsert_observation( _observation_payload("risk:projection:waiver") ) db.commit() requester = _user(requester_employee, roles=["finance"]) service = RiskDispositionService(db) service.execute_action( observation.id, RiskDispositionActionCreate( action="confirm", expected_version=0, request_id="projection-confirm-risk", ), tenant_id="default", actor_id=requester_employee.id, actor_name=requester_employee.name, current_user=requester, ) requested = service.execute_action( observation.id, RiskDispositionActionCreate( action="request_waiver", expected_version=1, request_id="projection-request-waiver", waiver_reason="客户现场暂时无法补齐原件。", waiver_scope="仅限本次报销单的当前风险观察。", waiver_expires_at=datetime.now(UTC) + timedelta(days=2), waiver_conditions=["到期前补齐原件"], ), tenant_id="default", actor_id=requester_employee.id, actor_name=requester_employee.name, current_user=requester, ) disposition = service.get_disposition(observation.id, tenant_id="default") assert disposition is not None assert requested.disposition.version == 2 return _SeededWaiver( observation=observation, disposition=disposition, requester_employee=requester_employee, decider_employee=decider_employee, admin_employee=admin_employee, requester=requester, decider=_user(decider_employee, roles=["finance"]), admin=_user(admin_employee, roles=[], is_admin=True), inactive_finance=_user(inactive_employee, roles=["finance"]), ) def _build_client() -> tuple[TestClient, sessionmaker[Session]]: engine = create_engine( "sqlite+pysqlite:///:memory:", connect_args={"check_same_thread": False}, poolclass=StaticPool, ) Base.metadata.create_all(bind=engine) factory = sessionmaker( bind=engine, autoflush=False, autocommit=False, expire_on_commit=False, ) app = FastAPI() app.include_router(risk_observations_router, prefix="/api/v1") install_legacy_header_auth_override(app) def override_db() -> Generator[Session, None, None]: with factory() as db: yield db app.dependency_overrides[get_db] = override_db return TestClient(app), factory def _employee(suffix: str, *, employment_status: str = "在职") -> Employee: return Employee( id=f"emp-{suffix}", employee_no=f"E-{suffix.upper()}", name=f"员工-{suffix}", email=f"{suffix}@risk-projection.example.com", position="财务专员", grade="P6", employment_status=employment_status, ) def _user( employee: Employee, *, roles: list[str], is_admin: bool = False, tenant_id: str = "default", ) -> CurrentUserContext: return CurrentUserContext( username=employee.email, name=employee.name, role_codes=roles, is_admin=is_admin, tenant_id=tenant_id, employee_no=employee.employee_no, employee_id=employee.id, ) def _headers( employee: Employee, *, roles: list[str], is_admin: bool = False, ) -> dict[str, str]: return { "X-Auth-Username": employee.email, "X-Auth-Name": employee.id, "X-Auth-Role-Codes": ",".join(roles), "X-Auth-Is-Admin": str(is_admin).lower(), "X-Auth-Employee-No": employee.employee_no, "X-Auth-Employee-Id": employee.id, "X-Auth-Tenant-Id": "default", } def _waiver_request(*, version: int, request_id: str) -> RiskDispositionActionCreate: return RiskDispositionActionCreate( action="request_waiver", expected_version=version, request_id=request_id, waiver_reason="客户现场暂时无法补齐原件。", waiver_scope="仅限当前报销单的当前风险观察。", waiver_expires_at=datetime.now(UTC) + timedelta(days=2), waiver_conditions=["到期前补齐原件"], ) def _claim(owner_id: str) -> ExpenseClaim: now = datetime.now(UTC) claim = ExpenseClaim( id="claim-risk-projection", claim_no="BX-RISK-PROJECTION", employee_id=owner_id, employee_name="风险申请人", department_id="dept-risk", department_name="风控部", expense_type="travel", reason="客户拜访", location="上海", amount=Decimal("1200"), currency="CNY", invoice_count=1, occurred_at=now, submitted_at=now, status="submitted", approval_stage="财务审批", risk_flags_json=[], ) claim.items = [ ExpenseClaimItem( id="item-risk-projection", claim_id=claim.id, item_date=date.today(), item_type="travel", item_reason="客户拜访交通费", item_location="上海", item_note="", item_amount=claim.amount, invoice_id="INV-RISK-PROJECTION", created_at=now, updated_at=now, ) ] return claim def _approval_task(claim: ExpenseClaim, *, now: datetime) -> ApprovalTask: return ApprovalTask( id="task-risk-projection", tenant_id="default", claim_id=claim.id, expense_case_id=None, node_instance_id="node-risk-projection", node_entry_key="node-entry-risk-projection", parent_task_id=None, task_kind="root", node_key="finance", node_label="财务审批", node_sequence=3, sequence_order=0, coordination_mode="single", owner_kind="role", owner_key="finance", owner_name="财务审批人", assignee_kind="role", assignee_key="finance", 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=7), 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, ) def _assert_sla_window( task: ApprovalTask, expected: tuple[datetime, int, datetime, datetime, int], ) -> None: actual = ( _as_utc(task.entered_at), task.sla_hours_snapshot, _as_utc(task.due_at), _as_utc(task.next_escalation_at), task.escalation_level, ) assert actual == expected def _observation_payload(observation_key: str) -> dict[str, object]: return { "observation_key": observation_key, "subject_type": "expense_claim", "subject_key": "claim:claim-risk-projection", "subject_label": "BX-RISK-PROJECTION", "claim_id": "claim-risk-projection", "claim_no": "BX-RISK-PROJECTION", "risk_type": "duplicate_invoice", "risk_signal": "duplicate_invoice", "title": "重复票据风险", "description": "同一票据可能重复报销。", "risk_score": 86, "risk_level": "high", "confidence_score": 0.91, "control_stage": "reimbursement", "control_mode": "risk_observation", "automation_mode": "semi_auto_review", "source": "financial_risk_graph", "algorithm_version": "financial_risk_graph.v1", "contribution_scores": {}, "baseline": {}, "evidence": [], "graph_node_keys": [], "graph_edge_keys": [], "policy_refs": [], "similar_case_claim_ids": [], "ontology_json": {}, "decision_trace": {}, } def _as_utc(value: datetime) -> datetime: if value.tzinfo is None: return value.replace(tzinfo=UTC) return value.astimezone(UTC)