1fedd683e0
Stand: agent-06 (Audit-Log), agent-05 (Krankmeldung), agent-07 Phase 1 (Personalnummer), Busylight-Pull-Integration, TOTP/2FA, Abwesenheiten, Zeiterfassung, Kiosk-Grundgerüst. Migrations 0001–0023 deployed auf 192.168.1.137 + .164. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
175 lines
6.7 KiB
Python
175 lines
6.7 KiB
Python
"""Feiertags-Service: berechnet und befüllt deutsche Feiertage per Bundesland."""
|
||
from __future__ import annotations
|
||
|
||
import uuid
|
||
from datetime import date, timedelta
|
||
|
||
from sqlalchemy import delete, select
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from app.models.public_holiday import PublicHoliday
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Osterformel (Gauß/Anonymous)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _easter(year: int) -> date:
|
||
"""Berechnet Ostersonntag nach der anonymen Gregorianischen Osterformel."""
|
||
a = year % 19
|
||
b = year // 100
|
||
c = year % 100
|
||
d = b // 4
|
||
e = b % 4
|
||
f = (b + 8) // 25
|
||
g = (b - f + 1) // 3
|
||
h = (19 * a + b - d - g + 15) % 30
|
||
i = c // 4
|
||
k = c % 4
|
||
l = (32 + 2 * e + 2 * i - h - k) % 7
|
||
m = (a + 11 * h + 22 * l) // 451
|
||
month = (h + l - 7 * m + 114) // 31
|
||
day = ((h + l - 7 * m + 114) % 31) + 1
|
||
return date(year, month, day)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Feiertage berechnen
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _holidays_for_state(year: int, state: str) -> list[tuple[date, str, bool]]:
|
||
"""
|
||
Gibt Liste von (date, name, is_high_rate) für das Bundesland zurück.
|
||
is_high_rate = True → 150% Zuschlag nach §3b EStG
|
||
"""
|
||
easter = _easter(year)
|
||
holidays: list[tuple[date, str, bool]] = []
|
||
|
||
def add(d: date, name: str, high: bool = False) -> None:
|
||
holidays.append((d, name, high))
|
||
|
||
# ── Bundesweit gültige Feiertage ────────────────────────────────────────
|
||
add(date(year, 1, 1), "Neujahr")
|
||
add(easter - timedelta(days=2), "Karfreitag")
|
||
add(easter, "Ostersonntag")
|
||
add(easter + timedelta(days=1), "Ostermontag")
|
||
add(date(year, 5, 1), "Tag der Arbeit", high=True)
|
||
add(easter + timedelta(days=39), "Christi Himmelfahrt")
|
||
add(easter + timedelta(days=49), "Pfingstsonntag")
|
||
add(easter + timedelta(days=50), "Pfingstmontag")
|
||
add(date(year, 10, 3), "Tag der Deutschen Einheit")
|
||
add(date(year, 12, 25), "1. Weihnachtstag", high=True)
|
||
add(date(year, 12, 26), "2. Weihnachtstag", high=True)
|
||
|
||
# ── Heilige Drei Könige: BY, BW, ST ────────────────────────────────────
|
||
if state in ("BY", "BW", "ST"):
|
||
add(date(year, 1, 6), "Heilige Drei Könige")
|
||
|
||
# ── Frauentag: BE (ab 2019) ─────────────────────────────────────────────
|
||
if state == "BE" and year >= 2019:
|
||
add(date(year, 3, 8), "Internationaler Frauentag")
|
||
|
||
# ── Gründonnerstag: BY (nur Schulen) – nicht als gesetzlicher Feiertag
|
||
|
||
# ── Fronleichnam: BW, BY, HE, NW, RP, SL (+ Teile ST, TH) ─────────────
|
||
if state in ("BW", "BY", "HE", "NW", "RP", "SL"):
|
||
add(easter + timedelta(days=60), "Fronleichnam")
|
||
|
||
# ── Mariä Himmelfahrt: BY (kath. Gemeinden), SL ─────────────────────────
|
||
if state in ("BY", "SL"):
|
||
add(date(year, 8, 15), "Mariä Himmelfahrt")
|
||
|
||
# ── Weltkindertag: TH (ab 2019) ─────────────────────────────────────────
|
||
if state == "TH" and year >= 2019:
|
||
add(date(year, 9, 20), "Weltkindertag")
|
||
|
||
# ── Reformationstag: BB, HB, HH, MV, NI, SH, SN, ST, TH ───────────────
|
||
if state in ("BB", "HB", "HH", "MV", "NI", "SH", "SN", "ST", "TH"):
|
||
add(date(year, 10, 31), "Reformationstag")
|
||
|
||
# ── Allerheiligen: BW, BY, NW, RP, SL ───────────────────────────────────
|
||
if state in ("BW", "BY", "NW", "RP", "SL"):
|
||
add(date(year, 11, 1), "Allerheiligen")
|
||
|
||
# ── Buß- und Bettag: SN ──────────────────────────────────────────────────
|
||
if state == "SN":
|
||
# Mittwoch vor dem 23. November
|
||
nov23 = date(year, 11, 23)
|
||
bbt = nov23 - timedelta(days=(nov23.weekday() + 3) % 7 + 1)
|
||
if bbt.weekday() != 2:
|
||
# Fallback: letzter Mittwoch vor 23.11.
|
||
bbt = nov23 - timedelta(days=(nov23.weekday() - 2) % 7 + 7)
|
||
add(bbt, "Buß- und Bettag")
|
||
|
||
return holidays
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# DB-Funktionen
|
||
# ---------------------------------------------------------------------------
|
||
|
||
async def ensure_holidays_for_year(year: int, state: str, db: AsyncSession) -> int:
|
||
"""
|
||
Stellt sicher dass Feiertage für (year, state) in der DB vorhanden sind.
|
||
Löscht ggf. alte Einträge und schreibt neu.
|
||
Gibt Anzahl geschriebener Einträge zurück.
|
||
"""
|
||
# Löschen falls schon vorhanden (refresh)
|
||
await db.execute(
|
||
delete(PublicHoliday).where(
|
||
PublicHoliday.country == "DE",
|
||
PublicHoliday.state == state,
|
||
PublicHoliday.year == year,
|
||
)
|
||
)
|
||
|
||
holidays = _holidays_for_state(year, state)
|
||
for d, name, high in holidays:
|
||
db.add(PublicHoliday(
|
||
id=uuid.uuid4(),
|
||
country="DE",
|
||
state=state,
|
||
date=d,
|
||
name=name,
|
||
year=year,
|
||
is_high_rate=high,
|
||
))
|
||
|
||
await db.flush()
|
||
return len(holidays)
|
||
|
||
|
||
async def get_holidays_set(
|
||
date_from: date,
|
||
date_to: date,
|
||
state: str,
|
||
db: AsyncSession,
|
||
) -> dict[date, tuple[str, bool]]:
|
||
"""
|
||
Gibt dict {date: (name, is_high_rate)} für den Zeitraum zurück.
|
||
Befüllt fehlende Jahre automatisch.
|
||
"""
|
||
years = set(range(date_from.year, date_to.year + 1))
|
||
|
||
# Prüfen welche Jahre schon in der DB sind
|
||
existing_years_q = await db.execute(
|
||
select(PublicHoliday.year).where(
|
||
PublicHoliday.country == "DE",
|
||
PublicHoliday.state == state,
|
||
).distinct()
|
||
)
|
||
existing_years = {r[0] for r in existing_years_q.all()}
|
||
|
||
for year in years - existing_years:
|
||
await ensure_holidays_for_year(year, state, db)
|
||
|
||
result_q = await db.execute(
|
||
select(PublicHoliday).where(
|
||
PublicHoliday.country == "DE",
|
||
PublicHoliday.state == state,
|
||
PublicHoliday.date >= date_from,
|
||
PublicHoliday.date <= date_to,
|
||
)
|
||
)
|
||
return {h.date: (h.name, h.is_high_rate) for h in result_q.scalars().all()}
|