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
+2
View File
@@ -16,6 +16,7 @@ from app.routers import kiosk
from app.routers import busylight
from app.routers import audit
from app.routers import special_assignments
from app.routers import hours_payouts
@asynccontextmanager
@@ -79,6 +80,7 @@ app.include_router(kiosk.router, prefix=API_PREFIX)
app.include_router(busylight.router, prefix=API_PREFIX)
app.include_router(audit.router, prefix=API_PREFIX)
app.include_router(special_assignments.router, prefix=API_PREFIX)
app.include_router(hours_payouts.router, prefix=API_PREFIX)
# ── Health ────────────────────────────────────────────────────────────────────
+2
View File
@@ -15,6 +15,7 @@ from app.models.smtp_config import SmtpConfig
from app.models.caldav_config import CaldavCompanyConfig, CaldavUserConfig
from app.models.kiosk_device import KioskDevice, KioskAuthMethod
from app.models.special_assignment import SpecialAssignment, AssignmentMode
from app.models.hours_payout import HoursPayout
__all__ = [
"Company",
@@ -38,4 +39,5 @@ __all__ = [
"KioskAuthMethod",
"SpecialAssignment",
"AssignmentMode",
"HoursPayout",
]
+45
View File
@@ -0,0 +1,45 @@
"""Stunden-Auszahlung: HR/Admin weist Überstunden-Stunden zur Lohn-Auszahlung an."""
import uuid
from datetime import datetime
from decimal import Decimal
from typing import TYPE_CHECKING
from sqlalchemy import DateTime, ForeignKey, Integer, Numeric, Text, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
if TYPE_CHECKING:
from app.models.user import User
from app.models.company import Company
class HoursPayout(Base):
"""Ein Auszahlungsvorgang für Überstunden-Stunden."""
__tablename__ = "hours_payouts"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
company_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("companies.id", ondelete="CASCADE"),
nullable=False, index=True
)
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"),
nullable=False, index=True
)
hours: Mapped[Decimal] = mapped_column(Numeric(6, 2), nullable=False) # ausgezahlte Stunden
period_year: Mapped[int | None] = mapped_column(Integer) # Abrechnungsmonat Jahr
period_month: Mapped[int | None] = mapped_column(Integer) # Abrechnungsmonat Monat
note: Mapped[str | None] = mapped_column(Text) # Notiz für Buchhaltung
created_by: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"),
nullable=False
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), index=True
)
user: Mapped["User"] = relationship("User", foreign_keys=[user_id], lazy="noload")
creator: Mapped["User"] = relationship("User", foreign_keys=[created_by], lazy="noload")
company: Mapped["Company"] = relationship("Company", lazy="noload")
+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()
+33
View File
@@ -0,0 +1,33 @@
import uuid
from datetime import datetime
from decimal import Decimal
from pydantic import BaseModel, Field
class HoursPayoutCreate(BaseModel):
user_id: uuid.UUID
hours: Decimal = Field(gt=0, le=999.99, decimal_places=2)
period_year: int | None = Field(None, ge=2000, le=2100)
period_month: int | None = Field(None, ge=1, le=12)
note: str | None = Field(None, max_length=500)
class HoursPayoutOut(BaseModel):
model_config = {"from_attributes": True}
id: uuid.UUID
company_id: uuid.UUID
user_id: uuid.UUID
user_name: str # Computed: first_name + last_name (wird im Router gesetzt)
hours: Decimal
period_year: int | None
period_month: int | None
note: str | None
created_by: uuid.UUID
created_by_name: str # Computed im Router
created_at: datetime
class HoursPayoutListResponse(BaseModel):
payouts: list[HoursPayoutOut]
total_count: int