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:
@@ -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