feat(approval): add task workflow and waiver decisions
This commit is contained in:
@@ -26,6 +26,7 @@ from app.services.risk_dispositions import (
|
||||
RiskDispositionPermissionError,
|
||||
RiskDispositionService,
|
||||
RiskDispositionVersionConflictError,
|
||||
RiskWaiverDecisionPermissionError,
|
||||
)
|
||||
from app.services.risk_observation_access_policy import RiskObservationAccessPolicy
|
||||
from app.services.risk_observations import RiskObservationService
|
||||
@@ -467,7 +468,9 @@ def test_risk_observation_api_enforces_pool_claim_and_typed_action_permissions(
|
||||
"action": "request_waiver",
|
||||
"expected_version": 0,
|
||||
"request_id": "api-stale-request-001",
|
||||
"comment": "申请风险豁免复核",
|
||||
"waiver_reason": "客户现场条件导致合规材料暂缺",
|
||||
"waiver_scope": "仅限本次客户拜访的行程材料",
|
||||
"waiver_expires_at": (datetime.now(UTC) + timedelta(days=3)).isoformat(),
|
||||
},
|
||||
)
|
||||
changed_replay = client.post(
|
||||
@@ -637,9 +640,315 @@ def test_disposition_rechecks_current_approver_after_claim_stage_changes() -> No
|
||||
assert db.scalar(select(func.count()).select_from(RiskDispositionEvent)) == 0
|
||||
|
||||
|
||||
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"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"action",
|
||||
["false_positive", "request_supplement", "request_waiver"],
|
||||
["false_positive", "request_supplement"],
|
||||
)
|
||||
def test_evidence_sensitive_actions_require_server_side_comment(action: str) -> None:
|
||||
with pytest.raises(ValueError, match="必须填写 comment"):
|
||||
@@ -650,21 +959,39 @@ def test_evidence_sensitive_actions_require_server_side_comment(action: str) ->
|
||||
)
|
||||
|
||||
|
||||
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",
|
||||
)
|
||||
|
||||
|
||||
def _action(
|
||||
action: str,
|
||||
*,
|
||||
version: int,
|
||||
request_id: str,
|
||||
) -> RiskDispositionActionCreate:
|
||||
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": ["补齐客户确认函", "到期前完成复核"],
|
||||
}
|
||||
return RiskDispositionActionCreate(
|
||||
action=action,
|
||||
expected_version=version,
|
||||
request_id=request_id,
|
||||
comment=(
|
||||
"风险处置说明"
|
||||
if action in {"false_positive", "request_supplement", "request_waiver"}
|
||||
if action in {"false_positive", "request_supplement"}
|
||||
else None
|
||||
),
|
||||
**waiver_fields,
|
||||
)
|
||||
|
||||
|
||||
@@ -713,6 +1040,24 @@ def _employee() -> Employee:
|
||||
)
|
||||
|
||||
|
||||
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 _claim() -> ExpenseClaim:
|
||||
now = datetime(2026, 7, 16, tzinfo=UTC)
|
||||
return ExpenseClaim(
|
||||
|
||||
Reference in New Issue
Block a user