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