locations: тип «Сервис / поставщик» для Вайтер Юг и аналогов.

Выбор в журнале работ и код объекта в it_monthly; сид Вайтер Юг.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-07 15:56:19 +03:00
parent c78c1de2a1
commit f4737155c5
6 changed files with 116 additions and 8 deletions
+9
View File
@@ -131,6 +131,7 @@ class LocationResolver:
self._by_num: dict[int, list[Location]] = {}
self._office: Location | None = None
self._warehouse: Location | None = None
self._vendors_by_name: dict[str, Location] = {}
self._built = False
def build(self):
@@ -147,6 +148,11 @@ class LocationResolver:
self._by_num.setdefault(int(num), []).append(pharmacy)
self._office = Location.objects.filter(location_type='office', is_active=True).order_by('id').first()
self._warehouse = Location.objects.filter(location_type='warehouse', is_active=True).order_by('id').first()
self._vendors_by_name = {}
for loc in Location.objects.filter(location_type='vendor', is_active=True):
key = normalize_name(loc.name or '')
if key:
self._vendors_by_name[key] = loc
def resolve(self, object_code: str) -> Location | None:
self.build()
@@ -158,6 +164,9 @@ class LocationResolver:
return self._office
if upper in ('СКЛАД',):
return self._warehouse
vendor = self._vendors_by_name.get(normalize_name(raw))
if vendor is not None:
return vendor
m = OBJ_CODE_RE.fullmatch(raw.replace(' ', ''))
if not m:
return None
+8 -4
View File
@@ -84,12 +84,12 @@ class LocationAdmin(ModelAdmin, ImportExportModelAdmin):
css = {'all': ('locations/css/location_admin.css',)}
js = ('assets/js/admin_leaflet_resize_fix.js',)
list_display = ['address_display', 'location_type', 'location_region',
list_display = ['address_display', 'name', 'location_type', 'location_region',
'coordinates_display', 'is_active', 'updated_at']
list_filter = ['location_type', 'is_active', HasCoordinatesFilter]
search_fields = ['address', 'street', 'house_number', 'comments']
search_fields = ['name', 'address', 'street', 'house_number', 'comments']
list_select_related = ['location_region']
ordering = ['location_region', 'address']
ordering = ['location_type', 'name', 'location_region', 'address']
# address тепер лише генерується в Location.save() з street/house_number/building/
# apartment — ручний ввід сюди все одно перезатреться при збереженні
@@ -97,7 +97,11 @@ class LocationAdmin(ModelAdmin, ImportExportModelAdmin):
fieldsets = (
(_('Тип и регион'), {
'fields': ('location_type', 'location_region', 'is_active'),
'fields': ('location_type', 'name', 'location_region', 'is_active'),
'description': _(
'«Сервис / поставщик» — обслуживающие организации (Вайтер Юг и т.п.): '
'укажите название, адрес по желанию.'
),
}),
(_('Адрес'), {
'fields': ('street', 'house_number', 'building', 'apartment', 'address'),
@@ -0,0 +1,49 @@
# Generated by Django 6.0.3 on 2026-10-07 12:55
from django.db import migrations, models
def seed_waiter_yug(apps, schema_editor):
Location = apps.get_model('locations', 'Location')
Directory = apps.get_model('core', 'Directory')
if Location.objects.filter(location_type='vendor', name__iexact='Вайтер Юг').exists():
return
region = (
Directory.objects.filter(directory_type__code='region', code='od').first()
or Directory.objects.filter(directory_type__code='region').order_by('id').first()
)
if region is None:
return
Location.objects.create(
location_type='vendor',
name='Вайтер Юг',
location_region_id=region.pk,
address='Вайтер Юг',
is_active=True,
)
def unseed_waiter_yug(apps, schema_editor):
Location = apps.get_model('locations', 'Location')
Location.objects.filter(location_type='vendor', name__iexact='Вайтер Юг').delete()
class Migration(migrations.Migration):
dependencies = [
('locations', '0014_alter_location_options_grouplocationaccess'),
]
operations = [
migrations.AddField(
model_name='location',
name='name',
field=models.CharField(blank=True, help_text='Для сервиса/поставщика (Вайтер Юг и т.п.) и при необходимости офиса/склада', max_length=150, verbose_name='Название'),
),
migrations.AlterField(
model_name='location',
name='location_type',
field=models.CharField(choices=[('pharmacy', 'Аптека'), ('warehouse', 'Склад'), ('office', 'Офис'), ('vendor', 'Сервис / поставщик')], max_length=20, verbose_name='Тип локации'),
),
migrations.RunPython(seed_waiter_yug, unseed_waiter_yug),
]
+21 -4
View File
@@ -77,12 +77,14 @@ _OBJECT_CODE_RE = re.compile(r'^([АAПPТTГGЛL])(\d+)$', re.I)
def location_sort_key(location) -> tuple:
"""Сортировка локаций: офис/склад, затем аптеки по коду А1…А70, П1…"""
"""Сортировка локаций: офис/склад/сервис, затем аптеки по коду А1…А70, П1…"""
loc_type = getattr(location, 'location_type', '') or ''
if loc_type == 'office':
return (0, 0, 0, (getattr(location, 'address', None) or '').casefold())
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, 'address', None) or '').casefold())
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:
@@ -126,6 +128,7 @@ class Location(BaseModel):
('pharmacy', _('Аптека')),
('warehouse', _('Склад')),
('office', _('Офис')),
('vendor', _('Сервис / поставщик')),
])
location_region = models.ForeignKey(
'core.Directory', on_delete=models.PROTECT,
@@ -133,6 +136,10 @@ class Location(BaseModel):
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)
@@ -176,6 +183,7 @@ class Location(BaseModel):
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:
@@ -202,7 +210,13 @@ class Location(BaseModel):
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
@@ -223,7 +237,8 @@ class Location(BaseModel):
except Exception:
text = f'{text} {status}'
return text
return self._display_label()
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 '')
@@ -231,6 +246,8 @@ class Location(BaseModel):
# "Полный адрес" — поле лише для читання (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):
+27
View File
@@ -680,3 +680,30 @@ class LocationSortKeyTests(TestCase):
street='Старый', house_number='2', is_active=False,
)
self.assertNotIn(dead_office.pk, ids)
def test_vendor_location_in_select_and_report_code(self):
from apps.it_manager.views import _it_locations_sorted
from apps.locations.models.base import Location
from apps.reports.it_monthly.builder import location_object_code
from core.test_utils import grant_all_locations
from apps.employees.tests import make_employee
region_type, _ = DirectoryType.objects.get_or_create(
code='region', defaults={'name': 'Регион'},
)
region = Directory.objects.create(
directory_type=region_type, code='od-vendor', name='Одесса-vendor',
)
vendor = Location.objects.create(
location_type='vendor',
name='Вайтер Юг',
location_region=region,
is_active=True,
)
self.assertEqual(str(vendor), 'Вайтер Юг')
self.assertEqual(location_object_code(vendor), 'Вайтер Юг')
user = make_employee('it-loc-vendor')
grant_all_locations(user)
ids = {loc.pk for loc in _it_locations_sorted(user)}
self.assertIn(vendor.pk, ids)
+2
View File
@@ -196,6 +196,8 @@ def location_object_code(location) -> str:
return 'ОФИС'
if location.location_type == 'warehouse':
return 'Склад'
if location.location_type == 'vendor':
return (getattr(location, 'name', None) or '').strip() or 'Сервис'
pharmacy = getattr(location, 'pharmacy', None)
if pharmacy is not None:
return pharmacy_object_code(pharmacy)