Files
X-Financial/server/tests/savings_postgres_testkit.py
caoxiaozhu 787bc3a481 feat(platform): close AI expense value loop
Add tenant-safe value, telemetry, connector, commercial, and production-readiness foundations.
2026-07-17 14:14:08 +08:00

380 lines
14 KiB
Python

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