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.models.fields import UkrainianPhoneField from core.models.fields import EncryptedCharField 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 = UkrainianPhoneField(_('Телефон'), blank=True) 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) # Пароль ящика хранится зашифрованным (обратимо), чтобы показать заведующему # для входа. max_length с запасом под шифртекст. carbonio_password = EncryptedCharField( _('Пароль ящика Carbonio'), max_length=512, blank=True, help_text=_('Хранится зашифрованным; показывается для входа заведующему.'), ) 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 (в 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()}' # Поля, по которым пишется PharmacyHistory. Служебные (пароль Carbonio, # статусы синка, timestamps) специально не трекаем. HISTORY_FIELDS = ( 'pharmacy_number', 'organization', 'license_number', 'license_expiry_date', 'head_pharmacist', 'responsible_manager', 'curator', 'pharmacists_count', 'status', 'category', 'vat_category', 'phone', 'working_hours', 'carbonio_email', 'carbonio_sync_enabled', 'street', 'house_number', 'building', 'apartment', 'address', 'location', 'location_region', 'is_active', 'comments', ) 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): update_fields = kwargs.get('update_fields') old = _pharmacy_snapshot(self, update_fields) 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) if old is not None: _record_pharmacy_history(self, old, update_fields) @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 = UkrainianPhoneField(_('Телефон')) 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.save().""" 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'] def _history_actor(instance): user = getattr(instance, '_history_user', None) if user is not None and getattr(user, 'is_authenticated', False) and getattr(user, 'pk', None): return user return None def _history_should_check(update_fields, history_fields): if update_fields is None: return True update_set = set(update_fields) return any(f in update_set or f'{f}_id' in update_set for f in history_fields) def _pharmacy_snapshot(pharmacy, update_fields): if not pharmacy.pk or not _history_should_check(update_fields, pharmacy.HISTORY_FIELDS): return None return Pharmacy.objects.filter(pk=pharmacy.pk).first() def _field_identity(instance, field_name): field = instance._meta.get_field(field_name) if field.is_relation and not field.many_to_many: return getattr(instance, field.attname) value = getattr(instance, field_name) return '' if value is None else value def _field_display(instance, field_name): field = instance._meta.get_field(field_name) if field.choices: display = getattr(instance, f'get_{field_name}_display')() return '' if display is None else str(display) value = getattr(instance, field_name) return '' if value is None else str(value) def _record_pharmacy_history(pharmacy, old, update_fields=None): fields = pharmacy.HISTORY_FIELDS if update_fields is not None: update_set = set(update_fields) fields = tuple( f for f in fields if f in update_set or f'{f}_id' in update_set ) user = _history_actor(pharmacy) rows = [] for field_name in fields: if _field_identity(old, field_name) == _field_identity(pharmacy, field_name): continue rows.append(PharmacyHistory( pharmacy=pharmacy, changed_field=field_name, old_value=_field_display(old, field_name), new_value=_field_display(pharmacy, field_name), changed_by=user, )) if rows: PharmacyHistory.objects.bulk_create(rows)