automapper-validation 0.1.64

AHB condition expression parsing, evaluation, and EDIFACT validation
Documentation
//! German Werktag (working day) arithmetic for BDEW AHB conditions.
//!
//! "Werktage" under GPKE/GeLi-Gas are calendar days that are **not**
//! Saturdays, Sundays, or a holiday per the BDEW Feiertagskalender
//! (see `Feiertagskalender_GPKE-GeLiGas_<FV>_V_x_y_<yyyymmdd>.pdf`).
//!
//! BDEW rule: "*Wenn in einem Bundesland ein Tag als Feiertag ausgewiesen
//! ist, dann gilt dieser Feiertag bundesweit. Generell gelten der 24.12.
//! und der 31.12. als Feiertage.*" — the union of every Land's public
//! holidays plus 24.12. and 31.12. as BDEW-specific non-working days.
//!
//! | # | Name | Date |
//! |---|------|------|
//! | 1 | Neujahr | 1. Januar |
//! | 2 | Hl. Drei Könige | 6. Januar |
//! | 3 | Internationaler Frauentag | 8. März |
//! | 4 | Karfreitag | Ostersonntag − 2 |
//! | 5 | Ostermontag | Ostersonntag + 1 |
//! | 6 | Tag der Arbeit | 1. Mai |
//! | 7 | Christi Himmelfahrt | Ostersonntag + 39 |
//! | 8 | Pfingstmontag | Ostersonntag + 50 |
//! | 9 | Fronleichnam | Ostersonntag + 60 |
//! | 10 | Mariä Himmelfahrt | 15. August |
//! | 11 | Weltkindertag | 20. September |
//! | 12 | Tag der Deutschen Einheit | 3. Oktober |
//! | 13 | Reformationstag | 31. Oktober |
//! | 14 | Allerheiligen | 1. November |
//! | 15 | Buß- und Bettag | Mittwoch vor dem 23. November |
//! | 16 | Heiligabend | 24. Dezember |
//! | 17 | 1. Weihnachtstag | 25. Dezember |
//! | 18 | 2. Weihnachtstag | 26. Dezember |
//! | 19 | Silvester | 31. Dezember |
//!
//! Easter (Ostersonntag) is computed via the Anonymous Gregorian algorithm
//! (Gauss 1816 / Meeus 1991).
//!
//! **Note on historical dates:** Frauentag (Berlin ab 2019, MV ab 2023) and
//! Weltkindertag (Thüringen ab 2019) are applied unconditionally; the
//! validator is intended for current-era energy-market traffic, not
//! pre-2019 archival data.

use chrono::{Datelike, Duration, NaiveDate, Weekday};

/// Compute Gregorian Easter Sunday for the given year.
///
/// Anonymous Gregorian algorithm. Valid for years ≥ 1583.
pub fn compute_easter(year: i32) -> NaiveDate {
    let a = year % 19;
    let b = year / 100;
    let c = year % 100;
    let d = b / 4;
    let e = b % 4;
    let f = (b + 8) / 25;
    let g = (b - f + 1) / 3;
    let h = (19 * a + b - d - g + 15) % 30;
    let i = c / 4;
    let k = c % 4;
    let l = (32 + 2 * e + 2 * i - h - k) % 7;
    let m = (a + 11 * h + 22 * l) / 451;
    let month = ((h + l - 7 * m + 114) / 31) as u32;
    let day = (((h + l - 7 * m + 114) % 31) + 1) as u32;
    NaiveDate::from_ymd_opt(year, month, day).expect("easter date is always valid")
}

/// Compute Buß- und Bettag (Wednesday before 23. November) for the given year.
pub fn buss_und_bettag(year: i32) -> NaiveDate {
    let nov_23 = NaiveDate::from_ymd_opt(year, 11, 23).expect("23.11. is always valid");
    // Days to subtract to reach the Wednesday strictly before Nov 23. If Nov 23
    // is itself a Wednesday, go back a full week.
    let days_back = match nov_23.weekday() {
        Weekday::Wed => 7,
        Weekday::Thu => 1,
        Weekday::Fri => 2,
        Weekday::Sat => 3,
        Weekday::Sun => 4,
        Weekday::Mon => 5,
        Weekday::Tue => 6,
    };
    nov_23 - Duration::days(days_back)
}

