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
+8
View File
@@ -70,6 +70,14 @@ class Company(Base):
# Mobile-Konfiguration
mobile_stamping_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
# Urlaubsanspruch-Konfiguration
# Basis-Jahresurlaub (Vollzeit) für neu angelegte Urlaubskonten.
vacation_default_days: Mapped[int] = mapped_column(Integer, nullable=False, default=30)
# Anteilige Berechnung im Ein-/Austrittsjahr (Zwölftel-Regel).
vacation_prorate_first_year: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
# Teilzeit: Anspruch aus Arbeitstagen/Woche des WorkSchedule ableiten (opt-in).
vacation_from_schedule: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
# Freizeitausgleich-Konfiguration
overtime_overdraft_allowed: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
overtime_warning_threshold_hours: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
+6 -2
View File
@@ -1,9 +1,9 @@
import uuid
import enum
from datetime import datetime
from datetime import datetime, date
from typing import TYPE_CHECKING
from sqlalchemy import Boolean, DateTime, Enum, ForeignKey, String, Text, func
from sqlalchemy import Boolean, Date, DateTime, Enum, ForeignKey, String, Text, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
@@ -50,6 +50,10 @@ class User(Base):
UUID(as_uuid=True), ForeignKey("work_schedules.id", ondelete="SET NULL")
)
# Ein-/Austrittsdatum Basis für anteilige Urlaubsberechnung (Zwölftel-Regel)
entry_date: Mapped[date | None] = mapped_column(Date)
exit_date: Mapped[date | None] = mapped_column(Date)
# Kiosk auth
kiosk_pin_hash: Mapped[str | None] = mapped_column(Text)
kiosk_qr_token: Mapped[str | None] = mapped_column(Text, unique=True)
+4 -12
View File
@@ -39,18 +39,8 @@ router = APIRouter(tags=["Abwesenheiten"])
def _carryover_expiry(company: Company, year: int) -> tuple[date | None, bool]:
"""Verfallsdatum für Resturlaub berechnen.
Gibt (expires_at, is_expired) zurück. None wenn kein Verfall konfiguriert."""
s = company.settings or {}
month = s.get("carryover_expires_month")
day = 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:
return None, False
"""Verfallsdatum für Resturlaub. Delegiert an den Service (eine Quelle der Wahrheit)."""
return absence_service._carryover_expired(company, year)
_admin_roles = (UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN)
_manager_roles = (UserRole.MANAGER, UserRole.HR, UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN)
@@ -142,6 +132,7 @@ async def get_own_balance(
expires_at, expired = _carryover_expiry(company, year) if company else (None, False)
return VacationBalanceOut.model_validate(balance).model_copy(update={
"pending_days": pending,
"available_days": absence_service.effective_available(balance, expired),
"carried_over_expires_at": expires_at,
"carried_over_expired": expired,
})
@@ -165,6 +156,7 @@ async def get_balance_for_user(
expires_at, expired = _carryover_expiry(company, year) if company else (None, False)
return VacationBalanceOut.model_validate(balance).model_copy(update={
"pending_days": pending,
"available_days": absence_service.effective_available(balance, expired),
"carried_over_expires_at": expires_at,
"carried_over_expired": expired,
})
+2
View File
@@ -188,6 +188,8 @@ class VacationBalanceOut(BaseModel):
used_days: int
total_days: int
remaining_days: int
# Effektiv verfügbar: berücksichtigt verfallenen Resturlaub (zur Laufzeit befüllt).
available_days: int = 0
pending_days: float = 0
# Resturlaub-Verfall (wird zur Laufzeit befüllt, nicht in DB)
carried_over_expires_at: date | None = None
+6
View File
@@ -47,6 +47,9 @@ class CompanyOut(BaseModel):
kiosk_track_current_user: bool = True
kiosk_heartbeat_interval_sec: int = 30
public_stamp_enabled: bool = False
vacation_default_days: int = 30
vacation_prorate_first_year: bool = True
vacation_from_schedule: bool = False
class PublicStampTokenStatus(BaseModel):
@@ -82,6 +85,9 @@ class CompanyUpdate(BaseModel):
kiosk_track_current_user: bool | None = None
kiosk_heartbeat_interval_sec: int | None = Field(None, ge=10, le=120)
public_stamp_enabled: bool | None = None
vacation_default_days: int | None = Field(None, ge=0, le=365)
vacation_prorate_first_year: bool | None = None
vacation_from_schedule: bool | None = None
class DepartmentOut(BaseModel):
+6 -1
View File
@@ -1,5 +1,5 @@
import uuid
from datetime import datetime
from datetime import datetime, date
from pydantic import BaseModel, EmailStr, Field, model_validator
@@ -27,6 +27,9 @@ class UserOut(BaseModel):
kuerzel: str | None = None
personnel_number: str | None = None
can_manual_time_entry: bool = False
work_schedule_id: uuid.UUID | None = None
entry_date: date | None = None
exit_date: date | None = None
class UserUpdate(BaseModel):
@@ -39,6 +42,8 @@ class UserUpdate(BaseModel):
personnel_number: str | None = Field(None, max_length=50, pattern=PERSONNEL_NUMBER_PATTERN)
can_manual_time_entry: bool | None = None
is_active: bool | None = None
entry_date: date | None = None
exit_date: date | None = None
class InviteRequest(BaseModel):
+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:
@@ -0,0 +1,36 @@
"""Vacation entitlement: pro-rata + part-time config (agent-11 PR2)
Revision ID: 0036
Revises: 0035
Create Date: 2026-06-23
- users.entry_date / exit_date (Basis für anteilige Urlaubsberechnung, Zwölftel)
- companies.vacation_default_days (Vollzeit-Grundurlaub für neue Konten)
- companies.vacation_prorate_first_year (anteilig im Ein-/Austrittsjahr)
- companies.vacation_from_schedule (Teilzeit-Anspruch aus WorkSchedule ableiten)
Resturlaub-Verfall bleibt in companies.settings (carryover_expires_month/day),
wird in PR2 nur scharf geschaltet (effektiv verfügbarer Saldo) keine Spalte nötig.
"""
from alembic import op
revision = "0036"
down_revision = "0035"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute("ALTER TABLE users ADD COLUMN IF NOT EXISTS entry_date DATE")
op.execute("ALTER TABLE users ADD COLUMN IF NOT EXISTS exit_date DATE")
op.execute("ALTER TABLE companies ADD COLUMN IF NOT EXISTS vacation_default_days INTEGER NOT NULL DEFAULT 30")
op.execute("ALTER TABLE companies ADD COLUMN IF NOT EXISTS vacation_prorate_first_year BOOLEAN NOT NULL DEFAULT TRUE")
op.execute("ALTER TABLE companies ADD COLUMN IF NOT EXISTS vacation_from_schedule BOOLEAN NOT NULL DEFAULT FALSE")
def downgrade() -> None:
op.execute("ALTER TABLE companies DROP COLUMN IF EXISTS vacation_from_schedule")
op.execute("ALTER TABLE companies DROP COLUMN IF EXISTS vacation_prorate_first_year")
op.execute("ALTER TABLE companies DROP COLUMN IF EXISTS vacation_default_days")
op.execute("ALTER TABLE users DROP COLUMN IF EXISTS exit_date")
op.execute("ALTER TABLE users DROP COLUMN IF EXISTS entry_date")
+67
View File
@@ -625,3 +625,70 @@ async def test_absence_comments(
assert lst.status_code == 200
bodies = [c["body"] for c in lst.json()]
assert "Bitte zuegig pruefen" in bodies
# ── agent-11 PR2: Urlaubsanspruch (Verfall · Pro-rata · Teilzeit) ──────────────
def test_effective_available_not_expired():
import uuid as _u
from app.services.absence_service import AbsenceService
from app.models.vacation_balance import VacationBalance
b = VacationBalance(user_id=_u.uuid4(), year=2026, entitled_days=30, special_days=0, carried_over=5, used_days=3)
assert AbsenceService.effective_available(b, False) == 32 # 30 + 5 - 3
assert AbsenceService.effective_available(b, True) == 30 # Resturlaub verfällt (3 < 5)
def test_effective_available_expired_overdraw():
import uuid as _u
from app.services.absence_service import AbsenceService
from app.models.vacation_balance import VacationBalance
b = VacationBalance(user_id=_u.uuid4(), year=2026, entitled_days=30, special_days=0, carried_over=5, used_days=8)
assert AbsenceService.effective_available(b, True) == 27 # 30 - max(0, 8 - 5)
@pytest.mark.asyncio
async def test_company_vacation_settings_roundtrip(client: AsyncClient, abs_headers):
r = await client.patch("/api/v1/companies/me", json={
"vacation_default_days": 28, "vacation_prorate_first_year": True, "vacation_from_schedule": False,
}, headers=abs_headers)
assert r.status_code == 200, r.text
assert r.json()["vacation_default_days"] == 28
await client.patch("/api/v1/companies/me", json={"vacation_default_days": 30}, headers=abs_headers)
@pytest.mark.asyncio
async def test_prorate_entry_year(client: AsyncClient, abs_headers):
inv = await client.post("/api/v1/users/invite", json={
"first_name": "Pro", "last_name": "Rata", "email": "prorata@absenceag.de",
"role": "EMPLOYEE", "initial_password": "Secret123",
}, headers=abs_headers)
uid = inv.json()["id"]
yr = date.today().year
await client.patch(f"/api/v1/users/{uid}", json={"entry_date": f"{yr}-07-01"}, headers=abs_headers)
bal = await client.get(f"/api/v1/absences/balance/{uid}?year={yr}", headers=abs_headers)
assert bal.status_code == 200, bal.text
assert bal.json()["entitled_days"] == 15 # 30 * 6/12 (Eintritt Juli)
@pytest.mark.asyncio
async def test_part_time_entitlement_from_schedule(client: AsyncClient, abs_headers):
sched = await client.post("/api/v1/time/schedules", json={
"name": "Teilzeit 3 Tage", "mon_h": 8, "tue_h": 8, "wed_h": 8,
"thu_h": 0, "fri_h": 0, "valid_from": "2026-01-01",
}, headers=abs_headers)
assert sched.status_code == 201, sched.text
sid = sched.json()["id"]
await client.patch("/api/v1/companies/me", json={
"vacation_from_schedule": True, "vacation_default_days": 30,
}, headers=abs_headers)
inv = await client.post("/api/v1/users/invite", json={
"first_name": "Teil", "last_name": "Zeit", "email": "teilzeit@absenceag.de",
"role": "EMPLOYEE", "initial_password": "Secret123",
}, headers=abs_headers)
uid = inv.json()["id"]
await client.patch(f"/api/v1/users/{uid}", json={"work_schedule_id": sid}, headers=abs_headers)
yr = date.today().year
bal = await client.get(f"/api/v1/absences/balance/{uid}?year={yr}", headers=abs_headers)
assert bal.status_code == 200, bal.text
assert bal.json()["entitled_days"] == 18 # 30 * 3/5
await client.patch("/api/v1/companies/me", json={"vacation_from_schedule": False}, headers=abs_headers)