From e88b861f68bfc465386d089d59a59567b292803e Mon Sep 17 00:00:00 2001 From: ackFromRedmi Date: Mon, 6 Apr 2026 08:06:37 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9E=D0=B3=D1=80=D0=BE=D0=BC=D0=BD=D0=B0?= =?UTF-8?q?=D1=8F=20=D0=B7=D0=B0=D0=BC=D0=B5=D0=BD=D0=B0=20=D0=BB=D0=BE?= =?UTF-8?q?=D0=B3=D0=B8=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .trae/rules/main.md | 65 ++ AI_RULES.md | 65 ++ TODO.md | 7 + core/settings.py | 3 +- exempl/manufacturing/models.py | 50 ++ exempl/shiftflow/models.py | 90 +++ exempl/warehouse/models.py | 104 ++++ manufacturing/__init__.py | 0 manufacturing/admin.py | 43 ++ manufacturing/apps.py | 7 + manufacturing/migrations/0001_initial.py | 61 ++ manufacturing/migrations/__init__.py | 0 manufacturing/models.py | 97 +++ manufacturing/tests.py | 3 + manufacturing/views.py | 3 + shiftflow/admin.py | 222 ++++++- .../commands/shiftflow_explode_deal.py | 29 + ...location_productiontask_entity_and_more.py | 88 +++ ...ptions_alter_shiftitem_options_and_more.py | 77 +++ ...hine_location_workshop_machine_workshop.py | 37 ++ ...ortconsumption_unique_together_and_more.py | 33 ++ .../0019_alter_employeeprofile_role.py | 18 + shiftflow/models.py | 205 ++++++- shiftflow/services/__init__.py | 14 + shiftflow/services/bom_explosion.py | 265 +++++++++ shiftflow/services/closing.py | 120 ++++ shiftflow/services/sessions.py | 191 ++++++ shiftflow/templates/shiftflow/closing.html | 270 +++++++++ .../templates/shiftflow/item_detail.html | 59 +- .../templates/shiftflow/warehouse_stocks.html | 560 ++++++++++++++++++ shiftflow/urls.py | 10 + shiftflow/views.py | 547 ++++++++++++++--- templates/base.html | 8 + templates/components/_navbar.html | 14 +- warehouse/admin.py | 111 +++- .../0004_location_stockitem_transferrecord.py | 65 ++ .../0005_alter_stockitem_options.py | 17 + .../0006_alter_stockitem_options.py | 17 + ...07_remove_transferrecord_items_and_more.py | 65 ++ ...ter_transferrecord_received_at_and_more.py | 24 + .../migrations/0009_stockitem_created_at.py | 19 + .../0010_materialcategory_form_factor.py | 18 + .../0011_stockitem_is_customer_supplied.py | 18 + warehouse/migrations/0012_stockitem_deal.py | 20 + warehouse/models warehouse.py | 48 ++ warehouse/models.py | 145 ++++- warehouse/services/__init__.py | 5 + warehouse/services/transfers.py | 71 +++ 48 files changed, 3833 insertions(+), 175 deletions(-) create mode 100644 .trae/rules/main.md create mode 100644 AI_RULES.md create mode 100644 TODO.md create mode 100644 exempl/manufacturing/models.py create mode 100644 exempl/shiftflow/models.py create mode 100644 exempl/warehouse/models.py create mode 100644 manufacturing/__init__.py create mode 100644 manufacturing/admin.py create mode 100644 manufacturing/apps.py create mode 100644 manufacturing/migrations/0001_initial.py create mode 100644 manufacturing/migrations/__init__.py create mode 100644 manufacturing/models.py create mode 100644 manufacturing/tests.py create mode 100644 manufacturing/views.py create mode 100644 shiftflow/management/commands/shiftflow_explode_deal.py create mode 100644 shiftflow/migrations/0015_machine_location_productiontask_entity_and_more.py create mode 100644 shiftflow/migrations/0016_alter_cuttingsession_options_alter_shiftitem_options_and_more.py create mode 100644 shiftflow/migrations/0017_alter_machine_location_workshop_machine_workshop.py create mode 100644 shiftflow/migrations/0018_alter_productionreportconsumption_unique_together_and_more.py create mode 100644 shiftflow/migrations/0019_alter_employeeprofile_role.py create mode 100644 shiftflow/services/__init__.py create mode 100644 shiftflow/services/bom_explosion.py create mode 100644 shiftflow/services/closing.py create mode 100644 shiftflow/services/sessions.py create mode 100644 shiftflow/templates/shiftflow/closing.html create mode 100644 shiftflow/templates/shiftflow/warehouse_stocks.html create mode 100644 warehouse/migrations/0004_location_stockitem_transferrecord.py create mode 100644 warehouse/migrations/0005_alter_stockitem_options.py create mode 100644 warehouse/migrations/0006_alter_stockitem_options.py create mode 100644 warehouse/migrations/0007_remove_transferrecord_items_and_more.py create mode 100644 warehouse/migrations/0008_alter_transferrecord_received_at_and_more.py create mode 100644 warehouse/migrations/0009_stockitem_created_at.py create mode 100644 warehouse/migrations/0010_materialcategory_form_factor.py create mode 100644 warehouse/migrations/0011_stockitem_is_customer_supplied.py create mode 100644 warehouse/migrations/0012_stockitem_deal.py create mode 100644 warehouse/models warehouse.py create mode 100644 warehouse/services/__init__.py create mode 100644 warehouse/services/transfers.py diff --git a/.trae/rules/main.md b/.trae/rules/main.md new file mode 100644 index 0000000..beb7714 --- /dev/null +++ b/.trae/rules/main.md @@ -0,0 +1,65 @@ +# AI_RULES — правила работы ассистента в проекте MES_Core +Роль: Ты Senior Django Backend Developer. + +Контекст: Разрабатывается MES/ERP система для металлообрабатывающего завода. Архитектура БД разделена на 3 приложения: warehouse, manufacturing, shiftflow. + +Задача: Разработать слой бизнес-логики (сервисы и CBV Views) для реализации сквозного процесса производства. + +# AI_RULES — правила работы ассистента в проекте MES_Core + +## 1) Коммуникация +- Пиши по-русски (если пользователь пишет по-русски). +- Не используй формулировки вида «по твоей просьбе», «добавил для тебя», «как договаривались» в комментариях к коду. +- Если предлагаешь новые файлы — всегда указывай: полное имя, абсолютный путь и весь контент в одном блоке. + +## 2) Изменения в коде +- Любые правки существующих файлов показывай через diff-превью (SEARCH/REPLACE). +- Не вставляй “просто код” для существующих файлов без diff-превью. +- Сначала читай файл и только потом предлагай правки (чтобы не ломать стиль и импорты). +- При создании новых файлов заголовок блока должен быть: язык + путь, например: python MES_Core/warehouse/services.py + +## 3) Создание новых файлов +- Для новых файлов заголовок блока должен быть: язык + путь, например: python MES_Core/warehouse/services.py + +## 4)Комментарии +- В Python/бекенде: + - добавляй поясняющие комментарии там, где есть бизнес-логика, транзакции, конкурентность, фоновые задачи, сложные алгоритмы (BOM, списания, начисления). + - комментарии должны быть нейтральными и описывать поведение/причину, без личных формулировок. +- В HTML-шаблонах Django: + - не добавляй template-комментарии {# ... #} . +- В остальных местах: + - не добавляй комментарии “для красоты”; только там, где они реально помогают поддержке. + + ## 5) Стиль и конвенции проекта +- Смотри на соседние файлы и придерживайся уже принятого стиля (структура, именование, импорты, форматирование). +- Не вводи новые библиотеки/фреймворки, пока не проверил, что они уже используются в проекте. +- Для UI-таблиц: + - если добавляешь новую таблицу — по умолчанию делай её сортируемой (если не мешает UX). + - для колонок с кнопками/прогрессом/иконками отключай сортировку. + + - Использовать Service Layer: сложная логика живет в services.py, вьюхи остаются тонкими. + + - Импорты моделей из других приложений — через строковые ссылки в полях ('app.Model') для избежания циклических импортов. +## 6) Безопасность и секреты +- Никогда не логируй и не печатай в stdout: + - SECRET_KEY + - пароли БД + - токены +- В логи допускаются только технические сообщения, ошибки и диагностические данные без секретов. + - В models.py всегда использовать on_delete=models.PROTECT для важных справочников (Металл, Сделки), чтобы нельзя было случайно удалить историю. +## 7) Логи и фоновые задачи +- Для долгих операций (рендер превью, массовые обновления, BOM explosion для больших заказов): + - не блокируй HTTP-ответ + - Использовать модуль threading для запуска таких задач в отдельном потоке. + + - Обязательно оборачивать фоновую функцию в try/except и логировать ошибки в БД или файл, так как ошибки в потоках не видны во вьюхе. +- Логи фоновых задач должны быть: + - с датой/временем + - доступны из интерфейса “Обслуживание сервера” (tail) + - очищаемы кнопкой (если задача не running) +## 8) Транзакции и гонки данных (warehouse/shiftflow) +- Все операции списания/начисления на складе делай в transaction.atomic() . +- На изменяемые складские остатки используй select_for_update() чтобы избежать гонок. +- Для массовых операций избегай N+1: + - select_related / prefetch_related + - bulk update/create там, где это безопасно. \ No newline at end of file diff --git a/AI_RULES.md b/AI_RULES.md new file mode 100644 index 0000000..beb7714 --- /dev/null +++ b/AI_RULES.md @@ -0,0 +1,65 @@ +# AI_RULES — правила работы ассистента в проекте MES_Core +Роль: Ты Senior Django Backend Developer. + +Контекст: Разрабатывается MES/ERP система для металлообрабатывающего завода. Архитектура БД разделена на 3 приложения: warehouse, manufacturing, shiftflow. + +Задача: Разработать слой бизнес-логики (сервисы и CBV Views) для реализации сквозного процесса производства. + +# AI_RULES — правила работы ассистента в проекте MES_Core + +## 1) Коммуникация +- Пиши по-русски (если пользователь пишет по-русски). +- Не используй формулировки вида «по твоей просьбе», «добавил для тебя», «как договаривались» в комментариях к коду. +- Если предлагаешь новые файлы — всегда указывай: полное имя, абсолютный путь и весь контент в одном блоке. + +## 2) Изменения в коде +- Любые правки существующих файлов показывай через diff-превью (SEARCH/REPLACE). +- Не вставляй “просто код” для существующих файлов без diff-превью. +- Сначала читай файл и только потом предлагай правки (чтобы не ломать стиль и импорты). +- При создании новых файлов заголовок блока должен быть: язык + путь, например: python MES_Core/warehouse/services.py + +## 3) Создание новых файлов +- Для новых файлов заголовок блока должен быть: язык + путь, например: python MES_Core/warehouse/services.py + +## 4)Комментарии +- В Python/бекенде: + - добавляй поясняющие комментарии там, где есть бизнес-логика, транзакции, конкурентность, фоновые задачи, сложные алгоритмы (BOM, списания, начисления). + - комментарии должны быть нейтральными и описывать поведение/причину, без личных формулировок. +- В HTML-шаблонах Django: + - не добавляй template-комментарии {# ... #} . +- В остальных местах: + - не добавляй комментарии “для красоты”; только там, где они реально помогают поддержке. + + ## 5) Стиль и конвенции проекта +- Смотри на соседние файлы и придерживайся уже принятого стиля (структура, именование, импорты, форматирование). +- Не вводи новые библиотеки/фреймворки, пока не проверил, что они уже используются в проекте. +- Для UI-таблиц: + - если добавляешь новую таблицу — по умолчанию делай её сортируемой (если не мешает UX). + - для колонок с кнопками/прогрессом/иконками отключай сортировку. + + - Использовать Service Layer: сложная логика живет в services.py, вьюхи остаются тонкими. + + - Импорты моделей из других приложений — через строковые ссылки в полях ('app.Model') для избежания циклических импортов. +## 6) Безопасность и секреты +- Никогда не логируй и не печатай в stdout: + - SECRET_KEY + - пароли БД + - токены +- В логи допускаются только технические сообщения, ошибки и диагностические данные без секретов. + - В models.py всегда использовать on_delete=models.PROTECT для важных справочников (Металл, Сделки), чтобы нельзя было случайно удалить историю. +## 7) Логи и фоновые задачи +- Для долгих операций (рендер превью, массовые обновления, BOM explosion для больших заказов): + - не блокируй HTTP-ответ + - Использовать модуль threading для запуска таких задач в отдельном потоке. + + - Обязательно оборачивать фоновую функцию в try/except и логировать ошибки в БД или файл, так как ошибки в потоках не видны во вьюхе. +- Логи фоновых задач должны быть: + - с датой/временем + - доступны из интерфейса “Обслуживание сервера” (tail) + - очищаемы кнопкой (если задача не running) +## 8) Транзакции и гонки данных (warehouse/shiftflow) +- Все операции списания/начисления на складе делай в transaction.atomic() . +- На изменяемые складские остатки используй select_for_update() чтобы избежать гонок. +- Для массовых операций избегай N+1: + - select_related / prefetch_related + - bulk update/create там, где это безопасно. \ No newline at end of file diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..012e525 --- /dev/null +++ b/TODO.md @@ -0,0 +1,7 @@ +# TODO (MES_Core) + +## Склады (UI) +- Доработать сортировку по дате «Поступление» (стабильно сортировать как datetime, а не как текст). +- По клику на строку открывать карточку «Единица на складе» (read-only для observer, редактирование для admin/technologist/master/clerk): + - правка: сделка, давальческий, размеры (лист/хлыст), количество, примечание (если добавим) + - отображение: история перемещений/приходов/отгрузок (если потребуется). \ No newline at end of file diff --git a/core/settings.py b/core/settings.py index 30acdda..db1e37e 100644 --- a/core/settings.py +++ b/core/settings.py @@ -59,8 +59,9 @@ INSTALLED_APPS = [ 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', - 'shiftflow', # Вот это допиши обязательно! + 'shiftflow', 'warehouse', + 'manufacturing', ] MIDDLEWARE = [ diff --git a/exempl/manufacturing/models.py b/exempl/manufacturing/models.py new file mode 100644 index 0000000..2194326 --- /dev/null +++ b/exempl/manufacturing/models.py @@ -0,0 +1,50 @@ +from django.db import models + +class RouteStub(models.Model): + """Заглушка для будущего модуля техпроцессов.""" + name = models.CharField("Маршрут (напр. Лазер-Гибка-Сварка)", max_length=200, unique=True) + + def __str__(self): return self.name + +class ProductEntity(models.Model): + """ + Универсальный паспорт Детали или Сборки. + Логика Вьюх: + Это "Чертеж". Он не привязан к конкретному заказу (Сделке). + planned_material - это то, что задумал конструктор. Оператор по факту может взять другое сырье. + """ + ENTITY_TYPE = [('product', 'Готовое изделие'), ('assembly', 'Сборочная единица'), ('part', 'Деталь')] + + name = models.CharField("Наименование", max_length=255) + drawing_number = models.CharField("Обозначение/Чертеж", max_length=100, blank=True) + entity_type = models.CharField("Тип", max_length=15, choices=ENTITY_TYPE, default='part') + + planned_material = models.ForeignKey('warehouse.Material', on_delete=models.PROTECT, null=True, blank=True, verbose_name="Заложенный материал") + route = models.ForeignKey(RouteStub, on_delete=models.SET_NULL, null=True, blank=True, verbose_name="Маршрут") + + weight_unit = models.FloatField("Вес 1 шт, кг", default=0.0) + surface_area = models.FloatField("Площадь поверхности, м2", default=0.0) + + dxf_file = models.FileField("Исходник (DXF/IGES)", upload_to="drawings/dxf/%Y/%m/", blank=True, null=True) + 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) + + class Meta: + verbose_name = "КД (Изделие/Деталь)"; verbose_name_plural = "Конструкторская документация" + + def __str__(self): return f"{self.drawing_number} {self.name}".strip() + +class BOM(models.Model): + """ + Спецификация (Bill of Materials). Состав изделия. + Логика Вьюх: + При создании заказа на 5 "Лавок", система рекурсивно ищет все BOM, где Лавка = parent, + чтобы создать потребность в материалах (child) умноженную на quantity. + """ + parent = models.ForeignKey(ProductEntity, related_name='components', on_delete=models.CASCADE, verbose_name="Куда входит (Сборка)") + child = models.ForeignKey(ProductEntity, related_name='used_in', on_delete=models.CASCADE, verbose_name="Что входит (Деталь)") + quantity = models.PositiveIntegerField("Кол-во в сборке", default=1) + + class Meta: + unique_together = ('parent', 'child') + verbose_name = "Спецификация (BOM)"; verbose_name_plural = "Спецификации (BOM)" \ No newline at end of file diff --git a/exempl/shiftflow/models.py b/exempl/shiftflow/models.py new file mode 100644 index 0000000..6773f13 --- /dev/null +++ b/exempl/shiftflow/models.py @@ -0,0 +1,90 @@ +from django.db import models +from django.utils import timezone +from django.contrib.auth.models import User + +# --- СПРАВОЧНИКИ --- +class Company(models.Model): + name = models.CharField("Название компании", max_length=255, unique=True) + def __str__(self): return self.name + +class Machine(models.Model): + name = models.CharField("Название станка", max_length=100) + def __str__(self): return self.name + +class EmployeeProfile(models.Model): + user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='profile') + role = models.CharField("Должность", max_length=20, default='operator') + machines = models.ManyToManyField(Machine, blank=True) + def __str__(self): return self.user.username + +# --- КОНТУР ЗАКАЗОВ (СДЕЛКИ И ПОТРЕБНОСТИ) --- +class Deal(models.Model): + """Сделка. Контейнер заказа клиента.""" + number = models.CharField("№ Сделки", max_length=100, unique=True) + company = models.ForeignKey(Company, on_delete=models.PROTECT, null=True, blank=True) + status = models.CharField("Статус", max_length=20, default='lead') + def __str__(self): return f"Сделка №{self.number}" + +class DealItem(models.Model): + """ + Что заказал клиент (точка входа MRP). + Логика Вьюх: + Менеджер вносит сюда 5 шт "Лавок". На основе этого генерируются MaterialRequirement и ProductionTask. + """ + deal = models.ForeignKey(Deal, related_name='items', on_delete=models.CASCADE) + entity = models.ForeignKey('manufacturing.ProductEntity', on_delete=models.PROTECT, verbose_name="Изделие") + quantity = models.PositiveIntegerField("Заказано, шт") + +class MaterialRequirement(models.Model): + """ + Потребность в закупке сырья для Сделки. + Логика Вьюх: + Генерируется автоматически после "взрыва" спецификации (BOM). + Снабженец видит это в своем АРМ и организует приход. + """ + STATUS = [('needed', 'К закупке'), ('ordered', 'В пути'), ('fulfilled', 'Обеспечено')] + deal = models.ForeignKey(Deal, on_delete=models.CASCADE) + material = models.ForeignKey('warehouse.Material', on_delete=models.PROTECT) + required_qty = models.FloatField("Нужно докупить (шт/м/кг)") + status = models.CharField(max_length=20, choices=STATUS, default='needed') + +# --- ПРОИЗВОДСТВЕННЫЙ КОНТУР --- +class ProductionTask(models.Model): + """ + Сменное задание (План на производство детали). + Логика: Связывает Сделку и КД (ProductEntity). + """ + deal = models.ForeignKey(Deal, on_delete=models.CASCADE, verbose_name="Сделка") + entity = models.ForeignKey('manufacturing.ProductEntity', on_delete=models.CASCADE, verbose_name="Что делать") + quantity_ordered = models.PositiveIntegerField("План, шт") + created_at = models.DateTimeField(auto_now_add=True) + + def __str__(self): return f"{self.entity.name} (Заказ {self.deal.number})" + +class CuttingSession(models.Model): + """ + Сессия переработки (Основа для списания/начисления). + Логика Вьюх: + Оператор создает сессию. Указывает, какой StockItem (Лист/Хлыст) он взял со своего участка. + В рамках сессии он закрывает пункты (ShiftItem). + При закрытии сессии Вьюха: 1) списывает used_stock_item, 2) начисляет новые StockItem с готовыми деталями, 3) начисляет ДО. + """ + operator = models.ForeignKey(User, on_delete=models.PROTECT) + machine = models.ForeignKey(Machine, on_delete=models.PROTECT) + used_stock_item = models.ForeignKey('warehouse.StockItem', on_delete=models.PROTECT, verbose_name="Взятый со склада материал") + + date = models.DateField("Дата", default=timezone.localdate) + created_at = models.DateTimeField(auto_now_add=True) + is_closed = models.BooleanField("Сессия закрыта", default=False) + +class ShiftItem(models.Model): + """ + Конкретный пункт отчета в рамках сессии. + (Замена старой модели Item). + """ + session = models.ForeignKey(CuttingSession, related_name='tasks', on_delete=models.CASCADE) + task = models.ForeignKey(ProductionTask, on_delete=models.PROTECT, verbose_name="Плановое задание") + quantity_fact = models.PositiveIntegerField("Изготовлено (Факт), шт", default=0) + + # Флаг для контроля отклонений (если взяли Ст3 вместо Ст10) + material_substitution = models.BooleanField("Замена материала по факту", default=False) \ No newline at end of file diff --git a/exempl/warehouse/models.py b/exempl/warehouse/models.py new file mode 100644 index 0000000..5daf2f9 --- /dev/null +++ b/exempl/warehouse/models.py @@ -0,0 +1,104 @@ +from django.db import models +from django.contrib.auth.models import User + +class MaterialCategory(models.Model): + """Категория сырья (Лист, Труба, Круг).""" + name = models.CharField("Название категории", max_length=100, unique=True) + gost_standard = models.CharField("ГОСТ", max_length=255, blank=True) + + class Meta: + verbose_name = "Категория материала" + verbose_name_plural = "Категории материалов" + + def __str__(self): return self.name + +class SteelGrade(models.Model): + """Марка стали.""" + name = models.CharField("Марка стали", max_length=100, unique=True) + gost_standard = models.CharField("ГОСТ/ТУ", max_length=255, blank=True) + + class Meta: + verbose_name = "Марка стали"; verbose_name_plural = "Марки стали" + + def __str__(self): return self.name + +class Material(models.Model): + """ + Справочник закупаемого сырья (Номенклатура). + Логика: Это только "идея" материала, а не физический объект на полке. + Для листа заполняем thickness, для трубы width (сечение), для всех length (стандартная длина). + """ + category = models.ForeignKey(MaterialCategory, on_delete=models.PROTECT, verbose_name="Категория") + steel_grade = models.ForeignKey(SteelGrade, on_delete=models.PROTECT, null=True, blank=True) + name = models.CharField("Наименование", max_length=255) + + thickness = models.FloatField("Толщина (S), мм", null=True, blank=True) + width = models.FloatField("Ширина/Сечение (B), мм", null=True, blank=True) + length = models.FloatField("Длина (L), мм", null=True, blank=True) + + class Meta: + verbose_name = "Номенклатура (Сырье)"; verbose_name_plural = "Номенклатура (Сырье)" + + def __str__(self): return f"{self.category.name} {self.name} {self.steel_grade.name if self.steel_grade else ''}" + +class Location(models.Model): + """Склады и участки (Центральный, Лазер, Сварка, СГП).""" + name = models.CharField("Место хранения", max_length=100, unique=True) + is_production_area = models.BooleanField("Это производственный участок", default=False) + + class Meta: + verbose_name = "Склад/Участок"; verbose_name_plural = "Склады и участки" + + def __str__(self): return self.name + +class StockItem(models.Model): + """ + Универсальная физическая единица на складе. + Логика Вьюх: + 1. Если это сырье: заполнен material, пусто entity. + 2. Если это готовая деталь: заполнен entity, пусто material. + 3. Если is_remnant=True, то current_length/width показывают реальный размер куска. + При списании в CuttingSession количество здесь уменьшается. Если 0 - можно удалять или скрывать. + """ + material = models.ForeignKey(Material, on_delete=models.PROTECT, null=True, blank=True, verbose_name="Сырье") + entity = models.ForeignKey('manufacturing.ProductEntity', on_delete=models.PROTECT, null=True, blank=True, verbose_name="Произведенная сущность") + + location = models.ForeignKey(Location, on_delete=models.PROTECT, verbose_name="Где находится") + quantity = models.FloatField("Количество (шт/м/кг)") + + # Для деловых остатков + is_remnant = models.BooleanField("Деловой остаток", default=False) + current_length = models.FloatField("Текущая длина, мм", null=True, blank=True) + current_width = models.FloatField("Текущая ширина, мм", null=True, blank=True) + unique_id = models.CharField("ID/Маркировка (для ДО)", max_length=50, unique=True, null=True, blank=True) + + class Meta: + verbose_name = "Единица на складе"; verbose_name_plural = "Остатки на складах" + + def __str__(self): + obj = self.entity if self.entity else self.material + return f"{obj} | {self.quantity} ед. | {self.location}" + +class TransferRecord(models.Model): + """ + Документ перемещения (Вариант Б: строгий учет). + Логика Вьюх: + Создается "Отправителем" (статус sent). + "Получатель" видит его в своем интерфейсе и жмет "Принять" (статус received). + В этот момент у связанных StockItem меняется location на to_location. + """ + STATUS_CHOICES = [('sent', 'В пути'), ('received', 'Принято'), ('discrepancy', 'Расхождение')] + + items = models.ManyToManyField(StockItem, verbose_name="Перемещаемые объекты") + from_location = models.ForeignKey(Location, related_name='outgoing', on_delete=models.PROTECT) + to_location = models.ForeignKey(Location, related_name='incoming', on_delete=models.PROTECT) + + sender = models.ForeignKey(User, related_name='sent_transfers', on_delete=models.PROTECT) + receiver = models.ForeignKey(User, related_name='received_transfers', on_delete=models.PROTECT, null=True, blank=True) + status = models.CharField("Статус", max_length=20, choices=STATUS_CHOICES, default='sent') + + created_at = models.DateTimeField(auto_now_add=True) + received_at = models.DateTimeField(null=True, blank=True) + + class Meta: + verbose_name = "Перемещение"; verbose_name_plural = "Перемещения" \ No newline at end of file diff --git a/manufacturing/__init__.py b/manufacturing/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/manufacturing/admin.py b/manufacturing/admin.py new file mode 100644 index 0000000..efc9a0a --- /dev/null +++ b/manufacturing/admin.py @@ -0,0 +1,43 @@ +from django.contrib import admin + +from .models import BOM, ProductEntity, RouteStub + + +@admin.register(RouteStub) +class RouteStubAdmin(admin.ModelAdmin): + list_display = ('name',) + search_fields = ('name',) + + +class BOMChildInline(admin.TabularInline): + """Состав изделия/сборки (строки BOM) прямо в карточке ProductEntity.""" + + model = BOM + fk_name = 'parent' + fields = ('child', 'quantity') + autocomplete_fields = ('child',) + extra = 10 + + +@admin.register(ProductEntity) +class ProductEntityAdmin(admin.ModelAdmin): + list_display = ( + 'drawing_number', + 'name', + 'entity_type', + 'planned_material', + 'blank_area_m2', + 'blank_length_mm', + ) + list_filter = ('entity_type', 'planned_material__category') + search_fields = ('drawing_number', 'name', 'planned_material__name', 'planned_material__full_name') + autocomplete_fields = ('planned_material', 'route') + inlines = (BOMChildInline,) + + +@admin.register(BOM) +class BOMAdmin(admin.ModelAdmin): + list_display = ('parent', 'child', 'quantity') + search_fields = ('parent__name', 'parent__drawing_number', 'child__name', 'child__drawing_number') + list_filter = ('parent',) + autocomplete_fields = ('parent', 'child') diff --git a/manufacturing/apps.py b/manufacturing/apps.py new file mode 100644 index 0000000..9bedafd --- /dev/null +++ b/manufacturing/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class ManufacturingConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'manufacturing' + verbose_name = 'Производство (КД/BOM)' \ No newline at end of file diff --git a/manufacturing/migrations/0001_initial.py b/manufacturing/migrations/0001_initial.py new file mode 100644 index 0000000..6d96d2c --- /dev/null +++ b/manufacturing/migrations/0001_initial.py @@ -0,0 +1,61 @@ +# Generated by Django 6.0.3 on 2026-04-04 15:14 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('warehouse', '0003_alter_material_full_name'), + ] + + operations = [ + migrations.CreateModel( + name='RouteStub', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=200, unique=True, verbose_name='Маршрут')), + ], + options={ + 'verbose_name': 'Маршрут', + 'verbose_name_plural': 'Маршруты', + }, + ), + migrations.CreateModel( + name='ProductEntity', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255, verbose_name='Наименование')), + ('drawing_number', models.CharField(blank=True, default='', max_length=100, verbose_name='Обозначение/Чертёж')), + ('entity_type', models.CharField(choices=[('product', 'Готовое изделие'), ('assembly', 'Сборочная единица'), ('part', 'Деталь')], default='part', max_length=15, verbose_name='Тип')), + ('blank_area_m2', models.FloatField(blank=True, null=True, verbose_name='Норма: площадь заготовки (м²/шт)')), + ('blank_length_mm', models.FloatField(blank=True, null=True, verbose_name='Норма: длина заготовки (мм/шт)')), + ('dxf_file', models.FileField(blank=True, null=True, upload_to='drawings/%Y/%m/', verbose_name='Исходник (DXF/IGES/STEP)')), + ('pdf_main', models.FileField(blank=True, null=True, upload_to='drawings_pdf/%Y/%m/', verbose_name='Чертёж (PDF)')), + ('preview', models.ImageField(blank=True, null=True, upload_to='previews/%Y/%m/', verbose_name='Превью')), + ('planned_material', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='warehouse.material', verbose_name='Заложенный материал')), + ('route', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='manufacturing.routestub', verbose_name='Маршрут')), + ], + options={ + 'verbose_name': 'КД (изделие/деталь)', + 'verbose_name_plural': 'КД (изделия/детали)', + }, + ), + migrations.CreateModel( + name='BOM', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('quantity', models.PositiveIntegerField(default=1, verbose_name='Кол-во в сборке')), + ('child', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='used_in', to='manufacturing.productentity', verbose_name='Что входит (деталь)')), + ('parent', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='components', to='manufacturing.productentity', verbose_name='Куда входит (сборка)')), + ], + options={ + 'verbose_name': 'Спецификация (BOM)', + 'verbose_name_plural': 'Спецификации (BOM)', + 'unique_together': {('parent', 'child')}, + }, + ), + ] diff --git a/manufacturing/migrations/__init__.py b/manufacturing/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/manufacturing/models.py b/manufacturing/models.py new file mode 100644 index 0000000..d328228 --- /dev/null +++ b/manufacturing/models.py @@ -0,0 +1,97 @@ +from django.db import models + + +class RouteStub(models.Model): + """Маршрут (пока заглушка под техпроцессы).""" + + name = models.CharField("Маршрут", max_length=200, unique=True) + + class Meta: + verbose_name = "Маршрут" + verbose_name_plural = "Маршруты" + + def __str__(self): + return self.name + + +class ProductEntity(models.Model): + """Паспорт детали/сборки/изделия (КД). + + planned_material: + - материал, заложенный в КД (для расчёта потребности и контроля замен при раскрое). + + Нормы расхода (для BOM Explosion и MaterialRequirement): + - для листовой детали: blank_area_m2 (м² на 1 шт) + - для линейной (профиль/труба/круг): blank_length_mm (мм на 1 шт) + + Примечание: + - категорию типа (лист/профиль) определяем по planned_material.category. + """ + + ENTITY_TYPE = [ + ('product', 'Готовое изделие'), + ('assembly', 'Сборочная единица'), + ('part', 'Деталь'), + ] + + name = models.CharField("Наименование", max_length=255) + drawing_number = models.CharField("Обозначение/Чертёж", max_length=100, blank=True, default="") + entity_type = models.CharField("Тип", max_length=15, choices=ENTITY_TYPE, default='part') + + planned_material = models.ForeignKey( + 'warehouse.Material', + on_delete=models.PROTECT, + null=True, + blank=True, + verbose_name="Заложенный материал", + ) + route = models.ForeignKey( + RouteStub, + on_delete=models.SET_NULL, + null=True, + blank=True, + verbose_name="Маршрут", + ) + + blank_area_m2 = models.FloatField("Норма: площадь заготовки (м²/шт)", null=True, blank=True) + blank_length_mm = models.FloatField("Норма: длина заготовки (мм/шт)", null=True, blank=True) + + dxf_file = models.FileField("Исходник (DXF/IGES/STEP)", upload_to="drawings/%Y/%m/", blank=True, null=True) + 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) + + class Meta: + verbose_name = "КД (изделие/деталь)" + verbose_name_plural = "КД (изделия/детали)" + + def __str__(self): + base = f"{self.drawing_number} {self.name}".strip() + return base if base else self.name + + +class BOM(models.Model): + """Спецификация (BOM): parent состоит из child в количестве quantity.""" + + parent = models.ForeignKey( + ProductEntity, + related_name='components', + on_delete=models.CASCADE, + verbose_name="Куда входит (сборка)", + ) + child = models.ForeignKey( + ProductEntity, + related_name='used_in', + on_delete=models.CASCADE, + verbose_name="Что входит (деталь)", + ) + quantity = models.PositiveIntegerField("Кол-во в сборке", default=1) + + class Meta: + unique_together = ('parent', 'child') + verbose_name = "Спецификация (BOM)" + verbose_name_plural = "Спецификации (BOM)" + + def __str__(self): + return f"{self.parent} -> {self.child} x{self.quantity}" + +# Create your models here. diff --git a/manufacturing/tests.py b/manufacturing/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/manufacturing/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/manufacturing/views.py b/manufacturing/views.py new file mode 100644 index 0000000..91ea44a --- /dev/null +++ b/manufacturing/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. diff --git a/shiftflow/admin.py b/shiftflow/admin.py index 24bee98..514c80d 100644 --- a/shiftflow/admin.py +++ b/shiftflow/admin.py @@ -1,28 +1,94 @@ import os -from django.contrib import admin -from .models import Company, EmployeeProfile, Machine, Deal, ProductionTask, Item +from django.contrib import admin, messages + +from shiftflow.services.sessions import close_cutting_session +from warehouse.models import StockItem + +from .models import ( + Company, + CuttingSession, + Deal, + DealItem, + DxfPreviewJob, + DxfPreviewSettings, + EmployeeProfile, + Item, + Machine, + MaterialRequirement, + ProductionReportConsumption, + ProductionReportRemnant, + ProductionTask, + ShiftItem, + Workshop, +) # --- Настройка отображения Компаний --- @admin.register(Company) class CompanyAdmin(admin.ModelAdmin): - list_display = ('name', 'description') - search_fields = ('name',) + """ + Панель администрирования Компаний + """ + list_display = ('name', 'description') # Что видим в общем списке + search_fields = ('name',) # Поиск по имени + +class DealItemInline(admin.TabularInline): + model = DealItem + fields = ('entity', 'quantity') + autocomplete_fields = ('entity',) + extra = 10 + # --- Настройка отображения Сделок --- @admin.register(Deal) class DealAdmin(admin.ModelAdmin): - list_display = ('number', 'status', 'company') + """ + Панель администрирования Сделок + """ + list_display = ('number', 'id', 'status', 'company') + list_display_links = ('number',) search_fields = ('number', 'company__name') list_filter = ('status', 'company') + inlines = (DealItemInline,) # --- Задания на производство (База) --- +""" +Панель администрирования Заданий на производство +""" @admin.register(ProductionTask) class ProductionTaskAdmin(admin.ModelAdmin): - list_display = ('drawing_name', 'deal', 'material', 'quantity_ordered', 'created_at') - search_fields = ('drawing_name', 'deal__number') + list_display = ('drawing_name', 'deal', 'entity', 'material', 'quantity_ordered', 'created_at') + search_fields = ('drawing_name', 'deal__number', 'entity__name', 'entity__drawing_number') list_filter = ('deal', 'material', 'is_bend') + autocomplete_fields = ('deal', 'entity', 'material') -# --- Сменные задания (Выполнение) --- + +""" +Панель администрирования Сделочных элементов +""" +@admin.register(DealItem) +class DealItemAdmin(admin.ModelAdmin): + """ + Панель администрирования Сделочных элементов + """ + list_display = ('deal', 'entity', 'quantity') + search_fields = ('deal__number', 'entity__name', 'entity__drawing_number') + list_filter = ('deal',) + autocomplete_fields = ('deal', 'entity') + + +@admin.register(MaterialRequirement) +class MaterialRequirementAdmin(admin.ModelAdmin): + """ + Панель администрирования Требований к Материалам + """ + list_display = ('deal', 'material', 'required_qty', 'unit', 'status') + search_fields = ('deal__number', 'material__name', 'material__full_name') + list_filter = ('status', 'unit', 'material__category') + autocomplete_fields = ('deal', 'material') + +""" +Панель администрирования Сменных задания (Выполнение) +""" @admin.register(Item) class ItemAdmin(admin.ModelAdmin): # Что видим в общем списке (используем task__ для доступа к полям базы) @@ -53,13 +119,147 @@ class ItemAdmin(admin.ModelAdmin): return obj.task.drawing_name if obj.task else "-" get_drawing.short_description = 'Деталь' +@admin.register(Workshop) +class WorkshopAdmin(admin.ModelAdmin): + list_display = ('name', 'location') + search_fields = ('name',) + list_filter = ('location',) + + @admin.register(Machine) class MachineAdmin(admin.ModelAdmin): - list_display = ('name', 'machine_type') - list_filter = ('machine_type',) + list_display = ('name', 'machine_type', 'workshop', 'location') + list_display_links = ('name',) + list_filter = ('machine_type', 'workshop') search_fields = ('name',) + fields = ('name', 'machine_type', 'workshop', 'location') + + +class ProductionReportLineInline(admin.TabularInline): + model = ShiftItem + fk_name = 'session' + fields = ('task', 'quantity_fact', 'material_substitution') + extra = 5 + + +class ProductionReportConsumptionInline(admin.TabularInline): + model = ProductionReportConsumption + fk_name = 'report' + fields = ('stock_item', 'quantity') + autocomplete_fields = ('stock_item',) + extra = 3 + + def formfield_for_foreignkey(self, db_field, request, **kwargs): + if db_field.name == 'stock_item': + report = getattr(request, '_production_report_obj', None) + if report and getattr(report, 'machine_id', None): + machine = report.machine + work_location = None + if getattr(machine, 'workshop_id', None) and getattr(machine.workshop, 'location_id', None): + work_location = machine.workshop.location + elif getattr(machine, 'location_id', None): + work_location = machine.location + + if work_location: + kwargs['queryset'] = StockItem.objects.filter(location=work_location, material__isnull=False) + else: + kwargs['queryset'] = StockItem.objects.none() + + return super().formfield_for_foreignkey(db_field, request, **kwargs) + + +class ProductionReportRemnantInline(admin.TabularInline): + model = ProductionReportRemnant + fk_name = 'report' + fields = ('material', 'quantity', 'current_length', 'current_width') + autocomplete_fields = ('material',) + extra = 3 + + +@admin.register(CuttingSession) +class CuttingSessionAdmin(admin.ModelAdmin): + """ + Панель администрирования Производственных отчетов. + + Ограничение по складу: + - списание сырья доступно только со склада цеха выбранного станка. + """ + list_display = ('date', 'id', 'machine', 'operator', 'used_stock_item', 'is_closed') + list_display_links = ('date',) + list_filter = ('date', 'machine', 'is_closed') + search_fields = ('operator__username',) + actions = ('action_close_sessions',) + inlines = (ProductionReportLineInline, ProductionReportConsumptionInline, ProductionReportRemnantInline) + + def get_form(self, request, obj=None, **kwargs): + request._production_report_obj = obj + return super().get_form(request, obj, **kwargs) + + def formfield_for_foreignkey(self, db_field, request, **kwargs): + if db_field.name == 'used_stock_item': + report = getattr(request, '_production_report_obj', None) + if report and getattr(report, 'machine_id', None): + machine = report.machine + work_location = None + if getattr(machine, 'workshop_id', None) and getattr(machine.workshop, 'location_id', None): + work_location = machine.workshop.location + elif getattr(machine, 'location_id', None): + work_location = machine.location + + if work_location: + kwargs['queryset'] = StockItem.objects.filter(location=work_location, material__isnull=False) + else: + kwargs['queryset'] = StockItem.objects.none() + + return super().formfield_for_foreignkey(db_field, request, **kwargs) + + @admin.action(description='Закрыть производственный отчет') + def action_close_sessions(self, request, queryset): + ok = 0 + skipped = 0 + failed = 0 + + for s in queryset: + try: + if s.is_closed: + skipped += 1 + continue + close_cutting_session(s.id) + ok += 1 + except Exception as e: + failed += 1 + self.message_user(request, f'Отчет id={s.id}: {e}', level=messages.ERROR) + + if ok: + self.message_user(request, f'Закрыто: {ok}.', level=messages.SUCCESS) + if skipped: + self.message_user(request, f'Пропущено (уже закрыто): {skipped}.', level=messages.WARNING) + if failed: + self.message_user(request, f'Ошибок: {failed}.', level=messages.ERROR) +@admin.register(ShiftItem) +class ShiftItemAdmin(admin.ModelAdmin): + list_display = ('session', 'task', 'quantity_fact', 'material_substitution') + list_filter = ('material_substitution',) + autocomplete_fields = ('session', 'task') +class DxfPreviewSettingsAdmin(admin.ModelAdmin): + list_display = ( + 'line_color', + 'lineweight_scaling', + 'min_lineweight', + 'keep_original_colors', + 'per_task_timeout_sec', + 'updated_at', + ) + + +@admin.register(DxfPreviewJob) +class DxfPreviewJobAdmin(admin.ModelAdmin): + list_display = ('id', 'status', 'created_by', 'processed', 'total', 'updated', 'errors', 'started_at', 'finished_at') + list_filter = ('status',) + search_fields = ('last_message',) + @admin.register(EmployeeProfile) class EmployeeProfileAdmin(admin.ModelAdmin): list_display = ('user', 'role') - filter_horizontal = ('machines',) # Красивый выбор станков двумя колонками \ No newline at end of file + filter_horizontal = ('machines',) \ No newline at end of file diff --git a/shiftflow/management/commands/shiftflow_explode_deal.py b/shiftflow/management/commands/shiftflow_explode_deal.py new file mode 100644 index 0000000..bde3ff5 --- /dev/null +++ b/shiftflow/management/commands/shiftflow_explode_deal.py @@ -0,0 +1,29 @@ +from django.core.management.base import BaseCommand + +from shiftflow.models import DealItem +from shiftflow.services.bom_explosion import explode_deal + + +class Command(BaseCommand): + help = "BOM Explosion для сделки: генерирует ProductionTask и MaterialRequirement." + + def add_arguments(self, parser): + parser.add_argument("deal_id", type=int) + + def handle(self, *args, **options): + deal_id = int(options["deal_id"]) + stats = explode_deal(deal_id) + + self.stdout.write( + self.style.SUCCESS( + f"OK deal={deal_id} tasks_created={stats.tasks_created} tasks_updated={stats.tasks_updated} " + f"req_created={stats.req_created} req_updated={stats.req_updated}" + ) + ) + + if stats.tasks_created == 0 and stats.tasks_updated == 0 and stats.req_created == 0 and stats.req_updated == 0: + di_count = DealItem.objects.filter(deal_id=deal_id).count() + if di_count == 0: + self.stdout.write('Подсказка: в сделке нет позиций (DealItem). Добавь DealItem и повтори команду.') + else: + self.stdout.write('Подсказка: проверь заполнение BOM и норм расхода (blank_area_m2/blank_length_mm) на leaf-деталях.') \ No newline at end of file diff --git a/shiftflow/migrations/0015_machine_location_productiontask_entity_and_more.py b/shiftflow/migrations/0015_machine_location_productiontask_entity_and_more.py new file mode 100644 index 0000000..b9fc51a --- /dev/null +++ b/shiftflow/migrations/0015_machine_location_productiontask_entity_and_more.py @@ -0,0 +1,88 @@ +# Generated by Django 6.0.3 on 2026-04-04 15:14 + +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('manufacturing', '0001_initial'), + ('shiftflow', '0014_dxfpreviewjob_cancel_requested_dxfpreviewjob_pid_and_more'), + ('warehouse', '0004_location_stockitem_transferrecord'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name='machine', + name='location', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='warehouse.location', verbose_name='Склад участка'), + ), + migrations.AddField( + model_name='productiontask', + name='entity', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='manufacturing.productentity', verbose_name='КД (изделие/деталь)'), + ), + migrations.CreateModel( + name='CuttingSession', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('date', models.DateField(default=django.utils.timezone.localdate, verbose_name='Дата')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('is_closed', models.BooleanField(default=False, verbose_name='Сессия закрыта')), + ('machine', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='shiftflow.machine', verbose_name='Станок')), + ('operator', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL, verbose_name='Оператор')), + ('used_stock_item', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='warehouse.stockitem', verbose_name='Взятый материал')), + ], + options={ + 'verbose_name': 'Сессия раскроя', + 'verbose_name_plural': 'Сессии раскроя', + }, + ), + migrations.CreateModel( + name='MaterialRequirement', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('required_qty', models.FloatField(verbose_name='Нужно докупить')), + ('unit', models.CharField(choices=[('m2', 'м²'), ('mm', 'мм'), ('pcs', 'шт')], default='pcs', max_length=8, verbose_name='Ед. изм.')), + ('status', models.CharField(choices=[('needed', 'К закупке'), ('ordered', 'В пути'), ('fulfilled', 'Обеспечено')], default='needed', max_length=20, verbose_name='Статус')), + ('deal', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='shiftflow.deal', verbose_name='Сделка')), + ('material', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='warehouse.material', verbose_name='Материал')), + ], + options={ + 'verbose_name': 'Потребность', + 'verbose_name_plural': 'Потребности', + }, + ), + migrations.CreateModel( + name='ShiftItem', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('quantity_fact', models.PositiveIntegerField(default=0, verbose_name='Изготовлено (факт), шт')), + ('material_substitution', models.BooleanField(default=False, verbose_name='Замена материала по факту')), + ('session', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tasks', to='shiftflow.cuttingsession')), + ('task', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='shiftflow.productiontask', verbose_name='Плановое задание')), + ], + options={ + 'verbose_name': 'Пункт сессии', + 'verbose_name_plural': 'Пункты сессий', + }, + ), + migrations.CreateModel( + name='DealItem', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('quantity', models.PositiveIntegerField(verbose_name='Заказано, шт')), + ('deal', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='shiftflow.deal', verbose_name='Сделка')), + ('entity', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='manufacturing.productentity', verbose_name='Изделие/деталь')), + ], + options={ + 'verbose_name': 'Позиция сделки', + 'verbose_name_plural': 'Позиции сделки', + 'unique_together': {('deal', 'entity')}, + }, + ), + ] diff --git a/shiftflow/migrations/0016_alter_cuttingsession_options_alter_shiftitem_options_and_more.py b/shiftflow/migrations/0016_alter_cuttingsession_options_alter_shiftitem_options_and_more.py new file mode 100644 index 0000000..39639e4 --- /dev/null +++ b/shiftflow/migrations/0016_alter_cuttingsession_options_alter_shiftitem_options_and_more.py @@ -0,0 +1,77 @@ +# Generated by Django 6.0.3 on 2026-04-05 07:29 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('shiftflow', '0015_machine_location_productiontask_entity_and_more'), + ('warehouse', '0004_location_stockitem_transferrecord'), + ] + + operations = [ + migrations.AlterModelOptions( + name='cuttingsession', + options={'verbose_name': 'Производственный отчет', 'verbose_name_plural': 'Производственные отчеты'}, + ), + migrations.AlterModelOptions( + name='shiftitem', + options={'verbose_name': 'Фиксация выработки', 'verbose_name_plural': 'Фиксации выработки'}, + ), + migrations.AlterField( + model_name='cuttingsession', + name='is_closed', + field=models.BooleanField(default=False, verbose_name='Отчет закрыт'), + ), + migrations.AlterField( + model_name='cuttingsession', + name='used_stock_item', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='warehouse.stockitem', verbose_name='Взятый материал (legacy)'), + ), + migrations.CreateModel( + name='ProductionReportRemnant', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('quantity', models.FloatField(default=1.0, verbose_name='Количество (ед.)')), + ('current_length', models.FloatField(blank=True, null=True, verbose_name='Текущая длина, мм')), + ('current_width', models.FloatField(blank=True, null=True, verbose_name='Текущая ширина, мм')), + ('unique_id', models.CharField(blank=True, max_length=50, null=True, verbose_name='ID/Маркировка')), + ('material', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='warehouse.material', verbose_name='Материал')), + ('report', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='remnants', to='shiftflow.cuttingsession', verbose_name='Производственный отчет')), + ], + options={ + 'verbose_name': 'Деловой остаток', + 'verbose_name_plural': 'Деловые остатки', + }, + ), + migrations.CreateModel( + name='ProductionReportConsumption', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('quantity', models.FloatField(verbose_name='Списано (ед.)')), + ('report', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='consumptions', to='shiftflow.cuttingsession', verbose_name='Производственный отчет')), + ('stock_item', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='warehouse.stockitem', verbose_name='Сырье (позиция склада)')), + ], + options={ + 'verbose_name': 'Списание сырья', + 'verbose_name_plural': 'Списание сырья', + 'unique_together': {('report', 'stock_item')}, + }, + ), + migrations.CreateModel( + name='ProductionReportStockResult', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('kind', models.CharField(choices=[('finished', 'Готовая деталь'), ('remnant', 'Деловой остаток')], max_length=16, verbose_name='Тип')), + ('report', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='results', to='shiftflow.cuttingsession', verbose_name='Производственный отчет')), + ('stock_item', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='warehouse.stockitem', verbose_name='Созданная позиция склада')), + ], + options={ + 'verbose_name': 'Результат отчета', + 'verbose_name_plural': 'Результаты отчета', + 'unique_together': {('report', 'stock_item')}, + }, + ), + ] diff --git a/shiftflow/migrations/0017_alter_machine_location_workshop_machine_workshop.py b/shiftflow/migrations/0017_alter_machine_location_workshop_machine_workshop.py new file mode 100644 index 0000000..20498cb --- /dev/null +++ b/shiftflow/migrations/0017_alter_machine_location_workshop_machine_workshop.py @@ -0,0 +1,37 @@ +# Generated by Django 6.0.3 on 2026-04-05 08:35 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('shiftflow', '0016_alter_cuttingsession_options_alter_shiftitem_options_and_more'), + ('warehouse', '0005_alter_stockitem_options'), + ] + + operations = [ + migrations.AlterField( + model_name='machine', + name='location', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='warehouse.location', verbose_name='Склад участка (устаревает)'), + ), + migrations.CreateModel( + name='Workshop', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=120, unique=True, verbose_name='Цех')), + ('location', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='warehouse.location', verbose_name='Склад цеха')), + ], + options={ + 'verbose_name': 'Цех', + 'verbose_name_plural': 'Цеха', + }, + ), + migrations.AddField( + model_name='machine', + name='workshop', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='shiftflow.workshop', verbose_name='Цех'), + ), + ] diff --git a/shiftflow/migrations/0018_alter_productionreportconsumption_unique_together_and_more.py b/shiftflow/migrations/0018_alter_productionreportconsumption_unique_together_and_more.py new file mode 100644 index 0000000..d850ecc --- /dev/null +++ b/shiftflow/migrations/0018_alter_productionreportconsumption_unique_together_and_more.py @@ -0,0 +1,33 @@ +# Generated by Django 6.0.3 on 2026-04-05 09:19 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('shiftflow', '0017_alter_machine_location_workshop_machine_workshop'), + ('warehouse', '0006_alter_stockitem_options'), + ] + + operations = [ + migrations.AlterUniqueTogether( + name='productionreportconsumption', + unique_together=set(), + ), + migrations.AddField( + model_name='productionreportconsumption', + name='material', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='warehouse.material', verbose_name='Материал'), + ), + migrations.AlterField( + model_name='productionreportconsumption', + name='stock_item', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='warehouse.stockitem', verbose_name='Сырье (позиция склада, legacy)'), + ), + migrations.AlterUniqueTogether( + name='productionreportconsumption', + unique_together={('report', 'material')}, + ), + ] diff --git a/shiftflow/migrations/0019_alter_employeeprofile_role.py b/shiftflow/migrations/0019_alter_employeeprofile_role.py new file mode 100644 index 0000000..f25bd03 --- /dev/null +++ b/shiftflow/migrations/0019_alter_employeeprofile_role.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.3 on 2026-04-06 04:28 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('shiftflow', '0018_alter_productionreportconsumption_unique_together_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='employeeprofile', + name='role', + field=models.CharField(choices=[('admin', 'Администратор'), ('technologist', 'Технолог'), ('master', 'Мастер'), ('operator', 'Оператор'), ('clerk', 'Учетчик'), ('observer', 'Наблюдатель')], default='operator', max_length=20, verbose_name='Должность'), + ), + ] diff --git a/shiftflow/models.py b/shiftflow/models.py index 45a6271..793c883 100644 --- a/shiftflow/models.py +++ b/shiftflow/models.py @@ -15,10 +15,31 @@ class Company(models.Model): class Meta: verbose_name = "Компания"; verbose_name_plural = "Компании" -class Machine(models.Model): +class Workshop(models.Model): + """Цех/участок верхнего уровня. + + Логика доступа к складу: + - оператор и станок работают со складом цеха; + - перемещения между складами (центральный <-> цех <-> следующий цех) выполняются через «Перемещение». """ - Список производственных участков (станков). - Используется для фильтрации сменных заданий для конкретных операторов. + + name = models.CharField('Цех', max_length=120, unique=True) + location = models.ForeignKey('warehouse.Location', on_delete=models.PROTECT, null=True, blank=True, verbose_name='Склад цеха') + + class Meta: + verbose_name = 'Цех' + verbose_name_plural = 'Цеха' + + def __str__(self): + return self.name + + +class Machine(models.Model): + """Список производственных участков (станков). + + Источник склада для операций выработки/списаний: + - предпочитаем склад цеха (Machine.workshop.location) + - поле Machine.location оставлено для совместимости (если цех не задан) """ MACHINE_TYPE_CHOICES = [ @@ -28,6 +49,8 @@ class Machine(models.Model): name = models.CharField("Название станка", max_length=100) machine_type = models.CharField("Тип станка", max_length=10, choices=MACHINE_TYPE_CHOICES, default='linear') + workshop = models.ForeignKey('shiftflow.Workshop', on_delete=models.PROTECT, null=True, blank=True, verbose_name='Цех') + location = models.ForeignKey('warehouse.Location', on_delete=models.PROTECT, null=True, blank=True, verbose_name="Склад участка (устаревает)") def __str__(self): return self.name @@ -59,11 +82,15 @@ class Deal(models.Model): verbose_name = "Сделка"; verbose_name_plural = "Сделки" class ProductionTask(models.Model): + """План производства детали по сделке. + + Переходный этап: + - сейчас в задаче ещё есть legacy-поля (drawing_name, файлы, material), чтобы не сломать UI; + - целевая модель: task.entity -> manufacturing.ProductEntity, а файлы/превью живут на entity. """ - Основание для производства. Определяет ЧТО делать. - Создается технологом или мастером на основе заказа. - """ + deal = models.ForeignKey(Deal, on_delete=models.CASCADE, verbose_name="Сделка") + entity = models.ForeignKey('manufacturing.ProductEntity', on_delete=models.PROTECT, null=True, blank=True, verbose_name="КД (изделие/деталь)") drawing_name = models.CharField("Название детали", max_length=255, blank=True, default="Б/ч") size_value = models.FloatField("Размер детали", help_text="Длина (мм) или Толщина (мм)") @@ -133,6 +160,171 @@ class DxfPreviewSettings(models.Model): return "Настройки превью DXF" +class DealItem(models.Model): + """Состав сделки: что заказал клиент (точка входа для BOM Explosion).""" + + deal = models.ForeignKey(Deal, related_name='items', on_delete=models.CASCADE, verbose_name='Сделка') + entity = models.ForeignKey('manufacturing.ProductEntity', on_delete=models.PROTECT, verbose_name='Изделие/деталь') + quantity = models.PositiveIntegerField('Заказано, шт') + + class Meta: + verbose_name = 'Позиция сделки' + verbose_name_plural = 'Позиции сделки' + unique_together = ('deal', 'entity') + + def __str__(self): + return f"{self.deal.number}: {self.entity} x{self.quantity}" + + +class MaterialRequirement(models.Model): + """Потребность в закупке сырья для сделки. + + required_qty хранит величину в unit: + - для листа: m2 + - для профиля/трубы: mm + + Статус отражает этап обеспечения. + """ + + STATUS_CHOICES = [ + ('needed', 'К закупке'), + ('ordered', 'В пути'), + ('fulfilled', 'Обеспечено'), + ] + + UNIT_CHOICES = [ + ('m2', 'м²'), + ('mm', 'мм'), + ('pcs', 'шт'), + ] + + deal = models.ForeignKey(Deal, on_delete=models.CASCADE, verbose_name='Сделка') + material = models.ForeignKey(WarehouseMaterial, on_delete=models.PROTECT, verbose_name='Материал') + required_qty = models.FloatField('Нужно докупить') + unit = models.CharField('Ед. изм.', max_length=8, choices=UNIT_CHOICES, default='pcs') + status = models.CharField('Статус', max_length=20, choices=STATUS_CHOICES, default='needed') + + class Meta: + verbose_name = 'Потребность' + verbose_name_plural = 'Потребности' + + def __str__(self): + return f"{self.deal.number}: {self.material} -> {self.required_qty} {self.unit}" + + +class CuttingSession(models.Model): + """Производственный отчет (основа для списания/начисления). + + Основная идея документа: + - оператор фиксирует выработку по нескольким плановым заданиям за смену; + - списание сырья на участке может включать несколько позиций (листы/хлысты/куски); + - по итогам могут появляться несколько деловых остатков. + + used_stock_item — legacy-поле для упрощённого случая «списали 1 единицу сырья». + Для реального списания используем ProductionReportConsumption. + """ + + operator = models.ForeignKey(User, on_delete=models.PROTECT, verbose_name='Оператор') + machine = models.ForeignKey(Machine, on_delete=models.PROTECT, verbose_name='Станок') + used_stock_item = models.ForeignKey( + 'warehouse.StockItem', + on_delete=models.PROTECT, + null=True, + blank=True, + verbose_name='Взятый материал (legacy)', + ) + + date = models.DateField('Дата', default=timezone.localdate) + created_at = models.DateTimeField(auto_now_add=True) + is_closed = models.BooleanField('Отчет закрыт', default=False) + + class Meta: + verbose_name = 'Производственный отчет' + verbose_name_plural = 'Производственные отчеты' + + def __str__(self): + return f"{self.date} {self.machine} ({self.operator})" + + +class ProductionReportConsumption(models.Model): + """Строка списания сырья в рамках производственного отчёта. + + Переходная схема: + - целевой ввод делается по номенклатуре (material); + - legacy-поле stock_item оставлено временно, чтобы мигрировать существующие записи. + + После переноса данных stock_item будет удалён, а material станет обязательным. + """ + + report = models.ForeignKey(CuttingSession, related_name='consumptions', on_delete=models.CASCADE, verbose_name='Производственный отчет') + material = models.ForeignKey(WarehouseMaterial, on_delete=models.PROTECT, null=True, blank=True, verbose_name='Материал') + stock_item = models.ForeignKey('warehouse.StockItem', on_delete=models.PROTECT, null=True, blank=True, verbose_name='Сырье (позиция склада, legacy)') + quantity = models.FloatField('Списано (ед.)') + + class Meta: + verbose_name = 'Списание сырья' + verbose_name_plural = 'Списание сырья' + unique_together = ('report', 'material') + + def __str__(self): + return f"{self.report_id}: {self.material} - {self.quantity}" + + +class ProductionReportRemnant(models.Model): + """Деловой остаток, который нужно начислить по итогам производственного отчёта.""" + + report = models.ForeignKey(CuttingSession, related_name='remnants', on_delete=models.CASCADE, verbose_name='Производственный отчет') + material = models.ForeignKey(WarehouseMaterial, on_delete=models.PROTECT, verbose_name='Материал') + quantity = models.FloatField('Количество (ед.)', default=1.0) + current_length = models.FloatField('Текущая длина, мм', null=True, blank=True) + current_width = models.FloatField('Текущая ширина, мм', null=True, blank=True) + unique_id = models.CharField('ID/Маркировка', max_length=50, null=True, blank=True) + + class Meta: + verbose_name = 'Деловой остаток' + verbose_name_plural = 'Деловые остатки' + + def __str__(self): + return f"{self.report_id}: {self.material}" + + +class ProductionReportStockResult(models.Model): + """След созданных складских позиций по отчету (готовые детали и деловые остатки).""" + + KIND_CHOICES = [ + ('finished', 'Готовая деталь'), + ('remnant', 'Деловой остаток'), + ] + + report = models.ForeignKey(CuttingSession, related_name='results', on_delete=models.CASCADE, verbose_name='Производственный отчет') + stock_item = models.ForeignKey('warehouse.StockItem', on_delete=models.PROTECT, verbose_name='Созданная позиция склада') + kind = models.CharField('Тип', max_length=16, choices=KIND_CHOICES) + + class Meta: + verbose_name = 'Результат отчета' + verbose_name_plural = 'Результаты отчета' + unique_together = ('report', 'stock_item') + + def __str__(self): + return f"{self.report_id}: {self.stock_item_id}" + + +class ShiftItem(models.Model): + """Фиксация выработки в рамках производственного отчёта.""" + + session = models.ForeignKey(CuttingSession, related_name='tasks', on_delete=models.CASCADE) + task = models.ForeignKey(ProductionTask, on_delete=models.PROTECT, verbose_name='Плановое задание') + quantity_fact = models.PositiveIntegerField('Изготовлено (факт), шт', default=0) + material_substitution = models.BooleanField('Замена материала по факту', default=False) + + class Meta: + verbose_name = 'Фиксация выработки' + verbose_name_plural = 'Фиксации выработки' + + def __str__(self): + return f"{self.session} -> {self.task}" + + class DxfPreviewJob(models.Model): """Фоновая задача пакетной регенерации превью DXF. @@ -239,6 +431,7 @@ class EmployeeProfile(models.Model): ('master', 'Мастер'), ('operator', 'Оператор'), ('clerk', 'Учетчик'), + ('observer', 'Наблюдатель'), ] # Связь 1 к 1 со стандартным юзером Django diff --git a/shiftflow/services/__init__.py b/shiftflow/services/__init__.py new file mode 100644 index 0000000..31691e8 --- /dev/null +++ b/shiftflow/services/__init__.py @@ -0,0 +1,14 @@ +""" +Сервисный слой приложения shiftflow. + +Здесь живёт бизнес-логика, которую можно вызывать из: +- view (HTTP) +- admin +- management commands +- фоновых воркеров + +Принцип: +- сервисы не зависят от шаблонов/HTML, +- сервисы работают с ORM и транзакциями, +- сервисы содержат правила заводской логики (MES/ERP). +""" \ No newline at end of file diff --git a/shiftflow/services/bom_explosion.py b/shiftflow/services/bom_explosion.py new file mode 100644 index 0000000..33fc2b3 --- /dev/null +++ b/shiftflow/services/bom_explosion.py @@ -0,0 +1,265 @@ +from __future__ import annotations + +from collections import defaultdict +from dataclasses import dataclass + +from django.db import transaction +from django.db.models import Sum +from django.db.models.functions import Coalesce + +from manufacturing.models import BOM, ProductEntity +from shiftflow.models import Deal, DealItem, MaterialRequirement, ProductionTask +from warehouse.models import Location, StockItem + + +@dataclass(frozen=True) +class ExplosionStats: + """ + Сводка результата BOM Explosion. + + tasks_*: + - сколько ProductionTask создано/обновлено (по leaf-деталям) + + req_*: + - сколько MaterialRequirement создано/обновлено (по сырью) + """ + + tasks_created: int + tasks_updated: int + req_created: int + req_updated: int + + +def _category_kind(material_category_name: str) -> str: + """ + Определение типа материала по названию категории. + + Возвращает: + - 'sheet' для листовых материалов + - 'linear' для профилей/труб/круга + - 'unknown' если не удалось определить + """ + s = (material_category_name or "").strip().lower() + + if "лист" in s: + return "sheet" + + if any(k in s for k in ["труба", "проф", "круг", "швел", "угол", "балк", "квадрат"]): + return "linear" + + return "unknown" + + +def _norm_and_unit(entity: ProductEntity) -> tuple[float | None, str]: + """ + Возвращает норму расхода и единицу измерения для MaterialRequirement. + + Логика: + - для листа берём blank_area_m2 (м²/шт) + - для линейного берём blank_length_mm (мм/шт) + + Если категория не распознана, но одна из норм задана — используем заданную. + """ + if not entity.planned_material_id or not getattr(entity.planned_material, "category_id", None): + if entity.blank_area_m2: + return float(entity.blank_area_m2), "m2" + if entity.blank_length_mm: + return float(entity.blank_length_mm), "mm" + return None, "pcs" + + kind = _category_kind(entity.planned_material.category.name) + + if kind == "sheet": + return (float(entity.blank_area_m2) if entity.blank_area_m2 else None), "m2" + + if kind == "linear": + return (float(entity.blank_length_mm) if entity.blank_length_mm else None), "mm" + + if entity.blank_area_m2: + return float(entity.blank_area_m2), "m2" + if entity.blank_length_mm: + return float(entity.blank_length_mm), "mm" + + return None, "pcs" + + +def _build_bom_graph(root_entity_ids: set[int]) -> dict[int, list[tuple[int, int]]]: + """ + Строит граф BOM в памяти для заданного множества root entity. + + Возвращает: + adjacency[parent_id] = [(child_id, qty), ...] + """ + adjacency: dict[int, list[tuple[int, int]]] = defaultdict(list) + + frontier = set(root_entity_ids) + seen = set() + + while frontier: + batch = frontier - seen + if not batch: + break + seen |= batch + + rows = BOM.objects.filter(parent_id__in=batch).values_list("parent_id", "child_id", "quantity") + next_frontier = set() + + for parent_id, child_id, qty in rows: + q = int(qty or 0) + if q <= 0: + continue + adjacency[int(parent_id)].append((int(child_id), q)) + next_frontier.add(int(child_id)) + + frontier |= next_frontier + + return adjacency + + +def _explode_to_leaves( + entity_id: int, + adjacency: dict[int, list[tuple[int, int]]], + memo: dict[int, dict[int, int]], + visiting: set[int], +) -> dict[int, int]: + """ + Возвращает разложение entity_id в leaf-детали в виде: + { leaf_entity_id: multiplier_for_one_unit } + """ + if entity_id in memo: + return memo[entity_id] + + if entity_id in visiting: + raise RuntimeError("Цикл в BOM: спецификация зациклена.") + + visiting.add(entity_id) + + children = adjacency.get(entity_id) or [] + if not children: + memo[entity_id] = {entity_id: 1} + visiting.remove(entity_id) + return memo[entity_id] + + out: dict[int, int] = defaultdict(int) + for child_id, qty in children: + child_map = _explode_to_leaves(child_id, adjacency, memo, visiting) + for leaf_id, leaf_qty in child_map.items(): + out[leaf_id] += int(qty) * int(leaf_qty) + + memo[entity_id] = dict(out) + visiting.remove(entity_id) + return memo[entity_id] + + +@transaction.atomic +def explode_deal( + deal_id: int, + *, + central_location_name: str = "Центральный склад", +) -> ExplosionStats: + """ + BOM Explosion: + - берём состав сделки (DealItem) + - рекурсивно обходим BOM + - считаем суммарное количество leaf-деталей + - создаём/обновляем ProductionTask (deal + entity) + - создаём/обновляем MaterialRequirement по нормам расхода и остаткам на центральном складе + """ + deal = Deal.objects.select_for_update().get(pk=deal_id) + + deal_items = list(DealItem.objects.select_related("entity").filter(deal=deal)) + if not deal_items: + return ExplosionStats(0, 0, 0, 0) + + root_ids = {di.entity_id for di in deal_items} + adjacency = _build_bom_graph(root_ids) + + memo: dict[int, dict[int, int]] = {} + required_leaves: dict[int, int] = defaultdict(int) + + for di in deal_items: + leaf_map = _explode_to_leaves(di.entity_id, adjacency, memo, set()) + for leaf_id, qty_per_unit in leaf_map.items(): + required_leaves[leaf_id] += int(di.quantity) * int(qty_per_unit) + + leaf_entities = { + e.id: e + for e in ProductEntity.objects.select_related("planned_material", "planned_material__category") + .filter(id__in=list(required_leaves.keys())) + } + + tasks_created = 0 + tasks_updated = 0 + + for entity_id, qty in required_leaves.items(): + entity = leaf_entities.get(entity_id) + if not entity: + continue + + pt, created = ProductionTask.objects.get_or_create( + deal=deal, + entity=entity, + defaults={ + "drawing_name": entity.name or "Б/ч", + "size_value": 0, + "material": entity.planned_material, + "quantity_ordered": int(qty), + "is_bend": False, + }, + ) + if created: + tasks_created += 1 + else: + changed = False + if pt.quantity_ordered != int(qty): + pt.quantity_ordered = int(qty) + changed = True + if not pt.material_id and entity.planned_material_id: + pt.material = entity.planned_material + changed = True + if changed: + pt.save(update_fields=["quantity_ordered", "material"]) + tasks_updated += 1 + + central, _ = Location.objects.get_or_create( + name=central_location_name, + defaults={"is_production_area": False}, + ) + + req_created = 0 + req_updated = 0 + + for entity_id, qty_parts in required_leaves.items(): + entity = leaf_entities.get(entity_id) + if not entity or not entity.planned_material_id: + continue + + per_unit, unit = _norm_and_unit(entity) + if not per_unit: + continue + + required_qty = float(qty_parts) * float(per_unit) + + available = ( + StockItem.objects.filter(location=central, material=entity.planned_material) + .aggregate(v=Coalesce(Sum("quantity"), 0.0))["v"] + ) + to_buy = max(0.0, required_qty - float(available or 0.0)) + if to_buy <= 0: + continue + + mr, created = MaterialRequirement.objects.get_or_create( + deal=deal, + material=entity.planned_material, + unit=unit, + defaults={"required_qty": to_buy, "status": "needed"}, + ) + if created: + req_created += 1 + else: + mr.required_qty = to_buy + mr.status = "needed" + mr.save(update_fields=["required_qty", "status"]) + req_updated += 1 + + return ExplosionStats(tasks_created, tasks_updated, req_created, req_updated) \ No newline at end of file diff --git a/shiftflow/services/closing.py b/shiftflow/services/closing.py new file mode 100644 index 0000000..32c9e9a --- /dev/null +++ b/shiftflow/services/closing.py @@ -0,0 +1,120 @@ +from django.db import transaction +from django.utils import timezone + +from shiftflow.models import ( + CuttingSession, + Item, + ProductionReportConsumption, + ProductionReportRemnant, + ShiftItem, +) +from shiftflow.services.sessions import close_cutting_session +from warehouse.models import StockItem + + +@transaction.atomic +def apply_closing( + *, + user_id: int, + machine_id: int, + material_id: int, + item_actions: dict[int, dict], + consumptions: dict[int, float], + remnants: list[dict], +) -> None: + items = list( + Item.objects.select_for_update(of=('self',)) + .select_related('task', 'task__deal', 'task__material', 'machine') + .filter(id__in=list(item_actions.keys()), machine_id=machine_id, status='work', task__material_id=material_id) + ) + if not items: + raise RuntimeError('Не найдено пунктов сменки для закрытия.') + + report = CuttingSession.objects.create( + operator_id=user_id, + machine_id=machine_id, + used_stock_item=None, + date=timezone.localdate(), + is_closed=False, + ) + + for it in items: + spec = item_actions.get(it.id) or {} + action = (spec.get('action') or '').strip() + fact = int(spec.get('fact') or 0) + + if action not in ['done', 'partial']: + continue + + plan = int(it.quantity_plan or 0) + if plan <= 0: + continue + + if action == 'done': + fact = plan + else: + fact = max(0, min(fact, plan)) + if fact <= 0: + raise RuntimeError('При частичном закрытии факт должен быть больше 0.') + + ShiftItem.objects.create(session=report, task=it.task, quantity_fact=fact) + + for stock_item_id, qty in consumptions.items(): + if qty <= 0: + continue + ProductionReportConsumption.objects.create( + report=report, + stock_item_id=stock_item_id, + material_id=None, + quantity=float(qty), + ) + + for r in remnants: + qty = float(r.get('quantity') or 0) + if qty <= 0: + continue + ProductionReportRemnant.objects.create( + report=report, + material_id=material_id, + quantity=qty, + current_length=r.get('current_length'), + current_width=r.get('current_width'), + unique_id=None, + ) + + close_cutting_session(report.id) + + for it in items: + spec = item_actions.get(it.id) or {} + action = (spec.get('action') or '').strip() + fact = int(spec.get('fact') or 0) + + if action not in ['done', 'partial']: + continue + + plan = int(it.quantity_plan or 0) + if plan <= 0: + continue + + if action == 'done': + it.quantity_fact = plan + it.status = 'done' + it.save(update_fields=['quantity_fact', 'status']) + continue + + fact = max(0, min(fact, plan)) + residual = plan - fact + it.quantity_fact = fact + it.status = 'partial' + it.save(update_fields=['quantity_fact', 'status']) + + if residual > 0: + Item.objects.create( + task=it.task, + date=it.date, + machine=it.machine, + quantity_plan=residual, + quantity_fact=0, + status='leftover', + is_synced_1c=False, + ) \ No newline at end of file diff --git a/shiftflow/services/sessions.py b/shiftflow/services/sessions.py new file mode 100644 index 0000000..7842011 --- /dev/null +++ b/shiftflow/services/sessions.py @@ -0,0 +1,191 @@ +from django.db import transaction + +from manufacturing.models import ProductEntity + +from shiftflow.models import ( + CuttingSession, + ProductionReportConsumption, + ProductionReportRemnant, + ProductionReportStockResult, + ShiftItem, +) +from warehouse.models import StockItem + + +@transaction.atomic +def close_cutting_session(session_id: int) -> None: + """ + Закрытие CuttingSession (транзакция склада). + + A) Списать сырьё: + - уменьшаем used_stock_item.quantity на 1 + - если стало 0 -> удаляем + + B) Начислить готовые детали: + - для каждого ShiftItem создаём StockItem(entity=..., location=machine.location, quantity=quantity_fact) + - если использованный материал не совпадает с planned_material КД -> material_substitution=True + """ + session = ( + CuttingSession.objects.select_for_update(of=('self',)) + .select_related( + "machine", + "machine__location", + "machine__workshop", + "machine__workshop__location", + "used_stock_item", + "used_stock_item__material", + ) + .get(pk=session_id) + ) + + if session.is_closed: + return + + work_location = None + if getattr(session.machine, 'workshop_id', None) and getattr(session.machine.workshop, 'location_id', None): + work_location = session.machine.workshop.location + elif session.machine.location_id: + work_location = session.machine.location + + if not work_location: + raise RuntimeError('Не задан склад цеха для станка (Цех -> Склад цеха).') + + consumed_material_ids: set[int] = set() + + consumptions = list( + ProductionReportConsumption.objects.select_related('material', 'stock_item', 'stock_item__material', 'stock_item__location') + .filter(report=session) + ) + + if consumptions: + for c in consumptions: + need = float(c.quantity) + if need <= 0: + continue + + if c.stock_item_id: + si = StockItem.objects.select_for_update(of=('self',)).select_related('material', 'location').get(pk=c.stock_item_id) + if not si.material_id: + raise RuntimeError('В списании сырья указана позиция склада без material.') + + if si.location_id != work_location.id: + raise RuntimeError('Списывать сырьё можно только со склада цеха станка.') + + if need > float(si.quantity): + raise RuntimeError('Недостаточно количества в выбранной складской позиции.') + + si.quantity = float(si.quantity) - need + if si.quantity == 0: + si.delete() + else: + si.save(update_fields=['quantity']) + + consumed_material_ids.add(int(si.material_id)) + continue + + if not c.material_id: + raise RuntimeError('В списании сырья не указан материал.') + + consumed_material_ids.add(int(c.material_id)) + + qs = ( + StockItem.objects.select_for_update(of=('self',)) + .select_related('material', 'location') + .filter(location=work_location, material_id=c.material_id, entity__isnull=True) + .order_by('id') + ) + + for si in qs: + if need <= 0: + break + + take = min(float(si.quantity), need) + si.quantity = float(si.quantity) - take + need -= take + + if si.quantity == 0: + si.delete() + else: + si.save(update_fields=['quantity']) + + if need > 0: + raise RuntimeError('Недостаточно сырья на складе цеха станка для списания.') + else: + if not session.used_stock_item_id: + raise RuntimeError('Не заполнено списание сырья: добавь строки «Списание сырья» или укажи legacy поле «Взятый материал».') + + used = StockItem.objects.select_for_update(of=('self',)).select_related('material', 'location').get(pk=session.used_stock_item_id) + if not used.material_id: + raise RuntimeError('Взятый материал должен ссылаться на сырьё (material), а не на готовую деталь (entity).') + + if used.location_id != work_location.id: + raise RuntimeError('Списывать сырьё можно только со склада цеха станка.') + + used.quantity = float(used.quantity) - 1.0 + if used.quantity < 0: + raise RuntimeError('Недостаточно сырья для списания.') + + if used.quantity == 0: + used.delete() + else: + used.save(update_fields=['quantity']) + + consumed_material_ids.add(int(used.material_id)) + + items = list( + ShiftItem.objects.select_related("task", "task__entity", "task__entity__planned_material", "task__material") + .filter(session=session) + ) + + for it in items: + if it.quantity_fact <= 0: + continue + + task = it.task + planned_material = None + + if task.entity_id and getattr(task.entity, 'planned_material_id', None): + planned_material = task.entity.planned_material + elif getattr(task, 'material_id', None): + planned_material = task.material + + if planned_material and consumed_material_ids: + it.material_substitution = planned_material.id not in consumed_material_ids + else: + it.material_substitution = False + it.save(update_fields=['material_substitution']) + + if not task.entity_id: + name = (getattr(task, 'drawing_name', '') or '').strip() or 'Без названия' + pe = ProductEntity.objects.create( + name=name[:255], + drawing_number=f"AUTO-{task.id}", + entity_type='part', + planned_material=planned_material, + ) + task.entity = pe + task.save(update_fields=['entity']) + + created = StockItem.objects.create( + entity=task.entity, + deal_id=getattr(task, 'deal_id', None), + location=work_location, + quantity=float(it.quantity_fact), + ) + ProductionReportStockResult.objects.create(report=session, stock_item=created, kind='finished') + + remnants = list(ProductionReportRemnant.objects.filter(report=session).select_related('material')) + for r in remnants: + created = StockItem.objects.create( + material=r.material, + location=work_location, + quantity=float(r.quantity), + is_remnant=True, + current_length=r.current_length, + current_width=r.current_width, + unique_id=r.unique_id, + ) + ProductionReportStockResult.objects.create(report=session, stock_item=created, kind='remnant') + + session.is_closed = True + session.save(update_fields=["is_closed"]) \ No newline at end of file diff --git a/shiftflow/templates/shiftflow/closing.html b/shiftflow/templates/shiftflow/closing.html new file mode 100644 index 0000000..bd299ac --- /dev/null +++ b/shiftflow/templates/shiftflow/closing.html @@ -0,0 +1,270 @@ +{% extends 'base.html' %} + +{% block content %} +
+
+
+
+ + +
+ +
+ + +
+ + +
+
+
+ +
+ {% csrf_token %} + + + +
+
+