/// Is `date` a holiday per the BDEW GPKE/GeLi-Gas Feiertagskalender?
pub fn is_german_holiday(date: NaiveDate) -> bool {
    let (year, month, day) = (date.year(), date.month(), date.day());
    // Fixed-date holidays (union of all Länder + BDEW 24.12. / 31.12.).
    if matches!(
        (month, day),
        (1, 1)     // Neujahr
            | (1, 6)   // Hl. Drei Könige (BW, BY, ST)
            | (3, 8)   // Internationaler Frauentag (BE, MV)
            | (5, 1)   // Tag der Arbeit
            | (8, 15)  // Mariä Himmelfahrt (BY, SL)
            | (9, 20)  // Weltkindertag (TH)
            | (10, 3)  // Tag der Deutschen Einheit
            | (10, 31) // Reformationstag (BB, MV, SN, ST, TH, HB, NDS, SH, HH)
            | (11, 1)  // Allerheiligen (BW, BY, NRW, RP, SL)
            | (12, 24) // Heiligabend (BDEW)
            | (12, 25) // 1. Weihnachtstag
            | (12, 26) // 2. Weihnachtstag
            | (12, 31) // Silvester (BDEW)
    ) {
        return true;
    }
    // Easter-relative holidays.
    let easter = compute_easter(year);
    if date == easter - Duration::days(2)
        || date == easter + Duration::days(1)
        || date == easter + Duration::days(39)
        || date == easter + Duration::days(50)
        || date == easter + Duration::days(60)
    {
        return true;
    }
    // Buß- und Bettag (SN) — Wednesday before 23. November.
    if month == 11 && date == buss_und_bettag(year) {
        return true;
    }
    false
}

/// Is `date` a Werktag (Mon–Fri and not a federal holiday)?
pub fn is_werktag(date: NaiveDate) -> bool {
    !matches!(date.weekday(), Weekday::Sat | Weekday::Sun) && !is_german_holiday(date)
}

/// Count Werktage strictly after `from` up to and including `to`.
///
/// Interpretation follows the BDEW convention "N Werktage nach X":
/// `werktage_between(X, X + N WT) == N`. The starting day `from` itself is
/// not counted.
///
/// Sign reflects direction: positive when `to > from`, negative when
/// `to < from`, zero when equal.
pub fn werktage_between(from: NaiveDate, to: NaiveDate) -> i32 {
    if from == to {
        return 0;
    }
    let (start, end, sign) = if from < to {
        (from, to, 1)
    } else {
        (to, from, -1)
    };
    let mut count = 0;
    let mut cur = start;
    while cur < end {
        cur = cur
            .succ_opt()
            .expect("succ_opt within validated date range");
        if is_werktag(cur) {
            count += 1;
        }
    }
    count * sign
}

