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 ProductEntity 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 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}")