3359a8db13
Модель/сервисы HR-заявок с заменами, приглашения на установку пароля (инвайты), обновлённая страница профиля сотрудника и правки 2FA. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
179 lines
7.4 KiB
Python
179 lines
7.4 KiB
Python
"""Кадровые заявки сотрудников: отпуск, больничный, справки."""
|
||
from django.conf import settings
|
||
from django.core.exceptions import ValidationError
|
||
from django.db import models
|
||
from django.utils import timezone
|
||
from django.utils.translation import gettext_lazy as _
|
||
|
||
from core.models import BaseModel
|
||
|
||
|
||
class LeaveEntitlement(BaseModel):
|
||
"""Норма дней отпуска/больничных на год (если нет записи — дефолты из сервиса)."""
|
||
|
||
employee = models.ForeignKey(
|
||
settings.AUTH_USER_MODEL,
|
||
on_delete=models.CASCADE,
|
||
related_name='leave_entitlements',
|
||
verbose_name=_('Сотрудник'),
|
||
)
|
||
year = models.PositiveIntegerField(_('Год'))
|
||
annual_days = models.PositiveSmallIntegerField(_('Основной отпуск, дн.'), default=24)
|
||
additional_days = models.PositiveSmallIntegerField(_('Доп. отпуск, дн.'), default=7)
|
||
sick_days = models.PositiveSmallIntegerField(_('Больничные, дн.'), default=30)
|
||
|
||
class Meta:
|
||
verbose_name = _('Норма отпуска')
|
||
verbose_name_plural = _('Нормы отпусков')
|
||
constraints = [
|
||
models.UniqueConstraint(fields=['employee', 'year'], name='unique_leave_entitlement_per_year'),
|
||
]
|
||
ordering = ['-year', 'employee__last_name']
|
||
|
||
def __str__(self):
|
||
return f'{self.employee} — {self.year}'
|
||
|
||
|
||
class EmployeeHrRequest(BaseModel):
|
||
REQUEST_TYPES = [
|
||
('vacation', _('Отпуск')),
|
||
('additional_vacation', _('Дополнительный отпуск')),
|
||
('sick_leave', _('Больничный')),
|
||
('day_off', _('Отгул')),
|
||
('certificate', _('Справка')),
|
||
('substitution', _('Подмена')),
|
||
]
|
||
STATUS_CHOICES = [
|
||
('pending', _('На рассмотрении')),
|
||
('approved', _('Утверждено')),
|
||
('rejected', _('Отклонено')),
|
||
('cancelled', _('Отменено')),
|
||
]
|
||
DATE_REQUIRED_TYPES = {
|
||
'vacation', 'additional_vacation', 'sick_leave', 'day_off', 'substitution',
|
||
}
|
||
|
||
employee = models.ForeignKey(
|
||
settings.AUTH_USER_MODEL,
|
||
on_delete=models.CASCADE,
|
||
related_name='hr_requests',
|
||
verbose_name=_('Сотрудник'),
|
||
)
|
||
request_type = models.CharField(_('Тип'), max_length=32, choices=REQUEST_TYPES)
|
||
date_from = models.DateField(_('Дата с'), null=True, blank=True)
|
||
date_to = models.DateField(_('Дата по'), null=True, blank=True)
|
||
note = models.TextField(_('Комментарий'), blank=True)
|
||
substitute = models.ForeignKey(
|
||
settings.AUTH_USER_MODEL,
|
||
on_delete=models.SET_NULL,
|
||
null=True,
|
||
blank=True,
|
||
related_name='hr_substitution_requests',
|
||
verbose_name=_('Кто подменяет'),
|
||
help_text=_('Заполняется, когда сотрудник берёт открытый запрос на подмену'),
|
||
)
|
||
status = models.CharField(
|
||
_('Статус'), max_length=16, choices=STATUS_CHOICES, default='pending',
|
||
)
|
||
decided_by = models.ForeignKey(
|
||
settings.AUTH_USER_MODEL,
|
||
on_delete=models.SET_NULL,
|
||
null=True,
|
||
blank=True,
|
||
related_name='hr_requests_decided',
|
||
verbose_name=_('Решение принял'),
|
||
)
|
||
decided_at = models.DateTimeField(_('Дата решения'), null=True, blank=True)
|
||
decision_note = models.TextField(_('Комментарий HR'), blank=True)
|
||
|
||
class Meta:
|
||
verbose_name = _('Кадровая заявка')
|
||
verbose_name_plural = _('Кадровые заявки')
|
||
ordering = ['-created_at']
|
||
indexes = [
|
||
models.Index(fields=['employee', 'status']),
|
||
models.Index(fields=['status', 'request_type']),
|
||
]
|
||
|
||
def __str__(self):
|
||
return f'{self.employee} — {self.get_request_type_display()} ({self.get_status_display()})'
|
||
|
||
@property
|
||
def day_count(self) -> int:
|
||
if not self.date_from or not self.date_to:
|
||
return 0
|
||
return (self.date_to - self.date_from).days + 1
|
||
|
||
@property
|
||
def summary(self) -> str:
|
||
label = self.get_request_type_display()
|
||
if self.request_type == 'substitution' and self.substitute_id:
|
||
who = self.substitute.get_full_name() or self.substitute.username
|
||
label = f'{label} ({who})'
|
||
if self.date_from and self.date_to:
|
||
if self.date_from == self.date_to:
|
||
return f'{label} {self.date_from.strftime("%d.%m.%Y")}'
|
||
return (
|
||
f'{label} {self.date_from.strftime("%d.%m.%Y")}'
|
||
f' – {self.date_to.strftime("%d.%m.%Y")}'
|
||
)
|
||
return label
|
||
|
||
@property
|
||
def pharmacy_label(self) -> str:
|
||
"""Краткое название аптеки заявителя (код/организация №)."""
|
||
pharmacy = getattr(self.employee, 'user_pharmacy', None) if self.employee_id else None
|
||
if pharmacy is None:
|
||
return ''
|
||
from apps.locations.models.base import pharmacy_object_code
|
||
code = pharmacy_object_code(pharmacy)
|
||
org = ''
|
||
if pharmacy.organization_id and pharmacy.organization:
|
||
org = pharmacy.organization.name or ''
|
||
if code and org:
|
||
return f'{code} ({org})'
|
||
if code:
|
||
return code
|
||
if pharmacy.pharmacy_number is not None:
|
||
head = f'Аптека №{pharmacy.pharmacy_number}'
|
||
return f'{org} {head}'.strip() if org else head
|
||
return str(pharmacy)
|
||
|
||
@property
|
||
def pharmacy_address(self) -> str:
|
||
pharmacy = getattr(self.employee, 'user_pharmacy', None) if self.employee_id else None
|
||
if pharmacy is None:
|
||
return ''
|
||
return (pharmacy.get_full_address() or pharmacy.address or '').strip()
|
||
|
||
@property
|
||
def pharmacy_place(self) -> str:
|
||
"""Аптека + адрес одной строкой для списков."""
|
||
label = self.pharmacy_label
|
||
addr = self.pharmacy_address
|
||
if label and addr:
|
||
return f'{label} — {addr}'
|
||
return label or addr
|
||
|
||
@property
|
||
def status_label(self) -> str:
|
||
if self.request_type == 'substitution' and self.status == 'pending':
|
||
if self.substitute_id:
|
||
return str(_('Ожидает подтверждения'))
|
||
return str(_('Ищет подмену'))
|
||
return self.get_status_display()
|
||
|
||
def clean(self):
|
||
super().clean()
|
||
if self.request_type in self.DATE_REQUIRED_TYPES:
|
||
if not self.date_from or not self.date_to:
|
||
raise ValidationError(_('Укажите даты начала и окончания.'))
|
||
if self.date_to < self.date_from:
|
||
raise ValidationError(_('Дата окончания не может быть раньше начала.'))
|
||
elif self.date_from and self.date_to and self.date_to < self.date_from:
|
||
raise ValidationError(_('Дата окончания не может быть раньше начала.'))
|
||
|
||
if self.request_type == 'substitution' and self.substitute_id:
|
||
if self.substitute_id == self.employee_id:
|
||
raise ValidationError({'substitute': _('Нельзя указать себя для подмены.')})
|