diff --git a/acore/urls.py b/acore/urls.py index a421475..0dbb78e 100644 --- a/acore/urls.py +++ b/acore/urls.py @@ -29,7 +29,7 @@ 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 +from apps.locations.views import pharmacy_update_view, vendor_create_view, vendor_update_view from apps.visit_manager.views_calendar import ( calendar_event_create_view, calendar_event_delete_view, @@ -73,6 +73,8 @@ urlpatterns = [ path('dislocation/', views.dislocation_view, name='dislocation'), 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//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'), path('calendar/events//delete/', calendar_event_delete_view, name='calendar_event_delete'), diff --git a/apps/it_manager/equipment_labels.py b/apps/it_manager/equipment_labels.py new file mode 100644 index 0000000..2b4251e --- /dev/null +++ b/apps/it_manager/equipment_labels.py @@ -0,0 +1,95 @@ +"""QR + EAN-13 для инвентарных этикеток оборудования.""" +from __future__ import annotations + +import base64 +import io +from typing import Any + +import qrcode +from barcode import get_barcode_class +from barcode.writer import ImageWriter +from PIL import Image + + +# Внутренний префикс EAN (2 = локальное использование) + 00 → 200… +_EAN_PREFIX = '200' + + +def equipment_ean13_body(pk: int) -> str: + """12 цифр без контрольной: 200 + pk (9 знаков).""" + if pk < 1 or pk > 999_999_999: + raise ValueError(f'pk вне диапазона для EAN-13: {pk}') + return f'{_EAN_PREFIX}{pk:09d}' + + +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() + + +def equipment_qr_payload(unit) -> str: + """Что кодируем в QR: инв. №, иначе EQ{pk}.""" + inv = (getattr(unit, 'inventory_number', None) or '').strip() + if inv: + return inv + return f'EQ{unit.pk}' + + +def _png_b64(img: Image.Image) -> str: + buf = io.BytesIO() + img.save(buf, format='PNG') + return base64.b64encode(buf.getvalue()).decode('ascii') + + +def qr_png_base64(payload: str, *, box_size: int = 4, border: int = 1) -> str: + qr = qrcode.QRCode(version=None, box_size=box_size, border=border) + qr.add_data(payload) + qr.make(fit=True) + img = qr.make_image(fill_color='black', back_color='white') + if not isinstance(img, Image.Image): + img = img.get_image() + return _png_b64(img.convert('RGB')) + + +def ean13_png_base64(pk: int) -> tuple[str, str]: + """(png_base64, human_digits).""" + EAN13 = get_barcode_class('ean13') + code = EAN13( + equipment_ean13_body(pk), + writer=ImageWriter(), + ) + buf = io.BytesIO() + code.write( + buf, + options={ + 'module_width': 0.25, + 'module_height': 10.0, + 'quiet_zone': 2.0, + 'font_size': 8, + 'text_distance': 3.0, + 'write_text': True, + }, + ) + buf.seek(0) + img = Image.open(buf).convert('RGB') + return _png_b64(img), code.get_fullcode() + + +def label_payload_for_unit(unit) -> dict[str, Any]: + qr_text = equipment_qr_payload(unit) + ean_b64, ean_digits = ean13_png_base64(unit.pk) + model = unit.equipment_model + return { + '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(), + 'qr_payload': qr_text, + 'qr_png': qr_png_base64(qr_text), + 'ean13': ean_digits, + 'ean_png': ean_b64, + } diff --git a/apps/it_manager/tests.py b/apps/it_manager/tests.py index 31b8983..36439ba 100644 --- a/apps/it_manager/tests.py +++ b/apps/it_manager/tests.py @@ -427,6 +427,26 @@ class ItManagerEquipmentTests(TestCase): self.assertContains(r, 'должно совпадать') self.assertFalse(Equipment.objects.filter(serial_number='SN-INV-3').exists()) + def test_equipment_labels_page(self): + from apps.it_manager.equipment_labels import equipment_ean13, equipment_qr_payload + + eq = make_equipment(serial_number='LBL-SN-1', inventory_number='INV-LBL-1') + self.assertEqual(equipment_qr_payload(eq), 'INV-LBL-1') + ean = equipment_ean13(eq.pk) + self.assertEqual(len(ean), 13) + self.assertTrue(ean.startswith('200')) + + r = self.client.get(f'/it-manager/equipment/labels/?ids={eq.pk}') + self.assertEqual(r.status_code, 200) + self.assertContains(r, 'INV-LBL-1') + self.assertContains(r, 'data:image/png;base64,') + self.assertContains(r, ean) + + 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_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 3335599..61a40be 100644 --- a/apps/it_manager/urls.py +++ b/apps/it_manager/urls.py @@ -30,6 +30,7 @@ urlpatterns = [ path('it-manager/equipment/models/create/', views.equipment_model_create_view, name='it_equipment_model_create'), 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//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 b71581d..93229ad 100644 --- a/apps/it_manager/views.py +++ b/apps/it_manager/views.py @@ -267,13 +267,26 @@ def _it_manager_context(request): equipment_qs = equipment_base if eq_q: - equipment_qs = equipment_qs.filter( + eq_q_filter = ( Q(serial_number__icontains=eq_q) | Q(inventory_number__icontains=eq_q) | Q(notes__icontains=eq_q) | Q(equipment_model__name__icontains=eq_q) | Q(equipment_model__brand__name__icontains=eq_q) ) + # Скан EAN-13 (200XXXXXXXXXC) или QR EQ{id} + digits = ''.join(c for c in eq_q if c.isdigit()) + scan_pk = None + if eq_q.upper().startswith('EQ') and eq_q[2:].isdigit(): + scan_pk = int(eq_q[2:]) + elif len(digits) >= 12 and digits.startswith('200'): + try: + scan_pk = int(digits[3:12]) + except ValueError: + scan_pk = None + if scan_pk: + eq_q_filter |= Q(pk=scan_pk) + equipment_qs = equipment_qs.filter(eq_q_filter) if eq_status: equipment_qs = equipment_qs.filter(status__code=eq_status) if eq_type: @@ -737,6 +750,46 @@ def equipment_bulk_create_view(request): return render_page(request, 'it-manager.html', context) +@login_required +def equipment_labels_view(request): + """Печатная страница этикеток QR + EAN-13 для выбранного оборудования.""" + from django.shortcuts import render + + from apps.it_manager.equipment_labels import label_payload_for_unit + + if not _is_it_staff(request.user): + return deny_section_access(request) + + 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) + + units_by_id = { + u.pk: u + for u in scoped_queryset(request.user, Equipment.objects.all()) + .filter(pk__in=ordered) + .select_related( + 'equipment_model', 'equipment_model__brand', 'equipment_model__equipment_type', + ) + } + labels = [] + for pk in ordered: + unit = units_by_id.get(pk) + if unit is not None: + labels.append(label_payload_for_unit(unit)) + + return render(request, 'pages/it-equipment-labels.html', {'labels': labels}) + + @login_required @_location_scoped_post def equipment_bulk_assign_view(request): diff --git a/apps/locations/forms.py b/apps/locations/forms.py index 8b4fd1f..9cd2f94 100644 --- a/apps/locations/forms.py +++ b/apps/locations/forms.py @@ -1,11 +1,11 @@ -"""Формы портала для аптек.""" +"""Формы портала для аптек и сервисов/поставщиков.""" from __future__ import annotations from django import forms from django.utils.translation import gettext_lazy as _ from core.models import Directory -from .models import Pharmacy +from .models import Location, Pharmacy class HierarchicalRegionChoiceField(forms.ModelChoiceField): @@ -102,3 +102,60 @@ class PortalPharmacyEditForm(forms.ModelForm): if user is not None and not user.has_perm('locations.manage_location_access'): for field in ('head_pharmacist', 'responsible_manager', 'curator'): self.fields.pop(field, None) + + +class PortalVendorForm(forms.ModelForm): + """Создание/редактирование сервиса или поставщика (location_type=vendor).""" + + location_region = HierarchicalRegionChoiceField( + label=_('Город/район'), + empty_label=_('— Выберите регион —'), + required=True, + ) + + class Meta: + model = Location + fields = ( + 'name', 'location_region', + 'street', 'house_number', 'building', 'apartment', + 'comments', 'is_active', + ) + widgets = { + 'name': forms.TextInput(attrs={ + 'class': 'input', + 'placeholder': 'Вайтер Юг', + 'required': True, + }), + 'street': forms.TextInput(attrs={'class': 'input'}), + 'house_number': forms.TextInput(attrs={'class': 'input'}), + 'building': forms.TextInput(attrs={'class': 'input'}), + 'apartment': forms.TextInput(attrs={'class': 'input'}), + 'comments': forms.Textarea(attrs={'class': 'input', 'rows': 2}), + 'is_active': forms.CheckboxInput(), + } + + def __init__(self, *args, **kwargs): + 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'): + self.fields[optional].required = False + + def clean_name(self): + name = (self.cleaned_data.get('name') or '').strip() + if not name: + raise forms.ValidationError(_('Укажите название.')) + qs = Location.objects.filter(location_type='vendor', name__iexact=name) + if self.instance and self.instance.pk: + qs = qs.exclude(pk=self.instance.pk) + if qs.exists(): + raise forms.ValidationError(_('Такой сервис/поставщик уже есть.')) + return name + + def save(self, commit=True): + obj = super().save(commit=False) + obj.location_type = 'vendor' + obj.name = (obj.name or '').strip() + if commit: + obj.save() + return obj diff --git a/apps/locations/tests.py b/apps/locations/tests.py index b5482a8..1d8faee 100644 --- a/apps/locations/tests.py +++ b/apps/locations/tests.py @@ -707,3 +707,98 @@ class LocationSortKeyTests(TestCase): grant_all_locations(user) ids = {loc.pk for loc in _it_locations_sorted(user)} self.assertIn(vendor.pk, ids) + + +class DislocationVendorsTabTests(TestCase): + """Вкладка «Сервисы / поставщики» на /dislocation/.""" + + @classmethod + def setUpTestData(cls): + from django.contrib.auth.models import Group + from apps.employees.tests import make_employee + from apps.locations.models.base import Location + + region_type, _ = DirectoryType.objects.get_or_create( + code='region', defaults={'name': 'Регион'}, + ) + cls.region = Directory.objects.create( + directory_type=region_type, code='od-dislo-v', name='Одесса-dislo-v', + ) + cls.vendor = Location.objects.create( + location_type='vendor', + name='ТестСервис', + location_region=cls.region, + is_active=True, + ) + cls.viewer = make_employee('dislo-vend-viewer', user_location_type='office') + dept_type, _ = DirectoryType.objects.get_or_create( + code='department', defaults={'name': 'Отдел'}, + ) + dept = Directory.objects.create(directory_type=dept_type, code='it-dv', name='IT-dv') + cls.viewer.department = dept + cls.viewer.save(update_fields=['department']) + cls.viewer.groups.add(Group.objects.get_or_create(name='Сотрудники (тест)')[0]) + + cls.it_user = make_employee('dislo-vend-it', user_location_type='office') + cls.it_user.department = dept + cls.it_user.save(update_fields=['department']) + cls.it_user.groups.add(Group.objects.get_or_create(name='IT')[0]) + + def test_tab_lists_vendors(self): + from django.test import Client + from core.test_utils import login_2fa + + client = Client() + login_2fa(client, self.viewer) + r = client.get('/dislocation/', {'tab': 'vendors'}, HTTP_HX_REQUEST='true') + self.assertEqual(r.status_code, 200) + self.assertContains(r, 'Сервисы / поставщики') + self.assertContains(r, 'ТестСервис') + self.assertNotContains(r, 'Добавить') + + def test_it_can_create_vendor(self): + from django.test import Client + from apps.locations.models.base import Location + from core.test_utils import login_2fa + + client = Client() + login_2fa(client, self.it_user) + r = client.post('/dislocation/vendors/create/', { + 'name': 'Новый Поставщик', + 'location_region': str(self.region.pk), + 'is_active': 'on', + }, HTTP_HX_REQUEST='true') + self.assertEqual(r.status_code, 200) + self.assertTrue( + Location.objects.filter(location_type='vendor', name='Новый Поставщик').exists() + ) + self.assertContains(r, 'Новый Поставщик') + + def test_viewer_cannot_create_vendor(self): + from django.test import Client + from core.test_utils import login_2fa + + client = Client() + login_2fa(client, self.viewer) + r = client.post('/dislocation/vendors/create/', { + 'name': 'Запрещённый', + 'location_region': str(self.region.pk), + }, HTTP_HX_REQUEST='true') + self.assertEqual(r.status_code, 403) + + def test_it_can_update_vendor(self): + from django.test import Client + from core.test_utils import login_2fa + + client = Client() + login_2fa(client, self.it_user) + r = client.post(f'/dislocation/vendors/{self.vendor.pk}/update/', { + 'name': 'ТестСервис Обновлён', + 'location_region': str(self.region.pk), + 'comments': 'ремонт техники', + 'is_active': 'on', + }, HTTP_HX_REQUEST='true') + self.assertEqual(r.status_code, 200) + self.vendor.refresh_from_db() + self.assertEqual(self.vendor.name, 'ТестСервис Обновлён') + self.assertEqual(self.vendor.comments, 'ремонт техники') diff --git a/apps/locations/views.py b/apps/locations/views.py index 80f8930..13c0af9 100644 --- a/apps/locations/views.py +++ b/apps/locations/views.py @@ -10,8 +10,40 @@ from core.utils.htmx import render_page from core.location_access import accessible_pharmacies -from .forms import PortalPharmacyEditForm -from .models import Pharmacy, pharmacy_card_flags, user_can_edit_pharmacy +from .forms import PortalPharmacyEditForm, PortalVendorForm +from .models import Location, Pharmacy, pharmacy_card_flags, user_can_edit_pharmacy + + +def user_can_manage_vendors(user) -> bool: + """IT-раздел или staff — могут вести справочник сервисов/поставщиков.""" + if not user or not getattr(user, 'is_authenticated', False): + return False + if getattr(user, 'is_staff', False) or getattr(user, 'is_superuser', False): + return True + from core.portal_access import user_can_access + return user_can_access(user, 'it_manager') + + +def _vendor_form_errors(form) -> str: + errors = [] + for field, msgs in form.errors.items(): + label = form.fields[field].label if field in form.fields else field + for msg in msgs: + errors.append(f'{label}: {msg}' if field != '__all__' else msg) + return ' '.join(errors) or 'Не удалось сохранить.' + + +def _dislocation_vendors_response(request, *, message=None, error=None, form=None): + from core.views import dislocation_view + + request._dislo_tab = 'vendors' + if message: + request._dislo_vendor_message = message + if error: + request._dislo_vendor_error = error + if form is not None: + request._dislo_vendor_form = form + return dislocation_view(request) def _pharmacy_detail_context(request, pharmacy, *, form=None, edit_mode=False, @@ -131,3 +163,34 @@ def pharmacy_update_view(request, pk): form_error=' '.join(errors) or 'Не удалось сохранить.', ) return render_page(request, 'pharmacy-detail.html', context) + + +@login_required +@require_POST +def vendor_create_view(request): + if not user_can_manage_vendors(request.user): + return HttpResponseForbidden() + + form = PortalVendorForm(request.POST) + if form.is_valid(): + form.save() + return _dislocation_vendors_response(request, message='Сервис / поставщик добавлен.') + return _dislocation_vendors_response( + request, error=_vendor_form_errors(form), form=form, + ) + + +@login_required +@require_POST +def vendor_update_view(request, pk): + if not user_can_manage_vendors(request.user): + return HttpResponseForbidden() + + vendor = get_object_or_404(Location, pk=pk, location_type='vendor') + form = PortalVendorForm(request.POST, instance=vendor) + if form.is_valid(): + form.save() + return _dislocation_vendors_response(request, message='Изменения сохранены.') + return _dislocation_vendors_response( + request, error=_vendor_form_errors(form), form=form, + ) diff --git a/core/views.py b/core/views.py index 443ba27..f1fe49a 100644 --- a/core/views.py +++ b/core/views.py @@ -424,6 +424,22 @@ def dislocation_view(request): office_departments = [d for d in office_departments if d.staff] office_no_department = by_dept.get(None, []) + # --- Сервисы / поставщики (location_type=vendor) --- + from apps.locations.forms import PortalVendorForm + from apps.locations.models.base import Location, location_sort_key + from apps.locations.views import user_can_manage_vendors + + vendors = sorted( + Location.objects.filter(location_type='vendor') + .select_related('location_region') + .order_by('name', 'id'), + key=location_sort_key, + ) + can_manage_vendors = user_can_manage_vendors(request.user) + vendor_form = getattr(request, '_dislo_vendor_form', None) + if vendor_form is None and can_manage_vendors: + vendor_form = PortalVendorForm() + visible_keys = resolve_columns_for_user(request.user) visible_columns = column_defs_for(visible_keys) visible_set = set(visible_keys) @@ -453,6 +469,17 @@ def dislocation_view(request): 'office_departments': office_departments, 'office_no_department': office_no_department, 'office_employees_count': len(office_employees), + 'vendors': vendors, + 'vendors_active_count': sum(1 for v in vendors if v.is_active), + 'can_manage_vendors': can_manage_vendors, + 'vendor_form': vendor_form, + 'vendor_form_message': getattr(request, '_dislo_vendor_message', None), + 'vendor_form_error': getattr(request, '_dislo_vendor_error', None), + 'dislo_active_tab': ( + getattr(request, '_dislo_tab', None) + or (request.GET.get('tab') or '').strip() + or 'pharmacy' + ), 'visible_columns': visible_columns, 'visible_column_keys': visible_keys, 'all_column_defs': all_defs, diff --git a/requirements.txt b/requirements.txt index eeeb3be..da03370 100644 --- a/requirements.txt +++ b/requirements.txt @@ -54,6 +54,7 @@ pyasn1==0.6.3 pycparser==3.0 PyJWT==2.12.1 PyOTP==2.10.0 +python-barcode==0.16.1 python-crontab==3.3.0 python-dateutil==2.9.0.post0 pytils==0.4.4 diff --git a/static/assets/js/it-manager.js b/static/assets/js/it-manager.js index 902e1b7..9ed548a 100644 --- a/static/assets/js/it-manager.js +++ b/static/assets/js/it-manager.js @@ -151,6 +151,14 @@ function itBulkClearSelection() { itBulkUpdate(); } +function itEquipmentOpenLabels() { + 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(',')); + window.open(url, '_blank', 'noopener'); +} + function itSetSearchableSelectValue(selectId, value) { var select = document.getElementById(selectId); if (!select) return; diff --git a/templates/pages/dislocation.html b/templates/pages/dislocation.html index a2f61cf..3f02221 100644 --- a/templates/pages/dislocation.html +++ b/templates/pages/dislocation.html @@ -6,20 +6,22 @@

{% trans "Дислокация" %}

-

{{ pharmacies|length }} {% trans "аптек" %} · {{ office_employees_count }} {% trans "сотрудников офиса/склада" %} · {% trans "персонал по каждой локации" %}

+

{{ pharmacies|length }} {% trans "аптек" %} · {{ office_employees_count }} {% trans "сотрудников офиса/склада" %} · {{ vendors|length }} {% trans "сервисов/поставщиков" %}

- - + + +
-
+