feat: agent-12 – Zwei-Stufen-Genehmigung für Abwesenheiten
Optionale zweistufige Freigabe (Feature-Parität mit Urlaubsverwaltung,
Second-Stage-Authority), ohne SSO:
- Firmen-Opt-in companies.two_stage_approval_enabled + two_stage_min_days
(nur Anträge ab X Arbeitstagen brauchen Stufe 2; 0 = alle).
- Ablauf PENDING → FIRST_APPROVED → APPROVED: erste Stufe durch Manager-Rollen,
finale Stufe nur HR/Admin und zwingend eine ANDERE Person als Stufe 1.
- Urlaubs-/FZA-Abzug, CalDAV-Sync und Vertreter-Mail erst bei finaler Genehmigung.
Ablehnen in beiden Stufen möglich; Eigentümer darf FIRST_APPROVED noch stornieren.
- pending_days, Kalender und Reminder-Digest berücksichtigen FIRST_APPROVED.
- Neuer Status-Wert + absences.first_approved_by; System-Kommentar bei Stufe 1.
Frontend: CompanySettingsPage (Toggle + Schwellwert), AbsencesPage
("Endgültig genehmigen"/Ablehnen für HR/Admin ≠ Erstgenehmiger, Status-Badge).
Migration 0038. 190/190 Tests grün. Deployed 137 + 164.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -692,3 +692,69 @@ async def test_part_time_entitlement_from_schedule(client: AsyncClient, abs_head
|
||||
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)
|
||||
|
||||
|
||||
# ── agent-12: Zwei-Stufen-Genehmigung ─────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_two_stage_approval_flow(
|
||||
client: AsyncClient, abs_headers, abs_approver_headers, vacation_type_id
|
||||
):
|
||||
await client.patch("/api/v1/companies/me",
|
||||
json={"two_stage_approval_enabled": True}, headers=abs_headers)
|
||||
inv = await client.post("/api/v1/users/invite", json={
|
||||
"first_name": "Two", "last_name": "Stage", "email": "twostage@absenceag.de",
|
||||
"role": "EMPLOYEE", "initial_password": "Secret123",
|
||||
}, headers=abs_headers)
|
||||
assert inv.status_code == 201, inv.text
|
||||
login = await client.post("/api/v1/auth/login", json={
|
||||
"email": "twostage@absenceag.de", "password": "Secret123"})
|
||||
emp_h = {"Authorization": f"Bearer {login.json()['access_token']}"}
|
||||
|
||||
start = _future_monday(17)
|
||||
cr = await client.post("/api/v1/absences/", json={
|
||||
"type_id": str(vacation_type_id), "start_date": str(start),
|
||||
"end_date": str(start + timedelta(days=2)),
|
||||
}, headers=emp_h)
|
||||
aid = cr.json()["id"]
|
||||
|
||||
# Erste Stufe: Approver A → first_approved (noch kein Abzug)
|
||||
r1 = await client.post(f"/api/v1/absences/{aid}/approve", headers=abs_approver_headers)
|
||||
assert r1.status_code == 200, r1.text
|
||||
assert r1.json()["status"] == "first_approved"
|
||||
|
||||
# Gleicher Genehmiger darf die finale Stufe nicht erteilen
|
||||
same = await client.post(f"/api/v1/absences/{aid}/approve", headers=abs_approver_headers)
|
||||
assert same.status_code == 409
|
||||
|
||||
# Finale Stufe: anderer Admin → approved
|
||||
r2 = await client.post(f"/api/v1/absences/{aid}/approve", headers=abs_headers)
|
||||
assert r2.status_code == 200, r2.text
|
||||
assert r2.json()["status"] == "approved"
|
||||
|
||||
await client.patch("/api/v1/companies/me",
|
||||
json={"two_stage_approval_enabled": False}, headers=abs_headers)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_two_stage_reject_at_first_stage(
|
||||
client: AsyncClient, abs_headers, abs_approver_headers, vacation_type_id
|
||||
):
|
||||
await client.patch("/api/v1/companies/me",
|
||||
json={"two_stage_approval_enabled": True}, headers=abs_headers)
|
||||
login = await client.post("/api/v1/auth/login", json={
|
||||
"email": "twostage@absenceag.de", "password": "Secret123"})
|
||||
emp_h = {"Authorization": f"Bearer {login.json()['access_token']}"}
|
||||
start = _future_monday(19)
|
||||
cr = await client.post("/api/v1/absences/", json={
|
||||
"type_id": str(vacation_type_id), "start_date": str(start),
|
||||
"end_date": str(start + timedelta(days=1)),
|
||||
}, headers=emp_h)
|
||||
aid = cr.json()["id"]
|
||||
await client.post(f"/api/v1/absences/{aid}/approve", headers=abs_approver_headers)
|
||||
rej = await client.post(f"/api/v1/absences/{aid}/reject",
|
||||
json={"rejection_reason": "Doch nicht"}, headers=abs_headers)
|
||||
assert rej.status_code == 200, rej.text
|
||||
assert rej.json()["status"] == "rejected"
|
||||
await client.patch("/api/v1/companies/me",
|
||||
json={"two_stage_approval_enabled": False}, headers=abs_headers)
|
||||
|
||||
Reference in New Issue
Block a user