Files
ACORE/apps/locations/models/base.py
T
lokses f4737155c5 locations: тип «Сервис / поставщик» для Вайтер Юг и аналогов.
Выбор в журнале работ и код объекта в it_monthly; сид Вайтер Юг.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 15:56:19 +03:00

307 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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