feat(approval): add task workflow and waiver decisions

This commit is contained in:
caoxiaozhu
2026-07-16 16:52:12 +08:00
parent 28b834edd3
commit 242d68c36f
89 changed files with 16313 additions and 294 deletions

View File

@@ -0,0 +1,400 @@
"""add tenant-safe approval task work queue and append-only events
Revision ID: 20260716_0013
Revises: 20260716_0012
Create Date: 2026-07-16 18:20:00
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "20260716_0013"
down_revision: str | None = "20260716_0012"
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_0013 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()
task_count = int(bind.scalar(sa.text("SELECT COUNT(*) FROM approval_tasks")) or 0)
event_count = int(bind.scalar(sa.text("SELECT COUNT(*) FROM approval_task_events")) or 0)
if task_count or event_count:
raise RuntimeError(
"cannot downgrade approval tasks: audit chain is not empty "
f"({task_count} tasks, {event_count} events)"
)
def upgrade() -> None:
_require_postgresql()
op.create_table(
"approval_tasks",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=64), nullable=False),
# expense_claims 仍由 legacy bootstrap 管理,故这里只保存软引用。
sa.Column("claim_id", sa.String(length=36), nullable=False),
sa.Column("expense_case_id", sa.String(length=36), nullable=True),
sa.Column("node_instance_id", sa.String(length=120), nullable=False),
sa.Column("node_entry_key", sa.String(length=160), nullable=False),
sa.Column("parent_task_id", sa.String(length=36), nullable=True),
sa.Column("task_kind", sa.String(length=24), nullable=False),
sa.Column("node_key", sa.String(length=32), nullable=False),
sa.Column("node_label", sa.String(length=120), nullable=False),
sa.Column("node_sequence", sa.Integer(), nullable=False),
sa.Column("sequence_order", sa.Integer(), nullable=False, server_default="0"),
sa.Column("coordination_mode", sa.String(length=32), nullable=False),
sa.Column("owner_kind", sa.String(length=20), nullable=False),
sa.Column("owner_key", sa.String(length=120), nullable=False),
sa.Column("owner_employee_id", sa.String(length=36), nullable=True),
sa.Column("owner_name", sa.String(length=120), nullable=False),
sa.Column("assignee_kind", sa.String(length=20), nullable=False),
sa.Column("assignee_key", sa.String(length=120), nullable=False),
sa.Column("assignee_employee_id", sa.String(length=36), nullable=True),
sa.Column("assignee_name", sa.String(length=120), nullable=False),
sa.Column("delegated_by", sa.String(length=120), nullable=True),
sa.Column("delegation_expires_at", sa.DateTime(timezone=True), nullable=True),
sa.Column(
"status",
sa.String(length=24),
nullable=False,
server_default="waiting",
),
sa.Column("decision", sa.String(length=20), nullable=True),
sa.Column("opinion", sa.Text(), nullable=True),
sa.Column("version", sa.Integer(), nullable=False, server_default="1"),
sa.Column("claim_status_snapshot", sa.String(length=30), nullable=False),
sa.Column("claim_stage_snapshot", sa.String(length=50), nullable=False),
sa.Column("entered_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("entered_at_source", sa.String(length=24), nullable=False),
sa.Column("activated_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("sla_hours_snapshot", sa.Integer(), nullable=False),
sa.Column("due_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("cancelled_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("escalation_level", sa.Integer(), nullable=False, server_default="0"),
sa.Column("escalated_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("next_escalation_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("priority_score", sa.Integer(), nullable=False, server_default="0"),
sa.Column(
"priority_tier",
sa.String(length=16),
nullable=False,
server_default="normal",
),
sa.Column(
"priority_reasons_json",
sa.JSON(),
nullable=False,
server_default=sa.text("'[]'::json"),
),
sa.Column(
"risk_level",
sa.String(length=16),
nullable=False,
server_default="low",
),
sa.Column("open_risk_count", sa.Integer(), nullable=False, server_default="0"),
sa.Column(
"evidence_completeness",
sa.Numeric(precision=5, scale=4),
nullable=False,
server_default="0",
),
sa.Column(
"batch_eligible",
sa.Boolean(),
nullable=False,
server_default=sa.false(),
),
sa.Column(
"batch_block_reasons_json",
sa.JSON(),
nullable=False,
server_default=sa.text("'[]'::json"),
),
sa.Column(
"projection_updated_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.func.now(),
),
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(
"task_kind IN ('root', 'add_sign', 'countersign')",
name="ck_approval_tasks_task_kind",
),
sa.CheckConstraint(
"node_key IN ('direct_manager', 'budget_manager', 'finance')",
name="ck_approval_tasks_node_key",
),
sa.CheckConstraint(
"coordination_mode IN ('single', 'sequential_before_owner', 'parallel_all')",
name="ck_approval_tasks_coordination_mode",
),
sa.CheckConstraint(
"owner_kind IN ('employee', 'role')",
name="ck_approval_tasks_owner_kind",
),
sa.CheckConstraint(
"assignee_kind IN ('employee', 'role')",
name="ck_approval_tasks_assignee_kind",
),
sa.CheckConstraint(
"status IN ('waiting', 'pending', 'completed', 'returned', "
"'cancelled', 'superseded')",
name="ck_approval_tasks_status",
),
sa.CheckConstraint(
"decision IS NULL OR decision IN ('approved', 'returned')",
name="ck_approval_tasks_decision",
),
sa.CheckConstraint(
"entered_at_source IN ('workflow_event', 'submitted_at', 'updated_at', 'backfill')",
name="ck_approval_tasks_entered_at_source",
),
sa.CheckConstraint(
"priority_tier IN ('normal', 'high', 'urgent')",
name="ck_approval_tasks_priority_tier",
),
sa.CheckConstraint(
"risk_level IN ('low', 'medium', 'high', 'critical')",
name="ck_approval_tasks_risk_level",
),
sa.CheckConstraint(
"(task_kind = 'root' AND parent_task_id IS NULL AND sequence_order = 0) OR "
"(task_kind IN ('add_sign', 'countersign') "
"AND parent_task_id IS NOT NULL AND sequence_order > 0)",
name="ck_approval_tasks_parent_shape",
),
sa.CheckConstraint(
"(status = 'completed' AND decision = 'approved' "
"AND completed_at IS NOT NULL AND cancelled_at IS NULL) OR "
"(status = 'returned' AND decision = 'returned' "
"AND completed_at IS NOT NULL AND cancelled_at IS NULL) OR "
"(status IN ('cancelled', 'superseded') AND decision IS NULL "
"AND completed_at IS NULL AND cancelled_at IS NOT NULL) OR "
"(status IN ('waiting', 'pending') AND decision IS NULL "
"AND completed_at IS NULL AND cancelled_at IS NULL)",
name="ck_approval_tasks_lifecycle",
),
sa.CheckConstraint(
"status NOT IN ('pending', 'completed', 'returned') OR activated_at IS NOT NULL",
name="ck_approval_tasks_activation",
),
sa.CheckConstraint(
"delegation_expires_at IS NULL OR delegated_by IS NOT NULL",
name="ck_approval_tasks_delegation",
),
sa.CheckConstraint("version >= 1", name="ck_approval_tasks_version"),
sa.CheckConstraint("node_sequence >= 1", name="ck_approval_tasks_node_sequence"),
sa.CheckConstraint("sla_hours_snapshot > 0", name="ck_approval_tasks_sla_hours"),
sa.CheckConstraint("due_at >= entered_at", name="ck_approval_tasks_due_at"),
sa.CheckConstraint("escalation_level >= 0", name="ck_approval_tasks_escalation"),
sa.CheckConstraint(
"priority_score >= 0 AND priority_score <= 100",
name="ck_approval_tasks_priority_score",
),
sa.CheckConstraint(
"open_risk_count >= 0",
name="ck_approval_tasks_open_risk_count",
),
sa.CheckConstraint(
"evidence_completeness >= 0 AND evidence_completeness <= 1",
name="ck_approval_tasks_evidence_completeness",
),
sa.ForeignKeyConstraint(
["tenant_id", "parent_task_id"],
["approval_tasks.tenant_id", "approval_tasks.id"],
name="fk_approval_tasks_tenant_parent",
ondelete="RESTRICT",
),
sa.ForeignKeyConstraint(
["tenant_id", "expense_case_id"],
["expense_cases.tenant_id", "expense_cases.id"],
name="fk_approval_tasks_tenant_expense_case",
ondelete="RESTRICT",
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("tenant_id", "id", name="uq_approval_tasks_tenant_id"),
sa.UniqueConstraint(
"tenant_id",
"node_entry_key",
name="uq_approval_tasks_tenant_node_entry",
),
sa.UniqueConstraint(
"tenant_id",
"node_instance_id",
"assignee_kind",
"assignee_key",
name="uq_approval_tasks_node_participant",
),
)
op.create_index(
"uq_approval_tasks_open_root_per_claim",
"approval_tasks",
["tenant_id", "claim_id"],
unique=True,
postgresql_where=sa.text(
"task_kind = 'root' AND status IN ('waiting', 'pending')"
),
)
op.create_index(
"ix_approval_tasks_personal_inbox",
"approval_tasks",
["tenant_id", "assignee_kind", "assignee_key", "status", "due_at"],
unique=False,
)
op.create_index(
"ix_approval_tasks_tenant_queue",
"approval_tasks",
["tenant_id", "status", "priority_score", "due_at"],
unique=False,
)
op.create_index(
"ix_approval_tasks_tenant_claim",
"approval_tasks",
["tenant_id", "claim_id", "node_sequence"],
unique=False,
)
op.create_index(
"ix_approval_tasks_tenant_node",
"approval_tasks",
["tenant_id", "node_instance_id", "sequence_order"],
unique=False,
)
op.create_table(
"approval_task_events",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=64), nullable=False),
sa.Column("task_id", sa.String(length=36), nullable=False),
sa.Column("node_instance_id", sa.String(length=120), nullable=False),
sa.Column("event_type", sa.String(length=50), nullable=False),
sa.Column("actor_id", sa.String(length=120), nullable=False),
sa.Column("actor_name", sa.String(length=120), nullable=False),
sa.Column("actor_type", sa.String(length=20), nullable=False),
sa.Column("request_id", sa.String(length=120), nullable=False),
sa.Column("expected_task_version", sa.Integer(), nullable=False),
sa.Column("result_task_version", sa.Integer(), nullable=False),
sa.Column("payload_fingerprint", sa.String(length=80), nullable=False),
sa.Column("payload_json", sa.JSON(), nullable=False),
sa.Column("before_json", sa.JSON(), nullable=False),
sa.Column("after_json", sa.JSON(), nullable=False),
sa.Column("response_json", sa.JSON(), nullable=False),
sa.Column("approval_action_ledger_id", sa.String(length=36), nullable=True),
sa.Column("business_event_id", sa.String(length=36), nullable=True),
sa.Column("correlation_id", sa.String(length=64), nullable=True),
sa.Column("causation_id", sa.String(length=64), nullable=True),
sa.Column(
"occurred_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.func.now(),
),
sa.CheckConstraint(
"length(trim(event_type)) > 0",
name="ck_approval_task_events_type",
),
sa.CheckConstraint(
"actor_type IN ('user', 'system', 'agent', 'service')",
name="ck_approval_task_events_actor_type",
),
sa.CheckConstraint(
"expected_task_version >= 0 AND result_task_version >= 1 "
"AND result_task_version >= expected_task_version",
name="ck_approval_task_events_version",
),
sa.ForeignKeyConstraint(
["tenant_id", "task_id"],
["approval_tasks.tenant_id", "approval_tasks.id"],
name="fk_approval_task_events_tenant_task",
ondelete="RESTRICT",
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint(
"tenant_id",
"actor_id",
"request_id",
name="uq_approval_task_events_actor_request",
),
sa.UniqueConstraint(
"tenant_id",
"task_id",
"result_task_version",
name="uq_approval_task_events_task_version",
),
)
op.create_index(
"ix_approval_task_events_tenant_task_time",
"approval_task_events",
["tenant_id", "task_id", "occurred_at"],
unique=False,
)
op.create_index(
"ix_approval_task_events_tenant_node_time",
"approval_task_events",
["tenant_id", "node_instance_id", "occurred_at"],
unique=False,
)
op.execute(
"CREATE FUNCTION reject_approval_task_event_mutation() "
"RETURNS trigger AS $$ "
"BEGIN "
"RAISE EXCEPTION 'approval_task_events is append-only'; "
"RETURN OLD; "
"END; "
"$$ LANGUAGE plpgsql"
)
op.execute(
"CREATE TRIGGER trg_approval_task_events_append_only "
"BEFORE UPDATE OR DELETE ON approval_task_events "
"FOR EACH ROW EXECUTE FUNCTION reject_approval_task_event_mutation()"
)
def downgrade() -> None:
_require_postgresql()
# 任务投影与事件共同组成审批审计链,降级前必须显式迁移或清空数据。
_require_empty_audit_chain_for_downgrade()
op.execute("DROP TRIGGER trg_approval_task_events_append_only ON approval_task_events")
op.execute("DROP FUNCTION reject_approval_task_event_mutation()")
op.drop_index(
"ix_approval_task_events_tenant_node_time",
table_name="approval_task_events",
)
op.drop_index(
"ix_approval_task_events_tenant_task_time",
table_name="approval_task_events",
)
op.drop_table("approval_task_events")
op.drop_index("ix_approval_tasks_tenant_node", table_name="approval_tasks")
op.drop_index("ix_approval_tasks_tenant_claim", table_name="approval_tasks")
op.drop_index("ix_approval_tasks_tenant_queue", table_name="approval_tasks")
op.drop_index("ix_approval_tasks_personal_inbox", table_name="approval_tasks")
op.drop_index("uq_approval_tasks_open_root_per_claim", table_name="approval_tasks")
op.drop_table("approval_tasks")

View File

@@ -0,0 +1,248 @@
"""add risk waiver decision metadata and separation of duties state
Revision ID: 20260716_0014
Revises: 20260716_0013
Create Date: 2026-07-16 19:10:00
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "20260716_0014"
down_revision: str | None = "20260716_0013"
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_0014 only supports PostgreSQL; "
f"refusing to mutate {dialect_name} without transactional constraint DDL"
)
def _require_no_waiver_audit_data_for_downgrade() -> None:
bind = op.get_bind()
disposition_count = int(
bind.scalar(
sa.text(
"SELECT COUNT(*) FROM risk_dispositions "
"WHERE waiver_requester_id IS NOT NULL "
"OR waiver_decision IS NOT NULL "
"OR lifecycle_status IN ('waived', 'waiver_rejected')"
)
)
or 0
)
event_count = int(
bind.scalar(
sa.text(
"SELECT COUNT(*) FROM risk_disposition_events "
"WHERE action IN ('approve_waiver', 'reject_waiver')"
)
)
or 0
)
if disposition_count or event_count:
raise RuntimeError(
"cannot downgrade risk waiver decisions: immutable waiver audit data exists "
f"({disposition_count} dispositions, {event_count} decision events)"
)
def upgrade() -> None:
_require_postgresql()
op.add_column(
"risk_dispositions",
sa.Column("waiver_requester_id", sa.String(length=120), nullable=True),
)
op.add_column(
"risk_dispositions",
sa.Column("waiver_requester_name", sa.String(length=120), nullable=True),
)
op.add_column(
"risk_dispositions",
sa.Column("waiver_requested_at", sa.DateTime(timezone=True), nullable=True),
)
op.add_column(
"risk_dispositions",
sa.Column("waiver_reason", sa.Text(), nullable=True),
)
op.add_column(
"risk_dispositions",
sa.Column("waiver_scope", sa.Text(), nullable=True),
)
op.add_column(
"risk_dispositions",
sa.Column("waiver_expires_at", sa.DateTime(timezone=True), nullable=True),
)
op.add_column(
"risk_dispositions",
sa.Column(
"waiver_conditions_json",
sa.JSON(),
nullable=False,
server_default=sa.text("'[]'::json"),
),
)
op.add_column(
"risk_dispositions",
sa.Column("waiver_decision", sa.String(length=20), nullable=True),
)
op.add_column(
"risk_dispositions",
sa.Column("waiver_decider_id", sa.String(length=120), nullable=True),
)
op.add_column(
"risk_dispositions",
sa.Column("waiver_decider_name", sa.String(length=120), nullable=True),
)
op.add_column(
"risk_dispositions",
sa.Column("waiver_decided_at", sa.DateTime(timezone=True), nullable=True),
)
op.add_column(
"risk_dispositions",
sa.Column("waiver_decision_reason", sa.Text(), nullable=True),
)
op.drop_constraint(
"ck_risk_dispositions_lifecycle",
"risk_dispositions",
type_="check",
)
op.create_check_constraint(
"ck_risk_dispositions_lifecycle",
"risk_dispositions",
"lifecycle_status IN ('open', 'supplement_requested', "
"'remediation_in_progress', 'waiver_requested', 'waived', "
"'waiver_rejected', 'resolved')",
)
op.create_check_constraint(
"ck_risk_dispositions_waiver_request",
"risk_dispositions",
"(waiver_requester_id IS NULL AND waiver_requester_name IS NULL "
"AND waiver_requested_at IS NULL AND waiver_reason IS NULL "
"AND waiver_scope IS NULL AND waiver_expires_at IS NULL) OR "
"(waiver_requester_id IS NOT NULL "
"AND length(trim(waiver_requester_id)) > 0 "
"AND waiver_requester_name IS NOT NULL "
"AND length(trim(waiver_requester_name)) > 0 "
"AND waiver_requested_at IS NOT NULL "
"AND waiver_reason IS NOT NULL AND length(trim(waiver_reason)) > 0 "
"AND waiver_scope IS NOT NULL AND length(trim(waiver_scope)) > 0 "
"AND waiver_expires_at IS NOT NULL "
"AND waiver_expires_at > waiver_requested_at)",
)
op.create_check_constraint(
"ck_risk_dispositions_waiver_decision",
"risk_dispositions",
"(waiver_decision IS NULL AND waiver_decider_id IS NULL "
"AND waiver_decider_name IS NULL AND waiver_decided_at IS NULL "
"AND waiver_decision_reason IS NULL) OR "
"(waiver_decision IN ('approved', 'rejected') "
"AND waiver_requester_id IS NOT NULL "
"AND waiver_decider_id IS NOT NULL "
"AND length(trim(waiver_decider_id)) > 0 "
"AND waiver_decider_name IS NOT NULL "
"AND length(trim(waiver_decider_name)) > 0 "
"AND waiver_decided_at IS NOT NULL "
"AND waiver_decision_reason IS NOT NULL "
"AND length(trim(waiver_decision_reason)) > 0)",
)
op.create_check_constraint(
"ck_risk_dispositions_waiver_lifecycle",
"risk_dispositions",
"(lifecycle_status != 'waived' OR waiver_decision = 'approved') AND "
"(lifecycle_status != 'waiver_rejected' OR waiver_decision = 'rejected')",
)
op.create_index(
"ix_risk_dispositions_tenant_waiver_expiry",
"risk_dispositions",
["tenant_id", "lifecycle_status", "waiver_expires_at"],
unique=False,
)
op.drop_constraint(
"ck_risk_disposition_events_action",
"risk_disposition_events",
type_="check",
)
op.create_check_constraint(
"ck_risk_disposition_events_action",
"risk_disposition_events",
"action IN ('confirm', 'false_positive', 'request_supplement', "
"'start_remediation', 'resolve', 'request_waiver', "
"'approve_waiver', 'reject_waiver')",
)
def downgrade() -> None:
_require_postgresql()
# 申请与决定元数据已经进入不可变事件快照时,禁止静默丢弃审计链。
_require_no_waiver_audit_data_for_downgrade()
op.drop_constraint(
"ck_risk_disposition_events_action",
"risk_disposition_events",
type_="check",
)
op.create_check_constraint(
"ck_risk_disposition_events_action",
"risk_disposition_events",
"action IN ('confirm', 'false_positive', 'request_supplement', "
"'start_remediation', 'resolve', 'request_waiver')",
)
op.drop_index(
"ix_risk_dispositions_tenant_waiver_expiry",
table_name="risk_dispositions",
)
op.drop_constraint(
"ck_risk_dispositions_waiver_lifecycle",
"risk_dispositions",
type_="check",
)
op.drop_constraint(
"ck_risk_dispositions_waiver_decision",
"risk_dispositions",
type_="check",
)
op.drop_constraint(
"ck_risk_dispositions_waiver_request",
"risk_dispositions",
type_="check",
)
op.drop_constraint(
"ck_risk_dispositions_lifecycle",
"risk_dispositions",
type_="check",
)
op.create_check_constraint(
"ck_risk_dispositions_lifecycle",
"risk_dispositions",
"lifecycle_status IN ('open', 'supplement_requested', "
"'remediation_in_progress', 'waiver_requested', 'resolved')",
)
for column_name in (
"waiver_decision_reason",
"waiver_decided_at",
"waiver_decider_name",
"waiver_decider_id",
"waiver_decision",
"waiver_conditions_json",
"waiver_expires_at",
"waiver_scope",
"waiver_reason",
"waiver_requested_at",
"waiver_requester_name",
"waiver_requester_id",
):
op.drop_column("risk_dispositions", column_name)

View File

@@ -0,0 +1,361 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import os
import sys
import uuid
from dataclasses import asdict
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from sqlalchemy import create_engine, text
from sqlalchemy.engine import Connection
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import Session
from sqlalchemy.pool import NullPool
SERVER_DIR = Path(__file__).resolve().parents[1]
SRC_DIR = SERVER_DIR / "src"
if str(SRC_DIR) not in sys.path:
sys.path.insert(0, str(SRC_DIR))
from app.db.maintenance_database_target import ( # noqa: E402
MaintenanceDatabaseTargetError,
validate_maintenance_database_target,
)
from app.db.migration_preflight import ( # noqa: E402
MigrationPreflightError,
validate_migration_state,
)
from app.services.approval_task_backfill import ( # noqa: E402
DEFAULT_BACKFILL_BATCH_SIZE,
MAX_BACKFILL_BATCH_SIZE,
ApprovalTaskBackfillCursor,
ApprovalTaskBackfillService,
)
REQUIRED_ALEMBIC_REVISION = "20260716_0014"
EXIT_SAFETY = 3
EXIT_LOCKED = 4
EXIT_RUNTIME = 6
class BackfillCommandError(RuntimeError):
def __init__(self, message: str, *, code: str, exit_code: int) -> None:
super().__init__(message)
self.code = code
self.exit_code = exit_code
def parse_timestamp(value: str) -> datetime:
normalized = str(value or "").strip()
if normalized.endswith("Z"):
normalized = f"{normalized[:-1]}+00:00"
try:
parsed = datetime.fromisoformat(normalized)
except ValueError as exc:
raise argparse.ArgumentTypeError("必须是带时区的 ISO 8601 时间") from exc
if parsed.tzinfo is None or parsed.utcoffset() is None:
raise argparse.ArgumentTypeError("时间必须显式包含时区")
return parsed.astimezone(UTC)
def positive_int(value: str) -> int:
try:
parsed = int(value)
except ValueError as exc:
raise argparse.ArgumentTypeError("必须是正整数") from exc
if parsed < 1:
raise argparse.ArgumentTypeError("必须是正整数")
return parsed
def batch_size(value: str) -> int:
parsed = positive_int(value)
if parsed > MAX_BACKFILL_BATCH_SIZE:
raise argparse.ArgumentTypeError(f"不能超过 {MAX_BACKFILL_BATCH_SIZE}")
return parsed
def non_empty_text(value: str) -> str:
normalized = str(value or "").strip()
if not normalized:
raise argparse.ArgumentTypeError("不能为空")
return normalized
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="为历史待审批费用单生成审批任务;默认只读预览。",
)
mode = parser.add_mutually_exclusive_group()
mode.add_argument("--dry-run", action="store_true", help="只读预览(默认)。")
mode.add_argument("--apply", action="store_true", help="显式写入审批任务和审计事件。")
parser.add_argument("--tenant-id", required=True, type=non_empty_text)
parser.add_argument("--created-before", required=True, type=parse_timestamp)
parser.add_argument("--batch-size", type=batch_size, default=DEFAULT_BACKFILL_BATCH_SIZE)
parser.add_argument("--max-claims", type=positive_int)
parser.add_argument("--sample-limit", type=positive_int, default=20)
parser.add_argument("--expected-host", required=True)
parser.add_argument("--expected-database", required=True)
parser.add_argument(
"--confirm-target",
help="apply 时必须精确等于解析后的 host:port/database。",
)
parser.add_argument("--allow-non-disposable-target", action="store_true")
return parser
def _cursor_payload(cursor: ApprovalTaskBackfillCursor | None) -> dict[str, str] | None:
if cursor is None:
return None
return {
"created_at": _isoformat(cursor.created_at),
"claim_id": cursor.claim_id,
}
def _item_payload(item: Any) -> dict[str, Any]:
payload = asdict(item)
payload["disposition"] = item.disposition.value
if item.entered_at is not None:
payload["entered_at"] = _isoformat(item.entered_at)
return payload
def _summary(args: argparse.Namespace, *, target: Any, revision: str) -> dict[str, Any]:
return {
"mode": "apply" if args.apply else "dry-run",
"database": {
"target": target.exact_target,
"url": target.sanitized_url,
"revision": revision,
},
"tenant_id": args.tenant_id,
"created_before": _isoformat(args.created_before),
"inspected": 0,
"eligible": 0,
"existing": 0,
"skipped": 0,
"created": 0,
"batches": 0,
"limited": False,
"last_cursor": None,
"samples": [],
}
def _page_size(configured: int, remaining: int | None) -> int:
return configured if remaining is None else min(configured, remaining)
def _preview(session: Session, args: argparse.Namespace, summary: dict[str, Any]) -> None:
service = ApprovalTaskBackfillService(
session,
tenant_id=args.tenant_id,
created_before=args.created_before,
)
cursor = None
remaining = args.max_claims
last_has_more = False
while remaining is None or remaining > 0:
page = service.preview(
batch_size=_page_size(args.batch_size, remaining),
after=cursor,
)
if not page.items:
break
summary["batches"] += 1
summary["inspected"] += page.inspected
summary["eligible"] += page.eligible
summary["existing"] += page.existing
summary["skipped"] += page.skipped
available = max(0, args.sample_limit - len(summary["samples"]))
summary["samples"].extend(_item_payload(item) for item in page.items[:available])
cursor = page.next_cursor
summary["last_cursor"] = _cursor_payload(cursor)
last_has_more = page.has_more
if remaining is not None:
remaining -= page.inspected
if not page.has_more:
break
summary["limited"] = bool(remaining == 0 and last_has_more)
def _acquire_lock(connection: Connection, tenant_id: str) -> str:
lock_name = f"approval-task-backfill:{tenant_id}"
acquired = connection.scalar(
text("SELECT pg_try_advisory_lock(hashtextextended(:name, 0))"),
{"name": lock_name},
)
connection.commit()
if not acquired:
raise BackfillCommandError(
"同一租户已有审批任务回填正在运行。",
code="advisory_lock_unavailable",
exit_code=EXIT_LOCKED,
)
return lock_name
def _release_lock(connection: Connection, lock_name: str) -> None:
if connection.in_transaction():
connection.rollback()
connection.execute(
text("SELECT pg_advisory_unlock(hashtextextended(:name, 0))"),
{"name": lock_name},
)
connection.commit()
def _apply(connection: Connection, args: argparse.Namespace, summary: dict[str, Any]) -> None:
lock_name = _acquire_lock(connection, args.tenant_id)
run_id = f"approval-task-backfill-{uuid.uuid4().hex}"
backfilled_at = datetime.now(UTC)
summary["run_id"] = run_id
summary["backfilled_at"] = _isoformat(backfilled_at)
cursor = None
remaining = args.max_claims
last_has_more = False
try:
with Session(bind=connection, autoflush=False, expire_on_commit=False) as session:
service = ApprovalTaskBackfillService(
session,
tenant_id=args.tenant_id,
created_before=args.created_before,
)
while remaining is None or remaining > 0:
session.execute(text("SET LOCAL lock_timeout = '5s'"))
result = service.apply_batch(
run_id=run_id,
batch_size=_page_size(args.batch_size, remaining),
after=cursor,
backfilled_at=backfilled_at,
)
if not result.items:
session.rollback()
break
session.commit()
summary["batches"] += 1
summary["inspected"] += result.inspected
summary["created"] += result.created
summary["existing"] += result.existing
summary["skipped"] += result.skipped
available = max(0, args.sample_limit - len(summary["samples"]))
summary["samples"].extend(_item_payload(item) for item in result.items[:available])
cursor = result.next_cursor
summary["last_cursor"] = _cursor_payload(cursor)
last_has_more = result.has_more
if remaining is not None:
remaining -= result.inspected
if not result.has_more:
break
summary["limited"] = bool(remaining == 0 and last_has_more)
finally:
_release_lock(connection, lock_name)
def run(args: argparse.Namespace) -> dict[str, Any]:
if args.apply and not str(args.confirm_target or "").strip():
raise BackfillCommandError(
"--apply 必须提供 --confirm-target 精确确认数据库目标。",
code="confirm_target_required",
exit_code=EXIT_SAFETY,
)
database_url = os.environ.get("DATABASE_URL", "")
target = validate_maintenance_database_target(
database_url,
expected_host=args.expected_host,
expected_database=args.expected_database,
apply=args.apply,
allow_non_disposable=args.allow_non_disposable_target,
confirm_target=args.confirm_target,
)
engine = create_engine(database_url, pool_pre_ping=True, poolclass=NullPool)
try:
with engine.connect() as connection:
database = str(connection.scalar(text("SELECT current_database()")) or "")
if database != target.database:
raise BackfillCommandError(
"连接后的数据库名与 DATABASE_URL 不一致。",
code="connected_database_mismatch",
exit_code=EXIT_SAFETY,
)
state = validate_migration_state(connection)
if state.revision != REQUIRED_ALEMBIC_REVISION:
raise BackfillCommandError(
f"数据库迁移版本必须为 {REQUIRED_ALEMBIC_REVISION}"
f"实际为 {state.revision or 'unversioned/base'}",
code="migration_revision_mismatch",
exit_code=EXIT_SAFETY,
)
connection.rollback()
summary = _summary(args, target=target, revision=state.revision)
with Session(bind=connection, autoflush=False) as session:
_preview(session, args, summary)
session.rollback()
if not args.apply:
summary["would_create"] = summary["eligible"]
return summary
preview = dict(summary)
summary.update(
inspected=0,
existing=0,
skipped=0,
created=0,
batches=0,
limited=False,
last_cursor=None,
samples=[],
preview=preview,
)
_apply(connection, args, summary)
return summary
finally:
engine.dispose()
def _isoformat(value: datetime) -> str:
normalized = value.replace(tzinfo=UTC) if value.tzinfo is None else value.astimezone(UTC)
return normalized.isoformat().replace("+00:00", "Z")
def _error_payload(exc: Exception, *, code: str) -> dict[str, str]:
return {"status": "error", "code": code, "message": str(exc)}
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
try:
payload = run(args)
except BackfillCommandError as exc:
print(json.dumps(_error_payload(exc, code=exc.code), ensure_ascii=False), file=sys.stderr)
return exc.exit_code
except MaintenanceDatabaseTargetError as exc:
print(json.dumps(_error_payload(exc, code=exc.code), ensure_ascii=False), file=sys.stderr)
return EXIT_SAFETY
except MigrationPreflightError as exc:
print(
json.dumps(
_error_payload(exc, code="migration_preflight_failed"),
ensure_ascii=False,
),
file=sys.stderr,
)
return EXIT_SAFETY
except (OSError, SQLAlchemyError) as exc:
print(
json.dumps(_error_payload(exc, code="database_runtime_error"), ensure_ascii=False),
file=sys.stderr,
)
return EXIT_RUNTIME
print(json.dumps(payload, ensure_ascii=False, indent=2, default=str))
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,394 @@
from __future__ import annotations
from typing import Annotated, NoReturn
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.api.deps import CurrentUserContext, get_current_user, get_db
from app.models.approval_task import ApprovalTask, ApprovalTaskEvent
from app.schemas.approval_task import (
ApprovalTaskActionBase,
ApprovalTaskAssignmentAction,
ApprovalTaskBatchApproveCreate,
ApprovalTaskBatchApproveRead,
ApprovalTaskCandidateRead,
ApprovalTaskDecisionAction,
ApprovalTaskDecisionRead,
ApprovalTaskListRead,
ApprovalTaskMutationRead,
ApprovalTaskParticipantsAction,
ApprovalTaskQueueItemRead,
ApprovalTaskReturnAction,
)
from app.schemas.reimbursement import ExpenseClaimRead
from app.services.approval_action_protocol import ApprovalActionConflictError
from app.services.approval_task_access_policy import ApprovalTaskPermissionError
from app.services.approval_task_actions import ApprovalTaskActionService
from app.services.approval_task_batch import ApprovalTaskBatchService
from app.services.approval_task_protocol import ApprovalTaskConflictError
from app.services.approval_task_query import ApprovalTaskQueryService
from app.services.expense_claim_risk_gate import ExpenseClaimRiskBlockedError
from app.services.expense_claim_tenant_scope import ExpenseClaimTenantScopeMixin
from app.services.expense_claims import ExpenseClaimService
router = APIRouter(prefix="/approval-tasks")
DbSession = Annotated[Session, Depends(get_db)]
CurrentUser = Annotated[CurrentUserContext, Depends(get_current_user)]
@router.get("", response_model=ApprovalTaskListRead, summary="查询个人审批任务队列")
def list_approval_tasks(
db: DbSession,
current_user: CurrentUser,
page: Annotated[int, Query(ge=1)] = 1,
page_size: Annotated[int, Query(ge=1, le=200)] = 20,
task_status: Annotated[str, Query(alias="status")] = "pending",
risk_level: str | None = None,
sla_state: str | None = None,
node_key: str | None = None,
task_kind: str | None = None,
batch_eligible: bool | None = None,
assignee: Annotated[str | None, Query(max_length=100)] = None,
sort: str = "priority_desc",
keyword: Annotated[str | None, Query(max_length=100)] = None,
) -> ApprovalTaskListRead:
try:
return ApprovalTaskQueryService(db).list_tasks(
current_user,
page=page,
page_size=page_size,
status=task_status,
risk_level=risk_level,
sla_state=sla_state,
node_key=node_key,
task_kind=task_kind,
batch_eligible=batch_eligible,
assignee=assignee,
sort=sort,
keyword=keyword,
)
except ValueError as error:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error
@router.get(
"/{task_id}",
response_model=ApprovalTaskQueueItemRead,
summary="查询审批任务详情",
)
def get_approval_task(
task_id: str,
db: DbSession,
current_user: CurrentUser,
) -> ApprovalTaskQueueItemRead:
item = ApprovalTaskQueryService(db).get_task(task_id, current_user)
if item is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Task not found")
return item
@router.get(
"/{task_id}/candidates",
response_model=list[ApprovalTaskCandidateRead],
summary="查询当前节点合格处理人",
)
def list_approval_task_candidates(
task_id: str,
db: DbSession,
current_user: CurrentUser,
action: str | None = None,
keyword: Annotated[str | None, Query(max_length=100)] = None,
limit: Annotated[int, Query(ge=1, le=100)] = 50,
) -> list[ApprovalTaskCandidateRead]:
try:
return ApprovalTaskActionService(db).candidates(
task_id,
current_user,
action=action,
keyword=keyword,
limit=limit,
)
except (LookupError, ApprovalTaskPermissionError) as error:
_raise_task_error(error)
@router.post(
"/{task_id}/delegate",
response_model=ApprovalTaskMutationRead,
summary="委托审批任务",
)
def delegate_approval_task(
task_id: str,
payload: ApprovalTaskAssignmentAction,
db: DbSession,
current_user: CurrentUser,
) -> ApprovalTaskMutationRead:
return _assignment_action(task_id, payload, db, current_user, action="delegate")
@router.post(
"/{task_id}/delegation/revoke",
response_model=ApprovalTaskMutationRead,
summary="撤销审批委托",
)
def revoke_approval_task_delegation(
task_id: str,
payload: ApprovalTaskActionBase,
db: DbSession,
current_user: CurrentUser,
) -> ApprovalTaskMutationRead:
try:
return ApprovalTaskActionService(db).revoke_delegation(task_id, current_user, payload)
except (LookupError, ValueError) as error:
_raise_task_error(error)
@router.post(
"/{task_id}/transfer",
response_model=ApprovalTaskMutationRead,
summary="永久转交审批任务",
)
def transfer_approval_task(
task_id: str,
payload: ApprovalTaskAssignmentAction,
db: DbSession,
current_user: CurrentUser,
) -> ApprovalTaskMutationRead:
return _assignment_action(task_id, payload, db, current_user, action="transfer")
@router.post(
"/{task_id}/add-sign",
response_model=ApprovalTaskMutationRead,
summary="发起顺序加签",
)
def add_sign_approval_task(
task_id: str,
payload: ApprovalTaskParticipantsAction,
db: DbSession,
current_user: CurrentUser,
) -> ApprovalTaskMutationRead:
return _participants_action(task_id, payload, db, current_user, action="add_sign")
@router.post(
"/{task_id}/countersign",
response_model=ApprovalTaskMutationRead,
summary="发起并行会签",
)
def countersign_approval_task(
task_id: str,
payload: ApprovalTaskParticipantsAction,
db: DbSession,
current_user: CurrentUser,
) -> ApprovalTaskMutationRead:
return _participants_action(task_id, payload, db, current_user, action="countersign")
@router.post(
"/{task_id}/escalate",
response_model=ApprovalTaskMutationRead,
summary="升级超时审批任务",
)
def escalate_approval_task(
task_id: str,
payload: ApprovalTaskActionBase,
db: DbSession,
current_user: CurrentUser,
) -> ApprovalTaskMutationRead:
try:
return ApprovalTaskActionService(db).escalate(task_id, current_user, payload)
except (LookupError, ValueError) as error:
_raise_task_error(error)
@router.post(
"/{task_id}/approve",
response_model=ApprovalTaskDecisionRead,
summary="通过审批任务",
)
def approve_approval_task(
task_id: str,
payload: ApprovalTaskDecisionAction,
db: DbSession,
current_user: CurrentUser,
) -> ApprovalTaskDecisionRead:
try:
task = _task_or_404(db, task_id, current_user)
if task.task_kind == "root":
claim = ExpenseClaimService(db).approve_claim(
task.claim_id,
current_user,
opinion=payload.opinion or payload.reason,
request_id=payload.request_id,
expected_status=task.claim_status_snapshot,
expected_approval_stage=task.claim_stage_snapshot,
task_id=task.id,
expected_task_version=payload.expected_task_version,
)
if claim is None:
raise LookupError("Claim not found.")
mutation = _mutation_for_request(db, task, payload.request_id)
if isinstance(claim, ExpenseClaimRead):
mutation = mutation.model_copy(update={"replayed": True})
claim_read = (
claim
if isinstance(claim, ExpenseClaimRead)
else ExpenseClaimRead.model_validate(claim)
)
else:
mutation = ApprovalTaskActionService(db).approve_child(
task.id,
current_user,
payload,
)
queue_item = ApprovalTaskQueryService(db).get_task(task.id, current_user)
if queue_item is None:
raise LookupError("Task not found.")
claim_read = queue_item.claim
return ApprovalTaskDecisionRead(mutation=mutation, claim=claim_read)
except (LookupError, ValueError) as error:
_raise_task_error(error)
@router.post(
"/{task_id}/return",
response_model=ApprovalTaskDecisionRead,
summary="退回审批任务关联单据",
)
def return_approval_task(
task_id: str,
payload: ApprovalTaskReturnAction,
db: DbSession,
current_user: CurrentUser,
) -> ApprovalTaskDecisionRead:
try:
task = _task_or_404(db, task_id, current_user)
claim = ExpenseClaimService(db).return_claim(
task.claim_id,
current_user,
reason=payload.reason,
reason_codes=payload.reason_codes,
request_id=payload.request_id,
expected_status=task.claim_status_snapshot,
expected_approval_stage=task.claim_stage_snapshot,
task_id=task.id,
expected_task_version=payload.expected_task_version,
)
if claim is None:
raise LookupError("Claim not found.")
mutation = _mutation_for_request(db, task, payload.request_id)
if isinstance(claim, ExpenseClaimRead):
mutation = mutation.model_copy(update={"replayed": True})
claim_read = (
claim
if isinstance(claim, ExpenseClaimRead)
else ExpenseClaimRead.model_validate(claim)
)
return ApprovalTaskDecisionRead(mutation=mutation, claim=claim_read)
except (LookupError, ValueError) as error:
_raise_task_error(error)
@router.post(
"/batch-approve",
response_model=ApprovalTaskBatchApproveRead,
summary="安全批量审批任务",
)
def batch_approve_tasks(
payload: ApprovalTaskBatchApproveCreate,
current_user: CurrentUser,
) -> ApprovalTaskBatchApproveRead:
return ApprovalTaskBatchService().approve(payload, current_user)
def _assignment_action(
task_id: str,
payload: ApprovalTaskAssignmentAction,
db: Session,
current_user: CurrentUserContext,
*,
action: str,
) -> ApprovalTaskMutationRead:
try:
return ApprovalTaskActionService(db).assign(
task_id,
current_user,
action=action,
payload=payload,
)
except (LookupError, ValueError) as error:
_raise_task_error(error)
def _participants_action(
task_id: str,
payload: ApprovalTaskParticipantsAction,
db: Session,
current_user: CurrentUserContext,
*,
action: str,
) -> ApprovalTaskMutationRead:
try:
return ApprovalTaskActionService(db).add_participants(
task_id,
current_user,
action=action,
payload=payload,
)
except (LookupError, ValueError) as error:
_raise_task_error(error)
def _task_or_404(
db: Session,
task_id: str,
current_user: CurrentUserContext,
) -> ApprovalTask:
tenant = ExpenseClaimTenantScopeMixin.normalize_tenant_id(current_user.tenant_id)
task = db.scalar(
select(ApprovalTask).where(
ApprovalTask.tenant_id == tenant,
ApprovalTask.id == str(task_id).strip(),
)
)
if task is None:
raise LookupError("Task not found.")
return task
def _mutation_for_request(
db: Session,
task: ApprovalTask,
request_id: str,
) -> ApprovalTaskMutationRead:
event = db.scalar(
select(ApprovalTaskEvent).where(
ApprovalTaskEvent.tenant_id == task.tenant_id,
ApprovalTaskEvent.task_id == task.id,
ApprovalTaskEvent.request_id == request_id,
)
)
if event is None:
raise ApprovalTaskConflictError("审批任务审计事件缺失。")
try:
return ApprovalTaskMutationRead.model_validate(event.response_json)
except ValueError as error:
raise ApprovalTaskConflictError("审批任务审计响应快照无效。") from error
def _raise_task_error(error: Exception) -> NoReturn:
if isinstance(error, LookupError):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(error)) from error
if isinstance(error, ApprovalTaskPermissionError):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(error)) from error
if isinstance(error, ExpenseClaimRiskBlockedError):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail={"code": "APPROVAL_BLOCKED_BY_OPEN_HIGH_RISK", "message": str(error)},
) from error
if isinstance(error, (ApprovalActionConflictError, ApprovalTaskConflictError)):
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

View File

@@ -14,6 +14,8 @@ from app.schemas.reimbursement import (
ExpenseClaimReturnPayload,
)
from app.services.approval_action_protocol import ApprovalActionConflictError
from app.services.approval_task_access_policy import ApprovalTaskPermissionError
from app.services.approval_task_protocol import ApprovalTaskConflictError
from app.services.expense_claim_risk_gate import ExpenseClaimRiskBlockedError
from app.services.expense_claims import ExpenseClaimService
@@ -43,6 +45,10 @@ def _raise_action_error(error: ValueError) -> NoReturn:
) from error
if isinstance(error, ApprovalActionConflictError):
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error)) from error
if isinstance(error, ApprovalTaskPermissionError):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(error)) from error
if isinstance(error, ApprovalTaskConflictError):
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
@@ -78,6 +84,8 @@ def return_expense_claim(
request_id=payload.request_id,
expected_status=payload.expected_status,
expected_approval_stage=payload.expected_approval_stage,
task_id=payload.task_id,
expected_task_version=payload.expected_task_version,
)
except ValueError as error:
_raise_action_error(error)
@@ -117,6 +125,8 @@ def approve_expense_claim(
request_id=payload.request_id,
expected_status=payload.expected_status,
expected_approval_stage=payload.expected_approval_stage,
task_id=payload.task_id,
expected_task_version=payload.expected_task_version,
)
except ValueError as error:
_raise_action_error(error)

View File

@@ -20,11 +20,13 @@ from app.schemas.risk_observation import (
RiskObservationListRead,
RiskObservationRead,
)
from app.services.risk_disposition_read_projection import RiskDispositionReadProjection
from app.services.risk_dispositions import (
RiskDispositionConflictError,
RiskDispositionPermissionError,
RiskDispositionService,
RiskDispositionVersionConflictError,
RiskWaiverDecisionPermissionError,
)
from app.services.risk_observation_access_policy import RiskObservationAccessPolicy
from app.services.risk_observations import RiskObservationService
@@ -69,7 +71,16 @@ def list_risk_observations(
limit=limit,
offset=offset,
)
return RiskObservationListRead(items=items, total=total, limit=limit, offset=offset)
projected_items = RiskDispositionReadProjection(db).observation_list_read(
items,
current_user,
)
return RiskObservationListRead(
items=projected_items,
total=total,
limit=limit,
offset=offset,
)
@router.get(
@@ -105,10 +116,14 @@ def list_claim_risk_observations(
) -> list[RiskObservationRead]:
if not RiskObservationAccessPolicy(db).can_read_claim_risks(claim_id, current_user):
raise _not_found()
return RiskObservationService(db).list_claim_observations(
observations = RiskObservationService(db).list_claim_observations(
claim_id,
tenant_id=current_user.tenant_id,
)
return RiskDispositionReadProjection(db).observation_list_read(
observations,
current_user,
)
@router.get(
@@ -123,10 +138,14 @@ def list_execution_log_risk_observations(
current_user: CurrentUser,
) -> list[RiskObservationRead]:
_require_pool_access(db, current_user)
return RiskObservationService(db).list_execution_log_observations(
observations = RiskObservationService(db).list_execution_log_observations(
execution_log_id,
tenant_id=current_user.tenant_id,
)
return RiskDispositionReadProjection(db).observation_list_read(
observations,
current_user,
)
@router.get(
@@ -156,7 +175,10 @@ def get_risk_observation(
status_code=status.HTTP_404_NOT_FOUND,
detail="Risk observation not found",
)
return observation
return RiskDispositionReadProjection(db).observation_read(
observation,
current_user,
)
@router.get(
@@ -178,7 +200,11 @@ def get_risk_observation_disposition(
)
if disposition is None:
raise _not_found("Risk disposition not found")
return disposition
return RiskDispositionReadProjection(db).disposition_read(
disposition,
observation,
current_user,
)
@router.post(
@@ -199,7 +225,19 @@ def execute_risk_disposition_action(
observation_key_or_id,
payload,
)
return mutation.response
observation = _get_observation_or_404(
db,
current_user,
observation_key_or_id,
)
projected_disposition = RiskDispositionReadProjection(db).disposition_snapshot_read(
mutation.response.disposition,
observation,
current_user,
)
return mutation.response.model_copy(
update={"disposition": projected_disposition},
)
@router.post(
@@ -303,6 +341,14 @@ def _execute_action(
"message": "风险处置状态已更新,请刷新证据链后重试。",
},
) from None
except RiskWaiverDecisionPermissionError as error:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={
"code": "RISK_WAIVER_DECISION_FORBIDDEN",
"message": str(error),
},
) from None
except RiskDispositionPermissionError as error:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,

View File

@@ -6,6 +6,7 @@ from app.api.v1.endpoints.agent_feedback import router as agent_feedback_router
from app.api.v1.endpoints.agent_runs import router as agent_runs_router
from app.api.v1.endpoints.agent_traces import router as agent_traces_router
from app.api.v1.endpoints.analytics import router as analytics_router
from app.api.v1.endpoints.approval_tasks import router as approval_tasks_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,
@@ -51,6 +52,7 @@ 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(approval_tasks_router, tags=["approval-tasks"])
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"])

View File

@@ -14,6 +14,7 @@ from app.models.ai_learning import AIDecision, AIDecisionFeedback, WorkflowOutco
from app.models.ai_memory import MemoryEntry, MemoryEvidenceLink
from app.models.approval import ApprovalRecord
from app.models.approval_action import ApprovalActionLedger
from app.models.approval_task import ApprovalTask, ApprovalTaskEvent
from app.models.attachment_association_job import AttachmentAssociationJob
from app.models.audit_log import AuditLog
from app.models.auth_session import AuthSession
@@ -63,6 +64,8 @@ __all__ = [
"AIDecisionFeedback",
"ApprovalRecord",
"ApprovalActionLedger",
"ApprovalTask",
"ApprovalTaskEvent",
"AttachmentAssociationJob",
"AuditLog",
"AuthSession",

View File

@@ -191,8 +191,54 @@ MIGRATION_OWNED_TABLES_BY_REVISION: dict[str, frozenset[str]] = {
"approval_action_ledgers",
}
),
"20260716_0013": 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",
"approval_tasks",
"approval_task_events",
}
),
"20260716_0014": 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",
"approval_tasks",
"approval_task_events",
}
),
}
if MIGRATION_OWNED_TABLES_BY_REVISION["20260716_0012"] != MIGRATION_OWNED_TABLES:
if MIGRATION_OWNED_TABLES_BY_REVISION["20260716_0014"] != MIGRATION_OWNED_TABLES:
raise RuntimeError("latest Alembic revision must own the centralized migration table set")
# 0008 之前这三张表由旧 bootstrap / 风险服务按需创建。迁移前置检查允许
@@ -266,6 +312,8 @@ def _validate_connection(connection: Connection) -> MigrationPreflightState:
"20260716_0010",
"20260716_0011",
"20260716_0012",
"20260716_0013",
"20260716_0014",
}
else frozenset()
)

View File

@@ -8,6 +8,8 @@ MIGRATION_OWNED_TABLES: frozenset[str] = frozenset(
{
"auth_sessions",
"approval_action_ledgers",
"approval_task_events",
"approval_tasks",
"attachment_association_jobs",
"ai_application_preview_decisions",
"ai_decisions",

View File

@@ -1,9 +1,9 @@
from __future__ import annotations
import threading
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from logging import Logger
import threading
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
@@ -16,9 +16,9 @@ from app.db.session import get_session_factory
from app.middleware.logging import AccessLogMiddleware
from app.schemas.common import RootStatusRead
from app.services.agent_foundation import prepare_agent_foundation
from app.services.approval_task_scheduler import approval_task_scheduler
from app.services.digital_employee_reminder_scheduler import digital_employee_reminder_scheduler
from app.services.employee import prepare_employee_directory
from app.services.employee import EmployeeService
from app.services.employee import EmployeeService, prepare_employee_directory
from app.services.employee_profile_scheduler import employee_profile_scheduler
from app.services.finance_dashboard_scheduler import finance_dashboard_scheduler
from app.services.finance_report_scheduler import finance_report_scheduler
@@ -34,7 +34,11 @@ from app.services.user_session_metrics import UserSessionMetricService
def _effective_server_workers(settings: object) -> int:
server_workers = getattr(settings, "server_workers", None)
web_concurrency = getattr(settings, "web_concurrency", None)
workers = web_concurrency if int(server_workers or 1) <= 1 and web_concurrency else server_workers
workers = (
web_concurrency
if int(server_workers or 1) <= 1 and web_concurrency
else server_workers
)
try:
return max(1, int(workers or 1))
except (TypeError, ValueError):
@@ -100,6 +104,7 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]:
schedulers_started = _should_start_background_schedulers(settings)
if schedulers_started:
knowledge_index_scheduler.start()
approval_task_scheduler.start()
finance_dashboard_scheduler.start()
employee_profile_scheduler.start()
digital_employee_reminder_scheduler.start()
@@ -118,6 +123,7 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]:
)
yield
if schedulers_started:
approval_task_scheduler.shutdown()
finance_report_scheduler.shutdown()
digital_employee_reminder_scheduler.shutdown()
employee_profile_scheduler.shutdown()

View File

@@ -12,6 +12,7 @@ from app.models.ai_learning import AIDecision, AIDecisionFeedback, WorkflowOutco
from app.models.ai_memory import MemoryEntry, MemoryEvidenceLink
from app.models.approval import ApprovalRecord
from app.models.approval_action import ApprovalActionLedger
from app.models.approval_task import ApprovalTask, ApprovalTaskEvent
from app.models.attachment_association_job import AttachmentAssociationJob
from app.models.audit_log import AuditLog
from app.models.auth_session import AuthSession
@@ -57,6 +58,8 @@ __all__ = [
"AIApplicationPreviewDecision",
"ApprovalRecord",
"ApprovalActionLedger",
"ApprovalTask",
"ApprovalTaskEvent",
"AttachmentAssociationJob",
"AuditLog",
"AuthSession",

View File

@@ -0,0 +1,361 @@
from __future__ import annotations
import uuid
from datetime import datetime
from decimal import Decimal
from typing import Any
from sqlalchemy import (
Boolean,
CheckConstraint,
DateTime,
ForeignKeyConstraint,
Index,
Integer,
Numeric,
String,
Text,
UniqueConstraint,
func,
text,
)
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 ApprovalTask(Base):
"""审批节点的个人工作项投影,不承载 BPMN 流程定义。"""
__tablename__ = "approval_tasks"
__table_args__ = (
UniqueConstraint(
"tenant_id",
"id",
name="uq_approval_tasks_tenant_id",
),
UniqueConstraint(
"tenant_id",
"node_entry_key",
name="uq_approval_tasks_tenant_node_entry",
),
UniqueConstraint(
"tenant_id",
"node_instance_id",
"assignee_kind",
"assignee_key",
name="uq_approval_tasks_node_participant",
),
ForeignKeyConstraint(
["tenant_id", "parent_task_id"],
["approval_tasks.tenant_id", "approval_tasks.id"],
ondelete="RESTRICT",
name="fk_approval_tasks_tenant_parent",
),
ForeignKeyConstraint(
["tenant_id", "expense_case_id"],
["expense_cases.tenant_id", "expense_cases.id"],
ondelete="RESTRICT",
name="fk_approval_tasks_tenant_expense_case",
),
CheckConstraint(
"task_kind IN ('root', 'add_sign', 'countersign')",
name="ck_approval_tasks_task_kind",
),
CheckConstraint(
"node_key IN ('direct_manager', 'budget_manager', 'finance')",
name="ck_approval_tasks_node_key",
),
CheckConstraint(
"coordination_mode IN ('single', 'sequential_before_owner', 'parallel_all')",
name="ck_approval_tasks_coordination_mode",
),
CheckConstraint(
"owner_kind IN ('employee', 'role')",
name="ck_approval_tasks_owner_kind",
),
CheckConstraint(
"assignee_kind IN ('employee', 'role')",
name="ck_approval_tasks_assignee_kind",
),
CheckConstraint(
"status IN ('waiting', 'pending', 'completed', 'returned', "
"'cancelled', 'superseded')",
name="ck_approval_tasks_status",
),
CheckConstraint(
"decision IS NULL OR decision IN ('approved', 'returned')",
name="ck_approval_tasks_decision",
),
CheckConstraint(
"entered_at_source IN ('workflow_event', 'submitted_at', 'updated_at', 'backfill')",
name="ck_approval_tasks_entered_at_source",
),
CheckConstraint(
"priority_tier IN ('normal', 'high', 'urgent')",
name="ck_approval_tasks_priority_tier",
),
CheckConstraint(
"risk_level IN ('low', 'medium', 'high', 'critical')",
name="ck_approval_tasks_risk_level",
),
CheckConstraint(
"(task_kind = 'root' AND parent_task_id IS NULL AND sequence_order = 0) OR "
"(task_kind IN ('add_sign', 'countersign') "
"AND parent_task_id IS NOT NULL AND sequence_order > 0)",
name="ck_approval_tasks_parent_shape",
),
CheckConstraint(
"(status = 'completed' AND decision = 'approved' "
"AND completed_at IS NOT NULL AND cancelled_at IS NULL) OR "
"(status = 'returned' AND decision = 'returned' "
"AND completed_at IS NOT NULL AND cancelled_at IS NULL) OR "
"(status IN ('cancelled', 'superseded') AND decision IS NULL "
"AND completed_at IS NULL AND cancelled_at IS NOT NULL) OR "
"(status IN ('waiting', 'pending') AND decision IS NULL "
"AND completed_at IS NULL AND cancelled_at IS NULL)",
name="ck_approval_tasks_lifecycle",
),
CheckConstraint(
"status NOT IN ('pending', 'completed', 'returned') OR activated_at IS NOT NULL",
name="ck_approval_tasks_activation",
),
CheckConstraint(
"delegation_expires_at IS NULL OR delegated_by IS NOT NULL",
name="ck_approval_tasks_delegation",
),
CheckConstraint("version >= 1", name="ck_approval_tasks_version"),
CheckConstraint("node_sequence >= 1", name="ck_approval_tasks_node_sequence"),
CheckConstraint("sla_hours_snapshot > 0", name="ck_approval_tasks_sla_hours"),
CheckConstraint("due_at >= entered_at", name="ck_approval_tasks_due_at"),
CheckConstraint("escalation_level >= 0", name="ck_approval_tasks_escalation"),
CheckConstraint(
"priority_score >= 0 AND priority_score <= 100",
name="ck_approval_tasks_priority_score",
),
CheckConstraint(
"open_risk_count >= 0",
name="ck_approval_tasks_open_risk_count",
),
CheckConstraint(
"evidence_completeness >= 0 AND evidence_completeness <= 1",
name="ck_approval_tasks_evidence_completeness",
),
Index(
"uq_approval_tasks_open_root_per_claim",
"tenant_id",
"claim_id",
unique=True,
postgresql_where=text(
"task_kind = 'root' AND status IN ('waiting', 'pending')"
),
).ddl_if(dialect="postgresql"),
Index(
"ix_approval_tasks_personal_inbox",
"tenant_id",
"assignee_kind",
"assignee_key",
"status",
"due_at",
),
Index(
"ix_approval_tasks_tenant_queue",
"tenant_id",
"status",
"priority_score",
"due_at",
),
Index(
"ix_approval_tasks_tenant_claim",
"tenant_id",
"claim_id",
"node_sequence",
),
Index(
"ix_approval_tasks_tenant_node",
"tenant_id",
"node_instance_id",
"sequence_order",
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_new_id)
tenant_id: Mapped[str] = mapped_column(String(64), nullable=False)
claim_id: Mapped[str] = mapped_column(String(36), nullable=False)
expense_case_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
node_instance_id: Mapped[str] = mapped_column(String(120), nullable=False)
node_entry_key: Mapped[str] = mapped_column(String(160), nullable=False)
parent_task_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
task_kind: Mapped[str] = mapped_column(String(24), nullable=False)
node_key: Mapped[str] = mapped_column(String(32), nullable=False)
node_label: Mapped[str] = mapped_column(String(120), nullable=False)
node_sequence: Mapped[int] = mapped_column(Integer, nullable=False)
sequence_order: Mapped[int] = mapped_column(
Integer, nullable=False, default=0, server_default="0"
)
coordination_mode: Mapped[str] = mapped_column(String(32), nullable=False)
owner_kind: Mapped[str] = mapped_column(String(20), nullable=False)
owner_key: Mapped[str] = mapped_column(String(120), nullable=False)
owner_employee_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
owner_name: Mapped[str] = mapped_column(String(120), nullable=False)
assignee_kind: Mapped[str] = mapped_column(String(20), nullable=False)
assignee_key: Mapped[str] = mapped_column(String(120), nullable=False)
assignee_employee_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
assignee_name: Mapped[str] = mapped_column(String(120), nullable=False)
delegated_by: Mapped[str | None] = mapped_column(String(120), nullable=True)
delegation_expires_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
status: Mapped[str] = mapped_column(
String(24), nullable=False, default="waiting", server_default="waiting"
)
decision: Mapped[str | None] = mapped_column(String(20), nullable=True)
opinion: Mapped[str | None] = mapped_column(Text(), nullable=True)
version: Mapped[int] = mapped_column(Integer, nullable=False, default=1, server_default="1")
claim_status_snapshot: Mapped[str] = mapped_column(String(30), nullable=False)
claim_stage_snapshot: Mapped[str] = mapped_column(String(50), nullable=False)
entered_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
entered_at_source: Mapped[str] = mapped_column(String(24), nullable=False)
activated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
sla_hours_snapshot: Mapped[int] = mapped_column(Integer, nullable=False)
due_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
cancelled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
escalation_level: Mapped[int] = mapped_column(
Integer, nullable=False, default=0, server_default="0"
)
escalated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
next_escalation_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
priority_score: Mapped[int] = mapped_column(
Integer, nullable=False, default=0, server_default="0"
)
priority_tier: Mapped[str] = mapped_column(
String(16), nullable=False, default="normal", server_default="normal"
)
priority_reasons_json: Mapped[list[dict[str, Any]]] = mapped_column(
JSON, nullable=False, default=list
)
risk_level: Mapped[str] = mapped_column(
String(16), nullable=False, default="low", server_default="low"
)
open_risk_count: Mapped[int] = mapped_column(
Integer, nullable=False, default=0, server_default="0"
)
evidence_completeness: Mapped[Decimal] = mapped_column(
Numeric(5, 4), nullable=False, default=Decimal("0"), server_default="0"
)
batch_eligible: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False, server_default="false"
)
batch_block_reasons_json: Mapped[list[str]] = mapped_column(
JSON, nullable=False, default=list
)
projection_updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
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(),
)
claim = relationship(
"ExpenseClaim",
primaryjoin="foreign(ApprovalTask.claim_id) == ExpenseClaim.id",
foreign_keys=[claim_id],
viewonly=True,
)
events = relationship(
"ApprovalTaskEvent",
back_populates="task",
order_by="asc(ApprovalTaskEvent.occurred_at)",
passive_deletes=True,
)
class ApprovalTaskEvent(Base):
"""审批任务的不可变事实记录;所有写动作都以事件响应支持幂等重放。"""
__tablename__ = "approval_task_events"
__table_args__ = (
UniqueConstraint(
"tenant_id",
"actor_id",
"request_id",
name="uq_approval_task_events_actor_request",
),
UniqueConstraint(
"tenant_id",
"task_id",
"result_task_version",
name="uq_approval_task_events_task_version",
),
ForeignKeyConstraint(
["tenant_id", "task_id"],
["approval_tasks.tenant_id", "approval_tasks.id"],
ondelete="RESTRICT",
name="fk_approval_task_events_tenant_task",
),
CheckConstraint(
"length(trim(event_type)) > 0",
name="ck_approval_task_events_type",
),
CheckConstraint(
"actor_type IN ('user', 'system', 'agent', 'service')",
name="ck_approval_task_events_actor_type",
),
CheckConstraint(
"expected_task_version >= 0 AND result_task_version >= 1 "
"AND result_task_version >= expected_task_version",
name="ck_approval_task_events_version",
),
Index(
"ix_approval_task_events_tenant_task_time",
"tenant_id",
"task_id",
"occurred_at",
),
Index(
"ix_approval_task_events_tenant_node_time",
"tenant_id",
"node_instance_id",
"occurred_at",
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_new_id)
tenant_id: Mapped[str] = mapped_column(String(64), nullable=False)
task_id: Mapped[str] = mapped_column(String(36), nullable=False)
node_instance_id: Mapped[str] = mapped_column(String(120), nullable=False)
event_type: Mapped[str] = mapped_column(String(50), nullable=False)
actor_id: Mapped[str] = mapped_column(String(120), nullable=False)
actor_name: Mapped[str] = mapped_column(String(120), nullable=False)
actor_type: Mapped[str] = mapped_column(String(20), nullable=False)
request_id: Mapped[str] = mapped_column(String(120), nullable=False)
expected_task_version: Mapped[int] = mapped_column(Integer, nullable=False)
result_task_version: Mapped[int] = mapped_column(Integer, nullable=False)
payload_fingerprint: Mapped[str] = mapped_column(String(80), nullable=False)
payload_json: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False, default=dict)
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)
response_json: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False, default=dict)
approval_action_ledger_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
business_event_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
correlation_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
causation_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
occurred_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
task = relationship("ApprovalTask", back_populates="events")

View File

@@ -46,10 +46,46 @@ class RiskDisposition(Base):
),
CheckConstraint(
"lifecycle_status IN ('open', 'supplement_requested', "
"'remediation_in_progress', 'waiver_requested', 'resolved')",
"'remediation_in_progress', 'waiver_requested', 'waived', "
"'waiver_rejected', 'resolved')",
name="ck_risk_dispositions_lifecycle",
),
CheckConstraint("version >= 0", name="ck_risk_dispositions_version"),
CheckConstraint(
"(waiver_requester_id IS NULL AND waiver_requester_name IS NULL "
"AND waiver_requested_at IS NULL AND waiver_reason IS NULL "
"AND waiver_scope IS NULL AND waiver_expires_at IS NULL) OR "
"(waiver_requester_id IS NOT NULL "
"AND length(trim(waiver_requester_id)) > 0 "
"AND waiver_requester_name IS NOT NULL "
"AND length(trim(waiver_requester_name)) > 0 "
"AND waiver_requested_at IS NOT NULL "
"AND waiver_reason IS NOT NULL AND length(trim(waiver_reason)) > 0 "
"AND waiver_scope IS NOT NULL AND length(trim(waiver_scope)) > 0 "
"AND waiver_expires_at IS NOT NULL "
"AND waiver_expires_at > waiver_requested_at)",
name="ck_risk_dispositions_waiver_request",
),
CheckConstraint(
"(waiver_decision IS NULL AND waiver_decider_id IS NULL "
"AND waiver_decider_name IS NULL AND waiver_decided_at IS NULL "
"AND waiver_decision_reason IS NULL) OR "
"(waiver_decision IN ('approved', 'rejected') "
"AND waiver_requester_id IS NOT NULL "
"AND waiver_decider_id IS NOT NULL "
"AND length(trim(waiver_decider_id)) > 0 "
"AND waiver_decider_name IS NOT NULL "
"AND length(trim(waiver_decider_name)) > 0 "
"AND waiver_decided_at IS NOT NULL "
"AND waiver_decision_reason IS NOT NULL "
"AND length(trim(waiver_decision_reason)) > 0)",
name="ck_risk_dispositions_waiver_decision",
),
CheckConstraint(
"(lifecycle_status != 'waived' OR waiver_decision = 'approved') AND "
"(lifecycle_status != 'waiver_rejected' OR waiver_decision = 'rejected')",
name="ck_risk_dispositions_waiver_lifecycle",
),
ForeignKeyConstraint(
["tenant_id", "observation_id"],
["risk_observations.tenant_id", "risk_observations.id"],
@@ -63,6 +99,12 @@ class RiskDisposition(Base):
"due_at",
),
Index("ix_risk_dispositions_assignee", "tenant_id", "assignee"),
Index(
"ix_risk_dispositions_tenant_waiver_expiry",
"tenant_id",
"lifecycle_status",
"waiver_expires_at",
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_new_id)
@@ -89,6 +131,26 @@ class RiskDisposition(Base):
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)
waiver_requester_id: Mapped[str | None] = mapped_column(String(120), nullable=True)
waiver_requester_name: Mapped[str | None] = mapped_column(String(120), nullable=True)
waiver_requested_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
waiver_reason: Mapped[str | None] = mapped_column(Text(), nullable=True)
waiver_scope: Mapped[str | None] = mapped_column(Text(), nullable=True)
waiver_expires_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
waiver_conditions_json: Mapped[list[str]] = mapped_column(
JSON, nullable=False, default=list
)
waiver_decision: Mapped[str | None] = mapped_column(String(20), nullable=True)
waiver_decider_id: Mapped[str | None] = mapped_column(String(120), nullable=True)
waiver_decider_name: Mapped[str | None] = mapped_column(String(120), nullable=True)
waiver_decided_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
waiver_decision_reason: Mapped[str | None] = mapped_column(Text(), nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
@@ -128,7 +190,8 @@ class RiskDispositionEvent(Base):
),
CheckConstraint(
"action IN ('confirm', 'false_positive', 'request_supplement', "
"'start_remediation', 'resolve', 'request_waiver')",
"'start_remediation', 'resolve', 'request_waiver', "
"'approve_waiver', 'reject_waiver')",
name="ck_risk_disposition_events_action",
),
CheckConstraint("version > 0", name="ck_risk_disposition_events_version"),

View File

@@ -0,0 +1,279 @@
from __future__ import annotations
from datetime import datetime
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from app.schemas.reimbursement import ExpenseClaimRead
ApprovalTaskKind = Literal["root", "add_sign", "countersign"]
ApprovalNodeKey = Literal["direct_manager", "budget_manager", "finance"]
ApprovalTaskStatus = Literal[
"waiting",
"pending",
"completed",
"returned",
"cancelled",
"superseded",
]
ApprovalTaskDecision = Literal["approved", "returned"]
ApprovalTaskAction = Literal[
"delegate",
"delegation_revoke",
"transfer",
"add_sign",
"countersign",
"approve",
"return",
"sla_escalate",
]
class ApprovalTaskEventRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
tenant_id: str
task_id: str
node_instance_id: str
event_type: str
actor_id: str
actor_name: str
actor_type: str
request_id: str
expected_task_version: int
result_task_version: int
payload_json: dict[str, Any] = Field(default_factory=dict)
before_json: dict[str, Any] = Field(default_factory=dict)
after_json: dict[str, Any] = Field(default_factory=dict)
approval_action_ledger_id: str | None = None
business_event_id: str | None = None
correlation_id: str | None = None
causation_id: str | None = None
occurred_at: datetime
class ApprovalTaskRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
tenant_id: str
claim_id: str
expense_case_id: str | None = None
node_instance_id: str
node_entry_key: str
parent_task_id: str | None = None
task_kind: ApprovalTaskKind
node_key: ApprovalNodeKey
node_label: str
node_sequence: int
sequence_order: int
coordination_mode: Literal["single", "sequential_before_owner", "parallel_all"]
owner_kind: Literal["employee", "role"]
owner_key: str
owner_employee_id: str | None = None
owner_name: str
assignee_kind: Literal["employee", "role"]
assignee_key: str
assignee_employee_id: str | None = None
assignee_name: str
delegated_by: str | None = None
delegation_expires_at: datetime | None = None
status: ApprovalTaskStatus
decision: ApprovalTaskDecision | None = None
opinion: str | None = None
version: int
claim_status_snapshot: str
claim_stage_snapshot: str
entered_at: datetime
entered_at_source: Literal["workflow_event", "submitted_at", "updated_at", "backfill"]
activated_at: datetime | None = None
sla_hours_snapshot: int
due_at: datetime
completed_at: datetime | None = None
cancelled_at: datetime | None = None
escalation_level: int
escalated_at: datetime | None = None
next_escalation_at: datetime | None = None
priority_score: int
priority_tier: Literal["normal", "high", "urgent"]
priority_reasons_json: list[dict[str, Any]] = Field(default_factory=list)
risk_level: Literal["low", "medium", "high", "critical"]
open_risk_count: int
evidence_completeness: float
batch_eligible: bool
batch_block_reasons_json: list[str] = Field(default_factory=list)
projection_updated_at: datetime
created_at: datetime
updated_at: datetime
can_act: bool = False
available_actions: list[str] = Field(default_factory=list)
read_only_reason: str = ""
class ApprovalTaskMutationRead(BaseModel):
task: ApprovalTaskRead
event: ApprovalTaskEventRead
related_tasks: list[ApprovalTaskRead] = Field(default_factory=list)
replayed: bool = False
class ApprovalTaskQueueItemRead(BaseModel):
task: ApprovalTaskRead
claim: ExpenseClaimRead
class ApprovalTaskListRead(BaseModel):
items: list[ApprovalTaskQueueItemRead] = Field(default_factory=list)
total: int = 0
page: int = 1
page_size: int = 20
total_pages: int = 0
generated_at: datetime
class ApprovalTaskDecisionRead(BaseModel):
mutation: ApprovalTaskMutationRead
claim: ExpenseClaimRead
class ApprovalTaskActionBase(BaseModel):
request_id: str = Field(min_length=8, max_length=120)
expected_task_version: int = Field(ge=1)
reason: str = Field(min_length=2, max_length=500)
@field_validator("request_id", "reason", mode="before")
@classmethod
def normalize_text(cls, value: Any) -> str:
return str(value or "").strip()
class ApprovalTaskAssignmentAction(ApprovalTaskActionBase):
target_employee_id: str = Field(min_length=1, max_length=36)
expires_at: datetime | None = None
@field_validator("target_employee_id", mode="before")
@classmethod
def normalize_employee_id(cls, value: Any) -> str:
return str(value or "").strip()
class ApprovalTaskParticipantsAction(ApprovalTaskActionBase):
participant_employee_ids: list[str] = Field(min_length=1, max_length=10)
@field_validator("participant_employee_ids", mode="before")
@classmethod
def normalize_participants(cls, value: Any) -> list[str]:
if not isinstance(value, list):
return []
normalized = [str(item or "").strip() for item in value]
return [item for item in normalized if item]
@model_validator(mode="after")
def validate_unique_participants(self) -> ApprovalTaskParticipantsAction:
if len(set(self.participant_employee_ids)) != len(self.participant_employee_ids):
raise ValueError("加签或会签参与人不能重复。")
return self
class ApprovalTaskDecisionAction(ApprovalTaskActionBase):
opinion: str | None = Field(default=None, max_length=500)
@field_validator("opinion", mode="before")
@classmethod
def normalize_opinion(cls, value: Any) -> str | None:
normalized = str(value or "").strip()
return normalized or None
class ApprovalTaskReturnAction(ApprovalTaskActionBase):
reason_codes: list[str] = Field(default_factory=list, max_length=10)
@field_validator("reason_codes", mode="before")
@classmethod
def normalize_reason_codes(cls, value: Any) -> list[str]:
if not isinstance(value, list):
return []
return list(
dict.fromkeys(
item
for item in (str(entry or "").strip() for entry in value)
if item
)
)
class ApprovalTaskBatchApproveItem(BaseModel):
task_id: str = Field(min_length=1, max_length=36)
expected_task_version: int = Field(ge=1)
expected_status: str = Field(min_length=1, max_length=30)
expected_approval_stage: str = Field(min_length=1, max_length=50)
opinion: str | None = Field(default=None, max_length=500)
@field_validator(
"task_id",
"expected_status",
"expected_approval_stage",
"opinion",
mode="before",
)
@classmethod
def normalize_batch_text(cls, value: Any) -> Any:
if value is None:
return None
return str(value).strip()
class ApprovalTaskBatchApproveCreate(BaseModel):
batch_request_id: str = Field(min_length=8, max_length=80)
items: list[ApprovalTaskBatchApproveItem] = Field(min_length=1, max_length=20)
@field_validator("batch_request_id", mode="before")
@classmethod
def normalize_batch_request_id(cls, value: Any) -> str:
return str(value or "").strip()
@model_validator(mode="after")
def validate_unique_tasks(self) -> ApprovalTaskBatchApproveCreate:
task_ids = [item.task_id for item in self.items]
if len(set(task_ids)) != len(task_ids):
raise ValueError("批量审批不能包含重复任务。")
return self
class ApprovalTaskBatchItemRead(BaseModel):
task_id: str
claim_id: str | None = None
status: Literal[
"succeeded",
"replayed",
"conflict",
"blocked",
"forbidden",
"failed",
]
code: str
message: str
claim: dict[str, Any] | None = None
class ApprovalTaskBatchApproveRead(BaseModel):
batch_request_id: str
status: Literal["succeeded", "partial", "failed"]
succeeded_count: int = 0
replayed_count: int = 0
conflict_count: int = 0
blocked_count: int = 0
forbidden_count: int = 0
failed_count: int = 0
items: list[ApprovalTaskBatchItemRead] = Field(default_factory=list)
class ApprovalTaskCandidateRead(BaseModel):
employee_id: str
employee_no: str
name: str
email: str
qualified: bool
reason: str = ""

View File

@@ -4,7 +4,7 @@ from datetime import date, datetime
from decimal import Decimal
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from app.services.expense_claim_budget_risk_flags import dedupe_budget_risk_flags
@@ -298,10 +298,26 @@ class ExpenseClaimActionProtocolPayload(BaseModel):
class ExpenseClaimReturnPayload(ExpenseClaimActionProtocolPayload):
reason: str | None = Field(default=None, max_length=500)
reason_codes: list[str] = Field(default_factory=list, max_length=10)
task_id: str | None = Field(default=None, min_length=1, max_length=36)
expected_task_version: int | None = Field(default=None, ge=1)
@model_validator(mode="after")
def validate_task_precondition(self) -> ExpenseClaimReturnPayload:
if (self.task_id is None) != (self.expected_task_version is None):
raise ValueError("task_id 与 expected_task_version 必须同时提供。")
return self
class ExpenseClaimApprovalPayload(ExpenseClaimActionProtocolPayload):
opinion: str | None = Field(default=None, max_length=500)
task_id: str | None = Field(default=None, min_length=1, max_length=36)
expected_task_version: int | None = Field(default=None, ge=1)
@model_validator(mode="after")
def validate_task_precondition(self) -> ExpenseClaimApprovalPayload:
if (self.task_id is None) != (self.expected_task_version is None):
raise ValueError("task_id 与 expected_task_version 必须同时提供。")
return self
class ExpenseClaimPaymentPayload(ExpenseClaimActionProtocolPayload):

View File

@@ -12,6 +12,8 @@ RiskDispositionAction = Literal[
"start_remediation",
"resolve",
"request_waiver",
"approve_waiver",
"reject_waiver",
]
RiskAdjudication = Literal["unreviewed", "confirmed", "false_positive"]
RiskLifecycleStatus = Literal[
@@ -19,6 +21,8 @@ RiskLifecycleStatus = Literal[
"supplement_requested",
"remediation_in_progress",
"waiver_requested",
"waived",
"waiver_rejected",
"resolved",
]
@@ -31,8 +35,20 @@ class RiskDispositionActionCreate(BaseModel):
assignee: str | None = Field(default=None, max_length=120)
due_at: datetime | None = None
resolution: str | None = Field(default=None, max_length=2000)
waiver_reason: str | None = Field(default=None, max_length=2000)
waiver_scope: str | None = Field(default=None, max_length=1000)
waiver_expires_at: datetime | None = None
waiver_conditions: list[str] = Field(default_factory=list, max_length=20)
@field_validator("request_id", "comment", "assignee", "resolution", mode="before")
@field_validator(
"request_id",
"comment",
"assignee",
"resolution",
"waiver_reason",
"waiver_scope",
mode="before",
)
@classmethod
def normalize_text(cls, value: Any) -> Any:
if value is None:
@@ -40,14 +56,42 @@ class RiskDispositionActionCreate(BaseModel):
normalized = str(value).strip()
return normalized or None
@field_validator("waiver_conditions", mode="before")
@classmethod
def normalize_waiver_conditions(cls, value: Any) -> list[str]:
if not isinstance(value, list):
return []
normalized = [str(item or "").strip() for item in value]
if any(len(item) > 500 for item in normalized):
raise ValueError("单条豁免条件不能超过 500 个字符")
return list(dict.fromkeys(item for item in normalized if item))
@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
):
if self.action in {"false_positive", "request_supplement"} and not self.comment:
raise ValueError(f"{self.action} 动作必须填写 comment")
if self.action == "request_waiver" and not all(
(self.waiver_reason, self.waiver_scope, self.waiver_expires_at)
):
raise ValueError("request_waiver 必须填写原因、范围和有效期")
if self.action in {"approve_waiver", "reject_waiver"} and not self.comment:
raise ValueError(f"{self.action} 动作必须填写 comment")
if self.action in {"approve_waiver", "reject_waiver"} and any(
value is not None for value in (self.assignee, self.due_at, self.resolution)
):
raise ValueError("豁免决定动作不能同时修改负责人、截止时间或解决说明")
waiver_fields_present = any(
(
self.waiver_reason,
self.waiver_scope,
self.waiver_expires_at,
self.waiver_conditions,
)
)
if self.action != "request_waiver" and waiver_fields_present:
raise ValueError("只有 request_waiver 可以填写豁免申请字段")
if self.action in {"confirm", "false_positive"} and any(
value is not None for value in (self.assignee, self.due_at, self.resolution)
):
@@ -85,6 +129,20 @@ class RiskDispositionRead(BaseModel):
assignee: str | None
due_at: datetime | None
resolution: str | None
waiver_requester_id: str | None = None
waiver_requester_name: str | None = None
waiver_requested_at: datetime | None = None
waiver_reason: str | None = None
waiver_scope: str | None = None
waiver_expires_at: datetime | None = None
waiver_conditions_json: list[str] = Field(default_factory=list)
waiver_decision: Literal["approved", "rejected"] | None = None
waiver_decider_id: str | None = None
waiver_decider_name: str | None = None
waiver_decided_at: datetime | None = None
waiver_decision_reason: str | None = None
available_actions: list[RiskDispositionAction] = Field(default_factory=list)
read_only_reason: str = ""
created_at: datetime
updated_at: datetime
events: list[RiskDispositionEventRead] = Field(default_factory=list)

View File

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

View File

@@ -0,0 +1,266 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import UTC, datetime
from sqlalchemy import select
from sqlalchemy.orm import Session, selectinload
from app.api.deps import CurrentUserContext
from app.models.approval_task import ApprovalTask
from app.models.employee import Employee
from app.models.financial_record import ExpenseClaim
from app.services.expense_claim_access_policy import ExpenseClaimAccessPolicy
from app.services.expense_claim_tenant_scope import ExpenseClaimTenantScopeMixin
_OPEN_STATUSES = {"pending", "waiting"}
_ACTIVE_EMPLOYMENT_STATUSES = {"", "active", "employed", "在职", "正常"}
class ApprovalTaskPermissionError(ValueError):
pass
@dataclass(frozen=True, slots=True)
class ApprovalTaskPermission:
can_read: bool
can_act: bool
available_actions: tuple[str, ...]
read_only_reason: str = ""
class ApprovalTaskAccessPolicy:
"""让列表可见性、任务管理和最终动作共用同一授权判断。"""
def __init__(self, db: Session) -> None:
self.db = db
self.claim_policy = ExpenseClaimAccessPolicy(db)
def evaluate(
self,
task: ApprovalTask,
current_user: CurrentUserContext,
*,
claim: ExpenseClaim | None = None,
) -> ApprovalTaskPermission:
if task.tenant_id != ExpenseClaimTenantScopeMixin.normalize_tenant_id(
current_user.tenant_id
):
return ApprovalTaskPermission(False, False, (), "任务不在当前租户范围内。")
assigned = self._matches_assignment(task, current_user, owner=False)
owner = self._matches_assignment(task, current_user, owner=True)
admin = bool(current_user.is_admin)
can_read = admin or assigned or owner
if not can_read:
return ApprovalTaskPermission(False, False, (), "当前用户不在该任务参与范围内。")
available: list[str] = []
read_only_reason = ""
can_act = False
if task.status == "waiting":
read_only_reason = "当前任务正在等待加签或会签完成。"
elif task.status != "pending":
read_only_reason = "该任务已经处理,当前仅可查看审计记录。"
elif assigned:
if claim is not None and self.claim_policy.is_claim_owned_by_current_user(
claim,
current_user,
):
read_only_reason = "申请人不能审批自己的费用单。"
else:
can_act = True
available.append("approve")
available.append("return")
if task.task_kind == "root":
available.extend(("delegate", "transfer", "add_sign", "countersign"))
elif owner and task.delegated_by:
available.append("delegation_revoke")
read_only_reason = "任务已委托,原责任人只能撤销委托。"
elif admin:
available.extend(("transfer", "sla_escalate"))
read_only_reason = "管理员可转交或升级任务,但不能凭查看权限直接审批。"
else:
read_only_reason = "当前用户不是任务的有效处理人。"
if admin and "sla_escalate" not in available and task.status in _OPEN_STATUSES:
available.append("sla_escalate")
return ApprovalTaskPermission(
can_read=True,
can_act=can_act,
available_actions=tuple(dict.fromkeys(available)),
read_only_reason=read_only_reason,
)
def require_read(
self,
task: ApprovalTask,
current_user: CurrentUserContext,
*,
claim: ExpenseClaim | None = None,
) -> ApprovalTaskPermission:
permission = self.evaluate(task, current_user, claim=claim)
if not permission.can_read:
raise LookupError("Approval task not found.")
return permission
def require_action(
self,
task: ApprovalTask,
current_user: CurrentUserContext,
*,
action: str,
claim: ExpenseClaim,
) -> ApprovalTaskPermission:
permission = self.require_read(task, current_user, claim=claim)
if action not in permission.available_actions:
raise ApprovalTaskPermissionError(
permission.read_only_reason or "当前用户无权执行该审批任务动作。"
)
if action in {"approve", "return"} and not permission.can_act:
raise ApprovalTaskPermissionError(
permission.read_only_reason or "当前用户不是任务处理人。"
)
return permission
def resolve_and_validate_target(
self,
*,
task: ApprovalTask,
claim: ExpenseClaim,
target_employee_id: str,
) -> Employee:
target = self.db.scalar(
select(Employee)
.options(
selectinload(Employee.roles),
selectinload(Employee.organization_unit),
)
.where(Employee.id == str(target_employee_id or "").strip())
)
if target is None:
raise ValueError("目标审批人不存在。")
employment_status = str(target.employment_status or "").strip().lower()
if employment_status not in _ACTIVE_EMPLOYMENT_STATUSES:
raise ValueError("目标审批人不是在职员工。")
if str(claim.employee_id or "").strip() == target.id:
raise ValueError("申请人不能成为自己的审批人。")
if not self._target_qualified_for_node(target, task=task, claim=claim):
raise ValueError("目标员工不具备当前审批节点的处理资格。")
return target
def candidate_employees(
self,
*,
task: ApprovalTask,
claim: ExpenseClaim,
) -> list[tuple[Employee, bool, str]]:
employees = list(
self.db.scalars(
select(Employee)
.options(
selectinload(Employee.roles),
selectinload(Employee.organization_unit),
)
.order_by(Employee.name.asc(), Employee.employee_no.asc())
).all()
)
rows: list[tuple[Employee, bool, str]] = []
for employee in employees:
if str(claim.employee_id or "").strip() == employee.id:
rows.append((employee, False, "申请人不能审批自己的费用单。"))
continue
status = str(employee.employment_status or "").strip().lower()
if status not in _ACTIVE_EMPLOYMENT_STATUSES:
rows.append((employee, False, "员工当前不在职。"))
continue
qualified = self._target_qualified_for_node(employee, task=task, claim=claim)
rows.append((employee, qualified, "" if qualified else "不具备当前节点资格。"))
return rows
def actor_id(self, current_user: CurrentUserContext) -> str:
employee = self.claim_policy.resolve_current_employee(current_user)
return str(
(employee.id if employee is not None else "")
or current_user.employee_id
or current_user.username
).strip()
def _matches_assignment(
self,
task: ApprovalTask,
current_user: CurrentUserContext,
*,
owner: bool,
) -> bool:
if (
not owner
and task.delegated_by
and task.delegation_expires_at is not None
and _as_utc(task.delegation_expires_at) <= datetime.now(UTC)
):
return False
kind = str(task.owner_kind if owner else task.assignee_kind).strip().lower()
key = str(task.owner_key if owner else task.assignee_key).strip().casefold()
employee_id = str(
(task.owner_employee_id if owner else task.assignee_employee_id) or ""
).strip()
if kind == "role":
return key in self.claim_policy.normalize_role_codes(current_user)
current_employee = self.claim_policy.resolve_current_employee(current_user)
identities = {
str(current_user.username or "").strip().casefold(),
str(current_user.employee_id or "").strip().casefold(),
str(current_user.employee_no or "").strip().casefold(),
}
if current_employee is not None:
identities.update(
{
str(current_employee.id or "").strip().casefold(),
str(current_employee.name or "").strip().casefold(),
str(current_employee.employee_no or "").strip().casefold(),
str(current_employee.email or "").strip().casefold(),
}
)
identities.discard("")
return key in identities or bool(employee_id and employee_id.casefold() in identities)
def _target_qualified_for_node(
self,
target: Employee,
*,
task: ApprovalTask,
claim: ExpenseClaim,
) -> bool:
role_codes = {
str(role.role_code or "").strip().lower()
for role in list(target.roles or [])
if str(role.role_code or "").strip()
}
if task.node_key == "finance":
return "finance" in role_codes
if task.node_key == "budget_manager":
same_department = bool(
claim.department_id
and target.organization_unit_id
and str(claim.department_id) == str(target.organization_unit_id)
) or (
bool(claim.department_name and target.organization_unit)
and str(claim.department_name).strip().casefold()
== str(target.organization_unit.name or "").strip().casefold()
)
grade = str(target.grade or "").strip().upper()
grade_level = int(grade[1:]) if grade.startswith("P") and grade[1:].isdigit() else 0
return same_department and grade_level >= 8 and bool(
role_codes & {"budget_monitor", "executive"}
)
if claim.employee is not None and claim.employee.manager_id == target.id:
return True
return bool(role_codes & {"manager", "approver", "executive"})
def _as_utc(value: datetime) -> datetime:
if value.tzinfo is None:
return value.replace(tzinfo=UTC)
return value.astimezone(UTC)

View File

@@ -0,0 +1,762 @@
from __future__ import annotations
import hashlib
import uuid
from datetime import UTC, datetime, timedelta
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.api.deps import CurrentUserContext
from app.models.approval_task import ApprovalTask
from app.models.financial_record import ExpenseClaim
from app.schemas.approval_task import (
ApprovalTaskActionBase,
ApprovalTaskAssignmentAction,
ApprovalTaskCandidateRead,
ApprovalTaskDecisionAction,
ApprovalTaskMutationRead,
ApprovalTaskParticipantsAction,
)
from app.services.approval_task_access_policy import (
ApprovalTaskAccessPolicy,
ApprovalTaskPermissionError,
)
from app.services.approval_task_protocol import (
ApprovalTaskConflictError,
ApprovalTaskEventProtocol,
ApprovalTaskVersionConflictError,
payload_fingerprint,
task_state,
)
from app.services.expense_claim_tenant_scope import ExpenseClaimTenantScopeMixin
class ApprovalTaskActionService:
"""审批任务辅助动作;不改变费用单状态机。"""
def __init__(self, db: Session) -> None:
self.db = db
self.access = ApprovalTaskAccessPolicy(db)
self.events = ApprovalTaskEventProtocol(db)
def candidates(
self,
task_id: str,
current_user: CurrentUserContext,
*,
action: str | None = None,
keyword: str | None = None,
limit: int = 50,
) -> list[ApprovalTaskCandidateRead]:
task, claim = self._read_context(task_id, current_user)
permission = self.access.require_read(task, current_user, claim=claim)
normalized_action = str(action or "").strip()
if normalized_action and normalized_action not in permission.available_actions:
raise ApprovalTaskPermissionError(
permission.read_only_reason or "当前用户无权为该任务选择处理人。"
)
normalized_keyword = str(keyword or "").strip().casefold()
rows = [
ApprovalTaskCandidateRead(
employee_id=employee.id,
employee_no=str(employee.employee_no or ""),
name=str(employee.name or ""),
email=str(employee.email or ""),
qualified=qualified,
reason=reason,
)
for employee, qualified, reason in self.access.candidate_employees(
task=task,
claim=claim,
)
]
if normalized_keyword:
rows = [
row
for row in rows
if normalized_keyword
in " ".join((row.name, row.employee_no, row.email)).casefold()
]
return rows[: min(max(int(limit), 1), 100)]
def assign(
self,
task_id: str,
current_user: CurrentUserContext,
*,
action: str,
payload: ApprovalTaskAssignmentAction,
) -> ApprovalTaskMutationRead:
if action not in {"delegate", "transfer"}:
raise ValueError("不支持的任务分配动作。")
event_type = "task_delegated" if action == "delegate" else "task_transferred"
actor_id = self.access.actor_id(current_user)
event_payload = payload.model_dump(mode="json", exclude={"request_id"})
fingerprint = payload_fingerprint(
task_id=task_id,
actor_id=actor_id,
event_type=event_type,
payload=event_payload,
)
replay = self.events.find_replay(
tenant_id=self._tenant(current_user),
actor_id=actor_id,
request_id=payload.request_id,
fingerprint=fingerprint,
)
if replay is not None:
return replay
try:
task, claim = self._lock_context(task_id, current_user)
replay = self.events.find_replay(
tenant_id=self._tenant(current_user),
actor_id=actor_id,
request_id=payload.request_id,
fingerprint=fingerprint,
)
if replay is not None:
self.db.commit()
return replay
self._ensure_expected_version(task, payload.expected_task_version)
permission = self.access.require_action(
task,
current_user,
action=action,
claim=claim,
)
target = self.access.resolve_and_validate_target(
task=task,
claim=claim,
target_employee_id=payload.target_employee_id,
)
if target.id == task.assignee_employee_id:
raise ApprovalTaskConflictError("目标员工已经是当前任务处理人。")
if action == "delegate" and task.delegated_by:
raise ApprovalTaskConflictError("当前任务已有委托,请先撤销委托或执行转交。")
if action == "delegate" and payload.expires_at is not None:
if _as_utc(payload.expires_at) <= datetime.now(UTC):
raise ValueError("委托失效时间必须晚于当前时间。")
if action == "transfer" and payload.expires_at is not None:
raise ValueError("永久转交不能设置委托失效时间。")
before = task_state(task)
expected = task.version
task.assignee_kind = "employee"
task.assignee_key = target.id
task.assignee_employee_id = target.id
task.assignee_name = str(target.name or target.employee_no)
if action == "delegate":
task.delegated_by = actor_id
task.delegation_expires_at = payload.expires_at
else:
task.owner_kind = "employee"
task.owner_key = target.id
task.owner_employee_id = target.id
task.owner_name = str(target.name or target.employee_no)
task.delegated_by = None
task.delegation_expires_at = None
task.version += 1
task.updated_at = datetime.now(UTC)
permission = self.access.evaluate(task, current_user, claim=claim)
_, response = self.events.append_event(
task=task,
event_type=event_type,
actor_id=actor_id,
actor_name=str(current_user.name or current_user.username),
actor_type="user",
request_id=payload.request_id,
expected_task_version=expected,
payload=event_payload,
before_json=before,
permission=permission,
)
self.db.commit()
return response
except IntegrityError as error:
self.db.rollback()
replay = self.events.find_replay(
tenant_id=self._tenant(current_user),
actor_id=actor_id,
request_id=payload.request_id,
fingerprint=fingerprint,
)
if replay is not None:
return replay
raise ApprovalTaskConflictError("审批任务已被并发修改,请刷新后重试。") from error
except Exception:
self.db.rollback()
raise
def revoke_delegation(
self,
task_id: str,
current_user: CurrentUserContext,
payload: ApprovalTaskActionBase,
) -> ApprovalTaskMutationRead:
return self._simple_mutation(
task_id,
current_user,
action="delegation_revoke",
payload=payload,
mutate=self._apply_delegation_revoke,
)
def escalate(
self,
task_id: str,
current_user: CurrentUserContext,
payload: ApprovalTaskActionBase,
) -> ApprovalTaskMutationRead:
return self._simple_mutation(
task_id,
current_user,
action="sla_escalate",
payload=payload,
mutate=self._apply_escalation,
)
def add_participants(
self,
task_id: str,
current_user: CurrentUserContext,
*,
action: str,
payload: ApprovalTaskParticipantsAction,
) -> ApprovalTaskMutationRead:
if action not in {"add_sign", "countersign"}:
raise ValueError("不支持的参与人动作。")
event_type = "add_sign_started" if action == "add_sign" else "countersign_started"
actor_id = self.access.actor_id(current_user)
event_payload = payload.model_dump(mode="json", exclude={"request_id"})
fingerprint = payload_fingerprint(
task_id=task_id,
actor_id=actor_id,
event_type=event_type,
payload=event_payload,
)
replay = self.events.find_replay(
tenant_id=self._tenant(current_user),
actor_id=actor_id,
request_id=payload.request_id,
fingerprint=fingerprint,
)
if replay is not None:
return replay
try:
root, claim = self._lock_context(task_id, current_user)
replay = self.events.find_replay(
tenant_id=self._tenant(current_user),
actor_id=actor_id,
request_id=payload.request_id,
fingerprint=fingerprint,
)
if replay is not None:
self.db.commit()
return replay
self._ensure_expected_version(root, payload.expected_task_version)
self.access.require_action(root, current_user, action=action, claim=claim)
if root.task_kind != "root" or root.status != "pending":
raise ApprovalTaskConflictError("只有待处理的根任务可以发起加签或会签。")
existing_children = self.db.scalar(
select(ApprovalTask.id).where(
ApprovalTask.tenant_id == root.tenant_id,
ApprovalTask.parent_task_id == root.id,
ApprovalTask.status.in_(("waiting", "pending")),
)
)
if existing_children is not None:
raise ApprovalTaskConflictError("当前节点已有未完成的加签或会签任务。")
participants = [
self.access.resolve_and_validate_target(
task=root,
claim=claim,
target_employee_id=employee_id,
)
for employee_id in payload.participant_employee_ids
]
occupied = {
str(root.owner_employee_id or ""),
str(root.assignee_employee_id or ""),
}
if any(employee.id in occupied for employee in participants):
raise ValueError("加签或会签参与人不能与当前责任人重复。")
now = datetime.now(UTC)
before = task_state(root)
expected = root.version
root.status = "waiting"
root.coordination_mode = (
"sequential_before_owner" if action == "add_sign" else "parallel_all"
)
root.version += 1
root.updated_at = now
children = [
self._new_child(
root,
claim=claim,
employee=employee,
task_kind=action,
sequence_order=index,
active=(action == "countersign" or index == 1),
now=now,
)
for index, employee in enumerate(participants, start=1)
]
self.db.add_all(children)
self.db.flush()
for index, child in enumerate(children, start=1):
self.events.append_event(
task=child,
event_type="node_entered",
actor_id="system",
actor_name="审批任务引擎",
actor_type="system",
request_id=_derived_request(payload.request_id, f"child:{index}"),
expected_task_version=0,
payload={
"parent_task_id": root.id,
"task_kind": action,
"sequence_order": index,
},
before_json={},
occurred_at=now,
)
event, response = self.events.append_event(
task=root,
event_type=event_type,
actor_id=actor_id,
actor_name=str(current_user.name or current_user.username),
actor_type="user",
request_id=payload.request_id,
expected_task_version=expected,
payload=event_payload,
before_json=before,
related_tasks=children,
)
event.response_json = response.model_dump(mode="json")
self.db.commit()
return response
except IntegrityError as error:
self.db.rollback()
replay = self.events.find_replay(
tenant_id=self._tenant(current_user),
actor_id=actor_id,
request_id=payload.request_id,
fingerprint=fingerprint,
)
if replay is not None:
return replay
raise ApprovalTaskConflictError("审批任务已被并发修改,请刷新后重试。") from error
except Exception:
self.db.rollback()
raise
def approve_child(
self,
task_id: str,
current_user: CurrentUserContext,
payload: ApprovalTaskDecisionAction,
) -> ApprovalTaskMutationRead:
actor_id = self.access.actor_id(current_user)
event_payload = payload.model_dump(mode="json", exclude={"request_id"})
fingerprint = payload_fingerprint(
task_id=task_id,
actor_id=actor_id,
event_type="task_approved",
payload=event_payload,
)
replay = self.events.find_replay(
tenant_id=self._tenant(current_user),
actor_id=actor_id,
request_id=payload.request_id,
fingerprint=fingerprint,
)
if replay is not None:
return replay
try:
task, claim = self._lock_context(task_id, current_user)
replay = self.events.find_replay(
tenant_id=self._tenant(current_user),
actor_id=actor_id,
request_id=payload.request_id,
fingerprint=fingerprint,
)
if replay is not None:
self.db.commit()
return replay
if task.task_kind == "root":
raise ApprovalTaskConflictError("根任务请使用单据审批动作。")
self._ensure_expected_version(task, payload.expected_task_version)
self.access.require_action(task, current_user, action="approve", claim=claim)
before = task_state(task)
expected = task.version
now = datetime.now(UTC)
task.status = "completed"
task.decision = "approved"
task.opinion = payload.opinion or payload.reason
task.completed_at = now
task.version += 1
task.updated_at = now
related = self._advance_node_family(task, claim=claim, now=now)
event, response = self.events.append_event(
task=task,
event_type="task_approved",
actor_id=actor_id,
actor_name=str(current_user.name or current_user.username),
actor_type="user",
request_id=payload.request_id,
expected_task_version=expected,
payload=event_payload,
before_json=before,
related_tasks=related,
occurred_at=now,
)
event.response_json = response.model_dump(mode="json")
self.db.commit()
return response
except IntegrityError as error:
self.db.rollback()
replay = self.events.find_replay(
tenant_id=self._tenant(current_user),
actor_id=actor_id,
request_id=payload.request_id,
fingerprint=fingerprint,
)
if replay is not None:
return replay
raise ApprovalTaskConflictError("审批任务已被并发修改,请刷新后重试。") from error
except Exception:
self.db.rollback()
raise
def _simple_mutation(
self,
task_id: str,
current_user: CurrentUserContext,
*,
action: str,
payload: ApprovalTaskActionBase,
mutate,
) -> ApprovalTaskMutationRead:
actor_id = self.access.actor_id(current_user)
event_payload = payload.model_dump(mode="json", exclude={"request_id"})
fingerprint = payload_fingerprint(
task_id=task_id,
actor_id=actor_id,
event_type=action,
payload=event_payload,
)
replay = self.events.find_replay(
tenant_id=self._tenant(current_user),
actor_id=actor_id,
request_id=payload.request_id,
fingerprint=fingerprint,
)
if replay is not None:
return replay
try:
task, claim = self._lock_context(task_id, current_user)
replay = self.events.find_replay(
tenant_id=self._tenant(current_user),
actor_id=actor_id,
request_id=payload.request_id,
fingerprint=fingerprint,
)
if replay is not None:
self.db.commit()
return replay
self._ensure_expected_version(task, payload.expected_task_version)
self.access.require_action(task, current_user, action=action, claim=claim)
before = task_state(task)
expected = task.version
mutate(task, actor_id=actor_id)
task.version += 1
task.updated_at = datetime.now(UTC)
_, response = self.events.append_event(
task=task,
event_type=action,
actor_id=actor_id,
actor_name=str(current_user.name or current_user.username),
actor_type="user",
request_id=payload.request_id,
expected_task_version=expected,
payload=event_payload,
before_json=before,
)
self.db.commit()
return response
except IntegrityError as error:
self.db.rollback()
replay = self.events.find_replay(
tenant_id=self._tenant(current_user),
actor_id=actor_id,
request_id=payload.request_id,
fingerprint=fingerprint,
)
if replay is not None:
return replay
raise ApprovalTaskConflictError("审批任务已被并发修改,请刷新后重试。") from error
except Exception:
self.db.rollback()
raise
def _read_context(
self,
task_id: str,
current_user: CurrentUserContext,
) -> tuple[ApprovalTask, ExpenseClaim]:
tenant = self._tenant(current_user)
task = self.db.scalar(
select(ApprovalTask).where(
ApprovalTask.tenant_id == tenant,
ApprovalTask.id == str(task_id).strip(),
)
)
if task is None:
raise LookupError("Approval task not found.")
claim = self.db.scalar(
select(ExpenseClaim).where(
ExpenseClaim.id == task.claim_id,
ExpenseClaimTenantScopeMixin.build_claim_tenant_condition(tenant),
)
)
if claim is None:
raise LookupError("Approval task not found.")
return task, claim
def _lock_context(
self,
task_id: str,
current_user: CurrentUserContext,
) -> tuple[ApprovalTask, ExpenseClaim]:
tenant = self._tenant(current_user)
locator = self.db.execute(
select(ApprovalTask.claim_id).where(
ApprovalTask.tenant_id == tenant,
ApprovalTask.id == str(task_id).strip(),
)
).one_or_none()
if locator is None:
raise LookupError("Approval task not found.")
claim_statement = select(ExpenseClaim).where(
ExpenseClaim.id == locator[0],
ExpenseClaimTenantScopeMixin.build_claim_tenant_condition(tenant),
)
task_statement = select(ApprovalTask).where(
ApprovalTask.tenant_id == tenant,
ApprovalTask.id == str(task_id).strip(),
)
bind = self.db.get_bind()
if bind is not None and bind.dialect.name == "postgresql":
claim_statement = claim_statement.with_for_update()
task_statement = task_statement.with_for_update()
claim = self.db.scalar(claim_statement.execution_options(populate_existing=True))
task = self.db.scalar(task_statement.execution_options(populate_existing=True))
if claim is None or task is None or task.claim_id != claim.id:
raise LookupError("Approval task not found.")
return task, claim
def _new_child(
self,
root: ApprovalTask,
*,
claim: ExpenseClaim,
employee,
task_kind: str,
sequence_order: int,
active: bool,
now: datetime,
) -> ApprovalTask:
return ApprovalTask(
id=str(uuid.uuid4()),
tenant_id=root.tenant_id,
claim_id=root.claim_id,
expense_case_id=root.expense_case_id,
node_instance_id=root.node_instance_id,
node_entry_key=f"{root.id}:{task_kind}:{sequence_order}:{employee.id}",
parent_task_id=root.id,
task_kind=task_kind,
node_key=root.node_key,
node_label=root.node_label,
node_sequence=root.node_sequence,
sequence_order=sequence_order,
coordination_mode=root.coordination_mode,
owner_kind="employee",
owner_key=employee.id,
owner_employee_id=employee.id,
owner_name=str(employee.name or employee.employee_no),
assignee_kind="employee",
assignee_key=employee.id,
assignee_employee_id=employee.id,
assignee_name=str(employee.name or employee.employee_no),
status="pending" if active else "waiting",
decision=None,
version=1,
claim_status_snapshot=str(claim.status or "").strip().lower(),
claim_stage_snapshot=str(claim.approval_stage or "").strip(),
entered_at=now,
entered_at_source="workflow_event",
activated_at=now if active else None,
sla_hours_snapshot=root.sla_hours_snapshot,
due_at=now + timedelta(hours=root.sla_hours_snapshot),
escalation_level=0,
next_escalation_at=root.next_escalation_at,
priority_score=root.priority_score,
priority_tier=root.priority_tier,
priority_reasons_json=list(root.priority_reasons_json or []),
risk_level=root.risk_level,
open_risk_count=root.open_risk_count,
evidence_completeness=root.evidence_completeness,
batch_eligible=False,
batch_block_reasons_json=["加签或会签任务不能批量审批。"],
projection_updated_at=root.projection_updated_at,
created_at=now,
updated_at=now,
)
def _advance_node_family(
self,
task: ApprovalTask,
*,
claim: ExpenseClaim,
now: datetime,
) -> list[ApprovalTask]:
root_statement = select(ApprovalTask).where(
ApprovalTask.tenant_id == task.tenant_id,
ApprovalTask.id == task.parent_task_id,
)
bind = self.db.get_bind()
if bind is not None and bind.dialect.name == "postgresql":
root_statement = root_statement.with_for_update()
root = self.db.scalar(root_statement)
if root is None or root.status != "waiting":
raise ApprovalTaskConflictError("加签或会签根任务状态异常。")
related: list[ApprovalTask] = []
if task.task_kind == "add_sign":
next_child = self.db.scalar(
select(ApprovalTask)
.where(
ApprovalTask.tenant_id == task.tenant_id,
ApprovalTask.parent_task_id == root.id,
ApprovalTask.status == "waiting",
)
.order_by(ApprovalTask.sequence_order.asc())
.limit(1)
)
if next_child is not None:
self._activate_related(
next_child,
claim=claim,
now=now,
event_type="task_activated",
)
related.append(next_child)
return related
elif self.db.scalar(
select(ApprovalTask.id).where(
ApprovalTask.tenant_id == task.tenant_id,
ApprovalTask.parent_task_id == root.id,
ApprovalTask.status.in_(("waiting", "pending")),
ApprovalTask.id != task.id,
)
) is not None:
return related
self._activate_related(
root,
claim=claim,
now=now,
event_type="root_reactivated",
coordination_mode="single",
)
related.append(root)
return related
def _activate_related(
self,
task: ApprovalTask,
*,
claim: ExpenseClaim,
now: datetime,
event_type: str,
coordination_mode: str | None = None,
) -> None:
before = task_state(task)
expected = task.version
task.status = "pending"
if coordination_mode is not None:
task.coordination_mode = coordination_mode
task.activated_at = now
task.version += 1
task.claim_status_snapshot = str(claim.status or "").strip().lower()
task.claim_stage_snapshot = str(claim.approval_stage or "").strip()
task.updated_at = now
self.events.append_event(
task=task,
event_type=event_type,
actor_id="system",
actor_name="审批任务引擎",
actor_type="system",
request_id=f"activate:{task.id}:{task.version}",
expected_task_version=expected,
payload={"reason": "participant_tasks_completed"},
before_json=before,
occurred_at=now,
)
@staticmethod
def _apply_delegation_revoke(task: ApprovalTask, *, actor_id: str) -> None:
if not task.delegated_by:
raise ApprovalTaskConflictError("当前任务没有可撤销的委托。")
task.assignee_kind = task.owner_kind
task.assignee_key = task.owner_key
task.assignee_employee_id = task.owner_employee_id
task.assignee_name = task.owner_name
task.delegated_by = None
task.delegation_expires_at = None
@staticmethod
def _apply_escalation(task: ApprovalTask, *, actor_id: str) -> None:
now = datetime.now(UTC)
if task.escalation_level >= 3:
raise ApprovalTaskConflictError("该任务已经达到最高升级等级。")
task.escalation_level += 1
task.escalated_at = now
task.next_escalation_at = (
None if task.escalation_level >= 3 else now + timedelta(hours=4)
)
task.priority_score = min(100, int(task.priority_score) + 10)
task.priority_tier = "urgent" if task.priority_score >= 65 else "high"
task.priority_reasons_json = [
*list(task.priority_reasons_json or []),
{
"code": f"sla_escalated_l{task.escalation_level}",
"label": f"审批超时已升级至 L{task.escalation_level}",
"weight": 10,
"tone": "danger",
},
]
@staticmethod
def _ensure_expected_version(task: ApprovalTask, expected: int) -> None:
if task.version != expected:
raise ApprovalTaskVersionConflictError(task.version)
@staticmethod
def _tenant(current_user: CurrentUserContext) -> str:
return ExpenseClaimTenantScopeMixin.normalize_tenant_id(current_user.tenant_id)
def _as_utc(value: datetime) -> datetime:
if value.tzinfo is None:
return value.replace(tzinfo=UTC)
return value.astimezone(UTC)
def _derived_request(request_id: str, suffix: str) -> str:
value = f"{request_id}:{suffix}"
if len(value) <= 120:
return value
digest = hashlib.sha256(value.encode("utf-8")).hexdigest()
return f"derived:{digest}"

View File

@@ -0,0 +1,761 @@
from __future__ import annotations
import hashlib
import uuid
from collections.abc import Sequence
from dataclasses import dataclass, replace
from datetime import UTC, datetime
from enum import StrEnum
from typing import Any
from sqlalchemy import and_, func, or_, select
from sqlalchemy.orm import Session, selectinload
from app.models.approval_task import ApprovalTask
from app.models.employee import Employee
from app.models.expense_case import BusinessEvent, ExpenseCaseLink
from app.models.financial_record import ExpenseClaim
from app.models.risk_disposition import RiskDisposition
from app.models.risk_observation import RiskObservation
from app.services.approval_task_projection import (
DEFAULT_APPROVAL_SLA_HOURS,
ApprovalTaskProjectionService,
)
from app.services.approval_task_protocol import ApprovalTaskEventProtocol
from app.services.expense_claim_access_policy import ExpenseClaimAccessPolicy
from app.services.expense_claim_status_registry import normalize_expense_claim_state
from app.services.expense_claim_tenant_scope import ExpenseClaimTenantScopeMixin
from app.services.expense_claim_workflow_constants import (
BUDGET_MANAGER_APPROVAL_STAGE,
DIRECT_MANAGER_APPROVAL_STAGE,
FINANCE_APPROVAL_STAGE,
)
DEFAULT_BACKFILL_BATCH_SIZE = 100
MAX_BACKFILL_BATCH_SIZE = 1000
_PENDING_STATUS_VALUES = frozenset(
{
"submitted",
"review",
"pending_review",
"approving",
"manager_review",
"budget_review",
"finance_review",
"in_approval",
"under_review",
}
)
_OPEN_TASK_STATUSES = frozenset({"waiting", "pending"})
_ACTIVE_EMPLOYMENT_STATUSES = frozenset({"", "active", "employed", "在职", "正常"})
_NODE_BY_STAGE = {
DIRECT_MANAGER_APPROVAL_STAGE: ("direct_manager", "直属领导审批", 1),
BUDGET_MANAGER_APPROVAL_STAGE: ("budget_manager", "预算管理者审批", 2),
FINANCE_APPROVAL_STAGE: ("finance", "财务审批", 3),
}
class ApprovalTaskBackfillDisposition(StrEnum):
ELIGIBLE = "eligible"
CREATED = "created"
EXISTING = "existing"
UNSUPPORTED_STAGE = "unsupported_stage"
ASSIGNEE_UNRESOLVED = "assignee_unresolved"
TENANT_CONFLICT = "tenant_conflict"
@dataclass(frozen=True, slots=True)
class ApprovalTaskBackfillCursor:
created_at: datetime
claim_id: str
@dataclass(frozen=True, slots=True)
class ApprovalTaskBackfillItem:
claim_id: str
claim_no: str
disposition: ApprovalTaskBackfillDisposition
node_key: str = ""
entered_at: datetime | None = None
entered_at_source: str = ""
assignee_key: str = ""
task_id: str | None = None
reason: str = ""
@dataclass(frozen=True, slots=True)
class ApprovalTaskBackfillPreview:
tenant_id: str
inspected: int
eligible: int
existing: int
skipped: int
has_more: bool
next_cursor: ApprovalTaskBackfillCursor | None
items: tuple[ApprovalTaskBackfillItem, ...]
@dataclass(frozen=True, slots=True)
class ApprovalTaskBackfillResult:
tenant_id: str
run_id: str
inspected: int
created: int
existing: int
skipped: int
has_more: bool
next_cursor: ApprovalTaskBackfillCursor | None
items: tuple[ApprovalTaskBackfillItem, ...]
@dataclass(frozen=True, slots=True)
class _Assignment:
kind: str
key: str
employee_id: str | None
name: str
@dataclass(slots=True)
class _BatchContext:
links_by_claim_id: dict[str, ExpenseCaseLink]
events_by_claim_id: dict[str, list[BusinessEvent]]
tasks_by_claim_id: dict[str, list[ApprovalTask]]
risks_by_claim_id: dict[
str,
list[tuple[RiskObservation, RiskDisposition | None]],
]
class ApprovalTaskBackfillService:
"""显式、可预览、幂等地为历史待审批 Claim 创建根任务。
不自行提交,也绝不能从 GET 隐式调用;``run`` 默认 dry-run。
"""
def __init__(
self,
db: Session,
*,
tenant_id: str,
created_before: datetime | None = None,
sla_hours: int = DEFAULT_APPROVAL_SLA_HOURS,
) -> None:
self.db = db
self.tenant_id = ExpenseClaimTenantScopeMixin.normalize_tenant_id(tenant_id)
self.created_before = (
self._aware(created_before, field_name="created_before")
if created_before is not None
else None
)
self.sla_hours = int(sla_hours)
if self.sla_hours <= 0:
raise ValueError("sla_hours must be greater than zero")
self.claim_policy = ExpenseClaimAccessPolicy(db)
self.projections = ApprovalTaskProjectionService(db)
self.events = ApprovalTaskEventProtocol(db)
def run(
self,
*,
dry_run: bool = True,
run_id: str | None = None,
batch_size: int = DEFAULT_BACKFILL_BATCH_SIZE,
after: ApprovalTaskBackfillCursor | None = None,
backfilled_at: datetime | None = None,
) -> ApprovalTaskBackfillPreview | ApprovalTaskBackfillResult:
if dry_run:
return self.preview(batch_size=batch_size, after=after)
return self.apply_batch(
run_id=self._require_text(run_id, field_name="run_id", max_length=64),
batch_size=batch_size,
after=after,
backfilled_at=backfilled_at,
)
def preview(
self,
*,
batch_size: int = DEFAULT_BACKFILL_BATCH_SIZE,
after: ApprovalTaskBackfillCursor | None = None,
) -> ApprovalTaskBackfillPreview:
"""只读预览;不 add、不 flush、不 commit。"""
with self.db.no_autoflush:
claims, has_more = self._load_claims(
batch_size=self._batch_size(batch_size),
after=after,
lock_rows=False,
)
context = self._load_context(claims)
items = self._classify(claims, context=context)
return ApprovalTaskBackfillPreview(
tenant_id=self.tenant_id,
inspected=len(items),
eligible=self._count(items, ApprovalTaskBackfillDisposition.ELIGIBLE),
existing=self._count(items, ApprovalTaskBackfillDisposition.EXISTING),
skipped=self._skipped_count(items),
has_more=has_more,
next_cursor=self._next_cursor(claims),
items=items,
)
def apply_batch(
self,
*,
run_id: str,
batch_size: int = DEFAULT_BACKFILL_BATCH_SIZE,
after: ApprovalTaskBackfillCursor | None = None,
backfilled_at: datetime | None = None,
) -> ApprovalTaskBackfillResult:
"""锁定并应用一批,不 commit失败时由调用方回滚整个批次。"""
normalized_run_id = self._require_text(run_id, field_name="run_id", max_length=64)
applied_at = self._aware(
backfilled_at or datetime.now(UTC),
field_name="backfilled_at",
)
claims, has_more = self._load_claims(
batch_size=self._batch_size(batch_size),
after=after,
lock_rows=True,
)
context = self._load_context(claims)
classified = self._classify(claims, context=context)
claims_by_id = {str(claim.id): claim for claim in claims}
result_items: list[ApprovalTaskBackfillItem] = []
for item in classified:
if item.disposition is not ApprovalTaskBackfillDisposition.ELIGIBLE:
result_items.append(item)
continue
claim = claims_by_id[item.claim_id]
assignment = self._resolve_assignment(claim, node_key=item.node_key)
if assignment is None:
result_items.append(
replace(
item,
disposition=ApprovalTaskBackfillDisposition.ASSIGNEE_UNRESOLVED,
reason="当前审批节点无法解析到安全的处理人。",
)
)
continue
task = self._create_task(
claim,
item=item,
assignment=assignment,
context=context,
run_id=normalized_run_id,
backfilled_at=applied_at,
)
result_items.append(
replace(
item,
disposition=ApprovalTaskBackfillDisposition.CREATED,
task_id=task.id,
)
)
result = tuple(result_items)
return ApprovalTaskBackfillResult(
tenant_id=self.tenant_id,
run_id=normalized_run_id,
inspected=len(result),
created=self._count(result, ApprovalTaskBackfillDisposition.CREATED),
existing=self._count(result, ApprovalTaskBackfillDisposition.EXISTING),
skipped=self._skipped_count(result),
has_more=has_more,
next_cursor=self._next_cursor(claims),
items=result,
)
def derive_entered_at(
self,
claim: ExpenseClaim,
*,
events: Sequence[BusinessEvent],
approval_stage: str,
) -> tuple[datetime, str, BusinessEvent | None]:
"""按 BusinessEvent > submitted_at > updated_at > created_at 推导节点时间。"""
matching_events = [
event
for event in events
if self._event_enters_stage(event, approval_stage=approval_stage)
]
if matching_events:
event = max(
matching_events,
key=lambda value: self._as_utc(value.occurred_at),
)
return self._as_utc(event.occurred_at), "workflow_event", event
if claim.submitted_at is not None:
return self._as_utc(claim.submitted_at), "submitted_at", None
if claim.updated_at is not None:
return self._as_utc(claim.updated_at), "updated_at", None
if claim.created_at is not None:
return self._as_utc(claim.created_at), "backfill", None
raise ValueError(f"Claim {claim.id} 缺少可用于回填的时间。")
def _load_claims(
self,
*,
batch_size: int,
after: ApprovalTaskBackfillCursor | None,
lock_rows: bool,
) -> tuple[list[ExpenseClaim], bool]:
normalized_status = func.lower(func.trim(func.coalesce(ExpenseClaim.status, "")))
stmt = (
select(ExpenseClaim)
.options(
selectinload(ExpenseClaim.items),
selectinload(ExpenseClaim.employee).selectinload(Employee.manager),
selectinload(ExpenseClaim.employee).selectinload(Employee.organization_unit),
selectinload(ExpenseClaim.employee).selectinload(Employee.roles),
)
.where(
normalized_status.in_(_PENDING_STATUS_VALUES),
ExpenseClaimTenantScopeMixin.build_claim_tenant_condition(self.tenant_id),
)
)
if self.created_before is not None:
stmt = stmt.where(ExpenseClaim.created_at < self.created_before)
if after is not None:
claim_id = self._require_text(
after.claim_id,
field_name="after.claim_id",
max_length=36,
)
stmt = stmt.where(
or_(
ExpenseClaim.created_at > after.created_at,
and_(
ExpenseClaim.created_at == after.created_at,
ExpenseClaim.id > claim_id,
),
)
)
stmt = stmt.order_by(ExpenseClaim.created_at.asc(), ExpenseClaim.id.asc()).limit(
batch_size + 1
)
if lock_rows:
stmt = stmt.with_for_update()
rows = list(self.db.scalars(stmt).unique().all())
return rows[:batch_size], len(rows) > batch_size
def _load_context(self, claims: Sequence[ExpenseClaim]) -> _BatchContext:
claim_ids = [str(claim.id) for claim in claims]
if not claim_ids:
return _BatchContext({}, {}, {}, {})
links_by_claim_id = {
str(link.resource_id): link
for link in self.db.scalars(
select(ExpenseCaseLink).where(
ExpenseCaseLink.resource_type == "expense_claim",
ExpenseCaseLink.resource_id.in_(claim_ids),
)
).all()
}
events_by_claim_id: dict[str, list[BusinessEvent]] = {}
for event in self.db.scalars(
select(BusinessEvent)
.where(
BusinessEvent.tenant_id == self.tenant_id,
BusinessEvent.aggregate_type == "expense_claim",
BusinessEvent.aggregate_id.in_(claim_ids),
)
.order_by(BusinessEvent.occurred_at.desc(), BusinessEvent.id.desc())
).all():
events_by_claim_id.setdefault(str(event.aggregate_id), []).append(event)
tasks_by_claim_id: dict[str, list[ApprovalTask]] = {}
for task in self.db.scalars(
select(ApprovalTask).where(
ApprovalTask.tenant_id == self.tenant_id,
ApprovalTask.claim_id.in_(claim_ids),
ApprovalTask.task_kind == "root",
)
).all():
tasks_by_claim_id.setdefault(str(task.claim_id), []).append(task)
risks_by_claim_id: dict[
str,
list[tuple[RiskObservation, RiskDisposition | None]],
] = {}
for observation, disposition in self.db.execute(
select(RiskObservation, RiskDisposition)
.outerjoin(
RiskDisposition,
(RiskDisposition.tenant_id == RiskObservation.tenant_id)
& (RiskDisposition.observation_id == RiskObservation.id),
)
.where(
RiskObservation.tenant_id == self.tenant_id,
RiskObservation.claim_id.in_(claim_ids),
)
).all():
risks_by_claim_id.setdefault(str(observation.claim_id), []).append(
(observation, disposition)
)
return _BatchContext(
links_by_claim_id=links_by_claim_id,
events_by_claim_id=events_by_claim_id,
tasks_by_claim_id=tasks_by_claim_id,
risks_by_claim_id=risks_by_claim_id,
)
def _classify(
self,
claims: Sequence[ExpenseClaim],
*,
context: _BatchContext,
) -> tuple[ApprovalTaskBackfillItem, ...]:
items: list[ApprovalTaskBackfillItem] = []
for claim in claims:
claim_id = str(claim.id)
link = context.links_by_claim_id.get(claim_id)
if link is not None and link.tenant_id != self.tenant_id:
items.append(
self._item(
claim,
ApprovalTaskBackfillDisposition.TENANT_CONFLICT,
reason="费用单据已关联到其他租户。",
)
)
continue
state = self._normalized_state(claim)
node_spec = _NODE_BY_STAGE.get(state.approval_stage)
if state.status != "submitted" or node_spec is None:
items.append(
self._item(
claim,
ApprovalTaskBackfillDisposition.UNSUPPORTED_STAGE,
reason="当前状态或审批阶段不属于可回填的待审批节点。",
)
)
continue
node_key, _label, _sequence = node_spec
entered_at, entered_source, _source_event = self.derive_entered_at(
claim,
events=context.events_by_claim_id.get(claim_id, ()),
approval_stage=state.approval_stage,
)
node_entry_key = self._node_entry_key(
claim_id=claim_id,
node_key=node_key,
entered_at=entered_at,
)
previous_tasks = context.tasks_by_claim_id.get(claim_id, ())
existing = next(
(
task
for task in previous_tasks
if task.status in _OPEN_TASK_STATUSES or task.node_entry_key == node_entry_key
),
None,
)
if existing is not None:
items.append(
self._item(
claim,
ApprovalTaskBackfillDisposition.EXISTING,
node_key=node_key,
entered_at=entered_at,
entered_at_source=entered_source,
assignee_key=existing.assignee_key,
task_id=existing.id,
reason="当前 Claim 已有开放根任务或相同节点进入事实。",
)
)
continue
assignment = self._resolve_assignment(claim, node_key=node_key)
if assignment is None:
items.append(
self._item(
claim,
ApprovalTaskBackfillDisposition.ASSIGNEE_UNRESOLVED,
node_key=node_key,
entered_at=entered_at,
entered_at_source=entered_source,
reason="当前审批节点无法解析到安全的处理人。",
)
)
continue
items.append(
self._item(
claim,
ApprovalTaskBackfillDisposition.ELIGIBLE,
node_key=node_key,
entered_at=entered_at,
entered_at_source=entered_source,
assignee_key=assignment.key,
)
)
return tuple(items)
def _resolve_assignment(
self,
claim: ExpenseClaim,
*,
node_key: str,
) -> _Assignment | None:
employee: Employee | None
if node_key == "direct_manager":
claim_employee = claim.employee
if claim_employee is None:
claim_employee = self.claim_policy.resolve_claim_employee_for_backfill(claim)
employee = claim_employee.manager if claim_employee is not None else None
if employee is None:
manager_name = self.claim_policy.resolve_claim_manager_name(claim)
employee = self.claim_policy.resolve_employee_by_identity_candidates([manager_name])
elif node_key == "budget_manager":
employee = self.claim_policy.resolve_department_budget_manager(claim)
elif node_key == "finance":
employee = self.claim_policy.resolve_finance_approver(claim)
if employee is None:
return _Assignment("role", "finance", None, "财务审批角色")
else:
return None
if employee is None or employee.id == str(claim.employee_id or "").strip():
return None
employment_status = str(employee.employment_status or "").strip().lower()
if employment_status not in _ACTIVE_EMPLOYMENT_STATUSES:
return (
_Assignment("role", "finance", None, "财务审批角色")
if node_key == "finance"
else None
)
return _Assignment(
kind="employee",
key=str(employee.id),
employee_id=str(employee.id),
name=str(employee.name or employee.employee_no or employee.id).strip(),
)
def _create_task(
self,
claim: ExpenseClaim,
*,
item: ApprovalTaskBackfillItem,
assignment: _Assignment,
context: _BatchContext,
run_id: str,
backfilled_at: datetime,
) -> ApprovalTask:
if item.entered_at is None:
raise RuntimeError("eligible backfill item is missing entered_at")
state = self._normalized_state(claim)
node_key, node_label, node_sequence = _NODE_BY_STAGE[state.approval_stage]
link = context.links_by_claim_id.get(str(claim.id))
node_entry_key = self._node_entry_key(
claim_id=str(claim.id), node_key=node_key, entered_at=item.entered_at
)
task = ApprovalTask(
id=str(uuid.uuid4()),
tenant_id=self.tenant_id,
claim_id=str(claim.id),
expense_case_id=link.expense_case_id if link is not None else None,
node_instance_id=node_entry_key,
node_entry_key=node_entry_key,
parent_task_id=None,
task_kind="root",
node_key=node_key,
node_label=node_label,
node_sequence=node_sequence,
sequence_order=0,
coordination_mode="single",
owner_kind=assignment.kind,
owner_key=assignment.key,
owner_employee_id=assignment.employee_id,
owner_name=assignment.name,
assignee_kind=assignment.kind,
assignee_key=assignment.key,
assignee_employee_id=assignment.employee_id,
assignee_name=assignment.name,
status="pending",
decision=None,
version=1,
claim_status_snapshot=state.status,
claim_stage_snapshot=state.approval_stage,
entered_at=item.entered_at,
entered_at_source=item.entered_at_source,
activated_at=item.entered_at,
sla_hours_snapshot=self.sla_hours,
completed_at=None,
cancelled_at=None,
escalation_level=0,
)
projection = self.projections.build(
claim,
tenant_id=self.tenant_id,
entered_at=item.entered_at,
now=backfilled_at,
sla_hours=self.sla_hours,
observation_rows=context.risks_by_claim_id.get(str(claim.id), ()),
)
self.projections.apply(task, projection)
self.db.add(task)
self.db.flush()
source_event = self._source_event(
context.events_by_claim_id.get(str(claim.id), ()),
approval_stage=state.approval_stage,
)
payload = {
"backfill_run_id": run_id,
"history_reconstructed": False,
"current_node_reconstructed": True,
"claim_id": str(claim.id),
"claim_no": str(claim.claim_no or ""),
"approval_stage": state.approval_stage,
"entered_at": self._isoformat(item.entered_at),
"entered_at_source": item.entered_at_source,
}
event, _response = self.events.append_event(
task=task,
event_type="node_entered_backfilled",
actor_id="approval-task-backfill",
actor_name="审批任务历史回填",
actor_type="system",
request_id=self._request_id(run_id=run_id, task=task),
expected_task_version=0,
payload=payload,
before_json={},
business_event_id=source_event.id if source_event is not None else None,
correlation_id=run_id,
occurred_at=backfilled_at,
)
self.db.flush()
return task
@staticmethod
def _item(
claim: ExpenseClaim,
disposition: ApprovalTaskBackfillDisposition,
**values: Any,
) -> ApprovalTaskBackfillItem:
return ApprovalTaskBackfillItem(
claim_id=str(claim.id),
claim_no=str(claim.claim_no or ""),
disposition=disposition,
**values,
)
@staticmethod
def _event_enters_stage(event: BusinessEvent, *, approval_stage: str) -> bool:
payload = event.payload_json if isinstance(event.payload_json, dict) else {}
next_stage = str(
payload.get("next_approval_stage") or payload.get("approval_stage") or ""
).strip()
next_status = str(payload.get("next_status") or "submitted").strip().lower()
return next_stage == approval_stage and next_status in _PENDING_STATUS_VALUES
def _source_event(
self,
events: Sequence[BusinessEvent],
*,
approval_stage: str,
) -> BusinessEvent | None:
matches = [
event
for event in events
if self._event_enters_stage(event, approval_stage=approval_stage)
]
return max(matches, key=lambda value: self._as_utc(value.occurred_at)) if matches else None
@staticmethod
def _normalized_state(claim: ExpenseClaim) -> Any:
raw_status = str(claim.status or "").strip().lower()
status = "submitted" if raw_status in {"in_approval", "under_review"} else claim.status
return normalize_expense_claim_state(
status,
claim.approval_stage,
claim_no=claim.claim_no,
expense_type=claim.expense_type,
)
@staticmethod
def _node_digest(*, claim_id: str, node_key: str, entered_at: datetime) -> str:
raw = f"{claim_id}:{node_key}:{ApprovalTaskBackfillService._isoformat(entered_at)}"
return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:20]
@classmethod
def _node_entry_key(cls, *, claim_id: str, node_key: str, entered_at: datetime) -> str:
digest = cls._node_digest(
claim_id=claim_id,
node_key=node_key,
entered_at=entered_at,
)
return f"claim:{claim_id}:{node_key}:{digest}"
@staticmethod
def _request_id(*, run_id: str, task: ApprovalTask) -> str:
digest = hashlib.sha256(f"{task.tenant_id}:{task.node_entry_key}".encode()).hexdigest()[:24]
return f"backfill:{run_id}:{digest}"[:120]
@staticmethod
def _count(
items: Sequence[ApprovalTaskBackfillItem],
disposition: ApprovalTaskBackfillDisposition,
) -> int:
return sum(1 for item in items if item.disposition is disposition)
@staticmethod
def _skipped_count(items: Sequence[ApprovalTaskBackfillItem]) -> int:
return sum(
1
for item in items
if item.disposition
not in {
ApprovalTaskBackfillDisposition.ELIGIBLE,
ApprovalTaskBackfillDisposition.CREATED,
ApprovalTaskBackfillDisposition.EXISTING,
}
)
@staticmethod
def _next_cursor(
claims: Sequence[ExpenseClaim],
) -> ApprovalTaskBackfillCursor | None:
if not claims:
return None
claim = claims[-1]
return ApprovalTaskBackfillCursor(
created_at=claim.created_at,
claim_id=str(claim.id),
)
@staticmethod
def _batch_size(value: int) -> int:
normalized = int(value)
if normalized < 1 or normalized > MAX_BACKFILL_BATCH_SIZE:
raise ValueError(f"batch_size must be between 1 and {MAX_BACKFILL_BATCH_SIZE}")
return normalized
@staticmethod
def _require_text(value: str | None, *, field_name: str, max_length: int) -> str:
normalized = str(value or "").strip()
if not normalized:
raise ValueError(f"{field_name} must not be empty")
if len(normalized) > max_length:
raise ValueError(f"{field_name} must be at most {max_length} characters")
return normalized
@staticmethod
def _aware(value: datetime, *, field_name: str) -> datetime:
if value.tzinfo is None or value.utcoffset() is None:
raise ValueError(f"{field_name} must include a timezone")
return value.astimezone(UTC)
@staticmethod
def _as_utc(value: datetime) -> datetime:
return value.replace(tzinfo=UTC) if value.tzinfo is None else value.astimezone(UTC)
@staticmethod
def _isoformat(value: datetime) -> str:
return ApprovalTaskBackfillService._as_utc(value).isoformat().replace("+00:00", "Z")

View File

@@ -0,0 +1,207 @@
from __future__ import annotations
import hashlib
from collections.abc import Callable
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.api.deps import CurrentUserContext
from app.db.session import get_session_factory
from app.models.approval_action import ApprovalActionLedger
from app.models.approval_task import ApprovalTask
from app.schemas.approval_task import (
ApprovalTaskBatchApproveCreate,
ApprovalTaskBatchApproveRead,
ApprovalTaskBatchItemRead,
)
from app.schemas.reimbursement import ExpenseClaimRead
from app.services.approval_action_protocol import ApprovalActionConflictError
from app.services.approval_task_access_policy import ApprovalTaskPermissionError
from app.services.approval_task_protocol import ApprovalTaskConflictError
from app.services.expense_claim_risk_gate import ExpenseClaimRiskBlockedError
from app.services.expense_claim_tenant_scope import ExpenseClaimTenantScopeMixin
from app.services.expense_claims import ExpenseClaimService
class ApprovalTaskBatchService:
"""小批量审批编排;每一项使用独立数据库事务。"""
def __init__(
self,
session_factory: Callable[[], Session] | None = None,
) -> None:
self.session_factory = session_factory or get_session_factory()
def approve(
self,
payload: ApprovalTaskBatchApproveCreate,
current_user: CurrentUserContext,
) -> ApprovalTaskBatchApproveRead:
results = [
self._approve_one(
item=item,
batch_request_id=payload.batch_request_id,
current_user=current_user,
)
for item in payload.items
]
counts = {
name: sum(item.status == name for item in results)
for name in (
"succeeded",
"replayed",
"conflict",
"blocked",
"forbidden",
"failed",
)
}
success_count = counts["succeeded"] + counts["replayed"]
overall = (
"succeeded"
if success_count == len(results)
else "failed"
if success_count == 0
else "partial"
)
return ApprovalTaskBatchApproveRead(
batch_request_id=payload.batch_request_id,
status=overall,
succeeded_count=counts["succeeded"],
replayed_count=counts["replayed"],
conflict_count=counts["conflict"],
blocked_count=counts["blocked"],
forbidden_count=counts["forbidden"],
failed_count=counts["failed"],
items=results,
)
def _approve_one(
self,
*,
item,
batch_request_id: str,
current_user: CurrentUserContext,
) -> ApprovalTaskBatchItemRead:
request_id = _item_request_id(batch_request_id, item.task_id)
tenant = ExpenseClaimTenantScopeMixin.normalize_tenant_id(current_user.tenant_id)
db = self.session_factory()
claim_id: str | None = None
try:
task = db.scalar(
select(ApprovalTask).where(
ApprovalTask.tenant_id == tenant,
ApprovalTask.id == item.task_id,
)
)
if task is None:
return self._result(
item.task_id,
None,
"conflict",
"TASK_NOT_FOUND",
"任务不存在或已移出当前租户。",
)
claim_id = task.claim_id
if not task.batch_eligible:
reasons = "".join(task.batch_block_reasons_json or [])
return self._result(
item.task_id,
claim_id,
"blocked",
"TASK_NOT_BATCH_ELIGIBLE",
reasons or "该任务不满足安全批量审批条件。",
)
actor_id = str(current_user.username or "").strip().casefold()
replayed = db.scalar(
select(ApprovalActionLedger.id).where(
ApprovalActionLedger.tenant_id == tenant,
ApprovalActionLedger.actor_id == actor_id,
ApprovalActionLedger.request_id == request_id,
ApprovalActionLedger.completed_at.is_not(None),
)
) is not None
claim = ExpenseClaimService(db).approve_claim(
task.claim_id,
current_user,
opinion=item.opinion,
request_id=request_id,
expected_status=item.expected_status,
expected_approval_stage=item.expected_approval_stage,
task_id=task.id,
expected_task_version=item.expected_task_version,
)
if claim is None:
return self._result(
item.task_id,
claim_id,
"conflict",
"CLAIM_NOT_FOUND",
"关联单据不存在或已不可见。",
)
claim_read = (
claim
if isinstance(claim, ExpenseClaimRead)
else ExpenseClaimRead.model_validate(claim)
)
return self._result(
item.task_id,
claim_id,
"replayed" if replayed else "succeeded",
"REPLAYED" if replayed else "APPROVED",
"审批结果已安全重放。" if replayed else "审批通过。",
claim=claim_read.model_dump(mode="json"),
)
except ApprovalTaskPermissionError as error:
return self._result(item.task_id, claim_id, "forbidden", "FORBIDDEN", str(error))
except ExpenseClaimRiskBlockedError as error:
return self._result(item.task_id, claim_id, "blocked", "OPEN_HIGH_RISK", str(error))
except (ApprovalActionConflictError, ApprovalTaskConflictError) as error:
return self._result(item.task_id, claim_id, "conflict", "CONCURRENT_CHANGE", str(error))
except ValueError as error:
return self._result(
item.task_id,
claim_id,
"blocked",
"VALIDATION_BLOCKED",
str(error),
)
except Exception:
return self._result(
item.task_id,
claim_id,
"failed",
"INTERNAL_ERROR",
"该任务处理失败,请稍后单独重试。",
)
finally:
db.rollback()
db.close()
@staticmethod
def _result(
task_id: str,
claim_id: str | None,
status: str,
code: str,
message: str,
*,
claim: dict | None = None,
) -> ApprovalTaskBatchItemRead:
return ApprovalTaskBatchItemRead(
task_id=task_id,
claim_id=claim_id,
status=status,
code=code,
message=message,
claim=claim,
)
def _item_request_id(batch_request_id: str, task_id: str) -> str:
value = f"batch:{batch_request_id}:{task_id}"
if len(value) <= 120:
return value
digest = hashlib.sha256(value.encode("utf-8")).hexdigest()
return f"batch:{digest}"

View File

@@ -0,0 +1,499 @@
from __future__ import annotations
import uuid
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.api.deps import CurrentUserContext
from app.models.approval_action import ApprovalActionLedger
from app.models.approval_task import ApprovalTask
from app.models.employee import Employee
from app.models.expense_case import BusinessEvent, ExpenseCaseLink
from app.models.financial_record import ExpenseClaim
from app.schemas.approval_task import ApprovalTaskMutationRead
from app.services.approval_task_access_policy import ApprovalTaskAccessPolicy
from app.services.approval_task_protocol import ApprovalTaskEventProtocol, task_state
from app.services.expense_claim_tenant_scope import ExpenseClaimTenantScopeMixin
from app.services.expense_claim_workflow_constants import (
BUDGET_MANAGER_APPROVAL_STAGE,
DIRECT_MANAGER_APPROVAL_STAGE,
FINANCE_APPROVAL_STAGE,
)
OPEN_TASK_STATUSES = ("waiting", "pending")
class ApprovalTaskConfigurationError(ValueError):
pass
@dataclass(frozen=True, slots=True)
class ApprovalNode:
key: str
label: str
sla_hours: int
@dataclass(frozen=True, slots=True)
class ApprovalAssignment:
kind: str
key: str
employee_id: str | None
name: str
@dataclass(slots=True)
class ApprovalTaskDecisionContext:
task: ApprovalTask
expected_task_version: int
before_json: dict[str, object]
_NODES = {
DIRECT_MANAGER_APPROVAL_STAGE: ApprovalNode(
key="direct_manager",
label=DIRECT_MANAGER_APPROVAL_STAGE,
sla_hours=24,
),
BUDGET_MANAGER_APPROVAL_STAGE: ApprovalNode(
key="budget_manager",
label=BUDGET_MANAGER_APPROVAL_STAGE,
sla_hours=12,
),
FINANCE_APPROVAL_STAGE: ApprovalNode(
key="finance",
label=FINANCE_APPROVAL_STAGE,
sla_hours=8,
),
}
def approval_node_for_stage(stage: object) -> ApprovalNode | None:
return _NODES.get(str(stage or "").strip())
class ApprovalTaskLifecycleService:
"""把既有费用状态机投影成可分配、可审计的个人审批任务。"""
def __init__(self, db: Session) -> None:
self.db = db
self.access = ApprovalTaskAccessPolicy(db)
self.events = ApprovalTaskEventProtocol(db)
def ensure_root_task(
self,
claim: ExpenseClaim,
*,
tenant_id: str,
entered_at: datetime,
entered_at_source: str,
business_event: BusinessEvent | None = None,
actor_id: str = "system",
actor_name: str = "系统",
request_id: str | None = None,
) -> ApprovalTask | None:
node = approval_node_for_stage(claim.approval_stage)
if str(claim.status or "").strip().lower() != "submitted" or node is None:
return None
tenant = ExpenseClaimTenantScopeMixin.normalize_tenant_id(tenant_id)
existing = self.db.scalar(
select(ApprovalTask).where(
ApprovalTask.tenant_id == tenant,
ApprovalTask.claim_id == claim.id,
ApprovalTask.task_kind == "root",
ApprovalTask.status.in_(OPEN_TASK_STATUSES),
)
)
if existing is not None:
if (
existing.node_key == node.key
and existing.claim_stage_snapshot == str(claim.approval_stage or "").strip()
):
return existing
self._supersede_stale_root(existing, claim=claim)
assignment = self.resolve_assignment(claim, node=node)
sequence = int(
self.db.scalar(
select(func.max(ApprovalTask.node_sequence)).where(
ApprovalTask.tenant_id == tenant,
ApprovalTask.claim_id == claim.id,
ApprovalTask.task_kind == "root",
)
)
or 0
) + 1
now = datetime.now(UTC)
normalized_entered_at = _aware_utc(entered_at)
task_id = str(uuid.uuid4())
entry_seed = (
business_event.id
if business_event is not None
else f"{normalized_entered_at.isoformat()}:{uuid.uuid4().hex[:12]}"
)
task = ApprovalTask(
id=task_id,
tenant_id=tenant,
claim_id=claim.id,
expense_case_id=self._expense_case_id(claim, business_event=business_event),
node_instance_id=f"{claim.id}:{sequence}:{node.key}:{uuid.uuid4().hex[:12]}",
node_entry_key=f"{claim.id}:{sequence}:{node.key}:{entry_seed}",
parent_task_id=None,
task_kind="root",
node_key=node.key,
node_label=node.label,
node_sequence=sequence,
sequence_order=0,
coordination_mode="single",
owner_kind=assignment.kind,
owner_key=assignment.key,
owner_employee_id=assignment.employee_id,
owner_name=assignment.name,
assignee_kind=assignment.kind,
assignee_key=assignment.key,
assignee_employee_id=assignment.employee_id,
assignee_name=assignment.name,
delegated_by=None,
delegation_expires_at=None,
status="pending",
decision=None,
opinion=None,
version=1,
claim_status_snapshot=str(claim.status or "").strip().lower(),
claim_stage_snapshot=str(claim.approval_stage or "").strip(),
entered_at=normalized_entered_at,
entered_at_source=entered_at_source,
activated_at=normalized_entered_at,
sla_hours_snapshot=node.sla_hours,
due_at=normalized_entered_at + timedelta(hours=node.sla_hours),
completed_at=None,
cancelled_at=None,
escalation_level=0,
escalated_at=None,
next_escalation_at=normalized_entered_at + timedelta(hours=node.sla_hours),
priority_score=0,
priority_tier="normal",
priority_reasons_json=[],
risk_level="low",
open_risk_count=0,
evidence_completeness=0,
batch_eligible=False,
batch_block_reasons_json=[],
projection_updated_at=now,
created_at=now,
updated_at=now,
)
self._apply_projection(task, claim=claim, now=now)
self.db.add(task)
self.db.flush()
event_request_id = str(request_id or "").strip() or f"node-enter:{task.id}"
self.events.append_event(
task=task,
event_type="node_entered",
actor_id=actor_id,
actor_name=actor_name,
actor_type="system" if actor_id == "system" else "user",
request_id=event_request_id,
expected_task_version=0,
payload={
"claim_id": claim.id,
"node_key": node.key,
"node_label": node.label,
"entered_at_source": entered_at_source,
},
before_json={},
business_event_id=business_event.id if business_event is not None else None,
correlation_id=(
business_event.correlation_id if business_event is not None else None
),
occurred_at=now,
)
return task
def begin_claim_decision(
self,
claim: ExpenseClaim,
current_user: CurrentUserContext,
*,
action: str,
task_id: str | None,
expected_task_version: int | None,
) -> ApprovalTaskDecisionContext:
tenant = ExpenseClaimTenantScopeMixin.normalize_tenant_id(current_user.tenant_id)
statement = select(ApprovalTask).where(
ApprovalTask.tenant_id == tenant,
ApprovalTask.claim_id == claim.id,
ApprovalTask.status.in_(OPEN_TASK_STATUSES),
)
if task_id:
statement = statement.where(ApprovalTask.id == str(task_id).strip())
else:
statement = statement.where(ApprovalTask.task_kind == "root")
bind = self.db.get_bind()
if bind is not None and bind.dialect.name == "postgresql":
statement = statement.with_for_update()
task = self.db.scalar(statement.execution_options(populate_existing=True))
if task is None and not task_id:
task = self.ensure_root_task(
claim,
tenant_id=tenant,
entered_at=claim.submitted_at or claim.updated_at or datetime.now(UTC),
entered_at_source=("submitted_at" if claim.submitted_at else "backfill"),
actor_id="system",
actor_name="系统兼容桥",
)
if task is None:
raise ApprovalTaskConflictError("当前单据没有可执行的审批任务。")
if task.task_kind != "root" and action != "return":
raise ApprovalTaskConflictError("加签或会签通过不会直接推进单据节点。")
if task.status != "pending":
raise ApprovalTaskConflictError("当前审批任务正在等待加签或会签完成。")
if (
task.claim_status_snapshot != str(claim.status or "").strip().lower()
or task.claim_stage_snapshot != str(claim.approval_stage or "").strip()
):
raise ApprovalTaskConflictError("审批任务与单据节点不一致,请刷新任务队列。")
expected = int(expected_task_version or task.version)
if task.version != expected:
from app.services.approval_task_protocol import (
ApprovalTaskVersionConflictError,
)
raise ApprovalTaskVersionConflictError(task.version)
self.access.require_action(task, current_user, action=action, claim=claim)
return ApprovalTaskDecisionContext(
task=task,
expected_task_version=expected,
before_json=task_state(task),
)
def complete_claim_decision(
self,
context: ApprovalTaskDecisionContext,
claim: ExpenseClaim,
current_user: CurrentUserContext,
*,
action: str,
opinion: str,
request_id: str,
ledger: ApprovalActionLedger,
business_event: BusinessEvent | None = None,
) -> ApprovalTaskMutationRead:
task = context.task
now = datetime.now(UTC)
task.status = "completed" if action == "approve" else "returned"
task.decision = "approved" if action == "approve" else "returned"
task.opinion = str(opinion or "").strip() or None
task.version += 1
task.claim_status_snapshot = str(claim.status or "").strip().lower()
task.claim_stage_snapshot = str(claim.approval_stage or "").strip()
task.completed_at = now
task.updated_at = now
related_tasks: list[ApprovalTask] = []
if action == "return" and task.task_kind != "root":
related_tasks = self._cancel_node_family(task, claim=claim, now=now)
# Session 关闭了 autoflush先持久化当前根任务的终态后续创建下一
# 节点时才不会把同一对象误判成仍开放并改写为 superseded。
self.db.flush()
next_task = self.ensure_root_task(
claim,
tenant_id=task.tenant_id,
entered_at=(
business_event.occurred_at
if business_event is not None
else now
),
entered_at_source=("workflow_event" if business_event is not None else "backfill"),
business_event=business_event,
actor_id="system",
actor_name="审批任务引擎",
)
if next_task is not None:
related_tasks.append(next_task)
_, response = self.events.append_event(
task=task,
event_type=("task_approved" if action == "approve" else "task_returned"),
actor_id=self.access.actor_id(current_user),
actor_name=str(current_user.name or current_user.username),
actor_type="user",
request_id=request_id,
expected_task_version=context.expected_task_version,
payload={"opinion": str(opinion or "").strip()},
before_json=context.before_json,
related_tasks=related_tasks,
approval_action_ledger_id=ledger.id,
business_event_id=business_event.id if business_event is not None else None,
correlation_id=(
business_event.correlation_id if business_event is not None else None
),
occurred_at=now,
)
return response
def _cancel_node_family(
self,
task: ApprovalTask,
*,
claim: ExpenseClaim,
now: datetime,
) -> list[ApprovalTask]:
statement = (
select(ApprovalTask)
.where(
ApprovalTask.tenant_id == task.tenant_id,
ApprovalTask.node_instance_id == task.node_instance_id,
ApprovalTask.id != task.id,
ApprovalTask.status.in_(OPEN_TASK_STATUSES),
)
.order_by(ApprovalTask.sequence_order.asc())
)
bind = self.db.get_bind()
if bind is not None and bind.dialect.name == "postgresql":
statement = statement.with_for_update()
related = list(self.db.scalars(statement).all())
for item in related:
before = task_state(item)
expected = item.version
item.status = "cancelled"
item.decision = None
item.completed_at = None
item.cancelled_at = now
item.version += 1
item.claim_status_snapshot = str(claim.status or "").strip().lower()
item.claim_stage_snapshot = str(claim.approval_stage or "").strip()
item.updated_at = now
self.events.append_event(
task=item,
event_type="task_cancelled",
actor_id="system",
actor_name="审批任务引擎",
actor_type="system",
request_id=f"cancel:{item.id}:{item.version}",
expected_task_version=expected,
payload={"reason": "participant_returned_claim"},
before_json=before,
occurred_at=now,
)
return related
def resolve_assignment(
self,
claim: ExpenseClaim,
*,
node: ApprovalNode,
) -> ApprovalAssignment:
if node.key == "direct_manager":
employee = self.access.claim_policy.resolve_claim_employee_for_backfill(claim)
manager = employee.manager if employee is not None else None
if manager is None:
manager = self.access.claim_policy.resolve_employee_by_identity_candidates(
[str(claim.manager_name or "").strip()]
)
if manager is None:
raise ApprovalTaskConfigurationError(
"未配置可唯一识别的直属领导,无法创建审批任务。"
)
return self._employee_assignment(manager)
if node.key == "budget_manager":
employee = self.access.claim_policy.resolve_department_budget_manager(claim)
if employee is None:
raise ApprovalTaskConfigurationError(
"未配置同部门 P8 预算管理者,无法创建审批任务。"
)
return self._employee_assignment(employee)
finance = self.access.claim_policy.resolve_finance_approver(claim)
if finance is not None:
return self._employee_assignment(finance)
return ApprovalAssignment(
kind="role",
key="finance",
employee_id=None,
name="财务审批组",
)
def _supersede_stale_root(
self,
task: ApprovalTask,
*,
claim: ExpenseClaim,
) -> None:
before = task_state(task)
now = datetime.now(UTC)
expected = task.version
task.status = "superseded"
task.decision = None
task.completed_at = None
task.cancelled_at = now
task.version += 1
task.claim_status_snapshot = str(claim.status or "").strip().lower()
task.claim_stage_snapshot = str(claim.approval_stage or "").strip()
task.updated_at = now
self.events.append_event(
task=task,
event_type="task_superseded",
actor_id="system",
actor_name="审批任务引擎",
actor_type="system",
request_id=f"supersede:{task.id}:{task.version}",
expected_task_version=expected,
payload={"reason": "claim_stage_changed"},
before_json=before,
occurred_at=now,
)
def _expense_case_id(
self,
claim: ExpenseClaim,
*,
business_event: BusinessEvent | None,
) -> str | None:
if business_event is not None:
return business_event.expense_case_id
return self.db.scalar(
select(ExpenseCaseLink.expense_case_id).where(
ExpenseCaseLink.resource_type == "expense_claim",
ExpenseCaseLink.resource_id == claim.id,
)
)
@staticmethod
def _employee_assignment(employee: Employee) -> ApprovalAssignment:
key = str(employee.id or employee.email or employee.employee_no).strip()
if not key:
raise ApprovalTaskConfigurationError("审批人缺少稳定身份标识。")
return ApprovalAssignment(
kind="employee",
key=key,
employee_id=employee.id,
name=str(employee.name or employee.email or employee.employee_no).strip(),
)
@staticmethod
def _read_without_permission(task: ApprovalTask):
from app.services.approval_task_protocol import task_read
return task_read(task)
@staticmethod
def _apply_projection(
task: ApprovalTask,
*,
claim: ExpenseClaim,
now: datetime,
) -> None:
from app.services.approval_task_projection import ApprovalTaskProjectionService
ApprovalTaskProjectionService.apply(task, claim=claim, now=now)
def _aware_utc(value: datetime) -> datetime:
if value.tzinfo is None:
return value.replace(tzinfo=UTC)
return value.astimezone(UTC)
# Local alias keeps callers independent of protocol module layout.
from app.services.approval_task_protocol import ApprovalTaskConflictError # noqa: E402

View File

@@ -0,0 +1,566 @@
from __future__ import annotations
from collections.abc import Sequence
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from decimal import ROUND_HALF_UP, Decimal, InvalidOperation
from typing import Any
from sqlalchemy import select
from sqlalchemy.orm import Session, object_session
from app.models.approval_task import ApprovalTask
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_open_claim_risk_flag,
)
from app.services.expense_claim_risk_stage import (
risk_business_stage_for_claim,
risk_flag_business_stage,
)
from app.services.expense_claim_status_registry import (
is_application_claim_reference,
normalize_expense_claim_state,
)
from app.services.expense_claim_tenant_scope import ExpenseClaimTenantScopeMixin
from app.services.expense_claim_workflow_constants import (
BUDGET_MANAGER_APPROVAL_STAGE,
DIRECT_MANAGER_APPROVAL_STAGE,
FINANCE_APPROVAL_STAGE,
)
DEFAULT_APPROVAL_SLA_HOURS = 24
SUPPORTED_APPROVAL_STAGES = frozenset(
{
DIRECT_MANAGER_APPROVAL_STAGE,
BUDGET_MANAGER_APPROVAL_STAGE,
FINANCE_APPROVAL_STAGE,
}
)
_RISK_ORDER = {"low": 0, "medium": 1, "high": 2, "critical": 3}
_RISK_WEIGHTS = {"low": 0, "medium": 14, "high": 30, "critical": 38}
_CLOSED_OBSERVATION_STATES = {
"false_positive",
"ignored",
"resolved",
}
_BATCH_AMOUNT_LIMIT = Decimal("10000")
_BATCH_BUDGET_USAGE_LIMIT = Decimal("90")
_COMPLETENESS_QUANTUM = Decimal("0.0001")
@dataclass(frozen=True, slots=True)
class ApprovalTaskProjection:
"""审批队列的可重算快照;它不是审批放行的权威依据。"""
priority_score: int
priority_tier: str
priority_reasons_json: tuple[dict[str, Any], ...]
risk_level: str
open_risk_count: int
evidence_completeness: Decimal
batch_eligible: bool
batch_block_reasons_json: tuple[str, ...]
due_at: datetime
next_escalation_at: datetime
projection_updated_at: datetime
class ApprovalTaskProjectionService:
"""计算审批任务的排序、风险、材料和安全批处理投影。
投影仅用于列表排序与操作提示。真正审批时仍必须重新读取 Claim、风险处置、
预算和任务版本,不能用这里的 ``batch_eligible`` 替代权威风控校验。
"""
def __init__(self, db: Session) -> None:
self.db = db
def build(
self,
claim: ExpenseClaim,
*,
tenant_id: str,
entered_at: datetime,
now: datetime | None = None,
sla_hours: int = DEFAULT_APPROVAL_SLA_HOURS,
observation_rows: Sequence[tuple[RiskObservation, RiskDisposition | None]] | None = None,
) -> ApprovalTaskProjection:
normalized_tenant = ExpenseClaimTenantScopeMixin.normalize_tenant_id(tenant_id)
normalized_entered_at = self._aware(entered_at, field_name="entered_at")
generated_at = self._aware(now or datetime.now(UTC), field_name="now")
normalized_sla_hours = int(sla_hours)
if normalized_sla_hours <= 0:
raise ValueError("sla_hours must be greater than zero")
rows = (
list(observation_rows)
if observation_rows is not None
else self._load_observation_rows(
tenant_id=normalized_tenant,
claim_id=str(claim.id or "").strip(),
)
)
risk_level, open_risk_count = self._combined_risk_summary(
claim,
rows,
now=generated_at,
)
evidence_completeness, missing_evidence = self._evidence_summary(claim)
budget_usage_rate = self._budget_usage_rate(claim.risk_flags_json)
amount = self._decimal(claim.amount)
due_at = normalized_entered_at + timedelta(hours=normalized_sla_hours)
waiting_hours = max(
Decimal("0"),
Decimal(str((generated_at - normalized_entered_at).total_seconds())) / Decimal("3600"),
)
reasons = self._priority_reasons(
risk_level=risk_level,
open_risk_count=open_risk_count,
waiting_hours=waiting_hours,
sla_hours=normalized_sla_hours,
budget_usage_rate=budget_usage_rate,
amount=amount,
missing_evidence=missing_evidence,
)
score = min(100, sum(int(item.get("weight") or 0) for item in reasons))
priority_tier = "urgent" if score >= 65 else "high" if score >= 40 else "normal"
state = normalize_expense_claim_state(
claim.status,
claim.approval_stage,
claim_no=claim.claim_no,
expense_type=claim.expense_type,
)
batch_block_reasons = self._batch_block_reasons(
approval_stage=state.approval_stage,
risk_level=risk_level,
open_risk_count=open_risk_count,
evidence_completeness=evidence_completeness,
amount=amount,
budget_usage_rate=budget_usage_rate,
overdue=generated_at >= due_at,
)
return ApprovalTaskProjection(
priority_score=score,
priority_tier=priority_tier,
priority_reasons_json=tuple(reasons),
risk_level=risk_level,
open_risk_count=open_risk_count,
evidence_completeness=evidence_completeness,
batch_eligible=not batch_block_reasons,
batch_block_reasons_json=tuple(batch_block_reasons),
due_at=due_at,
next_escalation_at=due_at,
projection_updated_at=generated_at,
)
@staticmethod
def apply(
task: ApprovalTask,
projection: ApprovalTaskProjection | None = None,
*,
claim: ExpenseClaim | None = None,
now: datetime | None = None,
observation_rows: Sequence[tuple[RiskObservation, RiskDisposition | None]] | None = None,
) -> ApprovalTask:
"""计算(可选)并应用投影,不 flush、不 commit。
``claim=`` 便捷形式会从持久化 Claim 取得 Session因此即使 Task 尚未 add
仍能租户隔离地合并结构化风险;也保留显式 ``projection`` 形式供批处理复用。
"""
if projection is None:
if claim is None:
raise ValueError("claim is required when projection is not provided")
db = object_session(claim) or object_session(task)
if db is None and observation_rows is None:
raise ValueError(
"claim must be attached to a Session when observation_rows is omitted"
)
projection = ApprovalTaskProjectionService(db).build( # type: ignore[arg-type]
claim,
tenant_id=str(task.tenant_id or "").strip(),
entered_at=task.entered_at,
now=now,
sla_hours=task.sla_hours_snapshot,
observation_rows=observation_rows,
)
task.priority_score = projection.priority_score
task.priority_tier = projection.priority_tier
task.priority_reasons_json = [dict(item) for item in projection.priority_reasons_json]
task.risk_level = projection.risk_level
task.open_risk_count = projection.open_risk_count
task.evidence_completeness = projection.evidence_completeness
task.batch_eligible = projection.batch_eligible
task.batch_block_reasons_json = list(projection.batch_block_reasons_json)
task.due_at = projection.due_at
task.next_escalation_at = projection.next_escalation_at
task.projection_updated_at = projection.projection_updated_at
return task
def _load_observation_rows(
self,
*,
tenant_id: str,
claim_id: str,
) -> list[tuple[RiskObservation, RiskDisposition | None]]:
if not claim_id:
return []
return list(
self.db.execute(
select(RiskObservation, RiskDisposition)
.outerjoin(
RiskDisposition,
(RiskDisposition.tenant_id == RiskObservation.tenant_id)
& (RiskDisposition.observation_id == RiskObservation.id),
)
.where(
RiskObservation.tenant_id == tenant_id,
RiskObservation.claim_id == claim_id,
)
).all()
)
@classmethod
def _combined_risk_summary(
cls,
claim: ExpenseClaim,
rows: Sequence[tuple[RiskObservation, RiskDisposition | None]],
*,
now: datetime,
) -> tuple[str, int]:
claim_business_stage = risk_business_stage_for_claim(
is_application_claim=is_application_claim_reference(
claim_no=claim.claim_no,
expense_type=claim.expense_type,
)
)
persisted_level, persisted_count = cls._persisted_risk_summary(
rows,
claim_business_stage=claim_business_stage,
now=now,
)
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._raw_risk_summary(
claim.risk_flags_json,
claim_id=str(claim.id or "").strip(),
materialized_keys=materialized_keys,
claim_business_stage=claim_business_stage,
)
return (
max((persisted_level, raw_level), key=lambda value: _RISK_ORDER[value]),
persisted_count + raw_count,
)
@staticmethod
def _persisted_risk_summary(
rows: Sequence[tuple[RiskObservation, RiskDisposition | None]],
*,
claim_business_stage: str,
now: datetime,
) -> tuple[str, int]:
level = "low"
count = 0
for observation, disposition in rows:
observation_stage = risk_flag_business_stage(
{"control_stage": observation.control_stage},
default="",
)
if observation_stage and observation_stage != claim_business_stage:
continue
adjudication = (
str(
disposition.adjudication
if disposition is not None
else observation.feedback_status or ""
)
.strip()
.lower()
)
lifecycle = (
str(disposition.lifecycle_status if disposition is not None else "open")
.strip()
.lower()
)
observation_status = str(observation.status or "").strip().lower()
active_waiver = False
if disposition is not None and lifecycle == "waived":
expires_at = disposition.waiver_expires_at
normalized_expiry = (
expires_at.replace(tzinfo=UTC)
if expires_at is not None and expires_at.tzinfo is None
else expires_at.astimezone(UTC)
if expires_at is not None
else None
)
active_waiver = (
str(disposition.waiver_decision or "").strip().lower() == "approved"
and normalized_expiry is not None
and normalized_expiry > now
)
if (
adjudication == "false_positive"
or lifecycle == "resolved"
or active_waiver
or observation_status in _CLOSED_OBSERVATION_STATES
):
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
@staticmethod
def _raw_risk_summary(
raw_flags: Any,
*,
claim_id: str,
materialized_keys: set[str],
claim_business_stage: str,
) -> tuple[str, int]:
flags = raw_flags if isinstance(raw_flags, list) else [raw_flags]
level = "low"
count = 0
for flag in flags:
if not isinstance(flag, dict) or not is_open_claim_risk_flag(flag):
continue
flag_stage = risk_flag_business_stage(flag, default="")
if flag_stage and flag_stage != claim_business_stage:
continue
observation_key = claim_risk_flag_observation_key(flag, claim_id=claim_id)
if observation_key and observation_key in materialized_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 _evidence_summary(cls, claim: ExpenseClaim) -> tuple[Decimal, tuple[str, ...]]:
checks = [
("事由", bool(str(claim.reason or "").strip())),
("地点", bool(str(claim.location or "").strip())),
("费用明细", bool(list(claim.items or []))),
]
if not is_application_claim_reference(
claim_no=claim.claim_no,
expense_type=claim.expense_type,
):
checks.append(
(
"票据",
int(claim.invoice_count or 0) > 0
or any(str(item.invoice_id or "").strip() for item in list(claim.items or [])),
)
)
missing = tuple(label for label, present in checks if not present)
if not checks:
return Decimal("1.0000"), missing
present_count = len(checks) - len(missing)
completeness = (Decimal(present_count) / Decimal(len(checks))).quantize(
_COMPLETENESS_QUANTUM, rounding=ROUND_HALF_UP
)
return completeness, missing
@classmethod
def _priority_reasons(
cls,
*,
risk_level: str,
open_risk_count: int,
waiting_hours: Decimal,
sla_hours: int,
budget_usage_rate: Decimal | None,
amount: Decimal,
missing_evidence: tuple[str, ...],
) -> list[dict[str, Any]]:
reasons: list[dict[str, Any]] = []
risk_weight = _RISK_WEIGHTS[risk_level]
if risk_weight:
reasons.append(
{
"code": "open_risk",
"label": f"{risk_level.upper()} 风险 {open_risk_count} 项待复核",
"weight": risk_weight,
"tone": "danger" if risk_level in {"high", "critical"} else "warning",
}
)
sla = Decimal(sla_hours)
if waiting_hours >= sla:
reasons.append(
{
"code": "sla_overdue",
"label": f"已超过 {sla_hours} 小时审批 SLA",
"weight": 26,
"tone": "danger",
}
)
elif waiting_hours >= sla * Decimal("0.6667"):
reasons.append(
{
"code": "sla_near_due",
"label": "审批 SLA 即将到期",
"weight": 16,
"tone": "warning",
}
)
elif waiting_hours >= sla * Decimal("0.3333"):
reasons.append(
{
"code": "waiting",
"label": "审批等待时间较长",
"weight": 8,
"tone": "warning",
}
)
if budget_usage_rate is not None and budget_usage_rate >= _BATCH_BUDGET_USAGE_LIMIT:
reasons.append(
{
"code": "budget_pressure",
"label": f"审批后预算占用约 {budget_usage_rate.quantize(Decimal('1'))}%",
"weight": 18,
"tone": "danger" if budget_usage_rate >= 100 else "warning",
}
)
if amount >= Decimal("50000"):
reasons.append(
{
"code": "large_amount",
"label": "大额费用需重点核对",
"weight": 15,
"tone": "warning",
}
)
elif amount >= _BATCH_AMOUNT_LIMIT:
reasons.append(
{
"code": "amount_attention",
"label": "金额达到 1 万元复核线",
"weight": 8,
"tone": "warning",
}
)
if missing_evidence:
reasons.append(
{
"code": "evidence_gap",
"label": "材料仍有缺口:" + "".join(missing_evidence),
"weight": 12,
"tone": "warning",
}
)
if not reasons:
reasons.append(
{
"code": "routine",
"label": "常规低风险待办",
"weight": 0,
"tone": "neutral",
}
)
return reasons
@staticmethod
def _batch_block_reasons(
*,
approval_stage: str,
risk_level: str,
open_risk_count: int,
evidence_completeness: Decimal,
amount: Decimal,
budget_usage_rate: Decimal | None,
overdue: bool,
) -> list[str]:
reasons: list[str] = []
if approval_stage not in SUPPORTED_APPROVAL_STAGES:
reasons.append("unsupported_approval_stage")
if open_risk_count > 0 or risk_level != "low":
reasons.append("open_risk")
if evidence_completeness < Decimal("1"):
reasons.append("evidence_incomplete")
if amount >= _BATCH_AMOUNT_LIMIT:
reasons.append("amount_requires_individual_review")
if budget_usage_rate is not None and budget_usage_rate >= _BATCH_BUDGET_USAGE_LIMIT:
reasons.append("budget_pressure")
if overdue:
reasons.append("sla_overdue")
return reasons
@classmethod
def _budget_usage_rate(cls, raw_flags: Any) -> Decimal | None:
values: list[Decimal] = []
flags = raw_flags if isinstance(raw_flags, list) else [raw_flags]
for flag in flags:
if not isinstance(flag, dict):
continue
candidates = [
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 candidates 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._optional_decimal(container.get(key))
if value is None or value < 0:
continue
values.append(value * 100 if Decimal("0") < value <= 1 else value)
return max(values).quantize(Decimal("0.01")) if values else None
@staticmethod
def _decimal(value: Any) -> Decimal:
try:
return Decimal(str(value or "0"))
except (InvalidOperation, TypeError, ValueError):
return Decimal("0")
@staticmethod
def _optional_decimal(value: Any) -> Decimal | None:
if value is None or str(value).strip() == "":
return None
try:
return Decimal(str(value))
except (InvalidOperation, TypeError, ValueError):
return None
@staticmethod
def _aware(value: datetime, *, field_name: str) -> datetime:
if value.tzinfo is None or value.utcoffset() is None:
raise ValueError(f"{field_name} must include a timezone")
return value.astimezone(UTC)

View File

@@ -0,0 +1,117 @@
from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models.approval_task import ApprovalTask
from app.models.financial_record import ExpenseClaim
from app.services.approval_task_projection import ApprovalTaskProjectionService
from app.services.expense_claim_tenant_scope import ExpenseClaimTenantScopeMixin
OPEN_TASK_STATUSES = ("waiting", "pending")
class ApprovalTaskProjectionRefreshService:
"""在风险事实变化后,同事务刷新开放任务的只读队列投影。"""
def __init__(self, db: Session) -> None:
self.db = db
self.projections = ApprovalTaskProjectionService(db)
def refresh_claim(
self,
*,
tenant_id: str,
claim_id: str,
now: datetime | None = None,
) -> list[str]:
tenant = ExpenseClaimTenantScopeMixin.normalize_tenant_id(tenant_id)
normalized_claim_id = str(claim_id or "").strip()
if not normalized_claim_id:
return []
claim_statement = select(ExpenseClaim).where(
ExpenseClaim.id == normalized_claim_id,
ExpenseClaimTenantScopeMixin.build_claim_tenant_condition(tenant),
)
task_statement = (
select(ApprovalTask)
.where(
ApprovalTask.tenant_id == tenant,
ApprovalTask.claim_id == normalized_claim_id,
ApprovalTask.status.in_(OPEN_TASK_STATUSES),
)
.order_by(ApprovalTask.id.asc())
)
bind = self.db.get_bind()
if bind is not None and bind.dialect.name == "postgresql":
claim_statement = claim_statement.with_for_update()
task_statement = task_statement.with_for_update()
claim = self.db.scalar(claim_statement.execution_options(populate_existing=True))
if claim is None:
return []
generated_at = _as_utc(now or datetime.now(UTC))
tasks = list(
self.db.scalars(
task_statement.execution_options(populate_existing=True)
).all()
)
for task in tasks:
projection = self.projections.build(
claim,
tenant_id=tenant,
entered_at=_as_utc(task.entered_at),
now=generated_at,
sla_hours=task.sla_hours_snapshot,
)
escalation_reasons = [
dict(reason)
for reason in list(task.priority_reasons_json or [])
if str(reason.get("code") or "").startswith("sla_escalated_l")
]
reasons = [
dict(reason)
for reason in projection.priority_reasons_json
if not (
str(reason.get("code") or "") == "routine" and escalation_reasons
)
]
reasons.extend(escalation_reasons)
task.priority_reasons_json = _unique_reasons(reasons)
task.priority_score = min(
100,
sum(int(reason.get("weight") or 0) for reason in task.priority_reasons_json),
)
task.priority_tier = (
"urgent"
if task.priority_score >= 65
else "high"
if task.priority_score >= 40
else "normal"
)
task.risk_level = projection.risk_level
task.open_risk_count = projection.open_risk_count
task.evidence_completeness = projection.evidence_completeness
task.batch_eligible = projection.batch_eligible
task.batch_block_reasons_json = list(projection.batch_block_reasons_json)
task.projection_updated_at = generated_at
task.updated_at = generated_at
if tasks:
self.db.flush()
return [task.id for task in tasks]
def _unique_reasons(reasons: list[dict]) -> list[dict]:
unique: dict[str, dict] = {}
for index, reason in enumerate(reasons):
code = str(reason.get("code") or "reason").strip()
unique[f"{code}:{index}" if code in unique else code] = reason
return list(unique.values())
def _as_utc(value: datetime) -> datetime:
if value.tzinfo is None:
return value.replace(tzinfo=UTC)
return value.astimezone(UTC)

View File

@@ -0,0 +1,223 @@
from __future__ import annotations
import hashlib
import json
import uuid
from collections.abc import Mapping, Sequence
from datetime import UTC, datetime
from typing import Any
from pydantic import ValidationError
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models.approval_task import ApprovalTask, ApprovalTaskEvent
from app.schemas.approval_task import (
ApprovalTaskEventRead,
ApprovalTaskMutationRead,
ApprovalTaskRead,
)
from app.services.approval_task_access_policy import ApprovalTaskPermission
class ApprovalTaskConflictError(ValueError):
pass
class ApprovalTaskVersionConflictError(ApprovalTaskConflictError):
def __init__(self, current_version: int) -> None:
self.current_version = current_version
super().__init__(f"审批任务版本已变化,当前版本为 {current_version},请刷新后重试。")
class ApprovalTaskIdempotencyConflictError(ApprovalTaskConflictError):
pass
def normalize_actor_id(value: object) -> str:
return str(value or "").strip().casefold() or "anonymous"
def payload_fingerprint(
*,
task_id: str,
actor_id: str,
event_type: str,
payload: Mapping[str, Any],
) -> str:
canonical = json.dumps(
{
"task_id": str(task_id),
"actor_id": normalize_actor_id(actor_id),
"event_type": str(event_type).strip(),
"payload": dict(payload),
},
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
default=_json_default,
)
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
class ApprovalTaskEventProtocol:
"""审批任务 append-only 事件与不可变响应重放。"""
def __init__(self, db: Session) -> None:
self.db = db
def find_replay(
self,
*,
tenant_id: str,
actor_id: str,
request_id: str,
fingerprint: str,
) -> ApprovalTaskMutationRead | None:
event = self.db.scalar(
select(ApprovalTaskEvent).where(
ApprovalTaskEvent.tenant_id == tenant_id,
ApprovalTaskEvent.actor_id == normalize_actor_id(actor_id),
ApprovalTaskEvent.request_id == str(request_id).strip(),
)
)
if event is None:
return None
if str(event.payload_fingerprint) != fingerprint:
raise ApprovalTaskIdempotencyConflictError(
"request_id 已被不同的审批任务动作使用。"
)
response = self._response_from_event(event)
return response.model_copy(update={"replayed": True})
def append_event(
self,
*,
task: ApprovalTask,
event_type: str,
actor_id: str,
actor_name: str,
actor_type: str,
request_id: str,
expected_task_version: int,
payload: Mapping[str, Any],
before_json: Mapping[str, Any],
related_tasks: Sequence[ApprovalTask] = (),
permission: ApprovalTaskPermission | None = None,
approval_action_ledger_id: str | None = None,
business_event_id: str | None = None,
correlation_id: str | None = None,
causation_id: str | None = None,
occurred_at: datetime | None = None,
) -> tuple[ApprovalTaskEvent, ApprovalTaskMutationRead]:
normalized_actor = normalize_actor_id(actor_id)
normalized_request = str(request_id or "").strip()
if not normalized_request:
raise ValueError("审批任务事件 request_id 不能为空。")
fingerprint = payload_fingerprint(
task_id=task.id,
actor_id=normalized_actor,
event_type=event_type,
payload=payload,
)
event = ApprovalTaskEvent(
id=str(uuid.uuid4()),
tenant_id=task.tenant_id,
task_id=task.id,
node_instance_id=task.node_instance_id,
event_type=str(event_type).strip(),
actor_id=normalized_actor,
actor_name=str(actor_name or actor_id or "anonymous").strip() or "anonymous",
actor_type=str(actor_type or "user").strip(),
request_id=normalized_request,
expected_task_version=int(expected_task_version),
result_task_version=int(task.version),
payload_fingerprint=fingerprint,
payload_json=dict(payload),
before_json=dict(before_json),
after_json=task_state(task),
response_json={},
approval_action_ledger_id=approval_action_ledger_id,
business_event_id=business_event_id,
correlation_id=correlation_id,
causation_id=causation_id,
)
event.occurred_at = occurred_at or datetime.now(UTC)
response = ApprovalTaskMutationRead(
task=task_read(task, permission=permission),
event=ApprovalTaskEventRead.model_validate(event),
related_tasks=[task_read(item) for item in related_tasks],
replayed=False,
)
event.response_json = response.model_dump(mode="json")
self.db.add(event)
return event, response
def _response_from_event(self, event: ApprovalTaskEvent) -> ApprovalTaskMutationRead:
try:
response = ApprovalTaskMutationRead.model_validate(event.response_json)
except (TypeError, ValueError, ValidationError) as error:
raise ApprovalTaskConflictError(
"审批任务事件缺少可验证的原始响应快照,请刷新后重试。"
) from error
if (
response.event.id != event.id
or response.event.task_id != event.task_id
or response.event.request_id != event.request_id
or response.event.result_task_version != event.result_task_version
or response.task.id != event.task_id
or response.task.tenant_id != event.tenant_id
or response.task.version != event.result_task_version
):
raise ApprovalTaskConflictError("审批任务事件响应快照身份校验失败。")
return response
def task_read(
task: ApprovalTask,
*,
permission: ApprovalTaskPermission | None = None,
) -> ApprovalTaskRead:
update: dict[str, Any] = {}
if permission is not None:
update = {
"can_act": permission.can_act,
"available_actions": list(permission.available_actions),
"read_only_reason": permission.read_only_reason,
}
return ApprovalTaskRead.model_validate(task).model_copy(update=update)
def task_state(task: ApprovalTask) -> dict[str, Any]:
return {
"status": task.status,
"decision": task.decision,
"opinion": task.opinion,
"version": task.version,
"coordination_mode": task.coordination_mode,
"owner_kind": task.owner_kind,
"owner_key": task.owner_key,
"owner_employee_id": task.owner_employee_id,
"owner_name": task.owner_name,
"assignee_kind": task.assignee_kind,
"assignee_key": task.assignee_key,
"assignee_employee_id": task.assignee_employee_id,
"assignee_name": task.assignee_name,
"delegated_by": task.delegated_by,
"delegation_expires_at": _json_default(task.delegation_expires_at),
"claim_status_snapshot": task.claim_status_snapshot,
"claim_stage_snapshot": task.claim_stage_snapshot,
"activated_at": _json_default(task.activated_at),
"completed_at": _json_default(task.completed_at),
"cancelled_at": _json_default(task.cancelled_at),
"escalation_level": task.escalation_level,
"escalated_at": _json_default(task.escalated_at),
"next_escalation_at": _json_default(task.next_escalation_at),
"projection_updated_at": _json_default(task.projection_updated_at),
}
def _json_default(value: object) -> object:
if isinstance(value, datetime):
return value.isoformat()
return value

View File

@@ -0,0 +1,278 @@
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from sqlalchemy import and_, func, or_, select
from sqlalchemy.orm import Session, selectinload
from app.api.deps import CurrentUserContext
from app.models.approval_task import ApprovalTask
from app.models.employee import Employee
from app.models.financial_record import ExpenseClaim
from app.schemas.approval_task import (
ApprovalTaskListRead,
ApprovalTaskQueueItemRead,
)
from app.schemas.reimbursement import ExpenseClaimRead
from app.services.approval_task_access_policy import ApprovalTaskAccessPolicy
from app.services.approval_task_protocol import task_read
from app.services.expense_claim_access_policy import ExpenseClaimAccessPolicy
from app.services.expense_claim_tenant_scope import ExpenseClaimTenantScopeMixin
class ApprovalTaskQueryService:
"""服务端分页的个人审批队列GET 路径严格只读。"""
def __init__(self, db: Session) -> None:
self.db = db
self.task_access = ApprovalTaskAccessPolicy(db)
self.claim_access = ExpenseClaimAccessPolicy(db)
def list_tasks(
self,
current_user: CurrentUserContext,
*,
page: int = 1,
page_size: int = 20,
status: str = "pending",
risk_level: str | None = None,
sla_state: str | None = None,
node_key: str | None = None,
task_kind: str | None = None,
batch_eligible: bool | None = None,
assignee: str | None = None,
sort: str = "priority_desc",
keyword: str | None = None,
) -> ApprovalTaskListRead:
page = max(int(page), 1)
page_size = min(max(int(page_size), 1), 200)
tenant = ExpenseClaimTenantScopeMixin.normalize_tenant_id(current_user.tenant_id)
now = datetime.now(UTC)
conditions = [
ApprovalTask.tenant_id == tenant,
ExpenseClaimTenantScopeMixin.build_claim_tenant_condition(tenant),
self._status_condition(status),
]
visibility = self._visibility_condition(current_user)
if visibility is not None:
conditions.append(visibility)
if risk_level:
conditions.append(ApprovalTask.risk_level == str(risk_level).strip().lower())
if node_key:
conditions.append(ApprovalTask.node_key == str(node_key).strip().lower())
if task_kind:
normalized_kind = str(task_kind).strip().lower()
if normalized_kind not in {"root", "add_sign", "countersign"}:
raise ValueError("不支持的审批任务类型筛选。")
conditions.append(ApprovalTask.task_kind == normalized_kind)
if batch_eligible is not None:
conditions.append(ApprovalTask.batch_eligible.is_(bool(batch_eligible)))
normalized_assignee = str(assignee or "").strip()
if normalized_assignee:
assignee_pattern = f"%{normalized_assignee}%"
conditions.append(
or_(
ApprovalTask.assignee_name.ilike(assignee_pattern),
ApprovalTask.assignee_key.ilike(assignee_pattern),
)
)
normalized_sla_state = str(sla_state or "").strip().lower()
if normalized_sla_state == "overdue":
conditions.append(ApprovalTask.due_at < now)
elif normalized_sla_state == "due_soon":
conditions.extend(
(
ApprovalTask.due_at >= now,
ApprovalTask.due_at <= now + timedelta(hours=4),
)
)
elif normalized_sla_state == "on_track":
conditions.append(ApprovalTask.due_at > now + timedelta(hours=4))
elif normalized_sla_state == "escalated":
conditions.append(ApprovalTask.escalation_level > 0)
elif normalized_sla_state:
raise ValueError("不支持的审批任务 SLA 状态筛选。")
normalized_keyword = str(keyword or "").strip()
if normalized_keyword:
pattern = f"%{normalized_keyword}%"
conditions.append(
or_(
ExpenseClaim.claim_no.ilike(pattern),
ExpenseClaim.employee_name.ilike(pattern),
ExpenseClaim.reason.ilike(pattern),
ApprovalTask.assignee_name.ilike(pattern),
ApprovalTask.owner_name.ilike(pattern),
)
)
total = int(
self.db.scalar(
select(func.count())
.select_from(ApprovalTask)
.join(ExpenseClaim, ExpenseClaim.id == ApprovalTask.claim_id)
.where(*conditions)
)
or 0
)
statement = (
select(ApprovalTask, ExpenseClaim)
.join(ExpenseClaim, ExpenseClaim.id == ApprovalTask.claim_id)
.options(
selectinload(ExpenseClaim.items),
selectinload(ExpenseClaim.employee).selectinload(Employee.manager),
selectinload(ExpenseClaim.employee).selectinload(Employee.roles),
selectinload(ExpenseClaim.employee).selectinload(Employee.organization_unit),
)
.where(*conditions)
.order_by(*self._ordering(sort))
.offset((page - 1) * page_size)
.limit(page_size)
)
items: list[ApprovalTaskQueueItemRead] = []
for task, claim in self.db.execute(statement).unique().all():
permission = self.task_access.evaluate(task, current_user, claim=claim)
if not permission.can_read: # SQL 与领域策略双重校验。
continue
self.claim_access.attach_approval_snapshot(claim)
items.append(
ApprovalTaskQueueItemRead(
task=task_read(task, permission=permission),
claim=ExpenseClaimRead.model_validate(claim),
)
)
return ApprovalTaskListRead(
items=items,
total=total,
page=page,
page_size=page_size,
total_pages=(total + page_size - 1) // page_size if total else 0,
generated_at=now,
)
def get_task(
self,
task_id: str,
current_user: CurrentUserContext,
) -> ApprovalTaskQueueItemRead | None:
tenant = ExpenseClaimTenantScopeMixin.normalize_tenant_id(current_user.tenant_id)
row = self.db.execute(
select(ApprovalTask, ExpenseClaim)
.join(ExpenseClaim, ExpenseClaim.id == ApprovalTask.claim_id)
.options(
selectinload(ExpenseClaim.items),
selectinload(ExpenseClaim.employee).selectinload(Employee.manager),
selectinload(ExpenseClaim.employee).selectinload(Employee.roles),
selectinload(ExpenseClaim.employee).selectinload(Employee.organization_unit),
)
.where(
ApprovalTask.tenant_id == tenant,
ApprovalTask.id == str(task_id).strip(),
ExpenseClaimTenantScopeMixin.build_claim_tenant_condition(tenant),
)
).unique().one_or_none()
if row is None:
return None
task, claim = row
permission = self.task_access.evaluate(task, current_user, claim=claim)
if not permission.can_read:
return None
self.claim_access.attach_approval_snapshot(claim)
return ApprovalTaskQueueItemRead(
task=task_read(task, permission=permission),
claim=ExpenseClaimRead.model_validate(claim),
)
def _visibility_condition(self, current_user: CurrentUserContext):
if current_user.is_admin:
return None
employee = self.claim_access.resolve_current_employee(current_user)
identities = {
str(current_user.username or "").strip().casefold(),
str(current_user.employee_id or "").strip().casefold(),
str(current_user.employee_no or "").strip().casefold(),
str(current_user.name or "").strip().casefold(),
}
employee_ids: set[str] = set()
if employee is not None:
identities.update(
{
str(employee.id or "").strip().casefold(),
str(employee.employee_no or "").strip().casefold(),
str(employee.email or "").strip().casefold(),
str(employee.name or "").strip().casefold(),
}
)
employee_ids.add(employee.id)
if current_user.employee_id:
employee_ids.add(str(current_user.employee_id).strip())
identities.discard("")
role_codes = ExpenseClaimAccessPolicy.normalize_role_codes(current_user)
employee_match = or_(
and_(
ApprovalTask.assignee_kind == "employee",
or_(
func.lower(ApprovalTask.assignee_key).in_(identities),
ApprovalTask.assignee_employee_id.in_(employee_ids),
),
),
and_(
ApprovalTask.owner_kind == "employee",
or_(
func.lower(ApprovalTask.owner_key).in_(identities),
ApprovalTask.owner_employee_id.in_(employee_ids),
),
),
)
role_match = or_(
and_(
ApprovalTask.assignee_kind == "role",
func.lower(ApprovalTask.assignee_key).in_(role_codes),
),
and_(
ApprovalTask.owner_kind == "role",
func.lower(ApprovalTask.owner_key).in_(role_codes),
),
)
return or_(employee_match, role_match)
@staticmethod
def _status_condition(value: str):
normalized = str(value or "pending").strip().lower()
if normalized in {"open", "pending"}:
return ApprovalTask.status.in_(("waiting", "pending"))
if normalized == "processed":
return ApprovalTask.status.in_(("completed", "returned", "cancelled", "superseded"))
if normalized in {
"waiting",
"completed",
"returned",
"cancelled",
"superseded",
}:
return ApprovalTask.status == normalized
raise ValueError("不支持的审批任务状态筛选。")
@staticmethod
def _ordering(value: str):
normalized = str(value or "priority_desc").strip().lower()
if normalized == "priority_desc":
return (
ApprovalTask.priority_score.desc(),
ApprovalTask.due_at.asc(),
ApprovalTask.entered_at.asc(),
ApprovalTask.id.asc(),
)
if normalized == "due_asc":
return (
ApprovalTask.due_at.asc(),
ApprovalTask.priority_score.desc(),
ApprovalTask.entered_at.asc(),
ApprovalTask.id.asc(),
)
if normalized == "entered_desc":
return (
ApprovalTask.entered_at.desc(),
ApprovalTask.priority_score.desc(),
ApprovalTask.id.asc(),
)
raise ValueError("不支持的审批任务排序方式。")

View File

@@ -0,0 +1,76 @@
from __future__ import annotations
import os
import threading
from app.core.logging import get_logger
from app.db.session import get_session_factory
from app.services.approval_task_sla import ApprovalTaskSlaService
logger = get_logger("app.services.approval_task_scheduler")
class ApprovalTaskScheduler:
def __init__(self) -> None:
interval = int(os.environ.get("X_FINANCIAL_APPROVAL_TASK_INTERVAL_SECONDS") or "60")
initial_delay = int(
os.environ.get("X_FINANCIAL_APPROVAL_TASK_INITIAL_DELAY_SECONDS") or "10"
)
self._interval_seconds = max(30, interval)
self._initial_delay_seconds = max(1, initial_delay)
self._stop_event = threading.Event()
self._thread: threading.Thread | None = None
self._lock = threading.Lock()
def start(self) -> None:
with self._lock:
if self._thread is not None and self._thread.is_alive():
return
self._stop_event.clear()
self._thread = threading.Thread(
target=self._run_loop,
name="approval-task-scheduler",
daemon=True,
)
self._thread.start()
logger.info(
"Approval task scheduler started interval=%ss",
self._interval_seconds,
)
def shutdown(self) -> None:
with self._lock:
thread = self._thread
self._thread = None
self._stop_event.set()
if thread is not None and thread.is_alive():
thread.join(timeout=3)
logger.info("Approval task scheduler stopped")
def _run_loop(self) -> None:
if self._stop_event.wait(self._initial_delay_seconds):
return
while not self._stop_event.is_set():
try:
self._run_once()
except Exception: # pragma: no cover - scheduler best effort logging
logger.exception("Scheduled approval task SLA escalation failed")
if self._stop_event.wait(self._interval_seconds):
break
@staticmethod
def _run_once() -> None:
db = get_session_factory()()
try:
escalated = ApprovalTaskSlaService(db).escalate_due_tasks()
db.commit()
if escalated:
logger.info("Approval task SLA escalated count=%s", len(escalated))
except Exception:
db.rollback()
raise
finally:
db.close()
approval_task_scheduler = ApprovalTaskScheduler()

View File

@@ -0,0 +1,147 @@
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models.approval_task import ApprovalTask
from app.models.financial_record import ExpenseClaim
from app.services.approval_task_protocol import ApprovalTaskEventProtocol, task_state
from app.services.expense_claim_tenant_scope import ExpenseClaimTenantScopeMixin
MAX_ESCALATION_LEVEL = 3
class ApprovalTaskSlaService:
"""按节点进入时间快照升级超时任务,不改变审批权限和 Claim 状态。"""
def __init__(self, db: Session) -> None:
self.db = db
self.events = ApprovalTaskEventProtocol(db)
def escalate_due_tasks(
self,
*,
now: datetime | None = None,
limit: int = 100,
) -> list[str]:
checked_at = _as_utc(now or datetime.now(UTC))
locators = list(
self.db.execute(
select(
ApprovalTask.id,
ApprovalTask.claim_id,
ApprovalTask.tenant_id,
)
.where(
ApprovalTask.status.in_(("waiting", "pending")),
ApprovalTask.next_escalation_at.is_not(None),
ApprovalTask.next_escalation_at <= checked_at,
ApprovalTask.escalation_level < MAX_ESCALATION_LEVEL,
)
.order_by(
ApprovalTask.next_escalation_at.asc(),
ApprovalTask.priority_score.desc(),
ApprovalTask.id.asc(),
)
.limit(min(max(int(limit), 1), 500))
).all()
)
escalated: list[str] = []
for task_id, claim_id, tenant_id in locators:
if self._escalate_one(
task_id=str(task_id),
claim_id=str(claim_id),
tenant_id=str(tenant_id),
now=checked_at,
):
escalated.append(str(task_id))
return escalated
def _escalate_one(
self,
*,
task_id: str,
claim_id: str,
tenant_id: str,
now: datetime,
) -> bool:
claim_statement = select(ExpenseClaim).where(
ExpenseClaim.id == claim_id,
ExpenseClaimTenantScopeMixin.build_claim_tenant_condition(tenant_id),
)
task_statement = select(ApprovalTask).where(
ApprovalTask.tenant_id == tenant_id,
ApprovalTask.id == task_id,
)
bind = self.db.get_bind()
if bind is not None and bind.dialect.name == "postgresql":
claim_statement = claim_statement.with_for_update()
task_statement = task_statement.with_for_update()
claim = self.db.scalar(claim_statement.execution_options(populate_existing=True))
if claim is None:
return False
task = self.db.scalar(task_statement.execution_options(populate_existing=True))
if (
task is None
or task.status not in {"waiting", "pending"}
or task.next_escalation_at is None
or _as_utc(task.next_escalation_at) > now
or task.escalation_level >= MAX_ESCALATION_LEVEL
):
return False
before = task_state(task)
expected = task.version
task.escalation_level += 1
task.escalated_at = now
task.version += 1
task.updated_at = now
task.next_escalation_at = (
None
if task.escalation_level >= MAX_ESCALATION_LEVEL
else now + timedelta(hours=4 * (2 ** (task.escalation_level - 1)))
)
task.priority_score = min(100, int(task.priority_score) + 10)
task.priority_tier = "urgent" if task.priority_score >= 65 else "high"
reasons = list(task.priority_reasons_json or [])
reasons.append(
{
"code": f"sla_escalated_l{task.escalation_level}",
"label": f"审批超时已升级至 L{task.escalation_level}",
"weight": 10,
"tone": "danger",
}
)
task.priority_reasons_json = reasons
self.events.append_event(
task=task,
event_type="sla_escalated",
actor_id="system",
actor_name="审批 SLA 调度器",
actor_type="system",
request_id=f"sla:{task.id}:{task.version}",
expected_task_version=expected,
payload={
"level": task.escalation_level,
"due_at": task.due_at.isoformat(),
"next_escalation_at": (
task.next_escalation_at.isoformat()
if task.next_escalation_at is not None
else None
),
},
before_json=before,
occurred_at=now,
)
# 项目 Session 关闭了 autoflush同一事务再次扫描前必须让版本、
# 下次升级时间和唯一事件版本同时可见。
self.db.flush()
return True
def _as_utc(value: datetime) -> datetime:
if value.tzinfo is None:
return value.replace(tzinfo=UTC)
return value.astimezone(UTC)

View File

@@ -8,6 +8,7 @@ from sqlalchemy import select
from app.api.deps import CurrentUserContext
from app.models.approval_action import ApprovalActionLedger
from app.models.approval_task import ApprovalTask
from app.models.financial_record import ExpenseClaim
from app.schemas.reimbursement import ExpenseClaimRead
from app.services.approval_action_protocol import (
@@ -34,6 +35,8 @@ class ExpenseClaimActionProtocolMixin:
expected_approval_stage: str | None,
payload: Mapping[str, Any],
executor: ClaimActionExecutor,
task_id: str | None = None,
expected_task_version: int | None = None,
) -> ExpenseClaim | ExpenseClaimRead | None:
protocol = ApprovalActionProtocol(self.db)
tenant_id = ExpenseClaimTenantScopeMixin.normalize_tenant_id(current_user.tenant_id)
@@ -55,6 +58,7 @@ class ExpenseClaimActionProtocolMixin:
claim_loader=lambda: self._load_claim_for_action(
claim_id,
current_user,
task_id=task_id,
),
)
if started.replayed:
@@ -69,11 +73,48 @@ class ExpenseClaimActionProtocolMixin:
if started.ledger is None: # pragma: no cover - defensive invariant
raise RuntimeError("审批动作账本初始化失败。")
task_lifecycle = None
task_context = None
if action in {"approve", "return"}:
from app.services.approval_task_lifecycle import (
ApprovalTaskLifecycleService,
)
task_lifecycle = ApprovalTaskLifecycleService(self.db)
task_context = task_lifecycle.begin_claim_decision(
started.claim,
current_user,
action=action,
task_id=task_id,
expected_task_version=expected_task_version,
)
started.claim._approval_task_authorized = True
claim = executor(
started.claim,
started.ledger,
normalized_request_id,
)
if task_lifecycle is not None and task_context is not None:
opinion = str(
payload.get("opinion")
or payload.get("reason")
or ""
).strip()
task_lifecycle.complete_claim_decision(
task_context,
claim,
current_user,
action=action,
opinion=opinion,
request_id=normalized_request_id,
ledger=started.ledger,
business_event=getattr(
claim,
"_approval_task_business_event",
None,
),
)
self.db.flush()
self.db.refresh(claim)
response = ExpenseClaimRead.model_validate(
@@ -96,13 +137,31 @@ class ExpenseClaimActionProtocolMixin:
self,
claim_id: str,
current_user: CurrentUserContext,
*,
task_id: str | None = None,
) -> ExpenseClaim | None:
stmt = select(ExpenseClaim).where(ExpenseClaim.id == claim_id)
stmt = self._access_policy.apply_claim_scope(
stmt,
current_user,
include_approval_scope=True,
)
if task_id:
tenant_id = ExpenseClaimTenantScopeMixin.normalize_tenant_id(
current_user.tenant_id
)
task_claim_id = self.db.scalar(
select(ApprovalTask.claim_id).where(
ApprovalTask.tenant_id == tenant_id,
ApprovalTask.id == str(task_id).strip(),
)
)
if str(task_claim_id or "") != str(claim_id):
return None
stmt = stmt.where(
ExpenseClaimTenantScopeMixin.build_claim_tenant_condition(tenant_id)
)
else:
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()

View File

@@ -36,6 +36,8 @@ class ExpenseClaimApprovalFlowMixin:
request_id: str | None = None,
expected_status: str | None = None,
expected_approval_stage: str | None = None,
task_id: str | None = None,
expected_task_version: int | None = None,
):
normalized_opinion = str(opinion or "").strip()
return self._execute_claim_action(
@@ -45,7 +47,11 @@ class ExpenseClaimApprovalFlowMixin:
request_id=request_id,
expected_status=expected_status,
expected_approval_stage=expected_approval_stage,
payload={"opinion": normalized_opinion},
payload={
"opinion": normalized_opinion,
"task_id": str(task_id or "").strip() or None,
"expected_task_version": expected_task_version,
},
executor=lambda claim, ledger, normalized_request_id: self._approve_claim_once(
claim,
current_user,
@@ -53,6 +59,8 @@ class ExpenseClaimApprovalFlowMixin:
ledger=ledger,
request_id=normalized_request_id,
),
task_id=task_id,
expected_task_version=expected_task_version,
)
def _approve_claim_once(
@@ -77,7 +85,9 @@ class ExpenseClaimApprovalFlowMixin:
route_decision_flag: dict[str, Any] | None = None
generated_draft = None
if previous_stage == DIRECT_MANAGER_APPROVAL_STAGE:
if not self._access_policy.can_approve_claim(current_user, claim):
if not getattr(claim, "_approval_task_authorized", False) and 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"
@@ -154,7 +164,9 @@ class ExpenseClaimApprovalFlowMixin:
"{operator} 已审批通过,系统判断预算充足且无风险,流转至{next_stage}"
)
elif previous_stage == BUDGET_MANAGER_APPROVAL_STAGE:
if not self._access_policy.can_approve_claim(current_user, claim):
if not getattr(claim, "_approval_task_authorized", False) and 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"
@@ -175,7 +187,9 @@ class ExpenseClaimApprovalFlowMixin:
elif previous_stage == FINANCE_APPROVAL_STAGE:
if is_application_claim:
raise ValueError("费用申请需先完成预算管理者审批。")
if not self._access_policy.can_approve_claim(current_user, claim):
if not getattr(claim, "_approval_task_authorized", False) and 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"
@@ -340,6 +354,7 @@ class ExpenseClaimApprovalFlowMixin:
),
},
)
claim._approval_task_business_event = _event
if generated_draft is not None:
self._expense_cases.record_claim_event(
generated_draft,

View File

@@ -21,6 +21,8 @@ class ExpenseClaimReturnFlowMixin:
request_id: str | None = None,
expected_status: str | None = None,
expected_approval_stage: str | None = None,
task_id: str | None = None,
expected_task_version: int | None = None,
) -> ExpenseClaim | ExpenseClaimRead | None:
normalized_reason = str(reason or "").strip()
reason_code_payload = self._normalize_return_reason_code_payload(reason_codes)
@@ -35,7 +37,12 @@ class ExpenseClaimReturnFlowMixin:
request_id=request_id,
expected_status=expected_status,
expected_approval_stage=expected_approval_stage,
payload={"reason": normalized_reason, "reason_codes": normalized_codes},
payload={
"reason": normalized_reason,
"reason_codes": normalized_codes,
"task_id": str(task_id or "").strip() or None,
"expected_task_version": expected_task_version,
},
executor=lambda claim, ledger, normalized_request_id: self._return_claim_once(
claim,
current_user,
@@ -44,6 +51,8 @@ class ExpenseClaimReturnFlowMixin:
ledger=ledger,
request_id=normalized_request_id,
),
task_id=task_id,
expected_task_version=expected_task_version,
)
def _return_claim_once(
@@ -64,7 +73,9 @@ class ExpenseClaimReturnFlowMixin:
if normalized_status in {"approved", "completed", "paid"}:
raise ValueError("已完成单据不允许退回。")
if not self._access_policy.can_return_claim(current_user, claim):
if not getattr(claim, "_approval_task_authorized", False) and not (
self._access_policy.can_return_claim(current_user, claim)
):
raise ValueError("只有财务人员、高级财务人员或当前审批人可以退回报销单。")
before_json = self._serialize_claim(claim)
@@ -162,7 +173,7 @@ class ExpenseClaimReturnFlowMixin:
business_stage=("expense_application" if is_application_claim else "reimbursement"),
)
self._expense_cases.record_claim_event(
_, return_event = self._expense_cases.record_claim_event(
claim,
event_type=("application_returned" if is_application_claim else "claim_returned"),
actor_id=current_user.username,
@@ -175,6 +186,7 @@ class ExpenseClaimReturnFlowMixin:
"reason_codes": normalized_reason_codes,
},
)
claim._approval_task_business_event = return_event
self.audit_service.log_action(
actor=operator,
action="expense_claim.return",

View File

@@ -1,6 +1,7 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import UTC, datetime
from sqlalchemy import select
from sqlalchemy.orm import Session
@@ -167,7 +168,18 @@ class ExpenseClaimRiskGate:
if disposition is not None:
adjudication = str(disposition.adjudication or "").strip().lower()
lifecycle = str(disposition.lifecycle_status or "").strip().lower()
if lifecycle == "waived":
expires_at = _as_utc(disposition.waiver_expires_at)
return expires_at is not None and expires_at > datetime.now(UTC)
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"
def _as_utc(value: datetime | None) -> datetime | None:
if value is None:
return None
if value.tzinfo is None:
return value.replace(tzinfo=UTC)
return value.astimezone(UTC)

View File

@@ -384,6 +384,18 @@ class ExpenseClaimItemActionMixin:
previous_status=str(before_json.get("status") or ""),
previous_approval_stage=str(before_json.get("approval_stage") or ""),
)
from app.services.approval_task_lifecycle import ApprovalTaskLifecycleService
ApprovalTaskLifecycleService(self.db).ensure_root_task(
claim,
tenant_id=current_user.tenant_id,
entered_at=submission_event.occurred_at,
entered_at_source="workflow_event",
business_event=submission_event,
actor_id="system",
actor_name="审批任务引擎",
request_id=f"node-enter:{submission_event.id}",
)
if before_commit is not None:
before_commit(submission_event)

View File

@@ -0,0 +1,214 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import UTC, datetime
from sqlalchemy.orm import Session
from app.api.deps import CurrentUserContext
from app.models.risk_disposition import RiskDisposition
from app.models.risk_observation import RiskObservation
from app.schemas.risk_disposition import RiskDispositionRead
from app.schemas.risk_observation import RiskObservationRead
from app.services.expense_claim_tenant_scope import ExpenseClaimTenantScopeMixin
from app.services.risk_observation_access_policy import RiskObservationAccessPolicy
from app.services.risk_waiver_decision_policy import RiskWaiverDecisionPolicy
@dataclass(frozen=True, slots=True)
class RiskDispositionActionPermission:
available_actions: tuple[str, ...]
read_only_reason: str = ""
class RiskDispositionActionPolicy:
"""根据当前用户与实时处置状态计算服务端权威动作集合。"""
def __init__(self, db: Session) -> None:
self.db = db
self.access_policy = RiskObservationAccessPolicy(db)
self.waiver_policy = RiskWaiverDecisionPolicy(db)
def evaluate(
self,
observation: RiskObservation,
disposition: RiskDisposition | RiskDispositionRead | None,
current_user: CurrentUserContext | None,
*,
now: datetime | None = None,
) -> RiskDispositionActionPermission:
current_at = _as_utc(now) or datetime.now(UTC)
tenant_id = ExpenseClaimTenantScopeMixin.normalize_tenant_id(observation.tenant_id)
if current_user is None:
return RiskDispositionActionPermission((), "请先登录后再处理风险观察。")
if ExpenseClaimTenantScopeMixin.normalize_tenant_id(current_user.tenant_id) != tenant_id:
return RiskDispositionActionPermission((), "风险观察不在当前租户范围内。")
adjudication = _adjudication(observation, disposition)
lifecycle = _lifecycle(observation, disposition)
can_manage = self.access_policy.can_manage_disposition(observation, current_user)
actions: list[str] = []
if can_manage and lifecycle != "resolved":
if adjudication != "confirmed":
actions.append("confirm")
if adjudication != "false_positive":
actions.append("false_positive")
if lifecycle != "supplement_requested":
actions.append("request_supplement")
if adjudication == "confirmed":
if lifecycle != "remediation_in_progress":
actions.append("start_remediation")
if lifecycle != "waiver_requested" or not _text(
disposition.waiver_requester_id if disposition is not None else None
):
actions.append("request_waiver")
actions.append("resolve")
decision_reason = ""
if disposition is not None and lifecycle == "waiver_requested":
eligibility = self.waiver_policy.evaluate_decider(
disposition,
current_user=current_user,
tenant_id=tenant_id,
)
decision_reason = eligibility.reason
if eligibility.allowed:
expires_at = _as_utc(disposition.waiver_expires_at)
if expires_at is not None and expires_at > current_at:
actions.append("approve_waiver")
actions.append("reject_waiver")
available_actions = tuple(dict.fromkeys(actions))
if available_actions:
return RiskDispositionActionPermission(available_actions)
if lifecycle == "resolved":
reason = "该风险已经解决,当前仅可查看审计记录。"
elif lifecycle == "waived" and _is_unexpired(disposition, now=current_at):
reason = "该风险处于有效豁免期,当前仅可查看审计记录。"
elif lifecycle == "waiver_requested" and decision_reason:
reason = decision_reason
elif not can_manage:
reason = "当前用户只有查看权限,无风险处置权限。"
else:
reason = "当前状态没有可执行的风险处置动作。"
return RiskDispositionActionPermission((), reason)
class RiskDispositionReadProjection:
"""把同一权限投影注入风险列表、详情和处置详情响应。"""
def __init__(self, db: Session) -> None:
self.policy = RiskDispositionActionPolicy(db)
def observation_read(
self,
observation: RiskObservation,
current_user: CurrentUserContext,
) -> RiskObservationRead:
disposition = observation.disposition
permission = self.policy.evaluate(observation, disposition, current_user)
disposition_read = (
self._with_permission(
RiskDispositionRead.model_validate(disposition),
permission,
)
if disposition is not None
else None
)
return RiskObservationRead.model_validate(observation).model_copy(
update={
"disposition": disposition_read,
"available_actions": list(permission.available_actions),
"read_only_reason": permission.read_only_reason,
}
)
def observation_list_read(
self,
observations: list[RiskObservation],
current_user: CurrentUserContext,
) -> list[RiskObservationRead]:
return [self.observation_read(item, current_user) for item in observations]
def disposition_read(
self,
disposition: RiskDisposition,
observation: RiskObservation,
current_user: CurrentUserContext,
) -> RiskDispositionRead:
return self.disposition_snapshot_read(
RiskDispositionRead.model_validate(disposition),
observation,
current_user,
)
def disposition_snapshot_read(
self,
disposition: RiskDispositionRead,
observation: RiskObservation,
current_user: CurrentUserContext,
) -> RiskDispositionRead:
"""为幂等响应快照补权限字段,不改写快照中的历史业务状态。"""
permission = self.policy.evaluate(observation, disposition, current_user)
return self._with_permission(disposition, permission)
@staticmethod
def _with_permission(
disposition: RiskDispositionRead,
permission: RiskDispositionActionPermission,
) -> RiskDispositionRead:
return disposition.model_copy(
update={
"available_actions": list(permission.available_actions),
"read_only_reason": permission.read_only_reason,
}
)
def _adjudication(
observation: RiskObservation,
disposition: RiskDisposition | RiskDispositionRead | None,
) -> str:
if disposition is not None:
return _text(disposition.adjudication).lower() or "unreviewed"
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 _lifecycle(
observation: RiskObservation,
disposition: RiskDisposition | RiskDispositionRead | None,
) -> str:
if disposition is not None:
return _text(disposition.lifecycle_status).lower() or "open"
return "resolved" if _text(observation.status).lower() == "resolved" else "open"
def _is_unexpired(
disposition: RiskDisposition | RiskDispositionRead | None,
*,
now: datetime | None,
) -> bool:
expires_at = _as_utc(disposition.waiver_expires_at if disposition is not None else None)
current_at = _as_utc(now) or datetime.now(UTC)
return expires_at is not None and expires_at > current_at
def _as_utc(value: datetime | None) -> datetime | None:
if value is None:
return None
if value.tzinfo is None:
return value.replace(tzinfo=UTC)
return value.astimezone(UTC)
def _text(value: object) -> str:
return str(value or "").strip()

View File

@@ -24,12 +24,19 @@ from app.schemas.risk_disposition import (
RiskDispositionMutationRead,
RiskDispositionRead,
)
from app.services.approval_task_projection_refresh import (
ApprovalTaskProjectionRefreshService,
)
from app.services.expense_claim_tenant_scope import ExpenseClaimTenantScopeMixin
from app.services.risk_observation_access_policy import RiskObservationAccessPolicy
from app.services.risk_waiver_decision_policy import (
RISK_WAIVER_DECISION_ACTIONS,
RiskWaiverDecisionPermissionError,
RiskWaiverDecisionPolicy,
)
logger = get_logger("app.services.risk_dispositions")
class RiskDispositionConflictError(RuntimeError):
pass
@@ -145,12 +152,17 @@ class RiskDispositionService:
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,
is_waiver_decision = payload.action in RISK_WAIVER_DECISION_ACTIONS
if (
not is_waiver_decision
and current_user is not None
and not RiskObservationAccessPolicy(
self.db
).can_manage_locked_disposition(
observation,
current_user,
locked_claim=locked_claim,
)
):
raise RiskDispositionPermissionError("当前用户已不再是该单据的有效审批人。")
@@ -180,9 +192,29 @@ class RiskDispositionService:
)
self.db.add(disposition)
_validate_transition(disposition, payload)
waiver_policy = RiskWaiverDecisionPolicy(self.db)
waiver_actor_id, waiver_actor_name = waiver_policy.actor_identity(
current_user,
fallback_id=normalized_actor_id,
fallback_name=_text(actor_name) or normalized_actor_id,
)
_validate_transition(disposition, payload, now=mutation_at)
if is_waiver_decision:
waiver_actor_id, waiver_actor_name = waiver_policy.require_decider(
disposition,
current_user=current_user,
tenant_id=normalized_tenant,
)
before = _state(disposition)
_apply_action(disposition, observation, payload)
_apply_action(
disposition,
observation,
payload,
actor_id=waiver_actor_id,
actor_name=waiver_actor_name,
mutation_at=mutation_at,
)
disposition.version = current_version + 1
disposition.updated_at = mutation_at
event = RiskDispositionEvent(
@@ -192,11 +224,11 @@ class RiskDispositionService:
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",
actor_id=waiver_actor_id,
actor_name=waiver_actor_name,
request_id=payload.request_id,
payload_fingerprint=fingerprint,
comment=payload.comment,
comment=payload.comment or payload.waiver_reason,
before_json=before,
after_json=_state(disposition),
created_at=mutation_at,
@@ -216,6 +248,14 @@ class RiskDispositionService:
payload,
actor_name=actor_name,
)
# 风险事实与审批任务队列投影必须原子可见autoflush 关闭时先显式
# 落下 observation/disposition再按最新风险状态重算开放任务。
self.db.flush()
ApprovalTaskProjectionRefreshService(self.db).refresh_claim(
tenant_id=normalized_tenant,
claim_id=str(observation.claim_id or "").strip(),
now=mutation_at,
)
self.db.commit()
self.db.refresh(disposition)
self.db.refresh(event)
@@ -226,7 +266,12 @@ class RiskDispositionService:
response=response,
legacy_feedback=legacy_feedback,
)
except (LookupError, RiskDispositionConflictError, RiskDispositionPermissionError):
except (
LookupError,
RiskDispositionConflictError,
RiskDispositionPermissionError,
RiskWaiverDecisionPermissionError,
):
self.db.rollback()
raise
except IntegrityError as error:
@@ -292,6 +337,18 @@ class RiskDispositionService:
assignee=disposition.assignee,
due_at=disposition.due_at,
resolution=disposition.resolution,
waiver_requester_id=disposition.waiver_requester_id,
waiver_requester_name=disposition.waiver_requester_name,
waiver_requested_at=disposition.waiver_requested_at,
waiver_reason=disposition.waiver_reason,
waiver_scope=disposition.waiver_scope,
waiver_expires_at=disposition.waiver_expires_at,
waiver_conditions_json=list(disposition.waiver_conditions_json or []),
waiver_decision=disposition.waiver_decision,
waiver_decider_id=disposition.waiver_decider_id,
waiver_decider_name=disposition.waiver_decider_name,
waiver_decided_at=disposition.waiver_decided_at,
waiver_decision_reason=disposition.waiver_decision_reason,
created_at=disposition.created_at,
updated_at=disposition.updated_at,
events=event_history,
@@ -382,6 +439,18 @@ class RiskDispositionService:
assignee=state.get("assignee"),
due_at=state.get("due_at"),
resolution=state.get("resolution"),
waiver_requester_id=state.get("waiver_requester_id"),
waiver_requester_name=state.get("waiver_requester_name"),
waiver_requested_at=state.get("waiver_requested_at"),
waiver_reason=state.get("waiver_reason"),
waiver_scope=state.get("waiver_scope"),
waiver_expires_at=state.get("waiver_expires_at"),
waiver_conditions_json=state.get("waiver_conditions_json") or [],
waiver_decision=state.get("waiver_decision"),
waiver_decider_id=state.get("waiver_decider_id"),
waiver_decider_name=state.get("waiver_decider_name"),
waiver_decided_at=state.get("waiver_decided_at"),
waiver_decision_reason=state.get("waiver_decision_reason"),
created_at=history[0].created_at,
updated_at=event.created_at,
events=event_reads,
@@ -468,6 +537,10 @@ def _apply_action(
disposition: RiskDisposition,
observation: RiskObservation,
payload: RiskDispositionActionCreate,
*,
actor_id: str,
actor_name: str,
mutation_at: datetime,
) -> None:
if payload.action == "confirm":
disposition.adjudication = "confirmed"
@@ -479,11 +552,48 @@ def _apply_action(
observation.status = "false_positive"
observation.feedback_status = "false_positive"
return
if payload.action == "request_waiver":
disposition.lifecycle_status = "waiver_requested"
disposition.waiver_requester_id = actor_id
disposition.waiver_requester_name = actor_name
disposition.waiver_requested_at = mutation_at
disposition.waiver_reason = payload.waiver_reason
disposition.waiver_scope = payload.waiver_scope
disposition.waiver_expires_at = payload.waiver_expires_at
disposition.waiver_conditions_json = list(payload.waiver_conditions)
disposition.waiver_decision = None
disposition.waiver_decider_id = None
disposition.waiver_decider_name = None
disposition.waiver_decided_at = None
disposition.waiver_decision_reason = None
disposition.resolution = None
if payload.assignee is not None:
disposition.assignee = payload.assignee
if payload.due_at is not None:
disposition.due_at = payload.due_at
return
if payload.action == "approve_waiver":
disposition.lifecycle_status = "waived"
disposition.waiver_decision = "approved"
disposition.waiver_decider_id = actor_id
disposition.waiver_decider_name = actor_name
disposition.waiver_decided_at = mutation_at
disposition.waiver_decision_reason = payload.comment
observation.status = "waived"
return
if payload.action == "reject_waiver":
disposition.lifecycle_status = "waiver_rejected"
disposition.waiver_decision = "rejected"
disposition.waiver_decider_id = actor_id
disposition.waiver_decider_name = actor_name
disposition.waiver_decided_at = mutation_at
disposition.waiver_decision_reason = payload.comment
observation.status = "confirmed"
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]
@@ -501,7 +611,21 @@ def _apply_action(
def _validate_transition(
disposition: RiskDisposition,
payload: RiskDispositionActionCreate,
*,
now: datetime,
) -> None:
if payload.action in RISK_WAIVER_DECISION_ACTIONS:
if disposition.lifecycle_status != "waiver_requested":
raise RiskDispositionConflictError("只有待决定的豁免申请才能批准或拒绝。")
if not _text(disposition.waiver_requester_id):
raise RiskDispositionConflictError(
"历史豁免申请缺少职责分离所需的申请人身份,请重新提交申请。"
)
if payload.action == "approve_waiver":
expires_at = _as_utc(disposition.waiver_expires_at)
if expires_at is None or expires_at <= _as_utc(now):
raise RiskDispositionConflictError("风险豁免申请已过期,不能批准。")
return
if disposition.lifecycle_status == "resolved":
raise RiskDispositionConflictError("已解决的风险不能再次变更裁决或处置生命周期。")
if payload.action == "confirm":
@@ -519,7 +643,17 @@ def _validate_transition(
"resolve": "resolved",
}[payload.action]
if disposition.lifecycle_status == target_lifecycle:
raise RiskDispositionConflictError("该风险已处于目标处置状态,请勿重复提交。")
if payload.action == "request_waiver" and not _text(
disposition.waiver_requester_id
):
# 0014 前的旧申请没有申请人和有效期,允许以完整协议重新提交。
pass
else:
raise RiskDispositionConflictError("该风险已处于目标处置状态,请勿重复提交。")
if payload.action == "request_waiver":
expires_at = _as_utc(payload.waiver_expires_at)
if expires_at is None or expires_at <= _as_utc(now):
raise RiskDispositionConflictError("风险豁免有效期必须晚于申请时间。")
if payload.action == "request_supplement":
return
if disposition.adjudication != "confirmed":
@@ -533,6 +667,30 @@ def _state(disposition: RiskDisposition) -> dict[str, object]:
"assignee": disposition.assignee,
"due_at": disposition.due_at.isoformat() if disposition.due_at else None,
"resolution": disposition.resolution,
"waiver_requester_id": disposition.waiver_requester_id,
"waiver_requester_name": disposition.waiver_requester_name,
"waiver_requested_at": (
disposition.waiver_requested_at.isoformat()
if disposition.waiver_requested_at
else None
),
"waiver_reason": disposition.waiver_reason,
"waiver_scope": disposition.waiver_scope,
"waiver_expires_at": (
disposition.waiver_expires_at.isoformat()
if disposition.waiver_expires_at
else None
),
"waiver_conditions_json": list(disposition.waiver_conditions_json or []),
"waiver_decision": disposition.waiver_decision,
"waiver_decider_id": disposition.waiver_decider_id,
"waiver_decider_name": disposition.waiver_decider_name,
"waiver_decided_at": (
disposition.waiver_decided_at.isoformat()
if disposition.waiver_decided_at
else None
),
"waiver_decision_reason": disposition.waiver_decision_reason,
"version": disposition.version,
}
@@ -581,3 +739,11 @@ def _tenant(value: str) -> str:
def _text(value: object) -> str:
return str(value or "").strip()
def _as_utc(value: datetime | None) -> datetime | None:
if value is None:
return None
if value.tzinfo is None:
return value.replace(tzinfo=UTC)
return value.astimezone(UTC)

View File

@@ -18,6 +18,9 @@ from app.schemas.risk_observation import (
RiskObservationDashboardRead,
RiskObservationFeedbackCreate,
)
from app.services.approval_task_projection_refresh import (
ApprovalTaskProjectionRefreshService,
)
from app.services.expense_claim_risk_stage import normalize_risk_business_stage
from app.services.expense_claim_tenant_scope import ExpenseClaimTenantScopeMixin
@@ -50,13 +53,9 @@ class RiskObservationService:
cache_key = str(getattr(bind, "url", "") or id(bind))
if cache_key in self._storage_ready_cache:
return
Base.metadata.create_all(
bind=bind,
tables=[
RiskObservation.__table__,
RiskObservationFeedback.__table__,
],
tables=[RiskObservation.__table__, RiskObservationFeedback.__table__],
)
self._storage_ready_cache.add(cache_key)
@@ -88,7 +87,6 @@ class RiskObservationService:
claim_id,
tenant_id=normalized_tenant_id,
)
item = self.db.scalar(
select(RiskObservation).where(
RiskObservation.tenant_id == normalized_tenant_id,
@@ -101,7 +99,6 @@ class RiskObservationService:
observation_key=observation_key,
)
self.db.add(item)
item.subject_type = _text(payload.get("subject_type"))
item.subject_key = _text(payload.get("subject_key"))
item.subject_label = _text(payload.get("subject_label"))
@@ -130,8 +127,12 @@ class RiskObservationService:
item.similar_case_claim_ids_json = _list(payload.get("similar_case_claim_ids"))
item.ontology_json = _risk_ontology_payload(payload)
item.decision_trace_json = _risk_decision_trace_payload(payload)
self.db.flush()
if item.claim_id:
ApprovalTaskProjectionRefreshService(self.db).refresh_claim(
tenant_id=normalized_tenant_id,
claim_id=item.claim_id,
)
return item
def lock_claim_for_risk_write(

View File

@@ -0,0 +1,154 @@
from __future__ import annotations
from dataclasses import dataclass
from sqlalchemy.orm import Session
from app.api.deps import CurrentUserContext
from app.models.employee import Employee
from app.models.risk_disposition import RiskDisposition
from app.services.expense_claim_access_policy import ExpenseClaimAccessPolicy
RISK_WAIVER_DECISION_ACTIONS = frozenset({"approve_waiver", "reject_waiver"})
_DECISION_ROLE_CODES = {"finance", "executive"}
_ACTIVE_EMPLOYMENT_STATUSES = {"active", "employed", "在职", "正常"}
class RiskWaiverDecisionPermissionError(RuntimeError):
pass
@dataclass(frozen=True, slots=True)
class RiskWaiverDecisionEligibility:
allowed: bool
actor_id: str = ""
actor_name: str = ""
reason: str = ""
class RiskWaiverDecisionPolicy:
"""集中校验风险豁免决定人的租户、在职、角色和职责分离边界。"""
def __init__(self, db: Session) -> None:
self.db = db
def actor_identity(
self,
current_user: CurrentUserContext | None,
*,
fallback_id: str,
fallback_name: str,
) -> tuple[str, str]:
employee = self._resolve_actor_employee(current_user)
if employee is None:
return (
_text(fallback_id) or "anonymous",
_text(fallback_name) or _text(fallback_id) or "anonymous",
)
return employee.id, _text(employee.name) or _text(fallback_name) or employee.id
def require_decider(
self,
disposition: RiskDisposition,
*,
current_user: CurrentUserContext | None,
tenant_id: str,
) -> tuple[str, str]:
eligibility = self.evaluate_decider(
disposition,
current_user=current_user,
tenant_id=tenant_id,
)
if not eligibility.allowed:
raise RiskWaiverDecisionPermissionError(eligibility.reason)
return eligibility.actor_id, eligibility.actor_name
def evaluate_decider(
self,
disposition: RiskDisposition,
*,
current_user: CurrentUserContext | None,
tenant_id: str,
) -> RiskWaiverDecisionEligibility:
if current_user is None:
return RiskWaiverDecisionEligibility(
False,
reason="风险豁免决定必须由已登录处理人执行。",
)
if _tenant(current_user.tenant_id) != tenant_id:
return RiskWaiverDecisionEligibility(
False,
reason="不能跨租户处理风险豁免决定。",
)
employee = self._resolve_actor_employee(current_user)
if employee is None:
return RiskWaiverDecisionEligibility(
False,
reason="风险豁免决定必须由企业在职员工执行。",
)
employment_status = _text(employee.employment_status).lower()
if employment_status not in _ACTIVE_EMPLOYMENT_STATUSES:
return RiskWaiverDecisionEligibility(
False,
reason="当前处理人不是在职员工,不能决定风险豁免。",
)
role_codes = ExpenseClaimAccessPolicy.normalize_role_codes(current_user)
role_codes.update(
_text(role.role_code).lower()
for role in list(employee.roles or [])
if _text(role.role_code)
)
if not role_codes & _DECISION_ROLE_CODES:
return RiskWaiverDecisionEligibility(
False,
reason="只有具有 finance 或 executive 明确角色的在职处理人可以决定风险豁免。",
)
requester_id = _text(disposition.waiver_requester_id)
if not requester_id:
return RiskWaiverDecisionEligibility(
False,
reason="历史豁免申请缺少申请人身份,不能执行豁免决定。",
)
actor_aliases = {
employee.id.casefold(),
_text(employee.email).casefold(),
_text(employee.employee_no).casefold(),
_text(current_user.employee_id).casefold(),
_text(current_user.username).casefold(),
}
actor_aliases.discard("")
if requester_id.casefold() in actor_aliases:
return RiskWaiverDecisionEligibility(
False,
reason="风险豁免申请人不能批准或拒绝自己的申请。",
)
return RiskWaiverDecisionEligibility(
True,
actor_id=employee.id,
actor_name=_text(employee.name) or employee.id,
)
def _resolve_actor_employee(
self,
current_user: CurrentUserContext | None,
) -> Employee | None:
if current_user is None:
return None
employee_id = _text(current_user.employee_id)
if employee_id:
employee = self.db.get(Employee, employee_id)
if employee is not None:
return employee
return ExpenseClaimAccessPolicy(self.db).resolve_current_employee(current_user)
def _tenant(value: str) -> str:
return _text(value) or "default"
def _text(value: object) -> str:
return str(value or "").strip()

View File

@@ -24,11 +24,13 @@ from app.db.migration_preflight import (
)
from app.db.schema_ownership import MIGRATION_OWNED_TABLES, create_legacy_schema
from app.models.ai_memory import MemoryEntry
from app.models.approval_task import ApprovalTask, ApprovalTaskEvent
from app.models.risk_disposition import RiskDisposition, RiskDispositionEvent
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_0012"
HEAD_REVISION = "20260716_0014"
SERVER_DIR = Path(__file__).resolve().parents[1]
ALEMBIC_INI_PATH = SERVER_DIR / "alembic.ini"
@@ -424,6 +426,11 @@ def _assert_head_schema(engine: Engine) -> None:
"due_at",
),
"ix_risk_dispositions_assignee": ("tenant_id", "assignee"),
"ix_risk_dispositions_tenant_waiver_expiry": (
"tenant_id",
"lifecycle_status",
"waiver_expires_at",
),
},
)
_assert_check_constraint(
@@ -441,6 +448,21 @@ def _assert_head_schema(engine: Engine) -> None:
"risk_dispositions",
"ck_risk_dispositions_version",
)
_assert_check_constraint(
engine,
"risk_dispositions",
"ck_risk_dispositions_waiver_request",
)
_assert_check_constraint(
engine,
"risk_dispositions",
"ck_risk_dispositions_waiver_decision",
)
_assert_check_constraint(
engine,
"risk_dispositions",
"ck_risk_dispositions_waiver_lifecycle",
)
_assert_unique_constraint(
engine,
"risk_disposition_events",
@@ -495,6 +517,24 @@ def _assert_head_schema(engine: Engine) -> None:
)
}
assert "response_json" in risk_disposition_event_columns
risk_disposition_columns = {
str(item["name"])
for item in inspect(engine).get_columns("risk_dispositions", schema="public")
}
assert {
"waiver_requester_id",
"waiver_requester_name",
"waiver_requested_at",
"waiver_reason",
"waiver_scope",
"waiver_expires_at",
"waiver_conditions_json",
"waiver_decision",
"waiver_decider_id",
"waiver_decider_name",
"waiver_decided_at",
"waiver_decision_reason",
}.issubset(risk_disposition_columns)
with engine.connect() as connection:
append_only_trigger_count = int(
connection.scalar(
@@ -509,6 +549,151 @@ def _assert_head_schema(engine: Engine) -> None:
or 0
)
assert append_only_trigger_count == 1
_assert_unique_constraint(
engine,
"approval_tasks",
"uq_approval_tasks_tenant_id",
("tenant_id", "id"),
)
_assert_unique_constraint(
engine,
"approval_tasks",
"uq_approval_tasks_tenant_node_entry",
("tenant_id", "node_entry_key"),
)
_assert_unique_constraint(
engine,
"approval_tasks",
"uq_approval_tasks_node_participant",
("tenant_id", "node_instance_id", "assignee_kind", "assignee_key"),
)
_assert_unique_constraint(
engine,
"approval_task_events",
"uq_approval_task_events_actor_request",
("tenant_id", "actor_id", "request_id"),
)
_assert_unique_constraint(
engine,
"approval_task_events",
"uq_approval_task_events_task_version",
("tenant_id", "task_id", "result_task_version"),
)
_assert_composite_foreign_key(
engine,
"approval_tasks",
("tenant_id", "parent_task_id"),
"approval_tasks",
)
_assert_composite_foreign_key(
engine,
"approval_tasks",
("tenant_id", "expense_case_id"),
"expense_cases",
)
_assert_composite_foreign_key(
engine,
"approval_task_events",
("tenant_id", "task_id"),
"approval_tasks",
)
_assert_no_foreign_key(
engine,
"approval_tasks",
("claim_id",),
"expense_claims",
)
_assert_indexes(
engine,
"approval_tasks",
{
"uq_approval_tasks_open_root_per_claim": ("tenant_id", "claim_id"),
"ix_approval_tasks_personal_inbox": (
"tenant_id",
"assignee_kind",
"assignee_key",
"status",
"due_at",
),
"ix_approval_tasks_tenant_queue": (
"tenant_id",
"status",
"priority_score",
"due_at",
),
"ix_approval_tasks_tenant_claim": (
"tenant_id",
"claim_id",
"node_sequence",
),
"ix_approval_tasks_tenant_node": (
"tenant_id",
"node_instance_id",
"sequence_order",
),
},
)
_assert_postgresql_index_predicate(
engine,
"approval_tasks",
"uq_approval_tasks_open_root_per_claim",
"task_kind",
"root",
"status",
"waiting",
"pending",
)
_assert_indexes(
engine,
"approval_task_events",
{
"ix_approval_task_events_tenant_task_time": (
"tenant_id",
"task_id",
"occurred_at",
),
"ix_approval_task_events_tenant_node_time": (
"tenant_id",
"node_instance_id",
"occurred_at",
),
},
)
for constraint_name in (
"ck_approval_tasks_task_kind",
"ck_approval_tasks_status",
"ck_approval_tasks_version",
"ck_approval_tasks_priority_score",
"ck_approval_tasks_evidence_completeness",
"ck_approval_task_events_type",
"ck_approval_task_events_actor_type",
"ck_approval_task_events_version",
):
table_name = (
"approval_task_events"
if constraint_name.startswith("ck_approval_task_events")
else "approval_tasks"
)
_assert_check_constraint(engine, table_name, constraint_name)
approval_event_columns = {
str(item["name"])
for item in inspect(engine).get_columns("approval_task_events", schema="public")
}
assert "response_json" in approval_event_columns
with engine.connect() as connection:
approval_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 = 'approval_task_events' "
"AND trigger.tgname = 'trg_approval_task_events_append_only' "
"AND NOT trigger.tgisinternal"
)
)
or 0
)
assert approval_append_only_trigger_count == 1
_assert_check_constraint(
engine,
"memory_entries",
@@ -1335,6 +1520,10 @@ def _assert_base_schema(engine: Engine) -> None:
("20260716_0011_risk_disposition.py", "downgrade"),
("20260716_0012_risk_disposition_response_snapshot.py", "upgrade"),
("20260716_0012_risk_disposition_response_snapshot.py", "downgrade"),
("20260716_0013_approval_tasks.py", "upgrade"),
("20260716_0013_approval_tasks.py", "downgrade"),
("20260716_0014_risk_waiver_decision.py", "upgrade"),
("20260716_0014_risk_waiver_decision.py", "downgrade"),
],
)
def test_postgresql_only_migrations_reject_other_dialects_before_mutation(
@@ -1363,6 +1552,30 @@ def test_risk_disposition_snapshot_migration_refuses_lossy_downgrade() -> None:
assert operation_guard.mutation_calls == []
def test_approval_task_migration_refuses_non_empty_audit_chain_downgrade() -> None:
migration = _load_migration_module("20260716_0013_approval_tasks.py")
operation_guard = _UnsupportedDialectOperationGuard(dialect_name="postgresql")
operation_guard.bind.scalar = lambda _statement: 1
migration.op = operation_guard
with pytest.raises(RuntimeError, match="audit chain is not empty"):
migration.downgrade()
assert operation_guard.mutation_calls == []
def test_risk_waiver_migration_refuses_lossy_audit_downgrade() -> None:
migration = _load_migration_module("20260716_0014_risk_waiver_decision.py")
operation_guard = _UnsupportedDialectOperationGuard(dialect_name="postgresql")
operation_guard.bind.scalar = lambda _statement: 1
migration.op = operation_guard
with pytest.raises(RuntimeError, match="immutable waiver audit data exists"):
migration.downgrade()
assert operation_guard.mutation_calls == []
def test_head_model_declares_soft_claim_reference_and_organization_only_active_index() -> None:
claim_column = RiskObservation.__table__.c.claim_id
assert not claim_column.foreign_keys
@@ -1379,6 +1592,83 @@ def test_head_model_declares_soft_claim_reference_and_organization_only_active_i
assert "scope_type IN ('department', 'enterprise')" in predicate
def test_approval_task_model_declares_tenant_safety_and_open_root_invariant() -> None:
claim_column = ApprovalTask.__table__.c.claim_id
assert not claim_column.foreign_keys
assert inspect(ApprovalTask).relationships["claim"].viewonly is True
constraint_names = {
constraint.name for constraint in ApprovalTask.__table__.constraints
}
assert {
"uq_approval_tasks_tenant_id",
"uq_approval_tasks_tenant_node_entry",
"uq_approval_tasks_node_participant",
"fk_approval_tasks_tenant_parent",
"ck_approval_tasks_status",
"ck_approval_tasks_version",
"ck_approval_tasks_priority_score",
"ck_approval_tasks_evidence_completeness",
}.issubset(constraint_names)
open_root_index = next(
index
for index in ApprovalTask.__table__.indexes
if index.name == "uq_approval_tasks_open_root_per_claim"
)
assert open_root_index.unique is True
predicate = str(open_root_index.dialect_options["postgresql"]["where"])
assert "task_kind = 'root'" in predicate
assert "status IN ('waiting', 'pending')" in predicate
event_constraint_names = {
constraint.name for constraint in ApprovalTaskEvent.__table__.constraints
}
assert {
"uq_approval_task_events_actor_request",
"uq_approval_task_events_task_version",
"fk_approval_task_events_tenant_task",
"ck_approval_task_events_version",
}.issubset(event_constraint_names)
assert ApprovalTaskEvent.__table__.c.response_json.nullable is False
def test_risk_waiver_model_declares_decision_metadata_constraints() -> None:
constraint_names = {
constraint.name for constraint in RiskDisposition.__table__.constraints
}
assert {
"ck_risk_dispositions_waiver_request",
"ck_risk_dispositions_waiver_decision",
"ck_risk_dispositions_waiver_lifecycle",
}.issubset(constraint_names)
lifecycle_constraint = next(
constraint
for constraint in RiskDisposition.__table__.constraints
if constraint.name == "ck_risk_dispositions_lifecycle"
)
assert "waived" in str(lifecycle_constraint.sqltext)
assert "waiver_rejected" in str(lifecycle_constraint.sqltext)
waiver_index = next(
index
for index in RiskDisposition.__table__.indexes
if index.name == "ix_risk_dispositions_tenant_waiver_expiry"
)
assert tuple(column.name for column in waiver_index.columns) == (
"tenant_id",
"lifecycle_status",
"waiver_expires_at",
)
event_action_constraint = next(
constraint
for constraint in RiskDispositionEvent.__table__.constraints
if constraint.name == "ck_risk_disposition_events_action"
)
assert "approve_waiver" in str(event_action_constraint.sqltext)
assert "reject_waiver" in str(event_action_constraint.sqltext)
@pytest.mark.parametrize(
"database_url",
[

View File

@@ -0,0 +1,436 @@
from __future__ import annotations
from datetime import UTC, date, datetime, timedelta
from decimal import Decimal
import pytest
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.api.deps import CurrentUserContext
from app.models.approval_task import ApprovalTask, ApprovalTaskEvent
from app.models.employee import Employee
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
from app.models.role import Role
from app.schemas.approval_task import (
ApprovalTaskAssignmentAction,
ApprovalTaskDecisionAction,
ApprovalTaskParticipantsAction,
)
from app.schemas.reimbursement import ExpenseClaimRead
from app.services.approval_task_actions import ApprovalTaskActionService
from app.services.approval_task_lifecycle import ApprovalTaskLifecycleService
from app.services.approval_task_protocol import ApprovalTaskVersionConflictError
from app.services.approval_task_query import ApprovalTaskQueryService
from app.services.approval_task_sla import ApprovalTaskSlaService
from app.services.expense_cases import ExpenseCaseService
from app.services.expense_claims import ExpenseClaimService
from app.test_helpers.db import build_in_memory_session_factory
def _user(
employee: Employee,
*,
tenant_id: str = "tenant-approval-task",
roles: list[str] | None = None,
is_admin: bool = False,
) -> CurrentUserContext:
return CurrentUserContext(
username=employee.email,
name=employee.name,
role_codes=list(roles or []),
is_admin=is_admin,
tenant_id=tenant_id,
employee_id=employee.id,
employee_no=employee.employee_no,
)
def _seed_root_task(
db: Session,
*,
tenant_id: str = "tenant-approval-task",
extra_managers: int = 1,
) -> tuple[ExpenseClaim, ApprovalTask, Employee, list[Employee]]:
manager_role = Role(
id="role-task-manager",
role_code="manager",
name="任务审批经理",
)
manager = Employee(
id="employee-task-manager",
employee_no="M-TASK-001",
name="李经理",
email="manager-task@example.com",
roles=[manager_role],
)
participants = [
Employee(
id=f"employee-task-participant-{index}",
employee_no=f"M-TASK-{index + 1:03d}",
name=f"加签经理{index}",
email=f"participant-task-{index}@example.com",
roles=[manager_role],
)
for index in range(1, extra_managers + 1)
]
employee = Employee(
id="employee-task-owner",
employee_no="E-TASK-001",
name="张三",
email="owner-task@example.com",
manager=manager,
)
occurred_at = datetime.now(UTC) - timedelta(hours=1)
claim = ExpenseClaim(
id="claim-task-001",
claim_no="RE-TASK-001",
employee=employee,
employee_name=employee.name,
department_name="市场部",
expense_type="transport",
reason="客户现场差旅",
location="上海",
amount=Decimal("88.00"),
currency="CNY",
invoice_count=1,
occurred_at=occurred_at,
submitted_at=occurred_at,
status="submitted",
approval_stage="直属领导审批",
risk_flags_json=[],
created_at=occurred_at,
updated_at=occurred_at,
)
claim.items = [
ExpenseClaimItem(
id="claim-task-item-001",
claim_id=claim.id,
item_date=date.today(),
item_type="transport",
item_reason="客户现场交通",
item_location="上海",
item_note="",
item_amount=Decimal("88.00"),
invoice_id="INV-TASK-001",
)
]
db.add_all([claim, *participants])
db.flush()
_, event = ExpenseCaseService(db).record_claim_event(
claim,
event_type="claim_submitted",
actor_id=employee.email,
tenant_id=tenant_id,
idempotency_key="claim-task-submitted-001",
previous_status="draft",
previous_approval_stage="待提交",
)
task = ApprovalTaskLifecycleService(db).ensure_root_task(
claim,
tenant_id=tenant_id,
entered_at=event.occurred_at,
entered_at_source="workflow_event",
business_event=event,
request_id=f"node-enter:{event.id}",
)
assert task is not None
db.commit()
return claim, task, manager, participants
def test_queue_is_tenant_safe_and_admin_has_no_implicit_approval() -> None:
factory = build_in_memory_session_factory()
with factory() as db:
_claim, task, manager, _participants = _seed_root_task(db)
manager_queue = ApprovalTaskQueryService(db).list_tasks(
_user(manager, roles=["manager"]),
)
assert manager_queue.total == 1
assert manager_queue.items[0].task.id == task.id
assert manager_queue.items[0].task.can_act is True
assert "approve" in manager_queue.items[0].task.available_actions
admin = Employee(
id="employee-task-admin",
employee_no="A-TASK-001",
name="平台管理员",
email="admin-task@example.com",
)
db.add(admin)
db.commit()
admin_queue = ApprovalTaskQueryService(db).list_tasks(
_user(admin, is_admin=True),
)
assert admin_queue.total == 1
assert admin_queue.items[0].task.can_act is False
assert "approve" not in admin_queue.items[0].task.available_actions
assert set(admin_queue.items[0].task.available_actions) >= {
"transfer",
"sla_escalate",
}
other_tenant_queue = ApprovalTaskQueryService(db).list_tasks(
_user(manager, tenant_id="tenant-other", roles=["manager"]),
)
assert other_tenant_queue.total == 0
def test_delegated_assignee_can_approve_claim_and_replay_is_immutable() -> None:
factory = build_in_memory_session_factory()
with factory() as db:
claim, task, manager, participants = _seed_root_task(db)
delegate = participants[0]
action_service = ApprovalTaskActionService(db)
payload = ApprovalTaskAssignmentAction(
request_id="delegate-task-001",
expected_task_version=1,
reason="出差期间委托处理",
target_employee_id=delegate.id,
expires_at=datetime.now(UTC) + timedelta(days=1),
)
first = action_service.assign(
task.id,
_user(manager, roles=["manager"]),
action="delegate",
payload=payload,
)
replay = action_service.assign(
task.id,
_user(manager, roles=["manager"]),
action="delegate",
payload=payload,
)
assert first.task.version == 2
assert first.task.assignee_employee_id == delegate.id
assert replay.replayed is True
assert replay.task.model_dump(mode="json") == first.task.model_dump(mode="json")
approved = ExpenseClaimService(db).approve_claim(
claim.id,
_user(delegate, roles=["manager"]),
opinion="已核对材料,同意",
request_id="approve-delegated-task-001",
expected_status="submitted",
expected_approval_stage="直属领导审批",
task_id=task.id,
expected_task_version=2,
)
action_replay = ExpenseClaimService(db).approve_claim(
claim.id,
_user(delegate, roles=["manager"]),
opinion="已核对材料,同意",
request_id="approve-delegated-task-001",
expected_status="submitted",
expected_approval_stage="直属领导审批",
task_id=task.id,
expected_task_version=2,
)
assert approved is not None and action_replay is not None
assert ExpenseClaimRead.model_validate(approved).model_dump(mode="json") == (
ExpenseClaimRead.model_validate(action_replay).model_dump(mode="json")
)
persisted = db.get(ApprovalTask, task.id)
assert persisted is not None
assert persisted.status == "completed"
assert persisted.version == 3
next_root = db.scalar(
select(ApprovalTask).where(
ApprovalTask.claim_id == claim.id,
ApprovalTask.task_kind == "root",
ApprovalTask.status == "pending",
)
)
assert next_root is not None
assert next_root.node_key == "finance"
assert (
db.scalar(
select(func.count())
.select_from(ApprovalTaskEvent)
.where(
ApprovalTaskEvent.task_id == task.id,
ApprovalTaskEvent.event_type == "task_approved",
)
)
== 1
)
def test_sequential_add_sign_reactivates_root_after_child_approval() -> None:
factory = build_in_memory_session_factory()
with factory() as db:
claim, root, manager, participants = _seed_root_task(db)
participant = participants[0]
service = ApprovalTaskActionService(db)
started = service.add_participants(
root.id,
_user(manager, roles=["manager"]),
action="add_sign",
payload=ApprovalTaskParticipantsAction(
request_id="add-sign-task-001",
expected_task_version=1,
reason="需要业务负责人补充复核",
participant_employee_ids=[participant.id],
),
)
assert started.task.status == "waiting"
assert len(started.related_tasks) == 1
child = started.related_tasks[0]
assert child.status == "pending"
completed = service.approve_child(
child.id,
_user(participant, roles=["manager"]),
ApprovalTaskDecisionAction(
request_id="approve-add-sign-task-001",
expected_task_version=1,
reason="补充复核完成",
opinion="材料真实完整",
),
)
assert completed.task.status == "completed"
refreshed_root = db.get(ApprovalTask, root.id)
assert refreshed_root is not None
assert refreshed_root.status == "pending"
assert refreshed_root.coordination_mode == "single"
assert refreshed_root.version == 3
assert refreshed_root.claim_stage_snapshot == claim.approval_stage
def test_parallel_countersign_waits_for_every_participant() -> None:
factory = build_in_memory_session_factory()
with factory() as db:
_claim, root, manager, participants = _seed_root_task(db, extra_managers=2)
service = ApprovalTaskActionService(db)
started = service.add_participants(
root.id,
_user(manager, roles=["manager"]),
action="countersign",
payload=ApprovalTaskParticipantsAction(
request_id="countersign-task-001",
expected_task_version=1,
reason="需要双人并行复核",
participant_employee_ids=[item.id for item in participants],
),
)
children = started.related_tasks
assert len(children) == 2
assert {item.status for item in children} == {"pending"}
service.approve_child(
children[0].id,
_user(participants[0], roles=["manager"]),
ApprovalTaskDecisionAction(
request_id="approve-countersign-task-001",
expected_task_version=1,
reason="第一位复核完成",
),
)
db.refresh(root)
assert root.status == "waiting"
service.approve_child(
children[1].id,
_user(participants[1], roles=["manager"]),
ApprovalTaskDecisionAction(
request_id="approve-countersign-task-002",
expected_task_version=1,
reason="第二位复核完成",
),
)
db.refresh(root)
assert root.status == "pending"
assert root.version == 3
def test_participant_return_cancels_open_node_family_and_returns_claim() -> None:
factory = build_in_memory_session_factory()
with factory() as db:
claim, root, manager, participants = _seed_root_task(db, extra_managers=2)
started = ApprovalTaskActionService(db).add_participants(
root.id,
_user(manager, roles=["manager"]),
action="countersign",
payload=ApprovalTaskParticipantsAction(
request_id="countersign-return-task-001",
expected_task_version=1,
reason="并行复核后再决定",
participant_employee_ids=[item.id for item in participants],
),
)
returned_child, sibling = started.related_tasks
returned = ExpenseClaimService(db).return_claim(
claim.id,
_user(participants[0], roles=["manager"]),
reason="材料与实际行程不一致",
request_id="return-countersign-task-001",
expected_status="submitted",
expected_approval_stage="直属领导审批",
task_id=returned_child.id,
expected_task_version=1,
)
assert returned is not None
assert returned.status == "returned"
assert db.get(ApprovalTask, returned_child.id).status == "returned"
assert db.get(ApprovalTask, sibling.id).status == "cancelled"
assert db.get(ApprovalTask, root.id).status == "cancelled"
def test_task_action_rejects_stale_version() -> None:
factory = build_in_memory_session_factory()
with factory() as db:
_claim, task, manager, participants = _seed_root_task(db)
with pytest.raises(ApprovalTaskVersionConflictError) as captured:
ApprovalTaskActionService(db).assign(
task.id,
_user(manager, roles=["manager"]),
action="transfer",
payload=ApprovalTaskAssignmentAction(
request_id="transfer-task-stale-001",
expected_task_version=99,
reason="测试过期版本保护",
target_employee_id=participants[0].id,
),
)
assert captured.value.current_version == 1
def test_sla_scheduler_escalates_due_task_once_per_window() -> None:
factory = build_in_memory_session_factory()
with factory() as db:
_claim, task, _manager, _participants = _seed_root_task(db)
now = datetime.now(UTC)
task.next_escalation_at = now - timedelta(minutes=1)
db.commit()
service = ApprovalTaskSlaService(db)
first = service.escalate_due_tasks(now=now)
repeated = service.escalate_due_tasks(now=now)
db.commit()
assert first == [task.id]
assert repeated == []
db.refresh(task)
assert task.escalation_level == 1
assert task.version == 2
persisted_next = task.next_escalation_at
assert persisted_next is not None
if persisted_next.tzinfo is None:
persisted_next = persisted_next.replace(tzinfo=UTC)
assert persisted_next == now + timedelta(hours=4)
event = db.scalar(
select(ApprovalTaskEvent).where(
ApprovalTaskEvent.task_id == task.id,
ApprovalTaskEvent.event_type == "sla_escalated",
)
)
assert event is not None
assert event.response_json["task"]["escalation_level"] == 1

View File

@@ -0,0 +1,409 @@
from __future__ import annotations
import argparse
import importlib.util
from datetime import UTC, date, datetime, timedelta
from decimal import Decimal
from pathlib import Path
import pytest
from sqlalchemy import create_engine, func, select
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool
from app.db.base import Base
from app.models.approval_task import ApprovalTask, ApprovalTaskEvent
from app.models.employee import Employee
from app.models.expense_case import BusinessEvent, ExpenseCase, ExpenseCaseLink
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
from app.schemas.approval_task import ApprovalTaskMutationRead
from app.services.approval_task_backfill import (
ApprovalTaskBackfillDisposition,
ApprovalTaskBackfillPreview,
ApprovalTaskBackfillService,
)
from app.services.approval_task_protocol import ApprovalTaskEventProtocol
from app.services.expense_claim_workflow_constants import (
DIRECT_MANAGER_APPROVAL_STAGE,
FINANCE_APPROVAL_STAGE,
)
def _load_cli_module():
path = Path(__file__).resolve().parents[1] / "scripts" / "backfill_approval_tasks.py"
spec = importlib.util.spec_from_file_location("backfill_approval_tasks_cli", path)
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
CLI = _load_cli_module()
def _session() -> Session:
engine = create_engine(
"sqlite+pysqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(bind=engine)
return sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)()
def _persist_claim(
db: Session,
*,
suffix: str,
tenant_id: str,
stage: str = DIRECT_MANAGER_APPROVAL_STAGE,
status: str = "submitted",
with_manager: bool = True,
with_event: bool = True,
) -> tuple[ExpenseClaim, Employee | None, BusinessEvent | None]:
submitted_at = datetime(2026, 7, 14, 10, 0, tzinfo=UTC)
manager = (
Employee(
id=f"manager-{suffix}",
employee_no=f"M-{suffix}",
name=f"经理{suffix}",
email=f"manager-{suffix}@example.com",
)
if with_manager
else None
)
employee = Employee(
id=f"employee-{suffix}",
employee_no=f"E-{suffix}",
name=f"员工{suffix}",
email=f"employee-{suffix}@example.com",
manager=manager,
)
claim = ExpenseClaim(
id=f"claim-{suffix}",
claim_no=f"RE-BACKFILL-{suffix}",
employee=employee,
employee_name=employee.name,
department_name="市场部",
project_code="PRJ-BACKFILL",
expense_type="travel",
reason="客户现场差旅",
location="上海",
amount=Decimal("888.00"),
currency="CNY",
invoice_count=1,
occurred_at=submitted_at - timedelta(days=1),
submitted_at=submitted_at,
status=status,
approval_stage=stage,
risk_flags_json=[],
created_at=submitted_at - timedelta(hours=2),
updated_at=submitted_at,
)
claim.items = [
ExpenseClaimItem(
id=f"item-{suffix}",
claim_id=claim.id,
item_date=date(2026, 7, 14),
item_type="transport",
item_reason="客户现场交通",
item_location="上海",
item_note="",
item_amount=Decimal("888.00"),
invoice_id=f"invoice-{suffix}",
created_at=submitted_at,
updated_at=submitted_at,
)
]
expense_case = ExpenseCase(
id=f"case-{suffix}",
tenant_id=tenant_id,
case_no=f"CASE-RE-BACKFILL-{suffix}",
scene_code="travel",
title="客户现场差旅",
owner_employee_id=employee.id,
current_stage="reviewing",
status="active",
created_at=submitted_at - timedelta(hours=2),
updated_at=submitted_at,
)
link = ExpenseCaseLink(
id=f"link-{suffix}",
tenant_id=tenant_id,
expense_case_id=expense_case.id,
resource_type="expense_claim",
resource_id=claim.id,
relation_type="claim",
created_at=submitted_at,
)
event = None
if with_event:
event = BusinessEvent(
id=f"event-{suffix}",
tenant_id=tenant_id,
expense_case_id=expense_case.id,
aggregate_type="expense_claim",
aggregate_id=claim.id,
event_type="claim_submitted",
event_version=1,
idempotency_key=f"submit-{suffix}",
correlation_id=f"correlation-{suffix}",
actor_id=employee.id,
actor_type="user",
payload_json={
"next_status": "submitted",
"next_approval_stage": stage,
},
delivery_status="published",
occurred_at=submitted_at + timedelta(hours=1),
)
db.add_all(
[
*([manager] if manager is not None else []),
employee,
claim,
expense_case,
link,
*([event] if event is not None else []),
]
)
db.commit()
return claim, manager, event
def test_preview_is_default_read_only_tenant_scoped_and_prefers_business_event() -> None:
with _session() as db:
claim, manager, event = _persist_claim(db, suffix="A", tenant_id="tenant-a")
_persist_claim(db, suffix="B", tenant_id="tenant-b")
assert manager is not None and event is not None
service = ApprovalTaskBackfillService(db, tenant_id="tenant-a")
result = service.run()
assert isinstance(result, ApprovalTaskBackfillPreview)
assert result.inspected == 1
assert result.eligible == 1
assert result.existing == 0
assert result.skipped == 0
assert result.items[0].claim_id == claim.id
assert result.items[0].entered_at_source == "workflow_event"
assert result.items[0].entered_at == event.occurred_at
assert result.items[0].assignee_key == manager.id
assert db.scalar(select(func.count()).select_from(ApprovalTask)) == 0
assert db.scalar(select(func.count()).select_from(ApprovalTaskEvent)) == 0
def test_apply_creates_audited_projected_root_task_and_is_idempotent() -> None:
applied_at = datetime(2026, 7, 16, 12, 0, tzinfo=UTC)
with _session() as db:
claim, manager, source_event = _persist_claim(
db,
suffix="APPLY",
tenant_id="tenant-a",
)
assert manager is not None and source_event is not None
service = ApprovalTaskBackfillService(db, tenant_id="tenant-a")
first = service.apply_batch(
run_id="approval-task-backfill-run-1",
backfilled_at=applied_at,
)
repeated = service.apply_batch(
run_id="approval-task-backfill-run-1",
backfilled_at=applied_at,
)
task = db.scalar(select(ApprovalTask).where(ApprovalTask.claim_id == claim.id))
event = db.scalar(select(ApprovalTaskEvent).where(ApprovalTaskEvent.task_id == task.id))
assert first.created == 1
assert repeated.created == 0
assert repeated.existing == 1
assert task.task_kind == "root"
assert task.status == "pending"
assert task.tenant_id == "tenant-a"
assert task.expense_case_id == "case-APPLY"
assert task.node_key == "direct_manager"
assert task.assignee_employee_id == manager.id
assert task.entered_at_source == "workflow_event"
assert task.due_at > task.entered_at
assert task.projection_updated_at.replace(tzinfo=UTC) == applied_at
assert task.risk_level == "low"
assert task.evidence_completeness == Decimal("1.0000")
assert task.batch_eligible is False
assert "sla_overdue" in task.batch_block_reasons_json
assert event.event_type == "node_entered_backfilled"
assert event.expected_task_version == 0
assert event.result_task_version == 1
assert event.business_event_id == source_event.id
assert event.payload_json["history_reconstructed"] is False
assert event.payload_json["current_node_reconstructed"] is True
assert event.response_json["task"]["id"] == task.id
snapshot = ApprovalTaskMutationRead.model_validate(event.response_json)
replay = ApprovalTaskEventProtocol(db).find_replay(
tenant_id="tenant-a",
actor_id="approval-task-backfill",
request_id=event.request_id,
fingerprint=event.payload_fingerprint,
)
assert snapshot.event.id == event.id
assert replay is not None and replay.replayed is True
assert replay.task.id == task.id
assert db.scalar(select(func.count()).select_from(ApprovalTask)) == 1
assert db.scalar(select(func.count()).select_from(ApprovalTaskEvent)) == 1
def test_unresolved_direct_manager_is_fail_closed_but_finance_role_is_safe_fallback() -> None:
with _session() as db:
unresolved, _manager, _event = _persist_claim(
db,
suffix="NO-MANAGER",
tenant_id="tenant-a",
with_manager=False,
)
inactive, inactive_manager, _event = _persist_claim(
db,
suffix="INACTIVE-MANAGER",
tenant_id="tenant-a",
)
assert inactive_manager is not None
inactive_manager.employment_status = "离职"
db.commit()
finance, _manager, _event = _persist_claim(
db,
suffix="FINANCE",
tenant_id="tenant-a",
stage=FINANCE_APPROVAL_STAGE,
with_manager=False,
)
service = ApprovalTaskBackfillService(db, tenant_id="tenant-a")
preview = service.preview()
by_claim = {item.claim_id: item for item in preview.items}
assert by_claim[unresolved.id].disposition is (
ApprovalTaskBackfillDisposition.ASSIGNEE_UNRESOLVED
)
assert by_claim[inactive.id].disposition is (
ApprovalTaskBackfillDisposition.ASSIGNEE_UNRESOLVED
)
assert by_claim[finance.id].disposition is ApprovalTaskBackfillDisposition.ELIGIBLE
assert by_claim[finance.id].assignee_key == "finance"
assert preview.eligible == 1
assert preview.skipped == 2
def test_legacy_in_approval_state_is_canonicalized_without_mutating_claim() -> None:
with _session() as db:
claim, manager, _event = _persist_claim(
db,
suffix="LEGACY",
tenant_id="tenant-a",
status="in_approval",
stage="",
with_event=False,
)
assert manager is not None
service = ApprovalTaskBackfillService(db, tenant_id="tenant-a")
preview = service.preview()
applied = service.apply_batch(
run_id="approval-task-backfill-legacy",
backfilled_at=datetime(2026, 7, 16, 12, 0, tzinfo=UTC),
)
task = db.scalar(select(ApprovalTask).where(ApprovalTask.claim_id == claim.id))
assert preview.eligible == 1
assert preview.items[0].entered_at_source == "submitted_at"
assert applied.created == 1
assert task.claim_status_snapshot == "submitted"
assert task.claim_stage_snapshot == DIRECT_MANAGER_APPROVAL_STAGE
assert task.assignee_employee_id == manager.id
assert claim.status == "in_approval"
assert claim.approval_stage == ""
def test_entered_at_fallback_order_is_explicit_and_deterministic() -> None:
submitted_at = datetime(2026, 7, 14, 10, 0, tzinfo=UTC)
claim = ExpenseClaim(
id="claim-time-fallback",
claim_no="RE-TIME-FALLBACK",
employee_name="张三",
department_name="市场部",
expense_type="travel",
reason="差旅",
location="上海",
amount=Decimal("100"),
occurred_at=submitted_at - timedelta(days=1),
submitted_at=submitted_at,
status="submitted",
approval_stage=DIRECT_MANAGER_APPROVAL_STAGE,
created_at=submitted_at - timedelta(hours=2),
updated_at=submitted_at - timedelta(hours=1),
)
with _session() as db:
service = ApprovalTaskBackfillService(db, tenant_id="tenant-a")
entered_at, source, _event = service.derive_entered_at(
claim,
events=[],
approval_stage=DIRECT_MANAGER_APPROVAL_STAGE,
)
assert (entered_at, source) == (submitted_at, "submitted_at")
claim.submitted_at = None
entered_at, source, _event = service.derive_entered_at(
claim,
events=[],
approval_stage=DIRECT_MANAGER_APPROVAL_STAGE,
)
assert (entered_at, source) == (claim.updated_at, "updated_at")
claim.updated_at = None
entered_at, source, _event = service.derive_entered_at(
claim,
events=[],
approval_stage=DIRECT_MANAGER_APPROVAL_STAGE,
)
assert (entered_at, source) == (claim.created_at, "backfill")
def test_backfill_cli_defaults_to_dry_run_and_requires_exact_apply_confirmation(
monkeypatch,
) -> None:
args = CLI.build_parser().parse_args(
[
"--tenant-id",
"tenant-a",
"--created-before",
"2026-07-16T00:00:00+08:00",
"--expected-host",
"migration-probe",
"--expected-database",
"migration_probe",
]
)
assert args.apply is False
assert args.created_before == datetime(2026, 7, 15, 16, 0, tzinfo=UTC)
apply_args = CLI.build_parser().parse_args(
[
"--apply",
"--tenant-id",
"tenant-a",
"--created-before",
"2026-07-16T00:00:00Z",
"--expected-host",
"migration-probe",
"--expected-database",
"migration_probe",
]
)
monkeypatch.delenv("DATABASE_URL", raising=False)
with pytest.raises(CLI.BackfillCommandError) as exc_info:
CLI.run(apply_args)
assert exc_info.value.code == "confirm_target_required"
@pytest.mark.parametrize("value", ["", "2026-07-16T00:00:00", "not-a-time"])
def test_backfill_cli_rejects_timestamp_without_explicit_timezone(value: str) -> None:
with pytest.raises(argparse.ArgumentTypeError):
CLI.parse_timestamp(value)

View File

@@ -0,0 +1,237 @@
from __future__ import annotations
import os
import uuid
from concurrent.futures import ThreadPoolExecutor
from datetime import UTC, datetime, timedelta
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_task import ApprovalTask, ApprovalTaskEvent
from app.models.employee import Employee
from app.models.financial_record import ExpenseClaim
from app.models.role import Role
from app.schemas.approval_task import ApprovalTaskAssignmentAction
from app.services.approval_task_actions import ApprovalTaskActionService
from app.services.approval_task_lifecycle import ApprovalTaskLifecycleService
from app.services.approval_task_protocol import ApprovalTaskVersionConflictError
from app.services.expense_cases import ExpenseCaseService
DATABASE_URL = os.environ.get("MIGRATION_TEST_DATABASE_URL", "").strip()
TENANT_ID = "tenant-approval-task-concurrency"
def test_concurrent_identical_delegation_replays_one_immutable_event() -> None:
database_url = _require_disposable_database_url()
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]
try:
task_id, manager, delegate_ids = _seed_case(factory, suffix=suffix, delegates=1)
payload = ApprovalTaskAssignmentAction(
request_id=f"delegate-concurrent-{suffix}",
expected_task_version=1,
reason="并发幂等委托验证",
target_employee_id=delegate_ids[0],
expires_at=datetime.now(UTC) + timedelta(days=1),
)
def delegate_once():
with factory() as db:
return ApprovalTaskActionService(db).assign(
task_id,
manager,
action="delegate",
payload=payload,
)
with ThreadPoolExecutor(max_workers=2) as pool:
futures = (pool.submit(delegate_once), pool.submit(delegate_once))
responses = [future.result(timeout=10) for future in futures]
assert sorted(response.replayed for response in responses) == [False, True]
assert responses[0].task.model_dump(mode="json") == responses[1].task.model_dump(
mode="json"
)
with factory() as db:
task = db.get(ApprovalTask, task_id)
assert task is not None and task.version == 2
assert task.assignee_employee_id == delegate_ids[0]
assert (
db.scalar(
select(func.count())
.select_from(ApprovalTaskEvent)
.where(
ApprovalTaskEvent.task_id == task_id,
ApprovalTaskEvent.request_id == payload.request_id,
)
)
== 1
)
finally:
engine.dispose()
def test_concurrent_distinct_delegations_allow_only_expected_version_winner() -> None:
database_url = _require_disposable_database_url()
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]
try:
task_id, manager, delegate_ids = _seed_case(factory, suffix=suffix, delegates=2)
def delegate_once(index: int):
with factory() as db:
try:
return ApprovalTaskActionService(db).assign(
task_id,
manager,
action="delegate",
payload=ApprovalTaskAssignmentAction(
request_id=f"delegate-race-{suffix}-{index}",
expected_task_version=1,
reason="并发版本竞争验证",
target_employee_id=delegate_ids[index],
expires_at=datetime.now(UTC) + timedelta(days=1),
),
)
except ApprovalTaskVersionConflictError as error:
return error
with ThreadPoolExecutor(max_workers=2) as pool:
futures = (pool.submit(delegate_once, 0), pool.submit(delegate_once, 1))
responses = [future.result(timeout=10) for future in futures]
assert sum(not isinstance(item, Exception) for item in responses) == 1
assert sum(isinstance(item, ApprovalTaskVersionConflictError) for item in responses) == 1
with factory() as db:
task = db.get(ApprovalTask, task_id)
assert task is not None and task.version == 2
assert task.assignee_employee_id in set(delegate_ids)
assert (
db.scalar(
select(func.count())
.select_from(ApprovalTaskEvent)
.where(
ApprovalTaskEvent.task_id == task_id,
ApprovalTaskEvent.event_type == "task_delegated",
)
)
== 1
)
finally:
engine.dispose()
def _seed_case(
factory: sessionmaker[Session],
*,
suffix: str,
delegates: int,
) -> tuple[str, CurrentUserContext, list[str]]:
with factory() as db:
role = db.scalar(select(Role).where(Role.role_code == "manager"))
if role is None:
role = Role(
id="role-appr-task-concur-manager",
role_code="manager",
name="并发审批经理",
)
manager = Employee(
id=f"manager-{suffix}",
employee_no=f"M-{suffix}",
name="并发审批经理",
email=f"manager-{suffix}@example.com",
roles=[role],
)
delegate_rows = [
Employee(
id=f"delegate-{suffix}-{index}",
employee_no=f"D-{suffix}-{index}",
name=f"委托审批人{index + 1}",
email=f"delegate-{suffix}-{index}@example.com",
roles=[role],
)
for index in range(delegates)
]
claimant = Employee(
id=f"claimant-{suffix}",
employee_no=f"E-{suffix}",
name="并发报销申请人",
email=f"claimant-{suffix}@example.com",
manager=manager,
)
occurred_at = datetime.now(UTC) - timedelta(hours=1)
claim = ExpenseClaim(
id=f"claim-{suffix}",
claim_no=f"RE-CONCURRENT-{suffix}",
employee=claimant,
employee_name=claimant.name,
department_name="并发验证部",
expense_type="transport",
reason="审批任务并发验证",
location="上海",
amount=Decimal("88.00"),
currency="CNY",
invoice_count=1,
occurred_at=occurred_at,
submitted_at=occurred_at,
status="submitted",
approval_stage="直属领导审批",
risk_flags_json=[],
)
db.add_all([claim, *delegate_rows])
db.flush()
_, business_event = ExpenseCaseService(db).record_claim_event(
claim,
event_type="claim_submitted",
actor_id=claimant.email,
tenant_id=TENANT_ID,
idempotency_key=f"submit-{suffix}",
previous_status="draft",
previous_approval_stage="待提交",
)
task = ApprovalTaskLifecycleService(db).ensure_root_task(
claim,
tenant_id=TENANT_ID,
entered_at=business_event.occurred_at,
entered_at_source="workflow_event",
business_event=business_event,
request_id=f"node-enter-{suffix}",
)
assert task is not None
db.commit()
return (
task.id,
CurrentUserContext(
username=manager.email,
name=manager.name,
role_codes=["manager"],
is_admin=False,
tenant_id=TENANT_ID,
employee_id=manager.id,
employee_no=manager.employee_no,
),
[row.id for row in delegate_rows],
)
def _require_disposable_database_url() -> str:
if not DATABASE_URL:
pytest.skip("仅在显式配置 MIGRATION_TEST_DATABASE_URL 时运行 PostgreSQL 并发测试")
parsed = make_url(DATABASE_URL)
host = str(parsed.host or "").replace("_", "-").lower()
database = str(parsed.database or "").replace("_", "-").lower()
if not host.startswith(("migration-probe", "disposable-probe")):
raise RuntimeError("并发测试数据库主机必须使用 disposable 前缀")
if not database.startswith(("migration-probe", "disposable-probe")):
raise RuntimeError("并发测试数据库名必须使用 disposable 前缀")
return DATABASE_URL

View File

@@ -0,0 +1,485 @@
from __future__ import annotations
from datetime import UTC, date, datetime, timedelta
from decimal import Decimal
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from app.db.base import Base
from app.models.approval_task import ApprovalTask
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_task_projection import ApprovalTaskProjectionService
from app.services.approval_task_projection_refresh import (
ApprovalTaskProjectionRefreshService,
)
from app.services.risk_observations import RiskObservationService
def _claim(
*,
claim_id: str = "claim-projection-1",
claim_no: str = "RE-PROJECTION-1",
amount: str = "888.00",
invoice_count: int = 1,
risk_flags: list[dict] | None = None,
) -> ExpenseClaim:
occurred_at = datetime(2026, 7, 15, 9, 0, tzinfo=UTC)
claim = ExpenseClaim(
id=claim_id,
claim_no=claim_no,
employee_name="张三",
department_name="市场部",
project_code="PRJ-PROJECTION",
expense_type="application" if claim_no.startswith("AP-") else "travel",
reason="客户现场差旅",
location="上海",
amount=Decimal(amount),
currency="CNY",
invoice_count=invoice_count,
occurred_at=occurred_at,
submitted_at=occurred_at + timedelta(hours=1),
status="submitted",
approval_stage="直属领导审批",
risk_flags_json=list(risk_flags or []),
created_at=occurred_at,
updated_at=occurred_at + timedelta(hours=1),
)
claim.items = [
ExpenseClaimItem(
id=f"item-{claim_id}",
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-PROJECTION" if invoice_count else None,
created_at=occurred_at,
updated_at=occurred_at,
)
]
return claim
def test_projection_materializes_explainable_priority_and_safe_batch_blocks() -> None:
now = datetime(2026, 7, 16, 12, 0, tzinfo=UTC)
claim = _claim(
amount="60000.00",
risk_flags=[
{
"source": "ai_pre_review",
"severity": "high",
"disposition": "review",
"route_decision": {"budget_result": {"metrics": {"after_usage_rate": "0.96"}}},
}
],
)
projection = ApprovalTaskProjectionService(None).build( # type: ignore[arg-type]
claim,
tenant_id="tenant-a",
entered_at=now - timedelta(hours=26),
now=now,
observation_rows=[],
)
reason_codes = {item["code"] for item in projection.priority_reasons_json}
assert projection.priority_score >= 85
assert projection.priority_tier == "urgent"
assert projection.risk_level == "high"
assert projection.open_risk_count == 1
assert projection.due_at == now - timedelta(hours=2)
assert projection.next_escalation_at == projection.due_at
assert reason_codes >= {
"open_risk",
"sla_overdue",
"budget_pressure",
"large_amount",
}
assert projection.batch_eligible is False
assert set(projection.batch_block_reasons_json) >= {
"open_risk",
"amount_requires_individual_review",
"budget_pressure",
"sla_overdue",
}
def test_resolved_materialized_risk_suppresses_stale_raw_flag_and_application_invoice() -> None:
now = datetime(2026, 7, 16, 12, 0, tzinfo=UTC)
claim = _claim(
claim_id="claim-projection-resolved",
claim_no="AP-PROJECTION-1",
invoice_count=0,
risk_flags=[
{
"severity": "critical",
"triggered": True,
"observation_key": "risk:projection:resolved",
}
],
)
observation = RiskObservation(
id="observation-projection-resolved",
tenant_id="tenant-a",
observation_key="risk:projection: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=98,
risk_level="critical",
confidence_score=0.99,
control_stage="expense_application",
control_mode="risk_observation",
automation_mode="manual",
source="financial_risk_graph",
algorithm_version="v1",
status="resolved",
feedback_status="confirmed",
)
disposition = RiskDisposition(
id="disposition-projection-resolved",
tenant_id="tenant-a",
observation_id=observation.id,
adjudication="confirmed",
lifecycle_status="resolved",
version=2,
)
projection = ApprovalTaskProjectionService(None).build( # type: ignore[arg-type]
claim,
tenant_id="tenant-a",
entered_at=now - timedelta(hours=1),
now=now,
observation_rows=[(observation, disposition)],
)
assert projection.risk_level == "low"
assert projection.open_risk_count == 0
assert projection.evidence_completeness == Decimal("1.0000")
assert projection.priority_score == 0
assert projection.priority_reasons_json[0]["code"] == "routine"
assert projection.batch_eligible is True
assert projection.batch_block_reasons_json == ()
def test_projection_marks_missing_evidence_and_apply_updates_task_without_persistence() -> None:
now = datetime(2026, 7, 16, 12, 0, tzinfo=UTC)
claim = _claim(invoice_count=0)
claim.location = ""
claim.items = []
projection = ApprovalTaskProjectionService(None).build( # type: ignore[arg-type]
claim,
tenant_id="tenant-a",
entered_at=now - timedelta(hours=1),
now=now,
observation_rows=[],
)
task = ApprovalTask()
returned = ApprovalTaskProjectionService.apply(task, projection)
assert returned is task
assert task.evidence_completeness == Decimal("0.2500")
assert task.batch_eligible is False
assert "evidence_incomplete" in task.batch_block_reasons_json
assert {item["code"] for item in task.priority_reasons_json} >= {"evidence_gap"}
def test_projection_rejects_ambiguous_time_and_invalid_sla() -> None:
claim = _claim()
service = ApprovalTaskProjectionService(None) # type: ignore[arg-type]
with pytest.raises(ValueError, match="entered_at.*timezone"):
service.build(
claim,
tenant_id="tenant-a",
entered_at=datetime(2026, 7, 16, 10, 0),
observation_rows=[],
)
with pytest.raises(ValueError, match="sla_hours"):
service.build(
claim,
tenant_id="tenant-a",
entered_at=datetime(2026, 7, 16, 10, 0, tzinfo=UTC),
sla_hours=0,
observation_rows=[],
)
def test_apply_convenience_form_uses_claim_session_for_tenant_risks() -> None:
engine = create_engine(
"sqlite+pysqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(bind=engine)
db_factory = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)
now = datetime(2026, 7, 16, 12, 0, tzinfo=UTC)
with db_factory() as db:
claim = _claim(claim_id="claim-projection-session")
observation = RiskObservation(
id="observation-projection-session",
tenant_id="tenant-a",
observation_key="risk:projection:session",
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="policy_violation",
risk_signal="policy_violation",
title="政策风险",
description="等待人工处置。",
risk_score=80,
risk_level="high",
confidence_score=0.9,
control_stage="reimbursement",
control_mode="risk_observation",
automation_mode="manual",
source="financial_risk_graph",
algorithm_version="v1",
status="pending_review",
feedback_status="unreviewed",
)
db.add_all([claim, observation])
db.flush()
task = ApprovalTask(
tenant_id="tenant-a",
entered_at=now - timedelta(hours=1),
sla_hours_snapshot=12,
)
returned = ApprovalTaskProjectionService.apply(task, claim=claim, now=now)
assert returned is task
assert task.risk_level == "high"
assert task.open_risk_count == 1
assert task.due_at == now + timedelta(hours=11)
def test_projection_filters_other_business_stage_and_only_honors_active_waiver() -> None:
now = datetime(2026, 7, 16, 12, 0, tzinfo=UTC)
claim = _claim(
claim_id="claim-projection-waiver",
claim_no="AP-PROJECTION-WAIVER",
risk_flags=[
{
"severity": "critical",
"triggered": True,
"business_stage": "reimbursement",
}
],
)
other_stage = RiskObservation(
id="observation-other-stage",
tenant_id="tenant-a",
observation_key="risk:projection:other-stage",
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="invoice",
risk_signal="invoice",
risk_level="critical",
control_stage="reimbursement",
status="pending_review",
feedback_status="unreviewed",
)
waived = RiskObservation(
id="observation-active-waiver",
tenant_id="tenant-a",
observation_key="risk:projection:active-waiver",
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="policy",
risk_signal="policy",
risk_level="high",
control_stage="expense_application",
status="pending_review",
feedback_status="confirmed",
)
disposition = RiskDisposition(
tenant_id="tenant-a",
observation_id=waived.id,
adjudication="confirmed",
lifecycle_status="waived",
waiver_expires_at=now + timedelta(days=1),
waiver_decision="approved",
)
service = ApprovalTaskProjectionService(None) # type: ignore[arg-type]
active = service.build(
claim,
tenant_id="tenant-a",
entered_at=now - timedelta(hours=1),
now=now,
observation_rows=[(other_stage, None), (waived, disposition)],
)
disposition.waiver_expires_at = now - timedelta(seconds=1)
expired = service.build(
claim,
tenant_id="tenant-a",
entered_at=now - timedelta(hours=1),
now=now,
observation_rows=[(other_stage, None), (waived, disposition)],
)
assert active.risk_level == "low"
assert active.open_risk_count == 0
assert active.batch_eligible is True
assert expired.risk_level == "high"
assert expired.open_risk_count == 1
assert expired.batch_eligible is False
def test_risk_upsert_refreshes_open_task_projection_without_resetting_sla_window() -> None:
engine = create_engine(
"sqlite+pysqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(bind=engine)
db_factory = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)
now = datetime(2026, 7, 16, 12, 0, tzinfo=UTC)
with db_factory() as db:
claim = _claim(claim_id="claim-projection-refresh")
task = ApprovalTask(
id="task-projection-refresh",
tenant_id="default",
claim_id=claim.id,
expense_case_id=None,
node_instance_id="node-projection-refresh",
node_entry_key="node-entry-projection-refresh",
parent_task_id=None,
task_kind="root",
node_key="direct_manager",
node_label="直属领导审批",
node_sequence=1,
sequence_order=0,
coordination_mode="single",
owner_kind="role",
owner_key="manager",
owner_name="审批经理",
assignee_kind="role",
assignee_key="manager",
assignee_name="审批经理",
status="pending",
version=1,
claim_status_snapshot="submitted",
claim_stage_snapshot="直属领导审批",
entered_at=now - timedelta(hours=1),
entered_at_source="workflow_event",
activated_at=now - timedelta(hours=1),
sla_hours_snapshot=24,
due_at=now + timedelta(hours=23),
escalation_level=1,
next_escalation_at=now + timedelta(hours=2),
priority_score=10,
priority_tier="normal",
priority_reasons_json=[
{
"code": "sla_escalated_l1",
"label": "审批超时已升级至 L1",
"weight": 10,
"tone": "danger",
}
],
risk_level="low",
open_risk_count=0,
evidence_completeness=Decimal("1.0000"),
batch_eligible=True,
batch_block_reasons_json=[],
projection_updated_at=now,
created_at=now,
updated_at=now,
)
db.add_all([claim, task])
db.commit()
observation = RiskObservationService(db).upsert_observation(
{
"observation_key": "risk:projection:refresh",
"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": "policy_violation",
"risk_signal": "policy_violation",
"title": "政策风险",
"description": "等待人工处置。",
"risk_score": 90,
"risk_level": "high",
"control_stage": "reimbursement",
"status": "pending_review",
},
tenant_id="default",
)
db.refresh(task)
assert task.risk_level == "high"
assert task.open_risk_count == 1
assert task.batch_eligible is False
next_escalation_at = task.next_escalation_at
assert next_escalation_at is not None
if next_escalation_at.tzinfo is None:
next_escalation_at = next_escalation_at.replace(tzinfo=UTC)
assert next_escalation_at == now + timedelta(hours=2)
assert task.escalation_level == 1
assert any(
reason["code"] == "sla_escalated_l1"
for reason in task.priority_reasons_json
)
db.add(
RiskDisposition(
tenant_id="default",
observation_id=observation.id,
adjudication="confirmed",
lifecycle_status="waived",
waiver_requester_id="requester-projection-refresh",
waiver_requester_name="风险申请人",
waiver_requested_at=now - timedelta(hours=1),
waiver_reason="业务连续性需要",
waiver_scope="本次报销单",
waiver_decision="approved",
waiver_expires_at=now + timedelta(days=1),
waiver_conditions_json=["补充主管确认"],
waiver_decider_id="finance-projection-refresh",
waiver_decider_name="财务复核人",
waiver_decided_at=now,
waiver_decision_reason="风险受控且有补偿措施",
version=1,
)
)
db.flush()
ApprovalTaskProjectionRefreshService(db).refresh_claim(
tenant_id="default",
claim_id=claim.id,
now=now,
)
assert task.risk_level == "low"
assert task.open_risk_count == 0
assert task.batch_eligible is True
next_escalation_at = task.next_escalation_at
assert next_escalation_at is not None
if next_escalation_at.tzinfo is None:
next_escalation_at = next_escalation_at.replace(tzinfo=UTC)
assert next_escalation_at == now + timedelta(hours=2)

View File

@@ -0,0 +1,600 @@
from __future__ import annotations
from collections.abc import Generator
from datetime import UTC, date, datetime, timedelta
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, func, select
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool
from app.api.deps import CurrentUserContext, get_db
from app.db.base import Base
from app.main import create_app
from app.models.approval_action import ApprovalActionLedger
from app.models.approval_task import ApprovalTask, ApprovalTaskEvent
from app.models.employee import Employee
from app.models.expense_case import ExpenseCase, ExpenseCaseLink
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
from app.models.role import Role
from app.schemas.approval_task import (
ApprovalTaskBatchApproveCreate,
ApprovalTaskBatchApproveItem,
)
from app.services.approval_task_batch import ApprovalTaskBatchService
from app.services.approval_task_query import ApprovalTaskQueryService
from app.services.expense_claim_workflow_constants import (
DIRECT_MANAGER_APPROVAL_STAGE,
FINANCE_APPROVAL_STAGE,
)
def _session_factory() -> sessionmaker[Session]:
engine = create_engine(
"sqlite+pysqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(bind=engine)
return sessionmaker(
bind=engine,
autoflush=False,
autocommit=False,
expire_on_commit=False,
)
def _user(
*,
tenant_id: str = "tenant-a",
employee_id: str = "approver-1",
username: str = "approver@example.com",
role_codes: list[str] | None = None,
is_admin: bool = False,
) -> CurrentUserContext:
return CurrentUserContext(
username=username,
name="审批人",
role_codes=list(role_codes or ["manager"]),
is_admin=is_admin,
tenant_id=tenant_id,
employee_id=employee_id,
employee_no=employee_id,
)
def _task_status_fields(
status: str,
*,
now: datetime,
) -> dict[str, object]:
if status == "completed":
return {
"decision": "approved",
"completed_at": now,
"cancelled_at": None,
}
if status == "returned":
return {
"decision": "returned",
"completed_at": now,
"cancelled_at": None,
}
if status in {"cancelled", "superseded"}:
return {
"decision": None,
"completed_at": None,
"cancelled_at": now,
}
return {"decision": None, "completed_at": None, "cancelled_at": None}
def _build_queue_task(
*,
tenant_id: str,
case_id: str,
index: int,
now: datetime,
assignee_id: str,
status: str = "pending",
risk_level: str | None = None,
node_key: str | None = None,
due_at: datetime | None = None,
reason: str | None = None,
) -> tuple[ExpenseClaim, ExpenseCaseLink, ApprovalTask]:
claim_id = f"queue-claim-{tenant_id[-1]}-{index:03d}"
task_id = f"queue-task-{tenant_id[-1]}-{index:03d}"
selected_node = node_key or ("direct_manager" if index % 2 == 0 else "finance")
stage = (
DIRECT_MANAGER_APPROVAL_STAGE
if selected_node == "direct_manager"
else FINANCE_APPROVAL_STAGE
)
# 任务进入节点后才会产生 SLA 截止时间;即使构造“已逾期”样本,
# due_at 也必须晚于 entered_at保持测试数据满足真实表约束。
entered_at = now - timedelta(hours=12 + index % 5, minutes=index % 7)
selected_due_at = due_at or (
now - timedelta(hours=2)
if index % 3 == 0
else now + timedelta(hours=2)
if index % 3 == 1
else now + timedelta(hours=8)
)
selected_risk = risk_level or ("high" if index % 5 == 0 else "low")
claim = ExpenseClaim(
id=claim_id,
claim_no=f"RE-QUEUE-{tenant_id[-1].upper()}-{index:03d}",
employee_id=None,
employee_name=f"申请人{index:03d}",
department_id=None,
department_name="市场部",
project_code="PRJ-QUEUE",
expense_type="misc",
reason=reason or f"队列分页测试费用 {index:03d}",
location="上海",
amount=Decimal("888.00") + Decimal(index),
currency="CNY",
invoice_count=1,
occurred_at=now - timedelta(days=1),
submitted_at=entered_at,
status="submitted",
approval_stage=stage,
risk_flags_json=[],
created_at=entered_at - timedelta(hours=1),
updated_at=entered_at,
)
link = ExpenseCaseLink(
id=f"queue-link-{tenant_id[-1]}-{index:03d}",
tenant_id=tenant_id,
expense_case_id=case_id,
resource_type="expense_claim",
resource_id=claim_id,
relation_type="claim",
created_at=entered_at,
)
lifecycle = _task_status_fields(status, now=now)
task = ApprovalTask(
id=task_id,
tenant_id=tenant_id,
claim_id=claim_id,
expense_case_id=case_id,
node_instance_id=f"queue-node-{tenant_id[-1]}-{index:03d}",
node_entry_key=f"queue-entry-{tenant_id[-1]}-{index:03d}",
parent_task_id=None,
task_kind="root",
node_key=selected_node,
node_label=stage,
node_sequence=1,
sequence_order=0,
coordination_mode="single",
owner_kind="employee",
owner_key=assignee_id,
owner_employee_id=assignee_id,
owner_name="审批人",
assignee_kind="employee",
assignee_key=assignee_id,
assignee_employee_id=assignee_id,
assignee_name="审批人张经理",
status=status,
decision=lifecycle["decision"],
version=2 if status != "pending" else 1,
claim_status_snapshot="submitted",
claim_stage_snapshot=stage,
entered_at=entered_at,
entered_at_source="submitted_at",
activated_at=entered_at,
sla_hours_snapshot=24,
due_at=selected_due_at,
completed_at=lifecycle["completed_at"],
cancelled_at=lifecycle["cancelled_at"],
escalation_level=0,
next_escalation_at=selected_due_at,
priority_score=(index % 4) * 25,
priority_tier="urgent" if index % 4 == 3 else "high" if index % 4 == 2 else "normal",
priority_reasons_json=[],
risk_level=selected_risk,
open_risk_count=1 if selected_risk != "low" else 0,
evidence_completeness=Decimal("1.0000"),
batch_eligible=(status == "pending" and selected_risk == "low"),
batch_block_reasons_json=[] if selected_risk == "low" else ["open_risk"],
projection_updated_at=now,
created_at=entered_at,
updated_at=now,
)
return claim, link, task
def _seed_queue(
db: Session,
*,
tenant_id: str = "tenant-a",
pending_count: int = 27,
assignee_id: str = "approver-1",
include_processed: bool = False,
) -> tuple[datetime, list[ApprovalTask]]:
now = datetime.now(UTC).replace(microsecond=0)
case_id = f"queue-case-{tenant_id[-1]}"
expense_case = ExpenseCase(
id=case_id,
tenant_id=tenant_id,
case_no=f"CASE-QUEUE-{tenant_id[-1].upper()}",
scene_code="misc",
title="审批队列测试",
current_stage="reviewing",
status="active",
created_at=now - timedelta(days=2),
updated_at=now,
)
db.add(expense_case)
tasks: list[ApprovalTask] = []
for index in range(pending_count):
claim, link, task = _build_queue_task(
tenant_id=tenant_id,
case_id=case_id,
index=index,
now=now,
assignee_id=assignee_id,
reason=("火星专项客户拜访" if index == 7 else None),
)
db.add_all([claim, link, task])
tasks.append(task)
if include_processed:
for offset, status in enumerate(
("completed", "returned", "cancelled", "superseded"),
start=pending_count,
):
claim, link, task = _build_queue_task(
tenant_id=tenant_id,
case_id=case_id,
index=offset,
now=now,
assignee_id=assignee_id,
status=status,
)
db.add_all([claim, link, task])
tasks.append(task)
db.commit()
return now, tasks
def test_query_uses_real_database_pagination_and_stable_persisted_sorting() -> None:
factory = _session_factory()
with factory() as db:
_now, tasks = _seed_queue(db)
expected = sorted(
tasks,
key=lambda task: (
-task.priority_score,
task.due_at,
task.entered_at,
task.id,
),
)
page = ApprovalTaskQueryService(db).list_tasks(
_user(),
page=2,
page_size=10,
)
repeated = ApprovalTaskQueryService(db).list_tasks(
_user(),
page=2,
page_size=10,
)
expected_ids = [task.id for task in expected[10:20]]
assert page.total == 27
assert page.page == 2
assert page.page_size == 10
assert page.total_pages == 3
assert [item.task.id for item in page.items] == expected_ids
assert [item.task.id for item in repeated.items] == expected_ids
def test_query_supports_status_risk_sla_node_and_keyword_filters() -> None:
factory = _session_factory()
with factory() as db:
_now, tasks = _seed_queue(db, include_processed=True)
tasks[0].escalation_level = 1
db.commit()
service = ApprovalTaskQueryService(db)
user = _user()
assert service.list_tasks(user, status="processed").total == 4
assert service.list_tasks(user, status="completed").total == 1
assert service.list_tasks(user, risk_level="high").total == 6
assert service.list_tasks(user, sla_state="overdue").total == 9
assert service.list_tasks(user, sla_state="due_soon").total == 9
assert service.list_tasks(user, sla_state="on_track").total == 9
assert service.list_tasks(user, sla_state="escalated").total == 1
assert service.list_tasks(user, node_key="finance").total == 13
keyword = service.list_tasks(user, keyword="火星专项")
assert keyword.total == 1
assert keyword.items[0].claim.reason == "火星专项客户拜访"
with pytest.raises(ValueError, match="SLA"):
service.list_tasks(user, sla_state="unknown")
def test_query_enforces_tenant_scope_and_admin_visibility_without_action_authority() -> None:
factory = _session_factory()
with factory() as db:
_seed_queue(db, tenant_id="tenant-a", pending_count=3)
_seed_queue(
db,
tenant_id="tenant-b",
pending_count=2,
assignee_id="other-approver",
)
service = ApprovalTaskQueryService(db)
personal = service.list_tasks(_user(tenant_id="tenant-a"))
admin_a = service.list_tasks(
_user(
tenant_id="tenant-a",
employee_id="admin-a",
username="admin-a@example.com",
role_codes=["admin"],
is_admin=True,
)
)
admin_b = service.list_tasks(
_user(
tenant_id="tenant-b",
employee_id="admin-b",
username="admin-b@example.com",
role_codes=["admin"],
is_admin=True,
)
)
assert personal.total == 3
assert all(item.task.can_act for item in personal.items)
assert admin_a.total == 3
assert all(item.task.can_act is False for item in admin_a.items)
assert all("approve" not in item.task.available_actions for item in admin_a.items)
assert admin_b.total == 2
assert {item.task.tenant_id for item in admin_b.items} == {"tenant-b"}
def _seed_batch(
db: Session,
) -> tuple[CurrentUserContext, ApprovalTask, ApprovalTask]:
now = datetime.now(UTC).replace(microsecond=0)
finance_role = Role(
id="batch-role-finance",
role_code="finance",
name="批处理财务",
)
finance = Employee(
id="batch-finance",
employee_no="BATCH-FINANCE",
name="财务审批人",
email="batch-finance@example.com",
roles=[finance_role],
)
owner = Employee(
id="batch-owner",
employee_no="BATCH-OWNER",
name="批处理申请人",
email="batch-owner@example.com",
)
expense_case = ExpenseCase(
id="batch-case",
tenant_id="tenant-a",
case_no="CASE-BATCH-APPROVAL",
scene_code="misc",
title="批量审批事务测试",
owner_employee_id=owner.id,
current_stage="reviewing",
status="active",
created_at=now - timedelta(days=1),
updated_at=now,
)
db.add_all([finance_role, finance, owner, expense_case])
tasks: list[ApprovalTask] = []
for index, eligible in enumerate((True, False), start=1):
claim_id = f"batch-claim-{index}"
claim = ExpenseClaim(
id=claim_id,
claim_no=f"RE-BATCH-{index:03d}",
employee=owner,
employee_name=owner.name,
department_name="市场部",
project_code="PRJ-BATCH",
expense_type="misc",
reason=f"批量审批测试 {index}",
location="上海",
amount=Decimal("88.00"),
currency="CNY",
invoice_count=1,
occurred_at=now - timedelta(days=1),
submitted_at=now - timedelta(hours=1),
status="submitted",
approval_stage=FINANCE_APPROVAL_STAGE,
risk_flags_json=[],
created_at=now - timedelta(days=1),
updated_at=now,
)
claim.items = [
ExpenseClaimItem(
id=f"batch-item-{index}",
claim_id=claim_id,
item_date=date.today(),
item_type="misc",
item_reason="测试",
item_location="上海",
item_note="",
item_amount=Decimal("88.00"),
invoice_id=f"BATCH-INVOICE-{index}",
created_at=now,
updated_at=now,
)
]
link = ExpenseCaseLink(
id=f"batch-link-{index}",
tenant_id="tenant-a",
expense_case_id=expense_case.id,
resource_type="expense_claim",
resource_id=claim_id,
relation_type="claim",
created_at=now,
)
task = ApprovalTask(
id=f"batch-task-{index}",
tenant_id="tenant-a",
claim_id=claim_id,
expense_case_id=expense_case.id,
node_instance_id=f"batch-node-{index}",
node_entry_key=f"batch-entry-{index}",
parent_task_id=None,
task_kind="root",
node_key="finance",
node_label=FINANCE_APPROVAL_STAGE,
node_sequence=1,
sequence_order=0,
coordination_mode="single",
owner_kind="employee",
owner_key=finance.id,
owner_employee_id=finance.id,
owner_name=finance.name,
assignee_kind="employee",
assignee_key=finance.id,
assignee_employee_id=finance.id,
assignee_name=finance.name,
status="pending",
decision=None,
version=1,
claim_status_snapshot="submitted",
claim_stage_snapshot=FINANCE_APPROVAL_STAGE,
entered_at=now - timedelta(hours=1),
entered_at_source="submitted_at",
activated_at=now - timedelta(hours=1),
sla_hours_snapshot=8,
due_at=now + timedelta(hours=7),
escalation_level=0,
next_escalation_at=now + timedelta(hours=7),
priority_score=0,
priority_tier="normal",
priority_reasons_json=[],
risk_level="low" if eligible else "high",
open_risk_count=0 if eligible else 1,
evidence_completeness=Decimal("1.0000"),
batch_eligible=eligible,
batch_block_reasons_json=[] if eligible else ["open_risk"],
projection_updated_at=now,
created_at=now,
updated_at=now,
)
db.add_all([claim, link, task])
tasks.append(task)
db.commit()
return (
_user(
tenant_id="tenant-a",
employee_id=finance.id,
username=finance.email,
role_codes=["finance"],
),
tasks[0],
tasks[1],
)
def test_batch_items_use_independent_transactions_and_retry_replays_success() -> None:
factory = _session_factory()
with factory() as db:
user, successful_task, blocked_task = _seed_batch(db)
payload = ApprovalTaskBatchApproveCreate(
batch_request_id="batch-request-001",
items=[
ApprovalTaskBatchApproveItem(
task_id=successful_task.id,
expected_task_version=1,
expected_status="submitted",
expected_approval_stage=FINANCE_APPROVAL_STAGE,
opinion="批量审批同意",
),
ApprovalTaskBatchApproveItem(
task_id=blocked_task.id,
expected_task_version=1,
expected_status="submitted",
expected_approval_stage=FINANCE_APPROVAL_STAGE,
opinion="批量审批同意",
),
],
)
first = ApprovalTaskBatchService(factory).approve(payload, user)
repeated = ApprovalTaskBatchService(factory).approve(payload, user)
assert first.status == "partial"
assert first.succeeded_count == 1
assert first.blocked_count == 1
assert [item.status for item in first.items] == ["succeeded", "blocked"]
assert repeated.status == "partial"
assert repeated.replayed_count == 1
assert repeated.blocked_count == 1
assert [item.status for item in repeated.items] == ["replayed", "blocked"]
with factory() as db:
successful_claim = db.get(ExpenseClaim, successful_task.claim_id)
blocked_claim = db.get(ExpenseClaim, blocked_task.claim_id)
persisted_success = db.get(ApprovalTask, successful_task.id)
persisted_blocked = db.get(ApprovalTask, blocked_task.id)
assert successful_claim.status == "pending_payment"
assert successful_claim.approval_stage == "待付款"
assert persisted_success.status == "completed"
assert persisted_success.decision == "approved"
assert blocked_claim.status == "submitted"
assert persisted_blocked.status == "pending"
assert db.scalar(select(func.count()).select_from(ApprovalActionLedger)) == 1
assert db.scalar(select(func.count()).select_from(ApprovalTaskEvent)) == 1
def test_approval_task_list_http_contract_preserves_pagination_metadata() -> None:
factory = _session_factory()
with factory() as db:
_now, tasks = _seed_queue(db)
expected = sorted(
tasks,
key=lambda task: (
-task.priority_score,
task.due_at,
task.entered_at,
task.id,
),
)
app = create_app()
install_legacy_header_auth_override(app)
def override_db() -> Generator[Session, None, None]:
with factory() as db:
yield db
app.dependency_overrides[get_db] = override_db
client = TestClient(app)
try:
response = client.get(
"/api/v1/approval-tasks",
params={"page": 3, "page_size": 10, "status": "pending"},
headers={
"X-Auth-Username": "approver@example.com",
"X-Auth-Employee-Id": "approver-1",
"X-Auth-Role-Codes": "manager",
"X-Auth-Tenant-Id": "tenant-a",
},
)
finally:
client.close()
app.dependency_overrides.clear()
body = response.json()
assert response.status_code == 200
assert body["total"] == 27
assert body["page"] == 3
assert body["page_size"] == 10
assert body["total_pages"] == 3
assert len(body["items"]) == 7
assert body["items"][0]["task"]["id"] == expected[20].id

View File

@@ -1,6 +1,6 @@
from __future__ import annotations
from datetime import UTC, datetime
from datetime import UTC, datetime, timedelta
from decimal import Decimal
import pytest
@@ -52,6 +52,48 @@ def test_high_risk_requires_false_positive_or_resolved_disposition() -> None:
gate.ensure_approvable(claim, tenant_id="default")
def test_only_unexpired_approved_waiver_bypasses_risk_gate() -> None:
with _session() as db:
claim = _claim(claim_id="claim-risk-waiver-gate")
observation = _observation(claim, observation_id="risk-waiver-gate")
now = datetime.now(UTC)
disposition = RiskDisposition(
tenant_id="default",
observation_id=observation.id,
adjudication="confirmed",
lifecycle_status="waived",
waiver_requester_id="waiver-requester",
waiver_requester_name="豁免申请人",
waiver_requested_at=now - timedelta(days=2),
waiver_reason="临时无法取得纸质材料。",
waiver_scope="仅限本次客户拜访。",
waiver_expires_at=now + timedelta(days=2),
waiver_conditions_json=["到期前补件"],
waiver_decision="approved",
waiver_decider_id="waiver-decider",
waiver_decider_name="豁免决定人",
waiver_decided_at=now - timedelta(days=1),
waiver_decision_reason="补偿控制充分,同意限时豁免。",
)
db.add_all([claim, observation, disposition])
db.commit()
gate = ExpenseClaimRiskGate(db)
gate.ensure_approvable(claim, tenant_id="default")
disposition.waiver_expires_at = now - timedelta(hours=1)
db.commit()
with pytest.raises(ExpenseClaimRiskBlockedError):
gate.ensure_approvable(claim, tenant_id="default")
disposition.lifecycle_status = "waiver_rejected"
disposition.waiver_decision = "rejected"
disposition.waiver_decision_reason = "补偿控制不足,拒绝豁免。"
db.commit()
with pytest.raises(ExpenseClaimRiskBlockedError):
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")

View File

@@ -149,6 +149,14 @@ def test_known_revision_requires_and_accepts_its_exact_owned_table_set(
"20260716_0012",
MIGRATION_OWNED_TABLES_BY_REVISION["20260716_0012"] - {"risk_disposition_events"},
),
(
"20260716_0013",
MIGRATION_OWNED_TABLES_BY_REVISION["20260716_0013"] - {"approval_task_events"},
),
(
"20260716_0014",
MIGRATION_OWNED_TABLES_BY_REVISION["20260716_0014"] - {"risk_disposition_events"},
),
],
)
def test_known_revision_with_missing_or_unexpected_owned_tables_is_rejected(

View File

@@ -0,0 +1,616 @@
from __future__ import annotations
from collections.abc import Generator
from dataclasses import dataclass
from datetime import UTC, date, 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
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.approval_task import ApprovalTask
from app.models.employee import Employee
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
from app.models.risk_disposition import RiskDisposition
from app.models.risk_observation import RiskObservation
from app.schemas.risk_disposition import RiskDispositionActionCreate
from app.services.approval_task_projection_refresh import (
ApprovalTaskProjectionRefreshService,
)
from app.services.risk_disposition_read_projection import RiskDispositionActionPolicy
from app.services.risk_dispositions import RiskDispositionService
from app.services.risk_observations import RiskObservationService
def test_available_actions_enforce_decider_separation_tenant_state_and_expiry(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("FEW_SHOT_INJECTION_ENABLED", "false")
client, factory = _build_client()
client.close()
with factory() as db:
seeded = _seed_waiver_request(db)
policy = RiskDispositionActionPolicy(db)
decider_actions = set(
policy.evaluate(
seeded.observation,
seeded.disposition,
seeded.decider,
).available_actions
)
assert {"approve_waiver", "reject_waiver"} <= decider_actions
for user in (seeded.requester, seeded.admin, seeded.inactive_finance):
actions = set(
policy.evaluate(
seeded.observation,
seeded.disposition,
user,
).available_actions
)
assert "approve_waiver" not in actions
assert "reject_waiver" not in actions
foreign_permission = policy.evaluate(
seeded.observation,
seeded.disposition,
_user(seeded.decider_employee, roles=["finance"], tenant_id="tenant-b"),
)
assert foreign_permission.available_actions == ()
assert "租户" in foreign_permission.read_only_reason
expires_at = seeded.disposition.waiver_expires_at
assert expires_at is not None
expired_actions = set(
policy.evaluate(
seeded.observation,
seeded.disposition,
seeded.decider,
now=_as_utc(expires_at) + timedelta(seconds=1),
).available_actions
)
assert "approve_waiver" not in expired_actions
assert "reject_waiver" in expired_actions
seeded.disposition.lifecycle_status = "waived"
seeded.disposition.waiver_decision = "approved"
waived_actions = set(
policy.evaluate(
seeded.observation,
seeded.disposition,
seeded.decider,
).available_actions
)
assert "approve_waiver" not in waived_actions
assert "reject_waiver" not in waived_actions
seeded.disposition.lifecycle_status = "open"
seeded.disposition.waiver_decision = None
seeded.disposition.waiver_requester_id = None
open_actions = set(
policy.evaluate(
seeded.observation,
seeded.disposition,
seeded.decider,
).available_actions
)
assert "request_waiver" in open_actions
def test_read_and_mutation_endpoints_return_server_projected_actions(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("FEW_SHOT_INJECTION_ENABLED", "false")
client, factory = _build_client()
with factory() as db:
seeded = _seed_waiver_request(db)
unreviewed = RiskObservationService(db).upsert_observation(
_observation_payload("risk:projection:unreviewed")
)
db.commit()
observation_id = seeded.observation.id
unreviewed_id = unreviewed.id
decider_headers = _headers(seeded.decider_employee, roles=["finance"])
claim_response = client.get(
"/api/v1/risk-observations/claim/claim-risk-projection",
headers=decider_headers,
)
assert claim_response.status_code == 200
by_id = {item["id"]: item for item in claim_response.json()}
requested_item = by_id[observation_id]
assert {"approve_waiver", "reject_waiver"} <= set(
requested_item["available_actions"]
)
assert requested_item["read_only_reason"] == ""
assert requested_item["disposition"]["available_actions"] == requested_item[
"available_actions"
]
assert requested_item["disposition"]["read_only_reason"] == ""
assert by_id[unreviewed_id]["disposition"] is None
assert {"confirm", "false_positive"} <= set(
by_id[unreviewed_id]["available_actions"]
)
for path in (
"/api/v1/risk-observations",
f"/api/v1/risk-observations/{observation_id}",
f"/api/v1/risk-observations/{observation_id}/disposition",
):
response = client.get(path, headers=decider_headers)
assert response.status_code == 200
payload = response.json()
if path.endswith("/risk-observations"):
payload = next(item for item in payload["items"] if item["id"] == observation_id)
if not path.endswith("/disposition"):
payload = payload["disposition"]
assert {"approve_waiver", "reject_waiver"} <= set(payload["available_actions"])
assert payload["read_only_reason"] == ""
for employee, roles, is_admin in (
(seeded.requester_employee, ["finance"], False),
(seeded.admin_employee, [], True),
):
response = client.get(
f"/api/v1/risk-observations/{observation_id}/disposition",
headers=_headers(employee, roles=roles, is_admin=is_admin),
)
assert response.status_code == 200
assert "approve_waiver" not in response.json()["available_actions"]
assert "reject_waiver" not in response.json()["available_actions"]
mutation_response = client.post(
f"/api/v1/risk-observations/{observation_id}/disposition/actions",
headers=decider_headers,
json={
"action": "reject_waiver",
"expected_version": 2,
"request_id": "projection-reject-waiver",
"comment": "当前证据不足,不同意风险豁免。",
},
)
assert mutation_response.status_code == 200
mutation_disposition = mutation_response.json()["disposition"]
assert "available_actions" in mutation_disposition
assert "read_only_reason" in mutation_disposition
assert "approve_waiver" not in mutation_disposition["available_actions"]
assert "reject_waiver" not in mutation_disposition["available_actions"]
def test_disposition_mutations_refresh_task_risk_without_resetting_sla(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("FEW_SHOT_INJECTION_ENABLED", "false")
client, factory = _build_client()
client.close()
with factory() as db:
owner = _employee("projection-owner")
requester_employee = _employee("projection-requester")
decider_employee = _employee("projection-decider")
claim = _claim(owner.id)
observation = RiskObservationService(db).upsert_observation(
_observation_payload("risk:projection:task-refresh")
)
now = datetime.now(UTC)
task = _approval_task(claim, now=now)
db.add_all([owner, requester_employee, decider_employee, claim, task])
db.commit()
original_sla = (
_as_utc(task.entered_at),
task.sla_hours_snapshot,
_as_utc(task.due_at),
_as_utc(task.next_escalation_at),
task.escalation_level,
)
requester = _user(requester_employee, roles=["finance"])
decider = _user(decider_employee, roles=["finance"])
service = RiskDispositionService(db)
service.execute_action(
observation.id,
RiskDispositionActionCreate(
action="confirm",
expected_version=0,
request_id="task-refresh-confirm-risk",
),
tenant_id="default",
actor_id=requester_employee.id,
actor_name=requester_employee.name,
current_user=requester,
)
db.refresh(task)
assert task.risk_level == "high"
assert task.open_risk_count == 1
assert task.batch_eligible is False
assert "open_risk" in task.batch_block_reasons_json
_assert_sla_window(task, original_sla)
service.execute_action(
observation.id,
_waiver_request(version=1, request_id="task-refresh-waiver-request-1"),
tenant_id="default",
actor_id=requester_employee.id,
actor_name=requester_employee.name,
current_user=requester,
)
service.execute_action(
observation.id,
RiskDispositionActionCreate(
action="reject_waiver",
expected_version=2,
request_id="task-refresh-reject-waiver",
comment="补偿措施不足,拒绝豁免。",
),
tenant_id="default",
actor_id=decider_employee.id,
actor_name=decider_employee.name,
current_user=decider,
)
db.refresh(task)
assert task.open_risk_count == 1
assert task.batch_eligible is False
_assert_sla_window(task, original_sla)
service.execute_action(
observation.id,
_waiver_request(version=3, request_id="task-refresh-waiver-request-2"),
tenant_id="default",
actor_id=requester_employee.id,
actor_name=requester_employee.name,
current_user=requester,
)
service.execute_action(
observation.id,
RiskDispositionActionCreate(
action="approve_waiver",
expected_version=4,
request_id="task-refresh-approve-waiver",
comment="补偿措施完整,同意限时豁免。",
),
tenant_id="default",
actor_id=decider_employee.id,
actor_name=decider_employee.name,
current_user=decider,
)
db.refresh(task)
assert task.risk_level == "low"
assert task.open_risk_count == 0
assert task.evidence_completeness == Decimal("1.0000")
assert task.batch_eligible is True
_assert_sla_window(task, original_sla)
disposition = service.get_disposition(observation.id, tenant_id="default")
assert disposition is not None
refresh_at = datetime.now(UTC)
disposition.waiver_requested_at = refresh_at - timedelta(days=2)
disposition.waiver_expires_at = refresh_at - timedelta(hours=1)
db.flush()
ApprovalTaskProjectionRefreshService(db).refresh_claim(
tenant_id="default",
claim_id=claim.id,
now=refresh_at,
)
db.commit()
db.refresh(task)
assert task.risk_level == "high"
assert task.open_risk_count == 1
assert task.batch_eligible is False
_assert_sla_window(task, original_sla)
@dataclass(slots=True)
class _SeededWaiver:
observation: RiskObservation
disposition: RiskDisposition
requester_employee: Employee
decider_employee: Employee
admin_employee: Employee
requester: CurrentUserContext
decider: CurrentUserContext
admin: CurrentUserContext
inactive_finance: CurrentUserContext
def _seed_waiver_request(db: Session) -> _SeededWaiver:
owner = _employee("owner")
requester_employee = _employee("requester")
decider_employee = _employee("decider")
admin_employee = _employee("admin")
inactive_employee = _employee("inactive", employment_status="离职")
db.add_all(
[
owner,
requester_employee,
decider_employee,
admin_employee,
inactive_employee,
_claim(owner.id),
]
)
observation = RiskObservationService(db).upsert_observation(
_observation_payload("risk:projection:waiver")
)
db.commit()
requester = _user(requester_employee, roles=["finance"])
service = RiskDispositionService(db)
service.execute_action(
observation.id,
RiskDispositionActionCreate(
action="confirm",
expected_version=0,
request_id="projection-confirm-risk",
),
tenant_id="default",
actor_id=requester_employee.id,
actor_name=requester_employee.name,
current_user=requester,
)
requested = service.execute_action(
observation.id,
RiskDispositionActionCreate(
action="request_waiver",
expected_version=1,
request_id="projection-request-waiver",
waiver_reason="客户现场暂时无法补齐原件。",
waiver_scope="仅限本次报销单的当前风险观察。",
waiver_expires_at=datetime.now(UTC) + timedelta(days=2),
waiver_conditions=["到期前补齐原件"],
),
tenant_id="default",
actor_id=requester_employee.id,
actor_name=requester_employee.name,
current_user=requester,
)
disposition = service.get_disposition(observation.id, tenant_id="default")
assert disposition is not None
assert requested.disposition.version == 2
return _SeededWaiver(
observation=observation,
disposition=disposition,
requester_employee=requester_employee,
decider_employee=decider_employee,
admin_employee=admin_employee,
requester=requester,
decider=_user(decider_employee, roles=["finance"]),
admin=_user(admin_employee, roles=[], is_admin=True),
inactive_finance=_user(inactive_employee, roles=["finance"]),
)
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,
expire_on_commit=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]:
with factory() as db:
yield db
app.dependency_overrides[get_db] = override_db
return TestClient(app), factory
def _employee(suffix: str, *, employment_status: str = "在职") -> Employee:
return Employee(
id=f"emp-{suffix}",
employee_no=f"E-{suffix.upper()}",
name=f"员工-{suffix}",
email=f"{suffix}@risk-projection.example.com",
position="财务专员",
grade="P6",
employment_status=employment_status,
)
def _user(
employee: Employee,
*,
roles: list[str],
is_admin: bool = False,
tenant_id: str = "default",
) -> CurrentUserContext:
return CurrentUserContext(
username=employee.email,
name=employee.name,
role_codes=roles,
is_admin=is_admin,
tenant_id=tenant_id,
employee_no=employee.employee_no,
employee_id=employee.id,
)
def _headers(
employee: Employee,
*,
roles: list[str],
is_admin: bool = False,
) -> dict[str, str]:
return {
"X-Auth-Username": employee.email,
"X-Auth-Name": employee.id,
"X-Auth-Role-Codes": ",".join(roles),
"X-Auth-Is-Admin": str(is_admin).lower(),
"X-Auth-Employee-No": employee.employee_no,
"X-Auth-Employee-Id": employee.id,
"X-Auth-Tenant-Id": "default",
}
def _waiver_request(*, version: int, request_id: str) -> RiskDispositionActionCreate:
return RiskDispositionActionCreate(
action="request_waiver",
expected_version=version,
request_id=request_id,
waiver_reason="客户现场暂时无法补齐原件。",
waiver_scope="仅限当前报销单的当前风险观察。",
waiver_expires_at=datetime.now(UTC) + timedelta(days=2),
waiver_conditions=["到期前补齐原件"],
)
def _claim(owner_id: str) -> ExpenseClaim:
now = datetime.now(UTC)
claim = ExpenseClaim(
id="claim-risk-projection",
claim_no="BX-RISK-PROJECTION",
employee_id=owner_id,
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=[],
)
claim.items = [
ExpenseClaimItem(
id="item-risk-projection",
claim_id=claim.id,
item_date=date.today(),
item_type="travel",
item_reason="客户拜访交通费",
item_location="上海",
item_note="",
item_amount=claim.amount,
invoice_id="INV-RISK-PROJECTION",
created_at=now,
updated_at=now,
)
]
return claim
def _approval_task(claim: ExpenseClaim, *, now: datetime) -> ApprovalTask:
return ApprovalTask(
id="task-risk-projection",
tenant_id="default",
claim_id=claim.id,
expense_case_id=None,
node_instance_id="node-risk-projection",
node_entry_key="node-entry-risk-projection",
parent_task_id=None,
task_kind="root",
node_key="finance",
node_label="财务审批",
node_sequence=3,
sequence_order=0,
coordination_mode="single",
owner_kind="role",
owner_key="finance",
owner_name="财务审批人",
assignee_kind="role",
assignee_key="finance",
assignee_name="财务审批人",
status="pending",
version=1,
claim_status_snapshot="submitted",
claim_stage_snapshot="财务审批",
entered_at=now - timedelta(hours=1),
entered_at_source="workflow_event",
activated_at=now - timedelta(hours=1),
sla_hours_snapshot=24,
due_at=now + timedelta(hours=23),
escalation_level=1,
next_escalation_at=now + timedelta(hours=7),
priority_score=10,
priority_tier="normal",
priority_reasons_json=[
{
"code": "sla_escalated_l1",
"label": "审批已升级至 L1",
"weight": 10,
"tone": "danger",
}
],
risk_level="low",
open_risk_count=0,
evidence_completeness=Decimal("1.0000"),
batch_eligible=True,
batch_block_reasons_json=[],
projection_updated_at=now,
created_at=now,
updated_at=now,
)
def _assert_sla_window(
task: ApprovalTask,
expected: tuple[datetime, int, datetime, datetime, int],
) -> None:
actual = (
_as_utc(task.entered_at),
task.sla_hours_snapshot,
_as_utc(task.due_at),
_as_utc(task.next_escalation_at),
task.escalation_level,
)
assert actual == expected
def _observation_payload(observation_key: str) -> dict[str, object]:
return {
"observation_key": observation_key,
"subject_type": "expense_claim",
"subject_key": "claim:claim-risk-projection",
"subject_label": "BX-RISK-PROJECTION",
"claim_id": "claim-risk-projection",
"claim_no": "BX-RISK-PROJECTION",
"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": {},
}
def _as_utc(value: datetime) -> datetime:
if value.tzinfo is None:
return value.replace(tzinfo=UTC)
return value.astimezone(UTC)

View File

@@ -26,6 +26,7 @@ from app.services.risk_dispositions import (
RiskDispositionPermissionError,
RiskDispositionService,
RiskDispositionVersionConflictError,
RiskWaiverDecisionPermissionError,
)
from app.services.risk_observation_access_policy import RiskObservationAccessPolicy
from app.services.risk_observations import RiskObservationService
@@ -467,7 +468,9 @@ def test_risk_observation_api_enforces_pool_claim_and_typed_action_permissions(
"action": "request_waiver",
"expected_version": 0,
"request_id": "api-stale-request-001",
"comment": "申请风险豁免复核",
"waiver_reason": "客户现场条件导致合规材料暂缺",
"waiver_scope": "仅限本次客户拜访的行程材料",
"waiver_expires_at": (datetime.now(UTC) + timedelta(days=3)).isoformat(),
},
)
changed_replay = client.post(
@@ -637,9 +640,315 @@ def test_disposition_rechecks_current_approver_after_claim_stage_changes() -> No
assert db.scalar(select(func.count()).select_from(RiskDispositionEvent)) == 0
def test_waiver_approval_enforces_separation_of_duties_and_replays_snapshot(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("FEW_SHOT_INJECTION_ENABLED", "false")
with _build_session() as db:
requester = Employee(
id="waiver-requester",
employee_no="W-REQUESTER",
name="豁免申请人",
email="waiver.requester@example.com",
employment_status="在职",
)
decider = Employee(
id="waiver-decider",
employee_no="W-DECIDER",
name="豁免决定人",
email="waiver.decider@example.com",
employment_status="在职",
)
admin_viewer = Employee(
id="waiver-admin",
employee_no="W-ADMIN",
name="仅管理员",
email="waiver.admin@example.com",
employment_status="在职",
)
inactive_decider = Employee(
id="waiver-inactive",
employee_no="W-INACTIVE",
name="离职财务",
email="waiver.inactive@example.com",
employment_status="离职",
)
db.add_all([_employee(), _claim(), requester, decider, admin_viewer, inactive_decider])
db.flush()
observation = RiskObservationService(db).upsert_observation(
_observation_payload("risk:waiver:sod")
)
observation_id = observation.id
db.commit()
service = RiskDispositionService(db)
requester_user = _user(requester, roles=["finance"])
decider_user = _user(decider, roles=["finance"])
service.execute_action(
observation_id,
_action("confirm", version=0, request_id="waiver-confirm-request-001"),
tenant_id="default",
actor_id=requester.id,
actor_name=requester.name,
current_user=requester_user,
)
requested = service.execute_action(
observation_id,
RiskDispositionActionCreate(
action="request_waiver",
expected_version=1,
request_id="waiver-application-request-001",
waiver_reason="客户现场临时封控,无法在审批前取得原件。",
waiver_scope="仅限本次客户拜访的纸质行程确认材料。",
waiver_expires_at=datetime.now(UTC) + timedelta(days=5),
waiver_conditions=["三日内补交扫描件", "到期前由财务复核"],
),
tenant_id="default",
actor_id=requester.id,
actor_name=requester.name,
current_user=requester_user,
)
assert requested.disposition.lifecycle_status == "waiver_requested"
assert requested.disposition.waiver_requester_id == requester.id
assert requested.disposition.waiver_reason == "客户现场临时封控,无法在审批前取得原件。"
assert requested.disposition.waiver_conditions_json == [
"三日内补交扫描件",
"到期前由财务复核",
]
decision = RiskDispositionActionCreate(
action="approve_waiver",
expected_version=2,
request_id="waiver-approve-request-001",
comment="条件明确且有效期受控,同意本次例外。",
)
with pytest.raises(RiskWaiverDecisionPermissionError, match="不能批准或拒绝自己的"):
service.execute_action(
observation_id,
decision,
tenant_id="default",
actor_id=requester.id,
actor_name=requester.name,
current_user=requester_user,
)
with pytest.raises(RiskWaiverDecisionPermissionError, match="明确角色"):
service.execute_action(
observation_id,
decision,
tenant_id="default",
actor_id=admin_viewer.id,
actor_name=admin_viewer.name,
current_user=_user(admin_viewer, roles=[], is_admin=True),
)
with pytest.raises(RiskWaiverDecisionPermissionError, match="不是在职员工"):
service.execute_action(
observation_id,
decision,
tenant_id="default",
actor_id=inactive_decider.id,
actor_name=inactive_decider.name,
current_user=_user(inactive_decider, roles=["finance"]),
)
with pytest.raises(RiskWaiverDecisionPermissionError, match="跨租户"):
service.execute_action(
observation_id,
decision,
tenant_id="default",
actor_id=decider.id,
actor_name=decider.name,
current_user=_user(decider, roles=["finance"], tenant_id="tenant-b"),
)
approved = service.execute_action(
observation_id,
decision,
tenant_id="default",
actor_id=decider.id,
actor_name=decider.name,
current_user=decider_user,
)
replayed = service.execute_action(
observation_id,
decision,
tenant_id="default",
actor_id=decider.id,
actor_name=decider.name,
current_user=decider_user,
)
assert approved.disposition.lifecycle_status == "waived"
assert approved.disposition.waiver_decision == "approved"
assert approved.disposition.waiver_decider_id == decider.id
assert approved.disposition.waiver_decision_reason == decision.comment
assert replayed.replayed is True
assert replayed.event.id == approved.event.id
assert replayed.disposition.waiver_requester_id == requester.id
assert replayed.disposition.waiver_decider_id == decider.id
assert db.scalar(select(func.count()).select_from(RiskDispositionEvent)) == 3
def test_waiver_rejection_is_explicit_and_decisions_require_requested_state(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("FEW_SHOT_INJECTION_ENABLED", "false")
with _build_session() as db:
requester = Employee(
id="waiver-reject-requester",
employee_no="W-REJECT-REQ",
name="拒绝流程申请人",
email="waiver.reject.requester@example.com",
employment_status="在职",
)
decider = Employee(
id="waiver-reject-decider",
employee_no="W-REJECT-DEC",
name="拒绝流程决定人",
email="waiver.reject.decider@example.com",
employment_status="在职",
)
db.add_all([_employee(), _claim(), requester, decider])
db.flush()
observation = RiskObservationService(db).upsert_observation(
_observation_payload("risk:waiver:rejected")
)
observation_id = observation.id
db.commit()
service = RiskDispositionService(db)
requester_user = _user(requester, roles=["finance"])
decider_user = _user(decider, roles=["executive"])
with pytest.raises(RiskDispositionConflictError, match="只有待决定"):
service.execute_action(
observation_id,
RiskDispositionActionCreate(
action="reject_waiver",
expected_version=0,
request_id="waiver-reject-without-request",
comment="当前没有可拒绝的申请。",
),
tenant_id="default",
actor_id=decider.id,
actor_name=decider.name,
current_user=decider_user,
)
service.execute_action(
observation_id,
_action("confirm", version=0, request_id="waiver-reject-confirm"),
tenant_id="default",
actor_id=requester.id,
actor_name=requester.name,
current_user=requester_user,
)
service.execute_action(
observation_id,
RiskDispositionActionCreate(
action="request_waiver",
expected_version=1,
request_id="waiver-reject-application",
waiver_reason="希望免除本次高风险拦截。",
waiver_scope="本次重复票据风险。",
waiver_expires_at=datetime.now(UTC) + timedelta(days=2),
waiver_conditions=[],
),
tenant_id="default",
actor_id=requester.id,
actor_name=requester.name,
current_user=requester_user,
)
rejected = service.execute_action(
observation_id,
RiskDispositionActionCreate(
action="reject_waiver",
expected_version=2,
request_id="waiver-reject-decision",
comment="缺少足够补偿控制,拒绝豁免。",
),
tenant_id="default",
actor_id=decider.id,
actor_name=decider.name,
current_user=decider_user,
)
assert rejected.disposition.lifecycle_status == "waiver_rejected"
assert rejected.disposition.waiver_decision == "rejected"
assert rejected.disposition.waiver_decider_id == decider.id
assert rejected.event.action == "reject_waiver"
def test_waiver_self_decision_endpoint_returns_typed_forbidden_error(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("FEW_SHOT_INJECTION_ENABLED", "false")
client, session_factory = _build_client()
requester = Employee(
id="waiver-api-requester",
employee_no="W-API-REQ",
name="接口豁免申请人",
email="waiver.api.requester@example.com",
employment_status="在职",
)
requester_user = _user(requester, roles=["finance"])
requester_headers = {
"X-Auth-Username": requester.email,
"X-Auth-Name": "Waiver Requester",
"X-Auth-Employee-No": requester.employee_no,
"X-Auth-Employee-Id": requester.id,
"X-Auth-Role-Codes": "finance",
}
with session_factory() as db:
db.add_all([_employee(), _claim(), requester])
db.flush()
observation = RiskObservationService(db).upsert_observation(
_observation_payload("risk:waiver:api-sod")
)
observation_id = observation.id
db.commit()
service = RiskDispositionService(db)
service.execute_action(
observation_id,
_action("confirm", version=0, request_id="waiver-api-confirm"),
tenant_id="default",
actor_id=requester.id,
actor_name=requester.name,
current_user=requester_user,
)
service.execute_action(
observation_id,
RiskDispositionActionCreate(
action="request_waiver",
expected_version=1,
request_id="waiver-api-application",
waiver_reason="接口申请职责分离验证。",
waiver_scope="仅限当前风险观察。",
waiver_expires_at=datetime.now(UTC) + timedelta(days=2),
),
tenant_id="default",
actor_id=requester.id,
actor_name=requester.name,
current_user=requester_user,
)
response = client.post(
f"/api/v1/risk-observations/{observation_id}/disposition/actions",
headers=requester_headers,
json={
"action": "approve_waiver",
"expected_version": 2,
"request_id": "waiver-api-self-approve",
"comment": "尝试自批。",
},
)
assert response.status_code == 403
assert response.json()["detail"]["code"] == "RISK_WAIVER_DECISION_FORBIDDEN"
assert "不能批准或拒绝自己的" in response.json()["detail"]["message"]
@pytest.mark.parametrize(
"action",
["false_positive", "request_supplement", "request_waiver"],
["false_positive", "request_supplement"],
)
def test_evidence_sensitive_actions_require_server_side_comment(action: str) -> None:
with pytest.raises(ValueError, match="必须填写 comment"):
@@ -650,21 +959,39 @@ def test_evidence_sensitive_actions_require_server_side_comment(action: str) ->
)
def test_waiver_request_requires_reason_scope_and_expiry() -> None:
with pytest.raises(ValueError, match="必须填写原因、范围和有效期"):
RiskDispositionActionCreate(
action="request_waiver",
expected_version=0,
request_id="request-waiver-fields-missing",
)
def _action(
action: str,
*,
version: int,
request_id: str,
) -> RiskDispositionActionCreate:
waiver_fields: dict[str, object] = {}
if action == "request_waiver":
waiver_fields = {
"waiver_reason": "客户现场条件导致合规材料暂缺",
"waiver_scope": "仅限本次客户拜访的行程材料",
"waiver_expires_at": datetime.now(UTC) + timedelta(days=3),
"waiver_conditions": ["补齐客户确认函", "到期前完成复核"],
}
return RiskDispositionActionCreate(
action=action,
expected_version=version,
request_id=request_id,
comment=(
"风险处置说明"
if action in {"false_positive", "request_supplement", "request_waiver"}
if action in {"false_positive", "request_supplement"}
else None
),
**waiver_fields,
)
@@ -713,6 +1040,24 @@ def _employee() -> Employee:
)
def _user(
employee: Employee,
*,
roles: list[str],
is_admin: bool = False,
tenant_id: str = "default",
) -> CurrentUserContext:
return CurrentUserContext(
username=employee.email,
name=employee.name,
role_codes=roles,
is_admin=is_admin,
tenant_id=tenant_id,
employee_no=employee.employee_no,
employee_id=employee.id,
)
def _claim() -> ExpenseClaim:
now = datetime(2026, 7, 16, tzinfo=UTC)
return ExpenseClaim(

View File

@@ -20,6 +20,8 @@ def test_create_legacy_schema_never_creates_migration_owned_tables() -> None:
"ai_decision_feedback",
"ai_decisions",
"approval_action_ledgers",
"approval_task_events",
"approval_tasks",
"auth_sessions",
"attachment_association_jobs",
"business_events",