All checks were successful
Deploy MES Core / deploy (push) Successful in 13s
194 lines
9.6 KiB
Python
194 lines
9.6 KiB
Python
from django.db import models
|
||
from django.contrib.auth.models import User
|
||
from django.utils import timezone
|
||
from django.core.exceptions import ValidationError
|
||
import uuid
|
||
|
||
class MaterialCategory(models.Model):
|
||
"""Категория материала (например, Труба, Лист, Круг)"""
|
||
|
||
FORM_FACTOR_CHOICES = [
|
||
('sheet', 'Лист'),
|
||
('bar', 'Прокат/хлыст'),
|
||
('other', 'Прочее'),
|
||
]
|
||
|
||
name = models.CharField("Название категории", max_length=100, unique=True)
|
||
form_factor = models.CharField('Форма', max_length=16, choices=FORM_FACTOR_CHOICES, default='other')
|
||
gost_standard = models.CharField("ГОСТ на тип проката", max_length=255, blank=True, help_text="Напр: ГОСТ 8639-82")
|
||
|
||
class Meta:
|
||
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()
|
||
|
||
|
||
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)
|
||
|
||
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)
|
||
|
||
is_archived = models.BooleanField('В архиве', default=False)
|
||
archived_at = models.DateTimeField('Дата архивации', 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}"
|