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:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user