+
+
DXF/IGES/STEP
+
+ {% if entity.dxf_file %}
+
+ {% endif %}
+
+
+
Картинка
{% if entity.preview %}
@@ -100,7 +108,7 @@
{% if can_edit %}
-
+
Операции техпроцесса
@@ -109,69 +117,154 @@
-
- {% for eo in entity_ops %}
-
- {{ eo.seq }}
- {{ eo.operation.name }}
- {% if eo.operation.workshop %}{{ eo.operation.workshop.name }}{% else %}—{% endif %}
-
-
-
-
-
-
-
-
- {% empty %}
- Операции не добавлены
- {% endfor %}
+
+ {% if selected_operation_ids %}
+ {% for op_id in selected_operation_ids %}
+
+ {{ forloop.counter }}
+
+
+ — выбери —
+ {% for op in operations %}
+ {{ op.name }}{% if op.workshop %} ({{ op.workshop.name }}){% endif %}
+ {% endfor %}
+
+
+
+
+ ↑
+ ↓
+ Удалить
+
+
+
+ {% endfor %}
+ {% else %}
+ {% for i in '1234'|make_list %}
+
+ {{ forloop.counter }}
+
+
+ — выбери —
+ {% for op in operations %}
+ {{ op.name }}{% if op.workshop %} ({{ op.workshop.name }}){% endif %}
+ {% endfor %}
+
+
+
+
+ ↑
+ ↓
+ Удалить
+
+
+
+ {% endfor %}
+ {% endif %}
-
+
+
+
+
+
+
+
+ — выбери —
+ {% for op in operations %}
+ {{ op.name }}{% if op.workshop %} ({{ op.workshop.name }}){% endif %}
+ {% endfor %}
+
+
+
+
+ ↑
+ ↓
+ Удалить
+
+
+
+
+
+
{% endif %}
diff --git a/shiftflow/templates/shiftflow/shipping.html b/shiftflow/templates/shiftflow/shipping.html
new file mode 100644
index 0000000..1d33103
--- /dev/null
+++ b/shiftflow/templates/shiftflow/shipping.html
@@ -0,0 +1,182 @@
+{% extends 'base.html' %}
+
+{% block content %}
+
+
+
+
+
+
+ {% if selected_deal_id %}
+
+
+
+
+
+ {% else %}
+
Выбери сделку, чтобы сформировать список к отгрузке.
+ {% endif %}
+
+
+{% endblock %}
\ No newline at end of file
diff --git a/shiftflow/templates/shiftflow/warehouse_stocks.html b/shiftflow/templates/shiftflow/warehouse_stocks.html
index 4b83f2b..7887015 100644
--- a/shiftflow/templates/shiftflow/warehouse_stocks.html
+++ b/shiftflow/templates/shiftflow/warehouse_stocks.html
@@ -169,20 +169,7 @@
Переместить
-
- Отгрузка
-
+
{% else %}
только просмотр
diff --git a/shiftflow/urls.py b/shiftflow/urls.py
index c7a86f7..e3f348c 100644
--- a/shiftflow/urls.py
+++ b/shiftflow/urls.py
@@ -57,6 +57,7 @@ from .views import (
WarehouseStocksView,
WarehouseTransferCreateView,
ProcurementDashboardView,
+ ShippingView,
)
urlpatterns = [
@@ -120,6 +121,7 @@ urlpatterns = [
path('closing/workitems/', ClosingWorkItemsView.as_view(), name='closing_workitems'),
path('writeoffs/', WriteOffsView.as_view(), name='writeoffs'),
path('procurement/', ProcurementDashboardView.as_view(), name='procurement'),
+ path('shipping/', ShippingView.as_view(), name='shipping'),
path('legacy/closing/', LegacyClosingView.as_view(), name='legacy_closing'),
path('legacy/writeoffs/', LegacyWriteOffsView.as_view(), name='legacy_writeoffs'),
diff --git a/shiftflow/views.py b/shiftflow/views.py
index 9d7a9c7..1120b4b 100644
--- a/shiftflow/views.py
+++ b/shiftflow/views.py
@@ -114,6 +114,7 @@ from shiftflow.services.closing import apply_closing, apply_closing_workitems
from shiftflow.services.bom_explosion import (
explode_deal,
explode_roots_additive,
+ rollback_roots_additive,
ExplosionValidationError,
_build_bom_graph,
_accumulate_requirements,
@@ -2663,30 +2664,34 @@ class WorkItemPlanAddView(LoginRequiredMixin, View):
machine.workshop_id if machine and machine.workshop_id else (workshop.id if workshop else getattr(op, 'workshop_id', None))
)
- # Комментарий: Если включен чекбокс recursive_bom, мы бежим по всему дереву BOM вниз
- # и создаем WorkItem для ВСЕХ операций маршрута каждого дочернего компонента,
- # плюс для выбранной операции родителя.
+ # Комментарий: Если включен чекбокс recursive_bom, бежим по дереву BOM вниз.
+ # Для дочерних компонентов создаём WorkItem только на текущую операцию
+ # (по DealEntityProgress.current_seq), чтобы операции возникали по очереди.
+ # Для родителя создаём WorkItem по выбранной операции из модалки.
if recursive_bom:
try:
with transaction.atomic():
adjacency = _build_bom_graph({entity_id})
required_nodes = {}
_accumulate_requirements(entity_id, qty, adjacency, set(), required_nodes)
-
- # Получаем все маршруты для собранных сущностей
+
node_ids = list(required_nodes.keys())
- 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)
-
+
+ progress_map = {
+ int(p.entity_id): int(p.current_seq or 1)
+ for p in DealEntityProgress.objects.filter(deal_id=int(deal_id), entity_id__in=node_ids)
+ }
+
+ ops_map = {
+ (int(eo.entity_id), int(eo.seq)): eo
+ for eo in EntityOperation.objects.select_related('operation', 'operation__workshop')
+ .filter(entity_id__in=node_ids)
+ }
+
created_count = 0
+
for c_id, c_qty in required_nodes.items():
- c_ops = ops_by_entity.get(c_id, [])
- if c_id == entity_id:
- # Для самого родителя мы ставим только ту операцию, которую выбрали в модалке (или тоже все?)
- # Пользователь просил "на все операции маршрута для каждой вложенной детали".
- # Родительскую мы создадим явно по выбранной, остальные дочерние - по всем.
+ if int(c_id) == int(entity_id):
WorkItem.objects.create(
deal_id=deal_id,
entity_id=entity_id,
@@ -2700,25 +2705,28 @@ class WorkItemPlanAddView(LoginRequiredMixin, View):
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
+ continue
+
+ seq = int(progress_map.get(int(c_id), 1) or 1)
+ eo = ops_map.get((int(c_id), seq))
+ if not eo or not getattr(eo, 'operation_id', None) or not getattr(eo, 'operation', None):
+ continue
+
+ cur_op = eo.operation
+ WorkItem.objects.create(
+ deal_id=deal_id,
+ entity_id=int(c_id),
+ operation_id=int(cur_op.id),
+ workshop_id=(int(cur_op.workshop_id) if getattr(cur_op, 'workshop_id', None) else None),
+ machine_id=None,
+ stage=(cur_op.name or '')[:32],
+ quantity_plan=int(c_qty),
+ quantity_done=0,
+ status='planned',
+ date=timezone.localdate(),
+ )
+ created_count += 1
+
messages.success(request, f'Рекурсивно добавлено в смену заданий: {created_count} шт.')
except Exception as e:
logger.exception('workitem_add recursive error')
@@ -2989,10 +2997,10 @@ class MaterialCategoryUpsertView(LoginRequiredMixin, View):
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']:
+ roles = get_user_roles(request.user)
+ if not has_any_role(roles, ['admin', 'technologist', 'manager', 'prod_head']):
return JsonResponse({'error': 'forbidden'}, status=403)
grade_id = request.POST.get('id')
@@ -3014,34 +3022,60 @@ class SteelGradeUpsertView(LoginRequiredMixin, View):
class EntitiesSearchView(LoginRequiredMixin, View):
+ """JSON-поиск сущностей ProductEntity для модальных окон.
+
+ Использование на фронтенде (пример):
+ /entities/search/?entity_type=part&q_dn=12.34&q_name=косынка
+
+ Возвращает:
+ {
+ "results": [{"id": 1, "type": "part", "drawing_number": "...", "name": "..."}, ...],
+ "count": 10
+ }
+
+ Диагностика:
+ - логируем start/forbidden/done (без секретов) для разбора кейсов «ничего не находит».
+ """
+
def get(self, request, *args, **kwargs):
- 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)
+ roles = get_user_roles(request.user) # Роли пользователя (Django Groups + fallback на profile.role)
+ if not has_any_role(roles, ['admin', 'technologist', 'manager', 'prod_head']): # Доступ только для этих ролей
+ logger.info('entities_search:forbidden user_id=%s roles=%s', request.user.id, sorted(roles))
+ return JsonResponse({'error': 'forbidden'}, status=403) # Для фронта это сигнал показать «нет доступа»
- q_dn = (request.GET.get('q_dn') or '').strip()
- q_name = (request.GET.get('q_name') or '').strip()
- et = (request.GET.get('entity_type') or '').strip()
+ q_dn = (request.GET.get('q_dn') or '').strip() # Поиск по обозначению (drawing_number), подстрока
+ q_name = (request.GET.get('q_name') or '').strip() # Поиск по наименованию (name), подстрока
+ et = (request.GET.get('entity_type') or '').strip() # Фильтр по типу сущности (ProductEntity.entity_type)
- qs = ProductEntity.objects.all()
- if et in ['product', 'assembly', 'part']:
+ logger.info('entities_search:start user_id=%s et=%s q_dn=%s q_name=%s', request.user.id, et, q_dn, q_name)
+
+ qs = ProductEntity.objects.all() # Базовая выборка по всем сущностям КД
+
+ # Фильтр по типу включаем для всех допустимых типов ProductEntity.
+ allowed_types = {'product', 'assembly', 'part', 'purchased', 'casting', 'outsourced'}
+ if et in allowed_types:
qs = qs.filter(entity_type=et)
- if q_dn:
- qs = qs.filter(drawing_number__icontains=q_dn)
- if q_name:
- qs = qs.filter(name__icontains=q_name)
- data = [
+ if q_dn:
+ qs = qs.filter(drawing_number__icontains=q_dn) # ILIKE по drawing_number
+
+ if q_name:
+ qs = qs.filter(name__icontains=q_name) # ILIKE по name
+
+ qs = qs.order_by('entity_type', 'drawing_number', 'name', 'id')
+
+ data = [ # Формируем компактный JSON (только то, что нужно для селекта в модалке)
{
- 'id': e.id,
- 'type': e.entity_type,
- 'drawing_number': e.drawing_number,
- 'name': e.name,
+ 'id': e.id, # PK сущности
+ '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]
+ for e in qs[:200] # Ограничиваем ответ 200 строками
]
- return JsonResponse({'results': data})
+
+ logger.info('entities_search:done user_id=%s count=%s', request.user.id, len(data))
+ return JsonResponse({'results': data, 'count': len(data)}) # Отдаём результаты в JSON
class DealBatchActionView(LoginRequiredMixin, View):
@@ -3184,6 +3218,65 @@ class DealBatchActionView(LoginRequiredMixin, View):
messages.success(request, 'Строка удалена.')
return redirect(next_url)
+ if action == 'rollback_batch_item_production':
+ item_id = parse_int(request.POST.get('item_id'))
+ qty = parse_int(request.POST.get('quantity'))
+ if not item_id or not qty or qty <= 0:
+ messages.error(request, 'Заполни количество.')
+ return redirect(next_url)
+
+ logger.info('rollback_batch_item_production:start deal_id=%s item_id=%s qty=%s', deal_id, item_id, qty)
+
+ try:
+ with transaction.atomic():
+ bi = (
+ DealBatchItem.objects.select_for_update()
+ .select_related('batch', 'entity')
+ .filter(id=item_id, batch__deal_id=deal_id)
+ .first()
+ )
+ if not bi:
+ messages.error(request, 'Строка партии не найдена.')
+ return redirect(next_url)
+
+ started = int(getattr(bi, 'started_qty', 0) or 0)
+ if int(qty) > started:
+ messages.error(request, 'Нельзя откатить больше, чем запущено в этой партии.')
+ return redirect(next_url)
+
+ # Комментарий: откат запрещён, если хоть что-то из этого запуска уже попало в смену.
+ adjacency = _build_bom_graph({int(bi.entity_id)})
+ required_nodes: dict[int, int] = {}
+ _accumulate_requirements(int(bi.entity_id), int(qty), adjacency, set(), required_nodes)
+ affected_ids = list(required_nodes.keys())
+
+ wi_exists = WorkItem.objects.filter(deal_id=deal_id, entity_id__in=affected_ids).filter(
+ Q(quantity_plan__gt=0) | Q(quantity_done__gt=0)
+ ).exists()
+ if wi_exists:
+ messages.error(request, 'Нельзя откатить: по этой позиции уже есть постановка в смену (план/факт).')
+ return redirect(next_url)
+
+ stats = rollback_roots_additive(int(deal_id), [(int(bi.entity_id), int(qty))])
+
+ bi.started_qty = started - int(qty)
+ bi.save(update_fields=['started_qty'])
+
+ messages.success(request, f'Откат выполнен: −{qty} шт. Задачи обновлено: {stats.tasks_updated}.')
+ logger.info(
+ 'rollback_batch_item_production:done deal_id=%s item_id=%s entity_id=%s qty=%s tasks_updated=%s',
+ deal_id,
+ item_id,
+ bi.entity_id,
+ qty,
+ stats.tasks_updated,
+ )
+ return redirect(next_url)
+ except Exception:
+ logger.exception('rollback_batch_item_production:error deal_id=%s item_id=%s qty=%s', deal_id, item_id, qty)
+ messages.error(request, 'Ошибка отката запуска. Подробности в логе сервера.')
+ return redirect(next_url)
+
if action == 'start_batch_item_production':
item_id = parse_int(request.POST.get('item_id'))
qty = parse_int(request.POST.get('quantity'))
@@ -3260,11 +3353,17 @@ class DealBatchActionView(LoginRequiredMixin, View):
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']:
+ roles = get_user_roles(request.user)
+ if not has_any_role(roles, ['admin', 'technologist', 'manager', 'prod_head']):
return redirect('planning')
+ action = (request.POST.get('action') or '').strip()
+ if not action:
+ action = 'add'
+
+ next_url = (request.POST.get('next') or '').strip()
+ next_url = next_url if next_url.startswith('/') else str(reverse_lazy('planning'))
+
def parse_int(s):
s = (s or '').strip()
return int(s) if s.isdigit() else None
@@ -3273,24 +3372,111 @@ class DealItemUpsertView(LoginRequiredMixin, View):
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')
+ if not (deal_id and entity_id):
+ messages.error(request, 'Не выбрана сделка или сущность.')
+ return redirect(next_url)
+
+ if action in ['add', 'set_qty']:
+ if not (qty and qty > 0):
+ messages.error(request, 'Заполни количество (больше 0).')
+ return redirect(next_url)
try:
- 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()
+ with transaction.atomic():
+ if action == 'delete':
+ item = DealItem.objects.select_for_update().filter(deal_id=deal_id, entity_id=entity_id).first()
+ if not item:
+ messages.error(request, 'Позиция сделки не найдена.')
+ return redirect(next_url)
- _reconcile_default_delivery_batch(int(deal_id))
- messages.success(request, 'Позиция сделки сохранена.')
+ started = (
+ DealBatchItem.objects.filter(batch__deal_id=deal_id, entity_id=entity_id)
+ .aggregate(s=Coalesce(Sum('started_qty'), 0))['s']
+ )
+ started = int(started or 0)
+
+ wi_agg = (
+ WorkItem.objects.filter(deal_id=deal_id, entity_id=entity_id)
+ .aggregate(p=Coalesce(Sum('quantity_plan'), 0), d=Coalesce(Sum('quantity_done'), 0))
+ )
+ planned = int((wi_agg or {}).get('p') or 0)
+ done = int((wi_agg or {}).get('d') or 0)
+
+ allocated = (
+ DealBatchItem.objects.filter(batch__deal_id=deal_id, entity_id=entity_id)
+ .aggregate(s=Coalesce(Sum('quantity'), 0))['s']
+ )
+ allocated = int(allocated or 0)
+
+ if started > 0 or planned > 0 or done > 0:
+ messages.error(request, 'Нельзя удалить позицию: по ней уже есть запуск/план/факт. Сначала откати производство.')
+ return redirect(next_url)
+
+ if allocated > 0:
+ messages.error(request, 'Нельзя удалить позицию: она уже распределена по партиям поставки. Сначала удали строки партий.')
+ return redirect(next_url)
+
+ DealEntityProgress.objects.filter(deal_id=deal_id, entity_id=entity_id).delete()
+ WorkItem.objects.filter(deal_id=deal_id, entity_id=entity_id).delete()
+ DealBatchItem.objects.filter(batch__deal_id=deal_id, entity_id=entity_id).delete()
+ item.delete()
+
+ _reconcile_default_delivery_batch(int(deal_id))
+ messages.success(request, 'Позиция удалена из сделки.')
+ return redirect(next_url)
+
+ item, created = DealItem.objects.select_for_update().get_or_create(
+ deal_id=deal_id,
+ entity_id=entity_id,
+ defaults={'quantity': int(qty)},
+ )
+
+ if action == 'add':
+ if not created:
+ messages.warning(request, 'Позиция уже есть в сделке. Измени количество в строке позиции (OK).')
+ return redirect(next_url)
+
+ item.quantity = int(qty)
+ item.save(update_fields=['quantity'])
+ _reconcile_default_delivery_batch(int(deal_id))
+ messages.success(request, 'Позиция сделки добавлена.')
+ return redirect(next_url)
+
+ if action == 'set_qty':
+ started = (
+ DealBatchItem.objects.filter(batch__deal_id=deal_id, entity_id=entity_id)
+ .aggregate(s=Coalesce(Sum('started_qty'), 0))['s']
+ )
+ started = int(started or 0)
+
+ allocated_non_default = (
+ DealBatchItem.objects.filter(batch__deal_id=deal_id, batch__is_default=False, entity_id=entity_id)
+ .aggregate(s=Coalesce(Sum('quantity'), 0))['s']
+ )
+ allocated_non_default = int(allocated_non_default or 0)
+
+ if int(qty) < started:
+ messages.error(request, f'Нельзя поставить {qty} шт: уже запущено {started} шт в производство.')
+ return redirect(next_url)
+
+ if int(qty) < allocated_non_default:
+ messages.error(request, f'Нельзя поставить {qty} шт: по партиям уже распределено {allocated_non_default} шт.')
+ return redirect(next_url)
+
+ before = int(item.quantity or 0)
+ if before != int(qty):
+ item.quantity = int(qty)
+ item.save(update_fields=['quantity'])
+
+ _reconcile_default_delivery_batch(int(deal_id))
+ messages.success(request, f'Количество по сделке обновлено: {before} → {item.quantity}.')
+ return redirect(next_url)
+
+ messages.error(request, 'Неизвестное действие.')
+ return redirect(next_url)
except Exception as e:
messages.error(request, f'Ошибка: {e}')
-
- next_url = (request.POST.get('next') or '').strip()
- return redirect(next_url if next_url.startswith('/') else 'planning')
+ return redirect(next_url)
class DirectoriesView(LoginRequiredMixin, TemplateView):
@@ -4180,6 +4366,128 @@ class WarehouseReceiptCreateView(LoginRequiredMixin, View):
return redirect(next_url)
+class ShippingView(LoginRequiredMixin, TemplateView):
+ template_name = 'shiftflow/shipping.html'
+
+ def dispatch(self, request, *args, **kwargs):
+ profile = getattr(request.user, 'profile', None)
+ roles = get_user_roles(request.user)
+ self.role = primary_role(roles)
+ self.roles = roles
+ self.is_readonly = bool(getattr(profile, 'is_readonly', False)) if profile else False
+ self.can_edit = has_any_role(roles, ['admin', 'clerk', 'manager', 'prod_head', 'technologist']) and not self.is_readonly
+
+ if not has_any_role(roles, ['admin', 'clerk', 'manager', 'prod_head', 'director', 'technologist', 'observer']):
+ return redirect('registry')
+
+ return super().dispatch(request, *args, **kwargs)
+
+ def get_context_data(self, **kwargs):
+ ctx = super().get_context_data(**kwargs)
+ ctx['user_role'] = self.role
+ ctx['user_roles'] = sorted(self.roles)
+ ctx['can_edit'] = bool(self.can_edit)
+ ctx['is_readonly'] = bool(self.is_readonly)
+
+ deal_id_raw = (self.request.GET.get('deal_id') or '').strip()
+ deal_id = int(deal_id_raw) if deal_id_raw.isdigit() else None
+
+ shipping_loc, _ = Location.objects.get_or_create(
+ name='Склад отгруженных позиций',
+ defaults={'is_production_area': False},
+ )
+ ctx['shipping_location'] = shipping_loc
+
+ ctx['deals'] = list(Deal.objects.select_related('company').order_by('-id')[:300])
+
+ ctx['selected_deal_id'] = deal_id
+
+ ctx['entity_rows'] = []
+ ctx['material_rows'] = []
+
+ if deal_id:
+ from shiftflow.services.shipping import build_shipment_rows
+
+ entity_rows, material_rows = build_shipment_rows(
+ deal_id=int(deal_id),
+ shipping_location_id=int(shipping_loc.id),
+ )
+ ctx['entity_rows'] = entity_rows
+ ctx['material_rows'] = material_rows
+
+ return ctx
+
+ def post(self, request, *args, **kwargs):
+ if not self.can_edit:
+ messages.error(request, 'Доступ только для просмотра.')
+ return redirect('shipping')
+
+ deal_id_raw = (request.POST.get('deal_id') or '').strip()
+
+ if not deal_id_raw.isdigit():
+ messages.error(request, 'Выбери сделку.')
+ return redirect('shipping')
+
+ deal_id = int(deal_id_raw)
+
+ shipping_loc, _ = Location.objects.get_or_create(
+ name='Склад отгруженных позиций',
+ defaults={'is_production_area': False},
+ )
+
+ entity_qty: dict[int, int] = {}
+ material_qty: dict[int, float] = {}
+
+ for k, v in request.POST.items():
+ if not k or v is None:
+ continue
+ s = (str(v) or '').strip().replace(',', '.')
+
+ if k.startswith('ent_'):
+ ent_id_raw = k.replace('ent_', '').strip()
+ if not ent_id_raw.isdigit():
+ continue
+ try:
+ qty = int(float(s)) if s else 0
+ except ValueError:
+ qty = 0
+ if qty > 0:
+ entity_qty[int(ent_id_raw)] = int(qty)
+
+ if k.startswith('mat_'):
+ mat_id_raw = k.replace('mat_', '').strip()
+ if not mat_id_raw.isdigit():
+ continue
+ try:
+ qty_f = float(s) if s else 0.0
+ except ValueError:
+ qty_f = 0.0
+ if qty_f > 0:
+ material_qty[int(mat_id_raw)] = float(qty_f)
+
+ if not entity_qty and not material_qty:
+ messages.error(request, 'Укажи количество к отгрузке хотя бы по одной позиции.')
+ return redirect(f"{reverse_lazy('shipping')}?deal_id={deal_id}&from_location_id={from_location_id}")
+
+ from shiftflow.services.shipping import create_shipment_transfers
+
+ try:
+ ids = create_shipment_transfers(
+ deal_id=int(deal_id),
+ shipping_location_id=int(shipping_loc.id),
+ entity_qty=entity_qty,
+ material_qty=material_qty,
+ user_id=int(request.user.id),
+ )
+ msg = ', '.join([str(i) for i in ids])
+ messages.success(request, f'Отгрузка оформлена. Документы перемещения: {msg}.')
+ except Exception as e:
+ logger.exception('shipping:error deal_id=%s', deal_id)
+ messages.error(request, f'Ошибка отгрузки: {e}')
+
+ return redirect(f"{reverse_lazy('shipping')}?deal_id={deal_id}")
+
+
from shiftflow.services.assembly_closing import get_assembly_closing_info, apply_assembly_closing
class AssemblyClosingView(LoginRequiredMixin, TemplateView):
@@ -5137,9 +5445,8 @@ class ProductsView(LoginRequiredMixin, TemplateView):
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']:
+ roles = get_user_roles(request.user)
+ if not has_any_role(roles, ['admin', 'technologist']):
return redirect('products')
entity_type = (request.POST.get('entity_type') or '').strip()
@@ -5176,10 +5483,12 @@ class ProductDetailView(LoginRequiredMixin, TemplateView):
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')
+ roles = get_user_roles(self.request.user)
+ role = primary_role(roles)
ctx['user_role'] = role
- ctx['can_edit'] = role in ['admin', 'technologist']
- ctx['can_add_to_deal'] = role in ['admin', 'technologist']
+ ctx['user_roles'] = sorted(roles)
+ ctx['can_edit'] = has_any_role(roles, ['admin', 'technologist'])
+ ctx['can_add_to_deal'] = has_any_role(roles, ['admin', 'technologist'])
entity = get_object_or_404(ProductEntity.objects.select_related('planned_material'), pk=int(self.kwargs['pk']))
ctx['entity'] = entity
@@ -5377,9 +5686,8 @@ class ProductDetailView(LoginRequiredMixin, TemplateView):
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']:
+ roles = get_user_roles(request.user)
+ if not has_any_role(roles, ['admin', 'technologist', 'observer']):
return redirect('registry')
return super().dispatch(request, *args, **kwargs)
@@ -5415,6 +5723,7 @@ class ProductInfoView(LoginRequiredMixin, TemplateView):
.filter(entity_id=entity.id)
.order_by('seq', 'id')
)
+ ctx['selected_operation_ids'] = [int(x.operation_id) for x in ctx['entity_ops'] if getattr(x, 'operation_id', None)]
ctx['operations'] = list(Operation.objects.select_related('workshop').order_by('name'))
next_url = (self.request.GET.get('next') or '').strip()
@@ -5794,6 +6103,28 @@ class ProductInfoView(LoginRequiredMixin, TemplateView):
passport.notes = (request.POST.get('notes') or '').strip()
passport.save()
+ if 'operation_ids' in request.POST:
+ op_ids = [int(x) for x in request.POST.getlist('operation_ids') if str(x).isdigit()]
+ op_ids = list(dict.fromkeys(op_ids))
+ valid = set(Operation.objects.filter(id__in=op_ids).values_list('id', flat=True))
+ op_ids = [int(x) for x in op_ids if int(x) in valid]
+
+ EntityOperation.objects.filter(entity_id=entity.id).exclude(operation_id__in=op_ids).delete()
+
+ existing = list(EntityOperation.objects.filter(entity_id=entity.id, operation_id__in=op_ids).order_by('id'))
+ by_op = {int(eo.operation_id): eo for eo in existing}
+
+ if existing:
+ EntityOperation.objects.filter(id__in=[eo.id for eo in existing]).update(seq=0)
+
+ for i, op_id in enumerate(op_ids, start=1):
+ eo = by_op.get(int(op_id))
+ if eo:
+ if int(eo.seq or 0) != i:
+ EntityOperation.objects.filter(id=eo.id).update(seq=i)
+ else:
+ EntityOperation.objects.create(entity_id=entity.id, operation_id=int(op_id), seq=i)
+
messages.success(request, 'Сохранено.')
return redirect(stay_url)
diff --git a/templates/components/_navbar.html b/templates/components/_navbar.html
index fb33827..6a1071a 100644
--- a/templates/components/_navbar.html
+++ b/templates/components/_navbar.html
@@ -26,6 +26,12 @@
Сделки
+ {% if user_role in 'admin,clerk,manager,prod_head,director,observer,technologist' %}
+
+ Отгрузка
+
+ {% endif %}
+
{% if user_role in 'admin,technologist,master,clerk' %}
План