use time::Date;
use crate::zeitreihen::{Familie, Kategorie, Zeitreihe};
pub const KALENDER: mako_fristen::HolidayCalendar = mako_fristen::HolidayCalendar::BdewMaKo;
pub const BIKO_WEITERLEITUNG_WERKTAGE: u32 = 1;
pub const BIKO_DATENSTATUS_WERKTAGE: u32 = 1;
pub const CLEARING_ENDE_LABEL: &str = "mabis-clearingfenster-ende";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Abrechnungslauf {
Bka,
Kbka,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Phase {
Vorlaufend,
Erstaufschlag,
Clearing,
ZwischenLaeufen,
Kbka,
Geschlossen,
}
impl Phase {
#[must_use]
pub fn ist_erstaufschlag(self) -> bool {
self == Self::Erstaufschlag
}
#[must_use]
pub fn nimmt_versionen_an(self) -> bool {
matches!(self, Self::Erstaufschlag | Self::Clearing | Self::Kbka)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct Fenster {
pub von: Date,
pub bis: Date,
}
impl Fenster {
#[must_use]
pub fn enthaelt(self, date: Date) -> bool {
self.von <= date && date <= self.bis
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Bilanzierungsmonat {
monatsende: Date,
}
impl Bilanzierungsmonat {
#[must_use]
pub fn new(monatsende: Date) -> Self {
Self { monatsende }
}
#[must_use]
pub fn enthaltend(tag: Date) -> Self {
let letzter = time::util::days_in_month(tag.month(), tag.year());
Self::new(
Date::from_calendar_date(tag.year(), tag.month(), letzter)
.expect("last day of a real month"),
)
}
#[must_use]
pub fn monatsende(self) -> Date {
self.monatsende
}
#[must_use]
pub fn werktag(self, n: u32) -> Date {
mako_fristen::add_werktage(self.monatsende, n, KALENDER)
}
#[must_use]
pub fn monatsende_nach(self, n: u32) -> Date {
let mut d = self.monatsende;
for _ in 0..n {
d = d
.replace_day(1)
.expect("day 1 is valid in every month")
.checked_add(time::Duration::days(32))
.expect("date overflow")
.replace_day(1)
.expect("day 1 is valid in every month");
let letzter = time::util::days_in_month(d.month(), d.year());
d = d.replace_day(letzter).expect("last day of a real month");
}
d
}
#[must_use]
pub fn werktag_im_monat(self, monat: u32, n: u32) -> Date {
let vormonatsende = self.monatsende_nach(monat.saturating_sub(1));
mako_fristen::add_werktage(vormonatsende, n, KALENDER)
}
#[must_use]
pub fn erstaufschlag(self, zeitreihe: Zeitreihe, lauf: Abrechnungslauf) -> Option<Fenster> {
if lauf != Abrechnungslauf::Bka {
return None;
}
let bis = match tabellenzeile(zeitreihe)? {
Zeile::BgSzr => 10,
Zeile::BkSzr => 12,
Zeile::Dzue => return None,
Zeile::TaeglicheAauez => {
return Some(Fenster {
von: self.monatsende.next_day()?,
bis: self.monatsende.next_day()?,
});
}
};
Some(Fenster {
von: self.werktag(1),
bis: self.werktag(bis),
})
}
#[must_use]
pub fn clearing(self, zeitreihe: Zeitreihe, lauf: Abrechnungslauf) -> Option<Fenster> {
let zeile = tabellenzeile(zeitreihe)?;
Some(match (zeile, lauf) {
(Zeile::BgSzr, Abrechnungslauf::Bka) => Fenster {
von: self.werktag(11),
bis: self.werktag(30),
},
(Zeile::BkSzr, Abrechnungslauf::Bka) => Fenster {
von: self.werktag(13),
bis: self.werktag(30),
},
(Zeile::Dzue, Abrechnungslauf::Bka) => Fenster {
von: self.werktag(31),
bis: self.werktag(34),
},
(Zeile::BgSzr | Zeile::BkSzr, Abrechnungslauf::Kbka) => Fenster {
von: self.werktag(31),
bis: self.monatsende_nach(7),
},
(Zeile::Dzue, Abrechnungslauf::Kbka) => Fenster {
von: self.werktag_im_monat(8, 1),
bis: self.werktag_im_monat(8, 8),
},
(Zeile::TaeglicheAauez, _) => return None,
})
}
#[must_use]
pub fn vorlaeufige_bilanzierung(self, lauf: Abrechnungslauf) -> Stichtag {
match lauf {
Abrechnungslauf::Bka => Stichtag {
faellig: self.werktag(18),
datenstand: self.werktag(15),
},
Abrechnungslauf::Kbka => Stichtag {
faellig: self.werktag_im_monat(5, 8),
datenstand: self.monatsende_nach(4),
},
}
}
#[must_use]
pub fn abrechnungsrelevante_bilanzierung(self, lauf: Abrechnungslauf) -> Stichtag {
match lauf {
Abrechnungslauf::Bka => Stichtag {
faellig: self.werktag(42),
datenstand: self.werktag(30),
},
Abrechnungslauf::Kbka => Stichtag {
faellig: self.monatsende_nach(8),
datenstand: self.monatsende_nach(7),
},
}
}
#[must_use]
pub fn phase(self, zeitreihe: Zeitreihe, date: Date) -> Phase {
if date <= self.monatsende {
return Phase::Vorlaufend;
}
let erstaufschlag = self.erstaufschlag(zeitreihe, Abrechnungslauf::Bka);
let bka = self.clearing(zeitreihe, Abrechnungslauf::Bka);
let kbka = self.clearing(zeitreihe, Abrechnungslauf::Kbka);
if erstaufschlag.is_some_and(|f| ab_dem_vortag(f, Some(self.monatsende)).enthaelt(date)) {
return Phase::Erstaufschlag;
}
if bka.is_some_and(|f| ab_dem_vortag(f, erstaufschlag.map(|e| e.bis)).enthaelt(date)) {
return Phase::Clearing;
}
if kbka.is_some_and(|f| ab_dem_vortag(f, bka.map(|c| c.bis)).enthaelt(date)) {
return Phase::Kbka;
}
match (bka.map(|f| f.bis), kbka.map(|f| f.von)) {
(Some(ende), Some(start)) if date > ende && date < start => Phase::ZwischenLaeufen,
_ => Phase::Geschlossen,
}
}
}
fn ab_dem_vortag(fenster: Fenster, vorheriges_ende: Option<Date>) -> Fenster {
match vorheriges_ende {
Some(ende)
if fenster.von == mako_fristen::add_werktage(ende, 1, KALENDER)
&& ende < fenster.von =>
{
Fenster {
von: ende.next_day().unwrap_or(fenster.von),
bis: fenster.bis,
}
}
_ => fenster,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct Stichtag {
pub faellig: Date,
pub datenstand: Date,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Zeile {
BgSzr,
BkSzr,
Dzue,
TaeglicheAauez,
}
fn tabellenzeile(zeitreihe: Zeitreihe) -> Option<Zeile> {
match (zeitreihe.familie(), zeitreihe.kategorie()) {
(Familie::Nzr, _) => Some(Zeile::BgSzr),
(Familie::BgSzr, Some(Kategorie::B)) => Some(Zeile::BgSzr),
(Familie::BkSzr, Some(Kategorie::A | Kategorie::B)) => Some(Zeile::BkSzr),
(Familie::Dzue, _) => Some(Zeile::Dzue),
(Familie::Aauez | Familie::LfAaszr, _) => Some(Zeile::BkSzr),
(Familie::TaeglicheAauez, _) => Some(Zeile::TaeglicheAauez),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::zeitreihen::Familie;
use time::Month;
fn d(y: i32, m: Month, day: u8) -> Date {
Date::from_calendar_date(y, m, day).expect("valid date")
}
fn bg() -> Zeitreihe {
Zeitreihe::new(Familie::BgSzr, Some(Kategorie::B)).unwrap()
}
fn bk() -> Zeitreihe {
Zeitreihe::new(Familie::BkSzr, Some(Kategorie::B)).unwrap()
}
fn dzue() -> Zeitreihe {
Zeitreihe::new(Familie::Dzue, None).unwrap()
}
#[test]
fn enthaltend_finds_the_month_end() {
assert_eq!(
Bilanzierungsmonat::enthaltend(d(2026, Month::February, 9)).monatsende(),
d(2026, Month::February, 28)
);
assert_eq!(
Bilanzierungsmonat::enthaltend(d(2028, Month::February, 1)).monatsende(),
d(2028, Month::February, 29)
);
}
#[test]
fn bg_and_bk_erstaufschlag_windows_differ_by_two_werktage() {
let m = Bilanzierungsmonat::enthaltend(d(2026, Month::September, 15));
let gebiet = m.erstaufschlag(bg(), Abrechnungslauf::Bka).unwrap();
let kreis = m.erstaufschlag(bk(), Abrechnungslauf::Bka).unwrap();
assert_eq!(gebiet.von, kreis.von, "both open on the 1. WT");
assert_eq!(gebiet.bis, m.werktag(10));
assert_eq!(kreis.bis, m.werktag(12));
assert_ne!(gebiet.bis, kreis.bis, "10. WT vs 12. WT");
}
#[test]
fn clearing_starts_the_werktag_after_the_erstaufschlag_closes() {
let m = Bilanzierungsmonat::enthaltend(d(2026, Month::September, 15));
for z in [bg(), bk()] {
let e = m.erstaufschlag(z, Abrechnungslauf::Bka).unwrap();
let c = m.clearing(z, Abrechnungslauf::Bka).unwrap();
assert_eq!(
c.von,
mako_fristen::add_werktage(e.bis, 1, KALENDER),
"no gap and no overlap between the two windows for {z}"
);
assert_eq!(c.bis, m.werktag(30), "both close on the 30. WT");
}
}
#[test]
fn kbka_opens_on_the_31st_werktag_and_closes_at_the_end_of_month_seven() {
let m = Bilanzierungsmonat::enthaltend(d(2026, Month::January, 10));
let k = m.clearing(bg(), Abrechnungslauf::Kbka).unwrap();
assert_eq!(k.von, m.werktag(31));
assert_eq!(
k.bis,
d(2026, Month::August, 31),
"Ende 7. Monat nach Januar"
);
}
#[test]
fn dzue_has_no_erstaufschlag_and_its_own_windows() {
let m = Bilanzierungsmonat::enthaltend(d(2026, Month::January, 10));
assert!(m.erstaufschlag(dzue(), Abrechnungslauf::Bka).is_none());
let bka = m.clearing(dzue(), Abrechnungslauf::Bka).unwrap();
assert_eq!(bka.von, m.werktag(31));
assert_eq!(bka.bis, m.werktag(34));
let kbka = m.clearing(dzue(), Abrechnungslauf::Kbka).unwrap();
assert_eq!(kbka.von, d(2026, Month::September, 1));
assert_eq!(kbka.bis, d(2026, Month::September, 10));
}
#[test]
fn abrechnungsstichtage_match_tabelle_2() {
let m = Bilanzierungsmonat::enthaltend(d(2026, Month::January, 10));
let vor = m.vorlaeufige_bilanzierung(Abrechnungslauf::Bka);
assert_eq!(vor.faellig, m.werktag(18));
assert_eq!(vor.datenstand, m.werktag(15));
let end = m.abrechnungsrelevante_bilanzierung(Abrechnungslauf::Bka);
assert_eq!(end.faellig, m.werktag(42));
assert_eq!(end.datenstand, m.werktag(30));
let kvor = m.vorlaeufige_bilanzierung(Abrechnungslauf::Kbka);
assert_eq!(kvor.datenstand, d(2026, Month::May, 31), "Ende 4. Monat");
let kend = m.abrechnungsrelevante_bilanzierung(Abrechnungslauf::Kbka);
assert_eq!(kend.faellig, d(2026, Month::September, 30), "Ende 8. Monat");
assert_eq!(kend.datenstand, d(2026, Month::August, 31), "Ende 7. Monat");
}
#[test]
fn monatsende_nach_survives_short_months() {
let m = Bilanzierungsmonat::new(d(2026, Month::January, 31));
assert_eq!(m.monatsende_nach(1), d(2026, Month::February, 28));
assert_eq!(m.monatsende_nach(2), d(2026, Month::March, 31));
let leap = Bilanzierungsmonat::new(d(2028, Month::January, 31));
assert_eq!(leap.monatsende_nach(1), d(2028, Month::February, 29));
}
#[test]
fn phase_separates_erstaufschlag_from_clearing() {
let m = Bilanzierungsmonat::enthaltend(d(2026, Month::September, 15));
assert_eq!(m.phase(bg(), m.monatsende()), Phase::Vorlaufend);
assert_eq!(m.phase(bg(), m.werktag(1)), Phase::Erstaufschlag);
assert_eq!(m.phase(bg(), m.werktag(10)), Phase::Erstaufschlag);
assert_eq!(m.phase(bg(), m.werktag(11)), Phase::Clearing);
assert_eq!(m.phase(bk(), m.werktag(11)), Phase::Erstaufschlag);
assert_eq!(m.phase(bk(), m.werktag(12)), Phase::Erstaufschlag);
assert_eq!(m.phase(bk(), m.werktag(13)), Phase::Clearing);
}
#[test]
fn phase_closes_after_the_kbka() {
let m = Bilanzierungsmonat::enthaltend(d(2026, Month::January, 10));
assert_eq!(m.phase(bg(), m.werktag(31)), Phase::Kbka);
assert_eq!(m.phase(bg(), d(2026, Month::August, 31)), Phase::Kbka);
assert_eq!(
m.phase(bg(), d(2026, Month::September, 1)),
Phase::Geschlossen
);
}
#[test]
fn a_non_werktag_between_two_windows_belongs_to_the_next_one() {
for z in [bg(), bk()] {
for monat in [
Month::January,
Month::April,
Month::July,
Month::September,
Month::December,
] {
let m = Bilanzierungsmonat::enthaltend(d(2026, monat, 15));
let mut tag = m.monatsende().next_day().unwrap();
let ende = m.clearing(z, Abrechnungslauf::Kbka).unwrap().bis;
while tag <= ende {
assert!(
m.phase(z, tag).nimmt_versionen_an(),
"{z} {monat:?}: {tag} lies between the 1. WT and the close of \
the KBKA and must take a version, got {:?}",
m.phase(z, tag)
);
tag = tag.next_day().unwrap();
}
}
}
}
#[test]
fn the_weekend_after_a_window_closes_belongs_to_the_next_phase() {
for jahr in 2026..2031 {
for monat in [Month::January, Month::May, Month::August] {
let m = Bilanzierungsmonat::enthaltend(d(jahr, monat, 15));
let zehnter = m.werktag(10);
let elfter = m.werktag(11);
if elfter == zehnter.next_day().unwrap() {
continue; }
let dazwischen = zehnter.next_day().unwrap();
assert_eq!(
m.phase(bg(), dazwischen),
Phase::Clearing,
"{jahr}-{monat:?}: {dazwischen} follows the 10. WT, so the \
Erstaufschlagsrecht has passed"
);
assert!(!m.phase(bg(), dazwischen).ist_erstaufschlag());
}
}
}
#[test]
fn the_taegliche_aauez_stays_on_the_calendar_day() {
let taeglich = Zeitreihe::new(Familie::TaeglicheAauez, None).unwrap();
let m = Bilanzierungsmonat::enthaltend(d(2026, Month::October, 15));
let folgetag = m.monatsende().next_day().unwrap();
assert_eq!(folgetag.weekday(), time::Weekday::Sunday);
assert_eq!(m.phase(taeglich, folgetag), Phase::Erstaufschlag);
assert_eq!(
m.phase(taeglich, folgetag.next_day().unwrap()),
Phase::Geschlossen,
"the obligation is one day wide"
);
}
#[test]
fn dzue_has_a_gap_between_its_two_runs() {
let m = Bilanzierungsmonat::enthaltend(d(2026, Month::January, 10));
let after_bka = mako_fristen::add_werktage(m.werktag(34), 1, KALENDER);
assert_eq!(m.phase(dzue(), after_bka), Phase::ZwischenLaeufen);
}
#[test]
fn the_ausfallarbeit_series_have_their_own_table() {
let m = Bilanzierungsmonat::enthaltend(d(2026, Month::September, 15));
for familie in [Familie::Aauez, Familie::LfAaszr] {
let z = Zeitreihe::new(familie, None).unwrap();
assert_eq!(
m.erstaufschlag(z, Abrechnungslauf::Bka),
m.erstaufschlag(bk(), Abrechnungslauf::Bka),
"{z}"
);
assert_eq!(
m.clearing(z, Abrechnungslauf::Bka),
m.clearing(bk(), Abrechnungslauf::Bka),
"{z}"
);
}
let taeglich = Zeitreihe::new(Familie::TaeglicheAauez, None).unwrap();
let fenster = m.erstaufschlag(taeglich, Abrechnungslauf::Bka).unwrap();
assert_eq!(fenster.von, fenster.bis, "Folgetag is one day wide");
assert_eq!(fenster.von, m.monatsende().next_day().unwrap());
assert!(m.clearing(taeglich, Abrechnungslauf::Bka).is_none());
assert!(m.clearing(taeglich, Abrechnungslauf::Kbka).is_none());
}
#[test]
fn series_without_a_tabelle_2_row_are_always_closed() {
let m = Bilanzierungsmonat::enthaltend(d(2026, Month::January, 10));
for z in [
Zeitreihe::new(Familie::BgSzr, Some(Kategorie::C)).unwrap(),
Zeitreihe::new(Familie::LfSzr, Some(Kategorie::A)).unwrap(),
Zeitreihe::new(Familie::Abrechnungssummenzeitreihe, None).unwrap(),
] {
assert!(m.erstaufschlag(z, Abrechnungslauf::Bka).is_none(), "{z}");
assert!(m.clearing(z, Abrechnungslauf::Bka).is_none(), "{z}");
assert_eq!(m.phase(z, m.werktag(5)), Phase::Geschlossen, "{z}");
}
}
#[test]
fn nzr_shares_the_bg_szr_row() {
let m = Bilanzierungsmonat::enthaltend(d(2026, Month::January, 10));
let nzr = Zeitreihe::new(Familie::Nzr, None).unwrap();
assert_eq!(
m.erstaufschlag(nzr, Abrechnungslauf::Bka),
m.erstaufschlag(bg(), Abrechnungslauf::Bka)
);
}
#[test]
fn phase_predicates_agree_with_the_variants() {
assert!(Phase::Erstaufschlag.ist_erstaufschlag());
assert!(!Phase::Clearing.ist_erstaufschlag());
for p in [Phase::Erstaufschlag, Phase::Clearing, Phase::Kbka] {
assert!(p.nimmt_versionen_an(), "{p:?}");
}
for p in [
Phase::Vorlaufend,
Phase::ZwischenLaeufen,
Phase::Geschlossen,
] {
assert!(!p.nimmt_versionen_an(), "{p:?}");
}
}
}