24f9e54283
Co-authored-by: Cursor <cursoragent@cursor.com>
381 lines
15 KiB
Python
381 lines
15 KiB
Python
"""Бізнес-логіка найму: інтерн-слот + IT Ticket на випробуванні;
|
||
персональний Employee і PDF — лише після затвердження."""
|
||
import secrets
|
||
|
||
from django.core.exceptions import ValidationError
|
||
from django.db import transaction
|
||
from django.utils import timezone
|
||
from django.utils.translation import gettext as _
|
||
from core.utils.validators import validate_ukrainian_tax_number
|
||
from pytils.translit import slugify
|
||
|
||
from apps.employees.models.base import Employee
|
||
from apps.hiring.models import HiringRequest, InternAccount
|
||
|
||
|
||
# Украинские буквы, которые pytils (русская либа) выбрасывает целиком —
|
||
# премаппим в латиницу до slugify, иначе Кіт→kt, Філіпʼєв→flpv и т.п.
|
||
_UA_TRANSLIT = {
|
||
'і': 'i', 'І': 'i', 'ї': 'i', 'Ї': 'i', 'є': 'ie', 'Є': 'ie',
|
||
'ґ': 'g', 'Ґ': 'g', 'ʼ': '', '’': '', '‘': '', "'": '', '`': '',
|
||
}
|
||
|
||
|
||
def translit_login(text: str) -> str:
|
||
"""Транслит для логина: украинские + русские буквы, нижний регистр, [a-z0-9]."""
|
||
pre = ''.join(_UA_TRANSLIT.get(c, c) for c in (text or ''))
|
||
return slugify(pre)
|
||
|
||
|
||
def _username_base(last_name, first_name):
|
||
"""Формат логина: <первая буква имени>.<фамилия>, транслит, нижний регистр.
|
||
Иван Иванов -> i.ivanov; Кіт Ірина -> i.kit."""
|
||
ln = translit_login(last_name)
|
||
fn = translit_login(first_name)
|
||
initial = fn[:1]
|
||
return f'{initial}.{ln}'.strip('.') or 'user'
|
||
|
||
|
||
def _generate_username(last_name, first_name):
|
||
base = _username_base(last_name, first_name)
|
||
username = base
|
||
counter = 1
|
||
while Employee.objects.filter(username=username).exists():
|
||
counter += 1
|
||
username = f'{base}{counter}'
|
||
return username
|
||
|
||
|
||
def _generate_password():
|
||
return secrets.token_urlsafe(12)
|
||
|
||
|
||
def _coerce_location_fields(user_location_type, department_id, user_pharmacy_id, user_location_id):
|
||
"""Приводить поля локації до правил Employee.clean(): аптека XOR офіс/склад."""
|
||
if user_location_type == 'pharmacy':
|
||
return None, user_pharmacy_id, None
|
||
if user_location_type in ('office', 'warehouse'):
|
||
return department_id, None, user_location_id
|
||
return department_id, user_pharmacy_id, user_location_id
|
||
|
||
|
||
def _filter_access_ids_for_location(user_location_type, requested_access_ids):
|
||
"""Залишає лише системи доступу, дозволені для типу локації.
|
||
|
||
Порожній location_types у системи = доступна скрізь (як у посад).
|
||
"""
|
||
if not requested_access_ids:
|
||
return []
|
||
from core.models import Directory
|
||
allowed = []
|
||
for access in Directory.objects.filter(
|
||
pk__in=requested_access_ids,
|
||
directory_type__code='access_system',
|
||
is_active=True,
|
||
).prefetch_related('location_types'):
|
||
types = [lt.code for lt in access.location_types.all()]
|
||
if not types or user_location_type in types:
|
||
allowed.append(access.pk)
|
||
return allowed
|
||
|
||
|
||
def _validate_hire_location(*, user_location_type, department_id, user_pharmacy_id,
|
||
user_location_id, post_id, hire_date):
|
||
"""Перевіряє правила локації Employee.clean() без збереження в БД."""
|
||
emp = Employee(
|
||
username='__hire_validate__',
|
||
last_name='_',
|
||
first_name='_',
|
||
hire_date=hire_date,
|
||
user_location_type=user_location_type,
|
||
department_id=department_id,
|
||
user_pharmacy_id=user_pharmacy_id,
|
||
user_location_id=user_location_id,
|
||
post_id=post_id,
|
||
)
|
||
emp.clean()
|
||
|
||
|
||
def _directory(type_code, code):
|
||
from core.models import Directory
|
||
return Directory.objects.get(directory_type__code=type_code, code=code)
|
||
|
||
|
||
def _ticket_location(hiring_request):
|
||
if hiring_request.user_location_type == 'pharmacy' and hiring_request.user_pharmacy_id:
|
||
return hiring_request.user_pharmacy
|
||
return hiring_request.user_location
|
||
|
||
|
||
def _build_ticket_description(hiring_request, intern):
|
||
loc_label = hiring_request.get_user_location_type_display()
|
||
if hiring_request.user_pharmacy_id:
|
||
loc_detail = str(hiring_request.user_pharmacy)
|
||
elif hiring_request.user_location_id:
|
||
loc_detail = str(hiring_request.user_location)
|
||
elif hiring_request.department_id:
|
||
loc_detail = hiring_request.department.name
|
||
else:
|
||
loc_detail = '—'
|
||
|
||
lines = [
|
||
f"{_('Кандидат')}: {hiring_request.last_name} {hiring_request.first_name} {hiring_request.middle_name}".strip(),
|
||
f"{_('ИНН')}: {hiring_request.inn_code or '—'}",
|
||
f"{_('Должность')}: {hiring_request.post.name if hiring_request.post_id else '—'}",
|
||
f"{_('Тип локации')}: {loc_label}",
|
||
f"{_('Локация / отдел')}: {loc_detail}",
|
||
f"{_('Дата выхода')}: {hiring_request.hire_date:%d.%m.%Y}",
|
||
f"{_('Оформление')}: {hiring_request.get_employment_type_display()}",
|
||
f"{_('Испытательный срок')}: {hiring_request.trial_days} {_('дн.')}",
|
||
f"{_('Интерн-аккаунт')}: {intern.label}",
|
||
f"{_('Логин')}: {intern.username}",
|
||
f"{_('Пароль')}: {intern.password}",
|
||
'',
|
||
f"{_('Требуемые доступы')}:",
|
||
]
|
||
accesses = list(hiring_request.requested_access.all())
|
||
if accesses:
|
||
for access in accesses:
|
||
lines.append(f' • {access.name}')
|
||
else:
|
||
lines.append(f" {_('(не указано)')}")
|
||
if hiring_request.access_note:
|
||
lines.append('')
|
||
lines.append(f"{_('Примечание')}: {hiring_request.access_note}")
|
||
lines.append('')
|
||
lines.append(_(
|
||
'Подготовьте рабочее место и выдайте доступы к интерн-аккаунту на время '
|
||
'испытания. После утверждения HR будет создан персональный аккаунт.'
|
||
))
|
||
return '\n'.join(lines)
|
||
|
||
|
||
def _create_access_ticket(hiring_request, requester, intern):
|
||
from apps.it_manager.models import Ticket
|
||
|
||
fio = f'{hiring_request.last_name} {hiring_request.first_name}'.strip()
|
||
ticket = Ticket.objects.create(
|
||
title=f"{_('Подготовка рабочего места')}: {fio}",
|
||
ticket_type=_directory('ticket_type', 'access'),
|
||
priority=_directory('ticket_priority', 'high'),
|
||
requester=requester,
|
||
location=_ticket_location(hiring_request),
|
||
description=_build_ticket_description(hiring_request, intern),
|
||
)
|
||
hiring_request.granted_ticket = ticket
|
||
hiring_request.save(update_fields=['granted_ticket', 'updated_at'])
|
||
return ticket
|
||
|
||
|
||
def _close_ticket(ticket):
|
||
if ticket is None:
|
||
return
|
||
closed = _directory('ticket_status', 'closed')
|
||
if ticket.status_id == closed.pk:
|
||
return
|
||
ticket.status = closed
|
||
ticket.save(update_fields=['status', 'closed_at', 'updated_at'])
|
||
|
||
|
||
@transaction.atomic
|
||
def _clean_inn_code(inn_code: str) -> str:
|
||
inn = (inn_code or '').strip()
|
||
if not inn:
|
||
raise ValidationError({
|
||
'inn_code': _('Укажите ИНН кандидата — он нужен фоторегистратору.'),
|
||
})
|
||
validate_ukrainian_tax_number(inn)
|
||
if Employee.objects.filter(inn_code=inn).exists():
|
||
raise ValidationError({'inn_code': _('Сотрудник с таким ИНН уже есть в системе.')})
|
||
if HiringRequest.objects.filter(inn_code=inn, status='trial').exists():
|
||
raise ValidationError({'inn_code': _('Заявка с таким ИНН уже на испытании.')})
|
||
return inn
|
||
|
||
|
||
def _clean_employment_type(employment_type: str) -> str:
|
||
allowed = {code for code, _label in Employee.EMPLOYMENT_TYPE_CHOICES}
|
||
value = (employment_type or '').strip()
|
||
if value in allowed:
|
||
return value
|
||
return Employee.EMPLOYMENT_STAFF
|
||
|
||
|
||
def create_hiring_request(*, requester, last_name, first_name, middle_name='',
|
||
candidate_email='', candidate_phone='', inn_code='',
|
||
user_location_type, department_id=None, user_pharmacy_id=None,
|
||
user_location_id=None, post_id=None,
|
||
hire_date, trial_days, requested_access_ids=None, access_note='',
|
||
employment_type=Employee.EMPLOYMENT_STAFF):
|
||
"""Створює заявку на випробування: виділяє інтерн-слот і IT Ticket.
|
||
Персональний Employee ще не створюється.
|
||
"""
|
||
department_id, user_pharmacy_id, user_location_id = _coerce_location_fields(
|
||
user_location_type, department_id, user_pharmacy_id, user_location_id,
|
||
)
|
||
_validate_hire_location(
|
||
user_location_type=user_location_type,
|
||
department_id=department_id,
|
||
user_pharmacy_id=user_pharmacy_id,
|
||
user_location_id=user_location_id,
|
||
post_id=post_id,
|
||
hire_date=hire_date,
|
||
)
|
||
requested_access_ids = _filter_access_ids_for_location(
|
||
user_location_type, requested_access_ids or [],
|
||
)
|
||
inn_code = _clean_inn_code(inn_code)
|
||
employment_type = _clean_employment_type(employment_type)
|
||
|
||
hiring_request = HiringRequest.objects.create(
|
||
last_name=last_name,
|
||
first_name=first_name,
|
||
middle_name=middle_name,
|
||
candidate_email=candidate_email,
|
||
candidate_phone=candidate_phone,
|
||
inn_code=inn_code,
|
||
user_location_type=user_location_type,
|
||
department_id=department_id,
|
||
user_pharmacy_id=user_pharmacy_id,
|
||
user_location_id=user_location_id,
|
||
post_id=post_id,
|
||
hire_date=hire_date,
|
||
employment_type=employment_type,
|
||
trial_days=trial_days,
|
||
access_note=access_note,
|
||
status='trial',
|
||
requested_by=requester,
|
||
)
|
||
if requested_access_ids:
|
||
hiring_request.requested_access.set(requested_access_ids)
|
||
|
||
intern = InternAccount.allocate(hiring_request)
|
||
_create_access_ticket(hiring_request, requester, intern)
|
||
return hiring_request
|
||
|
||
|
||
def _should_assign_office_email(user_location_type) -> bool:
|
||
return user_location_type == 'office'
|
||
|
||
|
||
def _office_email_for(username: str) -> str:
|
||
from apps.employees.models import EmployeeDefaults
|
||
domain = EmployeeDefaults.load().normalized_office_domain
|
||
return f'{username}@{domain}'
|
||
|
||
|
||
@transaction.atomic
|
||
def approve_hiring_request(hiring_request, decided_by, note=''):
|
||
locked = HiringRequest.objects.select_for_update().get(pk=hiring_request.pk)
|
||
if locked.status != 'trial':
|
||
return locked
|
||
|
||
username = _generate_username(locked.last_name, locked.first_name)
|
||
|
||
employee = Employee(
|
||
username=username,
|
||
last_name=locked.last_name,
|
||
first_name=locked.first_name,
|
||
middle_name=locked.middle_name,
|
||
personal_phone=locked.candidate_phone or '',
|
||
personal_email=locked.candidate_email or '',
|
||
inn_code=locked.inn_code or None,
|
||
is_active=True,
|
||
hire_date=locked.hire_date,
|
||
employment_type=locked.employment_type,
|
||
probation_end_date=timezone.localdate(),
|
||
user_location_type=locked.user_location_type,
|
||
department_id=locked.department_id,
|
||
user_pharmacy_id=locked.user_pharmacy_id,
|
||
user_location_id=locked.user_location_id,
|
||
post_id=locked.post_id,
|
||
ad_sync_enabled=False,
|
||
)
|
||
if _should_assign_office_email(locked.user_location_type):
|
||
employee.email = _office_email_for(username)
|
||
employee.set_unusable_password()
|
||
employee.full_clean()
|
||
employee.save()
|
||
|
||
# Роли по умолчанию по типу локации (Аптека/Офис/Склад).
|
||
from apps.employees.models import EmployeeDefaults
|
||
EmployeeDefaults.load().apply_default_groups(employee)
|
||
|
||
from apps.employees.services.password_invite import create_password_invite
|
||
invite = create_password_invite(employee, created_by=decided_by)
|
||
|
||
locked.employee = employee
|
||
locked.issued_password = ''
|
||
locked.status = 'approved'
|
||
locked.decision_by = decided_by
|
||
locked.decision_at = timezone.now()
|
||
locked.decision_note = note
|
||
locked.save(update_fields=[
|
||
'employee', 'issued_password', 'status',
|
||
'decision_by', 'decision_at', 'decision_note', 'updated_at',
|
||
])
|
||
locked.release_intern()
|
||
_close_ticket(locked.granted_ticket)
|
||
|
||
from core.security import log_security_event
|
||
|
||
log_security_event(
|
||
'employee.created_via_hiring',
|
||
actor=decided_by,
|
||
target=str(employee.pk),
|
||
details=f'username={employee.username} location={employee.user_location_type} invite={invite.pk}',
|
||
)
|
||
locked._password_invite = invite # для view/шаблону (не зберігається в БД)
|
||
return locked
|
||
|
||
|
||
@transaction.atomic
|
||
def reject_hiring_request(hiring_request, decided_by, note=''):
|
||
locked = HiringRequest.objects.select_for_update().get(pk=hiring_request.pk)
|
||
if locked.status != 'trial':
|
||
return locked
|
||
|
||
locked.release_intern()
|
||
_close_ticket(locked.granted_ticket)
|
||
|
||
locked.status = 'rejected'
|
||
locked.decision_by = decided_by
|
||
locked.decision_at = timezone.now()
|
||
locked.decision_note = note
|
||
locked.save(update_fields=[
|
||
'status', 'decision_by', 'decision_at', 'decision_note', 'updated_at',
|
||
])
|
||
return locked
|
||
|
||
|
||
@transaction.atomic
|
||
def build_hiring_credentials_pdf(hiring_request, request=None):
|
||
"""PDF з логіном і посиланням на встановлення пароля."""
|
||
from apps.hiring.pdf import build_credentials_pdf
|
||
from apps.employees.services.password_invite import (
|
||
build_invite_absolute_url,
|
||
create_password_invite,
|
||
get_active_invite_for_employee,
|
||
)
|
||
|
||
locked = HiringRequest.objects.select_for_update().get(pk=hiring_request.pk)
|
||
if locked.status != 'approved' or locked.employee_id is None:
|
||
return None
|
||
|
||
invite = get_active_invite_for_employee(locked.employee)
|
||
if invite is None and not locked.employee.has_usable_password():
|
||
invite = create_password_invite(locked.employee, created_by=None)
|
||
|
||
invite_url = build_invite_absolute_url(request, invite) if invite else ''
|
||
locked = (
|
||
HiringRequest.objects.select_related('employee', 'post')
|
||
.prefetch_related('requested_access')
|
||
.get(pk=locked.pk)
|
||
)
|
||
return build_credentials_pdf(locked, invite_url=invite_url)
|
||
|
||
|
||
@transaction.atomic
|
||
def consume_issued_password_for_pdf(hiring_request):
|
||
"""Зворотна сумісність: PDF без одноразового пароля."""
|
||
return build_hiring_credentials_pdf(hiring_request)
|