use std::collections::HashMap;
use mako_engine::{
envelope::EventEnvelope,
error::WorkflowError,
ids::DeadlineId,
outbox::PendingOutbox,
projection::Projection,
types::{DeviceId, MarktpartnerCode, MeLo, MessageRef, Pruefidentifikator, Sparte},
workflow::{CommandPayload, EventPayload, PendingDeadline, Workflow, WorkflowOutput},
};
use mako_fristen::{HolidayCalendar, deadline_at_werktage};
use time::OffsetDateTime;
pub const WORKFLOW_NAME: &str = "wim-geraeteubernahme";
pub const GERAETEUBERNAHME_PIDS: &[u32] = &[17001, 17009];
pub const BESTELLUNG_PIDS: &[u32] = &[17001];
pub const ANKUENDIGUNG_PIDS: &[u32] = &[17009];
pub const ANFRAGE_PID: Pruefidentifikator = Pruefidentifikator::const_new(35001);
pub const ANGEBOT_PID: Pruefidentifikator = Pruefidentifikator::const_new(15001);
pub const BESTAETIGUNG_PID: Pruefidentifikator = Pruefidentifikator::const_new(19001);
pub const ABLEHNUNG_PID: Pruefidentifikator = Pruefidentifikator::const_new(19002);
pub const GERAETEWECHSELABSICHT_PIDS: (u32, u32) = (19015, 19016);
pub const ANGEBOT_FRIST_WT: u32 = 4;
pub const BESTELLUNG_FRIST_WT: u32 = 3;
pub const BESTAETIGUNG_FRIST_WT: u32 = 2;
pub const GERAETEWECHSELABSICHT_ANTWORT_KEY: &str = "wim.antwort-geraetewechselabsicht";
pub const GERAETEWECHSEL_TERMIN_VORLAUF_KEY: &str = "wim.geraetewechsel-termin";
pub const ORDRSP_DEADLINE_LABEL: &str = "wim-geraeteubernahme-ordrsp-deadline";
pub const GERAETEWECHSELABSICHT_DEADLINE_LABEL: &str = "wim-geraetewechselabsicht-ordrsp-deadline";
#[must_use]
pub const fn ordrsp_ebd(orders_pid: u32, sparte: Sparte) -> Option<&'static str> {
use mako_pruefung::codes as c;
match (orders_pid, sparte) {
(17001, Sparte::Strom) => Some(c::EBD_BESTELLUNG_GERAETEUEBERNAHME),
(17001, Sparte::Gas) => Some(c::EBD_BESTELLUNG_GERAETEUEBERNAHME_GAS),
(17009, Sparte::Strom) => Some(c::EBD_GERAETEWECHSELABSICHT),
(17009, Sparte::Gas) => Some(c::EBD_GERAETEWECHSELABSICHT_GAS),
_ => None,
}
}
#[must_use]
pub const fn ordrsp_antwort_pid(orders_pid: u32, zustimmung: bool) -> Option<u32> {
match (orders_pid, zustimmung) {
(17001, true) => Some(19001),
(17001, false) => Some(19002),
(17009, true) => Some(GERAETEWECHSELABSICHT_PIDS.0),
(17009, false) => Some(GERAETEWECHSELABSICHT_PIDS.1),
_ => None,
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(tag = "type", content = "data")]
pub enum GeraeteubernahmeEvent {
OrdersEmpfangen {
pid: Pruefidentifikator,
msbn: MarktpartnerCode,
msba: MarktpartnerCode,
melo_id: MeLo,
device_id: DeviceId,
document_date: String,
termin: Option<String>,
message_ref: MessageRef,
sparte: Sparte,
},
ValidationPassed {
message_ref: MessageRef,
},
AntwortGesendet {
pruefidentifikator: Pruefidentifikator,
antwort_code: String,
antwort_ebd: String,
zustimmung: bool,
},
Abgeschlossen {
device_id: DeviceId,
},
Abgelehnt {
reason: String,
},
DeadlineExpired {
deadline_id: DeadlineId,
label: Box<str>,
},
}
impl EventPayload for GeraeteubernahmeEvent {
fn event_type(&self) -> &'static str {
match self {
Self::OrdersEmpfangen { .. } => "WimGeraeteubernahmeOrdersEmpfangen",
Self::ValidationPassed { .. } => "WimGeraeteubernahmeValidationPassed",
Self::AntwortGesendet { .. } => "WimGeraeteubernahmeAntwortGesendet",
Self::Abgeschlossen { .. } => "WimGeraeteubernahmeAbgeschlossen",
Self::Abgelehnt { .. } => "WimGeraeteubernahmeAbgelehnt",
Self::DeadlineExpired { .. } => "WimGeraeteubernahmeDeadlineExpired",
}
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct GeraeteubernahmeData {
pub pid: Pruefidentifikator,
pub msbn: MarktpartnerCode,
pub msba: MarktpartnerCode,
pub melo_id: MeLo,
pub device_id: DeviceId,
pub document_date: String,
pub termin: Option<String>,
pub message_ref: MessageRef,
pub sparte: Sparte,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(tag = "status", content = "data")]
#[derive(Default)]
pub enum GeraeteubernahmeState {
#[default]
New,
OrdersEmpfangen(GeraeteubernahmeData),
ValidationPassed(GeraeteubernahmeData),
Beantwortet(GeraeteubernahmeData),
Abgeschlossen(GeraeteubernahmeData),
Abgelehnt {
reason: String,
},
}
impl GeraeteubernahmeState {
#[must_use]
pub fn is_terminal(&self) -> bool {
matches!(self, Self::Abgeschlossen(_) | Self::Abgelehnt { .. })
}
#[must_use]
pub fn status_str(&self) -> &'static str {
match self {
Self::New => "New",
Self::OrdersEmpfangen(_) => "OrdersEmpfangen",
Self::ValidationPassed(_) => "ValidationPassed",
Self::Beantwortet(_) => "Beantwortet",
Self::Abgeschlossen(_) => "Abgeschlossen",
Self::Abgelehnt { .. } => "Abgelehnt",
}
}
}
impl mako_engine::workflow::OccupiesBusinessKey for GeraeteubernahmeState {
fn occupies_business_key(&self) -> bool {
!matches!(self, Self::New) && !self.is_terminal()
}
}
#[derive(Clone)]
pub enum GeraeteubernahmeCommand {
ReceiveOrders {
pid: Pruefidentifikator,
sender: MarktpartnerCode,
receiver: MarktpartnerCode,
melo_id: MeLo,
device_id: DeviceId,
document_date: String,
termin: Option<String>,
message_ref: MessageRef,
validation_passed: bool,
validation_errors: Vec<String>,
sparte: Sparte,
received_at: OffsetDateTime,
},
DispatchAntwort {
antwort_code: String,
bemerkung: Option<String>,
},
ConfirmTransfer {
device_id: DeviceId,
},
TimeoutExpired {
deadline_id: DeadlineId,
label: Box<str>,
},
}
impl CommandPayload for GeraeteubernahmeCommand {}
pub struct WimGeraeteubernahmeWorkflow;
impl Workflow for WimGeraeteubernahmeWorkflow {
type State = GeraeteubernahmeState;
type Event = GeraeteubernahmeEvent;
type Command = GeraeteubernahmeCommand;
fn on_deadline(
deadline: &mako_engine::deadline::Deadline,
state: &Self::State,
) -> Option<Self::Command> {
let ours = matches!(
deadline.label(),
ORDRSP_DEADLINE_LABEL | GERAETEWECHSELABSICHT_DEADLINE_LABEL
);
if ours && !state.is_terminal() {
Some(GeraeteubernahmeCommand::TimeoutExpired {
deadline_id: deadline.deadline_id(),
label: deadline.label().into(),
})
} else {
None
}
}
fn apply(state: Self::State, event: &Self::Event) -> Self::State {
match event {
GeraeteubernahmeEvent::OrdersEmpfangen {
pid,
msbn,
msba,
melo_id,
device_id,
document_date,
termin,
message_ref,
sparte,
} => GeraeteubernahmeState::OrdersEmpfangen(GeraeteubernahmeData {
pid: *pid,
msbn: msbn.clone(),
msba: msba.clone(),
melo_id: melo_id.clone(),
device_id: device_id.clone(),
document_date: document_date.clone(),
termin: termin.clone(),
message_ref: message_ref.clone(),
sparte: *sparte,
}),
GeraeteubernahmeEvent::ValidationPassed { .. } => {
if let GeraeteubernahmeState::OrdersEmpfangen(data) = state {
GeraeteubernahmeState::ValidationPassed(data)
} else {
state
}
}
GeraeteubernahmeEvent::AntwortGesendet {
zustimmung,
antwort_code,
antwort_ebd,
..
} => match state {
GeraeteubernahmeState::ValidationPassed(data) => {
if *zustimmung {
GeraeteubernahmeState::Beantwortet(data)
} else {
GeraeteubernahmeState::Abgelehnt {
reason: format!("{antwort_ebd} {antwort_code}"),
}
}
}
other => other,
},
GeraeteubernahmeEvent::Abgeschlossen { device_id } => match state {
GeraeteubernahmeState::Beantwortet(mut data) => {
data.device_id = device_id.clone();
GeraeteubernahmeState::Abgeschlossen(data)
}
other => other,
},
GeraeteubernahmeEvent::Abgelehnt { reason } => GeraeteubernahmeState::Abgelehnt {
reason: reason.clone(),
},
GeraeteubernahmeEvent::DeadlineExpired { label, .. } => {
if state.is_terminal() {
state
} else {
GeraeteubernahmeState::Abgelehnt {
reason: format!("deadline expired: {label}"),
}
}
}
}
}
fn handle(
state: &Self::State,
command: Self::Command,
) -> Result<WorkflowOutput<Self::Event>, WorkflowError> {
match command {
GeraeteubernahmeCommand::ReceiveOrders {
pid,
sender,
receiver,
melo_id,
device_id,
document_date,
termin,
message_ref,
validation_passed,
validation_errors,
sparte,
received_at,
} => {
if !matches!(state, GeraeteubernahmeState::New) {
return Err(WorkflowError::invalid_state("New", state.status_str()));
}
if !GERAETEUBERNAHME_PIDS.contains(&pid.as_u32()) {
return Err(WorkflowError::rejected(format!(
"PID {} is not answered by this workflow (expected {GERAETEUBERNAHME_PIDS:?})",
pid.as_u32(),
)));
}
let absender = sender.clone();
let empfaenger = receiver.clone();
let mut events = vec![GeraeteubernahmeEvent::OrdersEmpfangen {
pid,
msbn: sender,
msba: receiver,
melo_id,
device_id,
document_date,
termin: termin.clone(),
message_ref: message_ref.clone(),
sparte,
}];
if !validation_passed {
let reason = validation_errors.join("; ");
events.push(GeraeteubernahmeEvent::Abgelehnt {
reason: reason.clone(),
});
return Ok(WorkflowOutput::with_outbox_and_deadlines(
events,
vec![aperak(&empfaenger, &absender, sparte, Some(&reason))],
vec![aperak_deadline(sparte, pid.as_u32(), received_at)],
));
}
if pid.as_u32() == 17_009
&& let Some(mako_fristen::vorlauf::VorlaufVerdict::TooLate {
shortfall_wt,
earliest_possible,
}) = vorlauf_verdict(termin.as_deref(), received_at)
{
let reason = format!(
"E17: Mindestvorlaufzeit nicht eingehalten — der Gerätewechseltermin \
liegt {shortfall_wt} Werktage zu früh; frühestmöglicher Termin ist \
{earliest_possible} (WiM Teil 1 Kap. 3.1.2 Nr. 1)"
);
events.push(GeraeteubernahmeEvent::Abgelehnt {
reason: reason.clone(),
});
return Ok(WorkflowOutput::with_outbox_and_deadlines(
events,
vec![aperak(&empfaenger, &absender, sparte, Some(&reason))],
vec![aperak_deadline(sparte, pid.as_u32(), received_at)],
));
}
events.push(GeraeteubernahmeEvent::ValidationPassed { message_ref });
let mut deadlines = vec![aperak_deadline(sparte, pid.as_u32(), received_at)];
deadlines.push(antwort_deadline(
pid.as_u32(),
termin.as_deref(),
received_at,
)?);
Ok(WorkflowOutput::with_outbox_and_deadlines(
events,
vec![aperak(&empfaenger, &absender, sparte, None)],
deadlines,
))
}
GeraeteubernahmeCommand::DispatchAntwort {
antwort_code,
bemerkung,
} => {
let GeraeteubernahmeState::ValidationPassed(data) = state else {
return Err(WorkflowError::invalid_state(
"ValidationPassed",
state.status_str(),
));
};
let orders_pid = data.pid.as_u32();
let tree = ordrsp_ebd(orders_pid, data.sparte).ok_or_else(|| {
WorkflowError::rejected(format!(
"ORDERS {orders_pid} has no Entscheidungsbaum in Sparte {}",
data.sparte
))
})?;
let code = mako_pruefung::codes::lookup(tree, &antwort_code).ok_or_else(|| {
WorkflowError::rejected(format!(
"Antwortcode {antwort_code:?} is not published in {tree}"
))
})?;
let zustimmung = code.ist_zustimmung().ok_or_else(|| {
WorkflowError::rejected(format!("{} sits off the agreement axis", code.code))
})?;
if code.braucht_bemerkung && bemerkung.is_none() {
return Err(WorkflowError::rejected(format!(
"{tree} {} ({}) requires a written Erläuterung",
code.code, code.bedeutung
)));
}
let antwort_pid = ordrsp_antwort_pid(orders_pid, zustimmung).ok_or_else(|| {
WorkflowError::rejected(format!("ORDERS {orders_pid} has no ORDRSP answer PID"))
})?;
let codeliste = code.wire_codeliste().ok_or_else(|| {
WorkflowError::rejected(format!("{tree} {} names no Codeliste", code.code))
})?;
let mut payload = serde_json::json!({
"pid": antwort_pid,
"sender": data.msba.as_str(),
"receiver": data.msbn.as_str(),
"melo": data.melo_id.as_str(),
"antwort_code": code.code,
"antwort_codeliste": codeliste,
"antwort_tree": tree,
"orig_message_ref": data.message_ref.as_str(),
});
if let Some(ref t) = data.termin {
payload["termin"] = serde_json::Value::String(t.clone());
}
if let Some(ref text) = bemerkung {
payload["bemerkung"] = serde_json::Value::String(text.clone());
}
Ok(WorkflowOutput::with_outbox(
vec![GeraeteubernahmeEvent::AntwortGesendet {
pruefidentifikator: Pruefidentifikator::new(antwort_pid)
.map_err(WorkflowError::rejected)?,
antwort_code: code.code.to_owned(),
antwort_ebd: tree.to_owned(),
zustimmung,
}],
vec![PendingOutbox::new("ORDRSP", data.msbn.as_str(), payload).caused_by(0)],
))
}
GeraeteubernahmeCommand::ConfirmTransfer { device_id } => {
if !matches!(state, GeraeteubernahmeState::Beantwortet(_)) {
return Err(WorkflowError::invalid_state(
"Beantwortet",
state.status_str(),
));
}
Ok(vec![GeraeteubernahmeEvent::Abgeschlossen { device_id }].into())
}
GeraeteubernahmeCommand::TimeoutExpired { deadline_id, label } => {
if state.is_terminal() {
return Ok(WorkflowOutput::events(vec![]));
}
Ok(vec![GeraeteubernahmeEvent::DeadlineExpired { deadline_id, label }].into())
}
}
}
}
fn aperak(
from: &MarktpartnerCode,
to: &MarktpartnerCode,
sparte: Sparte,
reason: Option<&str>,
) -> PendingOutbox {
let positive = reason.is_none();
let mut payload = serde_json::json!({
"sender": from.as_str(),
"receiver": to.as_str(),
"pid": 29001_u32,
"positive": positive,
"sparte": sparte,
});
if positive && !mako_fristen::aperak_hat_anerkennungsmeldung(sparte == Sparte::Gas) {
payload["suppress_wire"] = serde_json::Value::Bool(true);
}
if let Some(r) = reason {
payload["error_code"] = serde_json::Value::String(mako_engine::erc::codes::Z29.to_owned());
payload["reason"] = serde_json::Value::String(r.to_owned());
}
PendingOutbox::new("APERAK", to.as_str(), payload).caused_by(0)
}
fn aperak_deadline(sparte: Sparte, pid: u32, received_at: OffsetDateTime) -> PendingDeadline {
match sparte {
Sparte::Strom => PendingDeadline::new(
mako_fristen::APERAK_STROM_WINDOW_LABEL,
mako_fristen::aperak_strom_due_at(received_at),
),
Sparte::Gas => {
let (label, due) = mako_fristen::aperak_gas_due_at(pid, received_at);
PendingDeadline::new(label, due)
}
}
}
fn vorlauf_verdict(
termin: Option<&str>,
received_at: OffsetDateTime,
) -> Option<mako_fristen::vorlauf::VorlaufVerdict> {
let datum = termin.and_then(parse_yyyymmdd)?;
let obligation = mako_fristen::vorlauf::vorlauf(GERAETEWECHSEL_TERMIN_VORLAUF_KEY)
.expect("the Gerätewechseltermin Vorlauffrist is catalogued");
Some(obligation.shape.check(
mako_fristen::berlin_date(received_at),
datum,
HolidayCalendar::BdewMaKo,
))
}
fn antwort_deadline(
orders_pid: u32,
termin: Option<&str>,
received_at: OffsetDateTime,
) -> Result<PendingDeadline, WorkflowError> {
match orders_pid {
17001 => Ok(PendingDeadline::new(
ORDRSP_DEADLINE_LABEL,
deadline_at_werktage(
received_at,
BESTAETIGUNG_FRIST_WT,
HolidayCalendar::BdewMaKo,
),
)),
17009 => {
let Some(datum) = termin.and_then(parse_yyyymmdd) else {
return Err(WorkflowError::rejected(
"eine Anzeige der Gerätewechselabsicht muss den Gerätewechseltermin nennen — \
die Antwortfrist ist der 2. WT davor (WiM Teil 1 Kap. 3.1.2 Nr. 2)"
.to_owned(),
));
};
let mako_fristen::vorlauf::VorlaufShape::LatestWerktageBefore(wt) =
mako_fristen::vorlauf::vorlauf(GERAETEWECHSELABSICHT_ANTWORT_KEY)
.expect("wim.antwort-geraetewechselabsicht is catalogued")
.shape
else {
unreachable!("{GERAETEWECHSELABSICHT_ANTWORT_KEY} is a LatestWerktageBefore window")
};
let due = mako_fristen::sub_werktage(datum, wt, HolidayCalendar::BdewMaKo);
Ok(PendingDeadline::new(
GERAETEWECHSELABSICHT_DEADLINE_LABEL,
mako_fristen::berlin_at(
due,
time::Time::from_hms(17, 0, 0).expect("17:00 is a valid time"),
),
))
}
other => Err(WorkflowError::rejected(format!(
"ORDERS {other} has no ORDRSP answer window in this workflow"
))),
}
}
fn parse_yyyymmdd(raw: &str) -> Option<time::Date> {
let digits: String = raw.chars().filter(char::is_ascii_digit).collect();
if digits.len() != 8 {
return None;
}
let year: i32 = digits[0..4].parse().ok()?;
let month = time::Month::try_from(digits[4..6].parse::<u8>().ok()?).ok()?;
let day: u8 = digits[6..8].parse().ok()?;
time::Date::from_calendar_date(year, month, day).ok()
}
#[derive(Debug)]
pub enum GeraeteubernahmeRecord {
New {
event_count: usize,
},
Active {
status: &'static str,
melo_id: MeLo,
msbn: MarktpartnerCode,
msba: MarktpartnerCode,
device_id: DeviceId,
pid: Pruefidentifikator,
sparte: Sparte,
event_count: usize,
},
}
impl GeraeteubernahmeRecord {
#[must_use]
pub fn status(&self) -> &'static str {
match self {
Self::New { .. } => "New",
Self::Active { status, .. } => status,
}
}
#[must_use]
pub fn event_count(&self) -> usize {
match self {
Self::New { event_count } | Self::Active { event_count, .. } => *event_count,
}
}
#[must_use]
pub fn active_data(&self) -> Option<GeraeteubernahmeRecordData<'_>> {
match self {
Self::New { .. } => None,
Self::Active {
melo_id,
msbn,
msba,
device_id,
pid,
sparte,
..
} => Some(GeraeteubernahmeRecordData {
melo_id,
msbn,
msba,
device_id,
pid,
sparte: *sparte,
}),
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct GeraeteubernahmeRecordData<'a> {
pub melo_id: &'a MeLo,
pub msbn: &'a MarktpartnerCode,
pub msba: &'a MarktpartnerCode,
pub device_id: &'a DeviceId,
pub pid: &'a Pruefidentifikator,
pub sparte: Sparte,
}
impl Default for GeraeteubernahmeRecord {
fn default() -> Self {
Self::New { event_count: 0 }
}
}
#[derive(Debug, Default)]
pub struct GeraeteubernahmeProjection {
pub records: HashMap<String, GeraeteubernahmeRecord>,
pub last_seq: u64,
}
impl Projection for GeraeteubernahmeProjection {
fn name(&self) -> &'static str {
"GeraeteubernahmeProjection"
}
fn handle_event(&mut self, envelope: &EventEnvelope) {
self.last_seq = self.last_seq.max(envelope.sequence_number);
let record = self
.records
.entry(envelope.stream_id.as_str().to_owned())
.or_default();
let Ok(event) = envelope.decode::<GeraeteubernahmeEvent>() else {
return;
};
match record {
GeraeteubernahmeRecord::New { event_count }
| GeraeteubernahmeRecord::Active { event_count, .. } => *event_count += 1,
}
match event {
GeraeteubernahmeEvent::OrdersEmpfangen {
pid,
msbn,
msba,
melo_id,
device_id,
sparte,
..
} => {
let count = record.event_count();
*record = GeraeteubernahmeRecord::Active {
status: "OrdersEmpfangen",
pid,
msbn,
msba,
melo_id,
device_id,
sparte,
event_count: count,
};
}
GeraeteubernahmeEvent::ValidationPassed { .. } => {
if let GeraeteubernahmeRecord::Active { status, .. } = record {
*status = "ValidationPassed";
}
}
GeraeteubernahmeEvent::AntwortGesendet { zustimmung, .. } => {
if let GeraeteubernahmeRecord::Active { status, .. } = record {
*status = if zustimmung {
"Beantwortet"
} else {
"Abgelehnt"
};
}
}
GeraeteubernahmeEvent::Abgeschlossen { device_id } => {
if let GeraeteubernahmeRecord::Active {
status,
device_id: d,
..
} = record
{
*status = "Abgeschlossen";
*d = device_id;
}
}
GeraeteubernahmeEvent::Abgelehnt { .. }
| GeraeteubernahmeEvent::DeadlineExpired { .. } => {
if let GeraeteubernahmeRecord::Active { status, .. } = record {
*status = "Abgelehnt";
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
const MELO: &str = "DE0000000001234567890000000000001";
fn orders(pid: u32, sparte: Sparte, termin: Option<&str>) -> GeraeteubernahmeCommand {
GeraeteubernahmeCommand::ReceiveOrders {
pid: Pruefidentifikator::new(pid).expect("valid PID"),
sender: MarktpartnerCode::new("4012345000023"),
receiver: MarktpartnerCode::new("9900357000004"),
melo_id: MeLo::new(MELO),
device_id: DeviceId::new("1ESY1161234567"),
document_date: "20260302".to_owned(),
termin: termin.map(str::to_owned),
message_ref: MessageRef::new("ORD-1"),
validation_passed: true,
validation_errors: vec![],
sparte,
received_at: time::macros::datetime!(2026-03-02 09:00 UTC),
}
}
fn validated(pid: u32, sparte: Sparte, termin: Option<&str>) -> GeraeteubernahmeState {
let s = GeraeteubernahmeState::default();
let out = WimGeraeteubernahmeWorkflow::handle(&s, orders(pid, sparte, termin))
.expect("valid ORDERS");
out.events
.iter()
.fold(s, WimGeraeteubernahmeWorkflow::apply)
}
#[test]
fn a_termin_inside_the_vorlauffrist_is_refused_with_e17() {
for (termin, admissible) in [
("20260304", false), ("20260305", false), ("20260306", true), ("20260401", true),
] {
let out = WimGeraeteubernahmeWorkflow::handle(
&GeraeteubernahmeState::default(),
orders(17_009, Sparte::Strom, Some(termin)),
)
.expect("the ORDERS is well-formed either way");
let refused = out.events.iter().any(|e| {
matches!(e, GeraeteubernahmeEvent::Abgelehnt { reason } if reason.contains("E17"))
});
assert_eq!(
refused, !admissible,
"Termin {termin}: expected admissible = {admissible}"
);
if !admissible {
let state = out.events.iter().fold(
GeraeteubernahmeState::default(),
WimGeraeteubernahmeWorkflow::apply,
);
assert!(state.is_terminal(), "a refused Anzeige does not stay open");
let GeraeteubernahmeState::Abgelehnt { reason } = &state else {
panic!("refused states are Abgelehnt");
};
assert!(
reason.contains("2026-03-06"),
"the refusal names the frühestmöglicher Termin: {reason}"
);
assert!(out.deadlines.iter().all(|d| d.label.starts_with("aperak-")));
}
}
}
#[test]
fn a_bestellung_is_answered_with_an_ordrsp() {
let out = WimGeraeteubernahmeWorkflow::handle(
&validated(17001, Sparte::Strom, Some("20260401")),
GeraeteubernahmeCommand::DispatchAntwort {
antwort_code: "Z13".to_owned(),
bemerkung: None,
},
)
.expect("Z13");
assert_eq!(&*out.outbox[0].message_type, "ORDRSP");
assert_eq!(out.outbox[0].payload["pid"], 19_001);
assert_eq!(out.outbox[0].payload["antwort_code"], "Z13");
assert_eq!(out.outbox[0].payload["antwort_codeliste"], "S_0067");
assert_eq!(out.outbox[0].payload["antwort_tree"], "E_0247");
}
#[test]
fn the_cluster_picks_the_answer_pid() {
let out = WimGeraeteubernahmeWorkflow::handle(
&validated(17001, Sparte::Strom, Some("20260401")),
GeraeteubernahmeCommand::DispatchAntwort {
antwort_code: "Z32".to_owned(),
bemerkung: None,
},
)
.expect("Z32");
assert_eq!(out.outbox[0].payload["pid"], 19_002);
assert_eq!(out.outbox[0].payload["antwort_codeliste"], "S_0068");
}
#[test]
fn the_sparte_picks_the_tree_on_a_shared_pid() {
for (sparte, ebd, codeliste) in [
(Sparte::Strom, "E_0247", "S_0067"),
(Sparte::Gas, "E_2011", "G_0061"),
] {
let out = WimGeraeteubernahmeWorkflow::handle(
&validated(17001, sparte, Some("20260401")),
GeraeteubernahmeCommand::DispatchAntwort {
antwort_code: "Z13".to_owned(),
bemerkung: None,
},
)
.expect("Z13");
assert_eq!(out.outbox[0].payload["antwort_tree"], ebd, "{sparte}");
assert_eq!(out.outbox[0].payload["antwort_codeliste"], codeliste);
}
}
#[test]
fn the_geraetewechselabsicht_answer_is_a_division_of_labour() {
for (code, pid) in [("ZB4", 19_015), ("ZB5", 19_016)] {
let out = WimGeraeteubernahmeWorkflow::handle(
&validated(17009, Sparte::Strom, Some("20260401")),
GeraeteubernahmeCommand::DispatchAntwort {
antwort_code: code.to_owned(),
bemerkung: None,
},
)
.unwrap_or_else(|e| panic!("{code}: {e}"));
assert_eq!(out.outbox[0].payload["pid"], pid);
}
}
#[test]
fn the_geraetewechselabsicht_window_is_anchored_on_the_termin() {
let out = WimGeraeteubernahmeWorkflow::handle(
&GeraeteubernahmeState::default(),
orders(17009, Sparte::Strom, Some("20260401")),
)
.expect("valid 17009");
let dl = out
.deadlines
.iter()
.find(|d| &*d.label == GERAETEWECHSELABSICHT_DEADLINE_LABEL)
.expect("the Gerätewechselabsicht window");
assert_eq!(dl.due_at.date(), time::macros::date!(2026 - 03 - 30));
assert!(dl.due_at > time::macros::datetime!(2026-03-04 00:00 UTC));
}
#[test]
fn a_geraetewechselabsicht_without_a_termin_is_refused() {
let err = WimGeraeteubernahmeWorkflow::handle(
&GeraeteubernahmeState::default(),
orders(17009, Sparte::Strom, None),
)
.expect_err("no Termin");
assert!(err.to_string().contains("Gerätewechseltermin"), "{err}");
}
#[test]
fn gas_suppresses_the_positive_aperak() {
let strom = WimGeraeteubernahmeWorkflow::handle(
&GeraeteubernahmeState::default(),
orders(17001, Sparte::Strom, Some("20260401")),
)
.expect("valid");
assert_eq!(strom.outbox[0].payload["positive"], true);
assert!(strom.outbox[0].payload.get("suppress_wire").is_none());
let gas = WimGeraeteubernahmeWorkflow::handle(
&GeraeteubernahmeState::default(),
orders(17001, Sparte::Gas, Some("20260401")),
)
.expect("valid");
assert_eq!(gas.outbox[0].payload["suppress_wire"], true);
}
#[test]
fn a_foreign_code_is_refused() {
let err = WimGeraeteubernahmeWorkflow::handle(
&validated(17001, Sparte::Strom, Some("20260401")),
GeraeteubernahmeCommand::DispatchAntwort {
antwort_code: "ZB4".to_owned(),
bemerkung: None,
},
)
.expect_err("ZB4 is not an E_0247 code");
assert!(err.to_string().contains("E_0247"), "{err}");
}
}