feat(approval): add safe risk disposition workflow

This commit is contained in:
caoxiaozhu
2026-07-16 15:34:58 +08:00
parent ee88a36baf
commit 4940ebc419
64 changed files with 6602 additions and 631 deletions

View File

@@ -0,0 +1,31 @@
from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from app.api.deps import CurrentUserContext, get_current_user, get_db
from app.schemas.approval_workbench import ApprovalWorkbenchListRead
from app.services.approval_workbench import ApprovalWorkbenchService
router = APIRouter(prefix="/approval-workbench")
DbSession = Annotated[Session, Depends(get_db)]
CurrentUser = Annotated[CurrentUserContext, Depends(get_current_user)]
@router.get(
"/items",
response_model=ApprovalWorkbenchListRead,
summary="查询当前用户的例外审批优先队列",
description=(
"复用现有审批权限范围,按风险、预算、金额、等待时长和材料完整度排序;"
"AI 建议仅供人工复核,不执行自动审批。"
),
)
def list_approval_workbench_items(
db: DbSession,
current_user: CurrentUser,
limit: Annotated[int, Query(ge=1, le=200)] = 100,
) -> ApprovalWorkbenchListRead:
return ApprovalWorkbenchService(db).list_items(current_user, limit=limit)

View File

@@ -0,0 +1,163 @@
from __future__ import annotations
from typing import Annotated, NoReturn
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.api.deps import CurrentUserContext, get_current_user, get_db
from app.schemas.common import ErrorResponse
from app.schemas.reimbursement import (
ExpenseClaimApprovalPayload,
ExpenseClaimPaymentPayload,
ExpenseClaimRead,
ExpenseClaimReturnPayload,
)
from app.services.approval_action_protocol import ApprovalActionConflictError
from app.services.expense_claim_risk_gate import ExpenseClaimRiskBlockedError
from app.services.expense_claims import ExpenseClaimService
router = APIRouter()
DbSession = Annotated[Session, Depends(get_db)]
CurrentUser = Annotated[CurrentUserContext, Depends(get_current_user)]
def _raise_action_error(error: ValueError) -> NoReturn:
if isinstance(error, ExpenseClaimRiskBlockedError):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail={
"code": "APPROVAL_BLOCKED_BY_OPEN_HIGH_RISK",
"message": str(error),
"observations": [
{
"id": item.observation_id,
"title": item.title,
"risk_level": item.risk_level,
"adjudication": item.adjudication,
"lifecycle_status": item.lifecycle_status,
}
for item in error.blockers
],
},
) from error
if isinstance(error, ApprovalActionConflictError):
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error)) from error
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error
@router.post(
"/claims/{claim_id}/return",
response_model=ExpenseClaimRead,
summary="退回报销单",
description="按请求幂等键和预期状态安全退回当前节点的单据。",
responses={
status.HTTP_404_NOT_FOUND: {"model": ErrorResponse, "description": "报销单不存在。"},
status.HTTP_400_BAD_REQUEST: {
"model": ErrorResponse,
"description": "当前用户或单据状态不允许退回。",
},
status.HTTP_409_CONFLICT: {
"model": ErrorResponse,
"description": "请求键冲突或单据状态/审批节点已经变化。",
},
},
)
def return_expense_claim(
claim_id: str,
payload: ExpenseClaimReturnPayload,
db: DbSession,
current_user: CurrentUser,
) -> ExpenseClaimRead:
try:
claim = ExpenseClaimService(db).return_claim(
claim_id,
current_user,
reason=payload.reason,
reason_codes=payload.reason_codes,
request_id=payload.request_id,
expected_status=payload.expected_status,
expected_approval_stage=payload.expected_approval_stage,
)
except ValueError as error:
_raise_action_error(error)
if claim is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Claim not found")
return claim
@router.post(
"/claims/{claim_id}/approve",
response_model=ExpenseClaimRead,
summary="审批通过单据",
description="按请求幂等键和预期状态安全完成当前审批节点。",
responses={
status.HTTP_404_NOT_FOUND: {"model": ErrorResponse, "description": "单据不存在。"},
status.HTTP_400_BAD_REQUEST: {
"model": ErrorResponse,
"description": "当前用户或单据状态不允许审批通过。",
},
status.HTTP_409_CONFLICT: {
"model": ErrorResponse,
"description": "请求键冲突或单据状态/审批节点已经变化。",
},
},
)
def approve_expense_claim(
claim_id: str,
payload: ExpenseClaimApprovalPayload,
db: DbSession,
current_user: CurrentUser,
) -> ExpenseClaimRead:
try:
claim = ExpenseClaimService(db).approve_claim(
claim_id,
current_user,
opinion=payload.opinion,
request_id=payload.request_id,
expected_status=payload.expected_status,
expected_approval_stage=payload.expected_approval_stage,
)
except ValueError as error:
_raise_action_error(error)
if claim is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Claim not found")
return claim
@router.post(
"/claims/{claim_id}/pay",
response_model=ExpenseClaimRead,
summary="确认报销单已付款",
description="按请求幂等键和预期状态安全确认待付款报销单已付款。",
responses={
status.HTTP_404_NOT_FOUND: {"model": ErrorResponse, "description": "单据不存在。"},
status.HTTP_400_BAD_REQUEST: {
"model": ErrorResponse,
"description": "当前用户或单据状态不允许确认付款。",
},
status.HTTP_409_CONFLICT: {
"model": ErrorResponse,
"description": "请求键冲突或单据状态/审批节点已经变化。",
},
},
)
def pay_expense_claim(
claim_id: str,
payload: ExpenseClaimPaymentPayload,
db: DbSession,
current_user: CurrentUser,
) -> ExpenseClaimRead:
try:
claim = ExpenseClaimService(db).mark_claim_paid(
claim_id,
current_user,
request_id=payload.request_id,
expected_status=payload.expected_status,
expected_approval_stage=payload.expected_approval_stage,
)
except ValueError as error:
_raise_action_error(error)
if claim is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Claim not found")
return claim

