@@ -1,4 +1,6 @@
""" Stunden-Auszahlung: HR/Admin bucht Überstunden-Stunden zur Lohn-Auszahlung aus. """
""" 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
@@ -9,10 +11,17 @@ 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 . hours_payout import HoursPayout
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
from app . schemas . hours_payout import (
HoursPayoutCreate ,
HoursPayoutListResponse ,
HoursPayoutOut ,
HoursPayoutReject ,
HoursPayoutRequest ,
)
router = APIRouter ( tags = [ " Stunden-Auszahlung " ] )
@@ -20,17 +29,30 @@ _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 :
async def _build_out ( payout : HoursPayout , db : AsyncSession ) - > 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 )
)
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 )
@@ -38,13 +60,11 @@ 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 ) ,
) :
""" 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
""" Auszahlungen der eigenen Firma, optional gefiltert. EMPLOYEE/MANAGER sehen nur eigene. """
if current_user . role not in _hr_roles :
user_id = current_user . id
@@ -55,24 +75,18 @@ async def list_payouts(
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 )
)
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 ) )
result = [ await _build_out ( p , db ) for p in rows ]
return HoursPayoutListResponse ( payouts = result , total_count = total_count or 0 )
# ── POST /hr/payouts ─────────────────── ───────────────────────────────────────
# ── POST /hr/payouts (HR-Direktbuchung) ───────────────────────────────────────
@router.post ( " /hr/payouts " , response_model = HoursPayoutOut , status_code = 201 )
async def create_payout (
@@ -81,36 +95,15 @@ async def create_payout(
current_user : User = require_role ( * _hr_roles ) ,
db : AsyncSession = Depends ( get_db ) ,
) :
""" Neue Auszahlung anlegen – reduziert sofort den Überstunden- Saldo."""
# Ziel-User prüfen
""" 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 " )
# 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
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 )
# Saldo anpassen
ob . taken_hours + = hours
# Auszahlungs-Datensatz anlegen
payout = HoursPayout (
company_id = current_user . company_id ,
user_id = data . user_id ,
@@ -118,36 +111,165 @@ async def create_payout(
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 ( ) # payout.id erzeugen
await db . flush ( )
# 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 ,
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 ,
" 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 )
creator = await db . get ( User , payout . created_by )
return _build_out ( payout , target , creator )
return await _build_out ( payout , db )
# ── DELETE /hr/payouts/{payout_id} ─────────────────────── ────────────────────
# ── 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 (
@@ -156,34 +278,26 @@ async def delete_payout(
current_user : User = require_role ( * _hr_roles ) ,
db : AsyncSession = Depends ( get_db ) ,
) :
""" Auszahlung stornieren – stellt die St und en in den Überstunden-Saldo zurück . """
""" Auszahlung stornieren – bei genehmigten Buch ung en 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 " )
# 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 )
# 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 )
# 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 ,
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 ,
" 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 ( )