from __future__ import annotations import os import re import uuid from collections.abc import Iterator from dataclasses import dataclass from datetime import UTC, date, datetime from decimal import Decimal from pathlib import Path import pytest from alembic.config import Config from sqlalchemy import create_engine from sqlalchemy.engine import make_url from sqlalchemy.orm import Session, sessionmaker import app.models # noqa: F401 - 注册迁移对应的完整 metadata from alembic import command from app.api.deps import CurrentUserContext from app.core.config import get_settings from app.db.schema_ownership import create_legacy_schema from app.models.expense_case import BusinessEvent, ExpenseCase from app.models.financial_record import ExpenseClaim from app.models.savings import ( ProfileBaselineSnapshot, SavingsEvidenceLink, SavingsOpportunity, SavingsRealization, ) from app.models.tenant import Tenant SERVER_DIR = Path(__file__).resolve().parents[1] ALEMBIC_INI_PATH = SERVER_DIR / "alembic.ini" @pytest.fixture(scope="module", name="pg_factory") def _pg_factory_fixture() -> Iterator[sessionmaker[Session]]: database_url = _require_disposable_database_url() previous_database_url = os.environ.get("DATABASE_URL") os.environ["DATABASE_URL"] = database_url get_settings.cache_clear() config = Config(str(ALEMBIC_INI_PATH)) config.set_main_option("sqlalchemy.url", database_url.replace("%", "%%")) command.upgrade(config, "head") engine = create_engine(database_url, pool_pre_ping=True) create_legacy_schema(engine) factory = sessionmaker(bind=engine, expire_on_commit=False) try: yield factory finally: engine.dispose() if previous_database_url is None: os.environ.pop("DATABASE_URL", None) else: os.environ["DATABASE_URL"] = previous_database_url get_settings.cache_clear() @dataclass(frozen=True, slots=True) class _SeededOpportunity: suffix: str tenant_id: str opportunity_id: str expense_case_id: str claim_id: str @dataclass(frozen=True, slots=True) class _SeededRealization(_SeededOpportunity): realization_id: str @dataclass(frozen=True, slots=True) class _SeededPayment(_SeededOpportunity): payment_event_id: str def _seed_opportunity( factory: sessionmaker[Session], *, tenant_id: str = "tenant-savings-concurrency", status: str = "identified", owner_id: str = "finance-owner", benefit_key: str | None = None, claim_id: str | None = None, ) -> _SeededOpportunity: suffix = uuid.uuid4().hex[:12] now = datetime.now(UTC) case_id = str(uuid.uuid4()) baseline_id = str(uuid.uuid4()) opportunity_id = str(uuid.uuid4()) claim_id = claim_id or str(uuid.uuid4()) with factory.begin() as db: db.add_all( [ ExpenseCase( id=case_id, tenant_id=tenant_id, case_no=f"CASE-SAV-{suffix}", scene_code="travel", title="Savings PostgreSQL 并发验证", current_stage="claiming", status="active", created_at=now, updated_at=now, ), ProfileBaselineSnapshot( id=baseline_id, tenant_id=tenant_id, baseline_key=f"baseline-{suffix}", baseline_type="policy_counterfactual", dimension_type="expense_claim_item", dimension_id=f"item-{suffix}", metric_key="pre_adjustment_reimbursable_amount", unit="currency", original_currency="CNY", baseline_value=Decimal("100.0000"), sample_count=1, method="postgres_concurrency_probe", query_fingerprint=f"sha256:{uuid.uuid4().hex}", data_quality_status="complete", data_quality_score=Decimal("1.0000"), quality_issues_json=[], algorithm_version="test-v1", policy_version="policy-test-v1", policy_effective_from=date(2026, 1, 1), target_resource_type="expense_claim_item", target_resource_id=f"item-{suffix}", frozen_at=now, frozen_by="postgres-test", version=1, created_at=now, ), ] ) db.flush() db.add( SavingsOpportunity( id=opportunity_id, tenant_id=tenant_id, opportunity_key=f"opportunity-{suffix}", benefit_key=benefit_key or f"benefit-{suffix}", expense_case_id=case_id, claim_id=claim_id, claim_no_snapshot=f"BX-{suffix}", source_type="standard_adjustment", source_id=f"source-{suffix}", category="policy_compliance", value_kind="cash", title="住宿标准重算", description="真实 PostgreSQL 并发验证机会", exposure_amount=Decimal("100.0000"), baseline_snapshot_id=baseline_id, baseline_amount=Decimal("100.0000"), target_amount=Decimal("0.0000"), estimated_gross=Decimal("100.0000"), estimated_cost=Decimal("0.0000"), estimated_net=Decimal("100.0000"), estimated_low=Decimal("100.0000"), estimated_high=Decimal("100.0000"), confidence=Decimal("1.0000"), currency="CNY", reporting_currency="CNY", attribution_method="server_policy_counterfactual", suggested_action="付款后确认", owner_id=owner_id, owner_name=owner_id, owner_role="finance", status=status, version=2 if status == "realized" else 1, dimension_json={}, baseline_snapshot_json={"baseline_value": "100.0000"}, evidence_json=[], accepted_at=now if status in {"accepted", "in_progress", "realized"} else None, started_at=now if status in {"in_progress", "realized"} else None, realized_at=now if status == "realized" else None, created_at=now, updated_at=now, ) ) return _SeededOpportunity(suffix, tenant_id, opportunity_id, case_id, claim_id) def _seed_pending_realization( factory: sessionmaker[Session], *, tenant_id: str = "tenant-savings-concurrency", benefit_key: str | None = None, owner_id: str, recorder_id: str, ) -> _SeededRealization: seed = _seed_opportunity( factory, tenant_id=tenant_id, status="realized", owner_id=owner_id, benefit_key=benefit_key, ) realization_id = str(uuid.uuid4()) now = datetime.now(UTC) with factory.begin() as db: opportunity = db.get(SavingsOpportunity, seed.opportunity_id) assert opportunity is not None evidence_key = f"evidence-{seed.suffix}" realization = SavingsRealization( id=realization_id, tenant_id=tenant_id, realization_key=f"actual-{seed.suffix}", opportunity_id=seed.opportunity_id, expense_case_id=seed.expense_case_id, claim_id=seed.claim_id, realization_type="actual", realized_at=now, recorded_by_id=recorder_id, recorded_by_name=recorder_id, actual_gross=Decimal("100.0000"), incremental_cost=Decimal("10.0000"), actual_net=Decimal("90.0000"), original_currency="CNY", reporting_amount=Decimal("90.0000"), reporting_currency="CNY", fx_rate=Decimal("1.00000000"), fx_source="same_currency", fx_date=now.date(), fx_version="identity-v1", attribution_method="server_policy_counterfactual", attribution_ratio=Decimal("1.000000"), benefit_key=opportunity.benefit_key, dedupe_status="pending_review", status="pending_confirmation", baseline_snapshot_json={"baseline_value": "100.0000"}, final_snapshot_json={"evidence_level": "business_state"}, evidence_json=[ { "evidence_key": evidence_key, "role": "payment_business_state", "resource_type": "business_event", "verification_status": "unverified", } ], version=1, created_at=now, updated_at=now, ) db.add_all( [ realization, SavingsEvidenceLink( id=str(uuid.uuid4()), tenant_id=tenant_id, evidence_key=evidence_key, entity_type="realization", entity_id=realization_id, realization_id=realization_id, evidence_role="payment_business_state", resource_type="business_event", resource_id=f"payment-evidence-{seed.suffix}", source_system="postgres-test", external_event_id=f"payment-evidence-{seed.suffix}", content_hash=f"sha256:{uuid.uuid4().hex}", occurred_at=now, collected_at=now, verification_status="unverified", metadata_json={"evidence_level": "business_state"}, created_at=now, ), ] ) return _SeededRealization( seed.suffix, seed.tenant_id, seed.opportunity_id, seed.expense_case_id, seed.claim_id, realization_id, ) def _seed_payment_case(factory: sessionmaker[Session]) -> _SeededPayment: claim_id = str(uuid.uuid4()) seed = _seed_opportunity( factory, status="in_progress", owner_id="finance-payment-owner", claim_id=claim_id, ) now = datetime.now(UTC) event_id = str(uuid.uuid4()) with factory.begin() as db: if db.get(Tenant, seed.tenant_id) is None: db.add( Tenant( tenant_id=seed.tenant_id, tenant_code=seed.tenant_id, name="节省并发探针租户", status="active", ) ) db.flush() db.add_all( [ ExpenseClaim( id=claim_id, tenant_id=seed.tenant_id, claim_no=f"BX-PAY-{seed.suffix}", employee_name="付款并发测试员工", department_name="财务部", expense_type="travel", reason="付款并发验证", location="上海", amount=Decimal("100.00"), currency="CNY", invoice_count=1, occurred_at=now, status="paid", risk_flags_json=[], ), BusinessEvent( id=event_id, tenant_id=seed.tenant_id, expense_case_id=seed.expense_case_id, aggregate_type="expense_claim", aggregate_id=claim_id, event_type="payment_completed", event_version=1, idempotency_key=f"payment-{seed.suffix}", correlation_id=f"payment-{seed.suffix}", actor_id="payment-actor", actor_type="user", payload_json={}, delivery_status="pending", occurred_at=now, ), ] ) return _SeededPayment( seed.suffix, seed.tenant_id, seed.opportunity_id, seed.expense_case_id, seed.claim_id, event_id, ) def _user( username: str, tenant_id: str, *, employee_id: str = "", roles: list[str] | None = None, ) -> CurrentUserContext: return CurrentUserContext( username=username, name=username, role_codes=list(roles or []), is_admin=False, tenant_id=tenant_id, employee_id=employee_id, ) def _require_disposable_database_url() -> str: database_url = os.environ.get("MIGRATION_TEST_DATABASE_URL", "").strip() if not database_url: pytest.skip("仅在显式配置 MIGRATION_TEST_DATABASE_URL 时运行 PostgreSQL 并发测试") parsed = make_url(database_url) host = re.sub(r"[^a-z0-9]+", "-", str(parsed.host or "").lower()).strip("-") database = re.sub(r"[^a-z0-9]+", "-", str(parsed.database or "").lower()).strip("-") if parsed.get_backend_name() != "postgresql": raise RuntimeError("Savings 并发测试只允许连接 PostgreSQL 一次性数据库") allowed_hosts = host.startswith( ("migration-probe", "disposable-probe", "x-financial-disposable-probe") ) if not allowed_hosts or not database.startswith(("migration-probe", "disposable-probe")): raise RuntimeError("Savings 并发测试数据库主机和库名必须使用 disposable-probe 前缀") return database_url