from __future__ import annotations import os import threading import time import uuid from concurrent.futures import ThreadPoolExecutor from datetime import UTC, datetime from decimal import Decimal import pytest from sqlalchemy import create_engine, func, select from sqlalchemy.engine import make_url from sqlalchemy.orm import Session, sessionmaker from app.api.deps import CurrentUserContext from app.db.base import Base from app.models.approval_action import ApprovalActionLedger from app.models.employee import Employee from app.models.financial_record import ExpenseClaim from app.models.risk_disposition import RiskDisposition from app.models.risk_observation import RiskObservation from app.schemas.risk_disposition import RiskDispositionActionCreate from app.services.expense_claim_risk_gate import ExpenseClaimRiskBlockedError from app.services.expense_claims import ExpenseClaimService from app.services.risk_dispositions import RiskDispositionService DATABASE_URL = os.environ.get("MIGRATION_TEST_DATABASE_URL", "").strip() def test_disposition_reopen_and_approval_share_claim_lock( monkeypatch: pytest.MonkeyPatch, ) -> None: database_url = _require_disposable_database_url() monkeypatch.setenv("FEW_SHOT_INJECTION_ENABLED", "false") engine = create_engine(database_url, pool_pre_ping=True) Base.metadata.create_all(bind=engine) factory = sessionmaker(bind=engine, expire_on_commit=False) suffix = uuid.uuid4().hex[:12] claim_id = f"claim-risk-lock-{suffix}" observation_id = f"risk-lock-{suffix}" disposition_id = f"disposition-lock-{suffix}" manager_email = f"manager-{suffix}@example.com" manager_user = CurrentUserContext( tenant_id="default", username=manager_email, name="并发审批经理", role_codes=["manager"], is_admin=False, ) with factory() as db: _seed_locked_risk_case( db, claim_id=claim_id, observation_id=observation_id, disposition_id=disposition_id, manager_email=manager_email, suffix=suffix, ) claim_locked = threading.Event() release_disposition = threading.Event() approval_started = threading.Event() from app.services import risk_dispositions as risk_disposition_module original_apply_action = risk_disposition_module._apply_action def pause_after_claim_lock(*args, **kwargs): claim_locked.set() if not release_disposition.wait(timeout=5): raise TimeoutError("test did not release risk disposition") return original_apply_action(*args, **kwargs) monkeypatch.setattr(risk_disposition_module, "_apply_action", pause_after_claim_lock) def reopen_risk() -> str: with factory() as db: result = RiskDispositionService(db).execute_action( observation_id, RiskDispositionActionCreate( action="confirm", expected_version=1, request_id=f"request-risk-reopen-{suffix}", comment="复核后确认风险成立", ), tenant_id="default", actor_id=manager_email, actor_name="并发审批经理", current_user=manager_user, ) return result.disposition.adjudication def approve_claim() -> str: approval_started.set() with factory() as db: try: ExpenseClaimService(db).approve_claim( claim_id, manager_user, opinion="同意", request_id=f"request-approve-after-risk-{suffix}", expected_status="submitted", expected_approval_stage="直属领导审批", ) except ExpenseClaimRiskBlockedError: return "blocked" return "approved" try: with ThreadPoolExecutor(max_workers=2) as pool: risk_future = pool.submit(reopen_risk) assert claim_locked.wait(timeout=5) approval_future = pool.submit(approve_claim) assert approval_started.wait(timeout=5) time.sleep(0.2) assert not approval_future.done() release_disposition.set() assert risk_future.result(timeout=5) == "confirmed" assert approval_future.result(timeout=5) == "blocked" with factory() as db: claim = db.get(ExpenseClaim, claim_id) disposition = db.get(RiskDisposition, disposition_id) assert claim is not None and claim.approval_stage == "直属领导审批" assert disposition is not None and disposition.adjudication == "confirmed" assert ( db.scalar( select(func.count()) .select_from(ApprovalActionLedger) .where(ApprovalActionLedger.claim_id == claim_id) ) == 0 ) finally: release_disposition.set() engine.dispose() def _seed_locked_risk_case( db: Session, *, claim_id: str, observation_id: str, disposition_id: str, manager_email: str, suffix: str, ) -> None: manager = Employee( id=f"manager-risk-lock-{suffix}", tenant_id="default", employee_no=f"M-RISK-LOCK-{suffix}", name="并发审批经理", email=manager_email, ) employee = Employee( id=f"employee-risk-lock-{suffix}", tenant_id="default", employee_no=f"E-RISK-LOCK-{suffix}", name="并发风险员工", email=f"employee-{suffix}@example.com", manager=manager, ) now = datetime.now(UTC) claim = ExpenseClaim( id=claim_id, tenant_id="default", claim_no=f"EXP-RISK-LOCK-{suffix}", employee=employee, employee_name=employee.name, department_name="风控部", expense_type="travel", reason="客户拜访", location="上海", amount=Decimal("1200.00"), currency="CNY", invoice_count=1, occurred_at=now, submitted_at=now, status="submitted", approval_stage="直属领导审批", risk_flags_json=[], ) observation = RiskObservation( id=observation_id, tenant_id="default", observation_key=f"risk:claim-lock:{suffix}", 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=92, risk_level="high", confidence_score=0.95, control_stage="reimbursement", control_mode="risk_observation", automation_mode="semi_auto_review", source="financial_risk_graph", algorithm_version="financial_risk_graph.v1", status="false_positive", feedback_status="false_positive", ) disposition = RiskDisposition( id=disposition_id, tenant_id="default", observation_id=observation_id, adjudication="false_positive", lifecycle_status="open", version=1, ) db.add_all([manager, employee, claim, observation, disposition]) db.commit() def _require_disposable_database_url() -> str: if not DATABASE_URL: pytest.skip("仅在显式配置 MIGRATION_TEST_DATABASE_URL 时运行 PostgreSQL 并发测试") parsed = make_url(DATABASE_URL) host = str(parsed.host or "").replace("_", "-").lower() database = str(parsed.database or "").replace("_", "-").lower() if not host.startswith(("migration-probe", "disposable-probe")): raise RuntimeError("并发测试数据库主机必须使用 disposable 前缀") if not database.startswith(("migration-probe", "disposable-probe")): raise RuntimeError("并发测试数据库名必须使用 disposable 前缀") return DATABASE_URL