Files
timemaster/backend/app/routers/hours_payouts.py
T
patrickandClaude Opus 4.8 65596e80a4
Security Audit / Node.js Dependency Audit (push) Has been cancelled
Security Audit / Python Dependency Audit (push) Has been cancelled
feat(overtime): Auszahlungs-Anträge durch Mitarbeiter (opt-in pro Firma)
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>
2026-07-08 22:43:26 +02:00

304 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Stunden-Auszahlung: HR/Admin bucht Überstunden aus oder Mitarbeiter beantragt
Auszahlung und HR genehmigt (Firmen-Opt-in `payout_request_enabled`)."""
from datetime import datetime
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 get_client_ip, require_role
from app.models.audit_log import AuditLog
from app.models.company import Company
from app.models.hours_payout import HoursPayout, PayoutStatus
from app.models.overtime_balance import OvertimeBalance
from app.models.user import User, UserRole
from app.schemas.hours_payout import (
HoursPayoutCreate,
HoursPayoutListResponse,
HoursPayoutOut,
HoursPayoutReject,
HoursPayoutRequest,
)
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)
async def _build_out(payout: HoursPayout, db: AsyncSession) -> HoursPayoutOut:
out = HoursPayoutOut.model_validate(payout)
user = await db.get(User, payout.user_id)
creator = await db.get(User, payout.created_by)
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)
if payout.decided_by:
decider = await db.get(User, payout.decided_by)
out.decided_by_name = f"{decider.first_name} {decider.last_name}" if decider else str(payout.decided_by)
return out
async def _get_or_create_balance(user_id: UUID, company_id: UUID, db: AsyncSession) -> OvertimeBalance:
ob = await db.scalar(select(OvertimeBalance).where(OvertimeBalance.user_id == user_id))
if ob is None:
ob = OvertimeBalance(
user_id=user_id, company_id=company_id,
total_hours=Decimal("0"), taken_hours=Decimal("0"),
)
db.add(ob)
await db.flush()
return ob
# ── 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),
status: str | None = Query(None),
current_user: User = require_role(*_all_roles),
db: AsyncSession = Depends(get_db),
):
"""Auszahlungen der eigenen Firma, optional gefiltert. EMPLOYEE/MANAGER sehen nur eigene."""
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)
if status is not None:
filters.append(HoursPayout.status == status)
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 = [await _build_out(p, db) for p in rows]
return HoursPayoutListResponse(payouts=result, total_count=total_count or 0)
# ── POST /hr/payouts (HR-Direktbuchung) ───────────────────────────────────────
@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),
):
"""HR bucht Auszahlung direkt (sofort genehmigt) reduziert sofort den Saldo."""
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")
hours = Decimal(str(data.hours))
ob = await _get_or_create_balance(data.user_id, current_user.company_id, db)
ob.taken_hours += hours # Überziehung erlaubt (kein Hard-Block, wie FZA)
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,
status=PayoutStatus.APPROVED.value,
created_by=current_user.id,
decided_by=current_user.id,
decided_at=datetime.utcnow(),
)
db.add(payout)
await db.flush()
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=get_client_ip(request),
))
await db.commit()
await db.refresh(payout)
return await _build_out(payout, db)
# ── POST /hr/payouts/request (Mitarbeiter-Selbstbedienung) ────────────────────
@router.post("/hr/payouts/request", response_model=HoursPayoutOut, status_code=201)
async def request_payout(
request: Request,
data: HoursPayoutRequest,
current_user: User = require_role(*_all_roles),
db: AsyncSession = Depends(get_db),
):
"""Mitarbeiter beantragt Auszahlung eigener Überstunden. Saldo wird erst bei
Genehmigung durch HR abgezogen. Nur wenn Firma `payout_request_enabled` hat."""
company = await db.get(Company, current_user.company_id)
if not company or not company.payout_request_enabled:
raise HTTPException(403, "Auszahlungs-Anträge sind für diese Firma nicht aktiviert.")
hours = Decimal(str(data.hours))
payout = HoursPayout(
company_id=current_user.company_id,
user_id=current_user.id,
hours=hours,
period_year=data.period_year,
period_month=data.period_month,
note=data.note,
status=PayoutStatus.REQUESTED.value,
created_by=current_user.id,
)
db.add(payout)
await db.flush()
db.add(AuditLog(
company_id=current_user.company_id, user_id=current_user.id,
action="hours_payout_requested", entity_type="hours_payout", entity_id=payout.id,
new_value={"hours": str(hours), "note": data.note},
ip=get_client_ip(request),
))
await db.commit()
await db.refresh(payout)
return await _build_out(payout, db)
# ── POST /hr/payouts/{id}/approve ─────────────────────────────────────────────
@router.post("/hr/payouts/{payout_id}/approve", response_model=HoursPayoutOut)
async def approve_payout(
payout_id: UUID,
request: Request,
current_user: User = require_role(*_hr_roles),
db: AsyncSession = Depends(get_db),
):
"""HR genehmigt einen Antrag erst jetzt wird der Saldo abgezogen."""
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")
if payout.status != PayoutStatus.REQUESTED.value:
raise HTTPException(409, "Nur offene Anträge können genehmigt werden.")
ob = await _get_or_create_balance(payout.user_id, current_user.company_id, db)
ob.taken_hours += payout.hours
payout.status = PayoutStatus.APPROVED.value
payout.decided_by = current_user.id
payout.decided_at = datetime.utcnow()
db.add(AuditLog(
company_id=current_user.company_id, user_id=current_user.id,
action="hours_payout_approved", entity_type="hours_payout", entity_id=payout.id,
new_value={"hours": str(payout.hours), "user_id": str(payout.user_id)},
ip=get_client_ip(request),
))
await db.commit()
await db.refresh(payout)
return await _build_out(payout, db)
# ── POST /hr/payouts/{id}/reject ──────────────────────────────────────────────
@router.post("/hr/payouts/{payout_id}/reject", response_model=HoursPayoutOut)
async def reject_payout(
payout_id: UUID,
request: Request,
data: HoursPayoutReject,
current_user: User = require_role(*_hr_roles),
db: AsyncSession = Depends(get_db),
):
"""HR lehnt Antrag ab kein Saldo-Abzug."""
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")
if payout.status != PayoutStatus.REQUESTED.value:
raise HTTPException(409, "Nur offene Anträge können abgelehnt werden.")
payout.status = PayoutStatus.REJECTED.value
payout.rejection_reason = data.rejection_reason
payout.decided_by = current_user.id
payout.decided_at = datetime.utcnow()
db.add(AuditLog(
company_id=current_user.company_id, user_id=current_user.id,
action="hours_payout_rejected", entity_type="hours_payout", entity_id=payout.id,
new_value={"rejection_reason": data.rejection_reason, "user_id": str(payout.user_id)},
ip=get_client_ip(request),
))
await db.commit()
await db.refresh(payout)
return await _build_out(payout, db)
# ── POST /hr/payouts/{id}/cancel (Antragsteller zieht zurück) ─────────────────
@router.post("/hr/payouts/{payout_id}/cancel", response_model=HoursPayoutOut)
async def cancel_payout(
payout_id: UUID,
request: Request,
current_user: User = require_role(*_all_roles),
db: AsyncSession = Depends(get_db),
):
"""Antragsteller zieht seinen offenen Antrag 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")
if payout.user_id != current_user.id:
raise HTTPException(403, "Nur der eigene Antrag kann zurückgezogen werden.")
if payout.status != PayoutStatus.REQUESTED.value:
raise HTTPException(409, "Nur offene Anträge können zurückgezogen werden.")
payout.status = PayoutStatus.CANCELLED.value
db.add(AuditLog(
company_id=current_user.company_id, user_id=current_user.id,
action="hours_payout_cancelled", entity_type="hours_payout", entity_id=payout.id,
ip=get_client_ip(request),
))
await db.commit()
await db.refresh(payout)
return await _build_out(payout, db)
# ── DELETE /hr/payouts/{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 bei genehmigten Buchungen werden die Stunden zurückgebucht."""
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")
# Nur genehmigte/gebuchte Auszahlungen haben den Saldo belastet → zurückbuchen
if payout.status == PayoutStatus.APPROVED.value:
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)
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),
"status": payout.status,
"period_year": payout.period_year, "period_month": payout.period_month, "note": payout.note,
},
ip=get_client_ip(request),
))
await db.delete(payout)
await db.commit()