feat(platform): close AI expense value loop
Add tenant-safe value, telemetry, connector, commercial, and production-readiness foundations.
This commit is contained in:
788
server/tests/test_financial_connector_services.py
Normal file
788
server/tests/test_financial_connector_services.py
Normal file
@@ -0,0 +1,788 @@
|
||||
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
|
||||
from app.models.financial_connector import (
|
||||
FinancialConnectorConfig,
|
||||
FinancialConnectorEvent,
|
||||
PaymentReconciliationCase,
|
||||
PaymentReconciliationEvent,
|
||||
)
|
||||
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
|
||||
from app.models.savings import SavingsEvidenceLink, SavingsRealization
|
||||
from app.schemas.financial_connector import (
|
||||
FinancialEventEnvelope,
|
||||
PaymentReconciliationActionCreate,
|
||||
)
|
||||
from app.schemas.savings import SavingsRealizationActionCreate
|
||||
from app.services.expense_cases import ExpenseCaseService
|
||||
from app.services.financial_connector_auth import (
|
||||
FinancialConnectorAuthError,
|
||||
FinancialConnectorSecretResolver,
|
||||
sign_financial_event,
|
||||
)
|
||||
from app.services.financial_connector_ingestion import (
|
||||
FinancialConnectorConflictError,
|
||||
FinancialConnectorIngestionService,
|
||||
)
|
||||
from app.services.financial_connector_projection import (
|
||||
FinancialConnectorProjectionService,
|
||||
FinancialReconciliationPermissionError,
|
||||
)
|
||||
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_hmac_is_tenant_bound_time_bounded_and_secret_never_persisted(db: Session) -> None:
|
||||
_config(db, tenant_id="tenant-a", environment="mock")
|
||||
claim = _claim(db, tenant_id="tenant-a")
|
||||
envelope = _event(claim, tenant_id="tenant-a", event_id="settled-auth-001")
|
||||
timestamp = 1_800_000_000
|
||||
signature = sign_financial_event(
|
||||
envelope,
|
||||
timestamp=timestamp,
|
||||
secret="server-only-secret",
|
||||
tenant_id="tenant-a",
|
||||
provider="mock-bank",
|
||||
key_version="v1",
|
||||
)
|
||||
|
||||
with pytest.raises(FinancialConnectorAuthError, match="租户"):
|
||||
_service(db, timestamp).ingest(
|
||||
envelope,
|
||||
tenant_header="tenant-b",
|
||||
provider_header="mock-bank",
|
||||
key_version_header="v1",
|
||||
timestamp_header=str(timestamp),
|
||||
signature_header=signature,
|
||||
)
|
||||
with pytest.raises(FinancialConnectorAuthError) as expired:
|
||||
_service(db, timestamp + 301).ingest(
|
||||
envelope,
|
||||
tenant_header="tenant-a",
|
||||
provider_header="mock-bank",
|
||||
key_version_header="v1",
|
||||
timestamp_header=str(timestamp),
|
||||
signature_header=signature,
|
||||
)
|
||||
assert expired.value.code == "timestamp_outside_window"
|
||||
with pytest.raises(FinancialConnectorAuthError) as invalid:
|
||||
_service(db, timestamp).ingest(
|
||||
envelope,
|
||||
tenant_header="tenant-a",
|
||||
provider_header="mock-bank",
|
||||
key_version_header="v1",
|
||||
timestamp_header=str(timestamp),
|
||||
signature_header="sha256=" + "0" * 64,
|
||||
)
|
||||
assert invalid.value.code == "signature_invalid"
|
||||
config = db.scalar(select(FinancialConnectorConfig))
|
||||
assert config is not None
|
||||
assert config.secret_ref == "connector/test-key"
|
||||
assert "server-only-secret" not in str(config.__dict__)
|
||||
with pytest.raises(ValueError, match="未允许字段"):
|
||||
FinancialEventEnvelope(
|
||||
**{
|
||||
**envelope.model_dump(),
|
||||
"payload": {**envelope.payload, "bank_account_number": "sensitive"},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_hmac_binds_provider_and_key_version_even_when_secret_is_shared(
|
||||
db: Session,
|
||||
) -> None:
|
||||
_config(db, tenant_id="tenant-a", provider="mock-bank", key_version="v1")
|
||||
_config(db, tenant_id="tenant-a", provider="alias-bank", key_version="v2")
|
||||
claim = _claim(db, tenant_id="tenant-a")
|
||||
envelope = _event(claim, tenant_id="tenant-a", event_id="source-bound-001")
|
||||
timestamp = 1_800_000_000
|
||||
signature = sign_financial_event(
|
||||
envelope,
|
||||
timestamp=timestamp,
|
||||
secret="server-only-secret",
|
||||
tenant_id="tenant-a",
|
||||
provider="mock-bank",
|
||||
key_version="v1",
|
||||
)
|
||||
service = _service(db, timestamp)
|
||||
|
||||
with pytest.raises(FinancialConnectorAuthError) as replayed_to_alias:
|
||||
service.ingest(
|
||||
envelope,
|
||||
tenant_header="tenant-a",
|
||||
provider_header="alias-bank",
|
||||
key_version_header="v2",
|
||||
timestamp_header=str(timestamp),
|
||||
signature_header=signature,
|
||||
)
|
||||
|
||||
assert replayed_to_alias.value.code == "signature_invalid"
|
||||
|
||||
|
||||
def test_settlement_replay_erp_reversal_and_savings_evidence_e2e(db: Session) -> None:
|
||||
_config(db, environment="production")
|
||||
claim = _claim(db)
|
||||
application = _linked_application(db, claim)
|
||||
_savings_opportunity(db, claim)
|
||||
db.commit()
|
||||
timestamp = 1_800_000_000
|
||||
settlement = _event(claim, event_id="settled-e2e-001")
|
||||
|
||||
first = _ingest(db, settlement, timestamp)
|
||||
db.commit()
|
||||
replay = _ingest(db, settlement, timestamp)
|
||||
db.commit()
|
||||
db.refresh(claim)
|
||||
assert first.replayed is False
|
||||
assert replay.replayed is True
|
||||
assert first.event_id == replay.event_id
|
||||
assert first.processing_status == "processed"
|
||||
assert first.verification_level == "production_verified"
|
||||
assert first.evidence_classification == "external_cash"
|
||||
assert first.projection_scope == "canonical"
|
||||
assert claim.status == "paid"
|
||||
db.refresh(application)
|
||||
assert application.approval_stage == "申请归档"
|
||||
assert db.scalar(select(func.count(FinancialConnectorEvent.id))) == 1
|
||||
assert db.scalar(select(func.count(PaymentReconciliationEvent.id))) == 1
|
||||
connector_fact = db.get(FinancialConnectorEvent, first.event_id)
|
||||
assert connector_fact is not None
|
||||
assert "claim_reference" not in connector_fact.normalized_payload_json
|
||||
assert claim.claim_no not in str(connector_fact.normalized_payload_json)
|
||||
|
||||
realization = db.scalar(
|
||||
select(SavingsRealization).where(SavingsRealization.claim_id == claim.id)
|
||||
)
|
||||
assert realization is not None
|
||||
evidence = db.scalar(
|
||||
select(SavingsEvidenceLink).where(
|
||||
SavingsEvidenceLink.realization_id == realization.id,
|
||||
SavingsEvidenceLink.resource_type == "financial_connector_event",
|
||||
)
|
||||
)
|
||||
assert evidence is not None
|
||||
assert evidence.resource_id == first.event_id
|
||||
assert evidence.source_system == "mock-bank"
|
||||
assert evidence.metadata_json["evidence_level"] == "external_cash"
|
||||
SavingsRealizationService(db).execute_action(
|
||||
realization.id,
|
||||
SavingsRealizationActionCreate(
|
||||
action="confirm",
|
||||
request_id="connector-confirm-001",
|
||||
expected_version=1,
|
||||
comment="独立财务已核验生产连接器回执与政策基线。",
|
||||
),
|
||||
_user("independent-confirmer", roles=["finance"]),
|
||||
)
|
||||
|
||||
invalid_erp = _event(
|
||||
claim,
|
||||
event_id="erp-e2e-invalid-001",
|
||||
event_type="erp_posted",
|
||||
extra={
|
||||
"amount": "65.00",
|
||||
"origin_external_event_id": settlement.external_event_id,
|
||||
"erp_document_number": "ERP-VOUCHER-INVALID",
|
||||
},
|
||||
)
|
||||
invalid_erp_result = _ingest(db, invalid_erp, timestamp + 1)
|
||||
db.commit()
|
||||
db.refresh(claim)
|
||||
assert invalid_erp_result.processing_status == "exception"
|
||||
assert invalid_erp_result.error_code == "erp_amount_mismatch"
|
||||
assert claim.status == "paid"
|
||||
assert "erp_posted" not in set(db.scalars(select(BusinessEvent.event_type)).all())
|
||||
|
||||
erp = _event(
|
||||
claim,
|
||||
event_id="erp-e2e-001",
|
||||
event_type="erp_posted",
|
||||
extra={
|
||||
"origin_external_event_id": settlement.external_event_id,
|
||||
"erp_document_number": "ERP-VOUCHER-2026-000991",
|
||||
"accounting_period": "2026-07",
|
||||
},
|
||||
)
|
||||
erp = FinancialEventEnvelope.model_validate(
|
||||
{
|
||||
**erp.model_dump(),
|
||||
"payload": {
|
||||
key: value
|
||||
for key, value in erp.payload.items()
|
||||
if key != "external_payment_reference"
|
||||
},
|
||||
}
|
||||
)
|
||||
erp_result = _ingest(db, erp, timestamp + 2)
|
||||
db.commit()
|
||||
db.refresh(claim)
|
||||
case = db.get(PaymentReconciliationCase, first.reconciliation_case_id)
|
||||
assert erp_result.processing_status == "processed"
|
||||
assert claim.status == "paid"
|
||||
assert case is not None and case.erp_status == "posted"
|
||||
assert case.erp_document_tail == "6-000991"
|
||||
assert "ERP-VOUCHER" not in str(case.__dict__)
|
||||
|
||||
reversal = _event(
|
||||
claim,
|
||||
event_id="refund-e2e-001",
|
||||
event_type="payment_refunded",
|
||||
extra={"origin_external_event_id": settlement.external_event_id},
|
||||
)
|
||||
reversed_result = _ingest(db, reversal, timestamp + 3)
|
||||
db.commit()
|
||||
db.refresh(claim)
|
||||
db.refresh(case)
|
||||
assert reversed_result.processing_status == "processed"
|
||||
assert claim.status == "pending_payment"
|
||||
assert case.status == "reopened"
|
||||
db.refresh(realization)
|
||||
reversal_realization = db.scalar(
|
||||
select(SavingsRealization).where(
|
||||
SavingsRealization.reversal_of_realization_id == realization.id
|
||||
)
|
||||
)
|
||||
assert realization.reversed_at is not None
|
||||
assert reversal_realization is not None
|
||||
assert reversal_realization.actual_net == -realization.actual_net
|
||||
assert any(
|
||||
item.get("role") == "external_payment_reversal"
|
||||
and item.get("verification_status") == "verified"
|
||||
for item in reversal_realization.evidence_json
|
||||
)
|
||||
event_types = set(db.scalars(select(BusinessEvent.event_type)).all())
|
||||
assert {"payment_completed", "erp_posted", "payment_reversed"} <= event_types
|
||||
second_settlement = _event(claim, event_id="settled-e2e-002")
|
||||
second_result = _ingest(db, second_settlement, timestamp + 4)
|
||||
db.commit()
|
||||
db.refresh(claim)
|
||||
db.refresh(case)
|
||||
assert second_result.processing_status == "processed"
|
||||
assert claim.status == "paid"
|
||||
assert case.status == "matched"
|
||||
assert case.erp_status == "pending_posting"
|
||||
assert db.scalar(select(func.count(FinancialConnectorEvent.id))) == 5
|
||||
|
||||
|
||||
def test_mismatch_failure_and_conflicting_replay_have_no_payment_side_effect(db: Session) -> None:
|
||||
_config(db)
|
||||
claim = _claim(db)
|
||||
db.commit()
|
||||
timestamp = 1_800_000_000
|
||||
mismatch = _event(
|
||||
claim,
|
||||
event_id="settled-mismatch-001",
|
||||
extra={"amount": "999.00"},
|
||||
)
|
||||
result = _ingest(db, mismatch, timestamp)
|
||||
db.commit()
|
||||
db.refresh(claim)
|
||||
assert result.processing_status == "exception"
|
||||
assert result.error_code == "amount_mismatch"
|
||||
assert claim.status == "pending_payment"
|
||||
assert db.scalar(select(func.count(BusinessEvent.id))) == 0
|
||||
|
||||
conflict = _event(
|
||||
claim,
|
||||
event_id="settled-mismatch-001",
|
||||
extra={"amount": "998.00"},
|
||||
)
|
||||
with pytest.raises(FinancialConnectorConflictError):
|
||||
_ingest(db, conflict, timestamp + 1)
|
||||
db.rollback()
|
||||
db.refresh(claim)
|
||||
assert claim.status == "pending_payment"
|
||||
assert db.scalar(select(func.count(FinancialConnectorEvent.id))) == 1
|
||||
|
||||
failed = _event(
|
||||
claim,
|
||||
event_id="payment-failed-001",
|
||||
event_type="payment_failed",
|
||||
extra={"failure_code": "provider_timeout"},
|
||||
)
|
||||
failed_result = _ingest(db, failed, timestamp + 2)
|
||||
db.commit()
|
||||
db.refresh(claim)
|
||||
assert failed_result.error_code == "external_payment_failed"
|
||||
assert claim.status == "pending_payment"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("environment", ["test", "mock", "staging"])
|
||||
def test_non_production_events_only_create_simulation_facts(
|
||||
db: Session,
|
||||
environment: str,
|
||||
) -> None:
|
||||
_config(db, environment=environment)
|
||||
claim = _claim(db)
|
||||
application = _linked_application(db, claim)
|
||||
_savings_opportunity(db, claim)
|
||||
db.commit()
|
||||
initial_business_event_count = int(
|
||||
db.scalar(select(func.count(BusinessEvent.id))) or 0
|
||||
)
|
||||
timestamp = 1_800_000_000
|
||||
settlement = _event(claim, event_id="pending-saving-settled-001")
|
||||
settlement_result = _ingest(db, settlement, timestamp)
|
||||
db.commit()
|
||||
db.refresh(claim)
|
||||
db.refresh(application)
|
||||
assert settlement_result.processing_status == "processed"
|
||||
assert settlement_result.projection_scope == "simulation_only"
|
||||
assert settlement_result.reconciliation_case_id is None
|
||||
assert settlement_result.claim_status == "pending_payment"
|
||||
assert claim.status == "pending_payment"
|
||||
assert application.approval_stage == "审批完成"
|
||||
|
||||
failed = _event(
|
||||
claim,
|
||||
event_id="pending-saving-failed-001",
|
||||
event_type="payment_failed",
|
||||
extra={"failure_code": "simulated_provider_failure"},
|
||||
)
|
||||
failed_result = _ingest(db, failed, timestamp + 1)
|
||||
db.commit()
|
||||
assert failed_result.processing_status == "exception"
|
||||
assert failed_result.error_code == "external_payment_failed"
|
||||
assert failed_result.projection_scope == "simulation_only"
|
||||
|
||||
erp = _event(
|
||||
claim,
|
||||
event_id="pending-saving-erp-001",
|
||||
event_type="erp_posted",
|
||||
extra={
|
||||
"origin_external_event_id": settlement.external_event_id,
|
||||
"erp_document_number": "ERP-SIMULATED-001",
|
||||
},
|
||||
)
|
||||
erp_result = _ingest(db, erp, timestamp + 2)
|
||||
db.commit()
|
||||
assert erp_result.processing_status == "processed"
|
||||
assert erp_result.projection_scope == "simulation_only"
|
||||
|
||||
erp_failed = _event(
|
||||
claim,
|
||||
event_id="pending-saving-erp-failed-001",
|
||||
event_type="erp_posting_failed",
|
||||
extra={
|
||||
"origin_external_event_id": settlement.external_event_id,
|
||||
"failure_code": "simulated_erp_failure",
|
||||
},
|
||||
)
|
||||
erp_failed_result = _ingest(db, erp_failed, timestamp + 3)
|
||||
db.commit()
|
||||
assert erp_failed_result.processing_status == "exception"
|
||||
assert erp_failed_result.error_code == "erp_posting_failed"
|
||||
assert erp_failed_result.projection_scope == "simulation_only"
|
||||
|
||||
refund = _event(
|
||||
claim,
|
||||
event_id="pending-saving-refund-001",
|
||||
event_type="payment_refunded",
|
||||
extra={"origin_external_event_id": settlement.external_event_id},
|
||||
)
|
||||
refund_result = _ingest(db, refund, timestamp + 4)
|
||||
db.commit()
|
||||
db.refresh(claim)
|
||||
assert refund_result.processing_status == "processed"
|
||||
assert refund_result.projection_scope == "simulation_only"
|
||||
reversal = _event(
|
||||
claim,
|
||||
event_id="pending-saving-reversal-001",
|
||||
event_type="payment_reversed",
|
||||
extra={"origin_external_event_id": settlement.external_event_id},
|
||||
)
|
||||
reversal_result = _ingest(db, reversal, timestamp + 5)
|
||||
db.commit()
|
||||
assert reversal_result.processing_status == "processed"
|
||||
assert reversal_result.projection_scope == "simulation_only"
|
||||
assert claim.status == "pending_payment"
|
||||
assert db.scalar(select(func.count(PaymentReconciliationCase.id))) == 0
|
||||
assert db.scalar(select(func.count(PaymentReconciliationEvent.id))) == 0
|
||||
assert db.scalar(select(func.count(BusinessEvent.id))) == initial_business_event_count
|
||||
assert db.scalar(select(func.count(SavingsRealization.id))) == 0
|
||||
facts = list(db.scalars(select(FinancialConnectorEvent)).all())
|
||||
assert len(facts) == 6
|
||||
assert all(item.expense_case_id is None for item in facts)
|
||||
assert all("claim_reference" not in item.normalized_payload_json for item in facts)
|
||||
|
||||
|
||||
def test_cross_tenant_claim_is_hidden_and_finance_actions_are_separated(db: Session) -> None:
|
||||
_config(db, tenant_id="tenant-a")
|
||||
_config(db, tenant_id="tenant-b", secret_ref="connector/tenant-b")
|
||||
claim = _claim(db, tenant_id="tenant-a", employee_name="申请人甲")
|
||||
db.commit()
|
||||
timestamp = 1_800_000_000
|
||||
cross = _event(claim, tenant_id="tenant-b", event_id="cross-tenant-001")
|
||||
signature = sign_financial_event(
|
||||
cross,
|
||||
timestamp=timestamp,
|
||||
secret="tenant-b-secret-strong",
|
||||
tenant_id="tenant-b",
|
||||
provider="mock-bank",
|
||||
key_version="v1",
|
||||
)
|
||||
result = FinancialConnectorIngestionService(
|
||||
db,
|
||||
secrets=FinancialConnectorSecretResolver(
|
||||
{
|
||||
"connector/test-key": "server-only-secret",
|
||||
"connector/tenant-b": "tenant-b-secret-strong",
|
||||
}
|
||||
),
|
||||
now_epoch=timestamp,
|
||||
).ingest(
|
||||
cross,
|
||||
tenant_header="tenant-b",
|
||||
provider_header="mock-bank",
|
||||
key_version_header="v1",
|
||||
timestamp_header=str(timestamp),
|
||||
signature_header=signature,
|
||||
)
|
||||
db.commit()
|
||||
db.refresh(claim)
|
||||
assert result.error_code == "claim_not_found"
|
||||
assert result.reconciliation_case_id is None
|
||||
assert claim.status == "pending_payment"
|
||||
|
||||
finance = _user("finance-a", tenant_id="tenant-a", roles=["finance"])
|
||||
outsider = _user("employee-a", tenant_id="tenant-a")
|
||||
with pytest.raises(FinancialReconciliationPermissionError):
|
||||
FinancialConnectorProjectionService(db).list_cases(
|
||||
outsider,
|
||||
status_filter=None,
|
||||
page=1,
|
||||
page_size=20,
|
||||
)
|
||||
tenant_a_cases = FinancialConnectorProjectionService(db).list_cases(
|
||||
finance,
|
||||
status_filter=None,
|
||||
page=1,
|
||||
page_size=20,
|
||||
)
|
||||
assert tenant_a_cases.total == 0
|
||||
|
||||
|
||||
def test_production_reversal_cannot_reference_simulation_origin(db: Session) -> None:
|
||||
_config(db, environment="mock", key_version="v1")
|
||||
_config(db, environment="production", key_version="v2")
|
||||
claim = _claim(db)
|
||||
db.commit()
|
||||
timestamp = 1_800_000_000
|
||||
simulated = _event(claim, event_id="simulated-origin-001")
|
||||
simulated_result = _ingest(db, simulated, timestamp)
|
||||
db.commit()
|
||||
assert simulated_result.projection_scope == "simulation_only"
|
||||
|
||||
reversal = _event(
|
||||
claim,
|
||||
event_id="production-reversal-001",
|
||||
event_type="payment_reversed",
|
||||
extra={"origin_external_event_id": simulated.external_event_id},
|
||||
)
|
||||
signature = sign_financial_event(
|
||||
reversal,
|
||||
timestamp=timestamp + 1,
|
||||
secret="server-only-secret",
|
||||
tenant_id="default",
|
||||
provider="mock-bank",
|
||||
key_version="v2",
|
||||
)
|
||||
result = FinancialConnectorIngestionService(
|
||||
db,
|
||||
secrets=FinancialConnectorSecretResolver(
|
||||
{"connector/test-key": "server-only-secret"}
|
||||
),
|
||||
now_epoch=timestamp + 1,
|
||||
).ingest(
|
||||
reversal,
|
||||
tenant_header="default",
|
||||
provider_header="mock-bank",
|
||||
key_version_header="v2",
|
||||
timestamp_header=str(timestamp + 1),
|
||||
signature_header=signature,
|
||||
)
|
||||
db.commit()
|
||||
db.refresh(claim)
|
||||
assert result.projection_scope == "canonical"
|
||||
assert result.error_code == "origin_settlement_not_found"
|
||||
assert claim.status == "pending_payment"
|
||||
assert result.reconciliation_case_id is None
|
||||
|
||||
|
||||
def test_applicant_cannot_confirm_own_exception(db: Session) -> None:
|
||||
_config(db, environment="production")
|
||||
employee_id = str(uuid.uuid4())
|
||||
claim = _claim(db, employee_id=employee_id, employee_name="申请人甲")
|
||||
db.commit()
|
||||
mismatch = _event(claim, event_id="own-exception-001", extra={"amount": "2.00"})
|
||||
result = _ingest(db, mismatch, 1_800_000_000)
|
||||
db.commit()
|
||||
case = db.get(PaymentReconciliationCase, result.reconciliation_case_id)
|
||||
assert case is not None
|
||||
applicant_finance = _user(
|
||||
"applicant-finance",
|
||||
roles=["finance"],
|
||||
employee_id=employee_id,
|
||||
name="申请人甲",
|
||||
)
|
||||
with pytest.raises(FinancialReconciliationPermissionError):
|
||||
FinancialConnectorProjectionService(db).resolve(
|
||||
case.id,
|
||||
PaymentReconciliationActionCreate(
|
||||
expected_version=case.version,
|
||||
reason="本人尝试确认自己的异常",
|
||||
),
|
||||
applicant_finance,
|
||||
action="confirmed",
|
||||
)
|
||||
db.rollback()
|
||||
db.refresh(case)
|
||||
independent_finance = _user("independent-finance", roles=["finance"])
|
||||
resolved = FinancialConnectorProjectionService(db).resolve(
|
||||
case.id,
|
||||
PaymentReconciliationActionCreate(
|
||||
expected_version=case.version,
|
||||
reason="独立财务确认该差异仅作为处置结论",
|
||||
),
|
||||
independent_finance,
|
||||
action="confirmed",
|
||||
)
|
||||
db.commit()
|
||||
db.refresh(claim)
|
||||
assert resolved is not None and resolved.status == "confirmed"
|
||||
assert claim.status == "pending_payment"
|
||||
|
||||
|
||||
def _service(db: Session, timestamp: int) -> FinancialConnectorIngestionService:
|
||||
return FinancialConnectorIngestionService(
|
||||
db,
|
||||
secrets=FinancialConnectorSecretResolver(
|
||||
{"connector/test-key": "server-only-secret"}
|
||||
),
|
||||
now_epoch=timestamp,
|
||||
)
|
||||
|
||||
|
||||
def _ingest(
|
||||
db: Session,
|
||||
envelope: FinancialEventEnvelope,
|
||||
timestamp: int,
|
||||
):
|
||||
signature = sign_financial_event(
|
||||
envelope,
|
||||
timestamp=timestamp,
|
||||
secret="server-only-secret",
|
||||
tenant_id=envelope.tenant_id,
|
||||
provider="mock-bank",
|
||||
key_version="v1",
|
||||
)
|
||||
return _service(db, timestamp).ingest(
|
||||
envelope,
|
||||
tenant_header=envelope.tenant_id,
|
||||
provider_header="mock-bank",
|
||||
key_version_header="v1",
|
||||
timestamp_header=str(timestamp),
|
||||
signature_header=signature,
|
||||
)
|
||||
|
||||
|
||||
def _config(
|
||||
db: Session,
|
||||
*,
|
||||
tenant_id: str = "default",
|
||||
environment: str = "mock",
|
||||
secret_ref: str = "connector/test-key",
|
||||
provider: str = "mock-bank",
|
||||
key_version: str = "v1",
|
||||
) -> FinancialConnectorConfig:
|
||||
row = FinancialConnectorConfig(
|
||||
id=str(uuid.uuid4()),
|
||||
tenant_id=tenant_id,
|
||||
provider=provider,
|
||||
environment=environment,
|
||||
key_version=key_version,
|
||||
secret_ref=secret_ref,
|
||||
allowed_event_types_json=[
|
||||
"payment_settled",
|
||||
"payment_failed",
|
||||
"erp_posted",
|
||||
"erp_posting_failed",
|
||||
"payment_refunded",
|
||||
"payment_reversed",
|
||||
],
|
||||
clock_skew_seconds=300,
|
||||
status="active",
|
||||
created_by="platform-admin",
|
||||
)
|
||||
db.add(row)
|
||||
db.flush()
|
||||
return row
|
||||
|
||||
|
||||
def _claim(
|
||||
db: Session,
|
||||
*,
|
||||
tenant_id: str = "default",
|
||||
employee_id: str | None = None,
|
||||
employee_name: str = "测试员工",
|
||||
) -> ExpenseClaim:
|
||||
claim = ExpenseClaim(
|
||||
id=str(uuid.uuid4()),
|
||||
claim_no=f"BX-CONNECTOR-{uuid.uuid4().hex[:10].upper()}",
|
||||
employee_id=employee_id,
|
||||
employee_name=employee_name,
|
||||
department_name="财务测试部",
|
||||
expense_type="travel",
|
||||
reason="连接器端到端测试",
|
||||
location="上海",
|
||||
amount=Decimal("66.00"),
|
||||
currency="CNY",
|
||||
invoice_count=1,
|
||||
occurred_at=datetime.now(UTC),
|
||||
submitted_at=datetime.now(UTC),
|
||||
status="pending_payment",
|
||||
approval_stage="待付款",
|
||||
risk_flags_json=[],
|
||||
)
|
||||
db.add(claim)
|
||||
db.flush()
|
||||
if tenant_id != "default":
|
||||
ExpenseCaseService(db).ensure_case_for_claim(claim, tenant_id=tenant_id)
|
||||
return claim
|
||||
|
||||
|
||||
def _event(
|
||||
claim: ExpenseClaim,
|
||||
*,
|
||||
tenant_id: str = "default",
|
||||
event_id: str,
|
||||
event_type: str = "payment_settled",
|
||||
extra: dict[str, str] | None = None,
|
||||
) -> FinancialEventEnvelope:
|
||||
payload = {
|
||||
"claim_id": claim.id,
|
||||
"claim_reference": claim.claim_no,
|
||||
"amount": str(claim.amount),
|
||||
"currency": claim.currency,
|
||||
"external_payment_reference": f"PAY-{claim.claim_no}-0001",
|
||||
**dict(extra or {}),
|
||||
}
|
||||
return FinancialEventEnvelope(
|
||||
tenant_id=tenant_id,
|
||||
external_event_id=event_id,
|
||||
event_type=event_type,
|
||||
occurred_at=datetime.now(UTC),
|
||||
correlation_id=f"corr-{event_id}"[:64],
|
||||
payload=payload,
|
||||
)
|
||||
|
||||
|
||||
def _savings_opportunity(db: Session, claim: ExpenseClaim) -> None:
|
||||
item = ExpenseClaimItem(
|
||||
id=str(uuid.uuid4()),
|
||||
claim=claim,
|
||||
item_date=date.today(),
|
||||
item_type="hotel",
|
||||
item_reason="住宿",
|
||||
item_location="上海",
|
||||
item_note="",
|
||||
item_amount=Decimal("100.00"),
|
||||
)
|
||||
db.add(item)
|
||||
db.flush()
|
||||
SavingsDiscoveryService(db).discover_standard_adjustments(
|
||||
claim=claim,
|
||||
items_by_id={item.id: item},
|
||||
adjustment_flags=[
|
||||
{
|
||||
"item_id": item.id,
|
||||
"message": "政策调整",
|
||||
"original_amount": "100.00",
|
||||
"reimbursable_amount": "66.00",
|
||||
"employee_absorbed_amount": "34.00",
|
||||
"policy_rule_version": "connector-policy-v1",
|
||||
"policy_rule_version_source": "published",
|
||||
"policy_grade": "P6",
|
||||
"policy_matched_city": "上海",
|
||||
"calculation_fingerprint": "sha256:" + "d" * 64,
|
||||
}
|
||||
],
|
||||
current_user=_user("finance-payer", roles=["finance"]),
|
||||
request_id="connector-discovery-001",
|
||||
)
|
||||
|
||||
|
||||
def _linked_application(db: Session, reimbursement: ExpenseClaim) -> ExpenseClaim:
|
||||
application = ExpenseClaim(
|
||||
id=str(uuid.uuid4()),
|
||||
claim_no=f"AP-CONNECTOR-{uuid.uuid4().hex[:10].upper()}",
|
||||
employee_name=reimbursement.employee_name,
|
||||
department_name=reimbursement.department_name,
|
||||
expense_type="travel_application",
|
||||
reason="连接器端到端申请",
|
||||
location=reimbursement.location,
|
||||
amount=reimbursement.amount,
|
||||
currency=reimbursement.currency,
|
||||
invoice_count=0,
|
||||
occurred_at=datetime.now(UTC),
|
||||
submitted_at=datetime.now(UTC),
|
||||
status="approved",
|
||||
approval_stage="审批完成",
|
||||
risk_flags_json=[],
|
||||
)
|
||||
db.add(application)
|
||||
db.flush()
|
||||
reimbursement.risk_flags_json = [
|
||||
{
|
||||
"source": "application_handoff",
|
||||
"event_type": "expense_application_to_reimbursement_draft",
|
||||
"application_claim_id": application.id,
|
||||
"application_claim_no": application.claim_no,
|
||||
}
|
||||
]
|
||||
return application
|
||||
|
||||
|
||||
def _user(
|
||||
username: str,
|
||||
*,
|
||||
tenant_id: str = "default",
|
||||
roles: list[str] | None = None,
|
||||
employee_id: str = "",
|
||||
name: str | None = None,
|
||||
) -> CurrentUserContext:
|
||||
return CurrentUserContext(
|
||||
username=username,
|
||||
name=name or username,
|
||||
role_codes=list(roles or []),
|
||||
is_admin=False,
|
||||
tenant_id=tenant_id,
|
||||
employee_id=employee_id,
|
||||
)
|
||||
Reference in New Issue
Block a user