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:
+50
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user