1use mako_engine::{
24 deadline::Deadline,
25 error::WorkflowError,
26 ids::DeadlineId,
27 workflow::{CommandPayload, EventPayload, Workflow, WorkflowOutput},
28};
29use serde::{Deserialize, Serialize};
30
31#[derive(Debug, Clone, Serialize, Deserialize)]
35#[serde(tag = "type", content = "data")]
36pub enum AckForwardEvent {
37 Received {
39 mrid: String,
41 doc_type: String,
43 sender: String,
45 receiver: String,
47 received_at: String,
49 },
50 Acknowledged {
52 ack_mrid: String,
54 },
55 Forwarded {
57 upstream_mrid: String,
59 },
60 DeadlineExpired {
62 deadline_id: DeadlineId,
64 label: Box<str>,
66 },
67}
68
69#[derive(Clone)]
71pub enum AckForwardCommand {
72 Receive {
74 mrid: String,
76 doc_type: String,
78 sender: String,
80 receiver: String,
82 received_at: String,
84 },
85 Acknowledge {
87 ack_mrid: String,
89 },
90 Forward {
92 upstream_mrid: String,
94 },
95 TimeoutExpired {
97 deadline_id: DeadlineId,
99 label: Box<str>,
101 },
102}
103
104impl CommandPayload for AckForwardCommand {}
105
106#[derive(Debug, Clone, Serialize, Deserialize)]
108#[serde(deny_unknown_fields)]
109pub struct ReceivedData {
110 pub mrid: String,
112 pub doc_type: String,
114 pub sender: String,
116 pub receiver: String,
118 pub received_at: String,
120}
121
122#[derive(Debug, Clone, Default, Serialize, Deserialize)]
124#[serde(tag = "status", content = "data")]
125pub enum AckForwardState {
126 #[default]
128 New,
129 Received(ReceivedData),
131 Acknowledged(ReceivedData),
133 Forwarded(ReceivedData),
135 DeadlineExpired {
137 reason: String,
139 },
140}
141
142impl AckForwardState {
143 #[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
167macro_rules! define_workflow_event {
179 ($event_type:ident, $prefix:expr) => {
180 #[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
224pub(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
259pub(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; Ok(vec![AckForwardEvent::DeadlineExpired { deadline_id, label }].into())
315 }
316 },
317 }
318}
319
320macro_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 pub const WORKFLOW_NAME: &'static str = $workflow_name;
375
376 #[must_use]
378 pub fn event_prefix() -> &'static str {
379 $event_prefix
380 }
381 }
382 };
383}
384
385ack_forward_workflow!(
386 VerfuegbarkeitWorkflow,
391 VerfuegbarkeitEvent,
392 verfuegbarkeit::WORKFLOW_NAME,
393 verfuegbarkeit::ACK_WINDOW_LABEL,
394 "Verfuegbarkeit",
395);
396
397ack_forward_workflow!(
398 NetzengpassWorkflow,
403 NetzengpassEvent,
404 netzengpass::WORKFLOW_NAME,
405 netzengpass::ACK_WINDOW_LABEL,
406 "Netzengpass",
407);
408
409ack_forward_workflow!(
410 KaskadeWorkflow,
416 KaskadeEvent,
417 kaskade::WORKFLOW_NAME,
418 kaskade::ACK_WINDOW_LABEL,
419 "Kaskade",
420);
421
422ack_forward_workflow!(
423 PlanungsdatenWorkflow,
428 PlanungsdatenEvent,
429 planungsdaten::WORKFLOW_NAME,
430 planungsdaten::ACK_WINDOW_LABEL,
431 "Planungsdaten",
432);
433
434ack_forward_workflow!(
435 StatusanfrageWorkflow,
445 StatusanfrageEvent,
446 statusanfrage::WORKFLOW_NAME,
447 statusanfrage::ACK_WINDOW_LABEL,
448 "Statusanfrage",
449);
450
451ack_forward_workflow!(
452 KostenblattWorkflow,
461 KostenblattEvent,
462 kostenblatt::WORKFLOW_NAME,
463 kostenblatt::ACK_WINDOW_LABEL,
464 "Kostenblatt",
465);
466
467pub mod verfuegbarkeit {
478 pub const WORKFLOW_NAME: &str = "redispatch-verfuegbarkeit";
480 pub const ACK_WINDOW_LABEL: &str = "redispatch-verfuegbarkeit-ack-window";
483}
484
485pub mod netzengpass {
487 pub const WORKFLOW_NAME: &str = "redispatch-netzengpass";
489 pub const ACK_WINDOW_LABEL: &str = "redispatch-netzengpass-ack-window";
492}
493
494pub mod kaskade {
496 pub const WORKFLOW_NAME: &str = "redispatch-kaskade";
498 pub const ACK_WINDOW_LABEL: &str = "redispatch-kaskade-ack-window";
501}
502
503pub mod planungsdaten {
505 pub const WORKFLOW_NAME: &str = "redispatch-planungsdaten";
507 pub const ACK_WINDOW_LABEL: &str = "redispatch-planungsdaten-ack-window";
510}
511
512pub mod statusanfrage {
514 pub const WORKFLOW_NAME: &str = "redispatch-statusanfrage";
516 pub const ACK_WINDOW_LABEL: &str = "redispatch-statusanfrage-response-window";
519}
520
521pub mod kostenblatt {
523 pub const WORKFLOW_NAME: &str = "redispatch-kostenblatt";
525 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 #[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 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 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}