from datetime import datetime, timedelta from urllib.parse import urlsplit import os import subprocess import sys import threading from pathlib import Path from django.conf import settings as django_settings from django.contrib import messages from django.core.files.base import ContentFile from django.db import close_old_connections from django.db.models import Case, ExpressionWrapper, F, IntegerField, Sum, Value, When from django.db.models import Q from django.db.models.functions import Coalesce from django.http import JsonResponse from django.shortcuts import get_object_or_404, redirect from django.urls import reverse_lazy from django.views import View from django.views.generic import FormView, ListView, TemplateView, UpdateView from django.contrib.auth.mixins import LoginRequiredMixin from django.utils import timezone from manufacturing.models import ( AssemblyPassport, BOM, CastingPassport, OutsourcedPassport, PartPassport, ProductEntity, PurchasedPassport, RouteStub, WeldingSeam, ) from warehouse.models import Location, Material, MaterialCategory, SteelGrade, StockItem, TransferLine, TransferRecord from warehouse.services.transfers import receive_transfer from shiftflow.services.closing import apply_closing from .forms import ProductionTaskCreateForm from .models import ( Company, CuttingSession, Deal, DxfPreviewJob, DxfPreviewSettings, EmployeeProfile, Item, Machine, ProductionReportConsumption, ProductionReportRemnant, ProductionReportStockResult, ProductionTask, ShiftItem, ) def _get_dxf_preview_settings() -> DxfPreviewSettings: """Возвращает (и при необходимости создаёт) настройки превью DXF. Мы храним настройки в БД, чтобы админ мог менять их в интерфейсе. Ожидаем одну запись (singleton), используем pk=1. """ obj, _ = DxfPreviewSettings.objects.get_or_create(pk=1) return obj def _render_dxf_preview_png( dxf_path: str, *, line_color: str, lineweight_scaling: float, min_lineweight_mm: float, keep_original_colors: bool, ) -> bytes: """Рендерит DXF в PNG (байты) с заданными параметрами. Зачем это нужно: - браузер не умеет стабильно показывать DXF как "превью"; - поэтому мы генерируем PNG на сервере и уже её показываем в интерфейсе. Требуемые зависимости: - ezdxf - matplotlib (backend Agg) Если зависимости не установлены — бросаем исключение с понятным текстом. """ try: # Важно: используем headless-backend, чтобы рендер работал без GUI (на сервере/в Docker). import matplotlib matplotlib.use('Agg') import matplotlib.pyplot as plt # ezdxf читает DXF и умеет отрисовывать через drawing add-on. from ezdxf import recover from ezdxf.addons.drawing import RenderContext, Frontend from ezdxf.addons.drawing.matplotlib import MatplotlibBackend from ezdxf.addons.drawing import config as draw_config except Exception as e: # Важно: сюда попадают не только «пакет не установлен», но и ошибки импорта из-за системных библиотек # (например, не хватает freetype/png в slim-образе). Поэтому сохраняем первопричину в тексте исключения. raise RuntimeError( f"Не удалось импортировать зависимости для превью DXF (ezdxf/matplotlib): {type(e).__name__}: {e}" ) from e if not dxf_path or not os.path.exists(dxf_path): raise FileNotFoundError('DXF файл не найден') # Безопасное чтение DXF (recover умеет поднимать часть повреждённых файлов). doc, auditor = recover.readfile(dxf_path) if auditor and getattr(auditor, 'has_errors', False): # Даже при ошибках структуры часто удаётся получить картинку — поэтому не прерываем. pass # Настройка итогового вида превью: # - прозрачный фон (чтобы хорошо смотрелось на тёмной теме) # - цвет/толщина линии задаются настройками (см. «Обслуживание сервера») # Конвертируем мм в единицы ezdxf (min_lineweight хранится в 1/300 inch). # Формула: мм / 25.4 * 300 min_lineweight = int(max(0.0, float(min_lineweight_mm)) / 25.4 * 300) # Конфигурация рендера: управляем толщиной линий. cfg = draw_config.Configuration( lineweight_scaling=float(lineweight_scaling), min_lineweight=min_lineweight, ) class PreviewFrontend(Frontend): """Переопределяет свойства сущностей перед отрисовкой. Если keep_original_colors=True — оставляем цвета DXF. Иначе принудительно красим все линии в заданный line_color. """ def override_properties(self, entity, properties) -> None: if not keep_original_colors: properties.color = line_color fig = plt.figure(figsize=(5, 3), dpi=160) ax = fig.add_axes([0, 0, 1, 1]) ax.set_axis_off() ax.margins(0) # Прозрачность фона фигуры и осей. fig.patch.set_alpha(0) ax.set_facecolor((0, 0, 0, 0)) ctx = RenderContext(doc) out = MatplotlibBackend(ax) PreviewFrontend(ctx, out, config=cfg).draw_layout(doc.modelspace(), finalize=True) import io buf = io.BytesIO() fig.savefig( buf, format='png', dpi=160, bbox_inches='tight', pad_inches=0.02, transparent=True, ) plt.close(fig) buf.seek(0) return buf.getvalue() def _extract_dxf_dimensions(dxf_path: str) -> str: """Возвращает строку габаритов заготовки вида «300х456 мм». Используем ezdxf.bbox.extents(), который корректно учитывает дуги/сплайны и вложенные блоки. Пытаемся учитывать единицы: если в заголовке $INSUNITS указаны дюймы/см/м — конвертируем в мм. """ import ezdxf from ezdxf import bbox as dzbbox doc = ezdxf.readfile(dxf_path) msp = doc.modelspace() # Коэффициент перевода в мм по $INSUNITS units = int(doc.header.get('$INSUNITS', 0) or 0) factor = {1: 25.4, 4: 1.0, 5: 10.0, 6: 1000.0}.get(units, 1.0) extent = dzbbox.extents(msp, cache=dzbbox.Cache()) if not extent.has_data: return '' (min_x, min_y, _), (max_x, max_y, _) = extent.min, extent.max width = (max_x - min_x) * factor height = (max_y - min_y) * factor return f"{round(width, 3)}х{round(height, 3)} мм" def _update_task_preview(task: ProductionTask) -> bool: """Обновляет превью PNG и габариты из DXF для одной детали. Использует текущие настройки из DxfPreviewSettings. Важно: функция может выполняться "тяжело" (рендер + bbox), поэтому её удобно вызывать из фонового потока, чтобы не блокировать HTTP-ответ. """ if not task.drawing_file: return False name = (task.drawing_file.name or '').lower() if not name.endswith('.dxf'): # Если не DXF — превью не делаем: удаляем старый PNG (если был) и очищаем габариты. try: if task.preview_image: task.preview_image.delete(save=False) except Exception: pass task.preview_image = None task.blank_dimensions = '' task.save(update_fields=['preview_image', 'blank_dimensions']) return False dxf_path = getattr(task.drawing_file, 'path', '') settings = _get_dxf_preview_settings() png_bytes = _render_dxf_preview_png( dxf_path, line_color=settings.line_color, lineweight_scaling=settings.lineweight_scaling, min_lineweight_mm=settings.min_lineweight, keep_original_colors=settings.keep_original_colors, ) # Временно отключаем вычисление габаритов (bbox), чтобы исключить его влияние на стабильность. # Превью PNG генерируем как и раньше. # Перед сохранением удаляем старое превью, иначе FileSystemStorage добавляет суффиксы # и папка с превью постепенно «засоряется». try: if task.preview_image: task.preview_image.delete(save=False) except Exception: pass filename = f"task_{task.id}_preview.png" task.preview_image.save(filename, ContentFile(png_bytes), save=False) task.save(update_fields=['preview_image']) return True # Класс главной страницы (роутер) class IndexView(TemplateView): template_name = 'shiftflow/landing.html' def get(self, request, *args, **kwargs): # Если юзер авторизован — сразу отправляем его в реестр if request.user.is_authenticated: return redirect('registry') # Если нет — показываем кнопку "Войти" return super().get(request, *args, **kwargs) # Класс реестра деталей (защищен LoginRequiredMixin) class RegistryView(LoginRequiredMixin, ListView): model = Item template_name = 'shiftflow/registry.html' context_object_name = 'items' def get_queryset(self): queryset = Item.objects.select_related('task', 'task__deal', 'task__material', 'machine') user = self.request.user profile = getattr(user, 'profile', None) role = profile.role if profile else 'operator' # Флаг, что фильтрация была применена через форму. Если нет — используем дефолты filtered = self.request.GET.get('filtered') # Принудительный сброс фильтров (?reset=1) — ведёт себя как первый заход на страницу reset = self.request.GET.get('reset') # Станки m_ids = self.request.GET.getlist('m_ids') if filtered and role != 'operator' and not m_ids: return queryset.none() if m_ids: queryset = queryset.filter(machine_id__in=m_ids) # Статусы (+ агрегат "closed" = done+partial) statuses = self.request.GET.getlist('statuses') if filtered and not statuses: return queryset.none() if statuses: expanded = [] for s in statuses: if s == 'closed': expanded += ['done', 'partial'] else: expanded.append(s) queryset = queryset.filter(status__in=expanded) # Диапазон дат, задаваемый пользователем. Если фильтры не активны или явно указан reset=1 — используем дефолты start_date = self.request.GET.get('start_date') end_date = self.request.GET.get('end_date') # Дефолтный режим: последние 7 дней и только статус "В работе" is_default = (not filtered) or bool(reset) if is_default: today = timezone.localdate() week_ago = today - timezone.timedelta(days=7) queryset = queryset.filter(date__gte=week_ago, date__lte=today, status__in=['work']) else: # Пользователь указал фильтры вручную — применяем их как есть if start_date: queryset = queryset.filter(date__gte=start_date) if end_date: queryset = queryset.filter(date__lte=end_date) # Списание (1С) is_synced = self.request.GET.get('is_synced') if is_synced in ['0', '1']: queryset = queryset.filter(is_synced_1c=bool(int(is_synced))) # Ограничения по ролям if role == 'operator': user_machines = profile.machines.all() if profile else Machine.objects.none() queryset = queryset.filter(machine__in=user_machines) if not filtered: queryset = queryset.filter(status='work') elif role == 'master' and not filtered: queryset = queryset.filter(status='work') return queryset.order_by('status', '-date', 'machine__name', 'task__deal__number') def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) user = self.request.user profile = getattr(user, 'profile', None) role = profile.role if profile else 'operator' context['user_role'] = role machines = Machine.objects.all() context['machines'] = machines filtered = self.request.GET.get('filtered') reset = self.request.GET.get('reset') # Дефолтное состояние формы фильтра: все станки включены, статус "В работе", # период от сегодня−7 до сегодня. Совпадает с серверной выборкой выше if (not filtered) or reset: today = timezone.localdate() week_ago = today - timezone.timedelta(days=7) context['start_date'] = week_ago.strftime('%Y-%m-%d') context['end_date'] = today.strftime('%Y-%m-%d') context['selected_statuses'] = ['work'] context['selected_machines'] = [m.id for m in machines] context['all_selected_machines'] = True else: context['selected_machines'] = [int(i) for i in self.request.GET.getlist('m_ids') if i.isdigit()] context['selected_statuses'] = self.request.GET.getlist('statuses') context['start_date'] = self.request.GET.get('start_date', '') context['end_date'] = self.request.GET.get('end_date', '') context['is_synced'] = self.request.GET.get('is_synced', '') context['all_selected_machines'] = False items = list(context.get('items') or []) for it in items: plan = int(it.quantity_plan or 0) fact = int(it.quantity_fact or 0) if plan > 0: fact_pct = int(round(fact * 100 / plan)) else: fact_pct = 0 it.fact_pct = fact_pct it.fact_width = max(0, min(100, fact_pct)) it.fact_bar_class = 'bg-success' if it.status in ['done', 'partial'] else 'bg-warning' context['items'] = items return context class RegistryPrintView(LoginRequiredMixin, TemplateView): template_name = 'shiftflow/registry_print.html' def dispatch(self, request, *args, **kwargs): profile = getattr(request.user, 'profile', None) role = profile.role if profile else 'operator' if role not in ['admin', 'technologist', 'master']: return redirect('registry') return super().dispatch(request, *args, **kwargs) def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) profile = getattr(self.request.user, 'profile', None) role = profile.role if profile else 'operator' context['user_role'] = role queryset = Item.objects.select_related('task', 'task__deal', 'task__material', 'task__material__category', 'machine') filtered = self.request.GET.get('filtered') m_ids = self.request.GET.getlist('m_ids') if filtered and not m_ids: queryset = queryset.none() if m_ids: queryset = queryset.filter(machine_id__in=m_ids) statuses = self.request.GET.getlist('statuses') if filtered and not statuses: queryset = queryset.none() if statuses: expanded = [] for s in statuses: if s == 'closed': expanded += ['done', 'partial'] else: expanded.append(s) queryset = queryset.filter(status__in=expanded) start_date = self.request.GET.get('start_date') end_date = self.request.GET.get('end_date') if not filtered: today = timezone.localdate() queryset = queryset.filter(date=today, status__in=['work', 'leftover']) start_date = today.strftime('%Y-%m-%d') end_date = start_date else: if start_date: queryset = queryset.filter(date__gte=start_date) if end_date: queryset = queryset.filter(date__lte=end_date) is_synced = self.request.GET.get('is_synced') if is_synced in ['0', '1']: queryset = queryset.filter(is_synced_1c=bool(int(is_synced))) if role == 'master' and not filtered: queryset = queryset.filter(status='work') items = list(queryset.order_by('machine__name', 'date', 'task__deal__number', 'id')) groups = {} for item in items: groups.setdefault(item.machine, []).append(item) context['groups'] = list(groups.items()) context['printed_at'] = timezone.now() context['end_date'] = end_date or '' print_date_raw = end_date or start_date print_date = None if isinstance(print_date_raw, str) and print_date_raw: try: print_date = datetime.strptime(print_date_raw, '%Y-%m-%d').date() except ValueError: print_date = None context['print_date'] = print_date if start_date and end_date and start_date == end_date: context['date_label'] = start_date elif start_date and end_date: context['date_label'] = f"{start_date} — {end_date}" elif start_date: context['date_label'] = f"c {start_date}" elif end_date: context['date_label'] = f"по {end_date}" else: context['date_label'] = '' return context class PlanningView(LoginRequiredMixin, TemplateView): template_name = 'shiftflow/planning.html' def dispatch(self, request, *args, **kwargs): profile = getattr(request.user, 'profile', None) role = profile.role if profile else ('admin' if request.user.is_superuser else 'operator') if role not in ['admin', 'technologist', 'master', 'clerk']: return redirect('registry') return super().dispatch(request, *args, **kwargs) def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) profile = getattr(self.request.user, 'profile', None) role = profile.role if profile else ('admin' if self.request.user.is_superuser else 'operator') context['user_role'] = role status = (self.request.GET.get('status') or 'work').strip() allowed = {k for k, _ in Deal.STATUS_CHOICES} if status not in allowed: status = 'work' context['selected_status'] = status context['deals'] = Deal.objects.select_related('company').filter(status=status).order_by('-id') context['companies'] = Company.objects.all().order_by('name') return context class DealPlanningView(LoginRequiredMixin, TemplateView): template_name = 'shiftflow/planning_deal.html' def dispatch(self, request, *args, **kwargs): profile = getattr(request.user, 'profile', None) role = profile.role if profile else ('admin' if request.user.is_superuser else 'operator') if role not in ['admin', 'technologist', 'master', 'clerk']: return redirect('registry') return super().dispatch(request, *args, **kwargs) def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) profile = getattr(self.request.user, 'profile', None) role = profile.role if profile else ('admin' if self.request.user.is_superuser else 'operator') context['user_role'] = role deal = get_object_or_404(Deal.objects.select_related('company'), pk=self.kwargs['pk']) context['deal'] = deal tasks_qs = ProductionTask.objects.filter(deal=deal).select_related('material').annotate( done_qty=Coalesce(Sum('items__quantity_fact'), 0), planned_qty=Coalesce( Sum( Case( When(items__status__in=['work', 'leftover'], then=F('items__quantity_plan')), default=Value(0), output_field=IntegerField(), ) ), 0, ), ).annotate( remaining_qty=ExpressionWrapper( F('quantity_ordered') - F('done_qty') - F('planned_qty'), output_field=IntegerField(), ) ).order_by('-id') tasks = list(tasks_qs) # Рассчитываем показатели прогресса для визуализации: # done_pct/plan_pct — проценты от "Надо"; done_width/plan_width — ширины сегментов бары, ограниченные 0..100 for t in tasks: need = int(t.quantity_ordered or 0) done_qty = int(t.done_qty or 0) planned_qty = int(t.planned_qty or 0) if need > 0: done_pct = int(round(done_qty * 100 / need)) plan_pct = int(round(planned_qty * 100 / need)) else: done_pct = 0 plan_pct = 0 done_width = max(0, min(100, done_pct)) plan_width = max(0, min(100 - done_width, plan_pct)) t.done_pct = done_pct t.plan_pct = plan_pct t.done_width = done_width t.plan_width = plan_width context['tasks'] = tasks context['machines'] = Machine.objects.all() return context class TaskItemsView(LoginRequiredMixin, TemplateView): template_name = 'shiftflow/task_items.html' def dispatch(self, request, *args, **kwargs): profile = getattr(request.user, 'profile', None) role = profile.role if profile else ('admin' if request.user.is_superuser else 'operator') if role not in ['admin', 'technologist', 'master', 'clerk']: return redirect('registry') return super().dispatch(request, *args, **kwargs) def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) profile = getattr(self.request.user, 'profile', None) role = profile.role if profile else ('admin' if self.request.user.is_superuser else 'operator') context['user_role'] = role task = get_object_or_404( ProductionTask.objects.select_related('deal', 'deal__company', 'material'), pk=self.kwargs['pk'], ) context['task'] = task items = list(Item.objects.filter(task=task).select_related('machine').order_by('-date', 'machine__name', '-id')) for it in items: plan = int(it.quantity_plan or 0) fact = int(it.quantity_fact or 0) if plan > 0: fact_pct = int(round(fact * 100 / plan)) else: fact_pct = 0 it.fact_pct = fact_pct it.fact_width = max(0, min(100, fact_pct)) it.fact_bar_class = 'bg-success' if it.status in ['done', 'partial'] else 'bg-warning' context['items'] = items return context def _run_dxf_preview_job(job_id: int) -> None: """Выполняет задачу пакетной регенерации превью в фоне. Пишем прогресс в DxfPreviewJob, чтобы UI мог показывать результаты. """ try: close_old_connections() job = DxfPreviewJob.objects.get(pk=job_id) except Exception: return job.status = 'running' job.started_at = timezone.now() job.last_message = '' job.save(update_fields=['status', 'started_at', 'last_message']) # Берём только сделки в статусах «Зашла» и «В работе» deal_statuses = ['lead', 'work'] qs = ProductionTask.objects.select_related('deal').filter(deal__status__in=deal_statuses) try: total = qs.count() except Exception: total = 0 job.total = total job.processed = 0 job.updated = 0 job.skipped = 0 job.errors = 0 job.save(update_fields=['total', 'processed', 'updated', 'skipped', 'errors']) # iterator() уменьшает потребление памяти на больших выборках processed = 0 updated = 0 skipped = 0 errors = 0 for task in qs.iterator(chunk_size=50): try: if _update_task_preview(task): updated += 1 else: skipped += 1 except Exception: errors += 1 processed += 1 # Обновляем прогресс периодически, чтобы не делать save() на каждую запись if processed % 10 == 0: DxfPreviewJob.objects.filter(pk=job_id).update( processed=processed, updated=updated, skipped=skipped, errors=errors, ) status = 'done' if errors == 0 else 'done' last_message = f"Превью обновлены: {updated}. Пропущено: {skipped}. Ошибок: {errors}." DxfPreviewJob.objects.filter(pk=job_id).update( status=status, finished_at=timezone.now(), processed=processed, updated=updated, skipped=skipped, errors=errors, last_message=last_message, ) try: close_old_connections() except Exception: pass def _mark_stale_preview_jobs() -> None: """Помечает «залипшие» задачи превью как failed. Почему это нужно: - генерация превью запускается в фоне (поток/процесс); - если сервер перезапустили или процесс был убит, job может навсегда остаться в queued/running; - из-за этого UI пишет «уже запущено» и прогресс не двигается. Правило: - если job в queued/running и нет finished_at, и он слишком долго не двигается — считаем его умершим. """ now = timezone.now() # Лимит «жизнеспособности» задачи. Можно подстроить. stale_after = timezone.timedelta(minutes=5) qs = DxfPreviewJob.objects.filter(status__in=['queued', 'running'], finished_at__isnull=True) for job in qs[:10]: # queued без started_at тоже может остаться после рестарта ref_time = job.started_at or job.created_at if ref_time and (now - ref_time) > stale_after: job.status = 'failed' job.finished_at = now job.last_message = 'Задача помечена как зависшая (сервер был перезапущен или процесс остановлен).' job.save(update_fields=['status', 'finished_at', 'last_message']) def _dxf_job_log_path(job_id: int) -> Path: """Путь к лог-файлу фоновой задачи DXF превью.""" base_dir = Path(getattr(django_settings, 'BASE_DIR', Path(__file__).resolve().parent.parent)) logs_dir = base_dir / 'logs' logs_dir.mkdir(parents=True, exist_ok=True) return logs_dir / f'dxf_preview_job_{job_id}.log' def _read_tail(path: Path, max_bytes: int = 32_000) -> str: """Читает «хвост» файла (последние max_bytes) для вывода в UI.""" try: if not path.exists(): return '' with path.open('rb') as f: f.seek(0, os.SEEK_END) size = f.tell() start = max(0, size - max_bytes) f.seek(start) data = f.read() if start > 0: nl = data.find(b'\n') if nl != -1: data = data[nl + 1 :] return data.decode('utf-8', errors='replace') except Exception: return '' class MaintenanceStatusView(LoginRequiredMixin, View): def get(self, request, *args, **kwargs): profile = getattr(request.user, 'profile', None) role = profile.role if profile else ('admin' if request.user.is_superuser else 'operator') if role != 'admin': return JsonResponse({'error': 'forbidden'}, status=403) _mark_stale_preview_jobs() job = DxfPreviewJob.objects.order_by('-id').first() if not job: return JsonResponse({'job': None, 'log_tail': ''}) log_tail = _read_tail(_dxf_job_log_path(job.id)) return JsonResponse({ 'job': { 'id': job.id, 'status': job.status, 'status_label': job.get_status_display(), 'total': job.total, 'processed': job.processed, 'updated': job.updated, 'skipped': job.skipped, 'errors': job.errors, 'cancel_requested': getattr(job, 'cancel_requested', False), 'last_message': job.last_message, 'log_tail': log_tail, 'log_path': str(_dxf_job_log_path(job.id)), } }) class MaintenanceView(LoginRequiredMixin, TemplateView): template_name = 'shiftflow/maintenance.html' def dispatch(self, request, *args, **kwargs): profile = getattr(request.user, 'profile', None) role = profile.role if profile else ('admin' if request.user.is_superuser else 'operator') # Обслуживание сервера доступно только админу if role != 'admin': return redirect('registry') return super().dispatch(request, *args, **kwargs) def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) context['user_role'] = 'admin' # Подтягиваем текущие настройки генерации превью, чтобы отрисовать форму. s = _get_dxf_preview_settings() context['dxf_settings'] = s # Последняя фоновая задача (для вывода статуса на странице) context['last_job'] = DxfPreviewJob.objects.order_by('-id').first() return context def post(self, request, *args, **kwargs): # На странице обслуживания есть 2 действия: # 1) сохранить настройки превью # 2) сохранить настройки и обновить превью по сделкам в статусах lead/work action = (request.POST.get('action') or '').strip() # Сохраняем настройки (даже если жмём «Обновить» — чтобы применить их сразу). s = _get_dxf_preview_settings() s.line_color = (request.POST.get('line_color') or s.line_color).strip() or s.line_color try: s.lineweight_scaling = float(request.POST.get('lineweight_scaling', s.lineweight_scaling)) except ValueError: pass try: s.min_lineweight = float(request.POST.get('min_lineweight', s.min_lineweight)) except ValueError: pass s.keep_original_colors = bool(request.POST.get('keep_original_colors')) # Таймаут на обработку одной детали (в секундах). # Используется в management-команде, чтобы «плохой» DXF не блокировал всю задачу. try: s.per_task_timeout_sec = int(request.POST.get('per_task_timeout_sec', s.per_task_timeout_sec)) except ValueError: pass s.save() if action == 'cancel_job': # Мягкая остановка: помечаем текущую задачу флагом cancel_requested. # Воркер завершит работу после текущей детали и поставит статус cancelled. job = DxfPreviewJob.objects.filter(status__in=['queued', 'running'], finished_at__isnull=True).order_by('-id').first() if not job: messages.info(request, 'Активной задачи нет.') return redirect('maintenance') job.cancel_requested = True job.last_message = 'Запрошена остановка. Ожидаем завершения текущей детали.' job.save(update_fields=['cancel_requested', 'last_message']) messages.success(request, 'Остановка запрошена.') return redirect('maintenance') if action == 'clear_log': # Очистка лог-файла последней задачи. Во время выполнения не трогаем, # потому что процесс может держать открытый дескриптор файла. job = DxfPreviewJob.objects.order_by('-id').first() if not job: messages.info(request, 'Логов нет.') return redirect('maintenance') if job.status in ['queued', 'running'] and not job.finished_at: messages.warning(request, 'Нельзя очистить лог во время выполнения задачи.') return redirect('maintenance') try: _dxf_job_log_path(job.id).open('wb').close() messages.success(request, 'Лог очищен.') except Exception: messages.error(request, 'Не удалось очистить лог.') return redirect('maintenance') if action != 'update_previews': messages.success(request, 'Настройки превью сохранены.') return redirect('maintenance') # Перед проверкой «уже запущено» снимаем залипшие задачи (например после перезапуска сервера). _mark_stale_preview_jobs() # Если уже есть выполняющаяся задача — не запускаем вторую, чтобы не перегружать сервер. running = DxfPreviewJob.objects.filter(status__in=['queued', 'running'], finished_at__isnull=True).exists() if running: messages.warning(request, 'Обновление уже запущено. Дождись завершения текущей задачи.') return redirect('maintenance') # Запускаем регенерацию в отдельном процессе через management-команду. # Причина: рендер DXF и bbox нагружают CPU и могут «тормозить» веб‑процесс из-за GIL, # даже если запускать в потоке. job = DxfPreviewJob.objects.create(status='queued', created_by=request.user) try: log_path = _dxf_job_log_path(job.id) log_fh = log_path.open('ab') try: p = subprocess.Popen( [sys.executable, 'manage.py', 'dxf_preview_job', str(job.id)], cwd=str(Path(__file__).resolve().parent.parent), stdout=log_fh, stderr=log_fh, close_fds=True, ) finally: log_fh.close() # Если в модели есть поле pid — сохраняем его для диагностики. try: job.pid = p.pid job.save(update_fields=['pid']) except Exception: pass messages.success(request, 'Запущено обновление превью DXF в фоне. Прогресс и лог обновляются ниже.') except Exception: job.status = 'failed' job.last_message = 'Не удалось запустить фоновый процесс генерации превью.' job.save(update_fields=['status', 'last_message']) messages.error(request, 'Не удалось запустить обновление превью DXF.') return redirect('maintenance') class CustomersView(LoginRequiredMixin, TemplateView): template_name = 'shiftflow/customers.html' def dispatch(self, request, *args, **kwargs): profile = getattr(request.user, 'profile', None) role = profile.role if profile else ('admin' if request.user.is_superuser else 'operator') if role not in ['admin', 'technologist', 'master', 'clerk']: return redirect('registry') return super().dispatch(request, *args, **kwargs) def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) profile = getattr(self.request.user, 'profile', None) role = profile.role if profile else ('admin' if self.request.user.is_superuser else 'operator') context['user_role'] = role companies = Company.objects.all().order_by('name') context['companies'] = companies return context class CustomerDealsView(LoginRequiredMixin, TemplateView): template_name = 'shiftflow/customer_deals.html' def dispatch(self, request, *args, **kwargs): profile = getattr(request.user, 'profile', None) role = profile.role if profile else ('admin' if request.user.is_superuser else 'operator') if role not in ['admin', 'technologist', 'master', 'clerk']: return redirect('registry') return super().dispatch(request, *args, **kwargs) def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) profile = getattr(self.request.user, 'profile', None) role = profile.role if profile else ('admin' if self.request.user.is_superuser else 'operator') context['user_role'] = role company = get_object_or_404(Company, pk=self.kwargs['pk']) context['company'] = company status = (self.request.GET.get('status') or 'work').strip() allowed = {k for k, _ in Deal.STATUS_CHOICES} if status not in allowed: status = 'work' context['selected_status'] = status context['deals'] = Deal.objects.select_related('company').filter(company=company, status=status).order_by('-id') return context class PlanningAddView(LoginRequiredMixin, View): def post(self, request, *args, **kwargs): profile = getattr(request.user, 'profile', None) role = profile.role if profile else ('admin' if request.user.is_superuser else 'operator') if role not in ['admin', 'technologist']: return redirect('planning') task_id = request.POST.get('task_id') machine_id = request.POST.get('machine_id') qty_raw = request.POST.get('quantity_plan') if not (task_id and task_id.isdigit() and machine_id and machine_id.isdigit() and qty_raw and qty_raw.isdigit()): return redirect('planning') qty = int(qty_raw) if qty <= 0: return redirect('planning') Item.objects.create( task_id=int(task_id), machine_id=int(machine_id), date=timezone.localdate(), quantity_plan=qty, quantity_fact=0, status='work', is_synced_1c=False, ) next_url = request.POST.get('next') or '' if next_url.startswith('/planning/deal/'): return redirect(next_url) return redirect('planning') class ProductionTaskCreateView(LoginRequiredMixin, FormView): template_name = 'shiftflow/task_create.html' form_class = ProductionTaskCreateForm success_url = reverse_lazy('planning') def get_initial(self): initial = super().get_initial() deal_id = self.request.GET.get('deal') if deal_id and str(deal_id).isdigit(): initial['deal'] = int(deal_id) return initial def dispatch(self, request, *args, **kwargs): profile = getattr(request.user, 'profile', None) role = profile.role if profile else ('admin' if request.user.is_superuser else 'operator') if role not in ['admin', 'technologist']: return redirect('registry') return super().dispatch(request, *args, **kwargs) def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) profile = getattr(self.request.user, 'profile', None) role = profile.role if profile else ('admin' if self.request.user.is_superuser else 'operator') context['user_role'] = role context['companies'] = Company.objects.all().order_by('name') context['material_categories'] = MaterialCategory.objects.all().order_by('name') context['steel_grades'] = SteelGrade.objects.all().order_by('name') return context def form_valid(self, form): task = ProductionTask( deal=form.cleaned_data['deal'], drawing_name=form.cleaned_data.get('drawing_name') or 'Б/ч', size_value=form.cleaned_data['size_value'], material=form.cleaned_data['material'], quantity_ordered=form.cleaned_data['quantity_ordered'], is_bend=form.cleaned_data.get('is_bend') or False, ) if form.cleaned_data.get('drawing_file'): task.drawing_file = form.cleaned_data['drawing_file'] if form.cleaned_data.get('extra_drawing'): task.extra_drawing = form.cleaned_data['extra_drawing'] task.save() # Генерация превью/габаритов может занимать время (особенно на больших DXF). # Поэтому запускаем её в фоне и НЕ блокируем сохранение/редирект. def _bg(task_id: int) -> None: try: close_old_connections() t = ProductionTask.objects.get(pk=task_id) _update_task_preview(t) except Exception: pass finally: try: close_old_connections() except Exception: pass threading.Thread(target=_bg, args=(task.id,), daemon=True).start() next_url = (self.request.POST.get('next') or '').strip() if next_url.startswith('/'): return redirect(next_url) return redirect('planning_deal', pk=task.deal_id) class DealDetailView(LoginRequiredMixin, View): def get(self, request, pk, *args, **kwargs): profile = getattr(request.user, 'profile', None) role = profile.role if profile else ('admin' if request.user.is_superuser else 'operator') if role not in ['admin', 'technologist']: return JsonResponse({'error': 'forbidden'}, status=403) deal = get_object_or_404(Deal, pk=pk) return JsonResponse({ 'id': deal.id, 'number': deal.number, 'status': deal.status, 'company_id': deal.company_id, 'description': deal.description or '', }) class DealUpsertView(LoginRequiredMixin, View): def post(self, request, *args, **kwargs): profile = getattr(request.user, 'profile', None) role = profile.role if profile else ('admin' if request.user.is_superuser else 'operator') if role not in ['admin', 'technologist']: return JsonResponse({'error': 'forbidden'}, status=403) deal_id = request.POST.get('id') number = (request.POST.get('number') or '').strip() description = (request.POST.get('description') or '').strip() company_id = request.POST.get('company_id') status = (request.POST.get('status') or 'work').strip() if not number: return JsonResponse({'error': 'number_required'}, status=400) if deal_id and str(deal_id).isdigit(): deal = get_object_or_404(Deal, pk=int(deal_id)) deal.number = number else: deal, _ = Deal.objects.get_or_create(number=number) allowed = {k for k, _ in Deal.STATUS_CHOICES} if status not in allowed: status = 'work' deal.status = status deal.description = description if company_id and str(company_id).isdigit(): deal.company_id = int(company_id) else: deal.company_id = None deal.save() return JsonResponse({'id': deal.id, 'label': deal.number}) class MaterialDetailView(LoginRequiredMixin, View): def get(self, request, pk, *args, **kwargs): profile = getattr(request.user, 'profile', None) role = profile.role if profile else ('admin' if request.user.is_superuser else 'operator') if role not in ['admin', 'technologist']: return JsonResponse({'error': 'forbidden'}, status=403) material = get_object_or_404(Material, pk=pk) return JsonResponse({ 'id': material.id, 'category_id': material.category_id, 'steel_grade_id': material.steel_grade_id, 'name': material.name, 'full_name': material.full_name, }) class MaterialUpsertView(LoginRequiredMixin, View): def post(self, request, *args, **kwargs): profile = getattr(request.user, 'profile', None) role = profile.role if profile else ('admin' if request.user.is_superuser else 'operator') if role not in ['admin', 'technologist']: return JsonResponse({'error': 'forbidden'}, status=403) material_id = request.POST.get('id') category_id = request.POST.get('category_id') steel_grade_id = request.POST.get('steel_grade_id') name = (request.POST.get('name') or '').strip() if not (category_id and str(category_id).isdigit() and name): return JsonResponse({'error': 'invalid'}, status=400) if material_id and str(material_id).isdigit(): material = get_object_or_404(Material, pk=int(material_id)) else: material = Material() material.category_id = int(category_id) material.name = name if steel_grade_id and str(steel_grade_id).isdigit(): material.steel_grade_id = int(steel_grade_id) else: material.steel_grade_id = None material.save() return JsonResponse({'id': material.id, 'label': material.full_name}) class CompanyUpsertView(LoginRequiredMixin, View): def post(self, request, *args, **kwargs): profile = getattr(request.user, 'profile', None) role = profile.role if profile else ('admin' if request.user.is_superuser else 'operator') if role not in ['admin', 'technologist']: return JsonResponse({'error': 'forbidden'}, status=403) company_id = request.POST.get('id') name = (request.POST.get('name') or '').strip() description = (request.POST.get('description') or '').strip() if not name: return JsonResponse({'error': 'name_required'}, status=400) if company_id and str(company_id).isdigit(): company = get_object_or_404(Company, pk=int(company_id)) company.name = name else: company, _ = Company.objects.get_or_create(name=name) company.description = description company.save() return JsonResponse({'id': company.id, 'label': company.name}) class MaterialCategoryUpsertView(LoginRequiredMixin, View): def post(self, request, *args, **kwargs): profile = getattr(request.user, 'profile', None) role = profile.role if profile else ('admin' if request.user.is_superuser else 'operator') if role not in ['admin', 'technologist']: return JsonResponse({'error': 'forbidden'}, status=403) category_id = request.POST.get('id') name = (request.POST.get('name') or '').strip() gost_standard = (request.POST.get('gost_standard') or '').strip() if not name: return JsonResponse({'error': 'name_required'}, status=400) if category_id and str(category_id).isdigit(): category = get_object_or_404(MaterialCategory, pk=int(category_id)) category.name = name else: category, _ = MaterialCategory.objects.get_or_create(name=name) category.gost_standard = gost_standard category.save() return JsonResponse({'id': category.id, 'label': category.name}) class SteelGradeUpsertView(LoginRequiredMixin, View): def post(self, request, *args, **kwargs): profile = getattr(request.user, 'profile', None) role = profile.role if profile else ('admin' if request.user.is_superuser else 'operator') if role not in ['admin', 'technologist']: return JsonResponse({'error': 'forbidden'}, status=403) grade_id = request.POST.get('id') name = (request.POST.get('name') or '').strip() gost_standard = (request.POST.get('gost_standard') or '').strip() if not name: return JsonResponse({'error': 'name_required'}, status=400) if grade_id and str(grade_id).isdigit(): grade = get_object_or_404(SteelGrade, pk=int(grade_id)) grade.name = name else: grade, _ = SteelGrade.objects.get_or_create(name=name) grade.gost_standard = gost_standard grade.save() return JsonResponse({'id': grade.id, 'label': grade.name}) # Вьюха детального вида и редактирования class ItemUpdateView(LoginRequiredMixin, UpdateView): model = Item template_name = 'shiftflow/item_detail.html' # Перечисляем поля, которые можно редактировать в сменке fields = [ 'machine', 'quantity_plan', 'quantity_fact', 'status', 'is_synced_1c', ] context_object_name = 'item' def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) profile = getattr(self.request.user, 'profile', None) role = profile.role if profile else ('admin' if self.request.user.is_superuser else 'operator') context['user_role'] = role context['machines'] = Machine.objects.all() # Вычисляем URL "Назад": приоритетно берём ?next=..., иначе пробуем Referer # Используем только ссылки на текущий хост, чтобы избежать внешних редиректов next_url = (self.request.GET.get('next') or '').strip() back_url = '' if next_url.startswith('/'): back_url = next_url else: ref = (self.request.META.get('HTTP_REFERER') or '').strip() if ref: parts = urlsplit(ref) if parts.netloc == self.request.get_host(): back_url = parts.path + (('?' + parts.query) if parts.query else '') if not back_url: back_url = str(reverse_lazy('registry')) context['back_url'] = back_url return context def post(self, request, *args, **kwargs): self.object = self.get_object() profile = getattr(request.user, 'profile', None) role = profile.role if profile else ('admin' if request.user.is_superuser else 'operator') # Поддерживаем "умный" возврат после действия: ?next=... или Referer next_url = (request.POST.get('next') or '').strip() if not next_url: ref = (request.META.get('HTTP_REFERER') or '').strip() if ref: parts = urlsplit(ref) if parts.netloc == request.get_host(): next_url = parts.path + (('?' + parts.query) if parts.query else '') def redirect_back(): # Возвращаемся туда, откуда пришли, иначе в реестр if next_url.startswith('/'): return redirect(next_url) return redirect('registry') if role in ['admin', 'technologist']: # Действие формы (обычное сохранение или закрытие позиции) action = request.POST.get('action', 'save') # Админ может заменить файлы у детали прямо из карточки пункта сменки. # Файлы лежат на ProductionTask (основании), а не на Item. if role == 'admin' and self.object.task_id: # Админ может заменить файлы детали. После замены: # - сбрасываем превью; # - пытаемся сразу извлечь габариты из DXF. task = self.object.task drawing_file = request.FILES.get('drawing_file') extra_drawing = request.FILES.get('extra_drawing') changed = False if drawing_file is not None: task.drawing_file = drawing_file changed = True if extra_drawing is not None: task.extra_drawing = extra_drawing changed = True if changed: task.preview_image = None # Переcчёт габаритов, если это DXF dims = '' try: if drawing_file is not None and (drawing_file.name or '').lower().endswith('.dxf'): # временно сохраняем файл на объекте до .save() pass path = getattr(task.drawing_file, 'path', '') if path and path.lower().endswith('.dxf'): dims = _extract_dxf_dimensions(path) except Exception: dims = '' task.blank_dimensions = dims task.save() machine_id = request.POST.get('machine') if machine_id and machine_id.isdigit(): self.object.machine_id = int(machine_id) date_value = request.POST.get('date') if date_value: self.object.date = date_value quantity_plan = request.POST.get('quantity_plan') if quantity_plan and quantity_plan.isdigit(): self.object.quantity_plan = int(quantity_plan) quantity_fact = request.POST.get('quantity_fact') if quantity_fact and quantity_fact.isdigit(): self.object.quantity_fact = int(quantity_fact) self.object.is_synced_1c = bool(request.POST.get('is_synced_1c')) # Действия закрытия для админа/технолога if action == 'close_done' and self.object.status == 'work': self.object.quantity_fact = self.object.quantity_plan self.object.status = 'done' self.object.save() return redirect_back() if action == 'close_partial' and self.object.status == 'work': try: fact = int(request.POST.get('quantity_fact', '0')) except ValueError: fact = 0 fact = max(0, min(fact, self.object.quantity_plan)) residual = self.object.quantity_plan - fact self.object.quantity_fact = fact self.object.status = 'partial' self.object.save() if residual > 0: Item.objects.create( task=self.object.task, date=self.object.date, machine=self.object.machine, quantity_plan=residual, quantity_fact=0, status='leftover', is_synced_1c=False, ) return redirect_back() self.object.save() return redirect_back() if role in ['operator', 'master']: action = request.POST.get('action', 'save') if action != 'save': return redirect_back() qf = request.POST.get('quantity_fact') if qf and qf.isdigit(): self.object.quantity_fact = int(qf) machine_changed = False if role == 'master': machine_id = request.POST.get('machine') if machine_id and machine_id.isdigit(): self.object.machine_id = int(machine_id) machine_changed = True fields = ['quantity_fact'] if machine_changed: fields.append('machine') self.object.save(update_fields=fields) return redirect_back() if role == 'clerk': if self.object.status not in ['done', 'partial']: return redirect_back() self.object.is_synced_1c = bool(request.POST.get('is_synced_1c')) self.object.save(update_fields=['is_synced_1c']) return redirect_back() return redirect_back() def get_success_url(self): return reverse_lazy('registry') class WarehouseStocksView(LoginRequiredMixin, TemplateView): template_name = 'shiftflow/warehouse_stocks.html' def dispatch(self, request, *args, **kwargs): profile = getattr(request.user, 'profile', None) role = profile.role if profile else ('admin' if request.user.is_superuser else 'operator') if role not in ['admin', 'technologist', 'master', 'clerk', 'observer']: return redirect('registry') return super().dispatch(request, *args, **kwargs) def get_context_data(self, **kwargs): ctx = super().get_context_data(**kwargs) profile = getattr(self.request.user, 'profile', None) role = profile.role if profile else ('admin' if self.request.user.is_superuser else 'operator') ctx['user_role'] = role 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 locations_qs = Location.objects.all().order_by('name') if ship_loc_id: locations_qs = locations_qs.exclude(id=ship_loc_id) locations = list(locations_qs) ctx['locations'] = locations q = (self.request.GET.get('q') or '').strip() location_id = (self.request.GET.get('location_id') or '').strip() kind = (self.request.GET.get('kind') or '').strip() start_date = (self.request.GET.get('start_date') or '').strip() end_date = (self.request.GET.get('end_date') or '').strip() filtered = self.request.GET.get('filtered') reset = self.request.GET.get('reset') is_default = (not filtered) or bool(reset) if is_default: today = timezone.localdate() start = today - timezone.timedelta(days=21) ctx['start_date'] = start.strftime('%Y-%m-%d') ctx['end_date'] = today.strftime('%Y-%m-%d') else: ctx['start_date'] = start_date ctx['end_date'] = end_date qs = StockItem.objects.select_related('location', 'material', 'material__category', 'entity', 'deal').filter(is_archived=False) if ship_loc_id: qs = qs.exclude(location_id=ship_loc_id) if location_id.isdigit(): qs = qs.filter(location_id=int(location_id)) start_val = ctx.get('start_date') end_val = ctx.get('end_date') if start_val: qs = qs.filter(created_at__date__gte=start_val) if end_val: qs = qs.filter(created_at__date__lte=end_val) if kind == 'raw': qs = qs.filter(material__isnull=False, entity__isnull=True) elif kind == 'finished': qs = qs.filter(entity__isnull=False) elif kind == 'remnant': qs = qs.filter(is_remnant=True) if q: qs = qs.filter( Q(material__full_name__icontains=q) | Q(material__name__icontains=q) | Q(entity__name__icontains=q) | Q(entity__drawing_number__icontains=q) | Q(unique_id__icontains=q) | Q(location__name__icontains=q) ) ctx['items'] = qs.order_by('-created_at', '-id') ctx['selected_location_id'] = location_id ctx['selected_kind'] = kind ctx['q'] = q ctx['can_transfer'] = role in ['admin', 'technologist', 'master', 'clerk'] ctx['can_receive'] = role in ['admin', 'technologist', 'master', 'clerk'] ctx['materials'] = Material.objects.select_related('category').all().order_by('full_name') ctx['entities'] = ProductEntity.objects.all().order_by('drawing_number', 'name') ctx['deals'] = Deal.objects.select_related('company').all().order_by('-id') ctx['shipping_location_id'] = ship_loc_id or '' ctx['shipping_location_label'] = ship_loc.name if ship_loc else '' return ctx class WarehouseTransferCreateView(LoginRequiredMixin, View): def post(self, request, *args, **kwargs): profile = getattr(request.user, 'profile', None) role = profile.role if profile else ('admin' if request.user.is_superuser else 'operator') if role not in ['admin', 'technologist', 'master', 'clerk']: return JsonResponse({'error': 'forbidden'}, status=403) stock_item_id = (request.POST.get('stock_item_id') or '').strip() to_location_id = (request.POST.get('to_location_id') or '').strip() qty_raw = (request.POST.get('quantity') or '').strip().replace(',', '.') next_url = (request.POST.get('next') or '').strip() if not next_url.startswith('/'): next_url = reverse_lazy('warehouse_stocks') if not (stock_item_id.isdigit() and to_location_id.isdigit()): messages.error(request, 'Заполни корректно: позиция склада и склад назначения.') return redirect(next_url) try: qty = float(qty_raw) except ValueError: qty = 0.0 if qty <= 0: messages.error(request, 'Количество должно быть больше 0.') return redirect(next_url) si = get_object_or_404(StockItem.objects.select_related('location'), pk=int(stock_item_id)) if int(to_location_id) == si.location_id: messages.error(request, 'Склад назначения должен отличаться от склада-источника.') return redirect(next_url) tr = TransferRecord.objects.create( from_location_id=si.location_id, to_location_id=int(to_location_id), sender=request.user, receiver=request.user, occurred_at=timezone.now(), status='received', received_at=timezone.now(), is_applied=False, ) TransferLine.objects.create(transfer=tr, stock_item=si, quantity=qty) try: receive_transfer(tr.id, request.user.id) messages.success(request, 'Операция применена.') except Exception as e: messages.error(request, f'Ошибка: {e}') return redirect(next_url) class WarehouseReceiptCreateView(LoginRequiredMixin, View): def post(self, request, *args, **kwargs): profile = getattr(request.user, 'profile', None) role = profile.role if profile else ('admin' if request.user.is_superuser else 'operator') if role not in ['admin', 'technologist', 'master', 'clerk']: 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') kind = (request.POST.get('kind') or '').strip() location_id = (request.POST.get('location_id') or '').strip() deal_id = (request.POST.get('deal_id') or '').strip() quantity_raw = (request.POST.get('quantity') or '').strip().replace(',', '.') if not location_id.isdigit(): messages.error(request, 'Выбери склад.') return redirect(next_url) try: qty = float(quantity_raw) except ValueError: qty = 0.0 if qty <= 0: messages.error(request, 'Количество должно быть больше 0.') return redirect(next_url) if kind == 'raw': material_id = (request.POST.get('material_id') or '').strip() is_customer_supplied = bool(request.POST.get('is_customer_supplied')) if not material_id.isdigit(): messages.error(request, 'Выбери материал.') return redirect(next_url) length_raw = (request.POST.get('current_length') or '').strip().replace(',', '.') width_raw = (request.POST.get('current_width') or '').strip().replace(',', '.') current_length = None current_width = None if length_raw: try: current_length = float(length_raw) except ValueError: current_length = None if width_raw: try: current_width = float(width_raw) except ValueError: current_width = None obj = StockItem( material_id=int(material_id), location_id=int(location_id), deal_id=(int(deal_id) if deal_id.isdigit() else None), quantity=float(qty), is_customer_supplied=is_customer_supplied, current_length=current_length, current_width=current_width, ) try: obj.full_clean() obj.save() messages.success(request, 'Приход сырья добавлен.') except Exception as e: messages.error(request, f'Ошибка прихода: {e}') return redirect(next_url) if kind == 'entity': entity_id = (request.POST.get('entity_id') or '').strip() if not entity_id.isdigit(): messages.error(request, 'Выбери КД (изделие/деталь).') return redirect(next_url) obj = StockItem( entity_id=int(entity_id), location_id=int(location_id), deal_id=(int(deal_id) if deal_id.isdigit() else None), quantity=float(qty), ) try: obj.full_clean() obj.save() messages.success(request, 'Приход изделия добавлен.') except Exception as e: messages.error(request, f'Ошибка прихода: {e}') return redirect(next_url) messages.error(request, 'Выбери тип прихода.') return redirect(next_url) class ClosingView(LoginRequiredMixin, TemplateView): template_name = 'shiftflow/closing.html' def dispatch(self, request, *args, **kwargs): profile = getattr(request.user, 'profile', None) role = profile.role if profile else ('admin' if request.user.is_superuser else 'operator') if role not in ['admin', 'master', 'operator', 'observer']: return redirect('registry') return super().dispatch(request, *args, **kwargs) def get_context_data(self, **kwargs): ctx = super().get_context_data(**kwargs) profile = getattr(self.request.user, 'profile', None) role = profile.role if profile else ('admin' if self.request.user.is_superuser else 'operator') ctx['user_role'] = role if role == 'operator' and profile: machines = list(profile.machines.all().order_by('name')) else: machines = list(Machine.objects.all().order_by('name')) ctx['machines'] = machines ctx['materials'] = list(Material.objects.select_related('category').order_by('full_name')) machine_id = (self.request.GET.get('machine_id') or '').strip() material_id = (self.request.GET.get('material_id') or '').strip() ctx['selected_machine_id'] = machine_id ctx['selected_material_id'] = material_id items = [] stock_items = [] if machine_id.isdigit() and material_id.isdigit(): items = list( Item.objects.select_related('task', 'task__deal', 'task__material', 'machine') .filter(machine_id=int(machine_id), status='work', task__material_id=int(material_id)) .order_by('date', 'task__deal__number', 'task__drawing_name') ) machine = Machine.objects.select_related('workshop', 'workshop__location', 'location').filter(pk=int(machine_id)).first() work_location_id = None if machine and getattr(machine, 'workshop_id', None) and getattr(machine.workshop, 'location_id', None): work_location_id = machine.workshop.location_id elif machine and getattr(machine, 'location_id', None): work_location_id = machine.location_id if work_location_id: stock_items = list( StockItem.objects.select_related('location', 'material') .filter(location_id=work_location_id, material_id=int(material_id), entity__isnull=True, is_archived=False) .filter(quantity__gt=0) .order_by('created_at', 'id') ) ctx['items'] = items ctx['stock_items'] = stock_items ctx['can_edit'] = role in ['admin', 'master', 'operator'] return ctx def post(self, request, *args, **kwargs): profile = getattr(request.user, 'profile', None) role = profile.role if profile else ('admin' if request.user.is_superuser else 'operator') if role not in ['admin', 'master', 'operator']: return redirect('closing') machine_id = (request.POST.get('machine_id') or '').strip() material_id = (request.POST.get('material_id') or '').strip() if not (machine_id.isdigit() and material_id.isdigit()): messages.error(request, 'Выбери станок и материал.') return redirect('closing') item_actions = {} for k, v in request.POST.items(): if not k.startswith('close_action_'): continue item_id = k.replace('close_action_', '') if not item_id.isdigit(): continue action = (v or '').strip() if action not in ['done', 'partial']: continue fact_raw = (request.POST.get(f'fact_{item_id}') or '').strip() try: fact = int(fact_raw) except ValueError: fact = 0 item_actions[int(item_id)] = {'action': action, 'fact': fact} consumptions = {} for k, v in request.POST.items(): if not k.startswith('consume_'): continue sid = k.replace('consume_', '') if not sid.isdigit(): continue raw = (v or '').strip().replace(',', '.') if not raw: continue try: qty = float(raw) except ValueError: qty = 0.0 if qty > 0: consumptions[int(sid)] = qty remnants = [] idx = 0 while True: has_any = ( f'remnant_qty_{idx}' in request.POST or f'remnant_len_{idx}' in request.POST or f'remnant_wid_{idx}' in request.POST ) if not has_any: break qty_raw = (request.POST.get(f'remnant_qty_{idx}') or '').strip().replace(',', '.') len_raw = (request.POST.get(f'remnant_len_{idx}') or '').strip().replace(',', '.') wid_raw = (request.POST.get(f'remnant_wid_{idx}') or '').strip().replace(',', '.') if qty_raw: try: rq = float(qty_raw) except ValueError: rq = 0.0 if rq > 0: rl = None rw = None if len_raw: try: rl = float(len_raw) except ValueError: rl = None if wid_raw: try: rw = float(wid_raw) except ValueError: rw = None remnants.append({'quantity': rq, 'current_length': rl, 'current_width': rw}) idx += 1 if idx > 200: break if not item_actions: messages.error(request, 'Выбери хотя бы один пункт сменки и режим закрытия (полностью/частично).') return redirect(f"{reverse_lazy('closing')}?machine_id={machine_id}&material_id={material_id}") class ProductsView(LoginRequiredMixin, TemplateView): template_name = 'shiftflow/products.html' def dispatch(self, request, *args, **kwargs): profile = getattr(request.user, 'profile', None) role = profile.role if profile else ('admin' if request.user.is_superuser else 'operator') if role not in ['admin', 'technologist', 'observer']: return redirect('registry') return super().dispatch(request, *args, **kwargs) def get_context_data(self, **kwargs): ctx = super().get_context_data(**kwargs) profile = getattr(self.request.user, 'profile', None) role = profile.role if profile else ('admin' if self.request.user.is_superuser else 'operator') ctx['user_role'] = role ctx['can_edit'] = role in ['admin', 'technologist'] q = (self.request.GET.get('q') or '').strip() entity_type = (self.request.GET.get('entity_type') or '').strip() qs = ProductEntity.objects.select_related('planned_material', 'route').all() if entity_type: qs = qs.filter(entity_type=entity_type) if q: qs = qs.filter( Q(drawing_number__icontains=q) | Q(name__icontains=q) | Q(planned_material__name__icontains=q) | Q(planned_material__full_name__icontains=q) ) ctx['q'] = q ctx['entity_type'] = entity_type ctx['products'] = qs.order_by('drawing_number', 'name') return ctx def post(self, request, *args, **kwargs): profile = getattr(request.user, 'profile', None) role = profile.role if profile else ('admin' if request.user.is_superuser else 'operator') if role not in ['admin', 'technologist']: return redirect('products') entity_type = (request.POST.get('entity_type') or '').strip() name = (request.POST.get('name') or '').strip() drawing_number = (request.POST.get('drawing_number') or '').strip() if entity_type not in ['product', 'assembly']: messages.error(request, 'Выбери тип: изделие или сборочная единица.') return redirect('products') if not name: messages.error(request, 'Заполни наименование.') return redirect('products') obj = ProductEntity.objects.create( entity_type=entity_type, name=name[:255], drawing_number=drawing_number[:100], ) return redirect('product_detail', pk=obj.id) class ProductDetailView(LoginRequiredMixin, TemplateView): template_name = 'shiftflow/product_detail.html' def dispatch(self, request, *args, **kwargs): profile = getattr(request.user, 'profile', None) role = profile.role if profile else ('admin' if request.user.is_superuser else 'operator') if role not in ['admin', 'technologist', 'observer']: return redirect('registry') return super().dispatch(request, *args, **kwargs) def get_context_data(self, **kwargs): ctx = super().get_context_data(**kwargs) profile = getattr(self.request.user, 'profile', None) role = profile.role if profile else ('admin' if self.request.user.is_superuser else 'operator') ctx['user_role'] = role ctx['can_edit'] = role in ['admin', 'technologist'] entity = get_object_or_404(ProductEntity.objects.select_related('planned_material', 'route'), pk=int(self.kwargs['pk'])) ctx['entity'] = entity lines = list( BOM.objects.select_related('child', 'child__planned_material', 'child__route') .filter(parent=entity) .order_by('child__entity_type', 'child__drawing_number', 'child__name', 'id') ) ctx['lines'] = lines q = (self.request.GET.get('q') or '').strip() ctx['q'] = q candidates = ProductEntity.objects.select_related('planned_material').exclude(id=entity.id) if q: candidates = candidates.filter(Q(drawing_number__icontains=q) | Q(name__icontains=q)) ctx['candidates'] = list(candidates.order_by('drawing_number', 'name')[:200]) parent_id = (self.request.GET.get('parent') or '').strip() ctx['parent_id'] = parent_id if parent_id.isdigit() else '' q_dn = (self.request.GET.get('q_dn') or '').strip() q_name = (self.request.GET.get('q_name') or '').strip() ctx['q_dn'] = q_dn ctx['q_name'] = q_name found = None searched = False if q_dn or q_name: searched = True candidates_qs = ProductEntity.objects.exclude(id=entity.id) if q_dn: candidates_qs = candidates_qs.filter(drawing_number__icontains=q_dn) if q_name: candidates_qs = candidates_qs.filter(name__icontains=q_name) found = candidates_qs.order_by('drawing_number', 'name', 'id').first() ctx['searched'] = searched ctx['found'] = found ctx['materials'] = list(Material.objects.select_related('category').order_by('full_name')) return ctx def post(self, request, *args, **kwargs): profile = getattr(request.user, 'profile', None) role = profile.role if profile else ('admin' if request.user.is_superuser else 'operator') if role not in ['admin', 'technologist']: return redirect('product_detail', pk=self.kwargs['pk']) entity = get_object_or_404(ProductEntity, pk=int(self.kwargs['pk'])) action = (request.POST.get('action') or '').strip() parent_id = (request.POST.get('parent') or '').strip() next_url = reverse_lazy('product_detail', kwargs={'pk': entity.id}) if parent_id.isdigit(): next_url = f"{next_url}?parent={parent_id}" def parse_int(value, default=None): s = (value or '').strip() if not s.isdigit(): return default return int(s) def parse_qty(value): v = parse_int(value, default=0) return v if v and v > 0 else None def would_cycle(parent_id: int, child_id: int) -> bool: stack = [child_id] seen = set() while stack: cur = stack.pop() if cur == parent_id: return True if cur in seen: continue seen.add(cur) stack.extend(list(BOM.objects.filter(parent_id=cur).values_list('child_id', flat=True))) return False if action == 'delete_line': bom_id = parse_int(request.POST.get('bom_id')) if not bom_id: messages.error(request, 'Не выбрана строка BOM.') return redirect(next_url) BOM.objects.filter(id=bom_id, parent_id=entity.id).delete() messages.success(request, 'Строка удалена.') return redirect(next_url) if action == 'update_qty': bom_id = parse_int(request.POST.get('bom_id')) qty = parse_qty(request.POST.get('quantity')) if not bom_id or not qty: messages.error(request, 'Заполни количество.') return redirect('product_detail', pk=entity.id) BOM.objects.filter(id=bom_id, parent_id=entity.id).update(quantity=qty) messages.success(request, 'Количество обновлено.') return redirect(next_url) if action == 'add_existing': child_id = parse_int(request.POST.get('child_id')) qty = parse_qty(request.POST.get('quantity')) if not child_id or not qty: messages.error(request, 'Выбери существующую сущность и количество.') return redirect('product_detail', pk=entity.id) if child_id == entity.id: messages.error(request, 'Нельзя добавить узел сам в себя.') return redirect('product_detail', pk=entity.id) if would_cycle(entity.id, child_id): messages.error(request, 'Нельзя добавить: получится цикл в структуре.') return redirect('product_detail', pk=entity.id) obj, _ = BOM.objects.get_or_create(parent_id=entity.id, child_id=child_id, defaults={'quantity': qty}) if obj.quantity != qty: obj.quantity = qty obj.save(update_fields=['quantity']) messages.success(request, 'Компонент добавлен.') return redirect(next_url) if action == 'create_and_add': child_type = (request.POST.get('child_type') or '').strip() name = (request.POST.get('name') or '').strip() drawing_number = (request.POST.get('drawing_number') or '').strip() qty = parse_qty(request.POST.get('quantity')) planned_material_id = parse_int(request.POST.get('planned_material_id')) if child_type not in ['assembly', 'part', 'purchased', 'casting', 'outsourced']: messages.error(request, 'Выбери корректный тип компонента.') return redirect('product_detail', pk=entity.id) if not name: messages.error(request, 'Заполни наименование компонента.') return redirect('product_detail', pk=entity.id) if not qty: messages.error(request, 'Заполни количество.') return redirect('product_detail', pk=entity.id) child = ProductEntity.objects.create( entity_type=child_type, name=name[:255], drawing_number=drawing_number[:100], planned_material_id=(planned_material_id if child_type == 'part' and planned_material_id else None), ) BOM.objects.create(parent_id=entity.id, child_id=child.id, quantity=qty) messages.success(request, 'Компонент создан и добавлен.') return redirect(next_url) messages.error(request, 'Неизвестное действие.') return redirect(next_url) class ProductInfoView(LoginRequiredMixin, TemplateView): def dispatch(self, request, *args, **kwargs): profile = getattr(request.user, 'profile', None) role = profile.role if profile else ('admin' if request.user.is_superuser else 'operator') if role not in ['admin', 'technologist', 'observer']: return redirect('registry') return super().dispatch(request, *args, **kwargs) def get_template_names(self): entity = get_object_or_404(ProductEntity, pk=int(self.kwargs['pk'])) et = entity.entity_type if et == 'part': return ['shiftflow/product_info_part.html'] if et in ['product', 'assembly']: return ['shiftflow/product_info_assembly.html'] if et == 'purchased': return ['shiftflow/product_info_purchased.html'] if et == 'casting': return ['shiftflow/product_info_casting.html'] if et == 'outsourced': return ['shiftflow/product_info_outsourced.html'] return ['shiftflow/product_info_external.html'] def get_context_data(self, **kwargs): ctx = super().get_context_data(**kwargs) profile = getattr(self.request.user, 'profile', None) role = profile.role if profile else ('admin' if self.request.user.is_superuser else 'operator') ctx['user_role'] = role ctx['can_edit'] = role in ['admin', 'technologist'] entity = get_object_or_404(ProductEntity.objects.select_related('planned_material', 'route'), pk=int(self.kwargs['pk'])) ctx['entity'] = entity ctx['materials'] = list(Material.objects.select_related('category').order_by('full_name')) ctx['routes'] = list(RouteStub.objects.all().order_by('name')) passport = None seams = [] if entity.entity_type in ['product', 'assembly']: passport, _ = AssemblyPassport.objects.get_or_create(entity_id=entity.id) seams = list(WeldingSeam.objects.filter(passport_id=passport.id).order_by('id')) elif entity.entity_type == 'part': passport, _ = PartPassport.objects.get_or_create(entity_id=entity.id) elif entity.entity_type == 'purchased': passport, _ = PurchasedPassport.objects.get_or_create(entity_id=entity.id) elif entity.entity_type == 'casting': passport, _ = CastingPassport.objects.get_or_create(entity_id=entity.id) elif entity.entity_type == 'outsourced': passport, _ = OutsourcedPassport.objects.get_or_create(entity_id=entity.id) ctx['passport'] = passport ctx['welding_seams'] = seams next_url = (self.request.GET.get('next') or '').strip() ctx['next'] = next_url if next_url.startswith('/') else reverse_lazy('products') return ctx def post(self, request, *args, **kwargs): profile = getattr(request.user, 'profile', None) role = profile.role if profile else ('admin' if request.user.is_superuser else 'operator') if role not in ['admin', 'technologist']: return redirect('products') entity = get_object_or_404(ProductEntity, pk=int(self.kwargs['pk'])) action = (request.POST.get('action') or '').strip() next_url = (request.POST.get('next') or '').strip() if not next_url.startswith('/'): next_url = reverse_lazy('products') def parse_int(value, default=None): s = (value or '').strip() if not s.isdigit(): return default return int(s) def parse_float(value): s = (value or '').strip().replace(',', '.') if not s: return None try: return float(s) except ValueError: return None if action == 'create_route': name = (request.POST.get('route_name') or '').strip() if not name: messages.error(request, 'Заполни название маршрута.') return redirect(next_url) RouteStub.objects.get_or_create(name=name[:200]) messages.success(request, 'Маршрут добавлен.') return redirect(next_url) if action == 'add_weld_seam': passport, _ = AssemblyPassport.objects.get_or_create(entity_id=entity.id) name = (request.POST.get('seam_name') or '').strip() leg_mm = parse_float(request.POST.get('seam_leg_mm')) length_mm = parse_float(request.POST.get('seam_length_mm')) qty = parse_int(request.POST.get('seam_quantity'), default=1) if not name: messages.error(request, 'Заполни наименование сварного шва.') return redirect(next_url) if leg_mm is None or leg_mm <= 0: messages.error(request, 'Катет должен быть больше 0.') return redirect(next_url) if length_mm is None or length_mm <= 0: messages.error(request, 'Длина должна быть больше 0.') return redirect(next_url) if not qty or qty <= 0: messages.error(request, 'Количество должно быть больше 0.') return redirect(next_url) WeldingSeam.objects.create( passport_id=passport.id, name=name[:255], leg_mm=float(leg_mm), length_mm=float(length_mm), quantity=int(qty), ) messages.success(request, 'Сварной шов добавлен.') return redirect(next_url) if action == 'delete_weld_seam': seam_id = parse_int(request.POST.get('seam_id')) passport = AssemblyPassport.objects.filter(entity_id=entity.id).first() if not seam_id or not passport: messages.error(request, 'Не выбран сварной шов.') return redirect(next_url) WeldingSeam.objects.filter(id=seam_id, passport_id=passport.id).delete() messages.success(request, 'Сварной шов удалён.') return redirect(next_url) if action != 'save': messages.error(request, 'Неизвестное действие.') return redirect(next_url) entity.drawing_number = (request.POST.get('drawing_number') or '').strip()[:100] entity.name = (request.POST.get('name') or '').strip()[:255] if not entity.name: messages.error(request, 'Наименование обязательно.') return redirect(next_url) entity.passport_filled = bool(request.POST.get('passport_filled')) route_id = parse_int(request.POST.get('route_id')) entity.route_id = route_id if entity.entity_type == 'part': pm_id = parse_int(request.POST.get('planned_material_id')) entity.planned_material_id = pm_id pdf = request.FILES.get('pdf_main') if pdf: entity.pdf_main = pdf dxf = request.FILES.get('dxf_file') if dxf: entity.dxf_file = dxf preview = request.FILES.get('preview') if preview: entity.preview = preview entity.save() if entity.entity_type in ['product', 'assembly']: passport, _ = AssemblyPassport.objects.get_or_create(entity_id=entity.id) passport.weight_kg = parse_float(request.POST.get('weight_kg')) passport.coating = (request.POST.get('coating') or '').strip()[:200] passport.coating_color = (request.POST.get('coating_color') or '').strip()[:100] passport.coating_area_m2 = parse_float(request.POST.get('coating_area_m2')) passport.technical_requirements = (request.POST.get('technical_requirements') or '').strip() passport.save() if entity.entity_type == 'part': passport, _ = PartPassport.objects.get_or_create(entity_id=entity.id) passport.thickness_mm = parse_float(request.POST.get('thickness_mm')) passport.length_mm = parse_float(request.POST.get('length_mm')) passport.mass_kg = parse_float(request.POST.get('mass_kg')) passport.cut_length_mm = parse_float(request.POST.get('cut_length_mm')) passport.pierce_count = parse_int(request.POST.get('pierce_count')) passport.engraving = (request.POST.get('engraving') or '').strip() passport.technical_requirements = (request.POST.get('technical_requirements') or '').strip() passport.save() if entity.entity_type == 'purchased': passport, _ = PurchasedPassport.objects.get_or_create(entity_id=entity.id) passport.gost = (request.POST.get('gost') or '').strip()[:255] passport.save() if entity.entity_type == 'casting': passport, _ = CastingPassport.objects.get_or_create(entity_id=entity.id) passport.casting_material = (request.POST.get('casting_material') or '').strip()[:200] passport.mass_kg = parse_float(request.POST.get('mass_kg')) passport.save() if entity.entity_type == 'outsourced': passport, _ = OutsourcedPassport.objects.get_or_create(entity_id=entity.id) passport.technical_requirements = (request.POST.get('technical_requirements') or '').strip() passport.notes = (request.POST.get('notes') or '').strip() passport.save() messages.success(request, 'Сохранено.') return redirect(next_url) class WriteOffsView(LoginRequiredMixin, TemplateView): template_name = 'shiftflow/writeoffs.html' def dispatch(self, request, *args, **kwargs): profile = getattr(request.user, 'profile', None) role = profile.role if profile else ('admin' if request.user.is_superuser else 'operator') if role not in ['admin', 'clerk', 'observer']: return redirect('registry') return super().dispatch(request, *args, **kwargs) def get_context_data(self, **kwargs): ctx = super().get_context_data(**kwargs) profile = getattr(self.request.user, 'profile', None) role = profile.role if profile else ('admin' if self.request.user.is_superuser else 'operator') ctx['user_role'] = role ctx['can_edit'] = role in ['admin', 'clerk'] start_date = (self.request.GET.get('start_date') or '').strip() end_date = (self.request.GET.get('end_date') or '').strip() reset = self.request.GET.get('reset') if not start_date or not end_date or reset: today = timezone.localdate() start = today - timedelta(days=21) start_date = start.strftime('%Y-%m-%d') end_date = today.strftime('%Y-%m-%d') ctx['start_date'] = start_date ctx['end_date'] = end_date reports_qs = ( CuttingSession.objects.select_related('machine', 'operator') .filter(is_closed=True, date__gte=start_date, date__lte=end_date) .order_by('-date', '-id') ) reports = list( reports_qs.prefetch_related( 'tasks__task__deal', 'tasks__task__material', 'consumptions__material', 'consumptions__stock_item__material', 'results__stock_item__material', 'results__stock_item__entity', ) ) report_cards = [] for r in reports: consumed = {} for c in list(getattr(r, 'consumptions', []).all() if hasattr(getattr(r, 'consumptions', None), 'all') else []): mat = None if getattr(c, 'material_id', None): mat = c.material elif getattr(c, 'stock_item_id', None) and getattr(c.stock_item, 'material_id', None): mat = c.stock_item.material label = str(mat) if mat else '—' key = getattr(mat, 'id', None) or label consumed[key] = consumed.get(key, 0.0) + float(c.quantity) produced = {} remnants = {} for res in list(getattr(r, 'results', []).all() if hasattr(getattr(r, 'results', None), 'all') else []): si = res.stock_item if res.kind == 'finished': label = str(getattr(si, 'entity', None) or '—') produced[label] = produced.get(label, 0.0) + float(si.quantity) elif res.kind == 'remnant': label = str(getattr(si, 'material', None) or '—') remnants[label] = remnants.get(label, 0.0) + float(si.quantity) report_cards.append({ 'report': r, 'consumed': consumed, 'produced': produced, 'remnants': remnants, 'tasks': list(getattr(r, 'tasks', []).all() if hasattr(getattr(r, 'tasks', None), 'all') else []), }) ctx['report_cards'] = report_cards items_qs = ( Item.objects.select_related('task', 'task__deal', 'machine') .filter(status__in=['done', 'partial'], date__gte=start_date, date__lte=end_date) .order_by('-date', '-id') ) ctx['items'] = list(items_qs) return ctx def post(self, request, *args, **kwargs): profile = getattr(request.user, 'profile', None) role = profile.role if profile else ('admin' if request.user.is_superuser else 'operator') if role not in ['admin', 'clerk']: return redirect('writeoffs') ids = request.POST.getlist('item_ids') item_ids = [int(x) for x in ids if x.isdigit()] if not item_ids: messages.error(request, 'Не выбрано ни одного сменного задания.') return redirect('writeoffs') Item.objects.filter(id__in=item_ids).update(is_synced_1c=True) messages.success(request, f'Отмечено в 1С: {len(item_ids)}.') start_date = (request.POST.get('start_date') or '').strip() end_date = (request.POST.get('end_date') or '').strip() return redirect(f"{reverse_lazy('writeoffs')}?start_date={start_date}&end_date={end_date}") if not consumptions: messages.error(request, 'Заполни списание: укажи, какие единицы на складе использованы и в каком количестве.') return redirect(f"{reverse_lazy('closing')}?machine_id={machine_id}&material_id={material_id}") try: apply_closing( user_id=request.user.id, machine_id=int(machine_id), material_id=int(material_id), item_actions=item_actions, consumptions=consumptions, remnants=remnants, ) messages.success(request, 'Закрытие выполнено.') except Exception as e: messages.error(request, f'Ошибка закрытия: {e}') return redirect(f"{reverse_lazy('closing')}?machine_id={machine_id}&material_id={material_id}")