ДОбавил изделия и заполнение спецификции изделия
All checks were successful
Deploy MES Core / deploy (push) Successful in 3m27s

This commit is contained in:
2026-04-07 12:09:46 +03:00
parent eb708a3ab7
commit a238c83b04
16 changed files with 1722 additions and 3 deletions

View File

@@ -24,7 +24,17 @@ from django.views.generic import FormView, ListView, TemplateView, UpdateView
from django.contrib.auth.mixins import LoginRequiredMixin
from django.utils import timezone
from manufacturing.models import ProductEntity
from manufacturing.models import (
AssemblyPassport,
BOM,
CastingPassport,
OutsourcedPassport,
PartPassport,
ProductEntity,
PurchasedPassport,
RouteStub,
WeldingSeam,
)
from warehouse.models import Location, Material, MaterialCategory, SteelGrade, StockItem, TransferLine, TransferRecord
from warehouse.services.transfers import receive_transfer
@@ -1800,6 +1810,452 @@ class ClosingView(LoginRequiredMixin, TemplateView):
return redirect(f"{reverse_lazy('closing')}?machine_id={machine_id}&material_id={material_id}")
class ProductsView(LoginRequiredMixin, TemplateView):
template_name = 'shiftflow/products.html'
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']:
return redirect('registry')
return super().dispatch(request, *args, **kwargs)
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')
ctx['user_role'] = role
ctx['can_edit'] = role in ['admin', 'technologist']
q = (self.request.GET.get('q') or '').strip()
entity_type = (self.request.GET.get('entity_type') or '').strip()
qs = ProductEntity.objects.select_related('planned_material', 'route').all()
if entity_type:
qs = qs.filter(entity_type=entity_type)
if q:
qs = qs.filter(
Q(drawing_number__icontains=q)
| Q(name__icontains=q)
| Q(planned_material__name__icontains=q)
| Q(planned_material__full_name__icontains=q)
)
ctx['q'] = q
ctx['entity_type'] = entity_type
ctx['products'] = qs.order_by('drawing_number', 'name')
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']:
return redirect('products')
entity_type = (request.POST.get('entity_type') or '').strip()
name = (request.POST.get('name') or '').strip()
drawing_number = (request.POST.get('drawing_number') or '').strip()
if entity_type not in ['product', 'assembly']:
messages.error(request, 'Выбери тип: изделие или сборочная единица.')
return redirect('products')
if not name:
messages.error(request, 'Заполни наименование.')
return redirect('products')
obj = ProductEntity.objects.create(
entity_type=entity_type,
name=name[:255],
drawing_number=drawing_number[:100],
)
return redirect('product_detail', pk=obj.id)
class ProductDetailView(LoginRequiredMixin, TemplateView):
template_name = 'shiftflow/product_detail.html'
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']:
return redirect('registry')
return super().dispatch(request, *args, **kwargs)
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')
ctx['user_role'] = role
ctx['can_edit'] = role in ['admin', 'technologist']
entity = get_object_or_404(ProductEntity.objects.select_related('planned_material', 'route'), pk=int(self.kwargs['pk']))
ctx['entity'] = entity
lines = list(
BOM.objects.select_related('child', 'child__planned_material', 'child__route')
.filter(parent=entity)
.order_by('child__entity_type', 'child__drawing_number', 'child__name', 'id')
)
ctx['lines'] = lines
q = (self.request.GET.get('q') or '').strip()
ctx['q'] = q
candidates = ProductEntity.objects.select_related('planned_material').exclude(id=entity.id)
if q:
candidates = candidates.filter(Q(drawing_number__icontains=q) | Q(name__icontains=q))
ctx['candidates'] = list(candidates.order_by('drawing_number', 'name')[:200])
parent_id = (self.request.GET.get('parent') or '').strip()
ctx['parent_id'] = parent_id if parent_id.isdigit() else ''
q_dn = (self.request.GET.get('q_dn') or '').strip()
q_name = (self.request.GET.get('q_name') or '').strip()
ctx['q_dn'] = q_dn
ctx['q_name'] = q_name
found = None
searched = False
if q_dn or q_name:
searched = True
candidates_qs = ProductEntity.objects.exclude(id=entity.id)
if q_dn:
candidates_qs = candidates_qs.filter(drawing_number__icontains=q_dn)
if q_name:
candidates_qs = candidates_qs.filter(name__icontains=q_name)
found = candidates_qs.order_by('drawing_number', 'name', 'id').first()
ctx['searched'] = searched
ctx['found'] = found
ctx['materials'] = list(Material.objects.select_related('category').order_by('full_name'))
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']:
return redirect('product_detail', pk=self.kwargs['pk'])
entity = get_object_or_404(ProductEntity, pk=int(self.kwargs['pk']))
action = (request.POST.get('action') or '').strip()
parent_id = (request.POST.get('parent') or '').strip()
next_url = reverse_lazy('product_detail', kwargs={'pk': entity.id})
if parent_id.isdigit():
next_url = f"{next_url}?parent={parent_id}"
def parse_int(value, default=None):
s = (value or '').strip()
if not s.isdigit():
return default
return int(s)
def parse_qty(value):
v = parse_int(value, default=0)
return v if v and v > 0 else None
def would_cycle(parent_id: int, child_id: int) -> bool:
stack = [child_id]
seen = set()
while stack:
cur = stack.pop()
if cur == parent_id:
return True
if cur in seen:
continue
seen.add(cur)
stack.extend(list(BOM.objects.filter(parent_id=cur).values_list('child_id', flat=True)))
return False
if action == 'delete_line':
bom_id = parse_int(request.POST.get('bom_id'))
if not bom_id:
messages.error(request, 'Не выбрана строка BOM.')
return redirect(next_url)
BOM.objects.filter(id=bom_id, parent_id=entity.id).delete()
messages.success(request, 'Строка удалена.')
return redirect(next_url)
if action == 'update_qty':
bom_id = parse_int(request.POST.get('bom_id'))
qty = parse_qty(request.POST.get('quantity'))
if not bom_id or not qty:
messages.error(request, 'Заполни количество.')
return redirect('product_detail', pk=entity.id)
BOM.objects.filter(id=bom_id, parent_id=entity.id).update(quantity=qty)
messages.success(request, 'Количество обновлено.')
return redirect(next_url)
if action == 'add_existing':
child_id = parse_int(request.POST.get('child_id'))
qty = parse_qty(request.POST.get('quantity'))
if not child_id or not qty:
messages.error(request, 'Выбери существующую сущность и количество.')
return redirect('product_detail', pk=entity.id)
if child_id == entity.id:
messages.error(request, 'Нельзя добавить узел сам в себя.')
return redirect('product_detail', pk=entity.id)
if would_cycle(entity.id, child_id):
messages.error(request, 'Нельзя добавить: получится цикл в структуре.')
return redirect('product_detail', pk=entity.id)
obj, _ = BOM.objects.get_or_create(parent_id=entity.id, child_id=child_id, defaults={'quantity': qty})
if obj.quantity != qty:
obj.quantity = qty
obj.save(update_fields=['quantity'])
messages.success(request, 'Компонент добавлен.')
return redirect(next_url)
if action == 'create_and_add':
child_type = (request.POST.get('child_type') or '').strip()
name = (request.POST.get('name') or '').strip()
drawing_number = (request.POST.get('drawing_number') or '').strip()
qty = parse_qty(request.POST.get('quantity'))
planned_material_id = parse_int(request.POST.get('planned_material_id'))
if child_type not in ['assembly', 'part', 'purchased', 'casting', 'outsourced']:
messages.error(request, 'Выбери корректный тип компонента.')
return redirect('product_detail', pk=entity.id)
if not name:
messages.error(request, 'Заполни наименование компонента.')
return redirect('product_detail', pk=entity.id)
if not qty:
messages.error(request, 'Заполни количество.')
return redirect('product_detail', pk=entity.id)
child = ProductEntity.objects.create(
entity_type=child_type,
name=name[:255],
drawing_number=drawing_number[:100],
planned_material_id=(planned_material_id if child_type == 'part' and planned_material_id else None),
)
BOM.objects.create(parent_id=entity.id, child_id=child.id, quantity=qty)
messages.success(request, 'Компонент создан и добавлен.')
return redirect(next_url)
messages.error(request, 'Неизвестное действие.')
return redirect(next_url)
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']:
return redirect('registry')
return super().dispatch(request, *args, **kwargs)
def get_template_names(self):
entity = get_object_or_404(ProductEntity, pk=int(self.kwargs['pk']))
et = entity.entity_type
if et == 'part':
return ['shiftflow/product_info_part.html']
if et in ['product', 'assembly']:
return ['shiftflow/product_info_assembly.html']
if et == 'purchased':
return ['shiftflow/product_info_purchased.html']
if et == 'casting':
return ['shiftflow/product_info_casting.html']
if et == 'outsourced':
return ['shiftflow/product_info_outsourced.html']
return ['shiftflow/product_info_external.html']
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')
ctx['user_role'] = role
ctx['can_edit'] = role in ['admin', 'technologist']
entity = get_object_or_404(ProductEntity.objects.select_related('planned_material', 'route'), pk=int(self.kwargs['pk']))
ctx['entity'] = entity
ctx['materials'] = list(Material.objects.select_related('category').order_by('full_name'))
ctx['routes'] = list(RouteStub.objects.all().order_by('name'))
passport = None
seams = []
if entity.entity_type in ['product', 'assembly']:
passport, _ = AssemblyPassport.objects.get_or_create(entity_id=entity.id)
seams = list(WeldingSeam.objects.filter(passport_id=passport.id).order_by('id'))
elif entity.entity_type == 'part':
passport, _ = PartPassport.objects.get_or_create(entity_id=entity.id)
elif entity.entity_type == 'purchased':
passport, _ = PurchasedPassport.objects.get_or_create(entity_id=entity.id)
elif entity.entity_type == 'casting':
passport, _ = CastingPassport.objects.get_or_create(entity_id=entity.id)
elif entity.entity_type == 'outsourced':
passport, _ = OutsourcedPassport.objects.get_or_create(entity_id=entity.id)
ctx['passport'] = passport
ctx['welding_seams'] = seams
next_url = (self.request.GET.get('next') or '').strip()
ctx['next'] = next_url if next_url.startswith('/') else reverse_lazy('products')
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']:
return redirect('products')
entity = get_object_or_404(ProductEntity, pk=int(self.kwargs['pk']))
action = (request.POST.get('action') or '').strip()
next_url = (request.POST.get('next') or '').strip()
if not next_url.startswith('/'):
next_url = reverse_lazy('products')
def parse_int(value, default=None):
s = (value or '').strip()
if not s.isdigit():
return default
return int(s)
def parse_float(value):
s = (value or '').strip().replace(',', '.')
if not s:
return None
try:
return float(s)
except ValueError:
return None
if action == 'create_route':
name = (request.POST.get('route_name') or '').strip()
if not name:
messages.error(request, 'Заполни название маршрута.')
return redirect(next_url)
RouteStub.objects.get_or_create(name=name[:200])
messages.success(request, 'Маршрут добавлен.')
return redirect(next_url)
if action == 'add_weld_seam':
passport, _ = AssemblyPassport.objects.get_or_create(entity_id=entity.id)
name = (request.POST.get('seam_name') or '').strip()
leg_mm = parse_float(request.POST.get('seam_leg_mm'))
length_mm = parse_float(request.POST.get('seam_length_mm'))
qty = parse_int(request.POST.get('seam_quantity'), default=1)
if not name:
messages.error(request, 'Заполни наименование сварного шва.')
return redirect(next_url)
if leg_mm is None or leg_mm <= 0:
messages.error(request, 'Катет должен быть больше 0.')
return redirect(next_url)
if length_mm is None or length_mm <= 0:
messages.error(request, 'Длина должна быть больше 0.')
return redirect(next_url)
if not qty or qty <= 0:
messages.error(request, 'Количество должно быть больше 0.')
return redirect(next_url)
WeldingSeam.objects.create(
passport_id=passport.id,
name=name[:255],
leg_mm=float(leg_mm),
length_mm=float(length_mm),
quantity=int(qty),
)
messages.success(request, 'Сварной шов добавлен.')
return redirect(next_url)
if action == 'delete_weld_seam':
seam_id = parse_int(request.POST.get('seam_id'))
passport = AssemblyPassport.objects.filter(entity_id=entity.id).first()
if not seam_id or not passport:
messages.error(request, 'Не выбран сварной шов.')
return redirect(next_url)
WeldingSeam.objects.filter(id=seam_id, passport_id=passport.id).delete()
messages.success(request, 'Сварной шов удалён.')
return redirect(next_url)
if action != 'save':
messages.error(request, 'Неизвестное действие.')
return redirect(next_url)
entity.drawing_number = (request.POST.get('drawing_number') or '').strip()[:100]
entity.name = (request.POST.get('name') or '').strip()[:255]
if not entity.name:
messages.error(request, 'Наименование обязательно.')
return redirect(next_url)
entity.passport_filled = bool(request.POST.get('passport_filled'))
route_id = parse_int(request.POST.get('route_id'))
entity.route_id = route_id
if entity.entity_type == 'part':
pm_id = parse_int(request.POST.get('planned_material_id'))
entity.planned_material_id = pm_id
pdf = request.FILES.get('pdf_main')
if pdf:
entity.pdf_main = pdf
dxf = request.FILES.get('dxf_file')
if dxf:
entity.dxf_file = dxf
preview = request.FILES.get('preview')
if preview:
entity.preview = preview
entity.save()
if entity.entity_type in ['product', 'assembly']:
passport, _ = AssemblyPassport.objects.get_or_create(entity_id=entity.id)
passport.weight_kg = parse_float(request.POST.get('weight_kg'))
passport.coating = (request.POST.get('coating') or '').strip()[:200]
passport.coating_color = (request.POST.get('coating_color') or '').strip()[:100]
passport.coating_area_m2 = parse_float(request.POST.get('coating_area_m2'))
passport.technical_requirements = (request.POST.get('technical_requirements') or '').strip()
passport.save()
if entity.entity_type == 'part':
passport, _ = PartPassport.objects.get_or_create(entity_id=entity.id)
passport.thickness_mm = parse_float(request.POST.get('thickness_mm'))
passport.length_mm = parse_float(request.POST.get('length_mm'))
passport.mass_kg = parse_float(request.POST.get('mass_kg'))
passport.cut_length_mm = parse_float(request.POST.get('cut_length_mm'))
passport.pierce_count = parse_int(request.POST.get('pierce_count'))
passport.engraving = (request.POST.get('engraving') or '').strip()
passport.technical_requirements = (request.POST.get('technical_requirements') or '').strip()
passport.save()
if entity.entity_type == 'purchased':
passport, _ = PurchasedPassport.objects.get_or_create(entity_id=entity.id)
passport.gost = (request.POST.get('gost') or '').strip()[:255]
passport.save()
if entity.entity_type == 'casting':
passport, _ = CastingPassport.objects.get_or_create(entity_id=entity.id)
passport.casting_material = (request.POST.get('casting_material') or '').strip()[:200]
passport.mass_kg = parse_float(request.POST.get('mass_kg'))
passport.save()
if entity.entity_type == 'outsourced':
passport, _ = OutsourcedPassport.objects.get_or_create(entity_id=entity.id)
passport.technical_requirements = (request.POST.get('technical_requirements') or '').strip()
passport.notes = (request.POST.get('notes') or '').strip()
passport.save()
messages.success(request, 'Сохранено.')
return redirect(next_url)
class WriteOffsView(LoginRequiredMixin, TemplateView):
template_name = 'shiftflow/writeoffs.html'