f4737155c5
Выбор в журнале работ и код объекта в it_monthly; сид Вайтер Юг. Co-authored-by: Cursor <cursoragent@cursor.com>
307 lines
13 KiB
Python
307 lines
13 KiB
Python
from django.db import models
|
||
from core.models import BaseModel
|
||
from django.core.exceptions import ValidationError
|
||
from django.utils.translation import gettext_lazy as _
|
||
from location_field.models.plain import PlainLocationField
|
||
import re
|
||
|
||
# Буква объекта IT-журнала, если у организации нет mail_prefix в extra_data.
|
||
_ORG_LETTER_BY_NAME = (
|
||
(re.compile(r'пентальф', re.I), 'п'),
|
||
(re.compile(r'тира|тіра', re.I), 'т'),
|
||
(re.compile(r'акс', re.I), 'а'), # ПП АКС / ТОВ АКС Одесса → А
|
||
(re.compile(r'гіппократ|гиппократ', re.I), 'г'),
|
||
(re.compile(r'лісковськ|лисковск', re.I), 'л'),
|
||
)
|
||
|
||
_LAT_TO_CYR = str.maketrans('APTGL', 'АПТГЛ')
|
||
_CYR_TO_LAT = str.maketrans('АПТГЛ', 'APTGL')
|
||
|
||
|
||
def organization_object_letter(organization) -> str:
|
||
"""Одна буква кода аптеки: а/п/т… из mail_prefix или по названию юрлица."""
|
||
if organization is None:
|
||
return ''
|
||
extra = organization.extra_data
|
||
prefix = ''
|
||
if isinstance(extra, dict):
|
||
prefix = (extra.get('mail_prefix') or '').strip()
|
||
if prefix:
|
||
letter = prefix[0].lower()
|
||
return letter.translate(str.maketrans('aptgl', 'аптгл'))
|
||
name = organization.name or ''
|
||
for pattern, letter in _ORG_LETTER_BY_NAME:
|
||
if pattern.search(name):
|
||
return letter
|
||
return ''
|
||
|
||
|
||
def pharmacy_object_code(pharmacy) -> str:
|
||
"""Код объекта как в IT-журналах: А12, П10, Т5."""
|
||
if pharmacy is None or pharmacy.pharmacy_number is None:
|
||
return ''
|
||
letter = organization_object_letter(pharmacy.organization)
|
||
if not letter:
|
||
return f'№{pharmacy.pharmacy_number}'
|
||
letter = letter.upper().translate(_LAT_TO_CYR)
|
||
if letter not in 'АПТГЛ':
|
||
letter = letter[0].upper()
|
||
return f'{letter}{pharmacy.pharmacy_number}'
|
||
|
||
|
||
def pharmacy_search_text(pharmacy) -> str:
|
||
"""Строка для поиска в searchable-select: А3, A3, организация, номер, адрес."""
|
||
if pharmacy is None:
|
||
return ''
|
||
parts = []
|
||
code = pharmacy_object_code(pharmacy)
|
||
if code:
|
||
parts.append(code)
|
||
parts.append(code.translate(_CYR_TO_LAT))
|
||
parts.append(code.lower())
|
||
parts.append(code.translate(_CYR_TO_LAT).lower())
|
||
if pharmacy.pharmacy_number is not None:
|
||
parts.append(str(pharmacy.pharmacy_number))
|
||
org = pharmacy.organization
|
||
if org is not None and org.name:
|
||
parts.append(org.name)
|
||
addr = pharmacy.get_full_address() or pharmacy.address or ''
|
||
if addr:
|
||
parts.append(addr)
|
||
return ' '.join(parts)
|
||
|
||
|
||
# Порядок юрлиц в селектах IT: А…, П…, Т…, Г…, Л…, прочие
|
||
_OBJECT_LETTER_ORDER = {'а': 0, 'п': 1, 'т': 2, 'г': 3, 'л': 4}
|
||
_OBJECT_CODE_RE = re.compile(r'^([АAПPТTГGЛL])(\d+)$', re.I)
|
||
|
||
|
||
def location_sort_key(location) -> tuple:
|
||
"""Сортировка локаций: офис/склад/сервис, затем аптеки по коду А1…А70, П1…"""
|
||
loc_type = getattr(location, 'location_type', '') or ''
|
||
if loc_type == 'office':
|
||
return (0, 0, 0, (getattr(location, 'name', None) or getattr(location, 'address', None) or '').casefold())
|
||
if loc_type == 'warehouse':
|
||
return (0, 1, 0, (getattr(location, 'name', None) or getattr(location, 'address', None) or '').casefold())
|
||
if loc_type == 'vendor':
|
||
return (0, 2, 0, (getattr(location, 'name', None) or getattr(location, 'address', None) or '').casefold())
|
||
|
||
pharmacy = None
|
||
try:
|
||
pharmacy = location.pharmacy
|
||
except Exception:
|
||
pharmacy = None
|
||
code = pharmacy_object_code(pharmacy) if pharmacy else ''
|
||
m = _OBJECT_CODE_RE.match(code)
|
||
if m:
|
||
letter = m.group(1).upper().translate(_LAT_TO_CYR).lower()
|
||
return (1, _OBJECT_LETTER_ORDER.get(letter, 9), int(m.group(2)), code)
|
||
if code.startswith('№'):
|
||
try:
|
||
return (1, 8, int(code[1:]), code)
|
||
except ValueError:
|
||
pass
|
||
return (1, 9, 0, (getattr(location, 'address', None) or code or '').casefold())
|
||
|
||
|
||
class LocationManager(models.Manager):
|
||
|
||
def active(self):
|
||
return self.filter(is_active=True)
|
||
|
||
def by_type(self, location_type_code):
|
||
return self.filter(location_type=location_type_code, is_active=True)
|
||
|
||
def by_region(self, region_id):
|
||
return self.filter(location_region_id=region_id, is_active=True)
|
||
|
||
def with_coordinates(self):
|
||
return self.exclude(location='')
|
||
|
||
|
||
class Location(BaseModel):
|
||
"""
|
||
Базовая географическая точка.
|
||
Pharmacy и Warehouse наследуются отсюда через multi-table inheritance.
|
||
"""
|
||
location_type = models.CharField(_('Тип локации'), max_length=20, choices=[
|
||
('pharmacy', _('Аптека')),
|
||
('warehouse', _('Склад')),
|
||
('office', _('Офис')),
|
||
('vendor', _('Сервис / поставщик')),
|
||
])
|
||
location_region = models.ForeignKey(
|
||
'core.Directory', on_delete=models.PROTECT,
|
||
limit_choices_to={'directory_type__code__in': ['region', 'district','city', 'city_district'], 'is_active': True},
|
||
related_name='region_locations', verbose_name=_('Город/район')
|
||
)
|
||
|
||
name = models.CharField(
|
||
_('Название'), max_length=150, blank=True,
|
||
help_text=_('Для сервиса/поставщика (Вайтер Юг и т.п.) и при необходимости офиса/склада'),
|
||
)
|
||
street = models.CharField(_('Улица'), max_length=100, blank=True)
|
||
house_number = models.CharField(_('Номер дома'), max_length=20, blank=True)
|
||
building = models.CharField(_('Корпус/строение'), max_length=20, blank=True)
|
||
apartment = models.CharField(_('Помещение/офис'), max_length=20, blank=True)
|
||
address = models.CharField(_('Полный адрес'), max_length=255, blank=True,
|
||
help_text=_('Заполняется автоматически из полей выше'))
|
||
|
||
# --- Координаты (django-location-field: "широта,долгота") ---
|
||
# Центр по умолчанию: Одесса (46.4825°N, 30.7233°E)
|
||
# based_fields — саме поля, які реально редагує людина (street/house_number/building),
|
||
# а НЕ address: той тепер лише читається (генерується в save()), тому сам по собі
|
||
# ніколи не змінюється користувачем і не міг би тригернути live-геокодування карти
|
||
location = PlainLocationField(
|
||
based_fields=['street', 'house_number', 'building'],
|
||
zoom=14,
|
||
verbose_name=_('Координаты на карте'),
|
||
blank=True,
|
||
default='46.4825,30.7233',
|
||
)
|
||
|
||
is_active = models.BooleanField(_('Активна'), default=True)
|
||
comments = models.TextField(_('Комментарии'), blank=True)
|
||
|
||
objects = LocationManager()
|
||
|
||
class Meta:
|
||
permissions = [('view_all_locations', 'Доступ к данным всех локаций портала'), ('manage_location_access', 'Назначение ответственных за локации')]
|
||
verbose_name = _('Локация')
|
||
verbose_name_plural = _('Локации')
|
||
indexes = [
|
||
# models.Index(fields=['location_type', 'is_active']),
|
||
# models.Index(fields=['location_region', 'is_active']),
|
||
models.Index(fields=['location']),
|
||
]
|
||
|
||
def __str__(self):
|
||
try:
|
||
return self._display_label()
|
||
except Exception:
|
||
return f'Локация #{self.pk}'
|
||
|
||
def _display_label(self) -> str:
|
||
addr = self.get_full_address() or self.address
|
||
title = (self.name or '').strip()
|
||
if self.location_type == 'pharmacy':
|
||
# Номер без організації неоднозначний (різні підприємства можуть мати №12).
|
||
try:
|
||
pharmacy = self.pharmacy
|
||
except Exception:
|
||
pharmacy = None
|
||
if pharmacy:
|
||
org = pharmacy.organization
|
||
org_name = org.name if org is not None else ''
|
||
code = pharmacy_object_code(pharmacy)
|
||
if code and org_name:
|
||
head = f'{code} ({org_name})'
|
||
elif code:
|
||
head = code
|
||
elif org_name:
|
||
head = f'{org_name} №{pharmacy.pharmacy_number}'
|
||
else:
|
||
head = f'Аптека №{pharmacy.pharmacy_number}'
|
||
status = getattr(pharmacy, 'status', 'open') or 'open'
|
||
if status != 'open':
|
||
try:
|
||
status_label = pharmacy.get_status_display()
|
||
except Exception:
|
||
status_label = status
|
||
head = f'{head} [{status_label}]'
|
||
return f'{head} — {addr}' if addr else head
|
||
if self.location_type == 'vendor':
|
||
head = title or self.get_location_type_display()
|
||
return f'{head} — {addr}' if addr and addr != head else head
|
||
type_label = self.get_location_type_display()
|
||
if title:
|
||
head = f'{type_label} — {title}'
|
||
return f'{head} — {addr}' if addr and addr != title else head
|
||
return f'{type_label} — {addr}' if addr else type_label
|
||
|
||
@property
|
||
def search_text(self) -> str:
|
||
"""Текст для searchable-select (коды А3/A3, П10/P10, организация…)."""
|
||
try:
|
||
if self.location_type == 'pharmacy':
|
||
try:
|
||
pharmacy = self.pharmacy
|
||
except Exception:
|
||
pharmacy = None
|
||
if pharmacy:
|
||
text = pharmacy_search_text(pharmacy)
|
||
status = getattr(pharmacy, 'status', 'open') or 'open'
|
||
if status != 'open':
|
||
try:
|
||
text = f'{text} {pharmacy.get_status_display()} {status}'
|
||
except Exception:
|
||
text = f'{text} {status}'
|
||
return text
|
||
parts = [self.name or '', self._display_label(), self.address or '']
|
||
return ' '.join(p for p in parts if p)
|
||
except Exception:
|
||
return str(self.pk or '')
|
||
|
||
def save(self, *args, **kwargs):
|
||
# "Полный адрес" — поле лише для читання (help_text це й обіцяв, але ніхто
|
||
# ніколи насправді не проставляв значення) — перераховуємо з частин щоразу
|
||
self.address = self.get_full_address()
|
||
if not self.address and (self.name or '').strip():
|
||
self.address = self.name.strip()
|
||
super().save(*args, **kwargs)
|
||
|
||
def clean(self):
|
||
super().clean()
|
||
lat, lon = self.latitude, self.longitude
|
||
if lat is not None and not (-90 <= lat <= 90):
|
||
raise ValidationError({'location': 'Широта должна быть от -90 до 90'})
|
||
if lon is not None and not (-180 <= lon <= 180):
|
||
raise ValidationError({'location': 'Долгота должна быть от -180 до 180'})
|
||
|
||
def get_full_address(self):
|
||
parts = []
|
||
# if self.location_region:
|
||
# parts.append(self.location_region.name)
|
||
if self.street:
|
||
parts.append(f"ул. {self.street}")
|
||
if self.house_number:
|
||
parts.append(f"д. {self.house_number}")
|
||
if self.building:
|
||
parts.append(f"корп. {self.building}")
|
||
if self.apartment:
|
||
parts.append(f"пом. {self.apartment}")
|
||
return ", ".join(parts) if parts else self.address
|
||
|
||
@property
|
||
def latitude(self):
|
||
"""Широта из location (для обратной совместимости)."""
|
||
if not self.location:
|
||
return None
|
||
parts = str(self.location).split(',')
|
||
if len(parts) >= 2:
|
||
try:
|
||
return float(parts[0].strip())
|
||
except (ValueError, TypeError):
|
||
pass
|
||
return None
|
||
|
||
@property
|
||
def longitude(self):
|
||
"""Долгота из location (для обратной совместимости)."""
|
||
if not self.location:
|
||
return None
|
||
parts = str(self.location).split(',')
|
||
if len(parts) >= 2:
|
||
try:
|
||
return float(parts[1].strip())
|
||
except (ValueError, TypeError):
|
||
pass
|
||
return None
|
||
|
||
@property
|
||
def has_coordinates(self):
|
||
"""True, лише якщо координати реально проставлені — не просто дефолт поля
|
||
(PlainLocationField.default непорожній, тож голий bool() завжди був True)."""
|
||
default = self._meta.get_field('location').default
|
||
return bool(self.location) and self.location != default
|