use time::{Date, Month, macros::date};
pub const BILAREM_WIRKSAM: Date = date!(2026 - 07 - 01);
pub const MABIS_ANLAGE1_KAP17_ENDE: Date = date!(2026 - 09 - 30);
pub const PAUSCHAL_ABRECHNUNG_ENDE: Date = date!(2028 - 12 - 31);
pub const SPITZ_WAHL_FRIST: Date = date!(2028 - 11 - 30);
pub const MIGRATION_SOLL_ZIEL: Date = date!(2031 - 01 - 01);
pub const PROGNOSEMODELL_ENDE: Date = date!(2031 - 12 - 31);
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Bilanzierungsmodell {
Planwertmodell,
Prognosemodell,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Abrechnungsverfahren {
Spitz,
VereinfachteSpitz,
Pauschal,
}
impl Abrechnungsverfahren {
#[must_use]
pub fn admissible(self, date: Date, grandfathered: bool, modell: Bilanzierungsmodell) -> bool {
match self {
Self::Spitz | Self::VereinfachteSpitz => true,
Self::Pauschal => {
grandfathered
&& date <= PAUSCHAL_ABRECHNUNG_ENDE
&& modell == Bilanzierungsmodell::Prognosemodell
}
}
}
#[must_use]
pub const fn post_pauschal_default() -> Self {
Self::VereinfachteSpitz
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum MigrationError {
#[error("Wirksamkeitsdatum {0} ist kein Quartalsbeginn (01.01/01.04/01.07/01.10)")]
KeinQuartalsbeginn(Date),
#[error("Ankündigungsfrist unterschritten: {notice} → {effective} (< 6 Monate)")]
FristUnterschritten {
notice: Date,
effective: Date,
},
#[error("Rückkehr vom Planwert- ins Prognosemodell ist unzulässig")]
KeinWegZurueck,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct Zuordnungsmitteilung {
pub sr_id: String,
pub redispatch_bilanzkreis: String,
pub mitteilungsdatum: Date,
pub wirksam_ab: Date,
}
impl Zuordnungsmitteilung {
pub fn validate(&self, current: Bilanzierungsmodell) -> Result<(), MigrationError> {
if current == Bilanzierungsmodell::Planwertmodell {
return Err(MigrationError::KeinWegZurueck);
}
let d = self.wirksam_ab;
let is_quarter_start = d.day() == 1
&& matches!(
d.month(),
Month::January | Month::April | Month::July | Month::October
);
if !is_quarter_start {
return Err(MigrationError::KeinQuartalsbeginn(d));
}
let mut y = self.mitteilungsdatum.year();
let mut m = self.mitteilungsdatum.month() as u8 + 6;
if m > 12 {
m -= 12;
y += 1;
}
let month = Month::try_from(m).expect("1..=12");
let day = self.mitteilungsdatum.day().min(month.length(y));
let earliest = Date::from_calendar_date(y, month, day).expect("valid date");
if d < earliest {
return Err(MigrationError::FristUnterschritten {
notice: self.mitteilungsdatum,
effective: d,
});
}
Ok(())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
pub enum AbstimmungError {
#[error(
"Abstimmungsfenster geschlossen: Maßnahme endete {massnahme_ende}, \
Frist lief am {frist} ab (BilAReM Kap. 6.4.3)"
)]
FensterGeschlossen {
massnahme_ende: Date,
frist: Date,
},
}
pub fn abstimmung_zulaessig(massnahme_ende: Date, heute: Date) -> Result<(), AbstimmungError> {
let frist = crate::fristen::ausfallarbeit_endet_am(massnahme_ende);
if heute > frist {
return Err(AbstimmungError::FensterGeschlossen {
massnahme_ende,
frist,
});
}
Ok(())
}
#[must_use]
pub fn neue_sr_mitteilung_spaetestens(
geplante_inbetriebnahme: Date,
information_vollstaendig_am: Date,
kalender: mako_fristen::HolidayCalendar,
) -> Date {
let rechtzeitig = mako_fristen::sub_werktage(
geplante_inbetriebnahme,
crate::fristen::PLANWERT_NEUE_SR_INFORMATION_WERKTAGE,
kalender,
);
if information_vollstaendig_am <= rechtzeitig {
mako_fristen::sub_werktage(
geplante_inbetriebnahme,
crate::fristen::PLANWERT_NEUE_SR_MITTEILUNG_WERKTAGE,
kalender,
)
} else {
mako_fristen::add_werktage(
information_vollstaendig_am,
crate::fristen::PLANWERT_NEUE_SR_MITTEILUNG_WERKTAGE,
kalender,
)
}
}
#[cfg(test)]
mod tests {
use super::*;
use time::macros::date;
fn mitteilung(notice: Date, effective: Date) -> Zuordnungsmitteilung {
Zuordnungsmitteilung {
sr_id: "SR-1".into(),
redispatch_bilanzkreis: "11XRD-NB-00001-L".into(),
mitteilungsdatum: notice,
wirksam_ab: effective,
}
}
#[test]
fn migration_requires_quarter_boundary() {
let m = mitteilung(date!(2026 - 08 - 01), date!(2027 - 05 - 01));
assert_eq!(
m.validate(Bilanzierungsmodell::Prognosemodell),
Err(MigrationError::KeinQuartalsbeginn(date!(2027 - 05 - 01)))
);
}
#[test]
fn migration_requires_six_months_notice() {
let m = mitteilung(date!(2026 - 08 - 15), date!(2027 - 01 - 01));
assert!(matches!(
m.validate(Bilanzierungsmodell::Prognosemodell),
Err(MigrationError::FristUnterschritten { .. })
));
let ok = mitteilung(date!(2026 - 08 - 15), date!(2027 - 04 - 01));
assert_eq!(ok.validate(Bilanzierungsmodell::Prognosemodell), Ok(()));
}
#[test]
fn migration_is_one_way() {
let m = mitteilung(date!(2026 - 08 - 01), date!(2027 - 04 - 01));
assert_eq!(
m.validate(Bilanzierungsmodell::Planwertmodell),
Err(MigrationError::KeinWegZurueck)
);
}
#[test]
fn pauschal_only_for_grandfathered_prognose_tr_until_2028() {
use Abrechnungsverfahren as A;
use Bilanzierungsmodell as B;
assert!(A::Pauschal.admissible(date!(2028 - 12 - 31), true, B::Prognosemodell));
assert!(!A::Pauschal.admissible(date!(2029 - 01 - 01), true, B::Prognosemodell));
assert!(!A::Pauschal.admissible(date!(2027 - 01 - 01), false, B::Prognosemodell));
assert!(!A::Pauschal.admissible(date!(2027 - 01 - 01), true, B::Planwertmodell));
assert!(A::Spitz.admissible(date!(2032 - 01 - 01), false, B::Planwertmodell));
assert!(A::VereinfachteSpitz.admissible(date!(2029 - 01 - 01), true, B::Prognosemodell));
assert_eq!(A::post_pauschal_default(), A::VereinfachteSpitz);
}
#[test]
fn the_ausfallarbeit_window_is_a_hard_stop() {
let ende = date!(2026 - 01 - 20);
assert!(abstimmung_zulaessig(ende, date!(2026 - 04 - 30)).is_ok());
assert!(matches!(
abstimmung_zulaessig(ende, date!(2026 - 05 - 01)),
Err(AbstimmungError::FensterGeschlossen { .. })
));
}
#[test]
fn late_information_moves_the_new_sr_deadline_instead_of_removing_it() {
use mako_fristen::HolidayCalendar::BdewMaKo;
let ibn = date!(2027 - 03 - 15);
let rechtzeitig = neue_sr_mitteilung_spaetestens(ibn, date!(2027 - 01 - 04), BdewMaKo);
assert_eq!(
rechtzeitig,
mako_fristen::sub_werktage(ibn, 5, BdewMaKo),
"the ordinary case is anchored on the Inbetriebnahme"
);
assert!(rechtzeitig < ibn);
let spaet = neue_sr_mitteilung_spaetestens(ibn, date!(2027 - 03 - 13), BdewMaKo);
assert_eq!(
spaet,
mako_fristen::add_werktage(date!(2027 - 03 - 13), 5, BdewMaKo)
);
assert!(spaet > ibn);
}
}