This commit is contained in:
2026-03-25 22:03:39 +01:00
parent a0073b4fb1
commit faf4ca10c9
5603 changed files with 1030866 additions and 79 deletions

View File

@@ -0,0 +1 @@
# -*- coding: utf-8 -*-

View File

@@ -0,0 +1,141 @@
# -*- coding: utf-8 -*-
"""Repository für Absence- und BalanceAccount-CRUD."""
import datetime
from typing import Optional
from sqlalchemy.orm import Session
from sqlalchemy import and_, func
from ..core.models import Absence, BalanceAccount, Employee
from ..core.enums import AbsenceStatus, AbsenceCategory, ABSENCE_META
from ..core.schemas import AbsenceCreate, AbsenceUpdate
class AbsenceRepository:
def __init__(self, db: Session):
self.db = db
def get_by_id(self, absence_id: str) -> Optional[Absence]:
return self.db.query(Absence).filter(Absence.id == absence_id).first()
def list_for_employee(
self, employee_id: str, year: Optional[int] = None
) -> list[Absence]:
q = self.db.query(Absence).filter(Absence.employee_id == employee_id)
if year:
jan1 = datetime.date(year, 1, 1)
dec31 = datetime.date(year, 12, 31)
q = q.filter(Absence.end_date >= jan1, Absence.start_date <= dec31)
return q.order_by(Absence.start_date).all()
def list_for_period(
self, start: datetime.date, end: datetime.date, status: Optional[AbsenceStatus] = None
) -> list[Absence]:
q = self.db.query(Absence).filter(
Absence.end_date >= start,
Absence.start_date <= end,
)
if status:
q = q.filter(Absence.status == status)
return q.order_by(Absence.start_date).all()
def list_all(self, year: Optional[int] = None) -> list[Absence]:
q = self.db.query(Absence)
if year:
jan1 = datetime.date(year, 1, 1)
dec31 = datetime.date(year, 12, 31)
q = q.filter(Absence.end_date >= jan1, Absence.start_date <= dec31)
return q.order_by(Absence.start_date).all()
def find_overlapping(
self, employee_id: str, start: datetime.date, end: datetime.date,
exclude_id: Optional[str] = None,
) -> list[Absence]:
q = self.db.query(Absence).filter(
Absence.employee_id == employee_id,
Absence.end_date >= start,
Absence.start_date <= end,
Absence.status != AbsenceStatus.CANCELLED,
)
if exclude_id:
q = q.filter(Absence.id != exclude_id)
return q.all()
def count_absent_on_date(self, date: datetime.date) -> int:
return self.db.query(Absence).filter(
Absence.start_date <= date,
Absence.end_date >= date,
Absence.status.in_([AbsenceStatus.APPROVED, AbsenceStatus.PENDING]),
).count()
def create(self, data: AbsenceCreate, business_days: float) -> Absence:
absence = Absence(
**data.model_dump(),
business_days=business_days,
)
self.db.add(absence)
self.db.flush()
return absence
def update(self, absence_id: str, data: AbsenceUpdate) -> Optional[Absence]:
absence = self.get_by_id(absence_id)
if not absence:
return None
for field, value in data.model_dump(exclude_unset=True).items():
setattr(absence, field, value)
self.db.flush()
return absence
def delete(self, absence_id: str) -> bool:
absence = self.get_by_id(absence_id)
if not absence:
return False
self.db.delete(absence)
self.db.flush()
return True
def used_days(self, employee_id: str, year: int) -> float:
"""Verbrauchte Ferientage (nur Kategorien mit deducts_balance)."""
deducting = [
cat for cat, meta in ABSENCE_META.items() if meta["deducts_balance"]
]
rows = (
self.db.query(Absence)
.filter(
Absence.employee_id == employee_id,
Absence.category.in_(deducting),
Absence.status != AbsenceStatus.CANCELLED,
Absence.end_date >= datetime.date(year, 1, 1),
Absence.start_date <= datetime.date(year, 12, 31),
)
.all()
)
return sum(r.business_days for r in rows)
class BalanceRepository:
def __init__(self, db: Session):
self.db = db
def get_or_create(self, employee_id: str, year: int) -> BalanceAccount:
ba = (
self.db.query(BalanceAccount)
.filter(BalanceAccount.employee_id == employee_id, BalanceAccount.year == year)
.first()
)
if not ba:
emp = self.db.query(Employee).filter(Employee.id == employee_id).first()
quota = emp.vacation_days_per_year if emp else 25
ba = BalanceAccount(employee_id=employee_id, year=year, yearly_quota=quota)
self.db.add(ba)
self.db.flush()
return ba
def update(self, employee_id: str, year: int, **kwargs) -> BalanceAccount:
ba = self.get_or_create(employee_id, year)
for k, v in kwargs.items():
if hasattr(ba, k) and v is not None:
setattr(ba, k, v)
self.db.flush()
return ba

