Files
MES_Core/shiftflow/models.py
2026-04-13 07:36:57 +03:00

596 lines
29 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from django.db import models
from django.utils import timezone
from django.contrib.auth.models import User
from warehouse.models import Material as WarehouseMaterial
class Company(models.Model):
"""
Справочник контрагентов/заказчиков.
Позволяет группировать сделки по компаниям и избегать дублей в названиях.
"""
name = models.CharField("Название компании", max_length=255, unique=True)
description = models.TextField("Краткое описание / Примечание", blank=True)
def __str__(self): return self.name
class Meta:
verbose_name = "Компания"; verbose_name_plural = "Компании"
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):
"""Справочник производственных постов (ресурсов).
Терминология UI:
- в интерфейсе используется слово «Пост», чтобы одинаково обозначать станок, линию,
камеру, рабочее место или бригаду (как единицу планирования у мастера).
- в базе и коде модель остаётся Machine, чтобы не ломать существующие связи.
Источник склада для операций выработки/списаний:
- предпочитаем склад цеха (Machine.workshop.location)
- поле Machine.location оставлено для совместимости (если цех не задан)
"""
MACHINE_TYPE_CHOICES = [
('linear', 'Линейный'),
('sheet', 'Листовой'),
('post', 'Пост'),
]
name = models.CharField("Название станка", max_length=100)
machine_type = models.CharField("Тип станка", max_length=10, choices=MACHINE_TYPE_CHOICES, default='linear')
workshop = models.ForeignKey('shiftflow.Workshop', on_delete=models.PROTECT, null=True, blank=True, verbose_name='Цех')
location = models.ForeignKey('warehouse.Location', on_delete=models.PROTECT, null=True, blank=True, verbose_name="Склад участка (устаревает)")
def __str__(self):
return self.name
class Meta:
verbose_name = "Станок"; verbose_name_plural = "Станки"
class Deal(models.Model):
"""
Заказ или проект. Номер парсится из пути к файлам.
Служит контейнером для группы деталей (позиций).
"""
STATUS_CHOICES = [
('lead', 'Зашла'),
('work', 'В работе'),
('done', 'Завершена'),
]
number = models.CharField("№ Сделки", max_length=100, unique=True)
status = models.CharField("Статус", max_length=10, choices=STATUS_CHOICES, default='work')
company = models.ForeignKey(Company, on_delete=models.PROTECT, verbose_name="Заказчик", null=True, blank=True)
description = models.TextField("Описание сделки", blank=True, help_text="Общая информация по заказу")
due_date = models.DateField("Срок отгрузки", null=True, blank=True)
def __str__(self):
return f"Сделка №{self.number} ({self.company})"
class Meta:
verbose_name = "Сделка"; verbose_name_plural = "Сделки"
class ProductionTask(models.Model):
"""План производства детали по сделке.
Переходный этап:
- сейчас в задаче ещё есть legacy-поля (drawing_name, файлы, material), чтобы не сломать UI;
- целевая модель: task.entity -> manufacturing.ProductEntity, а файлы/превью живут на entity.
"""
deal = models.ForeignKey(Deal, on_delete=models.CASCADE, verbose_name="Сделка")
entity = models.ForeignKey('manufacturing.ProductEntity', on_delete=models.PROTECT, null=True, blank=True, verbose_name="КД (изделие/деталь)")
drawing_name = models.CharField("Название детали", max_length=255, blank=True, default="Б/ч")
size_value = models.FloatField("Размер детали", help_text="Длина (мм) или Толщина (мм)")
drawing_file = models.FileField("Исходник (DXF/IGES)", upload_to="drawings/%Y/%m/", blank=True, null=True)
extra_drawing = models.FileField("Доп. чертеж (PDF)", upload_to="extra_drawings/%Y/%m/", blank=True, null=True)
preview_image = models.ImageField("Превью DXF (PNG)", upload_to="task_previews/%Y/%m/", blank=True, null=True)
blank_dimensions = models.CharField("Габариты заготовки", max_length=64, blank=True, default="")
material = models.ForeignKey(WarehouseMaterial, on_delete=models.PROTECT, verbose_name="Материал", null=True, blank=True)
quantity_ordered = models.PositiveIntegerField("Заказано всего, шт")
is_bend = models.BooleanField("Гибка", default=False)
created_at = models.DateTimeField("Дата создания", auto_now_add=True)
class Meta:
verbose_name = "Задание на деталь"; verbose_name_plural = "Задания на детали"
ordering = ['-created_at']
def __str__(self):
return f"{self.drawing_name} (Заказ {self.deal.number})"
class DxfPreviewSettings(models.Model):
"""Настройки генерации превью для DXF.
Храним в БД, чтобы админ мог менять параметры через страницу «Обслуживание сервера»
без правок кода.
Сделано как singleton: ожидается одна строка (обычно pk=1).
"""
line_color = models.CharField(
"Цвет линий превью (HEX)",
max_length=16,
default="#006400",
help_text="Напр: #006400 (тёмно-зелёный)",
)
lineweight_scaling = models.FloatField(
"Коэффициент толщины линий",
default=1.0,
help_text="1.0 = как в DXF, 2.0 = толще, 0.5 = тоньше",
)
min_lineweight = models.FloatField(
"Минимальная толщина (мм)",
default=0.1,
help_text="Если в DXF нет lineweight — используем минимум, чтобы линии были видимы",
)
keep_original_colors = models.BooleanField(
"Оставить цвета оригинальные",
default=False,
help_text="Если включено — не перекрашиваем линии, берём цвета из DXF",
)
per_task_timeout_sec = models.PositiveIntegerField(
"Таймаут на 1 DXF (сек)",
default=45,
help_text="Если конкретный DXF завис — убиваем обработку этой детали и идём дальше",
)
updated_at = models.DateTimeField("Обновлено", auto_now=True)
class Meta:
verbose_name = "Настройки превью DXF"
verbose_name_plural = "Настройки превью DXF"
def __str__(self):
return "Настройки превью DXF"
class DealItem(models.Model):
"""Состав сделки: что заказал клиент.
Примечание: при поставках частями используем DealDeliveryBatch/DealBatchItem.
"""
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 DealDeliveryBatch(models.Model):
"""Партия поставки по сделке (поставка частями)."""
deal = models.ForeignKey(Deal, on_delete=models.CASCADE, related_name='delivery_batches', verbose_name='Сделка')
name = models.CharField('Название', max_length=120, blank=True, default='')
due_date = models.DateField('Плановая отгрузка')
is_default = models.BooleanField('Дефолтная партия (остаток)', default=False)
created_at = models.DateTimeField('Создано', auto_now_add=True)
class Meta:
verbose_name = 'Партия поставки'
verbose_name_plural = 'Партии поставки'
ordering = ('deal', 'due_date', 'id')
def __str__(self):
label = self.name.strip() or f"Партия {self.id}"
return f"{self.deal.number}: {label} ({self.due_date:%d.%m.%Y})"
class DealBatchItem(models.Model):
"""Строка партии поставки: что и сколько отгружаем в эту дату.
started_qty — сколько уже запущено в производство по этой партии.
"""
batch = models.ForeignKey(DealDeliveryBatch, on_delete=models.CASCADE, related_name='items', verbose_name='Партия')
entity = models.ForeignKey('manufacturing.ProductEntity', on_delete=models.PROTECT, verbose_name='Изделие/деталь')
quantity = models.PositiveIntegerField('Количество, шт')
started_qty = models.PositiveIntegerField('Запущено в производство, шт', default=0)
class Meta:
verbose_name = 'Строка партии'
verbose_name_plural = 'Строки партий'
unique_together = ('batch', 'entity')
ordering = ('batch', 'entity__entity_type', 'entity__drawing_number', 'entity__name', 'id')
def __str__(self):
return f"{self.batch}: {self.entity} x{self.quantity}"
class DealEntityProgress(models.Model):
"""Текущая операция техпроцесса для пары (сделка, сущность).
Комментарий: current_seq=1 означает «выполняем 1-ю операцию в EntityOperation».
Когда current_seq больше числа операций — сущность для сделки считается прошедшей техпроцесс.
"""
deal = models.ForeignKey(Deal, on_delete=models.CASCADE, verbose_name='Сделка')
entity = models.ForeignKey('manufacturing.ProductEntity', on_delete=models.PROTECT, verbose_name='Сущность')
current_seq = models.PositiveSmallIntegerField('Текущая операция (порядок)', default=1)
class Meta:
verbose_name = 'Прогресс по операции'
verbose_name_plural = 'Прогресс по операциям'
unique_together = ('deal', 'entity')
def __str__(self):
return f"{self.deal.number}: {self.entity} -> {self.current_seq}"
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 ProcurementRequirement(models.Model):
"""
Потребность в закупке покупных комплектующих, литья и кооперации для сделки.
Рассчитывается при взрыве BOM (с учетом свободных остатков на складах).
"""
STATUS_CHOICES = [
('to_order', 'К заказу'),
('ordered', 'Заказано'),
('closed', 'Закрыто'),
]
deal = models.ForeignKey(Deal, on_delete=models.CASCADE, verbose_name='Сделка')
component = models.ForeignKey('manufacturing.ProductEntity', on_delete=models.PROTECT, verbose_name='Компонент (покупное/литье)')
required_qty = models.PositiveIntegerField('Потребность (к закупке), шт')
status = models.CharField('Статус', max_length=20, choices=STATUS_CHOICES, default='to_order')
class Meta:
verbose_name = 'Потребность снабжения'
verbose_name_plural = 'Потребности снабжения'
unique_together = ('deal', 'component')
def __str__(self):
return f"{self.deal.number}: {self.component} -> {self.required_qty}"
class WorkItem(models.Model):
deal = models.ForeignKey(Deal, on_delete=models.CASCADE, verbose_name='Сделка')
entity = models.ForeignKey('manufacturing.ProductEntity', on_delete=models.PROTECT, verbose_name='Сущность')
# Комментарий: operation — основной признак операции (расширяемый справочник).
operation = models.ForeignKey('manufacturing.Operation', on_delete=models.PROTECT, null=True, blank=True, verbose_name='Операция')
# Комментарий: stage оставляем строкой для совместимости с текущими фильтрами/экраном, но без choices.
stage = models.CharField('Стадия', max_length=32, blank=True, default='')
machine = models.ForeignKey('shiftflow.Machine', on_delete=models.PROTECT, null=True, blank=True, verbose_name='Станок/участок')
workshop = models.ForeignKey('shiftflow.Workshop', on_delete=models.PROTECT, null=True, blank=True, verbose_name='Цех')
quantity_plan = models.PositiveIntegerField('В план, шт', default=0)
quantity_done = models.PositiveIntegerField('Сделано, шт', default=0)
STATUS_CHOICES = [
('planned', 'В работе'),
('leftover', 'Недодел'),
('done', 'Закрыта'),
]
status = models.CharField('Статус', max_length=16, choices=STATUS_CHOICES, default='planned')
date = models.DateField('Дата', default=timezone.localdate)
comment = models.TextField('Комментарий', blank=True, default='')
class Meta:
verbose_name = 'План работ'
verbose_name_plural = 'План работ'
def __str__(self):
return f"{self.deal.number}: {self.entity} [{self.stage}] {self.quantity_plan}"
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)
is_synced_1c = models.BooleanField('Выгружено в 1С', default=False)
synced_1c_at = models.DateTimeField('Выгружено в 1С (время)', null=True, blank=True)
synced_1c_by = models.ForeignKey(
User,
on_delete=models.PROTECT,
null=True,
blank=True,
related_name='synced_cutting_sessions',
verbose_name='Выгрузил в 1С',
)
class Meta:
verbose_name = 'Производственный отчет'
verbose_name_plural = 'Производственные отчеты'
def __str__(self):
return f"{self.date} {self.machine} ({self.operator})"
class ProductionReportConsumption(models.Model):
"""Строка списания сырья в рамках производственного отчёта.
Переходная схема:
- целевой ввод делается по номенклатуре (material);
- legacy-поле stock_item оставлено временно, чтобы мигрировать существующие записи.
После переноса данных stock_item будет удалён, а material станет обязательным.
"""
report = models.ForeignKey(CuttingSession, related_name='consumptions', on_delete=models.CASCADE, verbose_name='Производственный отчет')
material = models.ForeignKey(WarehouseMaterial, on_delete=models.PROTECT, null=True, blank=True, verbose_name='Материал')
stock_item = models.ForeignKey('warehouse.StockItem', on_delete=models.PROTECT, null=True, blank=True, verbose_name='Сырье (позиция склада, legacy)')
quantity = models.FloatField('Списано (ед.)')
class Meta:
verbose_name = 'Списание сырья'
verbose_name_plural = 'Списание сырья'
unique_together = ('report', 'material')
def __str__(self):
return f"{self.report_id}: {self.material} - {self.quantity}"
class ProductionReportRemnant(models.Model):
"""Деловой остаток, который нужно начислить по итогам производственного отчёта."""
report = models.ForeignKey(CuttingSession, related_name='remnants', on_delete=models.CASCADE, verbose_name='Производственный отчет')
material = models.ForeignKey(WarehouseMaterial, on_delete=models.PROTECT, verbose_name='Материал')
quantity = models.FloatField('Количество (ед.)', default=1.0)
current_length = models.FloatField('Текущая длина, мм', null=True, blank=True)
current_width = models.FloatField('Текущая ширина, мм', null=True, blank=True)
unique_id = models.CharField('ID/Маркировка', max_length=50, null=True, blank=True)
class Meta:
verbose_name = 'Деловой остаток'
verbose_name_plural = 'Деловые остатки'
def __str__(self):
return f"{self.report_id}: {self.material}"
class ProductionReportStockResult(models.Model):
"""След созданных складских позиций по отчету (готовые детали и деловые остатки)."""
KIND_CHOICES = [
('finished', 'Готовая деталь'),
('remnant', 'Деловой остаток'),
]
report = models.ForeignKey(CuttingSession, related_name='results', on_delete=models.CASCADE, verbose_name='Производственный отчет')
stock_item = models.ForeignKey('warehouse.StockItem', on_delete=models.PROTECT, verbose_name='Созданная позиция склада')
kind = models.CharField('Тип', max_length=16, choices=KIND_CHOICES)
class Meta:
verbose_name = 'Результат отчета'
verbose_name_plural = 'Результаты отчета'
unique_together = ('report', 'stock_item')
def __str__(self):
return f"{self.report_id}: {self.stock_item_id}"
class ShiftItem(models.Model):
"""Фиксация выработки в рамках производственного отчёта."""
session = models.ForeignKey(CuttingSession, related_name='tasks', on_delete=models.CASCADE)
task = models.ForeignKey(ProductionTask, on_delete=models.PROTECT, verbose_name='Плановое задание')
quantity_fact = models.PositiveIntegerField('Изготовлено (факт), шт', default=0)
material_substitution = models.BooleanField('Замена материала по факту', default=False)
class Meta:
verbose_name = 'Фиксация выработки'
verbose_name_plural = 'Фиксации выработки'
def __str__(self):
return f"{self.session} -> {self.task}"
class DxfPreviewJob(models.Model):
"""Фоновая задача пакетной регенерации превью DXF.
Зачем нужна:
- генерация превью и bbox может быть тяжёлой и в синхронном POST «вешает» ответ;
- поэтому мы запускаем задачу в фоне и пишем прогресс в БД;
- UI может показывать статус/счётчики без ожидания завершения.
Важно:
- это не Celery и не очередь, а простой «фон» для текущего процесса Django;
- для продакшена лучше вынести в полноценный воркер, но этот вариант уже убирает зависания UI.
"""
STATUS_CHOICES = [
('queued', 'В очереди'),
('running', 'Выполняется'),
('done', 'Готово'),
('failed', 'Ошибка'),
('cancelled', 'Остановлено'),
]
status = models.CharField("Статус", max_length=16, choices=STATUS_CHOICES, default='queued')
created_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, verbose_name="Запустил")
cancel_requested = models.BooleanField(
"Запрошена остановка",
default=False,
help_text="Если включено — воркер завершит задачу после текущей детали",
)
pid = models.PositiveIntegerField(
"PID процесса",
null=True,
blank=True,
help_text="Номер процесса, который выполняет задачу (для диагностики)",
)
started_at = models.DateTimeField("Начато", null=True, blank=True)
finished_at = models.DateTimeField("Завершено", null=True, blank=True)
total = models.PositiveIntegerField("Всего задач", default=0)
processed = models.PositiveIntegerField("Обработано", default=0)
updated = models.PositiveIntegerField("Обновлено", default=0)
skipped = models.PositiveIntegerField("Пропущено", default=0)
errors = models.PositiveIntegerField("Ошибок", default=0)
last_message = models.CharField("Сообщение", max_length=255, blank=True, default='')
created_at = models.DateTimeField("Создано", auto_now_add=True)
class Meta:
verbose_name = "Задача превью DXF"
verbose_name_plural = "Задачи превью DXF"
ordering = ['-id']
def __str__(self):
return f"DXF превью: {self.get_status_display()}"
class Item(models.Model):
"""
Единица сменного задания. Определяет КТО, КОГДА и СКОЛЬКО сделал.
"""
STATUS_CHOICES = [
('work', 'В работе'),
('done', 'Выполнено'),
('partial', 'Частично'),
('leftover', 'Недодел'),
]
# --- Ссылка на основу (временно null=True для миграции старых данных) ---
task = models.ForeignKey(ProductionTask, on_delete=models.CASCADE, related_name='items', verbose_name="Задание", null=True, blank=True)
# --- Смена (заполняет мастер) ---
date = models.DateField("Дата смены", default=timezone.localdate)
machine = models.ForeignKey(Machine, on_delete=models.PROTECT, verbose_name="Станок")
quantity_plan = models.PositiveIntegerField("План на смену, шт")
# --- Исполнение (заполняет оператор) ---
quantity_fact = models.PositiveIntegerField("Факт, шт", default=0)
material_taken = models.TextField("Взятый материал", blank=True, help_text="Напр: 3 трубы по 12м")
usable_waste = models.TextField("Деловой отход", blank=True, help_text="Напр: кусок 1500мм")
scrap_weight = models.FloatField("Лом (кг)", default=0.0)
# --- Статусы и учет ---
status = models.CharField("Статус", max_length=10, choices=STATUS_CHOICES, default='work')
is_synced_1c = models.BooleanField("Учтено в 1С", default=False)
class Meta:
verbose_name = "Пункт сменки"; verbose_name_plural = "Реестр сменных заданий"
ordering = ['-date', 'task__deal']
def __str__(self):
if self.task:
return f"{self.task.drawing_name} - {self.date}"
return f"Без задания - {self.date}"
class EmployeeProfile(models.Model):
ROLE_CHOICES = [
('admin', 'Администратор'),
('technologist', 'Технолог'),
('master', 'Мастер'),
('operator', 'Оператор'),
('clerk', 'Учетчик'),
('observer', 'Наблюдатель'),
('manager', 'Руководитель'),
]
# Связь 1 к 1 со стандартным юзером Django
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='profile', verbose_name='Пользователь')
role = models.CharField(max_length=20, choices=ROLE_CHOICES, default='operator', verbose_name='Должность')
# Комментарий: режим для руководителя/наблюдателя — видит всё, но любые изменения запрещены.
is_readonly = models.BooleanField('Только просмотр', default=False)
# Привязка станков (можно выбрать несколько для одного оператора)
machines = models.ManyToManyField('Machine', blank=True, verbose_name='Закрепленные станки')
# Комментарий: ограничение видимости/действий по цехам.
# Если список пустой — считаем, что доступ не ограничен (админ/технолог/руководитель).
allowed_workshops = models.ManyToManyField('Workshop', blank=True, verbose_name='Доступные цеха')
def __str__(self):
return f"{self.user.username} - {self.get_role_display()}"
class Meta:
verbose_name = 'Профиль сотрудника'
verbose_name_plural = 'Профили сотрудников'