fix: fixed shifts time tracking

This commit is contained in:
2025-11-25 13:21:28 +04:00
parent 678dd18e12
commit cb0168a199
5 changed files with 168 additions and 92 deletions

View File

@@ -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)

18
main.py
View File

@@ -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,

View File

@@ -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(

View File

@@ -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))

View File

@@ -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: