diff --git a/apps/locations/migrations/0008_pharmacy_carbonio_account_id_pharmacy_carbonio_email_and_more.py b/apps/locations/migrations/0008_pharmacy_carbonio_account_id_pharmacy_carbonio_email_and_more.py new file mode 100644 index 0000000..9da8c3e --- /dev/null +++ b/apps/locations/migrations/0008_pharmacy_carbonio_account_id_pharmacy_carbonio_email_and_more.py @@ -0,0 +1,50 @@ +# Generated by Django 6.0.3 on 2026-07-23 16:43 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0004_encrypt_connector_secrets'), + ('locations', '0007_seed_pharmacy_manager_post'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name='pharmacy', + name='carbonio_account_id', + field=models.CharField(blank=True, max_length=100, verbose_name='ID аккаунта Carbonio'), + ), + migrations.AddField( + model_name='pharmacy', + name='carbonio_email', + field=models.EmailField(blank=True, help_text='Ящик аптеки. Пусто — сгенерируется как <префикс организации><номер>@домен. Можно задать вручную.', max_length=254, verbose_name='Почта Carbonio'), + ), + migrations.AddField( + model_name='pharmacy', + name='carbonio_last_sync', + field=models.DateTimeField(blank=True, null=True, verbose_name='Последняя синхронизация Carbonio'), + ), + migrations.AddField( + model_name='pharmacy', + name='carbonio_sync_enabled', + field=models.BooleanField(default=False, help_text='Управлять почтовым ящиком аптеки в Carbonio', verbose_name='Синхронизация с Carbonio'), + ), + migrations.AddField( + model_name='pharmacy', + name='carbonio_sync_error', + field=models.TextField(blank=True, verbose_name='Ошибка синхронизации Carbonio'), + ), + migrations.AddField( + model_name='pharmacy', + name='carbonio_sync_status', + field=models.CharField(blank=True, choices=[('pending', 'Ожидает синхронизации'), ('synced', 'Синхронизирован'), ('error', 'Ошибка'), ('disabled', 'Отключён')], max_length=10, verbose_name='Статус синхронизации Carbonio'), + ), + migrations.AddConstraint( + model_name='pharmacy', + constraint=models.UniqueConstraint(condition=models.Q(('carbonio_email__gt', '')), fields=('carbonio_email',), name='unique_carbonio_email'), + ), + ] diff --git a/apps/locations/models/pharmacies.py b/apps/locations/models/pharmacies.py index 7faeac6..bf0d316 100644 --- a/apps/locations/models/pharmacies.py +++ b/apps/locations/models/pharmacies.py @@ -6,6 +6,22 @@ from core.utils.validators import validate_ukrainian_phone from django.utils import timezone from .base import Location +# Домен почты по умолчанию, если не задан в ConnectorConfig(carbonio).extra_config['mail_domain'] +CARBONIO_DEFAULT_DOMAIN = 'aks-apteka.com.ua' + + +def carbonio_mail_domain() -> str: + """Домен ящиков Carbonio: из настроек коннектора, иначе дефолт.""" + try: + from core.models import ConnectorConfig + cfg = ConnectorConfig.objects.filter(connector_type='carbonio').first() + domain = (cfg.extra_config or {}).get('mail_domain') if cfg else None + if domain: + return domain + except Exception: + pass + return CARBONIO_DEFAULT_DOMAIN + class PharmacyManager(models.Manager): def get_queryset(self): @@ -128,6 +144,31 @@ class Pharmacy(Location): working_hours = models.CharField(_('Часы работы'), max_length=100, default='08:00-20:00') + # --- Carbonio CE (почтовый ящик аптеки) --- + CARBONIO_STATUS_CHOICES = [ + ('pending', _('Ожидает синхронизации')), + ('synced', _('Синхронизирован')), + ('error', _('Ошибка')), + ('disabled', _('Отключён')), + ] + carbonio_email = models.EmailField( + _('Почта Carbonio'), blank=True, + help_text=_('Ящик аптеки. Пусто — сгенерируется как ' + '<префикс организации><номер>@домен. Можно задать вручную.'), + ) + carbonio_account_id = models.CharField(_('ID аккаунта Carbonio'), max_length=100, blank=True) + carbonio_sync_enabled = models.BooleanField( + _('Синхронизация с Carbonio'), default=False, + help_text=_('Управлять почтовым ящиком аптеки в Carbonio'), + ) + carbonio_sync_status = models.CharField( + _('Статус синхронизации Carbonio'), max_length=10, + choices=CARBONIO_STATUS_CHOICES, blank=True, + ) + carbonio_last_sync = models.DateTimeField( + _('Последняя синхронизация Carbonio'), null=True, blank=True) + carbonio_sync_error = models.TextField(_('Ошибка синхронизации Carbonio'), blank=True) + # --- Услуги (через справочник — без хардкода булевых флагов) --- # services = models.ManyToManyField( # 'core.Directory', blank=True, @@ -173,7 +214,13 @@ class Pharmacy(Location): models.UniqueConstraint( fields=['organization', 'pharmacy_number'], name='unique_pharmacy_number_per_organization' - ) + ), + # Уникальность почты Carbonio среди заполненных (пустые не конфликтуют). + models.UniqueConstraint( + fields=['carbonio_email'], + condition=models.Q(carbonio_email__gt=''), + name='unique_carbonio_email', + ), ] def __str__(self): @@ -199,6 +246,27 @@ class Pharmacy(Location): raise ValidationError({ 'responsible_manager': 'Сотрудник должен иметь должность «Відповідальний менеджер»' }) + # Уникальность адреса Carbonio (и для заданного вручную, и для сгенерированного). + if self.carbonio_email and self._carbonio_email_clashes(self.carbonio_email): + raise ValidationError({ + 'carbonio_email': _('Такой адрес Carbonio уже используется другой аптекой') + }) + + def build_carbonio_email(self) -> str: + """Сгенерировать адрес ящика: <номер>@домен. + Пусто, если у организации не задан mail_prefix (в extra_data) или нет номера.""" + prefix = None + if self.organization_id and self.organization: + prefix = (self.organization.extra_data or {}).get('mail_prefix') + if not prefix or not self.pharmacy_number: + return '' + return f'{prefix}{self.pharmacy_number}@{carbonio_mail_domain()}' + + def _carbonio_email_clashes(self, email: str) -> bool: + qs = Pharmacy.objects.filter(carbonio_email__iexact=email) + if self.pk: + qs = qs.exclude(pk=self.pk) + return qs.exists() def save(self, *args, **kwargs): if not self.location_type: @@ -207,6 +275,12 @@ class Pharmacy(Location): # status: единственный источник правды — статус, иначе аптека может # числиться "закрыта" и одновременно "активна". self.is_active = self.status == 'open' + # Автогенерация адреса Carbonio, если синхронизация включена и адрес не + # задан вручную. При коллизии оставляем пустым — требуется ручной ввод. + if self.carbonio_sync_enabled and not self.carbonio_email: + candidate = self.build_carbonio_email() + if candidate and not self._carbonio_email_clashes(candidate): + self.carbonio_email = candidate super().save(*args, **kwargs) @property diff --git a/apps/locations/tests.py b/apps/locations/tests.py index 7ce503c..4df352a 100644 --- a/apps/locations/tests.py +++ b/apps/locations/tests.py @@ -1,3 +1,75 @@ +from django.core.exceptions import ValidationError +from django.db import IntegrityError, transaction from django.test import TestCase -# Create your tests here. +from core.models import Directory, DirectoryType +from apps.locations.models import Pharmacy + + +class CarbonioEmailTests(TestCase): + """Генерация адреса Carbonio (<префикс организации><номер>@домен), + ручной override и проверка уникальности.""" + + @classmethod + def setUpTestData(cls): + org_type = DirectoryType.objects.create(code='organization', name='Организация') + region_type = DirectoryType.objects.create(code='region', name='Регион') + cls.region = Directory.objects.create(directory_type=region_type, code='od', name='Одесса') + cls.aks = Directory.objects.create( + directory_type=org_type, code='akc', name='АКС', extra_data={'mail_prefix': 'a'}) + cls.tira = Directory.objects.create( + directory_type=org_type, code='tira', name='Тира', extra_data={'mail_prefix': 't'}) + cls.noprefix = Directory.objects.create( + directory_type=org_type, code='xx', name='Без префикса', extra_data={}) + + def _pharmacy(self, org, number, **kw): + return Pharmacy.objects.create( + organization=org, location_region=self.region, + pharmacy_number=number, location_type='pharmacy', **kw) + + def test_build_email_format(self): + self.assertEqual(self._pharmacy(self.aks, 5).build_carbonio_email(), 'a5@aks-apteka.com.ua') + + def test_build_email_uses_org_prefix(self): + self.assertEqual(self._pharmacy(self.tira, 5).build_carbonio_email(), 't5@aks-apteka.com.ua') + + def test_build_email_empty_without_prefix(self): + self.assertEqual(self._pharmacy(self.noprefix, 5).build_carbonio_email(), '') + + def test_autogenerate_on_save_when_enabled(self): + p = self._pharmacy(self.aks, 7, carbonio_sync_enabled=True) + self.assertEqual(p.carbonio_email, 'a7@aks-apteka.com.ua') + + def test_no_autogenerate_when_disabled(self): + self.assertEqual(self._pharmacy(self.aks, 8).carbonio_email, '') + + def test_manual_override_respected(self): + p = self._pharmacy(self.aks, 9, carbonio_sync_enabled=True, + carbonio_email='custom@aks-apteka.com.ua') + self.assertEqual(p.carbonio_email, 'custom@aks-apteka.com.ua') + + def test_uniqueness_rejected_in_clean(self): + self._pharmacy(self.aks, 10, carbonio_sync_enabled=True) # a10@ + p2 = self._pharmacy(self.tira, 11) + p2.carbonio_email = 'a10@aks-apteka.com.ua' + with self.assertRaises(ValidationError): + p2.clean() + + def test_uniqueness_enforced_by_db(self): + self._pharmacy(self.aks, 12, carbonio_sync_enabled=True) # a12@ + with self.assertRaises(IntegrityError): + with transaction.atomic(): + self._pharmacy(self.tira, 13, carbonio_email='a12@aks-apteka.com.ua') + + def test_autogenerate_skips_on_collision(self): + # ручной a15@ занят другой аптекой -> автоген для АКС №15 оставляет пусто + self._pharmacy(self.tira, 99, carbonio_sync_enabled=True, + carbonio_email='a15@aks-apteka.com.ua') + p = self._pharmacy(self.aks, 15, carbonio_sync_enabled=True) + self.assertEqual(p.carbonio_email, '') + + def test_blank_emails_do_not_clash(self): + # несколько аптек без адреса — частичный уникальный индекс их не блокирует + self._pharmacy(self.aks, 16) + self._pharmacy(self.tira, 17) + self.assertEqual(Pharmacy.objects.filter(carbonio_email='').count(), 2)