Огромная замена логики
All checks were successful
Deploy MES Core / deploy (push) Successful in 11s

This commit is contained in:
2026-04-06 08:06:37 +03:00
parent 0e8497ab1f
commit e88b861f68
48 changed files with 3833 additions and 175 deletions

65
.trae/rules/main.md Normal file
View 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
View 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
View File

@@ -0,0 +1,7 @@
# TODO (MES_Core)
## Склады (UI)
- Доработать сортировку по дате «Поступление» (стабильно сортировать как datetime, а не как текст).
- По клику на строку открывать карточку «Единица на складе» (read-only для observer, редактирование для admin/technologist/master/clerk):
- правка: сделка, давальческий, размеры (лист/хлыст), количество, примечание (если добавим)
- отображение: история перемещений/приходов/отгрузок (если потребуется).

View File

@@ -59,8 +59,9 @@ INSTALLED_APPS = [
'django.contrib.sessions', 'django.contrib.sessions',
'django.contrib.messages', 'django.contrib.messages',
'django.contrib.staticfiles', 'django.contrib.staticfiles',
'shiftflow', # Вот это допиши обязательно! 'shiftflow',
'warehouse', 'warehouse',
'manufacturing',
] ]
MIDDLEWARE = [ MIDDLEWARE = [

View 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)"

View 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
View 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 = "Перемещения"

View File

43
manufacturing/admin.py Normal file
View 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
View 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)'

View 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')},
},
),
]

View File

97
manufacturing/models.py Normal file
View 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
View File

@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

3
manufacturing/views.py Normal file
View File

@@ -0,0 +1,3 @@
from django.shortcuts import render
# Create your views here.

View File

@@ -1,28 +1,94 @@
import os import os
from django.contrib import admin from django.contrib import admin, messages
from .models import Company, EmployeeProfile, Machine, Deal, ProductionTask, Item
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) @admin.register(Company)
class CompanyAdmin(admin.ModelAdmin): 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) @admin.register(Deal)
class DealAdmin(admin.ModelAdmin): class DealAdmin(admin.ModelAdmin):
list_display = ('number', 'status', 'company') """
Панель администрирования Сделок
"""
list_display = ('number', 'id', 'status', 'company')
list_display_links = ('number',)
search_fields = ('number', 'company__name') search_fields = ('number', 'company__name')
list_filter = ('status', 'company') list_filter = ('status', 'company')
inlines = (DealItemInline,)
# --- Задания на производство (База) --- # --- Задания на производство (База) ---
"""
Панель администрирования Заданий на производство
"""
@admin.register(ProductionTask) @admin.register(ProductionTask)
class ProductionTaskAdmin(admin.ModelAdmin): class ProductionTaskAdmin(admin.ModelAdmin):
list_display = ('drawing_name', 'deal', 'material', 'quantity_ordered', 'created_at') list_display = ('drawing_name', 'deal', 'entity', 'material', 'quantity_ordered', 'created_at')
search_fields = ('drawing_name', 'deal__number') search_fields = ('drawing_name', 'deal__number', 'entity__name', 'entity__drawing_number')
list_filter = ('deal', 'material', 'is_bend') 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) @admin.register(Item)
class ItemAdmin(admin.ModelAdmin): class ItemAdmin(admin.ModelAdmin):
# Что видим в общем списке (используем task__ для доступа к полям базы) # Что видим в общем списке (используем task__ для доступа к полям базы)
@@ -53,13 +119,147 @@ class ItemAdmin(admin.ModelAdmin):
return obj.task.drawing_name if obj.task else "-" return obj.task.drawing_name if obj.task else "-"
get_drawing.short_description = 'Деталь' get_drawing.short_description = 'Деталь'
@admin.register(Workshop)
class WorkshopAdmin(admin.ModelAdmin):
list_display = ('name', 'location')
search_fields = ('name',)
list_filter = ('location',)
@admin.register(Machine) @admin.register(Machine)
class MachineAdmin(admin.ModelAdmin): class MachineAdmin(admin.ModelAdmin):
list_display = ('name', 'machine_type') list_display = ('name', 'machine_type', 'workshop', 'location')
list_filter = ('machine_type',) list_display_links = ('name',)
list_filter = ('machine_type', 'workshop')
search_fields = ('name',) 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) @admin.register(EmployeeProfile)
class EmployeeProfileAdmin(admin.ModelAdmin): class EmployeeProfileAdmin(admin.ModelAdmin):
list_display = ('user', 'role') list_display = ('user', 'role')
filter_horizontal = ('machines',) # Красивый выбор станков двумя колонками filter_horizontal = ('machines',)

