All checks were successful
Deploy MES Core / deploy (push) Successful in 11s
5900 lines
256 KiB
Python
5900 lines
256 KiB
Python
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,
|
||
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 для ВСЕХ операций маршрута каждого дочернего компонента,
|
||
# плюс для выбранной операции родителя.
|
||
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())
|
||
entity_ops = list(EntityOperation.objects.select_related('operation').filter(entity_id__in=node_ids))
|
||
ops_by_entity = {}
|
||
for eo in entity_ops:
|
||
ops_by_entity.setdefault(eo.entity_id, []).append(eo)
|
||
|
||
created_count = 0
|
||
for c_id, c_qty in required_nodes.items():
|
||
c_ops = ops_by_entity.get(c_id, [])
|
||
if c_id == 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
|
||
else:
|
||
# Для дочерних создаем на все операции маршрута
|
||
for eo in c_ops:
|
||
if not eo.operation:
|
||
continue
|
||
w_id = eo.operation.workshop_id
|
||
WorkItem.objects.create(
|
||
deal_id=deal_id,
|
||
entity_id=c_id,
|
||
operation_id=eo.operation_id,
|
||
workshop_id=w_id,
|
||
machine_id=None,
|
||
stage=(eo.operation.name or '')[:32],
|
||
quantity_plan=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):
|
||
profile = getattr(request.user, 'profile', None)
|
||
role = profile.role if profile else ('admin' if request.user.is_superuser else 'operator')
|
||
if role not in ['admin', 'technologist']:
|
||
return JsonResponse({'error': 'forbidden'}, status=403)
|
||
|
||
company_id = request.POST.get('id')
|
||
name = (request.POST.get('name') or '').strip()
|
||
description = (request.POST.get('description') or '').strip()
|
||
|
||
if not name:
|
||
return JsonResponse({'error': 'name_required'}, status=400)
|
||
|
||
if company_id and str(company_id).isdigit():
|
||
company = get_object_or_404(Company, pk=int(company_id))
|
||
company.name = name
|
||
else:
|
||
company, _ = Company.objects.get_or_create(name=name)
|
||
|
||
company.description = description
|
||
company.save()
|
||
return JsonResponse({'id': company.id, 'label': company.name})
|
||
|
||
|
||
class MaterialCategoryUpsertView(LoginRequiredMixin, View):
|
||
def post(self, request, *args, **kwargs):
|
||
profile = getattr(request.user, 'profile', None)
|
||
role = profile.role if profile else ('admin' if request.user.is_superuser else 'operator')
|
||
if role not in ['admin', 'technologist']:
|
||
return JsonResponse({'error': 'forbidden'}, status=403)
|
||
|
||
category_id = request.POST.get('id')
|
||
name = (request.POST.get('name') or '').strip()
|
||
gost_standard = (request.POST.get('gost_standard') or '').strip()
|
||
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):
|
||
profile = getattr(request.user, 'profile', None)
|
||
role = profile.role if profile else ('admin' if request.user.is_superuser else 'operator')
|
||
if role not in ['admin', 'technologist']:
|
||
return JsonResponse({'error': 'forbidden'}, status=403)
|
||
|
||
grade_id = request.POST.get('id')
|
||
name = (request.POST.get('name') or '').strip()
|
||
gost_standard = (request.POST.get('gost_standard') or '').strip()
|
||
|
||
if not name:
|
||
return JsonResponse({'error': 'name_required'}, status=400)
|
||
|
||
if grade_id and str(grade_id).isdigit():
|
||
grade = get_object_or_404(SteelGrade, pk=int(grade_id))
|
||
grade.name = name
|
||
else:
|
||
grade, _ = SteelGrade.objects.get_or_create(name=name)
|
||
|
||
grade.gost_standard = gost_standard
|
||
grade.save()
|
||
return JsonResponse({'id': grade.id, 'label': grade.name})
|
||
|
||
|
||
class EntitiesSearchView(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 not in ['admin', 'technologist']:
|
||
return JsonResponse({'error': 'forbidden'}, status=403)
|
||
|
||
q_dn = (request.GET.get('q_dn') or '').strip()
|
||
q_name = (request.GET.get('q_name') or '').strip()
|
||
et = (request.GET.get('entity_type') or '').strip()
|
||
|
||
qs = ProductEntity.objects.all()
|
||
if et in ['product', 'assembly', 'part']:
|
||
qs = qs.filter(entity_type=et)
|
||
if q_dn:
|
||
qs = qs.filter(drawing_number__icontains=q_dn)
|
||
if q_name:
|
||
qs = qs.filter(name__icontains=q_name)
|
||
|
||
data = [
|
||
{
|
||
'id': e.id,
|
||
'type': e.entity_type,
|
||
'drawing_number': e.drawing_number,
|
||
'name': e.name,
|
||
}
|
||
for e in qs.order_by('entity_type', 'drawing_number', 'name', 'id')[:200]
|
||
]
|
||
return JsonResponse({'results': data})
|
||
|
||
|
||
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 == '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):
|
||
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')
|
||
|
||
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 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')
|
||
|
||
try:
|
||
item, created = DealItem.objects.get_or_create(deal_id=deal_id, entity_id=entity_id, defaults={'quantity': qty})
|
||
if not created:
|
||
item.quantity = qty
|
||
item.save()
|
||
|
||
_reconcile_default_delivery_batch(int(deal_id))
|
||
messages.success(request, 'Позиция сделки сохранена.')
|
||
except Exception as e:
|
||
messages.error(request, f'Ошибка: {e}')
|
||
|
||
next_url = (request.POST.get('next') or '').strip()
|
||
return redirect(next_url if next_url.startswith('/') else 'planning')
|
||
|
||
|
||
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['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)
|
||
|
||
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)
|
||
|
||
|
||
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):
|
||
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_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)
|
||
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
|
||
|
||
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):
|
||
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_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['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()
|
||
|
||
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}") |