From e3ba6ea925b8d42e640b53203d4bf2e2f89f56d0 Mon Sep 17 00:00:00 2001 From: lizerk Date: Sun, 12 Apr 2026 16:09:38 +0300 Subject: [PATCH] refactor(ui): stabilize equipment deep links and qr flow --- backend/api/v1/procurement/views.py | 50 +- backend/api/v1/requests_app/views.py | 11 +- backend/procurement/README.md | 4 +- backend/procurement/services.py | 103 ++-- backend/requirements.txt | 1 - .../static/js/modules/equipmentListHandler.js | 52 -- .../procurement/equipment_detail.html | 7 - .../templates/procurement/equipment_list.html | 10 - .../api/v1/procurement/test_equipment.py | 16 - .../procurement/test_procurement_requests.py | 79 +++ .../api/v1/requests_app/test_requests_api.py | 10 + backend/tests/procurement/test_inventory.py | 99 +--- frontend/package-lock.json | 284 ++++++++++- frontend/package.json | 2 + frontend/src/app/documents/page.tsx | 4 +- frontend/src/app/equipment/page.tsx | 457 ++++++++++++++++-- frontend/src/app/globals.css | 68 +++ frontend/src/app/page.tsx | 6 +- frontend/src/app/procurement/page.tsx | 125 ++--- frontend/src/app/profile/page.tsx | 8 +- frontend/src/app/requests/page.tsx | 10 +- frontend/src/app/users/[id]/page.tsx | 6 +- .../src/components/ViewDayEventsModal.tsx | 2 +- .../src/components/ViewEventDetailsModal.tsx | 2 +- .../components/documents/DocumentComments.tsx | 2 +- .../documents/DocumentDetailModal.tsx | 4 +- .../messages/ReactionPickerModal.tsx | 12 +- .../components/requests/SwipeApprovalMode.tsx | 4 +- .../users/EmployeeActionsTimeline.tsx | 2 +- frontend/src/hooks/useEquipmentPage.ts | 36 +- frontend/src/hooks/useProcurementPage.ts | 11 +- frontend/src/lib/api/equipment.ts | 9 - 32 files changed, 1059 insertions(+), 437 deletions(-) diff --git a/backend/api/v1/procurement/views.py b/backend/api/v1/procurement/views.py index 48b0929b..5e1078d5 100644 --- a/backend/api/v1/procurement/views.py +++ b/backend/api/v1/procurement/views.py @@ -27,7 +27,7 @@ from procurement.models import ( ) from communications import comments_helpers from communications.models import Message -from procurement.services import ProcurementApprovalResolver, QRCodeGenerator +from procurement.services import ProcurementApprovalResolver from .permissions import ( CanApproveProcurementRequest, CanManageEquipment, @@ -198,16 +198,43 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet): status=status.HTTP_400_BAD_REQUEST, ) - resolved_approvals, missing_priorities = ( - ProcurementApprovalResolver.resolve_required_approvers( + resolved_approvals, missing_routes = ( + ProcurementApprovalResolver.resolve_required_approvers_detailed( procurement_request ) ) - if missing_priorities: + if missing_routes: + missing_priorities = [ + route["priority"] for route in missing_routes + ] + missing_department_head = next( + ( + route + for route in missing_routes + if route["reason"] == "department_head_missing" + ), + None, + ) + if missing_department_head: + return Response( + { + "error": ( + "У выбранного отдела не назначен руководитель. " + "Заявку нельзя отправить на согласование, " + "пока не будет назначен начальник отдела." + ), + "code": "department_head_missing", + "missing_priorities": missing_priorities, + "missing_routes": missing_routes, + }, + status=status.HTTP_400_BAD_REQUEST, + ) return Response( { "error": "Не настроены согласующие для обязательных этапов", + "code": "approval_routes_incomplete", "missing_priorities": missing_priorities, + "missing_routes": missing_routes, }, status=status.HTTP_400_BAD_REQUEST, ) @@ -1186,21 +1213,6 @@ class EquipmentViewSet(viewsets.ModelViewSet): status=status.HTTP_201_CREATED, ) - @action(detail=True, methods=["get"]) - def qr_code(self, request, pk=None): - """Получить QR-код для оборудования.""" - equipment = self.get_object() - - qr_file = QRCodeGenerator.generate_for_equipment(equipment) - - from django.http import HttpResponse - - response = HttpResponse(qr_file.read(), content_type="image/png") - response["Content-Disposition"] = ( - f'inline; filename="equipment_{equipment.id}_qr.png"' - ) - return response - @action(detail=True, methods=["get"]) def transfer_history(self, request, pk=None): """История переводов оборудования.""" diff --git a/backend/api/v1/requests_app/views.py b/backend/api/v1/requests_app/views.py index c30e7928..42f5f3c1 100644 --- a/backend/api/v1/requests_app/views.py +++ b/backend/api/v1/requests_app/views.py @@ -3,7 +3,8 @@ from __future__ import annotations from typing import Any # было: Any, Dict, List, Type from django.contrib.contenttypes.models import ContentType -from django.db.models import Count, OuterRef, Q, QuerySet, Subquery +from django.db.models import Count, IntegerField, OuterRef, Q, QuerySet, Subquery, Value +from django.db.models.functions import Coalesce from django.shortcuts import get_object_or_404 # раскомментируйте импорт from employees.models import ( Department, @@ -299,13 +300,19 @@ class RequestViewSet(viewsets.ModelViewSet): chat__type="comments", chat__context_content_type=request_ct, chat__context_object_id=OuterRef("pk"), + is_deleted=False, ) .values("chat") .annotate(count=Count("id")) .values("count") ) - qs = qs.annotate(comments_count=Subquery(comments_subquery)) + qs = qs.annotate( + comments_count=Coalesce( + Subquery(comments_subquery, output_field=IntegerField()), + Value(0), + ) + ) return qs diff --git a/backend/procurement/README.md b/backend/procurement/README.md index cfa02e8d..c4fa1dd5 100644 --- a/backend/procurement/README.md +++ b/backend/procurement/README.md @@ -25,7 +25,6 @@ ### 3. Инвентаризация оборудования - Учет оборудования с инвентарными номерами -- QR-коды для маркировки (автогенерация) - Иерархические категории оборудования - Статусы: на складе, в использовании, на обслуживании, списано - Привязка к отделам и ответственным лицам @@ -286,7 +285,6 @@ COMPLETED (Завершено) - Прогнозирование расходов ### Stage 5: Инвентаризация -- QR-коды для оборудования - Мобильное сканирование - Планирование ТО @@ -315,7 +313,7 @@ COMPLETED (Завершено) - Telegram: Bot API - **Scheduler**: APScheduler 3.11+ (для periodic tasks) - **Charts**: Chart.js (планируется) -- **Barcodes**: python-barcode, qrcode (планируется) +- **Barcodes**: python-barcode (планируется) - **Reports**: reportlab (планируется) ## Разработка diff --git a/backend/procurement/services.py b/backend/procurement/services.py index c26e57b2..ca5fc79b 100644 --- a/backend/procurement/services.py +++ b/backend/procurement/services.py @@ -2,13 +2,8 @@ Сервисы для модуля закупок и инвентаря. """ -import io -import os from typing import Optional -import qrcode -from django.conf import settings -from django.core.files.base import ContentFile from django.db import transaction from django.utils import timezone @@ -90,68 +85,6 @@ class InventoryNumberGenerator: return True -class QRCodeGenerator: - """Генератор QR-кодов для оборудования.""" - - QR_CODE_DIR = "qr_codes" - - @classmethod - def generate_for_equipment(cls, equipment) -> ContentFile: - """ - Генерирует QR-код для оборудования. - - QR содержит URL на страницу оборудования и основные данные. - - Returns: - ContentFile с PNG изображением QR-кода - """ - # Данные для QR-кода - data = cls._build_qr_data(equipment) - - # Генерируем QR-код - qr = qrcode.QRCode( - version=1, - error_correction=qrcode.constants.ERROR_CORRECT_M, - box_size=10, - border=4, - ) - qr.add_data(data) - qr.make(fit=True) - - # Создаём изображение - img = qr.make_image(fill_color="black", back_color="white") - - # Сохраняем в буфер - buffer = io.BytesIO() - img.save(buffer, format="PNG") - buffer.seek(0) - - filename = f"{equipment.inventory_number}.png" - return ContentFile(buffer.read(), name=filename) - - @classmethod - def _build_qr_data(cls, equipment) -> str: - """ - Формирует данные для QR-кода. - - Формат: URL с параметрами для быстрого доступа. - """ - # Формируем URL на страницу оборудования - base_url = getattr(settings, "SITE_URL", "http://corp.robotail.pro") - equipment_url = f"{base_url}/procurement/equipment/{equipment.id}/" - - # Можно также добавить vCard или JSON формат - # Но URL проще для сканирования - return equipment_url - - @classmethod - def get_qr_code_path(cls, inventory_number: str) -> str: - """ - Возвращает путь к файлу QR-кода. - """ - return os.path.join(cls.QR_CODE_DIR, f"{inventory_number}.png") - - class ProcurementApprovalResolver: """Определяет согласующих для заявки на закупку. @@ -212,6 +145,42 @@ class ProcurementApprovalResolver: resolved.sort(key=lambda item: item[0]) return resolved, missing + @classmethod + def resolve_required_approvers_detailed(cls, procurement_request): + """Возвращает согласующих и подробности по пропущенным этапам.""" + from .models import ApprovalRoute + + resolved = [] + missing = [] + + for route in procurement_request.get_required_approval_routes(): + step = cls.resolve_approval_step(procurement_request, route) + if step: + resolved.append(step) + continue + + step_label = route.name or get_default_approval_step_name( + route.priority, + resolver_type=route.resolver_type, + ) + reason = "unknown" + if route.resolver_type == ApprovalRoute.ResolverType.DEPARTMENT_HEAD: + reason = "department_head_missing" + elif route.resolver_type == ApprovalRoute.ResolverType.FIXED_EMPLOYEE: + reason = "approver_missing_or_inactive" + + missing.append( + { + "priority": route.priority, + "step_name": step_label, + "resolver_type": route.resolver_type, + "reason": reason, + } + ) + + resolved.sort(key=lambda item: item[0]) + return resolved, missing + @classmethod def get_available_approval(cls, user, procurement_request): if not user or not user.is_authenticated: diff --git a/backend/requirements.txt b/backend/requirements.txt index ae52635d..7ba486f9 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -64,7 +64,6 @@ http_ece==1.2.1 # Image Processing pillow==11.3.0 -qrcode==8.1 # Authentication and Security PyJWT==2.10.1 diff --git a/backend/static/js/modules/equipmentListHandler.js b/backend/static/js/modules/equipmentListHandler.js index 6865ecda..b582b91e 100644 --- a/backend/static/js/modules/equipmentListHandler.js +++ b/backend/static/js/modules/equipmentListHandler.js @@ -188,8 +188,6 @@ export class EquipmentListHandler { this.bindPaginationEvents(); } - // QR modals - this.bindQRModals(); } renderEquipmentCard(eq) { @@ -218,12 +216,6 @@ export class EquipmentListHandler { Подробнее - @@ -254,50 +246,6 @@ export class EquipmentListHandler { return ''; } - bindQRModals() { - document.querySelectorAll('[data-qr-url]').forEach(btn => { - btn.addEventListener('click', () => { - const url = btn.dataset.qrUrl; - const name = btn.dataset.qrName; - this.showQRModal(url, name); - }); - }); - } - - showQRModal(qrUrl, name) { - // Удаляем существующую модалку - document.querySelector('#qrModal')?.remove(); - - const modal = document.createElement('div'); - modal.id = 'qrModal'; - modal.className = 'modal fade'; - modal.innerHTML = ` - - `; - - document.body.appendChild(modal); - const bsModal = new bootstrap.Modal(modal); - bsModal.show(); - - modal.addEventListener('hidden.bs.modal', () => modal.remove()); - } - renderPagination(totalCount) { const totalPages = Math.ceil(totalCount / this.pageSize); let html = '