use std::collections::HashMap;
use mako_engine::types::Pruefidentifikator;
use mako_engine::{
envelope::EventEnvelope,
error::WorkflowError,
projection::Projection,
types::{BikoId, BillingPeriod, MarktpartnerCode, MessageRef},
workflow::{CommandPayload, EventPayload, Workflow, WorkflowOutput},
};
use mako_pruefung::mabis::MabisAntwort;
use crate::ids::MabisZaehlpunktId;
use crate::zeitreihen::Zeitreihe;
pub const WORKFLOW_NAME: &str = "mabis-billing";
pub const SUMMENZEITREIHE_PID: u32 = 13_003;
pub const AUSFALLARBEIT_PIDS: &[u32] = &[13_020, 13_023];
#[must_use]
pub fn ist_zeitreihen_pid(pid: u32) -> bool {
pid == SUMMENZEITREIHE_PID || AUSFALLARBEIT_PIDS.contains(&pid)
}
pub const IFTSTA_PIDS: &[u32] = &[21_000, 21_001, 21_002, 21_003, 21_004, 21_005];
pub const IFTSTA_DATENSTATUS_PIDS: &[u32] = &[21_003, 21_004];
pub const IFTSTA_PRUEFMITTEILUNG_PIDS: &[u32] = &[21_000, 21_001, 21_005];
pub const IFTSTA_ABWEISUNG_PID: u32 = 21_002;
#[derive(
Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize,
)]
#[serde(transparent)]
pub struct SzrVersion(String);
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum InvalidSzrVersion {
#[error("Versionsangabe hat {0} Zeichen, erwartet 17 (CCYYMMDDHHMMSSZZZ)")]
FalscheLaenge(usize),
#[error("Versionsangabe '{0}': die ersten 14 Zeichen sind kein Zeitstempel")]
KeinZeitstempel(String),
}
impl SzrVersion {
pub fn new(erstellungszeitpunkt: impl Into<String>) -> Result<Self, InvalidSzrVersion> {
let v: String = erstellungszeitpunkt.into();
let n = v.chars().count();
if n != 17 {
return Err(InvalidSzrVersion::FalscheLaenge(n));
}
if !v.chars().take(14).all(|c| c.is_ascii_digit()) {
return Err(InvalidSzrVersion::KeinZeitstempel(v));
}
Ok(Self(v))
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for SzrVersion {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
pub const RFF_QUALIFIER_VERSION: &str = "AUU";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Datenstatus {
Pruefdaten,
Abrechnungsdaten,
AbrechnungsdatenKbka,
AbgerechneteDaten,
AbgerechneteDatenKbka,
}
impl Datenstatus {
#[must_use]
pub fn from_code(code: &str) -> Option<Self> {
Some(match code {
"A01" => Self::Abrechnungsdaten,
"A02" => Self::Pruefdaten,
"A03" => Self::AbgerechneteDaten,
"A04" => Self::AbrechnungsdatenKbka,
"A06" => Self::AbgerechneteDatenKbka,
_ => return None,
})
}
#[must_use]
pub fn code(self) -> &'static str {
match self {
Self::Abrechnungsdaten => "A01",
Self::Pruefdaten => "A02",
Self::AbgerechneteDaten => "A03",
Self::AbrechnungsdatenKbka => "A04",
Self::AbgerechneteDatenKbka => "A06",
}
}
#[must_use]
pub fn ist_abgerechnet(self) -> bool {
matches!(self, Self::AbgerechneteDaten | Self::AbgerechneteDatenKbka)
}
#[must_use]
pub fn ist_abrechnungsrelevant(self) -> bool {
matches!(self, Self::Abrechnungsdaten | Self::AbrechnungsdatenKbka)
}
}
pub const STS_KATEGORIE_DATENSTATUS: &str = "Z04";
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(tag = "ergebnis", rename_all = "snake_case")]
pub enum Pruefergebnis {
Positiv {
antwort: MabisAntwort,
},
Negativ {
antwort: MabisAntwort,
grund: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum PruefergebnisError {
#[error("für {zeitreihe} führt mako keinen Entscheidungsbaum")]
KeinPruefbaum {
zeitreihe: Zeitreihe,
},
#[error("{ebd} veröffentlicht den Antwortcode {code} nicht")]
UnbekannterCode {
ebd: &'static str,
code: String,
},
#[error("{code} aus {ebd} ist {cluster} und trägt keine {erwartet}e Prüfmitteilung")]
FalschesCluster {
ebd: &'static str,
code: String,
cluster: &'static str,
erwartet: &'static str,
},
}
impl Pruefergebnis {
pub fn positiv(zeitreihe: Zeitreihe) -> Result<Self, PruefergebnisError> {
let ebd = zeitreihe
.pruef_ebd()
.ok_or(PruefergebnisError::KeinPruefbaum { zeitreihe })?;
let code =
mako_pruefung::mabis::zustimmung(ebd).ok_or(PruefergebnisError::UnbekannterCode {
ebd,
code: "<Zustimmung>".to_owned(),
})?;
Ok(Self::Positiv {
antwort: MabisAntwort::from_code(ebd, code, 0, None),
})
}
pub fn negativ(
zeitreihe: Zeitreihe,
code: &str,
grund: impl Into<String>,
) -> Result<Self, PruefergebnisError> {
let ebd = zeitreihe
.pruef_ebd()
.ok_or(PruefergebnisError::KeinPruefbaum { zeitreihe })?;
let entry = mako_pruefung::mabis::lookup(ebd, code).ok_or_else(|| {
PruefergebnisError::UnbekannterCode {
ebd,
code: code.to_owned(),
}
})?;
if entry.ist_zustimmung() == Some(true) {
return Err(PruefergebnisError::FalschesCluster {
ebd,
code: code.to_owned(),
cluster: entry.cluster.label(),
erwartet: "negativ",
});
}
Ok(Self::Negativ {
antwort: MabisAntwort::from_code(ebd, entry, 0, None),
grund: grund.into(),
})
}
pub fn aus_code(
zeitreihe: Zeitreihe,
code: &str,
grund: impl Into<String>,
) -> Result<Self, PruefergebnisError> {
let ebd = zeitreihe
.pruef_ebd()
.ok_or(PruefergebnisError::KeinPruefbaum { zeitreihe })?;
let entry = mako_pruefung::mabis::lookup(ebd, code).ok_or_else(|| {
PruefergebnisError::UnbekannterCode {
ebd,
code: code.to_owned(),
}
})?;
let antwort = MabisAntwort::from_code(ebd, entry, 0, None);
Ok(if entry.ist_zustimmung() == Some(true) {
Self::Positiv { antwort }
} else {
Self::Negativ {
antwort,
grund: grund.into(),
}
})
}
#[must_use]
pub fn ist_positiv(&self) -> bool {
matches!(self, Self::Positiv { .. })
}
#[must_use]
pub fn antwort(&self) -> &MabisAntwort {
match self {
Self::Positiv { antwort } | Self::Negativ { antwort, .. } => antwort,
}
}
#[must_use]
pub fn wird_weitergeleitet(&self) -> bool {
self.antwort().wird_weitergeleitet()
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct VersionRecord {
pub version: SzrVersion,
pub message_ref: MessageRef,
pub im_erstaufschlag: bool,
pub pruefergebnis: Option<Pruefergebnis>,
pub datenstatus: Option<Datenstatus>,
pub pruefmitteilung_abgewiesen: Option<String>,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(tag = "type", content = "data")]
pub enum BillingEvent {
SummenzeitreiheReceived {
zeitreihe: Zeitreihe,
mabis_zp: MabisZaehlpunktId,
bilanzierungsmonat: BillingPeriod,
version: SzrVersion,
im_erstaufschlag: bool,
absender: MarktpartnerCode,
biko_id: BikoId,
message_ref: MessageRef,
},
PruefmitteilungSent {
version: SzrVersion,
pid: Pruefidentifikator,
ergebnis: Pruefergebnis,
message_ref: MessageRef,
},
PruefmitteilungAbgewiesen {
version: SzrVersion,
grund: String,
message_ref: MessageRef,
},
DatenstatusReceived {
version: SzrVersion,
datenstatus: Datenstatus,
pid: Pruefidentifikator,
message_ref: MessageRef,
},
ClearingGeschlossen {
lauf: crate::fristen::Abrechnungslauf,
},
}
impl EventPayload for BillingEvent {
fn event_type(&self) -> &'static str {
match self {
Self::SummenzeitreiheReceived { .. } => "MabisSummenzeitreiheReceived",
Self::PruefmitteilungSent { .. } => "MabisPruefmitteilungSent",
Self::PruefmitteilungAbgewiesen { .. } => "MabisPruefmitteilungAbgewiesen",
Self::DatenstatusReceived { .. } => "MabisDatenstatusReceived",
Self::ClearingGeschlossen { .. } => "MabisClearingGeschlossen",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct BillingData {
pub zeitreihe: Zeitreihe,
pub mabis_zp: MabisZaehlpunktId,
pub bilanzierungsmonat: BillingPeriod,
pub biko_id: BikoId,
pub versionen: Vec<VersionRecord>,
}
impl BillingData {
#[must_use]
pub fn version(&self, version: &SzrVersion) -> Option<&VersionRecord> {
self.versionen.iter().find(|v| &v.version == version)
}
#[must_use]
pub fn hoechste_version(&self) -> Option<&SzrVersion> {
self.versionen.iter().map(|v| &v.version).max()
}
#[must_use]
pub fn abrechnungsrelevante_version(&self) -> Option<&VersionRecord> {
self.versionen
.iter()
.filter(|v| {
v.datenstatus
.is_some_and(|d| d.ist_abrechnungsrelevant() || d.ist_abgerechnet())
})
.max_by_key(|v| &v.version)
}
#[must_use]
pub fn offener_korrekturbedarf(&self) -> Vec<&SzrVersion> {
let hoechste = self.hoechste_version();
self.versionen
.iter()
.filter(|v| {
matches!(v.pruefergebnis, Some(Pruefergebnis::Negativ { .. }))
&& Some(&v.version) == hoechste
})
.map(|v| &v.version)
.collect()
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(tag = "status", content = "data")]
#[derive(Default)]
pub enum BillingState {
#[default]
New,
Offen(BillingData),
Geschlossen(BillingData),
}
impl BillingState {
#[must_use]
pub fn status_str(&self) -> &'static str {
match self {
Self::New => "New",
Self::Offen(_) => "Offen",
Self::Geschlossen(_) => "Geschlossen",
}
}
#[must_use]
pub fn data(&self) -> Option<&BillingData> {
match self {
Self::New => None,
Self::Offen(d) | Self::Geschlossen(d) => Some(d),
}
}
}
#[derive(Clone)]
pub enum BillingCommand {
ReceiveSummenzeitreihe {
pid: Pruefidentifikator,
zeitreihe: Zeitreihe,
mabis_zp: MabisZaehlpunktId,
bilanzierungsmonat: BillingPeriod,
version: SzrVersion,
im_erstaufschlag: bool,
absender: MarktpartnerCode,
biko_id: BikoId,
message_ref: MessageRef,
},
SendPruefmitteilung {
version: SzrVersion,
pid: Pruefidentifikator,
antwortcode: String,
grund: Option<String>,
message_ref: MessageRef,
},
ReceiveIftsta {
pid: Pruefidentifikator,
version: SzrVersion,
datenstatus: Option<Datenstatus>,
abweisungsgrund: Option<String>,
message_ref: MessageRef,
},
CloseClearing {
lauf: crate::fristen::Abrechnungslauf,
},
}
impl CommandPayload for BillingCommand {}
pub struct MabisBillingWorkflow;
impl Workflow for MabisBillingWorkflow {
type State = BillingState;
type Event = BillingEvent;
type Command = BillingCommand;
fn on_deadline(
deadline: &mako_engine::deadline::Deadline,
state: &Self::State,
) -> Option<Self::Command> {
match (deadline.label(), state) {
(crate::fristen::CLEARING_ENDE_LABEL, BillingState::Offen(_)) => {
Some(BillingCommand::CloseClearing {
lauf: crate::fristen::Abrechnungslauf::Bka,
})
}
_ => None,
}
}
fn apply(state: Self::State, event: &Self::Event) -> Self::State {
match event {
BillingEvent::SummenzeitreiheReceived {
zeitreihe,
mabis_zp,
bilanzierungsmonat,
version,
im_erstaufschlag,
biko_id,
message_ref,
..
} => {
let record = VersionRecord {
version: version.clone(),
message_ref: message_ref.clone(),
im_erstaufschlag: *im_erstaufschlag,
pruefergebnis: None,
datenstatus: None,
pruefmitteilung_abgewiesen: None,
};
match state {
BillingState::Offen(mut d) => {
d.versionen.push(record);
BillingState::Offen(d)
}
BillingState::New => BillingState::Offen(BillingData {
zeitreihe: *zeitreihe,
mabis_zp: mabis_zp.clone(),
bilanzierungsmonat: bilanzierungsmonat.clone(),
biko_id: biko_id.clone(),
versionen: vec![record],
}),
other @ BillingState::Geschlossen(_) => other,
}
}
BillingEvent::PruefmitteilungSent {
version, ergebnis, ..
} => mutate_version(state, version, |v| {
v.pruefergebnis = Some(ergebnis.clone());
v.pruefmitteilung_abgewiesen = None;
}),
BillingEvent::PruefmitteilungAbgewiesen { version, grund, .. } => {
mutate_version(state, version, |v| {
v.pruefergebnis = None;
v.pruefmitteilung_abgewiesen = Some(grund.clone());
})
}
BillingEvent::DatenstatusReceived {
version,
datenstatus,
..
} => mutate_version(state, version, |v| v.datenstatus = Some(*datenstatus)),
BillingEvent::ClearingGeschlossen { .. } => match state {
BillingState::Offen(d) => BillingState::Geschlossen(d),
other => other,
},
}
}
fn handle(
state: &Self::State,
command: Self::Command,
) -> Result<WorkflowOutput<Self::Event>, WorkflowError> {
match command {
BillingCommand::ReceiveSummenzeitreihe {
pid,
zeitreihe,
mabis_zp,
bilanzierungsmonat,
version,
im_erstaufschlag,
absender,
biko_id,
message_ref,
} => {
if !ist_zeitreihen_pid(pid.as_u32()) {
return Err(WorkflowError::not_implemented(pid.as_u32()));
}
if !zeitreihe.hat_pruefmitteilung_und_datenstatus() {
return Err(WorkflowError::validation(format!(
"{zeitreihe} sendet weder Prüfmitteilung noch Datenstatus \
(BK6-24-174 Anlage 3 Kap. 3.8.3) und wird nicht abgerechnet"
)));
}
match state {
BillingState::Geschlossen(_) => {
return Err(WorkflowError::invalid_state("Offen", "Geschlossen"));
}
BillingState::Offen(d) => {
if let Some(h) = d.hoechste_version()
&& &version <= h
{
return Err(WorkflowError::validation(format!(
"Version {version} ist nicht aufsteigend — \
zuletzt empfangen {h} (BK6-24-174 Anlage 3 Kap. 3.8.2)"
)));
}
if d.zeitreihe != zeitreihe {
return Err(WorkflowError::validation(format!(
"Stream führt {} — {zeitreihe} gehört in einen eigenen Stream",
d.zeitreihe
)));
}
}
BillingState::New => {}
}
Ok(vec![BillingEvent::SummenzeitreiheReceived {
zeitreihe,
mabis_zp,
bilanzierungsmonat,
version,
im_erstaufschlag,
absender,
biko_id,
message_ref,
}]
.into())
}
BillingCommand::SendPruefmitteilung {
version,
pid,
antwortcode,
grund,
message_ref,
} => {
let data = open_data(state)?;
if !IFTSTA_PRUEFMITTEILUNG_PIDS.contains(&pid.as_u32()) {
return Err(WorkflowError::validation(format!(
"PID {} trägt keine Prüfmitteilung dieses Teilnehmers — \
erwartet 21000, 21001 oder 21005",
pid.as_u32()
)));
}
if data.version(&version).is_none() {
return Err(WorkflowError::validation(format!(
"Prüfmitteilung zu {version}, die nie empfangen wurde \
(Kap. 3.8.3: eine Prüfmitteilung bezieht sich immer auf eine Version)"
)));
}
let ergebnis = Pruefergebnis::aus_code(
data.zeitreihe,
&antwortcode,
grund.as_deref().unwrap_or_default(),
)
.map_err(|e| WorkflowError::validation(e.to_string()))?;
if !ergebnis.ist_positiv() && grund.as_deref().unwrap_or_default().trim().is_empty()
{
return Err(WorkflowError::validation(
"eine negative Prüfmitteilung ohne Grund ist nicht zustellbar",
));
}
if ergebnis.antwort().braucht_bemerkung
&& grund.as_deref().unwrap_or_default().trim().is_empty()
{
return Err(WorkflowError::validation(format!(
"{} verlangt eine Erläuterung (FTX+ACB)",
ergebnis.antwort().code
)));
}
Ok(vec![BillingEvent::PruefmitteilungSent {
version,
pid,
ergebnis,
message_ref,
}]
.into())
}
BillingCommand::ReceiveIftsta {
pid,
version,
datenstatus,
abweisungsgrund,
message_ref,
} => {
let data = open_data(state)?;
if data.version(&version).is_none() {
return Err(WorkflowError::validation(format!(
"IFTSTA {} verweist auf {version}, die nie empfangen wurde",
pid.as_u32()
)));
}
let raw = pid.as_u32();
if raw == IFTSTA_ABWEISUNG_PID {
let grund = abweisungsgrund.ok_or_else(|| {
WorkflowError::validation(
"IFTSTA 21002 (Abweisung der Prüfmitteilung): Grund ist erforderlich",
)
})?;
return Ok(vec![BillingEvent::PruefmitteilungAbgewiesen {
version,
grund,
message_ref,
}]
.into());
}
if IFTSTA_DATENSTATUS_PIDS.contains(&raw) {
let ds = datenstatus.ok_or_else(|| {
WorkflowError::validation(format!(
"IFTSTA {raw} (Datenstatus): der Datenstatus-Code ist erforderlich"
))
})?;
return Ok(vec![BillingEvent::DatenstatusReceived {
version,
datenstatus: ds,
pid,
message_ref,
}]
.into());
}
Err(WorkflowError::validation(format!(
"IFTSTA {raw} ist eine ausgehende Prüfmitteilung dieses Teilnehmers \
und kein Eingang — erwartet 21002, 21003 oder 21004"
)))
}
BillingCommand::CloseClearing { lauf } => {
if !matches!(state, BillingState::Offen(_)) {
return Ok(WorkflowOutput::events(vec![]));
}
Ok(vec![BillingEvent::ClearingGeschlossen { lauf }].into())
}
}
}
}
fn mutate_version(
state: BillingState,
version: &SzrVersion,
f: impl FnOnce(&mut VersionRecord),
) -> BillingState {
match state {
BillingState::Offen(mut d) => {
if let Some(v) = d.versionen.iter_mut().find(|v| &v.version == version) {
f(v);
}
BillingState::Offen(d)
}
BillingState::Geschlossen(mut d) => {
if let Some(v) = d.versionen.iter_mut().find(|v| &v.version == version) {
f(v);
}
BillingState::Geschlossen(d)
}
new @ BillingState::New => new,
}
}
fn open_data(state: &BillingState) -> Result<&BillingData, WorkflowError> {
match state {
BillingState::Offen(d) => Ok(d),
other => Err(WorkflowError::invalid_state("Offen", other.status_str())),
}
}
#[derive(Debug, Default)]
pub struct BillingRecord {
pub status: &'static str,
pub zeitreihe: Option<Zeitreihe>,
pub bilanzierungsmonat: Option<BillingPeriod>,
pub hoechste_version: Option<SzrVersion>,
pub datenstatus: Option<Datenstatus>,
pub offene_korrekturen: Vec<SzrVersion>,
pub event_count: usize,
}
#[derive(Debug, Default)]
pub struct BillingProjection {
pub records: HashMap<String, BillingRecord>,
pub last_seq: u64,
}
impl Projection for BillingProjection {
fn name(&self) -> &'static str {
"BillingProjection"
}
fn handle_event(&mut self, envelope: &EventEnvelope) {
self.last_seq = self.last_seq.max(envelope.sequence_number);
let Ok(event) = envelope.decode::<BillingEvent>() else {
return;
};
let record = self
.records
.entry(envelope.stream_id.as_str().to_owned())
.or_default();
record.event_count += 1;
match event {
BillingEvent::SummenzeitreiheReceived {
zeitreihe,
bilanzierungsmonat,
version,
..
} => {
record.status = "Offen";
record.zeitreihe = Some(zeitreihe);
record.bilanzierungsmonat = Some(bilanzierungsmonat);
if record
.hoechste_version
.as_ref()
.is_none_or(|h| &version > h)
{
record.hoechste_version = Some(version);
record.datenstatus = None;
record.offene_korrekturen.clear();
}
}
BillingEvent::PruefmitteilungSent {
version, ergebnis, ..
} => {
record.offene_korrekturen.retain(|v| *v != version);
if !ergebnis.ist_positiv() {
record.offene_korrekturen.push(version);
}
}
BillingEvent::PruefmitteilungAbgewiesen { version, .. } => {
record.offene_korrekturen.retain(|v| *v != version);
}
BillingEvent::DatenstatusReceived {
version,
datenstatus,
..
} => {
if record.hoechste_version.as_ref() == Some(&version) {
record.datenstatus = Some(datenstatus);
}
}
BillingEvent::ClearingGeschlossen { .. } => {
record.status = "Geschlossen";
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::zeitreihen::{Familie, Kategorie};
fn bg() -> Zeitreihe {
Zeitreihe::new(Familie::BgSzr, Some(Kategorie::B)).expect("Tabelle-1 row")
}
fn v(n: u32) -> SzrVersion {
SzrVersion::new(format!("202601011200{n:02}+00")).expect("17 chars")
}
fn zp() -> MabisZaehlpunktId {
MabisZaehlpunktId::new("DE0001111222233334444555566667777").expect("33 chars")
}
fn receive(version: u32, im_erstaufschlag: bool) -> BillingCommand {
receive_as(SUMMENZEITREIHE_PID, bg(), version, im_erstaufschlag)
}
fn receive_as(
pid: u32,
zeitreihe: Zeitreihe,
version: u32,
im_erstaufschlag: bool,
) -> BillingCommand {
BillingCommand::ReceiveSummenzeitreihe {
pid: Pruefidentifikator::new(pid).expect("valid PID"),
zeitreihe,
mabis_zp: zp(),
bilanzierungsmonat: BillingPeriod::new("2026-01"),
version: v(version),
im_erstaufschlag,
absender: MarktpartnerCode::new("9900357000004"),
biko_id: BikoId::new("10YDE-VE-TRANSMIX"),
message_ref: MessageRef::new(format!("MSCONS-{version}")),
}
}
fn apply_all(state: BillingState, events: &[BillingEvent]) -> BillingState {
events.iter().fold(state, MabisBillingWorkflow::apply)
}
fn run(cmds: Vec<BillingCommand>) -> BillingState {
cmds.into_iter().fold(BillingState::default(), |s, c| {
let out = MabisBillingWorkflow::handle(&s, c).expect("command accepted");
apply_all(s, &out.events)
})
}
#[test]
fn a_stream_accumulates_versions() {
let state = run(vec![receive(1, true), receive(2, false), receive(7, false)]);
let d = state.data().expect("open");
assert_eq!(d.versionen.len(), 3);
assert_eq!(d.hoechste_version(), Some(&v(7)));
assert!(d.versionen[0].im_erstaufschlag);
assert!(!d.versionen[1].im_erstaufschlag);
}
#[test]
fn versions_must_ascend() {
let state = run(vec![receive(3, true)]);
for v in [1, 3] {
assert!(
MabisBillingWorkflow::handle(&state, receive(v, false)).is_err(),
"version {v} must be refused after 3"
);
}
assert!(MabisBillingWorkflow::handle(&state, receive(4, false)).is_ok());
}
#[test]
fn a_kategorie_c_series_has_no_settlement_stream() {
let cmd = receive_as(
SUMMENZEITREIHE_PID,
Zeitreihe::new(Familie::BgSzr, Some(Kategorie::C)).unwrap(),
1,
true,
);
assert!(MabisBillingWorkflow::handle(&BillingState::New, cmd).is_err());
}
#[test]
fn only_the_mabis_zeitreihen_pids_open_a_settlement() {
for pid in [SUMMENZEITREIHE_PID, 13_020, 13_023] {
assert!(ist_zeitreihen_pid(pid), "{pid}");
let cmd = receive_as(pid, bg(), 1, true);
assert!(MabisBillingWorkflow::handle(&BillingState::New, cmd).is_ok());
}
for pid in [13_021_u32, 13_022, 13_026] {
assert!(!ist_zeitreihen_pid(pid), "{pid}");
let cmd = receive_as(pid, bg(), 1, true);
assert!(MabisBillingWorkflow::handle(&BillingState::New, cmd).is_err());
}
}
#[test]
fn pruefmitteilung_needs_a_version_that_arrived() {
let state = run(vec![receive(1, true)]);
let cmd = BillingCommand::SendPruefmitteilung {
version: v(2),
pid: Pruefidentifikator::new(21_005).expect("valid"),
antwortcode: "A03".into(),
grund: None,
message_ref: MessageRef::new("PM-1"),
};
assert!(MabisBillingWorkflow::handle(&state, cmd).is_err());
}
#[test]
fn an_inbound_pid_cannot_be_sent_as_a_pruefmitteilung() {
let state = run(vec![receive(1, true)]);
for pid in [21_002_u32, 21_003, 21_004] {
let cmd = BillingCommand::SendPruefmitteilung {
version: v(1),
pid: Pruefidentifikator::new(pid).expect("valid"),
antwortcode: "A03".into(),
grund: None,
message_ref: MessageRef::new("PM-1"),
};
assert!(
MabisBillingWorkflow::handle(&state, cmd).is_err(),
"{pid} is inbound"
);
}
}
#[test]
fn an_outbound_pid_cannot_arrive_as_an_inbound_iftsta() {
let state = run(vec![receive(1, true)]);
for pid in IFTSTA_PRUEFMITTEILUNG_PIDS {
let cmd = BillingCommand::ReceiveIftsta {
pid: Pruefidentifikator::new(*pid).expect("valid"),
version: v(1),
datenstatus: Some(Datenstatus::Abrechnungsdaten),
abweisungsgrund: None,
message_ref: MessageRef::new("IN-1"),
};
assert!(
MabisBillingWorkflow::handle(&state, cmd).is_err(),
"{pid} is outbound"
);
}
}
#[test]
fn both_21003_and_21004_carry_a_datenstatus() {
for pid in IFTSTA_DATENSTATUS_PIDS {
let state = run(vec![
receive(1, false),
BillingCommand::ReceiveIftsta {
pid: Pruefidentifikator::new(*pid).expect("valid"),
version: v(1),
datenstatus: Some(Datenstatus::Pruefdaten),
abweisungsgrund: None,
message_ref: MessageRef::new("DS-1"),
},
]);
assert_eq!(
state.data().unwrap().version(&v(1)).unwrap().datenstatus,
Some(Datenstatus::Pruefdaten),
"PID {pid} must set the Datenstatus"
);
}
}
#[test]
fn a_datenstatus_message_without_a_code_is_refused() {
let state = run(vec![receive(1, false)]);
let cmd = BillingCommand::ReceiveIftsta {
pid: Pruefidentifikator::new(21_004).expect("valid"),
version: v(1),
datenstatus: None,
abweisungsgrund: None,
message_ref: MessageRef::new("DS-1"),
};
assert!(MabisBillingWorkflow::handle(&state, cmd).is_err());
}
#[test]
fn a_negative_pruefmitteilung_leaves_the_datenstatus_alone() {
let state = run(vec![
receive(1, true),
BillingCommand::ReceiveIftsta {
pid: Pruefidentifikator::new(21_004).expect("valid"),
version: v(1),
datenstatus: Some(Datenstatus::Abrechnungsdaten),
abweisungsgrund: None,
message_ref: MessageRef::new("DS-1"),
},
BillingCommand::SendPruefmitteilung {
version: v(1),
pid: Pruefidentifikator::new(21_005).expect("valid"),
antwortcode: "A02".into(),
grund: Some("Summe weicht um 12 kWh ab".into()),
message_ref: MessageRef::new("PM-1"),
},
]);
let v = state.data().unwrap().version(&v(1)).unwrap();
assert_eq!(v.datenstatus, Some(Datenstatus::Abrechnungsdaten));
assert!(matches!(
v.pruefergebnis,
Some(Pruefergebnis::Negativ { .. })
));
}
#[test]
fn a_negative_pruefmitteilung_needs_a_reason() {
let state = run(vec![receive(1, true)]);
let cmd = BillingCommand::SendPruefmitteilung {
version: v(1),
pid: Pruefidentifikator::new(21_005).expect("valid"),
antwortcode: "A02".into(),
grund: Some(" ".into()),
message_ref: MessageRef::new("PM-1"),
};
assert!(MabisBillingWorkflow::handle(&state, cmd).is_err());
}
#[test]
fn an_abgewiesene_pruefmitteilung_clears_the_check() {
let state = run(vec![
receive(1, true),
BillingCommand::SendPruefmitteilung {
version: v(1),
pid: Pruefidentifikator::new(21_005).expect("valid"),
antwortcode: "A03".into(),
grund: None,
message_ref: MessageRef::new("PM-1"),
},
BillingCommand::ReceiveIftsta {
pid: Pruefidentifikator::new(IFTSTA_ABWEISUNG_PID).expect("valid"),
version: v(1),
datenstatus: None,
abweisungsgrund: Some("MaBiS-ZP nicht aktiv".into()),
message_ref: MessageRef::new("AB-1"),
},
]);
let v = state.data().unwrap().version(&v(1)).unwrap();
assert!(v.pruefergebnis.is_none(), "the check no longer stands");
assert_eq!(
v.pruefmitteilung_abgewiesen.as_deref(),
Some("MaBiS-ZP nicht aktiv")
);
}
#[test]
fn an_abweisung_without_a_reason_is_refused() {
let state = run(vec![receive(1, true)]);
let cmd = BillingCommand::ReceiveIftsta {
pid: Pruefidentifikator::new(IFTSTA_ABWEISUNG_PID).expect("valid"),
version: v(1),
datenstatus: None,
abweisungsgrund: None,
message_ref: MessageRef::new("AB-1"),
};
assert!(MabisBillingWorkflow::handle(&state, cmd).is_err());
}
#[test]
fn the_highest_abrechnungsrelevante_version_settles() {
let ds = |n: u32, s: Datenstatus| BillingCommand::ReceiveIftsta {
pid: Pruefidentifikator::new(21_004).expect("valid"),
version: v(n),
datenstatus: Some(s),
abweisungsgrund: None,
message_ref: MessageRef::new(format!("DS-{n}")),
};
let state = run(vec![
receive(1, true),
ds(1, Datenstatus::Abrechnungsdaten),
receive(2, false),
ds(2, Datenstatus::Pruefdaten),
receive(3, false),
ds(3, Datenstatus::Abrechnungsdaten),
]);
let d = state.data().unwrap();
assert_eq!(
d.abrechnungsrelevante_version().map(|r| &r.version),
Some(&v(3)),
"V2 is only Prüfdaten, so V3 settles — not the highest version overall"
);
}
#[test]
fn a_closed_window_takes_no_further_version() {
let state = run(vec![
receive(1, true),
BillingCommand::CloseClearing {
lauf: crate::fristen::Abrechnungslauf::Bka,
},
]);
assert_eq!(state.status_str(), "Geschlossen");
assert!(MabisBillingWorkflow::handle(&state, receive(2, false)).is_err());
}
#[test]
fn a_datenstatus_still_lands_after_the_window_closed() {
let state = run(vec![
receive(1, true),
BillingCommand::CloseClearing {
lauf: crate::fristen::Abrechnungslauf::Bka,
},
]);
let evt = BillingEvent::DatenstatusReceived {
version: v(1),
datenstatus: Datenstatus::AbgerechneteDaten,
pid: Pruefidentifikator::new(21_004).expect("valid"),
message_ref: MessageRef::new("DS-final"),
};
let state = MabisBillingWorkflow::apply(state, &evt);
assert!(
state
.data()
.unwrap()
.version(&v(1))
.unwrap()
.datenstatus
.unwrap()
.ist_abgerechnet()
);
}
#[test]
fn closing_twice_is_a_no_op() {
let state = run(vec![
receive(1, true),
BillingCommand::CloseClearing {
lauf: crate::fristen::Abrechnungslauf::Bka,
},
]);
let out = MabisBillingWorkflow::handle(
&state,
BillingCommand::CloseClearing {
lauf: crate::fristen::Abrechnungslauf::Kbka,
},
)
.expect("idempotent");
assert!(out.events.is_empty());
}
#[test]
fn korrekturbedarf_tracks_only_the_highest_version() {
let state = run(vec![
receive(1, true),
BillingCommand::SendPruefmitteilung {
version: v(1),
pid: Pruefidentifikator::new(21_005).expect("valid"),
antwortcode: "A02".into(),
grund: Some("Abweichung".into()),
message_ref: MessageRef::new("PM-1"),
},
]);
assert_eq!(state.data().unwrap().offener_korrekturbedarf(), vec![&v(1)]);
let state = {
let out = MabisBillingWorkflow::handle(&state, receive(2, false)).unwrap();
apply_all(state, &out.events)
};
assert!(state.data().unwrap().offener_korrekturbedarf().is_empty());
}
#[test]
fn no_command_is_accepted_before_the_first_version() {
for cmd in [
BillingCommand::SendPruefmitteilung {
version: v(1),
pid: Pruefidentifikator::new(21_005).expect("valid"),
antwortcode: "A03".into(),
grund: None,
message_ref: MessageRef::new("PM-1"),
},
BillingCommand::ReceiveIftsta {
pid: Pruefidentifikator::new(21_004).expect("valid"),
version: v(1),
datenstatus: Some(Datenstatus::Pruefdaten),
abweisungsgrund: None,
message_ref: MessageRef::new("DS-1"),
},
] {
assert!(MabisBillingWorkflow::handle(&BillingState::New, cmd).is_err());
}
}
}