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