feat: async pytest, testcases for starting and finishing shifts

This commit is contained in:
2025-11-26 15:58:59 +04:00
parent c71a460170
commit ed00d1483d
14 changed files with 1933 additions and 1393 deletions

View File

@@ -1,5 +1,7 @@
import math
from datetime import date, timedelta, time
from collections import defaultdict
from datetime import date, timedelta
from typing import Generator, Any
from fastapi import HTTPException, status
from sqlalchemy import select, func, extract, literal, label, Select
@@ -80,38 +82,30 @@ class WorkShiftsService(BaseService):
if not work_shift or work_shift.finished_at:
return False, "Смена для сотрудника еще не начата"
# End active pause
if work_shift.is_paused:
await self.finish_pause_by_shift_id(work_shift.id)
work_shift.finished_at = datetime.now()
# Collect pauses
# End active pause
if work_shift.is_paused:
work_shift.is_paused = False
work_shift.pauses[-1].finished_at = datetime.now()
pauses = [
(pause.started_at, pause.finished_at)
for pause in work_shift.pauses
]
# Build raw work intervals
# Start with one interval: whole shift
raw_intervals = [(work_shift.started_at, work_shift.finished_at)]
# Subtract pauses from work intervals
work_intervals = self.subtract_pauses(raw_intervals, pauses)
# Split intervals by days
daily_hours = self.split_intervals_by_days(work_intervals)
daily_seconds = self.calculate_daily_worked(work_shift.started_at, work_shift.finished_at, pauses)
# Create work records per day
tts = TimeTrackingService(self.session)
for day, hours in daily_hours.items():
if hours < (1 / 60): # ignore <1 minute
for day, seconds in daily_seconds.items():
if seconds < 60: # ignore <1 minute
continue
data = UpdateTimeTrackingRecordRequest(
user_id=work_shift.user_id,
date=day,
hours=hours,
hours=seconds / 3600,
)
ok, msg = await tts.update_work_record(user, data, False)
if not ok:
@@ -120,7 +114,7 @@ class WorkShiftsService(BaseService):
await self.session.commit()
# Build human-readable result message
total_work_seconds = sum(hours * 3600 for hours in daily_hours.values())
total_work_seconds = sum(seconds for seconds in daily_seconds.values())
total_td = timedelta(seconds=total_work_seconds)
h, m = hours_to_hours_and_minutes(total_td)
logger.info(f"Успешное завершение смены. userID: {work_shift.user_id}. Отработано суммарно: {h} ч. {m} мин.")
@@ -131,50 +125,50 @@ class WorkShiftsService(BaseService):
await self.session.rollback()
return False, str(e)
def subtract_pauses(
self,
work_intervals: list[tuple[datetime, datetime]],
pauses: list[tuple[datetime, datetime]]
) -> list[tuple[datetime, datetime]]:
result = []
@staticmethod
def split_range_by_days(start: datetime, end: datetime) -> Generator[tuple[date, datetime, datetime], Any, None]:
"""
Yield (day_date, day_start, day_end) for each day in the datetime range.
"""
current = start
while current.date() < end.date():
day_end = datetime.combine(current.date(), datetime.max.time())
yield current.date(), current, day_end
current = day_end + timedelta(microseconds=1)
for w_start, w_end in work_intervals:
temp = [(w_start, w_end)]
for p_start, p_end in pauses:
new_temp = []
for s, e in temp:
# pause outside interval → keep original
if p_end <= s or p_start >= e:
new_temp.append((s, e))
else:
# pause cuts interval
if p_start > s:
new_temp.append((s, p_start))
if p_end < e:
new_temp.append((p_end, e))
temp = new_temp
result.extend(temp)
# final partial day
yield end.date(), current, end
return result
@staticmethod
def intersect(a_start, a_end, b_start, b_end) -> Optional[tuple[datetime, datetime]]:
start = max(a_start, b_start)
end = min(a_end, b_end)
return (start, end) if start < end else None
def split_intervals_by_days(self, intervals: list[tuple[datetime, datetime]]) -> dict[date, float]:
from collections import defaultdict
res = defaultdict(float)
@staticmethod
def calculate_daily_worked(
start_shift: datetime,
end_shift: datetime,
shift_pauses: list[tuple[datetime, datetime]]
) -> dict[date, float]:
# Step 1: break shift into days
daily_work = defaultdict(float)
for start, end in intervals:
cur = start
while cur.date() < end.date():
# end of current day
day_end = datetime.combine(cur.date(), time.max)
seconds = (day_end - cur).total_seconds()
res[cur.date()] += seconds / 3600
cur = day_end + timedelta(seconds=1)
for day, day_start, day_end in WorkShiftsService.split_range_by_days(start_shift, end_shift):
# last segment (same day)
seconds = (end - cur).total_seconds()
res[cur.date()] += seconds / 3600
# Compute raw work for the day (before pauses)
day_work_seconds = (day_end - day_start).total_seconds()
return res
# Subtract pauses intersecting with this day
for p_start, p_end in shift_pauses:
inter = WorkShiftsService.intersect(day_start, day_end, p_start, p_end)
if inter:
p_s, p_e = inter
day_work_seconds -= (p_e - p_s).total_seconds()
daily_work[day] += day_work_seconds
return daily_work
@staticmethod
def get_work_shifts_history_stmt() -> Select: