Skip to main content

mako_engine/
erp.rs

1//! ERP integration traits and reference implementations.
2//!
3//! ## Role
4//!
5//! `mako-engine` is a protocol processor — it handles EDIFACT parsing, BDEW
6//! process rules, AS4 delivery, and regulatory deadlines. All contract data,
7//! billing logic, and master data live in the operator's ERP.
8//!
9//! This module defines the **stable integration contract** between `mako-engine`
10//! and external ERP or backend systems.  The payload contract is **BO4E**, not
11//! raw EDIFACT.  ERP adapters never see EDIFACT segment codes or format-version
12//! identifiers — those are absorbed inside `mako-engine`.
13//!
14//! ## Outbound: mako → ERP
15//!
16//! Implement [`ErpAdapter`] and register it at startup.  Every domain event
17//! that requires ERP action is delivered as an [`ErpEvent`].  The production
18//! `WebhookErpAdapter` (in `makod`) serialises events as
19//! **[CloudEvents 1.0](https://cloudevents.io) structured-mode JSON** and POSTs
20//! them to the configured ERP endpoint.
21//!
22//! ```text
23//! POST <erp_webhook_url>
24//! Content-Type: application/cloudevents+json
25//! X-Idempotency-Key: <event.idempotency_key>
26//! webhook-id / webhook-timestamp / webhook-signature   ← Standard Webhooks,
27//!                                                       only when a secret is set
28//!
29//! {
30//!   "specversion": "1.0",
31//!   "id": "<idempotency_key>",
32//!   "source": "urn:mako:makod:tenant:<tenant_id>",
33//!   "type": "de.mako.aperak.accepted",
34//!   "time": "2026-10-01T10:15:00+02:00",
35//!   "subject": "<process_id>",
36//!   "dataschema": "https://.../Marktlokation.json",
37//!   "datacontenttype": "application/json",
38//!   "makoconvid": "<conversation_id>",
39//!   "makocausationid": "<causation_id>",
40//!   "makopid": 55001,
41//!   "data": { "_typ": "MARKTLOKATION", ... }
42//! }
43//! ```
44//!
45//! See [`ErpEventType::cloud_event_type`] for the full type → CE type mapping.
46//! The BO4E payload is always in the `data` field; the `payload_schema` URL
47//! maps to the CloudEvents `dataschema` attribute.
48//!
49//! ## Inbound: ERP → mako (event-driven)
50//!
51//! For ERP systems with a message bus, implement [`ErpCommandSource`] to feed
52//! BO4E business objects into the engine without a synchronous REST round-trip.
53//!
54//! ```rust,ignore
55//! struct MyKafkaSource { consumer: KafkaConsumer }
56//!
57//! impl ErpCommandSource for MyKafkaSource {
58//!     async fn next(&self) -> Result<Option<InboundErpCommand>, ErpAdapterError> {
59//!         let msg = self.consumer.poll(Duration::from_millis(100)).await;
60//!         Ok(msg.map(|m| InboundErpCommand {
61//!             idempotency_key: m.offset().to_string(),
62//!             tenant_id: TenantId::new(),
63//!             payload_schema: "…/Marktlokation.json".into(),
64//!             payload: serde_json::from_slice(m.payload()).unwrap(),
65//!         }))
66//!     }
67//!
68//!     async fn ack(&self, id: &str) -> Result<(), ErpAdapterError> {
69//!         self.consumer.commit_offset(id.parse().unwrap()).await
70//!             .map_err(ErpAdapterError::transport)
71//!     }
72//!
73//!     async fn nack(&self, _id: &str, _reason: &str) -> Result<(), ErpAdapterError> {
74//!         Ok(()) // Kafka auto-redelivers on next poll
75//!     }
76//! }
77//! ```
78//!
79//! ## Reference implementations
80//!
81//! | Type | Feature | Use case |
82//! |------|---------|---------|
83//! | `NoopErpAdapter` | `testing` | Unit tests, CI |
84//! | [`LogErpAdapter`] | — | Structured log output; starting point for new integrations |
85//! | `NoopErpCommandSource` | `testing` | No-op inbound source for tests |
86//!
87//! For the production `WebhookErpAdapter` and `POST /api/v1/commands` endpoint,
88//! see `makod/src/erp_adapter.rs`.
89
90use std::sync::Arc;
91
92use serde::{Deserialize, Serialize};
93use time::OffsetDateTime;
94
95use crate::erc::ErcCode;
96use crate::ids::{ConversationId, EventId, ProcessId, TenantId};
97
98// ── ErpAdapterError ───────────────────────────────────────────────────────────
99
100/// Errors produced by [`ErpAdapter`] and [`ErpCommandSource`] implementations.
101#[derive(Debug, thiserror::Error)]
102pub enum ErpAdapterError {
103    /// The ERP response payload could not be deserialised or is semantically
104    /// invalid.
105    #[error("ERP payload error: {0}")]
106    Payload(String),
107
108    /// A transient transport error (network timeout, HTTP 5xx, broker
109    /// disconnect).  The delivery worker will retry with exponential backoff.
110    #[error("ERP transport error: {0}")]
111    Transport(String),
112
113    /// A permanent, non-retryable error (e.g. invalid configuration,
114    /// authentication failure).  The delivery worker will dead-letter the
115    /// message.
116    #[error("ERP permanent error: {0}")]
117    Permanent(String),
118}
119
120impl ErpAdapterError {
121    /// Construct a [`Payload`](ErpAdapterError::Payload) variant.
122    pub fn payload(e: impl std::fmt::Display) -> Self {
123        Self::Payload(e.to_string())
124    }
125
126    /// Construct a [`Transport`](ErpAdapterError::Transport) variant.
127    pub fn transport(e: impl std::fmt::Display) -> Self {
128        Self::Transport(e.to_string())
129    }
130
131    /// Construct a [`Permanent`](ErpAdapterError::Permanent) variant.
132    pub fn permanent(e: impl std::fmt::Display) -> Self {
133        Self::Permanent(e.to_string())
134    }
135
136    /// Returns `true` for transient errors that warrant a retry.
137    #[must_use]
138    pub fn is_retryable(&self) -> bool {
139        matches!(self, Self::Transport(_))
140    }
141}
142
143// ── ErpEventType ─────────────────────────────────────────────────────────────
144
145/// Semantic classification of an outbound ERP process event.
146///
147/// The ERP uses this to decide which action to take — update an order status,
148/// trigger a billing run, open a complaint ticket, etc.
149#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
150#[serde(rename_all = "snake_case")]
151pub enum ErpEventType {
152    /// A new MaKo process was spawned (e.g. inbound UTILMD received).
153    ProcessInitiated,
154    /// The counterparty sent an APERAK accepting our UTILMD.
155    AperakAccepted,
156    /// The counterparty sent an APERAK rejecting our UTILMD.
157    ///
158    /// `erc_code` is `Some` when the APERAK carried a structured ERC segment
159    /// (BDEW APERAK AHB 1.0 §2.2), and `None` when the counterparty sent none.
160    AperakRejected {
161        /// Structured BDEW ERC error code from the APERAK ERC segment.
162        ///
163        /// Use [`crate::erc::recommended_action`] to derive the
164        /// recommended automated ERP response.
165        #[serde(skip_serializing_if = "Option::is_none")]
166        erc_code: Option<ErcCode>,
167    },
168    /// No APERAK received within the regulatory SLA window (deadline expired).
169    AperakTimeout,
170    /// A CONTRL syntax acknowledgement was received.
171    ContrlReceived,
172    /// The process reached its terminal success state
173    /// (e.g. Lieferbeginn/Lieferende confirmed).
174    ProcessCompleted,
175    /// A MaLo identification request was successfully resolved: the MaLo was
176    /// found and the positive callback was delivered to the requesting LF.
177    ///
178    /// The `payload` field of the associated [`ErpEvent`] carries a BO4E
179    /// `Marktlokation` JSON object with the resolved MaLo data.
180    MaloIdentified,
181    /// The process failed permanently (regulatory timeout, data error, …).
182    ProcessFailed {
183        /// Human-readable failure description.
184        reason: Box<str>,
185    },
186    /// A WiM Steuerungsauftrag (PID 55168) dispatch was positively confirmed by
187    /// the MSB (`EndantwortPositiv`). Triggers downstream VPP settlement billing
188    /// in `billingd` via `POST /api/v1/webhooks/vpp-dispatch`.
189    ///
190    /// Only emitted for `Konfiguration` (load-reduction) commands — not for
191    /// `InitialZustand` (reset) commands, which restore normal operation.
192    ///
193    /// The CE `data` payload carries:
194    /// `tx_id`, `location_id`, `location_type`, `execution_time_from`,
195    /// `execution_time_until`, `max_power_kw`, `command_type`, `sender_mp_id`,
196    /// `produkt_code`.
197    VppDispatchConfirmed,
198    /// The § 47 Ziffer 1 KoV XV final-allocation window closed with no binding final ALOCAT
199    /// on file, so the gas day's imbalance cannot be settled.
200    ///
201    /// Raised from the `gabi-gas-allocation` deadline. The obligation is the
202    /// FNB's/MGV's, not ours — the operator's action is to open a Clearingfall,
203    /// which is why this leaves the platform as an event rather than a message.
204    ///
205    /// The CE `data` payload carries `gas_day`, `deadline_label` and
206    /// `sender_eic` / `receiver_eic` of the last ALOCAT recorded for the stream.
207    GabiFinalAllocationOverdue,
208    /// The FNB/MGV confirmed less gas than was nominated.
209    ///
210    /// NOMRES carries no status segment, so a curtailment shows up only as a
211    /// reduced quantity — and nothing downstream sees the shortfall unless it
212    /// is notified. The CE `data` payload carries `gas_day`, `nominated_kwh`,
213    /// `confirmed_kwh`, `curtailed_kwh` and the parties.
214    GabiNominationCurtailed,
215    /// The FNB/MGV refused the nomination; nothing flows on it.
216    GabiNominationRejected,
217    /// The `KoV` NOMRES window closed with no answer on file, so the
218    /// nomination's status is unknown at gas-day start.
219    GabiNomresMissing,
220    /// The LFA answered the NB's Anfrage zur Beendigung der Zuordnung (55011 /
221    /// 55012), or its 09:00 window lapsed unanswered — „gilt dies als
222    /// Bestätigung nach Fall a)".
223    ///
224    /// Consumed by `processd`, which resumes the **Anmeldung** decision on it:
225    /// `E_0623` Prüfschritte 30–50 read the answer, and a Widerspruch that is
226    /// not `A30` refuses the Anmeldung with `A50`.
227    AbmeldeanfrageBeantwortet,
228}
229
230impl ErpEventType {
231    /// Short label for structured logging and metrics.
232    #[must_use]
233    pub fn label(&self) -> &'static str {
234        match self {
235            Self::ProcessInitiated => "process_initiated",
236            Self::AperakAccepted => "aperak_accepted",
237            Self::AperakRejected { .. } => "aperak_rejected",
238            Self::AperakTimeout => "aperak_timeout",
239            Self::ContrlReceived => "contrl_received",
240            Self::ProcessCompleted => "process_completed",
241            Self::MaloIdentified => "malo_identified",
242            Self::ProcessFailed { .. } => "process_failed",
243            Self::VppDispatchConfirmed => "vpp_dispatch_confirmed",
244            Self::GabiFinalAllocationOverdue => "gabi_final_allocation_overdue",
245            Self::GabiNominationCurtailed => "gabi_nomination_curtailed",
246            Self::GabiNominationRejected => "gabi_nomination_rejected",
247            Self::GabiNomresMissing => "gabi_nomres_missing",
248            Self::AbmeldeanfrageBeantwortet => "abmeldeanfrage_beantwortet",
249        }
250    }
251
252    /// CloudEvents 1.0 `type` attribute for this event.
253    ///
254    /// Follows the reverse-DNS prefix convention (`de.mako.<domain>.<action>`).
255    /// Used by the `WebhookErpAdapter` to populate the `type` field of the
256    /// CloudEvents envelope.
257    #[must_use]
258    pub fn cloud_event_type(&self) -> &'static str {
259        match self {
260            Self::ProcessInitiated => mako_events::mako::PROCESS_INITIATED,
261            Self::AperakAccepted => mako_events::mako::APERAK_ACCEPTED,
262            Self::AperakRejected { .. } => mako_events::mako::APERAK_REJECTED,
263            Self::AperakTimeout => mako_events::mako::APERAK_TIMEOUT,
264            Self::ContrlReceived => mako_events::mako::CONTRL_RECEIVED,
265            Self::ProcessCompleted => mako_events::mako::PROCESS_COMPLETED,
266            Self::MaloIdentified => mako_events::mako::MALO_IDENTIFIED,
267            Self::ProcessFailed { .. } => mako_events::mako::PROCESS_FAILED,
268            Self::VppDispatchConfirmed => mako_events::vpp::DISPATCH_CONFIRMED,
269            Self::GabiFinalAllocationOverdue => mako_events::gabi::ALOCAT_MISSING,
270            Self::GabiNominationCurtailed => mako_events::gabi::NOMINATION_CURTAILED,
271            Self::GabiNominationRejected => mako_events::gabi::NOMINATION_REJECTED,
272            Self::GabiNomresMissing => mako_events::gabi::NOMRES_MISSING,
273            Self::AbmeldeanfrageBeantwortet => mako_events::mako::ABMELDEANFRAGE_BEANTWORTET,
274        }
275    }
276}
277
278// ── ErpEvent ──────────────────────────────────────────────────────────────────
279
280/// A structured process event delivered from `mako-engine` to the ERP.
281///
282/// The payload is always a **BO4E-typed JSON object** — the ERP adapter never
283/// receives raw EDIFACT bytes or EDIFACT format-version identifiers.
284///
285/// On the wire (via `WebhookErpAdapter`) this struct is serialised as a
286/// **[CloudEvents 1.0](https://cloudevents.io) structured-mode JSON** envelope
287/// with `Content-Type: application/cloudevents+json`.  The BO4E payload lives
288/// in the CloudEvents `data` field; `payload_schema` maps to `dataschema`;
289/// `event_type` maps to the `type` attribute via [`ErpEventType::cloud_event_type`].
290///
291/// ## Idempotency
292///
293/// `idempotency_key` maps to the CloudEvents `id` attribute and is also sent
294/// as `X-Idempotency-Key` for ERP middleware that keys on headers.  The ERP
295/// **must** persist this key and return `HTTP 200 OK` for duplicate deliveries.
296#[derive(Debug, Clone, Serialize, Deserialize)]
297pub struct ErpEvent {
298    /// Stable dedup key — store in the ERP to reject duplicate deliveries.
299    ///
300    /// Derived from the outbox `message_id`; stable across retries.
301    pub idempotency_key: String,
302
303    /// Semantic classification of this event.
304    pub event_type: ErpEventType,
305
306    /// The mako process that generated this event.
307    pub process_id: ProcessId,
308
309    /// Tenant (operator GLN) that owns this process.
310    pub tenant_id: TenantId,
311
312    /// BDEW business conversation identifier.
313    pub conversation_id: ConversationId,
314
315    /// The mako domain event that directly caused this ERP notification.
316    pub causation_id: EventId,
317
318    /// Prüfidentifikator of the process.
319    pub pid: u32,
320
321    /// BO4E JSON Schema URL that validates [`payload`](ErpEvent::payload).
322    ///
323    /// Examples:
324    /// - `"https://raw.githubusercontent.com/BO4E/BO4E-Schemas/v202607.1.0/src/bo4e_schemas/bo/Marktlokation.json"`
325    /// - `"https://raw.githubusercontent.com/BO4E/BO4E-Schemas/v202607.1.0/src/bo4e_schemas/bo/Messlokation.json"`
326    ///
327    /// `None` for events where no primary BO4E object is applicable
328    /// (e.g. `ContrlReceived`).
329    #[serde(skip_serializing_if = "Option::is_none")]
330    pub payload_schema: Option<String>,
331
332    /// BO4E-typed payload.
333    ///
334    /// Deserialise using the ERP's own BO4E library.  Raw EDIFACT structures
335    /// are never exposed here.  `null` when no payload is applicable.
336    pub payload: serde_json::Value,
337
338    /// Wall-clock time when the domain event was persisted.
339    #[serde(with = "time::serde::rfc3339")]
340    pub occurred_at: OffsetDateTime,
341
342    /// W3C `traceparent` propagated from the request that caused this event.
343    ///
344    /// Copied from `OutboxMessage::trace_context`; injected into the webhook
345    /// delivery as the `traceparent` HTTP header and the CloudEvents
346    /// `traceparent` extension attribute (CloudEvents distributed-tracing
347    /// extension), so the ERP joins the same trace as the inbound transport.
348    #[serde(default, skip_serializing_if = "Option::is_none")]
349    pub trace_context: Option<Box<str>>,
350
351    /// Workflow family name that produced this event (e.g. `"gpke-sperrung"`).
352    ///
353    /// Carried through from `OutboxMessage::workflow_name`.  Emitted as the
354    /// `makoworkflow` CloudEvents extension attribute by `WebhookErpAdapter`.
355    /// `marktd` maps this to `marktrole` for role-scoped ERP subscriber fan-out.
356    pub workflow_name: Box<str>,
357}
358
359// ── ErpAdapter trait ──────────────────────────────────────────────────────────
360
361/// Outbound notification sink — `mako-engine` calls this when a process event
362/// should be reported to the ERP.
363///
364/// The payload is always a BO4E-typed JSON object; the adapter never receives
365/// raw EDIFACT bytes or format-version identifiers.
366///
367/// ## Contract
368///
369/// - Must be **idempotent** on `event.idempotency_key`.  Called twice with the
370///   same key must succeed without double-posting.
371/// - Return [`ErpAdapterError::Transport`] for transient failures — the caller
372///   will retry with exponential backoff.
373/// - Return [`ErpAdapterError::Permanent`] for non-retryable failures — the
374///   caller will dead-letter the event.
375#[allow(async_fn_in_trait)]
376pub trait ErpAdapter: Send + Sync + 'static {
377    /// Deliver `event` to the ERP.
378    async fn notify(&self, event: ErpEvent) -> Result<(), ErpAdapterError>;
379}
380
381/// Blanket `Arc` implementation so `ErpAdapter` can be shared across tasks.
382impl<T: ErpAdapter> ErpAdapter for Arc<T> {
383    async fn notify(&self, event: ErpEvent) -> Result<(), ErpAdapterError> {
384        (**self).notify(event).await
385    }
386}
387
388// ── InboundErpCommand ─────────────────────────────────────────────────────────
389
390/// A BO4E business object received from the ERP, intended to trigger a mako
391/// process.
392///
393/// `mako-engine` maps the BO4E payload to an internal `Command` via the
394/// domain crate's command mapper.
395#[derive(Debug, Clone, Serialize, Deserialize)]
396pub struct InboundErpCommand {
397    /// Stable dedup key — forwarded to [`InboxStore::accept`].
398    ///
399    /// The ERP must supply a stable, unique identifier per command so that
400    /// retransmissions do not double-execute the workflow.
401    ///
402    /// [`InboxStore::accept`]: crate::inbox::InboxStore::accept
403    pub idempotency_key: String,
404
405    /// Tenant (operator GLN) that owns the target process.
406    pub tenant_id: TenantId,
407
408    /// BO4E JSON Schema URL — identifies the object type without inspecting
409    /// `payload`.
410    ///
411    /// Example:
412    /// `"https://raw.githubusercontent.com/BO4E/BO4E-Schemas/v202607.1.0/src/bo4e_schemas/bo/Vertrag.json"`
413    pub payload_schema: String,
414
415    /// BO4E-typed JSON payload.  `mako-engine` maps this to an internal
416    /// `Command` via the registered domain command mapper.
417    pub payload: serde_json::Value,
418}
419
420// ── ErpCommandSource trait ────────────────────────────────────────────────────
421
422/// Inbound command source — `mako-engine` polls this for new BO4E objects
423/// from the ERP.
424///
425/// Implement this for broker-based inbound flows (Kafka consumer, SFTP poll,
426/// database change feed, …) to make the entire integration fully event-driven
427/// — no synchronous REST round-trip required.
428///
429/// ## Contract
430///
431/// - [`next`](ErpCommandSource::next) must be **non-blocking** when idle —
432///   return `Ok(None)` immediately when no command is available.
433/// - [`ack`](ErpCommandSource::ack) must suppress re-delivery of `id` after
434///   a successful ack (idempotent).
435/// - [`nack`](ErpCommandSource::nack) should allow re-delivery of `id` after
436///   an appropriate backoff.
437#[allow(async_fn_in_trait)]
438pub trait ErpCommandSource: Send + Sync + 'static {
439    /// Return the next pending BO4E command, or `None` when the source is idle.
440    async fn next(&self) -> Result<Option<InboundErpCommand>, ErpAdapterError>;
441
442    /// Acknowledge successful processing of `id`.
443    ///
444    /// After a successful ack the source must not re-deliver `id`.
445    async fn ack(&self, id: &str) -> Result<(), ErpAdapterError>;
446
447    /// Negative-acknowledge — allow re-delivery of `id` after backoff.
448    async fn nack(&self, id: &str, reason: &str) -> Result<(), ErpAdapterError>;
449}
450
451/// Blanket `Arc` implementation so `ErpCommandSource` can be shared across tasks.
452impl<S: ErpCommandSource> ErpCommandSource for Arc<S> {
453    async fn next(&self) -> Result<Option<InboundErpCommand>, ErpAdapterError> {
454        (**self).next().await
455    }
456    async fn ack(&self, id: &str) -> Result<(), ErpAdapterError> {
457        (**self).ack(id).await
458    }
459    async fn nack(&self, id: &str, reason: &str) -> Result<(), ErpAdapterError> {
460        (**self).nack(id, reason).await
461    }
462}
463
464// ── NoopErpAdapter ────────────────────────────────────────────────────────────
465
466/// An [`ErpAdapter`] that succeeds immediately without notifying anything.
467///
468/// Use in unit tests and CI where no real ERP endpoint is available.
469#[cfg(feature = "testing")]
470#[derive(Debug, Clone, Default)]
471pub struct NoopErpAdapter;
472
473#[cfg(feature = "testing")]
474impl ErpAdapter for NoopErpAdapter {
475    async fn notify(&self, _event: ErpEvent) -> Result<(), ErpAdapterError> {
476        Ok(())
477    }
478}
479
480// ── LogErpAdapter ─────────────────────────────────────────────────────────────
481
482/// An [`ErpAdapter`] that logs every event at `info` level without delivering
483/// it.
484///
485/// Useful as a development starting point — replace it with your concrete ERP
486/// adapter in production.
487#[derive(Debug, Clone, Default)]
488pub struct LogErpAdapter;
489
490impl ErpAdapter for LogErpAdapter {
491    async fn notify(&self, event: ErpEvent) -> Result<(), ErpAdapterError> {
492        tracing::info!(
493            idempotency_key = %event.idempotency_key,
494            event_type      = event.event_type.label(),
495            process_id      = %event.process_id,
496            tenant_id       = %event.tenant_id,
497            pid             = event.pid,
498            "ErpAdapter: event logged (no delivery configured)",
499        );
500        Ok(())
501    }
502}
503
504// ── NoopErpCommandSource ──────────────────────────────────────────────────────
505
506/// An [`ErpCommandSource`] that is always idle (returns `Ok(None)`).
507///
508/// Use in tests where no inbound ERP command flow is needed.
509#[cfg(feature = "testing")]
510#[derive(Debug, Clone, Default)]
511pub struct NoopErpCommandSource;
512
513#[cfg(feature = "testing")]
514impl ErpCommandSource for NoopErpCommandSource {
515    async fn next(&self) -> Result<Option<InboundErpCommand>, ErpAdapterError> {
516        Ok(None)
517    }
518    async fn ack(&self, _id: &str) -> Result<(), ErpAdapterError> {
519        Ok(())
520    }
521    async fn nack(&self, _id: &str, _reason: &str) -> Result<(), ErpAdapterError> {
522        Ok(())
523    }
524}
525
526// ── ErpAdapterTestHarness ─────────────────────────────────────────────────────
527
528/// A recording [`ErpAdapter`] for use in tests.
529///
530/// Records every [`ErpEvent`] delivered via [`notify`](ErpAdapter::notify) so
531/// tests can assert on event types, ordering, and BO4E payload shapes.
532///
533/// ```rust,ignore
534/// let harness = ErpAdapterTestHarness::new();
535/// my_workflow.run_with_adapter(harness.adapter()).await?;
536///
537/// let events = harness.events();
538/// assert_eq!(events[0].event_type, ErpEventType::ProcessInitiated);
539/// assert_eq!(events[1].event_type, ErpEventType::AperakAccepted);
540/// ```
541#[cfg(feature = "testing")]
542#[derive(Debug, Clone, Default)]
543pub struct ErpAdapterTestHarness {
544    events: Arc<tokio::sync::Mutex<Vec<ErpEvent>>>,
545}
546
547#[cfg(feature = "testing")]
548impl ErpAdapterTestHarness {
549    /// Create a new empty harness.
550    #[must_use]
551    pub fn new() -> Self {
552        Self::default()
553    }
554
555    /// Return a snapshot of all recorded events in delivery order.
556    pub async fn events(&self) -> Vec<ErpEvent> {
557        self.events.lock().await.clone()
558    }
559
560    /// Drain all recorded events, resetting the harness.
561    pub async fn drain(&self) -> Vec<ErpEvent> {
562        std::mem::take(&mut *self.events.lock().await)
563    }
564}
565
566#[cfg(feature = "testing")]
567impl ErpAdapter for ErpAdapterTestHarness {
568    async fn notify(&self, event: ErpEvent) -> Result<(), ErpAdapterError> {
569        self.events.lock().await.push(event);
570        Ok(())
571    }
572}
573
574// ── ErpCommandSourceTestHarness ───────────────────────────────────────────────
575
576/// A controllable [`ErpCommandSource`] for use in tests.
577///
578/// Inject canned [`InboundErpCommand`] payloads and verify that the engine
579/// processes them correctly.
580///
581/// ```text
582/// let source = ErpCommandSourceTestHarness::new();
583/// source.inject(InboundErpCommand {
584///     idempotency_key: "order-42".into(),
585///     tenant_id: TenantId::new(),
586///     payload_schema: ".../Vertrag.json".into(),
587///     payload: serde_json::json!({ "_typ": "VERTRAG", ... }),
588/// }).await;
589///
590/// // The engine picks up the command on the next poll.
591/// ```
592#[cfg(feature = "testing")]
593#[derive(Debug, Clone, Default)]
594pub struct ErpCommandSourceTestHarness {
595    queue: Arc<tokio::sync::Mutex<std::collections::VecDeque<InboundErpCommand>>>,
596    acked: Arc<tokio::sync::Mutex<Vec<String>>>,
597    nacked: Arc<tokio::sync::Mutex<Vec<(String, String)>>>,
598}
599
600#[cfg(feature = "testing")]
601impl ErpCommandSourceTestHarness {
602    /// Create a new empty harness.
603    #[must_use]
604    pub fn new() -> Self {
605        Self::default()
606    }
607
608    /// Enqueue a command to be returned by the next [`next`](ErpCommandSource::next) call.
609    pub async fn inject(&self, cmd: InboundErpCommand) {
610        self.queue.lock().await.push_back(cmd);
611    }
612
613    /// Return all acked command IDs.
614    pub async fn acked(&self) -> Vec<String> {
615        self.acked.lock().await.clone()
616    }
617
618    /// Return all nacked `(id, reason)` pairs.
619    pub async fn nacked(&self) -> Vec<(String, String)> {
620        self.nacked.lock().await.clone()
621    }
622}
623
624#[cfg(feature = "testing")]
625impl ErpCommandSource for ErpCommandSourceTestHarness {
626    async fn next(&self) -> Result<Option<InboundErpCommand>, ErpAdapterError> {
627        Ok(self.queue.lock().await.pop_front())
628    }
629
630    async fn ack(&self, id: &str) -> Result<(), ErpAdapterError> {
631        self.acked.lock().await.push(id.to_owned());
632        Ok(())
633    }
634
635    async fn nack(&self, id: &str, reason: &str) -> Result<(), ErpAdapterError> {
636        self.nacked
637            .lock()
638            .await
639            .push((id.to_owned(), reason.to_owned()));
640        Ok(())
641    }
642}
643
644// ── BO4E schema URL constants ─────────────────────────────────────────────────
645
646/// BO4E schema URL base for the pinned snapshot.
647///
648/// Use `bo4e_schema_url!(Marktlokation)` to construct typed schema URLs at
649/// compile time.
650///
651/// **The `v` belongs here and only here.** BO4E prefixes its git *tags* with
652/// one, so a raw.githubusercontent URL needs `v202607.1.0`; the `_version`
653/// field inside a payload never has it and reads `202607.1.0`.
654/// `mako_markt::bo4e::schema_version` is the payload spelling, and
655/// `services/makod/tests/bo4e_version_guard.rs` pins the two together — this
656/// crate cannot derive the tag itself because it does not depend on rubo4e.
657pub const BO4E_V202607_BASE: &str =
658    "https://raw.githubusercontent.com/BO4E/BO4E-Schemas/v202607.1.0/src/bo4e_schemas";
659
660/// Construct a BO4E JSON Schema URL for a Business Object.
661///
662/// ```rust
663/// use mako_engine::bo4e_schema_url;
664/// assert!(bo4e_schema_url!("bo", "Marktlokation").contains("Marktlokation"));
665/// ```
666#[macro_export]
667macro_rules! bo4e_schema_url {
668    ($category:literal, $name:literal) => {
669        concat!(
670            "https://raw.githubusercontent.com/BO4E/BO4E-Schemas/v202607.1.0/src/bo4e_schemas/",
671            $category,
672            "/",
673            $name,
674            ".json",
675        )
676    };
677}
678
679/// BO4E JSON Schema URL for `Marktlokation`.
680pub const BO4E_SCHEMA_MARKTLOKATION: &str = bo4e_schema_url!("bo", "Marktlokation");
681
682/// BO4E JSON Schema URL for `Messlokation`.
683pub const BO4E_SCHEMA_MESSLOKATION: &str = bo4e_schema_url!("bo", "Messlokation");
684
685/// BO4E JSON Schema URL for `Vertrag`.
686pub const BO4E_SCHEMA_VERTRAG: &str = bo4e_schema_url!("bo", "Vertrag");
687
688/// BO4E JSON Schema URL for `Energiemenge`.
689pub const BO4E_SCHEMA_ENERGIEMENGE: &str = bo4e_schema_url!("bo", "Energiemenge");
690
691/// BO4E JSON Schema URL for `Rechnung`.
692pub const BO4E_SCHEMA_RECHNUNG: &str = bo4e_schema_url!("bo", "Rechnung");
693
694/// BO4E JSON Schema URL for `Zaehler`.
695pub const BO4E_SCHEMA_ZAEHLER: &str = bo4e_schema_url!("bo", "Zaehler");
696
697/// BO4E JSON Schema URL for `Geschaeftspartner`.
698pub const BO4E_SCHEMA_GESCHAEFTSPARTNER: &str = bo4e_schema_url!("bo", "Geschaeftspartner");