use mako_engine::{
error::WorkflowError,
outbox::PendingOutbox,
types::{BillingPeriod, MarktpartnerCode, MessageRef, Pruefidentifikator},
workflow::{CommandPayload, EventPayload, Workflow, WorkflowOutput},
};
pub const WORKFLOW_NAME: &str = "mabis-profile";
pub const PROFIL_PIDS: &[u32] = &[13_010, 13_011, 13_012];
pub const REKLAMATION_PID: u32 = 17_211;
pub const REKLAMATION_EBD: &str = "E_0100";
pub const ERSTLIEFERUNG_WERKTAGE: u32 = 1;
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Bilanzierungsverfahren {
Synthetisch,
Analytisch,
}
impl Bilanzierungsverfahren {
#[must_use]
pub fn monatsfrist_werktage(self) -> u32 {
match self {
Self::Synthetisch => 10,
Self::Analytisch => 12,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Profilart {
NormiertesProfil,
Profilschar,
TepReferenzmessung,
}
impl Profilart {
#[must_use]
pub fn from_pid(pid: u32) -> Option<Self> {
match pid {
13_010 => Some(Self::NormiertesProfil),
13_011 => Some(Self::Profilschar),
13_012 => Some(Self::TepReferenzmessung),
_ => None,
}
}
#[must_use]
pub fn pid(self) -> u32 {
match self {
Self::NormiertesProfil => 13_010,
Self::Profilschar => 13_011,
Self::TepReferenzmessung => 13_012,
}
}
#[must_use]
pub fn label(self) -> &'static str {
match self {
Self::NormiertesProfil => "normiertes Profil",
Self::Profilschar => "Profilschar",
Self::TepReferenzmessung => "TEP vergangenheitsbezogene Werte (Referenzmessung)",
}
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ProfilData {
pub pruefidentifikator: Pruefidentifikator,
pub art: Profilart,
pub sender: MarktpartnerCode,
pub receiver: MarktpartnerCode,
pub bilanzierungsmonat: BillingPeriod,
pub version: u32,
pub message_ref: MessageRef,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(tag = "type", content = "data")]
pub enum ProfilEvent {
ProfileErhalten {
pruefidentifikator: Pruefidentifikator,
art: Profilart,
sender: MarktpartnerCode,
receiver: MarktpartnerCode,
bilanzierungsmonat: BillingPeriod,
version: u32,
message_ref: MessageRef,
},
Erfasst {
message_ref: MessageRef,
},
ReklamationGesendet {
pruefidentifikator: Pruefidentifikator,
maengel: String,
message_ref: MessageRef,
},
ValidationFailed {
reason: String,
},
}
impl EventPayload for ProfilEvent {
fn event_type(&self) -> &'static str {
match self {
Self::ProfileErhalten { .. } => "MabisProfileErhalten",
Self::Erfasst { .. } => "MabisProfileErfasst",
Self::ReklamationGesendet { .. } => "MabisProfilReklamationGesendet",
Self::ValidationFailed { .. } => "MabisProfilValidationFailed",
}
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)]
#[serde(tag = "status", content = "data")]
pub enum ProfilState {
#[default]
New,
Erhalten(Box<ProfilData>),
Erfasst(Box<ProfilData>),
Reklamiert {
data: Box<ProfilData>,
maengel: String,
},
ValidationFailed {
reason: String,
},
}
impl ProfilState {
#[must_use]
pub fn label(&self) -> &'static str {
match self {
Self::New => "New",
Self::Erhalten(_) => "Erhalten",
Self::Erfasst(_) => "Erfasst",
Self::Reklamiert { .. } => "Reklamiert",
Self::ValidationFailed { .. } => "ValidationFailed",
}
}
#[must_use]
pub fn data(&self) -> Option<&ProfilData> {
match self {
Self::Erhalten(d) | Self::Erfasst(d) | Self::Reklamiert { data: d, .. } => Some(d),
Self::New | Self::ValidationFailed { .. } => None,
}
}
#[must_use]
pub fn profil_gilt(&self) -> bool {
matches!(
self,
Self::Erhalten(_) | Self::Erfasst(_) | Self::Reklamiert { .. }
)
}
}
#[derive(Clone)]
pub enum ProfilCommand {
ReceiveProfile {
pid: Pruefidentifikator,
sender: MarktpartnerCode,
receiver: MarktpartnerCode,
bilanzierungsmonat: BillingPeriod,
version: u32,
message_ref: MessageRef,
validation_passed: bool,
validation_errors: Vec<String>,
},
Akzeptieren,
SendReklamation {
antwortcode: String,
maengel: String,
message_ref: MessageRef,
},
}
impl CommandPayload for ProfilCommand {}
pub struct MabisProfilWorkflow;
impl Workflow for MabisProfilWorkflow {
type State = ProfilState;
type Event = ProfilEvent;
type Command = ProfilCommand;
fn apply(state: Self::State, event: &Self::Event) -> Self::State {
match event {
ProfilEvent::ProfileErhalten {
pruefidentifikator,
art,
sender,
receiver,
bilanzierungsmonat,
version,
message_ref,
} => ProfilState::Erhalten(Box::new(ProfilData {
pruefidentifikator: *pruefidentifikator,
art: *art,
sender: sender.clone(),
receiver: receiver.clone(),
bilanzierungsmonat: bilanzierungsmonat.clone(),
version: *version,
message_ref: message_ref.clone(),
})),
ProfilEvent::Erfasst { .. } => match state {
ProfilState::Erhalten(d) => ProfilState::Erfasst(d),
other => other,
},
ProfilEvent::ReklamationGesendet { maengel, .. } => match state {
ProfilState::Erhalten(d) => ProfilState::Reklamiert {
data: d,
maengel: maengel.clone(),
},
other => other,
},
ProfilEvent::ValidationFailed { reason } => ProfilState::ValidationFailed {
reason: reason.clone(),
},
}
}
fn handle(
state: &Self::State,
command: Self::Command,
) -> Result<WorkflowOutput<Self::Event>, WorkflowError> {
match command {
ProfilCommand::ReceiveProfile {
pid,
sender,
receiver,
bilanzierungsmonat,
version,
message_ref,
validation_passed,
validation_errors,
} => {
if !matches!(state, ProfilState::New) {
return Ok(vec![].into());
}
let Some(art) = Profilart::from_pid(pid.as_u32()) else {
return Err(WorkflowError::rejected(format!(
"PID {pid} trägt kein normiertes Profil — erwartet {PROFIL_PIDS:?}"
)));
};
if !validation_passed {
return Ok(vec![ProfilEvent::ValidationFailed {
reason: validation_errors.join("; "),
}]
.into());
}
Ok(vec![ProfilEvent::ProfileErhalten {
pruefidentifikator: pid,
art,
sender,
receiver,
bilanzierungsmonat,
version,
message_ref,
}]
.into())
}
ProfilCommand::Akzeptieren => {
let ProfilState::Erhalten(data) = state else {
return Err(WorkflowError::invalid_state("Erhalten", state.label()));
};
Ok(vec![ProfilEvent::Erfasst {
message_ref: data.message_ref.clone(),
}]
.into())
}
ProfilCommand::SendReklamation {
antwortcode,
maengel,
message_ref,
} => {
let ProfilState::Erhalten(data) = state else {
return Err(WorkflowError::invalid_state("Erhalten", state.label()));
};
if maengel.trim().is_empty() {
return Err(WorkflowError::rejected(
"eine Reklamation ohne Mangelbeschreibung ist für den NB nicht \
bearbeitbar",
));
}
let code = mako_pruefung::mabis::lookup(REKLAMATION_EBD, &antwortcode).ok_or_else(
|| {
WorkflowError::rejected(format!(
"{REKLAMATION_EBD} veröffentlicht den Antwortcode \
{antwortcode} nicht"
))
},
)?;
if data.art == Profilart::NormiertesProfil
&& matches!(code.code, "A03" | "A04" | "A05" | "A06")
{
return Err(WorkflowError::rejected(format!(
"{} ist im Profilschar-Zweig von {REKLAMATION_EBD} \
veröffentlicht und für ein normiertes Profil nicht \
erreichbar",
code.code
)));
}
let pid = Pruefidentifikator::new(REKLAMATION_PID).map_err(|e| {
WorkflowError::rejected(format!("invalid PID {REKLAMATION_PID}: {e}"))
})?;
let outbox = PendingOutbox::new(
"ORDERS",
data.sender.as_str(),
serde_json::json!({
"pid": REKLAMATION_PID,
"ebd": REKLAMATION_EBD,
"antwortcode": code.code,
"bedeutung": code.bedeutung,
"profilart": data.art,
"bilanzierungsmonat": data.bilanzierungsmonat.as_str(),
"version": data.version,
"maengel": maengel,
}),
);
Ok(WorkflowOutput {
events: vec![ProfilEvent::ReklamationGesendet {
pruefidentifikator: pid,
maengel,
message_ref,
}],
outbox: vec![outbox],
deadlines: vec![],
})
}
}
}
}
#[must_use]
pub fn all_pids() -> Vec<u32> {
let mut v = PROFIL_PIDS.to_vec();
v.push(REKLAMATION_PID);
v.sort_unstable();
v
}
#[cfg(test)]
mod tests {
use super::*;
fn mp(s: &str) -> MarktpartnerCode {
MarktpartnerCode::new(s)
}
fn receive(pid: u32) -> ProfilCommand {
ProfilCommand::ReceiveProfile {
pid: Pruefidentifikator::new(pid).expect("valid PID"),
sender: mp("9900123456789"),
receiver: mp("9900987654321"),
bilanzierungsmonat: BillingPeriod::new("2026-01"),
version: 3,
message_ref: MessageRef::new("MSCONS-PROFIL-1"),
validation_passed: true,
validation_errors: vec![],
}
}
fn fold(events: &[ProfilEvent]) -> ProfilState {
events
.iter()
.fold(ProfilState::default(), MabisProfilWorkflow::apply)
}
#[test]
fn the_pid_table_round_trips() {
for &pid in PROFIL_PIDS {
let art = Profilart::from_pid(pid).expect("in the table");
assert_eq!(art.pid(), pid);
}
assert!(
Profilart::from_pid(13_003).is_none(),
"13003 is a Summenzeitreihe"
);
assert!(Profilart::from_pid(REKLAMATION_PID).is_none());
}
#[test]
fn the_frist_follows_the_bilanzierungsverfahren() {
assert_eq!(
Bilanzierungsverfahren::Synthetisch.monatsfrist_werktage(),
10
);
assert_eq!(
Bilanzierungsverfahren::Analytisch.monatsfrist_werktage(),
12
);
}
#[test]
fn a_reklamation_leaves_the_profile_in_force() {
let out = MabisProfilWorkflow::handle(&ProfilState::New, receive(13_011)).expect("ok");
let state = fold(&out.events);
assert!(state.profil_gilt());
let out = MabisProfilWorkflow::handle(
&state,
ProfilCommand::SendReklamation {
antwortcode: "A01".into(),
maengel: "Profil H0 gehört zu keiner abonnierten Profilgruppe".into(),
message_ref: MessageRef::new("ORDERS-REK-1"),
},
)
.expect("ok");
assert_eq!(out.outbox[0].payload["pid"], REKLAMATION_PID);
assert_eq!(out.outbox[0].payload["ebd"], REKLAMATION_EBD);
assert_eq!(out.outbox[0].payload["antwortcode"], "A01");
assert_eq!(
out.outbox[0].recipient.as_ref(),
"9900123456789",
"the Reklamation goes back to the publishing NB"
);
let state = out.events.iter().fold(state, MabisProfilWorkflow::apply);
assert_eq!(state.label(), "Reklamiert");
assert!(
state.profil_gilt(),
"a Reklamation must not strand the LF without a profile"
);
}
#[test]
fn a_profilschar_code_is_unreachable_for_a_normiertes_profil() {
let profil = fold(
&MabisProfilWorkflow::handle(&ProfilState::New, receive(13_010))
.unwrap()
.events,
);
let schar = fold(
&MabisProfilWorkflow::handle(&ProfilState::New, receive(13_011))
.unwrap()
.events,
);
let reklamation = |code: &str| ProfilCommand::SendReklamation {
antwortcode: code.to_owned(),
maengel: "Maßeinheit weicht ab".into(),
message_ref: MessageRef::new("ORDERS-REK-1"),
};
for code in ["A03", "A04", "A05", "A06"] {
assert!(
MabisProfilWorkflow::handle(&profil, reklamation(code)).is_err(),
"{code} is Profilschar-only"
);
assert!(MabisProfilWorkflow::handle(&schar, reklamation(code)).is_ok());
}
assert!(MabisProfilWorkflow::handle(&profil, reklamation("A01")).is_ok());
}
#[test]
fn an_unpublished_reklamationsgrund_is_refused() {
let state = fold(
&MabisProfilWorkflow::handle(&ProfilState::New, receive(13_010))
.unwrap()
.events,
);
assert!(
MabisProfilWorkflow::handle(
&state,
ProfilCommand::SendReklamation {
antwortcode: "A99".into(),
maengel: "Sonstiges".into(),
message_ref: MessageRef::new("ORDERS-REK-1"),
},
)
.is_err(),
"E_0100 publishes A01-A06 only"
);
}
#[test]
fn a_reklamation_needs_a_defect() {
let out = MabisProfilWorkflow::handle(&ProfilState::New, receive(13_010)).expect("ok");
let state = fold(&out.events);
assert!(
MabisProfilWorkflow::handle(
&state,
ProfilCommand::SendReklamation {
antwortcode: "A01".into(),
maengel: " ".into(),
message_ref: MessageRef::new("ORDERS-REK-1"),
},
)
.is_err()
);
}
#[test]
fn accepting_is_terminal_and_emits_nothing() {
let out = MabisProfilWorkflow::handle(&ProfilState::New, receive(13_012)).expect("ok");
let state = fold(&out.events);
let out = MabisProfilWorkflow::handle(&state, ProfilCommand::Akzeptieren).expect("ok");
assert!(out.outbox.is_empty());
let state = out.events.iter().fold(state, MabisProfilWorkflow::apply);
assert_eq!(state.label(), "Erfasst");
assert!(MabisProfilWorkflow::handle(&state, ProfilCommand::Akzeptieren).is_err());
}
#[test]
fn a_non_profile_pid_is_rejected() {
assert!(MabisProfilWorkflow::handle(&ProfilState::New, receive(13_003)).is_err());
}
#[test]
fn validation_failure_is_terminal() {
let cmd = ProfilCommand::ReceiveProfile {
pid: Pruefidentifikator::new(13_010).expect("valid PID"),
sender: mp("9900123456789"),
receiver: mp("9900987654321"),
bilanzierungsmonat: BillingPeriod::new("2026-01"),
version: 3,
message_ref: MessageRef::new("MSCONS-PROFIL-1"),
validation_passed: false,
validation_errors: vec!["SG6 LOC fehlt".into()],
};
let out = MabisProfilWorkflow::handle(&ProfilState::New, cmd).expect("ok");
assert_eq!(fold(&out.events).label(), "ValidationFailed");
}
#[test]
fn all_pids_covers_the_delivery_and_the_reklamation() {
assert_eq!(all_pids(), vec![13_010, 13_011, 13_012, REKLAMATION_PID]);
}
}