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