feat: agent-11 PR2 – Urlaubsanspruch (Verfall scharf, Pro-rata, Teilzeit)

Korrektheit der Urlaubskonten (Feature-Parität mit Urlaubsverwaltung):

- Verfall scharfgeschaltet: neuer effektiv verfügbarer Saldo (available_days)
  schließt verfallenen, noch nicht verbrauchten Resturlaub aus; Konto-Warnung
  beim Antrag nutzt jetzt available statt remaining. (Verfallsdatum bleibt in
  company.settings, UI bereits vorhanden.)
- Anteilige Berechnung (Zwölftel) im Ein-/Austrittsjahr anhand neuer Felder
  users.entry_date / exit_date; opt-in pro Firma (vacation_prorate_first_year).
- Teilzeit: Anspruch optional aus Arbeitstagen/Woche des WorkSchedule abgeleitet
  (vacation_from_schedule), Basis vacation_default_days.
- _get_or_create_balance berechnet den Grundanspruch jetzt frisch
  (_compute_entitlement); _carryover_expired/effective_available als Service-API,
  Router delegiert.

Frontend: CompanySettingsPage (Jahresurlaub + Pro-rata- und Teilzeit-Toggles),
UsersPage (Ein-/Austrittsdatum im Edit-Modal), AbsencesPage ("Verfügbar" + Hinweis
bei verfallenem Resturlaub).

Migration 0036. 183/183 Tests grün. Deployed auf 137 + 164.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-23 12:03:42 +02:00
co-authored by Claude Opus 4.8
parent 6fa66b8c13
commit e8bed43570
13 changed files with 277 additions and 20 deletions
+77 -3
View File
@@ -1,4 +1,5 @@
import asyncio
import math
from datetime import date, timedelta
from uuid import UUID
@@ -168,10 +169,13 @@ class AbsenceService:
warnings: list[str] = []
if absence_type.deducts_vacation:
balance = await self._get_or_create_balance(current_user.id, data.start_date.year, db)
if balance.remaining_days < working_days:
company = await db.get(Company, current_user.company_id)
_, expired = self._carryover_expired(company, data.start_date.year)
available = self.effective_available(balance, expired)
if available < working_days:
warnings.append(
f"Urlaubskonto reicht möglicherweise nicht aus: "
f"{balance.remaining_days} Tage verfügbar, {working_days} Tage beantragt."
f"{available} Tage verfügbar, {working_days} Tage beantragt."
)
# Überschneidung mit eigenen Abwesenheiten prüfen
@@ -631,7 +635,13 @@ class AbsenceService:
)
)
carried = max(0, prev.remaining_days) if prev else 0
entitled = prev.entitled_days if prev else 30
# Grundanspruch frisch berechnen (Firmen-Default · Teilzeit · Pro-rata).
# Fallback auf Vorjahres-Anspruch bzw. 30, falls keine Konfig greift.
user = await db.get(User, user_id)
company = await db.get(Company, user.company_id) if user and user.company_id else None
entitled = await self._compute_entitlement(user, company, year, db)
if entitled is None:
entitled = prev.entitled_days if prev else 30
balance = VacationBalance(
user_id=user_id,
year=year,
@@ -642,6 +652,70 @@ class AbsenceService:
await db.flush()
return balance
@staticmethod
def _working_days_per_week(schedule: WorkSchedule) -> int:
return sum(
1 for h in [schedule.mon_h, schedule.tue_h, schedule.wed_h,
schedule.thu_h, schedule.fri_h, schedule.sat_h, schedule.sun_h]
if h and h > 0
)
async def _compute_entitlement(
self, user: "User | None", company: "Company | None", year: int, db: AsyncSession
) -> int | None:
"""Grundurlaub für ein neues Konto: Firmen-Default, optional Teilzeit-Skalierung
(Arbeitstage/Woche aus WorkSchedule) und anteilig im Ein-/Austrittsjahr (Zwölftel).
Gibt None zurück, wenn keine Berechnung möglich ist (→ Aufrufer nutzt Fallback)."""
if company is None:
return None
days = float(company.vacation_default_days)
# Teilzeit: Anspruch proportional zu den Arbeitstagen/Woche (5 = Vollzeit)
if company.vacation_from_schedule and user and user.work_schedule_id:
schedule = await db.get(WorkSchedule, user.work_schedule_id)
if schedule:
wd = self._working_days_per_week(schedule)
days = days * wd / 5.0
# Anteilig im Ein-/Austrittsjahr (Zwölftel ab/bis Monat)
if company.vacation_prorate_first_year and user:
months = 12
if user.entry_date:
if user.entry_date.year > year:
months = 0
elif user.entry_date.year == year:
months = min(months, 12 - user.entry_date.month + 1)
if user.exit_date:
if user.exit_date.year < year:
months = 0
elif user.exit_date.year == year:
months = min(months, user.exit_date.month)
days = days * max(0, months) / 12.0
# Kaufmännisch runden
return int(math.floor(days + 0.5))
@staticmethod
def _carryover_expired(company: "Company | None", year: int) -> tuple[date | None, bool]:
"""Verfallsdatum des Resturlaubs aus company.settings. (expires_at, is_expired)."""
s = (company.settings or {}) if company else {}
month, day = s.get("carryover_expires_month"), s.get("carryover_expires_day")
if not month or not day:
return None, False
try:
expires_at = date(year, int(month), int(day))
return expires_at, date.today() > expires_at
except (ValueError, TypeError):
return None, False
@staticmethod
def effective_available(balance: VacationBalance, expired: bool) -> int:
"""Effektiv verfügbare Urlaubstage. Bei verfallenem Resturlaub zählt der noch
nicht verbrauchte Teil des Übertrags nicht mehr mit."""
if expired:
return balance.entitled_days + balance.special_days - max(0, balance.used_days - balance.carried_over)
return balance.remaining_days
async def _deduct_vacation(
self, user_id: UUID, year: int, days: int, db: AsyncSession
) -> None: