Доработал закрытие, техоперации автоматом переключаются, добавил выгрузку сменных заданий
All checks were successful
Deploy MES Core / deploy (push) Successful in 14s

This commit is contained in:
2026-04-25 11:58:11 +03:00
parent 6fd01c9a6e
commit 909ba05b5d
14 changed files with 998 additions and 74 deletions

View File

@@ -17,7 +17,7 @@ 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.http import HttpResponse, JsonResponse
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse_lazy
from django.views import View
@@ -30,6 +30,86 @@ from shiftflow.authz import get_user_group_roles, get_user_roles, primary_role,
logger = logging.getLogger('mes')
def _build_registry_workitems_queryset(request, *, role: str, profile):
"""Возвращает queryset WorkItem, соответствующий фильтрам страницы реестра.
Это ровно та же выборка, которая используется для таблицы WorkItem на странице
[registry] (см. RegistryView.get_context_data). Нужна для печати/выгрузок,
чтобы брать задания «как на экране».
"""
qs = WorkItem.objects.select_related(
'deal',
'deal__company',
'entity',
'entity__planned_material',
'operation',
'machine',
'workshop',
)
q = (request.GET.get('q') or '').strip()
if q:
qs = qs.filter(
Q(deal__number__icontains=q)
| Q(entity__name__icontains=q)
| Q(entity__drawing_number__icontains=q)
| Q(entity__planned_material__name__icontains=q)
| Q(entity__planned_material__full_name__icontains=q)
)
m_ids = [int(i) for i in request.GET.getlist('m_ids') if str(i).isdigit()]
if m_ids:
qs = qs.filter(Q(machine_id__in=m_ids) | Q(machine_id__isnull=True))
filtered = request.GET.get('filtered')
reset = request.GET.get('reset')
is_default = (not filtered) or bool(reset)
if is_default:
today = timezone.localdate()
week_ago = today - timezone.timedelta(days=7)
qs = qs.filter(date__gte=week_ago, date__lte=today)
else:
start_date = (request.GET.get('start_date') or '').strip()
end_date = (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 = request.GET.getlist('statuses')
if is_default:
qs = qs.filter(status__in=['planned'])
else:
if not statuses:
qs = 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:
qs = qs.filter(status__in=expanded)
if role == 'operator':
allowed_ws = list(profile.allowed_workshops.values_list('id', flat=True)) if profile else []
if allowed_ws:
qs = qs.filter(Q(workshop_id__in=allowed_ws) | Q(machine__workshop_id__in=allowed_ws))
else:
user_machines = profile.machines.all() if profile else Machine.objects.none()
qs = 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:
qs = qs.filter(Q(workshop_id__in=allowed_ws) | Q(machine__workshop_id__in=allowed_ws))
return qs
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:
@@ -111,6 +191,8 @@ from warehouse.models import Location, Material, MaterialCategory, SteelGrade, S
from warehouse.services.transfers import receive_transfer
from shiftflow.services.closing import apply_closing, apply_closing_workitems
from shiftflow.services.route_flow import advance_progress_and_generate_next_workitem
from shiftflow.services.workitem_registry_export import build_workitem_registry_export_zip
from shiftflow.services.bom_explosion import (
explode_deal,
explode_roots_additive,
@@ -522,65 +604,7 @@ class RegistryView(LoginRequiredMixin, ListView):
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', 'deal__company', 'entity', 'entity__planned_material', 'operation', 'machine', 'workshop')
q = (self.request.GET.get('q') or '').strip()
if q:
work_qs = work_qs.filter(
Q(deal__number__icontains=q)
| Q(entity__name__icontains=q)
| Q(entity__drawing_number__icontains=q)
| Q(entity__planned_material__name__icontains=q)
| Q(entity__planned_material__full_name__icontains=q)
)
m_ids = [int(i) for i in self.request.GET.getlist('m_ids') if str(i).isdigit()]
if m_ids:
work_qs = work_qs.filter(Q(machine_id__in=m_ids) | Q(machine_id__isnull=True))
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':
allowed_ws = list(profile.allowed_workshops.values_list('id', flat=True)) if profile else []
if allowed_ws:
work_qs = work_qs.filter(Q(workshop_id__in=allowed_ws) | Q(machine__workshop_id__in=allowed_ws))
else:
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(workshop_id__in=allowed_ws) | Q(machine__workshop_id__in=allowed_ws))
work_qs = _build_registry_workitems_queryset(self.request, role=str(role or ''), profile=profile)
workitems = list(work_qs.order_by('-date', 'deal__number', 'id')[:2000])
for wi in workitems:
@@ -1282,9 +1306,65 @@ class WorkItemRegistryPrintView(LoginRequiredMixin, TemplateView):
g['items'].append(wi)
ctx['groups'] = list(groups.values())
ctx['printed_at'] = timezone.now()
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
ctx['print_date'] = print_date or timezone.localdate()
return ctx
class WorkItemRegistryDownloadView(LoginRequiredMixin, TemplateView):
template_name = 'shiftflow/registry_workitems_download.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)
roles = get_user_group_roles(self.request.user)
ctx['user_roles'] = sorted(roles)
ctx['user_role'] = primary_role(roles)
ctx['only_work_deals'] = True
return ctx
def post(self, request, *args, **kwargs):
roles = get_user_group_roles(request.user)
role = primary_role(roles)
profile = getattr(request.user, 'profile', None)
translit = (request.POST.get('translit') or '').strip() in ['1', 'true', 'on', 'yes']
try:
qs = _build_registry_workitems_queryset(request, role=str(role or ''), profile=profile)
qs = qs.filter(deal__status='work', status='planned')
workitem_ids = list(qs.values_list('id', flat=True))
zip_bytes, filename = build_workitem_registry_export_zip(
request=request,
workitem_ids=workitem_ids,
translit=bool(translit),
only_work_deals=True,
)
except Exception as e:
logger.exception('workitem_registry_download:error user_id=%s', getattr(request.user, 'id', None))
messages.error(request, f'Ошибка формирования архива: {type(e).__name__}: {e}')
url = f"{reverse_lazy('registry_workitems_download')}?{request.GET.urlencode()}" if request.GET else str(reverse_lazy('registry_workitems_download'))
return redirect(url)
resp = HttpResponse(zip_bytes, content_type='application/zip')
resp['Content-Disposition'] = f'attachment; filename="{filename}"'
return resp
class WorkItemEntityListView(LoginRequiredMixin, TemplateView):
template_name = 'shiftflow/workitem_entity_list.html'
@@ -1360,6 +1440,9 @@ class WorkItemOpClosingView(LoginRequiredMixin, TemplateView):
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 getattr(wi.entity, 'planned_material_id', None) and not wi.machine_id:
messages.error(request, 'Для закрытия операции нужно выбрать пост/станок в сменном задании.')
return redirect('workitem_detail', pk=wi.id)
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)}")
@@ -1377,6 +1460,51 @@ class WorkItemOpClosingView(LoginRequiredMixin, TemplateView):
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)
work_location = get_work_location_for_workitem(wi)
ctx['work_location'] = work_location
hint = (self.request.session.get('op_closing_move_hint') or None)
if hint and int(hint.get('workitem_id') or 0) == int(wi.id):
try:
self.request.session.pop('op_closing_move_hint', None)
except Exception:
pass
needed = int(hint.get('qty') or 0)
available = int(hint.get('available') or 0)
missing = max(0, needed - available)
candidates = []
if work_location and missing > 0:
qs = (
StockItem.objects.select_related('location')
.filter(is_archived=False, quantity__gt=0)
.filter(entity_id=int(wi.entity_id))
.exclude(location_id=int(work_location.id))
.filter(Q(deal_id=wi.deal_id) | Q(deal_id__isnull=True))
.annotate(
prio=Case(
When(deal_id=wi.deal_id, then=Value(0)),
default=Value(1),
output_field=IntegerField(),
)
)
.order_by('prio', 'created_at', 'id')
)
for si in list(qs[:30]):
candidates.append({'id': int(si.id), 'location': si.location, 'quantity': float(si.quantity)})
ctx['move_hint'] = {
'needed': int(needed),
'available': int(available),
'missing': int(missing),
}
ctx['move_candidates'] = candidates
else:
ctx['move_hint'] = None
ctx['move_candidates'] = []
return ctx
def post(self, request, *args, **kwargs):
@@ -1409,7 +1537,8 @@ class WorkItemOpClosingView(LoginRequiredMixin, TemplateView):
)['s']
available_i = int(available or 0)
if qty > available_i:
messages.error(request, f'Нельзя закрыть операцию: на складе участка «{work_location.name}» доступно {available_i} шт. Сначала перемести изделие на участок.')
request.session['op_closing_move_hint'] = {'workitem_id': int(wi.id), 'qty': int(qty), 'available': int(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)
@@ -1468,8 +1597,11 @@ class WorkItemOpClosingView(LoginRequiredMixin, TemplateView):
if int(total_done or 0) >= int(target_qty):
progress.current_seq = cur + 1
progress.save(update_fields=['current_seq'])
advance_progress_and_generate_next_workitem(workitem_id=int(wi.id))
messages.success(request, f'Закрыто: {qty} шт.')
if (wi.status or '') == 'done':
return redirect('registry')
return redirect('workitem_detail', pk=wi.id)
@@ -5244,7 +5376,7 @@ class AssemblyClosingView(LoginRequiredMixin, TemplateView):
try:
apply_assembly_closing(self.workitem.id, qty, request.user.id)
messages.success(request, f'Успешно закрыто {qty} шт. Компоненты списаны, выпуск добавлен.')
return redirect('workitem_detail', pk=self.workitem.id)
return redirect('registry')
except Exception as e:
logger.exception('assembly_closing: error')
messages.error(request, f'Ошибка закрытия: {e}')