feat: Überstunden-Kappung + Jahresverfall pro Firma konfigurierbar

Backend:
- Company: overtime_cap_hours, overtime_expiry_enabled/month/day,
  overtime_max_carryover_hours
- OvertimeBalance: last_expiry_applied_at
- Migration 0031: neue Spalten in companies + overtime_balances
- _recalculate_overtime_balance: Kappung direkt nach Berechnung
- apply_overtime_expiry_if_needed(): lazy Verfall beim Balance-Abruf
- GET /absences/overtime-balance: prüft + wendet Verfall automatisch an
- POST /absences/overtime-balance/apply-expiry: manueller Trigger (Admin)

Frontend:
- CompanySettingsPage: neuer Block 'Überstunden-Konto'
  - Toggle Kappungsgrenze + Stunden-Input
  - Toggle Jahresverfall + Stichtag (Tag/Monat) + max. Übertrag
  - 'Verfall anwenden'-Button für Admins

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-25 22:48:30 +02:00
parent 23b45881a1
commit 23ba7f1762
8 changed files with 364 additions and 1 deletions
+54
View File
@@ -242,9 +242,63 @@ async def _recalculate_overtime_balance(
bal.total_hours = Decimal(str(round(overtime, 2)))
bal.last_calculated = datetime.utcnow()
# Kappung anwenden
company = await db.get(Company, user.company_id)
if company and company.overtime_cap_hours is not None:
cap = Decimal(str(company.overtime_cap_hours))
if bal.total_hours > cap:
bal.total_hours = cap
return bal
async def apply_overtime_expiry_if_needed(
bal: OvertimeBalance,
company, # Company model
db: AsyncSession,
) -> bool:
"""
Prüft ob der Überstunden-Verfall angewendet werden muss und tut es ggf.
Gibt True zurück wenn Verfall angewendet wurde.
"""
if not company or not company.overtime_expiry_enabled:
return False
today = date.today()
try:
expiry_this_year = date(today.year, company.overtime_expiry_month, company.overtime_expiry_day)
except ValueError:
# Ungültiges Datum (z.B. 31. Februar) überspringen
return False
expiry_last_year_year = today.year - 1
try:
expiry_last_year = date(expiry_last_year_year, company.overtime_expiry_month, company.overtime_expiry_day)
except ValueError:
expiry_last_year = None
last_applicable_expiry = expiry_this_year if today >= expiry_this_year else expiry_last_year
if last_applicable_expiry is None:
return False
# Schon angewendet?
if bal.last_expiry_applied_at:
applied_date = bal.last_expiry_applied_at.date() if hasattr(bal.last_expiry_applied_at, 'date') else bal.last_expiry_applied_at
if applied_date >= last_applicable_expiry:
return False
# Verfall anwenden: available_hours auf max_carryover kappen
available = bal.total_hours - bal.taken_hours
if company.overtime_max_carryover_hours is not None:
max_carry = Decimal(str(company.overtime_max_carryover_hours))
if available > max_carry:
bal.total_hours = bal.taken_hours + max_carry
bal.last_expiry_applied_at = datetime.utcnow()
return True
def _check_arbzg_day(entry: TimeEntry) -> list[str]:
"""ArbZG-Prüfung für einen einzelnen Zeiteintrag."""
if entry.end_time is None: