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
+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)