feat(approval): add task workflow and waiver decisions
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user