Add tenant-safe value, telemetry, connector, commercial, and production-readiness foundations.
230 lines
8.0 KiB
Python
230 lines
8.0 KiB
Python
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
|