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>
304 lines
12 KiB
Python
304 lines
12 KiB
Python
"""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()
|