Доработал закрытие, техоперации автоматом переключаются, добавил выгрузку сменных заданий
All checks were successful
Deploy MES Core / deploy (push) Successful in 14s
All checks were successful
Deploy MES Core / deploy (push) Successful in 14s
This commit is contained in:
@@ -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}')
|
||||
|
||||
Reference in New Issue
Block a user