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 %}
+
+
+
+
+
+
+
+
+
+ {% for ln in lines %}
+
+ {{ ln.child.get_entity_type_display }}
+ {{ ln.child.drawing_number|default:"—" }}
+ {{ ln.child.name }}
+
+ {% if ln.child.passport_filled %}
+
+ {% else %}
+
+ {% endif %}
+
+
+
+
+
+
+
+
+ {% empty %}
+ Пока нет компонентов
+ {% endfor %}
+
+
+
+
+
+
+
+
+
+
+
+
+
Найти существующее (обозначение / наименование)
+
+
+
+
+ {% if searched %}
+ {% if found %}
+
+
Найдено:
+
{{ found.get_entity_type_display }} | {{ found.drawing_number }} {{ found.name }}
+
+
+
+ {% else %}
+
Не найдено. Можно создать новое ниже.
+ {% endif %}
+ {% else %}
+
Введи обозначение и/или наименование и нажми «Поиск».
+ {% 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 @@
+
+
+
+ {% if can_edit %}
+
+ {% csrf_token %}
+
+
+
+ Добавить маршрут
+
+ {% endif %}
+
+
+
Сварные швы
+
+
+
+
+
+
+
+ {% for s in welding_seams %}
+
+ {{ s.name }}
+ {{ s.leg_mm }}
+ {{ s.length_mm }}
+ {{ s.quantity }}
+
+ {% if can_edit %}
+
+ {% csrf_token %}
+
+
+
+ Удалить
+
+ {% endif %}
+
+
+ {% empty %}
+ Швы не добавлены
+ {% endfor %}
+
+
+
+
+ {% 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 %}
+
+
+
+
+
+ Тип
+
+
+
+
+ Обозначение
+
+
+
+
+
Заполнен
+
+
+ Заполнено
+
+
+
+
+ Наименование
+
+
+
+
+ Материал литья
+
+
+
+
+ Масса, кг
+
+
+
+
+ Маршрут
+
+ — не указано —
+ {% for r in routes %}
+ {{ r.name }}
+ {% endfor %}
+
+
+
+
+
Чертёж (PDF)
+
+ {% 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 %}
+
+
+
+
+
+ Тип
+
+
+
+
+ Обозначение
+
+
+
+
+
Заполнен
+
+
+ Заполнено
+
+
+
+
+ Наименование
+
+
+
+
+ Маршрут
+
+ — не указано —
+ {% for r in routes %}
+ {{ r.name }}
+ {% endfor %}
+
+
+
+
+
Чертёж/ТЗ (PDF)
+
+ {% if entity.pdf_main %}
+
+ {% endif %}
+
+
+
+ Технические требования
+ {% if passport %}{{ passport.technical_requirements }}{% endif %}
+
+
+
+ Пояснения
+ {% if passport %}{{ passport.notes }}{% 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 %}
+
+
+
+
+
+ Тип
+
+
+
+
+ Обозначение
+
+
+
+
+
Заполнен
+
+
+ Заполнено
+
+
+
+
+ Наименование
+
+
+
+
+ Материал заготовки
+
+ — не указано —
+ {% for m in materials %}
+ {{ m.full_name|default:m.name }}
+ {% endfor %}
+
+
+
+
+ Маршрут
+
+ — не указано —
+ {% for r in routes %}
+ {{ r.name }}
+ {% endfor %}
+
+
+
+
+ Толщина, мм
+
+
+
+
+ Длина, мм
+
+
+
+
+ Масса, кг
+
+
+
+
+ Длина реза, мм
+
+
+
+
+ Кол-во врезок
+
+
+
+
+
Чертёж (PDF)
+
+ {% if entity.pdf_main %}
+
+ {% endif %}
+
+
+
+
DXF/IGES/STEP
+
+ {% if entity.dxf_file %}
+
+ {% endif %}
+
+
+
+
Картинка
+
+ {% if entity.preview %}
+
+ {% endif %}
+
+
+
+ Гравировка
+ {% if passport %}{{ passport.engraving }}{% endif %}
+
+
+
+ Технические требования
+ {% if passport %}{{ passport.technical_requirements }}{% 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 %}
+
+
+
+
+
+ Тип
+
+
+
+
+ Обозначение
+
+
+
+
+
Заполнен
+
+
+ Заполнено
+
+
+
+
+ Наименование
+
+
+
+
+ ГОСТ/ТУ
+
+
+
+
+ Маршрут
+
+ — не указано —
+ {% for r in routes %}
+ {{ r.name }}
+ {% endfor %}
+
+
+
+
+
Чертёж/паспорт (PDF)
+
+ {% 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 %}
+
+
+
+
+
+ {% csrf_token %}
+
+
+
+
+
+ Тип
+
+ Изделие
+ Сборочная единица
+
+
+
+
+ Обозначение
+
+
+
+
+ Наименование
+
+
+
+
+
+
+
+
+
+
+
+
+
+{% 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' %}
Закрытие