Files
X-Financial/server/tests/test_risk_disposition_read_projection.py

617 lines
21 KiB
Python
Raw Normal View History

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