This commit is contained in:
65
.trae/rules/main.md
Normal file
65
.trae/rules/main.md
Normal file
@@ -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 там, где это безопасно.
|
||||
65
AI_RULES.md
Normal file
65
AI_RULES.md
Normal file
@@ -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 там, где это безопасно.
|
||||
7
TODO.md
Normal file
7
TODO.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# TODO (MES_Core)
|
||||
|
||||
## Склады (UI)
|
||||
- Доработать сортировку по дате «Поступление» (стабильно сортировать как datetime, а не как текст).
|
||||
- По клику на строку открывать карточку «Единица на складе» (read-only для observer, редактирование для admin/technologist/master/clerk):
|
||||
- правка: сделка, давальческий, размеры (лист/хлыст), количество, примечание (если добавим)
|
||||
- отображение: история перемещений/приходов/отгрузок (если потребуется).
|
||||
@@ -59,8 +59,9 @@ INSTALLED_APPS = [
|
||||
'django.contrib.sessions',
|
||||
'django.contrib.messages',
|
||||
'django.contrib.staticfiles',
|
||||
'shiftflow', # Вот это допиши обязательно!
|
||||
'shiftflow',
|
||||
'warehouse',
|
||||
'manufacturing',
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
|
||||
50
exempl/manufacturing/models.py
Normal file
50
exempl/manufacturing/models.py
Normal file
@@ -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)"
|
||||
90
exempl/shiftflow/models.py
Normal file
90
exempl/shiftflow/models.py
Normal file
@@ -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)
|
||||
104
exempl/warehouse/models.py
Normal file
104
exempl/warehouse/models.py
Normal file
@@ -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 = "Перемещения"
|
||||
0
manufacturing/__init__.py
Normal file
0
manufacturing/__init__.py
Normal file
43
manufacturing/admin.py
Normal file
43
manufacturing/admin.py
Normal file
@@ -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')
|
||||
7
manufacturing/apps.py
Normal file
7
manufacturing/apps.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ManufacturingConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'manufacturing'
|
||||
verbose_name = 'Производство (КД/BOM)'
|
||||
61
manufacturing/migrations/0001_initial.py
Normal file
61
manufacturing/migrations/0001_initial.py
Normal file
@@ -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')},
|
||||
},
|
||||
),
|
||||
]
|
||||
0
manufacturing/migrations/__init__.py
Normal file
0
manufacturing/migrations/__init__.py
Normal file
97
manufacturing/models.py
Normal file
97
manufacturing/models.py
Normal file
@@ -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.
|
||||
3
manufacturing/tests.py
Normal file
3
manufacturing/tests.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
3
manufacturing/views.py
Normal file
3
manufacturing/views.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.shortcuts import render
|
||||
|
||||
# Create your views here.
|
||||
@@ -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',) # Красивый выбор станков двумя колонками
|
||||
filter_horizontal = ('machines',)
|
||||
29
shiftflow/management/commands/shiftflow_explode_deal.py
Normal file
29
shiftflow/management/commands/shiftflow_explode_deal.py
Normal file
@@ -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-деталях.')
|
||||
@@ -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')},
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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')},
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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='Цех'),
|
||||
),
|
||||
]
|
||||
@@ -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')},
|
||||
),
|
||||
]
|
||||
18
shiftflow/migrations/0019_alter_employeeprofile_role.py
Normal file
18
shiftflow/migrations/0019_alter_employeeprofile_role.py
Normal file
@@ -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='Должность'),
|
||||
),
|
||||
]
|
||||
@@ -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
|
||||
|
||||
14
shiftflow/services/__init__.py
Normal file
14
shiftflow/services/__init__.py
Normal file
@@ -0,0 +1,14 @@
|
||||
"""
|
||||
Сервисный слой приложения shiftflow.
|
||||
|
||||
Здесь живёт бизнес-логика, которую можно вызывать из:
|
||||
- view (HTTP)
|
||||
- admin
|
||||
- management commands
|
||||
- фоновых воркеров
|
||||
|
||||
Принцип:
|
||||
- сервисы не зависят от шаблонов/HTML,
|
||||
- сервисы работают с ORM и транзакциями,
|
||||
- сервисы содержат правила заводской логики (MES/ERP).
|
||||
"""
|
||||
265
shiftflow/services/bom_explosion.py
Normal file
265
shiftflow/services/bom_explosion.py
Normal file
@@ -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)
|
||||
120
shiftflow/services/closing.py
Normal file
120
shiftflow/services/closing.py
Normal file
@@ -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,
|
||||
)
|
||||
191
shiftflow/services/sessions.py
Normal file
191
shiftflow/services/sessions.py
Normal file
@@ -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"])
|
||||
270
shiftflow/templates/shiftflow/closing.html
Normal file
270
shiftflow/templates/shiftflow/closing.html
Normal file
@@ -0,0 +1,270 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card border-secondary mb-3 shadow-sm">
|
||||
<div class="card-body py-2">
|
||||
<form method="get" class="row g-2 align-items-center">
|
||||
<div class="col-md-4">
|
||||
<label class="small text-muted mb-1 fw-bold">Станок:</label>
|
||||
<select class="form-select form-select-sm bg-body text-body border-secondary" name="machine_id" onchange="this.form.submit()">
|
||||
<option value="">— выбрать —</option>
|
||||
{% for m in machines %}
|
||||
<option value="{{ m.id }}" {% if selected_machine_id == m.id|stringformat:"s" %}selected{% endif %}>{{ m.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<label class="small text-muted mb-1 fw-bold">Материал:</label>
|
||||
<select class="form-select form-select-sm bg-body text-body border-secondary" name="material_id" onchange="this.form.submit()">
|
||||
<option value="">— выбрать —</option>
|
||||
{% for mat in materials %}
|
||||
<option value="{{ mat.id }}" {% if selected_material_id == mat.id|stringformat:"s" %}selected{% endif %}>{{ mat.full_name|default:mat.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-md-2 text-end mt-auto">
|
||||
<a class="btn btn-outline-secondary btn-sm w-100" href="{% url 'closing' %}">Сброс</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="machine_id" value="{{ selected_machine_id }}">
|
||||
<input type="hidden" name="material_id" value="{{ selected_material_id }}">
|
||||
|
||||
<div class="card shadow border-secondary mb-3">
|
||||
<div class="card-header border-secondary py-3 d-flex justify-content-between align-items-center">
|
||||
<h3 class="text-accent mb-0"><i class="bi bi-check2-square me-2"></i>Закрытие</h3>
|
||||
</div>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0 align-middle">
|
||||
<thead>
|
||||
<tr class="table-custom-header">
|
||||
<th>Дата</th>
|
||||
<th>Сделка</th>
|
||||
<th>Деталь</th>
|
||||
<th>План</th>
|
||||
<th data-sort="false">Факт</th>
|
||||
<th data-sort="false">Режим</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for it in items %}
|
||||
<tr>
|
||||
<td class="small">{{ it.date|date:"d.m.Y" }}</td>
|
||||
<td><span class="text-accent fw-bold">{{ it.task.deal.number }}</span></td>
|
||||
<td class="fw-bold">{{ it.task.drawing_name }}</td>
|
||||
<td>{{ it.quantity_plan }}</td>
|
||||
<td style="max-width:140px;">
|
||||
<input class="form-control form-control-sm border-secondary" type="number" min="0" max="{{ it.quantity_plan }}" name="fact_{{ it.id }}" id="fact_{{ it.id }}" value="{{ it.quantity_fact }}" {% if not can_edit %}disabled{% endif %}>
|
||||
</td>
|
||||
<td style="min-width:260px;">
|
||||
<div class="d-flex gap-2 align-items-center flex-wrap">
|
||||
<button type="button" class="btn btn-sm btn-outline-success closing-set-action" data-item-id="{{ it.id }}" data-action="done" data-plan="{{ it.quantity_plan }}" {% if not can_edit %}disabled{% endif %}>Полностью</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-warning closing-set-action" data-item-id="{{ it.id }}" data-action="partial" data-plan="{{ it.quantity_plan }}" {% if not can_edit %}disabled{% endif %}>Частично</button>
|
||||
<input type="hidden" id="ca_{{ it.id }}" name="close_action_{{ it.id }}" value="">
|
||||
<span class="small text-muted" id="modeLabel_{{ it.id }}"></span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="6" class="text-center text-muted py-4">Выбери станок и материал</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card shadow border-secondary mb-3">
|
||||
<div class="card-header border-secondary py-3">
|
||||
<h5 class="mb-0">Списание со склада цеха (единицы)</h5>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0 align-middle">
|
||||
<thead>
|
||||
<tr class="table-custom-header">
|
||||
<th>Поступление</th>
|
||||
<th>Единица</th>
|
||||
<th>Доступно</th>
|
||||
<th data-sort="false">Использовано</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for s in stock_items %}
|
||||
<tr>
|
||||
<td class="small">{% if s.created_at %}{{ s.created_at|date:"d.m.Y H:i" }}{% endif %}</td>
|
||||
<td>{{ s }}</td>
|
||||
<td>{{ s.quantity }}</td>
|
||||
<td style="max-width:140px;">
|
||||
<input class="form-control form-control-sm border-secondary" name="consume_{{ s.id }}" placeholder="0" {% if not can_edit %}disabled{% endif %}>
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="4" class="text-center text-muted py-4">Нет единиц на складе для выбранного материала</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card shadow border-secondary">
|
||||
<div class="card-header border-secondary py-3 d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0">Остаток ДО</h5>
|
||||
<button type="button" class="btn btn-outline-accent btn-sm" id="addRemnantBtn" {% if not can_edit %}disabled{% endif %}>Добавить ДО</button>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0 align-middle">
|
||||
<thead>
|
||||
<tr class="table-custom-header">
|
||||
<th>Кол-во</th>
|
||||
<th>Длина (мм)</th>
|
||||
<th>Ширина (мм)</th>
|
||||
<th data-sort="false"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="remnantBody">
|
||||
<tr id="remnantEmptyRow">
|
||||
<td colspan="4" class="text-center text-muted py-4">ДО не добавлены</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-end mt-3">
|
||||
<button type="submit" class="btn btn-outline-accent" {% if not can_edit %}disabled{% endif %}>Сохранить</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const canEdit = {% if can_edit %}true{% else %}false{% endif %};
|
||||
|
||||
document.querySelectorAll('.closing-set-action').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
if (!canEdit) return;
|
||||
|
||||
const itemId = btn.getAttribute('data-item-id');
|
||||
const action = btn.getAttribute('data-action');
|
||||
const plan = parseInt(btn.getAttribute('data-plan') || '0', 10) || 0;
|
||||
|
||||
const hidden = document.getElementById('ca_' + itemId);
|
||||
const fact = document.getElementById('fact_' + itemId);
|
||||
const label = document.getElementById('modeLabel_' + itemId);
|
||||
|
||||
if (hidden) hidden.value = action;
|
||||
|
||||
const cell = btn.closest('td');
|
||||
if (cell) {
|
||||
cell.querySelectorAll('.closing-set-action').forEach(b => {
|
||||
const a = b.getAttribute('data-action');
|
||||
if (a === 'done') {
|
||||
b.classList.remove('btn-success');
|
||||
b.classList.add('btn-outline-success');
|
||||
}
|
||||
if (a === 'partial') {
|
||||
b.classList.remove('btn-warning');
|
||||
b.classList.add('btn-outline-warning');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (action === 'done') {
|
||||
btn.classList.remove('btn-outline-success');
|
||||
btn.classList.add('btn-success');
|
||||
if (fact) {
|
||||
fact.value = String(plan);
|
||||
fact.readOnly = true;
|
||||
}
|
||||
if (label) label.textContent = 'Выбрано: полностью';
|
||||
}
|
||||
|
||||
if (action === 'partial') {
|
||||
btn.classList.remove('btn-outline-warning');
|
||||
btn.classList.add('btn-warning');
|
||||
if (fact) {
|
||||
fact.readOnly = false;
|
||||
fact.focus();
|
||||
fact.select();
|
||||
}
|
||||
if (label) label.textContent = 'Выбрано: частично';
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const addBtn = document.getElementById('addRemnantBtn');
|
||||
const body = document.getElementById('remnantBody');
|
||||
const emptyRow = document.getElementById('remnantEmptyRow');
|
||||
|
||||
function renumberRemnants() {
|
||||
const rows = Array.from(body.querySelectorAll('tr[data-remnant-row="1"]'));
|
||||
rows.forEach((tr, idx) => {
|
||||
const qty = tr.querySelector('input[data-field="qty"]');
|
||||
const len = tr.querySelector('input[data-field="len"]');
|
||||
const wid = tr.querySelector('input[data-field="wid"]');
|
||||
if (qty) qty.name = 'remnant_qty_' + idx;
|
||||
if (len) len.name = 'remnant_len_' + idx;
|
||||
if (wid) wid.name = 'remnant_wid_' + idx;
|
||||
});
|
||||
|
||||
if (emptyRow) {
|
||||
emptyRow.style.display = rows.length ? 'none' : '';
|
||||
}
|
||||
}
|
||||
|
||||
function addRemnantRow() {
|
||||
if (!canEdit) return;
|
||||
|
||||
const rows = Array.from(body.querySelectorAll('tr[data-remnant-row="1"]'));
|
||||
if (rows.length >= 50) return;
|
||||
|
||||
const tr = document.createElement('tr');
|
||||
tr.setAttribute('data-remnant-row', '1');
|
||||
|
||||
tr.innerHTML = `
|
||||
<td style="max-width:180px;">
|
||||
<input class="form-control form-control-sm border-secondary" data-field="qty" inputmode="decimal" placeholder="Кол-во" required>
|
||||
</td>
|
||||
<td style="max-width:180px;">
|
||||
<input class="form-control form-control-sm border-secondary" data-field="len" inputmode="decimal" placeholder="Длина (мм)">
|
||||
</td>
|
||||
<td style="max-width:180px;">
|
||||
<input class="form-control form-control-sm border-secondary" data-field="wid" inputmode="decimal" placeholder="Ширина (мм)">
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" data-action="remove">Удалить</button>
|
||||
</td>
|
||||
`;
|
||||
|
||||
const rm = tr.querySelector('button[data-action="remove"]');
|
||||
if (rm) {
|
||||
rm.addEventListener('click', () => {
|
||||
tr.remove();
|
||||
renumberRemnants();
|
||||
});
|
||||
}
|
||||
|
||||
body.appendChild(tr);
|
||||
renumberRemnants();
|
||||
|
||||
const first = tr.querySelector('input[data-field="qty"]');
|
||||
if (first) {
|
||||
first.focus();
|
||||
first.select();
|
||||
}
|
||||
}
|
||||
|
||||
if (addBtn) {
|
||||
addBtn.addEventListener('click', addRemnantRow);
|
||||
}
|
||||
|
||||
renumberRemnants();
|
||||
});
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
@@ -134,39 +134,11 @@
|
||||
<input type="number" name="quantity_fact" id="id_quantity_fact" class="form-control border-secondary" value="{{ item.quantity_fact }}" max="{{ item.quantity_plan }}">
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<label class="small text-muted">Взятый материал</label>
|
||||
<input type="text" name="material_taken" class="form-control border-secondary" value="{{ item.material_taken }}" placeholder="Напр: 3 трубы по 12м" required>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="small text-muted">Остаток ДО</label>
|
||||
<input type="text" name="usable_waste" class="form-control border-secondary" value="{{ item.usable_waste }}" placeholder="Напр: 0.8м / 12кг" required>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="small text-muted">Лом (кг)</label>
|
||||
<input type="number" step="0.01" min="0" name="scrap_weight" class="form-control border-secondary" value="{{ item.scrap_weight|default_if_none:'0'|unlocalize }}" required>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="alert alert-success">Статус: {{ item.get_status_display }}. Сделано: {{ item.quantity_fact }} шт.</div>
|
||||
<input type="hidden" name="quantity_fact" value="{{ item.quantity_fact }}">
|
||||
{% if user_role == 'master' %}
|
||||
<div class="row g-3 mt-3 text-start">
|
||||
<div class="col-md-4">
|
||||
<label class="small text-muted">Взятый материал</label>
|
||||
<input type="text" name="material_taken" class="form-control border-secondary" value="{{ item.material_taken }}" placeholder="Напр: 3 трубы по 12м">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="small text-muted">Остаток ДО</label>
|
||||
<input type="text" name="usable_waste" class="form-control border-secondary" value="{{ item.usable_waste }}" placeholder="Напр: 0.8м / 12кг">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="small text-muted">Лом (кг)</label>
|
||||
<input type="number" step="0.01" min="0" name="scrap_weight" class="form-control border-secondary" value="{{ item.scrap_weight|default_if_none:'0'|unlocalize }}">
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
@@ -202,18 +174,6 @@
|
||||
<input type="number" name="quantity_fact" class="form-control border-secondary" value="{{ item.quantity_fact }}">
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<label class="small text-muted">Взятый материал</label>
|
||||
<input type="text" name="material_taken" class="form-control border-secondary" value="{{ item.material_taken }}" placeholder="Напр: 3 трубы по 12м">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="small text-muted">Деловой отход</label>
|
||||
<input type="text" name="usable_waste" class="form-control border-secondary" value="{{ item.usable_waste }}" placeholder="Напр: кусок 1500мм">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="small text-muted">Лом (кг)</label>
|
||||
<input type="number" step="0.01" name="scrap_weight" class="form-control border-secondary" value="{{ item.scrap_weight|default_if_none:'0'|unlocalize }}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-check form-switch p-3 rounded border border-warning mb-4 bg-body-tertiary d-flex justify-content-between align-items-center">
|
||||
@@ -223,28 +183,13 @@
|
||||
{% endif %}
|
||||
|
||||
{% if user_role == 'clerk' %}
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-4">
|
||||
<small class="text-muted d-block">Взятый материал</small>
|
||||
<strong>{{ item.material_taken|default:"-" }}</strong>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<small class="text-muted d-block">Остаток ДО</small>
|
||||
<strong>{{ item.usable_waste|default:"-" }}</strong>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<small class="text-muted d-block">Лом (кг)</small>
|
||||
<strong>{{ item.scrap_weight }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if item.status == 'done' or item.status == 'partial' %}
|
||||
<div class="form-check form-switch p-3 rounded border border-warning mb-4 bg-body-tertiary d-flex justify-content-between align-items-center">
|
||||
<label class="form-check-label fw-bold ms-2" for="sync1c">Списано в 1С</label>
|
||||
<input class="form-check-input ms-0" style="width: 3em; height: 1.5em;" type="checkbox" name="is_synced_1c" id="sync1c" {% if item.is_synced_1c %}checked{% endif %}>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-muted small mb-4"><i class="bi bi-info-circle me-1"></i>Списание будет доступно после закрытия (Выполнено/Частично).</div>
|
||||
<div class="text-muted small mb-4"><i class="bi bi-info-circle me-1"></i>Списание будет доступно после закрытия.</div>
|
||||
{% endif %}
|
||||
<input type="hidden" name="quantity_fact" value="{{ item.quantity_fact }}">
|
||||
{% endif %}
|
||||
@@ -253,7 +198,7 @@
|
||||
<a href="{{ back_url }}" class="btn btn-outline-secondary">Назад</a>
|
||||
<div class="d-flex gap-2">
|
||||
<input type="hidden" name="action" id="actionField" value="save">
|
||||
{% if item.status == 'work' %}
|
||||
{% if item.status == 'work' and user_role == 'admin' %}
|
||||
<button type="submit" class="btn btn-success px-4" onclick="document.getElementById('actionField').value='close_done'">
|
||||
<i class="bi bi-check-all me-2"></i>Выполнено
|
||||
</button>
|
||||
|
||||
560
shiftflow/templates/shiftflow/warehouse_stocks.html
Normal file
560
shiftflow/templates/shiftflow/warehouse_stocks.html
Normal file
@@ -0,0 +1,560 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card border-secondary mb-3 shadow-sm">
|
||||
<div class="card-body py-2">
|
||||
<form method="get" id="warehouse-filter-form" class="row g-2 align-items-center">
|
||||
<input type="hidden" name="q" value="{{ q }}">
|
||||
|
||||
<div class="col-md-7">
|
||||
<div class="small text-muted mb-1 fw-bold">Склады:</div>
|
||||
<div class="d-flex flex-wrap gap-1">
|
||||
<div>
|
||||
<input type="radio" class="btn-check" name="location_id" id="wl_all" value="" {% if not selected_location_id %}checked{% endif %} onchange="this.form.submit()">
|
||||
<label class="btn btn-outline-accent btn-sm" for="wl_all">Все</label>
|
||||
</div>
|
||||
{% for loc in locations %}
|
||||
<div>
|
||||
<input type="radio" class="btn-check" name="location_id" id="wl_{{ loc.id }}" value="{{ loc.id }}" {% if selected_location_id == loc.id|stringformat:"s" %}checked{% endif %} onchange="this.form.submit()">
|
||||
<label class="btn btn-outline-accent btn-sm" for="wl_{{ loc.id }}">{{ loc }}</label>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<div class="small text-muted mb-1 fw-bold">Тип:</div>
|
||||
<div class="d-flex flex-wrap gap-1">
|
||||
<div>
|
||||
<input type="radio" class="btn-check" name="kind" id="wk_all" value="" {% if not selected_kind %}checked{% endif %} onchange="this.form.submit()">
|
||||
<label class="btn btn-outline-accent btn-sm" for="wk_all">Все</label>
|
||||
</div>
|
||||
<div>
|
||||
<input type="radio" class="btn-check" name="kind" id="wk_raw" value="raw" {% if selected_kind == 'raw' %}checked{% endif %} onchange="this.form.submit()">
|
||||
<label class="btn btn-outline-primary btn-sm" for="wk_raw">Сырьё</label>
|
||||
</div>
|
||||
<div>
|
||||
<input type="radio" class="btn-check" name="kind" id="wk_finished" value="finished" {% if selected_kind == 'finished' %}checked{% endif %} onchange="this.form.submit()">
|
||||
<label class="btn btn-outline-success btn-sm" for="wk_finished">Изделия</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-auto">
|
||||
<div class="small text-muted mb-1 fw-bold">Период:</div>
|
||||
<div class="d-flex gap-2">
|
||||
<input type="date" name="start_date" class="form-control form-control-sm bg-body text-body border-secondary" value="{{ start_date }}" onchange="this.form.submit()">
|
||||
<input type="date" name="end_date" class="form-control form-control-sm bg-body text-body border-secondary" value="{{ end_date }}" onchange="this.form.submit()">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-1 text-end mt-auto">
|
||||
<a href="{% url 'warehouse_stocks' %}?reset=1" class="btn btn-outline-secondary btn-sm w-100" title="Сброс">
|
||||
<i class="bi bi-arrow-counterclockwise"></i>
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card shadow border-secondary">
|
||||
<div class="card-header border-secondary py-3 d-flex flex-wrap gap-2 justify-content-between align-items-center">
|
||||
<h3 class="text-accent mb-0"><i class="bi bi-box-seam me-2"></i>Склады</h3>
|
||||
|
||||
<div class="d-flex flex-wrap gap-2 align-items-center">
|
||||
{% if can_receive %}
|
||||
<button type="button" class="btn btn-outline-accent btn-sm" data-bs-toggle="modal" data-bs-target="#receiptModal">
|
||||
<i class="bi bi-box-arrow-in-down me-1"></i>Приход
|
||||
</button>
|
||||
{% endif %}
|
||||
|
||||
<form class="d-flex gap-2 align-items-center" method="get" action="{% url 'warehouse_stocks' %}">
|
||||
<input type="hidden" name="location_id" value="{{ selected_location_id }}">
|
||||
<input type="hidden" name="kind" value="{{ selected_kind }}">
|
||||
<input type="hidden" name="start_date" value="{{ start_date }}">
|
||||
<input type="hidden" name="end_date" value="{{ end_date }}">
|
||||
|
||||
<input class="form-control form-control-sm" name="q" value="{{ q }}" placeholder="Поиск (материал, деталь, склад, ID)" style="min-width: 360px;">
|
||||
<button class="btn btn-outline-secondary btn-sm" type="submit"><i class="bi bi-search me-1"></i>Найти</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0 align-middle" data-sortable="1">
|
||||
<thead>
|
||||
<tr class="table-custom-header">
|
||||
<th>Склад</th>
|
||||
<th data-sort-type="date">Поступление</th>
|
||||
<th>Сделка</th>
|
||||
<th>Наименование</th>
|
||||
<th>Тип</th>
|
||||
<th data-sort-type="number">Кол-во</th>
|
||||
<th>Ед. измерения</th>
|
||||
<th>ДО</th>
|
||||
<th data-sort="false">Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for it in items %}
|
||||
<tr>
|
||||
<td>{{ it.location }}</td>
|
||||
<td>{% if it.created_at %}{{ it.created_at|date:"d.m.Y H:i" }}{% endif %}</td>
|
||||
<td>
|
||||
{% if it.deal_id %}
|
||||
<span class="text-accent fw-bold">{{ it.deal.number }}</span>
|
||||
{% else %}
|
||||
—
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if it.material_id %}
|
||||
{{ it.material.full_name }}
|
||||
{% elif it.entity_id %}
|
||||
{{ it.entity }}
|
||||
{% else %}
|
||||
—
|
||||
{% endif %}
|
||||
{% if it.unique_id %}
|
||||
<div class="small text-muted mt-1">{{ it.unique_id }}</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if it.entity_id %}
|
||||
Изделие/деталь
|
||||
{% elif it.is_remnant %}
|
||||
ДО
|
||||
{% else %}
|
||||
Сырьё
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ it.quantity }}</td>
|
||||
<td>
|
||||
{% 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 %}
|
||||
</td>
|
||||
<td>
|
||||
{% if it.is_remnant %}Да{% else %}—{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if can_transfer %}
|
||||
<div class="d-flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline-accent btn-sm"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#transferModal"
|
||||
data-mode="transfer"
|
||||
data-stock-item-id="{{ it.id }}"
|
||||
data-stock-item-name="{% if it.material_id %}{{ it.material.full_name }}{% elif it.entity_id %}{{ it.entity }}{% else %}—{% endif %}"
|
||||
data-from-location="{{ it.location }}"
|
||||
data-from-location-id="{{ it.location_id }}"
|
||||
data-max="{{ it.quantity }}"
|
||||
>
|
||||
<i class="bi bi-arrow-left-right me-1"></i>Переместить
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline-secondary btn-sm"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#transferModal"
|
||||
data-mode="ship"
|
||||
data-stock-item-id="{{ it.id }}"
|
||||
data-stock-item-name="{% if it.material_id %}{{ it.material.full_name }}{% elif it.entity_id %}{{ it.entity }}{% else %}—{% endif %}"
|
||||
data-from-location="{{ it.location }}"
|
||||
data-from-location-id="{{ it.location_id }}"
|
||||
data-max="{{ it.quantity }}"
|
||||
>
|
||||
<i class="bi bi-truck me-1"></i>Отгрузка
|
||||
</button>
|
||||
</div>
|
||||
{% else %}
|
||||
<span class="text-muted small">только просмотр</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="8" class="text-center text-muted py-4">Нет позиций по текущим фильтрам</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal fade" id="receiptModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<form method="post" action="{% url 'warehouse_receipt' %}" class="modal-content border-secondary">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="next" value="{{ request.get_full_path }}">
|
||||
|
||||
<div class="modal-header border-secondary">
|
||||
<h5 class="modal-title">Приход</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<div class="row g-2">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Тип</label>
|
||||
<select class="form-select" name="kind" id="receiptKind" required>
|
||||
<option value="raw">Сырьё / покупное</option>
|
||||
<option value="entity">Изделие/деталь</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Сделка</label>
|
||||
<select class="form-select" name="deal_id">
|
||||
<option value="">— не указано —</option>
|
||||
{% for d in deals %}
|
||||
<option value="{{ d.id }}">{{ d.number }}{% if d.company_id %} — {{ d.company.name }}{% endif %}{% if d.description %} — {{ d.description }}{% endif %}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Склад</label>
|
||||
<select class="form-select" name="location_id" required>
|
||||
{% for loc in locations %}
|
||||
<option value="{{ loc.id }}">{{ loc }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12" id="receiptRawBlock">
|
||||
<label class="form-label">Материал</label>
|
||||
<select class="form-select" name="material_id" id="receiptMaterial">
|
||||
{% for m in materials %}
|
||||
<option value="{{ m.id }}" data-ff="{{ m.category.form_factor|default:'' }}">{{ m.full_name|default:m.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
|
||||
<div class="row g-2 mt-1">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Кол-во</label>
|
||||
<input class="form-control" name="quantity" id="receiptQtyRaw" placeholder="Напр. 1" required>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Длина (мм)</label>
|
||||
<input class="form-control" name="current_length" id="receiptLen" placeholder="Напр. 2500">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Ширина (мм)</label>
|
||||
<input class="form-control" name="current_width" id="receiptWid" placeholder="Напр. 1250">
|
||||
</div>
|
||||
<div class="col-12 mt-1">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="is_customer_supplied" id="receiptDav">
|
||||
<label class="form-check-label" for="receiptDav">Давальческий</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12" id="receiptEntityBlock" style="display:none;">
|
||||
<div class="row g-2">
|
||||
<div class="col-md-8">
|
||||
<label class="form-label">КД (изделие/деталь)</label>
|
||||
<select class="form-select" name="entity_id">
|
||||
{% for e in entities %}
|
||||
<option value="{{ e.id }}">{{ e }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Кол-во</label>
|
||||
<input class="form-control" name="quantity" id="receiptQtyEntity" placeholder="Напр. 1" required>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer border-secondary">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Отмена</button>
|
||||
<button type="submit" class="btn btn-outline-accent">Добавить</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal fade" id="transferModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<form method="post" action="{% url 'warehouse_transfer' %}" class="modal-content border-secondary">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="stock_item_id" id="transferStockItemId">
|
||||
<input type="hidden" name="next" value="{{ request.get_full_path }}">
|
||||
|
||||
<div class="modal-header border-secondary">
|
||||
<h5 class="modal-title" id="transferTitle">Перемещение</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<div class="mb-2">
|
||||
<div class="fw-bold" id="transferInfoFrom"></div>
|
||||
<div class="small text-muted" id="transferInfoName"></div>
|
||||
<div class="small text-muted" id="transferInfoAvail"></div>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-danger d-none" id="transferError" role="alert"></div>
|
||||
|
||||
<div class="row g-2">
|
||||
<div class="col-md-6" id="transferToCol">
|
||||
<label class="form-label">Куда</label>
|
||||
<select class="form-select" name="to_location_id" id="transferToLocation" required>
|
||||
{% if shipping_location_id %}
|
||||
<option value="{{ shipping_location_id }}" data-shipping="1" hidden disabled>{{ shipping_location_label|default:'Отгруженные позиции' }}</option>
|
||||
{% endif %}
|
||||
{% for loc in locations %}
|
||||
<option value="{{ loc.id }}">{{ loc }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Количество</label>
|
||||
<input class="form-control" name="quantity" id="transferQty" placeholder="Напр. 1 или 2.5" inputmode="decimal" autofocus required>
|
||||
<div class="small text-muted mt-1" id="transferMaxHint"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer border-secondary">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Отмена</button>
|
||||
<button type="submit" class="btn btn-outline-accent">Применить</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const modal = document.getElementById('transferModal');
|
||||
if (!modal) return;
|
||||
|
||||
const form = modal.querySelector('form');
|
||||
const idInput = document.getElementById('transferStockItemId');
|
||||
const title = document.getElementById('transferTitle');
|
||||
const infoFrom = document.getElementById('transferInfoFrom');
|
||||
const infoName = document.getElementById('transferInfoName');
|
||||
const infoAvail = document.getElementById('transferInfoAvail');
|
||||
const errBox = document.getElementById('transferError');
|
||||
const qty = document.getElementById('transferQty');
|
||||
const maxHint = document.getElementById('transferMaxHint');
|
||||
const toSel = document.getElementById('transferToLocation');
|
||||
const toCol = document.getElementById('transferToCol');
|
||||
|
||||
const receiptKind = document.getElementById('receiptKind');
|
||||
const receiptRaw = document.getElementById('receiptRawBlock');
|
||||
const receiptEntity = document.getElementById('receiptEntityBlock');
|
||||
const receiptMaterial = document.getElementById('receiptMaterial');
|
||||
const receiptLen = document.getElementById('receiptLen');
|
||||
const receiptWid = document.getElementById('receiptWid');
|
||||
const receiptQtyRaw = document.getElementById('receiptQtyRaw');
|
||||
const receiptQtyEntity = document.getElementById('receiptQtyEntity');
|
||||
|
||||
let currentMax = null;
|
||||
let currentMode = 'transfer';
|
||||
|
||||
function showErr(text) {
|
||||
if (!errBox) return;
|
||||
if (!text) {
|
||||
errBox.classList.add('d-none');
|
||||
errBox.textContent = '';
|
||||
return;
|
||||
}
|
||||
errBox.textContent = text;
|
||||
errBox.classList.remove('d-none');
|
||||
}
|
||||
|
||||
function parseNumber(text) {
|
||||
const s = (text || '').toString().trim().replace(',', '.');
|
||||
const n = parseFloat(s);
|
||||
return isNaN(n) ? null : n;
|
||||
}
|
||||
|
||||
modal.addEventListener('show.bs.modal', (ev) => {
|
||||
const btn = ev.relatedTarget;
|
||||
if (!btn) return;
|
||||
|
||||
currentMode = btn.getAttribute('data-mode') || 'transfer';
|
||||
|
||||
const stockItemId = btn.getAttribute('data-stock-item-id') || '';
|
||||
const name = btn.getAttribute('data-stock-item-name') || '';
|
||||
const fromLoc = btn.getAttribute('data-from-location') || '';
|
||||
const fromLocId = btn.getAttribute('data-from-location-id') || '';
|
||||
const maxRaw = btn.getAttribute('data-max') || '';
|
||||
|
||||
currentMax = parseNumber(maxRaw);
|
||||
|
||||
showErr('');
|
||||
|
||||
if (idInput) idInput.value = stockItemId;
|
||||
|
||||
if (title) title.textContent = currentMode === 'ship' ? 'Отгрузка' : 'Перемещение';
|
||||
if (infoFrom) infoFrom.textContent = `Откуда: ${fromLoc}`;
|
||||
if (infoName) infoName.textContent = `Что: ${name}`;
|
||||
if (infoAvail) infoAvail.textContent = currentMax !== null ? `Доступно: ${currentMax}` : '';
|
||||
|
||||
if (maxHint) maxHint.textContent = currentMax !== null ? `Доступно: ${currentMax}` : '';
|
||||
if (qty) {
|
||||
qty.value = currentMax !== null ? String(currentMax) : '';
|
||||
if (currentMax !== null) qty.setAttribute('max', String(currentMax));
|
||||
qty.setAttribute('min', '0');
|
||||
qty.setAttribute('step', 'any');
|
||||
}
|
||||
|
||||
if (toSel) {
|
||||
const shipId = '{{ shipping_location_id }}';
|
||||
|
||||
Array.from(toSel.options).forEach(opt => {
|
||||
const isShipping = opt.getAttribute('data-shipping') === '1';
|
||||
|
||||
if (isShipping) {
|
||||
if (currentMode === 'ship') {
|
||||
opt.disabled = false;
|
||||
opt.hidden = false;
|
||||
} else {
|
||||
opt.disabled = true;
|
||||
opt.hidden = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (fromLocId && String(opt.value) === String(fromLocId)) {
|
||||
opt.disabled = true;
|
||||
opt.hidden = true;
|
||||
} else {
|
||||
opt.disabled = false;
|
||||
opt.hidden = false;
|
||||
}
|
||||
});
|
||||
|
||||
const col = toCol || (toSel ? toSel.closest('.col-md-6') : null);
|
||||
|
||||
if (currentMode === 'ship') {
|
||||
if (shipId) {
|
||||
toSel.value = shipId;
|
||||
}
|
||||
if (col) col.style.display = 'none';
|
||||
} else {
|
||||
if (col) col.style.display = '';
|
||||
const first = Array.from(toSel.options).find(o => !o.disabled && !o.hidden);
|
||||
if (first) toSel.value = first.value;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
modal.addEventListener('shown.bs.modal', () => {
|
||||
if (qty) {
|
||||
qty.focus();
|
||||
qty.select();
|
||||
}
|
||||
});
|
||||
|
||||
if (form) {
|
||||
form.addEventListener('submit', (e) => {
|
||||
const v = parseNumber(qty ? qty.value : '');
|
||||
if (v === null || v <= 0) {
|
||||
e.preventDefault();
|
||||
showErr('Количество должно быть больше 0.');
|
||||
if (qty) {
|
||||
qty.focus();
|
||||
qty.select();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentMax !== null && v > currentMax) {
|
||||
e.preventDefault();
|
||||
showErr('Нельзя переместить больше, чем доступно.');
|
||||
if (qty) {
|
||||
qty.focus();
|
||||
qty.select();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentMode === 'ship') {
|
||||
const shipId = '{{ shipping_location_id }}';
|
||||
if (!shipId) {
|
||||
e.preventDefault();
|
||||
showErr('Не найден склад отгруженных позиций. Создай склад с названием содержащим "отгруж" или "отгруз".');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
showErr('');
|
||||
});
|
||||
}
|
||||
|
||||
function syncReceiptKind() {
|
||||
if (!receiptKind || !receiptRaw || !receiptEntity) return;
|
||||
const isRaw = (receiptKind.value || '') === 'raw';
|
||||
|
||||
receiptRaw.style.display = isRaw ? '' : 'none';
|
||||
receiptEntity.style.display = isRaw ? 'none' : '';
|
||||
|
||||
if (receiptQtyRaw) {
|
||||
receiptQtyRaw.disabled = !isRaw;
|
||||
receiptQtyRaw.required = isRaw;
|
||||
}
|
||||
if (receiptQtyEntity) {
|
||||
receiptQtyEntity.disabled = isRaw;
|
||||
receiptQtyEntity.required = !isRaw;
|
||||
}
|
||||
}
|
||||
|
||||
function applyReceiptDefaults() {
|
||||
if (!receiptMaterial) return;
|
||||
const opt = receiptMaterial.options[receiptMaterial.selectedIndex];
|
||||
const ff = (opt && opt.getAttribute('data-ff') || '').toLowerCase();
|
||||
|
||||
if (ff === 'sheet') {
|
||||
if (receiptLen && !receiptLen.value) receiptLen.value = '2500';
|
||||
if (receiptWid && !receiptWid.value) receiptWid.value = '1250';
|
||||
if (receiptWid) receiptWid.disabled = false;
|
||||
} else if (ff === 'bar') {
|
||||
if (receiptLen && !receiptLen.value) receiptLen.value = '6000';
|
||||
if (receiptWid) {
|
||||
receiptWid.value = '';
|
||||
receiptWid.disabled = true;
|
||||
}
|
||||
} else {
|
||||
if (receiptWid) receiptWid.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (receiptKind) {
|
||||
receiptKind.addEventListener('change', () => {
|
||||
syncReceiptKind();
|
||||
if (receiptKind.value === 'raw') {
|
||||
if (receiptQtyRaw) {
|
||||
receiptQtyRaw.focus();
|
||||
receiptQtyRaw.select();
|
||||
}
|
||||
} else {
|
||||
if (receiptQtyEntity) {
|
||||
receiptQtyEntity.focus();
|
||||
receiptQtyEntity.select();
|
||||
}
|
||||
}
|
||||
});
|
||||
syncReceiptKind();
|
||||
}
|
||||
|
||||
if (receiptMaterial) {
|
||||
receiptMaterial.addEventListener('change', applyReceiptDefaults);
|
||||
applyReceiptDefaults();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -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/<int:pk>/', 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'),
|
||||
]
|
||||
@@ -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')
|
||||
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}")
|
||||
@@ -17,6 +17,14 @@
|
||||
{% endif %}
|
||||
|
||||
<main class="container-fluid py-3 flex-grow-1 d-flex flex-column">
|
||||
{% if messages %}
|
||||
<div class="mb-3">
|
||||
{% for message in messages %}
|
||||
<div class="alert {% if message.tags %}alert-{{ message.tags }}{% else %}alert-secondary{% endif %} mb-2" role="alert">{{ message }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
|
||||
@@ -15,22 +15,24 @@
|
||||
<a class="nav-link {% if request.resolver_match.url_name == 'registry' %}active{% endif %}" href="{% url 'registry' %}">Реестр</a>
|
||||
</li>
|
||||
|
||||
{% if user_role in 'admin,technologist,master,clerk' %}
|
||||
{% if user_role in 'admin,technologist,master,clerk,observer' %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.resolver_match.url_name == 'planning' or request.resolver_match.url_name == 'planning_deal' %}active{% endif %}" href="{% url 'planning' %}">Сделки</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.resolver_match.url_name == 'customers' or request.resolver_match.url_name == 'customer_deals' %}active{% endif %}" href="{% url 'customers' %}">Заказчик</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.resolver_match.url_name == 'warehouse_stocks' %}active{% endif %}" href="{% url 'warehouse_stocks' %}">Склады</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
{% if user_role in 'admin,technologist,master,operator' %}
|
||||
<li class="nav-item"><a class="nav-link" href="#">Закрытие</a></li>
|
||||
{% if user_role in 'admin,master,operator,observer' %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.resolver_match.url_name == 'closing' %}active{% endif %}" href="{% url 'closing' %}">Закрытие</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
{% if user_role in 'admin,technologist,clerk' %}
|
||||
<li class="nav-item"><a class="nav-link" href="#">Списание</a></li>
|
||||
{% endif %}
|
||||
|
||||
{% if user_role == 'admin' %}
|
||||
<li class="nav-item">
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
from django.contrib import admin
|
||||
from .models import MaterialCategory, SteelGrade, Material
|
||||
from django.contrib import admin, messages
|
||||
from django.utils import timezone
|
||||
|
||||
from warehouse.services.transfers import receive_transfer
|
||||
|
||||
from .models import Location, Material, MaterialCategory, SteelGrade, StockItem, TransferLine, TransferRecord
|
||||
|
||||
@admin.register(MaterialCategory)
|
||||
class MaterialCategoryAdmin(admin.ModelAdmin):
|
||||
list_display = ('name', 'gost_standard')
|
||||
list_display = ('name', 'form_factor', 'gost_standard')
|
||||
list_filter = ('form_factor',)
|
||||
search_fields = ('name', 'gost_standard')
|
||||
|
||||
@admin.register(SteelGrade)
|
||||
@@ -17,3 +22,103 @@ class MaterialAdmin(admin.ModelAdmin):
|
||||
list_filter = ('category', 'steel_grade')
|
||||
search_fields = ('name', 'full_name')
|
||||
readonly_fields = ('full_name',)
|
||||
|
||||
|
||||
@admin.register(Location)
|
||||
class LocationAdmin(admin.ModelAdmin):
|
||||
list_display = ('name', 'is_production_area')
|
||||
list_filter = ('is_production_area',)
|
||||
search_fields = ('name',)
|
||||
|
||||
|
||||
@admin.register(StockItem)
|
||||
class StockItemAdmin(admin.ModelAdmin):
|
||||
list_display = ('location', 'material', 'entity', 'quantity', 'is_remnant', 'unique_id')
|
||||
list_filter = ('location', 'is_remnant', 'material__category')
|
||||
search_fields = ('material__name', 'material__full_name', 'entity__name', 'entity__drawing_number', 'unique_id')
|
||||
autocomplete_fields = ('location', 'material', 'entity')
|
||||
|
||||
|
||||
class TransferLineInline(admin.TabularInline):
|
||||
model = TransferLine
|
||||
fk_name = 'transfer'
|
||||
fields = ('stock_item', 'quantity')
|
||||
autocomplete_fields = ('stock_item',)
|
||||
extra = 5
|
||||
|
||||
def formfield_for_foreignkey(self, db_field, request, **kwargs):
|
||||
if db_field.name == 'stock_item':
|
||||
from_location_id = None
|
||||
|
||||
tr = getattr(request, '_transfer_obj', None)
|
||||
if tr and getattr(tr, 'from_location_id', None):
|
||||
from_location_id = tr.from_location_id
|
||||
|
||||
if not from_location_id and request.method == 'POST':
|
||||
raw = (request.POST.get('from_location') or '').strip()
|
||||
if raw.isdigit():
|
||||
from_location_id = int(raw)
|
||||
|
||||
if from_location_id:
|
||||
kwargs['queryset'] = StockItem.objects.filter(location_id=from_location_id)
|
||||
else:
|
||||
kwargs['queryset'] = StockItem.objects.all()
|
||||
|
||||
return super().formfield_for_foreignkey(db_field, request, **kwargs)
|
||||
|
||||
|
||||
@admin.register(TransferRecord)
|
||||
class TransferRecordAdmin(admin.ModelAdmin):
|
||||
list_display = ('from_location', 'to_location', 'id', 'occurred_at', 'sender', 'receiver', 'status', 'is_applied')
|
||||
list_display_links = ('from_location',)
|
||||
list_filter = ('status', 'from_location', 'to_location')
|
||||
search_fields = ('sender__username', 'receiver__username')
|
||||
autocomplete_fields = ('from_location', 'to_location', 'sender', 'receiver')
|
||||
inlines = (TransferLineInline,)
|
||||
actions = ('action_receive',)
|
||||
|
||||
def get_changeform_initial_data(self, request):
|
||||
initial = super().get_changeform_initial_data(request)
|
||||
initial.setdefault('sender', request.user.id)
|
||||
initial.setdefault('occurred_at', timezone.now())
|
||||
return initial
|
||||
|
||||
def get_form(self, request, obj=None, **kwargs):
|
||||
request._transfer_obj = obj
|
||||
return super().get_form(request, obj, **kwargs)
|
||||
|
||||
def save_model(self, request, obj, form, change):
|
||||
if not obj.sender_id:
|
||||
obj.sender = request.user
|
||||
super().save_model(request, obj, form, change)
|
||||
|
||||
def save_related(self, request, form, formsets, change):
|
||||
super().save_related(request, form, formsets, change)
|
||||
|
||||
obj = form.instance
|
||||
|
||||
# Применяем перемещение автоматически после сохранения строк.
|
||||
# Если строк нет — receive_transfer выбросит понятную ошибку.
|
||||
if not getattr(obj, 'is_applied', False):
|
||||
try:
|
||||
receive_transfer(obj.id, request.user.id)
|
||||
except Exception as e:
|
||||
self.message_user(request, f'Перемещение id={obj.id}: {e}', level=messages.ERROR)
|
||||
|
||||
@admin.action(description='Принять перемещение')
|
||||
def action_receive(self, request, queryset):
|
||||
ok = 0
|
||||
failed = 0
|
||||
|
||||
for tr in queryset:
|
||||
try:
|
||||
receive_transfer(tr.id, request.user.id)
|
||||
ok += 1
|
||||
except Exception as e:
|
||||
failed += 1
|
||||
self.message_user(request, f'Перемещение id={tr.id}: {e}', level=messages.ERROR)
|
||||
|
||||
if ok:
|
||||
self.message_user(request, f'Применено: {ok}.', level=messages.SUCCESS)
|
||||
if failed:
|
||||
self.message_user(request, f'Ошибок: {failed}.', level=messages.ERROR)
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
# Generated by Django 6.0.3 on 2026-04-04 15:14
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('manufacturing', '0001_initial'),
|
||||
('warehouse', '0003_alter_material_full_name'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Location',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=100, unique=True, verbose_name='Место хранения')),
|
||||
('is_production_area', models.BooleanField(default=False, verbose_name='Это производственный участок')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Склад/Участок',
|
||||
'verbose_name_plural': 'Склады и участки',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='StockItem',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('quantity', models.FloatField(verbose_name='Количество (шт/м/кг/лист)')),
|
||||
('is_remnant', models.BooleanField(default=False, 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, unique=True, verbose_name='ID/Маркировка (для ДО)')),
|
||||
('entity', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='manufacturing.productentity', verbose_name='Произведённая сущность')),
|
||||
('location', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='warehouse.location', verbose_name='Где находится')),
|
||||
('material', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='warehouse.material', verbose_name='Сырьё')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Единица на складе',
|
||||
'verbose_name_plural': 'Остатки на складах',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='TransferRecord',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('status', models.CharField(choices=[('sent', 'В пути'), ('received', 'Принято'), ('discrepancy', 'Расхождение')], default='sent', max_length=20, verbose_name='Статус')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('received_at', models.DateTimeField(blank=True, null=True)),
|
||||
('from_location', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='outgoing', to='warehouse.location')),
|
||||
('items', models.ManyToManyField(to='warehouse.stockitem', verbose_name='Перемещаемые объекты')),
|
||||
('receiver', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='received_transfers', to=settings.AUTH_USER_MODEL)),
|
||||
('sender', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='sent_transfers', to=settings.AUTH_USER_MODEL)),
|
||||
('to_location', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='incoming', to='warehouse.location')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Перемещение',
|
||||
'verbose_name_plural': 'Перемещения',
|
||||
},
|
||||
),
|
||||
]
|
||||
17
warehouse/migrations/0005_alter_stockitem_options.py
Normal file
17
warehouse/migrations/0005_alter_stockitem_options.py
Normal file
@@ -0,0 +1,17 @@
|
||||
# Generated by Django 6.0.3 on 2026-04-05 08:27
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('warehouse', '0004_location_stockitem_transferrecord'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='stockitem',
|
||||
options={'verbose_name': 'Единица на складах', 'verbose_name_plural': 'Единицы на складах'},
|
||||
),
|
||||
]
|
||||
17
warehouse/migrations/0006_alter_stockitem_options.py
Normal file
17
warehouse/migrations/0006_alter_stockitem_options.py
Normal file
@@ -0,0 +1,17 @@
|
||||
# Generated by Django 6.0.3 on 2026-04-05 08:50
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('warehouse', '0005_alter_stockitem_options'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='stockitem',
|
||||
options={'verbose_name': 'Единица на складе', 'verbose_name_plural': 'Единицы на складе'},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,65 @@
|
||||
# Generated by Django 6.0.3 on 2026-04-05 11:42
|
||||
|
||||
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 = [
|
||||
('warehouse', '0006_alter_stockitem_options'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='transferrecord',
|
||||
name='items',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='transferrecord',
|
||||
name='is_applied',
|
||||
field=models.BooleanField(default=False, verbose_name='Применено'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='transferrecord',
|
||||
name='occurred_at',
|
||||
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='Дата/время'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='transferrecord',
|
||||
name='from_location',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='outgoing', to='warehouse.location', verbose_name='Откуда'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='transferrecord',
|
||||
name='receiver',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='received_transfers', to=settings.AUTH_USER_MODEL, verbose_name='Кому'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='transferrecord',
|
||||
name='sender',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='sent_transfers', to=settings.AUTH_USER_MODEL, verbose_name='Кто'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='transferrecord',
|
||||
name='to_location',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='incoming', to='warehouse.location', verbose_name='Куда'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='TransferLine',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('quantity', models.FloatField(verbose_name='Количество')),
|
||||
('stock_item', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='warehouse.stockitem', verbose_name='Единица на складе')),
|
||||
('transfer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='lines', to='warehouse.transferrecord', verbose_name='Перемещение')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Строка перемещения',
|
||||
'verbose_name_plural': 'Строки перемещения',
|
||||
'unique_together': {('transfer', 'stock_item')},
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,24 @@
|
||||
# Generated by Django 6.0.3 on 2026-04-05 15:45
|
||||
|
||||
import django.utils.timezone
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('warehouse', '0007_remove_transferrecord_items_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='transferrecord',
|
||||
name='received_at',
|
||||
field=models.DateTimeField(blank=True, default=django.utils.timezone.now, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='transferrecord',
|
||||
name='status',
|
||||
field=models.CharField(choices=[('sent', 'В пути'), ('received', 'Принято'), ('discrepancy', 'Расхождение')], default='received', max_length=20, verbose_name='Статус'),
|
||||
),
|
||||
]
|
||||
19
warehouse/migrations/0009_stockitem_created_at.py
Normal file
19
warehouse/migrations/0009_stockitem_created_at.py
Normal file
@@ -0,0 +1,19 @@
|
||||
# Generated by Django 6.0.3 on 2026-04-05 19:06
|
||||
|
||||
import django.utils.timezone
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('warehouse', '0008_alter_transferrecord_received_at_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='stockitem',
|
||||
name='created_at',
|
||||
field=models.DateTimeField(default=django.utils.timezone.now, editable=False, verbose_name='Поступление'),
|
||||
),
|
||||
]
|
||||
18
warehouse/migrations/0010_materialcategory_form_factor.py
Normal file
18
warehouse/migrations/0010_materialcategory_form_factor.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 6.0.3 on 2026-04-05 19:49
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('warehouse', '0009_stockitem_created_at'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='materialcategory',
|
||||
name='form_factor',
|
||||
field=models.CharField(choices=[('sheet', 'Лист'), ('bar', 'Прокат/хлыст'), ('other', 'Прочее')], default='other', max_length=16, verbose_name='Форма'),
|
||||
),
|
||||
]
|
||||
18
warehouse/migrations/0011_stockitem_is_customer_supplied.py
Normal file
18
warehouse/migrations/0011_stockitem_is_customer_supplied.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 6.0.3 on 2026-04-05 20:09
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('warehouse', '0010_materialcategory_form_factor'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='stockitem',
|
||||
name='is_customer_supplied',
|
||||
field=models.BooleanField(default=False, verbose_name='Давальческий'),
|
||||
),
|
||||
]
|
||||
20
warehouse/migrations/0012_stockitem_deal.py
Normal file
20
warehouse/migrations/0012_stockitem_deal.py
Normal file
@@ -0,0 +1,20 @@
|
||||
# Generated by Django 6.0.3 on 2026-04-06 03:32
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('shiftflow', '0018_alter_productionreportconsumption_unique_together_and_more'),
|
||||
('warehouse', '0011_stockitem_is_customer_supplied'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='stockitem',
|
||||
name='deal',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='shiftflow.deal', verbose_name='Сделка'),
|
||||
),
|
||||
]
|
||||
48
warehouse/models warehouse.py
Normal file
48
warehouse/models warehouse.py
Normal file
@@ -0,0 +1,48 @@
|
||||
from django.db import models
|
||||
|
||||
class MaterialCategory(models.Model):
|
||||
"""Категория материала (например, Труба, Лист, Круг)"""
|
||||
name = models.CharField("Название категории", max_length=100, unique=True)
|
||||
gost_standard = models.CharField("ГОСТ на тип проката", max_length=255, blank=True, help_text="Напр: ГОСТ 8639-82")
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Категория материала"
|
||||
verbose_name_plural = "Категории материалов"
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
class SteelGrade(models.Model):
|
||||
"""Марка стали (например, Ст3сп, 09Г2С) и связанные с ней ГОСТы"""
|
||||
name = models.CharField("Марка стали", max_length=100, unique=True)
|
||||
gost_standard = models.CharField("ГОСТ/ТУ", max_length=255, blank=True, help_text="Основной стандарт для этой марки")
|
||||
certificate_pdf = models.FileField("Сертификат/ГОСТ (PDF)", upload_to='certificates/', blank=True, null=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Марка стали"
|
||||
verbose_name_plural = "Марки стали"
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name} ({self.gost_standard})" if self.gost_standard else self.name
|
||||
|
||||
class Material(models.Model):
|
||||
"""Конкретная номенклатурная единица (например, Труба 100х100х4)"""
|
||||
category = models.ForeignKey(MaterialCategory, on_delete=models.PROTECT, verbose_name="Категория")
|
||||
steel_grade = models.ForeignKey(SteelGrade, on_delete=models.PROTECT, verbose_name="Марка стали", null=True, blank=True)
|
||||
name = models.CharField("Наименование (размер/характеристики)", max_length=255)
|
||||
full_name = models.CharField("Полное наименование", max_length=500, blank=True, help_text="Генерируется автоматически")
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Материал (номенклатура)"
|
||||
verbose_name_plural = "Материалы"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
category_part = (self.category.name or '').strip() if self.category_id else ''
|
||||
name_part = (self.name or '').strip()
|
||||
grade_part = (self.steel_grade.name or '').strip() if self.steel_grade_id else ''
|
||||
|
||||
self.full_name = ' '.join([p for p in [category_part, name_part, grade_part] if p])
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def __str__(self):
|
||||
return self.full_name or ' '.join([p for p in [(self.category.name if self.category_id else ''), self.name, (self.steel_grade.name if self.steel_grade_id else '')] if p]).strip()
|
||||
@@ -1,8 +1,20 @@
|
||||
from django.db import models
|
||||
from django.contrib.auth.models import User
|
||||
from django.utils import timezone
|
||||
from django.core.exceptions import ValidationError
|
||||
import uuid
|
||||
|
||||
class MaterialCategory(models.Model):
|
||||
"""Категория материала (например, Труба, Лист, Круг)"""
|
||||
|
||||
FORM_FACTOR_CHOICES = [
|
||||
('sheet', 'Лист'),
|
||||
('bar', 'Прокат/хлыст'),
|
||||
('other', 'Прочее'),
|
||||
]
|
||||
|
||||
name = models.CharField("Название категории", max_length=100, unique=True)
|
||||
form_factor = models.CharField('Форма', max_length=16, choices=FORM_FACTOR_CHOICES, default='other')
|
||||
gost_standard = models.CharField("ГОСТ на тип проката", max_length=255, blank=True, help_text="Напр: ГОСТ 8639-82")
|
||||
|
||||
class Meta:
|
||||
@@ -26,7 +38,7 @@ class SteelGrade(models.Model):
|
||||
return f"{self.name} ({self.gost_standard})" if self.gost_standard else self.name
|
||||
|
||||
class Material(models.Model):
|
||||
"""Конкретная номенклатурная единица (например, Труба 100х100х4)"""
|
||||
"""Конкретная номенклатурная единица (например, Труба 100х100х4)."""
|
||||
category = models.ForeignKey(MaterialCategory, on_delete=models.PROTECT, verbose_name="Категория")
|
||||
steel_grade = models.ForeignKey(SteelGrade, on_delete=models.PROTECT, verbose_name="Марка стали", null=True, blank=True)
|
||||
name = models.CharField("Наименование (размер/характеристики)", max_length=255)
|
||||
@@ -46,3 +58,134 @@ class Material(models.Model):
|
||||
|
||||
def __str__(self):
|
||||
return self.full_name or ' '.join([p for p in [(self.category.name if self.category_id else ''), self.name, (self.steel_grade.name if self.steel_grade_id else '')] if p]).strip()
|
||||
|
||||
|
||||
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):
|
||||
"""Физический остаток на складе.
|
||||
|
||||
Правила заполнения:
|
||||
- если это сырьё: заполнен material, entity пустой
|
||||
- если это готовая деталь: заполнен entity, material пустой
|
||||
|
||||
Количество (quantity) интерпретируется как "единица учёта" для конкретного объекта:
|
||||
- для листа может быть 1 лист
|
||||
- для профиля/трубы может быть 1 хлыст
|
||||
- для готовых деталей обычно шт.
|
||||
"""
|
||||
|
||||
material = models.ForeignKey('warehouse.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="Произведённая сущность")
|
||||
|
||||
deal = models.ForeignKey('shiftflow.Deal', on_delete=models.PROTECT, null=True, blank=True, verbose_name='Сделка')
|
||||
|
||||
location = models.ForeignKey(Location, on_delete=models.PROTECT, verbose_name="Где находится")
|
||||
quantity = models.FloatField("Количество (шт/м/кг/лист)")
|
||||
created_at = models.DateTimeField("Поступление", default=timezone.now, editable=False)
|
||||
created_at = models.DateTimeField("Поступление", default=timezone.now, editable=False)
|
||||
|
||||
is_remnant = models.BooleanField("Деловой остаток", default=False)
|
||||
is_customer_supplied = 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 clean(self):
|
||||
if self.material_id and not self.entity_id:
|
||||
category = getattr(self.material, 'category', None)
|
||||
form_factor = getattr(category, 'form_factor', 'other') if category else 'other'
|
||||
|
||||
if form_factor == 'sheet':
|
||||
if self.current_length in (None, '') or self.current_width in (None, ''):
|
||||
raise ValidationError('Для листового материала нужно заполнить длину и ширину (мм).')
|
||||
|
||||
if form_factor == 'bar':
|
||||
if self.current_length in (None, ''):
|
||||
raise ValidationError('Для проката нужно заполнить длину (мм).')
|
||||
|
||||
super().clean()
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if self.is_remnant and not self.unique_id:
|
||||
while True:
|
||||
candidate = f"DO-{uuid.uuid4().hex[:12].upper()}"
|
||||
if not StockItem.objects.filter(unique_id=candidate).exists():
|
||||
self.unique_id = candidate
|
||||
break
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def __str__(self):
|
||||
obj = self.entity if self.entity_id else self.material
|
||||
return f"{obj} | {self.quantity} | {self.location}"
|
||||
|
||||
|
||||
class TransferRecord(models.Model):
|
||||
"""Документ перемещения между складами.
|
||||
|
||||
Состав перемещения хранится в строках TransferLine, где указывается:
|
||||
- какая складская позиция списывается со склада-источника
|
||||
- сколько списывается
|
||||
|
||||
Статусы «в пути/принято» можно расширить позже. Сейчас ключевое — корректное списание/начисление.
|
||||
"""
|
||||
|
||||
STATUS_CHOICES = [
|
||||
('sent', 'В пути'),
|
||||
('received', 'Принято'),
|
||||
('discrepancy', 'Расхождение'),
|
||||
]
|
||||
|
||||
from_location = models.ForeignKey(Location, related_name='outgoing', on_delete=models.PROTECT, verbose_name='Откуда')
|
||||
to_location = models.ForeignKey(Location, related_name='incoming', on_delete=models.PROTECT, verbose_name='Куда')
|
||||
|
||||
sender = models.ForeignKey(User, related_name='sent_transfers', on_delete=models.PROTECT, null=True, blank=True, verbose_name='Кто')
|
||||
receiver = models.ForeignKey(User, related_name='received_transfers', on_delete=models.PROTECT, null=True, blank=True, verbose_name='Кому')
|
||||
|
||||
occurred_at = models.DateTimeField('Дата/время', default=timezone.now)
|
||||
|
||||
status = models.CharField("Статус", max_length=20, choices=STATUS_CHOICES, default='received')
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
received_at = models.DateTimeField(null=True, blank=True, default=timezone.now)
|
||||
|
||||
is_applied = models.BooleanField('Применено', default=False)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Перемещение"
|
||||
verbose_name_plural = "Перемещения"
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.from_location} -> {self.to_location}"
|
||||
|
||||
|
||||
class TransferLine(models.Model):
|
||||
"""Строка перемещения: сколько списать из конкретной складской позиции."""
|
||||
|
||||
transfer = models.ForeignKey(TransferRecord, related_name='lines', on_delete=models.CASCADE, verbose_name='Перемещение')
|
||||
stock_item = models.ForeignKey(StockItem, on_delete=models.PROTECT, verbose_name='Единица на складе')
|
||||
quantity = models.FloatField('Количество')
|
||||
|
||||
class Meta:
|
||||
verbose_name = 'Строка перемещения'
|
||||
verbose_name_plural = 'Строки перемещения'
|
||||
unique_together = ('transfer', 'stock_item')
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.transfer_id}: {self.stock_item_id} x {self.quantity}"
|
||||
|
||||
5
warehouse/services/__init__.py
Normal file
5
warehouse/services/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""
|
||||
Сервисный слой приложения warehouse.
|
||||
|
||||
Здесь живут операции складского учёта, требующие транзакций и блокировок.
|
||||
"""
|
||||
71
warehouse/services/transfers.py
Normal file
71
warehouse/services/transfers.py
Normal file
@@ -0,0 +1,71 @@
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
|
||||
from warehouse.models import StockItem, TransferLine, TransferRecord
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def receive_transfer(transfer_id: int, receiver_id: int) -> None:
|
||||
"""
|
||||
Строгое перемещение: принять TransferRecord.
|
||||
|
||||
Логика:
|
||||
- если уже received -> идемпотентно выходим
|
||||
- блокируем TransferRecord
|
||||
- блокируем связанные StockItem
|
||||
- обновляем location на to_location
|
||||
- ставим receiver/received_at/status
|
||||
"""
|
||||
tr = (
|
||||
TransferRecord.objects.select_for_update()
|
||||
.select_related('from_location', 'to_location')
|
||||
.get(pk=transfer_id)
|
||||
)
|
||||
|
||||
if tr.is_applied:
|
||||
return
|
||||
|
||||
lines = list(TransferLine.objects.filter(transfer=tr).select_related('stock_item', 'stock_item__location', 'stock_item__material', 'stock_item__entity'))
|
||||
if not lines:
|
||||
raise RuntimeError('В перемещении нет строк.')
|
||||
|
||||
for ln in lines:
|
||||
if float(ln.quantity) <= 0:
|
||||
continue
|
||||
|
||||
src = StockItem.objects.select_for_update().get(pk=ln.stock_item_id)
|
||||
if src.location_id != tr.from_location_id:
|
||||
raise RuntimeError('Единица на складе находится не на складе-источнике.')
|
||||
|
||||
if float(ln.quantity) > float(src.quantity):
|
||||
raise RuntimeError('Недостаточно количества в источнике для перемещения.')
|
||||
|
||||
if src.unique_id and float(ln.quantity) != float(src.quantity):
|
||||
raise RuntimeError('Нельзя частично перемещать позицию с ID/маркировкой.')
|
||||
|
||||
if float(ln.quantity) == float(src.quantity):
|
||||
src.location_id = tr.to_location_id
|
||||
src.created_at = timezone.now()
|
||||
src.save(update_fields=['location', 'created_at'])
|
||||
continue
|
||||
|
||||
src.quantity = float(src.quantity) - float(ln.quantity)
|
||||
src.save(update_fields=['quantity'])
|
||||
|
||||
# ВАЖНО: не объединяем с существующими позициями на складе-получателе,
|
||||
# чтобы сохранялась история поступлений/дат/партий.
|
||||
StockItem.objects.create(
|
||||
material=src.material,
|
||||
entity=src.entity,
|
||||
location_id=tr.to_location_id,
|
||||
quantity=float(ln.quantity),
|
||||
is_remnant=src.is_remnant,
|
||||
current_length=src.current_length,
|
||||
current_width=src.current_width,
|
||||
)
|
||||
|
||||
tr.status = 'received'
|
||||
tr.receiver_id = receiver_id
|
||||
tr.received_at = timezone.now()
|
||||
tr.is_applied = True
|
||||
tr.save(update_fields=['status', 'receiver_id', 'received_at', 'is_applied'])
|
||||
Reference in New Issue
Block a user