feat(platform): close AI expense value loop
Add tenant-safe value, telemetry, connector, commercial, and production-readiness foundations.
This commit is contained in:
466
server/tests/test_savings_ledger_services.py
Normal file
466
server/tests/test_savings_ledger_services.py
Normal file
@@ -0,0 +1,466 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, date, datetime
|
||||
from decimal import Decimal
|
||||
|
||||
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.api.deps import CurrentUserContext
|
||||
from app.db.base_class import Base
|
||||
from app.models.expense_case import BusinessEvent, ExpenseCase
|
||||
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
|
||||
from app.models.savings import (
|
||||
ProfileBaselineSnapshot,
|
||||
SavingsEvent,
|
||||
SavingsOpportunity,
|
||||
SavingsRealization,
|
||||
)
|
||||
from app.schemas.savings import (
|
||||
SavingsEvidenceCreate,
|
||||
SavingsOpportunityActionCreate,
|
||||
SavingsRealizationActionCreate,
|
||||
SavingsRealizationCreate,
|
||||
)
|
||||
from app.services.savings_access_policy import SavingsPermissionError
|
||||
from app.services.savings_actions import SavingsActionService
|
||||
from app.services.savings_discovery import SavingsDiscoveryService
|
||||
from app.services.savings_protocol import SavingsIdempotencyConflictError
|
||||
from app.services.savings_query import SavingsQueryService
|
||||
from app.services.savings_realization import SavingsRealizationService
|
||||
|
||||
|
||||
@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_query_is_tenant_scoped_and_owner_scoped(db: Session) -> None:
|
||||
first = _seed_opportunity(db, tenant_id="tenant-a", owner_id="owner-a")
|
||||
_seed_opportunity(
|
||||
db,
|
||||
tenant_id="tenant-a",
|
||||
owner_id="owner-b",
|
||||
department_id="D-2",
|
||||
)
|
||||
_seed_opportunity(db, tenant_id="tenant-b", owner_id="owner-a")
|
||||
db.commit()
|
||||
|
||||
finance_result = SavingsQueryService(db).list_opportunities(
|
||||
_user("finance-a", tenant_id="tenant-a", roles=["finance"])
|
||||
)
|
||||
assert finance_result.total == 2
|
||||
assert first.id in {item.id for item in finance_result.items}
|
||||
|
||||
owner_result = SavingsQueryService(db).list_opportunities(
|
||||
_user("owner-a", tenant_id="tenant-a", employee_id="owner-a")
|
||||
)
|
||||
assert owner_result.total == 1
|
||||
scoped_result = SavingsQueryService(db).list_opportunities(
|
||||
_user(
|
||||
"budget-a",
|
||||
tenant_id="tenant-a",
|
||||
roles=["budget_monitor"],
|
||||
department_id="D-1",
|
||||
)
|
||||
)
|
||||
assert scoped_result.total == 1
|
||||
assert scoped_result.items[0].id == first.id
|
||||
assert (
|
||||
SavingsQueryService(db).get_opportunity(
|
||||
first.id,
|
||||
_user("outsider", tenant_id="tenant-b", roles=["finance"]),
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
def test_opportunity_action_is_versioned_and_idempotent(db: Session) -> None:
|
||||
opportunity = _seed_opportunity(db, status="identified", owner_id="owner-a")
|
||||
db.commit()
|
||||
owner = _user("owner-a", employee_id="owner-a")
|
||||
payload = SavingsOpportunityActionCreate(
|
||||
action="accept",
|
||||
request_id="accept-request-001",
|
||||
expected_version=1,
|
||||
comment="确认接受该节省机会",
|
||||
)
|
||||
|
||||
first = SavingsActionService(db).execute(opportunity.id, payload, owner)
|
||||
replay = SavingsActionService(db).execute(opportunity.id, payload, owner)
|
||||
|
||||
assert first.response.opportunity.status == "accepted"
|
||||
assert first.response.opportunity.version == 2
|
||||
assert replay.response.replayed is True
|
||||
assert db.scalar(
|
||||
select(func.count(SavingsEvent.id)).where(
|
||||
SavingsEvent.actor_id == "owner-a",
|
||||
SavingsEvent.request_id == payload.request_id,
|
||||
)
|
||||
) == 1
|
||||
|
||||
with pytest.raises(SavingsIdempotencyConflictError):
|
||||
SavingsActionService(db).execute(
|
||||
opportunity.id,
|
||||
payload.model_copy(update={"comment": "使用同一请求号篡改内容"}),
|
||||
owner,
|
||||
)
|
||||
|
||||
|
||||
def test_actual_requires_independent_finance_confirmation_and_appends_reversal(
|
||||
db: Session,
|
||||
) -> None:
|
||||
opportunity = _seed_opportunity(
|
||||
db,
|
||||
status="in_progress",
|
||||
owner_id="owner-a",
|
||||
accepted_at=datetime.now(UTC),
|
||||
started_at=datetime.now(UTC),
|
||||
)
|
||||
db.commit()
|
||||
owner = _user("owner-a", employee_id="owner-a")
|
||||
record_payload = SavingsRealizationCreate(
|
||||
request_id="record-request-001",
|
||||
expected_version=1,
|
||||
comment="付款完成后登记实际结果",
|
||||
actual_gross=Decimal("100.00"),
|
||||
incremental_cost=Decimal("10.00"),
|
||||
currency="CNY",
|
||||
realized_at=datetime.now(UTC),
|
||||
attribution_method="server_policy_counterfactual",
|
||||
attribution_ratio=Decimal("1"),
|
||||
evidence_level="business_state",
|
||||
evidence=[_result_evidence("record-request-001")],
|
||||
)
|
||||
recorded = SavingsRealizationService(db).record(
|
||||
opportunity.id,
|
||||
record_payload,
|
||||
owner,
|
||||
)
|
||||
realization_id = recorded.response.realization.id
|
||||
assert recorded.response.realization.status == "pending_confirmation"
|
||||
assert recorded.response.opportunity.status == "realized"
|
||||
|
||||
confirm_payload = SavingsRealizationActionCreate(
|
||||
action="confirm",
|
||||
request_id="confirm-request-001",
|
||||
expected_version=1,
|
||||
comment="已复核政策、付款业务状态及归因",
|
||||
)
|
||||
with pytest.raises(SavingsPermissionError):
|
||||
SavingsRealizationService(db).execute_action(
|
||||
realization_id,
|
||||
confirm_payload,
|
||||
owner,
|
||||
)
|
||||
with pytest.raises(SavingsPermissionError):
|
||||
SavingsRealizationService(db).execute_action(
|
||||
realization_id,
|
||||
confirm_payload,
|
||||
_user("platform-admin", is_admin=True),
|
||||
)
|
||||
|
||||
confirmer = _user("finance-b", employee_id="finance-b", roles=["finance"])
|
||||
confirmed = SavingsRealizationService(db).execute_action(
|
||||
realization_id,
|
||||
confirm_payload,
|
||||
confirmer,
|
||||
)
|
||||
assert confirmed.response.realization.status == "finance_confirmed"
|
||||
assert confirmed.response.realization.dedupe_status == "canonical"
|
||||
assert confirmed.response.realization.evidence_json[0]["verification_status"] == "verified"
|
||||
assert confirmed.response.opportunity.status == "verified"
|
||||
|
||||
reversed_result = SavingsRealizationService(db).execute_action(
|
||||
realization_id,
|
||||
SavingsRealizationActionCreate(
|
||||
action="reverse",
|
||||
request_id="reverse-request-001",
|
||||
expected_version=2,
|
||||
comment="员工申诉补付,全额冲回原节省",
|
||||
reversal_amount=Decimal("90.00"),
|
||||
),
|
||||
confirmer,
|
||||
)
|
||||
original = db.get(SavingsRealization, realization_id)
|
||||
reversal = db.get(SavingsRealization, reversed_result.response.realization.id)
|
||||
assert original is not None and original.status == "finance_confirmed"
|
||||
assert original.reversed_at is not None
|
||||
assert reversal is not None and reversal.realization_type == "reversal"
|
||||
assert reversal.actual_net == Decimal("-90.0000")
|
||||
assert reversed_result.response.opportunity.status == "reversed"
|
||||
|
||||
|
||||
def test_payment_realization_is_idempotent_and_stays_pending_confirmation(
|
||||
db: Session,
|
||||
) -> None:
|
||||
opportunity = _seed_opportunity(
|
||||
db,
|
||||
status="in_progress",
|
||||
owner_id="finance",
|
||||
accepted_at=datetime.now(UTC),
|
||||
started_at=datetime.now(UTC),
|
||||
)
|
||||
claim = _seed_claim(db, claim_id=opportunity.claim_id)
|
||||
payment_event = BusinessEvent(
|
||||
id=str(uuid.uuid4()),
|
||||
tenant_id="default",
|
||||
expense_case_id=opportunity.expense_case_id,
|
||||
aggregate_type="expense_claim",
|
||||
aggregate_id=claim.id,
|
||||
event_type="payment_completed",
|
||||
event_version=1,
|
||||
idempotency_key="payment-event-001",
|
||||
correlation_id="payment-event-001",
|
||||
actor_id="payer",
|
||||
actor_type="user",
|
||||
payload_json={},
|
||||
delivery_status="pending",
|
||||
occurred_at=datetime.now(UTC),
|
||||
)
|
||||
db.add(payment_event)
|
||||
db.commit()
|
||||
payer = _user("payer", employee_id="payer", roles=["finance"])
|
||||
|
||||
first = SavingsRealizationService(db).realize_paid_claim(
|
||||
claim,
|
||||
payment_event,
|
||||
payer,
|
||||
)
|
||||
second = SavingsRealizationService(db).realize_paid_claim(
|
||||
claim,
|
||||
payment_event,
|
||||
payer,
|
||||
)
|
||||
db.commit()
|
||||
|
||||
assert len(first) == 1
|
||||
assert second == []
|
||||
assert first[0].status == "pending_confirmation"
|
||||
assert first[0].dedupe_status == "pending_review"
|
||||
assert db.scalar(
|
||||
select(func.count(SavingsRealization.id)).where(
|
||||
SavingsRealization.opportunity_id == opportunity.id
|
||||
)
|
||||
) == 1
|
||||
|
||||
|
||||
def test_standard_adjustment_discovery_freezes_baseline_and_evidence(db: Session) -> None:
|
||||
claim = _seed_claim(db)
|
||||
item = ExpenseClaimItem(
|
||||
id=str(uuid.uuid4()),
|
||||
claim_id=claim.id,
|
||||
item_date=date(2026, 7, 10),
|
||||
item_type="hotel",
|
||||
item_reason="上海住宿 2 晚",
|
||||
item_location="上海",
|
||||
item_note="",
|
||||
item_amount=Decimal("1200.00"),
|
||||
)
|
||||
db.add(item)
|
||||
db.flush()
|
||||
flag = {
|
||||
"item_id": item.id,
|
||||
"message": "服务端按政策把 1200 元调整为 800 元",
|
||||
"original_amount": "1200.00",
|
||||
"reimbursable_amount": "800.00",
|
||||
"employee_absorbed_amount": "400.00",
|
||||
"policy_rule_version": "v1.2.0",
|
||||
"policy_rule_version_source": "published",
|
||||
"policy_grade": "P6",
|
||||
"policy_matched_city": "上海",
|
||||
"calculation_fingerprint": "sha256:" + "a" * 64,
|
||||
}
|
||||
|
||||
opportunities = SavingsDiscoveryService(db).discover_standard_adjustments(
|
||||
claim=claim,
|
||||
items_by_id={item.id: item},
|
||||
adjustment_flags=[flag],
|
||||
current_user=_user("employee-a", employee_id="employee-a"),
|
||||
request_id="standard-adjustment-001",
|
||||
)
|
||||
replay = SavingsDiscoveryService(db).discover_standard_adjustments(
|
||||
claim=claim,
|
||||
items_by_id={item.id: item},
|
||||
adjustment_flags=[flag],
|
||||
current_user=_user("employee-a", employee_id="employee-a"),
|
||||
request_id="standard-adjustment-001",
|
||||
)
|
||||
db.commit()
|
||||
|
||||
assert len(opportunities) == 1
|
||||
assert replay[0].id == opportunities[0].id
|
||||
assert opportunities[0].status == "in_progress"
|
||||
assert opportunities[0].estimated_net == Decimal("400.0000")
|
||||
assert opportunities[0].baseline_snapshot.policy_version == "v1.2.0"
|
||||
assert opportunities[0].baseline_snapshot.baseline_value == Decimal("1200.0000")
|
||||
assert opportunities[0].baseline_snapshot.data_quality_status == "complete"
|
||||
assert len(opportunities[0].evidence_links) == 1
|
||||
|
||||
|
||||
def _seed_opportunity(
|
||||
db: Session,
|
||||
*,
|
||||
tenant_id: str = "default",
|
||||
status: str = "identified",
|
||||
owner_id: str = "finance",
|
||||
accepted_at: datetime | None = None,
|
||||
started_at: datetime | None = None,
|
||||
department_id: str = "D-1",
|
||||
) -> SavingsOpportunity:
|
||||
now = datetime.now(UTC)
|
||||
case = ExpenseCase(
|
||||
id=str(uuid.uuid4()),
|
||||
tenant_id=tenant_id,
|
||||
case_no=f"CASE-{uuid.uuid4().hex[:12]}",
|
||||
scene_code="travel",
|
||||
title="节省测试费用事件",
|
||||
current_stage="claiming",
|
||||
status="active",
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
baseline = ProfileBaselineSnapshot(
|
||||
id=str(uuid.uuid4()),
|
||||
tenant_id=tenant_id,
|
||||
baseline_key=f"baseline-{uuid.uuid4()}",
|
||||
baseline_type="policy_counterfactual",
|
||||
dimension_type="expense_claim_item",
|
||||
dimension_id=str(uuid.uuid4()),
|
||||
metric_key="pre_adjustment_reimbursable_amount",
|
||||
unit="currency",
|
||||
original_currency="CNY",
|
||||
baseline_value=Decimal("100.00"),
|
||||
sample_count=1,
|
||||
method="test_policy",
|
||||
query_fingerprint="sha256:" + uuid.uuid4().hex,
|
||||
data_quality_status="complete",
|
||||
data_quality_score=Decimal("1"),
|
||||
quality_issues_json=[],
|
||||
algorithm_version="test-v1",
|
||||
policy_version="policy-v1",
|
||||
policy_effective_from=date(2026, 1, 1),
|
||||
target_resource_type="expense_claim_item",
|
||||
target_resource_id=str(uuid.uuid4()),
|
||||
frozen_at=now,
|
||||
frozen_by="test",
|
||||
version=1,
|
||||
created_at=now,
|
||||
)
|
||||
claim_id = str(uuid.uuid4())
|
||||
opportunity = SavingsOpportunity(
|
||||
id=str(uuid.uuid4()),
|
||||
tenant_id=tenant_id,
|
||||
opportunity_key=f"opportunity-{uuid.uuid4()}",
|
||||
benefit_key=f"benefit-{uuid.uuid4()}",
|
||||
expense_case_id=case.id,
|
||||
claim_id=claim_id,
|
||||
claim_no_snapshot=f"BX-{uuid.uuid4().hex[:8]}",
|
||||
source_type="standard_adjustment",
|
||||
source_id=str(uuid.uuid4()),
|
||||
category="policy_compliance",
|
||||
value_kind="cash",
|
||||
title="住宿标准重算",
|
||||
description="测试机会",
|
||||
exposure_amount=Decimal("100.00"),
|
||||
baseline_snapshot_id=baseline.id,
|
||||
baseline_amount=Decimal("100.00"),
|
||||
target_amount=Decimal("0.00"),
|
||||
estimated_gross=Decimal("100.00"),
|
||||
estimated_cost=Decimal("0.00"),
|
||||
estimated_net=Decimal("100.00"),
|
||||
estimated_low=Decimal("100.00"),
|
||||
estimated_high=Decimal("100.00"),
|
||||
confidence=Decimal("1"),
|
||||
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=1,
|
||||
dimension_json={"department_id": department_id, "city": "上海"},
|
||||
baseline_snapshot_json={"baseline_value": "100.00"},
|
||||
evidence_json=[],
|
||||
accepted_at=accepted_at,
|
||||
started_at=started_at,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
db.add_all([case, baseline, opportunity])
|
||||
db.flush()
|
||||
return opportunity
|
||||
|
||||
|
||||
def _seed_claim(db: Session, *, claim_id: str | None = None) -> ExpenseClaim:
|
||||
now = datetime.now(UTC)
|
||||
claim = ExpenseClaim(
|
||||
id=claim_id or str(uuid.uuid4()),
|
||||
claim_no=f"BX-{uuid.uuid4().hex[:10]}",
|
||||
employee_name="测试员工",
|
||||
department_name="财务部",
|
||||
project_code="P-001",
|
||||
expense_type="travel",
|
||||
reason="差旅",
|
||||
location="上海",
|
||||
amount=Decimal("1200.00"),
|
||||
currency="CNY",
|
||||
invoice_count=1,
|
||||
occurred_at=now,
|
||||
status="draft",
|
||||
risk_flags_json=[],
|
||||
)
|
||||
db.add(claim)
|
||||
db.flush()
|
||||
return claim
|
||||
|
||||
|
||||
def _result_evidence(key: str) -> SavingsEvidenceCreate:
|
||||
return SavingsEvidenceCreate(
|
||||
evidence_key=f"evidence-{key}",
|
||||
evidence_role="payment_business_state",
|
||||
resource_type="business_event",
|
||||
resource_id=f"payment-{key}",
|
||||
source_system="x-financial",
|
||||
external_event_id=f"payment-{key}",
|
||||
content_hash="c" * 64,
|
||||
occurred_at=datetime.now(UTC),
|
||||
verification_status="unverified",
|
||||
metadata_json={"source": "ledger-service-test"},
|
||||
)
|
||||
|
||||
|
||||
def _user(
|
||||
username: str,
|
||||
*,
|
||||
tenant_id: str = "default",
|
||||
employee_id: str = "",
|
||||
roles: list[str] | None = None,
|
||||
is_admin: bool = False,
|
||||
department_id: str = "",
|
||||
) -> CurrentUserContext:
|
||||
return CurrentUserContext(
|
||||
username=username,
|
||||
name=username,
|
||||
role_codes=list(roles or []),
|
||||
is_admin=is_admin,
|
||||
tenant_id=tenant_id,
|
||||
employee_id=employee_id,
|
||||
department_id=department_id,
|
||||
)
|
||||
Reference in New Issue
Block a user