Files
sysops 1fedd683e0 Initial commit – TimeMaster Zeiterfassung & HR-Tool
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>
2026-05-23 20:03:27 +02:00

175 lines
6.7 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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()}