use mako_engine::{
deadline::Deadline,
error::WorkflowError,
ids::DeadlineId,
outbox::PendingOutbox,
types::{MaLo, MarktpartnerCode, MessageRef, Pruefidentifikator},
workflow::{CommandPayload, EventPayload, Workflow, WorkflowOutput},
};
use serde::{Deserialize, Serialize};
pub const ANMELDUNG_WINDOW_LABEL: &str = "emob-anmeldung-antwort";
pub const ZUORDNUNGSENDE_WINDOW_LABEL: &str = "emob-zuordnungsende-antwort";
pub const ABMELDUNG_WINDOW_LABEL: &str = "emob-abmeldung-antwort";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct EmobAntwort {
pub antwort_code: String,
pub codeliste: String,
pub zustimmung: bool,
pub bemerkung: Option<String>,
pub zp_ngz: Option<String>,
}
impl EmobAntwort {
#[must_use]
pub fn zustimmung(code: impl Into<String>, tree: impl Into<String>) -> Self {
Self {
antwort_code: code.into(),
codeliste: tree.into(),
zustimmung: true,
bemerkung: None,
zp_ngz: None,
}
}
#[must_use]
pub fn ablehnung(code: impl Into<String>, tree: impl Into<String>) -> Self {
Self {
antwort_code: code.into(),
codeliste: tree.into(),
zustimmung: false,
bemerkung: None,
zp_ngz: None,
}
}
#[must_use]
pub fn mit_bemerkung(mut self, text: impl Into<String>) -> Self {
self.bemerkung = Some(text.into());
self
}
#[must_use]
pub fn mit_zp_ngz(mut self, zp: impl Into<String>) -> Self {
self.zp_ngz = Some(zp.into());
self
}
#[must_use]
pub fn fehlende_bemerkung(&self) -> bool {
self.antwort_code == "A99" && self.bemerkung.as_ref().is_none_or(|t| t.trim().is_empty())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct LegWire {
pub anfrage_pid: u32,
pub antwort_pid: u32,
pub bgm: &'static str,
pub dtm: &'static str,
pub dtm_bilanzierung: &'static str,
pub bilanzierung_key: &'static str,
pub window_label: &'static str,
}
const DTM_VERTRAGSBEGINN: &str = "92";
const DTM_VERTRAGSENDE: &str = "93";
const DTM_BILANZIERUNGSBEGINN: &str = "158";
const DTM_BILANZIERUNGSENDE: &str = "159";
pub const ANMELDUNG: LegWire = LegWire {
anfrage_pid: 55_238,
antwort_pid: 55_239,
bgm: "E01",
dtm: DTM_VERTRAGSBEGINN,
dtm_bilanzierung: DTM_BILANZIERUNGSBEGINN,
bilanzierung_key: "bilanzierungsbeginn",
window_label: ANMELDUNG_WINDOW_LABEL,
};
pub const ZUORDNUNGSENDE: LegWire = LegWire {
anfrage_pid: 55_240,
antwort_pid: 55_241,
bgm: "E44",
dtm: DTM_VERTRAGSENDE,
dtm_bilanzierung: DTM_BILANZIERUNGSENDE,
bilanzierung_key: "bilanzierungsende",
window_label: ZUORDNUNGSENDE_WINDOW_LABEL,
};
pub const ABMELDUNG: LegWire = LegWire {
anfrage_pid: 55_242,
antwort_pid: 55_243,
bgm: "E02",
dtm: DTM_VERTRAGSENDE,
dtm_bilanzierung: DTM_BILANZIERUNGSENDE,
bilanzierung_key: "bilanzierungsende",
window_label: ABMELDUNG_WINDOW_LABEL,
};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Modellwechseldaten {
pub malo: MaLo,
pub sender: MarktpartnerCode,
pub receiver: MarktpartnerCode,
pub process_date: String,
pub pruefidentifikator: Pruefidentifikator,
pub vorgangsnummer: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "status", content = "data")]
pub enum ModellwechselState {
#[default]
New,
Gesendet(Box<Modellwechseldaten>),
Erhalten(Box<Modellwechseldaten>),
Beantwortet {
data: Box<Modellwechseldaten>,
antwort: Box<EmobAntwort>,
},
AntwortErhalten {
data: Box<Modellwechseldaten>,
antwort: Box<EmobAntwort>,
},
Eskaliert {
grund: String,
},
Rejected {
grund: String,
},
}
impl ModellwechselState {
#[must_use]
pub const fn label(&self) -> &'static str {
match self {
Self::New => "New",
Self::Gesendet(_) => "Gesendet",
Self::Erhalten(_) => "Erhalten",
Self::Beantwortet { .. } => "Beantwortet",
Self::AntwortErhalten { .. } => "AntwortErhalten",
Self::Eskaliert { .. } => "Eskaliert",
Self::Rejected { .. } => "Rejected",
}
}
#[must_use]
pub const fn ist_terminal(&self) -> bool {
matches!(
self,
Self::Beantwortet { .. }
| Self::AntwortErhalten { .. }
| Self::Eskaliert { .. }
| Self::Rejected { .. }
)
}
#[must_use]
pub fn daten(&self) -> Option<&Modellwechseldaten> {
match self {
Self::Gesendet(d) | Self::Erhalten(d) => Some(d),
Self::Beantwortet { data, .. } | Self::AntwortErhalten { data, .. } => Some(data),
Self::New | Self::Eskaliert { .. } | Self::Rejected { .. } => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum ModellwechselEvent {
AnfrageGesendet {
data: Box<Modellwechseldaten>,
},
AnfrageErhalten {
data: Box<Modellwechseldaten>,
message_ref: MessageRef,
},
AntwortGesendet {
antwort: Box<EmobAntwort>,
},
AntwortErhalten {
antwort: Box<EmobAntwort>,
},
FristAbgelaufen {
label: String,
},
Abgewiesen {
grund: String,
},
}
impl EventPayload for ModellwechselEvent {
fn event_type(&self) -> &'static str {
match self {
Self::AnfrageGesendet { .. } => "EmobAnfrageGesendet",
Self::AnfrageErhalten { .. } => "EmobAnfrageErhalten",
Self::AntwortGesendet { .. } => "EmobAntwortGesendet",
Self::AntwortErhalten { .. } => "EmobAntwortErhalten",
Self::FristAbgelaufen { .. } => "EmobFristAbgelaufen",
Self::Abgewiesen { .. } => "EmobAbgewiesen",
}
}
}
#[derive(Debug, Clone)]
pub enum ModellwechselCommand {
Senden {
data: Box<Modellwechseldaten>,
},
ReceiveAnfrage {
data: Box<Modellwechseldaten>,
message_ref: MessageRef,
validation_passed: bool,
validation_errors: Vec<String>,
},
SendAntwort {
antwort: Box<EmobAntwort>,
},
ReceiveAntwort {
antwort: Box<EmobAntwort>,
},
TimeoutExpired {
deadline_id: DeadlineId,
label: Box<str>,
},
}
impl CommandPayload for ModellwechselCommand {}
fn apply(state: ModellwechselState, event: &ModellwechselEvent) -> ModellwechselState {
match event {
ModellwechselEvent::AnfrageGesendet { data } => ModellwechselState::Gesendet(data.clone()),
ModellwechselEvent::AnfrageErhalten { data, .. } => {
ModellwechselState::Erhalten(data.clone())
}
ModellwechselEvent::AntwortGesendet { antwort } => match state {
ModellwechselState::Erhalten(data) => ModellwechselState::Beantwortet {
data,
antwort: antwort.clone(),
},
other => other,
},
ModellwechselEvent::AntwortErhalten { antwort } => match state {
ModellwechselState::Gesendet(data) => ModellwechselState::AntwortErhalten {
data,
antwort: antwort.clone(),
},
other => other,
},
ModellwechselEvent::FristAbgelaufen { label } => {
if state.ist_terminal() {
state
} else {
ModellwechselState::Eskaliert {
grund: format!("Antwortfrist {label} verstrichen, keine Antwort eingegangen"),
}
}
}
ModellwechselEvent::Abgewiesen { grund } => ModellwechselState::Rejected {
grund: grund.clone(),
},
}
}
fn anfrage_outbox(leg: LegWire, data: &Modellwechseldaten) -> PendingOutbox {
let mut payload = serde_json::json!({
"pid": leg.anfrage_pid,
"sender": data.sender.as_str(),
"receiver": data.receiver.as_str(),
"malo": data.malo.as_str(),
"process_date": data.process_date,
"document_code": leg.bgm,
"dtm_qualifier": leg.dtm,
});
payload[leg.bilanzierung_key] = serde_json::Value::String(data.process_date.clone());
if let Some(vn) = &data.vorgangsnummer {
payload["vorgangsnummer"] = serde_json::Value::String(vn.clone());
}
PendingOutbox::new("UTILMD", data.receiver.as_str(), payload)
}
fn antwort_outbox(leg: LegWire, data: &Modellwechseldaten, antwort: &EmobAntwort) -> PendingOutbox {
let mut payload = serde_json::json!({
"pid": leg.antwort_pid,
"sender": data.receiver.as_str(),
"receiver": data.sender.as_str(),
"malo": data.malo.as_str(),
"process_date": data.process_date,
"document_code": leg.bgm,
"dtm_qualifier": leg.dtm,
"antwort_code": antwort.antwort_code,
"antwort_codeliste": antwort.codeliste,
});
payload[leg.bilanzierung_key] = serde_json::Value::String(data.process_date.clone());
if let Some(text) = &antwort.bemerkung {
payload["bemerkung"] = serde_json::Value::String(text.clone());
}
if let Some(zp) = &antwort.zp_ngz {
payload["mabis_zaehlpunkt"] = serde_json::Value::String(zp.clone());
}
if let Some(vn) = &data.vorgangsnummer {
payload["referenz_vorgangsnummer"] = serde_json::Value::String(vn.clone());
}
PendingOutbox::new("UTILMD", data.sender.as_str(), payload)
}
fn handle(
state: &ModellwechselState,
command: ModellwechselCommand,
leg: LegWire,
) -> Result<WorkflowOutput<ModellwechselEvent>, WorkflowError> {
match command {
ModellwechselCommand::Senden { data } => {
if !matches!(state, ModellwechselState::New) {
return Err(WorkflowError::invalid_state("New", state.label()));
}
let outbox = vec![anfrage_outbox(leg, &data).caused_by(0)];
Ok(WorkflowOutput::with_outbox(
vec![ModellwechselEvent::AnfrageGesendet { data }],
outbox,
))
}
ModellwechselCommand::ReceiveAnfrage {
data,
message_ref,
validation_passed,
validation_errors,
} => {
if !matches!(state, ModellwechselState::New) {
return Err(WorkflowError::invalid_state("New", state.label()));
}
if data.pruefidentifikator.as_u32() != leg.anfrage_pid {
return Err(WorkflowError::rejected(format!(
"expected the {} Anfrage, got {}",
leg.anfrage_pid, data.pruefidentifikator
)));
}
if validation_passed {
Ok(vec![ModellwechselEvent::AnfrageErhalten { data, message_ref }].into())
} else {
let grund = validation_errors.join("; ");
Ok(vec![
ModellwechselEvent::AnfrageErhalten { data, message_ref },
ModellwechselEvent::Abgewiesen { grund },
]
.into())
}
}
ModellwechselCommand::SendAntwort { antwort } => {
let ModellwechselState::Erhalten(data) = state else {
return Err(WorkflowError::invalid_state("Erhalten", state.label()));
};
if antwort.fehlende_bemerkung() {
return Err(WorkflowError::rejected(
"A99 must carry the FTX+ACB Erläuterung its EBD requires".to_owned(),
));
}
let outbox = vec![antwort_outbox(leg, data, &antwort).caused_by(0)];
Ok(WorkflowOutput::with_outbox(
vec![ModellwechselEvent::AntwortGesendet { antwort }],
outbox,
))
}
ModellwechselCommand::ReceiveAntwort { antwort } => match state {
ModellwechselState::Gesendet(_) => {
Ok(vec![ModellwechselEvent::AntwortErhalten { antwort }].into())
}
ModellwechselState::AntwortErhalten { .. } => Ok(vec![].into()),
other => Err(WorkflowError::invalid_state("Gesendet", other.label())),
},
ModellwechselCommand::TimeoutExpired { label, .. } => {
if state.ist_terminal() {
return Ok(vec![].into());
}
Ok(vec![ModellwechselEvent::FristAbgelaufen {
label: label.to_string(),
}]
.into())
}
}
}
fn timeout_command(
deadline: &Deadline,
state: &ModellwechselState,
) -> Option<ModellwechselCommand> {
(!state.ist_terminal()).then(|| ModellwechselCommand::TimeoutExpired {
deadline_id: deadline.deadline_id(),
label: deadline.label().into(),
})
}
pub struct EmobAnmeldungWorkflow;
pub struct EmobZuordnungsendeWorkflow;
pub struct EmobAbmeldungWorkflow;
impl EmobAnmeldungWorkflow {
pub const WORKFLOW_NAME: &'static str = "emob-anmeldung";
#[must_use]
pub const fn wire() -> LegWire {
ANMELDUNG
}
}
impl EmobZuordnungsendeWorkflow {
pub const WORKFLOW_NAME: &'static str = "emob-zuordnungsende";
#[must_use]
pub const fn wire() -> LegWire {
ZUORDNUNGSENDE
}
}
impl EmobAbmeldungWorkflow {
pub const WORKFLOW_NAME: &'static str = "emob-abmeldung";
#[must_use]
pub const fn wire() -> LegWire {
ABMELDUNG
}
}
impl Workflow for EmobAnmeldungWorkflow {
type State = ModellwechselState;
type Event = ModellwechselEvent;
type Command = ModellwechselCommand;
fn on_deadline(deadline: &Deadline, state: &Self::State) -> Option<Self::Command> {
if deadline.label() != ANMELDUNG_WINDOW_LABEL {
return None;
}
timeout_command(deadline, state)
}
fn apply(state: Self::State, event: &Self::Event) -> Self::State {
apply(state, event)
}
fn handle(
state: &Self::State,
command: Self::Command,
) -> Result<WorkflowOutput<Self::Event>, WorkflowError> {
handle(state, command, ANMELDUNG)
}
}
impl Workflow for EmobZuordnungsendeWorkflow {
type State = ModellwechselState;
type Event = ModellwechselEvent;
type Command = ModellwechselCommand;
fn on_deadline(deadline: &Deadline, state: &Self::State) -> Option<Self::Command> {
if deadline.label() != ZUORDNUNGSENDE_WINDOW_LABEL {
return None;
}
timeout_command(deadline, state)
}
fn apply(state: Self::State, event: &Self::Event) -> Self::State {
apply(state, event)
}
fn handle(
state: &Self::State,
command: Self::Command,
) -> Result<WorkflowOutput<Self::Event>, WorkflowError> {
handle(state, command, ZUORDNUNGSENDE)
}
}
impl Workflow for EmobAbmeldungWorkflow {
type State = ModellwechselState;
type Event = ModellwechselEvent;
type Command = ModellwechselCommand;
fn on_deadline(deadline: &Deadline, state: &Self::State) -> Option<Self::Command> {
if deadline.label() != ABMELDUNG_WINDOW_LABEL {
return None;
}
timeout_command(deadline, state)
}
fn apply(state: Self::State, event: &Self::Event) -> Self::State {
apply(state, event)
}
fn handle(
state: &Self::State,
command: Self::Command,
) -> Result<WorkflowOutput<Self::Event>, WorkflowError> {
handle(state, command, ABMELDUNG)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn daten(pid: u32) -> Modellwechseldaten {
Modellwechseldaten {
malo: MaLo::new("51238696012"),
sender: MarktpartnerCode::new("9900123456789"),
receiver: MarktpartnerCode::new("9900987654321"),
process_date: "20270101".to_owned(),
pruefidentifikator: Pruefidentifikator::const_new(pid),
vorgangsnummer: Some("LPB-0001".to_owned()),
}
}
fn erhalten(leg: LegWire) -> ModellwechselState {
let out = handle(
&ModellwechselState::New,
ModellwechselCommand::ReceiveAnfrage {
data: Box::new(daten(leg.anfrage_pid)),
message_ref: MessageRef::new("MSG1"),
validation_passed: true,
validation_errors: Vec::new(),
},
leg,
)
.expect("accepted");
out.events.iter().fold(ModellwechselState::New, apply)
}
#[test]
fn the_three_legs_carry_their_ahb_columns() {
assert_eq!(
(
ANMELDUNG.bgm,
ANMELDUNG.dtm,
ANMELDUNG.dtm_bilanzierung,
ANMELDUNG.bilanzierung_key
),
("E01", "92", "158", "bilanzierungsbeginn")
);
assert_eq!(
(
ZUORDNUNGSENDE.bgm,
ZUORDNUNGSENDE.dtm,
ZUORDNUNGSENDE.dtm_bilanzierung,
ZUORDNUNGSENDE.bilanzierung_key
),
("E44", "93", "159", "bilanzierungsende")
);
assert_eq!(
(
ABMELDUNG.bgm,
ABMELDUNG.dtm,
ABMELDUNG.dtm_bilanzierung,
ABMELDUNG.bilanzierung_key
),
("E02", "93", "159", "bilanzierungsende")
);
}
#[test]
fn every_leg_has_its_own_workflow_name_and_label() {
let names = [
EmobAnmeldungWorkflow::WORKFLOW_NAME,
EmobZuordnungsendeWorkflow::WORKFLOW_NAME,
EmobAbmeldungWorkflow::WORKFLOW_NAME,
];
let labels = [
ANMELDUNG.window_label,
ZUORDNUNGSENDE.window_label,
ABMELDUNG.window_label,
];
for set in [names, labels] {
let mut sorted = set.to_vec();
sorted.sort_unstable();
sorted.dedup();
assert_eq!(sorted.len(), 3, "{set:?} collide");
}
}
#[test]
fn a_sent_anmeldung_states_its_bgm_and_both_dates() {
let out = handle(
&ModellwechselState::New,
ModellwechselCommand::Senden {
data: Box::new(daten(55_238)),
},
ANMELDUNG,
)
.expect("sent");
let p = &out.outbox[0].payload;
assert_eq!(p["pid"], 55_238);
assert_eq!(p["document_code"], "E01");
assert_eq!(p["dtm_qualifier"], "92");
assert_eq!(p["bilanzierungsbeginn"], p["process_date"]);
assert!(p.get("bilanzierungsende").is_none());
}
#[test]
fn the_answer_travels_back_and_names_its_tree() {
let state = erhalten(ANMELDUNG);
let out = handle(
&state,
ModellwechselCommand::SendAntwort {
antwort: Box::new(
EmobAntwort::zustimmung("A02", "E_0510")
.mit_zp_ngz("DE0001234567890000000000000000123"),
),
},
ANMELDUNG,
)
.expect("answered");
let p = &out.outbox[0].payload;
assert_eq!(p["pid"], 55_239);
assert_eq!(p["sender"], "9900987654321", "the answerer sends");
assert_eq!(p["receiver"], "9900123456789", "the asker receives");
assert_eq!(p["antwort_code"], "A02");
assert_eq!(p["antwort_codeliste"], "E_0510");
assert_eq!(p["referenz_vorgangsnummer"], "LPB-0001");
assert_eq!(
p["mabis_zaehlpunkt"], "DE0001234567890000000000000000123",
"AHB Bedingung [663] — the Bestätigung names the ZP der NGZ"
);
assert!(
p.get("vorgangsnummer").is_none(),
"IDE+24 stays a fresh number; the request's rides RFF+TN"
);
}
#[test]
fn the_tree_rides_with_every_code() {
for (leg, antwort) in [
(ANMELDUNG, EmobAntwort::ablehnung("A01", "E_0510")),
(ZUORDNUNGSENDE, EmobAntwort::zustimmung("A01", "E_0511")),
(ABMELDUNG, EmobAntwort::zustimmung("A01", "E_0512")),
] {
let out = handle(
&erhalten(leg),
ModellwechselCommand::SendAntwort {
antwort: Box::new(antwort.clone()),
},
leg,
)
.expect("answered");
let p = &out.outbox[0].payload;
assert_eq!(p["antwort_code"], "A01");
assert_eq!(p["antwort_codeliste"], antwort.codeliste);
}
}
#[test]
fn an_a99_without_its_erlaeuterung_is_refused() {
let state = erhalten(ABMELDUNG);
assert!(
handle(
&state,
ModellwechselCommand::SendAntwort {
antwort: Box::new(EmobAntwort::ablehnung("A99", "E_0512")),
},
ABMELDUNG,
)
.is_err()
);
assert!(
handle(
&state,
ModellwechselCommand::SendAntwort {
antwort: Box::new(
EmobAntwort::ablehnung("A99", "E_0512").mit_bemerkung("BG nicht gültig")
),
},
ABMELDUNG,
)
.is_ok()
);
}
#[test]
fn a_leg_refuses_another_legs_pid() {
assert!(
handle(
&ModellwechselState::New,
ModellwechselCommand::ReceiveAnfrage {
data: Box::new(daten(55_242)),
message_ref: MessageRef::new("MSG1"),
validation_passed: true,
validation_errors: Vec::new(),
},
ANMELDUNG,
)
.is_err()
);
}
#[test]
fn an_expired_window_escalates_rather_than_confirming() {
let state = erhalten(ANMELDUNG);
let out = handle(
&state,
ModellwechselCommand::TimeoutExpired {
deadline_id: DeadlineId::new(),
label: ANMELDUNG.window_label.into(),
},
ANMELDUNG,
)
.expect("fires");
let next = out.events.iter().fold(state, apply);
assert!(
matches!(next, ModellwechselState::Eskaliert { .. }),
"{next:?}"
);
assert!(next.ist_terminal());
}
#[test]
fn a_late_deadline_on_a_settled_leg_is_a_no_op() {
let state = erhalten(ABMELDUNG);
let answered = handle(
&state,
ModellwechselCommand::SendAntwort {
antwort: Box::new(EmobAntwort::zustimmung("A01", "E_0512")),
},
ABMELDUNG,
)
.expect("answered");
let settled = answered.events.iter().fold(state, apply);
assert!(settled.ist_terminal());
let out = handle(
&settled,
ModellwechselCommand::TimeoutExpired {
deadline_id: DeadlineId::new(),
label: ABMELDUNG.window_label.into(),
},
ABMELDUNG,
)
.expect("no-op");
assert!(out.events.is_empty());
}
#[test]
fn a_failed_validation_still_records_what_arrived() {
let out = handle(
&ModellwechselState::New,
ModellwechselCommand::ReceiveAnfrage {
data: Box::new(daten(55_240)),
message_ref: MessageRef::new("MSG1"),
validation_passed: false,
validation_errors: vec!["SG10 CCI ist nicht erlaubt".to_owned()],
},
ZUORDNUNGSENDE,
)
.expect("recorded");
assert_eq!(out.events.len(), 2);
let state = out.events.iter().fold(ModellwechselState::New, apply);
assert!(matches!(state, ModellwechselState::Rejected { .. }));
}
#[test]
fn the_requester_takes_the_answer_back() {
let sent = handle(
&ModellwechselState::New,
ModellwechselCommand::Senden {
data: Box::new(daten(55_238)),
},
ANMELDUNG,
)
.expect("sent");
let state = sent.events.iter().fold(ModellwechselState::New, apply);
let got = handle(
&state,
ModellwechselCommand::ReceiveAntwort {
antwort: Box::new(EmobAntwort::zustimmung("A02", "E_0510")),
},
ANMELDUNG,
)
.expect("received");
let settled = got.events.iter().fold(state, apply);
assert!(matches!(
settled,
ModellwechselState::AntwortErhalten { .. }
));
assert!(settled.ist_terminal());
}
}