Закрытие

+
+ +
+ + + + + + + + + + + + + {% for it in items %} + + + + + + + + + {% empty %} + + {% endfor %} + +
ДатаСделкаДетальПланФактРежим
{{ it.date|date:"d.m.Y" }}{{ it.task.deal.number }}{{ it.task.drawing_name }}{{ it.quantity_plan }} + + +
+ + + + +
+
Выбери станок и материал
+
+
+ +
+
+
Списание со склада цеха (единицы)
+
+
+ + + + + + + + + + + {% for s in stock_items %} + + + + + + + {% empty %} + + {% endfor %} + +
ПоступлениеЕдиницаДоступноИспользовано
{% if s.created_at %}{{ s.created_at|date:"d.m.Y H:i" }}{% endif %}{{ s }}{{ s.quantity }} + +
Нет единиц на складе для выбранного материала
+
+
+ +
+
+
Остаток ДО
+ +
+
+ + + + + + + + + + + + + + +
Кол-воДлина (мм)Ширина (мм)
ДО не добавлены
+
+
+ +
+ +
+
+ + + +{% endblock %} \ No newline at end of file diff --git a/shiftflow/templates/shiftflow/item_detail.html b/shiftflow/templates/shiftflow/item_detail.html index 43c744f..0042d4c 100644 --- a/shiftflow/templates/shiftflow/item_detail.html +++ b/shiftflow/templates/shiftflow/item_detail.html @@ -134,39 +134,11 @@ -
- - -
-
- - -
-
- - -
{% else %}
Статус: {{ item.get_status_display }}. Сделано: {{ item.quantity_fact }} шт.
- {% if user_role == 'master' %} -
-
- - -
-
- - -
-
- - -
-
- {% endif %} {% endif %} {% endif %} @@ -202,18 +174,6 @@ -
- - -
-
- - -
-
- - -
@@ -223,28 +183,13 @@ {% endif %} {% if user_role == 'clerk' %} -
-
- Взятый материал - {{ item.material_taken|default:"-" }} -
-
- Остаток ДО - {{ item.usable_waste|default:"-" }} -
-
- Лом (кг) - {{ item.scrap_weight }} -
-
- {% if item.status == 'done' or item.status == 'partial' %}
{% else %} -
Списание будет доступно после закрытия (Выполнено/Частично).
+
Списание будет доступно после закрытия.
{% endif %} {% endif %} @@ -253,7 +198,7 @@ Назад
- {% if item.status == 'work' %} + {% if item.status == 'work' and user_role == 'admin' %} diff --git a/shiftflow/templates/shiftflow/warehouse_stocks.html b/shiftflow/templates/shiftflow/warehouse_stocks.html new file mode 100644 index 0000000..e30801e --- /dev/null +++ b/shiftflow/templates/shiftflow/warehouse_stocks.html @@ -0,0 +1,560 @@ +{% extends 'base.html' %} + +{% block content %} +
+
+
+ + +
+
Склады:
+
+
+ + +
+ {% for loc in locations %} +
+ + +
+ {% endfor %} +
+
+ +
+
Тип:
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+
Период:
+
+ + +
+
+ +
+ + + +
+
+
+
+ +
+
+

