diff --git a/TODO.md b/TODO.md index 012e525..a22313d 100644 --- a/TODO.md +++ b/TODO.md @@ -4,4 +4,10 @@ - Доработать сортировку по дате «Поступление» (стабильно сортировать как datetime, а не как текст). - По клику на строку открывать карточку «Единица на складе» (read-only для observer, редактирование для admin/technologist/master/clerk): - правка: сделка, давальческий, размеры (лист/хлыст), количество, примечание (если добавим) - - отображение: история перемещений/приходов/отгрузок (если потребуется). \ No newline at end of file + - отображение: история перемещений/приходов/отгрузок (если потребуется). + +## Списание (UI) +- Доработать страницу «Списание»: фильтры, удобная сводка по материалам/изделиям и отметка «внесено в 1С». + +## Потребность (Материалы) +- Пересмотреть расчёт потребности: уйти от м²/мм, формировать пачки DXF по материалам/толщинам и прокат по длинам (для nesting/ручного расчёта). \ No newline at end of file diff --git a/manufacturing/migrations/0002_productentity_passport_filled_and_more.py b/manufacturing/migrations/0002_productentity_passport_filled_and_more.py new file mode 100644 index 0000000..a551167 --- /dev/null +++ b/manufacturing/migrations/0002_productentity_passport_filled_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 6.0.3 on 2026-04-07 04:39 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('manufacturing', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='productentity', + name='passport_filled', + field=models.BooleanField(default=False, verbose_name='Паспорт заполнен'), + ), + migrations.AlterField( + model_name='productentity', + name='entity_type', + field=models.CharField(choices=[('product', 'Готовое изделие'), ('assembly', 'Сборочная единица'), ('part', 'Деталь'), ('purchased', 'Покупное'), ('casting', 'Литьё'), ('outsourced', 'Аутсорс')], default='part', max_length=15, verbose_name='Тип'), + ), + ] diff --git a/manufacturing/migrations/0003_assemblypassport_weldingseam.py b/manufacturing/migrations/0003_assemblypassport_weldingseam.py new file mode 100644 index 0000000..7a55f51 --- /dev/null +++ b/manufacturing/migrations/0003_assemblypassport_weldingseam.py @@ -0,0 +1,45 @@ +# Generated by Django 6.0.3 on 2026-04-07 08:56 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('manufacturing', '0002_productentity_passport_filled_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='AssemblyPassport', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('weight_kg', models.FloatField(blank=True, null=True, verbose_name='Масса, кг')), + ('coating', models.CharField(blank=True, default='', max_length=200, verbose_name='Покрытие')), + ('coating_color', models.CharField(blank=True, default='', max_length=100, verbose_name='Цвет')), + ('coating_area_m2', models.FloatField(blank=True, null=True, verbose_name='Площадь покрытия, м²')), + ('technical_requirements', models.TextField(blank=True, default='', verbose_name='Технические требования')), + ('entity', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='assembly_passport', to='manufacturing.productentity')), + ], + options={ + 'verbose_name': 'Паспорт сборки/изделия', + 'verbose_name_plural': 'Паспорта сборок/изделий', + }, + ), + migrations.CreateModel( + name='WeldingSeam', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255, verbose_name='Наименование')), + ('leg_mm', models.FloatField(verbose_name='Катет, мм')), + ('length_mm', models.FloatField(verbose_name='Длина, мм')), + ('quantity', models.PositiveIntegerField(default=1, verbose_name='Кол-во')), + ('passport', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='welding_seams', to='manufacturing.assemblypassport')), + ], + options={ + 'verbose_name': 'Сварной шов', + 'verbose_name_plural': 'Сварные швы', + }, + ), + ] diff --git a/manufacturing/migrations/0004_castingpassport_outsourcedpassport_partpassport_and_more.py b/manufacturing/migrations/0004_castingpassport_outsourcedpassport_partpassport_and_more.py new file mode 100644 index 0000000..bd4314c --- /dev/null +++ b/manufacturing/migrations/0004_castingpassport_outsourcedpassport_partpassport_and_more.py @@ -0,0 +1,70 @@ +# Generated by Django 6.0.3 on 2026-04-07 09:07 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('manufacturing', '0003_assemblypassport_weldingseam'), + ] + + operations = [ + migrations.CreateModel( + name='CastingPassport', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('casting_material', models.CharField(blank=True, default='', max_length=200, verbose_name='Материал литья')), + ('mass_kg', models.FloatField(blank=True, null=True, verbose_name='Масса, кг')), + ('entity', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='casting_passport', to='manufacturing.productentity')), + ], + options={ + 'verbose_name': 'Паспорт литья', + 'verbose_name_plural': 'Паспорта литья', + }, + ), + migrations.CreateModel( + name='OutsourcedPassport', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('technical_requirements', models.TextField(blank=True, default='', verbose_name='Технические требования')), + ('notes', models.TextField(blank=True, default='', verbose_name='Пояснения')), + ('entity', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='outsourced_passport', to='manufacturing.productentity')), + ], + options={ + 'verbose_name': 'Паспорт аутсорса', + 'verbose_name_plural': 'Паспорта аутсорса', + }, + ), + migrations.CreateModel( + name='PartPassport', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('thickness_mm', models.FloatField(blank=True, null=True, verbose_name='Толщина, мм')), + ('length_mm', models.FloatField(blank=True, null=True, verbose_name='Длина, мм')), + ('mass_kg', models.FloatField(blank=True, null=True, verbose_name='Масса, кг')), + ('cut_length_mm', models.FloatField(blank=True, null=True, verbose_name='Длина реза, мм')), + ('pierce_count', models.PositiveIntegerField(blank=True, null=True, verbose_name='Кол-во врезок')), + ('engraving', models.TextField(blank=True, default='', verbose_name='Гравировка')), + ('technical_requirements', models.TextField(blank=True, default='', verbose_name='Технические требования')), + ('entity', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='part_passport', to='manufacturing.productentity')), + ], + options={ + 'verbose_name': 'Паспорт детали', + 'verbose_name_plural': 'Паспорта деталей', + }, + ), + migrations.CreateModel( + name='PurchasedPassport', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('gost', models.CharField(blank=True, default='', max_length=255, verbose_name='ГОСТ/ТУ')), + ('entity', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='purchased_passport', to='manufacturing.productentity')), + ], + options={ + 'verbose_name': 'Паспорт покупного', + 'verbose_name_plural': 'Паспорта покупного', + }, + ), + ] diff --git a/manufacturing/models.py b/manufacturing/models.py index d328228..2c06139 100644 --- a/manufacturing/models.py +++ b/manufacturing/models.py @@ -32,6 +32,9 @@ class ProductEntity(models.Model): ('product', 'Готовое изделие'), ('assembly', 'Сборочная единица'), ('part', 'Деталь'), + ('purchased', 'Покупное'), + ('casting', 'Литьё'), + ('outsourced', 'Аутсорс'), ] name = models.CharField("Наименование", max_length=255) @@ -60,6 +63,8 @@ class ProductEntity(models.Model): pdf_main = models.FileField("Чертёж (PDF)", upload_to="drawings_pdf/%Y/%m/", blank=True, null=True) preview = models.ImageField("Превью", upload_to="previews/%Y/%m/", blank=True, null=True) + passport_filled = models.BooleanField('Паспорт заполнен', default=False) + class Meta: verbose_name = "КД (изделие/деталь)" verbose_name_plural = "КД (изделия/детали)" @@ -94,4 +99,98 @@ class BOM(models.Model): def __str__(self): return f"{self.parent} -> {self.child} x{self.quantity}" -# Create your models here. + +class AssemblyPassport(models.Model): + entity = models.OneToOneField(ProductEntity, on_delete=models.CASCADE, related_name='assembly_passport') + + weight_kg = models.FloatField('Масса, кг', null=True, blank=True) + coating = models.CharField('Покрытие', max_length=200, blank=True, default='') + coating_color = models.CharField('Цвет', max_length=100, blank=True, default='') + coating_area_m2 = models.FloatField('Площадь покрытия, м²', null=True, blank=True) + + technical_requirements = models.TextField('Технические требования', blank=True, default='') + + class Meta: + verbose_name = 'Паспорт сборки/изделия' + verbose_name_plural = 'Паспорта сборок/изделий' + + def __str__(self): + return str(self.entity) + + +class WeldingSeam(models.Model): + passport = models.ForeignKey(AssemblyPassport, related_name='welding_seams', on_delete=models.CASCADE) + + name = models.CharField('Наименование', max_length=255) + leg_mm = models.FloatField('Катет, мм') + length_mm = models.FloatField('Длина, мм') + quantity = models.PositiveIntegerField('Кол-во', default=1) + + class Meta: + verbose_name = 'Сварной шов' + verbose_name_plural = 'Сварные швы' + + def __str__(self): + return f"{self.name}" + + +class PartPassport(models.Model): + entity = models.OneToOneField(ProductEntity, on_delete=models.CASCADE, related_name='part_passport') + + thickness_mm = models.FloatField('Толщина, мм', null=True, blank=True) + length_mm = models.FloatField('Длина, мм', null=True, blank=True) + mass_kg = models.FloatField('Масса, кг', null=True, blank=True) + + cut_length_mm = models.FloatField('Длина реза, мм', null=True, blank=True) + pierce_count = models.PositiveIntegerField('Кол-во врезок', null=True, blank=True) + engraving = models.TextField('Гравировка', blank=True, default='') + + technical_requirements = models.TextField('Технические требования', blank=True, default='') + + class Meta: + verbose_name = 'Паспорт детали' + verbose_name_plural = 'Паспорта деталей' + + def __str__(self): + return str(self.entity) + + +class PurchasedPassport(models.Model): + entity = models.OneToOneField(ProductEntity, on_delete=models.CASCADE, related_name='purchased_passport') + + gost = models.CharField('ГОСТ/ТУ', max_length=255, blank=True, default='') + + class Meta: + verbose_name = 'Паспорт покупного' + verbose_name_plural = 'Паспорта покупного' + + def __str__(self): + return str(self.entity) + + +class CastingPassport(models.Model): + entity = models.OneToOneField(ProductEntity, on_delete=models.CASCADE, related_name='casting_passport') + + casting_material = models.CharField('Материал литья', max_length=200, blank=True, default='') + mass_kg = models.FloatField('Масса, кг', null=True, blank=True) + + class Meta: + verbose_name = 'Паспорт литья' + verbose_name_plural = 'Паспорта литья' + + def __str__(self): + return str(self.entity) + + +class OutsourcedPassport(models.Model): + entity = models.OneToOneField(ProductEntity, on_delete=models.CASCADE, related_name='outsourced_passport') + + technical_requirements = models.TextField('Технические требования', blank=True, default='') + notes = models.TextField('Пояснения', blank=True, default='') + + class Meta: + verbose_name = 'Паспорт аутсорса' + verbose_name_plural = 'Паспорта аутсорса' + + def __str__(self): + return str(self.entity) diff --git a/shiftflow/templates/shiftflow/product_detail.html b/shiftflow/templates/shiftflow/product_detail.html new file mode 100644 index 0000000..90dbb3d --- /dev/null +++ b/shiftflow/templates/shiftflow/product_detail.html @@ -0,0 +1,283 @@ +{% extends 'base.html' %} + +{% block content %} +
+
+
+