View 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-деталях.')

View File

@@ -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')},
},
),
]

View File

@@ -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')},
},
),
]

View File

@@ -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='Цех'),
),
]

View File

@@ -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')},
),
]

View 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='Должность'),
),
]

View File

@@ -15,10 +15,31 @@ class Company(models.Model):
class Meta: class Meta:
verbose_name = "Компания"; verbose_name_plural = "Компании" 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 = [ MACHINE_TYPE_CHOICES = [
@@ -28,6 +49,8 @@ class Machine(models.Model):
name = models.CharField("Название станка", max_length=100) name = models.CharField("Название станка", max_length=100)
machine_type = models.CharField("Тип станка", max_length=10, choices=MACHINE_TYPE_CHOICES, default='linear') 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): def __str__(self):
return self.name return self.name
@@ -59,11 +82,15 @@ class Deal(models.Model):
verbose_name = "Сделка"; verbose_name_plural = "Сделки" verbose_name = "Сделка"; verbose_name_plural = "Сделки"
class ProductionTask(models.Model): class ProductionTask(models.Model):
"""План производства детали по сделке.
Переходный этап:
- сейчас в задаче ещё есть legacy-поля (drawing_name, файлы, material), чтобы не сломать UI;
- целевая модель: task.entity -> manufacturing.ProductEntity, а файлы/превью живут на entity.
""" """
Основание для производства. Определяет ЧТО делать.
Создается технологом или мастером на основе заказа.
"""
deal = models.ForeignKey(Deal, on_delete=models.CASCADE, verbose_name="Сделка") 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="Б/ч") drawing_name = models.CharField("Название детали", max_length=255, blank=True, default="Б/ч")
size_value = models.FloatField("Размер детали", help_text="Длина (мм) или Толщина (мм)") size_value = models.FloatField("Размер детали", help_text="Длина (мм) или Толщина (мм)")
@@ -133,6 +160,171 @@ class DxfPreviewSettings(models.Model):
return "Настройки превью DXF" 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): class DxfPreviewJob(models.Model):
"""Фоновая задача пакетной регенерации превью DXF. """Фоновая задача пакетной регенерации превью DXF.
@@ -239,6 +431,7 @@ class EmployeeProfile(models.Model):
('master', 'Мастер'), ('master', 'Мастер'),
('operator', 'Оператор'), ('operator', 'Оператор'),
('clerk', 'Учетчик'), ('clerk', 'Учетчик'),
('observer', 'Наблюдатель'),
] ]
# Связь 1 к 1 со стандартным юзером Django # Связь 1 к 1 со стандартным юзером Django

View File

@@ -0,0 +1,14 @@
"""
Сервисный слой приложения shiftflow.
Здесь живёт бизнес-логика, которую можно вызывать из:
- view (HTTP)
- admin
- management commands
- фоновых воркеров
Принцип:
- сервисы не зависят от шаблонов/HTML,
- сервисы работают с ORM и транзакциями,
- сервисы содержат правила заводской логики (MES/ERP).
"""

View 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)

View 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,
)

View 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"])

View 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 %}

View File

@@ -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 }}"> <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>
<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>
</div> </div>
{% else %} {% else %}
<div class="alert alert-success">Статус: {{ item.get_status_display }}. Сделано: {{ item.quantity_fact }} шт.</div> <div class="alert alert-success">Статус: {{ item.get_status_display }}. Сделано: {{ item.quantity_fact }} шт.</div>
<input type="hidden" name="quantity_fact" value="{{ item.quantity_fact }}"> <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 %}
{% endif %} {% endif %}
@@ -202,18 +174,6 @@
<input type="number" name="quantity_fact" class="form-control border-secondary" value="{{ item.quantity_fact }}"> <input type="number" name="quantity_fact" class="form-control border-secondary" value="{{ item.quantity_fact }}">
</div> </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>
<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"> <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 %} {% endif %}
{% if user_role == 'clerk' %} {% 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' %} {% 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"> <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> <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 %}> <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> </div>
{% else %} {% 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 %} {% endif %}
<input type="hidden" name="quantity_fact" value="{{ item.quantity_fact }}"> <input type="hidden" name="quantity_fact" value="{{ item.quantity_fact }}">
{% endif %} {% endif %}
@@ -253,7 +198,7 @@
<a href="{{ back_url }}" class="btn btn-outline-secondary">Назад</a> <a href="{{ back_url }}" class="btn btn-outline-secondary">Назад</a>
<div class="d-flex gap-2"> <div class="d-flex gap-2">
<input type="hidden" name="action" id="actionField" value="save"> <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'"> <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>Выполнено <i class="bi bi-check-all me-2"></i>Выполнено
</button> </button>

View 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 %}

