386 lines
13 KiB
Python
386 lines
13 KiB
Python
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"
|