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). It is `None` for legacy outbox messages
160 /// that predate the typed ERC code field.
161 AperakRejected {
162 /// Structured BDEW ERC error code from the APERAK ERC segment.
163 ///
164 /// Use [`crate::erc::recommended_action`] to derive the
165 /// recommended automated ERP response.
166 #[serde(skip_serializing_if = "Option::is_none")]
167 erc_code: Option<ErcCode>,
168 },
169 /// No APERAK received within the regulatory SLA window (deadline expired).
170 AperakTimeout,
171 /// A CONTRL syntax acknowledgement was received.
172 ContrlReceived,
173 /// The process reached its terminal success state
174 /// (e.g. Lieferbeginn/Lieferende confirmed).
175 ProcessCompleted,
176 /// A MaLo identification request was successfully resolved: the MaLo was
177 /// found and the positive callback was delivered to the requesting LF.
178 ///
179 /// The `payload` field of the associated [`ErpEvent`] carries a BO4E
180 /// `Marktlokation` JSON object with the resolved MaLo data.
181 MaloIdentified,
182 /// The process failed permanently (regulatory timeout, data error, …).
183 ProcessFailed {
184 /// Human-readable failure description.
185 reason: Box<str>,
186 },
187 /// A WiM Steuerungsauftrag (PID 55168) dispatch was positively confirmed by
188 /// the MSB (`EndantwortPositiv`). Triggers downstream VPP settlement billing
189 /// in `billingd` via `POST /api/v1/webhooks/vpp-dispatch`.
190 ///
191 /// Only emitted for `Konfiguration` (load-reduction) commands — not for
192 /// `InitialZustand` (reset) commands, which restore normal operation.
193 ///
194 /// The CE `data` payload carries:
195 /// `tx_id`, `location_id`, `location_type`, `execution_time_from`,
196 /// `execution_time_until`, `max_power_kw`, `command_type`, `sender_mp_id`,
197 /// `produkt_code`.
198 VppDispatchConfirmed,
199 /// The KoV §6.4 final-allocation window closed with no binding final ALOCAT
200 /// on file, so the gas day's imbalance cannot be settled.
201 ///
202 /// Raised from the `gabi-gas-allocation` deadline. The obligation is the
203 /// FNB's/MGV's, not ours — the operator's action is to open a Clearingfall,
204 /// which is why this leaves the platform as an event rather than a message.
205 ///
206 /// The CE `data` payload carries `gas_day`, `deadline_label` and
207 /// `sender_eic` / `receiver_eic` of the last ALOCAT recorded for the stream.
208 GabiFinalAllocationOverdue,
209 /// The LFA answered the NB's Anfrage zur Beendigung der Zuordnung (55011 /
210 /// 55012), or its 09:00 window lapsed unanswered — „gilt dies als
211 /// Bestätigung nach Fall a)".
212 ///
213 /// Consumed by `processd`, which resumes the **Anmeldung** decision on it:
214 /// `E_0623` Prüfschritte 30–50 read the answer, and a Widerspruch that is
215 /// not `A30` refuses the Anmeldung with `A50`.
216 AbmeldeanfrageBeantwortet,
217}
218
219impl ErpEventType {
220 /// Short label for structured logging and metrics.
221 #[must_use]
222 pub fn label(&self) -> &'static str {
223 match self {
224 Self::ProcessInitiated => "process_initiated",
225 Self::AperakAccepted => "aperak_accepted",
226 Self::AperakRejected { .. } => "aperak_rejected",
227 Self::AperakTimeout => "aperak_timeout",
228 Self::ContrlReceived => "contrl_received",
229 Self::ProcessCompleted => "process_completed",
230 Self::MaloIdentified => "malo_identified",
231 Self::ProcessFailed { .. } => "process_failed",
232 Self::VppDispatchConfirmed => "vpp_dispatch_confirmed",
233 Self::GabiFinalAllocationOverdue => "gabi_final_allocation_overdue",
234 Self::AbmeldeanfrageBeantwortet => "abmeldeanfrage_beantwortet",
235 }
236 }
237
238 /// CloudEvents 1.0 `type` attribute for this event.
239 ///
240 /// Follows the reverse-DNS prefix convention (`de.mako.<domain>.<action>`).
241 /// Used by the `WebhookErpAdapter` to populate the `type` field of the
242 /// CloudEvents envelope.
243 #[must_use]
244 pub fn cloud_event_type(&self) -> &'static str {
245 match self {
246 Self::ProcessInitiated => mako_events::mako::PROCESS_INITIATED,
247 Self::AperakAccepted => mako_events::mako::APERAK_ACCEPTED,
248 Self::AperakRejected { .. } => mako_events::mako::APERAK_REJECTED,
249 Self::AperakTimeout => mako_events::mako::APERAK_TIMEOUT,
250 Self::ContrlReceived => mako_events::mako::CONTRL_RECEIVED,
251 Self::ProcessCompleted => mako_events::mako::PROCESS_COMPLETED,
252 Self::MaloIdentified => mako_events::mako::MALO_IDENTIFIED,
253 Self::ProcessFailed { .. } => mako_events::mako::PROCESS_FAILED,
254 Self::VppDispatchConfirmed => mako_events::vpp::DISPATCH_CONFIRMED,
255 Self::GabiFinalAllocationOverdue => mako_events::gabi::ALOCAT_MISSING,
256 Self::AbmeldeanfrageBeantwortet => mako_events::mako::ABMELDEANFRAGE_BEANTWORTET,
257 }
258 }
259}
260
261// ── ErpEvent ──────────────────────────────────────────────────────────────────
262
263/// A structured process event delivered from `mako-engine` to the ERP.
264///
265/// The payload is always a **BO4E-typed JSON object** — the ERP adapter never
266/// receives raw EDIFACT bytes or EDIFACT format-version identifiers.
267///
268/// On the wire (via `WebhookErpAdapter`) this struct is serialised as a
269/// **[CloudEvents 1.0](https://cloudevents.io) structured-mode JSON** envelope
270/// with `Content-Type: application/cloudevents+json`. The BO4E payload lives
271/// in the CloudEvents `data` field; `payload_schema` maps to `dataschema`;
272/// `event_type` maps to the `type` attribute via [`ErpEventType::cloud_event_type`].
273///
274/// ## Idempotency
275///
276/// `idempotency_key` maps to the CloudEvents `id` attribute and is also sent
277/// as `X-Idempotency-Key` for ERP middleware that keys on headers. The ERP
278/// **must** persist this key and return `HTTP 200 OK` for duplicate deliveries.
279#[derive(Debug, Clone, Serialize, Deserialize)]
280pub struct ErpEvent {
281 /// Stable dedup key — store in the ERP to reject duplicate deliveries.
282 ///
283 /// Derived from the outbox `message_id`; stable across retries.
284 pub idempotency_key: String,
285
286 /// Semantic classification of this event.
287 pub event_type: ErpEventType,
288
289 /// The mako process that generated this event.
290 pub process_id: ProcessId,
291
292 /// Tenant (operator GLN) that owns this process.
293 pub tenant_id: TenantId,
294
295 /// BDEW business conversation identifier.
296 pub conversation_id: ConversationId,
297
298 /// The mako domain event that directly caused this ERP notification.
299 pub causation_id: EventId,
300
301 /// Prüfidentifikator of the process.
302 pub pid: u32,
303
304 /// BO4E JSON Schema URL that validates [`payload`](ErpEvent::payload).
305 ///
306 /// Examples:
307 /// - `"https://raw.githubusercontent.com/BO4E/BO4E-Schemas/v202607.1.0/src/bo4e_schemas/bo/Marktlokation.json"`
308 /// - `"https://raw.githubusercontent.com/BO4E/BO4E-Schemas/v202607.1.0/src/bo4e_schemas/bo/Messlokation.json"`
309 ///
310 /// `None` for events where no primary BO4E object is applicable
311 /// (e.g. `ContrlReceived`).
312 #[serde(skip_serializing_if = "Option::is_none")]
313 pub payload_schema: Option<String>,
314
315 /// BO4E-typed payload.
316 ///
317 /// Deserialise using the ERP's own BO4E library. Raw EDIFACT structures
318 /// are never exposed here. `null` when no payload is applicable.
319 pub payload: serde_json::Value,
320
321 /// Wall-clock time when the domain event was persisted.
322 pub occurred_at: OffsetDateTime,
323
324 /// W3C `traceparent` propagated from the request that caused this event.
325 ///
326 /// Copied from `OutboxMessage::trace_context`; injected into the webhook
327 /// delivery as the `traceparent` HTTP header and the CloudEvents
328 /// `traceparent` extension attribute (CloudEvents distributed-tracing
329 /// extension), so the ERP joins the same trace as the inbound transport.
330 #[serde(default, skip_serializing_if = "Option::is_none")]
331 pub trace_context: Option<Box<str>>,
332
333 /// Workflow family name that produced this event (e.g. `"gpke-sperrung"`).
334 ///
335 /// Carried through from `OutboxMessage::workflow_name`. Emitted as the
336 /// `makoworkflow` CloudEvents extension attribute by `WebhookErpAdapter`.
337 /// `marktd` maps this to `marktrole` for role-scoped ERP subscriber fan-out.
338 ///
339 /// Empty string for events produced by legacy outbox messages that
340 /// predate this field.
341 pub workflow_name: Box<str>,
342}
343
344// ── ErpAdapter trait ──────────────────────────────────────────────────────────
345
346/// Outbound notification sink — `mako-engine` calls this when a process event
347/// should be reported to the ERP.
348///
349/// The payload is always a BO4E-typed JSON object; the adapter never receives
350/// raw EDIFACT bytes or format-version identifiers.
351///
352/// ## Contract
353///
354/// - Must be **idempotent** on `event.idempotency_key`. Called twice with the
355/// same key must succeed without double-posting.
356/// - Return [`ErpAdapterError::Transport`] for transient failures — the caller
357/// will retry with exponential backoff.
358/// - Return [`ErpAdapterError::Permanent`] for non-retryable failures — the
359/// caller will dead-letter the event.
360#[allow(async_fn_in_trait)]
361pub trait ErpAdapter: Send + Sync + 'static {
362 /// Deliver `event` to the ERP.
363 async fn notify(&self, event: ErpEvent) -> Result<(), ErpAdapterError>;
364}
365
366/// Blanket `Arc` implementation so `ErpAdapter` can be shared across tasks.
367impl<T: ErpAdapter> ErpAdapter for Arc<T> {
368 async fn notify(&self, event: ErpEvent) -> Result<(), ErpAdapterError> {
369 (**self).notify(event).await
370 }
371}
372
373// ── InboundErpCommand ─────────────────────────────────────────────────────────
374
375/// A BO4E business object received from the ERP, intended to trigger a mako
376/// process.
377///
378/// `mako-engine` maps the BO4E payload to an internal `Command` via the
379/// domain crate's command mapper.
380#[derive(Debug, Clone, Serialize, Deserialize)]
381pub struct InboundErpCommand {
382 /// Stable dedup key — forwarded to [`InboxStore::accept`].
383 ///
384 /// The ERP must supply a stable, unique identifier per command so that
385 /// retransmissions do not double-execute the workflow.
386 ///
387 /// [`InboxStore::accept`]: crate::inbox::InboxStore::accept
388 pub idempotency_key: String,
389
390 /// Tenant (operator GLN) that owns the target process.
391 pub tenant_id: TenantId,
392
393 /// BO4E JSON Schema URL — identifies the object type without inspecting
394 /// `payload`.
395 ///
396 /// Example:
397 /// `"https://raw.githubusercontent.com/BO4E/BO4E-Schemas/v202607.1.0/src/bo4e_schemas/bo/Vertrag.json"`
398 pub payload_schema: String,
399
400 /// BO4E-typed JSON payload. `mako-engine` maps this to an internal
401 /// `Command` via the registered domain command mapper.
402 pub payload: serde_json::Value,
403}
404
405// ── ErpCommandSource trait ────────────────────────────────────────────────────
406
407/// Inbound command source — `mako-engine` polls this for new BO4E objects
408/// from the ERP.
409///
410/// Implement this for broker-based inbound flows (Kafka consumer, SFTP poll,
411/// database change feed, …) to make the entire integration fully event-driven
412/// — no synchronous REST round-trip required.
413///
414/// ## Contract
415///
416/// - [`next`](ErpCommandSource::next) must be **non-blocking** when idle —
417/// return `Ok(None)` immediately when no command is available.
418/// - [`ack`](ErpCommandSource::ack) must suppress re-delivery of `id` after
419/// a successful ack (idempotent).
420/// - [`nack`](ErpCommandSource::nack) should allow re-delivery of `id` after
421/// an appropriate backoff.
422#[allow(async_fn_in_trait)]
423pub trait ErpCommandSource: Send + Sync + 'static {
424 /// Return the next pending BO4E command, or `None` when the source is idle.
425 async fn next(&self) -> Result<Option<InboundErpCommand>, ErpAdapterError>;
426
427 /// Acknowledge successful processing of `id`.
428 ///
429 /// After a successful ack the source must not re-deliver `id`.
430 async fn ack(&self, id: &str) -> Result<(), ErpAdapterError>;
431
432 /// Negative-acknowledge — allow re-delivery of `id` after backoff.
433 async fn nack(&self, id: &str, reason: &str) -> Result<(), ErpAdapterError>;
434}
435
436/// Blanket `Arc` implementation so `ErpCommandSource` can be shared across tasks.
437impl<S: ErpCommandSource> ErpCommandSource for Arc<S> {
438 async fn next(&self) -> Result<Option<InboundErpCommand>, ErpAdapterError> {
439 (**self).next().await
440 }
441 async fn ack(&self, id: &str) -> Result<(), ErpAdapterError> {
442 (**self).ack(id).await
443 }
444 async fn nack(&self, id: &str, reason: &str) -> Result<(), ErpAdapterError> {
445 (**self).nack(id, reason).await
446 }
447}
448
449// ── NoopErpAdapter ────────────────────────────────────────────────────────────
450
451/// An [`ErpAdapter`] that succeeds immediately without notifying anything.
452///
453/// Use in unit tests and CI where no real ERP endpoint is available.
454#[cfg(feature = "testing")]
455#[derive(Debug, Clone, Default)]
456pub struct NoopErpAdapter;
457
458#[cfg(feature = "testing")]
459impl ErpAdapter for NoopErpAdapter {
460 async fn notify(&self, _event: ErpEvent) -> Result<(), ErpAdapterError> {
461 Ok(())
462 }
463}
464
465// ── LogErpAdapter ─────────────────────────────────────────────────────────────
466
467/// An [`ErpAdapter`] that logs every event at `info` level without delivering
468/// it.
469///
470/// Useful as a development starting point — replace it with your concrete ERP
471/// adapter in production.
472#[derive(Debug, Clone, Default)]
473pub struct LogErpAdapter;
474
475impl ErpAdapter for LogErpAdapter {
476 async fn notify(&self, event: ErpEvent) -> Result<(), ErpAdapterError> {
477 tracing::info!(
478 idempotency_key = %event.idempotency_key,
479 event_type = event.event_type.label(),
480 process_id = %event.process_id,
481 tenant_id = %event.tenant_id,
482 pid = event.pid,
483 "ErpAdapter: event logged (no delivery configured)",
484 );
485 Ok(())
486 }
487}
488
489// ── NoopErpCommandSource ──────────────────────────────────────────────────────
490
491/// An [`ErpCommandSource`] that is always idle (returns `Ok(None)`).
492///
493/// Use in tests where no inbound ERP command flow is needed.
494#[cfg(feature = "testing")]
495#[derive(Debug, Clone, Default)]
496pub struct NoopErpCommandSource;
497
498#[cfg(feature = "testing")]
499impl ErpCommandSource for NoopErpCommandSource {
500 async fn next(&self) -> Result<Option<InboundErpCommand>, ErpAdapterError> {
501 Ok(None)
502 }
503 async fn ack(&self, _id: &str) -> Result<(), ErpAdapterError> {
504 Ok(())
505 }
506 async fn nack(&self, _id: &str, _reason: &str) -> Result<(), ErpAdapterError> {
507 Ok(())
508 }
509}
510
511// ── ErpAdapterTestHarness ─────────────────────────────────────────────────────
512
513/// A recording [`ErpAdapter`] for use in tests.
514///
515/// Records every [`ErpEvent`] delivered via [`notify`](ErpAdapter::notify) so
516/// tests can assert on event types, ordering, and BO4E payload shapes.
517///
518/// ```rust,ignore
519/// let harness = ErpAdapterTestHarness::new();
520/// my_workflow.run_with_adapter(harness.adapter()).await?;
521///
522/// let events = harness.events();
523/// assert_eq!(events[0].event_type, ErpEventType::ProcessInitiated);
524/// assert_eq!(events[1].event_type, ErpEventType::AperakAccepted);
525/// ```
526#[cfg(feature = "testing")]
527#[derive(Debug, Clone, Default)]
528pub struct ErpAdapterTestHarness {
529 events: Arc<tokio::sync::Mutex<Vec<ErpEvent>>>,
530}
531
532#[cfg(feature = "testing")]
533impl ErpAdapterTestHarness {
534 /// Create a new empty harness.
535 #[must_use]
536 pub fn new() -> Self {
537 Self::default()
538 }
539
540 /// Return a snapshot of all recorded events in delivery order.
541 pub async fn events(&self) -> Vec<ErpEvent> {
542 self.events.lock().await.clone()
543 }
544
545 /// Drain all recorded events, resetting the harness.
546 pub async fn drain(&self) -> Vec<ErpEvent> {
547 std::mem::take(&mut *self.events.lock().await)
548 }
549}
550
551#[cfg(feature = "testing")]
552impl ErpAdapter for ErpAdapterTestHarness {
553 async fn notify(&self, event: ErpEvent) -> Result<(), ErpAdapterError> {
554 self.events.lock().await.push(event);
555 Ok(())
556 }
557}
558
559// ── ErpCommandSourceTestHarness ───────────────────────────────────────────────
560
561/// A controllable [`ErpCommandSource`] for use in tests.
562///
563/// Inject canned [`InboundErpCommand`] payloads and verify that the engine
564/// processes them correctly.
565///
566/// ```text
567/// let source = ErpCommandSourceTestHarness::new();
568/// source.inject(InboundErpCommand {
569/// idempotency_key: "order-42".into(),
570/// tenant_id: TenantId::new(),
571/// payload_schema: ".../Vertrag.json".into(),
572/// payload: serde_json::json!({ "_typ": "VERTRAG", ... }),
573/// }).await;
574///
575/// // The engine picks up the command on the next poll.
576/// ```
577#[cfg(feature = "testing")]
578#[derive(Debug, Clone, Default)]
579pub struct ErpCommandSourceTestHarness {
580 queue: Arc<tokio::sync::Mutex<std::collections::VecDeque<InboundErpCommand>>>,
581 acked: Arc<tokio::sync::Mutex<Vec<String>>>,
582 nacked: Arc<tokio::sync::Mutex<Vec<(String, String)>>>,
583}
584
585#[cfg(feature = "testing")]
586impl ErpCommandSourceTestHarness {
587 /// Create a new empty harness.
588 #[must_use]
589 pub fn new() -> Self {
590 Self::default()
591 }
592
593 /// Enqueue a command to be returned by the next [`next`](ErpCommandSource::next) call.
594 pub async fn inject(&self, cmd: InboundErpCommand) {
595 self.queue.lock().await.push_back(cmd);
596 }
597
598 /// Return all acked command IDs.
599 pub async fn acked(&self) -> Vec<String> {
600 self.acked.lock().await.clone()
601 }
602
603 /// Return all nacked `(id, reason)` pairs.
604 pub async fn nacked(&self) -> Vec<(String, String)> {
605 self.nacked.lock().await.clone()
606 }
607}
608
609#[cfg(feature = "testing")]
610impl ErpCommandSource for ErpCommandSourceTestHarness {
611 async fn next(&self) -> Result<Option<InboundErpCommand>, ErpAdapterError> {
612 Ok(self.queue.lock().await.pop_front())
613 }
614
615 async fn ack(&self, id: &str) -> Result<(), ErpAdapterError> {
616 self.acked.lock().await.push(id.to_owned());
617 Ok(())
618 }
619
620 async fn nack(&self, id: &str, reason: &str) -> Result<(), ErpAdapterError> {
621 self.nacked
622 .lock()
623 .await
624 .push((id.to_owned(), reason.to_owned()));
625 Ok(())
626 }
627}
628
629// ── BO4E schema URL constants ─────────────────────────────────────────────────
630
631/// BO4E schema URL base for the pinned snapshot.
632///
633/// Use `bo4e_schema_url!(Marktlokation)` to construct typed schema URLs at
634/// compile time.
635///
636/// **The `v` belongs here and only here.** BO4E prefixes its git *tags* with
637/// one, so a raw.githubusercontent URL needs `v202607.1.0`; the `_version`
638/// field inside a payload never has it and reads `202607.1.0`.
639/// `mako_markt::bo4e::schema_version` is the payload spelling, and
640/// `services/makod/tests/bo4e_version_guard.rs` pins the two together — this
641/// crate cannot derive the tag itself because it does not depend on rubo4e.
642pub const BO4E_V202607_BASE: &str =
643 "https://raw.githubusercontent.com/BO4E/BO4E-Schemas/v202607.1.0/src/bo4e_schemas";
644
645/// Construct a BO4E JSON Schema URL for a Business Object.
646///
647/// ```rust
648/// use mako_engine::bo4e_schema_url;
649/// assert!(bo4e_schema_url!("bo", "Marktlokation").contains("Marktlokation"));
650/// ```
651#[macro_export]
652macro_rules! bo4e_schema_url {
653 ($category:literal, $name:literal) => {
654 concat!(
655 "https://raw.githubusercontent.com/BO4E/BO4E-Schemas/v202607.1.0/src/bo4e_schemas/",
656 $category,
657 "/",
658 $name,
659 ".json",
660 )
661 };
662}
663
664/// BO4E JSON Schema URL for `Marktlokation`.
665pub const BO4E_SCHEMA_MARKTLOKATION: &str = bo4e_schema_url!("bo", "Marktlokation");
666
667/// BO4E JSON Schema URL for `Messlokation`.
668pub const BO4E_SCHEMA_MESSLOKATION: &str = bo4e_schema_url!("bo", "Messlokation");
669
670/// BO4E JSON Schema URL for `Vertrag`.
671pub const BO4E_SCHEMA_VERTRAG: &str = bo4e_schema_url!("bo", "Vertrag");
672
673/// BO4E JSON Schema URL for `Energiemenge`.
674pub const BO4E_SCHEMA_ENERGIEMENGE: &str = bo4e_schema_url!("bo", "Energiemenge");
675
676/// BO4E JSON Schema URL for `Rechnung`.
677pub const BO4E_SCHEMA_RECHNUNG: &str = bo4e_schema_url!("bo", "Rechnung");
678
679/// BO4E JSON Schema URL for `Zaehler`.
680pub const BO4E_SCHEMA_ZAEHLER: &str = bo4e_schema_url!("bo", "Zaehler");
681
682/// BO4E JSON Schema URL for `Geschaeftspartner`.
683pub const BO4E_SCHEMA_GESCHAEFTSPARTNER: &str = bo4e_schema_url!("bo", "Geschaeftspartner");