use mako_engine::types::Pruefidentifikator;
use mako_engine::{
deadline::Deadline,
error::WorkflowError,
fristen::{APERAK_STROM_WINDOW_LABEL, aperak_strom_due_at},
ids::DeadlineId,
outbox::PendingOutbox,
types::{MaLo, MarktpartnerCode, MessageRef},
workflow::{CommandPayload, EventPayload, PendingDeadline, Workflow, WorkflowOutput},
};
pub const WORKFLOW_NAME: &str = "gpke-neuanlage";
pub const NEUANLAGE_PIDS: &[u32] = &[55600, 55601];
pub const NEUANLAGE_APERAK_WINDOW_LABEL: &str = "gpke-neuanlage-aperak-window";
fn neuanlage_response_pid(anfrage_pid: u32, accepted: bool) -> Option<Pruefidentifikator> {
let code: u32 = match anfrage_pid {
55600 => {
if accepted {
55602
} else {
55604
}
}
55601 => {
if accepted {
55603
} else {
55605
}
}
_ => return None,
};
Pruefidentifikator::new(code).ok()
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(tag = "type", content = "data")]
pub enum NeuanlageEvent {
AnmeldungErhalten {
location_id: MaLo,
sender: MarktpartnerCode,
receiver: MarktpartnerCode,
document_date: String,
process_date: String,
message_ref: MessageRef,
pruefidentifikator: Pruefidentifikator,
},
ValidationPassed {
message_ref: MessageRef,
},
AntwortGesendet {
response_pid: Option<Pruefidentifikator>,
accepted: bool,
reason: Option<String>,
},
Aktiviert,
AperakFehlerDispatched {
aperak_pid: Pruefidentifikator,
reason: String,
outbound_ref: MessageRef,
},
Rejected {
reason: String,
},
DeadlineExpired {
deadline_id: DeadlineId,
label: Box<str>,
},
}
impl EventPayload for NeuanlageEvent {
fn event_type(&self) -> &'static str {
match self {
Self::AnmeldungErhalten { .. } => "NeuanlageAnmeldungErhalten",
Self::ValidationPassed { .. } => "NeuanlageValidationPassed",
Self::AntwortGesendet { .. } => "NeuanlageAntwortGesendet",
Self::Aktiviert => "NeuanlageAktiviert",
Self::AperakFehlerDispatched { .. } => "NeuanlageAperakFehlerDispatched",
Self::Rejected { .. } => "NeuanlageRejected",
Self::DeadlineExpired { .. } => "NeuanlageDeadlineExpired",
}
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct NeuanlageData {
pub location_id: MaLo,
pub sender: MarktpartnerCode,
pub receiver: MarktpartnerCode,
pub document_date: String,
pub process_date: String,
pub pruefidentifikator: Pruefidentifikator,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(tag = "status", content = "data")]
#[derive(Default)]
pub enum NeuanlageState {
#[default]
New,
Eingegangen(NeuanlageData),
ValidationPassed(NeuanlageData),
AntwortGesendet {
data: NeuanlageData,
response_pid: Option<Pruefidentifikator>,
},
Aktiviert(NeuanlageData),
Rejected {
reason: String,
},
}
impl NeuanlageState {
#[must_use]
pub fn label(&self) -> &'static str {
match self {
Self::New => "New",
Self::Eingegangen(_) => "Eingegangen",
Self::ValidationPassed(_) => "ValidationPassed",
Self::AntwortGesendet { .. } => "AntwortGesendet",
Self::Aktiviert(_) => "Aktiviert",
Self::Rejected { .. } => "Rejected",
}
}
#[must_use]
pub fn data(&self) -> Option<&NeuanlageData> {
match self {
Self::Eingegangen(d) | Self::ValidationPassed(d) | Self::Aktiviert(d) => Some(d),
Self::AntwortGesendet { data, .. } => Some(data),
Self::New | Self::Rejected { .. } => None,
}
}
}
#[derive(Clone)]
pub enum NeuanlageCommand {
ReceiveAnmeldung {
pid: Pruefidentifikator,
sender: MarktpartnerCode,
receiver: MarktpartnerCode,
location_id: MaLo,
document_date: String,
process_date: String,
message_ref: MessageRef,
received_at: time::OffsetDateTime,
validation_passed: bool,
validation_errors: Vec<String>,
},
SendAntwort {
accepted: bool,
reason: Option<String>,
},
Aktivieren,
DispatchAperakFehler {
reason: String,
outbound_ref: MessageRef,
},
TimeoutExpired {
deadline_id: DeadlineId,
label: Box<str>,
},
}
impl CommandPayload for NeuanlageCommand {}
pub struct GpkeNeuanlageWorkflow;
impl Workflow for GpkeNeuanlageWorkflow {
type State = NeuanlageState;
type Event = NeuanlageEvent;
type Command = NeuanlageCommand;
fn on_deadline(deadline: &Deadline, state: &Self::State) -> Option<Self::Command> {
match (deadline.label(), state) {
(NEUANLAGE_APERAK_WINDOW_LABEL, NeuanlageState::Eingegangen(_))
| (NEUANLAGE_APERAK_WINDOW_LABEL, NeuanlageState::ValidationPassed(_)) => {
Some(NeuanlageCommand::TimeoutExpired {
deadline_id: deadline.deadline_id(),
label: deadline.label().into(),
})
}
_ => None,
}
}
fn apply(state: Self::State, event: &Self::Event) -> Self::State {
match event {
NeuanlageEvent::AnmeldungErhalten {
location_id,
sender,
receiver,
document_date,
process_date,
pruefidentifikator,
..
} => NeuanlageState::Eingegangen(NeuanlageData {
location_id: location_id.clone(),
sender: sender.clone(),
receiver: receiver.clone(),
document_date: document_date.clone(),
process_date: process_date.clone(),
pruefidentifikator: *pruefidentifikator,
}),
NeuanlageEvent::ValidationPassed { .. } => match state {
NeuanlageState::Eingegangen(data) => NeuanlageState::ValidationPassed(data),
other => other,
},
NeuanlageEvent::AntwortGesendet {
accepted,
response_pid,
..
} => {
if *accepted {
match state {
NeuanlageState::ValidationPassed(data) => NeuanlageState::AntwortGesendet {
response_pid: *response_pid,
data,
},
other => other,
}
} else {
NeuanlageState::Rejected {
reason: "Neuanlage abgelehnt".to_owned(),
}
}
}
NeuanlageEvent::Aktiviert => match state {
NeuanlageState::AntwortGesendet { data, .. } => NeuanlageState::Aktiviert(data),
other => other,
},
NeuanlageEvent::AperakFehlerDispatched { reason, .. } => NeuanlageState::Rejected {
reason: format!("APERAK 29001: {reason}"),
},
NeuanlageEvent::Rejected { reason } => NeuanlageState::Rejected {
reason: reason.clone(),
},
NeuanlageEvent::DeadlineExpired { label, .. } => match state {
NeuanlageState::Aktiviert(_) | NeuanlageState::Rejected { .. } => state,
_ => NeuanlageState::Rejected {
reason: format!("deadline expired: {label}"),
},
},
}
}
fn handle(
state: &Self::State,
command: Self::Command,
) -> Result<WorkflowOutput<Self::Event>, WorkflowError> {
match command {
NeuanlageCommand::ReceiveAnmeldung {
pid,
sender,
receiver,
location_id,
document_date,
process_date,
message_ref,
received_at,
validation_passed,
validation_errors,
} => {
if !matches!(state, NeuanlageState::New) {
return Err(WorkflowError::invalid_state("New", state.label()));
}
if !NEUANLAGE_PIDS.contains(&pid.as_u32()) {
return Err(WorkflowError::rejected(format!(
"expected Neuanlage PID (55600 or 55601), got {pid}",
)));
}
let sender_mp_id = sender.clone();
let receiver_gln = receiver.clone();
let mut events = vec![NeuanlageEvent::AnmeldungErhalten {
location_id,
sender,
receiver,
document_date,
process_date,
message_ref: message_ref.clone(),
pruefidentifikator: pid,
}];
if validation_passed {
events.push(NeuanlageEvent::ValidationPassed { message_ref });
let outbox = vec![
PendingOutbox::new(
"APERAK",
sender_mp_id.as_str(),
serde_json::json!({
"sender": receiver_gln.as_str(),
"receiver": sender_mp_id.as_str(),
"pid": 29001_u32,
"document_code": "312",
}),
)
.caused_by(1),
];
let aperak_dl = PendingDeadline::new(
APERAK_STROM_WINDOW_LABEL,
aperak_strom_due_at(received_at),
);
Ok(WorkflowOutput::with_outbox_and_deadline(
events, outbox, aperak_dl,
))
} else {
let reason = validation_errors.join("; ");
events.push(NeuanlageEvent::Rejected {
reason: reason.clone(),
});
let outbox = vec![
PendingOutbox::new(
"APERAK",
sender_mp_id.as_str(),
serde_json::json!({
"sender": receiver_gln.as_str(),
"receiver": sender_mp_id.as_str(),
"pid": 29001_u32,
"error_code": mako_engine::erc::codes::Z29,
"reason": reason,
}),
)
.caused_by(0),
];
let aperak_dl = PendingDeadline::new(
APERAK_STROM_WINDOW_LABEL,
aperak_strom_due_at(received_at),
);
Ok(WorkflowOutput::with_outbox_and_deadline(
events, outbox, aperak_dl,
))
}
}
NeuanlageCommand::SendAntwort { accepted, reason } => {
let data = match state {
NeuanlageState::ValidationPassed(d) => d,
_ => {
return Err(WorkflowError::invalid_state(
"ValidationPassed",
state.label(),
));
}
};
let response_pid =
neuanlage_response_pid(data.pruefidentifikator.as_u32(), accepted);
Ok(vec![NeuanlageEvent::AntwortGesendet {
response_pid,
accepted,
reason,
}]
.into())
}
NeuanlageCommand::Aktivieren => {
if !matches!(state, NeuanlageState::AntwortGesendet { .. }) {
return Err(WorkflowError::invalid_state(
"AntwortGesendet",
state.label(),
));
}
Ok(vec![NeuanlageEvent::Aktiviert].into())
}
NeuanlageCommand::DispatchAperakFehler {
reason,
outbound_ref,
} => {
match state {
NeuanlageState::Eingegangen(_) | NeuanlageState::ValidationPassed(_) => {}
_ => {
return Err(WorkflowError::invalid_state(
"Eingegangen or ValidationPassed",
state.label(),
));
}
}
let aperak_pid = Pruefidentifikator::new(29_001)
.map_err(|e| WorkflowError::rejected(e.clone()))?;
Ok(vec![NeuanlageEvent::AperakFehlerDispatched {
aperak_pid,
reason,
outbound_ref,
}]
.into())
}
NeuanlageCommand::TimeoutExpired { deadline_id, label } => {
match state {
NeuanlageState::Aktiviert(_) | NeuanlageState::Rejected { .. } => {
Ok(vec![].into())
}
_ => Ok(vec![NeuanlageEvent::DeadlineExpired { deadline_id, label }].into()),
}
}
}
}
}
#[cfg(test)]
mod tests {
use mako_engine::{ids::DeadlineId, workflow::Workflow};
use super::*;
fn pid(code: u32) -> Pruefidentifikator {
Pruefidentifikator::new(code).unwrap()
}
fn mcod(s: &str) -> MarktpartnerCode {
MarktpartnerCode::new(s)
}
fn malo(s: &str) -> MaLo {
MaLo::new(s)
}
fn mref(s: &str) -> MessageRef {
MessageRef::new(s)
}
fn anmeldung_cmd(pid_code: u32, ok: bool) -> NeuanlageCommand {
NeuanlageCommand::ReceiveAnmeldung {
pid: pid(pid_code),
sender: mcod("4012345000023"),
receiver: mcod("9900357000004"),
location_id: malo("51238696781"),
document_date: "20251001".to_owned(),
process_date: "20260101".to_owned(),
message_ref: mref("NEUA-001"),
received_at: time::OffsetDateTime::now_utc(),
validation_passed: ok,
validation_errors: if ok {
vec![]
} else {
vec!["missing mandatory segment".to_owned()]
},
}
}
#[test]
fn neuanlage_55600_happy_path() {
let out = GpkeNeuanlageWorkflow::handle(&NeuanlageState::New, anmeldung_cmd(55600, true))
.unwrap();
assert_eq!(out.events.len(), 2); let state = out.events.iter().fold(NeuanlageState::New, |s, e| {
GpkeNeuanlageWorkflow::apply(s, e)
});
assert!(matches!(state, NeuanlageState::ValidationPassed(_)));
let out = GpkeNeuanlageWorkflow::handle(
&state,
NeuanlageCommand::SendAntwort {
accepted: true,
reason: None,
},
)
.unwrap();
assert_eq!(out.events.len(), 1);
if let NeuanlageEvent::AntwortGesendet {
response_pid,
accepted,
..
} = &out.events[0]
{
assert!(accepted);
assert_eq!(response_pid.map(|p| p.as_u32()), Some(55602));
} else {
panic!("expected AntwortGesendet");
}
let state = out.events.iter().fold(state, GpkeNeuanlageWorkflow::apply);
assert!(matches!(state, NeuanlageState::AntwortGesendet { .. }));
let out = GpkeNeuanlageWorkflow::handle(&state, NeuanlageCommand::Aktivieren).unwrap();
let state = out.events.iter().fold(state, GpkeNeuanlageWorkflow::apply);
assert!(matches!(state, NeuanlageState::Aktiviert(_)));
}
#[test]
fn neuanlage_55601_rejected() {
let out = GpkeNeuanlageWorkflow::handle(&NeuanlageState::New, anmeldung_cmd(55601, true))
.unwrap();
let state = out.events.iter().fold(NeuanlageState::New, |s, e| {
GpkeNeuanlageWorkflow::apply(s, e)
});
let out = GpkeNeuanlageWorkflow::handle(
&state,
NeuanlageCommand::SendAntwort {
accepted: false,
reason: Some("Kapazitätsmangel".to_owned()),
},
)
.unwrap();
if let NeuanlageEvent::AntwortGesendet {
response_pid,
accepted,
..
} = &out.events[0]
{
assert!(!accepted);
assert_eq!(response_pid.map(|p| p.as_u32()), Some(55605));
} else {
panic!("expected AntwortGesendet");
}
let state = out.events.iter().fold(state, GpkeNeuanlageWorkflow::apply);
assert!(matches!(state, NeuanlageState::Rejected { .. }));
}
#[test]
fn neuanlage_validation_failure_rejects() {
let out = GpkeNeuanlageWorkflow::handle(&NeuanlageState::New, anmeldung_cmd(55600, false))
.unwrap();
let state = out.events.iter().fold(NeuanlageState::New, |s, e| {
GpkeNeuanlageWorkflow::apply(s, e)
});
assert!(matches!(state, NeuanlageState::Rejected { .. }));
}
#[test]
fn neuanlage_wrong_pid_rejected() {
let result = GpkeNeuanlageWorkflow::handle(
&NeuanlageState::New,
NeuanlageCommand::ReceiveAnmeldung {
pid: pid(55001), sender: mcod("4012345000023"),
receiver: mcod("9900357000004"),
location_id: malo("51238696781"),
document_date: "20251001".to_owned(),
process_date: "20260101".to_owned(),
message_ref: mref("NEUA-X"),
received_at: time::OffsetDateTime::now_utc(),
validation_passed: true,
validation_errors: vec![],
},
);
assert!(result.is_err());
}
#[test]
fn timeout_in_terminal_state_is_noop() {
let state = NeuanlageState::Aktiviert(NeuanlageData {
location_id: malo("51238696781"),
sender: mcod("4012345000023"),
receiver: mcod("9900357000004"),
document_date: "20251001".to_owned(),
process_date: "20260101".to_owned(),
pruefidentifikator: pid(55600),
});
let dl_id = DeadlineId::new();
let out = GpkeNeuanlageWorkflow::handle(
&state,
NeuanlageCommand::TimeoutExpired {
deadline_id: dl_id,
label: NEUANLAGE_APERAK_WINDOW_LABEL.into(),
},
)
.unwrap();
assert!(out.events.is_empty());
}
}