5a274f7f90
При створенні HR-заявки з обраними базами/доступами тепер одразу ставиться задача IT-відділу (замість тікета на затвердженні): - TaskChecklistItem: чек-лист задачі, виконавець відмічає зроблене (task-detail: чекбокси, лічильник виконаного, хто/коли відмітив) - create_hiring_request → _create_access_task: задача з чек-листом по базах, creator = HR (постановник), co_executors = IT-група (тригерить сповіщення ботом Ричард), priority=high, due_date рахується за робочим часом - add_working_hours (visit_manager/services): дедлайн за робочим вікном 09:00–18:00 з урахуванням вихідних і свят (Holiday) - Постановнику (HR) бот пише, що заявку взято в роботу, строк — 24 год робочого часу (до розрахованої дати) - approve більше не створює дублюючий IT-тікет (доступи вже задачею) - HiringRequest.access_task; чек-лист в адмінці задач (inline) - Тести: hiring (задача+чек-лист, approve без тікета), робочі години (вихідні/свята), відмітка чек-листа через в'ю Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
129 lines
4.6 KiB
Python
129 lines
4.6 KiB
Python
"""
|
||
Сервисный слой: приём фото, генерация превью, сохранение AttendanceLog, обновление WorkShift.
|
||
"""
|
||
import datetime
|
||
import io
|
||
from django.core.files.base import ContentFile
|
||
from django.utils import timezone
|
||
from PIL import Image
|
||
|
||
from .models import AttendanceLog, Holiday, WorkShift
|
||
|
||
# Рабочее окно для расчёта дедлайнов по «рабочему времени» (SLA задач).
|
||
WORK_DAY_START = datetime.time(9, 0)
|
||
WORK_DAY_END = datetime.time(18, 0) # 9 рабочих часов в день
|
||
|
||
|
||
def _is_working_day(d: datetime.date) -> bool:
|
||
"""Будни, не выходной и не нерабочий праздник (Holiday)."""
|
||
if d.weekday() >= 5: # сб/вс
|
||
return False
|
||
holiday = Holiday.objects.filter(date=d).first()
|
||
if holiday and not holiday.is_working_day:
|
||
return False
|
||
return True
|
||
|
||
|
||
def add_working_hours(start: datetime.datetime, hours: float) -> datetime.datetime:
|
||
"""Дата-время через `hours` РАБОЧИХ часов от start, с учётом рабочего окна
|
||
(09:00–18:00), выходных и праздников. Возвращает aware-datetime в локальной TZ."""
|
||
current = timezone.localtime(start) if timezone.is_aware(start) else start
|
||
remaining = datetime.timedelta(hours=hours)
|
||
tz = current.tzinfo
|
||
|
||
for _ in range(100000): # страховка от зацикливания на кривых данных
|
||
d = current.date()
|
||
if not _is_working_day(d):
|
||
current = datetime.datetime.combine(d + datetime.timedelta(days=1), WORK_DAY_START, tzinfo=tz)
|
||
continue
|
||
day_start = datetime.datetime.combine(d, WORK_DAY_START, tzinfo=tz)
|
||
day_end = datetime.datetime.combine(d, WORK_DAY_END, tzinfo=tz)
|
||
if current < day_start:
|
||
current = day_start
|
||
if current >= day_end:
|
||
current = datetime.datetime.combine(d + datetime.timedelta(days=1), WORK_DAY_START, tzinfo=tz)
|
||
continue
|
||
available = day_end - current
|
||
if remaining <= available:
|
||
return current + remaining
|
||
remaining -= available
|
||
current = datetime.datetime.combine(d + datetime.timedelta(days=1), WORK_DAY_START, tzinfo=tz)
|
||
return current
|
||
|
||
|
||
THUMB_SIZE = (64, 64)
|
||
PHOTO_QUALITY = 75
|
||
THUMB_QUALITY = 60
|
||
MAX_PHOTO_RES = (640, 480)
|
||
|
||
|
||
def _compress_photo(image_bytes: bytes) -> bytes:
|
||
img = Image.open(io.BytesIO(image_bytes))
|
||
img = img.convert('RGB')
|
||
img.thumbnail(MAX_PHOTO_RES, Image.LANCZOS)
|
||
buf = io.BytesIO()
|
||
img.save(buf, format='JPEG', quality=PHOTO_QUALITY, optimize=True)
|
||
return buf.getvalue()
|
||
|
||
|
||
def _generate_thumb(image_bytes: bytes) -> bytes:
|
||
img = Image.open(io.BytesIO(image_bytes))
|
||
img = img.convert('RGB')
|
||
img.thumbnail(THUMB_SIZE, Image.LANCZOS)
|
||
buf = io.BytesIO()
|
||
img.save(buf, format='JPEG', quality=THUMB_QUALITY, optimize=True)
|
||
return buf.getvalue()
|
||
|
||
|
||
def register_attendance(
|
||
*,
|
||
employee_id: int,
|
||
location_id: int,
|
||
direction: str,
|
||
photo_bytes: bytes,
|
||
device_id: str = '',
|
||
ip_address: str | None = None,
|
||
) -> AttendanceLog:
|
||
"""
|
||
Создаёт AttendanceLog:
|
||
- сжимает оригинал до 640×480 и сохраняет через ImageField (ФС)
|
||
- генерирует превью 64×64 и сохраняет в bytea
|
||
- обновляет WorkShift (материализованная пара приход/уход)
|
||
"""
|
||
now = timezone.now()
|
||
compressed = _compress_photo(photo_bytes)
|
||
thumb = _generate_thumb(photo_bytes)
|
||
|
||
log = AttendanceLog(
|
||
employee_id=employee_id,
|
||
location_id=location_id,
|
||
direction=direction,
|
||
timestamp=now,
|
||
device_id=device_id,
|
||
ip_address=ip_address,
|
||
photo_thumb=thumb,
|
||
)
|
||
filename = f'{employee_id}_{now.strftime("%Y%m%d_%H%M%S")}.jpg'
|
||
log.photo.save(filename, ContentFile(compressed), save=False)
|
||
log.save()
|
||
|
||
_update_work_shift(log)
|
||
return log
|
||
|
||
|
||
def _update_work_shift(log: AttendanceLog) -> None:
|
||
shift, _ = WorkShift.objects.get_or_create(
|
||
employee_id=log.employee_id,
|
||
location_id=log.location_id,
|
||
date=log.timestamp.date(),
|
||
)
|
||
if log.direction == 'in' and shift.log_in_id is None:
|
||
shift.log_in = log
|
||
shift.arrived_at = log.timestamp
|
||
elif log.direction == 'out':
|
||
shift.log_out = log
|
||
shift.left_at = log.timestamp
|
||
if shift.arrived_at:
|
||
shift.duration = shift.left_at - shift.arrived_at
|
||
shift.save()
|