from __future__ import annotations from datetime import UTC, datetime, timedelta from decimal import Decimal 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.expense_case import BusinessEvent, ExpenseCase, ExpenseCaseLink from app.models.financial_record import ExpenseClaim from app.services.expense_case_legacy_backfill import ( HISTORICAL_CLAIM_IMPORTED_EVENT, HISTORICAL_IMPORT_DELIVERY_STATUS, ExpenseCaseLegacyBackfillService, LegacyBackfillDisposition, ) CUTOFF = datetime(2026, 7, 14, tzinfo=UTC) def build_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, autocommit=False)() def build_claim(claim_no: str, *, created_at: datetime | None = None) -> ExpenseClaim: return ExpenseClaim( claim_no=claim_no, employee_name="张三", department_name="市场部", project_code="PRJ-BACKFILL", expense_type="travel", reason="历史客户拜访", location="上海", amount=Decimal("128.50"), currency="CNY", invoice_count=1, occurred_at=datetime(2026, 7, 10, 9, 0, tzinfo=UTC), submitted_at=datetime(2026, 7, 11, 10, 0, tzinfo=UTC), status="submitted", approval_stage="直属领导审批", risk_flags_json=[], created_at=created_at or datetime(2026, 7, 10, 8, 0, tzinfo=UTC), updated_at=datetime(2026, 7, 11, 10, 0, tzinfo=UTC), ) def seed_link(db: Session, claim: ExpenseClaim, *, tenant_id: str = "default") -> None: expense_case = ExpenseCase( tenant_id=tenant_id, case_no=f"CASE-{claim.claim_no}", scene_code="travel", title=claim.reason, current_stage="reviewing", status="active", ) db.add(expense_case) db.flush() db.add( ExpenseCaseLink( tenant_id=tenant_id, expense_case_id=expense_case.id, resource_type="expense_claim", resource_id=claim.id, relation_type="claim", ) ) db.flush() def seed_event_without_link(db: Session, claim: ExpenseClaim) -> None: expense_case = ExpenseCase( tenant_id="default", case_no=f"CASE-ORPHAN-{claim.claim_no}", scene_code="travel", title=claim.reason, current_stage="reviewing", status="active", ) db.add(expense_case) db.flush() db.add( BusinessEvent( tenant_id="default", expense_case_id=expense_case.id, aggregate_type="expense_claim", aggregate_id=claim.id, event_type="claim_submitted", event_version=1, idempotency_key=f"orphan:{claim.id}", correlation_id="orphan-event", actor_id="system", actor_type="system", payload_json={}, delivery_status="pending", occurred_at=datetime(2026, 7, 11, tzinfo=UTC), ) ) db.flush() def table_counts(db: Session) -> tuple[int, int, int]: return ( db.scalar(select(func.count()).select_from(ExpenseCase)) or 0, db.scalar(select(func.count()).select_from(ExpenseCaseLink)) or 0, db.scalar(select(func.count()).select_from(BusinessEvent)) or 0, ) def test_requires_explicit_tenant_and_timezone_aware_cutoff() -> None: with build_session() as db: with pytest.raises(ValueError, match="tenant_id"): ExpenseCaseLegacyBackfillService(db, tenant_id="", cutoff=CUTOFF) with pytest.raises(ValueError, match="timezone"): ExpenseCaseLegacyBackfillService( db, tenant_id="default", cutoff=datetime(2026, 7, 14), ) def test_fingerprint_is_stable_and_idempotency_key_is_tenant_scoped() -> None: with build_session() as db: claim = build_claim("EXP-HISTORY-FINGERPRINT") db.add(claim) db.flush() default_service = ExpenseCaseLegacyBackfillService( db, tenant_id="default", cutoff=CUTOFF, ) tenant_service = ExpenseCaseLegacyBackfillService( db, tenant_id="tenant-b", cutoff=CUTOFF, ) first_fingerprint = default_service.source_fingerprint(claim) assert default_service.source_fingerprint(claim) == first_fingerprint assert default_service.idempotency_key(claim.id) == ( f"historical-import:v1:default:{claim.id}" ) assert tenant_service.idempotency_key(claim.id) == ( f"historical-import:v1:tenant-b:{claim.id}" ) claim.status = "approved" assert default_service.source_fingerprint(claim) != first_fingerprint def test_long_tenant_idempotency_key_is_stably_hashed_to_database_limit() -> None: with build_session() as db: service = ExpenseCaseLegacyBackfillService( db, tenant_id="t" * 64, cutoff=CUTOFF, ) first = service.idempotency_key("c" * 36) second = service.idempotency_key("c" * 36) assert first == second assert first.startswith("historical-import:v1:sha256:") assert len(first) <= 120 def test_preview_is_read_only_and_classifies_linked_and_orphan_event_conflicts() -> None: with build_session() as db: eligible = build_claim("EXP-HISTORY-ELIGIBLE") linked = build_claim("EXP-HISTORY-LINKED", created_at=CUTOFF - timedelta(days=3)) conflict = build_claim("EXP-HISTORY-CONFLICT", created_at=CUTOFF - timedelta(days=2)) future = build_claim("EXP-HISTORY-FUTURE", created_at=CUTOFF + timedelta(seconds=1)) db.add_all([eligible, linked, conflict, future]) db.flush() seed_link(db, linked) seed_event_without_link(db, conflict) db.commit() counts_before = table_counts(db) preview = ExpenseCaseLegacyBackfillService( db, tenant_id="default", cutoff=CUTOFF, ).preview(batch_size=10) assert preview.inspected == 3 assert preview.eligible == 1 assert preview.linked == 1 assert preview.conflicts == 1 assert not preview.has_more assert table_counts(db) == counts_before dispositions = {item.claim_no: item.disposition for item in preview.items} assert dispositions == { "EXP-HISTORY-ELIGIBLE": LegacyBackfillDisposition.ELIGIBLE, "EXP-HISTORY-LINKED": LegacyBackfillDisposition.LINKED, "EXP-HISTORY-CONFLICT": LegacyBackfillDisposition.CONFLICT, } def test_apply_batch_writes_honest_snapshot_without_committing_and_is_idempotent() -> None: with build_session() as db: claim = build_claim("EXP-HISTORY-APPLY") db.add(claim) db.commit() backfilled_at = datetime(2026, 7, 14, 12, 30, tzinfo=UTC) service = ExpenseCaseLegacyBackfillService( db, tenant_id="default", cutoff=CUTOFF, ) result = service.apply_batch( run_id="legacy-backfill-run-1", batch_size=10, backfilled_at=backfilled_at, ) assert result.created == 1 assert result.skipped_linked == 0 assert result.conflicts == 0 event = db.scalar(select(BusinessEvent)) assert event is not None assert event.event_type == HISTORICAL_CLAIM_IMPORTED_EVENT assert event.idempotency_key == (f"historical-import:v1:default:{claim.id}") assert event.idempotency_key == service.idempotency_key(claim.id) assert event.actor_id == "system" assert event.actor_type == "system" assert event.delivery_status == HISTORICAL_IMPORT_DELIVERY_STATUS assert event.occurred_at == backfilled_at assert event.payload_json["history_reconstructed"] is False assert event.payload_json["backfill_version"] == 1 assert event.payload_json["backfilled_at"] == "2026-07-14T12:30:00Z" assert event.payload_json["performed_by"] == "expense_case_legacy_backfill_cli" assert event.payload_json["source_times"] == { "occurred_at": "2026-07-10T09:00:00Z", "submitted_at": "2026-07-11T10:00:00Z", "created_at": "2026-07-10T08:00:00Z", "updated_at": "2026-07-11T10:00:00Z", } assert event.payload_json["source_fingerprint"].startswith("sha256:") assert "未重建" in event.payload_json["reason"] db.rollback() assert table_counts(db) == (0, 0, 0) first_apply = service.apply_batch( run_id="legacy-backfill-run-2", batch_size=10, backfilled_at=backfilled_at, ) db.commit() repeated_apply = service.apply_batch( run_id="legacy-backfill-run-3", batch_size=10, backfilled_at=backfilled_at + timedelta(minutes=1), ) assert first_apply.created == 1 assert repeated_apply.created == 0 assert repeated_apply.skipped_linked == 1 assert table_counts(db) == (1, 1, 1) def test_apply_batch_reports_event_without_link_as_conflict() -> None: with build_session() as db: claim = build_claim("EXP-HISTORY-ORPHAN") db.add(claim) db.flush() seed_event_without_link(db, claim) db.commit() result = ExpenseCaseLegacyBackfillService( db, tenant_id="default", cutoff=CUTOFF, ).apply_batch( run_id="legacy-backfill-conflict", batch_size=10, ) assert result.created == 0 assert result.conflicts == 1 assert result.items[0].disposition is LegacyBackfillDisposition.CONFLICT assert "without an expense case link" in result.items[0].reason assert db.scalar(select(func.count()).select_from(ExpenseCaseLink)) == 0 def test_preview_rejects_damaged_link_tenant_and_unlinked_case() -> None: with build_session() as db: damaged_link_claim = build_claim("EXP-HISTORY-DAMAGED-LINK") unlinked_case_claim = build_claim( "EXP-HISTORY-UNLINKED-CASE", created_at=CUTOFF - timedelta(days=2), ) db.add_all([damaged_link_claim, unlinked_case_claim]) db.flush() foreign_case = ExpenseCase( tenant_id="other-tenant", case_no=f"CASE-{damaged_link_claim.claim_no}", scene_code="travel", title=damaged_link_claim.reason, current_stage="reviewing", status="active", ) orphan_case = ExpenseCase( tenant_id="default", case_no=f"CASE-{unlinked_case_claim.claim_no}", scene_code="travel", title=unlinked_case_claim.reason, current_stage="reviewing", status="active", ) db.add_all([foreign_case, orphan_case]) db.flush() db.add( ExpenseCaseLink( tenant_id="default", expense_case_id=foreign_case.id, resource_type="expense_claim", resource_id=damaged_link_claim.id, relation_type="claim", ) ) db.commit() preview = ExpenseCaseLegacyBackfillService( db, tenant_id="default", cutoff=CUTOFF, ).preview(batch_size=10) assert preview.conflicts == 2 reasons = {item.claim_no: item.reason for item in preview.items} assert "invalid tenant" in reasons["EXP-HISTORY-DAMAGED-LINK"] assert "without an expense claim link" in reasons["EXP-HISTORY-UNLINKED-CASE"] def test_preview_cursor_provides_stable_keyset_batches() -> None: with build_session() as db: first = build_claim("EXP-HISTORY-001", created_at=CUTOFF - timedelta(days=3)) second = build_claim("EXP-HISTORY-002", created_at=CUTOFF - timedelta(days=2)) db.add_all([first, second]) db.commit() service = ExpenseCaseLegacyBackfillService( db, tenant_id="default", cutoff=CUTOFF, ) page_one = service.preview(batch_size=1) page_two = service.preview(batch_size=1, after=page_one.next_cursor) assert page_one.has_more assert page_one.items[0].claim_no == "EXP-HISTORY-001" assert not page_two.has_more assert page_two.items[0].claim_no == "EXP-HISTORY-002" def test_standard_event_delivery_status_remains_pending_by_default() -> None: with build_session() as db: claim = build_claim("EXP-NORMAL-EVENT") db.add(claim) db.commit() _expense_case, event = ExpenseCaseLegacyBackfillService( db, tenant_id="default", cutoff=CUTOFF, ).expense_cases.record_claim_event( claim, event_type="claim_submitted", actor_id="owner@example.com", tenant_id="default", ) assert event.delivery_status == "pending"