617 lines
21 KiB
Python
617 lines
21 KiB
Python
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)
|