diff --git a/apps/employees/admin.py b/apps/employees/admin.py
index ea88ed7..7c3e02f 100644
--- a/apps/employees/admin.py
+++ b/apps/employees/admin.py
@@ -141,7 +141,7 @@ class EmployeeAdmin(ModelAdmin):
}),
(_('Контакты'), {
'fields': (
- 'phone', 'personal_phone', 'personal_email',
+ 'phone', 'internal_number', 'personal_phone', 'personal_email',
'emergency_contact_name', 'emergency_contact_phone', 'emergency_contact_relation',
),
'classes': ('tab',),
diff --git a/apps/employees/management/__init__.py b/apps/employees/management/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/apps/employees/management/commands/__init__.py b/apps/employees/management/commands/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/apps/employees/management/commands/import_employees_json.py b/apps/employees/management/commands/import_employees_json.py
new file mode 100644
index 0000000..3d6f45e
--- /dev/null
+++ b/apps/employees/management/commands/import_employees_json.py
@@ -0,0 +1,220 @@
+"""Импорт сотрудников из employees.json (выгрузка из Excel-отчёта отдела
+кадров) с сопоставлением на существующие отделы (core.Directory,
+directory_type=department).
+
+Часть отделов из JSON не совпадает по названию с уже существующими узлами с
+тем же кодом (реорганизация/иначе пронумеровано в Excel) — решения по каждому
+конфликту согласованы с пользователем и зашиты в CONFLICT_NAME_TO_NEW_CODE /
+DEPT_73_NAME_TO_CODE / NEW_DEPARTMENTS ниже.
+"""
+import json
+import re
+from pathlib import Path
+
+from django.core.management.base import BaseCommand, CommandError
+from django.db import transaction
+
+from apps.employees.models.base import Employee
+from apps.locations.models.base import Location
+from core.models.directories import Directory, DirectoryType
+
+LOCATION_TYPE_MAP = {'офис': 'office', 'склад': 'warehouse'}
+ACTIVE_STATUSES = {'активен', 'принят', 'мобилизован', 'отпуск'}
+DATE_IN_COMMENT_RE = re.compile(r'(\d{2})\.(\d{2})\.(\d{4})')
+
+# Украинские и русские варианты одних и тех же имён отличаются буквами
+# (і/и, ї/и, є/е, ґ/г) — нормализуем перед сравнением, иначе "Бабич Віктор"
+# из JSON не находит уже существующего в БД "Бабич Виктор".
+UA_LOWER_TO_RU = {'і': 'и', 'ї': 'и', 'є': 'е', 'ґ': 'г'}
+
+
+def normalize_name(value):
+ value = (value or '').strip().lower()
+ return ''.join(UA_LOWER_TO_RU.get(c, c) for c in value)
+
+# Отделы, которых нет в БД: (код, код родителя, название). Коды не пересекаются
+# с существующими — свободны в своих ветках дерева.
+NEW_DEPARTMENTS = [
+ ('6.4', '6', 'Секція онлайн-консультацій'),
+ ('5.3.1', '5.3', 'Отдел обеспечения склада и аптек (Офис)'),
+ ('6.5', '6', 'Отдел digital маркетинга'),
+ ('6.6', '6', 'Отдел рекламы'),
+]
+
+# JSON-отдел с таким названием (код в JSON совпадает с ДРУГИМ отделом в БД) —
+# реально соответствует новому узлу с этим кодом.
+CONFLICT_NAME_TO_NEW_CODE = {
+ 'Отдел digital маркетинга': '6.5',
+ 'Отдел рекламы': '6.6',
+}
+
+# В JSON код 7.3 встречается дважды под разными названиями — это два разных
+# дочерних узла в БД (7.3.1 / 7.3.2), а не сам родитель 7.3.
+DEPT_73_NAME_TO_CODE = {
+ 'Офис официальных вопросов (СБ)': '7.3.2',
+ 'Офис официальных вопросов (Юристы)': '7.3.1',
+}
+
+
+def transliterate_username(last_name, first_name, taken):
+ from pytils.translit import slugify
+ base = slugify(f'{last_name}.{first_name}') or 'user'
+ username = base
+ counter = 1
+ while username in taken or Employee.objects.filter(username=username).exists():
+ counter += 1
+ username = f'{base}{counter}'
+ taken.add(username)
+ return username
+
+
+def parse_date_from_comment(comment):
+ if not comment:
+ return None
+ m = DATE_IN_COMMENT_RE.search(comment)
+ if not m:
+ return None
+ d, mo, y = m.groups()
+ return f'{y}-{mo}-{d}'
+
+
+class Command(BaseCommand):
+ help = 'Импорт сотрудников из employees.json с сопоставлением на существующие отделы'
+
+ def add_arguments(self, parser):
+ parser.add_argument('--file', default='employees.json', help='Путь к JSON-файлу')
+ parser.add_argument('--apply', action='store_true',
+ help='Записать изменения в БД. Без этого флага — только отчёт (dry-run)')
+
+ def handle(self, *args, **options):
+ path = Path(options['file'])
+ if not path.exists():
+ raise CommandError(f'Файл не найден: {path}')
+ data = json.loads(path.read_text(encoding='utf-8'))
+
+ dept_type = DirectoryType.objects.get(code='department')
+ post_type = DirectoryType.objects.get(code='post')
+
+ apply = options['apply']
+ with transaction.atomic():
+ office_location = Location.objects.filter(location_type='office').first()
+ if office_location is None:
+ raise CommandError('В БД нет ни одной Location(location_type=office)')
+
+ warehouse_location, created = Location.objects.get_or_create(
+ location_type='warehouse',
+ location_region=office_location.location_region,
+ defaults={
+ 'address': 'Склад (адрес уточняется)',
+ 'comments': 'Заглушка, создана автоматически при импорте employees.json — уточнить реальный адрес',
+ 'location': office_location.location,
+ },
+ )
+ if created:
+ self.stdout.write(self.style.WARNING(
+ f'Создана заглушка склада Location#{warehouse_location.pk} — уточните адрес в админке'
+ ))
+ location_by_type = {'office': office_location, 'warehouse': warehouse_location}
+
+ for code, parent_code, name in NEW_DEPARTMENTS:
+ parent = Directory.objects.get(directory_type=dept_type, code=parent_code)
+ dep, created = Directory.objects.get_or_create(
+ directory_type=dept_type, code=code,
+ defaults={'name': name, 'parent': parent},
+ )
+ if created:
+ self.stdout.write(self.style.WARNING(f'Создан отдел {code} «{name}»'))
+
+ dept_by_code = {
+ d.code: d for d in Directory.objects.filter(directory_type=dept_type)
+ }
+
+ created_count = 0
+ skipped = []
+ taken_usernames = set()
+ existing_keys = {
+ (normalize_name(last), normalize_name(first))
+ for last, first in Employee.objects.values_list('last_name', 'first_name')
+ }
+
+ for dept in data['departments']:
+ loc_key = LOCATION_TYPE_MAP.get(dept['location'])
+ if loc_key is None:
+ raise CommandError(f"Неизвестный тип локации отдела: {dept['location']!r}")
+
+ for emp in dept['employees']:
+ department = self._resolve_department(dept, emp, dept_by_code)
+ if department is None:
+ skipped.append((emp['full_name'], f"нет сопоставления отдела для кода {dept['code']}"))
+ continue
+
+ if not emp.get('hire_date_iso'):
+ skipped.append((emp['full_name'], 'нет даты приёма (hire_date)'))
+ continue
+
+ key = (normalize_name(emp['last_name']), normalize_name(emp['first_name']))
+ if key in existing_keys:
+ skipped.append((emp['full_name'], 'уже есть в БД (совпадение по фамилии+имени), пропущен'))
+ continue
+ existing_keys.add(key)
+
+ post, _ = Directory.objects.get_or_create(
+ directory_type=post_type, name=emp['position'].strip(),
+ defaults={'parent': None},
+ )
+
+ is_active = emp['status'] in ACTIVE_STATUSES
+ termination_date = None
+ if emp['status'] == 'уволен':
+ termination_date = parse_date_from_comment(emp.get('comment'))
+
+ username = transliterate_username(emp['last_name'], emp['first_name'], taken_usernames)
+
+ self.stdout.write(
+ f"{'[apply]' if apply else '[dry-run]'} {emp['full_name']} -> "
+ f"{department.code} «{department.name}», {post.name}, "
+ f"{loc_key}, active={is_active}"
+ )
+
+ if apply:
+ Employee.objects.create(
+ username=username,
+ last_name=emp['last_name'],
+ first_name=emp['first_name'],
+ middle_name=emp.get('middle_name') or '',
+ hire_date=emp['hire_date_iso'],
+ termination_date=termination_date,
+ is_active=is_active,
+ department=department,
+ post=post,
+ user_location_type=loc_key,
+ user_location=location_by_type[loc_key],
+ mattermost_sync_enabled=False,
+ ad_sync_enabled=False,
+ )
+ created_count += 1
+
+ if not apply:
+ transaction.set_rollback(True)
+
+ self.stdout.write(self.style.SUCCESS(
+ f"{'Импортировано' if apply else 'Будет импортировано (dry-run)'}: {created_count}, пропущено: {len(skipped)}"
+ ))
+ if skipped:
+ self.stdout.write(self.style.WARNING('Пропущены:'))
+ for name, reason in skipped:
+ self.stdout.write(f' - {name}: {reason}')
+ if not apply:
+ self.stdout.write(self.style.WARNING('Это был dry-run. Повторите с --apply, чтобы записать в БД.'))
+
+ def _resolve_department(self, dept, emp, dept_by_code):
+ name = dept['name']
+ code = dept['code']
+ if name in DEPT_73_NAME_TO_CODE:
+ return dept_by_code.get(DEPT_73_NAME_TO_CODE[name])
+ if name in CONFLICT_NAME_TO_NEW_CODE:
+ return dept_by_code.get(CONFLICT_NAME_TO_NEW_CODE[name])
+ if code == '7.2':
+ position = (emp.get('position') or '').lower()
+ return dept_by_code.get('7.2.1' if 'ревизор' in position else '7.2')
+ return dept_by_code.get(code)
diff --git a/apps/employees/migrations/0008_employee_internal_number.py b/apps/employees/migrations/0008_employee_internal_number.py
new file mode 100644
index 0000000..92a184c
--- /dev/null
+++ b/apps/employees/migrations/0008_employee_internal_number.py
@@ -0,0 +1,18 @@
+# Generated by Django 6.0.3 on 2026-07-09 10:03
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('employees', '0007_alter_employee_is_staff'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='employee',
+ name='internal_number',
+ field=models.CharField(blank=True, help_text='Короткий внутрішній (додатковий) номер АТС', max_length=10, verbose_name='Внутрішній номер'),
+ ),
+ ]
diff --git a/apps/employees/migrations/0009_employee_default_page_size.py b/apps/employees/migrations/0009_employee_default_page_size.py
new file mode 100644
index 0000000..38de14d
--- /dev/null
+++ b/apps/employees/migrations/0009_employee_default_page_size.py
@@ -0,0 +1,18 @@
+# Generated by Django 6.0.3 on 2026-07-12 21:04
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('employees', '0008_employee_internal_number'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='employee',
+ name='default_page_size',
+ field=models.PositiveSmallIntegerField(choices=[(10, '10'), (15, '15'), (25, '25'), (100, '100')], default=25, help_text='Сколько строк показывать в таблицах портала по умолчанию', verbose_name='Записей на странице'),
+ ),
+ ]
diff --git a/apps/employees/migrations/0010_employee_last_seen.py b/apps/employees/migrations/0010_employee_last_seen.py
new file mode 100644
index 0000000..9075513
--- /dev/null
+++ b/apps/employees/migrations/0010_employee_last_seen.py
@@ -0,0 +1,18 @@
+# Generated by Django 6.0.3 on 2026-07-12 21:22
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('employees', '0009_employee_default_page_size'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='employee',
+ name='last_seen',
+ field=models.DateTimeField(blank=True, help_text='Обновляется автоматически при активности в портале (не в AD/Mattermost)', null=True, verbose_name='Был онлайн'),
+ ),
+ ]
diff --git a/apps/employees/models/base.py b/apps/employees/models/base.py
index 8ded6f1..7b0956b 100644
--- a/apps/employees/models/base.py
+++ b/apps/employees/models/base.py
@@ -5,6 +5,7 @@ from django.utils.translation import gettext_lazy as _
from core.models import BaseModel
from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin, BaseUserManager
from core.utils.validators import validate_ukrainian_phone, validate_birth_date, validate_ukrainian_tax_number
+from core.utils.pagination import PAGE_SIZE_CHOICES, DEFAULT_PAGE_SIZE
class EmployeeManager(BaseUserManager):
def create_user(self, email=None, password=None, **extra_fields):
@@ -61,6 +62,10 @@ class Employee(AbstractBaseUser, PermissionsMixin, BaseModel):
_('Рабочий телефон'), max_length=20, blank=True,
validators=[validate_ukrainian_phone]
)
+ internal_number = models.CharField(
+ _('Внутрішній номер'), max_length=10, blank=True,
+ help_text=_('Короткий внутрішній (додатковий) номер АТС')
+ )
personal_phone = models.CharField(
_('Личный телефон'), max_length=20, blank=True,
validators=[validate_ukrainian_phone]
@@ -161,6 +166,17 @@ class Employee(AbstractBaseUser, PermissionsMixin, BaseModel):
ad_sync_enabled = models.BooleanField(_('Синхронизация AD'), default=False,
help_text=_('Только для офисных сотрудников'))
+ default_page_size = models.PositiveSmallIntegerField(
+ _('Записей на странице'), choices=[(n, str(n)) for n in PAGE_SIZE_CHOICES],
+ default=DEFAULT_PAGE_SIZE,
+ help_text=_('Сколько строк показывать в таблицах портала по умолчанию'),
+ )
+
+ last_seen = models.DateTimeField(
+ _('Был онлайн'), null=True, blank=True,
+ help_text=_('Обновляется автоматически при активности в портале (не в AD/Mattermost)'),
+ )
+
objects = EmployeeManager()
USERNAME_FIELD = 'username'
diff --git a/apps/employees/serializers.py b/apps/employees/serializers.py
index ee61542..cc664a5 100644
--- a/apps/employees/serializers.py
+++ b/apps/employees/serializers.py
@@ -22,7 +22,7 @@ class EmployeeDetailSerializer(EmployeeSerializer):
class Meta(EmployeeSerializer.Meta):
fields = EmployeeSerializer.Meta.fields + [
- 'phone', 'personal_phone', 'personal_email',
+ 'phone', 'internal_number', 'personal_phone', 'personal_email',
'birth_date', 'inn_code',
'emergency_contact_name', 'emergency_contact_phone', 'emergency_contact_relation',
'mattermost_username', 'mattermost_sync_enabled',
diff --git a/apps/hiring/__init__.py b/apps/hiring/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/apps/hiring/admin.py b/apps/hiring/admin.py
new file mode 100644
index 0000000..a01a92d
--- /dev/null
+++ b/apps/hiring/admin.py
@@ -0,0 +1,14 @@
+from django.contrib import admin
+from unfold.admin import ModelAdmin
+
+from apps.hiring.models import HiringRequest
+
+
+@admin.register(HiringRequest)
+class HiringRequestAdmin(ModelAdmin):
+ list_display = ['last_name', 'first_name', 'status', 'hire_date', 'trial_days', 'requested_by', 'created_at']
+ list_filter = ['status', 'user_location_type']
+ search_fields = ['last_name', 'first_name', 'middle_name', 'candidate_email']
+ list_select_related = ['requested_by', 'decision_by', 'employee', 'post', 'department']
+ filter_horizontal = ['requested_access']
+ ordering = ['-created_at']
diff --git a/apps/hiring/apps.py b/apps/hiring/apps.py
new file mode 100644
index 0000000..244060d
--- /dev/null
+++ b/apps/hiring/apps.py
@@ -0,0 +1,7 @@
+from django.apps import AppConfig
+
+
+class HiringConfig(AppConfig):
+ default_auto_field = 'django.db.models.BigAutoField'
+ name = 'apps.hiring'
+ verbose_name = 'Найм персоналу'
diff --git a/apps/hiring/migrations/0001_initial.py b/apps/hiring/migrations/0001_initial.py
new file mode 100644
index 0000000..11a8386
--- /dev/null
+++ b/apps/hiring/migrations/0001_initial.py
@@ -0,0 +1,59 @@
+# Generated by Django 6.0.3 on 2026-07-08 23:14
+
+import core.utils.validators
+import django.core.validators
+import django.db.models.deletion
+import django.utils.timezone
+import uuid
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ('core', '0002_connector_config'),
+ ('it_manager', '0006_equipment_brand_to_directory'),
+ ('locations', '0003_pharmacy_status'),
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='HiringRequest',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
+ ('created_at', models.DateTimeField(auto_now_add=True)),
+ ('updated_at', models.DateTimeField(auto_now=True)),
+ ('last_name', models.CharField(max_length=150, verbose_name='Прізвище')),
+ ('first_name', models.CharField(max_length=150, verbose_name="Ім'я")),
+ ('middle_name', models.CharField(blank=True, max_length=150, verbose_name='По батькові')),
+ ('candidate_email', models.EmailField(blank=True, max_length=254, verbose_name='Особистий email кандидата')),
+ ('candidate_phone', models.CharField(blank=True, max_length=20, validators=[core.utils.validators.validate_ukrainian_phone], verbose_name='Телефон кандидата')),
+ ('user_location_type', models.CharField(choices=[('pharmacy', 'Аптека'), ('warehouse', 'Склад'), ('office', 'Офіс')], max_length=20, verbose_name='Тип локації')),
+ ('hire_date', models.DateField(default=django.utils.timezone.localdate, verbose_name='Дата виходу')),
+ ('trial_days', models.PositiveSmallIntegerField(choices=[(1, '1 дн.'), (2, '2 дн.'), (3, '3 дн.'), (4, '4 дн.'), (5, '5 дн.')], default=3, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(5)], verbose_name='Термін випробування, днів')),
+ ('access_note', models.TextField(blank=True, verbose_name='Примітка щодо доступів')),
+ ('status', models.CharField(choices=[('trial', 'На випробуванні'), ('approved', 'Затверджено'), ('rejected', 'Відхилено')], default='trial', max_length=10, verbose_name='Статус')),
+ ('decision_at', models.DateTimeField(blank=True, null=True, verbose_name='Дата рішення')),
+ ('decision_note', models.TextField(blank=True, verbose_name='Коментар до рішення')),
+ ('decision_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='hiring_requests_decided', to=settings.AUTH_USER_MODEL, verbose_name='Рішення прийняв')),
+ ('department', models.ForeignKey(blank=True, limit_choices_to={'directory_type__code': 'department', 'is_active': True}, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='hiring_requests_department', to='core.directory', verbose_name='Відділ')),
+ ('employee', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='hiring_request', to=settings.AUTH_USER_MODEL, verbose_name='Обліковий запис')),
+ ('granted_ticket', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='hiring_requests', to='it_manager.ticket', verbose_name='Заявка в IT')),
+ ('post', models.ForeignKey(blank=True, limit_choices_to={'directory_type__code': 'post', 'is_active': True}, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='hiring_requests_post', to='core.directory', verbose_name='Посада')),
+ ('requested_access', models.ManyToManyField(blank=True, limit_choices_to={'directory_type__code': 'access_system', 'is_active': True}, related_name='hiring_requests_access', to='core.directory', verbose_name='Потрібні доступи')),
+ ('requested_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='hiring_requests_submitted', to=settings.AUTH_USER_MODEL, verbose_name='Подав HR')),
+ ('user_pharmacy', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='hiring_requests', to='locations.pharmacy', verbose_name='Аптека')),
+ ],
+ options={
+ 'verbose_name': 'Заявка на найм',
+ 'verbose_name_plural': 'Заявки на найм',
+ 'ordering': ['-created_at'],
+ 'indexes': [models.Index(fields=['status'], name='hiring_hiri_status_3e18a8_idx')],
+ },
+ ),
+ ]
diff --git a/apps/hiring/migrations/0002_seed_access_systems.py b/apps/hiring/migrations/0002_seed_access_systems.py
new file mode 100644
index 0000000..6185d3e
--- /dev/null
+++ b/apps/hiring/migrations/0002_seed_access_systems.py
@@ -0,0 +1,42 @@
+from django.db import migrations
+from pytils.translit import slugify
+
+ACCESS_SYSTEMS = [
+ '1С: Бухгалтерія',
+ '1С: Управління торгівлею',
+ '1С: ЗУП',
+ 'Пошта',
+ 'Mattermost',
+ 'CRM',
+]
+
+
+def forwards(apps, schema_editor):
+ DirectoryType = apps.get_model('core', 'DirectoryType')
+ Directory = apps.get_model('core', 'Directory')
+
+ directory_type, _ = DirectoryType.objects.get_or_create(
+ code='access_system',
+ defaults={'name': 'Система доступу', 'hierarchy_type': 'strict'},
+ )
+ for name in ACCESS_SYSTEMS:
+ Directory.objects.get_or_create(
+ directory_type=directory_type, name=name,
+ defaults={'is_active': True, 'code': slugify(name)},
+ )
+
+
+def backwards(apps, schema_editor):
+ DirectoryType = apps.get_model('core', 'DirectoryType')
+ DirectoryType.objects.filter(code='access_system').delete()
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('hiring', '0001_initial'),
+ ]
+
+ operations = [
+ migrations.RunPython(forwards, backwards),
+ ]
diff --git a/apps/hiring/migrations/__init__.py b/apps/hiring/migrations/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/apps/hiring/models.py b/apps/hiring/models.py
new file mode 100644
index 0000000..4c2c37d
--- /dev/null
+++ b/apps/hiring/models.py
@@ -0,0 +1,114 @@
+from datetime import timedelta
+
+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 core.models import BaseModel
+from core.utils.validators import validate_ukrainian_phone
+
+
+class HiringRequest(BaseModel):
+ """Заявка HR на найм кандидата: створення тимчасового (стажерського)
+ облікового запису та подальше затвердження/відхилення."""
+
+ 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],
+ )
+
+ 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=_('Аптека'),
+ )
+ 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)
+ 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'),
+ )
+
+ 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()
diff --git a/apps/hiring/services.py b/apps/hiring/services.py
new file mode 100644
index 0000000..fb7a2ef
--- /dev/null
+++ b/apps/hiring/services.py
@@ -0,0 +1,134 @@
+"""Бізнес-логіка заявок на найм: створення тимчасового акаунта стажера та
+подальше затвердження/відхилення. Використовує вже робочу AD/Mattermost
+синхронізацію через сигнали Employee.save() — жодного нового коду для
+зовнішніх систем тут немає."""
+from django.db import transaction
+from django.utils import timezone
+from pytils.translit import slugify
+
+from apps.employees.models.base import Employee
+from apps.hiring.models import HiringRequest
+from apps.it_manager.models import Ticket
+
+
+def _generate_username(last_name, first_name):
+ base = slugify(f'{last_name}.{first_name}') or 'user'
+ username = base
+ counter = 1
+ while Employee.objects.filter(username=username).exists():
+ counter += 1
+ username = f'{base}{counter}'
+ return username
+
+
+@transaction.atomic
+def create_hiring_request(*, requester, last_name, first_name, middle_name='',
+ candidate_email='', candidate_phone='',
+ user_location_type, department_id=None, user_pharmacy_id=None, post_id=None,
+ hire_date, trial_days, requested_access_ids=None, access_note=''):
+ """Створює одразу особистий обмежений обліковий запис (Employee) і
+ заявку (HiringRequest) на випробувальний термін."""
+ username = _generate_username(last_name, first_name)
+
+ employee = Employee.objects.create(
+ username=username,
+ last_name=last_name,
+ first_name=first_name,
+ middle_name=middle_name,
+ personal_phone=candidate_phone,
+ is_active=True,
+ hire_date=hire_date,
+ probation_end_date=hire_date + timezone.timedelta(days=trial_days),
+ user_location_type=user_location_type,
+ department_id=department_id,
+ user_pharmacy_id=user_pharmacy_id,
+ post_id=post_id,
+ # AD-синхронізація стосується лише офісних співробітників — це вже
+ # чинне правило в apps/ad_connector/signals.py (_is_office), тут лише
+ # вмикаємо прапорець, без якого сигнал взагалі не спрацює.
+ ad_sync_enabled=(user_location_type == 'office'),
+ )
+
+ 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,
+ user_location_type=user_location_type,
+ department_id=department_id,
+ user_pharmacy_id=user_pharmacy_id,
+ post_id=post_id,
+ hire_date=hire_date,
+ trial_days=trial_days,
+ access_note=access_note,
+ status='trial',
+ requested_by=requester,
+ employee=employee,
+ )
+ if requested_access_ids:
+ hiring_request.requested_access.set(requested_access_ids)
+
+ return hiring_request
+
+
+@transaction.atomic
+def approve_hiring_request(hiring_request, decided_by, note=''):
+ if hiring_request.status != 'trial':
+ return hiring_request
+
+ employee = hiring_request.employee
+ if employee is not None:
+ employee.probation_end_date = timezone.localdate()
+ employee.save(update_fields=['probation_end_date'])
+
+ access_names = list(hiring_request.requested_access.values_list('name', flat=True))
+ description_lines = [
+ f'Новий співробітник: {hiring_request.last_name} {hiring_request.first_name}',
+ ]
+ if hiring_request.post_id:
+ description_lines.append(f'Посада: {hiring_request.post.name}')
+ if access_names:
+ description_lines.append('Потрібні доступи: ' + ', '.join(access_names))
+ if hiring_request.access_note:
+ description_lines.append(f'Примітка: {hiring_request.access_note}')
+
+ from core.models.directories import Directory
+ ticket = Ticket.objects.create(
+ title=f'Надати доступи: {hiring_request.last_name} {hiring_request.first_name}',
+ description='\n'.join(description_lines),
+ ticket_type_id=Directory.objects.get(directory_type__code='ticket_type', code='access').id,
+ priority_id=Directory.objects.get(directory_type__code='ticket_priority', code='medium').id,
+ requester=decided_by,
+ )
+
+ hiring_request.status = 'approved'
+ hiring_request.decision_by = decided_by
+ hiring_request.decision_at = timezone.now()
+ hiring_request.decision_note = note
+ hiring_request.granted_ticket = ticket
+ hiring_request.save(update_fields=[
+ 'status', 'decision_by', 'decision_at', 'decision_note', 'granted_ticket', 'updated_at',
+ ])
+ return hiring_request
+
+
+@transaction.atomic
+def reject_hiring_request(hiring_request, decided_by, note=''):
+ if hiring_request.status != 'trial':
+ return hiring_request
+
+ employee = hiring_request.employee
+ if employee is not None:
+ employee.is_active = False
+ employee.termination_date = timezone.localdate()
+ employee.save(update_fields=['is_active', 'termination_date'])
+
+ hiring_request.status = 'rejected'
+ hiring_request.decision_by = decided_by
+ hiring_request.decision_at = timezone.now()
+ hiring_request.decision_note = note
+ hiring_request.save(update_fields=[
+ 'status', 'decision_by', 'decision_at', 'decision_note', 'updated_at',
+ ])
+ return hiring_request
diff --git a/apps/hiring/tests.py b/apps/hiring/tests.py
new file mode 100644
index 0000000..7b34def
--- /dev/null
+++ b/apps/hiring/tests.py
@@ -0,0 +1,135 @@
+"""Тести бізнес-логіки заявок на найм: створення тимчасового акаунта,
+затвердження (заявка в IT) і відхилення (деактивація)."""
+from datetime import date, timedelta
+
+from django.contrib.auth import get_user_model
+from django.test import TestCase
+
+from apps.hiring.models import HiringRequest
+from apps.hiring.services import approve_hiring_request, create_hiring_request, reject_hiring_request
+from apps.it_manager.models import Ticket
+
+Employee = get_user_model()
+
+_employee_seq = 0
+
+
+def make_employee(**overrides):
+ global _employee_seq
+ _employee_seq += 1
+ defaults = {
+ 'username': f'hiring.hr{_employee_seq}',
+ 'email': f'hiring.hr{_employee_seq}@company.local',
+ 'first_name': 'Ганна',
+ 'last_name': 'HR-Тестова',
+ 'hire_date': date(2026, 1, 1),
+ 'user_location_type': 'office',
+ 'is_staff': True,
+ 'mattermost_sync_enabled': False,
+ 'ad_sync_enabled': False,
+ }
+ defaults.update(overrides)
+ return Employee.objects.create(**defaults)
+
+
+class HiringServiceTests(TestCase):
+
+ def setUp(self):
+ self.hr_user = make_employee()
+
+ def test_create_hiring_request_creates_trial_employee(self):
+ hire_date = date.today() + timedelta(days=1)
+ hr = create_hiring_request(
+ requester=self.hr_user,
+ last_name='Кандидат',
+ first_name='Іван',
+ user_location_type='pharmacy',
+ hire_date=hire_date,
+ trial_days=3,
+ )
+
+ self.assertEqual(hr.status, 'trial')
+ self.assertIsNotNone(hr.employee)
+ self.assertTrue(hr.employee.is_active)
+ self.assertEqual(hr.employee.probation_end_date, hire_date + timedelta(days=3))
+ self.assertEqual(hr.trial_end_date, hire_date + timedelta(days=3))
+ self.assertFalse(hr.is_overdue)
+
+ def test_ad_sync_enabled_only_for_office_employees(self):
+ office_hr = create_hiring_request(
+ requester=self.hr_user, last_name='Офісний', first_name='Кандидат',
+ user_location_type='office', hire_date=date.today() + timedelta(days=1), trial_days=2,
+ )
+ pharmacy_hr = create_hiring_request(
+ requester=self.hr_user, last_name='Аптечний', first_name='Кандидат',
+ user_location_type='pharmacy', hire_date=date.today() + timedelta(days=1), trial_days=2,
+ )
+
+ self.assertTrue(office_hr.employee.ad_sync_enabled)
+ self.assertFalse(pharmacy_hr.employee.ad_sync_enabled)
+
+ def test_is_overdue_when_trial_period_passed(self):
+ hr = create_hiring_request(
+ requester=self.hr_user, last_name='Прострочений', first_name='Тест',
+ user_location_type='pharmacy', hire_date=date(2026, 1, 1), trial_days=1,
+ )
+ self.assertTrue(hr.is_overdue)
+
+ def test_create_hiring_request_generates_unique_username(self):
+ hr1 = create_hiring_request(
+ requester=self.hr_user, last_name='Дубль', first_name='Петро',
+ user_location_type='pharmacy', hire_date=date(2026, 6, 1), trial_days=1,
+ )
+ hr2 = create_hiring_request(
+ requester=self.hr_user, last_name='Дубль', first_name='Петро',
+ user_location_type='pharmacy', hire_date=date(2026, 6, 1), trial_days=1,
+ )
+
+ self.assertNotEqual(hr1.employee.username, hr2.employee.username)
+
+ def test_approve_creates_it_ticket_and_ends_probation(self):
+ hr = create_hiring_request(
+ requester=self.hr_user, last_name='Кандидат', first_name='Марія',
+ user_location_type='pharmacy', hire_date=date(2026, 6, 1), trial_days=5,
+ )
+ access = hr._meta.get_field('requested_access').related_model.objects.filter(
+ directory_type__code='access_system',
+ )[:2]
+ hr.requested_access.set(access)
+
+ approved = approve_hiring_request(hr, decided_by=self.hr_user, note='ok')
+
+ self.assertEqual(approved.status, 'approved')
+ self.assertEqual(approved.decision_by, self.hr_user)
+ self.assertIsNotNone(approved.granted_ticket)
+ self.assertEqual(approved.granted_ticket.ticket_type.code, 'access')
+ self.assertEqual(Ticket.objects.count(), 1)
+ approved.employee.refresh_from_db()
+ self.assertEqual(approved.employee.probation_end_date, date.today())
+
+ def test_reject_deactivates_employee(self):
+ hr = create_hiring_request(
+ requester=self.hr_user, last_name='Кандидат', first_name='Олег',
+ user_location_type='pharmacy', hire_date=date(2026, 6, 1), trial_days=2,
+ )
+
+ rejected = reject_hiring_request(hr, decided_by=self.hr_user, note='не пройшов')
+
+ self.assertEqual(rejected.status, 'rejected')
+ rejected.employee.refresh_from_db()
+ self.assertFalse(rejected.employee.is_active)
+ self.assertEqual(rejected.employee.termination_date, date.today())
+
+ def test_decision_is_a_noop_once_already_decided(self):
+ hr = create_hiring_request(
+ requester=self.hr_user, last_name='Кандидат', first_name='Ліда',
+ user_location_type='pharmacy', hire_date=date(2026, 6, 1), trial_days=2,
+ )
+ approve_hiring_request(hr, decided_by=self.hr_user)
+ ticket_count = Ticket.objects.count()
+
+ reject_hiring_request(hr, decided_by=self.hr_user)
+ hr.refresh_from_db()
+
+ self.assertEqual(hr.status, 'approved')
+ self.assertEqual(Ticket.objects.count(), ticket_count)
diff --git a/apps/locations/admin.py b/apps/locations/admin.py
index f4afaab..919843d 100644
--- a/apps/locations/admin.py
+++ b/apps/locations/admin.py
@@ -1,6 +1,8 @@
from django import forms
from django.contrib import admin
+from django.db import models
from django.utils.html import format_html
+from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _
from django.utils import timezone
from unfold.admin import ModelAdmin, TabularInline
@@ -32,6 +34,27 @@ class HierarchicalRegionChoiceField(forms.ModelChoiceField):
return f'{indent}{obj.name}'
+class HasCoordinatesFilter(admin.SimpleListFilter):
+ """Записи, які так і лишились на дефолтних координатах поля (центр Одеси) —
+ тобто піну ніхто не ставив і геокодування по адресі не спрацювало."""
+ title = _('Координаты на карте')
+ parameter_name = 'has_coordinates'
+
+ def lookups(self, request, model_admin):
+ return [
+ ('no', _('Без координат')),
+ ('yes', _('С координатами')),
+ ]
+
+ def queryset(self, request, queryset):
+ default = queryset.model._meta.get_field('location').default
+ if self.value() == 'no':
+ return queryset.filter(models.Q(location=default) | models.Q(location=''))
+ if self.value() == 'yes':
+ return queryset.exclude(location=default).exclude(location='')
+ return queryset
+
+
# ---------------------------------------------------------------------------
# Location
# ---------------------------------------------------------------------------
@@ -56,15 +79,18 @@ class LocationAdmin(ModelAdmin, ImportExportModelAdmin):
export_form_class = ExportForm
class Media:
css = {'all': ('locations/css/location_admin.css',)}
+ js = ('assets/js/admin_leaflet_resize_fix.js',)
list_display = ['address_display', 'location_type', 'location_region',
'coordinates_display', 'is_active', 'updated_at']
- list_filter = ['location_type', 'is_active']
+ list_filter = ['location_type', 'is_active', HasCoordinatesFilter]
search_fields = ['address', 'street', 'house_number', 'comments']
list_select_related = ['location_region']
ordering = ['location_region', 'address']
- readonly_fields = ['created_at', 'updated_at']
+ # address тепер лише генерується в Location.save() з street/house_number/building/
+ # apartment — ручний ввід сюди все одно перезатреться при збереженні
+ readonly_fields = ['address', 'created_at', 'updated_at']
fieldsets = (
(_('Тип и регион'), {
@@ -109,7 +135,7 @@ class LocationAdmin(ModelAdmin, ImportExportModelAdmin):
def coordinates_display(self, obj):
if obj.has_coordinates and obj.latitude is not None and obj.longitude is not None:
return format_html('📍 {}', f'{float(obj.latitude):.5f}, {float(obj.longitude):.5f}')
- return '—'
+ return mark_safe('⚠ немає координат')
coordinates_display.short_description = _('Координаты')
coordinates_display.admin_order_field = 'location'
@@ -199,7 +225,7 @@ class PharmacyAdmin(ModelAdmin, ImportExportModelAdmin):
class Media:
css = {'all': ('locations/css/location_admin.css',)}
- js = ('assets/js/admin_phone_format.js',)
+ js = ('assets/js/admin_phone_format.js', 'assets/js/admin_leaflet_resize_fix.js')
# ------------------------------------------------------------------
# List view
@@ -208,36 +234,45 @@ class PharmacyAdmin(ModelAdmin, ImportExportModelAdmin):
list_display = [
'pharmacy_display',
'organization',
+ 'category',
+ 'vat_category',
'location_region',
+ 'coordinates_display',
'phone_numbers_display',
- 'status_display',
+ 'status',
'license_status_display',
'head_pharmacist',
+ 'responsible_manager',
+ 'curator',
'employees_count_display',
'working_hours',
'is_active',
]
- list_editable = ['working_hours', 'is_active']
- list_filter = ['organization', 'status', 'is_active', LicenseStatusFilter]
+ list_editable = ['category', 'vat_category', 'status', 'working_hours']
+ list_filter = ['organization', 'category', 'vat_category', 'status', 'is_active', LicenseStatusFilter, HasCoordinatesFilter]
search_fields = ['pharmacy_number', 'address', 'phone', 'license_number',
- 'head_pharmacist__last_name']
- list_select_related = ['organization', 'location_region', 'head_pharmacist']
+ 'head_pharmacist__last_name', 'responsible_manager__last_name', 'curator__last_name']
+ list_select_related = ['organization', 'location_region', 'head_pharmacist', 'responsible_manager', 'curator']
ordering = ['organization', 'pharmacy_number']
# ------------------------------------------------------------------
# Detail view — tabs
# ------------------------------------------------------------------
+ # address тепер лише генерується в Location.save() з street/house_number/building/
+ # apartment — ручний ввід сюди все одно перезатреться при збереженні
+ # is_active тепер похідне поле від status (Pharmacy.save()) — ручне
+ # редагування нічого не дасть, тому лише readonly-показ.
readonly_fields = [
- 'employees_count_display', 'license_status_display',
- 'created_at', 'updated_at',
+ 'address', 'employees_count_display', 'license_status_display',
+ 'is_active', 'created_at', 'updated_at',
]
fieldsets = (
(_('Основное'), {
'classes': ('tab',),
'fields': (
- 'organization', 'pharmacy_number',
+ 'organization', 'pharmacy_number', 'category', 'vat_category',
'status', 'is_active', 'working_hours',
'location_type',
),
@@ -265,8 +300,8 @@ class PharmacyAdmin(ModelAdmin, ImportExportModelAdmin):
(_('Персонал'), {
'classes': ('tab',),
'fields': (
- 'head_pharmacist', 'pharmacists_count',
- 'employees_count_display',
+ 'head_pharmacist', 'responsible_manager', 'curator',
+ 'pharmacists_count', 'employees_count_display',
),
}),
(_('Примечания'), {
@@ -300,21 +335,12 @@ class PharmacyAdmin(ModelAdmin, ImportExportModelAdmin):
pharmacy_display.short_description = _('Аптека')
pharmacy_display.admin_order_field = 'pharmacy_number'
- STATUS_COLORS = {
- 'open': '#16a34a',
- 'closed': '#dc2626',
- 'suspended': '#ca8a04',
- 'preparing': '#2563eb',
- }
-
- def status_display(self, obj):
- color = self.STATUS_COLORS.get(obj.status, '#6b7280')
- return format_html(
- '{}',
- color, obj.get_status_display(),
- )
- status_display.short_description = _('Статус')
- status_display.admin_order_field = 'status'
+ def coordinates_display(self, obj):
+ if obj.has_coordinates and obj.latitude is not None and obj.longitude is not None:
+ return format_html('📍 {}', f'{float(obj.latitude):.5f}, {float(obj.longitude):.5f}')
+ return mark_safe('⚠ немає координат')
+ coordinates_display.short_description = _('Координаты')
+ coordinates_display.admin_order_field = 'location'
def license_status_display(self, obj):
status = obj.license_status
diff --git a/apps/locations/management/__init__.py b/apps/locations/management/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/apps/locations/management/commands/__init__.py b/apps/locations/management/commands/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/apps/locations/management/commands/import_closed_pharmacy_branches.py b/apps/locations/management/commands/import_closed_pharmacy_branches.py
new file mode 100644
index 0000000..1451b23
--- /dev/null
+++ b/apps/locations/management/commands/import_closed_pharmacy_branches.py
@@ -0,0 +1,278 @@
+"""Импорт закрытых филиалов (категория 'F' в исходной POS-выгрузке,
+tableConvert.com_ppy9h9.json) — та же схема столбцов, что и в основном
+файле филиалов (import_pharmacy_branches), но все 28 записей отмечены как
+закрытые. 'F' — не бизнес-категория A/B/C/D, а служебная метка "закрыто" в
+исходной системе, поэтому Pharmacy.category здесь не заполняется, а вместо
+неё используется status='closed'.
+
+№ A 4 (ПП АКС) совпадает с уже существующей в БД аптекой #4 (Семена Палія,
+118) — она была в БД, но отсутствовала в первом файле филиалов именно
+потому, что уже закрыта; здесь она обновляется, а не создаётся заново.
+"""
+import json
+import re
+from pathlib import Path
+
+from django.core.management.base import BaseCommand, CommandError
+from django.db import transaction
+
+from apps.employees.models.base import Employee
+from apps.locations.models.pharmacies import Pharmacy
+from core.models.directories import Directory, DirectoryType
+
+ORG_FILE_NAME = 'Название фирмы\n(бухгалтерия)'
+
+EXISTING_ORG_NAME = {
+ 'ПП "АКС"': 'ПП АКС',
+ 'ТОВ "АКС-ОДЕСА"': 'ТОВ "АКС ОДЕССА"',
+ 'ПП "ТІРА"': 'ТИРА',
+ 'ТОВ "ПЕНТАЛЬФА"': 'Пентальфа',
+ 'ТОВ "ЛІКИ ОДЕЩИНИ"': 'Ліки Одещини',
+ 'ППФ "ГІППОКРАТ"': 'ППФ "ГІППОКРАТ"',
+}
+NEW_ORGS = [
+ ('fop_shvets', 'ФОП Швець Станіслав Сергійович'),
+ ('unknown_org', 'Не визначено'),
+]
+NEW_ORG_CODE = {
+ 'ФОП Швець Станіслав Сергійович': 'fop_shvets',
+ 'ФОП <НЕ ОПРЕДЕЛЕНО>': 'unknown_org',
+}
+
+EXISTING_CITY_CODE = {
+ 'Одесса': 'odessa',
+ 'Измаил': 'Izmaill',
+ 'Котовск': 'podilsk',
+}
+NEW_CITIES = [
+ # (код, название, код родителя-района)
+ ('veseloe', 'Веселе', 'bolgradsky'),
+ ('tuzly', 'Тузли', 'bilgorod-dnistrovsky'),
+]
+NEW_CITY_CODE = {
+ 'Веселое': 'veseloe',
+ 'Тузлы': 'tuzly',
+}
+
+MANAGER_EMPLOYEE_ID = {
+ 'Плотникова И.В.': 120,
+}
+MISSING_MANAGER_NAME = 'Савельева С.В.'
+CURATOR_EMPLOYEE_ID = {
+ 'Филипьев Д.Г.': 94,
+}
+
+
+def parse_number(raw):
+ m = re.search(r'(\d+)', raw)
+ if not m:
+ raise CommandError(f'Не удалось извлечь номер из "№": {raw!r}')
+ return int(m.group(1))
+
+
+def parse_ukr_address(ukr):
+ if not ukr:
+ return '', '', '', ''
+ parts = [p.strip() for p in ukr.split(',')]
+ parts = parts[1:]
+ house, building, apartment = '', '', ''
+ rest = []
+ for p in parts:
+ m = re.match(r'буд\.?\s*(.+)', p, re.I)
+ if m:
+ house = m.group(1).strip()
+ continue
+ m = re.match(r'кор\.?\s*(.+)', p, re.I)
+ if m:
+ building = m.group(1).strip()
+ continue
+ m = re.match(r'прим\.?\s*(.+)', p, re.I)
+ if m:
+ apartment = m.group(1).strip()
+ continue
+ rest.append(p)
+ if not house and rest:
+ for i in range(len(rest) - 1, -1, -1):
+ if re.search(r'\d', rest[i]):
+ house = rest[i]
+ street = ' '.join(rest[:i] + rest[i + 1:])
+ break
+ else:
+ street = ' '.join(rest)
+ else:
+ street = ' '.join(rest)
+ street = re.sub(r'^вул\.?\s*', '', street, flags=re.I).strip()
+ # хвостовое пояснение в скобках прямо в номере дома без запятой
+ # ("23(Дитяча поліклініка)") — не адрес, переносим в apartment
+ m = re.match(r'^([^\(\)]*?)\s*\(([^()]+)\)\s*$', house)
+ if m:
+ house = m.group(1).strip()
+ if not apartment:
+ apartment = m.group(2).strip()
+ house = house[:20]
+ apartment = apartment[:20]
+ building = building[:20]
+ return street, house, building, apartment
+
+
+class Command(BaseCommand):
+ help = 'Импорт закрытых филиалов (категория F) из tableConvert.com_ppy9h9.json'
+
+ def add_arguments(self, parser):
+ parser.add_argument(
+ '--file',
+ default=str(Path.home() / 'Загрузки' / 'tableConvert.com_ppy9h9.json'),
+ help='Путь к JSON-файлу выгрузки',
+ )
+ parser.add_argument('--apply', action='store_true',
+ help='Записать изменения в БД. Без этого флага — только отчёт (dry-run)')
+
+ def handle(self, *args, **options):
+ path = Path(options['file'])
+ if not path.exists():
+ raise CommandError(f'Файл не найден: {path}')
+ rows = json.loads(path.read_text(encoding='utf-8'))
+ apply = options['apply']
+
+ updated, created_pharm = [], []
+
+ with transaction.atomic():
+ org_type = DirectoryType.objects.get(code='organization')
+ city_type = DirectoryType.objects.get(code='city')
+ post_type = DirectoryType.objects.get(code='post')
+
+ for code, name in NEW_ORGS:
+ org, was_created = Directory.objects.get_or_create(
+ directory_type=org_type, code=code, defaults={'name': name, 'is_active': True},
+ )
+ if was_created:
+ self.stdout.write(self.style.WARNING(f'Создана организация «{name}» ({code})'))
+
+ org_by_name = {d.name: d for d in Directory.objects.filter(directory_type=org_type)}
+ org_by_code = {d.code: d for d in Directory.objects.filter(directory_type=org_type)}
+ org_by_key = dict(org_by_name)
+ for code, _name in NEW_ORGS:
+ org_by_key[code] = org_by_code[code]
+
+ district_by_code = {
+ d.code: d for d in Directory.objects.filter(directory_type__code__in=['district', 'region'])
+ }
+ for code, name, parent_code in NEW_CITIES:
+ parent = district_by_code.get(parent_code)
+ if parent is None:
+ raise CommandError(f'Родитель-район с кодом {parent_code!r} не найден')
+ city, was_created = Directory.objects.get_or_create(
+ directory_type=city_type, code=code, defaults={'name': name, 'parent': parent, 'is_active': True},
+ )
+ if was_created:
+ self.stdout.write(self.style.WARNING(f'Создан город «{name}» под «{parent.name}»'))
+
+ city_by_code = {d.code: d for d in Directory.objects.filter(directory_type=city_type)}
+ city_lookup = {}
+ for file_name, code in EXISTING_CITY_CODE.items():
+ city_lookup[file_name] = city_by_code[code]
+ for file_name, code in NEW_CITY_CODE.items():
+ city_lookup[file_name] = city_by_code[code]
+
+ responsible_manager_post = Directory.objects.get(directory_type=post_type, code='responsible_manager')
+
+ missing_manager = Employee.objects.filter(last_name='Савельева', first_name='С.', middle_name='В.').first()
+ if missing_manager is None:
+ from pytils.translit import slugify
+ username = slugify('Савельева.С') or 'savelyeva'
+ if Employee.objects.filter(username=username).exists():
+ username = f'{username}2'
+ self.stdout.write(
+ f"{'[apply]' if apply else '[dry-run]'} создание сотрудника «Савельева С.В.» "
+ f"(responsible_manager), ФИО в источнике дано только инициалами"
+ )
+ import datetime
+ missing_manager = Employee(
+ username=username,
+ last_name='Савельева', first_name='С.', middle_name='В.',
+ post=responsible_manager_post,
+ hire_date=datetime.date.today(),
+ mattermost_sync_enabled=False,
+ ad_sync_enabled=False,
+ )
+ missing_manager.set_unusable_password()
+ missing_manager.save()
+
+ manager_lookup = dict(MANAGER_EMPLOYEE_ID)
+ manager_lookup[MISSING_MANAGER_NAME] = missing_manager.pk
+ curator_lookup = dict(CURATOR_EMPLOYEE_ID)
+
+ for r in rows:
+ org_name = (r.get(ORG_FILE_NAME) or '').strip()
+ if not org_name:
+ continue
+ num = parse_number(r['№'])
+ manager_name = (r.get('Менеджер') or '').strip()
+ curator_name = (r.get('Куратор') or '').strip()
+ manager_id = manager_lookup.get(manager_name)
+ curator_id = curator_lookup.get(curator_name)
+
+ target_org_key = EXISTING_ORG_NAME.get(org_name) or NEW_ORG_CODE.get(org_name)
+ if target_org_key is None:
+ raise CommandError(f'Неизвестная организация в файле: {org_name!r}')
+ target_org = org_by_key[target_org_key]
+
+ existing = Pharmacy.objects.filter(organization=target_org, pharmacy_number=num).first()
+
+ if existing is not None:
+ changes = []
+ if existing.status != 'closed':
+ changes.append(f'status: {existing.status!r} -> closed')
+ existing.status = 'closed'
+ if manager_id and existing.responsible_manager_id != manager_id:
+ changes.append(f'responsible_manager -> {manager_name}')
+ existing.responsible_manager_id = manager_id
+ if curator_id and existing.curator_id != curator_id:
+ changes.append(f'curator -> {curator_name}')
+ existing.curator_id = curator_id
+ if changes:
+ self.stdout.write(
+ f"{'[apply]' if apply else '[dry-run]'} UPDATE #{existing.pharmacy_number} "
+ f"({target_org_key}): {'; '.join(changes)}"
+ )
+ if apply:
+ existing.save()
+ updated.append(existing.pharmacy_number)
+ continue
+
+ city = city_lookup.get(r.get('Город', '').strip())
+ if city is None:
+ raise CommandError(f"Неизвестный город в файле: {r.get('Город')!r} (№{r['№']})")
+ street, house, building, apartment = parse_ukr_address(r.get('Адрес (укр.)', ''))
+
+ self.stdout.write(
+ f"{'[apply]' if apply else '[dry-run]'} CREATE (closed) {org_name} №{num}: "
+ f"{city.name}, {street or '(нет адреса)'}, {house}"
+ f"{', корп.' + building if building else ''}"
+ f" | менеджер={manager_name or '—'} куратор={curator_name or '—'}"
+ )
+ if apply:
+ Pharmacy.objects.create(
+ location_type='pharmacy',
+ organization=target_org,
+ pharmacy_number=num,
+ location_region=city,
+ street=street,
+ house_number=house,
+ building=building,
+ apartment=apartment,
+ status='closed',
+ responsible_manager_id=manager_id,
+ curator_id=curator_id,
+ )
+ created_pharm.append(f'{org_name} №{num}')
+
+ if not apply:
+ transaction.set_rollback(True)
+
+ self.stdout.write(self.style.SUCCESS(
+ f"{'Импортировано' if apply else 'Будет импортировано (dry-run)'}: "
+ f"обновлено {len(updated)}, создано {len(created_pharm)}"
+ ))
+ if not apply:
+ self.stdout.write(self.style.WARNING('Это был dry-run. Повторите с --apply, чтобы записать в БД.'))
diff --git a/apps/locations/management/commands/import_pharmacy_branches.py b/apps/locations/management/commands/import_pharmacy_branches.py
new file mode 100644
index 0000000..07cff8a
--- /dev/null
+++ b/apps/locations/management/commands/import_pharmacy_branches.py
@@ -0,0 +1,379 @@
+"""Импорт данных о филиалах из внешней POS/бухгалтерской выгрузки
+(tableConvert.com_zyv1c1.json) — обогащение существующих аптек
+(категория, куратор, ответственный менеджер) и создание недостающих
+филиалов/организаций/сотрудников.
+
+Сопоставление файла с БД и все решения по неоднозначностям (какие адреса
+считать переименованными улицами и совпадающими, куда подвесить новые
+города, что делать с отсутствующими сотрудниками) согласованы с
+пользователем по каждому пункту — зашиты в таблицы ниже, а не угадываются.
+"""
+import json
+import re
+from pathlib import Path
+
+from django.core.management.base import BaseCommand, CommandError
+from django.db import transaction
+
+from apps.employees.models.base import Employee
+from apps.locations.models.pharmacies import Pharmacy
+from core.models.directories import Directory, DirectoryType
+
+ORG_FILE_NAME = 'Название фирмы\n(бухгалтерия)'
+VAT_CAT_FILE_NAME = 'Кат.\nНДС 20%'
+
+# файловое название организации -> точное name существующей Directory(organization)
+# (ключ по name, а не code: code для части орг. записей был раньше
+# автосгенерирован slugify(name) и не совпадает с интуитивным транслитом)
+EXISTING_ORG_NAME = {
+ 'ПП "АКС"': 'ПП АКС',
+ 'ПП АКС': 'ПП АКС',
+ 'ТОВ "АКС-ОДЕСА"': 'ТОВ "АКС ОДЕССА"',
+ 'ТОВ "АКС ОДЕССА"': 'ТОВ "АКС ОДЕССА"',
+ 'ПП "ТІРА"': 'ТИРА',
+ 'ТОВ "ПЕНТАЛЬФА"': 'Пентальфа',
+}
+
+# организации, которых ещё нет в справочнике — создаём с этими кодами
+NEW_ORGS = [
+ ('fop_liskovskyi', 'ФОП Лісковський Сергій Вікторович'),
+ ('tov_111', 'ТОВ "111"'),
+ ('ppf_gippokrat', 'ППФ "ГІППОКРАТ"'),
+]
+NEW_ORG_CODE = {
+ 'ФОП Лісковський Сергій Вікторович': 'fop_liskovskyi',
+ 'ТОВ "111"': 'tov_111',
+ 'ППФ "ГІППОКРАТ"': 'ppf_gippokrat',
+}
+
+# два номера, которые физически уже есть в БД под "ПП АКС", но по этому
+# файлу принадлежат "ФОП Лісковський Сергій Вікторович" — адреса совпадают
+# 1:1 (Овідіопольська дорога 3/2 и Віталія Шума 4), это переоформление
+# организации у существующей записи, а не создание новой.
+ORG_REASSIGN = {
+ ('ПП АКС', 43): 'fop_liskovskyi',
+ ('ПП АКС', 60): 'fop_liskovskyi',
+}
+
+# города, уже существующие в справочнике под своим кодом (region/district/city)
+EXISTING_CITY_CODE = {
+ 'Одесса': 'odessa',
+ 'Тарутино': 'tarutino',
+ 'Черноморск': 'сhernomorsk',
+ 'Южный': 'yuzhny',
+ 'Городня': 'gorodne',
+ 'Утконосовка': 'utkonosovka',
+}
+
+# новые города: (код, название, код родителя-района/области)
+NEW_CITIES = [
+ ('bilhorod_dnistrovsky_city', 'Білгород-Дністровський', 'bilgorod-dnistrovsky'),
+ ('shabo', 'Шабо', 'bilgorod-dnistrovsky'),
+ ('podilsk', 'Подільськ', 'podolsky'),
+ ('ovidiopol', 'Овідіополь', 'odesky'),
+ ('fontanka', 'Фонтанка', 'odesky'),
+ ('sarata', 'Сарата', 'bolgradsky'),
+]
+NEW_CITY_CODE = {
+ 'Белгород-Днестровский': 'bilhorod_dnistrovsky_city',
+ 'Шабо': 'shabo',
+ 'Котовск': 'podilsk',
+ 'Овидиополь': 'ovidiopol',
+ 'Фонтанка': 'fontanka',
+ 'Сарата': 'sarata',
+}
+# Коблево — отдельная ветка региона (Николаевская обл., не Одесская)
+KOBLEVO_REGION_CODE = 'mykolaivska'
+KOBLEVO_CITY_CODE = 'koblevo'
+
+# файловое имя "Менеджер" -> id существующего Employee (post уже переведён
+# на responsible_manager этой же командой перед использованием)
+MANAGER_EMPLOYEE_ID = {
+ 'Кравец Д.Г.': 88,
+ 'Нарезная Е.Б.': 89,
+ 'Коник Т.С.': 90,
+ 'Стёпина А.': 91,
+}
+# сотрудники, у которых пост нужно сменить на responsible_manager, чтобы
+# назначение прошло валидацию Pharmacy.clean() (все, кто реально встречается
+# в файле как "Менеджер", + остальные носители post=menedzher/menedzher_1
+# по решению пользователя)
+EMPLOYEES_TO_RESPONSIBLE_MANAGER = [79, 81, 88, 89, 90, 91, 114, 115, 120, 140]
+
+# файловое имя "Куратор" -> id существующего Employee (post уже kurator)
+CURATOR_EMPLOYEE_ID = {
+ 'Крысенко О.С.': 92,
+ 'Филипьев Д.Г.': 94,
+ 'Ященко О.А.': 95,
+ 'Мишанова М.': 93,
+}
+# отсутствует в Employee — создаём новую запись с постом kurator
+MISSING_CURATOR_NAME = 'Штувбейная С.Ю.'
+
+
+def parse_number(raw):
+ m = re.search(r'(\d+)', raw)
+ if not m:
+ raise CommandError(f'Не удалось извлечь номер из "№": {raw!r}')
+ return int(m.group(1))
+
+
+def parse_ukr_address(ukr):
+ """м.Одеса, вул. Польська, 14 / м.Одеса, буд. 3, кор.2, прим. 19-Н."""
+ parts = [p.strip() for p in ukr.split(',')]
+ parts = parts[1:] # отбрасываем префикс населённого пункта
+ house, building, apartment = '', '', ''
+ rest = []
+ for p in parts:
+ m = re.match(r'буд\.?\s*(.+)', p, re.I)
+ if m:
+ house = m.group(1).strip()
+ continue
+ m = re.match(r'кор\.?\s*(.+)', p, re.I)
+ if m:
+ building = m.group(1).strip()
+ continue
+ m = re.match(r'прим\.?\s*(.+)', p, re.I)
+ if m:
+ apartment = m.group(1).strip()
+ continue
+ rest.append(p)
+ if not house and rest:
+ for i in range(len(rest) - 1, -1, -1):
+ if re.search(r'\d', rest[i]):
+ house = rest[i]
+ street = ' '.join(rest[:i] + rest[i + 1:])
+ break
+ else:
+ street = ' '.join(rest)
+ else:
+ street = ' '.join(rest)
+ street = re.sub(r'^вул\.?\s*', '', street, flags=re.I).strip()
+ return street, house, building, apartment
+
+
+class Command(BaseCommand):
+ help = 'Импорт филиалов из tableConvert.com_zyv1c1.json (обогащение + создание недостающих)'
+
+ def add_arguments(self, parser):
+ parser.add_argument(
+ '--file',
+ default=str(Path.home() / 'Загрузки' / 'tableConvert.com_zyv1c1.json'),
+ help='Путь к JSON-файлу выгрузки',
+ )
+ parser.add_argument('--apply', action='store_true',
+ help='Записать изменения в БД. Без этого флага — только отчёт (dry-run)')
+
+ def handle(self, *args, **options):
+ path = Path(options['file'])
+ if not path.exists():
+ raise CommandError(f'Файл не найден: {path}')
+ rows = json.loads(path.read_text(encoding='utf-8'))
+ apply = options['apply']
+
+ updated, created_pharm, skipped = [], [], []
+
+ with transaction.atomic():
+ org_type = DirectoryType.objects.get(code='organization')
+ city_type = DirectoryType.objects.get(code='city')
+ region_type = DirectoryType.objects.get(code='region')
+ post_type = DirectoryType.objects.get(code='post')
+ country = Directory.objects.get(directory_type__code='country', code='Ukraine')
+
+ # --- организации ---
+ for code, name in NEW_ORGS:
+ org, was_created = Directory.objects.get_or_create(
+ directory_type=org_type, code=code, defaults={'name': name, 'is_active': True},
+ )
+ if was_created:
+ self.stdout.write(self.style.WARNING(f'Создана организация «{name}» ({code})'))
+
+ org_by_code = {d.code: d for d in Directory.objects.filter(directory_type=org_type)}
+ org_by_name = {d.name: d for d in Directory.objects.filter(directory_type=org_type)}
+ # ключ = либо точное name существующей организации, либо code новой
+ org_by_key = dict(org_by_name)
+ for code, _name in NEW_ORGS:
+ org_by_key[code] = org_by_code[code]
+
+ # --- новые города ---
+ district_by_code = {
+ d.code: d for d in Directory.objects.filter(directory_type__code__in=['district', 'region'])
+ }
+ for code, name, parent_code in NEW_CITIES:
+ parent = district_by_code.get(parent_code)
+ if parent is None:
+ raise CommandError(f'Родитель-район с кодом {parent_code!r} не найден')
+ city, was_created = Directory.objects.get_or_create(
+ directory_type=city_type, code=code, defaults={'name': name, 'parent': parent, 'is_active': True},
+ )
+ if was_created:
+ self.stdout.write(self.style.WARNING(f'Создан город «{name}» под «{parent.name}»'))
+
+ # --- Николаевская область -> Коблево (отдельная ветка) ---
+ mykolaiv_region, was_created = Directory.objects.get_or_create(
+ directory_type=region_type, code=KOBLEVO_REGION_CODE,
+ defaults={'name': 'Николаевская область', 'parent': country, 'is_active': True},
+ )
+ if was_created:
+ self.stdout.write(self.style.WARNING('Создана область «Николаевская область»'))
+ koblevo, was_created = Directory.objects.get_or_create(
+ directory_type=city_type, code=KOBLEVO_CITY_CODE,
+ defaults={'name': 'Коблево', 'parent': mykolaiv_region, 'is_active': True},
+ )
+ if was_created:
+ self.stdout.write(self.style.WARNING('Создано село «Коблево»'))
+
+ city_by_code = {d.code: d for d in Directory.objects.filter(directory_type=city_type)}
+ city_lookup = {}
+ for file_name, code in EXISTING_CITY_CODE.items():
+ city_lookup[file_name] = city_by_code[code]
+ for file_name, code in NEW_CITY_CODE.items():
+ city_lookup[file_name] = city_by_code[code]
+ city_lookup['Коблево'] = koblevo
+
+ # --- посты и сотрудники ---
+ responsible_manager_post = Directory.objects.get(directory_type=post_type, code='responsible_manager')
+ kurator_post = Directory.objects.get(directory_type=post_type, code='kurator')
+
+ for emp_id in EMPLOYEES_TO_RESPONSIBLE_MANAGER:
+ emp = Employee.objects.get(pk=emp_id)
+ if emp.post_id != responsible_manager_post.pk:
+ self.stdout.write(
+ f"{'[apply]' if apply else '[dry-run]'} пост {emp.last_name} {emp.first_name}: "
+ f"{emp.post.name if emp.post else '—'} -> Відповідальний менеджер"
+ )
+ emp.post = responsible_manager_post
+ emp.save(update_fields=['post'])
+
+ missing_curator = Employee.objects.filter(
+ last_name='Штувбейная', first_name='С.', middle_name='Ю.'
+ ).first()
+ if missing_curator is None:
+ from pytils.translit import slugify
+ username = slugify('Штувбейная.С') or 'shtuvbeynaya'
+ if Employee.objects.filter(username=username).exists():
+ username = f'{username}2'
+ self.stdout.write(
+ f"{'[apply]' if apply else '[dry-run]'} создание сотрудника «Штувбейная С.Ю.» (kurator), "
+ f"ФИО в источнике дано только инициалами"
+ )
+ missing_curator = Employee(
+ username=username,
+ last_name='Штувбейная', first_name='С.', middle_name='Ю.',
+ post=kurator_post,
+ mattermost_sync_enabled=False,
+ ad_sync_enabled=False,
+ )
+ import datetime
+ missing_curator.hire_date = datetime.date.today()
+ missing_curator.set_unusable_password()
+ missing_curator.save()
+
+ manager_lookup = dict(MANAGER_EMPLOYEE_ID)
+ curator_lookup = dict(CURATOR_EMPLOYEE_ID)
+ curator_lookup[MISSING_CURATOR_NAME] = missing_curator.pk
+
+ # --- проход по строкам файла ---
+ for r in rows:
+ org_name = (r.get(ORG_FILE_NAME) or '').strip()
+ if not org_name:
+ continue # строка-разделитель групп
+ num = parse_number(r['№'])
+ category = (r.get('Кат.') or '').strip()
+ manager_name = (r.get('Менеджер') or '').strip()
+ curator_name = (r.get('Куратор') or '').strip()
+ manager_id = manager_lookup.get(manager_name)
+ curator_id = curator_lookup.get(curator_name)
+
+ if manager_name and manager_id is None:
+ skipped.append(f"№{r['№']}: неизвестный менеджер {manager_name!r}")
+ if curator_name and curator_id is None:
+ skipped.append(f"№{r['№']}: неизвестный куратор {curator_name!r}")
+
+ target_org_key = EXISTING_ORG_NAME.get(org_name) or NEW_ORG_CODE.get(org_name)
+ if target_org_key is None:
+ raise CommandError(f'Неизвестная организация в файле: {org_name!r}')
+ target_org = org_by_key[target_org_key]
+
+ # ищем существующую запись: сначала под "родной" (для файла)
+ # организацией, затем — спец-случай переоформления org
+ existing = Pharmacy.objects.filter(
+ organization=target_org, pharmacy_number=num,
+ ).first()
+ reassign_from = None
+ if existing is None:
+ for (from_key, from_num), to_code in ORG_REASSIGN.items():
+ if to_code == target_org_key and from_num == num:
+ candidate = Pharmacy.objects.filter(
+ organization=org_by_key[from_key], pharmacy_number=num,
+ ).first()
+ if candidate is not None:
+ existing = candidate
+ reassign_from = from_key
+ break
+
+ if existing is not None:
+ changes = []
+ if reassign_from:
+ changes.append(f'organization: {reassign_from} -> {target_org_key}')
+ existing.organization = target_org
+ if category and existing.category != category:
+ changes.append(f'category: {existing.category!r} -> {category!r}')
+ existing.category = category
+ if manager_id and existing.responsible_manager_id != manager_id:
+ changes.append(f'responsible_manager -> {manager_name}')
+ existing.responsible_manager_id = manager_id
+ if curator_id and existing.curator_id != curator_id:
+ changes.append(f'curator -> {curator_name}')
+ existing.curator_id = curator_id
+ if changes:
+ self.stdout.write(
+ f"{'[apply]' if apply else '[dry-run]'} UPDATE #{existing.pharmacy_number} "
+ f"({target_org_key}): {'; '.join(changes)}"
+ )
+ if apply:
+ existing.save()
+ updated.append(existing.pharmacy_number)
+ continue
+
+ # --- создание новой аптеки ---
+ city = city_lookup.get(r.get('Город', '').strip())
+ if city is None:
+ raise CommandError(f"Неизвестный город в файле: {r.get('Город')!r} (№{r['№']})")
+ street, house, building, apartment = parse_ukr_address(r.get('Адрес (укр.)', ''))
+
+ self.stdout.write(
+ f"{'[apply]' if apply else '[dry-run]'} CREATE {org_name} №{num}: "
+ f"{city.name}, {street}, {house}"
+ f"{', корп.' + building if building else ''}"
+ f" | кат={category or '—'} менеджер={manager_name or '—'} куратор={curator_name or '—'}"
+ )
+ if apply:
+ Pharmacy.objects.create(
+ location_type='pharmacy',
+ organization=target_org,
+ pharmacy_number=num,
+ location_region=city,
+ street=street,
+ house_number=house,
+ building=building,
+ apartment=apartment,
+ category=category,
+ responsible_manager_id=manager_id,
+ curator_id=curator_id,
+ )
+ created_pharm.append(f'{org_name} №{num}')
+
+ if not apply:
+ transaction.set_rollback(True)
+
+ self.stdout.write(self.style.SUCCESS(
+ f"{'Импортировано' if apply else 'Будет импортировано (dry-run)'}: "
+ f"обновлено {len(updated)}, создано {len(created_pharm)}, проблем {len(skipped)}"
+ ))
+ if skipped:
+ self.stdout.write(self.style.WARNING('Проблемы:'))
+ for s in skipped:
+ self.stdout.write(f' - {s}')
+ if not apply:
+ self.stdout.write(self.style.WARNING('Это был dry-run. Повторите с --apply, чтобы записать в БД.'))
diff --git a/apps/locations/migrations/0004_pharmacy_category.py b/apps/locations/migrations/0004_pharmacy_category.py
new file mode 100644
index 0000000..1b081cc
--- /dev/null
+++ b/apps/locations/migrations/0004_pharmacy_category.py
@@ -0,0 +1,18 @@
+# Generated by Django 6.0.3 on 2026-07-11 07:08
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('locations', '0003_pharmacy_status'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='pharmacy',
+ name='category',
+ field=models.CharField(blank=True, choices=[('A', 'A'), ('B', 'B'), ('C', 'C'), ('D', 'D')], db_index=True, help_text='Классификация аптеки: A/B/C/D', max_length=1, verbose_name='Категория аптеки'),
+ ),
+ ]
diff --git a/apps/locations/migrations/0005_pharmacy_curator_pharmacy_responsible_manager_and_more.py b/apps/locations/migrations/0005_pharmacy_curator_pharmacy_responsible_manager_and_more.py
new file mode 100644
index 0000000..be0b752
--- /dev/null
+++ b/apps/locations/migrations/0005_pharmacy_curator_pharmacy_responsible_manager_and_more.py
@@ -0,0 +1,31 @@
+# Generated by Django 6.0.3 on 2026-07-11 09:03
+
+import django.db.models.deletion
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('locations', '0004_pharmacy_category'),
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='pharmacy',
+ name='curator',
+ field=models.ForeignKey(blank=True, help_text='Сотрудник с должностью «куратор»', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='curator_at', to=settings.AUTH_USER_MODEL, verbose_name='Куратор'),
+ ),
+ migrations.AddField(
+ model_name='pharmacy',
+ name='responsible_manager',
+ field=models.ForeignKey(blank=True, help_text='Сотрудник с должностью «Відповідальний менеджер»', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='responsible_manager_at', to=settings.AUTH_USER_MODEL, verbose_name='Ответственный менеджер'),
+ ),
+ migrations.AddField(
+ model_name='pharmacy',
+ name='vat_category',
+ field=models.CharField(blank=True, choices=[('20', 'ПДВ 20%'), ('7', 'ПДВ 7% (пільгова)'), ('0', 'ПДВ 0%'), ('none', 'Без ПДВ')], db_index=True, max_length=4, verbose_name='Категорія ПДВ'),
+ ),
+ ]
diff --git a/apps/locations/migrations/0006_seed_responsible_manager_post.py b/apps/locations/migrations/0006_seed_responsible_manager_post.py
new file mode 100644
index 0000000..ec9e148
--- /dev/null
+++ b/apps/locations/migrations/0006_seed_responsible_manager_post.py
@@ -0,0 +1,32 @@
+from django.db import migrations
+
+
+def forwards(apps, schema_editor):
+ DirectoryType = apps.get_model('core', 'DirectoryType')
+ Directory = apps.get_model('core', 'Directory')
+
+ post_type, _ = DirectoryType.objects.get_or_create(
+ code='post', defaults={'name': 'Посада', 'hierarchy_type': 'strict'},
+ )
+ Directory.objects.get_or_create(
+ directory_type=post_type, code='responsible_manager',
+ defaults={'name': 'Відповідальний менеджер', 'is_active': True},
+ )
+
+
+def backwards(apps, schema_editor):
+ Directory = apps.get_model('core', 'Directory')
+ Directory.objects.filter(
+ directory_type__code='post', code='responsible_manager',
+ ).delete()
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('locations', '0005_pharmacy_curator_pharmacy_responsible_manager_and_more'),
+ ]
+
+ operations = [
+ migrations.RunPython(forwards, backwards),
+ ]
diff --git a/apps/locations/migrations/0007_seed_pharmacy_manager_post.py b/apps/locations/migrations/0007_seed_pharmacy_manager_post.py
new file mode 100644
index 0000000..c199007
--- /dev/null
+++ b/apps/locations/migrations/0007_seed_pharmacy_manager_post.py
@@ -0,0 +1,32 @@
+from django.db import migrations
+
+
+def forwards(apps, schema_editor):
+ DirectoryType = apps.get_model('core', 'DirectoryType')
+ Directory = apps.get_model('core', 'Directory')
+
+ post_type, _ = DirectoryType.objects.get_or_create(
+ code='post', defaults={'name': 'Посада', 'hierarchy_type': 'strict'},
+ )
+ Directory.objects.get_or_create(
+ directory_type=post_type, code='pharmacy_manager',
+ defaults={'name': 'Завідувач аптеки', 'is_active': True},
+ )
+
+
+def backwards(apps, schema_editor):
+ Directory = apps.get_model('core', 'Directory')
+ Directory.objects.filter(
+ directory_type__code='post', code='pharmacy_manager',
+ ).delete()
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('locations', '0006_seed_responsible_manager_post'),
+ ]
+
+ operations = [
+ migrations.RunPython(forwards, backwards),
+ ]
diff --git a/apps/locations/models/base.py b/apps/locations/models/base.py
index 1cdc079..324a6c2 100644
--- a/apps/locations/models/base.py
+++ b/apps/locations/models/base.py
@@ -45,8 +45,11 @@ class Location(BaseModel):
# --- Координаты (django-location-field: "широта,долгота") ---
# Центр по умолчанию: Одесса (46.4825°N, 30.7233°E)
+ # based_fields — саме поля, які реально редагує людина (street/house_number/building),
+ # а НЕ address: той тепер лише читається (генерується в save()), тому сам по собі
+ # ніколи не змінюється користувачем і не міг би тригернути live-геокодування карти
location = PlainLocationField(
- based_fields=['address'],
+ based_fields=['street', 'house_number', 'building'],
zoom=14,
verbose_name=_('Координаты на карте'),
blank=True,
@@ -68,10 +71,22 @@ class Location(BaseModel):
]
def __str__(self):
- region = self.location_region.name if self.location_region else ''
addr = self.get_full_address() or self.address
- parts = [p for p in (region, addr) if p]
- return ', '.join(parts) or self.get_location_type_display()
+ if self.location_type == 'pharmacy':
+ # Location базовий — для аптеки завжди є child-рядок Pharmacy
+ # (multi-table inheritance); pharmacy_number — головний орієнтир
+ # для пошуку/вибору аптеки, адреса сама по собі його не показує
+ pharmacy = getattr(self, 'pharmacy', None)
+ if pharmacy:
+ return f'Аптека №{pharmacy.pharmacy_number} — {addr}' if addr else f'Аптека №{pharmacy.pharmacy_number}'
+ type_label = self.get_location_type_display()
+ return f'{type_label} — {addr}' if addr else type_label
+
+ def save(self, *args, **kwargs):
+ # "Полный адрес" — поле лише для читання (help_text це й обіцяв, але ніхто
+ # ніколи насправді не проставляв значення) — перераховуємо з частин щоразу
+ self.address = self.get_full_address()
+ super().save(*args, **kwargs)
def clean(self):
super().clean()
@@ -123,4 +138,7 @@ class Location(BaseModel):
@property
def has_coordinates(self):
- return bool(self.location)
\ No newline at end of file
+ """True, лише якщо координати реально проставлені — не просто дефолт поля
+ (PlainLocationField.default непорожній, тож голий bool() завжди був True)."""
+ default = self._meta.get_field('location').default
+ return bool(self.location) and self.location != default
\ No newline at end of file
diff --git a/apps/locations/models/pharmacies.py b/apps/locations/models/pharmacies.py
index 3f6eeb2..7faeac6 100644
--- a/apps/locations/models/pharmacies.py
+++ b/apps/locations/models/pharmacies.py
@@ -46,6 +46,20 @@ class Pharmacy(Location):
('preparing', _('Подготовка к запуску')),
]
+ CATEGORY_CHOICES = [
+ ('A', 'A'),
+ ('B', 'B'),
+ ('C', 'C'),
+ ('D', 'D'),
+ ]
+
+ VAT_CATEGORY_CHOICES = [
+ ('20', _('ПДВ 20%')),
+ ('7', _('ПДВ 7% (пільгова)')),
+ ('0', _('ПДВ 0%')),
+ ('none', _('Без ПДВ')),
+ ]
+
# --- Идентификация ---
pharmacy_number = models.PositiveIntegerField(
_('Номер аптеки'),
@@ -76,6 +90,18 @@ class Pharmacy(Location):
null=True, blank=True,
related_name='head_pharmacist_at', verbose_name=_('Заведующий аптекой')
)
+ responsible_manager = models.ForeignKey(
+ settings.AUTH_USER_MODEL, on_delete=models.SET_NULL,
+ null=True, blank=True,
+ related_name='responsible_manager_at', verbose_name=_('Ответственный менеджер'),
+ help_text=_('Сотрудник с должностью «Відповідальний менеджер»'),
+ )
+ curator = models.ForeignKey(
+ settings.AUTH_USER_MODEL, on_delete=models.SET_NULL,
+ null=True, blank=True,
+ related_name='curator_at', verbose_name=_('Куратор'),
+ help_text=_('Сотрудник с должностью «куратор»'),
+ )
pharmacists_count = models.PositiveSmallIntegerField(
_('Количество фармацевтов'), default=1,
help_text=_('Кешированное поле — обновляется при синхронизации')
@@ -86,6 +112,17 @@ class Pharmacy(Location):
choices=STATUS_CHOICES, default='open', db_index=True,
)
+ category = models.CharField(
+ _('Категория аптеки'), max_length=1,
+ choices=CATEGORY_CHOICES, blank=True, db_index=True,
+ help_text=_('Классификация аптеки: A/B/C/D'),
+ )
+
+ vat_category = models.CharField(
+ _('Категорія ПДВ'), max_length=4,
+ choices=VAT_CATEGORY_CHOICES, blank=True, db_index=True,
+ )
+
phone = models.CharField(_('Телефон'), max_length=20, blank=True,
validators=[validate_ukrainian_phone])
working_hours = models.CharField(_('Часы работы'), max_length=100,
@@ -154,10 +191,22 @@ class Pharmacy(Location):
raise ValidationError({
'head_pharmacist': 'Сотрудник должен иметь должность заведующего аптекой'
})
+ if self.curator and hasattr(self.curator, 'post') and self.curator.post:
+ if getattr(self.curator.post, 'code', None) != 'kurator':
+ raise ValidationError({'curator': 'Сотрудник должен иметь должность «куратор»'})
+ if self.responsible_manager and hasattr(self.responsible_manager, 'post') and self.responsible_manager.post:
+ if getattr(self.responsible_manager.post, 'code', None) != 'responsible_manager':
+ raise ValidationError({
+ 'responsible_manager': 'Сотрудник должен иметь должность «Відповідальний менеджер»'
+ })
def save(self, *args, **kwargs):
if not self.location_type:
self.location_type = 'pharmacy'
+ # is_active — не отдельное ручное поле для аптек, а производное от
+ # status: единственный источник правды — статус, иначе аптека может
+ # числиться "закрыта" и одновременно "активна".
+ self.is_active = self.status == 'open'
super().save(*args, **kwargs)
@property
diff --git a/apps/utilities/__init__.py b/apps/utilities/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/apps/utilities/admin.py b/apps/utilities/admin.py
new file mode 100644
index 0000000..cae2a29
--- /dev/null
+++ b/apps/utilities/admin.py
@@ -0,0 +1,15 @@
+from django.contrib import admin
+from unfold.admin import ModelAdmin
+
+from .models import UtilityReading
+
+
+@admin.register(UtilityReading)
+class UtilityReadingAdmin(ModelAdmin):
+ list_display = ['pharmacy', 'utility_type', 'reading_date', 'value', 'submitted_by']
+ list_filter = ['utility_type', 'pharmacy__organization']
+ search_fields = ['pharmacy__pharmacy_number', 'comment']
+ list_select_related = ['pharmacy', 'pharmacy__organization', 'utility_type', 'submitted_by']
+ ordering = ['-reading_date']
+ readonly_fields = ['created_at', 'updated_at']
+ autocomplete_fields = ['pharmacy']
diff --git a/apps/utilities/apps.py b/apps/utilities/apps.py
new file mode 100644
index 0000000..0e16db3
--- /dev/null
+++ b/apps/utilities/apps.py
@@ -0,0 +1,7 @@
+from django.apps import AppConfig
+
+
+class UtilitiesConfig(AppConfig):
+ default_auto_field = 'django.db.models.BigAutoField'
+ name = 'apps.utilities'
+ verbose_name = 'Комунальні послуги'
diff --git a/apps/utilities/migrations/0001_initial.py b/apps/utilities/migrations/0001_initial.py
new file mode 100644
index 0000000..4191b05
--- /dev/null
+++ b/apps/utilities/migrations/0001_initial.py
@@ -0,0 +1,43 @@
+# Generated by Django 6.0.3 on 2026-07-11 07:44
+
+import django.db.models.deletion
+import django.utils.timezone
+import uuid
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ('core', '0002_connector_config'),
+ ('locations', '0004_pharmacy_category'),
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='UtilityReading',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
+ ('created_at', models.DateTimeField(auto_now_add=True)),
+ ('updated_at', models.DateTimeField(auto_now=True)),
+ ('reading_date', models.DateField(default=django.utils.timezone.localdate, verbose_name='Дата зняття показань')),
+ ('value', models.DecimalField(decimal_places=2, max_digits=12, verbose_name='Показання')),
+ ('comment', models.CharField(blank=True, max_length=255, verbose_name='Коментар')),
+ ('pharmacy', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='utility_readings', to='locations.pharmacy', verbose_name='Аптека')),
+ ('submitted_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='utility_readings_submitted', to=settings.AUTH_USER_MODEL, verbose_name='Хто подав')),
+ ('utility_type', models.ForeignKey(limit_choices_to={'directory_type__code': 'utility_type', 'is_active': True}, on_delete=django.db.models.deletion.PROTECT, related_name='utility_readings', to='core.directory', verbose_name='Тип лічильника')),
+ ],
+ options={
+ 'verbose_name': 'Показання лічильника',
+ 'verbose_name_plural': 'Показання лічильників',
+ 'ordering': ['-reading_date', '-created_at'],
+ 'indexes': [models.Index(fields=['pharmacy', 'utility_type', '-reading_date'], name='utilities_u_pharmac_3cbba6_idx')],
+ 'constraints': [models.UniqueConstraint(fields=('pharmacy', 'utility_type', 'reading_date'), name='unique_reading_per_pharmacy_type_date')],
+ },
+ ),
+ ]
diff --git a/apps/utilities/migrations/0002_seed_utility_types.py b/apps/utilities/migrations/0002_seed_utility_types.py
new file mode 100644
index 0000000..865b114
--- /dev/null
+++ b/apps/utilities/migrations/0002_seed_utility_types.py
@@ -0,0 +1,38 @@
+from django.db import migrations
+from pytils.translit import slugify
+
+UTILITY_TYPES = [
+ 'Електроенергія',
+ 'Вода',
+]
+
+
+def forwards(apps, schema_editor):
+ DirectoryType = apps.get_model('core', 'DirectoryType')
+ Directory = apps.get_model('core', 'Directory')
+
+ directory_type, _ = DirectoryType.objects.get_or_create(
+ code='utility_type',
+ defaults={'name': 'Тип лічильника', 'hierarchy_type': 'strict'},
+ )
+ for name in UTILITY_TYPES:
+ Directory.objects.get_or_create(
+ directory_type=directory_type, name=name,
+ defaults={'is_active': True, 'code': slugify(name)},
+ )
+
+
+def backwards(apps, schema_editor):
+ DirectoryType = apps.get_model('core', 'DirectoryType')
+ DirectoryType.objects.filter(code='utility_type').delete()
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('utilities', '0001_initial'),
+ ]
+
+ operations = [
+ migrations.RunPython(forwards, backwards),
+ ]
diff --git a/apps/utilities/migrations/__init__.py b/apps/utilities/migrations/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/apps/utilities/models.py b/apps/utilities/models.py
new file mode 100644
index 0000000..fb029e1
--- /dev/null
+++ b/apps/utilities/models.py
@@ -0,0 +1,76 @@
+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 UtilityReading(BaseModel):
+ """Показання лічильника (світло, вода...), подані аптекою для комунального відділу.
+ Типи лічильників — довідник core.Directory (directory_type=utility_type), не хардкод,
+ бо список типів може відрізнятись і розширюватись адміном без релізу коду."""
+
+ pharmacy = models.ForeignKey(
+ 'locations.Pharmacy', on_delete=models.CASCADE,
+ related_name='utility_readings', verbose_name=_('Аптека'),
+ )
+ utility_type = models.ForeignKey(
+ 'core.Directory', on_delete=models.PROTECT,
+ limit_choices_to={'directory_type__code': 'utility_type', 'is_active': True},
+ related_name='utility_readings', verbose_name=_('Тип лічильника'),
+ )
+ reading_date = models.DateField(_('Дата зняття показань'), default=timezone.localdate)
+ value = models.DecimalField(_('Показання'), max_digits=12, decimal_places=2)
+ comment = models.CharField(_('Коментар'), max_length=255, blank=True)
+
+ submitted_by = models.ForeignKey(
+ settings.AUTH_USER_MODEL, on_delete=models.SET_NULL,
+ null=True, blank=True,
+ related_name='utility_readings_submitted', verbose_name=_('Хто подав'),
+ )
+
+ class Meta:
+ verbose_name = _('Показання лічильника')
+ verbose_name_plural = _('Показання лічильників')
+ ordering = ['-reading_date', '-created_at']
+ constraints = [
+ models.UniqueConstraint(
+ fields=['pharmacy', 'utility_type', 'reading_date'],
+ name='unique_reading_per_pharmacy_type_date',
+ ),
+ ]
+ indexes = [
+ models.Index(fields=['pharmacy', 'utility_type', '-reading_date']),
+ ]
+
+ def __str__(self):
+ return f'{self.pharmacy} — {self.utility_type.name}: {self.value} ({self.reading_date})'
+
+ def clean(self):
+ super().clean()
+ if self.value is not None and self.value < 0:
+ raise ValidationError({'value': _("Показання не може бути від'ємним")})
+ if self.reading_date and self.reading_date > timezone.localdate():
+ raise ValidationError({'reading_date': _('Дата не може бути в майбутньому')})
+
+ @property
+ def previous_reading(self):
+ qs = UtilityReading.objects.filter(
+ pharmacy=self.pharmacy, utility_type=self.utility_type,
+ reading_date__lt=self.reading_date,
+ )
+ if self.pk:
+ qs = qs.exclude(pk=self.pk)
+ return qs.order_by('-reading_date').first()
+
+ @property
+ def consumption(self):
+ """Витрата з моменту попереднього показання. None — немає з чим порівняти.
+ Може бути від'ємною (заміна лічильника, помилка вводу) — навмисно не блокується
+ в clean(), лише позначається попередженням в шаблоні."""
+ previous = self.previous_reading
+ if previous is None:
+ return None
+ return self.value - previous.value
diff --git a/apps/utilities/urls.py b/apps/utilities/urls.py
new file mode 100644
index 0000000..ae82fd4
--- /dev/null
+++ b/apps/utilities/urls.py
@@ -0,0 +1,8 @@
+from django.urls import path
+
+from . import views
+
+urlpatterns = [
+ path('utilities/', views.utilities_view, name='utilities'),
+ path('utilities//delete/', views.utility_reading_delete_view, name='utility_reading_delete'),
+]
diff --git a/apps/utilities/views.py b/apps/utilities/views.py
new file mode 100644
index 0000000..8604e68
--- /dev/null
+++ b/apps/utilities/views.py
@@ -0,0 +1,98 @@
+from django.contrib.auth.decorators import login_required
+from django.core.exceptions import ValidationError
+from django.shortcuts import get_object_or_404, redirect
+from django.views.decorators.http import require_POST
+
+from apps.locations.models.pharmacies import Pharmacy
+from core.models.directories import Directory
+from core.utils.htmx import render_page
+from core.utils.permissions import is_owner_or_staff
+
+from .models import UtilityReading
+
+
+def can_delete_reading(user, reading) -> bool:
+ return is_owner_or_staff(user, reading, 'submitted_by_id')
+
+
+@login_required
+def utilities_view(request):
+ """Показання лічильників: staff бачить усі аптеки й фільтри (звіт для
+ комунального відділу), звичайний співробітник аптеки — тільки свою аптеку
+ і подає показання лише за неї."""
+ is_staff = request.user.is_staff
+ own_pharmacy = request.user.user_pharmacy
+
+ form_error = ''
+ if request.method == 'POST':
+ pharmacy_id = request.POST.get('pharmacy') if is_staff else (own_pharmacy.pk if own_pharmacy else None)
+ utility_type_id = request.POST.get('utility_type')
+ reading_date = request.POST.get('reading_date')
+ value = request.POST.get('value')
+
+ if not pharmacy_id:
+ form_error = 'Показання не збережено: у вас не вказана аптека (зверніться до HR).'
+ elif not (utility_type_id and reading_date and value):
+ form_error = 'Показання не збережено: заповніть тип лічильника, дату і значення.'
+ else:
+ reading = UtilityReading(
+ pharmacy_id=pharmacy_id,
+ utility_type_id=utility_type_id,
+ reading_date=reading_date,
+ value=value,
+ comment=request.POST.get('comment', '').strip(),
+ submitted_by=request.user,
+ )
+ try:
+ reading.full_clean()
+ reading.save()
+ except ValidationError as e:
+ form_error = ' '.join(m for messages in e.message_dict.values() for m in messages)
+
+ utility_types = Directory.objects.filter(directory_type__code='utility_type', is_active=True).order_by('name')
+
+ readings = UtilityReading.objects.select_related(
+ 'pharmacy', 'pharmacy__organization', 'utility_type', 'submitted_by',
+ )
+ if is_staff:
+ filter_pharmacy = request.GET.get('pharmacy', '')
+ filter_type = request.GET.get('utility_type', '')
+ if filter_pharmacy:
+ readings = readings.filter(pharmacy_id=filter_pharmacy)
+ if filter_type:
+ readings = readings.filter(utility_type_id=filter_type)
+ pharmacies_for_form = Pharmacy.objects.filter(is_active=True).select_related('organization').order_by('organization__name', 'pharmacy_number')
+ else:
+ filter_pharmacy = filter_type = ''
+ if own_pharmacy:
+ readings = readings.filter(pharmacy=own_pharmacy)
+ else:
+ readings = readings.none()
+ pharmacies_for_form = [own_pharmacy] if own_pharmacy else []
+
+ readings = list(readings[:200])
+ for r in readings:
+ r.can_delete = can_delete_reading(request.user, r)
+
+ context = {
+ 'is_staff': is_staff,
+ 'own_pharmacy': own_pharmacy,
+ 'pharmacies_for_form': pharmacies_for_form,
+ 'utility_types': utility_types,
+ 'readings': readings,
+ 'form_error': form_error,
+ 'filter_pharmacy': filter_pharmacy,
+ 'filter_type': filter_type,
+ 'all_pharmacies': Pharmacy.objects.filter(is_active=True).select_related('organization').order_by('organization__name', 'pharmacy_number') if is_staff else [],
+ }
+ return render_page(request, 'utilities.html', context)
+
+
+@login_required
+@require_POST
+def utility_reading_delete_view(request, pk):
+ reading = get_object_or_404(UtilityReading, pk=pk)
+ if not can_delete_reading(request.user, reading):
+ return redirect('utilities')
+ reading.delete()
+ return redirect('utilities')
diff --git a/apps/visit_manager/admin.py b/apps/visit_manager/admin.py
index d301bd8..6ba2b23 100644
--- a/apps/visit_manager/admin.py
+++ b/apps/visit_manager/admin.py
@@ -1,6 +1,7 @@
from django import forms
from django.contrib import admin
from django.utils.html import format_html
+from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _
from unfold.admin import ModelAdmin, TabularInline
@@ -97,9 +98,9 @@ class HolidayAdmin(ModelAdmin):
def days_until_display(self, obj):
days = obj.days_until
if days is None:
- return format_html('Пройшло')
+ return mark_safe('Пройшло')
if days == 0:
- return format_html('Сьогодні')
+ return mark_safe('Сьогодні')
if days <= 7:
return format_html('{} дн.', days)
return f"{days} дн."
@@ -193,8 +194,8 @@ class WorkScheduleAdmin(ModelAdmin):
def is_current(self, obj):
if obj.is_current:
- return format_html('● Активний')
- return format_html('● Неактивний')
+ return mark_safe('● Активний')
+ return mark_safe('● Неактивний')
is_current.short_description = _('Статус')
def save_model(self, request, obj, form, change):