from __future__ import annotations import argparse import importlib.util import uuid from datetime import UTC, date, datetime 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 import app.models # noqa: F401 - 注册完整 metadata from app.cli.savings_standard_adjustment_backfill import ( StandardAdjustmentBackfillDisposition, StandardAdjustmentSavingsBackfillService, ) from app.db.base_class import Base from app.models.expense_case import BusinessEvent, ExpenseCase, ExpenseCaseLink from app.models.financial_record import ExpenseClaim, ExpenseClaimItem from app.models.savings import ( ProfileBaselineSnapshot, SavingsEvent, SavingsEvidenceLink, SavingsOpportunity, ) from app.services.expense_claim_standard_adjustment import ExpenseClaimStandardAdjustmentMixin @pytest.fixture() def db() -> Session: engine = create_engine( "sqlite+pysqlite:///:memory:", connect_args={"check_same_thread": False}, poolclass=StaticPool, ) Base.metadata.create_all(engine) factory = sessionmaker(bind=engine, expire_on_commit=False) with factory() as session: yield session Base.metadata.drop_all(engine) engine.dispose() def test_dry_run_reports_eligible_without_any_write(db: Session) -> None: _seed_adjusted_claim(db, tenant_id="tenant-a") db.commit() before = _write_counts(db) preview = StandardAdjustmentSavingsBackfillService( db, tenant_id="tenant-a", ).preview() assert preview.claims_inspected == 1 assert preview.flags_inspected == 1 assert preview.eligible == 1 assert preview.replayed == 0 assert preview.skipped == 0 assert preview.reasons == {"eligible": 1} assert _write_counts(db) == before assert not db.new assert not db.dirty def test_apply_creates_once_and_second_apply_is_replayed(db: Session) -> None: claim, item = _seed_adjusted_claim(db, tenant_id="tenant-a") db.commit() service = StandardAdjustmentSavingsBackfillService(db, tenant_id="tenant-a") applied = service.apply_batch(run_id="run-001") db.commit() assert applied.created == 1 assert applied.replayed == 0 assert applied.skipped == 0 opportunity = db.scalar( select(SavingsOpportunity).where( SavingsOpportunity.tenant_id == "tenant-a", SavingsOpportunity.claim_id == claim.id, ) ) assert opportunity is not None assert opportunity.claim_item_id == item.id assert opportunity.estimated_net == Decimal("400.0000") assert opportunity.status == "in_progress" assert db.scalar( select(func.count(SavingsEvidenceLink.id)).where( SavingsEvidenceLink.tenant_id == "tenant-a" ) ) == 2 counts_after_first = _write_counts(db) replayed = service.apply_batch(run_id="run-002") db.commit() assert replayed.created == 0 assert replayed.replayed == 1 assert replayed.skipped == 0 assert replayed.items[0].opportunity_id == opportunity.id assert _write_counts(db) == counts_after_first def test_apply_skips_cross_tenant_missing_case_and_missing_evidence(db: Session) -> None: _seed_adjusted_claim(db, tenant_id="tenant-b") _seed_adjusted_claim(db, tenant_id=None) _seed_adjusted_claim( db, tenant_id="tenant-a", flag_overrides={"policy_rule_version": "", "calculation_fingerprint": ""}, ) db.commit() service = StandardAdjustmentSavingsBackfillService(db, tenant_id="tenant-a") preview = service.preview() dispositions = {item.disposition for item in preview.items} assert StandardAdjustmentBackfillDisposition.TENANT_CONFLICT in dispositions assert StandardAdjustmentBackfillDisposition.MISSING_CASE_LINK in dispositions assert StandardAdjustmentBackfillDisposition.MISSING_POLICY_VERSION in dispositions assert preview.eligible == 0 assert preview.skipped == 3 applied = service.apply_batch(run_id="run-quality-report") db.commit() assert applied.created == 0 assert applied.replayed == 0 assert applied.skipped == 3 assert db.scalar(select(func.count(SavingsOpportunity.id))) == 0 assert db.scalar(select(func.count(ProfileBaselineSnapshot.id))) == 0 def test_tampered_amount_or_policy_snapshot_is_never_monetized(db: Session) -> None: _seed_adjusted_claim( db, tenant_id="tenant-a", flag_overrides={"original_amount": "9999.00"}, ) _seed_adjusted_claim( db, tenant_id="tenant-a", flag_overrides={"policy_hotel_rate": "451.00"}, ) db.commit() preview = StandardAdjustmentSavingsBackfillService( db, tenant_id="tenant-a", ).preview() dispositions = [item.disposition for item in preview.items] assert StandardAdjustmentBackfillDisposition.ORIGINAL_AMOUNT_MISMATCH in dispositions assert ( StandardAdjustmentBackfillDisposition.CALCULATION_FINGERPRINT_MISMATCH in dispositions ) assert preview.eligible == 0 def test_cli_defaults_to_dry_run_and_apply_requires_target_confirmation( monkeypatch: pytest.MonkeyPatch, ) -> None: module = _load_cli() args = module.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 = module.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(module.BackfillCommandError) as exc_info: module.run(apply_args) assert exc_info.value.code == "confirm_target_required" @pytest.mark.parametrize( ("current_revision", "expected"), [ ("20260716_0015", True), ("20260716_0016", True), ("20260716_0017", True), ("20260716_0014", False), (None, False), ], ) def test_cli_accepts_any_migration_descended_from_required_savings_revision( current_revision: str | None, expected: bool, ) -> None: module = _load_cli() assert module.revision_contains_required(current_revision) is expected @pytest.mark.parametrize("value", ["", "2026-07-16T00:00:00", "not-a-time"]) def test_cli_rejects_timestamp_without_explicit_timezone(value: str) -> None: with pytest.raises(argparse.ArgumentTypeError): _load_cli().parse_timestamp(value) def _seed_adjusted_claim( db: Session, *, tenant_id: str | None, flag_overrides: dict[str, object] | None = None, ) -> tuple[ExpenseClaim, ExpenseClaimItem]: claim_id = str(uuid.uuid4()) item_id = str(uuid.uuid4()) suffix = uuid.uuid4().hex[:10] policy_result = { "days": 2, "location": "上海市", "matched_city": "上海", "grade": "P6", "grade_band": "P6-P7", "hotel_rate": Decimal("400.00"), "hotel_amount": Decimal("800.00"), "rule_name": "差旅住宿标准", "rule_version": "hotel-policy-v3", } flag: dict[str, object] = { "source": "reimbursement_standard_adjustment", "event_type": "standard_adjustment_accepted", "calculation_source": "server_policy", "item_id": item_id, "original_amount": "1200.00", "reimbursable_amount": "800.00", "employee_absorbed_amount": "400.00", "policy_days": policy_result["days"], "policy_location": policy_result["location"], "policy_matched_city": policy_result["matched_city"], "policy_grade": policy_result["grade"], "policy_grade_band": policy_result["grade_band"], "policy_hotel_rate": "400.00", "policy_hotel_amount": "800.00", "policy_rule_name": policy_result["rule_name"], "policy_rule_version": policy_result["rule_version"], "policy_rule_version_source": "finance_rules_content_hash", } flag["calculation_fingerprint"] = ( ExpenseClaimStandardAdjustmentMixin._standard_adjustment_calculation_fingerprint( item_id=item_id, original_amount=Decimal("1200.00"), reimbursable_amount=Decimal("800.00"), policy_result=policy_result, ) ) flag.update(flag_overrides or {}) claim = ExpenseClaim( id=claim_id, claim_no=f"BX-BACKFILL-{suffix}", employee_name="历史员工", department_name="销售部", project_code="PRJ-BACKFILL", expense_type="hotel", reason="历史住宿报销", location="上海", amount=Decimal("800.00"), currency="CNY", invoice_count=1, occurred_at=datetime(2026, 6, 1, 9, 0, tzinfo=UTC), submitted_at=datetime(2026, 6, 2, 9, 0, tzinfo=UTC), status="submitted", approval_stage="财务审批", risk_flags_json=[flag], created_at=datetime(2026, 6, 1, 8, 0, tzinfo=UTC), updated_at=datetime(2026, 6, 2, 8, 0, tzinfo=UTC), ) item = ExpenseClaimItem( id=item_id, claim=claim, item_date=date(2026, 6, 1), item_type="hotel", item_reason="上海住宿 2 晚", item_location="上海", item_note="", item_amount=Decimal("1200.00"), ) db.add(claim) if tenant_id is not None: expense_case = ExpenseCase( id=str(uuid.uuid4()), tenant_id=tenant_id, case_no=f"CASE-{suffix}", scene_code="travel", title="历史住宿费用事件", current_stage="claiming", status="active", ) db.add_all( [ expense_case, ExpenseCaseLink( id=str(uuid.uuid4()), tenant_id=tenant_id, expense_case_id=expense_case.id, resource_type="expense_claim", resource_id=claim.id, relation_type="reimbursement", ), ] ) return claim, item def _write_counts(db: Session) -> tuple[int, ...]: return tuple( int(db.scalar(select(func.count()).select_from(model)) or 0) for model in ( SavingsOpportunity, ProfileBaselineSnapshot, SavingsEvidenceLink, SavingsEvent, BusinessEvent, ) ) def _load_cli(): path = ( Path(__file__).resolve().parents[1] / "scripts" / "backfill_standard_adjustment_savings.py" ) spec = importlib.util.spec_from_file_location("backfill_standard_adjustment_savings_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