Skip to main content

mako_redispatch/
stammdaten.rs

1//! Stammdatenübermittlung workflow for Redispatch 2.0.
2//!
3//! **Direction:** ANB → VNB → ÜNB\
4//! **Document:** `redispatch_xml::Stammdaten` (Z02 reduced, Z03 enriched,
5//! Z04 NB aggregate, Z14 BKV)
6//!
7//! # Process description
8//!
9//! 1. ANB sends `Stammdaten` to VNB (initial + updates on change).
10//! 2. Receiver sends `AcknowledgementDocument` within **3 minutes**
11//!    (UTC — see note below).
12//! 3. VNB optionally forwards enriched `Stammdaten` to ÜNB. `BilAReM`
13//!    Kap. 6.2.1.1 obliges the responsible Marktpartner to send a changed value
14//!    „unverzüglich nach Bekanntwerden" but names no countable window, so the
15//!    length is operator-configured
16//!    ([`crate::fristen::`Betreiberfristen`::stammdaten_weiterleitung_werktage`]).
17//!
18//! # Who owns which Stammdatum
19//!
20//! `BilAReM` Kap. 6.2.1.1: „Für jedes ausgetauschte Stammdatum gibt es genau
21//! **einen** Verantwortlichen und mindestens einen Berechtigten." The
22//! Verantwortliche is the final authority on the value and must push a change
23//! unverzüglich; the Berechtigte may request one (Kap. 6.2.1.4) and dispute it
24//! through a Clearingprozess (Kap. 6.2.1.5). Kap. 6.1.3 fixes what a
25//! Clearingprozess has to achieve: agreement **or a formally established
26//! Dissens** — and „bis zu einer Änderung … durch den Verantwortlichen sind die
27//! vom Verantwortlichen verteilten Informationen weiter gültig", so a disputed
28//! Stammdatum keeps its current value rather than becoming unknown.
29//!
30//! Kap. 6.2.1.2 assigns the TR-, SR- and SG-bezogene `Stammdaten` to the **ANB**
31//! and Kap. 6.2.1.7 the clusterbezogene to the **clusternder NB**.
32//!
33//! # `gueltig_ab` is bounded on both sides
34//!
35//! The `Stammdaten` AWT 1.4b constrains the effective date of a change:
36//!
37//! | Rule | Value | Source |
38//! |---|---|---|
39//! | at least this far ahead of receipt | 5 Werktage | Fußnote 27 |
40//! | …or, for the `Stammdaten` marked with Fußnote 33 | 10 Werktage | Fußnote 33 |
41//! | at most this far after the Erstellungszeitpunkt | 2 years | Fußnoten 31, 32 |
42//!
43//! See [`crate::fristen`] for the constants.
44//!
45//! # Clock semantics
46//!
47//! The acknowledgement window is wall-clock **UTC** — the XSD `UtcDateTime`
48//! fields carry explicit `Z` offsets. The `gueltig_ab` Werktag rules follow
49//! German local time and the GPKE Werktag definition, like GPKE/WiM.
50//!
51//! # Regulatory basis
52//!
53//! `BNetzA` **BK6-23-241** Anlage `BilAReM` Kap. 6.2.1 (Austausch von
54//! `Stammdaten`); EDI@Energy *`Stammdaten`* FB/AWT 1.4b for the wire format, the
55//! `gueltig_ab` Werktag rules and the two-year ceiling; *`AcknowledgementDocument`*
56//! FB 1.0g for the 3-minute Frist.
57
58use mako_engine::{
59    deadline::Deadline,
60    error::WorkflowError,
61    ids::DeadlineId,
62    workflow::{CommandPayload, EventPayload, Workflow, WorkflowOutput},
63};
64use serde::{Deserialize, Serialize};
65
66// ── Workflow name ─────────────────────────────────────────────────────────────
67
68/// Stable workflow name — used in `ProcessRegistry` lookups and log output.
69pub const WORKFLOW_NAME: &str = "redispatch-stammdaten";
70
71// ── Deadline labels ───────────────────────────────────────────────────────────
72
73/// Deadline label for the 3-minute `AcknowledgementDocument` window
74/// ([`crate::fristen::ACK_FRIST`]).
75///
76/// Register immediately after [`StammdatenEvent::Received`] is applied.
77pub const ACK_WINDOW_LABEL: &str = "redispatch-stammdaten-ack-window";
78
79/// Deadline label for the VNB→ÜNB forwarding window.
80///
81/// The length is operator-configured: BK6-23-241 Tenorziffer 4 repealed
82/// BK6-20-060, and `BilAReM` Kap. 6.2.1.1 keeps the obligation („unverzüglich
83/// nach Bekanntwerden") without a countable window. See
84/// [`crate::fristen::`Betreiberfristen`::stammdaten_weiterleitung_werktage`].
85///
86/// Register after [`StammdatenEvent::Acknowledged`] is applied, when the
87/// deployment role is VNB.
88pub const FORWARD_WINDOW_LABEL: &str = "redispatch-stammdaten-forward-window";
89
90// ── Events ────────────────────────────────────────────────────────────────────
91
92/// Events emitted by the `Stammdaten` workflow.
93#[derive(Debug, Clone, Serialize, Deserialize)]
94#[serde(tag = "type", content = "data")]
95pub enum StammdatenEvent {
96    /// `Stammdaten` document received from ANB or VNB.
97    Received {
98        /// MRID (UUID) of the received `Stammdaten` document.
99        mrid: String,
100        /// GLN of the sender (ANB or VNB).
101        sender: String,
102        /// GLN of the receiver (VNB or ÜNB).
103        receiver: String,
104        /// Document type code (Z02/Z03/Z04/Z14).
105        doc_type: String,
106        /// Number of resource objects (`Anlagen`) included.
107        anlagen_count: u32,
108        /// UTC receipt timestamp in ISO-8601 format.
109        received_at: String,
110    },
111    /// `AcknowledgementDocument` dispatched within the 3-minute window.
112    Acknowledged {
113        /// MRID of the outbound `AcknowledgementDocument`.
114        ack_mrid: String,
115    },
116    /// Enriched `Stammdaten` forwarded upstream (VNB→ÜNB, role-conditional).
117    Forwarded {
118        /// MRID of the upstream `Stammdaten` sent to ÜNB.
119        upstream_mrid: String,
120    },
121    /// The acknowledgement window expired without a response.
122    DeadlineExpired {
123        /// Unique ID of the expired deadline.
124        deadline_id: DeadlineId,
125        /// Label identifying the deadline type.
126        label: Box<str>,
127    },
128}
129
130impl EventPayload for StammdatenEvent {
131    fn event_type(&self) -> &'static str {
132        match self {
133            Self::Received { .. } => "StammdatenReceived",
134            Self::Acknowledged { .. } => "StammdatenAcknowledged",
135            Self::Forwarded { .. } => "StammdatenForwarded",
136            Self::DeadlineExpired { .. } => "StammdatenDeadlineExpired",
137        }
138    }
139}
140
141// ── Domain data ───────────────────────────────────────────────────────────────
142
143/// Business data captured when the `Stammdaten` document is first received.
144#[derive(Debug, Clone, Serialize, Deserialize)]
145#[serde(deny_unknown_fields)]
146pub struct ReceivedData {
147    /// MRID (UUID) of the received `Stammdaten` document.
148    pub mrid: String,
149    /// GLN of the sender.
150    pub sender: String,
151    /// GLN of the receiver.
152    pub receiver: String,
153    /// Document type code.
154    pub doc_type: String,
155    /// Number of resource objects.
156    pub anlagen_count: u32,
157    /// UTC receipt timestamp.
158    pub received_at: String,
159}
160
161// ── State ─────────────────────────────────────────────────────────────────────
162
163/// Current state of a `Stammdaten` process stream.
164///
165/// # Lifecycle
166///
167/// ```text
168/// New → Received → Acknowledged → [Forwarded →] Done
169///                ↘ DeadlineExpired (ACK window lapsed)
170/// ```
171#[derive(Debug, Clone, Default, Serialize, Deserialize)]
172#[serde(tag = "status", content = "data")]
173pub enum StammdatenState {
174    /// No events yet.
175    #[default]
176    New,
177    /// Document received; `AcknowledgementDocument` not yet sent.
178    Received(ReceivedData),
179    /// `AcknowledgementDocument` sent; forwarding to ÜNB not yet done.
180    Acknowledged(ReceivedData),
181    /// Enriched document forwarded to ÜNB (VNB role only).
182    Forwarded(ReceivedData),
183    /// Process terminated due to a missed deadline.
184    DeadlineExpired {
185        /// Human-readable description of the expired deadline.
186        reason: String,
187    },
188}
189
190impl StammdatenState {
191    /// Stable string label for the current variant.
192    #[must_use]
193    pub fn label(&self) -> &'static str {
194        match self {
195            Self::New => "New",
196            Self::Received(_) => "Received",
197            Self::Acknowledged(_) => "Acknowledged",
198            Self::Forwarded(_) => "Forwarded",
199            Self::DeadlineExpired { .. } => "DeadlineExpired",
200        }
201    }
202}
203
204// ── Commands ──────────────────────────────────────────────────────────────────
205
206/// Commands for the `Stammdaten` workflow.
207///
208/// All domain values are pre-extracted by the transport layer before
209/// construction. `Workflow::handle` is pure — no I/O.
210#[derive(Clone)]
211pub enum StammdatenCommand {
212    /// Inbound `Stammdaten` document received and parsed by the transport layer.
213    Receive {
214        /// MRID (UUID) of the received document.
215        mrid: String,
216        /// GLN of the sender.
217        sender: String,
218        /// GLN of the receiver.
219        receiver: String,
220        /// Document type code (Z02/Z03/Z04/Z14).
221        doc_type: String,
222        /// Number of resource objects in the document.
223        anlagen_count: u32,
224        /// UTC receipt timestamp (ISO-8601 string).
225        received_at: String,
226    },
227    /// `AcknowledgementDocument` dispatched to the sender.
228    ///
229    /// The caller is responsible for building and enqueuing the outbound XML
230    /// via the outbox before issuing this command.
231    SendAcknowledgement {
232        /// MRID assigned to the outbound `AcknowledgementDocument`.
233        ack_mrid: String,
234    },
235    /// Enriched `Stammdaten` forwarded to ÜNB (VNB role only).
236    ///
237    /// The caller is responsible for building and enqueuing the upstream XML.
238    Forward {
239        /// MRID assigned to the upstream `Stammdaten` document.
240        upstream_mrid: String,
241    },
242    /// A registered deadline fired.
243    TimeoutExpired {
244        /// Unique ID of the expired deadline.
245        deadline_id: DeadlineId,
246        /// Label identifying the deadline type.
247        label: Box<str>,
248    },
249}
250
251impl CommandPayload for StammdatenCommand {}
252
253// ── Workflow ──────────────────────────────────────────────────────────────────
254
255/// Stammdatenübermittlung workflow for Redispatch 2.0.
256///
257/// Handles the reception, acknowledgement, and optional forwarding of
258/// `Stammdaten` documents exchanged between ANB, VNB, and ÜNB.
259///
260/// Spawn via [`mako_engine::process::Process`]:
261/// ```rust,ignore
262/// let process = ctx.spawn::<StammdatenWorkflow>(
263///     tenant_id,
264///     WorkflowId::new(WORKFLOW_NAME, "FV2025-10-01"),
265/// );
266/// ```
267pub struct StammdatenWorkflow;
268
269impl Workflow for StammdatenWorkflow {
270    type State = StammdatenState;
271    type Event = StammdatenEvent;
272    type Command = StammdatenCommand;
273
274    /// Fire deadline commands when the ACK or forward windows expire.
275    fn on_deadline(deadline: &Deadline, state: &Self::State) -> Option<Self::Command> {
276        match (deadline.label(), state) {
277            // ACK window while Received; forwarding window (VNB → ÜNB)
278            // while Acknowledged, so an acknowledged
279            // Stammdaten document that is never forwarded expires visibly.
280            (ACK_WINDOW_LABEL, StammdatenState::Received(_))
281            | (FORWARD_WINDOW_LABEL, StammdatenState::Acknowledged { .. }) => {
282                Some(StammdatenCommand::TimeoutExpired {
283                    deadline_id: deadline.deadline_id(),
284                    label: deadline.label().into(),
285                })
286            }
287            _ => None,
288        }
289    }
290
291    fn apply(state: Self::State, event: &Self::Event) -> Self::State {
292        match event {
293            StammdatenEvent::Received {
294                mrid,
295                sender,
296                receiver,
297                doc_type,
298                anlagen_count,
299                received_at,
300            } => StammdatenState::Received(ReceivedData {
301                mrid: mrid.clone(),
302                sender: sender.clone(),
303                receiver: receiver.clone(),
304                doc_type: doc_type.clone(),
305                anlagen_count: *anlagen_count,
306                received_at: received_at.clone(),
307            }),
308
309            StammdatenEvent::Acknowledged { .. } => match state {
310                StammdatenState::Received(data) => StammdatenState::Acknowledged(data),
311                other => other,
312            },
313
314            StammdatenEvent::Forwarded { .. } => match state {
315                StammdatenState::Acknowledged(data) => StammdatenState::Forwarded(data),
316                other => other,
317            },
318
319            StammdatenEvent::DeadlineExpired { label, .. } => StammdatenState::DeadlineExpired {
320                reason: format!("deadline expired: {label}"),
321            },
322        }
323    }
324
325    fn handle(
326        state: &Self::State,
327        command: Self::Command,
328    ) -> Result<WorkflowOutput<Self::Event>, WorkflowError> {
329        match command {
330            StammdatenCommand::Receive {
331                mrid,
332                sender,
333                receiver,
334                doc_type,
335                anlagen_count,
336                received_at,
337            } => {
338                if !matches!(state, StammdatenState::New) {
339                    // Idempotent: document already received — this is a retry.
340                    return Ok(vec![].into());
341                }
342                Ok(vec![StammdatenEvent::Received {
343                    mrid,
344                    sender,
345                    receiver,
346                    doc_type,
347                    anlagen_count,
348                    received_at,
349                }]
350                .into())
351            }
352
353            StammdatenCommand::SendAcknowledgement { ack_mrid } => match state {
354                StammdatenState::Received(_) => {
355                    Ok(vec![StammdatenEvent::Acknowledged { ack_mrid }].into())
356                }
357                StammdatenState::Acknowledged(_) | StammdatenState::Forwarded(_) => {
358                    // Idempotent — acknowledgement already sent.
359                    Ok(vec![].into())
360                }
361                other => Err(WorkflowError::rejected(format!(
362                    "SendAcknowledgement not valid in state {}",
363                    other.label()
364                ))),
365            },
366
367            StammdatenCommand::Forward { upstream_mrid } => match state {
368                StammdatenState::Acknowledged(_) => {
369                    Ok(vec![StammdatenEvent::Forwarded { upstream_mrid }].into())
370                }
371                StammdatenState::Forwarded(_) => {
372                    // Idempotent.
373                    Ok(vec![].into())
374                }
375                other => Err(WorkflowError::rejected(format!(
376                    "Forward not valid in state {}",
377                    other.label()
378                ))),
379            },
380
381            StammdatenCommand::TimeoutExpired { deadline_id, label } => {
382                let is_forward_window = &*label == FORWARD_WINDOW_LABEL;
383                match state {
384                    // 1-Werktag forward window: an acknowledged document the
385                    // VNB never forwarded to the ÜNB expires visibly.
386                    StammdatenState::Acknowledged(_) if is_forward_window => {
387                        Ok(vec![StammdatenEvent::DeadlineExpired { deadline_id, label }].into())
388                    }
389                    // Terminal / already-progressed states — no-op.
390                    StammdatenState::Acknowledged(_)
391                    | StammdatenState::Forwarded(_)
392                    | StammdatenState::DeadlineExpired { .. } => Ok(vec![].into()),
393                    _ => Ok(vec![StammdatenEvent::DeadlineExpired { deadline_id, label }].into()),
394                }
395            }
396        }
397    }
398}
399
400#[cfg(test)]
401mod tests {
402    use super::*;
403    use mako_engine::ids::DeadlineId;
404
405    fn received_cmd() -> StammdatenCommand {
406        StammdatenCommand::Receive {
407            mrid: "mrid-001".into(),
408            sender: "4012345000001".into(),
409            receiver: "4012345000002".into(),
410            doc_type: "Z02".into(),
411            anlagen_count: 3,
412            received_at: "2025-10-15T10:00:00Z".into(),
413        }
414    }
415
416    #[test]
417    fn receive_transitions_new_to_received() {
418        let state = StammdatenState::New;
419        let output = StammdatenWorkflow::handle(&state, received_cmd()).unwrap();
420        assert_eq!(output.events.len(), 1);
421        let new_state = StammdatenWorkflow::apply(state, &output.events[0]);
422        assert!(matches!(new_state, StammdatenState::Received(_)));
423    }
424
425    #[test]
426    fn acknowledge_transitions_received_to_acknowledged() {
427        let state = StammdatenState::Received(ReceivedData {
428            mrid: "m".into(),
429            sender: "s".into(),
430            receiver: "r".into(),
431            doc_type: "Z02".into(),
432            anlagen_count: 1,
433            received_at: "2025-10-15T10:00:00Z".into(),
434        });
435        let output = StammdatenWorkflow::handle(
436            &state,
437            StammdatenCommand::SendAcknowledgement {
438                ack_mrid: "ack-001".into(),
439            },
440        )
441        .unwrap();
442        assert_eq!(output.events.len(), 1);
443        let new_state = StammdatenWorkflow::apply(state, &output.events[0]);
444        assert!(matches!(new_state, StammdatenState::Acknowledged(_)));
445    }
446
447    #[test]
448    fn forward_requires_acknowledged_state() {
449        let state = StammdatenState::Received(ReceivedData {
450            mrid: "m".into(),
451            sender: "s".into(),
452            receiver: "r".into(),
453            doc_type: "Z03".into(),
454            anlagen_count: 1,
455            received_at: "2025-10-15T10:00:00Z".into(),
456        });
457        let result = StammdatenWorkflow::handle(
458            &state,
459            StammdatenCommand::Forward {
460                upstream_mrid: "u".into(),
461            },
462        );
463        assert!(result.is_err());
464    }
465
466    #[test]
467    fn timeout_in_received_state_emits_deadline_expired() {
468        let state = StammdatenState::Received(ReceivedData {
469            mrid: "m".into(),
470            sender: "s".into(),
471            receiver: "r".into(),
472            doc_type: "Z02".into(),
473            anlagen_count: 1,
474            received_at: "2025-10-15T10:00:00Z".into(),
475        });
476        let output = StammdatenWorkflow::handle(
477            &state,
478            StammdatenCommand::TimeoutExpired {
479                deadline_id: DeadlineId::new(),
480                label: ACK_WINDOW_LABEL.into(),
481            },
482        )
483        .unwrap();
484        assert!(matches!(
485            output.events.as_slice(),
486            [StammdatenEvent::DeadlineExpired { .. }]
487        ));
488    }
489
490    #[test]
491    fn timeout_in_acknowledged_state_is_noop() {
492        let state = StammdatenState::Acknowledged(ReceivedData {
493            mrid: "m".into(),
494            sender: "s".into(),
495            receiver: "r".into(),
496            doc_type: "Z02".into(),
497            anlagen_count: 1,
498            received_at: "2025-10-15T10:00:00Z".into(),
499        });
500        let output = StammdatenWorkflow::handle(
501            &state,
502            StammdatenCommand::TimeoutExpired {
503                deadline_id: DeadlineId::new(),
504                label: ACK_WINDOW_LABEL.into(),
505            },
506        )
507        .unwrap();
508        assert!(output.events.is_empty());
509    }
510
511    #[test]
512    fn unforwarded_stammdaten_expires_after_the_forward_window() {
513        let data = ReceivedData {
514            mrid: "sd-001".into(),
515            sender: "4012345000001".into(),
516            receiver: "4012345000002".into(),
517            doc_type: "Z01".into(),
518            anlagen_count: 1,
519            received_at: "2025-10-15T09:00:00Z".into(),
520        };
521        let state = StammdatenState::Acknowledged(data);
522        let out = StammdatenWorkflow::handle(
523            &state,
524            StammdatenCommand::TimeoutExpired {
525                deadline_id: DeadlineId::new(),
526                label: FORWARD_WINDOW_LABEL.into(),
527            },
528        )
529        .expect("forward-window timeout handled");
530        assert_eq!(out.events.len(), 1, "1-Werktag forward window must fire");
531        // The ACK label stays a no-op in Acknowledged.
532        let noop = StammdatenWorkflow::handle(
533            &state,
534            StammdatenCommand::TimeoutExpired {
535                deadline_id: DeadlineId::new(),
536                label: ACK_WINDOW_LABEL.into(),
537            },
538        )
539        .expect("ack timeout in acknowledged is noop");
540        assert!(noop.events.is_empty());
541    }
542}