01f0c6f002
Вариант А — пароль ящика хранится обратимо-зашифрованным, чтобы показать заведующему для входа: - Pharmacy.carbonio_password (EncryptedCharField) — шифруется в БД - sync_service при создании ящика генерирует пароль, отдаёт его в Carbonio и сохраняет свою зашифрованную копию; на update-пути пароль не трогается - Пароль виден в админке аптеки (readonly, расшифрованный) — вкладка Carbonio - 2 теста (мок CarbonioService): пароль сохранён, совпадает с отданным в Carbonio, в БД лежит шифртекстом; update не перетирает пароль Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
364 lines
17 KiB
Python
364 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 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 = 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)
|
|
# Пароль ящика хранится зашифрованным (обратимо), чтобы показать заведующему
|
|
# для входа. 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 организации><номер>@домен.
|
|
Пусто, если у организации не задан 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']
|