View File

@@ -0,0 +1,106 @@
# -*- coding: utf-8 -*-
"""Business Rules Validierungsregeln für Abwesenheiten.
Jede Regel ist eine Funktion die (db, AbsenceCreate) nimmt und
bei Verstoss eine RuleViolation raised.
"""
import datetime
from sqlalchemy.orm import Session
from ..core.schemas import AbsenceCreate
from ..core.enums import ABSENCE_META
from ..employees.repository import EmployeeRepository
from .repository import AbsenceRepository, BalanceRepository
from ..config import MIN_STAFF_COUNT
class RuleViolation(Exception):
"""Wird geworfen wenn eine Business Rule verletzt ist."""
def __init__(self, rule: str, message: str):
self.rule = rule
self.message = message
super().__init__(f"[{rule}] {message}")
def _business_days(start: datetime.date, end: datetime.date) -> float:
count = 0
d = start
while d <= end:
if d.weekday() < 5:
count += 1
d += datetime.timedelta(days=1)
return count
def check_no_overlap(db: Session, data: AbsenceCreate):
"""Keine überlappenden Abwesenheiten für denselben Mitarbeiter."""
repo = AbsenceRepository(db)
overlaps = repo.find_overlapping(data.employee_id, data.start_date, data.end_date)
if overlaps:
first = overlaps[0]
raise RuleViolation(
"NO_OVERLAP",
f"Überschneidung mit bestehender Abwesenheit "
f"({first.start_date} {first.end_date}, {first.category.value})"
)
def check_balance(db: Session, data: AbsenceCreate):
"""Ferientage-Kontingent prüfen (nur für Kategorien mit deducts_balance)."""
meta = ABSENCE_META.get(data.category, {})
if not meta.get("deducts_balance"):
return
year = data.start_date.year
abs_repo = AbsenceRepository(db)
bal_repo = BalanceRepository(db)
balance = bal_repo.get_or_create(data.employee_id, year)
used = abs_repo.used_days(data.employee_id, year)
needed = _business_days(data.start_date, data.end_date)
remaining = balance.total_available - used
if needed > remaining:
raise RuleViolation(
"INSUFFICIENT_BALANCE",
f"Nicht genügend Ferientage. Benötigt: {needed:.0f}, "
f"Verfügbar: {remaining:.0f} (Anspruch: {balance.total_available:.0f}, "
f"Genommen: {used:.0f})"
)
def check_min_staffing(db: Session, data: AbsenceCreate):
"""Mindestbesetzung prüfen nicht alle gleichzeitig abwesend."""
emp_repo = EmployeeRepository(db)
abs_repo = AbsenceRepository(db)
total_active = emp_repo.count_active()
if total_active <= MIN_STAFF_COUNT:
return
d = data.start_date
while d <= data.end_date:
if d.weekday() < 5:
absent_count = abs_repo.count_absent_on_date(d)
present = total_active - absent_count - 1
if present < MIN_STAFF_COUNT:
raise RuleViolation(
"MIN_STAFFING",
f"Am {d.strftime('%d.%m.%Y')} wären nur noch {present} "
f"Mitarbeiter anwesend (Minimum: {MIN_STAFF_COUNT})"
)
d += datetime.timedelta(days=1)
ALL_RULES = [check_no_overlap, check_balance, check_min_staffing]
def validate_absence(db: Session, data: AbsenceCreate, skip_rules: list[str] | None = None):
"""Alle Regeln durchlaufen. Wirft RuleViolation beim ersten Verstoss."""
skip = set(skip_rules or [])
for rule_fn in ALL_RULES:
if rule_fn.__name__ in skip:
continue
rule_fn(db, data)

