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            /// The name this workflow's processes are spawned and resumed
372            /// under — the module constant, restated on the type so a reader
373            /// holding the struct can reach it.
374            pub const WORKFLOW_NAME: &'static str = $workflow_name;
375
376            /// Return the event-type prefix for this workflow's events.
377            #[must_use]
378            pub fn event_prefix() -> &'static str {
379                $event_prefix
380            }
381        }
382    };
383}
384
385ack_forward_workflow!(
386    /// Verfügbarkeitsmeldung workflow — `UnavailabilityMarketDocument`.
387    ///
388    /// ANB → VNB. Receiver acknowledges within 3 minutes
389    /// ([`crate::fristen::ACK_FRIST`]).
390    VerfuegbarkeitWorkflow,
391    VerfuegbarkeitEvent,
392    verfuegbarkeit::WORKFLOW_NAME,
393    verfuegbarkeit::ACK_WINDOW_LABEL,
394    "Verfuegbarkeit",
395);
396
397ack_forward_workflow!(
398    /// Netzengpassinformation workflow — `NetworkConstraintDocument`.
399    ///
400    /// ÜNB ↔ VNB. Receiver acknowledges within 3 minutes
401    /// ([`crate::fristen::ACK_FRIST`]).
402    NetzengpassWorkflow,
403    NetzengpassEvent,
404    netzengpass::WORKFLOW_NAME,
405    netzengpass::ACK_WINDOW_LABEL,
406    "Netzengpass",
407);
408
409ack_forward_workflow!(
410    /// `Kaskade` workflow — emergency measures per § 13 Abs. 2 `EnWG`.
411    ///
412    /// ÜNB → VNB → ANB. Receiver acknowledges within 3 minutes
413    /// ([`crate::fristen::ACK_FRIST`]).
414    /// Only active for `Marktrolle::Nb` and `Marktrolle::Unb` deployments.
415    KaskadeWorkflow,
416    KaskadeEvent,
417    kaskade::WORKFLOW_NAME,
418    kaskade::ACK_WINDOW_LABEL,
419    "Kaskade",
420);
421
422ack_forward_workflow!(
423    /// Planungsdaten (Abruffahrplan) workflow — `PlannedResourceScheduleDocument`.
424    ///
425    /// ÜNB → VNB → ANB. Receiver acknowledges within 3 minutes
426    /// ([`crate::fristen::ACK_FRIST`]).
427    PlanungsdatenWorkflow,
428    PlanungsdatenEvent,
429    planungsdaten::WORKFLOW_NAME,
430    planungsdaten::ACK_WINDOW_LABEL,
431    "Planungsdaten",
432);
433
434ack_forward_workflow!(
435    /// Statusanfrage workflow — `StatusRequest_MarketDocument`.
436    ///
437    /// **Not a request/response pair.** The document's `type` is `A60` (status
438    /// request for a position independently from a specific process) or `Z15`
439    /// Erreichbarkeitsinformation, and its `status` carries `A03` Deactivated /
440    /// `A04` Reactivated / `A13` Withdrawn — a communication-availability
441    /// notification about a Marktpartner. There is no answer document and no
442    /// 24-hour window; the acknowledgement is the only thing owed back
443    /// (`StatusRequest_MarketDocument` FB 1.1).
444    StatusanfrageWorkflow,
445    StatusanfrageEvent,
446    statusanfrage::WORKFLOW_NAME,
447    statusanfrage::ACK_WINDOW_LABEL,
448    "Statusanfrage",
449);
450
451ack_forward_workflow!(
452    /// `Kostenblatt` workflow — monthly cost reconciliation.
453    ///
454    /// VNB → ÜNB. Receiver acknowledges within 3 minutes
455    /// ([`crate::fristen::ACK_FRIST`]). The submission day is
456    /// operator-configured — BK6-23-241 Tenorziffer 3 repealed BK6-20-061, so
457    /// the 15th of the following month is a historical default rather than a
458    /// published obligation
459    /// ([`crate::fristen::`Betreiberfristen`::kostenblatt_stichtag`]).
460    KostenblattWorkflow,
461    KostenblattEvent,
462    kostenblatt::WORKFLOW_NAME,
463    kostenblatt::ACK_WINDOW_LABEL,
464    "Kostenblatt",
465);
466
467// ── Workflow names and deadline labels ────────────────────────────────────────
468//
469// One module per process, each carrying the two strings both ends of the
470// platform have to agree on: the name a process is spawned and resumed under,
471// and the label its acknowledgement deadline fires with. Spelled once here and
472// read everywhere else — a second spelling that drifts writes the stream under
473// one name and looks it up under another, and a deadline no `on_deadline`
474// matches expires into `None`.
475
476/// Verfügbarkeitsmeldung — `UnavailabilityMarketDocument` (ANB → VNB).
477pub mod verfuegbarkeit {
478    /// Stable workflow name — used in `ProcessRegistry` lookups and log output.
479    pub const WORKFLOW_NAME: &str = "redispatch-verfuegbarkeit";
480    /// Deadline label for the 3-minute `AcknowledgementDocument` window
481    /// ([`crate::fristen::ACK_FRIST`]).
482    pub const ACK_WINDOW_LABEL: &str = "redispatch-verfuegbarkeit-ack-window";
483}
484
485/// Netzengpassinformation — `NetworkConstraintDocument` (ÜNB ↔ VNB).
486pub mod netzengpass {
487    /// Stable workflow name — used in `ProcessRegistry` lookups and log output.
488    pub const WORKFLOW_NAME: &str = "redispatch-netzengpass";
489    /// Deadline label for the 3-minute `AcknowledgementDocument` window
490    /// ([`crate::fristen::ACK_FRIST`]).
491    pub const ACK_WINDOW_LABEL: &str = "redispatch-netzengpass-ack-window";
492}
493
494/// `Kaskade` — § 13 Abs. 2 `EnWG` emergency measures (ÜNB → VNB → ANB).
495pub mod kaskade {
496    /// Stable workflow name — used in `ProcessRegistry` lookups and log output.
497    pub const WORKFLOW_NAME: &str = "redispatch-kaskade";
498    /// Deadline label for the 3-minute `AcknowledgementDocument` window
499    /// ([`crate::fristen::ACK_FRIST`]).
500    pub const ACK_WINDOW_LABEL: &str = "redispatch-kaskade-ack-window";
501}
502
503/// Planungsdaten (Abruffahrplan) — `PlannedResourceScheduleDocument`.
504pub mod planungsdaten {
505    /// Stable workflow name — used in `ProcessRegistry` lookups and log output.
506    pub const WORKFLOW_NAME: &str = "redispatch-planungsdaten";
507    /// Deadline label for the 3-minute `AcknowledgementDocument` window
508    /// ([`crate::fristen::ACK_FRIST`]).
509    pub const ACK_WINDOW_LABEL: &str = "redispatch-planungsdaten-ack-window";
510}
511
512/// Statusanfrage — `StatusRequest_MarketDocument`.
513pub mod statusanfrage {
514    /// Stable workflow name — used in `ProcessRegistry` lookups and log output.
515    pub const WORKFLOW_NAME: &str = "redispatch-statusanfrage";
516    /// Deadline label for the 3-minute `AcknowledgementDocument` window
517    /// ([`crate::fristen::ACK_FRIST`]).
518    pub const ACK_WINDOW_LABEL: &str = "redispatch-statusanfrage-response-window";
519}
520
521/// `Kostenblatt` — monthly cost reconciliation (VNB → ÜNB).
522pub mod kostenblatt {
523    /// Stable workflow name — used in `ProcessRegistry` lookups and log output.
524    pub const WORKFLOW_NAME: &str = "redispatch-kostenblatt";
525    /// Deadline label for the 3-minute `AcknowledgementDocument` window
526    /// ([`crate::fristen::ACK_FRIST`]).
527    pub const ACK_WINDOW_LABEL: &str = "redispatch-kostenblatt-ack-window";
528}
529
530#[cfg(test)]
531mod tests {
532    use super::*;
533    use mako_engine::workflow::EventPayload;
534
535    #[test]
536    fn verfuegbarkeit_receive_to_acknowledged() {
537        let state = AckForwardState::New;
538        let output = VerfuegbarkeitWorkflow::handle(
539            &state,
540            AckForwardCommand::Receive {
541                mrid: "m1".into(),
542                doc_type: "Unavailability".into(),
543                sender: "s".into(),
544                receiver: "r".into(),
545                received_at: "2025-10-15T10:00:00Z".into(),
546            },
547        )
548        .unwrap();
549        assert_eq!(output.events.len(), 1);
550
551        let state2 = VerfuegbarkeitWorkflow::apply(state, &output.events[0]);
552        assert!(matches!(state2, AckForwardState::Received(_)));
553
554        let output2 = VerfuegbarkeitWorkflow::handle(
555            &state2,
556            AckForwardCommand::Acknowledge {
557                ack_mrid: "ack-1".into(),
558            },
559        )
560        .unwrap();
561        let state3 = VerfuegbarkeitWorkflow::apply(state2, &output2.events[0]);
562        assert!(matches!(state3, AckForwardState::Acknowledged(_)));
563    }
564
565    #[test]
566    fn kaskade_forward_requires_acknowledged_state() {
567        let state = AckForwardState::Received(ReceivedData {
568            mrid: "m".into(),
569            doc_type: "Kaskade".into(),
570            sender: "s".into(),
571            receiver: "r".into(),
572            received_at: "2025-10-15T10:00:00Z".into(),
573        });
574        let result = KaskadeWorkflow::handle(
575            &state,
576            AckForwardCommand::Forward {
577                upstream_mrid: "u".into(),
578            },
579        );
580        assert!(result.is_err());
581    }
582
583    /// Verify that each workflow's event types are unique and correctly prefixed.
584    #[test]
585    fn event_types_are_unique_per_workflow() {
586        let inner = AckForwardEvent::Received {
587            mrid: "m".into(),
588            doc_type: "X".into(),
589            sender: "s".into(),
590            receiver: "r".into(),
591            received_at: "t".into(),
592        };
593
594        let types: Vec<&'static str> = vec![
595            VerfuegbarkeitEvent(inner.clone()).event_type(),
596            NetzengpassEvent(inner.clone()).event_type(),
597            KaskadeEvent(inner.clone()).event_type(),
598            PlanungsdatenEvent(inner.clone()).event_type(),
599            StatusanfrageEvent(inner.clone()).event_type(),
600            KostenblattEvent(inner.clone()).event_type(),
601        ];
602
603        // All event types must be distinct.
604        let unique: std::collections::HashSet<_> = types.iter().collect();
605        assert_eq!(
606            unique.len(),
607            types.len(),
608            "event_type() strings must be unique across all ack-forward workflows: {types:?}"
609        );
610
611        // All event types must be prefixed (not generic "AckForward…" names).
612        for t in &types {
613            assert!(
614                !t.starts_with("AckForward"),
615                "event_type '{t}' must not use the generic AckForward prefix"
616            );
617        }
618    }
619}