320 lines
9.8 KiB
Python
320 lines
9.8 KiB
Python
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import uuid
|
||
|
|
from datetime import UTC, date, datetime, timedelta
|
||
|
|
from decimal import Decimal
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
from sqlalchemy import create_engine
|
||
|
|
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.financial_record import ExpenseClaim, ExpenseClaimItem
|
||
|
|
from app.schemas.cfo_value import CfoValueFiltersRead
|
||
|
|
from app.schemas.savings import (
|
||
|
|
SavingsEvidenceCreate,
|
||
|
|
SavingsRealizationActionCreate,
|
||
|
|
SavingsRealizationCreate,
|
||
|
|
)
|
||
|
|
from app.services.cfo_value_analytics import CfoValueAnalyticsService
|
||
|
|
from app.services.savings_access_policy import SavingsPermissionError
|
||
|
|
from app.services.savings_discovery import SavingsDiscoveryService
|
||
|
|
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_cfo_value_counts_only_confirmed_canonical_and_replays_as_of(db: Session) -> None:
|
||
|
|
operator = _user("finance-operator", roles=["finance"])
|
||
|
|
confirmer = _user("finance-confirmer", roles=["finance"])
|
||
|
|
cny_opportunity = _discover(db, operator, currency="CNY", saving=Decimal("200"))
|
||
|
|
usd_opportunity = _discover(db, operator, currency="USD", saving=Decimal("50"))
|
||
|
|
db.commit()
|
||
|
|
|
||
|
|
cny_realization = (
|
||
|
|
SavingsRealizationService(db)
|
||
|
|
.record(
|
||
|
|
cny_opportunity,
|
||
|
|
_record_payload("record-cny-001", Decimal("200"), "CNY"),
|
||
|
|
operator,
|
||
|
|
)
|
||
|
|
.response.realization
|
||
|
|
)
|
||
|
|
usd_realization = (
|
||
|
|
SavingsRealizationService(db)
|
||
|
|
.record(
|
||
|
|
usd_opportunity,
|
||
|
|
_record_payload("record-usd-001", Decimal("50"), "USD"),
|
||
|
|
operator,
|
||
|
|
)
|
||
|
|
.response.realization
|
||
|
|
)
|
||
|
|
|
||
|
|
start = datetime.now(UTC) - timedelta(days=2)
|
||
|
|
end = datetime.now(UTC) + timedelta(days=2)
|
||
|
|
pending_dashboard = CfoValueAnalyticsService(db).build_dashboard(
|
||
|
|
confirmer,
|
||
|
|
start=start,
|
||
|
|
end=end,
|
||
|
|
as_of=datetime.now(UTC),
|
||
|
|
filters=CfoValueFiltersRead(),
|
||
|
|
)
|
||
|
|
assert pending_dashboard.kpis.verified_cash.status == "empty"
|
||
|
|
assert pending_dashboard.data_quality.pending_confirmation_count == 2
|
||
|
|
assert _stage(pending_dashboard, "actual_pending").count == 2
|
||
|
|
|
||
|
|
confirmed_cny = (
|
||
|
|
SavingsRealizationService(db)
|
||
|
|
.execute_action(
|
||
|
|
cny_realization.id,
|
||
|
|
SavingsRealizationActionCreate(
|
||
|
|
action="confirm",
|
||
|
|
request_id="confirm-cny-001",
|
||
|
|
expected_version=1,
|
||
|
|
comment="独立复核 CNY 结果与归因",
|
||
|
|
),
|
||
|
|
confirmer,
|
||
|
|
)
|
||
|
|
.response
|
||
|
|
)
|
||
|
|
SavingsRealizationService(db).execute_action(
|
||
|
|
usd_realization.id,
|
||
|
|
SavingsRealizationActionCreate(
|
||
|
|
action="confirm",
|
||
|
|
request_id="confirm-usd-001",
|
||
|
|
expected_version=1,
|
||
|
|
comment="独立复核 USD 结果与归因",
|
||
|
|
),
|
||
|
|
confirmer,
|
||
|
|
)
|
||
|
|
confirmed_as_of = confirmed_cny.event.occurred_at
|
||
|
|
|
||
|
|
current = CfoValueAnalyticsService(db).build_dashboard(
|
||
|
|
confirmer,
|
||
|
|
start=start,
|
||
|
|
end=end,
|
||
|
|
as_of=datetime.now(UTC),
|
||
|
|
filters=CfoValueFiltersRead(),
|
||
|
|
)
|
||
|
|
assert _money(current.kpis.verified_cash.values) == {
|
||
|
|
"CNY": Decimal("200.0000"),
|
||
|
|
"USD": Decimal("50.0000"),
|
||
|
|
}
|
||
|
|
assert current.kpis.releasable_labor.status == "collecting"
|
||
|
|
assert current.kpis.safe_straight_through.status == "collecting"
|
||
|
|
assert (
|
||
|
|
next(
|
||
|
|
item for item in current.guardrails if item.key == "confirmed_high_risk_exposure"
|
||
|
|
).status
|
||
|
|
== "unavailable"
|
||
|
|
)
|
||
|
|
|
||
|
|
SavingsRealizationService(db).execute_action(
|
||
|
|
cny_realization.id,
|
||
|
|
SavingsRealizationActionCreate(
|
||
|
|
action="reverse",
|
||
|
|
request_id="reverse-cny-001",
|
||
|
|
expected_version=2,
|
||
|
|
comment="补付后全额冲回 CNY 节省",
|
||
|
|
reversal_amount=Decimal("200"),
|
||
|
|
),
|
||
|
|
confirmer,
|
||
|
|
)
|
||
|
|
latest = CfoValueAnalyticsService(db).build_dashboard(
|
||
|
|
confirmer,
|
||
|
|
start=start,
|
||
|
|
end=end,
|
||
|
|
as_of=datetime.now(UTC),
|
||
|
|
filters=CfoValueFiltersRead(),
|
||
|
|
)
|
||
|
|
assert _money(latest.kpis.verified_cash.values) == {
|
||
|
|
"CNY": Decimal("0.0000"),
|
||
|
|
"USD": Decimal("50.0000"),
|
||
|
|
}
|
||
|
|
assert _stage(latest, "reversed").count == 1
|
||
|
|
|
||
|
|
historical = CfoValueAnalyticsService(db).build_dashboard(
|
||
|
|
confirmer,
|
||
|
|
start=start,
|
||
|
|
end=end,
|
||
|
|
as_of=confirmed_as_of,
|
||
|
|
filters=CfoValueFiltersRead(),
|
||
|
|
)
|
||
|
|
assert _money(historical.kpis.verified_cash.values)["CNY"] == Decimal("200.0000")
|
||
|
|
|
||
|
|
with pytest.raises(SavingsPermissionError):
|
||
|
|
CfoValueAnalyticsService(db).build_dashboard(
|
||
|
|
_user("employee"),
|
||
|
|
start=start,
|
||
|
|
end=end,
|
||
|
|
as_of=datetime.now(UTC),
|
||
|
|
filters=CfoValueFiltersRead(),
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def test_cfo_value_budget_monitor_is_limited_to_own_department(db: Session) -> None:
|
||
|
|
operator = _user("finance-operator", roles=["finance"])
|
||
|
|
_discover(
|
||
|
|
db,
|
||
|
|
operator,
|
||
|
|
currency="CNY",
|
||
|
|
saving=Decimal("100"),
|
||
|
|
department_name="销售部",
|
||
|
|
)
|
||
|
|
_discover(
|
||
|
|
db,
|
||
|
|
operator,
|
||
|
|
currency="CNY",
|
||
|
|
saving=Decimal("300"),
|
||
|
|
department_name="研发部",
|
||
|
|
)
|
||
|
|
db.commit()
|
||
|
|
dashboard = CfoValueAnalyticsService(db).build_dashboard(
|
||
|
|
_user("budget-sales", roles=["budget_monitor"], department_name="销售部"),
|
||
|
|
start=datetime.now(UTC) - timedelta(days=1),
|
||
|
|
end=datetime.now(UTC) + timedelta(days=1),
|
||
|
|
as_of=datetime.now(UTC),
|
||
|
|
filters=CfoValueFiltersRead(),
|
||
|
|
)
|
||
|
|
assert dashboard.source.opportunity_count == 1
|
||
|
|
assert _money(_stage(dashboard, "estimated").values) == {
|
||
|
|
"CNY": Decimal("100.0000")
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
def _discover(
|
||
|
|
db: Session,
|
||
|
|
current_user: CurrentUserContext,
|
||
|
|
*,
|
||
|
|
currency: str,
|
||
|
|
saving: Decimal,
|
||
|
|
department_name: str = "销售部",
|
||
|
|
) -> str:
|
||
|
|
original = Decimal("1000")
|
||
|
|
target = original - saving
|
||
|
|
claim = ExpenseClaim(
|
||
|
|
id=str(uuid.uuid4()),
|
||
|
|
claim_no=f"BX-{uuid.uuid4().hex[:12]}",
|
||
|
|
employee_name="测试员工",
|
||
|
|
department_name=department_name,
|
||
|
|
project_code="PROJECT-CFO",
|
||
|
|
expense_type="hotel",
|
||
|
|
reason="客户现场差旅",
|
||
|
|
location="上海",
|
||
|
|
amount=original,
|
||
|
|
currency=currency,
|
||
|
|
invoice_count=1,
|
||
|
|
occurred_at=datetime.now(UTC),
|
||
|
|
status="draft",
|
||
|
|
risk_flags_json=[],
|
||
|
|
)
|
||
|
|
item = ExpenseClaimItem(
|
||
|
|
id=str(uuid.uuid4()),
|
||
|
|
claim=claim,
|
||
|
|
item_date=date.today(),
|
||
|
|
item_type="hotel",
|
||
|
|
item_reason="住宿",
|
||
|
|
item_location="上海",
|
||
|
|
item_note="",
|
||
|
|
item_amount=original,
|
||
|
|
)
|
||
|
|
db.add(claim)
|
||
|
|
db.flush()
|
||
|
|
return (
|
||
|
|
SavingsDiscoveryService(db)
|
||
|
|
.discover_standard_adjustments(
|
||
|
|
claim=claim,
|
||
|
|
items_by_id={item.id: item},
|
||
|
|
adjustment_flags=[
|
||
|
|
{
|
||
|
|
"item_id": item.id,
|
||
|
|
"message": "CFO 聚合测试政策差额",
|
||
|
|
"original_amount": str(original),
|
||
|
|
"reimbursable_amount": str(target),
|
||
|
|
"employee_absorbed_amount": str(saving),
|
||
|
|
"policy_rule_version": f"cfo-{currency.lower()}-v1",
|
||
|
|
"policy_grade": "P6",
|
||
|
|
"policy_matched_city": "上海",
|
||
|
|
"calculation_fingerprint": "sha256:" + uuid.uuid4().hex * 2,
|
||
|
|
}
|
||
|
|
],
|
||
|
|
current_user=current_user,
|
||
|
|
request_id=f"discover-{currency.lower()}-{uuid.uuid4().hex[:8]}",
|
||
|
|
)[0]
|
||
|
|
.id
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def _record_payload(
|
||
|
|
request_id: str,
|
||
|
|
amount: Decimal,
|
||
|
|
currency: str,
|
||
|
|
) -> SavingsRealizationCreate:
|
||
|
|
return SavingsRealizationCreate(
|
||
|
|
request_id=request_id,
|
||
|
|
expected_version=1,
|
||
|
|
comment="登记待独立确认的实际结果",
|
||
|
|
actual_gross=amount,
|
||
|
|
incremental_cost=Decimal("0"),
|
||
|
|
currency=currency,
|
||
|
|
realized_at=datetime.now(UTC),
|
||
|
|
attribution_method="server_policy_counterfactual",
|
||
|
|
attribution_ratio=Decimal("1"),
|
||
|
|
evidence_level="business_state",
|
||
|
|
evidence=[
|
||
|
|
SavingsEvidenceCreate(
|
||
|
|
evidence_key=f"evidence-{request_id}",
|
||
|
|
evidence_role="payment_business_state",
|
||
|
|
resource_type="business_event",
|
||
|
|
resource_id=f"payment-{request_id}",
|
||
|
|
source_system="x-financial",
|
||
|
|
external_event_id=f"payment-{request_id}",
|
||
|
|
content_hash="b" * 64,
|
||
|
|
occurred_at=datetime.now(UTC),
|
||
|
|
verification_status="unverified",
|
||
|
|
metadata_json={"source": "cfo-test"},
|
||
|
|
)
|
||
|
|
],
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def _user(
|
||
|
|
username: str,
|
||
|
|
*,
|
||
|
|
roles: list[str] | None = None,
|
||
|
|
department_name: str = "",
|
||
|
|
) -> CurrentUserContext:
|
||
|
|
return CurrentUserContext(
|
||
|
|
username=username,
|
||
|
|
name=username,
|
||
|
|
role_codes=list(roles or []),
|
||
|
|
is_admin=False,
|
||
|
|
tenant_id="default",
|
||
|
|
employee_id=username,
|
||
|
|
department_name=department_name,
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def _money(items) -> dict[str, Decimal]:
|
||
|
|
return {item.currency: item.amount for item in items}
|
||
|
|
|
||
|
|
|
||
|
|
def _stage(dashboard, key: str):
|
||
|
|
return next(item for item in dashboard.funnel.stages if item.key == key)
|