hiring: спрашивать ИНН в заявке на найм для фоторегистратора.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -270,6 +270,7 @@ class HiringInviteFlowTests(TestCase):
|
||||
requester=self.hr,
|
||||
last_name='Новий',
|
||||
first_name='Співробітник',
|
||||
inn_code='0000000017',
|
||||
user_location_type='pharmacy',
|
||||
user_pharmacy_id=self.pharmacy.pk,
|
||||
department_id=self.dept.pk,
|
||||
|
||||
@@ -34,7 +34,7 @@ class HiringRequestAdmin(ModelAdmin):
|
||||
'requested_by', 'granted_ticket', 'created_at',
|
||||
]
|
||||
list_filter = ['status', 'user_location_type']
|
||||
search_fields = ['last_name', 'first_name', 'middle_name', 'candidate_email']
|
||||
search_fields = ['last_name', 'first_name', 'middle_name', 'candidate_email', 'inn_code']
|
||||
list_select_related = [
|
||||
'requested_by', 'decision_by', 'employee', 'post', 'department',
|
||||
'user_pharmacy', 'user_location', 'granted_ticket',
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import core.utils.validators
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('hiring', '0008_vacancy'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='hiringrequest',
|
||||
name='inn_code',
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
help_text='Нужен для фоторегистратора (учёт посещаемости).',
|
||||
max_length=10,
|
||||
validators=[core.utils.validators.validate_ukrainian_tax_number],
|
||||
verbose_name='ИНН',
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -8,7 +8,7 @@ from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from core.models import BaseModel
|
||||
from core.models.fields import EncryptedCharField
|
||||
from core.utils.validators import validate_ukrainian_phone
|
||||
from core.utils.validators import validate_ukrainian_phone, validate_ukrainian_tax_number
|
||||
|
||||
|
||||
class InternAccount(BaseModel):
|
||||
@@ -81,6 +81,11 @@ class HiringRequest(BaseModel):
|
||||
_('Телефон кандидата'), 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', _('Аптека')),
|
||||
|
||||
+21
-1
@@ -2,9 +2,11 @@
|
||||
персональний 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
|
||||
@@ -118,6 +120,7 @@ def _build_ticket_description(hiring_request, intern):
|
||||
|
||||
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}",
|
||||
@@ -174,8 +177,22 @@ def _close_ticket(ticket):
|
||||
|
||||
|
||||
@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 create_hiring_request(*, requester, last_name, first_name, middle_name='',
|
||||
candidate_email='', candidate_phone='',
|
||||
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=''):
|
||||
@@ -196,6 +213,7 @@ def create_hiring_request(*, requester, last_name, first_name, middle_name='',
|
||||
requested_access_ids = _filter_access_ids_for_location(
|
||||
user_location_type, requested_access_ids or [],
|
||||
)
|
||||
inn_code = _clean_inn_code(inn_code)
|
||||
|
||||
hiring_request = HiringRequest.objects.create(
|
||||
last_name=last_name,
|
||||
@@ -203,6 +221,7 @@ def create_hiring_request(*, requester, last_name, first_name, middle_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,
|
||||
@@ -247,6 +266,7 @@ def approve_hiring_request(hiring_request, decided_by, note=''):
|
||||
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,
|
||||
probation_end_date=timezone.localdate(),
|
||||
|
||||
@@ -16,6 +16,25 @@ from apps.locations.models import Location, Pharmacy
|
||||
Employee = get_user_model()
|
||||
|
||||
_employee_seq = 0
|
||||
_inn_seq = 1000
|
||||
|
||||
_INN_COEFFS = (-1, 5, 7, 9, 4, 6, 10, 5, 7)
|
||||
|
||||
|
||||
def next_inn():
|
||||
"""Валидный 10-значный ИНН, уникальный в пределах прогона тестов."""
|
||||
global _inn_seq
|
||||
_inn_seq += 1
|
||||
body = f'{_inn_seq:09d}'
|
||||
total = sum(int(body[i]) * _INN_COEFFS[i] for i in range(9))
|
||||
remainder = total % 11
|
||||
if remainder < 2:
|
||||
checksum = 0
|
||||
elif remainder >= 10:
|
||||
checksum = remainder - 10
|
||||
else:
|
||||
checksum = remainder
|
||||
return body + str(checksum)
|
||||
|
||||
|
||||
from core.test_utils import login_2fa
|
||||
@@ -87,6 +106,7 @@ class HiringServiceTests(TestCase):
|
||||
user_pharmacy_id=self.pharmacy.pk,
|
||||
hire_date=date.today() + timedelta(days=1),
|
||||
trial_days=3,
|
||||
inn_code=next_inn(),
|
||||
)
|
||||
defaults.update(extra)
|
||||
return defaults
|
||||
@@ -101,10 +121,32 @@ class HiringServiceTests(TestCase):
|
||||
user_location_id=self.office.pk,
|
||||
hire_date=date.today() + timedelta(days=1),
|
||||
trial_days=2,
|
||||
inn_code=next_inn(),
|
||||
)
|
||||
defaults.update(extra)
|
||||
return defaults
|
||||
|
||||
def test_inn_required(self):
|
||||
with self.assertRaises(ValidationError) as cm:
|
||||
create_hiring_request(**self._pharmacy_kwargs(inn_code=''))
|
||||
self.assertIn('inn_code', cm.exception.message_dict)
|
||||
|
||||
def test_inn_copied_to_employee_on_approve(self):
|
||||
inn = next_inn()
|
||||
hr = create_hiring_request(**self._pharmacy_kwargs(inn_code=inn))
|
||||
approved = approve_hiring_request(hr, decided_by=self.hr_user)
|
||||
self.assertEqual(approved.employee.inn_code, inn)
|
||||
|
||||
def test_duplicate_inn_on_trial_rejected(self):
|
||||
inn = next_inn()
|
||||
create_hiring_request(**self._pharmacy_kwargs(inn_code=inn))
|
||||
make_intern('Intern-2')
|
||||
with self.assertRaises(ValidationError) as cm:
|
||||
create_hiring_request(**self._pharmacy_kwargs(
|
||||
last_name='Другий', first_name='Кандидат', inn_code=inn,
|
||||
))
|
||||
self.assertIn('inn_code', cm.exception.message_dict)
|
||||
|
||||
def test_create_assigns_intern_and_ticket_without_employee(self):
|
||||
hire_date = date.today() + timedelta(days=1)
|
||||
hr = create_hiring_request(**self._pharmacy_kwargs(hire_date=hire_date))
|
||||
|
||||
@@ -83,6 +83,7 @@ class HRPortalITSeparationTests(TestCase):
|
||||
last_name='Тест',
|
||||
first_name='Кандидат',
|
||||
middle_name='',
|
||||
inn_code='0000000023',
|
||||
user_location_type='pharmacy',
|
||||
user_pharmacy_id=pharmacy.pk,
|
||||
hire_date=date(2026, 6, 1),
|
||||
|
||||
@@ -1242,6 +1242,7 @@ def hiring_create_view(request):
|
||||
middle_name=middle_name,
|
||||
candidate_email=request.POST.get('candidate_email', '').strip(),
|
||||
candidate_phone=request.POST.get('candidate_phone', '').strip(),
|
||||
inn_code=request.POST.get('inn_code', '').strip(),
|
||||
user_location_type=location_type,
|
||||
department_id=department_id,
|
||||
user_pharmacy_id=pharmacy_id,
|
||||
|
||||
@@ -356,6 +356,7 @@
|
||||
<tr style="border-bottom:1px solid var(--color-border); vertical-align:top;">
|
||||
<td style="padding:0.75rem 1rem;">
|
||||
<div style="font-weight:500; color:var(--color-card-foreground);">{{ hr.last_name }} {{ hr.first_name }}</div>
|
||||
{% if hr.inn_code %}<div style="font-size:0.75rem; color:var(--color-muted-foreground); margin-top:0.125rem;">{% trans "ИНН" %} {{ hr.inn_code }}</div>{% endif %}
|
||||
{% if hr.is_overdue %}
|
||||
<span class="badge badge-danger" style="margin-top:0.25rem;">{% trans "Просрочено" %}</span>
|
||||
{% else %}
|
||||
@@ -496,6 +497,13 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style="display:block; font-size:0.8125rem; font-weight:500; color:var(--color-foreground); margin-bottom:0.375rem;">{% trans "ИНН " %}<span style="color:var(--color-destructive);">*</span></label>
|
||||
<input type="text" name="inn_code" required inputmode="numeric" pattern="[0-9]{10}" maxlength="10" autocomplete="off"
|
||||
class="input" style="width:100%; max-width:16rem;" placeholder="0000000000">
|
||||
<p style="font-size:0.75rem; color:var(--color-muted-foreground); margin-top:0.25rem;">{% trans "10 цифр. Нужен фоторегистратору для учёта посещаемости." %}</p>
|
||||
</div>
|
||||
|
||||
<div style="display:grid; grid-template-columns:repeat(3,1fr); gap:0.75rem;">
|
||||
<div>
|
||||
<label style="display:block; font-size:0.8125rem; font-weight:500; color:var(--color-foreground); margin-bottom:0.375rem;">{% trans "Тип локации " %}<span style="color:var(--color-destructive);">*</span></label>
|
||||
@@ -623,6 +631,7 @@
|
||||
<tr style="border-bottom:1px solid var(--color-border); vertical-align:top;">
|
||||
<td style="padding:0.75rem 1rem;">
|
||||
<div style="font-weight:500; color:var(--color-card-foreground);">{{ hr.last_name }} {{ hr.first_name }}</div>
|
||||
{% if hr.inn_code %}<div style="font-size:0.75rem; color:var(--color-muted-foreground); margin-top:0.125rem;">{% trans "ИНН" %} {{ hr.inn_code }}</div>{% endif %}
|
||||
{% if hr.candidate_email or hr.candidate_phone %}
|
||||
<div style="font-size:0.75rem; color:var(--color-muted-foreground); margin-top:0.125rem;">{{ hr.candidate_email }} {{ hr.candidate_phone }}</div>
|
||||
{% endif %}
|
||||
|
||||
Reference in New Issue
Block a user