Files
ACORE/apps/visit_manager/services.py
T
lokses 5a274f7f90 Найм: авто-задача IT на доступи з чек-листом + сповіщення постановника
При створенні 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>
2026-07-25 01:01:30 +03:00

129 lines
4.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Сервисный слой: приём фото, генерация превью, сохранение 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()