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:
@@ -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 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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")
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -0,0 +1,36 @@
|
||||
"""hours_payouts table
|
||||
|
||||
Revision ID: 0030
|
||||
Revises: 0029
|
||||
Create Date: 2026-05-25
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
|
||||
revision = '0030'
|
||||
down_revision = '0029'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.create_table(
|
||||
'hours_payouts',
|
||||
sa.Column('id', UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column('company_id', UUID(as_uuid=True), sa.ForeignKey('companies.id', ondelete='CASCADE'), nullable=False),
|
||||
sa.Column('user_id', UUID(as_uuid=True), sa.ForeignKey('users.id', ondelete='CASCADE'), nullable=False),
|
||||
sa.Column('hours', sa.Numeric(6, 2), nullable=False),
|
||||
sa.Column('period_year', sa.Integer(), nullable=True),
|
||||
sa.Column('period_month', sa.Integer(), nullable=True),
|
||||
sa.Column('note', sa.Text(), nullable=True),
|
||||
sa.Column('created_by', UUID(as_uuid=True), sa.ForeignKey('users.id', ondelete='SET NULL'), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
)
|
||||
op.create_index('ix_hours_payouts_company_id', 'hours_payouts', ['company_id'])
|
||||
op.create_index('ix_hours_payouts_user_id', 'hours_payouts', ['user_id'])
|
||||
op.create_index('ix_hours_payouts_created_at', 'hours_payouts', ['created_at'])
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_table('hours_payouts')
|
||||
Reference in New Issue
Block a user