Files
X-Financial/server/tests/test_standard_adjustment_savings_backfill.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

355 lines
11 KiB
Python

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