431 lines
15 KiB
Python
431 lines
15 KiB
Python
|
|
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()
|