Add tenant-safe value, telemetry, connector, commercial, and production-readiness foundations.
442 lines
16 KiB
Python
442 lines
16 KiB
Python
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",
|
|
tenant_id=tenant_id,
|
|
employee_no="M-TASK-001",
|
|
name="李经理",
|
|
email="manager-task@example.com",
|
|
roles=[manager_role],
|
|
)
|
|
participants = [
|
|
Employee(
|
|
id=f"employee-task-participant-{index}",
|
|
tenant_id=tenant_id,
|
|
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",
|
|
tenant_id=tenant_id,
|
|
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",
|
|
tenant_id=tenant_id,
|
|
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",
|
|
tenant_id="tenant-approval-task",
|
|
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
|