Files
MES_Core/shiftflow/views.py

6244 lines
272 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, timedelta
from urllib.parse import urlencode, urlsplit
import logging
import os
import subprocess
import sys
import threading
from pathlib import Path
from django.conf import settings as django_settings
from django.contrib import messages
from django.core.files.base import ContentFile
from django.db import close_old_connections, transaction
from django.db.models import Case, ExpressionWrapper, F, IntegerField, Max, Sum, Value, When
from django.db.models import Q
from django.db.models.functions import Coalesce
from django.http import JsonResponse
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse_lazy
from django.views import View
from django.views.generic import FormView, ListView, TemplateView, UpdateView
from django.contrib.auth.mixins import LoginRequiredMixin
from django.utils import timezone
from shiftflow.authz import get_user_group_roles, get_user_roles, primary_role, has_any_role
logger = logging.getLogger('mes')
def _reconcile_default_delivery_batch(deal_id: int) -> None:
deal_items = list(DealItem.objects.filter(deal_id=deal_id).values_list('entity_id', 'quantity'))
if not deal_items:
return
deal_due = Deal.objects.filter(id=deal_id).values_list('due_date', flat=True).first()
non_default_dates = list(
DealDeliveryBatch.objects.filter(deal_id=deal_id, is_default=False).values_list('due_date', flat=True)
)
due = max(non_default_dates) if non_default_dates else (deal_due or timezone.localdate())
default_batch, created = DealDeliveryBatch.objects.get_or_create(
deal_id=deal_id,
is_default=True,
defaults={'name': 'К закрытию', 'due_date': due},
)
upd = []
if created or default_batch.name.strip() != 'К закрытию':
default_batch.name = 'К закрытию'
upd.append('name')
if default_batch.due_date != due:
default_batch.due_date = due
upd.append('due_date')
if upd:
default_batch.save(update_fields=upd)
allocated = {
int(r['entity_id']): int(r['s'] or 0)
for r in DealBatchItem.objects.filter(batch__deal_id=deal_id, batch__is_default=False)
.values('entity_id')
.annotate(s=Coalesce(Sum('quantity'), 0))
}
current_defaults = {
int(x.entity_id): x
for x in DealBatchItem.objects.filter(batch_id=default_batch.id).select_related('entity')
}
for entity_id, qty in deal_items:
total = int(qty or 0)
used = int(allocated.get(int(entity_id), 0) or 0)
residual = total - used
if residual < 0:
residual = 0
cur = current_defaults.get(int(entity_id))
if residual <= 0:
if cur:
cur.delete()
continue
if cur:
changed = False
if int(cur.quantity or 0) != residual:
cur.quantity = residual
changed = True
if int(cur.started_qty or 0) > residual:
cur.started_qty = residual
changed = True
if changed:
cur.save(update_fields=['quantity', 'started_qty'])
else:
DealBatchItem.objects.create(batch_id=default_batch.id, entity_id=int(entity_id), quantity=residual, started_qty=0)
from manufacturing.models import (
AssemblyPassport,
BOM,
CastingPassport,
EntityOperation,
Operation,
OutsourcedPassport,
PartPassport,
ProductEntity,
PurchasedPassport,
WeldingSeam,
)
from warehouse.models import Location, Material, MaterialCategory, SteelGrade, StockItem, TransferLine, TransferRecord
from warehouse.services.transfers import receive_transfer
from shiftflow.services.closing import apply_closing, apply_closing_workitems
from shiftflow.services.bom_explosion import (
explode_deal,
explode_roots_additive,
rollback_roots_additive,
ExplosionValidationError,
_build_bom_graph,
_accumulate_requirements,
)
from shiftflow.services.kitting import (
build_kitting_requirements,
build_kitting_leaf_requirements,
get_work_location_for_workitem,
add_kitting_line,
remove_kitting_line,
get_kitting_draft,
clear_kitting_draft,
apply_kitting_draft,
)
from .forms import ProductionTaskCreateForm
from .models import (
Company,
CuttingSession,
Deal,
DealBatchItem,
DealDeliveryBatch,
DealEntityProgress,
DealItem,
DxfPreviewJob,
DxfPreviewSettings,
EmployeeProfile,
Item,
Machine,
ProductionReportConsumption,
ProductionReportRemnant,
ProductionReportStockResult,
ProductionTask,
MaterialRequirement,
ProcurementRequirement,
ShiftItem,
WorkItem,
Workshop,
)
def _get_dxf_preview_settings() -> DxfPreviewSettings:
"""Возвращает (и при необходимости создаёт) настройки превью DXF.
Мы храним настройки в БД, чтобы админ мог менять их в интерфейсе.
Ожидаем одну запись (singleton), используем pk=1.
"""
obj, _ = DxfPreviewSettings.objects.get_or_create(pk=1)
return obj
def _render_dxf_preview_png(
dxf_path: str,
*,
line_color: str,
lineweight_scaling: float,
min_lineweight_mm: float,
keep_original_colors: bool,
) -> bytes:
"""Рендерит DXF в PNG (байты) с заданными параметрами.
Зачем это нужно:
- браузер не умеет стабильно показывать DXF как "превью";
- поэтому мы генерируем PNG на сервере и уже её показываем в интерфейсе.
Требуемые зависимости:
- ezdxf
- matplotlib (backend Agg)
Если зависимости не установлены — бросаем исключение с понятным текстом.
"""
try:
# Важно: используем headless-backend, чтобы рендер работал без GUI (на сервере/в Docker).
import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt
# ezdxf читает DXF и умеет отрисовывать через drawing add-on.
from ezdxf import recover
from ezdxf.addons.drawing import RenderContext, Frontend
from ezdxf.addons.drawing.matplotlib import MatplotlibBackend
from ezdxf.addons.drawing import config as draw_config
except Exception as e:
# Важно: сюда попадают не только «пакет не установлен», но и ошибки импорта из-за системных библиотек
# (например, не хватает freetype/png в slim-образе). Поэтому сохраняем первопричину в тексте исключения.
raise RuntimeError(
f"Не удалось импортировать зависимости для превью DXF (ezdxf/matplotlib): {type(e).__name__}: {e}"
) from e
if not dxf_path or not os.path.exists(dxf_path):
raise FileNotFoundError('DXF файл не найден')
# Безопасное чтение DXF (recover умеет поднимать часть повреждённых файлов).
doc, auditor = recover.readfile(dxf_path)
if auditor and getattr(auditor, 'has_errors', False):
# Даже при ошибках структуры часто удаётся получить картинку — поэтому не прерываем.
pass
# Настройка итогового вида превью:
# - прозрачный фон (чтобы хорошо смотрелось на тёмной теме)
# - цвет/толщина линии задаются настройками (см. «Обслуживание сервера»)
# Конвертируем мм в единицы ezdxf (min_lineweight хранится в 1/300 inch).
# Формула: мм / 25.4 * 300
min_lineweight = int(max(0.0, float(min_lineweight_mm)) / 25.4 * 300)
# Конфигурация рендера: управляем толщиной линий.
cfg = draw_config.Configuration(
lineweight_scaling=float(lineweight_scaling),
min_lineweight=min_lineweight,
)
class PreviewFrontend(Frontend):
"""Переопределяет свойства сущностей перед отрисовкой.
Если keep_original_colors=True — оставляем цвета DXF.
Иначе принудительно красим все линии в заданный line_color.
"""
def override_properties(self, entity, properties) -> None:
if not keep_original_colors:
properties.color = line_color
fig = plt.figure(figsize=(5, 3), dpi=160)
ax = fig.add_axes([0, 0, 1, 1])
ax.set_axis_off()
ax.margins(0)
# Прозрачность фона фигуры и осей.
fig.patch.set_alpha(0)
ax.set_facecolor((0, 0, 0, 0))
ctx = RenderContext(doc)
out = MatplotlibBackend(ax)
PreviewFrontend(ctx, out, config=cfg).draw_layout(doc.modelspace(), finalize=True)
import io
buf = io.BytesIO()
fig.savefig(
buf,
format='png',
dpi=160,
bbox_inches='tight',
pad_inches=0.02,
transparent=True,
)
plt.close(fig)
buf.seek(0)
return buf.getvalue()
def _extract_dxf_dimensions(dxf_path: str) -> str:
"""Возвращает строку габаритов заготовки вида «300х456 мм».
Используем ezdxf.bbox.extents(), который корректно учитывает дуги/сплайны и вложенные блоки.
Пытаемся учитывать единицы: если в заголовке $INSUNITS указаны дюймы/см/м — конвертируем в мм.
"""
import ezdxf
from ezdxf import bbox as dzbbox
doc = ezdxf.readfile(dxf_path)
msp = doc.modelspace()
# Коэффициент перевода в мм по $INSUNITS
units = int(doc.header.get('$INSUNITS', 0) or 0)
factor = {1: 25.4, 4: 1.0, 5: 10.0, 6: 1000.0}.get(units, 1.0)
extent = dzbbox.extents(msp, cache=dzbbox.Cache())
if not extent.has_data:
return ''
(min_x, min_y, _), (max_x, max_y, _) = extent.min, extent.max
width = (max_x - min_x) * factor
height = (max_y - min_y) * factor
return f"{round(width, 3)}х{round(height, 3)} мм"
def _update_task_preview(task: ProductionTask) -> bool:
"""Обновляет превью PNG и габариты из DXF для одной детали.
Использует текущие настройки из DxfPreviewSettings.
Важно: функция может выполняться "тяжело" (рендер + bbox), поэтому её удобно
вызывать из фонового потока, чтобы не блокировать HTTP-ответ.
"""
if not task.drawing_file:
return False
name = (task.drawing_file.name or '').lower()
if not name.endswith('.dxf'):
# Если не DXF — превью не делаем: удаляем старый PNG (если был) и очищаем габариты.
try:
if task.preview_image:
task.preview_image.delete(save=False)
except Exception:
pass
task.preview_image = None
task.blank_dimensions = ''
task.save(update_fields=['preview_image', 'blank_dimensions'])
return False
dxf_path = getattr(task.drawing_file, 'path', '')
settings = _get_dxf_preview_settings()
png_bytes = _render_dxf_preview_png(
dxf_path,
line_color=settings.line_color,
lineweight_scaling=settings.lineweight_scaling,
min_lineweight_mm=settings.min_lineweight,
keep_original_colors=settings.keep_original_colors,
)
# Временно отключаем вычисление габаритов (bbox), чтобы исключить его влияние на стабильность.
# Превью PNG генерируем как и раньше.
# Перед сохранением удаляем старое превью, иначе FileSystemStorage добавляет суффиксы
# и папка с превью постепенно «засоряется».
try:
if task.preview_image:
task.preview_image.delete(save=False)
except Exception:
pass
filename = f"task_{task.id}_preview.png"
task.preview_image.save(filename, ContentFile(png_bytes), save=False)
task.save(update_fields=['preview_image'])
return True
def _update_entity_preview(entity: ProductEntity) -> bool:
if not entity.dxf_file:
return False
name = (entity.dxf_file.name or '').lower()
if not name.endswith('.dxf'):
try:
if entity.preview:
entity.preview.delete(save=False)
except Exception:
pass
entity.preview = None
entity.save(update_fields=['preview'])
return False
dxf_path = getattr(entity.dxf_file, 'path', '')
settings = _get_dxf_preview_settings()
png_bytes = _render_dxf_preview_png(
dxf_path,
line_color=settings.line_color,
lineweight_scaling=settings.lineweight_scaling,
min_lineweight_mm=settings.min_lineweight,
keep_original_colors=settings.keep_original_colors,
)
try:
if entity.preview:
entity.preview.delete(save=False)
except Exception:
pass
filename = f"entity_{entity.id}_preview.png"
entity.preview.save(filename, ContentFile(png_bytes), save=False)
entity.save(update_fields=['preview'])
return True
# Класс главной страницы (роутер)
class IndexView(TemplateView):
template_name = 'shiftflow/landing.html'
def get(self, request, *args, **kwargs):
# Если юзер авторизован — сразу отправляем его в реестр
if request.user.is_authenticated:
return redirect('registry')
# Если нет — показываем кнопку "Войти"
return super().get(request, *args, **kwargs)
# Класс реестра деталей (защищен LoginRequiredMixin)
class RegistryView(LoginRequiredMixin, ListView):
model = Item
template_name = 'shiftflow/registry.html'
context_object_name = 'items'
def dispatch(self, request, *args, **kwargs):
roles = get_user_group_roles(request.user)
if not has_any_role(roles, ['admin', 'operator', 'master', 'technologist', 'clerk', 'prod_head', 'director', 'observer']):
return redirect('index')
return super().dispatch(request, *args, **kwargs)
def get_queryset(self):
queryset = Item.objects.select_related('task', 'task__deal', 'task__material', 'machine')
user = self.request.user
profile = getattr(user, 'profile', None)
roles = get_user_group_roles(user)
role = primary_role(roles)
# Флаг, что фильтрация была применена через форму. Если нет — используем дефолты
filtered = self.request.GET.get('filtered')
# Принудительный сброс фильтров (?reset=1) — ведёт себя как первый заход на страницу
reset = self.request.GET.get('reset')
# Станки
m_ids = self.request.GET.getlist('m_ids')
if filtered and role != 'operator' and not m_ids:
return queryset.none()
if m_ids:
queryset = queryset.filter(machine_id__in=m_ids)
# Статусы (+ агрегат "closed" = done+partial)
statuses = self.request.GET.getlist('statuses')
if filtered and not statuses:
return queryset.none()
if statuses:
expanded = []
for s in statuses:
if s == 'closed':
expanded += ['done', 'partial']
else:
expanded.append(s)
queryset = queryset.filter(status__in=expanded)
# Диапазон дат, задаваемый пользователем. Если фильтры не активны или явно указан reset=1 — используем дефолты
start_date = self.request.GET.get('start_date')
end_date = self.request.GET.get('end_date')
# Дефолтный режим: последние 7 дней и только статус "В работе"
is_default = (not filtered) or bool(reset)
if is_default:
today = timezone.localdate()
week_ago = today - timezone.timedelta(days=7)
queryset = queryset.filter(date__gte=week_ago, date__lte=today, status__in=['work'])
else:
# Пользователь указал фильтры вручную — применяем их как есть
if start_date:
queryset = queryset.filter(date__gte=start_date)
if end_date:
queryset = queryset.filter(date__lte=end_date)
# Ограничения по ролям
if role == 'operator':
user_machines = profile.machines.all() if profile else Machine.objects.none()
queryset = queryset.filter(machine__in=user_machines)
if not filtered:
queryset = queryset.filter(status='work')
elif role == 'master' and not filtered:
queryset = queryset.filter(status='work')
return queryset.order_by('status', '-date', 'machine__name', 'task__deal__number')
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
user = self.request.user
profile = getattr(user, 'profile', None)
roles = get_user_group_roles(user)
role = primary_role(roles)
context['user_role'] = role
context['user_roles'] = sorted(roles)
context['is_readonly'] = bool(getattr(profile, 'is_readonly', False)) if profile else False
allowed_ws = list(profile.allowed_workshops.values_list('id', flat=True)) if profile else []
context['allowed_workshop_ids'] = allowed_ws
machines = Machine.objects.filter(machine_type__in=['linear', 'sheet']).order_by('name')
context['machines'] = machines
filtered = self.request.GET.get('filtered')
reset = self.request.GET.get('reset')
# Дефолтное состояние формы фильтра: все станки включены, статус "В работе",
# период от сегодня7 до сегодня. Совпадает с серверной выборкой выше
if (not filtered) or reset:
today = timezone.localdate()
week_ago = today - timezone.timedelta(days=7)
context['start_date'] = week_ago.strftime('%Y-%m-%d')
context['end_date'] = today.strftime('%Y-%m-%d')
context['selected_statuses'] = ['work']
context['selected_machines'] = [m.id for m in machines]
context['all_selected_machines'] = True
else:
context['selected_machines'] = [int(i) for i in self.request.GET.getlist('m_ids') if i.isdigit()]
context['selected_statuses'] = self.request.GET.getlist('statuses')
context['start_date'] = self.request.GET.get('start_date', '')
context['end_date'] = self.request.GET.get('end_date', '')
context['all_selected_machines'] = False
items = list(context.get('items') or [])
for it in items:
plan = int(it.quantity_plan or 0)
fact = int(it.quantity_fact or 0)
if plan > 0:
fact_pct = int(round(fact * 100 / plan))
else:
fact_pct = 0
it.fact_pct = fact_pct
it.fact_width = max(0, min(100, fact_pct))
it.fact_bar_class = 'bg-success' if it.status in ['done', 'partial'] else 'bg-warning'
context['items'] = items
work_qs = WorkItem.objects.select_related('deal', 'entity', 'entity__planned_material', 'operation', 'machine', 'workshop')
m_ids = [int(i) for i in self.request.GET.getlist('m_ids') if str(i).isdigit()]
if m_ids:
ws_ids = list(
Machine.objects.filter(id__in=m_ids)
.exclude(workshop_id__isnull=True)
.values_list('workshop_id', flat=True)
)
work_qs = work_qs.filter(Q(machine_id__in=m_ids) | Q(machine_id__isnull=True, workshop_id__in=ws_ids))
filtered = self.request.GET.get('filtered')
reset = self.request.GET.get('reset')
is_default = (not filtered) or bool(reset)
if is_default:
today = timezone.localdate()
week_ago = today - timezone.timedelta(days=7)
work_qs = work_qs.filter(date__gte=week_ago, date__lte=today)
else:
if context.get('start_date'):
work_qs = work_qs.filter(date__gte=context['start_date'])
if context.get('end_date'):
work_qs = work_qs.filter(date__lte=context['end_date'])
statuses = self.request.GET.getlist('statuses')
if is_default:
work_qs = work_qs.filter(status__in=['planned'])
else:
if not statuses:
work_qs = work_qs.none()
else:
expanded = []
for s in statuses:
if s == 'work':
expanded += ['planned']
elif s == 'leftover':
expanded.append('leftover')
elif s == 'closed':
expanded.append('done')
if expanded:
work_qs = work_qs.filter(status__in=expanded)
if role == 'operator':
user_machines = profile.machines.all() if profile else Machine.objects.none()
work_qs = work_qs.filter(machine__in=user_machines)
elif role == 'master':
allowed_ws = list(profile.allowed_workshops.values_list('id', flat=True)) if profile else []
if allowed_ws:
work_qs = work_qs.filter(Q(machine__workshop_id__in=allowed_ws) | Q(machine_id__isnull=True, workshop_id__in=allowed_ws))
workitems = list(work_qs.order_by('-date', 'deal__number', 'id')[:2000])
for wi in workitems:
plan = int(wi.quantity_plan or 0)
done = int(wi.quantity_done or 0)
if plan > 0:
pct = int(round(done * 100 / plan))
else:
pct = 0
wi.fact_pct = pct
wi.fact_width = max(0, min(100, pct))
context['workitems'] = workitems
return context
class LegacyRegistryView(RegistryView):
template_name = 'shiftflow/legacy_registry.html'
def dispatch(self, request, *args, **kwargs):
roles = get_user_group_roles(request.user)
if not has_any_role(roles, ['admin', 'master', 'technologist', 'clerk', 'operator', 'prod_head', 'director', 'observer']):
return redirect('registry')
return super().dispatch(request, *args, **kwargs)
class WeldingPlanAddView(LoginRequiredMixin, View):
def post(self, request, *args, **kwargs):
profile = getattr(request.user, 'profile', None)
role = profile.role if profile else ('admin' if request.user.is_superuser else 'operator')
is_readonly = bool(getattr(profile, 'is_readonly', False)) if profile else False
if role not in ['admin', 'technologist', 'master', 'clerk', 'manager', 'observer']:
return redirect('planning')
if is_readonly:
messages.error(request, 'Доступ только для просмотра.')
return redirect('planning')
def parse_int(s):
s = (s or '').strip()
return int(s) if s.isdigit() else None
deal_id = parse_int(request.POST.get('deal_id'))
entity_id = parse_int(request.POST.get('entity_id'))
qty = parse_int(request.POST.get('quantity'))
workshop_id = parse_int(request.POST.get('workshop_id'))
allowed_ws = set(profile.allowed_workshops.values_list('id', flat=True)) if profile else set()
if not (deal_id and entity_id and qty and qty > 0):
messages.error(request, 'Заполни сущность и количество для сварки.')
next_url = (request.POST.get('next') or '').strip()
return redirect(next_url if next_url.startswith('/') else 'planning')
di = DealItem.objects.filter(deal_id=deal_id, entity_id=entity_id).first()
if not di:
messages.error(request, 'Позиция сделки не найдена.')
next_url = (request.POST.get('next') or '').strip()
return redirect(next_url if next_url.startswith('/') else 'planning')
# Комментарий: берём текущую операцию по маршруту детали/сборки.
cur = DealEntityProgress.objects.filter(deal_id=deal_id, entity_id=entity_id).values_list('current_seq', flat=True).first()
cur = int(cur or 1)
eo = EntityOperation.objects.select_related('operation').filter(entity_id=entity_id, seq=cur).first()
op = eo.operation if eo else Operation.objects.filter(code='welding').first()
if not op:
messages.error(request, 'Не найдена операция welding. Создай её в справочнике операций.')
next_url = (request.POST.get('next') or '').strip()
return redirect(next_url if next_url.startswith('/') else 'planning')
if eo and op.code != 'welding':
messages.error(request, f"Текущая операция по маршруту: {op.name}. Нельзя поставить в план сварки.")
next_url = (request.POST.get('next') or '').strip()
return redirect(next_url if next_url.startswith('/') else 'planning')
if not workshop_id and op.workshop_id:
workshop_id = int(op.workshop_id)
if allowed_ws:
if not workshop_id:
messages.error(request, 'Выбери цех.')
next_url = (request.POST.get('next') or '').strip()
return redirect(next_url if next_url.startswith('/') else 'planning')
if workshop_id not in allowed_ws:
messages.error(request, 'Нет доступа к выбранному цеху.')
next_url = (request.POST.get('next') or '').strip()
return redirect(next_url if next_url.startswith('/') else 'planning')
# Комментарий: не даём планировать сварку сверх заказа в сделке.
planned = WorkItem.objects.filter(deal_id=deal_id, entity_id=entity_id).filter(Q(operation__code='welding') | Q(stage='welding')).aggregate(s=Coalesce(Sum('quantity_plan'), 0))['s']
remaining = int(di.quantity or 0) - int(planned or 0)
if qty > remaining:
messages.error(request, f'Нельзя добавить {qty} шт: осталось {max(0, remaining)} шт.')
next_url = (request.POST.get('next') or '').strip()
return redirect(next_url if next_url.startswith('/') else 'planning')
WorkItem.objects.create(
deal_id=deal_id,
entity_id=entity_id,
operation_id=op.id,
stage='welding',
workshop_id=(workshop_id if workshop_id else None),
quantity_plan=qty,
status='planned',
date=timezone.localdate(),
)
messages.success(request, 'Добавлено в план сварки.')
next_url = (request.POST.get('next') or '').strip()
return redirect(next_url if next_url.startswith('/') else 'planning')
class PaintingPlanAddView(LoginRequiredMixin, View):
def post(self, request, *args, **kwargs):
profile = getattr(request.user, 'profile', None)
role = profile.role if profile else ('admin' if request.user.is_superuser else 'operator')
is_readonly = bool(getattr(profile, 'is_readonly', False)) if profile else False
if role not in ['admin', 'technologist', 'master', 'clerk', 'manager', 'observer']:
return redirect('planning')
if is_readonly:
messages.error(request, 'Доступ только для просмотра.')
return redirect('planning')
def parse_int(s):
s = (s or '').strip()
return int(s) if s.isdigit() else None
deal_id = parse_int(request.POST.get('deal_id'))
entity_id = parse_int(request.POST.get('entity_id'))
qty = parse_int(request.POST.get('quantity'))
workshop_id = parse_int(request.POST.get('workshop_id'))
allowed_ws = set(profile.allowed_workshops.values_list('id', flat=True)) if profile else set()
if allowed_ws:
if not workshop_id:
messages.error(request, 'Выбери цех.')
next_url = (request.POST.get('next') or '').strip()
return redirect(next_url if next_url.startswith('/') else 'planning')
if workshop_id not in allowed_ws:
messages.error(request, 'Нет доступа к выбранному цеху.')
next_url = (request.POST.get('next') or '').strip()
return redirect(next_url if next_url.startswith('/') else 'planning')
if not (deal_id and entity_id and qty and qty > 0):
messages.error(request, 'Заполни сущность и количество для покраски.')
next_url = (request.POST.get('next') or '').strip()
return redirect(next_url if next_url.startswith('/') else 'planning')
di = DealItem.objects.filter(deal_id=deal_id, entity_id=entity_id).first()
if not di:
messages.error(request, 'Позиция сделки не найдена.')
next_url = (request.POST.get('next') or '').strip()
return redirect(next_url if next_url.startswith('/') else 'planning')
# Комментарий: покраску можно планировать только на то, что реально сварено.
# Доступно к покраске = min(заказано, сварено) уже в плане покраски.
# Комментарий: берём текущую операцию по маршруту детали/сборки.
cur = DealEntityProgress.objects.filter(deal_id=deal_id, entity_id=entity_id).values_list('current_seq', flat=True).first()
cur = int(cur or 1)
eo = EntityOperation.objects.select_related('operation').filter(entity_id=entity_id, seq=cur).first()
op = eo.operation if eo else Operation.objects.filter(code='painting').first()
if not op:
messages.error(request, 'Не найдена операция painting. Создай её в справочнике операций.')
next_url = (request.POST.get('next') or '').strip()
return redirect(next_url if next_url.startswith('/') else 'planning')
if eo and op.code != 'painting':
messages.error(request, f"Текущая операция по маршруту: {op.name}. Нельзя поставить в план покраски.")
next_url = (request.POST.get('next') or '').strip()
return redirect(next_url if next_url.startswith('/') else 'planning')
if not workshop_id and op.workshop_id:
workshop_id = int(op.workshop_id)
allowed_ws = set(profile.allowed_workshops.values_list('id', flat=True)) if profile else set()
if allowed_ws:
if not workshop_id:
messages.error(request, 'Выбери цех.')
next_url = (request.POST.get('next') or '').strip()
return redirect(next_url if next_url.startswith('/') else 'planning')
if workshop_id not in allowed_ws:
messages.error(request, 'Нет доступа к выбранному цеху.')
next_url = (request.POST.get('next') or '').strip()
return redirect(next_url if next_url.startswith('/') else 'planning')
# Комментарий: покраску можно планировать только на то, что реально сварено.
welded_done = WorkItem.objects.filter(deal_id=deal_id, entity_id=entity_id).filter(Q(operation__code='welding') | Q(stage='welding')).aggregate(s=Coalesce(Sum('quantity_done'), 0))['s']
painting_planned = WorkItem.objects.filter(deal_id=deal_id, entity_id=entity_id).filter(Q(operation__code='painting') | Q(stage='painting')).aggregate(s=Coalesce(Sum('quantity_plan'), 0))['s']
max_paintable = min(int(di.quantity or 0), int(welded_done or 0)) - int(painting_planned or 0)
if qty > max_paintable:
messages.error(request, f'Нельзя добавить {qty} шт: доступно {max(0, max_paintable)} шт.')
next_url = (request.POST.get('next') or '').strip()
return redirect(next_url if next_url.startswith('/') else 'planning')
WorkItem.objects.create(
deal_id=deal_id,
entity_id=entity_id,
operation_id=op.id,
stage='painting',
workshop_id=(workshop_id if workshop_id else None),
quantity_plan=qty,
status='planned',
date=timezone.localdate(),
)
messages.success(request, 'Добавлено в план покраски.')
next_url = (request.POST.get('next') or '').strip()
return redirect(next_url if next_url.startswith('/') else 'planning')
class WorkItemUpdateView(LoginRequiredMixin, View):
def post(self, request, *args, **kwargs):
profile = getattr(request.user, 'profile', None)
roles = get_user_roles(request.user)
role = primary_role(roles)
is_readonly = bool(getattr(profile, 'is_readonly', False)) if profile else False
edit_roles = ['admin', 'technologist', 'master', 'operator', 'prod_head']
if not has_any_role(roles, edit_roles):
return redirect('planning')
if is_readonly:
messages.error(request, 'Доступ только для просмотра.')
return redirect('planning')
def parse_int(s):
s = (s or '').strip()
return int(s) if s.isdigit() else None
wi_id = parse_int(request.POST.get('workitem_id'))
action = (request.POST.get('action') or '').strip()
next_url = (request.POST.get('next') or '').strip()
next_url = next_url if next_url.startswith('/') else str(reverse_lazy('planning'))
wi = None
if wi_id:
wi = WorkItem.objects.filter(pk=wi_id).first()
if not wi:
messages.error(request, 'Запись плана не найдена.')
return redirect(next_url)
allowed_ws = set(profile.allowed_workshops.values_list('id', flat=True)) if profile else set()
if role == 'operator':
user_machines = profile.machines.all() if profile else Machine.objects.none()
user_machine_ids = set(user_machines.values_list('id', flat=True))
user_ws_ids = set(
Machine.objects.filter(id__in=list(user_machine_ids))
.exclude(workshop_id__isnull=True)
.values_list('workshop_id', flat=True)
)
if wi.machine_id:
if wi.machine_id not in user_machine_ids:
messages.error(request, 'Нет доступа к заданию на другом станке.')
return redirect(next_url)
else:
if wi.workshop_id and wi.workshop_id not in user_ws_ids:
messages.error(request, 'Нет доступа к заданию из другого цеха.')
return redirect(next_url)
allowed_ws = user_ws_ids
if allowed_ws and wi.workshop_id and wi.workshop_id not in allowed_ws:
messages.error(request, 'Нет доступа к записи плана из другого цеха.')
return redirect(next_url)
if action == 'delete':
wi.delete()
messages.success(request, 'Запись плана удалена.')
return redirect(next_url)
qty_plan = parse_int(request.POST.get('quantity_plan'))
qty_done = parse_int(request.POST.get('quantity_done'))
workshop_id = parse_int(request.POST.get('workshop_id'))
machine_id = parse_int(request.POST.get('machine_id'))
date_raw = (request.POST.get('date') or '').strip()
workitem_status = (request.POST.get('workitem_status') or '').strip()
comment = (request.POST.get('comment') or '').strip()
changed_fields = []
# Комментарий: правка плана/факта должна оставаться в рамках потребности сделки.
# Это защищает от ситуации, когда вручную «перепланировали» больше, чем заказано.
deal_item = DealItem.objects.filter(deal_id=wi.deal_id, entity_id=wi.entity_id).first()
ordered_qty = int(deal_item.quantity) if deal_item else None
if role == 'operator':
qty_plan = None
workshop_id = None
machine_id = None
date_raw = ''
workitem_status = ''
if role == 'master':
workitem_status = ''
if qty_plan is not None and qty_plan >= 0:
if ordered_qty is not None and wi.stage == 'welding':
other = WorkItem.objects.filter(deal_id=wi.deal_id, entity_id=wi.entity_id, stage='welding').exclude(pk=wi.id).aggregate(s=Coalesce(Sum('quantity_plan'), 0))['s']
max_for_row = max(0, ordered_qty - int(other or 0))
if qty_plan > max_for_row:
messages.error(request, f'Нельзя поставить в план {qty_plan} шт: максимум {max_for_row} шт.')
return redirect(next_url)
if ordered_qty is not None and wi.stage == 'painting':
welded_done = WorkItem.objects.filter(deal_id=wi.deal_id, entity_id=wi.entity_id, stage='welding').aggregate(s=Coalesce(Sum('quantity_done'), 0))['s']
other = WorkItem.objects.filter(deal_id=wi.deal_id, entity_id=wi.entity_id, stage='painting').exclude(pk=wi.id).aggregate(s=Coalesce(Sum('quantity_plan'), 0))['s']
max_paintable = max(0, min(ordered_qty, int(welded_done or 0)) - int(other or 0))
if qty_plan > max_paintable:
messages.error(request, f'Нельзя поставить в план {qty_plan} шт: доступно {max_paintable} шт.')
return redirect(next_url)
wi.quantity_plan = qty_plan
changed_fields.append('quantity_plan')
if qty_done is not None and qty_done >= 0:
# Комментарий: факт не должен превышать план по строке, иначе ломается «доступно к покраске».
plan_val = int((qty_plan if qty_plan is not None else wi.quantity_plan) or 0)
if plan_val > 0 and qty_done > plan_val:
messages.error(request, f'Факт ({qty_done}) не может быть больше плана ({plan_val}).')
return redirect(next_url)
wi.quantity_done = qty_done
changed_fields.append('quantity_done')
if machine_id is not None and role in ['admin', 'technologist', 'master']:
wi.machine_id = machine_id
changed_fields.append('machine')
if date_raw and role in ['admin', 'technologist']:
try:
wi.date = datetime.strptime(date_raw, '%Y-%m-%d').date()
changed_fields.append('date')
except Exception:
pass
fixed_workshop_id = None
if getattr(wi, 'operation_id', None):
fixed_workshop_id = Operation.objects.filter(id=wi.operation_id).values_list('workshop_id', flat=True).first()
if fixed_workshop_id:
fixed_workshop_id = int(fixed_workshop_id)
if wi.workshop_id != fixed_workshop_id:
wi.workshop_id = fixed_workshop_id
changed_fields.append('workshop')
else:
if workshop_id is not None:
wi.workshop_id = workshop_id
changed_fields.append('workshop')
elif 'workshop_id' in request.POST and role in ['admin', 'technologist', 'master', 'clerk', 'manager']:
wi.workshop_id = None
changed_fields.append('workshop')
if workitem_status and role in ['admin', 'technologist']:
allowed = {k for k, _ in WorkItem.STATUS_CHOICES}
if workitem_status in allowed:
wi.status = workitem_status
changed_fields.append('status')
if 'comment' in request.POST and role in ['admin', 'technologist', 'master']:
wi.comment = comment
changed_fields.append('comment')
if not changed_fields and not workitem_status:
messages.error(request, 'Нет данных для обновления.')
return redirect(next_url)
if not (role in ['admin', 'technologist'] and workitem_status in {k for k, _ in WorkItem.STATUS_CHOICES}):
plan = int(wi.quantity_plan or 0)
done = int(wi.quantity_done or 0)
if plan > 0 and done >= plan:
wi.status = 'done'
changed_fields.append('status')
elif done > 0:
wi.status = 'planned'
changed_fields.append('status')
else:
wi.status = 'planned'
changed_fields.append('status')
wi.save(update_fields=list(dict.fromkeys(changed_fields)))
# Комментарий: автоматический переход на следующую операцию по маршруту для пары (сделка, сущность).
# Сдвигаем только когда выполнено количество по позиции сделки.
if ordered_qty is not None:
op_code = None
if getattr(wi, 'operation_id', None):
op_code = Operation.objects.filter(pk=wi.operation_id).values_list('code', flat=True).first()
if not op_code:
op_code = (wi.stage or '').strip()
if op_code:
progress, _ = DealEntityProgress.objects.get_or_create(deal_id=wi.deal_id, entity_id=wi.entity_id, defaults={'current_seq': 1})
cur = int(progress.current_seq or 1)
cur_eo = EntityOperation.objects.select_related('operation').filter(entity_id=wi.entity_id, seq=cur).first()
if cur_eo and cur_eo.operation and cur_eo.operation.code == op_code:
total_done = WorkItem.objects.filter(deal_id=wi.deal_id, entity_id=wi.entity_id).filter(Q(operation__code=op_code) | Q(stage=op_code)).aggregate(s=Coalesce(Sum('quantity_done'), 0))['s']
if int(total_done or 0) >= int(ordered_qty):
progress.current_seq = cur + 1
progress.save(update_fields=['current_seq'])
messages.success(request, 'Обновлено.')
return redirect(next_url)
class PlanningStagesView(LoginRequiredMixin, TemplateView):
template_name = 'shiftflow/planning_stages.html'
def dispatch(self, request, *args, **kwargs):
roles = get_user_roles(request.user)
if not has_any_role(roles, ['admin', 'technologist', 'master', 'clerk', 'manager', 'observer', 'prod_head', 'director']):
return redirect('registry')
return super().dispatch(request, *args, **kwargs)
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
profile = getattr(self.request.user, 'profile', None)
roles = get_user_roles(self.request.user)
role = primary_role(roles)
ctx['user_role'] = role
ctx['user_roles'] = sorted(roles)
ctx['is_readonly'] = bool(getattr(profile, 'is_readonly', False)) if profile else False
q = (self.request.GET.get('q') or '').strip()
deals_qs = Deal.objects.select_related('company').filter(status='work')
if q:
deals_qs = deals_qs.filter(Q(number__icontains=q) | Q(company__name__icontains=q))
deals = list(deals_qs.order_by('due_date', '-id'))
ctx['q'] = q
if not deals:
ctx['deal_cards'] = []
return ctx
deal_ids = [d.id for d in deals]
deal_items = list(
DealItem.objects.select_related('deal', 'entity')
.filter(deal_id__in=deal_ids, entity__entity_type__in=['product', 'assembly', 'part'])
.order_by('deal__due_date', 'deal__number', 'entity__drawing_number', 'entity__name', 'id')
)
entity_ids = sorted({int(x.entity_id) for x in deal_items})
entity_ops = list(
EntityOperation.objects.select_related('operation')
.filter(entity_id__in=entity_ids)
.order_by('entity_id', 'seq', 'id')
)
route_codes = {}
last_code = {}
op_meta = {}
for eo in entity_ops:
if not eo.operation_id or not eo.operation:
continue
code = (eo.operation.code or '').strip()
if not code:
continue
route_codes.setdefault(int(eo.entity_id), []).append(code)
m = op_meta.get(code)
if not m:
op_meta[code] = {
'code': code,
'name': (eo.operation.name or code),
'min_seq': int(eo.seq or 0) or 0,
}
else:
cur = int(m.get('min_seq') or 0)
seq = int(eo.seq or 0) or 0
if cur == 0 or (seq > 0 and seq < cur):
m['min_seq'] = seq
for eid, codes in route_codes.items():
if codes:
last_code[eid] = codes[-1]
op_columns = sorted(op_meta.values(), key=lambda x: (int(x.get('min_seq') or 0), str(x.get('name') or ''), str(x.get('code') or '')))
ctx['op_columns'] = op_columns
wi_qs = (
WorkItem.objects.select_related('operation')
.filter(deal_id__in=deal_ids, entity_id__in=entity_ids)
)
done_by = {}
done_total_by_entity = {}
for wi in wi_qs:
op_code = ''
if getattr(wi, 'operation_id', None) and getattr(wi, 'operation', None):
op_code = (wi.operation.code or '').strip()
if not op_code:
op_code = (wi.stage or '').strip()
if not op_code:
continue
did = int(wi.deal_id)
eid = int(wi.entity_id)
k = (did, eid, op_code)
done_by[k] = done_by.get(k, 0) + int(wi.quantity_done or 0)
done_total_by_entity[(did, eid)] = done_total_by_entity.get((did, eid), 0) + int(wi.quantity_done or 0)
ship_loc = (
Location.objects.filter(Q(name__icontains='отгруж') | Q(name__icontains='отгруз'))
.order_by('id')
.first()
)
shipped_by = {}
if ship_loc:
for r in (
StockItem.objects.filter(
is_archived=False,
location_id=ship_loc.id,
deal_id__in=deal_ids,
entity_id__in=entity_ids,
)
.values('deal_id', 'entity_id')
.annotate(s=Coalesce(Sum('quantity'), 0.0))
):
shipped_by[(int(r['deal_id']), int(r['entity_id']))] = int(r['s'] or 0)
def pct(val, total):
if int(total or 0) <= 0:
return 0
return max(0, min(100, int(round((int(val or 0) * 100) / int(total)))))
items_by_deal = {}
for di in deal_items:
did = int(di.deal_id)
eid = int(di.entity_id)
need = int(di.quantity or 0)
codes = set(route_codes.get(eid) or [])
last = last_code.get(eid)
op_cells = []
for col in op_columns:
code = col.get('code')
if code and code in codes:
done = int(done_by.get((did, eid, str(code)), 0) or 0)
done_val = min(need, done)
op_cells.append({
'code': str(code),
'done': done_val,
'pct': pct(done_val, need),
'has': True,
})
else:
op_cells.append({'code': str(code or ''), 'done': 0, 'pct': 0, 'has': False})
ready = int(done_by.get((did, eid, last), 0)) if last else int(done_total_by_entity.get((did, eid), 0))
shipped = int(shipped_by.get((did, eid), 0))
ready_val = min(need, int(ready))
shipped_val = min(need, int(shipped))
items_by_deal.setdefault(did, []).append({
'entity': di.entity,
'need': need,
'op_cells': op_cells,
'ready': ready_val,
'ready_pct': pct(ready_val, need),
'shipped': shipped_val,
'shipped_pct': pct(shipped_val, need),
})
deal_cards = []
for d in deals:
rows = items_by_deal.get(int(d.id)) or []
if not rows:
continue
deal_cards.append({
'deal': d,
'rows': rows,
})
ctx['deal_cards'] = deal_cards
return ctx
class RegistryPrintView(LoginRequiredMixin, TemplateView):
template_name = 'shiftflow/registry_print.html'
def dispatch(self, request, *args, **kwargs):
roles = get_user_group_roles(request.user)
if not has_any_role(roles, ['admin', 'operator', 'master', 'technologist', 'clerk', 'prod_head', 'director', 'observer']):
return redirect('index')
return super().dispatch(request, *args, **kwargs)
class WorkItemRegistryPrintView(LoginRequiredMixin, TemplateView):
template_name = 'shiftflow/registry_workitems_print.html'
def dispatch(self, request, *args, **kwargs):
roles = get_user_group_roles(request.user)
if not has_any_role(roles, ['admin', 'operator', 'master', 'technologist', 'clerk', 'prod_head', 'director', 'observer']):
return redirect('index')
return super().dispatch(request, *args, **kwargs)
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
user = self.request.user
profile = getattr(user, 'profile', None)
roles = get_user_group_roles(user)
role = primary_role(roles)
ctx['user_role'] = role
ctx['user_roles'] = sorted(roles)
qs = WorkItem.objects.select_related('deal', 'entity', 'entity__planned_material', 'operation', 'machine', 'workshop')
m_ids = [int(i) for i in self.request.GET.getlist('m_ids') if str(i).isdigit()]
if m_ids:
qs = qs.filter(machine_id__in=m_ids)
start_date = (self.request.GET.get('start_date') or '').strip()
end_date = (self.request.GET.get('end_date') or '').strip()
if start_date:
qs = qs.filter(date__gte=start_date)
if end_date:
qs = qs.filter(date__lte=end_date)
statuses = self.request.GET.getlist('statuses')
filtered = (self.request.GET.get('filtered') or '').strip()
if filtered and not statuses:
qs = qs.none()
elif not statuses:
qs = qs.filter(status__in=['planned'])
else:
expanded = []
for s in statuses:
if s == 'work':
expanded += ['planned']
elif s == 'leftover':
expanded.append('leftover')
elif s == 'closed':
expanded.append('done')
if expanded:
qs = qs.filter(status__in=expanded)
if role == 'operator':
user_machines = profile.machines.all() if profile else Machine.objects.none()
user_machine_ids = list(user_machines.values_list('id', flat=True))
user_ws_ids = list(
Machine.objects.filter(id__in=user_machine_ids)
.exclude(workshop_id__isnull=True)
.values_list('workshop_id', flat=True)
)
allowed_ws = list(profile.allowed_workshops.values_list('id', flat=True)) if profile else []
ws_ids = list({int(x) for x in (user_ws_ids + allowed_ws) if x})
qs = qs.filter(Q(machine_id__in=user_machine_ids) | Q(machine_id__isnull=True, workshop_id__in=ws_ids))
rows = list(qs.order_by('workshop__name', 'machine__name', 'date', 'deal__number', 'id'))
groups = {}
for wi in rows:
ws_label = wi.workshop.name if wi.workshop else ''
m_label = wi.machine.name if wi.machine else ''
key = (ws_label, m_label)
g = groups.get(key)
if not g:
g = {'workshop': ws_label, 'machine': m_label, 'items': []}
groups[key] = g
g['items'].append(wi)
ctx['groups'] = list(groups.values())
return ctx
class WorkItemEntityListView(LoginRequiredMixin, TemplateView):
template_name = 'shiftflow/workitem_entity_list.html'
def dispatch(self, request, *args, **kwargs):
roles = get_user_roles(request.user)
if not has_any_role(roles, ['admin', 'technologist', 'master', 'clerk', 'operator', 'observer', 'prod_head', 'director']):
return redirect('registry')
return super().dispatch(request, *args, **kwargs)
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
profile = getattr(self.request.user, 'profile', None)
roles = get_user_roles(self.request.user)
role = primary_role(roles)
ctx['user_role'] = role
ctx['user_roles'] = sorted(roles)
ctx['can_edit_entity'] = 'admin' in roles or 'technologist' in roles
deal_id = int(self.kwargs['deal_id'])
entity_id = int(self.kwargs['entity_id'])
deal = get_object_or_404(Deal, pk=deal_id)
entity = get_object_or_404(ProductEntity.objects.select_related('planned_material'), pk=entity_id)
qs = WorkItem.objects.select_related('deal', 'entity', 'entity__planned_material', 'operation', 'machine', 'workshop').filter(
deal_id=deal_id,
entity_id=entity_id,
)
rows = list(qs.order_by('-date', '-id'))
for wi in rows:
plan = int(wi.quantity_plan or 0)
done = int(wi.quantity_done or 0)
wi.fact_pct = int(round(done * 100 / plan)) if plan > 0 else 0
wi.fact_width = max(0, min(100, wi.fact_pct))
ctx['deal'] = deal
ctx['entity'] = entity
ctx['workitems'] = rows
next_url = (self.request.GET.get('next') or '').strip()
ctx['back_url'] = next_url if next_url.startswith('/') else str(reverse_lazy('registry'))
return ctx
from shiftflow.services.assembly_closing import get_first_operation_id
class WorkItemOpClosingView(LoginRequiredMixin, TemplateView):
template_name = 'shiftflow/workitem_op_closing.html'
def dispatch(self, request, *args, **kwargs):
roles = get_user_group_roles(request.user)
if not has_any_role(roles, ['admin', 'master', 'operator', 'prod_head']):
return redirect('registry')
profile = getattr(request.user, 'profile', None)
if bool(getattr(profile, 'is_readonly', False)) if profile else False:
messages.error(request, 'Доступ только для просмотра.')
return redirect('registry')
wi = get_object_or_404(
WorkItem.objects.select_related('deal', 'entity', 'entity__planned_material', 'operation', 'machine', 'workshop'),
pk=int(self.kwargs['pk']),
)
first_op_id = get_first_operation_id(int(wi.entity_id))
is_first = True
if first_op_id and getattr(wi, 'operation_id', None):
is_first = int(wi.operation_id) == int(first_op_id)
if is_first:
if wi.entity and wi.entity.entity_type in ['product', 'assembly']:
return redirect('assembly_closing', pk=wi.id)
if wi.entity and wi.entity.entity_type == 'part':
if wi.machine_id and getattr(wi.entity, 'planned_material_id', None):
return redirect(f"{reverse_lazy('closing')}?machine_id={int(wi.machine_id)}&material_id={int(wi.entity.planned_material_id)}")
self.workitem = wi
self.is_first_operation = is_first
return super().dispatch(request, *args, **kwargs)
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
roles = get_user_group_roles(self.request.user)
ctx['user_roles'] = sorted(roles)
ctx['user_role'] = primary_role(roles)
wi = self.workitem
ctx['workitem'] = wi
ctx['remaining'] = max(0, int(wi.quantity_plan or 0) - int(wi.quantity_done or 0))
ctx['is_first_operation'] = bool(self.is_first_operation)
return ctx
def post(self, request, *args, **kwargs):
wi = self.workitem
qty_raw = (request.POST.get('fact_qty') or '').strip()
try:
qty = int(qty_raw)
except ValueError:
qty = 0
if qty <= 0:
messages.error(request, 'Количество должно быть больше 0.')
return redirect('workitem_op_closing', pk=wi.id)
with transaction.atomic():
wi = WorkItem.objects.select_for_update(of=('self',)).select_related('machine', 'machine__workshop', 'workshop').get(pk=int(wi.id))
work_location = get_work_location_for_workitem(wi)
if not work_location:
messages.error(request, 'Для задания не определён склад участка (цех -> склад цеха / пост -> склад).')
return redirect('workitem_op_closing', pk=wi.id)
available = (
StockItem.objects.select_for_update(of=('self',))
.filter(is_archived=False, quantity__gt=0)
.filter(location_id=work_location.id, entity_id=int(wi.entity_id))
.filter(Q(deal_id=wi.deal_id) | Q(deal_id__isnull=True))
.aggregate(s=Coalesce(Sum('quantity'), 0.0))
)['s']
available_i = int(available or 0)
if qty > available_i:
messages.error(request, f'Нельзя закрыть операцию: на складе участка «{work_location.name}» доступно {available_i} шт. Сначала перемести изделие на участок.')
return redirect('workitem_op_closing', pk=wi.id)
plan_total = int(wi.quantity_plan or 0)
done_total = int(wi.quantity_done or 0)
remaining = max(0, plan_total - done_total)
if qty > remaining:
messages.error(request, f'Нельзя закрыть {qty} шт: доступно {remaining} шт.')
return redirect('workitem_op_closing', pk=wi.id)
wi.quantity_done = done_total + qty
if wi.quantity_done >= plan_total and plan_total > 0:
wi.status = 'done'
elif wi.quantity_done > 0:
wi.status = 'planned'
else:
wi.status = 'planned'
wi.save(update_fields=['quantity_done', 'status'])
deal_item = DealItem.objects.filter(deal_id=wi.deal_id, entity_id=wi.entity_id).first()
ordered_qty = int(deal_item.quantity) if deal_item else None
if ordered_qty is not None:
op_code = None
if getattr(wi, 'operation_id', None):
op_code = Operation.objects.filter(pk=wi.operation_id).values_list('code', flat=True).first()
if not op_code:
op_code = (wi.stage or '').strip()
if op_code:
progress, _ = DealEntityProgress.objects.get_or_create(deal_id=wi.deal_id, entity_id=wi.entity_id, defaults={'current_seq': 1})
cur = int(progress.current_seq or 1)
cur_eo = EntityOperation.objects.select_related('operation').filter(entity_id=wi.entity_id, seq=cur).first()
if cur_eo and cur_eo.operation and cur_eo.operation.code == op_code:
total_done = WorkItem.objects.filter(deal_id=wi.deal_id, entity_id=wi.entity_id).filter(Q(operation__code=op_code) | Q(stage=op_code)).aggregate(s=Coalesce(Sum('quantity_done'), 0))['s']
if int(total_done or 0) >= int(ordered_qty):
progress.current_seq = cur + 1
progress.save(update_fields=['current_seq'])
messages.success(request, f'Закрыто: {qty} шт.')
return redirect('workitem_detail', pk=wi.id)
class WorkItemDetailView(LoginRequiredMixin, TemplateView):
template_name = 'shiftflow/workitem_detail.html'
def dispatch(self, request, *args, **kwargs):
roles = get_user_roles(request.user)
if not has_any_role(roles, ['admin', 'technologist', 'master', 'clerk', 'operator', 'observer', 'prod_head', 'director']):
return redirect('registry')
return super().dispatch(request, *args, **kwargs)
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
profile = getattr(self.request.user, 'profile', None)
role = profile.role if profile else ('admin' if self.request.user.is_superuser else 'operator')
ctx['user_role'] = role
ctx['can_edit_entity'] = role in ['admin', 'technologist']
wi = get_object_or_404(
WorkItem.objects.select_related(
'deal',
'entity',
'entity__planned_material',
'operation',
'machine',
'workshop',
),
pk=int(self.kwargs['pk']),
)
ctx['workitem'] = wi
ctx['remaining'] = max(0, (wi.quantity_plan or 0) - (wi.quantity_done or 0))
first_op_id = get_first_operation_id(int(wi.entity_id))
is_first = True
if first_op_id and getattr(wi, 'operation_id', None):
is_first = int(wi.operation_id) == int(first_op_id)
ctx['is_first_operation'] = is_first
close_url = ''
close_label = 'Закрыть'
if wi.entity and wi.entity.entity_type in ['product', 'assembly']:
if is_first:
close_url = str(reverse_lazy('assembly_closing', kwargs={'pk': wi.id}))
close_label = 'Закрыть сборку'
else:
close_url = str(reverse_lazy('workitem_op_closing', kwargs={'pk': wi.id}))
elif wi.entity and wi.entity.entity_type == 'part':
if is_first and wi.machine_id and getattr(wi.entity, 'planned_material_id', None):
close_url = f"{reverse_lazy('closing')}?machine_id={int(wi.machine_id)}&material_id={int(wi.entity.planned_material_id)}"
else:
close_url = str(reverse_lazy('workitem_op_closing', kwargs={'pk': wi.id}))
else:
close_url = str(reverse_lazy('workitem_op_closing', kwargs={'pk': wi.id}))
ctx['close_url'] = close_url
ctx['close_label'] = close_label
ctx['machines'] = list(Machine.objects.all().order_by('name'))
ctx['workitem_status_choices'] = list(WorkItem.STATUS_CHOICES)
entity = wi.entity
passport = None
seams = []
if entity.entity_type in ['product', 'assembly']:
passport, _ = AssemblyPassport.objects.get_or_create(entity_id=entity.id)
seams = list(WeldingSeam.objects.filter(passport_id=passport.id).order_by('id'))
elif entity.entity_type == 'part':
passport, _ = PartPassport.objects.get_or_create(entity_id=entity.id)
elif entity.entity_type == 'purchased':
passport, _ = PurchasedPassport.objects.get_or_create(entity_id=entity.id)
elif entity.entity_type == 'casting':
passport, _ = CastingPassport.objects.get_or_create(entity_id=entity.id)
elif entity.entity_type == 'outsourced':
passport, _ = OutsourcedPassport.objects.get_or_create(entity_id=entity.id)
ctx['passport'] = passport
ctx['welding_seams'] = seams
next_url = (self.request.GET.get('next') or '').strip()
ctx['back_url'] = next_url if next_url.startswith('/') else str(reverse_lazy('registry'))
return ctx
class WorkItemKittingView(LoginRequiredMixin, TemplateView):
template_name = 'shiftflow/workitem_kitting.html'
def dispatch(self, request, *args, **kwargs):
roles = get_user_roles(request.user)
if not has_any_role(roles, ['admin', 'technologist', 'master', 'clerk', 'prod_head']):
return redirect('registry')
pk = self.kwargs.get('pk')
wi = None
if pk:
wi = WorkItem.objects.select_related('entity').filter(pk=int(pk)).first()
if wi and (wi.entity.entity_type not in ['product', 'assembly']):
messages.error(request, 'Комплектация доступна только для сборочных единиц и изделий.')
return redirect('workitem_detail', pk=wi.id)
return super().dispatch(request, *args, **kwargs)
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
profile = getattr(self.request.user, 'profile', None)
role = profile.role if profile else ('admin' if self.request.user.is_superuser else 'operator')
ctx['user_role'] = role
wi = get_object_or_404(
WorkItem.objects.select_related('deal', 'entity', 'machine', 'machine__workshop', 'workshop'),
pk=int(self.kwargs['pk']),
)
ctx['workitem'] = wi
to_location = get_work_location_for_workitem(wi)
if not to_location:
ctx['to_location'] = None
ctx['rows'] = []
ctx['draft'] = []
ctx['draft_groups'] = []
ctx['qty_to_make'] = 0
ctx['back_url'] = str(reverse_lazy('workitem_detail', kwargs={'pk': wi.id}))
messages.error(self.request, 'Для задания не определён склад участка (нет цеха/склада у поста).')
return ctx
ctx['to_location'] = to_location
qty_to_make = max(0, int(wi.quantity_plan or 0) - int(wi.quantity_done or 0))
qty_param = (self.request.GET.get('qty') or '').strip()
if qty_param.isdigit():
qty_to_make = max(0, int(qty_param))
ctx['qty_to_make'] = qty_to_make
req = build_kitting_requirements(int(wi.entity_id), int(qty_to_make))
component_ids = list(req.keys())
draft = get_kitting_draft(self.request.session, int(wi.id))
ctx['draft'] = draft
to_move_by_entity = {}
to_move_by_source = {}
for ln in draft:
eid = int(ln.get('entity_id') or 0)
lid = int(ln.get('from_location_id') or 0)
qty_ln = int(ln.get('quantity') or 0)
if eid <= 0 or lid <= 0 or qty_ln <= 0:
continue
to_move_by_entity[eid] = int(to_move_by_entity.get(eid, 0) or 0) + qty_ln
to_move_by_source[(eid, lid)] = int(to_move_by_source.get((eid, lid), 0) or 0) + qty_ln
entities = {
int(e.id): e
for e in ProductEntity.objects.filter(id__in=component_ids).order_by('entity_type', 'drawing_number', 'name', 'id')
}
avail_qs = (
StockItem.objects.select_related('location')
.filter(is_archived=False)
.filter(quantity__gt=0)
.filter(entity_id__in=component_ids)
.filter(Q(deal_id=wi.deal_id) | Q(deal_id__isnull=True))
.values('entity_id', 'location_id')
.annotate(q=Coalesce(Sum('quantity'), 0.0))
)
loc_ids = set()
by_entity_loc = {}
for r in avail_qs:
eid = int(r['entity_id'])
lid = int(r['location_id'])
q = float(r['q'] or 0)
by_entity_loc[(eid, lid)] = q
loc_ids.add(lid)
locations = {int(l.id): l for l in Location.objects.filter(id__in=list(loc_ids)).order_by('name')}
rows = []
for eid, need in req.items():
ent = entities.get(int(eid))
if not ent:
continue
need_i = int(need or 0)
to_have = int(by_entity_loc.get((int(eid), int(to_location.id)), 0) or 0)
to_move = int(to_move_by_entity.get(int(eid), 0) or 0)
missing = max(0, need_i - to_have - to_move)
sources = []
for lid in sorted(loc_ids, key=lambda x: (0 if x == int(to_location.id) else 1, str(getattr(locations.get(x), 'name', '')))):
if lid == int(to_location.id):
continue
q = float(by_entity_loc.get((int(eid), int(lid)), 0) or 0)
if q <= 0:
continue
loc = locations.get(int(lid))
if not loc:
continue
sources.append({'location': loc, 'available': int(q), 'selected': int(to_move_by_source.get((int(eid), int(lid)), 0) or 0)})
rows.append({
'entity': ent,
'need': need_i,
'have_to': to_have,
'to_move': to_move,
'missing': missing,
'sources': sources,
})
ctx['rows'] = rows
next_url = (self.request.GET.get('next') or '').strip()
ctx['back_url'] = next_url if next_url.startswith('/') else str(reverse_lazy('workitem_detail', kwargs={'pk': wi.id}))
return ctx
def post(self, request, *args, **kwargs):
roles = get_user_roles(request.user)
if not has_any_role(roles, ['admin', 'technologist', 'master', 'clerk', 'prod_head']):
return redirect('registry')
wi = get_object_or_404(
WorkItem.objects.select_related('deal', 'entity', 'machine', 'machine__workshop', 'workshop'),
pk=int(self.kwargs['pk']),
)
to_location = get_work_location_for_workitem(wi)
if not to_location:
messages.error(request, 'Для задания не определён склад участка.')
return redirect('workitem_detail', pk=wi.id)
action = (request.POST.get('action') or '').strip()
next_url = (request.POST.get('next') or '').strip()
if not next_url.startswith('/'):
next_url = str(reverse_lazy('workitem_kitting', kwargs={'pk': wi.id}))
def parse_int(s):
s = (s or '').strip()
return int(s) if s.isdigit() else None
if action == 'clear':
clear_kitting_draft(request.session, int(wi.id))
messages.success(request, 'Лист комплектации очищен.')
return redirect(next_url)
if action in ['add_line', 'remove_line']:
entity_id = parse_int(request.POST.get('entity_id'))
from_location_id = parse_int(request.POST.get('from_location_id'))
qty = parse_int(request.POST.get('quantity'))
if not (entity_id and from_location_id and qty and qty > 0):
messages.error(request, 'Заполни корректно: компонент, склад-источник и количество.')
return redirect(next_url)
if int(from_location_id) == int(to_location.id):
messages.error(request, 'Склад-источник должен отличаться от склада участка.')
return redirect(next_url)
if action == 'add_line':
add_kitting_line(request.session, int(wi.id), int(entity_id), int(from_location_id), int(qty))
messages.success(request, 'Добавлено в перемещение.')
else:
remove_kitting_line(request.session, int(wi.id), int(entity_id), int(from_location_id), int(qty))
messages.success(request, 'Откат выполнен.')
return redirect(next_url)
messages.error(request, 'Неизвестное действие.')
return redirect(next_url)
class WorkItemKittingPrintView(LoginRequiredMixin, TemplateView):
template_name = 'shiftflow/workitem_kitting_print.html'
def dispatch(self, request, *args, **kwargs):
roles = get_user_roles(request.user)
if not has_any_role(roles, ['admin', 'technologist', 'master', 'clerk', 'prod_head']):
return redirect('registry')
pk = self.kwargs.get('pk')
wi = None
if pk:
wi = WorkItem.objects.select_related('entity').filter(pk=int(pk)).first()
if wi and (wi.entity.entity_type not in ['product', 'assembly']):
messages.error(request, 'Комплектация доступна только для сборочных единиц и изделий.')
return redirect('workitem_detail', pk=wi.id)
return super().dispatch(request, *args, **kwargs)
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
wi = get_object_or_404(
WorkItem.objects.select_related('deal', 'entity', 'machine', 'machine__workshop', 'workshop'),
pk=int(self.kwargs['pk']),
)
ctx['workitem'] = wi
ctx['printed_at'] = timezone.now()
to_location = get_work_location_for_workitem(wi)
ctx['to_location'] = to_location
draft = get_kitting_draft(self.request.session, int(wi.id))
ctx['draft'] = draft
if not draft:
ctx['groups'] = []
return ctx
entity_ids = sorted({int(x.get('entity_id') or 0) for x in draft if int(x.get('entity_id') or 0) > 0})
loc_ids = sorted({int(x.get('from_location_id') or 0) for x in draft if int(x.get('from_location_id') or 0) > 0})
entities = {int(e.id): e for e in ProductEntity.objects.filter(id__in=entity_ids)}
locations = {int(l.id): l for l in Location.objects.filter(id__in=loc_ids)}
grouped = {}
for ln in draft:
lid = int(ln.get('from_location_id') or 0)
eid = int(ln.get('entity_id') or 0)
qty = int(ln.get('quantity') or 0)
if lid <= 0 or eid <= 0 or qty <= 0:
continue
grouped.setdefault(lid, {})
grouped[lid][eid] = int(grouped[lid].get(eid, 0)) + qty
groups = []
for lid, items in grouped.items():
loc = locations.get(int(lid))
if not loc:
continue
lst = []
for eid, qty in items.items():
ent = entities.get(int(eid))
if not ent:
continue
lst.append({'entity': ent, 'quantity': int(qty)})
lst.sort(key=lambda x: ((x['entity'].entity_type or ''), (x['entity'].drawing_number or ''), (x['entity'].name or ''), int(x['entity'].id)))
groups.append({'from_location': loc, 'items': lst})
groups.sort(key=lambda g: (str(g['from_location'].name or ''), int(g['from_location'].id)))
ctx['groups'] = groups
return ctx
def get(self, request, *args, **kwargs):
# GET — только предпросмотр листа перемещения (без выполнения перемещений)
ctx = self.get_context_data(**kwargs)
ctx['auto_print'] = False
return self.render_to_response(ctx)
def post(self, request, *args, **kwargs):
# POST — выполняем перемещения и затем рендерим лист (с автопечатью при необходимости)
wi = get_object_or_404(
WorkItem.objects.select_related('deal', 'entity', 'machine', 'machine__workshop', 'workshop'),
pk=int(self.kwargs['pk']),
)
to_location = get_work_location_for_workitem(wi)
if not to_location:
messages.error(request, 'Для задания не определён склад участка.')
return redirect('workitem_detail', pk=wi.id)
action = (request.POST.get('action') or '').strip()
if action not in ['apply', 'apply_print']:
messages.error(request, 'Неизвестное действие.')
return redirect('workitem_kitting_print', pk=wi.id)
ctx = self.get_context_data(**kwargs)
if not ctx.get('groups'):
messages.error(request, 'Лист перемещения пуст.')
return redirect('workitem_kitting', pk=wi.id)
stats = apply_kitting_draft(
session=request.session,
workitem_id=int(wi.id),
deal_id=int(wi.deal_id),
to_location_id=int(to_location.id),
user_id=int(request.user.id),
)
if int(stats.get('errors', 0) or 0) > 0:
messages.error(request, f"Ошибка перемещения. Ошибок: {stats.get('errors', 0)}")
return redirect('workitem_kitting', pk=wi.id)
ctx['auto_print'] = (action == 'apply_print')
return self.render_to_response(ctx)
class ProductEntityPreviewUpdateView(LoginRequiredMixin, View):
def post(self, request, pk, *args, **kwargs):
profile = getattr(request.user, 'profile', None)
role = profile.role if profile else ('admin' if request.user.is_superuser else 'operator')
if role not in ['admin', 'technologist']:
return redirect('registry')
next_url = (request.POST.get('next') or '').strip()
next_url = next_url if next_url.startswith('/') else str(reverse_lazy('registry'))
entity = get_object_or_404(ProductEntity, pk=int(pk))
try:
ok = _update_entity_preview(entity)
if ok:
messages.success(request, 'Превью обновлено.')
else:
messages.error(request, 'Превью не создано (нет DXF).')
except Exception as e:
logger.exception('entity_preview_update: failed entity_id=%s', entity.id)
messages.error(request, f'Ошибка генерации превью: {type(e).__name__}: {e}')
return redirect(next_url)
def dispatch(self, request, *args, **kwargs):
profile = getattr(request.user, 'profile', None)
role = profile.role if profile else 'operator'
if role not in ['admin', 'technologist', 'master']:
return redirect('registry')
return super().dispatch(request, *args, **kwargs)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
profile = getattr(self.request.user, 'profile', None)
role = profile.role if profile else 'operator'
context['user_role'] = role
queryset = Item.objects.select_related('task', 'task__deal', 'task__material', 'task__material__category', 'machine')
filtered = self.request.GET.get('filtered')
m_ids = self.request.GET.getlist('m_ids')
if filtered and not m_ids:
queryset = queryset.none()
if m_ids:
queryset = queryset.filter(machine_id__in=m_ids)
statuses = self.request.GET.getlist('statuses')
if filtered and not statuses:
queryset = queryset.none()
if statuses:
expanded = []
for s in statuses:
if s == 'closed':
expanded += ['done', 'partial']
else:
expanded.append(s)
queryset = queryset.filter(status__in=expanded)
start_date = self.request.GET.get('start_date')
end_date = self.request.GET.get('end_date')
if not filtered:
today = timezone.localdate()
queryset = queryset.filter(date=today, status__in=['work', 'leftover'])
start_date = today.strftime('%Y-%m-%d')
end_date = start_date
else:
if start_date:
queryset = queryset.filter(date__gte=start_date)
if end_date:
queryset = queryset.filter(date__lte=end_date)
is_synced = self.request.GET.get('is_synced')
if is_synced in ['0', '1']:
queryset = queryset.filter(is_synced_1c=bool(int(is_synced)))
if role == 'master' and not filtered:
queryset = queryset.filter(status='work')
items = list(queryset.order_by('machine__name', 'date', 'task__deal__number', 'id'))
groups = {}
for item in items:
groups.setdefault(item.machine, []).append(item)
context['groups'] = list(groups.items())
context['printed_at'] = timezone.now()
context['end_date'] = end_date or ''
print_date_raw = end_date or start_date
print_date = None
if isinstance(print_date_raw, str) and print_date_raw:
try:
print_date = datetime.strptime(print_date_raw, '%Y-%m-%d').date()
except ValueError:
print_date = None
context['print_date'] = print_date or timezone.localdate()
if start_date and end_date and start_date == end_date:
context['date_label'] = start_date
elif start_date and end_date:
context['date_label'] = f"{start_date}{end_date}"
elif start_date:
context['date_label'] = f"c {start_date}"
elif end_date:
context['date_label'] = f"по {end_date}"
else:
context['date_label'] = ''
return context
class PlanningView(LoginRequiredMixin, TemplateView):
template_name = 'shiftflow/planning.html'
def dispatch(self, request, *args, **kwargs):
roles = get_user_roles(request.user)
if not has_any_role(roles, ['admin', 'technologist', 'master', 'clerk', 'prod_head', 'director', 'observer']):
return redirect('registry')
return super().dispatch(request, *args, **kwargs)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
profile = getattr(self.request.user, 'profile', None)
roles = get_user_roles(self.request.user)
role = primary_role(roles)
context['user_role'] = role
context['user_roles'] = sorted(roles)
allowed_ws = list(profile.allowed_workshops.values_list('id', flat=True)) if profile else []
context['allowed_workshop_ids'] = allowed_ws
context['is_readonly'] = bool(getattr(profile, 'is_readonly', False)) if profile else False
allowed_ws = list(profile.allowed_workshops.values_list('id', flat=True)) if profile else []
context['allowed_workshop_ids'] = allowed_ws
context['is_readonly'] = bool(getattr(profile, 'is_readonly', False)) if profile else False
status = (self.request.GET.get('status') or 'work').strip()
allowed = {k for k, _ in Deal.STATUS_CHOICES}
if status not in allowed:
status = 'work'
context['selected_status'] = status
context['deals'] = Deal.objects.select_related('company').filter(status=status).order_by('-id')
context['companies'] = Company.objects.all().order_by('name')
return context
class DealPlanningView(LoginRequiredMixin, TemplateView):
template_name = 'shiftflow/planning_deal.html'
def dispatch(self, request, *args, **kwargs):
roles = get_user_roles(request.user)
if not has_any_role(roles, ['admin', 'technologist', 'master', 'clerk', 'prod_head', 'director', 'observer']):
return redirect('registry')
return super().dispatch(request, *args, **kwargs)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
profile = getattr(self.request.user, 'profile', None)
roles = get_user_roles(self.request.user)
role = primary_role(roles)
context['user_role'] = role
context['user_roles'] = sorted(roles)
allowed_ws = list(profile.allowed_workshops.values_list('id', flat=True)) if profile else []
context['allowed_workshop_ids'] = allowed_ws
context['is_readonly'] = bool(getattr(profile, 'is_readonly', False)) if profile else False
deal = get_object_or_404(Deal.objects.select_related('company'), pk=self.kwargs['pk'])
context['deal'] = deal
di = list(
DealItem.objects.select_related('entity', 'entity__assembly_passport')
.filter(deal=deal)
.order_by('entity__entity_type', 'entity__drawing_number', 'entity__name')
)
_reconcile_default_delivery_batch(int(deal.id))
allocated_non_default = {
int(r['entity_id']): int(r['s'] or 0)
for r in DealBatchItem.objects.filter(batch__deal=deal, batch__is_default=False, entity_id__in=[x.entity_id for x in di])
.values('entity_id')
.annotate(s=Coalesce(Sum('quantity'), 0))
}
started_map = {
int(r['entity_id']): int(r['started'] or 0)
for r in DealBatchItem.objects.filter(batch__deal=deal, entity_id__in=[x.entity_id for x in di])
.values('entity_id')
.annotate(started=Coalesce(Sum('started_qty'), 0))
}
for it in di:
need = int(it.quantity or 0)
started = int(started_map.get(int(it.entity_id), 0) or 0)
if started > need:
started = need
allocated = int(allocated_non_default.get(int(it.entity_id), 0) or 0)
if allocated > need:
allocated = need
it.remaining_to_allocate = max(0, need - allocated)
it.done_qty = 0
it.planned_qty = started
it.remaining_qty = max(0, need - started)
if need > 0:
done_width = 0
plan_pct = int(round(started * 100 / need))
else:
done_width = 0
plan_pct = 0
it.done_width = done_width
it.plan_width = max(0, min(100, plan_pct))
batches = list(DealDeliveryBatch.objects.filter(deal=deal).order_by('is_default', 'due_date', 'id'))
batch_items = list(
DealBatchItem.objects.select_related('batch', 'entity')
.filter(batch__deal=deal)
.order_by('batch__due_date', 'batch_id', 'entity__entity_type', 'entity__drawing_number', 'entity__name', 'id')
)
by_batch = {}
for bi in batch_items:
started = int(getattr(bi, 'started_qty', 0) or 0)
qty = int(getattr(bi, 'quantity', 0) or 0)
if started < 0:
started = 0
if qty < 0:
qty = 0
if started > qty:
started = qty
bi.started_qty = started
bi.remaining_to_start = max(0, qty - started)
bi.started_pct = int(round(started * 100 / qty)) if qty > 0 else 0
by_batch.setdefault(int(bi.batch_id), []).append(bi)
for b in batches:
items = by_batch.get(int(b.id), [])
b.items_list = items
b.total_qty = sum(int(getattr(x, 'quantity', 0) or 0) for x in items)
b.total_started = sum(int(getattr(x, 'started_qty', 0) or 0) for x in items)
b.total_remaining = max(0, int(b.total_qty or 0) - int(b.total_started or 0))
b.started_pct = int(round(b.total_started * 100 / b.total_qty)) if b.total_qty > 0 else 0
context['delivery_batches'] = batches
context['deal_items'] = di
tasks = list(
ProductionTask.objects.filter(deal=deal)
.select_related('material', 'entity')
.order_by('-id')
)
task_entity_ids = {int(x.entity_id) for x in tasks if getattr(x, 'entity_id', None)}
progress_task_map = {
int(p.entity_id): int(p.current_seq or 1)
for p in DealEntityProgress.objects.filter(deal=deal, entity_id__in=list(task_entity_ids))
}
ops_task_map = {}
for eo in (
EntityOperation.objects.select_related('operation', 'operation__workshop')
.filter(entity_id__in=list(task_entity_ids))
.order_by('entity_id', 'seq', 'id')
):
ops_task_map[(int(eo.entity_id), int(eo.seq))] = eo
for t in tasks:
t.current_operation_id = None
t.current_operation_name = ''
t.current_workshop_id = None
t.current_workshop_name = ''
if not getattr(t, 'entity_id', None):
continue
seq = int(progress_task_map.get(int(t.entity_id), 1) or 1)
eo = ops_task_map.get((int(t.entity_id), seq))
if not eo:
continue
t.current_operation_id = int(eo.operation_id)
t.current_operation_name = eo.operation.name if eo.operation else ''
t.current_workshop_id = int(eo.operation.workshop_id) if eo.operation and eo.operation.workshop_id else None
t.current_workshop_name = eo.operation.workshop.name if eo.operation and eo.operation.workshop else ''
wi_qs = WorkItem.objects.filter(deal=deal, entity_id__in=list(task_entity_ids)).filter(operation_id__isnull=False)
if allowed_ws:
wi_qs = wi_qs.filter(workshop_id__in=allowed_ws)
wi_sums = {
(int(r['entity_id']), int(r['operation_id'])): (int(r['planned'] or 0), int(r['done'] or 0))
for r in wi_qs.values('entity_id', 'operation_id').annotate(
planned=Coalesce(Sum('quantity_plan'), 0),
done=Coalesce(Sum('quantity_done'), 0),
)
}
workshop_groups = {}
for t in tasks:
if allowed_ws and t.current_workshop_id and int(t.current_workshop_id) not in allowed_ws:
continue
need = int(t.quantity_ordered or 0)
key = None
if getattr(t, 'entity_id', None) and getattr(t, 'current_operation_id', None):
key = (int(t.entity_id), int(t.current_operation_id))
planned_qty, done_qty = wi_sums.get(key, (0, 0)) if key else (0, 0)
planned_qty = int(planned_qty or 0)
done_qty = int(done_qty or 0)
remaining_qty = need - done_qty - planned_qty
if remaining_qty < 0:
remaining_qty = 0
t.planned_qty = planned_qty
t.done_qty = done_qty
t.remaining_qty = remaining_qty
if need > 0:
done_pct = int(round(done_qty * 100 / need))
plan_pct = int(round(planned_qty * 100 / need))
else:
done_pct = 0
plan_pct = 0
done_width = max(0, min(100, done_pct))
plan_width = max(0, min(100 - done_width, plan_pct))
t.done_pct = done_pct
t.plan_pct = plan_pct
t.done_width = done_width
t.plan_width = plan_width
ws_id = int(t.current_workshop_id) if t.current_workshop_id else 0
ws_name = (t.current_workshop_name or '').strip() or 'Без техпроцесса'
grp = workshop_groups.get(ws_id)
if not grp:
grp = {'id': ws_id, 'name': ws_name, 'tasks': []}
workshop_groups[ws_id] = grp
grp['tasks'].append(t)
context['workshop_task_groups'] = sorted(
workshop_groups.values(),
key=lambda g: (1 if int(g['id'] or 0) == 0 else 0, str(g['name'])),
)
context['machines'] = Machine.objects.all()
return context
def post(self, request, *args, **kwargs):
roles = get_user_group_roles(request.user)
if not has_any_role(roles, ['admin', 'prod_head', 'technologist', 'master']):
return redirect('planning_deal', pk=self.kwargs['pk'])
action = (request.POST.get('action') or '').strip()
deal_id = int(self.kwargs['pk'])
if action == 'set_work':
Deal.objects.filter(id=deal_id, status='lead').update(status='work')
messages.success(request, 'Сделка переведена в статус «В работе».')
return redirect('planning_deal', pk=deal_id)
if action == 'explode_deal':
deal = get_object_or_404(Deal, pk=deal_id)
try:
stats = explode_deal(deal_id, create_tasks=False, create_procurement=True)
messages.success(
request,
f'BOM пересчитан для снабжения (Сделка {deal.number}). '
f'Потребностей создано/обновлено: ({stats.req_created}/{stats.req_updated}).'
)
except Exception as e:
logger.exception('explode_deal:error deal_id=%s', deal_id)
messages.error(request, f'Ошибка вскрытия BOM: {e}')
return redirect('planning_deal', pk=deal_id)
class TaskItemsView(LoginRequiredMixin, TemplateView):
template_name = 'shiftflow/task_items.html'
def dispatch(self, request, *args, **kwargs):
profile = getattr(request.user, 'profile', None)
role = profile.role if profile else ('admin' if request.user.is_superuser else 'operator')
if role not in ['admin', 'technologist', 'master', 'clerk']:
return redirect('registry')
return super().dispatch(request, *args, **kwargs)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
profile = getattr(self.request.user, 'profile', None)
role = profile.role if profile else ('admin' if self.request.user.is_superuser else 'operator')
context['user_role'] = role
task = get_object_or_404(
ProductionTask.objects.select_related('deal', 'deal__company', 'material'),
pk=self.kwargs['pk'],
)
context['task'] = task
items = list(Item.objects.filter(task=task).select_related('machine').order_by('-date', 'machine__name', '-id'))
for it in items:
plan = int(it.quantity_plan or 0)
fact = int(it.quantity_fact or 0)
if plan > 0:
fact_pct = int(round(fact * 100 / plan))
else:
fact_pct = 0
it.fact_pct = fact_pct
it.fact_width = max(0, min(100, fact_pct))
it.fact_bar_class = 'bg-success' if it.status in ['done', 'partial'] else 'bg-warning'
context['items'] = items
return context
def _run_dxf_preview_job(job_id: int) -> None:
"""Выполняет задачу пакетной регенерации превью в фоне.
Пишем прогресс в DxfPreviewJob, чтобы UI мог показывать результаты.
"""
try:
close_old_connections()
job = DxfPreviewJob.objects.get(pk=job_id)
except Exception:
return
job.status = 'running'
job.started_at = timezone.now()
job.last_message = ''
job.save(update_fields=['status', 'started_at', 'last_message'])
# Берём только сделки в статусах «Зашла» и «В работе»
deal_statuses = ['lead', 'work']
qs = ProductionTask.objects.select_related('deal').filter(deal__status__in=deal_statuses)
try:
total = qs.count()
except Exception:
total = 0
job.total = total
job.processed = 0
job.updated = 0
job.skipped = 0
job.errors = 0
job.save(update_fields=['total', 'processed', 'updated', 'skipped', 'errors'])
# iterator() уменьшает потребление памяти на больших выборках
processed = 0
updated = 0
skipped = 0
errors = 0
for task in qs.iterator(chunk_size=50):
try:
if _update_task_preview(task):
updated += 1
else:
skipped += 1
except Exception:
errors += 1
processed += 1
# Обновляем прогресс периодически, чтобы не делать save() на каждую запись
if processed % 10 == 0:
DxfPreviewJob.objects.filter(pk=job_id).update(
processed=processed,
updated=updated,
skipped=skipped,
errors=errors,
)
status = 'done' if errors == 0 else 'done'
last_message = f"Превью обновлены: {updated}. Пропущено: {skipped}. Ошибок: {errors}."
DxfPreviewJob.objects.filter(pk=job_id).update(
status=status,
finished_at=timezone.now(),
processed=processed,
updated=updated,
skipped=skipped,
errors=errors,
last_message=last_message,
)
try:
close_old_connections()
except Exception:
pass
def _mark_stale_preview_jobs() -> None:
"""Помечает «залипшие» задачи превью как failed.
Почему это нужно:
- генерация превью запускается в фоне (поток/процесс);
- если сервер перезапустили или процесс был убит, job может навсегда остаться в queued/running;
- из-за этого UI пишет «уже запущено» и прогресс не двигается.
Правило:
- если job в queued/running и нет finished_at, и он слишком долго не двигается — считаем его умершим.
"""
now = timezone.now()
# Лимит «жизнеспособности» задачи. Можно подстроить.
stale_after = timezone.timedelta(minutes=5)
qs = DxfPreviewJob.objects.filter(status__in=['queued', 'running'], finished_at__isnull=True)
for job in qs[:10]:
# queued без started_at тоже может остаться после рестарта
ref_time = job.started_at or job.created_at
if ref_time and (now - ref_time) > stale_after:
job.status = 'failed'
job.finished_at = now
job.last_message = 'Задача помечена как зависшая (сервер был перезапущен или процесс остановлен).'
job.save(update_fields=['status', 'finished_at', 'last_message'])
def _dxf_job_log_path(job_id: int) -> Path:
"""Путь к лог-файлу фоновой задачи DXF превью."""
base_dir = Path(getattr(django_settings, 'BASE_DIR', Path(__file__).resolve().parent.parent))
logs_dir = base_dir / 'logs'
logs_dir.mkdir(parents=True, exist_ok=True)
return logs_dir / f'dxf_preview_job_{job_id}.log'
def _read_tail(path: Path, max_bytes: int = 32_000) -> str:
"""Читает «хвост» файла (последние max_bytes) для вывода в UI."""
try:
if not path.exists():
return ''
with path.open('rb') as f:
f.seek(0, os.SEEK_END)
size = f.tell()
start = max(0, size - max_bytes)
f.seek(start)
data = f.read()
if start > 0:
nl = data.find(b'\n')
if nl != -1:
data = data[nl + 1 :]
return data.decode('utf-8', errors='replace')
except Exception:
return ''
class MaintenanceStatusView(LoginRequiredMixin, View):
def get(self, request, *args, **kwargs):
profile = getattr(request.user, 'profile', None)
role = profile.role if profile else ('admin' if request.user.is_superuser else 'operator')
if role != 'admin':
return JsonResponse({'error': 'forbidden'}, status=403)
_mark_stale_preview_jobs()
job = DxfPreviewJob.objects.order_by('-id').first()
if not job:
return JsonResponse({'job': None, 'log_tail': ''})
log_tail = _read_tail(_dxf_job_log_path(job.id))
return JsonResponse({
'job': {
'id': job.id,
'status': job.status,
'status_label': job.get_status_display(),
'total': job.total,
'processed': job.processed,
'updated': job.updated,
'skipped': job.skipped,
'errors': job.errors,
'cancel_requested': getattr(job, 'cancel_requested', False),
'last_message': job.last_message,
'log_tail': log_tail,
'log_path': str(_dxf_job_log_path(job.id)),
}
})
class MaintenanceView(LoginRequiredMixin, TemplateView):
template_name = 'shiftflow/maintenance.html'
def _server_log_path(self):
p = (django_settings.BASE_DIR / 'logs' / 'mes.log')
return p
def _read_tail(self, path, max_bytes: int = 20000) -> str:
try:
if not path.exists():
return ''
size = path.stat().st_size
start = max(0, size - max_bytes)
with path.open('rb') as f:
f.seek(start)
data = f.read()
try:
return data.decode('utf-8', errors='replace')
except Exception:
return str(data)
except Exception:
return ''
def dispatch(self, request, *args, **kwargs):
profile = getattr(request.user, 'profile', None)
role = profile.role if profile else ('admin' if request.user.is_superuser else 'operator')
# Обслуживание сервера доступно только админу
if role != 'admin':
return redirect('registry')
return super().dispatch(request, *args, **kwargs)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['user_role'] = 'admin'
log_path = self._server_log_path()
context['log_path'] = str(log_path)
context['log_tail'] = self._read_tail(log_path)
# Подтягиваем текущие настройки генерации превью, чтобы отрисовать форму.
s = _get_dxf_preview_settings()
context['dxf_settings'] = s
# Последняя фоновая задача (для вывода статуса на странице)
context['last_job'] = DxfPreviewJob.objects.order_by('-id').first()
return context
def post(self, request, *args, **kwargs):
# На странице обслуживания есть 2 действия:
# 1) сохранить настройки превью
# 2) сохранить настройки и обновить превью по сделкам в статусах lead/work
action = (request.POST.get('action') or '').strip()
# Сохраняем настройки (даже если жмём «Обновить» — чтобы применить их сразу).
s = _get_dxf_preview_settings()
s.line_color = (request.POST.get('line_color') or s.line_color).strip() or s.line_color
try:
s.lineweight_scaling = float(request.POST.get('lineweight_scaling', s.lineweight_scaling))
except ValueError:
pass
try:
s.min_lineweight = float(request.POST.get('min_lineweight', s.min_lineweight))
except ValueError:
pass
s.keep_original_colors = bool(request.POST.get('keep_original_colors'))
# Таймаут на обработку одной детали (в секундах).
# Используется в management-команде, чтобы «плохой» DXF не блокировал всю задачу.
try:
s.per_task_timeout_sec = int(request.POST.get('per_task_timeout_sec', s.per_task_timeout_sec))
except ValueError:
pass
s.save()
if action == 'cancel_job':
# Мягкая остановка: помечаем текущую задачу флагом cancel_requested.
# Воркер завершит работу после текущей детали и поставит статус cancelled.
job = DxfPreviewJob.objects.filter(status__in=['queued', 'running'], finished_at__isnull=True).order_by('-id').first()
if not job:
messages.info(request, 'Активной задачи нет.')
return redirect('maintenance')
job.cancel_requested = True
job.last_message = 'Запрошена остановка. Ожидаем завершения текущей детали.'
job.save(update_fields=['cancel_requested', 'last_message'])
messages.success(request, 'Остановка запрошена.')
return redirect('maintenance')
if action == 'refresh_log':
return redirect('maintenance')
if action == 'clear_log':
try:
self._server_log_path().open('wb').close()
messages.success(request, 'Лог очищен.')
except Exception:
messages.error(request, 'Не удалось очистить лог.')
return redirect('maintenance')
if action == 'clear_dxf_job_log':
job = DxfPreviewJob.objects.order_by('-id').first()
if not job:
messages.info(request, 'Логов нет.')
return redirect('maintenance')
if job.status in ['queued', 'running'] and not job.finished_at:
messages.warning(request, 'Нельзя очистить лог во время выполнения задачи.')
return redirect('maintenance')
try:
_dxf_job_log_path(job.id).open('wb').close()
messages.success(request, 'Лог DXF-генерации очищен.')
except Exception:
messages.error(request, 'Не удалось очистить лог DXF-генерации.')
return redirect('maintenance')
if action != 'update_previews':
messages.success(request, 'Настройки превью сохранены.')
return redirect('maintenance')
# Перед проверкой «уже запущено» снимаем залипшие задачи (например после перезапуска сервера).
_mark_stale_preview_jobs()
# Если уже есть выполняющаяся задача — не запускаем вторую, чтобы не перегружать сервер.
running = DxfPreviewJob.objects.filter(status__in=['queued', 'running'], finished_at__isnull=True).exists()
if running:
messages.warning(request, 'Обновление уже запущено. Дождись завершения текущей задачи.')
return redirect('maintenance')
# Запускаем регенерацию в отдельном процессе через management-команду.
# Причина: рендер DXF и bbox нагружают CPU и могут «тормозить» веб‑процесс из-за GIL,
# даже если запускать в потоке.
job = DxfPreviewJob.objects.create(status='queued', created_by=request.user)
try:
log_path = _dxf_job_log_path(job.id)
log_fh = log_path.open('ab')
try:
p = subprocess.Popen(
[sys.executable, 'manage.py', 'dxf_preview_job', str(job.id)],
cwd=str(Path(__file__).resolve().parent.parent),
stdout=log_fh,
stderr=log_fh,
close_fds=True,
)
finally:
log_fh.close()
# Если в модели есть поле pid — сохраняем его для диагностики.
try:
job.pid = p.pid
job.save(update_fields=['pid'])
except Exception:
pass
messages.success(request, 'Запущено обновление превью DXF в фоне. Прогресс и лог обновляются ниже.')
except Exception:
job.status = 'failed'
job.last_message = 'Не удалось запустить фоновый процесс генерации превью.'
job.save(update_fields=['status', 'last_message'])
messages.error(request, 'Не удалось запустить обновление превью DXF.')
return redirect('maintenance')
class CustomersView(LoginRequiredMixin, TemplateView):
template_name = 'shiftflow/customers.html'
def dispatch(self, request, *args, **kwargs):
roles = get_user_roles(request.user)
if not has_any_role(roles, ['admin', 'technologist', 'master', 'clerk', 'prod_head', 'director', 'observer']):
return redirect('registry')
return super().dispatch(request, *args, **kwargs)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
roles = get_user_roles(self.request.user)
role = primary_role(roles)
context['user_role'] = role
context['user_roles'] = sorted(roles)
companies = Company.objects.all().order_by('name')
context['companies'] = companies
return context
class CustomerDealsView(LoginRequiredMixin, TemplateView):
template_name = 'shiftflow/customer_deals.html'
def dispatch(self, request, *args, **kwargs):
roles = get_user_roles(request.user)
if not has_any_role(roles, ['admin', 'technologist', 'master', 'clerk', 'prod_head', 'director', 'observer']):
return redirect('registry')
return super().dispatch(request, *args, **kwargs)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
profile = getattr(self.request.user, 'profile', None)
roles = get_user_roles(self.request.user)
role = primary_role(roles)
context['user_role'] = role
context['user_roles'] = sorted(roles)
allowed_ws = list(profile.allowed_workshops.values_list('id', flat=True)) if profile else []
context['allowed_workshop_ids'] = allowed_ws
context['is_readonly'] = bool(getattr(profile, 'is_readonly', False)) if profile else False
company = get_object_or_404(Company, pk=self.kwargs['pk'])
context['company'] = company
status = (self.request.GET.get('status') or 'work').strip()
allowed = {k for k, _ in Deal.STATUS_CHOICES}
if status not in allowed:
status = 'work'
context['selected_status'] = status
context['deals'] = Deal.objects.select_related('company').filter(company=company, status=status).order_by('-id')
return context
class PlanningAddView(LoginRequiredMixin, View):
def post(self, request, *args, **kwargs):
profile = getattr(request.user, 'profile', None)
role = profile.role if profile else ('admin' if request.user.is_superuser else 'operator')
if role not in ['admin', 'technologist']:
return redirect('planning')
task_id = request.POST.get('task_id')
machine_id = request.POST.get('machine_id')
qty_raw = request.POST.get('quantity_plan')
if not (task_id and task_id.isdigit() and machine_id and machine_id.isdigit() and qty_raw and qty_raw.isdigit()):
return redirect('planning')
qty = int(qty_raw)
if qty <= 0:
return redirect('planning')
Item.objects.create(
task_id=int(task_id),
machine_id=int(machine_id),
date=timezone.localdate(),
quantity_plan=qty,
quantity_fact=0,
status='work',
is_synced_1c=False,
)
next_url = request.POST.get('next') or ''
if next_url.startswith('/planning/deal/'):
return redirect(next_url)
return redirect('planning')
class WorkItemPlanAddView(LoginRequiredMixin, View):
def post(self, request, *args, **kwargs):
profile = getattr(request.user, 'profile', None)
role = profile.role if profile else ('admin' if request.user.is_superuser else 'operator')
if role not in ['admin', 'technologist', 'master', 'clerk']:
return redirect('planning')
def parse_int(s):
s = (s or '').strip()
return int(s) if s.isdigit() else None
deal_id = parse_int(request.POST.get('deal_id'))
entity_id = parse_int(request.POST.get('entity_id'))
operation_id = parse_int(request.POST.get('operation_id'))
machine_id = parse_int(request.POST.get('machine_id'))
workshop_id = parse_int(request.POST.get('workshop_id'))
qty = parse_int(request.POST.get('quantity_plan'))
recursive_bom = request.POST.get('recursive_bom') == 'on'
next_url = (request.POST.get('next') or '').strip()
if not next_url.startswith('/'):
next_url = str(reverse_lazy('planning'))
if not (deal_id and entity_id and operation_id and qty and qty > 0):
messages.error(request, 'Заполни операцию и количество.')
return redirect(next_url)
op = Operation.objects.select_related('workshop').filter(id=operation_id).first()
if not op:
messages.error(request, 'Операция не найдена.')
return redirect(next_url)
machine = None
if machine_id:
machine = Machine.objects.select_related('workshop').filter(id=machine_id).first()
workshop = None
if workshop_id:
workshop = Workshop.objects.filter(id=workshop_id).first()
if not workshop:
messages.error(request, 'Цех не найден.')
return redirect(next_url)
if not machine and not (workshop_id or getattr(op, 'workshop_id', None)):
messages.error(request, 'Выбери станок или цех.')
return redirect(next_url)
resolved_workshop_id = (
machine.workshop_id if machine and machine.workshop_id else (workshop.id if workshop else getattr(op, 'workshop_id', None))
)
# Комментарий: Если включен чекбокс recursive_bom, бежим по дереву BOM вниз.
# Для дочерних компонентов создаём WorkItem только на текущую операцию
# (по DealEntityProgress.current_seq), чтобы операции возникали по очереди.
# Для родителя создаём WorkItem по выбранной операции из модалки.
if recursive_bom:
try:
with transaction.atomic():
adjacency = _build_bom_graph({entity_id})
required_nodes = {}
_accumulate_requirements(entity_id, qty, adjacency, set(), required_nodes)
node_ids = list(required_nodes.keys())
progress_map = {
int(p.entity_id): int(p.current_seq or 1)
for p in DealEntityProgress.objects.filter(deal_id=int(deal_id), entity_id__in=node_ids)
}
ops_map = {
(int(eo.entity_id), int(eo.seq)): eo
for eo in EntityOperation.objects.select_related('operation', 'operation__workshop')
.filter(entity_id__in=node_ids)
}
created_count = 0
for c_id, c_qty in required_nodes.items():
if int(c_id) == int(entity_id):
WorkItem.objects.create(
deal_id=deal_id,
entity_id=entity_id,
operation_id=operation_id,
workshop_id=resolved_workshop_id,
machine_id=(machine.id if machine else None),
stage=(op.name or '')[:32],
quantity_plan=qty,
quantity_done=0,
status='planned',
date=timezone.localdate(),
)
created_count += 1
continue
seq = int(progress_map.get(int(c_id), 1) or 1)
eo = ops_map.get((int(c_id), seq))
if not eo or not getattr(eo, 'operation_id', None) or not getattr(eo, 'operation', None):
continue
cur_op = eo.operation
WorkItem.objects.create(
deal_id=deal_id,
entity_id=int(c_id),
operation_id=int(cur_op.id),
workshop_id=(int(cur_op.workshop_id) if getattr(cur_op, 'workshop_id', None) else None),
machine_id=None,
stage=(cur_op.name or '')[:32],
quantity_plan=int(c_qty),
quantity_done=0,
status='planned',
date=timezone.localdate(),
)
created_count += 1
messages.success(request, f'Рекурсивно добавлено в смену заданий: {created_count} шт.')
except Exception as e:
logger.exception('workitem_add recursive error')
messages.error(request, f'Ошибка при рекурсивном добавлении: {e}')
else:
wi = WorkItem.objects.create(
deal_id=int(deal_id),
entity_id=int(entity_id),
operation_id=int(operation_id),
workshop_id=resolved_workshop_id,
machine_id=(machine.id if machine else None),
stage=(op.name or '')[:32],
quantity_plan=int(qty),
quantity_done=0,
status='planned',
date=timezone.localdate(),
)
logger.info('workitem_add: id=%s deal_id=%s entity_id=%s operation_id=%s machine_id=%s qty=%s', wi.id, deal_id, entity_id, operation_id, machine_id, qty)
messages.success(request, 'Добавлено в смену.')
return redirect(next_url)
class ProductionTaskCreateView(LoginRequiredMixin, FormView):
template_name = 'shiftflow/task_create.html'
form_class = ProductionTaskCreateForm
success_url = reverse_lazy('planning')
def get_initial(self):
initial = super().get_initial()
deal_id = self.request.GET.get('deal')
if deal_id and str(deal_id).isdigit():
initial['deal'] = int(deal_id)
return initial
def dispatch(self, request, *args, **kwargs):
profile = getattr(request.user, 'profile', None)
role = profile.role if profile else ('admin' if request.user.is_superuser else 'operator')
if role not in ['admin', 'technologist']:
return redirect('registry')
return super().dispatch(request, *args, **kwargs)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
profile = getattr(self.request.user, 'profile', None)
role = profile.role if profile else ('admin' if self.request.user.is_superuser else 'operator')
context['user_role'] = role
context['companies'] = Company.objects.all().order_by('name')
context['material_categories'] = MaterialCategory.objects.all().order_by('name')
context['steel_grades'] = SteelGrade.objects.all().order_by('name')
return context
def form_valid(self, form):
task = ProductionTask(
deal=form.cleaned_data['deal'],
drawing_name=form.cleaned_data.get('drawing_name') or 'Б/ч',
size_value=form.cleaned_data['size_value'],
material=form.cleaned_data['material'],
quantity_ordered=form.cleaned_data['quantity_ordered'],
is_bend=form.cleaned_data.get('is_bend') or False,
)
if form.cleaned_data.get('drawing_file'):
task.drawing_file = form.cleaned_data['drawing_file']
if form.cleaned_data.get('extra_drawing'):
task.extra_drawing = form.cleaned_data['extra_drawing']
task.save()
# Генерация превью/габаритов может занимать время (особенно на больших DXF).
# Поэтому запускаем её в фоне и НЕ блокируем сохранение/редирект.
def _bg(task_id: int) -> None:
try:
close_old_connections()
t = ProductionTask.objects.get(pk=task_id)
_update_task_preview(t)
except Exception:
pass
finally:
try:
close_old_connections()
except Exception:
pass
threading.Thread(target=_bg, args=(task.id,), daemon=True).start()
next_url = (self.request.POST.get('next') or '').strip()
if next_url.startswith('/'):
return redirect(next_url)
return redirect('planning_deal', pk=task.deal_id)
class DealDetailView(LoginRequiredMixin, View):
def get(self, request, pk, *args, **kwargs):
profile = getattr(request.user, 'profile', None)
role = profile.role if profile else ('admin' if request.user.is_superuser else 'operator')
if role not in ['admin', 'technologist', 'master', 'clerk']:
return JsonResponse({'error': 'forbidden'}, status=403)
deal = get_object_or_404(Deal, pk=pk)
return JsonResponse({
'id': deal.id,
'number': deal.number,
'status': deal.status,
'company_id': deal.company_id,
'description': deal.description or '',
'due_date': deal.due_date.isoformat() if getattr(deal, 'due_date', None) else '',
})
class DealUpsertView(LoginRequiredMixin, View):
def post(self, request, *args, **kwargs):
profile = getattr(request.user, 'profile', None)
role = profile.role if profile else ('admin' if request.user.is_superuser else 'operator')
if role not in ['admin', 'technologist', 'master', 'clerk']:
return JsonResponse({'error': 'forbidden'}, status=403)
deal_id = request.POST.get('id')
number = (request.POST.get('number') or '').strip()
description = (request.POST.get('description') or '').strip()
company_id = request.POST.get('company_id')
status = (request.POST.get('status') or 'work').strip()
due_date = (request.POST.get('due_date') or '').strip()
if not number:
return JsonResponse({'error': 'number_required'}, status=400)
if deal_id and str(deal_id).isdigit():
deal = get_object_or_404(Deal, pk=int(deal_id))
deal.number = number
else:
deal, _ = Deal.objects.get_or_create(number=number)
allowed = {k for k, _ in Deal.STATUS_CHOICES}
if status not in allowed:
status = 'work'
deal.status = status
deal.description = description
if due_date:
try:
deal.due_date = datetime.strptime(due_date, '%Y-%m-%d').date()
except Exception:
deal.due_date = None
else:
deal.due_date = None
if company_id and str(company_id).isdigit():
deal.company_id = int(company_id)
else:
deal.company_id = None
deal.save()
return JsonResponse({'id': deal.id, 'label': deal.number})
class MaterialDetailView(LoginRequiredMixin, View):
def get(self, request, pk, *args, **kwargs):
profile = getattr(request.user, 'profile', None)
role = profile.role if profile else ('admin' if request.user.is_superuser else 'operator')
if role not in ['admin', 'technologist', 'master', 'clerk']:
return JsonResponse({'error': 'forbidden'}, status=403)
material = get_object_or_404(Material, pk=pk)
return JsonResponse({
'id': material.id,
'category_id': material.category_id,
'steel_grade_id': material.steel_grade_id,
'name': material.name,
'full_name': material.full_name,
'mass_per_unit': material.mass_per_unit,
})
class MaterialUpsertView(LoginRequiredMixin, View):
def post(self, request, *args, **kwargs):
profile = getattr(request.user, 'profile', None)
role = profile.role if profile else ('admin' if request.user.is_superuser else 'operator')
if role not in ['admin', 'technologist', 'master', 'clerk']:
return JsonResponse({'error': 'forbidden'}, status=403)
def parse_float(value):
s = (value or '').strip().replace(',', '.')
if not s:
return None
try:
return float(s)
except ValueError:
return None
material_id = request.POST.get('id')
category_id = request.POST.get('category_id')
steel_grade_id = request.POST.get('steel_grade_id')
name = (request.POST.get('name') or '').strip()
mass_per_unit = parse_float(request.POST.get('mass_per_unit'))
if not (category_id and str(category_id).isdigit() and name):
return JsonResponse({'error': 'invalid'}, status=400)
if material_id and str(material_id).isdigit():
material = get_object_or_404(Material, pk=int(material_id))
else:
material = Material()
material.category_id = int(category_id)
material.name = name
material.mass_per_unit = mass_per_unit
if steel_grade_id and str(steel_grade_id).isdigit():
material.steel_grade_id = int(steel_grade_id)
else:
material.steel_grade_id = None
material.save()
return JsonResponse({'id': material.id, 'label': material.full_name})
class CompanyUpsertView(LoginRequiredMixin, View):
def post(self, request, *args, **kwargs):
roles = get_user_roles(request.user)
if not has_any_role(roles, ['admin', 'clerk', 'manager', 'technologist']):
return JsonResponse({'error': 'forbidden'}, status=403)
company_id = request.POST.get('id')
name = (request.POST.get('name') or '').strip()
description = (request.POST.get('description') or '').strip()
if not name:
return JsonResponse({'error': 'name_required'}, status=400)
if company_id and str(company_id).isdigit():
company = get_object_or_404(Company, pk=int(company_id))
company.name = name
else:
company, _ = Company.objects.get_or_create(name=name)
company.description = description
company.save()
return JsonResponse({'id': company.id, 'label': company.name})
class MaterialCategoryUpsertView(LoginRequiredMixin, View):
def post(self, request, *args, **kwargs):
profile = getattr(request.user, 'profile', None)
role = profile.role if profile else ('admin' if request.user.is_superuser else 'operator')
if role not in ['admin', 'technologist']:
return JsonResponse({'error': 'forbidden'}, status=403)
category_id = request.POST.get('id')
name = (request.POST.get('name') or '').strip()
gost_standard = (request.POST.get('gost_standard') or '').strip()
form_factor = (request.POST.get('form_factor') or '').strip() or 'other'
if not name:
return JsonResponse({'error': 'name_required'}, status=400)
if category_id and str(category_id).isdigit():
category = get_object_or_404(MaterialCategory, pk=int(category_id))
category.name = name
else:
category, _ = MaterialCategory.objects.get_or_create(name=name)
category.gost_standard = gost_standard
if form_factor in ['sheet', 'bar', 'other']:
category.form_factor = form_factor
category.save()
return JsonResponse({'id': category.id, 'label': category.name})
class SteelGradeUpsertView(LoginRequiredMixin, View):
def post(self, request, *args, **kwargs):
roles = get_user_roles(request.user)
if not has_any_role(roles, ['admin', 'technologist', 'manager', 'prod_head']):
return JsonResponse({'error': 'forbidden'}, status=403)
grade_id = request.POST.get('id')
name = (request.POST.get('name') or '').strip()
gost_standard = (request.POST.get('gost_standard') or '').strip()
if not name:
return JsonResponse({'error': 'name_required'}, status=400)
if grade_id and str(grade_id).isdigit():
grade = get_object_or_404(SteelGrade, pk=int(grade_id))
grade.name = name
else:
grade, _ = SteelGrade.objects.get_or_create(name=name)
grade.gost_standard = gost_standard
grade.save()
return JsonResponse({'id': grade.id, 'label': grade.name})
class EntitiesSearchView(LoginRequiredMixin, View):
"""JSON-поиск сущностей ProductEntity для модальных окон.
Использование на фронтенде (пример):
/entities/search/?entity_type=part&q_dn=12.34&q_name=косынка
Возвращает:
{
"results": [{"id": 1, "type": "part", "drawing_number": "...", "name": "..."}, ...],
"count": 10
}
Диагностика:
- логируем start/forbidden/done (без секретов) для разбора кейсов «ничего не находит».
"""
def get(self, request, *args, **kwargs):
roles = get_user_roles(request.user) # Роли пользователя (Django Groups + fallback на profile.role)
if not has_any_role(roles, ['admin', 'technologist', 'manager', 'prod_head']): # Доступ только для этих ролей
logger.info('entities_search:forbidden user_id=%s roles=%s', request.user.id, sorted(roles))
return JsonResponse({'error': 'forbidden'}, status=403) # Для фронта это сигнал показать «нет доступа»
q_dn = (request.GET.get('q_dn') or '').strip() # Поиск по обозначению (drawing_number), подстрока
q_name = (request.GET.get('q_name') or '').strip() # Поиск по наименованию (name), подстрока
et = (request.GET.get('entity_type') or '').strip() # Фильтр по типу сущности (ProductEntity.entity_type)
logger.info('entities_search:start user_id=%s et=%s q_dn=%s q_name=%s', request.user.id, et, q_dn, q_name)
qs = ProductEntity.objects.all() # Базовая выборка по всем сущностям КД
# Фильтр по типу включаем для всех допустимых типов ProductEntity.
allowed_types = {'product', 'assembly', 'part', 'purchased', 'casting', 'outsourced'}
if et in allowed_types:
qs = qs.filter(entity_type=et)
if q_dn:
qs = qs.filter(drawing_number__icontains=q_dn) # ILIKE по drawing_number
if q_name:
qs = qs.filter(name__icontains=q_name) # ILIKE по name
qs = qs.order_by('entity_type', 'drawing_number', 'name', 'id')
data = [ # Формируем компактный JSON (только то, что нужно для селекта в модалке)
{
'id': e.id, # PK сущности
'type': e.entity_type, # Код типа
'drawing_number': e.drawing_number, # Обозначение
'name': e.name, # Наименование
}
for e in qs[:200] # Ограничиваем ответ 200 строками
]
logger.info('entities_search:done user_id=%s count=%s', request.user.id, len(data))
return JsonResponse({'results': data, 'count': len(data)}) # Отдаём результаты в JSON
class DealBatchActionView(LoginRequiredMixin, View):
def post(self, request, *args, **kwargs):
profile = getattr(request.user, 'profile', None)
role = profile.role if profile else ('admin' if request.user.is_superuser else 'operator')
if role not in ['admin', 'technologist']:
return redirect('planning')
action = (request.POST.get('action') or '').strip()
next_url = (request.POST.get('next') or '').strip()
if not next_url.startswith('/'):
next_url = str(reverse_lazy('planning'))
def parse_int(s):
s = (s or '').strip()
return int(s) if s.isdigit() else None
deal_id = parse_int(request.POST.get('deal_id'))
if action == 'create_batch':
due_date = (request.POST.get('due_date') or '').strip()
name = (request.POST.get('name') or '').strip()[:120]
if not deal_id or not due_date:
messages.error(request, 'Заполни дату отгрузки.')
return redirect(next_url)
try:
dd = datetime.strptime(due_date, '%Y-%m-%d').date()
except Exception:
messages.error(request, 'Некорректная дата.')
return redirect(next_url)
DealDeliveryBatch.objects.create(deal_id=deal_id, due_date=dd, name=name, is_default=False)
_reconcile_default_delivery_batch(int(deal_id))
messages.success(request, 'Партия добавлена.')
return redirect(next_url)
if action == 'delete_batch':
batch_id = parse_int(request.POST.get('batch_id'))
if not batch_id:
return redirect(next_url)
b = DealDeliveryBatch.objects.filter(id=batch_id, deal_id=deal_id).first()
if b and getattr(b, 'is_default', False):
messages.error(request, 'Дефолтная партия рассчитывается автоматически и не удаляется.')
return redirect(next_url)
DealDeliveryBatch.objects.filter(id=batch_id, deal_id=deal_id).delete()
_reconcile_default_delivery_batch(int(deal_id))
messages.success(request, 'Партия удалена.')
return redirect(next_url)
if action == 'add_batch_item':
batch_id = parse_int(request.POST.get('batch_id'))
entity_id = parse_int(request.POST.get('entity_id'))
qty = parse_int(request.POST.get('quantity'))
if not (deal_id and batch_id and entity_id and qty and qty > 0):
messages.error(request, 'Заполни позицию и количество.')
return redirect(next_url)
batch = DealDeliveryBatch.objects.filter(id=batch_id, deal_id=deal_id).first()
if not batch:
messages.error(request, 'Партия не найдена.')
return redirect(next_url)
if getattr(batch, 'is_default', False):
messages.error(request, 'Дефолтная партия заполняется автоматически. Создай партию с датой и распределяй туда.')
return redirect(next_url)
deal_item = DealItem.objects.filter(deal_id=deal_id, entity_id=entity_id).first()
if not deal_item:
messages.error(request, 'Добавлять в партию можно только позиции из сделки.')
return redirect(next_url)
existing = DealBatchItem.objects.filter(batch_id=batch_id, entity_id=entity_id).first()
allocated_other = (
DealBatchItem.objects.filter(batch__deal_id=deal_id, batch__is_default=False, entity_id=entity_id)
.exclude(id=existing.id if existing else None)
.aggregate(s=Coalesce(Sum('quantity'), 0))['s']
)
total = int(deal_item.quantity or 0)
if qty + int(allocated_other or 0) > total:
messages.error(request, 'Нельзя распределить больше, чем заказано по позиции сделки.')
return redirect(next_url)
if existing:
if existing.quantity != qty:
existing.quantity = qty
existing.save(update_fields=['quantity'])
else:
DealBatchItem.objects.create(batch_id=batch_id, entity_id=entity_id, quantity=qty)
_reconcile_default_delivery_batch(int(deal_id))
messages.success(request, 'Позиция партии сохранена.')
return redirect(next_url)
if action == 'update_batch_item_qty':
item_id = parse_int(request.POST.get('item_id'))
qty = parse_int(request.POST.get('quantity'))
if not item_id or not qty or qty <= 0:
messages.error(request, 'Заполни количество.')
return redirect(next_url)
bi = DealBatchItem.objects.select_related('batch').filter(id=item_id, batch__deal_id=deal_id).first()
if not bi:
return redirect(next_url)
if getattr(getattr(bi, 'batch', None), 'is_default', False):
messages.error(request, 'Количество дефолтной партии рассчитывается автоматически.')
return redirect(next_url)
deal_item = DealItem.objects.filter(deal_id=deal_id, entity_id=bi.entity_id).first()
if not deal_item:
messages.error(request, 'Позиция не найдена в списке позиций сделки.')
return redirect(next_url)
allocated_other = (
DealBatchItem.objects.filter(batch__deal_id=deal_id, batch__is_default=False, entity_id=bi.entity_id)
.exclude(id=bi.id)
.aggregate(s=Coalesce(Sum('quantity'), 0))['s']
)
total = int(deal_item.quantity or 0)
if qty + int(allocated_other or 0) > total:
messages.error(request, 'Нельзя распределить больше, чем заказано по позиции сделки.')
return redirect(next_url)
if bi.quantity != qty:
bi.quantity = qty
bi.save(update_fields=['quantity'])
_reconcile_default_delivery_batch(int(deal_id))
messages.success(request, 'Количество обновлено.')
return redirect(next_url)
if action == 'delete_batch_item':
item_id = parse_int(request.POST.get('item_id'))
if not item_id:
return redirect(next_url)
bi = DealBatchItem.objects.select_related('batch').filter(id=item_id, batch__deal_id=deal_id).first()
if bi and getattr(getattr(bi, 'batch', None), 'is_default', False):
messages.error(request, 'Дефолтная партия рассчитывается автоматически.')
return redirect(next_url)
DealBatchItem.objects.filter(id=item_id, batch__deal_id=deal_id).delete()
_reconcile_default_delivery_batch(int(deal_id))
messages.success(request, 'Строка удалена.')
return redirect(next_url)
if action == 'rollback_batch_item_production':
item_id = parse_int(request.POST.get('item_id'))
qty = parse_int(request.POST.get('quantity'))
if not item_id or not qty or qty <= 0:
messages.error(request, 'Заполни количество.')
return redirect(next_url)
logger.info('rollback_batch_item_production:start deal_id=%s item_id=%s qty=%s', deal_id, item_id, qty)
try:
with transaction.atomic():
bi = (
DealBatchItem.objects.select_for_update()
.select_related('batch', 'entity')
.filter(id=item_id, batch__deal_id=deal_id)
.first()
)
if not bi:
messages.error(request, 'Строка партии не найдена.')
return redirect(next_url)
started = int(getattr(bi, 'started_qty', 0) or 0)
if int(qty) > started:
messages.error(request, 'Нельзя откатить больше, чем запущено в этой партии.')
return redirect(next_url)
# Комментарий: откат запрещён, если хоть что-то из этого запуска уже попало в смену.
adjacency = _build_bom_graph({int(bi.entity_id)})
required_nodes: dict[int, int] = {}
_accumulate_requirements(int(bi.entity_id), int(qty), adjacency, set(), required_nodes)
affected_ids = list(required_nodes.keys())
wi_exists = WorkItem.objects.filter(deal_id=deal_id, entity_id__in=affected_ids).filter(
Q(quantity_plan__gt=0) | Q(quantity_done__gt=0)
).exists()
if wi_exists:
messages.error(request, 'Нельзя откатить: по этой позиции уже есть постановка в смену (план/факт).')
return redirect(next_url)
stats = rollback_roots_additive(int(deal_id), [(int(bi.entity_id), int(qty))])
bi.started_qty = started - int(qty)
bi.save(update_fields=['started_qty'])
messages.success(request, f'Откат выполнен: {qty} шт. Задачи обновлено: {stats.tasks_updated}.')
logger.info(
'rollback_batch_item_production:done deal_id=%s item_id=%s entity_id=%s qty=%s tasks_updated=%s',
deal_id,
item_id,
bi.entity_id,
qty,
stats.tasks_updated,
)
return redirect(next_url)
except Exception:
logger.exception('rollback_batch_item_production:error deal_id=%s item_id=%s qty=%s', deal_id, item_id, qty)
messages.error(request, 'Ошибка отката запуска. Подробности в логе сервера.')
return redirect(next_url)
if action == 'start_batch_item_production':
item_id = parse_int(request.POST.get('item_id'))
qty = parse_int(request.POST.get('quantity'))
if not item_id or not qty or qty <= 0:
messages.error(request, 'Заполни количество.')
return redirect(next_url)
logger.info('start_batch_item_production: deal_id=%s item_id=%s qty=%s', deal_id, item_id, qty)
try:
with transaction.atomic():
bi = (
DealBatchItem.objects.select_for_update()
.select_related('batch', 'entity')
.filter(id=item_id, batch__deal_id=deal_id)
.first()
)
if not bi:
messages.error(request, 'Строка партии не найдена.')
return redirect(next_url)
et = getattr(bi.entity, 'entity_type', '')
if et in ['purchased', 'casting', 'outsourced']:
messages.error(request, 'Эта позиция относится к снабжению. Запуск через производство пока не реализован.')
logger.info('start_batch_item_production: skipped supply entity_type=%s entity_id=%s', et, bi.entity_id)
return redirect(next_url)
started = int(getattr(bi, 'started_qty', 0) or 0)
total = int(bi.quantity or 0)
remaining = total - started
if qty > remaining:
messages.error(request, 'Нельзя запустить больше, чем осталось в партии.')
logger.info('start_batch_item_production: qty_exceeds_remaining remaining=%s started=%s total=%s', remaining, started, total)
return redirect(next_url)
stats = explode_roots_additive(int(deal_id), [(int(bi.entity_id), int(qty))])
bi.started_qty = started + int(qty)
bi.save(update_fields=['started_qty'])
if int(stats.tasks_created or 0) == 0 and int(stats.tasks_updated or 0) == 0:
messages.warning(request, 'Запуск выполнен, но задачи не созданы. Проверь, что leaf-детали имеют материал (planned_material) и не относятся к снабжению.')
else:
messages.success(request, f'Запущено в производство: {qty} шт. Задачи: +{stats.tasks_created} / обновлено {stats.tasks_updated}.')
logger.info(
'start_batch_item_production: ok deal_id=%s entity_id=%s qty=%s tasks_created=%s tasks_updated=%s',
deal_id,
bi.entity_id,
qty,
stats.tasks_created,
stats.tasks_updated,
)
return redirect(next_url)
except ExplosionValidationError as ev:
try:
from manufacturing.models import ProductEntity
bad = list(ProductEntity.objects.filter(id__in=list(ev.missing_material_ids)).values_list('drawing_number', 'name'))
except Exception:
bad = []
if bad:
preview = ", ".join([f"{dn or ''} {nm}" for dn, nm in bad[:5]])
more = '' if len(bad) <= 5 else f" и ещё {len(bad)-5}"
messages.error(request, f'В спецификации есть детали без материала: {preview}{more}. Добавь material в паспорт(ы) и повтори запуск.')
else:
messages.error(request, 'В спецификации есть детали без материала. Добавь material и повтори запуск.')
return redirect(next_url)
except Exception:
logger.exception('start_batch_item_production: failed deal_id=%s item_id=%s qty=%s', deal_id, item_id, qty)
messages.error(request, 'Ошибка запуска в производство. Подробности в логе сервера.')
return redirect(next_url)
messages.error(request, 'Неизвестное действие.')
return redirect(next_url)
class DealItemUpsertView(LoginRequiredMixin, View):
def post(self, request, *args, **kwargs):
roles = get_user_roles(request.user)
if not has_any_role(roles, ['admin', 'technologist', 'manager', 'prod_head']):
return redirect('planning')
action = (request.POST.get('action') or '').strip()
if not action:
action = 'add'
next_url = (request.POST.get('next') or '').strip()
next_url = next_url if next_url.startswith('/') else str(reverse_lazy('planning'))
def parse_int(s):
s = (s or '').strip()
return int(s) if s.isdigit() else None
deal_id = parse_int(request.POST.get('deal_id'))
entity_id = parse_int(request.POST.get('entity_id'))
qty = parse_int(request.POST.get('quantity'))
if not (deal_id and entity_id):
messages.error(request, 'Не выбрана сделка или сущность.')
return redirect(next_url)
if action in ['add', 'set_qty']:
if not (qty and qty > 0):
messages.error(request, 'Заполни количество (больше 0).')
return redirect(next_url)
try:
with transaction.atomic():
if action == 'delete':
item = DealItem.objects.select_for_update().filter(deal_id=deal_id, entity_id=entity_id).first()
if not item:
messages.error(request, 'Позиция сделки не найдена.')
return redirect(next_url)
started = (
DealBatchItem.objects.filter(batch__deal_id=deal_id, entity_id=entity_id)
.aggregate(s=Coalesce(Sum('started_qty'), 0))['s']
)
started = int(started or 0)
wi_agg = (
WorkItem.objects.filter(deal_id=deal_id, entity_id=entity_id)
.aggregate(p=Coalesce(Sum('quantity_plan'), 0), d=Coalesce(Sum('quantity_done'), 0))
)
planned = int((wi_agg or {}).get('p') or 0)
done = int((wi_agg or {}).get('d') or 0)
allocated = (
DealBatchItem.objects.filter(batch__deal_id=deal_id, entity_id=entity_id)
.aggregate(s=Coalesce(Sum('quantity'), 0))['s']
)
allocated = int(allocated or 0)
if started > 0 or planned > 0 or done > 0:
messages.error(request, 'Нельзя удалить позицию: по ней уже есть запуск/план/факт. Сначала откати производство.')
return redirect(next_url)
if allocated > 0:
messages.error(request, 'Нельзя удалить позицию: она уже распределена по партиям поставки. Сначала удали строки партий.')
return redirect(next_url)
DealEntityProgress.objects.filter(deal_id=deal_id, entity_id=entity_id).delete()
WorkItem.objects.filter(deal_id=deal_id, entity_id=entity_id).delete()
DealBatchItem.objects.filter(batch__deal_id=deal_id, entity_id=entity_id).delete()
item.delete()
_reconcile_default_delivery_batch(int(deal_id))
messages.success(request, 'Позиция удалена из сделки.')
return redirect(next_url)
item, created = DealItem.objects.select_for_update().get_or_create(
deal_id=deal_id,
entity_id=entity_id,
defaults={'quantity': int(qty)},
)
if action == 'add':
if not created:
messages.warning(request, 'Позиция уже есть в сделке. Измени количество в строке позиции (OK).')
return redirect(next_url)
item.quantity = int(qty)
item.save(update_fields=['quantity'])
_reconcile_default_delivery_batch(int(deal_id))
messages.success(request, 'Позиция сделки добавлена.')
return redirect(next_url)
if action == 'set_qty':
started = (
DealBatchItem.objects.filter(batch__deal_id=deal_id, entity_id=entity_id)
.aggregate(s=Coalesce(Sum('started_qty'), 0))['s']
)
started = int(started or 0)
allocated_non_default = (
DealBatchItem.objects.filter(batch__deal_id=deal_id, batch__is_default=False, entity_id=entity_id)
.aggregate(s=Coalesce(Sum('quantity'), 0))['s']
)
allocated_non_default = int(allocated_non_default or 0)
if int(qty) < started:
messages.error(request, f'Нельзя поставить {qty} шт: уже запущено {started} шт в производство.')
return redirect(next_url)
if int(qty) < allocated_non_default:
messages.error(request, f'Нельзя поставить {qty} шт: по партиям уже распределено {allocated_non_default} шт.')
return redirect(next_url)
before = int(item.quantity or 0)
if before != int(qty):
item.quantity = int(qty)
item.save(update_fields=['quantity'])
_reconcile_default_delivery_batch(int(deal_id))
messages.success(request, f'Количество по сделке обновлено: {before}{item.quantity}.')
return redirect(next_url)
messages.error(request, 'Неизвестное действие.')
return redirect(next_url)
except Exception as e:
messages.error(request, f'Ошибка: {e}')
return redirect(next_url)
class DirectoriesView(LoginRequiredMixin, TemplateView):
template_name = 'shiftflow/directories.html'
def dispatch(self, request, *args, **kwargs):
roles = get_user_roles(request.user)
if not has_any_role(roles, ['admin', 'technologist', 'master', 'clerk', 'observer', 'prod_head', 'director']):
return redirect('registry')
return super().dispatch(request, *args, **kwargs)
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
roles = get_user_roles(self.request.user)
role = primary_role(roles)
ctx['user_role'] = role
ctx['user_roles'] = sorted(roles)
return ctx
class LocationsCatalogView(LoginRequiredMixin, TemplateView):
template_name = 'shiftflow/locations_catalog.html'
def dispatch(self, request, *args, **kwargs):
roles = get_user_roles(request.user)
if not has_any_role(roles, ['admin', 'technologist', 'master', 'clerk', 'observer', 'prod_head', 'director']):
return redirect('registry')
return super().dispatch(request, *args, **kwargs)
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
roles = get_user_roles(self.request.user)
role = primary_role(roles)
ctx['user_role'] = role
ctx['user_roles'] = sorted(roles)
ctx['can_edit'] = has_any_role(roles, ['admin', 'prod_head', 'director'])
ctx['locations'] = list(Location.objects.order_by('name'))
return ctx
def post(self, request, *args, **kwargs):
roles = get_user_roles(request.user)
if not has_any_role(roles, ['admin', 'prod_head', 'director']):
return redirect('locations_catalog')
action = (request.POST.get('action') or '').strip()
name = (request.POST.get('name') or '').strip()
if action == 'create':
if not name:
messages.error(request, 'Заполни название склада.')
return redirect('locations_catalog')
obj = Location(name=name[:100])
try:
obj.full_clean()
obj.save()
messages.success(request, 'Склад создан.')
except Exception as e:
messages.error(request, f'Ошибка: {e}')
return redirect('locations_catalog')
if action == 'update':
lid = (request.POST.get('location_id') or '').strip()
if not lid.isdigit():
return redirect('locations_catalog')
obj = get_object_or_404(Location, pk=int(lid))
if name:
obj.name = name[:100]
try:
obj.full_clean()
obj.save()
messages.success(request, 'Склад обновлён.')
except Exception as e:
messages.error(request, f'Ошибка: {e}')
return redirect('locations_catalog')
return redirect('locations_catalog')
class WorkshopsCatalogView(LoginRequiredMixin, TemplateView):
template_name = 'shiftflow/workshops_catalog.html'
def dispatch(self, request, *args, **kwargs):
roles = get_user_roles(request.user)
if not has_any_role(roles, ['admin', 'technologist', 'master', 'clerk', 'observer', 'prod_head', 'director']):
return redirect('registry')
return super().dispatch(request, *args, **kwargs)
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
roles = get_user_roles(self.request.user)
role = primary_role(roles)
ctx['user_role'] = role
ctx['user_roles'] = sorted(roles)
workshops = list(Workshop.objects.select_related('location').order_by('id'))
ws_ids = [int(w.id) for w in workshops]
machines = list(Machine.objects.filter(workshop_id__in=ws_ids).order_by('name', 'id'))
by_ws = {}
for m in machines:
by_ws.setdefault(int(m.workshop_id), []).append(m)
for ws in workshops:
ms = by_ws.get(int(ws.id)) or []
labels = [x.name for x in ms][:8]
tail = ''
if len(ms) > 8:
tail = f" +{len(ms) - 8}"
ws.machine_labels = ', '.join(labels) + tail if labels else ''
ctx['workshops'] = workshops
return ctx
class MachinesCatalogView(LoginRequiredMixin, TemplateView):
template_name = 'shiftflow/machines_catalog.html'
def dispatch(self, request, *args, **kwargs):
roles = get_user_roles(request.user)
if not has_any_role(roles, ['admin', 'technologist', 'master', 'clerk', 'observer', 'prod_head', 'director']):
return redirect('registry')
self.roles = roles
self.role = primary_role(roles)
self.can_edit = has_any_role(roles, ['admin', 'prod_head', 'director'])
return super().dispatch(request, *args, **kwargs)
def _workshop_id(self):
ws_id = (self.request.GET.get('workshop_id') or '').strip()
return int(ws_id) if ws_id.isdigit() else None
def get(self, request, *args, **kwargs):
if not self._workshop_id():
return redirect('workshops_catalog')
return super().get(request, *args, **kwargs)
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['user_role'] = self.role
ctx['can_edit'] = self.can_edit
ws_id = self._workshop_id()
workshop = get_object_or_404(Workshop.objects.select_related('location'), pk=int(ws_id))
ctx['workshop'] = workshop
ctx['locations'] = list(Location.objects.order_by('name'))
machines = list(Machine.objects.filter(workshop_id=workshop.id).order_by('name', 'id'))
ctx['machines'] = machines
ctx['machine_types'] = list(getattr(Machine, 'MACHINE_TYPE_CHOICES', []))
return ctx
def post(self, request, *args, **kwargs):
ws_id = self._workshop_id()
if not ws_id:
return redirect('workshops_catalog')
if not self.can_edit:
return redirect(f"{reverse_lazy('machines_catalog')}?workshop_id={int(ws_id)}")
workshop = get_object_or_404(Workshop, pk=int(ws_id))
action = (request.POST.get('action') or '').strip()
if action == 'update_workshop':
name = (request.POST.get('name') or '').strip()
location_id = (request.POST.get('location_id') or '').strip()
if name:
workshop.name = name[:120]
workshop.location_id = int(location_id) if location_id.isdigit() else None
try:
workshop.full_clean()
workshop.save()
messages.success(request, 'Цех сохранён.')
except Exception as e:
messages.error(request, f'Ошибка: {e}')
return redirect(f"{reverse_lazy('machines_catalog')}?workshop_id={workshop.id}")
if action == 'create_machine':
name = (request.POST.get('name') or '').strip()
machine_type = (request.POST.get('machine_type') or '').strip()
if not name:
messages.error(request, 'Заполни название поста.')
return redirect(f"{reverse_lazy('machines_catalog')}?workshop_id={workshop.id}")
allowed = {t[0] for t in getattr(Machine, 'MACHINE_TYPE_CHOICES', [])}
if machine_type not in allowed:
machine_type = 'linear'
m = Machine(name=name[:100], workshop_id=workshop.id, machine_type=machine_type)
try:
m.full_clean()
m.save()
messages.success(request, 'Пост добавлен.')
except Exception as e:
messages.error(request, f'Ошибка: {e}')
return redirect(f"{reverse_lazy('machines_catalog')}?workshop_id={workshop.id}")
if action == 'update_machine':
mid = (request.POST.get('machine_id') or '').strip()
if not mid.isdigit():
return redirect(f"{reverse_lazy('machines_catalog')}?workshop_id={workshop.id}")
m = get_object_or_404(Machine, pk=int(mid), workshop_id=workshop.id)
name = (request.POST.get('name') or '').strip()
machine_type = (request.POST.get('machine_type') or '').strip()
if name:
m.name = name[:100]
allowed = {t[0] for t in getattr(Machine, 'MACHINE_TYPE_CHOICES', [])}
if machine_type in allowed:
m.machine_type = machine_type
try:
m.full_clean()
m.save()
messages.success(request, 'Пост сохранён.')
except Exception as e:
messages.error(request, f'Ошибка: {e}')
return redirect(f"{reverse_lazy('machines_catalog')}?workshop_id={workshop.id}")
if action == 'delete_machine':
mid = (request.POST.get('machine_id') or '').strip()
if not mid.isdigit():
return redirect(f"{reverse_lazy('machines_catalog')}?workshop_id={workshop.id}")
m = get_object_or_404(Machine, pk=int(mid), workshop_id=workshop.id)
# Защита удаления: не удаляем пост, если по нему есть сменка/задания.
has_items = Item.objects.filter(machine_id=m.id).exists()
has_workitems = WorkItem.objects.filter(machine_id=m.id).exists()
if has_items or has_workitems:
messages.error(request, 'Нельзя удалить пост: по нему есть сменные задания или производственные операции.')
return redirect(f"{reverse_lazy('machines_catalog')}?workshop_id={workshop.id}")
try:
m.delete()
messages.success(request, 'Пост удалён.')
except Exception as e:
messages.error(request, f'Нельзя удалить пост: {e}')
return redirect(f"{reverse_lazy('machines_catalog')}?workshop_id={workshop.id}")
messages.error(request, 'Неизвестное действие.')
return redirect(f"{reverse_lazy('machines_catalog')}?workshop_id={workshop.id}")
class SupplyCatalogView(LoginRequiredMixin, TemplateView):
template_name = 'shiftflow/supply_catalog.html'
TYPE_CHOICES = [
('purchased', 'Покупное'),
('outsourced', 'Аутсорс'),
]
def dispatch(self, request, *args, **kwargs):
profile = getattr(request.user, 'profile', None)
role = profile.role if profile else ('admin' if request.user.is_superuser else 'operator')
if role not in ['admin', 'technologist', 'master', 'clerk']:
return redirect('registry')
return super().dispatch(request, *args, **kwargs)
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
profile = getattr(self.request.user, 'profile', None)
role = profile.role if profile else ('admin' if self.request.user.is_superuser else 'operator')
ctx['user_role'] = role
ctx['can_edit'] = role in ['admin', 'technologist', 'clerk']
q = (self.request.GET.get('q') or '').strip()
entity_types = [x.strip() for x in self.request.GET.getlist('types') if (x or '').strip()]
allowed_types = {c[0] for c in self.TYPE_CHOICES}
entity_types = [x for x in entity_types if x in allowed_types]
if not entity_types:
entity_types = list(allowed_types)
qs = ProductEntity.objects.all().filter(entity_type__in=entity_types)
if q:
qs = qs.filter(Q(drawing_number__icontains=q) | Q(name__icontains=q))
ctx['q'] = q
ctx['entity_types'] = entity_types
ctx['type_choices'] = list(self.TYPE_CHOICES)
ctx['items'] = qs.order_by('entity_type', 'drawing_number', 'name', 'id')
return ctx
def post(self, request, *args, **kwargs):
profile = getattr(request.user, 'profile', None)
role = profile.role if profile else ('admin' if request.user.is_superuser else 'operator')
if role not in ['admin', 'technologist', 'clerk']:
return redirect('supply_catalog')
entity_type = (request.POST.get('entity_type') or '').strip()
name = (request.POST.get('name') or '').strip()
drawing_number = (request.POST.get('drawing_number') or '').strip()
allowed_types = {c[0] for c in self.TYPE_CHOICES}
if entity_type not in allowed_types:
messages.error(request, 'Выбери тип: покупное / аутсорс.')
return redirect('supply_catalog')
if not name:
messages.error(request, 'Заполни наименование.')
return redirect('supply_catalog')
obj = ProductEntity.objects.create(
entity_type=entity_type,
name=name[:255],
drawing_number=drawing_number[:100],
)
return redirect('product_detail', pk=obj.id)
class MaterialsCatalogView(LoginRequiredMixin, TemplateView):
template_name = 'shiftflow/materials_catalog.html'
def dispatch(self, request, *args, **kwargs):
profile = getattr(request.user, 'profile', None)
role = profile.role if profile else ('admin' if request.user.is_superuser else 'operator')
if role not in ['admin', 'technologist', 'master', 'clerk']:
return redirect('registry')
return super().dispatch(request, *args, **kwargs)
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
profile = getattr(self.request.user, 'profile', None)
role = profile.role if profile else ('admin' if self.request.user.is_superuser else 'operator')
ctx['user_role'] = role
ctx['can_edit'] = role in ['admin', 'technologist', 'master', 'clerk']
q = (self.request.GET.get('q') or '').strip()
qs = Material.objects.select_related('category', 'steel_grade').all()
if q:
qs = qs.filter(Q(full_name__icontains=q) | Q(name__icontains=q) | Q(category__name__icontains=q) | Q(steel_grade__name__icontains=q))
def unit_for(m):
ff = getattr(getattr(m, 'category', None), 'form_factor', 'other')
if ff == 'sheet':
return 'кг/кв.м'
if ff == 'bar':
return 'кг/п.м'
return 'кг/шт'
rows = []
for m in qs.order_by('category__name', 'name', 'steel_grade__name', 'id'):
rows.append({
'm': m,
'unit': unit_for(m),
})
ctx['q'] = q
ctx['rows'] = rows
ctx['categories'] = list(MaterialCategory.objects.order_by('name'))
ctx['grades'] = list(SteelGrade.objects.order_by('name'))
return ctx
class MaterialCategoriesCatalogView(LoginRequiredMixin, TemplateView):
template_name = 'shiftflow/material_categories_catalog.html'
def dispatch(self, request, *args, **kwargs):
profile = getattr(request.user, 'profile', None)
role = profile.role if profile else ('admin' if request.user.is_superuser else 'operator')
if role not in ['admin', 'technologist', 'master', 'clerk']:
return redirect('registry')
return super().dispatch(request, *args, **kwargs)
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
profile = getattr(self.request.user, 'profile', None)
role = profile.role if profile else ('admin' if self.request.user.is_superuser else 'operator')
ctx['user_role'] = role
ctx['can_edit'] = role in ['admin', 'technologist', 'master', 'clerk']
q = (self.request.GET.get('q') or '').strip()
qs = MaterialCategory.objects.all()
if q:
qs = qs.filter(Q(name__icontains=q) | Q(gost_standard__icontains=q))
ctx['q'] = q
ctx['categories'] = list(qs.order_by('name'))
return ctx
class SteelGradesCatalogView(LoginRequiredMixin, TemplateView):
template_name = 'shiftflow/steel_grades_catalog.html'
def dispatch(self, request, *args, **kwargs):
profile = getattr(request.user, 'profile', None)
role = profile.role if profile else ('admin' if request.user.is_superuser else 'operator')
if role not in ['admin', 'technologist', 'master', 'clerk']:
return redirect('registry')
return super().dispatch(request, *args, **kwargs)
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
profile = getattr(self.request.user, 'profile', None)
role = profile.role if profile else ('admin' if self.request.user.is_superuser else 'operator')
ctx['user_role'] = role
ctx['can_edit'] = role in ['admin', 'technologist', 'master', 'clerk']
q = (self.request.GET.get('q') or '').strip()
qs = SteelGrade.objects.all()
if q:
qs = qs.filter(Q(name__icontains=q) | Q(gost_standard__icontains=q))
ctx['q'] = q
ctx['grades'] = list(qs.order_by('name'))
return ctx
# Вьюха детального вида и редактирования
class ItemUpdateView(LoginRequiredMixin, UpdateView):
model = Item
template_name = 'shiftflow/item_detail.html'
# Перечисляем поля, которые можно редактировать в сменке
fields = [
'machine',
'quantity_plan',
'quantity_fact',
'status',
'is_synced_1c',
]
context_object_name = 'item'
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
profile = getattr(self.request.user, 'profile', None)
role = profile.role if profile else ('admin' if self.request.user.is_superuser else 'operator')
context['user_role'] = role
context['machines'] = Machine.objects.all()
# Вычисляем URL "Назад": приоритетно берём ?next=..., иначе пробуем Referer
# Используем только ссылки на текущий хост, чтобы избежать внешних редиректов
next_url = (self.request.GET.get('next') or '').strip()
back_url = ''
if next_url.startswith('/'):
back_url = next_url
else:
ref = (self.request.META.get('HTTP_REFERER') or '').strip()
if ref:
parts = urlsplit(ref)
if parts.netloc == self.request.get_host():
back_url = parts.path + (('?' + parts.query) if parts.query else '')
if not back_url:
back_url = str(reverse_lazy('registry'))
context['back_url'] = back_url
return context
def post(self, request, *args, **kwargs):
self.object = self.get_object()
profile = getattr(request.user, 'profile', None)
role = profile.role if profile else ('admin' if request.user.is_superuser else 'operator')
# Поддерживаем "умный" возврат после действия: ?next=... или Referer
next_url = (request.POST.get('next') or '').strip()
if not next_url:
ref = (request.META.get('HTTP_REFERER') or '').strip()
if ref:
parts = urlsplit(ref)
if parts.netloc == request.get_host():
next_url = parts.path + (('?' + parts.query) if parts.query else '')
def redirect_back():
# Возвращаемся туда, откуда пришли, иначе в реестр
if next_url.startswith('/'):
return redirect(next_url)
return redirect('registry')
if role in ['admin', 'technologist']:
# Действие формы (обычное сохранение или закрытие позиции)
action = request.POST.get('action', 'save')
# Админ может заменить файлы у детали прямо из карточки пункта сменки.
# Файлы лежат на ProductionTask (основании), а не на Item.
if role == 'admin' and self.object.task_id:
# Админ может заменить файлы детали. После замены:
# - сбрасываем превью;
# - пытаемся сразу извлечь габариты из DXF.
task = self.object.task
drawing_file = request.FILES.get('drawing_file')
extra_drawing = request.FILES.get('extra_drawing')
changed = False
if drawing_file is not None:
task.drawing_file = drawing_file
changed = True
if extra_drawing is not None:
task.extra_drawing = extra_drawing
changed = True
if changed:
task.preview_image = None
# Переcчёт габаритов, если это DXF
dims = ''
try:
if drawing_file is not None and (drawing_file.name or '').lower().endswith('.dxf'):
# временно сохраняем файл на объекте до .save()
pass
path = getattr(task.drawing_file, 'path', '')
if path and path.lower().endswith('.dxf'):
dims = _extract_dxf_dimensions(path)
except Exception:
dims = ''
task.blank_dimensions = dims
task.save()
machine_id = request.POST.get('machine')
if machine_id and machine_id.isdigit():
self.object.machine_id = int(machine_id)
date_value = request.POST.get('date')
if date_value:
self.object.date = date_value
quantity_plan = request.POST.get('quantity_plan')
if quantity_plan and quantity_plan.isdigit():
self.object.quantity_plan = int(quantity_plan)
quantity_fact = request.POST.get('quantity_fact')
if quantity_fact and quantity_fact.isdigit():
self.object.quantity_fact = int(quantity_fact)
self.object.is_synced_1c = bool(request.POST.get('is_synced_1c'))
# Действия закрытия для админа/технолога
if action == 'close_done' and self.object.status == 'work':
self.object.quantity_fact = self.object.quantity_plan
self.object.status = 'done'
self.object.save()
return redirect_back()
if action == 'close_partial' and self.object.status == 'work':
try:
fact = int(request.POST.get('quantity_fact', '0'))
except ValueError:
fact = 0
fact = max(0, min(fact, self.object.quantity_plan))
residual = self.object.quantity_plan - fact
self.object.quantity_fact = fact
self.object.status = 'partial'
self.object.save()
if residual > 0:
Item.objects.create(
task=self.object.task,
date=self.object.date,
machine=self.object.machine,
quantity_plan=residual,
quantity_fact=0,
status='leftover',
is_synced_1c=False,
)
return redirect_back()
self.object.save()
return redirect_back()
if role in ['operator', 'master']:
action = request.POST.get('action', 'save')
if action != 'save':
return redirect_back()
qf = request.POST.get('quantity_fact')
if qf and qf.isdigit():
self.object.quantity_fact = int(qf)
machine_changed = False
if role == 'master':
machine_id = request.POST.get('machine')
if machine_id and machine_id.isdigit():
self.object.machine_id = int(machine_id)
machine_changed = True
fields = ['quantity_fact']
if machine_changed:
fields.append('machine')
self.object.save(update_fields=fields)
return redirect_back()
if role == 'clerk':
if self.object.status not in ['done', 'partial']:
return redirect_back()
self.object.is_synced_1c = bool(request.POST.get('is_synced_1c'))
self.object.save(update_fields=['is_synced_1c'])
return redirect_back()
return redirect_back()
def get_success_url(self):
return reverse_lazy('registry')
class WarehouseStocksView(LoginRequiredMixin, TemplateView):
template_name = 'shiftflow/warehouse_stocks.html'
def dispatch(self, request, *args, **kwargs):
roles = get_user_roles(request.user)
if not has_any_role(roles, ['admin', 'technologist', 'master', 'clerk', 'observer', 'prod_head', 'director']):
return redirect('registry')
return super().dispatch(request, *args, **kwargs)
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
profile = getattr(self.request.user, 'profile', None)
roles = get_user_roles(self.request.user)
role = primary_role(roles)
ctx['user_role'] = role
ctx['user_roles'] = sorted(roles)
ship_loc = (
Location.objects.filter(
Q(name__icontains='отгруж')
| Q(name__icontains='Отгруж')
| Q(name__icontains='отгруз')
| Q(name__icontains='Отгруз')
)
.order_by('id')
.first()
)
ship_loc_id = ship_loc.id if ship_loc else None
locations_qs = Location.objects.all().order_by('name')
if ship_loc_id:
locations_qs = locations_qs.exclude(id=ship_loc_id)
locations = list(locations_qs)
ctx['locations'] = locations
q = (self.request.GET.get('q') or '').strip()
location_id = (self.request.GET.get('location_id') or '').strip()
kind = (self.request.GET.get('kind') or '').strip()
start_date = (self.request.GET.get('start_date') or '').strip()
end_date = (self.request.GET.get('end_date') or '').strip()
filtered = self.request.GET.get('filtered')
reset = self.request.GET.get('reset')
is_default = (not filtered) or bool(reset)
if is_default:
today = timezone.localdate()
start = today - timezone.timedelta(days=21)
ctx['start_date'] = start.strftime('%Y-%m-%d')
ctx['end_date'] = today.strftime('%Y-%m-%d')
else:
ctx['start_date'] = start_date
ctx['end_date'] = end_date
qs = StockItem.objects.select_related('location', 'material', 'material__category', 'entity', 'deal').filter(is_archived=False)
if ship_loc_id:
qs = qs.exclude(location_id=ship_loc_id)
if location_id.isdigit():
qs = qs.filter(location_id=int(location_id))
start_val = ctx.get('start_date')
end_val = ctx.get('end_date')
if start_val:
qs = qs.filter(created_at__date__gte=start_val)
if end_val:
qs = qs.filter(created_at__date__lte=end_val)
if kind == 'raw':
qs = qs.filter(material__isnull=False, entity__isnull=True)
elif kind == 'finished':
qs = qs.filter(entity__isnull=False)
elif kind == 'remnant':
qs = qs.filter(is_remnant=True)
if q:
qs = qs.filter(
Q(material__full_name__icontains=q)
| Q(material__name__icontains=q)
| Q(entity__name__icontains=q)
| Q(entity__drawing_number__icontains=q)
| Q(unique_id__icontains=q)
| Q(location__name__icontains=q)
)
ctx['items'] = qs.order_by('-created_at', '-id')
ctx['selected_location_id'] = location_id
ctx['selected_kind'] = kind
ctx['q'] = q
ctx['can_transfer'] = has_any_role(roles, ['admin', 'technologist', 'master', 'clerk', 'prod_head', 'director'])
ctx['can_receive'] = has_any_role(roles, ['admin', 'technologist', 'master', 'clerk', 'prod_head', 'director'])
allowed_transfer_locations = None
if role == 'master' and not has_any_role(roles, ['admin', 'technologist', 'clerk', 'prod_head', 'director']):
allowed_ws_ids = list(profile.allowed_workshops.values_list('id', flat=True)) if profile else []
if not allowed_ws_ids and profile:
user_machine_ids = list(profile.machines.values_list('id', flat=True))
allowed_ws_ids = list(Machine.objects.filter(id__in=user_machine_ids).exclude(workshop_id__isnull=True).values_list('workshop_id', flat=True))
allowed_loc_ids = list(Workshop.objects.filter(id__in=allowed_ws_ids).exclude(location_id__isnull=True).values_list('location_id', flat=True))
if allowed_loc_ids:
allowed_transfer_locations = list(Location.objects.filter(id__in=allowed_loc_ids).order_by('name'))
ctx['transfer_locations'] = allowed_transfer_locations if allowed_transfer_locations is not None else locations
ctx['receipt_locations'] = allowed_transfer_locations if allowed_transfer_locations is not None else locations
ctx['materials'] = Material.objects.select_related('category').all().order_by('full_name')
ctx['entities'] = ProductEntity.objects.all().order_by('drawing_number', 'name')
ctx['deals'] = Deal.objects.select_related('company').all().order_by('-id')
ctx['shipping_location_id'] = ship_loc_id or ''
ctx['shipping_location_label'] = ship_loc.name if ship_loc else ''
return ctx
class WarehouseTransferCreateView(LoginRequiredMixin, View):
def post(self, request, *args, **kwargs):
profile = getattr(request.user, 'profile', None)
roles = get_user_roles(request.user)
role = primary_role(roles)
if not has_any_role(roles, ['admin', 'technologist', 'master', 'clerk', 'prod_head', 'director']):
return JsonResponse({'error': 'forbidden'}, status=403)
stock_item_id = (request.POST.get('stock_item_id') or '').strip()
to_location_id = (request.POST.get('to_location_id') or '').strip()
qty_raw = (request.POST.get('quantity') or '').strip().replace(',', '.')
next_url = (request.POST.get('next') or '').strip()
if not next_url.startswith('/'):
next_url = reverse_lazy('warehouse_stocks')
if not (stock_item_id.isdigit() and to_location_id.isdigit()):
messages.error(request, 'Заполни корректно: позиция склада и склад назначения.')
return redirect(next_url)
try:
qty = float(qty_raw)
except ValueError:
qty = 0.0
if qty <= 0:
messages.error(request, 'Количество должно быть больше 0.')
return redirect(next_url)
si = get_object_or_404(StockItem.objects.select_related('location'), pk=int(stock_item_id))
if int(to_location_id) == si.location_id:
messages.error(request, 'Склад назначения должен отличаться от склада-источника.')
return redirect(next_url)
if role == 'master' and not has_any_role(roles, ['admin', 'technologist', 'clerk', 'prod_head', 'director']):
allowed_ws_ids = list(profile.allowed_workshops.values_list('id', flat=True)) if profile else []
if not allowed_ws_ids and profile:
user_machine_ids = list(profile.machines.values_list('id', flat=True))
allowed_ws_ids = list(Machine.objects.filter(id__in=user_machine_ids).exclude(workshop_id__isnull=True).values_list('workshop_id', flat=True))
allowed_loc_ids = list(Workshop.objects.filter(id__in=allowed_ws_ids).exclude(location_id__isnull=True).values_list('location_id', flat=True))
if not allowed_loc_ids or int(to_location_id) not in {int(x) for x in allowed_loc_ids}:
messages.error(request, 'Мастер может перемещать только на склад своего цеха.')
return redirect(next_url)
tr = TransferRecord.objects.create(
from_location_id=si.location_id,
to_location_id=int(to_location_id),
sender=request.user,
receiver=request.user,
occurred_at=timezone.now(),
status='received',
received_at=timezone.now(),
is_applied=False,
)
TransferLine.objects.create(transfer=tr, stock_item=si, quantity=qty)
try:
receive_transfer(tr.id, request.user.id)
messages.success(request, 'Операция применена.')
except Exception as e:
messages.error(request, f'Ошибка: {e}')
return redirect(next_url)
class WarehouseReceiptCreateView(LoginRequiredMixin, View):
def post(self, request, *args, **kwargs):
roles = get_user_roles(request.user)
if not has_any_role(roles, ['admin', 'technologist', 'master', 'clerk', 'prod_head', 'director']):
return JsonResponse({'error': 'forbidden'}, status=403)
next_url = (request.POST.get('next') or '').strip()
if not next_url.startswith('/'):
next_url = reverse_lazy('warehouse_stocks')
kind = (request.POST.get('kind') or '').strip()
location_id = (request.POST.get('location_id') or '').strip()
deal_id = (request.POST.get('deal_id') or '').strip()
quantity_raw = (request.POST.get('quantity') or '').strip().replace(',', '.')
if not location_id.isdigit():
messages.error(request, 'Выбери склад.')
return redirect(next_url)
profile = getattr(request.user, 'profile', None)
role = primary_role(roles)
if role == 'master' and not has_any_role(roles, ['admin', 'technologist', 'clerk', 'prod_head', 'director']):
allowed_ws_ids = list(profile.allowed_workshops.values_list('id', flat=True)) if profile else []
if not allowed_ws_ids and profile:
user_machine_ids = list(profile.machines.values_list('id', flat=True))
allowed_ws_ids = list(Machine.objects.filter(id__in=user_machine_ids).exclude(workshop_id__isnull=True).values_list('workshop_id', flat=True))
allowed_loc_ids = list(Workshop.objects.filter(id__in=allowed_ws_ids).exclude(location_id__isnull=True).values_list('location_id', flat=True))
if not allowed_loc_ids or int(location_id) not in {int(x) for x in allowed_loc_ids}:
messages.error(request, 'Мастер может делать приход только на склад своего цеха.')
return redirect(next_url)
try:
qty = float(quantity_raw)
except ValueError:
qty = 0.0
if qty <= 0:
messages.error(request, 'Количество должно быть больше 0.')
return redirect(next_url)
if kind == 'raw':
material_id = (request.POST.get('material_id') or '').strip()
is_customer_supplied = bool(request.POST.get('is_customer_supplied'))
if not material_id.isdigit():
messages.error(request, 'Выбери материал.')
return redirect(next_url)
length_raw = (request.POST.get('current_length') or '').strip().replace(',', '.')
width_raw = (request.POST.get('current_width') or '').strip().replace(',', '.')
current_length = None
current_width = None
if length_raw:
try:
current_length = float(length_raw)
except ValueError:
current_length = None
if width_raw:
try:
current_width = float(width_raw)
except ValueError:
current_width = None
obj = StockItem(
material_id=int(material_id),
location_id=int(location_id),
deal_id=(int(deal_id) if deal_id.isdigit() else None),
quantity=float(qty),
is_customer_supplied=is_customer_supplied,
current_length=current_length,
current_width=current_width,
)
try:
obj.full_clean()
obj.save()
messages.success(request, 'Приход сырья добавлен.')
except Exception as e:
messages.error(request, f'Ошибка прихода: {e}')
return redirect(next_url)
if kind == 'entity':
entity_id = (request.POST.get('entity_id') or '').strip()
if not entity_id.isdigit():
messages.error(request, 'Выбери КД (изделие/деталь).')
return redirect(next_url)
obj = StockItem(
entity_id=int(entity_id),
location_id=int(location_id),
deal_id=(int(deal_id) if deal_id.isdigit() else None),
quantity=float(qty),
)
try:
obj.full_clean()
obj.save()
messages.success(request, 'Приход изделия добавлен.')
except Exception as e:
messages.error(request, f'Ошибка прихода: {e}')
return redirect(next_url)
messages.error(request, 'Выбери тип прихода.')
return redirect(next_url)
class ShippingView(LoginRequiredMixin, TemplateView):
template_name = 'shiftflow/shipping.html'
def dispatch(self, request, *args, **kwargs):
profile = getattr(request.user, 'profile', None)
roles = get_user_roles(request.user)
self.role = primary_role(roles)
self.roles = roles
self.is_readonly = bool(getattr(profile, 'is_readonly', False)) if profile else False
self.can_edit = has_any_role(roles, ['admin', 'clerk', 'manager', 'prod_head', 'technologist']) and not self.is_readonly
if not has_any_role(roles, ['admin', 'clerk', 'manager', 'prod_head', 'director', 'technologist', 'observer']):
return redirect('registry')
return super().dispatch(request, *args, **kwargs)
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['user_role'] = self.role
ctx['user_roles'] = sorted(self.roles)
ctx['can_edit'] = bool(self.can_edit)
ctx['is_readonly'] = bool(self.is_readonly)
deal_id_raw = (self.request.GET.get('deal_id') or '').strip()
deal_id = int(deal_id_raw) if deal_id_raw.isdigit() else None
shipping_loc, _ = Location.objects.get_or_create(
name='Склад отгруженных позиций',
defaults={'is_production_area': False},
)
ctx['shipping_location'] = shipping_loc
ctx['deals'] = list(Deal.objects.select_related('company').order_by('-id')[:300])
ctx['selected_deal_id'] = deal_id
ctx['entity_rows'] = []
ctx['material_rows'] = []
if deal_id:
from shiftflow.services.shipping import build_shipment_rows
entity_rows, material_rows = build_shipment_rows(
deal_id=int(deal_id),
shipping_location_id=int(shipping_loc.id),
)
ctx['entity_rows'] = entity_rows
ctx['material_rows'] = material_rows
return ctx
def post(self, request, *args, **kwargs):
if not self.can_edit:
messages.error(request, 'Доступ только для просмотра.')
return redirect('shipping')
deal_id_raw = (request.POST.get('deal_id') or '').strip()
if not deal_id_raw.isdigit():
messages.error(request, 'Выбери сделку.')
return redirect('shipping')
deal_id = int(deal_id_raw)
shipping_loc, _ = Location.objects.get_or_create(
name='Склад отгруженных позиций',
defaults={'is_production_area': False},
)
entity_qty: dict[int, int] = {}
material_qty: dict[int, float] = {}
for k, v in request.POST.items():
if not k or v is None:
continue
s = (str(v) or '').strip().replace(',', '.')
if k.startswith('ent_'):
ent_id_raw = k.replace('ent_', '').strip()
if not ent_id_raw.isdigit():
continue
try:
qty = int(float(s)) if s else 0
except ValueError:
qty = 0
if qty > 0:
entity_qty[int(ent_id_raw)] = int(qty)
if k.startswith('mat_'):
mat_id_raw = k.replace('mat_', '').strip()
if not mat_id_raw.isdigit():
continue
try:
qty_f = float(s) if s else 0.0
except ValueError:
qty_f = 0.0
if qty_f > 0:
material_qty[int(mat_id_raw)] = float(qty_f)
if not entity_qty and not material_qty:
messages.error(request, 'Укажи количество к отгрузке хотя бы по одной позиции.')
return redirect(f"{reverse_lazy('shipping')}?deal_id={deal_id}&from_location_id={from_location_id}")
from shiftflow.services.shipping import create_shipment_transfers
try:
ids = create_shipment_transfers(
deal_id=int(deal_id),
shipping_location_id=int(shipping_loc.id),
entity_qty=entity_qty,
material_qty=material_qty,
user_id=int(request.user.id),
)
msg = ', '.join([str(i) for i in ids])
messages.success(request, f'Отгрузка оформлена. Документы перемещения: {msg}.')
except Exception as e:
logger.exception('shipping:error deal_id=%s', deal_id)
messages.error(request, f'Ошибка отгрузки: {e}')
return redirect(f"{reverse_lazy('shipping')}?deal_id={deal_id}")
from shiftflow.services.assembly_closing import get_assembly_closing_info, apply_assembly_closing
class AssemblyClosingView(LoginRequiredMixin, TemplateView):
template_name = 'shiftflow/assembly_closing.html'
def dispatch(self, request, *args, **kwargs):
roles = get_user_roles(request.user)
if not has_any_role(roles, ['admin', 'master', 'operator', 'prod_head']):
return redirect('registry')
pk = self.kwargs.get('pk')
wi = get_object_or_404(WorkItem.objects.select_related('entity', 'deal', 'machine', 'workshop'), pk=int(pk))
if wi.entity.entity_type not in ['product', 'assembly']:
messages.error(request, 'Закрытие сборки доступно только для сборочных единиц и изделий.')
return redirect('workitem_detail', pk=wi.id)
self.workitem = wi
return super().dispatch(request, *args, **kwargs)
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
roles = get_user_group_roles(self.request.user)
ctx['user_roles'] = sorted(roles)
ctx['user_role'] = primary_role(roles)
ctx['workitem'] = self.workitem
ctx['remaining'] = max(0, int(self.workitem.quantity_plan or 0) - int(self.workitem.quantity_done or 0))
info = get_assembly_closing_info(self.workitem)
ctx.update(info)
ws_id = getattr(self.workitem, 'workshop_id', None)
ctx['workshop_machines'] = list(Machine.objects.filter(workshop_id=ws_id).order_by('name')) if ws_id else []
if info.get('error'):
messages.warning(self.request, info['error'])
return ctx
def post(self, request, *args, **kwargs):
action = (request.POST.get('action') or '').strip()
if action == 'close':
qty_raw = (request.POST.get('fact_qty') or '').strip()
try:
qty = int(qty_raw)
except ValueError:
qty = 0
if qty <= 0:
messages.error(request, 'Количество должно быть больше 0.')
return redirect('assembly_closing', pk=self.workitem.id)
if not getattr(self.workitem, 'machine_id', None):
mid_raw = (request.POST.get('machine_id') or '').strip()
if not mid_raw.isdigit():
messages.error(request, 'Выбери пост для производственного отчёта.')
return redirect('assembly_closing', pk=self.workitem.id)
mid = int(mid_raw)
ws_id = getattr(self.workitem, 'workshop_id', None)
if ws_id:
ok = Machine.objects.filter(id=mid, workshop_id=int(ws_id)).exists()
else:
ok = Machine.objects.filter(id=mid).exists()
if not ok:
messages.error(request, 'Выбранный пост не относится к цеху задания.')
return redirect('assembly_closing', pk=self.workitem.id)
WorkItem.objects.filter(id=int(self.workitem.id), machine_id__isnull=True).update(machine_id=mid)
self.workitem.machine_id = mid
try:
apply_assembly_closing(self.workitem.id, qty, request.user.id)
messages.success(request, f'Успешно закрыто {qty} шт. Компоненты списаны, выпуск добавлен.')
return redirect('workitem_detail', pk=self.workitem.id)
except Exception as e:
logger.exception('assembly_closing: error')
messages.error(request, f'Ошибка закрытия: {e}')
return redirect('assembly_closing', pk=self.workitem.id)
return redirect('assembly_closing', pk=self.workitem.id)
class ClosingWorkItemsView(LoginRequiredMixin, TemplateView):
template_name = 'shiftflow/closing_workitems.html'
def dispatch(self, request, *args, **kwargs):
roles = get_user_group_roles(request.user)
if not has_any_role(roles, ['admin', 'master', 'operator', 'observer', 'prod_head', 'director']):
return redirect('registry')
return super().dispatch(request, *args, **kwargs)
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
profile = getattr(self.request.user, 'profile', None)
roles = get_user_group_roles(self.request.user)
role = primary_role(roles)
ctx['user_role'] = role
ctx['user_roles'] = sorted(roles)
q = (self.request.GET.get('q') or '').strip()
ctx['q'] = q
qs = (
WorkItem.objects.select_related('deal', 'entity', 'operation', 'machine', 'workshop', 'entity__planned_material')
.filter(quantity_done__lt=F('quantity_plan'))
.filter(status__in=['planned', 'leftover'])
)
if q:
qs = qs.filter(
Q(deal__number__icontains=q)
| Q(entity__drawing_number__icontains=q)
| Q(entity__name__icontains=q)
| Q(machine__name__icontains=q)
| Q(workshop__name__icontains=q)
)
if role == 'operator' and profile:
user_machine_ids = list(profile.machines.values_list('id', flat=True))
user_ws_ids = list(
Machine.objects.filter(id__in=user_machine_ids)
.exclude(workshop_id__isnull=True)
.values_list('workshop_id', flat=True)
)
allowed_ws = list(profile.allowed_workshops.values_list('id', flat=True))
ws_ids = list({int(x) for x in (user_ws_ids + allowed_ws) if x})
qs = qs.filter(Q(machine_id__in=user_machine_ids) | Q(machine_id__isnull=True, workshop_id__in=ws_ids))
elif role == 'master' and profile:
allowed_ws = list(profile.allowed_workshops.values_list('id', flat=True))
if allowed_ws:
qs = qs.filter(Q(workshop_id__in=allowed_ws) | Q(machine__workshop_id__in=allowed_ws))
rows = list(qs.order_by('workshop__name', 'machine__name', 'date', 'deal__number', 'entity__drawing_number', 'id'))
for wi in rows:
plan = int(wi.quantity_plan or 0)
done = int(wi.quantity_done or 0)
wi.remaining = max(0, plan - done)
first_op_id = get_first_operation_id(int(wi.entity_id))
is_first = True
if first_op_id and getattr(wi, 'operation_id', None):
is_first = int(wi.operation_id) == int(first_op_id)
if wi.entity and wi.entity.entity_type in ['product', 'assembly']:
wi.close_url = str(reverse_lazy('assembly_closing', kwargs={'pk': wi.id})) if is_first else str(reverse_lazy('workitem_op_closing', kwargs={'pk': wi.id}))
elif wi.entity and wi.entity.entity_type == 'part':
m_id = int(wi.machine_id) if wi.machine_id else 0
mat_id = int(getattr(wi.entity, 'planned_material_id', None) or 0) if wi.entity else 0
if is_first and m_id and mat_id:
wi.close_url = f"{reverse_lazy('closing')}?machine_id={m_id}&material_id={mat_id}"
else:
wi.close_url = str(reverse_lazy('workitem_op_closing', kwargs={'pk': wi.id}))
else:
wi.close_url = str(reverse_lazy('workitem_op_closing', kwargs={'pk': wi.id}))
ctx['workitems'] = rows
return ctx
class ClosingView(LoginRequiredMixin, TemplateView):
template_name = 'shiftflow/closing.html'
def dispatch(self, request, *args, **kwargs):
roles = get_user_group_roles(request.user)
if not has_any_role(roles, ['admin', 'master', 'operator', 'observer', 'prod_head', 'director']):
return redirect('registry')
return super().dispatch(request, *args, **kwargs)
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
profile = getattr(self.request.user, 'profile', None)
roles = get_user_group_roles(self.request.user)
role = primary_role(roles)
ctx['user_role'] = role
ctx['user_roles'] = sorted(roles)
if role == 'operator' and profile:
machines = list(profile.machines.all().order_by('name'))
else:
machines = list(Machine.objects.all().order_by('name'))
ctx['machines'] = machines
def materials_from_workitems(machine_ids=None, workshop_ids=None):
wi = WorkItem.objects.select_related('entity', 'entity__planned_material')\
.filter(status='planned')\
.filter(quantity_done__lt=F('quantity_plan'))
if machine_ids:
wi = wi.filter(Q(machine_id__in=list(machine_ids)) | Q(machine_id__isnull=True))
if workshop_ids:
wi = wi.filter(Q(workshop_id__in=list(workshop_ids)) | Q(machine_id__in=list(machine_ids or [])))
mat_ids = (
wi.values_list('entity__planned_material_id', flat=True)
.exclude(entity__planned_material_id__isnull=True)
.distinct()
)
return list(Material.objects.select_related('category').filter(id__in=list(mat_ids)).order_by('full_name'))
if role == 'operator' and profile:
user_machine_ids = set(profile.machines.values_list('id', flat=True))
user_ws_ids = set(
Machine.objects.filter(id__in=list(user_machine_ids))
.exclude(workshop_id__isnull=True)
.values_list('workshop_id', flat=True)
)
ctx['materials'] = materials_from_workitems(machine_ids=user_machine_ids, workshop_ids=user_ws_ids)
elif role == 'master' and profile:
allowed_ws = set(profile.allowed_workshops.values_list('id', flat=True))
ctx['materials'] = materials_from_workitems(workshop_ids=allowed_ws)
else:
ctx['materials'] = materials_from_workitems()
machine_id = (self.request.GET.get('machine_id') or '').strip()
material_id = (self.request.GET.get('material_id') or '').strip()
ctx['selected_machine_id'] = machine_id
ctx['selected_material_id'] = material_id
workitems = []
stock_items = []
if machine_id.isdigit() and material_id.isdigit():
workitems = list(
WorkItem.objects.select_related('deal', 'entity', 'machine')
.filter(machine_id=int(machine_id), status__in=['planned'], entity__planned_material_id=int(material_id))
.filter(quantity_done__lt=F('quantity_plan'))
.order_by('date', 'deal__number', 'entity__drawing_number')
)
for wi in workitems:
plan = int(wi.quantity_plan or 0)
done = int(wi.quantity_done or 0)
wi.remaining = max(0, plan - done)
machine = Machine.objects.select_related('workshop', 'workshop__location', 'location').filter(pk=int(machine_id)).first()
work_location_id = None
if machine and getattr(machine, 'workshop_id', None) and getattr(machine.workshop, 'location_id', None):
work_location_id = machine.workshop.location_id
elif machine and getattr(machine, 'location_id', None):
work_location_id = machine.location_id
if work_location_id:
stock_items = list(
StockItem.objects.select_related('location', 'material')
.filter(location_id=work_location_id, material_id=int(material_id), entity__isnull=True, is_archived=False)
.filter(quantity__gt=0)
.order_by('created_at', 'id')
)
ctx['workitems'] = workitems
ctx['stock_items'] = stock_items
readonly = bool(getattr(profile, 'is_readonly', False)) if profile else False
ctx['can_edit'] = (has_any_role(roles, ['admin', 'master', 'operator', 'prod_head']) and not readonly)
return ctx
def post(self, request, *args, **kwargs):
roles = get_user_group_roles(request.user)
if not has_any_role(roles, ['admin', 'master', 'operator', 'prod_head']):
return redirect('closing')
profile = getattr(request.user, 'profile', None)
if bool(getattr(profile, 'is_readonly', False)) if profile else False:
messages.error(request, 'Доступ только для просмотра.')
return redirect('closing')
machine_id = (request.POST.get('machine_id') or '').strip()
material_id = (request.POST.get('material_id') or '').strip()
if not (machine_id.isdigit() and material_id.isdigit()):
messages.error(request, 'Выбери станок и материал.')
return redirect('closing')
item_actions = {}
for k, v in request.POST.items():
if not k.startswith('close_action_'):
continue
item_id = k.replace('close_action_', '')
if not item_id.isdigit():
continue
action = (v or '').strip()
if action not in ['done', 'partial']:
continue
fact_raw = (request.POST.get(f'fact_{item_id}') or '').strip()
try:
fact = int(fact_raw)
except ValueError:
fact = 0
item_actions[int(item_id)] = {'action': action, 'fact': fact}
consumptions = {}
for k, v in request.POST.items():
if not k.startswith('consume_'):
continue
sid = k.replace('consume_', '')
if not sid.isdigit():
continue
raw = (v or '').strip().replace(',', '.')
if not raw:
continue
try:
qty = float(raw)
except ValueError:
qty = 0.0
if qty > 0:
consumptions[int(sid)] = qty
remnants = []
idx = 0
while True:
has_any = (
f'remnant_qty_{idx}' in request.POST
or f'remnant_len_{idx}' in request.POST
or f'remnant_wid_{idx}' in request.POST
)
if not has_any:
break
qty_raw = (request.POST.get(f'remnant_qty_{idx}') or '').strip().replace(',', '.')
len_raw = (request.POST.get(f'remnant_len_{idx}') or '').strip().replace(',', '.')
wid_raw = (request.POST.get(f'remnant_wid_{idx}') or '').strip().replace(',', '.')
if qty_raw:
try:
rq = float(qty_raw)
except ValueError:
rq = 0.0
if rq > 0:
rl = None
rw = None
if len_raw:
try:
rl = float(len_raw)
except ValueError:
rl = None
if wid_raw:
try:
rw = float(wid_raw)
except ValueError:
rw = None
remnants.append({'quantity': rq, 'current_length': rl, 'current_width': rw})
idx += 1
if idx > 200:
break
if not item_actions:
messages.error(request, 'Выбери хотя бы один пункт сменки и режим закрытия (полностью/частично).')
return redirect(f"{reverse_lazy('closing')}?machine_id={machine_id}&material_id={material_id}")
if not consumptions:
messages.error(request, 'Заполни списание: укажи, какие единицы на складе использованы и в каком количестве.')
return redirect(f"{reverse_lazy('closing')}?machine_id={machine_id}&material_id={material_id}")
try:
apply_closing_workitems(
user_id=request.user.id,
machine_id=int(machine_id),
material_id=int(material_id),
item_actions=item_actions,
consumptions=consumptions,
remnants=remnants,
)
messages.success(request, 'Закрытие выполнено.')
except Exception as e:
logger.exception('closing_workitems:error machine_id=%s material_id=%s', machine_id, material_id)
messages.error(request, f'Ошибка закрытия: {e}')
return redirect(f"{reverse_lazy('closing')}?machine_id={machine_id}&material_id={material_id}")
class ProcurementDashboardView(LoginRequiredMixin, TemplateView):
template_name = 'shiftflow/procurement_dashboard.html'
TYPE_CHOICES = [
('raw', 'Сырьё'),
('purchased', 'Покупное'),
('casting', 'Литьё'),
('outsourced', 'Аутсорс'),
]
STATUS_CHOICES = [
('to_order', 'К заказу'),
('ordered', 'Заказано'),
('closed', 'Закрыто'),
]
def dispatch(self, request, *args, **kwargs):
roles = get_user_group_roles(request.user)
if not has_any_role(roles, ['admin', 'supply', 'observer', 'clerk', 'prod_head', 'director']):
return redirect('index')
return super().dispatch(request, *args, **kwargs)
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
roles = get_user_group_roles(self.request.user)
role = primary_role(roles)
ctx['user_role'] = role
ctx['user_roles'] = sorted(roles)
ctx['can_edit'] = has_any_role(roles, ['admin', 'supply'])
q = (self.request.GET.get('q') or '').strip()
filtered = (self.request.GET.get('filtered') or '').strip()
filtered_flag = filtered in ['1', 'true', 'yes', 'on']
types = [t for t in self.request.GET.getlist('types') if t]
statuses = [s for s in self.request.GET.getlist('statuses') if s]
grouped = (self.request.GET.get('grouped') or '').strip() in ['1', 'true', 'yes', 'on']
allowed_types = {c[0] for c in self.TYPE_CHOICES}
allowed_statuses = {c[0] for c in self.STATUS_CHOICES}
is_default = not filtered_flag
if is_default and not types:
types = list(allowed_types)
else:
types = [t for t in types if t in allowed_types]
if is_default and not statuses:
statuses = [s for s in ['to_order', 'ordered'] if s in allowed_statuses]
else:
statuses = [s for s in statuses if s in allowed_statuses]
proc_qs = ProcurementRequirement.objects.select_related('deal', 'component')
raw_qs = MaterialRequirement.objects.select_related('deal', 'material')
if (not types) or (not statuses):
proc_qs = proc_qs.none()
raw_qs = raw_qs.none()
if q:
proc_qs = proc_qs.filter(
Q(deal__number__icontains=q)
| Q(component__drawing_number__icontains=q)
| Q(component__name__icontains=q)
)
raw_qs = raw_qs.filter(
Q(deal__number__icontains=q)
| Q(material__full_name__icontains=q)
| Q(material__name__icontains=q)
)
proc_type_map = {
'purchased': 'purchased',
'casting': 'casting',
'outsourced': 'outsourced',
}
proc_qs = proc_qs.filter(component__entity_type__in=list(proc_type_map.keys()))
if types:
proc_qs = proc_qs.filter(component__entity_type__in=[t for t in types if t in proc_type_map])
raw_status_map = {
'needed': 'to_order',
'ordered': 'ordered',
'fulfilled': 'closed',
}
inv_raw_status_map = {
'to_order': ['needed'],
'ordered': ['ordered'],
'closed': ['fulfilled'],
}
if statuses:
proc_qs = proc_qs.filter(status__in=statuses)
raw_qs = raw_qs.filter(status__in=sum([inv_raw_status_map.get(s, []) for s in statuses], []))
requirements = []
if 'raw' in types:
for r in raw_qs.order_by('status', 'deal__number', 'material__full_name', 'id'):
requirements.append({
'kind': 'raw',
'type': 'raw',
'component_id': int(r.material_id),
'component_label': str(r.material),
'required_qty': float(r.required_qty),
'unit': r.unit,
'deal_id': int(r.deal_id),
'deals': [str(r.deal.number)],
'status': raw_status_map.get(r.status, 'to_order'),
'row_id': f'raw_{r.id}',
'obj_id': int(r.id),
})
for r in proc_qs.order_by('status', 'deal__number', 'component__drawing_number', 'component__name', 'id'):
requirements.append({
'kind': 'component',
'type': str(r.component.entity_type),
'component_id': int(r.component_id),
'component_label': str(r.component),
'required_qty': int(r.required_qty or 0),
'unit': 'pcs',
'deal_id': int(r.deal_id),
'deals': [str(r.deal.number)],
'status': str(r.status),
'row_id': f'pr_{r.id}',
'obj_id': int(r.id),
})
if grouped:
grouped_map = {}
for row in requirements:
key = (row['kind'], int(row['component_id']))
g = grouped_map.get(key)
if not g:
grouped_map[key] = {
**row,
'deals': list(row.get('deals') or []),
'required_qty': row['required_qty'],
}
continue
g['required_qty'] = (g.get('required_qty') or 0) + (row.get('required_qty') or 0)
for dn in row.get('deals') or []:
if dn not in g['deals']:
g['deals'].append(dn)
p = {'to_order': 0, 'ordered': 1, 'closed': 2}
if p.get(row.get('status'), 0) < p.get(g.get('status'), 0):
g['status'] = row.get('status')
requirements = list(grouped_map.values())
requirements.sort(key=lambda x: (x.get('status') or '', x.get('component_label') or '', x.get('type') or ''))
if self.request.GET.get('print'):
# Группируем для печати по типам
from collections import defaultdict
print_data = defaultdict(list)
for r in requirements:
# Если группировка выключена, но мы печатаем, можно оставить как есть
# Либо группировать по типу
t = r.get('type') or 'other'
print_data[t].append(r)
ctx['print_data'] = dict(print_data)
ctx['type_labels'] = dict(self.TYPE_CHOICES)
self.template_name = 'shiftflow/procurement_print.html'
return ctx
ctx['requirements'] = requirements
ctx['q'] = q
ctx['selected_types'] = types
ctx['type_choices'] = list(self.TYPE_CHOICES)
ctx['selected_statuses'] = statuses
ctx['status_choices'] = list(self.STATUS_CHOICES)
ctx['grouped'] = grouped
# Исключаем склад «отгруженных/отгрузки» из приходов в панели снабжения.
ship_loc = (
Location.objects.filter(Q(name__icontains='отгруж') | Q(name__icontains='отгруз'))
.order_by('id')
.first()
)
ship_loc_id = ship_loc.id if ship_loc else None
locations_qs = Location.objects.all().order_by('name')
if ship_loc_id:
locations_qs = locations_qs.exclude(id=ship_loc_id)
ctx['locations'] = list(locations_qs)
ctx['deals'] = list(Deal.objects.all().order_by('-id')[:200])
return ctx
def post(self, request, *args, **kwargs):
roles = get_user_group_roles(request.user)
if not has_any_role(roles, ['admin', 'supply']):
return redirect('procurement')
action = (request.POST.get('action') or '').strip()
next_url = (request.POST.get('next') or '').strip()
if not next_url.startswith('/'):
next_url = reverse_lazy('procurement')
if action == 'mark_ordered':
pr_id = (request.POST.get('pr_id') or '').strip()
if pr_id.isdigit():
ProcurementRequirement.objects.filter(id=int(pr_id), status='to_order').update(status='ordered')
messages.success(request, 'Отмечено как «Заказано».')
return redirect(next_url)
if action == 'receive_component':
pr_id = (request.POST.get('pr_id') or '').strip()
location_id = (request.POST.get('location_id') or '').strip()
deal_id = (request.POST.get('deal_id') or '').strip() # опционально: привязать приход к сделке
qty_raw = (request.POST.get('quantity') or '').strip().replace(',', '.')
if not (pr_id.isdigit() and location_id.isdigit()):
messages.error(request, 'Заполни корректно: склад и позиция потребности.')
return redirect(next_url)
try:
qty = int(float(qty_raw))
except ValueError:
qty = 0
if qty <= 0:
messages.error(request, 'Количество должно быть больше 0.')
return redirect(next_url)
with transaction.atomic():
pr = ProcurementRequirement.objects.select_for_update().select_related('component').filter(id=int(pr_id)).first()
if not pr:
messages.error(request, 'Потребность не найдена.')
return redirect(next_url)
resolved_deal_id = int(deal_id) if deal_id.isdigit() else None
obj = StockItem(
entity_id=int(pr.component_id),
location_id=int(location_id),
deal_id=resolved_deal_id,
quantity=float(qty),
)
obj.full_clean()
obj.save()
cur = int(pr.required_qty or 0)
new_need = cur - int(qty)
if new_need <= 0:
pr.required_qty = 0
pr.status = 'closed'
else:
pr.required_qty = int(new_need)
pr.status = 'ordered'
pr.save(update_fields=['required_qty', 'status'])
messages.success(request, 'Приход оформлен, потребность обновлена.')
return redirect(next_url)
messages.error(request, 'Неизвестное действие.')
return redirect(next_url)
class LegacyWriteOffsView(LoginRequiredMixin, TemplateView):
template_name = 'shiftflow/legacy_writeoffs.html'
def dispatch(self, request, *args, **kwargs):
roles = get_user_group_roles(request.user)
if not has_any_role(roles, ['admin', 'clerk', 'observer', 'prod_head', 'director']):
return redirect('registry')
return super().dispatch(request, *args, **kwargs)
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
roles = get_user_group_roles(self.request.user)
role = primary_role(roles)
ctx['user_role'] = role
ctx['user_roles'] = sorted(roles)
ctx['can_edit'] = has_any_role(roles, ['admin', 'clerk', 'prod_head'])
start_date = (self.request.GET.get('start_date') or '').strip()
end_date = (self.request.GET.get('end_date') or '').strip()
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',
'remnants__material',
)
)
report_cards = []
for r in reports:
consumed = {}
for c in list(getattr(r, 'consumptions', []).all() if hasattr(getattr(r, 'consumptions', None), 'all') else []):
mat = None
if getattr(c, 'material_id', None):
mat = c.material
elif getattr(c, 'stock_item_id', None) and getattr(c.stock_item, 'material_id', None):
mat = c.stock_item.material
label = str(mat) if mat else ''
key = getattr(mat, 'id', None) or label
consumed[key] = consumed.get(key, 0.0) + float(c.quantity)
produced = {}
remnants = {}
for res in list(getattr(r, 'results', []).all() if hasattr(getattr(r, 'results', None), 'all') else []):
si = res.stock_item
if res.kind == 'finished':
label = str(getattr(si, 'entity', None) or '')
produced[label] = produced.get(label, 0.0) + float(si.quantity)
elif res.kind == 'remnant':
label = str(getattr(si, 'material', None) or '')
remnants[label] = remnants.get(label, 0.0) + float(si.quantity)
report_cards.append({
'report': r,
'consumed': consumed,
'produced': produced,
'remnants': remnants,
'tasks': list(getattr(r, 'tasks', []).all() if hasattr(getattr(r, 'tasks', None), 'all') else []),
})
ctx['report_cards'] = report_cards
return ctx
def post(self, request, *args, **kwargs):
roles = get_user_group_roles(request.user)
if not has_any_role(roles, ['admin', 'clerk', 'prod_head']):
return redirect('legacy_writeoffs')
ids = request.POST.getlist('item_ids')
item_ids = [int(x) for x in ids if x.isdigit()]
if not item_ids:
messages.error(request, 'Не выбрано ни одного сменного задания.')
return redirect('legacy_writeoffs')
Item.objects.filter(id__in=item_ids).update(is_synced_1c=True)
messages.success(request, f'Отмечено в 1С: {len(item_ids)}.')
start_date = (request.POST.get('start_date') or '').strip()
end_date = (request.POST.get('end_date') or '').strip()
return redirect(f"{reverse_lazy('legacy_writeoffs')}?start_date={start_date}&end_date={end_date}")
class LegacyClosingView(LoginRequiredMixin, TemplateView):
template_name = 'shiftflow/legacy_closing.html'
def dispatch(self, request, *args, **kwargs):
roles = get_user_group_roles(request.user)
if not has_any_role(roles, ['admin', 'master', 'operator', 'observer', 'prod_head', 'director']):
return redirect('registry')
return super().dispatch(request, *args, **kwargs)
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
profile = getattr(self.request.user, 'profile', None)
roles = get_user_group_roles(self.request.user)
role = primary_role(roles)
ctx['user_role'] = role
ctx['user_roles'] = sorted(roles)
if role == 'operator' and profile:
machines = list(profile.machines.all().order_by('name'))
else:
machines = list(Machine.objects.all().order_by('name'))
ctx['machines'] = machines
ctx['materials'] = list(Material.objects.select_related('category').order_by('full_name'))
machine_id = (self.request.GET.get('machine_id') or '').strip()
material_id = (self.request.GET.get('material_id') or '').strip()
ctx['selected_machine_id'] = machine_id
ctx['selected_material_id'] = material_id
items = []
stock_items = []
if machine_id.isdigit() and material_id.isdigit():
items = list(
Item.objects.select_related('task', 'task__deal', 'task__material', 'machine')
.filter(machine_id=int(machine_id), status='work', task__material_id=int(material_id))
.order_by('date', 'task__deal__number', 'task__drawing_name')
)
machine = Machine.objects.select_related('workshop', 'workshop__location', 'location').filter(pk=int(machine_id)).first()
work_location_id = None
if machine and getattr(machine, 'workshop_id', None) and getattr(machine.workshop, 'location_id', None):
work_location_id = machine.workshop.location_id
elif machine and getattr(machine, 'location_id', None):
work_location_id = machine.location_id
if work_location_id:
stock_items = list(
StockItem.objects.select_related('location', 'material')
.filter(location_id=work_location_id, material_id=int(material_id), entity__isnull=True, is_archived=False)
.filter(quantity__gt=0)
.order_by('created_at', 'id')
)
ctx['items'] = items
ctx['stock_items'] = stock_items
readonly = bool(getattr(profile, 'is_readonly', False)) if profile else False
ctx['can_edit'] = (has_any_role(roles, ['admin', 'master', 'operator', 'prod_head']) and not readonly)
return ctx
def post(self, request, *args, **kwargs):
roles = get_user_group_roles(request.user)
if not has_any_role(roles, ['admin', 'master', 'operator', 'prod_head']):
return redirect('legacy_closing')
profile = getattr(request.user, 'profile', None)
if bool(getattr(profile, 'is_readonly', False)) if profile else False:
messages.error(request, 'Доступ только для просмотра.')
return redirect('legacy_closing')
machine_id = (request.POST.get('machine_id') or '').strip()
material_id = (request.POST.get('material_id') or '').strip()
if not (machine_id.isdigit() and material_id.isdigit()):
messages.error(request, 'Выбери станок и материал.')
return redirect('legacy_closing')
item_actions = {}
for k, v in request.POST.items():
if not k.startswith('close_action_'):
continue
item_id = k.replace('close_action_', '')
if not item_id.isdigit():
continue
action = (v or '').strip()
if action not in ['done', 'partial']:
continue
fact_raw = (request.POST.get(f'fact_{item_id}') or '').strip()
try:
fact = int(fact_raw)
except ValueError:
fact = 0
item_actions[int(item_id)] = {'action': action, 'fact': fact}
consumptions = {}
for k, v in request.POST.items():
if not k.startswith('consume_'):
continue
sid = k.replace('consume_', '')
if not sid.isdigit():
continue
raw = (v or '').strip().replace(',', '.')
if not raw:
continue
try:
qty = float(raw)
except ValueError:
qty = 0.0
if qty > 0:
consumptions[int(sid)] = qty
remnants = []
idx = 0
while True:
has_any = (
f'remnant_qty_{idx}' in request.POST
or f'remnant_len_{idx}' in request.POST
or f'remnant_wid_{idx}' in request.POST
)
if not has_any:
break
qty_raw = (request.POST.get(f'remnant_qty_{idx}') or '').strip().replace(',', '.')
len_raw = (request.POST.get(f'remnant_len_{idx}') or '').strip().replace(',', '.')
wid_raw = (request.POST.get(f'remnant_wid_{idx}') or '').strip().replace(',', '.')
if qty_raw:
try:
rq = float(qty_raw)
except ValueError:
rq = 0.0
if rq > 0:
rl = None
rw = None
if len_raw:
try:
rl = float(len_raw)
except ValueError:
rl = None
if wid_raw:
try:
rw = float(wid_raw)
except ValueError:
rw = None
remnants.append({'quantity': rq, 'current_length': rl, 'current_width': rw})
idx += 1
if idx > 60:
break
try:
apply_closing(
user_id=request.user.id,
machine_id=int(machine_id),
material_id=int(material_id),
item_actions=item_actions,
consumptions=consumptions,
remnants=remnants,
)
messages.success(request, 'Сохранено.')
except Exception as e:
messages.error(request, str(e))
return redirect(f"{reverse_lazy('legacy_closing')}?machine_id={machine_id}&material_id={material_id}")
class ProductsView(LoginRequiredMixin, TemplateView):
template_name = 'shiftflow/products.html'
def dispatch(self, request, *args, **kwargs):
profile = getattr(request.user, 'profile', None)
role = profile.role if profile else ('admin' if request.user.is_superuser else 'operator')
if role not in ['admin', 'technologist', 'observer']:
return redirect('registry')
return super().dispatch(request, *args, **kwargs)
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
profile = getattr(self.request.user, 'profile', None)
role = profile.role if profile else ('admin' if self.request.user.is_superuser else 'operator')
ctx['user_role'] = role
ctx['can_edit'] = role in ['admin', 'technologist']
q = (self.request.GET.get('q') or '').strip()
entity_types = [x.strip() for x in self.request.GET.getlist('types') if (x or '').strip()]
allowed_types = {'product', 'assembly', 'part', 'casting'}
entity_types = [x for x in entity_types if x in allowed_types]
if not entity_types:
entity_types = ['product']
qs = ProductEntity.objects.select_related('planned_material').all()
qs = qs.filter(entity_type__in=entity_types)
if q:
qs = qs.filter(
Q(drawing_number__icontains=q)
| Q(name__icontains=q)
| Q(planned_material__name__icontains=q)
| Q(planned_material__full_name__icontains=q)
)
ctx['q'] = q
ctx['entity_types'] = entity_types
ctx['products'] = qs.order_by('entity_type', 'drawing_number', 'name')
return ctx
def post(self, request, *args, **kwargs):
roles = get_user_roles(request.user)
if not has_any_role(roles, ['admin', 'technologist']):
return redirect('products')
entity_type = (request.POST.get('entity_type') or '').strip()
name = (request.POST.get('name') or '').strip()
drawing_number = (request.POST.get('drawing_number') or '').strip()
if entity_type not in ['product', 'assembly']:
messages.error(request, 'Выбери тип: изделие или сборочная единица.')
return redirect('products')
if not name:
messages.error(request, 'Заполни наименование.')
return redirect('products')
obj = ProductEntity.objects.create(
entity_type=entity_type,
name=name[:255],
drawing_number=drawing_number[:100],
)
return redirect('product_detail', pk=obj.id)
class ProductDetailView(LoginRequiredMixin, TemplateView):
template_name = 'shiftflow/product_detail.html'
def dispatch(self, request, *args, **kwargs):
profile = getattr(request.user, 'profile', None)
role = profile.role if profile else ('admin' if request.user.is_superuser else 'operator')
if role not in ['admin', 'technologist', 'observer']:
return redirect('registry')
return super().dispatch(request, *args, **kwargs)
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
profile = getattr(self.request.user, 'profile', None)
roles = get_user_roles(self.request.user)
role = primary_role(roles)
ctx['user_role'] = role
ctx['user_roles'] = sorted(roles)
ctx['can_edit'] = has_any_role(roles, ['admin', 'technologist'])
ctx['can_add_to_deal'] = has_any_role(roles, ['admin', 'technologist'])
entity = get_object_or_404(ProductEntity.objects.select_related('planned_material'), pk=int(self.kwargs['pk']))
ctx['entity'] = entity
lines = list(
BOM.objects.select_related('child', 'child__planned_material', 'child__assembly_passport')
.filter(parent=entity)
.order_by('child__entity_type', 'child__drawing_number', 'child__name', 'id')
)
ctx['lines'] = lines
q = (self.request.GET.get('q') or '').strip()
ctx['q'] = q
candidates = ProductEntity.objects.select_related('planned_material').exclude(id=entity.id)
if q:
candidates = candidates.filter(Q(drawing_number__icontains=q) | Q(name__icontains=q))
ctx['candidates'] = list(candidates.order_by('drawing_number', 'name')[:200])
parent_id = (self.request.GET.get('parent') or '').strip()
ctx['parent_id'] = parent_id if parent_id.isdigit() else ''
raw_trail = (self.request.GET.get('trail') or '').strip()
trail_ids = []
if raw_trail:
for part in raw_trail.split(','):
part = part.strip()
if part.isdigit():
trail_ids.append(int(part))
trail_ids = trail_ids[:20]
bc_ids = trail_ids + [int(entity.id)]
bc_objs = {x.id: x for x in ProductEntity.objects.filter(id__in=bc_ids)}
breadcrumbs = []
for i, eid in enumerate(bc_ids):
obj = bc_objs.get(eid)
if not obj:
continue
t = ','.join(str(x) for x in bc_ids[:i])
url = str(reverse_lazy('product_detail', kwargs={'pk': eid}))
if t:
url = f"{url}?trail={t}"
breadcrumbs.append({'id': eid, 'label': str(obj), 'url': url})
ctx['breadcrumbs'] = breadcrumbs
back_url = ''
if trail_ids:
prev_id = int(trail_ids[-1])
t = ','.join(str(x) for x in trail_ids[:-1])
back_url = str(reverse_lazy('product_detail', kwargs={'pk': prev_id}))
if t:
back_url = f"{back_url}?trail={t}"
elif ctx['parent_id']:
back_url = str(reverse_lazy('product_detail', kwargs={'pk': int(ctx['parent_id'])}))
ctx['back_url'] = back_url
child_trail = ','.join(str(x) for x in (trail_ids + [int(entity.id)]))
ctx['trail_child'] = child_trail
q_dn = (self.request.GET.get('q_dn') or '').strip()
q_name = (self.request.GET.get('q_name') or '').strip()
ctx['q_dn'] = q_dn
ctx['q_name'] = q_name
found = None
searched = False
if q_dn or q_name:
searched = True
candidates_qs = ProductEntity.objects.exclude(id=entity.id)
if q_dn:
candidates_qs = candidates_qs.filter(drawing_number__icontains=q_dn)
if q_name:
candidates_qs = candidates_qs.filter(name__icontains=q_name)
found = candidates_qs.order_by('drawing_number', 'name', 'id').first()
ctx['searched'] = searched
ctx['found'] = found
ctx['materials'] = list(Material.objects.select_related('category').order_by('full_name'))
return ctx
def post(self, request, *args, **kwargs):
profile = getattr(request.user, 'profile', None)
role = profile.role if profile else ('admin' if request.user.is_superuser else 'operator')
if role not in ['admin', 'technologist']:
return redirect('product_detail', pk=self.kwargs['pk'])
entity = get_object_or_404(ProductEntity, pk=int(self.kwargs['pk']))
action = (request.POST.get('action') or '').strip()
parent_id = (request.POST.get('parent') or '').strip()
next_url = reverse_lazy('product_detail', kwargs={'pk': entity.id})
if parent_id.isdigit():
next_url = f"{next_url}?parent={parent_id}"
def parse_int(value, default=None):
s = (value or '').strip()
if not s.isdigit():
return default
return int(s)
def parse_qty(value):
v = parse_int(value, default=0)
return v if v and v > 0 else None
def would_cycle(parent_id: int, child_id: int) -> bool:
stack = [child_id]
seen = set()
while stack:
cur = stack.pop()
if cur == parent_id:
return True
if cur in seen:
continue
seen.add(cur)
stack.extend(list(BOM.objects.filter(parent_id=cur).values_list('child_id', flat=True)))
return False
if action == 'delete_line':
bom_id = parse_int(request.POST.get('bom_id'))
if not bom_id:
messages.error(request, 'Не выбрана строка BOM.')
return redirect(next_url)
BOM.objects.filter(id=bom_id, parent_id=entity.id).delete()
messages.success(request, 'Строка удалена.')
return redirect(next_url)
if action == 'update_qty':
bom_id = parse_int(request.POST.get('bom_id'))
qty = parse_qty(request.POST.get('quantity'))
if not bom_id or not qty:
messages.error(request, 'Заполни количество.')
return redirect('product_detail', pk=entity.id)
BOM.objects.filter(id=bom_id, parent_id=entity.id).update(quantity=qty)
messages.success(request, 'Количество обновлено.')
return redirect(next_url)
if action == 'add_existing':
child_id = parse_int(request.POST.get('child_id'))
qty = parse_qty(request.POST.get('quantity'))
if not child_id or not qty:
messages.error(request, 'Выбери существующую сущность и количество.')
return redirect('product_detail', pk=entity.id)
if child_id == entity.id:
messages.error(request, 'Нельзя добавить узел сам в себя.')
return redirect('product_detail', pk=entity.id)
if would_cycle(entity.id, child_id):
messages.error(request, 'Нельзя добавить: получится цикл в структуре.')
return redirect('product_detail', pk=entity.id)
obj, _ = BOM.objects.get_or_create(parent_id=entity.id, child_id=child_id, defaults={'quantity': qty})
if obj.quantity != qty:
obj.quantity = qty
obj.save(update_fields=['quantity'])
messages.success(request, 'Компонент добавлен.')
return redirect(next_url)
if action == 'create_and_add':
child_type = (request.POST.get('child_type') or '').strip()
name = (request.POST.get('name') or '').strip()
drawing_number = (request.POST.get('drawing_number') or '').strip()
qty = parse_qty(request.POST.get('quantity'))
planned_material_id = parse_int(request.POST.get('planned_material_id'))
if child_type not in ['assembly', 'part', 'purchased', 'casting', 'outsourced']:
messages.error(request, 'Выбери корректный тип компонента.')
return redirect('product_detail', pk=entity.id)
if not name:
messages.error(request, 'Заполни наименование компонента.')
return redirect('product_detail', pk=entity.id)
if not qty:
messages.error(request, 'Заполни количество.')
return redirect('product_detail', pk=entity.id)
child = ProductEntity.objects.create(
entity_type=child_type,
name=name[:255],
drawing_number=drawing_number[:100],
planned_material_id=(planned_material_id if child_type == 'part' and planned_material_id else None),
)
BOM.objects.create(parent_id=entity.id, child_id=child.id, quantity=qty)
messages.success(request, 'Компонент создан и добавлен.')
return redirect(next_url)
messages.error(request, 'Неизвестное действие.')
return redirect(next_url)
class ProductInfoView(LoginRequiredMixin, TemplateView):
def dispatch(self, request, *args, **kwargs):
roles = get_user_roles(request.user)
if not has_any_role(roles, ['admin', 'technologist', 'observer']):
return redirect('registry')
return super().dispatch(request, *args, **kwargs)
def get_template_names(self):
entity = get_object_or_404(ProductEntity, pk=int(self.kwargs['pk']))
et = entity.entity_type
if et == 'part':
return ['shiftflow/product_info_part.html']
if et in ['product', 'assembly']:
return ['shiftflow/product_info_assembly.html']
if et == 'purchased':
return ['shiftflow/product_info_purchased.html']
if et == 'casting':
return ['shiftflow/product_info_casting.html']
if et == 'outsourced':
return ['shiftflow/product_info_outsourced.html']
return ['shiftflow/product_info_external.html']
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
profile = getattr(self.request.user, 'profile', None)
role = profile.role if profile else ('admin' if self.request.user.is_superuser else 'operator')
ctx['user_role'] = role
ctx['can_edit'] = role in ['admin', 'technologist']
ctx['can_add_to_deal'] = role in ['admin', 'technologist']
entity = get_object_or_404(ProductEntity.objects.select_related('planned_material'), pk=int(self.kwargs['pk']))
ctx['entity'] = entity
ctx['materials'] = list(Material.objects.select_related('category').order_by('full_name'))
ctx['entity_ops'] = list(
EntityOperation.objects.select_related('operation', 'operation__workshop')
.filter(entity_id=entity.id)
.order_by('seq', 'id')
)
ctx['selected_operation_ids'] = [int(x.operation_id) for x in ctx['entity_ops'] if getattr(x, 'operation_id', None)]
ctx['operations'] = list(Operation.objects.select_related('workshop').order_by('name'))
next_url = (self.request.GET.get('next') or '').strip()
next_safe = next_url if next_url.startswith('/') else str(reverse_lazy('products'))
ctx['next'] = next_safe
ctx['deals_for_add'] = list(
Deal.objects.filter(status='lead').select_related('company').order_by('-id')[:200]
)
raw_trail = (self.request.GET.get('trail') or '').strip()
trail_ids = []
if raw_trail:
for part in raw_trail.split(','):
part = part.strip()
if part.isdigit():
trail_ids.append(int(part))
trail_ids = trail_ids[:20]
bc_ids = trail_ids + [int(entity.id)]
bc_objs = {x.id: x for x in ProductEntity.objects.filter(id__in=bc_ids)}
breadcrumbs = []
for i, eid in enumerate(bc_ids):
obj = bc_objs.get(eid)
if not obj:
continue
t = ','.join(str(x) for x in bc_ids[:i])
url = str(reverse_lazy('product_info', kwargs={'pk': eid}))
params = {'next': next_safe}
if t:
params['trail'] = t
url = f"{url}?{urlencode(params)}"
breadcrumbs.append({'id': eid, 'label': str(obj), 'url': url})
ctx['breadcrumbs'] = breadcrumbs
ctx['trail_child'] = ','.join(str(x) for x in (trail_ids + [int(entity.id)]))
ctx['bom_lines'] = []
if entity.entity_type in ['product', 'assembly']:
type_rank = Case(
When(child__entity_type='product', then=Value(1)),
When(child__entity_type='assembly', then=Value(2)),
When(child__entity_type='part', then=Value(3)),
When(child__entity_type='purchased', then=Value(4)),
When(child__entity_type='casting', then=Value(5)),
default=Value(99),
output_field=IntegerField(),
)
ctx['bom_lines'] = list(
BOM.objects.select_related('child', 'child__planned_material', 'child__assembly_passport')
.filter(parent_id=entity.id)
.annotate(_type_rank=type_rank)
.order_by('_type_rank', 'child__drawing_number', 'child__name', 'id')
)
passport = None
seams = []
if entity.entity_type in ['product', 'assembly']:
passport, _ = AssemblyPassport.objects.get_or_create(entity_id=entity.id)
seams = list(WeldingSeam.objects.filter(passport_id=passport.id).order_by('id'))
elif entity.entity_type == 'part':
passport, _ = PartPassport.objects.get_or_create(entity_id=entity.id)
elif entity.entity_type == 'purchased':
passport, _ = PurchasedPassport.objects.get_or_create(entity_id=entity.id)
elif entity.entity_type == 'casting':
passport, _ = CastingPassport.objects.get_or_create(entity_id=entity.id)
elif entity.entity_type == 'outsourced':
passport, _ = OutsourcedPassport.objects.get_or_create(entity_id=entity.id)
ctx['passport'] = passport
ctx['welding_seams'] = seams
return ctx
def post(self, request, *args, **kwargs):
profile = getattr(request.user, 'profile', None)
role = profile.role if profile else ('admin' if request.user.is_superuser else 'operator')
if role not in ['admin', 'technologist']:
return redirect('products')
entity = get_object_or_404(ProductEntity, pk=int(self.kwargs['pk']))
action = (request.POST.get('action') or '').strip()
next_url = (request.POST.get('next') or '').strip()
if not next_url.startswith('/'):
next_url = reverse_lazy('products')
trail = (request.POST.get('trail') or '').strip()
if trail and not all((p.strip().isdigit() for p in trail.split(',') if p.strip())):
trail = ''
def parse_int(value, default=None):
s = (value or '').strip()
if not s.isdigit():
return default
return int(s)
def parse_float(value):
s = (value or '').strip().replace(',', '.')
if not s:
return None
try:
return float(s)
except ValueError:
return None
stay_url = str(reverse_lazy('product_info', kwargs={'pk': entity.id}))
params = {'next': str(next_url)}
if trail:
params['trail'] = trail
stay_url = f"{stay_url}?{urlencode(params)}"
def parse_qty(value):
v = parse_int(value, default=0)
return v if v and v > 0 else None
def would_cycle(parent_id: int, child_id: int) -> bool:
stack = [child_id]
seen = set()
while stack:
cur = stack.pop()
if cur == parent_id:
return True
if cur in seen:
continue
seen.add(cur)
stack.extend(list(BOM.objects.filter(parent_id=cur).values_list('child_id', flat=True)))
return False
if action == 'bom_update_qty':
if entity.entity_type not in ['product', 'assembly']:
return redirect(stay_url)
bom_id = parse_int(request.POST.get('bom_id'))
qty = parse_qty(request.POST.get('quantity'))
if not bom_id or not qty:
messages.error(request, 'Заполни количество.')
return redirect(stay_url)
BOM.objects.filter(id=bom_id, parent_id=entity.id).update(quantity=qty)
messages.success(request, 'Количество обновлено.')
return redirect(stay_url)
if action == 'bom_delete_line':
if entity.entity_type not in ['product', 'assembly']:
return redirect(stay_url)
bom_id = parse_int(request.POST.get('bom_id'))
if not bom_id:
messages.error(request, 'Не выбрана строка состава.')
return redirect(stay_url)
BOM.objects.filter(id=bom_id, parent_id=entity.id).delete()
messages.success(request, 'Компонент удалён из состава.')
return redirect(stay_url)
if action == 'bom_add_existing':
if entity.entity_type not in ['product', 'assembly']:
return redirect(stay_url)
child_id = parse_int(request.POST.get('child_id'))
qty = parse_qty(request.POST.get('quantity'))
if not child_id or not qty:
messages.error(request, 'Выбери компонент и количество.')
return redirect(stay_url)
if child_id == entity.id:
messages.error(request, 'Нельзя добавить сущность саму в себя.')
return redirect(stay_url)
if would_cycle(int(entity.id), int(child_id)):
messages.error(request, 'Нельзя добавить: получится цикл в спецификации.')
return redirect(stay_url)
obj, _ = BOM.objects.get_or_create(parent_id=entity.id, child_id=child_id, defaults={'quantity': qty})
if obj.quantity != qty:
obj.quantity = qty
obj.save(update_fields=['quantity'])
messages.success(request, 'Компонент добавлен.')
return redirect(stay_url)
if action == 'bom_create_and_add':
if entity.entity_type not in ['product', 'assembly']:
return redirect(stay_url)
child_type = (request.POST.get('child_type') or '').strip()
name = (request.POST.get('name') or '').strip()
drawing_number = (request.POST.get('drawing_number') or '').strip()
qty = parse_qty(request.POST.get('quantity'))
planned_material_id = parse_int(request.POST.get('planned_material_id'))
if child_type not in ['assembly', 'part', 'purchased', 'casting', 'outsourced']:
messages.error(request, 'Выбери корректный тип компонента.')
return redirect(stay_url)
if not name:
messages.error(request, 'Заполни наименование компонента.')
return redirect(stay_url)
if not qty:
messages.error(request, 'Заполни количество.')
return redirect(stay_url)
child = ProductEntity.objects.create(
entity_type=child_type,
name=name[:255],
drawing_number=drawing_number[:100],
planned_material_id=(planned_material_id if child_type == 'part' and planned_material_id else None),
)
BOM.objects.create(parent_id=entity.id, child_id=child.id, quantity=qty)
messages.success(request, 'Компонент создан и добавлен.')
return redirect(stay_url)
if action == 'add_entity_operation':
op_id = parse_int(request.POST.get('operation_id'))
if not op_id:
messages.error(request, 'Выбери операцию.')
return redirect(stay_url)
last_seq = EntityOperation.objects.filter(entity_id=entity.id).order_by('-seq').values_list('seq', flat=True).first()
seq = int(last_seq or 0) + 1
EntityOperation.objects.create(entity_id=entity.id, operation_id=op_id, seq=seq)
messages.success(request, 'Операция добавлена.')
return redirect(stay_url)
if action == 'add_weld_seam':
passport, _ = AssemblyPassport.objects.get_or_create(entity_id=entity.id)
name = (request.POST.get('seam_name') or '').strip()
leg_mm = parse_float(request.POST.get('seam_leg_mm'))
length_mm = parse_float(request.POST.get('seam_length_mm'))
qty = parse_int(request.POST.get('seam_quantity'), default=1)
if not name:
messages.error(request, 'Заполни наименование сварного шва.')
return redirect(next_url)
if leg_mm is None or leg_mm <= 0:
messages.error(request, 'Катет должен быть больше 0.')
return redirect(next_url)
if length_mm is None or length_mm <= 0:
messages.error(request, 'Длина должна быть больше 0.')
return redirect(next_url)
if not qty or qty <= 0:
messages.error(request, 'Количество должно быть больше 0.')
return redirect(next_url)
WeldingSeam.objects.create(
passport_id=passport.id,
name=name[:255],
leg_mm=float(leg_mm),
length_mm=float(length_mm),
quantity=int(qty),
)
messages.success(request, 'Сварной шов добавлен.')
return redirect(next_url)
if action == 'delete_weld_seam':
seam_id = parse_int(request.POST.get('seam_id'))
passport = AssemblyPassport.objects.filter(entity_id=entity.id).first()
if not seam_id or not passport:
messages.error(request, 'Не выбран сварной шов.')
return redirect(next_url)
WeldingSeam.objects.filter(id=seam_id, passport_id=passport.id).delete()
messages.success(request, 'Сварной шов удалён.')
return redirect(next_url)
if action == 'delete_entity_operation':
eo_id = parse_int(request.POST.get('entity_operation_id'))
if not eo_id:
messages.error(request, 'Не выбрана операция.')
return redirect(stay_url)
EntityOperation.objects.filter(id=eo_id, entity_id=entity.id).delete()
# Комментарий: после удаления перенумеровываем seq, чтобы не было дыр и конфликтов unique_together.
ops = list(EntityOperation.objects.filter(entity_id=entity.id).order_by('seq', 'id'))
for i, eo in enumerate(ops, start=1):
if eo.seq != i:
eo.seq = i
eo.save(update_fields=['seq'])
messages.success(request, 'Операция удалена.')
return redirect(stay_url)
if action == 'move_entity_operation':
eo_id = parse_int(request.POST.get('entity_operation_id'))
direction = (request.POST.get('direction') or '').strip()
if not eo_id or direction not in ['up', 'down']:
messages.error(request, 'Некорректное действие.')
return redirect(stay_url)
eo = EntityOperation.objects.select_related('operation').filter(id=eo_id, entity_id=entity.id).first()
if not eo:
messages.error(request, 'Операция не найдена.')
return redirect(stay_url)
ops = list(EntityOperation.objects.filter(entity_id=entity.id).order_by('seq', 'id'))
idx = next((i for i, x in enumerate(ops) if x.id == eo.id), None)
if idx is None:
return redirect(stay_url)
swap_with = idx - 1 if direction == 'up' else idx + 1
if swap_with < 0 or swap_with >= len(ops):
return redirect(stay_url)
a = ops[idx]
b = ops[swap_with]
a_seq, b_seq = int(a.seq), int(b.seq)
EntityOperation.objects.filter(pk=a.id).update(seq=0)
EntityOperation.objects.filter(pk=b.id).update(seq=a_seq)
EntityOperation.objects.filter(pk=a.id).update(seq=b_seq)
messages.success(request, 'Порядок обновлён.')
return redirect(stay_url)
if action != 'save':
messages.error(request, 'Неизвестное действие.')
return redirect(next_url)
entity.drawing_number = (request.POST.get('drawing_number') or '').strip()[:100]
entity.name = (request.POST.get('name') or '').strip()[:255]
if not entity.name:
messages.error(request, 'Наименование обязательно.')
return redirect(next_url)
entity.passport_filled = bool(request.POST.get('passport_filled'))
if entity.entity_type == 'part':
pm_id = parse_int(request.POST.get('planned_material_id'))
entity.planned_material_id = pm_id
pdf = request.FILES.get('pdf_main')
if pdf:
entity.pdf_main = pdf
dxf = request.FILES.get('dxf_file')
if dxf:
entity.dxf_file = dxf
preview = request.FILES.get('preview')
if preview:
entity.preview = preview
entity.save()
if entity.entity_type in ['product', 'assembly']:
passport, _ = AssemblyPassport.objects.get_or_create(entity_id=entity.id)
passport.requires_welding = bool(request.POST.get('requires_welding'))
passport.requires_painting = bool(request.POST.get('requires_painting'))
passport.weight_kg = parse_float(request.POST.get('weight_kg'))
passport.coating = (request.POST.get('coating') or '').strip()[:200]
passport.coating_color = (request.POST.get('coating_color') or '').strip()[:100]
passport.coating_area_m2 = parse_float(request.POST.get('coating_area_m2'))
passport.technical_requirements = (request.POST.get('technical_requirements') or '').strip()
passport.save()
if entity.entity_type == 'part':
passport, _ = PartPassport.objects.get_or_create(entity_id=entity.id)
passport.thickness_mm = parse_float(request.POST.get('thickness_mm'))
passport.length_mm = parse_float(request.POST.get('length_mm'))
passport.mass_kg = parse_float(request.POST.get('mass_kg'))
passport.cut_length_mm = parse_float(request.POST.get('cut_length_mm'))
passport.pierce_count = parse_int(request.POST.get('pierce_count'))
passport.engraving = (request.POST.get('engraving') or '').strip()
passport.technical_requirements = (request.POST.get('technical_requirements') or '').strip()
passport.save()
if entity.entity_type == 'purchased':
passport, _ = PurchasedPassport.objects.get_or_create(entity_id=entity.id)
passport.gost = (request.POST.get('gost') or '').strip()[:255]
passport.save()
if entity.entity_type == 'casting':
passport, _ = CastingPassport.objects.get_or_create(entity_id=entity.id)
passport.casting_material = (request.POST.get('casting_material') or '').strip()[:200]
passport.mass_kg = parse_float(request.POST.get('mass_kg'))
passport.save()
if entity.entity_type == 'outsourced':
passport, _ = OutsourcedPassport.objects.get_or_create(entity_id=entity.id)
passport.technical_requirements = (request.POST.get('technical_requirements') or '').strip()
passport.notes = (request.POST.get('notes') or '').strip()
passport.save()
if 'operation_ids' in request.POST:
op_ids = [int(x) for x in request.POST.getlist('operation_ids') if str(x).isdigit()]
op_ids = list(dict.fromkeys(op_ids))
valid = set(Operation.objects.filter(id__in=op_ids).values_list('id', flat=True))
op_ids = [int(x) for x in op_ids if int(x) in valid]
EntityOperation.objects.filter(entity_id=entity.id).exclude(operation_id__in=op_ids).delete()
existing = list(EntityOperation.objects.filter(entity_id=entity.id, operation_id__in=op_ids).order_by('id'))
by_op = {int(eo.operation_id): eo for eo in existing}
if existing:
EntityOperation.objects.filter(id__in=[eo.id for eo in existing]).update(seq=0)
for i, op_id in enumerate(op_ids, start=1):
eo = by_op.get(int(op_id))
if eo:
if int(eo.seq or 0) != i:
EntityOperation.objects.filter(id=eo.id).update(seq=i)
else:
EntityOperation.objects.create(entity_id=entity.id, operation_id=int(op_id), seq=i)
messages.success(request, 'Сохранено.')
return redirect(stay_url)
class WriteOffsView(LoginRequiredMixin, TemplateView):
template_name = 'shiftflow/writeoffs.html'
def dispatch(self, request, *args, **kwargs):
roles = get_user_group_roles(request.user)
if not has_any_role(roles, ['admin', 'clerk', 'observer', 'prod_head', 'director']):
return redirect('registry')
return super().dispatch(request, *args, **kwargs)
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
roles = get_user_group_roles(self.request.user)
role = primary_role(roles)
ctx['user_role'] = role
ctx['user_roles'] = sorted(roles)
ctx['can_edit'] = has_any_role(roles, ['admin', 'clerk', 'prod_head'])
start_date = (self.request.GET.get('start_date') or '').strip()
end_date = (self.request.GET.get('end_date') or '').strip()
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',
'remnants__material',
)
)
report_cards = []
for r in reports:
consumed = {}
for c in list(getattr(r, 'consumptions', []).all() if hasattr(getattr(r, 'consumptions', None), 'all') else []):
mat = None
if getattr(c, 'material_id', None):
mat = c.material
elif getattr(c, 'stock_item_id', None) and getattr(c.stock_item, 'material_id', None):
mat = c.stock_item.material
label = str(mat) if mat else ''
key = getattr(mat, 'id', None) or label
consumed[key] = consumed.get(key, 0.0) + float(c.quantity)
produced = {}
remnants = {}
for res in list(getattr(r, 'results', []).all() if hasattr(getattr(r, 'results', None), 'all') else []):
si = res.stock_item
if res.kind == 'finished':
label = str(getattr(si, 'entity', None) or '')
produced[label] = produced.get(label, 0.0) + float(si.quantity)
elif res.kind == 'remnant':
label = str(getattr(si, 'material', None) or '')
remnants[label] = remnants.get(label, 0.0) + float(si.quantity)
report_cards.append({
'report': r,
'consumed': consumed,
'produced': produced,
'remnants': remnants,
'tasks': list(getattr(r, 'tasks', []).all() if hasattr(getattr(r, 'tasks', None), 'all') else []),
})
ctx['report_cards'] = report_cards
items_qs = (
Item.objects.select_related('task', 'task__deal', 'machine')
.filter(status__in=['done', 'partial'], date__gte=start_date, date__lte=end_date)
.order_by('-date', '-id')
)
ctx['items'] = list(items_qs)
return ctx
def post(self, request, *args, **kwargs):
roles = get_user_group_roles(request.user)
if not has_any_role(roles, ['admin', 'clerk', 'prod_head']):
return redirect('writeoffs')
ids = request.POST.getlist('report_ids')
report_ids = [int(x) for x in ids if x.isdigit()]
if not report_ids:
messages.error(request, 'Не выбрано ни одного производственного отчёта.')
return redirect('writeoffs')
updated = CuttingSession.objects.filter(id__in=report_ids, is_synced_1c=False).update(
is_synced_1c=True,
synced_1c_at=timezone.now(),
synced_1c_by_id=request.user.id,
)
messages.success(request, f'Отмечено «выгружено в 1С»: {updated}.')
start_date = (request.POST.get('start_date') or '').strip()
end_date = (request.POST.get('end_date') or '').strip()
return redirect(f"{reverse_lazy('writeoffs')}?start_date={start_date}&end_date={end_date}")