feat(approval): add safe risk disposition workflow
This commit is contained in:
@@ -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")
|
||||
233
server/alembic/versions/20260716_0011_risk_disposition.py
Normal file
233
server/alembic/versions/20260716_0011_risk_disposition.py
Normal 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",
|
||||
)
|
||||
31
server/src/app/api/v1/endpoints/approval_workbench.py
Normal file
31
server/src/app/api/v1/endpoints/approval_workbench.py
Normal file
@@ -0,0 +1,31 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import CurrentUserContext, get_current_user, get_db
|
||||
from app.schemas.approval_workbench import ApprovalWorkbenchListRead
|
||||
from app.services.approval_workbench import ApprovalWorkbenchService
|
||||
|
||||
router = APIRouter(prefix="/approval-workbench")
|
||||
DbSession = Annotated[Session, Depends(get_db)]
|
||||
CurrentUser = Annotated[CurrentUserContext, Depends(get_current_user)]
|
||||
|
||||
|
||||
@router.get(
|
||||
"/items",
|
||||
response_model=ApprovalWorkbenchListRead,
|
||||
summary="查询当前用户的例外审批优先队列",
|
||||
description=(
|
||||
"复用现有审批权限范围,按风险、预算、金额、等待时长和材料完整度排序;"
|
||||
"AI 建议仅供人工复核,不执行自动审批。"
|
||||
),
|
||||
)
|
||||
def list_approval_workbench_items(
|
||||
db: DbSession,
|
||||
current_user: CurrentUser,
|
||||
limit: Annotated[int, Query(ge=1, le=200)] = 100,
|
||||
) -> ApprovalWorkbenchListRead:
|
||||
return ApprovalWorkbenchService(db).list_items(current_user, limit=limit)
|
||||
@@ -0,0 +1,163 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated, NoReturn
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import CurrentUserContext, get_current_user, get_db
|
||||
from app.schemas.common import ErrorResponse
|
||||
from app.schemas.reimbursement import (
|
||||
ExpenseClaimApprovalPayload,
|
||||
ExpenseClaimPaymentPayload,
|
||||
ExpenseClaimRead,
|
||||
ExpenseClaimReturnPayload,
|
||||
)
|
||||
from app.services.approval_action_protocol import ApprovalActionConflictError
|
||||
from app.services.expense_claim_risk_gate import ExpenseClaimRiskBlockedError
|
||||
from app.services.expense_claims import ExpenseClaimService
|
||||
|
||||
router = APIRouter()
|
||||
DbSession = Annotated[Session, Depends(get_db)]
|
||||
CurrentUser = Annotated[CurrentUserContext, Depends(get_current_user)]
|
||||
|
||||
|
||||
def _raise_action_error(error: ValueError) -> NoReturn:
|
||||
if isinstance(error, ExpenseClaimRiskBlockedError):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail={
|
||||
"code": "APPROVAL_BLOCKED_BY_OPEN_HIGH_RISK",
|
||||
"message": str(error),
|
||||
"observations": [
|
||||
{
|
||||
"id": item.observation_id,
|
||||
"title": item.title,
|
||||
"risk_level": item.risk_level,
|
||||
"adjudication": item.adjudication,
|
||||
"lifecycle_status": item.lifecycle_status,
|
||||
}
|
||||
for item in error.blockers
|
||||
],
|
||||
},
|
||||
) from error
|
||||
if isinstance(error, ApprovalActionConflictError):
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error)) from error
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error
|
||||
|
||||
|
||||
@router.post(
|
||||
"/claims/{claim_id}/return",
|
||||
response_model=ExpenseClaimRead,
|
||||
summary="退回报销单",
|
||||
description="按请求幂等键和预期状态安全退回当前节点的单据。",
|
||||
responses={
|
||||
status.HTTP_404_NOT_FOUND: {"model": ErrorResponse, "description": "报销单不存在。"},
|
||||
status.HTTP_400_BAD_REQUEST: {
|
||||
"model": ErrorResponse,
|
||||
"description": "当前用户或单据状态不允许退回。",
|
||||
},
|
||||
status.HTTP_409_CONFLICT: {
|
||||
"model": ErrorResponse,
|
||||
"description": "请求键冲突或单据状态/审批节点已经变化。",
|
||||
},
|
||||
},
|
||||
)
|
||||
def return_expense_claim(
|
||||
claim_id: str,
|
||||
payload: ExpenseClaimReturnPayload,
|
||||
db: DbSession,
|
||||
current_user: CurrentUser,
|
||||
) -> ExpenseClaimRead:
|
||||
try:
|
||||
claim = ExpenseClaimService(db).return_claim(
|
||||
claim_id,
|
||||
current_user,
|
||||
reason=payload.reason,
|
||||
reason_codes=payload.reason_codes,
|
||||
request_id=payload.request_id,
|
||||
expected_status=payload.expected_status,
|
||||
expected_approval_stage=payload.expected_approval_stage,
|
||||
)
|
||||
except ValueError as error:
|
||||
_raise_action_error(error)
|
||||
if claim is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Claim not found")
|
||||
return claim
|
||||
|
||||
|
||||
@router.post(
|
||||
"/claims/{claim_id}/approve",
|
||||
response_model=ExpenseClaimRead,
|
||||
summary="审批通过单据",
|
||||
description="按请求幂等键和预期状态安全完成当前审批节点。",
|
||||
responses={
|
||||
status.HTTP_404_NOT_FOUND: {"model": ErrorResponse, "description": "单据不存在。"},
|
||||
status.HTTP_400_BAD_REQUEST: {
|
||||
"model": ErrorResponse,
|
||||
"description": "当前用户或单据状态不允许审批通过。",
|
||||
},
|
||||
status.HTTP_409_CONFLICT: {
|
||||
"model": ErrorResponse,
|
||||
"description": "请求键冲突或单据状态/审批节点已经变化。",
|
||||
},
|
||||
},
|
||||
)
|
||||
def approve_expense_claim(
|
||||
claim_id: str,
|
||||
payload: ExpenseClaimApprovalPayload,
|
||||
db: DbSession,
|
||||
current_user: CurrentUser,
|
||||
) -> ExpenseClaimRead:
|
||||
try:
|
||||
claim = ExpenseClaimService(db).approve_claim(
|
||||
claim_id,
|
||||
current_user,
|
||||
opinion=payload.opinion,
|
||||
request_id=payload.request_id,
|
||||
expected_status=payload.expected_status,
|
||||
expected_approval_stage=payload.expected_approval_stage,
|
||||
)
|
||||
except ValueError as error:
|
||||
_raise_action_error(error)
|
||||
if claim is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Claim not found")
|
||||
return claim
|
||||
|
||||
|
||||
@router.post(
|
||||
"/claims/{claim_id}/pay",
|
||||
response_model=ExpenseClaimRead,
|
||||
summary="确认报销单已付款",
|
||||
description="按请求幂等键和预期状态安全确认待付款报销单已付款。",
|
||||
responses={
|
||||
status.HTTP_404_NOT_FOUND: {"model": ErrorResponse, "description": "单据不存在。"},
|
||||
status.HTTP_400_BAD_REQUEST: {
|
||||
"model": ErrorResponse,
|
||||
"description": "当前用户或单据状态不允许确认付款。",
|
||||
},
|
||||
status.HTTP_409_CONFLICT: {
|
||||
"model": ErrorResponse,
|
||||
"description": "请求键冲突或单据状态/审批节点已经变化。",
|
||||
},
|
||||
},
|
||||
)
|
||||
def pay_expense_claim(
|
||||
claim_id: str,
|
||||
payload: ExpenseClaimPaymentPayload,
|
||||
db: DbSession,
|
||||
current_user: CurrentUser,
|
||||
) -> ExpenseClaimRead:
|
||||
try:
|
||||
claim = ExpenseClaimService(db).mark_claim_paid(
|
||||
claim_id,
|
||||
current_user,
|
||||
request_id=payload.request_id,
|
||||
expected_status=payload.expected_status,
|
||||
expected_approval_stage=payload.expected_approval_stage,
|
||||
)
|
||||
except ValueError as error:
|
||||
_raise_action_error(error)
|
||||
if claim is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Claim not found")
|
||||
return claim
|
||||
@@ -12,14 +12,12 @@ from app.schemas.budget import BudgetClaimAnalysisRead
|
||||
from app.schemas.common import ErrorResponse, PaginatedResponse
|
||||
from app.schemas.reimbursement import (
|
||||
ExpenseClaimActionResponse,
|
||||
ExpenseClaimApprovalPayload,
|
||||
ExpenseClaimAttachmentActionResponse,
|
||||
ExpenseClaimAttachmentRead,
|
||||
ExpenseClaimItemActionResponse,
|
||||
ExpenseClaimItemCreate,
|
||||
ExpenseClaimItemUpdate,
|
||||
ExpenseClaimRead,
|
||||
ExpenseClaimReturnPayload,
|
||||
ExpenseClaimStandardAdjustmentPayload,
|
||||
ExpenseClaimSubmitPayload,
|
||||
ExpenseClaimUpdate,
|
||||
@@ -33,6 +31,7 @@ from app.services.expense_claims import ExpenseClaimService
|
||||
from app.services.reimbursement import ReimbursementService
|
||||
from app.services.travel_reimbursement_calculator import TravelReimbursementCalculatorService
|
||||
|
||||
from .reimbursement_approval_actions import router as approval_action_router
|
||||
from .reimbursement_pre_review import (
|
||||
RequestIdHeader,
|
||||
expense_claim_deletion_response,
|
||||
@@ -41,6 +40,7 @@ from .reimbursement_pre_review import (
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
router.include_router(approval_action_router)
|
||||
DbSession = Annotated[Session, Depends(get_db)]
|
||||
CurrentUser = Annotated[CurrentUserContext, Depends(get_current_user)]
|
||||
|
||||
@@ -198,10 +198,16 @@ def get_expense_claim_budget_analysis(
|
||||
claim = service.get_claim(claim_id, current_user)
|
||||
if claim is None:
|
||||
if not service.can_view_budget_analysis(current_user):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="只有当前审核人、该部门预算监控员或高级财务人员可以查看预算分析。")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="只有当前审核人、该部门预算监控员或高级财务人员可以查看预算分析。",
|
||||
)
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Claim not found")
|
||||
if not service.can_view_budget_analysis(current_user, claim):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="只有当前审核人、该部门预算监控员或高级财务人员可以查看预算分析。")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="只有当前审核人、该部门预算监控员或高级财务人员可以查看预算分析。",
|
||||
)
|
||||
return BudgetService(db).analyze_claim_budget(claim)
|
||||
|
||||
|
||||
@@ -418,7 +424,9 @@ async def upload_expense_claim_item_attachment(
|
||||
file: Annotated[UploadFile, File(description="待上传的附件文件。")],
|
||||
db: DbSession,
|
||||
current_user: CurrentUser,
|
||||
receipt_id: Annotated[str | None, Form(description="可选,来源于票据夹的持久化票据 ID。")] = None,
|
||||
receipt_id: Annotated[
|
||||
str | None, Form(description="可选,来源于票据夹的持久化票据 ID。")
|
||||
] = None,
|
||||
) -> ExpenseClaimAttachmentActionResponse:
|
||||
service = ExpenseClaimService(db)
|
||||
try:
|
||||
@@ -651,104 +659,6 @@ def submit_expense_claim(
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/claims/{claim_id}/return",
|
||||
response_model=ExpenseClaimRead,
|
||||
summary="退回报销单",
|
||||
description="财务人员、高级财务人员或当前审批人可将可见报销单退回到待提交状态。",
|
||||
responses={
|
||||
status.HTTP_404_NOT_FOUND: {
|
||||
"model": ErrorResponse,
|
||||
"description": "报销单不存在。",
|
||||
},
|
||||
status.HTTP_400_BAD_REQUEST: {
|
||||
"model": ErrorResponse,
|
||||
"description": "当前用户或单据状态不允许退回。",
|
||||
},
|
||||
},
|
||||
)
|
||||
def return_expense_claim(
|
||||
claim_id: str,
|
||||
payload: ExpenseClaimReturnPayload,
|
||||
db: DbSession,
|
||||
current_user: CurrentUser,
|
||||
) -> ExpenseClaimRead:
|
||||
service = ExpenseClaimService(db)
|
||||
try:
|
||||
claim = service.return_claim(claim_id, current_user, reason=payload.reason, reason_codes=payload.reason_codes)
|
||||
except ValueError as error:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error
|
||||
|
||||
if claim is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Claim not found")
|
||||
return claim
|
||||
|
||||
|
||||
@router.post(
|
||||
"/claims/{claim_id}/approve",
|
||||
response_model=ExpenseClaimRead,
|
||||
summary="审批通过单据",
|
||||
description="费用申请由直属领导审批后流转到预算管理者审批,预算审核通过后生成报销草稿;报销单直属领导审批后流转到财务审批。",
|
||||
responses={
|
||||
status.HTTP_404_NOT_FOUND: {
|
||||
"model": ErrorResponse,
|
||||
"description": "单据不存在。",
|
||||
},
|
||||
status.HTTP_400_BAD_REQUEST: {
|
||||
"model": ErrorResponse,
|
||||
"description": "当前用户或单据状态不允许审批通过。",
|
||||
},
|
||||
},
|
||||
)
|
||||
def approve_expense_claim(
|
||||
claim_id: str,
|
||||
payload: ExpenseClaimApprovalPayload,
|
||||
db: DbSession,
|
||||
current_user: CurrentUser,
|
||||
) -> ExpenseClaimRead:
|
||||
service = ExpenseClaimService(db)
|
||||
try:
|
||||
claim = service.approve_claim(claim_id, current_user, opinion=payload.opinion)
|
||||
except ValueError as error:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error
|
||||
|
||||
if claim is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Claim not found")
|
||||
return claim
|
||||
|
||||
|
||||
@router.post(
|
||||
"/claims/{claim_id}/pay",
|
||||
response_model=ExpenseClaimRead,
|
||||
summary="确认报销单已付款",
|
||||
description="财务人员或高级财务人员确认待付款报销单已完成付款。",
|
||||
responses={
|
||||
status.HTTP_404_NOT_FOUND: {
|
||||
"model": ErrorResponse,
|
||||
"description": "单据不存在。",
|
||||
},
|
||||
status.HTTP_400_BAD_REQUEST: {
|
||||
"model": ErrorResponse,
|
||||
"description": "当前用户或单据状态不允许确认付款。",
|
||||
},
|
||||
},
|
||||
)
|
||||
def pay_expense_claim(
|
||||
claim_id: str,
|
||||
db: DbSession,
|
||||
current_user: CurrentUser,
|
||||
) -> ExpenseClaimRead:
|
||||
service = ExpenseClaimService(db)
|
||||
try:
|
||||
claim = service.mark_claim_paid(claim_id, current_user)
|
||||
except ValueError as error:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error
|
||||
|
||||
if claim is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Claim not found")
|
||||
return claim
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/claims/{claim_id}",
|
||||
response_model=ExpenseClaimActionResponse,
|
||||
@@ -765,7 +675,9 @@ def pay_expense_claim(
|
||||
},
|
||||
},
|
||||
)
|
||||
def delete_expense_claim(claim_id: str, db: DbSession, current_user: CurrentUser) -> ExpenseClaimActionResponse:
|
||||
def delete_expense_claim(
|
||||
claim_id: str, db: DbSession, current_user: CurrentUser
|
||||
) -> ExpenseClaimActionResponse:
|
||||
service = ExpenseClaimService(db)
|
||||
try:
|
||||
claim = service.delete_claim(claim_id, current_user)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
@@ -7,6 +8,11 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import CurrentUserContext, get_current_user, get_db
|
||||
from app.schemas.common import ErrorResponse
|
||||
from app.schemas.risk_disposition import (
|
||||
RiskDispositionActionCreate,
|
||||
RiskDispositionMutationRead,
|
||||
RiskDispositionRead,
|
||||
)
|
||||
from app.schemas.risk_observation import (
|
||||
RiskObservationDashboardRead,
|
||||
RiskObservationFeedbackCreate,
|
||||
@@ -14,6 +20,13 @@ from app.schemas.risk_observation import (
|
||||
RiskObservationListRead,
|
||||
RiskObservationRead,
|
||||
)
|
||||
from app.services.risk_dispositions import (
|
||||
RiskDispositionConflictError,
|
||||
RiskDispositionPermissionError,
|
||||
RiskDispositionService,
|
||||
RiskDispositionVersionConflictError,
|
||||
)
|
||||
from app.services.risk_observation_access_policy import RiskObservationAccessPolicy
|
||||
from app.services.risk_observations import RiskObservationService
|
||||
|
||||
router = APIRouter(prefix="/risk-observations")
|
||||
@@ -43,6 +56,7 @@ def list_risk_observations(
|
||||
limit: Annotated[int, Query(ge=1, le=200)] = 50,
|
||||
offset: Annotated[int, Query(ge=0)] = 0,
|
||||
) -> RiskObservationListRead:
|
||||
_require_pool_access(db, current_user)
|
||||
items, total = RiskObservationService(db).list_observations(
|
||||
tenant_id=current_user.tenant_id,
|
||||
claim_id=claim_id,
|
||||
@@ -70,6 +84,7 @@ def summarize_risk_observations(
|
||||
window_days: Annotated[int, Query(ge=1, le=365)] = 30,
|
||||
limit: Annotated[int, Query(ge=1, le=2000)] = 500,
|
||||
) -> RiskObservationDashboardRead:
|
||||
_require_pool_access(db, current_user)
|
||||
return RiskObservationService(db).summarize_dashboard(
|
||||
tenant_id=current_user.tenant_id,
|
||||
window_days=window_days,
|
||||
@@ -88,6 +103,8 @@ def list_claim_risk_observations(
|
||||
db: DbSession,
|
||||
current_user: CurrentUser,
|
||||
) -> list[RiskObservationRead]:
|
||||
if not RiskObservationAccessPolicy(db).can_read_claim_risks(claim_id, current_user):
|
||||
raise _not_found()
|
||||
return RiskObservationService(db).list_claim_observations(
|
||||
claim_id,
|
||||
tenant_id=current_user.tenant_id,
|
||||
@@ -105,6 +122,7 @@ def list_execution_log_risk_observations(
|
||||
db: DbSession,
|
||||
current_user: CurrentUser,
|
||||
) -> list[RiskObservationRead]:
|
||||
_require_pool_access(db, current_user)
|
||||
return RiskObservationService(db).list_execution_log_observations(
|
||||
execution_log_id,
|
||||
tenant_id=current_user.tenant_id,
|
||||
@@ -128,6 +146,7 @@ def get_risk_observation(
|
||||
db: DbSession,
|
||||
current_user: CurrentUser,
|
||||
) -> RiskObservationRead:
|
||||
_require_pool_access(db, current_user)
|
||||
observation = RiskObservationService(db).get_observation(
|
||||
observation_key_or_id,
|
||||
tenant_id=current_user.tenant_id,
|
||||
@@ -140,6 +159,53 @@ def get_risk_observation(
|
||||
return observation
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{observation_key_or_id}/disposition",
|
||||
response_model=RiskDispositionRead,
|
||||
summary="读取风险观察处置状态",
|
||||
description="返回裁决结论、处置生命周期、负责人、截止时间和只追加事件。",
|
||||
)
|
||||
def get_risk_observation_disposition(
|
||||
observation_key_or_id: str,
|
||||
db: DbSession,
|
||||
current_user: CurrentUser,
|
||||
) -> RiskDispositionRead:
|
||||
_require_pool_access(db, current_user)
|
||||
observation = _get_observation_or_404(db, current_user, observation_key_or_id)
|
||||
disposition = RiskDispositionService(db).get_disposition(
|
||||
observation.id,
|
||||
tenant_id=current_user.tenant_id,
|
||||
)
|
||||
if disposition is None:
|
||||
raise _not_found("Risk disposition not found")
|
||||
return disposition
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{observation_key_or_id}/disposition/actions",
|
||||
response_model=RiskDispositionMutationRead,
|
||||
summary="执行类型化风险处置动作",
|
||||
description="使用乐观锁和请求幂等键追加风险处置事件。",
|
||||
)
|
||||
def execute_risk_disposition_action(
|
||||
observation_key_or_id: str,
|
||||
payload: RiskDispositionActionCreate,
|
||||
db: DbSession,
|
||||
current_user: CurrentUser,
|
||||
) -> RiskDispositionMutationRead:
|
||||
mutation = _execute_action(
|
||||
db,
|
||||
current_user,
|
||||
observation_key_or_id,
|
||||
payload,
|
||||
)
|
||||
return RiskDispositionMutationRead(
|
||||
disposition=mutation.disposition,
|
||||
event=mutation.event,
|
||||
replayed=mutation.replayed,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{observation_key_or_id}/feedback",
|
||||
response_model=RiskObservationFeedbackRead,
|
||||
@@ -158,15 +224,103 @@ def create_risk_observation_feedback(
|
||||
db: DbSession,
|
||||
current_user: CurrentUser,
|
||||
) -> RiskObservationFeedbackRead:
|
||||
observation = _get_observation_or_404(db, current_user, observation_key_or_id)
|
||||
if (
|
||||
payload.feedback_type not in {"confirm", "false_positive"}
|
||||
or payload.action is not None
|
||||
or bool(payload.payload_json)
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_410_GONE,
|
||||
detail=("自由格式反馈入口已停用;请使用 disposition/actions 类型化处置接口。"),
|
||||
)
|
||||
|
||||
service = RiskDispositionService(db)
|
||||
mutation = _execute_action(
|
||||
db,
|
||||
current_user,
|
||||
observation_key_or_id,
|
||||
RiskDispositionActionCreate(
|
||||
action=payload.feedback_type,
|
||||
expected_version=service.get_current_version(
|
||||
observation.id,
|
||||
tenant_id=current_user.tenant_id,
|
||||
),
|
||||
request_id=f"legacy:{uuid.uuid4()}",
|
||||
comment=payload.comment,
|
||||
),
|
||||
)
|
||||
if mutation.legacy_feedback is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Legacy feedback projection is unavailable.",
|
||||
)
|
||||
return mutation.legacy_feedback
|
||||
|
||||
|
||||
def _require_pool_access(db: Session, current_user: CurrentUserContext) -> None:
|
||||
if RiskObservationAccessPolicy(db).can_read_tenant_pool(current_user):
|
||||
return
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="当前用户无权访问企业风险观察池。",
|
||||
)
|
||||
|
||||
|
||||
def _get_observation_or_404(
|
||||
db: Session,
|
||||
current_user: CurrentUserContext,
|
||||
observation_key_or_id: str,
|
||||
):
|
||||
observation = RiskObservationService(db).get_observation(
|
||||
observation_key_or_id,
|
||||
tenant_id=current_user.tenant_id,
|
||||
)
|
||||
if observation is None:
|
||||
raise _not_found()
|
||||
return observation
|
||||
|
||||
|
||||
def _execute_action(
|
||||
db: Session,
|
||||
current_user: CurrentUserContext,
|
||||
observation_key_or_id: str,
|
||||
payload: RiskDispositionActionCreate,
|
||||
):
|
||||
try:
|
||||
return RiskObservationService(db).create_feedback(
|
||||
return RiskDispositionService(db).execute_action(
|
||||
observation_key_or_id,
|
||||
payload,
|
||||
tenant_id=current_user.tenant_id,
|
||||
actor=current_user.name or current_user.username,
|
||||
actor_id=current_user.employee_id or current_user.username,
|
||||
actor_name=current_user.name or current_user.username,
|
||||
current_user=current_user,
|
||||
)
|
||||
except LookupError:
|
||||
raise _not_found() from None
|
||||
except RiskDispositionVersionConflictError as error:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Risk observation not found",
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail={
|
||||
"code": "RISK_DISPOSITION_VERSION_CONFLICT",
|
||||
"current_version": error.current_version,
|
||||
"message": "风险处置状态已更新,请刷新证据链后重试。",
|
||||
},
|
||||
) from None
|
||||
except RiskDispositionPermissionError as error:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=str(error),
|
||||
) from None
|
||||
except RiskDispositionConflictError as error:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=str(error),
|
||||
) from None
|
||||
|
||||
|
||||
def _not_found(detail: str = "Risk observation not found") -> HTTPException:
|
||||
return HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=detail,
|
||||
)
|
||||
|
||||
@@ -6,6 +6,7 @@ from app.api.v1.endpoints.agent_feedback import router as agent_feedback_router
|
||||
from app.api.v1.endpoints.agent_runs import router as agent_runs_router
|
||||
from app.api.v1.endpoints.agent_traces import router as agent_traces_router
|
||||
from app.api.v1.endpoints.analytics import router as analytics_router
|
||||
from app.api.v1.endpoints.approval_workbench import router as approval_workbench_router
|
||||
from app.api.v1.endpoints.attachment_association_jobs import (
|
||||
router as attachment_association_jobs_router,
|
||||
)
|
||||
@@ -49,10 +50,14 @@ router.include_router(agent_feedback_router, tags=["agent-feedback"])
|
||||
router.include_router(agent_runs_router, tags=["agent-runs"])
|
||||
router.include_router(agent_traces_router, tags=["agent-traces"])
|
||||
router.include_router(analytics_router, tags=["analytics"])
|
||||
router.include_router(approval_workbench_router, tags=["approval-workbench"])
|
||||
router.include_router(attachment_association_jobs_router, tags=["attachment-association-jobs"])
|
||||
router.include_router(audit_logs_router, tags=["audit-logs"])
|
||||
router.include_router(knowledge_router, tags=["knowledge"])
|
||||
router.include_router(linked_reimbursement_draft_jobs_router, tags=["linked-reimbursement-draft-jobs"])
|
||||
router.include_router(
|
||||
linked_reimbursement_draft_jobs_router,
|
||||
tags=["linked-reimbursement-draft-jobs"],
|
||||
)
|
||||
router.include_router(notification_states_router, tags=["notification-states"])
|
||||
router.include_router(ocr_router, tags=["ocr"])
|
||||
router.include_router(ontology_router, tags=["ontology"])
|
||||
|
||||
@@ -13,6 +13,7 @@ from app.models.ai_application_preview import AIApplicationPreviewDecision
|
||||
from app.models.ai_learning import AIDecision, AIDecisionFeedback, WorkflowOutcome
|
||||
from app.models.ai_memory import MemoryEntry, MemoryEvidenceLink
|
||||
from app.models.approval import ApprovalRecord
|
||||
from app.models.approval_action import ApprovalActionLedger
|
||||
from app.models.attachment_association_job import AttachmentAssociationJob
|
||||
from app.models.audit_log import AuditLog
|
||||
from app.models.auth_session import AuthSession
|
||||
@@ -34,6 +35,7 @@ from app.models.hermes_report import HermesRiskReport
|
||||
from app.models.notification_state import NotificationState
|
||||
from app.models.organization import OrganizationUnit
|
||||
from app.models.reimbursement import ReimbursementRequest
|
||||
from app.models.risk_disposition import RiskDisposition, RiskDispositionEvent
|
||||
from app.models.risk_observation import RiskObservation, RiskObservationFeedback
|
||||
from app.models.role import Role
|
||||
from app.models.system_model_setting import SystemModelSetting
|
||||
@@ -60,6 +62,7 @@ __all__ = [
|
||||
"AIDecision",
|
||||
"AIDecisionFeedback",
|
||||
"ApprovalRecord",
|
||||
"ApprovalActionLedger",
|
||||
"AttachmentAssociationJob",
|
||||
"AuditLog",
|
||||
"AuthSession",
|
||||
@@ -84,6 +87,8 @@ __all__ = [
|
||||
"NotificationState",
|
||||
"OrganizationUnit",
|
||||
"ReimbursementRequest",
|
||||
"RiskDisposition",
|
||||
"RiskDispositionEvent",
|
||||
"RiskObservation",
|
||||
"RiskObservationFeedback",
|
||||
"Role",
|
||||
|
||||
@@ -130,8 +130,48 @@ MIGRATION_OWNED_TABLES_BY_REVISION: dict[str, frozenset[str]] = {
|
||||
"workflow_outcomes",
|
||||
}
|
||||
),
|
||||
"20260716_0010": frozenset(
|
||||
{
|
||||
"expense_cases",
|
||||
"expense_case_links",
|
||||
"business_events",
|
||||
"auth_sessions",
|
||||
"attachment_association_jobs",
|
||||
"ai_application_preview_decisions",
|
||||
"ai_decisions",
|
||||
"ai_decision_feedback",
|
||||
"memory_entries",
|
||||
"memory_evidence_links",
|
||||
"risk_observations",
|
||||
"risk_observation_feedback",
|
||||
"few_shot_samples",
|
||||
"workflow_outcomes",
|
||||
"approval_action_ledgers",
|
||||
}
|
||||
),
|
||||
"20260716_0011": frozenset(
|
||||
{
|
||||
"expense_cases",
|
||||
"expense_case_links",
|
||||
"business_events",
|
||||
"auth_sessions",
|
||||
"attachment_association_jobs",
|
||||
"ai_application_preview_decisions",
|
||||
"ai_decisions",
|
||||
"ai_decision_feedback",
|
||||
"memory_entries",
|
||||
"memory_evidence_links",
|
||||
"risk_observations",
|
||||
"risk_observation_feedback",
|
||||
"risk_dispositions",
|
||||
"risk_disposition_events",
|
||||
"few_shot_samples",
|
||||
"workflow_outcomes",
|
||||
"approval_action_ledgers",
|
||||
}
|
||||
),
|
||||
}
|
||||
if MIGRATION_OWNED_TABLES_BY_REVISION["20260716_0009"] != MIGRATION_OWNED_TABLES:
|
||||
if MIGRATION_OWNED_TABLES_BY_REVISION["20260716_0011"] != MIGRATION_OWNED_TABLES:
|
||||
raise RuntimeError("latest Alembic revision must own the centralized migration table set")
|
||||
|
||||
# 0008 之前这三张表由旧 bootstrap / 风险服务按需创建。迁移前置检查允许
|
||||
@@ -198,7 +238,13 @@ def _validate_connection(connection: Connection) -> MigrationPreflightState:
|
||||
|
||||
adoptable_tables = (
|
||||
LEGACY_ADOPTABLE_HISTORICAL_CASE_TABLES
|
||||
if revision not in {"20260716_0008", "20260716_0009"}
|
||||
if revision
|
||||
not in {
|
||||
"20260716_0008",
|
||||
"20260716_0009",
|
||||
"20260716_0010",
|
||||
"20260716_0011",
|
||||
}
|
||||
else frozenset()
|
||||
)
|
||||
missing_tables = expected_tables - owned_tables
|
||||
|
||||
@@ -7,6 +7,7 @@ from app.db.base import Base
|
||||
MIGRATION_OWNED_TABLES: frozenset[str] = frozenset(
|
||||
{
|
||||
"auth_sessions",
|
||||
"approval_action_ledgers",
|
||||
"attachment_association_jobs",
|
||||
"ai_application_preview_decisions",
|
||||
"ai_decisions",
|
||||
@@ -18,6 +19,8 @@ MIGRATION_OWNED_TABLES: frozenset[str] = frozenset(
|
||||
"memory_evidence_links",
|
||||
"risk_observations",
|
||||
"risk_observation_feedback",
|
||||
"risk_dispositions",
|
||||
"risk_disposition_events",
|
||||
"few_shot_samples",
|
||||
"workflow_outcomes",
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ from app.models.ai_application_preview import AIApplicationPreviewDecision
|
||||
from app.models.ai_learning import AIDecision, AIDecisionFeedback, WorkflowOutcome
|
||||
from app.models.ai_memory import MemoryEntry, MemoryEvidenceLink
|
||||
from app.models.approval import ApprovalRecord
|
||||
from app.models.approval_action import ApprovalActionLedger
|
||||
from app.models.attachment_association_job import AttachmentAssociationJob
|
||||
from app.models.audit_log import AuditLog
|
||||
from app.models.auth_session import AuthSession
|
||||
@@ -32,6 +33,7 @@ from app.models.hermes_report import HermesRiskReport
|
||||
from app.models.notification_state import NotificationState
|
||||
from app.models.organization import OrganizationUnit
|
||||
from app.models.reimbursement import ReimbursementRequest
|
||||
from app.models.risk_disposition import RiskDisposition, RiskDispositionEvent
|
||||
from app.models.risk_observation import RiskObservation, RiskObservationFeedback
|
||||
from app.models.role import Role
|
||||
from app.models.system_model_setting import SystemModelSetting
|
||||
@@ -54,6 +56,7 @@ __all__ = [
|
||||
"AgentTraceEvent",
|
||||
"AIApplicationPreviewDecision",
|
||||
"ApprovalRecord",
|
||||
"ApprovalActionLedger",
|
||||
"AttachmentAssociationJob",
|
||||
"AuditLog",
|
||||
"AuthSession",
|
||||
@@ -80,6 +83,8 @@ __all__ = [
|
||||
"NotificationState",
|
||||
"OrganizationUnit",
|
||||
"ReimbursementRequest",
|
||||
"RiskDisposition",
|
||||
"RiskDispositionEvent",
|
||||
"RiskObservation",
|
||||
"RiskObservationFeedback",
|
||||
"Role",
|
||||
|
||||
64
server/src/app/models/approval_action.py
Normal file
64
server/src/app/models/approval_action.py
Normal file
@@ -0,0 +1,64 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import CheckConstraint, DateTime, Index, String, UniqueConstraint, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from sqlalchemy.types import JSON
|
||||
|
||||
from app.db.base_class import Base
|
||||
|
||||
|
||||
class ApprovalActionLedger(Base):
|
||||
"""审批类写动作的持久化幂等账本。"""
|
||||
|
||||
__tablename__ = "approval_action_ledgers"
|
||||
__table_args__ = (
|
||||
CheckConstraint(
|
||||
"action IN ('approve', 'return', 'pay')",
|
||||
name="ck_approval_action_ledger_action",
|
||||
),
|
||||
CheckConstraint(
|
||||
"(completed_at IS NULL AND result_status IS NULL "
|
||||
"AND result_approval_stage IS NULL) OR "
|
||||
"(completed_at IS NOT NULL AND result_status IS NOT NULL "
|
||||
"AND result_approval_stage IS NOT NULL)",
|
||||
name="ck_approval_action_ledger_completion",
|
||||
),
|
||||
UniqueConstraint(
|
||||
"tenant_id",
|
||||
"actor_id",
|
||||
"request_id",
|
||||
name="uq_approval_action_ledger_request",
|
||||
),
|
||||
Index(
|
||||
"ix_approval_action_ledger_claim_action",
|
||||
"tenant_id",
|
||||
"claim_id",
|
||||
"action",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(
|
||||
String(36),
|
||||
primary_key=True,
|
||||
default=lambda: str(uuid.uuid4()),
|
||||
)
|
||||
tenant_id: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
actor_id: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||
request_id: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||
claim_id: Mapped[str] = mapped_column(String(36), nullable=False)
|
||||
action: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||
payload_fingerprint: Mapped[str] = mapped_column(String(80), nullable=False)
|
||||
expected_status: Mapped[str] = mapped_column(String(30), nullable=False)
|
||||
expected_approval_stage: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
result_status: Mapped[str | None] = mapped_column(String(30), nullable=True)
|
||||
result_approval_stage: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
response_json: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
|
||||
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
)
|
||||
174
server/src/app/models/risk_disposition.py
Normal file
174
server/src/app/models/risk_disposition.py
Normal file
@@ -0,0 +1,174 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import (
|
||||
CheckConstraint,
|
||||
DateTime,
|
||||
ForeignKeyConstraint,
|
||||
Index,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
func,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.types import JSON
|
||||
|
||||
from app.db.base_class import Base
|
||||
|
||||
|
||||
def _new_id() -> str:
|
||||
return str(uuid.uuid4())
|
||||
|
||||
|
||||
class RiskDisposition(Base):
|
||||
"""风险观察的当前处置投影;裁决结论和处置生命周期彼此独立。"""
|
||||
|
||||
__tablename__ = "risk_dispositions"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"tenant_id",
|
||||
"id",
|
||||
name="uq_risk_dispositions_tenant_id",
|
||||
),
|
||||
UniqueConstraint(
|
||||
"tenant_id",
|
||||
"observation_id",
|
||||
name="uq_risk_dispositions_tenant_observation",
|
||||
),
|
||||
CheckConstraint(
|
||||
"adjudication IN ('unreviewed', 'confirmed', 'false_positive')",
|
||||
name="ck_risk_dispositions_adjudication",
|
||||
),
|
||||
CheckConstraint(
|
||||
"lifecycle_status IN ('open', 'supplement_requested', "
|
||||
"'remediation_in_progress', 'waiver_requested', 'resolved')",
|
||||
name="ck_risk_dispositions_lifecycle",
|
||||
),
|
||||
CheckConstraint("version >= 0", name="ck_risk_dispositions_version"),
|
||||
ForeignKeyConstraint(
|
||||
["tenant_id", "observation_id"],
|
||||
["risk_observations.tenant_id", "risk_observations.id"],
|
||||
ondelete="RESTRICT",
|
||||
name="fk_risk_dispositions_tenant_observation",
|
||||
),
|
||||
Index(
|
||||
"ix_risk_dispositions_tenant_lifecycle_due",
|
||||
"tenant_id",
|
||||
"lifecycle_status",
|
||||
"due_at",
|
||||
),
|
||||
Index("ix_risk_dispositions_assignee", "tenant_id", "assignee"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_new_id)
|
||||
tenant_id: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
observation_id: Mapped[str] = mapped_column(String(36), nullable=False)
|
||||
adjudication: Mapped[str] = mapped_column(
|
||||
String(24),
|
||||
nullable=False,
|
||||
default="unreviewed",
|
||||
server_default="unreviewed",
|
||||
)
|
||||
lifecycle_status: Mapped[str] = mapped_column(
|
||||
String(32),
|
||||
nullable=False,
|
||||
default="open",
|
||||
server_default="open",
|
||||
)
|
||||
version: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
nullable=False,
|
||||
default=0,
|
||||
server_default="0",
|
||||
)
|
||||
assignee: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||
due_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
resolution: Mapped[str | None] = mapped_column(Text(), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=func.now(),
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
)
|
||||
|
||||
observation = relationship("RiskObservation", back_populates="disposition")
|
||||
events = relationship(
|
||||
"RiskDispositionEvent",
|
||||
back_populates="disposition",
|
||||
order_by="asc(RiskDispositionEvent.version)",
|
||||
lazy="selectin",
|
||||
passive_deletes=True,
|
||||
)
|
||||
|
||||
|
||||
class RiskDispositionEvent(Base):
|
||||
"""只追加的风险处置事实;每个版本只对应一个类型化动作。"""
|
||||
|
||||
__tablename__ = "risk_disposition_events"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"tenant_id",
|
||||
"request_id",
|
||||
name="uq_risk_disposition_events_tenant_request",
|
||||
),
|
||||
UniqueConstraint(
|
||||
"disposition_id",
|
||||
"version",
|
||||
name="uq_risk_disposition_events_version",
|
||||
),
|
||||
CheckConstraint(
|
||||
"action IN ('confirm', 'false_positive', 'request_supplement', "
|
||||
"'start_remediation', 'resolve', 'request_waiver')",
|
||||
name="ck_risk_disposition_events_action",
|
||||
),
|
||||
CheckConstraint("version > 0", name="ck_risk_disposition_events_version"),
|
||||
ForeignKeyConstraint(
|
||||
["tenant_id", "disposition_id"],
|
||||
["risk_dispositions.tenant_id", "risk_dispositions.id"],
|
||||
ondelete="RESTRICT",
|
||||
name="fk_risk_disposition_events_tenant_disposition",
|
||||
),
|
||||
ForeignKeyConstraint(
|
||||
["tenant_id", "observation_id"],
|
||||
["risk_observations.tenant_id", "risk_observations.id"],
|
||||
ondelete="RESTRICT",
|
||||
name="fk_risk_disposition_events_tenant_observation",
|
||||
),
|
||||
Index(
|
||||
"ix_risk_disposition_events_tenant_observation_time",
|
||||
"tenant_id",
|
||||
"observation_id",
|
||||
"created_at",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_new_id)
|
||||
tenant_id: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
disposition_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
observation_id: Mapped[str] = mapped_column(String(36), nullable=False)
|
||||
version: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
action: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
actor_id: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||
actor_name: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||
request_id: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||
payload_fingerprint: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
comment: Mapped[str | None] = mapped_column(Text(), nullable=True)
|
||||
before_json: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False, default=dict)
|
||||
after_json: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False, default=dict)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=func.now(),
|
||||
)
|
||||
|
||||
disposition = relationship("RiskDisposition", back_populates="events")
|
||||
@@ -24,6 +24,11 @@ from app.db.base_class import Base
|
||||
class RiskObservation(Base):
|
||||
__tablename__ = "risk_observations"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"tenant_id",
|
||||
"id",
|
||||
name="uq_risk_observations_tenant_id",
|
||||
),
|
||||
UniqueConstraint(
|
||||
"tenant_id",
|
||||
"observation_key",
|
||||
@@ -95,6 +100,12 @@ class RiskObservation(Base):
|
||||
cascade="all, delete-orphan",
|
||||
order_by="desc(RiskObservationFeedback.created_at)",
|
||||
)
|
||||
disposition = relationship(
|
||||
"RiskDisposition",
|
||||
back_populates="observation",
|
||||
passive_deletes=True,
|
||||
uselist=False,
|
||||
)
|
||||
|
||||
@property
|
||||
def sampling_strategy(self) -> dict[str, Any]:
|
||||
|
||||
58
server/src/app/schemas/approval_workbench.py
Normal file
58
server/src/app/schemas/approval_workbench.py
Normal file
@@ -0,0 +1,58 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.schemas.reimbursement import ExpenseClaimRead
|
||||
|
||||
|
||||
class ApprovalWorkbenchPriorityReasonRead(BaseModel):
|
||||
code: str
|
||||
label: str
|
||||
weight: int = 0
|
||||
tone: Literal["normal", "warning", "danger"] = "normal"
|
||||
|
||||
|
||||
class ApprovalWorkbenchEvidenceRead(BaseModel):
|
||||
completeness: float = 0.0
|
||||
present_count: int = 0
|
||||
required_count: int = 0
|
||||
missing_labels: list[str] = Field(default_factory=list)
|
||||
historical_labels: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ApprovalWorkbenchSuggestionRead(BaseModel):
|
||||
action: Literal[
|
||||
"manual_review",
|
||||
"request_supplement",
|
||||
"budget_review",
|
||||
"approve_candidate",
|
||||
]
|
||||
label: str
|
||||
reason: str
|
||||
advisory_only: Literal[True] = True
|
||||
|
||||
|
||||
class ApprovalWorkbenchItemRead(BaseModel):
|
||||
task_key: str
|
||||
claim: ExpenseClaimRead
|
||||
priority_score: int = 0
|
||||
priority_tier: Literal["normal", "high", "urgent"] = "normal"
|
||||
priority_reasons: list[ApprovalWorkbenchPriorityReasonRead] = Field(default_factory=list)
|
||||
risk_level: Literal["low", "medium", "high", "critical"] = "low"
|
||||
open_risk_count: int = 0
|
||||
budget_usage_rate: float | None = None
|
||||
waiting_hours: float = 0.0
|
||||
sla_due_at: datetime | None = None
|
||||
sla_overdue: bool = False
|
||||
evidence: ApprovalWorkbenchEvidenceRead
|
||||
suggestion: ApprovalWorkbenchSuggestionRead
|
||||
|
||||
|
||||
class ApprovalWorkbenchListRead(BaseModel):
|
||||
items: list[ApprovalWorkbenchItemRead] = Field(default_factory=list)
|
||||
total: int = 0
|
||||
generated_at: datetime
|
||||
scoring_version: str = "approval_workbench_priority.v1"
|
||||
@@ -276,15 +276,38 @@ class ExpenseApplicationPreviewActionResponse(BaseModel):
|
||||
result: ExpenseApplicationPreviewActionResult
|
||||
|
||||
|
||||
class ExpenseClaimReturnPayload(BaseModel):
|
||||
class ExpenseClaimActionProtocolPayload(BaseModel):
|
||||
request_id: str = Field(min_length=1, max_length=120)
|
||||
expected_status: str = Field(min_length=1, max_length=30)
|
||||
expected_approval_stage: str = Field(min_length=1, max_length=50)
|
||||
|
||||
@field_validator("request_id", "expected_status", "expected_approval_stage")
|
||||
@classmethod
|
||||
def validate_action_protocol_text(cls, value: str) -> str:
|
||||
normalized = str(value or "").strip()
|
||||
if not normalized:
|
||||
raise ValueError("审批动作协议字段不能为空。")
|
||||
return normalized
|
||||
|
||||
@field_validator("expected_status")
|
||||
@classmethod
|
||||
def normalize_expected_status(cls, value: str) -> str:
|
||||
return value.lower()
|
||||
|
||||
|
||||
class ExpenseClaimReturnPayload(ExpenseClaimActionProtocolPayload):
|
||||
reason: str | None = Field(default=None, max_length=500)
|
||||
reason_codes: list[str] = Field(default_factory=list, max_length=10)
|
||||
|
||||
|
||||
class ExpenseClaimApprovalPayload(BaseModel):
|
||||
class ExpenseClaimApprovalPayload(ExpenseClaimActionProtocolPayload):
|
||||
opinion: str | None = Field(default=None, max_length=500)
|
||||
|
||||
|
||||
class ExpenseClaimPaymentPayload(ExpenseClaimActionProtocolPayload):
|
||||
pass
|
||||
|
||||
|
||||
class TravelReimbursementCalculatorRequest(BaseModel):
|
||||
days: int = Field(ge=1, le=365)
|
||||
location: str = Field(min_length=1, max_length=120)
|
||||
|
||||
96
server/src/app/schemas/risk_disposition.py
Normal file
96
server/src/app/schemas/risk_disposition.py
Normal file
@@ -0,0 +1,96 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
|
||||
RiskDispositionAction = Literal[
|
||||
"confirm",
|
||||
"false_positive",
|
||||
"request_supplement",
|
||||
"start_remediation",
|
||||
"resolve",
|
||||
"request_waiver",
|
||||
]
|
||||
RiskAdjudication = Literal["unreviewed", "confirmed", "false_positive"]
|
||||
RiskLifecycleStatus = Literal[
|
||||
"open",
|
||||
"supplement_requested",
|
||||
"remediation_in_progress",
|
||||
"waiver_requested",
|
||||
"resolved",
|
||||
]
|
||||
|
||||
|
||||
class RiskDispositionActionCreate(BaseModel):
|
||||
action: RiskDispositionAction
|
||||
expected_version: int = Field(ge=0)
|
||||
request_id: str = Field(min_length=8, max_length=120)
|
||||
comment: str | None = Field(default=None, max_length=1000)
|
||||
assignee: str | None = Field(default=None, max_length=120)
|
||||
due_at: datetime | None = None
|
||||
resolution: str | None = Field(default=None, max_length=2000)
|
||||
|
||||
@field_validator("request_id", "comment", "assignee", "resolution", mode="before")
|
||||
@classmethod
|
||||
def normalize_text(cls, value: Any) -> Any:
|
||||
if value is None:
|
||||
return None
|
||||
normalized = str(value).strip()
|
||||
return normalized or None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_action_fields(self) -> RiskDispositionActionCreate:
|
||||
if self.action == "resolve" and not self.resolution:
|
||||
raise ValueError("resolve 动作必须填写 resolution")
|
||||
if self.action in {"false_positive", "request_supplement", "request_waiver"} and not (
|
||||
self.comment
|
||||
):
|
||||
raise ValueError(f"{self.action} 动作必须填写 comment")
|
||||
if self.action in {"confirm", "false_positive"} and any(
|
||||
value is not None for value in (self.assignee, self.due_at, self.resolution)
|
||||
):
|
||||
raise ValueError("裁决动作不能同时修改负责人、截止时间或解决说明")
|
||||
return self
|
||||
|
||||
|
||||
class RiskDispositionEventRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: str
|
||||
tenant_id: str
|
||||
disposition_id: str
|
||||
observation_id: str
|
||||
version: int
|
||||
action: RiskDispositionAction
|
||||
actor_id: str
|
||||
actor_name: str
|
||||
request_id: str
|
||||
comment: str | None
|
||||
before_json: dict[str, Any]
|
||||
after_json: dict[str, Any]
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class RiskDispositionRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: str
|
||||
tenant_id: str
|
||||
observation_id: str
|
||||
adjudication: RiskAdjudication
|
||||
lifecycle_status: RiskLifecycleStatus
|
||||
version: int
|
||||
assignee: str | None
|
||||
due_at: datetime | None
|
||||
resolution: str | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
events: list[RiskDispositionEventRead] = Field(default_factory=list)
|
||||
|
||||
|
||||
class RiskDispositionMutationRead(BaseModel):
|
||||
disposition: RiskDispositionRead
|
||||
event: RiskDispositionEventRead
|
||||
replayed: bool = False
|
||||
@@ -5,6 +5,8 @@ from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
from app.schemas.risk_disposition import RiskDispositionRead
|
||||
|
||||
RiskObservationStatus = Literal[
|
||||
"pending_review",
|
||||
"confirmed",
|
||||
@@ -89,6 +91,7 @@ class RiskObservationRead(BaseModel):
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
feedback_items: list[RiskObservationFeedbackRead] = Field(default_factory=list)
|
||||
disposition: RiskDispositionRead | None = None
|
||||
|
||||
|
||||
class RiskObservationListRead(BaseModel):
|
||||
|
||||
270
server/src/app/services/approval_action_protocol.py
Normal file
270
server/src/app/services/approval_action_protocol.py
Normal file
@@ -0,0 +1,270 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import threading
|
||||
import uuid
|
||||
from collections.abc import Callable, Iterator, Mapping
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select, text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import CurrentUserContext
|
||||
from app.models.approval_action import ApprovalActionLedger
|
||||
from app.models.financial_record import ExpenseClaim
|
||||
from app.services.expense_claim_tenant_scope import ExpenseClaimTenantScopeMixin
|
||||
|
||||
|
||||
class ApprovalActionConflictError(ValueError):
|
||||
"""请求已被占用,或动作前置状态已经过期。"""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ApprovalActionStart:
|
||||
claim: ExpenseClaim | None
|
||||
ledger: ApprovalActionLedger | None
|
||||
request_id: str
|
||||
replayed: bool
|
||||
|
||||
|
||||
class ApprovalActionProtocol:
|
||||
"""为 approve/return/pay 提供并发安全和事务内幂等语义。"""
|
||||
|
||||
_fallback_registry_guard = threading.Lock()
|
||||
_fallback_locks: dict[str, tuple[threading.RLock, int]] = {}
|
||||
|
||||
def __init__(self, db: Session) -> None:
|
||||
self.db = db
|
||||
|
||||
@contextmanager
|
||||
def serialize_request(
|
||||
self,
|
||||
*,
|
||||
tenant_id: str,
|
||||
actor_id: str,
|
||||
request_id: str | None,
|
||||
) -> Iterator[str]:
|
||||
normalized_request_id = self._normalize_request_id(request_id)
|
||||
lock_name = f"approval-action:{tenant_id}:{actor_id}:{normalized_request_id}"
|
||||
if self._dialect_name() == "postgresql":
|
||||
self.db.execute(
|
||||
text("SELECT pg_advisory_xact_lock(:lock_id)"),
|
||||
{"lock_id": self._signed_lock_id(lock_name)},
|
||||
)
|
||||
yield normalized_request_id
|
||||
return
|
||||
|
||||
with self._serialize_fallback(lock_name):
|
||||
yield normalized_request_id
|
||||
|
||||
def begin(
|
||||
self,
|
||||
*,
|
||||
action: str,
|
||||
claim_id: str,
|
||||
current_user: CurrentUserContext,
|
||||
request_id: str,
|
||||
expected_status: str | None,
|
||||
expected_approval_stage: str | None,
|
||||
payload: Mapping[str, Any],
|
||||
claim_loader: Callable[[], ExpenseClaim | None],
|
||||
replay_claim_loader: Callable[[], ExpenseClaim | None],
|
||||
) -> ApprovalActionStart:
|
||||
tenant_id = ExpenseClaimTenantScopeMixin.normalize_tenant_id(current_user.tenant_id)
|
||||
actor_id = self._normalize_actor_id(current_user.username)
|
||||
normalized_action = self._normalize_action(action)
|
||||
existing = self.db.scalar(
|
||||
select(ApprovalActionLedger).where(
|
||||
ApprovalActionLedger.tenant_id == tenant_id,
|
||||
ApprovalActionLedger.actor_id == actor_id,
|
||||
ApprovalActionLedger.request_id == request_id,
|
||||
)
|
||||
)
|
||||
if existing is not None:
|
||||
supplied_fingerprint = self._fingerprint(
|
||||
action=normalized_action,
|
||||
claim_id=claim_id,
|
||||
expected_status=expected_status or existing.expected_status,
|
||||
expected_approval_stage=(
|
||||
expected_approval_stage or existing.expected_approval_stage
|
||||
),
|
||||
payload=payload,
|
||||
)
|
||||
if supplied_fingerprint != existing.payload_fingerprint:
|
||||
raise ApprovalActionConflictError(
|
||||
"该 request_id 已用于另一项审批动作,请生成新的 request_id 后重试。"
|
||||
)
|
||||
if existing.completed_at is None:
|
||||
raise ApprovalActionConflictError("该审批动作仍在处理中,请稍后重试。")
|
||||
return ApprovalActionStart(
|
||||
claim=replay_claim_loader(),
|
||||
ledger=existing,
|
||||
request_id=request_id,
|
||||
replayed=True,
|
||||
)
|
||||
|
||||
claim = claim_loader()
|
||||
if claim is None:
|
||||
return ApprovalActionStart(
|
||||
claim=None,
|
||||
ledger=None,
|
||||
request_id=request_id,
|
||||
replayed=False,
|
||||
)
|
||||
|
||||
actual_tenant_id = ExpenseClaimTenantScopeMixin.resolve_claim_tenant_id(
|
||||
self.db,
|
||||
claim.id,
|
||||
)
|
||||
if actual_tenant_id != tenant_id:
|
||||
# 正常情况下租户查询范围会先拦截;这里是写入账本前的纵深校验。
|
||||
raise ApprovalActionConflictError("单据租户上下文已变化,请刷新后重试。")
|
||||
|
||||
current_status = self._normalize_status(claim.status)
|
||||
current_stage = self._normalize_stage(claim.approval_stage)
|
||||
normalized_expected_status = self._normalize_status(
|
||||
expected_status if expected_status is not None else current_status
|
||||
)
|
||||
normalized_expected_stage = self._normalize_stage(
|
||||
expected_approval_stage if expected_approval_stage is not None else current_stage
|
||||
)
|
||||
if normalized_expected_status != current_status:
|
||||
raise ApprovalActionConflictError(
|
||||
f"单据状态已从 {normalized_expected_status} 变为 {current_status},请刷新后重试。"
|
||||
)
|
||||
if normalized_expected_stage != current_stage:
|
||||
raise ApprovalActionConflictError(
|
||||
f"审批节点已从 {normalized_expected_stage} 变为 {current_stage},请刷新后重试。"
|
||||
)
|
||||
|
||||
ledger = ApprovalActionLedger(
|
||||
tenant_id=tenant_id,
|
||||
actor_id=actor_id,
|
||||
request_id=request_id,
|
||||
claim_id=str(claim.id),
|
||||
action=normalized_action,
|
||||
payload_fingerprint=self._fingerprint(
|
||||
action=normalized_action,
|
||||
claim_id=claim.id,
|
||||
expected_status=normalized_expected_status,
|
||||
expected_approval_stage=normalized_expected_stage,
|
||||
payload=payload,
|
||||
),
|
||||
expected_status=normalized_expected_status,
|
||||
expected_approval_stage=normalized_expected_stage,
|
||||
)
|
||||
self.db.add(ledger)
|
||||
self.db.flush()
|
||||
return ApprovalActionStart(
|
||||
claim=claim,
|
||||
ledger=ledger,
|
||||
request_id=request_id,
|
||||
replayed=False,
|
||||
)
|
||||
|
||||
def complete(
|
||||
self,
|
||||
ledger: ApprovalActionLedger,
|
||||
claim: ExpenseClaim,
|
||||
*,
|
||||
response_json: Mapping[str, Any] | None = None,
|
||||
) -> None:
|
||||
ledger.result_status = self._normalize_status(claim.status)
|
||||
ledger.result_approval_stage = self._normalize_stage(claim.approval_stage)
|
||||
ledger.response_json = dict(response_json or {})
|
||||
ledger.completed_at = datetime.now(UTC)
|
||||
self.db.flush()
|
||||
|
||||
def _dialect_name(self) -> str:
|
||||
bind = self.db.get_bind()
|
||||
return str(bind.dialect.name if bind is not None else "")
|
||||
|
||||
@classmethod
|
||||
@contextmanager
|
||||
def _serialize_fallback(cls, name: str) -> Iterator[None]:
|
||||
with cls._fallback_registry_guard:
|
||||
lock, references = cls._fallback_locks.get(
|
||||
name,
|
||||
(threading.RLock(), 0),
|
||||
)
|
||||
cls._fallback_locks[name] = (lock, references + 1)
|
||||
try:
|
||||
with lock:
|
||||
yield
|
||||
finally:
|
||||
with cls._fallback_registry_guard:
|
||||
current = cls._fallback_locks.get(name)
|
||||
if current is None or current[0] is not lock:
|
||||
return
|
||||
if current[1] <= 1:
|
||||
cls._fallback_locks.pop(name, None)
|
||||
else:
|
||||
cls._fallback_locks[name] = (lock, current[1] - 1)
|
||||
|
||||
@staticmethod
|
||||
def _signed_lock_id(value: str) -> int:
|
||||
unsigned = int.from_bytes(
|
||||
hashlib.sha256(value.encode("utf-8")).digest()[:8],
|
||||
byteorder="big",
|
||||
signed=False,
|
||||
)
|
||||
return unsigned - (1 << 64) if unsigned >= (1 << 63) else unsigned
|
||||
|
||||
@staticmethod
|
||||
def _normalize_request_id(value: str | None) -> str:
|
||||
normalized = str(value or "").strip() or f"internal:{uuid.uuid4()}"
|
||||
if len(normalized) > 120:
|
||||
raise ValueError("request_id 最长为 120 个字符。")
|
||||
return normalized
|
||||
|
||||
@staticmethod
|
||||
def _normalize_actor_id(value: str | None) -> str:
|
||||
normalized = str(value or "").strip().casefold()
|
||||
if not normalized:
|
||||
raise ValueError("当前用户缺少可审计的账号标识。")
|
||||
return normalized
|
||||
|
||||
@staticmethod
|
||||
def _normalize_action(value: str) -> str:
|
||||
normalized = str(value or "").strip().lower()
|
||||
if normalized not in {"approve", "return", "pay"}:
|
||||
raise ValueError("不支持的审批动作。")
|
||||
return normalized
|
||||
|
||||
@staticmethod
|
||||
def _normalize_status(value: str | None) -> str:
|
||||
return str(value or "").strip().lower()
|
||||
|
||||
@staticmethod
|
||||
def _normalize_stage(value: str | None) -> str:
|
||||
return str(value or "").strip()
|
||||
|
||||
@classmethod
|
||||
def _fingerprint(
|
||||
cls,
|
||||
*,
|
||||
action: str,
|
||||
claim_id: str,
|
||||
expected_status: str,
|
||||
expected_approval_stage: str,
|
||||
payload: Mapping[str, Any],
|
||||
) -> str:
|
||||
canonical = {
|
||||
"action": cls._normalize_action(action),
|
||||
"claim_id": str(claim_id or "").strip(),
|
||||
"expected_status": cls._normalize_status(expected_status),
|
||||
"expected_approval_stage": cls._normalize_stage(expected_approval_stage),
|
||||
"payload": dict(payload),
|
||||
}
|
||||
encoded = json.dumps(
|
||||
canonical,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
default=str,
|
||||
).encode("utf-8")
|
||||
return f"sha256:{hashlib.sha256(encoded).hexdigest()}"
|
||||
518
server/src/app/services/approval_workbench.py
Normal file
518
server/src/app/services/approval_workbench.py
Normal file
@@ -0,0 +1,518 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import CurrentUserContext
|
||||
from app.models.financial_record import ExpenseClaim
|
||||
from app.models.risk_disposition import RiskDisposition
|
||||
from app.models.risk_observation import RiskObservation
|
||||
from app.schemas.approval_workbench import (
|
||||
ApprovalWorkbenchEvidenceRead,
|
||||
ApprovalWorkbenchItemRead,
|
||||
ApprovalWorkbenchListRead,
|
||||
ApprovalWorkbenchPriorityReasonRead,
|
||||
ApprovalWorkbenchSuggestionRead,
|
||||
)
|
||||
from app.schemas.reimbursement import ExpenseClaimRead
|
||||
from app.services.expense_claim_risk_flags import (
|
||||
claim_risk_flag_observation_key,
|
||||
claim_risk_flag_severity,
|
||||
is_open_claim_risk_flag,
|
||||
)
|
||||
from app.services.expense_claim_tenant_scope import ExpenseClaimTenantScopeMixin
|
||||
|
||||
_SLA_HOURS = 24
|
||||
_RISK_WEIGHTS = {"critical": 38, "high": 30, "medium": 14, "low": 0}
|
||||
_RISK_ORDER = {"low": 0, "medium": 1, "high": 2, "critical": 3}
|
||||
_RESOLVED_STATUSES = {"resolved", "accepted", "waived", "false_positive"}
|
||||
|
||||
|
||||
class ApprovalWorkbenchService:
|
||||
"""构建只读、可解释且不扩大审批权限的例外审批队列。"""
|
||||
|
||||
def __init__(self, db: Session) -> None:
|
||||
self.db = db
|
||||
|
||||
def list_items(
|
||||
self,
|
||||
current_user: CurrentUserContext,
|
||||
*,
|
||||
limit: int = 100,
|
||||
now: datetime | None = None,
|
||||
) -> ApprovalWorkbenchListRead:
|
||||
# 延迟导入,避免 ExpenseClaimService 的 mixin 聚合产生循环依赖。
|
||||
from app.services.expense_claims import ExpenseClaimService
|
||||
|
||||
generated_at = now or datetime.now(UTC)
|
||||
claims = ExpenseClaimService(self.db).list_approval_claims(current_user)
|
||||
observations_by_claim = self._risk_observations_by_claim(
|
||||
claims,
|
||||
tenant_id=current_user.tenant_id,
|
||||
)
|
||||
items = [
|
||||
self.build_item(
|
||||
claim,
|
||||
now=generated_at,
|
||||
observation_rows=observations_by_claim.get(claim.id),
|
||||
)
|
||||
for claim in claims
|
||||
]
|
||||
items.sort(
|
||||
key=lambda item: (
|
||||
-item.priority_score,
|
||||
-item.waiting_hours,
|
||||
item.claim.claim_no,
|
||||
)
|
||||
)
|
||||
return ApprovalWorkbenchListRead(
|
||||
items=items[: max(1, min(int(limit), 200))],
|
||||
total=len(items),
|
||||
generated_at=generated_at,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def build_item(
|
||||
cls,
|
||||
claim: ExpenseClaim,
|
||||
*,
|
||||
now: datetime | None = None,
|
||||
observation_rows: list[tuple[RiskObservation, RiskDisposition | None]] | None = None,
|
||||
) -> ApprovalWorkbenchItemRead:
|
||||
generated_at = now or datetime.now(UTC)
|
||||
submitted_at = cls._aware(claim.submitted_at or claim.created_at)
|
||||
waiting_hours = max(
|
||||
0.0,
|
||||
(generated_at - submitted_at).total_seconds() / 3600,
|
||||
)
|
||||
sla_due_at = submitted_at + timedelta(hours=_SLA_HOURS)
|
||||
risk_level, open_risk_count = cls._combined_risk_summary(
|
||||
claim,
|
||||
observation_rows or [],
|
||||
)
|
||||
budget_usage_rate = cls._budget_usage_rate(claim.risk_flags_json)
|
||||
evidence = cls._evidence_summary(claim)
|
||||
reasons = cls._priority_reasons(
|
||||
claim=claim,
|
||||
risk_level=risk_level,
|
||||
open_risk_count=open_risk_count,
|
||||
budget_usage_rate=budget_usage_rate,
|
||||
waiting_hours=waiting_hours,
|
||||
evidence=evidence,
|
||||
)
|
||||
score = min(100, sum(item.weight for item in reasons))
|
||||
tier = "urgent" if score >= 65 else "high" if score >= 40 else "normal"
|
||||
return ApprovalWorkbenchItemRead(
|
||||
task_key=f"{claim.id}:{claim.approval_stage or ''}:{submitted_at.isoformat()}",
|
||||
claim=cls._safe_claim_read(claim),
|
||||
priority_score=score,
|
||||
priority_tier=tier,
|
||||
priority_reasons=reasons,
|
||||
risk_level=risk_level,
|
||||
open_risk_count=open_risk_count,
|
||||
budget_usage_rate=budget_usage_rate,
|
||||
waiting_hours=round(waiting_hours, 2),
|
||||
sla_due_at=sla_due_at,
|
||||
sla_overdue=generated_at >= sla_due_at,
|
||||
evidence=evidence,
|
||||
suggestion=cls._suggestion(
|
||||
risk_level=risk_level,
|
||||
open_risk_count=open_risk_count,
|
||||
budget_usage_rate=budget_usage_rate,
|
||||
evidence=evidence,
|
||||
),
|
||||
)
|
||||
|
||||
def _risk_observations_by_claim(
|
||||
self,
|
||||
claims: list[ExpenseClaim],
|
||||
*,
|
||||
tenant_id: str | None,
|
||||
) -> dict[str, list[tuple[RiskObservation, RiskDisposition | None]]]:
|
||||
claim_ids = [str(claim.id) for claim in claims if str(claim.id or "").strip()]
|
||||
if not claim_ids:
|
||||
return {}
|
||||
normalized_tenant = ExpenseClaimTenantScopeMixin.normalize_tenant_id(tenant_id)
|
||||
rows = self.db.execute(
|
||||
select(RiskObservation, RiskDisposition)
|
||||
.outerjoin(
|
||||
RiskDisposition,
|
||||
(
|
||||
(RiskDisposition.tenant_id == RiskObservation.tenant_id)
|
||||
& (RiskDisposition.observation_id == RiskObservation.id)
|
||||
),
|
||||
)
|
||||
.where(
|
||||
RiskObservation.tenant_id == normalized_tenant,
|
||||
RiskObservation.claim_id.in_(claim_ids),
|
||||
)
|
||||
).all()
|
||||
grouped: dict[str, list[tuple[RiskObservation, RiskDisposition | None]]] = {}
|
||||
for observation, disposition in rows:
|
||||
grouped.setdefault(str(observation.claim_id), []).append((observation, disposition))
|
||||
return grouped
|
||||
|
||||
@classmethod
|
||||
def _safe_claim_read(cls, claim: ExpenseClaim) -> ExpenseClaimRead:
|
||||
claim_read = ExpenseClaimRead.model_validate(claim)
|
||||
claim_read.risk_flags_json = [
|
||||
cls._safe_risk_flag(flag)
|
||||
for flag in list(claim_read.risk_flags_json or [])
|
||||
if isinstance(flag, dict)
|
||||
]
|
||||
return claim_read
|
||||
|
||||
@staticmethod
|
||||
def _safe_risk_flag(flag: dict[str, Any]) -> dict[str, Any]:
|
||||
safe_flag = dict(flag)
|
||||
if "historical_case_evidence" not in safe_flag:
|
||||
return safe_flag
|
||||
evidence: list[dict[str, Any]] = []
|
||||
for item in list(safe_flag.get("historical_case_evidence") or []):
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
label = str(item.get("label") or "").strip().lower()
|
||||
if label not in {"confirmed", "false_positive"}:
|
||||
continue
|
||||
evidence.append(
|
||||
{
|
||||
"label": label,
|
||||
"label_text": (
|
||||
"历史已确认,仅供复核" if label == "confirmed" else "历史误报,仅供复核"
|
||||
),
|
||||
"advisory_only": True,
|
||||
"stale": bool(item.get("stale")),
|
||||
"summary": (
|
||||
"历史相似案例经人工复核确认风险成立。"
|
||||
if label == "confirmed"
|
||||
else "历史相似案例经人工复核判定为误报。"
|
||||
),
|
||||
}
|
||||
)
|
||||
safe_flag["historical_case_evidence"] = evidence
|
||||
return safe_flag
|
||||
|
||||
@classmethod
|
||||
def _priority_reasons(
|
||||
cls,
|
||||
*,
|
||||
claim: ExpenseClaim,
|
||||
risk_level: str,
|
||||
open_risk_count: int,
|
||||
budget_usage_rate: float | None,
|
||||
waiting_hours: float,
|
||||
evidence: ApprovalWorkbenchEvidenceRead,
|
||||
) -> list[ApprovalWorkbenchPriorityReasonRead]:
|
||||
reasons: list[ApprovalWorkbenchPriorityReasonRead] = []
|
||||
risk_weight = _RISK_WEIGHTS[risk_level]
|
||||
if risk_weight:
|
||||
reasons.append(
|
||||
ApprovalWorkbenchPriorityReasonRead(
|
||||
code="open_risk",
|
||||
label=f"{risk_level.upper()} 风险 {open_risk_count} 项待复核",
|
||||
weight=risk_weight,
|
||||
tone="danger" if risk_level in {"high", "critical"} else "warning",
|
||||
)
|
||||
)
|
||||
if waiting_hours >= _SLA_HOURS:
|
||||
reasons.append(
|
||||
ApprovalWorkbenchPriorityReasonRead(
|
||||
code="sla_overdue",
|
||||
label="已超过 24 小时审批 SLA",
|
||||
weight=26,
|
||||
tone="danger",
|
||||
)
|
||||
)
|
||||
elif waiting_hours >= 16:
|
||||
reasons.append(
|
||||
ApprovalWorkbenchPriorityReasonRead(
|
||||
code="sla_near_due",
|
||||
label="审批 SLA 即将到期",
|
||||
weight=16,
|
||||
tone="warning",
|
||||
)
|
||||
)
|
||||
elif waiting_hours >= 8:
|
||||
reasons.append(
|
||||
ApprovalWorkbenchPriorityReasonRead(
|
||||
code="waiting",
|
||||
label="等待时间已超过 8 小时",
|
||||
weight=8,
|
||||
tone="warning",
|
||||
)
|
||||
)
|
||||
if budget_usage_rate is not None and budget_usage_rate >= 90:
|
||||
reasons.append(
|
||||
ApprovalWorkbenchPriorityReasonRead(
|
||||
code="budget_pressure",
|
||||
label=f"审批后预算占用约 {budget_usage_rate:.0f}%",
|
||||
weight=18,
|
||||
tone="danger" if budget_usage_rate >= 100 else "warning",
|
||||
)
|
||||
)
|
||||
amount = cls._decimal(claim.amount)
|
||||
if amount >= Decimal("50000"):
|
||||
reasons.append(
|
||||
ApprovalWorkbenchPriorityReasonRead(
|
||||
code="large_amount",
|
||||
label="大额费用需重点核对",
|
||||
weight=15,
|
||||
tone="warning",
|
||||
)
|
||||
)
|
||||
elif amount >= Decimal("10000"):
|
||||
reasons.append(
|
||||
ApprovalWorkbenchPriorityReasonRead(
|
||||
code="amount_attention",
|
||||
label="金额超过 1 万元",
|
||||
weight=8,
|
||||
tone="warning",
|
||||
)
|
||||
)
|
||||
if evidence.completeness < 1:
|
||||
reasons.append(
|
||||
ApprovalWorkbenchPriorityReasonRead(
|
||||
code="evidence_gap",
|
||||
label="材料仍有缺口:" + "、".join(evidence.missing_labels),
|
||||
weight=12,
|
||||
tone="warning",
|
||||
)
|
||||
)
|
||||
if not reasons:
|
||||
reasons.append(
|
||||
ApprovalWorkbenchPriorityReasonRead(
|
||||
code="routine",
|
||||
label="常规低风险待办",
|
||||
)
|
||||
)
|
||||
return reasons
|
||||
|
||||
@classmethod
|
||||
def _risk_summary(
|
||||
cls,
|
||||
raw_flags: Any,
|
||||
*,
|
||||
claim_id: str = "",
|
||||
materialized_keys: set[str] | None = None,
|
||||
) -> tuple[str, int]:
|
||||
flags = raw_flags if isinstance(raw_flags, list) else [raw_flags]
|
||||
persisted_keys = materialized_keys or set()
|
||||
level = "low"
|
||||
count = 0
|
||||
for flag in flags:
|
||||
if not isinstance(flag, dict) or not is_open_claim_risk_flag(flag):
|
||||
continue
|
||||
observation_key = claim_risk_flag_observation_key(flag, claim_id=claim_id)
|
||||
if observation_key and observation_key in persisted_keys:
|
||||
continue
|
||||
candidate = claim_risk_flag_severity(flag) or "medium"
|
||||
if candidate not in _RISK_ORDER:
|
||||
candidate = "medium"
|
||||
count += 1
|
||||
if _RISK_ORDER[candidate] > _RISK_ORDER[level]:
|
||||
level = candidate
|
||||
return level, count
|
||||
|
||||
@classmethod
|
||||
def _combined_risk_summary(
|
||||
cls,
|
||||
claim: ExpenseClaim,
|
||||
rows: list[tuple[RiskObservation, RiskDisposition | None]],
|
||||
) -> tuple[str, int]:
|
||||
persisted_level, persisted_count = cls._persisted_risk_summary(rows)
|
||||
materialized_keys = {
|
||||
str(observation.observation_key or "").strip()
|
||||
for observation, _disposition in rows
|
||||
if str(observation.observation_key or "").strip()
|
||||
}
|
||||
raw_level, raw_count = cls._risk_summary(
|
||||
claim.risk_flags_json,
|
||||
claim_id=str(claim.id or ""),
|
||||
materialized_keys=materialized_keys,
|
||||
)
|
||||
return (
|
||||
max((persisted_level, raw_level), key=lambda item: _RISK_ORDER[item]),
|
||||
persisted_count + raw_count,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _persisted_risk_summary(
|
||||
rows: list[tuple[RiskObservation, RiskDisposition | None]],
|
||||
) -> tuple[str, int]:
|
||||
level = "low"
|
||||
count = 0
|
||||
for observation, disposition in rows:
|
||||
adjudication = (
|
||||
str(disposition.adjudication or "").strip().lower()
|
||||
if disposition is not None
|
||||
else str(observation.feedback_status or "").strip().lower()
|
||||
)
|
||||
lifecycle = (
|
||||
str(disposition.lifecycle_status or "").strip().lower()
|
||||
if disposition is not None
|
||||
else "open"
|
||||
)
|
||||
status = str(observation.status or "").strip().lower()
|
||||
if (
|
||||
adjudication == "false_positive"
|
||||
or lifecycle == "resolved"
|
||||
or status in _RESOLVED_STATUSES
|
||||
):
|
||||
continue
|
||||
candidate = str(observation.risk_level or "medium").strip().lower()
|
||||
if candidate == "danger":
|
||||
candidate = "high"
|
||||
if candidate not in _RISK_ORDER:
|
||||
candidate = "medium"
|
||||
count += 1
|
||||
if _RISK_ORDER[candidate] > _RISK_ORDER[level]:
|
||||
level = candidate
|
||||
return level, count
|
||||
|
||||
@classmethod
|
||||
def _budget_usage_rate(cls, raw_flags: Any) -> float | None:
|
||||
values: list[float] = []
|
||||
flags = raw_flags if isinstance(raw_flags, list) else [raw_flags]
|
||||
for flag in flags:
|
||||
if not isinstance(flag, dict):
|
||||
continue
|
||||
root_containers = [
|
||||
flag,
|
||||
flag.get("metrics"),
|
||||
flag.get("budget_result"),
|
||||
(flag.get("route_decision") or {}).get("budget_result")
|
||||
if isinstance(flag.get("route_decision"), dict)
|
||||
else None,
|
||||
]
|
||||
containers = [item for item in root_containers if isinstance(item, dict)]
|
||||
containers.extend(
|
||||
item["metrics"]
|
||||
for item in list(containers)
|
||||
if isinstance(item.get("metrics"), dict)
|
||||
)
|
||||
for container in containers:
|
||||
for key in (
|
||||
"after_usage_rate",
|
||||
"budget_usage_rate",
|
||||
"usage_rate",
|
||||
"utilization_rate",
|
||||
):
|
||||
value = cls._float(container.get(key))
|
||||
if value is None:
|
||||
continue
|
||||
values.append(value * 100 if 0 < value <= 1 else value)
|
||||
return round(max(values), 2) if values else None
|
||||
|
||||
@classmethod
|
||||
def _evidence_summary(cls, claim: ExpenseClaim) -> ApprovalWorkbenchEvidenceRead:
|
||||
is_application = cls._is_application(claim)
|
||||
checks = [
|
||||
("事由", bool(str(claim.reason or "").strip())),
|
||||
("地点", bool(str(claim.location or "").strip())),
|
||||
("费用明细", bool(list(claim.items or []))),
|
||||
]
|
||||
if not is_application:
|
||||
checks.append(
|
||||
(
|
||||
"票据",
|
||||
int(claim.invoice_count or 0) > 0
|
||||
or any(str(item.invoice_id or "").strip() for item in claim.items or []),
|
||||
)
|
||||
)
|
||||
missing = [label for label, present in checks if not present]
|
||||
historical_labels = cls._historical_labels(claim.risk_flags_json)
|
||||
present_count = len(checks) - len(missing)
|
||||
return ApprovalWorkbenchEvidenceRead(
|
||||
completeness=round(present_count / len(checks), 4) if checks else 1,
|
||||
present_count=present_count,
|
||||
required_count=len(checks),
|
||||
missing_labels=missing,
|
||||
historical_labels=historical_labels,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _historical_labels(raw_flags: Any) -> list[str]:
|
||||
result: list[str] = []
|
||||
flags = raw_flags if isinstance(raw_flags, list) else [raw_flags]
|
||||
for flag in flags:
|
||||
if not isinstance(flag, dict):
|
||||
continue
|
||||
for item in list(flag.get("historical_case_evidence") or []):
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
label = str(item.get("label") or "").strip().lower()
|
||||
text = (
|
||||
"历史已确认,仅供复核"
|
||||
if label == "confirmed"
|
||||
else "历史误报,仅供复核"
|
||||
if label == "false_positive"
|
||||
else ""
|
||||
)
|
||||
if text and text not in result:
|
||||
result.append(text)
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _suggestion(
|
||||
*,
|
||||
risk_level: str,
|
||||
open_risk_count: int,
|
||||
budget_usage_rate: float | None,
|
||||
evidence: ApprovalWorkbenchEvidenceRead,
|
||||
) -> ApprovalWorkbenchSuggestionRead:
|
||||
if risk_level in {"high", "critical"} and open_risk_count:
|
||||
return ApprovalWorkbenchSuggestionRead(
|
||||
action="manual_review",
|
||||
label="先核对风险再决策",
|
||||
reason="存在高风险关注项,AI 不建议直接通过。",
|
||||
)
|
||||
if evidence.missing_labels:
|
||||
return ApprovalWorkbenchSuggestionRead(
|
||||
action="request_supplement",
|
||||
label="建议退回补充材料",
|
||||
reason="缺少" + "、".join(evidence.missing_labels) + "。",
|
||||
)
|
||||
if budget_usage_rate is not None and budget_usage_rate >= 90:
|
||||
return ApprovalWorkbenchSuggestionRead(
|
||||
action="budget_review",
|
||||
label="重点复核预算影响",
|
||||
reason="审批后预算占用已达到复核线。",
|
||||
)
|
||||
return ApprovalWorkbenchSuggestionRead(
|
||||
action="approve_candidate",
|
||||
label="可人工确认后通过",
|
||||
reason="材料相对完整且未发现高风险阻断项。",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _is_application(claim: ExpenseClaim) -> bool:
|
||||
claim_no = str(claim.claim_no or "").strip().upper()
|
||||
expense_type = str(claim.expense_type or "").strip().lower()
|
||||
return (
|
||||
claim_no.startswith(("AP-", "APP-"))
|
||||
or expense_type == "application"
|
||||
or expense_type.endswith("_application")
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _aware(value: datetime) -> datetime:
|
||||
return value if value.tzinfo is not None else value.replace(tzinfo=UTC)
|
||||
|
||||
@staticmethod
|
||||
def _decimal(value: Any) -> Decimal:
|
||||
try:
|
||||
return Decimal(str(value or "0"))
|
||||
except (InvalidOperation, ValueError):
|
||||
return Decimal("0")
|
||||
|
||||
@staticmethod
|
||||
def _float(value: Any) -> float | None:
|
||||
try:
|
||||
number = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return number if number >= 0 else None
|
||||
120
server/src/app/services/expense_claim_action_protocol.py
Normal file
120
server/src/app/services/expense_claim_action_protocol.py
Normal file
@@ -0,0 +1,120 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Mapping
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.api.deps import CurrentUserContext
|
||||
from app.models.approval_action import ApprovalActionLedger
|
||||
from app.models.financial_record import ExpenseClaim
|
||||
from app.services.approval_action_protocol import ApprovalActionProtocol
|
||||
from app.services.expense_claim_tenant_scope import ExpenseClaimTenantScopeMixin
|
||||
|
||||
ClaimActionExecutor = Callable[
|
||||
[ExpenseClaim, ApprovalActionLedger, str],
|
||||
ExpenseClaim,
|
||||
]
|
||||
|
||||
|
||||
class ExpenseClaimActionProtocolMixin:
|
||||
def _execute_claim_action(
|
||||
self,
|
||||
*,
|
||||
action: str,
|
||||
claim_id: str,
|
||||
current_user: CurrentUserContext,
|
||||
request_id: str | None,
|
||||
expected_status: str | None,
|
||||
expected_approval_stage: str | None,
|
||||
payload: Mapping[str, Any],
|
||||
executor: ClaimActionExecutor,
|
||||
) -> ExpenseClaim | None:
|
||||
protocol = ApprovalActionProtocol(self.db)
|
||||
tenant_id = ExpenseClaimTenantScopeMixin.normalize_tenant_id(current_user.tenant_id)
|
||||
actor_id = str(current_user.username or "").strip().casefold()
|
||||
with protocol.serialize_request(
|
||||
tenant_id=tenant_id,
|
||||
actor_id=actor_id,
|
||||
request_id=request_id,
|
||||
) as normalized_request_id:
|
||||
try:
|
||||
started = protocol.begin(
|
||||
action=action,
|
||||
claim_id=claim_id,
|
||||
current_user=current_user,
|
||||
request_id=normalized_request_id,
|
||||
expected_status=expected_status,
|
||||
expected_approval_stage=expected_approval_stage,
|
||||
payload=payload,
|
||||
claim_loader=lambda: self._load_claim_for_action(
|
||||
claim_id,
|
||||
current_user,
|
||||
),
|
||||
replay_claim_loader=lambda: self._load_claim_for_replay(
|
||||
claim_id,
|
||||
current_user,
|
||||
),
|
||||
)
|
||||
if started.claim is None:
|
||||
self.db.rollback()
|
||||
return None
|
||||
if started.replayed:
|
||||
self.db.commit()
|
||||
self.db.refresh(started.claim)
|
||||
return self._access_policy.attach_approval_snapshot(started.claim)
|
||||
if started.ledger is None: # pragma: no cover - defensive invariant
|
||||
raise RuntimeError("审批动作账本初始化失败。")
|
||||
|
||||
claim = executor(
|
||||
started.claim,
|
||||
started.ledger,
|
||||
normalized_request_id,
|
||||
)
|
||||
protocol.complete(
|
||||
started.ledger,
|
||||
claim,
|
||||
response_json={
|
||||
"claim_id": claim.id,
|
||||
"status": str(claim.status or "").strip(),
|
||||
"approval_stage": str(claim.approval_stage or "").strip(),
|
||||
},
|
||||
)
|
||||
self.db.commit()
|
||||
self.db.refresh(claim)
|
||||
return self._access_policy.attach_approval_snapshot(claim)
|
||||
except Exception:
|
||||
self.db.rollback()
|
||||
raise
|
||||
|
||||
def _load_claim_for_action(
|
||||
self,
|
||||
claim_id: str,
|
||||
current_user: CurrentUserContext,
|
||||
) -> ExpenseClaim | None:
|
||||
stmt = select(ExpenseClaim).where(ExpenseClaim.id == claim_id)
|
||||
stmt = self._access_policy.apply_claim_scope(
|
||||
stmt,
|
||||
current_user,
|
||||
include_approval_scope=True,
|
||||
)
|
||||
bind = self.db.get_bind()
|
||||
if bind is not None and bind.dialect.name == "postgresql":
|
||||
stmt = stmt.with_for_update()
|
||||
claim = self.db.scalar(stmt)
|
||||
if claim is not None:
|
||||
# 动作协议已经持有事务级请求锁和 Claim 行锁;这里只允许在同一
|
||||
# 事务内修正对象,不能调用会自行 commit 的读取兼容修复入口。
|
||||
self._repair_duplicate_budget_approval_stage(claim)
|
||||
return claim
|
||||
|
||||
def _load_claim_for_replay(
|
||||
self,
|
||||
claim_id: str,
|
||||
current_user: CurrentUserContext,
|
||||
) -> ExpenseClaim | None:
|
||||
stmt = select(ExpenseClaim).where(
|
||||
ExpenseClaim.id == claim_id,
|
||||
ExpenseClaimTenantScopeMixin.build_claim_tenant_condition(current_user.tenant_id),
|
||||
)
|
||||
return self.db.scalar(stmt)
|
||||
@@ -6,8 +6,14 @@ from decimal import Decimal, InvalidOperation
|
||||
from typing import Any
|
||||
|
||||
from app.api.deps import CurrentUserContext
|
||||
from app.models.approval_action import ApprovalActionLedger
|
||||
from app.models.financial_record import ExpenseClaim
|
||||
from app.services.budget import BudgetService
|
||||
from app.services.expense_claim_risk_gate import ExpenseClaimRiskGate
|
||||
from app.services.expense_claim_risk_stage import (
|
||||
risk_business_stage_for_claim,
|
||||
with_risk_business_stage,
|
||||
)
|
||||
from app.services.expense_claim_workflow_constants import (
|
||||
APPLICATION_LINK_STATUS_STAGE,
|
||||
BUDGET_MANAGER_APPROVAL_STAGE,
|
||||
@@ -18,10 +24,6 @@ from app.services.expense_claim_workflow_constants import (
|
||||
PAYMENT_PENDING_STAGE,
|
||||
PAYMENT_PENDING_STATUS,
|
||||
)
|
||||
from app.services.expense_claim_risk_stage import (
|
||||
risk_business_stage_for_claim,
|
||||
with_risk_business_stage,
|
||||
)
|
||||
|
||||
|
||||
class ExpenseClaimApprovalFlowMixin:
|
||||
@@ -31,10 +33,37 @@ class ExpenseClaimApprovalFlowMixin:
|
||||
current_user: CurrentUserContext,
|
||||
*,
|
||||
opinion: str | None = None,
|
||||
request_id: str | None = None,
|
||||
expected_status: str | None = None,
|
||||
expected_approval_stage: str | None = None,
|
||||
):
|
||||
claim = self.get_claim(claim_id, current_user)
|
||||
if claim is None:
|
||||
return None
|
||||
normalized_opinion = str(opinion or "").strip()
|
||||
return self._execute_claim_action(
|
||||
action="approve",
|
||||
claim_id=claim_id,
|
||||
current_user=current_user,
|
||||
request_id=request_id,
|
||||
expected_status=expected_status,
|
||||
expected_approval_stage=expected_approval_stage,
|
||||
payload={"opinion": normalized_opinion},
|
||||
executor=lambda claim, ledger, normalized_request_id: self._approve_claim_once(
|
||||
claim,
|
||||
current_user,
|
||||
opinion=normalized_opinion,
|
||||
ledger=ledger,
|
||||
request_id=normalized_request_id,
|
||||
),
|
||||
)
|
||||
|
||||
def _approve_claim_once(
|
||||
self,
|
||||
claim: ExpenseClaim,
|
||||
current_user: CurrentUserContext,
|
||||
*,
|
||||
opinion: str,
|
||||
ledger: ApprovalActionLedger,
|
||||
request_id: str,
|
||||
) -> ExpenseClaim:
|
||||
|
||||
normalized_status = str(claim.status or "").strip().lower()
|
||||
if normalized_status != "submitted":
|
||||
@@ -50,8 +79,11 @@ class ExpenseClaimApprovalFlowMixin:
|
||||
if previous_stage == DIRECT_MANAGER_APPROVAL_STAGE:
|
||||
if not self._access_policy.can_approve_claim(current_user, claim):
|
||||
raise ValueError("只有当前直属领导审批人可以审批通过该单据。")
|
||||
self._ensure_claim_has_no_blocking_risk(claim, current_user)
|
||||
approval_source = "manual_approval"
|
||||
event_type = "expense_application_approval" if is_application_claim else "expense_claim_approval"
|
||||
event_type = (
|
||||
"expense_application_approval" if is_application_claim else "expense_claim_approval"
|
||||
)
|
||||
label = "领导审批通过"
|
||||
route_decision_flag = self._build_approval_route_decision(
|
||||
claim,
|
||||
@@ -67,18 +99,29 @@ class ExpenseClaimApprovalFlowMixin:
|
||||
label = "领导及预算审核通过"
|
||||
next_status = "approved"
|
||||
next_stage = APPLICATION_LINK_STATUS_STAGE
|
||||
default_message = "{operator} 已完成直属领导和预算管理者审核,申请流程完成并生成报销草稿。"
|
||||
default_message = (
|
||||
"{operator} 已完成直属领导和预算管理者审核,申请流程完成并生成报销草稿。"
|
||||
)
|
||||
elif requires_budget_review:
|
||||
next_budget_manager = self._access_policy.resolve_department_budget_manager(claim)
|
||||
next_budget_manager = self._access_policy.resolve_department_budget_manager(
|
||||
claim
|
||||
)
|
||||
if next_budget_manager is None:
|
||||
raise ValueError("未找到同部门 P8 预算审批人,无法流转预算审批。请先配置预算审批人。")
|
||||
raise ValueError(
|
||||
"未找到同部门 P8 预算审批人,无法流转预算审批。请先配置预算审批人。"
|
||||
)
|
||||
next_status = "submitted"
|
||||
next_stage = BUDGET_MANAGER_APPROVAL_STAGE
|
||||
default_message = "{operator} 已确认直属领导审核,因预算或风险关注项流转至预算管理者审批。"
|
||||
default_message = (
|
||||
"{operator} 已确认直属领导审核,因预算或风险关注项流转至预算管理者审批。"
|
||||
)
|
||||
else:
|
||||
next_status = "approved"
|
||||
next_stage = APPLICATION_LINK_STATUS_STAGE
|
||||
default_message = "{operator} 已确认直属领导审核,系统判断预算充足且无风险,申请流程完成并生成报销草稿。"
|
||||
default_message = (
|
||||
"{operator} 已确认直属领导审核,系统判断预算充足且无风险,"
|
||||
"申请流程完成并生成报销草稿。"
|
||||
)
|
||||
else:
|
||||
merged_budget_approval = (
|
||||
requires_budget_review
|
||||
@@ -88,21 +131,32 @@ class ExpenseClaimApprovalFlowMixin:
|
||||
label = "领导及预算审核通过"
|
||||
next_status = "submitted"
|
||||
next_stage = FINANCE_APPROVAL_STAGE
|
||||
default_message = "{operator} 已完成直属领导和预算管理者审核,流转至{next_stage}。"
|
||||
default_message = (
|
||||
"{operator} 已完成直属领导和预算管理者审核,流转至{next_stage}。"
|
||||
)
|
||||
elif requires_budget_review:
|
||||
next_budget_manager = self._access_policy.resolve_department_budget_manager(claim)
|
||||
next_budget_manager = self._access_policy.resolve_department_budget_manager(
|
||||
claim
|
||||
)
|
||||
if next_budget_manager is None:
|
||||
raise ValueError("未找到同部门 P8 预算审批人,无法流转预算审批。请先配置预算审批人。")
|
||||
raise ValueError(
|
||||
"未找到同部门 P8 预算审批人,无法流转预算审批。请先配置预算审批人。"
|
||||
)
|
||||
next_status = "submitted"
|
||||
next_stage = BUDGET_MANAGER_APPROVAL_STAGE
|
||||
default_message = "{operator} 已审批通过,因预算或风险关注项流转至预算管理者审批。"
|
||||
default_message = (
|
||||
"{operator} 已审批通过,因预算或风险关注项流转至预算管理者审批。"
|
||||
)
|
||||
else:
|
||||
next_status = "submitted"
|
||||
next_stage = FINANCE_APPROVAL_STAGE
|
||||
default_message = "{operator} 已审批通过,系统判断预算充足且无风险,流转至{next_stage}。"
|
||||
default_message = (
|
||||
"{operator} 已审批通过,系统判断预算充足且无风险,流转至{next_stage}。"
|
||||
)
|
||||
elif previous_stage == BUDGET_MANAGER_APPROVAL_STAGE:
|
||||
if not self._access_policy.can_approve_claim(current_user, claim):
|
||||
raise ValueError("只有当前预算管理者可以审批通过该单据。")
|
||||
self._ensure_claim_has_no_blocking_risk(claim, current_user)
|
||||
approval_source = "budget_approval"
|
||||
event_type = (
|
||||
"expense_application_budget_approval"
|
||||
@@ -123,6 +177,7 @@ class ExpenseClaimApprovalFlowMixin:
|
||||
raise ValueError("费用申请需先完成预算管理者审批。")
|
||||
if not self._access_policy.can_approve_claim(current_user, claim):
|
||||
raise ValueError("只有财务人员可以完成财务终审。")
|
||||
self._ensure_claim_has_no_blocking_risk(claim, current_user)
|
||||
approval_source = "finance_approval"
|
||||
event_type = "expense_claim_finance_approval"
|
||||
label = "财务审核通过"
|
||||
@@ -146,7 +201,10 @@ class ExpenseClaimApprovalFlowMixin:
|
||||
and not approval_opinion
|
||||
):
|
||||
raise ValueError("预算已超过警戒值,预算管理者需填写审批意见后才能通过。")
|
||||
if previous_stage in {DIRECT_MANAGER_APPROVAL_STAGE, BUDGET_MANAGER_APPROVAL_STAGE} and not approval_opinion:
|
||||
if (
|
||||
previous_stage in {DIRECT_MANAGER_APPROVAL_STAGE, BUDGET_MANAGER_APPROVAL_STAGE}
|
||||
and not approval_opinion
|
||||
):
|
||||
approval_opinion = "同意"
|
||||
|
||||
before_json = self._serialize_claim(claim)
|
||||
@@ -163,7 +221,8 @@ class ExpenseClaimApprovalFlowMixin:
|
||||
"approval_event_id": str(uuid.uuid4()),
|
||||
"severity": "info",
|
||||
"label": label,
|
||||
"message": approval_opinion or default_message.format(operator=operator, next_stage=next_stage),
|
||||
"message": approval_opinion
|
||||
or default_message.format(operator=operator, next_stage=next_stage),
|
||||
"opinion": approval_opinion,
|
||||
"operator": operator,
|
||||
"operator_username": current_user.username,
|
||||
@@ -193,8 +252,10 @@ class ExpenseClaimApprovalFlowMixin:
|
||||
"next_approver_name": str(next_budget_manager.name or "").strip(),
|
||||
"next_approver_employee_id": next_budget_manager.id,
|
||||
"next_approver_grade": str(next_budget_manager.grade or "").strip(),
|
||||
"next_approver_role_code": self._access_policy.resolve_budget_approval_role_code(
|
||||
next_budget_manager,
|
||||
"next_approver_role_code": (
|
||||
self._access_policy.resolve_budget_approval_role_code(
|
||||
next_budget_manager,
|
||||
)
|
||||
),
|
||||
}
|
||||
)
|
||||
@@ -299,10 +360,6 @@ class ExpenseClaimApprovalFlowMixin:
|
||||
update_case_state=False,
|
||||
)
|
||||
|
||||
self.db.commit()
|
||||
self.db.refresh(claim)
|
||||
self._access_policy.attach_budget_approval_snapshot(claim)
|
||||
|
||||
self.audit_service.log_action(
|
||||
actor=operator,
|
||||
action="expense_claim.approve",
|
||||
@@ -310,18 +367,55 @@ class ExpenseClaimApprovalFlowMixin:
|
||||
resource_id=claim.id,
|
||||
before_json=before_json,
|
||||
after_json=self._serialize_claim(claim),
|
||||
request_id=request_id,
|
||||
commit=False,
|
||||
)
|
||||
|
||||
return claim
|
||||
|
||||
def _ensure_claim_has_no_blocking_risk(
|
||||
self,
|
||||
claim: ExpenseClaim,
|
||||
current_user: CurrentUserContext,
|
||||
) -> None:
|
||||
ExpenseClaimRiskGate(self.db).ensure_approvable(
|
||||
claim,
|
||||
tenant_id=current_user.tenant_id,
|
||||
)
|
||||
|
||||
def mark_claim_paid(
|
||||
self,
|
||||
claim_id: str,
|
||||
current_user: CurrentUserContext,
|
||||
*,
|
||||
request_id: str | None = None,
|
||||
expected_status: str | None = None,
|
||||
expected_approval_stage: str | None = None,
|
||||
):
|
||||
claim = self.get_claim(claim_id, current_user)
|
||||
if claim is None:
|
||||
return None
|
||||
return self._execute_claim_action(
|
||||
action="pay",
|
||||
claim_id=claim_id,
|
||||
current_user=current_user,
|
||||
request_id=request_id,
|
||||
expected_status=expected_status,
|
||||
expected_approval_stage=expected_approval_stage,
|
||||
payload={},
|
||||
executor=lambda claim, ledger, normalized_request_id: self._mark_claim_paid_once(
|
||||
claim,
|
||||
current_user,
|
||||
ledger=ledger,
|
||||
request_id=normalized_request_id,
|
||||
),
|
||||
)
|
||||
|
||||
def _mark_claim_paid_once(
|
||||
self,
|
||||
claim: ExpenseClaim,
|
||||
current_user: CurrentUserContext,
|
||||
*,
|
||||
ledger: ApprovalActionLedger,
|
||||
request_id: str,
|
||||
) -> ExpenseClaim:
|
||||
|
||||
normalized_status = str(claim.status or "").strip().lower()
|
||||
if normalized_status == PAYMENT_PAID_STATUS:
|
||||
@@ -412,9 +506,6 @@ class ExpenseClaimApprovalFlowMixin:
|
||||
update_case_state=False,
|
||||
)
|
||||
|
||||
self.db.commit()
|
||||
self.db.refresh(claim)
|
||||
|
||||
self.audit_service.log_action(
|
||||
actor=operator,
|
||||
action="expense_claim.mark_paid",
|
||||
@@ -422,6 +513,8 @@ class ExpenseClaimApprovalFlowMixin:
|
||||
resource_id=claim.id,
|
||||
before_json=before_json,
|
||||
after_json=self._serialize_claim(claim),
|
||||
request_id=request_id,
|
||||
commit=False,
|
||||
)
|
||||
|
||||
return claim
|
||||
@@ -440,7 +533,9 @@ class ExpenseClaimApprovalFlowMixin:
|
||||
|
||||
def _budget_approval_opinion_required(self, claim) -> bool:
|
||||
budget_result = BudgetService(self.db).analyze_claim_budget(claim)
|
||||
metrics = budget_result.get("metrics") if isinstance(budget_result.get("metrics"), dict) else {}
|
||||
metrics = (
|
||||
budget_result.get("metrics") if isinstance(budget_result.get("metrics"), dict) else {}
|
||||
)
|
||||
context = (
|
||||
budget_result.get("budget_context")
|
||||
if isinstance(budget_result.get("budget_context"), dict)
|
||||
|
||||
@@ -30,7 +30,6 @@ class ExpenseClaimPaginationMixin:
|
||||
)
|
||||
stmt = self._access_policy.apply_claim_scope(stmt, current_user)
|
||||
result = paginate_select(self.db, stmt, page=page, page_size=page_size)
|
||||
self._repair_duplicate_budget_approval_stages(result.items)
|
||||
self._access_policy.attach_budget_approval_snapshots(result.items)
|
||||
return result
|
||||
|
||||
@@ -47,7 +46,6 @@ class ExpenseClaimPaginationMixin:
|
||||
)
|
||||
stmt = self._access_policy.apply_approval_claim_scope(stmt, current_user)
|
||||
result = paginate_select(self.db, stmt, page=page, page_size=page_size)
|
||||
self._repair_duplicate_budget_approval_stages(result.items)
|
||||
self._access_policy.attach_budget_approval_snapshots(result.items)
|
||||
return result
|
||||
|
||||
|
||||
187
server/src/app/services/expense_claim_return_flow.py
Normal file
187
server/src/app/services/expense_claim_return_flow.py
Normal file
@@ -0,0 +1,187 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from app.api.deps import CurrentUserContext
|
||||
from app.models.approval_action import ApprovalActionLedger
|
||||
from app.models.financial_record import ExpenseClaim
|
||||
from app.services.expense_claim_constants import RETURN_REASON_OPTIONS
|
||||
|
||||
|
||||
class ExpenseClaimReturnFlowMixin:
|
||||
def return_claim(
|
||||
self,
|
||||
claim_id: str,
|
||||
current_user: CurrentUserContext,
|
||||
*,
|
||||
reason: str | None = None,
|
||||
reason_codes: list[str] | None = None,
|
||||
request_id: str | None = None,
|
||||
expected_status: str | None = None,
|
||||
expected_approval_stage: str | None = None,
|
||||
) -> ExpenseClaim | None:
|
||||
normalized_reason = str(reason or "").strip()
|
||||
reason_code_payload = self._normalize_return_reason_code_payload(reason_codes)
|
||||
normalized_codes = [
|
||||
*reason_code_payload["reason_codes"],
|
||||
*reason_code_payload["unknown_reason_codes"],
|
||||
]
|
||||
return self._execute_claim_action(
|
||||
action="return",
|
||||
claim_id=claim_id,
|
||||
current_user=current_user,
|
||||
request_id=request_id,
|
||||
expected_status=expected_status,
|
||||
expected_approval_stage=expected_approval_stage,
|
||||
payload={"reason": normalized_reason, "reason_codes": normalized_codes},
|
||||
executor=lambda claim, ledger, normalized_request_id: self._return_claim_once(
|
||||
claim,
|
||||
current_user,
|
||||
reason=normalized_reason,
|
||||
reason_codes=normalized_codes,
|
||||
ledger=ledger,
|
||||
request_id=normalized_request_id,
|
||||
),
|
||||
)
|
||||
|
||||
def _return_claim_once(
|
||||
self,
|
||||
claim: ExpenseClaim,
|
||||
current_user: CurrentUserContext,
|
||||
*,
|
||||
reason: str,
|
||||
reason_codes: list[str],
|
||||
ledger: ApprovalActionLedger,
|
||||
request_id: str,
|
||||
) -> ExpenseClaim:
|
||||
normalized_status = str(claim.status or "").strip().lower()
|
||||
if normalized_status == "draft":
|
||||
raise ValueError("草稿状态无需退回。")
|
||||
if normalized_status == "returned":
|
||||
raise ValueError("该单据已处于退回待提交状态,无需重复退回。")
|
||||
if normalized_status in {"approved", "completed", "paid"}:
|
||||
raise ValueError("已完成单据不允许退回。")
|
||||
|
||||
if not self._access_policy.can_return_claim(current_user, claim):
|
||||
raise ValueError("只有财务人员、高级财务人员或当前审批人可以退回报销单。")
|
||||
|
||||
before_json = self._serialize_claim(claim)
|
||||
operator = self._access_policy.resolve_current_user_display_name(current_user)
|
||||
previous_status = str(claim.status or "").strip()
|
||||
previous_stage = str(claim.approval_stage or "").strip() or "未标记审批环节"
|
||||
previous_stage_key = self._normalize_return_stage_key(previous_stage)
|
||||
is_application_claim = self._is_expense_application_claim(claim)
|
||||
is_direct_manager_return = previous_stage_key == "direct_manager"
|
||||
is_budget_return = previous_stage_key == "budget"
|
||||
is_application_return = is_application_claim and (
|
||||
is_direct_manager_return or is_budget_return
|
||||
)
|
||||
return_event_type = (
|
||||
"expense_application_return" if is_application_return else "expense_claim_return"
|
||||
)
|
||||
return_label = (
|
||||
"领导退回"
|
||||
if is_application_claim and is_direct_manager_return
|
||||
else "预算退回"
|
||||
if is_application_claim and is_budget_return
|
||||
else "人工退回"
|
||||
)
|
||||
reason_code_payload = self._normalize_return_reason_code_payload(reason_codes)
|
||||
normalized_reason_codes = reason_code_payload["reason_codes"]
|
||||
unknown_reason_codes = reason_code_payload["unknown_reason_codes"]
|
||||
if is_application_return and not any(
|
||||
code.startswith("application_") for code in normalized_reason_codes
|
||||
):
|
||||
raise ValueError("申请单退回必须选择至少一个退单类型。")
|
||||
risk_points = [RETURN_REASON_OPTIONS[code] for code in normalized_reason_codes]
|
||||
existing_return_flags = self._collect_return_flags(claim.risk_flags_json)
|
||||
return_count = len(existing_return_flags) + 1
|
||||
stage_return_count = (
|
||||
sum(
|
||||
1
|
||||
for flag in existing_return_flags
|
||||
if (
|
||||
str(flag.get("return_stage_key") or "").strip()
|
||||
or self._normalize_return_stage_key(str(flag.get("return_stage") or "").strip())
|
||||
)
|
||||
== previous_stage_key
|
||||
)
|
||||
+ 1
|
||||
)
|
||||
message = reason or self._build_default_return_message(
|
||||
operator=operator,
|
||||
risk_points=risk_points,
|
||||
)
|
||||
return_flag = {
|
||||
"source": "manual_return",
|
||||
"event_type": return_event_type,
|
||||
"return_event_id": str(uuid.uuid4()),
|
||||
"severity": "medium",
|
||||
"label": return_label,
|
||||
"node_key": "returned",
|
||||
"node_label": "退回",
|
||||
"approval_node": "退回",
|
||||
"message": message,
|
||||
"reason": reason,
|
||||
"opinion": message,
|
||||
"leader_opinion": message if is_application_claim and is_direct_manager_return else "",
|
||||
"budget_opinion": message if is_application_claim and is_budget_return else "",
|
||||
"reason_codes": normalized_reason_codes,
|
||||
"risk_points": risk_points,
|
||||
"operator": operator,
|
||||
"operator_username": current_user.username,
|
||||
"operator_role_codes": [
|
||||
str(item).strip().lower() for item in current_user.role_codes if str(item).strip()
|
||||
],
|
||||
"previous_status": previous_status,
|
||||
"previous_approval_stage": previous_stage,
|
||||
"return_stage": previous_stage,
|
||||
"return_stage_key": previous_stage_key,
|
||||
"next_status": "returned",
|
||||
"next_approval_stage": "待提交",
|
||||
"return_count": return_count,
|
||||
"stage_return_count": stage_return_count,
|
||||
"created_at": datetime.now(UTC).isoformat(),
|
||||
}
|
||||
if unknown_reason_codes:
|
||||
return_flag["unknown_reason_codes"] = unknown_reason_codes
|
||||
|
||||
budget_flags = self._release_budget_for_return(
|
||||
claim,
|
||||
current_user,
|
||||
reason=message,
|
||||
)
|
||||
claim.status = "returned"
|
||||
claim.approval_stage = "待提交"
|
||||
claim.submitted_at = None
|
||||
claim.risk_flags_json = self._append_budget_flags(
|
||||
[*list(claim.risk_flags_json or []), return_flag],
|
||||
budget_flags,
|
||||
business_stage=("expense_application" if is_application_claim else "reimbursement"),
|
||||
)
|
||||
|
||||
self._expense_cases.record_claim_event(
|
||||
claim,
|
||||
event_type=("application_returned" if is_application_claim else "claim_returned"),
|
||||
actor_id=current_user.username,
|
||||
tenant_id=getattr(current_user, "tenant_id", None),
|
||||
idempotency_key=str(return_flag.get("return_event_id") or ""),
|
||||
previous_status=previous_status,
|
||||
previous_approval_stage=previous_stage,
|
||||
extra_payload={
|
||||
"reason": message,
|
||||
"reason_codes": normalized_reason_codes,
|
||||
},
|
||||
)
|
||||
self.audit_service.log_action(
|
||||
actor=operator,
|
||||
action="expense_claim.return",
|
||||
resource_type="expense_claim",
|
||||
resource_id=claim.id,
|
||||
before_json=before_json,
|
||||
after_json=self._serialize_claim(claim),
|
||||
request_id=request_id,
|
||||
commit=False,
|
||||
)
|
||||
return claim
|
||||
@@ -2,7 +2,6 @@ from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
_SEVERITY_WEIGHT = {
|
||||
"critical": 0,
|
||||
"high": 1,
|
||||
@@ -12,6 +11,48 @@ _SEVERITY_WEIGHT = {
|
||||
"pass": 5,
|
||||
}
|
||||
|
||||
_BLOCKING_RISK_SEVERITIES = {"high", "critical", "danger"}
|
||||
_NON_RISK_SOURCES = {
|
||||
"application_detail",
|
||||
"application_handoff",
|
||||
"application_link",
|
||||
"application_link_sync",
|
||||
"application_submission",
|
||||
"approval",
|
||||
"approval_log",
|
||||
"approval_routing",
|
||||
"budget_approval",
|
||||
"expense_claim_approval",
|
||||
"expense_claim_finance_approval",
|
||||
"finance_approval",
|
||||
"manual_approval",
|
||||
"manual_return",
|
||||
"payment",
|
||||
"reminder",
|
||||
"sla_reminder",
|
||||
"urge",
|
||||
}
|
||||
_NON_RISK_EVENTS = {
|
||||
"expense_application_budget_approval",
|
||||
"expense_application_reimbursement_deleted",
|
||||
"expense_application_submission",
|
||||
"expense_application_to_reimbursement_draft",
|
||||
"expense_claim_approval",
|
||||
"expense_claim_finance_approval",
|
||||
"expense_claim_payment_completed",
|
||||
"expense_reimbursement_application_linked",
|
||||
"reminder",
|
||||
"sla_reminder",
|
||||
"urge",
|
||||
}
|
||||
_CLOSED_RISK_STATES = {
|
||||
"accepted",
|
||||
"false_positive",
|
||||
"ignored",
|
||||
"resolved",
|
||||
"waived",
|
||||
}
|
||||
|
||||
|
||||
def _text(value: Any) -> str:
|
||||
return str(value or "").strip()
|
||||
@@ -22,6 +63,74 @@ def _severity_weight(flag: dict[str, Any]) -> int:
|
||||
return _SEVERITY_WEIGHT.get(severity, 9)
|
||||
|
||||
|
||||
def claim_risk_flag_severity(flag: dict[str, Any]) -> str:
|
||||
severity = _text(
|
||||
flag.get("severity")
|
||||
or flag.get("risk_level")
|
||||
or flag.get("riskLevel")
|
||||
or flag.get("tone")
|
||||
or flag.get("level")
|
||||
).lower()
|
||||
return "high" if severity == "danger" else severity
|
||||
|
||||
|
||||
def is_open_claim_risk_flag(flag: dict[str, Any]) -> bool:
|
||||
"""识别仍需处理的业务风险,排除审批、付款等流程轨迹。"""
|
||||
|
||||
source = _text(flag.get("source")).lower()
|
||||
event_type = _text(flag.get("event_type") or flag.get("eventType")).lower()
|
||||
if source in _NON_RISK_SOURCES or event_type in _NON_RISK_EVENTS:
|
||||
return False
|
||||
if _text(flag.get("actionability")).lower() == "system_trace":
|
||||
return False
|
||||
states = {
|
||||
_text(flag.get("resolution_status") or flag.get("resolutionStatus")).lower(),
|
||||
_text(flag.get("status")).lower(),
|
||||
_text(flag.get("feedback_status") or flag.get("feedbackStatus")).lower(),
|
||||
_text(flag.get("adjudication")).lower(),
|
||||
}
|
||||
if states & _CLOSED_RISK_STATES or bool(flag.get("resolved")):
|
||||
return False
|
||||
severity = claim_risk_flag_severity(flag)
|
||||
return bool(
|
||||
severity in {"medium", *_BLOCKING_RISK_SEVERITIES}
|
||||
or flag.get("triggered") is True
|
||||
or _text(flag.get("disposition")).lower() in {"fix", "review"}
|
||||
)
|
||||
|
||||
|
||||
def is_blocking_claim_risk_flag(flag: dict[str, Any]) -> bool:
|
||||
if not (
|
||||
is_open_claim_risk_flag(flag)
|
||||
and claim_risk_flag_severity(flag) in _BLOCKING_RISK_SEVERITIES
|
||||
):
|
||||
return False
|
||||
actionability = _text(flag.get("actionability")).lower()
|
||||
disposition = _text(flag.get("disposition")).lower()
|
||||
return actionability not in {"advisory_only", "route_review"} and disposition != "review"
|
||||
|
||||
|
||||
def claim_risk_flag_observation_key(
|
||||
flag: dict[str, Any],
|
||||
*,
|
||||
claim_id: str,
|
||||
) -> str:
|
||||
explicit_key = _text(flag.get("observation_key") or flag.get("observationKey"))
|
||||
if explicit_key:
|
||||
return explicit_key
|
||||
source = _text(flag.get("source")).lower()
|
||||
hit_source = _text(flag.get("hit_source") or flag.get("hitSource")).lower()
|
||||
rule_code = _text(flag.get("rule_code") or flag.get("ruleCode"))
|
||||
signal = _text(flag.get("risk_signal") or flag.get("riskSignal"))
|
||||
if source in {"platform_risk", "platform_risk_rule", "rule_center"} or hit_source == (
|
||||
"rule_center"
|
||||
):
|
||||
suffix = rule_code or signal
|
||||
if suffix:
|
||||
return f"risk:{claim_id}:platform:{suffix}"
|
||||
return ""
|
||||
|
||||
|
||||
def _list_values(value: Any) -> list[Any]:
|
||||
if isinstance(value, list):
|
||||
return value
|
||||
@@ -138,7 +247,9 @@ def dedupe_claim_risk_flags(flags: list[Any] | None) -> list[Any]:
|
||||
continue
|
||||
|
||||
other_weight = _severity_weight(other)
|
||||
if other_weight < current_weight or (other_weight == current_weight and other_index < index):
|
||||
if other_weight < current_weight or (
|
||||
other_weight == current_weight and other_index < index
|
||||
):
|
||||
is_shadowed = True
|
||||
break
|
||||
|
||||
|
||||
148
server/src/app/services/expense_claim_risk_gate.py
Normal file
148
server/src/app/services/expense_claim_risk_gate.py
Normal file
@@ -0,0 +1,148 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.financial_record import ExpenseClaim
|
||||
from app.models.risk_disposition import RiskDisposition
|
||||
from app.models.risk_observation import RiskObservation
|
||||
from app.services.expense_claim_risk_flags import (
|
||||
claim_risk_flag_observation_key,
|
||||
claim_risk_flag_severity,
|
||||
is_blocking_claim_risk_flag,
|
||||
)
|
||||
from app.services.expense_claim_tenant_scope import ExpenseClaimTenantScopeMixin
|
||||
|
||||
_BLOCKING_LEVELS = {"high", "critical", "danger"}
|
||||
_LEGACY_CLOSED_STATUSES = {"false_positive", "ignored", "resolved"}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class BlockingRiskObservation:
|
||||
observation_id: str
|
||||
title: str
|
||||
risk_level: str
|
||||
adjudication: str
|
||||
lifecycle_status: str
|
||||
|
||||
|
||||
class ExpenseClaimRiskBlockedError(ValueError):
|
||||
def __init__(self, blockers: list[BlockingRiskObservation]) -> None:
|
||||
self.blockers = blockers
|
||||
titles = "、".join(item.title for item in blockers[:3])
|
||||
suffix = f"等 {len(blockers)} 项" if len(blockers) > 3 else ""
|
||||
super().__init__(
|
||||
"存在尚未完成处置的高风险观察,暂不能审批通过:"
|
||||
f"{titles}{suffix}。请先在风险证据链中确认并关闭风险,或标记为误报。"
|
||||
)
|
||||
|
||||
|
||||
class ExpenseClaimRiskGate:
|
||||
"""把持久化风险处置状态变成审批前的强制业务门禁。"""
|
||||
|
||||
def __init__(self, db: Session) -> None:
|
||||
self.db = db
|
||||
|
||||
def find_blockers(
|
||||
self,
|
||||
claim: ExpenseClaim,
|
||||
*,
|
||||
tenant_id: str | None,
|
||||
) -> list[BlockingRiskObservation]:
|
||||
normalized_tenant = ExpenseClaimTenantScopeMixin.normalize_tenant_id(tenant_id)
|
||||
rows = self.db.execute(
|
||||
select(RiskObservation, RiskDisposition)
|
||||
.outerjoin(
|
||||
RiskDisposition,
|
||||
(
|
||||
(RiskDisposition.tenant_id == RiskObservation.tenant_id)
|
||||
& (RiskDisposition.observation_id == RiskObservation.id)
|
||||
),
|
||||
)
|
||||
.where(
|
||||
RiskObservation.tenant_id == normalized_tenant,
|
||||
RiskObservation.claim_id == claim.id,
|
||||
)
|
||||
.order_by(
|
||||
RiskObservation.risk_score.desc(),
|
||||
RiskObservation.created_at.desc(),
|
||||
)
|
||||
).all()
|
||||
|
||||
blockers: list[BlockingRiskObservation] = []
|
||||
materialized_keys: set[str] = set()
|
||||
for observation, disposition in rows:
|
||||
materialized_keys.add(str(observation.observation_key or "").strip())
|
||||
level = str(observation.risk_level or "").strip().lower()
|
||||
if level not in _BLOCKING_LEVELS:
|
||||
continue
|
||||
if self._is_closed(observation, disposition):
|
||||
continue
|
||||
blockers.append(
|
||||
BlockingRiskObservation(
|
||||
observation_id=observation.id,
|
||||
title=str(observation.title or observation.risk_signal or "高风险").strip(),
|
||||
risk_level=level,
|
||||
adjudication=(
|
||||
str(disposition.adjudication or "unreviewed").strip().lower()
|
||||
if disposition is not None
|
||||
else str(observation.feedback_status or "unreviewed").strip().lower()
|
||||
),
|
||||
lifecycle_status=(
|
||||
str(disposition.lifecycle_status or "open").strip().lower()
|
||||
if disposition is not None
|
||||
else "open"
|
||||
),
|
||||
)
|
||||
)
|
||||
for index, flag in enumerate(list(claim.risk_flags_json or [])):
|
||||
if not isinstance(flag, dict) or not is_blocking_claim_risk_flag(flag):
|
||||
continue
|
||||
observation_key = claim_risk_flag_observation_key(flag, claim_id=claim.id)
|
||||
if observation_key and observation_key in materialized_keys:
|
||||
# 完整物化后的处置投影优先;仅对没有 Observation 的高风险兜底。
|
||||
continue
|
||||
blockers.append(
|
||||
BlockingRiskObservation(
|
||||
observation_id=observation_key or f"raw:{claim.id}:{index}",
|
||||
title=str(
|
||||
flag.get("label")
|
||||
or flag.get("title")
|
||||
or flag.get("message")
|
||||
or "未物化高风险"
|
||||
).strip(),
|
||||
risk_level=claim_risk_flag_severity(flag),
|
||||
adjudication=str(flag.get("adjudication") or "unreviewed").strip().lower(),
|
||||
lifecycle_status=str(
|
||||
flag.get("lifecycle_status") or flag.get("lifecycleStatus") or "open"
|
||||
)
|
||||
.strip()
|
||||
.lower(),
|
||||
)
|
||||
)
|
||||
return blockers
|
||||
|
||||
def ensure_approvable(
|
||||
self,
|
||||
claim: ExpenseClaim,
|
||||
*,
|
||||
tenant_id: str | None,
|
||||
) -> None:
|
||||
blockers = self.find_blockers(claim, tenant_id=tenant_id)
|
||||
if blockers:
|
||||
raise ExpenseClaimRiskBlockedError(blockers)
|
||||
|
||||
@staticmethod
|
||||
def _is_closed(
|
||||
observation: RiskObservation,
|
||||
disposition: RiskDisposition | None,
|
||||
) -> bool:
|
||||
if disposition is not None:
|
||||
adjudication = str(disposition.adjudication or "").strip().lower()
|
||||
lifecycle = str(disposition.lifecycle_status or "").strip().lower()
|
||||
return adjudication == "false_positive" or lifecycle == "resolved"
|
||||
status = str(observation.status or "").strip().lower()
|
||||
feedback_status = str(observation.feedback_status or "").strip().lower()
|
||||
return status in _LEGACY_CLOSED_STATUSES or feedback_status == "false_positive"
|
||||
@@ -15,7 +15,10 @@ from app.services.expense_claim_constants import (
|
||||
from app.services.expense_claim_item_sync import ExpenseClaimItemSyncMixin
|
||||
from app.services.expense_claim_platform_risk import ExpenseClaimPlatformRiskMixin
|
||||
from app.services.expense_claim_policy_review import ExpenseClaimPolicyReviewMixin
|
||||
from app.services.expense_claim_risk_flags import dedupe_claim_risk_flags
|
||||
from app.services.expense_claim_risk_flags import (
|
||||
dedupe_claim_risk_flags,
|
||||
is_blocking_claim_risk_flag,
|
||||
)
|
||||
from app.services.expense_claim_risk_stage import with_risk_business_stage
|
||||
from app.services.expense_claim_tenant_scope import ExpenseClaimTenantScopeMixin
|
||||
from app.services.risk_observations import RiskObservationService
|
||||
@@ -98,11 +101,15 @@ class ExpenseClaimRiskReviewMixin(
|
||||
claim,
|
||||
platform_risk_flags,
|
||||
)
|
||||
except Exception:
|
||||
except Exception as error:
|
||||
logger.exception(
|
||||
"Failed to persist platform risk observations for claim_id=%s",
|
||||
claim.id,
|
||||
)
|
||||
if any(is_blocking_claim_risk_flag(flag) for flag in platform_risk_flags):
|
||||
raise RuntimeError(
|
||||
"高风险观察持久化失败,已停止提交;请稍后重试或联系管理员。"
|
||||
) from error
|
||||
|
||||
review_flags = [with_risk_business_stage(flag, "reimbursement") for flag in review_flags]
|
||||
final_risk_flags = dedupe_claim_risk_flags([*preserved_flags, *review_flags])
|
||||
@@ -111,9 +118,7 @@ class ExpenseClaimRiskReviewMixin(
|
||||
"status": "submitted",
|
||||
"approval_stage": "直属领导审批",
|
||||
"risk_flags": final_risk_flags,
|
||||
"rule_set_fingerprint": str(
|
||||
platform_risk_review.get("rule_set_fingerprint") or ""
|
||||
),
|
||||
"rule_set_fingerprint": str(platform_risk_review.get("rule_set_fingerprint") or ""),
|
||||
"message": (
|
||||
f"报销单 {claim.claim_no} 已完成自动检测,"
|
||||
f"现已提交给直属领导 {manager_name or '审批人'} 审批。"
|
||||
@@ -159,4 +164,3 @@ class ExpenseClaimRiskReviewMixin(
|
||||
)
|
||||
recent_claims = list(self.db.scalars(stmt).all())
|
||||
return sum(1 for item in recent_claims if list(item.risk_flags_json or []))
|
||||
|
||||
|
||||
@@ -4,7 +4,10 @@ from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from app.models.financial_record import ExpenseClaim
|
||||
from app.services.expense_claim_risk_stage import risk_business_stage_for_claim, with_risk_business_stage
|
||||
from app.services.expense_claim_risk_stage import (
|
||||
risk_business_stage_for_claim,
|
||||
with_risk_business_stage,
|
||||
)
|
||||
from app.services.expense_claim_workflow_constants import (
|
||||
BUDGET_MANAGER_APPROVAL_STAGE,
|
||||
DIRECT_MANAGER_APPROVAL_STAGE,
|
||||
@@ -13,18 +16,17 @@ from app.services.expense_claim_workflow_constants import (
|
||||
|
||||
|
||||
class ExpenseClaimWorkflowRepairMixin:
|
||||
def _repair_duplicate_budget_approval_stages(self, claims: list[ExpenseClaim]) -> None:
|
||||
repaired_claims = [
|
||||
def _repair_duplicate_budget_approval_stages(
|
||||
self,
|
||||
claims: list[ExpenseClaim],
|
||||
) -> list[ExpenseClaim]:
|
||||
"""只修改当前事务中的对象,不在底层辅助方法里提交事务。"""
|
||||
|
||||
return [
|
||||
claim
|
||||
for claim in claims
|
||||
if claim is not None and self._repair_duplicate_budget_approval_stage(claim)
|
||||
]
|
||||
if not repaired_claims:
|
||||
return
|
||||
|
||||
self.db.commit()
|
||||
for claim in repaired_claims:
|
||||
self.db.refresh(claim)
|
||||
|
||||
def _repair_duplicate_budget_approval_stage(self, claim: ExpenseClaim) -> bool:
|
||||
if self._is_expense_application_claim(claim):
|
||||
@@ -54,7 +56,8 @@ class ExpenseClaimWorkflowRepairMixin:
|
||||
if isinstance(flag, dict)
|
||||
and str(flag.get("source") or "").strip() == "manual_approval"
|
||||
and str(flag.get("event_type") or "").strip() == "expense_claim_approval"
|
||||
and str(flag.get("previous_approval_stage") or "").strip() == DIRECT_MANAGER_APPROVAL_STAGE
|
||||
and str(flag.get("previous_approval_stage") or "").strip()
|
||||
== DIRECT_MANAGER_APPROVAL_STAGE
|
||||
and str(flag.get("next_approval_stage") or "").strip() == BUDGET_MANAGER_APPROVAL_STAGE
|
||||
]
|
||||
for flag in reversed(flags):
|
||||
@@ -74,11 +77,14 @@ class ExpenseClaimWorkflowRepairMixin:
|
||||
return any(
|
||||
isinstance(flag, dict)
|
||||
and str(flag.get("source") or "").strip() == "approval_flow_repair"
|
||||
and str(flag.get("event_type") or "").strip() == "duplicate_budget_approval_stage_repaired"
|
||||
and str(flag.get("event_type") or "").strip()
|
||||
== "duplicate_budget_approval_stage_repaired"
|
||||
for flag in list(claim.risk_flags_json or [])
|
||||
)
|
||||
|
||||
def _build_duplicate_budget_stage_repair_flag(self, approval_event: dict[str, Any]) -> dict[str, Any]:
|
||||
def _build_duplicate_budget_stage_repair_flag(
|
||||
self, approval_event: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
return with_risk_business_stage(
|
||||
{
|
||||
"source": "approval_flow_repair",
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime
|
||||
from decimal import Decimal
|
||||
@@ -25,6 +24,7 @@ from app.services.budget_types import BudgetControlError
|
||||
from app.services.document_numbering import is_application_claim_no
|
||||
from app.services.expense_cases import ExpenseCaseService
|
||||
from app.services.expense_claim_access_policy import ExpenseClaimAccessPolicy
|
||||
from app.services.expense_claim_action_protocol import ExpenseClaimActionProtocolMixin
|
||||
from app.services.expense_claim_application_handoff import ExpenseClaimApplicationHandoffMixin
|
||||
from app.services.expense_claim_approval_flow import ExpenseClaimApprovalFlowMixin
|
||||
from app.services.expense_claim_approval_routing import ExpenseClaimApprovalRoutingMixin
|
||||
@@ -34,7 +34,6 @@ from app.services.expense_claim_attachment_operations import ExpenseClaimAttachm
|
||||
from app.services.expense_claim_attachment_presentation import ExpenseClaimAttachmentPresentation
|
||||
from app.services.expense_claim_attachment_storage import ExpenseClaimAttachmentStorage
|
||||
from app.services.expense_claim_budget_flow import ExpenseClaimBudgetFlowMixin
|
||||
from app.services.expense_claim_constants import RETURN_REASON_OPTIONS
|
||||
from app.services.expense_claim_document_item_builder import ExpenseClaimDocumentItemBuilderMixin
|
||||
from app.services.expense_claim_document_parsing import ExpenseClaimDocumentParsingMixin
|
||||
from app.services.expense_claim_draft_flow import ExpenseClaimDraftFlowMixin
|
||||
@@ -51,6 +50,7 @@ from app.services.expense_claim_pre_review_decision import (
|
||||
pre_review_public_payload,
|
||||
)
|
||||
from app.services.expense_claim_read_model import ExpenseClaimReadModelMixin
|
||||
from app.services.expense_claim_return_flow import ExpenseClaimReturnFlowMixin
|
||||
from app.services.expense_claim_review_preview import ExpenseClaimReviewPreviewMixin
|
||||
from app.services.expense_claim_risk_flags import dedupe_claim_risk_flags
|
||||
from app.services.expense_claim_risk_review import ExpenseClaimRiskReviewMixin
|
||||
@@ -87,7 +87,10 @@ class ExpenseClaimItemActionMixin:
|
||||
if payload.item_date is not None:
|
||||
item.item_date = payload.item_date
|
||||
if payload.item_type is not None:
|
||||
item.item_type = self._normalize_optional_text(payload.item_type, fallback=item.item_type) or item.item_type
|
||||
item.item_type = (
|
||||
self._normalize_optional_text(payload.item_type, fallback=item.item_type)
|
||||
or item.item_type
|
||||
)
|
||||
if payload.item_reason is not None:
|
||||
item.item_reason = (
|
||||
self._normalize_optional_text(payload.item_reason, allow_empty=True) or ""
|
||||
@@ -97,7 +100,9 @@ class ExpenseClaimItemActionMixin:
|
||||
self._normalize_optional_text(payload.item_location, allow_empty=True) or ""
|
||||
)
|
||||
if payload.item_note is not None:
|
||||
item.item_note = self._normalize_optional_text(payload.item_note, allow_empty=True) or ""
|
||||
item.item_note = (
|
||||
self._normalize_optional_text(payload.item_note, allow_empty=True) or ""
|
||||
)
|
||||
if payload.item_amount is not None:
|
||||
amount = payload.item_amount.quantize(Decimal("0.01"))
|
||||
if amount < Decimal("0.00"):
|
||||
@@ -203,7 +208,9 @@ class ExpenseClaimItemActionMixin:
|
||||
|
||||
self._ensure_draft_claim(claim)
|
||||
before_json = self._serialize_claim(claim)
|
||||
item_label = str(item.item_reason or "").strip() or self._resolve_expense_type_label(item.item_type)
|
||||
item_label = str(item.item_reason or "").strip() or self._resolve_expense_type_label(
|
||||
item.item_type
|
||||
)
|
||||
|
||||
self._attachment_storage.delete_item_files(item)
|
||||
claim.items = [entry for entry in claim.items if entry.id != item.id]
|
||||
@@ -266,8 +273,7 @@ class ExpenseClaimItemActionMixin:
|
||||
raise RuntimeError("无法生成提交前预审结果。")
|
||||
|
||||
client_review_provided = bool(
|
||||
str(pre_review_id or "").strip()
|
||||
or str(pre_review_input_fingerprint or "").strip()
|
||||
str(pre_review_id or "").strip() or str(pre_review_input_fingerprint or "").strip()
|
||||
)
|
||||
client_review_matches = pre_review_identity_matches(
|
||||
pre_review_flag,
|
||||
@@ -423,7 +429,9 @@ class ExpenseClaimItemActionMixin:
|
||||
if not self._access_policy.has_claim_delete_access(current_user):
|
||||
self._ensure_draft_claim(claim)
|
||||
if not self._access_policy.is_claim_owned_by_current_user(claim, current_user):
|
||||
raise ValueError("只有系统管理员或草稿、待补充、退回待提交阶段的申请人本人可以删除单据。")
|
||||
raise ValueError(
|
||||
"只有系统管理员或草稿、待补充、退回待提交阶段的申请人本人可以删除单据。"
|
||||
)
|
||||
|
||||
before_json = self._serialize_claim(claim)
|
||||
resource_id = claim.id
|
||||
@@ -464,154 +472,31 @@ class ExpenseClaimItemActionMixin:
|
||||
self.db.execute(delete(RiskObservation).where(RiskObservation.claim_id == claim_id))
|
||||
self.db.execute(delete(HermesRiskReport).where(HermesRiskReport.claim_id == claim_id))
|
||||
|
||||
def return_claim(
|
||||
self,
|
||||
claim_id: str,
|
||||
current_user: CurrentUserContext,
|
||||
*,
|
||||
reason: str | None = None,
|
||||
reason_codes: list[str] | None = None,
|
||||
) -> ExpenseClaim | None:
|
||||
claim = self.get_claim(claim_id, current_user)
|
||||
if claim is None:
|
||||
return None
|
||||
|
||||
normalized_status = str(claim.status or "").strip().lower()
|
||||
if normalized_status == "draft":
|
||||
raise ValueError("草稿状态无需退回。")
|
||||
if normalized_status == "returned":
|
||||
raise ValueError("该单据已处于退回待提交状态,无需重复退回。")
|
||||
if normalized_status in {"approved", "completed", "paid"}:
|
||||
raise ValueError("已完成单据不允许退回。")
|
||||
|
||||
if not self._access_policy.can_return_claim(current_user, claim):
|
||||
raise ValueError("只有财务人员、高级财务人员或当前审批人可以退回报销单。")
|
||||
|
||||
before_json = self._serialize_claim(claim)
|
||||
operator = self._access_policy.resolve_current_user_display_name(current_user)
|
||||
previous_status = str(claim.status or "").strip()
|
||||
previous_stage = str(claim.approval_stage or "").strip() or "未标记审批环节"
|
||||
previous_stage_key = self._normalize_return_stage_key(previous_stage)
|
||||
is_application_claim = self._is_expense_application_claim(claim)
|
||||
is_direct_manager_return = previous_stage_key == "direct_manager"
|
||||
is_budget_return = previous_stage_key == "budget"
|
||||
is_application_return = is_application_claim and (is_direct_manager_return or is_budget_return)
|
||||
return_event_type = (
|
||||
"expense_application_return"
|
||||
if is_application_return
|
||||
else "expense_claim_return"
|
||||
)
|
||||
return_label = (
|
||||
"领导退回"
|
||||
if is_application_claim and is_direct_manager_return
|
||||
else "预算退回"
|
||||
if is_application_claim and is_budget_return
|
||||
else "人工退回"
|
||||
)
|
||||
return_reason = str(reason or "").strip()
|
||||
reason_code_payload = self._normalize_return_reason_code_payload(reason_codes)
|
||||
normalized_reason_codes = reason_code_payload["reason_codes"]
|
||||
unknown_reason_codes = reason_code_payload["unknown_reason_codes"]
|
||||
if is_application_return and not any(
|
||||
code.startswith("application_") for code in normalized_reason_codes
|
||||
):
|
||||
raise ValueError("申请单退回必须选择至少一个退单类型。")
|
||||
risk_points = [RETURN_REASON_OPTIONS[code] for code in normalized_reason_codes]
|
||||
existing_return_flags = self._collect_return_flags(claim.risk_flags_json)
|
||||
return_count = len(existing_return_flags) + 1
|
||||
stage_return_count = (
|
||||
sum(
|
||||
1
|
||||
for flag in existing_return_flags
|
||||
if (
|
||||
str(flag.get("return_stage_key") or "").strip()
|
||||
or self._normalize_return_stage_key(str(flag.get("return_stage") or "").strip())
|
||||
)
|
||||
== previous_stage_key
|
||||
)
|
||||
+ 1
|
||||
)
|
||||
message = return_reason or self._build_default_return_message(operator=operator, risk_points=risk_points)
|
||||
return_flag = {
|
||||
"source": "manual_return",
|
||||
"event_type": return_event_type,
|
||||
"return_event_id": str(uuid.uuid4()),
|
||||
"severity": "medium",
|
||||
"label": return_label,
|
||||
"node_key": "returned",
|
||||
"node_label": "退回",
|
||||
"approval_node": "退回",
|
||||
"message": message,
|
||||
"reason": return_reason,
|
||||
"opinion": message,
|
||||
"leader_opinion": message if is_application_claim and is_direct_manager_return else "",
|
||||
"budget_opinion": message if is_application_claim and is_budget_return else "",
|
||||
"reason_codes": normalized_reason_codes,
|
||||
"risk_points": risk_points,
|
||||
"operator": operator,
|
||||
"operator_username": current_user.username,
|
||||
"operator_role_codes": [
|
||||
str(item).strip().lower()
|
||||
for item in current_user.role_codes
|
||||
if str(item).strip()
|
||||
],
|
||||
"previous_status": previous_status,
|
||||
"previous_approval_stage": previous_stage,
|
||||
"return_stage": previous_stage,
|
||||
"return_stage_key": previous_stage_key,
|
||||
"next_status": "returned",
|
||||
"next_approval_stage": "待提交",
|
||||
"return_count": return_count,
|
||||
"stage_return_count": stage_return_count,
|
||||
"created_at": datetime.now(UTC).isoformat(),
|
||||
}
|
||||
if unknown_reason_codes:
|
||||
return_flag["unknown_reason_codes"] = unknown_reason_codes
|
||||
|
||||
budget_flags = self._release_budget_for_return(
|
||||
claim,
|
||||
current_user,
|
||||
reason=message,
|
||||
)
|
||||
claim.status = "returned"
|
||||
claim.approval_stage = "待提交"
|
||||
claim.submitted_at = None
|
||||
claim.risk_flags_json = self._append_budget_flags(
|
||||
[*list(claim.risk_flags_json or []), return_flag],
|
||||
budget_flags,
|
||||
business_stage="expense_application" if is_application_claim else "reimbursement",
|
||||
)
|
||||
|
||||
self._expense_cases.record_claim_event(
|
||||
claim,
|
||||
event_type=("application_returned" if is_application_claim else "claim_returned"),
|
||||
actor_id=current_user.username,
|
||||
tenant_id=getattr(current_user, "tenant_id", None),
|
||||
idempotency_key=str(return_flag.get("return_event_id") or ""),
|
||||
previous_status=previous_status,
|
||||
previous_approval_stage=previous_stage,
|
||||
extra_payload={
|
||||
"reason": message,
|
||||
"reason_codes": normalized_reason_codes,
|
||||
},
|
||||
)
|
||||
|
||||
self.db.commit()
|
||||
self.db.refresh(claim)
|
||||
|
||||
self.audit_service.log_action(
|
||||
actor=operator,
|
||||
action="expense_claim.return",
|
||||
resource_type="expense_claim",
|
||||
resource_id=claim.id,
|
||||
before_json=before_json,
|
||||
after_json=self._serialize_claim(claim),
|
||||
)
|
||||
|
||||
return claim
|
||||
|
||||
|
||||
class ExpenseClaimService(ExpenseClaimStandardAdjustmentMixin, ExpenseClaimItemActionMixin, ExpenseClaimPaginationMixin, ExpenseClaimApprovalFlowMixin, ExpenseClaimApprovalRoutingMixin, ExpenseClaimApplicationHandoffMixin, ExpenseClaimPreReviewMixin, ExpenseClaimBudgetFlowMixin, ExpenseClaimAttachmentOperationsMixin, ExpenseClaimReviewPreviewMixin, ExpenseClaimDraftFlowMixin, ExpenseClaimDraftPersistenceMixin, ExpenseClaimDocumentItemBuilderMixin, ExpenseClaimDocumentParsingMixin, ExpenseClaimOntologyResolverMixin, ExpenseClaimAttachmentDocumentMixin, ExpenseClaimAttachmentAnalysisMixin, ExpenseClaimReadModelMixin, ExpenseClaimRiskReviewMixin, ExpenseClaimWorkflowRepairMixin):
|
||||
class ExpenseClaimService(
|
||||
ExpenseClaimActionProtocolMixin,
|
||||
ExpenseClaimReturnFlowMixin,
|
||||
ExpenseClaimStandardAdjustmentMixin,
|
||||
ExpenseClaimItemActionMixin,
|
||||
ExpenseClaimPaginationMixin,
|
||||
ExpenseClaimApprovalFlowMixin,
|
||||
ExpenseClaimApprovalRoutingMixin,
|
||||
ExpenseClaimApplicationHandoffMixin,
|
||||
ExpenseClaimPreReviewMixin,
|
||||
ExpenseClaimBudgetFlowMixin,
|
||||
ExpenseClaimAttachmentOperationsMixin,
|
||||
ExpenseClaimReviewPreviewMixin,
|
||||
ExpenseClaimDraftFlowMixin,
|
||||
ExpenseClaimDraftPersistenceMixin,
|
||||
ExpenseClaimDocumentItemBuilderMixin,
|
||||
ExpenseClaimDocumentParsingMixin,
|
||||
ExpenseClaimOntologyResolverMixin,
|
||||
ExpenseClaimAttachmentDocumentMixin,
|
||||
ExpenseClaimAttachmentAnalysisMixin,
|
||||
ExpenseClaimReadModelMixin,
|
||||
ExpenseClaimRiskReviewMixin,
|
||||
ExpenseClaimWorkflowRepairMixin,
|
||||
):
|
||||
def __init__(self, db: Session) -> None:
|
||||
self.db = db
|
||||
self.audit_service = AuditLogService(db)
|
||||
@@ -624,11 +509,15 @@ class ExpenseClaimService(ExpenseClaimStandardAdjustmentMixin, ExpenseClaimItemA
|
||||
def _is_expense_application_claim(claim: ExpenseClaim) -> bool:
|
||||
claim_no = str(getattr(claim, "claim_no", "") or "").strip().upper()
|
||||
expense_type = str(getattr(claim, "expense_type", "") or "").strip().lower()
|
||||
document_type = str(
|
||||
getattr(claim, "document_type_code", "")
|
||||
or getattr(claim, "document_type", "")
|
||||
or ""
|
||||
).strip().lower()
|
||||
document_type = (
|
||||
str(
|
||||
getattr(claim, "document_type_code", "")
|
||||
or getattr(claim, "document_type", "")
|
||||
or ""
|
||||
)
|
||||
.strip()
|
||||
.lower()
|
||||
)
|
||||
return (
|
||||
is_application_claim_no(claim_no)
|
||||
or expense_type == "application"
|
||||
@@ -667,7 +556,6 @@ class ExpenseClaimService(ExpenseClaimStandardAdjustmentMixin, ExpenseClaimItemA
|
||||
)
|
||||
stmt = self._access_policy.apply_claim_scope(stmt, current_user)
|
||||
claims = list(self.db.scalars(stmt).all())
|
||||
self._repair_duplicate_budget_approval_stages(claims)
|
||||
return self._access_policy.attach_budget_approval_snapshots(claims)
|
||||
|
||||
def list_approval_claims(self, current_user: CurrentUserContext) -> list[ExpenseClaim]:
|
||||
@@ -683,7 +571,6 @@ class ExpenseClaimService(ExpenseClaimStandardAdjustmentMixin, ExpenseClaimItemA
|
||||
)
|
||||
stmt = self._access_policy.apply_approval_claim_scope(stmt, current_user)
|
||||
claims = list(self.db.scalars(stmt).all())
|
||||
self._repair_duplicate_budget_approval_stages(claims)
|
||||
return self._access_policy.attach_budget_approval_snapshots(claims)
|
||||
|
||||
def list_archived_claims(self, current_user: CurrentUserContext) -> list[ExpenseClaim]:
|
||||
@@ -695,7 +582,11 @@ class ExpenseClaimService(ExpenseClaimStandardAdjustmentMixin, ExpenseClaimItemA
|
||||
selectinload(ExpenseClaim.employee).selectinload(Employee.organization_unit),
|
||||
selectinload(ExpenseClaim.employee).selectinload(Employee.roles),
|
||||
)
|
||||
.order_by(ExpenseClaim.updated_at.desc(), ExpenseClaim.submitted_at.desc(), ExpenseClaim.created_at.desc())
|
||||
.order_by(
|
||||
ExpenseClaim.updated_at.desc(),
|
||||
ExpenseClaim.submitted_at.desc(),
|
||||
ExpenseClaim.created_at.desc(),
|
||||
)
|
||||
)
|
||||
stmt = self._access_policy.apply_archived_claim_scope(stmt, current_user)
|
||||
return list(self.db.scalars(stmt).all())
|
||||
@@ -711,13 +602,15 @@ class ExpenseClaimService(ExpenseClaimStandardAdjustmentMixin, ExpenseClaimItemA
|
||||
)
|
||||
.where(ExpenseClaim.id == claim_id)
|
||||
)
|
||||
stmt = self._access_policy.apply_claim_scope(stmt, current_user, include_approval_scope=True)
|
||||
stmt = self._access_policy.apply_claim_scope(
|
||||
stmt, current_user, include_approval_scope=True
|
||||
)
|
||||
claim = self.db.scalar(stmt)
|
||||
if claim is not None:
|
||||
self._repair_duplicate_budget_approval_stages([claim])
|
||||
return self._access_policy.attach_approval_snapshot(claim)
|
||||
|
||||
def can_view_budget_analysis(self, current_user: CurrentUserContext, claim: ExpenseClaim | None = None) -> bool:
|
||||
def can_view_budget_analysis(
|
||||
self, current_user: CurrentUserContext, claim: ExpenseClaim | None = None
|
||||
) -> bool:
|
||||
if claim is None:
|
||||
return self._access_policy.is_budget_manager_user(current_user)
|
||||
if current_user.is_admin:
|
||||
@@ -725,10 +618,9 @@ class ExpenseClaimService(ExpenseClaimStandardAdjustmentMixin, ExpenseClaimItemA
|
||||
role_codes = self._access_policy.normalize_role_codes(current_user)
|
||||
if "executive" in role_codes:
|
||||
return True
|
||||
if (
|
||||
self._access_policy.has_privileged_claim_access(current_user)
|
||||
and not self._access_policy.is_claim_owned_by_current_user(claim, current_user)
|
||||
):
|
||||
if self._access_policy.has_privileged_claim_access(
|
||||
current_user
|
||||
) and not self._access_policy.is_claim_owned_by_current_user(claim, current_user):
|
||||
return True
|
||||
if self._access_policy.can_approve_claim(current_user, claim):
|
||||
return True
|
||||
@@ -751,7 +643,9 @@ class ExpenseClaimService(ExpenseClaimStandardAdjustmentMixin, ExpenseClaimItemA
|
||||
before_json = self._serialize_claim(claim)
|
||||
|
||||
if payload.reason is not None:
|
||||
claim.reason = self._normalize_optional_text(payload.reason, allow_empty=True) or "待补充"
|
||||
claim.reason = (
|
||||
self._normalize_optional_text(payload.reason, allow_empty=True) or "待补充"
|
||||
)
|
||||
|
||||
if not self._is_expense_application_claim(claim):
|
||||
self._refresh_claim_pre_review_flags(claim, is_application_claim=False)
|
||||
|
||||
@@ -40,63 +40,72 @@ class HermesRiskScannerService:
|
||||
observation_service = RiskObservationService(self.db)
|
||||
|
||||
observation_count = 0
|
||||
scanned_claim_count = 0
|
||||
graph_node_count = 0
|
||||
graph_edge_count = 0
|
||||
for tenant_id, tenant_claims in self._group_claims_by_tenant(claims).items():
|
||||
now = datetime.now(timezone.utc)
|
||||
grouped_claims = self._group_claims_by_tenant(claims)
|
||||
for tenant_id in sorted(grouped_claims):
|
||||
tenant_claims = sorted(grouped_claims[tenant_id], key=lambda item: str(item.id))
|
||||
snapshot_versions = {claim.id: claim.updated_at for claim in tenant_claims}
|
||||
result = evaluate_financial_risk_graph(
|
||||
RiskGraphEvaluationContext(
|
||||
claims=[
|
||||
RiskGraphClaimSnapshot.from_orm(claim)
|
||||
for claim in tenant_claims
|
||||
],
|
||||
claims=[RiskGraphClaimSnapshot.from_orm(claim) for claim in tenant_claims],
|
||||
target_claim_ids={claim.id for claim in tenant_claims},
|
||||
history_stats=observation_service.build_history_stats(
|
||||
tenant_id=tenant_id,
|
||||
expense_types={
|
||||
str(claim.expense_type or "") for claim in tenant_claims
|
||||
},
|
||||
expense_types={str(claim.expense_type or "") for claim in tenant_claims},
|
||||
),
|
||||
)
|
||||
)
|
||||
claims_by_id = {claim.id: claim for claim in tenant_claims}
|
||||
observation_count += len(result.observations)
|
||||
graph_node_count += len(result.nodes)
|
||||
graph_edge_count += len(result.edges)
|
||||
|
||||
observations_by_claim = {}
|
||||
for observation in result.observations:
|
||||
claim = claims_by_id.get(observation.claim_id)
|
||||
if claim is None:
|
||||
continue
|
||||
observation_service.upsert_observation(
|
||||
observation,
|
||||
tenant_id=tenant_id,
|
||||
run_id=run_id,
|
||||
execution_log_id=log_id,
|
||||
)
|
||||
claim.hermes_risk_flag = True
|
||||
claim.risk_flags_json = self._append_algorithm_flag(
|
||||
claim,
|
||||
observation.as_dict(),
|
||||
)
|
||||
observations_by_claim.setdefault(observation.claim_id, []).append(observation)
|
||||
|
||||
if log_id:
|
||||
self.db.add(
|
||||
HermesRiskReport(
|
||||
claim_id=observation.claim_id,
|
||||
execution_log_id=log_id,
|
||||
risk_level=observation.risk_level,
|
||||
risk_type=observation.risk_signal,
|
||||
risk_description=observation.description,
|
||||
related_claim_ids=[
|
||||
observation.claim_id,
|
||||
*observation.similar_case_claim_ids,
|
||||
],
|
||||
)
|
||||
for snapshot_claim in tenant_claims:
|
||||
claim = observation_service.lock_claim_for_risk_write(
|
||||
snapshot_claim.id,
|
||||
tenant_id=tenant_id,
|
||||
refresh=True,
|
||||
)
|
||||
if claim is None or not self._is_scan_eligible(claim):
|
||||
continue
|
||||
if claim.updated_at != snapshot_versions.get(claim.id):
|
||||
# 计算期间单据已变化,旧快照不再写回;下一轮重新扫描。
|
||||
continue
|
||||
for observation in observations_by_claim.get(claim.id, []):
|
||||
observation_service.upsert_observation(
|
||||
observation,
|
||||
tenant_id=tenant_id,
|
||||
run_id=run_id,
|
||||
execution_log_id=log_id,
|
||||
claim_lock_acquired=True,
|
||||
)
|
||||
observation_count += 1
|
||||
claim.hermes_risk_flag = True
|
||||
claim.risk_flags_json = self._append_algorithm_flag(
|
||||
claim,
|
||||
observation.as_dict(),
|
||||
)
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
for claim in claims:
|
||||
claim.hermes_scanned_at = now
|
||||
if log_id:
|
||||
self.db.add(
|
||||
HermesRiskReport(
|
||||
claim_id=observation.claim_id,
|
||||
execution_log_id=log_id,
|
||||
risk_level=observation.risk_level,
|
||||
risk_type=observation.risk_signal,
|
||||
risk_description=observation.description,
|
||||
related_claim_ids=[
|
||||
observation.claim_id,
|
||||
*observation.similar_case_claim_ids,
|
||||
],
|
||||
)
|
||||
)
|
||||
claim.hermes_scanned_at = now
|
||||
scanned_claim_count += 1
|
||||
|
||||
self.db.commit()
|
||||
logger.info(
|
||||
@@ -104,7 +113,7 @@ class HermesRiskScannerService:
|
||||
observation_count,
|
||||
)
|
||||
return {
|
||||
"scanned_claim_count": len(claims),
|
||||
"scanned_claim_count": scanned_claim_count,
|
||||
"risk_observation_count": observation_count,
|
||||
"graph_node_count": graph_node_count,
|
||||
"graph_edge_count": graph_edge_count,
|
||||
@@ -134,11 +143,16 @@ class HermesRiskScannerService:
|
||||
ExpenseClaim.hermes_risk_flag.is_(False),
|
||||
),
|
||||
)
|
||||
.order_by(ExpenseClaim.id)
|
||||
.limit(50)
|
||||
)
|
||||
|
||||
return list(self.db.scalars(stmt).all())
|
||||
|
||||
@staticmethod
|
||||
def _is_scan_eligible(claim: ExpenseClaim) -> bool:
|
||||
return str(claim.status or "").strip().lower() in {"draft", "submitted", "review"}
|
||||
|
||||
@staticmethod
|
||||
def _append_algorithm_flag(claim: ExpenseClaim, observation: dict) -> list:
|
||||
existing = list(claim.risk_flags_json or [])
|
||||
@@ -155,8 +169,7 @@ class HermesRiskScannerService:
|
||||
"reimbursement",
|
||||
)
|
||||
if any(
|
||||
isinstance(item, dict)
|
||||
and item.get("observation_key") == flag["observation_key"]
|
||||
isinstance(item, dict) and item.get("observation_key") == flag["observation_key"]
|
||||
for item in existing
|
||||
):
|
||||
return existing
|
||||
|
||||
430
server/src/app/services/risk_dispositions.py
Normal file
430
server/src/app/services/risk_dispositions.py
Normal file
@@ -0,0 +1,430 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import CurrentUserContext
|
||||
from app.core.logging import get_logger
|
||||
from app.models.financial_record import ExpenseClaim
|
||||
from app.models.risk_disposition import RiskDisposition, RiskDispositionEvent
|
||||
from app.models.risk_observation import RiskObservation, RiskObservationFeedback
|
||||
from app.schemas.risk_disposition import RiskDispositionActionCreate
|
||||
from app.services.expense_claim_tenant_scope import ExpenseClaimTenantScopeMixin
|
||||
from app.services.risk_observation_access_policy import RiskObservationAccessPolicy
|
||||
|
||||
logger = get_logger("app.services.risk_dispositions")
|
||||
|
||||
|
||||
class RiskDispositionConflictError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class RiskDispositionVersionConflictError(RiskDispositionConflictError):
|
||||
def __init__(self, current_version: int) -> None:
|
||||
self.current_version = current_version
|
||||
super().__init__(
|
||||
f"Risk disposition version conflict; current version is {current_version}."
|
||||
)
|
||||
|
||||
|
||||
class RiskDispositionIdempotencyConflictError(RiskDispositionConflictError):
|
||||
pass
|
||||
|
||||
|
||||
class RiskDispositionPermissionError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class RiskDispositionMutation:
|
||||
disposition: RiskDisposition
|
||||
event: RiskDispositionEvent
|
||||
replayed: bool
|
||||
legacy_feedback: RiskObservationFeedback | None = None
|
||||
|
||||
|
||||
class RiskDispositionService:
|
||||
def __init__(self, db: Session) -> None:
|
||||
self.db = db
|
||||
|
||||
def get_disposition(
|
||||
self,
|
||||
observation_id: str,
|
||||
*,
|
||||
tenant_id: str,
|
||||
) -> RiskDisposition | None:
|
||||
return self.db.scalar(
|
||||
select(RiskDisposition).where(
|
||||
RiskDisposition.tenant_id == _tenant(tenant_id),
|
||||
RiskDisposition.observation_id == observation_id,
|
||||
)
|
||||
)
|
||||
|
||||
def get_current_version(self, observation_id: str, *, tenant_id: str) -> int:
|
||||
disposition = self.get_disposition(observation_id, tenant_id=tenant_id)
|
||||
return disposition.version if disposition is not None else 0
|
||||
|
||||
def execute_action(
|
||||
self,
|
||||
observation_key_or_id: str,
|
||||
payload: RiskDispositionActionCreate,
|
||||
*,
|
||||
tenant_id: str,
|
||||
actor_id: str,
|
||||
actor_name: str,
|
||||
current_user: CurrentUserContext | None = None,
|
||||
) -> RiskDispositionMutation:
|
||||
normalized_tenant = _tenant(tenant_id)
|
||||
normalized_actor_id = _text(actor_id) or "anonymous"
|
||||
observation_locator = self.db.execute(
|
||||
select(RiskObservation.id, RiskObservation.claim_id).where(
|
||||
RiskObservation.tenant_id == normalized_tenant,
|
||||
(
|
||||
(RiskObservation.id == observation_key_or_id)
|
||||
| (RiskObservation.observation_key == observation_key_or_id)
|
||||
),
|
||||
)
|
||||
).one_or_none()
|
||||
if observation_locator is None:
|
||||
raise LookupError("Risk observation not found.")
|
||||
observation_id, located_claim_id = observation_locator
|
||||
fingerprint = _payload_fingerprint(
|
||||
payload,
|
||||
observation_id=observation_id,
|
||||
actor_id=normalized_actor_id,
|
||||
)
|
||||
replay = self._find_replay(
|
||||
tenant_id=normalized_tenant,
|
||||
request_id=payload.request_id,
|
||||
fingerprint=fingerprint,
|
||||
)
|
||||
if replay is not None:
|
||||
return replay
|
||||
|
||||
try:
|
||||
locked_claim = self._lock_claim(
|
||||
str(located_claim_id or "").strip(),
|
||||
tenant_id=normalized_tenant,
|
||||
)
|
||||
observation = self.db.scalar(
|
||||
select(RiskObservation)
|
||||
.where(
|
||||
RiskObservation.tenant_id == normalized_tenant,
|
||||
RiskObservation.id == observation_id,
|
||||
)
|
||||
.with_for_update()
|
||||
)
|
||||
if observation is None:
|
||||
raise LookupError("Risk observation not found.")
|
||||
if str(observation.claim_id or "").strip() != str(located_claim_id or "").strip():
|
||||
raise RiskDispositionConflictError(
|
||||
"Risk observation claim changed concurrently; reload and retry."
|
||||
)
|
||||
if current_user is not None and not RiskObservationAccessPolicy(
|
||||
self.db
|
||||
).can_manage_locked_disposition(
|
||||
observation,
|
||||
current_user,
|
||||
locked_claim=locked_claim,
|
||||
):
|
||||
raise RiskDispositionPermissionError("当前用户已不再是该单据的有效审批人。")
|
||||
|
||||
disposition = self.db.scalar(
|
||||
select(RiskDisposition)
|
||||
.where(
|
||||
RiskDisposition.tenant_id == normalized_tenant,
|
||||
RiskDisposition.observation_id == observation.id,
|
||||
)
|
||||
.with_for_update()
|
||||
)
|
||||
current_version = disposition.version if disposition is not None else 0
|
||||
if current_version != payload.expected_version:
|
||||
raise RiskDispositionVersionConflictError(current_version)
|
||||
|
||||
if disposition is None:
|
||||
disposition = RiskDisposition(
|
||||
id=str(uuid.uuid4()),
|
||||
tenant_id=normalized_tenant,
|
||||
observation_id=observation.id,
|
||||
adjudication=_initial_adjudication(observation),
|
||||
lifecycle_status=_initial_lifecycle_status(observation),
|
||||
version=0,
|
||||
)
|
||||
self.db.add(disposition)
|
||||
|
||||
_validate_transition(disposition, payload)
|
||||
before = _state(disposition)
|
||||
_apply_action(disposition, observation, payload)
|
||||
disposition.version = current_version + 1
|
||||
disposition.updated_at = datetime.now(UTC)
|
||||
event = RiskDispositionEvent(
|
||||
id=str(uuid.uuid4()),
|
||||
tenant_id=normalized_tenant,
|
||||
disposition_id=disposition.id,
|
||||
observation_id=observation.id,
|
||||
version=disposition.version,
|
||||
action=payload.action,
|
||||
actor_id=normalized_actor_id,
|
||||
actor_name=_text(actor_name) or _text(actor_id) or "anonymous",
|
||||
request_id=payload.request_id,
|
||||
payload_fingerprint=fingerprint,
|
||||
comment=payload.comment,
|
||||
before_json=before,
|
||||
after_json=_state(disposition),
|
||||
)
|
||||
self.db.add(event)
|
||||
legacy_feedback = self._append_safe_feedback(
|
||||
observation,
|
||||
event,
|
||||
payload,
|
||||
actor_name=actor_name,
|
||||
)
|
||||
self.db.commit()
|
||||
self.db.refresh(disposition)
|
||||
self.db.refresh(event)
|
||||
if legacy_feedback is not None:
|
||||
self.db.refresh(legacy_feedback)
|
||||
self._ingest_feedback_sample(observation, legacy_feedback)
|
||||
return RiskDispositionMutation(
|
||||
disposition=disposition,
|
||||
event=event,
|
||||
replayed=False,
|
||||
legacy_feedback=legacy_feedback,
|
||||
)
|
||||
except (LookupError, RiskDispositionConflictError, RiskDispositionPermissionError):
|
||||
self.db.rollback()
|
||||
raise
|
||||
except IntegrityError as error:
|
||||
self.db.rollback()
|
||||
replay = self._find_replay(
|
||||
tenant_id=normalized_tenant,
|
||||
request_id=payload.request_id,
|
||||
fingerprint=fingerprint,
|
||||
)
|
||||
if replay is not None:
|
||||
return replay
|
||||
raise RiskDispositionConflictError(
|
||||
"Risk disposition was changed concurrently; reload and retry."
|
||||
) from error
|
||||
|
||||
def _lock_claim(
|
||||
self,
|
||||
claim_id: str,
|
||||
*,
|
||||
tenant_id: str,
|
||||
) -> ExpenseClaim | None:
|
||||
if not claim_id:
|
||||
return None
|
||||
statement = select(ExpenseClaim).where(
|
||||
ExpenseClaim.id == claim_id,
|
||||
ExpenseClaimTenantScopeMixin.build_claim_tenant_condition(tenant_id),
|
||||
)
|
||||
bind = self.db.get_bind()
|
||||
if bind is not None and bind.dialect.name == "postgresql":
|
||||
statement = statement.with_for_update()
|
||||
return self.db.scalar(statement.execution_options(populate_existing=True))
|
||||
|
||||
def _find_replay(
|
||||
self,
|
||||
*,
|
||||
tenant_id: str,
|
||||
request_id: str,
|
||||
fingerprint: str,
|
||||
) -> RiskDispositionMutation | None:
|
||||
event = self.db.scalar(
|
||||
select(RiskDispositionEvent).where(
|
||||
RiskDispositionEvent.tenant_id == tenant_id,
|
||||
RiskDispositionEvent.request_id == request_id,
|
||||
)
|
||||
)
|
||||
if event is None:
|
||||
return None
|
||||
if event.payload_fingerprint != fingerprint:
|
||||
raise RiskDispositionIdempotencyConflictError("request_id 已被不同的风险处置内容使用。")
|
||||
disposition = self.db.get(RiskDisposition, event.disposition_id)
|
||||
if disposition is None:
|
||||
raise RiskDispositionConflictError("Risk disposition replay target is missing.")
|
||||
feedback = self.db.scalar(
|
||||
select(RiskObservationFeedback).where(
|
||||
RiskObservationFeedback.observation_id == event.observation_id,
|
||||
RiskObservationFeedback.action == f"disposition:{event.id}",
|
||||
)
|
||||
)
|
||||
return RiskDispositionMutation(
|
||||
disposition=disposition,
|
||||
event=event,
|
||||
replayed=True,
|
||||
legacy_feedback=feedback,
|
||||
)
|
||||
|
||||
def _append_safe_feedback(
|
||||
self,
|
||||
observation: RiskObservation,
|
||||
event: RiskDispositionEvent,
|
||||
payload: RiskDispositionActionCreate,
|
||||
*,
|
||||
actor_name: str,
|
||||
) -> RiskObservationFeedback | None:
|
||||
if payload.action not in {"confirm", "false_positive"}:
|
||||
return None
|
||||
feedback = RiskObservationFeedback(
|
||||
observation_id=observation.id,
|
||||
feedback_type=payload.action,
|
||||
action=f"disposition:{event.id}",
|
||||
actor=_text(actor_name) or "anonymous",
|
||||
comment=payload.comment,
|
||||
payload_json={
|
||||
"decision": payload.action,
|
||||
"source": "typed_risk_disposition",
|
||||
},
|
||||
)
|
||||
self.db.add(feedback)
|
||||
return feedback
|
||||
|
||||
def _ingest_feedback_sample(
|
||||
self,
|
||||
observation: RiskObservation,
|
||||
feedback: RiskObservationFeedback,
|
||||
) -> None:
|
||||
enabled = os.environ.get("FEW_SHOT_INJECTION_ENABLED", "true")
|
||||
if enabled.strip().lower() in {"0", "false", "no"}:
|
||||
return
|
||||
try:
|
||||
from app.services.few_shot_ingestion import FewShotIngestionService
|
||||
|
||||
FewShotIngestionService(self.db).ingest_observation_feedback(
|
||||
observation,
|
||||
feedback,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"few-shot ingestion failed for disposition event %s",
|
||||
feedback.action,
|
||||
)
|
||||
|
||||
|
||||
def _apply_action(
|
||||
disposition: RiskDisposition,
|
||||
observation: RiskObservation,
|
||||
payload: RiskDispositionActionCreate,
|
||||
) -> None:
|
||||
if payload.action == "confirm":
|
||||
disposition.adjudication = "confirmed"
|
||||
observation.status = "confirmed"
|
||||
observation.feedback_status = "confirmed"
|
||||
return
|
||||
if payload.action == "false_positive":
|
||||
disposition.adjudication = "false_positive"
|
||||
observation.status = "false_positive"
|
||||
observation.feedback_status = "false_positive"
|
||||
return
|
||||
|
||||
lifecycle_by_action = {
|
||||
"request_supplement": "supplement_requested",
|
||||
"start_remediation": "remediation_in_progress",
|
||||
"request_waiver": "waiver_requested",
|
||||
"resolve": "resolved",
|
||||
}
|
||||
disposition.lifecycle_status = lifecycle_by_action[payload.action]
|
||||
if payload.assignee is not None:
|
||||
disposition.assignee = payload.assignee
|
||||
if payload.due_at is not None:
|
||||
disposition.due_at = payload.due_at
|
||||
if payload.action != "resolve":
|
||||
disposition.resolution = None
|
||||
else:
|
||||
disposition.resolution = payload.resolution
|
||||
observation.status = "resolved"
|
||||
|
||||
|
||||
def _validate_transition(
|
||||
disposition: RiskDisposition,
|
||||
payload: RiskDispositionActionCreate,
|
||||
) -> None:
|
||||
if disposition.lifecycle_status == "resolved":
|
||||
raise RiskDispositionConflictError("已解决的风险不能再次变更裁决或处置生命周期。")
|
||||
if payload.action == "confirm":
|
||||
if disposition.adjudication == "confirmed":
|
||||
raise RiskDispositionConflictError("该风险已经确认成立,请勿重复提交。")
|
||||
return
|
||||
if payload.action == "false_positive":
|
||||
if disposition.adjudication == "false_positive":
|
||||
raise RiskDispositionConflictError("该风险已经标记为误报,请勿重复提交。")
|
||||
return
|
||||
target_lifecycle = {
|
||||
"request_supplement": "supplement_requested",
|
||||
"start_remediation": "remediation_in_progress",
|
||||
"request_waiver": "waiver_requested",
|
||||
"resolve": "resolved",
|
||||
}[payload.action]
|
||||
if disposition.lifecycle_status == target_lifecycle:
|
||||
raise RiskDispositionConflictError("该风险已处于目标处置状态,请勿重复提交。")
|
||||
if payload.action == "request_supplement":
|
||||
return
|
||||
if disposition.adjudication != "confirmed":
|
||||
raise RiskDispositionConflictError("风险必须先确认成立,才能启动整改、申请豁免或标记解决。")
|
||||
|
||||
|
||||
def _state(disposition: RiskDisposition) -> dict[str, object]:
|
||||
return {
|
||||
"adjudication": disposition.adjudication,
|
||||
"lifecycle_status": disposition.lifecycle_status,
|
||||
"assignee": disposition.assignee,
|
||||
"due_at": disposition.due_at.isoformat() if disposition.due_at else None,
|
||||
"resolution": disposition.resolution,
|
||||
"version": disposition.version,
|
||||
}
|
||||
|
||||
|
||||
def _initial_adjudication(observation: RiskObservation) -> str:
|
||||
values = {
|
||||
_text(observation.feedback_status).lower(),
|
||||
_text(observation.status).lower(),
|
||||
}
|
||||
if "false_positive" in values:
|
||||
return "false_positive"
|
||||
if "confirmed" in values:
|
||||
return "confirmed"
|
||||
return "unreviewed"
|
||||
|
||||
|
||||
def _initial_lifecycle_status(observation: RiskObservation) -> str:
|
||||
return "resolved" if _text(observation.status).lower() == "resolved" else "open"
|
||||
|
||||
|
||||
def _payload_fingerprint(
|
||||
payload: RiskDispositionActionCreate,
|
||||
*,
|
||||
observation_id: str,
|
||||
actor_id: str,
|
||||
) -> str:
|
||||
canonical_payload = payload.model_dump(
|
||||
mode="json",
|
||||
exclude={"request_id"},
|
||||
exclude_none=False,
|
||||
)
|
||||
canonical_payload["observation_id"] = observation_id
|
||||
canonical_payload["actor_id"] = actor_id
|
||||
canonical = json.dumps(
|
||||
canonical_payload,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
)
|
||||
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _tenant(value: str) -> str:
|
||||
return _text(value) or "default"
|
||||
|
||||
|
||||
def _text(value: object) -> str:
|
||||
return str(value or "").strip()
|
||||
84
server/src/app/services/risk_observation_access_policy.py
Normal file
84
server/src/app/services/risk_observation_access_policy.py
Normal file
@@ -0,0 +1,84 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import CurrentUserContext
|
||||
from app.models.financial_record import ExpenseClaim
|
||||
from app.models.risk_observation import RiskObservation
|
||||
from app.services.expense_claim_access_policy import ExpenseClaimAccessPolicy
|
||||
|
||||
RISK_POOL_ROLE_CODES = {
|
||||
"budget_monitor",
|
||||
"executive",
|
||||
"finance",
|
||||
}
|
||||
|
||||
|
||||
class RiskObservationAccessPolicy:
|
||||
"""集中定义风险池、单据风险和处置动作的访问边界。"""
|
||||
|
||||
def __init__(self, db: Session) -> None:
|
||||
self.db = db
|
||||
self.claim_policy = ExpenseClaimAccessPolicy(db)
|
||||
|
||||
def can_read_tenant_pool(self, current_user: CurrentUserContext) -> bool:
|
||||
if current_user.is_admin:
|
||||
return True
|
||||
return bool(self.claim_policy.normalize_role_codes(current_user) & RISK_POOL_ROLE_CODES)
|
||||
|
||||
def find_visible_claim(
|
||||
self,
|
||||
claim_id: str,
|
||||
current_user: CurrentUserContext,
|
||||
) -> ExpenseClaim | None:
|
||||
statement = select(ExpenseClaim).where(ExpenseClaim.id == claim_id)
|
||||
statement = self.claim_policy.apply_claim_scope(
|
||||
statement,
|
||||
current_user,
|
||||
include_approval_scope=True,
|
||||
)
|
||||
return self.db.scalar(statement)
|
||||
|
||||
def can_read_claim_risks(
|
||||
self,
|
||||
claim_id: str,
|
||||
current_user: CurrentUserContext,
|
||||
) -> bool:
|
||||
claim = self.find_visible_claim(claim_id, current_user)
|
||||
if claim is None:
|
||||
return False
|
||||
if current_user.is_admin:
|
||||
return True
|
||||
# 完整证据链含图谱、相似案例与人工反馈,仅当前审批人可读取。
|
||||
return self.claim_policy.can_approve_claim(current_user, claim)
|
||||
|
||||
def can_manage_disposition(
|
||||
self,
|
||||
observation: RiskObservation,
|
||||
current_user: CurrentUserContext,
|
||||
) -> bool:
|
||||
claim_id = str(observation.claim_id or "").strip()
|
||||
claim = self.find_visible_claim(claim_id, current_user) if claim_id else None
|
||||
return self.can_manage_locked_disposition(
|
||||
observation,
|
||||
current_user,
|
||||
locked_claim=claim,
|
||||
)
|
||||
|
||||
def can_manage_locked_disposition(
|
||||
self,
|
||||
observation: RiskObservation,
|
||||
current_user: CurrentUserContext,
|
||||
*,
|
||||
locked_claim: ExpenseClaim | None,
|
||||
) -> bool:
|
||||
"""在共享 Claim 行锁内按最新审批节点复核处置权限。"""
|
||||
|
||||
if current_user.is_admin:
|
||||
return True
|
||||
claim_id = str(observation.claim_id or "").strip()
|
||||
if not claim_id or locked_claim is None or str(locked_claim.id) != claim_id:
|
||||
# 无关联单据的租户级风险缺少可复用的业务权限边界,仅 admin 可处置。
|
||||
return False
|
||||
return self.claim_policy.can_approve_claim(current_user, locked_claim)
|
||||
@@ -19,6 +19,7 @@ from app.schemas.risk_observation import (
|
||||
RiskObservationFeedbackCreate,
|
||||
)
|
||||
from app.services.expense_claim_risk_stage import normalize_risk_business_stage
|
||||
from app.services.expense_claim_tenant_scope import ExpenseClaimTenantScopeMixin
|
||||
|
||||
logger = get_logger("app.services.risk_observations")
|
||||
|
||||
@@ -66,6 +67,7 @@ class RiskObservationService:
|
||||
tenant_id: str | None = None,
|
||||
run_id: str | None = None,
|
||||
execution_log_id: str | None = None,
|
||||
claim_lock_acquired: bool = False,
|
||||
) -> RiskObservation:
|
||||
self.ensure_storage_ready()
|
||||
payload = (
|
||||
@@ -80,6 +82,12 @@ class RiskObservationService:
|
||||
tenant_id=tenant_id or _optional_text(payload.get("tenant_id")),
|
||||
claim_id=_optional_text(payload.get("claim_id")),
|
||||
)
|
||||
claim_id = _optional_text(payload.get("claim_id"))
|
||||
if claim_id and not claim_lock_acquired:
|
||||
self.lock_claim_for_risk_write(
|
||||
claim_id,
|
||||
tenant_id=normalized_tenant_id,
|
||||
)
|
||||
|
||||
item = self.db.scalar(
|
||||
select(RiskObservation).where(
|
||||
@@ -126,6 +134,27 @@ class RiskObservationService:
|
||||
self.db.flush()
|
||||
return item
|
||||
|
||||
def lock_claim_for_risk_write(
|
||||
self,
|
||||
claim_id: str,
|
||||
*,
|
||||
tenant_id: str | None,
|
||||
refresh: bool = False,
|
||||
) -> ExpenseClaim | None:
|
||||
"""所有 claim-linked 风险写入共享同一 Claim 行锁。"""
|
||||
|
||||
normalized_tenant = _normalize_tenant_id(tenant_id)
|
||||
statement = select(ExpenseClaim).where(
|
||||
ExpenseClaim.id == claim_id,
|
||||
ExpenseClaimTenantScopeMixin.build_claim_tenant_condition(normalized_tenant),
|
||||
)
|
||||
bind = self.db.get_bind()
|
||||
if bind is not None and bind.dialect.name == "postgresql":
|
||||
statement = statement.with_for_update()
|
||||
if refresh:
|
||||
statement = statement.execution_options(populate_existing=True)
|
||||
return self.db.scalar(statement)
|
||||
|
||||
def upsert_platform_risk_flags(
|
||||
self,
|
||||
claim: ExpenseClaim,
|
||||
@@ -154,9 +183,7 @@ class RiskObservationService:
|
||||
score = SEVERITY_SCORE.get(severity, SEVERITY_SCORE["medium"])
|
||||
rule_code = _text(flag.get("rule_code"))
|
||||
business_stage = normalize_risk_business_stage(flag.get("business_stage"))
|
||||
observation_key = (
|
||||
f"risk:{claim.id}:platform:{rule_code or signal}"
|
||||
)
|
||||
observation_key = f"risk:{claim.id}:platform:{rule_code or signal}"
|
||||
observations.append(
|
||||
self.upsert_observation(
|
||||
{
|
||||
@@ -176,9 +203,7 @@ class RiskObservationService:
|
||||
"control_stage": business_stage,
|
||||
"control_mode": "risk_observation",
|
||||
"automation_mode": (
|
||||
"semi_auto_review"
|
||||
if severity in HIGH_LEVELS
|
||||
else "manual_review"
|
||||
"semi_auto_review" if severity in HIGH_LEVELS else "manual_review"
|
||||
),
|
||||
"source": "rule_center",
|
||||
"algorithm_version": _text(flag.get("rule_version")) or "v1.0.0",
|
||||
@@ -357,6 +382,24 @@ class RiskObservationService:
|
||||
observation_key_or_id,
|
||||
tenant_id=tenant_id,
|
||||
)
|
||||
if observation is None:
|
||||
raise LookupError("Risk observation not found.")
|
||||
normalized_tenant = _normalize_tenant_id(tenant_id)
|
||||
if observation.claim_id:
|
||||
self.lock_claim_for_risk_write(
|
||||
observation.claim_id,
|
||||
tenant_id=normalized_tenant,
|
||||
)
|
||||
observation_statement = select(RiskObservation).where(
|
||||
RiskObservation.tenant_id == normalized_tenant,
|
||||
RiskObservation.id == observation.id,
|
||||
)
|
||||
bind = self.db.get_bind()
|
||||
if bind is not None and bind.dialect.name == "postgresql":
|
||||
observation_statement = observation_statement.with_for_update()
|
||||
observation = self.db.scalar(
|
||||
observation_statement.execution_options(populate_existing=True)
|
||||
)
|
||||
if observation is None:
|
||||
raise LookupError("Risk observation not found.")
|
||||
|
||||
@@ -505,12 +548,16 @@ class RiskObservationService:
|
||||
) -> str:
|
||||
explicit_tenant_id = str(tenant_id or "").strip()
|
||||
normalized_claim_id = str(claim_id or "").strip()
|
||||
linked_tenant_id = self.db.scalar(
|
||||
select(ExpenseCaseLink.tenant_id).where(
|
||||
ExpenseCaseLink.resource_type == "expense_claim",
|
||||
ExpenseCaseLink.resource_id == normalized_claim_id,
|
||||
linked_tenant_id = (
|
||||
self.db.scalar(
|
||||
select(ExpenseCaseLink.tenant_id).where(
|
||||
ExpenseCaseLink.resource_type == "expense_claim",
|
||||
ExpenseCaseLink.resource_id == normalized_claim_id,
|
||||
)
|
||||
)
|
||||
) if normalized_claim_id else None
|
||||
if normalized_claim_id
|
||||
else None
|
||||
)
|
||||
claim_tenant_id = _normalize_tenant_id(linked_tenant_id)
|
||||
if explicit_tenant_id:
|
||||
normalized_tenant_id = _normalize_tenant_id(explicit_tenant_id)
|
||||
|
||||
@@ -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(
|
||||
|
||||
225
server/tests/test_approval_risk_concurrency_postgres.py
Normal file
225
server/tests/test_approval_risk_concurrency_postgres.py
Normal 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
|
||||
221
server/tests/test_approval_workbench.py
Normal file
221
server/tests/test_approval_workbench.py
Normal 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"
|
||||
322
server/tests/test_expense_claim_action_protocol.py
Normal file
322
server/tests/test_expense_claim_action_protocol.py
Normal 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()
|
||||
222
server/tests/test_expense_claim_risk_gate.py
Normal file
222
server/tests/test_expense_claim_risk_gate.py
Normal 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",
|
||||
)
|
||||
@@ -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] == []
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
661
server/tests/test_risk_dispositions.py
Normal file
661
server/tests/test_risk_dispositions.py
Normal 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": {},
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user