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

This commit is contained in:
2026-04-14 07:27:54 +03:00
parent 69edd3fa97
commit 49e9080d0e
14 changed files with 2056 additions and 564 deletions

View File

@@ -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)