use mako_engine::types::Pruefidentifikator;
use mako_engine::{
deadline::Deadline,
error::WorkflowError,
ids::DeadlineId,
outbox::PendingOutbox,
types::{MarktpartnerCode, MessageRef},
workflow::{CommandPayload, EventPayload, Workflow, WorkflowOutput},
};
pub const WORKFLOW_NAME: &str = "wim-rechnungsabwicklung";
pub const RECHNUNGSABWICKLUNG_ORDERS_PIDS: &[u32] = &[17005, 17006];
pub const RECHNUNGSABWICKLUNG_ORDRSP_PIDS: &[u32] = &[19009, 19010];
pub const RECHNUNGSABWICKLUNG_ABLEHNUNG_PID: u32 = 21032;
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum AngebotHerkunft {
AngebotDurchMsb,
AnfrageDurchLf,
}
#[must_use]
pub const fn angebot_ablehnung_ebd(herkunft: AngebotHerkunft) -> &'static str {
match herkunft {
AngebotHerkunft::AngebotDurchMsb => mako_pruefung::codes::EBD_RECHNUNGSABWICKLUNG_ANGEBOT,
AngebotHerkunft::AnfrageDurchLf => {
mako_pruefung::codes::EBD_RECHNUNGSABWICKLUNG_ANFRAGE_ANTWORT
}
}
}
#[must_use]
pub fn antwort_pid(zustimmung: bool) -> u32 {
if zustimmung { 19009 } else { 19010 }
}
pub const RECHNUNGSABWICKLUNG_DEADLINE_LABEL: &str = "wim-rechnungsabwicklung-antwort";
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(tag = "type", content = "data")]
pub enum RechnungsabwicklungEvent {
BestellungErhalten {
sender: MarktpartnerCode,
receiver: MarktpartnerCode,
message_ref: MessageRef,
},
AngebotAbgelehnt {
sender: MarktpartnerCode,
receiver: MarktpartnerCode,
ebd: Option<String>,
antwort_code: Option<String>,
message_ref: MessageRef,
},
BeendigungErhalten {
sender: MarktpartnerCode,
receiver: MarktpartnerCode,
message_ref: MessageRef,
},
BeendigungGesendet {
message_ref: MessageRef,
},
AntwortGesendet {
response_pid: Pruefidentifikator,
message_ref: MessageRef,
},
AntwortErhalten {
response_pid: Pruefidentifikator,
message_ref: MessageRef,
},
AperakFehlerDispatched {
reason: String,
outbound_ref: MessageRef,
},
Rejected {
reason: String,
},
DeadlineExpired {
deadline_id: DeadlineId,
label: Box<str>,
},
}
impl EventPayload for RechnungsabwicklungEvent {
fn event_type(&self) -> &'static str {
match self {
Self::BestellungErhalten { .. } => "RechnungsabwicklungBestellungErhalten",
Self::AngebotAbgelehnt { .. } => "RechnungsabwicklungAngebotAbgelehnt",
Self::BeendigungErhalten { .. } => "RechnungsabwicklungBeendigungErhalten",
Self::BeendigungGesendet { .. } => "RechnungsabwicklungBeendigungGesendet",
Self::AntwortGesendet { .. } => "RechnungsabwicklungAntwortGesendet",
Self::AntwortErhalten { .. } => "RechnungsabwicklungAntwortErhalten",
Self::AperakFehlerDispatched { .. } => "RechnungsabwicklungAperakFehlerDispatched",
Self::Rejected { .. } => "RechnungsabwicklungRejected",
Self::DeadlineExpired { .. } => "RechnungsabwicklungDeadlineExpired",
}
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RechnungsabwicklungData {
pub sender: MarktpartnerCode,
pub receiver: MarktpartnerCode,
pub message_ref: MessageRef,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(tag = "status", content = "data")]
#[derive(Default)]
pub enum RechnungsabwicklungState {
#[default]
New,
Bestellt(RechnungsabwicklungData),
Abgelehnt {
ebd: Option<String>,
antwort_code: Option<String>,
},
BeendigungEingegangen(RechnungsabwicklungData),
BeendigungAngefragt,
Beendet {
zugestimmt: bool,
},
Rejected {
reason: String,
},
}
impl mako_engine::workflow::OccupiesBusinessKey for RechnungsabwicklungState {
fn occupies_business_key(&self) -> bool {
matches!(
self,
Self::BeendigungEingegangen(_) | Self::BeendigungAngefragt
)
}
}
impl RechnungsabwicklungState {
#[must_use]
pub fn label(&self) -> &'static str {
match self {
Self::New => "New",
Self::Bestellt(_) => "Bestellt",
Self::Abgelehnt { .. } => "Abgelehnt",
Self::BeendigungEingegangen(_) => "BeendigungEingegangen",
Self::BeendigungAngefragt => "BeendigungAngefragt",
Self::Beendet { .. } => "Beendet",
Self::Rejected { .. } => "Rejected",
}
}
}
#[derive(Clone)]
pub enum RechnungsabwicklungCommand {
ReceiveOrders {
pid: Pruefidentifikator,
sender: MarktpartnerCode,
receiver: MarktpartnerCode,
message_ref: MessageRef,
validation_passed: bool,
validation_errors: Vec<String>,
},
ReceiveAngebotAblehnung {
sender: MarktpartnerCode,
receiver: MarktpartnerCode,
herkunft: Option<AngebotHerkunft>,
antwort_code: Option<String>,
message_ref: MessageRef,
},
SendBeendigung {
counterparty: MarktpartnerCode,
location_id: String,
message_ref: MessageRef,
},
SendAntwort {
zustimmung: bool,
message_ref: MessageRef,
},
ReceiveAntwort {
pid: Pruefidentifikator,
message_ref: MessageRef,
},
DispatchAperakFehler {
reason: String,
outbound_ref: MessageRef,
},
TimeoutExpired {
deadline_id: DeadlineId,
label: Box<str>,
},
}
impl CommandPayload for RechnungsabwicklungCommand {}
pub struct WimRechnungsabwicklungWorkflow;
impl Workflow for WimRechnungsabwicklungWorkflow {
type State = RechnungsabwicklungState;
type Event = RechnungsabwicklungEvent;
type Command = RechnungsabwicklungCommand;
fn on_deadline(deadline: &Deadline, state: &Self::State) -> Option<Self::Command> {
match (deadline.label(), state) {
(
RECHNUNGSABWICKLUNG_DEADLINE_LABEL,
RechnungsabwicklungState::BeendigungEingegangen(_)
| RechnungsabwicklungState::BeendigungAngefragt,
) => Some(RechnungsabwicklungCommand::TimeoutExpired {
deadline_id: deadline.deadline_id(),
label: deadline.label().into(),
}),
_ => None,
}
}
fn apply(state: Self::State, event: &Self::Event) -> Self::State {
match event {
RechnungsabwicklungEvent::BestellungErhalten {
sender,
receiver,
message_ref,
} => RechnungsabwicklungState::Bestellt(RechnungsabwicklungData {
sender: sender.clone(),
receiver: receiver.clone(),
message_ref: message_ref.clone(),
}),
RechnungsabwicklungEvent::AngebotAbgelehnt {
ebd, antwort_code, ..
} => RechnungsabwicklungState::Abgelehnt {
ebd: ebd.clone(),
antwort_code: antwort_code.clone(),
},
RechnungsabwicklungEvent::BeendigungErhalten {
sender,
receiver,
message_ref,
} => RechnungsabwicklungState::BeendigungEingegangen(RechnungsabwicklungData {
sender: sender.clone(),
receiver: receiver.clone(),
message_ref: message_ref.clone(),
}),
RechnungsabwicklungEvent::BeendigungGesendet { .. } => match state {
RechnungsabwicklungState::New => RechnungsabwicklungState::BeendigungAngefragt,
other => other,
},
RechnungsabwicklungEvent::AntwortGesendet { response_pid, .. } => match state {
RechnungsabwicklungState::BeendigungEingegangen(_) => {
RechnungsabwicklungState::Beendet {
zugestimmt: response_pid.as_u32() == 19009,
}
}
other => other,
},
RechnungsabwicklungEvent::AntwortErhalten { response_pid, .. } => match state {
RechnungsabwicklungState::BeendigungAngefragt => {
RechnungsabwicklungState::Beendet {
zugestimmt: response_pid.as_u32() == 19009,
}
}
other => other,
},
RechnungsabwicklungEvent::AperakFehlerDispatched { reason, .. } => {
RechnungsabwicklungState::Rejected {
reason: format!("APERAK 29001: {reason}"),
}
}
RechnungsabwicklungEvent::Rejected { reason } => RechnungsabwicklungState::Rejected {
reason: reason.clone(),
},
RechnungsabwicklungEvent::DeadlineExpired { label, .. } => match state {
RechnungsabwicklungState::Bestellt(_)
| RechnungsabwicklungState::Beendet { .. }
| RechnungsabwicklungState::Rejected { .. } => state,
_ => RechnungsabwicklungState::Rejected {
reason: format!("deadline expired: {label}"),
},
},
}
}
fn handle(
state: &Self::State,
command: Self::Command,
) -> Result<WorkflowOutput<Self::Event>, WorkflowError> {
match command {
RechnungsabwicklungCommand::ReceiveOrders {
pid,
sender,
receiver,
message_ref,
validation_passed,
validation_errors,
} => {
if !matches!(state, RechnungsabwicklungState::New) {
return Err(WorkflowError::invalid_state("New", state.label()));
}
if !RECHNUNGSABWICKLUNG_ORDERS_PIDS.contains(&pid.as_u32()) {
return Err(WorkflowError::rejected(format!(
"expected ORDERS 17005/17006, got {pid}",
)));
}
if !validation_passed {
return Ok(vec![RechnungsabwicklungEvent::Rejected {
reason: validation_errors.join("; "),
}]
.into());
}
let event = if pid.as_u32() == 17005 {
RechnungsabwicklungEvent::BestellungErhalten {
sender,
receiver,
message_ref,
}
} else {
RechnungsabwicklungEvent::BeendigungErhalten {
sender,
receiver,
message_ref,
}
};
Ok(vec![event].into())
}
RechnungsabwicklungCommand::ReceiveAngebotAblehnung {
sender,
receiver,
herkunft,
antwort_code,
message_ref,
} => {
if !matches!(state, RechnungsabwicklungState::New) {
return Err(WorkflowError::invalid_state("New", state.label()));
}
let ebd = herkunft.map(angebot_ablehnung_ebd);
if let (Some(t), Some(c)) = (ebd, antwort_code.as_ref())
&& mako_pruefung::codes::lookup(t, c).is_none()
{
return Err(WorkflowError::rejected(format!(
"Antwortcode {c:?} is not published in {t}"
)));
}
Ok(vec![RechnungsabwicklungEvent::AngebotAbgelehnt {
sender,
receiver,
ebd: ebd.map(ToOwned::to_owned),
antwort_code,
message_ref,
}]
.into())
}
RechnungsabwicklungCommand::SendBeendigung {
counterparty,
location_id,
message_ref,
} => {
if !matches!(state, RechnungsabwicklungState::New) {
return Err(WorkflowError::invalid_state("New", state.label()));
}
let outbox = PendingOutbox::new(
"ORDERS",
counterparty.as_str(),
serde_json::json!({
"pid": 17006,
"receiver": counterparty.as_str(),
"location": location_id,
"message_ref": message_ref.as_str(),
}),
);
Ok(WorkflowOutput {
events: vec![RechnungsabwicklungEvent::BeendigungGesendet { message_ref }],
outbox: vec![outbox],
deadlines: vec![],
})
}
RechnungsabwicklungCommand::SendAntwort {
zustimmung,
message_ref,
} => {
let RechnungsabwicklungState::BeendigungEingegangen(data) = state else {
return Err(WorkflowError::invalid_state(
"BeendigungEingegangen",
state.label(),
));
};
let response_pid = Pruefidentifikator::new(antwort_pid(zustimmung))
.map_err(|e| WorkflowError::rejected(e.clone()))?;
let outbox = PendingOutbox::new(
"ORDRSP",
data.sender.as_str(),
serde_json::json!({
"pid": response_pid.as_u32(),
"receiver": data.sender.as_str(),
"order_reference": data.message_ref.as_str(),
"message_ref": message_ref.as_str(),
}),
);
Ok(WorkflowOutput {
events: vec![RechnungsabwicklungEvent::AntwortGesendet {
response_pid,
message_ref,
}],
outbox: vec![outbox],
deadlines: vec![],
})
}
RechnungsabwicklungCommand::ReceiveAntwort { pid, message_ref } => {
if !RECHNUNGSABWICKLUNG_ORDRSP_PIDS.contains(&pid.as_u32()) {
return Err(WorkflowError::rejected(format!(
"expected ORDRSP 19009/19010, got {pid}",
)));
}
if !matches!(state, RechnungsabwicklungState::BeendigungAngefragt) {
return Err(WorkflowError::invalid_state(
"BeendigungAngefragt",
state.label(),
));
}
Ok(vec![RechnungsabwicklungEvent::AntwortErhalten {
response_pid: pid,
message_ref,
}]
.into())
}
RechnungsabwicklungCommand::DispatchAperakFehler {
reason,
outbound_ref,
} => {
if !matches!(
state,
RechnungsabwicklungState::New
| RechnungsabwicklungState::BeendigungEingegangen(_)
) {
return Err(WorkflowError::invalid_state(
"New or BeendigungEingegangen",
state.label(),
));
}
Ok(vec![RechnungsabwicklungEvent::AperakFehlerDispatched {
reason,
outbound_ref,
}]
.into())
}
RechnungsabwicklungCommand::TimeoutExpired { deadline_id, label } => match state {
RechnungsabwicklungState::Bestellt(_)
| RechnungsabwicklungState::Beendet { .. }
| RechnungsabwicklungState::Rejected { .. } => Ok(vec![].into()),
_ => Ok(
vec![RechnungsabwicklungEvent::DeadlineExpired { deadline_id, label }].into(),
),
},
}
}
}
#[cfg(test)]
mod tests {
use mako_engine::workflow::Workflow;
use super::*;
fn pid(code: u32) -> Pruefidentifikator {
Pruefidentifikator::new(code).unwrap()
}
fn mcod(s: &str) -> MarktpartnerCode {
MarktpartnerCode::new(s)
}
fn mref(s: &str) -> MessageRef {
MessageRef::new(s)
}
fn receive(pid_code: u32, ok: bool) -> RechnungsabwicklungCommand {
RechnungsabwicklungCommand::ReceiveOrders {
pid: pid(pid_code),
sender: mcod("9900000000004"),
receiver: mcod("9900357000004"),
message_ref: mref("ORDERS-1"),
validation_passed: ok,
validation_errors: if ok { vec![] } else { vec!["AHB".into()] },
}
}
fn apply_all(
init: RechnungsabwicklungState,
events: &[RechnungsabwicklungEvent],
) -> RechnungsabwicklungState {
events
.iter()
.fold(init, WimRechnungsabwicklungWorkflow::apply)
}
#[test]
fn a_bestellung_records_and_completes() {
let out = WimRechnungsabwicklungWorkflow::handle(
&RechnungsabwicklungState::New,
receive(17005, true),
)
.unwrap();
let state = apply_all(RechnungsabwicklungState::New, &out.events);
assert!(matches!(state, RechnungsabwicklungState::Bestellt(_)));
assert!(
WimRechnungsabwicklungWorkflow::handle(
&state,
RechnungsabwicklungCommand::SendAntwort {
zustimmung: true,
message_ref: mref("X"),
},
)
.is_err(),
"a Bestellung has no answer to send",
);
}
#[test]
fn a_received_beendigung_is_answered_by_decision() {
for (zustimmung, want_pid, want_ended) in [(true, 19009, true), (false, 19010, false)] {
let out = WimRechnungsabwicklungWorkflow::handle(
&RechnungsabwicklungState::New,
receive(17006, true),
)
.unwrap();
let state = apply_all(RechnungsabwicklungState::New, &out.events);
assert!(matches!(
state,
RechnungsabwicklungState::BeendigungEingegangen(_)
));
let out = WimRechnungsabwicklungWorkflow::handle(
&state,
RechnungsabwicklungCommand::SendAntwort {
zustimmung,
message_ref: mref("ORDRSP-1"),
},
)
.unwrap();
let RechnungsabwicklungEvent::AntwortGesendet { response_pid, .. } = &out.events[0]
else {
panic!("expected AntwortGesendet");
};
assert_eq!(response_pid.as_u32(), want_pid);
let state = apply_all(state, &out.events);
assert!(
matches!(state, RechnungsabwicklungState::Beendet { zugestimmt } if zugestimmt == want_ended),
);
}
}
#[test]
fn an_initiated_beendigung_completes_on_the_counterparty_answer() {
let out = WimRechnungsabwicklungWorkflow::handle(
&RechnungsabwicklungState::New,
RechnungsabwicklungCommand::SendBeendigung {
counterparty: mcod("9900000000004"),
location_id: "51238696012".to_owned(),
message_ref: mref("ORDERS-OUT-1"),
},
)
.unwrap();
let state = apply_all(RechnungsabwicklungState::New, &out.events);
assert!(matches!(
state,
RechnungsabwicklungState::BeendigungAngefragt
));
let out = WimRechnungsabwicklungWorkflow::handle(
&state,
RechnungsabwicklungCommand::ReceiveAntwort {
pid: pid(19010),
message_ref: mref("ORDRSP-IN-1"),
},
)
.unwrap();
let state = apply_all(state, &out.events);
assert!(
matches!(
state,
RechnungsabwicklungState::Beendet { zugestimmt: false }
),
"an Ablehnung ends the process with the arrangement still running",
);
}
#[test]
fn bad_input_is_refused() {
let out = WimRechnungsabwicklungWorkflow::handle(
&RechnungsabwicklungState::New,
receive(17006, false),
)
.unwrap();
let state = apply_all(RechnungsabwicklungState::New, &out.events);
assert!(matches!(state, RechnungsabwicklungState::Rejected { .. }));
assert!(
WimRechnungsabwicklungWorkflow::handle(
&RechnungsabwicklungState::New,
receive(17001, true)
)
.is_err(),
"a Geräteübernahme ORDERS does not belong here",
);
}
#[test]
fn timeout_on_settled_states_is_noop() {
for state in [
RechnungsabwicklungState::Bestellt(RechnungsabwicklungData {
sender: mcod("9900000000004"),
receiver: mcod("9900357000004"),
message_ref: mref("ORDERS-1"),
}),
RechnungsabwicklungState::Beendet { zugestimmt: true },
] {
let out = WimRechnungsabwicklungWorkflow::handle(
&state,
RechnungsabwicklungCommand::TimeoutExpired {
deadline_id: DeadlineId::new(),
label: RECHNUNGSABWICKLUNG_DEADLINE_LABEL.into(),
},
)
.unwrap();
assert!(out.events.is_empty());
}
}
use mako_engine::ids::DeadlineId;
#[test]
fn the_two_angebot_trees_overlap_and_disagree() {
use mako_pruefung::codes::lookup;
for c in ["A01", "A02", "A03"] {
let a = lookup("E_0205", c).unwrap_or_else(|| panic!("E_0205 publishes {c}"));
let b = lookup("E_0208", c).unwrap_or_else(|| panic!("E_0208 publishes {c}"));
assert_ne!(
a.bedeutung, b.bedeutung,
"{c} must mean different things in the two trees — that is the trap"
);
}
for c in ["A04", "A05", "A06"] {
assert!(lookup("E_0205", c).is_some());
assert!(lookup("E_0208", c).is_none());
}
}
#[test]
fn an_unknown_herkunft_records_the_code_without_a_tree() {
let out = WimRechnungsabwicklungWorkflow::handle(
&RechnungsabwicklungState::New,
RechnungsabwicklungCommand::ReceiveAngebotAblehnung {
sender: MarktpartnerCode::new("9900000000002"),
receiver: MarktpartnerCode::new("9900000000003"),
herkunft: None,
antwort_code: Some("A01".to_owned()),
message_ref: MessageRef::new("MSG-1"),
},
)
.expect("an unknown Herkunft is recorded, not refused");
let state = out
.events
.iter()
.fold(RechnungsabwicklungState::New, |s, e| {
WimRechnungsabwicklungWorkflow::apply(s, e)
});
let RechnungsabwicklungState::Abgelehnt { ebd, antwort_code } = &state else {
panic!("expected Abgelehnt, got {}", state.label());
};
assert_eq!(*ebd, None, "no tree may be claimed without the Herkunft");
assert_eq!(antwort_code.as_deref(), Some("A01"));
}
#[test]
fn a_stated_herkunft_resolves_the_tree_and_checks_the_code() {
let cmd = |herkunft, code: &str| RechnungsabwicklungCommand::ReceiveAngebotAblehnung {
sender: MarktpartnerCode::new("9900000000002"),
receiver: MarktpartnerCode::new("9900000000003"),
herkunft: Some(herkunft),
antwort_code: Some(code.to_owned()),
message_ref: MessageRef::new("MSG-1"),
};
let out = WimRechnungsabwicklungWorkflow::handle(
&RechnungsabwicklungState::New,
cmd(AngebotHerkunft::AngebotDurchMsb, "A05"),
)
.expect("A05 is published in E_0205");
let RechnungsabwicklungEvent::AngebotAbgelehnt { ebd, .. } = &out.events[0] else {
panic!("expected an AngebotAbgelehnt");
};
assert_eq!(ebd.as_deref(), Some("E_0205"));
assert!(
WimRechnungsabwicklungWorkflow::handle(
&RechnungsabwicklungState::New,
cmd(AngebotHerkunft::AnfrageDurchLf, "A05"),
)
.is_err()
);
}
}