From 6ff01c4119fd461b1a9368302d26fe07d5d6cca9 Mon Sep 17 00:00:00 2001 From: lokses Date: Wed, 22 Jul 2026 17:33:16 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=BC=D0=B5=D0=BD=D0=BD=D1=8B?= =?UTF-8?q?=D0=B5=20=D0=BC=D0=BE=D0=B4=D1=83=D0=BB=D0=B8:=20=D1=81=D0=BE?= =?UTF-8?q?=D1=82=D1=80=D1=83=D0=B4=D0=BD=D0=B8=D0=BA=D0=B8,=20=D0=BB?= =?UTF-8?q?=D0=BE=D0=BA=D0=B0=D1=86=D0=B8=D0=B8,=20=D0=BD=D0=B0=D0=B9?= =?UTF-8?q?=D0=BC,=20=D1=81=D1=87=D1=91=D1=82=D1=87=D0=B8=D0=BA=D0=B8,=20?= =?UTF-8?q?=D0=BF=D0=BE=D1=81=D0=B5=D1=89=D0=B5=D0=BD=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - employees: last_seen, internal_number, default_page_size; управление-команды - locations: категория/куратор/ответственный аптеки, seed должностей; Location.__str__ показывает номер аптеки (не только адрес) - hiring: HiringRequest (заявки на найм) + тесты (7) - utilities: UtilityReading (показания счётчиков по аптекам) - visit_manager: правки Co-Authored-By: Claude Opus 4.8 --- apps/employees/admin.py | 2 +- apps/employees/management/__init__.py | 0 .../employees/management/commands/__init__.py | 0 .../commands/import_employees_json.py | 220 ++++++++++ .../0008_employee_internal_number.py | 18 + .../0009_employee_default_page_size.py | 18 + .../migrations/0010_employee_last_seen.py | 18 + apps/employees/models/base.py | 16 + apps/employees/serializers.py | 2 +- apps/hiring/__init__.py | 0 apps/hiring/admin.py | 14 + apps/hiring/apps.py | 7 + apps/hiring/migrations/0001_initial.py | 59 +++ .../migrations/0002_seed_access_systems.py | 42 ++ apps/hiring/migrations/__init__.py | 0 apps/hiring/models.py | 114 ++++++ apps/hiring/services.py | 134 +++++++ apps/hiring/tests.py | 135 +++++++ apps/locations/admin.py | 84 ++-- apps/locations/management/__init__.py | 0 .../locations/management/commands/__init__.py | 0 .../import_closed_pharmacy_branches.py | 278 +++++++++++++ .../commands/import_pharmacy_branches.py | 379 ++++++++++++++++++ .../migrations/0004_pharmacy_category.py | 18 + ...r_pharmacy_responsible_manager_and_more.py | 31 ++ .../0006_seed_responsible_manager_post.py | 32 ++ .../0007_seed_pharmacy_manager_post.py | 32 ++ apps/locations/models/base.py | 28 +- apps/locations/models/pharmacies.py | 49 +++ apps/utilities/__init__.py | 0 apps/utilities/admin.py | 15 + apps/utilities/apps.py | 7 + apps/utilities/migrations/0001_initial.py | 43 ++ .../migrations/0002_seed_utility_types.py | 38 ++ apps/utilities/migrations/__init__.py | 0 apps/utilities/models.py | 76 ++++ apps/utilities/urls.py | 8 + apps/utilities/views.py | 98 +++++ apps/visit_manager/admin.py | 9 +- 39 files changed, 1984 insertions(+), 40 deletions(-) create mode 100644 apps/employees/management/__init__.py create mode 100644 apps/employees/management/commands/__init__.py create mode 100644 apps/employees/management/commands/import_employees_json.py create mode 100644 apps/employees/migrations/0008_employee_internal_number.py create mode 100644 apps/employees/migrations/0009_employee_default_page_size.py create mode 100644 apps/employees/migrations/0010_employee_last_seen.py create mode 100644 apps/hiring/__init__.py create mode 100644 apps/hiring/admin.py create mode 100644 apps/hiring/apps.py create mode 100644 apps/hiring/migrations/0001_initial.py create mode 100644 apps/hiring/migrations/0002_seed_access_systems.py create mode 100644 apps/hiring/migrations/__init__.py create mode 100644 apps/hiring/models.py create mode 100644 apps/hiring/services.py create mode 100644 apps/hiring/tests.py create mode 100644 apps/locations/management/__init__.py create mode 100644 apps/locations/management/commands/__init__.py create mode 100644 apps/locations/management/commands/import_closed_pharmacy_branches.py create mode 100644 apps/locations/management/commands/import_pharmacy_branches.py create mode 100644 apps/locations/migrations/0004_pharmacy_category.py create mode 100644 apps/locations/migrations/0005_pharmacy_curator_pharmacy_responsible_manager_and_more.py create mode 100644 apps/locations/migrations/0006_seed_responsible_manager_post.py create mode 100644 apps/locations/migrations/0007_seed_pharmacy_manager_post.py create mode 100644 apps/utilities/__init__.py create mode 100644 apps/utilities/admin.py create mode 100644 apps/utilities/apps.py create mode 100644 apps/utilities/migrations/0001_initial.py create mode 100644 apps/utilities/migrations/0002_seed_utility_types.py create mode 100644 apps/utilities/migrations/__init__.py create mode 100644 apps/utilities/models.py create mode 100644 apps/utilities/urls.py create mode 100644 apps/utilities/views.py 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):