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}