feat(approval): add safe risk disposition workflow

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

View File

@@ -0,0 +1,95 @@
"""add transactional approval action idempotency ledger
Revision ID: 20260716_0010
Revises: 20260716_0009
Create Date: 2026-07-16 16:10:00
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "20260716_0010"
down_revision: str | None = "20260716_0009"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _require_postgresql() -> None:
dialect_name = op.get_bind().dialect.name
if dialect_name != "postgresql":
raise RuntimeError(
"20260716_0010 only supports PostgreSQL; "
f"refusing to mutate {dialect_name} without transactional constraint DDL"
)
def upgrade() -> None:
_require_postgresql()
op.create_table(
"approval_action_ledgers",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=64), nullable=False),
sa.Column("actor_id", sa.String(length=120), nullable=False),
sa.Column("request_id", sa.String(length=120), nullable=False),
sa.Column("claim_id", sa.String(length=36), nullable=False),
sa.Column("action", sa.String(length=20), nullable=False),
sa.Column("payload_fingerprint", sa.String(length=80), nullable=False),
sa.Column("expected_status", sa.String(length=30), nullable=False),
sa.Column("expected_approval_stage", sa.String(length=50), nullable=False),
sa.Column("result_status", sa.String(length=30), nullable=True),
sa.Column("result_approval_stage", sa.String(length=50), nullable=True),
sa.Column("response_json", sa.JSON(), nullable=True),
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.CheckConstraint(
"action IN ('approve', 'return', 'pay')",
name="ck_approval_action_ledger_action",
),
sa.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",
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint(
"tenant_id",
"actor_id",
"request_id",
name="uq_approval_action_ledger_request",
),
)
op.create_index(
"ix_approval_action_ledger_claim_action",
"approval_action_ledgers",
["tenant_id", "claim_id", "action"],
unique=False,
)
def downgrade() -> None:
_require_postgresql()
ledger_count = int(
op.get_bind().scalar(sa.text("SELECT COUNT(*) FROM approval_action_ledgers")) or 0
)
if ledger_count:
raise RuntimeError(
"cannot downgrade approval action protocol: "
f"approval_action_ledgers contains {ledger_count} audit record(s)"
)
op.drop_index(
"ix_approval_action_ledger_claim_action",
table_name="approval_action_ledgers",
)
op.drop_table("approval_action_ledgers")

View File

@@ -0,0 +1,233 @@
"""add typed risk disposition state and append-only events
Revision ID: 20260716_0011
Revises: 20260716_0010
Create Date: 2026-07-16 16:20:00
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "20260716_0011"
down_revision: str | None = "20260716_0010"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _require_postgresql() -> None:
dialect_name = op.get_bind().dialect.name
if dialect_name != "postgresql":
raise RuntimeError(
"20260716_0011 only supports PostgreSQL; "
f"refusing to mutate {dialect_name} without transactional constraint DDL"
)
def _require_empty_audit_chain_for_downgrade() -> None:
bind = op.get_bind()
disposition_count = int(bind.scalar(sa.text("SELECT COUNT(*) FROM risk_dispositions")) or 0)
event_count = int(bind.scalar(sa.text("SELECT COUNT(*) FROM risk_disposition_events")) or 0)
if disposition_count or event_count:
raise RuntimeError(
"cannot downgrade risk dispositions: audit chain is not empty "
f"({disposition_count} dispositions, {event_count} events)"
)
def upgrade() -> None:
_require_postgresql()
op.create_unique_constraint(
"uq_risk_observations_tenant_id",
"risk_observations",
["tenant_id", "id"],
)
op.create_table(
"risk_dispositions",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=64), nullable=False),
sa.Column("observation_id", sa.String(length=36), nullable=False),
sa.Column(
"adjudication",
sa.String(length=24),
nullable=False,
server_default="unreviewed",
),
sa.Column(
"lifecycle_status",
sa.String(length=32),
nullable=False,
server_default="open",
),
sa.Column("version", sa.Integer(), nullable=False, server_default="0"),
sa.Column("assignee", sa.String(length=120), nullable=True),
sa.Column("due_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("resolution", sa.Text(), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.func.now(),
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.func.now(),
),
sa.CheckConstraint(
"adjudication IN ('unreviewed', 'confirmed', 'false_positive')",
name="ck_risk_dispositions_adjudication",
),
sa.CheckConstraint(
"lifecycle_status IN ('open', 'supplement_requested', "
"'remediation_in_progress', 'waiver_requested', 'resolved')",
name="ck_risk_dispositions_lifecycle",
),
sa.CheckConstraint("version >= 0", name="ck_risk_dispositions_version"),
sa.ForeignKeyConstraint(
["tenant_id", "observation_id"],
["risk_observations.tenant_id", "risk_observations.id"],
name="fk_risk_dispositions_tenant_observation",
ondelete="RESTRICT",
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint(
"tenant_id",
"id",
name="uq_risk_dispositions_tenant_id",
),
sa.UniqueConstraint(
"tenant_id",
"observation_id",
name="uq_risk_dispositions_tenant_observation",
),
)
op.create_index(
"ix_risk_dispositions_tenant_lifecycle_due",
"risk_dispositions",
["tenant_id", "lifecycle_status", "due_at"],
unique=False,
)
op.create_index(
"ix_risk_dispositions_assignee",
"risk_dispositions",
["tenant_id", "assignee"],
unique=False,
)
op.create_table(
"risk_disposition_events",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=64), nullable=False),
sa.Column("disposition_id", sa.String(length=36), nullable=False),
sa.Column("observation_id", sa.String(length=36), nullable=False),
sa.Column("version", sa.Integer(), nullable=False),
sa.Column("action", sa.String(length=32), nullable=False),
sa.Column("actor_id", sa.String(length=120), nullable=False),
sa.Column("actor_name", sa.String(length=120), nullable=False),
sa.Column("request_id", sa.String(length=120), nullable=False),
sa.Column("payload_fingerprint", sa.String(length=64), nullable=False),
sa.Column("comment", sa.Text(), nullable=True),
sa.Column("before_json", sa.JSON(), nullable=False),
sa.Column("after_json", sa.JSON(), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.func.now(),
),
sa.CheckConstraint(
"action IN ('confirm', 'false_positive', 'request_supplement', "
"'start_remediation', 'resolve', 'request_waiver')",
name="ck_risk_disposition_events_action",
),
sa.CheckConstraint(
"version > 0",
name="ck_risk_disposition_events_version",
),
sa.ForeignKeyConstraint(
["tenant_id", "disposition_id"],
["risk_dispositions.tenant_id", "risk_dispositions.id"],
name="fk_risk_disposition_events_tenant_disposition",
ondelete="RESTRICT",
),
sa.ForeignKeyConstraint(
["tenant_id", "observation_id"],
["risk_observations.tenant_id", "risk_observations.id"],
name="fk_risk_disposition_events_tenant_observation",
ondelete="RESTRICT",
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint(
"tenant_id",
"request_id",
name="uq_risk_disposition_events_tenant_request",
),
sa.UniqueConstraint(
"disposition_id",
"version",
name="uq_risk_disposition_events_version",
),
)
op.create_index(
"ix_risk_disposition_events_disposition_id",
"risk_disposition_events",
["disposition_id"],
unique=False,
)
op.create_index(
"ix_risk_disposition_events_tenant_observation_time",
"risk_disposition_events",
["tenant_id", "observation_id", "created_at"],
unique=False,
)
op.execute(
"CREATE FUNCTION reject_risk_disposition_event_mutation() "
"RETURNS trigger AS $$ "
"BEGIN "
"RAISE EXCEPTION 'risk_disposition_events is append-only'; "
"RETURN OLD; "
"END; "
"$$ LANGUAGE plpgsql"
)
op.execute(
"CREATE TRIGGER trg_risk_disposition_events_append_only "
"BEFORE UPDATE OR DELETE ON risk_disposition_events "
"FOR EACH ROW EXECUTE FUNCTION reject_risk_disposition_event_mutation()"
)
def downgrade() -> None:
_require_postgresql()
# 审计链不可静默销毁;数据归档或迁移必须由显式运维流程完成。
_require_empty_audit_chain_for_downgrade()
op.execute("DROP TRIGGER trg_risk_disposition_events_append_only ON risk_disposition_events")
op.execute("DROP FUNCTION reject_risk_disposition_event_mutation()")
op.drop_index(
"ix_risk_disposition_events_tenant_observation_time",
table_name="risk_disposition_events",
)
op.drop_index(
"ix_risk_disposition_events_disposition_id",
table_name="risk_disposition_events",
)
op.drop_table("risk_disposition_events")
op.drop_index(
"ix_risk_dispositions_assignee",
table_name="risk_dispositions",
)
op.drop_index(
"ix_risk_dispositions_tenant_lifecycle_due",
table_name="risk_dispositions",
)
op.drop_table("risk_dispositions")
op.drop_constraint(
"uq_risk_observations_tenant_id",
"risk_observations",
type_="unique",
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,270 @@
from __future__ import annotations
import hashlib
import json
import threading
import uuid
from collections.abc import Callable, Iterator, Mapping
from contextlib import contextmanager
from dataclasses import dataclass
from datetime import UTC, datetime
from typing import Any
from sqlalchemy import select, text
from sqlalchemy.orm import Session
from app.api.deps import CurrentUserContext
from app.models.approval_action import ApprovalActionLedger
from app.models.financial_record import ExpenseClaim
from app.services.expense_claim_tenant_scope import ExpenseClaimTenantScopeMixin
class ApprovalActionConflictError(ValueError):
"""请求已被占用,或动作前置状态已经过期。"""
@dataclass(frozen=True, slots=True)
class ApprovalActionStart:
claim: ExpenseClaim | None
ledger: ApprovalActionLedger | None
request_id: str
replayed: bool
class ApprovalActionProtocol:
"""为 approve/return/pay 提供并发安全和事务内幂等语义。"""
_fallback_registry_guard = threading.Lock()
_fallback_locks: dict[str, tuple[threading.RLock, int]] = {}
def __init__(self, db: Session) -> None:
self.db = db
@contextmanager
def serialize_request(
self,
*,
tenant_id: str,
actor_id: str,
request_id: str | None,
) -> Iterator[str]:
normalized_request_id = self._normalize_request_id(request_id)
lock_name = f"approval-action:{tenant_id}:{actor_id}:{normalized_request_id}"
if self._dialect_name() == "postgresql":
self.db.execute(
text("SELECT pg_advisory_xact_lock(:lock_id)"),
{"lock_id": self._signed_lock_id(lock_name)},
)
yield normalized_request_id
return
with self._serialize_fallback(lock_name):
yield normalized_request_id
def begin(
self,
*,
action: str,
claim_id: str,
current_user: CurrentUserContext,
request_id: str,
expected_status: str | None,
expected_approval_stage: str | None,
payload: Mapping[str, Any],
claim_loader: Callable[[], ExpenseClaim | None],
replay_claim_loader: Callable[[], ExpenseClaim | None],
) -> ApprovalActionStart:
tenant_id = ExpenseClaimTenantScopeMixin.normalize_tenant_id(current_user.tenant_id)
actor_id = self._normalize_actor_id(current_user.username)
normalized_action = self._normalize_action(action)
existing = self.db.scalar(
select(ApprovalActionLedger).where(
ApprovalActionLedger.tenant_id == tenant_id,
ApprovalActionLedger.actor_id == actor_id,
ApprovalActionLedger.request_id == request_id,
)
)
if existing is not None:
supplied_fingerprint = self._fingerprint(
action=normalized_action,
claim_id=claim_id,
expected_status=expected_status or existing.expected_status,
expected_approval_stage=(
expected_approval_stage or existing.expected_approval_stage
),
payload=payload,
)
if supplied_fingerprint != existing.payload_fingerprint:
raise ApprovalActionConflictError(
"该 request_id 已用于另一项审批动作,请生成新的 request_id 后重试。"
)
if existing.completed_at is None:
raise ApprovalActionConflictError("该审批动作仍在处理中,请稍后重试。")
return ApprovalActionStart(
claim=replay_claim_loader(),
ledger=existing,
request_id=request_id,
replayed=True,
)
claim = claim_loader()
if claim is None:
return ApprovalActionStart(
claim=None,
ledger=None,
request_id=request_id,
replayed=False,
)
actual_tenant_id = ExpenseClaimTenantScopeMixin.resolve_claim_tenant_id(
self.db,
claim.id,
)
if actual_tenant_id != tenant_id:
# 正常情况下租户查询范围会先拦截;这里是写入账本前的纵深校验。
raise ApprovalActionConflictError("单据租户上下文已变化,请刷新后重试。")
current_status = self._normalize_status(claim.status)
current_stage = self._normalize_stage(claim.approval_stage)
normalized_expected_status = self._normalize_status(
expected_status if expected_status is not None else current_status
)
normalized_expected_stage = self._normalize_stage(
expected_approval_stage if expected_approval_stage is not None else current_stage
)
if normalized_expected_status != current_status:
raise ApprovalActionConflictError(
f"单据状态已从 {normalized_expected_status} 变为 {current_status},请刷新后重试。"
)
if normalized_expected_stage != current_stage:
raise ApprovalActionConflictError(
f"审批节点已从 {normalized_expected_stage} 变为 {current_stage},请刷新后重试。"
)
ledger = ApprovalActionLedger(
tenant_id=tenant_id,
actor_id=actor_id,
request_id=request_id,
claim_id=str(claim.id),
action=normalized_action,
payload_fingerprint=self._fingerprint(
action=normalized_action,
claim_id=claim.id,
expected_status=normalized_expected_status,
expected_approval_stage=normalized_expected_stage,
payload=payload,
),
expected_status=normalized_expected_status,
expected_approval_stage=normalized_expected_stage,
)
self.db.add(ledger)
self.db.flush()
return ApprovalActionStart(
claim=claim,
ledger=ledger,
request_id=request_id,
replayed=False,
)
def complete(
self,
ledger: ApprovalActionLedger,
claim: ExpenseClaim,
*,
response_json: Mapping[str, Any] | None = None,
) -> None:
ledger.result_status = self._normalize_status(claim.status)
ledger.result_approval_stage = self._normalize_stage(claim.approval_stage)
ledger.response_json = dict(response_json or {})
ledger.completed_at = datetime.now(UTC)
self.db.flush()
def _dialect_name(self) -> str:
bind = self.db.get_bind()
return str(bind.dialect.name if bind is not None else "")
@classmethod
@contextmanager
def _serialize_fallback(cls, name: str) -> Iterator[None]:
with cls._fallback_registry_guard:
lock, references = cls._fallback_locks.get(
name,
(threading.RLock(), 0),
)
cls._fallback_locks[name] = (lock, references + 1)
try:
with lock:
yield
finally:
with cls._fallback_registry_guard:
current = cls._fallback_locks.get(name)
if current is None or current[0] is not lock:
return
if current[1] <= 1:
cls._fallback_locks.pop(name, None)
else:
cls._fallback_locks[name] = (lock, current[1] - 1)
@staticmethod
def _signed_lock_id(value: str) -> int:
unsigned = int.from_bytes(
hashlib.sha256(value.encode("utf-8")).digest()[:8],
byteorder="big",
signed=False,
)
return unsigned - (1 << 64) if unsigned >= (1 << 63) else unsigned
@staticmethod
def _normalize_request_id(value: str | None) -> str:
normalized = str(value or "").strip() or f"internal:{uuid.uuid4()}"
if len(normalized) > 120:
raise ValueError("request_id 最长为 120 个字符。")
return normalized
@staticmethod
def _normalize_actor_id(value: str | None) -> str:
normalized = str(value or "").strip().casefold()
if not normalized:
raise ValueError("当前用户缺少可审计的账号标识。")
return normalized
@staticmethod
def _normalize_action(value: str) -> str:
normalized = str(value or "").strip().lower()
if normalized not in {"approve", "return", "pay"}:
raise ValueError("不支持的审批动作。")
return normalized
@staticmethod
def _normalize_status(value: str | None) -> str:
return str(value or "").strip().lower()
@staticmethod
def _normalize_stage(value: str | None) -> str:
return str(value or "").strip()
@classmethod
def _fingerprint(
cls,
*,
action: str,
claim_id: str,
expected_status: str,
expected_approval_stage: str,
payload: Mapping[str, Any],
) -> str:
canonical = {
"action": cls._normalize_action(action),
"claim_id": str(claim_id or "").strip(),
"expected_status": cls._normalize_status(expected_status),
"expected_approval_stage": cls._normalize_stage(expected_approval_stage),
"payload": dict(payload),
}
encoded = json.dumps(
canonical,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
default=str,
).encode("utf-8")
return f"sha256:{hashlib.sha256(encoded).hexdigest()}"

View File

@@ -0,0 +1,518 @@
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from decimal import Decimal, InvalidOperation
from typing import Any
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.api.deps import CurrentUserContext
from app.models.financial_record import ExpenseClaim
from app.models.risk_disposition import RiskDisposition
from app.models.risk_observation import RiskObservation
from app.schemas.approval_workbench import (
ApprovalWorkbenchEvidenceRead,
ApprovalWorkbenchItemRead,
ApprovalWorkbenchListRead,
ApprovalWorkbenchPriorityReasonRead,
ApprovalWorkbenchSuggestionRead,
)
from app.schemas.reimbursement import ExpenseClaimRead
from app.services.expense_claim_risk_flags import (
claim_risk_flag_observation_key,
claim_risk_flag_severity,
is_open_claim_risk_flag,
)
from app.services.expense_claim_tenant_scope import ExpenseClaimTenantScopeMixin
_SLA_HOURS = 24
_RISK_WEIGHTS = {"critical": 38, "high": 30, "medium": 14, "low": 0}
_RISK_ORDER = {"low": 0, "medium": 1, "high": 2, "critical": 3}
_RESOLVED_STATUSES = {"resolved", "accepted", "waived", "false_positive"}
class ApprovalWorkbenchService:
"""构建只读、可解释且不扩大审批权限的例外审批队列。"""
def __init__(self, db: Session) -> None:
self.db = db
def list_items(
self,
current_user: CurrentUserContext,
*,
limit: int = 100,
now: datetime | None = None,
) -> ApprovalWorkbenchListRead:
# 延迟导入,避免 ExpenseClaimService 的 mixin 聚合产生循环依赖。
from app.services.expense_claims import ExpenseClaimService
generated_at = now or datetime.now(UTC)
claims = ExpenseClaimService(self.db).list_approval_claims(current_user)
observations_by_claim = self._risk_observations_by_claim(
claims,
tenant_id=current_user.tenant_id,
)
items = [
self.build_item(
claim,
now=generated_at,
observation_rows=observations_by_claim.get(claim.id),
)
for claim in claims
]
items.sort(
key=lambda item: (
-item.priority_score,
-item.waiting_hours,
item.claim.claim_no,
)
)
return ApprovalWorkbenchListRead(
items=items[: max(1, min(int(limit), 200))],
total=len(items),
generated_at=generated_at,
)
@classmethod
def build_item(
cls,
claim: ExpenseClaim,
*,
now: datetime | None = None,
observation_rows: list[tuple[RiskObservation, RiskDisposition | None]] | None = None,
) -> ApprovalWorkbenchItemRead:
generated_at = now or datetime.now(UTC)
submitted_at = cls._aware(claim.submitted_at or claim.created_at)
waiting_hours = max(
0.0,
(generated_at - submitted_at).total_seconds() / 3600,
)
sla_due_at = submitted_at + timedelta(hours=_SLA_HOURS)
risk_level, open_risk_count = cls._combined_risk_summary(
claim,
observation_rows or [],
)
budget_usage_rate = cls._budget_usage_rate(claim.risk_flags_json)
evidence = cls._evidence_summary(claim)
reasons = cls._priority_reasons(
claim=claim,
risk_level=risk_level,
open_risk_count=open_risk_count,
budget_usage_rate=budget_usage_rate,
waiting_hours=waiting_hours,
evidence=evidence,
)
score = min(100, sum(item.weight for item in reasons))
tier = "urgent" if score >= 65 else "high" if score >= 40 else "normal"
return ApprovalWorkbenchItemRead(
task_key=f"{claim.id}:{claim.approval_stage or ''}:{submitted_at.isoformat()}",
claim=cls._safe_claim_read(claim),
priority_score=score,
priority_tier=tier,
priority_reasons=reasons,
risk_level=risk_level,
open_risk_count=open_risk_count,
budget_usage_rate=budget_usage_rate,
waiting_hours=round(waiting_hours, 2),
sla_due_at=sla_due_at,
sla_overdue=generated_at >= sla_due_at,
evidence=evidence,
suggestion=cls._suggestion(
risk_level=risk_level,
open_risk_count=open_risk_count,
budget_usage_rate=budget_usage_rate,
evidence=evidence,
),
)
def _risk_observations_by_claim(
self,
claims: list[ExpenseClaim],
*,
tenant_id: str | None,
) -> dict[str, list[tuple[RiskObservation, RiskDisposition | None]]]:
claim_ids = [str(claim.id) for claim in claims if str(claim.id or "").strip()]
if not claim_ids:
return {}
normalized_tenant = ExpenseClaimTenantScopeMixin.normalize_tenant_id(tenant_id)
rows = self.db.execute(
select(RiskObservation, RiskDisposition)
.outerjoin(
RiskDisposition,
(
(RiskDisposition.tenant_id == RiskObservation.tenant_id)
& (RiskDisposition.observation_id == RiskObservation.id)
),
)
.where(
RiskObservation.tenant_id == normalized_tenant,
RiskObservation.claim_id.in_(claim_ids),
)
).all()
grouped: dict[str, list[tuple[RiskObservation, RiskDisposition | None]]] = {}
for observation, disposition in rows:
grouped.setdefault(str(observation.claim_id), []).append((observation, disposition))
return grouped
@classmethod
def _safe_claim_read(cls, claim: ExpenseClaim) -> ExpenseClaimRead:
claim_read = ExpenseClaimRead.model_validate(claim)
claim_read.risk_flags_json = [
cls._safe_risk_flag(flag)
for flag in list(claim_read.risk_flags_json or [])
if isinstance(flag, dict)
]
return claim_read
@staticmethod
def _safe_risk_flag(flag: dict[str, Any]) -> dict[str, Any]:
safe_flag = dict(flag)
if "historical_case_evidence" not in safe_flag:
return safe_flag
evidence: list[dict[str, Any]] = []
for item in list(safe_flag.get("historical_case_evidence") or []):
if not isinstance(item, dict):
continue
label = str(item.get("label") or "").strip().lower()
if label not in {"confirmed", "false_positive"}:
continue
evidence.append(
{
"label": label,
"label_text": (
"历史已确认,仅供复核" if label == "confirmed" else "历史误报,仅供复核"
),
"advisory_only": True,
"stale": bool(item.get("stale")),
"summary": (
"历史相似案例经人工复核确认风险成立。"
if label == "confirmed"
else "历史相似案例经人工复核判定为误报。"
),
}
)
safe_flag["historical_case_evidence"] = evidence
return safe_flag
@classmethod
def _priority_reasons(
cls,
*,
claim: ExpenseClaim,
risk_level: str,
open_risk_count: int,
budget_usage_rate: float | None,
waiting_hours: float,
evidence: ApprovalWorkbenchEvidenceRead,
) -> list[ApprovalWorkbenchPriorityReasonRead]:
reasons: list[ApprovalWorkbenchPriorityReasonRead] = []
risk_weight = _RISK_WEIGHTS[risk_level]
if risk_weight:
reasons.append(
ApprovalWorkbenchPriorityReasonRead(
code="open_risk",
label=f"{risk_level.upper()} 风险 {open_risk_count} 项待复核",
weight=risk_weight,
tone="danger" if risk_level in {"high", "critical"} else "warning",
)
)
if waiting_hours >= _SLA_HOURS:
reasons.append(
ApprovalWorkbenchPriorityReasonRead(
code="sla_overdue",
label="已超过 24 小时审批 SLA",
weight=26,
tone="danger",
)
)
elif waiting_hours >= 16:
reasons.append(
ApprovalWorkbenchPriorityReasonRead(
code="sla_near_due",
label="审批 SLA 即将到期",
weight=16,
tone="warning",
)
)
elif waiting_hours >= 8:
reasons.append(
ApprovalWorkbenchPriorityReasonRead(
code="waiting",
label="等待时间已超过 8 小时",
weight=8,
tone="warning",
)
)
if budget_usage_rate is not None and budget_usage_rate >= 90:
reasons.append(
ApprovalWorkbenchPriorityReasonRead(
code="budget_pressure",
label=f"审批后预算占用约 {budget_usage_rate:.0f}%",
weight=18,
tone="danger" if budget_usage_rate >= 100 else "warning",
)
)
amount = cls._decimal(claim.amount)
if amount >= Decimal("50000"):
reasons.append(
ApprovalWorkbenchPriorityReasonRead(
code="large_amount",
label="大额费用需重点核对",
weight=15,
tone="warning",
)
)
elif amount >= Decimal("10000"):
reasons.append(
ApprovalWorkbenchPriorityReasonRead(
code="amount_attention",
label="金额超过 1 万元",
weight=8,
tone="warning",
)
)
if evidence.completeness < 1:
reasons.append(
ApprovalWorkbenchPriorityReasonRead(
code="evidence_gap",
label="材料仍有缺口:" + "".join(evidence.missing_labels),
weight=12,
tone="warning",
)
)
if not reasons:
reasons.append(
ApprovalWorkbenchPriorityReasonRead(
code="routine",
label="常规低风险待办",
)
)
return reasons
@classmethod
def _risk_summary(
cls,
raw_flags: Any,
*,
claim_id: str = "",
materialized_keys: set[str] | None = None,
) -> tuple[str, int]:
flags = raw_flags if isinstance(raw_flags, list) else [raw_flags]
persisted_keys = materialized_keys or set()
level = "low"
count = 0
for flag in flags:
if not isinstance(flag, dict) or not is_open_claim_risk_flag(flag):
continue
observation_key = claim_risk_flag_observation_key(flag, claim_id=claim_id)
if observation_key and observation_key in persisted_keys:
continue
candidate = claim_risk_flag_severity(flag) or "medium"
if candidate not in _RISK_ORDER:
candidate = "medium"
count += 1
if _RISK_ORDER[candidate] > _RISK_ORDER[level]:
level = candidate
return level, count
@classmethod
def _combined_risk_summary(
cls,
claim: ExpenseClaim,
rows: list[tuple[RiskObservation, RiskDisposition | None]],
) -> tuple[str, int]:
persisted_level, persisted_count = cls._persisted_risk_summary(rows)
materialized_keys = {
str(observation.observation_key or "").strip()
for observation, _disposition in rows
if str(observation.observation_key or "").strip()
}
raw_level, raw_count = cls._risk_summary(
claim.risk_flags_json,
claim_id=str(claim.id or ""),
materialized_keys=materialized_keys,
)
return (
max((persisted_level, raw_level), key=lambda item: _RISK_ORDER[item]),
persisted_count + raw_count,
)
@staticmethod
def _persisted_risk_summary(
rows: list[tuple[RiskObservation, RiskDisposition | None]],
) -> tuple[str, int]:
level = "low"
count = 0
for observation, disposition in rows:
adjudication = (
str(disposition.adjudication or "").strip().lower()
if disposition is not None
else str(observation.feedback_status or "").strip().lower()
)
lifecycle = (
str(disposition.lifecycle_status or "").strip().lower()
if disposition is not None
else "open"
)
status = str(observation.status or "").strip().lower()
if (
adjudication == "false_positive"
or lifecycle == "resolved"
or status in _RESOLVED_STATUSES
):
continue
candidate = str(observation.risk_level or "medium").strip().lower()
if candidate == "danger":
candidate = "high"
if candidate not in _RISK_ORDER:
candidate = "medium"
count += 1
if _RISK_ORDER[candidate] > _RISK_ORDER[level]:
level = candidate
return level, count
@classmethod
def _budget_usage_rate(cls, raw_flags: Any) -> float | None:
values: list[float] = []
flags = raw_flags if isinstance(raw_flags, list) else [raw_flags]
for flag in flags:
if not isinstance(flag, dict):
continue
root_containers = [
flag,
flag.get("metrics"),
flag.get("budget_result"),
(flag.get("route_decision") or {}).get("budget_result")
if isinstance(flag.get("route_decision"), dict)
else None,
]
containers = [item for item in root_containers if isinstance(item, dict)]
containers.extend(
item["metrics"]
for item in list(containers)
if isinstance(item.get("metrics"), dict)
)
for container in containers:
for key in (
"after_usage_rate",
"budget_usage_rate",
"usage_rate",
"utilization_rate",
):
value = cls._float(container.get(key))
if value is None:
continue
values.append(value * 100 if 0 < value <= 1 else value)
return round(max(values), 2) if values else None
@classmethod
def _evidence_summary(cls, claim: ExpenseClaim) -> ApprovalWorkbenchEvidenceRead:
is_application = cls._is_application(claim)
checks = [
("事由", bool(str(claim.reason or "").strip())),
("地点", bool(str(claim.location or "").strip())),
("费用明细", bool(list(claim.items or []))),
]
if not is_application:
checks.append(
(
"票据",
int(claim.invoice_count or 0) > 0
or any(str(item.invoice_id or "").strip() for item in claim.items or []),
)
)
missing = [label for label, present in checks if not present]
historical_labels = cls._historical_labels(claim.risk_flags_json)
present_count = len(checks) - len(missing)
return ApprovalWorkbenchEvidenceRead(
completeness=round(present_count / len(checks), 4) if checks else 1,
present_count=present_count,
required_count=len(checks),
missing_labels=missing,
historical_labels=historical_labels,
)
@staticmethod
def _historical_labels(raw_flags: Any) -> list[str]:
result: list[str] = []
flags = raw_flags if isinstance(raw_flags, list) else [raw_flags]
for flag in flags:
if not isinstance(flag, dict):
continue
for item in list(flag.get("historical_case_evidence") or []):
if not isinstance(item, dict):
continue
label = str(item.get("label") or "").strip().lower()
text = (
"历史已确认,仅供复核"
if label == "confirmed"
else "历史误报,仅供复核"
if label == "false_positive"
else ""
)
if text and text not in result:
result.append(text)
return result
@staticmethod
def _suggestion(
*,
risk_level: str,
open_risk_count: int,
budget_usage_rate: float | None,
evidence: ApprovalWorkbenchEvidenceRead,
) -> ApprovalWorkbenchSuggestionRead:
if risk_level in {"high", "critical"} and open_risk_count:
return ApprovalWorkbenchSuggestionRead(
action="manual_review",
label="先核对风险再决策",
reason="存在高风险关注项AI 不建议直接通过。",
)
if evidence.missing_labels:
return ApprovalWorkbenchSuggestionRead(
action="request_supplement",
label="建议退回补充材料",
reason="缺少" + "".join(evidence.missing_labels) + "",
)
if budget_usage_rate is not None and budget_usage_rate >= 90:
return ApprovalWorkbenchSuggestionRead(
action="budget_review",
label="重点复核预算影响",
reason="审批后预算占用已达到复核线。",
)
return ApprovalWorkbenchSuggestionRead(
action="approve_candidate",
label="可人工确认后通过",
reason="材料相对完整且未发现高风险阻断项。",
)
@staticmethod
def _is_application(claim: ExpenseClaim) -> bool:
claim_no = str(claim.claim_no or "").strip().upper()
expense_type = str(claim.expense_type or "").strip().lower()
return (
claim_no.startswith(("AP-", "APP-"))
or expense_type == "application"
or expense_type.endswith("_application")
)
@staticmethod
def _aware(value: datetime) -> datetime:
return value if value.tzinfo is not None else value.replace(tzinfo=UTC)
@staticmethod
def _decimal(value: Any) -> Decimal:
try:
return Decimal(str(value or "0"))
except (InvalidOperation, ValueError):
return Decimal("0")
@staticmethod
def _float(value: Any) -> float | None:
try:
number = float(value)
except (TypeError, ValueError):
return None
return number if number >= 0 else None

View File

@@ -0,0 +1,120 @@
from __future__ import annotations
from collections.abc import Callable, Mapping
from typing import Any
from sqlalchemy import select
from app.api.deps import CurrentUserContext
from app.models.approval_action import ApprovalActionLedger
from app.models.financial_record import ExpenseClaim
from app.services.approval_action_protocol import ApprovalActionProtocol
from app.services.expense_claim_tenant_scope import ExpenseClaimTenantScopeMixin
ClaimActionExecutor = Callable[
[ExpenseClaim, ApprovalActionLedger, str],
ExpenseClaim,
]
class ExpenseClaimActionProtocolMixin:
def _execute_claim_action(
self,
*,
action: str,
claim_id: str,
current_user: CurrentUserContext,
request_id: str | None,
expected_status: str | None,
expected_approval_stage: str | None,
payload: Mapping[str, Any],
executor: ClaimActionExecutor,
) -> ExpenseClaim | None:
protocol = ApprovalActionProtocol(self.db)
tenant_id = ExpenseClaimTenantScopeMixin.normalize_tenant_id(current_user.tenant_id)
actor_id = str(current_user.username or "").strip().casefold()
with protocol.serialize_request(
tenant_id=tenant_id,
actor_id=actor_id,
request_id=request_id,
) as normalized_request_id:
try:
started = protocol.begin(
action=action,
claim_id=claim_id,
current_user=current_user,
request_id=normalized_request_id,
expected_status=expected_status,
expected_approval_stage=expected_approval_stage,
payload=payload,
claim_loader=lambda: self._load_claim_for_action(
claim_id,
current_user,
),
replay_claim_loader=lambda: self._load_claim_for_replay(
claim_id,
current_user,
),
)
if started.claim is None:
self.db.rollback()
return None
if started.replayed:
self.db.commit()
self.db.refresh(started.claim)
return self._access_policy.attach_approval_snapshot(started.claim)
if started.ledger is None: # pragma: no cover - defensive invariant
raise RuntimeError("审批动作账本初始化失败。")
claim = executor(
started.claim,
started.ledger,
normalized_request_id,
)
protocol.complete(
started.ledger,
claim,
response_json={
"claim_id": claim.id,
"status": str(claim.status or "").strip(),
"approval_stage": str(claim.approval_stage or "").strip(),
},
)
self.db.commit()
self.db.refresh(claim)
return self._access_policy.attach_approval_snapshot(claim)
except Exception:
self.db.rollback()
raise
def _load_claim_for_action(
self,
claim_id: str,
current_user: CurrentUserContext,
) -> ExpenseClaim | None:
stmt = select(ExpenseClaim).where(ExpenseClaim.id == claim_id)
stmt = self._access_policy.apply_claim_scope(
stmt,
current_user,
include_approval_scope=True,
)
bind = self.db.get_bind()
if bind is not None and bind.dialect.name == "postgresql":
stmt = stmt.with_for_update()
claim = self.db.scalar(stmt)
if claim is not None:
# 动作协议已经持有事务级请求锁和 Claim 行锁;这里只允许在同一
# 事务内修正对象,不能调用会自行 commit 的读取兼容修复入口。
self._repair_duplicate_budget_approval_stage(claim)
return claim
def _load_claim_for_replay(
self,
claim_id: str,
current_user: CurrentUserContext,
) -> ExpenseClaim | None:
stmt = select(ExpenseClaim).where(
ExpenseClaim.id == claim_id,
ExpenseClaimTenantScopeMixin.build_claim_tenant_condition(current_user.tenant_id),
)
return self.db.scalar(stmt)

View File

@@ -6,8 +6,14 @@ from decimal import Decimal, InvalidOperation
from typing import Any
from app.api.deps import CurrentUserContext
from app.models.approval_action import ApprovalActionLedger
from app.models.financial_record import ExpenseClaim
from app.services.budget import BudgetService
from app.services.expense_claim_risk_gate import ExpenseClaimRiskGate
from app.services.expense_claim_risk_stage import (
risk_business_stage_for_claim,
with_risk_business_stage,
)
from app.services.expense_claim_workflow_constants import (
APPLICATION_LINK_STATUS_STAGE,
BUDGET_MANAGER_APPROVAL_STAGE,
@@ -18,10 +24,6 @@ from app.services.expense_claim_workflow_constants import (
PAYMENT_PENDING_STAGE,
PAYMENT_PENDING_STATUS,
)
from app.services.expense_claim_risk_stage import (
risk_business_stage_for_claim,
with_risk_business_stage,
)
class ExpenseClaimApprovalFlowMixin:
@@ -31,10 +33,37 @@ class ExpenseClaimApprovalFlowMixin:
current_user: CurrentUserContext,
*,
opinion: str | None = None,
request_id: str | None = None,
expected_status: str | None = None,
expected_approval_stage: str | None = None,
):
claim = self.get_claim(claim_id, current_user)
if claim is None:
return None
normalized_opinion = str(opinion or "").strip()
return self._execute_claim_action(
action="approve",
claim_id=claim_id,
current_user=current_user,
request_id=request_id,
expected_status=expected_status,
expected_approval_stage=expected_approval_stage,
payload={"opinion": normalized_opinion},
executor=lambda claim, ledger, normalized_request_id: self._approve_claim_once(
claim,
current_user,
opinion=normalized_opinion,
ledger=ledger,
request_id=normalized_request_id,
),
)
def _approve_claim_once(
self,
claim: ExpenseClaim,
current_user: CurrentUserContext,
*,
opinion: str,
ledger: ApprovalActionLedger,
request_id: str,
) -> ExpenseClaim:
normalized_status = str(claim.status or "").strip().lower()
if normalized_status != "submitted":
@@ -50,8 +79,11 @@ class ExpenseClaimApprovalFlowMixin:
if previous_stage == DIRECT_MANAGER_APPROVAL_STAGE:
if not self._access_policy.can_approve_claim(current_user, claim):
raise ValueError("只有当前直属领导审批人可以审批通过该单据。")
self._ensure_claim_has_no_blocking_risk(claim, current_user)
approval_source = "manual_approval"
event_type = "expense_application_approval" if is_application_claim else "expense_claim_approval"
event_type = (
"expense_application_approval" if is_application_claim else "expense_claim_approval"
)
label = "领导审批通过"
route_decision_flag = self._build_approval_route_decision(
claim,
@@ -67,18 +99,29 @@ class ExpenseClaimApprovalFlowMixin:
label = "领导及预算审核通过"
next_status = "approved"
next_stage = APPLICATION_LINK_STATUS_STAGE
default_message = "{operator} 已完成直属领导和预算管理者审核,申请流程完成并生成报销草稿。"
default_message = (
"{operator} 已完成直属领导和预算管理者审核,申请流程完成并生成报销草稿。"
)
elif requires_budget_review:
next_budget_manager = self._access_policy.resolve_department_budget_manager(claim)
next_budget_manager = self._access_policy.resolve_department_budget_manager(
claim
)
if next_budget_manager is None:
raise ValueError("未找到同部门 P8 预算审批人,无法流转预算审批。请先配置预算审批人。")
raise ValueError(
"未找到同部门 P8 预算审批人,无法流转预算审批。请先配置预算审批人。"
)
next_status = "submitted"
next_stage = BUDGET_MANAGER_APPROVAL_STAGE
default_message = "{operator} 已确认直属领导审核,因预算或风险关注项流转至预算管理者审批。"
default_message = (
"{operator} 已确认直属领导审核,因预算或风险关注项流转至预算管理者审批。"
)
else:
next_status = "approved"
next_stage = APPLICATION_LINK_STATUS_STAGE
default_message = "{operator} 已确认直属领导审核,系统判断预算充足且无风险,申请流程完成并生成报销草稿。"
default_message = (
"{operator} 已确认直属领导审核,系统判断预算充足且无风险,"
"申请流程完成并生成报销草稿。"
)
else:
merged_budget_approval = (
requires_budget_review
@@ -88,21 +131,32 @@ class ExpenseClaimApprovalFlowMixin:
label = "领导及预算审核通过"
next_status = "submitted"
next_stage = FINANCE_APPROVAL_STAGE
default_message = "{operator} 已完成直属领导和预算管理者审核,流转至{next_stage}"
default_message = (
"{operator} 已完成直属领导和预算管理者审核,流转至{next_stage}"
)
elif requires_budget_review:
next_budget_manager = self._access_policy.resolve_department_budget_manager(claim)
next_budget_manager = self._access_policy.resolve_department_budget_manager(
claim
)
if next_budget_manager is None:
raise ValueError("未找到同部门 P8 预算审批人,无法流转预算审批。请先配置预算审批人。")
raise ValueError(
"未找到同部门 P8 预算审批人,无法流转预算审批。请先配置预算审批人。"
)
next_status = "submitted"
next_stage = BUDGET_MANAGER_APPROVAL_STAGE
default_message = "{operator} 已审批通过,因预算或风险关注项流转至预算管理者审批。"
default_message = (
"{operator} 已审批通过,因预算或风险关注项流转至预算管理者审批。"
)
else:
next_status = "submitted"
next_stage = FINANCE_APPROVAL_STAGE
default_message = "{operator} 已审批通过,系统判断预算充足且无风险,流转至{next_stage}"
default_message = (
"{operator} 已审批通过,系统判断预算充足且无风险,流转至{next_stage}"
)
elif previous_stage == BUDGET_MANAGER_APPROVAL_STAGE:
if not self._access_policy.can_approve_claim(current_user, claim):
raise ValueError("只有当前预算管理者可以审批通过该单据。")
self._ensure_claim_has_no_blocking_risk(claim, current_user)
approval_source = "budget_approval"
event_type = (
"expense_application_budget_approval"
@@ -123,6 +177,7 @@ class ExpenseClaimApprovalFlowMixin:
raise ValueError("费用申请需先完成预算管理者审批。")
if not self._access_policy.can_approve_claim(current_user, claim):
raise ValueError("只有财务人员可以完成财务终审。")
self._ensure_claim_has_no_blocking_risk(claim, current_user)
approval_source = "finance_approval"
event_type = "expense_claim_finance_approval"
label = "财务审核通过"
@@ -146,7 +201,10 @@ class ExpenseClaimApprovalFlowMixin:
and not approval_opinion
):
raise ValueError("预算已超过警戒值,预算管理者需填写审批意见后才能通过。")
if previous_stage in {DIRECT_MANAGER_APPROVAL_STAGE, BUDGET_MANAGER_APPROVAL_STAGE} and not approval_opinion:
if (
previous_stage in {DIRECT_MANAGER_APPROVAL_STAGE, BUDGET_MANAGER_APPROVAL_STAGE}
and not approval_opinion
):
approval_opinion = "同意"
before_json = self._serialize_claim(claim)
@@ -163,7 +221,8 @@ class ExpenseClaimApprovalFlowMixin:
"approval_event_id": str(uuid.uuid4()),
"severity": "info",
"label": label,
"message": approval_opinion or default_message.format(operator=operator, next_stage=next_stage),
"message": approval_opinion
or default_message.format(operator=operator, next_stage=next_stage),
"opinion": approval_opinion,
"operator": operator,
"operator_username": current_user.username,
@@ -193,8 +252,10 @@ class ExpenseClaimApprovalFlowMixin:
"next_approver_name": str(next_budget_manager.name or "").strip(),
"next_approver_employee_id": next_budget_manager.id,
"next_approver_grade": str(next_budget_manager.grade or "").strip(),
"next_approver_role_code": self._access_policy.resolve_budget_approval_role_code(
next_budget_manager,
"next_approver_role_code": (
self._access_policy.resolve_budget_approval_role_code(
next_budget_manager,
)
),
}
)
@@ -299,10 +360,6 @@ class ExpenseClaimApprovalFlowMixin:
update_case_state=False,
)
self.db.commit()
self.db.refresh(claim)
self._access_policy.attach_budget_approval_snapshot(claim)
self.audit_service.log_action(
actor=operator,
action="expense_claim.approve",
@@ -310,18 +367,55 @@ class ExpenseClaimApprovalFlowMixin:
resource_id=claim.id,
before_json=before_json,
after_json=self._serialize_claim(claim),
request_id=request_id,
commit=False,
)
return claim
def _ensure_claim_has_no_blocking_risk(
self,
claim: ExpenseClaim,
current_user: CurrentUserContext,
) -> None:
ExpenseClaimRiskGate(self.db).ensure_approvable(
claim,
tenant_id=current_user.tenant_id,
)
def mark_claim_paid(
self,
claim_id: str,
current_user: CurrentUserContext,
*,
request_id: str | None = None,
expected_status: str | None = None,
expected_approval_stage: str | None = None,
):
claim = self.get_claim(claim_id, current_user)
if claim is None:
return None
return self._execute_claim_action(
action="pay",
claim_id=claim_id,
current_user=current_user,
request_id=request_id,
expected_status=expected_status,
expected_approval_stage=expected_approval_stage,
payload={},
executor=lambda claim, ledger, normalized_request_id: self._mark_claim_paid_once(
claim,
current_user,
ledger=ledger,
request_id=normalized_request_id,
),
)
def _mark_claim_paid_once(
self,
claim: ExpenseClaim,
current_user: CurrentUserContext,
*,
ledger: ApprovalActionLedger,
request_id: str,
) -> ExpenseClaim:
normalized_status = str(claim.status or "").strip().lower()
if normalized_status == PAYMENT_PAID_STATUS:
@@ -412,9 +506,6 @@ class ExpenseClaimApprovalFlowMixin:
update_case_state=False,
)
self.db.commit()
self.db.refresh(claim)
self.audit_service.log_action(
actor=operator,
action="expense_claim.mark_paid",
@@ -422,6 +513,8 @@ class ExpenseClaimApprovalFlowMixin:
resource_id=claim.id,
before_json=before_json,
after_json=self._serialize_claim(claim),
request_id=request_id,
commit=False,
)
return claim
@@ -440,7 +533,9 @@ class ExpenseClaimApprovalFlowMixin:
def _budget_approval_opinion_required(self, claim) -> bool:
budget_result = BudgetService(self.db).analyze_claim_budget(claim)
metrics = budget_result.get("metrics") if isinstance(budget_result.get("metrics"), dict) else {}
metrics = (
budget_result.get("metrics") if isinstance(budget_result.get("metrics"), dict) else {}
)
context = (
budget_result.get("budget_context")
if isinstance(budget_result.get("budget_context"), dict)

View File

@@ -30,7 +30,6 @@ class ExpenseClaimPaginationMixin:
)
stmt = self._access_policy.apply_claim_scope(stmt, current_user)
result = paginate_select(self.db, stmt, page=page, page_size=page_size)
self._repair_duplicate_budget_approval_stages(result.items)
self._access_policy.attach_budget_approval_snapshots(result.items)
return result
@@ -47,7 +46,6 @@ class ExpenseClaimPaginationMixin:
)
stmt = self._access_policy.apply_approval_claim_scope(stmt, current_user)
result = paginate_select(self.db, stmt, page=page, page_size=page_size)
self._repair_duplicate_budget_approval_stages(result.items)
self._access_policy.attach_budget_approval_snapshots(result.items)
return result

