from __future__ import annotations import argparse import importlib.util from datetime import UTC, date, datetime, timedelta from decimal import Decimal from pathlib import Path import pytest from sqlalchemy import create_engine, func, select from sqlalchemy.orm import Session, sessionmaker from sqlalchemy.pool import StaticPool from app.db.base import Base from app.models.approval_task import ApprovalTask, ApprovalTaskEvent from app.models.employee import Employee from app.models.expense_case import BusinessEvent, ExpenseCase, ExpenseCaseLink from app.models.financial_record import ExpenseClaim, ExpenseClaimItem from app.schemas.approval_task import ApprovalTaskMutationRead from app.services.approval_task_backfill import ( ApprovalTaskBackfillDisposition, ApprovalTaskBackfillPreview, ApprovalTaskBackfillService, ) from app.services.approval_task_protocol import ApprovalTaskEventProtocol from app.services.expense_claim_workflow_constants import ( DIRECT_MANAGER_APPROVAL_STAGE, FINANCE_APPROVAL_STAGE, ) def _load_cli_module(): path = Path(__file__).resolve().parents[1] / "scripts" / "backfill_approval_tasks.py" spec = importlib.util.spec_from_file_location("backfill_approval_tasks_cli", path) assert spec is not None and spec.loader is not None module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) return module CLI = _load_cli_module() def _session() -> 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, expire_on_commit=False)() def _persist_claim( db: Session, *, suffix: str, tenant_id: str, stage: str = DIRECT_MANAGER_APPROVAL_STAGE, status: str = "submitted", with_manager: bool = True, with_event: bool = True, ) -> tuple[ExpenseClaim, Employee | None, BusinessEvent | None]: submitted_at = datetime(2026, 7, 14, 10, 0, tzinfo=UTC) manager = ( Employee( id=f"manager-{suffix}", employee_no=f"M-{suffix}", name=f"经理{suffix}", email=f"manager-{suffix}@example.com", ) if with_manager else None ) employee = Employee( id=f"employee-{suffix}", employee_no=f"E-{suffix}", name=f"员工{suffix}", email=f"employee-{suffix}@example.com", manager=manager, ) claim = ExpenseClaim( id=f"claim-{suffix}", claim_no=f"RE-BACKFILL-{suffix}", employee=employee, employee_name=employee.name, department_name="市场部", project_code="PRJ-BACKFILL", expense_type="travel", reason="客户现场差旅", location="上海", amount=Decimal("888.00"), currency="CNY", invoice_count=1, occurred_at=submitted_at - timedelta(days=1), submitted_at=submitted_at, status=status, approval_stage=stage, risk_flags_json=[], created_at=submitted_at - timedelta(hours=2), updated_at=submitted_at, ) claim.items = [ ExpenseClaimItem( id=f"item-{suffix}", claim_id=claim.id, item_date=date(2026, 7, 14), item_type="transport", item_reason="客户现场交通", item_location="上海", item_note="", item_amount=Decimal("888.00"), invoice_id=f"invoice-{suffix}", created_at=submitted_at, updated_at=submitted_at, ) ] expense_case = ExpenseCase( id=f"case-{suffix}", tenant_id=tenant_id, case_no=f"CASE-RE-BACKFILL-{suffix}", scene_code="travel", title="客户现场差旅", owner_employee_id=employee.id, current_stage="reviewing", status="active", created_at=submitted_at - timedelta(hours=2), updated_at=submitted_at, ) link = ExpenseCaseLink( id=f"link-{suffix}", tenant_id=tenant_id, expense_case_id=expense_case.id, resource_type="expense_claim", resource_id=claim.id, relation_type="claim", created_at=submitted_at, ) event = None if with_event: event = BusinessEvent( id=f"event-{suffix}", tenant_id=tenant_id, expense_case_id=expense_case.id, aggregate_type="expense_claim", aggregate_id=claim.id, event_type="claim_submitted", event_version=1, idempotency_key=f"submit-{suffix}", correlation_id=f"correlation-{suffix}", actor_id=employee.id, actor_type="user", payload_json={ "next_status": "submitted", "next_approval_stage": stage, }, delivery_status="published", occurred_at=submitted_at + timedelta(hours=1), ) db.add_all( [ *([manager] if manager is not None else []), employee, claim, expense_case, link, *([event] if event is not None else []), ] ) db.commit() return claim, manager, event def test_preview_is_default_read_only_tenant_scoped_and_prefers_business_event() -> None: with _session() as db: claim, manager, event = _persist_claim(db, suffix="A", tenant_id="tenant-a") _persist_claim(db, suffix="B", tenant_id="tenant-b") assert manager is not None and event is not None service = ApprovalTaskBackfillService(db, tenant_id="tenant-a") result = service.run() assert isinstance(result, ApprovalTaskBackfillPreview) assert result.inspected == 1 assert result.eligible == 1 assert result.existing == 0 assert result.skipped == 0 assert result.items[0].claim_id == claim.id assert result.items[0].entered_at_source == "workflow_event" assert result.items[0].entered_at == event.occurred_at assert result.items[0].assignee_key == manager.id assert db.scalar(select(func.count()).select_from(ApprovalTask)) == 0 assert db.scalar(select(func.count()).select_from(ApprovalTaskEvent)) == 0 def test_apply_creates_audited_projected_root_task_and_is_idempotent() -> None: applied_at = datetime(2026, 7, 16, 12, 0, tzinfo=UTC) with _session() as db: claim, manager, source_event = _persist_claim( db, suffix="APPLY", tenant_id="tenant-a", ) assert manager is not None and source_event is not None service = ApprovalTaskBackfillService(db, tenant_id="tenant-a") first = service.apply_batch( run_id="approval-task-backfill-run-1", backfilled_at=applied_at, ) repeated = service.apply_batch( run_id="approval-task-backfill-run-1", backfilled_at=applied_at, ) task = db.scalar(select(ApprovalTask).where(ApprovalTask.claim_id == claim.id)) event = db.scalar(select(ApprovalTaskEvent).where(ApprovalTaskEvent.task_id == task.id)) assert first.created == 1 assert repeated.created == 0 assert repeated.existing == 1 assert task.task_kind == "root" assert task.status == "pending" assert task.tenant_id == "tenant-a" assert task.expense_case_id == "case-APPLY" assert task.node_key == "direct_manager" assert task.assignee_employee_id == manager.id assert task.entered_at_source == "workflow_event" assert task.due_at > task.entered_at assert task.projection_updated_at.replace(tzinfo=UTC) == applied_at assert task.risk_level == "low" assert task.evidence_completeness == Decimal("1.0000") assert task.batch_eligible is False assert "sla_overdue" in task.batch_block_reasons_json assert event.event_type == "node_entered_backfilled" assert event.expected_task_version == 0 assert event.result_task_version == 1 assert event.business_event_id == source_event.id assert event.payload_json["history_reconstructed"] is False assert event.payload_json["current_node_reconstructed"] is True assert event.response_json["task"]["id"] == task.id snapshot = ApprovalTaskMutationRead.model_validate(event.response_json) replay = ApprovalTaskEventProtocol(db).find_replay( tenant_id="tenant-a", actor_id="approval-task-backfill", request_id=event.request_id, fingerprint=event.payload_fingerprint, ) assert snapshot.event.id == event.id assert replay is not None and replay.replayed is True assert replay.task.id == task.id assert db.scalar(select(func.count()).select_from(ApprovalTask)) == 1 assert db.scalar(select(func.count()).select_from(ApprovalTaskEvent)) == 1 def test_unresolved_direct_manager_is_fail_closed_but_finance_role_is_safe_fallback() -> None: with _session() as db: unresolved, _manager, _event = _persist_claim( db, suffix="NO-MANAGER", tenant_id="tenant-a", with_manager=False, ) inactive, inactive_manager, _event = _persist_claim( db, suffix="INACTIVE-MANAGER", tenant_id="tenant-a", ) assert inactive_manager is not None inactive_manager.employment_status = "离职" db.commit() finance, _manager, _event = _persist_claim( db, suffix="FINANCE", tenant_id="tenant-a", stage=FINANCE_APPROVAL_STAGE, with_manager=False, ) service = ApprovalTaskBackfillService(db, tenant_id="tenant-a") preview = service.preview() by_claim = {item.claim_id: item for item in preview.items} assert by_claim[unresolved.id].disposition is ( ApprovalTaskBackfillDisposition.ASSIGNEE_UNRESOLVED ) assert by_claim[inactive.id].disposition is ( ApprovalTaskBackfillDisposition.ASSIGNEE_UNRESOLVED ) assert by_claim[finance.id].disposition is ApprovalTaskBackfillDisposition.ELIGIBLE assert by_claim[finance.id].assignee_key == "finance" assert preview.eligible == 1 assert preview.skipped == 2 def test_legacy_in_approval_state_is_canonicalized_without_mutating_claim() -> None: with _session() as db: claim, manager, _event = _persist_claim( db, suffix="LEGACY", tenant_id="tenant-a", status="in_approval", stage="", with_event=False, ) assert manager is not None service = ApprovalTaskBackfillService(db, tenant_id="tenant-a") preview = service.preview() applied = service.apply_batch( run_id="approval-task-backfill-legacy", backfilled_at=datetime(2026, 7, 16, 12, 0, tzinfo=UTC), ) task = db.scalar(select(ApprovalTask).where(ApprovalTask.claim_id == claim.id)) assert preview.eligible == 1 assert preview.items[0].entered_at_source == "submitted_at" assert applied.created == 1 assert task.claim_status_snapshot == "submitted" assert task.claim_stage_snapshot == DIRECT_MANAGER_APPROVAL_STAGE assert task.assignee_employee_id == manager.id assert claim.status == "in_approval" assert claim.approval_stage == "" def test_entered_at_fallback_order_is_explicit_and_deterministic() -> None: submitted_at = datetime(2026, 7, 14, 10, 0, tzinfo=UTC) claim = ExpenseClaim( id="claim-time-fallback", claim_no="RE-TIME-FALLBACK", employee_name="张三", department_name="市场部", expense_type="travel", reason="差旅", location="上海", amount=Decimal("100"), occurred_at=submitted_at - timedelta(days=1), submitted_at=submitted_at, status="submitted", approval_stage=DIRECT_MANAGER_APPROVAL_STAGE, created_at=submitted_at - timedelta(hours=2), updated_at=submitted_at - timedelta(hours=1), ) with _session() as db: service = ApprovalTaskBackfillService(db, tenant_id="tenant-a") entered_at, source, _event = service.derive_entered_at( claim, events=[], approval_stage=DIRECT_MANAGER_APPROVAL_STAGE, ) assert (entered_at, source) == (submitted_at, "submitted_at") claim.submitted_at = None entered_at, source, _event = service.derive_entered_at( claim, events=[], approval_stage=DIRECT_MANAGER_APPROVAL_STAGE, ) assert (entered_at, source) == (claim.updated_at, "updated_at") claim.updated_at = None entered_at, source, _event = service.derive_entered_at( claim, events=[], approval_stage=DIRECT_MANAGER_APPROVAL_STAGE, ) assert (entered_at, source) == (claim.created_at, "backfill") def test_backfill_cli_defaults_to_dry_run_and_requires_exact_apply_confirmation( monkeypatch, ) -> None: args = CLI.build_parser().parse_args( [ "--tenant-id", "tenant-a", "--created-before", "2026-07-16T00:00:00+08:00", "--expected-host", "migration-probe", "--expected-database", "migration_probe", ] ) assert args.apply is False assert args.created_before == datetime(2026, 7, 15, 16, 0, tzinfo=UTC) apply_args = CLI.build_parser().parse_args( [ "--apply", "--tenant-id", "tenant-a", "--created-before", "2026-07-16T00:00:00Z", "--expected-host", "migration-probe", "--expected-database", "migration_probe", ] ) monkeypatch.delenv("DATABASE_URL", raising=False) with pytest.raises(CLI.BackfillCommandError) as exc_info: CLI.run(apply_args) assert exc_info.value.code == "confirm_target_required" @pytest.mark.parametrize("value", ["", "2026-07-16T00:00:00", "not-a-time"]) def test_backfill_cli_rejects_timestamp_without_explicit_timezone(value: str) -> None: with pytest.raises(argparse.ArgumentTypeError): CLI.parse_timestamp(value)