feat: work shift pauses
This commit is contained in:
		@@ -2,10 +2,11 @@ from datetime import date, timedelta
 | 
			
		||||
 | 
			
		||||
import math
 | 
			
		||||
from fastapi import HTTPException, status
 | 
			
		||||
from sqlalchemy import select, delete, func
 | 
			
		||||
from sqlalchemy.orm import joinedload
 | 
			
		||||
from sqlalchemy import select, func, extract, literal, label, Select
 | 
			
		||||
from sqlalchemy.orm import joinedload, selectinload
 | 
			
		||||
 | 
			
		||||
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 *
 | 
			
		||||
@@ -16,9 +17,10 @@ from utils.work_time import hours_to_hours_and_minutes
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class WorkShiftsService(BaseService):
 | 
			
		||||
    async def _get_last_work_shift_for_today(self, user_id: int) -> Optional[WorkShift]:
 | 
			
		||||
    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)
 | 
			
		||||
@@ -28,16 +30,17 @@ class WorkShiftsService(BaseService):
 | 
			
		||||
        work_shift = work_shift.one_or_none()
 | 
			
		||||
        work_shift = work_shift[0] if work_shift else None
 | 
			
		||||
 | 
			
		||||
        if work_shift and work_shift.started_at.date() == date.today():
 | 
			
		||||
            return work_shift
 | 
			
		||||
        return 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_for_today(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="Предыдущая смена еще не закончена")
 | 
			
		||||
@@ -48,90 +51,153 @@ class WorkShiftsService(BaseService):
 | 
			
		||||
        await self.session.commit()
 | 
			
		||||
        return StartShiftResponse(ok=True, message="Смена начата")
 | 
			
		||||
 | 
			
		||||
    async def finish_shift(self, user: User, user_id: int) -> FinishShiftResponse:
 | 
			
		||||
    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_for_today(user_id)
 | 
			
		||||
        work_shift = await self._get_last_work_shift(user_id)
 | 
			
		||||
 | 
			
		||||
        if not work_shift or work_shift.finished_at:
 | 
			
		||||
            return FinishShiftResponse(ok=False, message="Смена для сотрудника еще не начата")
 | 
			
		||||
 | 
			
		||||
        work_shift.finished_at = datetime.now()
 | 
			
		||||
        await self.session.commit()
 | 
			
		||||
 | 
			
		||||
        diff: timedelta = work_shift.finished_at - work_shift.started_at
 | 
			
		||||
        hours = diff.total_seconds() / 3600
 | 
			
		||||
 | 
			
		||||
        if diff.total_seconds() < 60:
 | 
			
		||||
            return FinishShiftResponse(ok=True, message=f"Смена закончена. Отработано 0 ч. 0 мин.")
 | 
			
		||||
 | 
			
		||||
        data = UpdateTimeTrackingRecordRequest(
 | 
			
		||||
            user_id=user_id,
 | 
			
		||||
            date=work_shift.started_at.date(),
 | 
			
		||||
            hours=hours,
 | 
			
		||||
        )
 | 
			
		||||
        await TimeTrackingService(self.session).update_record(user, data)
 | 
			
		||||
 | 
			
		||||
        hours, minutes = hours_to_hours_and_minutes(hours)
 | 
			
		||||
        return FinishShiftResponse(ok=True, message=f"Смена закончена. Отработано {hours} ч. {minutes} мин.")
 | 
			
		||||
        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:
 | 
			
		||||
        work_shift = await self.session.get(WorkShift, shift_id)
 | 
			
		||||
        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]:
 | 
			
		||||
        if not work_shift or work_shift.finished_at:
 | 
			
		||||
            return FinishShiftByIdResponse(ok=False, message="Смена для сотрудника еще не начата")
 | 
			
		||||
            return False, "Смена для сотрудника еще не начата"
 | 
			
		||||
 | 
			
		||||
        if work_shift.is_paused:
 | 
			
		||||
            await self.finish_pause_by_shift_id(work_shift.id)
 | 
			
		||||
 | 
			
		||||
        work_shift.finished_at = datetime.now()
 | 
			
		||||
        await self.session.commit()
 | 
			
		||||
 | 
			
		||||
        diff: timedelta = work_shift.finished_at - work_shift.started_at
 | 
			
		||||
        hours = diff.total_seconds() / 3600
 | 
			
		||||
        pause_time = timedelta()
 | 
			
		||||
        for pause in work_shift.pauses:
 | 
			
		||||
            pause_time += pause.finished_at - pause.started_at
 | 
			
		||||
 | 
			
		||||
        if diff.total_seconds() < 60:
 | 
			
		||||
            return FinishShiftByIdResponse(ok=True, message=f"Смена закончена. Отработано 0 ч. 0 мин.")
 | 
			
		||||
        total_work_time: timedelta = work_shift.finished_at - work_shift.started_at
 | 
			
		||||
        pure_work_seconds = total_work_time.total_seconds() - pause_time.total_seconds()
 | 
			
		||||
        hours = pure_work_seconds / 3600
 | 
			
		||||
 | 
			
		||||
        data = UpdateTimeTrackingRecordRequest(
 | 
			
		||||
            user_id=work_shift.user_id,
 | 
			
		||||
            date=work_shift.started_at.date(),
 | 
			
		||||
            hours=hours,
 | 
			
		||||
        if pure_work_seconds >= 60:
 | 
			
		||||
            data = UpdateTimeTrackingRecordRequest(
 | 
			
		||||
                user_id=work_shift.user_id,
 | 
			
		||||
                date=work_shift.started_at.date(),
 | 
			
		||||
                hours=hours,
 | 
			
		||||
            )
 | 
			
		||||
            await TimeTrackingService(self.session).update_record(user, data)
 | 
			
		||||
 | 
			
		||||
        hours, minutes = hours_to_hours_and_minutes(total_work_time)
 | 
			
		||||
        return True, f"Смена закончена. Отработано {hours} ч. {minutes} мин."
 | 
			
		||||
 | 
			
		||||
    @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()
 | 
			
		||||
        )
 | 
			
		||||
        await TimeTrackingService(self.session).update_record(user, data)
 | 
			
		||||
 | 
			
		||||
        hours, minutes = hours_to_hours_and_minutes(hours)
 | 
			
		||||
        return FinishShiftByIdResponse(ok=True, message=f"Смена закончена. Отработано {hours} ч. {minutes} мин.")
 | 
			
		||||
        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())
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    async def get_shifts(self, is_active: bool, pagination: PaginationSchema) -> GetWorkShiftsResponse:
 | 
			
		||||
    @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')
 | 
			
		||||
        page = max(0, pagination.page - 1)
 | 
			
		||||
            raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid pagination")
 | 
			
		||||
 | 
			
		||||
        total_shifts = await self.session.scalar(
 | 
			
		||||
    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))
 | 
			
		||||
        )
 | 
			
		||||
        if not total_shifts:
 | 
			
		||||
            return GetWorkShiftsResponse(
 | 
			
		||||
                shifts=[],
 | 
			
		||||
                pagination_info=PaginationInfoSchema(
 | 
			
		||||
                    total_pages=0,
 | 
			
		||||
                    total_items=0
 | 
			
		||||
                )
 | 
			
		||||
            )
 | 
			
		||||
        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)
 | 
			
		||||
 | 
			
		||||
        stmt = (
 | 
			
		||||
            select(WorkShift)
 | 
			
		||||
            .options(joinedload(WorkShift.user))
 | 
			
		||||
            .where(WorkShift.finished_at.is_(None) if is_active else WorkShift.finished_at.is_not(None))
 | 
			
		||||
            .order_by(WorkShift.started_at.desc())
 | 
			
		||||
        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 = (await self.session.execute(stmt)).scalars().all()
 | 
			
		||||
 | 
			
		||||
        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,
 | 
			
		||||
@@ -142,10 +208,77 @@ class WorkShiftsService(BaseService):
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    async def delete_shift(self, shift_id: int) -> DeleteShiftResponse:
 | 
			
		||||
        stmt = (
 | 
			
		||||
            delete(WorkShift)
 | 
			
		||||
            .where(WorkShift.id == shift_id)
 | 
			
		||||
        )
 | 
			
		||||
        await self.session.execute(stmt)
 | 
			
		||||
        await self.session.commit()
 | 
			
		||||
        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)
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user