feat(approval): add task workflow and waiver decisions
This commit is contained in:
436
server/tests/test_approval_task_actions.py
Normal file
436
server/tests/test_approval_task_actions.py
Normal 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
|
||||
Reference in New Issue
Block a user