from django.db import models from django.utils import timezone from django.core.exceptions import ValidationError from django.utils.translation import gettext_lazy as _ from core.models import BaseModel from core.models.fields import EncryptedCharField from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin, BaseUserManager from core.utils.validators import validate_ukrainian_phone, validate_birth_date, validate_ukrainian_tax_number from core.utils.pagination import PAGE_SIZE_CHOICES, DEFAULT_PAGE_SIZE class EmployeeManager(BaseUserManager): def create_user(self, email=None, password=None, **extra_fields): if not email: username = extra_fields.get('username') if username: email = f"{username}@local.domain" else: raise ValueError('Необходимо указать email или username') email = self.normalize_email(email) user = self.model(email=email, **extra_fields) user.set_password(password) user.save(using=self._db) return user def create_superuser(self, email=None, password=None, **extra_fields): extra_fields.setdefault('is_staff', True) extra_fields.setdefault('is_superuser', True) extra_fields.setdefault('is_active', True) return self.create_user(email, password, **extra_fields) class Employee(AbstractBaseUser, PermissionsMixin, BaseModel): username = models.CharField( _('Логин'), max_length=150, unique=True, help_text=_('Уникальный логин для входа в систему') ) email = models.EmailField( _('Email адрес'), unique=True, blank=True, null=True, help_text=_('Рабочий email. Если не указан — генерируется из username@local.domain') ) last_name = models.CharField(_('Фамилия'), max_length=150) first_name = models.CharField(_('Имя'), max_length=150) middle_name = models.CharField(_('Отчество'), max_length=150, blank=True) is_staff = models.BooleanField(_('Доступ к панели администратора'), default=False, help_text=_('Разрешает вход в Django admin. Только для IT-администраторов системы.')) is_active = models.BooleanField(_('Активный'), default=True, help_text=_('Работает ли сотрудник сейчас')) date_joined = models.DateTimeField(_('Дата создания аккаунта'), default=timezone.now) birth_date = models.DateField( _('Дата рождения'), null=True, blank=True, validators=[validate_birth_date] ) inn_code = models.CharField( _('ИНН'), max_length=10, blank=True, null=True, help_text=_('Идентификационный номер налогоплательщика'), validators=[validate_ukrainian_tax_number] ) phone = models.CharField( _('Рабочий телефон'), max_length=20, blank=True, validators=[validate_ukrainian_phone] ) internal_number = models.CharField( _('Внутрішній номер'), max_length=10, blank=True, help_text=_('Короткий внутрішній (додатковий) номер АТС') ) personal_phone = models.CharField( _('Личный телефон'), max_length=20, blank=True, validators=[validate_ukrainian_phone] ) personal_email = models.EmailField(_('Личный email'), blank=True) emergency_contact_name = models.CharField(_('Контакт в экстренных случаях'), max_length=200, blank=True) emergency_contact_phone = models.CharField(_('Телефон экстренного контакта'), max_length=20, blank=True, validators=[validate_ukrainian_phone]) emergency_contact_relation = models.CharField(_('Степень родства'), max_length=100, blank=True) LOCATION_TYPES = [ ('pharmacy', _('Аптека')), ('warehouse', _('Склад')), ('office', _('Офис')), ] user_location_type = models.CharField( _('Тип локации'), max_length=20, choices=LOCATION_TYPES, help_text=_('Где работает: аптека / склад / офис'), null=True, blank=True ) user_organization = models.ForeignKey( 'core.Directory', on_delete=models.SET_NULL, null=True, blank=True, limit_choices_to={'directory_type__code': 'organization', 'is_active': True}, related_name='organization_employees', verbose_name=_('Организация'), help_text=_('Юрлицо: АКС, АКС Одесса, Тира, Пентальфа...') ) user_pharmacy = models.ForeignKey( 'locations.Pharmacy', on_delete=models.SET_NULL, null=True, blank=True, related_name='pharmacy_employees', verbose_name=_('Аптека'), help_text=_('Заполняется только для location_type=pharmacy') ) user_location = models.ForeignKey( 'locations.Location', on_delete=models.SET_NULL, null=True, blank=True, related_name='location_employees', verbose_name=_('Локация (офис/склад)'), help_text=_('Заполняется для location_type=office или warehouse') ) department = models.ForeignKey( 'core.Directory', on_delete=models.SET_NULL, limit_choices_to={'directory_type__code': 'department', 'is_active': True}, related_name='department_employees', verbose_name=_('Отдел'), null=True, blank=True, help_text=_('Заполняется для office / warehouse') ) post = models.ForeignKey( 'core.Directory', on_delete=models.SET_NULL, limit_choices_to={'directory_type__code': 'post', 'is_active': True}, related_name='post_employees', verbose_name=_('Должность'), null=True, blank=True ) manager = models.ForeignKey( 'self', on_delete=models.SET_NULL, null=True, blank=True, related_name='subordinates', verbose_name=_('Руководитель') ) hire_date = models.DateField(_('Дата приёма на работу')) termination_date = models.DateField(_('Дата увольнения'), null=True, blank=True) probation_end_date = models.DateField(_('Конец испытательного срока'), null=True, blank=True) photo = models.ImageField( _('Фото'), upload_to='employees/photos/%Y/%m/', blank=True, null=True ) mattermost_user_id = models.CharField(_('ID Mattermost'), max_length=50, blank=True, null=True) mattermost_username = models.CharField(_('Логин Mattermost'), max_length=50, blank=True, null=True) mattermost_sync_status = models.CharField( _('Статус синхронизации Mattermost'), max_length=20, choices=[ ('pending', _('Ожидает')), ('synced', _('Синхронизирован')), ('error', _('Ошибка')), ('disabled', _('Отключена')), ], default='pending' ) mattermost_last_sync = models.DateTimeField(_('Последняя синхронизация Mattermost'), null=True, blank=True) mattermost_sync_enabled = models.BooleanField(_('Синхронизация Mattermost'), default=True) ad_user_dn = models.CharField('AD User DN', max_length=500, blank=True, help_text='CN=Ivanov Ivan,OU=Users,DC=company,DC=local') ad_sync_status = models.CharField( _('Статус синхронизации AD'), max_length=20, choices=[ ('not_synced', _('Не синхронизирован')), ('synced', _('Синхронизирован')), ('error', _('Ошибка')), ('disabled', _('Отключена')), ], default='not_synced' ) ad_sync_error = models.TextField(_('Ошибка синхронизации AD'), blank=True) ad_last_sync = models.DateTimeField(_('Последняя синхронизация AD'), null=True, blank=True) ad_sync_enabled = models.BooleanField(_('Синхронизация AD'), default=False, help_text=_('Только для офисных сотрудников')) IT_WORK_CHANNEL_CHOICES = [ ('', _('— не задано (авто из роли IT-отчёта)')), ('remote', _('Удалённо (без выезда)')), ('field', _('С выездом')), ('office', _('Офис')), ('infra', _('Инфраструктура / руководство')), ] it_work_channel = models.CharField( _('Канал работ (IT-отчёт)'), max_length=16, choices=IT_WORK_CHANNEL_CHOICES, blank=True, default='', help_text=_( 'Для месячного IT-отчёта: как учитывать задачи сотрудника ' '(удалённо / с выездом / офис / инфра). ' 'Объект заявки (аптека А35…) сам по себе выездом не считается.' ), ) default_page_size = models.PositiveSmallIntegerField( _('Записей на странице'), choices=[(n, str(n)) for n in PAGE_SIZE_CHOICES], default=DEFAULT_PAGE_SIZE, help_text=_('Сколько строк показывать в таблицах портала по умолчанию'), ) dislocation_columns = models.JSONField( _('Колонки дислокации'), null=True, blank=True, help_text=_( 'Персональный набор колонок таблицы /dislocation/. ' 'Пусто — пресет отдела или системный «Стандарт».' ), ) last_seen = models.DateTimeField( _('Был онлайн'), null=True, blank=True, help_text=_('Обновляется автоматически при активности в портале (не в AD/Mattermost)'), ) # --- Двухфакторная аутентификация (TOTP) --- totp_secret = EncryptedCharField( _('TOTP-секрет'), max_length=255, blank=True, help_text=_('Секрет TOTP (шифруется). Заполняется при настройке 2FA.'), ) totp_enabled = models.BooleanField(_('2FA включена'), default=False) totp_backup_codes = models.JSONField( _('Резервные коды 2FA (хеши)'), default=list, blank=True, help_text=_('Хеши одноразовых резервных кодов; использованный код удаляется.'), ) objects = EmployeeManager() USERNAME_FIELD = 'username' REQUIRED_FIELDS = ['first_name', 'last_name', 'hire_date'] class Meta: verbose_name = _('Сотрудник') verbose_name_plural = _('Сотрудники') indexes = [ models.Index(fields=['last_name', 'first_name', 'middle_name']), models.Index(fields=['email']), models.Index(fields=['is_active']), ] def __str__(self): if self.middle_name: return f"{self.last_name} {self.first_name} {self.middle_name}" return f"{self.last_name} {self.first_name}" def save(self, *args, **kwargs): if not self.email and self.username: self.email = f"{self.username}@local.domain" super().save(*args, **kwargs) def clean(self): super().clean() if self.user_location_type: loc = self.user_location_type if loc == 'pharmacy': if not self.user_pharmacy_id: raise ValidationError({'user_pharmacy': 'Для фармацевта обязательно укажите аптеку'}) if self.department_id: raise ValidationError({'department': 'Для фармацевта отдел не указывается'}) if self.user_location_id: raise ValidationError({'user_location': 'Локация (офис/склад) — только для office/warehouse'}) elif loc in ['office', 'warehouse']: if not self.department_id: raise ValidationError({'department': f'Для сотрудника {loc} обязателен отдел'}) if not self.user_location_id: raise ValidationError({'user_location': 'Укажите конкретную локацию (офис или склад)'}) if self.user_location_id and self.user_location.location_type != loc: raise ValidationError({'user_location': f'Локация должна быть типа «{self.get_user_location_type_display()}»'}) if self.user_pharmacy_id: raise ValidationError({'user_pharmacy': 'Аптека только для фармацевтов'}) if self.post_id and self.user_location_type: if self.post.location_types.exists(): if not self.post.location_types.filter(code=self.user_location_type).exists(): names = ', '.join(lt.name for lt in self.post.location_types.all()) raise ValidationError({'post': f'Должность доступна только для: {names}'}) if self.termination_date and self.hire_date: if self.termination_date < self.hire_date: raise ValidationError({'termination_date': 'Не может быть раньше даты приёма'}) if self.manager_id and self.manager_id == self.pk: raise ValidationError({'manager': 'Сотрудник не может быть своим руководителем'}) @property def short_name(self): s = self.last_name if self.first_name: s += f" {self.first_name[0]}." if self.middle_name: s += f"{self.middle_name[0]}." return s @property def age(self): if not self.birth_date: return None today = timezone.now().date() age = today.year - self.birth_date.year if today < self.birth_date.replace(year=today.year): age -= 1 return age @property def work_experience(self): if not self.hire_date: return 0 end = self.termination_date or timezone.now().date() exp = end.year - self.hire_date.year if end < self.hire_date.replace(year=end.year): exp -= 1 return max(0, exp) @property def is_on_probation(self): if not self.probation_end_date: return False return timezone.now().date() <= self.probation_end_date @property def is_manager(self): return self.subordinates.exists() @property def subordinate_count(self): return self.subordinates.filter(is_active=True).count() def get_manager_chain(self): managers = [] current = self.manager while current and len(managers) < 10: managers.append(current) current = current.manager return managers def get_full_name(self): return self.__str__() def get_short_name(self): return self.short_name def has_perm(self, perm, obj=None): today = timezone.now().date() codename = perm.split('.')[-1] override = self.permission_overrides.filter( permission__codename=codename, is_active=True, ).filter( models.Q(valid_from__isnull=True) | models.Q(valid_from__lte=today) ).filter( models.Q(valid_until__isnull=True) | models.Q(valid_until__gte=today) ).values_list('override_type', flat=True).first() if override == 'revoke': return False if override == 'grant': return True return super().has_perm(perm, obj)