Add tenant-safe value, telemetry, connector, commercial, and production-readiness foundations.
380 lines
14 KiB
Python
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
|