Files
ACORE/apps/locations/models/pharmacies.py
T
lokses 3cbc019268 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>
2026-07-23 19:55:15 +03:00

357 lines
17 KiB
Python

from django.db import models
from django.conf import settings
from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _
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):
# Location.location_type — CharField
return super().get_queryset().filter(location_type='pharmacy')
def active(self):
return self.get_queryset().filter(is_active=True)
def by_organization(self, organization_id):
return self.get_queryset().filter(
organization_id=organization_id, is_active=True
)
# def by_format(self, format_code):
# return self.get_queryset().filter(
# pharmacy_format__code=format_code, is_active=True
# )
def by_region(self, region_id):
return self.get_queryset().filter(
location_region_id=region_id, is_active=True
)
class Pharmacy(Location):
"""
Аптека — расширение Location через multi-table inheritance.
Все общие поля (адрес, координаты, регион) живут в Location.
"""
objects = PharmacyManager()
STATUS_CHOICES = [
('open', _('Открыта')),
('closed', _('Закрыта')),
('suspended', _('Остановлена')),
('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(
_('Номер аптеки'),
help_text=_('Числовой номер аптеки внутри организации')
)
organization = models.ForeignKey(
'core.Directory', on_delete=models.PROTECT,
limit_choices_to={'directory_type__code': 'organization', 'is_active': True},
related_name='pharmacies', verbose_name=_('Организация'),
help_text=_('Юрлицо: АКС, АКС Одесса, Тира, Пентальфа...')
)
# pharmacy_format = models.ForeignKey(
# 'core.Directory', on_delete=models.PROTECT,
# limit_choices_to={'directory_type__code': 'pharmacy_format', 'is_active': True},
# related_name='pharmacy_format_set', verbose_name='Формат аптеки',
# null=True, blank=True
# )
# --- Лицензия ---
license_number = models.CharField(_('Номер лицензии'), max_length=50,
blank=True, null=True)
license_expiry_date = models.DateField(_('Дата окончания лицензии'),
blank=True, null=True)
# --- Персонал ---
head_pharmacist = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.SET_NULL,
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=_('Кешированное поле — обновляется при синхронизации')
)
status = models.CharField(
_('Статус'), max_length=20,
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,
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,
# limit_choices_to={'directory_type__code': 'pharmacy_service', 'is_active': True},
# related_name='pharmacies_with_service', verbose_name='Услуги',
# help_text='Измерение давления, тест на глюкозу, дежурная аптека...'
# )
# --- Онлайн ---
# has_online_ordering = models.BooleanField(_('Онлайн заказы'), default=True)
# sync_url = models.URLField(_('URL локального сервера'), blank=True,
# help_text=_('Адрес PPOS: http://192.168.1.10:8000'))
# sync_token = models.CharField(
# _('Токен синхронизации'), max_length=255, blank=True,
# help_text=_('Генерируется автоматически при сохранении, если пусто')
# )
# last_synced_at = models.DateTimeField(_('Последняя синхронизация'), null=True, blank=True,
# help_text=_('Заполняется при первой синхронизации с PPOS'))
# # --- Mattermost ---
# mattermost_channel = models.ForeignKey(
# 'mattermost_connector.MattermostChannel', on_delete=models.SET_NULL,
# null=True, blank=True,
# related_name='pharmacy', verbose_name='Канал Mattermost'
# )
# mattermost_auto_create_channel = models.BooleanField(
# 'Автосоздание канала', default=True
# )
class Meta:
verbose_name = _('Аптека')
verbose_name_plural = _('Аптеки')
ordering = ['organization', 'pharmacy_number']
indexes = [
models.Index(fields=['pharmacy_number']),
models.Index(fields=['organization']),
# models.Index(fields=['pharmacy_format']),
models.Index(fields=['head_pharmacist']),
# models.Index(fields=['last_synced_at']),
]
constraints = [
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):
org = self.organization.name if self.organization else ''
return f"{org} Аптека №{self.pharmacy_number}: {self.address}"
def clean(self):
super().clean()
if self.license_expiry_date and self.license_expiry_date < timezone.now().date():
raise ValidationError({'license_expiry_date': 'Лицензия истекла'})
if self.pharmacists_count < 1:
raise ValidationError({'pharmacists_count': 'Минимум 1 фармацевт'})
if self.head_pharmacist and hasattr(self.head_pharmacist, 'post') and self.head_pharmacist.post:
if getattr(self.head_pharmacist.post, 'code', None) != 'pharmacy_manager':
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': 'Сотрудник должен иметь должность «Відповідальний менеджер»'
})
# Уникальность адреса 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:
self.location_type = 'pharmacy'
# is_active — не отдельное ручное поле для аптек, а производное от
# 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
def is_license_valid(self):
if not self.license_expiry_date:
return None
return self.license_expiry_date >= timezone.now().date()
@property
def license_status(self):
if not self.license_number:
return 'Нет лицензии'
if not self.license_expiry_date:
return 'Бессрочная'
if self.is_license_valid:
days_left = (self.license_expiry_date - timezone.now().date()).days
if days_left <= 30:
return f'Истекает через {days_left} дн.'
return 'Действительна'
return 'Истекла'
def get_employees(self):
"""Сотрудники аптеки."""
from django.contrib.auth import get_user_model
return get_user_model().objects.filter(user_pharmacy=self, is_active=True)
@property
def employees_count(self):
return self.get_employees().count()
class PharmacyPhone(models.Model):
"""Множественные телефоны аптеки"""
PHONE_TYPES = [
('main', _('Основной')),
('mobile', _('Мобильный')),
]
pharmacy = models.ForeignKey(Pharmacy, on_delete=models.CASCADE,
related_name='phones', verbose_name='Аптека')
phone = models.CharField(_('Телефон'), max_length=20,
validators=[validate_ukrainian_phone])
phone_type = models.CharField(_('Тип'), max_length=10, choices=PHONE_TYPES, default='main')
is_primary = models.BooleanField(_('Основной номер'), default=False)
is_active = models.BooleanField(_('Активен'), default=True)
notes = models.CharField(_('Примечания'), max_length=200, blank=True)
class Meta:
verbose_name = _('Телефон аптеки')
verbose_name_plural = _('Телефоны аптек')
ordering = ['-is_primary', 'phone_type']
def __str__(self):
return f"{self.pharmacy} — {self.phone}"
class PharmacyHistory(models.Model):
"""Аудит изменений полей аптеки"""
pharmacy = models.ForeignKey(Pharmacy, on_delete=models.CASCADE,
related_name='history', verbose_name=_('Аптека'))
changed_field = models.CharField(_('Изменённое поле'), max_length=50)
old_value = models.TextField(_('Старое значение'), blank=True)
new_value = models.TextField(_('Новое значение'), blank=True)
changed_at = models.DateTimeField(_('Дата изменения'), auto_now_add=True)
changed_by = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True,
verbose_name=_('Кто изменил')
# ← FK вместо CharField из оригинала
)
class Meta:
verbose_name = _('История изменений аптеки')
verbose_name_plural = _('История изменений аптек')
ordering = ['-changed_at']