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