2026-07-16 15:34:58 +08:00
|
|
|
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
|
2026-07-17 14:14:08 +08:00
|
|
|
from app.core.agent_enums import AgentAssetDomain, AgentAssetStatus, AgentAssetType
|
2026-07-16 15:34:58 +08:00
|
|
|
from app.db.base import Base
|
2026-07-17 14:14:08 +08:00
|
|
|
from app.models.agent_asset import AgentAsset
|
|
|
|
|
from app.models.agent_asset_release_telemetry import AgentAssetReleaseLabel
|
2026-07-16 15:34:58 +08:00
|
|
|
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
|
2026-07-17 14:14:08 +08:00
|
|
|
from app.services.agent_asset_release_telemetry import (
|
|
|
|
|
AgentAssetReleaseTelemetryService,
|
|
|
|
|
ReleaseObservationInput,
|
|
|
|
|
)
|
2026-07-16 15:34:58 +08:00
|
|
|
from app.services.risk_dispositions import (
|
|
|
|
|
RiskDispositionConflictError,
|
|
|
|
|
RiskDispositionIdempotencyConflictError,
|
|
|
|
|
RiskDispositionPermissionError,
|
|
|
|
|
RiskDispositionService,
|
|
|
|
|
RiskDispositionVersionConflictError,
|
2026-07-16 16:52:12 +08:00
|
|
|
RiskWaiverDecisionPermissionError,
|
2026-07-16 15:34:58 +08:00
|
|
|
)
|
|
|
|
|
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="财务甲",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-07-17 14:14:08 +08:00
|
|
|
def test_typed_risk_disposition_appends_current_release_label_automatically(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
) -> None:
|
|
|
|
|
monkeypatch.setenv("FEW_SHOT_INJECTION_ENABLED", "false")
|
|
|
|
|
with _build_session() as db:
|
|
|
|
|
db.add(_claim())
|
|
|
|
|
asset = AgentAsset(
|
|
|
|
|
id="risk-disposition-release-asset",
|
|
|
|
|
tenant_id="default",
|
|
|
|
|
scope="tenant",
|
|
|
|
|
asset_type=AgentAssetType.RULE.value,
|
|
|
|
|
code="risk.disposition.release",
|
|
|
|
|
name="处置学习发布规则",
|
|
|
|
|
description="",
|
|
|
|
|
domain=AgentAssetDomain.EXPENSE.value,
|
|
|
|
|
scenario_json=["travel"],
|
|
|
|
|
owner="finance",
|
|
|
|
|
status=AgentAssetStatus.ACTIVE.value,
|
|
|
|
|
current_version="v1",
|
|
|
|
|
published_version="v1",
|
|
|
|
|
working_version="v2",
|
|
|
|
|
config_json={
|
|
|
|
|
"tenant_id": "default",
|
|
|
|
|
"detail_mode": "json_risk",
|
|
|
|
|
"stable_marker": "stable-risk-disposition-release",
|
|
|
|
|
"release_guard": {
|
|
|
|
|
"release_id": "risk-disposition-release-1",
|
|
|
|
|
"stage": "shadow",
|
|
|
|
|
"candidate_version": "v2",
|
|
|
|
|
"previous_version": "v1",
|
|
|
|
|
"previous_config": {
|
|
|
|
|
"tenant_id": "default",
|
|
|
|
|
"detail_mode": "json_risk",
|
|
|
|
|
"enabled": True,
|
|
|
|
|
"stable_marker": "stable-risk-disposition-release",
|
|
|
|
|
},
|
|
|
|
|
"policy": {
|
|
|
|
|
"shadow_min_samples": 1,
|
|
|
|
|
"canary_min_samples": 1,
|
|
|
|
|
"max_error_rate": 1.0,
|
|
|
|
|
"min_precision": 0.9,
|
|
|
|
|
"max_precision_drop": 1.0,
|
|
|
|
|
"canary_traffic_percent": 10,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
db.add(asset)
|
|
|
|
|
db.commit()
|
|
|
|
|
telemetry = AgentAssetReleaseTelemetryService(db).record_observation(
|
|
|
|
|
ReleaseObservationInput(
|
|
|
|
|
tenant_id="default",
|
|
|
|
|
asset_id=asset.id,
|
|
|
|
|
release_id="risk-disposition-release-1",
|
|
|
|
|
stage="shadow",
|
|
|
|
|
version="v2",
|
|
|
|
|
rule_code=asset.code,
|
|
|
|
|
source_key="claim-risk-1",
|
|
|
|
|
candidate_hit=True,
|
|
|
|
|
baseline_hit=True,
|
|
|
|
|
business_stage="reimbursement",
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
observation = RiskObservationService(db).upsert_observation(
|
|
|
|
|
{
|
|
|
|
|
**_observation_payload("risk:typed:release-telemetry"),
|
|
|
|
|
"risk_signal": asset.code,
|
|
|
|
|
"algorithm_version": "v1",
|
|
|
|
|
"policy_refs": [asset.code],
|
|
|
|
|
"decision_trace": {"rule_code": asset.code},
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
db.commit()
|
|
|
|
|
|
|
|
|
|
mutation = RiskDispositionService(db).execute_action(
|
|
|
|
|
observation.id,
|
|
|
|
|
_action("confirm", version=0, request_id="request-release-label-001"),
|
|
|
|
|
tenant_id="default",
|
|
|
|
|
actor_id="finance-secret-account",
|
|
|
|
|
actor_name="财务甲",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
label = db.scalar(
|
|
|
|
|
select(AgentAssetReleaseLabel).where(
|
|
|
|
|
AgentAssetReleaseLabel.observation_id == telemetry.id
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
assert mutation.disposition.adjudication == "confirmed"
|
|
|
|
|
assert label is not None
|
|
|
|
|
assert label.label == "confirmed"
|
|
|
|
|
assert label.verification_source == "typed_risk_disposition"
|
|
|
|
|
assert "finance-secret-account" not in str(label.__dict__)
|
|
|
|
|
|
|
|
|
|
corrected = RiskDispositionService(db).execute_action(
|
|
|
|
|
observation.id,
|
|
|
|
|
_action(
|
|
|
|
|
"false_positive",
|
|
|
|
|
version=1,
|
|
|
|
|
request_id="request-release-label-correction-001",
|
|
|
|
|
),
|
|
|
|
|
tenant_id="default",
|
|
|
|
|
actor_id="finance-secret-account",
|
|
|
|
|
actor_name="财务甲",
|
|
|
|
|
)
|
|
|
|
|
db.expire_all()
|
|
|
|
|
refreshed_asset = db.get(AgentAsset, asset.id)
|
|
|
|
|
|
|
|
|
|
assert corrected.disposition.adjudication == "false_positive"
|
|
|
|
|
assert refreshed_asset is not None
|
|
|
|
|
assert refreshed_asset.published_version == "v1"
|
|
|
|
|
assert refreshed_asset.config_json["stable_marker"] == ("stable-risk-disposition-release")
|
|
|
|
|
assert refreshed_asset.config_json["release_guard"]["stage"] == "rolled_back"
|
|
|
|
|
assert refreshed_asset.config_json["release_guard"]["rollback"]["automatic"] is True
|
|
|
|
|
|
|
|
|
|
|
2026-07-16 15:34:58 +08:00
|
|
|
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
|
2026-07-16 15:49:43 +08:00
|
|
|
stored_event = db.get(RiskDispositionEvent, first.event.id)
|
|
|
|
|
assert stored_event is not None
|
|
|
|
|
assert stored_event.response_json is not None
|
|
|
|
|
assert stored_event.response_json["replayed"] is False
|
|
|
|
|
assert stored_event.response_json["disposition"]["version"] == 1
|
|
|
|
|
|
|
|
|
|
advanced = service.execute_action(
|
|
|
|
|
observation.id,
|
|
|
|
|
_action(
|
|
|
|
|
"start_remediation",
|
|
|
|
|
version=1,
|
|
|
|
|
request_id="request-remediation-after-idempotent",
|
|
|
|
|
),
|
|
|
|
|
tenant_id="default",
|
|
|
|
|
actor_id="finance-1",
|
|
|
|
|
actor_name="财务甲",
|
|
|
|
|
)
|
|
|
|
|
late_replay = service.execute_action(
|
|
|
|
|
observation.id,
|
|
|
|
|
payload,
|
|
|
|
|
tenant_id="default",
|
|
|
|
|
actor_id="finance-1",
|
|
|
|
|
actor_name="财务甲",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert advanced.disposition.version == 2
|
|
|
|
|
assert late_replay.replayed is True
|
|
|
|
|
assert late_replay.disposition.version == 1
|
|
|
|
|
assert late_replay.disposition.lifecycle_status == "open"
|
|
|
|
|
assert [item.version for item in late_replay.disposition.events] == [1]
|
|
|
|
|
assert db.scalar(select(func.count()).select_from(RiskDispositionEvent)) == 2
|
2026-07-16 15:34:58 +08:00
|
|
|
|
|
|
|
|
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="财务甲",
|
|
|
|
|
)
|
2026-07-16 15:49:43 +08:00
|
|
|
assert error.value.current_version == 2
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_legacy_event_replay_rebuilds_only_target_version_history(
|
|
|
|
|
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-replay")
|
|
|
|
|
)
|
|
|
|
|
db.commit()
|
|
|
|
|
service = RiskDispositionService(db)
|
|
|
|
|
payload = _action("confirm", version=0, request_id="request-legacy-replay-001")
|
|
|
|
|
first = service.execute_action(
|
|
|
|
|
observation.id,
|
|
|
|
|
payload,
|
|
|
|
|
tenant_id="default",
|
|
|
|
|
actor_id="finance-1",
|
|
|
|
|
actor_name="财务甲",
|
|
|
|
|
)
|
|
|
|
|
legacy_event = db.get(RiskDispositionEvent, first.event.id)
|
|
|
|
|
assert legacy_event is not None
|
|
|
|
|
legacy_event.response_json = None
|
|
|
|
|
db.commit()
|
|
|
|
|
|
|
|
|
|
service.execute_action(
|
|
|
|
|
observation.id,
|
|
|
|
|
_action(
|
|
|
|
|
"start_remediation",
|
|
|
|
|
version=1,
|
|
|
|
|
request_id="request-legacy-replay-later",
|
|
|
|
|
),
|
|
|
|
|
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 replay.disposition.version == 1
|
|
|
|
|
assert replay.disposition.lifecycle_status == "open"
|
|
|
|
|
assert [item.version for item in replay.disposition.events] == [1]
|
2026-07-16 15:34:58 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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": "人工复核确认",
|
|
|
|
|
},
|
|
|
|
|
)
|
2026-07-16 15:49:43 +08:00
|
|
|
advanced = client.post(
|
|
|
|
|
f"/api/v1/risk-observations/{observation_id}/disposition/actions",
|
|
|
|
|
headers=finance_headers,
|
|
|
|
|
json={
|
|
|
|
|
"action": "request_supplement",
|
|
|
|
|
"expected_version": 1,
|
|
|
|
|
"request_id": "api-supplement-request-001",
|
|
|
|
|
"comment": "请补充完整行程材料",
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
late_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": "人工复核确认",
|
|
|
|
|
},
|
|
|
|
|
)
|
2026-07-16 15:34:58 +08:00
|
|
|
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",
|
2026-07-16 16:52:12 +08:00
|
|
|
"waiver_reason": "客户现场条件导致合规材料暂缺",
|
|
|
|
|
"waiver_scope": "仅限本次客户拜访的行程材料",
|
|
|
|
|
"waiver_expires_at": (datetime.now(UTC) + timedelta(days=3)).isoformat(),
|
2026-07-16 15:34:58 +08:00
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
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"]
|
2026-07-16 15:49:43 +08:00
|
|
|
assert advanced.status_code == 200
|
|
|
|
|
assert advanced.json()["disposition"]["version"] == 2
|
|
|
|
|
assert late_replay.status_code == 200
|
|
|
|
|
assert late_replay.json()["replayed"] is True
|
|
|
|
|
assert late_replay.json()["disposition"]["version"] == 1
|
|
|
|
|
assert late_replay.json()["disposition"]["lifecycle_status"] == "open"
|
|
|
|
|
assert [item["version"] for item in late_replay.json()["disposition"]["events"]] == [1]
|
2026-07-16 15:34:58 +08:00
|
|
|
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
|
2026-07-16 15:49:43 +08:00
|
|
|
assert detail.json()["disposition"]["version"] == 2
|
|
|
|
|
assert len(detail.json()["disposition"]["events"]) == 2
|
2026-07-16 15:34:58 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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(
|
2026-07-17 14:14:08 +08:00
|
|
|
tenant_id="default",
|
2026-07-16 15:34:58 +08:00
|
|
|
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(
|
2026-07-17 14:14:08 +08:00
|
|
|
tenant_id="default",
|
2026-07-16 15:34:58 +08:00
|
|
|
username="unrelated.manager@example.com",
|
|
|
|
|
name="其他经理",
|
|
|
|
|
role_codes=["manager"],
|
|
|
|
|
is_admin=False,
|
|
|
|
|
)
|
|
|
|
|
finance_outside_stage = CurrentUserContext(
|
2026-07-17 14:14:08 +08:00
|
|
|
tenant_id="default",
|
2026-07-16 15:34:58 +08:00
|
|
|
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(
|
2026-07-17 14:14:08 +08:00
|
|
|
tenant_id="default",
|
2026-07-16 15:34:58 +08:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-07-16 16:52:12 +08:00
|
|
|
def test_waiver_approval_enforces_separation_of_duties_and_replays_snapshot(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
) -> None:
|
|
|
|
|
monkeypatch.setenv("FEW_SHOT_INJECTION_ENABLED", "false")
|
|
|
|
|
with _build_session() as db:
|
|
|
|
|
requester = Employee(
|
|
|
|
|
id="waiver-requester",
|
|
|
|
|
employee_no="W-REQUESTER",
|
|
|
|
|
name="豁免申请人",
|
|
|
|
|
email="waiver.requester@example.com",
|
|
|
|
|
employment_status="在职",
|
|
|
|
|
)
|
|
|
|
|
decider = Employee(
|
|
|
|
|
id="waiver-decider",
|
|
|
|
|
employee_no="W-DECIDER",
|
|
|
|
|
name="豁免决定人",
|
|
|
|
|
email="waiver.decider@example.com",
|
|
|
|
|
employment_status="在职",
|
|
|
|
|
)
|
|
|
|
|
admin_viewer = Employee(
|
|
|
|
|
id="waiver-admin",
|
|
|
|
|
employee_no="W-ADMIN",
|
|
|
|
|
name="仅管理员",
|
|
|
|
|
email="waiver.admin@example.com",
|
|
|
|
|
employment_status="在职",
|
|
|
|
|
)
|
|
|
|
|
inactive_decider = Employee(
|
|
|
|
|
id="waiver-inactive",
|
|
|
|
|
employee_no="W-INACTIVE",
|
|
|
|
|
name="离职财务",
|
|
|
|
|
email="waiver.inactive@example.com",
|
|
|
|
|
employment_status="离职",
|
|
|
|
|
)
|
|
|
|
|
db.add_all([_employee(), _claim(), requester, decider, admin_viewer, inactive_decider])
|
|
|
|
|
db.flush()
|
|
|
|
|
observation = RiskObservationService(db).upsert_observation(
|
|
|
|
|
_observation_payload("risk:waiver:sod")
|
|
|
|
|
)
|
|
|
|
|
observation_id = observation.id
|
|
|
|
|
db.commit()
|
|
|
|
|
service = RiskDispositionService(db)
|
|
|
|
|
requester_user = _user(requester, roles=["finance"])
|
|
|
|
|
decider_user = _user(decider, roles=["finance"])
|
|
|
|
|
|
|
|
|
|
service.execute_action(
|
|
|
|
|
observation_id,
|
|
|
|
|
_action("confirm", version=0, request_id="waiver-confirm-request-001"),
|
|
|
|
|
tenant_id="default",
|
|
|
|
|
actor_id=requester.id,
|
|
|
|
|
actor_name=requester.name,
|
|
|
|
|
current_user=requester_user,
|
|
|
|
|
)
|
|
|
|
|
requested = service.execute_action(
|
|
|
|
|
observation_id,
|
|
|
|
|
RiskDispositionActionCreate(
|
|
|
|
|
action="request_waiver",
|
|
|
|
|
expected_version=1,
|
|
|
|
|
request_id="waiver-application-request-001",
|
|
|
|
|
waiver_reason="客户现场临时封控,无法在审批前取得原件。",
|
|
|
|
|
waiver_scope="仅限本次客户拜访的纸质行程确认材料。",
|
|
|
|
|
waiver_expires_at=datetime.now(UTC) + timedelta(days=5),
|
|
|
|
|
waiver_conditions=["三日内补交扫描件", "到期前由财务复核"],
|
|
|
|
|
),
|
|
|
|
|
tenant_id="default",
|
|
|
|
|
actor_id=requester.id,
|
|
|
|
|
actor_name=requester.name,
|
|
|
|
|
current_user=requester_user,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert requested.disposition.lifecycle_status == "waiver_requested"
|
|
|
|
|
assert requested.disposition.waiver_requester_id == requester.id
|
|
|
|
|
assert requested.disposition.waiver_reason == "客户现场临时封控,无法在审批前取得原件。"
|
|
|
|
|
assert requested.disposition.waiver_conditions_json == [
|
|
|
|
|
"三日内补交扫描件",
|
|
|
|
|
"到期前由财务复核",
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
decision = RiskDispositionActionCreate(
|
|
|
|
|
action="approve_waiver",
|
|
|
|
|
expected_version=2,
|
|
|
|
|
request_id="waiver-approve-request-001",
|
|
|
|
|
comment="条件明确且有效期受控,同意本次例外。",
|
|
|
|
|
)
|
|
|
|
|
with pytest.raises(RiskWaiverDecisionPermissionError, match="不能批准或拒绝自己的"):
|
|
|
|
|
service.execute_action(
|
|
|
|
|
observation_id,
|
|
|
|
|
decision,
|
|
|
|
|
tenant_id="default",
|
|
|
|
|
actor_id=requester.id,
|
|
|
|
|
actor_name=requester.name,
|
|
|
|
|
current_user=requester_user,
|
|
|
|
|
)
|
|
|
|
|
with pytest.raises(RiskWaiverDecisionPermissionError, match="明确角色"):
|
|
|
|
|
service.execute_action(
|
|
|
|
|
observation_id,
|
|
|
|
|
decision,
|
|
|
|
|
tenant_id="default",
|
|
|
|
|
actor_id=admin_viewer.id,
|
|
|
|
|
actor_name=admin_viewer.name,
|
|
|
|
|
current_user=_user(admin_viewer, roles=[], is_admin=True),
|
|
|
|
|
)
|
|
|
|
|
with pytest.raises(RiskWaiverDecisionPermissionError, match="不是在职员工"):
|
|
|
|
|
service.execute_action(
|
|
|
|
|
observation_id,
|
|
|
|
|
decision,
|
|
|
|
|
tenant_id="default",
|
|
|
|
|
actor_id=inactive_decider.id,
|
|
|
|
|
actor_name=inactive_decider.name,
|
|
|
|
|
current_user=_user(inactive_decider, roles=["finance"]),
|
|
|
|
|
)
|
|
|
|
|
with pytest.raises(RiskWaiverDecisionPermissionError, match="跨租户"):
|
|
|
|
|
service.execute_action(
|
|
|
|
|
observation_id,
|
|
|
|
|
decision,
|
|
|
|
|
tenant_id="default",
|
|
|
|
|
actor_id=decider.id,
|
|
|
|
|
actor_name=decider.name,
|
|
|
|
|
current_user=_user(decider, roles=["finance"], tenant_id="tenant-b"),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
approved = service.execute_action(
|
|
|
|
|
observation_id,
|
|
|
|
|
decision,
|
|
|
|
|
tenant_id="default",
|
|
|
|
|
actor_id=decider.id,
|
|
|
|
|
actor_name=decider.name,
|
|
|
|
|
current_user=decider_user,
|
|
|
|
|
)
|
|
|
|
|
replayed = service.execute_action(
|
|
|
|
|
observation_id,
|
|
|
|
|
decision,
|
|
|
|
|
tenant_id="default",
|
|
|
|
|
actor_id=decider.id,
|
|
|
|
|
actor_name=decider.name,
|
|
|
|
|
current_user=decider_user,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert approved.disposition.lifecycle_status == "waived"
|
|
|
|
|
assert approved.disposition.waiver_decision == "approved"
|
|
|
|
|
assert approved.disposition.waiver_decider_id == decider.id
|
|
|
|
|
assert approved.disposition.waiver_decision_reason == decision.comment
|
|
|
|
|
assert replayed.replayed is True
|
|
|
|
|
assert replayed.event.id == approved.event.id
|
|
|
|
|
assert replayed.disposition.waiver_requester_id == requester.id
|
|
|
|
|
assert replayed.disposition.waiver_decider_id == decider.id
|
|
|
|
|
assert db.scalar(select(func.count()).select_from(RiskDispositionEvent)) == 3
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_waiver_rejection_is_explicit_and_decisions_require_requested_state(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
) -> None:
|
|
|
|
|
monkeypatch.setenv("FEW_SHOT_INJECTION_ENABLED", "false")
|
|
|
|
|
with _build_session() as db:
|
|
|
|
|
requester = Employee(
|
|
|
|
|
id="waiver-reject-requester",
|
|
|
|
|
employee_no="W-REJECT-REQ",
|
|
|
|
|
name="拒绝流程申请人",
|
|
|
|
|
email="waiver.reject.requester@example.com",
|
|
|
|
|
employment_status="在职",
|
|
|
|
|
)
|
|
|
|
|
decider = Employee(
|
|
|
|
|
id="waiver-reject-decider",
|
|
|
|
|
employee_no="W-REJECT-DEC",
|
|
|
|
|
name="拒绝流程决定人",
|
|
|
|
|
email="waiver.reject.decider@example.com",
|
|
|
|
|
employment_status="在职",
|
|
|
|
|
)
|
|
|
|
|
db.add_all([_employee(), _claim(), requester, decider])
|
|
|
|
|
db.flush()
|
|
|
|
|
observation = RiskObservationService(db).upsert_observation(
|
|
|
|
|
_observation_payload("risk:waiver:rejected")
|
|
|
|
|
)
|
|
|
|
|
observation_id = observation.id
|
|
|
|
|
db.commit()
|
|
|
|
|
service = RiskDispositionService(db)
|
|
|
|
|
requester_user = _user(requester, roles=["finance"])
|
|
|
|
|
decider_user = _user(decider, roles=["executive"])
|
|
|
|
|
|
|
|
|
|
with pytest.raises(RiskDispositionConflictError, match="只有待决定"):
|
|
|
|
|
service.execute_action(
|
|
|
|
|
observation_id,
|
|
|
|
|
RiskDispositionActionCreate(
|
|
|
|
|
action="reject_waiver",
|
|
|
|
|
expected_version=0,
|
|
|
|
|
request_id="waiver-reject-without-request",
|
|
|
|
|
comment="当前没有可拒绝的申请。",
|
|
|
|
|
),
|
|
|
|
|
tenant_id="default",
|
|
|
|
|
actor_id=decider.id,
|
|
|
|
|
actor_name=decider.name,
|
|
|
|
|
current_user=decider_user,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
service.execute_action(
|
|
|
|
|
observation_id,
|
|
|
|
|
_action("confirm", version=0, request_id="waiver-reject-confirm"),
|
|
|
|
|
tenant_id="default",
|
|
|
|
|
actor_id=requester.id,
|
|
|
|
|
actor_name=requester.name,
|
|
|
|
|
current_user=requester_user,
|
|
|
|
|
)
|
|
|
|
|
service.execute_action(
|
|
|
|
|
observation_id,
|
|
|
|
|
RiskDispositionActionCreate(
|
|
|
|
|
action="request_waiver",
|
|
|
|
|
expected_version=1,
|
|
|
|
|
request_id="waiver-reject-application",
|
|
|
|
|
waiver_reason="希望免除本次高风险拦截。",
|
|
|
|
|
waiver_scope="本次重复票据风险。",
|
|
|
|
|
waiver_expires_at=datetime.now(UTC) + timedelta(days=2),
|
|
|
|
|
waiver_conditions=[],
|
|
|
|
|
),
|
|
|
|
|
tenant_id="default",
|
|
|
|
|
actor_id=requester.id,
|
|
|
|
|
actor_name=requester.name,
|
|
|
|
|
current_user=requester_user,
|
|
|
|
|
)
|
|
|
|
|
rejected = service.execute_action(
|
|
|
|
|
observation_id,
|
|
|
|
|
RiskDispositionActionCreate(
|
|
|
|
|
action="reject_waiver",
|
|
|
|
|
expected_version=2,
|
|
|
|
|
request_id="waiver-reject-decision",
|
|
|
|
|
comment="缺少足够补偿控制,拒绝豁免。",
|
|
|
|
|
),
|
|
|
|
|
tenant_id="default",
|
|
|
|
|
actor_id=decider.id,
|
|
|
|
|
actor_name=decider.name,
|
|
|
|
|
current_user=decider_user,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert rejected.disposition.lifecycle_status == "waiver_rejected"
|
|
|
|
|
assert rejected.disposition.waiver_decision == "rejected"
|
|
|
|
|
assert rejected.disposition.waiver_decider_id == decider.id
|
|
|
|
|
assert rejected.event.action == "reject_waiver"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_waiver_self_decision_endpoint_returns_typed_forbidden_error(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
) -> None:
|
|
|
|
|
monkeypatch.setenv("FEW_SHOT_INJECTION_ENABLED", "false")
|
|
|
|
|
client, session_factory = _build_client()
|
|
|
|
|
requester = Employee(
|
|
|
|
|
id="waiver-api-requester",
|
|
|
|
|
employee_no="W-API-REQ",
|
|
|
|
|
name="接口豁免申请人",
|
|
|
|
|
email="waiver.api.requester@example.com",
|
|
|
|
|
employment_status="在职",
|
|
|
|
|
)
|
|
|
|
|
requester_user = _user(requester, roles=["finance"])
|
|
|
|
|
requester_headers = {
|
|
|
|
|
"X-Auth-Username": requester.email,
|
|
|
|
|
"X-Auth-Name": "Waiver Requester",
|
|
|
|
|
"X-Auth-Employee-No": requester.employee_no,
|
|
|
|
|
"X-Auth-Employee-Id": requester.id,
|
|
|
|
|
"X-Auth-Role-Codes": "finance",
|
|
|
|
|
}
|
|
|
|
|
with session_factory() as db:
|
|
|
|
|
db.add_all([_employee(), _claim(), requester])
|
|
|
|
|
db.flush()
|
|
|
|
|
observation = RiskObservationService(db).upsert_observation(
|
|
|
|
|
_observation_payload("risk:waiver:api-sod")
|
|
|
|
|
)
|
|
|
|
|
observation_id = observation.id
|
|
|
|
|
db.commit()
|
|
|
|
|
service = RiskDispositionService(db)
|
|
|
|
|
service.execute_action(
|
|
|
|
|
observation_id,
|
|
|
|
|
_action("confirm", version=0, request_id="waiver-api-confirm"),
|
|
|
|
|
tenant_id="default",
|
|
|
|
|
actor_id=requester.id,
|
|
|
|
|
actor_name=requester.name,
|
|
|
|
|
current_user=requester_user,
|
|
|
|
|
)
|
|
|
|
|
service.execute_action(
|
|
|
|
|
observation_id,
|
|
|
|
|
RiskDispositionActionCreate(
|
|
|
|
|
action="request_waiver",
|
|
|
|
|
expected_version=1,
|
|
|
|
|
request_id="waiver-api-application",
|
|
|
|
|
waiver_reason="接口申请职责分离验证。",
|
|
|
|
|
waiver_scope="仅限当前风险观察。",
|
|
|
|
|
waiver_expires_at=datetime.now(UTC) + timedelta(days=2),
|
|
|
|
|
),
|
|
|
|
|
tenant_id="default",
|
|
|
|
|
actor_id=requester.id,
|
|
|
|
|
actor_name=requester.name,
|
|
|
|
|
current_user=requester_user,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
response = client.post(
|
|
|
|
|
f"/api/v1/risk-observations/{observation_id}/disposition/actions",
|
|
|
|
|
headers=requester_headers,
|
|
|
|
|
json={
|
|
|
|
|
"action": "approve_waiver",
|
|
|
|
|
"expected_version": 2,
|
|
|
|
|
"request_id": "waiver-api-self-approve",
|
|
|
|
|
"comment": "尝试自批。",
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert response.status_code == 403
|
|
|
|
|
assert response.json()["detail"]["code"] == "RISK_WAIVER_DECISION_FORBIDDEN"
|
|
|
|
|
assert "不能批准或拒绝自己的" in response.json()["detail"]["message"]
|
|
|
|
|
|
|
|
|
|
|
2026-07-16 15:34:58 +08:00
|
|
|
@pytest.mark.parametrize(
|
|
|
|
|
"action",
|
2026-07-16 16:52:12 +08:00
|
|
|
["false_positive", "request_supplement"],
|
2026-07-16 15:34:58 +08:00
|
|
|
)
|
|
|
|
|
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}",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-07-16 16:52:12 +08:00
|
|
|
def test_waiver_request_requires_reason_scope_and_expiry() -> None:
|
|
|
|
|
with pytest.raises(ValueError, match="必须填写原因、范围和有效期"):
|
|
|
|
|
RiskDispositionActionCreate(
|
|
|
|
|
action="request_waiver",
|
|
|
|
|
expected_version=0,
|
|
|
|
|
request_id="request-waiver-fields-missing",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-07-16 15:34:58 +08:00
|
|
|
def _action(
|
|
|
|
|
action: str,
|
|
|
|
|
*,
|
|
|
|
|
version: int,
|
|
|
|
|
request_id: str,
|
|
|
|
|
) -> RiskDispositionActionCreate:
|
2026-07-16 16:52:12 +08:00
|
|
|
waiver_fields: dict[str, object] = {}
|
|
|
|
|
if action == "request_waiver":
|
|
|
|
|
waiver_fields = {
|
|
|
|
|
"waiver_reason": "客户现场条件导致合规材料暂缺",
|
|
|
|
|
"waiver_scope": "仅限本次客户拜访的行程材料",
|
|
|
|
|
"waiver_expires_at": datetime.now(UTC) + timedelta(days=3),
|
|
|
|
|
"waiver_conditions": ["补齐客户确认函", "到期前完成复核"],
|
|
|
|
|
}
|
2026-07-16 15:34:58 +08:00
|
|
|
return RiskDispositionActionCreate(
|
|
|
|
|
action=action,
|
|
|
|
|
expected_version=version,
|
|
|
|
|
request_id=request_id,
|
2026-07-17 14:14:08 +08:00
|
|
|
comment=("风险处置说明" if action in {"false_positive", "request_supplement"} else None),
|
2026-07-16 16:52:12 +08:00
|
|
|
**waiver_fields,
|
2026-07-16 15:34:58 +08:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-07-16 16:52:12 +08:00
|
|
|
def _user(
|
|
|
|
|
employee: Employee,
|
|
|
|
|
*,
|
|
|
|
|
roles: list[str],
|
|
|
|
|
is_admin: bool = False,
|
|
|
|
|
tenant_id: str = "default",
|
|
|
|
|
) -> CurrentUserContext:
|
|
|
|
|
return CurrentUserContext(
|
|
|
|
|
username=employee.email,
|
|
|
|
|
name=employee.name,
|
|
|
|
|
role_codes=roles,
|
|
|
|
|
is_admin=is_admin,
|
|
|
|
|
tenant_id=tenant_id,
|
|
|
|
|
employee_no=employee.employee_no,
|
|
|
|
|
employee_id=employee.id,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-07-16 15:34:58 +08:00
|
|
|
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": {},
|
|
|
|
|
}
|