feat: Stunden-Auszahlung Feature (/hr/payouts)

- Backend: Model HoursPayout, Schema, Router GET/POST/DELETE
- GET /hr/payouts: HR/Admin sehen alle, Employee/Manager nur eigene
- POST /hr/payouts: reduziert OvertimeBalance.taken_hours sofort
- DELETE /hr/payouts/{id}: storniert und bucht Stunden zurück
- AuditLog-Einträge bei Anlegen und Stornieren
- Migration 0030: hours_payouts Tabelle
- Frontend: /hr/payouts Seite (lila, 💸) mit Filter, Tabelle, Modal
- Modal zeigt verfügbares Überstundenguthaben + Warnung bei Überziehung
- Navigation: Stunden-Auszahlung (HR/COMPANY_ADMIN/SUPER_ADMIN)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-25 22:17:52 +02:00
parent e83a3fbbdd
commit a63b0e835f
11 changed files with 956 additions and 0 deletions
+189
View File
@@ -0,0 +1,189 @@
"""Stunden-Auszahlung: HR/Admin bucht Überstunden-Stunden zur Lohn-Auszahlung aus."""
from decimal import Decimal
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.dependencies import require_role
from app.models.audit_log import AuditLog
from app.models.hours_payout import HoursPayout
from app.models.overtime_balance import OvertimeBalance
from app.models.user import User, UserRole
from app.schemas.hours_payout import HoursPayoutCreate, HoursPayoutListResponse, HoursPayoutOut
router = APIRouter(tags=["Stunden-Auszahlung"])
_hr_roles = (UserRole.HR, UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN)
_all_roles = (UserRole.EMPLOYEE, UserRole.MANAGER, UserRole.HR, UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN)
def _build_out(payout: HoursPayout, user: User | None, creator: User | None) -> HoursPayoutOut:
out = HoursPayoutOut.model_validate(payout)
out.user_name = (
f"{user.first_name} {user.last_name}" if user else str(payout.user_id)
)
out.created_by_name = (
f"{creator.first_name} {creator.last_name}" if creator else str(payout.created_by)
)
return out
# ── GET /hr/payouts ───────────────────────────────────────────────────────────
@router.get("/hr/payouts", response_model=HoursPayoutListResponse)
async def list_payouts(
user_id: UUID | None = Query(None),
year: int | None = Query(None, ge=2000, le=2100),
month: int | None = Query(None, ge=1, le=12),
current_user: User = require_role(*_all_roles),
db: AsyncSession = Depends(get_db),
):
"""Alle Auszahlungen der eigenen Firma, optional gefiltert nach Mitarbeiter / Monat.
EMPLOYEE und MANAGER sehen ausschließlich ihre eigenen Auszahlungen.
"""
# Employees und Manager sehen nur ihre eigenen Daten Query-Param wird ignoriert
if current_user.role not in _hr_roles:
user_id = current_user.id
filters = [HoursPayout.company_id == current_user.company_id]
if user_id is not None:
filters.append(HoursPayout.user_id == user_id)
if year is not None:
filters.append(HoursPayout.period_year == year)
if month is not None:
filters.append(HoursPayout.period_month == month)
total_count = await db.scalar(
select(func.count()).select_from(HoursPayout).where(*filters)
)
rows = list(await db.scalars(
select(HoursPayout).where(*filters).order_by(HoursPayout.created_at.desc())
))
result: list[HoursPayoutOut] = []
for payout in rows:
user = await db.get(User, payout.user_id)
creator = await db.get(User, payout.created_by)
result.append(_build_out(payout, user, creator))
return HoursPayoutListResponse(payouts=result, total_count=total_count or 0)
# ── POST /hr/payouts ──────────────────────────────────────────────────────────
@router.post("/hr/payouts", response_model=HoursPayoutOut, status_code=201)
async def create_payout(
request: Request,
data: HoursPayoutCreate,
current_user: User = require_role(*_hr_roles),
db: AsyncSession = Depends(get_db),
):
"""Neue Auszahlung anlegen reduziert sofort den Überstunden-Saldo."""
# Ziel-User prüfen
target = await db.get(User, data.user_id)
if not target or target.company_id != current_user.company_id:
raise HTTPException(404, "Mitarbeiter nicht gefunden")
# OvertimeBalance laden oder anlegen
ob = await db.scalar(
select(OvertimeBalance).where(OvertimeBalance.user_id == data.user_id)
)
if ob is None:
ob = OvertimeBalance(
user_id=data.user_id,
company_id=current_user.company_id,
total_hours=Decimal("0"),
taken_hours=Decimal("0"),
)
db.add(ob)
await db.flush() # id erzeugen
# Warnung bei Überziehung (kein Hard-Block)
hours = Decimal(str(data.hours))
if ob.available_hours < hours:
# Wir blockieren nicht Auszahlung trotzdem buchen (wie FZA mit overdraft)
pass
# Saldo anpassen
ob.taken_hours += hours
# Auszahlungs-Datensatz anlegen
payout = HoursPayout(
company_id=current_user.company_id,
user_id=data.user_id,
hours=hours,
period_year=data.period_year,
period_month=data.period_month,
note=data.note,
created_by=current_user.id,
)
db.add(payout)
await db.flush() # payout.id erzeugen
# AuditLog
db.add(AuditLog(
company_id=current_user.company_id,
user_id=current_user.id,
action="hours_payout_created",
entity_type="hours_payout",
entity_id=payout.id,
new_value={
"user_id": str(data.user_id),
"hours": str(hours),
"period_year": data.period_year,
"period_month": data.period_month,
"note": data.note,
},
ip=request.client.host if request.client else None,
))
await db.commit()
await db.refresh(payout)
creator = await db.get(User, payout.created_by)
return _build_out(payout, target, creator)
# ── DELETE /hr/payouts/{payout_id} ───────────────────────────────────────────
@router.delete("/hr/payouts/{payout_id}", status_code=204)
async def delete_payout(
payout_id: UUID,
request: Request,
current_user: User = require_role(*_hr_roles),
db: AsyncSession = Depends(get_db),
):
"""Auszahlung stornieren stellt die Stunden in den Überstunden-Saldo zurück."""
payout = await db.get(HoursPayout, payout_id)
if payout is None or payout.company_id != current_user.company_id:
raise HTTPException(404, "Auszahlung nicht gefunden")
# OvertimeBalance laden und Stunden zurückbuchen
ob = await db.scalar(
select(OvertimeBalance).where(OvertimeBalance.user_id == payout.user_id)
)
if ob is not None:
ob.taken_hours = max(Decimal("0"), ob.taken_hours - payout.hours)
# AuditLog
db.add(AuditLog(
company_id=current_user.company_id,
user_id=current_user.id,
action="hours_payout_deleted",
entity_type="hours_payout",
entity_id=payout.id,
old_value={
"user_id": str(payout.user_id),
"hours": str(payout.hours),
"period_year": payout.period_year,
"period_month": payout.period_month,
"note": payout.note,
},
ip=request.client.host if request.client else None,
))
await db.delete(payout)
await db.commit()