diff --git a/.env b/.env index e9312c9..81eadf2 100644 --- a/.env +++ b/.env @@ -4,8 +4,6 @@ DB_USER=prodman_user DB_PASS=prodman_password_zwE45t! # Настройки Django -# ENV_TYPE=dev -# ENV_TYPE=server DB_HOST=db SECRET_KEY='django-insecure-9p*t_(vtwo144=u)ie8qzb31a8%i(&1w4$0vq#udautexj^vms' # todo потом установить домен для продакшена diff --git a/core/settings.py b/core/settings.py index 2320a87..3eb273a 100644 --- a/core/settings.py +++ b/core/settings.py @@ -63,6 +63,7 @@ INSTALLED_APPS = [ 'django.contrib.messages', 'django.contrib.staticfiles', 'shiftflow', # Вот это допиши обязательно! + 'warehouse', ] MIDDLEWARE = [ diff --git a/core/urls.py b/core/urls.py index 401dc10..64b7860 100644 --- a/core/urls.py +++ b/core/urls.py @@ -16,10 +16,13 @@ Including another URLconf """ from django.contrib import admin from django.urls import path, include +from django.views.generic import RedirectView +from django.templatetags.static import static as static_url from django.conf.urls.static import static # <--- Добавьте эту строку from core import settings urlpatterns = [ + path('favicon.ico', RedirectView.as_view(url=static_url('favicon.svg'), permanent=True)), path('admin/', admin.site.urls), # Добавь эту строку, она подключит login, logout и прочие стандартные пути path('accounts/', include('django.contrib.auth.urls')), diff --git a/shiftflow/admin.py b/shiftflow/admin.py index 2862820..d78645a 100644 --- a/shiftflow/admin.py +++ b/shiftflow/admin.py @@ -1,6 +1,6 @@ import os from django.contrib import admin -from .models import Company, EmployeeProfile, Machine, Deal, Material, ProductionTask, Item +from .models import Company, EmployeeProfile, Machine, Deal, ProductionTask, Item # --- Настройка отображения Компаний --- @admin.register(Company) @@ -15,11 +15,6 @@ class DealAdmin(admin.ModelAdmin): search_fields = ('number', 'company__name') list_filter = ('company',) -# --- Настройка отображения Материалов --- -@admin.register(Material) -class MaterialAdmin(admin.ModelAdmin): - search_fields = ('name',) - # --- Задания на производство (База) --- @admin.register(ProductionTask) class ProductionTaskAdmin(admin.ModelAdmin): @@ -50,10 +45,12 @@ class ItemAdmin(admin.ModelAdmin): }), ) - def get_deal(self, obj): return obj.task.deal + def get_deal(self, obj): + return obj.task.deal if obj.task else "-" get_deal.short_description = 'Сделка' - def get_drawing(self, obj): return obj.task.drawing_name + def get_drawing(self, obj): + return obj.task.drawing_name if obj.task else "-" get_drawing.short_description = 'Деталь' # Регистрация станков просто списком diff --git a/shiftflow/migrations/0005_alter_productiontask_material_delete_material.py b/shiftflow/migrations/0005_alter_productiontask_material_delete_material.py new file mode 100644 index 0000000..a65a92f --- /dev/null +++ b/shiftflow/migrations/0005_alter_productiontask_material_delete_material.py @@ -0,0 +1,23 @@ +# Generated by Django 6.0.3 on 2026-03-29 14:16 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('shiftflow', '0004_alter_item_options_remove_item_deal_and_more'), + ('warehouse', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='productiontask', + name='material', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='warehouse.material', verbose_name='Материал'), + ), + migrations.DeleteModel( + name='Material', + ), + ] diff --git a/shiftflow/migrations/0006_alter_item_options_alter_item_status.py b/shiftflow/migrations/0006_alter_item_options_alter_item_status.py new file mode 100644 index 0000000..5071f8c --- /dev/null +++ b/shiftflow/migrations/0006_alter_item_options_alter_item_status.py @@ -0,0 +1,22 @@ +# Generated by Django 6.0.3 on 2026-03-29 16:51 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('shiftflow', '0005_alter_productiontask_material_delete_material'), + ] + + operations = [ + migrations.AlterModelOptions( + name='item', + options={'ordering': ['-date', 'task__deal'], 'verbose_name': 'Пункт сменки', 'verbose_name_plural': 'Реестр сменных заданий'}, + ), + migrations.AlterField( + model_name='item', + name='status', + field=models.CharField(choices=[('work', 'В работе'), ('done', 'Выполнено'), ('partial', 'Частично'), ('leftover', 'Недодел'), ('imported', 'Импортировано')], default='work', max_length=10, verbose_name='Статус'), + ), + ] diff --git a/shiftflow/models.py b/shiftflow/models.py index efa52b5..9971bb4 100644 --- a/shiftflow/models.py +++ b/shiftflow/models.py @@ -1,6 +1,7 @@ from django.db import models from django.utils import timezone from django.contrib.auth.models import User +from warehouse.models import Material as WarehouseMaterial class Company(models.Model): """ @@ -38,17 +39,6 @@ class Deal(models.Model): class Meta: verbose_name = "Сделка"; verbose_name_plural = "Сделки" -class Material(models.Model): - """ - Справочник ТМЦ (Трубы, листы, профили). - Необходим для точного списания остатков и синхронизации с 1С. - """ - name = models.CharField("Наименование", max_length=255, unique=True) - - def __str__(self): return self.name - class Meta: - verbose_name = "Материал"; verbose_name_plural = "Материалы" - class ProductionTask(models.Model): """ Основание для производства. Определяет ЧТО делать. @@ -62,7 +52,7 @@ class ProductionTask(models.Model): drawing_file = models.FileField("Исходник (DXF/IGES)", upload_to="drawings/%Y/%m/", blank=True, null=True) extra_drawing = models.FileField("Доп. чертеж (PDF)", upload_to="extra_drawings/%Y/%m/", blank=True, null=True) - material = models.ForeignKey(Material, on_delete=models.PROTECT, verbose_name="Материал") + material = models.ForeignKey(WarehouseMaterial, on_delete=models.PROTECT, verbose_name="Материал") quantity_ordered = models.PositiveIntegerField("Заказано всего, шт") is_bend = models.BooleanField("Гибка", default=False) @@ -84,6 +74,7 @@ class Item(models.Model): ('done', 'Выполнено'), ('partial', 'Частично'), ('leftover', 'Недодел'), + ('imported', 'Импортировано'), ] # --- Ссылка на основу (временно null=True для миграции старых данных) --- @@ -106,11 +97,13 @@ class Item(models.Model): is_synced_1c = models.BooleanField("Учтено в 1С", default=False) class Meta: - verbose_name = "Позиция сменки"; verbose_name_plural = "Реестр сменных заданий" + verbose_name = "Пункт сменки"; verbose_name_plural = "Реестр сменных заданий" ordering = ['-date', 'task__deal'] def __str__(self): - return f"{self.task.drawing_name} - {self.date}" + if self.task: + return f"{self.task.drawing_name} - {self.date}" + return f"Без задания - {self.date}" class EmployeeProfile(models.Model): diff --git a/shiftflow/templates/shiftflow/item_detail.html b/shiftflow/templates/shiftflow/item_detail.html index f115074..6927229 100644 --- a/shiftflow/templates/shiftflow/item_detail.html +++ b/shiftflow/templates/shiftflow/item_detail.html @@ -52,6 +52,21 @@ + +
+
+ + +
+
+ + +
+
+ + +
+
{% else %}
Статус: {{ item.get_status_display }}. Сделано: {{ item.quantity_fact }} шт.
@@ -120,7 +135,7 @@ function closeTask(status) { function showPartial() { document.getElementById('partialInput').classList.remove('d-none'); - document.getElementById('id_status').value = 'part'; // Статус Частично + document.getElementById('id_status').value = 'partial'; } {% endblock %} \ No newline at end of file diff --git a/shiftflow/templates/shiftflow/partials/_filter.html b/shiftflow/templates/shiftflow/partials/_filter.html index de4d3ce..1473704 100644 --- a/shiftflow/templates/shiftflow/partials/_filter.html +++ b/shiftflow/templates/shiftflow/partials/_filter.html @@ -1,45 +1,81 @@
-
+ + + {% if user_role != 'operator' %} +
Станки:
{% for m in machines %}
+ {% if all_selected_machines or m.id in selected_machines %}checked{% endif %} onchange="this.form.submit()">
{% endfor %}
+ {% endif %}
Статус:
- - - - - + {% if user_role == 'operator' %} + + В работе + {% else %} + + + + + - - + + + + {% if user_role in 'admin,technologist' %} + + + {% endif %} + {% endif %}
+ {% if user_role in 'admin,technologist,clerk' %} +
+ + +
+ {% endif %} +
- +
- +
+
\ No newline at end of file diff --git a/shiftflow/templates/shiftflow/registry.html b/shiftflow/templates/shiftflow/registry.html index 44723a8..41e0371 100644 --- a/shiftflow/templates/shiftflow/registry.html +++ b/shiftflow/templates/shiftflow/registry.html @@ -1,6 +1,8 @@ {% extends 'base.html' %} {% block content %} +{% include 'shiftflow/partials/_filter.html' %} +

Реестр заданий

@@ -27,23 +29,23 @@ {% for item in items %} {{ item.date|date:"d.m.y" }} - {{ item.deal.number }} + {{ item.task.deal.number|default:"-" }} {{ item.machine.name }} - {{ item.drawing_name }} - {{ item.size_value }} + {{ item.task.drawing_name|default:"Б/ч" }} + {{ item.task.size_value|default:"-" }} {{ item.quantity_plan }} / {{ item.quantity_fact }} - {{ item.material.name }} + {{ item.task.material.full_name|default:item.task.material.name|default:"-" }} - {% if item.drawing_file %} - + {% if item.task.drawing_file %} + {% endif %} - {% if item.extra_drawing %} - + {% if item.task.extra_drawing %} + {% endif %} diff --git a/shiftflow/views.py b/shiftflow/views.py index 2c51981..b093589 100644 --- a/shiftflow/views.py +++ b/shiftflow/views.py @@ -2,7 +2,8 @@ from django.shortcuts import redirect from django.urls import reverse_lazy from django.views.generic import TemplateView, ListView, UpdateView from django.contrib.auth.mixins import LoginRequiredMixin -from .models import Item # Проверь, как точно называется твоя модель деталей/заказов +from django.utils import timezone +from .models import Item, Machine # Класс главной страницы (роутер) class IndexView(TemplateView): @@ -22,22 +23,84 @@ class RegistryView(LoginRequiredMixin, ListView): context_object_name = 'items' def get_queryset(self): - # Оптимизируем запросы, подгружая связанные данные сразу - queryset = Item.objects.select_related('task', 'task__deal', 'task__material', 'machine').all() - - # Если это оператор, показываем только задания для его станков - if hasattr(self.request.user, 'profile') and self.request.user.profile.role == 'operator': - user_machines = self.request.user.profile.machines.all() - if user_machines.exists(): - queryset = queryset.filter(machine__in=user_machines) - + queryset = Item.objects.select_related('task', 'task__deal', 'task__material', 'machine') + user = self.request.user + profile = getattr(user, 'profile', None) + role = profile.role if profile else 'operator' + filtered = self.request.GET.get('filtered') + + # Станки + m_ids = self.request.GET.getlist('m_ids') + if filtered and role != 'operator' and not m_ids: + return queryset.none() + if m_ids: + queryset = queryset.filter(machine_id__in=m_ids) + + # Статусы (+ агрегат "closed" = done+partial) + statuses = self.request.GET.getlist('statuses') + if filtered and not statuses: + return queryset.none() + if statuses: + expanded = [] + for s in statuses: + if s == 'closed': + expanded += ['done', 'partial'] + else: + expanded.append(s) + queryset = queryset.filter(status__in=expanded) + + # Даты + start_date = self.request.GET.get('start_date') + end_date = self.request.GET.get('end_date') + if not filtered: + today = timezone.now().date() + queryset = queryset.filter(date=today, status__in=['work', 'leftover']) + else: + if start_date: + queryset = queryset.filter(date__gte=start_date) + if end_date: + queryset = queryset.filter(date__lte=end_date) + + # Списание (1С) + is_synced = self.request.GET.get('is_synced') + if is_synced in ['0', '1']: + queryset = queryset.filter(is_synced_1c=bool(int(is_synced))) + + # Ограничения по ролям + if role == 'operator': + user_machines = profile.machines.all() if profile else Machine.objects.none() + queryset = queryset.filter(machine__in=user_machines, status='work') + elif role == 'master' and not filtered: + queryset = queryset.filter(status='work') + return queryset.order_by('status', '-date', 'machine__name', 'task__deal__number') def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - # Передаем роль в шаблон, чтобы скрывать/показывать кнопки - if hasattr(self.request.user, 'profile'): - context['user_role'] = self.request.user.profile.role + user = self.request.user + profile = getattr(user, 'profile', None) + role = profile.role if profile else 'operator' + context['user_role'] = role + + machines = Machine.objects.all() + context['machines'] = machines + filtered = self.request.GET.get('filtered') + + if not filtered: + today_str = timezone.now().date().strftime('%Y-%m-%d') + context['start_date'] = today_str + context['end_date'] = today_str + context['selected_statuses'] = ['work', 'leftover'] + context['selected_machines'] = [m.id for m in machines] + context['all_selected_machines'] = True + else: + context['selected_machines'] = [int(i) for i in self.request.GET.getlist('m_ids') if i.isdigit()] + context['selected_statuses'] = self.request.GET.getlist('statuses') + context['start_date'] = self.request.GET.get('start_date', '') + context['end_date'] = self.request.GET.get('end_date', '') + context['is_synced'] = self.request.GET.get('is_synced', '') + context['all_selected_machines'] = False + return context # Вьюха детального вида и редактирования @@ -59,6 +122,54 @@ class ItemUpdateView(LoginRequiredMixin, UpdateView): context['user_role'] = self.request.user.profile.role return context + def post(self, request, *args, **kwargs): + self.object = self.get_object() + profile = getattr(request.user, 'profile', None) + role = profile.role if profile else 'operator' + + # Общие поля + self.object.material_taken = request.POST.get('material_taken', self.object.material_taken) + self.object.usable_waste = request.POST.get('usable_waste', self.object.usable_waste) + self.object.scrap_weight = request.POST.get('scrap_weight', self.object.scrap_weight or 0) + + status = request.POST.get('status', self.object.status) + + if role in ['operator', 'master']: + if status == 'done': + self.object.quantity_fact = self.object.quantity_plan + self.object.status = 'done' + self.object.save() + elif status == 'partial': + try: + fact = int(request.POST.get('quantity_fact', '0')) + except ValueError: + fact = 0 + fact = max(0, min(fact, self.object.quantity_plan)) + residual = self.object.quantity_plan - fact + self.object.quantity_fact = fact + self.object.status = 'partial' + self.object.save() + if residual > 0: + Item.objects.create( + task=self.object.task, + date=self.object.date, + machine=self.object.machine, + quantity_plan=residual, + quantity_fact=0, + status='leftover', + is_synced_1c=False, + ) + else: + # Просто сохранить без спец-логики + return super().post(request, *args, **kwargs) + elif role == 'clerk': + # Учетчик может отмечать списание 1С + self.object.is_synced_1c = bool(request.POST.get('is_synced_1c')) + self.object.save() + else: + return super().post(request, *args, **kwargs) + + return redirect('registry') + def get_success_url(self): - # После сохранения возвращаемся в реестр return reverse_lazy('registry') \ No newline at end of file diff --git a/static/css/style.css b/static/css/style.css index 540a4fa..3b35a3a 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -20,7 +20,8 @@ body { .navbar .nav-link, .navbar .navbar-brand, .footer-custom span, -.footer-custom strong { +.footer-custom strong, +.footer-custom .text-muted { color: #e9ecef !important; } @@ -62,6 +63,11 @@ body { --bs-accent: #0d6efd; /* Синий акцент для светлой темы */ } +[data-bs-theme="dark"] input[type="date"] { color-scheme: dark; } +[data-bs-theme="dark"] .form-control[type="date"] { background-color: #1e1e1e; border-color: #3d4246; color: #e9ecef; } +[data-bs-theme="dark"] input[type="date"]::-webkit-calendar-picker-indicator { filter: invert(1) brightness(1.4) contrast(1.2); opacity: 0.95; } +[data-bs-theme="light"] input[type="date"] { color-scheme: light; } + /* --- ТАБЛИЦА И КАРТОЧКИ --- */ /* Заголовок таблицы: всегда темный с акцентным текстом */ @@ -90,10 +96,25 @@ body { border-color: var(--bs-accent) !important; } -/* Состояние кнопки при наведении */ -.btn-outline-accent:hover { +.btn-check:checked + .btn-outline-accent, +.btn-outline-accent.active, +.btn-outline-accent:active { background-color: var(--bs-accent) !important; - color: #000 !important; /* Текст становится черным для контраста */ + border-color: var(--bs-accent) !important; +} + +[data-bs-theme="dark"] .btn-outline-accent:hover, +[data-bs-theme="dark"] .btn-check:checked + .btn-outline-accent, +[data-bs-theme="dark"] .btn-outline-accent.active, +[data-bs-theme="dark"] .btn-outline-accent:active { + color: #212529 !important; +} + +[data-bs-theme="light"] .btn-outline-accent:hover, +[data-bs-theme="light"] .btn-check:checked + .btn-outline-accent, +[data-bs-theme="light"] .btn-outline-accent.active, +[data-bs-theme="light"] .btn-outline-accent:active { + color: #ffffff !important; } /* Специальный класс для центрирования окна логина (вернем его только там) */ diff --git a/static/favicon.svg b/static/favicon.svg new file mode 100644 index 0000000..a7f76be --- /dev/null +++ b/static/favicon.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/templates/base.html b/templates/base.html index 80bae8d..c1cb72c 100644 --- a/templates/base.html +++ b/templates/base.html @@ -5,6 +5,7 @@ {% block title %}ShiftFlow MES{% endblock %} + diff --git a/warehouse/__init__.py b/warehouse/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/warehouse/admin.py b/warehouse/admin.py new file mode 100644 index 0000000..07c63ff --- /dev/null +++ b/warehouse/admin.py @@ -0,0 +1,19 @@ +from django.contrib import admin +from .models import MaterialCategory, SteelGrade, Material + +@admin.register(MaterialCategory) +class MaterialCategoryAdmin(admin.ModelAdmin): + list_display = ('name', 'gost_standard') + search_fields = ('name', 'gost_standard') + +@admin.register(SteelGrade) +class SteelGradeAdmin(admin.ModelAdmin): + list_display = ('name', 'gost_standard') + search_fields = ('name', 'gost_standard') + +@admin.register(Material) +class MaterialAdmin(admin.ModelAdmin): + list_display = ('full_name', 'category', 'steel_grade', 'name') + list_filter = ('category', 'steel_grade') + search_fields = ('name', 'full_name') + readonly_fields = ('full_name',) diff --git a/warehouse/apps.py b/warehouse/apps.py new file mode 100644 index 0000000..7e527b8 --- /dev/null +++ b/warehouse/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + +class WarehouseConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'warehouse' + verbose_name = 'Склад и материалы' diff --git a/warehouse/migrations/0001_initial.py b/warehouse/migrations/0001_initial.py new file mode 100644 index 0000000..20eec05 --- /dev/null +++ b/warehouse/migrations/0001_initial.py @@ -0,0 +1,53 @@ +# Generated by Django 6.0.3 on 2026-03-29 14:16 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='MaterialCategory', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100, unique=True, verbose_name='Название категории')), + ], + options={ + 'verbose_name': 'Категория материала', + 'verbose_name_plural': 'Категории материалов', + }, + ), + migrations.CreateModel( + name='SteelGrade', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100, unique=True, verbose_name='Марка стали')), + ('gost_standard', models.CharField(blank=True, help_text='Основной стандарт для этой марки', max_length=255, verbose_name='ГОСТ/ТУ')), + ('certificate_pdf', models.FileField(blank=True, null=True, upload_to='certificates/', verbose_name='Сертификат/ГОСТ (PDF)')), + ], + options={ + 'verbose_name': 'Марка стали', + 'verbose_name_plural': 'Марки стали', + }, + ), + migrations.CreateModel( + name='Material', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255, verbose_name='Наименование (размер/характеристики)')), + ('full_name', models.CharField(blank=True, help_text='Генерируется автоматически, если пусто', max_length=500, verbose_name='Полное наименование')), + ('category', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='warehouse.materialcategory', verbose_name='Категория')), + ('steel_grade', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='warehouse.steelgrade', verbose_name='Марка стали')), + ], + options={ + 'verbose_name': 'Материал (номенклатура)', + 'verbose_name_plural': 'Материалы', + }, + ), + ] diff --git a/warehouse/migrations/0002_materialcategory_gost_standard.py b/warehouse/migrations/0002_materialcategory_gost_standard.py new file mode 100644 index 0000000..61591c5 --- /dev/null +++ b/warehouse/migrations/0002_materialcategory_gost_standard.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.3 on 2026-03-29 14:21 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('warehouse', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='materialcategory', + name='gost_standard', + field=models.CharField(blank=True, help_text='Напр: ГОСТ 8639-82', max_length=255, verbose_name='ГОСТ на тип проката'), + ), + ] diff --git a/warehouse/migrations/__init__.py b/warehouse/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/warehouse/models.py b/warehouse/models.py new file mode 100644 index 0000000..c338be5 --- /dev/null +++ b/warehouse/models.py @@ -0,0 +1,46 @@ +from django.db import models + +class MaterialCategory(models.Model): + """Категория материала (например, Труба, Лист, Круг)""" + name = models.CharField("Название категории", max_length=100, unique=True) + gost_standard = models.CharField("ГОСТ на тип проката", max_length=255, blank=True, help_text="Напр: ГОСТ 8639-82") + + class Meta: + verbose_name = "Категория материала" + verbose_name_plural = "Категории материалов" + + def __str__(self): + return self.name + +class SteelGrade(models.Model): + """Марка стали (например, Ст3сп, 09Г2С) и связанные с ней ГОСТы""" + name = models.CharField("Марка стали", max_length=100, unique=True) + gost_standard = models.CharField("ГОСТ/ТУ", max_length=255, blank=True, help_text="Основной стандарт для этой марки") + certificate_pdf = models.FileField("Сертификат/ГОСТ (PDF)", upload_to='certificates/', blank=True, null=True) + + class Meta: + verbose_name = "Марка стали" + verbose_name_plural = "Марки стали" + + def __str__(self): + return f"{self.name} ({self.gost_standard})" if self.gost_standard else self.name + +class Material(models.Model): + """Конкретная номенклатурная единица (например, Труба 100х100х4)""" + category = models.ForeignKey(MaterialCategory, on_delete=models.PROTECT, verbose_name="Категория") + steel_grade = models.ForeignKey(SteelGrade, on_delete=models.PROTECT, verbose_name="Марка стали", null=True, blank=True) + name = models.CharField("Наименование (размер/характеристики)", max_length=255) + full_name = models.CharField("Полное наименование", max_length=500, blank=True, help_text="Генерируется автоматически, если пусто") + + class Meta: + verbose_name = "Материал (номенклатура)" + verbose_name_plural = "Материалы" + + def save(self, *args, **kwargs): + if not self.full_name: + grade_str = f" {self.steel_grade.name}" if self.steel_grade else "" + self.full_name = f"{self.category.name} {self.name}{grade_str}" + super().save(*args, **kwargs) + + def __str__(self): + return self.full_name or f"{self.category.name} {self.name}" diff --git a/warehouse/views.py b/warehouse/views.py new file mode 100644 index 0000000..e69de29