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