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>
This commit is contained in:
@@ -0,0 +1,174 @@
|
||||
"""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()}
|
||||
Reference in New Issue
Block a user