View File

@@ -12,14 +12,12 @@ from app.schemas.budget import BudgetClaimAnalysisRead
from app.schemas.common import ErrorResponse, PaginatedResponse
from app.schemas.reimbursement import (
ExpenseClaimActionResponse,
ExpenseClaimApprovalPayload,
ExpenseClaimAttachmentActionResponse,
ExpenseClaimAttachmentRead,
ExpenseClaimItemActionResponse,
ExpenseClaimItemCreate,
ExpenseClaimItemUpdate,
ExpenseClaimRead,
ExpenseClaimReturnPayload,
ExpenseClaimStandardAdjustmentPayload,
ExpenseClaimSubmitPayload,
ExpenseClaimUpdate,
@@ -33,6 +31,7 @@ from app.services.expense_claims import ExpenseClaimService
from app.services.reimbursement import ReimbursementService
from app.services.travel_reimbursement_calculator import TravelReimbursementCalculatorService
from .reimbursement_approval_actions import router as approval_action_router
from .reimbursement_pre_review import (
RequestIdHeader,
expense_claim_deletion_response,
@@ -41,6 +40,7 @@ from .reimbursement_pre_review import (
)
router = APIRouter()
router.include_router(approval_action_router)
DbSession = Annotated[Session, Depends(get_db)]
CurrentUser = Annotated[CurrentUserContext, Depends(get_current_user)]
@@ -198,10 +198,16 @@ def get_expense_claim_budget_analysis(
claim = service.get_claim(claim_id, current_user)
if claim is None:
if not service.can_view_budget_analysis(current_user):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="只有当前审核人、该部门预算监控员或高级财务人员可以查看预算分析。")
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="只有当前审核人、该部门预算监控员或高级财务人员可以查看预算分析。",
)
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Claim not found")
if not service.can_view_budget_analysis(current_user, claim):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="只有当前审核人、该部门预算监控员或高级财务人员可以查看预算分析。")
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="只有当前审核人、该部门预算监控员或高级财务人员可以查看预算分析。",
)
return BudgetService(db).analyze_claim_budget(claim)
@@ -418,7 +424,9 @@ async def upload_expense_claim_item_attachment(
file: Annotated[UploadFile, File(description="待上传的附件文件。")],
db: DbSession,
current_user: CurrentUser,
receipt_id: Annotated[str | None, Form(description="可选,来源于票据夹的持久化票据 ID。")] = None,
receipt_id: Annotated[
str | None, Form(description="可选,来源于票据夹的持久化票据 ID。")
] = None,
) -> ExpenseClaimAttachmentActionResponse:
service = ExpenseClaimService(db)
try:
@@ -651,104 +659,6 @@ def submit_expense_claim(
)
@router.post(
"/claims/{claim_id}/return",
response_model=ExpenseClaimRead,
summary="退回报销单",
description="财务人员、高级财务人员或当前审批人可将可见报销单退回到待提交状态。",
responses={
status.HTTP_404_NOT_FOUND: {
"model": ErrorResponse,
"description": "报销单不存在。",
},
status.HTTP_400_BAD_REQUEST: {
"model": ErrorResponse,
"description": "当前用户或单据状态不允许退回。",
},
},
)
def return_expense_claim(
claim_id: str,
payload: ExpenseClaimReturnPayload,
db: DbSession,
current_user: CurrentUser,
) -> ExpenseClaimRead:
service = ExpenseClaimService(db)
try:
claim = service.return_claim(claim_id, current_user, reason=payload.reason, reason_codes=payload.reason_codes)
except ValueError as error:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error
if claim is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Claim not found")
return claim
@router.post(
"/claims/{claim_id}/approve",
response_model=ExpenseClaimRead,
summary="审批通过单据",
description="费用申请由直属领导审批后流转到预算管理者审批,预算审核通过后生成报销草稿;报销单直属领导审批后流转到财务审批。",
responses={
status.HTTP_404_NOT_FOUND: {
"model": ErrorResponse,
"description": "单据不存在。",
},
status.HTTP_400_BAD_REQUEST: {
"model": ErrorResponse,
"description": "当前用户或单据状态不允许审批通过。",
},
},
)
def approve_expense_claim(
claim_id: str,
payload: ExpenseClaimApprovalPayload,
db: DbSession,
current_user: CurrentUser,
) -> ExpenseClaimRead:
service = ExpenseClaimService(db)
try:
claim = service.approve_claim(claim_id, current_user, opinion=payload.opinion)
except ValueError as error:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error
if claim is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Claim not found")
return claim
@router.post(
"/claims/{claim_id}/pay",
response_model=ExpenseClaimRead,
summary="确认报销单已付款",
description="财务人员或高级财务人员确认待付款报销单已完成付款。",
responses={
status.HTTP_404_NOT_FOUND: {
"model": ErrorResponse,
"description": "单据不存在。",
},
status.HTTP_400_BAD_REQUEST: {
"model": ErrorResponse,
"description": "当前用户或单据状态不允许确认付款。",
},
},
)
def pay_expense_claim(
claim_id: str,
db: DbSession,
current_user: CurrentUser,
) -> ExpenseClaimRead:
service = ExpenseClaimService(db)
try:
claim = service.mark_claim_paid(claim_id, current_user)
except ValueError as error:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error
if claim is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Claim not found")
return claim
@router.delete(
"/claims/{claim_id}",
response_model=ExpenseClaimActionResponse,
@@ -765,7 +675,9 @@ def pay_expense_claim(
},
},
)
def delete_expense_claim(claim_id: str, db: DbSession, current_user: CurrentUser) -> ExpenseClaimActionResponse:
def delete_expense_claim(
claim_id: str, db: DbSession, current_user: CurrentUser
) -> ExpenseClaimActionResponse:
service = ExpenseClaimService(db)
try:
claim = service.delete_claim(claim_id, current_user)

