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:
sysops
2026-05-23 20:03:27 +02:00
commit 1fedd683e0
178 changed files with 29896 additions and 0 deletions
+174
View File
@@ -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()}