use mako_engine::{
deadline::Deadline,
error::WorkflowError,
ids::DeadlineId,
workflow::{CommandPayload, EventPayload, Workflow, WorkflowOutput},
};
use serde::{Deserialize, Serialize};
pub const WORKFLOW_NAME: &str = "redispatch-aktivierung";
pub const IFTSTA_PIDS: &[u32] = &[21_037, 21_038];
pub const MSCONS_PIDS: &[u32] = &[13_021, 13_022];
pub const ORDERS_PIDS: &[u32] = &[17_209];
pub const ACTIVATION_RESPONSE_WINDOW_LABEL: &str = "redispatch-activation-response-window";
pub const ACK_WINDOW_LABEL: &str = "redispatch-aktivierung-ack-window";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ResponseType {
Confirmed,
PartialRejection,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Abrufart {
Delta,
Sollwert,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "fall")]
pub enum Abwicklung {
Aufforderungsfall {
abrufart: Abrufart,
},
Duldungsfall,
}
impl Abwicklung {
#[must_use]
pub fn response_window_applies(self) -> bool {
matches!(self, Self::Aufforderungsfall { .. })
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", content = "data")]
pub enum AktivierungEvent {
AcoReceived {
mrid: String,
abwicklung: Abwicklung,
ordered_mw: f64,
resource_id: String,
period: String,
sender: String,
receiver: String,
received_at: String,
},
AcoCascaded {
recipient_anb: String,
child_mrid: String,
},
AcrSent {
acr_mrid: String,
activated_mw: f64,
},
AarSent {
aar_mrid: String,
available_mw: f64,
reason_code: String,
},
AnbResponseReceived {
anb_id: String,
response_type: ResponseType,
response_mrid: String,
},
ResponseForwarded {
upstream_mrid: String,
response_type: ResponseType,
},
AckReceived {
ack_mrid: String,
acknowledged_mrid: String,
reason_code: String,
},
MarketMessageReceived {
pid: u32,
sender: String,
receiver: String,
message_ref: String,
},
IftstaReceived {
pid: u32,
sender: String,
receiver: String,
message_ref: String,
},
DeadlineExpired {
deadline_id: DeadlineId,
label: Box<str>,
},
}
impl EventPayload for AktivierungEvent {
fn event_type(&self) -> &'static str {
match self {
Self::AcoReceived { .. } => "AktivierungAcoReceived",
Self::AcoCascaded { .. } => "AktivierungAcoCascaded",
Self::AcrSent { .. } => "AktivierungAcrSent",
Self::AarSent { .. } => "AktivierungAarSent",
Self::AnbResponseReceived { .. } => "AktivierungAnbResponseReceived",
Self::ResponseForwarded { .. } => "AktivierungResponseForwarded",
Self::AckReceived { .. } => "AktivierungAckReceived",
Self::MarketMessageReceived { .. } => "AktivierungMarketMessageReceived",
Self::IftstaReceived { .. } => "AktivierungIftstaReceived",
Self::DeadlineExpired { .. } => "AktivierungDeadlineExpired",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AcoData {
pub mrid: String,
pub abwicklung: Abwicklung,
pub ordered_mw: f64,
pub resource_id: String,
pub period: String,
pub sender: String,
pub receiver: String,
pub received_at: String,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(tag = "status", content = "data")]
pub enum AktivierungState {
#[default]
New,
ActivationOrdered(AcoData),
DispatchedToAnb(AcoData),
Confirmed {
data: AcoData,
acr_mrid: String,
},
PartialRejection {
data: AcoData,
aar_mrid: String,
},
Done(AcoData),
DeadlineExpired {
reason: String,
},
}
impl AktivierungState {
#[must_use]
pub fn label(&self) -> &'static str {
match self {
Self::New => "New",
Self::ActivationOrdered(_) => "ActivationOrdered",
Self::DispatchedToAnb(_) => "DispatchedToAnb",
Self::Confirmed { .. } => "Confirmed",
Self::PartialRejection { .. } => "PartialRejection",
Self::Done(_) => "Done",
Self::DeadlineExpired { .. } => "DeadlineExpired",
}
}
}
#[derive(Clone)]
pub enum AktivierungCommand {
ReceiveAco {
mrid: String,
abwicklung: Abwicklung,
ordered_mw: f64,
resource_id: String,
period: String,
sender: String,
receiver: String,
received_at: String,
},
CascadeToAnb {
recipient_anb: String,
child_mrid: String,
},
SendAcr {
acr_mrid: String,
activated_mw: f64,
},
SendAar {
aar_mrid: String,
available_mw: f64,
reason_code: String,
},
RecordAnbResponse {
anb_id: String,
response_type: ResponseType,
response_mrid: String,
},
ForwardResponse {
upstream_mrid: String,
response_type: ResponseType,
},
ReceiveMarketMessage {
pid: u32,
sender: String,
receiver: String,
message_ref: String,
},
ReceiveAck {
ack_mrid: String,
acknowledged_mrid: String,
reason_code: String,
},
ReceiveIftsta {
pid: u32,
sender: String,
receiver: String,
message_ref: String,
},
TimeoutExpired {
deadline_id: DeadlineId,
label: Box<str>,
},
}
impl CommandPayload for AktivierungCommand {}
pub struct AktivierungWorkflow;
impl Workflow for AktivierungWorkflow {
type State = AktivierungState;
type Event = AktivierungEvent;
type Command = AktivierungCommand;
fn on_deadline(deadline: &Deadline, state: &Self::State) -> Option<Self::Command> {
match (deadline.label(), state) {
(
ACTIVATION_RESPONSE_WINDOW_LABEL,
AktivierungState::ActivationOrdered(data) | AktivierungState::DispatchedToAnb(data),
) if data.abwicklung.response_window_applies() => {
Some(AktivierungCommand::TimeoutExpired {
deadline_id: deadline.deadline_id(),
label: deadline.label().into(),
})
}
(
ACK_WINDOW_LABEL,
AktivierungState::Confirmed { .. } | AktivierungState::PartialRejection { .. },
) => Some(AktivierungCommand::TimeoutExpired {
deadline_id: deadline.deadline_id(),
label: deadline.label().into(),
}),
_ => None,
}
}
fn apply(state: Self::State, event: &Self::Event) -> Self::State {
match event {
AktivierungEvent::AcoReceived {
mrid,
abwicklung,
ordered_mw,
resource_id,
period,
sender,
receiver,
received_at,
} => AktivierungState::ActivationOrdered(AcoData {
mrid: mrid.clone(),
abwicklung: *abwicklung,
ordered_mw: *ordered_mw,
resource_id: resource_id.clone(),
period: period.clone(),
sender: sender.clone(),
receiver: receiver.clone(),
received_at: received_at.clone(),
}),
AktivierungEvent::AcoCascaded { .. } => match state {
AktivierungState::ActivationOrdered(data) => {
AktivierungState::DispatchedToAnb(data)
}
other => other,
},
AktivierungEvent::AcrSent { acr_mrid, .. } => match state {
AktivierungState::ActivationOrdered(data)
| AktivierungState::DispatchedToAnb(data) => AktivierungState::Confirmed {
data,
acr_mrid: acr_mrid.clone(),
},
other => other,
},
AktivierungEvent::AarSent { aar_mrid, .. } => match state {
AktivierungState::ActivationOrdered(data)
| AktivierungState::DispatchedToAnb(data) => AktivierungState::PartialRejection {
data,
aar_mrid: aar_mrid.clone(),
},
other => other,
},
AktivierungEvent::AnbResponseReceived { .. } => {
state
}
AktivierungEvent::ResponseForwarded { .. } => match state {
AktivierungState::DispatchedToAnb(data) => AktivierungState::Done(data),
other => other,
},
AktivierungEvent::IftstaReceived { .. }
| AktivierungEvent::AckReceived { .. }
| AktivierungEvent::MarketMessageReceived { .. } => state,
AktivierungEvent::DeadlineExpired { label, .. } => {
let is_ack_window = &**label == ACK_WINDOW_LABEL;
match state {
AktivierungState::Confirmed { .. }
| AktivierungState::PartialRejection { .. }
if is_ack_window =>
{
AktivierungState::DeadlineExpired {
reason: format!(
"AcknowledgementDocument not received within the 3-minute window ({label})"
),
}
}
AktivierungState::Confirmed { .. }
| AktivierungState::PartialRejection { .. }
| AktivierungState::Done(_)
| AktivierungState::DeadlineExpired { .. } => state,
_ => AktivierungState::DeadlineExpired {
reason: format!("deadline expired: {label}"),
},
}
}
}
}
#[allow(clippy::too_many_lines)]
fn handle(
state: &Self::State,
command: Self::Command,
) -> Result<WorkflowOutput<Self::Event>, WorkflowError> {
match command {
AktivierungCommand::ReceiveAco {
mrid,
abwicklung,
ordered_mw,
resource_id,
period,
sender,
receiver,
received_at,
} => {
if !matches!(state, AktivierungState::New) {
return Ok(vec![].into());
}
Ok(vec![AktivierungEvent::AcoReceived {
mrid,
abwicklung,
ordered_mw,
resource_id,
period,
sender,
receiver,
received_at,
}]
.into())
}
AktivierungCommand::CascadeToAnb {
recipient_anb,
child_mrid,
} => match state {
AktivierungState::ActivationOrdered(_) => Ok(vec![AktivierungEvent::AcoCascaded {
recipient_anb,
child_mrid,
}]
.into()),
AktivierungState::DispatchedToAnb(_) => Ok(vec![].into()),
other => Err(WorkflowError::rejected(format!(
"CascadeToAnb not valid in state {}",
other.label()
))),
},
AktivierungCommand::SendAcr {
acr_mrid,
activated_mw,
} => match state {
AktivierungState::ActivationOrdered(_) | AktivierungState::DispatchedToAnb(_) => {
Ok(vec![AktivierungEvent::AcrSent {
acr_mrid,
activated_mw,
}]
.into())
}
AktivierungState::Confirmed { .. } => Ok(vec![].into()),
other => Err(WorkflowError::rejected(format!(
"SendAcr not valid in state {}",
other.label()
))),
},
AktivierungCommand::SendAar {
aar_mrid,
available_mw,
reason_code,
} => match state {
AktivierungState::ActivationOrdered(_) | AktivierungState::DispatchedToAnb(_) => {
Ok(vec![AktivierungEvent::AarSent {
aar_mrid,
available_mw,
reason_code,
}]
.into())
}
AktivierungState::PartialRejection { .. } => Ok(vec![].into()),
other => Err(WorkflowError::rejected(format!(
"SendAar not valid in state {}",
other.label()
))),
},
AktivierungCommand::RecordAnbResponse {
anb_id,
response_type,
response_mrid,
} => match state {
AktivierungState::DispatchedToAnb(_) => {
Ok(vec![AktivierungEvent::AnbResponseReceived {
anb_id,
response_type,
response_mrid,
}]
.into())
}
other => Err(WorkflowError::rejected(format!(
"RecordAnbResponse not valid in state {}",
other.label()
))),
},
AktivierungCommand::ForwardResponse {
upstream_mrid,
response_type,
} => match state {
AktivierungState::DispatchedToAnb(_) => {
Ok(vec![AktivierungEvent::ResponseForwarded {
upstream_mrid,
response_type,
}]
.into())
}
AktivierungState::Done(_) => Ok(vec![].into()),
other => Err(WorkflowError::rejected(format!(
"ForwardResponse not valid in state {}",
other.label()
))),
},
AktivierungCommand::ReceiveAck {
ack_mrid,
acknowledged_mrid,
reason_code,
} => Ok(vec![AktivierungEvent::AckReceived {
ack_mrid,
acknowledged_mrid,
reason_code,
}]
.into()),
AktivierungCommand::ReceiveMarketMessage {
pid,
sender,
receiver,
message_ref,
} => Ok(vec![AktivierungEvent::MarketMessageReceived {
pid,
sender,
receiver,
message_ref,
}]
.into()),
AktivierungCommand::ReceiveIftsta {
pid,
sender,
receiver,
message_ref,
} => Ok(vec![AktivierungEvent::IftstaReceived {
pid,
sender,
receiver,
message_ref,
}]
.into()),
AktivierungCommand::TimeoutExpired { deadline_id, label } => {
match (state, &*label) {
(
AktivierungState::Confirmed { .. }
| AktivierungState::PartialRejection { .. },
ACK_WINDOW_LABEL,
) => Ok(vec![AktivierungEvent::DeadlineExpired { deadline_id, label }].into()),
(
AktivierungState::Confirmed { .. }
| AktivierungState::PartialRejection { .. }
| AktivierungState::Done(_)
| AktivierungState::DeadlineExpired { .. },
_,
) => Ok(vec![].into()),
_ => Ok(vec![AktivierungEvent::DeadlineExpired { deadline_id, label }].into()),
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use mako_engine::ids::DeadlineId;
fn aco_data() -> AcoData {
AcoData {
abwicklung: Abwicklung::Aufforderungsfall {
abrufart: Abrufart::Sollwert,
},
mrid: "aco-001".into(),
ordered_mw: 50.0,
resource_id: "4012345678901".into(),
period: "2025-10-15T10:00:00Z/2025-10-15T11:00:00Z".into(),
sender: "4012345000001".into(),
receiver: "4012345000002".into(),
received_at: "2025-10-15T09:58:00Z".into(),
}
}
fn test_deadline(label: &str) -> Deadline {
use mako_engine::ids::{ProcessId, StreamId, TenantId};
use mako_engine::version::WorkflowId;
Deadline::new(
StreamId::new("process/test"),
ProcessId::new(),
TenantId::new(),
WorkflowId::new(WORKFLOW_NAME, "FV2025-10-01"),
label,
time::OffsetDateTime::now_utc(),
)
}
fn receive_aco_cmd() -> AktivierungCommand {
AktivierungCommand::ReceiveAco {
abwicklung: Abwicklung::Aufforderungsfall {
abrufart: Abrufart::Sollwert,
},
mrid: "aco-001".into(),
ordered_mw: 50.0,
resource_id: "4012345678901".into(),
period: "2025-10-15T10:00:00Z/2025-10-15T11:00:00Z".into(),
sender: "4012345000001".into(),
receiver: "4012345000002".into(),
received_at: "2025-10-15T09:58:00Z".into(),
}
}
#[test]
fn receive_aco_transitions_new_to_activation_ordered() {
let state = AktivierungState::New;
let output = AktivierungWorkflow::handle(&state, receive_aco_cmd()).unwrap();
assert_eq!(output.events.len(), 1);
let new_state = AktivierungWorkflow::apply(state, &output.events[0]);
assert!(matches!(new_state, AktivierungState::ActivationOrdered(_)));
}
#[test]
fn send_acr_from_activation_ordered_produces_confirmed() {
let state = AktivierungState::ActivationOrdered(aco_data());
let output = AktivierungWorkflow::handle(
&state,
AktivierungCommand::SendAcr {
acr_mrid: "acr-001".into(),
activated_mw: 50.0,
},
)
.unwrap();
let new_state = AktivierungWorkflow::apply(state, &output.events[0]);
assert!(matches!(new_state, AktivierungState::Confirmed { .. }));
}
#[test]
fn send_aar_from_activation_ordered_produces_partial_rejection() {
let state = AktivierungState::ActivationOrdered(aco_data());
let output = AktivierungWorkflow::handle(
&state,
AktivierungCommand::SendAar {
aar_mrid: "aar-001".into(),
available_mw: 30.0,
reason_code: "A96".into(),
},
)
.unwrap();
let new_state = AktivierungWorkflow::apply(state, &output.events[0]);
assert!(matches!(
new_state,
AktivierungState::PartialRejection { .. }
));
}
#[test]
fn timeout_in_confirmed_state_is_noop() {
let state = AktivierungState::Confirmed {
data: aco_data(),
acr_mrid: "acr-001".into(),
};
let output = AktivierungWorkflow::handle(
&state,
AktivierungCommand::TimeoutExpired {
deadline_id: DeadlineId::new(),
label: ACTIVATION_RESPONSE_WINDOW_LABEL.into(),
},
)
.unwrap();
assert!(output.events.is_empty());
}
#[test]
fn ack_received_is_audit_only_and_accepted_in_any_state() {
for state in [
AktivierungState::New,
AktivierungState::ActivationOrdered(aco_data()),
AktivierungState::Confirmed {
data: aco_data(),
acr_mrid: "acr-001".into(),
},
] {
let output = AktivierungWorkflow::handle(
&state,
AktivierungCommand::ReceiveAck {
ack_mrid: "ack-001".into(),
acknowledged_mrid: "aco-001".into(),
reason_code: String::new(),
},
)
.unwrap();
assert!(matches!(
output.events.as_slice(),
[AktivierungEvent::AckReceived { .. }]
));
let before = format!("{state:?}");
let after = AktivierungWorkflow::apply(state, &output.events[0]);
assert_eq!(before, format!("{after:?}"));
}
}
#[test]
fn iftsta_accepted_in_any_state() {
for state in [
AktivierungState::New,
AktivierungState::ActivationOrdered(aco_data()),
AktivierungState::Confirmed {
data: aco_data(),
acr_mrid: "x".into(),
},
] {
let output = AktivierungWorkflow::handle(
&state,
AktivierungCommand::ReceiveIftsta {
pid: 21_037,
sender: "s".into(),
receiver: "r".into(),
message_ref: "ref-1".into(),
},
)
.unwrap();
assert!(matches!(
output.events.as_slice(),
[AktivierungEvent::IftstaReceived { pid: 21037, .. }]
));
}
}
#[test]
fn duldungsfall_ignores_the_response_window_deadline() {
let mut data = aco_data();
data.abwicklung = Abwicklung::Duldungsfall;
let state = AktivierungState::ActivationOrdered(data);
let deadline = test_deadline(ACTIVATION_RESPONSE_WINDOW_LABEL);
assert!(
AktivierungWorkflow::on_deadline(&deadline, &state).is_none(),
"Duldungsfall must not time out waiting for a response"
);
let state2 = AktivierungState::ActivationOrdered(aco_data());
assert!(AktivierungWorkflow::on_deadline(&deadline, &state2).is_some());
}
#[test]
fn unacknowledged_response_escalates_after_the_ack_window() {
let state = AktivierungState::Confirmed {
data: aco_data(),
acr_mrid: "ACR-1".into(),
};
let deadline = test_deadline(ACK_WINDOW_LABEL);
let cmd = AktivierungWorkflow::on_deadline(&deadline, &state)
.expect("ACK window fires from Confirmed");
let out = AktivierungWorkflow::handle(&state, cmd).expect("timeout handled");
let next = out.events.iter().fold(state, AktivierungWorkflow::apply);
assert!(
matches!(&next, AktivierungState::DeadlineExpired { reason } if reason.contains("Acknowledgement")),
"got {next:?}"
);
}
#[test]
fn market_messages_are_audited_in_any_state() {
let state = AktivierungState::Confirmed {
data: aco_data(),
acr_mrid: "ACR-1".into(),
};
let out = AktivierungWorkflow::handle(
&state,
AktivierungCommand::ReceiveMarketMessage {
pid: 13_020,
sender: "9900000000001".into(),
receiver: "9900000000002".into(),
message_ref: "MSG-1".into(),
},
)
.expect("audit accepted");
assert_eq!(out.events.len(), 1);
let next = out.events.iter().fold(state, AktivierungWorkflow::apply);
assert!(matches!(next, AktivierungState::Confirmed { .. }));
}
}