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