127 lines
4.1 KiB
Python
127 lines
4.1 KiB
Python
from collections import defaultdict
|
|
from datetime import date, datetime, timedelta
|
|
import re
|
|
|
|
|
|
def parse_time_to_minutes(value: str) -> int:
|
|
if not re.fullmatch(r"([01]\d|2[0-3]):[0-5]\d", value):
|
|
raise ValueError("Uhrzeit muss im Format HH:MM sein")
|
|
try:
|
|
parsed = datetime.strptime(value, "%H:%M")
|
|
except ValueError as exc:
|
|
raise ValueError("Uhrzeit muss im Format HH:MM sein") from exc
|
|
return parsed.hour * 60 + parsed.minute
|
|
|
|
|
|
def minutes_to_hhmm(minutes: int) -> str:
|
|
sign = "-" if minutes < 0 else ""
|
|
minutes_abs = abs(minutes)
|
|
hours = minutes_abs // 60
|
|
mins = minutes_abs % 60
|
|
return f"{sign}{hours:02d}:{mins:02d}"
|
|
|
|
|
|
def validate_entry(start_minutes: int, end_minutes: int, break_minutes: int) -> None:
|
|
if end_minutes <= start_minutes:
|
|
raise ValueError("Arbeitsende muss nach Arbeitsbeginn liegen")
|
|
|
|
if break_minutes < 0:
|
|
raise ValueError("Pause darf nicht negativ sein")
|
|
|
|
gross_minutes = end_minutes - start_minutes
|
|
if break_minutes > gross_minutes:
|
|
raise ValueError("Pause darf nicht laenger als die Arbeitszeit sein")
|
|
|
|
|
|
def required_break_minutes_for_span(work_span_minutes: int) -> int:
|
|
if work_span_minutes > 9 * 60:
|
|
return 45
|
|
if work_span_minutes > 6 * 60:
|
|
return 30
|
|
return 0
|
|
|
|
|
|
def automatic_break_minutes(start_minutes: int, end_minutes: int) -> int:
|
|
if end_minutes <= start_minutes:
|
|
raise ValueError("Arbeitsende muss nach Arbeitsbeginn liegen")
|
|
return required_break_minutes_for_span(end_minutes - start_minutes)
|
|
|
|
|
|
def automatic_break_minutes_for_net_minutes(net_minutes: int) -> int:
|
|
if net_minutes < 0:
|
|
raise ValueError("Nettoarbeitszeit darf nicht negativ sein")
|
|
if net_minutes > (9 * 60 - 45):
|
|
return 45
|
|
if net_minutes > (6 * 60 - 30):
|
|
return 30
|
|
return 0
|
|
|
|
|
|
def compute_net_minutes(start_minutes: int, end_minutes: int, break_minutes: int) -> int:
|
|
validate_entry(start_minutes, end_minutes, break_minutes)
|
|
return (end_minutes - start_minutes) - break_minutes
|
|
|
|
|
|
def iso_week_bounds(day: date) -> tuple[date, date]:
|
|
week_start = day - timedelta(days=day.weekday())
|
|
week_end = week_start + timedelta(days=6)
|
|
return week_start, week_end
|
|
|
|
|
|
def daterange(start: date, end: date):
|
|
current = start
|
|
while current <= end:
|
|
yield current
|
|
current += timedelta(days=1)
|
|
|
|
|
|
def aggregate_week(entries: list, week_start: date, weekly_target_minutes: int) -> dict:
|
|
week_end = week_start + timedelta(days=6)
|
|
entries_by_date = {entry.date: entry for entry in entries}
|
|
|
|
days = []
|
|
weekly_ist = 0
|
|
for day in daterange(week_start, week_end):
|
|
entry = entries_by_date.get(day)
|
|
if entry is None:
|
|
days.append({"date": day, "entry": None, "net_minutes": 0})
|
|
continue
|
|
|
|
net_minutes = compute_net_minutes(entry.start_minutes, entry.end_minutes, entry.break_minutes)
|
|
weekly_ist += net_minutes
|
|
days.append({"date": day, "entry": entry, "net_minutes": net_minutes})
|
|
|
|
weekly_delta = weekly_ist - weekly_target_minutes
|
|
|
|
return {
|
|
"week_start": week_start,
|
|
"week_end": week_end,
|
|
"days": days,
|
|
"weekly_ist": weekly_ist,
|
|
"weekly_soll": weekly_target_minutes,
|
|
"weekly_delta": weekly_delta,
|
|
}
|
|
|
|
|
|
def cumulative_delta(entries: list, selected_week_start: date, weekly_target_minutes: int) -> int:
|
|
if not entries:
|
|
return 0
|
|
|
|
earliest_entry_date = min(entry.date for entry in entries)
|
|
current_week_start = earliest_entry_date - timedelta(days=earliest_entry_date.weekday())
|
|
|
|
net_by_week_start = defaultdict(int)
|
|
for entry in entries:
|
|
week_start, _ = iso_week_bounds(entry.date)
|
|
net_by_week_start[week_start] += compute_net_minutes(
|
|
entry.start_minutes, entry.end_minutes, entry.break_minutes
|
|
)
|
|
|
|
running = 0
|
|
while current_week_start <= selected_week_start:
|
|
weekly_ist = net_by_week_start[current_week_start]
|
|
running += weekly_ist - weekly_target_minutes
|
|
current_week_start += timedelta(days=7)
|
|
|
|
return running
|