Files
MES_Core/shiftflow/views.py
2026-04-03 01:10:05 +03:00

1357 lines
57 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from datetime import datetime
from urllib.parse import urlsplit
import os
import subprocess
import sys
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.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 warehouse.models import Material, MaterialCategory, SteelGrade
from .forms import ProductionTaskCreateForm
from .models import Company, Deal, DxfPreviewJob, DxfPreviewSettings, EmployeeProfile, Item, Machine, ProductionTask
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:
raise RuntimeError('Не установлены зависимости для превью DXF: ezdxf и matplotlib') 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 — превью не делаем и очищаем габариты
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,
)
dims = ''
try:
dims = _extract_dxf_dimensions(dxf_path)
except Exception:
dims = ''
filename = f"task_{task.id}_preview.png"
task.preview_image.save(filename, ContentFile(png_bytes), save=False)
task.blank_dimensions = dims
task.save(update_fields=['preview_image', 'blank_dimensions'])
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'])
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})
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': job.cancel_requested,
'last_message': job.last_message,
}
})
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 != '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:
subprocess.Popen(
[sys.executable, 'manage.py', 'dxf_preview_job', str(job.id)],
cwd=os.path.dirname(os.path.dirname(__file__)),
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
close_fds=True,
)
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',
'material_taken', 'usable_waste', 'scrap_weight'
]
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'))
self.object.material_taken = request.POST.get('material_taken', self.object.material_taken)
self.object.usable_waste = request.POST.get('usable_waste', self.object.usable_waste)
scrap_weight = request.POST.get('scrap_weight')
if scrap_weight is not None and scrap_weight != '':
try:
self.object.scrap_weight = float(scrap_weight)
except ValueError:
pass
# Действия закрытия для админа/технолога
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')
material_taken = (request.POST.get('material_taken') or '').strip()
usable_waste = (request.POST.get('usable_waste') or '').strip()
scrap_weight_raw = (request.POST.get('scrap_weight') or '').strip()
if action == 'save':
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 self.object.status != 'work':
return redirect_back()
errors = []
if not material_taken:
errors.append('Заполни поле "Взятый материал"')
if not usable_waste:
errors.append('Заполни поле "Остаток ДО"')
if scrap_weight_raw == '':
errors.append('Заполни поле "Лом (кг)" (можно 0)')
scrap_weight = None
if scrap_weight_raw != '':
try:
scrap_weight = float(scrap_weight_raw)
except ValueError:
errors.append('Поле "Лом (кг)" должно быть числом')
if errors:
context = self.get_context_data()
context['errors'] = errors
return self.render_to_response(context)
self.object.material_taken = material_taken
self.object.usable_waste = usable_waste
if scrap_weight is not None:
self.object.scrap_weight = scrap_weight
if action == 'close_done':
self.object.quantity_fact = self.object.quantity_plan
self.object.status = 'done'
self.object.save()
return redirect_back()
if action == 'close_partial':
try:
fact = int(request.POST.get('quantity_fact', '0'))
except ValueError:
fact = 0
if fact <= 0:
context = self.get_context_data()
context['errors'] = ['При частичном закрытии укажи, сколько сделано (больше 0)']
return self.render_to_response(context)
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()
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')