View File

@@ -0,0 +1,187 @@
from __future__ import annotations
import uuid
from datetime import UTC, datetime
from app.api.deps import CurrentUserContext
from app.models.approval_action import ApprovalActionLedger
from app.models.financial_record import ExpenseClaim
from app.services.expense_claim_constants import RETURN_REASON_OPTIONS
class ExpenseClaimReturnFlowMixin:
def return_claim(
self,
claim_id: str,
current_user: CurrentUserContext,
*,
reason: str | None = None,
reason_codes: list[str] | None = None,
request_id: str | None = None,
expected_status: str | None = None,
expected_approval_stage: str | None = None,
) -> ExpenseClaim | None:
normalized_reason = str(reason or "").strip()
reason_code_payload = self._normalize_return_reason_code_payload(reason_codes)
normalized_codes = [
*reason_code_payload["reason_codes"],
*reason_code_payload["unknown_reason_codes"],
]
return self._execute_claim_action(
action="return",
claim_id=claim_id,
current_user=current_user,
request_id=request_id,
expected_status=expected_status,
expected_approval_stage=expected_approval_stage,
payload={"reason": normalized_reason, "reason_codes": normalized_codes},
executor=lambda claim, ledger, normalized_request_id: self._return_claim_once(
claim,
current_user,
reason=normalized_reason,
reason_codes=normalized_codes,
ledger=ledger,
request_id=normalized_request_id,
),
)
def _return_claim_once(
self,
claim: ExpenseClaim,
current_user: CurrentUserContext,
*,
reason: str,
reason_codes: list[str],
ledger: ApprovalActionLedger,
request_id: str,
) -> ExpenseClaim:
normalized_status = str(claim.status or "").strip().lower()
if normalized_status == "draft":
raise ValueError("草稿状态无需退回。")
if normalized_status == "returned":
raise ValueError("该单据已处于退回待提交状态,无需重复退回。")
if normalized_status in {"approved", "completed", "paid"}:
raise ValueError("已完成单据不允许退回。")
if not self._access_policy.can_return_claim(current_user, claim):
raise ValueError("只有财务人员、高级财务人员或当前审批人可以退回报销单。")
before_json = self._serialize_claim(claim)
operator = self._access_policy.resolve_current_user_display_name(current_user)
previous_status = str(claim.status or "").strip()
previous_stage = str(claim.approval_stage or "").strip() or "未标记审批环节"
previous_stage_key = self._normalize_return_stage_key(previous_stage)
is_application_claim = self._is_expense_application_claim(claim)
is_direct_manager_return = previous_stage_key == "direct_manager"
is_budget_return = previous_stage_key == "budget"
is_application_return = is_application_claim and (
is_direct_manager_return or is_budget_return
)
return_event_type = (
"expense_application_return" if is_application_return else "expense_claim_return"
)
return_label = (
"领导退回"
if is_application_claim and is_direct_manager_return
else "预算退回"
if is_application_claim and is_budget_return
else "人工退回"
)
reason_code_payload = self._normalize_return_reason_code_payload(reason_codes)
normalized_reason_codes = reason_code_payload["reason_codes"]
unknown_reason_codes = reason_code_payload["unknown_reason_codes"]
if is_application_return and not any(
code.startswith("application_") for code in normalized_reason_codes
):
raise ValueError("申请单退回必须选择至少一个退单类型。")
risk_points = [RETURN_REASON_OPTIONS[code] for code in normalized_reason_codes]
existing_return_flags = self._collect_return_flags(claim.risk_flags_json)
return_count = len(existing_return_flags) + 1
stage_return_count = (
sum(
1
for flag in existing_return_flags
if (
str(flag.get("return_stage_key") or "").strip()
or self._normalize_return_stage_key(str(flag.get("return_stage") or "").strip())
)
== previous_stage_key
)
+ 1
)
message = reason or self._build_default_return_message(
operator=operator,
risk_points=risk_points,
)
return_flag = {
"source": "manual_return",
"event_type": return_event_type,
"return_event_id": str(uuid.uuid4()),
"severity": "medium",
"label": return_label,
"node_key": "returned",
"node_label": "退回",
"approval_node": "退回",
"message": message,
"reason": reason,
"opinion": message,
"leader_opinion": message if is_application_claim and is_direct_manager_return else "",
"budget_opinion": message if is_application_claim and is_budget_return else "",
"reason_codes": normalized_reason_codes,
"risk_points": risk_points,
"operator": operator,
"operator_username": current_user.username,
"operator_role_codes": [
str(item).strip().lower() for item in current_user.role_codes if str(item).strip()
],
"previous_status": previous_status,
"previous_approval_stage": previous_stage,
"return_stage": previous_stage,
"return_stage_key": previous_stage_key,
"next_status": "returned",
"next_approval_stage": "待提交",
"return_count": return_count,
"stage_return_count": stage_return_count,
"created_at": datetime.now(UTC).isoformat(),
}
if unknown_reason_codes:
return_flag["unknown_reason_codes"] = unknown_reason_codes
budget_flags = self._release_budget_for_return(
claim,
current_user,
reason=message,
)
claim.status = "returned"
claim.approval_stage = "待提交"
claim.submitted_at = None
claim.risk_flags_json = self._append_budget_flags(
[*list(claim.risk_flags_json or []), return_flag],
budget_flags,
business_stage=("expense_application" if is_application_claim else "reimbursement"),
)
self._expense_cases.record_claim_event(
claim,
event_type=("application_returned" if is_application_claim else "claim_returned"),
actor_id=current_user.username,
tenant_id=getattr(current_user, "tenant_id", None),
idempotency_key=str(return_flag.get("return_event_id") or ""),
previous_status=previous_status,
previous_approval_stage=previous_stage,
extra_payload={
"reason": message,
"reason_codes": normalized_reason_codes,
},
)
self.audit_service.log_action(
actor=operator,
action="expense_claim.return",
resource_type="expense_claim",
resource_id=claim.id,
before_json=before_json,
after_json=self._serialize_claim(claim),
request_id=request_id,
commit=False,
)
return claim

