Files
X-Financial/server/tests/test_approval_task_query_and_batch.py
caoxiaozhu 787bc3a481 feat(platform): close AI expense value loop
Add tenant-safe value, telemetry, connector, commercial, and production-readiness foundations.
2026-07-17 14:14:08 +08:00

605 lines
20 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
tenant_id=tenant_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",
tenant_id="tenant-a",
employee_no="BATCH-FINANCE",
name="财务审批人",
email="batch-finance@example.com",
roles=[finance_role],
)
owner = Employee(
id="batch-owner",
tenant_id="tenant-a",
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,
tenant_id="tenant-a",
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