feat(overtime): Auszahlungs-Anträge durch Mitarbeiter (opt-in pro Firma)
Security Audit / Node.js Dependency Audit (push) Has been cancelled
Security Audit / Python Dependency Audit (push) Has been cancelled

Mitarbeiter beantragt Überstunden-Auszahlung, HR genehmigt/lehnt ab.
Firmen-Opt-in payout_request_enabled (Default aus - nur HR-Direktbuchung).
Saldo-Abzug erst bei Genehmigung; Reject/Cancel bucht nichts.

- Migration 0042: hours_payouts.status/decided_by/decided_at/rejection_reason
  + companies.payout_request_enabled (nur Spalten, RLS unveraendert)
- Router: POST /hr/payouts/request|{id}/approve|reject|cancel; list status-Filter;
  HR-Direktbuchung bleibt (status approved)
- Frontend: PayoutRequestCard (Selbstbedienung in AbsencesPage), HR-Page
  Status-Spalte + Genehmigen/Ablehnen, CompanySettings-Toggle
- 4 pytest-Cases

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-08 22:43:26 +02:00
co-authored by Claude Opus 4.8
parent 2c57c36abd
commit 65596e80a4
15 changed files with 1134 additions and 96 deletions
+87
View File
@@ -0,0 +1,87 @@
"""Tests für Auszahlungs-Anträge mit Genehmigung (router/hours_payouts.py)."""
import pytest
import pytest_asyncio
from httpx import AsyncClient
@pytest_asyncio.fixture(scope="session", loop_scope="session")
async def payout_headers(client: AsyncClient):
resp = await client.post("/api/v1/auth/register", json={
"company_name": "Payout GmbH",
"first_name": "Paul",
"last_name": "Out",
"email": "admin@payoutgmbh.de",
"password": "Secret123",
})
assert resp.status_code == 201, resp.text
return {"Authorization": f"Bearer {resp.json()['access_token']}"}
@pytest.mark.asyncio(loop_scope="session")
async def test_request_disabled_by_default(client: AsyncClient, payout_headers):
r = await client.post("/api/v1/hr/payouts/request",
json={"hours": 4}, headers=payout_headers)
assert r.status_code == 403
@pytest.mark.asyncio(loop_scope="session")
async def test_enable_and_request_approve(client: AsyncClient, payout_headers):
# Firma schaltet Selbstbedienung frei
up = await client.patch("/api/v1/companies/me",
json={"payout_request_enabled": True}, headers=payout_headers)
assert up.status_code == 200, up.text
assert up.json()["payout_request_enabled"] is True
# Antrag stellen Status requested, kein Saldo-Abzug
r = await client.post("/api/v1/hr/payouts/request",
json={"hours": 5, "note": "Bitte auszahlen"}, headers=payout_headers)
assert r.status_code == 201, r.text
pid = r.json()["id"]
assert r.json()["status"] == "requested"
bal = await client.get("/api/v1/absences/overtime-balance", headers=payout_headers)
assert float(bal.json()["taken_hours"]) == 0.0
# HR genehmigt jetzt Saldo-Abzug
ap = await client.post(f"/api/v1/hr/payouts/{pid}/approve", json={}, headers=payout_headers)
assert ap.status_code == 200, ap.text
assert ap.json()["status"] == "approved"
bal2 = await client.get("/api/v1/absences/overtime-balance", headers=payout_headers)
assert float(bal2.json()["taken_hours"]) == 5.0
# Erneutes Genehmigen scheitert
ap2 = await client.post(f"/api/v1/hr/payouts/{pid}/approve", json={}, headers=payout_headers)
assert ap2.status_code == 409
@pytest.mark.asyncio(loop_scope="session")
async def test_reject_no_deduction(client: AsyncClient, payout_headers):
r = await client.post("/api/v1/hr/payouts/request",
json={"hours": 3}, headers=payout_headers)
pid = r.json()["id"]
before = float((await client.get("/api/v1/absences/overtime-balance",
headers=payout_headers)).json()["taken_hours"])
rej = await client.post(f"/api/v1/hr/payouts/{pid}/reject",
json={"rejection_reason": "kein Budget"}, headers=payout_headers)
assert rej.status_code == 200
assert rej.json()["status"] == "rejected"
assert rej.json()["rejection_reason"] == "kein Budget"
after = float((await client.get("/api/v1/absences/overtime-balance",
headers=payout_headers)).json()["taken_hours"])
assert after == before # kein Abzug bei Ablehnung
@pytest.mark.asyncio(loop_scope="session")
async def test_cancel_own_request(client: AsyncClient, payout_headers):
r = await client.post("/api/v1/hr/payouts/request",
json={"hours": 2}, headers=payout_headers)
pid = r.json()["id"]
c = await client.post(f"/api/v1/hr/payouts/{pid}/cancel", json={}, headers=payout_headers)
assert c.status_code == 200
assert c.json()["status"] == "cancelled"
# Genehmigen eines zurückgezogenen Antrags scheitert
ap = await client.post(f"/api/v1/hr/payouts/{pid}/approve", json={}, headers=payout_headers)
assert ap.status_code == 409