From cb0168a19937ba0b0e2c108a1493ef1c3e502bb7 Mon Sep 17 00:00:00 2001 From: aserbin Date: Tue, 25 Nov 2025 13:21:28 +0400 Subject: [PATCH] fix: fixed shifts time tracking --- logger/formatter.py | 2 +- main.py | 18 +++--- models/work_shifts.py | 24 +++----- services/time_tracking.py | 96 ++++++++++++++++-------------- services/work_shifts.py | 120 ++++++++++++++++++++++++++++++-------- 5 files changed, 168 insertions(+), 92 deletions(-) diff --git a/logger/formatter.py b/logger/formatter.py index e08339f..4b4833a 100644 --- a/logger/formatter.py +++ b/logger/formatter.py @@ -17,4 +17,4 @@ class JsonFormatter(logging.Formatter): if record.exc_info: log_record["exception"] = self.formatException(record.exc_info) - return json.dumps(log_record) + return json.dumps(log_record, ensure_ascii=False) diff --git a/main.py b/main.py index c67175a..ac0361b 100644 --- a/main.py +++ b/main.py @@ -24,24 +24,24 @@ async def lifespan(app: FastAPI): except KafkaConnectionError as e: print(e) - consumer_task = asyncio.create_task(consume_messages()) + # consumer_task = asyncio.create_task(consume_messages()) yield producer = await get_producer() if producer: await producer.stop() - consumer_task.cancel() + # consumer_task.cancel() app = FastAPI(lifespan=lifespan, separate_input_output_schemas=False, root_path='/api') -# app.add_middleware( -# CORSMiddleware, -# allow_origins=origins, -# allow_credentials=True, -# allow_methods=["*"], -# allow_headers=["*"], -# ) +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) routers_list = [ routers.attribute_router, diff --git a/models/work_shifts.py b/models/work_shifts.py index 4feded3..7967eed 100644 --- a/models/work_shifts.py +++ b/models/work_shifts.py @@ -1,5 +1,5 @@ from datetime import datetime, date -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Optional from sqlalchemy import ForeignKey, Table, Column from sqlalchemy.sql import expression @@ -15,12 +15,8 @@ class WorkShift(BaseModel): __tablename__ = "work_shifts" id: Mapped[int] = mapped_column(primary_key=True) - started_at: Mapped[datetime] = mapped_column( - nullable=False, - ) - finished_at: Mapped[datetime] = mapped_column( - nullable=True, - ) + started_at: Mapped[datetime] = mapped_column() + finished_at: Mapped[Optional[datetime]] = mapped_column() is_paused: Mapped[bool] = mapped_column( default=False, server_default=expression.false(), @@ -48,12 +44,8 @@ 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, - ) + started_at: Mapped[datetime] = mapped_column() + finished_at: Mapped[Optional[datetime]] = mapped_column() work_shift_id: Mapped[int] = mapped_column( ForeignKey("work_shifts.id"), @@ -77,10 +69,10 @@ class PlannedWorkShift(BaseModel): __tablename__ = "planned_work_shifts" id: Mapped[int] = mapped_column(primary_key=True) - shift_date: Mapped[date] = mapped_column(nullable=False, index=True) - created_at: Mapped[datetime] = mapped_column(nullable=False) + shift_date: Mapped[date] = mapped_column(index=True) + created_at: Mapped[datetime] = mapped_column() - user_id: Mapped[int] = mapped_column(ForeignKey('users.id'), nullable=False, index=True) + user_id: Mapped[int] = mapped_column(ForeignKey('users.id'), index=True) user: Mapped["User"] = relationship(lazy="selectin", backref="planned_work_shifts") positions: Mapped[list["Position"]] = relationship( diff --git a/services/time_tracking.py b/services/time_tracking.py index 58a70ac..c16749b 100644 --- a/services/time_tracking.py +++ b/services/time_tracking.py @@ -59,54 +59,64 @@ class TimeTrackingService(BaseService): records.append(record) return GetTimeTrackingRecordsResponse(records=records) + async def update_work_record( + self, + user: User, + request: UpdateTimeTrackingRecordRequest, + commit: bool = True, + ) -> tuple[bool, str]: + record_user = await UserService(self.session).get_by_id(user_id=request.user_id) + if not record_user: + return False, "Указанный пользователь не найден!" + if not record_user.pay_rate: + return False, "У пользователя не указана схема оплаты!" + existing_record_stmt = ( + select( + PaymentRecord + ) + .where( + PaymentRecord.user_id == request.user_id, + PaymentRecord.start_date == request.date, + PaymentRecord.end_date == request.date, + ) + ) + amount = ( + PayrollService( + self.session + ) + .get_amount( + user=record_user, + work_units=request.hours + ) + ) + existing_record = await self.session.scalar(existing_record_stmt) + if existing_record: + existing_record: PaymentRecord + existing_record.work_units = request.hours + existing_record.amount = amount + else: + new_record = PaymentRecord( + user_id=request.user_id, + created_by_user_id=user.id, + start_date=request.date, + end_date=request.date, + created_at=datetime.datetime.now(), + payroll_scheme_key=record_user.pay_rate.payroll_scheme_key, + amount=amount, + work_units=request.hours + ) + self.session.add(new_record) + if commit: + await self.session.commit() + return True, "Запись успешно добавлена" + async def update_record( self, user: User, request: UpdateTimeTrackingRecordRequest ) -> UpdateTimeTrackingRecordResponse: try: - record_user = await UserService(self.session).get_by_id(user_id=request.user_id) - if not record_user: - return UpdateTimeTrackingRecordResponse(ok=False, message="Указанный пользователь не найден!") - if not record_user.pay_rate: - return UpdateTimeTrackingRecordResponse(ok=False, message="У пользователя не указана схема оплаты!") - existing_record_stmt = ( - select( - PaymentRecord - ) - .where( - PaymentRecord.user_id == request.user_id, - PaymentRecord.start_date == request.date, - PaymentRecord.end_date == request.date, - ) - ) - amount = ( - PayrollService( - self.session - ) - .get_amount( - user=record_user, - work_units=request.hours - ) - ) - existing_record = await self.session.scalar(existing_record_stmt) - if existing_record: - existing_record: PaymentRecord - existing_record.work_units = request.hours - existing_record.amount = amount - else: - new_record = PaymentRecord( - user_id=request.user_id, - created_by_user_id=user.id, - start_date=request.date, - end_date=request.date, - created_at=datetime.datetime.now(), - payroll_scheme_key=record_user.pay_rate.payroll_scheme_key, - amount=amount, - work_units=request.hours - ) - self.session.add(new_record) - await self.session.commit() - return UpdateTimeTrackingRecordResponse(ok=True, message="Запись успешно обновлена") + ok, message = await self.update_work_record(user, request) + return UpdateTimeTrackingRecordResponse(ok=ok, message=message) except Exception as e: return UpdateTimeTrackingRecordResponse(ok=False, message=str(e)) diff --git a/services/work_shifts.py b/services/work_shifts.py index e4817d2..7b46d06 100644 --- a/services/work_shifts.py +++ b/services/work_shifts.py @@ -1,10 +1,11 @@ -from datetime import date, timedelta - import math +from datetime import date, timedelta, time + 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 @@ -74,33 +75,106 @@ class WorkShiftsService(BaseService): 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 False, "Смена для сотрудника еще не начата" + logger = logger_builder.get_logger() + try: + if not work_shift or work_shift.finished_at: + return False, "Смена для сотрудника еще не начата" - if work_shift.is_paused: - await self.finish_pause_by_shift_id(work_shift.id) + # End active pause + 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() + work_shift.finished_at = datetime.now() - pause_time = timedelta() - for pause in work_shift.pauses: - pause_time += pause.finished_at - pause.started_at + # Collect pauses + pauses = [ + (pause.started_at, pause.finished_at) + for pause in work_shift.pauses + ] - 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 + # Build raw work intervals + # Start with one interval: whole shift + raw_intervals = [(work_shift.started_at, work_shift.finished_at)] - 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) + # Subtract pauses from work intervals + work_intervals = self.subtract_pauses(raw_intervals, pauses) - hours, minutes = hours_to_hours_and_minutes(total_work_time) - return True, f"Смена закончена. Отработано {hours} ч. {minutes} мин." + # Split intervals by days + daily_hours = self.split_intervals_by_days(work_intervals) + + # Create work records per day + tts = TimeTrackingService(self.session) + for day, hours in daily_hours.items(): + if hours < (1 / 60): # ignore <1 minute + continue + + data = UpdateTimeTrackingRecordRequest( + user_id=work_shift.user_id, + date=day, + hours=hours, + ) + 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(hours * 3600 for hours in daily_hours.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) + + def subtract_pauses( + self, + work_intervals: list[tuple[datetime, datetime]], + pauses: list[tuple[datetime, datetime]] + ) -> list[tuple[datetime, datetime]]: + result = [] + + 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) + + return result + + def split_intervals_by_days(self, intervals: list[tuple[datetime, datetime]]) -> dict[date, float]: + from collections import defaultdict + res = 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) + + # last segment (same day) + seconds = (end - cur).total_seconds() + res[cur.date()] += seconds / 3600 + + return res @staticmethod def get_work_shifts_history_stmt() -> Select: