24f9e54283
Co-authored-by: Cursor <cursoragent@cursor.com>
284 lines
11 KiB
Python
284 lines
11 KiB
Python
from datetime import timedelta
|
||
|
||
from django.core.exceptions import ValidationError
|
||
from django.core.validators import MaxValueValidator, MinValueValidator
|
||
from django.db import models
|
||
from django.utils import timezone
|
||
from django.utils.translation import gettext_lazy as _
|
||
|
||
from apps.employees.models.base import Employee
|
||
from core.models import BaseModel
|
||
from core.models.fields import EncryptedCharField
|
||
from core.utils.validators import validate_ukrainian_phone, validate_ukrainian_tax_number
|
||
|
||
|
||
class InternAccount(BaseModel):
|
||
"""Спільний інтерн-обліковий запис з пулу (5 слотів). Призначається заявці
|
||
на час випробування; після approve/reject звільняється."""
|
||
|
||
label = models.CharField(_('Метка'), max_length=50, unique=True)
|
||
username = EncryptedCharField(_('Логин'), max_length=512)
|
||
password = EncryptedCharField(_('Пароль'), max_length=512)
|
||
is_active = models.BooleanField(_('Активный'), default=True)
|
||
assigned_to = models.OneToOneField(
|
||
'hiring.HiringRequest', on_delete=models.SET_NULL,
|
||
null=True, blank=True,
|
||
related_name='intern_account', verbose_name=_('Назначено заявке'),
|
||
)
|
||
|
||
class Meta:
|
||
verbose_name = _('Интерн-аккаунт')
|
||
verbose_name_plural = _('Пул интерн-аккаунтов')
|
||
ordering = ['label']
|
||
|
||
def __str__(self):
|
||
status = _('занят') if self.assigned_to_id else _('свободен')
|
||
return f'{self.label} ({status})'
|
||
|
||
@classmethod
|
||
def allocate(cls, hiring_request):
|
||
"""Виділяє свободен слот під select_for_update (викликати всередині atomic)."""
|
||
account = (
|
||
cls.objects.select_for_update()
|
||
.filter(is_active=True, assigned_to__isnull=True)
|
||
.order_by('label')
|
||
.first()
|
||
)
|
||
if account is None:
|
||
raise ValidationError({
|
||
'__all__': _(
|
||
'Нет свободных интерн-аккаунтов. '
|
||
'Дождитесь завершения стажировки или добавьте слот в админке.'
|
||
),
|
||
})
|
||
account.assigned_to = hiring_request
|
||
account.save(update_fields=['assigned_to', 'updated_at'])
|
||
return account
|
||
|
||
def release(self):
|
||
if self.assigned_to_id is None:
|
||
return
|
||
self.assigned_to = None
|
||
self.save(update_fields=['assigned_to', 'updated_at'])
|
||
|
||
|
||
class HiringRequest(BaseModel):
|
||
"""Заявка HR на найм кандидата: інтерн-слот + IT Ticket на випробуванні,
|
||
персональний Employee — лише після затвердження."""
|
||
|
||
STATUS_CHOICES = [
|
||
('trial', _('На испытании')),
|
||
('approved', _('Утверждено')),
|
||
('rejected', _('Отклонено')),
|
||
]
|
||
TRIAL_DAYS_CHOICES = [(i, _('%(n)d дн.') % {'n': i}) for i in range(1, 6)]
|
||
|
||
last_name = models.CharField(_('Фамилия'), max_length=150)
|
||
first_name = models.CharField(_('Имя'), max_length=150)
|
||
middle_name = models.CharField(_('Отчество'), max_length=150, blank=True)
|
||
|
||
candidate_email = models.EmailField(_('Личный email кандидата'), blank=True)
|
||
candidate_phone = models.CharField(
|
||
_('Телефон кандидата'), max_length=20, blank=True,
|
||
validators=[validate_ukrainian_phone],
|
||
)
|
||
inn_code = models.CharField(
|
||
_('ИНН'), max_length=10, blank=True,
|
||
help_text=_('Нужен для фоторегистратора (учёт посещаемости).'),
|
||
validators=[validate_ukrainian_tax_number],
|
||
)
|
||
|
||
LOCATION_TYPES = [
|
||
('pharmacy', _('Аптека')),
|
||
('warehouse', _('Склад')),
|
||
('office', _('Офис')),
|
||
]
|
||
user_location_type = models.CharField(
|
||
_('Тип локации'), max_length=20, choices=LOCATION_TYPES,
|
||
)
|
||
department = models.ForeignKey(
|
||
'core.Directory', on_delete=models.SET_NULL,
|
||
null=True, blank=True,
|
||
limit_choices_to={'directory_type__code': 'department', 'is_active': True},
|
||
related_name='hiring_requests_department', verbose_name=_('Отдел'),
|
||
)
|
||
user_pharmacy = models.ForeignKey(
|
||
'locations.Pharmacy', on_delete=models.SET_NULL,
|
||
null=True, blank=True,
|
||
related_name='hiring_requests', verbose_name=_('Аптека'),
|
||
)
|
||
user_location = models.ForeignKey(
|
||
'locations.Location', on_delete=models.SET_NULL,
|
||
null=True, blank=True,
|
||
related_name='hiring_requests_location', verbose_name=_('Локация (офис/склад)'),
|
||
)
|
||
post = models.ForeignKey(
|
||
'core.Directory', on_delete=models.SET_NULL,
|
||
null=True, blank=True,
|
||
limit_choices_to={'directory_type__code': 'post', 'is_active': True},
|
||
related_name='hiring_requests_post', verbose_name=_('Должность'),
|
||
)
|
||
|
||
hire_date = models.DateField(_('Дата виходу'), default=timezone.localdate)
|
||
employment_type = models.CharField(
|
||
_('Оформление'),
|
||
max_length=16,
|
||
choices=Employee.EMPLOYMENT_TYPE_CHOICES,
|
||
default=Employee.EMPLOYMENT_STAFF,
|
||
help_text=_('Штат по трудовому договору или работа по гражданско-правовому договору.'),
|
||
)
|
||
trial_days = models.PositiveSmallIntegerField(
|
||
_('Срок испытания, дней'), choices=TRIAL_DAYS_CHOICES, default=3,
|
||
validators=[MinValueValidator(1), MaxValueValidator(5)],
|
||
)
|
||
|
||
requested_access = models.ManyToManyField(
|
||
'core.Directory', blank=True,
|
||
limit_choices_to={'directory_type__code': 'access_system', 'is_active': True},
|
||
related_name='hiring_requests_access', verbose_name=_('Требуемые доступы'),
|
||
)
|
||
access_note = models.TextField(_('Примечание по доступам'), blank=True)
|
||
|
||
status = models.CharField(_('Статус'), max_length=10, choices=STATUS_CHOICES, default='trial')
|
||
|
||
requested_by = models.ForeignKey(
|
||
'employees.Employee', on_delete=models.PROTECT,
|
||
related_name='hiring_requests_submitted', verbose_name=_('Подал HR'),
|
||
)
|
||
employee = models.OneToOneField(
|
||
'employees.Employee', on_delete=models.SET_NULL,
|
||
null=True, blank=True,
|
||
related_name='hiring_request', verbose_name=_('Учётная запись'),
|
||
)
|
||
decision_by = models.ForeignKey(
|
||
'employees.Employee', on_delete=models.SET_NULL,
|
||
null=True, blank=True,
|
||
related_name='hiring_requests_decided', verbose_name=_('Решение принял'),
|
||
)
|
||
decision_at = models.DateTimeField(_('Дата решения'), null=True, blank=True)
|
||
decision_note = models.TextField(_('Комментарий к решению'), blank=True)
|
||
|
||
granted_ticket = models.ForeignKey(
|
||
'it_manager.Ticket', on_delete=models.SET_NULL,
|
||
null=True, blank=True,
|
||
related_name='hiring_requests', verbose_name=_('Заявка в IT'),
|
||
)
|
||
access_task = models.ForeignKey(
|
||
'tasks.Task', on_delete=models.SET_NULL,
|
||
null=True, blank=True,
|
||
related_name='+', verbose_name=_('Задача на выдачу доступов'),
|
||
)
|
||
|
||
# Пароль персонального акаунта після approve — для одноразового PDF.
|
||
issued_password = EncryptedCharField(
|
||
_('Выданный пароль (PDF)'), max_length=512, blank=True,
|
||
)
|
||
|
||
class Meta:
|
||
verbose_name = _('Заявка на найм')
|
||
verbose_name_plural = _('Заявки на найм')
|
||
ordering = ['-created_at']
|
||
indexes = [
|
||
models.Index(fields=['status']),
|
||
]
|
||
|
||
def __str__(self):
|
||
return f'{self.last_name} {self.first_name} ({self.get_status_display()})'
|
||
|
||
@property
|
||
def trial_end_date(self):
|
||
return self.hire_date + timedelta(days=self.trial_days)
|
||
|
||
@property
|
||
def is_overdue(self):
|
||
return self.status == 'trial' and self.trial_end_date < timezone.localdate()
|
||
|
||
def release_intern(self):
|
||
try:
|
||
account = self.intern_account
|
||
except Exception:
|
||
return
|
||
account.release()
|
||
|
||
|
||
class Vacancy(BaseModel):
|
||
"""Открытая вакансия (рекрутинг в HR-портале)."""
|
||
|
||
STATUS_CHOICES = [
|
||
('open', _('Открыта')),
|
||
('closed', _('Закрыта')),
|
||
('filled', _('Закрыта наймом')),
|
||
]
|
||
PRIORITY_CHOICES = [
|
||
('urgent', _('Срочно')),
|
||
('medium', _('Средний')),
|
||
('normal', _('Обычный')),
|
||
]
|
||
LOCATION_TYPES = [
|
||
('pharmacy', _('Аптека')),
|
||
('warehouse', _('Склад')),
|
||
('office', _('Офис')),
|
||
]
|
||
|
||
title = models.CharField(_('Название'), max_length=200)
|
||
post = models.ForeignKey(
|
||
'core.Directory', on_delete=models.SET_NULL,
|
||
null=True, blank=True,
|
||
limit_choices_to={'directory_type__code': 'post', 'is_active': True},
|
||
related_name='vacancies', verbose_name=_('Должность'),
|
||
)
|
||
user_location_type = models.CharField(
|
||
_('Тип локации'), max_length=20, choices=LOCATION_TYPES, default='pharmacy',
|
||
)
|
||
department = models.ForeignKey(
|
||
'core.Directory', on_delete=models.SET_NULL,
|
||
null=True, blank=True,
|
||
limit_choices_to={'directory_type__code': 'department', 'is_active': True},
|
||
related_name='vacancies_department', verbose_name=_('Отдел'),
|
||
)
|
||
pharmacy = models.ForeignKey(
|
||
'locations.Pharmacy', on_delete=models.SET_NULL,
|
||
null=True, blank=True,
|
||
related_name='pharmacy_vacancies', verbose_name=_('Аптека'),
|
||
)
|
||
location = models.ForeignKey(
|
||
'locations.Location', on_delete=models.SET_NULL,
|
||
null=True, blank=True,
|
||
related_name='location_vacancies', verbose_name=_('Локация'),
|
||
)
|
||
priority = models.CharField(
|
||
_('Приоритет'), max_length=16, choices=PRIORITY_CHOICES, default='normal',
|
||
)
|
||
status = models.CharField(
|
||
_('Статус'), max_length=16, choices=STATUS_CHOICES, default='open',
|
||
)
|
||
opened_at = models.DateField(_('Открыта с'), default=timezone.localdate)
|
||
description = models.TextField(_('Описание'), blank=True)
|
||
candidates_count = models.PositiveIntegerField(_('Кандидатов'), default=0)
|
||
created_by = models.ForeignKey(
|
||
'employees.Employee', on_delete=models.SET_NULL,
|
||
null=True, blank=True,
|
||
related_name='vacancies_created', verbose_name=_('Создал'),
|
||
)
|
||
|
||
class Meta:
|
||
verbose_name = _('Вакансия')
|
||
verbose_name_plural = _('Вакансии')
|
||
ordering = ['-opened_at', '-created_at']
|
||
indexes = [
|
||
models.Index(fields=['status', 'priority']),
|
||
]
|
||
|
||
def __str__(self):
|
||
return self.title
|
||
|
||
@property
|
||
def place_label(self) -> str:
|
||
if self.user_location_type == 'pharmacy' and self.pharmacy_id:
|
||
return str(self.pharmacy)
|
||
if self.department_id:
|
||
return self.department.name
|
||
if self.location_id:
|
||
return str(self.location)
|
||
return '—'
|