mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-05-18 04:08:48 -05:00
b4486a627f
- Webhook models: remove duplicate index definitions so db.create_all() no longer raises 'index already exists' (columns already have index=True) - ImportService: fix circular import by late-importing ClientService, ProjectService, TimeTrackingService in __init__ - reports: fix F823 by renaming unpack variable _ to _entry_count to avoid shadowing gettext _ in export_task_excel() - Code quality: add .flake8 with extend-ignore so flake8 CI passes; simplify pyproject.toml isort config (drop unsupported options) - Format: run black and isort on app/ - tests: restore minimal app fixture in test_import_export_models
428 lines
16 KiB
Python
428 lines
16 KiB
Python
"""
|
|
Overtime Calculation Utilities
|
|
|
|
Provides functions to calculate overtime hours based on user's standard working hours
|
|
per day or per week (configurable via user.overtime_calculation_mode).
|
|
"""
|
|
|
|
from datetime import date, datetime, timedelta
|
|
from typing import Any, Dict, List, Optional, Tuple
|
|
|
|
|
|
def get_week_start_for_date(d: date, user: Any) -> date:
|
|
"""
|
|
Return the week-start date for a given date using the user's week_start_day.
|
|
|
|
User convention: 0=Sunday, 1=Monday, ..., 6=Saturday.
|
|
Python weekday: 0=Monday, 6=Sunday.
|
|
"""
|
|
week_start_day = getattr(user, "week_start_day", 1)
|
|
python_week_start = (week_start_day - 1) % 7
|
|
days_since = (d.weekday() - python_week_start) % 7
|
|
return d - timedelta(days=days_since)
|
|
|
|
|
|
def calculate_daily_overtime(total_hours: float, standard_hours: float) -> float:
|
|
"""
|
|
Calculate overtime hours for a single day.
|
|
|
|
Args:
|
|
total_hours: Total hours worked in a day
|
|
standard_hours: Standard working hours per day
|
|
|
|
Returns:
|
|
Overtime hours (0 if no overtime)
|
|
"""
|
|
if total_hours <= standard_hours:
|
|
return 0.0
|
|
return round(total_hours - standard_hours, 2)
|
|
|
|
|
|
def calculate_period_overtime(
|
|
user, start_date: date, end_date: date, include_weekends: Optional[bool] = None
|
|
) -> Dict[str, float]:
|
|
"""
|
|
Calculate overtime for a specific period.
|
|
|
|
Uses user.overtime_calculation_mode: 'daily' (cap per day) or 'weekly' (cap per week).
|
|
user.standard_hours_per_day is used in daily mode; user.standard_hours_per_week in weekly mode.
|
|
|
|
Args:
|
|
user: User object with standard_hours_per_day, optional overtime_include_weekends,
|
|
overtime_calculation_mode, standard_hours_per_week, week_start_day
|
|
start_date: Start date of the period
|
|
end_date: End date of the period
|
|
include_weekends: If None, use user.overtime_include_weekends; if True, weekend hours
|
|
count as regular/overtime like weekdays; if False, all weekend hours count as overtime.
|
|
|
|
Returns:
|
|
Dictionary with regular_hours, overtime_hours, undertime_hours, and total_hours
|
|
"""
|
|
mode = getattr(user, "overtime_calculation_mode", "daily") or "daily"
|
|
if mode == "weekly":
|
|
return _calculate_period_overtime_weekly(user, start_date, end_date, include_weekends)
|
|
|
|
if include_weekends is None:
|
|
include_weekends = getattr(user, "overtime_include_weekends", True)
|
|
from datetime import datetime as dt
|
|
|
|
from app.models import TimeEntry
|
|
|
|
start_datetime = dt.combine(start_date, dt.min.time())
|
|
end_datetime = dt.combine(end_date, dt.max.time())
|
|
|
|
entries = TimeEntry.query.filter(
|
|
TimeEntry.user_id == user.id,
|
|
TimeEntry.end_time.isnot(None),
|
|
TimeEntry.start_time >= start_datetime,
|
|
TimeEntry.start_time <= end_datetime,
|
|
).all()
|
|
|
|
daily_hours = {}
|
|
for entry in entries:
|
|
entry_date = entry.start_time.date()
|
|
hours = entry.duration_hours
|
|
if entry_date not in daily_hours:
|
|
daily_hours[entry_date] = 0.0
|
|
daily_hours[entry_date] += hours
|
|
|
|
standard_hours = getattr(user, "standard_hours_per_day", 8.0) or 8.0
|
|
total_regular = 0.0
|
|
total_overtime = 0.0
|
|
total_undertime = 0.0
|
|
days_under = 0
|
|
|
|
for day_date, hours in daily_hours.items():
|
|
if not include_weekends and day_date.weekday() >= 5:
|
|
total_overtime += hours
|
|
else:
|
|
if hours <= standard_hours:
|
|
total_regular += hours
|
|
undertime = max(0.0, standard_hours - hours)
|
|
if undertime > 0:
|
|
total_undertime += undertime
|
|
days_under += 1
|
|
else:
|
|
total_regular += standard_hours
|
|
total_overtime += hours - standard_hours
|
|
|
|
return {
|
|
"regular_hours": round(total_regular, 2),
|
|
"overtime_hours": round(total_overtime, 2),
|
|
"undertime_hours": round(total_undertime, 2),
|
|
"days_under": days_under,
|
|
"total_hours": round(total_regular + total_overtime, 2),
|
|
"days_with_overtime": sum(1 for h in daily_hours.values() if h > standard_hours),
|
|
}
|
|
|
|
|
|
def _calculate_period_overtime_weekly(
|
|
user, start_date: date, end_date: date, include_weekends: Optional[bool]
|
|
) -> Dict[str, float]:
|
|
"""Overtime for a period in weekly mode: group by week, cap at standard_hours_per_week."""
|
|
if include_weekends is None:
|
|
include_weekends = getattr(user, "overtime_include_weekends", True)
|
|
from datetime import datetime as dt
|
|
|
|
from app.models import TimeEntry
|
|
|
|
standard_weekly = getattr(user, "standard_hours_per_week", None)
|
|
if standard_weekly is None or standard_weekly <= 0:
|
|
standard_weekly = (getattr(user, "standard_hours_per_day", 8.0) or 8.0) * 5
|
|
|
|
start_datetime = dt.combine(start_date, dt.min.time())
|
|
end_datetime = dt.combine(end_date, dt.max.time())
|
|
|
|
entries = TimeEntry.query.filter(
|
|
TimeEntry.user_id == user.id,
|
|
TimeEntry.end_time.isnot(None),
|
|
TimeEntry.start_time >= start_datetime,
|
|
TimeEntry.start_time <= end_datetime,
|
|
).all()
|
|
|
|
# Group by week (week-start date)
|
|
weekly_hours: Dict[date, float] = {}
|
|
for entry in entries:
|
|
entry_date = entry.start_time.date()
|
|
if not include_weekends and entry_date.weekday() >= 5:
|
|
# Weekend: count as overtime in weekly total (add to week bucket but we'll treat all as overtime for that week's excess)
|
|
pass # Still add to week total; overtime is (total - standard), so weekend hours push total up
|
|
week_start = get_week_start_for_date(entry_date, user)
|
|
if week_start not in weekly_hours:
|
|
weekly_hours[week_start] = 0.0
|
|
weekly_hours[week_start] += entry.duration_hours
|
|
|
|
total_regular = 0.0
|
|
total_overtime = 0.0
|
|
total_undertime = 0.0
|
|
weeks_under = 0
|
|
weeks_with_overtime = 0
|
|
|
|
for week_start, total_week_hours in weekly_hours.items():
|
|
week_end = week_start + timedelta(days=6)
|
|
# Only full weeks entirely inside [start_date, end_date] get regular/overtime split
|
|
if week_start >= start_date and week_end <= end_date:
|
|
regular_week = min(total_week_hours, standard_weekly)
|
|
overtime_week = max(0.0, total_week_hours - standard_weekly)
|
|
undertime_week = max(0.0, standard_weekly - total_week_hours)
|
|
total_regular += regular_week
|
|
total_overtime += overtime_week
|
|
total_undertime += undertime_week
|
|
if undertime_week > 0:
|
|
weeks_under += 1
|
|
if overtime_week > 0:
|
|
weeks_with_overtime += 1
|
|
else:
|
|
# Partial week: count all as regular for simplicity (no overtime from partial weeks)
|
|
total_regular += total_week_hours
|
|
|
|
total_hours = total_regular + total_overtime
|
|
return {
|
|
"regular_hours": round(total_regular, 2),
|
|
"overtime_hours": round(total_overtime, 2),
|
|
"undertime_hours": round(total_undertime, 2),
|
|
"days_under": weeks_under, # reuse field for "weeks under" in weekly mode
|
|
"total_hours": round(total_hours, 2),
|
|
"days_with_overtime": weeks_with_overtime, # reuse for "weeks with overtime"
|
|
}
|
|
|
|
|
|
def get_daily_breakdown(user, start_date: date, end_date: date, include_weekends: Optional[bool] = None) -> List[Dict]:
|
|
"""
|
|
Get a daily breakdown of regular and overtime hours.
|
|
|
|
In weekly mode (user.overtime_calculation_mode == 'weekly'), per-day regular/overtime
|
|
are not defined; each row has total_hours and regular_hours=0, overtime_hours=0.
|
|
Use calculate_period_overtime for period-level regular/overtime in that case.
|
|
|
|
Args:
|
|
user: User object with standard_hours_per_day and optional overtime_include_weekends
|
|
start_date: Start date of the period
|
|
end_date: End date of the period
|
|
include_weekends: If None, use user.overtime_include_weekends (see calculate_period_overtime).
|
|
|
|
Returns:
|
|
List of dictionaries with daily breakdown
|
|
"""
|
|
mode = getattr(user, "overtime_calculation_mode", "daily") or "daily"
|
|
if include_weekends is None:
|
|
include_weekends = getattr(user, "overtime_include_weekends", True)
|
|
from datetime import datetime as dt
|
|
|
|
from app.models import TimeEntry
|
|
|
|
start_datetime = dt.combine(start_date, dt.min.time())
|
|
end_datetime = dt.combine(end_date, dt.max.time())
|
|
|
|
entries = (
|
|
TimeEntry.query.filter(
|
|
TimeEntry.user_id == user.id,
|
|
TimeEntry.end_time.isnot(None),
|
|
TimeEntry.start_time >= start_datetime,
|
|
TimeEntry.start_time <= end_datetime,
|
|
)
|
|
.order_by(TimeEntry.start_time)
|
|
.all()
|
|
)
|
|
|
|
daily_data = {}
|
|
for entry in entries:
|
|
entry_date = entry.start_time.date()
|
|
if entry_date not in daily_data:
|
|
daily_data[entry_date] = {"date": entry_date, "total_hours": 0.0, "entries": []}
|
|
daily_data[entry_date]["total_hours"] += entry.duration_hours
|
|
daily_data[entry_date]["entries"].append(entry)
|
|
|
|
if mode == "weekly":
|
|
# In weekly mode no per-day split; just total_hours per day
|
|
breakdown = []
|
|
for day_date in sorted(daily_data.keys()):
|
|
day_info = daily_data[day_date]
|
|
total_hours = day_info["total_hours"]
|
|
breakdown.append(
|
|
{
|
|
"date": day_date,
|
|
"date_str": day_date.strftime("%Y-%m-%d"),
|
|
"weekday": day_date.strftime("%A"),
|
|
"total_hours": round(total_hours, 2),
|
|
"regular_hours": 0.0,
|
|
"overtime_hours": 0.0,
|
|
"undertime_hours": 0.0,
|
|
"is_overtime": False,
|
|
"is_undertime": False,
|
|
"entries_count": len(day_info["entries"]),
|
|
}
|
|
)
|
|
return breakdown
|
|
|
|
standard_hours = getattr(user, "standard_hours_per_day", 8.0) or 8.0
|
|
breakdown = []
|
|
for day_date in sorted(daily_data.keys()):
|
|
day_info = daily_data[day_date]
|
|
total_hours = day_info["total_hours"]
|
|
if not include_weekends and day_date.weekday() >= 5:
|
|
regular_hours = 0.0
|
|
overtime_hours = total_hours
|
|
undertime_hours = 0.0
|
|
else:
|
|
regular_hours = min(total_hours, standard_hours)
|
|
overtime_hours = max(0, total_hours - standard_hours)
|
|
undertime_hours = max(0, standard_hours - total_hours) if total_hours < standard_hours else 0.0
|
|
is_undertime = undertime_hours > 0
|
|
breakdown.append(
|
|
{
|
|
"date": day_date,
|
|
"date_str": day_date.strftime("%Y-%m-%d"),
|
|
"weekday": day_date.strftime("%A"),
|
|
"total_hours": round(total_hours, 2),
|
|
"regular_hours": round(regular_hours, 2),
|
|
"overtime_hours": round(overtime_hours, 2),
|
|
"undertime_hours": round(undertime_hours, 2),
|
|
"is_overtime": overtime_hours > 0,
|
|
"is_undertime": is_undertime,
|
|
"entries_count": len(day_info["entries"]),
|
|
}
|
|
)
|
|
return breakdown
|
|
|
|
|
|
def get_weekly_overtime_summary(user, weeks: int = 4) -> List[Dict]:
|
|
"""
|
|
Get a weekly summary of overtime for the last N weeks.
|
|
|
|
Uses user's week_start_day for week boundaries. In weekly mode compares each
|
|
week's total to standard_hours_per_week; in daily mode uses per-day cap then sums.
|
|
"""
|
|
from app.models import TimeEntry
|
|
|
|
end_date = datetime.now().date()
|
|
start_date = end_date - timedelta(weeks=weeks)
|
|
start_datetime = datetime.combine(start_date, datetime.min.time())
|
|
end_datetime = datetime.combine(end_date, datetime.max.time())
|
|
|
|
entries = TimeEntry.query.filter(
|
|
TimeEntry.user_id == user.id,
|
|
TimeEntry.end_time.isnot(None),
|
|
TimeEntry.start_time >= start_datetime,
|
|
TimeEntry.start_time <= end_datetime,
|
|
).all()
|
|
|
|
# Group by week using user's week start
|
|
weekly_data: Dict[date, Dict[date, float]] = {}
|
|
for entry in entries:
|
|
entry_date = entry.start_time.date()
|
|
week_start = get_week_start_for_date(entry_date, user)
|
|
if week_start not in weekly_data:
|
|
weekly_data[week_start] = {}
|
|
if entry_date not in weekly_data[week_start]:
|
|
weekly_data[week_start][entry_date] = 0.0
|
|
weekly_data[week_start][entry_date] += entry.duration_hours
|
|
|
|
mode = getattr(user, "overtime_calculation_mode", "daily") or "daily"
|
|
standard_daily = getattr(user, "standard_hours_per_day", 8.0) or 8.0
|
|
standard_weekly = getattr(user, "standard_hours_per_week", None) or (standard_daily * 5)
|
|
weekly_summary = []
|
|
|
|
for week_start in sorted(weekly_data.keys()):
|
|
daily_hours = weekly_data[week_start]
|
|
week_total = sum(daily_hours.values())
|
|
|
|
if mode == "weekly":
|
|
week_regular = min(week_total, standard_weekly)
|
|
week_overtime = max(0.0, week_total - standard_weekly)
|
|
else:
|
|
week_regular = 0.0
|
|
week_overtime = 0.0
|
|
for hours in daily_hours.values():
|
|
if hours <= standard_daily:
|
|
week_regular += hours
|
|
else:
|
|
week_regular += standard_daily
|
|
week_overtime += hours - standard_daily
|
|
|
|
week_end = week_start + timedelta(days=6)
|
|
weekly_summary.append(
|
|
{
|
|
"week_start": week_start,
|
|
"week_end": week_end,
|
|
"week_label": f"{week_start.strftime('%b %d')} - {week_end.strftime('%b %d')}",
|
|
"regular_hours": round(week_regular, 2),
|
|
"overtime_hours": round(week_overtime, 2),
|
|
"total_hours": round(week_regular + week_overtime, 2),
|
|
"days_worked": len(daily_hours),
|
|
}
|
|
)
|
|
|
|
return weekly_summary
|
|
|
|
|
|
def get_overtime_ytd(user) -> Dict[str, float]:
|
|
"""
|
|
Return overtime for the current year to date (Jan 1 through today).
|
|
Uses calculate_period_overtime; no stored balance.
|
|
"""
|
|
today = datetime.now().date()
|
|
start_ytd = date(today.year, 1, 1)
|
|
return calculate_period_overtime(user, start_ytd, today)
|
|
|
|
|
|
def get_overtime_last_12_months(user) -> Dict[str, float]:
|
|
"""
|
|
Return overtime for the last 12 months (rolling).
|
|
"""
|
|
today = datetime.now().date()
|
|
start = today - timedelta(days=365)
|
|
return calculate_period_overtime(user, start, today)
|
|
|
|
|
|
def get_overtime_statistics(user, start_date: date, end_date: date) -> Dict:
|
|
"""
|
|
Get comprehensive overtime statistics for a period.
|
|
|
|
Args:
|
|
user: User object
|
|
start_date: Start date
|
|
end_date: End date
|
|
|
|
Returns:
|
|
Dictionary with various overtime statistics
|
|
"""
|
|
period_data = calculate_period_overtime(user, start_date, end_date)
|
|
daily_breakdown = get_daily_breakdown(user, start_date, end_date)
|
|
|
|
# Calculate additional statistics
|
|
days_worked = len(daily_breakdown)
|
|
days_with_overtime = sum(1 for day in daily_breakdown if day["is_overtime"])
|
|
|
|
# Average hours per day
|
|
avg_hours_per_day = period_data["total_hours"] / days_worked if days_worked > 0 else 0
|
|
|
|
# Max overtime in a single day
|
|
max_overtime_day = max(
|
|
(day for day in daily_breakdown if day["is_overtime"]), key=lambda x: x["overtime_hours"], default=None
|
|
)
|
|
|
|
return {
|
|
"period": {
|
|
"start_date": start_date.strftime("%Y-%m-%d"),
|
|
"end_date": end_date.strftime("%Y-%m-%d"),
|
|
"days_in_period": (end_date - start_date).days + 1,
|
|
},
|
|
"hours": period_data,
|
|
"days_statistics": {
|
|
"days_worked": days_worked,
|
|
"days_with_overtime": days_with_overtime,
|
|
"percentage_overtime_days": (round(days_with_overtime / days_worked * 100, 1) if days_worked > 0 else 0),
|
|
},
|
|
"averages": {
|
|
"avg_hours_per_day": round(avg_hours_per_day, 2),
|
|
"avg_overtime_per_overtime_day": (
|
|
round(period_data["overtime_hours"] / days_with_overtime, 2) if days_with_overtime > 0 else 0
|
|
),
|
|
},
|
|
"max_overtime": {
|
|
"date": max_overtime_day["date_str"] if max_overtime_day else None,
|
|
"hours": max_overtime_day["overtime_hours"] if max_overtime_day else 0,
|
|
},
|
|
}
|