from datetime import datetime, timedelta from urllib.parse import urlencode, urlsplit import logging 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, transaction from django.db.models import Case, ExpressionWrapper, F, IntegerField, Max, 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 shiftflow.authz import get_user_group_roles, get_user_roles, primary_role, has_any_role logger = logging.getLogger('mes') def _reconcile_default_delivery_batch(deal_id: int) -> None: deal_items = list(DealItem.objects.filter(deal_id=deal_id).values_list('entity_id', 'quantity')) if not deal_items: return deal_due = Deal.objects.filter(id=deal_id).values_list('due_date', flat=True).first() non_default_dates = list( DealDeliveryBatch.objects.filter(deal_id=deal_id, is_default=False).values_list('due_date', flat=True) ) due = max(non_default_dates) if non_default_dates else (deal_due or timezone.localdate()) default_batch, created = DealDeliveryBatch.objects.get_or_create( deal_id=deal_id, is_default=True, defaults={'name': 'К закрытию', 'due_date': due}, ) upd = [] if created or default_batch.name.strip() != 'К закрытию': default_batch.name = 'К закрытию' upd.append('name') if default_batch.due_date != due: default_batch.due_date = due upd.append('due_date') if upd: default_batch.save(update_fields=upd) allocated = { int(r['entity_id']): int(r['s'] or 0) for r in DealBatchItem.objects.filter(batch__deal_id=deal_id, batch__is_default=False) .values('entity_id') .annotate(s=Coalesce(Sum('quantity'), 0)) } current_defaults = { int(x.entity_id): x for x in DealBatchItem.objects.filter(batch_id=default_batch.id).select_related('entity') } for entity_id, qty in deal_items: total = int(qty or 0) used = int(allocated.get(int(entity_id), 0) or 0) residual = total - used if residual < 0: residual = 0 cur = current_defaults.get(int(entity_id)) if residual <= 0: if cur: cur.delete() continue if cur: changed = False if int(cur.quantity or 0) != residual: cur.quantity = residual changed = True if int(cur.started_qty or 0) > residual: cur.started_qty = residual changed = True if changed: cur.save(update_fields=['quantity', 'started_qty']) else: DealBatchItem.objects.create(batch_id=default_batch.id, entity_id=int(entity_id), quantity=residual, started_qty=0) from manufacturing.models import ( AssemblyPassport, BOM, CastingPassport, EntityOperation, Operation, OutsourcedPassport, PartPassport, ProductEntity, PurchasedPassport, 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, apply_closing_workitems from shiftflow.services.bom_explosion import ( explode_deal, explode_roots_additive, rollback_roots_additive, ExplosionValidationError, _build_bom_graph, _accumulate_requirements, ) from shiftflow.services.kitting import ( build_kitting_requirements, build_kitting_leaf_requirements, get_work_location_for_workitem, add_kitting_line, remove_kitting_line, get_kitting_draft, clear_kitting_draft, apply_kitting_draft, ) from .forms import ProductionTaskCreateForm from .models import ( Company, CuttingSession, Deal, DealBatchItem, DealDeliveryBatch, DealEntityProgress, DealItem, DxfPreviewJob, DxfPreviewSettings, EmployeeProfile, Item, Machine, ProductionReportConsumption, ProductionReportRemnant, ProductionReportStockResult, ProductionTask, MaterialRequirement, ProcurementRequirement, ShiftItem, WorkItem, Workshop, ) 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 def _update_entity_preview(entity: ProductEntity) -> bool: if not entity.dxf_file: return False name = (entity.dxf_file.name or '').lower() if not name.endswith('.dxf'): try: if entity.preview: entity.preview.delete(save=False) except Exception: pass entity.preview = None entity.save(update_fields=['preview']) return False dxf_path = getattr(entity.dxf_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, ) try: if entity.preview: entity.preview.delete(save=False) except Exception: pass filename = f"entity_{entity.id}_preview.png" entity.preview.save(filename, ContentFile(png_bytes), save=False) entity.save(update_fields=['preview']) 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 dispatch(self, request, *args, **kwargs): roles = get_user_group_roles(request.user) if not has_any_role(roles, ['admin', 'operator', 'master', 'technologist', 'clerk', 'prod_head', 'director', 'observer']): return redirect('index') return super().dispatch(request, *args, **kwargs) def get_queryset(self): queryset = Item.objects.select_related('task', 'task__deal', 'task__material', 'machine') user = self.request.user profile = getattr(user, 'profile', None) roles = get_user_group_roles(user) role = primary_role(roles) # Флаг, что фильтрация была применена через форму. Если нет — используем дефолты 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) # Ограничения по ролям 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) roles = get_user_group_roles(user) role = primary_role(roles) context['user_role'] = role context['user_roles'] = sorted(roles) context['is_readonly'] = bool(getattr(profile, 'is_readonly', False)) if profile else False allowed_ws = list(profile.allowed_workshops.values_list('id', flat=True)) if profile else [] context['allowed_workshop_ids'] = allowed_ws machines = Machine.objects.filter(machine_type__in=['linear', 'sheet']).order_by('name') 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['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 work_qs = WorkItem.objects.select_related('deal', 'entity', 'entity__planned_material', 'operation', 'machine', 'workshop') m_ids = [int(i) for i in self.request.GET.getlist('m_ids') if str(i).isdigit()] if m_ids: ws_ids = list( Machine.objects.filter(id__in=m_ids) .exclude(workshop_id__isnull=True) .values_list('workshop_id', flat=True) ) work_qs = work_qs.filter(Q(machine_id__in=m_ids) | Q(machine_id__isnull=True, workshop_id__in=ws_ids)) 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() week_ago = today - timezone.timedelta(days=7) work_qs = work_qs.filter(date__gte=week_ago, date__lte=today) else: if context.get('start_date'): work_qs = work_qs.filter(date__gte=context['start_date']) if context.get('end_date'): work_qs = work_qs.filter(date__lte=context['end_date']) statuses = self.request.GET.getlist('statuses') if is_default: work_qs = work_qs.filter(status__in=['planned']) else: if not statuses: work_qs = work_qs.none() else: expanded = [] for s in statuses: if s == 'work': expanded += ['planned'] elif s == 'leftover': expanded.append('leftover') elif s == 'closed': expanded.append('done') if expanded: work_qs = work_qs.filter(status__in=expanded) if role == 'operator': user_machines = profile.machines.all() if profile else Machine.objects.none() work_qs = work_qs.filter(machine__in=user_machines) elif role == 'master': allowed_ws = list(profile.allowed_workshops.values_list('id', flat=True)) if profile else [] if allowed_ws: work_qs = work_qs.filter(Q(machine__workshop_id__in=allowed_ws) | Q(machine_id__isnull=True, workshop_id__in=allowed_ws)) workitems = list(work_qs.order_by('-date', 'deal__number', 'id')[:2000]) for wi in workitems: plan = int(wi.quantity_plan or 0) done = int(wi.quantity_done or 0) if plan > 0: pct = int(round(done * 100 / plan)) else: pct = 0 wi.fact_pct = pct wi.fact_width = max(0, min(100, pct)) context['workitems'] = workitems return context class LegacyRegistryView(RegistryView): template_name = 'shiftflow/legacy_registry.html' def dispatch(self, request, *args, **kwargs): roles = get_user_group_roles(request.user) if not has_any_role(roles, ['admin', 'master', 'technologist', 'clerk', 'operator', 'prod_head', 'director', 'observer']): return redirect('registry') return super().dispatch(request, *args, **kwargs) class WeldingPlanAddView(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') is_readonly = bool(getattr(profile, 'is_readonly', False)) if profile else False if role not in ['admin', 'technologist', 'master', 'clerk', 'manager', 'observer']: return redirect('planning') if is_readonly: messages.error(request, 'Доступ только для просмотра.') return redirect('planning') def parse_int(s): s = (s or '').strip() return int(s) if s.isdigit() else None deal_id = parse_int(request.POST.get('deal_id')) entity_id = parse_int(request.POST.get('entity_id')) qty = parse_int(request.POST.get('quantity')) workshop_id = parse_int(request.POST.get('workshop_id')) allowed_ws = set(profile.allowed_workshops.values_list('id', flat=True)) if profile else set() if not (deal_id and entity_id and qty and qty > 0): messages.error(request, 'Заполни сущность и количество для сварки.') next_url = (request.POST.get('next') or '').strip() return redirect(next_url if next_url.startswith('/') else 'planning') di = DealItem.objects.filter(deal_id=deal_id, entity_id=entity_id).first() if not di: messages.error(request, 'Позиция сделки не найдена.') next_url = (request.POST.get('next') or '').strip() return redirect(next_url if next_url.startswith('/') else 'planning') # Комментарий: берём текущую операцию по маршруту детали/сборки. cur = DealEntityProgress.objects.filter(deal_id=deal_id, entity_id=entity_id).values_list('current_seq', flat=True).first() cur = int(cur or 1) eo = EntityOperation.objects.select_related('operation').filter(entity_id=entity_id, seq=cur).first() op = eo.operation if eo else Operation.objects.filter(code='welding').first() if not op: messages.error(request, 'Не найдена операция welding. Создай её в справочнике операций.') next_url = (request.POST.get('next') or '').strip() return redirect(next_url if next_url.startswith('/') else 'planning') if eo and op.code != 'welding': messages.error(request, f"Текущая операция по маршруту: {op.name}. Нельзя поставить в план сварки.") next_url = (request.POST.get('next') or '').strip() return redirect(next_url if next_url.startswith('/') else 'planning') if not workshop_id and op.workshop_id: workshop_id = int(op.workshop_id) if allowed_ws: if not workshop_id: messages.error(request, 'Выбери цех.') next_url = (request.POST.get('next') or '').strip() return redirect(next_url if next_url.startswith('/') else 'planning') if workshop_id not in allowed_ws: messages.error(request, 'Нет доступа к выбранному цеху.') next_url = (request.POST.get('next') or '').strip() return redirect(next_url if next_url.startswith('/') else 'planning') # Комментарий: не даём планировать сварку сверх заказа в сделке. planned = WorkItem.objects.filter(deal_id=deal_id, entity_id=entity_id).filter(Q(operation__code='welding') | Q(stage='welding')).aggregate(s=Coalesce(Sum('quantity_plan'), 0))['s'] remaining = int(di.quantity or 0) - int(planned or 0) if qty > remaining: messages.error(request, f'Нельзя добавить {qty} шт: осталось {max(0, remaining)} шт.') next_url = (request.POST.get('next') or '').strip() return redirect(next_url if next_url.startswith('/') else 'planning') WorkItem.objects.create( deal_id=deal_id, entity_id=entity_id, operation_id=op.id, stage='welding', workshop_id=(workshop_id if workshop_id else None), quantity_plan=qty, status='planned', date=timezone.localdate(), ) messages.success(request, 'Добавлено в план сварки.') next_url = (request.POST.get('next') or '').strip() return redirect(next_url if next_url.startswith('/') else 'planning') class PaintingPlanAddView(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') is_readonly = bool(getattr(profile, 'is_readonly', False)) if profile else False if role not in ['admin', 'technologist', 'master', 'clerk', 'manager', 'observer']: return redirect('planning') if is_readonly: messages.error(request, 'Доступ только для просмотра.') return redirect('planning') def parse_int(s): s = (s or '').strip() return int(s) if s.isdigit() else None deal_id = parse_int(request.POST.get('deal_id')) entity_id = parse_int(request.POST.get('entity_id')) qty = parse_int(request.POST.get('quantity')) workshop_id = parse_int(request.POST.get('workshop_id')) allowed_ws = set(profile.allowed_workshops.values_list('id', flat=True)) if profile else set() if allowed_ws: if not workshop_id: messages.error(request, 'Выбери цех.') next_url = (request.POST.get('next') or '').strip() return redirect(next_url if next_url.startswith('/') else 'planning') if workshop_id not in allowed_ws: messages.error(request, 'Нет доступа к выбранному цеху.') next_url = (request.POST.get('next') or '').strip() return redirect(next_url if next_url.startswith('/') else 'planning') if not (deal_id and entity_id and qty and qty > 0): messages.error(request, 'Заполни сущность и количество для покраски.') next_url = (request.POST.get('next') or '').strip() return redirect(next_url if next_url.startswith('/') else 'planning') di = DealItem.objects.filter(deal_id=deal_id, entity_id=entity_id).first() if not di: messages.error(request, 'Позиция сделки не найдена.') next_url = (request.POST.get('next') or '').strip() return redirect(next_url if next_url.startswith('/') else 'planning') # Комментарий: покраску можно планировать только на то, что реально сварено. # Доступно к покраске = min(заказано, сварено) − уже в плане покраски. # Комментарий: берём текущую операцию по маршруту детали/сборки. cur = DealEntityProgress.objects.filter(deal_id=deal_id, entity_id=entity_id).values_list('current_seq', flat=True).first() cur = int(cur or 1) eo = EntityOperation.objects.select_related('operation').filter(entity_id=entity_id, seq=cur).first() op = eo.operation if eo else Operation.objects.filter(code='painting').first() if not op: messages.error(request, 'Не найдена операция painting. Создай её в справочнике операций.') next_url = (request.POST.get('next') or '').strip() return redirect(next_url if next_url.startswith('/') else 'planning') if eo and op.code != 'painting': messages.error(request, f"Текущая операция по маршруту: {op.name}. Нельзя поставить в план покраски.") next_url = (request.POST.get('next') or '').strip() return redirect(next_url if next_url.startswith('/') else 'planning') if not workshop_id and op.workshop_id: workshop_id = int(op.workshop_id) allowed_ws = set(profile.allowed_workshops.values_list('id', flat=True)) if profile else set() if allowed_ws: if not workshop_id: messages.error(request, 'Выбери цех.') next_url = (request.POST.get('next') or '').strip() return redirect(next_url if next_url.startswith('/') else 'planning') if workshop_id not in allowed_ws: messages.error(request, 'Нет доступа к выбранному цеху.') next_url = (request.POST.get('next') or '').strip() return redirect(next_url if next_url.startswith('/') else 'planning') # Комментарий: покраску можно планировать только на то, что реально сварено. welded_done = WorkItem.objects.filter(deal_id=deal_id, entity_id=entity_id).filter(Q(operation__code='welding') | Q(stage='welding')).aggregate(s=Coalesce(Sum('quantity_done'), 0))['s'] painting_planned = WorkItem.objects.filter(deal_id=deal_id, entity_id=entity_id).filter(Q(operation__code='painting') | Q(stage='painting')).aggregate(s=Coalesce(Sum('quantity_plan'), 0))['s'] max_paintable = min(int(di.quantity or 0), int(welded_done or 0)) - int(painting_planned or 0) if qty > max_paintable: messages.error(request, f'Нельзя добавить {qty} шт: доступно {max(0, max_paintable)} шт.') next_url = (request.POST.get('next') or '').strip() return redirect(next_url if next_url.startswith('/') else 'planning') WorkItem.objects.create( deal_id=deal_id, entity_id=entity_id, operation_id=op.id, stage='painting', workshop_id=(workshop_id if workshop_id else None), quantity_plan=qty, status='planned', date=timezone.localdate(), ) messages.success(request, 'Добавлено в план покраски.') next_url = (request.POST.get('next') or '').strip() return redirect(next_url if next_url.startswith('/') else 'planning') class WorkItemUpdateView(LoginRequiredMixin, View): def post(self, request, *args, **kwargs): profile = getattr(request.user, 'profile', None) roles = get_user_roles(request.user) role = primary_role(roles) is_readonly = bool(getattr(profile, 'is_readonly', False)) if profile else False edit_roles = ['admin', 'technologist', 'master', 'operator', 'prod_head'] if not has_any_role(roles, edit_roles): return redirect('planning') if is_readonly: messages.error(request, 'Доступ только для просмотра.') return redirect('planning') def parse_int(s): s = (s or '').strip() return int(s) if s.isdigit() else None wi_id = parse_int(request.POST.get('workitem_id')) action = (request.POST.get('action') or '').strip() next_url = (request.POST.get('next') or '').strip() next_url = next_url if next_url.startswith('/') else str(reverse_lazy('planning')) wi = None if wi_id: wi = WorkItem.objects.filter(pk=wi_id).first() if not wi: messages.error(request, 'Запись плана не найдена.') return redirect(next_url) allowed_ws = set(profile.allowed_workshops.values_list('id', flat=True)) if profile else set() if role == 'operator': user_machines = profile.machines.all() if profile else Machine.objects.none() user_machine_ids = set(user_machines.values_list('id', flat=True)) user_ws_ids = set( Machine.objects.filter(id__in=list(user_machine_ids)) .exclude(workshop_id__isnull=True) .values_list('workshop_id', flat=True) ) if wi.machine_id: if wi.machine_id not in user_machine_ids: messages.error(request, 'Нет доступа к заданию на другом станке.') return redirect(next_url) else: if wi.workshop_id and wi.workshop_id not in user_ws_ids: messages.error(request, 'Нет доступа к заданию из другого цеха.') return redirect(next_url) allowed_ws = user_ws_ids if allowed_ws and wi.workshop_id and wi.workshop_id not in allowed_ws: messages.error(request, 'Нет доступа к записи плана из другого цеха.') return redirect(next_url) if action == 'delete': wi.delete() messages.success(request, 'Запись плана удалена.') return redirect(next_url) qty_plan = parse_int(request.POST.get('quantity_plan')) qty_done = parse_int(request.POST.get('quantity_done')) workshop_id = parse_int(request.POST.get('workshop_id')) machine_id = parse_int(request.POST.get('machine_id')) date_raw = (request.POST.get('date') or '').strip() workitem_status = (request.POST.get('workitem_status') or '').strip() comment = (request.POST.get('comment') or '').strip() changed_fields = [] # Комментарий: правка плана/факта должна оставаться в рамках потребности сделки. # Это защищает от ситуации, когда вручную «перепланировали» больше, чем заказано. deal_item = DealItem.objects.filter(deal_id=wi.deal_id, entity_id=wi.entity_id).first() ordered_qty = int(deal_item.quantity) if deal_item else None if role == 'operator': qty_plan = None workshop_id = None machine_id = None date_raw = '' workitem_status = '' if role == 'master': workitem_status = '' if qty_plan is not None and qty_plan >= 0: if ordered_qty is not None and wi.stage == 'welding': other = WorkItem.objects.filter(deal_id=wi.deal_id, entity_id=wi.entity_id, stage='welding').exclude(pk=wi.id).aggregate(s=Coalesce(Sum('quantity_plan'), 0))['s'] max_for_row = max(0, ordered_qty - int(other or 0)) if qty_plan > max_for_row: messages.error(request, f'Нельзя поставить в план {qty_plan} шт: максимум {max_for_row} шт.') return redirect(next_url) if ordered_qty is not None and wi.stage == 'painting': welded_done = WorkItem.objects.filter(deal_id=wi.deal_id, entity_id=wi.entity_id, stage='welding').aggregate(s=Coalesce(Sum('quantity_done'), 0))['s'] other = WorkItem.objects.filter(deal_id=wi.deal_id, entity_id=wi.entity_id, stage='painting').exclude(pk=wi.id).aggregate(s=Coalesce(Sum('quantity_plan'), 0))['s'] max_paintable = max(0, min(ordered_qty, int(welded_done or 0)) - int(other or 0)) if qty_plan > max_paintable: messages.error(request, f'Нельзя поставить в план {qty_plan} шт: доступно {max_paintable} шт.') return redirect(next_url) wi.quantity_plan = qty_plan changed_fields.append('quantity_plan') if qty_done is not None and qty_done >= 0: # Комментарий: факт не должен превышать план по строке, иначе ломается «доступно к покраске». plan_val = int((qty_plan if qty_plan is not None else wi.quantity_plan) or 0) if plan_val > 0 and qty_done > plan_val: messages.error(request, f'Факт ({qty_done}) не может быть больше плана ({plan_val}).') return redirect(next_url) wi.quantity_done = qty_done changed_fields.append('quantity_done') if machine_id is not None and role in ['admin', 'technologist', 'master']: wi.machine_id = machine_id changed_fields.append('machine') if date_raw and role in ['admin', 'technologist']: try: wi.date = datetime.strptime(date_raw, '%Y-%m-%d').date() changed_fields.append('date') except Exception: pass fixed_workshop_id = None if getattr(wi, 'operation_id', None): fixed_workshop_id = Operation.objects.filter(id=wi.operation_id).values_list('workshop_id', flat=True).first() if fixed_workshop_id: fixed_workshop_id = int(fixed_workshop_id) if wi.workshop_id != fixed_workshop_id: wi.workshop_id = fixed_workshop_id changed_fields.append('workshop') else: if workshop_id is not None: wi.workshop_id = workshop_id changed_fields.append('workshop') elif 'workshop_id' in request.POST and role in ['admin', 'technologist', 'master', 'clerk', 'manager']: wi.workshop_id = None changed_fields.append('workshop') if workitem_status and role in ['admin', 'technologist']: allowed = {k for k, _ in WorkItem.STATUS_CHOICES} if workitem_status in allowed: wi.status = workitem_status changed_fields.append('status') if 'comment' in request.POST and role in ['admin', 'technologist', 'master']: wi.comment = comment changed_fields.append('comment') if not changed_fields and not workitem_status: messages.error(request, 'Нет данных для обновления.') return redirect(next_url) if not (role in ['admin', 'technologist'] and workitem_status in {k for k, _ in WorkItem.STATUS_CHOICES}): plan = int(wi.quantity_plan or 0) done = int(wi.quantity_done or 0) if plan > 0 and done >= plan: wi.status = 'done' changed_fields.append('status') elif done > 0: wi.status = 'planned' changed_fields.append('status') else: wi.status = 'planned' changed_fields.append('status') wi.save(update_fields=list(dict.fromkeys(changed_fields))) # Комментарий: автоматический переход на следующую операцию по маршруту для пары (сделка, сущность). # Сдвигаем только когда выполнено количество по позиции сделки. if ordered_qty is not None: op_code = None if getattr(wi, 'operation_id', None): op_code = Operation.objects.filter(pk=wi.operation_id).values_list('code', flat=True).first() if not op_code: op_code = (wi.stage or '').strip() if op_code: progress, _ = DealEntityProgress.objects.get_or_create(deal_id=wi.deal_id, entity_id=wi.entity_id, defaults={'current_seq': 1}) cur = int(progress.current_seq or 1) cur_eo = EntityOperation.objects.select_related('operation').filter(entity_id=wi.entity_id, seq=cur).first() if cur_eo and cur_eo.operation and cur_eo.operation.code == op_code: total_done = WorkItem.objects.filter(deal_id=wi.deal_id, entity_id=wi.entity_id).filter(Q(operation__code=op_code) | Q(stage=op_code)).aggregate(s=Coalesce(Sum('quantity_done'), 0))['s'] if int(total_done or 0) >= int(ordered_qty): progress.current_seq = cur + 1 progress.save(update_fields=['current_seq']) messages.success(request, 'Обновлено.') return redirect(next_url) class PlanningStagesView(LoginRequiredMixin, TemplateView): template_name = 'shiftflow/planning_stages.html' def dispatch(self, request, *args, **kwargs): roles = get_user_roles(request.user) if not has_any_role(roles, ['admin', 'technologist', 'master', 'clerk', 'manager', 'observer', 'prod_head', 'director']): 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) roles = get_user_roles(self.request.user) role = primary_role(roles) ctx['user_role'] = role ctx['user_roles'] = sorted(roles) ctx['is_readonly'] = bool(getattr(profile, 'is_readonly', False)) if profile else False q = (self.request.GET.get('q') or '').strip() deals_qs = Deal.objects.select_related('company').filter(status='work') if q: deals_qs = deals_qs.filter(Q(number__icontains=q) | Q(company__name__icontains=q)) deals = list(deals_qs.order_by('due_date', '-id')) ctx['q'] = q if not deals: ctx['deal_cards'] = [] return ctx deal_ids = [d.id for d in deals] deal_items = list( DealItem.objects.select_related('deal', 'entity') .filter(deal_id__in=deal_ids, entity__entity_type__in=['product', 'assembly', 'part']) .order_by('deal__due_date', 'deal__number', 'entity__drawing_number', 'entity__name', 'id') ) entity_ids = sorted({int(x.entity_id) for x in deal_items}) entity_ops = list( EntityOperation.objects.select_related('operation') .filter(entity_id__in=entity_ids) .order_by('entity_id', 'seq', 'id') ) route_codes = {} last_code = {} op_meta = {} for eo in entity_ops: if not eo.operation_id or not eo.operation: continue code = (eo.operation.code or '').strip() if not code: continue route_codes.setdefault(int(eo.entity_id), []).append(code) m = op_meta.get(code) if not m: op_meta[code] = { 'code': code, 'name': (eo.operation.name or code), 'min_seq': int(eo.seq or 0) or 0, } else: cur = int(m.get('min_seq') or 0) seq = int(eo.seq or 0) or 0 if cur == 0 or (seq > 0 and seq < cur): m['min_seq'] = seq for eid, codes in route_codes.items(): if codes: last_code[eid] = codes[-1] op_columns = sorted(op_meta.values(), key=lambda x: (int(x.get('min_seq') or 0), str(x.get('name') or ''), str(x.get('code') or ''))) ctx['op_columns'] = op_columns wi_qs = ( WorkItem.objects.select_related('operation') .filter(deal_id__in=deal_ids, entity_id__in=entity_ids) ) done_by = {} done_total_by_entity = {} for wi in wi_qs: op_code = '' if getattr(wi, 'operation_id', None) and getattr(wi, 'operation', None): op_code = (wi.operation.code or '').strip() if not op_code: op_code = (wi.stage or '').strip() if not op_code: continue did = int(wi.deal_id) eid = int(wi.entity_id) k = (did, eid, op_code) done_by[k] = done_by.get(k, 0) + int(wi.quantity_done or 0) done_total_by_entity[(did, eid)] = done_total_by_entity.get((did, eid), 0) + int(wi.quantity_done or 0) ship_loc = ( Location.objects.filter(Q(name__icontains='отгруж') | Q(name__icontains='отгруз')) .order_by('id') .first() ) shipped_by = {} if ship_loc: for r in ( StockItem.objects.filter( is_archived=False, location_id=ship_loc.id, deal_id__in=deal_ids, entity_id__in=entity_ids, ) .values('deal_id', 'entity_id') .annotate(s=Coalesce(Sum('quantity'), 0.0)) ): shipped_by[(int(r['deal_id']), int(r['entity_id']))] = int(r['s'] or 0) def pct(val, total): if int(total or 0) <= 0: return 0 return max(0, min(100, int(round((int(val or 0) * 100) / int(total))))) items_by_deal = {} for di in deal_items: did = int(di.deal_id) eid = int(di.entity_id) need = int(di.quantity or 0) codes = set(route_codes.get(eid) or []) last = last_code.get(eid) op_cells = [] for col in op_columns: code = col.get('code') if code and code in codes: done = int(done_by.get((did, eid, str(code)), 0) or 0) done_val = min(need, done) op_cells.append({ 'code': str(code), 'done': done_val, 'pct': pct(done_val, need), 'has': True, }) else: op_cells.append({'code': str(code or ''), 'done': 0, 'pct': 0, 'has': False}) ready = int(done_by.get((did, eid, last), 0)) if last else int(done_total_by_entity.get((did, eid), 0)) shipped = int(shipped_by.get((did, eid), 0)) ready_val = min(need, int(ready)) shipped_val = min(need, int(shipped)) items_by_deal.setdefault(did, []).append({ 'entity': di.entity, 'need': need, 'op_cells': op_cells, 'ready': ready_val, 'ready_pct': pct(ready_val, need), 'shipped': shipped_val, 'shipped_pct': pct(shipped_val, need), }) deal_cards = [] for d in deals: rows = items_by_deal.get(int(d.id)) or [] if not rows: continue deal_cards.append({ 'deal': d, 'rows': rows, }) ctx['deal_cards'] = deal_cards return ctx class RegistryPrintView(LoginRequiredMixin, TemplateView): template_name = 'shiftflow/registry_print.html' def dispatch(self, request, *args, **kwargs): roles = get_user_group_roles(request.user) if not has_any_role(roles, ['admin', 'operator', 'master', 'technologist', 'clerk', 'prod_head', 'director', 'observer']): return redirect('index') return super().dispatch(request, *args, **kwargs) class WorkItemRegistryPrintView(LoginRequiredMixin, TemplateView): template_name = 'shiftflow/registry_workitems_print.html' def dispatch(self, request, *args, **kwargs): roles = get_user_group_roles(request.user) if not has_any_role(roles, ['admin', 'operator', 'master', 'technologist', 'clerk', 'prod_head', 'director', 'observer']): return redirect('index') return super().dispatch(request, *args, **kwargs) def get_context_data(self, **kwargs): ctx = super().get_context_data(**kwargs) user = self.request.user profile = getattr(user, 'profile', None) roles = get_user_group_roles(user) role = primary_role(roles) ctx['user_role'] = role ctx['user_roles'] = sorted(roles) qs = WorkItem.objects.select_related('deal', 'entity', 'entity__planned_material', 'operation', 'machine', 'workshop') m_ids = [int(i) for i in self.request.GET.getlist('m_ids') if str(i).isdigit()] if m_ids: qs = qs.filter(machine_id__in=m_ids) start_date = (self.request.GET.get('start_date') or '').strip() end_date = (self.request.GET.get('end_date') or '').strip() if start_date: qs = qs.filter(date__gte=start_date) if end_date: qs = qs.filter(date__lte=end_date) statuses = self.request.GET.getlist('statuses') filtered = (self.request.GET.get('filtered') or '').strip() if filtered and not statuses: qs = qs.none() elif not statuses: qs = qs.filter(status__in=['planned']) else: expanded = [] for s in statuses: if s == 'work': expanded += ['planned'] elif s == 'leftover': expanded.append('leftover') elif s == 'closed': expanded.append('done') if expanded: qs = qs.filter(status__in=expanded) if role == 'operator': user_machines = profile.machines.all() if profile else Machine.objects.none() user_machine_ids = list(user_machines.values_list('id', flat=True)) user_ws_ids = list( Machine.objects.filter(id__in=user_machine_ids) .exclude(workshop_id__isnull=True) .values_list('workshop_id', flat=True) ) allowed_ws = list(profile.allowed_workshops.values_list('id', flat=True)) if profile else [] ws_ids = list({int(x) for x in (user_ws_ids + allowed_ws) if x}) qs = qs.filter(Q(machine_id__in=user_machine_ids) | Q(machine_id__isnull=True, workshop_id__in=ws_ids)) rows = list(qs.order_by('workshop__name', 'machine__name', 'date', 'deal__number', 'id')) groups = {} for wi in rows: ws_label = wi.workshop.name if wi.workshop else '—' m_label = wi.machine.name if wi.machine else '' key = (ws_label, m_label) g = groups.get(key) if not g: g = {'workshop': ws_label, 'machine': m_label, 'items': []} groups[key] = g g['items'].append(wi) ctx['groups'] = list(groups.values()) return ctx class WorkItemEntityListView(LoginRequiredMixin, TemplateView): template_name = 'shiftflow/workitem_entity_list.html' def dispatch(self, request, *args, **kwargs): roles = get_user_roles(request.user) if not has_any_role(roles, ['admin', 'technologist', 'master', 'clerk', 'operator', 'observer', 'prod_head', 'director']): 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) roles = get_user_roles(self.request.user) role = primary_role(roles) ctx['user_role'] = role ctx['user_roles'] = sorted(roles) ctx['can_edit_entity'] = 'admin' in roles or 'technologist' in roles deal_id = int(self.kwargs['deal_id']) entity_id = int(self.kwargs['entity_id']) deal = get_object_or_404(Deal, pk=deal_id) entity = get_object_or_404(ProductEntity.objects.select_related('planned_material'), pk=entity_id) qs = WorkItem.objects.select_related('deal', 'entity', 'entity__planned_material', 'operation', 'machine', 'workshop').filter( deal_id=deal_id, entity_id=entity_id, ) rows = list(qs.order_by('-date', '-id')) for wi in rows: plan = int(wi.quantity_plan or 0) done = int(wi.quantity_done or 0) wi.fact_pct = int(round(done * 100 / plan)) if plan > 0 else 0 wi.fact_width = max(0, min(100, wi.fact_pct)) ctx['deal'] = deal ctx['entity'] = entity ctx['workitems'] = rows next_url = (self.request.GET.get('next') or '').strip() ctx['back_url'] = next_url if next_url.startswith('/') else str(reverse_lazy('registry')) return ctx from shiftflow.services.assembly_closing import get_first_operation_id class WorkItemOpClosingView(LoginRequiredMixin, TemplateView): template_name = 'shiftflow/workitem_op_closing.html' def dispatch(self, request, *args, **kwargs): roles = get_user_group_roles(request.user) if not has_any_role(roles, ['admin', 'master', 'operator', 'prod_head']): return redirect('registry') profile = getattr(request.user, 'profile', None) if bool(getattr(profile, 'is_readonly', False)) if profile else False: messages.error(request, 'Доступ только для просмотра.') return redirect('registry') wi = get_object_or_404( WorkItem.objects.select_related('deal', 'entity', 'entity__planned_material', 'operation', 'machine', 'workshop'), pk=int(self.kwargs['pk']), ) first_op_id = get_first_operation_id(int(wi.entity_id)) is_first = True if first_op_id and getattr(wi, 'operation_id', None): is_first = int(wi.operation_id) == int(first_op_id) if is_first: if wi.entity and wi.entity.entity_type in ['product', 'assembly']: return redirect('assembly_closing', pk=wi.id) if wi.entity and wi.entity.entity_type == 'part': if wi.machine_id and getattr(wi.entity, 'planned_material_id', None): return redirect(f"{reverse_lazy('closing')}?machine_id={int(wi.machine_id)}&material_id={int(wi.entity.planned_material_id)}") self.workitem = wi self.is_first_operation = is_first return super().dispatch(request, *args, **kwargs) def get_context_data(self, **kwargs): ctx = super().get_context_data(**kwargs) roles = get_user_group_roles(self.request.user) ctx['user_roles'] = sorted(roles) ctx['user_role'] = primary_role(roles) wi = self.workitem ctx['workitem'] = wi ctx['remaining'] = max(0, int(wi.quantity_plan or 0) - int(wi.quantity_done or 0)) ctx['is_first_operation'] = bool(self.is_first_operation) return ctx def post(self, request, *args, **kwargs): wi = self.workitem qty_raw = (request.POST.get('fact_qty') or '').strip() try: qty = int(qty_raw) except ValueError: qty = 0 if qty <= 0: messages.error(request, 'Количество должно быть больше 0.') return redirect('workitem_op_closing', pk=wi.id) with transaction.atomic(): wi = WorkItem.objects.select_for_update(of=('self',)).select_related('machine', 'machine__workshop', 'workshop').get(pk=int(wi.id)) work_location = get_work_location_for_workitem(wi) if not work_location: messages.error(request, 'Для задания не определён склад участка (цех -> склад цеха / пост -> склад).') return redirect('workitem_op_closing', pk=wi.id) available = ( StockItem.objects.select_for_update(of=('self',)) .filter(is_archived=False, quantity__gt=0) .filter(location_id=work_location.id, entity_id=int(wi.entity_id)) .filter(Q(deal_id=wi.deal_id) | Q(deal_id__isnull=True)) .aggregate(s=Coalesce(Sum('quantity'), 0.0)) )['s'] available_i = int(available or 0) if qty > available_i: messages.error(request, f'Нельзя закрыть операцию: на складе участка «{work_location.name}» доступно {available_i} шт. Сначала перемести изделие на участок.') return redirect('workitem_op_closing', pk=wi.id) plan_total = int(wi.quantity_plan or 0) done_total = int(wi.quantity_done or 0) remaining = max(0, plan_total - done_total) if qty > remaining: messages.error(request, f'Нельзя закрыть {qty} шт: доступно {remaining} шт.') return redirect('workitem_op_closing', pk=wi.id) wi.quantity_done = done_total + qty if wi.quantity_done >= plan_total and plan_total > 0: wi.status = 'done' elif wi.quantity_done > 0: wi.status = 'planned' else: wi.status = 'planned' wi.save(update_fields=['quantity_done', 'status']) deal_item = DealItem.objects.filter(deal_id=wi.deal_id, entity_id=wi.entity_id).first() ordered_qty = int(deal_item.quantity) if deal_item else None if ordered_qty is not None: op_code = None if getattr(wi, 'operation_id', None): op_code = Operation.objects.filter(pk=wi.operation_id).values_list('code', flat=True).first() if not op_code: op_code = (wi.stage or '').strip() if op_code: progress, _ = DealEntityProgress.objects.get_or_create(deal_id=wi.deal_id, entity_id=wi.entity_id, defaults={'current_seq': 1}) cur = int(progress.current_seq or 1) cur_eo = EntityOperation.objects.select_related('operation').filter(entity_id=wi.entity_id, seq=cur).first() if cur_eo and cur_eo.operation and cur_eo.operation.code == op_code: total_done = WorkItem.objects.filter(deal_id=wi.deal_id, entity_id=wi.entity_id).filter(Q(operation__code=op_code) | Q(stage=op_code)).aggregate(s=Coalesce(Sum('quantity_done'), 0))['s'] if int(total_done or 0) >= int(ordered_qty): progress.current_seq = cur + 1 progress.save(update_fields=['current_seq']) messages.success(request, f'Закрыто: {qty} шт.') return redirect('workitem_detail', pk=wi.id) class WorkItemDetailView(LoginRequiredMixin, TemplateView): template_name = 'shiftflow/workitem_detail.html' def dispatch(self, request, *args, **kwargs): roles = get_user_roles(request.user) if not has_any_role(roles, ['admin', 'technologist', 'master', 'clerk', 'operator', 'observer', 'prod_head', 'director']): 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_entity'] = role in ['admin', 'technologist'] wi = get_object_or_404( WorkItem.objects.select_related( 'deal', 'entity', 'entity__planned_material', 'operation', 'machine', 'workshop', ), pk=int(self.kwargs['pk']), ) ctx['workitem'] = wi ctx['remaining'] = max(0, (wi.quantity_plan or 0) - (wi.quantity_done or 0)) first_op_id = get_first_operation_id(int(wi.entity_id)) is_first = True if first_op_id and getattr(wi, 'operation_id', None): is_first = int(wi.operation_id) == int(first_op_id) ctx['is_first_operation'] = is_first close_url = '' close_label = 'Закрыть' if wi.entity and wi.entity.entity_type in ['product', 'assembly']: if is_first: close_url = str(reverse_lazy('assembly_closing', kwargs={'pk': wi.id})) close_label = 'Закрыть сборку' else: close_url = str(reverse_lazy('workitem_op_closing', kwargs={'pk': wi.id})) elif wi.entity and wi.entity.entity_type == 'part': if is_first and wi.machine_id and getattr(wi.entity, 'planned_material_id', None): close_url = f"{reverse_lazy('closing')}?machine_id={int(wi.machine_id)}&material_id={int(wi.entity.planned_material_id)}" else: close_url = str(reverse_lazy('workitem_op_closing', kwargs={'pk': wi.id})) else: close_url = str(reverse_lazy('workitem_op_closing', kwargs={'pk': wi.id})) ctx['close_url'] = close_url ctx['close_label'] = close_label ctx['machines'] = list(Machine.objects.all().order_by('name')) ctx['workitem_status_choices'] = list(WorkItem.STATUS_CHOICES) entity = wi.entity 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['back_url'] = next_url if next_url.startswith('/') else str(reverse_lazy('registry')) return ctx class WorkItemKittingView(LoginRequiredMixin, TemplateView): template_name = 'shiftflow/workitem_kitting.html' def dispatch(self, request, *args, **kwargs): roles = get_user_roles(request.user) if not has_any_role(roles, ['admin', 'technologist', 'master', 'clerk', 'prod_head']): return redirect('registry') pk = self.kwargs.get('pk') wi = None if pk: wi = WorkItem.objects.select_related('entity').filter(pk=int(pk)).first() if wi and (wi.entity.entity_type not in ['product', 'assembly']): messages.error(request, 'Комплектация доступна только для сборочных единиц и изделий.') return redirect('workitem_detail', pk=wi.id) 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 wi = get_object_or_404( WorkItem.objects.select_related('deal', 'entity', 'machine', 'machine__workshop', 'workshop'), pk=int(self.kwargs['pk']), ) ctx['workitem'] = wi to_location = get_work_location_for_workitem(wi) if not to_location: ctx['to_location'] = None ctx['rows'] = [] ctx['draft'] = [] ctx['draft_groups'] = [] ctx['qty_to_make'] = 0 ctx['back_url'] = str(reverse_lazy('workitem_detail', kwargs={'pk': wi.id})) messages.error(self.request, 'Для задания не определён склад участка (нет цеха/склада у поста).') return ctx ctx['to_location'] = to_location qty_to_make = max(0, int(wi.quantity_plan or 0) - int(wi.quantity_done or 0)) qty_param = (self.request.GET.get('qty') or '').strip() if qty_param.isdigit(): qty_to_make = max(0, int(qty_param)) ctx['qty_to_make'] = qty_to_make req = build_kitting_requirements(int(wi.entity_id), int(qty_to_make)) component_ids = list(req.keys()) draft = get_kitting_draft(self.request.session, int(wi.id)) ctx['draft'] = draft to_move_by_entity = {} to_move_by_source = {} for ln in draft: eid = int(ln.get('entity_id') or 0) lid = int(ln.get('from_location_id') or 0) qty_ln = int(ln.get('quantity') or 0) if eid <= 0 or lid <= 0 or qty_ln <= 0: continue to_move_by_entity[eid] = int(to_move_by_entity.get(eid, 0) or 0) + qty_ln to_move_by_source[(eid, lid)] = int(to_move_by_source.get((eid, lid), 0) or 0) + qty_ln entities = { int(e.id): e for e in ProductEntity.objects.filter(id__in=component_ids).order_by('entity_type', 'drawing_number', 'name', 'id') } avail_qs = ( StockItem.objects.select_related('location') .filter(is_archived=False) .filter(quantity__gt=0) .filter(entity_id__in=component_ids) .filter(Q(deal_id=wi.deal_id) | Q(deal_id__isnull=True)) .values('entity_id', 'location_id') .annotate(q=Coalesce(Sum('quantity'), 0.0)) ) loc_ids = set() by_entity_loc = {} for r in avail_qs: eid = int(r['entity_id']) lid = int(r['location_id']) q = float(r['q'] or 0) by_entity_loc[(eid, lid)] = q loc_ids.add(lid) locations = {int(l.id): l for l in Location.objects.filter(id__in=list(loc_ids)).order_by('name')} rows = [] for eid, need in req.items(): ent = entities.get(int(eid)) if not ent: continue need_i = int(need or 0) to_have = int(by_entity_loc.get((int(eid), int(to_location.id)), 0) or 0) to_move = int(to_move_by_entity.get(int(eid), 0) or 0) missing = max(0, need_i - to_have - to_move) sources = [] for lid in sorted(loc_ids, key=lambda x: (0 if x == int(to_location.id) else 1, str(getattr(locations.get(x), 'name', '')))): if lid == int(to_location.id): continue q = float(by_entity_loc.get((int(eid), int(lid)), 0) or 0) if q <= 0: continue loc = locations.get(int(lid)) if not loc: continue sources.append({'location': loc, 'available': int(q), 'selected': int(to_move_by_source.get((int(eid), int(lid)), 0) or 0)}) rows.append({ 'entity': ent, 'need': need_i, 'have_to': to_have, 'to_move': to_move, 'missing': missing, 'sources': sources, }) ctx['rows'] = rows next_url = (self.request.GET.get('next') or '').strip() ctx['back_url'] = next_url if next_url.startswith('/') else str(reverse_lazy('workitem_detail', kwargs={'pk': wi.id})) return ctx def post(self, request, *args, **kwargs): roles = get_user_roles(request.user) if not has_any_role(roles, ['admin', 'technologist', 'master', 'clerk', 'prod_head']): return redirect('registry') wi = get_object_or_404( WorkItem.objects.select_related('deal', 'entity', 'machine', 'machine__workshop', 'workshop'), pk=int(self.kwargs['pk']), ) to_location = get_work_location_for_workitem(wi) if not to_location: messages.error(request, 'Для задания не определён склад участка.') return redirect('workitem_detail', pk=wi.id) action = (request.POST.get('action') or '').strip() next_url = (request.POST.get('next') or '').strip() if not next_url.startswith('/'): next_url = str(reverse_lazy('workitem_kitting', kwargs={'pk': wi.id})) def parse_int(s): s = (s or '').strip() return int(s) if s.isdigit() else None if action == 'clear': clear_kitting_draft(request.session, int(wi.id)) messages.success(request, 'Лист комплектации очищен.') return redirect(next_url) if action in ['add_line', 'remove_line']: entity_id = parse_int(request.POST.get('entity_id')) from_location_id = parse_int(request.POST.get('from_location_id')) qty = parse_int(request.POST.get('quantity')) if not (entity_id and from_location_id and qty and qty > 0): messages.error(request, 'Заполни корректно: компонент, склад-источник и количество.') return redirect(next_url) if int(from_location_id) == int(to_location.id): messages.error(request, 'Склад-источник должен отличаться от склада участка.') return redirect(next_url) if action == 'add_line': add_kitting_line(request.session, int(wi.id), int(entity_id), int(from_location_id), int(qty)) messages.success(request, 'Добавлено в перемещение.') else: remove_kitting_line(request.session, int(wi.id), int(entity_id), int(from_location_id), int(qty)) messages.success(request, 'Откат выполнен.') return redirect(next_url) messages.error(request, 'Неизвестное действие.') return redirect(next_url) class WorkItemKittingPrintView(LoginRequiredMixin, TemplateView): template_name = 'shiftflow/workitem_kitting_print.html' def dispatch(self, request, *args, **kwargs): roles = get_user_roles(request.user) if not has_any_role(roles, ['admin', 'technologist', 'master', 'clerk', 'prod_head']): return redirect('registry') pk = self.kwargs.get('pk') wi = None if pk: wi = WorkItem.objects.select_related('entity').filter(pk=int(pk)).first() if wi and (wi.entity.entity_type not in ['product', 'assembly']): messages.error(request, 'Комплектация доступна только для сборочных единиц и изделий.') return redirect('workitem_detail', pk=wi.id) return super().dispatch(request, *args, **kwargs) def get_context_data(self, **kwargs): ctx = super().get_context_data(**kwargs) wi = get_object_or_404( WorkItem.objects.select_related('deal', 'entity', 'machine', 'machine__workshop', 'workshop'), pk=int(self.kwargs['pk']), ) ctx['workitem'] = wi ctx['printed_at'] = timezone.now() to_location = get_work_location_for_workitem(wi) ctx['to_location'] = to_location draft = get_kitting_draft(self.request.session, int(wi.id)) ctx['draft'] = draft if not draft: ctx['groups'] = [] return ctx entity_ids = sorted({int(x.get('entity_id') or 0) for x in draft if int(x.get('entity_id') or 0) > 0}) loc_ids = sorted({int(x.get('from_location_id') or 0) for x in draft if int(x.get('from_location_id') or 0) > 0}) entities = {int(e.id): e for e in ProductEntity.objects.filter(id__in=entity_ids)} locations = {int(l.id): l for l in Location.objects.filter(id__in=loc_ids)} grouped = {} for ln in draft: lid = int(ln.get('from_location_id') or 0) eid = int(ln.get('entity_id') or 0) qty = int(ln.get('quantity') or 0) if lid <= 0 or eid <= 0 or qty <= 0: continue grouped.setdefault(lid, {}) grouped[lid][eid] = int(grouped[lid].get(eid, 0)) + qty groups = [] for lid, items in grouped.items(): loc = locations.get(int(lid)) if not loc: continue lst = [] for eid, qty in items.items(): ent = entities.get(int(eid)) if not ent: continue lst.append({'entity': ent, 'quantity': int(qty)}) lst.sort(key=lambda x: ((x['entity'].entity_type or ''), (x['entity'].drawing_number or ''), (x['entity'].name or ''), int(x['entity'].id))) groups.append({'from_location': loc, 'items': lst}) groups.sort(key=lambda g: (str(g['from_location'].name or ''), int(g['from_location'].id))) ctx['groups'] = groups return ctx def get(self, request, *args, **kwargs): # GET — только предпросмотр листа перемещения (без выполнения перемещений) ctx = self.get_context_data(**kwargs) ctx['auto_print'] = False return self.render_to_response(ctx) def post(self, request, *args, **kwargs): # POST — выполняем перемещения и затем рендерим лист (с автопечатью при необходимости) wi = get_object_or_404( WorkItem.objects.select_related('deal', 'entity', 'machine', 'machine__workshop', 'workshop'), pk=int(self.kwargs['pk']), ) to_location = get_work_location_for_workitem(wi) if not to_location: messages.error(request, 'Для задания не определён склад участка.') return redirect('workitem_detail', pk=wi.id) action = (request.POST.get('action') or '').strip() if action not in ['apply', 'apply_print']: messages.error(request, 'Неизвестное действие.') return redirect('workitem_kitting_print', pk=wi.id) ctx = self.get_context_data(**kwargs) if not ctx.get('groups'): messages.error(request, 'Лист перемещения пуст.') return redirect('workitem_kitting', pk=wi.id) stats = apply_kitting_draft( session=request.session, workitem_id=int(wi.id), deal_id=int(wi.deal_id), to_location_id=int(to_location.id), user_id=int(request.user.id), ) if int(stats.get('errors', 0) or 0) > 0: messages.error(request, f"Ошибка перемещения. Ошибок: {stats.get('errors', 0)}") return redirect('workitem_kitting', pk=wi.id) ctx['auto_print'] = (action == 'apply_print') return self.render_to_response(ctx) class ProductEntityPreviewUpdateView(LoginRequiredMixin, View): def post(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 redirect('registry') next_url = (request.POST.get('next') or '').strip() next_url = next_url if next_url.startswith('/') else str(reverse_lazy('registry')) entity = get_object_or_404(ProductEntity, pk=int(pk)) try: ok = _update_entity_preview(entity) if ok: messages.success(request, 'Превью обновлено.') else: messages.error(request, 'Превью не создано (нет DXF).') except Exception as e: logger.exception('entity_preview_update: failed entity_id=%s', entity.id) messages.error(request, f'Ошибка генерации превью: {type(e).__name__}: {e}') return redirect(next_url) 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 or timezone.localdate() 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): roles = get_user_roles(request.user) if not has_any_role(roles, ['admin', 'technologist', 'master', 'clerk', 'prod_head', 'director', 'observer']): 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) roles = get_user_roles(self.request.user) role = primary_role(roles) context['user_role'] = role context['user_roles'] = sorted(roles) allowed_ws = list(profile.allowed_workshops.values_list('id', flat=True)) if profile else [] context['allowed_workshop_ids'] = allowed_ws context['is_readonly'] = bool(getattr(profile, 'is_readonly', False)) if profile else False allowed_ws = list(profile.allowed_workshops.values_list('id', flat=True)) if profile else [] context['allowed_workshop_ids'] = allowed_ws context['is_readonly'] = bool(getattr(profile, 'is_readonly', False)) if profile else False 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): roles = get_user_roles(request.user) if not has_any_role(roles, ['admin', 'technologist', 'master', 'clerk', 'prod_head', 'director', 'observer']): 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) roles = get_user_roles(self.request.user) role = primary_role(roles) context['user_role'] = role context['user_roles'] = sorted(roles) allowed_ws = list(profile.allowed_workshops.values_list('id', flat=True)) if profile else [] context['allowed_workshop_ids'] = allowed_ws context['is_readonly'] = bool(getattr(profile, 'is_readonly', False)) if profile else False deal = get_object_or_404(Deal.objects.select_related('company'), pk=self.kwargs['pk']) context['deal'] = deal di = list( DealItem.objects.select_related('entity', 'entity__assembly_passport') .filter(deal=deal) .order_by('entity__entity_type', 'entity__drawing_number', 'entity__name') ) _reconcile_default_delivery_batch(int(deal.id)) allocated_non_default = { int(r['entity_id']): int(r['s'] or 0) for r in DealBatchItem.objects.filter(batch__deal=deal, batch__is_default=False, entity_id__in=[x.entity_id for x in di]) .values('entity_id') .annotate(s=Coalesce(Sum('quantity'), 0)) } started_map = { int(r['entity_id']): int(r['started'] or 0) for r in DealBatchItem.objects.filter(batch__deal=deal, entity_id__in=[x.entity_id for x in di]) .values('entity_id') .annotate(started=Coalesce(Sum('started_qty'), 0)) } for it in di: need = int(it.quantity or 0) started = int(started_map.get(int(it.entity_id), 0) or 0) if started > need: started = need allocated = int(allocated_non_default.get(int(it.entity_id), 0) or 0) if allocated > need: allocated = need it.remaining_to_allocate = max(0, need - allocated) it.done_qty = 0 it.planned_qty = started it.remaining_qty = max(0, need - started) if need > 0: done_width = 0 plan_pct = int(round(started * 100 / need)) else: done_width = 0 plan_pct = 0 it.done_width = done_width it.plan_width = max(0, min(100, plan_pct)) batches = list(DealDeliveryBatch.objects.filter(deal=deal).order_by('is_default', 'due_date', 'id')) batch_items = list( DealBatchItem.objects.select_related('batch', 'entity') .filter(batch__deal=deal) .order_by('batch__due_date', 'batch_id', 'entity__entity_type', 'entity__drawing_number', 'entity__name', 'id') ) by_batch = {} for bi in batch_items: started = int(getattr(bi, 'started_qty', 0) or 0) qty = int(getattr(bi, 'quantity', 0) or 0) if started < 0: started = 0 if qty < 0: qty = 0 if started > qty: started = qty bi.started_qty = started bi.remaining_to_start = max(0, qty - started) bi.started_pct = int(round(started * 100 / qty)) if qty > 0 else 0 by_batch.setdefault(int(bi.batch_id), []).append(bi) for b in batches: items = by_batch.get(int(b.id), []) b.items_list = items b.total_qty = sum(int(getattr(x, 'quantity', 0) or 0) for x in items) b.total_started = sum(int(getattr(x, 'started_qty', 0) or 0) for x in items) b.total_remaining = max(0, int(b.total_qty or 0) - int(b.total_started or 0)) b.started_pct = int(round(b.total_started * 100 / b.total_qty)) if b.total_qty > 0 else 0 context['delivery_batches'] = batches context['deal_items'] = di tasks = list( ProductionTask.objects.filter(deal=deal) .select_related('material', 'entity') .order_by('-id') ) task_entity_ids = {int(x.entity_id) for x in tasks if getattr(x, 'entity_id', None)} progress_task_map = { int(p.entity_id): int(p.current_seq or 1) for p in DealEntityProgress.objects.filter(deal=deal, entity_id__in=list(task_entity_ids)) } ops_task_map = {} for eo in ( EntityOperation.objects.select_related('operation', 'operation__workshop') .filter(entity_id__in=list(task_entity_ids)) .order_by('entity_id', 'seq', 'id') ): ops_task_map[(int(eo.entity_id), int(eo.seq))] = eo for t in tasks: t.current_operation_id = None t.current_operation_name = '' t.current_workshop_id = None t.current_workshop_name = '' if not getattr(t, 'entity_id', None): continue seq = int(progress_task_map.get(int(t.entity_id), 1) or 1) eo = ops_task_map.get((int(t.entity_id), seq)) if not eo: continue t.current_operation_id = int(eo.operation_id) t.current_operation_name = eo.operation.name if eo.operation else '' t.current_workshop_id = int(eo.operation.workshop_id) if eo.operation and eo.operation.workshop_id else None t.current_workshop_name = eo.operation.workshop.name if eo.operation and eo.operation.workshop else '' wi_qs = WorkItem.objects.filter(deal=deal, entity_id__in=list(task_entity_ids)).filter(operation_id__isnull=False) if allowed_ws: wi_qs = wi_qs.filter(workshop_id__in=allowed_ws) wi_sums = { (int(r['entity_id']), int(r['operation_id'])): (int(r['planned'] or 0), int(r['done'] or 0)) for r in wi_qs.values('entity_id', 'operation_id').annotate( planned=Coalesce(Sum('quantity_plan'), 0), done=Coalesce(Sum('quantity_done'), 0), ) } workshop_groups = {} for t in tasks: if allowed_ws and t.current_workshop_id and int(t.current_workshop_id) not in allowed_ws: continue need = int(t.quantity_ordered or 0) key = None if getattr(t, 'entity_id', None) and getattr(t, 'current_operation_id', None): key = (int(t.entity_id), int(t.current_operation_id)) planned_qty, done_qty = wi_sums.get(key, (0, 0)) if key else (0, 0) planned_qty = int(planned_qty or 0) done_qty = int(done_qty or 0) remaining_qty = need - done_qty - planned_qty if remaining_qty < 0: remaining_qty = 0 t.planned_qty = planned_qty t.done_qty = done_qty t.remaining_qty = remaining_qty 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 ws_id = int(t.current_workshop_id) if t.current_workshop_id else 0 ws_name = (t.current_workshop_name or '').strip() or 'Без техпроцесса' grp = workshop_groups.get(ws_id) if not grp: grp = {'id': ws_id, 'name': ws_name, 'tasks': []} workshop_groups[ws_id] = grp grp['tasks'].append(t) context['workshop_task_groups'] = sorted( workshop_groups.values(), key=lambda g: (1 if int(g['id'] or 0) == 0 else 0, str(g['name'])), ) context['machines'] = Machine.objects.all() return context def post(self, request, *args, **kwargs): roles = get_user_group_roles(request.user) if not has_any_role(roles, ['admin', 'prod_head', 'technologist', 'master']): return redirect('planning_deal', pk=self.kwargs['pk']) action = (request.POST.get('action') or '').strip() deal_id = int(self.kwargs['pk']) if action == 'set_work': Deal.objects.filter(id=deal_id, status='lead').update(status='work') messages.success(request, 'Сделка переведена в статус «В работе».') return redirect('planning_deal', pk=deal_id) if action == 'explode_deal': deal = get_object_or_404(Deal, pk=deal_id) try: stats = explode_deal(deal_id, create_tasks=False, create_procurement=True) messages.success( request, f'BOM пересчитан для снабжения (Сделка {deal.number}). ' f'Потребностей создано/обновлено: ({stats.req_created}/{stats.req_updated}).' ) except Exception as e: logger.exception('explode_deal:error deal_id=%s', deal_id) messages.error(request, f'Ошибка вскрытия BOM: {e}') return redirect('planning_deal', pk=deal_id) 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 _server_log_path(self): p = (django_settings.BASE_DIR / 'logs' / 'mes.log') return p def _read_tail(self, path, max_bytes: int = 20000) -> str: try: if not path.exists(): return '' size = path.stat().st_size start = max(0, size - max_bytes) with path.open('rb') as f: f.seek(start) data = f.read() try: return data.decode('utf-8', errors='replace') except Exception: return str(data) except Exception: return '' 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' log_path = self._server_log_path() context['log_path'] = str(log_path) context['log_tail'] = self._read_tail(log_path) # Подтягиваем текущие настройки генерации превью, чтобы отрисовать форму. 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 == 'refresh_log': return redirect('maintenance') if action == 'clear_log': try: self._server_log_path().open('wb').close() messages.success(request, 'Лог очищен.') except Exception: messages.error(request, 'Не удалось очистить лог.') return redirect('maintenance') if action == 'clear_dxf_job_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, 'Лог DXF-генерации очищен.') except Exception: messages.error(request, 'Не удалось очистить лог DXF-генерации.') 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): roles = get_user_roles(request.user) if not has_any_role(roles, ['admin', 'technologist', 'master', 'clerk', 'prod_head', 'director', 'observer']): return redirect('registry') return super().dispatch(request, *args, **kwargs) def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) roles = get_user_roles(self.request.user) role = primary_role(roles) context['user_role'] = role context['user_roles'] = sorted(roles) 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): roles = get_user_roles(request.user) if not has_any_role(roles, ['admin', 'technologist', 'master', 'clerk', 'prod_head', 'director', 'observer']): 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) roles = get_user_roles(self.request.user) role = primary_role(roles) context['user_role'] = role context['user_roles'] = sorted(roles) allowed_ws = list(profile.allowed_workshops.values_list('id', flat=True)) if profile else [] context['allowed_workshop_ids'] = allowed_ws context['is_readonly'] = bool(getattr(profile, 'is_readonly', False)) if profile else False 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 WorkItemPlanAddView(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 redirect('planning') def parse_int(s): s = (s or '').strip() return int(s) if s.isdigit() else None deal_id = parse_int(request.POST.get('deal_id')) entity_id = parse_int(request.POST.get('entity_id')) operation_id = parse_int(request.POST.get('operation_id')) machine_id = parse_int(request.POST.get('machine_id')) workshop_id = parse_int(request.POST.get('workshop_id')) qty = parse_int(request.POST.get('quantity_plan')) recursive_bom = request.POST.get('recursive_bom') == 'on' next_url = (request.POST.get('next') or '').strip() if not next_url.startswith('/'): next_url = str(reverse_lazy('planning')) if not (deal_id and entity_id and operation_id and qty and qty > 0): messages.error(request, 'Заполни операцию и количество.') return redirect(next_url) op = Operation.objects.select_related('workshop').filter(id=operation_id).first() if not op: messages.error(request, 'Операция не найдена.') return redirect(next_url) machine = None if machine_id: machine = Machine.objects.select_related('workshop').filter(id=machine_id).first() workshop = None if workshop_id: workshop = Workshop.objects.filter(id=workshop_id).first() if not workshop: messages.error(request, 'Цех не найден.') return redirect(next_url) if not machine and not (workshop_id or getattr(op, 'workshop_id', None)): messages.error(request, 'Выбери станок или цех.') return redirect(next_url) resolved_workshop_id = ( machine.workshop_id if machine and machine.workshop_id else (workshop.id if workshop else getattr(op, 'workshop_id', None)) ) # Комментарий: Если включен чекбокс recursive_bom, бежим по дереву BOM вниз. # Для дочерних компонентов создаём WorkItem только на текущую операцию # (по DealEntityProgress.current_seq), чтобы операции возникали по очереди. # Для родителя создаём WorkItem по выбранной операции из модалки. if recursive_bom: try: with transaction.atomic(): adjacency = _build_bom_graph({entity_id}) required_nodes = {} _accumulate_requirements(entity_id, qty, adjacency, set(), required_nodes) node_ids = list(required_nodes.keys()) progress_map = { int(p.entity_id): int(p.current_seq or 1) for p in DealEntityProgress.objects.filter(deal_id=int(deal_id), entity_id__in=node_ids) } ops_map = { (int(eo.entity_id), int(eo.seq)): eo for eo in EntityOperation.objects.select_related('operation', 'operation__workshop') .filter(entity_id__in=node_ids) } created_count = 0 for c_id, c_qty in required_nodes.items(): if int(c_id) == int(entity_id): WorkItem.objects.create( deal_id=deal_id, entity_id=entity_id, operation_id=operation_id, workshop_id=resolved_workshop_id, machine_id=(machine.id if machine else None), stage=(op.name or '')[:32], quantity_plan=qty, quantity_done=0, status='planned', date=timezone.localdate(), ) created_count += 1 continue seq = int(progress_map.get(int(c_id), 1) or 1) eo = ops_map.get((int(c_id), seq)) if not eo or not getattr(eo, 'operation_id', None) or not getattr(eo, 'operation', None): continue cur_op = eo.operation WorkItem.objects.create( deal_id=deal_id, entity_id=int(c_id), operation_id=int(cur_op.id), workshop_id=(int(cur_op.workshop_id) if getattr(cur_op, 'workshop_id', None) else None), machine_id=None, stage=(cur_op.name or '')[:32], quantity_plan=int(c_qty), quantity_done=0, status='planned', date=timezone.localdate(), ) created_count += 1 messages.success(request, f'Рекурсивно добавлено в смену заданий: {created_count} шт.') except Exception as e: logger.exception('workitem_add recursive error') messages.error(request, f'Ошибка при рекурсивном добавлении: {e}') else: wi = WorkItem.objects.create( deal_id=int(deal_id), entity_id=int(entity_id), operation_id=int(operation_id), workshop_id=resolved_workshop_id, machine_id=(machine.id if machine else None), stage=(op.name or '')[:32], quantity_plan=int(qty), quantity_done=0, status='planned', date=timezone.localdate(), ) logger.info('workitem_add: id=%s deal_id=%s entity_id=%s operation_id=%s machine_id=%s qty=%s', wi.id, deal_id, entity_id, operation_id, machine_id, qty) messages.success(request, 'Добавлено в смену.') return redirect(next_url) 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', 'master', 'clerk']: 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 '', 'due_date': deal.due_date.isoformat() if getattr(deal, 'due_date', None) else '', }) 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', 'master', 'clerk']: 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() due_date = (request.POST.get('due_date') or '').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 due_date: try: deal.due_date = datetime.strptime(due_date, '%Y-%m-%d').date() except Exception: deal.due_date = None else: deal.due_date = None 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', 'master', 'clerk']: 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, 'mass_per_unit': material.mass_per_unit, }) 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', 'master', 'clerk']: return JsonResponse({'error': 'forbidden'}, status=403) def parse_float(value): s = (value or '').strip().replace(',', '.') if not s: return None try: return float(s) except ValueError: return None 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() mass_per_unit = parse_float(request.POST.get('mass_per_unit')) 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 material.mass_per_unit = mass_per_unit 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): roles = get_user_roles(request.user) if not has_any_role(roles, ['admin', 'clerk', 'manager', '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() form_factor = (request.POST.get('form_factor') or '').strip() or 'other' 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 if form_factor in ['sheet', 'bar', 'other']: category.form_factor = form_factor category.save() return JsonResponse({'id': category.id, 'label': category.name}) class SteelGradeUpsertView(LoginRequiredMixin, View): def post(self, request, *args, **kwargs): roles = get_user_roles(request.user) if not has_any_role(roles, ['admin', 'technologist', 'manager', 'prod_head']): 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 EntitiesSearchView(LoginRequiredMixin, View): """JSON-поиск сущностей ProductEntity для модальных окон. Использование на фронтенде (пример): /entities/search/?entity_type=part&q_dn=12.34&q_name=косынка Возвращает: { "results": [{"id": 1, "type": "part", "drawing_number": "...", "name": "..."}, ...], "count": 10 } Диагностика: - логируем start/forbidden/done (без секретов) для разбора кейсов «ничего не находит». """ def get(self, request, *args, **kwargs): roles = get_user_roles(request.user) # Роли пользователя (Django Groups + fallback на profile.role) if not has_any_role(roles, ['admin', 'technologist', 'manager', 'prod_head']): # Доступ только для этих ролей logger.info('entities_search:forbidden user_id=%s roles=%s', request.user.id, sorted(roles)) return JsonResponse({'error': 'forbidden'}, status=403) # Для фронта это сигнал показать «нет доступа» q_dn = (request.GET.get('q_dn') or '').strip() # Поиск по обозначению (drawing_number), подстрока q_name = (request.GET.get('q_name') or '').strip() # Поиск по наименованию (name), подстрока et = (request.GET.get('entity_type') or '').strip() # Фильтр по типу сущности (ProductEntity.entity_type) logger.info('entities_search:start user_id=%s et=%s q_dn=%s q_name=%s', request.user.id, et, q_dn, q_name) qs = ProductEntity.objects.all() # Базовая выборка по всем сущностям КД # Фильтр по типу включаем для всех допустимых типов ProductEntity. allowed_types = {'product', 'assembly', 'part', 'purchased', 'casting', 'outsourced'} if et in allowed_types: qs = qs.filter(entity_type=et) if q_dn: qs = qs.filter(drawing_number__icontains=q_dn) # ILIKE по drawing_number if q_name: qs = qs.filter(name__icontains=q_name) # ILIKE по name qs = qs.order_by('entity_type', 'drawing_number', 'name', 'id') data = [ # Формируем компактный JSON (только то, что нужно для селекта в модалке) { 'id': e.id, # PK сущности 'type': e.entity_type, # Код типа 'drawing_number': e.drawing_number, # Обозначение 'name': e.name, # Наименование } for e in qs[:200] # Ограничиваем ответ 200 строками ] logger.info('entities_search:done user_id=%s count=%s', request.user.id, len(data)) return JsonResponse({'results': data, 'count': len(data)}) # Отдаём результаты в JSON class DealBatchActionView(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') action = (request.POST.get('action') or '').strip() next_url = (request.POST.get('next') or '').strip() if not next_url.startswith('/'): next_url = str(reverse_lazy('planning')) def parse_int(s): s = (s or '').strip() return int(s) if s.isdigit() else None deal_id = parse_int(request.POST.get('deal_id')) if action == 'create_batch': due_date = (request.POST.get('due_date') or '').strip() name = (request.POST.get('name') or '').strip()[:120] if not deal_id or not due_date: messages.error(request, 'Заполни дату отгрузки.') return redirect(next_url) try: dd = datetime.strptime(due_date, '%Y-%m-%d').date() except Exception: messages.error(request, 'Некорректная дата.') return redirect(next_url) DealDeliveryBatch.objects.create(deal_id=deal_id, due_date=dd, name=name, is_default=False) _reconcile_default_delivery_batch(int(deal_id)) messages.success(request, 'Партия добавлена.') return redirect(next_url) if action == 'delete_batch': batch_id = parse_int(request.POST.get('batch_id')) if not batch_id: return redirect(next_url) b = DealDeliveryBatch.objects.filter(id=batch_id, deal_id=deal_id).first() if b and getattr(b, 'is_default', False): messages.error(request, 'Дефолтная партия рассчитывается автоматически и не удаляется.') return redirect(next_url) DealDeliveryBatch.objects.filter(id=batch_id, deal_id=deal_id).delete() _reconcile_default_delivery_batch(int(deal_id)) messages.success(request, 'Партия удалена.') return redirect(next_url) if action == 'add_batch_item': batch_id = parse_int(request.POST.get('batch_id')) entity_id = parse_int(request.POST.get('entity_id')) qty = parse_int(request.POST.get('quantity')) if not (deal_id and batch_id and entity_id and qty and qty > 0): messages.error(request, 'Заполни позицию и количество.') return redirect(next_url) batch = DealDeliveryBatch.objects.filter(id=batch_id, deal_id=deal_id).first() if not batch: messages.error(request, 'Партия не найдена.') return redirect(next_url) if getattr(batch, 'is_default', False): messages.error(request, 'Дефолтная партия заполняется автоматически. Создай партию с датой и распределяй туда.') return redirect(next_url) deal_item = DealItem.objects.filter(deal_id=deal_id, entity_id=entity_id).first() if not deal_item: messages.error(request, 'Добавлять в партию можно только позиции из сделки.') return redirect(next_url) existing = DealBatchItem.objects.filter(batch_id=batch_id, entity_id=entity_id).first() allocated_other = ( DealBatchItem.objects.filter(batch__deal_id=deal_id, batch__is_default=False, entity_id=entity_id) .exclude(id=existing.id if existing else None) .aggregate(s=Coalesce(Sum('quantity'), 0))['s'] ) total = int(deal_item.quantity or 0) if qty + int(allocated_other or 0) > total: messages.error(request, 'Нельзя распределить больше, чем заказано по позиции сделки.') return redirect(next_url) if existing: if existing.quantity != qty: existing.quantity = qty existing.save(update_fields=['quantity']) else: DealBatchItem.objects.create(batch_id=batch_id, entity_id=entity_id, quantity=qty) _reconcile_default_delivery_batch(int(deal_id)) messages.success(request, 'Позиция партии сохранена.') return redirect(next_url) if action == 'update_batch_item_qty': item_id = parse_int(request.POST.get('item_id')) qty = parse_int(request.POST.get('quantity')) if not item_id or not qty or qty <= 0: messages.error(request, 'Заполни количество.') return redirect(next_url) bi = DealBatchItem.objects.select_related('batch').filter(id=item_id, batch__deal_id=deal_id).first() if not bi: return redirect(next_url) if getattr(getattr(bi, 'batch', None), 'is_default', False): messages.error(request, 'Количество дефолтной партии рассчитывается автоматически.') return redirect(next_url) deal_item = DealItem.objects.filter(deal_id=deal_id, entity_id=bi.entity_id).first() if not deal_item: messages.error(request, 'Позиция не найдена в списке позиций сделки.') return redirect(next_url) allocated_other = ( DealBatchItem.objects.filter(batch__deal_id=deal_id, batch__is_default=False, entity_id=bi.entity_id) .exclude(id=bi.id) .aggregate(s=Coalesce(Sum('quantity'), 0))['s'] ) total = int(deal_item.quantity or 0) if qty + int(allocated_other or 0) > total: messages.error(request, 'Нельзя распределить больше, чем заказано по позиции сделки.') return redirect(next_url) if bi.quantity != qty: bi.quantity = qty bi.save(update_fields=['quantity']) _reconcile_default_delivery_batch(int(deal_id)) messages.success(request, 'Количество обновлено.') return redirect(next_url) if action == 'delete_batch_item': item_id = parse_int(request.POST.get('item_id')) if not item_id: return redirect(next_url) bi = DealBatchItem.objects.select_related('batch').filter(id=item_id, batch__deal_id=deal_id).first() if bi and getattr(getattr(bi, 'batch', None), 'is_default', False): messages.error(request, 'Дефолтная партия рассчитывается автоматически.') return redirect(next_url) DealBatchItem.objects.filter(id=item_id, batch__deal_id=deal_id).delete() _reconcile_default_delivery_batch(int(deal_id)) messages.success(request, 'Строка удалена.') return redirect(next_url) if action == 'rollback_batch_item_production': item_id = parse_int(request.POST.get('item_id')) qty = parse_int(request.POST.get('quantity')) if not item_id or not qty or qty <= 0: messages.error(request, 'Заполни количество.') return redirect(next_url) logger.info('rollback_batch_item_production:start deal_id=%s item_id=%s qty=%s', deal_id, item_id, qty) try: with transaction.atomic(): bi = ( DealBatchItem.objects.select_for_update() .select_related('batch', 'entity') .filter(id=item_id, batch__deal_id=deal_id) .first() ) if not bi: messages.error(request, 'Строка партии не найдена.') return redirect(next_url) started = int(getattr(bi, 'started_qty', 0) or 0) if int(qty) > started: messages.error(request, 'Нельзя откатить больше, чем запущено в этой партии.') return redirect(next_url) # Комментарий: откат запрещён, если хоть что-то из этого запуска уже попало в смену. adjacency = _build_bom_graph({int(bi.entity_id)}) required_nodes: dict[int, int] = {} _accumulate_requirements(int(bi.entity_id), int(qty), adjacency, set(), required_nodes) affected_ids = list(required_nodes.keys()) wi_exists = WorkItem.objects.filter(deal_id=deal_id, entity_id__in=affected_ids).filter( Q(quantity_plan__gt=0) | Q(quantity_done__gt=0) ).exists() if wi_exists: messages.error(request, 'Нельзя откатить: по этой позиции уже есть постановка в смену (план/факт).') return redirect(next_url) stats = rollback_roots_additive(int(deal_id), [(int(bi.entity_id), int(qty))]) bi.started_qty = started - int(qty) bi.save(update_fields=['started_qty']) messages.success(request, f'Откат выполнен: −{qty} шт. Задачи обновлено: {stats.tasks_updated}.') logger.info( 'rollback_batch_item_production:done deal_id=%s item_id=%s entity_id=%s qty=%s tasks_updated=%s', deal_id, item_id, bi.entity_id, qty, stats.tasks_updated, ) return redirect(next_url) except Exception: logger.exception('rollback_batch_item_production:error deal_id=%s item_id=%s qty=%s', deal_id, item_id, qty) messages.error(request, 'Ошибка отката запуска. Подробности в логе сервера.') return redirect(next_url) if action == 'start_batch_item_production': item_id = parse_int(request.POST.get('item_id')) qty = parse_int(request.POST.get('quantity')) if not item_id or not qty or qty <= 0: messages.error(request, 'Заполни количество.') return redirect(next_url) logger.info('start_batch_item_production: deal_id=%s item_id=%s qty=%s', deal_id, item_id, qty) try: with transaction.atomic(): bi = ( DealBatchItem.objects.select_for_update() .select_related('batch', 'entity') .filter(id=item_id, batch__deal_id=deal_id) .first() ) if not bi: messages.error(request, 'Строка партии не найдена.') return redirect(next_url) et = getattr(bi.entity, 'entity_type', '') if et in ['purchased', 'casting', 'outsourced']: messages.error(request, 'Эта позиция относится к снабжению. Запуск через производство пока не реализован.') logger.info('start_batch_item_production: skipped supply entity_type=%s entity_id=%s', et, bi.entity_id) return redirect(next_url) started = int(getattr(bi, 'started_qty', 0) or 0) total = int(bi.quantity or 0) remaining = total - started if qty > remaining: messages.error(request, 'Нельзя запустить больше, чем осталось в партии.') logger.info('start_batch_item_production: qty_exceeds_remaining remaining=%s started=%s total=%s', remaining, started, total) return redirect(next_url) stats = explode_roots_additive(int(deal_id), [(int(bi.entity_id), int(qty))]) bi.started_qty = started + int(qty) bi.save(update_fields=['started_qty']) if int(stats.tasks_created or 0) == 0 and int(stats.tasks_updated or 0) == 0: messages.warning(request, 'Запуск выполнен, но задачи не созданы. Проверь, что leaf-детали имеют материал (planned_material) и не относятся к снабжению.') else: messages.success(request, f'Запущено в производство: {qty} шт. Задачи: +{stats.tasks_created} / обновлено {stats.tasks_updated}.') logger.info( 'start_batch_item_production: ok deal_id=%s entity_id=%s qty=%s tasks_created=%s tasks_updated=%s', deal_id, bi.entity_id, qty, stats.tasks_created, stats.tasks_updated, ) return redirect(next_url) except ExplosionValidationError as ev: try: from manufacturing.models import ProductEntity bad = list(ProductEntity.objects.filter(id__in=list(ev.missing_material_ids)).values_list('drawing_number', 'name')) except Exception: bad = [] if bad: preview = ", ".join([f"{dn or '—'} {nm}" for dn, nm in bad[:5]]) more = '' if len(bad) <= 5 else f" и ещё {len(bad)-5}" messages.error(request, f'В спецификации есть детали без материала: {preview}{more}. Добавь material в паспорт(ы) и повтори запуск.') else: messages.error(request, 'В спецификации есть детали без материала. Добавь material и повтори запуск.') return redirect(next_url) except Exception: logger.exception('start_batch_item_production: failed deal_id=%s item_id=%s qty=%s', deal_id, item_id, qty) messages.error(request, 'Ошибка запуска в производство. Подробности в логе сервера.') return redirect(next_url) messages.error(request, 'Неизвестное действие.') return redirect(next_url) class DealItemUpsertView(LoginRequiredMixin, View): def post(self, request, *args, **kwargs): roles = get_user_roles(request.user) if not has_any_role(roles, ['admin', 'technologist', 'manager', 'prod_head']): return redirect('planning') action = (request.POST.get('action') or '').strip() if not action: action = 'add' next_url = (request.POST.get('next') or '').strip() next_url = next_url if next_url.startswith('/') else str(reverse_lazy('planning')) def parse_int(s): s = (s or '').strip() return int(s) if s.isdigit() else None deal_id = parse_int(request.POST.get('deal_id')) entity_id = parse_int(request.POST.get('entity_id')) qty = parse_int(request.POST.get('quantity')) if not (deal_id and entity_id): messages.error(request, 'Не выбрана сделка или сущность.') return redirect(next_url) if action in ['add', 'set_qty']: if not (qty and qty > 0): messages.error(request, 'Заполни количество (больше 0).') return redirect(next_url) try: with transaction.atomic(): if action == 'delete': item = DealItem.objects.select_for_update().filter(deal_id=deal_id, entity_id=entity_id).first() if not item: messages.error(request, 'Позиция сделки не найдена.') return redirect(next_url) started = ( DealBatchItem.objects.filter(batch__deal_id=deal_id, entity_id=entity_id) .aggregate(s=Coalesce(Sum('started_qty'), 0))['s'] ) started = int(started or 0) wi_agg = ( WorkItem.objects.filter(deal_id=deal_id, entity_id=entity_id) .aggregate(p=Coalesce(Sum('quantity_plan'), 0), d=Coalesce(Sum('quantity_done'), 0)) ) planned = int((wi_agg or {}).get('p') or 0) done = int((wi_agg or {}).get('d') or 0) allocated = ( DealBatchItem.objects.filter(batch__deal_id=deal_id, entity_id=entity_id) .aggregate(s=Coalesce(Sum('quantity'), 0))['s'] ) allocated = int(allocated or 0) if started > 0 or planned > 0 or done > 0: messages.error(request, 'Нельзя удалить позицию: по ней уже есть запуск/план/факт. Сначала откати производство.') return redirect(next_url) if allocated > 0: messages.error(request, 'Нельзя удалить позицию: она уже распределена по партиям поставки. Сначала удали строки партий.') return redirect(next_url) DealEntityProgress.objects.filter(deal_id=deal_id, entity_id=entity_id).delete() WorkItem.objects.filter(deal_id=deal_id, entity_id=entity_id).delete() DealBatchItem.objects.filter(batch__deal_id=deal_id, entity_id=entity_id).delete() item.delete() _reconcile_default_delivery_batch(int(deal_id)) messages.success(request, 'Позиция удалена из сделки.') return redirect(next_url) item, created = DealItem.objects.select_for_update().get_or_create( deal_id=deal_id, entity_id=entity_id, defaults={'quantity': int(qty)}, ) if action == 'add': if not created: messages.warning(request, 'Позиция уже есть в сделке. Измени количество в строке позиции (OK).') return redirect(next_url) item.quantity = int(qty) item.save(update_fields=['quantity']) _reconcile_default_delivery_batch(int(deal_id)) messages.success(request, 'Позиция сделки добавлена.') return redirect(next_url) if action == 'set_qty': started = ( DealBatchItem.objects.filter(batch__deal_id=deal_id, entity_id=entity_id) .aggregate(s=Coalesce(Sum('started_qty'), 0))['s'] ) started = int(started or 0) allocated_non_default = ( DealBatchItem.objects.filter(batch__deal_id=deal_id, batch__is_default=False, entity_id=entity_id) .aggregate(s=Coalesce(Sum('quantity'), 0))['s'] ) allocated_non_default = int(allocated_non_default or 0) if int(qty) < started: messages.error(request, f'Нельзя поставить {qty} шт: уже запущено {started} шт в производство.') return redirect(next_url) if int(qty) < allocated_non_default: messages.error(request, f'Нельзя поставить {qty} шт: по партиям уже распределено {allocated_non_default} шт.') return redirect(next_url) before = int(item.quantity or 0) if before != int(qty): item.quantity = int(qty) item.save(update_fields=['quantity']) _reconcile_default_delivery_batch(int(deal_id)) messages.success(request, f'Количество по сделке обновлено: {before} → {item.quantity}.') return redirect(next_url) messages.error(request, 'Неизвестное действие.') return redirect(next_url) except Exception as e: messages.error(request, f'Ошибка: {e}') return redirect(next_url) class DirectoriesView(LoginRequiredMixin, TemplateView): template_name = 'shiftflow/directories.html' def dispatch(self, request, *args, **kwargs): roles = get_user_roles(request.user) if not has_any_role(roles, ['admin', 'technologist', 'master', 'clerk', 'observer', 'prod_head', 'director']): return redirect('registry') return super().dispatch(request, *args, **kwargs) def get_context_data(self, **kwargs): ctx = super().get_context_data(**kwargs) roles = get_user_roles(self.request.user) role = primary_role(roles) ctx['user_role'] = role ctx['user_roles'] = sorted(roles) return ctx class LocationsCatalogView(LoginRequiredMixin, TemplateView): template_name = 'shiftflow/locations_catalog.html' def dispatch(self, request, *args, **kwargs): roles = get_user_roles(request.user) if not has_any_role(roles, ['admin', 'technologist', 'master', 'clerk', 'observer', 'prod_head', 'director']): return redirect('registry') return super().dispatch(request, *args, **kwargs) def get_context_data(self, **kwargs): ctx = super().get_context_data(**kwargs) roles = get_user_roles(self.request.user) role = primary_role(roles) ctx['user_role'] = role ctx['user_roles'] = sorted(roles) ctx['can_edit'] = has_any_role(roles, ['admin', 'prod_head', 'director']) ctx['locations'] = list(Location.objects.order_by('name')) return ctx def post(self, request, *args, **kwargs): roles = get_user_roles(request.user) if not has_any_role(roles, ['admin', 'prod_head', 'director']): return redirect('locations_catalog') action = (request.POST.get('action') or '').strip() name = (request.POST.get('name') or '').strip() if action == 'create': if not name: messages.error(request, 'Заполни название склада.') return redirect('locations_catalog') obj = Location(name=name[:100]) try: obj.full_clean() obj.save() messages.success(request, 'Склад создан.') except Exception as e: messages.error(request, f'Ошибка: {e}') return redirect('locations_catalog') if action == 'update': lid = (request.POST.get('location_id') or '').strip() if not lid.isdigit(): return redirect('locations_catalog') obj = get_object_or_404(Location, pk=int(lid)) if name: obj.name = name[:100] try: obj.full_clean() obj.save() messages.success(request, 'Склад обновлён.') except Exception as e: messages.error(request, f'Ошибка: {e}') return redirect('locations_catalog') return redirect('locations_catalog') class WorkshopsCatalogView(LoginRequiredMixin, TemplateView): template_name = 'shiftflow/workshops_catalog.html' def dispatch(self, request, *args, **kwargs): roles = get_user_roles(request.user) if not has_any_role(roles, ['admin', 'technologist', 'master', 'clerk', 'observer', 'prod_head', 'director']): return redirect('registry') return super().dispatch(request, *args, **kwargs) def get_context_data(self, **kwargs): ctx = super().get_context_data(**kwargs) roles = get_user_roles(self.request.user) role = primary_role(roles) ctx['user_role'] = role ctx['user_roles'] = sorted(roles) workshops = list(Workshop.objects.select_related('location').order_by('id')) ws_ids = [int(w.id) for w in workshops] machines = list(Machine.objects.filter(workshop_id__in=ws_ids).order_by('name', 'id')) by_ws = {} for m in machines: by_ws.setdefault(int(m.workshop_id), []).append(m) for ws in workshops: ms = by_ws.get(int(ws.id)) or [] labels = [x.name for x in ms][:8] tail = '' if len(ms) > 8: tail = f" +{len(ms) - 8}" ws.machine_labels = ', '.join(labels) + tail if labels else '' ctx['workshops'] = workshops return ctx class MachinesCatalogView(LoginRequiredMixin, TemplateView): template_name = 'shiftflow/machines_catalog.html' def dispatch(self, request, *args, **kwargs): roles = get_user_roles(request.user) if not has_any_role(roles, ['admin', 'technologist', 'master', 'clerk', 'observer', 'prod_head', 'director']): return redirect('registry') self.roles = roles self.role = primary_role(roles) self.can_edit = has_any_role(roles, ['admin', 'prod_head', 'director']) return super().dispatch(request, *args, **kwargs) def _workshop_id(self): ws_id = (self.request.GET.get('workshop_id') or '').strip() return int(ws_id) if ws_id.isdigit() else None def get(self, request, *args, **kwargs): if not self._workshop_id(): return redirect('workshops_catalog') return super().get(request, *args, **kwargs) def get_context_data(self, **kwargs): ctx = super().get_context_data(**kwargs) ctx['user_role'] = self.role ctx['can_edit'] = self.can_edit ws_id = self._workshop_id() workshop = get_object_or_404(Workshop.objects.select_related('location'), pk=int(ws_id)) ctx['workshop'] = workshop ctx['locations'] = list(Location.objects.order_by('name')) machines = list(Machine.objects.filter(workshop_id=workshop.id).order_by('name', 'id')) ctx['machines'] = machines ctx['machine_types'] = list(getattr(Machine, 'MACHINE_TYPE_CHOICES', [])) return ctx def post(self, request, *args, **kwargs): ws_id = self._workshop_id() if not ws_id: return redirect('workshops_catalog') if not self.can_edit: return redirect(f"{reverse_lazy('machines_catalog')}?workshop_id={int(ws_id)}") workshop = get_object_or_404(Workshop, pk=int(ws_id)) action = (request.POST.get('action') or '').strip() if action == 'update_workshop': name = (request.POST.get('name') or '').strip() location_id = (request.POST.get('location_id') or '').strip() if name: workshop.name = name[:120] workshop.location_id = int(location_id) if location_id.isdigit() else None try: workshop.full_clean() workshop.save() messages.success(request, 'Цех сохранён.') except Exception as e: messages.error(request, f'Ошибка: {e}') return redirect(f"{reverse_lazy('machines_catalog')}?workshop_id={workshop.id}") if action == 'create_machine': name = (request.POST.get('name') or '').strip() machine_type = (request.POST.get('machine_type') or '').strip() if not name: messages.error(request, 'Заполни название поста.') return redirect(f"{reverse_lazy('machines_catalog')}?workshop_id={workshop.id}") allowed = {t[0] for t in getattr(Machine, 'MACHINE_TYPE_CHOICES', [])} if machine_type not in allowed: machine_type = 'linear' m = Machine(name=name[:100], workshop_id=workshop.id, machine_type=machine_type) try: m.full_clean() m.save() messages.success(request, 'Пост добавлен.') except Exception as e: messages.error(request, f'Ошибка: {e}') return redirect(f"{reverse_lazy('machines_catalog')}?workshop_id={workshop.id}") if action == 'update_machine': mid = (request.POST.get('machine_id') or '').strip() if not mid.isdigit(): return redirect(f"{reverse_lazy('machines_catalog')}?workshop_id={workshop.id}") m = get_object_or_404(Machine, pk=int(mid), workshop_id=workshop.id) name = (request.POST.get('name') or '').strip() machine_type = (request.POST.get('machine_type') or '').strip() if name: m.name = name[:100] allowed = {t[0] for t in getattr(Machine, 'MACHINE_TYPE_CHOICES', [])} if machine_type in allowed: m.machine_type = machine_type try: m.full_clean() m.save() messages.success(request, 'Пост сохранён.') except Exception as e: messages.error(request, f'Ошибка: {e}') return redirect(f"{reverse_lazy('machines_catalog')}?workshop_id={workshop.id}") if action == 'delete_machine': mid = (request.POST.get('machine_id') or '').strip() if not mid.isdigit(): return redirect(f"{reverse_lazy('machines_catalog')}?workshop_id={workshop.id}") m = get_object_or_404(Machine, pk=int(mid), workshop_id=workshop.id) # Защита удаления: не удаляем пост, если по нему есть сменка/задания. has_items = Item.objects.filter(machine_id=m.id).exists() has_workitems = WorkItem.objects.filter(machine_id=m.id).exists() if has_items or has_workitems: messages.error(request, 'Нельзя удалить пост: по нему есть сменные задания или производственные операции.') return redirect(f"{reverse_lazy('machines_catalog')}?workshop_id={workshop.id}") try: m.delete() messages.success(request, 'Пост удалён.') except Exception as e: messages.error(request, f'Нельзя удалить пост: {e}') return redirect(f"{reverse_lazy('machines_catalog')}?workshop_id={workshop.id}") messages.error(request, 'Неизвестное действие.') return redirect(f"{reverse_lazy('machines_catalog')}?workshop_id={workshop.id}") class SupplyCatalogView(LoginRequiredMixin, TemplateView): template_name = 'shiftflow/supply_catalog.html' TYPE_CHOICES = [ ('purchased', 'Покупное'), ('outsourced', 'Аутсорс'), ] 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): 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', 'clerk'] q = (self.request.GET.get('q') or '').strip() entity_types = [x.strip() for x in self.request.GET.getlist('types') if (x or '').strip()] allowed_types = {c[0] for c in self.TYPE_CHOICES} entity_types = [x for x in entity_types if x in allowed_types] if not entity_types: entity_types = list(allowed_types) qs = ProductEntity.objects.all().filter(entity_type__in=entity_types) if q: qs = qs.filter(Q(drawing_number__icontains=q) | Q(name__icontains=q)) ctx['q'] = q ctx['entity_types'] = entity_types ctx['type_choices'] = list(self.TYPE_CHOICES) ctx['items'] = qs.order_by('entity_type', 'drawing_number', 'name', 'id') 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', 'clerk']: return redirect('supply_catalog') 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() allowed_types = {c[0] for c in self.TYPE_CHOICES} if entity_type not in allowed_types: messages.error(request, 'Выбери тип: покупное / аутсорс.') return redirect('supply_catalog') if not name: messages.error(request, 'Заполни наименование.') return redirect('supply_catalog') obj = ProductEntity.objects.create( entity_type=entity_type, name=name[:255], drawing_number=drawing_number[:100], ) return redirect('product_detail', pk=obj.id) class MaterialsCatalogView(LoginRequiredMixin, TemplateView): template_name = 'shiftflow/materials_catalog.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): 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', 'master', 'clerk'] q = (self.request.GET.get('q') or '').strip() qs = Material.objects.select_related('category', 'steel_grade').all() if q: qs = qs.filter(Q(full_name__icontains=q) | Q(name__icontains=q) | Q(category__name__icontains=q) | Q(steel_grade__name__icontains=q)) def unit_for(m): ff = getattr(getattr(m, 'category', None), 'form_factor', 'other') if ff == 'sheet': return 'кг/кв.м' if ff == 'bar': return 'кг/п.м' return 'кг/шт' rows = [] for m in qs.order_by('category__name', 'name', 'steel_grade__name', 'id'): rows.append({ 'm': m, 'unit': unit_for(m), }) ctx['q'] = q ctx['rows'] = rows ctx['categories'] = list(MaterialCategory.objects.order_by('name')) ctx['grades'] = list(SteelGrade.objects.order_by('name')) return ctx class MaterialCategoriesCatalogView(LoginRequiredMixin, TemplateView): template_name = 'shiftflow/material_categories_catalog.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): 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', 'master', 'clerk'] q = (self.request.GET.get('q') or '').strip() qs = MaterialCategory.objects.all() if q: qs = qs.filter(Q(name__icontains=q) | Q(gost_standard__icontains=q)) ctx['q'] = q ctx['categories'] = list(qs.order_by('name')) return ctx class SteelGradesCatalogView(LoginRequiredMixin, TemplateView): template_name = 'shiftflow/steel_grades_catalog.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): 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', 'master', 'clerk'] q = (self.request.GET.get('q') or '').strip() qs = SteelGrade.objects.all() if q: qs = qs.filter(Q(name__icontains=q) | Q(gost_standard__icontains=q)) ctx['q'] = q ctx['grades'] = list(qs.order_by('name')) return ctx # Вьюха детального вида и редактирования 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): roles = get_user_roles(request.user) if not has_any_role(roles, ['admin', 'technologist', 'master', 'clerk', 'observer', 'prod_head', 'director']): 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) roles = get_user_roles(self.request.user) role = primary_role(roles) ctx['user_role'] = role ctx['user_roles'] = sorted(roles) 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'] = has_any_role(roles, ['admin', 'technologist', 'master', 'clerk', 'prod_head', 'director']) ctx['can_receive'] = has_any_role(roles, ['admin', 'technologist', 'master', 'clerk', 'prod_head', 'director']) allowed_transfer_locations = None if role == 'master' and not has_any_role(roles, ['admin', 'technologist', 'clerk', 'prod_head', 'director']): allowed_ws_ids = list(profile.allowed_workshops.values_list('id', flat=True)) if profile else [] if not allowed_ws_ids and profile: user_machine_ids = list(profile.machines.values_list('id', flat=True)) allowed_ws_ids = list(Machine.objects.filter(id__in=user_machine_ids).exclude(workshop_id__isnull=True).values_list('workshop_id', flat=True)) allowed_loc_ids = list(Workshop.objects.filter(id__in=allowed_ws_ids).exclude(location_id__isnull=True).values_list('location_id', flat=True)) if allowed_loc_ids: allowed_transfer_locations = list(Location.objects.filter(id__in=allowed_loc_ids).order_by('name')) ctx['transfer_locations'] = allowed_transfer_locations if allowed_transfer_locations is not None else locations ctx['receipt_locations'] = allowed_transfer_locations if allowed_transfer_locations is not None else locations 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) roles = get_user_roles(request.user) role = primary_role(roles) if not has_any_role(roles, ['admin', 'technologist', 'master', 'clerk', 'prod_head', 'director']): return JsonResponse({'error': 'forbidden'}, status=403) 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) if role == 'master' and not has_any_role(roles, ['admin', 'technologist', 'clerk', 'prod_head', 'director']): allowed_ws_ids = list(profile.allowed_workshops.values_list('id', flat=True)) if profile else [] if not allowed_ws_ids and profile: user_machine_ids = list(profile.machines.values_list('id', flat=True)) allowed_ws_ids = list(Machine.objects.filter(id__in=user_machine_ids).exclude(workshop_id__isnull=True).values_list('workshop_id', flat=True)) allowed_loc_ids = list(Workshop.objects.filter(id__in=allowed_ws_ids).exclude(location_id__isnull=True).values_list('location_id', flat=True)) if not allowed_loc_ids or int(to_location_id) not in {int(x) for x in allowed_loc_ids}: 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): roles = get_user_roles(request.user) if not has_any_role(roles, ['admin', 'technologist', 'master', 'clerk', 'prod_head', 'director']): return JsonResponse({'error': 'forbidden'}, status=403) next_url = (request.POST.get('next') or '').strip() if not next_url.startswith('/'): next_url = reverse_lazy('warehouse_stocks') 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) profile = getattr(request.user, 'profile', None) role = primary_role(roles) if role == 'master' and not has_any_role(roles, ['admin', 'technologist', 'clerk', 'prod_head', 'director']): allowed_ws_ids = list(profile.allowed_workshops.values_list('id', flat=True)) if profile else [] if not allowed_ws_ids and profile: user_machine_ids = list(profile.machines.values_list('id', flat=True)) allowed_ws_ids = list(Machine.objects.filter(id__in=user_machine_ids).exclude(workshop_id__isnull=True).values_list('workshop_id', flat=True)) allowed_loc_ids = list(Workshop.objects.filter(id__in=allowed_ws_ids).exclude(location_id__isnull=True).values_list('location_id', flat=True)) if not allowed_loc_ids or int(location_id) not in {int(x) for x in allowed_loc_ids}: 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 ShippingView(LoginRequiredMixin, TemplateView): template_name = 'shiftflow/shipping.html' def dispatch(self, request, *args, **kwargs): profile = getattr(request.user, 'profile', None) roles = get_user_roles(request.user) self.role = primary_role(roles) self.roles = roles self.is_readonly = bool(getattr(profile, 'is_readonly', False)) if profile else False self.can_edit = has_any_role(roles, ['admin', 'clerk', 'manager', 'prod_head', 'technologist']) and not self.is_readonly if not has_any_role(roles, ['admin', 'clerk', 'manager', 'prod_head', 'director', 'technologist', 'observer']): return redirect('registry') return super().dispatch(request, *args, **kwargs) def get_context_data(self, **kwargs): ctx = super().get_context_data(**kwargs) ctx['user_role'] = self.role ctx['user_roles'] = sorted(self.roles) ctx['can_edit'] = bool(self.can_edit) ctx['is_readonly'] = bool(self.is_readonly) deal_id_raw = (self.request.GET.get('deal_id') or '').strip() deal_id = int(deal_id_raw) if deal_id_raw.isdigit() else None shipping_loc, _ = Location.objects.get_or_create( name='Склад отгруженных позиций', defaults={'is_production_area': False}, ) ctx['shipping_location'] = shipping_loc ctx['deals'] = list(Deal.objects.select_related('company').order_by('-id')[:300]) ctx['selected_deal_id'] = deal_id ctx['entity_rows'] = [] ctx['material_rows'] = [] if deal_id: from shiftflow.services.shipping import build_shipment_rows entity_rows, material_rows = build_shipment_rows( deal_id=int(deal_id), shipping_location_id=int(shipping_loc.id), ) ctx['entity_rows'] = entity_rows ctx['material_rows'] = material_rows return ctx def post(self, request, *args, **kwargs): if not self.can_edit: messages.error(request, 'Доступ только для просмотра.') return redirect('shipping') deal_id_raw = (request.POST.get('deal_id') or '').strip() if not deal_id_raw.isdigit(): messages.error(request, 'Выбери сделку.') return redirect('shipping') deal_id = int(deal_id_raw) shipping_loc, _ = Location.objects.get_or_create( name='Склад отгруженных позиций', defaults={'is_production_area': False}, ) entity_qty: dict[int, int] = {} material_qty: dict[int, float] = {} for k, v in request.POST.items(): if not k or v is None: continue s = (str(v) or '').strip().replace(',', '.') if k.startswith('ent_'): ent_id_raw = k.replace('ent_', '').strip() if not ent_id_raw.isdigit(): continue try: qty = int(float(s)) if s else 0 except ValueError: qty = 0 if qty > 0: entity_qty[int(ent_id_raw)] = int(qty) if k.startswith('mat_'): mat_id_raw = k.replace('mat_', '').strip() if not mat_id_raw.isdigit(): continue try: qty_f = float(s) if s else 0.0 except ValueError: qty_f = 0.0 if qty_f > 0: material_qty[int(mat_id_raw)] = float(qty_f) if not entity_qty and not material_qty: messages.error(request, 'Укажи количество к отгрузке хотя бы по одной позиции.') return redirect(f"{reverse_lazy('shipping')}?deal_id={deal_id}&from_location_id={from_location_id}") from shiftflow.services.shipping import create_shipment_transfers try: ids = create_shipment_transfers( deal_id=int(deal_id), shipping_location_id=int(shipping_loc.id), entity_qty=entity_qty, material_qty=material_qty, user_id=int(request.user.id), ) msg = ', '.join([str(i) for i in ids]) messages.success(request, f'Отгрузка оформлена. Документы перемещения: {msg}.') except Exception as e: logger.exception('shipping:error deal_id=%s', deal_id) messages.error(request, f'Ошибка отгрузки: {e}') return redirect(f"{reverse_lazy('shipping')}?deal_id={deal_id}") from shiftflow.services.assembly_closing import get_assembly_closing_info, apply_assembly_closing class AssemblyClosingView(LoginRequiredMixin, TemplateView): template_name = 'shiftflow/assembly_closing.html' def dispatch(self, request, *args, **kwargs): roles = get_user_roles(request.user) if not has_any_role(roles, ['admin', 'master', 'operator', 'prod_head']): return redirect('registry') pk = self.kwargs.get('pk') wi = get_object_or_404(WorkItem.objects.select_related('entity', 'deal', 'machine', 'workshop'), pk=int(pk)) if wi.entity.entity_type not in ['product', 'assembly']: messages.error(request, 'Закрытие сборки доступно только для сборочных единиц и изделий.') return redirect('workitem_detail', pk=wi.id) self.workitem = wi return super().dispatch(request, *args, **kwargs) def get_context_data(self, **kwargs): ctx = super().get_context_data(**kwargs) roles = get_user_group_roles(self.request.user) ctx['user_roles'] = sorted(roles) ctx['user_role'] = primary_role(roles) ctx['workitem'] = self.workitem ctx['remaining'] = max(0, int(self.workitem.quantity_plan or 0) - int(self.workitem.quantity_done or 0)) info = get_assembly_closing_info(self.workitem) ctx.update(info) ws_id = getattr(self.workitem, 'workshop_id', None) ctx['workshop_machines'] = list(Machine.objects.filter(workshop_id=ws_id).order_by('name')) if ws_id else [] if info.get('error'): messages.warning(self.request, info['error']) return ctx def post(self, request, *args, **kwargs): action = (request.POST.get('action') or '').strip() if action == 'close': qty_raw = (request.POST.get('fact_qty') or '').strip() try: qty = int(qty_raw) except ValueError: qty = 0 if qty <= 0: messages.error(request, 'Количество должно быть больше 0.') return redirect('assembly_closing', pk=self.workitem.id) if not getattr(self.workitem, 'machine_id', None): mid_raw = (request.POST.get('machine_id') or '').strip() if not mid_raw.isdigit(): messages.error(request, 'Выбери пост для производственного отчёта.') return redirect('assembly_closing', pk=self.workitem.id) mid = int(mid_raw) ws_id = getattr(self.workitem, 'workshop_id', None) if ws_id: ok = Machine.objects.filter(id=mid, workshop_id=int(ws_id)).exists() else: ok = Machine.objects.filter(id=mid).exists() if not ok: messages.error(request, 'Выбранный пост не относится к цеху задания.') return redirect('assembly_closing', pk=self.workitem.id) WorkItem.objects.filter(id=int(self.workitem.id), machine_id__isnull=True).update(machine_id=mid) self.workitem.machine_id = mid try: apply_assembly_closing(self.workitem.id, qty, request.user.id) messages.success(request, f'Успешно закрыто {qty} шт. Компоненты списаны, выпуск добавлен.') return redirect('workitem_detail', pk=self.workitem.id) except Exception as e: logger.exception('assembly_closing: error') messages.error(request, f'Ошибка закрытия: {e}') return redirect('assembly_closing', pk=self.workitem.id) return redirect('assembly_closing', pk=self.workitem.id) class ClosingWorkItemsView(LoginRequiredMixin, TemplateView): template_name = 'shiftflow/closing_workitems.html' def dispatch(self, request, *args, **kwargs): roles = get_user_group_roles(request.user) if not has_any_role(roles, ['admin', 'master', 'operator', 'observer', 'prod_head', 'director']): 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) roles = get_user_group_roles(self.request.user) role = primary_role(roles) ctx['user_role'] = role ctx['user_roles'] = sorted(roles) q = (self.request.GET.get('q') or '').strip() ctx['q'] = q qs = ( WorkItem.objects.select_related('deal', 'entity', 'operation', 'machine', 'workshop', 'entity__planned_material') .filter(quantity_done__lt=F('quantity_plan')) .filter(status__in=['planned', 'leftover']) ) if q: qs = qs.filter( Q(deal__number__icontains=q) | Q(entity__drawing_number__icontains=q) | Q(entity__name__icontains=q) | Q(machine__name__icontains=q) | Q(workshop__name__icontains=q) ) if role == 'operator' and profile: user_machine_ids = list(profile.machines.values_list('id', flat=True)) user_ws_ids = list( Machine.objects.filter(id__in=user_machine_ids) .exclude(workshop_id__isnull=True) .values_list('workshop_id', flat=True) ) allowed_ws = list(profile.allowed_workshops.values_list('id', flat=True)) ws_ids = list({int(x) for x in (user_ws_ids + allowed_ws) if x}) qs = qs.filter(Q(machine_id__in=user_machine_ids) | Q(machine_id__isnull=True, workshop_id__in=ws_ids)) elif role == 'master' and profile: allowed_ws = list(profile.allowed_workshops.values_list('id', flat=True)) if allowed_ws: qs = qs.filter(Q(workshop_id__in=allowed_ws) | Q(machine__workshop_id__in=allowed_ws)) rows = list(qs.order_by('workshop__name', 'machine__name', 'date', 'deal__number', 'entity__drawing_number', 'id')) for wi in rows: plan = int(wi.quantity_plan or 0) done = int(wi.quantity_done or 0) wi.remaining = max(0, plan - done) first_op_id = get_first_operation_id(int(wi.entity_id)) is_first = True if first_op_id and getattr(wi, 'operation_id', None): is_first = int(wi.operation_id) == int(first_op_id) if wi.entity and wi.entity.entity_type in ['product', 'assembly']: wi.close_url = str(reverse_lazy('assembly_closing', kwargs={'pk': wi.id})) if is_first else str(reverse_lazy('workitem_op_closing', kwargs={'pk': wi.id})) elif wi.entity and wi.entity.entity_type == 'part': m_id = int(wi.machine_id) if wi.machine_id else 0 mat_id = int(getattr(wi.entity, 'planned_material_id', None) or 0) if wi.entity else 0 if is_first and m_id and mat_id: wi.close_url = f"{reverse_lazy('closing')}?machine_id={m_id}&material_id={mat_id}" else: wi.close_url = str(reverse_lazy('workitem_op_closing', kwargs={'pk': wi.id})) else: wi.close_url = str(reverse_lazy('workitem_op_closing', kwargs={'pk': wi.id})) ctx['workitems'] = rows return ctx class ClosingView(LoginRequiredMixin, TemplateView): template_name = 'shiftflow/closing.html' def dispatch(self, request, *args, **kwargs): roles = get_user_group_roles(request.user) if not has_any_role(roles, ['admin', 'master', 'operator', 'observer', 'prod_head', 'director']): 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) roles = get_user_group_roles(self.request.user) role = primary_role(roles) ctx['user_role'] = role ctx['user_roles'] = sorted(roles) 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 def materials_from_workitems(machine_ids=None, workshop_ids=None): wi = WorkItem.objects.select_related('entity', 'entity__planned_material')\ .filter(status='planned')\ .filter(quantity_done__lt=F('quantity_plan')) if machine_ids: wi = wi.filter(Q(machine_id__in=list(machine_ids)) | Q(machine_id__isnull=True)) if workshop_ids: wi = wi.filter(Q(workshop_id__in=list(workshop_ids)) | Q(machine_id__in=list(machine_ids or []))) mat_ids = ( wi.values_list('entity__planned_material_id', flat=True) .exclude(entity__planned_material_id__isnull=True) .distinct() ) return list(Material.objects.select_related('category').filter(id__in=list(mat_ids)).order_by('full_name')) if role == 'operator' and profile: user_machine_ids = set(profile.machines.values_list('id', flat=True)) user_ws_ids = set( Machine.objects.filter(id__in=list(user_machine_ids)) .exclude(workshop_id__isnull=True) .values_list('workshop_id', flat=True) ) ctx['materials'] = materials_from_workitems(machine_ids=user_machine_ids, workshop_ids=user_ws_ids) elif role == 'master' and profile: allowed_ws = set(profile.allowed_workshops.values_list('id', flat=True)) ctx['materials'] = materials_from_workitems(workshop_ids=allowed_ws) else: ctx['materials'] = materials_from_workitems() 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 workitems = [] stock_items = [] if machine_id.isdigit() and material_id.isdigit(): workitems = list( WorkItem.objects.select_related('deal', 'entity', 'machine') .filter(machine_id=int(machine_id), status__in=['planned'], entity__planned_material_id=int(material_id)) .filter(quantity_done__lt=F('quantity_plan')) .order_by('date', 'deal__number', 'entity__drawing_number') ) for wi in workitems: plan = int(wi.quantity_plan or 0) done = int(wi.quantity_done or 0) wi.remaining = max(0, plan - done) 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['workitems'] = workitems ctx['stock_items'] = stock_items readonly = bool(getattr(profile, 'is_readonly', False)) if profile else False ctx['can_edit'] = (has_any_role(roles, ['admin', 'master', 'operator', 'prod_head']) and not readonly) return ctx def post(self, request, *args, **kwargs): roles = get_user_group_roles(request.user) if not has_any_role(roles, ['admin', 'master', 'operator', 'prod_head']): return redirect('closing') profile = getattr(request.user, 'profile', None) if bool(getattr(profile, 'is_readonly', False)) if profile else False: messages.error(request, 'Доступ только для просмотра.') 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}") if not consumptions: messages.error(request, 'Заполни списание: укажи, какие единицы на складе использованы и в каком количестве.') return redirect(f"{reverse_lazy('closing')}?machine_id={machine_id}&material_id={material_id}") try: apply_closing_workitems( 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: logger.exception('closing_workitems:error machine_id=%s material_id=%s', machine_id, material_id) messages.error(request, f'Ошибка закрытия: {e}') return redirect(f"{reverse_lazy('closing')}?machine_id={machine_id}&material_id={material_id}") class ProcurementDashboardView(LoginRequiredMixin, TemplateView): template_name = 'shiftflow/procurement_dashboard.html' TYPE_CHOICES = [ ('raw', 'Сырьё'), ('purchased', 'Покупное'), ('casting', 'Литьё'), ('outsourced', 'Аутсорс'), ] STATUS_CHOICES = [ ('to_order', 'К заказу'), ('ordered', 'Заказано'), ('closed', 'Закрыто'), ] def dispatch(self, request, *args, **kwargs): roles = get_user_group_roles(request.user) if not has_any_role(roles, ['admin', 'supply', 'observer', 'clerk', 'prod_head', 'director']): return redirect('index') return super().dispatch(request, *args, **kwargs) def get_context_data(self, **kwargs): ctx = super().get_context_data(**kwargs) roles = get_user_group_roles(self.request.user) role = primary_role(roles) ctx['user_role'] = role ctx['user_roles'] = sorted(roles) ctx['can_edit'] = has_any_role(roles, ['admin', 'supply']) q = (self.request.GET.get('q') or '').strip() filtered = (self.request.GET.get('filtered') or '').strip() filtered_flag = filtered in ['1', 'true', 'yes', 'on'] types = [t for t in self.request.GET.getlist('types') if t] statuses = [s for s in self.request.GET.getlist('statuses') if s] grouped = (self.request.GET.get('grouped') or '').strip() in ['1', 'true', 'yes', 'on'] allowed_types = {c[0] for c in self.TYPE_CHOICES} allowed_statuses = {c[0] for c in self.STATUS_CHOICES} is_default = not filtered_flag if is_default and not types: types = list(allowed_types) else: types = [t for t in types if t in allowed_types] if is_default and not statuses: statuses = [s for s in ['to_order', 'ordered'] if s in allowed_statuses] else: statuses = [s for s in statuses if s in allowed_statuses] proc_qs = ProcurementRequirement.objects.select_related('deal', 'component') raw_qs = MaterialRequirement.objects.select_related('deal', 'material') if (not types) or (not statuses): proc_qs = proc_qs.none() raw_qs = raw_qs.none() if q: proc_qs = proc_qs.filter( Q(deal__number__icontains=q) | Q(component__drawing_number__icontains=q) | Q(component__name__icontains=q) ) raw_qs = raw_qs.filter( Q(deal__number__icontains=q) | Q(material__full_name__icontains=q) | Q(material__name__icontains=q) ) proc_type_map = { 'purchased': 'purchased', 'casting': 'casting', 'outsourced': 'outsourced', } proc_qs = proc_qs.filter(component__entity_type__in=list(proc_type_map.keys())) if types: proc_qs = proc_qs.filter(component__entity_type__in=[t for t in types if t in proc_type_map]) raw_status_map = { 'needed': 'to_order', 'ordered': 'ordered', 'fulfilled': 'closed', } inv_raw_status_map = { 'to_order': ['needed'], 'ordered': ['ordered'], 'closed': ['fulfilled'], } if statuses: proc_qs = proc_qs.filter(status__in=statuses) raw_qs = raw_qs.filter(status__in=sum([inv_raw_status_map.get(s, []) for s in statuses], [])) requirements = [] if 'raw' in types: for r in raw_qs.order_by('status', 'deal__number', 'material__full_name', 'id'): requirements.append({ 'kind': 'raw', 'type': 'raw', 'component_id': int(r.material_id), 'component_label': str(r.material), 'required_qty': float(r.required_qty), 'unit': r.unit, 'deal_id': int(r.deal_id), 'deals': [str(r.deal.number)], 'status': raw_status_map.get(r.status, 'to_order'), 'row_id': f'raw_{r.id}', 'obj_id': int(r.id), }) for r in proc_qs.order_by('status', 'deal__number', 'component__drawing_number', 'component__name', 'id'): requirements.append({ 'kind': 'component', 'type': str(r.component.entity_type), 'component_id': int(r.component_id), 'component_label': str(r.component), 'required_qty': int(r.required_qty or 0), 'unit': 'pcs', 'deal_id': int(r.deal_id), 'deals': [str(r.deal.number)], 'status': str(r.status), 'row_id': f'pr_{r.id}', 'obj_id': int(r.id), }) if grouped: grouped_map = {} for row in requirements: key = (row['kind'], int(row['component_id'])) g = grouped_map.get(key) if not g: grouped_map[key] = { **row, 'deals': list(row.get('deals') or []), 'required_qty': row['required_qty'], } continue g['required_qty'] = (g.get('required_qty') or 0) + (row.get('required_qty') or 0) for dn in row.get('deals') or []: if dn not in g['deals']: g['deals'].append(dn) p = {'to_order': 0, 'ordered': 1, 'closed': 2} if p.get(row.get('status'), 0) < p.get(g.get('status'), 0): g['status'] = row.get('status') requirements = list(grouped_map.values()) requirements.sort(key=lambda x: (x.get('status') or '', x.get('component_label') or '', x.get('type') or '')) if self.request.GET.get('print'): # Группируем для печати по типам from collections import defaultdict print_data = defaultdict(list) for r in requirements: # Если группировка выключена, но мы печатаем, можно оставить как есть # Либо группировать по типу t = r.get('type') or 'other' print_data[t].append(r) ctx['print_data'] = dict(print_data) ctx['type_labels'] = dict(self.TYPE_CHOICES) self.template_name = 'shiftflow/procurement_print.html' return ctx ctx['requirements'] = requirements ctx['q'] = q ctx['selected_types'] = types ctx['type_choices'] = list(self.TYPE_CHOICES) ctx['selected_statuses'] = statuses ctx['status_choices'] = list(self.STATUS_CHOICES) ctx['grouped'] = grouped # Исключаем склад «отгруженных/отгрузки» из приходов в панели снабжения. ship_loc = ( Location.objects.filter(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) ctx['locations'] = list(locations_qs) ctx['deals'] = list(Deal.objects.all().order_by('-id')[:200]) return ctx def post(self, request, *args, **kwargs): roles = get_user_group_roles(request.user) if not has_any_role(roles, ['admin', 'supply']): return redirect('procurement') action = (request.POST.get('action') or '').strip() next_url = (request.POST.get('next') or '').strip() if not next_url.startswith('/'): next_url = reverse_lazy('procurement') if action == 'mark_ordered': pr_id = (request.POST.get('pr_id') or '').strip() if pr_id.isdigit(): ProcurementRequirement.objects.filter(id=int(pr_id), status='to_order').update(status='ordered') messages.success(request, 'Отмечено как «Заказано».') return redirect(next_url) if action == 'receive_component': pr_id = (request.POST.get('pr_id') or '').strip() location_id = (request.POST.get('location_id') or '').strip() deal_id = (request.POST.get('deal_id') or '').strip() # опционально: привязать приход к сделке qty_raw = (request.POST.get('quantity') or '').strip().replace(',', '.') if not (pr_id.isdigit() and location_id.isdigit()): messages.error(request, 'Заполни корректно: склад и позиция потребности.') return redirect(next_url) try: qty = int(float(qty_raw)) except ValueError: qty = 0 if qty <= 0: messages.error(request, 'Количество должно быть больше 0.') return redirect(next_url) with transaction.atomic(): pr = ProcurementRequirement.objects.select_for_update().select_related('component').filter(id=int(pr_id)).first() if not pr: messages.error(request, 'Потребность не найдена.') return redirect(next_url) resolved_deal_id = int(deal_id) if deal_id.isdigit() else None obj = StockItem( entity_id=int(pr.component_id), location_id=int(location_id), deal_id=resolved_deal_id, quantity=float(qty), ) obj.full_clean() obj.save() cur = int(pr.required_qty or 0) new_need = cur - int(qty) if new_need <= 0: pr.required_qty = 0 pr.status = 'closed' else: pr.required_qty = int(new_need) pr.status = 'ordered' pr.save(update_fields=['required_qty', 'status']) messages.success(request, 'Приход оформлен, потребность обновлена.') return redirect(next_url) messages.error(request, 'Неизвестное действие.') return redirect(next_url) class LegacyWriteOffsView(LoginRequiredMixin, TemplateView): template_name = 'shiftflow/legacy_writeoffs.html' def dispatch(self, request, *args, **kwargs): roles = get_user_group_roles(request.user) if not has_any_role(roles, ['admin', 'clerk', 'observer', 'prod_head', 'director']): return redirect('registry') return super().dispatch(request, *args, **kwargs) def get_context_data(self, **kwargs): ctx = super().get_context_data(**kwargs) roles = get_user_group_roles(self.request.user) role = primary_role(roles) ctx['user_role'] = role ctx['user_roles'] = sorted(roles) ctx['can_edit'] = has_any_role(roles, ['admin', 'clerk', 'prod_head']) start_date = (self.request.GET.get('start_date') or '').strip() end_date = (self.request.GET.get('end_date') or '').strip() is_synced = (self.request.GET.get('is_synced') 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 ctx['is_synced'] = is_synced if is_synced in ['0', '1'] else '' ctx['is_synced'] = is_synced if is_synced in ['0', '1'] else '' reports_qs = ( CuttingSession.objects.select_related('machine', 'operator') .filter(is_closed=True, date__gte=start_date, date__lte=end_date) .order_by('-date', '-id') ) if is_synced in ['0', '1']: reports_qs = reports_qs.filter(is_synced_1c=bool(int(is_synced))) if is_synced in ['0', '1']: reports_qs = reports_qs.filter(is_synced_1c=bool(int(is_synced))) 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', 'remnants__material', ) ) 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 return ctx def post(self, request, *args, **kwargs): roles = get_user_group_roles(request.user) if not has_any_role(roles, ['admin', 'clerk', 'prod_head']): return redirect('legacy_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('legacy_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('legacy_writeoffs')}?start_date={start_date}&end_date={end_date}") class LegacyClosingView(LoginRequiredMixin, TemplateView): template_name = 'shiftflow/legacy_closing.html' def dispatch(self, request, *args, **kwargs): roles = get_user_group_roles(request.user) if not has_any_role(roles, ['admin', 'master', 'operator', 'observer', 'prod_head', 'director']): 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) roles = get_user_group_roles(self.request.user) role = primary_role(roles) ctx['user_role'] = role ctx['user_roles'] = sorted(roles) 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 readonly = bool(getattr(profile, 'is_readonly', False)) if profile else False ctx['can_edit'] = (has_any_role(roles, ['admin', 'master', 'operator', 'prod_head']) and not readonly) return ctx def post(self, request, *args, **kwargs): roles = get_user_group_roles(request.user) if not has_any_role(roles, ['admin', 'master', 'operator', 'prod_head']): return redirect('legacy_closing') profile = getattr(request.user, 'profile', None) if bool(getattr(profile, 'is_readonly', False)) if profile else False: messages.error(request, 'Доступ только для просмотра.') return redirect('legacy_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('legacy_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 > 60: break 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, str(e)) return redirect(f"{reverse_lazy('legacy_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_types = [x.strip() for x in self.request.GET.getlist('types') if (x or '').strip()] allowed_types = {'product', 'assembly', 'part', 'casting'} entity_types = [x for x in entity_types if x in allowed_types] if not entity_types: entity_types = ['product'] qs = ProductEntity.objects.select_related('planned_material').all() qs = qs.filter(entity_type__in=entity_types) 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_types'] = entity_types ctx['products'] = qs.order_by('entity_type', 'drawing_number', 'name') return ctx def post(self, request, *args, **kwargs): roles = get_user_roles(request.user) if not has_any_role(roles, ['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) roles = get_user_roles(self.request.user) role = primary_role(roles) ctx['user_role'] = role ctx['user_roles'] = sorted(roles) ctx['can_edit'] = has_any_role(roles, ['admin', 'technologist']) ctx['can_add_to_deal'] = has_any_role(roles, ['admin', 'technologist']) entity = get_object_or_404(ProductEntity.objects.select_related('planned_material'), pk=int(self.kwargs['pk'])) ctx['entity'] = entity lines = list( BOM.objects.select_related('child', 'child__planned_material', 'child__assembly_passport') .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 '' raw_trail = (self.request.GET.get('trail') or '').strip() trail_ids = [] if raw_trail: for part in raw_trail.split(','): part = part.strip() if part.isdigit(): trail_ids.append(int(part)) trail_ids = trail_ids[:20] bc_ids = trail_ids + [int(entity.id)] bc_objs = {x.id: x for x in ProductEntity.objects.filter(id__in=bc_ids)} breadcrumbs = [] for i, eid in enumerate(bc_ids): obj = bc_objs.get(eid) if not obj: continue t = ','.join(str(x) for x in bc_ids[:i]) url = str(reverse_lazy('product_detail', kwargs={'pk': eid})) if t: url = f"{url}?trail={t}" breadcrumbs.append({'id': eid, 'label': str(obj), 'url': url}) ctx['breadcrumbs'] = breadcrumbs back_url = '' if trail_ids: prev_id = int(trail_ids[-1]) t = ','.join(str(x) for x in trail_ids[:-1]) back_url = str(reverse_lazy('product_detail', kwargs={'pk': prev_id})) if t: back_url = f"{back_url}?trail={t}" elif ctx['parent_id']: back_url = str(reverse_lazy('product_detail', kwargs={'pk': int(ctx['parent_id'])})) ctx['back_url'] = back_url child_trail = ','.join(str(x) for x in (trail_ids + [int(entity.id)])) ctx['trail_child'] = child_trail 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): roles = get_user_roles(request.user) if not has_any_role(roles, ['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'] ctx['can_add_to_deal'] = role in ['admin', 'technologist'] entity = get_object_or_404(ProductEntity.objects.select_related('planned_material'), pk=int(self.kwargs['pk'])) ctx['entity'] = entity ctx['materials'] = list(Material.objects.select_related('category').order_by('full_name')) ctx['entity_ops'] = list( EntityOperation.objects.select_related('operation', 'operation__workshop') .filter(entity_id=entity.id) .order_by('seq', 'id') ) ctx['selected_operation_ids'] = [int(x.operation_id) for x in ctx['entity_ops'] if getattr(x, 'operation_id', None)] ctx['operations'] = list(Operation.objects.select_related('workshop').order_by('name')) next_url = (self.request.GET.get('next') or '').strip() next_safe = next_url if next_url.startswith('/') else str(reverse_lazy('products')) ctx['next'] = next_safe ctx['deals_for_add'] = list( Deal.objects.filter(status='lead').select_related('company').order_by('-id')[:200] ) raw_trail = (self.request.GET.get('trail') or '').strip() trail_ids = [] if raw_trail: for part in raw_trail.split(','): part = part.strip() if part.isdigit(): trail_ids.append(int(part)) trail_ids = trail_ids[:20] bc_ids = trail_ids + [int(entity.id)] bc_objs = {x.id: x for x in ProductEntity.objects.filter(id__in=bc_ids)} breadcrumbs = [] for i, eid in enumerate(bc_ids): obj = bc_objs.get(eid) if not obj: continue t = ','.join(str(x) for x in bc_ids[:i]) url = str(reverse_lazy('product_info', kwargs={'pk': eid})) params = {'next': next_safe} if t: params['trail'] = t url = f"{url}?{urlencode(params)}" breadcrumbs.append({'id': eid, 'label': str(obj), 'url': url}) ctx['breadcrumbs'] = breadcrumbs ctx['trail_child'] = ','.join(str(x) for x in (trail_ids + [int(entity.id)])) ctx['bom_lines'] = [] if entity.entity_type in ['product', 'assembly']: type_rank = Case( When(child__entity_type='product', then=Value(1)), When(child__entity_type='assembly', then=Value(2)), When(child__entity_type='part', then=Value(3)), When(child__entity_type='purchased', then=Value(4)), When(child__entity_type='casting', then=Value(5)), default=Value(99), output_field=IntegerField(), ) ctx['bom_lines'] = list( BOM.objects.select_related('child', 'child__planned_material', 'child__assembly_passport') .filter(parent_id=entity.id) .annotate(_type_rank=type_rank) .order_by('_type_rank', 'child__drawing_number', 'child__name', 'id') ) 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 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') trail = (request.POST.get('trail') or '').strip() if trail and not all((p.strip().isdigit() for p in trail.split(',') if p.strip())): trail = '' 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 stay_url = str(reverse_lazy('product_info', kwargs={'pk': entity.id})) params = {'next': str(next_url)} if trail: params['trail'] = trail stay_url = f"{stay_url}?{urlencode(params)}" 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 == 'bom_update_qty': if entity.entity_type not in ['product', 'assembly']: return redirect(stay_url) 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(stay_url) BOM.objects.filter(id=bom_id, parent_id=entity.id).update(quantity=qty) messages.success(request, 'Количество обновлено.') return redirect(stay_url) if action == 'bom_delete_line': if entity.entity_type not in ['product', 'assembly']: return redirect(stay_url) bom_id = parse_int(request.POST.get('bom_id')) if not bom_id: messages.error(request, 'Не выбрана строка состава.') return redirect(stay_url) BOM.objects.filter(id=bom_id, parent_id=entity.id).delete() messages.success(request, 'Компонент удалён из состава.') return redirect(stay_url) if action == 'bom_add_existing': if entity.entity_type not in ['product', 'assembly']: return redirect(stay_url) 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(stay_url) if child_id == entity.id: messages.error(request, 'Нельзя добавить сущность саму в себя.') return redirect(stay_url) if would_cycle(int(entity.id), int(child_id)): messages.error(request, 'Нельзя добавить: получится цикл в спецификации.') return redirect(stay_url) 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(stay_url) if action == 'bom_create_and_add': if entity.entity_type not in ['product', 'assembly']: return redirect(stay_url) 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(stay_url) if not name: messages.error(request, 'Заполни наименование компонента.') return redirect(stay_url) if not qty: messages.error(request, 'Заполни количество.') return redirect(stay_url) 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(stay_url) if action == 'add_entity_operation': op_id = parse_int(request.POST.get('operation_id')) if not op_id: messages.error(request, 'Выбери операцию.') return redirect(stay_url) last_seq = EntityOperation.objects.filter(entity_id=entity.id).order_by('-seq').values_list('seq', flat=True).first() seq = int(last_seq or 0) + 1 EntityOperation.objects.create(entity_id=entity.id, operation_id=op_id, seq=seq) messages.success(request, 'Операция добавлена.') return redirect(stay_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 == 'delete_entity_operation': eo_id = parse_int(request.POST.get('entity_operation_id')) if not eo_id: messages.error(request, 'Не выбрана операция.') return redirect(stay_url) EntityOperation.objects.filter(id=eo_id, entity_id=entity.id).delete() # Комментарий: после удаления перенумеровываем seq, чтобы не было дыр и конфликтов unique_together. ops = list(EntityOperation.objects.filter(entity_id=entity.id).order_by('seq', 'id')) for i, eo in enumerate(ops, start=1): if eo.seq != i: eo.seq = i eo.save(update_fields=['seq']) messages.success(request, 'Операция удалена.') return redirect(stay_url) if action == 'move_entity_operation': eo_id = parse_int(request.POST.get('entity_operation_id')) direction = (request.POST.get('direction') or '').strip() if not eo_id or direction not in ['up', 'down']: messages.error(request, 'Некорректное действие.') return redirect(stay_url) eo = EntityOperation.objects.select_related('operation').filter(id=eo_id, entity_id=entity.id).first() if not eo: messages.error(request, 'Операция не найдена.') return redirect(stay_url) ops = list(EntityOperation.objects.filter(entity_id=entity.id).order_by('seq', 'id')) idx = next((i for i, x in enumerate(ops) if x.id == eo.id), None) if idx is None: return redirect(stay_url) swap_with = idx - 1 if direction == 'up' else idx + 1 if swap_with < 0 or swap_with >= len(ops): return redirect(stay_url) a = ops[idx] b = ops[swap_with] a_seq, b_seq = int(a.seq), int(b.seq) EntityOperation.objects.filter(pk=a.id).update(seq=0) EntityOperation.objects.filter(pk=b.id).update(seq=a_seq) EntityOperation.objects.filter(pk=a.id).update(seq=b_seq) messages.success(request, 'Порядок обновлён.') return redirect(stay_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')) 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.requires_welding = bool(request.POST.get('requires_welding')) passport.requires_painting = bool(request.POST.get('requires_painting')) 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() if 'operation_ids' in request.POST: op_ids = [int(x) for x in request.POST.getlist('operation_ids') if str(x).isdigit()] op_ids = list(dict.fromkeys(op_ids)) valid = set(Operation.objects.filter(id__in=op_ids).values_list('id', flat=True)) op_ids = [int(x) for x in op_ids if int(x) in valid] EntityOperation.objects.filter(entity_id=entity.id).exclude(operation_id__in=op_ids).delete() existing = list(EntityOperation.objects.filter(entity_id=entity.id, operation_id__in=op_ids).order_by('id')) by_op = {int(eo.operation_id): eo for eo in existing} if existing: EntityOperation.objects.filter(id__in=[eo.id for eo in existing]).update(seq=0) for i, op_id in enumerate(op_ids, start=1): eo = by_op.get(int(op_id)) if eo: if int(eo.seq or 0) != i: EntityOperation.objects.filter(id=eo.id).update(seq=i) else: EntityOperation.objects.create(entity_id=entity.id, operation_id=int(op_id), seq=i) messages.success(request, 'Сохранено.') return redirect(stay_url) class WriteOffsView(LoginRequiredMixin, TemplateView): template_name = 'shiftflow/writeoffs.html' def dispatch(self, request, *args, **kwargs): roles = get_user_group_roles(request.user) if not has_any_role(roles, ['admin', 'clerk', 'observer', 'prod_head', 'director']): return redirect('registry') return super().dispatch(request, *args, **kwargs) def get_context_data(self, **kwargs): ctx = super().get_context_data(**kwargs) roles = get_user_group_roles(self.request.user) role = primary_role(roles) ctx['user_role'] = role ctx['user_roles'] = sorted(roles) ctx['can_edit'] = has_any_role(roles, ['admin', 'clerk', 'prod_head']) start_date = (self.request.GET.get('start_date') or '').strip() end_date = (self.request.GET.get('end_date') or '').strip() is_synced = (self.request.GET.get('is_synced') 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 ctx['is_synced'] = is_synced if is_synced in ['0', '1'] else '' reports_qs = ( CuttingSession.objects.select_related('machine', 'operator') .filter(is_closed=True, date__gte=start_date, date__lte=end_date) .order_by('-date', '-id') ) if is_synced in ['0', '1']: reports_qs = reports_qs.filter(is_synced_1c=bool(int(is_synced))) 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', 'remnants__material', ) ) 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') ) if is_synced in ['0', '1']: items_qs = items_qs.filter(is_synced_1c=bool(int(is_synced))) ctx['items'] = list(items_qs) return ctx def post(self, request, *args, **kwargs): roles = get_user_group_roles(request.user) if not has_any_role(roles, ['admin', 'clerk', 'prod_head']): return redirect('writeoffs') ids = request.POST.getlist('report_ids') report_ids = [int(x) for x in ids if x.isdigit()] if not report_ids: messages.error(request, 'Не выбрано ни одного производственного отчёта.') return redirect('writeoffs') updated = CuttingSession.objects.filter(id__in=report_ids, is_synced_1c=False).update( is_synced_1c=True, synced_1c_at=timezone.now(), synced_1c_by_id=request.user.id, ) messages.success(request, f'Отмечено «выгружено в 1С»: {updated}.') start_date = (request.POST.get('start_date') or '').strip() end_date = (request.POST.get('end_date') or '').strip() is_synced = (request.POST.get('is_synced') or '').strip() url = f"{reverse_lazy('writeoffs')}?start_date={start_date}&end_date={end_date}" if is_synced in ['0', '1']: url += f"&is_synced={is_synced}" return redirect(url)