feat(erp): plan payable exports and reconcile bookings
Module Package Release / publish-packages (push) Successful in 13s
Module Package Release / publish-packages (push) Successful in 13s
This commit is contained in:
@@ -0,0 +1,136 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, date, datetime
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from govoplan_erp.backend.manifest import get_manifest
|
||||
from govoplan_erp.backend.payables import (
|
||||
BookingObservation,
|
||||
ErpPayableProfile,
|
||||
PayableExportInput,
|
||||
build_payable_export_plan,
|
||||
reconcile_booking,
|
||||
)
|
||||
|
||||
|
||||
def _profile() -> ErpPayableProfile:
|
||||
return ErpPayableProfile(
|
||||
profile_id="reviewed-target-v1",
|
||||
provider_id="erp-target",
|
||||
company_code="1000",
|
||||
payable_schema="govoplan-neutral-payable",
|
||||
payable_schema_version="1",
|
||||
mapping_revision="mapping-3",
|
||||
connection_ref="core-credential:erp-target",
|
||||
booking_status_mapping=(
|
||||
("RECEIVED", "received"),
|
||||
("POSTED", "booked"),
|
||||
("REJECTED", "rejected"),
|
||||
("REVERSED", "reversed"),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _payable() -> PayableExportInput:
|
||||
return PayableExportInput(
|
||||
tenant_id="tenant-a",
|
||||
source_module="payments",
|
||||
payable_id="payable-7",
|
||||
payable_revision=3,
|
||||
invoice_reference="invoice-2026-7",
|
||||
creditor_reference="creditor-4",
|
||||
currency="eur",
|
||||
gross_amount_minor=123_45,
|
||||
due_date=date(2026, 9, 15),
|
||||
cost_center_reference="cost-12",
|
||||
budget_reference="budget-2026",
|
||||
invoice_document_sha256="a" * 64,
|
||||
evidence_references=("xrechnung:handoff-8",),
|
||||
)
|
||||
|
||||
|
||||
def _observation(plan, *, status: str = "POSTED", plan_sha256: str | None = None):
|
||||
return BookingObservation(
|
||||
tenant_id=plan.tenant_id,
|
||||
payable_id=plan.payable_id,
|
||||
provider_id=plan.provider_id,
|
||||
external_booking_id="booking-99",
|
||||
raw_status=status,
|
||||
external_revision="external-4",
|
||||
observed_at=datetime(2026, 8, 23, 12, tzinfo=UTC),
|
||||
exported_plan_sha256=plan_sha256 or plan.plan_sha256,
|
||||
evidence_sha256="b" * 64,
|
||||
currency="EUR",
|
||||
gross_amount_minor=123_45,
|
||||
)
|
||||
|
||||
|
||||
def test_plan_is_canonical_digest_bound_and_effect_free() -> None:
|
||||
first = build_payable_export_plan(_profile(), _payable())
|
||||
second = build_payable_export_plan(_profile(), _payable())
|
||||
|
||||
assert first == second
|
||||
assert first.dispatch_allowed is False
|
||||
assert first.idempotency_key.startswith("erp-payable:")
|
||||
payload = json.loads(first.payload_json)
|
||||
assert payload["payable"]["gross_amount_minor"] == 12345
|
||||
assert payload["mapping_revision"] == "mapping-3"
|
||||
|
||||
|
||||
def test_money_rejects_floating_point_and_invalid_currency() -> None:
|
||||
values = _payable().to_payload()
|
||||
with pytest.raises(ValueError, match="integer minor units"):
|
||||
PayableExportInput(
|
||||
**{
|
||||
**values,
|
||||
"due_date": date(2026, 9, 15),
|
||||
"gross_amount_minor": 123.45,
|
||||
"evidence_references": (),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("raw_status", "outcome", "action"),
|
||||
[
|
||||
("RECEIVED", "pending", "wait"),
|
||||
("POSTED", "booked", "record_booking"),
|
||||
("REJECTED", "rejected", "record_rejection"),
|
||||
("REVERSED", "reversed", "record_reversal"),
|
||||
],
|
||||
)
|
||||
def test_reconciliation_maps_reviewed_statuses(raw_status, outcome, action) -> None:
|
||||
profile = _profile()
|
||||
plan = build_payable_export_plan(profile, _payable())
|
||||
|
||||
decision = reconcile_booking(profile, plan, _observation(plan, status=raw_status))
|
||||
|
||||
assert decision.outcome == outcome
|
||||
assert decision.action == action
|
||||
assert len(decision.decision_sha256) == 64
|
||||
|
||||
|
||||
def test_unknown_or_wrong_plan_observation_is_quarantined() -> None:
|
||||
profile = _profile()
|
||||
plan = build_payable_export_plan(profile, _payable())
|
||||
|
||||
unknown = reconcile_booking(profile, plan, _observation(plan, status="NEW-VENDOR-STATE"))
|
||||
wrong_plan = reconcile_booking(
|
||||
profile,
|
||||
plan,
|
||||
_observation(plan, plan_sha256="c" * 64),
|
||||
)
|
||||
|
||||
assert (unknown.outcome, unknown.action) == ("conflict", "quarantine")
|
||||
assert (wrong_plan.outcome, wrong_plan.action) == ("conflict", "quarantine")
|
||||
|
||||
|
||||
def test_manifest_documents_unconfigured_provider_and_german_workflow() -> None:
|
||||
manifest = get_manifest()
|
||||
assert manifest.version == "0.1.19"
|
||||
assert manifest.external_providers[0].id == "erp.payables_target"
|
||||
assert manifest.architecture is not None
|
||||
assert manifest.architecture.target_tested_providers == ()
|
||||
assert manifest.documentation[0].translations["de"]["title"]
|
||||
Reference in New Issue
Block a user