feat(approval): add task workflow and waiver decisions

This commit is contained in:
caoxiaozhu
2026-07-16 16:52:12 +08:00
parent 28b834edd3
commit 242d68c36f
89 changed files with 16313 additions and 294 deletions

View File

@@ -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(