use mako_engine::types::Pruefidentifikator;
use mako_engine::{
deadline::Deadline,
error::WorkflowError,
ids::DeadlineId,
types::{MarktpartnerCode, MessageRef},
workflow::{CommandPayload, EventPayload, Workflow, WorkflowOutput},
};
pub const WORKFLOW_NAME: &str = "wim-preisanfrage";
pub const REQOTE_PIDS: &[u32] = &[35001, 35002, 35003, 35004, 35005];
pub const QUOTES_PIDS: &[u32] = &[15001, 15002, 15003, 15004, 15005];
pub const PREISANFRAGE_DEADLINE_LABEL: &str = "wim-preisanfrage-antwort";
fn quotes_response_pid(reqote_pid: u32) -> Option<Pruefidentifikator> {
let code: u32 = match reqote_pid {
35001 => 15001,
35002 => 15002,
35003 => 15003,
35004 => 15004,
35005 => 15005,
_ => return None,
};
Pruefidentifikator::new(code).ok()
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(tag = "type", content = "data")]
pub enum PreisanfrageEvent {
AnfrageErhalten {
sender: MarktpartnerCode,
receiver: MarktpartnerCode,
message_ref: MessageRef,
pruefidentifikator: Pruefidentifikator,
},
ValidationPassed {
message_ref: MessageRef,
},
AngebotGesendet {
response_pid: Option<Pruefidentifikator>,
message_ref: MessageRef,
},
AngebotErhalten {
response_pid: Pruefidentifikator,
message_ref: MessageRef,
},
Abgeschlossen,
AperakFehlerDispatched {
aperak_pid: Pruefidentifikator,
reason: String,
outbound_ref: MessageRef,
},
Rejected {
reason: String,
},
DeadlineExpired {
deadline_id: DeadlineId,
label: Box<str>,
},
}
impl EventPayload for PreisanfrageEvent {
fn event_type(&self) -> &'static str {
match self {
Self::AnfrageErhalten { .. } => "PreisanfrageAnfrageErhalten",
Self::ValidationPassed { .. } => "PreisanfrageValidationPassed",
Self::AngebotGesendet { .. } => "PreisanfrageAngebotGesendet",
Self::AngebotErhalten { .. } => "PreisanfrageAngebotErhalten",
Self::Abgeschlossen => "PreisanfrageAbgeschlossen",
Self::AperakFehlerDispatched { .. } => "PreisanfrageAperakFehlerDispatched",
Self::Rejected { .. } => "PreisanfrageRejected",
Self::DeadlineExpired { .. } => "PreisanfrageDeadlineExpired",
}
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PreisanfrageData {
pub sender: MarktpartnerCode,
pub receiver: MarktpartnerCode,
pub pruefidentifikator: Pruefidentifikator,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(tag = "status", content = "data")]
#[derive(Default)]
pub enum PreisanfrageState {
#[default]
New,
Eingegangen(PreisanfrageData),
ValidationPassed(PreisanfrageData),
AngebotAusgetauscht {
data: PreisanfrageData,
response_pid: Option<Pruefidentifikator>,
},
Abgeschlossen(PreisanfrageData),
Rejected {
reason: String,
},
}
impl PreisanfrageState {
#[must_use]
pub fn label(&self) -> &'static str {
match self {
Self::New => "New",
Self::Eingegangen(_) => "Eingegangen",
Self::ValidationPassed(_) => "ValidationPassed",
Self::AngebotAusgetauscht { .. } => "AngebotAusgetauscht",
Self::Abgeschlossen(_) => "Abgeschlossen",
Self::Rejected { .. } => "Rejected",
}
}
}
#[derive(Clone)]
pub enum PreisanfrageCommand {
ReceiveReqote {
pid: Pruefidentifikator,
sender: MarktpartnerCode,
receiver: MarktpartnerCode,
message_ref: MessageRef,
validation_passed: bool,
validation_errors: Vec<String>,
},
SendAngebot {
message_ref: MessageRef,
},
ReceiveAngebot {
pid: Pruefidentifikator,
message_ref: MessageRef,
},
Abschliessen,
DispatchAperakFehler {
reason: String,
outbound_ref: MessageRef,
},
TimeoutExpired {
deadline_id: DeadlineId,
label: Box<str>,
},
}
impl CommandPayload for PreisanfrageCommand {}
pub struct WimPreisanfrageWorkflow;
impl Workflow for WimPreisanfrageWorkflow {
type State = PreisanfrageState;
type Event = PreisanfrageEvent;
type Command = PreisanfrageCommand;
fn on_deadline(deadline: &Deadline, state: &Self::State) -> Option<Self::Command> {
match (deadline.label(), state) {
(PREISANFRAGE_DEADLINE_LABEL, PreisanfrageState::Eingegangen(_))
| (PREISANFRAGE_DEADLINE_LABEL, PreisanfrageState::ValidationPassed(_)) => {
Some(PreisanfrageCommand::TimeoutExpired {
deadline_id: deadline.deadline_id(),
label: deadline.label().into(),
})
}
_ => None,
}
}
fn apply(state: Self::State, event: &Self::Event) -> Self::State {
match event {
PreisanfrageEvent::AnfrageErhalten {
sender,
receiver,
pruefidentifikator,
..
} => PreisanfrageState::Eingegangen(PreisanfrageData {
sender: sender.clone(),
receiver: receiver.clone(),
pruefidentifikator: *pruefidentifikator,
}),
PreisanfrageEvent::ValidationPassed { .. } => match state {
PreisanfrageState::Eingegangen(data) => PreisanfrageState::ValidationPassed(data),
other => other,
},
PreisanfrageEvent::AngebotGesendet { response_pid, .. } => match state {
PreisanfrageState::ValidationPassed(data) => {
PreisanfrageState::AngebotAusgetauscht {
response_pid: *response_pid,
data,
}
}
other => other,
},
PreisanfrageEvent::AngebotErhalten { response_pid, .. } => match state {
PreisanfrageState::ValidationPassed(data) => {
PreisanfrageState::AngebotAusgetauscht {
response_pid: Some(*response_pid),
data,
}
}
other => other,
},
PreisanfrageEvent::Abgeschlossen => match state {
PreisanfrageState::AngebotAusgetauscht { data, .. } => {
PreisanfrageState::Abgeschlossen(data)
}
other => other,
},
PreisanfrageEvent::AperakFehlerDispatched { reason, .. } => {
PreisanfrageState::Rejected {
reason: format!("APERAK 29001: {reason}"),
}
}
PreisanfrageEvent::Rejected { reason } => PreisanfrageState::Rejected {
reason: reason.clone(),
},
PreisanfrageEvent::DeadlineExpired { label, .. } => match state {
PreisanfrageState::Abgeschlossen(_) | PreisanfrageState::Rejected { .. } => state,
_ => PreisanfrageState::Rejected {
reason: format!("deadline expired: {label}"),
},
},
}
}
fn handle(
state: &Self::State,
command: Self::Command,
) -> Result<WorkflowOutput<Self::Event>, WorkflowError> {
match command {
PreisanfrageCommand::ReceiveReqote {
pid,
sender,
receiver,
message_ref,
validation_passed,
validation_errors,
} => {
if !matches!(state, PreisanfrageState::New) {
return Err(WorkflowError::invalid_state("New", state.label()));
}
if !REQOTE_PIDS.contains(&pid.as_u32()) {
return Err(WorkflowError::rejected(format!(
"expected REQOTE PID (35001–35005), got {pid}",
)));
}
let mut events = vec![PreisanfrageEvent::AnfrageErhalten {
sender,
receiver,
message_ref: message_ref.clone(),
pruefidentifikator: pid,
}];
if validation_passed {
events.push(PreisanfrageEvent::ValidationPassed { message_ref });
} else {
events.push(PreisanfrageEvent::Rejected {
reason: validation_errors.join("; "),
});
}
Ok(events.into())
}
PreisanfrageCommand::SendAngebot { message_ref } => {
let data = match state {
PreisanfrageState::ValidationPassed(d) => d,
_ => {
return Err(WorkflowError::invalid_state(
"ValidationPassed",
state.label(),
));
}
};
let response_pid = quotes_response_pid(data.pruefidentifikator.as_u32());
Ok(vec![PreisanfrageEvent::AngebotGesendet {
response_pid,
message_ref,
}]
.into())
}
PreisanfrageCommand::ReceiveAngebot { pid, message_ref } => {
if !QUOTES_PIDS.contains(&pid.as_u32()) {
return Err(WorkflowError::rejected(format!(
"expected QUOTES PID (15001–15005), got {pid}",
)));
}
match state {
PreisanfrageState::ValidationPassed(_) => {}
_ => {
return Err(WorkflowError::invalid_state(
"ValidationPassed",
state.label(),
));
}
}
Ok(vec![PreisanfrageEvent::AngebotErhalten {
response_pid: pid,
message_ref,
}]
.into())
}
PreisanfrageCommand::Abschliessen => {
if !matches!(state, PreisanfrageState::AngebotAusgetauscht { .. }) {
return Err(WorkflowError::invalid_state(
"AngebotAusgetauscht",
state.label(),
));
}
Ok(vec![PreisanfrageEvent::Abgeschlossen].into())
}
PreisanfrageCommand::DispatchAperakFehler {
reason,
outbound_ref,
} => {
match state {
PreisanfrageState::Eingegangen(_) | PreisanfrageState::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![PreisanfrageEvent::AperakFehlerDispatched {
aperak_pid,
reason,
outbound_ref,
}]
.into())
}
PreisanfrageCommand::TimeoutExpired { deadline_id, label } => match state {
PreisanfrageState::Abgeschlossen(_) | PreisanfrageState::Rejected { .. } => {
Ok(vec![].into())
}
_ => Ok(vec![PreisanfrageEvent::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 mref(s: &str) -> MessageRef {
MessageRef::new(s)
}
fn apply_all(init: PreisanfrageState, events: &[PreisanfrageEvent]) -> PreisanfrageState {
events.iter().fold(init, WimPreisanfrageWorkflow::apply)
}
#[test]
fn reqote_happy_path_35001() {
let out = WimPreisanfrageWorkflow::handle(
&PreisanfrageState::New,
PreisanfrageCommand::ReceiveReqote {
pid: pid(35001),
sender: mcod("4012345000023"),
receiver: mcod("9900357000004"),
message_ref: mref("REQOTE-001"),
validation_passed: true,
validation_errors: vec![],
},
)
.unwrap();
assert_eq!(out.events.len(), 2);
let state = apply_all(PreisanfrageState::New, &out.events);
assert!(matches!(state, PreisanfrageState::ValidationPassed(_)));
let out = WimPreisanfrageWorkflow::handle(
&state,
PreisanfrageCommand::SendAngebot {
message_ref: mref("QUOTES-001"),
},
)
.unwrap();
if let PreisanfrageEvent::AngebotGesendet { response_pid, .. } = &out.events[0] {
assert_eq!(response_pid.map(|p| p.as_u32()), Some(15001));
} else {
panic!("expected AngebotGesendet");
}
}
#[test]
fn reqote_wrong_pid_rejected() {
let result = WimPreisanfrageWorkflow::handle(
&PreisanfrageState::New,
PreisanfrageCommand::ReceiveReqote {
pid: pid(55001),
sender: mcod("4012345000023"),
receiver: mcod("9900357000004"),
message_ref: mref("X"),
validation_passed: true,
validation_errors: vec![],
},
);
assert!(result.is_err());
}
#[test]
fn timeout_in_abgeschlossen_is_noop() {
let data = PreisanfrageData {
sender: mcod("4012345000023"),
receiver: mcod("9900357000004"),
pruefidentifikator: pid(35001),
};
let state = PreisanfrageState::Abgeschlossen(data);
let dl_id = DeadlineId::new();
let out = WimPreisanfrageWorkflow::handle(
&state,
PreisanfrageCommand::TimeoutExpired {
deadline_id: dl_id,
label: PREISANFRAGE_DEADLINE_LABEL.into(),
},
)
.unwrap();
assert!(out.events.is_empty());
}
}