{{ entity }}

+
{{ entity.get_entity_type_display }}
+
+ +
+ {% if can_edit %} + + {% endif %} + {% if parent_id %} + Назад + {% else %} + Назад + {% endif %} +
+
+ +
+ + + + + + + + + + + + + {% for ln in lines %} + + + + + + + + + {% empty %} + + {% endfor %} + +
ТипОбозначениеНаименованиеЗаполненКол-во
{{ ln.child.get_entity_type_display }}{{ ln.child.drawing_number|default:"—" }}{{ ln.child.name }} + {% if ln.child.passport_filled %} + + {% else %} + + {% endif %} + +
+ {% csrf_token %} + {% if parent_id %}{% endif %} + + + + +
+
+
+ Состав + +
+ {% csrf_token %} + {% if parent_id %}{% endif %} + + + +
+
+
Пока нет компонентов
+
+
+ + + + + + + +{% if request.GET.open == '1' %} + +{% endif %} + + +{% endblock %} \ No newline at end of file diff --git a/shiftflow/templates/shiftflow/product_info_assembly.html b/shiftflow/templates/shiftflow/product_info_assembly.html new file mode 100644 index 0000000..65e0a8f --- /dev/null +++ b/shiftflow/templates/shiftflow/product_info_assembly.html @@ -0,0 +1,160 @@ +
+
+ {% csrf_token %} + + + +
+
+ + +
+ +
+ + +
+ +
+ +
+ + +
+
+ +
+ + +
+ +
+ + + {% if entity.pdf_main %} + + {% endif %} +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ {% if can_edit %} + + {% endif %} +
+
+
+ + {% if can_edit %} +
+ {% csrf_token %} + + + + +
+ {% endif %} + +
+
Сварные швы
+ +
+ + + + + + + + + + + + {% for s in welding_seams %} + + + + + + + + {% empty %} + + {% endfor %} + +
НаименованиеКатет, ммДлина, ммКол-во
{{ s.name }}{{ s.leg_mm }}{{ s.length_mm }}{{ s.quantity }} + {% if can_edit %} +
+ {% csrf_token %} + + + + +
+ {% endif %} +
Швы не добавлены
+
+ + {% if can_edit %} +
+ {% csrf_token %} + + + +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ {% endif %} +
+
\ No newline at end of file diff --git a/shiftflow/templates/shiftflow/product_info_casting.html b/shiftflow/templates/shiftflow/product_info_casting.html new file mode 100644 index 0000000..653b545 --- /dev/null +++ b/shiftflow/templates/shiftflow/product_info_casting.html @@ -0,0 +1,84 @@ +
+
+ {% csrf_token %} + + + +
+
+ + +
+ +
+ + +
+ +
+ +
+ + +
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + + {% if entity.pdf_main %} + + {% endif %} +
+ +
+ + + {% if entity.preview %} + + {% endif %} +
+ +
+ {% if can_edit %} + + {% endif %} +
+
+
+ + {% if can_edit %} +
+ {% csrf_token %} + + + + +
+ {% endif %} +
\ No newline at end of file diff --git a/shiftflow/templates/shiftflow/product_info_external.html b/shiftflow/templates/shiftflow/product_info_external.html new file mode 100644 index 0000000..240daeb --- /dev/null +++ b/shiftflow/templates/shiftflow/product_info_external.html @@ -0,0 +1,36 @@ +
+ {% csrf_token %} + + + +
+
+ + +
+ +
+ + +
+ +
+ +
+ + +
+
+ +
+ + +
+ +
+ {% if can_edit %} + + {% endif %} +
+
+
\ No newline at end of file diff --git a/shiftflow/templates/shiftflow/product_info_outsourced.html b/shiftflow/templates/shiftflow/product_info_outsourced.html new file mode 100644 index 0000000..2b114ea --- /dev/null +++ b/shiftflow/templates/shiftflow/product_info_outsourced.html @@ -0,0 +1,76 @@ +
+
+ {% csrf_token %} + + + +
+
+ + +
+ +
+ + +
+ +
+ +
+ + +
+
+ +
+ + +
+ +
+ + +
+ +
+ + + {% if entity.pdf_main %} + + {% endif %} +
+ +
+ + +
+ +
+ + +
+ +
+ {% if can_edit %} + + {% endif %} +
+
+
+ + {% if can_edit %} +
+ {% csrf_token %} + + + + +
+ {% endif %} +
\ No newline at end of file diff --git a/shiftflow/templates/shiftflow/product_info_part.html b/shiftflow/templates/shiftflow/product_info_part.html new file mode 100644 index 0000000..38807f8 --- /dev/null +++ b/shiftflow/templates/shiftflow/product_info_part.html @@ -0,0 +1,127 @@ +
+
+ {% csrf_token %} + + + +
+
+ + +
+ +
+ + +
+ +
+ +
+ + +
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + + {% if entity.pdf_main %} + + {% endif %} +
+ +
+ + + {% if entity.dxf_file %} + + {% endif %} +
+ +
+ + + {% if entity.preview %} + + {% endif %} +
+ +
+ + +
+ +
+ + +
+ +
+ {% if can_edit %} + + {% endif %} +
+
+
+ + {% if can_edit %} +
+ {% csrf_token %} + + + + +
+ {% endif %} +
\ No newline at end of file diff --git a/shiftflow/templates/shiftflow/product_info_purchased.html b/shiftflow/templates/shiftflow/product_info_purchased.html new file mode 100644 index 0000000..74752d1 --- /dev/null +++ b/shiftflow/templates/shiftflow/product_info_purchased.html @@ -0,0 +1,79 @@ +
+
+ {% csrf_token %} + + + +
+
+ + +
+ +
+ + +
+ +
+ +
+ + +
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + + {% if entity.pdf_main %} + + {% endif %} +
+ +
+ + + {% if entity.preview %} + + {% endif %} +
+ +
+ {% if can_edit %} + + {% endif %} +
+
+
+ + {% if can_edit %} +
+ {% csrf_token %} + + + + +
+ {% endif %} +
\ No newline at end of file diff --git a/shiftflow/templates/shiftflow/products.html b/shiftflow/templates/shiftflow/products.html new file mode 100644 index 0000000..e39b508 --- /dev/null +++ b/shiftflow/templates/shiftflow/products.html @@ -0,0 +1,162 @@ +{% extends 'base.html' %} + +{% block content %} +
+
+

