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
+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