/// Parse a CCYYMMDD date prefix (ignoring anything after the 8th character).
/// Used for DTM values in formats 102 / 203 / 303.
pub fn parse_ccyymmdd_prefix(value: &str) -> Option<NaiveDate> {
    let s = value.trim();
    if s.len() < 8 {
        return None;
    }
    let year: i32 = s.get(0..4).and_then(|v| v.parse().ok())?;
    let month: u32 = s.get(4..6).and_then(|v| v.parse().ok())?;
    let day: u32 = s.get(6..8).and_then(|v| v.parse().ok())?;
    NaiveDate::from_ymd_opt(year, month, day)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_easter_2024() {
        // 2024-03-31
        assert_eq!(
            compute_easter(2024),
            NaiveDate::from_ymd_opt(2024, 3, 31).unwrap()
        );
    }

    #[test]
    fn test_easter_2025() {
        // 2025-04-20
        assert_eq!(
            compute_easter(2025),
            NaiveDate::from_ymd_opt(2025, 4, 20).unwrap()
        );
    }

    #[test]
    fn test_easter_2026() {
        // 2026-04-05
        assert_eq!(
            compute_easter(2026),
            NaiveDate::from_ymd_opt(2026, 4, 5).unwrap()
        );
    }

    #[test]
    fn test_fixed_federal_holidays_2026() {
        assert!(is_german_holiday(NaiveDate::from_ymd_opt(2026, 1, 1).unwrap()));
        assert!(is_german_holiday(NaiveDate::from_ymd_opt(2026, 5, 1).unwrap()));
        assert!(is_german_holiday(
            NaiveDate::from_ymd_opt(2026, 10, 3).unwrap()
        ));
        assert!(is_german_holiday(
            NaiveDate::from_ymd_opt(2026, 12, 25).unwrap()
        ));
        assert!(is_german_holiday(
            NaiveDate::from_ymd_opt(2026, 12, 26).unwrap()
        ));
    }

    #[test]
    fn test_state_level_holidays_included_bundesweit_2026() {
        // BDEW rule: Feiertag in any Bundesland → bundesweit.
        assert!(is_german_holiday(NaiveDate::from_ymd_opt(2026, 1, 6).unwrap())); // Hl. Drei Könige
        assert!(is_german_holiday(NaiveDate::from_ymd_opt(2026, 3, 8).unwrap())); // Frauentag
        assert!(is_german_holiday(NaiveDate::from_ymd_opt(2026, 8, 15).unwrap())); // Mariä Himmelfahrt
        assert!(is_german_holiday(NaiveDate::from_ymd_opt(2026, 9, 20).unwrap())); // Weltkindertag
        assert!(is_german_holiday(NaiveDate::from_ymd_opt(2026, 10, 31).unwrap())); // Reformationstag
        assert!(is_german_holiday(NaiveDate::from_ymd_opt(2026, 11, 1).unwrap())); // Allerheiligen
    }

    #[test]
    fn test_bdew_specific_holidays_2026() {
        // Heiligabend and Silvester are BDEW-specific non-working days.
        assert!(is_german_holiday(NaiveDate::from_ymd_opt(2026, 12, 24).unwrap()));
        assert!(is_german_holiday(NaiveDate::from_ymd_opt(2026, 12, 31).unwrap()));
    }

    #[test]
    fn test_fronleichnam_2026() {
        // Easter 2026-04-05 + 60 days = 2026-06-04 (Thursday).
        assert!(is_german_holiday(NaiveDate::from_ymd_opt(2026, 6, 4).unwrap()));
    }

    #[test]
    fn test_buss_und_bettag() {
        // Nov 23 weekday → expected Buß- und Bettag per BDEW calendar.
        // 2024: Nov 23 is Sat → Mi 2024-11-20
        assert_eq!(buss_und_bettag(2024), NaiveDate::from_ymd_opt(2024, 11, 20).unwrap());
        // 2025: Nov 23 is Sun → Mi 2025-11-19
        assert_eq!(buss_und_bettag(2025), NaiveDate::from_ymd_opt(2025, 11, 19).unwrap());
        // 2026: Nov 23 is Mon → Mi 2026-11-18
        assert_eq!(buss_und_bettag(2026), NaiveDate::from_ymd_opt(2026, 11, 18).unwrap());
        // 2022: Nov 23 is Wed → Mi 2022-11-16 (go back a full week)
        assert_eq!(buss_und_bettag(2022), NaiveDate::from_ymd_opt(2022, 11, 16).unwrap());
    }

    #[test]
    fn test_buss_und_bettag_is_holiday() {
        assert!(is_german_holiday(NaiveDate::from_ymd_opt(2026, 11, 18).unwrap()));
        // The surrounding Wednesday is NOT a holiday.
        assert!(!is_german_holiday(NaiveDate::from_ymd_opt(2026, 11, 11).unwrap()));
        assert!(!is_german_holiday(NaiveDate::from_ymd_opt(2026, 11, 25).unwrap()));
    }

    #[test]
    fn test_easter_holidays_2026() {
        // Easter 2026 = 2026-04-05
        // Karfreitag = 2026-04-03
        // Ostermontag = 2026-04-06
        // Christi Himmelfahrt = 2026-05-14
        // Pfingstmontag = 2026-05-25
        assert!(is_german_holiday(NaiveDate::from_ymd_opt(2026, 4, 3).unwrap()));
        assert!(is_german_holiday(NaiveDate::from_ymd_opt(2026, 4, 6).unwrap()));
        assert!(is_german_holiday(
            NaiveDate::from_ymd_opt(2026, 5, 14).unwrap()
        ));
        assert!(is_german_holiday(
            NaiveDate::from_ymd_opt(2026, 5, 25).unwrap()
        ));
    }

    #[test]
    fn test_non_holidays() {
        assert!(!is_german_holiday(
            NaiveDate::from_ymd_opt(2026, 1, 2).unwrap()
        ));
        assert!(!is_german_holiday(
            NaiveDate::from_ymd_opt(2026, 7, 15).unwrap()
        ));
    }

    #[test]
    fn test_werktag_skips_weekend() {
        // 2026-04-04 is a Saturday, 2026-04-05 a Sunday (also Ostersonntag).
        assert!(!is_werktag(
            NaiveDate::from_ymd_opt(2026, 4, 4).unwrap()
        ));
        assert!(!is_werktag(
            NaiveDate::from_ymd_opt(2026, 4, 5).unwrap()
        ));
    }

    #[test]
    fn test_werktag_skips_holiday() {
        // 2026-04-03 Karfreitag is a Friday but a holiday.
        assert!(!is_werktag(
            NaiveDate::from_ymd_opt(2026, 4, 3).unwrap()
        ));
    }

    #[test]
    fn test_werktage_between_simple() {
        // Mon 2026-01-12 → Fri 2026-01-16: 4 Werktage (no holidays in range).
        // (2026-01-05..09 avoided because Hl. Drei Könige = Tue 2026-01-06.)
        let from = NaiveDate::from_ymd_opt(2026, 1, 12).unwrap();
        let to = NaiveDate::from_ymd_opt(2026, 1, 16).unwrap();
        assert_eq!(werktage_between(from, to), 4);
    }

    #[test]
    fn test_werktage_between_skips_hl_drei_koenige() {
        // Mon 2026-01-05 → Fri 2026-01-09: Tue 01-06 is Hl. Drei Könige.
        // Counted: Wed, Thu, Fri = 3.
        let from = NaiveDate::from_ymd_opt(2026, 1, 5).unwrap();
        let to = NaiveDate::from_ymd_opt(2026, 1, 9).unwrap();
        assert_eq!(werktage_between(from, to), 3);
    }

    #[test]
    fn test_werktage_between_across_weekend() {
        // Fri 2026-01-09 → Mon 2026-01-12 = 1 Werktag (Mon).
        let from = NaiveDate::from_ymd_opt(2026, 1, 9).unwrap();
        let to = NaiveDate::from_ymd_opt(2026, 1, 12).unwrap();
        assert_eq!(werktage_between(from, to), 1);
    }

    #[test]
    fn test_werktage_between_skips_easter_holidays() {
        // Wed 2026-04-01 → Tue 2026-04-07.
        // Skipped: Fri 04-03 Karfreitag, Sat 04-04, Sun 04-05, Mon 04-06 Ostermontag.
        // Counted: Thu 04-02, Tue 04-07 = 2.
        let from = NaiveDate::from_ymd_opt(2026, 4, 1).unwrap();
        let to = NaiveDate::from_ymd_opt(2026, 4, 7).unwrap();
        assert_eq!(werktage_between(from, to), 2);
    }

    #[test]
    fn test_werktage_between_10_wt_after_monday() {
        // Mon 2026-01-12 + 10 WT = Mon 2026-01-26 (no holidays in this range).
        let from = NaiveDate::from_ymd_opt(2026, 1, 12).unwrap();
        let to = NaiveDate::from_ymd_opt(2026, 1, 26).unwrap();
        assert_eq!(werktage_between(from, to), 10);
    }

    #[test]
    fn test_werktage_between_negative() {
        let from = NaiveDate::from_ymd_opt(2026, 1, 26).unwrap();
        let to = NaiveDate::from_ymd_opt(2026, 1, 12).unwrap();
        assert_eq!(werktage_between(from, to), -10);
    }

    #[test]
    fn test_werktage_between_same_day() {
        let d = NaiveDate::from_ymd_opt(2026, 1, 5).unwrap();
        assert_eq!(werktage_between(d, d), 0);
    }

    #[test]
    fn test_parse_ccyymmdd_prefix_from_format_102() {
        assert_eq!(
            parse_ccyymmdd_prefix("20260115"),
            Some(NaiveDate::from_ymd_opt(2026, 1, 15).unwrap())
        );
    }

    #[test]
    fn test_parse_ccyymmdd_prefix_from_format_303() {
        // CCYYMMDDHHMM + TZ suffix (e.g., "202601151200+00") → date part only.
        assert_eq!(
            parse_ccyymmdd_prefix("202601151200+00"),
            Some(NaiveDate::from_ymd_opt(2026, 1, 15).unwrap())
        );
    }

    #[test]
    fn test_parse_ccyymmdd_prefix_rejects_short() {
        assert!(parse_ccyymmdd_prefix("2026").is_none());
        assert!(parse_ccyymmdd_prefix("").is_none());
    }

    #[test]
    fn test_parse_ccyymmdd_prefix_rejects_invalid() {
        assert!(parse_ccyymmdd_prefix("20261315").is_none());
    }
}