From 6fd01c9a6e54cb29d1859e67c821a969c7aabc62 Mon Sep 17 00:00:00 2001 From: ackFromRedmi Date: Thu, 23 Apr 2026 08:49:26 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D0=BB=20?= =?UTF-8?q?=D1=80=D0=B5=D0=B4=D0=B0=D0=BA=D1=82=D0=B8=D1=80=D0=BE=D0=B2?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D0=B5=20=D0=BC=D0=B0=D1=82=D0=B5=D1=80=D0=B8?= =?UTF-8?q?=D0=B0=D0=BB=D0=BB=D0=B0=20=D0=BD=D0=B0=20=D1=81=D1=82=D1=80?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D1=86=D0=B5=20=D1=81=D0=BA=D0=BB=D0=B0=D0=B4?= =?UTF-8?q?=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .trae/rules/main.md | 10 +- CHANGELOG.md | 1 + .../templates/shiftflow/warehouse_stocks.html | 149 ++++++++++++++- shiftflow/urls.py | 2 + shiftflow/views.py | 178 ++++++++++++++++++ 5 files changed, 336 insertions(+), 4 deletions(-) diff --git a/.trae/rules/main.md b/.trae/rules/main.md index 488f771..75e39b1 100644 --- a/.trae/rules/main.md +++ b/.trae/rules/main.md @@ -49,9 +49,13 @@ ## SHOULD — правила, которые желательно соблюдать ### Комментарии -- Python/бекенд: добавлять поясняющие комментарии там, где есть бизнес‑логика, транзакции, конкурентность, фоновые задачи, сложные алгоритмы (BOM, списания, начисления). - - Комментарии нейтральные: описывают поведение/причину, без личных формулировок. -- Django HTML‑шаблоны: не добавлять template‑комментарии ({# ... #}). + - Python/бекенд: добавлять поясняющие комментариии там, где они нужны, без личных формулировок. + + - Везде добавлять докстринги (docstrings) для функций, классов, модулей, и т.д. + + - Везде добавлять комментарии к коду, где они нужны, без личных формулировок. + + - Django HTML‑шаблоны: не добавлять template‑комментарии ({# ... #}). ### Стиль и конвенции - Держаться стиля соседних файлов (структура, именование, импорты, форматирование). diff --git a/CHANGELOG.md b/CHANGELOG.md index d61a363..ecbfe41 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ - Списание / Производство: в блоках «Списано» и «Остаток ДО» выводится масса материалов (по размерам и «Масса на ед. учёта»); если масса не задана — показывается прочерк. - Закрытие: деловой остаток (ДО) может наследовать сделку от списанного сырья (отключается чекбоксом) и доступен к отгрузке как сырьё по сделке. - Реестр заданий: комментарий сменного задания/операции отображается под наименованием. +- Склады: по клику по строке сырья/ДО открывается модальное окно редактирования позиции. - Паспорта изделий/компонентов: ссылки на PDF/DXF/картинки отображаются иконками и открываются в новой вкладке. - Паспорта изделий/сборок: блок «Состав» перенесён в верхнюю часть страницы, в таблицу состава добавлена колонка «Файлы». - Производственные задачи и прогресс техпроцесса ведутся в разрезе партий поставки (серий) для одной сделки. diff --git a/shiftflow/templates/shiftflow/warehouse_stocks.html b/shiftflow/templates/shiftflow/warehouse_stocks.html index 7887015..b79f632 100644 --- a/shiftflow/templates/shiftflow/warehouse_stocks.html +++ b/shiftflow/templates/shiftflow/warehouse_stocks.html @@ -97,7 +97,7 @@ {% for it in items %} - + {{ it.location }} {% if it.created_at %}{{ it.created_at|date:"d.m.Y H:i" }}{% endif %} @@ -327,6 +327,90 @@ + + {% endblock %} \ No newline at end of file diff --git a/shiftflow/urls.py b/shiftflow/urls.py index a5b775d..0ef4158 100644 --- a/shiftflow/urls.py +++ b/shiftflow/urls.py @@ -56,6 +56,7 @@ from .views import ( LegacyRegistryView, LegacyWriteOffsView, WarehouseReceiptCreateView, + WarehouseStockItemUpdateView, WarehouseStocksView, WarehouseTransferCreateView, ProcurementDashboardView, @@ -119,6 +120,7 @@ urlpatterns = [ path('workitems///', WorkItemEntityListView.as_view(), name='workitem_entity_list'), path('warehouse/stocks/', WarehouseStocksView.as_view(), name='warehouse_stocks'), + path('warehouse/stock-item/update/', WarehouseStockItemUpdateView.as_view(), name='warehouse_stockitem_update'), path('warehouse/transfer/', WarehouseTransferCreateView.as_view(), name='warehouse_transfer'), path('warehouse/receipt/', WarehouseReceiptCreateView.as_view(), name='warehouse_receipt'), diff --git a/shiftflow/views.py b/shiftflow/views.py index 0705c14..9bae60b 100644 --- a/shiftflow/views.py +++ b/shiftflow/views.py @@ -4442,6 +4442,184 @@ class WarehouseStocksView(LoginRequiredMixin, TemplateView): return ctx +class WarehouseStockItemUpdateView(LoginRequiredMixin, View): + def post(self, request, *args, **kwargs): + profile = getattr(request.user, 'profile', None) + roles = get_user_roles(request.user) + role = primary_role(roles) + if not has_any_role(roles, ['admin', 'technologist', 'master', 'clerk', 'prod_head', 'director']): + return JsonResponse({'error': 'forbidden'}, status=403) + + next_url = (request.POST.get('next') or '').strip() + if not next_url.startswith('/'): + next_url = reverse_lazy('warehouse_stocks') + + stock_item_id = (request.POST.get('stock_item_id') or '').strip() + if not stock_item_id.isdigit(): + messages.error(request, 'Не выбрана складская позиция.') + return redirect(next_url) + + qty_raw = (request.POST.get('quantity') or '').strip().replace(',', '.') + len_raw = (request.POST.get('current_length') or '').strip().replace(',', '.') + wid_raw = (request.POST.get('current_width') or '').strip().replace(',', '.') + deal_id_raw = (request.POST.get('deal_id') or '').strip() + unique_id_raw = (request.POST.get('unique_id') or '').strip() + material_id_raw = (request.POST.get('material_id') or '').strip() + location_id_raw = (request.POST.get('location_id') or '').strip() + + try: + qty = float(qty_raw) + except ValueError: + qty = 0.0 + if qty <= 0: + messages.error(request, 'Количество должно быть больше 0.') + return redirect(next_url) + + def parse_float_opt(s: str): + s = (s or '').strip().replace(',', '.') + if not s: + return None + try: + return float(s) + except ValueError: + return None + + cur_len = parse_float_opt(len_raw) + cur_wid = parse_float_opt(wid_raw) + + is_remnant = bool(request.POST.get('is_remnant')) + is_customer_supplied = bool(request.POST.get('is_customer_supplied')) + + deal_id = None + if deal_id_raw.isdigit(): + deal_id = int(deal_id_raw) + + material_id = None + if material_id_raw.isdigit(): + material_id = int(material_id_raw) + + location_id = None + if location_id_raw.isdigit(): + location_id = int(location_id_raw) + + unique_id = unique_id_raw or None + + with transaction.atomic(): + si = ( + StockItem.objects.select_for_update(of=('self',)) + .select_related('material', 'material__category', 'location', 'deal') + .filter(id=int(stock_item_id), is_archived=False) + .first() + ) + if not si: + messages.error(request, 'Складская позиция не найдена.') + return redirect(next_url) + + if not getattr(si, 'material_id', None) or getattr(si, 'entity_id', None): + messages.error(request, 'Редактирование доступно только для сырья/ДО.') + return redirect(next_url) + + if role == 'master' and not has_any_role(roles, ['admin', 'technologist', 'clerk', 'prod_head', 'director']): + allowed_ws_ids = list(profile.allowed_workshops.values_list('id', flat=True)) if profile else [] + if not allowed_ws_ids and profile: + user_machine_ids = list(profile.machines.values_list('id', flat=True)) + allowed_ws_ids = list( + Machine.objects.filter(id__in=user_machine_ids) + .exclude(workshop_id__isnull=True) + .values_list('workshop_id', flat=True) + ) + + allowed_loc_ids = list( + Workshop.objects.filter(id__in=allowed_ws_ids) + .exclude(location_id__isnull=True) + .values_list('location_id', flat=True) + ) + if allowed_loc_ids and int(si.location_id) not in {int(x) for x in allowed_loc_ids}: + messages.error(request, 'Мастер может редактировать только позиции своего цеха.') + return redirect(next_url) + + ship_loc = ( + Location.objects.filter( + Q(name__icontains='отгруж') + | Q(name__icontains='Отгруж') + | Q(name__icontains='отгруз') + | Q(name__icontains='Отгруз') + ) + .order_by('id') + .first() + ) + ship_loc_id = ship_loc.id if ship_loc else None + + if deal_id is not None: + ok = Deal.objects.filter(id=int(deal_id)).exists() + if not ok: + messages.error(request, 'Выбрана несуществующая сделка.') + return redirect(next_url) + + if material_id is None: + messages.error(request, 'Выбери материал.') + return redirect(next_url) + mat = Material.objects.select_related('category').filter(id=int(material_id)).first() + if not mat: + messages.error(request, 'Материал не найден.') + return redirect(next_url) + + if location_id is None: + messages.error(request, 'Выбери склад.') + return redirect(next_url) + loc = Location.objects.filter(id=int(location_id)).first() + if not loc: + messages.error(request, 'Склад не найден.') + return redirect(next_url) + if ship_loc_id and int(location_id) == int(ship_loc_id): + messages.error(request, 'Перенос на склад отгрузки выполняется через отгрузку/перемещение.') + return redirect(next_url) + + ff = (getattr(getattr(mat, 'category', None), 'form_factor', '') or '').strip().lower() + + if ff == 'sheet': + if cur_len is None or cur_wid is None: + messages.error(request, 'Для листового материала нужно заполнить длину и ширину (мм).') + return redirect(next_url) + elif ff == 'bar': + if cur_len is None: + messages.error(request, 'Для проката нужно заполнить длину (мм).') + return redirect(next_url) + cur_wid = None + + if unique_id: + exists = StockItem.objects.filter(unique_id=unique_id).exclude(id=int(si.id)).exists() + if exists: + messages.error(request, 'Маркировка (ID) уже используется в другой позиции.') + return redirect(next_url) + + si.quantity = float(qty) + si.current_length = cur_len + si.current_width = cur_wid + si.deal_id = deal_id + si.unique_id = unique_id + si.is_remnant = bool(is_remnant) + si.is_customer_supplied = bool(is_customer_supplied) + si.material_id = int(material_id) + si.location_id = int(location_id) + si.save( + update_fields=[ + 'quantity', + 'current_length', + 'current_width', + 'deal', + 'unique_id', + 'is_remnant', + 'is_customer_supplied', + 'material', + 'location', + ] + ) + + messages.success(request, 'Позиция обновлена.') + return redirect(next_url) + + class WarehouseTransferCreateView(LoginRequiredMixin, View): def post(self, request, *args, **kwargs): profile = getattr(request.user, 'profile', None)