View File

@@ -2,7 +2,6 @@ from __future__ import annotations
from typing import Any
_SEVERITY_WEIGHT = {
"critical": 0,
"high": 1,
@@ -12,6 +11,48 @@ _SEVERITY_WEIGHT = {
"pass": 5,
}
_BLOCKING_RISK_SEVERITIES = {"high", "critical", "danger"}
_NON_RISK_SOURCES = {
"application_detail",
"application_handoff",
"application_link",
"application_link_sync",
"application_submission",
"approval",
"approval_log",
"approval_routing",
"budget_approval",
"expense_claim_approval",
"expense_claim_finance_approval",
"finance_approval",
"manual_approval",
"manual_return",
"payment",
"reminder",
"sla_reminder",
"urge",
}
_NON_RISK_EVENTS = {
"expense_application_budget_approval",
"expense_application_reimbursement_deleted",
"expense_application_submission",
"expense_application_to_reimbursement_draft",
"expense_claim_approval",
"expense_claim_finance_approval",
"expense_claim_payment_completed",
"expense_reimbursement_application_linked",
"reminder",
"sla_reminder",
"urge",
}
_CLOSED_RISK_STATES = {
"accepted",
"false_positive",
"ignored",
"resolved",
"waived",
}
def _text(value: Any) -> str:
return str(value or "").strip()
@@ -22,6 +63,74 @@ def _severity_weight(flag: dict[str, Any]) -> int:
return _SEVERITY_WEIGHT.get(severity, 9)
def claim_risk_flag_severity(flag: dict[str, Any]) -> str:
severity = _text(
flag.get("severity")
or flag.get("risk_level")
or flag.get("riskLevel")
or flag.get("tone")
or flag.get("level")
).lower()
return "high" if severity == "danger" else severity
def is_open_claim_risk_flag(flag: dict[str, Any]) -> bool:
"""识别仍需处理的业务风险,排除审批、付款等流程轨迹。"""
source = _text(flag.get("source")).lower()
event_type = _text(flag.get("event_type") or flag.get("eventType")).lower()
if source in _NON_RISK_SOURCES or event_type in _NON_RISK_EVENTS:
return False
if _text(flag.get("actionability")).lower() == "system_trace":
return False
states = {
_text(flag.get("resolution_status") or flag.get("resolutionStatus")).lower(),
_text(flag.get("status")).lower(),
_text(flag.get("feedback_status") or flag.get("feedbackStatus")).lower(),
_text(flag.get("adjudication")).lower(),
}
if states & _CLOSED_RISK_STATES or bool(flag.get("resolved")):
return False
severity = claim_risk_flag_severity(flag)
return bool(
severity in {"medium", *_BLOCKING_RISK_SEVERITIES}
or flag.get("triggered") is True
or _text(flag.get("disposition")).lower() in {"fix", "review"}
)
def is_blocking_claim_risk_flag(flag: dict[str, Any]) -> bool:
if not (
is_open_claim_risk_flag(flag)
and claim_risk_flag_severity(flag) in _BLOCKING_RISK_SEVERITIES
):
return False
actionability = _text(flag.get("actionability")).lower()
disposition = _text(flag.get("disposition")).lower()
return actionability not in {"advisory_only", "route_review"} and disposition != "review"
def claim_risk_flag_observation_key(
flag: dict[str, Any],
*,
claim_id: str,
) -> str:
explicit_key = _text(flag.get("observation_key") or flag.get("observationKey"))
if explicit_key:
return explicit_key
source = _text(flag.get("source")).lower()
hit_source = _text(flag.get("hit_source") or flag.get("hitSource")).lower()
rule_code = _text(flag.get("rule_code") or flag.get("ruleCode"))
signal = _text(flag.get("risk_signal") or flag.get("riskSignal"))
if source in {"platform_risk", "platform_risk_rule", "rule_center"} or hit_source == (
"rule_center"
):
suffix = rule_code or signal
if suffix:
return f"risk:{claim_id}:platform:{suffix}"
return ""
def _list_values(value: Any) -> list[Any]:
if isinstance(value, list):
return value
@@ -138,7 +247,9 @@ def dedupe_claim_risk_flags(flags: list[Any] | None) -> list[Any]:
continue
other_weight = _severity_weight(other)
if other_weight < current_weight or (other_weight == current_weight and other_index < index):
if other_weight < current_weight or (
other_weight == current_weight and other_index < index
):
is_shadowed = True
break

View File

@@ -0,0 +1,148 @@
from __future__ import annotations
from dataclasses import dataclass
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models.financial_record import ExpenseClaim
from app.models.risk_disposition import RiskDisposition
from app.models.risk_observation import RiskObservation
from app.services.expense_claim_risk_flags import (
claim_risk_flag_observation_key,
claim_risk_flag_severity,
is_blocking_claim_risk_flag,
)
from app.services.expense_claim_tenant_scope import ExpenseClaimTenantScopeMixin
_BLOCKING_LEVELS = {"high", "critical", "danger"}
_LEGACY_CLOSED_STATUSES = {"false_positive", "ignored", "resolved"}
@dataclass(frozen=True, slots=True)
class BlockingRiskObservation:
observation_id: str
title: str
risk_level: str
adjudication: str
lifecycle_status: str
class ExpenseClaimRiskBlockedError(ValueError):
def __init__(self, blockers: list[BlockingRiskObservation]) -> None:
self.blockers = blockers
titles = "".join(item.title for item in blockers[:3])
suffix = f"{len(blockers)}" if len(blockers) > 3 else ""
super().__init__(
"存在尚未完成处置的高风险观察,暂不能审批通过:"
f"{titles}{suffix}。请先在风险证据链中确认并关闭风险,或标记为误报。"
)
class ExpenseClaimRiskGate:
"""把持久化风险处置状态变成审批前的强制业务门禁。"""
def __init__(self, db: Session) -> None:
self.db = db
def find_blockers(
self,
claim: ExpenseClaim,
*,
tenant_id: str | None,
) -> list[BlockingRiskObservation]:
normalized_tenant = ExpenseClaimTenantScopeMixin.normalize_tenant_id(tenant_id)
rows = self.db.execute(
select(RiskObservation, RiskDisposition)
.outerjoin(
RiskDisposition,
(
(RiskDisposition.tenant_id == RiskObservation.tenant_id)
& (RiskDisposition.observation_id == RiskObservation.id)
),
)
.where(
RiskObservation.tenant_id == normalized_tenant,
RiskObservation.claim_id == claim.id,
)
.order_by(
RiskObservation.risk_score.desc(),
RiskObservation.created_at.desc(),
)
).all()
blockers: list[BlockingRiskObservation] = []
materialized_keys: set[str] = set()
for observation, disposition in rows:
materialized_keys.add(str(observation.observation_key or "").strip())
level = str(observation.risk_level or "").strip().lower()
if level not in _BLOCKING_LEVELS:
continue
if self._is_closed(observation, disposition):
continue
blockers.append(
BlockingRiskObservation(
observation_id=observation.id,
title=str(observation.title or observation.risk_signal or "高风险").strip(),
risk_level=level,
adjudication=(
str(disposition.adjudication or "unreviewed").strip().lower()
if disposition is not None
else str(observation.feedback_status or "unreviewed").strip().lower()
),
lifecycle_status=(
str(disposition.lifecycle_status or "open").strip().lower()
if disposition is not None
else "open"
),
)
)
for index, flag in enumerate(list(claim.risk_flags_json or [])):
if not isinstance(flag, dict) or not is_blocking_claim_risk_flag(flag):
continue
observation_key = claim_risk_flag_observation_key(flag, claim_id=claim.id)
if observation_key and observation_key in materialized_keys:
# 完整物化后的处置投影优先;仅对没有 Observation 的高风险兜底。
continue
blockers.append(
BlockingRiskObservation(
observation_id=observation_key or f"raw:{claim.id}:{index}",
title=str(
flag.get("label")
or flag.get("title")
or flag.get("message")
or "未物化高风险"
).strip(),
risk_level=claim_risk_flag_severity(flag),
adjudication=str(flag.get("adjudication") or "unreviewed").strip().lower(),
lifecycle_status=str(
flag.get("lifecycle_status") or flag.get("lifecycleStatus") or "open"
)
.strip()
.lower(),
)
)
return blockers
def ensure_approvable(
self,
claim: ExpenseClaim,
*,
tenant_id: str | None,
) -> None:
blockers = self.find_blockers(claim, tenant_id=tenant_id)
if blockers:
raise ExpenseClaimRiskBlockedError(blockers)
@staticmethod
def _is_closed(
observation: RiskObservation,
disposition: RiskDisposition | None,
) -> bool:
if disposition is not None:
adjudication = str(disposition.adjudication or "").strip().lower()
lifecycle = str(disposition.lifecycle_status or "").strip().lower()
return adjudication == "false_positive" or lifecycle == "resolved"
status = str(observation.status or "").strip().lower()
feedback_status = str(observation.feedback_status or "").strip().lower()
return status in _LEGACY_CLOSED_STATUSES or feedback_status == "false_positive"

View File

@@ -15,7 +15,10 @@ from app.services.expense_claim_constants import (
from app.services.expense_claim_item_sync import ExpenseClaimItemSyncMixin
from app.services.expense_claim_platform_risk import ExpenseClaimPlatformRiskMixin
from app.services.expense_claim_policy_review import ExpenseClaimPolicyReviewMixin
from app.services.expense_claim_risk_flags import dedupe_claim_risk_flags
from app.services.expense_claim_risk_flags import (
dedupe_claim_risk_flags,
is_blocking_claim_risk_flag,
)
from app.services.expense_claim_risk_stage import with_risk_business_stage
from app.services.expense_claim_tenant_scope import ExpenseClaimTenantScopeMixin
from app.services.risk_observations import RiskObservationService
@@ -98,11 +101,15 @@ class ExpenseClaimRiskReviewMixin(
claim,
platform_risk_flags,
)
except Exception:
except Exception as error:
logger.exception(
"Failed to persist platform risk observations for claim_id=%s",
claim.id,
)
if any(is_blocking_claim_risk_flag(flag) for flag in platform_risk_flags):
raise RuntimeError(
"高风险观察持久化失败,已停止提交;请稍后重试或联系管理员。"
) from error
review_flags = [with_risk_business_stage(flag, "reimbursement") for flag in review_flags]
final_risk_flags = dedupe_claim_risk_flags([*preserved_flags, *review_flags])
@@ -111,9 +118,7 @@ class ExpenseClaimRiskReviewMixin(
"status": "submitted",
"approval_stage": "直属领导审批",
"risk_flags": final_risk_flags,
"rule_set_fingerprint": str(
platform_risk_review.get("rule_set_fingerprint") or ""
),
"rule_set_fingerprint": str(platform_risk_review.get("rule_set_fingerprint") or ""),
"message": (
f"报销单 {claim.claim_no} 已完成自动检测,"
f"现已提交给直属领导 {manager_name or '审批人'} 审批。"
@@ -159,4 +164,3 @@ class ExpenseClaimRiskReviewMixin(
)
recent_claims = list(self.db.scalars(stmt).all())
return sum(1 for item in recent_claims if list(item.risk_flags_json or []))

View File

@@ -4,7 +4,10 @@ from datetime import UTC, datetime
from typing import Any
from app.models.financial_record import ExpenseClaim
from app.services.expense_claim_risk_stage import risk_business_stage_for_claim, with_risk_business_stage
from app.services.expense_claim_risk_stage import (
risk_business_stage_for_claim,
with_risk_business_stage,
)
from app.services.expense_claim_workflow_constants import (
BUDGET_MANAGER_APPROVAL_STAGE,
DIRECT_MANAGER_APPROVAL_STAGE,
@@ -13,18 +16,17 @@ from app.services.expense_claim_workflow_constants import (
class ExpenseClaimWorkflowRepairMixin:
def _repair_duplicate_budget_approval_stages(self, claims: list[ExpenseClaim]) -> None:
repaired_claims = [
def _repair_duplicate_budget_approval_stages(
self,
claims: list[ExpenseClaim],
) -> list[ExpenseClaim]:
"""只修改当前事务中的对象,不在底层辅助方法里提交事务。"""
return [
claim
for claim in claims
if claim is not None and self._repair_duplicate_budget_approval_stage(claim)
]
if not repaired_claims:
return
self.db.commit()
for claim in repaired_claims:
self.db.refresh(claim)
def _repair_duplicate_budget_approval_stage(self, claim: ExpenseClaim) -> bool:
if self._is_expense_application_claim(claim):
@@ -54,7 +56,8 @@ class ExpenseClaimWorkflowRepairMixin:
if isinstance(flag, dict)
and str(flag.get("source") or "").strip() == "manual_approval"
and str(flag.get("event_type") or "").strip() == "expense_claim_approval"
and str(flag.get("previous_approval_stage") or "").strip() == DIRECT_MANAGER_APPROVAL_STAGE
and str(flag.get("previous_approval_stage") or "").strip()
== DIRECT_MANAGER_APPROVAL_STAGE
and str(flag.get("next_approval_stage") or "").strip() == BUDGET_MANAGER_APPROVAL_STAGE
]
for flag in reversed(flags):
@@ -74,11 +77,14 @@ class ExpenseClaimWorkflowRepairMixin:
return any(
isinstance(flag, dict)
and str(flag.get("source") or "").strip() == "approval_flow_repair"
and str(flag.get("event_type") or "").strip() == "duplicate_budget_approval_stage_repaired"
and str(flag.get("event_type") or "").strip()
== "duplicate_budget_approval_stage_repaired"
for flag in list(claim.risk_flags_json or [])
)
def _build_duplicate_budget_stage_repair_flag(self, approval_event: dict[str, Any]) -> dict[str, Any]:
def _build_duplicate_budget_stage_repair_flag(
self, approval_event: dict[str, Any]
) -> dict[str, Any]:
return with_risk_business_stage(
{
"source": "approval_flow_repair",

View File

@@ -1,6 +1,5 @@
from __future__ import annotations
import uuid
from collections.abc import Callable
from datetime import UTC, datetime
from decimal import Decimal
@@ -25,6 +24,7 @@ from app.services.budget_types import BudgetControlError
from app.services.document_numbering import is_application_claim_no
from app.services.expense_cases import ExpenseCaseService
from app.services.expense_claim_access_policy import ExpenseClaimAccessPolicy
from app.services.expense_claim_action_protocol import ExpenseClaimActionProtocolMixin
from app.services.expense_claim_application_handoff import ExpenseClaimApplicationHandoffMixin
from app.services.expense_claim_approval_flow import ExpenseClaimApprovalFlowMixin
from app.services.expense_claim_approval_routing import ExpenseClaimApprovalRoutingMixin
@@ -34,7 +34,6 @@ from app.services.expense_claim_attachment_operations import ExpenseClaimAttachm
from app.services.expense_claim_attachment_presentation import ExpenseClaimAttachmentPresentation
from app.services.expense_claim_attachment_storage import ExpenseClaimAttachmentStorage
from app.services.expense_claim_budget_flow import ExpenseClaimBudgetFlowMixin
from app.services.expense_claim_constants import RETURN_REASON_OPTIONS
from app.services.expense_claim_document_item_builder import ExpenseClaimDocumentItemBuilderMixin
from app.services.expense_claim_document_parsing import ExpenseClaimDocumentParsingMixin
from app.services.expense_claim_draft_flow import ExpenseClaimDraftFlowMixin
@@ -51,6 +50,7 @@ from app.services.expense_claim_pre_review_decision import (
pre_review_public_payload,
)
from app.services.expense_claim_read_model import ExpenseClaimReadModelMixin
from app.services.expense_claim_return_flow import ExpenseClaimReturnFlowMixin
from app.services.expense_claim_review_preview import ExpenseClaimReviewPreviewMixin
from app.services.expense_claim_risk_flags import dedupe_claim_risk_flags
from app.services.expense_claim_risk_review import ExpenseClaimRiskReviewMixin
@@ -87,7 +87,10 @@ class ExpenseClaimItemActionMixin:
if payload.item_date is not None:
item.item_date = payload.item_date
if payload.item_type is not None:
item.item_type = self._normalize_optional_text(payload.item_type, fallback=item.item_type) or item.item_type
item.item_type = (
self._normalize_optional_text(payload.item_type, fallback=item.item_type)
or item.item_type
)
if payload.item_reason is not None:
item.item_reason = (
self._normalize_optional_text(payload.item_reason, allow_empty=True) or ""
@@ -97,7 +100,9 @@ class ExpenseClaimItemActionMixin:
self._normalize_optional_text(payload.item_location, allow_empty=True) or ""
)
if payload.item_note is not None:
item.item_note = self._normalize_optional_text(payload.item_note, allow_empty=True) or ""
item.item_note = (
self._normalize_optional_text(payload.item_note, allow_empty=True) or ""
)
if payload.item_amount is not None:
amount = payload.item_amount.quantize(Decimal("0.01"))
if amount < Decimal("0.00"):
@@ -203,7 +208,9 @@ class ExpenseClaimItemActionMixin:
self._ensure_draft_claim(claim)
before_json = self._serialize_claim(claim)
item_label = str(item.item_reason or "").strip() or self._resolve_expense_type_label(item.item_type)
item_label = str(item.item_reason or "").strip() or self._resolve_expense_type_label(
item.item_type
)
self._attachment_storage.delete_item_files(item)
claim.items = [entry for entry in claim.items if entry.id != item.id]
@@ -266,8 +273,7 @@ class ExpenseClaimItemActionMixin:
raise RuntimeError("无法生成提交前预审结果。")
client_review_provided = bool(
str(pre_review_id or "").strip()
or str(pre_review_input_fingerprint or "").strip()
str(pre_review_id or "").strip() or str(pre_review_input_fingerprint or "").strip()
)
client_review_matches = pre_review_identity_matches(
pre_review_flag,
@@ -423,7 +429,9 @@ class ExpenseClaimItemActionMixin:
if not self._access_policy.has_claim_delete_access(current_user):
self._ensure_draft_claim(claim)
if not self._access_policy.is_claim_owned_by_current_user(claim, current_user):
raise ValueError("只有系统管理员或草稿、待补充、退回待提交阶段的申请人本人可以删除单据。")
raise ValueError(
"只有系统管理员或草稿、待补充、退回待提交阶段的申请人本人可以删除单据。"
)
before_json = self._serialize_claim(claim)
resource_id = claim.id
@@ -464,154 +472,31 @@ class ExpenseClaimItemActionMixin:
self.db.execute(delete(RiskObservation).where(RiskObservation.claim_id == claim_id))
self.db.execute(delete(HermesRiskReport).where(HermesRiskReport.claim_id == claim_id))
def return_claim(
self,
claim_id: str,
current_user: CurrentUserContext,
*,
reason: str | None = None,
reason_codes: list[str] | None = None,
) -> ExpenseClaim | None:
claim = self.get_claim(claim_id, current_user)
if claim is None:
return None
normalized_status = str(claim.status or "").strip().lower()
if normalized_status == "draft":
raise ValueError("草稿状态无需退回。")
if normalized_status == "returned":
raise ValueError("该单据已处于退回待提交状态,无需重复退回。")
if normalized_status in {"approved", "completed", "paid"}:
raise ValueError("已完成单据不允许退回。")
if not self._access_policy.can_return_claim(current_user, claim):
raise ValueError("只有财务人员、高级财务人员或当前审批人可以退回报销单。")
before_json = self._serialize_claim(claim)
operator = self._access_policy.resolve_current_user_display_name(current_user)
previous_status = str(claim.status or "").strip()
previous_stage = str(claim.approval_stage or "").strip() or "未标记审批环节"
previous_stage_key = self._normalize_return_stage_key(previous_stage)
is_application_claim = self._is_expense_application_claim(claim)
is_direct_manager_return = previous_stage_key == "direct_manager"
is_budget_return = previous_stage_key == "budget"
is_application_return = is_application_claim and (is_direct_manager_return or is_budget_return)
return_event_type = (
"expense_application_return"
if is_application_return
else "expense_claim_return"
)
return_label = (
"领导退回"
if is_application_claim and is_direct_manager_return
else "预算退回"
if is_application_claim and is_budget_return
else "人工退回"
)
return_reason = str(reason or "").strip()
reason_code_payload = self._normalize_return_reason_code_payload(reason_codes)
normalized_reason_codes = reason_code_payload["reason_codes"]
unknown_reason_codes = reason_code_payload["unknown_reason_codes"]
if is_application_return and not any(
code.startswith("application_") for code in normalized_reason_codes
):
raise ValueError("申请单退回必须选择至少一个退单类型。")
risk_points = [RETURN_REASON_OPTIONS[code] for code in normalized_reason_codes]
existing_return_flags = self._collect_return_flags(claim.risk_flags_json)
return_count = len(existing_return_flags) + 1
stage_return_count = (
sum(
1
for flag in existing_return_flags
if (
str(flag.get("return_stage_key") or "").strip()
or self._normalize_return_stage_key(str(flag.get("return_stage") or "").strip())
)
== previous_stage_key
)
+ 1
)
message = return_reason or self._build_default_return_message(operator=operator, risk_points=risk_points)
return_flag = {
"source": "manual_return",
"event_type": return_event_type,
"return_event_id": str(uuid.uuid4()),
"severity": "medium",
"label": return_label,
"node_key": "returned",
"node_label": "退回",
"approval_node": "退回",
"message": message,
"reason": return_reason,
"opinion": message,
"leader_opinion": message if is_application_claim and is_direct_manager_return else "",
"budget_opinion": message if is_application_claim and is_budget_return else "",
"reason_codes": normalized_reason_codes,
"risk_points": risk_points,
"operator": operator,
"operator_username": current_user.username,
"operator_role_codes": [
str(item).strip().lower()
for item in current_user.role_codes
if str(item).strip()
],
"previous_status": previous_status,
"previous_approval_stage": previous_stage,
"return_stage": previous_stage,
"return_stage_key": previous_stage_key,
"next_status": "returned",
"next_approval_stage": "待提交",
"return_count": return_count,
"stage_return_count": stage_return_count,
"created_at": datetime.now(UTC).isoformat(),
}
if unknown_reason_codes:
return_flag["unknown_reason_codes"] = unknown_reason_codes
budget_flags = self._release_budget_for_return(
claim,
current_user,
reason=message,
)
claim.status = "returned"
claim.approval_stage = "待提交"
claim.submitted_at = None
claim.risk_flags_json = self._append_budget_flags(
[*list(claim.risk_flags_json or []), return_flag],
budget_flags,
business_stage="expense_application" if is_application_claim else "reimbursement",
)
self._expense_cases.record_claim_event(
claim,
event_type=("application_returned" if is_application_claim else "claim_returned"),
actor_id=current_user.username,
tenant_id=getattr(current_user, "tenant_id", None),
idempotency_key=str(return_flag.get("return_event_id") or ""),
previous_status=previous_status,
previous_approval_stage=previous_stage,
extra_payload={
"reason": message,
"reason_codes": normalized_reason_codes,
},
)
self.db.commit()
self.db.refresh(claim)
self.audit_service.log_action(
actor=operator,
action="expense_claim.return",
resource_type="expense_claim",
resource_id=claim.id,
before_json=before_json,
after_json=self._serialize_claim(claim),
)
return claim
class ExpenseClaimService(ExpenseClaimStandardAdjustmentMixin, ExpenseClaimItemActionMixin, ExpenseClaimPaginationMixin, ExpenseClaimApprovalFlowMixin, ExpenseClaimApprovalRoutingMixin, ExpenseClaimApplicationHandoffMixin, ExpenseClaimPreReviewMixin, ExpenseClaimBudgetFlowMixin, ExpenseClaimAttachmentOperationsMixin, ExpenseClaimReviewPreviewMixin, ExpenseClaimDraftFlowMixin, ExpenseClaimDraftPersistenceMixin, ExpenseClaimDocumentItemBuilderMixin, ExpenseClaimDocumentParsingMixin, ExpenseClaimOntologyResolverMixin, ExpenseClaimAttachmentDocumentMixin, ExpenseClaimAttachmentAnalysisMixin, ExpenseClaimReadModelMixin, ExpenseClaimRiskReviewMixin, ExpenseClaimWorkflowRepairMixin):
class ExpenseClaimService(
ExpenseClaimActionProtocolMixin,
ExpenseClaimReturnFlowMixin,
ExpenseClaimStandardAdjustmentMixin,
ExpenseClaimItemActionMixin,
ExpenseClaimPaginationMixin,
ExpenseClaimApprovalFlowMixin,
ExpenseClaimApprovalRoutingMixin,
ExpenseClaimApplicationHandoffMixin,
ExpenseClaimPreReviewMixin,
ExpenseClaimBudgetFlowMixin,
ExpenseClaimAttachmentOperationsMixin,
ExpenseClaimReviewPreviewMixin,
ExpenseClaimDraftFlowMixin,
ExpenseClaimDraftPersistenceMixin,
ExpenseClaimDocumentItemBuilderMixin,
ExpenseClaimDocumentParsingMixin,
ExpenseClaimOntologyResolverMixin,
ExpenseClaimAttachmentDocumentMixin,
ExpenseClaimAttachmentAnalysisMixin,
ExpenseClaimReadModelMixin,
ExpenseClaimRiskReviewMixin,
ExpenseClaimWorkflowRepairMixin,
):
def __init__(self, db: Session) -> None:
self.db = db
self.audit_service = AuditLogService(db)
@@ -624,11 +509,15 @@ class ExpenseClaimService(ExpenseClaimStandardAdjustmentMixin, ExpenseClaimItemA
def _is_expense_application_claim(claim: ExpenseClaim) -> bool:
claim_no = str(getattr(claim, "claim_no", "") or "").strip().upper()
expense_type = str(getattr(claim, "expense_type", "") or "").strip().lower()
document_type = str(
getattr(claim, "document_type_code", "")
or getattr(claim, "document_type", "")
or ""
).strip().lower()
document_type = (
str(
getattr(claim, "document_type_code", "")
or getattr(claim, "document_type", "")
or ""
)
.strip()
.lower()
)
return (
is_application_claim_no(claim_no)
or expense_type == "application"
@@ -667,7 +556,6 @@ class ExpenseClaimService(ExpenseClaimStandardAdjustmentMixin, ExpenseClaimItemA
)
stmt = self._access_policy.apply_claim_scope(stmt, current_user)
claims = list(self.db.scalars(stmt).all())
self._repair_duplicate_budget_approval_stages(claims)
return self._access_policy.attach_budget_approval_snapshots(claims)
def list_approval_claims(self, current_user: CurrentUserContext) -> list[ExpenseClaim]:
@@ -683,7 +571,6 @@ class ExpenseClaimService(ExpenseClaimStandardAdjustmentMixin, ExpenseClaimItemA
)
stmt = self._access_policy.apply_approval_claim_scope(stmt, current_user)
claims = list(self.db.scalars(stmt).all())
self._repair_duplicate_budget_approval_stages(claims)
return self._access_policy.attach_budget_approval_snapshots(claims)
def list_archived_claims(self, current_user: CurrentUserContext) -> list[ExpenseClaim]:
@@ -695,7 +582,11 @@ class ExpenseClaimService(ExpenseClaimStandardAdjustmentMixin, ExpenseClaimItemA
selectinload(ExpenseClaim.employee).selectinload(Employee.organization_unit),
selectinload(ExpenseClaim.employee).selectinload(Employee.roles),
)
.order_by(ExpenseClaim.updated_at.desc(), ExpenseClaim.submitted_at.desc(), ExpenseClaim.created_at.desc())
.order_by(
ExpenseClaim.updated_at.desc(),
ExpenseClaim.submitted_at.desc(),
ExpenseClaim.created_at.desc(),
)
)
stmt = self._access_policy.apply_archived_claim_scope(stmt, current_user)
return list(self.db.scalars(stmt).all())
@@ -711,13 +602,15 @@ class ExpenseClaimService(ExpenseClaimStandardAdjustmentMixin, ExpenseClaimItemA
)
.where(ExpenseClaim.id == claim_id)
)
stmt = self._access_policy.apply_claim_scope(stmt, current_user, include_approval_scope=True)
stmt = self._access_policy.apply_claim_scope(
stmt, current_user, include_approval_scope=True
)
claim = self.db.scalar(stmt)
if claim is not None:
self._repair_duplicate_budget_approval_stages([claim])
return self._access_policy.attach_approval_snapshot(claim)
def can_view_budget_analysis(self, current_user: CurrentUserContext, claim: ExpenseClaim | None = None) -> bool:
def can_view_budget_analysis(
self, current_user: CurrentUserContext, claim: ExpenseClaim | None = None
) -> bool:
if claim is None:
return self._access_policy.is_budget_manager_user(current_user)
if current_user.is_admin:
@@ -725,10 +618,9 @@ class ExpenseClaimService(ExpenseClaimStandardAdjustmentMixin, ExpenseClaimItemA
role_codes = self._access_policy.normalize_role_codes(current_user)
if "executive" in role_codes:
return True
if (
self._access_policy.has_privileged_claim_access(current_user)
and not self._access_policy.is_claim_owned_by_current_user(claim, current_user)
):
if self._access_policy.has_privileged_claim_access(
current_user
) and not self._access_policy.is_claim_owned_by_current_user(claim, current_user):
return True
if self._access_policy.can_approve_claim(current_user, claim):
return True
@@ -751,7 +643,9 @@ class ExpenseClaimService(ExpenseClaimStandardAdjustmentMixin, ExpenseClaimItemA
before_json = self._serialize_claim(claim)
if payload.reason is not None:
claim.reason = self._normalize_optional_text(payload.reason, allow_empty=True) or "待补充"
claim.reason = (
self._normalize_optional_text(payload.reason, allow_empty=True) or "待补充"
)
if not self._is_expense_application_claim(claim):
self._refresh_claim_pre_review_flags(claim, is_application_claim=False)

View File

@@ -40,63 +40,72 @@ class HermesRiskScannerService:
observation_service = RiskObservationService(self.db)
observation_count = 0
scanned_claim_count = 0
graph_node_count = 0
graph_edge_count = 0
for tenant_id, tenant_claims in self._group_claims_by_tenant(claims).items():
now = datetime.now(timezone.utc)
grouped_claims = self._group_claims_by_tenant(claims)
for tenant_id in sorted(grouped_claims):
tenant_claims = sorted(grouped_claims[tenant_id], key=lambda item: str(item.id))
snapshot_versions = {claim.id: claim.updated_at for claim in tenant_claims}
result = evaluate_financial_risk_graph(
RiskGraphEvaluationContext(
claims=[
RiskGraphClaimSnapshot.from_orm(claim)
for claim in tenant_claims
],
claims=[RiskGraphClaimSnapshot.from_orm(claim) for claim in tenant_claims],
target_claim_ids={claim.id for claim in tenant_claims},
history_stats=observation_service.build_history_stats(
tenant_id=tenant_id,
expense_types={
str(claim.expense_type or "") for claim in tenant_claims
},
expense_types={str(claim.expense_type or "") for claim in tenant_claims},
),
)
)
claims_by_id = {claim.id: claim for claim in tenant_claims}
observation_count += len(result.observations)
graph_node_count += len(result.nodes)
graph_edge_count += len(result.edges)
observations_by_claim = {}
for observation in result.observations:
claim = claims_by_id.get(observation.claim_id)
if claim is None:
continue
observation_service.upsert_observation(
observation,
tenant_id=tenant_id,
run_id=run_id,
execution_log_id=log_id,
)
claim.hermes_risk_flag = True
claim.risk_flags_json = self._append_algorithm_flag(
claim,
observation.as_dict(),
)
observations_by_claim.setdefault(observation.claim_id, []).append(observation)
if log_id:
self.db.add(
HermesRiskReport(
claim_id=observation.claim_id,
execution_log_id=log_id,
risk_level=observation.risk_level,
risk_type=observation.risk_signal,
risk_description=observation.description,
related_claim_ids=[
observation.claim_id,
*observation.similar_case_claim_ids,
],
)
for snapshot_claim in tenant_claims:
claim = observation_service.lock_claim_for_risk_write(
snapshot_claim.id,
tenant_id=tenant_id,
refresh=True,
)
if claim is None or not self._is_scan_eligible(claim):
continue
if claim.updated_at != snapshot_versions.get(claim.id):
# 计算期间单据已变化,旧快照不再写回;下一轮重新扫描。
continue
for observation in observations_by_claim.get(claim.id, []):
observation_service.upsert_observation(
observation,
tenant_id=tenant_id,
run_id=run_id,
execution_log_id=log_id,
claim_lock_acquired=True,
)
observation_count += 1
claim.hermes_risk_flag = True
claim.risk_flags_json = self._append_algorithm_flag(
claim,
observation.as_dict(),
)
now = datetime.now(timezone.utc)
for claim in claims:
claim.hermes_scanned_at = now
if log_id:
self.db.add(
HermesRiskReport(
claim_id=observation.claim_id,
execution_log_id=log_id,
risk_level=observation.risk_level,
risk_type=observation.risk_signal,
risk_description=observation.description,
related_claim_ids=[
observation.claim_id,
*observation.similar_case_claim_ids,
],
)
)
claim.hermes_scanned_at = now
scanned_claim_count += 1
self.db.commit()
logger.info(
@@ -104,7 +113,7 @@ class HermesRiskScannerService:
observation_count,
)
return {
"scanned_claim_count": len(claims),
"scanned_claim_count": scanned_claim_count,
"risk_observation_count": observation_count,
"graph_node_count": graph_node_count,
"graph_edge_count": graph_edge_count,
@@ -134,11 +143,16 @@ class HermesRiskScannerService:
ExpenseClaim.hermes_risk_flag.is_(False),
),
)
.order_by(ExpenseClaim.id)
.limit(50)
)
return list(self.db.scalars(stmt).all())
@staticmethod
def _is_scan_eligible(claim: ExpenseClaim) -> bool:
return str(claim.status or "").strip().lower() in {"draft", "submitted", "review"}
@staticmethod
def _append_algorithm_flag(claim: ExpenseClaim, observation: dict) -> list:
existing = list(claim.risk_flags_json or [])
@@ -155,8 +169,7 @@ class HermesRiskScannerService:
"reimbursement",
)
if any(
isinstance(item, dict)
and item.get("observation_key") == flag["observation_key"]
isinstance(item, dict) and item.get("observation_key") == flag["observation_key"]
for item in existing
):
return existing

View File

@@ -0,0 +1,430 @@
from __future__ import annotations
import hashlib
import json
import os
import uuid
from dataclasses import dataclass
from datetime import UTC, datetime
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.api.deps import CurrentUserContext
from app.core.logging import get_logger
from app.models.financial_record import ExpenseClaim
from app.models.risk_disposition import RiskDisposition, RiskDispositionEvent
from app.models.risk_observation import RiskObservation, RiskObservationFeedback
from app.schemas.risk_disposition import RiskDispositionActionCreate
from app.services.expense_claim_tenant_scope import ExpenseClaimTenantScopeMixin
from app.services.risk_observation_access_policy import RiskObservationAccessPolicy
logger = get_logger("app.services.risk_dispositions")
class RiskDispositionConflictError(RuntimeError):
pass
class RiskDispositionVersionConflictError(RiskDispositionConflictError):
def __init__(self, current_version: int) -> None:
self.current_version = current_version
super().__init__(
f"Risk disposition version conflict; current version is {current_version}."
)
class RiskDispositionIdempotencyConflictError(RiskDispositionConflictError):
pass
class RiskDispositionPermissionError(RuntimeError):
pass
@dataclass(slots=True)
class RiskDispositionMutation:
disposition: RiskDisposition
event: RiskDispositionEvent
replayed: bool
legacy_feedback: RiskObservationFeedback | None = None
class RiskDispositionService:
def __init__(self, db: Session) -> None:
self.db = db
def get_disposition(
self,
observation_id: str,
*,
tenant_id: str,
) -> RiskDisposition | None:
return self.db.scalar(
select(RiskDisposition).where(
RiskDisposition.tenant_id == _tenant(tenant_id),
RiskDisposition.observation_id == observation_id,
)
)
def get_current_version(self, observation_id: str, *, tenant_id: str) -> int:
disposition = self.get_disposition(observation_id, tenant_id=tenant_id)
return disposition.version if disposition is not None else 0
def execute_action(
self,
observation_key_or_id: str,
payload: RiskDispositionActionCreate,
*,
tenant_id: str,
actor_id: str,
actor_name: str,
current_user: CurrentUserContext | None = None,
) -> RiskDispositionMutation:
normalized_tenant = _tenant(tenant_id)
normalized_actor_id = _text(actor_id) or "anonymous"
observation_locator = self.db.execute(
select(RiskObservation.id, RiskObservation.claim_id).where(
RiskObservation.tenant_id == normalized_tenant,
(
(RiskObservation.id == observation_key_or_id)
| (RiskObservation.observation_key == observation_key_or_id)
),
)
).one_or_none()
if observation_locator is None:
raise LookupError("Risk observation not found.")
observation_id, located_claim_id = observation_locator
fingerprint = _payload_fingerprint(
payload,
observation_id=observation_id,
actor_id=normalized_actor_id,
)
replay = self._find_replay(
tenant_id=normalized_tenant,
request_id=payload.request_id,
fingerprint=fingerprint,
)
if replay is not None:
return replay
try:
locked_claim = self._lock_claim(
str(located_claim_id or "").strip(),
tenant_id=normalized_tenant,
)
observation = self.db.scalar(
select(RiskObservation)
.where(
RiskObservation.tenant_id == normalized_tenant,
RiskObservation.id == observation_id,
)
.with_for_update()
)
if observation is None:
raise LookupError("Risk observation not found.")
if str(observation.claim_id or "").strip() != str(located_claim_id or "").strip():
raise RiskDispositionConflictError(
"Risk observation claim changed concurrently; reload and retry."
)
if current_user is not None and not RiskObservationAccessPolicy(
self.db
).can_manage_locked_disposition(
observation,
current_user,
locked_claim=locked_claim,
):
raise RiskDispositionPermissionError("当前用户已不再是该单据的有效审批人。")
disposition = self.db.scalar(
select(RiskDisposition)
.where(
RiskDisposition.tenant_id == normalized_tenant,
RiskDisposition.observation_id == observation.id,
)
.with_for_update()
)
current_version = disposition.version if disposition is not None else 0
if current_version != payload.expected_version:
raise RiskDispositionVersionConflictError(current_version)
if disposition is None:
disposition = RiskDisposition(
id=str(uuid.uuid4()),
tenant_id=normalized_tenant,
observation_id=observation.id,
adjudication=_initial_adjudication(observation),
lifecycle_status=_initial_lifecycle_status(observation),
version=0,
)
self.db.add(disposition)
_validate_transition(disposition, payload)
before = _state(disposition)
_apply_action(disposition, observation, payload)
disposition.version = current_version + 1
disposition.updated_at = datetime.now(UTC)
event = RiskDispositionEvent(
id=str(uuid.uuid4()),
tenant_id=normalized_tenant,
disposition_id=disposition.id,
observation_id=observation.id,
version=disposition.version,
action=payload.action,
actor_id=normalized_actor_id,
actor_name=_text(actor_name) or _text(actor_id) or "anonymous",
request_id=payload.request_id,
payload_fingerprint=fingerprint,
comment=payload.comment,
before_json=before,
after_json=_state(disposition),
)
self.db.add(event)
legacy_feedback = self._append_safe_feedback(
observation,
event,
payload,
actor_name=actor_name,
)
self.db.commit()
self.db.refresh(disposition)
self.db.refresh(event)
if legacy_feedback is not None:
self.db.refresh(legacy_feedback)
self._ingest_feedback_sample(observation, legacy_feedback)
return RiskDispositionMutation(
disposition=disposition,
event=event,
replayed=False,
legacy_feedback=legacy_feedback,
)
except (LookupError, RiskDispositionConflictError, RiskDispositionPermissionError):
self.db.rollback()
raise
except IntegrityError as error:
self.db.rollback()
replay = self._find_replay(
tenant_id=normalized_tenant,
request_id=payload.request_id,
fingerprint=fingerprint,
)
if replay is not None:
return replay
raise RiskDispositionConflictError(
"Risk disposition was changed concurrently; reload and retry."
) from error
def _lock_claim(
self,
claim_id: str,
*,
tenant_id: str,
) -> ExpenseClaim | None:
if not claim_id:
return None
statement = select(ExpenseClaim).where(
ExpenseClaim.id == claim_id,
ExpenseClaimTenantScopeMixin.build_claim_tenant_condition(tenant_id),
)
bind = self.db.get_bind()
if bind is not None and bind.dialect.name == "postgresql":
statement = statement.with_for_update()
return self.db.scalar(statement.execution_options(populate_existing=True))
def _find_replay(
self,
*,
tenant_id: str,
request_id: str,
fingerprint: str,
) -> RiskDispositionMutation | None:
event = self.db.scalar(
select(RiskDispositionEvent).where(
RiskDispositionEvent.tenant_id == tenant_id,
RiskDispositionEvent.request_id == request_id,
)
)
if event is None:
return None
if event.payload_fingerprint != fingerprint:
raise RiskDispositionIdempotencyConflictError("request_id 已被不同的风险处置内容使用。")
disposition = self.db.get(RiskDisposition, event.disposition_id)
if disposition is None:
raise RiskDispositionConflictError("Risk disposition replay target is missing.")
feedback = self.db.scalar(
select(RiskObservationFeedback).where(
RiskObservationFeedback.observation_id == event.observation_id,
RiskObservationFeedback.action == f"disposition:{event.id}",
)
)
return RiskDispositionMutation(
disposition=disposition,
event=event,
replayed=True,
legacy_feedback=feedback,
)
def _append_safe_feedback(
self,
observation: RiskObservation,
event: RiskDispositionEvent,
payload: RiskDispositionActionCreate,
*,
actor_name: str,
) -> RiskObservationFeedback | None:
if payload.action not in {"confirm", "false_positive"}:
return None
feedback = RiskObservationFeedback(
observation_id=observation.id,
feedback_type=payload.action,
action=f"disposition:{event.id}",
actor=_text(actor_name) or "anonymous",
comment=payload.comment,
payload_json={
"decision": payload.action,
"source": "typed_risk_disposition",
},
)
self.db.add(feedback)
return feedback
def _ingest_feedback_sample(
self,
observation: RiskObservation,
feedback: RiskObservationFeedback,
) -> None:
enabled = os.environ.get("FEW_SHOT_INJECTION_ENABLED", "true")
if enabled.strip().lower() in {"0", "false", "no"}:
return
try:
from app.services.few_shot_ingestion import FewShotIngestionService
FewShotIngestionService(self.db).ingest_observation_feedback(
observation,
feedback,
)
except Exception:
logger.exception(
"few-shot ingestion failed for disposition event %s",
feedback.action,
)
def _apply_action(
disposition: RiskDisposition,
observation: RiskObservation,
payload: RiskDispositionActionCreate,
) -> None:
if payload.action == "confirm":
disposition.adjudication = "confirmed"
observation.status = "confirmed"
observation.feedback_status = "confirmed"
return
if payload.action == "false_positive":
disposition.adjudication = "false_positive"
observation.status = "false_positive"
observation.feedback_status = "false_positive"
return
lifecycle_by_action = {
"request_supplement": "supplement_requested",
"start_remediation": "remediation_in_progress",
"request_waiver": "waiver_requested",
"resolve": "resolved",
}
disposition.lifecycle_status = lifecycle_by_action[payload.action]
if payload.assignee is not None:
disposition.assignee = payload.assignee
if payload.due_at is not None:
disposition.due_at = payload.due_at
if payload.action != "resolve":
disposition.resolution = None
else:
disposition.resolution = payload.resolution
observation.status = "resolved"
def _validate_transition(
disposition: RiskDisposition,
payload: RiskDispositionActionCreate,
) -> None:
if disposition.lifecycle_status == "resolved":
raise RiskDispositionConflictError("已解决的风险不能再次变更裁决或处置生命周期。")
if payload.action == "confirm":
if disposition.adjudication == "confirmed":
raise RiskDispositionConflictError("该风险已经确认成立,请勿重复提交。")
return
if payload.action == "false_positive":
if disposition.adjudication == "false_positive":
raise RiskDispositionConflictError("该风险已经标记为误报,请勿重复提交。")
return
target_lifecycle = {
"request_supplement": "supplement_requested",
"start_remediation": "remediation_in_progress",
"request_waiver": "waiver_requested",
"resolve": "resolved",
}[payload.action]
if disposition.lifecycle_status == target_lifecycle:
raise RiskDispositionConflictError("该风险已处于目标处置状态,请勿重复提交。")
if payload.action == "request_supplement":
return
if disposition.adjudication != "confirmed":
raise RiskDispositionConflictError("风险必须先确认成立,才能启动整改、申请豁免或标记解决。")
def _state(disposition: RiskDisposition) -> dict[str, object]:
return {
"adjudication": disposition.adjudication,
"lifecycle_status": disposition.lifecycle_status,
"assignee": disposition.assignee,
"due_at": disposition.due_at.isoformat() if disposition.due_at else None,
"resolution": disposition.resolution,
"version": disposition.version,
}
def _initial_adjudication(observation: RiskObservation) -> str:
values = {
_text(observation.feedback_status).lower(),
_text(observation.status).lower(),
}
if "false_positive" in values:
return "false_positive"
if "confirmed" in values:
return "confirmed"
return "unreviewed"
def _initial_lifecycle_status(observation: RiskObservation) -> str:
return "resolved" if _text(observation.status).lower() == "resolved" else "open"
def _payload_fingerprint(
payload: RiskDispositionActionCreate,
*,
observation_id: str,
actor_id: str,
) -> str:
canonical_payload = payload.model_dump(
mode="json",
exclude={"request_id"},
exclude_none=False,
)
canonical_payload["observation_id"] = observation_id
canonical_payload["actor_id"] = actor_id
canonical = json.dumps(
canonical_payload,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
)
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
def _tenant(value: str) -> str:
return _text(value) or "default"
def _text(value: object) -> str:
return str(value or "").strip()

View File

@@ -0,0 +1,84 @@
from __future__ import annotations
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.api.deps import CurrentUserContext
from app.models.financial_record import ExpenseClaim
from app.models.risk_observation import RiskObservation
from app.services.expense_claim_access_policy import ExpenseClaimAccessPolicy
RISK_POOL_ROLE_CODES = {
"budget_monitor",
"executive",
"finance",
}
class RiskObservationAccessPolicy:
"""集中定义风险池、单据风险和处置动作的访问边界。"""
def __init__(self, db: Session) -> None:
self.db = db
self.claim_policy = ExpenseClaimAccessPolicy(db)
def can_read_tenant_pool(self, current_user: CurrentUserContext) -> bool:
if current_user.is_admin:
return True
return bool(self.claim_policy.normalize_role_codes(current_user) & RISK_POOL_ROLE_CODES)
def find_visible_claim(
self,
claim_id: str,
current_user: CurrentUserContext,
) -> ExpenseClaim | None:
statement = select(ExpenseClaim).where(ExpenseClaim.id == claim_id)
statement = self.claim_policy.apply_claim_scope(
statement,
current_user,
include_approval_scope=True,
)
return self.db.scalar(statement)
def can_read_claim_risks(
self,
claim_id: str,
current_user: CurrentUserContext,
) -> bool:
claim = self.find_visible_claim(claim_id, current_user)
if claim is None:
return False
if current_user.is_admin:
return True
# 完整证据链含图谱、相似案例与人工反馈,仅当前审批人可读取。
return self.claim_policy.can_approve_claim(current_user, claim)
def can_manage_disposition(
self,
observation: RiskObservation,
current_user: CurrentUserContext,
) -> bool:
claim_id = str(observation.claim_id or "").strip()
claim = self.find_visible_claim(claim_id, current_user) if claim_id else None
return self.can_manage_locked_disposition(
observation,
current_user,
locked_claim=claim,
)
def can_manage_locked_disposition(
self,
observation: RiskObservation,
current_user: CurrentUserContext,
*,
locked_claim: ExpenseClaim | None,
) -> bool:
"""在共享 Claim 行锁内按最新审批节点复核处置权限。"""
if current_user.is_admin:
return True
claim_id = str(observation.claim_id or "").strip()
if not claim_id or locked_claim is None or str(locked_claim.id) != claim_id:
# 无关联单据的租户级风险缺少可复用的业务权限边界,仅 admin 可处置。
return False
return self.claim_policy.can_approve_claim(current_user, locked_claim)

View File

@@ -19,6 +19,7 @@ from app.schemas.risk_observation import (
RiskObservationFeedbackCreate,
)
from app.services.expense_claim_risk_stage import normalize_risk_business_stage
from app.services.expense_claim_tenant_scope import ExpenseClaimTenantScopeMixin
logger = get_logger("app.services.risk_observations")
@@ -66,6 +67,7 @@ class RiskObservationService:
tenant_id: str | None = None,
run_id: str | None = None,
execution_log_id: str | None = None,
claim_lock_acquired: bool = False,
) -> RiskObservation:
self.ensure_storage_ready()
payload = (
@@ -80,6 +82,12 @@ class RiskObservationService:
tenant_id=tenant_id or _optional_text(payload.get("tenant_id")),
claim_id=_optional_text(payload.get("claim_id")),
)
claim_id = _optional_text(payload.get("claim_id"))
if claim_id and not claim_lock_acquired:
self.lock_claim_for_risk_write(
claim_id,
tenant_id=normalized_tenant_id,
)
item = self.db.scalar(
select(RiskObservation).where(
@@ -126,6 +134,27 @@ class RiskObservationService:
self.db.flush()
return item
def lock_claim_for_risk_write(
self,
claim_id: str,
*,
tenant_id: str | None,
refresh: bool = False,
) -> ExpenseClaim | None:
"""所有 claim-linked 风险写入共享同一 Claim 行锁。"""
normalized_tenant = _normalize_tenant_id(tenant_id)
statement = select(ExpenseClaim).where(
ExpenseClaim.id == claim_id,
ExpenseClaimTenantScopeMixin.build_claim_tenant_condition(normalized_tenant),
)
bind = self.db.get_bind()
if bind is not None and bind.dialect.name == "postgresql":
statement = statement.with_for_update()
if refresh:
statement = statement.execution_options(populate_existing=True)
return self.db.scalar(statement)
def upsert_platform_risk_flags(
self,
claim: ExpenseClaim,
@@ -154,9 +183,7 @@ class RiskObservationService:
score = SEVERITY_SCORE.get(severity, SEVERITY_SCORE["medium"])
rule_code = _text(flag.get("rule_code"))
business_stage = normalize_risk_business_stage(flag.get("business_stage"))
observation_key = (
f"risk:{claim.id}:platform:{rule_code or signal}"
)
observation_key = f"risk:{claim.id}:platform:{rule_code or signal}"
observations.append(
self.upsert_observation(
{
@@ -176,9 +203,7 @@ class RiskObservationService:
"control_stage": business_stage,
"control_mode": "risk_observation",
"automation_mode": (
"semi_auto_review"
if severity in HIGH_LEVELS
else "manual_review"
"semi_auto_review" if severity in HIGH_LEVELS else "manual_review"
),
"source": "rule_center",
"algorithm_version": _text(flag.get("rule_version")) or "v1.0.0",
@@ -357,6 +382,24 @@ class RiskObservationService:
observation_key_or_id,
tenant_id=tenant_id,
)
if observation is None:
raise LookupError("Risk observation not found.")
normalized_tenant = _normalize_tenant_id(tenant_id)
if observation.claim_id:
self.lock_claim_for_risk_write(
observation.claim_id,
tenant_id=normalized_tenant,
)
observation_statement = select(RiskObservation).where(
RiskObservation.tenant_id == normalized_tenant,
RiskObservation.id == observation.id,
)
bind = self.db.get_bind()
if bind is not None and bind.dialect.name == "postgresql":
observation_statement = observation_statement.with_for_update()
observation = self.db.scalar(
observation_statement.execution_options(populate_existing=True)
)
if observation is None:
raise LookupError("Risk observation not found.")
@@ -505,12 +548,16 @@ class RiskObservationService:
) -> str:
explicit_tenant_id = str(tenant_id or "").strip()
normalized_claim_id = str(claim_id or "").strip()
linked_tenant_id = self.db.scalar(
select(ExpenseCaseLink.tenant_id).where(
ExpenseCaseLink.resource_type == "expense_claim",
ExpenseCaseLink.resource_id == normalized_claim_id,
linked_tenant_id = (
self.db.scalar(
select(ExpenseCaseLink.tenant_id).where(
ExpenseCaseLink.resource_type == "expense_claim",
ExpenseCaseLink.resource_id == normalized_claim_id,
)
)
) if normalized_claim_id else None
if normalized_claim_id
else None
)
claim_tenant_id = _normalize_tenant_id(linked_tenant_id)
if explicit_tenant_id:
normalized_tenant_id = _normalize_tenant_id(explicit_tenant_id)

View File

@@ -28,7 +28,7 @@ from app.models.risk_observation import RiskObservation
MIGRATION_TEST_DATABASE_URL = os.getenv("MIGRATION_TEST_DATABASE_URL", "").strip()
LEGACY_PROBE_TABLE = "legacy_migration_probe_records"
HEAD_REVISION = "20260716_0009"
HEAD_REVISION = "20260716_0011"
SERVER_DIR = Path(__file__).resolve().parents[1]
ALEMBIC_INI_PATH = SERVER_DIR / "alembic.ini"
@@ -357,12 +357,150 @@ def _assert_head_schema(engine: Engine) -> None:
"uq_risk_observations_tenant_key",
("tenant_id", "observation_key"),
)
_assert_unique_constraint(
engine,
"risk_observations",
"uq_risk_observations_tenant_id",
("tenant_id", "id"),
)
_assert_unique_constraint(
engine,
"few_shot_samples",
"uq_few_shot_samples_tenant_key",
("tenant_id", "sample_key"),
)
_assert_unique_constraint(
engine,
"approval_action_ledgers",
"uq_approval_action_ledger_request",
("tenant_id", "actor_id", "request_id"),
)
_assert_indexes(
engine,
"approval_action_ledgers",
{
"ix_approval_action_ledger_claim_action": (
"tenant_id",
"claim_id",
"action",
)
},
)
_assert_check_constraint(
engine,
"approval_action_ledgers",
"ck_approval_action_ledger_action",
)
_assert_check_constraint(
engine,
"approval_action_ledgers",
"ck_approval_action_ledger_completion",
)
_assert_unique_constraint(
engine,
"risk_dispositions",
"uq_risk_dispositions_tenant_observation",
("tenant_id", "observation_id"),
)
_assert_unique_constraint(
engine,
"risk_dispositions",
"uq_risk_dispositions_tenant_id",
("tenant_id", "id"),
)
_assert_composite_foreign_key(
engine,
"risk_dispositions",
("tenant_id", "observation_id"),
"risk_observations",
)
_assert_indexes(
engine,
"risk_dispositions",
{
"ix_risk_dispositions_tenant_lifecycle_due": (
"tenant_id",
"lifecycle_status",
"due_at",
),
"ix_risk_dispositions_assignee": ("tenant_id", "assignee"),
},
)
_assert_check_constraint(
engine,
"risk_dispositions",
"ck_risk_dispositions_adjudication",
)
_assert_check_constraint(
engine,
"risk_dispositions",
"ck_risk_dispositions_lifecycle",
)
_assert_check_constraint(
engine,
"risk_dispositions",
"ck_risk_dispositions_version",
)
_assert_unique_constraint(
engine,
"risk_disposition_events",
"uq_risk_disposition_events_tenant_request",
("tenant_id", "request_id"),
)
_assert_unique_constraint(
engine,
"risk_disposition_events",
"uq_risk_disposition_events_version",
("disposition_id", "version"),
)
_assert_indexes(
engine,
"risk_disposition_events",
{
"ix_risk_disposition_events_disposition_id": ("disposition_id",),
"ix_risk_disposition_events_tenant_observation_time": (
"tenant_id",
"observation_id",
"created_at",
),
},
)
_assert_check_constraint(
engine,
"risk_disposition_events",
"ck_risk_disposition_events_action",
)
_assert_check_constraint(
engine,
"risk_disposition_events",
"ck_risk_disposition_events_version",
)
_assert_composite_foreign_key(
engine,
"risk_disposition_events",
("tenant_id", "disposition_id"),
"risk_dispositions",
)
_assert_composite_foreign_key(
engine,
"risk_disposition_events",
("tenant_id", "observation_id"),
"risk_observations",
)
with engine.connect() as connection:
append_only_trigger_count = int(
connection.scalar(
text(
"SELECT COUNT(*) FROM pg_trigger trigger "
"JOIN pg_class relation ON relation.oid = trigger.tgrelid "
"WHERE relation.relname = 'risk_disposition_events' "
"AND trigger.tgname = 'trg_risk_disposition_events_append_only' "
"AND NOT trigger.tgisinternal"
)
)
or 0
)
assert append_only_trigger_count == 1
_assert_check_constraint(
engine,
"memory_entries",
@@ -972,12 +1110,15 @@ def _create_hierarchical_memory_downgrade_probe(engine: Engine) -> None:
"""
)
)
assert connection.scalar(
text(
"SELECT COUNT(*) FROM memory_entries "
"WHERE id = 'hierarchical-memory-downgrade-probe'"
assert (
connection.scalar(
text(
"SELECT COUNT(*) FROM memory_entries "
"WHERE id = 'hierarchical-memory-downgrade-probe'"
)
)
) == 1
== 1
)
def _create_duplicate_active_organization_memory_probe(engine: Engine) -> None:
@@ -1056,10 +1197,7 @@ def _create_enriched_few_shot_downgrade_probe(engine: Engine) -> None:
def _delete_enriched_few_shot_downgrade_probe(engine: Engine) -> None:
with engine.begin() as connection:
connection.execute(
text(
"DELETE FROM few_shot_samples "
"WHERE id = 'enriched-few-shot-downgrade-probe'"
)
text("DELETE FROM few_shot_samples WHERE id = 'enriched-few-shot-downgrade-probe'")
)
@@ -1116,12 +1254,8 @@ def _create_historical_case_downgrade_probe(engine: Engine) -> None:
def _assert_historical_case_downgrade_probe(engine: Engine) -> None:
inspector = inspect(engine)
risk_columns = {
str(item["name"]) for item in inspector.get_columns("risk_observations")
}
sample_columns = {
str(item["name"]) for item in inspector.get_columns("few_shot_samples")
}
risk_columns = {str(item["name"]) for item in inspector.get_columns("risk_observations")}
sample_columns = {str(item["name"]) for item in inspector.get_columns("few_shot_samples")}
assert "tenant_id" not in risk_columns
assert {"tenant_id", "policy_ref", "rule_version"}.isdisjoint(sample_columns)
assert not any(
@@ -1131,24 +1265,32 @@ def _assert_historical_case_downgrade_probe(engine: Engine) -> None:
for item in inspector.get_foreign_keys("risk_observations")
)
with engine.connect() as connection:
assert connection.scalar(
text(
"SELECT COUNT(*) FROM risk_observations "
"WHERE id = 'historical-downgrade-observation'"
assert (
connection.scalar(
text(
"SELECT COUNT(*) FROM risk_observations "
"WHERE id = 'historical-downgrade-observation'"
)
)
) == 1
assert connection.scalar(
text(
"SELECT COUNT(*) FROM risk_observation_feedback "
"WHERE id = 'historical-downgrade-feedback'"
== 1
)
assert (
connection.scalar(
text(
"SELECT COUNT(*) FROM risk_observation_feedback "
"WHERE id = 'historical-downgrade-feedback'"
)
)
) == 1
assert connection.scalar(
text(
"SELECT COUNT(*) FROM few_shot_samples "
"WHERE id = 'historical-downgrade-sample'"
== 1
)
assert (
connection.scalar(
text(
"SELECT COUNT(*) FROM few_shot_samples WHERE id = 'historical-downgrade-sample'"
)
)
) == 1
== 1
)
def _assert_legacy_sentinel(engine: Engine) -> None:
@@ -1179,6 +1321,10 @@ def _assert_base_schema(engine: Engine) -> None:
("20260716_0008_tenant_safe_historical_cases.py", "downgrade"),
("20260716_0009_organization_memory_idempotency.py", "upgrade"),
("20260716_0009_organization_memory_idempotency.py", "downgrade"),
("20260716_0010_approval_action_protocol.py", "upgrade"),
("20260716_0010_approval_action_protocol.py", "downgrade"),
("20260716_0011_risk_disposition.py", "upgrade"),
("20260716_0011_risk_disposition.py", "downgrade"),
],
)
def test_postgresql_only_migrations_reject_other_dialects_before_mutation(

View File

@@ -0,0 +1,225 @@
from __future__ import annotations
import os
import threading
import time
import uuid
from concurrent.futures import ThreadPoolExecutor
from datetime import UTC, datetime
from decimal import Decimal
import pytest
from sqlalchemy import create_engine, func, select
from sqlalchemy.engine import make_url
from sqlalchemy.orm import Session, sessionmaker
from app.api.deps import CurrentUserContext
from app.db.base import Base
from app.models.approval_action import ApprovalActionLedger
from app.models.employee import Employee
from app.models.financial_record import ExpenseClaim
from app.models.risk_disposition import RiskDisposition
from app.models.risk_observation import RiskObservation
from app.schemas.risk_disposition import RiskDispositionActionCreate
from app.services.expense_claim_risk_gate import ExpenseClaimRiskBlockedError
from app.services.expense_claims import ExpenseClaimService
from app.services.risk_dispositions import RiskDispositionService
DATABASE_URL = os.environ.get("MIGRATION_TEST_DATABASE_URL", "").strip()
def test_disposition_reopen_and_approval_share_claim_lock(
monkeypatch: pytest.MonkeyPatch,
) -> None:
database_url = _require_disposable_database_url()
monkeypatch.setenv("FEW_SHOT_INJECTION_ENABLED", "false")
engine = create_engine(database_url, pool_pre_ping=True)
Base.metadata.create_all(bind=engine)
factory = sessionmaker(bind=engine, expire_on_commit=False)
suffix = uuid.uuid4().hex[:12]
claim_id = f"claim-risk-lock-{suffix}"
observation_id = f"risk-lock-{suffix}"
disposition_id = f"disposition-lock-{suffix}"
manager_email = f"manager-{suffix}@example.com"
manager_user = CurrentUserContext(
username=manager_email,
name="并发审批经理",
role_codes=["manager"],
is_admin=False,
)
with factory() as db:
_seed_locked_risk_case(
db,
claim_id=claim_id,
observation_id=observation_id,
disposition_id=disposition_id,
manager_email=manager_email,
suffix=suffix,
)
claim_locked = threading.Event()
release_disposition = threading.Event()
approval_started = threading.Event()
from app.services import risk_dispositions as risk_disposition_module
original_apply_action = risk_disposition_module._apply_action
def pause_after_claim_lock(*args, **kwargs):
claim_locked.set()
if not release_disposition.wait(timeout=5):
raise TimeoutError("test did not release risk disposition")
return original_apply_action(*args, **kwargs)
monkeypatch.setattr(risk_disposition_module, "_apply_action", pause_after_claim_lock)
def reopen_risk() -> str:
with factory() as db:
result = RiskDispositionService(db).execute_action(
observation_id,
RiskDispositionActionCreate(
action="confirm",
expected_version=1,
request_id=f"request-risk-reopen-{suffix}",
comment="复核后确认风险成立",
),
tenant_id="default",
actor_id=manager_email,
actor_name="并发审批经理",
current_user=manager_user,
)
return result.disposition.adjudication
def approve_claim() -> str:
approval_started.set()
with factory() as db:
try:
ExpenseClaimService(db).approve_claim(
claim_id,
manager_user,
opinion="同意",
request_id=f"request-approve-after-risk-{suffix}",
expected_status="submitted",
expected_approval_stage="直属领导审批",
)
except ExpenseClaimRiskBlockedError:
return "blocked"
return "approved"
try:
with ThreadPoolExecutor(max_workers=2) as pool:
risk_future = pool.submit(reopen_risk)
assert claim_locked.wait(timeout=5)
approval_future = pool.submit(approve_claim)
assert approval_started.wait(timeout=5)
time.sleep(0.2)
assert not approval_future.done()
release_disposition.set()
assert risk_future.result(timeout=5) == "confirmed"
assert approval_future.result(timeout=5) == "blocked"
with factory() as db:
claim = db.get(ExpenseClaim, claim_id)
disposition = db.get(RiskDisposition, disposition_id)
assert claim is not None and claim.approval_stage == "直属领导审批"
assert disposition is not None and disposition.adjudication == "confirmed"
assert (
db.scalar(
select(func.count())
.select_from(ApprovalActionLedger)
.where(ApprovalActionLedger.claim_id == claim_id)
)
== 0
)
finally:
release_disposition.set()
engine.dispose()
def _seed_locked_risk_case(
db: Session,
*,
claim_id: str,
observation_id: str,
disposition_id: str,
manager_email: str,
suffix: str,
) -> None:
manager = Employee(
id=f"manager-risk-lock-{suffix}",
employee_no=f"M-RISK-LOCK-{suffix}",
name="并发审批经理",
email=manager_email,
)
employee = Employee(
id=f"employee-risk-lock-{suffix}",
employee_no=f"E-RISK-LOCK-{suffix}",
name="并发风险员工",
email=f"employee-{suffix}@example.com",
manager=manager,
)
now = datetime.now(UTC)
claim = ExpenseClaim(
id=claim_id,
claim_no=f"EXP-RISK-LOCK-{suffix}",
employee=employee,
employee_name=employee.name,
department_name="风控部",
expense_type="travel",
reason="客户拜访",
location="上海",
amount=Decimal("1200.00"),
currency="CNY",
invoice_count=1,
occurred_at=now,
submitted_at=now,
status="submitted",
approval_stage="直属领导审批",
risk_flags_json=[],
)
observation = RiskObservation(
id=observation_id,
tenant_id="default",
observation_key=f"risk:claim-lock:{suffix}",
subject_type="expense_claim",
subject_key=f"claim:{claim_id}",
subject_label=claim.claim_no,
claim_id=claim_id,
claim_no=claim.claim_no,
risk_type="duplicate_invoice",
risk_signal="duplicate_invoice",
title="重复票据风险",
description="此前被标记为误报,现重新确认。",
risk_score=92,
risk_level="high",
confidence_score=0.95,
control_stage="reimbursement",
control_mode="risk_observation",
automation_mode="semi_auto_review",
source="financial_risk_graph",
algorithm_version="financial_risk_graph.v1",
status="false_positive",
feedback_status="false_positive",
)
disposition = RiskDisposition(
id=disposition_id,
tenant_id="default",
observation_id=observation_id,
adjudication="false_positive",
lifecycle_status="open",
version=1,
)
db.add_all([manager, employee, claim, observation, disposition])
db.commit()
def _require_disposable_database_url() -> str:
if not DATABASE_URL:
pytest.skip("仅在显式配置 MIGRATION_TEST_DATABASE_URL 时运行 PostgreSQL 并发测试")
parsed = make_url(DATABASE_URL)
host = str(parsed.host or "").replace("_", "-").lower()
database = str(parsed.database or "").replace("_", "-").lower()
if not host.startswith(("migration-probe", "disposable-probe")):
raise RuntimeError("并发测试数据库主机必须使用 disposable 前缀")
if not database.startswith(("migration-probe", "disposable-probe")):
raise RuntimeError("并发测试数据库名必须使用 disposable 前缀")
return DATABASE_URL

View File

@@ -0,0 +1,221 @@
from __future__ import annotations
from datetime import UTC, date, datetime, timedelta
from decimal import Decimal
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
from app.models.risk_disposition import RiskDisposition
from app.models.risk_observation import RiskObservation
from app.services.approval_workbench import ApprovalWorkbenchService
def _claim(
*,
claim_no: str = "RE-WORKBENCH-1",
amount: str = "888.00",
submitted_at: datetime,
risk_flags: list[dict] | None = None,
invoice_count: int = 1,
) -> ExpenseClaim:
claim = ExpenseClaim(
id=f"claim-{claim_no.lower()}",
claim_no=claim_no,
employee_id=None,
employee_name="张三",
department_id=None,
department_name="市场部",
project_code="PRJ-WORKBENCH",
expense_type="travel",
reason="客户现场差旅",
location="上海",
amount=Decimal(amount),
currency="CNY",
invoice_count=invoice_count,
occurred_at=submitted_at,
submitted_at=submitted_at,
status="submitted",
approval_stage="直属领导审批",
risk_flags_json=list(risk_flags or []),
created_at=submitted_at,
updated_at=submitted_at,
)
claim.items = [
ExpenseClaimItem(
id=f"item-{claim_no.lower()}",
claim_id=claim.id,
item_date=date(2026, 7, 15),
item_type="hotel",
item_reason="住宿",
item_location="上海",
item_note="",
item_amount=Decimal(amount),
invoice_id="INV-WORKBENCH" if invoice_count else None,
created_at=submitted_at,
updated_at=submitted_at,
)
]
return claim
def test_priority_queue_explains_risk_budget_sla_amount_and_history() -> None:
now = datetime(2026, 7, 16, 12, 0, tzinfo=UTC)
claim = _claim(
amount="60000.00",
submitted_at=now - timedelta(hours=26),
risk_flags=[
{
"source": "ai_pre_review",
"severity": "high",
"disposition": "review",
"resolution_status": "unresolved",
"route_decision": {"budget_result": {"metrics": {"after_usage_rate": "96.5"}}},
"historical_case_evidence": [{"label": "confirmed", "sample_id": "must-not-leak"}],
}
],
)
item = ApprovalWorkbenchService.build_item(claim, now=now)
assert item.priority_score >= 85
assert item.priority_tier == "urgent"
assert item.risk_level == "high"
assert item.sla_overdue is True
assert item.budget_usage_rate == 96.5
assert item.suggestion.action == "manual_review"
assert item.evidence.historical_labels == ["历史已确认,仅供复核"]
assert "must-not-leak" not in repr(item.model_dump())
assert {reason.code for reason in item.priority_reasons} >= {
"open_risk",
"sla_overdue",
"budget_pressure",
"large_amount",
}
def test_application_evidence_does_not_require_invoice_and_resolved_risk_is_ignored() -> None:
now = datetime(2026, 7, 16, 12, 0, tzinfo=UTC)
claim = _claim(
claim_no="AP-WORKBENCH-1",
submitted_at=now - timedelta(hours=1),
invoice_count=0,
risk_flags=[
{
"source": "ai_pre_review",
"severity": "critical",
"resolution_status": "resolved",
}
],
)
item = ApprovalWorkbenchService.build_item(claim, now=now)
assert item.evidence.completeness == 1
assert item.evidence.missing_labels == []
assert item.risk_level == "low"
assert item.open_risk_count == 0
assert item.suggestion.action == "approve_candidate"
assert item.priority_score == 0
def test_persisted_disposition_is_authoritative_over_stale_claim_risk_flags() -> None:
now = datetime(2026, 7, 16, 12, 0, tzinfo=UTC)
claim = _claim(
submitted_at=now - timedelta(hours=1),
risk_flags=[
{
"severity": "critical",
"triggered": True,
"observation_key": "risk:workbench:resolved",
}
],
)
observation = RiskObservation(
id="risk-workbench-resolved",
tenant_id="default",
observation_key="risk:workbench:resolved",
subject_type="expense_claim",
subject_key=f"claim:{claim.id}",
subject_label=claim.claim_no,
claim_id=claim.id,
claim_no=claim.claim_no,
risk_type="duplicate_invoice",
risk_signal="duplicate_invoice",
title="重复票据风险",
description="已复核完成。",
risk_score=95,
risk_level="critical",
confidence_score=0.96,
control_stage="reimbursement",
control_mode="risk_observation",
automation_mode="semi_auto_review",
source="financial_risk_graph",
algorithm_version="v1",
status="resolved",
feedback_status="confirmed",
)
disposition = RiskDisposition(
tenant_id="default",
observation_id=observation.id,
adjudication="confirmed",
lifecycle_status="resolved",
version=2,
)
item = ApprovalWorkbenchService.build_item(
claim,
now=now,
observation_rows=[(observation, disposition)],
)
assert item.risk_level == "low"
assert item.open_risk_count == 0
assert item.suggestion.action == "approve_candidate"
def test_persisted_low_risk_does_not_hide_unmaterialized_raw_high_risk() -> None:
now = datetime(2026, 7, 16, 12, 0, tzinfo=UTC)
claim = _claim(
submitted_at=now - timedelta(hours=1),
risk_flags=[
{
"source": "attachment_analysis",
"severity": "high",
"label": "票据金额异常",
"triggered": True,
}
],
)
observation = RiskObservation(
id="risk-workbench-low",
tenant_id="default",
observation_key="risk:workbench:low",
subject_type="expense_claim",
subject_key=f"claim:{claim.id}",
subject_label=claim.claim_no,
claim_id=claim.id,
claim_no=claim.claim_no,
risk_type="minor_notice",
risk_signal="minor_notice",
title="普通提醒",
description="普通提醒。",
risk_score=30,
risk_level="low",
confidence_score=0.8,
control_stage="reimbursement",
control_mode="risk_observation",
automation_mode="manual_review",
source="financial_risk_graph",
algorithm_version="v1",
status="pending_review",
feedback_status="unreviewed",
)
item = ApprovalWorkbenchService.build_item(
claim,
now=now,
observation_rows=[(observation, None)],
)
assert item.risk_level == "high"
assert item.open_risk_count == 2
assert item.suggestion.action == "manual_review"

View File

@@ -0,0 +1,322 @@
from __future__ import annotations
from concurrent.futures import ThreadPoolExecutor
from datetime import UTC, datetime
from decimal import Decimal
import pytest
from sqlalchemy import create_engine, func, select
from sqlalchemy.orm import Session, sessionmaker
from app.api.deps import CurrentUserContext
from app.db.base import Base
from app.models.approval_action import ApprovalActionLedger
from app.models.audit_log import AuditLog
from app.models.employee import Employee
from app.models.expense_case import BusinessEvent
from app.models.financial_record import ExpenseClaim
from app.services.approval_action_protocol import (
ApprovalActionConflictError,
ApprovalActionProtocol,
)
from app.services.expense_claims import ExpenseClaimService
def _manager_user() -> CurrentUserContext:
return CurrentUserContext(
username="manager-action@example.com",
name="李经理",
role_codes=["manager"],
is_admin=False,
)
def _finance_user() -> CurrentUserContext:
return CurrentUserContext(
username="finance-action@example.com",
name="王财务",
role_codes=["finance"],
is_admin=False,
)
def _seed_claim(
db: Session,
*,
claim_id: str = "claim-action-1",
manager_email: str = "manager-action@example.com",
) -> ExpenseClaim:
manager = Employee(
id=f"manager-{claim_id}",
employee_no=f"M-{claim_id}",
name="李经理",
email=manager_email,
)
employee = Employee(
id=f"employee-{claim_id}",
employee_no=f"E-{claim_id}",
name="张三",
email=f"employee-{claim_id}@example.com",
manager=manager,
)
claim = ExpenseClaim(
id=claim_id,
claim_no=f"EXP-{claim_id}",
employee=employee,
employee_name="张三",
department_name="市场部",
expense_type="transport",
reason="客户拜访",
location="上海",
amount=Decimal("88.00"),
currency="CNY",
invoice_count=1,
occurred_at=datetime(2026, 7, 16, tzinfo=UTC),
submitted_at=datetime(2026, 7, 16, tzinfo=UTC),
status="submitted",
approval_stage="直属领导审批",
risk_flags_json=[],
)
db.add(claim)
db.commit()
return claim
@pytest.fixture
def session_factory() -> sessionmaker[Session]:
engine = create_engine("sqlite+pysqlite:///:memory:")
Base.metadata.create_all(engine)
factory = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)
try:
yield factory
finally:
engine.dispose()
def test_approve_replay_persists_one_ledger_event_and_audit(
session_factory: sessionmaker[Session],
) -> None:
with session_factory() as db:
claim = _seed_claim(db)
service = ExpenseClaimService(db)
first = service.approve_claim(
claim.id,
_manager_user(),
opinion="同意",
request_id="approve-retry-1",
expected_status="submitted",
expected_approval_stage="直属领导审批",
)
replay = service.approve_claim(
claim.id,
_manager_user(),
opinion="同意",
request_id="approve-retry-1",
expected_status="submitted",
expected_approval_stage="直属领导审批",
)
assert first is not None and replay is not None
assert replay.approval_stage == "财务审批"
assert db.scalar(select(func.count()).select_from(ApprovalActionLedger)) == 1
assert db.scalar(select(func.count()).select_from(BusinessEvent)) == 1
assert (
db.scalar(
select(func.count())
.select_from(AuditLog)
.where(AuditLog.request_id == "approve-retry-1")
)
== 1
)
def test_request_id_payload_mismatch_and_stale_preconditions_return_conflict(
session_factory: sessionmaker[Session],
) -> None:
with session_factory() as db:
claim = _seed_claim(db, claim_id="claim-action-conflict")
service = ExpenseClaimService(db)
with pytest.raises(ApprovalActionConflictError, match="单据状态已从"):
service.approve_claim(
claim.id,
_manager_user(),
opinion="同意",
request_id="approve-stale-1",
expected_status="draft",
expected_approval_stage="直属领导审批",
)
service.approve_claim(
claim.id,
_manager_user(),
opinion="同意",
request_id="approve-conflict-1",
expected_status="submitted",
expected_approval_stage="直属领导审批",
)
with pytest.raises(ApprovalActionConflictError, match="已用于另一项"):
service.approve_claim(
claim.id,
_manager_user(),
opinion="改为有条件通过",
request_id="approve-conflict-1",
expected_status="submitted",
expected_approval_stage="直属领导审批",
)
assert (
db.scalar(
select(func.count())
.select_from(ApprovalActionLedger)
.where(ApprovalActionLedger.request_id == "approve-stale-1")
)
== 0
)
def test_action_failure_rolls_back_ledger_claim_event_and_audit(
session_factory: sessionmaker[Session],
monkeypatch: pytest.MonkeyPatch,
) -> None:
with session_factory() as db:
claim = _seed_claim(db, claim_id="claim-action-rollback")
service = ExpenseClaimService(db)
def fail_completion(*args, **kwargs):
raise RuntimeError("ledger completion failed")
monkeypatch.setattr(ApprovalActionProtocol, "complete", fail_completion)
with pytest.raises(RuntimeError, match="ledger completion failed"):
service.approve_claim(
claim.id,
_manager_user(),
opinion="同意",
request_id="approve-rollback-1",
expected_status="submitted",
expected_approval_stage="直属领导审批",
)
db.expire_all()
persisted = db.get(ExpenseClaim, claim.id)
assert persisted is not None
assert persisted.status == "submitted"
assert persisted.approval_stage == "直属领导审批"
assert db.scalar(select(func.count()).select_from(ApprovalActionLedger)) == 0
assert db.scalar(select(func.count()).select_from(BusinessEvent)) == 0
assert db.scalar(select(func.count()).select_from(AuditLog)) == 0
def test_legacy_stage_repair_cannot_commit_inside_action_protocol(
session_factory: sessionmaker[Session],
) -> None:
with session_factory() as db:
claim = _seed_claim(db, claim_id="claim-action-stage-repair")
claim.approval_stage = "预算管理者审批"
claim.risk_flags_json = [
{
"source": "manual_approval",
"event_type": "expense_claim_approval",
"previous_approval_stage": "直属领导审批",
"next_approval_stage": "预算管理者审批",
"operator": "李经理",
"next_approver_name": "李经理",
}
]
db.commit()
admin_user = CurrentUserContext(
username="admin-action@example.com",
name="审批管理员",
role_codes=["admin"],
is_admin=True,
)
with pytest.raises(ApprovalActionConflictError, match="审批节点已从"):
ExpenseClaimService(db).approve_claim(
claim.id,
admin_user,
opinion="同意",
request_id="approve-stage-repair-1",
expected_status="submitted",
expected_approval_stage="预算管理者审批",
)
db.expire_all()
persisted = db.get(ExpenseClaim, claim.id)
assert persisted is not None
assert persisted.approval_stage == "预算管理者审批"
assert db.scalar(select(func.count()).select_from(ApprovalActionLedger)) == 0
def test_return_and_pay_actions_use_the_same_protocol(
session_factory: sessionmaker[Session],
) -> None:
with session_factory() as db:
returned_claim = _seed_claim(db, claim_id="claim-action-return")
paid_claim = _seed_claim(
db,
claim_id="claim-action-pay",
manager_email="manager-pay-action@example.com",
)
paid_claim.status = "pending_payment"
paid_claim.approval_stage = "待付款"
db.commit()
returned = ExpenseClaimService(db).return_claim(
returned_claim.id,
_manager_user(),
reason="请补充材料",
request_id="return-action-1",
expected_status="submitted",
expected_approval_stage="直属领导审批",
)
paid = ExpenseClaimService(db).mark_claim_paid(
paid_claim.id,
_finance_user(),
request_id="pay-action-1",
expected_status="pending_payment",
expected_approval_stage="待付款",
)
assert returned is not None and returned.status == "returned"
assert paid is not None and paid.status == "paid"
ledgers = list(
db.scalars(select(ApprovalActionLedger).order_by(ApprovalActionLedger.action)).all()
)
assert [(item.action, item.result_status) for item in ledgers] == [
("pay", "paid"),
("return", "returned"),
]
def test_concurrent_identical_request_executes_once(tmp_path) -> None:
engine = create_engine(
f"sqlite+pysqlite:///{tmp_path / 'approval-action.db'}",
connect_args={"check_same_thread": False},
)
Base.metadata.create_all(engine)
factory = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)
with factory() as db:
_seed_claim(db, claim_id="claim-action-concurrent")
def approve() -> str:
with factory() as db:
result = ExpenseClaimService(db).approve_claim(
"claim-action-concurrent",
_manager_user(),
opinion="同意",
request_id="approve-concurrent-1",
expected_status="submitted",
expected_approval_stage="直属领导审批",
)
assert result is not None
return str(result.approval_stage)
try:
with ThreadPoolExecutor(max_workers=2) as pool:
results = list(pool.map(lambda _: approve(), range(2)))
assert results == ["财务审批", "财务审批"]
with factory() as db:
assert db.scalar(select(func.count()).select_from(ApprovalActionLedger)) == 1
assert db.scalar(select(func.count()).select_from(BusinessEvent)) == 1
finally:
engine.dispose()

View File

@@ -0,0 +1,222 @@
from __future__ import annotations
from datetime import UTC, datetime
from decimal import Decimal
import pytest
from sqlalchemy import create_engine, func, select
from sqlalchemy.orm import Session
from app.api.deps import CurrentUserContext
from app.db.base import Base
from app.models.approval_action import ApprovalActionLedger
from app.models.employee import Employee
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_gate import (
ExpenseClaimRiskBlockedError,
ExpenseClaimRiskGate,
)
from app.services.expense_claims import ExpenseClaimService
def test_high_risk_requires_false_positive_or_resolved_disposition() -> None:
with _session() as db:
claim = _claim()
observation = _observation(claim)
db.add_all([claim, observation])
db.commit()
gate = ExpenseClaimRiskGate(db)
with pytest.raises(ExpenseClaimRiskBlockedError):
gate.ensure_approvable(claim, tenant_id="default")
disposition = RiskDisposition(
tenant_id="default",
observation_id=observation.id,
adjudication="false_positive",
lifecycle_status="open",
)
db.add(disposition)
db.commit()
gate.ensure_approvable(claim, tenant_id="default")
disposition.adjudication = "confirmed"
db.commit()
with pytest.raises(ExpenseClaimRiskBlockedError):
gate.ensure_approvable(claim, tenant_id="default")
disposition.lifecycle_status = "resolved"
db.commit()
gate.ensure_approvable(claim, tenant_id="default")
def test_medium_and_foreign_tenant_risks_do_not_block_claim() -> None:
with _session() as db:
claim = _claim(claim_id="claim-risk-nonblocking")
medium = _observation(claim, observation_id="risk-medium", risk_level="medium")
foreign = _observation(
claim,
observation_id="risk-foreign",
risk_level="critical",
tenant_id="tenant-b",
)
db.add_all([claim, medium, foreign])
db.commit()
ExpenseClaimRiskGate(db).ensure_approvable(claim, tenant_id="default")
def test_unmaterialized_raw_high_risk_blocks_approval() -> None:
with _session() as db:
claim = _claim(claim_id="claim-risk-raw-only")
claim.risk_flags_json = [
{
"source": "attachment_analysis",
"severity": "high",
"label": "票据金额异常",
"message": "票据金额与申报金额不一致。",
"triggered": True,
}
]
db.add(claim)
db.commit()
with pytest.raises(ExpenseClaimRiskBlockedError) as captured:
ExpenseClaimRiskGate(db).ensure_approvable(claim, tenant_id="default")
assert captured.value.blockers[0].observation_id.startswith("raw:")
assert captured.value.blockers[0].risk_level == "high"
def test_persisted_observation_does_not_hide_another_raw_high_risk() -> None:
with _session() as db:
claim = _claim(claim_id="claim-risk-partial-materialization")
claim.risk_flags_json = [
{
"source": "attachment_analysis",
"severity": "critical",
"label": "另一条未物化风险",
"triggered": True,
}
]
low_observation = _observation(
claim,
observation_id="risk-low-materialized",
risk_level="low",
)
db.add_all([claim, low_observation])
db.commit()
with pytest.raises(ExpenseClaimRiskBlockedError) as captured:
ExpenseClaimRiskGate(db).ensure_approvable(claim, tenant_id="default")
assert [item.risk_level for item in captured.value.blockers] == ["critical"]
def test_blocked_approval_rolls_back_action_ledger_and_claim_mutation() -> None:
with _session() as db:
manager = Employee(
id="manager-risk-gate",
employee_no="M-RISK-GATE",
name="风险经理",
email="risk-gate-manager@example.com",
)
employee = Employee(
id="employee-risk-gate",
employee_no="E-RISK-GATE",
name="风险员工",
email="risk-gate-employee@example.com",
manager=manager,
)
claim = _claim(employee=employee, claim_id="claim-risk-blocked-approval")
db.add_all([manager, employee, claim, _observation(claim)])
db.commit()
with pytest.raises(ExpenseClaimRiskBlockedError):
ExpenseClaimService(db).approve_claim(
claim.id,
CurrentUserContext(
username=manager.email,
name=manager.name,
role_codes=["manager"],
is_admin=False,
),
opinion="同意",
request_id="risk-blocked-approval-001",
expected_status="submitted",
expected_approval_stage="直属领导审批",
)
db.expire_all()
persisted = db.get(ExpenseClaim, claim.id)
assert persisted is not None
assert persisted.status == "submitted"
assert persisted.approval_stage == "直属领导审批"
assert db.scalar(select(func.count()).select_from(ApprovalActionLedger)) == 0
def _session() -> Session:
engine = create_engine("sqlite+pysqlite:///:memory:")
Base.metadata.create_all(bind=engine)
return Session(engine)
def _claim(
*,
claim_id: str = "claim-risk-gate",
employee: Employee | None = None,
) -> ExpenseClaim:
now = datetime(2026, 7, 16, tzinfo=UTC)
return ExpenseClaim(
id=claim_id,
claim_no=f"EXP-{claim_id}",
employee=employee,
employee_name=employee.name if employee else "风险员工",
department_name="风控部",
expense_type="travel",
reason="客户拜访",
location="上海",
amount=Decimal("1200"),
currency="CNY",
invoice_count=1,
occurred_at=now,
submitted_at=now,
status="submitted",
approval_stage="直属领导审批",
risk_flags_json=[],
)
def _observation(
claim: ExpenseClaim,
*,
observation_id: str = "risk-gate-observation",
risk_level: str = "high",
tenant_id: str = "default",
) -> RiskObservation:
return RiskObservation(
id=f"{observation_id}-{claim.id}",
tenant_id=tenant_id,
observation_key=f"risk:{tenant_id}:{observation_id}:{claim.id}",
subject_type="expense_claim",
subject_key=f"claim:{claim.id}",
subject_label=claim.claim_no,
claim_id=claim.id,
claim_no=claim.claim_no,
risk_type="duplicate_invoice",
risk_signal="duplicate_invoice",
title="重复票据风险",
description="同一票据可能重复报销。",
risk_score=90,
risk_level=risk_level,
confidence_score=0.95,
control_stage="reimbursement",
control_mode="risk_observation",
automation_mode="semi_auto_review",
source="financial_risk_graph",
algorithm_version="financial_risk_graph.v1",
status="pending_review",
feedback_status="unreviewed",
)

View File

@@ -38,7 +38,6 @@ from app.services.expense_claim_workflow_constants import (
APPROVAL_DONE_STAGE,
BUDGET_MANAGER_APPROVAL_STAGE,
DIRECT_MANAGER_APPROVAL_STAGE,
FINANCE_APPROVAL_STAGE,
)
from app.services.expense_claims import ExpenseClaimService
from app.services.ocr import OcrService
@@ -486,11 +485,13 @@ def test_upsert_draft_from_ontology_persists_linked_application_context() -> Non
)
db.add(employee)
db.flush()
db.add(build_application_claim(
id="application-linked-1",
claim_no="AP-202605-001",
employee=employee,
))
db.add(
build_application_claim(
id="application-linked-1",
claim_no="AP-202605-001",
employee=employee,
)
)
db.commit()
ontology = SemanticOntologyService(db).parse(
OntologyParseRequest(
@@ -554,11 +555,13 @@ def test_upsert_linked_application_draft_without_receipts_has_no_placeholder_ite
)
db.add(employee)
db.flush()
db.add(build_application_claim(
id="application-linked-no-receipt",
claim_no="AP-202606-001",
employee=employee,
))
db.add(
build_application_claim(
id="application-linked-no-receipt",
claim_no="AP-202606-001",
employee=employee,
)
)
db.commit()
ontology = SemanticOntologyService(db).parse(
OntologyParseRequest(
@@ -623,7 +626,10 @@ def test_upsert_linked_application_draft_without_receipts_has_no_placeholder_ite
)
assert link_flag["application_claim_no"] == "AP-202606-001"
assert link_flag["application_detail"]["application_time"] == "2026-02-20 至 2026-02-23"
assert link_flag["application_detail"]["application_business_time"] == "2026-02-20 至 2026-02-23"
assert (
link_flag["application_detail"]["application_business_time"]
== "2026-02-20 至 2026-02-23"
)
assert link_flag["application_detail"]["application_date"] == "2026-06-02T00:58:00Z"
assert link_flag["application_detail"]["application_amount"] == "3000"
assert link_flag["application_detail"]["application_days"] == "4 天"
@@ -649,11 +655,13 @@ def test_upsert_linked_application_draft_clears_existing_placeholder_item() -> N
)
db.add(employee)
db.flush()
db.add(build_application_claim(
id="application-linked-existing-placeholder",
claim_no="AP-202606-002",
employee=employee,
))
db.add(
build_application_claim(
id="application-linked-existing-placeholder",
claim_no="AP-202606-002",
employee=employee,
)
)
existing_claim = ExpenseClaim(
claim_no="RE-202606020001-PLACEHOLDER",
employee_id=employee.id,
@@ -738,12 +746,14 @@ def test_upsert_linked_application_requires_approved_application() -> None:
employee = Employee(employee_no="E5108", name="Linked Employee", email=user_id)
db.add(employee)
db.flush()
db.add(build_application_claim(
id="application-returned-blocked",
claim_no="AP-202606-STATUS",
employee=employee,
status="returned",
))
db.add(
build_application_claim(
id="application-returned-blocked",
claim_no="AP-202606-STATUS",
employee=employee,
status="returned",
)
)
db.commit()
ontology = SemanticOntologyService(db).parse(
@@ -785,11 +795,13 @@ def test_upsert_linked_application_rejects_duplicate_reimbursement_draft() -> No
employee = Employee(employee_no="E5109", name="Linked Employee", email=user_id)
db.add(employee)
db.flush()
db.add(build_application_claim(
id="application-duplicate-blocked",
claim_no="AP-202606-DUP",
employee=employee,
))
db.add(
build_application_claim(
id="application-duplicate-blocked",
claim_no="AP-202606-DUP",
employee=employee,
)
)
existing_claim = ExpenseClaim(
claim_no="RE-202606-DUP-DRAFT",
employee_id=employee.id,
@@ -995,11 +1007,7 @@ def test_unsaved_conversation_expires_after_retention_but_saved_conversation_sta
def test_resolve_expense_type_maps_office_supplies_review_value_to_office() -> None:
expense_type = ExpenseClaimService._resolve_expense_type(
[],
context_json={
"review_form_values": {
"expense_type": "办公用品"
}
},
context_json={"review_form_values": {"expense_type": "办公用品"}},
)
assert expense_type == "office"
@@ -1008,11 +1016,7 @@ def test_resolve_expense_type_maps_office_supplies_review_value_to_office() -> N
def test_resolve_expense_type_maps_riding_fare_review_value_to_transport() -> None:
expense_type = ExpenseClaimService._resolve_expense_type(
[],
context_json={
"review_form_values": {
"expense_type": "乘车费用"
}
},
context_json={"review_form_values": {"expense_type": "乘车费用"}},
)
assert expense_type == "transport"
@@ -1340,7 +1344,9 @@ def test_upsert_draft_from_ontology_supports_link_or_create_for_multi_documents(
"text": "停车费 合计 18 元",
"document_type": "parking_toll_receipt",
"scene_code": "transport",
"document_fields": [{"key": "total_amount", "label": "合计金额", "value": "18"}],
"document_fields": [
{"key": "total_amount", "label": "合计金额", "value": "18"}
],
},
],
}
@@ -2044,6 +2050,7 @@ def test_update_claim_item_reanalyzes_existing_attachment(monkeypatch, tmp_path)
assert refreshed_meta["requirement_check"]["matches"] is False
assert any("附件类型要求" in point for point in refreshed_meta["analysis"]["points"])
def test_upload_attachment_refreshes_claim_pre_review(monkeypatch, tmp_path) -> None:
current_user = CurrentUserContext(
username="emp-1",
@@ -2518,15 +2525,13 @@ def test_upload_attachment_runs_rule_center_city_risk_from_origin_destination_fi
flags = payload["claim_risk_flags"]
assert any(
isinstance(flag, dict)
and flag.get("rule_code") == "risk.travel.high.city_mismatch"
isinstance(flag, dict) and flag.get("rule_code") == "risk.travel.high.city_mismatch"
for flag in flags
)
city_flag = next(
flag
for flag in flags
if isinstance(flag, dict)
and flag.get("rule_code") == "risk.travel.high.city_mismatch"
if isinstance(flag, dict) and flag.get("rule_code") == "risk.travel.high.city_mismatch"
)
assert city_flag.get("item_ids") == [claim.items[0].id]
@@ -2604,8 +2609,7 @@ def test_upload_attachment_uses_linked_application_business_time_for_date_risk(
flags = payload["claim_risk_flags"]
assert any(
isinstance(flag, dict)
and flag.get("rule_code") == "risk.travel.high.date_outside_trip"
isinstance(flag, dict) and flag.get("rule_code") == "risk.travel.high.date_outside_trip"
for flag in flags
)
@@ -2686,8 +2690,13 @@ def test_upload_hotel_attachment_audits_date_like_amount(monkeypatch, tmp_path)
)
assert uploaded_meta is not None
assert uploaded_meta["analysis"]["severity"] == "medium"
assert any("费用核算" in point and "828.00 元" in point for point in uploaded_meta["analysis"]["points"])
assert not any("2026.00 元与报销金额" in point for point in uploaded_meta["analysis"]["points"])
assert any(
"费用核算" in point and "828.00 元" in point
for point in uploaded_meta["analysis"]["points"]
)
assert not any(
"2026.00 元与报销金额" in point for point in uploaded_meta["analysis"]["points"]
)
def test_upload_hotel_attachment_flags_amount_over_travel_policy(monkeypatch, tmp_path) -> None:
@@ -2889,7 +2898,9 @@ def test_upload_hotel_attachment_does_not_add_generic_auto_review_summary(
)
def test_delete_claim_item_attachment_removes_attachment_analysis_risk(monkeypatch, tmp_path) -> None:
def test_delete_claim_item_attachment_removes_attachment_analysis_risk(
monkeypatch, tmp_path
) -> None:
current_user = CurrentUserContext(
username="emp-hotel-risk@example.com",
name="张三",
@@ -2964,7 +2975,8 @@ def test_delete_claim_item_attachment_removes_attachment_analysis_risk(monkeypat
assert upload_payload is not None
assert any(
isinstance(flag, dict) and str(flag.get("source") or "").strip() == "attachment_analysis"
isinstance(flag, dict)
and str(flag.get("source") or "").strip() == "attachment_analysis"
for flag in upload_payload["claim_risk_flags"]
)
@@ -2977,7 +2989,8 @@ def test_delete_claim_item_attachment_removes_attachment_analysis_risk(monkeypat
assert delete_payload is not None
assert delete_payload["invoice_id"] is None
assert not any(
isinstance(flag, dict) and str(flag.get("source") or "").strip() == "attachment_analysis"
isinstance(flag, dict)
and str(flag.get("source") or "").strip() == "attachment_analysis"
for flag in delete_payload["claim_risk_flags"]
)
assert not any(
@@ -2990,7 +3003,8 @@ def test_delete_claim_item_attachment_removes_attachment_analysis_risk(monkeypat
assert claim.invoice_count == 0
assert claim.items[0].invoice_id is None
assert not any(
isinstance(flag, dict) and str(flag.get("source") or "").strip() == "attachment_analysis"
isinstance(flag, dict)
and str(flag.get("source") or "").strip() == "attachment_analysis"
for flag in list(claim.risk_flags_json or [])
)
@@ -3278,7 +3292,9 @@ def test_applicant_can_delete_own_editable_draft_claim(monkeypatch, tmp_path) ->
assert db.get(ExpenseClaim, claim_id) is None
def test_attachment_preview_resolves_legacy_filename_in_claim_item_directory(monkeypatch, tmp_path) -> None:
def test_attachment_preview_resolves_legacy_filename_in_claim_item_directory(
monkeypatch, tmp_path
) -> None:
current_user = CurrentUserContext(
username="emp-1",
name="张三",
@@ -3316,7 +3332,9 @@ def test_attachment_preview_resolves_legacy_filename_in_claim_item_directory(mon
assert filename == "legacy-ticket.pdf"
def test_attachment_pdf_preview_falls_back_to_source_when_render_fonts_missing(monkeypatch, tmp_path) -> None:
def test_attachment_pdf_preview_falls_back_to_source_when_render_fonts_missing(
monkeypatch, tmp_path
) -> None:
current_user = CurrentUserContext(
username="emp-1",
name="张三",
@@ -3359,9 +3377,13 @@ def test_attachment_pdf_preview_falls_back_to_source_when_render_fonts_missing(m
def fake_render_pdf_first_page(*, pdf_path, preview_path, timeout_seconds):
raise RuntimeError("Missing language pack for 'Adobe-GB1' mapping")
monkeypatch.setattr(DocumentPreviewAssets, "render_pdf_first_page", fake_render_pdf_first_page)
monkeypatch.setattr(
DocumentPreviewAssets, "render_pdf_first_page", fake_render_pdf_first_page
)
resolved_path, media_type, filename = ExpenseClaimService(db).get_claim_item_attachment_preview_content(
resolved_path, media_type, filename = ExpenseClaimService(
db
).get_claim_item_attachment_preview_content(
claim_id=claim.id,
item_id=claim.items[0].id,
current_user=current_user,
@@ -3412,6 +3434,7 @@ def test_submit_claim_runs_ai_review_and_routes_to_direct_manager() -> None:
assert submitted.approval_stage == "直属领导审批"
assert submitted.submitted_at is not None
def test_submit_claim_refreshes_legacy_pre_review_without_fingerprint(monkeypatch) -> None:
current_user = CurrentUserContext(
username="emp-submit@example.com",
@@ -3470,13 +3493,10 @@ def test_submit_claim_refreshes_legacy_pre_review_without_fingerprint(monkeypatc
assert submitted.status == "submitted"
assert review_calls == 1
assert not any(
flag.get("label") == "upload-time-warning"
for flag in submitted.risk_flags_json
flag.get("label") == "upload-time-warning" for flag in submitted.risk_flags_json
)
pre_review_flag = next(
flag
for flag in submitted.risk_flags_json
if flag.get("source") == "ai_pre_review"
flag for flag in submitted.risk_flags_json if flag.get("source") == "ai_pre_review"
)
assert pre_review_flag["review_id"]
assert pre_review_flag["input_fingerprint"].startswith("sha256:")
@@ -3823,8 +3843,7 @@ def test_submit_claim_blocks_high_risk_attachment_until_submitter_fixes_it(
assert blocked.submitted_at is None
assert error_info.value.review["decision"] == "needs_fix"
assert any(
finding["severity"] == "high"
and finding["disposition"] == "fix"
finding["severity"] == "high" and finding["disposition"] == "fix"
for finding in error_info.value.review["findings"]
)
@@ -4000,10 +4019,7 @@ def test_submit_claim_blocks_travel_route_mismatch_until_submitter_explains_it(
if "多城市" in finding["message"] or "终点" in finding["message"]
]
assert route_findings
assert any(
"travel-item-2" in finding["item_ids"]
for finding in route_findings
)
assert any("travel-item-2" in finding["item_ids"] for finding in route_findings)
def test_submit_claim_allows_round_trip_ticket_origin_inferred_from_route(
@@ -4297,8 +4313,7 @@ def test_submit_claim_blocks_hotel_amount_over_policy_until_standard_adjustment(
assert blocked.status == "draft"
assert error_info.value.review["decision"] == "needs_fix"
assert any(
finding.get("remediation", {}).get("alternative_action")
== "accept_standard_limit"
finding.get("remediation", {}).get("alternative_action") == "accept_standard_limit"
for finding in error_info.value.review["findings"]
)
assert any(
@@ -5360,12 +5375,15 @@ def test_admin_delete_linked_reimbursement_resets_application_link_status() -> N
sync_flag = next(
flag
for flag in application_claim.risk_flags_json
if isinstance(flag, dict) and flag.get("event_type") == "expense_application_reimbursement_deleted"
if isinstance(flag, dict)
and flag.get("event_type") == "expense_application_reimbursement_deleted"
)
assert sync_flag["source"] == "application_link_sync"
assert sync_flag["severity"] == "info"
assert sync_flag["actionability"] == "system_trace"
assert sync_flag["deleted_reimbursement_claim_id"] == "reimbursement-delete-linked-application"
assert (
sync_flag["deleted_reimbursement_claim_id"] == "reimbursement-delete-linked-application"
)
assert sync_flag["deleted_reimbursement_claim_no"] == "RDELETE01"
assert sync_flag["next_approval_stage"] == APPLICATION_LINK_STATUS_STAGE
@@ -5414,7 +5432,9 @@ def test_direct_manager_can_return_subordinate_claim_to_pending_submission() ->
db.commit()
claim_id = claim.id
returned = ExpenseClaimService(db).return_claim(claim_id, current_user, reason="请补充行程说明")
returned = ExpenseClaimService(db).return_claim(
claim_id, current_user, reason="请补充行程说明"
)
assert returned is not None
assert returned.status == "returned"
@@ -5603,6 +5623,7 @@ def test_direct_manager_budget_monitor_routes_reimbursement_directly_to_finance(
{
"source": "submission_review",
"severity": "high",
"actionability": "route_review",
"label": "报销风险复核",
"message": "多城市行程和住宿超标需要预算管理者二次确认。",
}
@@ -5621,8 +5642,7 @@ def test_direct_manager_budget_monitor_routes_reimbursement_directly_to_finance(
assert approved.status == "submitted"
assert approved.approval_stage == "财务审批"
assert not any(
isinstance(flag, dict)
and flag.get("next_approval_stage") == "预算管理者审批"
isinstance(flag, dict) and flag.get("next_approval_stage") == "预算管理者审批"
for flag in approved.risk_flags_json
)
assert any(
@@ -5635,12 +5655,13 @@ def test_direct_manager_budget_monitor_routes_reimbursement_directly_to_finance(
and flag.get("next_status") == "submitted"
and flag.get("next_approval_stage") == "财务审批"
and flag.get("budget_approval_merged") is True
and flag.get("budget_approval_merged_reason") == "direct_manager_is_department_budget_approver"
and flag.get("budget_approval_merged_reason")
== "direct_manager_is_department_budget_approver"
for flag in approved.risk_flags_json
)
def test_duplicate_budget_stage_from_legacy_reimbursement_is_repaired_on_read() -> None:
def test_legacy_duplicate_budget_stage_is_not_mutated_by_read() -> None:
admin_user = CurrentUserContext(
username="admin",
name="admin",
@@ -5706,20 +5727,25 @@ def test_duplicate_budget_stage_from_legacy_reimbursement_is_repaired_on_read()
db.add(claim)
db.commit()
repaired = ExpenseClaimService(db).get_claim(claim.id, admin_user)
result = ExpenseClaimService(db).get_claim(claim.id, admin_user)
assert repaired is not None
assert repaired.approval_stage == FINANCE_APPROVAL_STAGE
assert any(
assert result is not None
assert result.approval_stage == BUDGET_MANAGER_APPROVAL_STAGE
assert not any(
isinstance(flag, dict)
and flag.get("source") == "approval_flow_repair"
and flag.get("event_type") == "duplicate_budget_approval_stage_repaired"
and flag.get("next_approval_stage") == FINANCE_APPROVAL_STAGE
for flag in repaired.risk_flags_json
for flag in result.risk_flags_json
)
db.expire_all()
persisted = db.get(ExpenseClaim, claim.id)
assert persisted is not None
assert persisted.approval_stage == BUDGET_MANAGER_APPROVAL_STAGE
def test_application_submit_skips_ai_review_and_receipt_requirements(monkeypatch: pytest.MonkeyPatch) -> None:
def test_application_submit_skips_ai_review_and_receipt_requirements(
monkeypatch: pytest.MonkeyPatch,
) -> None:
current_user = CurrentUserContext(
username="application-owner@example.com",
name="张三",
@@ -5970,7 +5996,9 @@ def test_application_submit_skips_budget_for_non_demo_subject() -> None:
)
def test_direct_manager_can_route_application_claim_to_budget_approval_then_budget_manager_creates_draft() -> None:
def test_direct_manager_can_route_application_claim_to_budget_approval_then_budget_manager_creates_draft() -> (
None
):
manager_user = CurrentUserContext(
username="manager-application-approve@example.com",
name="李经理",
@@ -6053,9 +6081,10 @@ def test_direct_manager_can_route_application_claim_to_budget_approval_then_budg
{
"source": "submission_review",
"severity": "high",
"actionability": "route_review",
"label": "申请风险复核",
"message": "申请金额和行程安排需要预算管理者二次确认。",
}
},
],
)
db.add(claim)
@@ -6120,13 +6149,18 @@ def test_direct_manager_can_route_application_claim_to_budget_approval_then_budg
and flag.get("source") == "application_handoff"
and flag.get("event_type") == "expense_application_to_reimbursement_draft"
and flag.get("application_claim_no") == "APP-20260525-APPROVE"
and flag.get("application_detail", {}).get("application_content") == "差旅费用申请 / 上海"
and flag.get("application_detail", {}).get("application_reason") == "支撑国网服务器上线部署"
and flag.get("application_detail", {}).get("application_content")
== "差旅费用申请 / 上海"
and flag.get("application_detail", {}).get("application_reason")
== "支撑国网服务器上线部署"
and flag.get("application_detail", {}).get("application_days") == "3 天"
and flag.get("application_detail", {}).get("application_transport_mode") == "高铁"
and flag.get("application_detail", {}).get("application_lodging_daily_cap") == "600元/天"
and flag.get("application_detail", {}).get("application_subsidy_daily_cap") == "120元/天"
and flag.get("application_detail", {}).get("application_transport_policy") == "按真实票据复核"
and flag.get("application_detail", {}).get("application_lodging_daily_cap")
== "600元/天"
and flag.get("application_detail", {}).get("application_subsidy_daily_cap")
== "120元/天"
and flag.get("application_detail", {}).get("application_transport_policy")
== "按真实票据复核"
and flag.get("application_detail", {}).get("application_policy_estimate")
== "交通按真实票据 + 住宿 1,800元 + 补贴 360元"
and flag.get("application_detail", {}).get("application_rule_name") == "差旅标准规则"
@@ -6214,6 +6248,7 @@ def test_application_routes_to_department_p8_executive_with_approver_name() -> N
{
"source": "submission_review",
"severity": "high",
"actionability": "route_review",
"label": "Route risk",
"message": "Application requires budget confirmation.",
}
@@ -6306,6 +6341,7 @@ def test_direct_manager_cannot_route_application_to_missing_budget_approver() ->
{
"source": "submission_review",
"severity": "high",
"actionability": "route_review",
"label": "Route risk",
"message": "Application requires budget confirmation.",
}
@@ -6328,7 +6364,9 @@ def test_direct_manager_cannot_route_application_to_missing_budget_approver() ->
assert reimbursement_claim_query(db).count() == 0
def test_direct_manager_p8_executive_completes_application_without_duplicate_budget_approval() -> None:
def test_direct_manager_p8_executive_completes_application_without_duplicate_budget_approval() -> (
None
):
manager_user = CurrentUserContext(
username="manager-executive-merged@example.com",
name="P8 Manager",
@@ -6381,6 +6419,7 @@ def test_direct_manager_p8_executive_completes_application_without_duplicate_bud
{
"source": "submission_review",
"severity": "high",
"actionability": "route_review",
"label": "Route risk",
"message": "Application requires budget confirmation.",
}
@@ -6411,12 +6450,15 @@ def test_direct_manager_p8_executive_completes_application_without_duplicate_bud
and flag.get("next_status") == "approved"
and flag.get("next_approval_stage") == APPLICATION_LINK_STATUS_STAGE
and flag.get("budget_approval_merged") is True
and flag.get("budget_approval_merged_reason") == "direct_manager_is_department_budget_approver"
and flag.get("budget_approval_merged_reason")
== "direct_manager_is_department_budget_approver"
for flag in approved.risk_flags_json
)
def test_direct_manager_budget_monitor_completes_application_claim_without_duplicate_budget_approval() -> None:
def test_direct_manager_budget_monitor_completes_application_claim_without_duplicate_budget_approval() -> (
None
):
manager_user = CurrentUserContext(
username="manager-budget-monitor-application@example.com",
name="李预算经理",
@@ -6469,6 +6511,7 @@ def test_direct_manager_budget_monitor_completes_application_claim_without_dupli
{
"source": "submission_review",
"severity": "high",
"actionability": "route_review",
"label": "申请风险复核",
"message": "申请金额和行程安排需要预算管理者二次确认。",
}
@@ -6489,8 +6532,7 @@ def test_direct_manager_budget_monitor_completes_application_claim_without_dupli
assert approved.approval_stage == "关联单据状态"
assert reimbursement_claim_query(db).count() == 1
assert not any(
isinstance(flag, dict)
and flag.get("next_approval_stage") == "预算管理者审批"
isinstance(flag, dict) and flag.get("next_approval_stage") == "预算管理者审批"
for flag in approved.risk_flags_json
)
assert any(
@@ -6503,7 +6545,8 @@ def test_direct_manager_budget_monitor_completes_application_claim_without_dupli
and flag.get("next_status") == "approved"
and flag.get("next_approval_stage") == "关联单据状态"
and flag.get("budget_approval_merged") is True
and flag.get("budget_approval_merged_reason") == "direct_manager_is_department_budget_approver"
and flag.get("budget_approval_merged_reason")
== "direct_manager_is_department_budget_approver"
for flag in approved.risk_flags_json
)
generated_draft = reimbursement_claim_query(db).one()
@@ -6692,6 +6735,7 @@ def test_application_approval_transfers_budget_reservation_to_reimbursement_draf
{
"source": "platform_risk",
"severity": "high",
"actionability": "route_review",
"label": "申请风险复核",
"message": "申请金额和行程安排需要预算管理者二次确认。",
}
@@ -6719,10 +6763,11 @@ def test_application_approval_transfers_budget_reservation_to_reimbursement_draf
assert reservation.source_type == "claim"
assert reservation.source_id == generated_draft.id
assert reservation.source_no == generated_draft.claim_no
assert any(item.transaction_type == "transfer" for item in db.query(BudgetTransaction).all())
assert any(
isinstance(flag, dict)
and flag.get("event_type") == "budget_reservation_transferred"
item.transaction_type == "transfer" for item in db.query(BudgetTransaction).all()
)
assert any(
isinstance(flag, dict) and flag.get("event_type") == "budget_reservation_transferred"
for flag in generated_draft.risk_flags_json
)
@@ -6916,7 +6961,12 @@ def test_finance_approve_reimbursement_consumes_budget_reservation() -> None:
db.refresh(reservation)
assert reservation.source_status == "consumed"
assert reservation.consumed_amount == Decimal("12000.00")
assert db.query(BudgetTransaction).filter(BudgetTransaction.transaction_type == "consume").count() == 1
assert (
db.query(BudgetTransaction)
.filter(BudgetTransaction.transaction_type == "consume")
.count()
== 1
)
assert any(
isinstance(flag, dict)
and flag.get("source") == "budget_control"
@@ -7290,7 +7340,10 @@ def test_return_claim_records_each_return_event_with_stage_reason_and_counts() -
assert return_events[0]["stage_return_count"] == 1
assert return_events[0]["return_stage"] == "直属领导审批"
assert return_events[0]["reason_codes"] == ["invoice_mismatch", "business_explanation"]
assert return_events[0]["risk_points"] == ["票据类型/金额与明细不一致", "业务事由/地点/人员信息不完整"]
assert return_events[0]["risk_points"] == [
"票据类型/金额与明细不一致",
"业务事由/地点/人员信息不完整",
]
assert return_events[0]["reason"] == "发票金额与明细金额不一致,请重新核对。"
assert return_events[0]["operator_role_codes"] == ["manager"]
assert return_events[1]["return_count"] == 2
@@ -7624,14 +7677,16 @@ def test_list_approval_claims_allows_budget_monitor_to_view_budget_stage_applica
email="budget-list-market@example.com",
organization_unit=market_department,
)
db.add_all([
delivery_department,
market_department,
budget_manager,
p8_without_budget_employee,
employee,
market_employee,
])
db.add_all(
[
delivery_department,
market_department,
budget_manager,
p8_without_budget_employee,
employee,
market_employee,
]
)
db.flush()
db.add_all(
[
@@ -7702,5 +7757,7 @@ def test_list_approval_claims_allows_budget_monitor_to_view_budget_stage_applica
assert getattr(claims[0], "budget_approver_name", "") == "赵预算"
assert getattr(claims[0], "budget_approver_grade", "") == "P8"
assert getattr(claims[0], "budget_approver_role_code", "") == "budget_monitor"
claims_without_budget_role = ExpenseClaimService(db).list_approval_claims(p8_without_budget_role)
claims_without_budget_role = ExpenseClaimService(db).list_approval_claims(
p8_without_budget_role
)
assert [claim.claim_no for claim in claims_without_budget_role] == []

View File

@@ -137,6 +137,14 @@ def test_known_revision_requires_and_accepts_its_exact_owned_table_set(
"20260716_0009",
MIGRATION_OWNED_TABLES_BY_REVISION["20260716_0009"] - {"memory_entries"},
),
(
"20260716_0010",
MIGRATION_OWNED_TABLES_BY_REVISION["20260716_0010"] - {"approval_action_ledgers"},
),
(
"20260716_0011",
MIGRATION_OWNED_TABLES_BY_REVISION["20260716_0011"] - {"risk_disposition_events"},
),
],
)
def test_known_revision_with_missing_or_unexpected_owned_tables_is_rejected(

View File

@@ -9,7 +9,7 @@ from decimal import Decimal
import pytest
from auth_helpers import install_legacy_header_auth_override
from fastapi.testclient import TestClient
from sqlalchemy import create_engine, select
from sqlalchemy import create_engine, func, select
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool
@@ -17,6 +17,7 @@ from app.api.deps import get_db
from app.db.base import Base
from app.main import create_app
from app.models.ai_learning import AIDecision, AIDecisionFeedback, WorkflowOutcome
from app.models.approval_action import ApprovalActionLedger
from app.models.budget import BudgetAllocation, BudgetReservation, BudgetTransaction
from app.models.employee import Employee
from app.models.expense_case import BusinessEvent, ExpenseCaseLink
@@ -213,12 +214,15 @@ def test_claim_submit_returns_structured_pre_review_conflict() -> None:
assert claim is not None
assert claim.status == "draft"
assert claim.submitted_at is None
assert db.scalar(
select(BusinessEvent).where(
BusinessEvent.aggregate_id == claim.id,
BusinessEvent.event_type == "claim_submitted",
assert (
db.scalar(
select(BusinessEvent).where(
BusinessEvent.aggregate_id == claim.id,
BusinessEvent.event_type == "claim_submitted",
)
)
) is None
is None
)
def test_claim_submit_returns_changed_conflict_when_dynamic_review_changes(
@@ -402,7 +406,9 @@ def test_claim_read_attaches_finance_approver_name_for_finance_stage() -> None:
db.commit()
headers = {"x-auth-username": "qianqi@example.com"}
response = client.get("/api/v1/reimbursements/claims/claim-finance-stage-reader", headers=headers)
response = client.get(
"/api/v1/reimbursements/claims/claim-finance-stage-reader", headers=headers
)
assert response.status_code == 200
payload = response.json()
assert payload["finance_owner_name"] == "Wang Finance Group"
@@ -518,7 +524,9 @@ def test_claim_item_attachment_upload_preview_and_delete(monkeypatch, tmp_path)
meta_payload = meta_response.json()
assert meta_payload["media_type"] == "image/png"
assert meta_payload["preview_kind"] == "image"
assert meta_payload["preview_url"].endswith(f"/reimbursements/claims/{claim_id}/items/{item_id}/attachment/preview")
assert meta_payload["preview_url"].endswith(
f"/reimbursements/claims/{claim_id}/items/{item_id}/attachment/preview"
)
assert meta_payload["analysis"]["headline"]
assert meta_payload["document_info"]["fields"][0]["label"] == "金额"
@@ -550,7 +558,9 @@ def test_claim_item_attachment_upload_preview_and_delete(monkeypatch, tmp_path)
assert deleted_meta_response.status_code == 404
def test_claim_item_attachment_upload_flags_purpose_and_amount_mismatch(monkeypatch, tmp_path) -> None:
def test_claim_item_attachment_upload_flags_purpose_and_amount_mismatch(
monkeypatch, tmp_path
) -> None:
def fake_recognize(
self,
files: list[tuple[str, bytes, str | None]],
@@ -596,7 +606,9 @@ def test_claim_item_attachment_upload_flags_purpose_and_amount_mismatch(monkeypa
assert upload_response.json()["attachment"]["requirement_check"]["matches"] is False
def test_claim_item_attachment_upload_flags_non_invoice_image_as_high_risk(monkeypatch, tmp_path) -> None:
def test_claim_item_attachment_upload_flags_non_invoice_image_as_high_risk(
monkeypatch, tmp_path
) -> None:
def fake_recognize(
self,
files: list[tuple[str, bytes, str | None]],
@@ -679,14 +691,32 @@ def test_approve_claim_endpoint_routes_direct_manager_claim_to_finance_review()
db.add_all([manager, employee, claim])
db.commit()
action_headers = {
"X-Auth-Username": "manager-approve-api@example.com",
"X-Auth-Name": "manager-approve-api@example.com",
"X-Auth-Role-Codes": "manager",
}
stale_response = client.post(
"/api/v1/reimbursements/claims/claim-approve-1/approve",
json={
"opinion": "情况属实,同意报销。",
"request_id": "approve-api-stale-1",
"expected_status": "draft",
"expected_approval_stage": "直属领导审批",
},
headers=action_headers,
)
assert stale_response.status_code == 409
response = client.post(
"/api/v1/reimbursements/claims/claim-approve-1/approve",
json={"opinion": "情况属实,同意报销。"},
headers={
"X-Auth-Username": "manager-approve-api@example.com",
"X-Auth-Name": "manager-approve-api@example.com",
"X-Auth-Role-Codes": "manager",
json={
"opinion": "情况属实,同意报销。",
"request_id": "approve-api-claim-1",
"expected_status": "submitted",
"expected_approval_stage": "直属领导审批",
},
headers=action_headers,
)
assert response.status_code == 200
@@ -701,13 +731,136 @@ def test_approve_claim_endpoint_routes_direct_manager_claim_to_finance_review()
for item in payload["risk_flags_json"]
)
approval_events = [
item
for item in payload["risk_flags_json"]
if item["source"] == "manual_approval"
item for item in payload["risk_flags_json"] if item["source"] == "manual_approval"
]
assert approval_events[0]["operator"] == "李经理"
assert "manager-approve-api@example.com" not in approval_events[0]["message"]
replay_response = client.post(
"/api/v1/reimbursements/claims/claim-approve-1/approve",
json={
"opinion": "情况属实,同意报销。",
"request_id": "approve-api-claim-1",
"expected_status": "submitted",
"expected_approval_stage": "直属领导审批",
},
headers=action_headers,
)
assert replay_response.status_code == 200
assert replay_response.json()["approval_stage"] == "财务审批"
changed_payload_response = client.post(
"/api/v1/reimbursements/claims/claim-approve-1/approve",
json={
"opinion": "改为有条件通过",
"request_id": "approve-api-claim-1",
"expected_status": "submitted",
"expected_approval_stage": "直属领导审批",
},
headers=action_headers,
)
assert changed_payload_response.status_code == 409
with session_factory() as db:
ledgers = list(db.scalars(select(ApprovalActionLedger)).all())
assert len(ledgers) == 1
assert ledgers[0].completed_at is not None
def test_approve_claim_endpoint_blocks_open_high_risk_with_machine_readable_detail() -> None:
client, session_factory = build_client()
with session_factory() as db:
manager = Employee(
id="manager-risk-block-api",
employee_no="M-RISK-BLOCK-API",
name="风险经理",
email="manager-risk-block-api@example.com",
)
employee = Employee(
id="employee-risk-block-api",
employee_no="E-RISK-BLOCK-API",
name="风险员工",
email="employee-risk-block-api@example.com",
manager=manager,
)
claim = ExpenseClaim(
id="claim-risk-block-api",
claim_no="EXP-RISK-BLOCK-API",
employee=employee,
employee_name=employee.name,
department_name="风控部",
expense_type="travel",
reason="客户拜访",
location="上海",
amount=Decimal("1200.00"),
currency="CNY",
invoice_count=1,
occurred_at=datetime(2026, 7, 16, tzinfo=UTC),
submitted_at=datetime(2026, 7, 16, tzinfo=UTC),
status="submitted",
approval_stage="直属领导审批",
risk_flags_json=[],
)
observation = RiskObservation(
id="risk-block-api-observation",
tenant_id="default",
observation_key="risk:claim-risk-block-api:duplicate",
subject_type="expense_claim",
subject_key="claim:claim-risk-block-api",
subject_label=claim.claim_no,
claim_id=claim.id,
claim_no=claim.claim_no,
risk_type="duplicate_invoice",
risk_signal="duplicate_invoice",
title="重复票据风险",
description="同一票据可能重复报销。",
risk_score=92,
risk_level="high",
confidence_score=0.95,
control_stage="reimbursement",
control_mode="risk_observation",
automation_mode="semi_auto_review",
source="financial_risk_graph",
algorithm_version="financial_risk_graph.v1",
status="pending_review",
feedback_status="unreviewed",
)
db.add_all([manager, employee, claim, observation])
db.commit()
manager_email = manager.email
response = client.post(
"/api/v1/reimbursements/claims/claim-risk-block-api/approve",
headers={
"X-Auth-Username": manager_email,
"X-Auth-Name": "Risk Manager",
"X-Auth-Role-Codes": "manager",
},
json={
"opinion": "同意",
"request_id": "approve-risk-block-api-001",
"expected_status": "submitted",
"expected_approval_stage": "直属领导审批",
},
)
assert response.status_code == 409
detail = response.json()["detail"]
assert detail["code"] == "APPROVAL_BLOCKED_BY_OPEN_HIGH_RISK"
assert detail["observations"] == [
{
"id": "risk-block-api-observation",
"title": "重复票据风险",
"risk_level": "high",
"adjudication": "unreviewed",
"lifecycle_status": "open",
}
]
with session_factory() as db:
persisted = db.get(ExpenseClaim, "claim-risk-block-api")
assert persisted is not None
assert persisted.approval_stage == "直属领导审批"
assert db.scalar(select(func.count()).select_from(ApprovalActionLedger)) == 0
def test_approve_application_endpoint_routes_direct_manager_review_to_budget_review() -> None:
client, session_factory = build_client()
@@ -766,10 +919,11 @@ def test_approve_application_endpoint_routes_direct_manager_review_to_budget_rev
status="submitted",
approval_stage="直属领导审批",
risk_flags_json=[
{
"source": "submission_review",
"severity": "high",
"label": "申请风险复核",
{
"source": "submission_review",
"severity": "high",
"actionability": "route_review",
"label": "申请风险复核",
"message": "申请金额和行程安排需要预算管理者二次确认。",
}
],
@@ -779,7 +933,12 @@ def test_approve_application_endpoint_routes_direct_manager_review_to_budget_rev
response = client.post(
"/api/v1/reimbursements/claims/claim-application-approve-1/approve",
json={"opinion": "业务必要,同意申请。"},
json={
"opinion": "业务必要,同意申请。",
"request_id": "approve-api-application-1",
"expected_status": "submitted",
"expected_approval_stage": "直属领导审批",
},
headers={
"X-Auth-Username": "manager-application-approve-api@example.com",
"X-Auth-Name": "manager-application-approve-api@example.com",
@@ -853,7 +1012,9 @@ def test_claim_item_pdf_attachment_preview_returns_generated_image(monkeypatch,
assert upload_response.status_code == 200
meta_payload = upload_response.json()["attachment"]
assert meta_payload["preview_kind"] == "image"
assert meta_payload["preview_url"].endswith(f"/reimbursements/claims/{claim_id}/items/{item_id}/attachment/preview")
assert meta_payload["preview_url"].endswith(
f"/reimbursements/claims/{claim_id}/items/{item_id}/attachment/preview"
)
meta_path = next(tmp_path.rglob("invoice.pdf.meta.json"))
stored_meta = json.loads(meta_path.read_text(encoding="utf-8"))
assert stored_meta["preview_rendered_with"] == DocumentPreviewAssets.PDF_RENDERER_ID
@@ -1015,7 +1176,9 @@ def test_claim_delete_allows_applicant_to_delete_own_draft(monkeypatch, tmp_path
assert db.get(ExpenseClaim, claim_id) is None
def test_claim_delete_allows_legacy_superadmin_without_is_admin_header(monkeypatch, tmp_path) -> None:
def test_claim_delete_allows_legacy_superadmin_without_is_admin_header(
monkeypatch, tmp_path
) -> None:
monkeypatch.setattr(ExpenseClaimAttachmentStorage, "root", lambda self: tmp_path)
client, session_factory = build_client()
@@ -1157,8 +1320,7 @@ def test_application_preview_action_submits_without_orchestrator_run(monkeypatch
assert outcome.outcome_type == "application_submitted"
assert outcome.business_event_id == event.id
assert any(
isinstance(flag, dict)
and flag.get("event_type") == "expense_application_submission"
isinstance(flag, dict) and flag.get("event_type") == "expense_application_submission"
for flag in list(claim.risk_flags_json or [])
)
@@ -1212,7 +1374,9 @@ def test_application_direct_submit_rolls_back_budget_when_case_event_fails(
assert list(db.scalars(select(WorkflowOutcome)).all()) == []
def test_application_preview_action_saves_draft_with_detail_reference(monkeypatch, tmp_path) -> None:
def test_application_preview_action_saves_draft_with_detail_reference(
monkeypatch, tmp_path
) -> None:
monkeypatch.setattr(ExpenseClaimAttachmentStorage, "root", lambda self: tmp_path)
client, session_factory = build_client()
@@ -1288,19 +1452,16 @@ def test_application_preview_action_saves_draft_with_detail_reference(monkeypatc
assert claim.approval_stage == "待提交"
assert claim.submitted_at is None
assert claim.employee_name == "张三"
assert db.scalar(
select(BudgetReservation).where(BudgetReservation.source_id == claim.id)
) is None
event = db.scalar(
select(BusinessEvent).where(BusinessEvent.aggregate_id == claim.id)
assert (
db.scalar(select(BudgetReservation).where(BudgetReservation.source_id == claim.id))
is None
)
event = db.scalar(select(BusinessEvent).where(BusinessEvent.aggregate_id == claim.id))
assert event is not None
assert event.event_type == "claim_draft_created"
assert event.payload_json["previous_status"] == ""
assert event.payload_json["next_status"] == "draft"
link = db.scalar(
select(ExpenseCaseLink).where(ExpenseCaseLink.resource_id == claim.id)
)
link = db.scalar(select(ExpenseCaseLink).where(ExpenseCaseLink.resource_id == claim.id))
assert link is not None
assert link.relation_type == "application"
@@ -1372,9 +1533,10 @@ def test_application_preview_action_rejects_forged_identity_when_editing_other_c
assert persisted.reason == "其他员工原申请"
assert persisted.status == "returned"
assert persisted.approval_stage == "退回补充"
assert db.scalar(
select(BusinessEvent).where(BusinessEvent.aggregate_id == persisted.id)
) is None
assert (
db.scalar(select(BusinessEvent).where(BusinessEvent.aggregate_id == persisted.id))
is None
)
def test_application_preview_action_reuses_created_draft_for_identical_retry() -> None:
@@ -1420,17 +1582,13 @@ def test_application_preview_action_reuses_created_draft_for_identical_retry() -
with session_factory() as db:
application_claims = list(
db.scalars(
select(ExpenseClaim).where(
ExpenseClaim.expense_type == "travel_application"
)
select(ExpenseClaim).where(ExpenseClaim.expense_type == "travel_application")
).all()
)
assert len(application_claims) == 1
events = list(
db.scalars(
select(BusinessEvent).where(
BusinessEvent.aggregate_id == application_claims[0].id
)
select(BusinessEvent).where(BusinessEvent.aggregate_id == application_claims[0].id)
).all()
)
assert len(events) == 1

View File

@@ -0,0 +1,661 @@
from __future__ import annotations
from collections.abc import Generator
from datetime import UTC, datetime, timedelta
from decimal import Decimal
import pytest
from auth_helpers import install_legacy_header_auth_override
from fastapi import FastAPI
from fastapi.testclient import TestClient
from sqlalchemy import create_engine, func, select
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool
from app.api.deps import CurrentUserContext, get_db
from app.api.v1.endpoints.risk_observations import router as risk_observations_router
from app.db.base import Base
from app.models.employee import Employee
from app.models.financial_record import ExpenseClaim
from app.models.risk_disposition import RiskDispositionEvent
from app.models.risk_observation import RiskObservationFeedback
from app.schemas.risk_disposition import RiskDispositionActionCreate
from app.services.risk_dispositions import (
RiskDispositionConflictError,
RiskDispositionIdempotencyConflictError,
RiskDispositionPermissionError,
RiskDispositionService,
RiskDispositionVersionConflictError,
)
from app.services.risk_observation_access_policy import RiskObservationAccessPolicy
from app.services.risk_observations import RiskObservationService
def test_risk_disposition_separates_adjudication_and_lifecycle_with_audit_events(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("FEW_SHOT_INJECTION_ENABLED", "false")
with _build_session() as db:
observation = RiskObservationService(db).upsert_observation(
_observation_payload("risk:typed:duplicate")
)
db.commit()
service = RiskDispositionService(db)
confirmed = service.execute_action(
observation.id,
_action("confirm", version=0, request_id="request-confirm-001"),
tenant_id="default",
actor_id="finance-1",
actor_name="财务甲",
)
assert confirmed.disposition.adjudication == "confirmed"
assert confirmed.disposition.lifecycle_status == "open"
assert confirmed.disposition.version == 1
assert confirmed.event.before_json["adjudication"] == "unreviewed"
assert confirmed.event.after_json["adjudication"] == "confirmed"
supplemented = service.execute_action(
observation.id,
RiskDispositionActionCreate(
action="request_supplement",
expected_version=1,
request_id="request-supplement-001",
assignee="员工甲",
due_at=datetime.now(UTC) + timedelta(days=2),
comment="请补齐行程单",
),
tenant_id="default",
actor_id="finance-1",
actor_name="财务甲",
)
assert supplemented.disposition.adjudication == "confirmed"
assert supplemented.disposition.lifecycle_status == "supplement_requested"
assert supplemented.disposition.assignee == "员工甲"
assert supplemented.disposition.version == 2
resolved = service.execute_action(
observation.id,
RiskDispositionActionCreate(
action="resolve",
expected_version=2,
request_id="request-resolve-001",
resolution="补充材料已核验,风险关闭。",
),
tenant_id="default",
actor_id="finance-1",
actor_name="财务甲",
)
assert resolved.disposition.adjudication == "confirmed"
assert resolved.disposition.lifecycle_status == "resolved"
assert resolved.disposition.resolution == "补充材料已核验,风险关闭。"
assert resolved.disposition.version == 3
assert db.scalar(select(func.count()).select_from(RiskDispositionEvent)) == 3
assert db.scalar(select(func.count()).select_from(RiskObservationFeedback)) == 1
with pytest.raises(RiskDispositionConflictError, match="已解决"):
service.execute_action(
observation.id,
_action(
"start_remediation",
version=3,
request_id="request-remediation-after-resolve",
),
tenant_id="default",
actor_id="finance-1",
actor_name="财务甲",
)
with pytest.raises(RiskDispositionConflictError, match="已解决"):
service.execute_action(
observation.id,
_action(
"false_positive",
version=3,
request_id="request-readjudicate-after-resolve",
),
tenant_id="default",
actor_id="finance-1",
actor_name="财务甲",
)
def test_risk_disposition_requires_confirmation_before_remediation_or_resolution(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("FEW_SHOT_INJECTION_ENABLED", "false")
with _build_session() as db:
observation = RiskObservationService(db).upsert_observation(
_observation_payload("risk:typed:transition-guard")
)
db.commit()
service = RiskDispositionService(db)
with pytest.raises(RiskDispositionConflictError, match="必须先确认成立"):
service.execute_action(
observation.id,
RiskDispositionActionCreate(
action="resolve",
expected_version=0,
request_id="request-resolve-unreviewed",
resolution="不能跳过裁决直接关闭。",
),
tenant_id="default",
actor_id="finance-1",
actor_name="财务甲",
)
supplemented = service.execute_action(
observation.id,
RiskDispositionActionCreate(
action="request_supplement",
expected_version=0,
request_id="request-supplement-unreviewed",
comment="先补充材料再裁决。",
),
tenant_id="default",
actor_id="finance-1",
actor_name="财务甲",
)
assert supplemented.disposition.adjudication == "unreviewed"
assert supplemented.disposition.lifecycle_status == "supplement_requested"
def test_risk_disposition_imports_legacy_confirmation_before_first_typed_action(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("FEW_SHOT_INJECTION_ENABLED", "false")
with _build_session() as db:
observation = RiskObservationService(db).upsert_observation(
_observation_payload("risk:typed:legacy-confirmed")
)
observation.status = "confirmed"
observation.feedback_status = "confirmed"
db.commit()
resolved = RiskDispositionService(db).execute_action(
observation.id,
RiskDispositionActionCreate(
action="resolve",
expected_version=0,
request_id="request-resolve-legacy-confirmed",
resolution="历史确认风险已完成整改。",
),
tenant_id="default",
actor_id="finance-1",
actor_name="财务甲",
)
assert resolved.event.before_json["adjudication"] == "confirmed"
assert resolved.disposition.adjudication == "confirmed"
assert resolved.disposition.lifecycle_status == "resolved"
def test_risk_disposition_idempotency_and_optimistic_version_are_enforced(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("FEW_SHOT_INJECTION_ENABLED", "false")
with _build_session() as db:
observation = RiskObservationService(db).upsert_observation(
_observation_payload("risk:typed:idempotency")
)
second_observation = RiskObservationService(db).upsert_observation(
{
**_observation_payload("risk:typed:idempotency:second"),
"claim_id": None,
"claim_no": "",
"subject_key": "standalone:second",
}
)
db.commit()
service = RiskDispositionService(db)
payload = _action("confirm", version=0, request_id="request-idempotent-001")
first = service.execute_action(
observation.id,
payload,
tenant_id="default",
actor_id="finance-1",
actor_name="财务甲",
)
replay = service.execute_action(
observation.id,
payload,
tenant_id="default",
actor_id="finance-1",
actor_name="财务甲",
)
assert replay.replayed is True
assert replay.event.id == first.event.id
assert db.scalar(select(func.count()).select_from(RiskDispositionEvent)) == 1
with pytest.raises(RiskDispositionIdempotencyConflictError):
service.execute_action(
observation.id,
_action(
"false_positive",
version=0,
request_id="request-idempotent-001",
),
tenant_id="default",
actor_id="finance-1",
actor_name="财务甲",
)
with pytest.raises(RiskDispositionIdempotencyConflictError):
service.execute_action(
second_observation.id,
payload,
tenant_id="default",
actor_id="finance-1",
actor_name="财务甲",
)
with pytest.raises(RiskDispositionIdempotencyConflictError):
service.execute_action(
observation.id,
payload,
tenant_id="default",
actor_id="finance-2",
actor_name="财务乙",
)
with pytest.raises(RiskDispositionVersionConflictError) as error:
service.execute_action(
observation.id,
_action("request_waiver", version=0, request_id="request-stale-001"),
tenant_id="default",
actor_id="finance-1",
actor_name="财务甲",
)
assert error.value.current_version == 1
def test_risk_observation_api_enforces_pool_claim_and_typed_action_permissions(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("FEW_SHOT_INJECTION_ENABLED", "false")
client, session_factory = _build_client()
with session_factory() as db:
db.add(_employee())
db.add(_claim())
db.flush()
observation = RiskObservationService(db).upsert_observation(
_observation_payload("risk:api:duplicate")
)
standalone_observation = RiskObservationService(db).upsert_observation(
{
**_observation_payload("risk:api:standalone"),
"claim_id": None,
"claim_no": "",
"subject_key": "default:standalone",
}
)
foreign_observation = RiskObservationService(db).upsert_observation(
{
**_observation_payload("risk:api:foreign"),
"claim_id": None,
"claim_no": "",
"subject_key": "tenant-b:standalone",
},
tenant_id="tenant-b",
)
observation_id = observation.id
standalone_observation_id = standalone_observation.id
foreign_observation_id = foreign_observation.id
db.commit()
employee_headers = {
"X-Auth-Username": "risk.employee@example.com",
"X-Auth-Name": "Risk Employee",
"X-Auth-Employee-No": "E-RISK",
}
finance_headers = {
"X-Auth-Username": "finance@example.com",
"X-Auth-Name": "Finance Reviewer",
"X-Auth-Role-Codes": "finance",
}
assert client.get("/api/v1/risk-observations", headers=employee_headers).status_code == 403
assert (
client.get(
f"/api/v1/risk-observations/{observation_id}",
headers=employee_headers,
).status_code
== 403
)
assert (
client.get(
"/api/v1/risk-observations/claim/claim-risk-1",
headers=employee_headers,
).status_code
== 404
)
assert (
client.post(
f"/api/v1/risk-observations/{observation_id}/feedback",
headers=employee_headers,
json={"feedback_type": "confirm"},
).status_code
== 403
)
first = client.post(
f"/api/v1/risk-observations/{observation_id}/disposition/actions",
headers=finance_headers,
json={
"action": "confirm",
"expected_version": 0,
"request_id": "api-confirm-request-001",
"comment": "人工复核确认",
},
)
replay = client.post(
f"/api/v1/risk-observations/{observation_id}/disposition/actions",
headers=finance_headers,
json={
"action": "confirm",
"expected_version": 0,
"request_id": "api-confirm-request-001",
"comment": "人工复核确认",
},
)
stale = client.post(
f"/api/v1/risk-observations/{observation_id}/disposition/actions",
headers=finance_headers,
json={
"action": "request_waiver",
"expected_version": 0,
"request_id": "api-stale-request-001",
"comment": "申请风险豁免复核",
},
)
changed_replay = client.post(
f"/api/v1/risk-observations/{observation_id}/disposition/actions",
headers=finance_headers,
json={
"action": "false_positive",
"expected_version": 0,
"request_id": "api-confirm-request-001",
"comment": "经核验属于误报",
},
)
unsafe_legacy = client.post(
f"/api/v1/risk-observations/{observation_id}/feedback",
headers=finance_headers,
json={"feedback_type": "comment", "payload_json": {"arbitrary": True}},
)
foreign_action = client.post(
f"/api/v1/risk-observations/{foreign_observation_id}/disposition/actions",
headers=finance_headers,
json={
"action": "confirm",
"expected_version": 0,
"request_id": "api-cross-tenant-001",
},
)
standalone_action = client.post(
f"/api/v1/risk-observations/{standalone_observation_id}/disposition/actions",
headers=finance_headers,
json={
"action": "confirm",
"expected_version": 0,
"request_id": "api-standalone-finance-001",
},
)
assert first.status_code == 200
assert first.json()["disposition"]["adjudication"] == "confirmed"
assert first.json()["disposition"]["lifecycle_status"] == "open"
assert replay.status_code == 200
assert replay.json()["replayed"] is True
assert replay.json()["event"]["id"] == first.json()["event"]["id"]
assert stale.status_code == 409
assert stale.json()["detail"]["code"] == "RISK_DISPOSITION_VERSION_CONFLICT"
assert stale.json()["detail"]["message"] == "风险处置状态已更新,请刷新证据链后重试。"
assert changed_replay.status_code == 409
assert unsafe_legacy.status_code == 410
assert foreign_action.status_code == 404
assert standalone_action.status_code == 403
detail = client.get(
f"/api/v1/risk-observations/{observation_id}",
headers=finance_headers,
)
assert detail.status_code == 200
assert detail.json()["disposition"]["version"] == 1
assert len(detail.json()["disposition"]["events"]) == 1
def test_current_claim_approver_can_manage_disposition_without_pool_access() -> None:
with _build_session() as db:
manager = Employee(
id="manager-risk",
employee_no="M-RISK",
name="风险主管",
email="risk.manager@example.com",
position="部门经理",
grade="P8",
)
employee = _employee()
employee.manager_id = manager.id
claim = _claim()
claim.approval_stage = "直属领导审批"
db.add_all([manager, employee, claim])
db.flush()
observation = RiskObservationService(db).upsert_observation(
_observation_payload("risk:approver:duplicate")
)
db.commit()
current_user = CurrentUserContext(
username="risk.manager@example.com",
name="风险主管",
role_codes=["approver"],
is_admin=False,
employee_no="M-RISK",
)
policy = RiskObservationAccessPolicy(db)
assert policy.can_read_tenant_pool(current_user) is False
assert policy.can_read_claim_risks(claim.id, current_user) is True
assert policy.can_manage_disposition(observation, current_user) is True
unrelated_manager = CurrentUserContext(
username="unrelated.manager@example.com",
name="其他经理",
role_codes=["manager"],
is_admin=False,
)
finance_outside_stage = CurrentUserContext(
username="finance@example.com",
name="财务甲",
role_codes=["finance"],
is_admin=False,
)
assert policy.can_read_tenant_pool(unrelated_manager) is False
assert policy.can_read_claim_risks(claim.id, unrelated_manager) is False
assert policy.can_manage_disposition(observation, unrelated_manager) is False
assert policy.can_manage_disposition(observation, finance_outside_stage) is False
def test_disposition_rechecks_current_approver_after_claim_stage_changes() -> None:
with _build_session() as db:
manager = Employee(
id="manager-risk-stage-change",
employee_no="M-RISK-STAGE",
name="原审批主管",
email="risk.stage.manager@example.com",
)
employee = _employee()
employee.id = "emp-risk-stage-change"
employee.employee_no = "E-RISK-STAGE"
employee.email = "risk.stage.employee@example.com"
employee.manager_id = manager.id
claim = _claim()
claim.id = "claim-risk-stage-change"
claim.claim_no = "BX-RISK-STAGE"
claim.employee_id = employee.id
claim.approval_stage = "直属领导审批"
db.add_all([manager, employee, claim])
db.flush()
observation = RiskObservationService(db).upsert_observation(
{
**_observation_payload("risk:approver:stage-change"),
"claim_id": claim.id,
"claim_no": claim.claim_no,
"subject_key": f"claim:{claim.id}",
}
)
db.commit()
current_user = CurrentUserContext(
username=manager.email,
name=manager.name,
role_codes=["manager"],
is_admin=False,
employee_no=manager.employee_no,
)
claim.approval_stage = "财务审批"
db.commit()
with pytest.raises(RiskDispositionPermissionError, match="不再是"):
RiskDispositionService(db).execute_action(
observation.id,
_action("confirm", version=0, request_id="request-stage-changed-001"),
tenant_id="default",
actor_id=manager.id,
actor_name=manager.name,
current_user=current_user,
)
assert db.scalar(select(func.count()).select_from(RiskDispositionEvent)) == 0
@pytest.mark.parametrize(
"action",
["false_positive", "request_supplement", "request_waiver"],
)
def test_evidence_sensitive_actions_require_server_side_comment(action: str) -> None:
with pytest.raises(ValueError, match="必须填写 comment"):
RiskDispositionActionCreate(
action=action,
expected_version=0,
request_id=f"request-comment-{action}",
)
def _action(
action: str,
*,
version: int,
request_id: str,
) -> RiskDispositionActionCreate:
return RiskDispositionActionCreate(
action=action,
expected_version=version,
request_id=request_id,
comment=(
"风险处置说明"
if action in {"false_positive", "request_supplement", "request_waiver"}
else None
),
)
def _build_session() -> Session:
engine = create_engine(
"sqlite+pysqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(bind=engine)
factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
return factory()
def _build_client() -> tuple[TestClient, sessionmaker[Session]]:
engine = create_engine(
"sqlite+pysqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(bind=engine)
factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
app = FastAPI()
app.include_router(risk_observations_router, prefix="/api/v1")
install_legacy_header_auth_override(app)
def override_db() -> Generator[Session, None, None]:
db = factory()
try:
yield db
finally:
db.close()
app.dependency_overrides[get_db] = override_db
return TestClient(app), factory
def _employee() -> Employee:
return Employee(
id="emp-risk",
employee_no="E-RISK",
name="风险员工",
email="risk.employee@example.com",
position="高级专员",
grade="P6",
)
def _claim() -> ExpenseClaim:
now = datetime(2026, 7, 16, tzinfo=UTC)
return ExpenseClaim(
id="claim-risk-1",
claim_no="BX-RISK-001",
employee_id="emp-risk",
employee_name="风险员工",
department_id="dept-risk",
department_name="风控部",
expense_type="travel",
reason="客户拜访",
location="上海",
amount=Decimal("1200"),
currency="CNY",
invoice_count=1,
occurred_at=now,
submitted_at=now,
status="submitted",
approval_stage="财务审批",
risk_flags_json=[],
)
def _observation_payload(observation_key: str) -> dict[str, object]:
return {
"observation_key": observation_key,
"subject_type": "expense_claim",
"subject_key": "claim:claim-risk-1",
"subject_label": "BX-RISK-001",
"claim_id": "claim-risk-1",
"claim_no": "BX-RISK-001",
"risk_type": "duplicate_invoice",
"risk_signal": "duplicate_invoice",
"title": "重复票据风险",
"description": "同一票据可能重复报销。",
"risk_score": 86,
"risk_level": "high",
"confidence_score": 0.91,
"control_stage": "reimbursement",
"control_mode": "risk_observation",
"automation_mode": "semi_auto_review",
"source": "financial_risk_graph",
"algorithm_version": "financial_risk_graph.v1",
"contribution_scores": {},
"baseline": {},
"evidence": [],
"graph_node_keys": [],
"graph_edge_keys": [],
"policy_refs": [],
"similar_case_claim_ids": [],
"ontology_json": {},
"decision_trace": {},
}

View File

@@ -1,7 +1,7 @@
from __future__ import annotations
from collections.abc import Generator
from datetime import UTC, datetime
from datetime import UTC, datetime, timedelta
from decimal import Decimal
from types import SimpleNamespace
@@ -22,6 +22,7 @@ from app.models.expense_case import ExpenseCase, ExpenseCaseLink
from app.models.financial_record import ExpenseClaim
from app.models.risk_observation import RiskObservation
from app.schemas.risk_observation import RiskObservationFeedbackCreate
from app.services.expense_claims import ExpenseClaimService
from app.services.hermes_risk_scanner import HermesRiskScannerService
from app.services.risk_observations import RiskObservationService
@@ -129,6 +130,47 @@ def test_platform_rule_flags_are_persisted_as_risk_observations() -> None:
assert persisted.contribution_scores_json == {"S_rule": 100}
def test_high_platform_risk_persistence_failure_is_fail_closed(
monkeypatch: pytest.MonkeyPatch,
) -> None:
with _build_session() as db:
claim = _claim_orm("c-platform-fail-closed", "BX-PLATFORM-FAIL-CLOSED")
db.add(claim)
db.flush()
service = ExpenseClaimService(db)
monkeypatch.setattr(
service,
"evaluate_platform_risk_rules",
lambda _claim, **_kwargs: {
"flags": [
{
"source": "platform_risk",
"hit_source": "rule_center",
"rule_type": "risk",
"rule_code": "risk.invoice.blocking",
"severity": "high",
"action": "block",
"label": "高风险票据",
"message": "票据需要人工核验。",
}
],
"rule_set_fingerprint": "rules-v1",
},
)
def fail_persistence(*_args, **_kwargs):
raise RuntimeError("database unavailable")
monkeypatch.setattr(
RiskObservationService,
"upsert_platform_risk_flags",
fail_persistence,
)
with pytest.raises(RuntimeError, match="高风险观察持久化失败"):
service._run_ai_submission_review(claim)
def test_risk_observation_storage_ready_is_cached_per_bind(monkeypatch: pytest.MonkeyPatch) -> None:
with _build_session() as db:
RiskObservationService._storage_ready_cache.clear()
@@ -189,9 +231,9 @@ def test_risk_observation_endpoints_return_list_detail_dashboard_and_feedback()
assert updated_detail_response.json()["feedback_items"][0]["feedback_type"] == "false_positive"
with session_factory() as db:
observation = db.query(RiskObservation).filter_by(
observation_key="risk:c1:duplicate_invoice"
).one()
observation = (
db.query(RiskObservation).filter_by(observation_key="risk:c1:duplicate_invoice").one()
)
assert observation.status == "false_positive"
assert observation.feedback_status == "false_positive"
@@ -223,11 +265,15 @@ def test_risk_observation_endpoints_enforce_tenant_scope_and_authenticated_actor
tenant_a_headers = {
"X-Auth-Username": "auditor-a",
"X-Auth-Name": "Tenant A Auditor",
"X-Auth-Role-Codes": "finance",
"X-Auth-Is-Admin": "true",
"X-Auth-Tenant-Id": "tenant-a",
}
tenant_b_headers = {
"X-Auth-Username": "auditor-b",
"X-Auth-Name": "Tenant B Auditor",
"X-Auth-Role-Codes": "finance",
"X-Auth-Is-Admin": "true",
"X-Auth-Tenant-Id": "tenant-b",
}
@@ -269,8 +315,8 @@ def test_risk_observation_endpoints_enforce_tenant_scope_and_authenticated_actor
assert detail_response.status_code == 200
assert detail_response.json()["tenant_id"] == "tenant-a"
assert foreign_detail_response.status_code == 404
assert claim_response.status_code == 200
assert [item["tenant_id"] for item in claim_response.json()] == ["tenant-a"]
# 单据风险入口必须先通过单据自身可见范围;不存在的历史 claim 不再旁路读取。
assert claim_response.status_code == 404
assert execution_log_response.status_code == 200
assert [item["tenant_id"] for item in execution_log_response.json()] == ["tenant-a"]
assert dashboard_response.status_code == 200
@@ -359,9 +405,12 @@ def test_risk_observation_rejects_explicit_tenant_mismatching_claim_link() -> No
tenant_id="tenant-b",
)
assert db.query(RiskObservation).filter_by(
observation_key="risk:tenant-boundary"
).one_or_none() is None
assert (
db.query(RiskObservation)
.filter_by(observation_key="risk:tenant-boundary")
.one_or_none()
is None
)
def test_hermes_global_scan_builds_graphs_inside_each_tenant(
@@ -427,6 +476,37 @@ def test_hermes_global_scan_builds_graphs_inside_each_tenant(
assert summary["scanned_claim_count"] == 2
def test_risk_scan_discards_snapshot_after_claim_changes_during_evaluation(
monkeypatch: pytest.MonkeyPatch,
) -> None:
with _build_session() as db:
claim = _claim_orm("claim-scan-stale", "BX-SCAN-STALE")
db.add(claim)
db.commit()
original_updated_at = claim.updated_at
def fake_evaluate(_context):
claim.status = "pending_payment"
claim.approval_stage = "待付款"
claim.updated_at = original_updated_at + timedelta(seconds=1)
db.flush()
return SimpleNamespace(observations=[], nodes=[], edges=[])
scanner = HermesRiskScannerService(db)
monkeypatch.setattr(scanner, "_fetch_unscanned_claims", lambda: [claim])
monkeypatch.setattr(
"app.services.hermes_risk_scanner.evaluate_financial_risk_graph",
fake_evaluate,
)
summary = scanner.scan_global_risks()
db.refresh(claim)
assert summary["scanned_claim_count"] == 0
assert claim.status == "pending_payment"
assert claim.hermes_scanned_at is None
def test_risk_observation_feedback_pool_fields_and_replay_set_contract() -> None:
with _build_session() as db:
service = RiskObservationService(db)

View File

@@ -19,6 +19,7 @@ def test_create_legacy_schema_never_creates_migration_owned_tables() -> None:
"ai_application_preview_decisions",
"ai_decision_feedback",
"ai_decisions",
"approval_action_ledgers",
"auth_sessions",
"attachment_association_jobs",
"business_events",
@@ -28,6 +29,8 @@ def test_create_legacy_schema_never_creates_migration_owned_tables() -> None:
"memory_evidence_links",
"risk_observations",
"risk_observation_feedback",
"risk_disposition_events",
"risk_dispositions",
"few_shot_samples",
"workflow_outcomes",
}