Carbonio CE: почтовый ящик аптеки — поля, генерация адреса, уникальность

Фундамент интеграции Carbonio на уровне модели Pharmacy (без обращений к серверу):

- Поля Pharmacy: carbonio_email, carbonio_account_id, carbonio_sync_enabled
  (default False), carbonio_sync_status, carbonio_last_sync, carbonio_sync_error
- Генерация адреса <mail_prefix организации><номер>@домен (a1@, t1@, p1@...);
  префикс из Directory.extra_data['mail_prefix'], домен из ConnectorConfig(carbonio)
  с дефолтом aks-apteka.com.ua
- Ручной override адреса не перетирается автогенерацией
- Уникальность carbonio_email: clean() + частичный unique-констрейнт в БД
  (пустые не конфликтуют); автоген при коллизии оставляет поле пустым
- 10 тестов (apps.locations.tests) — у приложения было 0

API-слой (клиент/sync/сигналы/задачи) — следующим шагом.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-23 19:55:15 +03:00
parent 4f3fccb68d
commit 3cbc019268
3 changed files with 198 additions and 2 deletions
@@ -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'),
),
]
+75 -1
View File
@@ -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 организации><номер>@домен.
Пусто, если у организации не задан 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
+73 -1
View File
@@ -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)