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