View File

@@ -20,6 +20,10 @@ from .views import (
RegistryView, RegistryView,
SteelGradeUpsertView, SteelGradeUpsertView,
TaskItemsView, TaskItemsView,
ClosingView,
WarehouseReceiptCreateView,
WarehouseStocksView,
WarehouseTransferCreateView,
) )
urlpatterns = [ urlpatterns = [
@@ -48,4 +52,10 @@ urlpatterns = [
# Печать сменного листа # Печать сменного листа
path('registry/print/', RegistryPrintView.as_view(), name='registry_print'), path('registry/print/', RegistryPrintView.as_view(), name='registry_print'),
path('item/<int:pk>/', ItemUpdateView.as_view(), name='item_detail'), 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'),
] ]

View File

@@ -14,6 +14,7 @@ from django.core.files.base import ContentFile
from django.db import close_old_connections from django.db import close_old_connections
from django.db.models import Case, ExpressionWrapper, F, IntegerField, Sum, Value, When 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.db.models.functions import Coalesce
from django.http import JsonResponse from django.http import JsonResponse
from django.shortcuts import get_object_or_404, redirect 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.contrib.auth.mixins import LoginRequiredMixin
from django.utils import timezone 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 .forms import ProductionTaskCreateForm
from .models import Company, Deal, DxfPreviewJob, DxfPreviewSettings, EmployeeProfile, Item, Machine, ProductionTask 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' template_name = 'shiftflow/item_detail.html'
# Перечисляем поля, которые можно редактировать в сменке # Перечисляем поля, которые можно редактировать в сменке
fields = [ fields = [
'machine', 'quantity_plan', 'quantity_fact', 'machine',
'status', 'is_synced_1c', 'quantity_plan',
'material_taken', 'usable_waste', 'scrap_weight' 'quantity_fact',
'status',
'is_synced_1c',
] ]
context_object_name = 'item' context_object_name = 'item'
@@ -1296,15 +1304,6 @@ class ItemUpdateView(LoginRequiredMixin, UpdateView):
self.object.quantity_fact = int(quantity_fact) self.object.quantity_fact = int(quantity_fact)
self.object.is_synced_1c = bool(request.POST.get('is_synced_1c')) 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': if action == 'close_done' and self.object.status == 'work':
@@ -1340,88 +1339,26 @@ class ItemUpdateView(LoginRequiredMixin, UpdateView):
if role in ['operator', 'master']: if role in ['operator', 'master']:
action = request.POST.get('action', 'save') 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': 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)
return redirect_back() return redirect_back()
if self.object.status != 'work': qf = request.POST.get('quantity_fact')
return redirect_back() if qf and qf.isdigit():
self.object.quantity_fact = int(qf)
errors = [] machine_changed = False
if not material_taken: if role == 'master':
errors.append('Заполни поле "Взятый материал"') machine_id = request.POST.get('machine')
if not usable_waste: if machine_id and machine_id.isdigit():
errors.append('Заполни поле "Остаток ДО"') self.object.machine_id = int(machine_id)
if scrap_weight_raw == '': machine_changed = True
errors.append('Заполни поле "Лом (кг)" (можно 0)')
scrap_weight = None fields = ['quantity_fact']
if scrap_weight_raw != '': if machine_changed:
try: fields.append('machine')
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()
self.object.save(update_fields=fields)
return redirect_back() return redirect_back()
if role == 'clerk': if role == 'clerk':
@@ -1434,4 +1371,434 @@ class ItemUpdateView(LoginRequiredMixin, UpdateView):
return redirect_back() return redirect_back()
def get_success_url(self): 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}")

View File

