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

437 lines
16 KiB
Python
Raw Normal View History

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