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

@@ -24,12 +24,19 @@ from app.schemas.risk_disposition import (
RiskDispositionMutationRead,
RiskDispositionRead,
)
from app.services.approval_task_projection_refresh import (
ApprovalTaskProjectionRefreshService,
)
from app.services.expense_claim_tenant_scope import ExpenseClaimTenantScopeMixin
from app.services.risk_observation_access_policy import RiskObservationAccessPolicy
from app.services.risk_waiver_decision_policy import (
RISK_WAIVER_DECISION_ACTIONS,
RiskWaiverDecisionPermissionError,
RiskWaiverDecisionPolicy,
)
logger = get_logger("app.services.risk_dispositions")
class RiskDispositionConflictError(RuntimeError):
pass
@@ -145,12 +152,17 @@ class RiskDispositionService:
raise RiskDispositionConflictError(
"Risk observation claim changed concurrently; reload and retry."
)
if current_user is not None and not RiskObservationAccessPolicy(
self.db
).can_manage_locked_disposition(
observation,
current_user,
locked_claim=locked_claim,
is_waiver_decision = payload.action in RISK_WAIVER_DECISION_ACTIONS
if (
not is_waiver_decision
and current_user is not None
and not RiskObservationAccessPolicy(
self.db
).can_manage_locked_disposition(
observation,
current_user,
locked_claim=locked_claim,
)
):
raise RiskDispositionPermissionError("当前用户已不再是该单据的有效审批人。")
@@ -180,9 +192,29 @@ class RiskDispositionService:
)
self.db.add(disposition)
_validate_transition(disposition, payload)
waiver_policy = RiskWaiverDecisionPolicy(self.db)
waiver_actor_id, waiver_actor_name = waiver_policy.actor_identity(
current_user,
fallback_id=normalized_actor_id,
fallback_name=_text(actor_name) or normalized_actor_id,
)
_validate_transition(disposition, payload, now=mutation_at)
if is_waiver_decision:
waiver_actor_id, waiver_actor_name = waiver_policy.require_decider(
disposition,
current_user=current_user,
tenant_id=normalized_tenant,
)
before = _state(disposition)
_apply_action(disposition, observation, payload)
_apply_action(
disposition,
observation,
payload,
actor_id=waiver_actor_id,
actor_name=waiver_actor_name,
mutation_at=mutation_at,
)
disposition.version = current_version + 1
disposition.updated_at = mutation_at
event = RiskDispositionEvent(
@@ -192,11 +224,11 @@ class RiskDispositionService:
observation_id=observation.id,
version=disposition.version,
action=payload.action,
actor_id=normalized_actor_id,
actor_name=_text(actor_name) or _text(actor_id) or "anonymous",
actor_id=waiver_actor_id,
actor_name=waiver_actor_name,
request_id=payload.request_id,
payload_fingerprint=fingerprint,
comment=payload.comment,
comment=payload.comment or payload.waiver_reason,
before_json=before,
after_json=_state(disposition),
created_at=mutation_at,
@@ -216,6 +248,14 @@ class RiskDispositionService:
payload,
actor_name=actor_name,
)
# 风险事实与审批任务队列投影必须原子可见autoflush 关闭时先显式
# 落下 observation/disposition再按最新风险状态重算开放任务。
self.db.flush()
ApprovalTaskProjectionRefreshService(self.db).refresh_claim(
tenant_id=normalized_tenant,
claim_id=str(observation.claim_id or "").strip(),
now=mutation_at,
)
self.db.commit()
self.db.refresh(disposition)
self.db.refresh(event)
@@ -226,7 +266,12 @@ class RiskDispositionService:
response=response,
legacy_feedback=legacy_feedback,
)
except (LookupError, RiskDispositionConflictError, RiskDispositionPermissionError):
except (
LookupError,
RiskDispositionConflictError,
RiskDispositionPermissionError,
RiskWaiverDecisionPermissionError,
):
self.db.rollback()
raise
except IntegrityError as error:
@@ -292,6 +337,18 @@ class RiskDispositionService:
assignee=disposition.assignee,
due_at=disposition.due_at,
resolution=disposition.resolution,
waiver_requester_id=disposition.waiver_requester_id,
waiver_requester_name=disposition.waiver_requester_name,
waiver_requested_at=disposition.waiver_requested_at,
waiver_reason=disposition.waiver_reason,
waiver_scope=disposition.waiver_scope,
waiver_expires_at=disposition.waiver_expires_at,
waiver_conditions_json=list(disposition.waiver_conditions_json or []),
waiver_decision=disposition.waiver_decision,
waiver_decider_id=disposition.waiver_decider_id,
waiver_decider_name=disposition.waiver_decider_name,
waiver_decided_at=disposition.waiver_decided_at,
waiver_decision_reason=disposition.waiver_decision_reason,
created_at=disposition.created_at,
updated_at=disposition.updated_at,
events=event_history,
@@ -382,6 +439,18 @@ class RiskDispositionService:
assignee=state.get("assignee"),
due_at=state.get("due_at"),
resolution=state.get("resolution"),
waiver_requester_id=state.get("waiver_requester_id"),
waiver_requester_name=state.get("waiver_requester_name"),
waiver_requested_at=state.get("waiver_requested_at"),
waiver_reason=state.get("waiver_reason"),
waiver_scope=state.get("waiver_scope"),
waiver_expires_at=state.get("waiver_expires_at"),
waiver_conditions_json=state.get("waiver_conditions_json") or [],
waiver_decision=state.get("waiver_decision"),
waiver_decider_id=state.get("waiver_decider_id"),
waiver_decider_name=state.get("waiver_decider_name"),
waiver_decided_at=state.get("waiver_decided_at"),
waiver_decision_reason=state.get("waiver_decision_reason"),
created_at=history[0].created_at,
updated_at=event.created_at,
events=event_reads,
@@ -468,6 +537,10 @@ def _apply_action(
disposition: RiskDisposition,
observation: RiskObservation,
payload: RiskDispositionActionCreate,
*,
actor_id: str,
actor_name: str,
mutation_at: datetime,
) -> None:
if payload.action == "confirm":
disposition.adjudication = "confirmed"
@@ -479,11 +552,48 @@ def _apply_action(
observation.status = "false_positive"
observation.feedback_status = "false_positive"
return
if payload.action == "request_waiver":
disposition.lifecycle_status = "waiver_requested"
disposition.waiver_requester_id = actor_id
disposition.waiver_requester_name = actor_name
disposition.waiver_requested_at = mutation_at
disposition.waiver_reason = payload.waiver_reason
disposition.waiver_scope = payload.waiver_scope
disposition.waiver_expires_at = payload.waiver_expires_at
disposition.waiver_conditions_json = list(payload.waiver_conditions)
disposition.waiver_decision = None
disposition.waiver_decider_id = None
disposition.waiver_decider_name = None
disposition.waiver_decided_at = None
disposition.waiver_decision_reason = None
disposition.resolution = None
if payload.assignee is not None:
disposition.assignee = payload.assignee
if payload.due_at is not None:
disposition.due_at = payload.due_at
return
if payload.action == "approve_waiver":
disposition.lifecycle_status = "waived"
disposition.waiver_decision = "approved"
disposition.waiver_decider_id = actor_id
disposition.waiver_decider_name = actor_name
disposition.waiver_decided_at = mutation_at
disposition.waiver_decision_reason = payload.comment
observation.status = "waived"
return
if payload.action == "reject_waiver":
disposition.lifecycle_status = "waiver_rejected"
disposition.waiver_decision = "rejected"
disposition.waiver_decider_id = actor_id
disposition.waiver_decider_name = actor_name
disposition.waiver_decided_at = mutation_at
disposition.waiver_decision_reason = payload.comment
observation.status = "confirmed"
return
lifecycle_by_action = {
"request_supplement": "supplement_requested",
"start_remediation": "remediation_in_progress",
"request_waiver": "waiver_requested",
"resolve": "resolved",
}
disposition.lifecycle_status = lifecycle_by_action[payload.action]
@@ -501,7 +611,21 @@ def _apply_action(
def _validate_transition(
disposition: RiskDisposition,
payload: RiskDispositionActionCreate,
*,
now: datetime,
) -> None:
if payload.action in RISK_WAIVER_DECISION_ACTIONS:
if disposition.lifecycle_status != "waiver_requested":
raise RiskDispositionConflictError("只有待决定的豁免申请才能批准或拒绝。")
if not _text(disposition.waiver_requester_id):
raise RiskDispositionConflictError(
"历史豁免申请缺少职责分离所需的申请人身份,请重新提交申请。"
)
if payload.action == "approve_waiver":
expires_at = _as_utc(disposition.waiver_expires_at)
if expires_at is None or expires_at <= _as_utc(now):
raise RiskDispositionConflictError("风险豁免申请已过期,不能批准。")
return
if disposition.lifecycle_status == "resolved":
raise RiskDispositionConflictError("已解决的风险不能再次变更裁决或处置生命周期。")
if payload.action == "confirm":
@@ -519,7 +643,17 @@ def _validate_transition(
"resolve": "resolved",
}[payload.action]
if disposition.lifecycle_status == target_lifecycle:
raise RiskDispositionConflictError("该风险已处于目标处置状态,请勿重复提交。")
if payload.action == "request_waiver" and not _text(
disposition.waiver_requester_id
):
# 0014 前的旧申请没有申请人和有效期,允许以完整协议重新提交。
pass
else:
raise RiskDispositionConflictError("该风险已处于目标处置状态,请勿重复提交。")
if payload.action == "request_waiver":
expires_at = _as_utc(payload.waiver_expires_at)
if expires_at is None or expires_at <= _as_utc(now):
raise RiskDispositionConflictError("风险豁免有效期必须晚于申请时间。")
if payload.action == "request_supplement":
return
if disposition.adjudication != "confirmed":
@@ -533,6 +667,30 @@ def _state(disposition: RiskDisposition) -> dict[str, object]:
"assignee": disposition.assignee,
"due_at": disposition.due_at.isoformat() if disposition.due_at else None,
"resolution": disposition.resolution,
"waiver_requester_id": disposition.waiver_requester_id,
"waiver_requester_name": disposition.waiver_requester_name,
"waiver_requested_at": (
disposition.waiver_requested_at.isoformat()
if disposition.waiver_requested_at
else None
),
"waiver_reason": disposition.waiver_reason,
"waiver_scope": disposition.waiver_scope,
"waiver_expires_at": (
disposition.waiver_expires_at.isoformat()
if disposition.waiver_expires_at
else None
),
"waiver_conditions_json": list(disposition.waiver_conditions_json or []),
"waiver_decision": disposition.waiver_decision,
"waiver_decider_id": disposition.waiver_decider_id,
"waiver_decider_name": disposition.waiver_decider_name,
"waiver_decided_at": (
disposition.waiver_decided_at.isoformat()
if disposition.waiver_decided_at
else None
),
"waiver_decision_reason": disposition.waiver_decision_reason,
"version": disposition.version,
}
@@ -581,3 +739,11 @@ def _tenant(value: str) -> str:
def _text(value: object) -> str:
return str(value or "").strip()
def _as_utc(value: datetime | None) -> datetime | None:
if value is None:
return None
if value.tzinfo is None:
return value.replace(tzinfo=UTC)
return value.astimezone(UTC)