feat: agent-11 PR1 – Vertretung, Storno-Re-Genehmigung, Kommentare

Abwesenheits-Modul abgerundet (Feature-Parität mit Urlaubsverwaltung):

- Vertretung: Overlap-Warnung beim Anlegen, E-Mail an Vertretung bei
  Genehmigung, GET /absences/?as_substitute=true, neuer schlanker
  GET /users/colleagues (alle Rollen, RLS-gefenced) für die Auswahl;
  Vertreter-Dropdown + Anzeige in der Liste.
- Stornierung mit Re-Genehmigung: neuer Status CANCELLATION_REQUESTED,
  POST /absences/{id}/request-cancellation; Manager genehmigt/lehnt über
  bestehende approve/reject ab (Urlaub + FZA-Rückbuchung via _apply_cancellation).
- Kommentare: Model AbsenceComment (company_id-RLS), GET/POST comments,
  System-Kommentare bei Statuswechsel, AbsenceCommentsModal.
- Fix: CalDAV fire-and-forget nutzte die Request-Session weiter (in Tests
  geteilt -> "another operation in progress"); jetzt sync_*_bg mit eigener
  Session + RLS-Bypass.

Migration 0035. 178/178 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 11:47:47 +02:00
co-authored by Claude Opus 4.8
parent be22a51805
commit 6fa66b8c13
18 changed files with 793 additions and 27 deletions
+144
View File
@@ -481,3 +481,147 @@ async def test_sick_stats_bradford_factor(client: AsyncClient, abs_headers):
# Bradford-Formel verifizieren
expected = float(row["episodes"]) ** 2 * row["total_days"]
assert abs(row["bradford_factor"] - expected) < 0.001
# ── agent-11 PR1: Vertretung · Stornierung · Kommentare ────────────────────────
@pytest_asyncio.fixture(scope="session", loop_scope="session")
async def substitute_user_id(client: AsyncClient, abs_headers):
"""Mitarbeiter, der als Vertretung eingetragen werden kann."""
resp = await client.post("/api/v1/users/invite", json={
"first_name": "Sub", "last_name": "Stitute",
"email": "sub@absenceag.de", "role": "EMPLOYEE",
"initial_password": "Secret123",
}, headers=abs_headers)
assert resp.status_code == 201, resp.text
return resp.json()["id"]
def _future_monday(weeks: int) -> date:
return date.today() + timedelta(days=(7 - date.today().weekday()) + 7 * weeks)
@pytest.mark.asyncio
async def test_cancellation_request_flow(
client: AsyncClient, abs_headers, abs_approver_headers, vacation_type_id
):
"""Genehmigten Antrag → Stornoantrag → Manager genehmigt → cancelled + Urlaub zurück."""
start = _future_monday(7)
create = await client.post("/api/v1/absences/", json={
"type_id": str(vacation_type_id),
"start_date": str(start), "end_date": str(start + timedelta(days=4)),
}, headers=abs_headers)
aid = create.json()["id"]
working_days = create.json()["working_days"]
approve = await client.post(f"/api/v1/absences/{aid}/approve", headers=abs_approver_headers)
assert approve.status_code == 200
used_after_approve = (await client.get(
"/api/v1/absences/balance", params={"year": start.year}, headers=abs_headers
)).json()["used_days"]
# Stornoantrag durch Mitarbeiter (Owner)
req = await client.post(
f"/api/v1/absences/{aid}/request-cancellation",
json={"reason": "Plan geaendert"}, headers=abs_headers,
)
assert req.status_code == 200, req.text
assert req.json()["status"] == "cancellation_requested"
# Manager genehmigt die Stornierung
ok = await client.post(f"/api/v1/absences/{aid}/approve", headers=abs_approver_headers)
assert ok.status_code == 200, ok.text
assert ok.json()["status"] == "cancelled"
used_after_cancel = (await client.get(
"/api/v1/absences/balance", params={"year": start.year}, headers=abs_headers
)).json()["used_days"]
assert used_after_cancel == used_after_approve - int(working_days)
@pytest.mark.asyncio
async def test_cancellation_request_rejected_keeps_approved(
client: AsyncClient, abs_headers, abs_approver_headers, vacation_type_id
):
start = _future_monday(9)
create = 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=abs_headers)
aid = create.json()["id"]
await client.post(f"/api/v1/absences/{aid}/approve", headers=abs_approver_headers)
await client.post(f"/api/v1/absences/{aid}/request-cancellation", json={}, headers=abs_headers)
rej = await client.post(
f"/api/v1/absences/{aid}/reject",
json={"rejection_reason": "Vertretung fehlt"}, headers=abs_approver_headers,
)
assert rej.status_code == 200, rej.text
assert rej.json()["status"] == "approved"
@pytest.mark.asyncio
async def test_request_cancellation_requires_approved(
client: AsyncClient, abs_headers, vacation_type_id
):
"""PENDING-Antrag kann nicht zur Stornierung eingereicht werden (nur direkt löschen)."""
start = _future_monday(11)
create = 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=abs_headers)
aid = create.json()["id"]
req = await client.post(f"/api/v1/absences/{aid}/request-cancellation", json={}, headers=abs_headers)
assert req.status_code == 409
@pytest.mark.asyncio
async def test_substitute_filter_and_notification(
client: AsyncClient, abs_headers, abs_approver_headers, vacation_type_id, substitute_user_id
):
"""Antrag mit Vertretung → Vertreter sieht ihn unter ?as_substitute=true."""
start = _future_monday(13)
create = await client.post("/api/v1/absences/", json={
"type_id": str(vacation_type_id),
"start_date": str(start), "end_date": str(start + timedelta(days=2)),
"substitute_id": substitute_user_id,
}, headers=abs_headers)
assert create.status_code == 201, create.text
aid = create.json()["id"]
assert create.json()["substitute_id"] == substitute_user_id
await client.post(f"/api/v1/absences/{aid}/approve", headers=abs_approver_headers)
sub_login = await client.post("/api/v1/auth/login", json={
"email": "sub@absenceag.de", "password": "Secret123",
})
sub_headers = {"Authorization": f"Bearer {sub_login.json()['access_token']}"}
lst = await client.get("/api/v1/absences/?as_substitute=true", headers=sub_headers)
assert lst.status_code == 200, lst.text
assert any(a["id"] == aid for a in lst.json()["items"])
@pytest.mark.asyncio
async def test_absence_comments(
client: AsyncClient, abs_headers, vacation_type_id
):
"""Kommentar posten + System-Kommentar bei Stornoantrag erscheint im Thread."""
start = _future_monday(15)
create = await client.post("/api/v1/absences/", json={
"type_id": str(vacation_type_id),
"start_date": str(start), "end_date": str(start + timedelta(days=1)),
"note": "Brueckentag",
}, headers=abs_headers)
aid = create.json()["id"]
add = await client.post(
f"/api/v1/absences/{aid}/comments",
json={"body": "Bitte zuegig pruefen"}, headers=abs_headers,
)
assert add.status_code == 201, add.text
assert add.json()["is_system"] is False
assert add.json()["author_name"]
lst = await client.get(f"/api/v1/absences/{aid}/comments", headers=abs_headers)
assert lst.status_code == 200
bodies = [c["body"] for c in lst.json()]
assert "Bitte zuegig pruefen" in bodies