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