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, 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", employee_no="BATCH-FINANCE", name="财务审批人", email="batch-finance@example.com", roles=[finance_role], ) owner = Employee( id="batch-owner", 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, 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