hiring: спрашивать ИНН в заявке на найм для фоторегистратора.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-12 17:29:31 +03:00
parent e3154f8118
commit 7b04ca7477
9 changed files with 105 additions and 3 deletions
+1
View File
@@ -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,
+1 -1
View File
@@ -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='ИНН',
),
),
]
+6 -1
View File
@@ -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
View File
@@ -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(),
+42
View File
@@ -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))
+1
View File
@@ -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),
+1
View File
@@ -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,
+9
View File
@@ -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 %}