Склады

+ +
+ {% if can_receive %} + + {% endif %} + +
+ + + + + + + +
+
+
+ +
+ + + + + + + + + + + + + + + + {% for it in items %} + + + + + + + + + + + + {% empty %} + + {% endfor %} + +
СкладПоступлениеСделкаНаименованиеТипКол-воЕд. измеренияДОДействия
{{ it.location }}{% if it.created_at %}{{ it.created_at|date:"d.m.Y H:i" }}{% endif %} + {% if it.deal_id %} + {{ it.deal.number }} + {% else %} + — + {% endif %} + + {% if it.material_id %} + {{ it.material.full_name }} + {% elif it.entity_id %} + {{ it.entity }} + {% else %} + — + {% endif %} + {% if it.unique_id %} +
{{ it.unique_id }}
+ {% endif %} +
+ {% if it.entity_id %} + Изделие/деталь + {% elif it.is_remnant %} + ДО + {% else %} + Сырьё + {% endif %} + {{ it.quantity }} + {% if it.entity_id %} + шт + {% elif it.material_id and it.material.category_id %} + {% with ff=it.material.category.form_factor|stringformat:"s"|lower %} + {% if ff == 'лист' or ff == 'sheet' %}лист + {% elif ff == 'прокат' or ff == 'rolled' or ff == 'roll' %}прокат + {% else %}ед. + {% endif %} + {% endwith %} + {% else %} + ед. + {% endif %} + + {% if it.is_remnant %}Да{% else %}—{% endif %} + + {% if can_transfer %} +
+ + + +
+ {% else %} + только просмотр + {% endif %} +
Нет позиций по текущим фильтрам
+
+
+ + + + + + +{% endblock %} \ No newline at end of file diff --git a/shiftflow/urls.py b/shiftflow/urls.py index 9a23ad2..1457631 100644 --- a/shiftflow/urls.py +++ b/shiftflow/urls.py @@ -20,6 +20,10 @@ from .views import ( RegistryView, SteelGradeUpsertView, TaskItemsView, + ClosingView, + WarehouseReceiptCreateView, + WarehouseStocksView, + WarehouseTransferCreateView, ) urlpatterns = [ @@ -48,4 +52,10 @@ urlpatterns = [ # Печать сменного листа path('registry/print/', RegistryPrintView.as_view(), name='registry_print'), path('item//', ItemUpdateView.as_view(), name='item_detail'), + + path('warehouse/stocks/', WarehouseStocksView.as_view(), name='warehouse_stocks'), + path('warehouse/transfer/', WarehouseTransferCreateView.as_view(), name='warehouse_transfer'), + path('warehouse/receipt/', WarehouseReceiptCreateView.as_view(), name='warehouse_receipt'), + + path('closing/', ClosingView.as_view(), name='closing'), ] \ No newline at end of file diff --git a/shiftflow/views.py b/shiftflow/views.py index 46c5df6..fd412fc 100644 --- a/shiftflow/views.py +++ b/shiftflow/views.py @@ -14,6 +14,7 @@ from django.core.files.base import ContentFile from django.db import close_old_connections from django.db.models import Case, ExpressionWrapper, F, IntegerField, Sum, Value, When +from django.db.models import Q from django.db.models.functions import Coalesce from django.http import JsonResponse from django.shortcuts import get_object_or_404, redirect @@ -23,7 +24,12 @@ from django.views.generic import FormView, ListView, TemplateView, UpdateView from django.contrib.auth.mixins import LoginRequiredMixin from django.utils import timezone -from warehouse.models import Material, MaterialCategory, SteelGrade +from manufacturing.models import ProductEntity + +from warehouse.models import Location, Material, MaterialCategory, SteelGrade, StockItem, TransferLine, TransferRecord +from warehouse.services.transfers import receive_transfer + +from shiftflow.services.closing import apply_closing from .forms import ProductionTaskCreateForm from .models import Company, Deal, DxfPreviewJob, DxfPreviewSettings, EmployeeProfile, Item, Machine, ProductionTask @@ -1193,9 +1199,11 @@ class ItemUpdateView(LoginRequiredMixin, UpdateView): template_name = 'shiftflow/item_detail.html' # Перечисляем поля, которые можно редактировать в сменке fields = [ - 'machine', 'quantity_plan', 'quantity_fact', - 'status', 'is_synced_1c', - 'material_taken', 'usable_waste', 'scrap_weight' + 'machine', + 'quantity_plan', + 'quantity_fact', + 'status', + 'is_synced_1c', ] context_object_name = 'item' @@ -1296,15 +1304,6 @@ class ItemUpdateView(LoginRequiredMixin, UpdateView): self.object.quantity_fact = int(quantity_fact) self.object.is_synced_1c = bool(request.POST.get('is_synced_1c')) - 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) - - scrap_weight = request.POST.get('scrap_weight') - if scrap_weight is not None and scrap_weight != '': - try: - self.object.scrap_weight = float(scrap_weight) - except ValueError: - pass # Действия закрытия для админа/технолога if action == 'close_done' and self.object.status == 'work': @@ -1340,88 +1339,26 @@ class ItemUpdateView(LoginRequiredMixin, UpdateView): if role in ['operator', 'master']: action = request.POST.get('action', 'save') - material_taken = (request.POST.get('material_taken') or '').strip() - usable_waste = (request.POST.get('usable_waste') or '').strip() - scrap_weight_raw = (request.POST.get('scrap_weight') or '').strip() - if action == 'save': - qf = request.POST.get('quantity_fact') - if qf and qf.isdigit(): - self.object.quantity_fact = int(qf) - machine_changed = False - if role == 'master': - machine_id = request.POST.get('machine') - if machine_id and machine_id.isdigit(): - self.object.machine_id = int(machine_id) - machine_changed = True - fields = ['quantity_fact'] - if machine_changed: - fields.append('machine') - self.object.save(update_fields=fields) + if action != 'save': return redirect_back() - if self.object.status != 'work': - return redirect_back() + qf = request.POST.get('quantity_fact') + if qf and qf.isdigit(): + self.object.quantity_fact = int(qf) - errors = [] - if not material_taken: - errors.append('Заполни поле "Взятый материал"') - if not usable_waste: - errors.append('Заполни поле "Остаток ДО"') - if scrap_weight_raw == '': - errors.append('Заполни поле "Лом (кг)" (можно 0)') + machine_changed = False + if role == 'master': + machine_id = request.POST.get('machine') + if machine_id and machine_id.isdigit(): + self.object.machine_id = int(machine_id) + machine_changed = True - scrap_weight = None - if scrap_weight_raw != '': - try: - scrap_weight = float(scrap_weight_raw) - except ValueError: - errors.append('Поле "Лом (кг)" должно быть числом') - - if errors: - context = self.get_context_data() - context['errors'] = errors - return self.render_to_response(context) - - self.object.material_taken = material_taken - self.object.usable_waste = usable_waste - if scrap_weight is not None: - self.object.scrap_weight = scrap_weight - - if action == 'close_done': - self.object.quantity_fact = self.object.quantity_plan - self.object.status = 'done' - self.object.save() - return redirect_back() - - if action == 'close_partial': - try: - fact = int(request.POST.get('quantity_fact', '0')) - except ValueError: - fact = 0 - if fact <= 0: - context = self.get_context_data() - context['errors'] = ['При частичном закрытии укажи, сколько сделано (больше 0)'] - return self.render_to_response(context) - 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, - ) - return redirect_back() + fields = ['quantity_fact'] + if machine_changed: + fields.append('machine') + self.object.save(update_fields=fields) return redirect_back() if role == 'clerk': @@ -1434,4 +1371,434 @@ class ItemUpdateView(LoginRequiredMixin, UpdateView): return redirect_back() def get_success_url(self): - return reverse_lazy('registry') \ No newline at end of file + return reverse_lazy('registry') + + +class WarehouseStocksView(LoginRequiredMixin, TemplateView): + template_name = 'shiftflow/warehouse_stocks.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', 'master', 'clerk', '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 + + ship_loc = ( + Location.objects.filter( + Q(name__icontains='отгруж') + | Q(name__icontains='Отгруж') + | Q(name__icontains='отгруз') + | Q(name__icontains='Отгруз') + ) + .order_by('id') + .first() + ) + ship_loc_id = ship_loc.id if ship_loc else None + + locations_qs = Location.objects.all().order_by('name') + if ship_loc_id: + locations_qs = locations_qs.exclude(id=ship_loc_id) + locations = list(locations_qs) + ctx['locations'] = locations + + q = (self.request.GET.get('q') or '').strip() + location_id = (self.request.GET.get('location_id') or '').strip() + kind = (self.request.GET.get('kind') or '').strip() + + start_date = (self.request.GET.get('start_date') or '').strip() + end_date = (self.request.GET.get('end_date') or '').strip() + filtered = self.request.GET.get('filtered') + reset = self.request.GET.get('reset') + is_default = (not filtered) or bool(reset) + + if is_default: + today = timezone.localdate() + start = today - timezone.timedelta(days=21) + ctx['start_date'] = start.strftime('%Y-%m-%d') + ctx['end_date'] = today.strftime('%Y-%m-%d') + else: + ctx['start_date'] = start_date + ctx['end_date'] = end_date + + qs = StockItem.objects.select_related('location', 'material', 'material__category', 'entity', 'deal').all() + if ship_loc_id: + qs = qs.exclude(location_id=ship_loc_id) + + if location_id.isdigit(): + qs = qs.filter(location_id=int(location_id)) + + start_val = ctx.get('start_date') + end_val = ctx.get('end_date') + if start_val: + qs = qs.filter(created_at__date__gte=start_val) + if end_val: + qs = qs.filter(created_at__date__lte=end_val) + + if kind == 'raw': + qs = qs.filter(material__isnull=False, entity__isnull=True) + elif kind == 'finished': + qs = qs.filter(entity__isnull=False) + elif kind == 'remnant': + qs = qs.filter(is_remnant=True) + + if q: + qs = qs.filter( + Q(material__full_name__icontains=q) + | Q(material__name__icontains=q) + | Q(entity__name__icontains=q) + | Q(entity__drawing_number__icontains=q) + | Q(unique_id__icontains=q) + | Q(location__name__icontains=q) + ) + + ctx['items'] = qs.order_by('-created_at', '-id') + + ctx['selected_location_id'] = location_id + ctx['selected_kind'] = kind + ctx['q'] = q + + ctx['can_transfer'] = role in ['admin', 'technologist', 'master', 'clerk'] + ctx['can_receive'] = role in ['admin', 'technologist', 'master', 'clerk'] + + ctx['materials'] = Material.objects.select_related('category').all().order_by('full_name') + ctx['entities'] = ProductEntity.objects.all().order_by('drawing_number', 'name') + ctx['deals'] = Deal.objects.select_related('company').all().order_by('-id') + + ctx['shipping_location_id'] = ship_loc_id or '' + ctx['shipping_location_label'] = ship_loc.name if ship_loc else '' + + return ctx + + +class WarehouseTransferCreateView(LoginRequiredMixin, View): + def post(self, request, *args, **kwargs): + profile = getattr(request.user, 'profile', None) + role = profile.role if profile else ('admin' if request.user.is_superuser else 'operator') + if role not in ['admin', 'technologist', 'master', 'clerk']: + return JsonResponse({'error': 'forbidden'}, status=403) + + stock_item_id = (request.POST.get('stock_item_id') or '').strip() + to_location_id = (request.POST.get('to_location_id') or '').strip() + qty_raw = (request.POST.get('quantity') or '').strip().replace(',', '.') + + next_url = (request.POST.get('next') or '').strip() + if not next_url.startswith('/'): + next_url = reverse_lazy('warehouse_stocks') + + if not (stock_item_id.isdigit() and to_location_id.isdigit()): + messages.error(request, 'Заполни корректно: позиция склада и склад назначения.') + return redirect(next_url) + + try: + qty = float(qty_raw) + except ValueError: + qty = 0.0 + + if qty <= 0: + messages.error(request, 'Количество должно быть больше 0.') + return redirect(next_url) + + si = get_object_or_404(StockItem.objects.select_related('location'), pk=int(stock_item_id)) + if int(to_location_id) == si.location_id: + messages.error(request, 'Склад назначения должен отличаться от склада-источника.') + return redirect(next_url) + + tr = TransferRecord.objects.create( + from_location_id=si.location_id, + to_location_id=int(to_location_id), + sender=request.user, + receiver=request.user, + occurred_at=timezone.now(), + status='received', + received_at=timezone.now(), + is_applied=False, + ) + TransferLine.objects.create(transfer=tr, stock_item=si, quantity=qty) + + try: + receive_transfer(tr.id, request.user.id) + messages.success(request, 'Операция применена.') + except Exception as e: + messages.error(request, f'Ошибка: {e}') + + return redirect(next_url) + + +class WarehouseReceiptCreateView(LoginRequiredMixin, View): + def post(self, request, *args, **kwargs): + profile = getattr(request.user, 'profile', None) + role = profile.role if profile else ('admin' if request.user.is_superuser else 'operator') + if role not in ['admin', 'technologist', 'master', 'clerk']: + return JsonResponse({'error': 'forbidden'}, status=403) + + next_url = (request.POST.get('next') or '').strip() + if not next_url.startswith('/'): + next_url = reverse_lazy('warehouse_stocks') + + kind = (request.POST.get('kind') or '').strip() + location_id = (request.POST.get('location_id') or '').strip() + deal_id = (request.POST.get('deal_id') or '').strip() + quantity_raw = (request.POST.get('quantity') or '').strip().replace(',', '.') + + if not location_id.isdigit(): + messages.error(request, 'Выбери склад.') + return redirect(next_url) + + try: + qty = float(quantity_raw) + except ValueError: + qty = 0.0 + + if qty <= 0: + messages.error(request, 'Количество должно быть больше 0.') + return redirect(next_url) + + if kind == 'raw': + material_id = (request.POST.get('material_id') or '').strip() + is_customer_supplied = bool(request.POST.get('is_customer_supplied')) + + if not material_id.isdigit(): + messages.error(request, 'Выбери материал.') + return redirect(next_url) + + length_raw = (request.POST.get('current_length') or '').strip().replace(',', '.') + width_raw = (request.POST.get('current_width') or '').strip().replace(',', '.') + + current_length = None + current_width = None + + if length_raw: + try: + current_length = float(length_raw) + except ValueError: + current_length = None + + if width_raw: + try: + current_width = float(width_raw) + except ValueError: + current_width = None + + obj = StockItem( + material_id=int(material_id), + location_id=int(location_id), + deal_id=(int(deal_id) if deal_id.isdigit() else None), + quantity=float(qty), + is_customer_supplied=is_customer_supplied, + current_length=current_length, + current_width=current_width, + ) + + try: + obj.full_clean() + obj.save() + messages.success(request, 'Приход сырья добавлен.') + except Exception as e: + messages.error(request, f'Ошибка прихода: {e}') + + return redirect(next_url) + + if kind == 'entity': + entity_id = (request.POST.get('entity_id') or '').strip() + if not entity_id.isdigit(): + messages.error(request, 'Выбери КД (изделие/деталь).') + return redirect(next_url) + + obj = StockItem( + entity_id=int(entity_id), + location_id=int(location_id), + deal_id=(int(deal_id) if deal_id.isdigit() else None), + quantity=float(qty), + ) + + try: + obj.full_clean() + obj.save() + messages.success(request, 'Приход изделия добавлен.') + except Exception as e: + messages.error(request, f'Ошибка прихода: {e}') + + return redirect(next_url) + + messages.error(request, 'Выбери тип прихода.') + return redirect(next_url) + + +class ClosingView(LoginRequiredMixin, TemplateView): + template_name = 'shiftflow/closing.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', 'master', 'operator', '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 + + if role == 'operator' and profile: + machines = list(profile.machines.all().order_by('name')) + else: + machines = list(Machine.objects.all().order_by('name')) + + ctx['machines'] = machines + ctx['materials'] = list(Material.objects.select_related('category').order_by('full_name')) + + machine_id = (self.request.GET.get('machine_id') or '').strip() + material_id = (self.request.GET.get('material_id') or '').strip() + + ctx['selected_machine_id'] = machine_id + ctx['selected_material_id'] = material_id + + items = [] + stock_items = [] + + if machine_id.isdigit() and material_id.isdigit(): + items = list( + Item.objects.select_related('task', 'task__deal', 'task__material', 'machine') + .filter(machine_id=int(machine_id), status='work', task__material_id=int(material_id)) + .order_by('date', 'task__deal__number', 'task__drawing_name') + ) + + machine = Machine.objects.select_related('workshop', 'workshop__location', 'location').filter(pk=int(machine_id)).first() + work_location_id = None + if machine and getattr(machine, 'workshop_id', None) and getattr(machine.workshop, 'location_id', None): + work_location_id = machine.workshop.location_id + elif machine and getattr(machine, 'location_id', None): + work_location_id = machine.location_id + + if work_location_id: + stock_items = list( + StockItem.objects.select_related('location', 'material') + .filter(location_id=work_location_id, material_id=int(material_id), entity__isnull=True) + .order_by('created_at', 'id') + ) + + ctx['items'] = items + ctx['stock_items'] = stock_items + ctx['can_edit'] = role in ['admin', 'master', 'operator'] + 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', 'master', 'operator']: + return redirect('closing') + + machine_id = (request.POST.get('machine_id') or '').strip() + material_id = (request.POST.get('material_id') or '').strip() + + if not (machine_id.isdigit() and material_id.isdigit()): + messages.error(request, 'Выбери станок и материал.') + return redirect('closing') + + item_actions = {} + for k, v in request.POST.items(): + if not k.startswith('close_action_'): + continue + item_id = k.replace('close_action_', '') + if not item_id.isdigit(): + continue + action = (v or '').strip() + if action not in ['done', 'partial']: + continue + fact_raw = (request.POST.get(f'fact_{item_id}') or '').strip() + try: + fact = int(fact_raw) + except ValueError: + fact = 0 + item_actions[int(item_id)] = {'action': action, 'fact': fact} + + consumptions = {} + for k, v in request.POST.items(): + if not k.startswith('consume_'): + continue + sid = k.replace('consume_', '') + if not sid.isdigit(): + continue + raw = (v or '').strip().replace(',', '.') + if not raw: + continue + try: + qty = float(raw) + except ValueError: + qty = 0.0 + if qty > 0: + consumptions[int(sid)] = qty + + remnants = [] + idx = 0 + while True: + has_any = ( + f'remnant_qty_{idx}' in request.POST + or f'remnant_len_{idx}' in request.POST + or f'remnant_wid_{idx}' in request.POST + ) + if not has_any: + break + + qty_raw = (request.POST.get(f'remnant_qty_{idx}') or '').strip().replace(',', '.') + len_raw = (request.POST.get(f'remnant_len_{idx}') or '').strip().replace(',', '.') + wid_raw = (request.POST.get(f'remnant_wid_{idx}') or '').strip().replace(',', '.') + + if qty_raw: + try: + rq = float(qty_raw) + except ValueError: + rq = 0.0 + + if rq > 0: + rl = None + rw = None + + if len_raw: + try: + rl = float(len_raw) + except ValueError: + rl = None + + if wid_raw: + try: + rw = float(wid_raw) + except ValueError: + rw = None + + remnants.append({'quantity': rq, 'current_length': rl, 'current_width': rw}) + + idx += 1 + if idx > 200: + break + + if not item_actions: + messages.error(request, 'Выбери хотя бы один пункт сменки и режим закрытия (полностью/частично).') + return redirect(f"{reverse_lazy('closing')}?machine_id={machine_id}&material_id={material_id}") + + if not consumptions: + messages.error(request, 'Заполни списание: укажи, какие единицы на складе использованы и в каком количестве.') + return redirect(f"{reverse_lazy('closing')}?machine_id={machine_id}&material_id={material_id}") + + try: + apply_closing( + user_id=request.user.id, + machine_id=int(machine_id), + material_id=int(material_id), + item_actions=item_actions, + consumptions=consumptions, + remnants=remnants, + ) + messages.success(request, 'Закрытие выполнено.') + except Exception as e: + messages.error(request, f'Ошибка закрытия: {e}') + + return redirect(f"{reverse_lazy('closing')}?machine_id={machine_id}&material_id={material_id}") \ No newline at end of file diff --git a/templates/base.html b/templates/base.html index cd56d9f..fb1a258 100644 --- a/templates/base.html +++ b/templates/base.html @@ -17,6 +17,14 @@ {% endif %}
+ {% if messages %} +
+ {% for message in messages %} + + {% endfor %} +
+ {% endif %} + {% block content %}{% endblock %}
diff --git a/templates/components/_navbar.html b/templates/components/_navbar.html index 9f1eba5..068e55c 100644 --- a/templates/components/_navbar.html +++ b/templates/components/_navbar.html @@ -15,22 +15,24 @@ Реестр - {% if user_role in 'admin,technologist,master,clerk' %} + {% if user_role in 'admin,technologist,master,clerk,observer' %} + {% endif %} - {% if user_role in 'admin,technologist,master,operator' %} - + {% if user_role in 'admin,master,operator,observer' %} + {% endif %} - {% if user_role in 'admin,technologist,clerk' %} - - {% endif %} {% if user_role == 'admin' %}