feat: async pytest, testcases for starting and finishing shifts
This commit is contained in:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user