diff --git a/apps/it_manager/tests.py b/apps/it_manager/tests.py index c991d1a..41d0a04 100644 --- a/apps/it_manager/tests.py +++ b/apps/it_manager/tests.py @@ -269,6 +269,130 @@ class ItManagerEquipmentTests(TestCase): EquipmentAssignment.objects.filter(equipment=unit, location=location).count(), 1, ) + def test_search_by_serial_and_inventory(self): + make_equipment(serial_number='SRCH-SN-AA', inventory_number='INV-1001') + make_equipment(serial_number='OTHER-99', inventory_number='INV-2002') + + r = self.client.get('/it-manager/', {'tab': 'equipment', 'q': 'SRCH-SN'}) + self.assertEqual(r.status_code, 200) + serials = [e.serial_number for e in r.context['equipment_page']] + self.assertEqual(serials, ['SRCH-SN-AA']) + + r = self.client.get('/it-manager/', {'tab': 'equipment', 'q': 'INV-2002'}) + self.assertEqual(r.status_code, 200) + serials = [e.serial_number for e in r.context['equipment_page']] + self.assertEqual(serials, ['OTHER-99']) + + def test_filter_by_status_and_location(self): + from apps.locations.models.base import Location + from core.models.directories import DirectoryType + + region_type, _ = DirectoryType.objects.get_or_create( + code='location_region', defaults={'name': 'Регион'}, + ) + region, _ = Directory.objects.get_or_create( + directory_type=region_type, code='eq-filt-reg', defaults={'name': 'Фильтр'}, + ) + loc_a = Location.objects.create( + address='Лок А', location_type='pharmacy', + location_region=region, is_active=True, + ) + loc_b = Location.objects.create( + address='Лок Б', location_type='pharmacy', + location_region=region, is_active=True, + ) + stock = make_equipment(serial_number='FILT-STOCK') + assigned = make_equipment( + serial_number='FILT-ASGN', + status_id=_dir_id('equipment_status', 'assigned'), + location=loc_a, + ) + make_equipment( + serial_number='FILT-OTHER', + status_id=_dir_id('equipment_status', 'assigned'), + location=loc_b, + ) + + r = self.client.get('/it-manager/', {'tab': 'equipment', 'status': 'stock'}) + self.assertEqual(r.status_code, 200) + serials = [e.serial_number for e in r.context['equipment_page']] + self.assertIn('FILT-STOCK', serials) + self.assertNotIn('FILT-ASGN', serials) + + r = self.client.get('/it-manager/', { + 'tab': 'equipment', 'status': 'assigned', 'location_id': str(loc_a.pk), + }) + self.assertEqual(r.status_code, 200) + serials = [e.serial_number for e in r.context['equipment_page']] + self.assertEqual(serials, ['FILT-ASGN']) + self.assertEqual(stock.status.code, 'stock') + self.assertEqual(assigned.location_id, loc_a.pk) + + def test_update_inventory_number_and_notes(self): + equipment = make_equipment(serial_number='INV-EDIT-1') + r = self.client.post(f'/it-manager/equipment/{equipment.id}/update/', { + 'status': 'stock', + 'assign_target': '', + 'inventory_number': 'INV-EDIT-99', + 'notes': 'Проверено на складе', + 'purchase_date': '2025-06-01', + 'warranty_until': '2027-06-01', + }, HTTP_HX_REQUEST='true') + self.assertEqual(r.status_code, 200) + equipment.refresh_from_db() + self.assertEqual(equipment.inventory_number, 'INV-EDIT-99') + self.assertEqual(equipment.notes, 'Проверено на складе') + self.assertEqual(str(equipment.purchase_date), '2025-06-01') + self.assertEqual(str(equipment.warranty_until), '2027-06-01') + + other = make_equipment(serial_number='INV-EDIT-2', inventory_number='INV-TAKEN') + r = self.client.post(f'/it-manager/equipment/{equipment.id}/update/', { + 'status': 'stock', + 'assign_target': '', + 'inventory_number': other.inventory_number, + 'notes': equipment.notes, + }, HTTP_HX_REQUEST='true') + self.assertEqual(r.status_code, 200) + self.assertContains(r, 'уже используется') + equipment.refresh_from_db() + self.assertEqual(equipment.inventory_number, 'INV-EDIT-99') + + def test_bulk_create_with_inventory_numbers(self): + model = make_equipment_model() + r = self.client.post('/it-manager/equipment/bulk-create/', { + 'equipment_model_id': str(model.pk), + 'serial_numbers': 'SN-INV-1\nSN-INV-2', + 'inventory_numbers': 'INV-A1\nINV-A2', + }, HTTP_HX_REQUEST='true') + self.assertEqual(r.status_code, 200) + u1 = Equipment.objects.get(serial_number='SN-INV-1') + u2 = Equipment.objects.get(serial_number='SN-INV-2') + self.assertEqual(u1.inventory_number, 'INV-A1') + self.assertEqual(u2.inventory_number, 'INV-A2') + + r = self.client.post('/it-manager/equipment/bulk-create/', { + 'equipment_model_id': str(model.pk), + 'serial_numbers': 'SN-INV-3\nSN-INV-4', + 'inventory_numbers': 'INV-ONLY-ONE', + }, HTTP_HX_REQUEST='true') + self.assertEqual(r.status_code, 200) + self.assertContains(r, 'должно совпадать') + self.assertFalse(Equipment.objects.filter(serial_number='SN-INV-3').exists()) + + def test_assignment_history_visible_in_card(self): + equipment = make_equipment(serial_number='HIST-UI-1') + emp = make_employee(first_name='Иван', last_name='Историков') + self.client.post(f'/it-manager/equipment/{equipment.id}/update/', { + 'status': 'assigned', + 'assign_target': 'employee', + 'assigned_to_id': emp.id, + }) + r = self.client.get('/it-manager/', {'tab': 'equipment', 'q': 'HIST-UI-1'}) + self.assertEqual(r.status_code, 200) + self.assertContains(r, 'История перемещений') + self.assertContains(r, 'Историков') + self.assertContains(r, 'открыта') + class ItManagerTicketTests(TestCase): diff --git a/apps/it_manager/views.py b/apps/it_manager/views.py index dbe4b8e..9193747 100644 --- a/apps/it_manager/views.py +++ b/apps/it_manager/views.py @@ -4,8 +4,8 @@ import calendar from django.contrib.auth import get_user_model from django.contrib.auth.decorators import login_required from django.core.exceptions import ValidationError -from django.db import models -from django.db.models import Q, Sum +from django.db import IntegrityError, models +from django.db.models import Count, Prefetch, Q, Sum from django.http import HttpResponse, HttpResponseForbidden from django.shortcuts import get_object_or_404 from django.utils import timezone @@ -222,18 +222,71 @@ def _apply_equipment_issue(unit, *, location_id=None, assigned_to_id=None, user= def _it_manager_context(request): - equipment_qs = ( + equipment_base = ( scoped_queryset(request.user, Equipment.objects.all()).select_related( 'equipment_model__brand', 'equipment_model__equipment_type', 'assigned_to', 'location', 'status', ) .order_by('equipment_model__equipment_type__name', 'equipment_model__name', 'serial_number') ) - equipment = list(equipment_qs) + equipment = list(equipment_base) equipment_statuses = _directory_choices('equipment_status') equipment_counts = {code: 0 for code, _ in equipment_statuses} - for item in equipment: - equipment_counts[item.status.code] = equipment_counts.get(item.status.code, 0) + 1 + for row in equipment_base.values('status__code').annotate(c=Count('id')): + code = row['status__code'] + if code: + equipment_counts[code] = row['c'] + equipment_total_count = equipment_base.count() + + eq_q = (request.GET.get('q') or '').strip() + eq_status = (request.GET.get('status') or '').strip() + eq_type = (request.GET.get('equipment_type') or '').strip() + eq_brand = (request.GET.get('brand') or '').strip() + eq_location_raw = (request.GET.get('location_id') or '').strip() + eq_location_id = int(eq_location_raw) if eq_location_raw.isdigit() else None + eq_assignee_raw = (request.GET.get('assigned_to') or '').strip() + eq_assigned_to_id = int(eq_assignee_raw) if eq_assignee_raw.isdigit() else None + + equipment_qs = equipment_base + if eq_q: + equipment_qs = equipment_qs.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) + ) + if eq_status: + equipment_qs = equipment_qs.filter(status__code=eq_status) + if eq_type: + equipment_qs = equipment_qs.filter(equipment_model__equipment_type__code=eq_type) + if eq_brand.isdigit(): + equipment_qs = equipment_qs.filter(equipment_model__brand_id=int(eq_brand)) + if eq_location_id: + equipment_qs = equipment_qs.filter(location_id=eq_location_id) + if eq_assigned_to_id: + equipment_qs = equipment_qs.filter(assigned_to_id=eq_assigned_to_id) + + equipment_filters = { + 'q': eq_q, + 'status': eq_status, + 'equipment_type': eq_type, + 'brand': eq_brand, + 'location_id': eq_location_id, + 'assigned_to': eq_assigned_to_id, + } + equipment_filters_active = any([ + eq_q, eq_status, eq_type, eq_brand, eq_location_id, eq_assigned_to_id, + ]) + + equipment_qs = equipment_qs.prefetch_related( + Prefetch( + 'assignments', + queryset=EquipmentAssignment.objects.select_related( + 'assigned_to', 'location', 'assigned_by', + ).order_by('-assigned_at'), + ), + ) equipment_page = paginate(request, equipment_qs, page_param='equipment_page', size_param='equipment_per_page') equipment_models = list( @@ -388,7 +441,10 @@ def _it_manager_context(request): return { 'equipment_list': equipment, 'equipment_page': equipment_page, + 'equipment_total_count': equipment_total_count, 'equipment_counts': equipment_counts, + 'equipment_filters': equipment_filters, + 'equipment_filters_active': equipment_filters_active, 'equipment_types': _directory_choices('equipment_type'), 'equipment_statuses': equipment_statuses, 'equipment_brands': Directory.objects.filter(directory_type__code='equipment_brand', is_active=True).order_by('name'), @@ -466,6 +522,16 @@ def it_manager_view(request): or request.GET.get('worklogs_page') ): context['it_active_tab'] = 'worklog' + elif ( + request.GET.get('q') + or request.GET.get('status') + or request.GET.get('equipment_type') + or request.GET.get('brand') + or request.GET.get('location_id') + or request.GET.get('assigned_to') + or request.GET.get('equipment_page') + ): + context['it_active_tab'] = 'equipment' return render_page(request, 'it-manager.html', context) @@ -522,11 +588,24 @@ def equipment_bulk_create_view(request): seen.add(s) serials.append(s) + raw_inventory = request.POST.get('inventory_numbers', '') + inventory_list = None + if raw_inventory.strip(): + inv_lines = raw_inventory.splitlines() + while inv_lines and not inv_lines[-1].strip(): + inv_lines.pop() + inventory_list = [(line.strip() or None) for line in inv_lines] + model = EquipmentModel.objects.filter(pk=model_id).first() if model_id else None if not model: context_extra['equipment_form_error'] = 'Оберіть модель з каталогу.' elif not serials: context_extra['equipment_form_error'] = 'Вставте хоча б один серійний номер.' + elif inventory_list is not None and len(inventory_list) != len(serials): + context_extra['equipment_form_error'] = ( + 'Количество инвентарных номеров должно совпадать с количеством серийных ' + f'({len(serials)}).' + ) else: purchase_date = request.POST.get('purchase_date') or None warranty_until = request.POST.get('warranty_until') or None @@ -556,23 +635,55 @@ def equipment_bulk_create_view(request): status_id = _resolve_directory('equipment_status', status_code) \ or _resolve_directory('equipment_status', 'stock') + if inventory_list: + non_empty_inv = [inv for inv in inventory_list if inv] + if len(non_empty_inv) != len(set(non_empty_inv)): + context_extra['equipment_form_error'] = 'В списке есть повторяющиеся инвентарные номера.' + context = _it_manager_context(request) + context['it_active_tab'] = 'equipment' + context.update(context_extra) + return render_page(request, 'it-manager.html', context) + existing_inv = set( + Equipment.objects.filter(inventory_number__in=non_empty_inv) + .values_list('inventory_number', flat=True) + ) + if existing_inv: + context_extra['equipment_form_error'] = ( + 'Инвентарные номера уже заняты: ' + ', '.join(sorted(existing_inv)) + ) + context = _it_manager_context(request) + context['it_active_tab'] = 'equipment' + context.update(context_extra) + return render_page(request, 'it-manager.html', context) + existing = set( scoped_queryset(request.user, Equipment.objects.all()).filter(serial_number__in=serials) .values_list('serial_number', flat=True) ) created = 0 - for s in serials: + for idx, s in enumerate(serials): if s in existing: continue - unit = Equipment.objects.create( - equipment_model=model, - serial_number=s, - status_id=status_id, - location_id=location_id, - assigned_to_id=assigned_to_id, - purchase_date=purchase_date, - warranty_until=warranty_until, - ) + inv = inventory_list[idx] if inventory_list else None + try: + unit = Equipment.objects.create( + equipment_model=model, + serial_number=s, + inventory_number=inv, + status_id=status_id, + location_id=location_id, + assigned_to_id=assigned_to_id, + purchase_date=purchase_date, + warranty_until=warranty_until, + ) + except IntegrityError: + context_extra['equipment_form_error'] = ( + f'Не удалось создать «{s}»: серийный или инвентарный номер уже существует.' + ) + context = _it_manager_context(request) + context['it_active_tab'] = 'equipment' + context.update(context_extra) + return render_page(request, 'it-manager.html', context) if location_id or assigned_to_id: EquipmentAssignment.objects.create( equipment=unit, @@ -646,6 +757,28 @@ def equipment_update_view(request, pk): new_status_code = request.POST.get('status') or '' new_status_id = _resolve_directory('equipment_status', new_status_code) target, location_id, assigned_to_id, assign_err = _parse_assign_post(request) + inventory_number = (request.POST.get('inventory_number') or '').strip() or None + notes = request.POST.get('notes', '') + purchase_date = request.POST.get('purchase_date') or None + warranty_until = request.POST.get('warranty_until') or None + + if inventory_number and ( + Equipment.objects.exclude(pk=equipment.pk) + .filter(inventory_number=inventory_number) + .exists() + ): + context = _it_manager_context(request) + context['it_active_tab'] = 'equipment' + context['equipment_form_error'] = ( + f'Инвентарный номер «{inventory_number}» уже используется.' + ) + return render_page(request, 'it-manager.html', context) + + def _apply_meta(unit): + unit.inventory_number = inventory_number + unit.notes = notes + unit.purchase_date = purchase_date + unit.warranty_until = warranty_until # Повернення на склад: статус stock або порожній тип видачі if new_status_code == 'stock' or not target: @@ -660,7 +793,16 @@ def equipment_update_view(request, pk): equipment.status_id = stock_id equipment.location_id = None equipment.assigned_to_id = None - equipment.save() + _apply_meta(equipment) + try: + equipment.save() + except IntegrityError: + context = _it_manager_context(request) + context['it_active_tab'] = 'equipment' + context['equipment_form_error'] = ( + f'Инвентарный номер «{inventory_number}» уже используется.' + ) + return render_page(request, 'it-manager.html', context) elif assign_err: context = _it_manager_context(request) context['it_active_tab'] = 'equipment' @@ -677,9 +819,18 @@ def equipment_update_view(request, pk): force_assigned_status=True, ) equipment.refresh_from_db() + _apply_meta(equipment) if new_status_id and new_status_code not in ('', 'stock') and equipment.status.code != new_status_code: equipment.status_id = new_status_id - equipment.save(update_fields=['status_id', 'updated_at']) + try: + equipment.save() + except IntegrityError: + context = _it_manager_context(request) + context['it_active_tab'] = 'equipment' + context['equipment_form_error'] = ( + f'Инвентарный номер «{inventory_number}» уже используется.' + ) + return render_page(request, 'it-manager.html', context) context = _it_manager_context(request) context['it_active_tab'] = 'equipment' diff --git a/templates/pages/it-manager.html b/templates/pages/it-manager.html index 5589793..45318e8 100644 --- a/templates/pages/it-manager.html +++ b/templates/pages/it-manager.html @@ -36,22 +36,30 @@
+