View File

@@ -1,5 +1,6 @@
from __future__ import annotations
import uuid
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query, status
@@ -7,6 +8,11 @@ from sqlalchemy.orm import Session
from app.api.deps import CurrentUserContext, get_current_user, get_db
from app.schemas.common import ErrorResponse
from app.schemas.risk_disposition import (
RiskDispositionActionCreate,
RiskDispositionMutationRead,
RiskDispositionRead,
)
from app.schemas.risk_observation import (
RiskObservationDashboardRead,
RiskObservationFeedbackCreate,
@@ -14,6 +20,13 @@ from app.schemas.risk_observation import (
RiskObservationListRead,
RiskObservationRead,
)
from app.services.risk_dispositions import (
RiskDispositionConflictError,
RiskDispositionPermissionError,
RiskDispositionService,
RiskDispositionVersionConflictError,
)
from app.services.risk_observation_access_policy import RiskObservationAccessPolicy
from app.services.risk_observations import RiskObservationService
router = APIRouter(prefix="/risk-observations")
@@ -43,6 +56,7 @@ def list_risk_observations(
limit: Annotated[int, Query(ge=1, le=200)] = 50,
offset: Annotated[int, Query(ge=0)] = 0,
) -> RiskObservationListRead:
_require_pool_access(db, current_user)
items, total = RiskObservationService(db).list_observations(
tenant_id=current_user.tenant_id,
claim_id=claim_id,
@@ -70,6 +84,7 @@ def summarize_risk_observations(
window_days: Annotated[int, Query(ge=1, le=365)] = 30,
limit: Annotated[int, Query(ge=1, le=2000)] = 500,
) -> RiskObservationDashboardRead:
_require_pool_access(db, current_user)
return RiskObservationService(db).summarize_dashboard(
tenant_id=current_user.tenant_id,
window_days=window_days,
@@ -88,6 +103,8 @@ def list_claim_risk_observations(
db: DbSession,
current_user: CurrentUser,
) -> list[RiskObservationRead]:
if not RiskObservationAccessPolicy(db).can_read_claim_risks(claim_id, current_user):
raise _not_found()
return RiskObservationService(db).list_claim_observations(
claim_id,
tenant_id=current_user.tenant_id,
@@ -105,6 +122,7 @@ def list_execution_log_risk_observations(
db: DbSession,
current_user: CurrentUser,
) -> list[RiskObservationRead]:
_require_pool_access(db, current_user)
return RiskObservationService(db).list_execution_log_observations(
execution_log_id,
tenant_id=current_user.tenant_id,
@@ -128,6 +146,7 @@ def get_risk_observation(
db: DbSession,
current_user: CurrentUser,
) -> RiskObservationRead:
_require_pool_access(db, current_user)
observation = RiskObservationService(db).get_observation(
observation_key_or_id,
tenant_id=current_user.tenant_id,
@@ -140,6 +159,53 @@ def get_risk_observation(
return observation
@router.get(
"/{observation_key_or_id}/disposition",
response_model=RiskDispositionRead,
summary="读取风险观察处置状态",
description="返回裁决结论、处置生命周期、负责人、截止时间和只追加事件。",
)
def get_risk_observation_disposition(
observation_key_or_id: str,
db: DbSession,
current_user: CurrentUser,
) -> RiskDispositionRead:
_require_pool_access(db, current_user)
observation = _get_observation_or_404(db, current_user, observation_key_or_id)
disposition = RiskDispositionService(db).get_disposition(
observation.id,
tenant_id=current_user.tenant_id,
)
if disposition is None:
raise _not_found("Risk disposition not found")
return disposition
@router.post(
"/{observation_key_or_id}/disposition/actions",
response_model=RiskDispositionMutationRead,
summary="执行类型化风险处置动作",
description="使用乐观锁和请求幂等键追加风险处置事件。",
)
def execute_risk_disposition_action(
observation_key_or_id: str,
payload: RiskDispositionActionCreate,
db: DbSession,
current_user: CurrentUser,
) -> RiskDispositionMutationRead:
mutation = _execute_action(
db,
current_user,
observation_key_or_id,
payload,
)
return RiskDispositionMutationRead(
disposition=mutation.disposition,
event=mutation.event,
replayed=mutation.replayed,
)
@router.post(
"/{observation_key_or_id}/feedback",
response_model=RiskObservationFeedbackRead,
@@ -158,15 +224,103 @@ def create_risk_observation_feedback(
db: DbSession,
current_user: CurrentUser,
) -> RiskObservationFeedbackRead:
observation = _get_observation_or_404(db, current_user, observation_key_or_id)
if (
payload.feedback_type not in {"confirm", "false_positive"}
or payload.action is not None
or bool(payload.payload_json)
):
raise HTTPException(
status_code=status.HTTP_410_GONE,
detail=("自由格式反馈入口已停用;请使用 disposition/actions 类型化处置接口。"),
)
service = RiskDispositionService(db)
mutation = _execute_action(
db,
current_user,
observation_key_or_id,
RiskDispositionActionCreate(
action=payload.feedback_type,
expected_version=service.get_current_version(
observation.id,
tenant_id=current_user.tenant_id,
),
request_id=f"legacy:{uuid.uuid4()}",
comment=payload.comment,
),
)
if mutation.legacy_feedback is None:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Legacy feedback projection is unavailable.",
)
return mutation.legacy_feedback
def _require_pool_access(db: Session, current_user: CurrentUserContext) -> None:
if RiskObservationAccessPolicy(db).can_read_tenant_pool(current_user):
return
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="当前用户无权访问企业风险观察池。",
)
def _get_observation_or_404(
db: Session,
current_user: CurrentUserContext,
observation_key_or_id: str,
):
observation = RiskObservationService(db).get_observation(
observation_key_or_id,
tenant_id=current_user.tenant_id,
)
if observation is None:
raise _not_found()
return observation
def _execute_action(
db: Session,
current_user: CurrentUserContext,
observation_key_or_id: str,
payload: RiskDispositionActionCreate,
):
try:
return RiskObservationService(db).create_feedback(
return RiskDispositionService(db).execute_action(
observation_key_or_id,
payload,
tenant_id=current_user.tenant_id,
actor=current_user.name or current_user.username,
actor_id=current_user.employee_id or current_user.username,
actor_name=current_user.name or current_user.username,
current_user=current_user,
)
except LookupError:
raise _not_found() from None
except RiskDispositionVersionConflictError as error:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Risk observation not found",
status_code=status.HTTP_409_CONFLICT,
detail={
"code": "RISK_DISPOSITION_VERSION_CONFLICT",
"current_version": error.current_version,
"message": "风险处置状态已更新,请刷新证据链后重试。",
},
) from None
except RiskDispositionPermissionError as error:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=str(error),
) from None
except RiskDispositionConflictError as error:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=str(error),
) from None
def _not_found(detail: str = "Risk observation not found") -> HTTPException:
return HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=detail,
)

View File

@@ -6,6 +6,7 @@ from app.api.v1.endpoints.agent_feedback import router as agent_feedback_router
from app.api.v1.endpoints.agent_runs import router as agent_runs_router
from app.api.v1.endpoints.agent_traces import router as agent_traces_router
from app.api.v1.endpoints.analytics import router as analytics_router
from app.api.v1.endpoints.approval_workbench import router as approval_workbench_router
from app.api.v1.endpoints.attachment_association_jobs import (
router as attachment_association_jobs_router,
)
@@ -49,10 +50,14 @@ router.include_router(agent_feedback_router, tags=["agent-feedback"])
router.include_router(agent_runs_router, tags=["agent-runs"])
router.include_router(agent_traces_router, tags=["agent-traces"])
router.include_router(analytics_router, tags=["analytics"])
router.include_router(approval_workbench_router, tags=["approval-workbench"])
router.include_router(attachment_association_jobs_router, tags=["attachment-association-jobs"])
router.include_router(audit_logs_router, tags=["audit-logs"])
router.include_router(knowledge_router, tags=["knowledge"])
router.include_router(linked_reimbursement_draft_jobs_router, tags=["linked-reimbursement-draft-jobs"])
router.include_router(
linked_reimbursement_draft_jobs_router,
tags=["linked-reimbursement-draft-jobs"],
)
router.include_router(notification_states_router, tags=["notification-states"])
router.include_router(ocr_router, tags=["ocr"])
router.include_router(ontology_router, tags=["ontology"])

View File

@@ -13,6 +13,7 @@ from app.models.ai_application_preview import AIApplicationPreviewDecision
from app.models.ai_learning import AIDecision, AIDecisionFeedback, WorkflowOutcome
from app.models.ai_memory import MemoryEntry, MemoryEvidenceLink
from app.models.approval import ApprovalRecord
from app.models.approval_action import ApprovalActionLedger
from app.models.attachment_association_job import AttachmentAssociationJob
from app.models.audit_log import AuditLog
from app.models.auth_session import AuthSession
@@ -34,6 +35,7 @@ from app.models.hermes_report import HermesRiskReport
from app.models.notification_state import NotificationState
from app.models.organization import OrganizationUnit
from app.models.reimbursement import ReimbursementRequest
from app.models.risk_disposition import RiskDisposition, RiskDispositionEvent
from app.models.risk_observation import RiskObservation, RiskObservationFeedback
from app.models.role import Role
from app.models.system_model_setting import SystemModelSetting
@@ -60,6 +62,7 @@ __all__ = [
"AIDecision",
"AIDecisionFeedback",
"ApprovalRecord",
"ApprovalActionLedger",
"AttachmentAssociationJob",
"AuditLog",
"AuthSession",
@@ -84,6 +87,8 @@ __all__ = [
"NotificationState",
"OrganizationUnit",
"ReimbursementRequest",
"RiskDisposition",
"RiskDispositionEvent",
"RiskObservation",
"RiskObservationFeedback",
"Role",

View File

@@ -130,8 +130,48 @@ MIGRATION_OWNED_TABLES_BY_REVISION: dict[str, frozenset[str]] = {
"workflow_outcomes",
}
),
"20260716_0010": frozenset(
{
"expense_cases",
"expense_case_links",
"business_events",
"auth_sessions",
"attachment_association_jobs",
"ai_application_preview_decisions",
"ai_decisions",
"ai_decision_feedback",
"memory_entries",
"memory_evidence_links",
"risk_observations",
"risk_observation_feedback",
"few_shot_samples",
"workflow_outcomes",
"approval_action_ledgers",
}
),
"20260716_0011": frozenset(
{
"expense_cases",
"expense_case_links",
"business_events",
"auth_sessions",
"attachment_association_jobs",
"ai_application_preview_decisions",
"ai_decisions",
"ai_decision_feedback",
"memory_entries",
"memory_evidence_links",
"risk_observations",
"risk_observation_feedback",
"risk_dispositions",
"risk_disposition_events",
"few_shot_samples",
"workflow_outcomes",
"approval_action_ledgers",
}
),
}
if MIGRATION_OWNED_TABLES_BY_REVISION["20260716_0009"] != MIGRATION_OWNED_TABLES:
if MIGRATION_OWNED_TABLES_BY_REVISION["20260716_0011"] != MIGRATION_OWNED_TABLES:
raise RuntimeError("latest Alembic revision must own the centralized migration table set")
# 0008 之前这三张表由旧 bootstrap / 风险服务按需创建。迁移前置检查允许
@@ -198,7 +238,13 @@ def _validate_connection(connection: Connection) -> MigrationPreflightState:
adoptable_tables = (
LEGACY_ADOPTABLE_HISTORICAL_CASE_TABLES
if revision not in {"20260716_0008", "20260716_0009"}
if revision
not in {
"20260716_0008",
"20260716_0009",
"20260716_0010",
"20260716_0011",
}
else frozenset()
)
missing_tables = expected_tables - owned_tables

View File

@@ -7,6 +7,7 @@ from app.db.base import Base
MIGRATION_OWNED_TABLES: frozenset[str] = frozenset(
{
"auth_sessions",
"approval_action_ledgers",
"attachment_association_jobs",
"ai_application_preview_decisions",
"ai_decisions",
@@ -18,6 +19,8 @@ MIGRATION_OWNED_TABLES: frozenset[str] = frozenset(
"memory_evidence_links",
"risk_observations",
"risk_observation_feedback",
"risk_dispositions",
"risk_disposition_events",
"few_shot_samples",
"workflow_outcomes",
}

View File

@@ -11,6 +11,7 @@ from app.models.ai_application_preview import AIApplicationPreviewDecision
from app.models.ai_learning import AIDecision, AIDecisionFeedback, WorkflowOutcome
from app.models.ai_memory import MemoryEntry, MemoryEvidenceLink
from app.models.approval import ApprovalRecord
from app.models.approval_action import ApprovalActionLedger
from app.models.attachment_association_job import AttachmentAssociationJob
from app.models.audit_log import AuditLog
from app.models.auth_session import AuthSession
@@ -32,6 +33,7 @@ from app.models.hermes_report import HermesRiskReport
from app.models.notification_state import NotificationState
from app.models.organization import OrganizationUnit
from app.models.reimbursement import ReimbursementRequest
from app.models.risk_disposition import RiskDisposition, RiskDispositionEvent
from app.models.risk_observation import RiskObservation, RiskObservationFeedback
from app.models.role import Role
from app.models.system_model_setting import SystemModelSetting
@@ -54,6 +56,7 @@ __all__ = [
"AgentTraceEvent",
"AIApplicationPreviewDecision",
"ApprovalRecord",
"ApprovalActionLedger",
"AttachmentAssociationJob",
"AuditLog",
"AuthSession",
@@ -80,6 +83,8 @@ __all__ = [
"NotificationState",
"OrganizationUnit",
"ReimbursementRequest",
"RiskDisposition",
"RiskDispositionEvent",
"RiskObservation",
"RiskObservationFeedback",
"Role",

View File

@@ -0,0 +1,64 @@
from __future__ import annotations
import uuid
from datetime import datetime
from typing import Any
from sqlalchemy import CheckConstraint, DateTime, Index, String, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.types import JSON
from app.db.base_class import Base
class ApprovalActionLedger(Base):
"""审批类写动作的持久化幂等账本。"""
__tablename__ = "approval_action_ledgers"
__table_args__ = (
CheckConstraint(
"action IN ('approve', 'return', 'pay')",
name="ck_approval_action_ledger_action",
),
CheckConstraint(
"(completed_at IS NULL AND result_status IS NULL "
"AND result_approval_stage IS NULL) OR "
"(completed_at IS NOT NULL AND result_status IS NOT NULL "
"AND result_approval_stage IS NOT NULL)",
name="ck_approval_action_ledger_completion",
),
UniqueConstraint(
"tenant_id",
"actor_id",
"request_id",
name="uq_approval_action_ledger_request",
),
Index(
"ix_approval_action_ledger_claim_action",
"tenant_id",
"claim_id",
"action",
),
)
id: Mapped[str] = mapped_column(
String(36),
primary_key=True,
default=lambda: str(uuid.uuid4()),
)
tenant_id: Mapped[str] = mapped_column(String(64), nullable=False)
actor_id: Mapped[str] = mapped_column(String(120), nullable=False)
request_id: Mapped[str] = mapped_column(String(120), nullable=False)
claim_id: Mapped[str] = mapped_column(String(36), nullable=False)
action: Mapped[str] = mapped_column(String(20), nullable=False)
payload_fingerprint: Mapped[str] = mapped_column(String(80), nullable=False)
expected_status: Mapped[str] = mapped_column(String(30), nullable=False)
expected_approval_stage: Mapped[str] = mapped_column(String(50), nullable=False)
result_status: Mapped[str | None] = mapped_column(String(30), nullable=True)
result_approval_stage: Mapped[str | None] = mapped_column(String(50), nullable=True)
response_json: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
)

View File

@@ -0,0 +1,174 @@
from __future__ import annotations
import uuid
from datetime import datetime
from typing import Any
from sqlalchemy import (
CheckConstraint,
DateTime,
ForeignKeyConstraint,
Index,
Integer,
String,
Text,
UniqueConstraint,
func,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.types import JSON
from app.db.base_class import Base
def _new_id() -> str:
return str(uuid.uuid4())
class RiskDisposition(Base):
"""风险观察的当前处置投影;裁决结论和处置生命周期彼此独立。"""
__tablename__ = "risk_dispositions"
__table_args__ = (
UniqueConstraint(
"tenant_id",
"id",
name="uq_risk_dispositions_tenant_id",
),
UniqueConstraint(
"tenant_id",
"observation_id",
name="uq_risk_dispositions_tenant_observation",
),
CheckConstraint(
"adjudication IN ('unreviewed', 'confirmed', 'false_positive')",
name="ck_risk_dispositions_adjudication",
),
CheckConstraint(
"lifecycle_status IN ('open', 'supplement_requested', "
"'remediation_in_progress', 'waiver_requested', 'resolved')",
name="ck_risk_dispositions_lifecycle",
),
CheckConstraint("version >= 0", name="ck_risk_dispositions_version"),
ForeignKeyConstraint(
["tenant_id", "observation_id"],
["risk_observations.tenant_id", "risk_observations.id"],
ondelete="RESTRICT",
name="fk_risk_dispositions_tenant_observation",
),
Index(
"ix_risk_dispositions_tenant_lifecycle_due",
"tenant_id",
"lifecycle_status",
"due_at",
),
Index("ix_risk_dispositions_assignee", "tenant_id", "assignee"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_new_id)
tenant_id: Mapped[str] = mapped_column(String(64), nullable=False)
observation_id: Mapped[str] = mapped_column(String(36), nullable=False)
adjudication: Mapped[str] = mapped_column(
String(24),
nullable=False,
default="unreviewed",
server_default="unreviewed",
)
lifecycle_status: Mapped[str] = mapped_column(
String(32),
nullable=False,
default="open",
server_default="open",
)
version: Mapped[int] = mapped_column(
Integer,
nullable=False,
default=0,
server_default="0",
)
assignee: Mapped[str | None] = mapped_column(String(120), nullable=True)
due_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
resolution: Mapped[str | None] = mapped_column(Text(), nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
server_default=func.now(),
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
server_default=func.now(),
onupdate=func.now(),
)
observation = relationship("RiskObservation", back_populates="disposition")
events = relationship(
"RiskDispositionEvent",
back_populates="disposition",
order_by="asc(RiskDispositionEvent.version)",
lazy="selectin",
passive_deletes=True,
)
class RiskDispositionEvent(Base):
"""只追加的风险处置事实;每个版本只对应一个类型化动作。"""
__tablename__ = "risk_disposition_events"
__table_args__ = (
UniqueConstraint(
"tenant_id",
"request_id",
name="uq_risk_disposition_events_tenant_request",
),
UniqueConstraint(
"disposition_id",
"version",
name="uq_risk_disposition_events_version",
),
CheckConstraint(
"action IN ('confirm', 'false_positive', 'request_supplement', "
"'start_remediation', 'resolve', 'request_waiver')",
name="ck_risk_disposition_events_action",
),
CheckConstraint("version > 0", name="ck_risk_disposition_events_version"),
ForeignKeyConstraint(
["tenant_id", "disposition_id"],
["risk_dispositions.tenant_id", "risk_dispositions.id"],
ondelete="RESTRICT",
name="fk_risk_disposition_events_tenant_disposition",
),
ForeignKeyConstraint(
["tenant_id", "observation_id"],
["risk_observations.tenant_id", "risk_observations.id"],
ondelete="RESTRICT",
name="fk_risk_disposition_events_tenant_observation",
),
Index(
"ix_risk_disposition_events_tenant_observation_time",
"tenant_id",
"observation_id",
"created_at",
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_new_id)
tenant_id: Mapped[str] = mapped_column(String(64), nullable=False)
disposition_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
observation_id: Mapped[str] = mapped_column(String(36), nullable=False)
version: Mapped[int] = mapped_column(Integer, nullable=False)
action: Mapped[str] = mapped_column(String(32), nullable=False)
actor_id: Mapped[str] = mapped_column(String(120), nullable=False)
actor_name: Mapped[str] = mapped_column(String(120), nullable=False)
request_id: Mapped[str] = mapped_column(String(120), nullable=False)
payload_fingerprint: Mapped[str] = mapped_column(String(64), nullable=False)
comment: Mapped[str | None] = mapped_column(Text(), nullable=True)
before_json: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False, default=dict)
after_json: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False, default=dict)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
server_default=func.now(),
)
disposition = relationship("RiskDisposition", back_populates="events")

View File

@@ -24,6 +24,11 @@ from app.db.base_class import Base
class RiskObservation(Base):
__tablename__ = "risk_observations"
__table_args__ = (
UniqueConstraint(
"tenant_id",
"id",
name="uq_risk_observations_tenant_id",
),
UniqueConstraint(
"tenant_id",
"observation_key",
@@ -95,6 +100,12 @@ class RiskObservation(Base):
cascade="all, delete-orphan",
order_by="desc(RiskObservationFeedback.created_at)",
)
disposition = relationship(
"RiskDisposition",
back_populates="observation",
passive_deletes=True,
uselist=False,
)
@property
def sampling_strategy(self) -> dict[str, Any]:

View File

@@ -0,0 +1,58 @@
from __future__ import annotations
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, Field
from app.schemas.reimbursement import ExpenseClaimRead
class ApprovalWorkbenchPriorityReasonRead(BaseModel):
code: str
label: str
weight: int = 0
tone: Literal["normal", "warning", "danger"] = "normal"
class ApprovalWorkbenchEvidenceRead(BaseModel):
completeness: float = 0.0
present_count: int = 0
required_count: int = 0
missing_labels: list[str] = Field(default_factory=list)
historical_labels: list[str] = Field(default_factory=list)
class ApprovalWorkbenchSuggestionRead(BaseModel):
action: Literal[
"manual_review",
"request_supplement",
"budget_review",
"approve_candidate",
]
label: str
reason: str
advisory_only: Literal[True] = True
class ApprovalWorkbenchItemRead(BaseModel):
task_key: str
claim: ExpenseClaimRead
priority_score: int = 0
priority_tier: Literal["normal", "high", "urgent"] = "normal"
priority_reasons: list[ApprovalWorkbenchPriorityReasonRead] = Field(default_factory=list)
risk_level: Literal["low", "medium", "high", "critical"] = "low"
open_risk_count: int = 0
budget_usage_rate: float | None = None
waiting_hours: float = 0.0
sla_due_at: datetime | None = None
sla_overdue: bool = False
evidence: ApprovalWorkbenchEvidenceRead
suggestion: ApprovalWorkbenchSuggestionRead
class ApprovalWorkbenchListRead(BaseModel):
items: list[ApprovalWorkbenchItemRead] = Field(default_factory=list)
total: int = 0
generated_at: datetime
scoring_version: str = "approval_workbench_priority.v1"

View File

@@ -276,15 +276,38 @@ class ExpenseApplicationPreviewActionResponse(BaseModel):
result: ExpenseApplicationPreviewActionResult
class ExpenseClaimReturnPayload(BaseModel):
class ExpenseClaimActionProtocolPayload(BaseModel):
request_id: str = Field(min_length=1, max_length=120)
expected_status: str = Field(min_length=1, max_length=30)
expected_approval_stage: str = Field(min_length=1, max_length=50)
@field_validator("request_id", "expected_status", "expected_approval_stage")
@classmethod
def validate_action_protocol_text(cls, value: str) -> str:
normalized = str(value or "").strip()
if not normalized:
raise ValueError("审批动作协议字段不能为空。")
return normalized
@field_validator("expected_status")
@classmethod
def normalize_expected_status(cls, value: str) -> str:
return value.lower()
class ExpenseClaimReturnPayload(ExpenseClaimActionProtocolPayload):
reason: str | None = Field(default=None, max_length=500)
reason_codes: list[str] = Field(default_factory=list, max_length=10)
class ExpenseClaimApprovalPayload(BaseModel):
class ExpenseClaimApprovalPayload(ExpenseClaimActionProtocolPayload):
opinion: str | None = Field(default=None, max_length=500)
class ExpenseClaimPaymentPayload(ExpenseClaimActionProtocolPayload):
pass
class TravelReimbursementCalculatorRequest(BaseModel):
days: int = Field(ge=1, le=365)
location: str = Field(min_length=1, max_length=120)

View File

@@ -0,0 +1,96 @@
from __future__ import annotations
from datetime import datetime
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
RiskDispositionAction = Literal[
"confirm",
"false_positive",
"request_supplement",
"start_remediation",
"resolve",
"request_waiver",
]
RiskAdjudication = Literal["unreviewed", "confirmed", "false_positive"]
RiskLifecycleStatus = Literal[
"open",
"supplement_requested",
"remediation_in_progress",
"waiver_requested",
"resolved",
]
class RiskDispositionActionCreate(BaseModel):
action: RiskDispositionAction
expected_version: int = Field(ge=0)
request_id: str = Field(min_length=8, max_length=120)
comment: str | None = Field(default=None, max_length=1000)
assignee: str | None = Field(default=None, max_length=120)
due_at: datetime | None = None
resolution: str | None = Field(default=None, max_length=2000)
@field_validator("request_id", "comment", "assignee", "resolution", mode="before")
@classmethod
def normalize_text(cls, value: Any) -> Any:
if value is None:
return None
normalized = str(value).strip()
return normalized or None
@model_validator(mode="after")
def validate_action_fields(self) -> RiskDispositionActionCreate:
if self.action == "resolve" and not self.resolution:
raise ValueError("resolve 动作必须填写 resolution")
if self.action in {"false_positive", "request_supplement", "request_waiver"} and not (
self.comment
):
raise ValueError(f"{self.action} 动作必须填写 comment")
if self.action in {"confirm", "false_positive"} and any(
value is not None for value in (self.assignee, self.due_at, self.resolution)
):
raise ValueError("裁决动作不能同时修改负责人、截止时间或解决说明")
return self
class RiskDispositionEventRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
tenant_id: str
disposition_id: str
observation_id: str
version: int
action: RiskDispositionAction
actor_id: str
actor_name: str
request_id: str
comment: str | None
before_json: dict[str, Any]
after_json: dict[str, Any]
created_at: datetime
class RiskDispositionRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
tenant_id: str
observation_id: str
adjudication: RiskAdjudication
lifecycle_status: RiskLifecycleStatus
version: int
assignee: str | None
due_at: datetime | None
resolution: str | None
created_at: datetime
updated_at: datetime
events: list[RiskDispositionEventRead] = Field(default_factory=list)
class RiskDispositionMutationRead(BaseModel):
disposition: RiskDispositionRead
event: RiskDispositionEventRead
replayed: bool = False

View File

@@ -5,6 +5,8 @@ from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator
from app.schemas.risk_disposition import RiskDispositionRead
RiskObservationStatus = Literal[
"pending_review",
"confirmed",
@@ -89,6 +91,7 @@ class RiskObservationRead(BaseModel):
created_at: datetime
updated_at: datetime
feedback_items: list[RiskObservationFeedbackRead] = Field(default_factory=list)
disposition: RiskDispositionRead | None = None
class RiskObservationListRead(BaseModel):

View 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()}"

View 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

View 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)

View File

@@ -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)

View File

@@ -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

View 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

View File

@@ -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

View 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"

View File

@@ -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 []))

View File

@@ -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",

View File

@@ -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)

View File

@@ -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

View 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()

View 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)

View File

@@ -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)