333 lines
10 KiB
Python
333 lines
10 KiB
Python
from datetime import date, timedelta
|
|
|
|
from sqlalchemy import select
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.models import AutoEntrySkip, OvertimeAdjustment, SpecialDayStatus, TimeEntry, User
|
|
from app.services.calculations import automatic_break_minutes_for_net_minutes, compute_net_minutes
|
|
from app.services.targets import list_rules_for_user, monday_of, target_for_week
|
|
from app.services.vacations import expand_vacation_dates, list_vacations_for_user
|
|
from app.services.workdays import parse_working_days_csv
|
|
|
|
ENTRY_MODE_MANUAL = "manual"
|
|
ENTRY_MODE_AUTO_UNTIL_TODAY = "auto_until_today"
|
|
AUTO_ENTRY_NOTE = "Automatisch vorausgefuellt"
|
|
SPECIAL_DAY_STATUS_HOLIDAY = "holiday"
|
|
SPECIAL_DAY_STATUS_SICK = "sick"
|
|
|
|
|
|
def get_user_working_days(user: User) -> set[int]:
|
|
return parse_working_days_csv(user.working_days_csv)
|
|
|
|
|
|
def list_special_statuses_for_user(
|
|
db: Session,
|
|
user_id: str,
|
|
from_date: date,
|
|
to_date: date,
|
|
) -> list[SpecialDayStatus]:
|
|
stmt = (
|
|
select(SpecialDayStatus)
|
|
.where(
|
|
SpecialDayStatus.user_id == user_id,
|
|
SpecialDayStatus.date >= from_date,
|
|
SpecialDayStatus.date <= to_date,
|
|
)
|
|
.order_by(SpecialDayStatus.date.asc())
|
|
)
|
|
return db.execute(stmt).scalars().all()
|
|
|
|
|
|
def special_status_map(periods: list[SpecialDayStatus]) -> dict[date, str]:
|
|
return {period.date: period.status for period in periods}
|
|
|
|
|
|
def special_status_dates(periods: list[SpecialDayStatus]) -> set[date]:
|
|
return {period.date for period in periods}
|
|
|
|
|
|
def count_as_worktime_dates_for_user(
|
|
*,
|
|
user: User,
|
|
vacation_dates: set[date],
|
|
special_statuses: list[SpecialDayStatus],
|
|
) -> set[date]:
|
|
dates: set[date] = set()
|
|
if user.count_vacation_as_worktime:
|
|
dates.update(vacation_dates)
|
|
if user.count_holiday_as_worktime:
|
|
dates.update(period.date for period in special_statuses if period.status == SPECIAL_DAY_STATUS_HOLIDAY)
|
|
if user.count_sick_as_worktime:
|
|
dates.update(period.date for period in special_statuses if period.status == SPECIAL_DAY_STATUS_SICK)
|
|
return dates
|
|
|
|
|
|
def effective_non_working_dates_for_user(
|
|
*,
|
|
user: User,
|
|
special_statuses: list[SpecialDayStatus],
|
|
) -> set[date]:
|
|
blocked: set[date] = set()
|
|
for period in special_statuses:
|
|
if period.status == SPECIAL_DAY_STATUS_HOLIDAY and user.count_holiday_as_worktime:
|
|
continue
|
|
if period.status == SPECIAL_DAY_STATUS_SICK and user.count_sick_as_worktime:
|
|
continue
|
|
blocked.add(period.date)
|
|
return blocked
|
|
|
|
|
|
def clear_special_status_for_date(*, db: Session, user_id: str, day: date) -> None:
|
|
stmt = select(SpecialDayStatus).where(SpecialDayStatus.user_id == user_id, SpecialDayStatus.date == day)
|
|
existing = db.execute(stmt).scalar_one_or_none()
|
|
if existing:
|
|
db.delete(existing)
|
|
|
|
|
|
def list_overtime_adjustments_for_user(
|
|
db: Session,
|
|
user_id: str,
|
|
from_date: date,
|
|
to_date: date,
|
|
) -> list[OvertimeAdjustment]:
|
|
stmt = (
|
|
select(OvertimeAdjustment)
|
|
.where(
|
|
OvertimeAdjustment.user_id == user_id,
|
|
OvertimeAdjustment.date >= from_date,
|
|
OvertimeAdjustment.date <= to_date,
|
|
)
|
|
.order_by(OvertimeAdjustment.date.asc())
|
|
)
|
|
return db.execute(stmt).scalars().all()
|
|
|
|
|
|
def overtime_adjustment_map(adjustments: list[OvertimeAdjustment]) -> dict[date, OvertimeAdjustment]:
|
|
return {adjustment.date: adjustment for adjustment in adjustments}
|
|
|
|
|
|
def overtime_adjustment_minutes_map(adjustments: list[OvertimeAdjustment]) -> dict[date, int]:
|
|
return {adjustment.date: adjustment.minutes for adjustment in adjustments}
|
|
|
|
|
|
def clear_overtime_adjustment_for_date(*, db: Session, user_id: str, day: date) -> None:
|
|
stmt = select(OvertimeAdjustment).where(OvertimeAdjustment.user_id == user_id, OvertimeAdjustment.date == day)
|
|
existing = db.execute(stmt).scalar_one_or_none()
|
|
if existing:
|
|
db.delete(existing)
|
|
|
|
|
|
def auto_entry_skip_dates_for_user(
|
|
db: Session,
|
|
user_id: str,
|
|
from_date: date,
|
|
to_date: date,
|
|
) -> set[date]:
|
|
stmt = (
|
|
select(AutoEntrySkip.date)
|
|
.where(
|
|
AutoEntrySkip.user_id == user_id,
|
|
AutoEntrySkip.date >= from_date,
|
|
AutoEntrySkip.date <= to_date,
|
|
)
|
|
.order_by(AutoEntrySkip.date.asc())
|
|
)
|
|
return set(db.execute(stmt).scalars().all())
|
|
|
|
|
|
def mark_auto_entry_skip_for_date(*, db: Session, user_id: str, day: date) -> None:
|
|
stmt = select(AutoEntrySkip).where(AutoEntrySkip.user_id == user_id, AutoEntrySkip.date == day)
|
|
existing = db.execute(stmt).scalar_one_or_none()
|
|
if not existing:
|
|
db.add(AutoEntrySkip(user_id=user_id, date=day))
|
|
|
|
|
|
def clear_auto_entry_skip_for_date(*, db: Session, user_id: str, day: date) -> None:
|
|
stmt = select(AutoEntrySkip).where(AutoEntrySkip.user_id == user_id, AutoEntrySkip.date == day)
|
|
existing = db.execute(stmt).scalar_one_or_none()
|
|
if existing:
|
|
db.delete(existing)
|
|
|
|
|
|
def build_auto_day_entry(
|
|
*,
|
|
weekly_target_minutes: int,
|
|
workdays_per_week: int,
|
|
automatic_break_rules_enabled: bool,
|
|
default_break_minutes: int,
|
|
) -> tuple[int, int, int] | None:
|
|
if workdays_per_week <= 0:
|
|
return None
|
|
|
|
day_net_minutes = int(round(weekly_target_minutes / workdays_per_week))
|
|
if day_net_minutes <= 0:
|
|
return None
|
|
|
|
start_minutes = 8 * 60 + 30
|
|
break_minutes = (
|
|
automatic_break_minutes_for_net_minutes(day_net_minutes)
|
|
if automatic_break_rules_enabled
|
|
else max(0, default_break_minutes)
|
|
)
|
|
end_minutes = start_minutes + day_net_minutes + break_minutes
|
|
|
|
if end_minutes > (24 * 60 - 1):
|
|
end_minutes = 24 * 60 - 1
|
|
available_span = end_minutes - start_minutes
|
|
if available_span <= 0:
|
|
return None
|
|
break_minutes = min(break_minutes, max(0, available_span - 1))
|
|
|
|
return start_minutes, end_minutes, break_minutes
|
|
|
|
|
|
def auto_entry_sync_start_date(user: User) -> date:
|
|
if user.overtime_start_date:
|
|
return user.overtime_start_date
|
|
return user.created_at.date()
|
|
|
|
|
|
def delete_future_auto_entries(
|
|
*,
|
|
db: Session,
|
|
user_id: str,
|
|
after_date: date,
|
|
) -> int:
|
|
stmt = (
|
|
select(TimeEntry)
|
|
.where(
|
|
TimeEntry.user_id == user_id,
|
|
TimeEntry.date > after_date,
|
|
TimeEntry.notes == AUTO_ENTRY_NOTE,
|
|
)
|
|
.order_by(TimeEntry.date.asc())
|
|
)
|
|
entries = db.execute(stmt).scalars().all()
|
|
for entry in entries:
|
|
db.delete(entry)
|
|
return len(entries)
|
|
|
|
|
|
def autofill_entries_for_range(
|
|
*,
|
|
db: Session,
|
|
user: User,
|
|
range_start: date,
|
|
range_end: date,
|
|
) -> int:
|
|
if user.entry_mode != ENTRY_MODE_AUTO_UNTIL_TODAY:
|
|
return 0
|
|
if range_end < range_start:
|
|
return 0
|
|
|
|
effective_end = min(range_end, date.today())
|
|
effective_start = max(range_start, auto_entry_sync_start_date(user))
|
|
if effective_start > effective_end:
|
|
return 0
|
|
|
|
working_days = get_user_working_days(user)
|
|
if not working_days:
|
|
return 0
|
|
workdays_per_week = len(working_days)
|
|
|
|
rules = list_rules_for_user(db, user.id)
|
|
vacations = list_vacations_for_user(db, user.id, effective_start, effective_end)
|
|
vacation_dates = expand_vacation_dates(vacations, effective_start, effective_end, relevant_weekdays=working_days)
|
|
special_statuses = list_special_statuses_for_user(db, user.id, effective_start, effective_end)
|
|
special_dates = special_status_dates(special_statuses)
|
|
overtime_adjustments = list_overtime_adjustments_for_user(db, user.id, effective_start, effective_end)
|
|
adjustment_dates = set(overtime_adjustment_minutes_map(overtime_adjustments).keys())
|
|
skipped_auto_dates = auto_entry_skip_dates_for_user(db, user.id, effective_start, effective_end)
|
|
|
|
existing_dates_stmt = (
|
|
select(TimeEntry.date)
|
|
.where(
|
|
TimeEntry.user_id == user.id,
|
|
TimeEntry.date >= effective_start,
|
|
TimeEntry.date <= effective_end,
|
|
)
|
|
.order_by(TimeEntry.date.asc())
|
|
)
|
|
existing_dates = set(db.execute(existing_dates_stmt).scalars().all())
|
|
|
|
created = 0
|
|
cursor = effective_start
|
|
while cursor <= effective_end:
|
|
if cursor in existing_dates:
|
|
cursor += timedelta(days=1)
|
|
continue
|
|
if cursor in vacation_dates:
|
|
cursor += timedelta(days=1)
|
|
continue
|
|
if cursor in special_dates:
|
|
cursor += timedelta(days=1)
|
|
continue
|
|
if cursor in adjustment_dates:
|
|
cursor += timedelta(days=1)
|
|
continue
|
|
if cursor in skipped_auto_dates:
|
|
cursor += timedelta(days=1)
|
|
continue
|
|
if cursor.weekday() not in working_days:
|
|
cursor += timedelta(days=1)
|
|
continue
|
|
|
|
weekly_target_minutes = target_for_week(rules, monday_of(cursor), user.weekly_target_minutes)
|
|
entry_values = build_auto_day_entry(
|
|
weekly_target_minutes=weekly_target_minutes,
|
|
workdays_per_week=workdays_per_week,
|
|
automatic_break_rules_enabled=bool(user.automatic_break_rules_enabled),
|
|
default_break_minutes=user.default_break_minutes,
|
|
)
|
|
if entry_values is None:
|
|
cursor += timedelta(days=1)
|
|
continue
|
|
start_minutes, end_minutes, break_minutes = entry_values
|
|
|
|
db.add(
|
|
TimeEntry(
|
|
user_id=user.id,
|
|
date=cursor,
|
|
start_minutes=start_minutes,
|
|
end_minutes=end_minutes,
|
|
break_minutes=break_minutes,
|
|
break_rule_mode="auto",
|
|
notes=AUTO_ENTRY_NOTE,
|
|
)
|
|
)
|
|
existing_dates.add(cursor)
|
|
created += 1
|
|
cursor += timedelta(days=1)
|
|
|
|
return created
|
|
|
|
|
|
def sync_auto_entries_for_all_users(
|
|
*,
|
|
db: Session,
|
|
up_to_date: date | None = None,
|
|
) -> dict[str, int]:
|
|
effective_date = up_to_date or date.today()
|
|
stmt = (
|
|
select(User)
|
|
.where(
|
|
User.is_active.is_(True),
|
|
User.entry_mode == ENTRY_MODE_AUTO_UNTIL_TODAY,
|
|
)
|
|
.order_by(User.created_at.asc())
|
|
)
|
|
users = db.execute(stmt).scalars().all()
|
|
|
|
created = 0
|
|
deleted = 0
|
|
for user in users:
|
|
deleted += delete_future_auto_entries(db=db, user_id=user.id, after_date=effective_date)
|
|
created += autofill_entries_for_range(
|
|
db=db,
|
|
user=user,
|
|
range_start=auto_entry_sync_start_date(user),
|
|
range_end=effective_date,
|
|
)
|
|
|
|
return {"users": len(users), "created": created, "deleted_future": deleted}
|