Изделия

+ +
+ {% if can_edit %} + + {% endif %} + +
+ + + + + Сброс +
+
+
+ +
+ + + + + + + + + + + + + {% for p in products %} + + + + + + + + + {% empty %} + + {% endfor %} + +
ТипОбозначениеНаименованиеМатериалЗаполнен
{{ p.get_entity_type_display }}{{ p.drawing_number|default:"—" }}{{ p.name }} + {% if p.planned_material_id %} + {{ p.planned_material.full_name|default:p.planned_material.name }} + {% else %} + — + {% endif %} + + {% if p.passport_filled %} + + {% else %} + + {% endif %} + + + {% if p.entity_type == 'product' or p.entity_type == 'assembly' %}Состав{% else %}Открыть{% endif %} + +
Пока ничего нет
+
+
+ + + + + + +{% endblock %} \ No newline at end of file diff --git a/shiftflow/urls.py b/shiftflow/urls.py index 2233e69..cfaccc6 100644 --- a/shiftflow/urls.py +++ b/shiftflow/urls.py @@ -21,6 +21,9 @@ from .views import ( SteelGradeUpsertView, TaskItemsView, ClosingView, + ProductDetailView, + ProductInfoView, + ProductsView, WriteOffsView, WarehouseReceiptCreateView, WarehouseStocksView, @@ -60,4 +63,8 @@ urlpatterns = [ path('closing/', ClosingView.as_view(), name='closing'), path('writeoffs/', WriteOffsView.as_view(), name='writeoffs'), + + path('products/', ProductsView.as_view(), name='products'), + path('products//', ProductDetailView.as_view(), name='product_detail'), + path('products//info/', ProductInfoView.as_view(), name='product_info'), ] \ No newline at end of file diff --git a/shiftflow/views.py b/shiftflow/views.py index 38c62de..7f1127e 100644 --- a/shiftflow/views.py +++ b/shiftflow/views.py @@ -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' diff --git a/templates/components/_navbar.html b/templates/components/_navbar.html index a6896fb..3d3007b 100644 --- a/templates/components/_navbar.html +++ b/templates/components/_navbar.html @@ -27,6 +27,12 @@ {% endif %} + {% if user_role in 'admin,technologist,observer' %} + + {% endif %} + {% if user_role in 'admin,master,operator,observer' %}