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 = 'Профили сотрудников'