use serde::{Deserialize, Serialize};
use time::Date;
use mako_mabis::MabisZaehlpunktId;
use rubo4e::identifiers::{MaloId, MarktpartnerId};
use crate::bg::{VirtualBalancingArea, ist_monatserster};
use crate::error::EmobError;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum Abwicklungsmodell {
Modell1,
Modell2,
}
impl Abwicklungsmodell {
#[must_use]
pub const fn merkmal(self) -> &'static str {
match self {
Self::Modell1 => crate::wire::MERKMAL_MODELL_1,
Self::Modell2 => crate::wire::MERKMAL_MODELL_2,
}
}
#[must_use]
pub const fn bo4e(self) -> rubo4e::current::Abwicklungsmodell {
match self {
Self::Modell1 => rubo4e::current::Abwicklungsmodell::Modell1,
Self::Modell2 => rubo4e::current::Abwicklungsmodell::Modell2,
}
}
#[must_use]
pub fn bo4e_wire(self) -> &'static str {
match self.bo4e() {
rubo4e::current::Abwicklungsmodell::Modell1 => "MODELL_1",
_ => "MODELL_2",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum AccessBasis {
NzrEmobOeffentlich,
IndividuellerNetzzugang,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum MeteringMode {
Zaehlerstandsgang,
Rlm,
Sonstiges,
}
impl MeteringMode {
#[must_use]
pub const fn ist_viertelstundenscharf(self) -> bool {
matches!(self, Self::Zaehlerstandsgang | Self::Rlm)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct Modellwechsel {
pub termin: Date,
}
impl Modellwechsel {
pub fn neu(termin: Date) -> Result<Self, EmobError> {
if !ist_monatserster(termin) {
return Err(EmobError::NotFirstOfMonth {
was: "the Modellwechseltermin",
date: termin,
});
}
Ok(Self { termin })
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Uebergabestelle {
pub malo: MaloId,
pub vnb: MarktpartnerId,
pub basis: AccessBasis,
pub modell: Abwicklungsmodell,
pub zp_ngz: Option<MabisZaehlpunktId>,
pub metering: MeteringMode,
pub eeg_anlage_vorhanden: bool,
}
impl Uebergabestelle {
pub fn anmelden(
&self,
bg: &VirtualBalancingArea,
wechsel: Modellwechsel,
) -> Result<(), EmobError> {
if !self.metering.ist_viertelstundenscharf() {
return Err(EmobError::KeineViertelstundenmessung);
}
if wechsel.termin < bg.valid_from {
return Err(EmobError::AnmeldungVorBgBeginn {
anmeldung: wechsel.termin,
bg_start: bg.valid_from,
});
}
if let Some(ende) = bg.valid_to.filter(|&to| wechsel.termin >= to) {
return Err(EmobError::AnmeldungNachBgEnde {
anmeldung: wechsel.termin,
bg_ende: ende,
});
}
if self.basis == AccessBasis::IndividuellerNetzzugang && self.eeg_anlage_vorhanden {
return Err(EmobError::EegAnlageAusserhalbDesBeschlusses);
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::bg::Regelzone;
use mako_mabis::{BilanzierungsgebietId, BilanzkreisId};
use time::Month;
fn d(y: i32, m: u8, day: u8) -> Date {
Date::from_calendar_date(y, Month::try_from(m).unwrap(), day).unwrap()
}
fn bg() -> VirtualBalancingArea {
VirtualBalancingArea::neu(
BilanzierungsgebietId::new("11YN-0000-0001-Q").unwrap(),
Regelzone::TenneT,
BilanzkreisId::new("11XSUEDWESTSTRO8").unwrap(),
d(2026, 9, 1),
)
.unwrap()
}
fn us(basis: AccessBasis, metering: MeteringMode, eeg: bool) -> Uebergabestelle {
Uebergabestelle {
malo: MaloId::new("51238696781").expect("valid MaLo id"),
vnb: MarktpartnerId::new("9903030000001").expect("valid MP id"),
basis,
modell: Abwicklungsmodell::Modell1,
zp_ngz: None,
metering,
eeg_anlage_vorhanden: eeg,
}
}
#[test]
fn a_modellwechsel_lands_on_the_first() {
assert!(Modellwechsel::neu(d(2026, 11, 1)).is_ok());
assert!(Modellwechsel::neu(d(2026, 11, 2)).is_err());
}
#[test]
fn slp_metering_cannot_carry_the_model() {
let e = us(
AccessBasis::NzrEmobOeffentlich,
MeteringMode::Sonstiges,
false,
)
.anmelden(&bg(), Modellwechsel::neu(d(2026, 11, 1)).unwrap())
.unwrap_err();
assert_eq!(e, EmobError::KeineViertelstundenmessung);
}
#[test]
fn the_anmeldung_cannot_precede_the_bg() {
let e = us(AccessBasis::NzrEmobOeffentlich, MeteringMode::Rlm, false)
.anmelden(&bg(), Modellwechsel::neu(d(2026, 8, 1)).unwrap())
.unwrap_err();
assert!(matches!(e, EmobError::AnmeldungVorBgBeginn { .. }));
}
#[test]
fn an_eeg_plant_blocks_only_the_individual_access_path() {
let wechsel = Modellwechsel::neu(d(2026, 11, 1)).unwrap();
assert_eq!(
us(
AccessBasis::IndividuellerNetzzugang,
MeteringMode::Zaehlerstandsgang,
true
)
.anmelden(&bg(), wechsel)
.unwrap_err(),
EmobError::EegAnlageAusserhalbDesBeschlusses
);
assert!(
us(
AccessBasis::NzrEmobOeffentlich,
MeteringMode::Zaehlerstandsgang,
true
)
.anmelden(&bg(), wechsel)
.is_ok()
);
}
#[test]
fn both_metering_modes_are_accepted() {
let wechsel = Modellwechsel::neu(d(2026, 11, 1)).unwrap();
for m in [MeteringMode::Zaehlerstandsgang, MeteringMode::Rlm] {
assert!(
us(AccessBasis::NzrEmobOeffentlich, m, false)
.anmelden(&bg(), wechsel)
.is_ok()
);
}
}
#[test]
fn the_anmeldung_cannot_land_after_the_bg_has_ended() {
let mut b = bg();
b.beenden(d(2026, 12, 31)).unwrap();
let us = us(AccessBasis::NzrEmobOeffentlich, MeteringMode::Rlm, false);
assert!(
us.anmelden(&b, Modellwechsel::neu(d(2026, 12, 1)).unwrap())
.is_ok(),
"the last month inside the BG still works"
);
let e = us
.anmelden(&b, Modellwechsel::neu(d(2027, 1, 1)).unwrap())
.unwrap_err();
assert!(matches!(e, EmobError::AnmeldungNachBgEnde { .. }), "{e:?}");
}
#[test]
fn the_merkmal_matches_the_wire_module() {
assert_eq!(Abwicklungsmodell::Modell2.merkmal(), "ZF0");
assert_eq!(Abwicklungsmodell::Modell1.merkmal(), "ZE9");
assert_eq!(Abwicklungsmodell::Modell1.merkmal(), "ZE9");
}
#[test]
fn the_bo4e_wire_value_matches_what_rubo4e_serialises() {
for m in [Abwicklungsmodell::Modell1, Abwicklungsmodell::Modell2] {
let json = serde_json::to_string(&m.bo4e()).expect("the BO4E enum serialises");
assert_eq!(
json.trim_matches('"'),
m.bo4e_wire(),
"{m:?}: bo4e_wire disagrees with rubo4e's serde value"
);
}
}
}