diff --git a/acore/urls.py b/acore/urls.py index 0dbb78e..8cda538 100644 --- a/acore/urls.py +++ b/acore/urls.py @@ -29,7 +29,12 @@ from core.accounting_portal import accounting_portal_view from apps.it_manager.billing import internet_payment_update_view from apps.employees.views_password import password_setup_view from apps.mattermost_connector.chat_proxy import chat_api_proxy -from apps.locations.views import pharmacy_update_view, vendor_create_view, vendor_update_view +from apps.locations.views import ( + pharmacy_update_view, + vendor_create_view, + vendor_detail_view, + vendor_update_view, +) from apps.visit_manager.views_calendar import ( calendar_event_create_view, calendar_event_delete_view, @@ -74,6 +79,7 @@ urlpatterns = [ path('dislocation/columns/', views.dislocation_columns_view, name='dislocation_columns'), path('dislocation/column-presets/', views.dislocation_column_presets_view, name='dislocation_column_presets'), path('dislocation/vendors/create/', vendor_create_view, name='dislocation_vendor_create'), + path('dislocation/vendors//', vendor_detail_view, name='dislocation_vendor_detail'), path('dislocation/vendors//update/', vendor_update_view, name='dislocation_vendor_update'), path('calendar/', views.calendar_view, name='calendar'), path('calendar/events/', calendar_event_create_view, name='calendar_event_create'), diff --git a/apps/it_manager/equipment_labels.py b/apps/it_manager/equipment_labels.py index 2b4251e..d73a4f2 100644 --- a/apps/it_manager/equipment_labels.py +++ b/apps/it_manager/equipment_labels.py @@ -11,9 +11,21 @@ from barcode.writer import ImageWriter from PIL import Image -# Внутренний префикс EAN (2 = локальное использование) + 00 → 200… _EAN_PREFIX = '200' +TEXT_BLOCK_TYPES = frozenset({'model', 'type', 'inventory', 'serial', 'custom'}) +CODE_BLOCK_TYPES = frozenset({'qr', 'ean13'}) + + +def parse_label_codes(raw: str | None) -> str: + """Нормализация: qr | ean | both.""" + value = (raw or 'both').strip().lower() + if value in ('qr', 'ean', 'both'): + return value + if value in ('ean13', 'barcode'): + return 'ean' + return 'both' + def equipment_ean13_body(pk: int) -> str: """12 цифр без контрольной: 200 + pk (9 знаков).""" @@ -25,7 +37,6 @@ def equipment_ean13_body(pk: int) -> str: def equipment_ean13(pk: int) -> str: """Полный EAN-13 (13 цифр) для единицы оборудования.""" EAN13 = get_barcode_class('ean13') - # python-barcode сам считает check digit из 12 цифр code = EAN13(equipment_ean13_body(pk), writer=ImageWriter()) return code.get_fullcode() @@ -78,18 +89,131 @@ def ean13_png_base64(pk: int) -> tuple[str, str]: return _png_b64(img), code.get_fullcode() -def label_payload_for_unit(unit) -> dict[str, Any]: +def get_default_template(): + from apps.it_manager.models import EquipmentLabelTemplate + return EquipmentLabelTemplate.get_default() + + +def _block_visible(block_type: str, codes: str) -> bool: + if block_type in TEXT_BLOCK_TYPES: + return True + if block_type == 'qr': + return codes in ('qr', 'both') + if block_type == 'ean13': + return codes in ('ean', 'both') + return False + + +def _text_for_block(block_type: str, block: dict, fields: dict[str, str]) -> str: + if block_type == 'custom': + return (block.get('text') or '').strip() + if block_type == 'model': + return fields['model'] + if block_type == 'type': + return fields['type_name'] + if block_type == 'inventory': + return fields['inventory'] or f"EQ{fields['id']}" + if block_type == 'serial': + serial = fields['serial'] + return f'S/N {serial}' if serial else '' + return '' + + +def label_payload_for_unit(unit, *, codes: str = 'both', template=None) -> dict[str, Any]: + codes = parse_label_codes(codes) + if template is None: + template = get_default_template() + qr_text = equipment_qr_payload(unit) - ean_b64, ean_digits = ean13_png_base64(unit.pk) + need_qr = codes in ('qr', 'both') + need_ean = codes in ('ean', 'both') + + qr_png = qr_png_base64(qr_text) if need_qr else '' + ean_b64, ean_digits = ('', '') + if need_ean: + ean_b64, ean_digits = ean13_png_base64(unit.pk) + model = unit.equipment_model - return { + fields = { 'id': unit.pk, 'model': str(model) if model else '—', 'type_name': model.equipment_type.name if model and model.equipment_type_id else '', 'serial': unit.serial_number or '', 'inventory': (unit.inventory_number or '').strip(), + } + + blocks_out = [] + for raw in (template.layout or []): + if not isinstance(raw, dict): + continue + btype = (raw.get('type') or '').strip() + if not _block_visible(btype, codes): + continue + block = { + 'id': raw.get('id') or btype, + 'type': btype, + 'x_mm': float(raw.get('x_mm') or 0), + 'y_mm': float(raw.get('y_mm') or 0), + 'w_mm': float(raw.get('w_mm') or 10), + 'h_mm': float(raw.get('h_mm') or 10), + 'font_pt': float(raw.get('font_pt') or 9), + 'bold': bool(raw.get('bold')), + 'align': (raw.get('align') or 'left'), + 'text': '', + 'png': '', + } + if btype == 'qr': + block['png'] = qr_png + block['text'] = qr_text + elif btype == 'ean13': + block['png'] = ean_b64 + block['text'] = ean_digits + else: + block['text'] = _text_for_block(btype, raw, fields) + blocks_out.append(block) + + return { + 'id': unit.pk, + 'model': fields['model'], + 'type_name': fields['type_name'], + 'serial': fields['serial'], + 'inventory': fields['inventory'], 'qr_payload': qr_text, - 'qr_png': qr_png_base64(qr_text), + 'qr_png': qr_png, 'ean13': ean_digits, 'ean_png': ean_b64, + 'codes': codes, + 'width_mm': float(template.width_mm), + 'height_mm': float(template.height_mm), + 'blocks': blocks_out, } + + +def normalize_layout(raw_blocks) -> list[dict]: + """Валидация layout из редактора.""" + allowed = CODE_BLOCK_TYPES | TEXT_BLOCK_TYPES + out = [] + if not isinstance(raw_blocks, list): + return out + for i, raw in enumerate(raw_blocks): + if not isinstance(raw, dict): + continue + btype = (raw.get('type') or '').strip() + if btype not in allowed: + continue + bid = (raw.get('id') or f'{btype}-{i}').strip()[:40] + out.append({ + 'id': bid, + 'type': btype, + 'x_mm': max(0.0, float(raw.get('x_mm') or 0)), + 'y_mm': max(0.0, float(raw.get('y_mm') or 0)), + 'w_mm': max(2.0, float(raw.get('w_mm') or 10)), + 'h_mm': max(2.0, float(raw.get('h_mm') or 10)), + 'font_pt': max(6.0, min(24.0, float(raw.get('font_pt') or 9))), + 'bold': bool(raw.get('bold')), + 'align': (raw.get('align') or 'left') if (raw.get('align') or 'left') in ( + 'left', 'center', 'right', + ) else 'left', + 'text': (raw.get('text') or '')[:200] if btype == 'custom' else '', + }) + return out diff --git a/apps/it_manager/migrations/0023_equipment_label_template.py b/apps/it_manager/migrations/0023_equipment_label_template.py new file mode 100644 index 0000000..2153724 --- /dev/null +++ b/apps/it_manager/migrations/0023_equipment_label_template.py @@ -0,0 +1,54 @@ +# Generated by Django 6.0.3 on 2026-10-07 13:32 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +def seed_default_template(apps, schema_editor): + from apps.it_manager.models.label_template import DEFAULT_LABEL_LAYOUT + + Template = apps.get_model('it_manager', 'EquipmentLabelTemplate') + if Template.objects.filter(code='default').exists(): + return + Template.objects.create( + code='default', + name='Этикетка оборудования', + width_mm=70.0, + height_mm=42.0, + layout=list(DEFAULT_LABEL_LAYOUT), + ) + + +def unseed_default_template(apps, schema_editor): + Template = apps.get_model('it_manager', 'EquipmentLabelTemplate') + Template.objects.filter(code='default').delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ('it_manager', '0022_worklog_catalog_work'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='EquipmentLabelTemplate', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('code', models.SlugField(default='default', max_length=40, unique=True, verbose_name='Код')), + ('name', models.CharField(default='Этикетка оборудования', max_length=100, verbose_name='Название')), + ('width_mm', models.FloatField(default=70.0, verbose_name='Ширина (мм)')), + ('height_mm', models.FloatField(default=42.0, verbose_name='Высота (мм)')), + ('layout', models.JSONField(blank=True, default=list, verbose_name='Блоки')), + ('updated_at', models.DateTimeField(auto_now=True)), + ('updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='Кто обновил')), + ], + options={ + 'verbose_name': 'Шаблон этикетки', + 'verbose_name_plural': 'Шаблоны этикеток', + }, + ), + migrations.RunPython(seed_default_template, unseed_default_template), + ] diff --git a/apps/it_manager/models/__init__.py b/apps/it_manager/models/__init__.py index e8d0a8a..7f879d0 100644 --- a/apps/it_manager/models/__init__.py +++ b/apps/it_manager/models/__init__.py @@ -3,3 +3,4 @@ from .tickets import Ticket, TicketComment from .providers import InternetProvider, InternetContract, InternetContractPayment from .worklogs import WorkLog, ServiceCall from .consumables import Consumable +from .label_template import EquipmentLabelTemplate, DEFAULT_LABEL_LAYOUT diff --git a/apps/it_manager/models/label_template.py b/apps/it_manager/models/label_template.py new file mode 100644 index 0000000..1318bcd --- /dev/null +++ b/apps/it_manager/models/label_template.py @@ -0,0 +1,117 @@ +from django.conf import settings +from django.db import models + + +DEFAULT_LABEL_LAYOUT = [ + { + 'id': 'qr', + 'type': 'qr', + 'x_mm': 2.0, + 'y_mm': 2.0, + 'w_mm': 22.0, + 'h_mm': 22.0, + 'font_pt': 9, + 'bold': False, + 'align': 'left', + 'text': '', + }, + { + 'id': 'model', + 'type': 'model', + 'x_mm': 26.0, + 'y_mm': 2.0, + 'w_mm': 40.0, + 'h_mm': 8.0, + 'font_pt': 10, + 'bold': True, + 'align': 'left', + 'text': '', + }, + { + 'id': 'type', + 'type': 'type', + 'x_mm': 26.0, + 'y_mm': 10.0, + 'w_mm': 40.0, + 'h_mm': 5.0, + 'font_pt': 8, + 'bold': False, + 'align': 'left', + 'text': '', + }, + { + 'id': 'inventory', + 'type': 'inventory', + 'x_mm': 26.0, + 'y_mm': 15.5, + 'w_mm': 40.0, + 'h_mm': 5.0, + 'font_pt': 10, + 'bold': False, + 'align': 'left', + 'text': '', + }, + { + 'id': 'serial', + 'type': 'serial', + 'x_mm': 26.0, + 'y_mm': 21.0, + 'w_mm': 40.0, + 'h_mm': 4.0, + 'font_pt': 8, + 'bold': False, + 'align': 'left', + 'text': '', + }, + { + 'id': 'ean', + 'type': 'ean13', + 'x_mm': 2.0, + 'y_mm': 26.0, + 'w_mm': 64.0, + 'h_mm': 14.0, + 'font_pt': 8, + 'bold': False, + 'align': 'center', + 'text': '', + }, +] + + +class EquipmentLabelTemplate(models.Model): + """Общий шаблон инвентарной этикетки (singleton code=default).""" + + code = models.SlugField('Код', max_length=40, unique=True, default='default') + name = models.CharField('Название', max_length=100, default='Этикетка оборудования') + width_mm = models.FloatField('Ширина (мм)', default=70.0) + height_mm = models.FloatField('Высота (мм)', default=42.0) + layout = models.JSONField('Блоки', default=list, blank=True) + updated_at = models.DateTimeField(auto_now=True) + updated_by = models.ForeignKey( + settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, + null=True, blank=True, related_name='+', + verbose_name='Кто обновил', + ) + + class Meta: + verbose_name = 'Шаблон этикетки' + verbose_name_plural = 'Шаблоны этикеток' + + def __str__(self): + return self.name + + @classmethod + def get_default(cls): + obj, created = cls.objects.get_or_create( + code='default', + defaults={ + 'name': 'Этикетка оборудования', + 'width_mm': 70.0, + 'height_mm': 42.0, + 'layout': list(DEFAULT_LABEL_LAYOUT), + }, + ) + if not created and not obj.layout: + obj.layout = list(DEFAULT_LABEL_LAYOUT) + obj.save(update_fields=['layout']) + return obj diff --git a/apps/it_manager/tests.py b/apps/it_manager/tests.py index 36439ba..e5c465a 100644 --- a/apps/it_manager/tests.py +++ b/apps/it_manager/tests.py @@ -436,17 +436,74 @@ class ItManagerEquipmentTests(TestCase): self.assertEqual(len(ean), 13) self.assertTrue(ean.startswith('200')) - r = self.client.get(f'/it-manager/equipment/labels/?ids={eq.pk}') + r = self.client.get(f'/it-manager/equipment/labels/?ids={eq.pk}&codes=both') self.assertEqual(r.status_code, 200) self.assertContains(r, 'INV-LBL-1') self.assertContains(r, 'data:image/png;base64,') self.assertContains(r, ean) + r_qr = self.client.get(f'/it-manager/equipment/labels/?ids={eq.pk}&codes=qr') + self.assertEqual(r_qr.status_code, 200) + self.assertContains(r_qr, 'INV-LBL-1') + self.assertNotContains(r_qr, f'alt="{ean}"') + + r_ean = self.client.get(f'/it-manager/equipment/labels/?ids={eq.pk}&codes=ean') + self.assertEqual(r_ean.status_code, 200) + self.assertContains(r_ean, ean) + self.assertNotContains(r_ean, f'alt="INV-LBL-1"') + r2 = self.client.get('/it-manager/', {'tab': 'equipment', 'q': ean}) self.assertEqual(r2.status_code, 200) serials = [e.serial_number for e in r2.context['equipment_page']] self.assertIn('LBL-SN-1', serials) + def test_label_template_editor(self): + import json + + from apps.it_manager.models import EquipmentLabelTemplate + + r = self.client.get('/it-manager/equipment/labels/template/') + self.assertEqual(r.status_code, 200) + self.assertContains(r, 'Шаблон этикетки') + + tmpl = EquipmentLabelTemplate.get_default() + payload = { + 'width_mm': 80, + 'height_mm': 50, + 'layout': [ + { + 'id': 'qr1', 'type': 'qr', + 'x_mm': 3, 'y_mm': 3, 'w_mm': 20, 'h_mm': 20, + 'font_pt': 9, 'bold': False, 'align': 'left', 'text': '', + }, + { + 'id': 'm1', 'type': 'model', + 'x_mm': 26, 'y_mm': 3, 'w_mm': 40, 'h_mm': 8, + 'font_pt': 10, 'bold': True, 'align': 'left', 'text': '', + }, + ], + } + r_save = self.client.post( + '/it-manager/equipment/labels/template/', + data=json.dumps(payload), + content_type='application/json', + ) + self.assertEqual(r_save.status_code, 200) + data = r_save.json() + self.assertTrue(data.get('ok')) + tmpl.refresh_from_db() + self.assertEqual(tmpl.width_mm, 80) + self.assertEqual(len(tmpl.layout), 2) + + outsider = make_employee(username='no-it-labels') + client2 = Client() + login_2fa(client2, outsider) + forbidden = client2.get('/it-manager/equipment/labels/template/') + self.assertIn(forbidden.status_code, (403, 302, 200)) + if forbidden.status_code == 200: + # deny_section_access может вернуть dashboard-фрагмент + self.assertNotContains(forbidden, 'label-canvas') + def test_assignment_history_visible_in_card(self): equipment = make_equipment(serial_number='HIST-UI-1') emp = make_employee(first_name='Иван', last_name='Историков') diff --git a/apps/it_manager/urls.py b/apps/it_manager/urls.py index 61a40be..f930644 100644 --- a/apps/it_manager/urls.py +++ b/apps/it_manager/urls.py @@ -31,6 +31,11 @@ urlpatterns = [ path('it-manager/equipment/bulk-create/', views.equipment_bulk_create_view, name='it_equipment_bulk_create'), path('it-manager/equipment/bulk-assign/', views.equipment_bulk_assign_view, name='it_equipment_bulk_assign'), path('it-manager/equipment/labels/', views.equipment_labels_view, name='it_equipment_labels'), + path( + 'it-manager/equipment/labels/template/', + views.equipment_label_template_view, + name='it_equipment_label_template', + ), path('it-manager/equipment//update/', views.equipment_update_view, name='it_equipment_update'), path('it-manager/tickets//update/', views.ticket_update_view, name='it_ticket_update'), path('it-manager/tickets//comment/', views.ticket_comment_view, name='it_ticket_comment'), diff --git a/apps/it_manager/views.py b/apps/it_manager/views.py index 93229ad..00a43d0 100644 --- a/apps/it_manager/views.py +++ b/apps/it_manager/views.py @@ -752,27 +752,30 @@ def equipment_bulk_create_view(request): @login_required def equipment_labels_view(request): - """Печатная страница этикеток QR + EAN-13 для выбранного оборудования.""" + """Печатная страница этикеток по шаблону; codes=qr|ean|both.""" from django.shortcuts import render - from apps.it_manager.equipment_labels import label_payload_for_unit + from apps.it_manager.equipment_labels import ( + get_default_template, label_payload_for_unit, parse_label_codes, + ) if not _is_it_staff(request.user): return deny_section_access(request) + codes = parse_label_codes(request.GET.get('codes')) raw_ids = request.GET.getlist('ids') or request.GET.get('ids', '').split(',') ids = [] for part in raw_ids: part = (part or '').strip() if part.isdigit(): ids.append(int(part)) - # порядок как в запросе, без дублей seen, ordered = set(), [] for pk in ids: if pk not in seen: seen.add(pk) ordered.append(pk) + template = get_default_template() units_by_id = { u.pk: u for u in scoped_queryset(request.user, Equipment.objects.all()) @@ -785,9 +788,61 @@ def equipment_labels_view(request): for pk in ordered: unit = units_by_id.get(pk) if unit is not None: - labels.append(label_payload_for_unit(unit)) + labels.append(label_payload_for_unit(unit, codes=codes, template=template)) - return render(request, 'pages/it-equipment-labels.html', {'labels': labels}) + return render(request, 'pages/it-equipment-labels.html', { + 'labels': labels, + 'codes': codes, + 'template_width_mm': template.width_mm, + 'template_height_mm': template.height_mm, + }) + + +@login_required +def equipment_label_template_view(request): + """Редактор общего шаблона этикетки (GET страница / POST JSON).""" + import json + + from django.http import JsonResponse + from django.shortcuts import render + + from apps.it_manager.equipment_labels import get_default_template, normalize_layout + + if not _is_it_staff(request.user): + return deny_section_access(request) + + template = get_default_template() + + if request.method == 'POST': + try: + payload = json.loads(request.body.decode('utf-8') or '{}') + except (json.JSONDecodeError, UnicodeDecodeError): + return JsonResponse({'ok': False, 'error': 'Некорректный JSON'}, status=400) + try: + width = float(payload.get('width_mm') or template.width_mm) + height = float(payload.get('height_mm') or template.height_mm) + except (TypeError, ValueError): + return JsonResponse({'ok': False, 'error': 'Некорректный размер'}, status=400) + if width < 20 or width > 200 or height < 15 or height > 200: + return JsonResponse({'ok': False, 'error': 'Размер вне диапазона 20–200 × 15–200 мм'}, status=400) + layout = normalize_layout(payload.get('layout')) + if not layout: + return JsonResponse({'ok': False, 'error': 'Добавьте хотя бы один блок'}, status=400) + template.width_mm = width + template.height_mm = height + template.layout = layout + template.updated_by = request.user + template.save() + return JsonResponse({'ok': True, 'updated_at': template.updated_at.isoformat()}) + + return render(request, 'pages/it-equipment-label-template.html', { + 'template': template, + 'template_json': json.dumps({ + 'width_mm': template.width_mm, + 'height_mm': template.height_mm, + 'layout': template.layout or [], + }, ensure_ascii=False), + }) @login_required diff --git a/apps/locations/forms.py b/apps/locations/forms.py index 9cd2f94..a44b6cd 100644 --- a/apps/locations/forms.py +++ b/apps/locations/forms.py @@ -118,7 +118,7 @@ class PortalVendorForm(forms.ModelForm): fields = ( 'name', 'location_region', 'street', 'house_number', 'building', 'apartment', - 'comments', 'is_active', + 'location', 'comments', 'is_active', ) widgets = { 'name': forms.TextInput(attrs={ @@ -130,6 +130,10 @@ class PortalVendorForm(forms.ModelForm): 'house_number': forms.TextInput(attrs={'class': 'input'}), 'building': forms.TextInput(attrs={'class': 'input'}), 'apartment': forms.TextInput(attrs={'class': 'input'}), + 'location': forms.TextInput(attrs={ + 'class': 'input', + 'placeholder': '46.4825,30.7233', + }), 'comments': forms.Textarea(attrs={'class': 'input', 'rows': 2}), 'is_active': forms.CheckboxInput(), } @@ -138,7 +142,7 @@ class PortalVendorForm(forms.ModelForm): super().__init__(*args, **kwargs) self.fields['name'].required = True self.fields['location_region'].widget.attrs['class'] = 'input' - for optional in ('street', 'house_number', 'building', 'apartment', 'comments'): + for optional in ('street', 'house_number', 'building', 'apartment', 'comments', 'location'): self.fields[optional].required = False def clean_name(self): diff --git a/apps/locations/tests.py b/apps/locations/tests.py index 1d8faee..a6c5b31 100644 --- a/apps/locations/tests.py +++ b/apps/locations/tests.py @@ -802,3 +802,34 @@ class DislocationVendorsTabTests(TestCase): self.vendor.refresh_from_db() self.assertEqual(self.vendor.name, 'ТестСервис Обновлён') self.assertEqual(self.vendor.comments, 'ремонт техники') + + def test_vendor_detail_card_and_map(self): + from django.test import Client + from core.test_utils import login_2fa + + client = Client() + login_2fa(client, self.viewer) + r = client.get(f'/dislocation/vendors/{self.vendor.pk}/', HTTP_HX_REQUEST='true') + self.assertEqual(r.status_code, 200) + self.assertContains(r, 'ТестСервис') + self.assertContains(r, 'Координаты не указаны') + + login_2fa(client, self.it_user) + r2 = client.post(f'/dislocation/vendors/{self.vendor.pk}/update/', { + 'name': 'ТестСервис', + 'location_region': str(self.region.pk), + 'location': '46.482500,30.723300', + 'is_active': 'on', + 'from_card': '1', + }, HTTP_HX_REQUEST='true') + self.assertEqual(r2.status_code, 200) + self.vendor.refresh_from_db() + # Дефолт поля location — как раз Одесса; has_coordinates False для дефолта. + # Ставим недефолтные координаты. + self.vendor.location = '46.500000,30.750000' + self.vendor.save(update_fields=['location', 'address']) + r3 = client.get(f'/dislocation/vendors/{self.vendor.pk}/', HTTP_HX_REQUEST='true') + self.assertEqual(r3.status_code, 200) + self.assertContains(r3, 'vendor-map') + self.assertContains(r3, '46') + self.assertContains(r3, '30,75000') diff --git a/apps/locations/views.py b/apps/locations/views.py index 13c0af9..6741951 100644 --- a/apps/locations/views.py +++ b/apps/locations/views.py @@ -165,6 +165,34 @@ def pharmacy_update_view(request, pk): return render_page(request, 'pharmacy-detail.html', context) +def _vendor_detail_context(request, vendor, *, form=None, edit_mode=False, + form_message=None, form_error=None): + can_edit = user_can_manage_vendors(request.user) + if edit_mode and can_edit and form is None: + form = PortalVendorForm(instance=vendor) + return { + 'vendor': vendor, + 'can_edit_vendor': can_edit, + 'vendor_edit_mode': bool(edit_mode and can_edit), + 'vendor_edit_form': form if (edit_mode and can_edit) else None, + 'vendor_form_message': form_message, + 'vendor_form_error': form_error, + } + + +@login_required +def vendor_detail_view(request, pk): + vendor = get_object_or_404( + Location.objects.select_related('location_region'), + pk=pk, location_type='vendor', + ) + edit_mode = request.GET.get('edit') == '1' + if edit_mode and not user_can_manage_vendors(request.user): + return HttpResponseForbidden() + context = _vendor_detail_context(request, vendor, edit_mode=edit_mode) + return render_page(request, 'vendor-detail.html', context) + + @login_required @require_POST def vendor_create_view(request): @@ -188,9 +216,22 @@ def vendor_update_view(request, pk): vendor = get_object_or_404(Location, pk=pk, location_type='vendor') form = PortalVendorForm(request.POST, instance=vendor) + # Если пришли с карточки — остаёмся на ней + from_card = request.POST.get('from_card') == '1' if form.is_valid(): - form.save() + vendor = form.save() + if from_card: + context = _vendor_detail_context( + request, vendor, form_message='Изменения сохранены.', + ) + return render_page(request, 'vendor-detail.html', context) return _dislocation_vendors_response(request, message='Изменения сохранены.') + if from_card: + context = _vendor_detail_context( + request, vendor, form=form, edit_mode=True, + form_error=_vendor_form_errors(form), + ) + return render_page(request, 'vendor-detail.html', context) return _dislocation_vendors_response( request, error=_vendor_form_errors(form), form=form, ) diff --git a/static/assets/js/it-manager.js b/static/assets/js/it-manager.js index 9ed548a..8c43c66 100644 --- a/static/assets/js/it-manager.js +++ b/static/assets/js/it-manager.js @@ -151,11 +151,13 @@ function itBulkClearSelection() { itBulkUpdate(); } -function itEquipmentOpenLabels() { +function itEquipmentOpenLabels(codes) { var checked = Array.prototype.slice.call(document.querySelectorAll('.it-equip-check:checked')); if (!checked.length) return; var ids = checked.map(function (cb) { return cb.value; }); - var url = '/it-manager/equipment/labels/?ids=' + encodeURIComponent(ids.join(',')); + var mode = codes || 'both'; + var url = '/it-manager/equipment/labels/?ids=' + encodeURIComponent(ids.join(',')) + + '&codes=' + encodeURIComponent(mode); window.open(url, '_blank', 'noopener'); } diff --git a/static/assets/js/label-template-editor.js b/static/assets/js/label-template-editor.js new file mode 100644 index 0000000..4cfc9f2 --- /dev/null +++ b/static/assets/js/label-template-editor.js @@ -0,0 +1,290 @@ +(function () { + 'use strict'; + + var TYPE_LABELS = { + qr: 'QR', + ean13: 'EAN-13', + model: 'Модель', + type: 'Тип', + inventory: 'Инв. №', + serial: 'S/N', + custom: 'Текст', + }; + + var DEMO = { + model: 'Mikrotik RB952', + type: 'Роутер', + inventory: 'INV-DEMO-1', + serial: 'S/N ABC123', + custom: '', + qr: 'QR', + ean13: 'EAN-13', + }; + + var dataEl = document.getElementById('label-template-data'); + var state = { width_mm: 70, height_mm: 42, layout: [] }; + try { + state = JSON.parse(dataEl.textContent || '{}'); + } catch (e) { /* keep defaults */ } + if (!Array.isArray(state.layout)) state.layout = []; + + var canvas = document.getElementById('label-canvas'); + var scaleInput = document.getElementById('tmpl-scale'); + var widthInput = document.getElementById('tmpl-width'); + var heightInput = document.getElementById('tmpl-height'); + var statusEl = document.getElementById('status'); + var selectedId = null; + var drag = null; + var uid = 1; + + state.layout.forEach(function (b) { + if (b && b.id) { + var n = parseInt(String(b.id).replace(/\D/g, ''), 10); + if (n >= uid) uid = n + 1; + } + }); + + function scale() { + return parseFloat(scaleInput.value) || 4; + } + + function setStatus(msg, isErr) { + statusEl.textContent = msg || ''; + statusEl.className = isErr ? 'err' : ''; + } + + function selectedBlock() { + return state.layout.find(function (b) { return b.id === selectedId; }) || null; + } + + function previewText(block) { + if (block.type === 'custom') return block.text || 'Текст'; + return DEMO[block.type] || block.type; + } + + function render() { + var s = scale(); + canvas.style.width = (state.width_mm * s) + 'px'; + canvas.style.height = (state.height_mm * s) + 'px'; + canvas.innerHTML = ''; + state.layout.forEach(function (block) { + var el = document.createElement('div'); + el.className = 'lbl-block' + (block.id === selectedId ? ' selected' : ''); + el.dataset.id = block.id; + el.style.left = (block.x_mm * s) + 'px'; + el.style.top = (block.y_mm * s) + 'px'; + el.style.width = (block.w_mm * s) + 'px'; + el.style.height = (block.h_mm * s) + 'px'; + el.style.fontSize = Math.max(8, block.font_pt * (s / 3.5)) + 'px'; + el.style.fontWeight = block.bold ? '600' : '400'; + el.style.textAlign = block.align || 'left'; + el.innerHTML = '
' + (TYPE_LABELS[block.type] || block.type) + '
' + + '
' + escapeHtml(previewText(block)) + '
' + + '
'; + canvas.appendChild(el); + }); + syncProps(); + } + + function escapeHtml(str) { + return String(str || '') + .replace(/&/g, '&') + .replace(//g, '>'); + } + + function syncProps() { + var block = selectedBlock(); + var empty = document.getElementById('props-empty'); + var fields = document.getElementById('props-fields'); + if (!block) { + empty.style.display = ''; + fields.style.display = 'none'; + return; + } + empty.style.display = 'none'; + fields.style.display = ''; + document.getElementById('prop-type-label').textContent = TYPE_LABELS[block.type] || block.type; + document.getElementById('prop-x').value = block.x_mm; + document.getElementById('prop-y').value = block.y_mm; + document.getElementById('prop-w').value = block.w_mm; + document.getElementById('prop-h').value = block.h_mm; + document.getElementById('prop-font').value = block.font_pt; + document.getElementById('prop-align').value = block.align || 'left'; + document.getElementById('prop-bold').checked = !!block.bold; + var customWrap = document.getElementById('prop-custom-wrap'); + customWrap.style.display = block.type === 'custom' ? '' : 'none'; + document.getElementById('prop-text').value = block.text || ''; + } + + function clampBlock(block) { + block.w_mm = Math.max(2, Number(block.w_mm) || 10); + block.h_mm = Math.max(2, Number(block.h_mm) || 10); + block.x_mm = Math.max(0, Math.min(state.width_mm - 2, Number(block.x_mm) || 0)); + block.y_mm = Math.max(0, Math.min(state.height_mm - 2, Number(block.y_mm) || 0)); + if (block.x_mm + block.w_mm > state.width_mm) block.w_mm = Math.max(2, state.width_mm - block.x_mm); + if (block.y_mm + block.h_mm > state.height_mm) block.h_mm = Math.max(2, state.height_mm - block.y_mm); + } + + function addBlock(type) { + var defaults = { + qr: { w_mm: 22, h_mm: 22 }, + ean13: { w_mm: 50, h_mm: 14 }, + model: { w_mm: 35, h_mm: 6, bold: true, font_pt: 10 }, + type: { w_mm: 35, h_mm: 5, font_pt: 8 }, + inventory: { w_mm: 35, h_mm: 5, font_pt: 10 }, + serial: { w_mm: 35, h_mm: 4, font_pt: 8 }, + custom: { w_mm: 30, h_mm: 5, text: 'Текст' }, + }; + var d = defaults[type] || { w_mm: 20, h_mm: 6 }; + var block = { + id: type + '-' + (uid++), + type: type, + x_mm: 2, + y_mm: 2, + w_mm: d.w_mm, + h_mm: d.h_mm, + font_pt: d.font_pt || 9, + bold: !!d.bold, + align: 'left', + text: d.text || '', + }; + clampBlock(block); + state.layout.push(block); + selectedId = block.id; + render(); + } + + canvas.addEventListener('pointerdown', function (ev) { + var target = ev.target.closest('.lbl-block'); + if (!target) { + selectedId = null; + render(); + return; + } + selectedId = target.dataset.id; + var block = selectedBlock(); + if (!block) return; + var s = scale(); + var isResize = !!ev.target.closest('[data-resize]'); + drag = { + id: block.id, + resize: isResize, + startX: ev.clientX, + startY: ev.clientY, + origX: block.x_mm, + origY: block.y_mm, + origW: block.w_mm, + origH: block.h_mm, + scale: s, + pointerId: ev.pointerId, + }; + canvas.setPointerCapture(ev.pointerId); + render(); + ev.preventDefault(); + }); + + canvas.addEventListener('pointermove', function (ev) { + if (!drag) return; + var block = state.layout.find(function (b) { return b.id === drag.id; }); + if (!block) return; + var dx = (ev.clientX - drag.startX) / drag.scale; + var dy = (ev.clientY - drag.startY) / drag.scale; + if (drag.resize) { + block.w_mm = drag.origW + dx; + block.h_mm = drag.origH + dy; + } else { + block.x_mm = drag.origX + dx; + block.y_mm = drag.origY + dy; + } + clampBlock(block); + render(); + }); + + function endDrag() { + drag = null; + syncProps(); + } + canvas.addEventListener('pointerup', endDrag); + canvas.addEventListener('pointercancel', endDrag); + + document.querySelectorAll('.palette [data-add]').forEach(function (btn) { + btn.addEventListener('click', function () { + addBlock(btn.getAttribute('data-add')); + }); + }); + + function bindProp(id, apply) { + var el = document.getElementById(id); + el.addEventListener('change', function () { + var block = selectedBlock(); + if (!block) return; + apply(block, el); + clampBlock(block); + render(); + }); + el.addEventListener('input', function () { + var block = selectedBlock(); + if (!block) return; + apply(block, el); + clampBlock(block); + render(); + }); + } + + bindProp('prop-x', function (b, el) { b.x_mm = parseFloat(el.value) || 0; }); + bindProp('prop-y', function (b, el) { b.y_mm = parseFloat(el.value) || 0; }); + bindProp('prop-w', function (b, el) { b.w_mm = parseFloat(el.value) || 2; }); + bindProp('prop-h', function (b, el) { b.h_mm = parseFloat(el.value) || 2; }); + bindProp('prop-font', function (b, el) { b.font_pt = parseFloat(el.value) || 9; }); + bindProp('prop-align', function (b, el) { b.align = el.value; }); + bindProp('prop-bold', function (b, el) { b.bold = el.checked; }); + bindProp('prop-text', function (b, el) { b.text = el.value; }); + + document.getElementById('prop-delete').addEventListener('click', function () { + if (!selectedId) return; + state.layout = state.layout.filter(function (b) { return b.id !== selectedId; }); + selectedId = null; + render(); + }); + + function resizeFromInputs() { + state.width_mm = Math.max(20, Math.min(200, parseFloat(widthInput.value) || 70)); + state.height_mm = Math.max(15, Math.min(200, parseFloat(heightInput.value) || 42)); + widthInput.value = state.width_mm; + heightInput.value = state.height_mm; + state.layout.forEach(clampBlock); + render(); + } + widthInput.addEventListener('change', resizeFromInputs); + heightInput.addEventListener('change', resizeFromInputs); + scaleInput.addEventListener('change', render); + + document.getElementById('btn-save').addEventListener('click', function () { + var csrf = document.querySelector('meta[name="csrf-token"]'); + var payload = { + width_mm: state.width_mm, + height_mm: state.height_mm, + layout: state.layout, + }; + setStatus('Сохранение…'); + fetch('/it-manager/equipment/labels/template/', { + method: 'POST', + credentials: 'same-origin', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': csrf ? csrf.content : '', + }, + body: JSON.stringify(payload), + }).then(function (r) { + return r.json().then(function (data) { + if (!r.ok || !data.ok) throw new Error((data && data.error) || 'Ошибка сохранения'); + setStatus('Сохранено'); + }); + }).catch(function (err) { + setStatus(err.message || 'Ошибка', true); + }); + }); + + render(); +})(); diff --git a/templates/pages/dislocation.html b/templates/pages/dislocation.html index 3f02221..2a0ddc2 100644 --- a/templates/pages/dislocation.html +++ b/templates/pages/dislocation.html @@ -464,9 +464,13 @@ {% for vendor in vendors %} - + data-status="{% if vendor.is_active %}active{% else %}inactive{% endif %}" + hx-get="/dislocation/vendors/{{ vendor.pk }}/" + hx-target="#main-content" + hx-push-url="/dislocation/vendors/{{ vendor.pk }}/" + hx-indicator="#loader"> {{ vendor.name|default:"—" }} {{ vendor.location_region.name|default:"—" }} @@ -485,15 +489,16 @@ {% endif %} {% if can_manage_vendors %} - + @@ -551,6 +556,10 @@ {{ vendor_form.apartment }} +
+ + {{ vendor_form.location }} +
{{ vendor_form.comments }} @@ -652,7 +661,7 @@ function disloVendorPanelOpen() { if (nameEl) nameEl.value = ''; const regionEl = form.querySelector('[name="location_region"]'); if (regionEl) regionEl.value = ''; - ['street', 'house_number', 'building', 'apartment', 'comments'].forEach(function (n) { + ['street', 'house_number', 'building', 'apartment', 'location', 'comments'].forEach(function (n) { const el = form.querySelector('[name="' + n + '"]'); if (el) el.value = ''; }); @@ -672,7 +681,7 @@ function disloVendorPanelClose() { function disloVendorEdit(pk) { const form = document.getElementById('dislo-vendor-form'); if (!form) return; - const btn = document.querySelector('button[onclick="disloVendorEdit(' + pk + ')"]'); + const btn = document.querySelector('button[onclick*="disloVendorEdit(' + pk + ')"]'); if (!btn) return; form.setAttribute('hx-post', '/dislocation/vendors/' + pk + '/update/'); if (window.htmx) htmx.process(form); @@ -688,6 +697,7 @@ function disloVendorEdit(pk) { set('house_number', btn.dataset.house); set('building', btn.dataset.building); set('apartment', btn.dataset.apartment); + set('location', btn.dataset.location); set('comments', btn.dataset.comments); const activeEl = form.querySelector('[name="is_active"]'); if (activeEl) activeEl.checked = btn.dataset.active === '1'; diff --git a/templates/pages/it-equipment-label-template.html b/templates/pages/it-equipment-label-template.html new file mode 100644 index 0000000..ceb6de0 --- /dev/null +++ b/templates/pages/it-equipment-label-template.html @@ -0,0 +1,163 @@ +{% load i18n static %} + + + + + {% trans "Шаблон этикетки" %} — ACORE + + + + +
+

{% trans "Шаблон этикетки оборудования" %}

+ + {% trans "К оборудованию" %} + +
+
+
+

{% trans "Блоки" %}

+ + + + + + + +

{% trans "Размер этикетки" %}

+ + + + + + +
+
+
+
+
+

{% trans "Свойства" %}

+
{% trans "Выберите блок на макете" %}
+ +
+
+ {{ template_json|json_script:"label-template-data" }} + + + diff --git a/templates/pages/it-equipment-labels.html b/templates/pages/it-equipment-labels.html index 183175a..d4bdc1a 100644 --- a/templates/pages/it-equipment-labels.html +++ b/templates/pages/it-equipment-labels.html @@ -28,44 +28,41 @@ .toolbar .primary { background: #2563eb; color: #fff; border-color: #2563eb; } .hint { font-size: 0.8125rem; color: #555; } .sheet { - display: grid; - grid-template-columns: repeat(2, 1fr); - gap: 6mm; + display: flex; + flex-wrap: wrap; + gap: 5mm; padding: 8mm; + align-content: flex-start; } .label { + position: relative; + width: {{ template_width_mm }}mm; + height: {{ template_height_mm }}mm; border: 1px solid #222; - border-radius: 2mm; - padding: 3mm 3.5mm; - display: grid; - grid-template-columns: 22mm 1fr; - grid-template-rows: auto auto; - gap: 2mm 3mm; + border-radius: 1mm; + overflow: hidden; break-inside: avoid; page-break-inside: avoid; - min-height: 32mm; + background: #fff; } - .label .qr { width: 22mm; height: 22mm; object-fit: contain; } - .label .meta { font-size: 9pt; line-height: 1.25; min-width: 0; } - .label .meta .model { - font-weight: 600; font-size: 10pt; - overflow: hidden; text-overflow: ellipsis; - display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; + .block { + position: absolute; + overflow: hidden; + line-height: 1.15; } - .label .meta .muted { color: #444; font-size: 8pt; margin-top: 1mm; } - .label .meta .inv { font-family: ui-monospace, monospace; font-size: 10pt; margin-top: 1.5mm; } - .label .ean { - grid-column: 1 / -1; - text-align: center; - } - .label .ean img { - max-width: 100%; - height: 14mm; + .block img { + width: 100%; + height: 100%; object-fit: contain; + display: block; + } + .block.text { + white-space: pre-wrap; + word-break: break-word; } @media print { .toolbar { display: none !important; } - .sheet { padding: 0; gap: 5mm; } + .sheet { padding: 0; gap: 4mm; } .label { border-color: #000; } } @@ -73,30 +70,31 @@
- {% trans "Этикеток" %}: {{ labels|length }} · QR = {% trans "инв. № или EQ{id}" %} · EAN-13 = 200 + id + + {% trans "Этикеток" %}: {{ labels|length }} · + {% if codes == 'qr' %}QR{% elif codes == 'ean' %}EAN-13{% else %}QR + EAN-13{% endif %} · + {{ template_width_mm }}×{{ template_height_mm }} {% trans "мм" %} + + {% trans "Шаблон" %} {% trans "К оборудованию" %}
- {% for label in labels %} + {% for label in labels %}
- QR {{ label.qr_payload }} -
-
{{ label.model }}
- {% if label.type_name %}
{{ label.type_name }}
{% endif %} - {% if label.inventory %} -
{{ label.inventory }}
+ {% for block in label.blocks %} +
+ {% if block.type == 'qr' or block.type == 'ean13' %} + {% if block.png %}{{ block.text }}{% endif %} {% else %} -
EQ{{ label.id }}
+ {{ block.text }} {% endif %} -
S/N {{ label.serial }}
-
-
- EAN {{ label.ean13 }}
+ {% endfor %}
- {% empty %} + {% empty %}

{% trans "Не выбрано оборудование." %}

- {% endfor %} + {% endfor %}
diff --git a/templates/pages/it-manager.html b/templates/pages/it-manager.html index d35a7b1..10ac3f4 100644 --- a/templates/pages/it-manager.html +++ b/templates/pages/it-manager.html @@ -216,7 +216,13 @@ {% include "partials/it-equipment-assign-fields.html" with widget_id="bulk-assign" selected_target="" selected_location_id=None selected_employee_id=None require_target=True allow_empty=False %} + onclick="itEquipmentOpenLabels('qr')">{% trans "QR" %} + + + {% trans "Шаблон этикетки" %}
diff --git a/templates/pages/vendor-detail.html b/templates/pages/vendor-detail.html new file mode 100644 index 0000000..e875c78 --- /dev/null +++ b/templates/pages/vendor-detail.html @@ -0,0 +1,162 @@ +{% load i18n %} +
+ +
+ + + + + {% trans "К дислокации" %} + + {% if can_edit_vendor %} +
+ {% if vendor_edit_mode %} + {% trans "Отмена" %} + {% else %} + {% trans "Редактировать" %} + {% endif %} +
+ {% endif %} +
+ + {% if vendor_form_message %} +
+ {{ vendor_form_message }} +
+ {% endif %} + {% if vendor_form_error %} +
+ {{ vendor_form_error }} +
+ {% endif %} + +
+
+
+

{{ vendor.name|default:"—" }}

+
+ {% trans "Сервис / поставщик" %} + {% if vendor.location_region %} · {{ vendor.location_region.name }}{% endif %} +
+
+ {% if vendor.is_active %} + {% trans "Активен" %} + {% else %} + {% trans "Неактивен" %} + {% endif %} +
+
+ + {% if vendor_edit_mode and vendor_edit_form %} +
+ {% csrf_token %} + +
+
+ + {{ vendor_edit_form.name }} +
+
+ + {{ vendor_edit_form.location_region }} +
+
+ + {{ vendor_edit_form.location }} +
+
+ + {{ vendor_edit_form.street }} +
+
+
+ + {{ vendor_edit_form.house_number }} +
+
+ + {{ vendor_edit_form.building }} +
+
+ + {{ vendor_edit_form.apartment }} +
+
+
+ + {{ vendor_edit_form.comments }} +
+ +
+ +
+ {% else %} +
+
+

{% trans "Данные" %}

+
+
+
{% trans "Адрес" %}
+
+ {% if vendor.street or vendor.house_number %} + {% if vendor.street %}ул. {{ vendor.street }}{% endif %}{% if vendor.house_number %} {{ vendor.house_number }}{% endif %}{% if vendor.building %}, корп. {{ vendor.building }}{% endif %}{% if vendor.apartment %}, пом. {{ vendor.apartment }}{% endif %} + {% elif vendor.address %} + {{ vendor.address }} + {% else %} + — + {% endif %} +
+
+
+
{% trans "Город/район" %}
+
{{ vendor.location_region.name|default:"—" }}
+
+
+
{% trans "Комментарий" %}
+
{{ vendor.comments|default:"—" }}
+
+
+
+
{% trans "Создано:" %} {{ vendor.created_at|date:"d.m.Y H:i" }}
+
{% trans "Обновлено:" %} {{ vendor.updated_at|date:"d.m.Y H:i" }}
+
+
+ +
+

{% trans "Карта" %}

+ {% if vendor.has_coordinates %} +
+
{{ vendor.latitude|floatformat:5 }} · {{ vendor.longitude|floatformat:5 }}
+ + + + {% else %} +
{% trans "Координаты не указаны" %}
+ {% if can_edit_vendor %} +
{% trans "Укажите lat,lng в режиме редактирования." %}
+ {% endif %} + {% endif %} +
+
+ {% endif %} +