update
This commit is contained in:
1
AzA march 2026/workforce_planner/absences/__init__.py
Normal file
1
AzA march 2026/workforce_planner/absences/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
141
AzA march 2026/workforce_planner/absences/repository.py
Normal file
141
AzA march 2026/workforce_planner/absences/repository.py
Normal 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
|
||||
106
AzA march 2026/workforce_planner/absences/rules.py
Normal file
106
AzA march 2026/workforce_planner/absences/rules.py
Normal 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)
|
||||
127
AzA march 2026/workforce_planner/absences/service.py
Normal file
127
AzA march 2026/workforce_planner/absences/service.py
Normal 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)
|
||||
Reference in New Issue
Block a user