Skip to main content

mako_redispatch/
ack_forward.rs

1//! Generic acknowledge-and-forward workflow for Redispatch 2.0.
2//!
3//! Shared state machine used by:
4//! - Verfügbarkeitsmeldung (`redispatch-verfuegbarkeit`)
5//! - Netzengpassinformation (`redispatch-netzengpass`)
6//! - `Kaskade` §13 Abs. 2 (`redispatch-kaskade`)
7//! - Planungsdaten Abruffahrplan (`redispatch-planungsdaten`)
8//! - Statusanfrage (`redispatch-statusanfrage`)
9//! - `Kostenblatt` (`redispatch-kostenblatt`)
10//!
11//! Each of these processes follows the same pattern:
12//! 1. Receive an XML document.
13//! 2. Send an `AcknowledgementDocument` within **3 minutes** (UTC) —
14//!    „unverzüglich, jedoch spätestens 3 Minuten nach Erhalt der
15//!    Übertragungsdatei" (`AcknowledgementDocument` FB 1.0g). It answers the
16//!    transfer file's *syntax*; a late one must not fail the Geschäftsvorfall
17//!    it carried.
18//! 3. Optionally forward to an upstream party.
19//!
20//! A separate workflow struct per process is defined below so that workflow
21//! names, BDEW references, and deadline labels remain distinct.
22
23use mako_engine::{
24    deadline::Deadline,
25    error::WorkflowError,
26    ids::DeadlineId,
27    workflow::{CommandPayload, EventPayload, Workflow, WorkflowOutput},
28};
29use serde::{Deserialize, Serialize};
30
31// ── Generic events ─────────────────────────────────────────────────────────────
32
33/// Events shared by all acknowledge-and-forward workflows.
34#[derive(Debug, Clone, Serialize, Deserialize)]
35#[serde(tag = "type", content = "data")]
36pub enum AckForwardEvent {
37    /// XML document received.
38    Received {
39        /// MRID (UUID) of the received document.
40        mrid: String,
41        /// Document type string (e.g. `"Unavailability"`, `"`Kaskade`"`).
42        doc_type: String,
43        /// GLN of the sender.
44        sender: String,
45        /// GLN of the receiver.
46        receiver: String,
47        /// UTC receipt timestamp (ISO-8601).
48        received_at: String,
49    },
50    /// `AcknowledgementDocument` dispatched within the 3-minute window.
51    Acknowledged {
52        /// MRID of the outbound `AcknowledgementDocument`.
53        ack_mrid: String,
54    },
55    /// Document forwarded upstream (role-conditional).
56    Forwarded {
57        /// MRID of the forwarded document.
58        upstream_mrid: String,
59    },
60    /// A registered deadline expired.
61    DeadlineExpired {
62        /// Unique ID of the expired deadline.
63        deadline_id: DeadlineId,
64        /// Label identifying the deadline.
65        label: Box<str>,
66    },
67}
68
69/// Commands shared by all acknowledge-and-forward workflows.
70#[derive(Clone)]
71pub enum AckForwardCommand {
72    /// Inbound document received.
73    Receive {
74        /// MRID of the received document.
75        mrid: String,
76        /// Document type string.
77        doc_type: String,
78        /// Sender GLN.
79        sender: String,
80        /// Receiver GLN.
81        receiver: String,
82        /// UTC receipt timestamp.
83        received_at: String,
84    },
85    /// `AcknowledgementDocument` dispatched.
86    Acknowledge {
87        /// MRID of the outbound `AcknowledgementDocument`.
88        ack_mrid: String,
89    },
90    /// Document forwarded upstream.
91    Forward {
92        /// MRID of the forwarded document.
93        upstream_mrid: String,
94    },
95    /// Deadline fired.
96    TimeoutExpired {
97        /// Unique ID of the expired deadline.
98        deadline_id: DeadlineId,
99        /// Label identifying the deadline.
100        label: Box<str>,
101    },
102}
103
104impl CommandPayload for AckForwardCommand {}
105
106/// Core data captured on receipt.
107#[derive(Debug, Clone, Serialize, Deserialize)]
108#[serde(deny_unknown_fields)]
109pub struct ReceivedData {
110    /// MRID of the received document.
111    pub mrid: String,
112    /// Document type string.
113    pub doc_type: String,
114    /// Sender GLN.
115    pub sender: String,
116    /// Receiver GLN.
117    pub receiver: String,
118    /// Receipt timestamp.
119    pub received_at: String,
120}
121
122/// Generic state for acknowledge-and-forward workflows.
123#[derive(Debug, Clone, Default, Serialize, Deserialize)]
124#[serde(tag = "status", content = "data")]
125pub enum AckForwardState {
126    /// No events yet.
127    #[default]
128    New,
129    /// Document received; acknowledgement not yet sent.
130    Received(ReceivedData),
131    /// `AcknowledgementDocument` dispatched.
132    Acknowledged(ReceivedData),
133    /// Document forwarded upstream.
134    Forwarded(ReceivedData),
135    /// A registered deadline expired without acknowledgement.
136    DeadlineExpired {
137        /// Human-readable reason.
138        reason: String,
139    },
140}
141
142impl AckForwardState {
143    /// Stable string label.
144    #[must_use]
145    pub fn label(&self) -> &'static str {
146        match self {
147            Self::New => "New",
148            Self::Received(_) => "Received",
149            Self::Acknowledged(_) => "Acknowledged",
150            Self::Forwarded(_) => "Forwarded",
151            Self::DeadlineExpired { .. } => "DeadlineExpired",
152        }
153    }
154}
155
156impl EventPayload for AckForwardEvent {
157    fn event_type(&self) -> &'static str {
158        match self {
159            Self::Received { .. } => "AckForwardReceived",
160            Self::Acknowledged { .. } => "AckForwardAcknowledged",
161            Self::Forwarded { .. } => "AckForwardForwarded",
162            Self::DeadlineExpired { .. } => "AckForwardDeadlineExpired",
163        }
164    }
165}
166
167// ── Per-workflow event newtypes ────────────────────────────────────────────────
168//
169// Each workflow needs distinct event_type() strings so that event logs,
170// projections, and observability tools can identify events unambiguously
171// across all six ack-forward process families.
172//
173// The macro below generates a thin newtype `FooEvent(AckForwardEvent)` for each
174// workflow, with `EventPayload::event_type()` returning prefixed names such as
175// `"VerfuegbarkeitReceived"`.  All apply/handle logic delegates to the shared
176// `AckForwardEvent` via `From<FooEvent> for AckForwardEvent`.
177
178macro_rules! define_workflow_event {
179    ($event_type:ident, $prefix:expr) => {
180        /// Workflow-specific event newtype for one of the six ack-forward process
181        /// families.
182        ///
183        /// Wraps [`AckForwardEvent`] and returns a workflow-specific prefix from
184        /// [`EventPayload::event_type`] so events from different ack-forward
185        /// workflows are distinguishable in projections and the event log.
186        #[derive(Debug, Clone, Serialize, Deserialize)]
187        #[serde(transparent)]
188        pub struct $event_type(pub AckForwardEvent);
189
190        impl From<AckForwardEvent> for $event_type {
191            fn from(e: AckForwardEvent) -> Self {
192                Self(e)
193            }
194        }
195
196        impl From<$event_type> for AckForwardEvent {
197            fn from(e: $event_type) -> AckForwardEvent {
198                e.0
199            }
200        }
201
202        impl EventPayload for $event_type {
203            fn event_type(&self) -> &'static str {
204                match &self.0 {
205                    AckForwardEvent::Received { .. } => concat!($prefix, "Received"),
206                    AckForwardEvent::Acknowledged { .. } => concat!($prefix, "Acknowledged"),
207                    AckForwardEvent::Forwarded { .. } => concat!($prefix, "Forwarded"),
208                    AckForwardEvent::DeadlineExpired { .. } => {
209                        concat!($prefix, "DeadlineExpired")
210                    }
211                }
212            }
213        }
214    };
215}
216
217define_workflow_event!(VerfuegbarkeitEvent, "Verfuegbarkeit");
218define_workflow_event!(NetzengpassEvent, "Netzengpass");
219define_workflow_event!(KaskadeEvent, "Kaskade");
220define_workflow_event!(PlanungsdatenEvent, "Planungsdaten");
221define_workflow_event!(StatusanfrageEvent, "Statusanfrage");
222define_workflow_event!(KostenblattEvent, "Kostenblatt");
223
224// ── Shared apply / handle logic ───────────────────────────────────────────────
225
226/// Apply an `AckForwardEvent` to `AckForwardState`.
227pub(crate) fn apply(state: AckForwardState, event: &AckForwardEvent) -> AckForwardState {
228    match event {
229        AckForwardEvent::Received {
230            mrid,
231            doc_type,
232            sender,
233            receiver,
234            received_at,
235        } => AckForwardState::Received(ReceivedData {
236            mrid: mrid.clone(),
237            doc_type: doc_type.clone(),
238            sender: sender.clone(),
239            receiver: receiver.clone(),
240            received_at: received_at.clone(),
241        }),
242
243        AckForwardEvent::Acknowledged { .. } => match state {
244            AckForwardState::Received(data) => AckForwardState::Acknowledged(data),
245            other => other,
246        },
247
248        AckForwardEvent::Forwarded { .. } => match state {
249            AckForwardState::Acknowledged(data) => AckForwardState::Forwarded(data),
250            other => other,
251        },
252
253        AckForwardEvent::DeadlineExpired { label, .. } => AckForwardState::DeadlineExpired {
254            reason: format!("deadline expired: {label}"),
255        },
256    }
257}
258
259/// Handle an `AckForwardCommand` against `AckForwardState`.
260pub(crate) fn handle(
261    state: &AckForwardState,
262    command: AckForwardCommand,
263    ack_window_label: &str,
264) -> Result<WorkflowOutput<AckForwardEvent>, WorkflowError> {
265    match command {
266        AckForwardCommand::Receive {
267            mrid,
268            doc_type,
269            sender,
270            receiver,
271            received_at,
272        } => {
273            if !matches!(state, AckForwardState::New) {
274                return Ok(vec![].into());
275            }
276            Ok(vec![AckForwardEvent::Received {
277                mrid,
278                doc_type,
279                sender,
280                receiver,
281                received_at,
282            }]
283            .into())
284        }
285
286        AckForwardCommand::Acknowledge { ack_mrid } => match state {
287            AckForwardState::Received(_) => {
288                Ok(vec![AckForwardEvent::Acknowledged { ack_mrid }].into())
289            }
290            AckForwardState::Acknowledged(_) | AckForwardState::Forwarded(_) => Ok(vec![].into()),
291            other => Err(WorkflowError::rejected(format!(
292                "Acknowledge not valid in state {}",
293                other.label()
294            ))),
295        },
296
297        AckForwardCommand::Forward { upstream_mrid } => match state {
298            AckForwardState::Acknowledged(_) => {
299                Ok(vec![AckForwardEvent::Forwarded { upstream_mrid }].into())
300            }
301            AckForwardState::Forwarded(_) => Ok(vec![].into()),
302            other => Err(WorkflowError::rejected(format!(
303                "Forward not valid in state {}",
304                other.label()
305            ))),
306        },
307
308        AckForwardCommand::TimeoutExpired { deadline_id, label } => match state {
309            AckForwardState::Acknowledged(_)
310            | AckForwardState::Forwarded(_)
311            | AckForwardState::DeadlineExpired { .. } => Ok(vec![].into()),
312            _ => {
313                let _ = ack_window_label; // used by caller for label registration
314                Ok(vec![AckForwardEvent::DeadlineExpired { deadline_id, label }].into())
315            }
316        },
317    }
318}
319
320// ── Per-process workflow structs ───────────────────────────────────────────────
321
322macro_rules! ack_forward_workflow {
323    (
324        $(#[$meta:meta])*
325        $name:ident,
326        $event_newtype:ident,
327        $workflow_name:expr,
328        $ack_label:expr,
329        $event_prefix:expr $(,)?
330    ) => {
331        $(#[$meta])*
332        pub struct $name;
333
334        impl Workflow for $name {
335            type State   = AckForwardState;
336            type Event   = $event_newtype;
337            type Command = AckForwardCommand;
338
339            fn on_deadline(
340                deadline: &Deadline,
341                state: &Self::State,
342            ) -> Option<Self::Command> {
343                if deadline.label() == $ack_label {
344                    if matches!(state, AckForwardState::Received(_)) {
345                        return Some(AckForwardCommand::TimeoutExpired {
346                            deadline_id: deadline.deadline_id(),
347                            label: deadline.label().into(),
348                        });
349                    }
350                }
351                None
352            }
353
354            fn apply(state: Self::State, event: &Self::Event) -> Self::State {
355                crate::ack_forward::apply(state, &event.0)
356            }
357
358            fn handle(
359                state: &Self::State,
360                command: Self::Command,
361            ) -> Result<WorkflowOutput<Self::Event>, WorkflowError> {
362                let output = crate::ack_forward::handle(state, command, $ack_label)?;
363                Ok(WorkflowOutput::with_outbox(
364                    output.events.into_iter().map($event_newtype).collect(),
365                    output.outbox,
366                ))
367            }
368        }
369
370        impl $name {
371            /// Return the event-type prefix for this workflow's events.
372            #[must_use]
373            pub fn event_prefix() -> &'static str {
374                $event_prefix
375            }
376        }
377    };
378}
379
380ack_forward_workflow!(
381    /// Verfügbarkeitsmeldung workflow — `UnavailabilityMarketDocument`.
382    ///
383    /// ANB → VNB. Receiver acknowledges within 3 minutes
384    /// ([`crate::fristen::ACK_FRIST`]).
385    VerfuegbarkeitWorkflow,
386    VerfuegbarkeitEvent,
387    "redispatch-verfuegbarkeit",
388    "redispatch-verfuegbarkeit-ack-window",
389    "Verfuegbarkeit",
390);
391
392ack_forward_workflow!(
393    /// Netzengpassinformation workflow — `NetworkConstraintDocument`.
394    ///
395    /// ÜNB ↔ VNB. Receiver acknowledges within 3 minutes
396    /// ([`crate::fristen::ACK_FRIST`]).
397    NetzengpassWorkflow,
398    NetzengpassEvent,
399    "redispatch-netzengpass",
400    "redispatch-netzengpass-ack-window",
401    "Netzengpass",
402);
403
404ack_forward_workflow!(
405    /// `Kaskade` workflow — emergency measures per § 13 Abs. 2 `EnWG`.
406    ///
407    /// ÜNB → VNB → ANB. Receiver acknowledges within 3 minutes
408    /// ([`crate::fristen::ACK_FRIST`]).
409    /// Only active for `Marktrolle::Nb` and `Marktrolle::Unb` deployments.
410    KaskadeWorkflow,
411    KaskadeEvent,
412    "redispatch-kaskade",
413    "redispatch-kaskade-ack-window",
414    "Kaskade",
415);
416
417ack_forward_workflow!(
418    /// Planungsdaten (Abruffahrplan) workflow — `PlannedResourceScheduleDocument`.
419    ///
420    /// ÜNB → VNB → ANB. Receiver acknowledges within 3 minutes
421    /// ([`crate::fristen::ACK_FRIST`]).
422    PlanungsdatenWorkflow,
423    PlanungsdatenEvent,
424    "redispatch-planungsdaten",
425    "redispatch-planungsdaten-ack-window",
426    "Planungsdaten",
427);
428
429ack_forward_workflow!(
430    /// Statusanfrage workflow — `StatusRequest_MarketDocument`.
431    ///
432    /// **Not a request/response pair.** The document's `type` is `A60` (status
433    /// request for a position independently from a specific process) or `Z15`
434    /// Erreichbarkeitsinformation, and its `status` carries `A03` Deactivated /
435    /// `A04` Reactivated / `A13` Withdrawn — a communication-availability
436    /// notification about a Marktpartner. There is no answer document and no
437    /// 24-hour window; the acknowledgement is the only thing owed back
438    /// (`StatusRequest_MarketDocument` FB 1.1).
439    StatusanfrageWorkflow,
440    StatusanfrageEvent,
441    "redispatch-statusanfrage",
442    "redispatch-statusanfrage-response-window",
443    "Statusanfrage",
444);
445
446ack_forward_workflow!(
447    /// `Kostenblatt` workflow — monthly cost reconciliation.
448    ///
449    /// VNB → ÜNB. Receiver acknowledges within 3 minutes
450    /// ([`crate::fristen::ACK_FRIST`]). The submission day is
451    /// operator-configured — BK6-23-241 Tenorziffer 3 repealed BK6-20-061, so
452    /// the 15th of the following month is a historical default rather than a
453    /// published obligation
454    /// ([`crate::fristen::`Betreiberfristen`::kostenblatt_stichtag`]).
455    KostenblattWorkflow,
456    KostenblattEvent,
457    "redispatch-kostenblatt",
458    "redispatch-kostenblatt-ack-window",
459    "Kostenblatt",
460);
461
462/// Workflow name constants for each process.
463/// Workflow name constants for each process in the acknowledge-and-forward family.
464pub mod names {
465    /// Workflow name for `VerfuegbarkeitWorkflow`.
466    pub const VERFUEGBARKEIT: &str = "redispatch-verfuegbarkeit";
467    /// Workflow name for `NetzengpassWorkflow`.
468    pub const NETZENGPASS: &str = "redispatch-netzengpass";
469    /// Workflow name for `KaskadeWorkflow`.
470    pub const KASKADE: &str = "redispatch-kaskade";
471    /// Workflow name for `PlanungsdatenWorkflow`.
472    pub const PLANUNGSDATEN: &str = "redispatch-planungsdaten";
473    /// Workflow name for `StatusanfrageWorkflow`.
474    pub const STATUSANFRAGE: &str = "redispatch-statusanfrage";
475    /// Workflow name for `KostenblattWorkflow`.
476    pub const KOSTENBLATT: &str = "redispatch-kostenblatt";
477}
478
479#[cfg(test)]
480mod tests {
481    use super::*;
482    use mako_engine::workflow::EventPayload;
483
484    #[test]
485    fn verfuegbarkeit_receive_to_acknowledged() {
486        let state = AckForwardState::New;
487        let output = VerfuegbarkeitWorkflow::handle(
488            &state,
489            AckForwardCommand::Receive {
490                mrid: "m1".into(),
491                doc_type: "Unavailability".into(),
492                sender: "s".into(),
493                receiver: "r".into(),
494                received_at: "2025-10-15T10:00:00Z".into(),
495            },
496        )
497        .unwrap();
498        assert_eq!(output.events.len(), 1);
499
500        let state2 = VerfuegbarkeitWorkflow::apply(state, &output.events[0]);
501        assert!(matches!(state2, AckForwardState::Received(_)));
502
503        let output2 = VerfuegbarkeitWorkflow::handle(
504            &state2,
505            AckForwardCommand::Acknowledge {
506                ack_mrid: "ack-1".into(),
507            },
508        )
509        .unwrap();
510        let state3 = VerfuegbarkeitWorkflow::apply(state2, &output2.events[0]);
511        assert!(matches!(state3, AckForwardState::Acknowledged(_)));
512    }
513
514    #[test]
515    fn kaskade_forward_requires_acknowledged_state() {
516        let state = AckForwardState::Received(ReceivedData {
517            mrid: "m".into(),
518            doc_type: "Kaskade".into(),
519            sender: "s".into(),
520            receiver: "r".into(),
521            received_at: "2025-10-15T10:00:00Z".into(),
522        });
523        let result = KaskadeWorkflow::handle(
524            &state,
525            AckForwardCommand::Forward {
526                upstream_mrid: "u".into(),
527            },
528        );
529        assert!(result.is_err());
530    }
531
532    /// Verify that each workflow's event types are unique and correctly prefixed.
533    #[test]
534    fn event_types_are_unique_per_workflow() {
535        let inner = AckForwardEvent::Received {
536            mrid: "m".into(),
537            doc_type: "X".into(),
538            sender: "s".into(),
539            receiver: "r".into(),
540            received_at: "t".into(),
541        };
542
543        let types: Vec<&'static str> = vec![
544            VerfuegbarkeitEvent(inner.clone()).event_type(),
545            NetzengpassEvent(inner.clone()).event_type(),
546            KaskadeEvent(inner.clone()).event_type(),
547            PlanungsdatenEvent(inner.clone()).event_type(),
548            StatusanfrageEvent(inner.clone()).event_type(),
549            KostenblattEvent(inner.clone()).event_type(),
550        ];
551
552        // All event types must be distinct.
553        let unique: std::collections::HashSet<_> = types.iter().collect();
554        assert_eq!(
555            unique.len(),
556            types.len(),
557            "event_type() strings must be unique across all ack-forward workflows: {types:?}"
558        );
559
560        // All event types must be prefixed (not generic "AckForward…" names).
561        for t in &types {
562            assert!(
563                !t.starts_with("AckForward"),
564                "event_type '{t}' must not use the generic AckForward prefix"
565            );
566        }
567    }
568}