Files
X-Financial/server/tests/test_approval_task_query_and_batch.py

605 lines
20 KiB
Python
Raw Normal View History

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