feat(overtime): Auszahlungs-Anträge durch Mitarbeiter (opt-in pro Firma)
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>
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
# backend – Dev Log
|
||||
|
||||
## 2026-06-23 13:31 – 13:43 (12m)
|
||||
**Beschreibung:** Claude Code Session
|
||||
**Projekt:** timemaster
|
||||
|
||||
### Commits
|
||||
Keine Commits in dieser Session.
|
||||
|
||||
### Geänderte Dateien
|
||||
- frontend/src/pages/DashboardPage.tsx | 95 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||
|
||||
---
|
||||
@@ -93,6 +93,8 @@ class Company(Base):
|
||||
overtime_expiry_month: Mapped[int] = mapped_column(Integer, nullable=False, default=3) # März
|
||||
overtime_expiry_day: Mapped[int] = mapped_column(Integer, nullable=False, default=31) # 31.
|
||||
overtime_max_carryover_hours: Mapped[int | None] = mapped_column(Integer, nullable=True) # None = alles
|
||||
# Überstunden-Auszahlung: Selbstbedienung durch Mitarbeiter (Antrag → HR-Genehmigung)
|
||||
payout_request_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
|
||||
# Relationships
|
||||
users: Mapped[list["User"]] = relationship(
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
"""Stunden-Auszahlung: HR/Admin weist Überstunden-Stunden zur Lohn-Auszahlung an."""
|
||||
import enum
|
||||
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 import DateTime, ForeignKey, Integer, Numeric, String, Text, func
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
@@ -15,6 +16,13 @@ if TYPE_CHECKING:
|
||||
from app.models.company import Company
|
||||
|
||||
|
||||
class PayoutStatus(str, enum.Enum):
|
||||
REQUESTED = "requested" # Mitarbeiter hat beantragt, wartet auf HR
|
||||
APPROVED = "approved" # genehmigt / HR-Direktbuchung – Saldo abgezogen
|
||||
REJECTED = "rejected" # HR hat abgelehnt
|
||||
CANCELLED = "cancelled" # Antragsteller hat zurückgezogen (nur requested)
|
||||
|
||||
|
||||
class HoursPayout(Base):
|
||||
"""Ein Auszahlungsvorgang für Überstunden-Stunden."""
|
||||
__tablename__ = "hours_payouts"
|
||||
@@ -32,14 +40,21 @@ class HoursPayout(Base):
|
||||
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
|
||||
status: Mapped[str] = mapped_column(String(12), nullable=False, default=PayoutStatus.APPROVED.value)
|
||||
rejection_reason: Mapped[str | None] = mapped_column(Text)
|
||||
created_by: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"),
|
||||
nullable=False
|
||||
)
|
||||
decided_by: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL")
|
||||
)
|
||||
decided_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
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")
|
||||
decider: Mapped["User"] = relationship("User", foreign_keys=[decided_by], lazy="noload")
|
||||
company: Mapped["Company"] = relationship("Company", lazy="noload")
|
||||
|
||||
@@ -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 Stunden in den Überstunden-Saldo zurück."""
|
||||
"""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")
|
||||
|
||||
# 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()
|
||||
|
||||
@@ -43,6 +43,7 @@ class CompanyOut(BaseModel):
|
||||
overtime_expiry_month: int = 3
|
||||
overtime_expiry_day: int = 31
|
||||
overtime_max_carryover_hours: int | None = None
|
||||
payout_request_enabled: bool = False
|
||||
kiosk_require_approval: bool = True
|
||||
kiosk_track_current_user: bool = True
|
||||
kiosk_heartbeat_interval_sec: int = 30
|
||||
@@ -83,6 +84,7 @@ class CompanyUpdate(BaseModel):
|
||||
overtime_expiry_month: int | None = Field(None, ge=1, le=12)
|
||||
overtime_expiry_day: int | None = Field(None, ge=1, le=31)
|
||||
overtime_max_carryover_hours: int | None = Field(None, ge=0, le=9999)
|
||||
payout_request_enabled: bool | None = None
|
||||
kiosk_require_approval: bool | None = None
|
||||
kiosk_track_current_user: bool | None = None
|
||||
kiosk_heartbeat_interval_sec: int | None = Field(None, ge=10, le=120)
|
||||
|
||||
@@ -12,6 +12,18 @@ class HoursPayoutCreate(BaseModel):
|
||||
note: str | None = Field(None, max_length=500)
|
||||
|
||||
|
||||
class HoursPayoutRequest(BaseModel):
|
||||
"""Mitarbeiter beantragt Auszahlung eigener Überstunden (kein user_id – immer self)."""
|
||||
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 HoursPayoutReject(BaseModel):
|
||||
rejection_reason: str | None = Field(None, max_length=500)
|
||||
|
||||
|
||||
class HoursPayoutOut(BaseModel):
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
@@ -23,8 +35,13 @@ class HoursPayoutOut(BaseModel):
|
||||
period_year: int | None
|
||||
period_month: int | None
|
||||
note: str | None
|
||||
status: str = "approved"
|
||||
rejection_reason: str | None = None
|
||||
created_by: uuid.UUID
|
||||
created_by_name: str = "" # Computed im Router
|
||||
decided_by: uuid.UUID | None = None
|
||||
decided_by_name: str = "" # Computed im Router
|
||||
decided_at: datetime | None = None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Auszahlungs-Anträge: Status + Genehmigungs-Metadaten + Firmen-Opt-in
|
||||
|
||||
Revision ID: 0042
|
||||
Revises: 0041
|
||||
Create Date: 2026-07-08
|
||||
|
||||
Erweitert hours_payouts um einen Genehmigungs-Workflow: Mitarbeiter beantragt eine
|
||||
Überstunden-Auszahlung (status='requested'), HR genehmigt/lehnt ab. Bestehende Zeilen
|
||||
und HR-Direktbuchungen sind 'approved'. companies.payout_request_enabled schaltet die
|
||||
Selbstbedienung pro Firma frei (Default aus → nur HR-Direktbuchung wie bisher).
|
||||
|
||||
Keine RLS-Änderung nötig: nur neue Spalten auf bereits gefencten Tabellen
|
||||
(hours_payouts-Policy seit 0039, companies-Policy vorhanden) → conftest unverändert.
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
revision = "0042"
|
||||
down_revision = "0041"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute("ALTER TABLE hours_payouts ADD COLUMN IF NOT EXISTS status VARCHAR(12) NOT NULL DEFAULT 'approved'")
|
||||
op.execute("ALTER TABLE hours_payouts ADD COLUMN IF NOT EXISTS decided_by UUID REFERENCES users(id) ON DELETE SET NULL")
|
||||
op.execute("ALTER TABLE hours_payouts ADD COLUMN IF NOT EXISTS decided_at TIMESTAMPTZ")
|
||||
op.execute("ALTER TABLE hours_payouts ADD COLUMN IF NOT EXISTS rejection_reason TEXT")
|
||||
op.execute("ALTER TABLE companies ADD COLUMN IF NOT EXISTS payout_request_enabled BOOLEAN NOT NULL DEFAULT FALSE")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("ALTER TABLE companies DROP COLUMN IF EXISTS payout_request_enabled")
|
||||
op.execute("ALTER TABLE hours_payouts DROP COLUMN IF EXISTS rejection_reason")
|
||||
op.execute("ALTER TABLE hours_payouts DROP COLUMN IF EXISTS decided_at")
|
||||
op.execute("ALTER TABLE hours_payouts DROP COLUMN IF EXISTS decided_by")
|
||||
op.execute("ALTER TABLE hours_payouts DROP COLUMN IF EXISTS status")
|
||||
@@ -0,0 +1,87 @@
|
||||
"""Tests für Auszahlungs-Anträge mit Genehmigung (router/hours_payouts.py)."""
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from httpx import AsyncClient
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope="session", loop_scope="session")
|
||||
async def payout_headers(client: AsyncClient):
|
||||
resp = await client.post("/api/v1/auth/register", json={
|
||||
"company_name": "Payout GmbH",
|
||||
"first_name": "Paul",
|
||||
"last_name": "Out",
|
||||
"email": "admin@payoutgmbh.de",
|
||||
"password": "Secret123",
|
||||
})
|
||||
assert resp.status_code == 201, resp.text
|
||||
return {"Authorization": f"Bearer {resp.json()['access_token']}"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_request_disabled_by_default(client: AsyncClient, payout_headers):
|
||||
r = await client.post("/api/v1/hr/payouts/request",
|
||||
json={"hours": 4}, headers=payout_headers)
|
||||
assert r.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_enable_and_request_approve(client: AsyncClient, payout_headers):
|
||||
# Firma schaltet Selbstbedienung frei
|
||||
up = await client.patch("/api/v1/companies/me",
|
||||
json={"payout_request_enabled": True}, headers=payout_headers)
|
||||
assert up.status_code == 200, up.text
|
||||
assert up.json()["payout_request_enabled"] is True
|
||||
|
||||
# Antrag stellen – Status requested, kein Saldo-Abzug
|
||||
r = await client.post("/api/v1/hr/payouts/request",
|
||||
json={"hours": 5, "note": "Bitte auszahlen"}, headers=payout_headers)
|
||||
assert r.status_code == 201, r.text
|
||||
pid = r.json()["id"]
|
||||
assert r.json()["status"] == "requested"
|
||||
|
||||
bal = await client.get("/api/v1/absences/overtime-balance", headers=payout_headers)
|
||||
assert float(bal.json()["taken_hours"]) == 0.0
|
||||
|
||||
# HR genehmigt – jetzt Saldo-Abzug
|
||||
ap = await client.post(f"/api/v1/hr/payouts/{pid}/approve", json={}, headers=payout_headers)
|
||||
assert ap.status_code == 200, ap.text
|
||||
assert ap.json()["status"] == "approved"
|
||||
|
||||
bal2 = await client.get("/api/v1/absences/overtime-balance", headers=payout_headers)
|
||||
assert float(bal2.json()["taken_hours"]) == 5.0
|
||||
|
||||
# Erneutes Genehmigen scheitert
|
||||
ap2 = await client.post(f"/api/v1/hr/payouts/{pid}/approve", json={}, headers=payout_headers)
|
||||
assert ap2.status_code == 409
|
||||
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_reject_no_deduction(client: AsyncClient, payout_headers):
|
||||
r = await client.post("/api/v1/hr/payouts/request",
|
||||
json={"hours": 3}, headers=payout_headers)
|
||||
pid = r.json()["id"]
|
||||
before = float((await client.get("/api/v1/absences/overtime-balance",
|
||||
headers=payout_headers)).json()["taken_hours"])
|
||||
|
||||
rej = await client.post(f"/api/v1/hr/payouts/{pid}/reject",
|
||||
json={"rejection_reason": "kein Budget"}, headers=payout_headers)
|
||||
assert rej.status_code == 200
|
||||
assert rej.json()["status"] == "rejected"
|
||||
assert rej.json()["rejection_reason"] == "kein Budget"
|
||||
|
||||
after = float((await client.get("/api/v1/absences/overtime-balance",
|
||||
headers=payout_headers)).json()["taken_hours"])
|
||||
assert after == before # kein Abzug bei Ablehnung
|
||||
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_cancel_own_request(client: AsyncClient, payout_headers):
|
||||
r = await client.post("/api/v1/hr/payouts/request",
|
||||
json={"hours": 2}, headers=payout_headers)
|
||||
pid = r.json()["id"]
|
||||
c = await client.post(f"/api/v1/hr/payouts/{pid}/cancel", json={}, headers=payout_headers)
|
||||
assert c.status_code == 200
|
||||
assert c.json()["status"] == "cancelled"
|
||||
# Genehmigen eines zurückgezogenen Antrags scheitert
|
||||
ap = await client.post(f"/api/v1/hr/payouts/{pid}/approve", json={}, headers=payout_headers)
|
||||
assert ap.status_code == 409
|
||||
Reference in New Issue
Block a user