feat(platform): close AI expense value loop
Add tenant-safe value, telemetry, connector, commercial, and production-readiness foundations.
This commit is contained in:
379
server/tests/savings_postgres_testkit.py
Normal file
379
server/tests/savings_postgres_testkit.py
Normal file
@@ -0,0 +1,379 @@
|
||||
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
|
||||
Reference in New Issue
Block a user