feat(approval): add safe risk disposition workflow
This commit is contained in:
@@ -28,7 +28,7 @@ from app.models.risk_observation import RiskObservation
|
||||
|
||||
MIGRATION_TEST_DATABASE_URL = os.getenv("MIGRATION_TEST_DATABASE_URL", "").strip()
|
||||
LEGACY_PROBE_TABLE = "legacy_migration_probe_records"
|
||||
HEAD_REVISION = "20260716_0009"
|
||||
HEAD_REVISION = "20260716_0011"
|
||||
SERVER_DIR = Path(__file__).resolve().parents[1]
|
||||
ALEMBIC_INI_PATH = SERVER_DIR / "alembic.ini"
|
||||
|
||||
@@ -357,12 +357,150 @@ def _assert_head_schema(engine: Engine) -> None:
|
||||
"uq_risk_observations_tenant_key",
|
||||
("tenant_id", "observation_key"),
|
||||
)
|
||||
_assert_unique_constraint(
|
||||
engine,
|
||||
"risk_observations",
|
||||
"uq_risk_observations_tenant_id",
|
||||
("tenant_id", "id"),
|
||||
)
|
||||
_assert_unique_constraint(
|
||||
engine,
|
||||
"few_shot_samples",
|
||||
"uq_few_shot_samples_tenant_key",
|
||||
("tenant_id", "sample_key"),
|
||||
)
|
||||
_assert_unique_constraint(
|
||||
engine,
|
||||
"approval_action_ledgers",
|
||||
"uq_approval_action_ledger_request",
|
||||
("tenant_id", "actor_id", "request_id"),
|
||||
)
|
||||
_assert_indexes(
|
||||
engine,
|
||||
"approval_action_ledgers",
|
||||
{
|
||||
"ix_approval_action_ledger_claim_action": (
|
||||
"tenant_id",
|
||||
"claim_id",
|
||||
"action",
|
||||
)
|
||||
},
|
||||
)
|
||||
_assert_check_constraint(
|
||||
engine,
|
||||
"approval_action_ledgers",
|
||||
"ck_approval_action_ledger_action",
|
||||
)
|
||||
_assert_check_constraint(
|
||||
engine,
|
||||
"approval_action_ledgers",
|
||||
"ck_approval_action_ledger_completion",
|
||||
)
|
||||
_assert_unique_constraint(
|
||||
engine,
|
||||
"risk_dispositions",
|
||||
"uq_risk_dispositions_tenant_observation",
|
||||
("tenant_id", "observation_id"),
|
||||
)
|
||||
_assert_unique_constraint(
|
||||
engine,
|
||||
"risk_dispositions",
|
||||
"uq_risk_dispositions_tenant_id",
|
||||
("tenant_id", "id"),
|
||||
)
|
||||
_assert_composite_foreign_key(
|
||||
engine,
|
||||
"risk_dispositions",
|
||||
("tenant_id", "observation_id"),
|
||||
"risk_observations",
|
||||
)
|
||||
_assert_indexes(
|
||||
engine,
|
||||
"risk_dispositions",
|
||||
{
|
||||
"ix_risk_dispositions_tenant_lifecycle_due": (
|
||||
"tenant_id",
|
||||
"lifecycle_status",
|
||||
"due_at",
|
||||
),
|
||||
"ix_risk_dispositions_assignee": ("tenant_id", "assignee"),
|
||||
},
|
||||
)
|
||||
_assert_check_constraint(
|
||||
engine,
|
||||
"risk_dispositions",
|
||||
"ck_risk_dispositions_adjudication",
|
||||
)
|
||||
_assert_check_constraint(
|
||||
engine,
|
||||
"risk_dispositions",
|
||||
"ck_risk_dispositions_lifecycle",
|
||||
)
|
||||
_assert_check_constraint(
|
||||
engine,
|
||||
"risk_dispositions",
|
||||
"ck_risk_dispositions_version",
|
||||
)
|
||||
_assert_unique_constraint(
|
||||
engine,
|
||||
"risk_disposition_events",
|
||||
"uq_risk_disposition_events_tenant_request",
|
||||
("tenant_id", "request_id"),
|
||||
)
|
||||
_assert_unique_constraint(
|
||||
engine,
|
||||
"risk_disposition_events",
|
||||
"uq_risk_disposition_events_version",
|
||||
("disposition_id", "version"),
|
||||
)
|
||||
_assert_indexes(
|
||||
engine,
|
||||
"risk_disposition_events",
|
||||
{
|
||||
"ix_risk_disposition_events_disposition_id": ("disposition_id",),
|
||||
"ix_risk_disposition_events_tenant_observation_time": (
|
||||
"tenant_id",
|
||||
"observation_id",
|
||||
"created_at",
|
||||
),
|
||||
},
|
||||
)
|
||||
_assert_check_constraint(
|
||||
engine,
|
||||
"risk_disposition_events",
|
||||
"ck_risk_disposition_events_action",
|
||||
)
|
||||
_assert_check_constraint(
|
||||
engine,
|
||||
"risk_disposition_events",
|
||||
"ck_risk_disposition_events_version",
|
||||
)
|
||||
_assert_composite_foreign_key(
|
||||
engine,
|
||||
"risk_disposition_events",
|
||||
("tenant_id", "disposition_id"),
|
||||
"risk_dispositions",
|
||||
)
|
||||
_assert_composite_foreign_key(
|
||||
engine,
|
||||
"risk_disposition_events",
|
||||
("tenant_id", "observation_id"),
|
||||
"risk_observations",
|
||||
)
|
||||
with engine.connect() as connection:
|
||||
append_only_trigger_count = int(
|
||||
connection.scalar(
|
||||
text(
|
||||
"SELECT COUNT(*) FROM pg_trigger trigger "
|
||||
"JOIN pg_class relation ON relation.oid = trigger.tgrelid "
|
||||
"WHERE relation.relname = 'risk_disposition_events' "
|
||||
"AND trigger.tgname = 'trg_risk_disposition_events_append_only' "
|
||||
"AND NOT trigger.tgisinternal"
|
||||
)
|
||||
)
|
||||
or 0
|
||||
)
|
||||
assert append_only_trigger_count == 1
|
||||
_assert_check_constraint(
|
||||
engine,
|
||||
"memory_entries",
|
||||
@@ -972,12 +1110,15 @@ def _create_hierarchical_memory_downgrade_probe(engine: Engine) -> None:
|
||||
"""
|
||||
)
|
||||
)
|
||||
assert connection.scalar(
|
||||
text(
|
||||
"SELECT COUNT(*) FROM memory_entries "
|
||||
"WHERE id = 'hierarchical-memory-downgrade-probe'"
|
||||
assert (
|
||||
connection.scalar(
|
||||
text(
|
||||
"SELECT COUNT(*) FROM memory_entries "
|
||||
"WHERE id = 'hierarchical-memory-downgrade-probe'"
|
||||
)
|
||||
)
|
||||
) == 1
|
||||
== 1
|
||||
)
|
||||
|
||||
|
||||
def _create_duplicate_active_organization_memory_probe(engine: Engine) -> None:
|
||||
@@ -1056,10 +1197,7 @@ def _create_enriched_few_shot_downgrade_probe(engine: Engine) -> None:
|
||||
def _delete_enriched_few_shot_downgrade_probe(engine: Engine) -> None:
|
||||
with engine.begin() as connection:
|
||||
connection.execute(
|
||||
text(
|
||||
"DELETE FROM few_shot_samples "
|
||||
"WHERE id = 'enriched-few-shot-downgrade-probe'"
|
||||
)
|
||||
text("DELETE FROM few_shot_samples WHERE id = 'enriched-few-shot-downgrade-probe'")
|
||||
)
|
||||
|
||||
|
||||
@@ -1116,12 +1254,8 @@ def _create_historical_case_downgrade_probe(engine: Engine) -> None:
|
||||
|
||||
def _assert_historical_case_downgrade_probe(engine: Engine) -> None:
|
||||
inspector = inspect(engine)
|
||||
risk_columns = {
|
||||
str(item["name"]) for item in inspector.get_columns("risk_observations")
|
||||
}
|
||||
sample_columns = {
|
||||
str(item["name"]) for item in inspector.get_columns("few_shot_samples")
|
||||
}
|
||||
risk_columns = {str(item["name"]) for item in inspector.get_columns("risk_observations")}
|
||||
sample_columns = {str(item["name"]) for item in inspector.get_columns("few_shot_samples")}
|
||||
assert "tenant_id" not in risk_columns
|
||||
assert {"tenant_id", "policy_ref", "rule_version"}.isdisjoint(sample_columns)
|
||||
assert not any(
|
||||
@@ -1131,24 +1265,32 @@ def _assert_historical_case_downgrade_probe(engine: Engine) -> None:
|
||||
for item in inspector.get_foreign_keys("risk_observations")
|
||||
)
|
||||
with engine.connect() as connection:
|
||||
assert connection.scalar(
|
||||
text(
|
||||
"SELECT COUNT(*) FROM risk_observations "
|
||||
"WHERE id = 'historical-downgrade-observation'"
|
||||
assert (
|
||||
connection.scalar(
|
||||
text(
|
||||
"SELECT COUNT(*) FROM risk_observations "
|
||||
"WHERE id = 'historical-downgrade-observation'"
|
||||
)
|
||||
)
|
||||
) == 1
|
||||
assert connection.scalar(
|
||||
text(
|
||||
"SELECT COUNT(*) FROM risk_observation_feedback "
|
||||
"WHERE id = 'historical-downgrade-feedback'"
|
||||
== 1
|
||||
)
|
||||
assert (
|
||||
connection.scalar(
|
||||
text(
|
||||
"SELECT COUNT(*) FROM risk_observation_feedback "
|
||||
"WHERE id = 'historical-downgrade-feedback'"
|
||||
)
|
||||
)
|
||||
) == 1
|
||||
assert connection.scalar(
|
||||
text(
|
||||
"SELECT COUNT(*) FROM few_shot_samples "
|
||||
"WHERE id = 'historical-downgrade-sample'"
|
||||
== 1
|
||||
)
|
||||
assert (
|
||||
connection.scalar(
|
||||
text(
|
||||
"SELECT COUNT(*) FROM few_shot_samples WHERE id = 'historical-downgrade-sample'"
|
||||
)
|
||||
)
|
||||
) == 1
|
||||
== 1
|
||||
)
|
||||
|
||||
|
||||
def _assert_legacy_sentinel(engine: Engine) -> None:
|
||||
@@ -1179,6 +1321,10 @@ def _assert_base_schema(engine: Engine) -> None:
|
||||
("20260716_0008_tenant_safe_historical_cases.py", "downgrade"),
|
||||
("20260716_0009_organization_memory_idempotency.py", "upgrade"),
|
||||
("20260716_0009_organization_memory_idempotency.py", "downgrade"),
|
||||
("20260716_0010_approval_action_protocol.py", "upgrade"),
|
||||
("20260716_0010_approval_action_protocol.py", "downgrade"),
|
||||
("20260716_0011_risk_disposition.py", "upgrade"),
|
||||
("20260716_0011_risk_disposition.py", "downgrade"),
|
||||
],
|
||||
)
|
||||
def test_postgresql_only_migrations_reject_other_dialects_before_mutation(
|
||||
|
||||
225
server/tests/test_approval_risk_concurrency_postgres.py
Normal file
225
server/tests/test_approval_risk_concurrency_postgres.py
Normal file
@@ -0,0 +1,225 @@
|
||||
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(
|
||||
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}",
|
||||
employee_no=f"M-RISK-LOCK-{suffix}",
|
||||
name="并发审批经理",
|
||||
email=manager_email,
|
||||
)
|
||||
employee = Employee(
|
||||
id=f"employee-risk-lock-{suffix}",
|
||||
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,
|
||||
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
|
||||
221
server/tests/test_approval_workbench.py
Normal file
221
server/tests/test_approval_workbench.py
Normal file
@@ -0,0 +1,221 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, date, datetime, timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
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_workbench import ApprovalWorkbenchService
|
||||
|
||||
|
||||
def _claim(
|
||||
*,
|
||||
claim_no: str = "RE-WORKBENCH-1",
|
||||
amount: str = "888.00",
|
||||
submitted_at: datetime,
|
||||
risk_flags: list[dict] | None = None,
|
||||
invoice_count: int = 1,
|
||||
) -> ExpenseClaim:
|
||||
claim = ExpenseClaim(
|
||||
id=f"claim-{claim_no.lower()}",
|
||||
claim_no=claim_no,
|
||||
employee_id=None,
|
||||
employee_name="张三",
|
||||
department_id=None,
|
||||
department_name="市场部",
|
||||
project_code="PRJ-WORKBENCH",
|
||||
expense_type="travel",
|
||||
reason="客户现场差旅",
|
||||
location="上海",
|
||||
amount=Decimal(amount),
|
||||
currency="CNY",
|
||||
invoice_count=invoice_count,
|
||||
occurred_at=submitted_at,
|
||||
submitted_at=submitted_at,
|
||||
status="submitted",
|
||||
approval_stage="直属领导审批",
|
||||
risk_flags_json=list(risk_flags or []),
|
||||
created_at=submitted_at,
|
||||
updated_at=submitted_at,
|
||||
)
|
||||
claim.items = [
|
||||
ExpenseClaimItem(
|
||||
id=f"item-{claim_no.lower()}",
|
||||
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-WORKBENCH" if invoice_count else None,
|
||||
created_at=submitted_at,
|
||||
updated_at=submitted_at,
|
||||
)
|
||||
]
|
||||
return claim
|
||||
|
||||
|
||||
def test_priority_queue_explains_risk_budget_sla_amount_and_history() -> None:
|
||||
now = datetime(2026, 7, 16, 12, 0, tzinfo=UTC)
|
||||
claim = _claim(
|
||||
amount="60000.00",
|
||||
submitted_at=now - timedelta(hours=26),
|
||||
risk_flags=[
|
||||
{
|
||||
"source": "ai_pre_review",
|
||||
"severity": "high",
|
||||
"disposition": "review",
|
||||
"resolution_status": "unresolved",
|
||||
"route_decision": {"budget_result": {"metrics": {"after_usage_rate": "96.5"}}},
|
||||
"historical_case_evidence": [{"label": "confirmed", "sample_id": "must-not-leak"}],
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
item = ApprovalWorkbenchService.build_item(claim, now=now)
|
||||
|
||||
assert item.priority_score >= 85
|
||||
assert item.priority_tier == "urgent"
|
||||
assert item.risk_level == "high"
|
||||
assert item.sla_overdue is True
|
||||
assert item.budget_usage_rate == 96.5
|
||||
assert item.suggestion.action == "manual_review"
|
||||
assert item.evidence.historical_labels == ["历史已确认,仅供复核"]
|
||||
assert "must-not-leak" not in repr(item.model_dump())
|
||||
assert {reason.code for reason in item.priority_reasons} >= {
|
||||
"open_risk",
|
||||
"sla_overdue",
|
||||
"budget_pressure",
|
||||
"large_amount",
|
||||
}
|
||||
|
||||
|
||||
def test_application_evidence_does_not_require_invoice_and_resolved_risk_is_ignored() -> None:
|
||||
now = datetime(2026, 7, 16, 12, 0, tzinfo=UTC)
|
||||
claim = _claim(
|
||||
claim_no="AP-WORKBENCH-1",
|
||||
submitted_at=now - timedelta(hours=1),
|
||||
invoice_count=0,
|
||||
risk_flags=[
|
||||
{
|
||||
"source": "ai_pre_review",
|
||||
"severity": "critical",
|
||||
"resolution_status": "resolved",
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
item = ApprovalWorkbenchService.build_item(claim, now=now)
|
||||
|
||||
assert item.evidence.completeness == 1
|
||||
assert item.evidence.missing_labels == []
|
||||
assert item.risk_level == "low"
|
||||
assert item.open_risk_count == 0
|
||||
assert item.suggestion.action == "approve_candidate"
|
||||
assert item.priority_score == 0
|
||||
|
||||
|
||||
def test_persisted_disposition_is_authoritative_over_stale_claim_risk_flags() -> None:
|
||||
now = datetime(2026, 7, 16, 12, 0, tzinfo=UTC)
|
||||
claim = _claim(
|
||||
submitted_at=now - timedelta(hours=1),
|
||||
risk_flags=[
|
||||
{
|
||||
"severity": "critical",
|
||||
"triggered": True,
|
||||
"observation_key": "risk:workbench:resolved",
|
||||
}
|
||||
],
|
||||
)
|
||||
observation = RiskObservation(
|
||||
id="risk-workbench-resolved",
|
||||
tenant_id="default",
|
||||
observation_key="risk:workbench: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=95,
|
||||
risk_level="critical",
|
||||
confidence_score=0.96,
|
||||
control_stage="reimbursement",
|
||||
control_mode="risk_observation",
|
||||
automation_mode="semi_auto_review",
|
||||
source="financial_risk_graph",
|
||||
algorithm_version="v1",
|
||||
status="resolved",
|
||||
feedback_status="confirmed",
|
||||
)
|
||||
disposition = RiskDisposition(
|
||||
tenant_id="default",
|
||||
observation_id=observation.id,
|
||||
adjudication="confirmed",
|
||||
lifecycle_status="resolved",
|
||||
version=2,
|
||||
)
|
||||
|
||||
item = ApprovalWorkbenchService.build_item(
|
||||
claim,
|
||||
now=now,
|
||||
observation_rows=[(observation, disposition)],
|
||||
)
|
||||
|
||||
assert item.risk_level == "low"
|
||||
assert item.open_risk_count == 0
|
||||
assert item.suggestion.action == "approve_candidate"
|
||||
|
||||
|
||||
def test_persisted_low_risk_does_not_hide_unmaterialized_raw_high_risk() -> None:
|
||||
now = datetime(2026, 7, 16, 12, 0, tzinfo=UTC)
|
||||
claim = _claim(
|
||||
submitted_at=now - timedelta(hours=1),
|
||||
risk_flags=[
|
||||
{
|
||||
"source": "attachment_analysis",
|
||||
"severity": "high",
|
||||
"label": "票据金额异常",
|
||||
"triggered": True,
|
||||
}
|
||||
],
|
||||
)
|
||||
observation = RiskObservation(
|
||||
id="risk-workbench-low",
|
||||
tenant_id="default",
|
||||
observation_key="risk:workbench:low",
|
||||
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="minor_notice",
|
||||
risk_signal="minor_notice",
|
||||
title="普通提醒",
|
||||
description="普通提醒。",
|
||||
risk_score=30,
|
||||
risk_level="low",
|
||||
confidence_score=0.8,
|
||||
control_stage="reimbursement",
|
||||
control_mode="risk_observation",
|
||||
automation_mode="manual_review",
|
||||
source="financial_risk_graph",
|
||||
algorithm_version="v1",
|
||||
status="pending_review",
|
||||
feedback_status="unreviewed",
|
||||
)
|
||||
|
||||
item = ApprovalWorkbenchService.build_item(
|
||||
claim,
|
||||
now=now,
|
||||
observation_rows=[(observation, None)],
|
||||
)
|
||||
|
||||
assert item.risk_level == "high"
|
||||
assert item.open_risk_count == 2
|
||||
assert item.suggestion.action == "manual_review"
|
||||
322
server/tests/test_expense_claim_action_protocol.py
Normal file
322
server/tests/test_expense_claim_action_protocol.py
Normal file
@@ -0,0 +1,322 @@
|
||||
from __future__ import annotations
|
||||
|
||||
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.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.audit_log import AuditLog
|
||||
from app.models.employee import Employee
|
||||
from app.models.expense_case import BusinessEvent
|
||||
from app.models.financial_record import ExpenseClaim
|
||||
from app.services.approval_action_protocol import (
|
||||
ApprovalActionConflictError,
|
||||
ApprovalActionProtocol,
|
||||
)
|
||||
from app.services.expense_claims import ExpenseClaimService
|
||||
|
||||
|
||||
def _manager_user() -> CurrentUserContext:
|
||||
return CurrentUserContext(
|
||||
username="manager-action@example.com",
|
||||
name="李经理",
|
||||
role_codes=["manager"],
|
||||
is_admin=False,
|
||||
)
|
||||
|
||||
|
||||
def _finance_user() -> CurrentUserContext:
|
||||
return CurrentUserContext(
|
||||
username="finance-action@example.com",
|
||||
name="王财务",
|
||||
role_codes=["finance"],
|
||||
is_admin=False,
|
||||
)
|
||||
|
||||
|
||||
def _seed_claim(
|
||||
db: Session,
|
||||
*,
|
||||
claim_id: str = "claim-action-1",
|
||||
manager_email: str = "manager-action@example.com",
|
||||
) -> ExpenseClaim:
|
||||
manager = Employee(
|
||||
id=f"manager-{claim_id}",
|
||||
employee_no=f"M-{claim_id}",
|
||||
name="李经理",
|
||||
email=manager_email,
|
||||
)
|
||||
employee = Employee(
|
||||
id=f"employee-{claim_id}",
|
||||
employee_no=f"E-{claim_id}",
|
||||
name="张三",
|
||||
email=f"employee-{claim_id}@example.com",
|
||||
manager=manager,
|
||||
)
|
||||
claim = ExpenseClaim(
|
||||
id=claim_id,
|
||||
claim_no=f"EXP-{claim_id}",
|
||||
employee=employee,
|
||||
employee_name="张三",
|
||||
department_name="市场部",
|
||||
expense_type="transport",
|
||||
reason="客户拜访",
|
||||
location="上海",
|
||||
amount=Decimal("88.00"),
|
||||
currency="CNY",
|
||||
invoice_count=1,
|
||||
occurred_at=datetime(2026, 7, 16, tzinfo=UTC),
|
||||
submitted_at=datetime(2026, 7, 16, tzinfo=UTC),
|
||||
status="submitted",
|
||||
approval_stage="直属领导审批",
|
||||
risk_flags_json=[],
|
||||
)
|
||||
db.add(claim)
|
||||
db.commit()
|
||||
return claim
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def session_factory() -> sessionmaker[Session]:
|
||||
engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||
Base.metadata.create_all(engine)
|
||||
factory = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)
|
||||
try:
|
||||
yield factory
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_approve_replay_persists_one_ledger_event_and_audit(
|
||||
session_factory: sessionmaker[Session],
|
||||
) -> None:
|
||||
with session_factory() as db:
|
||||
claim = _seed_claim(db)
|
||||
service = ExpenseClaimService(db)
|
||||
first = service.approve_claim(
|
||||
claim.id,
|
||||
_manager_user(),
|
||||
opinion="同意",
|
||||
request_id="approve-retry-1",
|
||||
expected_status="submitted",
|
||||
expected_approval_stage="直属领导审批",
|
||||
)
|
||||
replay = service.approve_claim(
|
||||
claim.id,
|
||||
_manager_user(),
|
||||
opinion="同意",
|
||||
request_id="approve-retry-1",
|
||||
expected_status="submitted",
|
||||
expected_approval_stage="直属领导审批",
|
||||
)
|
||||
|
||||
assert first is not None and replay is not None
|
||||
assert replay.approval_stage == "财务审批"
|
||||
assert db.scalar(select(func.count()).select_from(ApprovalActionLedger)) == 1
|
||||
assert db.scalar(select(func.count()).select_from(BusinessEvent)) == 1
|
||||
assert (
|
||||
db.scalar(
|
||||
select(func.count())
|
||||
.select_from(AuditLog)
|
||||
.where(AuditLog.request_id == "approve-retry-1")
|
||||
)
|
||||
== 1
|
||||
)
|
||||
|
||||
|
||||
def test_request_id_payload_mismatch_and_stale_preconditions_return_conflict(
|
||||
session_factory: sessionmaker[Session],
|
||||
) -> None:
|
||||
with session_factory() as db:
|
||||
claim = _seed_claim(db, claim_id="claim-action-conflict")
|
||||
service = ExpenseClaimService(db)
|
||||
with pytest.raises(ApprovalActionConflictError, match="单据状态已从"):
|
||||
service.approve_claim(
|
||||
claim.id,
|
||||
_manager_user(),
|
||||
opinion="同意",
|
||||
request_id="approve-stale-1",
|
||||
expected_status="draft",
|
||||
expected_approval_stage="直属领导审批",
|
||||
)
|
||||
service.approve_claim(
|
||||
claim.id,
|
||||
_manager_user(),
|
||||
opinion="同意",
|
||||
request_id="approve-conflict-1",
|
||||
expected_status="submitted",
|
||||
expected_approval_stage="直属领导审批",
|
||||
)
|
||||
|
||||
with pytest.raises(ApprovalActionConflictError, match="已用于另一项"):
|
||||
service.approve_claim(
|
||||
claim.id,
|
||||
_manager_user(),
|
||||
opinion="改为有条件通过",
|
||||
request_id="approve-conflict-1",
|
||||
expected_status="submitted",
|
||||
expected_approval_stage="直属领导审批",
|
||||
)
|
||||
|
||||
assert (
|
||||
db.scalar(
|
||||
select(func.count())
|
||||
.select_from(ApprovalActionLedger)
|
||||
.where(ApprovalActionLedger.request_id == "approve-stale-1")
|
||||
)
|
||||
== 0
|
||||
)
|
||||
|
||||
|
||||
def test_action_failure_rolls_back_ledger_claim_event_and_audit(
|
||||
session_factory: sessionmaker[Session],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
with session_factory() as db:
|
||||
claim = _seed_claim(db, claim_id="claim-action-rollback")
|
||||
service = ExpenseClaimService(db)
|
||||
|
||||
def fail_completion(*args, **kwargs):
|
||||
raise RuntimeError("ledger completion failed")
|
||||
|
||||
monkeypatch.setattr(ApprovalActionProtocol, "complete", fail_completion)
|
||||
with pytest.raises(RuntimeError, match="ledger completion failed"):
|
||||
service.approve_claim(
|
||||
claim.id,
|
||||
_manager_user(),
|
||||
opinion="同意",
|
||||
request_id="approve-rollback-1",
|
||||
expected_status="submitted",
|
||||
expected_approval_stage="直属领导审批",
|
||||
)
|
||||
|
||||
db.expire_all()
|
||||
persisted = db.get(ExpenseClaim, claim.id)
|
||||
assert persisted is not None
|
||||
assert persisted.status == "submitted"
|
||||
assert persisted.approval_stage == "直属领导审批"
|
||||
assert db.scalar(select(func.count()).select_from(ApprovalActionLedger)) == 0
|
||||
assert db.scalar(select(func.count()).select_from(BusinessEvent)) == 0
|
||||
assert db.scalar(select(func.count()).select_from(AuditLog)) == 0
|
||||
|
||||
|
||||
def test_legacy_stage_repair_cannot_commit_inside_action_protocol(
|
||||
session_factory: sessionmaker[Session],
|
||||
) -> None:
|
||||
with session_factory() as db:
|
||||
claim = _seed_claim(db, claim_id="claim-action-stage-repair")
|
||||
claim.approval_stage = "预算管理者审批"
|
||||
claim.risk_flags_json = [
|
||||
{
|
||||
"source": "manual_approval",
|
||||
"event_type": "expense_claim_approval",
|
||||
"previous_approval_stage": "直属领导审批",
|
||||
"next_approval_stage": "预算管理者审批",
|
||||
"operator": "李经理",
|
||||
"next_approver_name": "李经理",
|
||||
}
|
||||
]
|
||||
db.commit()
|
||||
admin_user = CurrentUserContext(
|
||||
username="admin-action@example.com",
|
||||
name="审批管理员",
|
||||
role_codes=["admin"],
|
||||
is_admin=True,
|
||||
)
|
||||
|
||||
with pytest.raises(ApprovalActionConflictError, match="审批节点已从"):
|
||||
ExpenseClaimService(db).approve_claim(
|
||||
claim.id,
|
||||
admin_user,
|
||||
opinion="同意",
|
||||
request_id="approve-stage-repair-1",
|
||||
expected_status="submitted",
|
||||
expected_approval_stage="预算管理者审批",
|
||||
)
|
||||
|
||||
db.expire_all()
|
||||
persisted = db.get(ExpenseClaim, claim.id)
|
||||
assert persisted is not None
|
||||
assert persisted.approval_stage == "预算管理者审批"
|
||||
assert db.scalar(select(func.count()).select_from(ApprovalActionLedger)) == 0
|
||||
|
||||
|
||||
def test_return_and_pay_actions_use_the_same_protocol(
|
||||
session_factory: sessionmaker[Session],
|
||||
) -> None:
|
||||
with session_factory() as db:
|
||||
returned_claim = _seed_claim(db, claim_id="claim-action-return")
|
||||
paid_claim = _seed_claim(
|
||||
db,
|
||||
claim_id="claim-action-pay",
|
||||
manager_email="manager-pay-action@example.com",
|
||||
)
|
||||
paid_claim.status = "pending_payment"
|
||||
paid_claim.approval_stage = "待付款"
|
||||
db.commit()
|
||||
|
||||
returned = ExpenseClaimService(db).return_claim(
|
||||
returned_claim.id,
|
||||
_manager_user(),
|
||||
reason="请补充材料",
|
||||
request_id="return-action-1",
|
||||
expected_status="submitted",
|
||||
expected_approval_stage="直属领导审批",
|
||||
)
|
||||
paid = ExpenseClaimService(db).mark_claim_paid(
|
||||
paid_claim.id,
|
||||
_finance_user(),
|
||||
request_id="pay-action-1",
|
||||
expected_status="pending_payment",
|
||||
expected_approval_stage="待付款",
|
||||
)
|
||||
|
||||
assert returned is not None and returned.status == "returned"
|
||||
assert paid is not None and paid.status == "paid"
|
||||
ledgers = list(
|
||||
db.scalars(select(ApprovalActionLedger).order_by(ApprovalActionLedger.action)).all()
|
||||
)
|
||||
assert [(item.action, item.result_status) for item in ledgers] == [
|
||||
("pay", "paid"),
|
||||
("return", "returned"),
|
||||
]
|
||||
|
||||
|
||||
def test_concurrent_identical_request_executes_once(tmp_path) -> None:
|
||||
engine = create_engine(
|
||||
f"sqlite+pysqlite:///{tmp_path / 'approval-action.db'}",
|
||||
connect_args={"check_same_thread": False},
|
||||
)
|
||||
Base.metadata.create_all(engine)
|
||||
factory = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)
|
||||
with factory() as db:
|
||||
_seed_claim(db, claim_id="claim-action-concurrent")
|
||||
|
||||
def approve() -> str:
|
||||
with factory() as db:
|
||||
result = ExpenseClaimService(db).approve_claim(
|
||||
"claim-action-concurrent",
|
||||
_manager_user(),
|
||||
opinion="同意",
|
||||
request_id="approve-concurrent-1",
|
||||
expected_status="submitted",
|
||||
expected_approval_stage="直属领导审批",
|
||||
)
|
||||
assert result is not None
|
||||
return str(result.approval_stage)
|
||||
|
||||
try:
|
||||
with ThreadPoolExecutor(max_workers=2) as pool:
|
||||
results = list(pool.map(lambda _: approve(), range(2)))
|
||||
assert results == ["财务审批", "财务审批"]
|
||||
with factory() as db:
|
||||
assert db.scalar(select(func.count()).select_from(ApprovalActionLedger)) == 1
|
||||
assert db.scalar(select(func.count()).select_from(BusinessEvent)) == 1
|
||||
finally:
|
||||
engine.dispose()
|
||||
222
server/tests/test_expense_claim_risk_gate.py
Normal file
222
server/tests/test_expense_claim_risk_gate.py
Normal file
@@ -0,0 +1,222 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine, func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
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.services.expense_claim_risk_gate import (
|
||||
ExpenseClaimRiskBlockedError,
|
||||
ExpenseClaimRiskGate,
|
||||
)
|
||||
from app.services.expense_claims import ExpenseClaimService
|
||||
|
||||
|
||||
def test_high_risk_requires_false_positive_or_resolved_disposition() -> None:
|
||||
with _session() as db:
|
||||
claim = _claim()
|
||||
observation = _observation(claim)
|
||||
db.add_all([claim, observation])
|
||||
db.commit()
|
||||
gate = ExpenseClaimRiskGate(db)
|
||||
|
||||
with pytest.raises(ExpenseClaimRiskBlockedError):
|
||||
gate.ensure_approvable(claim, tenant_id="default")
|
||||
|
||||
disposition = RiskDisposition(
|
||||
tenant_id="default",
|
||||
observation_id=observation.id,
|
||||
adjudication="false_positive",
|
||||
lifecycle_status="open",
|
||||
)
|
||||
db.add(disposition)
|
||||
db.commit()
|
||||
gate.ensure_approvable(claim, tenant_id="default")
|
||||
|
||||
disposition.adjudication = "confirmed"
|
||||
db.commit()
|
||||
with pytest.raises(ExpenseClaimRiskBlockedError):
|
||||
gate.ensure_approvable(claim, tenant_id="default")
|
||||
|
||||
disposition.lifecycle_status = "resolved"
|
||||
db.commit()
|
||||
gate.ensure_approvable(claim, tenant_id="default")
|
||||
|
||||
|
||||
def test_medium_and_foreign_tenant_risks_do_not_block_claim() -> None:
|
||||
with _session() as db:
|
||||
claim = _claim(claim_id="claim-risk-nonblocking")
|
||||
medium = _observation(claim, observation_id="risk-medium", risk_level="medium")
|
||||
foreign = _observation(
|
||||
claim,
|
||||
observation_id="risk-foreign",
|
||||
risk_level="critical",
|
||||
tenant_id="tenant-b",
|
||||
)
|
||||
db.add_all([claim, medium, foreign])
|
||||
db.commit()
|
||||
|
||||
ExpenseClaimRiskGate(db).ensure_approvable(claim, tenant_id="default")
|
||||
|
||||
|
||||
def test_unmaterialized_raw_high_risk_blocks_approval() -> None:
|
||||
with _session() as db:
|
||||
claim = _claim(claim_id="claim-risk-raw-only")
|
||||
claim.risk_flags_json = [
|
||||
{
|
||||
"source": "attachment_analysis",
|
||||
"severity": "high",
|
||||
"label": "票据金额异常",
|
||||
"message": "票据金额与申报金额不一致。",
|
||||
"triggered": True,
|
||||
}
|
||||
]
|
||||
db.add(claim)
|
||||
db.commit()
|
||||
|
||||
with pytest.raises(ExpenseClaimRiskBlockedError) as captured:
|
||||
ExpenseClaimRiskGate(db).ensure_approvable(claim, tenant_id="default")
|
||||
|
||||
assert captured.value.blockers[0].observation_id.startswith("raw:")
|
||||
assert captured.value.blockers[0].risk_level == "high"
|
||||
|
||||
|
||||
def test_persisted_observation_does_not_hide_another_raw_high_risk() -> None:
|
||||
with _session() as db:
|
||||
claim = _claim(claim_id="claim-risk-partial-materialization")
|
||||
claim.risk_flags_json = [
|
||||
{
|
||||
"source": "attachment_analysis",
|
||||
"severity": "critical",
|
||||
"label": "另一条未物化风险",
|
||||
"triggered": True,
|
||||
}
|
||||
]
|
||||
low_observation = _observation(
|
||||
claim,
|
||||
observation_id="risk-low-materialized",
|
||||
risk_level="low",
|
||||
)
|
||||
db.add_all([claim, low_observation])
|
||||
db.commit()
|
||||
|
||||
with pytest.raises(ExpenseClaimRiskBlockedError) as captured:
|
||||
ExpenseClaimRiskGate(db).ensure_approvable(claim, tenant_id="default")
|
||||
|
||||
assert [item.risk_level for item in captured.value.blockers] == ["critical"]
|
||||
|
||||
|
||||
def test_blocked_approval_rolls_back_action_ledger_and_claim_mutation() -> None:
|
||||
with _session() as db:
|
||||
manager = Employee(
|
||||
id="manager-risk-gate",
|
||||
employee_no="M-RISK-GATE",
|
||||
name="风险经理",
|
||||
email="risk-gate-manager@example.com",
|
||||
)
|
||||
employee = Employee(
|
||||
id="employee-risk-gate",
|
||||
employee_no="E-RISK-GATE",
|
||||
name="风险员工",
|
||||
email="risk-gate-employee@example.com",
|
||||
manager=manager,
|
||||
)
|
||||
claim = _claim(employee=employee, claim_id="claim-risk-blocked-approval")
|
||||
db.add_all([manager, employee, claim, _observation(claim)])
|
||||
db.commit()
|
||||
|
||||
with pytest.raises(ExpenseClaimRiskBlockedError):
|
||||
ExpenseClaimService(db).approve_claim(
|
||||
claim.id,
|
||||
CurrentUserContext(
|
||||
username=manager.email,
|
||||
name=manager.name,
|
||||
role_codes=["manager"],
|
||||
is_admin=False,
|
||||
),
|
||||
opinion="同意",
|
||||
request_id="risk-blocked-approval-001",
|
||||
expected_status="submitted",
|
||||
expected_approval_stage="直属领导审批",
|
||||
)
|
||||
|
||||
db.expire_all()
|
||||
persisted = db.get(ExpenseClaim, claim.id)
|
||||
assert persisted is not None
|
||||
assert persisted.status == "submitted"
|
||||
assert persisted.approval_stage == "直属领导审批"
|
||||
assert db.scalar(select(func.count()).select_from(ApprovalActionLedger)) == 0
|
||||
|
||||
|
||||
def _session() -> Session:
|
||||
engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||
Base.metadata.create_all(bind=engine)
|
||||
return Session(engine)
|
||||
|
||||
|
||||
def _claim(
|
||||
*,
|
||||
claim_id: str = "claim-risk-gate",
|
||||
employee: Employee | None = None,
|
||||
) -> ExpenseClaim:
|
||||
now = datetime(2026, 7, 16, tzinfo=UTC)
|
||||
return ExpenseClaim(
|
||||
id=claim_id,
|
||||
claim_no=f"EXP-{claim_id}",
|
||||
employee=employee,
|
||||
employee_name=employee.name if employee else "风险员工",
|
||||
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=[],
|
||||
)
|
||||
|
||||
|
||||
def _observation(
|
||||
claim: ExpenseClaim,
|
||||
*,
|
||||
observation_id: str = "risk-gate-observation",
|
||||
risk_level: str = "high",
|
||||
tenant_id: str = "default",
|
||||
) -> RiskObservation:
|
||||
return RiskObservation(
|
||||
id=f"{observation_id}-{claim.id}",
|
||||
tenant_id=tenant_id,
|
||||
observation_key=f"risk:{tenant_id}:{observation_id}:{claim.id}",
|
||||
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=90,
|
||||
risk_level=risk_level,
|
||||
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="pending_review",
|
||||
feedback_status="unreviewed",
|
||||
)
|
||||
@@ -38,7 +38,6 @@ from app.services.expense_claim_workflow_constants import (
|
||||
APPROVAL_DONE_STAGE,
|
||||
BUDGET_MANAGER_APPROVAL_STAGE,
|
||||
DIRECT_MANAGER_APPROVAL_STAGE,
|
||||
FINANCE_APPROVAL_STAGE,
|
||||
)
|
||||
from app.services.expense_claims import ExpenseClaimService
|
||||
from app.services.ocr import OcrService
|
||||
@@ -486,11 +485,13 @@ def test_upsert_draft_from_ontology_persists_linked_application_context() -> Non
|
||||
)
|
||||
db.add(employee)
|
||||
db.flush()
|
||||
db.add(build_application_claim(
|
||||
id="application-linked-1",
|
||||
claim_no="AP-202605-001",
|
||||
employee=employee,
|
||||
))
|
||||
db.add(
|
||||
build_application_claim(
|
||||
id="application-linked-1",
|
||||
claim_no="AP-202605-001",
|
||||
employee=employee,
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
ontology = SemanticOntologyService(db).parse(
|
||||
OntologyParseRequest(
|
||||
@@ -554,11 +555,13 @@ def test_upsert_linked_application_draft_without_receipts_has_no_placeholder_ite
|
||||
)
|
||||
db.add(employee)
|
||||
db.flush()
|
||||
db.add(build_application_claim(
|
||||
id="application-linked-no-receipt",
|
||||
claim_no="AP-202606-001",
|
||||
employee=employee,
|
||||
))
|
||||
db.add(
|
||||
build_application_claim(
|
||||
id="application-linked-no-receipt",
|
||||
claim_no="AP-202606-001",
|
||||
employee=employee,
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
ontology = SemanticOntologyService(db).parse(
|
||||
OntologyParseRequest(
|
||||
@@ -623,7 +626,10 @@ def test_upsert_linked_application_draft_without_receipts_has_no_placeholder_ite
|
||||
)
|
||||
assert link_flag["application_claim_no"] == "AP-202606-001"
|
||||
assert link_flag["application_detail"]["application_time"] == "2026-02-20 至 2026-02-23"
|
||||
assert link_flag["application_detail"]["application_business_time"] == "2026-02-20 至 2026-02-23"
|
||||
assert (
|
||||
link_flag["application_detail"]["application_business_time"]
|
||||
== "2026-02-20 至 2026-02-23"
|
||||
)
|
||||
assert link_flag["application_detail"]["application_date"] == "2026-06-02T00:58:00Z"
|
||||
assert link_flag["application_detail"]["application_amount"] == "3000"
|
||||
assert link_flag["application_detail"]["application_days"] == "4 天"
|
||||
@@ -649,11 +655,13 @@ def test_upsert_linked_application_draft_clears_existing_placeholder_item() -> N
|
||||
)
|
||||
db.add(employee)
|
||||
db.flush()
|
||||
db.add(build_application_claim(
|
||||
id="application-linked-existing-placeholder",
|
||||
claim_no="AP-202606-002",
|
||||
employee=employee,
|
||||
))
|
||||
db.add(
|
||||
build_application_claim(
|
||||
id="application-linked-existing-placeholder",
|
||||
claim_no="AP-202606-002",
|
||||
employee=employee,
|
||||
)
|
||||
)
|
||||
existing_claim = ExpenseClaim(
|
||||
claim_no="RE-202606020001-PLACEHOLDER",
|
||||
employee_id=employee.id,
|
||||
@@ -738,12 +746,14 @@ def test_upsert_linked_application_requires_approved_application() -> None:
|
||||
employee = Employee(employee_no="E5108", name="Linked Employee", email=user_id)
|
||||
db.add(employee)
|
||||
db.flush()
|
||||
db.add(build_application_claim(
|
||||
id="application-returned-blocked",
|
||||
claim_no="AP-202606-STATUS",
|
||||
employee=employee,
|
||||
status="returned",
|
||||
))
|
||||
db.add(
|
||||
build_application_claim(
|
||||
id="application-returned-blocked",
|
||||
claim_no="AP-202606-STATUS",
|
||||
employee=employee,
|
||||
status="returned",
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
ontology = SemanticOntologyService(db).parse(
|
||||
@@ -785,11 +795,13 @@ def test_upsert_linked_application_rejects_duplicate_reimbursement_draft() -> No
|
||||
employee = Employee(employee_no="E5109", name="Linked Employee", email=user_id)
|
||||
db.add(employee)
|
||||
db.flush()
|
||||
db.add(build_application_claim(
|
||||
id="application-duplicate-blocked",
|
||||
claim_no="AP-202606-DUP",
|
||||
employee=employee,
|
||||
))
|
||||
db.add(
|
||||
build_application_claim(
|
||||
id="application-duplicate-blocked",
|
||||
claim_no="AP-202606-DUP",
|
||||
employee=employee,
|
||||
)
|
||||
)
|
||||
existing_claim = ExpenseClaim(
|
||||
claim_no="RE-202606-DUP-DRAFT",
|
||||
employee_id=employee.id,
|
||||
@@ -995,11 +1007,7 @@ def test_unsaved_conversation_expires_after_retention_but_saved_conversation_sta
|
||||
def test_resolve_expense_type_maps_office_supplies_review_value_to_office() -> None:
|
||||
expense_type = ExpenseClaimService._resolve_expense_type(
|
||||
[],
|
||||
context_json={
|
||||
"review_form_values": {
|
||||
"expense_type": "办公用品"
|
||||
}
|
||||
},
|
||||
context_json={"review_form_values": {"expense_type": "办公用品"}},
|
||||
)
|
||||
|
||||
assert expense_type == "office"
|
||||
@@ -1008,11 +1016,7 @@ def test_resolve_expense_type_maps_office_supplies_review_value_to_office() -> N
|
||||
def test_resolve_expense_type_maps_riding_fare_review_value_to_transport() -> None:
|
||||
expense_type = ExpenseClaimService._resolve_expense_type(
|
||||
[],
|
||||
context_json={
|
||||
"review_form_values": {
|
||||
"expense_type": "乘车费用"
|
||||
}
|
||||
},
|
||||
context_json={"review_form_values": {"expense_type": "乘车费用"}},
|
||||
)
|
||||
|
||||
assert expense_type == "transport"
|
||||
@@ -1340,7 +1344,9 @@ def test_upsert_draft_from_ontology_supports_link_or_create_for_multi_documents(
|
||||
"text": "停车费 合计 18 元",
|
||||
"document_type": "parking_toll_receipt",
|
||||
"scene_code": "transport",
|
||||
"document_fields": [{"key": "total_amount", "label": "合计金额", "value": "18"}],
|
||||
"document_fields": [
|
||||
{"key": "total_amount", "label": "合计金额", "value": "18"}
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -2044,6 +2050,7 @@ def test_update_claim_item_reanalyzes_existing_attachment(monkeypatch, tmp_path)
|
||||
assert refreshed_meta["requirement_check"]["matches"] is False
|
||||
assert any("附件类型要求" in point for point in refreshed_meta["analysis"]["points"])
|
||||
|
||||
|
||||
def test_upload_attachment_refreshes_claim_pre_review(monkeypatch, tmp_path) -> None:
|
||||
current_user = CurrentUserContext(
|
||||
username="emp-1",
|
||||
@@ -2518,15 +2525,13 @@ def test_upload_attachment_runs_rule_center_city_risk_from_origin_destination_fi
|
||||
|
||||
flags = payload["claim_risk_flags"]
|
||||
assert any(
|
||||
isinstance(flag, dict)
|
||||
and flag.get("rule_code") == "risk.travel.high.city_mismatch"
|
||||
isinstance(flag, dict) and flag.get("rule_code") == "risk.travel.high.city_mismatch"
|
||||
for flag in flags
|
||||
)
|
||||
city_flag = next(
|
||||
flag
|
||||
for flag in flags
|
||||
if isinstance(flag, dict)
|
||||
and flag.get("rule_code") == "risk.travel.high.city_mismatch"
|
||||
if isinstance(flag, dict) and flag.get("rule_code") == "risk.travel.high.city_mismatch"
|
||||
)
|
||||
assert city_flag.get("item_ids") == [claim.items[0].id]
|
||||
|
||||
@@ -2604,8 +2609,7 @@ def test_upload_attachment_uses_linked_application_business_time_for_date_risk(
|
||||
|
||||
flags = payload["claim_risk_flags"]
|
||||
assert any(
|
||||
isinstance(flag, dict)
|
||||
and flag.get("rule_code") == "risk.travel.high.date_outside_trip"
|
||||
isinstance(flag, dict) and flag.get("rule_code") == "risk.travel.high.date_outside_trip"
|
||||
for flag in flags
|
||||
)
|
||||
|
||||
@@ -2686,8 +2690,13 @@ def test_upload_hotel_attachment_audits_date_like_amount(monkeypatch, tmp_path)
|
||||
)
|
||||
assert uploaded_meta is not None
|
||||
assert uploaded_meta["analysis"]["severity"] == "medium"
|
||||
assert any("费用核算" in point and "828.00 元" in point for point in uploaded_meta["analysis"]["points"])
|
||||
assert not any("2026.00 元与报销金额" in point for point in uploaded_meta["analysis"]["points"])
|
||||
assert any(
|
||||
"费用核算" in point and "828.00 元" in point
|
||||
for point in uploaded_meta["analysis"]["points"]
|
||||
)
|
||||
assert not any(
|
||||
"2026.00 元与报销金额" in point for point in uploaded_meta["analysis"]["points"]
|
||||
)
|
||||
|
||||
|
||||
def test_upload_hotel_attachment_flags_amount_over_travel_policy(monkeypatch, tmp_path) -> None:
|
||||
@@ -2889,7 +2898,9 @@ def test_upload_hotel_attachment_does_not_add_generic_auto_review_summary(
|
||||
)
|
||||
|
||||
|
||||
def test_delete_claim_item_attachment_removes_attachment_analysis_risk(monkeypatch, tmp_path) -> None:
|
||||
def test_delete_claim_item_attachment_removes_attachment_analysis_risk(
|
||||
monkeypatch, tmp_path
|
||||
) -> None:
|
||||
current_user = CurrentUserContext(
|
||||
username="emp-hotel-risk@example.com",
|
||||
name="张三",
|
||||
@@ -2964,7 +2975,8 @@ def test_delete_claim_item_attachment_removes_attachment_analysis_risk(monkeypat
|
||||
|
||||
assert upload_payload is not None
|
||||
assert any(
|
||||
isinstance(flag, dict) and str(flag.get("source") or "").strip() == "attachment_analysis"
|
||||
isinstance(flag, dict)
|
||||
and str(flag.get("source") or "").strip() == "attachment_analysis"
|
||||
for flag in upload_payload["claim_risk_flags"]
|
||||
)
|
||||
|
||||
@@ -2977,7 +2989,8 @@ def test_delete_claim_item_attachment_removes_attachment_analysis_risk(monkeypat
|
||||
assert delete_payload is not None
|
||||
assert delete_payload["invoice_id"] is None
|
||||
assert not any(
|
||||
isinstance(flag, dict) and str(flag.get("source") or "").strip() == "attachment_analysis"
|
||||
isinstance(flag, dict)
|
||||
and str(flag.get("source") or "").strip() == "attachment_analysis"
|
||||
for flag in delete_payload["claim_risk_flags"]
|
||||
)
|
||||
assert not any(
|
||||
@@ -2990,7 +3003,8 @@ def test_delete_claim_item_attachment_removes_attachment_analysis_risk(monkeypat
|
||||
assert claim.invoice_count == 0
|
||||
assert claim.items[0].invoice_id is None
|
||||
assert not any(
|
||||
isinstance(flag, dict) and str(flag.get("source") or "").strip() == "attachment_analysis"
|
||||
isinstance(flag, dict)
|
||||
and str(flag.get("source") or "").strip() == "attachment_analysis"
|
||||
for flag in list(claim.risk_flags_json or [])
|
||||
)
|
||||
|
||||
@@ -3278,7 +3292,9 @@ def test_applicant_can_delete_own_editable_draft_claim(monkeypatch, tmp_path) ->
|
||||
assert db.get(ExpenseClaim, claim_id) is None
|
||||
|
||||
|
||||
def test_attachment_preview_resolves_legacy_filename_in_claim_item_directory(monkeypatch, tmp_path) -> None:
|
||||
def test_attachment_preview_resolves_legacy_filename_in_claim_item_directory(
|
||||
monkeypatch, tmp_path
|
||||
) -> None:
|
||||
current_user = CurrentUserContext(
|
||||
username="emp-1",
|
||||
name="张三",
|
||||
@@ -3316,7 +3332,9 @@ def test_attachment_preview_resolves_legacy_filename_in_claim_item_directory(mon
|
||||
assert filename == "legacy-ticket.pdf"
|
||||
|
||||
|
||||
def test_attachment_pdf_preview_falls_back_to_source_when_render_fonts_missing(monkeypatch, tmp_path) -> None:
|
||||
def test_attachment_pdf_preview_falls_back_to_source_when_render_fonts_missing(
|
||||
monkeypatch, tmp_path
|
||||
) -> None:
|
||||
current_user = CurrentUserContext(
|
||||
username="emp-1",
|
||||
name="张三",
|
||||
@@ -3359,9 +3377,13 @@ def test_attachment_pdf_preview_falls_back_to_source_when_render_fonts_missing(m
|
||||
def fake_render_pdf_first_page(*, pdf_path, preview_path, timeout_seconds):
|
||||
raise RuntimeError("Missing language pack for 'Adobe-GB1' mapping")
|
||||
|
||||
monkeypatch.setattr(DocumentPreviewAssets, "render_pdf_first_page", fake_render_pdf_first_page)
|
||||
monkeypatch.setattr(
|
||||
DocumentPreviewAssets, "render_pdf_first_page", fake_render_pdf_first_page
|
||||
)
|
||||
|
||||
resolved_path, media_type, filename = ExpenseClaimService(db).get_claim_item_attachment_preview_content(
|
||||
resolved_path, media_type, filename = ExpenseClaimService(
|
||||
db
|
||||
).get_claim_item_attachment_preview_content(
|
||||
claim_id=claim.id,
|
||||
item_id=claim.items[0].id,
|
||||
current_user=current_user,
|
||||
@@ -3412,6 +3434,7 @@ def test_submit_claim_runs_ai_review_and_routes_to_direct_manager() -> None:
|
||||
assert submitted.approval_stage == "直属领导审批"
|
||||
assert submitted.submitted_at is not None
|
||||
|
||||
|
||||
def test_submit_claim_refreshes_legacy_pre_review_without_fingerprint(monkeypatch) -> None:
|
||||
current_user = CurrentUserContext(
|
||||
username="emp-submit@example.com",
|
||||
@@ -3470,13 +3493,10 @@ def test_submit_claim_refreshes_legacy_pre_review_without_fingerprint(monkeypatc
|
||||
assert submitted.status == "submitted"
|
||||
assert review_calls == 1
|
||||
assert not any(
|
||||
flag.get("label") == "upload-time-warning"
|
||||
for flag in submitted.risk_flags_json
|
||||
flag.get("label") == "upload-time-warning" for flag in submitted.risk_flags_json
|
||||
)
|
||||
pre_review_flag = next(
|
||||
flag
|
||||
for flag in submitted.risk_flags_json
|
||||
if flag.get("source") == "ai_pre_review"
|
||||
flag for flag in submitted.risk_flags_json if flag.get("source") == "ai_pre_review"
|
||||
)
|
||||
assert pre_review_flag["review_id"]
|
||||
assert pre_review_flag["input_fingerprint"].startswith("sha256:")
|
||||
@@ -3823,8 +3843,7 @@ def test_submit_claim_blocks_high_risk_attachment_until_submitter_fixes_it(
|
||||
assert blocked.submitted_at is None
|
||||
assert error_info.value.review["decision"] == "needs_fix"
|
||||
assert any(
|
||||
finding["severity"] == "high"
|
||||
and finding["disposition"] == "fix"
|
||||
finding["severity"] == "high" and finding["disposition"] == "fix"
|
||||
for finding in error_info.value.review["findings"]
|
||||
)
|
||||
|
||||
@@ -4000,10 +4019,7 @@ def test_submit_claim_blocks_travel_route_mismatch_until_submitter_explains_it(
|
||||
if "多城市" in finding["message"] or "终点" in finding["message"]
|
||||
]
|
||||
assert route_findings
|
||||
assert any(
|
||||
"travel-item-2" in finding["item_ids"]
|
||||
for finding in route_findings
|
||||
)
|
||||
assert any("travel-item-2" in finding["item_ids"] for finding in route_findings)
|
||||
|
||||
|
||||
def test_submit_claim_allows_round_trip_ticket_origin_inferred_from_route(
|
||||
@@ -4297,8 +4313,7 @@ def test_submit_claim_blocks_hotel_amount_over_policy_until_standard_adjustment(
|
||||
assert blocked.status == "draft"
|
||||
assert error_info.value.review["decision"] == "needs_fix"
|
||||
assert any(
|
||||
finding.get("remediation", {}).get("alternative_action")
|
||||
== "accept_standard_limit"
|
||||
finding.get("remediation", {}).get("alternative_action") == "accept_standard_limit"
|
||||
for finding in error_info.value.review["findings"]
|
||||
)
|
||||
assert any(
|
||||
@@ -5360,12 +5375,15 @@ def test_admin_delete_linked_reimbursement_resets_application_link_status() -> N
|
||||
sync_flag = next(
|
||||
flag
|
||||
for flag in application_claim.risk_flags_json
|
||||
if isinstance(flag, dict) and flag.get("event_type") == "expense_application_reimbursement_deleted"
|
||||
if isinstance(flag, dict)
|
||||
and flag.get("event_type") == "expense_application_reimbursement_deleted"
|
||||
)
|
||||
assert sync_flag["source"] == "application_link_sync"
|
||||
assert sync_flag["severity"] == "info"
|
||||
assert sync_flag["actionability"] == "system_trace"
|
||||
assert sync_flag["deleted_reimbursement_claim_id"] == "reimbursement-delete-linked-application"
|
||||
assert (
|
||||
sync_flag["deleted_reimbursement_claim_id"] == "reimbursement-delete-linked-application"
|
||||
)
|
||||
assert sync_flag["deleted_reimbursement_claim_no"] == "RDELETE01"
|
||||
assert sync_flag["next_approval_stage"] == APPLICATION_LINK_STATUS_STAGE
|
||||
|
||||
@@ -5414,7 +5432,9 @@ def test_direct_manager_can_return_subordinate_claim_to_pending_submission() ->
|
||||
db.commit()
|
||||
claim_id = claim.id
|
||||
|
||||
returned = ExpenseClaimService(db).return_claim(claim_id, current_user, reason="请补充行程说明")
|
||||
returned = ExpenseClaimService(db).return_claim(
|
||||
claim_id, current_user, reason="请补充行程说明"
|
||||
)
|
||||
|
||||
assert returned is not None
|
||||
assert returned.status == "returned"
|
||||
@@ -5603,6 +5623,7 @@ def test_direct_manager_budget_monitor_routes_reimbursement_directly_to_finance(
|
||||
{
|
||||
"source": "submission_review",
|
||||
"severity": "high",
|
||||
"actionability": "route_review",
|
||||
"label": "报销风险复核",
|
||||
"message": "多城市行程和住宿超标需要预算管理者二次确认。",
|
||||
}
|
||||
@@ -5621,8 +5642,7 @@ def test_direct_manager_budget_monitor_routes_reimbursement_directly_to_finance(
|
||||
assert approved.status == "submitted"
|
||||
assert approved.approval_stage == "财务审批"
|
||||
assert not any(
|
||||
isinstance(flag, dict)
|
||||
and flag.get("next_approval_stage") == "预算管理者审批"
|
||||
isinstance(flag, dict) and flag.get("next_approval_stage") == "预算管理者审批"
|
||||
for flag in approved.risk_flags_json
|
||||
)
|
||||
assert any(
|
||||
@@ -5635,12 +5655,13 @@ def test_direct_manager_budget_monitor_routes_reimbursement_directly_to_finance(
|
||||
and flag.get("next_status") == "submitted"
|
||||
and flag.get("next_approval_stage") == "财务审批"
|
||||
and flag.get("budget_approval_merged") is True
|
||||
and flag.get("budget_approval_merged_reason") == "direct_manager_is_department_budget_approver"
|
||||
and flag.get("budget_approval_merged_reason")
|
||||
== "direct_manager_is_department_budget_approver"
|
||||
for flag in approved.risk_flags_json
|
||||
)
|
||||
|
||||
|
||||
def test_duplicate_budget_stage_from_legacy_reimbursement_is_repaired_on_read() -> None:
|
||||
def test_legacy_duplicate_budget_stage_is_not_mutated_by_read() -> None:
|
||||
admin_user = CurrentUserContext(
|
||||
username="admin",
|
||||
name="admin",
|
||||
@@ -5706,20 +5727,25 @@ def test_duplicate_budget_stage_from_legacy_reimbursement_is_repaired_on_read()
|
||||
db.add(claim)
|
||||
db.commit()
|
||||
|
||||
repaired = ExpenseClaimService(db).get_claim(claim.id, admin_user)
|
||||
result = ExpenseClaimService(db).get_claim(claim.id, admin_user)
|
||||
|
||||
assert repaired is not None
|
||||
assert repaired.approval_stage == FINANCE_APPROVAL_STAGE
|
||||
assert any(
|
||||
assert result is not None
|
||||
assert result.approval_stage == BUDGET_MANAGER_APPROVAL_STAGE
|
||||
assert not any(
|
||||
isinstance(flag, dict)
|
||||
and flag.get("source") == "approval_flow_repair"
|
||||
and flag.get("event_type") == "duplicate_budget_approval_stage_repaired"
|
||||
and flag.get("next_approval_stage") == FINANCE_APPROVAL_STAGE
|
||||
for flag in repaired.risk_flags_json
|
||||
for flag in result.risk_flags_json
|
||||
)
|
||||
db.expire_all()
|
||||
persisted = db.get(ExpenseClaim, claim.id)
|
||||
assert persisted is not None
|
||||
assert persisted.approval_stage == BUDGET_MANAGER_APPROVAL_STAGE
|
||||
|
||||
|
||||
def test_application_submit_skips_ai_review_and_receipt_requirements(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def test_application_submit_skips_ai_review_and_receipt_requirements(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
current_user = CurrentUserContext(
|
||||
username="application-owner@example.com",
|
||||
name="张三",
|
||||
@@ -5970,7 +5996,9 @@ def test_application_submit_skips_budget_for_non_demo_subject() -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_direct_manager_can_route_application_claim_to_budget_approval_then_budget_manager_creates_draft() -> None:
|
||||
def test_direct_manager_can_route_application_claim_to_budget_approval_then_budget_manager_creates_draft() -> (
|
||||
None
|
||||
):
|
||||
manager_user = CurrentUserContext(
|
||||
username="manager-application-approve@example.com",
|
||||
name="李经理",
|
||||
@@ -6053,9 +6081,10 @@ def test_direct_manager_can_route_application_claim_to_budget_approval_then_budg
|
||||
{
|
||||
"source": "submission_review",
|
||||
"severity": "high",
|
||||
"actionability": "route_review",
|
||||
"label": "申请风险复核",
|
||||
"message": "申请金额和行程安排需要预算管理者二次确认。",
|
||||
}
|
||||
},
|
||||
],
|
||||
)
|
||||
db.add(claim)
|
||||
@@ -6120,13 +6149,18 @@ def test_direct_manager_can_route_application_claim_to_budget_approval_then_budg
|
||||
and flag.get("source") == "application_handoff"
|
||||
and flag.get("event_type") == "expense_application_to_reimbursement_draft"
|
||||
and flag.get("application_claim_no") == "APP-20260525-APPROVE"
|
||||
and flag.get("application_detail", {}).get("application_content") == "差旅费用申请 / 上海"
|
||||
and flag.get("application_detail", {}).get("application_reason") == "支撑国网服务器上线部署"
|
||||
and flag.get("application_detail", {}).get("application_content")
|
||||
== "差旅费用申请 / 上海"
|
||||
and flag.get("application_detail", {}).get("application_reason")
|
||||
== "支撑国网服务器上线部署"
|
||||
and flag.get("application_detail", {}).get("application_days") == "3 天"
|
||||
and flag.get("application_detail", {}).get("application_transport_mode") == "高铁"
|
||||
and flag.get("application_detail", {}).get("application_lodging_daily_cap") == "600元/天"
|
||||
and flag.get("application_detail", {}).get("application_subsidy_daily_cap") == "120元/天"
|
||||
and flag.get("application_detail", {}).get("application_transport_policy") == "按真实票据复核"
|
||||
and flag.get("application_detail", {}).get("application_lodging_daily_cap")
|
||||
== "600元/天"
|
||||
and flag.get("application_detail", {}).get("application_subsidy_daily_cap")
|
||||
== "120元/天"
|
||||
and flag.get("application_detail", {}).get("application_transport_policy")
|
||||
== "按真实票据复核"
|
||||
and flag.get("application_detail", {}).get("application_policy_estimate")
|
||||
== "交通按真实票据 + 住宿 1,800元 + 补贴 360元"
|
||||
and flag.get("application_detail", {}).get("application_rule_name") == "差旅标准规则"
|
||||
@@ -6214,6 +6248,7 @@ def test_application_routes_to_department_p8_executive_with_approver_name() -> N
|
||||
{
|
||||
"source": "submission_review",
|
||||
"severity": "high",
|
||||
"actionability": "route_review",
|
||||
"label": "Route risk",
|
||||
"message": "Application requires budget confirmation.",
|
||||
}
|
||||
@@ -6306,6 +6341,7 @@ def test_direct_manager_cannot_route_application_to_missing_budget_approver() ->
|
||||
{
|
||||
"source": "submission_review",
|
||||
"severity": "high",
|
||||
"actionability": "route_review",
|
||||
"label": "Route risk",
|
||||
"message": "Application requires budget confirmation.",
|
||||
}
|
||||
@@ -6328,7 +6364,9 @@ def test_direct_manager_cannot_route_application_to_missing_budget_approver() ->
|
||||
assert reimbursement_claim_query(db).count() == 0
|
||||
|
||||
|
||||
def test_direct_manager_p8_executive_completes_application_without_duplicate_budget_approval() -> None:
|
||||
def test_direct_manager_p8_executive_completes_application_without_duplicate_budget_approval() -> (
|
||||
None
|
||||
):
|
||||
manager_user = CurrentUserContext(
|
||||
username="manager-executive-merged@example.com",
|
||||
name="P8 Manager",
|
||||
@@ -6381,6 +6419,7 @@ def test_direct_manager_p8_executive_completes_application_without_duplicate_bud
|
||||
{
|
||||
"source": "submission_review",
|
||||
"severity": "high",
|
||||
"actionability": "route_review",
|
||||
"label": "Route risk",
|
||||
"message": "Application requires budget confirmation.",
|
||||
}
|
||||
@@ -6411,12 +6450,15 @@ def test_direct_manager_p8_executive_completes_application_without_duplicate_bud
|
||||
and flag.get("next_status") == "approved"
|
||||
and flag.get("next_approval_stage") == APPLICATION_LINK_STATUS_STAGE
|
||||
and flag.get("budget_approval_merged") is True
|
||||
and flag.get("budget_approval_merged_reason") == "direct_manager_is_department_budget_approver"
|
||||
and flag.get("budget_approval_merged_reason")
|
||||
== "direct_manager_is_department_budget_approver"
|
||||
for flag in approved.risk_flags_json
|
||||
)
|
||||
|
||||
|
||||
def test_direct_manager_budget_monitor_completes_application_claim_without_duplicate_budget_approval() -> None:
|
||||
def test_direct_manager_budget_monitor_completes_application_claim_without_duplicate_budget_approval() -> (
|
||||
None
|
||||
):
|
||||
manager_user = CurrentUserContext(
|
||||
username="manager-budget-monitor-application@example.com",
|
||||
name="李预算经理",
|
||||
@@ -6469,6 +6511,7 @@ def test_direct_manager_budget_monitor_completes_application_claim_without_dupli
|
||||
{
|
||||
"source": "submission_review",
|
||||
"severity": "high",
|
||||
"actionability": "route_review",
|
||||
"label": "申请风险复核",
|
||||
"message": "申请金额和行程安排需要预算管理者二次确认。",
|
||||
}
|
||||
@@ -6489,8 +6532,7 @@ def test_direct_manager_budget_monitor_completes_application_claim_without_dupli
|
||||
assert approved.approval_stage == "关联单据状态"
|
||||
assert reimbursement_claim_query(db).count() == 1
|
||||
assert not any(
|
||||
isinstance(flag, dict)
|
||||
and flag.get("next_approval_stage") == "预算管理者审批"
|
||||
isinstance(flag, dict) and flag.get("next_approval_stage") == "预算管理者审批"
|
||||
for flag in approved.risk_flags_json
|
||||
)
|
||||
assert any(
|
||||
@@ -6503,7 +6545,8 @@ def test_direct_manager_budget_monitor_completes_application_claim_without_dupli
|
||||
and flag.get("next_status") == "approved"
|
||||
and flag.get("next_approval_stage") == "关联单据状态"
|
||||
and flag.get("budget_approval_merged") is True
|
||||
and flag.get("budget_approval_merged_reason") == "direct_manager_is_department_budget_approver"
|
||||
and flag.get("budget_approval_merged_reason")
|
||||
== "direct_manager_is_department_budget_approver"
|
||||
for flag in approved.risk_flags_json
|
||||
)
|
||||
generated_draft = reimbursement_claim_query(db).one()
|
||||
@@ -6692,6 +6735,7 @@ def test_application_approval_transfers_budget_reservation_to_reimbursement_draf
|
||||
{
|
||||
"source": "platform_risk",
|
||||
"severity": "high",
|
||||
"actionability": "route_review",
|
||||
"label": "申请风险复核",
|
||||
"message": "申请金额和行程安排需要预算管理者二次确认。",
|
||||
}
|
||||
@@ -6719,10 +6763,11 @@ def test_application_approval_transfers_budget_reservation_to_reimbursement_draf
|
||||
assert reservation.source_type == "claim"
|
||||
assert reservation.source_id == generated_draft.id
|
||||
assert reservation.source_no == generated_draft.claim_no
|
||||
assert any(item.transaction_type == "transfer" for item in db.query(BudgetTransaction).all())
|
||||
assert any(
|
||||
isinstance(flag, dict)
|
||||
and flag.get("event_type") == "budget_reservation_transferred"
|
||||
item.transaction_type == "transfer" for item in db.query(BudgetTransaction).all()
|
||||
)
|
||||
assert any(
|
||||
isinstance(flag, dict) and flag.get("event_type") == "budget_reservation_transferred"
|
||||
for flag in generated_draft.risk_flags_json
|
||||
)
|
||||
|
||||
@@ -6916,7 +6961,12 @@ def test_finance_approve_reimbursement_consumes_budget_reservation() -> None:
|
||||
db.refresh(reservation)
|
||||
assert reservation.source_status == "consumed"
|
||||
assert reservation.consumed_amount == Decimal("12000.00")
|
||||
assert db.query(BudgetTransaction).filter(BudgetTransaction.transaction_type == "consume").count() == 1
|
||||
assert (
|
||||
db.query(BudgetTransaction)
|
||||
.filter(BudgetTransaction.transaction_type == "consume")
|
||||
.count()
|
||||
== 1
|
||||
)
|
||||
assert any(
|
||||
isinstance(flag, dict)
|
||||
and flag.get("source") == "budget_control"
|
||||
@@ -7290,7 +7340,10 @@ def test_return_claim_records_each_return_event_with_stage_reason_and_counts() -
|
||||
assert return_events[0]["stage_return_count"] == 1
|
||||
assert return_events[0]["return_stage"] == "直属领导审批"
|
||||
assert return_events[0]["reason_codes"] == ["invoice_mismatch", "business_explanation"]
|
||||
assert return_events[0]["risk_points"] == ["票据类型/金额与明细不一致", "业务事由/地点/人员信息不完整"]
|
||||
assert return_events[0]["risk_points"] == [
|
||||
"票据类型/金额与明细不一致",
|
||||
"业务事由/地点/人员信息不完整",
|
||||
]
|
||||
assert return_events[0]["reason"] == "发票金额与明细金额不一致,请重新核对。"
|
||||
assert return_events[0]["operator_role_codes"] == ["manager"]
|
||||
assert return_events[1]["return_count"] == 2
|
||||
@@ -7624,14 +7677,16 @@ def test_list_approval_claims_allows_budget_monitor_to_view_budget_stage_applica
|
||||
email="budget-list-market@example.com",
|
||||
organization_unit=market_department,
|
||||
)
|
||||
db.add_all([
|
||||
delivery_department,
|
||||
market_department,
|
||||
budget_manager,
|
||||
p8_without_budget_employee,
|
||||
employee,
|
||||
market_employee,
|
||||
])
|
||||
db.add_all(
|
||||
[
|
||||
delivery_department,
|
||||
market_department,
|
||||
budget_manager,
|
||||
p8_without_budget_employee,
|
||||
employee,
|
||||
market_employee,
|
||||
]
|
||||
)
|
||||
db.flush()
|
||||
db.add_all(
|
||||
[
|
||||
@@ -7702,5 +7757,7 @@ def test_list_approval_claims_allows_budget_monitor_to_view_budget_stage_applica
|
||||
assert getattr(claims[0], "budget_approver_name", "") == "赵预算"
|
||||
assert getattr(claims[0], "budget_approver_grade", "") == "P8"
|
||||
assert getattr(claims[0], "budget_approver_role_code", "") == "budget_monitor"
|
||||
claims_without_budget_role = ExpenseClaimService(db).list_approval_claims(p8_without_budget_role)
|
||||
claims_without_budget_role = ExpenseClaimService(db).list_approval_claims(
|
||||
p8_without_budget_role
|
||||
)
|
||||
assert [claim.claim_no for claim in claims_without_budget_role] == []
|
||||
|
||||
@@ -137,6 +137,14 @@ def test_known_revision_requires_and_accepts_its_exact_owned_table_set(
|
||||
"20260716_0009",
|
||||
MIGRATION_OWNED_TABLES_BY_REVISION["20260716_0009"] - {"memory_entries"},
|
||||
),
|
||||
(
|
||||
"20260716_0010",
|
||||
MIGRATION_OWNED_TABLES_BY_REVISION["20260716_0010"] - {"approval_action_ledgers"},
|
||||
),
|
||||
(
|
||||
"20260716_0011",
|
||||
MIGRATION_OWNED_TABLES_BY_REVISION["20260716_0011"] - {"risk_disposition_events"},
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_known_revision_with_missing_or_unexpected_owned_tables_is_rejected(
|
||||
|
||||
@@ -9,7 +9,7 @@ from decimal import Decimal
|
||||
import pytest
|
||||
from auth_helpers import install_legacy_header_auth_override
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine, select
|
||||
from sqlalchemy import create_engine, func, select
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
@@ -17,6 +17,7 @@ from app.api.deps import get_db
|
||||
from app.db.base import Base
|
||||
from app.main import create_app
|
||||
from app.models.ai_learning import AIDecision, AIDecisionFeedback, WorkflowOutcome
|
||||
from app.models.approval_action import ApprovalActionLedger
|
||||
from app.models.budget import BudgetAllocation, BudgetReservation, BudgetTransaction
|
||||
from app.models.employee import Employee
|
||||
from app.models.expense_case import BusinessEvent, ExpenseCaseLink
|
||||
@@ -213,12 +214,15 @@ def test_claim_submit_returns_structured_pre_review_conflict() -> None:
|
||||
assert claim is not None
|
||||
assert claim.status == "draft"
|
||||
assert claim.submitted_at is None
|
||||
assert db.scalar(
|
||||
select(BusinessEvent).where(
|
||||
BusinessEvent.aggregate_id == claim.id,
|
||||
BusinessEvent.event_type == "claim_submitted",
|
||||
assert (
|
||||
db.scalar(
|
||||
select(BusinessEvent).where(
|
||||
BusinessEvent.aggregate_id == claim.id,
|
||||
BusinessEvent.event_type == "claim_submitted",
|
||||
)
|
||||
)
|
||||
) is None
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
def test_claim_submit_returns_changed_conflict_when_dynamic_review_changes(
|
||||
@@ -402,7 +406,9 @@ def test_claim_read_attaches_finance_approver_name_for_finance_stage() -> None:
|
||||
db.commit()
|
||||
|
||||
headers = {"x-auth-username": "qianqi@example.com"}
|
||||
response = client.get("/api/v1/reimbursements/claims/claim-finance-stage-reader", headers=headers)
|
||||
response = client.get(
|
||||
"/api/v1/reimbursements/claims/claim-finance-stage-reader", headers=headers
|
||||
)
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["finance_owner_name"] == "Wang Finance Group"
|
||||
@@ -518,7 +524,9 @@ def test_claim_item_attachment_upload_preview_and_delete(monkeypatch, tmp_path)
|
||||
meta_payload = meta_response.json()
|
||||
assert meta_payload["media_type"] == "image/png"
|
||||
assert meta_payload["preview_kind"] == "image"
|
||||
assert meta_payload["preview_url"].endswith(f"/reimbursements/claims/{claim_id}/items/{item_id}/attachment/preview")
|
||||
assert meta_payload["preview_url"].endswith(
|
||||
f"/reimbursements/claims/{claim_id}/items/{item_id}/attachment/preview"
|
||||
)
|
||||
assert meta_payload["analysis"]["headline"]
|
||||
assert meta_payload["document_info"]["fields"][0]["label"] == "金额"
|
||||
|
||||
@@ -550,7 +558,9 @@ def test_claim_item_attachment_upload_preview_and_delete(monkeypatch, tmp_path)
|
||||
assert deleted_meta_response.status_code == 404
|
||||
|
||||
|
||||
def test_claim_item_attachment_upload_flags_purpose_and_amount_mismatch(monkeypatch, tmp_path) -> None:
|
||||
def test_claim_item_attachment_upload_flags_purpose_and_amount_mismatch(
|
||||
monkeypatch, tmp_path
|
||||
) -> None:
|
||||
def fake_recognize(
|
||||
self,
|
||||
files: list[tuple[str, bytes, str | None]],
|
||||
@@ -596,7 +606,9 @@ def test_claim_item_attachment_upload_flags_purpose_and_amount_mismatch(monkeypa
|
||||
assert upload_response.json()["attachment"]["requirement_check"]["matches"] is False
|
||||
|
||||
|
||||
def test_claim_item_attachment_upload_flags_non_invoice_image_as_high_risk(monkeypatch, tmp_path) -> None:
|
||||
def test_claim_item_attachment_upload_flags_non_invoice_image_as_high_risk(
|
||||
monkeypatch, tmp_path
|
||||
) -> None:
|
||||
def fake_recognize(
|
||||
self,
|
||||
files: list[tuple[str, bytes, str | None]],
|
||||
@@ -679,14 +691,32 @@ def test_approve_claim_endpoint_routes_direct_manager_claim_to_finance_review()
|
||||
db.add_all([manager, employee, claim])
|
||||
db.commit()
|
||||
|
||||
action_headers = {
|
||||
"X-Auth-Username": "manager-approve-api@example.com",
|
||||
"X-Auth-Name": "manager-approve-api@example.com",
|
||||
"X-Auth-Role-Codes": "manager",
|
||||
}
|
||||
stale_response = client.post(
|
||||
"/api/v1/reimbursements/claims/claim-approve-1/approve",
|
||||
json={
|
||||
"opinion": "情况属实,同意报销。",
|
||||
"request_id": "approve-api-stale-1",
|
||||
"expected_status": "draft",
|
||||
"expected_approval_stage": "直属领导审批",
|
||||
},
|
||||
headers=action_headers,
|
||||
)
|
||||
assert stale_response.status_code == 409
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/reimbursements/claims/claim-approve-1/approve",
|
||||
json={"opinion": "情况属实,同意报销。"},
|
||||
headers={
|
||||
"X-Auth-Username": "manager-approve-api@example.com",
|
||||
"X-Auth-Name": "manager-approve-api@example.com",
|
||||
"X-Auth-Role-Codes": "manager",
|
||||
json={
|
||||
"opinion": "情况属实,同意报销。",
|
||||
"request_id": "approve-api-claim-1",
|
||||
"expected_status": "submitted",
|
||||
"expected_approval_stage": "直属领导审批",
|
||||
},
|
||||
headers=action_headers,
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
@@ -701,13 +731,136 @@ def test_approve_claim_endpoint_routes_direct_manager_claim_to_finance_review()
|
||||
for item in payload["risk_flags_json"]
|
||||
)
|
||||
approval_events = [
|
||||
item
|
||||
for item in payload["risk_flags_json"]
|
||||
if item["source"] == "manual_approval"
|
||||
item for item in payload["risk_flags_json"] if item["source"] == "manual_approval"
|
||||
]
|
||||
assert approval_events[0]["operator"] == "李经理"
|
||||
assert "manager-approve-api@example.com" not in approval_events[0]["message"]
|
||||
|
||||
replay_response = client.post(
|
||||
"/api/v1/reimbursements/claims/claim-approve-1/approve",
|
||||
json={
|
||||
"opinion": "情况属实,同意报销。",
|
||||
"request_id": "approve-api-claim-1",
|
||||
"expected_status": "submitted",
|
||||
"expected_approval_stage": "直属领导审批",
|
||||
},
|
||||
headers=action_headers,
|
||||
)
|
||||
assert replay_response.status_code == 200
|
||||
assert replay_response.json()["approval_stage"] == "财务审批"
|
||||
|
||||
changed_payload_response = client.post(
|
||||
"/api/v1/reimbursements/claims/claim-approve-1/approve",
|
||||
json={
|
||||
"opinion": "改为有条件通过",
|
||||
"request_id": "approve-api-claim-1",
|
||||
"expected_status": "submitted",
|
||||
"expected_approval_stage": "直属领导审批",
|
||||
},
|
||||
headers=action_headers,
|
||||
)
|
||||
assert changed_payload_response.status_code == 409
|
||||
with session_factory() as db:
|
||||
ledgers = list(db.scalars(select(ApprovalActionLedger)).all())
|
||||
assert len(ledgers) == 1
|
||||
assert ledgers[0].completed_at is not None
|
||||
|
||||
|
||||
def test_approve_claim_endpoint_blocks_open_high_risk_with_machine_readable_detail() -> None:
|
||||
client, session_factory = build_client()
|
||||
with session_factory() as db:
|
||||
manager = Employee(
|
||||
id="manager-risk-block-api",
|
||||
employee_no="M-RISK-BLOCK-API",
|
||||
name="风险经理",
|
||||
email="manager-risk-block-api@example.com",
|
||||
)
|
||||
employee = Employee(
|
||||
id="employee-risk-block-api",
|
||||
employee_no="E-RISK-BLOCK-API",
|
||||
name="风险员工",
|
||||
email="employee-risk-block-api@example.com",
|
||||
manager=manager,
|
||||
)
|
||||
claim = ExpenseClaim(
|
||||
id="claim-risk-block-api",
|
||||
claim_no="EXP-RISK-BLOCK-API",
|
||||
employee=employee,
|
||||
employee_name=employee.name,
|
||||
department_name="风控部",
|
||||
expense_type="travel",
|
||||
reason="客户拜访",
|
||||
location="上海",
|
||||
amount=Decimal("1200.00"),
|
||||
currency="CNY",
|
||||
invoice_count=1,
|
||||
occurred_at=datetime(2026, 7, 16, tzinfo=UTC),
|
||||
submitted_at=datetime(2026, 7, 16, tzinfo=UTC),
|
||||
status="submitted",
|
||||
approval_stage="直属领导审批",
|
||||
risk_flags_json=[],
|
||||
)
|
||||
observation = RiskObservation(
|
||||
id="risk-block-api-observation",
|
||||
tenant_id="default",
|
||||
observation_key="risk:claim-risk-block-api:duplicate",
|
||||
subject_type="expense_claim",
|
||||
subject_key="claim:claim-risk-block-api",
|
||||
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="pending_review",
|
||||
feedback_status="unreviewed",
|
||||
)
|
||||
db.add_all([manager, employee, claim, observation])
|
||||
db.commit()
|
||||
manager_email = manager.email
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/reimbursements/claims/claim-risk-block-api/approve",
|
||||
headers={
|
||||
"X-Auth-Username": manager_email,
|
||||
"X-Auth-Name": "Risk Manager",
|
||||
"X-Auth-Role-Codes": "manager",
|
||||
},
|
||||
json={
|
||||
"opinion": "同意",
|
||||
"request_id": "approve-risk-block-api-001",
|
||||
"expected_status": "submitted",
|
||||
"expected_approval_stage": "直属领导审批",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 409
|
||||
detail = response.json()["detail"]
|
||||
assert detail["code"] == "APPROVAL_BLOCKED_BY_OPEN_HIGH_RISK"
|
||||
assert detail["observations"] == [
|
||||
{
|
||||
"id": "risk-block-api-observation",
|
||||
"title": "重复票据风险",
|
||||
"risk_level": "high",
|
||||
"adjudication": "unreviewed",
|
||||
"lifecycle_status": "open",
|
||||
}
|
||||
]
|
||||
with session_factory() as db:
|
||||
persisted = db.get(ExpenseClaim, "claim-risk-block-api")
|
||||
assert persisted is not None
|
||||
assert persisted.approval_stage == "直属领导审批"
|
||||
assert db.scalar(select(func.count()).select_from(ApprovalActionLedger)) == 0
|
||||
|
||||
|
||||
def test_approve_application_endpoint_routes_direct_manager_review_to_budget_review() -> None:
|
||||
client, session_factory = build_client()
|
||||
@@ -766,10 +919,11 @@ def test_approve_application_endpoint_routes_direct_manager_review_to_budget_rev
|
||||
status="submitted",
|
||||
approval_stage="直属领导审批",
|
||||
risk_flags_json=[
|
||||
{
|
||||
"source": "submission_review",
|
||||
"severity": "high",
|
||||
"label": "申请风险复核",
|
||||
{
|
||||
"source": "submission_review",
|
||||
"severity": "high",
|
||||
"actionability": "route_review",
|
||||
"label": "申请风险复核",
|
||||
"message": "申请金额和行程安排需要预算管理者二次确认。",
|
||||
}
|
||||
],
|
||||
@@ -779,7 +933,12 @@ def test_approve_application_endpoint_routes_direct_manager_review_to_budget_rev
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/reimbursements/claims/claim-application-approve-1/approve",
|
||||
json={"opinion": "业务必要,同意申请。"},
|
||||
json={
|
||||
"opinion": "业务必要,同意申请。",
|
||||
"request_id": "approve-api-application-1",
|
||||
"expected_status": "submitted",
|
||||
"expected_approval_stage": "直属领导审批",
|
||||
},
|
||||
headers={
|
||||
"X-Auth-Username": "manager-application-approve-api@example.com",
|
||||
"X-Auth-Name": "manager-application-approve-api@example.com",
|
||||
@@ -853,7 +1012,9 @@ def test_claim_item_pdf_attachment_preview_returns_generated_image(monkeypatch,
|
||||
assert upload_response.status_code == 200
|
||||
meta_payload = upload_response.json()["attachment"]
|
||||
assert meta_payload["preview_kind"] == "image"
|
||||
assert meta_payload["preview_url"].endswith(f"/reimbursements/claims/{claim_id}/items/{item_id}/attachment/preview")
|
||||
assert meta_payload["preview_url"].endswith(
|
||||
f"/reimbursements/claims/{claim_id}/items/{item_id}/attachment/preview"
|
||||
)
|
||||
meta_path = next(tmp_path.rglob("invoice.pdf.meta.json"))
|
||||
stored_meta = json.loads(meta_path.read_text(encoding="utf-8"))
|
||||
assert stored_meta["preview_rendered_with"] == DocumentPreviewAssets.PDF_RENDERER_ID
|
||||
@@ -1015,7 +1176,9 @@ def test_claim_delete_allows_applicant_to_delete_own_draft(monkeypatch, tmp_path
|
||||
assert db.get(ExpenseClaim, claim_id) is None
|
||||
|
||||
|
||||
def test_claim_delete_allows_legacy_superadmin_without_is_admin_header(monkeypatch, tmp_path) -> None:
|
||||
def test_claim_delete_allows_legacy_superadmin_without_is_admin_header(
|
||||
monkeypatch, tmp_path
|
||||
) -> None:
|
||||
monkeypatch.setattr(ExpenseClaimAttachmentStorage, "root", lambda self: tmp_path)
|
||||
|
||||
client, session_factory = build_client()
|
||||
@@ -1157,8 +1320,7 @@ def test_application_preview_action_submits_without_orchestrator_run(monkeypatch
|
||||
assert outcome.outcome_type == "application_submitted"
|
||||
assert outcome.business_event_id == event.id
|
||||
assert any(
|
||||
isinstance(flag, dict)
|
||||
and flag.get("event_type") == "expense_application_submission"
|
||||
isinstance(flag, dict) and flag.get("event_type") == "expense_application_submission"
|
||||
for flag in list(claim.risk_flags_json or [])
|
||||
)
|
||||
|
||||
@@ -1212,7 +1374,9 @@ def test_application_direct_submit_rolls_back_budget_when_case_event_fails(
|
||||
assert list(db.scalars(select(WorkflowOutcome)).all()) == []
|
||||
|
||||
|
||||
def test_application_preview_action_saves_draft_with_detail_reference(monkeypatch, tmp_path) -> None:
|
||||
def test_application_preview_action_saves_draft_with_detail_reference(
|
||||
monkeypatch, tmp_path
|
||||
) -> None:
|
||||
monkeypatch.setattr(ExpenseClaimAttachmentStorage, "root", lambda self: tmp_path)
|
||||
|
||||
client, session_factory = build_client()
|
||||
@@ -1288,19 +1452,16 @@ def test_application_preview_action_saves_draft_with_detail_reference(monkeypatc
|
||||
assert claim.approval_stage == "待提交"
|
||||
assert claim.submitted_at is None
|
||||
assert claim.employee_name == "张三"
|
||||
assert db.scalar(
|
||||
select(BudgetReservation).where(BudgetReservation.source_id == claim.id)
|
||||
) is None
|
||||
event = db.scalar(
|
||||
select(BusinessEvent).where(BusinessEvent.aggregate_id == claim.id)
|
||||
assert (
|
||||
db.scalar(select(BudgetReservation).where(BudgetReservation.source_id == claim.id))
|
||||
is None
|
||||
)
|
||||
event = db.scalar(select(BusinessEvent).where(BusinessEvent.aggregate_id == claim.id))
|
||||
assert event is not None
|
||||
assert event.event_type == "claim_draft_created"
|
||||
assert event.payload_json["previous_status"] == ""
|
||||
assert event.payload_json["next_status"] == "draft"
|
||||
link = db.scalar(
|
||||
select(ExpenseCaseLink).where(ExpenseCaseLink.resource_id == claim.id)
|
||||
)
|
||||
link = db.scalar(select(ExpenseCaseLink).where(ExpenseCaseLink.resource_id == claim.id))
|
||||
assert link is not None
|
||||
assert link.relation_type == "application"
|
||||
|
||||
@@ -1372,9 +1533,10 @@ def test_application_preview_action_rejects_forged_identity_when_editing_other_c
|
||||
assert persisted.reason == "其他员工原申请"
|
||||
assert persisted.status == "returned"
|
||||
assert persisted.approval_stage == "退回补充"
|
||||
assert db.scalar(
|
||||
select(BusinessEvent).where(BusinessEvent.aggregate_id == persisted.id)
|
||||
) is None
|
||||
assert (
|
||||
db.scalar(select(BusinessEvent).where(BusinessEvent.aggregate_id == persisted.id))
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
def test_application_preview_action_reuses_created_draft_for_identical_retry() -> None:
|
||||
@@ -1420,17 +1582,13 @@ def test_application_preview_action_reuses_created_draft_for_identical_retry() -
|
||||
with session_factory() as db:
|
||||
application_claims = list(
|
||||
db.scalars(
|
||||
select(ExpenseClaim).where(
|
||||
ExpenseClaim.expense_type == "travel_application"
|
||||
)
|
||||
select(ExpenseClaim).where(ExpenseClaim.expense_type == "travel_application")
|
||||
).all()
|
||||
)
|
||||
assert len(application_claims) == 1
|
||||
events = list(
|
||||
db.scalars(
|
||||
select(BusinessEvent).where(
|
||||
BusinessEvent.aggregate_id == application_claims[0].id
|
||||
)
|
||||
select(BusinessEvent).where(BusinessEvent.aggregate_id == application_claims[0].id)
|
||||
).all()
|
||||
)
|
||||
assert len(events) == 1
|
||||
|
||||
661
server/tests/test_risk_dispositions.py
Normal file
661
server/tests/test_risk_dispositions.py
Normal file
@@ -0,0 +1,661 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Generator
|
||||
from datetime import UTC, 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, func, select
|
||||
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.employee import Employee
|
||||
from app.models.financial_record import ExpenseClaim
|
||||
from app.models.risk_disposition import RiskDispositionEvent
|
||||
from app.models.risk_observation import RiskObservationFeedback
|
||||
from app.schemas.risk_disposition import RiskDispositionActionCreate
|
||||
from app.services.risk_dispositions import (
|
||||
RiskDispositionConflictError,
|
||||
RiskDispositionIdempotencyConflictError,
|
||||
RiskDispositionPermissionError,
|
||||
RiskDispositionService,
|
||||
RiskDispositionVersionConflictError,
|
||||
)
|
||||
from app.services.risk_observation_access_policy import RiskObservationAccessPolicy
|
||||
from app.services.risk_observations import RiskObservationService
|
||||
|
||||
|
||||
def test_risk_disposition_separates_adjudication_and_lifecycle_with_audit_events(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("FEW_SHOT_INJECTION_ENABLED", "false")
|
||||
with _build_session() as db:
|
||||
observation = RiskObservationService(db).upsert_observation(
|
||||
_observation_payload("risk:typed:duplicate")
|
||||
)
|
||||
db.commit()
|
||||
service = RiskDispositionService(db)
|
||||
|
||||
confirmed = service.execute_action(
|
||||
observation.id,
|
||||
_action("confirm", version=0, request_id="request-confirm-001"),
|
||||
tenant_id="default",
|
||||
actor_id="finance-1",
|
||||
actor_name="财务甲",
|
||||
)
|
||||
assert confirmed.disposition.adjudication == "confirmed"
|
||||
assert confirmed.disposition.lifecycle_status == "open"
|
||||
assert confirmed.disposition.version == 1
|
||||
assert confirmed.event.before_json["adjudication"] == "unreviewed"
|
||||
assert confirmed.event.after_json["adjudication"] == "confirmed"
|
||||
|
||||
supplemented = service.execute_action(
|
||||
observation.id,
|
||||
RiskDispositionActionCreate(
|
||||
action="request_supplement",
|
||||
expected_version=1,
|
||||
request_id="request-supplement-001",
|
||||
assignee="员工甲",
|
||||
due_at=datetime.now(UTC) + timedelta(days=2),
|
||||
comment="请补齐行程单",
|
||||
),
|
||||
tenant_id="default",
|
||||
actor_id="finance-1",
|
||||
actor_name="财务甲",
|
||||
)
|
||||
assert supplemented.disposition.adjudication == "confirmed"
|
||||
assert supplemented.disposition.lifecycle_status == "supplement_requested"
|
||||
assert supplemented.disposition.assignee == "员工甲"
|
||||
assert supplemented.disposition.version == 2
|
||||
|
||||
resolved = service.execute_action(
|
||||
observation.id,
|
||||
RiskDispositionActionCreate(
|
||||
action="resolve",
|
||||
expected_version=2,
|
||||
request_id="request-resolve-001",
|
||||
resolution="补充材料已核验,风险关闭。",
|
||||
),
|
||||
tenant_id="default",
|
||||
actor_id="finance-1",
|
||||
actor_name="财务甲",
|
||||
)
|
||||
assert resolved.disposition.adjudication == "confirmed"
|
||||
assert resolved.disposition.lifecycle_status == "resolved"
|
||||
assert resolved.disposition.resolution == "补充材料已核验,风险关闭。"
|
||||
assert resolved.disposition.version == 3
|
||||
assert db.scalar(select(func.count()).select_from(RiskDispositionEvent)) == 3
|
||||
assert db.scalar(select(func.count()).select_from(RiskObservationFeedback)) == 1
|
||||
|
||||
with pytest.raises(RiskDispositionConflictError, match="已解决"):
|
||||
service.execute_action(
|
||||
observation.id,
|
||||
_action(
|
||||
"start_remediation",
|
||||
version=3,
|
||||
request_id="request-remediation-after-resolve",
|
||||
),
|
||||
tenant_id="default",
|
||||
actor_id="finance-1",
|
||||
actor_name="财务甲",
|
||||
)
|
||||
with pytest.raises(RiskDispositionConflictError, match="已解决"):
|
||||
service.execute_action(
|
||||
observation.id,
|
||||
_action(
|
||||
"false_positive",
|
||||
version=3,
|
||||
request_id="request-readjudicate-after-resolve",
|
||||
),
|
||||
tenant_id="default",
|
||||
actor_id="finance-1",
|
||||
actor_name="财务甲",
|
||||
)
|
||||
|
||||
|
||||
def test_risk_disposition_requires_confirmation_before_remediation_or_resolution(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("FEW_SHOT_INJECTION_ENABLED", "false")
|
||||
with _build_session() as db:
|
||||
observation = RiskObservationService(db).upsert_observation(
|
||||
_observation_payload("risk:typed:transition-guard")
|
||||
)
|
||||
db.commit()
|
||||
service = RiskDispositionService(db)
|
||||
|
||||
with pytest.raises(RiskDispositionConflictError, match="必须先确认成立"):
|
||||
service.execute_action(
|
||||
observation.id,
|
||||
RiskDispositionActionCreate(
|
||||
action="resolve",
|
||||
expected_version=0,
|
||||
request_id="request-resolve-unreviewed",
|
||||
resolution="不能跳过裁决直接关闭。",
|
||||
),
|
||||
tenant_id="default",
|
||||
actor_id="finance-1",
|
||||
actor_name="财务甲",
|
||||
)
|
||||
|
||||
supplemented = service.execute_action(
|
||||
observation.id,
|
||||
RiskDispositionActionCreate(
|
||||
action="request_supplement",
|
||||
expected_version=0,
|
||||
request_id="request-supplement-unreviewed",
|
||||
comment="先补充材料再裁决。",
|
||||
),
|
||||
tenant_id="default",
|
||||
actor_id="finance-1",
|
||||
actor_name="财务甲",
|
||||
)
|
||||
assert supplemented.disposition.adjudication == "unreviewed"
|
||||
assert supplemented.disposition.lifecycle_status == "supplement_requested"
|
||||
|
||||
|
||||
def test_risk_disposition_imports_legacy_confirmation_before_first_typed_action(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("FEW_SHOT_INJECTION_ENABLED", "false")
|
||||
with _build_session() as db:
|
||||
observation = RiskObservationService(db).upsert_observation(
|
||||
_observation_payload("risk:typed:legacy-confirmed")
|
||||
)
|
||||
observation.status = "confirmed"
|
||||
observation.feedback_status = "confirmed"
|
||||
db.commit()
|
||||
|
||||
resolved = RiskDispositionService(db).execute_action(
|
||||
observation.id,
|
||||
RiskDispositionActionCreate(
|
||||
action="resolve",
|
||||
expected_version=0,
|
||||
request_id="request-resolve-legacy-confirmed",
|
||||
resolution="历史确认风险已完成整改。",
|
||||
),
|
||||
tenant_id="default",
|
||||
actor_id="finance-1",
|
||||
actor_name="财务甲",
|
||||
)
|
||||
|
||||
assert resolved.event.before_json["adjudication"] == "confirmed"
|
||||
assert resolved.disposition.adjudication == "confirmed"
|
||||
assert resolved.disposition.lifecycle_status == "resolved"
|
||||
|
||||
|
||||
def test_risk_disposition_idempotency_and_optimistic_version_are_enforced(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("FEW_SHOT_INJECTION_ENABLED", "false")
|
||||
with _build_session() as db:
|
||||
observation = RiskObservationService(db).upsert_observation(
|
||||
_observation_payload("risk:typed:idempotency")
|
||||
)
|
||||
second_observation = RiskObservationService(db).upsert_observation(
|
||||
{
|
||||
**_observation_payload("risk:typed:idempotency:second"),
|
||||
"claim_id": None,
|
||||
"claim_no": "",
|
||||
"subject_key": "standalone:second",
|
||||
}
|
||||
)
|
||||
db.commit()
|
||||
service = RiskDispositionService(db)
|
||||
payload = _action("confirm", version=0, request_id="request-idempotent-001")
|
||||
|
||||
first = service.execute_action(
|
||||
observation.id,
|
||||
payload,
|
||||
tenant_id="default",
|
||||
actor_id="finance-1",
|
||||
actor_name="财务甲",
|
||||
)
|
||||
replay = service.execute_action(
|
||||
observation.id,
|
||||
payload,
|
||||
tenant_id="default",
|
||||
actor_id="finance-1",
|
||||
actor_name="财务甲",
|
||||
)
|
||||
|
||||
assert replay.replayed is True
|
||||
assert replay.event.id == first.event.id
|
||||
assert db.scalar(select(func.count()).select_from(RiskDispositionEvent)) == 1
|
||||
|
||||
with pytest.raises(RiskDispositionIdempotencyConflictError):
|
||||
service.execute_action(
|
||||
observation.id,
|
||||
_action(
|
||||
"false_positive",
|
||||
version=0,
|
||||
request_id="request-idempotent-001",
|
||||
),
|
||||
tenant_id="default",
|
||||
actor_id="finance-1",
|
||||
actor_name="财务甲",
|
||||
)
|
||||
|
||||
with pytest.raises(RiskDispositionIdempotencyConflictError):
|
||||
service.execute_action(
|
||||
second_observation.id,
|
||||
payload,
|
||||
tenant_id="default",
|
||||
actor_id="finance-1",
|
||||
actor_name="财务甲",
|
||||
)
|
||||
|
||||
with pytest.raises(RiskDispositionIdempotencyConflictError):
|
||||
service.execute_action(
|
||||
observation.id,
|
||||
payload,
|
||||
tenant_id="default",
|
||||
actor_id="finance-2",
|
||||
actor_name="财务乙",
|
||||
)
|
||||
|
||||
with pytest.raises(RiskDispositionVersionConflictError) as error:
|
||||
service.execute_action(
|
||||
observation.id,
|
||||
_action("request_waiver", version=0, request_id="request-stale-001"),
|
||||
tenant_id="default",
|
||||
actor_id="finance-1",
|
||||
actor_name="财务甲",
|
||||
)
|
||||
assert error.value.current_version == 1
|
||||
|
||||
|
||||
def test_risk_observation_api_enforces_pool_claim_and_typed_action_permissions(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("FEW_SHOT_INJECTION_ENABLED", "false")
|
||||
client, session_factory = _build_client()
|
||||
with session_factory() as db:
|
||||
db.add(_employee())
|
||||
db.add(_claim())
|
||||
db.flush()
|
||||
observation = RiskObservationService(db).upsert_observation(
|
||||
_observation_payload("risk:api:duplicate")
|
||||
)
|
||||
standalone_observation = RiskObservationService(db).upsert_observation(
|
||||
{
|
||||
**_observation_payload("risk:api:standalone"),
|
||||
"claim_id": None,
|
||||
"claim_no": "",
|
||||
"subject_key": "default:standalone",
|
||||
}
|
||||
)
|
||||
foreign_observation = RiskObservationService(db).upsert_observation(
|
||||
{
|
||||
**_observation_payload("risk:api:foreign"),
|
||||
"claim_id": None,
|
||||
"claim_no": "",
|
||||
"subject_key": "tenant-b:standalone",
|
||||
},
|
||||
tenant_id="tenant-b",
|
||||
)
|
||||
observation_id = observation.id
|
||||
standalone_observation_id = standalone_observation.id
|
||||
foreign_observation_id = foreign_observation.id
|
||||
db.commit()
|
||||
|
||||
employee_headers = {
|
||||
"X-Auth-Username": "risk.employee@example.com",
|
||||
"X-Auth-Name": "Risk Employee",
|
||||
"X-Auth-Employee-No": "E-RISK",
|
||||
}
|
||||
finance_headers = {
|
||||
"X-Auth-Username": "finance@example.com",
|
||||
"X-Auth-Name": "Finance Reviewer",
|
||||
"X-Auth-Role-Codes": "finance",
|
||||
}
|
||||
|
||||
assert client.get("/api/v1/risk-observations", headers=employee_headers).status_code == 403
|
||||
assert (
|
||||
client.get(
|
||||
f"/api/v1/risk-observations/{observation_id}",
|
||||
headers=employee_headers,
|
||||
).status_code
|
||||
== 403
|
||||
)
|
||||
assert (
|
||||
client.get(
|
||||
"/api/v1/risk-observations/claim/claim-risk-1",
|
||||
headers=employee_headers,
|
||||
).status_code
|
||||
== 404
|
||||
)
|
||||
assert (
|
||||
client.post(
|
||||
f"/api/v1/risk-observations/{observation_id}/feedback",
|
||||
headers=employee_headers,
|
||||
json={"feedback_type": "confirm"},
|
||||
).status_code
|
||||
== 403
|
||||
)
|
||||
|
||||
first = client.post(
|
||||
f"/api/v1/risk-observations/{observation_id}/disposition/actions",
|
||||
headers=finance_headers,
|
||||
json={
|
||||
"action": "confirm",
|
||||
"expected_version": 0,
|
||||
"request_id": "api-confirm-request-001",
|
||||
"comment": "人工复核确认",
|
||||
},
|
||||
)
|
||||
replay = client.post(
|
||||
f"/api/v1/risk-observations/{observation_id}/disposition/actions",
|
||||
headers=finance_headers,
|
||||
json={
|
||||
"action": "confirm",
|
||||
"expected_version": 0,
|
||||
"request_id": "api-confirm-request-001",
|
||||
"comment": "人工复核确认",
|
||||
},
|
||||
)
|
||||
stale = client.post(
|
||||
f"/api/v1/risk-observations/{observation_id}/disposition/actions",
|
||||
headers=finance_headers,
|
||||
json={
|
||||
"action": "request_waiver",
|
||||
"expected_version": 0,
|
||||
"request_id": "api-stale-request-001",
|
||||
"comment": "申请风险豁免复核",
|
||||
},
|
||||
)
|
||||
changed_replay = client.post(
|
||||
f"/api/v1/risk-observations/{observation_id}/disposition/actions",
|
||||
headers=finance_headers,
|
||||
json={
|
||||
"action": "false_positive",
|
||||
"expected_version": 0,
|
||||
"request_id": "api-confirm-request-001",
|
||||
"comment": "经核验属于误报",
|
||||
},
|
||||
)
|
||||
unsafe_legacy = client.post(
|
||||
f"/api/v1/risk-observations/{observation_id}/feedback",
|
||||
headers=finance_headers,
|
||||
json={"feedback_type": "comment", "payload_json": {"arbitrary": True}},
|
||||
)
|
||||
foreign_action = client.post(
|
||||
f"/api/v1/risk-observations/{foreign_observation_id}/disposition/actions",
|
||||
headers=finance_headers,
|
||||
json={
|
||||
"action": "confirm",
|
||||
"expected_version": 0,
|
||||
"request_id": "api-cross-tenant-001",
|
||||
},
|
||||
)
|
||||
standalone_action = client.post(
|
||||
f"/api/v1/risk-observations/{standalone_observation_id}/disposition/actions",
|
||||
headers=finance_headers,
|
||||
json={
|
||||
"action": "confirm",
|
||||
"expected_version": 0,
|
||||
"request_id": "api-standalone-finance-001",
|
||||
},
|
||||
)
|
||||
|
||||
assert first.status_code == 200
|
||||
assert first.json()["disposition"]["adjudication"] == "confirmed"
|
||||
assert first.json()["disposition"]["lifecycle_status"] == "open"
|
||||
assert replay.status_code == 200
|
||||
assert replay.json()["replayed"] is True
|
||||
assert replay.json()["event"]["id"] == first.json()["event"]["id"]
|
||||
assert stale.status_code == 409
|
||||
assert stale.json()["detail"]["code"] == "RISK_DISPOSITION_VERSION_CONFLICT"
|
||||
assert stale.json()["detail"]["message"] == "风险处置状态已更新,请刷新证据链后重试。"
|
||||
assert changed_replay.status_code == 409
|
||||
assert unsafe_legacy.status_code == 410
|
||||
assert foreign_action.status_code == 404
|
||||
assert standalone_action.status_code == 403
|
||||
|
||||
detail = client.get(
|
||||
f"/api/v1/risk-observations/{observation_id}",
|
||||
headers=finance_headers,
|
||||
)
|
||||
assert detail.status_code == 200
|
||||
assert detail.json()["disposition"]["version"] == 1
|
||||
assert len(detail.json()["disposition"]["events"]) == 1
|
||||
|
||||
|
||||
def test_current_claim_approver_can_manage_disposition_without_pool_access() -> None:
|
||||
with _build_session() as db:
|
||||
manager = Employee(
|
||||
id="manager-risk",
|
||||
employee_no="M-RISK",
|
||||
name="风险主管",
|
||||
email="risk.manager@example.com",
|
||||
position="部门经理",
|
||||
grade="P8",
|
||||
)
|
||||
employee = _employee()
|
||||
employee.manager_id = manager.id
|
||||
claim = _claim()
|
||||
claim.approval_stage = "直属领导审批"
|
||||
db.add_all([manager, employee, claim])
|
||||
db.flush()
|
||||
observation = RiskObservationService(db).upsert_observation(
|
||||
_observation_payload("risk:approver:duplicate")
|
||||
)
|
||||
db.commit()
|
||||
current_user = CurrentUserContext(
|
||||
username="risk.manager@example.com",
|
||||
name="风险主管",
|
||||
role_codes=["approver"],
|
||||
is_admin=False,
|
||||
employee_no="M-RISK",
|
||||
)
|
||||
policy = RiskObservationAccessPolicy(db)
|
||||
|
||||
assert policy.can_read_tenant_pool(current_user) is False
|
||||
assert policy.can_read_claim_risks(claim.id, current_user) is True
|
||||
assert policy.can_manage_disposition(observation, current_user) is True
|
||||
|
||||
unrelated_manager = CurrentUserContext(
|
||||
username="unrelated.manager@example.com",
|
||||
name="其他经理",
|
||||
role_codes=["manager"],
|
||||
is_admin=False,
|
||||
)
|
||||
finance_outside_stage = CurrentUserContext(
|
||||
username="finance@example.com",
|
||||
name="财务甲",
|
||||
role_codes=["finance"],
|
||||
is_admin=False,
|
||||
)
|
||||
assert policy.can_read_tenant_pool(unrelated_manager) is False
|
||||
assert policy.can_read_claim_risks(claim.id, unrelated_manager) is False
|
||||
assert policy.can_manage_disposition(observation, unrelated_manager) is False
|
||||
assert policy.can_manage_disposition(observation, finance_outside_stage) is False
|
||||
|
||||
|
||||
def test_disposition_rechecks_current_approver_after_claim_stage_changes() -> None:
|
||||
with _build_session() as db:
|
||||
manager = Employee(
|
||||
id="manager-risk-stage-change",
|
||||
employee_no="M-RISK-STAGE",
|
||||
name="原审批主管",
|
||||
email="risk.stage.manager@example.com",
|
||||
)
|
||||
employee = _employee()
|
||||
employee.id = "emp-risk-stage-change"
|
||||
employee.employee_no = "E-RISK-STAGE"
|
||||
employee.email = "risk.stage.employee@example.com"
|
||||
employee.manager_id = manager.id
|
||||
claim = _claim()
|
||||
claim.id = "claim-risk-stage-change"
|
||||
claim.claim_no = "BX-RISK-STAGE"
|
||||
claim.employee_id = employee.id
|
||||
claim.approval_stage = "直属领导审批"
|
||||
db.add_all([manager, employee, claim])
|
||||
db.flush()
|
||||
observation = RiskObservationService(db).upsert_observation(
|
||||
{
|
||||
**_observation_payload("risk:approver:stage-change"),
|
||||
"claim_id": claim.id,
|
||||
"claim_no": claim.claim_no,
|
||||
"subject_key": f"claim:{claim.id}",
|
||||
}
|
||||
)
|
||||
db.commit()
|
||||
current_user = CurrentUserContext(
|
||||
username=manager.email,
|
||||
name=manager.name,
|
||||
role_codes=["manager"],
|
||||
is_admin=False,
|
||||
employee_no=manager.employee_no,
|
||||
)
|
||||
|
||||
claim.approval_stage = "财务审批"
|
||||
db.commit()
|
||||
|
||||
with pytest.raises(RiskDispositionPermissionError, match="不再是"):
|
||||
RiskDispositionService(db).execute_action(
|
||||
observation.id,
|
||||
_action("confirm", version=0, request_id="request-stage-changed-001"),
|
||||
tenant_id="default",
|
||||
actor_id=manager.id,
|
||||
actor_name=manager.name,
|
||||
current_user=current_user,
|
||||
)
|
||||
assert db.scalar(select(func.count()).select_from(RiskDispositionEvent)) == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"action",
|
||||
["false_positive", "request_supplement", "request_waiver"],
|
||||
)
|
||||
def test_evidence_sensitive_actions_require_server_side_comment(action: str) -> None:
|
||||
with pytest.raises(ValueError, match="必须填写 comment"):
|
||||
RiskDispositionActionCreate(
|
||||
action=action,
|
||||
expected_version=0,
|
||||
request_id=f"request-comment-{action}",
|
||||
)
|
||||
|
||||
|
||||
def _action(
|
||||
action: str,
|
||||
*,
|
||||
version: int,
|
||||
request_id: str,
|
||||
) -> RiskDispositionActionCreate:
|
||||
return RiskDispositionActionCreate(
|
||||
action=action,
|
||||
expected_version=version,
|
||||
request_id=request_id,
|
||||
comment=(
|
||||
"风险处置说明"
|
||||
if action in {"false_positive", "request_supplement", "request_waiver"}
|
||||
else None
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _build_session() -> 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)
|
||||
return factory()
|
||||
|
||||
|
||||
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)
|
||||
app = FastAPI()
|
||||
app.include_router(risk_observations_router, prefix="/api/v1")
|
||||
install_legacy_header_auth_override(app)
|
||||
|
||||
def override_db() -> Generator[Session, None, None]:
|
||||
db = factory()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
app.dependency_overrides[get_db] = override_db
|
||||
return TestClient(app), factory
|
||||
|
||||
|
||||
def _employee() -> Employee:
|
||||
return Employee(
|
||||
id="emp-risk",
|
||||
employee_no="E-RISK",
|
||||
name="风险员工",
|
||||
email="risk.employee@example.com",
|
||||
position="高级专员",
|
||||
grade="P6",
|
||||
)
|
||||
|
||||
|
||||
def _claim() -> ExpenseClaim:
|
||||
now = datetime(2026, 7, 16, tzinfo=UTC)
|
||||
return ExpenseClaim(
|
||||
id="claim-risk-1",
|
||||
claim_no="BX-RISK-001",
|
||||
employee_id="emp-risk",
|
||||
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=[],
|
||||
)
|
||||
|
||||
|
||||
def _observation_payload(observation_key: str) -> dict[str, object]:
|
||||
return {
|
||||
"observation_key": observation_key,
|
||||
"subject_type": "expense_claim",
|
||||
"subject_key": "claim:claim-risk-1",
|
||||
"subject_label": "BX-RISK-001",
|
||||
"claim_id": "claim-risk-1",
|
||||
"claim_no": "BX-RISK-001",
|
||||
"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": {},
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Generator
|
||||
from datetime import UTC, datetime
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from decimal import Decimal
|
||||
from types import SimpleNamespace
|
||||
|
||||
@@ -22,6 +22,7 @@ from app.models.expense_case import ExpenseCase, ExpenseCaseLink
|
||||
from app.models.financial_record import ExpenseClaim
|
||||
from app.models.risk_observation import RiskObservation
|
||||
from app.schemas.risk_observation import RiskObservationFeedbackCreate
|
||||
from app.services.expense_claims import ExpenseClaimService
|
||||
from app.services.hermes_risk_scanner import HermesRiskScannerService
|
||||
from app.services.risk_observations import RiskObservationService
|
||||
|
||||
@@ -129,6 +130,47 @@ def test_platform_rule_flags_are_persisted_as_risk_observations() -> None:
|
||||
assert persisted.contribution_scores_json == {"S_rule": 100}
|
||||
|
||||
|
||||
def test_high_platform_risk_persistence_failure_is_fail_closed(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
with _build_session() as db:
|
||||
claim = _claim_orm("c-platform-fail-closed", "BX-PLATFORM-FAIL-CLOSED")
|
||||
db.add(claim)
|
||||
db.flush()
|
||||
service = ExpenseClaimService(db)
|
||||
monkeypatch.setattr(
|
||||
service,
|
||||
"evaluate_platform_risk_rules",
|
||||
lambda _claim, **_kwargs: {
|
||||
"flags": [
|
||||
{
|
||||
"source": "platform_risk",
|
||||
"hit_source": "rule_center",
|
||||
"rule_type": "risk",
|
||||
"rule_code": "risk.invoice.blocking",
|
||||
"severity": "high",
|
||||
"action": "block",
|
||||
"label": "高风险票据",
|
||||
"message": "票据需要人工核验。",
|
||||
}
|
||||
],
|
||||
"rule_set_fingerprint": "rules-v1",
|
||||
},
|
||||
)
|
||||
|
||||
def fail_persistence(*_args, **_kwargs):
|
||||
raise RuntimeError("database unavailable")
|
||||
|
||||
monkeypatch.setattr(
|
||||
RiskObservationService,
|
||||
"upsert_platform_risk_flags",
|
||||
fail_persistence,
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="高风险观察持久化失败"):
|
||||
service._run_ai_submission_review(claim)
|
||||
|
||||
|
||||
def test_risk_observation_storage_ready_is_cached_per_bind(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
with _build_session() as db:
|
||||
RiskObservationService._storage_ready_cache.clear()
|
||||
@@ -189,9 +231,9 @@ def test_risk_observation_endpoints_return_list_detail_dashboard_and_feedback()
|
||||
assert updated_detail_response.json()["feedback_items"][0]["feedback_type"] == "false_positive"
|
||||
|
||||
with session_factory() as db:
|
||||
observation = db.query(RiskObservation).filter_by(
|
||||
observation_key="risk:c1:duplicate_invoice"
|
||||
).one()
|
||||
observation = (
|
||||
db.query(RiskObservation).filter_by(observation_key="risk:c1:duplicate_invoice").one()
|
||||
)
|
||||
assert observation.status == "false_positive"
|
||||
assert observation.feedback_status == "false_positive"
|
||||
|
||||
@@ -223,11 +265,15 @@ def test_risk_observation_endpoints_enforce_tenant_scope_and_authenticated_actor
|
||||
tenant_a_headers = {
|
||||
"X-Auth-Username": "auditor-a",
|
||||
"X-Auth-Name": "Tenant A Auditor",
|
||||
"X-Auth-Role-Codes": "finance",
|
||||
"X-Auth-Is-Admin": "true",
|
||||
"X-Auth-Tenant-Id": "tenant-a",
|
||||
}
|
||||
tenant_b_headers = {
|
||||
"X-Auth-Username": "auditor-b",
|
||||
"X-Auth-Name": "Tenant B Auditor",
|
||||
"X-Auth-Role-Codes": "finance",
|
||||
"X-Auth-Is-Admin": "true",
|
||||
"X-Auth-Tenant-Id": "tenant-b",
|
||||
}
|
||||
|
||||
@@ -269,8 +315,8 @@ def test_risk_observation_endpoints_enforce_tenant_scope_and_authenticated_actor
|
||||
assert detail_response.status_code == 200
|
||||
assert detail_response.json()["tenant_id"] == "tenant-a"
|
||||
assert foreign_detail_response.status_code == 404
|
||||
assert claim_response.status_code == 200
|
||||
assert [item["tenant_id"] for item in claim_response.json()] == ["tenant-a"]
|
||||
# 单据风险入口必须先通过单据自身可见范围;不存在的历史 claim 不再旁路读取。
|
||||
assert claim_response.status_code == 404
|
||||
assert execution_log_response.status_code == 200
|
||||
assert [item["tenant_id"] for item in execution_log_response.json()] == ["tenant-a"]
|
||||
assert dashboard_response.status_code == 200
|
||||
@@ -359,9 +405,12 @@ def test_risk_observation_rejects_explicit_tenant_mismatching_claim_link() -> No
|
||||
tenant_id="tenant-b",
|
||||
)
|
||||
|
||||
assert db.query(RiskObservation).filter_by(
|
||||
observation_key="risk:tenant-boundary"
|
||||
).one_or_none() is None
|
||||
assert (
|
||||
db.query(RiskObservation)
|
||||
.filter_by(observation_key="risk:tenant-boundary")
|
||||
.one_or_none()
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
def test_hermes_global_scan_builds_graphs_inside_each_tenant(
|
||||
@@ -427,6 +476,37 @@ def test_hermes_global_scan_builds_graphs_inside_each_tenant(
|
||||
assert summary["scanned_claim_count"] == 2
|
||||
|
||||
|
||||
def test_risk_scan_discards_snapshot_after_claim_changes_during_evaluation(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
with _build_session() as db:
|
||||
claim = _claim_orm("claim-scan-stale", "BX-SCAN-STALE")
|
||||
db.add(claim)
|
||||
db.commit()
|
||||
original_updated_at = claim.updated_at
|
||||
|
||||
def fake_evaluate(_context):
|
||||
claim.status = "pending_payment"
|
||||
claim.approval_stage = "待付款"
|
||||
claim.updated_at = original_updated_at + timedelta(seconds=1)
|
||||
db.flush()
|
||||
return SimpleNamespace(observations=[], nodes=[], edges=[])
|
||||
|
||||
scanner = HermesRiskScannerService(db)
|
||||
monkeypatch.setattr(scanner, "_fetch_unscanned_claims", lambda: [claim])
|
||||
monkeypatch.setattr(
|
||||
"app.services.hermes_risk_scanner.evaluate_financial_risk_graph",
|
||||
fake_evaluate,
|
||||
)
|
||||
|
||||
summary = scanner.scan_global_risks()
|
||||
|
||||
db.refresh(claim)
|
||||
assert summary["scanned_claim_count"] == 0
|
||||
assert claim.status == "pending_payment"
|
||||
assert claim.hermes_scanned_at is None
|
||||
|
||||
|
||||
def test_risk_observation_feedback_pool_fields_and_replay_set_contract() -> None:
|
||||
with _build_session() as db:
|
||||
service = RiskObservationService(db)
|
||||
|
||||
@@ -19,6 +19,7 @@ def test_create_legacy_schema_never_creates_migration_owned_tables() -> None:
|
||||
"ai_application_preview_decisions",
|
||||
"ai_decision_feedback",
|
||||
"ai_decisions",
|
||||
"approval_action_ledgers",
|
||||
"auth_sessions",
|
||||
"attachment_association_jobs",
|
||||
"business_events",
|
||||
@@ -28,6 +29,8 @@ def test_create_legacy_schema_never_creates_migration_owned_tables() -> None:
|
||||
"memory_evidence_links",
|
||||
"risk_observations",
|
||||
"risk_observation_feedback",
|
||||
"risk_disposition_events",
|
||||
"risk_dispositions",
|
||||
"few_shot_samples",
|
||||
"workflow_outcomes",
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user