"""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() await db.refresh(payout) # server_default created_at – noch in-Transaktion (RLS-Kontext da) 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), )) out = await _build_out(payout, db) await db.commit() return out # ── 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() await db.refresh(payout) # server_default created_at – noch in-Transaktion (RLS-Kontext da) 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), )) out = await _build_out(payout, db) await db.commit() return out # ── 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), )) out = await _build_out(payout, db) await db.commit() return out # ── 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), )) out = await _build_out(payout, db) await db.commit() return out # ── 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), )) out = await _build_out(payout, db) await db.commit() return out # ── 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()