feat(approval): add safe risk disposition workflow
This commit is contained in:
430
server/src/app/services/risk_dispositions.py
Normal file
430
server/src/app/services/risk_dispositions.py
Normal file
@@ -0,0 +1,430 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import CurrentUserContext
|
||||
from app.core.logging import get_logger
|
||||
from app.models.financial_record import ExpenseClaim
|
||||
from app.models.risk_disposition import RiskDisposition, RiskDispositionEvent
|
||||
from app.models.risk_observation import RiskObservation, RiskObservationFeedback
|
||||
from app.schemas.risk_disposition import RiskDispositionActionCreate
|
||||
from app.services.expense_claim_tenant_scope import ExpenseClaimTenantScopeMixin
|
||||
from app.services.risk_observation_access_policy import RiskObservationAccessPolicy
|
||||
|
||||
logger = get_logger("app.services.risk_dispositions")
|
||||
|
||||
|
||||
class RiskDispositionConflictError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class RiskDispositionVersionConflictError(RiskDispositionConflictError):
|
||||
def __init__(self, current_version: int) -> None:
|
||||
self.current_version = current_version
|
||||
super().__init__(
|
||||
f"Risk disposition version conflict; current version is {current_version}."
|
||||
)
|
||||
|
||||
|
||||
class RiskDispositionIdempotencyConflictError(RiskDispositionConflictError):
|
||||
pass
|
||||
|
||||
|
||||
class RiskDispositionPermissionError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class RiskDispositionMutation:
|
||||
disposition: RiskDisposition
|
||||
event: RiskDispositionEvent
|
||||
replayed: bool
|
||||
legacy_feedback: RiskObservationFeedback | None = None
|
||||
|
||||
|
||||
class RiskDispositionService:
|
||||
def __init__(self, db: Session) -> None:
|
||||
self.db = db
|
||||
|
||||
def get_disposition(
|
||||
self,
|
||||
observation_id: str,
|
||||
*,
|
||||
tenant_id: str,
|
||||
) -> RiskDisposition | None:
|
||||
return self.db.scalar(
|
||||
select(RiskDisposition).where(
|
||||
RiskDisposition.tenant_id == _tenant(tenant_id),
|
||||
RiskDisposition.observation_id == observation_id,
|
||||
)
|
||||
)
|
||||
|
||||
def get_current_version(self, observation_id: str, *, tenant_id: str) -> int:
|
||||
disposition = self.get_disposition(observation_id, tenant_id=tenant_id)
|
||||
return disposition.version if disposition is not None else 0
|
||||
|
||||
def execute_action(
|
||||
self,
|
||||
observation_key_or_id: str,
|
||||
payload: RiskDispositionActionCreate,
|
||||
*,
|
||||
tenant_id: str,
|
||||
actor_id: str,
|
||||
actor_name: str,
|
||||
current_user: CurrentUserContext | None = None,
|
||||
) -> RiskDispositionMutation:
|
||||
normalized_tenant = _tenant(tenant_id)
|
||||
normalized_actor_id = _text(actor_id) or "anonymous"
|
||||
observation_locator = self.db.execute(
|
||||
select(RiskObservation.id, RiskObservation.claim_id).where(
|
||||
RiskObservation.tenant_id == normalized_tenant,
|
||||
(
|
||||
(RiskObservation.id == observation_key_or_id)
|
||||
| (RiskObservation.observation_key == observation_key_or_id)
|
||||
),
|
||||
)
|
||||
).one_or_none()
|
||||
if observation_locator is None:
|
||||
raise LookupError("Risk observation not found.")
|
||||
observation_id, located_claim_id = observation_locator
|
||||
fingerprint = _payload_fingerprint(
|
||||
payload,
|
||||
observation_id=observation_id,
|
||||
actor_id=normalized_actor_id,
|
||||
)
|
||||
replay = self._find_replay(
|
||||
tenant_id=normalized_tenant,
|
||||
request_id=payload.request_id,
|
||||
fingerprint=fingerprint,
|
||||
)
|
||||
if replay is not None:
|
||||
return replay
|
||||
|
||||
try:
|
||||
locked_claim = self._lock_claim(
|
||||
str(located_claim_id or "").strip(),
|
||||
tenant_id=normalized_tenant,
|
||||
)
|
||||
observation = self.db.scalar(
|
||||
select(RiskObservation)
|
||||
.where(
|
||||
RiskObservation.tenant_id == normalized_tenant,
|
||||
RiskObservation.id == observation_id,
|
||||
)
|
||||
.with_for_update()
|
||||
)
|
||||
if observation is None:
|
||||
raise LookupError("Risk observation not found.")
|
||||
if str(observation.claim_id or "").strip() != str(located_claim_id or "").strip():
|
||||
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,
|
||||
):
|
||||
raise RiskDispositionPermissionError("当前用户已不再是该单据的有效审批人。")
|
||||
|
||||
disposition = self.db.scalar(
|
||||
select(RiskDisposition)
|
||||
.where(
|
||||
RiskDisposition.tenant_id == normalized_tenant,
|
||||
RiskDisposition.observation_id == observation.id,
|
||||
)
|
||||
.with_for_update()
|
||||
)
|
||||
current_version = disposition.version if disposition is not None else 0
|
||||
if current_version != payload.expected_version:
|
||||
raise RiskDispositionVersionConflictError(current_version)
|
||||
|
||||
if disposition is None:
|
||||
disposition = RiskDisposition(
|
||||
id=str(uuid.uuid4()),
|
||||
tenant_id=normalized_tenant,
|
||||
observation_id=observation.id,
|
||||
adjudication=_initial_adjudication(observation),
|
||||
lifecycle_status=_initial_lifecycle_status(observation),
|
||||
version=0,
|
||||
)
|
||||
self.db.add(disposition)
|
||||
|
||||
_validate_transition(disposition, payload)
|
||||
before = _state(disposition)
|
||||
_apply_action(disposition, observation, payload)
|
||||
disposition.version = current_version + 1
|
||||
disposition.updated_at = datetime.now(UTC)
|
||||
event = RiskDispositionEvent(
|
||||
id=str(uuid.uuid4()),
|
||||
tenant_id=normalized_tenant,
|
||||
disposition_id=disposition.id,
|
||||
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",
|
||||
request_id=payload.request_id,
|
||||
payload_fingerprint=fingerprint,
|
||||
comment=payload.comment,
|
||||
before_json=before,
|
||||
after_json=_state(disposition),
|
||||
)
|
||||
self.db.add(event)
|
||||
legacy_feedback = self._append_safe_feedback(
|
||||
observation,
|
||||
event,
|
||||
payload,
|
||||
actor_name=actor_name,
|
||||
)
|
||||
self.db.commit()
|
||||
self.db.refresh(disposition)
|
||||
self.db.refresh(event)
|
||||
if legacy_feedback is not None:
|
||||
self.db.refresh(legacy_feedback)
|
||||
self._ingest_feedback_sample(observation, legacy_feedback)
|
||||
return RiskDispositionMutation(
|
||||
disposition=disposition,
|
||||
event=event,
|
||||
replayed=False,
|
||||
legacy_feedback=legacy_feedback,
|
||||
)
|
||||
except (LookupError, RiskDispositionConflictError, RiskDispositionPermissionError):
|
||||
self.db.rollback()
|
||||
raise
|
||||
except IntegrityError as error:
|
||||
self.db.rollback()
|
||||
replay = self._find_replay(
|
||||
tenant_id=normalized_tenant,
|
||||
request_id=payload.request_id,
|
||||
fingerprint=fingerprint,
|
||||
)
|
||||
if replay is not None:
|
||||
return replay
|
||||
raise RiskDispositionConflictError(
|
||||
"Risk disposition was changed concurrently; reload and retry."
|
||||
) from error
|
||||
|
||||
def _lock_claim(
|
||||
self,
|
||||
claim_id: str,
|
||||
*,
|
||||
tenant_id: str,
|
||||
) -> ExpenseClaim | None:
|
||||
if not claim_id:
|
||||
return None
|
||||
statement = select(ExpenseClaim).where(
|
||||
ExpenseClaim.id == claim_id,
|
||||
ExpenseClaimTenantScopeMixin.build_claim_tenant_condition(tenant_id),
|
||||
)
|
||||
bind = self.db.get_bind()
|
||||
if bind is not None and bind.dialect.name == "postgresql":
|
||||
statement = statement.with_for_update()
|
||||
return self.db.scalar(statement.execution_options(populate_existing=True))
|
||||
|
||||
def _find_replay(
|
||||
self,
|
||||
*,
|
||||
tenant_id: str,
|
||||
request_id: str,
|
||||
fingerprint: str,
|
||||
) -> RiskDispositionMutation | None:
|
||||
event = self.db.scalar(
|
||||
select(RiskDispositionEvent).where(
|
||||
RiskDispositionEvent.tenant_id == tenant_id,
|
||||
RiskDispositionEvent.request_id == request_id,
|
||||
)
|
||||
)
|
||||
if event is None:
|
||||
return None
|
||||
if event.payload_fingerprint != fingerprint:
|
||||
raise RiskDispositionIdempotencyConflictError("request_id 已被不同的风险处置内容使用。")
|
||||
disposition = self.db.get(RiskDisposition, event.disposition_id)
|
||||
if disposition is None:
|
||||
raise RiskDispositionConflictError("Risk disposition replay target is missing.")
|
||||
feedback = self.db.scalar(
|
||||
select(RiskObservationFeedback).where(
|
||||
RiskObservationFeedback.observation_id == event.observation_id,
|
||||
RiskObservationFeedback.action == f"disposition:{event.id}",
|
||||
)
|
||||
)
|
||||
return RiskDispositionMutation(
|
||||
disposition=disposition,
|
||||
event=event,
|
||||
replayed=True,
|
||||
legacy_feedback=feedback,
|
||||
)
|
||||
|
||||
def _append_safe_feedback(
|
||||
self,
|
||||
observation: RiskObservation,
|
||||
event: RiskDispositionEvent,
|
||||
payload: RiskDispositionActionCreate,
|
||||
*,
|
||||
actor_name: str,
|
||||
) -> RiskObservationFeedback | None:
|
||||
if payload.action not in {"confirm", "false_positive"}:
|
||||
return None
|
||||
feedback = RiskObservationFeedback(
|
||||
observation_id=observation.id,
|
||||
feedback_type=payload.action,
|
||||
action=f"disposition:{event.id}",
|
||||
actor=_text(actor_name) or "anonymous",
|
||||
comment=payload.comment,
|
||||
payload_json={
|
||||
"decision": payload.action,
|
||||
"source": "typed_risk_disposition",
|
||||
},
|
||||
)
|
||||
self.db.add(feedback)
|
||||
return feedback
|
||||
|
||||
def _ingest_feedback_sample(
|
||||
self,
|
||||
observation: RiskObservation,
|
||||
feedback: RiskObservationFeedback,
|
||||
) -> None:
|
||||
enabled = os.environ.get("FEW_SHOT_INJECTION_ENABLED", "true")
|
||||
if enabled.strip().lower() in {"0", "false", "no"}:
|
||||
return
|
||||
try:
|
||||
from app.services.few_shot_ingestion import FewShotIngestionService
|
||||
|
||||
FewShotIngestionService(self.db).ingest_observation_feedback(
|
||||
observation,
|
||||
feedback,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"few-shot ingestion failed for disposition event %s",
|
||||
feedback.action,
|
||||
)
|
||||
|
||||
|
||||
def _apply_action(
|
||||
disposition: RiskDisposition,
|
||||
observation: RiskObservation,
|
||||
payload: RiskDispositionActionCreate,
|
||||
) -> None:
|
||||
if payload.action == "confirm":
|
||||
disposition.adjudication = "confirmed"
|
||||
observation.status = "confirmed"
|
||||
observation.feedback_status = "confirmed"
|
||||
return
|
||||
if payload.action == "false_positive":
|
||||
disposition.adjudication = "false_positive"
|
||||
observation.status = "false_positive"
|
||||
observation.feedback_status = "false_positive"
|
||||
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]
|
||||
if payload.assignee is not None:
|
||||
disposition.assignee = payload.assignee
|
||||
if payload.due_at is not None:
|
||||
disposition.due_at = payload.due_at
|
||||
if payload.action != "resolve":
|
||||
disposition.resolution = None
|
||||
else:
|
||||
disposition.resolution = payload.resolution
|
||||
observation.status = "resolved"
|
||||
|
||||
|
||||
def _validate_transition(
|
||||
disposition: RiskDisposition,
|
||||
payload: RiskDispositionActionCreate,
|
||||
) -> None:
|
||||
if disposition.lifecycle_status == "resolved":
|
||||
raise RiskDispositionConflictError("已解决的风险不能再次变更裁决或处置生命周期。")
|
||||
if payload.action == "confirm":
|
||||
if disposition.adjudication == "confirmed":
|
||||
raise RiskDispositionConflictError("该风险已经确认成立,请勿重复提交。")
|
||||
return
|
||||
if payload.action == "false_positive":
|
||||
if disposition.adjudication == "false_positive":
|
||||
raise RiskDispositionConflictError("该风险已经标记为误报,请勿重复提交。")
|
||||
return
|
||||
target_lifecycle = {
|
||||
"request_supplement": "supplement_requested",
|
||||
"start_remediation": "remediation_in_progress",
|
||||
"request_waiver": "waiver_requested",
|
||||
"resolve": "resolved",
|
||||
}[payload.action]
|
||||
if disposition.lifecycle_status == target_lifecycle:
|
||||
raise RiskDispositionConflictError("该风险已处于目标处置状态,请勿重复提交。")
|
||||
if payload.action == "request_supplement":
|
||||
return
|
||||
if disposition.adjudication != "confirmed":
|
||||
raise RiskDispositionConflictError("风险必须先确认成立,才能启动整改、申请豁免或标记解决。")
|
||||
|
||||
|
||||
def _state(disposition: RiskDisposition) -> dict[str, object]:
|
||||
return {
|
||||
"adjudication": disposition.adjudication,
|
||||
"lifecycle_status": disposition.lifecycle_status,
|
||||
"assignee": disposition.assignee,
|
||||
"due_at": disposition.due_at.isoformat() if disposition.due_at else None,
|
||||
"resolution": disposition.resolution,
|
||||
"version": disposition.version,
|
||||
}
|
||||
|
||||
|
||||
def _initial_adjudication(observation: RiskObservation) -> str:
|
||||
values = {
|
||||
_text(observation.feedback_status).lower(),
|
||||
_text(observation.status).lower(),
|
||||
}
|
||||
if "false_positive" in values:
|
||||
return "false_positive"
|
||||
if "confirmed" in values:
|
||||
return "confirmed"
|
||||
return "unreviewed"
|
||||
|
||||
|
||||
def _initial_lifecycle_status(observation: RiskObservation) -> str:
|
||||
return "resolved" if _text(observation.status).lower() == "resolved" else "open"
|
||||
|
||||
|
||||
def _payload_fingerprint(
|
||||
payload: RiskDispositionActionCreate,
|
||||
*,
|
||||
observation_id: str,
|
||||
actor_id: str,
|
||||
) -> str:
|
||||
canonical_payload = payload.model_dump(
|
||||
mode="json",
|
||||
exclude={"request_id"},
|
||||
exclude_none=False,
|
||||
)
|
||||
canonical_payload["observation_id"] = observation_id
|
||||
canonical_payload["actor_id"] = actor_id
|
||||
canonical = json.dumps(
|
||||
canonical_payload,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
)
|
||||
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _tenant(value: str) -> str:
|
||||
return _text(value) or "default"
|
||||
|
||||
|
||||
def _text(value: object) -> str:
|
||||
return str(value or "").strip()
|
||||
Reference in New Issue
Block a user