feat(approval): add safe risk disposition workflow
This commit is contained in:
270
server/src/app/services/approval_action_protocol.py
Normal file
270
server/src/app/services/approval_action_protocol.py
Normal file
@@ -0,0 +1,270 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import threading
|
||||
import uuid
|
||||
from collections.abc import Callable, Iterator, Mapping
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select, text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import CurrentUserContext
|
||||
from app.models.approval_action import ApprovalActionLedger
|
||||
from app.models.financial_record import ExpenseClaim
|
||||
from app.services.expense_claim_tenant_scope import ExpenseClaimTenantScopeMixin
|
||||
|
||||
|
||||
class ApprovalActionConflictError(ValueError):
|
||||
"""请求已被占用,或动作前置状态已经过期。"""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ApprovalActionStart:
|
||||
claim: ExpenseClaim | None
|
||||
ledger: ApprovalActionLedger | None
|
||||
request_id: str
|
||||
replayed: bool
|
||||
|
||||
|
||||
class ApprovalActionProtocol:
|
||||
"""为 approve/return/pay 提供并发安全和事务内幂等语义。"""
|
||||
|
||||
_fallback_registry_guard = threading.Lock()
|
||||
_fallback_locks: dict[str, tuple[threading.RLock, int]] = {}
|
||||
|
||||
def __init__(self, db: Session) -> None:
|
||||
self.db = db
|
||||
|
||||
@contextmanager
|
||||
def serialize_request(
|
||||
self,
|
||||
*,
|
||||
tenant_id: str,
|
||||
actor_id: str,
|
||||
request_id: str | None,
|
||||
) -> Iterator[str]:
|
||||
normalized_request_id = self._normalize_request_id(request_id)
|
||||
lock_name = f"approval-action:{tenant_id}:{actor_id}:{normalized_request_id}"
|
||||
if self._dialect_name() == "postgresql":
|
||||
self.db.execute(
|
||||
text("SELECT pg_advisory_xact_lock(:lock_id)"),
|
||||
{"lock_id": self._signed_lock_id(lock_name)},
|
||||
)
|
||||
yield normalized_request_id
|
||||
return
|
||||
|
||||
with self._serialize_fallback(lock_name):
|
||||
yield normalized_request_id
|
||||
|
||||
def begin(
|
||||
self,
|
||||
*,
|
||||
action: str,
|
||||
claim_id: str,
|
||||
current_user: CurrentUserContext,
|
||||
request_id: str,
|
||||
expected_status: str | None,
|
||||
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)
|
||||
normalized_action = self._normalize_action(action)
|
||||
existing = self.db.scalar(
|
||||
select(ApprovalActionLedger).where(
|
||||
ApprovalActionLedger.tenant_id == tenant_id,
|
||||
ApprovalActionLedger.actor_id == actor_id,
|
||||
ApprovalActionLedger.request_id == request_id,
|
||||
)
|
||||
)
|
||||
if existing is not None:
|
||||
supplied_fingerprint = self._fingerprint(
|
||||
action=normalized_action,
|
||||
claim_id=claim_id,
|
||||
expected_status=expected_status or existing.expected_status,
|
||||
expected_approval_stage=(
|
||||
expected_approval_stage or existing.expected_approval_stage
|
||||
),
|
||||
payload=payload,
|
||||
)
|
||||
if supplied_fingerprint != existing.payload_fingerprint:
|
||||
raise ApprovalActionConflictError(
|
||||
"该 request_id 已用于另一项审批动作,请生成新的 request_id 后重试。"
|
||||
)
|
||||
if existing.completed_at is None:
|
||||
raise ApprovalActionConflictError("该审批动作仍在处理中,请稍后重试。")
|
||||
return ApprovalActionStart(
|
||||
claim=replay_claim_loader(),
|
||||
ledger=existing,
|
||||
request_id=request_id,
|
||||
replayed=True,
|
||||
)
|
||||
|
||||
claim = claim_loader()
|
||||
if claim is None:
|
||||
return ApprovalActionStart(
|
||||
claim=None,
|
||||
ledger=None,
|
||||
request_id=request_id,
|
||||
replayed=False,
|
||||
)
|
||||
|
||||
actual_tenant_id = ExpenseClaimTenantScopeMixin.resolve_claim_tenant_id(
|
||||
self.db,
|
||||
claim.id,
|
||||
)
|
||||
if actual_tenant_id != tenant_id:
|
||||
# 正常情况下租户查询范围会先拦截;这里是写入账本前的纵深校验。
|
||||
raise ApprovalActionConflictError("单据租户上下文已变化,请刷新后重试。")
|
||||
|
||||
current_status = self._normalize_status(claim.status)
|
||||
current_stage = self._normalize_stage(claim.approval_stage)
|
||||
normalized_expected_status = self._normalize_status(
|
||||
expected_status if expected_status is not None else current_status
|
||||
)
|
||||
normalized_expected_stage = self._normalize_stage(
|
||||
expected_approval_stage if expected_approval_stage is not None else current_stage
|
||||
)
|
||||
if normalized_expected_status != current_status:
|
||||
raise ApprovalActionConflictError(
|
||||
f"单据状态已从 {normalized_expected_status} 变为 {current_status},请刷新后重试。"
|
||||
)
|
||||
if normalized_expected_stage != current_stage:
|
||||
raise ApprovalActionConflictError(
|
||||
f"审批节点已从 {normalized_expected_stage} 变为 {current_stage},请刷新后重试。"
|
||||
)
|
||||
|
||||
ledger = ApprovalActionLedger(
|
||||
tenant_id=tenant_id,
|
||||
actor_id=actor_id,
|
||||
request_id=request_id,
|
||||
claim_id=str(claim.id),
|
||||
action=normalized_action,
|
||||
payload_fingerprint=self._fingerprint(
|
||||
action=normalized_action,
|
||||
claim_id=claim.id,
|
||||
expected_status=normalized_expected_status,
|
||||
expected_approval_stage=normalized_expected_stage,
|
||||
payload=payload,
|
||||
),
|
||||
expected_status=normalized_expected_status,
|
||||
expected_approval_stage=normalized_expected_stage,
|
||||
)
|
||||
self.db.add(ledger)
|
||||
self.db.flush()
|
||||
return ApprovalActionStart(
|
||||
claim=claim,
|
||||
ledger=ledger,
|
||||
request_id=request_id,
|
||||
replayed=False,
|
||||
)
|
||||
|
||||
def complete(
|
||||
self,
|
||||
ledger: ApprovalActionLedger,
|
||||
claim: ExpenseClaim,
|
||||
*,
|
||||
response_json: Mapping[str, Any] | None = None,
|
||||
) -> None:
|
||||
ledger.result_status = self._normalize_status(claim.status)
|
||||
ledger.result_approval_stage = self._normalize_stage(claim.approval_stage)
|
||||
ledger.response_json = dict(response_json or {})
|
||||
ledger.completed_at = datetime.now(UTC)
|
||||
self.db.flush()
|
||||
|
||||
def _dialect_name(self) -> str:
|
||||
bind = self.db.get_bind()
|
||||
return str(bind.dialect.name if bind is not None else "")
|
||||
|
||||
@classmethod
|
||||
@contextmanager
|
||||
def _serialize_fallback(cls, name: str) -> Iterator[None]:
|
||||
with cls._fallback_registry_guard:
|
||||
lock, references = cls._fallback_locks.get(
|
||||
name,
|
||||
(threading.RLock(), 0),
|
||||
)
|
||||
cls._fallback_locks[name] = (lock, references + 1)
|
||||
try:
|
||||
with lock:
|
||||
yield
|
||||
finally:
|
||||
with cls._fallback_registry_guard:
|
||||
current = cls._fallback_locks.get(name)
|
||||
if current is None or current[0] is not lock:
|
||||
return
|
||||
if current[1] <= 1:
|
||||
cls._fallback_locks.pop(name, None)
|
||||
else:
|
||||
cls._fallback_locks[name] = (lock, current[1] - 1)
|
||||
|
||||
@staticmethod
|
||||
def _signed_lock_id(value: str) -> int:
|
||||
unsigned = int.from_bytes(
|
||||
hashlib.sha256(value.encode("utf-8")).digest()[:8],
|
||||
byteorder="big",
|
||||
signed=False,
|
||||
)
|
||||
return unsigned - (1 << 64) if unsigned >= (1 << 63) else unsigned
|
||||
|
||||
@staticmethod
|
||||
def _normalize_request_id(value: str | None) -> str:
|
||||
normalized = str(value or "").strip() or f"internal:{uuid.uuid4()}"
|
||||
if len(normalized) > 120:
|
||||
raise ValueError("request_id 最长为 120 个字符。")
|
||||
return normalized
|
||||
|
||||
@staticmethod
|
||||
def _normalize_actor_id(value: str | None) -> str:
|
||||
normalized = str(value or "").strip().casefold()
|
||||
if not normalized:
|
||||
raise ValueError("当前用户缺少可审计的账号标识。")
|
||||
return normalized
|
||||
|
||||
@staticmethod
|
||||
def _normalize_action(value: str) -> str:
|
||||
normalized = str(value or "").strip().lower()
|
||||
if normalized not in {"approve", "return", "pay"}:
|
||||
raise ValueError("不支持的审批动作。")
|
||||
return normalized
|
||||
|
||||
@staticmethod
|
||||
def _normalize_status(value: str | None) -> str:
|
||||
return str(value or "").strip().lower()
|
||||
|
||||
@staticmethod
|
||||
def _normalize_stage(value: str | None) -> str:
|
||||
return str(value or "").strip()
|
||||
|
||||
@classmethod
|
||||
def _fingerprint(
|
||||
cls,
|
||||
*,
|
||||
action: str,
|
||||
claim_id: str,
|
||||
expected_status: str,
|
||||
expected_approval_stage: str,
|
||||
payload: Mapping[str, Any],
|
||||
) -> str:
|
||||
canonical = {
|
||||
"action": cls._normalize_action(action),
|
||||
"claim_id": str(claim_id or "").strip(),
|
||||
"expected_status": cls._normalize_status(expected_status),
|
||||
"expected_approval_stage": cls._normalize_stage(expected_approval_stage),
|
||||
"payload": dict(payload),
|
||||
}
|
||||
encoded = json.dumps(
|
||||
canonical,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
default=str,
|
||||
).encode("utf-8")
|
||||
return f"sha256:{hashlib.sha256(encoded).hexdigest()}"
|
||||
518
server/src/app/services/approval_workbench.py
Normal file
518
server/src/app/services/approval_workbench.py
Normal file
@@ -0,0 +1,518 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import CurrentUserContext
|
||||
from app.models.financial_record import ExpenseClaim
|
||||
from app.models.risk_disposition import RiskDisposition
|
||||
from app.models.risk_observation import RiskObservation
|
||||
from app.schemas.approval_workbench import (
|
||||
ApprovalWorkbenchEvidenceRead,
|
||||
ApprovalWorkbenchItemRead,
|
||||
ApprovalWorkbenchListRead,
|
||||
ApprovalWorkbenchPriorityReasonRead,
|
||||
ApprovalWorkbenchSuggestionRead,
|
||||
)
|
||||
from app.schemas.reimbursement import ExpenseClaimRead
|
||||
from app.services.expense_claim_risk_flags import (
|
||||
claim_risk_flag_observation_key,
|
||||
claim_risk_flag_severity,
|
||||
is_open_claim_risk_flag,
|
||||
)
|
||||
from app.services.expense_claim_tenant_scope import ExpenseClaimTenantScopeMixin
|
||||
|
||||
_SLA_HOURS = 24
|
||||
_RISK_WEIGHTS = {"critical": 38, "high": 30, "medium": 14, "low": 0}
|
||||
_RISK_ORDER = {"low": 0, "medium": 1, "high": 2, "critical": 3}
|
||||
_RESOLVED_STATUSES = {"resolved", "accepted", "waived", "false_positive"}
|
||||
|
||||
|
||||
class ApprovalWorkbenchService:
|
||||
"""构建只读、可解释且不扩大审批权限的例外审批队列。"""
|
||||
|
||||
def __init__(self, db: Session) -> None:
|
||||
self.db = db
|
||||
|
||||
def list_items(
|
||||
self,
|
||||
current_user: CurrentUserContext,
|
||||
*,
|
||||
limit: int = 100,
|
||||
now: datetime | None = None,
|
||||
) -> ApprovalWorkbenchListRead:
|
||||
# 延迟导入,避免 ExpenseClaimService 的 mixin 聚合产生循环依赖。
|
||||
from app.services.expense_claims import ExpenseClaimService
|
||||
|
||||
generated_at = now or datetime.now(UTC)
|
||||
claims = ExpenseClaimService(self.db).list_approval_claims(current_user)
|
||||
observations_by_claim = self._risk_observations_by_claim(
|
||||
claims,
|
||||
tenant_id=current_user.tenant_id,
|
||||
)
|
||||
items = [
|
||||
self.build_item(
|
||||
claim,
|
||||
now=generated_at,
|
||||
observation_rows=observations_by_claim.get(claim.id),
|
||||
)
|
||||
for claim in claims
|
||||
]
|
||||
items.sort(
|
||||
key=lambda item: (
|
||||
-item.priority_score,
|
||||
-item.waiting_hours,
|
||||
item.claim.claim_no,
|
||||
)
|
||||
)
|
||||
return ApprovalWorkbenchListRead(
|
||||
items=items[: max(1, min(int(limit), 200))],
|
||||
total=len(items),
|
||||
generated_at=generated_at,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def build_item(
|
||||
cls,
|
||||
claim: ExpenseClaim,
|
||||
*,
|
||||
now: datetime | None = None,
|
||||
observation_rows: list[tuple[RiskObservation, RiskDisposition | None]] | None = None,
|
||||
) -> ApprovalWorkbenchItemRead:
|
||||
generated_at = now or datetime.now(UTC)
|
||||
submitted_at = cls._aware(claim.submitted_at or claim.created_at)
|
||||
waiting_hours = max(
|
||||
0.0,
|
||||
(generated_at - submitted_at).total_seconds() / 3600,
|
||||
)
|
||||
sla_due_at = submitted_at + timedelta(hours=_SLA_HOURS)
|
||||
risk_level, open_risk_count = cls._combined_risk_summary(
|
||||
claim,
|
||||
observation_rows or [],
|
||||
)
|
||||
budget_usage_rate = cls._budget_usage_rate(claim.risk_flags_json)
|
||||
evidence = cls._evidence_summary(claim)
|
||||
reasons = cls._priority_reasons(
|
||||
claim=claim,
|
||||
risk_level=risk_level,
|
||||
open_risk_count=open_risk_count,
|
||||
budget_usage_rate=budget_usage_rate,
|
||||
waiting_hours=waiting_hours,
|
||||
evidence=evidence,
|
||||
)
|
||||
score = min(100, sum(item.weight for item in reasons))
|
||||
tier = "urgent" if score >= 65 else "high" if score >= 40 else "normal"
|
||||
return ApprovalWorkbenchItemRead(
|
||||
task_key=f"{claim.id}:{claim.approval_stage or ''}:{submitted_at.isoformat()}",
|
||||
claim=cls._safe_claim_read(claim),
|
||||
priority_score=score,
|
||||
priority_tier=tier,
|
||||
priority_reasons=reasons,
|
||||
risk_level=risk_level,
|
||||
open_risk_count=open_risk_count,
|
||||
budget_usage_rate=budget_usage_rate,
|
||||
waiting_hours=round(waiting_hours, 2),
|
||||
sla_due_at=sla_due_at,
|
||||
sla_overdue=generated_at >= sla_due_at,
|
||||
evidence=evidence,
|
||||
suggestion=cls._suggestion(
|
||||
risk_level=risk_level,
|
||||
open_risk_count=open_risk_count,
|
||||
budget_usage_rate=budget_usage_rate,
|
||||
evidence=evidence,
|
||||
),
|
||||
)
|
||||
|
||||
def _risk_observations_by_claim(
|
||||
self,
|
||||
claims: list[ExpenseClaim],
|
||||
*,
|
||||
tenant_id: str | None,
|
||||
) -> dict[str, list[tuple[RiskObservation, RiskDisposition | None]]]:
|
||||
claim_ids = [str(claim.id) for claim in claims if str(claim.id or "").strip()]
|
||||
if not claim_ids:
|
||||
return {}
|
||||
normalized_tenant = ExpenseClaimTenantScopeMixin.normalize_tenant_id(tenant_id)
|
||||
rows = self.db.execute(
|
||||
select(RiskObservation, RiskDisposition)
|
||||
.outerjoin(
|
||||
RiskDisposition,
|
||||
(
|
||||
(RiskDisposition.tenant_id == RiskObservation.tenant_id)
|
||||
& (RiskDisposition.observation_id == RiskObservation.id)
|
||||
),
|
||||
)
|
||||
.where(
|
||||
RiskObservation.tenant_id == normalized_tenant,
|
||||
RiskObservation.claim_id.in_(claim_ids),
|
||||
)
|
||||
).all()
|
||||
grouped: dict[str, list[tuple[RiskObservation, RiskDisposition | None]]] = {}
|
||||
for observation, disposition in rows:
|
||||
grouped.setdefault(str(observation.claim_id), []).append((observation, disposition))
|
||||
return grouped
|
||||
|
||||
@classmethod
|
||||
def _safe_claim_read(cls, claim: ExpenseClaim) -> ExpenseClaimRead:
|
||||
claim_read = ExpenseClaimRead.model_validate(claim)
|
||||
claim_read.risk_flags_json = [
|
||||
cls._safe_risk_flag(flag)
|
||||
for flag in list(claim_read.risk_flags_json or [])
|
||||
if isinstance(flag, dict)
|
||||
]
|
||||
return claim_read
|
||||
|
||||
@staticmethod
|
||||
def _safe_risk_flag(flag: dict[str, Any]) -> dict[str, Any]:
|
||||
safe_flag = dict(flag)
|
||||
if "historical_case_evidence" not in safe_flag:
|
||||
return safe_flag
|
||||
evidence: list[dict[str, Any]] = []
|
||||
for item in list(safe_flag.get("historical_case_evidence") or []):
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
label = str(item.get("label") or "").strip().lower()
|
||||
if label not in {"confirmed", "false_positive"}:
|
||||
continue
|
||||
evidence.append(
|
||||
{
|
||||
"label": label,
|
||||
"label_text": (
|
||||
"历史已确认,仅供复核" if label == "confirmed" else "历史误报,仅供复核"
|
||||
),
|
||||
"advisory_only": True,
|
||||
"stale": bool(item.get("stale")),
|
||||
"summary": (
|
||||
"历史相似案例经人工复核确认风险成立。"
|
||||
if label == "confirmed"
|
||||
else "历史相似案例经人工复核判定为误报。"
|
||||
),
|
||||
}
|
||||
)
|
||||
safe_flag["historical_case_evidence"] = evidence
|
||||
return safe_flag
|
||||
|
||||
@classmethod
|
||||
def _priority_reasons(
|
||||
cls,
|
||||
*,
|
||||
claim: ExpenseClaim,
|
||||
risk_level: str,
|
||||
open_risk_count: int,
|
||||
budget_usage_rate: float | None,
|
||||
waiting_hours: float,
|
||||
evidence: ApprovalWorkbenchEvidenceRead,
|
||||
) -> list[ApprovalWorkbenchPriorityReasonRead]:
|
||||
reasons: list[ApprovalWorkbenchPriorityReasonRead] = []
|
||||
risk_weight = _RISK_WEIGHTS[risk_level]
|
||||
if risk_weight:
|
||||
reasons.append(
|
||||
ApprovalWorkbenchPriorityReasonRead(
|
||||
code="open_risk",
|
||||
label=f"{risk_level.upper()} 风险 {open_risk_count} 项待复核",
|
||||
weight=risk_weight,
|
||||
tone="danger" if risk_level in {"high", "critical"} else "warning",
|
||||
)
|
||||
)
|
||||
if waiting_hours >= _SLA_HOURS:
|
||||
reasons.append(
|
||||
ApprovalWorkbenchPriorityReasonRead(
|
||||
code="sla_overdue",
|
||||
label="已超过 24 小时审批 SLA",
|
||||
weight=26,
|
||||
tone="danger",
|
||||
)
|
||||
)
|
||||
elif waiting_hours >= 16:
|
||||
reasons.append(
|
||||
ApprovalWorkbenchPriorityReasonRead(
|
||||
code="sla_near_due",
|
||||
label="审批 SLA 即将到期",
|
||||
weight=16,
|
||||
tone="warning",
|
||||
)
|
||||
)
|
||||
elif waiting_hours >= 8:
|
||||
reasons.append(
|
||||
ApprovalWorkbenchPriorityReasonRead(
|
||||
code="waiting",
|
||||
label="等待时间已超过 8 小时",
|
||||
weight=8,
|
||||
tone="warning",
|
||||
)
|
||||
)
|
||||
if budget_usage_rate is not None and budget_usage_rate >= 90:
|
||||
reasons.append(
|
||||
ApprovalWorkbenchPriorityReasonRead(
|
||||
code="budget_pressure",
|
||||
label=f"审批后预算占用约 {budget_usage_rate:.0f}%",
|
||||
weight=18,
|
||||
tone="danger" if budget_usage_rate >= 100 else "warning",
|
||||
)
|
||||
)
|
||||
amount = cls._decimal(claim.amount)
|
||||
if amount >= Decimal("50000"):
|
||||
reasons.append(
|
||||
ApprovalWorkbenchPriorityReasonRead(
|
||||
code="large_amount",
|
||||
label="大额费用需重点核对",
|
||||
weight=15,
|
||||
tone="warning",
|
||||
)
|
||||
)
|
||||
elif amount >= Decimal("10000"):
|
||||
reasons.append(
|
||||
ApprovalWorkbenchPriorityReasonRead(
|
||||
code="amount_attention",
|
||||
label="金额超过 1 万元",
|
||||
weight=8,
|
||||
tone="warning",
|
||||
)
|
||||
)
|
||||
if evidence.completeness < 1:
|
||||
reasons.append(
|
||||
ApprovalWorkbenchPriorityReasonRead(
|
||||
code="evidence_gap",
|
||||
label="材料仍有缺口:" + "、".join(evidence.missing_labels),
|
||||
weight=12,
|
||||
tone="warning",
|
||||
)
|
||||
)
|
||||
if not reasons:
|
||||
reasons.append(
|
||||
ApprovalWorkbenchPriorityReasonRead(
|
||||
code="routine",
|
||||
label="常规低风险待办",
|
||||
)
|
||||
)
|
||||
return reasons
|
||||
|
||||
@classmethod
|
||||
def _risk_summary(
|
||||
cls,
|
||||
raw_flags: Any,
|
||||
*,
|
||||
claim_id: str = "",
|
||||
materialized_keys: set[str] | None = None,
|
||||
) -> tuple[str, int]:
|
||||
flags = raw_flags if isinstance(raw_flags, list) else [raw_flags]
|
||||
persisted_keys = materialized_keys or set()
|
||||
level = "low"
|
||||
count = 0
|
||||
for flag in flags:
|
||||
if not isinstance(flag, dict) or not is_open_claim_risk_flag(flag):
|
||||
continue
|
||||
observation_key = claim_risk_flag_observation_key(flag, claim_id=claim_id)
|
||||
if observation_key and observation_key in persisted_keys:
|
||||
continue
|
||||
candidate = claim_risk_flag_severity(flag) or "medium"
|
||||
if candidate not in _RISK_ORDER:
|
||||
candidate = "medium"
|
||||
count += 1
|
||||
if _RISK_ORDER[candidate] > _RISK_ORDER[level]:
|
||||
level = candidate
|
||||
return level, count
|
||||
|
||||
@classmethod
|
||||
def _combined_risk_summary(
|
||||
cls,
|
||||
claim: ExpenseClaim,
|
||||
rows: list[tuple[RiskObservation, RiskDisposition | None]],
|
||||
) -> tuple[str, int]:
|
||||
persisted_level, persisted_count = cls._persisted_risk_summary(rows)
|
||||
materialized_keys = {
|
||||
str(observation.observation_key or "").strip()
|
||||
for observation, _disposition in rows
|
||||
if str(observation.observation_key or "").strip()
|
||||
}
|
||||
raw_level, raw_count = cls._risk_summary(
|
||||
claim.risk_flags_json,
|
||||
claim_id=str(claim.id or ""),
|
||||
materialized_keys=materialized_keys,
|
||||
)
|
||||
return (
|
||||
max((persisted_level, raw_level), key=lambda item: _RISK_ORDER[item]),
|
||||
persisted_count + raw_count,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _persisted_risk_summary(
|
||||
rows: list[tuple[RiskObservation, RiskDisposition | None]],
|
||||
) -> tuple[str, int]:
|
||||
level = "low"
|
||||
count = 0
|
||||
for observation, disposition in rows:
|
||||
adjudication = (
|
||||
str(disposition.adjudication or "").strip().lower()
|
||||
if disposition is not None
|
||||
else str(observation.feedback_status or "").strip().lower()
|
||||
)
|
||||
lifecycle = (
|
||||
str(disposition.lifecycle_status or "").strip().lower()
|
||||
if disposition is not None
|
||||
else "open"
|
||||
)
|
||||
status = str(observation.status or "").strip().lower()
|
||||
if (
|
||||
adjudication == "false_positive"
|
||||
or lifecycle == "resolved"
|
||||
or status in _RESOLVED_STATUSES
|
||||
):
|
||||
continue
|
||||
candidate = str(observation.risk_level or "medium").strip().lower()
|
||||
if candidate == "danger":
|
||||
candidate = "high"
|
||||
if candidate not in _RISK_ORDER:
|
||||
candidate = "medium"
|
||||
count += 1
|
||||
if _RISK_ORDER[candidate] > _RISK_ORDER[level]:
|
||||
level = candidate
|
||||
return level, count
|
||||
|
||||
@classmethod
|
||||
def _budget_usage_rate(cls, raw_flags: Any) -> float | None:
|
||||
values: list[float] = []
|
||||
flags = raw_flags if isinstance(raw_flags, list) else [raw_flags]
|
||||
for flag in flags:
|
||||
if not isinstance(flag, dict):
|
||||
continue
|
||||
root_containers = [
|
||||
flag,
|
||||
flag.get("metrics"),
|
||||
flag.get("budget_result"),
|
||||
(flag.get("route_decision") or {}).get("budget_result")
|
||||
if isinstance(flag.get("route_decision"), dict)
|
||||
else None,
|
||||
]
|
||||
containers = [item for item in root_containers if isinstance(item, dict)]
|
||||
containers.extend(
|
||||
item["metrics"]
|
||||
for item in list(containers)
|
||||
if isinstance(item.get("metrics"), dict)
|
||||
)
|
||||
for container in containers:
|
||||
for key in (
|
||||
"after_usage_rate",
|
||||
"budget_usage_rate",
|
||||
"usage_rate",
|
||||
"utilization_rate",
|
||||
):
|
||||
value = cls._float(container.get(key))
|
||||
if value is None:
|
||||
continue
|
||||
values.append(value * 100 if 0 < value <= 1 else value)
|
||||
return round(max(values), 2) if values else None
|
||||
|
||||
@classmethod
|
||||
def _evidence_summary(cls, claim: ExpenseClaim) -> ApprovalWorkbenchEvidenceRead:
|
||||
is_application = cls._is_application(claim)
|
||||
checks = [
|
||||
("事由", bool(str(claim.reason or "").strip())),
|
||||
("地点", bool(str(claim.location or "").strip())),
|
||||
("费用明细", bool(list(claim.items or []))),
|
||||
]
|
||||
if not is_application:
|
||||
checks.append(
|
||||
(
|
||||
"票据",
|
||||
int(claim.invoice_count or 0) > 0
|
||||
or any(str(item.invoice_id or "").strip() for item in claim.items or []),
|
||||
)
|
||||
)
|
||||
missing = [label for label, present in checks if not present]
|
||||
historical_labels = cls._historical_labels(claim.risk_flags_json)
|
||||
present_count = len(checks) - len(missing)
|
||||
return ApprovalWorkbenchEvidenceRead(
|
||||
completeness=round(present_count / len(checks), 4) if checks else 1,
|
||||
present_count=present_count,
|
||||
required_count=len(checks),
|
||||
missing_labels=missing,
|
||||
historical_labels=historical_labels,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _historical_labels(raw_flags: Any) -> list[str]:
|
||||
result: list[str] = []
|
||||
flags = raw_flags if isinstance(raw_flags, list) else [raw_flags]
|
||||
for flag in flags:
|
||||
if not isinstance(flag, dict):
|
||||
continue
|
||||
for item in list(flag.get("historical_case_evidence") or []):
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
label = str(item.get("label") or "").strip().lower()
|
||||
text = (
|
||||
"历史已确认,仅供复核"
|
||||
if label == "confirmed"
|
||||
else "历史误报,仅供复核"
|
||||
if label == "false_positive"
|
||||
else ""
|
||||
)
|
||||
if text and text not in result:
|
||||
result.append(text)
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _suggestion(
|
||||
*,
|
||||
risk_level: str,
|
||||
open_risk_count: int,
|
||||
budget_usage_rate: float | None,
|
||||
evidence: ApprovalWorkbenchEvidenceRead,
|
||||
) -> ApprovalWorkbenchSuggestionRead:
|
||||
if risk_level in {"high", "critical"} and open_risk_count:
|
||||
return ApprovalWorkbenchSuggestionRead(
|
||||
action="manual_review",
|
||||
label="先核对风险再决策",
|
||||
reason="存在高风险关注项,AI 不建议直接通过。",
|
||||
)
|
||||
if evidence.missing_labels:
|
||||
return ApprovalWorkbenchSuggestionRead(
|
||||
action="request_supplement",
|
||||
label="建议退回补充材料",
|
||||
reason="缺少" + "、".join(evidence.missing_labels) + "。",
|
||||
)
|
||||
if budget_usage_rate is not None and budget_usage_rate >= 90:
|
||||
return ApprovalWorkbenchSuggestionRead(
|
||||
action="budget_review",
|
||||
label="重点复核预算影响",
|
||||
reason="审批后预算占用已达到复核线。",
|
||||
)
|
||||
return ApprovalWorkbenchSuggestionRead(
|
||||
action="approve_candidate",
|
||||
label="可人工确认后通过",
|
||||
reason="材料相对完整且未发现高风险阻断项。",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _is_application(claim: ExpenseClaim) -> bool:
|
||||
claim_no = str(claim.claim_no or "").strip().upper()
|
||||
expense_type = str(claim.expense_type or "").strip().lower()
|
||||
return (
|
||||
claim_no.startswith(("AP-", "APP-"))
|
||||
or expense_type == "application"
|
||||
or expense_type.endswith("_application")
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _aware(value: datetime) -> datetime:
|
||||
return value if value.tzinfo is not None else value.replace(tzinfo=UTC)
|
||||
|
||||
@staticmethod
|
||||
def _decimal(value: Any) -> Decimal:
|
||||
try:
|
||||
return Decimal(str(value or "0"))
|
||||
except (InvalidOperation, ValueError):
|
||||
return Decimal("0")
|
||||
|
||||
@staticmethod
|
||||
def _float(value: Any) -> float | None:
|
||||
try:
|
||||
number = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return number if number >= 0 else None
|
||||
120
server/src/app/services/expense_claim_action_protocol.py
Normal file
120
server/src/app/services/expense_claim_action_protocol.py
Normal file
@@ -0,0 +1,120 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Mapping
|
||||
from typing import Any
|
||||
|
||||
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.services.expense_claim_tenant_scope import ExpenseClaimTenantScopeMixin
|
||||
|
||||
ClaimActionExecutor = Callable[
|
||||
[ExpenseClaim, ApprovalActionLedger, str],
|
||||
ExpenseClaim,
|
||||
]
|
||||
|
||||
|
||||
class ExpenseClaimActionProtocolMixin:
|
||||
def _execute_claim_action(
|
||||
self,
|
||||
*,
|
||||
action: str,
|
||||
claim_id: str,
|
||||
current_user: CurrentUserContext,
|
||||
request_id: str | None,
|
||||
expected_status: str | None,
|
||||
expected_approval_stage: str | None,
|
||||
payload: Mapping[str, Any],
|
||||
executor: ClaimActionExecutor,
|
||||
) -> ExpenseClaim | None:
|
||||
protocol = ApprovalActionProtocol(self.db)
|
||||
tenant_id = ExpenseClaimTenantScopeMixin.normalize_tenant_id(current_user.tenant_id)
|
||||
actor_id = str(current_user.username or "").strip().casefold()
|
||||
with protocol.serialize_request(
|
||||
tenant_id=tenant_id,
|
||||
actor_id=actor_id,
|
||||
request_id=request_id,
|
||||
) as normalized_request_id:
|
||||
try:
|
||||
started = protocol.begin(
|
||||
action=action,
|
||||
claim_id=claim_id,
|
||||
current_user=current_user,
|
||||
request_id=normalized_request_id,
|
||||
expected_status=expected_status,
|
||||
expected_approval_stage=expected_approval_stage,
|
||||
payload=payload,
|
||||
claim_loader=lambda: self._load_claim_for_action(
|
||||
claim_id,
|
||||
current_user,
|
||||
),
|
||||
replay_claim_loader=lambda: self._load_claim_for_replay(
|
||||
claim_id,
|
||||
current_user,
|
||||
),
|
||||
)
|
||||
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("审批动作账本初始化失败。")
|
||||
|
||||
claim = executor(
|
||||
started.claim,
|
||||
started.ledger,
|
||||
normalized_request_id,
|
||||
)
|
||||
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(),
|
||||
},
|
||||
)
|
||||
self.db.commit()
|
||||
self.db.refresh(claim)
|
||||
return self._access_policy.attach_approval_snapshot(claim)
|
||||
except Exception:
|
||||
self.db.rollback()
|
||||
raise
|
||||
|
||||
def _load_claim_for_action(
|
||||
self,
|
||||
claim_id: str,
|
||||
current_user: CurrentUserContext,
|
||||
) -> ExpenseClaim | None:
|
||||
stmt = select(ExpenseClaim).where(ExpenseClaim.id == claim_id)
|
||||
stmt = self._access_policy.apply_claim_scope(
|
||||
stmt,
|
||||
current_user,
|
||||
include_approval_scope=True,
|
||||
)
|
||||
bind = self.db.get_bind()
|
||||
if bind is not None and bind.dialect.name == "postgresql":
|
||||
stmt = stmt.with_for_update()
|
||||
claim = self.db.scalar(stmt)
|
||||
if claim is not None:
|
||||
# 动作协议已经持有事务级请求锁和 Claim 行锁;这里只允许在同一
|
||||
# 事务内修正对象,不能调用会自行 commit 的读取兼容修复入口。
|
||||
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)
|
||||
@@ -6,8 +6,14 @@ from decimal import Decimal, InvalidOperation
|
||||
from typing import Any
|
||||
|
||||
from app.api.deps import CurrentUserContext
|
||||
from app.models.approval_action import ApprovalActionLedger
|
||||
from app.models.financial_record import ExpenseClaim
|
||||
from app.services.budget import BudgetService
|
||||
from app.services.expense_claim_risk_gate import ExpenseClaimRiskGate
|
||||
from app.services.expense_claim_risk_stage import (
|
||||
risk_business_stage_for_claim,
|
||||
with_risk_business_stage,
|
||||
)
|
||||
from app.services.expense_claim_workflow_constants import (
|
||||
APPLICATION_LINK_STATUS_STAGE,
|
||||
BUDGET_MANAGER_APPROVAL_STAGE,
|
||||
@@ -18,10 +24,6 @@ from app.services.expense_claim_workflow_constants import (
|
||||
PAYMENT_PENDING_STAGE,
|
||||
PAYMENT_PENDING_STATUS,
|
||||
)
|
||||
from app.services.expense_claim_risk_stage import (
|
||||
risk_business_stage_for_claim,
|
||||
with_risk_business_stage,
|
||||
)
|
||||
|
||||
|
||||
class ExpenseClaimApprovalFlowMixin:
|
||||
@@ -31,10 +33,37 @@ class ExpenseClaimApprovalFlowMixin:
|
||||
current_user: CurrentUserContext,
|
||||
*,
|
||||
opinion: str | None = None,
|
||||
request_id: str | None = None,
|
||||
expected_status: str | None = None,
|
||||
expected_approval_stage: str | None = None,
|
||||
):
|
||||
claim = self.get_claim(claim_id, current_user)
|
||||
if claim is None:
|
||||
return None
|
||||
normalized_opinion = str(opinion or "").strip()
|
||||
return self._execute_claim_action(
|
||||
action="approve",
|
||||
claim_id=claim_id,
|
||||
current_user=current_user,
|
||||
request_id=request_id,
|
||||
expected_status=expected_status,
|
||||
expected_approval_stage=expected_approval_stage,
|
||||
payload={"opinion": normalized_opinion},
|
||||
executor=lambda claim, ledger, normalized_request_id: self._approve_claim_once(
|
||||
claim,
|
||||
current_user,
|
||||
opinion=normalized_opinion,
|
||||
ledger=ledger,
|
||||
request_id=normalized_request_id,
|
||||
),
|
||||
)
|
||||
|
||||
def _approve_claim_once(
|
||||
self,
|
||||
claim: ExpenseClaim,
|
||||
current_user: CurrentUserContext,
|
||||
*,
|
||||
opinion: str,
|
||||
ledger: ApprovalActionLedger,
|
||||
request_id: str,
|
||||
) -> ExpenseClaim:
|
||||
|
||||
normalized_status = str(claim.status or "").strip().lower()
|
||||
if normalized_status != "submitted":
|
||||
@@ -50,8 +79,11 @@ class ExpenseClaimApprovalFlowMixin:
|
||||
if previous_stage == DIRECT_MANAGER_APPROVAL_STAGE:
|
||||
if not self._access_policy.can_approve_claim(current_user, claim):
|
||||
raise ValueError("只有当前直属领导审批人可以审批通过该单据。")
|
||||
self._ensure_claim_has_no_blocking_risk(claim, current_user)
|
||||
approval_source = "manual_approval"
|
||||
event_type = "expense_application_approval" if is_application_claim else "expense_claim_approval"
|
||||
event_type = (
|
||||
"expense_application_approval" if is_application_claim else "expense_claim_approval"
|
||||
)
|
||||
label = "领导审批通过"
|
||||
route_decision_flag = self._build_approval_route_decision(
|
||||
claim,
|
||||
@@ -67,18 +99,29 @@ class ExpenseClaimApprovalFlowMixin:
|
||||
label = "领导及预算审核通过"
|
||||
next_status = "approved"
|
||||
next_stage = APPLICATION_LINK_STATUS_STAGE
|
||||
default_message = "{operator} 已完成直属领导和预算管理者审核,申请流程完成并生成报销草稿。"
|
||||
default_message = (
|
||||
"{operator} 已完成直属领导和预算管理者审核,申请流程完成并生成报销草稿。"
|
||||
)
|
||||
elif requires_budget_review:
|
||||
next_budget_manager = self._access_policy.resolve_department_budget_manager(claim)
|
||||
next_budget_manager = self._access_policy.resolve_department_budget_manager(
|
||||
claim
|
||||
)
|
||||
if next_budget_manager is None:
|
||||
raise ValueError("未找到同部门 P8 预算审批人,无法流转预算审批。请先配置预算审批人。")
|
||||
raise ValueError(
|
||||
"未找到同部门 P8 预算审批人,无法流转预算审批。请先配置预算审批人。"
|
||||
)
|
||||
next_status = "submitted"
|
||||
next_stage = BUDGET_MANAGER_APPROVAL_STAGE
|
||||
default_message = "{operator} 已确认直属领导审核,因预算或风险关注项流转至预算管理者审批。"
|
||||
default_message = (
|
||||
"{operator} 已确认直属领导审核,因预算或风险关注项流转至预算管理者审批。"
|
||||
)
|
||||
else:
|
||||
next_status = "approved"
|
||||
next_stage = APPLICATION_LINK_STATUS_STAGE
|
||||
default_message = "{operator} 已确认直属领导审核,系统判断预算充足且无风险,申请流程完成并生成报销草稿。"
|
||||
default_message = (
|
||||
"{operator} 已确认直属领导审核,系统判断预算充足且无风险,"
|
||||
"申请流程完成并生成报销草稿。"
|
||||
)
|
||||
else:
|
||||
merged_budget_approval = (
|
||||
requires_budget_review
|
||||
@@ -88,21 +131,32 @@ class ExpenseClaimApprovalFlowMixin:
|
||||
label = "领导及预算审核通过"
|
||||
next_status = "submitted"
|
||||
next_stage = FINANCE_APPROVAL_STAGE
|
||||
default_message = "{operator} 已完成直属领导和预算管理者审核,流转至{next_stage}。"
|
||||
default_message = (
|
||||
"{operator} 已完成直属领导和预算管理者审核,流转至{next_stage}。"
|
||||
)
|
||||
elif requires_budget_review:
|
||||
next_budget_manager = self._access_policy.resolve_department_budget_manager(claim)
|
||||
next_budget_manager = self._access_policy.resolve_department_budget_manager(
|
||||
claim
|
||||
)
|
||||
if next_budget_manager is None:
|
||||
raise ValueError("未找到同部门 P8 预算审批人,无法流转预算审批。请先配置预算审批人。")
|
||||
raise ValueError(
|
||||
"未找到同部门 P8 预算审批人,无法流转预算审批。请先配置预算审批人。"
|
||||
)
|
||||
next_status = "submitted"
|
||||
next_stage = BUDGET_MANAGER_APPROVAL_STAGE
|
||||
default_message = "{operator} 已审批通过,因预算或风险关注项流转至预算管理者审批。"
|
||||
default_message = (
|
||||
"{operator} 已审批通过,因预算或风险关注项流转至预算管理者审批。"
|
||||
)
|
||||
else:
|
||||
next_status = "submitted"
|
||||
next_stage = FINANCE_APPROVAL_STAGE
|
||||
default_message = "{operator} 已审批通过,系统判断预算充足且无风险,流转至{next_stage}。"
|
||||
default_message = (
|
||||
"{operator} 已审批通过,系统判断预算充足且无风险,流转至{next_stage}。"
|
||||
)
|
||||
elif previous_stage == BUDGET_MANAGER_APPROVAL_STAGE:
|
||||
if not self._access_policy.can_approve_claim(current_user, claim):
|
||||
raise ValueError("只有当前预算管理者可以审批通过该单据。")
|
||||
self._ensure_claim_has_no_blocking_risk(claim, current_user)
|
||||
approval_source = "budget_approval"
|
||||
event_type = (
|
||||
"expense_application_budget_approval"
|
||||
@@ -123,6 +177,7 @@ class ExpenseClaimApprovalFlowMixin:
|
||||
raise ValueError("费用申请需先完成预算管理者审批。")
|
||||
if not self._access_policy.can_approve_claim(current_user, claim):
|
||||
raise ValueError("只有财务人员可以完成财务终审。")
|
||||
self._ensure_claim_has_no_blocking_risk(claim, current_user)
|
||||
approval_source = "finance_approval"
|
||||
event_type = "expense_claim_finance_approval"
|
||||
label = "财务审核通过"
|
||||
@@ -146,7 +201,10 @@ class ExpenseClaimApprovalFlowMixin:
|
||||
and not approval_opinion
|
||||
):
|
||||
raise ValueError("预算已超过警戒值,预算管理者需填写审批意见后才能通过。")
|
||||
if previous_stage in {DIRECT_MANAGER_APPROVAL_STAGE, BUDGET_MANAGER_APPROVAL_STAGE} and not approval_opinion:
|
||||
if (
|
||||
previous_stage in {DIRECT_MANAGER_APPROVAL_STAGE, BUDGET_MANAGER_APPROVAL_STAGE}
|
||||
and not approval_opinion
|
||||
):
|
||||
approval_opinion = "同意"
|
||||
|
||||
before_json = self._serialize_claim(claim)
|
||||
@@ -163,7 +221,8 @@ class ExpenseClaimApprovalFlowMixin:
|
||||
"approval_event_id": str(uuid.uuid4()),
|
||||
"severity": "info",
|
||||
"label": label,
|
||||
"message": approval_opinion or default_message.format(operator=operator, next_stage=next_stage),
|
||||
"message": approval_opinion
|
||||
or default_message.format(operator=operator, next_stage=next_stage),
|
||||
"opinion": approval_opinion,
|
||||
"operator": operator,
|
||||
"operator_username": current_user.username,
|
||||
@@ -193,8 +252,10 @@ class ExpenseClaimApprovalFlowMixin:
|
||||
"next_approver_name": str(next_budget_manager.name or "").strip(),
|
||||
"next_approver_employee_id": next_budget_manager.id,
|
||||
"next_approver_grade": str(next_budget_manager.grade or "").strip(),
|
||||
"next_approver_role_code": self._access_policy.resolve_budget_approval_role_code(
|
||||
next_budget_manager,
|
||||
"next_approver_role_code": (
|
||||
self._access_policy.resolve_budget_approval_role_code(
|
||||
next_budget_manager,
|
||||
)
|
||||
),
|
||||
}
|
||||
)
|
||||
@@ -299,10 +360,6 @@ class ExpenseClaimApprovalFlowMixin:
|
||||
update_case_state=False,
|
||||
)
|
||||
|
||||
self.db.commit()
|
||||
self.db.refresh(claim)
|
||||
self._access_policy.attach_budget_approval_snapshot(claim)
|
||||
|
||||
self.audit_service.log_action(
|
||||
actor=operator,
|
||||
action="expense_claim.approve",
|
||||
@@ -310,18 +367,55 @@ class ExpenseClaimApprovalFlowMixin:
|
||||
resource_id=claim.id,
|
||||
before_json=before_json,
|
||||
after_json=self._serialize_claim(claim),
|
||||
request_id=request_id,
|
||||
commit=False,
|
||||
)
|
||||
|
||||
return claim
|
||||
|
||||
def _ensure_claim_has_no_blocking_risk(
|
||||
self,
|
||||
claim: ExpenseClaim,
|
||||
current_user: CurrentUserContext,
|
||||
) -> None:
|
||||
ExpenseClaimRiskGate(self.db).ensure_approvable(
|
||||
claim,
|
||||
tenant_id=current_user.tenant_id,
|
||||
)
|
||||
|
||||
def mark_claim_paid(
|
||||
self,
|
||||
claim_id: str,
|
||||
current_user: CurrentUserContext,
|
||||
*,
|
||||
request_id: str | None = None,
|
||||
expected_status: str | None = None,
|
||||
expected_approval_stage: str | None = None,
|
||||
):
|
||||
claim = self.get_claim(claim_id, current_user)
|
||||
if claim is None:
|
||||
return None
|
||||
return self._execute_claim_action(
|
||||
action="pay",
|
||||
claim_id=claim_id,
|
||||
current_user=current_user,
|
||||
request_id=request_id,
|
||||
expected_status=expected_status,
|
||||
expected_approval_stage=expected_approval_stage,
|
||||
payload={},
|
||||
executor=lambda claim, ledger, normalized_request_id: self._mark_claim_paid_once(
|
||||
claim,
|
||||
current_user,
|
||||
ledger=ledger,
|
||||
request_id=normalized_request_id,
|
||||
),
|
||||
)
|
||||
|
||||
def _mark_claim_paid_once(
|
||||
self,
|
||||
claim: ExpenseClaim,
|
||||
current_user: CurrentUserContext,
|
||||
*,
|
||||
ledger: ApprovalActionLedger,
|
||||
request_id: str,
|
||||
) -> ExpenseClaim:
|
||||
|
||||
normalized_status = str(claim.status or "").strip().lower()
|
||||
if normalized_status == PAYMENT_PAID_STATUS:
|
||||
@@ -412,9 +506,6 @@ class ExpenseClaimApprovalFlowMixin:
|
||||
update_case_state=False,
|
||||
)
|
||||
|
||||
self.db.commit()
|
||||
self.db.refresh(claim)
|
||||
|
||||
self.audit_service.log_action(
|
||||
actor=operator,
|
||||
action="expense_claim.mark_paid",
|
||||
@@ -422,6 +513,8 @@ class ExpenseClaimApprovalFlowMixin:
|
||||
resource_id=claim.id,
|
||||
before_json=before_json,
|
||||
after_json=self._serialize_claim(claim),
|
||||
request_id=request_id,
|
||||
commit=False,
|
||||
)
|
||||
|
||||
return claim
|
||||
@@ -440,7 +533,9 @@ class ExpenseClaimApprovalFlowMixin:
|
||||
|
||||
def _budget_approval_opinion_required(self, claim) -> bool:
|
||||
budget_result = BudgetService(self.db).analyze_claim_budget(claim)
|
||||
metrics = budget_result.get("metrics") if isinstance(budget_result.get("metrics"), dict) else {}
|
||||
metrics = (
|
||||
budget_result.get("metrics") if isinstance(budget_result.get("metrics"), dict) else {}
|
||||
)
|
||||
context = (
|
||||
budget_result.get("budget_context")
|
||||
if isinstance(budget_result.get("budget_context"), dict)
|
||||
|
||||
@@ -30,7 +30,6 @@ class ExpenseClaimPaginationMixin:
|
||||
)
|
||||
stmt = self._access_policy.apply_claim_scope(stmt, current_user)
|
||||
result = paginate_select(self.db, stmt, page=page, page_size=page_size)
|
||||
self._repair_duplicate_budget_approval_stages(result.items)
|
||||
self._access_policy.attach_budget_approval_snapshots(result.items)
|
||||
return result
|
||||
|
||||
@@ -47,7 +46,6 @@ class ExpenseClaimPaginationMixin:
|
||||
)
|
||||
stmt = self._access_policy.apply_approval_claim_scope(stmt, current_user)
|
||||
result = paginate_select(self.db, stmt, page=page, page_size=page_size)
|
||||
self._repair_duplicate_budget_approval_stages(result.items)
|
||||
self._access_policy.attach_budget_approval_snapshots(result.items)
|
||||
return result
|
||||
|
||||
|
||||
187
server/src/app/services/expense_claim_return_flow.py
Normal file
187
server/src/app/services/expense_claim_return_flow.py
Normal file
@@ -0,0 +1,187 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
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.services.expense_claim_constants import RETURN_REASON_OPTIONS
|
||||
|
||||
|
||||
class ExpenseClaimReturnFlowMixin:
|
||||
def return_claim(
|
||||
self,
|
||||
claim_id: str,
|
||||
current_user: CurrentUserContext,
|
||||
*,
|
||||
reason: str | None = None,
|
||||
reason_codes: list[str] | None = None,
|
||||
request_id: str | None = None,
|
||||
expected_status: str | None = None,
|
||||
expected_approval_stage: str | None = None,
|
||||
) -> ExpenseClaim | None:
|
||||
normalized_reason = str(reason or "").strip()
|
||||
reason_code_payload = self._normalize_return_reason_code_payload(reason_codes)
|
||||
normalized_codes = [
|
||||
*reason_code_payload["reason_codes"],
|
||||
*reason_code_payload["unknown_reason_codes"],
|
||||
]
|
||||
return self._execute_claim_action(
|
||||
action="return",
|
||||
claim_id=claim_id,
|
||||
current_user=current_user,
|
||||
request_id=request_id,
|
||||
expected_status=expected_status,
|
||||
expected_approval_stage=expected_approval_stage,
|
||||
payload={"reason": normalized_reason, "reason_codes": normalized_codes},
|
||||
executor=lambda claim, ledger, normalized_request_id: self._return_claim_once(
|
||||
claim,
|
||||
current_user,
|
||||
reason=normalized_reason,
|
||||
reason_codes=normalized_codes,
|
||||
ledger=ledger,
|
||||
request_id=normalized_request_id,
|
||||
),
|
||||
)
|
||||
|
||||
def _return_claim_once(
|
||||
self,
|
||||
claim: ExpenseClaim,
|
||||
current_user: CurrentUserContext,
|
||||
*,
|
||||
reason: str,
|
||||
reason_codes: list[str],
|
||||
ledger: ApprovalActionLedger,
|
||||
request_id: str,
|
||||
) -> ExpenseClaim:
|
||||
normalized_status = str(claim.status or "").strip().lower()
|
||||
if normalized_status == "draft":
|
||||
raise ValueError("草稿状态无需退回。")
|
||||
if normalized_status == "returned":
|
||||
raise ValueError("该单据已处于退回待提交状态,无需重复退回。")
|
||||
if normalized_status in {"approved", "completed", "paid"}:
|
||||
raise ValueError("已完成单据不允许退回。")
|
||||
|
||||
if not self._access_policy.can_return_claim(current_user, claim):
|
||||
raise ValueError("只有财务人员、高级财务人员或当前审批人可以退回报销单。")
|
||||
|
||||
before_json = self._serialize_claim(claim)
|
||||
operator = self._access_policy.resolve_current_user_display_name(current_user)
|
||||
previous_status = str(claim.status or "").strip()
|
||||
previous_stage = str(claim.approval_stage or "").strip() or "未标记审批环节"
|
||||
previous_stage_key = self._normalize_return_stage_key(previous_stage)
|
||||
is_application_claim = self._is_expense_application_claim(claim)
|
||||
is_direct_manager_return = previous_stage_key == "direct_manager"
|
||||
is_budget_return = previous_stage_key == "budget"
|
||||
is_application_return = is_application_claim and (
|
||||
is_direct_manager_return or is_budget_return
|
||||
)
|
||||
return_event_type = (
|
||||
"expense_application_return" if is_application_return else "expense_claim_return"
|
||||
)
|
||||
return_label = (
|
||||
"领导退回"
|
||||
if is_application_claim and is_direct_manager_return
|
||||
else "预算退回"
|
||||
if is_application_claim and is_budget_return
|
||||
else "人工退回"
|
||||
)
|
||||
reason_code_payload = self._normalize_return_reason_code_payload(reason_codes)
|
||||
normalized_reason_codes = reason_code_payload["reason_codes"]
|
||||
unknown_reason_codes = reason_code_payload["unknown_reason_codes"]
|
||||
if is_application_return and not any(
|
||||
code.startswith("application_") for code in normalized_reason_codes
|
||||
):
|
||||
raise ValueError("申请单退回必须选择至少一个退单类型。")
|
||||
risk_points = [RETURN_REASON_OPTIONS[code] for code in normalized_reason_codes]
|
||||
existing_return_flags = self._collect_return_flags(claim.risk_flags_json)
|
||||
return_count = len(existing_return_flags) + 1
|
||||
stage_return_count = (
|
||||
sum(
|
||||
1
|
||||
for flag in existing_return_flags
|
||||
if (
|
||||
str(flag.get("return_stage_key") or "").strip()
|
||||
or self._normalize_return_stage_key(str(flag.get("return_stage") or "").strip())
|
||||
)
|
||||
== previous_stage_key
|
||||
)
|
||||
+ 1
|
||||
)
|
||||
message = reason or self._build_default_return_message(
|
||||
operator=operator,
|
||||
risk_points=risk_points,
|
||||
)
|
||||
return_flag = {
|
||||
"source": "manual_return",
|
||||
"event_type": return_event_type,
|
||||
"return_event_id": str(uuid.uuid4()),
|
||||
"severity": "medium",
|
||||
"label": return_label,
|
||||
"node_key": "returned",
|
||||
"node_label": "退回",
|
||||
"approval_node": "退回",
|
||||
"message": message,
|
||||
"reason": reason,
|
||||
"opinion": message,
|
||||
"leader_opinion": message if is_application_claim and is_direct_manager_return else "",
|
||||
"budget_opinion": message if is_application_claim and is_budget_return else "",
|
||||
"reason_codes": normalized_reason_codes,
|
||||
"risk_points": risk_points,
|
||||
"operator": operator,
|
||||
"operator_username": current_user.username,
|
||||
"operator_role_codes": [
|
||||
str(item).strip().lower() for item in current_user.role_codes if str(item).strip()
|
||||
],
|
||||
"previous_status": previous_status,
|
||||
"previous_approval_stage": previous_stage,
|
||||
"return_stage": previous_stage,
|
||||
"return_stage_key": previous_stage_key,
|
||||
"next_status": "returned",
|
||||
"next_approval_stage": "待提交",
|
||||
"return_count": return_count,
|
||||
"stage_return_count": stage_return_count,
|
||||
"created_at": datetime.now(UTC).isoformat(),
|
||||
}
|
||||
if unknown_reason_codes:
|
||||
return_flag["unknown_reason_codes"] = unknown_reason_codes
|
||||
|
||||
budget_flags = self._release_budget_for_return(
|
||||
claim,
|
||||
current_user,
|
||||
reason=message,
|
||||
)
|
||||
claim.status = "returned"
|
||||
claim.approval_stage = "待提交"
|
||||
claim.submitted_at = None
|
||||
claim.risk_flags_json = self._append_budget_flags(
|
||||
[*list(claim.risk_flags_json or []), return_flag],
|
||||
budget_flags,
|
||||
business_stage=("expense_application" if is_application_claim else "reimbursement"),
|
||||
)
|
||||
|
||||
self._expense_cases.record_claim_event(
|
||||
claim,
|
||||
event_type=("application_returned" if is_application_claim else "claim_returned"),
|
||||
actor_id=current_user.username,
|
||||
tenant_id=getattr(current_user, "tenant_id", None),
|
||||
idempotency_key=str(return_flag.get("return_event_id") or ""),
|
||||
previous_status=previous_status,
|
||||
previous_approval_stage=previous_stage,
|
||||
extra_payload={
|
||||
"reason": message,
|
||||
"reason_codes": normalized_reason_codes,
|
||||
},
|
||||
)
|
||||
self.audit_service.log_action(
|
||||
actor=operator,
|
||||
action="expense_claim.return",
|
||||
resource_type="expense_claim",
|
||||
resource_id=claim.id,
|
||||
before_json=before_json,
|
||||
after_json=self._serialize_claim(claim),
|
||||
request_id=request_id,
|
||||
commit=False,
|
||||
)
|
||||
return claim
|
||||
@@ -2,7 +2,6 @@ from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
_SEVERITY_WEIGHT = {
|
||||
"critical": 0,
|
||||
"high": 1,
|
||||
@@ -12,6 +11,48 @@ _SEVERITY_WEIGHT = {
|
||||
"pass": 5,
|
||||
}
|
||||
|
||||
_BLOCKING_RISK_SEVERITIES = {"high", "critical", "danger"}
|
||||
_NON_RISK_SOURCES = {
|
||||
"application_detail",
|
||||
"application_handoff",
|
||||
"application_link",
|
||||
"application_link_sync",
|
||||
"application_submission",
|
||||
"approval",
|
||||
"approval_log",
|
||||
"approval_routing",
|
||||
"budget_approval",
|
||||
"expense_claim_approval",
|
||||
"expense_claim_finance_approval",
|
||||
"finance_approval",
|
||||
"manual_approval",
|
||||
"manual_return",
|
||||
"payment",
|
||||
"reminder",
|
||||
"sla_reminder",
|
||||
"urge",
|
||||
}
|
||||
_NON_RISK_EVENTS = {
|
||||
"expense_application_budget_approval",
|
||||
"expense_application_reimbursement_deleted",
|
||||
"expense_application_submission",
|
||||
"expense_application_to_reimbursement_draft",
|
||||
"expense_claim_approval",
|
||||
"expense_claim_finance_approval",
|
||||
"expense_claim_payment_completed",
|
||||
"expense_reimbursement_application_linked",
|
||||
"reminder",
|
||||
"sla_reminder",
|
||||
"urge",
|
||||
}
|
||||
_CLOSED_RISK_STATES = {
|
||||
"accepted",
|
||||
"false_positive",
|
||||
"ignored",
|
||||
"resolved",
|
||||
"waived",
|
||||
}
|
||||
|
||||
|
||||
def _text(value: Any) -> str:
|
||||
return str(value or "").strip()
|
||||
@@ -22,6 +63,74 @@ def _severity_weight(flag: dict[str, Any]) -> int:
|
||||
return _SEVERITY_WEIGHT.get(severity, 9)
|
||||
|
||||
|
||||
def claim_risk_flag_severity(flag: dict[str, Any]) -> str:
|
||||
severity = _text(
|
||||
flag.get("severity")
|
||||
or flag.get("risk_level")
|
||||
or flag.get("riskLevel")
|
||||
or flag.get("tone")
|
||||
or flag.get("level")
|
||||
).lower()
|
||||
return "high" if severity == "danger" else severity
|
||||
|
||||
|
||||
def is_open_claim_risk_flag(flag: dict[str, Any]) -> bool:
|
||||
"""识别仍需处理的业务风险,排除审批、付款等流程轨迹。"""
|
||||
|
||||
source = _text(flag.get("source")).lower()
|
||||
event_type = _text(flag.get("event_type") or flag.get("eventType")).lower()
|
||||
if source in _NON_RISK_SOURCES or event_type in _NON_RISK_EVENTS:
|
||||
return False
|
||||
if _text(flag.get("actionability")).lower() == "system_trace":
|
||||
return False
|
||||
states = {
|
||||
_text(flag.get("resolution_status") or flag.get("resolutionStatus")).lower(),
|
||||
_text(flag.get("status")).lower(),
|
||||
_text(flag.get("feedback_status") or flag.get("feedbackStatus")).lower(),
|
||||
_text(flag.get("adjudication")).lower(),
|
||||
}
|
||||
if states & _CLOSED_RISK_STATES or bool(flag.get("resolved")):
|
||||
return False
|
||||
severity = claim_risk_flag_severity(flag)
|
||||
return bool(
|
||||
severity in {"medium", *_BLOCKING_RISK_SEVERITIES}
|
||||
or flag.get("triggered") is True
|
||||
or _text(flag.get("disposition")).lower() in {"fix", "review"}
|
||||
)
|
||||
|
||||
|
||||
def is_blocking_claim_risk_flag(flag: dict[str, Any]) -> bool:
|
||||
if not (
|
||||
is_open_claim_risk_flag(flag)
|
||||
and claim_risk_flag_severity(flag) in _BLOCKING_RISK_SEVERITIES
|
||||
):
|
||||
return False
|
||||
actionability = _text(flag.get("actionability")).lower()
|
||||
disposition = _text(flag.get("disposition")).lower()
|
||||
return actionability not in {"advisory_only", "route_review"} and disposition != "review"
|
||||
|
||||
|
||||
def claim_risk_flag_observation_key(
|
||||
flag: dict[str, Any],
|
||||
*,
|
||||
claim_id: str,
|
||||
) -> str:
|
||||
explicit_key = _text(flag.get("observation_key") or flag.get("observationKey"))
|
||||
if explicit_key:
|
||||
return explicit_key
|
||||
source = _text(flag.get("source")).lower()
|
||||
hit_source = _text(flag.get("hit_source") or flag.get("hitSource")).lower()
|
||||
rule_code = _text(flag.get("rule_code") or flag.get("ruleCode"))
|
||||
signal = _text(flag.get("risk_signal") or flag.get("riskSignal"))
|
||||
if source in {"platform_risk", "platform_risk_rule", "rule_center"} or hit_source == (
|
||||
"rule_center"
|
||||
):
|
||||
suffix = rule_code or signal
|
||||
if suffix:
|
||||
return f"risk:{claim_id}:platform:{suffix}"
|
||||
return ""
|
||||
|
||||
|
||||
def _list_values(value: Any) -> list[Any]:
|
||||
if isinstance(value, list):
|
||||
return value
|
||||
@@ -138,7 +247,9 @@ def dedupe_claim_risk_flags(flags: list[Any] | None) -> list[Any]:
|
||||
continue
|
||||
|
||||
other_weight = _severity_weight(other)
|
||||
if other_weight < current_weight or (other_weight == current_weight and other_index < index):
|
||||
if other_weight < current_weight or (
|
||||
other_weight == current_weight and other_index < index
|
||||
):
|
||||
is_shadowed = True
|
||||
break
|
||||
|
||||
|
||||
148
server/src/app/services/expense_claim_risk_gate.py
Normal file
148
server/src/app/services/expense_claim_risk_gate.py
Normal file
@@ -0,0 +1,148 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from sqlalchemy import select
|
||||
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.expense_claim_risk_flags import (
|
||||
claim_risk_flag_observation_key,
|
||||
claim_risk_flag_severity,
|
||||
is_blocking_claim_risk_flag,
|
||||
)
|
||||
from app.services.expense_claim_tenant_scope import ExpenseClaimTenantScopeMixin
|
||||
|
||||
_BLOCKING_LEVELS = {"high", "critical", "danger"}
|
||||
_LEGACY_CLOSED_STATUSES = {"false_positive", "ignored", "resolved"}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class BlockingRiskObservation:
|
||||
observation_id: str
|
||||
title: str
|
||||
risk_level: str
|
||||
adjudication: str
|
||||
lifecycle_status: str
|
||||
|
||||
|
||||
class ExpenseClaimRiskBlockedError(ValueError):
|
||||
def __init__(self, blockers: list[BlockingRiskObservation]) -> None:
|
||||
self.blockers = blockers
|
||||
titles = "、".join(item.title for item in blockers[:3])
|
||||
suffix = f"等 {len(blockers)} 项" if len(blockers) > 3 else ""
|
||||
super().__init__(
|
||||
"存在尚未完成处置的高风险观察,暂不能审批通过:"
|
||||
f"{titles}{suffix}。请先在风险证据链中确认并关闭风险,或标记为误报。"
|
||||
)
|
||||
|
||||
|
||||
class ExpenseClaimRiskGate:
|
||||
"""把持久化风险处置状态变成审批前的强制业务门禁。"""
|
||||
|
||||
def __init__(self, db: Session) -> None:
|
||||
self.db = db
|
||||
|
||||
def find_blockers(
|
||||
self,
|
||||
claim: ExpenseClaim,
|
||||
*,
|
||||
tenant_id: str | None,
|
||||
) -> list[BlockingRiskObservation]:
|
||||
normalized_tenant = ExpenseClaimTenantScopeMixin.normalize_tenant_id(tenant_id)
|
||||
rows = self.db.execute(
|
||||
select(RiskObservation, RiskDisposition)
|
||||
.outerjoin(
|
||||
RiskDisposition,
|
||||
(
|
||||
(RiskDisposition.tenant_id == RiskObservation.tenant_id)
|
||||
& (RiskDisposition.observation_id == RiskObservation.id)
|
||||
),
|
||||
)
|
||||
.where(
|
||||
RiskObservation.tenant_id == normalized_tenant,
|
||||
RiskObservation.claim_id == claim.id,
|
||||
)
|
||||
.order_by(
|
||||
RiskObservation.risk_score.desc(),
|
||||
RiskObservation.created_at.desc(),
|
||||
)
|
||||
).all()
|
||||
|
||||
blockers: list[BlockingRiskObservation] = []
|
||||
materialized_keys: set[str] = set()
|
||||
for observation, disposition in rows:
|
||||
materialized_keys.add(str(observation.observation_key or "").strip())
|
||||
level = str(observation.risk_level or "").strip().lower()
|
||||
if level not in _BLOCKING_LEVELS:
|
||||
continue
|
||||
if self._is_closed(observation, disposition):
|
||||
continue
|
||||
blockers.append(
|
||||
BlockingRiskObservation(
|
||||
observation_id=observation.id,
|
||||
title=str(observation.title or observation.risk_signal or "高风险").strip(),
|
||||
risk_level=level,
|
||||
adjudication=(
|
||||
str(disposition.adjudication or "unreviewed").strip().lower()
|
||||
if disposition is not None
|
||||
else str(observation.feedback_status or "unreviewed").strip().lower()
|
||||
),
|
||||
lifecycle_status=(
|
||||
str(disposition.lifecycle_status or "open").strip().lower()
|
||||
if disposition is not None
|
||||
else "open"
|
||||
),
|
||||
)
|
||||
)
|
||||
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
|
||||
observation_key = claim_risk_flag_observation_key(flag, claim_id=claim.id)
|
||||
if observation_key and observation_key in materialized_keys:
|
||||
# 完整物化后的处置投影优先;仅对没有 Observation 的高风险兜底。
|
||||
continue
|
||||
blockers.append(
|
||||
BlockingRiskObservation(
|
||||
observation_id=observation_key or f"raw:{claim.id}:{index}",
|
||||
title=str(
|
||||
flag.get("label")
|
||||
or flag.get("title")
|
||||
or flag.get("message")
|
||||
or "未物化高风险"
|
||||
).strip(),
|
||||
risk_level=claim_risk_flag_severity(flag),
|
||||
adjudication=str(flag.get("adjudication") or "unreviewed").strip().lower(),
|
||||
lifecycle_status=str(
|
||||
flag.get("lifecycle_status") or flag.get("lifecycleStatus") or "open"
|
||||
)
|
||||
.strip()
|
||||
.lower(),
|
||||
)
|
||||
)
|
||||
return blockers
|
||||
|
||||
def ensure_approvable(
|
||||
self,
|
||||
claim: ExpenseClaim,
|
||||
*,
|
||||
tenant_id: str | None,
|
||||
) -> None:
|
||||
blockers = self.find_blockers(claim, tenant_id=tenant_id)
|
||||
if blockers:
|
||||
raise ExpenseClaimRiskBlockedError(blockers)
|
||||
|
||||
@staticmethod
|
||||
def _is_closed(
|
||||
observation: RiskObservation,
|
||||
disposition: RiskDisposition | None,
|
||||
) -> bool:
|
||||
if disposition is not None:
|
||||
adjudication = str(disposition.adjudication or "").strip().lower()
|
||||
lifecycle = str(disposition.lifecycle_status or "").strip().lower()
|
||||
return adjudication == "false_positive" or lifecycle == "resolved"
|
||||
status = str(observation.status or "").strip().lower()
|
||||
feedback_status = str(observation.feedback_status or "").strip().lower()
|
||||
return status in _LEGACY_CLOSED_STATUSES or feedback_status == "false_positive"
|
||||
@@ -15,7 +15,10 @@ from app.services.expense_claim_constants import (
|
||||
from app.services.expense_claim_item_sync import ExpenseClaimItemSyncMixin
|
||||
from app.services.expense_claim_platform_risk import ExpenseClaimPlatformRiskMixin
|
||||
from app.services.expense_claim_policy_review import ExpenseClaimPolicyReviewMixin
|
||||
from app.services.expense_claim_risk_flags import dedupe_claim_risk_flags
|
||||
from app.services.expense_claim_risk_flags import (
|
||||
dedupe_claim_risk_flags,
|
||||
is_blocking_claim_risk_flag,
|
||||
)
|
||||
from app.services.expense_claim_risk_stage import with_risk_business_stage
|
||||
from app.services.expense_claim_tenant_scope import ExpenseClaimTenantScopeMixin
|
||||
from app.services.risk_observations import RiskObservationService
|
||||
@@ -98,11 +101,15 @@ class ExpenseClaimRiskReviewMixin(
|
||||
claim,
|
||||
platform_risk_flags,
|
||||
)
|
||||
except Exception:
|
||||
except Exception as error:
|
||||
logger.exception(
|
||||
"Failed to persist platform risk observations for claim_id=%s",
|
||||
claim.id,
|
||||
)
|
||||
if any(is_blocking_claim_risk_flag(flag) for flag in platform_risk_flags):
|
||||
raise RuntimeError(
|
||||
"高风险观察持久化失败,已停止提交;请稍后重试或联系管理员。"
|
||||
) from error
|
||||
|
||||
review_flags = [with_risk_business_stage(flag, "reimbursement") for flag in review_flags]
|
||||
final_risk_flags = dedupe_claim_risk_flags([*preserved_flags, *review_flags])
|
||||
@@ -111,9 +118,7 @@ class ExpenseClaimRiskReviewMixin(
|
||||
"status": "submitted",
|
||||
"approval_stage": "直属领导审批",
|
||||
"risk_flags": final_risk_flags,
|
||||
"rule_set_fingerprint": str(
|
||||
platform_risk_review.get("rule_set_fingerprint") or ""
|
||||
),
|
||||
"rule_set_fingerprint": str(platform_risk_review.get("rule_set_fingerprint") or ""),
|
||||
"message": (
|
||||
f"报销单 {claim.claim_no} 已完成自动检测,"
|
||||
f"现已提交给直属领导 {manager_name or '审批人'} 审批。"
|
||||
@@ -159,4 +164,3 @@ class ExpenseClaimRiskReviewMixin(
|
||||
)
|
||||
recent_claims = list(self.db.scalars(stmt).all())
|
||||
return sum(1 for item in recent_claims if list(item.risk_flags_json or []))
|
||||
|
||||
|
||||
@@ -4,7 +4,10 @@ from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from app.models.financial_record import ExpenseClaim
|
||||
from app.services.expense_claim_risk_stage import risk_business_stage_for_claim, with_risk_business_stage
|
||||
from app.services.expense_claim_risk_stage import (
|
||||
risk_business_stage_for_claim,
|
||||
with_risk_business_stage,
|
||||
)
|
||||
from app.services.expense_claim_workflow_constants import (
|
||||
BUDGET_MANAGER_APPROVAL_STAGE,
|
||||
DIRECT_MANAGER_APPROVAL_STAGE,
|
||||
@@ -13,18 +16,17 @@ from app.services.expense_claim_workflow_constants import (
|
||||
|
||||
|
||||
class ExpenseClaimWorkflowRepairMixin:
|
||||
def _repair_duplicate_budget_approval_stages(self, claims: list[ExpenseClaim]) -> None:
|
||||
repaired_claims = [
|
||||
def _repair_duplicate_budget_approval_stages(
|
||||
self,
|
||||
claims: list[ExpenseClaim],
|
||||
) -> list[ExpenseClaim]:
|
||||
"""只修改当前事务中的对象,不在底层辅助方法里提交事务。"""
|
||||
|
||||
return [
|
||||
claim
|
||||
for claim in claims
|
||||
if claim is not None and self._repair_duplicate_budget_approval_stage(claim)
|
||||
]
|
||||
if not repaired_claims:
|
||||
return
|
||||
|
||||
self.db.commit()
|
||||
for claim in repaired_claims:
|
||||
self.db.refresh(claim)
|
||||
|
||||
def _repair_duplicate_budget_approval_stage(self, claim: ExpenseClaim) -> bool:
|
||||
if self._is_expense_application_claim(claim):
|
||||
@@ -54,7 +56,8 @@ class ExpenseClaimWorkflowRepairMixin:
|
||||
if isinstance(flag, dict)
|
||||
and str(flag.get("source") or "").strip() == "manual_approval"
|
||||
and str(flag.get("event_type") or "").strip() == "expense_claim_approval"
|
||||
and str(flag.get("previous_approval_stage") or "").strip() == DIRECT_MANAGER_APPROVAL_STAGE
|
||||
and str(flag.get("previous_approval_stage") or "").strip()
|
||||
== DIRECT_MANAGER_APPROVAL_STAGE
|
||||
and str(flag.get("next_approval_stage") or "").strip() == BUDGET_MANAGER_APPROVAL_STAGE
|
||||
]
|
||||
for flag in reversed(flags):
|
||||
@@ -74,11 +77,14 @@ class ExpenseClaimWorkflowRepairMixin:
|
||||
return any(
|
||||
isinstance(flag, dict)
|
||||
and str(flag.get("source") or "").strip() == "approval_flow_repair"
|
||||
and str(flag.get("event_type") or "").strip() == "duplicate_budget_approval_stage_repaired"
|
||||
and str(flag.get("event_type") or "").strip()
|
||||
== "duplicate_budget_approval_stage_repaired"
|
||||
for flag in list(claim.risk_flags_json or [])
|
||||
)
|
||||
|
||||
def _build_duplicate_budget_stage_repair_flag(self, approval_event: dict[str, Any]) -> dict[str, Any]:
|
||||
def _build_duplicate_budget_stage_repair_flag(
|
||||
self, approval_event: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
return with_risk_business_stage(
|
||||
{
|
||||
"source": "approval_flow_repair",
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime
|
||||
from decimal import Decimal
|
||||
@@ -25,6 +24,7 @@ from app.services.budget_types import BudgetControlError
|
||||
from app.services.document_numbering import is_application_claim_no
|
||||
from app.services.expense_cases import ExpenseCaseService
|
||||
from app.services.expense_claim_access_policy import ExpenseClaimAccessPolicy
|
||||
from app.services.expense_claim_action_protocol import ExpenseClaimActionProtocolMixin
|
||||
from app.services.expense_claim_application_handoff import ExpenseClaimApplicationHandoffMixin
|
||||
from app.services.expense_claim_approval_flow import ExpenseClaimApprovalFlowMixin
|
||||
from app.services.expense_claim_approval_routing import ExpenseClaimApprovalRoutingMixin
|
||||
@@ -34,7 +34,6 @@ from app.services.expense_claim_attachment_operations import ExpenseClaimAttachm
|
||||
from app.services.expense_claim_attachment_presentation import ExpenseClaimAttachmentPresentation
|
||||
from app.services.expense_claim_attachment_storage import ExpenseClaimAttachmentStorage
|
||||
from app.services.expense_claim_budget_flow import ExpenseClaimBudgetFlowMixin
|
||||
from app.services.expense_claim_constants import RETURN_REASON_OPTIONS
|
||||
from app.services.expense_claim_document_item_builder import ExpenseClaimDocumentItemBuilderMixin
|
||||
from app.services.expense_claim_document_parsing import ExpenseClaimDocumentParsingMixin
|
||||
from app.services.expense_claim_draft_flow import ExpenseClaimDraftFlowMixin
|
||||
@@ -51,6 +50,7 @@ from app.services.expense_claim_pre_review_decision import (
|
||||
pre_review_public_payload,
|
||||
)
|
||||
from app.services.expense_claim_read_model import ExpenseClaimReadModelMixin
|
||||
from app.services.expense_claim_return_flow import ExpenseClaimReturnFlowMixin
|
||||
from app.services.expense_claim_review_preview import ExpenseClaimReviewPreviewMixin
|
||||
from app.services.expense_claim_risk_flags import dedupe_claim_risk_flags
|
||||
from app.services.expense_claim_risk_review import ExpenseClaimRiskReviewMixin
|
||||
@@ -87,7 +87,10 @@ class ExpenseClaimItemActionMixin:
|
||||
if payload.item_date is not None:
|
||||
item.item_date = payload.item_date
|
||||
if payload.item_type is not None:
|
||||
item.item_type = self._normalize_optional_text(payload.item_type, fallback=item.item_type) or item.item_type
|
||||
item.item_type = (
|
||||
self._normalize_optional_text(payload.item_type, fallback=item.item_type)
|
||||
or item.item_type
|
||||
)
|
||||
if payload.item_reason is not None:
|
||||
item.item_reason = (
|
||||
self._normalize_optional_text(payload.item_reason, allow_empty=True) or ""
|
||||
@@ -97,7 +100,9 @@ class ExpenseClaimItemActionMixin:
|
||||
self._normalize_optional_text(payload.item_location, allow_empty=True) or ""
|
||||
)
|
||||
if payload.item_note is not None:
|
||||
item.item_note = self._normalize_optional_text(payload.item_note, allow_empty=True) or ""
|
||||
item.item_note = (
|
||||
self._normalize_optional_text(payload.item_note, allow_empty=True) or ""
|
||||
)
|
||||
if payload.item_amount is not None:
|
||||
amount = payload.item_amount.quantize(Decimal("0.01"))
|
||||
if amount < Decimal("0.00"):
|
||||
@@ -203,7 +208,9 @@ class ExpenseClaimItemActionMixin:
|
||||
|
||||
self._ensure_draft_claim(claim)
|
||||
before_json = self._serialize_claim(claim)
|
||||
item_label = str(item.item_reason or "").strip() or self._resolve_expense_type_label(item.item_type)
|
||||
item_label = str(item.item_reason or "").strip() or self._resolve_expense_type_label(
|
||||
item.item_type
|
||||
)
|
||||
|
||||
self._attachment_storage.delete_item_files(item)
|
||||
claim.items = [entry for entry in claim.items if entry.id != item.id]
|
||||
@@ -266,8 +273,7 @@ class ExpenseClaimItemActionMixin:
|
||||
raise RuntimeError("无法生成提交前预审结果。")
|
||||
|
||||
client_review_provided = bool(
|
||||
str(pre_review_id or "").strip()
|
||||
or str(pre_review_input_fingerprint or "").strip()
|
||||
str(pre_review_id or "").strip() or str(pre_review_input_fingerprint or "").strip()
|
||||
)
|
||||
client_review_matches = pre_review_identity_matches(
|
||||
pre_review_flag,
|
||||
@@ -423,7 +429,9 @@ class ExpenseClaimItemActionMixin:
|
||||
if not self._access_policy.has_claim_delete_access(current_user):
|
||||
self._ensure_draft_claim(claim)
|
||||
if not self._access_policy.is_claim_owned_by_current_user(claim, current_user):
|
||||
raise ValueError("只有系统管理员或草稿、待补充、退回待提交阶段的申请人本人可以删除单据。")
|
||||
raise ValueError(
|
||||
"只有系统管理员或草稿、待补充、退回待提交阶段的申请人本人可以删除单据。"
|
||||
)
|
||||
|
||||
before_json = self._serialize_claim(claim)
|
||||
resource_id = claim.id
|
||||
@@ -464,154 +472,31 @@ class ExpenseClaimItemActionMixin:
|
||||
self.db.execute(delete(RiskObservation).where(RiskObservation.claim_id == claim_id))
|
||||
self.db.execute(delete(HermesRiskReport).where(HermesRiskReport.claim_id == claim_id))
|
||||
|
||||
def return_claim(
|
||||
self,
|
||||
claim_id: str,
|
||||
current_user: CurrentUserContext,
|
||||
*,
|
||||
reason: str | None = None,
|
||||
reason_codes: list[str] | None = None,
|
||||
) -> ExpenseClaim | None:
|
||||
claim = self.get_claim(claim_id, current_user)
|
||||
if claim is None:
|
||||
return None
|
||||
|
||||
normalized_status = str(claim.status or "").strip().lower()
|
||||
if normalized_status == "draft":
|
||||
raise ValueError("草稿状态无需退回。")
|
||||
if normalized_status == "returned":
|
||||
raise ValueError("该单据已处于退回待提交状态,无需重复退回。")
|
||||
if normalized_status in {"approved", "completed", "paid"}:
|
||||
raise ValueError("已完成单据不允许退回。")
|
||||
|
||||
if not self._access_policy.can_return_claim(current_user, claim):
|
||||
raise ValueError("只有财务人员、高级财务人员或当前审批人可以退回报销单。")
|
||||
|
||||
before_json = self._serialize_claim(claim)
|
||||
operator = self._access_policy.resolve_current_user_display_name(current_user)
|
||||
previous_status = str(claim.status or "").strip()
|
||||
previous_stage = str(claim.approval_stage or "").strip() or "未标记审批环节"
|
||||
previous_stage_key = self._normalize_return_stage_key(previous_stage)
|
||||
is_application_claim = self._is_expense_application_claim(claim)
|
||||
is_direct_manager_return = previous_stage_key == "direct_manager"
|
||||
is_budget_return = previous_stage_key == "budget"
|
||||
is_application_return = is_application_claim and (is_direct_manager_return or is_budget_return)
|
||||
return_event_type = (
|
||||
"expense_application_return"
|
||||
if is_application_return
|
||||
else "expense_claim_return"
|
||||
)
|
||||
return_label = (
|
||||
"领导退回"
|
||||
if is_application_claim and is_direct_manager_return
|
||||
else "预算退回"
|
||||
if is_application_claim and is_budget_return
|
||||
else "人工退回"
|
||||
)
|
||||
return_reason = str(reason or "").strip()
|
||||
reason_code_payload = self._normalize_return_reason_code_payload(reason_codes)
|
||||
normalized_reason_codes = reason_code_payload["reason_codes"]
|
||||
unknown_reason_codes = reason_code_payload["unknown_reason_codes"]
|
||||
if is_application_return and not any(
|
||||
code.startswith("application_") for code in normalized_reason_codes
|
||||
):
|
||||
raise ValueError("申请单退回必须选择至少一个退单类型。")
|
||||
risk_points = [RETURN_REASON_OPTIONS[code] for code in normalized_reason_codes]
|
||||
existing_return_flags = self._collect_return_flags(claim.risk_flags_json)
|
||||
return_count = len(existing_return_flags) + 1
|
||||
stage_return_count = (
|
||||
sum(
|
||||
1
|
||||
for flag in existing_return_flags
|
||||
if (
|
||||
str(flag.get("return_stage_key") or "").strip()
|
||||
or self._normalize_return_stage_key(str(flag.get("return_stage") or "").strip())
|
||||
)
|
||||
== previous_stage_key
|
||||
)
|
||||
+ 1
|
||||
)
|
||||
message = return_reason or self._build_default_return_message(operator=operator, risk_points=risk_points)
|
||||
return_flag = {
|
||||
"source": "manual_return",
|
||||
"event_type": return_event_type,
|
||||
"return_event_id": str(uuid.uuid4()),
|
||||
"severity": "medium",
|
||||
"label": return_label,
|
||||
"node_key": "returned",
|
||||
"node_label": "退回",
|
||||
"approval_node": "退回",
|
||||
"message": message,
|
||||
"reason": return_reason,
|
||||
"opinion": message,
|
||||
"leader_opinion": message if is_application_claim and is_direct_manager_return else "",
|
||||
"budget_opinion": message if is_application_claim and is_budget_return else "",
|
||||
"reason_codes": normalized_reason_codes,
|
||||
"risk_points": risk_points,
|
||||
"operator": operator,
|
||||
"operator_username": current_user.username,
|
||||
"operator_role_codes": [
|
||||
str(item).strip().lower()
|
||||
for item in current_user.role_codes
|
||||
if str(item).strip()
|
||||
],
|
||||
"previous_status": previous_status,
|
||||
"previous_approval_stage": previous_stage,
|
||||
"return_stage": previous_stage,
|
||||
"return_stage_key": previous_stage_key,
|
||||
"next_status": "returned",
|
||||
"next_approval_stage": "待提交",
|
||||
"return_count": return_count,
|
||||
"stage_return_count": stage_return_count,
|
||||
"created_at": datetime.now(UTC).isoformat(),
|
||||
}
|
||||
if unknown_reason_codes:
|
||||
return_flag["unknown_reason_codes"] = unknown_reason_codes
|
||||
|
||||
budget_flags = self._release_budget_for_return(
|
||||
claim,
|
||||
current_user,
|
||||
reason=message,
|
||||
)
|
||||
claim.status = "returned"
|
||||
claim.approval_stage = "待提交"
|
||||
claim.submitted_at = None
|
||||
claim.risk_flags_json = self._append_budget_flags(
|
||||
[*list(claim.risk_flags_json or []), return_flag],
|
||||
budget_flags,
|
||||
business_stage="expense_application" if is_application_claim else "reimbursement",
|
||||
)
|
||||
|
||||
self._expense_cases.record_claim_event(
|
||||
claim,
|
||||
event_type=("application_returned" if is_application_claim else "claim_returned"),
|
||||
actor_id=current_user.username,
|
||||
tenant_id=getattr(current_user, "tenant_id", None),
|
||||
idempotency_key=str(return_flag.get("return_event_id") or ""),
|
||||
previous_status=previous_status,
|
||||
previous_approval_stage=previous_stage,
|
||||
extra_payload={
|
||||
"reason": message,
|
||||
"reason_codes": normalized_reason_codes,
|
||||
},
|
||||
)
|
||||
|
||||
self.db.commit()
|
||||
self.db.refresh(claim)
|
||||
|
||||
self.audit_service.log_action(
|
||||
actor=operator,
|
||||
action="expense_claim.return",
|
||||
resource_type="expense_claim",
|
||||
resource_id=claim.id,
|
||||
before_json=before_json,
|
||||
after_json=self._serialize_claim(claim),
|
||||
)
|
||||
|
||||
return claim
|
||||
|
||||
|
||||
class ExpenseClaimService(ExpenseClaimStandardAdjustmentMixin, ExpenseClaimItemActionMixin, ExpenseClaimPaginationMixin, ExpenseClaimApprovalFlowMixin, ExpenseClaimApprovalRoutingMixin, ExpenseClaimApplicationHandoffMixin, ExpenseClaimPreReviewMixin, ExpenseClaimBudgetFlowMixin, ExpenseClaimAttachmentOperationsMixin, ExpenseClaimReviewPreviewMixin, ExpenseClaimDraftFlowMixin, ExpenseClaimDraftPersistenceMixin, ExpenseClaimDocumentItemBuilderMixin, ExpenseClaimDocumentParsingMixin, ExpenseClaimOntologyResolverMixin, ExpenseClaimAttachmentDocumentMixin, ExpenseClaimAttachmentAnalysisMixin, ExpenseClaimReadModelMixin, ExpenseClaimRiskReviewMixin, ExpenseClaimWorkflowRepairMixin):
|
||||
class ExpenseClaimService(
|
||||
ExpenseClaimActionProtocolMixin,
|
||||
ExpenseClaimReturnFlowMixin,
|
||||
ExpenseClaimStandardAdjustmentMixin,
|
||||
ExpenseClaimItemActionMixin,
|
||||
ExpenseClaimPaginationMixin,
|
||||
ExpenseClaimApprovalFlowMixin,
|
||||
ExpenseClaimApprovalRoutingMixin,
|
||||
ExpenseClaimApplicationHandoffMixin,
|
||||
ExpenseClaimPreReviewMixin,
|
||||
ExpenseClaimBudgetFlowMixin,
|
||||
ExpenseClaimAttachmentOperationsMixin,
|
||||
ExpenseClaimReviewPreviewMixin,
|
||||
ExpenseClaimDraftFlowMixin,
|
||||
ExpenseClaimDraftPersistenceMixin,
|
||||
ExpenseClaimDocumentItemBuilderMixin,
|
||||
ExpenseClaimDocumentParsingMixin,
|
||||
ExpenseClaimOntologyResolverMixin,
|
||||
ExpenseClaimAttachmentDocumentMixin,
|
||||
ExpenseClaimAttachmentAnalysisMixin,
|
||||
ExpenseClaimReadModelMixin,
|
||||
ExpenseClaimRiskReviewMixin,
|
||||
ExpenseClaimWorkflowRepairMixin,
|
||||
):
|
||||
def __init__(self, db: Session) -> None:
|
||||
self.db = db
|
||||
self.audit_service = AuditLogService(db)
|
||||
@@ -624,11 +509,15 @@ class ExpenseClaimService(ExpenseClaimStandardAdjustmentMixin, ExpenseClaimItemA
|
||||
def _is_expense_application_claim(claim: ExpenseClaim) -> bool:
|
||||
claim_no = str(getattr(claim, "claim_no", "") or "").strip().upper()
|
||||
expense_type = str(getattr(claim, "expense_type", "") or "").strip().lower()
|
||||
document_type = str(
|
||||
getattr(claim, "document_type_code", "")
|
||||
or getattr(claim, "document_type", "")
|
||||
or ""
|
||||
).strip().lower()
|
||||
document_type = (
|
||||
str(
|
||||
getattr(claim, "document_type_code", "")
|
||||
or getattr(claim, "document_type", "")
|
||||
or ""
|
||||
)
|
||||
.strip()
|
||||
.lower()
|
||||
)
|
||||
return (
|
||||
is_application_claim_no(claim_no)
|
||||
or expense_type == "application"
|
||||
@@ -667,7 +556,6 @@ class ExpenseClaimService(ExpenseClaimStandardAdjustmentMixin, ExpenseClaimItemA
|
||||
)
|
||||
stmt = self._access_policy.apply_claim_scope(stmt, current_user)
|
||||
claims = list(self.db.scalars(stmt).all())
|
||||
self._repair_duplicate_budget_approval_stages(claims)
|
||||
return self._access_policy.attach_budget_approval_snapshots(claims)
|
||||
|
||||
def list_approval_claims(self, current_user: CurrentUserContext) -> list[ExpenseClaim]:
|
||||
@@ -683,7 +571,6 @@ class ExpenseClaimService(ExpenseClaimStandardAdjustmentMixin, ExpenseClaimItemA
|
||||
)
|
||||
stmt = self._access_policy.apply_approval_claim_scope(stmt, current_user)
|
||||
claims = list(self.db.scalars(stmt).all())
|
||||
self._repair_duplicate_budget_approval_stages(claims)
|
||||
return self._access_policy.attach_budget_approval_snapshots(claims)
|
||||
|
||||
def list_archived_claims(self, current_user: CurrentUserContext) -> list[ExpenseClaim]:
|
||||
@@ -695,7 +582,11 @@ class ExpenseClaimService(ExpenseClaimStandardAdjustmentMixin, ExpenseClaimItemA
|
||||
selectinload(ExpenseClaim.employee).selectinload(Employee.organization_unit),
|
||||
selectinload(ExpenseClaim.employee).selectinload(Employee.roles),
|
||||
)
|
||||
.order_by(ExpenseClaim.updated_at.desc(), ExpenseClaim.submitted_at.desc(), ExpenseClaim.created_at.desc())
|
||||
.order_by(
|
||||
ExpenseClaim.updated_at.desc(),
|
||||
ExpenseClaim.submitted_at.desc(),
|
||||
ExpenseClaim.created_at.desc(),
|
||||
)
|
||||
)
|
||||
stmt = self._access_policy.apply_archived_claim_scope(stmt, current_user)
|
||||
return list(self.db.scalars(stmt).all())
|
||||
@@ -711,13 +602,15 @@ class ExpenseClaimService(ExpenseClaimStandardAdjustmentMixin, ExpenseClaimItemA
|
||||
)
|
||||
.where(ExpenseClaim.id == claim_id)
|
||||
)
|
||||
stmt = self._access_policy.apply_claim_scope(stmt, current_user, include_approval_scope=True)
|
||||
stmt = self._access_policy.apply_claim_scope(
|
||||
stmt, current_user, include_approval_scope=True
|
||||
)
|
||||
claim = self.db.scalar(stmt)
|
||||
if claim is not None:
|
||||
self._repair_duplicate_budget_approval_stages([claim])
|
||||
return self._access_policy.attach_approval_snapshot(claim)
|
||||
|
||||
def can_view_budget_analysis(self, current_user: CurrentUserContext, claim: ExpenseClaim | None = None) -> bool:
|
||||
def can_view_budget_analysis(
|
||||
self, current_user: CurrentUserContext, claim: ExpenseClaim | None = None
|
||||
) -> bool:
|
||||
if claim is None:
|
||||
return self._access_policy.is_budget_manager_user(current_user)
|
||||
if current_user.is_admin:
|
||||
@@ -725,10 +618,9 @@ class ExpenseClaimService(ExpenseClaimStandardAdjustmentMixin, ExpenseClaimItemA
|
||||
role_codes = self._access_policy.normalize_role_codes(current_user)
|
||||
if "executive" in role_codes:
|
||||
return True
|
||||
if (
|
||||
self._access_policy.has_privileged_claim_access(current_user)
|
||||
and not self._access_policy.is_claim_owned_by_current_user(claim, current_user)
|
||||
):
|
||||
if self._access_policy.has_privileged_claim_access(
|
||||
current_user
|
||||
) and not self._access_policy.is_claim_owned_by_current_user(claim, current_user):
|
||||
return True
|
||||
if self._access_policy.can_approve_claim(current_user, claim):
|
||||
return True
|
||||
@@ -751,7 +643,9 @@ class ExpenseClaimService(ExpenseClaimStandardAdjustmentMixin, ExpenseClaimItemA
|
||||
before_json = self._serialize_claim(claim)
|
||||
|
||||
if payload.reason is not None:
|
||||
claim.reason = self._normalize_optional_text(payload.reason, allow_empty=True) or "待补充"
|
||||
claim.reason = (
|
||||
self._normalize_optional_text(payload.reason, allow_empty=True) or "待补充"
|
||||
)
|
||||
|
||||
if not self._is_expense_application_claim(claim):
|
||||
self._refresh_claim_pre_review_flags(claim, is_application_claim=False)
|
||||
|
||||
@@ -40,63 +40,72 @@ class HermesRiskScannerService:
|
||||
observation_service = RiskObservationService(self.db)
|
||||
|
||||
observation_count = 0
|
||||
scanned_claim_count = 0
|
||||
graph_node_count = 0
|
||||
graph_edge_count = 0
|
||||
for tenant_id, tenant_claims in self._group_claims_by_tenant(claims).items():
|
||||
now = datetime.now(timezone.utc)
|
||||
grouped_claims = self._group_claims_by_tenant(claims)
|
||||
for tenant_id in sorted(grouped_claims):
|
||||
tenant_claims = sorted(grouped_claims[tenant_id], key=lambda item: str(item.id))
|
||||
snapshot_versions = {claim.id: claim.updated_at for claim in tenant_claims}
|
||||
result = evaluate_financial_risk_graph(
|
||||
RiskGraphEvaluationContext(
|
||||
claims=[
|
||||
RiskGraphClaimSnapshot.from_orm(claim)
|
||||
for claim in tenant_claims
|
||||
],
|
||||
claims=[RiskGraphClaimSnapshot.from_orm(claim) for claim in tenant_claims],
|
||||
target_claim_ids={claim.id for claim in tenant_claims},
|
||||
history_stats=observation_service.build_history_stats(
|
||||
tenant_id=tenant_id,
|
||||
expense_types={
|
||||
str(claim.expense_type or "") for claim in tenant_claims
|
||||
},
|
||||
expense_types={str(claim.expense_type or "") for claim in tenant_claims},
|
||||
),
|
||||
)
|
||||
)
|
||||
claims_by_id = {claim.id: claim for claim in tenant_claims}
|
||||
observation_count += len(result.observations)
|
||||
graph_node_count += len(result.nodes)
|
||||
graph_edge_count += len(result.edges)
|
||||
|
||||
observations_by_claim = {}
|
||||
for observation in result.observations:
|
||||
claim = claims_by_id.get(observation.claim_id)
|
||||
if claim is None:
|
||||
continue
|
||||
observation_service.upsert_observation(
|
||||
observation,
|
||||
tenant_id=tenant_id,
|
||||
run_id=run_id,
|
||||
execution_log_id=log_id,
|
||||
)
|
||||
claim.hermes_risk_flag = True
|
||||
claim.risk_flags_json = self._append_algorithm_flag(
|
||||
claim,
|
||||
observation.as_dict(),
|
||||
)
|
||||
observations_by_claim.setdefault(observation.claim_id, []).append(observation)
|
||||
|
||||
if log_id:
|
||||
self.db.add(
|
||||
HermesRiskReport(
|
||||
claim_id=observation.claim_id,
|
||||
execution_log_id=log_id,
|
||||
risk_level=observation.risk_level,
|
||||
risk_type=observation.risk_signal,
|
||||
risk_description=observation.description,
|
||||
related_claim_ids=[
|
||||
observation.claim_id,
|
||||
*observation.similar_case_claim_ids,
|
||||
],
|
||||
)
|
||||
for snapshot_claim in tenant_claims:
|
||||
claim = observation_service.lock_claim_for_risk_write(
|
||||
snapshot_claim.id,
|
||||
tenant_id=tenant_id,
|
||||
refresh=True,
|
||||
)
|
||||
if claim is None or not self._is_scan_eligible(claim):
|
||||
continue
|
||||
if claim.updated_at != snapshot_versions.get(claim.id):
|
||||
# 计算期间单据已变化,旧快照不再写回;下一轮重新扫描。
|
||||
continue
|
||||
for observation in observations_by_claim.get(claim.id, []):
|
||||
observation_service.upsert_observation(
|
||||
observation,
|
||||
tenant_id=tenant_id,
|
||||
run_id=run_id,
|
||||
execution_log_id=log_id,
|
||||
claim_lock_acquired=True,
|
||||
)
|
||||
observation_count += 1
|
||||
claim.hermes_risk_flag = True
|
||||
claim.risk_flags_json = self._append_algorithm_flag(
|
||||
claim,
|
||||
observation.as_dict(),
|
||||
)
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
for claim in claims:
|
||||
claim.hermes_scanned_at = now
|
||||
if log_id:
|
||||
self.db.add(
|
||||
HermesRiskReport(
|
||||
claim_id=observation.claim_id,
|
||||
execution_log_id=log_id,
|
||||
risk_level=observation.risk_level,
|
||||
risk_type=observation.risk_signal,
|
||||
risk_description=observation.description,
|
||||
related_claim_ids=[
|
||||
observation.claim_id,
|
||||
*observation.similar_case_claim_ids,
|
||||
],
|
||||
)
|
||||
)
|
||||
claim.hermes_scanned_at = now
|
||||
scanned_claim_count += 1
|
||||
|
||||
self.db.commit()
|
||||
logger.info(
|
||||
@@ -104,7 +113,7 @@ class HermesRiskScannerService:
|
||||
observation_count,
|
||||
)
|
||||
return {
|
||||
"scanned_claim_count": len(claims),
|
||||
"scanned_claim_count": scanned_claim_count,
|
||||
"risk_observation_count": observation_count,
|
||||
"graph_node_count": graph_node_count,
|
||||
"graph_edge_count": graph_edge_count,
|
||||
@@ -134,11 +143,16 @@ class HermesRiskScannerService:
|
||||
ExpenseClaim.hermes_risk_flag.is_(False),
|
||||
),
|
||||
)
|
||||
.order_by(ExpenseClaim.id)
|
||||
.limit(50)
|
||||
)
|
||||
|
||||
return list(self.db.scalars(stmt).all())
|
||||
|
||||
@staticmethod
|
||||
def _is_scan_eligible(claim: ExpenseClaim) -> bool:
|
||||
return str(claim.status or "").strip().lower() in {"draft", "submitted", "review"}
|
||||
|
||||
@staticmethod
|
||||
def _append_algorithm_flag(claim: ExpenseClaim, observation: dict) -> list:
|
||||
existing = list(claim.risk_flags_json or [])
|
||||
@@ -155,8 +169,7 @@ class HermesRiskScannerService:
|
||||
"reimbursement",
|
||||
)
|
||||
if any(
|
||||
isinstance(item, dict)
|
||||
and item.get("observation_key") == flag["observation_key"]
|
||||
isinstance(item, dict) and item.get("observation_key") == flag["observation_key"]
|
||||
for item in existing
|
||||
):
|
||||
return existing
|
||||
|
||||
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()
|
||||
84
server/src/app/services/risk_observation_access_policy.py
Normal file
84
server/src/app/services/risk_observation_access_policy.py
Normal file
@@ -0,0 +1,84 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import CurrentUserContext
|
||||
from app.models.financial_record import ExpenseClaim
|
||||
from app.models.risk_observation import RiskObservation
|
||||
from app.services.expense_claim_access_policy import ExpenseClaimAccessPolicy
|
||||
|
||||
RISK_POOL_ROLE_CODES = {
|
||||
"budget_monitor",
|
||||
"executive",
|
||||
"finance",
|
||||
}
|
||||
|
||||
|
||||
class RiskObservationAccessPolicy:
|
||||
"""集中定义风险池、单据风险和处置动作的访问边界。"""
|
||||
|
||||
def __init__(self, db: Session) -> None:
|
||||
self.db = db
|
||||
self.claim_policy = ExpenseClaimAccessPolicy(db)
|
||||
|
||||
def can_read_tenant_pool(self, current_user: CurrentUserContext) -> bool:
|
||||
if current_user.is_admin:
|
||||
return True
|
||||
return bool(self.claim_policy.normalize_role_codes(current_user) & RISK_POOL_ROLE_CODES)
|
||||
|
||||
def find_visible_claim(
|
||||
self,
|
||||
claim_id: str,
|
||||
current_user: CurrentUserContext,
|
||||
) -> ExpenseClaim | None:
|
||||
statement = select(ExpenseClaim).where(ExpenseClaim.id == claim_id)
|
||||
statement = self.claim_policy.apply_claim_scope(
|
||||
statement,
|
||||
current_user,
|
||||
include_approval_scope=True,
|
||||
)
|
||||
return self.db.scalar(statement)
|
||||
|
||||
def can_read_claim_risks(
|
||||
self,
|
||||
claim_id: str,
|
||||
current_user: CurrentUserContext,
|
||||
) -> bool:
|
||||
claim = self.find_visible_claim(claim_id, current_user)
|
||||
if claim is None:
|
||||
return False
|
||||
if current_user.is_admin:
|
||||
return True
|
||||
# 完整证据链含图谱、相似案例与人工反馈,仅当前审批人可读取。
|
||||
return self.claim_policy.can_approve_claim(current_user, claim)
|
||||
|
||||
def can_manage_disposition(
|
||||
self,
|
||||
observation: RiskObservation,
|
||||
current_user: CurrentUserContext,
|
||||
) -> bool:
|
||||
claim_id = str(observation.claim_id or "").strip()
|
||||
claim = self.find_visible_claim(claim_id, current_user) if claim_id else None
|
||||
return self.can_manage_locked_disposition(
|
||||
observation,
|
||||
current_user,
|
||||
locked_claim=claim,
|
||||
)
|
||||
|
||||
def can_manage_locked_disposition(
|
||||
self,
|
||||
observation: RiskObservation,
|
||||
current_user: CurrentUserContext,
|
||||
*,
|
||||
locked_claim: ExpenseClaim | None,
|
||||
) -> bool:
|
||||
"""在共享 Claim 行锁内按最新审批节点复核处置权限。"""
|
||||
|
||||
if current_user.is_admin:
|
||||
return True
|
||||
claim_id = str(observation.claim_id or "").strip()
|
||||
if not claim_id or locked_claim is None or str(locked_claim.id) != claim_id:
|
||||
# 无关联单据的租户级风险缺少可复用的业务权限边界,仅 admin 可处置。
|
||||
return False
|
||||
return self.claim_policy.can_approve_claim(current_user, locked_claim)
|
||||
@@ -19,6 +19,7 @@ from app.schemas.risk_observation import (
|
||||
RiskObservationFeedbackCreate,
|
||||
)
|
||||
from app.services.expense_claim_risk_stage import normalize_risk_business_stage
|
||||
from app.services.expense_claim_tenant_scope import ExpenseClaimTenantScopeMixin
|
||||
|
||||
logger = get_logger("app.services.risk_observations")
|
||||
|
||||
@@ -66,6 +67,7 @@ class RiskObservationService:
|
||||
tenant_id: str | None = None,
|
||||
run_id: str | None = None,
|
||||
execution_log_id: str | None = None,
|
||||
claim_lock_acquired: bool = False,
|
||||
) -> RiskObservation:
|
||||
self.ensure_storage_ready()
|
||||
payload = (
|
||||
@@ -80,6 +82,12 @@ class RiskObservationService:
|
||||
tenant_id=tenant_id or _optional_text(payload.get("tenant_id")),
|
||||
claim_id=_optional_text(payload.get("claim_id")),
|
||||
)
|
||||
claim_id = _optional_text(payload.get("claim_id"))
|
||||
if claim_id and not claim_lock_acquired:
|
||||
self.lock_claim_for_risk_write(
|
||||
claim_id,
|
||||
tenant_id=normalized_tenant_id,
|
||||
)
|
||||
|
||||
item = self.db.scalar(
|
||||
select(RiskObservation).where(
|
||||
@@ -126,6 +134,27 @@ class RiskObservationService:
|
||||
self.db.flush()
|
||||
return item
|
||||
|
||||
def lock_claim_for_risk_write(
|
||||
self,
|
||||
claim_id: str,
|
||||
*,
|
||||
tenant_id: str | None,
|
||||
refresh: bool = False,
|
||||
) -> ExpenseClaim | None:
|
||||
"""所有 claim-linked 风险写入共享同一 Claim 行锁。"""
|
||||
|
||||
normalized_tenant = _normalize_tenant_id(tenant_id)
|
||||
statement = select(ExpenseClaim).where(
|
||||
ExpenseClaim.id == claim_id,
|
||||
ExpenseClaimTenantScopeMixin.build_claim_tenant_condition(normalized_tenant),
|
||||
)
|
||||
bind = self.db.get_bind()
|
||||
if bind is not None and bind.dialect.name == "postgresql":
|
||||
statement = statement.with_for_update()
|
||||
if refresh:
|
||||
statement = statement.execution_options(populate_existing=True)
|
||||
return self.db.scalar(statement)
|
||||
|
||||
def upsert_platform_risk_flags(
|
||||
self,
|
||||
claim: ExpenseClaim,
|
||||
@@ -154,9 +183,7 @@ class RiskObservationService:
|
||||
score = SEVERITY_SCORE.get(severity, SEVERITY_SCORE["medium"])
|
||||
rule_code = _text(flag.get("rule_code"))
|
||||
business_stage = normalize_risk_business_stage(flag.get("business_stage"))
|
||||
observation_key = (
|
||||
f"risk:{claim.id}:platform:{rule_code or signal}"
|
||||
)
|
||||
observation_key = f"risk:{claim.id}:platform:{rule_code or signal}"
|
||||
observations.append(
|
||||
self.upsert_observation(
|
||||
{
|
||||
@@ -176,9 +203,7 @@ class RiskObservationService:
|
||||
"control_stage": business_stage,
|
||||
"control_mode": "risk_observation",
|
||||
"automation_mode": (
|
||||
"semi_auto_review"
|
||||
if severity in HIGH_LEVELS
|
||||
else "manual_review"
|
||||
"semi_auto_review" if severity in HIGH_LEVELS else "manual_review"
|
||||
),
|
||||
"source": "rule_center",
|
||||
"algorithm_version": _text(flag.get("rule_version")) or "v1.0.0",
|
||||
@@ -357,6 +382,24 @@ class RiskObservationService:
|
||||
observation_key_or_id,
|
||||
tenant_id=tenant_id,
|
||||
)
|
||||
if observation is None:
|
||||
raise LookupError("Risk observation not found.")
|
||||
normalized_tenant = _normalize_tenant_id(tenant_id)
|
||||
if observation.claim_id:
|
||||
self.lock_claim_for_risk_write(
|
||||
observation.claim_id,
|
||||
tenant_id=normalized_tenant,
|
||||
)
|
||||
observation_statement = select(RiskObservation).where(
|
||||
RiskObservation.tenant_id == normalized_tenant,
|
||||
RiskObservation.id == observation.id,
|
||||
)
|
||||
bind = self.db.get_bind()
|
||||
if bind is not None and bind.dialect.name == "postgresql":
|
||||
observation_statement = observation_statement.with_for_update()
|
||||
observation = self.db.scalar(
|
||||
observation_statement.execution_options(populate_existing=True)
|
||||
)
|
||||
if observation is None:
|
||||
raise LookupError("Risk observation not found.")
|
||||
|
||||
@@ -505,12 +548,16 @@ class RiskObservationService:
|
||||
) -> str:
|
||||
explicit_tenant_id = str(tenant_id or "").strip()
|
||||
normalized_claim_id = str(claim_id or "").strip()
|
||||
linked_tenant_id = self.db.scalar(
|
||||
select(ExpenseCaseLink.tenant_id).where(
|
||||
ExpenseCaseLink.resource_type == "expense_claim",
|
||||
ExpenseCaseLink.resource_id == normalized_claim_id,
|
||||
linked_tenant_id = (
|
||||
self.db.scalar(
|
||||
select(ExpenseCaseLink.tenant_id).where(
|
||||
ExpenseCaseLink.resource_type == "expense_claim",
|
||||
ExpenseCaseLink.resource_id == normalized_claim_id,
|
||||
)
|
||||
)
|
||||
) if normalized_claim_id else None
|
||||
if normalized_claim_id
|
||||
else None
|
||||
)
|
||||
claim_tenant_id = _normalize_tenant_id(linked_tenant_id)
|
||||
if explicit_tenant_id:
|
||||
normalized_tenant_id = _normalize_tenant_id(explicit_tenant_id)
|
||||
|
||||
Reference in New Issue
Block a user