fix: fixed shifts time tracking
This commit is contained in:
@@ -17,4 +17,4 @@ class JsonFormatter(logging.Formatter):
|
|||||||
if record.exc_info:
|
if record.exc_info:
|
||||||
log_record["exception"] = self.formatException(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
18
main.py
@@ -24,24 +24,24 @@ async def lifespan(app: FastAPI):
|
|||||||
except KafkaConnectionError as e:
|
except KafkaConnectionError as e:
|
||||||
print(e)
|
print(e)
|
||||||
|
|
||||||
consumer_task = asyncio.create_task(consume_messages())
|
# consumer_task = asyncio.create_task(consume_messages())
|
||||||
|
|
||||||
yield
|
yield
|
||||||
producer = await get_producer()
|
producer = await get_producer()
|
||||||
if producer:
|
if producer:
|
||||||
await producer.stop()
|
await producer.stop()
|
||||||
consumer_task.cancel()
|
# consumer_task.cancel()
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(lifespan=lifespan, separate_input_output_schemas=False, root_path='/api')
|
app = FastAPI(lifespan=lifespan, separate_input_output_schemas=False, root_path='/api')
|
||||||
|
|
||||||
# app.add_middleware(
|
app.add_middleware(
|
||||||
# CORSMiddleware,
|
CORSMiddleware,
|
||||||
# allow_origins=origins,
|
allow_origins=["*"],
|
||||||
# allow_credentials=True,
|
allow_credentials=True,
|
||||||
# allow_methods=["*"],
|
allow_methods=["*"],
|
||||||
# allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
# )
|
)
|
||||||
|
|
||||||
routers_list = [
|
routers_list = [
|
||||||
routers.attribute_router,
|
routers.attribute_router,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
from datetime import datetime, date
|
from datetime import datetime, date
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING, Optional
|
||||||
|
|
||||||
from sqlalchemy import ForeignKey, Table, Column
|
from sqlalchemy import ForeignKey, Table, Column
|
||||||
from sqlalchemy.sql import expression
|
from sqlalchemy.sql import expression
|
||||||
@@ -15,12 +15,8 @@ class WorkShift(BaseModel):
|
|||||||
__tablename__ = "work_shifts"
|
__tablename__ = "work_shifts"
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
started_at: Mapped[datetime] = mapped_column(
|
started_at: Mapped[datetime] = mapped_column()
|
||||||
nullable=False,
|
finished_at: Mapped[Optional[datetime]] = mapped_column()
|
||||||
)
|
|
||||||
finished_at: Mapped[datetime] = mapped_column(
|
|
||||||
nullable=True,
|
|
||||||
)
|
|
||||||
is_paused: Mapped[bool] = mapped_column(
|
is_paused: Mapped[bool] = mapped_column(
|
||||||
default=False,
|
default=False,
|
||||||
server_default=expression.false(),
|
server_default=expression.false(),
|
||||||
@@ -48,12 +44,8 @@ class WorkShiftPause(BaseModel):
|
|||||||
__tablename__ = "work_shifts_pauses"
|
__tablename__ = "work_shifts_pauses"
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
started_at: Mapped[datetime] = mapped_column(
|
started_at: Mapped[datetime] = mapped_column()
|
||||||
nullable=False,
|
finished_at: Mapped[Optional[datetime]] = mapped_column()
|
||||||
)
|
|
||||||
finished_at: Mapped[datetime] = mapped_column(
|
|
||||||
nullable=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
work_shift_id: Mapped[int] = mapped_column(
|
work_shift_id: Mapped[int] = mapped_column(
|
||||||
ForeignKey("work_shifts.id"),
|
ForeignKey("work_shifts.id"),
|
||||||
@@ -77,10 +69,10 @@ class PlannedWorkShift(BaseModel):
|
|||||||
__tablename__ = "planned_work_shifts"
|
__tablename__ = "planned_work_shifts"
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
shift_date: Mapped[date] = mapped_column(nullable=False, index=True)
|
shift_date: Mapped[date] = mapped_column(index=True)
|
||||||
created_at: Mapped[datetime] = mapped_column(nullable=False)
|
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")
|
user: Mapped["User"] = relationship(lazy="selectin", backref="planned_work_shifts")
|
||||||
|
|
||||||
positions: Mapped[list["Position"]] = relationship(
|
positions: Mapped[list["Position"]] = relationship(
|
||||||
|
|||||||
@@ -59,54 +59,64 @@ class TimeTrackingService(BaseService):
|
|||||||
records.append(record)
|
records.append(record)
|
||||||
return GetTimeTrackingRecordsResponse(records=records)
|
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(
|
async def update_record(
|
||||||
self,
|
self,
|
||||||
user: User,
|
user: User,
|
||||||
request: UpdateTimeTrackingRecordRequest
|
request: UpdateTimeTrackingRecordRequest
|
||||||
) -> UpdateTimeTrackingRecordResponse:
|
) -> UpdateTimeTrackingRecordResponse:
|
||||||
try:
|
try:
|
||||||
record_user = await UserService(self.session).get_by_id(user_id=request.user_id)
|
ok, message = await self.update_work_record(user, request)
|
||||||
if not record_user:
|
return UpdateTimeTrackingRecordResponse(ok=ok, message=message)
|
||||||
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="Запись успешно обновлена")
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return UpdateTimeTrackingRecordResponse(ok=False, message=str(e))
|
return UpdateTimeTrackingRecordResponse(ok=False, message=str(e))
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
from datetime import date, timedelta
|
|
||||||
|
|
||||||
import math
|
import math
|
||||||
|
from datetime import date, timedelta, time
|
||||||
|
|
||||||
from fastapi import HTTPException, status
|
from fastapi import HTTPException, status
|
||||||
from sqlalchemy import select, func, extract, literal, label, Select
|
from sqlalchemy import select, func, extract, literal, label, Select
|
||||||
from sqlalchemy.orm import joinedload, selectinload
|
from sqlalchemy.orm import joinedload, selectinload
|
||||||
|
|
||||||
|
from logger import logger_builder
|
||||||
from models import WorkShift, User
|
from models import WorkShift, User
|
||||||
from models.work_shifts import WorkShiftPause
|
from models.work_shifts import WorkShiftPause
|
||||||
from schemas.base import PaginationSchema
|
from schemas.base import PaginationSchema
|
||||||
@@ -74,33 +75,106 @@ class WorkShiftsService(BaseService):
|
|||||||
return FinishShiftByIdResponse(ok=ok, message=message)
|
return FinishShiftByIdResponse(ok=ok, message=message)
|
||||||
|
|
||||||
async def _finish_shift_common(self, user: User, work_shift: Optional[WorkShift]) -> tuple[bool, str]:
|
async def _finish_shift_common(self, user: User, work_shift: Optional[WorkShift]) -> tuple[bool, str]:
|
||||||
if not work_shift or work_shift.finished_at:
|
logger = logger_builder.get_logger()
|
||||||
return False, "Смена для сотрудника еще не начата"
|
try:
|
||||||
|
if not work_shift or work_shift.finished_at:
|
||||||
|
return False, "Смена для сотрудника еще не начата"
|
||||||
|
|
||||||
if work_shift.is_paused:
|
# End active pause
|
||||||
await self.finish_pause_by_shift_id(work_shift.id)
|
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()
|
|
||||||
|
|
||||||
pause_time = timedelta()
|
# Collect pauses
|
||||||
for pause in work_shift.pauses:
|
pauses = [
|
||||||
pause_time += pause.finished_at - pause.started_at
|
(pause.started_at, pause.finished_at)
|
||||||
|
for pause in work_shift.pauses
|
||||||
|
]
|
||||||
|
|
||||||
total_work_time: timedelta = work_shift.finished_at - work_shift.started_at
|
# Build raw work intervals
|
||||||
pure_work_seconds = total_work_time.total_seconds() - pause_time.total_seconds()
|
# Start with one interval: whole shift
|
||||||
hours = pure_work_seconds / 3600
|
raw_intervals = [(work_shift.started_at, work_shift.finished_at)]
|
||||||
|
|
||||||
if pure_work_seconds >= 60:
|
# Subtract pauses from work intervals
|
||||||
data = UpdateTimeTrackingRecordRequest(
|
work_intervals = self.subtract_pauses(raw_intervals, pauses)
|
||||||
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)
|
# Split intervals by days
|
||||||
return True, f"Смена закончена. Отработано {hours} ч. {minutes} мин."
|
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
|
@staticmethod
|
||||||
def get_work_shifts_history_stmt() -> Select:
|
def get_work_shifts_history_stmt() -> Select:
|
||||||
|
|||||||
Reference in New Issue
Block a user