feat: work shift pauses

This commit is contained in:
2024-12-04 11:00:00 +04:00
parent 2bd2486653
commit 9a6fc3fdd7
5 changed files with 316 additions and 72 deletions

View File

@@ -2,6 +2,7 @@ from datetime import datetime
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from sqlalchemy import ForeignKey from sqlalchemy import ForeignKey
from sqlalchemy.sql import expression
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from models.base import BaseModel from models.base import BaseModel
@@ -20,6 +21,10 @@ class WorkShift(BaseModel):
finished_at: Mapped[datetime] = mapped_column( finished_at: Mapped[datetime] = mapped_column(
nullable=True, nullable=True,
) )
is_paused: Mapped[bool] = mapped_column(
default=False,
server_default=expression.false(),
)
user_id: Mapped[int] = mapped_column( user_id: Mapped[int] = mapped_column(
ForeignKey("users.id"), ForeignKey("users.id"),
@@ -29,3 +34,32 @@ class WorkShift(BaseModel):
back_populates="work_shifts", back_populates="work_shifts",
lazy="selectin", lazy="selectin",
) )
pauses: Mapped[list["WorkShiftPause"]] = relationship(
"WorkShiftPause",
back_populates="work_shift",
uselist=True,
foreign_keys="[WorkShiftPause.work_shift_id]",
cascade="all, delete",
)
class WorkShiftPause(BaseModel):
__tablename__ = "work_shifts_pauses"
id: Mapped[int] = mapped_column(primary_key=True)
started_at: Mapped[datetime] = mapped_column(
nullable=False,
)
finished_at: Mapped[datetime] = mapped_column(
nullable=True,
)
work_shift_id: Mapped[int] = mapped_column(
ForeignKey("work_shifts.id"),
)
work_shift: Mapped[WorkShift] = relationship(
"WorkShift",
back_populates="pauses",
lazy="selectin",
)

View File

@@ -47,7 +47,7 @@ async def finish_shift(
user_id: int, user_id: int,
user: CurrentUserDependency, user: CurrentUserDependency,
): ):
return await WorkShiftsService(session).finish_shift(user, user_id) return await WorkShiftsService(session).finish_shift_by_user_id(user, user_id)
@work_shifts_router.post( @work_shifts_router.post(
@@ -86,3 +86,51 @@ async def delete_work_shift(
shift_id: int, shift_id: int,
): ):
return await WorkShiftsService(session).delete_shift(shift_id) return await WorkShiftsService(session).delete_shift(shift_id)
@work_shifts_router.post(
"/pause/start/{shift_id}",
response_model=StartPauseByShiftIdResponse,
operation_id="start_pause_by_shift_id",
)
async def start_pause_by_shift_id(
session: SessionDependency,
shift_id: int,
):
return await WorkShiftsService(session).start_pause_by_shift_id(shift_id)
@work_shifts_router.post(
"/pause/start/for-user/{user_id}",
response_model=StartPauseByUserIdResponse,
operation_id="start_pause_by_user_id",
)
async def start_pause_by_user_id(
session: SessionDependency,
user_id: int,
):
return await WorkShiftsService(session).start_pause_by_user_id(user_id)
@work_shifts_router.post(
"/pause/finish/{shift_id}",
response_model=FinishPauseByShiftIdResponse,
operation_id="finish_pause_by_shift_id",
)
async def finish_pause_by_shift_id(
session: SessionDependency,
shift_id: int,
):
return await WorkShiftsService(session).finish_pause_by_shift_id(shift_id)
@work_shifts_router.post(
"/pause/finish/for-user/{shift_id}",
response_model=FinishPauseByUserIdResponse,
operation_id="finish_pause_by_user_id",
)
async def finish_pause_by_user_id(
session: SessionDependency,
user_id: int,
):
return await WorkShiftsService(session).finish_pause_by_user_id(user_id)

View File

@@ -11,10 +11,16 @@ class WorkShiftSchema(BaseSchema):
id: int id: int
started_at: datetime started_at: datetime
finished_at: Optional[datetime] = None finished_at: Optional[datetime] = None
hours: Optional[float] = None is_paused: Optional[bool] = None
user: UserSchema user: UserSchema
class WorkShiftRowSchema(BaseSchema):
work_shift: WorkShiftSchema
total_hours: Optional[float] = None
pause_hours: Optional[float] = None
# endregion # endregion
# region Responses # region Responses
@@ -32,11 +38,28 @@ class FinishShiftByIdResponse(OkMessageSchema):
class GetWorkShiftsResponse(BaseSchema): class GetWorkShiftsResponse(BaseSchema):
shifts: List[WorkShiftSchema] shifts: List[WorkShiftRowSchema]
pagination_info: PaginationInfoSchema pagination_info: PaginationInfoSchema
class DeleteShiftResponse(OkMessageSchema): class DeleteShiftResponse(OkMessageSchema):
pass pass
class StartPauseByUserIdResponse(OkMessageSchema):
pass
class FinishPauseByUserIdResponse(OkMessageSchema):
pass
class StartPauseByShiftIdResponse(OkMessageSchema):
pass
class FinishPauseByShiftIdResponse(OkMessageSchema):
pass
# endregion # endregion

View File

@@ -2,10 +2,11 @@ from datetime import date, timedelta
import math import math
from fastapi import HTTPException, status from fastapi import HTTPException, status
from sqlalchemy import select, delete, func from sqlalchemy import select, func, extract, literal, label, Select
from sqlalchemy.orm import joinedload from sqlalchemy.orm import joinedload, selectinload
from models import WorkShift, User from models import WorkShift, User
from models.work_shifts import WorkShiftPause
from schemas.base import PaginationSchema from schemas.base import PaginationSchema
from schemas.time_tracking import UpdateTimeTrackingRecordRequest from schemas.time_tracking import UpdateTimeTrackingRecordRequest
from schemas.work_shifts import * from schemas.work_shifts import *
@@ -16,9 +17,10 @@ from utils.work_time import hours_to_hours_and_minutes
class WorkShiftsService(BaseService): 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 = ( stmt = (
select(WorkShift) select(WorkShift)
.options(selectinload(WorkShift.pauses))
.where(WorkShift.user_id == user_id) .where(WorkShift.user_id == user_id)
.order_by(WorkShift.started_at.desc()) .order_by(WorkShift.started_at.desc())
.limit(1) .limit(1)
@@ -28,16 +30,17 @@ class WorkShiftsService(BaseService):
work_shift = work_shift.one_or_none() work_shift = work_shift.one_or_none()
work_shift = work_shift[0] if work_shift else None work_shift = work_shift[0] if work_shift else None
if work_shift and work_shift.started_at.date() == date.today(): if for_today:
return work_shift return work_shift if work_shift and work_shift.started_at.date() == date.today() else None
return None
return work_shift
async def start_shift(self, user_id: int) -> StartShiftResponse: async def start_shift(self, user_id: int) -> StartShiftResponse:
employee = await self.session.get(User, user_id) employee = await self.session.get(User, user_id)
if not employee or employee.is_deleted: if not employee or employee.is_deleted:
return StartShiftResponse(ok=False, message=f"Пользователь с ID {user_id} не найден") 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 work_shift:
if not work_shift.finished_at: if not work_shift.finished_at:
return StartShiftResponse(ok=False, message="Предыдущая смена еще не закончена") return StartShiftResponse(ok=False, message="Предыдущая смена еще не закончена")
@@ -48,90 +51,153 @@ class WorkShiftsService(BaseService):
await self.session.commit() await self.session.commit()
return StartShiftResponse(ok=True, message="Смена начата") 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) employee = await self.session.get(User, user_id)
if not employee or employee.is_deleted: if not employee or employee.is_deleted:
return FinishShiftResponse(ok=False, message=f"Пользователь с ID {user_id} не найден") 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: ok, message = await self._finish_shift_common(user, work_shift)
return FinishShiftResponse(ok=False, message="Смена для сотрудника еще не начата") return FinishShiftResponse(ok=ok, message=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} мин.")
async def finish_shift_by_id(self, user: User, shift_id: int) -> FinishShiftByIdResponse: 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: 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() work_shift.finished_at = datetime.now()
await self.session.commit() await self.session.commit()
diff: timedelta = work_shift.finished_at - work_shift.started_at pause_time = timedelta()
hours = diff.total_seconds() / 3600 for pause in work_shift.pauses:
pause_time += pause.finished_at - pause.started_at
if diff.total_seconds() < 60: total_work_time: timedelta = work_shift.finished_at - work_shift.started_at
return FinishShiftByIdResponse(ok=True, message=f"Смена закончена. Отработано 0 ч. 0 мин.") pure_work_seconds = total_work_time.total_seconds() - pause_time.total_seconds()
hours = pure_work_seconds / 3600
data = UpdateTimeTrackingRecordRequest( if pure_work_seconds >= 60:
user_id=work_shift.user_id, data = UpdateTimeTrackingRecordRequest(
date=work_shift.started_at.date(), user_id=work_shift.user_id,
hours=hours, 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 (
return FinishShiftByIdResponse(ok=True, message=f"Смена закончена. Отработано {hours} ч. {minutes} мин.") 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): if not is_valid_pagination(pagination):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Invalid pagination') raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid pagination")
page = max(0, pagination.page - 1)
total_shifts = await self.session.scalar( async def get_total_shifts_count(self, is_active: bool) -> int:
count_query = (
select(func.count()) select(func.count())
.select_from(WorkShift) .select_from(WorkShift)
.where(WorkShift.finished_at.is_(None) if is_active else WorkShift.finished_at.is_not(None)) .where(WorkShift.finished_at.is_(None) if is_active else WorkShift.finished_at.is_not(None))
) )
if not total_shifts: return await self.session.scalar(count_query)
return GetWorkShiftsResponse(
shifts=[],
pagination_info=PaginationInfoSchema(
total_pages=0,
total_items=0
)
)
@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) total_pages = math.ceil(total_shifts / pagination.items_per_page)
stmt = ( if is_active:
select(WorkShift) stmt = self.get_active_work_shifts_stmt()
.options(joinedload(WorkShift.user)) else:
.where(WorkShift.finished_at.is_(None) if is_active else WorkShift.finished_at.is_not(None)) stmt = self.get_work_shifts_history_stmt()
.order_by(WorkShift.started_at.desc())
stmt_with_pagination = (
stmt
.offset(page * pagination.items_per_page) .offset(page * pagination.items_per_page)
.limit(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( return GetWorkShiftsResponse(
shifts=shifts, shifts=shifts,
@@ -142,10 +208,77 @@ class WorkShiftsService(BaseService):
) )
async def delete_shift(self, shift_id: int) -> DeleteShiftResponse: async def delete_shift(self, shift_id: int) -> DeleteShiftResponse:
stmt = ( work_shift = await self.session.get(WorkShift, shift_id)
delete(WorkShift) if work_shift:
.where(WorkShift.id == shift_id) await self.session.delete(work_shift)
) await self.session.commit()
await self.session.execute(stmt)
await self.session.commit()
return DeleteShiftResponse(ok=True, message="Запись о смене успешно удалена") 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)

View File

@@ -1,7 +1,13 @@
from datetime import timedelta
from math import floor from math import floor
def hours_to_hours_and_minutes(hours: float) -> tuple[int, int]: def hours_to_hours_and_minutes(time: timedelta) -> tuple[int, int]:
if time.total_seconds() < 60:
return 0, 0
hours = time.total_seconds() / 3600
res_hours = int(floor(hours)) res_hours = int(floor(hours))
minutes = int(round((hours - res_hours) * 60)) minutes = int(floor((hours - res_hours) * 60))
return res_hours, minutes return res_hours, minutes