@@ -17,6 +17,14 @@
{% endif %} {% endif %}
<main class="container-fluid py-3 flex-grow-1 d-flex flex-column"> <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 %} {% block content %}{% endblock %}
</main> </main>

View File

@@ -15,22 +15,24 @@
<a class="nav-link {% if request.resolver_match.url_name == 'registry' %}active{% endif %}" href="{% url 'registry' %}">Реестр</a> <a class="nav-link {% if request.resolver_match.url_name == 'registry' %}active{% endif %}" href="{% url 'registry' %}">Реестр</a>
</li> </li>
{% if user_role in 'admin,technologist,master,clerk' %} {% if user_role in 'admin,technologist,master,clerk,observer' %}
<li class="nav-item"> <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> <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>
<li class="nav-item"> <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> <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>
<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 %} {% endif %}
{% if user_role in 'admin,technologist,master,operator' %} {% if user_role in 'admin,master,operator,observer' %}
<li class="nav-item"><a class="nav-link" href="#">Закрытие</a></li> <li class="nav-item">
<a class="nav-link {% if request.resolver_match.url_name == 'closing' %}active{% endif %}" href="{% url 'closing' %}">Закрытие</a>
</li>
{% endif %} {% endif %}
{% if user_role in 'admin,technologist,clerk' %}
<li class="nav-item"><a class="nav-link" href="#">Списание</a></li>
{% endif %}
{% if user_role == 'admin' %} {% if user_role == 'admin' %}
<li class="nav-item"> <li class="nav-item">

View File

@@ -1,9 +1,14 @@
from django.contrib import admin from django.contrib import admin, messages
from .models import MaterialCategory, SteelGrade, Material 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) @admin.register(MaterialCategory)
class MaterialCategoryAdmin(admin.ModelAdmin): 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') search_fields = ('name', 'gost_standard')
@admin.register(SteelGrade) @admin.register(SteelGrade)
@@ -17,3 +22,103 @@ class MaterialAdmin(admin.ModelAdmin):
list_filter = ('category', 'steel_grade') list_filter = ('category', 'steel_grade')
search_fields = ('name', 'full_name') search_fields = ('name', 'full_name')
readonly_fields = ('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)

View File

@@ -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': 'Перемещения',
},
),
]

View 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': 'Единицы на складах'},
),
]

View 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': 'Единицы на складе'},
),
]

View File

@@ -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')},
},
),
]

View File

@@ -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='Статус'),
),
]

View 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='Поступление'),
),
]

View 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='Форма'),
),
]

View 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='Давальческий'),
),
]

View 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='Сделка'),
),
]

View 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()

View File

@@ -1,8 +1,20 @@
from django.db import models 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): class MaterialCategory(models.Model):
"""Категория материала (например, Труба, Лист, Круг)""" """Категория материала (например, Труба, Лист, Круг)"""
FORM_FACTOR_CHOICES = [
('sheet', 'Лист'),
('bar', 'Прокат/хлыст'),
('other', 'Прочее'),
]
name = models.CharField("Название категории", max_length=100, unique=True) 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") gost_standard = models.CharField("ГОСТ на тип проката", max_length=255, blank=True, help_text="Напр: ГОСТ 8639-82")
class Meta: class Meta:
@@ -26,7 +38,7 @@ class SteelGrade(models.Model):
return f"{self.name} ({self.gost_standard})" if self.gost_standard else self.name return f"{self.name} ({self.gost_standard})" if self.gost_standard else self.name
class Material(models.Model): class Material(models.Model):
"""Конкретная номенклатурная единица (например, Труба 100х100х4)""" """Конкретная номенклатурная единица (например, Труба 100х100х4)."""
category = models.ForeignKey(MaterialCategory, on_delete=models.PROTECT, verbose_name="Категория") 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) steel_grade = models.ForeignKey(SteelGrade, on_delete=models.PROTECT, verbose_name="Марка стали", null=True, blank=True)
name = models.CharField("Наименование (размер/характеристики)", max_length=255) name = models.CharField("Наименование (размер/характеристики)", max_length=255)
@@ -46,3 +58,134 @@ class Material(models.Model):
def __str__(self): 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() 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}"

View File

@@ -0,0 +1,5 @@
"""
Сервисный слой приложения warehouse.
Здесь живут операции складского учёта, требующие транзакций и блокировок.
"""

View 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'])