import math 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 from sqlalchemy.orm import joinedload, selectinload from logger import logger_builder from models import WorkShift, User from models.work_shifts import WorkShiftPause from schemas.base import PaginationSchema from schemas.time_tracking import UpdateTimeTrackingRecordRequest from schemas.work_shifts import * from services.base import BaseService from services.time_tracking import TimeTrackingService from utils.dependecies import is_valid_pagination from utils.work_time import hours_to_hours_and_minutes class WorkShiftsService(BaseService): async def _get_last_work_shift(self, user_id: int, for_today: bool = False) -> Optional[WorkShift]: stmt = ( select(WorkShift) .options(selectinload(WorkShift.pauses)) .where(WorkShift.user_id == user_id) .order_by(WorkShift.started_at.desc()) .limit(1) ) work_shift = await self.session.execute(stmt) work_shift = work_shift.one_or_none() work_shift = work_shift[0] if work_shift else None if for_today: return work_shift if work_shift and work_shift.started_at.date() == date.today() else None return work_shift async def start_shift(self, user_id: int) -> StartShiftResponse: employee = await self.session.get(User, user_id) if not employee or employee.is_deleted: return StartShiftResponse(ok=False, message=f"Пользователь с ID {user_id} не найден") work_shift = await self._get_last_work_shift(user_id, for_today=True) if work_shift: if not work_shift.finished_at: return StartShiftResponse(ok=False, message="Предыдущая смена еще не закончена") return StartShiftResponse(ok=False, message="Смена для сотрудника на сегодня уже закончена") work_shift = WorkShift(user_id=user_id, started_at=datetime.now()) self.session.add(work_shift) await self.session.commit() return StartShiftResponse(ok=True, message="Смена начата") async def finish_shift_by_user_id(self, user: User, user_id: int) -> FinishShiftResponse: employee = await self.session.get(User, user_id) if not employee or employee.is_deleted: return FinishShiftResponse(ok=False, message=f"Пользователь с ID {user_id} не найден") work_shift = await self._get_last_work_shift(user_id) ok, message = await self._finish_shift_common(user, work_shift) return FinishShiftResponse(ok=ok, message=message) async def finish_shift_by_id(self, user: User, shift_id: int) -> FinishShiftByIdResponse: stmt = ( select(WorkShift) .options(selectinload(WorkShift.pauses)) .where(WorkShift.id == shift_id) ) work_shift = await self.session.execute(stmt) work_shift = work_shift.scalars().one_or_none() ok, message = await self._finish_shift_common(user, work_shift) return FinishShiftByIdResponse(ok=ok, message=message) async def _finish_shift_common(self, user: User, work_shift: Optional[WorkShift]) -> tuple[bool, str]: logger = logger_builder.get_logger() try: if not work_shift or work_shift.finished_at: return False, "Смена для сотрудника еще не начата" work_shift.finished_at = datetime.now() # 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 ] 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, seconds in daily_seconds.items(): if seconds < 60: # ignore <1 minute continue data = UpdateTimeTrackingRecordRequest( user_id=work_shift.user_id, date=day, hours=seconds / 3600, ) ok, msg = await tts.update_work_record(user, data, False) if not ok: raise Exception(msg) await self.session.commit() # Build human-readable result message 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} мин.") return True, f"Смена закончена. Отработано {h} ч. {m} мин." except Exception as e: logger.error(f"Ошибка завершения смены. userID: {work_shift.user_id}. Ошибка: {str(e)}") await self.session.rollback() return False, str(e) @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) # final partial day yield end.date(), current, end @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 @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 day, day_start, day_end in WorkShiftsService.split_range_by_days(start_shift, end_shift): # Compute raw work for the day (before pauses) day_work_seconds = (day_end - day_start).total_seconds() # 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: sub_hours = ( select( WorkShift.id, label( "total_hours", extract('epoch', WorkShift.finished_at) - extract('epoch', WorkShift.started_at) ), func.sum( extract('epoch', WorkShiftPause.finished_at) - extract('epoch', WorkShiftPause.started_at) ).label("pause_hours"), ) .join(WorkShiftPause, isouter=True) .where(WorkShift.finished_at.is_not(None)) .group_by(WorkShift.id) .subquery() ) return ( select( WorkShift, sub_hours.c.total_hours, sub_hours.c.pause_hours, ) .join(sub_hours, sub_hours.c.id == WorkShift.id) .options(joinedload(WorkShift.user)) .order_by(WorkShift.started_at.desc()) ) @staticmethod def get_active_work_shifts_stmt() -> Select: return ( select( WorkShift, literal(0), literal(0), ) .options(joinedload(WorkShift.user)) .where(WorkShift.finished_at.is_(None)) .order_by(WorkShift.started_at.desc()) ) @staticmethod def validate_pagination(pagination: PaginationSchema): if not is_valid_pagination(pagination): raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid pagination") async def get_total_shifts_count(self, is_active: bool) -> int: count_query = ( select(func.count()) .select_from(WorkShift) .where(WorkShift.finished_at.is_(None) if is_active else WorkShift.finished_at.is_not(None)) ) return await self.session.scalar(count_query) @staticmethod def empty_shifts_response() -> GetWorkShiftsResponse: return GetWorkShiftsResponse( shifts=[], pagination_info=PaginationInfoSchema( total_pages=0, total_items=0 ) ) async def get_shifts(self, is_active: bool, pagination: PaginationSchema) -> GetWorkShiftsResponse: self.validate_pagination(pagination) page = max(0, pagination.page - 1) total_shifts = await self.get_total_shifts_count(is_active) if not total_shifts: return self.empty_shifts_response() total_pages = math.ceil(total_shifts / pagination.items_per_page) if is_active: stmt = self.get_active_work_shifts_stmt() else: stmt = self.get_work_shifts_history_stmt() stmt_with_pagination = ( stmt .offset(page * pagination.items_per_page) .limit(pagination.items_per_page) ) shifts_rows = await self.session.execute(stmt_with_pagination) shifts = [] for shift, total_hours, pause_hours in shifts_rows: shift = WorkShiftRowSchema( work_shift=shift, total_hours=total_hours, pause_hours=pause_hours, ) shifts.append(shift) return GetWorkShiftsResponse( shifts=shifts, pagination_info=PaginationInfoSchema( total_pages=total_pages, total_items=total_shifts, ) ) async def delete_shift(self, shift_id: int) -> DeleteShiftResponse: work_shift = await self.session.get(WorkShift, shift_id) if work_shift: await self.session.delete(work_shift) await self.session.commit() return DeleteShiftResponse(ok=True, message="Запись о смене успешно удалена") async def _get_last_work_shift_pause(self, work_shift_id: int) -> Optional[WorkShiftPause]: stmt = ( select(WorkShiftPause) .where(WorkShiftPause.work_shift_id == work_shift_id) .order_by(WorkShiftPause.started_at.desc()) .limit(1) ) work_shift_pause = await self.session.execute(stmt) work_shift_pause = work_shift_pause.one_or_none() work_shift_pause = work_shift_pause[0] if work_shift_pause else None return work_shift_pause if work_shift_pause and work_shift_pause.started_at.date() == date.today() else None async def start_pause(self, work_shift: Optional[WorkShift]) -> tuple[bool, str]: if not work_shift: return False, "Смена не найдена" if work_shift.finished_at: return False, "Смена уже завершена" work_shift_pause = await self._get_last_work_shift_pause(work_shift.id) if work_shift_pause and not work_shift_pause.finished_at: return False, "Перерыв для смены уже начат" work_shift.is_paused = True work_shift_pause = WorkShiftPause(work_shift_id=work_shift.id, started_at=datetime.now()) self.session.add(work_shift_pause) await self.session.commit() return True, "Перерыв начат" async def start_pause_by_user_id(self, user_id: int) -> StartPauseByUserIdResponse: last_shift = await self._get_last_work_shift(user_id) ok, message = await self.start_pause(last_shift) return StartPauseByUserIdResponse(ok=ok, message=message) async def start_pause_by_shift_id(self, shift_id: int) -> StartPauseByShiftIdResponse: work_shift = await self.session.get(WorkShift, shift_id) ok, message = await self.start_pause(work_shift) return StartPauseByShiftIdResponse(ok=ok, message=message) async def finish_pause(self, work_shift: Optional[WorkShift]) -> tuple[bool, str]: if not work_shift: return False, "Смена не найдена" if work_shift.finished_at: return False, "Смена уже завершена" work_shift_pause = await self._get_last_work_shift_pause(work_shift.id) if not work_shift_pause or work_shift_pause.finished_at: return False, "Перерыв еще не начат" work_shift.is_paused = False work_shift_pause.finished_at = datetime.now() await self.session.commit() diff: timedelta = work_shift_pause.finished_at - work_shift_pause.started_at hours, minutes = hours_to_hours_and_minutes(diff) return True, f"Перерыв закончен: {hours} ч. {minutes} мин." async def finish_pause_by_user_id(self, user_id: int) -> FinishPauseByUserIdResponse: last_shift = await self._get_last_work_shift(user_id) ok, message = await self.finish_pause(last_shift) return FinishPauseByUserIdResponse(ok=ok, message=message) async def finish_pause_by_shift_id(self, shift_id: int) -> FinishPauseByShiftIdResponse: work_shift = await self.session.get(WorkShift, shift_id) ok, message = await self.finish_pause(work_shift) return FinishPauseByShiftIdResponse(ok=ok, message=message)