feat: Überstunden-Kappung + Jahresverfall pro Firma konfigurierbar
Backend: - Company: overtime_cap_hours, overtime_expiry_enabled/month/day, overtime_max_carryover_hours - OvertimeBalance: last_expiry_applied_at - Migration 0031: neue Spalten in companies + overtime_balances - _recalculate_overtime_balance: Kappung direkt nach Berechnung - apply_overtime_expiry_if_needed(): lazy Verfall beim Balance-Abruf - GET /absences/overtime-balance: prüft + wendet Verfall automatisch an - POST /absences/overtime-balance/apply-expiry: manueller Trigger (Admin) Frontend: - CompanySettingsPage: neuer Block 'Überstunden-Konto' - Toggle Kappungsgrenze + Stunden-Input - Toggle Jahresverfall + Stichtag (Tag/Monat) + max. Übertrag - 'Verfall anwenden'-Button für Admins Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -55,6 +55,13 @@ class Company(Base):
|
||||
# Freizeitausgleich-Konfiguration
|
||||
overtime_overdraft_allowed: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
overtime_warning_threshold_hours: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
# Überstunden-Kappung
|
||||
overtime_cap_hours: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
# Überstunden-Verfall
|
||||
overtime_expiry_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
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
|
||||
|
||||
# Relationships
|
||||
users: Mapped[list["User"]] = relationship("User", back_populates="company", lazy="noload")
|
||||
|
||||
@@ -34,6 +34,7 @@ class OvertimeBalance(Base):
|
||||
total_hours: Mapped[Decimal] = mapped_column(Numeric(8, 2), default=Decimal("0"))
|
||||
taken_hours: Mapped[Decimal] = mapped_column(Numeric(8, 2), default=Decimal("0"))
|
||||
last_calculated: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
last_expiry_applied_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||
|
||||
@@ -210,6 +210,15 @@ async def get_overtime_balance(
|
||||
)
|
||||
if bal is None:
|
||||
return OvertimeBalanceOut(total_hours=0, taken_hours=0, available_hours=0)
|
||||
|
||||
# Verfall anwenden wenn nötig
|
||||
from app.services.report_service import apply_overtime_expiry_if_needed
|
||||
from app.models.company import Company as CompanyModel
|
||||
company = await db.get(CompanyModel, current_user.company_id)
|
||||
changed = await apply_overtime_expiry_if_needed(bal, company, db)
|
||||
if changed:
|
||||
await db.commit()
|
||||
|
||||
return OvertimeBalanceOut(
|
||||
total_hours=float(bal.total_hours),
|
||||
taken_hours=float(bal.taken_hours),
|
||||
@@ -217,6 +226,29 @@ async def get_overtime_balance(
|
||||
)
|
||||
|
||||
|
||||
@router.post("/absences/overtime-balance/apply-expiry")
|
||||
async def apply_overtime_expiry_all(
|
||||
current_user: User = require_role(UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Überstunden-Verfall manuell für alle Mitarbeiter der Firma anwenden."""
|
||||
from sqlalchemy import select as sa_select
|
||||
from app.models.company import Company as CompanyModel
|
||||
from app.services.report_service import apply_overtime_expiry_if_needed
|
||||
|
||||
company = await db.get(CompanyModel, current_user.company_id)
|
||||
balances = list(await db.scalars(
|
||||
sa_select(OvertimeBalance).where(OvertimeBalance.company_id == current_user.company_id)
|
||||
))
|
||||
applied_count = 0
|
||||
for bal in balances:
|
||||
changed = await apply_overtime_expiry_if_needed(bal, company, db)
|
||||
if changed:
|
||||
applied_count += 1
|
||||
await db.commit()
|
||||
return {"applied_to": applied_count, "total": len(balances)}
|
||||
|
||||
|
||||
@router.get("/absences/", response_model=AbsenceListResponse)
|
||||
async def list_absences(
|
||||
current_user: CurrentUser,
|
||||
|
||||
@@ -24,6 +24,11 @@ class CompanyOut(BaseModel):
|
||||
mobile_stamping_enabled: bool = True
|
||||
overtime_overdraft_allowed: bool = True
|
||||
overtime_warning_threshold_hours: int = 0
|
||||
overtime_cap_hours: int | None = None
|
||||
overtime_expiry_enabled: bool = False
|
||||
overtime_expiry_month: int = 3
|
||||
overtime_expiry_day: int = 31
|
||||
overtime_max_carryover_hours: int | None = None
|
||||
kiosk_require_approval: bool = True
|
||||
kiosk_track_current_user: bool = True
|
||||
kiosk_heartbeat_interval_sec: int = 30
|
||||
@@ -39,6 +44,11 @@ class CompanyUpdate(BaseModel):
|
||||
mobile_stamping_enabled: bool | None = None
|
||||
overtime_overdraft_allowed: bool | None = None
|
||||
overtime_warning_threshold_hours: int | None = Field(None, ge=0)
|
||||
overtime_cap_hours: int | None = Field(None, ge=1, le=9999)
|
||||
overtime_expiry_enabled: bool | None = None
|
||||
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)
|
||||
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)
|
||||
|
||||
@@ -242,9 +242,63 @@ async def _recalculate_overtime_balance(
|
||||
|
||||
bal.total_hours = Decimal(str(round(overtime, 2)))
|
||||
bal.last_calculated = datetime.utcnow()
|
||||
|
||||
# Kappung anwenden
|
||||
company = await db.get(Company, user.company_id)
|
||||
if company and company.overtime_cap_hours is not None:
|
||||
cap = Decimal(str(company.overtime_cap_hours))
|
||||
if bal.total_hours > cap:
|
||||
bal.total_hours = cap
|
||||
|
||||
return bal
|
||||
|
||||
|
||||
async def apply_overtime_expiry_if_needed(
|
||||
bal: OvertimeBalance,
|
||||
company, # Company model
|
||||
db: AsyncSession,
|
||||
) -> bool:
|
||||
"""
|
||||
Prüft ob der Überstunden-Verfall angewendet werden muss und tut es ggf.
|
||||
Gibt True zurück wenn Verfall angewendet wurde.
|
||||
"""
|
||||
if not company or not company.overtime_expiry_enabled:
|
||||
return False
|
||||
|
||||
today = date.today()
|
||||
try:
|
||||
expiry_this_year = date(today.year, company.overtime_expiry_month, company.overtime_expiry_day)
|
||||
except ValueError:
|
||||
# Ungültiges Datum (z.B. 31. Februar) – überspringen
|
||||
return False
|
||||
|
||||
expiry_last_year_year = today.year - 1
|
||||
try:
|
||||
expiry_last_year = date(expiry_last_year_year, company.overtime_expiry_month, company.overtime_expiry_day)
|
||||
except ValueError:
|
||||
expiry_last_year = None
|
||||
|
||||
last_applicable_expiry = expiry_this_year if today >= expiry_this_year else expiry_last_year
|
||||
if last_applicable_expiry is None:
|
||||
return False
|
||||
|
||||
# Schon angewendet?
|
||||
if bal.last_expiry_applied_at:
|
||||
applied_date = bal.last_expiry_applied_at.date() if hasattr(bal.last_expiry_applied_at, 'date') else bal.last_expiry_applied_at
|
||||
if applied_date >= last_applicable_expiry:
|
||||
return False
|
||||
|
||||
# Verfall anwenden: available_hours auf max_carryover kappen
|
||||
available = bal.total_hours - bal.taken_hours
|
||||
if company.overtime_max_carryover_hours is not None:
|
||||
max_carry = Decimal(str(company.overtime_max_carryover_hours))
|
||||
if available > max_carry:
|
||||
bal.total_hours = bal.taken_hours + max_carry
|
||||
|
||||
bal.last_expiry_applied_at = datetime.utcnow()
|
||||
return True
|
||||
|
||||
|
||||
def _check_arbzg_day(entry: TimeEntry) -> list[str]:
|
||||
"""ArbZG-Prüfung für einen einzelnen Zeiteintrag."""
|
||||
if entry.end_time is None:
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
"""overtime cap and expiry config
|
||||
|
||||
Revision ID: 0031
|
||||
Revises: 0030
|
||||
Create Date: 2026-05-25
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = '0031'
|
||||
down_revision = '0030'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# Companies: Kappung + Verfall
|
||||
op.add_column('companies', sa.Column('overtime_cap_hours', sa.Integer(), nullable=True))
|
||||
op.add_column('companies', sa.Column('overtime_expiry_enabled', sa.Boolean(), nullable=False, server_default='false'))
|
||||
op.add_column('companies', sa.Column('overtime_expiry_month', sa.Integer(), nullable=False, server_default='3'))
|
||||
op.add_column('companies', sa.Column('overtime_expiry_day', sa.Integer(), nullable=False, server_default='31'))
|
||||
op.add_column('companies', sa.Column('overtime_max_carryover_hours', sa.Integer(), nullable=True))
|
||||
# OvertimeBalance: Verfall-Zeitstempel
|
||||
op.add_column('overtime_balances', sa.Column('last_expiry_applied_at', sa.DateTime(timezone=True), nullable=True))
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_column('companies', 'overtime_cap_hours')
|
||||
op.drop_column('companies', 'overtime_expiry_enabled')
|
||||
op.drop_column('companies', 'overtime_expiry_month')
|
||||
op.drop_column('companies', 'overtime_expiry_day')
|
||||
op.drop_column('companies', 'overtime_max_carryover_hours')
|
||||
op.drop_column('overtime_balances', 'last_expiry_applied_at')
|
||||
Reference in New Issue
Block a user