Files
stundenfuchs/app/services/overtime.py
T
maddin 9794362f39
CI / checks (push) Has been cancelled
chore: initialize public repository
2026-03-22 12:55:55 +00:00

247 lines
9.1 KiB
Python

from datetime import date, timedelta
from app.services.calculations import compute_net_minutes
from app.services.targets import monday_of, target_map_for_weeks, week_starts_between
from app.services.vacations import expand_vacation_dates
from app.services.workdays import DEFAULT_WORKING_DAYS, is_workday
def compute_effective_span_totals(
*,
entries: list,
range_start: date,
range_end: date,
weekly_target_minutes: int,
vacation_dates: set[date] | None,
non_working_dates: set[date] | None,
count_as_worktime_dates: set[date] | None,
overtime_adjustment_minutes_by_date: dict[date, int] | None,
overtime_start_date: date | None,
relevant_weekdays: set[int] | None = None,
) -> dict[str, int]:
if range_end < range_start:
return {
"ist_minutes": 0,
"soll_minutes": 0,
"delta_minutes": 0,
"eligible_workdays": 0,
"vacation_workdays": 0,
}
blocked_before = overtime_start_date
vacation_dates = vacation_dates or set()
non_working_dates = non_working_dates or set()
count_as_worktime_dates = count_as_worktime_dates or set()
overtime_adjustment_minutes_by_date = overtime_adjustment_minutes_by_date or {}
relevant_weekdays = relevant_weekdays or set(DEFAULT_WORKING_DAYS)
workdays_per_week = max(1, len(relevant_weekdays))
net_by_date: dict[date, int] = {}
for entry in entries:
if entry.date < range_start or entry.date > range_end:
continue
net_by_date[entry.date] = compute_net_minutes(
entry.start_minutes,
entry.end_minutes,
entry.break_minutes,
)
eligible_workdays = 0
vacation_workdays = 0
ist_minutes = 0
overtime_adjustment_minutes = 0
current = range_start
while current <= range_end:
overtime_adjustment_minutes += int(overtime_adjustment_minutes_by_date.get(current, 0))
if blocked_before is None or current >= blocked_before:
day_counts_as_worktime = current in count_as_worktime_dates and is_workday(current, relevant_weekdays)
day_target_minutes = int(round(weekly_target_minutes / workdays_per_week)) if is_workday(current, relevant_weekdays) else 0
if day_counts_as_worktime:
ist_minutes += day_target_minutes
elif current not in non_working_dates:
ist_minutes += net_by_date.get(current, 0)
if is_workday(current, relevant_weekdays):
if current in vacation_dates and not day_counts_as_worktime:
vacation_workdays += 1
elif current in non_working_dates and not day_counts_as_worktime:
pass
else:
eligible_workdays += 1
current += timedelta(days=1)
soll_minutes = int(round((weekly_target_minutes / workdays_per_week) * eligible_workdays))
delta_minutes = ist_minutes - soll_minutes + overtime_adjustment_minutes
return {
"ist_minutes": ist_minutes,
"soll_minutes": soll_minutes,
"delta_minutes": delta_minutes,
"eligible_workdays": eligible_workdays,
"vacation_workdays": vacation_workdays,
"overtime_adjustment_minutes": overtime_adjustment_minutes,
}
def compute_effective_week_totals(
*,
entries: list,
week_start: date,
weekly_target_minutes: int,
vacation_dates: set[date] | None,
non_working_dates: set[date] | None,
count_as_worktime_dates: set[date] | None,
overtime_adjustment_minutes_by_date: dict[date, int] | None,
overtime_start_date: date | None,
relevant_weekdays: set[int] | None = None,
) -> dict[str, int]:
week_end = week_start + timedelta(days=6)
totals = compute_effective_span_totals(
entries=entries,
range_start=week_start,
range_end=week_end,
weekly_target_minutes=weekly_target_minutes,
vacation_dates=vacation_dates,
non_working_dates=non_working_dates,
count_as_worktime_dates=count_as_worktime_dates,
overtime_adjustment_minutes_by_date=overtime_adjustment_minutes_by_date,
overtime_start_date=overtime_start_date,
relevant_weekdays=relevant_weekdays,
)
return {
"weekly_ist": totals["ist_minutes"],
"weekly_soll": totals["soll_minutes"],
"weekly_delta": totals["delta_minutes"],
}
def compute_cumulative_overtime_minutes(
*,
entries: list,
rules: list,
weekly_target_fallback: int,
vacation_periods: list,
non_working_dates: set[date] | None,
count_as_worktime_dates: set[date] | None,
overtime_adjustment_minutes_by_date: dict[date, int] | None,
selected_week_start: date,
overtime_start_date: date | None,
overtime_expiry_days: int | None,
expire_negative_overtime: bool,
relevant_weekdays: set[int] | None = None,
) -> int:
selected_week_end = selected_week_start + timedelta(days=6)
return compute_cumulative_overtime_until_date(
entries=entries,
rules=rules,
weekly_target_fallback=weekly_target_fallback,
vacation_periods=vacation_periods,
non_working_dates=non_working_dates,
count_as_worktime_dates=count_as_worktime_dates,
overtime_adjustment_minutes_by_date=overtime_adjustment_minutes_by_date,
as_of_date=selected_week_end,
overtime_start_date=overtime_start_date,
overtime_expiry_days=overtime_expiry_days,
expire_negative_overtime=expire_negative_overtime,
relevant_weekdays=relevant_weekdays,
)
def compute_cumulative_overtime_until_date(
*,
entries: list,
rules: list,
weekly_target_fallback: int,
vacation_periods: list,
non_working_dates: set[date] | None,
count_as_worktime_dates: set[date] | None,
overtime_adjustment_minutes_by_date: dict[date, int] | None,
as_of_date: date,
overtime_start_date: date | None,
overtime_expiry_days: int | None,
expire_negative_overtime: bool,
relevant_weekdays: set[int] | None = None,
) -> int:
relevant_weekdays = relevant_weekdays or set(DEFAULT_WORKING_DAYS)
workdays_per_week = max(1, len(relevant_weekdays))
overtime_adjustment_minutes_by_date = overtime_adjustment_minutes_by_date or {}
earliest_entry_date = min((entry.date for entry in entries), default=None)
earliest_adjustment_date = min(overtime_adjustment_minutes_by_date.keys(), default=None)
range_start_candidates = [candidate for candidate in [earliest_entry_date, earliest_adjustment_date] if candidate is not None]
if not range_start_candidates:
return 0
range_start = min(range_start_candidates)
if range_start > as_of_date:
return 0
first_week_start = monday_of(range_start)
relevant_weeks = week_starts_between(first_week_start, monday_of(as_of_date))
base_target_map = target_map_for_weeks(rules, relevant_weeks, weekly_target_fallback)
vacation_dates = expand_vacation_dates(
vacation_periods,
range_start,
as_of_date,
relevant_weekdays=relevant_weekdays,
)
non_working_dates = non_working_dates or set()
count_as_worktime_dates = count_as_worktime_dates or set()
net_by_date: dict[date, int] = {}
for entry in entries:
if entry.date < range_start or entry.date > as_of_date:
continue
net_by_date[entry.date] = compute_net_minutes(
entry.start_minutes,
entry.end_minutes,
entry.break_minutes,
)
cutoff_date: date | None = None
if overtime_expiry_days is not None and overtime_expiry_days > 0:
cutoff_date = as_of_date - timedelta(days=overtime_expiry_days)
total = 0.0
current = range_start
while current <= as_of_date:
week_start = monday_of(current)
weekly_target = base_target_map.get(week_start, weekly_target_fallback)
day_adjustment = float(overtime_adjustment_minutes_by_date.get(current, 0))
regular_delta_allowed = overtime_start_date is None or current >= overtime_start_date
day_counts_as_worktime = current in count_as_worktime_dates and current.weekday() in relevant_weekdays
if regular_delta_allowed and current.weekday() in relevant_weekdays and (current not in vacation_dates or day_counts_as_worktime):
if current in non_working_dates and not day_counts_as_worktime:
day_target = 0.0
else:
day_target = weekly_target / workdays_per_week
else:
day_target = 0.0
if regular_delta_allowed:
if day_counts_as_worktime:
day_net = day_target
else:
day_net = 0.0 if current in non_working_dates else float(net_by_date.get(current, 0))
else:
day_net = 0.0
delta = day_net - day_target + day_adjustment
expired = cutoff_date is not None and current < cutoff_date
if expired:
if delta > 0:
current += timedelta(days=1)
continue
if delta < 0 and expire_negative_overtime:
current += timedelta(days=1)
continue
total += delta
current += timedelta(days=1)
return int(round(total))