fix(approval): replay immutable action responses
This commit is contained in:
@@ -199,11 +199,7 @@ def execute_risk_disposition_action(
|
||||
observation_key_or_id,
|
||||
payload,
|
||||
)
|
||||
return RiskDispositionMutationRead(
|
||||
disposition=mutation.disposition,
|
||||
event=mutation.event,
|
||||
replayed=mutation.replayed,
|
||||
)
|
||||
return mutation.response
|
||||
|
||||
|
||||
@router.post(
|
||||
|
||||
@@ -170,8 +170,29 @@ MIGRATION_OWNED_TABLES_BY_REVISION: dict[str, frozenset[str]] = {
|
||||
"approval_action_ledgers",
|
||||
}
|
||||
),
|
||||
"20260716_0012": frozenset(
|
||||
{
|
||||
"expense_cases",
|
||||
"expense_case_links",
|
||||
"business_events",
|
||||
"auth_sessions",
|
||||
"attachment_association_jobs",
|
||||
"ai_application_preview_decisions",
|
||||
"ai_decisions",
|
||||
"ai_decision_feedback",
|
||||
"memory_entries",
|
||||
"memory_evidence_links",
|
||||
"risk_observations",
|
||||
"risk_observation_feedback",
|
||||
"risk_dispositions",
|
||||
"risk_disposition_events",
|
||||
"few_shot_samples",
|
||||
"workflow_outcomes",
|
||||
"approval_action_ledgers",
|
||||
}
|
||||
),
|
||||
}
|
||||
if MIGRATION_OWNED_TABLES_BY_REVISION["20260716_0011"] != MIGRATION_OWNED_TABLES:
|
||||
if MIGRATION_OWNED_TABLES_BY_REVISION["20260716_0012"] != MIGRATION_OWNED_TABLES:
|
||||
raise RuntimeError("latest Alembic revision must own the centralized migration table set")
|
||||
|
||||
# 0008 之前这三张表由旧 bootstrap / 风险服务按需创建。迁移前置检查允许
|
||||
@@ -244,6 +265,7 @@ def _validate_connection(connection: Connection) -> MigrationPreflightState:
|
||||
"20260716_0009",
|
||||
"20260716_0010",
|
||||
"20260716_0011",
|
||||
"20260716_0012",
|
||||
}
|
||||
else frozenset()
|
||||
)
|
||||
|
||||
@@ -165,6 +165,7 @@ class RiskDispositionEvent(Base):
|
||||
comment: Mapped[str | None] = mapped_column(Text(), nullable=True)
|
||||
before_json: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False, default=dict)
|
||||
after_json: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False, default=dict)
|
||||
response_json: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
|
||||
@@ -72,7 +72,6 @@ class ApprovalActionProtocol:
|
||||
expected_approval_stage: str | None,
|
||||
payload: Mapping[str, Any],
|
||||
claim_loader: Callable[[], ExpenseClaim | None],
|
||||
replay_claim_loader: Callable[[], ExpenseClaim | None],
|
||||
) -> ApprovalActionStart:
|
||||
tenant_id = ExpenseClaimTenantScopeMixin.normalize_tenant_id(current_user.tenant_id)
|
||||
actor_id = self._normalize_actor_id(current_user.username)
|
||||
@@ -101,7 +100,7 @@ class ApprovalActionProtocol:
|
||||
if existing.completed_at is None:
|
||||
raise ApprovalActionConflictError("该审批动作仍在处理中,请稍后重试。")
|
||||
return ApprovalActionStart(
|
||||
claim=replay_claim_loader(),
|
||||
claim=None,
|
||||
ledger=existing,
|
||||
request_id=request_id,
|
||||
replayed=True,
|
||||
|
||||
@@ -3,12 +3,17 @@ from __future__ import annotations
|
||||
from collections.abc import Callable, Mapping
|
||||
from typing import Any
|
||||
|
||||
from pydantic import ValidationError
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.api.deps import CurrentUserContext
|
||||
from app.models.approval_action import ApprovalActionLedger
|
||||
from app.models.financial_record import ExpenseClaim
|
||||
from app.services.approval_action_protocol import ApprovalActionProtocol
|
||||
from app.schemas.reimbursement import ExpenseClaimRead
|
||||
from app.services.approval_action_protocol import (
|
||||
ApprovalActionConflictError,
|
||||
ApprovalActionProtocol,
|
||||
)
|
||||
from app.services.expense_claim_tenant_scope import ExpenseClaimTenantScopeMixin
|
||||
|
||||
ClaimActionExecutor = Callable[
|
||||
@@ -29,7 +34,7 @@ class ExpenseClaimActionProtocolMixin:
|
||||
expected_approval_stage: str | None,
|
||||
payload: Mapping[str, Any],
|
||||
executor: ClaimActionExecutor,
|
||||
) -> ExpenseClaim | None:
|
||||
) -> ExpenseClaim | ExpenseClaimRead | None:
|
||||
protocol = ApprovalActionProtocol(self.db)
|
||||
tenant_id = ExpenseClaimTenantScopeMixin.normalize_tenant_id(current_user.tenant_id)
|
||||
actor_id = str(current_user.username or "").strip().casefold()
|
||||
@@ -51,18 +56,16 @@ class ExpenseClaimActionProtocolMixin:
|
||||
claim_id,
|
||||
current_user,
|
||||
),
|
||||
replay_claim_loader=lambda: self._load_claim_for_replay(
|
||||
claim_id,
|
||||
current_user,
|
||||
),
|
||||
)
|
||||
if started.replayed:
|
||||
if started.ledger is None: # pragma: no cover - defensive invariant
|
||||
raise RuntimeError("审批动作重放账本缺失。")
|
||||
response = self._response_from_ledger(started.ledger)
|
||||
self.db.commit()
|
||||
return response
|
||||
if started.claim is None:
|
||||
self.db.rollback()
|
||||
return None
|
||||
if started.replayed:
|
||||
self.db.commit()
|
||||
self.db.refresh(started.claim)
|
||||
return self._access_policy.attach_approval_snapshot(started.claim)
|
||||
if started.ledger is None: # pragma: no cover - defensive invariant
|
||||
raise RuntimeError("审批动作账本初始化失败。")
|
||||
|
||||
@@ -71,14 +74,16 @@ class ExpenseClaimActionProtocolMixin:
|
||||
started.ledger,
|
||||
normalized_request_id,
|
||||
)
|
||||
self.db.flush()
|
||||
self.db.refresh(claim)
|
||||
response = ExpenseClaimRead.model_validate(
|
||||
self._access_policy.attach_approval_snapshot(claim)
|
||||
)
|
||||
response_json = response.model_dump(mode="json")
|
||||
protocol.complete(
|
||||
started.ledger,
|
||||
claim,
|
||||
response_json={
|
||||
"claim_id": claim.id,
|
||||
"status": str(claim.status or "").strip(),
|
||||
"approval_stage": str(claim.approval_stage or "").strip(),
|
||||
},
|
||||
response_json=response_json,
|
||||
)
|
||||
self.db.commit()
|
||||
self.db.refresh(claim)
|
||||
@@ -108,13 +113,22 @@ class ExpenseClaimActionProtocolMixin:
|
||||
self._repair_duplicate_budget_approval_stage(claim)
|
||||
return claim
|
||||
|
||||
def _load_claim_for_replay(
|
||||
self,
|
||||
claim_id: str,
|
||||
current_user: CurrentUserContext,
|
||||
) -> ExpenseClaim | None:
|
||||
stmt = select(ExpenseClaim).where(
|
||||
ExpenseClaim.id == claim_id,
|
||||
ExpenseClaimTenantScopeMixin.build_claim_tenant_condition(current_user.tenant_id),
|
||||
)
|
||||
return self.db.scalar(stmt)
|
||||
@staticmethod
|
||||
def _response_from_ledger(ledger: ApprovalActionLedger) -> ExpenseClaimRead:
|
||||
try:
|
||||
response = ExpenseClaimRead.model_validate(ledger.response_json)
|
||||
except (TypeError, ValueError, ValidationError) as error:
|
||||
raise ApprovalActionConflictError(
|
||||
"该审批动作缺少可验证的原始响应快照,请刷新单据后使用新的 request_id。"
|
||||
) from error
|
||||
if str(response.id) != str(ledger.claim_id):
|
||||
raise ApprovalActionConflictError("审批动作原始响应快照与单据不一致。")
|
||||
if str(response.status or "").strip().lower() != str(
|
||||
ledger.result_status or ""
|
||||
).strip().lower():
|
||||
raise ApprovalActionConflictError("审批动作原始响应快照状态校验失败。")
|
||||
if str(response.approval_stage or "").strip() != str(
|
||||
ledger.result_approval_stage or ""
|
||||
).strip():
|
||||
raise ApprovalActionConflictError("审批动作原始响应快照节点校验失败。")
|
||||
return response
|
||||
|
||||
@@ -6,6 +6,7 @@ from datetime import UTC, datetime
|
||||
from app.api.deps import CurrentUserContext
|
||||
from app.models.approval_action import ApprovalActionLedger
|
||||
from app.models.financial_record import ExpenseClaim
|
||||
from app.schemas.reimbursement import ExpenseClaimRead
|
||||
from app.services.expense_claim_constants import RETURN_REASON_OPTIONS
|
||||
|
||||
|
||||
@@ -20,7 +21,7 @@ class ExpenseClaimReturnFlowMixin:
|
||||
request_id: str | None = None,
|
||||
expected_status: str | None = None,
|
||||
expected_approval_stage: str | None = None,
|
||||
) -> ExpenseClaim | None:
|
||||
) -> ExpenseClaim | ExpenseClaimRead | None:
|
||||
normalized_reason = str(reason or "").strip()
|
||||
reason_code_payload = self._normalize_return_reason_code_payload(reason_codes)
|
||||
normalized_codes = [
|
||||
|
||||
@@ -8,11 +8,16 @@ from sqlalchemy.orm import Session
|
||||
from app.models.financial_record import ExpenseClaim
|
||||
from app.models.risk_disposition import RiskDisposition
|
||||
from app.models.risk_observation import RiskObservation
|
||||
from app.services.document_numbering import is_application_claim_no
|
||||
from app.services.expense_claim_risk_flags import (
|
||||
claim_risk_flag_observation_key,
|
||||
claim_risk_flag_severity,
|
||||
is_blocking_claim_risk_flag,
|
||||
)
|
||||
from app.services.expense_claim_risk_stage import (
|
||||
risk_business_stage_for_claim,
|
||||
risk_flag_business_stage,
|
||||
)
|
||||
from app.services.expense_claim_tenant_scope import ExpenseClaimTenantScopeMixin
|
||||
|
||||
_BLOCKING_LEVELS = {"high", "critical", "danger"}
|
||||
@@ -52,6 +57,7 @@ class ExpenseClaimRiskGate:
|
||||
tenant_id: str | None,
|
||||
) -> list[BlockingRiskObservation]:
|
||||
normalized_tenant = ExpenseClaimTenantScopeMixin.normalize_tenant_id(tenant_id)
|
||||
claim_business_stage = self._claim_business_stage(claim)
|
||||
rows = self.db.execute(
|
||||
select(RiskObservation, RiskDisposition)
|
||||
.outerjoin(
|
||||
@@ -75,6 +81,12 @@ class ExpenseClaimRiskGate:
|
||||
materialized_keys: set[str] = set()
|
||||
for observation, disposition in rows:
|
||||
materialized_keys.add(str(observation.observation_key or "").strip())
|
||||
observation_stage = risk_flag_business_stage(
|
||||
{"control_stage": observation.control_stage},
|
||||
default="",
|
||||
)
|
||||
if observation_stage and observation_stage != claim_business_stage:
|
||||
continue
|
||||
level = str(observation.risk_level or "").strip().lower()
|
||||
if level not in _BLOCKING_LEVELS:
|
||||
continue
|
||||
@@ -100,6 +112,9 @@ class ExpenseClaimRiskGate:
|
||||
for index, flag in enumerate(list(claim.risk_flags_json or [])):
|
||||
if not isinstance(flag, dict) or not is_blocking_claim_risk_flag(flag):
|
||||
continue
|
||||
flag_stage = risk_flag_business_stage(flag, default="")
|
||||
if flag_stage and flag_stage != claim_business_stage:
|
||||
continue
|
||||
observation_key = claim_risk_flag_observation_key(flag, claim_id=claim.id)
|
||||
if observation_key and observation_key in materialized_keys:
|
||||
# 完整物化后的处置投影优先;仅对没有 Observation 的高风险兜底。
|
||||
@@ -124,6 +139,16 @@ class ExpenseClaimRiskGate:
|
||||
)
|
||||
return blockers
|
||||
|
||||
@staticmethod
|
||||
def _claim_business_stage(claim: ExpenseClaim) -> str:
|
||||
expense_type = str(claim.expense_type or "").strip().lower()
|
||||
is_application = (
|
||||
is_application_claim_no(str(claim.claim_no or "").strip())
|
||||
or expense_type == "application"
|
||||
or expense_type.endswith("_application")
|
||||
)
|
||||
return risk_business_stage_for_claim(is_application_claim=is_application)
|
||||
|
||||
def ensure_approvable(
|
||||
self,
|
||||
claim: ExpenseClaim,
|
||||
|
||||
@@ -27,6 +27,7 @@ SUPPORTED_RISK_VISIBILITY_SCOPES = {
|
||||
SUPPORTED_RISK_ACTIONABILITIES = {
|
||||
"fixable_by_submitter",
|
||||
"review_decision",
|
||||
"route_review",
|
||||
"budget_governance",
|
||||
"finance_check",
|
||||
"system_trace",
|
||||
|
||||
@@ -6,7 +6,9 @@ import os
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import ValidationError
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -16,7 +18,12 @@ 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.schemas.risk_disposition import (
|
||||
RiskDispositionActionCreate,
|
||||
RiskDispositionEventRead,
|
||||
RiskDispositionMutationRead,
|
||||
RiskDispositionRead,
|
||||
)
|
||||
from app.services.expense_claim_tenant_scope import ExpenseClaimTenantScopeMixin
|
||||
from app.services.risk_observation_access_policy import RiskObservationAccessPolicy
|
||||
|
||||
@@ -45,11 +52,21 @@ class RiskDispositionPermissionError(RuntimeError):
|
||||
|
||||
@dataclass(slots=True)
|
||||
class RiskDispositionMutation:
|
||||
disposition: RiskDisposition
|
||||
event: RiskDispositionEvent
|
||||
replayed: bool
|
||||
response: RiskDispositionMutationRead
|
||||
legacy_feedback: RiskObservationFeedback | None = None
|
||||
|
||||
@property
|
||||
def disposition(self) -> RiskDispositionRead:
|
||||
return self.response.disposition
|
||||
|
||||
@property
|
||||
def event(self) -> RiskDispositionEventRead:
|
||||
return self.response.event
|
||||
|
||||
@property
|
||||
def replayed(self) -> bool:
|
||||
return self.response.replayed
|
||||
|
||||
|
||||
class RiskDispositionService:
|
||||
def __init__(self, db: Session) -> None:
|
||||
@@ -149,6 +166,7 @@ class RiskDispositionService:
|
||||
if current_version != payload.expected_version:
|
||||
raise RiskDispositionVersionConflictError(current_version)
|
||||
|
||||
mutation_at = datetime.now(UTC)
|
||||
if disposition is None:
|
||||
disposition = RiskDisposition(
|
||||
id=str(uuid.uuid4()),
|
||||
@@ -157,6 +175,8 @@ class RiskDispositionService:
|
||||
adjudication=_initial_adjudication(observation),
|
||||
lifecycle_status=_initial_lifecycle_status(observation),
|
||||
version=0,
|
||||
created_at=mutation_at,
|
||||
updated_at=mutation_at,
|
||||
)
|
||||
self.db.add(disposition)
|
||||
|
||||
@@ -164,7 +184,7 @@ class RiskDispositionService:
|
||||
before = _state(disposition)
|
||||
_apply_action(disposition, observation, payload)
|
||||
disposition.version = current_version + 1
|
||||
disposition.updated_at = datetime.now(UTC)
|
||||
disposition.updated_at = mutation_at
|
||||
event = RiskDispositionEvent(
|
||||
id=str(uuid.uuid4()),
|
||||
tenant_id=normalized_tenant,
|
||||
@@ -179,7 +199,16 @@ class RiskDispositionService:
|
||||
comment=payload.comment,
|
||||
before_json=before,
|
||||
after_json=_state(disposition),
|
||||
created_at=mutation_at,
|
||||
)
|
||||
response = self._build_response_snapshot(
|
||||
disposition,
|
||||
event,
|
||||
prior_version=current_version,
|
||||
)
|
||||
# PostgreSQL 会拒绝 append-only 事件的后续 UPDATE,因此完整响应
|
||||
# 必须和事件在同一条 INSERT 中原子写入。
|
||||
event.response_json = response.model_dump(mode="json")
|
||||
self.db.add(event)
|
||||
legacy_feedback = self._append_safe_feedback(
|
||||
observation,
|
||||
@@ -194,9 +223,7 @@ class RiskDispositionService:
|
||||
self.db.refresh(legacy_feedback)
|
||||
self._ingest_feedback_sample(observation, legacy_feedback)
|
||||
return RiskDispositionMutation(
|
||||
disposition=disposition,
|
||||
event=event,
|
||||
replayed=False,
|
||||
response=response,
|
||||
legacy_feedback=legacy_feedback,
|
||||
)
|
||||
except (LookupError, RiskDispositionConflictError, RiskDispositionPermissionError):
|
||||
@@ -232,6 +259,49 @@ class RiskDispositionService:
|
||||
statement = statement.with_for_update()
|
||||
return self.db.scalar(statement.execution_options(populate_existing=True))
|
||||
|
||||
def _build_response_snapshot(
|
||||
self,
|
||||
disposition: RiskDisposition,
|
||||
event: RiskDispositionEvent,
|
||||
*,
|
||||
prior_version: int,
|
||||
) -> RiskDispositionMutationRead:
|
||||
prior_events = list(
|
||||
self.db.scalars(
|
||||
select(RiskDispositionEvent)
|
||||
.where(
|
||||
RiskDispositionEvent.tenant_id == disposition.tenant_id,
|
||||
RiskDispositionEvent.disposition_id == disposition.id,
|
||||
RiskDispositionEvent.version <= prior_version,
|
||||
)
|
||||
.order_by(RiskDispositionEvent.version.asc())
|
||||
).all()
|
||||
)
|
||||
event_read = RiskDispositionEventRead.model_validate(event)
|
||||
event_history = [
|
||||
*[RiskDispositionEventRead.model_validate(item) for item in prior_events],
|
||||
event_read,
|
||||
]
|
||||
disposition_read = RiskDispositionRead(
|
||||
id=disposition.id,
|
||||
tenant_id=disposition.tenant_id,
|
||||
observation_id=disposition.observation_id,
|
||||
adjudication=disposition.adjudication,
|
||||
lifecycle_status=disposition.lifecycle_status,
|
||||
version=disposition.version,
|
||||
assignee=disposition.assignee,
|
||||
due_at=disposition.due_at,
|
||||
resolution=disposition.resolution,
|
||||
created_at=disposition.created_at,
|
||||
updated_at=disposition.updated_at,
|
||||
events=event_history,
|
||||
)
|
||||
return RiskDispositionMutationRead(
|
||||
disposition=disposition_read,
|
||||
event=event_read,
|
||||
replayed=False,
|
||||
)
|
||||
|
||||
def _find_replay(
|
||||
self,
|
||||
*,
|
||||
@@ -249,9 +319,7 @@ class RiskDispositionService:
|
||||
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.")
|
||||
response = self._event_response(event)
|
||||
feedback = self.db.scalar(
|
||||
select(RiskObservationFeedback).where(
|
||||
RiskObservationFeedback.observation_id == event.observation_id,
|
||||
@@ -259,12 +327,97 @@ class RiskDispositionService:
|
||||
)
|
||||
)
|
||||
return RiskDispositionMutation(
|
||||
disposition=disposition,
|
||||
event=event,
|
||||
replayed=True,
|
||||
response=response.model_copy(update={"replayed": True}),
|
||||
legacy_feedback=feedback,
|
||||
)
|
||||
|
||||
def _event_response(
|
||||
self,
|
||||
event: RiskDispositionEvent,
|
||||
) -> RiskDispositionMutationRead:
|
||||
if isinstance(event.response_json, dict) and event.response_json:
|
||||
try:
|
||||
response = RiskDispositionMutationRead.model_validate(event.response_json)
|
||||
except ValidationError as error:
|
||||
raise RiskDispositionConflictError(
|
||||
"Stored risk disposition response snapshot is invalid."
|
||||
) from error
|
||||
self._validate_snapshot_identity(event, response)
|
||||
return response
|
||||
return self._rebuild_legacy_event_response(event)
|
||||
|
||||
def _rebuild_legacy_event_response(
|
||||
self,
|
||||
event: RiskDispositionEvent,
|
||||
) -> RiskDispositionMutationRead:
|
||||
# 0012 之前的事件没有响应快照。只读取目标版本及以前的 append-only
|
||||
# 事件,并使用目标事件 after_json 重建;禁止读取当前 disposition。
|
||||
history = list(
|
||||
self.db.scalars(
|
||||
select(RiskDispositionEvent)
|
||||
.where(
|
||||
RiskDispositionEvent.tenant_id == event.tenant_id,
|
||||
RiskDispositionEvent.disposition_id == event.disposition_id,
|
||||
RiskDispositionEvent.version <= event.version,
|
||||
)
|
||||
.order_by(RiskDispositionEvent.version.asc())
|
||||
).all()
|
||||
)
|
||||
target = next((item for item in history if item.id == event.id), None)
|
||||
if target is None:
|
||||
raise RiskDispositionConflictError(
|
||||
"Legacy risk disposition replay event is missing from its audit chain."
|
||||
)
|
||||
state: dict[str, Any] = dict(event.after_json or {})
|
||||
event_reads = [RiskDispositionEventRead.model_validate(item) for item in history]
|
||||
target_read = RiskDispositionEventRead.model_validate(target)
|
||||
try:
|
||||
disposition = RiskDispositionRead(
|
||||
id=event.disposition_id,
|
||||
tenant_id=event.tenant_id,
|
||||
observation_id=event.observation_id,
|
||||
adjudication=state.get("adjudication"),
|
||||
lifecycle_status=state.get("lifecycle_status"),
|
||||
version=event.version,
|
||||
assignee=state.get("assignee"),
|
||||
due_at=state.get("due_at"),
|
||||
resolution=state.get("resolution"),
|
||||
created_at=history[0].created_at,
|
||||
updated_at=event.created_at,
|
||||
events=event_reads,
|
||||
)
|
||||
response = RiskDispositionMutationRead(
|
||||
disposition=disposition,
|
||||
event=target_read,
|
||||
replayed=False,
|
||||
)
|
||||
except ValidationError as error:
|
||||
raise RiskDispositionConflictError(
|
||||
"Legacy risk disposition response cannot be reconstructed safely."
|
||||
) from error
|
||||
self._validate_snapshot_identity(event, response)
|
||||
return response
|
||||
|
||||
@staticmethod
|
||||
def _validate_snapshot_identity(
|
||||
event: RiskDispositionEvent,
|
||||
response: RiskDispositionMutationRead,
|
||||
) -> None:
|
||||
if (
|
||||
response.event.id != event.id
|
||||
or response.event.request_id != event.request_id
|
||||
or response.event.version != event.version
|
||||
or response.disposition.id != event.disposition_id
|
||||
or response.disposition.observation_id != event.observation_id
|
||||
or response.disposition.tenant_id != event.tenant_id
|
||||
or response.disposition.version != event.version
|
||||
or not any(item.id == event.id for item in response.disposition.events)
|
||||
or any(item.version > event.version for item in response.disposition.events)
|
||||
):
|
||||
raise RiskDispositionConflictError(
|
||||
"Risk disposition response snapshot does not match its immutable event."
|
||||
)
|
||||
|
||||
def _append_safe_feedback(
|
||||
self,
|
||||
observation: RiskObservation,
|
||||
|
||||
Reference in New Issue
Block a user