View File

@@ -0,0 +1,127 @@
# -*- coding: utf-8 -*-
"""Service Layer Orchestriert Business-Logik für Abwesenheiten."""
import datetime
from typing import Optional
from sqlalchemy.orm import Session
from ..core.models import Absence
from ..core.enums import AbsenceStatus, ABSENCE_META
from ..core.schemas import (
AbsenceCreate, AbsenceUpdate, AbsenceRead,
BalanceRead, BalanceAdjust, ApprovalDecision,
)
from .repository import AbsenceRepository, BalanceRepository
from .rules import validate_absence, _business_days, RuleViolation
from ..employees.repository import EmployeeRepository
class AbsenceService:
"""Zentrale Geschäftslogik wird von API UND Desktop-Client genutzt."""
def __init__(self, db: Session):
self.db = db
self.abs_repo = AbsenceRepository(db)
self.bal_repo = BalanceRepository(db)
self.emp_repo = EmployeeRepository(db)
def create_absence(self, data: AbsenceCreate) -> Absence:
emp = self.emp_repo.get_by_id(data.employee_id)
if not emp:
raise ValueError(f"Mitarbeiter {data.employee_id} nicht gefunden")
validate_absence(self.db, data)
bd = _business_days(data.start_date, data.end_date)
meta = ABSENCE_META.get(data.category, {})
absence = self.abs_repo.create(data, business_days=bd)
if not meta.get("requires_approval"):
absence.status = AbsenceStatus.APPROVED
self.db.commit()
return absence
def update_absence(self, absence_id: str, data: AbsenceUpdate) -> Optional[Absence]:
absence = self.abs_repo.get_by_id(absence_id)
if not absence:
return None
if data.start_date or data.end_date:
start = data.start_date or absence.start_date
end = data.end_date or absence.end_date
check_data = AbsenceCreate(
employee_id=absence.employee_id,
category=data.category or absence.category,
start_date=start,
end_date=end,
reason=data.reason or absence.reason,
)
validate_absence(self.db, check_data, skip_rules=[])
absence.business_days = _business_days(start, end)
result = self.abs_repo.update(absence_id, data)
self.db.commit()
return result
def delete_absence(self, absence_id: str) -> bool:
ok = self.abs_repo.delete(absence_id)
if ok:
self.db.commit()
return ok
def approve(self, decision: ApprovalDecision) -> Absence:
absence = self.abs_repo.get_by_id(decision.absence_id)
if not absence:
raise ValueError("Abwesenheit nicht gefunden")
if absence.status != AbsenceStatus.PENDING:
raise ValueError(f"Status ist bereits {absence.status.value}")
absence.approver_id = decision.approver_id
absence.approved_at = datetime.datetime.utcnow()
absence.status = AbsenceStatus.APPROVED if decision.approved else AbsenceStatus.REJECTED
self.db.commit()
return absence
def get_absences_for_employee(self, employee_id: str, year: Optional[int] = None) -> list[Absence]:
return self.abs_repo.list_for_employee(employee_id, year)
def get_absences_for_period(
self, start: datetime.date, end: datetime.date
) -> list[Absence]:
return self.abs_repo.list_for_period(start, end)
def get_all_absences(self, year: Optional[int] = None) -> list[Absence]:
return self.abs_repo.list_all(year)
def get_balance(self, employee_id: str, year: int) -> BalanceRead:
emp = self.emp_repo.get_by_id(employee_id)
if not emp:
raise ValueError(f"Mitarbeiter {employee_id} nicht gefunden")
ba = self.bal_repo.get_or_create(employee_id, year)
used = self.abs_repo.used_days(employee_id, year)
return BalanceRead(
employee_id=employee_id,
employee_name=emp.name,
year=year,
yearly_quota=ba.yearly_quota,
carry_over=ba.carry_over,
manual_adjustment=ba.manual_adjustment,
total_available=ba.total_available,
used_days=used,
remaining=ba.total_available - used,
)
def adjust_balance(self, employee_id: str, year: int, data: BalanceAdjust) -> BalanceRead:
self.bal_repo.update(
employee_id, year,
carry_over=data.carry_over,
manual_adjustment=data.manual_adjustment,
yearly_quota=data.yearly_quota,
)
self.db.commit()
return self.get_balance(employee_id, year)