Конкретно пересмотрел логику работы. Легаси вынесена в архив
All checks were successful
Deploy MES Core / deploy (push) Successful in 13s

This commit is contained in:
2026-04-13 07:36:57 +03:00
parent 86215c9fa8
commit 28537447f8
80 changed files with 10246 additions and 684 deletions

View File

@@ -35,7 +35,12 @@ class Workshop(models.Model):
class Machine(models.Model):
"""Список производственных участков (станков).
"""Справочник производственных постов (ресурсов).
Терминология UI:
- в интерфейсе используется слово «Пост», чтобы одинаково обозначать станок, линию,
камеру, рабочее место или бригаду (как единицу планирования у мастера).
- в базе и коде модель остаётся Machine, чтобы не ломать существующие связи.
Источник склада для операций выработки/списаний:
- предпочитаем склад цеха (Machine.workshop.location)
@@ -45,6 +50,7 @@ class Machine(models.Model):
MACHINE_TYPE_CHOICES = [
('linear', 'Линейный'),
('sheet', 'Листовой'),
('post', 'Пост'),
]
name = models.CharField("Название станка", max_length=100)
@@ -74,6 +80,7 @@ class Deal(models.Model):
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})"
@@ -100,7 +107,7 @@ class ProductionTask(models.Model):
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="Материал")
material = models.ForeignKey(WarehouseMaterial, on_delete=models.PROTECT, verbose_name="Материал", null=True, blank=True)
quantity_ordered = models.PositiveIntegerField("Заказано всего, шт")
is_bend = models.BooleanField("Гибка", default=False)
@@ -161,7 +168,10 @@ class DxfPreviewSettings(models.Model):
class DealItem(models.Model):
"""Состав сделки: что заказал клиент (точка входа для BOM Explosion)."""
"""Состав сделки: что заказал клиент.
Примечание: при поставках частями используем 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='Изделие/деталь')
@@ -176,6 +186,66 @@ class DealItem(models.Model):
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):
"""Потребность в закупке сырья для сделки.
@@ -212,6 +282,65 @@ class MaterialRequirement(models.Model):
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):
"""Производственный отчет (основа для списания/начисления).
@@ -238,6 +367,17 @@ class CuttingSession(models.Model):
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 = 'Производственные отчеты'
@@ -392,7 +532,6 @@ class Item(models.Model):
('done', 'Выполнено'),
('partial', 'Частично'),
('leftover', 'Недодел'),
('imported', 'Импортировано'),
]
# --- Ссылка на основу (временно null=True для миграции старых данных) ---
@@ -432,14 +571,23 @@ class EmployeeProfile(models.Model):
('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()}"