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
+32
View File
@@ -210,6 +210,15 @@ async def get_overtime_balance(
)
if bal is None:
return OvertimeBalanceOut(total_hours=0, taken_hours=0, available_hours=0)
# Verfall anwenden wenn nötig
from app.services.report_service import apply_overtime_expiry_if_needed
from app.models.company import Company as CompanyModel
company = await db.get(CompanyModel, current_user.company_id)
changed = await apply_overtime_expiry_if_needed(bal, company, db)
if changed:
await db.commit()
return OvertimeBalanceOut(
total_hours=float(bal.total_hours),
taken_hours=float(bal.taken_hours),
@@ -217,6 +226,29 @@ async def get_overtime_balance(
)
@router.post("/absences/overtime-balance/apply-expiry")
async def apply_overtime_expiry_all(
current_user: User = require_role(UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN),
db: AsyncSession = Depends(get_db),
):
"""Überstunden-Verfall manuell für alle Mitarbeiter der Firma anwenden."""
from sqlalchemy import select as sa_select
from app.models.company import Company as CompanyModel
from app.services.report_service import apply_overtime_expiry_if_needed
company = await db.get(CompanyModel, current_user.company_id)
balances = list(await db.scalars(
sa_select(OvertimeBalance).where(OvertimeBalance.company_id == current_user.company_id)
))
applied_count = 0
for bal in balances:
changed = await apply_overtime_expiry_if_needed(bal, company, db)
if changed:
applied_count += 1
await db.commit()
return {"applied_to": applied_count, "total": len(balances)}
@router.get("/absences/", response_model=AbsenceListResponse)
async def list_absences(
current_user: CurrentUser,