diff --git a/### shiftflow/templates/shiftflow/steel_grades_catalog.html django b/### shiftflow/templates/shiftflow/steel_grades_catalog.html django new file mode 100644 index 0000000..9cc4010 --- /dev/null +++ b/### shiftflow/templates/shiftflow/steel_grades_catalog.html django @@ -0,0 +1,129 @@ +{% extends 'base.html' %} + +{% block content %} +
+
+

Марки стали

+ +
+
+ + + Сброс +
+ + {% if can_edit %} + + {% endif %} +
+
+ +
+ + + + + + + + + {% for g in grades %} + + + + + {% empty %} + + {% endfor %} + +
НазваниеГОСТ
{{ g.name }}{{ g.gost_standard|default:"—" }}
Нет данных
+
+
+ +
+ Назад +
+ + + + +{% endblock %} \ No newline at end of file diff --git a/.trae/rules/main.md b/.trae/rules/main.md index 2255cc5..a2111ba 100644 --- a/.trae/rules/main.md +++ b/.trae/rules/main.md @@ -8,18 +8,13 @@ # AI_RULES — правила работы ассистента в проекте MES_Core ## 1) Коммуникация -- Пиши по-русски (если пользователь пишет по-русски). -- Не используй формулировки вида «по твоей просьбе», «добавил для тебя», «как договаривались» в комментариях к коду. -- Если предлагаешь новые файлы — всегда указывай: полное имя, абсолютный путь и весь контент в одном блоке. +- Пиши по-русски всегда. ## 2) Изменения в коде -- Любые правки существующих файлов показывай через diff-превью (SEARCH/REPLACE). -- Не вставляй “просто код” для существующих файлов без diff-превью. - Сначала читай файл и только потом предлагай правки (чтобы не ломать стиль и импорты). -- При создании новых файлов заголовок блока должен быть: язык + путь, например: python MES_Core/warehouse/services.py ## 3) Создание новых файлов -- Для новых файлов заголовок блока должен быть: язык + путь, например: python MES_Core/warehouse/services.py +- Для новых файлов звсегда указывай: полное имя, абсолютный путь и весь контент в одном блоке. ## 4)Комментарии - В Python/бекенде: @@ -27,19 +22,17 @@ - комментарии должны быть нейтральными и описывать поведение/причину, без личных формулировок. - В HTML-шаблонах Django: - не добавляй template-комментарии {# ... #} . -- В остальных местах: - - не добавляй комментарии “для красоты”; только там, где они реально помогают поддержке. ## 5) Стиль и конвенции проекта - Смотри на соседние файлы и придерживайся уже принятого стиля (структура, именование, импорты, форматирование). - Не вводи новые библиотеки/фреймворки, пока не проверил, что они уже используются в проекте. - Для UI-таблиц: - если добавляешь новую таблицу — по умолчанию делай её сортируемой (если не мешает UX). - - для колонок с кнопками/прогрессом/иконками отключай сортировку. - Использовать Service Layer: сложная логика живет в services.py, вьюхи остаются тонкими. - Импорты моделей из других приложений — через строковые ссылки в полях ('app.Model') для избежания циклических импортов. + ## 6) Безопасность и секреты - Никогда не логируй и не печатай в stdout: - SECRET_KEY @@ -57,13 +50,36 @@ - с датой/временем - доступны из интерфейса “Обслуживание сервера” (tail) - очищаемы кнопкой (если задача не running) + ## 8) Транзакции и гонки данных (warehouse/shiftflow) - Все операции списания/начисления на складе делай в transaction.atomic() . - На изменяемые складские остатки используй select_for_update() чтобы избежать гонок. - Для массовых операций избегай N+1: - select_related / prefetch_related - - bulk update/create там, где это безопасно. + - bulk update/create там, где это безопасно. +## 9) Роли и доступ (Django Groups) +- Использовать Django Groups как роли приложения (мульти-роли). +- Имена групп должны совпадать с кодами ролей, используемых в коде, например: + - operator + - master + - technologist + - clerk + - supply + - prod_head + - director + - observer + - admin +- Назначение ролей в Django admin: + - Users → выбрать пользователя → поле Groups → добавить нужные группы → Save. +- Примечание: на этапе миграции допускается fallback на EmployeeProfile.role, чтобы при деплое до раздачи групп доступ не "слетал". + +### Назначение станков и цехов пользователю +- Привязка станков/цехов делается через профиль сотрудника: + - Shiftflow → Employee profiles → выбрать профиль пользователя. + - Machines: закреплённые станки (для операторов). + - Allowed workshops: доступные цеха (ограничение видимости/действий). + - Is readonly: режим "только просмотр". Правило для новых внутренних функций (как договор): diff --git a/README.md b/README.md index d9bee88..e592d18 100644 --- a/README.md +++ b/README.md @@ -67,4 +67,45 @@ git push origin main Если сайт по адресу `192.168.1.108` не открывается: 1. Проверь логи контейнеров: `docker compose logs -f`. 2. Убедись, что порты в `docker-compose.yml` стоят `80:80`. -3. Перезапусти всё одной командой: `docker compose up -d --build`. \ No newline at end of file +3. Перезапусти всё одной командой: `docker compose up -d --build`. + +--- + +# 👤 Роли и доступ (Django Admin) + +В проекте используются **Django Groups как роли** (можно назначать несколько ролей одному пользователю). + +## 1) Роли (Groups) +Имена групп должны совпадать с кодами ролей: +- `operator` +- `master` +- `technologist` +- `clerk` +- `supply` +- `prod_head` +- `director` +- `observer` +- `admin` + +Важно: +- Название группы — это "код роли" и используется прямо в коде (чувствительно к регистру). +- Писать строчными латиницей, без пробелов. +- Для панели снабженца используется группа `supply` (экран `/procurement/`). + +Как выдать роль пользователю: +1. Открой Django admin: `/admin/` +2. `Users` → выбери пользователя +3. Поле `Groups` → добавь нужные группы +4. `Save` + +Примечание: на переходном этапе может использоваться fallback на `EmployeeProfile.role`, чтобы при деплое до раздачи групп доступ не "слетал". + +## 2) Назначение станков и цехов пользователю +Станки/цеха назначаются через профиль сотрудника (Shiftflow): +1. Django admin: `/admin/` +2. `Shiftflow` → `Employee profiles` → выбрать профиль пользователя +3. Поля: + - `Machines` — закреплённые станки (обычно для операторов) + - `Allowed workshops` — доступные цеха (ограничение видимости/действий) + - `Is readonly` — режим "только просмотр" (удобно для руководителя/наблюдателя) +4. `Save` \ No newline at end of file diff --git a/TODO.md b/TODO.md index d6ddf7c..d7000ee 100644 --- a/TODO.md +++ b/TODO.md @@ -5,6 +5,13 @@ - По клику на строку открывать карточку «Единица на складе» (read-only для observer, редактирование для admin/technologist/master/clerk): - правка: сделка, давальческий, размеры (лист/хлыст), количество, примечание (если добавим) - отображение: история перемещений/приходов/отгрузок (если потребуется). +- Реализовать инвентаризацию складов участков/цехов: + - сценарий: фактический пересчёт, ввод корректировок (излишек/недостача), фиксация причины + - хранить историю инвентаризаций и разницы по позициям + - права: master/clerk/admin, read-only для observer + +## Доступы (UI) +- Доработать видимость и действия для разных ролей/цехов: фильтрация по allowed_workshops, замещение, read-only руководитель. ## Списание (UI) - Доработать страницу «Списание»: фильтры, удобная сводка по материалам/изделиям и отметка «внесено в 1С». @@ -13,4 +20,33 @@ - Пересмотреть расчёт потребности: уйти от м²/мм, формировать пачки DXF по материалам/толщинам и прокат по длинам (для nesting/ручного расчёта). ## Изделия (Сборка) -- Проработать интерфейс сборки изделия: редактирование состава, паспорт узла, маршруты, сварные швы, быстрые переходы по уровням. \ No newline at end of file +- Проработать интерфейс сборки изделия: редактирование состава, паспорт узла, маршруты, сварные швы, быстрые переходы по уровням. + +# TODO: Миграция сменных заданий на WorkItem + +- WorkItem как единая сущность сменных назначений: + - operation/workshop обязательны; machine — опционально + - plan/done/status/date — общие поля + - запись создаётся в planning_deal (кнопка «В смену») + +- Переход от Item к WorkItem: + - Экраны «Реестр сменных заданий» и «Закрытие смены» + - читать и отображать WorkItem + - для резки предусмотреть учёт списаний/остатков; временно можно дублировать Item ← WorkItem (мост) + - Data‑migration: + - перенести исторические Item → WorkItem (deal, entity, date, machine, qty_plan, qty_fact, status) + - восстановить operation/workshop по EntityOperation + DealEntityProgress/историческим правилам + - Постепенное отключение Item: + - заменить все места создания Item на WorkItem + - после стабилизации убрать Item из UI и сервисов + +- Прогресс/план по сделке: + - верхняя таблица «Позиции сделки»: Надо / Запущено / Осталось (по DealBatchItem.started_qty) + - факт (Сделано) — от WorkItem.quantity_done на последней операции техпроцесса + +- Снабжение: + - покупное/литьё/аутсорс — не создавать ProductionTask, вести учёт как ProcurementRequirement + - вывести сводку потребностей для снабженца (группировка по сделке/позиции/сроку) + +- Логи и диагностика: + - единый логгер `mes` для всех сервисных действий (включая explode_roots_additive и start_batch_item_production) \ No newline at end of file diff --git a/main copy.md b/main copy.md new file mode 100644 index 0000000..2255cc5 --- /dev/null +++ b/main copy.md @@ -0,0 +1,74 @@ +# AI_RULES — правила работы ассистента в проекте MES_Core +Роль: Ты Senior Django Backend Developer. + +Контекст: Разрабатывается MES/ERP система для металлообрабатывающего завода. Архитектура БД разделена на 3 приложения: warehouse, manufacturing, shiftflow. + +Задача: Разработать слой бизнес-логики (сервисы и CBV Views) для реализации сквозного процесса производства. + +# AI_RULES — правила работы ассистента в проекте MES_Core + +## 1) Коммуникация +- Пиши по-русски (если пользователь пишет по-русски). +- Не используй формулировки вида «по твоей просьбе», «добавил для тебя», «как договаривались» в комментариях к коду. +- Если предлагаешь новые файлы — всегда указывай: полное имя, абсолютный путь и весь контент в одном блоке. + +## 2) Изменения в коде +- Любые правки существующих файлов показывай через diff-превью (SEARCH/REPLACE). +- Не вставляй “просто код” для существующих файлов без diff-превью. +- Сначала читай файл и только потом предлагай правки (чтобы не ломать стиль и импорты). +- При создании новых файлов заголовок блока должен быть: язык + путь, например: python MES_Core/warehouse/services.py + +## 3) Создание новых файлов +- Для новых файлов заголовок блока должен быть: язык + путь, например: python MES_Core/warehouse/services.py + +## 4)Комментарии +- В Python/бекенде: + - добавляй поясняющие комментарии там, где есть бизнес-логика, транзакции, конкурентность, фоновые задачи, сложные алгоритмы (BOM, списания, начисления). + - комментарии должны быть нейтральными и описывать поведение/причину, без личных формулировок. +- В HTML-шаблонах Django: + - не добавляй template-комментарии {# ... #} . +- В остальных местах: + - не добавляй комментарии “для красоты”; только там, где они реально помогают поддержке. + + ## 5) Стиль и конвенции проекта +- Смотри на соседние файлы и придерживайся уже принятого стиля (структура, именование, импорты, форматирование). +- Не вводи новые библиотеки/фреймворки, пока не проверил, что они уже используются в проекте. +- Для UI-таблиц: + - если добавляешь новую таблицу — по умолчанию делай её сортируемой (если не мешает UX). + - для колонок с кнопками/прогрессом/иконками отключай сортировку. + + - Использовать Service Layer: сложная логика живет в services.py, вьюхи остаются тонкими. + + - Импорты моделей из других приложений — через строковые ссылки в полях ('app.Model') для избежания циклических импортов. +## 6) Безопасность и секреты +- Никогда не логируй и не печатай в stdout: + - SECRET_KEY + - пароли БД + - токены +- В логи допускаются только технические сообщения, ошибки и диагностические данные без секретов. + - В models.py всегда использовать on_delete=models.PROTECT для важных справочников (Металл, Сделки), чтобы нельзя было случайно удалить историю. +## 7) Логи и фоновые задачи +- Для долгих операций (рендер превью, массовые обновления, BOM explosion для больших заказов): + - не блокируй HTTP-ответ + - Использовать модуль threading для запуска таких задач в отдельном потоке. + + - Обязательно оборачивать фоновую функцию в try/except и логировать ошибки в БД или файл, так как ошибки в потоках не видны во вьюхе. +- Логи фоновых задач должны быть: + - с датой/временем + - доступны из интерфейса “Обслуживание сервера” (tail) + - очищаемы кнопкой (если задача не running) +## 8) Транзакции и гонки данных (warehouse/shiftflow) +- Все операции списания/начисления на складе делай в transaction.atomic() . +- На изменяемые складские остатки используй select_for_update() чтобы избежать гонок. +- Для массовых операций избегай N+1: + - select_related / prefetch_related + - bulk update/create там, где это безопасно. + + + Правило для новых внутренних функций (как договор): + +- Всегда берём логгер logger = logging.getLogger('mes') +- Перед выполнением — logger.info('fn:start ...', ключевые параметры) +- После успешного выполнения — logger.info('fn:done ...', ключевые результаты) +- На важных шагах — logger.info('fn:step ...', детали) +- Исключение — с context: logger.exception('fn:error ...') — не глотаем, пробрасываем дальше \ No newline at end of file diff --git a/manufacturing/admin.py b/manufacturing/admin.py index efc9a0a..4cc8f4d 100644 --- a/manufacturing/admin.py +++ b/manufacturing/admin.py @@ -1,12 +1,21 @@ from django.contrib import admin -from .models import BOM, ProductEntity, RouteStub +from .models import BOM, EntityOperation, Operation, ProductEntity -@admin.register(RouteStub) -class RouteStubAdmin(admin.ModelAdmin): - list_display = ('name',) - search_fields = ('name',) +@admin.register(Operation) +class OperationAdmin(admin.ModelAdmin): + list_display = ('name', 'code', 'workshop') + search_fields = ('name', 'code') + list_filter = ('workshop',) + autocomplete_fields = ('workshop',) + + +class EntityOperationInline(admin.TabularInline): + model = EntityOperation + fields = ('seq', 'operation') + autocomplete_fields = ('operation',) + extra = 5 class BOMChildInline(admin.TabularInline): @@ -31,8 +40,8 @@ class ProductEntityAdmin(admin.ModelAdmin): ) list_filter = ('entity_type', 'planned_material__category') search_fields = ('drawing_number', 'name', 'planned_material__name', 'planned_material__full_name') - autocomplete_fields = ('planned_material', 'route') - inlines = (BOMChildInline,) + autocomplete_fields = ('planned_material',) + inlines = (EntityOperationInline, BOMChildInline,) @admin.register(BOM) diff --git a/manufacturing/migrations/0005_assemblypassport_requires_painting_and_more.py b/manufacturing/migrations/0005_assemblypassport_requires_painting_and_more.py new file mode 100644 index 0000000..3f05651 --- /dev/null +++ b/manufacturing/migrations/0005_assemblypassport_requires_painting_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 6.0.3 on 2026-04-07 18:11 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('manufacturing', '0004_castingpassport_outsourcedpassport_partpassport_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='assemblypassport', + name='requires_painting', + field=models.BooleanField(default=False, verbose_name='Требуется покраска'), + ), + migrations.AddField( + model_name='assemblypassport', + name='requires_welding', + field=models.BooleanField(default=False, verbose_name='Требуется сварка'), + ), + ] diff --git a/manufacturing/migrations/0006_operation_entityoperation.py b/manufacturing/migrations/0006_operation_entityoperation.py new file mode 100644 index 0000000..b7cb5c2 --- /dev/null +++ b/manufacturing/migrations/0006_operation_entityoperation.py @@ -0,0 +1,43 @@ +# Generated by Django 6.0.3 on 2026-04-08 18:00 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('manufacturing', '0005_assemblypassport_requires_painting_and_more'), + ('shiftflow', '0022_employeeprofile_allowed_workshops_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='Operation', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=200, unique=True, verbose_name='Операция')), + ('code', models.SlugField(help_text='Стабильный идентификатор (например welding, painting, laser_cutting).', max_length=64, unique=True, verbose_name='Код')), + ('workshop', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='shiftflow.workshop', verbose_name='Цех по умолчанию')), + ], + options={ + 'verbose_name': 'Операция', + 'verbose_name_plural': 'Операции', + }, + ), + migrations.CreateModel( + name='EntityOperation', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('seq', models.PositiveSmallIntegerField(default=1, verbose_name='Порядок')), + ('entity', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='operations', to='manufacturing.productentity', verbose_name='Сущность')), + ('operation', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='manufacturing.operation', verbose_name='Операция')), + ], + options={ + 'verbose_name': 'Операция сущности', + 'verbose_name_plural': 'Операции сущностей', + 'ordering': ('entity', 'seq', 'id'), + 'unique_together': {('entity', 'seq')}, + }, + ), + ] diff --git a/manufacturing/migrations/0007_remove_productentity_route_delete_routestub.py b/manufacturing/migrations/0007_remove_productentity_route_delete_routestub.py new file mode 100644 index 0000000..e77c556 --- /dev/null +++ b/manufacturing/migrations/0007_remove_productentity_route_delete_routestub.py @@ -0,0 +1,20 @@ +# Generated by Django 6.0.3 on 2026-04-08 18:37 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('manufacturing', '0006_operation_entityoperation'), + ] + + operations = [ + migrations.RemoveField( + model_name='productentity', + name='route', + ), + migrations.DeleteModel( + name='RouteStub', + ), + ] diff --git a/manufacturing/models.py b/manufacturing/models.py index 2c06139..15d3927 100644 --- a/manufacturing/models.py +++ b/manufacturing/models.py @@ -1,14 +1,30 @@ from django.db import models -class RouteStub(models.Model): - """Маршрут (пока заглушка под техпроцессы).""" +class Operation(models.Model): + """Операция техпроцесса. - name = models.CharField("Маршрут", max_length=200, unique=True) + Комментарий: справочник расширяется без изменений кода. + """ + + name = models.CharField('Операция', max_length=200, unique=True) + code = models.SlugField( + 'Код', + max_length=64, + unique=True, + help_text='Стабильный идентификатор (например welding, painting, laser_cutting).', + ) + workshop = models.ForeignKey( + 'shiftflow.Workshop', + on_delete=models.PROTECT, + null=True, + blank=True, + verbose_name='Цех по умолчанию', + ) class Meta: - verbose_name = "Маршрут" - verbose_name_plural = "Маршруты" + verbose_name = 'Операция' + verbose_name_plural = 'Операции' def __str__(self): return self.name @@ -48,13 +64,6 @@ class ProductEntity(models.Model): blank=True, verbose_name="Заложенный материал", ) - route = models.ForeignKey( - RouteStub, - on_delete=models.SET_NULL, - null=True, - blank=True, - verbose_name="Маршрут", - ) blank_area_m2 = models.FloatField("Норма: площадь заготовки (м²/шт)", null=True, blank=True) blank_length_mm = models.FloatField("Норма: длина заготовки (мм/шт)", null=True, blank=True) @@ -74,6 +83,23 @@ class ProductEntity(models.Model): return base if base else self.name +class EntityOperation(models.Model): + """Операции техпроцесса для конкретной сущности (деталь/сборка/изделие).""" + + entity = models.ForeignKey(ProductEntity, on_delete=models.CASCADE, related_name='operations', verbose_name='Сущность') + operation = models.ForeignKey(Operation, on_delete=models.PROTECT, verbose_name='Операция') + seq = models.PositiveSmallIntegerField('Порядок', default=1) + + class Meta: + verbose_name = 'Операция сущности' + verbose_name_plural = 'Операции сущностей' + ordering = ('entity', 'seq', 'id') + unique_together = ('entity', 'seq') + + def __str__(self): + return f"{self.entity}: {self.seq}. {self.operation}" + + class BOM(models.Model): """Спецификация (BOM): parent состоит из child в количестве quantity.""" @@ -103,6 +129,9 @@ class BOM(models.Model): class AssemblyPassport(models.Model): entity = models.OneToOneField(ProductEntity, on_delete=models.CASCADE, related_name='assembly_passport') + requires_welding = models.BooleanField('Требуется сварка', default=False) + requires_painting = models.BooleanField('Требуется покраска', default=False) + weight_kg = models.FloatField('Масса, кг', null=True, blank=True) coating = models.CharField('Покрытие', max_length=200, blank=True, default='') coating_color = models.CharField('Цвет', max_length=100, blank=True, default='') diff --git a/shiftflow/admin.py b/shiftflow/admin.py index 514c80d..a82a129 100644 --- a/shiftflow/admin.py +++ b/shiftflow/admin.py @@ -20,8 +20,35 @@ from .models import ( ProductionTask, ShiftItem, Workshop, + WorkItem, + DealEntityProgress, ) +_models_to_reregister = ( + Company, + CuttingSession, + Deal, + DealItem, + DxfPreviewJob, + DxfPreviewSettings, + EmployeeProfile, + Item, + Machine, + MaterialRequirement, + ProductionReportConsumption, + ProductionReportRemnant, + ProductionTask, + ShiftItem, + Workshop, + WorkItem, + DealEntityProgress, +) +for _m in _models_to_reregister: + try: + admin.site.unregister(_m) + except Exception: + pass + # --- Настройка отображения Компаний --- @admin.register(Company) class CompanyAdmin(admin.ModelAdmin): @@ -119,6 +146,21 @@ class ItemAdmin(admin.ModelAdmin): return obj.task.drawing_name if obj.task else "-" get_drawing.short_description = 'Деталь' +@admin.register(WorkItem) +class WorkItemAdmin(admin.ModelAdmin): + list_display = ('date', 'deal', 'entity', 'operation', 'workshop', 'machine', 'quantity_plan', 'quantity_done', 'status') + list_filter = ('date', 'status', 'workshop', 'machine', 'operation') + search_fields = ('deal__number', 'entity__name', 'entity__drawing_number', 'operation__name', 'operation__code') + autocomplete_fields = ('deal', 'entity', 'operation', 'workshop', 'machine') + + +@admin.register(DealEntityProgress) +class DealEntityProgressAdmin(admin.ModelAdmin): + list_display = ('deal', 'entity', 'current_seq') + search_fields = ('deal__number', 'entity__name', 'entity__drawing_number') + autocomplete_fields = ('deal', 'entity') + + @admin.register(Workshop) class WorkshopAdmin(admin.ModelAdmin): list_display = ('name', 'location') @@ -261,5 +303,5 @@ class DxfPreviewJobAdmin(admin.ModelAdmin): @admin.register(EmployeeProfile) class EmployeeProfileAdmin(admin.ModelAdmin): - list_display = ('user', 'role') - filter_horizontal = ('machines',) \ No newline at end of file + list_display = ('user', 'role', 'is_readonly') + filter_horizontal = ('machines', 'allowed_workshops') \ No newline at end of file diff --git a/shiftflow/authz.py b/shiftflow/authz.py new file mode 100644 index 0000000..e885c97 --- /dev/null +++ b/shiftflow/authz.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +from typing import Iterable + + +ROLE_PRIORITY = [ + 'admin', + 'prod_head', + 'technologist', + 'master', + 'clerk', + 'supply', + 'manager', + 'operator', + 'observer', + 'director', +] + + +def get_user_group_roles(user) -> set[str]: + """Возвращает роли пользователя только из Django Groups. + + Используется для экранов, где включён строгий доступ "только по группам". + EmployeeProfile.role здесь намеренно не учитывается. + + Правило: superuser получает роль admin. + """ + + roles: set[str] = set() + if not user or not getattr(user, 'is_authenticated', False): + return roles + + if getattr(user, 'is_superuser', False): + roles.add('admin') + + try: + roles |= set(user.groups.values_list('name', flat=True)) + except Exception: + pass + + return roles + + +def get_user_roles(user) -> set[str]: + """Возвращает множество ролей пользователя. + + Источник ролей (вариант A, плавная миграция): + - Django Groups: позволяет назначать несколько ролей одному пользователю. + - Fallback на EmployeeProfile.role: чтобы при деплое и до раздачи групп система + продолжала работать по старой модели (одна роль). + + Правило: superuser всегда получает роль admin независимо от групп/профиля. + """ + + roles: set[str] = set() # Изначально множество пустое + # 1. Проверяем, что пользователь авторизован + if not user or not getattr(user, 'is_authenticated', False): + return roles + # 2. Проверяем, что пользователь не superuser + if getattr(user, 'is_superuser', False): + roles.add('admin') + # 3. Проверяем, что у пользователя есть хотя бы одна группа + try: + roles |= set(user.groups.values_list('name', flat=True)) + except Exception: + pass + + + # 4. Проверяем, что у пользователя есть роль в EmployeeProfile + profile = getattr(user, 'profile', None) + if profile and getattr(profile, 'role', None): + roles.add(str(profile.role)) + + return roles + + +def primary_role(roles: Iterable[str]) -> str: + """Выбирает "основную" роль для отображения в UI. + + Примечание: права доступа должны проверяться по всем ролям (has_any_role). + primary_role используется только для: + - подписи/лейбла "роль пользователя" в шаблонах + - дефолтного поведения, где требуется один статус (например, оформление UI) + """ + + s = set(roles or []) + for r in ROLE_PRIORITY: + if r in s: + return r + return 'operator' + + +def has_any_role(roles: Iterable[str], required: Iterable[str]) -> bool: + """Проверяет, что у пользователя есть хотя бы одна роль из required. + + Используется во вьюхах для разрешений вида: + roles = get_user_roles(request.user) + if not has_any_role(roles, ['admin', 'master']): + return redirect('registry') + """ + + s = set(roles or []) + for r in required or []: + if r in s: + return True + return False \ No newline at end of file diff --git a/shiftflow/management/commands/shiftflow_explode_deal.py b/shiftflow/management/commands/shiftflow_explode_deal.py index bde3ff5..cc5879b 100644 --- a/shiftflow/management/commands/shiftflow_explode_deal.py +++ b/shiftflow/management/commands/shiftflow_explode_deal.py @@ -5,14 +5,14 @@ from shiftflow.services.bom_explosion import explode_deal class Command(BaseCommand): - help = "BOM Explosion для сделки: генерирует ProductionTask и MaterialRequirement." + help = "BOM Explosion для сделки: генерирует ProductionTask и пересчитывает снабжение." def add_arguments(self, parser): parser.add_argument("deal_id", type=int) def handle(self, *args, **options): deal_id = int(options["deal_id"]) - stats = explode_deal(deal_id) + stats = explode_deal(deal_id, create_tasks=True, create_procurement=True) self.stdout.write( self.style.SUCCESS( diff --git a/shiftflow/migrations/0020_dealitem_due_date.py b/shiftflow/migrations/0020_dealitem_due_date.py new file mode 100644 index 0000000..6e5f75d --- /dev/null +++ b/shiftflow/migrations/0020_dealitem_due_date.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.3 on 2026-04-08 03:44 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('shiftflow', '0019_alter_employeeprofile_role'), + ] + + operations = [ + migrations.AddField( + model_name='dealitem', + name='due_date', + field=models.DateField(blank=True, null=True, verbose_name='Плановая отгрузка'), + ), + ] diff --git a/shiftflow/migrations/0021_workitem.py b/shiftflow/migrations/0021_workitem.py new file mode 100644 index 0000000..b162a1e --- /dev/null +++ b/shiftflow/migrations/0021_workitem.py @@ -0,0 +1,35 @@ +# Generated by Django 6.0.3 on 2026-04-08 03:54 + +import django.db.models.deletion +import django.utils.timezone +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('manufacturing', '0005_assemblypassport_requires_painting_and_more'), + ('shiftflow', '0020_dealitem_due_date'), + ] + + operations = [ + migrations.CreateModel( + name='WorkItem', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('stage', models.CharField(choices=[('cutting', 'Резка'), ('welding', 'Сварка'), ('painting', 'Покраска')], max_length=16, verbose_name='Стадия')), + ('quantity_plan', models.PositiveIntegerField(default=0, verbose_name='В план, шт')), + ('quantity_done', models.PositiveIntegerField(default=0, verbose_name='Сделано, шт')), + ('status', models.CharField(default='planned', max_length=16, verbose_name='Статус')), + ('date', models.DateField(default=django.utils.timezone.localdate, verbose_name='Дата')), + ('deal', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='shiftflow.deal', verbose_name='Сделка')), + ('entity', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='manufacturing.productentity', verbose_name='Сущность')), + ('machine', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='shiftflow.machine', verbose_name='Станок/участок')), + ('workshop', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='shiftflow.workshop', verbose_name='Цех')), + ], + options={ + 'verbose_name': 'План работ', + 'verbose_name_plural': 'План работ', + }, + ), + ] diff --git a/shiftflow/migrations/0022_employeeprofile_allowed_workshops_and_more.py b/shiftflow/migrations/0022_employeeprofile_allowed_workshops_and_more.py new file mode 100644 index 0000000..bd1e690 --- /dev/null +++ b/shiftflow/migrations/0022_employeeprofile_allowed_workshops_and_more.py @@ -0,0 +1,28 @@ +# Generated by Django 6.0.3 on 2026-04-08 16:55 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('shiftflow', '0021_workitem'), + ] + + operations = [ + migrations.AddField( + model_name='employeeprofile', + name='allowed_workshops', + field=models.ManyToManyField(blank=True, to='shiftflow.workshop', verbose_name='Доступные цеха'), + ), + migrations.AddField( + model_name='employeeprofile', + name='is_readonly', + field=models.BooleanField(default=False, verbose_name='Только просмотр'), + ), + migrations.AlterField( + model_name='employeeprofile', + name='role', + field=models.CharField(choices=[('admin', 'Администратор'), ('technologist', 'Технолог'), ('master', 'Мастер'), ('operator', 'Оператор'), ('clerk', 'Учетчик'), ('observer', 'Наблюдатель'), ('manager', 'Руководитель')], default='operator', max_length=20, verbose_name='Должность'), + ), + ] diff --git a/shiftflow/migrations/0023_workitem_operation_alter_workitem_stage_and_more.py b/shiftflow/migrations/0023_workitem_operation_alter_workitem_stage_and_more.py new file mode 100644 index 0000000..459edbe --- /dev/null +++ b/shiftflow/migrations/0023_workitem_operation_alter_workitem_stage_and_more.py @@ -0,0 +1,39 @@ +# Generated by Django 6.0.3 on 2026-04-08 18:00 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('manufacturing', '0006_operation_entityoperation'), + ('shiftflow', '0022_employeeprofile_allowed_workshops_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='workitem', + name='operation', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='manufacturing.operation', verbose_name='Операция'), + ), + migrations.AlterField( + model_name='workitem', + name='stage', + field=models.CharField(blank=True, default='', max_length=32, verbose_name='Стадия'), + ), + migrations.CreateModel( + name='DealEntityProgress', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('current_seq', models.PositiveSmallIntegerField(default=1, verbose_name='Текущая операция (порядок)')), + ('deal', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='shiftflow.deal', verbose_name='Сделка')), + ('entity', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='manufacturing.productentity', verbose_name='Сущность')), + ], + options={ + 'verbose_name': 'Прогресс по операции', + 'verbose_name_plural': 'Прогресс по операциям', + 'unique_together': {('deal', 'entity')}, + }, + ), + ] diff --git a/shiftflow/migrations/0024_dealdeliverybatch_dealbatchitem.py b/shiftflow/migrations/0024_dealdeliverybatch_dealbatchitem.py new file mode 100644 index 0000000..48f34c0 --- /dev/null +++ b/shiftflow/migrations/0024_dealdeliverybatch_dealbatchitem.py @@ -0,0 +1,45 @@ +# Generated by Django 6.0.3 on 2026-04-08 21:33 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('manufacturing', '0007_remove_productentity_route_delete_routestub'), + ('shiftflow', '0023_workitem_operation_alter_workitem_stage_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='DealDeliveryBatch', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(blank=True, default='', max_length=120, verbose_name='Название')), + ('due_date', models.DateField(verbose_name='Плановая отгрузка')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Создано')), + ('deal', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='delivery_batches', to='shiftflow.deal', verbose_name='Сделка')), + ], + options={ + 'verbose_name': 'Партия поставки', + 'verbose_name_plural': 'Партии поставки', + 'ordering': ('deal', 'due_date', 'id'), + }, + ), + migrations.CreateModel( + name='DealBatchItem', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('quantity', models.PositiveIntegerField(verbose_name='Количество, шт')), + ('entity', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='manufacturing.productentity', verbose_name='Изделие/деталь')), + ('batch', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='shiftflow.dealdeliverybatch', verbose_name='Партия')), + ], + options={ + 'verbose_name': 'Строка партии', + 'verbose_name_plural': 'Строки партий', + 'ordering': ('batch', 'entity__entity_type', 'entity__drawing_number', 'entity__name', 'id'), + 'unique_together': {('batch', 'entity')}, + }, + ), + ] diff --git a/shiftflow/migrations/0025_dealbatchitem_started_qty.py b/shiftflow/migrations/0025_dealbatchitem_started_qty.py new file mode 100644 index 0000000..6e2c33b --- /dev/null +++ b/shiftflow/migrations/0025_dealbatchitem_started_qty.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.3 on 2026-04-09 04:16 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('shiftflow', '0024_dealdeliverybatch_dealbatchitem'), + ] + + operations = [ + migrations.AddField( + model_name='dealbatchitem', + name='started_qty', + field=models.PositiveIntegerField(default=0, verbose_name='Запущено в производство, шт'), + ), + ] diff --git a/shiftflow/migrations/0026_dealdeliverybatch_is_default.py b/shiftflow/migrations/0026_dealdeliverybatch_is_default.py new file mode 100644 index 0000000..c2246a1 --- /dev/null +++ b/shiftflow/migrations/0026_dealdeliverybatch_is_default.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.3 on 2026-04-09 10:22 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('shiftflow', '0025_dealbatchitem_started_qty'), + ] + + operations = [ + migrations.AddField( + model_name='dealdeliverybatch', + name='is_default', + field=models.BooleanField(default=False, verbose_name='Дефолтная партия (остаток)'), + ), + ] diff --git a/shiftflow/migrations/0027_remove_dealitem_due_date.py b/shiftflow/migrations/0027_remove_dealitem_due_date.py new file mode 100644 index 0000000..7729749 --- /dev/null +++ b/shiftflow/migrations/0027_remove_dealitem_due_date.py @@ -0,0 +1,17 @@ +# Generated by Django 6.0.3 on 2026-04-09 10:28 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('shiftflow', '0026_dealdeliverybatch_is_default'), + ] + + operations = [ + migrations.RemoveField( + model_name='dealitem', + name='due_date', + ), + ] diff --git a/shiftflow/migrations/0028_alter_productiontask_material.py b/shiftflow/migrations/0028_alter_productiontask_material.py new file mode 100644 index 0000000..ced397b --- /dev/null +++ b/shiftflow/migrations/0028_alter_productiontask_material.py @@ -0,0 +1,20 @@ +# Generated by Django 6.0.3 on 2026-04-09 11:38 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('shiftflow', '0027_remove_dealitem_due_date'), + ('warehouse', '0014_material_mass_per_unit'), + ] + + operations = [ + migrations.AlterField( + model_name='productiontask', + name='material', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='warehouse.material', verbose_name='Материал'), + ), + ] diff --git a/shiftflow/migrations/0029_deal_due_date.py b/shiftflow/migrations/0029_deal_due_date.py new file mode 100644 index 0000000..f3035c5 --- /dev/null +++ b/shiftflow/migrations/0029_deal_due_date.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.3 on 2026-04-09 12:20 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('shiftflow', '0028_alter_productiontask_material'), + ] + + operations = [ + migrations.AddField( + model_name='deal', + name='due_date', + field=models.DateField(blank=True, null=True, verbose_name='Срок отгрузки'), + ), + ] diff --git a/shiftflow/migrations/0030_workitem_comment_alter_item_status_and_more.py b/shiftflow/migrations/0030_workitem_comment_alter_item_status_and_more.py new file mode 100644 index 0000000..5c93fed --- /dev/null +++ b/shiftflow/migrations/0030_workitem_comment_alter_item_status_and_more.py @@ -0,0 +1,28 @@ +# Generated by Django 6.0.3 on 2026-04-11 05:25 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('shiftflow', '0029_deal_due_date'), + ] + + operations = [ + migrations.AddField( + model_name='workitem', + name='comment', + field=models.TextField(blank=True, default='', verbose_name='Комментарий'), + ), + migrations.AlterField( + model_name='item', + name='status', + field=models.CharField(choices=[('work', 'В работе'), ('done', 'Выполнено'), ('partial', 'Частично'), ('leftover', 'Недодел')], default='work', max_length=10, verbose_name='Статус'), + ), + migrations.AlterField( + model_name='workitem', + name='status', + field=models.CharField(choices=[('planned', 'В работе'), ('leftover', 'Недодел'), ('done', 'Закрыта')], default='planned', max_length=16, verbose_name='Статус'), + ), + ] diff --git a/shiftflow/migrations/0031_procurementrequirement.py b/shiftflow/migrations/0031_procurementrequirement.py new file mode 100644 index 0000000..683723e --- /dev/null +++ b/shiftflow/migrations/0031_procurementrequirement.py @@ -0,0 +1,30 @@ +# Generated by Django 6.0.3 on 2026-04-11 20:08 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('manufacturing', '0007_remove_productentity_route_delete_routestub'), + ('shiftflow', '0030_workitem_comment_alter_item_status_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='ProcurementRequirement', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('required_qty', models.FloatField(verbose_name='Потребность (к закупке)')), + ('status', models.CharField(choices=[('to_order', 'К заказу'), ('ordered', 'Заказано'), ('closed', 'Закрыто')], default='to_order', max_length=20, verbose_name='Статус')), + ('component', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='manufacturing.productentity', verbose_name='Компонент (покупное/литье)')), + ('deal', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='shiftflow.deal', verbose_name='Сделка')), + ], + options={ + 'verbose_name': 'Потребность снабжения', + 'verbose_name_plural': 'Потребности снабжения', + 'unique_together': {('deal', 'component')}, + }, + ), + ] diff --git a/shiftflow/migrations/0032_alter_procurementrequirement_required_qty.py b/shiftflow/migrations/0032_alter_procurementrequirement_required_qty.py new file mode 100644 index 0000000..96f743e --- /dev/null +++ b/shiftflow/migrations/0032_alter_procurementrequirement_required_qty.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.3 on 2026-04-11 20:41 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('shiftflow', '0031_procurementrequirement'), + ] + + operations = [ + migrations.AlterField( + model_name='procurementrequirement', + name='required_qty', + field=models.PositiveIntegerField(verbose_name='Потребность (к закупке), шт'), + ), + ] diff --git a/shiftflow/migrations/0033_cuttingsession_is_synced_1c_and_more.py b/shiftflow/migrations/0033_cuttingsession_is_synced_1c_and_more.py new file mode 100644 index 0000000..da317c1 --- /dev/null +++ b/shiftflow/migrations/0033_cuttingsession_is_synced_1c_and_more.py @@ -0,0 +1,31 @@ +# Generated by Django 6.0.3 on 2026-04-12 09:22 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('shiftflow', '0032_alter_procurementrequirement_required_qty'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name='cuttingsession', + name='is_synced_1c', + field=models.BooleanField(default=False, verbose_name='Выгружено в 1С'), + ), + migrations.AddField( + model_name='cuttingsession', + name='synced_1c_at', + field=models.DateTimeField(blank=True, null=True, verbose_name='Выгружено в 1С (время)'), + ), + migrations.AddField( + model_name='cuttingsession', + name='synced_1c_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='synced_cutting_sessions', to=settings.AUTH_USER_MODEL, verbose_name='Выгрузил в 1С'), + ), + ] diff --git a/shiftflow/models.py b/shiftflow/models.py index 793c883..5d57f3a 100644 --- a/shiftflow/models.py +++ b/shiftflow/models.py @@ -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()}" diff --git a/shiftflow/services/assembly_closing.py b/shiftflow/services/assembly_closing.py new file mode 100644 index 0000000..cf6e9e8 --- /dev/null +++ b/shiftflow/services/assembly_closing.py @@ -0,0 +1,202 @@ +import logging +from django.db import transaction +from django.db.models import Q, Case, When, Value, IntegerField +from django.utils import timezone +from warehouse.models import StockItem +from shiftflow.models import WorkItem, CuttingSession, ProductionReportConsumption, ProductionReportStockResult +from shiftflow.services.bom_explosion import _build_bom_graph +from shiftflow.services.kitting import get_work_location_for_workitem +from manufacturing.models import EntityOperation + + +def get_first_operation_id(entity_id: int) -> int | None: + op_id = ( + EntityOperation.objects.filter(entity_id=int(entity_id)) + .order_by('seq', 'id') + .values_list('operation_id', flat=True) + .first() + ) + return int(op_id) if op_id else None + +logger = logging.getLogger('mes') + +def get_assembly_closing_info(workitem: WorkItem) -> dict: + """ + Возвращает информацию о том, сколько сборок можно выпустить и + какие компоненты для этого нужны. + """ + first_op_id = get_first_operation_id(int(workitem.entity_id)) + if first_op_id and getattr(workitem, 'operation_id', None) and int(workitem.operation_id) != int(first_op_id): + return {'error': 'Списание комплектации выполняется только на первой операции техпроцесса. Для этой операции закрывай только факт выполнения.', 'is_first_operation': False} + + to_location = get_work_location_for_workitem(workitem) + if not to_location: + return {'error': 'Не определён склад участка для этого задания.'} + + # Считаем BOM 1-го уровня + adjacency = _build_bom_graph({workitem.entity_id}) + children = adjacency.get(workitem.entity_id) or [] + + if not children: + return {'error': 'Спецификация пуста. Нечего списывать.', 'to_location': to_location} + + bom_req = {} # entity_id -> qty_per_1 + for child_id, qty in children: + bom_req[child_id] = bom_req.get(child_id, 0) + qty + + component_ids = list(bom_req.keys()) + stocks = StockItem.objects.filter( + location=to_location, + entity_id__in=component_ids, + is_archived=False, + quantity__gt=0 + ).filter(Q(deal_id=workitem.deal_id) | Q(deal_id__isnull=True)) + + stock_by_entity = {} + for s in stocks: + stock_by_entity[s.entity_id] = stock_by_entity.get(s.entity_id, 0) + s.quantity + + max_possible = float('inf') + components_info = [] + + from manufacturing.models import ProductEntity + entities = {e.id: e for e in ProductEntity.objects.filter(id__in=component_ids)} + + for eid, req_qty in bom_req.items(): + avail = float(stock_by_entity.get(eid, 0)) + can_make = int(avail // float(req_qty)) if req_qty > 0 else 0 + if can_make < max_possible: + max_possible = can_make + + components_info.append({ + 'entity': entities.get(eid), + 'req_per_1': float(req_qty), + 'available': avail, + 'max_possible': can_make + }) + + if max_possible == float('inf'): + max_possible = 0 + + components_info.sort(key=lambda x: (str(x['entity'].entity_type or ''), str(x['entity'].name or '')) if x['entity'] else ('', '')) + + # Ограничиваем max_possible тем, что реально осталось собрать по заданию + remaining = max(0, (workitem.quantity_plan or 0) - (workitem.quantity_done or 0)) + if max_possible > remaining: + max_possible = remaining + + return { + 'to_location': to_location, + 'max_possible': int(max_possible), + 'components': components_info, + 'error': None, + 'is_first_operation': True, + } + +@transaction.atomic +def apply_assembly_closing(workitem_id: int, fact_qty: int, user_id: int) -> bool: + logger.info('assembly_closing:start workitem_id=%s qty=%s user_id=%s', workitem_id, fact_qty, user_id) + workitem = WorkItem.objects.select_for_update(of=('self',)).get(id=int(workitem_id)) + + first_op_id = get_first_operation_id(int(workitem.entity_id)) + if first_op_id and getattr(workitem, 'operation_id', None) and int(workitem.operation_id) != int(first_op_id): + raise ValueError('Списание комплектации выполняется только на первой операции техпроцесса.') + if fact_qty <= 0: + raise ValueError('Количество должно быть больше 0.') + + info = get_assembly_closing_info(workitem) + if info.get('error'): + raise ValueError(info['error']) + + if fact_qty > info['max_possible']: + raise ValueError(f'Недостаточно компонентов на участке. Максимум можно собрать: {info["max_possible"]} шт.') + + to_location = info['to_location'] + + if not getattr(workitem, 'machine_id', None): + raise ValueError('Для закрытия сборки требуется выбрать пост (станок) в сменном задании.') + + report = CuttingSession.objects.create( + operator_id=int(user_id), + machine_id=int(workitem.machine_id), + used_stock_item=None, + date=timezone.localdate(), + is_closed=True, + ) + logger.info('assembly_closing:report_created id=%s', report.id) + + # Списываем компоненты 1-го уровня + adjacency = _build_bom_graph({workitem.entity_id}) + children = adjacency.get(workitem.entity_id) or [] + bom_req = {} + for child_id, qty in children: + bom_req[child_id] = bom_req.get(child_id, 0) + qty + + for eid, req_qty in bom_req.items(): + total_needed = float(req_qty * fact_qty) + + # Приоритет "сделка", потом "свободные", FIFO + qs = StockItem.objects.select_for_update().filter( + location=to_location, + entity_id=eid, + is_archived=False, + quantity__gt=0 + ).filter(Q(deal_id=workitem.deal_id) | Q(deal_id__isnull=True)).annotate( + prio=Case( + When(deal_id=workitem.deal_id, then=Value(0)), + default=Value(1), + output_field=IntegerField(), + ) + ).order_by('prio', 'created_at', 'id') + + rem = total_needed + for si in qs: + if rem <= 0: + break + take = min(rem, float(si.quantity)) + + ProductionReportConsumption.objects.create( + report=report, + material=None, + stock_item=si, + quantity=float(take), + ) + + si.quantity = float(si.quantity) - take + if si.quantity <= 0.0001: + si.quantity = 0 + si.is_archived = True + si.archived_at = timezone.now() + si.save(update_fields=['quantity', 'is_archived', 'archived_at']) + rem -= take + + if rem > 0.0001: + raise ValueError(f'Непредвиденная нехватка компонента ID {eid} при списании. Нужно еще: {rem}') + + # Выпуск готовой сборки + produced = StockItem.objects.create( + entity_id=workitem.entity_id, + deal_id=workitem.deal_id, + location=to_location, + quantity=float(fact_qty), + is_customer_supplied=False, + ) + ProductionReportStockResult.objects.create(report=report, stock_item=produced, kind='finished') + + # Двигаем техпроцесс + workitem.quantity_done = (workitem.quantity_done or 0) + fact_qty + if workitem.quantity_done >= workitem.quantity_plan: + workitem.status = 'done' + workitem.save(update_fields=['quantity_done', 'status']) + + logger.info( + 'assembly_closing:done workitem_id=%s qty=%s deal_id=%s location_id=%s user_id=%s report_id=%s', + workitem.id, + fact_qty, + workitem.deal_id, + to_location.id, + user_id, + report.id, + ) + + return True \ No newline at end of file diff --git a/shiftflow/services/bom_explosion.py b/shiftflow/services/bom_explosion.py index 33fc2b3..aefd4bd 100644 --- a/shiftflow/services/bom_explosion.py +++ b/shiftflow/services/bom_explosion.py @@ -1,15 +1,24 @@ from __future__ import annotations +import logging from collections import defaultdict from dataclasses import dataclass -from django.db import transaction +from django.db import models, transaction from django.db.models import Sum from django.db.models.functions import Coalesce from manufacturing.models import BOM, ProductEntity -from shiftflow.models import Deal, DealItem, MaterialRequirement, ProductionTask -from warehouse.models import Location, StockItem +from shiftflow.models import Deal, DealItem, ProcurementRequirement, ProductionTask +from warehouse.models import StockItem + +logger = logging.getLogger('mes') + + +class ExplosionValidationError(Exception): + def __init__(self, missing_material_ids: list[int]): + super().__init__('missing_material') + self.missing_material_ids = [int(x) for x in (missing_material_ids or [])] @dataclass(frozen=True) @@ -21,7 +30,10 @@ class ExplosionStats: - сколько ProductionTask создано/обновлено (по leaf-деталям) req_*: - - сколько MaterialRequirement создано/обновлено (по сырью) + - сколько ProcurementRequirement создано/обновлено (по потребностям снабжения) + + Примечание: + - потребность по сырью (лист/профиль) сейчас не считаем автоматически — будет вводиться вручную. """ tasks_created: int @@ -151,19 +163,40 @@ def _explode_to_leaves( return memo[entity_id] +def _accumulate_requirements( + entity_id: int, + multiplier: int, + adjacency: dict[int, list[tuple[int, int]]], + visiting: set[int], + out: dict[int, int], +) -> None: + if entity_id in visiting: + raise RuntimeError("Цикл в BOM: спецификация зациклена.") + visiting.add(entity_id) + + out[int(entity_id)] = int(out.get(int(entity_id), 0) or 0) + int(multiplier) + + for child_id, qty in adjacency.get(int(entity_id), []) or []: + _accumulate_requirements(int(child_id), int(multiplier) * int(qty), adjacency, visiting, out) + + visiting.remove(entity_id) + + @transaction.atomic def explode_deal( deal_id: int, *, central_location_name: str = "Центральный склад", + create_tasks: bool = False, + create_procurement: bool = True, ) -> ExplosionStats: - """ - BOM Explosion: - - берём состав сделки (DealItem) - - рекурсивно обходим BOM - - считаем суммарное количество leaf-деталей - - создаём/обновляем ProductionTask (deal + entity) - - создаём/обновляем MaterialRequirement по нормам расхода и остаткам на центральном складе + """BOM Explosion по сделке. + + Используется в двух режимах: + - create_procurement=True: пересчитать потребности снабжения (покупное/литьё/аутсорс) + - create_tasks=True: создать/обновить ProductionTask по внутреннему производству + + Примечание: потребность по сырью (MaterialRequirement) здесь не считаем автоматически. """ deal = Deal.objects.select_for_update().get(pk=deal_id) @@ -191,28 +224,200 @@ def explode_deal( tasks_created = 0 tasks_updated = 0 - for entity_id, qty in required_leaves.items(): + if create_tasks: + for entity_id, qty in required_leaves.items(): + entity = leaf_entities.get(entity_id) + if not entity: + continue + + if not entity.planned_material_id: + continue + + pt, created = ProductionTask.objects.get_or_create( + deal=deal, + entity=entity, + defaults={ + "drawing_name": entity.name or "Б/ч", + "size_value": 0, + "material": entity.planned_material, + "quantity_ordered": int(qty), + "is_bend": False, + }, + ) + if created: + tasks_created += 1 + else: + changed = False + if pt.quantity_ordered != int(qty): + pt.quantity_ordered = int(qty) + changed = True + if not pt.material_id and entity.planned_material_id: + pt.material = entity.planned_material + changed = True + if changed: + pt.save(update_fields=["quantity_ordered", "material"]) + tasks_updated += 1 + + req_created = 0 + req_updated = 0 + seen_component_ids: set[int] = set() + + if not create_procurement: + return ExplosionStats(tasks_created, tasks_updated, 0, 0) + + for entity_id, qty_parts in required_leaves.items(): entity = leaf_entities.get(entity_id) if not entity: continue + # Комментарий: потребность снабжения считаем только для покупного/литья/аутсорса. + et = (entity.entity_type or '').strip() + if et not in ['purchased', 'casting', 'outsourced']: + continue + + seen_component_ids.add(int(entity.id)) + + required_qty = int(qty_parts or 0) + + # Комментарий: снабжение работает с поштучными позициями. + # StockItem.quantity в БД float (универсальная единица), поэтому здесь приводим к int. + # Разрешены: + # - свободные (deal is null) + # - уже закреплённые за этой же сделкой (deal = deal) + available_raw = ( + StockItem.objects.filter(entity=entity, is_archived=False) + .filter(models.Q(deal__isnull=True) | models.Q(deal=deal)) + .aggregate(v=Coalesce(Sum("quantity"), 0.0))["v"] + ) + available = int(available_raw or 0) + + to_buy = max(0, int(required_qty) - int(available)) + + if to_buy > 0: + pr, created = ProcurementRequirement.objects.get_or_create( + deal=deal, + component=entity, + defaults={"required_qty": int(to_buy), "status": "to_order"}, + ) + if created: + req_created += 1 + else: + pr.required_qty = int(to_buy) + + # Комментарий: если снабженец уже отметил «Заказано», пересчёт не должен сбрасывать статус назад. + if pr.status != 'ordered': + pr.status = 'to_order' + + pr.save(update_fields=["required_qty", "status"]) + req_updated += 1 + else: + updated = ProcurementRequirement.objects.filter(deal=deal, component=entity).update( + required_qty=0, + status='closed', + ) + if updated: + req_updated += int(updated) + + # Комментарий: если компонент исчез из сделки/спецификации — закрываем устаревшие строки, + # чтобы при повторном «вскрытии» данные обновлялись, а не накапливались. + qs_stale = ProcurementRequirement.objects.filter( + deal=deal, + component__entity_type__in=['purchased', 'casting', 'outsourced'], + ) + if seen_component_ids: + qs_stale = qs_stale.exclude(component_id__in=list(seen_component_ids)) + + updated = qs_stale.update(required_qty=0, status='closed') + if updated: + req_updated += int(updated) + + return ExplosionStats(tasks_created, tasks_updated, req_created, req_updated) + + +@transaction.atomic +def explode_roots_additive( + deal_id: int, + roots: list[tuple[int, int]], +) -> ExplosionStats: + """Additive BOM Explosion для запуска в производство по частям. + + roots: список (root_entity_id, qty_to_start). + + В отличие от explode_deal: + - не пересчитывает всю сделку + - увеличивает quantity_ordered у ProductionTask по leaf-деталям на добавленный объём. + + Примечание: MaterialRequirement здесь намеренно не трогаем — её лучше считать отдельной процедурой + по всей сделке/партии, чтобы не накапливать ошибки при многократных инкрементах. + """ + deal = Deal.objects.select_for_update().get(pk=deal_id) + + roots = [(int(eid), int(q)) for eid, q in (roots or []) if int(q or 0) > 0] + if not roots: + return ExplosionStats(0, 0, 0, 0) + + root_ids = {eid for eid, _ in roots} + adjacency = _build_bom_graph(root_ids) + + required_nodes: dict[int, int] = {} + + for root_entity_id, root_qty in roots: + _accumulate_requirements(int(root_entity_id), int(root_qty), adjacency, set(), required_nodes) + + entities = { + e.id: e + for e in ProductEntity.objects.select_related("planned_material", "planned_material__category") + .filter(id__in=list(required_nodes.keys())) + } + + missing = [ + int(e.id) + for e in entities.values() + if (getattr(e, 'entity_type', '') == 'part' and not getattr(e, 'planned_material_id', None) and int(required_nodes.get(int(e.id), 0) or 0) > 0) + ] + if missing: + raise ExplosionValidationError(missing) + + tasks_created = 0 + tasks_updated = 0 + skipped_no_material = 0 + skipped_supply = 0 + + for entity_id, qty in required_nodes.items(): + entity = entities.get(int(entity_id)) + if not entity: + continue + + et = (entity.entity_type or '').strip() + if et in ['purchased', 'casting', 'outsourced']: + skipped_supply += 1 + continue + + allow_no_material = et in ['assembly', 'product'] + if not allow_no_material and not entity.planned_material_id: + skipped_no_material += 1 + continue + + defaults = { + "drawing_name": entity.name or "Б/ч", + "size_value": 0, + "material": entity.planned_material if entity.planned_material_id else None, + "quantity_ordered": int(qty), + "is_bend": False, + } + pt, created = ProductionTask.objects.get_or_create( deal=deal, entity=entity, - defaults={ - "drawing_name": entity.name or "Б/ч", - "size_value": 0, - "material": entity.planned_material, - "quantity_ordered": int(qty), - "is_bend": False, - }, + defaults=defaults, ) if created: tasks_created += 1 else: changed = False - if pt.quantity_ordered != int(qty): - pt.quantity_ordered = int(qty) + new_qty = int(pt.quantity_ordered or 0) + int(qty) + if pt.quantity_ordered != new_qty: + pt.quantity_ordered = new_qty changed = True if not pt.material_id and entity.planned_material_id: pt.material = entity.planned_material @@ -221,45 +426,14 @@ def explode_deal( pt.save(update_fields=["quantity_ordered", "material"]) tasks_updated += 1 - central, _ = Location.objects.get_or_create( - name=central_location_name, - defaults={"is_production_area": False}, + logger.info( + 'explode_roots_additive: deal_id=%s roots=%s nodes=%s tasks_created=%s tasks_updated=%s skipped_no_material=%s skipped_supply=%s', + deal_id, + roots, + len(required_nodes), + tasks_created, + tasks_updated, + skipped_no_material, + skipped_supply, ) - - req_created = 0 - req_updated = 0 - - for entity_id, qty_parts in required_leaves.items(): - entity = leaf_entities.get(entity_id) - if not entity or not entity.planned_material_id: - continue - - per_unit, unit = _norm_and_unit(entity) - if not per_unit: - continue - - required_qty = float(qty_parts) * float(per_unit) - - available = ( - StockItem.objects.filter(location=central, material=entity.planned_material) - .aggregate(v=Coalesce(Sum("quantity"), 0.0))["v"] - ) - to_buy = max(0.0, required_qty - float(available or 0.0)) - if to_buy <= 0: - continue - - mr, created = MaterialRequirement.objects.get_or_create( - deal=deal, - material=entity.planned_material, - unit=unit, - defaults={"required_qty": to_buy, "status": "needed"}, - ) - if created: - req_created += 1 - else: - mr.required_qty = to_buy - mr.status = "needed" - mr.save(update_fields=["required_qty", "status"]) - req_updated += 1 - - return ExplosionStats(tasks_created, tasks_updated, req_created, req_updated) \ No newline at end of file + return ExplosionStats(tasks_created, tasks_updated, 0, 0) \ No newline at end of file diff --git a/shiftflow/services/closing.py b/shiftflow/services/closing.py index 5f41d81..537c42a 100644 --- a/shiftflow/services/closing.py +++ b/shiftflow/services/closing.py @@ -1,4 +1,5 @@ from django.db import transaction +from django.db.models import F from django.utils import timezone import logging @@ -130,3 +131,92 @@ def apply_closing( ) logger.info('apply_closing:done report=%s', report.id) + + +@transaction.atomic +def apply_closing_workitems( + *, + user_id: int, + machine_id: int, + material_id: int, + item_actions: dict[int, dict], # workitem_id -> {'action': 'done'|'partial', 'fact': int} + consumptions: dict[int, float], + remnants: list[dict], +) -> None: + logger.info('apply_closing_workitems:start user=%s machine=%s material=%s workitems=%s cons=%s rem=%s', user_id, machine_id, material_id, list(item_actions.keys()), list(consumptions.keys()), len(remnants)) + + from shiftflow.models import WorkItem, ProductionTask + + wis = list( + WorkItem.objects.select_for_update(of=("self",)) + .select_related('deal', 'entity', 'machine') + .filter(id__in=list(item_actions.keys()), machine_id=machine_id, status__in=['planned'], entity__planned_material_id=material_id) + .filter(quantity_done__lt=F('quantity_plan')) + ) + if not wis: + raise RuntimeError('Не найдено сменных заданий для закрытия.') + + report = CuttingSession.objects.create( + operator_id=user_id, + machine_id=machine_id, + used_stock_item=None, + date=timezone.localdate(), + is_closed=False, + ) + + created_shift = 0 + for wi in wis: + spec = item_actions.get(wi.id) or {} + action = (spec.get('action') or '').strip() + fact = int(spec.get('fact') or 0) + if action not in ['done', 'partial']: + continue + + plan_total = int(wi.quantity_plan or 0) + done_total = int(wi.quantity_done or 0) + remaining = max(0, plan_total - done_total) + if remaining <= 0: + continue + + if action == 'done': + fact = remaining + else: + fact = max(0, min(fact, remaining)) + if fact <= 0: + raise RuntimeError('При частичном закрытии факт должен быть больше 0.') + + pt = ProductionTask.objects.filter(deal_id=wi.deal_id, entity_id=wi.entity_id).first() + if not pt: + raise RuntimeError('Не найден ProductionTask для задания.') + + ShiftItem.objects.create(session=report, task=pt, quantity_fact=fact) + created_shift += 1 + + wi.quantity_done = done_total + fact + if wi.quantity_done >= plan_total: + wi.status = 'done' + elif wi.quantity_done > 0: + wi.status = 'leftover' + else: + wi.status = 'planned' + wi.save(update_fields=['quantity_done', 'status']) + + for stock_item_id, qty in consumptions.items(): + if qty and float(qty) > 0: + ProductionReportConsumption.objects.create(report=report, stock_item_id=stock_item_id, material_id=None, quantity=float(qty)) + + for r in remnants: + qty = float(r.get('quantity') or 0) + if qty <= 0: + continue + ProductionReportRemnant.objects.create( + report=report, + material_id=material_id, + quantity=qty, + current_length=r.get('current_length'), + current_width=r.get('current_width'), + unique_id=None, + ) + + close_cutting_session(report.id) + logger.info('apply_closing_workitems:done report=%s shift_items=%s', report.id, created_shift) diff --git a/shiftflow/services/kitting.py b/shiftflow/services/kitting.py new file mode 100644 index 0000000..61696ce --- /dev/null +++ b/shiftflow/services/kitting.py @@ -0,0 +1,268 @@ +import logging +from typing import Any + +from django.db import transaction +from django.db.models import Case, IntegerField, Q, Value, When +from django.utils import timezone + +from manufacturing.models import ProductEntity +from warehouse.models import Location, StockItem, TransferLine, TransferRecord +from warehouse.services.transfers import receive_transfer + +from shiftflow.services.bom_explosion import _accumulate_requirements, _build_bom_graph + +logger = logging.getLogger('mes') + + +def _session_key(workitem_id: int) -> str: + return f'kitting_draft_workitem_{int(workitem_id)}' + + +def get_kitting_draft(session: Any, workitem_id: int) -> list[dict]: + key = _session_key(workitem_id) + raw = session.get(key) + if isinstance(raw, list): + out = [] + for x in raw: + if not isinstance(x, dict): + continue + out.append({ + 'entity_id': int(x.get('entity_id') or 0), + 'from_location_id': int(x.get('from_location_id') or 0), + 'quantity': int(x.get('quantity') or 0), + }) + return out + return [] + + +def clear_kitting_draft(session: Any, workitem_id: int) -> None: + key = _session_key(workitem_id) + if key in session: + del session[key] + session.modified = True + + +def add_kitting_line(session: Any, workitem_id: int, entity_id: int, from_location_id: int, quantity: int) -> None: + workitem_id = int(workitem_id) + entity_id = int(entity_id) + from_location_id = int(from_location_id) + quantity = int(quantity) + + if workitem_id <= 0 or entity_id <= 0 or from_location_id <= 0 or quantity <= 0: + return + + key = _session_key(workitem_id) + draft = get_kitting_draft(session, workitem_id) + + merged = False + for ln in draft: + if int(ln.get('entity_id') or 0) == entity_id and int(ln.get('from_location_id') or 0) == from_location_id: + ln['quantity'] = int(ln.get('quantity') or 0) + quantity + merged = True + break + + if not merged: + draft.append({'entity_id': entity_id, 'from_location_id': from_location_id, 'quantity': quantity}) + + session[key] = draft + session.modified = True + + +def remove_kitting_line(session: Any, workitem_id: int, entity_id: int, from_location_id: int, quantity: int) -> None: + workitem_id = int(workitem_id) + entity_id = int(entity_id) + from_location_id = int(from_location_id) + quantity = int(quantity) + + if workitem_id <= 0 or entity_id <= 0 or from_location_id <= 0 or quantity <= 0: + return + + key = _session_key(workitem_id) + draft = get_kitting_draft(session, workitem_id) + + out = [] + for ln in draft: + if int(ln.get('entity_id') or 0) == entity_id and int(ln.get('from_location_id') or 0) == from_location_id: + cur = int(ln.get('quantity') or 0) + cur = max(0, cur - quantity) + if cur > 0: + ln['quantity'] = cur + out.append(ln) + continue + out.append(ln) + + session[key] = out + session.modified = True + + +def get_work_location_for_workitem(workitem) -> Location | None: + m = getattr(workitem, 'machine', None) + if m and getattr(m, 'workshop_id', None) and getattr(getattr(m, 'workshop', None), 'location_id', None): + return m.workshop.location + if m and getattr(m, 'location_id', None): + return m.location + w = getattr(workitem, 'workshop', None) + if w and getattr(w, 'location_id', None): + return w.location + return None + + +def build_kitting_requirements(root_entity_id: int, qty_to_make: int) -> dict[int, int]: + """Потребность на комплектацию для сборки/изделия. + + Комментарий: для ручной комплектации мастеру важны прямые компоненты (1 уровень BOM), + включая подсборки. Глубину дерева раскрываем отдельными заданиями на подсборки. + """ + root_entity_id = int(root_entity_id) + qty_to_make = int(qty_to_make or 0) + if root_entity_id <= 0 or qty_to_make <= 0: + return {} + + adjacency = _build_bom_graph({root_entity_id}) + children = adjacency.get(root_entity_id) or [] + + out: dict[int, int] = {} + for child_id, per1 in children: + cid = int(child_id) + need = int(per1 or 0) * qty_to_make + if cid <= 0 or need <= 0: + continue + out[cid] = int(out.get(cid, 0) or 0) + need + + return out + + +def build_kitting_leaf_requirements(root_entity_id: int, qty_to_make: int) -> dict[int, int]: + """Совместимость: старое имя оставлено, сейчас возвращает потребность 1-го уровня BOM.""" + return build_kitting_requirements(root_entity_id, qty_to_make) + + +@transaction.atomic +def _apply_one_transfer( + *, + deal_id: int, + component_entity_id: int, + from_location_id: int, + to_location_id: int, + quantity: int, + user_id: int, +) -> int: + deal_id = int(deal_id) + component_entity_id = int(component_entity_id) + from_location_id = int(from_location_id) + to_location_id = int(to_location_id) + quantity = int(quantity) + user_id = int(user_id) + + if quantity <= 0: + raise RuntimeError('Количество должно быть больше 0.') + + if from_location_id == to_location_id: + raise RuntimeError('Склад-источник и склад назначения совпадают.') + + # Комментарий: двигаем только "под сделку" и свободные остатки. + # Приоритет: под сделку -> свободные, затем FIFO по поступлению. + qs = ( + StockItem.objects.select_for_update() + .filter(is_archived=False, quantity__gt=0) + .filter(location_id=from_location_id, entity_id=component_entity_id) + .filter(Q(deal_id=deal_id) | Q(deal_id__isnull=True)) + .annotate( + prio=Case( + When(deal_id=deal_id, then=Value(0)), + default=Value(1), + output_field=IntegerField(), + ) + ) + .order_by('prio', 'created_at', 'id') + ) + + remaining = float(quantity) + picked: list[tuple[int, float]] = [] + for si in qs: + if remaining <= 0: + break + avail = float(si.quantity or 0) + if avail <= 0: + continue + take = min(remaining, avail) + if take <= 0: + continue + picked.append((int(si.id), float(take))) + remaining -= take + + if remaining > 0: + # Комментарий: допускаем расхождения с фактом (по данным базы может быть меньше, чем нужно по месту). + # Для продолжения процесса создаем "виртуальный" остаток под сделку на складе-источнике и перемещаем его. + phantom = StockItem.objects.create( + entity_id=int(component_entity_id), + deal_id=int(deal_id), + location_id=int(from_location_id), + quantity=float(remaining), + ) + picked.append((int(phantom.id), float(remaining))) + logger.warning( + 'kitting_transfer: phantom_created deal_id=%s entity_id=%s from_location=%s qty=%s', + deal_id, + component_entity_id, + from_location_id, + float(remaining), + ) + remaining = 0.0 + + tr = TransferRecord.objects.create( + from_location_id=from_location_id, + to_location_id=to_location_id, + sender_id=user_id, + receiver_id=user_id, + occurred_at=timezone.now(), + status='received', + received_at=timezone.now(), + is_applied=False, + ) + + for sid, qty in picked: + TransferLine.objects.create(transfer=tr, stock_item_id=sid, quantity=float(qty)) + + receive_transfer(tr.id, user_id) + logger.info( + 'kitting_transfer: ok tr_id=%s deal_id=%s component=%s from=%s to=%s qty=%s', + tr.id, deal_id, component_entity_id, from_location_id, to_location_id, quantity + ) + return int(tr.id) + + +def apply_kitting_draft( + *, + session: Any, + workitem_id: int, + deal_id: int, + to_location_id: int, + user_id: int, +) -> dict[str, int]: + draft = get_kitting_draft(session, int(workitem_id)) + if not draft: + return {'applied': 0, 'errors': 0} + + applied = 0 + errors = 0 + + for ln in draft: + try: + _apply_one_transfer( + deal_id=int(deal_id), + component_entity_id=int(ln.get('entity_id') or 0), + from_location_id=int(ln.get('from_location_id') or 0), + to_location_id=int(to_location_id), + quantity=int(ln.get('quantity') or 0), + user_id=int(user_id), + ) + applied += 1 + except Exception: + errors += 1 + logger.exception('kitting_transfer:error deal_id=%s workitem_id=%s line=%s', deal_id, workitem_id, ln) + + if errors == 0: + clear_kitting_draft(session, int(workitem_id)) + + return {'applied': applied, 'errors': errors} \ No newline at end of file diff --git a/shiftflow/templates/shiftflow/assembly_closing.html b/shiftflow/templates/shiftflow/assembly_closing.html new file mode 100644 index 0000000..46ae8eb --- /dev/null +++ b/shiftflow/templates/shiftflow/assembly_closing.html @@ -0,0 +1,141 @@ +{% extends 'base.html' %} + +{% block content %} +
+
+
+
+

+ Закрытие сборки +

+ Назад к заданию +
+ +
+
+
{{ workitem.entity.drawing_number|default:"—" }} {{ workitem.entity.name }}
+
Сделка № {{ workitem.deal.number }}
+
План: {{ workitem.quantity_plan }} шт. · Собрано: {{ workitem.quantity_done }} шт.
+
Осталось собрать: {{ remaining }} шт.
+ {% if to_location %} +
Участок сборки (склад): {{ to_location.name }}
+ {% else %} +
Участок сборки не определен! Закрытие невозможно.
+ {% endif %} + +
+ Пост для отчёта: + {% if workitem.machine_id %} + {{ workitem.machine.name }} + {% else %} + не выбран + {% endif %} +
+
+ + {% if error %} +
+ {{ error }} +
+ {% else %} +
Наличие компонентов на участке
+
+ + + + + + + + + + + {% for c in components %} + + + + + + + {% empty %} + + + + {% endfor %} + +
КомпонентНужно на 1 штЕсть на участкеХватит на сборок
+
{{ c.entity.drawing_number|default:"—" }} {{ c.entity.name }}
+
{{ c.entity.get_entity_type_display }}
+
{{ c.req_per_1 }}{{ c.available|floatformat:2 }} + {{ c.max_possible }} +
Спецификация пуста или не найдена.
+
+ +
+
+ Максимум можно закрыть сейчас: {{ max_possible }} шт. +
+
+ +
+ {% csrf_token %} + + +
+
+ + +
+
+ {% if workitem.machine_id %} + + {% else %} + + {% endif %} +
+
+ +
+ При закрытии компоненты будут списаны со склада участка {{ to_location.name }}, а готовая сборка будет оприходована на этот же участок. Производственный отчёт привязывается к выбранному посту. +
+ + +
+ {% endif %} +
+
+
+
+{% endblock %} \ No newline at end of file diff --git a/shiftflow/templates/shiftflow/closing.html b/shiftflow/templates/shiftflow/closing.html index 92b2f0c..2af702a 100644 --- a/shiftflow/templates/shiftflow/closing.html +++ b/shiftflow/templates/shiftflow/closing.html @@ -48,27 +48,27 @@ Дата Сделка Деталь - План + К закрытию Факт Режим - {% for it in items %} + {% for wi in workitems %} - {{ it.date|date:"d.m.Y" }} - {{ it.task.deal.number }} - {{ it.task.drawing_name }} - {{ it.quantity_plan }} + {{ wi.date|date:"d.m.Y" }} + {{ wi.deal.number }} + {{ wi.entity.drawing_number|default:"—" }} {{ wi.entity.name }} + {{ wi.remaining }} - +
- - - - + + + +
diff --git a/shiftflow/templates/shiftflow/closing_workitems.html b/shiftflow/templates/shiftflow/closing_workitems.html new file mode 100644 index 0000000..2aa39ce --- /dev/null +++ b/shiftflow/templates/shiftflow/closing_workitems.html @@ -0,0 +1,78 @@ +{% extends 'base.html' %} + +{% block content %} +
+
+

+ Закрытие · Мои сменные задания +

+ +
+
+ + +
+ + + Сброс + +
+
+ +
+ + + + + + + + + + + + + + + {% for wi in workitems %} + + + + + + + + + + + {% empty %} + + + + {% endfor %} + +
СделкаКДОперацияЦех/ПостПланФактОстатокДействие
+ {{ wi.deal.number }} + + +
{{ wi.entity.get_entity_type_display }}
+
+ {% if wi.operation %}{{ wi.operation.name }}{% else %}{{ wi.stage|default:"—" }}{% endif %} + + {% if wi.machine %}{{ wi.machine.name }}{% elif wi.workshop %}{{ wi.workshop.name }}{% else %}—{% endif %} + {{ wi.quantity_plan }}{{ wi.quantity_done }} + {{ wi.remaining }} + + + Закрыть + +
Нет активных сменных заданий.
+
+
+{% endblock %} \ No newline at end of file diff --git a/shiftflow/templates/shiftflow/customer_deals.html b/shiftflow/templates/shiftflow/customer_deals.html index aeabf0a..c01ad6e 100644 --- a/shiftflow/templates/shiftflow/customer_deals.html +++ b/shiftflow/templates/shiftflow/customer_deals.html @@ -86,6 +86,10 @@ +
+ + +
@@ -112,6 +116,7 @@ document.addEventListener('DOMContentLoaded', function () { const dealNumber = document.getElementById('dealNumber'); const dealStatus = document.getElementById('dealStatus'); const dealDescription = document.getElementById('dealDescription'); + const dealDueDate = document.getElementById('dealDueDate'); const dealSaveBtn = document.getElementById('dealSaveBtn'); function getCookie(name) { @@ -139,6 +144,7 @@ document.addEventListener('DOMContentLoaded', function () { dealModal.addEventListener('show.bs.modal', function () { if (dealNumber) dealNumber.value = ''; if (dealDescription) dealDescription.value = ''; + if (dealDueDate) dealDueDate.value = ''; if (dealStatus) dealStatus.value = 'work'; }); } @@ -150,6 +156,7 @@ document.addEventListener('DOMContentLoaded', function () { status: dealStatus ? dealStatus.value : 'work', company_id: '{{ company.id }}', description: (dealDescription ? dealDescription.value : ''), + due_date: dealDueDate ? dealDueDate.value : '', }; await postForm('{% url "deal_upsert" %}', payload); window.location.reload(); diff --git a/shiftflow/templates/shiftflow/directories.html b/shiftflow/templates/shiftflow/directories.html new file mode 100644 index 0000000..5ac91ca --- /dev/null +++ b/shiftflow/templates/shiftflow/directories.html @@ -0,0 +1,36 @@ +{% extends 'base.html' %} + +{% block content %} +
+
+

Справочники

+
+ + +
+{% endblock %} \ No newline at end of file diff --git a/shiftflow/templates/shiftflow/legacy_closing.html b/shiftflow/templates/shiftflow/legacy_closing.html new file mode 100644 index 0000000..38aea9e --- /dev/null +++ b/shiftflow/templates/shiftflow/legacy_closing.html @@ -0,0 +1,291 @@ +{% extends 'base.html' %} + +{% block content %} +
+
+
+
+ + +
+ +
+ + +
+ + +
+
+
+ +
+ {% csrf_token %} + + + +
+
+
+

Архив / Закрытие

+
Legacy: Item
+
+
+ +
+ + + + + + + + + + + + + {% for it in items %} + + + + + + + + + {% empty %} + + {% endfor %} + +
ДатаСделкаДетальПланФактРежим
{{ it.date|date:"d.m.Y" }}{{ it.task.deal.number }}{{ it.task.drawing_name }}{{ it.quantity_plan }} + + +
+ + + + +
+
Выбери станок и материал
+
+
+ +
+
+
Списание со склада цеха (единицы)
+
+
+ + + + + + + + + + + + + {% for s in stock_items %} + + + + + + + + + {% empty %} + + {% endfor %} + +
ПоступлениеСделкаЕдиницаРазмерыДоступноИспользовано
{% if s.created_at %}{{ s.created_at|date:"d.m.Y H:i" }}{% endif %} + {% if s.deal_id %} + {{ s.deal.number }} + {% else %} + — + {% endif %} + {{ s }} + {% if s.current_length and s.current_width %} + {{ s.current_length|floatformat:"-g" }} × {{ s.current_width|floatformat:"-g" }} мм + {% elif s.current_length %} + {{ s.current_length|floatformat:"-g" }} мм + {% else %} + — + {% endif %} + {{ s.quantity }} + +
Нет единиц на складе для выбранного материала
+
+
+ +
+
+
Остаток ДО
+ +
+
+ + + + + + + + + + + + + + +
Кол-воДлина (мм)Ширина (мм)
ДО не добавлены
+
+
+ +
+ +
+
+ + + +{% endblock %} \ No newline at end of file diff --git a/shiftflow/templates/shiftflow/legacy_registry.html b/shiftflow/templates/shiftflow/legacy_registry.html new file mode 100644 index 0000000..3778994 --- /dev/null +++ b/shiftflow/templates/shiftflow/legacy_registry.html @@ -0,0 +1,21 @@ +{% extends 'base.html' %} + +{% block content %} +{% include 'shiftflow/partials/_filter.html' %} + +
+
+
+

Архив / Реестр

+
Legacy: Item
+
+ {% if user_role in 'admin,technologist,master' %} + + Печать + + {% endif %} +
+ + {% include 'shiftflow/partials/_items_table.html' with items=items %} +
+{% endblock %} \ No newline at end of file diff --git a/shiftflow/templates/shiftflow/legacy_writeoffs.html b/shiftflow/templates/shiftflow/legacy_writeoffs.html new file mode 100644 index 0000000..ab993d2 --- /dev/null +++ b/shiftflow/templates/shiftflow/legacy_writeoffs.html @@ -0,0 +1,173 @@ +{% extends 'base.html' %} + +{% block content %} +
+
+
+
+ + +
+
+ + +
+
+ +
+ +
+
+
+ +
+
+
+

Архив / Списание / Производство

+
По производственным отчетам
+
+
+ +
+ {% for card in report_cards %} +
+
+
+ {{ card.report.date|date:"d.m.Y" }} — {{ card.report.machine }} — {{ card.report.operator }} + #{{ card.report.id }} +
+
+ +
+
+
Списано
+ {% if card.report.consumptions.all %} +
    + {% for c in card.report.consumptions.all %} + {% if c.stock_item_id and c.stock_item.material_id %} +
  • + {{ c.stock_item.material.full_name|default:c.stock_item.material.name }} + ({% if c.stock_item.current_length and c.stock_item.current_width %}{{ c.stock_item.current_length|floatformat:"-g" }}×{{ c.stock_item.current_width|floatformat:"-g" }}{% elif c.stock_item.current_length %}{{ c.stock_item.current_length|floatformat:"-g" }}{% else %}—{% endif %}) + {{ c.quantity|floatformat:"-g" }} шт +
  • + {% elif c.material_id %} +
  • {{ c.material }} {{ c.quantity|floatformat:"-g" }} шт
  • + {% else %} +
  • — {{ c.quantity|floatformat:"-g" }} шт
  • + {% endif %} + {% endfor %} +
+ {% else %} +
+ {% endif %} +
+ +
+
Произведено
+ {% if card.produced %} +
    + {% for k,v in card.produced.items %} +
  • {{ k }}: {{ v }} шт
  • + {% endfor %} +
+ {% else %} +
+ {% endif %} +
+ +
+
Остаток ДО
+ {% if card.report.remnants.all %} +
    + {% for r in card.report.remnants.all %} +
  • + {{ r.material.full_name|default:r.material.name|default:r.material }} + ({% if r.current_length and r.current_width %}{{ r.current_length|floatformat:"-g" }}×{{ r.current_width|floatformat:"-g" }}{% elif r.current_length %}{{ r.current_length|floatformat:"-g" }}{% else %}—{% endif %}) + {{ r.quantity|floatformat:"-g" }} шт +
  • + {% endfor %} +
+ {% else %} +
+ {% endif %} +
+
+
+ {% empty %} +
За выбранный период отчётов нет.
+ {% endfor %} +
+
+ +
+
+

Сменные задания (1С)

+
Отметка «Списано в 1С»
+
+ +
+ {% csrf_token %} + + + +
+ + + + + + + + + + + + + + {% for it in items %} + + + + + + + + + + {% empty %} + + {% endfor %} + +
ДатаСделкаСтанокПозицияПлан / Факт
+ {% if can_edit %} + + {% endif %} + {{ it.date|date:"d.m.Y" }}{{ it.task.deal.number|default:"-" }}{{ it.machine.name }}{{ it.task.drawing_name|default:"—" }} + {{ it.quantity_plan }} / + {{ it.quantity_fact }} + + {% if it.is_synced_1c %} + + {% else %} + + {% endif %} +
Нет сменных заданий за период
+
+ + {% if can_edit %} +
+ +
+ {% endif %} +
+
+ +{% endblock %} \ No newline at end of file diff --git a/shiftflow/templates/shiftflow/locations_catalog.html b/shiftflow/templates/shiftflow/locations_catalog.html new file mode 100644 index 0000000..47b76c5 --- /dev/null +++ b/shiftflow/templates/shiftflow/locations_catalog.html @@ -0,0 +1,72 @@ +{% extends 'base.html' %} + +{% block content %} +
+
+

Справочник · Склады

+ Назад +
+ + {% if can_edit %} +
+
+ {% csrf_token %} + + +
+ + +
+ +
+ +
+
+
+ {% endif %} + +
+ + + + + + + + + + {% for l in locations %} + + + + + + + + {% empty %} + + {% endfor %} + +
СкладДействия
{{ forloop.counter }} + {% if can_edit %} +
+ {% csrf_token %} + + +
+ +
+ {% else %} + {{ l.name }} + {% endif %} +
+ {% if can_edit %} + + + {% else %} + — + {% endif %} +
Складов нет.
+
+
+{% endblock %} \ No newline at end of file diff --git a/shiftflow/templates/shiftflow/machines_catalog.html b/shiftflow/templates/shiftflow/machines_catalog.html new file mode 100644 index 0000000..9cd9db0 --- /dev/null +++ b/shiftflow/templates/shiftflow/machines_catalog.html @@ -0,0 +1,158 @@ +{% extends 'base.html' %} + +{% block content %} +
+
+
+

{{ workshop.name }}

+
Цех · ID {{ workshop.id }}
+
+
+ Назад + {% if can_edit %} + + {% endif %} +
+
+ +
+ {% if can_edit %} +
+ {% csrf_token %} + + +
+ + +
+ +
+ + +
+ +
+ +
+
+ {% else %} +
+
+
Цех
+
{{ workshop.name }}
+
+
+
Склад цеха
+
{{ workshop.location.name|default:"—" }}
+
+
+ {% endif %} +
+ +
+ + + + + + + + + + + + {% for m in machines %} + + + + + + + + + + + {% empty %} + + {% endfor %} + +
IDПост/станокТипДействия
{{ forloop.counter }}{{ m.id }} + {% if can_edit %} +
+ {% csrf_token %} + + +
+ +
+ {% else %} + {{ m.name }} + {% endif %} +
+ {% if can_edit %} + + {% else %} + {{ m.get_machine_type_display }} + {% endif %} + + {% if can_edit %} +
+ + +
+ {% csrf_token %} + + + +
+
+ {% else %} + — + {% endif %} +
Постов нет.
+
+
+ +{% if can_edit %} + +{% endif %} +{% endblock %} \ No newline at end of file diff --git a/shiftflow/templates/shiftflow/material_categories_catalog.html b/shiftflow/templates/shiftflow/material_categories_catalog.html new file mode 100644 index 0000000..5d5cbda --- /dev/null +++ b/shiftflow/templates/shiftflow/material_categories_catalog.html @@ -0,0 +1,149 @@ +{% extends 'base.html' %} + +{% block content %} +
+
+
+ +

Категории материалов

+
+ +
+
+ + + Сброс +
+ + Назад + + {% if can_edit %} + + {% endif %} +
+
+ +
+ + + + + + + + + + {% for c in categories %} + + + + + + {% empty %} + + {% endfor %} + +
НазваниеГОСТФорма
{{ c.name }}{{ c.gost_standard|default:"—" }}{{ c.get_form_factor_display }}
Нет данных
+
+
+ + + + + +{% endblock %} \ No newline at end of file diff --git a/shiftflow/templates/shiftflow/materials_catalog.html b/shiftflow/templates/shiftflow/materials_catalog.html new file mode 100644 index 0000000..e5a2b18 --- /dev/null +++ b/shiftflow/templates/shiftflow/materials_catalog.html @@ -0,0 +1,179 @@ +{% extends 'base.html' %} + +{% block content %} +
+
+
+ +

Материалы

+
+ +
+
+ + + Сброс +
+ + Назад + + {% if can_edit %} + + {% endif %} +
+
+ +
+ + + + + + + + + + + + {% for r in rows %} + + + + + + + + {% empty %} + + {% endfor %} + +
КатегорияМаркаНаименованиеПолное имяМасса
{{ r.m.category.name }}{{ r.m.steel_grade.name|default:"—" }}{{ r.m.name }}{{ r.m.full_name }} + {% if r.m.mass_per_unit %} + {{ r.m.mass_per_unit|floatformat:"-g" }} {{ r.unit }} + {% else %} + — + {% endif %} +
Нет данных
+
+
+ + + + + +{% endblock %} \ No newline at end of file diff --git a/shiftflow/templates/shiftflow/partials/_filter.html b/shiftflow/templates/shiftflow/partials/_filter.html index 27f7a33..60a9b31 100644 --- a/shiftflow/templates/shiftflow/partials/_filter.html +++ b/shiftflow/templates/shiftflow/partials/_filter.html @@ -25,6 +25,9 @@ + + + {% else %} @@ -32,29 +35,14 @@ - + - - {% if user_role in 'admin,technologist' %} - - - {% endif %} {% endif %}
- {% if user_role in 'admin,technologist,clerk' %} -
- - -
- {% endif %}
@@ -95,8 +83,7 @@ const data = { statuses: Array.from(form.querySelectorAll('input[name="statuses"]:checked')).map(i=>i.value), m_ids: Array.from(form.querySelectorAll('input[name="m_ids"]:checked')).map(i=>i.value), - start_date: s ? s.value : '', - is_synced: (form.querySelector('select[name="is_synced"]')||{}).value || '' + start_date: s ? s.value : '' }; try { localStorage.setItem('registry_filters', JSON.stringify(data)); } catch(_){} } @@ -122,8 +109,6 @@ } if (s) s.value = data.start_date || weekAgo; if (e) e.value = today; - const sel = form.querySelector('select[name="is_synced"]'); - if (sel && data.is_synced !== undefined) sel.value = data.is_synced; const filtered = form.querySelector('input[name="filtered"]'); if (filtered) filtered.value = '1'; form.submit(); diff --git a/shiftflow/templates/shiftflow/partials/_workitems_table.html b/shiftflow/templates/shiftflow/partials/_workitems_table.html new file mode 100644 index 0000000..8aaa12e --- /dev/null +++ b/shiftflow/templates/shiftflow/partials/_workitems_table.html @@ -0,0 +1,93 @@ +
+
+ + + + + + + + + + + + + + + + {% for wi in workitems %} + + + + + + + + + + + + {% empty %} + + {% endfor %} + +
ДатаСделкаЦех/ПостНаименованиеМатериалФайлыПрогрессПлан / ФактСтатус
{{ wi.date|date:"d.m.y" }}{{ wi.deal.number|default:"-" }} + {% if wi.machine %} + {{ wi.workshop.name|default:"—" }}/{{ wi.machine.name }} + {% elif wi.workshop %} + {{ wi.workshop.name }} + {% else %} + + {% endif %} + + {{ wi.entity.drawing_number|default:"—" }} {{ wi.entity.name }} + + {% if wi.entity.planned_material %} + {{ wi.entity.planned_material.full_name|default:wi.entity.planned_material.name }} + {% else %} + — + {% endif %} + + {% if wi.entity.dxf_file %} + + + + {% endif %} + {% if wi.entity.pdf_main %} + + + + {% endif %} + +
+
+
+
+ {{ wi.quantity_plan }} / + {{ wi.quantity_done }} + + {% if wi.status == 'done' %} + {{ wi.get_status_display }} + {% elif wi.status == 'leftover' %} + {{ wi.get_status_display }} + {% else %} + {{ wi.get_status_display }} + {% endif %} +
Записей WorkItem нет
+
+
+ + \ No newline at end of file diff --git a/shiftflow/templates/shiftflow/partials_workitems_table.html b/shiftflow/templates/shiftflow/partials_workitems_table.html new file mode 100644 index 0000000..e69de29 diff --git a/shiftflow/templates/shiftflow/planning.html b/shiftflow/templates/shiftflow/planning.html index ef2e9c4..57b20d4 100644 --- a/shiftflow/templates/shiftflow/planning.html +++ b/shiftflow/templates/shiftflow/planning.html @@ -88,6 +88,10 @@
+
+ + +
@@ -157,6 +161,7 @@ document.addEventListener('DOMContentLoaded', function () { const dealStatus = document.getElementById('dealStatus'); const dealCompany = document.getElementById('dealCompany'); const dealDescription = document.getElementById('dealDescription'); + const dealDueDate = document.getElementById('dealDueDate'); const dealSaveBtn = document.getElementById('dealSaveBtn'); const openCompanyModalBtn = document.getElementById('openCompanyModalBtn'); @@ -234,6 +239,7 @@ document.addEventListener('DOMContentLoaded', function () { dealId.value = ''; dealNumber.value = ''; dealDescription.value = ''; + if (dealDueDate) dealDueDate.value = ''; if (dealStatus) dealStatus.value = 'work'; if (dealCompany) dealCompany.value = ''; }); @@ -268,6 +274,7 @@ document.addEventListener('DOMContentLoaded', function () { status: dealStatus ? dealStatus.value : 'work', company_id: dealCompany.value, description: dealDescription.value, + due_date: dealDueDate ? dealDueDate.value : '', }; await postForm('{% url "deal_upsert" %}', payload); window.location.reload(); diff --git a/shiftflow/templates/shiftflow/planning_deal.html b/shiftflow/templates/shiftflow/planning_deal.html index 1961ac1..9949438 100644 --- a/shiftflow/templates/shiftflow/planning_deal.html +++ b/shiftflow/templates/shiftflow/planning_deal.html @@ -17,126 +17,752 @@ {{ deal.get_status_display }} + + {% if deal.status == 'lead' and user_role in 'admin,prod_head,technologist,master,clerk' %} +
+ {% csrf_token %} + + +
+ {% endif %} + Назад + {% if user_role in 'admin,prod_head,technologist,master' %} +
+ {% csrf_token %} + + +
+ {% endif %} {% if user_role in 'admin,technologist' %} - - Добавить деталь - + {% endif %}
-
- - - - - - - - - - - - - - - {% for t in tasks %} - - - - - - - - - - - {% empty %} - - {% endfor %} - -
ДетальМатериалРазмерПрогрессНадо / Сделано / В планеОсталосьФайлыДействия
{{ t.drawing_name|default:"Б/ч" }}{{ t.material.full_name|default:t.material.name }}{{ t.size_value }} -
-
-
-
-
- {{ t.quantity_ordered }} / - {{ t.done_qty }} / - {{ t.planned_qty }} - {{ t.remaining_qty }} - {% if t.drawing_file %} - - - - {% endif %} - {% if t.extra_drawing %} - - - - {% endif %} - - {% if user_role in 'admin,technologist' %} - - {% endif %} -
Деталей не найдено
+ +
+
+
+ Позиции сделки +
Изделие / СБ / Деталь
+
+ +
+ + + + + + + + + + + + {% for it in deal_items %} + + + + + + + + {% empty %} + + {% endfor %} + +
ПозицияПрогрессЗаказано / Сделано / В планеОсталосьВ производство
+
{{ it.entity.drawing_number|default:"—" }} {{ it.entity.name }}
+
{{ it.entity.get_entity_type_display }}
+
+
+
+
+
+
+ {{ it.quantity }} / + {{ it.done_qty }} / + {{ it.planned_qty }} + {{ it.remaining_qty }} + {% if user_role in 'admin,technologist' %} + + {% else %} + + {% endif %} +
Пока нет позиций
+
+
+ +
+
+
+ Партии поставки + {% if user_role in 'admin,technologist' %} + + {% endif %} +
+ +
+ + + + + + + + + + + + {% for b in delivery_batches %} + + + + + + + + {% empty %} + + {% endfor %} + +
ОтгрузкаПартияЗапущеноСостав партии
{{ b.due_date|date:"d.m.Y" }} + {{ b.name|default:"—" }} + {% if b.is_default %} + по умолчанию + {% endif %} + +
{{ b.total_started }} / {{ b.total_qty }} (осталось {{ b.total_remaining }})
+
+
+
+
+ {% if b.items_list %} +
+ + + + + + + + + + + + + + + {% for bi in b.items_list %} + + + + + + + + + + + {% endfor %} + +
ТипОбозначениеНаименованиеКол-воЗапущеноОсталосьПрогресс
{{ bi.entity.get_entity_type_display }}{{ bi.entity.drawing_number|default:"—" }}{{ bi.entity.name }}{{ bi.quantity }}{{ bi.started_qty }}{{ bi.remaining_to_start }} +
+
+
+
+ {% if user_role in 'admin,technologist' and not b.is_default %} + +
+ {% csrf_token %} + + + + + +
+ {% endif %} +
+
+ {% else %} +
Пусто
+ {% endif %} +
+ {% if user_role in 'admin,technologist' and not b.is_default %} + +
+ {% csrf_token %} + + + + + +
+ {% endif %} +
Партий пока нет
+
+
+
+ + {% for g in workshop_task_groups %} +
+
+ {{ g.name }} +
{{ g.tasks|length }} задач
+
+
+ + + + + + + + + + + + + + {% for t in g.tasks %} + + + + + + + + + + {% empty %} + + {% endfor %} + +
ПозицияОперацияПрогрессЗаказано / Сделано / В сменеОсталосьФайлыДействия
+
+ {% if t.entity %} + {{ t.entity.drawing_number|default:"—" }} {{ t.entity.name }} + {% else %} + {{ t.drawing_name|default:"Б/ч" }} + {% endif %} +
+
+ {% if t.material %}{{ t.material.full_name|default:t.material.name }}{% else %}—{% endif %} +
+
{{ t.current_operation_name|default:"—" }} +
+
+
+
+
+ {{ t.quantity_ordered }} / + {{ t.done_qty }} / + {{ t.planned_qty }} + {{ t.remaining_qty }} + {% if t.drawing_file %} + + + + {% endif %} + {% if t.extra_drawing %} + + + + {% endif %} + + {% if user_role in 'admin,technologist' %} + {% if t.current_operation_id and t.entity_id %} + + {% else %} + + {% endif %} + {% endif %} +
Задач нет
+
+
+ {% empty %} +
Задач нет
+ {% endfor %}
- + +{% endblock %} \ No newline at end of file diff --git a/shiftflow/templates/shiftflow/product_info_casting.html b/shiftflow/templates/shiftflow/product_info_casting.html index 653b545..b34ed96 100644 --- a/shiftflow/templates/shiftflow/product_info_casting.html +++ b/shiftflow/templates/shiftflow/product_info_casting.html @@ -1,8 +1,35 @@ +{% extends 'base.html' %} + +{% block content %} +
+
+
+ +
{{ entity.get_entity_type_display }}
+
+
+ {% include 'components/_add_to_deal.html' %} + Назад +
+
+
{% csrf_token %} +
@@ -38,15 +65,20 @@
+ {% if not can_edit %}
- - + +
+ {% if entity_ops %} + {% for eo in entity_ops %} + {{ eo.seq }}. {{ eo.operation.name }}{% if eo.operation.workshop %} ({{ eo.operation.workshop.name }}){% endif %}{% if not forloop.last %} · {% endif %} + {% endfor %} + {% else %} + — + {% endif %} +
+ {% endif %}
@@ -73,12 +105,81 @@ {% if can_edit %} -
- {% csrf_token %} - - - - -
+
+
Операции техпроцесса
+ +
+ + + + + + + + + + + {% for eo in entity_ops %} + + + + + + + {% empty %} + + {% endfor %} + +
ОперацияЦех
{{ eo.seq }}{{ eo.operation.name }}{% if eo.operation.workshop %}{{ eo.operation.workshop.name }}{% else %}—{% endif %} +
+
+ {% csrf_token %} + + + + + +
+
+ {% csrf_token %} + + + + + +
+
+ {% csrf_token %} + + + + +
+
+
Операции не добавлены
+
+ +
+ {% csrf_token %} + + + +
+ + +
+
+ +
+
+
{% endif %} -
\ No newline at end of file +
+
+
+{% endblock %} \ No newline at end of file diff --git a/shiftflow/templates/shiftflow/product_info_external.html b/shiftflow/templates/shiftflow/product_info_external.html index 240daeb..c5dfae3 100644 --- a/shiftflow/templates/shiftflow/product_info_external.html +++ b/shiftflow/templates/shiftflow/product_info_external.html @@ -1,7 +1,35 @@ +{% extends 'base.html' %} + +{% block content %} +
+
+
+ +
{{ entity.get_entity_type_display }}
+
+
+ {% include 'components/_add_to_deal.html' %} + Назад +
+
+
{% csrf_token %} + +
@@ -33,4 +61,7 @@ {% endif %}
-
\ No newline at end of file + +
+
+{% endblock %} \ No newline at end of file diff --git a/shiftflow/templates/shiftflow/product_info_outsourced.html b/shiftflow/templates/shiftflow/product_info_outsourced.html index 2b114ea..f7a987e 100644 --- a/shiftflow/templates/shiftflow/product_info_outsourced.html +++ b/shiftflow/templates/shiftflow/product_info_outsourced.html @@ -1,8 +1,35 @@ +{% extends 'base.html' %} + +{% block content %} +
+
+
+ +
{{ entity.get_entity_type_display }}
+
+
+ {% include 'components/_add_to_deal.html' %} + Назад +
+
+
{% csrf_token %} +
@@ -28,15 +55,20 @@
+ {% if not can_edit %}
- - + +
+ {% if entity_ops %} + {% for eo in entity_ops %} + {{ eo.seq }}. {{ eo.operation.name }}{% if eo.operation.workshop %} ({{ eo.operation.workshop.name }}){% endif %}{% if not forloop.last %} · {% endif %} + {% endfor %} + {% else %} + — не указан — + {% endif %} +
+ {% endif %}
@@ -65,12 +97,81 @@ {% if can_edit %} -
- {% csrf_token %} - - - - -
+
+
Операции техпроцесса
+ +
+ + + + + + + + + + + {% for eo in entity_ops %} + + + + + + + {% empty %} + + {% endfor %} + +
ОперацияЦех
{{ eo.seq }}{{ eo.operation.name }}{% if eo.operation.workshop %}{{ eo.operation.workshop.name }}{% else %}—{% endif %} +
+
+ {% csrf_token %} + + + + + +
+
+ {% csrf_token %} + + + + + +
+
+ {% csrf_token %} + + + + +
+
+
Операции не добавлены
+
+ +
+ {% csrf_token %} + + + +
+ + +
+
+ +
+
+
{% endif %} -
\ No newline at end of file +
+
+
+{% endblock %} \ No newline at end of file diff --git a/shiftflow/templates/shiftflow/product_info_part.html b/shiftflow/templates/shiftflow/product_info_part.html index 38807f8..57e6883 100644 --- a/shiftflow/templates/shiftflow/product_info_part.html +++ b/shiftflow/templates/shiftflow/product_info_part.html @@ -1,8 +1,35 @@ +{% extends 'base.html' %} + +{% block content %} +
+
+
+ +
{{ entity.get_entity_type_display }}
+
+
+ {% include 'components/_add_to_deal.html' %} + Назад +
+
+
{% csrf_token %} +
@@ -38,15 +65,20 @@
+ {% if not can_edit %}
- - + +
+ {% if entity_ops %} + {% for eo in entity_ops %} + {{ eo.seq }}. {{ eo.operation.name }}{% if eo.operation.workshop %} ({{ eo.operation.workshop.name }}){% endif %}{% if not forloop.last %} · {% endif %} + {% endfor %} + {% else %} + — не указан — + {% endif %} +
+ {% endif %}
@@ -116,12 +148,81 @@ {% if can_edit %} -
- {% csrf_token %} - - - - -
+
+
Операции техпроцесса
+ +
+ + + + + + + + + + + {% for eo in entity_ops %} + + + + + + + {% empty %} + + {% endfor %} + +
ОперацияЦех
{{ eo.seq }}{{ eo.operation.name }}{% if eo.operation.workshop %}{{ eo.operation.workshop.name }}{% else %}—{% endif %} +
+
+ {% csrf_token %} + + + + + +
+
+ {% csrf_token %} + + + + + +
+
+ {% csrf_token %} + + + + +
+
+
Операции не добавлены
+
+ +
+ {% csrf_token %} + + + +
+ + +
+
+ +
+
+
{% endif %} -
\ No newline at end of file +
+
+
+{% endblock %} \ No newline at end of file diff --git a/shiftflow/templates/shiftflow/product_info_purchased.html b/shiftflow/templates/shiftflow/product_info_purchased.html index 74752d1..e927902 100644 --- a/shiftflow/templates/shiftflow/product_info_purchased.html +++ b/shiftflow/templates/shiftflow/product_info_purchased.html @@ -1,8 +1,35 @@ +{% extends 'base.html' %} + +{% block content %} +
+
+
+ +
{{ entity.get_entity_type_display }}
+
+
+ {% include 'components/_add_to_deal.html' %} + Назад +
+
+
{% csrf_token %} +
@@ -33,15 +60,20 @@
+ {% if not can_edit %}
- - + +
+ {% if entity_ops %} + {% for eo in entity_ops %} + {{ eo.seq }}. {{ eo.operation.name }}{% if eo.operation.workshop %} ({{ eo.operation.workshop.name }}){% endif %}{% if not forloop.last %} · {% endif %} + {% endfor %} + {% else %} + — не указан — + {% endif %} +
+ {% endif %}
@@ -68,12 +100,81 @@ {% if can_edit %} -
- {% csrf_token %} - - - - -
+
+
Операции техпроцесса
+ +
+ + + + + + + + + + + {% for eo in entity_ops %} + + + + + + + {% empty %} + + {% endfor %} + +
ОперацияЦех
{{ eo.seq }}{{ eo.operation.name }}{% if eo.operation.workshop %}{{ eo.operation.workshop.name }}{% else %}—{% endif %} +
+
+ {% csrf_token %} + + + + + +
+
+ {% csrf_token %} + + + + + +
+
+ {% csrf_token %} + + + + +
+
+
Операции не добавлены
+
+ +
+ {% csrf_token %} + + + +
+ + +
+
+ +
+
+
{% endif %} -
\ No newline at end of file +
+
+
+{% endblock %} \ No newline at end of file diff --git a/shiftflow/templates/shiftflow/products.html b/shiftflow/templates/shiftflow/products.html index e39b508..d44ec57 100644 --- a/shiftflow/templates/shiftflow/products.html +++ b/shiftflow/templates/shiftflow/products.html @@ -12,16 +12,20 @@ {% endif %} -
- + +
+ + + + + + + + + + + +
@@ -39,12 +43,11 @@ Наименование Материал Заполнен - {% for p in products %} - + {{ p.get_entity_type_display }} {{ p.drawing_number|default:"—" }} {{ p.name }} @@ -62,11 +65,6 @@ {% endif %} - - - {% if p.entity_type == 'product' or p.entity_type == 'assembly' %}Состав{% else %}Открыть{% endif %} - - {% empty %} Пока ничего нет @@ -115,46 +113,32 @@
- - +{% endblock %} \ No newline at end of file diff --git a/shiftflow/templates/shiftflow/supply_catalog.html b/shiftflow/templates/shiftflow/supply_catalog.html new file mode 100644 index 0000000..c1ca11c --- /dev/null +++ b/shiftflow/templates/shiftflow/supply_catalog.html @@ -0,0 +1,141 @@ +{% extends 'base.html' %} + +{% block content %} +
+
+
+ +

Номенклатура снабжения

+
Покупное / Аутсорс
+
+ +
+ Назад + + {% if can_edit %} + + {% endif %} + + +
+ {% for code, label in type_choices %} + + + {% endfor %} +
+ + + + Сброс + +
+
+ +
+ + + + + + + + + + + {% for p in items %} + + + + + + + {% empty %} + + {% endfor %} + +
ТипОбозначениеНаименованиеЗаполнен
{{ p.get_entity_type_display }}{{ p.drawing_number|default:"—" }}{{ p.name }} + {% if p.passport_filled %} + + {% else %} + + {% endif %} +
Пока ничего нет
+
+
+ + + + +{% endblock %} \ No newline at end of file diff --git a/shiftflow/templates/shiftflow/workitem_detail.html b/shiftflow/templates/shiftflow/workitem_detail.html new file mode 100644 index 0000000..a5abf7b --- /dev/null +++ b/shiftflow/templates/shiftflow/workitem_detail.html @@ -0,0 +1,308 @@ +{% extends 'base.html' %} + +{% block content %} +
+
+
+
+

+ + + {{ workitem.entity.drawing_number|default:"—" }} {{ workitem.entity.name }} + + {% if can_edit_entity %} + + + + {% endif %} +

+
+ + Сделка № {{ workitem.deal.number }} + + + {% if user_role in 'admin,master,operator,prod_head' and close_url %} + + {{ close_label|default:'Закрыть' }} + + {% endif %} + + {% if user_role in 'admin,technologist,master,clerk' %} + {% if workitem.entity.entity_type == 'product' or workitem.entity.entity_type == 'assembly' %} + + Комплектация + + {% endif %} + {% endif %} +
+
+ +
+
+ {% csrf_token %} + + + +
+
+ Дата + {% if user_role in 'admin,technologist' %} + + {% else %} + {{ workitem.date|date:"d.m.Y" }} + {% endif %} +
+
+ Цех/Пост + {% if user_role in 'admin,technologist,master' %} + + {% else %} + + {% if workitem.machine %}{{ workitem.machine.name }}{% elif workitem.workshop %}{{ workitem.workshop.name }}{% else %}—{% endif %} + + {% endif %} +
+
+ Операция + {% if workitem.operation %}{{ workitem.operation.name }}{% else %}{{ workitem.stage|default:"—" }}{% endif %} +
+
+ +
+
+ Материал (паспорт) + + {% if workitem.entity.planned_material %} + {{ workitem.entity.planned_material.full_name|default:workitem.entity.planned_material.name }} + {% else %} + — + {% endif %} + +
+
+ План + {% if user_role in 'admin,technologist' %} + + {% else %} + {{ workitem.quantity_plan }} шт. + {% endif %} +
+
+ Факт + {% if user_role in 'admin,technologist,master,operator' %} + + {% else %} + {{ workitem.quantity_done }} шт. + {% endif %} +
+
+ +
+
+ Статус задания + {% if user_role in 'admin,technologist' %} + + {% else %} + {{ workitem.get_status_display }} + {% endif %} +
+ +
+ {% if workitem.comment %} +
+
+ +
{{ workitem.comment }}
+
+
+ {% endif %} + + {% if user_role in 'admin,technologist,master' %} + Комментарий + + {% endif %} +
+
+ +
+ + {% if workitem.entity.entity_type == 'part' %} +
+
Превью
+
+
+
+ {% if workitem.entity.preview %} + Превью + {% else %} +
+ {% endif %} +
+
+
+
+
+ {% if workitem.entity.dxf_file %} + + + + {% else %} + + + + {% endif %} +
DXF/STEP
+
+
+ +
+
+ {% if workitem.entity.pdf_main %} + + + + {% else %} + + + + {% endif %} +
PDF
+
+
+
+ + {% if can_edit_entity and workitem.entity.dxf_file %} +
+ {% csrf_token %} + + +
+ {% endif %} +
+
+ {% else %} +
+
Файлы
+
+ {% if workitem.entity.dxf_file %} + + DXF/STEP + + {% endif %} + {% if workitem.entity.pdf_main %} + + PDF + + {% endif %} + {% if not workitem.entity.dxf_file and not workitem.entity.pdf_main %} +
+ {% endif %} +
+
+ {% endif %} + + {% if workitem.entity.entity_type == 'product' or workitem.entity.entity_type == 'assembly' %} +
+
Паспорт сборки
+
+
+
Масса, кг
+
{{ passport.weight_kg|default:"—" }}
+
+
+
Покрытие
+
{{ passport.coating|default:"—" }}
+
+
+
Цвет
+
{{ passport.coating_color|default:"—" }}
+
+
+
Площадь покрытия, м²
+
{{ passport.coating_area_m2|default:"—" }}
+
+
+
Сварка
+
{% if passport.requires_welding %}Да{% else %}Нет{% endif %}
+
+
+
Покраска
+
{% if passport.requires_painting %}Да{% else %}Нет{% endif %}
+
+
+
Технические требования
+
{{ passport.technical_requirements|default:"—"|linebreaksbr }}
+
+
+
+ +
+
Сварочные швы
+
+ + + + + + + + + + + {% for s in welding_seams %} + + + + + + + {% empty %} + + {% endfor %} + +
НаименованиеКатет, ммДлина, ммКол-во
{{ s.name }}{{ s.leg_mm }}{{ s.length_mm }}{{ s.quantity }}
Швов нет
+
+
+ {% endif %} + +
+ Назад + {% if user_role in 'admin,technologist,master,operator' %} + + {% endif %} +
+
+
+
+
+ + +{% endblock %} \ No newline at end of file diff --git a/shiftflow/templates/shiftflow/workitem_entity_list.html b/shiftflow/templates/shiftflow/workitem_entity_list.html new file mode 100644 index 0000000..40593a3 --- /dev/null +++ b/shiftflow/templates/shiftflow/workitem_entity_list.html @@ -0,0 +1,29 @@ +{% extends 'base.html' %} + +{% block content %} +
+
+
+

+ {{ entity.drawing_number|default:"—" }} {{ entity.name }} +

+
+ Сделка {{ deal.number }} +
+
+ +
+ {% if can_edit_entity %} + + Паспорт + + {% endif %} + + Назад + +
+
+ + {% include 'shiftflow/partials/_workitems_table.html' with workitems=workitems %} +
+{% endblock %} \ No newline at end of file diff --git a/shiftflow/templates/shiftflow/workitem_kitting.html b/shiftflow/templates/shiftflow/workitem_kitting.html new file mode 100644 index 0000000..a78870b --- /dev/null +++ b/shiftflow/templates/shiftflow/workitem_kitting.html @@ -0,0 +1,117 @@ +{% extends 'base.html' %} + +{% block content %} +
+
+

+ Комплектация +

+
+ Сделка № {{ workitem.deal.number }} · {{ workitem.entity.drawing_number|default:"—" }} {{ workitem.entity.name }} +
+
+
+ + Назад + + {% if draft %} + + Печать + +
+ {% csrf_token %} + + + +
+ {% endif %} +
+
+ +
+
+
+
+
Потребные компоненты
+
+ {% if to_location %} + Куда: {{ to_location.name }} · Кол-во: {{ qty_to_make }} шт + {% else %} + Склад участка не определён + {% endif %} +
+
+ +
+ + + + + + + + + + + + + {% for r in rows %} + + + + + + + + + {% empty %} + + {% endfor %} + +
КомпонентНужноЕсть на участкеК перемещениюНе хватаетОткуда взять
+
{{ r.entity.drawing_number|default:"—" }} {{ r.entity.name }}
+
{{ r.entity.get_entity_type_display }}
+
{{ r.need }}{{ r.have_to }}{{ r.to_move }} + {% if r.missing > 0 %} + {{ r.missing }} + {% else %} + 0 + {% endif %} + + {% if r.sources %} +
+ {% for s in r.sources %} +
+ {% csrf_token %} + + + +
+
+
{{ s.location.name }}
+
Доступно: {{ s.available }}{% if s.selected %} · В перемещении: {{ s.selected }}{% endif %}
+
+
+ + + +
+
+
+ {% endfor %} +
+ {% else %} +
Нет остатков (под сделку/свободных)
+ {% endif %} +
Нет потребных компонентов (или кол-во = 0)
+
+ + +
+
+ + +
+{% endblock %} \ No newline at end of file diff --git a/shiftflow/templates/shiftflow/workitem_kitting_print.html b/shiftflow/templates/shiftflow/workitem_kitting_print.html new file mode 100644 index 0000000..003d22d --- /dev/null +++ b/shiftflow/templates/shiftflow/workitem_kitting_print.html @@ -0,0 +1,75 @@ + + + + + + Перемещение + + + +
+
+ +
+ {% csrf_token %} + +
+
+ {% csrf_token %} + +
+
+ +

Перемещение на {{ to_location.name }}

+
Сделка № {{ workitem.deal.number }} · {{ workitem.entity.drawing_number|default:"—" }} {{ workitem.entity.name }} · {{ printed_at|date:"d.m.Y H:i" }}
+ + {% for g in groups %} +
С {{ g.from_location.name }}
+ + + + + + + + + + + {% for it in g.items %} + + + + + + + {% endfor %} + +
НаименованиеК перемещению, штОтметка
{{ forloop.counter }}{{ it.entity.drawing_number|default:"—" }} {{ it.entity.name }}{{ it.quantity }}
+ {% empty %} +
Лист перемещения пуст.
+ {% endfor %} +
+ +{% if auto_print %} + +{% endif %} + + \ No newline at end of file diff --git a/shiftflow/templates/shiftflow/workitem_op_closing.html b/shiftflow/templates/shiftflow/workitem_op_closing.html new file mode 100644 index 0000000..2d43015 --- /dev/null +++ b/shiftflow/templates/shiftflow/workitem_op_closing.html @@ -0,0 +1,47 @@ +{% extends 'base.html' %} + +{% block content %} +
+
+
+
+

+ Закрытие операции +

+ Назад к заданию +
+ +
+
+
{{ workitem.entity.drawing_number|default:"—" }} {{ workitem.entity.name }}
+
Сделка № {{ workitem.deal.number }}
+
+ Операция: {% if workitem.operation %}{{ workitem.operation.name }}{% else %}{{ workitem.stage|default:"—" }}{% endif %} +
+
План: {{ workitem.quantity_plan }} · Факт: {{ workitem.quantity_done }} · Остаток: {{ remaining }}
+
+ +
+ Эта операция не списывает сырьё/комплектующие. Здесь фиксируется только факт выполнения. +
+ +
+ {% csrf_token %} +
+
+ + +
+
+ +
+
+
+ +
+
+
+
+{% endblock %} \ No newline at end of file diff --git a/shiftflow/templates/shiftflow/workshops_catalog.html b/shiftflow/templates/shiftflow/workshops_catalog.html new file mode 100644 index 0000000..0a390a7 --- /dev/null +++ b/shiftflow/templates/shiftflow/workshops_catalog.html @@ -0,0 +1,43 @@ +{% extends 'base.html' %} + +{% block content %} +
+
+

Справочник · Цеха

+ Назад +
+ +
+ + + + + + + + + + + + {% for ws in workshops %} + + + + + + + + {% empty %} + + {% endfor %} + +
IDЦехСклад цехаПосты/станки
{{ forloop.counter }}{{ ws.id }}{{ ws.name }}{{ ws.location.name|default:"—" }} + {% if ws.machine_labels %} + {{ ws.machine_labels }} + {% else %} + + {% endif %} +
Цехов нет.
+
+
+{% endblock %} \ No newline at end of file diff --git a/shiftflow/templates/shiftflow/writeoffs.html b/shiftflow/templates/shiftflow/writeoffs.html index d7c7b11..9e0d6ba 100644 --- a/shiftflow/templates/shiftflow/writeoffs.html +++ b/shiftflow/templates/shiftflow/writeoffs.html @@ -32,14 +32,30 @@
По производственным отчетам
-
- {% for card in report_cards %} +
+ {% csrf_token %} + + + +
+ {% for card in report_cards %}
-
+
{{ card.report.date|date:"d.m.Y" }} — {{ card.report.machine }} — {{ card.report.operator }} #{{ card.report.id }}
+ +
+ {% if card.report.is_synced_1c %} + Выгружено в 1С + {% else %} + Не выгружено + {% if can_edit %} + + {% endif %} + {% endif %} +
@@ -54,6 +70,12 @@ ({% if c.stock_item.current_length and c.stock_item.current_width %}{{ c.stock_item.current_length|floatformat:"-g" }}×{{ c.stock_item.current_width|floatformat:"-g" }}{% elif c.stock_item.current_length %}{{ c.stock_item.current_length|floatformat:"-g" }}{% else %}—{% endif %}) {{ c.quantity|floatformat:"-g" }} шт + {% elif c.stock_item_id and c.stock_item.entity_id %} +
  • + {{ c.stock_item.entity }} + {% if c.stock_item.deal_id %}(сделка № {{ c.stock_item.deal.number }}){% endif %} + {{ c.quantity|floatformat:"-g" }} шт +
  • {% elif c.material_id %}
  • {{ c.material }} {{ c.quantity|floatformat:"-g" }} шт
  • {% else %} @@ -81,10 +103,14 @@
    Остаток ДО
    - {% if card.remnants %} + {% if card.report.remnants.all %}
      - {% for k,v in card.remnants.items %} -
    • {{ k }}: {{ v }} шт
    • + {% for r in card.report.remnants.all %} +
    • + {{ r.material.full_name|default:r.material.name|default:r.material }} + ({% if r.current_length and r.current_width %}{{ r.current_length|floatformat:"-g" }}×{{ r.current_width|floatformat:"-g" }}{% elif r.current_length %}{{ r.current_length|floatformat:"-g" }}{% else %}—{% endif %}) + {{ r.quantity|floatformat:"-g" }} шт +
    • {% endfor %}
    {% else %} @@ -93,79 +119,16 @@
    - {% empty %} -
    За выбранный период отчётов нет.
    - {% endfor %} -
    -
    - -
    -
    -

    Сменные задания (1С)

    -
    Отметка «Списано в 1С»
    -
    - - - {% csrf_token %} - - - -
    - - - - - - - - - - - - - - - {% for it in items %} - - - - - - - - - - - {% empty %} - - {% endfor %} - -
    ДатаСделкаСтанокДетальСтатусФакт
    - {% if can_edit and not it.is_synced_1c %} - - {% endif %} - {{ it.date|date:"d.m.Y" }} - {% if it.task.deal_id %} - {{ it.task.deal.number }} - {% else %} - — - {% endif %} - {{ it.machine }} - {{ it.task.drawing_name }} - {{ it.get_status_display }}{{ it.quantity_fact }} - {% if it.is_synced_1c %} - Да - {% else %} - Нет - {% endif %} -
    Нет закрытых заданий за период
    + {% empty %} +
    За выбранный период отчётов нет.
    + {% endfor %}
    + {% if can_edit %} + {% endif %}
    {% endblock %} \ No newline at end of file diff --git a/shiftflow/urls.py b/shiftflow/urls.py index cfaccc6..c7a86f7 100644 --- a/shiftflow/urls.py +++ b/shiftflow/urls.py @@ -6,28 +6,57 @@ from .views import ( DealDetailView, DealPlanningView, DealUpsertView, + DealBatchActionView, + DealItemUpsertView, + DirectoriesView, + SupplyCatalogView, + LocationsCatalogView, + WorkshopsCatalogView, + MachinesCatalogView, + EntitiesSearchView, IndexView, ItemUpdateView, MaintenanceStatusView, MaintenanceView, + MaterialCategoriesCatalogView, MaterialCategoryUpsertView, MaterialDetailView, MaterialUpsertView, + MaterialsCatalogView, PlanningAddView, PlanningView, + PlanningStagesView, ProductionTaskCreateView, + WeldingPlanAddView, + PaintingPlanAddView, + WorkItemPlanAddView, + WorkItemUpdateView, RegistryPrintView, + WorkItemDetailView, + WorkItemEntityListView, + WorkItemOpClosingView, + WorkItemKittingView, + WorkItemKittingPrintView, + AssemblyClosingView, + WorkItemRegistryPrintView, RegistryView, + SteelGradesCatalogView, SteelGradeUpsertView, TaskItemsView, ClosingView, + ClosingWorkItemsView, ProductDetailView, + ProductEntityPreviewUpdateView, ProductInfoView, ProductsView, WriteOffsView, + LegacyClosingView, + LegacyRegistryView, + LegacyWriteOffsView, WarehouseReceiptCreateView, WarehouseStocksView, WarehouseTransferCreateView, + ProcurementDashboardView, ) urlpatterns = [ @@ -36,6 +65,7 @@ urlpatterns = [ # Реестр path('registry/', RegistryView.as_view(), name='registry'), + path('legacy/registry/', LegacyRegistryView.as_view(), name='legacy_registry'), # Сделки path('planning/', PlanningView.as_view(), name='planning'), path('planning/deal//', DealPlanningView.as_view(), name='planning_deal'), @@ -46,25 +76,55 @@ urlpatterns = [ path('maintenance/status/', MaintenanceStatusView.as_view(), name='maintenance_status'), path('planning/add/', PlanningAddView.as_view(), name='planning_add'), path('planning/task/add/', ProductionTaskCreateView.as_view(), name='task_add'), + path('planning/stages/', PlanningStagesView.as_view(), name='planning_stages'), + path('planning/welding/add/', WeldingPlanAddView.as_view(), name='welding_plan_add'), + path('planning/painting/add/', PaintingPlanAddView.as_view(), name='painting_plan_add'), + path('planning/workitem/add/', WorkItemPlanAddView.as_view(), name='workitem_add'), + path('planning/workitem/update/', WorkItemUpdateView.as_view(), name='workitem_update'), path('planning/deal//json/', DealDetailView.as_view(), name='deal_json'), path('planning/deal/upsert/', DealUpsertView.as_view(), name='deal_upsert'), + path('planning/deal/batch/action/', DealBatchActionView.as_view(), name='deal_batch_action'), + path('planning/deal/item/upsert/', DealItemUpsertView.as_view(), name='deal_item_upsert'), + path('entities/search/', EntitiesSearchView.as_view(), name='entities_search'), path('planning/company/upsert/', CompanyUpsertView.as_view(), name='company_upsert'), path('planning/material//json/', MaterialDetailView.as_view(), name='material_json'), path('planning/material/upsert/', MaterialUpsertView.as_view(), name='material_upsert'), path('planning/material-category/upsert/', MaterialCategoryUpsertView.as_view(), name='material_category_upsert'), path('planning/steel-grade/upsert/', SteelGradeUpsertView.as_view(), name='steel_grade_upsert'), + + path('directories/', DirectoriesView.as_view(), name='directories'), + path('directories/supply/', SupplyCatalogView.as_view(), name='supply_catalog'), + path('directories/locations/', LocationsCatalogView.as_view(), name='locations_catalog'), + path('directories/workshops/', WorkshopsCatalogView.as_view(), name='workshops_catalog'), + path('directories/machines/', MachinesCatalogView.as_view(), name='machines_catalog'), + path('directories/materials/', MaterialsCatalogView.as_view(), name='materials_catalog'), + path('directories/material-categories/', MaterialCategoriesCatalogView.as_view(), name='material_categories_catalog'), + path('directories/steel-grades/', SteelGradesCatalogView.as_view(), name='steel_grades_catalog'), + # Печать сменного листа path('registry/print/', RegistryPrintView.as_view(), name='registry_print'), + path('registry/workitems/print/', WorkItemRegistryPrintView.as_view(), name='registry_workitems_print'), path('item//', ItemUpdateView.as_view(), name='item_detail'), + path('workitem//', WorkItemDetailView.as_view(), name='workitem_detail'), + path('workitem//op_closing/', WorkItemOpClosingView.as_view(), name='workitem_op_closing'), + path('workitem//kitting/', WorkItemKittingView.as_view(), name='workitem_kitting'), + path('workitem//kitting/print/', WorkItemKittingPrintView.as_view(), name='workitem_kitting_print'), + path('workitem//assembly_closing/', AssemblyClosingView.as_view(), name='assembly_closing'), + path('workitems///', WorkItemEntityListView.as_view(), name='workitem_entity_list'), path('warehouse/stocks/', WarehouseStocksView.as_view(), name='warehouse_stocks'), path('warehouse/transfer/', WarehouseTransferCreateView.as_view(), name='warehouse_transfer'), path('warehouse/receipt/', WarehouseReceiptCreateView.as_view(), name='warehouse_receipt'), path('closing/', ClosingView.as_view(), name='closing'), + path('closing/workitems/', ClosingWorkItemsView.as_view(), name='closing_workitems'), path('writeoffs/', WriteOffsView.as_view(), name='writeoffs'), + path('procurement/', ProcurementDashboardView.as_view(), name='procurement'), + path('legacy/closing/', LegacyClosingView.as_view(), name='legacy_closing'), + path('legacy/writeoffs/', LegacyWriteOffsView.as_view(), name='legacy_writeoffs'), path('products/', ProductsView.as_view(), name='products'), path('products//', ProductDetailView.as_view(), name='product_detail'), path('products//info/', ProductInfoView.as_view(), name='product_info'), + path('products//preview/update/', ProductEntityPreviewUpdateView.as_view(), name='product_preview_update'), ] \ No newline at end of file diff --git a/shiftflow/views.py b/shiftflow/views.py index acc3ac4..23dd54f 100644 --- a/shiftflow/views.py +++ b/shiftflow/views.py @@ -1,6 +1,7 @@ from datetime import datetime, timedelta -from urllib.parse import urlsplit +from urllib.parse import urlencode, urlsplit +import logging import os import subprocess import sys @@ -11,9 +12,9 @@ from django.conf import settings as django_settings from django.contrib import messages from django.core.files.base import ContentFile -from django.db import close_old_connections +from django.db import close_old_connections, transaction -from django.db.models import Case, ExpressionWrapper, F, IntegerField, Sum, Value, When +from django.db.models import Case, ExpressionWrapper, F, IntegerField, Max, Sum, Value, When from django.db.models import Q from django.db.models.functions import Coalesce from django.http import JsonResponse @@ -24,28 +25,120 @@ from django.views.generic import FormView, ListView, TemplateView, UpdateView from django.contrib.auth.mixins import LoginRequiredMixin from django.utils import timezone +from shiftflow.authz import get_user_group_roles, get_user_roles, primary_role, has_any_role + +logger = logging.getLogger('mes') + + +def _reconcile_default_delivery_batch(deal_id: int) -> None: + deal_items = list(DealItem.objects.filter(deal_id=deal_id).values_list('entity_id', 'quantity')) + if not deal_items: + return + + deal_due = Deal.objects.filter(id=deal_id).values_list('due_date', flat=True).first() + non_default_dates = list( + DealDeliveryBatch.objects.filter(deal_id=deal_id, is_default=False).values_list('due_date', flat=True) + ) + due = max(non_default_dates) if non_default_dates else (deal_due or timezone.localdate()) + + default_batch, created = DealDeliveryBatch.objects.get_or_create( + deal_id=deal_id, + is_default=True, + defaults={'name': 'К закрытию', 'due_date': due}, + ) + upd = [] + if created or default_batch.name.strip() != 'К закрытию': + default_batch.name = 'К закрытию' + upd.append('name') + if default_batch.due_date != due: + default_batch.due_date = due + upd.append('due_date') + if upd: + default_batch.save(update_fields=upd) + + allocated = { + int(r['entity_id']): int(r['s'] or 0) + for r in DealBatchItem.objects.filter(batch__deal_id=deal_id, batch__is_default=False) + .values('entity_id') + .annotate(s=Coalesce(Sum('quantity'), 0)) + } + + current_defaults = { + int(x.entity_id): x + for x in DealBatchItem.objects.filter(batch_id=default_batch.id).select_related('entity') + } + + for entity_id, qty in deal_items: + total = int(qty or 0) + used = int(allocated.get(int(entity_id), 0) or 0) + residual = total - used + if residual < 0: + residual = 0 + + cur = current_defaults.get(int(entity_id)) + if residual <= 0: + if cur: + cur.delete() + continue + + if cur: + changed = False + if int(cur.quantity or 0) != residual: + cur.quantity = residual + changed = True + if int(cur.started_qty or 0) > residual: + cur.started_qty = residual + changed = True + if changed: + cur.save(update_fields=['quantity', 'started_qty']) + else: + DealBatchItem.objects.create(batch_id=default_batch.id, entity_id=int(entity_id), quantity=residual, started_qty=0) + from manufacturing.models import ( AssemblyPassport, BOM, CastingPassport, + EntityOperation, + Operation, OutsourcedPassport, PartPassport, ProductEntity, PurchasedPassport, - RouteStub, WeldingSeam, ) from warehouse.models import Location, Material, MaterialCategory, SteelGrade, StockItem, TransferLine, TransferRecord from warehouse.services.transfers import receive_transfer -from shiftflow.services.closing import apply_closing +from shiftflow.services.closing import apply_closing, apply_closing_workitems +from shiftflow.services.bom_explosion import ( + explode_deal, + explode_roots_additive, + ExplosionValidationError, + _build_bom_graph, + _accumulate_requirements, +) + +from shiftflow.services.kitting import ( + build_kitting_requirements, + build_kitting_leaf_requirements, + get_work_location_for_workitem, + add_kitting_line, + remove_kitting_line, + get_kitting_draft, + clear_kitting_draft, + apply_kitting_draft, +) from .forms import ProductionTaskCreateForm from .models import ( Company, CuttingSession, Deal, + DealBatchItem, + DealDeliveryBatch, + DealEntityProgress, + DealItem, DxfPreviewJob, DxfPreviewSettings, EmployeeProfile, @@ -55,7 +148,11 @@ from .models import ( ProductionReportRemnant, ProductionReportStockResult, ProductionTask, + MaterialRequirement, + ProcurementRequirement, ShiftItem, + WorkItem, + Workshop, ) @@ -243,6 +340,43 @@ def _update_task_preview(task: ProductionTask) -> bool: task.save(update_fields=['preview_image']) return True + +def _update_entity_preview(entity: ProductEntity) -> bool: + if not entity.dxf_file: + return False + + name = (entity.dxf_file.name or '').lower() + if not name.endswith('.dxf'): + try: + if entity.preview: + entity.preview.delete(save=False) + except Exception: + pass + entity.preview = None + entity.save(update_fields=['preview']) + return False + + dxf_path = getattr(entity.dxf_file, 'path', '') + settings = _get_dxf_preview_settings() + png_bytes = _render_dxf_preview_png( + dxf_path, + line_color=settings.line_color, + lineweight_scaling=settings.lineweight_scaling, + min_lineweight_mm=settings.min_lineweight, + keep_original_colors=settings.keep_original_colors, + ) + + try: + if entity.preview: + entity.preview.delete(save=False) + except Exception: + pass + + filename = f"entity_{entity.id}_preview.png" + entity.preview.save(filename, ContentFile(png_bytes), save=False) + entity.save(update_fields=['preview']) + return True + # Класс главной страницы (роутер) class IndexView(TemplateView): template_name = 'shiftflow/landing.html' @@ -260,11 +394,18 @@ class RegistryView(LoginRequiredMixin, ListView): template_name = 'shiftflow/registry.html' context_object_name = 'items' + def dispatch(self, request, *args, **kwargs): + roles = get_user_group_roles(request.user) + if not has_any_role(roles, ['admin', 'operator', 'master', 'technologist', 'clerk', 'prod_head', 'director', 'observer']): + return redirect('index') + return super().dispatch(request, *args, **kwargs) + def get_queryset(self): queryset = Item.objects.select_related('task', 'task__deal', 'task__material', 'machine') user = self.request.user profile = getattr(user, 'profile', None) - role = profile.role if profile else 'operator' + roles = get_user_group_roles(user) + role = primary_role(roles) # Флаг, что фильтрация была применена через форму. Если нет — используем дефолты filtered = self.request.GET.get('filtered') # Принудительный сброс фильтров (?reset=1) — ведёт себя как первый заход на страницу @@ -307,10 +448,6 @@ class RegistryView(LoginRequiredMixin, ListView): if end_date: queryset = queryset.filter(date__lte=end_date) - # Списание (1С) - is_synced = self.request.GET.get('is_synced') - if is_synced in ['0', '1']: - queryset = queryset.filter(is_synced_1c=bool(int(is_synced))) # Ограничения по ролям if role == 'operator': @@ -327,10 +464,16 @@ class RegistryView(LoginRequiredMixin, ListView): context = super().get_context_data(**kwargs) user = self.request.user profile = getattr(user, 'profile', None) - role = profile.role if profile else 'operator' + roles = get_user_group_roles(user) + role = primary_role(roles) context['user_role'] = role + context['user_roles'] = sorted(roles) + context['is_readonly'] = bool(getattr(profile, 'is_readonly', False)) if profile else False - machines = Machine.objects.all() + allowed_ws = list(profile.allowed_workshops.values_list('id', flat=True)) if profile else [] + context['allowed_workshop_ids'] = allowed_ws + + machines = Machine.objects.filter(machine_type__in=['linear', 'sheet']).order_by('name') context['machines'] = machines filtered = self.request.GET.get('filtered') @@ -350,7 +493,6 @@ class RegistryView(LoginRequiredMixin, ListView): context['selected_statuses'] = self.request.GET.getlist('statuses') context['start_date'] = self.request.GET.get('start_date', '') context['end_date'] = self.request.GET.get('end_date', '') - context['is_synced'] = self.request.GET.get('is_synced', '') context['all_selected_machines'] = False items = list(context.get('items') or []) @@ -366,12 +508,1295 @@ class RegistryView(LoginRequiredMixin, ListView): it.fact_bar_class = 'bg-success' if it.status in ['done', 'partial'] else 'bg-warning' context['items'] = items + work_qs = WorkItem.objects.select_related('deal', 'entity', 'entity__planned_material', 'operation', 'machine', 'workshop') + + m_ids = [int(i) for i in self.request.GET.getlist('m_ids') if str(i).isdigit()] + if m_ids: + ws_ids = list( + Machine.objects.filter(id__in=m_ids) + .exclude(workshop_id__isnull=True) + .values_list('workshop_id', flat=True) + ) + work_qs = work_qs.filter(Q(machine_id__in=m_ids) | Q(machine_id__isnull=True, workshop_id__in=ws_ids)) + + filtered = self.request.GET.get('filtered') + reset = self.request.GET.get('reset') + is_default = (not filtered) or bool(reset) + + if is_default: + today = timezone.localdate() + week_ago = today - timezone.timedelta(days=7) + work_qs = work_qs.filter(date__gte=week_ago, date__lte=today) + else: + if context.get('start_date'): + work_qs = work_qs.filter(date__gte=context['start_date']) + if context.get('end_date'): + work_qs = work_qs.filter(date__lte=context['end_date']) + + statuses = self.request.GET.getlist('statuses') + if is_default: + work_qs = work_qs.filter(status__in=['planned']) + else: + if not statuses: + work_qs = work_qs.none() + else: + expanded = [] + for s in statuses: + if s == 'work': + expanded += ['planned'] + elif s == 'leftover': + expanded.append('leftover') + elif s == 'closed': + expanded.append('done') + if expanded: + work_qs = work_qs.filter(status__in=expanded) + + if role == 'operator': + user_machines = profile.machines.all() if profile else Machine.objects.none() + work_qs = work_qs.filter(machine__in=user_machines) + elif role == 'master': + allowed_ws = list(profile.allowed_workshops.values_list('id', flat=True)) if profile else [] + if allowed_ws: + work_qs = work_qs.filter(Q(machine__workshop_id__in=allowed_ws) | Q(machine_id__isnull=True, workshop_id__in=allowed_ws)) + + workitems = list(work_qs.order_by('-date', 'deal__number', 'id')[:2000]) + for wi in workitems: + plan = int(wi.quantity_plan or 0) + done = int(wi.quantity_done or 0) + if plan > 0: + pct = int(round(done * 100 / plan)) + else: + pct = 0 + wi.fact_pct = pct + wi.fact_width = max(0, min(100, pct)) + context['workitems'] = workitems + return context +class LegacyRegistryView(RegistryView): + template_name = 'shiftflow/legacy_registry.html' + + def dispatch(self, request, *args, **kwargs): + roles = get_user_group_roles(request.user) + if not has_any_role(roles, ['admin', 'master', 'technologist', 'clerk', 'operator', 'prod_head', 'director', 'observer']): + return redirect('registry') + return super().dispatch(request, *args, **kwargs) + + +class WeldingPlanAddView(LoginRequiredMixin, View): + def post(self, request, *args, **kwargs): + profile = getattr(request.user, 'profile', None) + role = profile.role if profile else ('admin' if request.user.is_superuser else 'operator') + is_readonly = bool(getattr(profile, 'is_readonly', False)) if profile else False + if role not in ['admin', 'technologist', 'master', 'clerk', 'manager', 'observer']: + return redirect('planning') + if is_readonly: + messages.error(request, 'Доступ только для просмотра.') + return redirect('planning') + + def parse_int(s): + s = (s or '').strip() + return int(s) if s.isdigit() else None + + deal_id = parse_int(request.POST.get('deal_id')) + entity_id = parse_int(request.POST.get('entity_id')) + qty = parse_int(request.POST.get('quantity')) + workshop_id = parse_int(request.POST.get('workshop_id')) + + allowed_ws = set(profile.allowed_workshops.values_list('id', flat=True)) if profile else set() + + if not (deal_id and entity_id and qty and qty > 0): + messages.error(request, 'Заполни сущность и количество для сварки.') + next_url = (request.POST.get('next') or '').strip() + return redirect(next_url if next_url.startswith('/') else 'planning') + + di = DealItem.objects.filter(deal_id=deal_id, entity_id=entity_id).first() + if not di: + messages.error(request, 'Позиция сделки не найдена.') + next_url = (request.POST.get('next') or '').strip() + return redirect(next_url if next_url.startswith('/') else 'planning') + + # Комментарий: берём текущую операцию по маршруту детали/сборки. + cur = DealEntityProgress.objects.filter(deal_id=deal_id, entity_id=entity_id).values_list('current_seq', flat=True).first() + cur = int(cur or 1) + eo = EntityOperation.objects.select_related('operation').filter(entity_id=entity_id, seq=cur).first() + op = eo.operation if eo else Operation.objects.filter(code='welding').first() + + if not op: + messages.error(request, 'Не найдена операция welding. Создай её в справочнике операций.') + next_url = (request.POST.get('next') or '').strip() + return redirect(next_url if next_url.startswith('/') else 'planning') + + if eo and op.code != 'welding': + messages.error(request, f"Текущая операция по маршруту: {op.name}. Нельзя поставить в план сварки.") + next_url = (request.POST.get('next') or '').strip() + return redirect(next_url if next_url.startswith('/') else 'planning') + + if not workshop_id and op.workshop_id: + workshop_id = int(op.workshop_id) + + if allowed_ws: + if not workshop_id: + messages.error(request, 'Выбери цех.') + next_url = (request.POST.get('next') or '').strip() + return redirect(next_url if next_url.startswith('/') else 'planning') + if workshop_id not in allowed_ws: + messages.error(request, 'Нет доступа к выбранному цеху.') + next_url = (request.POST.get('next') or '').strip() + return redirect(next_url if next_url.startswith('/') else 'planning') + + # Комментарий: не даём планировать сварку сверх заказа в сделке. + planned = WorkItem.objects.filter(deal_id=deal_id, entity_id=entity_id).filter(Q(operation__code='welding') | Q(stage='welding')).aggregate(s=Coalesce(Sum('quantity_plan'), 0))['s'] + remaining = int(di.quantity or 0) - int(planned or 0) + if qty > remaining: + messages.error(request, f'Нельзя добавить {qty} шт: осталось {max(0, remaining)} шт.') + next_url = (request.POST.get('next') or '').strip() + return redirect(next_url if next_url.startswith('/') else 'planning') + + WorkItem.objects.create( + deal_id=deal_id, + entity_id=entity_id, + operation_id=op.id, + stage='welding', + workshop_id=(workshop_id if workshop_id else None), + quantity_plan=qty, + status='planned', + date=timezone.localdate(), + ) + messages.success(request, 'Добавлено в план сварки.') + next_url = (request.POST.get('next') or '').strip() + return redirect(next_url if next_url.startswith('/') else 'planning') + + +class PaintingPlanAddView(LoginRequiredMixin, View): + def post(self, request, *args, **kwargs): + profile = getattr(request.user, 'profile', None) + role = profile.role if profile else ('admin' if request.user.is_superuser else 'operator') + is_readonly = bool(getattr(profile, 'is_readonly', False)) if profile else False + if role not in ['admin', 'technologist', 'master', 'clerk', 'manager', 'observer']: + return redirect('planning') + if is_readonly: + messages.error(request, 'Доступ только для просмотра.') + return redirect('planning') + + def parse_int(s): + s = (s or '').strip() + return int(s) if s.isdigit() else None + + deal_id = parse_int(request.POST.get('deal_id')) + entity_id = parse_int(request.POST.get('entity_id')) + qty = parse_int(request.POST.get('quantity')) + workshop_id = parse_int(request.POST.get('workshop_id')) + + allowed_ws = set(profile.allowed_workshops.values_list('id', flat=True)) if profile else set() + if allowed_ws: + if not workshop_id: + messages.error(request, 'Выбери цех.') + next_url = (request.POST.get('next') or '').strip() + return redirect(next_url if next_url.startswith('/') else 'planning') + if workshop_id not in allowed_ws: + messages.error(request, 'Нет доступа к выбранному цеху.') + next_url = (request.POST.get('next') or '').strip() + return redirect(next_url if next_url.startswith('/') else 'planning') + + if not (deal_id and entity_id and qty and qty > 0): + messages.error(request, 'Заполни сущность и количество для покраски.') + next_url = (request.POST.get('next') or '').strip() + return redirect(next_url if next_url.startswith('/') else 'planning') + + di = DealItem.objects.filter(deal_id=deal_id, entity_id=entity_id).first() + if not di: + messages.error(request, 'Позиция сделки не найдена.') + next_url = (request.POST.get('next') or '').strip() + return redirect(next_url if next_url.startswith('/') else 'planning') + + # Комментарий: покраску можно планировать только на то, что реально сварено. + # Доступно к покраске = min(заказано, сварено) − уже в плане покраски. + # Комментарий: берём текущую операцию по маршруту детали/сборки. + cur = DealEntityProgress.objects.filter(deal_id=deal_id, entity_id=entity_id).values_list('current_seq', flat=True).first() + cur = int(cur or 1) + eo = EntityOperation.objects.select_related('operation').filter(entity_id=entity_id, seq=cur).first() + op = eo.operation if eo else Operation.objects.filter(code='painting').first() + + if not op: + messages.error(request, 'Не найдена операция painting. Создай её в справочнике операций.') + next_url = (request.POST.get('next') or '').strip() + return redirect(next_url if next_url.startswith('/') else 'planning') + + if eo and op.code != 'painting': + messages.error(request, f"Текущая операция по маршруту: {op.name}. Нельзя поставить в план покраски.") + next_url = (request.POST.get('next') or '').strip() + return redirect(next_url if next_url.startswith('/') else 'planning') + + if not workshop_id and op.workshop_id: + workshop_id = int(op.workshop_id) + + allowed_ws = set(profile.allowed_workshops.values_list('id', flat=True)) if profile else set() + if allowed_ws: + if not workshop_id: + messages.error(request, 'Выбери цех.') + next_url = (request.POST.get('next') or '').strip() + return redirect(next_url if next_url.startswith('/') else 'planning') + if workshop_id not in allowed_ws: + messages.error(request, 'Нет доступа к выбранному цеху.') + next_url = (request.POST.get('next') or '').strip() + return redirect(next_url if next_url.startswith('/') else 'planning') + + # Комментарий: покраску можно планировать только на то, что реально сварено. + welded_done = WorkItem.objects.filter(deal_id=deal_id, entity_id=entity_id).filter(Q(operation__code='welding') | Q(stage='welding')).aggregate(s=Coalesce(Sum('quantity_done'), 0))['s'] + painting_planned = WorkItem.objects.filter(deal_id=deal_id, entity_id=entity_id).filter(Q(operation__code='painting') | Q(stage='painting')).aggregate(s=Coalesce(Sum('quantity_plan'), 0))['s'] + max_paintable = min(int(di.quantity or 0), int(welded_done or 0)) - int(painting_planned or 0) + if qty > max_paintable: + messages.error(request, f'Нельзя добавить {qty} шт: доступно {max(0, max_paintable)} шт.') + next_url = (request.POST.get('next') or '').strip() + return redirect(next_url if next_url.startswith('/') else 'planning') + + WorkItem.objects.create( + deal_id=deal_id, + entity_id=entity_id, + operation_id=op.id, + stage='painting', + workshop_id=(workshop_id if workshop_id else None), + quantity_plan=qty, + status='planned', + date=timezone.localdate(), + ) + messages.success(request, 'Добавлено в план покраски.') + next_url = (request.POST.get('next') or '').strip() + return redirect(next_url if next_url.startswith('/') else 'planning') + + +class WorkItemUpdateView(LoginRequiredMixin, View): + def post(self, request, *args, **kwargs): + profile = getattr(request.user, 'profile', None) + roles = get_user_roles(request.user) + role = primary_role(roles) + is_readonly = bool(getattr(profile, 'is_readonly', False)) if profile else False + + edit_roles = ['admin', 'technologist', 'master', 'operator', 'prod_head'] + if not has_any_role(roles, edit_roles): + return redirect('planning') + if is_readonly: + messages.error(request, 'Доступ только для просмотра.') + return redirect('planning') + + def parse_int(s): + s = (s or '').strip() + return int(s) if s.isdigit() else None + + wi_id = parse_int(request.POST.get('workitem_id')) + action = (request.POST.get('action') or '').strip() + next_url = (request.POST.get('next') or '').strip() + next_url = next_url if next_url.startswith('/') else str(reverse_lazy('planning')) + + wi = None + if wi_id: + wi = WorkItem.objects.filter(pk=wi_id).first() + if not wi: + messages.error(request, 'Запись плана не найдена.') + return redirect(next_url) + + allowed_ws = set(profile.allowed_workshops.values_list('id', flat=True)) if profile else set() + if role == 'operator': + user_machines = profile.machines.all() if profile else Machine.objects.none() + user_machine_ids = set(user_machines.values_list('id', flat=True)) + user_ws_ids = set( + Machine.objects.filter(id__in=list(user_machine_ids)) + .exclude(workshop_id__isnull=True) + .values_list('workshop_id', flat=True) + ) + + if wi.machine_id: + if wi.machine_id not in user_machine_ids: + messages.error(request, 'Нет доступа к заданию на другом станке.') + return redirect(next_url) + else: + if wi.workshop_id and wi.workshop_id not in user_ws_ids: + messages.error(request, 'Нет доступа к заданию из другого цеха.') + return redirect(next_url) + + allowed_ws = user_ws_ids + + if allowed_ws and wi.workshop_id and wi.workshop_id not in allowed_ws: + messages.error(request, 'Нет доступа к записи плана из другого цеха.') + return redirect(next_url) + + if action == 'delete': + wi.delete() + messages.success(request, 'Запись плана удалена.') + return redirect(next_url) + + qty_plan = parse_int(request.POST.get('quantity_plan')) + qty_done = parse_int(request.POST.get('quantity_done')) + workshop_id = parse_int(request.POST.get('workshop_id')) + machine_id = parse_int(request.POST.get('machine_id')) + date_raw = (request.POST.get('date') or '').strip() + workitem_status = (request.POST.get('workitem_status') or '').strip() + comment = (request.POST.get('comment') or '').strip() + + changed_fields = [] + + # Комментарий: правка плана/факта должна оставаться в рамках потребности сделки. + # Это защищает от ситуации, когда вручную «перепланировали» больше, чем заказано. + deal_item = DealItem.objects.filter(deal_id=wi.deal_id, entity_id=wi.entity_id).first() + ordered_qty = int(deal_item.quantity) if deal_item else None + + if role == 'operator': + qty_plan = None + workshop_id = None + machine_id = None + date_raw = '' + workitem_status = '' + + if role == 'master': + workitem_status = '' + + if qty_plan is not None and qty_plan >= 0: + if ordered_qty is not None and wi.stage == 'welding': + other = WorkItem.objects.filter(deal_id=wi.deal_id, entity_id=wi.entity_id, stage='welding').exclude(pk=wi.id).aggregate(s=Coalesce(Sum('quantity_plan'), 0))['s'] + max_for_row = max(0, ordered_qty - int(other or 0)) + if qty_plan > max_for_row: + messages.error(request, f'Нельзя поставить в план {qty_plan} шт: максимум {max_for_row} шт.') + return redirect(next_url) + + if ordered_qty is not None and wi.stage == 'painting': + welded_done = WorkItem.objects.filter(deal_id=wi.deal_id, entity_id=wi.entity_id, stage='welding').aggregate(s=Coalesce(Sum('quantity_done'), 0))['s'] + other = WorkItem.objects.filter(deal_id=wi.deal_id, entity_id=wi.entity_id, stage='painting').exclude(pk=wi.id).aggregate(s=Coalesce(Sum('quantity_plan'), 0))['s'] + max_paintable = max(0, min(ordered_qty, int(welded_done or 0)) - int(other or 0)) + if qty_plan > max_paintable: + messages.error(request, f'Нельзя поставить в план {qty_plan} шт: доступно {max_paintable} шт.') + return redirect(next_url) + + wi.quantity_plan = qty_plan + changed_fields.append('quantity_plan') + + if qty_done is not None and qty_done >= 0: + # Комментарий: факт не должен превышать план по строке, иначе ломается «доступно к покраске». + plan_val = int((qty_plan if qty_plan is not None else wi.quantity_plan) or 0) + if plan_val > 0 and qty_done > plan_val: + messages.error(request, f'Факт ({qty_done}) не может быть больше плана ({plan_val}).') + return redirect(next_url) + + wi.quantity_done = qty_done + changed_fields.append('quantity_done') + + if machine_id is not None and role in ['admin', 'technologist', 'master']: + wi.machine_id = machine_id + changed_fields.append('machine') + + if date_raw and role in ['admin', 'technologist']: + try: + wi.date = datetime.strptime(date_raw, '%Y-%m-%d').date() + changed_fields.append('date') + except Exception: + pass + + fixed_workshop_id = None + if getattr(wi, 'operation_id', None): + fixed_workshop_id = Operation.objects.filter(id=wi.operation_id).values_list('workshop_id', flat=True).first() + + if fixed_workshop_id: + fixed_workshop_id = int(fixed_workshop_id) + if wi.workshop_id != fixed_workshop_id: + wi.workshop_id = fixed_workshop_id + changed_fields.append('workshop') + else: + if workshop_id is not None: + wi.workshop_id = workshop_id + changed_fields.append('workshop') + elif 'workshop_id' in request.POST and role in ['admin', 'technologist', 'master', 'clerk', 'manager']: + wi.workshop_id = None + changed_fields.append('workshop') + + if workitem_status and role in ['admin', 'technologist']: + allowed = {k for k, _ in WorkItem.STATUS_CHOICES} + if workitem_status in allowed: + wi.status = workitem_status + changed_fields.append('status') + + if 'comment' in request.POST and role in ['admin', 'technologist', 'master']: + wi.comment = comment + changed_fields.append('comment') + + if not changed_fields and not workitem_status: + messages.error(request, 'Нет данных для обновления.') + return redirect(next_url) + + if not (role in ['admin', 'technologist'] and workitem_status in {k for k, _ in WorkItem.STATUS_CHOICES}): + plan = int(wi.quantity_plan or 0) + done = int(wi.quantity_done or 0) + if plan > 0 and done >= plan: + wi.status = 'done' + changed_fields.append('status') + elif done > 0: + wi.status = 'planned' + changed_fields.append('status') + else: + wi.status = 'planned' + changed_fields.append('status') + + wi.save(update_fields=list(dict.fromkeys(changed_fields))) + + # Комментарий: автоматический переход на следующую операцию по маршруту для пары (сделка, сущность). + # Сдвигаем только когда выполнено количество по позиции сделки. + if ordered_qty is not None: + op_code = None + if getattr(wi, 'operation_id', None): + op_code = Operation.objects.filter(pk=wi.operation_id).values_list('code', flat=True).first() + if not op_code: + op_code = (wi.stage or '').strip() + + if op_code: + progress, _ = DealEntityProgress.objects.get_or_create(deal_id=wi.deal_id, entity_id=wi.entity_id, defaults={'current_seq': 1}) + cur = int(progress.current_seq or 1) + cur_eo = EntityOperation.objects.select_related('operation').filter(entity_id=wi.entity_id, seq=cur).first() + + if cur_eo and cur_eo.operation and cur_eo.operation.code == op_code: + total_done = WorkItem.objects.filter(deal_id=wi.deal_id, entity_id=wi.entity_id).filter(Q(operation__code=op_code) | Q(stage=op_code)).aggregate(s=Coalesce(Sum('quantity_done'), 0))['s'] + if int(total_done or 0) >= int(ordered_qty): + progress.current_seq = cur + 1 + progress.save(update_fields=['current_seq']) + + messages.success(request, 'Обновлено.') + return redirect(next_url) + + +class PlanningStagesView(LoginRequiredMixin, TemplateView): + template_name = 'shiftflow/planning_stages.html' + + def dispatch(self, request, *args, **kwargs): + profile = getattr(request.user, 'profile', None) + role = profile.role if profile else ('admin' if request.user.is_superuser else 'operator') + if role not in ['admin', 'technologist', 'master', 'clerk', 'manager', 'observer']: + return redirect('registry') + return super().dispatch(request, *args, **kwargs) + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + profile = getattr(self.request.user, 'profile', None) + roles = get_user_roles(self.request.user) + role = primary_role(roles) + ctx['user_role'] = role + ctx['user_roles'] = sorted(roles) + ctx['is_readonly'] = bool(getattr(profile, 'is_readonly', False)) if profile else False + + q = (self.request.GET.get('q') or '').strip() + + deals_qs = Deal.objects.select_related('company').filter(status='work') + if q: + deals_qs = deals_qs.filter(Q(number__icontains=q) | Q(company__name__icontains=q)) + deals = list(deals_qs.order_by('due_date', '-id')) + + ctx['q'] = q + + if not deals: + ctx['deal_cards'] = [] + return ctx + + deal_ids = [d.id for d in deals] + + deal_items = list( + DealItem.objects.select_related('deal', 'entity') + .filter(deal_id__in=deal_ids, entity__entity_type__in=['product', 'assembly', 'part']) + .order_by('deal__due_date', 'deal__number', 'entity__drawing_number', 'entity__name', 'id') + ) + + entity_ids = sorted({int(x.entity_id) for x in deal_items}) + + entity_ops = list( + EntityOperation.objects.select_related('operation') + .filter(entity_id__in=entity_ids) + .order_by('entity_id', 'seq', 'id') + ) + + route_codes = {} + last_code = {} + op_meta = {} + + for eo in entity_ops: + if not eo.operation_id or not eo.operation: + continue + code = (eo.operation.code or '').strip() + if not code: + continue + + route_codes.setdefault(int(eo.entity_id), []).append(code) + + m = op_meta.get(code) + if not m: + op_meta[code] = { + 'code': code, + 'name': (eo.operation.name or code), + 'min_seq': int(eo.seq or 0) or 0, + } + else: + cur = int(m.get('min_seq') or 0) + seq = int(eo.seq or 0) or 0 + if cur == 0 or (seq > 0 and seq < cur): + m['min_seq'] = seq + + for eid, codes in route_codes.items(): + if codes: + last_code[eid] = codes[-1] + + op_columns = sorted(op_meta.values(), key=lambda x: (int(x.get('min_seq') or 0), str(x.get('name') or ''), str(x.get('code') or ''))) + ctx['op_columns'] = op_columns + + wi_qs = ( + WorkItem.objects.select_related('operation') + .filter(deal_id__in=deal_ids, entity_id__in=entity_ids) + ) + done_by = {} + done_total_by_entity = {} + for wi in wi_qs: + op_code = '' + if getattr(wi, 'operation_id', None) and getattr(wi, 'operation', None): + op_code = (wi.operation.code or '').strip() + if not op_code: + op_code = (wi.stage or '').strip() + if not op_code: + continue + did = int(wi.deal_id) + eid = int(wi.entity_id) + k = (did, eid, op_code) + done_by[k] = done_by.get(k, 0) + int(wi.quantity_done or 0) + done_total_by_entity[(did, eid)] = done_total_by_entity.get((did, eid), 0) + int(wi.quantity_done or 0) + + ship_loc = ( + Location.objects.filter(Q(name__icontains='отгруж') | Q(name__icontains='отгруз')) + .order_by('id') + .first() + ) + shipped_by = {} + if ship_loc: + for r in ( + StockItem.objects.filter( + is_archived=False, + location_id=ship_loc.id, + deal_id__in=deal_ids, + entity_id__in=entity_ids, + ) + .values('deal_id', 'entity_id') + .annotate(s=Coalesce(Sum('quantity'), 0.0)) + ): + shipped_by[(int(r['deal_id']), int(r['entity_id']))] = int(r['s'] or 0) + + def pct(val, total): + if int(total or 0) <= 0: + return 0 + return max(0, min(100, int(round((int(val or 0) * 100) / int(total))))) + + items_by_deal = {} + for di in deal_items: + did = int(di.deal_id) + eid = int(di.entity_id) + need = int(di.quantity or 0) + + codes = set(route_codes.get(eid) or []) + last = last_code.get(eid) + + op_cells = [] + for col in op_columns: + code = col.get('code') + if code and code in codes: + done = int(done_by.get((did, eid, str(code)), 0) or 0) + done_val = min(need, done) + op_cells.append({ + 'code': str(code), + 'done': done_val, + 'pct': pct(done_val, need), + 'has': True, + }) + else: + op_cells.append({'code': str(code or ''), 'done': 0, 'pct': 0, 'has': False}) + + ready = int(done_by.get((did, eid, last), 0)) if last else int(done_total_by_entity.get((did, eid), 0)) + shipped = int(shipped_by.get((did, eid), 0)) + + ready_val = min(need, int(ready)) + shipped_val = min(need, int(shipped)) + + items_by_deal.setdefault(did, []).append({ + 'entity': di.entity, + 'need': need, + 'op_cells': op_cells, + 'ready': ready_val, + 'ready_pct': pct(ready_val, need), + 'shipped': shipped_val, + 'shipped_pct': pct(shipped_val, need), + }) + + deal_cards = [] + for d in deals: + rows = items_by_deal.get(int(d.id)) or [] + if not rows: + continue + + deal_cards.append({ + 'deal': d, + 'rows': rows, + }) + + ctx['deal_cards'] = deal_cards + return ctx + + class RegistryPrintView(LoginRequiredMixin, TemplateView): template_name = 'shiftflow/registry_print.html' + def dispatch(self, request, *args, **kwargs): + roles = get_user_group_roles(request.user) + if not has_any_role(roles, ['admin', 'operator', 'master', 'technologist', 'clerk', 'prod_head', 'director', 'observer']): + return redirect('index') + return super().dispatch(request, *args, **kwargs) + + +class WorkItemRegistryPrintView(LoginRequiredMixin, TemplateView): + template_name = 'shiftflow/registry_workitems_print.html' + + def dispatch(self, request, *args, **kwargs): + roles = get_user_group_roles(request.user) + if not has_any_role(roles, ['admin', 'operator', 'master', 'technologist', 'clerk', 'prod_head', 'director', 'observer']): + return redirect('index') + return super().dispatch(request, *args, **kwargs) + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + user = self.request.user + profile = getattr(user, 'profile', None) + roles = get_user_group_roles(user) + role = primary_role(roles) + ctx['user_role'] = role + ctx['user_roles'] = sorted(roles) + + qs = WorkItem.objects.select_related('deal', 'entity', 'entity__planned_material', 'operation', 'machine', 'workshop') + + m_ids = [int(i) for i in self.request.GET.getlist('m_ids') if str(i).isdigit()] + if m_ids: + qs = qs.filter(machine_id__in=m_ids) + + start_date = (self.request.GET.get('start_date') or '').strip() + end_date = (self.request.GET.get('end_date') or '').strip() + if start_date: + qs = qs.filter(date__gte=start_date) + if end_date: + qs = qs.filter(date__lte=end_date) + + statuses = self.request.GET.getlist('statuses') + filtered = (self.request.GET.get('filtered') or '').strip() + if filtered and not statuses: + qs = qs.none() + elif not statuses: + qs = qs.filter(status__in=['planned']) + else: + expanded = [] + for s in statuses: + if s == 'work': + expanded += ['planned'] + elif s == 'leftover': + expanded.append('leftover') + elif s == 'closed': + expanded.append('done') + if expanded: + qs = qs.filter(status__in=expanded) + + if role == 'operator': + user_machines = profile.machines.all() if profile else Machine.objects.none() + user_machine_ids = list(user_machines.values_list('id', flat=True)) + user_ws_ids = list( + Machine.objects.filter(id__in=user_machine_ids) + .exclude(workshop_id__isnull=True) + .values_list('workshop_id', flat=True) + ) + allowed_ws = list(profile.allowed_workshops.values_list('id', flat=True)) if profile else [] + ws_ids = list({int(x) for x in (user_ws_ids + allowed_ws) if x}) + qs = qs.filter(Q(machine_id__in=user_machine_ids) | Q(machine_id__isnull=True, workshop_id__in=ws_ids)) + + rows = list(qs.order_by('workshop__name', 'machine__name', 'date', 'deal__number', 'id')) + + groups = {} + for wi in rows: + ws_label = wi.workshop.name if wi.workshop else '—' + m_label = wi.machine.name if wi.machine else '' + key = (ws_label, m_label) + g = groups.get(key) + if not g: + g = {'workshop': ws_label, 'machine': m_label, 'items': []} + groups[key] = g + g['items'].append(wi) + + ctx['groups'] = list(groups.values()) + return ctx + + +class WorkItemEntityListView(LoginRequiredMixin, TemplateView): + template_name = 'shiftflow/workitem_entity_list.html' + + def dispatch(self, request, *args, **kwargs): + roles = get_user_roles(request.user) + if not has_any_role(roles, ['admin', 'technologist', 'master', 'clerk', 'operator', 'observer', 'prod_head', 'director']): + return redirect('registry') + return super().dispatch(request, *args, **kwargs) + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + profile = getattr(self.request.user, 'profile', None) + roles = get_user_roles(self.request.user) + role = primary_role(roles) + ctx['user_role'] = role + ctx['user_roles'] = sorted(roles) + ctx['can_edit_entity'] = 'admin' in roles or 'technologist' in roles + + deal_id = int(self.kwargs['deal_id']) + entity_id = int(self.kwargs['entity_id']) + + deal = get_object_or_404(Deal, pk=deal_id) + entity = get_object_or_404(ProductEntity.objects.select_related('planned_material'), pk=entity_id) + + qs = WorkItem.objects.select_related('deal', 'entity', 'entity__planned_material', 'operation', 'machine', 'workshop').filter( + deal_id=deal_id, + entity_id=entity_id, + ) + rows = list(qs.order_by('-date', '-id')) + + for wi in rows: + plan = int(wi.quantity_plan or 0) + done = int(wi.quantity_done or 0) + wi.fact_pct = int(round(done * 100 / plan)) if plan > 0 else 0 + wi.fact_width = max(0, min(100, wi.fact_pct)) + + ctx['deal'] = deal + ctx['entity'] = entity + ctx['workitems'] = rows + + next_url = (self.request.GET.get('next') or '').strip() + ctx['back_url'] = next_url if next_url.startswith('/') else str(reverse_lazy('registry')) + return ctx + + +from shiftflow.services.assembly_closing import get_first_operation_id + + +class WorkItemOpClosingView(LoginRequiredMixin, TemplateView): + template_name = 'shiftflow/workitem_op_closing.html' + + def dispatch(self, request, *args, **kwargs): + roles = get_user_group_roles(request.user) + if not has_any_role(roles, ['admin', 'master', 'operator', 'prod_head']): + return redirect('registry') + + profile = getattr(request.user, 'profile', None) + if bool(getattr(profile, 'is_readonly', False)) if profile else False: + messages.error(request, 'Доступ только для просмотра.') + return redirect('registry') + + wi = get_object_or_404( + WorkItem.objects.select_related('deal', 'entity', 'entity__planned_material', 'operation', 'machine', 'workshop'), + pk=int(self.kwargs['pk']), + ) + + first_op_id = get_first_operation_id(int(wi.entity_id)) + is_first = True + if first_op_id and getattr(wi, 'operation_id', None): + is_first = int(wi.operation_id) == int(first_op_id) + + if is_first: + if wi.entity and wi.entity.entity_type in ['product', 'assembly']: + return redirect('assembly_closing', pk=wi.id) + if wi.entity and wi.entity.entity_type == 'part': + if wi.machine_id and getattr(wi.entity, 'planned_material_id', None): + return redirect(f"{reverse_lazy('closing')}?machine_id={int(wi.machine_id)}&material_id={int(wi.entity.planned_material_id)}") + + self.workitem = wi + self.is_first_operation = is_first + return super().dispatch(request, *args, **kwargs) + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + roles = get_user_group_roles(self.request.user) + ctx['user_roles'] = sorted(roles) + ctx['user_role'] = primary_role(roles) + + wi = self.workitem + ctx['workitem'] = wi + ctx['remaining'] = max(0, int(wi.quantity_plan or 0) - int(wi.quantity_done or 0)) + ctx['is_first_operation'] = bool(self.is_first_operation) + return ctx + + def post(self, request, *args, **kwargs): + wi = self.workitem + + qty_raw = (request.POST.get('fact_qty') or '').strip() + try: + qty = int(qty_raw) + except ValueError: + qty = 0 + + if qty <= 0: + messages.error(request, 'Количество должно быть больше 0.') + return redirect('workitem_op_closing', pk=wi.id) + + with transaction.atomic(): + wi = WorkItem.objects.select_for_update(of=('self',)).select_related('machine', 'machine__workshop', 'workshop').get(pk=int(wi.id)) + + work_location = get_work_location_for_workitem(wi) + if not work_location: + messages.error(request, 'Для задания не определён склад участка (цех -> склад цеха / пост -> склад).') + return redirect('workitem_op_closing', pk=wi.id) + + available = ( + StockItem.objects.select_for_update(of=('self',)) + .filter(is_archived=False, quantity__gt=0) + .filter(location_id=work_location.id, entity_id=int(wi.entity_id)) + .filter(Q(deal_id=wi.deal_id) | Q(deal_id__isnull=True)) + .aggregate(s=Coalesce(Sum('quantity'), 0.0)) + )['s'] + available_i = int(available or 0) + if qty > available_i: + messages.error(request, f'Нельзя закрыть операцию: на складе участка «{work_location.name}» доступно {available_i} шт. Сначала перемести изделие на участок.') + return redirect('workitem_op_closing', pk=wi.id) + + plan_total = int(wi.quantity_plan or 0) + done_total = int(wi.quantity_done or 0) + remaining = max(0, plan_total - done_total) + if qty > remaining: + messages.error(request, f'Нельзя закрыть {qty} шт: доступно {remaining} шт.') + return redirect('workitem_op_closing', pk=wi.id) + + wi.quantity_done = done_total + qty + if wi.quantity_done >= plan_total and plan_total > 0: + wi.status = 'done' + elif wi.quantity_done > 0: + wi.status = 'planned' + else: + wi.status = 'planned' + wi.save(update_fields=['quantity_done', 'status']) + + deal_item = DealItem.objects.filter(deal_id=wi.deal_id, entity_id=wi.entity_id).first() + ordered_qty = int(deal_item.quantity) if deal_item else None + if ordered_qty is not None: + op_code = None + if getattr(wi, 'operation_id', None): + op_code = Operation.objects.filter(pk=wi.operation_id).values_list('code', flat=True).first() + if not op_code: + op_code = (wi.stage or '').strip() + + if op_code: + progress, _ = DealEntityProgress.objects.get_or_create(deal_id=wi.deal_id, entity_id=wi.entity_id, defaults={'current_seq': 1}) + cur = int(progress.current_seq or 1) + cur_eo = EntityOperation.objects.select_related('operation').filter(entity_id=wi.entity_id, seq=cur).first() + if cur_eo and cur_eo.operation and cur_eo.operation.code == op_code: + total_done = WorkItem.objects.filter(deal_id=wi.deal_id, entity_id=wi.entity_id).filter(Q(operation__code=op_code) | Q(stage=op_code)).aggregate(s=Coalesce(Sum('quantity_done'), 0))['s'] + if int(total_done or 0) >= int(ordered_qty): + progress.current_seq = cur + 1 + progress.save(update_fields=['current_seq']) + + messages.success(request, f'Закрыто: {qty} шт.') + return redirect('workitem_detail', pk=wi.id) + + +class WorkItemDetailView(LoginRequiredMixin, TemplateView): + template_name = 'shiftflow/workitem_detail.html' + + def dispatch(self, request, *args, **kwargs): + roles = get_user_roles(request.user) + if not has_any_role(roles, ['admin', 'technologist', 'master', 'clerk', 'operator', 'observer', 'prod_head', 'director']): + return redirect('registry') + return super().dispatch(request, *args, **kwargs) + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + profile = getattr(self.request.user, 'profile', None) + role = profile.role if profile else ('admin' if self.request.user.is_superuser else 'operator') + ctx['user_role'] = role + ctx['can_edit_entity'] = role in ['admin', 'technologist'] + + wi = get_object_or_404( + WorkItem.objects.select_related( + 'deal', + 'entity', + 'entity__planned_material', + 'operation', + 'machine', + 'workshop', + ), + pk=int(self.kwargs['pk']), + ) + ctx['workitem'] = wi + ctx['remaining'] = max(0, (wi.quantity_plan or 0) - (wi.quantity_done or 0)) + + first_op_id = get_first_operation_id(int(wi.entity_id)) + is_first = True + if first_op_id and getattr(wi, 'operation_id', None): + is_first = int(wi.operation_id) == int(first_op_id) + ctx['is_first_operation'] = is_first + + close_url = '' + close_label = 'Закрыть' + if wi.entity and wi.entity.entity_type in ['product', 'assembly']: + if is_first: + close_url = str(reverse_lazy('assembly_closing', kwargs={'pk': wi.id})) + close_label = 'Закрыть сборку' + else: + close_url = str(reverse_lazy('workitem_op_closing', kwargs={'pk': wi.id})) + elif wi.entity and wi.entity.entity_type == 'part': + if is_first and wi.machine_id and getattr(wi.entity, 'planned_material_id', None): + close_url = f"{reverse_lazy('closing')}?machine_id={int(wi.machine_id)}&material_id={int(wi.entity.planned_material_id)}" + else: + close_url = str(reverse_lazy('workitem_op_closing', kwargs={'pk': wi.id})) + else: + close_url = str(reverse_lazy('workitem_op_closing', kwargs={'pk': wi.id})) + + ctx['close_url'] = close_url + ctx['close_label'] = close_label + ctx['machines'] = list(Machine.objects.all().order_by('name')) + ctx['workitem_status_choices'] = list(WorkItem.STATUS_CHOICES) + + entity = wi.entity + passport = None + seams = [] + + if entity.entity_type in ['product', 'assembly']: + passport, _ = AssemblyPassport.objects.get_or_create(entity_id=entity.id) + seams = list(WeldingSeam.objects.filter(passport_id=passport.id).order_by('id')) + elif entity.entity_type == 'part': + passport, _ = PartPassport.objects.get_or_create(entity_id=entity.id) + elif entity.entity_type == 'purchased': + passport, _ = PurchasedPassport.objects.get_or_create(entity_id=entity.id) + elif entity.entity_type == 'casting': + passport, _ = CastingPassport.objects.get_or_create(entity_id=entity.id) + elif entity.entity_type == 'outsourced': + passport, _ = OutsourcedPassport.objects.get_or_create(entity_id=entity.id) + + ctx['passport'] = passport + ctx['welding_seams'] = seams + + next_url = (self.request.GET.get('next') or '').strip() + ctx['back_url'] = next_url if next_url.startswith('/') else str(reverse_lazy('registry')) + return ctx + + +class WorkItemKittingView(LoginRequiredMixin, TemplateView): + template_name = 'shiftflow/workitem_kitting.html' + + def dispatch(self, request, *args, **kwargs): + roles = get_user_roles(request.user) + if not has_any_role(roles, ['admin', 'technologist', 'master', 'clerk', 'prod_head']): + return redirect('registry') + + pk = self.kwargs.get('pk') + wi = None + if pk: + wi = WorkItem.objects.select_related('entity').filter(pk=int(pk)).first() + if wi and (wi.entity.entity_type not in ['product', 'assembly']): + messages.error(request, 'Комплектация доступна только для сборочных единиц и изделий.') + return redirect('workitem_detail', pk=wi.id) + + return super().dispatch(request, *args, **kwargs) + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + profile = getattr(self.request.user, 'profile', None) + role = profile.role if profile else ('admin' if self.request.user.is_superuser else 'operator') + ctx['user_role'] = role + + wi = get_object_or_404( + WorkItem.objects.select_related('deal', 'entity', 'machine', 'machine__workshop', 'workshop'), + pk=int(self.kwargs['pk']), + ) + ctx['workitem'] = wi + + to_location = get_work_location_for_workitem(wi) + if not to_location: + ctx['to_location'] = None + ctx['rows'] = [] + ctx['draft'] = [] + ctx['draft_groups'] = [] + ctx['qty_to_make'] = 0 + ctx['back_url'] = str(reverse_lazy('workitem_detail', kwargs={'pk': wi.id})) + messages.error(self.request, 'Для задания не определён склад участка (нет цеха/склада у поста).') + return ctx + + ctx['to_location'] = to_location + + qty_to_make = max(0, int(wi.quantity_plan or 0) - int(wi.quantity_done or 0)) + qty_param = (self.request.GET.get('qty') or '').strip() + if qty_param.isdigit(): + qty_to_make = max(0, int(qty_param)) + ctx['qty_to_make'] = qty_to_make + + req = build_kitting_requirements(int(wi.entity_id), int(qty_to_make)) + component_ids = list(req.keys()) + + draft = get_kitting_draft(self.request.session, int(wi.id)) + ctx['draft'] = draft + + to_move_by_entity = {} + to_move_by_source = {} + for ln in draft: + eid = int(ln.get('entity_id') or 0) + lid = int(ln.get('from_location_id') or 0) + qty_ln = int(ln.get('quantity') or 0) + if eid <= 0 or lid <= 0 or qty_ln <= 0: + continue + to_move_by_entity[eid] = int(to_move_by_entity.get(eid, 0) or 0) + qty_ln + to_move_by_source[(eid, lid)] = int(to_move_by_source.get((eid, lid), 0) or 0) + qty_ln + + entities = { + int(e.id): e + for e in ProductEntity.objects.filter(id__in=component_ids).order_by('entity_type', 'drawing_number', 'name', 'id') + } + + avail_qs = ( + StockItem.objects.select_related('location') + .filter(is_archived=False) + .filter(quantity__gt=0) + .filter(entity_id__in=component_ids) + .filter(Q(deal_id=wi.deal_id) | Q(deal_id__isnull=True)) + .values('entity_id', 'location_id') + .annotate(q=Coalesce(Sum('quantity'), 0.0)) + ) + + loc_ids = set() + by_entity_loc = {} + for r in avail_qs: + eid = int(r['entity_id']) + lid = int(r['location_id']) + q = float(r['q'] or 0) + by_entity_loc[(eid, lid)] = q + loc_ids.add(lid) + + locations = {int(l.id): l for l in Location.objects.filter(id__in=list(loc_ids)).order_by('name')} + + rows = [] + for eid, need in req.items(): + ent = entities.get(int(eid)) + if not ent: + continue + + need_i = int(need or 0) + to_have = int(by_entity_loc.get((int(eid), int(to_location.id)), 0) or 0) + to_move = int(to_move_by_entity.get(int(eid), 0) or 0) + missing = max(0, need_i - to_have - to_move) + + sources = [] + for lid in sorted(loc_ids, key=lambda x: (0 if x == int(to_location.id) else 1, str(getattr(locations.get(x), 'name', '')))): + if lid == int(to_location.id): + continue + q = float(by_entity_loc.get((int(eid), int(lid)), 0) or 0) + if q <= 0: + continue + loc = locations.get(int(lid)) + if not loc: + continue + sources.append({'location': loc, 'available': int(q), 'selected': int(to_move_by_source.get((int(eid), int(lid)), 0) or 0)}) + + rows.append({ + 'entity': ent, + 'need': need_i, + 'have_to': to_have, + 'to_move': to_move, + 'missing': missing, + 'sources': sources, + }) + + ctx['rows'] = rows + + next_url = (self.request.GET.get('next') or '').strip() + ctx['back_url'] = next_url if next_url.startswith('/') else str(reverse_lazy('workitem_detail', kwargs={'pk': wi.id})) + return ctx + + def post(self, request, *args, **kwargs): + roles = get_user_roles(request.user) + if not has_any_role(roles, ['admin', 'technologist', 'master', 'clerk', 'prod_head']): + return redirect('registry') + + wi = get_object_or_404( + WorkItem.objects.select_related('deal', 'entity', 'machine', 'machine__workshop', 'workshop'), + pk=int(self.kwargs['pk']), + ) + to_location = get_work_location_for_workitem(wi) + if not to_location: + messages.error(request, 'Для задания не определён склад участка.') + return redirect('workitem_detail', pk=wi.id) + + action = (request.POST.get('action') or '').strip() + next_url = (request.POST.get('next') or '').strip() + if not next_url.startswith('/'): + next_url = str(reverse_lazy('workitem_kitting', kwargs={'pk': wi.id})) + + def parse_int(s): + s = (s or '').strip() + return int(s) if s.isdigit() else None + + if action == 'clear': + clear_kitting_draft(request.session, int(wi.id)) + messages.success(request, 'Лист комплектации очищен.') + return redirect(next_url) + + if action in ['add_line', 'remove_line']: + entity_id = parse_int(request.POST.get('entity_id')) + from_location_id = parse_int(request.POST.get('from_location_id')) + qty = parse_int(request.POST.get('quantity')) + + if not (entity_id and from_location_id and qty and qty > 0): + messages.error(request, 'Заполни корректно: компонент, склад-источник и количество.') + return redirect(next_url) + + if int(from_location_id) == int(to_location.id): + messages.error(request, 'Склад-источник должен отличаться от склада участка.') + return redirect(next_url) + + if action == 'add_line': + add_kitting_line(request.session, int(wi.id), int(entity_id), int(from_location_id), int(qty)) + messages.success(request, 'Добавлено в перемещение.') + else: + remove_kitting_line(request.session, int(wi.id), int(entity_id), int(from_location_id), int(qty)) + messages.success(request, 'Откат выполнен.') + + return redirect(next_url) + + messages.error(request, 'Неизвестное действие.') + return redirect(next_url) + + +class WorkItemKittingPrintView(LoginRequiredMixin, TemplateView): + template_name = 'shiftflow/workitem_kitting_print.html' + + def dispatch(self, request, *args, **kwargs): + roles = get_user_roles(request.user) + if not has_any_role(roles, ['admin', 'technologist', 'master', 'clerk', 'prod_head']): + return redirect('registry') + + pk = self.kwargs.get('pk') + wi = None + if pk: + wi = WorkItem.objects.select_related('entity').filter(pk=int(pk)).first() + if wi and (wi.entity.entity_type not in ['product', 'assembly']): + messages.error(request, 'Комплектация доступна только для сборочных единиц и изделий.') + return redirect('workitem_detail', pk=wi.id) + + return super().dispatch(request, *args, **kwargs) + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + wi = get_object_or_404( + WorkItem.objects.select_related('deal', 'entity', 'machine', 'machine__workshop', 'workshop'), + pk=int(self.kwargs['pk']), + ) + ctx['workitem'] = wi + ctx['printed_at'] = timezone.now() + + to_location = get_work_location_for_workitem(wi) + ctx['to_location'] = to_location + + draft = get_kitting_draft(self.request.session, int(wi.id)) + ctx['draft'] = draft + + if not draft: + ctx['groups'] = [] + return ctx + + entity_ids = sorted({int(x.get('entity_id') or 0) for x in draft if int(x.get('entity_id') or 0) > 0}) + loc_ids = sorted({int(x.get('from_location_id') or 0) for x in draft if int(x.get('from_location_id') or 0) > 0}) + + entities = {int(e.id): e for e in ProductEntity.objects.filter(id__in=entity_ids)} + locations = {int(l.id): l for l in Location.objects.filter(id__in=loc_ids)} + + grouped = {} + for ln in draft: + lid = int(ln.get('from_location_id') or 0) + eid = int(ln.get('entity_id') or 0) + qty = int(ln.get('quantity') or 0) + if lid <= 0 or eid <= 0 or qty <= 0: + continue + grouped.setdefault(lid, {}) + grouped[lid][eid] = int(grouped[lid].get(eid, 0)) + qty + + groups = [] + for lid, items in grouped.items(): + loc = locations.get(int(lid)) + if not loc: + continue + lst = [] + for eid, qty in items.items(): + ent = entities.get(int(eid)) + if not ent: + continue + lst.append({'entity': ent, 'quantity': int(qty)}) + lst.sort(key=lambda x: ((x['entity'].entity_type or ''), (x['entity'].drawing_number or ''), (x['entity'].name or ''), int(x['entity'].id))) + groups.append({'from_location': loc, 'items': lst}) + + groups.sort(key=lambda g: (str(g['from_location'].name or ''), int(g['from_location'].id))) + ctx['groups'] = groups + return ctx + + def get(self, request, *args, **kwargs): + # GET — только предпросмотр листа перемещения (без выполнения перемещений) + ctx = self.get_context_data(**kwargs) + ctx['auto_print'] = False + return self.render_to_response(ctx) + + def post(self, request, *args, **kwargs): + # POST — выполняем перемещения и затем рендерим лист (с автопечатью при необходимости) + wi = get_object_or_404( + WorkItem.objects.select_related('deal', 'entity', 'machine', 'machine__workshop', 'workshop'), + pk=int(self.kwargs['pk']), + ) + to_location = get_work_location_for_workitem(wi) + if not to_location: + messages.error(request, 'Для задания не определён склад участка.') + return redirect('workitem_detail', pk=wi.id) + + action = (request.POST.get('action') or '').strip() + if action not in ['apply', 'apply_print']: + messages.error(request, 'Неизвестное действие.') + return redirect('workitem_kitting_print', pk=wi.id) + + ctx = self.get_context_data(**kwargs) + if not ctx.get('groups'): + messages.error(request, 'Лист перемещения пуст.') + return redirect('workitem_kitting', pk=wi.id) + + stats = apply_kitting_draft( + session=request.session, + workitem_id=int(wi.id), + deal_id=int(wi.deal_id), + to_location_id=int(to_location.id), + user_id=int(request.user.id), + ) + if int(stats.get('errors', 0) or 0) > 0: + messages.error(request, f"Ошибка перемещения. Ошибок: {stats.get('errors', 0)}") + return redirect('workitem_kitting', pk=wi.id) + + ctx['auto_print'] = (action == 'apply_print') + return self.render_to_response(ctx) + + +class ProductEntityPreviewUpdateView(LoginRequiredMixin, View): + def post(self, request, pk, *args, **kwargs): + profile = getattr(request.user, 'profile', None) + role = profile.role if profile else ('admin' if request.user.is_superuser else 'operator') + if role not in ['admin', 'technologist']: + return redirect('registry') + + next_url = (request.POST.get('next') or '').strip() + next_url = next_url if next_url.startswith('/') else str(reverse_lazy('registry')) + + entity = get_object_or_404(ProductEntity, pk=int(pk)) + try: + ok = _update_entity_preview(entity) + if ok: + messages.success(request, 'Превью обновлено.') + else: + messages.error(request, 'Превью не создано (нет DXF).') + except Exception as e: + logger.exception('entity_preview_update: failed entity_id=%s', entity.id) + messages.error(request, f'Ошибка генерации превью: {type(e).__name__}: {e}') + + return redirect(next_url) + def dispatch(self, request, *args, **kwargs): profile = getattr(request.user, 'profile', None) role = profile.role if profile else 'operator' @@ -443,7 +1868,7 @@ class RegistryPrintView(LoginRequiredMixin, TemplateView): print_date = datetime.strptime(print_date_raw, '%Y-%m-%d').date() except ValueError: print_date = None - context['print_date'] = print_date + context['print_date'] = print_date or timezone.localdate() if start_date and end_date and start_date == end_date: context['date_label'] = start_date @@ -465,7 +1890,7 @@ class PlanningView(LoginRequiredMixin, TemplateView): def dispatch(self, request, *args, **kwargs): profile = getattr(request.user, 'profile', None) role = profile.role if profile else ('admin' if request.user.is_superuser else 'operator') - if role not in ['admin', 'technologist', 'master', 'clerk']: + if role not in ['admin', 'technologist', 'master', 'clerk', 'prod_head', 'director']: return redirect('registry') return super().dispatch(request, *args, **kwargs) @@ -475,6 +1900,14 @@ class PlanningView(LoginRequiredMixin, TemplateView): role = profile.role if profile else ('admin' if self.request.user.is_superuser else 'operator') context['user_role'] = role + allowed_ws = list(profile.allowed_workshops.values_list('id', flat=True)) if profile else [] + context['allowed_workshop_ids'] = allowed_ws + context['is_readonly'] = bool(getattr(profile, 'is_readonly', False)) if profile else False + + allowed_ws = list(profile.allowed_workshops.values_list('id', flat=True)) if profile else [] + context['allowed_workshop_ids'] = allowed_ws + context['is_readonly'] = bool(getattr(profile, 'is_readonly', False)) if profile else False + status = (self.request.GET.get('status') or 'work').strip() allowed = {k for k, _ in Deal.STATUS_CHOICES} if status not in allowed: @@ -502,35 +1935,166 @@ class DealPlanningView(LoginRequiredMixin, TemplateView): role = profile.role if profile else ('admin' if self.request.user.is_superuser else 'operator') context['user_role'] = role + allowed_ws = list(profile.allowed_workshops.values_list('id', flat=True)) if profile else [] + context['allowed_workshop_ids'] = allowed_ws + context['is_readonly'] = bool(getattr(profile, 'is_readonly', False)) if profile else False + deal = get_object_or_404(Deal.objects.select_related('company'), pk=self.kwargs['pk']) context['deal'] = deal - tasks_qs = ProductionTask.objects.filter(deal=deal).select_related('material').annotate( - done_qty=Coalesce(Sum('items__quantity_fact'), 0), - planned_qty=Coalesce( - Sum( - Case( - When(items__status__in=['work', 'leftover'], then=F('items__quantity_plan')), - default=Value(0), - output_field=IntegerField(), - ) - ), - 0, - ), - ).annotate( - remaining_qty=ExpressionWrapper( - F('quantity_ordered') - F('done_qty') - F('planned_qty'), - output_field=IntegerField(), - ) - ).order_by('-id') + di = list( + DealItem.objects.select_related('entity', 'entity__assembly_passport') + .filter(deal=deal) + .order_by('entity__entity_type', 'entity__drawing_number', 'entity__name') + ) + + _reconcile_default_delivery_batch(int(deal.id)) + + allocated_non_default = { + int(r['entity_id']): int(r['s'] or 0) + for r in DealBatchItem.objects.filter(batch__deal=deal, batch__is_default=False, entity_id__in=[x.entity_id for x in di]) + .values('entity_id') + .annotate(s=Coalesce(Sum('quantity'), 0)) + } + + started_map = { + int(r['entity_id']): int(r['started'] or 0) + for r in DealBatchItem.objects.filter(batch__deal=deal, entity_id__in=[x.entity_id for x in di]) + .values('entity_id') + .annotate(started=Coalesce(Sum('started_qty'), 0)) + } + + for it in di: + need = int(it.quantity or 0) + started = int(started_map.get(int(it.entity_id), 0) or 0) + if started > need: + started = need + + allocated = int(allocated_non_default.get(int(it.entity_id), 0) or 0) + if allocated > need: + allocated = need + it.remaining_to_allocate = max(0, need - allocated) + + it.done_qty = 0 + it.planned_qty = started + it.remaining_qty = max(0, need - started) + + if need > 0: + done_width = 0 + plan_pct = int(round(started * 100 / need)) + else: + done_width = 0 + plan_pct = 0 + + it.done_width = done_width + it.plan_width = max(0, min(100, plan_pct)) + + batches = list(DealDeliveryBatch.objects.filter(deal=deal).order_by('is_default', 'due_date', 'id')) + batch_items = list( + DealBatchItem.objects.select_related('batch', 'entity') + .filter(batch__deal=deal) + .order_by('batch__due_date', 'batch_id', 'entity__entity_type', 'entity__drawing_number', 'entity__name', 'id') + ) + by_batch = {} + for bi in batch_items: + started = int(getattr(bi, 'started_qty', 0) or 0) + qty = int(getattr(bi, 'quantity', 0) or 0) + if started < 0: + started = 0 + if qty < 0: + qty = 0 + if started > qty: + started = qty + + bi.started_qty = started + bi.remaining_to_start = max(0, qty - started) + bi.started_pct = int(round(started * 100 / qty)) if qty > 0 else 0 + + by_batch.setdefault(int(bi.batch_id), []).append(bi) + + for b in batches: + items = by_batch.get(int(b.id), []) + b.items_list = items + b.total_qty = sum(int(getattr(x, 'quantity', 0) or 0) for x in items) + b.total_started = sum(int(getattr(x, 'started_qty', 0) or 0) for x in items) + b.total_remaining = max(0, int(b.total_qty or 0) - int(b.total_started or 0)) + b.started_pct = int(round(b.total_started * 100 / b.total_qty)) if b.total_qty > 0 else 0 + + context['delivery_batches'] = batches + context['deal_items'] = di + + + tasks = list( + ProductionTask.objects.filter(deal=deal) + .select_related('material', 'entity') + .order_by('-id') + ) + + task_entity_ids = {int(x.entity_id) for x in tasks if getattr(x, 'entity_id', None)} + progress_task_map = { + int(p.entity_id): int(p.current_seq or 1) + for p in DealEntityProgress.objects.filter(deal=deal, entity_id__in=list(task_entity_ids)) + } + ops_task_map = {} + for eo in ( + EntityOperation.objects.select_related('operation', 'operation__workshop') + .filter(entity_id__in=list(task_entity_ids)) + .order_by('entity_id', 'seq', 'id') + ): + ops_task_map[(int(eo.entity_id), int(eo.seq))] = eo - tasks = list(tasks_qs) - # Рассчитываем показатели прогресса для визуализации: - # done_pct/plan_pct — проценты от "Надо"; done_width/plan_width — ширины сегментов бары, ограниченные 0..100 for t in tasks: + t.current_operation_id = None + t.current_operation_name = '' + t.current_workshop_id = None + t.current_workshop_name = '' + + if not getattr(t, 'entity_id', None): + continue + + seq = int(progress_task_map.get(int(t.entity_id), 1) or 1) + eo = ops_task_map.get((int(t.entity_id), seq)) + if not eo: + continue + + t.current_operation_id = int(eo.operation_id) + t.current_operation_name = eo.operation.name if eo.operation else '' + t.current_workshop_id = int(eo.operation.workshop_id) if eo.operation and eo.operation.workshop_id else None + t.current_workshop_name = eo.operation.workshop.name if eo.operation and eo.operation.workshop else '' + + wi_qs = WorkItem.objects.filter(deal=deal, entity_id__in=list(task_entity_ids)).filter(operation_id__isnull=False) + if allowed_ws: + wi_qs = wi_qs.filter(workshop_id__in=allowed_ws) + + wi_sums = { + (int(r['entity_id']), int(r['operation_id'])): (int(r['planned'] or 0), int(r['done'] or 0)) + for r in wi_qs.values('entity_id', 'operation_id').annotate( + planned=Coalesce(Sum('quantity_plan'), 0), + done=Coalesce(Sum('quantity_done'), 0), + ) + } + + workshop_groups = {} + for t in tasks: + if allowed_ws and t.current_workshop_id and int(t.current_workshop_id) not in allowed_ws: + continue + need = int(t.quantity_ordered or 0) - done_qty = int(t.done_qty or 0) - planned_qty = int(t.planned_qty or 0) + key = None + if getattr(t, 'entity_id', None) and getattr(t, 'current_operation_id', None): + key = (int(t.entity_id), int(t.current_operation_id)) + + planned_qty, done_qty = wi_sums.get(key, (0, 0)) if key else (0, 0) + planned_qty = int(planned_qty or 0) + done_qty = int(done_qty or 0) + + remaining_qty = need - done_qty - planned_qty + if remaining_qty < 0: + remaining_qty = 0 + + t.planned_qty = planned_qty + t.done_qty = done_qty + t.remaining_qty = remaining_qty if need > 0: done_pct = int(round(done_qty * 100 / need)) @@ -547,10 +2111,49 @@ class DealPlanningView(LoginRequiredMixin, TemplateView): t.done_width = done_width t.plan_width = plan_width - context['tasks'] = tasks + ws_id = int(t.current_workshop_id) if t.current_workshop_id else 0 + ws_name = (t.current_workshop_name or '').strip() or 'Без техпроцесса' + grp = workshop_groups.get(ws_id) + if not grp: + grp = {'id': ws_id, 'name': ws_name, 'tasks': []} + workshop_groups[ws_id] = grp + grp['tasks'].append(t) + + context['workshop_task_groups'] = sorted( + workshop_groups.values(), + key=lambda g: (1 if int(g['id'] or 0) == 0 else 0, str(g['name'])), + ) context['machines'] = Machine.objects.all() return context + def post(self, request, *args, **kwargs): + roles = get_user_group_roles(request.user) + if not has_any_role(roles, ['admin', 'prod_head', 'technologist', 'master']): + return redirect('planning_deal', pk=self.kwargs['pk']) + + action = (request.POST.get('action') or '').strip() + deal_id = int(self.kwargs['pk']) + + if action == 'set_work': + Deal.objects.filter(id=deal_id, status='lead').update(status='work') + messages.success(request, 'Сделка переведена в статус «В работе».') + return redirect('planning_deal', pk=deal_id) + + if action == 'explode_deal': + deal = get_object_or_404(Deal, pk=deal_id) + try: + stats = explode_deal(deal_id, create_tasks=False, create_procurement=True) + messages.success( + request, + f'BOM пересчитан для снабжения (Сделка {deal.number}). ' + f'Потребностей создано/обновлено: ({stats.req_created}/{stats.req_updated}).' + ) + except Exception as e: + logger.exception('explode_deal:error deal_id=%s', deal_id) + messages.error(request, f'Ошибка вскрытия BOM: {e}') + + return redirect('planning_deal', pk=deal_id) + class TaskItemsView(LoginRequiredMixin, TemplateView): template_name = 'shiftflow/task_items.html' @@ -955,6 +2558,10 @@ class CustomerDealsView(LoginRequiredMixin, TemplateView): role = profile.role if profile else ('admin' if self.request.user.is_superuser else 'operator') context['user_role'] = role + allowed_ws = list(profile.allowed_workshops.values_list('id', flat=True)) if profile else [] + context['allowed_workshop_ids'] = allowed_ws + context['is_readonly'] = bool(getattr(profile, 'is_readonly', False)) if profile else False + company = get_object_or_404(Company, pk=self.kwargs['pk']) context['company'] = company @@ -1003,6 +2610,136 @@ class PlanningAddView(LoginRequiredMixin, View): return redirect('planning') +class WorkItemPlanAddView(LoginRequiredMixin, View): + def post(self, request, *args, **kwargs): + profile = getattr(request.user, 'profile', None) + role = profile.role if profile else ('admin' if request.user.is_superuser else 'operator') + if role not in ['admin', 'technologist', 'master', 'clerk']: + return redirect('planning') + + def parse_int(s): + s = (s or '').strip() + return int(s) if s.isdigit() else None + + deal_id = parse_int(request.POST.get('deal_id')) + entity_id = parse_int(request.POST.get('entity_id')) + operation_id = parse_int(request.POST.get('operation_id')) + machine_id = parse_int(request.POST.get('machine_id')) + workshop_id = parse_int(request.POST.get('workshop_id')) + qty = parse_int(request.POST.get('quantity_plan')) + recursive_bom = request.POST.get('recursive_bom') == 'on' + + next_url = (request.POST.get('next') or '').strip() + if not next_url.startswith('/'): + next_url = str(reverse_lazy('planning')) + + if not (deal_id and entity_id and operation_id and qty and qty > 0): + messages.error(request, 'Заполни операцию и количество.') + return redirect(next_url) + + op = Operation.objects.select_related('workshop').filter(id=operation_id).first() + if not op: + messages.error(request, 'Операция не найдена.') + return redirect(next_url) + + machine = None + if machine_id: + machine = Machine.objects.select_related('workshop').filter(id=machine_id).first() + + workshop = None + if workshop_id: + workshop = Workshop.objects.filter(id=workshop_id).first() + if not workshop: + messages.error(request, 'Цех не найден.') + return redirect(next_url) + + if not machine and not (workshop_id or getattr(op, 'workshop_id', None)): + messages.error(request, 'Выбери станок или цех.') + return redirect(next_url) + + resolved_workshop_id = ( + machine.workshop_id if machine and machine.workshop_id else (workshop.id if workshop else getattr(op, 'workshop_id', None)) + ) + + # Комментарий: Если включен чекбокс recursive_bom, мы бежим по всему дереву BOM вниз + # и создаем WorkItem для ВСЕХ операций маршрута каждого дочернего компонента, + # плюс для выбранной операции родителя. + if recursive_bom: + try: + with transaction.atomic(): + adjacency = _build_bom_graph({entity_id}) + required_nodes = {} + _accumulate_requirements(entity_id, qty, adjacency, set(), required_nodes) + + # Получаем все маршруты для собранных сущностей + node_ids = list(required_nodes.keys()) + entity_ops = list(EntityOperation.objects.select_related('operation').filter(entity_id__in=node_ids)) + ops_by_entity = {} + for eo in entity_ops: + ops_by_entity.setdefault(eo.entity_id, []).append(eo) + + created_count = 0 + for c_id, c_qty in required_nodes.items(): + c_ops = ops_by_entity.get(c_id, []) + if c_id == entity_id: + # Для самого родителя мы ставим только ту операцию, которую выбрали в модалке (или тоже все?) + # Пользователь просил "на все операции маршрута для каждой вложенной детали". + # Родительскую мы создадим явно по выбранной, остальные дочерние - по всем. + WorkItem.objects.create( + deal_id=deal_id, + entity_id=entity_id, + operation_id=operation_id, + workshop_id=resolved_workshop_id, + machine_id=(machine.id if machine else None), + stage=(op.name or '')[:32], + quantity_plan=qty, + quantity_done=0, + status='planned', + date=timezone.localdate(), + ) + created_count += 1 + else: + # Для дочерних создаем на все операции маршрута + for eo in c_ops: + if not eo.operation: + continue + w_id = eo.operation.workshop_id + WorkItem.objects.create( + deal_id=deal_id, + entity_id=c_id, + operation_id=eo.operation_id, + workshop_id=w_id, + machine_id=None, + stage=(eo.operation.name or '')[:32], + quantity_plan=c_qty, + quantity_done=0, + status='planned', + date=timezone.localdate(), + ) + created_count += 1 + messages.success(request, f'Рекурсивно добавлено в смену заданий: {created_count} шт.') + except Exception as e: + logger.exception('workitem_add recursive error') + messages.error(request, f'Ошибка при рекурсивном добавлении: {e}') + else: + wi = WorkItem.objects.create( + deal_id=int(deal_id), + entity_id=int(entity_id), + operation_id=int(operation_id), + workshop_id=resolved_workshop_id, + machine_id=(machine.id if machine else None), + stage=(op.name or '')[:32], + quantity_plan=int(qty), + quantity_done=0, + status='planned', + date=timezone.localdate(), + ) + logger.info('workitem_add: id=%s deal_id=%s entity_id=%s operation_id=%s machine_id=%s qty=%s', wi.id, deal_id, entity_id, operation_id, machine_id, qty) + messages.success(request, 'Добавлено в смену.') + + return redirect(next_url) + + class ProductionTaskCreateView(LoginRequiredMixin, FormView): template_name = 'shiftflow/task_create.html' form_class = ProductionTaskCreateForm @@ -1077,7 +2814,7 @@ class DealDetailView(LoginRequiredMixin, View): def get(self, request, pk, *args, **kwargs): profile = getattr(request.user, 'profile', None) role = profile.role if profile else ('admin' if request.user.is_superuser else 'operator') - if role not in ['admin', 'technologist']: + if role not in ['admin', 'technologist', 'master', 'clerk']: return JsonResponse({'error': 'forbidden'}, status=403) deal = get_object_or_404(Deal, pk=pk) @@ -1087,6 +2824,7 @@ class DealDetailView(LoginRequiredMixin, View): 'status': deal.status, 'company_id': deal.company_id, 'description': deal.description or '', + 'due_date': deal.due_date.isoformat() if getattr(deal, 'due_date', None) else '', }) @@ -1094,7 +2832,7 @@ class DealUpsertView(LoginRequiredMixin, View): def post(self, request, *args, **kwargs): profile = getattr(request.user, 'profile', None) role = profile.role if profile else ('admin' if request.user.is_superuser else 'operator') - if role not in ['admin', 'technologist']: + if role not in ['admin', 'technologist', 'master', 'clerk']: return JsonResponse({'error': 'forbidden'}, status=403) deal_id = request.POST.get('id') @@ -1102,6 +2840,7 @@ class DealUpsertView(LoginRequiredMixin, View): description = (request.POST.get('description') or '').strip() company_id = request.POST.get('company_id') status = (request.POST.get('status') or 'work').strip() + due_date = (request.POST.get('due_date') or '').strip() if not number: return JsonResponse({'error': 'number_required'}, status=400) @@ -1118,6 +2857,14 @@ class DealUpsertView(LoginRequiredMixin, View): deal.status = status deal.description = description + + if due_date: + try: + deal.due_date = datetime.strptime(due_date, '%Y-%m-%d').date() + except Exception: + deal.due_date = None + else: + deal.due_date = None if company_id and str(company_id).isdigit(): deal.company_id = int(company_id) else: @@ -1131,7 +2878,7 @@ class MaterialDetailView(LoginRequiredMixin, View): def get(self, request, pk, *args, **kwargs): profile = getattr(request.user, 'profile', None) role = profile.role if profile else ('admin' if request.user.is_superuser else 'operator') - if role not in ['admin', 'technologist']: + if role not in ['admin', 'technologist', 'master', 'clerk']: return JsonResponse({'error': 'forbidden'}, status=403) material = get_object_or_404(Material, pk=pk) @@ -1141,6 +2888,7 @@ class MaterialDetailView(LoginRequiredMixin, View): 'steel_grade_id': material.steel_grade_id, 'name': material.name, 'full_name': material.full_name, + 'mass_per_unit': material.mass_per_unit, }) @@ -1148,13 +2896,23 @@ class MaterialUpsertView(LoginRequiredMixin, View): def post(self, request, *args, **kwargs): profile = getattr(request.user, 'profile', None) role = profile.role if profile else ('admin' if request.user.is_superuser else 'operator') - if role not in ['admin', 'technologist']: + if role not in ['admin', 'technologist', 'master', 'clerk']: return JsonResponse({'error': 'forbidden'}, status=403) + def parse_float(value): + s = (value or '').strip().replace(',', '.') + if not s: + return None + try: + return float(s) + except ValueError: + return None + material_id = request.POST.get('id') category_id = request.POST.get('category_id') steel_grade_id = request.POST.get('steel_grade_id') name = (request.POST.get('name') or '').strip() + mass_per_unit = parse_float(request.POST.get('mass_per_unit')) if not (category_id and str(category_id).isdigit() and name): return JsonResponse({'error': 'invalid'}, status=400) @@ -1166,6 +2924,7 @@ class MaterialUpsertView(LoginRequiredMixin, View): material.category_id = int(category_id) material.name = name + material.mass_per_unit = mass_per_unit if steel_grade_id and str(steel_grade_id).isdigit(): material.steel_grade_id = int(steel_grade_id) else: @@ -1253,6 +3012,698 @@ class SteelGradeUpsertView(LoginRequiredMixin, View): return JsonResponse({'id': grade.id, 'label': grade.name}) +class EntitiesSearchView(LoginRequiredMixin, View): + def get(self, request, *args, **kwargs): + profile = getattr(request.user, 'profile', None) + role = profile.role if profile else ('admin' if request.user.is_superuser else 'operator') + if role not in ['admin', 'technologist']: + return JsonResponse({'error': 'forbidden'}, status=403) + + q_dn = (request.GET.get('q_dn') or '').strip() + q_name = (request.GET.get('q_name') or '').strip() + et = (request.GET.get('entity_type') or '').strip() + + qs = ProductEntity.objects.all() + if et in ['product', 'assembly', 'part']: + qs = qs.filter(entity_type=et) + if q_dn: + qs = qs.filter(drawing_number__icontains=q_dn) + if q_name: + qs = qs.filter(name__icontains=q_name) + + data = [ + { + 'id': e.id, + 'type': e.entity_type, + 'drawing_number': e.drawing_number, + 'name': e.name, + } + for e in qs.order_by('entity_type', 'drawing_number', 'name', 'id')[:200] + ] + return JsonResponse({'results': data}) + + +class DealBatchActionView(LoginRequiredMixin, View): + def post(self, request, *args, **kwargs): + profile = getattr(request.user, 'profile', None) + role = profile.role if profile else ('admin' if request.user.is_superuser else 'operator') + if role not in ['admin', 'technologist']: + return redirect('planning') + + action = (request.POST.get('action') or '').strip() + next_url = (request.POST.get('next') or '').strip() + if not next_url.startswith('/'): + next_url = str(reverse_lazy('planning')) + + def parse_int(s): + s = (s or '').strip() + return int(s) if s.isdigit() else None + + deal_id = parse_int(request.POST.get('deal_id')) + + if action == 'create_batch': + due_date = (request.POST.get('due_date') or '').strip() + name = (request.POST.get('name') or '').strip()[:120] + if not deal_id or not due_date: + messages.error(request, 'Заполни дату отгрузки.') + return redirect(next_url) + try: + dd = datetime.strptime(due_date, '%Y-%m-%d').date() + except Exception: + messages.error(request, 'Некорректная дата.') + return redirect(next_url) + DealDeliveryBatch.objects.create(deal_id=deal_id, due_date=dd, name=name, is_default=False) + _reconcile_default_delivery_batch(int(deal_id)) + messages.success(request, 'Партия добавлена.') + return redirect(next_url) + + if action == 'delete_batch': + batch_id = parse_int(request.POST.get('batch_id')) + if not batch_id: + return redirect(next_url) + b = DealDeliveryBatch.objects.filter(id=batch_id, deal_id=deal_id).first() + if b and getattr(b, 'is_default', False): + messages.error(request, 'Дефолтная партия рассчитывается автоматически и не удаляется.') + return redirect(next_url) + DealDeliveryBatch.objects.filter(id=batch_id, deal_id=deal_id).delete() + _reconcile_default_delivery_batch(int(deal_id)) + messages.success(request, 'Партия удалена.') + return redirect(next_url) + + if action == 'add_batch_item': + batch_id = parse_int(request.POST.get('batch_id')) + entity_id = parse_int(request.POST.get('entity_id')) + qty = parse_int(request.POST.get('quantity')) + if not (deal_id and batch_id and entity_id and qty and qty > 0): + messages.error(request, 'Заполни позицию и количество.') + return redirect(next_url) + + batch = DealDeliveryBatch.objects.filter(id=batch_id, deal_id=deal_id).first() + if not batch: + messages.error(request, 'Партия не найдена.') + return redirect(next_url) + if getattr(batch, 'is_default', False): + messages.error(request, 'Дефолтная партия заполняется автоматически. Создай партию с датой и распределяй туда.') + return redirect(next_url) + + deal_item = DealItem.objects.filter(deal_id=deal_id, entity_id=entity_id).first() + if not deal_item: + messages.error(request, 'Добавлять в партию можно только позиции из сделки.') + return redirect(next_url) + + existing = DealBatchItem.objects.filter(batch_id=batch_id, entity_id=entity_id).first() + allocated_other = ( + DealBatchItem.objects.filter(batch__deal_id=deal_id, batch__is_default=False, entity_id=entity_id) + .exclude(id=existing.id if existing else None) + .aggregate(s=Coalesce(Sum('quantity'), 0))['s'] + ) + total = int(deal_item.quantity or 0) + if qty + int(allocated_other or 0) > total: + messages.error(request, 'Нельзя распределить больше, чем заказано по позиции сделки.') + return redirect(next_url) + + if existing: + if existing.quantity != qty: + existing.quantity = qty + existing.save(update_fields=['quantity']) + else: + DealBatchItem.objects.create(batch_id=batch_id, entity_id=entity_id, quantity=qty) + + _reconcile_default_delivery_batch(int(deal_id)) + messages.success(request, 'Позиция партии сохранена.') + return redirect(next_url) + + if action == 'update_batch_item_qty': + item_id = parse_int(request.POST.get('item_id')) + qty = parse_int(request.POST.get('quantity')) + if not item_id or not qty or qty <= 0: + messages.error(request, 'Заполни количество.') + return redirect(next_url) + + bi = DealBatchItem.objects.select_related('batch').filter(id=item_id, batch__deal_id=deal_id).first() + if not bi: + return redirect(next_url) + if getattr(getattr(bi, 'batch', None), 'is_default', False): + messages.error(request, 'Количество дефолтной партии рассчитывается автоматически.') + return redirect(next_url) + + deal_item = DealItem.objects.filter(deal_id=deal_id, entity_id=bi.entity_id).first() + if not deal_item: + messages.error(request, 'Позиция не найдена в списке позиций сделки.') + return redirect(next_url) + + allocated_other = ( + DealBatchItem.objects.filter(batch__deal_id=deal_id, batch__is_default=False, entity_id=bi.entity_id) + .exclude(id=bi.id) + .aggregate(s=Coalesce(Sum('quantity'), 0))['s'] + ) + total = int(deal_item.quantity or 0) + if qty + int(allocated_other or 0) > total: + messages.error(request, 'Нельзя распределить больше, чем заказано по позиции сделки.') + return redirect(next_url) + + if bi.quantity != qty: + bi.quantity = qty + bi.save(update_fields=['quantity']) + + _reconcile_default_delivery_batch(int(deal_id)) + messages.success(request, 'Количество обновлено.') + return redirect(next_url) + + if action == 'delete_batch_item': + item_id = parse_int(request.POST.get('item_id')) + if not item_id: + return redirect(next_url) + bi = DealBatchItem.objects.select_related('batch').filter(id=item_id, batch__deal_id=deal_id).first() + if bi and getattr(getattr(bi, 'batch', None), 'is_default', False): + messages.error(request, 'Дефолтная партия рассчитывается автоматически.') + return redirect(next_url) + DealBatchItem.objects.filter(id=item_id, batch__deal_id=deal_id).delete() + _reconcile_default_delivery_batch(int(deal_id)) + messages.success(request, 'Строка удалена.') + return redirect(next_url) + + if action == 'start_batch_item_production': + item_id = parse_int(request.POST.get('item_id')) + qty = parse_int(request.POST.get('quantity')) + if not item_id or not qty or qty <= 0: + messages.error(request, 'Заполни количество.') + return redirect(next_url) + + logger.info('start_batch_item_production: deal_id=%s item_id=%s qty=%s', deal_id, item_id, qty) + + try: + with transaction.atomic(): + bi = ( + DealBatchItem.objects.select_for_update() + .select_related('batch', 'entity') + .filter(id=item_id, batch__deal_id=deal_id) + .first() + ) + if not bi: + messages.error(request, 'Строка партии не найдена.') + return redirect(next_url) + + et = getattr(bi.entity, 'entity_type', '') + if et in ['purchased', 'casting', 'outsourced']: + messages.error(request, 'Эта позиция относится к снабжению. Запуск через производство пока не реализован.') + logger.info('start_batch_item_production: skipped supply entity_type=%s entity_id=%s', et, bi.entity_id) + return redirect(next_url) + + started = int(getattr(bi, 'started_qty', 0) or 0) + total = int(bi.quantity or 0) + remaining = total - started + if qty > remaining: + messages.error(request, 'Нельзя запустить больше, чем осталось в партии.') + logger.info('start_batch_item_production: qty_exceeds_remaining remaining=%s started=%s total=%s', remaining, started, total) + return redirect(next_url) + + stats = explode_roots_additive(int(deal_id), [(int(bi.entity_id), int(qty))]) + bi.started_qty = started + int(qty) + bi.save(update_fields=['started_qty']) + + if int(stats.tasks_created or 0) == 0 and int(stats.tasks_updated or 0) == 0: + messages.warning(request, 'Запуск выполнен, но задачи не созданы. Проверь, что leaf-детали имеют материал (planned_material) и не относятся к снабжению.') + else: + messages.success(request, f'Запущено в производство: {qty} шт. Задачи: +{stats.tasks_created} / обновлено {stats.tasks_updated}.') + logger.info( + 'start_batch_item_production: ok deal_id=%s entity_id=%s qty=%s tasks_created=%s tasks_updated=%s', + deal_id, + bi.entity_id, + qty, + stats.tasks_created, + stats.tasks_updated, + ) + return redirect(next_url) + except ExplosionValidationError as ev: + try: + from manufacturing.models import ProductEntity + bad = list(ProductEntity.objects.filter(id__in=list(ev.missing_material_ids)).values_list('drawing_number', 'name')) + except Exception: + bad = [] + if bad: + preview = ", ".join([f"{dn or '—'} {nm}" for dn, nm in bad[:5]]) + more = '' if len(bad) <= 5 else f" и ещё {len(bad)-5}" + messages.error(request, f'В спецификации есть детали без материала: {preview}{more}. Добавь material в паспорт(ы) и повтори запуск.') + else: + messages.error(request, 'В спецификации есть детали без материала. Добавь material и повтори запуск.') + return redirect(next_url) + except Exception: + logger.exception('start_batch_item_production: failed deal_id=%s item_id=%s qty=%s', deal_id, item_id, qty) + messages.error(request, 'Ошибка запуска в производство. Подробности в логе сервера.') + return redirect(next_url) + + messages.error(request, 'Неизвестное действие.') + return redirect(next_url) + + +class DealItemUpsertView(LoginRequiredMixin, View): + def post(self, request, *args, **kwargs): + profile = getattr(request.user, 'profile', None) + role = profile.role if profile else ('admin' if request.user.is_superuser else 'operator') + if role not in ['admin', 'technologist']: + return redirect('planning') + + def parse_int(s): + s = (s or '').strip() + return int(s) if s.isdigit() else None + + deal_id = parse_int(request.POST.get('deal_id')) + entity_id = parse_int(request.POST.get('entity_id')) + qty = parse_int(request.POST.get('quantity')) + + if not (deal_id and entity_id and qty and qty > 0): + messages.error(request, 'Заполни сущность и количество.') + next_url = (request.POST.get('next') or '').strip() + return redirect(next_url if next_url.startswith('/') else 'planning') + + try: + item, created = DealItem.objects.get_or_create(deal_id=deal_id, entity_id=entity_id, defaults={'quantity': qty}) + if not created: + item.quantity = qty + item.save() + + _reconcile_default_delivery_batch(int(deal_id)) + messages.success(request, 'Позиция сделки сохранена.') + except Exception as e: + messages.error(request, f'Ошибка: {e}') + + next_url = (request.POST.get('next') or '').strip() + return redirect(next_url if next_url.startswith('/') else 'planning') + + +class DirectoriesView(LoginRequiredMixin, TemplateView): + template_name = 'shiftflow/directories.html' + + def dispatch(self, request, *args, **kwargs): + profile = getattr(request.user, 'profile', None) + role = profile.role if profile else ('admin' if request.user.is_superuser else 'operator') + if role not in ['admin', 'technologist', 'master', 'clerk', 'prod_head', 'director']: + return redirect('registry') + return super().dispatch(request, *args, **kwargs) + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + profile = getattr(self.request.user, 'profile', None) + role = profile.role if profile else ('admin' if self.request.user.is_superuser else 'operator') + ctx['user_role'] = role + return ctx + + +class LocationsCatalogView(LoginRequiredMixin, TemplateView): + template_name = 'shiftflow/locations_catalog.html' + + def dispatch(self, request, *args, **kwargs): + profile = getattr(request.user, 'profile', None) + role = profile.role if profile else ('admin' if request.user.is_superuser else 'operator') + if role not in ['admin', 'technologist', 'master', 'clerk', 'prod_head', 'director']: + return redirect('registry') + return super().dispatch(request, *args, **kwargs) + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + profile = getattr(self.request.user, 'profile', None) + role = profile.role if profile else ('admin' if self.request.user.is_superuser else 'operator') + ctx['user_role'] = role + ctx['can_edit'] = role in ['admin', 'prod_head', 'director'] + + ctx['locations'] = list(Location.objects.order_by('name')) + return ctx + + def post(self, request, *args, **kwargs): + profile = getattr(request.user, 'profile', None) + role = profile.role if profile else ('admin' if request.user.is_superuser else 'operator') + if role not in ['admin', 'prod_head', 'director']: + return redirect('locations_catalog') + + action = (request.POST.get('action') or '').strip() + name = (request.POST.get('name') or '').strip() + + if action == 'create': + if not name: + messages.error(request, 'Заполни название склада.') + return redirect('locations_catalog') + + obj = Location(name=name[:100]) + try: + obj.full_clean() + obj.save() + messages.success(request, 'Склад создан.') + except Exception as e: + messages.error(request, f'Ошибка: {e}') + return redirect('locations_catalog') + + if action == 'update': + lid = (request.POST.get('location_id') or '').strip() + if not lid.isdigit(): + return redirect('locations_catalog') + + obj = get_object_or_404(Location, pk=int(lid)) + if name: + obj.name = name[:100] + + try: + obj.full_clean() + obj.save() + messages.success(request, 'Склад обновлён.') + except Exception as e: + messages.error(request, f'Ошибка: {e}') + return redirect('locations_catalog') + + return redirect('locations_catalog') + + +class WorkshopsCatalogView(LoginRequiredMixin, TemplateView): + template_name = 'shiftflow/workshops_catalog.html' + + def dispatch(self, request, *args, **kwargs): + profile = getattr(request.user, 'profile', None) + role = profile.role if profile else ('admin' if request.user.is_superuser else 'operator') + if role not in ['admin', 'technologist', 'master', 'clerk', 'prod_head', 'director']: + return redirect('registry') + return super().dispatch(request, *args, **kwargs) + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + profile = getattr(self.request.user, 'profile', None) + role = profile.role if profile else ('admin' if self.request.user.is_superuser else 'operator') + ctx['user_role'] = role + + workshops = list(Workshop.objects.select_related('location').order_by('id')) + ws_ids = [int(w.id) for w in workshops] + machines = list(Machine.objects.filter(workshop_id__in=ws_ids).order_by('name', 'id')) + + by_ws = {} + for m in machines: + by_ws.setdefault(int(m.workshop_id), []).append(m) + + for ws in workshops: + ms = by_ws.get(int(ws.id)) or [] + labels = [x.name for x in ms][:8] + tail = '' + if len(ms) > 8: + tail = f" +{len(ms) - 8}" + ws.machine_labels = ', '.join(labels) + tail if labels else '' + + ctx['workshops'] = workshops + return ctx + + + +class MachinesCatalogView(LoginRequiredMixin, TemplateView): + template_name = 'shiftflow/machines_catalog.html' + + def dispatch(self, request, *args, **kwargs): + profile = getattr(request.user, 'profile', None) + role = profile.role if profile else ('admin' if request.user.is_superuser else 'operator') + if role not in ['admin', 'technologist', 'master', 'clerk', 'prod_head', 'director']: + return redirect('registry') + self.role = role + self.can_edit = role in ['admin', 'prod_head', 'director'] + return super().dispatch(request, *args, **kwargs) + + def _workshop_id(self): + ws_id = (self.request.GET.get('workshop_id') or '').strip() + return int(ws_id) if ws_id.isdigit() else None + + def get(self, request, *args, **kwargs): + if not self._workshop_id(): + return redirect('workshops_catalog') + return super().get(request, *args, **kwargs) + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + ctx['user_role'] = self.role + ctx['can_edit'] = self.can_edit + + ws_id = self._workshop_id() + workshop = get_object_or_404(Workshop.objects.select_related('location'), pk=int(ws_id)) + ctx['workshop'] = workshop + ctx['locations'] = list(Location.objects.order_by('name')) + + machines = list(Machine.objects.filter(workshop_id=workshop.id).order_by('name', 'id')) + ctx['machines'] = machines + ctx['machine_types'] = list(getattr(Machine, 'MACHINE_TYPE_CHOICES', [])) + return ctx + + def post(self, request, *args, **kwargs): + ws_id = self._workshop_id() + if not ws_id: + return redirect('workshops_catalog') + + if not self.can_edit: + return redirect(f"{reverse_lazy('machines_catalog')}?workshop_id={int(ws_id)}") + + workshop = get_object_or_404(Workshop, pk=int(ws_id)) + action = (request.POST.get('action') or '').strip() + + if action == 'update_workshop': + name = (request.POST.get('name') or '').strip() + location_id = (request.POST.get('location_id') or '').strip() + if name: + workshop.name = name[:120] + workshop.location_id = int(location_id) if location_id.isdigit() else None + try: + workshop.full_clean() + workshop.save() + messages.success(request, 'Цех сохранён.') + except Exception as e: + messages.error(request, f'Ошибка: {e}') + return redirect(f"{reverse_lazy('machines_catalog')}?workshop_id={workshop.id}") + + if action == 'create_machine': + name = (request.POST.get('name') or '').strip() + machine_type = (request.POST.get('machine_type') or '').strip() + if not name: + messages.error(request, 'Заполни название поста.') + return redirect(f"{reverse_lazy('machines_catalog')}?workshop_id={workshop.id}") + + allowed = {t[0] for t in getattr(Machine, 'MACHINE_TYPE_CHOICES', [])} + if machine_type not in allowed: + machine_type = 'linear' + + m = Machine(name=name[:100], workshop_id=workshop.id, machine_type=machine_type) + try: + m.full_clean() + m.save() + messages.success(request, 'Пост добавлен.') + except Exception as e: + messages.error(request, f'Ошибка: {e}') + return redirect(f"{reverse_lazy('machines_catalog')}?workshop_id={workshop.id}") + + if action == 'update_machine': + mid = (request.POST.get('machine_id') or '').strip() + if not mid.isdigit(): + return redirect(f"{reverse_lazy('machines_catalog')}?workshop_id={workshop.id}") + + m = get_object_or_404(Machine, pk=int(mid), workshop_id=workshop.id) + name = (request.POST.get('name') or '').strip() + machine_type = (request.POST.get('machine_type') or '').strip() + + if name: + m.name = name[:100] + + allowed = {t[0] for t in getattr(Machine, 'MACHINE_TYPE_CHOICES', [])} + if machine_type in allowed: + m.machine_type = machine_type + + try: + m.full_clean() + m.save() + messages.success(request, 'Пост сохранён.') + except Exception as e: + messages.error(request, f'Ошибка: {e}') + return redirect(f"{reverse_lazy('machines_catalog')}?workshop_id={workshop.id}") + + if action == 'delete_machine': + mid = (request.POST.get('machine_id') or '').strip() + if not mid.isdigit(): + return redirect(f"{reverse_lazy('machines_catalog')}?workshop_id={workshop.id}") + + m = get_object_or_404(Machine, pk=int(mid), workshop_id=workshop.id) + + # Защита удаления: не удаляем пост, если по нему есть сменка/задания. + has_items = Item.objects.filter(machine_id=m.id).exists() + has_workitems = WorkItem.objects.filter(machine_id=m.id).exists() + if has_items or has_workitems: + messages.error(request, 'Нельзя удалить пост: по нему есть сменные задания или производственные операции.') + return redirect(f"{reverse_lazy('machines_catalog')}?workshop_id={workshop.id}") + + try: + m.delete() + messages.success(request, 'Пост удалён.') + except Exception as e: + messages.error(request, f'Нельзя удалить пост: {e}') + return redirect(f"{reverse_lazy('machines_catalog')}?workshop_id={workshop.id}") + + messages.error(request, 'Неизвестное действие.') + return redirect(f"{reverse_lazy('machines_catalog')}?workshop_id={workshop.id}") + + +class SupplyCatalogView(LoginRequiredMixin, TemplateView): + template_name = 'shiftflow/supply_catalog.html' + + TYPE_CHOICES = [ + ('purchased', 'Покупное'), + ('outsourced', 'Аутсорс'), + ] + + def dispatch(self, request, *args, **kwargs): + profile = getattr(request.user, 'profile', None) + role = profile.role if profile else ('admin' if request.user.is_superuser else 'operator') + if role not in ['admin', 'technologist', 'master', 'clerk']: + return redirect('registry') + return super().dispatch(request, *args, **kwargs) + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + profile = getattr(self.request.user, 'profile', None) + role = profile.role if profile else ('admin' if self.request.user.is_superuser else 'operator') + ctx['user_role'] = role + ctx['can_edit'] = role in ['admin', 'technologist', 'clerk'] + + q = (self.request.GET.get('q') or '').strip() + entity_types = [x.strip() for x in self.request.GET.getlist('types') if (x or '').strip()] + allowed_types = {c[0] for c in self.TYPE_CHOICES} + entity_types = [x for x in entity_types if x in allowed_types] + if not entity_types: + entity_types = list(allowed_types) + + qs = ProductEntity.objects.all().filter(entity_type__in=entity_types) + if q: + qs = qs.filter(Q(drawing_number__icontains=q) | Q(name__icontains=q)) + + ctx['q'] = q + ctx['entity_types'] = entity_types + ctx['type_choices'] = list(self.TYPE_CHOICES) + ctx['items'] = qs.order_by('entity_type', 'drawing_number', 'name', 'id') + return ctx + + def post(self, request, *args, **kwargs): + profile = getattr(request.user, 'profile', None) + role = profile.role if profile else ('admin' if request.user.is_superuser else 'operator') + if role not in ['admin', 'technologist', 'clerk']: + return redirect('supply_catalog') + + entity_type = (request.POST.get('entity_type') or '').strip() + name = (request.POST.get('name') or '').strip() + drawing_number = (request.POST.get('drawing_number') or '').strip() + + allowed_types = {c[0] for c in self.TYPE_CHOICES} + if entity_type not in allowed_types: + messages.error(request, 'Выбери тип: покупное / аутсорс.') + return redirect('supply_catalog') + + if not name: + messages.error(request, 'Заполни наименование.') + return redirect('supply_catalog') + + obj = ProductEntity.objects.create( + entity_type=entity_type, + name=name[:255], + drawing_number=drawing_number[:100], + ) + return redirect('product_detail', pk=obj.id) + + +class MaterialsCatalogView(LoginRequiredMixin, TemplateView): + template_name = 'shiftflow/materials_catalog.html' + + def dispatch(self, request, *args, **kwargs): + profile = getattr(request.user, 'profile', None) + role = profile.role if profile else ('admin' if request.user.is_superuser else 'operator') + if role not in ['admin', 'technologist', 'master', 'clerk']: + return redirect('registry') + return super().dispatch(request, *args, **kwargs) + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + profile = getattr(self.request.user, 'profile', None) + role = profile.role if profile else ('admin' if self.request.user.is_superuser else 'operator') + ctx['user_role'] = role + ctx['can_edit'] = role in ['admin', 'technologist', 'master', 'clerk'] + + q = (self.request.GET.get('q') or '').strip() + qs = Material.objects.select_related('category', 'steel_grade').all() + if q: + qs = qs.filter(Q(full_name__icontains=q) | Q(name__icontains=q) | Q(category__name__icontains=q) | Q(steel_grade__name__icontains=q)) + + def unit_for(m): + ff = getattr(getattr(m, 'category', None), 'form_factor', 'other') + if ff == 'sheet': + return 'кг/кв.м' + if ff == 'bar': + return 'кг/п.м' + return 'кг/шт' + + rows = [] + for m in qs.order_by('category__name', 'name', 'steel_grade__name', 'id'): + rows.append({ + 'm': m, + 'unit': unit_for(m), + }) + + ctx['q'] = q + ctx['rows'] = rows + ctx['categories'] = list(MaterialCategory.objects.order_by('name')) + ctx['grades'] = list(SteelGrade.objects.order_by('name')) + return ctx + + +class MaterialCategoriesCatalogView(LoginRequiredMixin, TemplateView): + template_name = 'shiftflow/material_categories_catalog.html' + + def dispatch(self, request, *args, **kwargs): + profile = getattr(request.user, 'profile', None) + role = profile.role if profile else ('admin' if request.user.is_superuser else 'operator') + if role not in ['admin', 'technologist', 'master', 'clerk']: + return redirect('registry') + return super().dispatch(request, *args, **kwargs) + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + profile = getattr(self.request.user, 'profile', None) + role = profile.role if profile else ('admin' if self.request.user.is_superuser else 'operator') + ctx['user_role'] = role + ctx['can_edit'] = role in ['admin', 'technologist', 'master', 'clerk'] + + q = (self.request.GET.get('q') or '').strip() + qs = MaterialCategory.objects.all() + if q: + qs = qs.filter(Q(name__icontains=q) | Q(gost_standard__icontains=q)) + + ctx['q'] = q + ctx['categories'] = list(qs.order_by('name')) + return ctx + + +class SteelGradesCatalogView(LoginRequiredMixin, TemplateView): + template_name = 'shiftflow/steel_grades_catalog.html' + + def dispatch(self, request, *args, **kwargs): + profile = getattr(request.user, 'profile', None) + role = profile.role if profile else ('admin' if request.user.is_superuser else 'operator') + if role not in ['admin', 'technologist', 'master', 'clerk']: + return redirect('registry') + return super().dispatch(request, *args, **kwargs) + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + profile = getattr(self.request.user, 'profile', None) + role = profile.role if profile else ('admin' if self.request.user.is_superuser else 'operator') + ctx['user_role'] = role + ctx['can_edit'] = role in ['admin', 'technologist', 'master', 'clerk'] + + q = (self.request.GET.get('q') or '').strip() + qs = SteelGrade.objects.all() + if q: + qs = qs.filter(Q(name__icontains=q) | Q(gost_standard__icontains=q)) + + ctx['q'] = q + ctx['grades'] = list(qs.order_by('name')) + return ctx + + # Вьюха детального вида и редактирования class ItemUpdateView(LoginRequiredMixin, UpdateView): model = Item @@ -1691,21 +4142,183 @@ class WarehouseReceiptCreateView(LoginRequiredMixin, View): return redirect(next_url) -class ClosingView(LoginRequiredMixin, TemplateView): - template_name = 'shiftflow/closing.html' +from shiftflow.services.assembly_closing import get_assembly_closing_info, apply_assembly_closing + +class AssemblyClosingView(LoginRequiredMixin, TemplateView): + template_name = 'shiftflow/assembly_closing.html' def dispatch(self, request, *args, **kwargs): - profile = getattr(request.user, 'profile', None) - role = profile.role if profile else ('admin' if request.user.is_superuser else 'operator') - if role not in ['admin', 'master', 'operator', 'observer']: + roles = get_user_roles(request.user) + if not has_any_role(roles, ['admin', 'master', 'operator', 'prod_head']): + return redirect('registry') + + pk = self.kwargs.get('pk') + wi = get_object_or_404(WorkItem.objects.select_related('entity', 'deal', 'machine', 'workshop'), pk=int(pk)) + if wi.entity.entity_type not in ['product', 'assembly']: + messages.error(request, 'Закрытие сборки доступно только для сборочных единиц и изделий.') + return redirect('workitem_detail', pk=wi.id) + + self.workitem = wi + return super().dispatch(request, *args, **kwargs) + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + roles = get_user_group_roles(self.request.user) + ctx['user_roles'] = sorted(roles) + ctx['user_role'] = primary_role(roles) + + ctx['workitem'] = self.workitem + ctx['remaining'] = max(0, int(self.workitem.quantity_plan or 0) - int(self.workitem.quantity_done or 0)) + + info = get_assembly_closing_info(self.workitem) + ctx.update(info) + + ws_id = getattr(self.workitem, 'workshop_id', None) + ctx['workshop_machines'] = list(Machine.objects.filter(workshop_id=ws_id).order_by('name')) if ws_id else [] + + if info.get('error'): + messages.warning(self.request, info['error']) + + return ctx + + def post(self, request, *args, **kwargs): + action = (request.POST.get('action') or '').strip() + if action == 'close': + qty_raw = (request.POST.get('fact_qty') or '').strip() + try: + qty = int(qty_raw) + except ValueError: + qty = 0 + + if qty <= 0: + messages.error(request, 'Количество должно быть больше 0.') + return redirect('assembly_closing', pk=self.workitem.id) + + if not getattr(self.workitem, 'machine_id', None): + mid_raw = (request.POST.get('machine_id') or '').strip() + if not mid_raw.isdigit(): + messages.error(request, 'Выбери пост для производственного отчёта.') + return redirect('assembly_closing', pk=self.workitem.id) + + mid = int(mid_raw) + ws_id = getattr(self.workitem, 'workshop_id', None) + if ws_id: + ok = Machine.objects.filter(id=mid, workshop_id=int(ws_id)).exists() + else: + ok = Machine.objects.filter(id=mid).exists() + if not ok: + messages.error(request, 'Выбранный пост не относится к цеху задания.') + return redirect('assembly_closing', pk=self.workitem.id) + + WorkItem.objects.filter(id=int(self.workitem.id), machine_id__isnull=True).update(machine_id=mid) + self.workitem.machine_id = mid + + try: + apply_assembly_closing(self.workitem.id, qty, request.user.id) + messages.success(request, f'Успешно закрыто {qty} шт. Компоненты списаны, выпуск добавлен.') + return redirect('workitem_detail', pk=self.workitem.id) + except Exception as e: + logger.exception('assembly_closing: error') + messages.error(request, f'Ошибка закрытия: {e}') + return redirect('assembly_closing', pk=self.workitem.id) + + return redirect('assembly_closing', pk=self.workitem.id) + + +class ClosingWorkItemsView(LoginRequiredMixin, TemplateView): + template_name = 'shiftflow/closing_workitems.html' + + def dispatch(self, request, *args, **kwargs): + roles = get_user_group_roles(request.user) + if not has_any_role(roles, ['admin', 'master', 'operator', 'observer', 'prod_head', 'director']): return redirect('registry') return super().dispatch(request, *args, **kwargs) def get_context_data(self, **kwargs): ctx = super().get_context_data(**kwargs) profile = getattr(self.request.user, 'profile', None) - role = profile.role if profile else ('admin' if self.request.user.is_superuser else 'operator') + roles = get_user_group_roles(self.request.user) + role = primary_role(roles) ctx['user_role'] = role + ctx['user_roles'] = sorted(roles) + + q = (self.request.GET.get('q') or '').strip() + ctx['q'] = q + + qs = ( + WorkItem.objects.select_related('deal', 'entity', 'operation', 'machine', 'workshop', 'entity__planned_material') + .filter(quantity_done__lt=F('quantity_plan')) + .filter(status__in=['planned', 'leftover']) + ) + + if q: + qs = qs.filter( + Q(deal__number__icontains=q) + | Q(entity__drawing_number__icontains=q) + | Q(entity__name__icontains=q) + | Q(machine__name__icontains=q) + | Q(workshop__name__icontains=q) + ) + + if role == 'operator' and profile: + user_machine_ids = list(profile.machines.values_list('id', flat=True)) + user_ws_ids = list( + Machine.objects.filter(id__in=user_machine_ids) + .exclude(workshop_id__isnull=True) + .values_list('workshop_id', flat=True) + ) + allowed_ws = list(profile.allowed_workshops.values_list('id', flat=True)) + ws_ids = list({int(x) for x in (user_ws_ids + allowed_ws) if x}) + qs = qs.filter(Q(machine_id__in=user_machine_ids) | Q(machine_id__isnull=True, workshop_id__in=ws_ids)) + + elif role == 'master' and profile: + allowed_ws = list(profile.allowed_workshops.values_list('id', flat=True)) + if allowed_ws: + qs = qs.filter(Q(workshop_id__in=allowed_ws) | Q(machine__workshop_id__in=allowed_ws)) + + rows = list(qs.order_by('workshop__name', 'machine__name', 'date', 'deal__number', 'entity__drawing_number', 'id')) + for wi in rows: + plan = int(wi.quantity_plan or 0) + done = int(wi.quantity_done or 0) + wi.remaining = max(0, plan - done) + + first_op_id = get_first_operation_id(int(wi.entity_id)) + is_first = True + if first_op_id and getattr(wi, 'operation_id', None): + is_first = int(wi.operation_id) == int(first_op_id) + + if wi.entity and wi.entity.entity_type in ['product', 'assembly']: + wi.close_url = str(reverse_lazy('assembly_closing', kwargs={'pk': wi.id})) if is_first else str(reverse_lazy('workitem_op_closing', kwargs={'pk': wi.id})) + elif wi.entity and wi.entity.entity_type == 'part': + m_id = int(wi.machine_id) if wi.machine_id else 0 + mat_id = int(getattr(wi.entity, 'planned_material_id', None) or 0) if wi.entity else 0 + if is_first and m_id and mat_id: + wi.close_url = f"{reverse_lazy('closing')}?machine_id={m_id}&material_id={mat_id}" + else: + wi.close_url = str(reverse_lazy('workitem_op_closing', kwargs={'pk': wi.id})) + else: + wi.close_url = str(reverse_lazy('workitem_op_closing', kwargs={'pk': wi.id})) + + ctx['workitems'] = rows + return ctx + + +class ClosingView(LoginRequiredMixin, TemplateView): + template_name = 'shiftflow/closing.html' + + def dispatch(self, request, *args, **kwargs): + roles = get_user_group_roles(request.user) + if not has_any_role(roles, ['admin', 'master', 'operator', 'observer', 'prod_head', 'director']): + return redirect('registry') + return super().dispatch(request, *args, **kwargs) + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + profile = getattr(self.request.user, 'profile', None) + roles = get_user_group_roles(self.request.user) + role = primary_role(roles) + ctx['user_role'] = role + ctx['user_roles'] = sorted(roles) if role == 'operator' and profile: machines = list(profile.machines.all().order_by('name')) @@ -1713,7 +4326,35 @@ class ClosingView(LoginRequiredMixin, TemplateView): machines = list(Machine.objects.all().order_by('name')) ctx['machines'] = machines - ctx['materials'] = list(Material.objects.select_related('category').order_by('full_name')) + + def materials_from_workitems(machine_ids=None, workshop_ids=None): + wi = WorkItem.objects.select_related('entity', 'entity__planned_material')\ + .filter(status='planned')\ + .filter(quantity_done__lt=F('quantity_plan')) + if machine_ids: + wi = wi.filter(Q(machine_id__in=list(machine_ids)) | Q(machine_id__isnull=True)) + if workshop_ids: + wi = wi.filter(Q(workshop_id__in=list(workshop_ids)) | Q(machine_id__in=list(machine_ids or []))) + mat_ids = ( + wi.values_list('entity__planned_material_id', flat=True) + .exclude(entity__planned_material_id__isnull=True) + .distinct() + ) + return list(Material.objects.select_related('category').filter(id__in=list(mat_ids)).order_by('full_name')) + + if role == 'operator' and profile: + user_machine_ids = set(profile.machines.values_list('id', flat=True)) + user_ws_ids = set( + Machine.objects.filter(id__in=list(user_machine_ids)) + .exclude(workshop_id__isnull=True) + .values_list('workshop_id', flat=True) + ) + ctx['materials'] = materials_from_workitems(machine_ids=user_machine_ids, workshop_ids=user_ws_ids) + elif role == 'master' and profile: + allowed_ws = set(profile.allowed_workshops.values_list('id', flat=True)) + ctx['materials'] = materials_from_workitems(workshop_ids=allowed_ws) + else: + ctx['materials'] = materials_from_workitems() machine_id = (self.request.GET.get('machine_id') or '').strip() material_id = (self.request.GET.get('material_id') or '').strip() @@ -1721,15 +4362,20 @@ class ClosingView(LoginRequiredMixin, TemplateView): ctx['selected_machine_id'] = machine_id ctx['selected_material_id'] = material_id - items = [] + workitems = [] stock_items = [] if machine_id.isdigit() and material_id.isdigit(): - items = list( - Item.objects.select_related('task', 'task__deal', 'task__material', 'machine') - .filter(machine_id=int(machine_id), status='work', task__material_id=int(material_id)) - .order_by('date', 'task__deal__number', 'task__drawing_name') + workitems = list( + WorkItem.objects.select_related('deal', 'entity', 'machine') + .filter(machine_id=int(machine_id), status__in=['planned'], entity__planned_material_id=int(material_id)) + .filter(quantity_done__lt=F('quantity_plan')) + .order_by('date', 'deal__number', 'entity__drawing_number') ) + for wi in workitems: + plan = int(wi.quantity_plan or 0) + done = int(wi.quantity_done or 0) + wi.remaining = max(0, plan - done) machine = Machine.objects.select_related('workshop', 'workshop__location', 'location').filter(pk=int(machine_id)).first() work_location_id = None @@ -1746,15 +4392,20 @@ class ClosingView(LoginRequiredMixin, TemplateView): .order_by('created_at', 'id') ) - ctx['items'] = items + ctx['workitems'] = workitems ctx['stock_items'] = stock_items - ctx['can_edit'] = role in ['admin', 'master', 'operator'] + readonly = bool(getattr(profile, 'is_readonly', False)) if profile else False + ctx['can_edit'] = (has_any_role(roles, ['admin', 'master', 'operator', 'prod_head']) and not readonly) return ctx def post(self, request, *args, **kwargs): + roles = get_user_group_roles(request.user) + if not has_any_role(roles, ['admin', 'master', 'operator', 'prod_head']): + return redirect('closing') + profile = getattr(request.user, 'profile', None) - role = profile.role if profile else ('admin' if request.user.is_superuser else 'operator') - if role not in ['admin', 'master', 'operator']: + if bool(getattr(profile, 'is_readonly', False)) if profile else False: + messages.error(request, 'Доступ только для просмотра.') return redirect('closing') machine_id = (request.POST.get('machine_id') or '').strip() @@ -1850,7 +4501,7 @@ class ClosingView(LoginRequiredMixin, TemplateView): return redirect(f"{reverse_lazy('closing')}?machine_id={machine_id}&material_id={material_id}") try: - apply_closing( + apply_closing_workitems( user_id=request.user.id, machine_id=int(machine_id), material_id=int(material_id), @@ -1860,11 +4511,553 @@ class ClosingView(LoginRequiredMixin, TemplateView): ) messages.success(request, 'Закрытие выполнено.') except Exception as e: + logger.exception('closing_workitems:error machine_id=%s material_id=%s', machine_id, material_id) messages.error(request, f'Ошибка закрытия: {e}') return redirect(f"{reverse_lazy('closing')}?machine_id={machine_id}&material_id={material_id}") +class ProcurementDashboardView(LoginRequiredMixin, TemplateView): + template_name = 'shiftflow/procurement_dashboard.html' + + TYPE_CHOICES = [ + ('raw', 'Сырьё'), + ('purchased', 'Покупное'), + ('casting', 'Литьё'), + ('outsourced', 'Аутсорс'), + ] + + STATUS_CHOICES = [ + ('to_order', 'К заказу'), + ('ordered', 'Заказано'), + ('closed', 'Закрыто'), + ] + + def dispatch(self, request, *args, **kwargs): + roles = get_user_group_roles(request.user) + if not has_any_role(roles, ['admin', 'supply', 'observer', 'clerk', 'prod_head', 'director']): + return redirect('index') + return super().dispatch(request, *args, **kwargs) + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + + roles = get_user_group_roles(self.request.user) + role = primary_role(roles) + ctx['user_role'] = role + ctx['user_roles'] = sorted(roles) + ctx['can_edit'] = has_any_role(roles, ['admin', 'supply']) + + q = (self.request.GET.get('q') or '').strip() + filtered = (self.request.GET.get('filtered') or '').strip() + filtered_flag = filtered in ['1', 'true', 'yes', 'on'] + + types = [t for t in self.request.GET.getlist('types') if t] + statuses = [s for s in self.request.GET.getlist('statuses') if s] + grouped = (self.request.GET.get('grouped') or '').strip() in ['1', 'true', 'yes', 'on'] + + allowed_types = {c[0] for c in self.TYPE_CHOICES} + allowed_statuses = {c[0] for c in self.STATUS_CHOICES} + + is_default = not filtered_flag + + if is_default and not types: + types = list(allowed_types) + else: + types = [t for t in types if t in allowed_types] + + if is_default and not statuses: + statuses = [s for s in ['to_order', 'ordered'] if s in allowed_statuses] + else: + statuses = [s for s in statuses if s in allowed_statuses] + + proc_qs = ProcurementRequirement.objects.select_related('deal', 'component') + raw_qs = MaterialRequirement.objects.select_related('deal', 'material') + + if (not types) or (not statuses): + proc_qs = proc_qs.none() + raw_qs = raw_qs.none() + + if q: + proc_qs = proc_qs.filter( + Q(deal__number__icontains=q) + | Q(component__drawing_number__icontains=q) + | Q(component__name__icontains=q) + ) + raw_qs = raw_qs.filter( + Q(deal__number__icontains=q) + | Q(material__full_name__icontains=q) + | Q(material__name__icontains=q) + ) + + proc_type_map = { + 'purchased': 'purchased', + 'casting': 'casting', + 'outsourced': 'outsourced', + } + + proc_qs = proc_qs.filter(component__entity_type__in=list(proc_type_map.keys())) + if types: + proc_qs = proc_qs.filter(component__entity_type__in=[t for t in types if t in proc_type_map]) + + raw_status_map = { + 'needed': 'to_order', + 'ordered': 'ordered', + 'fulfilled': 'closed', + } + inv_raw_status_map = { + 'to_order': ['needed'], + 'ordered': ['ordered'], + 'closed': ['fulfilled'], + } + + if statuses: + proc_qs = proc_qs.filter(status__in=statuses) + raw_qs = raw_qs.filter(status__in=sum([inv_raw_status_map.get(s, []) for s in statuses], [])) + + requirements = [] + + if 'raw' in types: + for r in raw_qs.order_by('status', 'deal__number', 'material__full_name', 'id'): + requirements.append({ + 'kind': 'raw', + 'type': 'raw', + 'component_id': int(r.material_id), + 'component_label': str(r.material), + 'required_qty': float(r.required_qty), + 'unit': r.unit, + 'deal_id': int(r.deal_id), + 'deals': [str(r.deal.number)], + 'status': raw_status_map.get(r.status, 'to_order'), + 'row_id': f'raw_{r.id}', + 'obj_id': int(r.id), + }) + + for r in proc_qs.order_by('status', 'deal__number', 'component__drawing_number', 'component__name', 'id'): + requirements.append({ + 'kind': 'component', + 'type': str(r.component.entity_type), + 'component_id': int(r.component_id), + 'component_label': str(r.component), + 'required_qty': int(r.required_qty or 0), + 'unit': 'pcs', + 'deal_id': int(r.deal_id), + 'deals': [str(r.deal.number)], + 'status': str(r.status), + 'row_id': f'pr_{r.id}', + 'obj_id': int(r.id), + }) + + if grouped: + grouped_map = {} + for row in requirements: + key = (row['kind'], int(row['component_id'])) + g = grouped_map.get(key) + if not g: + grouped_map[key] = { + **row, + 'deals': list(row.get('deals') or []), + 'required_qty': row['required_qty'], + } + continue + + g['required_qty'] = (g.get('required_qty') or 0) + (row.get('required_qty') or 0) + for dn in row.get('deals') or []: + if dn not in g['deals']: + g['deals'].append(dn) + + p = {'to_order': 0, 'ordered': 1, 'closed': 2} + if p.get(row.get('status'), 0) < p.get(g.get('status'), 0): + g['status'] = row.get('status') + + requirements = list(grouped_map.values()) + requirements.sort(key=lambda x: (x.get('status') or '', x.get('component_label') or '', x.get('type') or '')) + + if self.request.GET.get('print'): + # Группируем для печати по типам + from collections import defaultdict + print_data = defaultdict(list) + for r in requirements: + # Если группировка выключена, но мы печатаем, можно оставить как есть + # Либо группировать по типу + t = r.get('type') or 'other' + print_data[t].append(r) + + ctx['print_data'] = dict(print_data) + ctx['type_labels'] = dict(self.TYPE_CHOICES) + self.template_name = 'shiftflow/procurement_print.html' + return ctx + + ctx['requirements'] = requirements + ctx['q'] = q + ctx['selected_types'] = types + ctx['type_choices'] = list(self.TYPE_CHOICES) + ctx['selected_statuses'] = statuses + ctx['status_choices'] = list(self.STATUS_CHOICES) + ctx['grouped'] = grouped + + # Исключаем склад «отгруженных/отгрузки» из приходов в панели снабжения. + ship_loc = ( + Location.objects.filter(Q(name__icontains='отгруж') | Q(name__icontains='отгруз')) + .order_by('id') + .first() + ) + ship_loc_id = ship_loc.id if ship_loc else None + + locations_qs = Location.objects.all().order_by('name') + if ship_loc_id: + locations_qs = locations_qs.exclude(id=ship_loc_id) + + ctx['locations'] = list(locations_qs) + ctx['deals'] = list(Deal.objects.all().order_by('-id')[:200]) + return ctx + + def post(self, request, *args, **kwargs): + roles = get_user_group_roles(request.user) + if not has_any_role(roles, ['admin', 'supply']): + return redirect('procurement') + + action = (request.POST.get('action') or '').strip() + next_url = (request.POST.get('next') or '').strip() + if not next_url.startswith('/'): + next_url = reverse_lazy('procurement') + + if action == 'mark_ordered': + pr_id = (request.POST.get('pr_id') or '').strip() + if pr_id.isdigit(): + ProcurementRequirement.objects.filter(id=int(pr_id), status='to_order').update(status='ordered') + messages.success(request, 'Отмечено как «Заказано».') + return redirect(next_url) + + if action == 'receive_component': + pr_id = (request.POST.get('pr_id') or '').strip() + location_id = (request.POST.get('location_id') or '').strip() + deal_id = (request.POST.get('deal_id') or '').strip() # опционально: привязать приход к сделке + qty_raw = (request.POST.get('quantity') or '').strip().replace(',', '.') + + if not (pr_id.isdigit() and location_id.isdigit()): + messages.error(request, 'Заполни корректно: склад и позиция потребности.') + return redirect(next_url) + + try: + qty = int(float(qty_raw)) + except ValueError: + qty = 0 + + if qty <= 0: + messages.error(request, 'Количество должно быть больше 0.') + return redirect(next_url) + + with transaction.atomic(): + pr = ProcurementRequirement.objects.select_for_update().select_related('component').filter(id=int(pr_id)).first() + if not pr: + messages.error(request, 'Потребность не найдена.') + return redirect(next_url) + + resolved_deal_id = int(deal_id) if deal_id.isdigit() else None + obj = StockItem( + entity_id=int(pr.component_id), + location_id=int(location_id), + deal_id=resolved_deal_id, + quantity=float(qty), + ) + obj.full_clean() + obj.save() + + cur = int(pr.required_qty or 0) + new_need = cur - int(qty) + if new_need <= 0: + pr.required_qty = 0 + pr.status = 'closed' + else: + pr.required_qty = int(new_need) + pr.status = 'ordered' + + pr.save(update_fields=['required_qty', 'status']) + + messages.success(request, 'Приход оформлен, потребность обновлена.') + return redirect(next_url) + + messages.error(request, 'Неизвестное действие.') + return redirect(next_url) + + +class LegacyWriteOffsView(LoginRequiredMixin, TemplateView): + template_name = 'shiftflow/legacy_writeoffs.html' + + def dispatch(self, request, *args, **kwargs): + roles = get_user_group_roles(request.user) + if not has_any_role(roles, ['admin', 'clerk', 'observer', 'prod_head', 'director']): + return redirect('registry') + return super().dispatch(request, *args, **kwargs) + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + + roles = get_user_group_roles(self.request.user) + role = primary_role(roles) + ctx['user_role'] = role + ctx['user_roles'] = sorted(roles) + ctx['can_edit'] = has_any_role(roles, ['admin', 'clerk', 'prod_head']) + + start_date = (self.request.GET.get('start_date') or '').strip() + end_date = (self.request.GET.get('end_date') or '').strip() + reset = self.request.GET.get('reset') + + if not start_date or not end_date or reset: + today = timezone.localdate() + start = today - timedelta(days=21) + start_date = start.strftime('%Y-%m-%d') + end_date = today.strftime('%Y-%m-%d') + + ctx['start_date'] = start_date + ctx['end_date'] = end_date + + reports_qs = ( + CuttingSession.objects.select_related('machine', 'operator') + .filter(is_closed=True, date__gte=start_date, date__lte=end_date) + .order_by('-date', '-id') + ) + + reports = list( + reports_qs.prefetch_related( + 'tasks__task__deal', + 'tasks__task__material', + 'consumptions__material', + 'consumptions__stock_item__material', + 'results__stock_item__material', + 'results__stock_item__entity', + 'remnants__material', + ) + ) + + report_cards = [] + for r in reports: + consumed = {} + for c in list(getattr(r, 'consumptions', []).all() if hasattr(getattr(r, 'consumptions', None), 'all') else []): + mat = None + if getattr(c, 'material_id', None): + mat = c.material + elif getattr(c, 'stock_item_id', None) and getattr(c.stock_item, 'material_id', None): + mat = c.stock_item.material + + label = str(mat) if mat else '—' + key = getattr(mat, 'id', None) or label + consumed[key] = consumed.get(key, 0.0) + float(c.quantity) + + produced = {} + remnants = {} + for res in list(getattr(r, 'results', []).all() if hasattr(getattr(r, 'results', None), 'all') else []): + si = res.stock_item + if res.kind == 'finished': + label = str(getattr(si, 'entity', None) or '—') + produced[label] = produced.get(label, 0.0) + float(si.quantity) + elif res.kind == 'remnant': + label = str(getattr(si, 'material', None) or '—') + remnants[label] = remnants.get(label, 0.0) + float(si.quantity) + + report_cards.append({ + 'report': r, + 'consumed': consumed, + 'produced': produced, + 'remnants': remnants, + 'tasks': list(getattr(r, 'tasks', []).all() if hasattr(getattr(r, 'tasks', None), 'all') else []), + }) + + ctx['report_cards'] = report_cards + + return ctx + + def post(self, request, *args, **kwargs): + roles = get_user_group_roles(request.user) + if not has_any_role(roles, ['admin', 'clerk', 'prod_head']): + return redirect('legacy_writeoffs') + + ids = request.POST.getlist('item_ids') + item_ids = [int(x) for x in ids if x.isdigit()] + if not item_ids: + messages.error(request, 'Не выбрано ни одного сменного задания.') + return redirect('legacy_writeoffs') + + Item.objects.filter(id__in=item_ids).update(is_synced_1c=True) + messages.success(request, f'Отмечено в 1С: {len(item_ids)}.') + start_date = (request.POST.get('start_date') or '').strip() + end_date = (request.POST.get('end_date') or '').strip() + return redirect(f"{reverse_lazy('legacy_writeoffs')}?start_date={start_date}&end_date={end_date}") + + +class LegacyClosingView(LoginRequiredMixin, TemplateView): + template_name = 'shiftflow/legacy_closing.html' + + def dispatch(self, request, *args, **kwargs): + roles = get_user_group_roles(request.user) + if not has_any_role(roles, ['admin', 'master', 'operator', 'observer', 'prod_head', 'director']): + return redirect('registry') + return super().dispatch(request, *args, **kwargs) + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + profile = getattr(self.request.user, 'profile', None) + roles = get_user_group_roles(self.request.user) + role = primary_role(roles) + ctx['user_role'] = role + ctx['user_roles'] = sorted(roles) + + if role == 'operator' and profile: + machines = list(profile.machines.all().order_by('name')) + else: + machines = list(Machine.objects.all().order_by('name')) + ctx['machines'] = machines + + ctx['materials'] = list(Material.objects.select_related('category').order_by('full_name')) + + machine_id = (self.request.GET.get('machine_id') or '').strip() + material_id = (self.request.GET.get('material_id') or '').strip() + ctx['selected_machine_id'] = machine_id + ctx['selected_material_id'] = material_id + + items = [] + stock_items = [] + + if machine_id.isdigit() and material_id.isdigit(): + items = list( + Item.objects.select_related('task', 'task__deal', 'task__material', 'machine') + .filter(machine_id=int(machine_id), status='work', task__material_id=int(material_id)) + .order_by('date', 'task__deal__number', 'task__drawing_name') + ) + + machine = Machine.objects.select_related('workshop', 'workshop__location', 'location').filter(pk=int(machine_id)).first() + work_location_id = None + if machine and getattr(machine, 'workshop_id', None) and getattr(machine.workshop, 'location_id', None): + work_location_id = machine.workshop.location_id + elif machine and getattr(machine, 'location_id', None): + work_location_id = machine.location_id + + if work_location_id: + stock_items = list( + StockItem.objects.select_related('location', 'material') + .filter(location_id=work_location_id, material_id=int(material_id), entity__isnull=True, is_archived=False) + .filter(quantity__gt=0) + .order_by('created_at', 'id') + ) + + ctx['items'] = items + ctx['stock_items'] = stock_items + + readonly = bool(getattr(profile, 'is_readonly', False)) if profile else False + ctx['can_edit'] = (has_any_role(roles, ['admin', 'master', 'operator', 'prod_head']) and not readonly) + return ctx + + def post(self, request, *args, **kwargs): + roles = get_user_group_roles(request.user) + if not has_any_role(roles, ['admin', 'master', 'operator', 'prod_head']): + return redirect('legacy_closing') + + profile = getattr(request.user, 'profile', None) + if bool(getattr(profile, 'is_readonly', False)) if profile else False: + messages.error(request, 'Доступ только для просмотра.') + return redirect('legacy_closing') + + machine_id = (request.POST.get('machine_id') or '').strip() + material_id = (request.POST.get('material_id') or '').strip() + + if not (machine_id.isdigit() and material_id.isdigit()): + messages.error(request, 'Выбери станок и материал.') + return redirect('legacy_closing') + + item_actions = {} + for k, v in request.POST.items(): + if not k.startswith('close_action_'): + continue + item_id = k.replace('close_action_', '') + if not item_id.isdigit(): + continue + action = (v or '').strip() + if action not in ['done', 'partial']: + continue + fact_raw = (request.POST.get(f'fact_{item_id}') or '').strip() + try: + fact = int(fact_raw) + except ValueError: + fact = 0 + item_actions[int(item_id)] = {'action': action, 'fact': fact} + + consumptions = {} + for k, v in request.POST.items(): + if not k.startswith('consume_'): + continue + sid = k.replace('consume_', '') + if not sid.isdigit(): + continue + raw = (v or '').strip().replace(',', '.') + if not raw: + continue + try: + qty = float(raw) + except ValueError: + qty = 0.0 + if qty > 0: + consumptions[int(sid)] = qty + + remnants = [] + idx = 0 + while True: + has_any = ( + f'remnant_qty_{idx}' in request.POST + or f'remnant_len_{idx}' in request.POST + or f'remnant_wid_{idx}' in request.POST + ) + if not has_any: + break + + qty_raw = (request.POST.get(f'remnant_qty_{idx}') or '').strip().replace(',', '.') + len_raw = (request.POST.get(f'remnant_len_{idx}') or '').strip().replace(',', '.') + wid_raw = (request.POST.get(f'remnant_wid_{idx}') or '').strip().replace(',', '.') + + if qty_raw: + try: + rq = float(qty_raw) + except ValueError: + rq = 0.0 + + if rq > 0: + rl = None + rw = None + + if len_raw: + try: + rl = float(len_raw) + except ValueError: + rl = None + + if wid_raw: + try: + rw = float(wid_raw) + except ValueError: + rw = None + + remnants.append({'quantity': rq, 'current_length': rl, 'current_width': rw}) + + idx += 1 + if idx > 60: + break + + try: + apply_closing( + user_id=request.user.id, + machine_id=int(machine_id), + material_id=int(material_id), + item_actions=item_actions, + consumptions=consumptions, + remnants=remnants, + ) + messages.success(request, 'Сохранено.') + except Exception as e: + messages.error(request, str(e)) + + return redirect(f"{reverse_lazy('legacy_closing')}?machine_id={machine_id}&material_id={material_id}") + + class ProductsView(LoginRequiredMixin, TemplateView): template_name = 'shiftflow/products.html' @@ -1883,11 +5076,14 @@ class ProductsView(LoginRequiredMixin, TemplateView): ctx['can_edit'] = role in ['admin', 'technologist'] q = (self.request.GET.get('q') or '').strip() - entity_type = (self.request.GET.get('entity_type') or '').strip() + entity_types = [x.strip() for x in self.request.GET.getlist('types') if (x or '').strip()] + allowed_types = {'product', 'assembly', 'part', 'casting'} + entity_types = [x for x in entity_types if x in allowed_types] + if not entity_types: + entity_types = ['product'] - qs = ProductEntity.objects.select_related('planned_material', 'route').all() - if entity_type: - qs = qs.filter(entity_type=entity_type) + qs = ProductEntity.objects.select_related('planned_material').all() + qs = qs.filter(entity_type__in=entity_types) if q: qs = qs.filter( @@ -1898,8 +5094,8 @@ class ProductsView(LoginRequiredMixin, TemplateView): ) ctx['q'] = q - ctx['entity_type'] = entity_type - ctx['products'] = qs.order_by('drawing_number', 'name') + ctx['entity_types'] = entity_types + ctx['products'] = qs.order_by('entity_type', 'drawing_number', 'name') return ctx def post(self, request, *args, **kwargs): @@ -1945,12 +5141,13 @@ class ProductDetailView(LoginRequiredMixin, TemplateView): role = profile.role if profile else ('admin' if self.request.user.is_superuser else 'operator') ctx['user_role'] = role ctx['can_edit'] = role in ['admin', 'technologist'] + ctx['can_add_to_deal'] = role in ['admin', 'technologist'] - entity = get_object_or_404(ProductEntity.objects.select_related('planned_material', 'route'), pk=int(self.kwargs['pk'])) + entity = get_object_or_404(ProductEntity.objects.select_related('planned_material'), pk=int(self.kwargs['pk'])) ctx['entity'] = entity lines = list( - BOM.objects.select_related('child', 'child__planned_material', 'child__route') + BOM.objects.select_related('child', 'child__planned_material', 'child__assembly_passport') .filter(parent=entity) .order_by('child__entity_type', 'child__drawing_number', 'child__name', 'id') ) @@ -1967,6 +5164,45 @@ class ProductDetailView(LoginRequiredMixin, TemplateView): parent_id = (self.request.GET.get('parent') or '').strip() ctx['parent_id'] = parent_id if parent_id.isdigit() else '' + raw_trail = (self.request.GET.get('trail') or '').strip() + trail_ids = [] + if raw_trail: + for part in raw_trail.split(','): + part = part.strip() + if part.isdigit(): + trail_ids.append(int(part)) + trail_ids = trail_ids[:20] + + bc_ids = trail_ids + [int(entity.id)] + bc_objs = {x.id: x for x in ProductEntity.objects.filter(id__in=bc_ids)} + + breadcrumbs = [] + for i, eid in enumerate(bc_ids): + obj = bc_objs.get(eid) + if not obj: + continue + t = ','.join(str(x) for x in bc_ids[:i]) + url = str(reverse_lazy('product_detail', kwargs={'pk': eid})) + if t: + url = f"{url}?trail={t}" + breadcrumbs.append({'id': eid, 'label': str(obj), 'url': url}) + + ctx['breadcrumbs'] = breadcrumbs + + back_url = '' + if trail_ids: + prev_id = int(trail_ids[-1]) + t = ','.join(str(x) for x in trail_ids[:-1]) + back_url = str(reverse_lazy('product_detail', kwargs={'pk': prev_id})) + if t: + back_url = f"{back_url}?trail={t}" + elif ctx['parent_id']: + back_url = str(reverse_lazy('product_detail', kwargs={'pk': int(ctx['parent_id'])})) + ctx['back_url'] = back_url + + child_trail = ','.join(str(x) for x in (trail_ids + [int(entity.id)])) + ctx['trail_child'] = child_trail + q_dn = (self.request.GET.get('q_dn') or '').strip() q_name = (self.request.GET.get('q_name') or '').strip() ctx['q_dn'] = q_dn @@ -2130,11 +5366,72 @@ class ProductInfoView(LoginRequiredMixin, TemplateView): role = profile.role if profile else ('admin' if self.request.user.is_superuser else 'operator') ctx['user_role'] = role ctx['can_edit'] = role in ['admin', 'technologist'] + ctx['can_add_to_deal'] = role in ['admin', 'technologist'] - entity = get_object_or_404(ProductEntity.objects.select_related('planned_material', 'route'), pk=int(self.kwargs['pk'])) + entity = get_object_or_404(ProductEntity.objects.select_related('planned_material'), pk=int(self.kwargs['pk'])) ctx['entity'] = entity ctx['materials'] = list(Material.objects.select_related('category').order_by('full_name')) - ctx['routes'] = list(RouteStub.objects.all().order_by('name')) + + ctx['entity_ops'] = list( + EntityOperation.objects.select_related('operation', 'operation__workshop') + .filter(entity_id=entity.id) + .order_by('seq', 'id') + ) + ctx['operations'] = list(Operation.objects.select_related('workshop').order_by('name')) + + next_url = (self.request.GET.get('next') or '').strip() + next_safe = next_url if next_url.startswith('/') else str(reverse_lazy('products')) + ctx['next'] = next_safe + + ctx['deals_for_add'] = list( + Deal.objects.filter(status='lead').select_related('company').order_by('-id')[:200] + ) + + raw_trail = (self.request.GET.get('trail') or '').strip() + trail_ids = [] + if raw_trail: + for part in raw_trail.split(','): + part = part.strip() + if part.isdigit(): + trail_ids.append(int(part)) + trail_ids = trail_ids[:20] + + bc_ids = trail_ids + [int(entity.id)] + bc_objs = {x.id: x for x in ProductEntity.objects.filter(id__in=bc_ids)} + + breadcrumbs = [] + for i, eid in enumerate(bc_ids): + obj = bc_objs.get(eid) + if not obj: + continue + t = ','.join(str(x) for x in bc_ids[:i]) + url = str(reverse_lazy('product_info', kwargs={'pk': eid})) + params = {'next': next_safe} + if t: + params['trail'] = t + url = f"{url}?{urlencode(params)}" + breadcrumbs.append({'id': eid, 'label': str(obj), 'url': url}) + + ctx['breadcrumbs'] = breadcrumbs + ctx['trail_child'] = ','.join(str(x) for x in (trail_ids + [int(entity.id)])) + + ctx['bom_lines'] = [] + if entity.entity_type in ['product', 'assembly']: + type_rank = Case( + When(child__entity_type='product', then=Value(1)), + When(child__entity_type='assembly', then=Value(2)), + When(child__entity_type='part', then=Value(3)), + When(child__entity_type='purchased', then=Value(4)), + When(child__entity_type='casting', then=Value(5)), + default=Value(99), + output_field=IntegerField(), + ) + ctx['bom_lines'] = list( + BOM.objects.select_related('child', 'child__planned_material', 'child__assembly_passport') + .filter(parent_id=entity.id) + .annotate(_type_rank=type_rank) + .order_by('_type_rank', 'child__drawing_number', 'child__name', 'id') + ) passport = None seams = [] @@ -2154,8 +5451,6 @@ class ProductInfoView(LoginRequiredMixin, TemplateView): ctx['passport'] = passport ctx['welding_seams'] = seams - next_url = (self.request.GET.get('next') or '').strip() - ctx['next'] = next_url if next_url.startswith('/') else reverse_lazy('products') return ctx def post(self, request, *args, **kwargs): @@ -2170,6 +5465,10 @@ class ProductInfoView(LoginRequiredMixin, TemplateView): if not next_url.startswith('/'): next_url = reverse_lazy('products') + trail = (request.POST.get('trail') or '').strip() + if trail and not all((p.strip().isdigit() for p in trail.split(',') if p.strip())): + trail = '' + def parse_int(value, default=None): s = (value or '').strip() if not s.isdigit(): @@ -2185,14 +5484,116 @@ class ProductInfoView(LoginRequiredMixin, TemplateView): except ValueError: return None - if action == 'create_route': - name = (request.POST.get('route_name') or '').strip() + + stay_url = str(reverse_lazy('product_info', kwargs={'pk': entity.id})) + params = {'next': str(next_url)} + if trail: + params['trail'] = trail + stay_url = f"{stay_url}?{urlencode(params)}" + + def parse_qty(value): + v = parse_int(value, default=0) + return v if v and v > 0 else None + + def would_cycle(parent_id: int, child_id: int) -> bool: + stack = [child_id] + seen = set() + while stack: + cur = stack.pop() + if cur == parent_id: + return True + if cur in seen: + continue + seen.add(cur) + stack.extend(list(BOM.objects.filter(parent_id=cur).values_list('child_id', flat=True))) + return False + + if action == 'bom_update_qty': + if entity.entity_type not in ['product', 'assembly']: + return redirect(stay_url) + bom_id = parse_int(request.POST.get('bom_id')) + qty = parse_qty(request.POST.get('quantity')) + if not bom_id or not qty: + messages.error(request, 'Заполни количество.') + return redirect(stay_url) + BOM.objects.filter(id=bom_id, parent_id=entity.id).update(quantity=qty) + messages.success(request, 'Количество обновлено.') + return redirect(stay_url) + + if action == 'bom_delete_line': + if entity.entity_type not in ['product', 'assembly']: + return redirect(stay_url) + bom_id = parse_int(request.POST.get('bom_id')) + if not bom_id: + messages.error(request, 'Не выбрана строка состава.') + return redirect(stay_url) + BOM.objects.filter(id=bom_id, parent_id=entity.id).delete() + messages.success(request, 'Компонент удалён из состава.') + return redirect(stay_url) + + if action == 'bom_add_existing': + if entity.entity_type not in ['product', 'assembly']: + return redirect(stay_url) + child_id = parse_int(request.POST.get('child_id')) + qty = parse_qty(request.POST.get('quantity')) + if not child_id or not qty: + messages.error(request, 'Выбери компонент и количество.') + return redirect(stay_url) + if child_id == entity.id: + messages.error(request, 'Нельзя добавить сущность саму в себя.') + return redirect(stay_url) + if would_cycle(int(entity.id), int(child_id)): + messages.error(request, 'Нельзя добавить: получится цикл в спецификации.') + return redirect(stay_url) + + obj, _ = BOM.objects.get_or_create(parent_id=entity.id, child_id=child_id, defaults={'quantity': qty}) + if obj.quantity != qty: + obj.quantity = qty + obj.save(update_fields=['quantity']) + + messages.success(request, 'Компонент добавлен.') + return redirect(stay_url) + + if action == 'bom_create_and_add': + if entity.entity_type not in ['product', 'assembly']: + return redirect(stay_url) + child_type = (request.POST.get('child_type') or '').strip() + name = (request.POST.get('name') or '').strip() + drawing_number = (request.POST.get('drawing_number') or '').strip() + qty = parse_qty(request.POST.get('quantity')) + planned_material_id = parse_int(request.POST.get('planned_material_id')) + + if child_type not in ['assembly', 'part', 'purchased', 'casting', 'outsourced']: + messages.error(request, 'Выбери корректный тип компонента.') + return redirect(stay_url) if not name: - messages.error(request, 'Заполни название маршрута.') - return redirect(next_url) - RouteStub.objects.get_or_create(name=name[:200]) - messages.success(request, 'Маршрут добавлен.') - return redirect(next_url) + messages.error(request, 'Заполни наименование компонента.') + return redirect(stay_url) + if not qty: + messages.error(request, 'Заполни количество.') + return redirect(stay_url) + + child = ProductEntity.objects.create( + entity_type=child_type, + name=name[:255], + drawing_number=drawing_number[:100], + planned_material_id=(planned_material_id if child_type == 'part' and planned_material_id else None), + ) + BOM.objects.create(parent_id=entity.id, child_id=child.id, quantity=qty) + messages.success(request, 'Компонент создан и добавлен.') + return redirect(stay_url) + + if action == 'add_entity_operation': + op_id = parse_int(request.POST.get('operation_id')) + if not op_id: + messages.error(request, 'Выбери операцию.') + return redirect(stay_url) + + last_seq = EntityOperation.objects.filter(entity_id=entity.id).order_by('-seq').values_list('seq', flat=True).first() + seq = int(last_seq or 0) + 1 + EntityOperation.objects.create(entity_id=entity.id, operation_id=op_id, seq=seq) + messages.success(request, 'Операция добавлена.') + return redirect(stay_url) if action == 'add_weld_seam': passport, _ = AssemblyPassport.objects.get_or_create(entity_id=entity.id) @@ -2236,6 +5637,54 @@ class ProductInfoView(LoginRequiredMixin, TemplateView): messages.success(request, 'Сварной шов удалён.') return redirect(next_url) + if action == 'delete_entity_operation': + eo_id = parse_int(request.POST.get('entity_operation_id')) + if not eo_id: + messages.error(request, 'Не выбрана операция.') + return redirect(stay_url) + EntityOperation.objects.filter(id=eo_id, entity_id=entity.id).delete() + + # Комментарий: после удаления перенумеровываем seq, чтобы не было дыр и конфликтов unique_together. + ops = list(EntityOperation.objects.filter(entity_id=entity.id).order_by('seq', 'id')) + for i, eo in enumerate(ops, start=1): + if eo.seq != i: + eo.seq = i + eo.save(update_fields=['seq']) + + messages.success(request, 'Операция удалена.') + return redirect(stay_url) + + if action == 'move_entity_operation': + eo_id = parse_int(request.POST.get('entity_operation_id')) + direction = (request.POST.get('direction') or '').strip() + if not eo_id or direction not in ['up', 'down']: + messages.error(request, 'Некорректное действие.') + return redirect(stay_url) + + eo = EntityOperation.objects.select_related('operation').filter(id=eo_id, entity_id=entity.id).first() + if not eo: + messages.error(request, 'Операция не найдена.') + return redirect(stay_url) + + ops = list(EntityOperation.objects.filter(entity_id=entity.id).order_by('seq', 'id')) + idx = next((i for i, x in enumerate(ops) if x.id == eo.id), None) + if idx is None: + return redirect(stay_url) + + swap_with = idx - 1 if direction == 'up' else idx + 1 + if swap_with < 0 or swap_with >= len(ops): + return redirect(stay_url) + + a = ops[idx] + b = ops[swap_with] + a_seq, b_seq = int(a.seq), int(b.seq) + EntityOperation.objects.filter(pk=a.id).update(seq=0) + EntityOperation.objects.filter(pk=b.id).update(seq=a_seq) + EntityOperation.objects.filter(pk=a.id).update(seq=b_seq) + + messages.success(request, 'Порядок обновлён.') + return redirect(stay_url) + if action != 'save': messages.error(request, 'Неизвестное действие.') return redirect(next_url) @@ -2249,8 +5698,6 @@ class ProductInfoView(LoginRequiredMixin, TemplateView): entity.passport_filled = bool(request.POST.get('passport_filled')) - route_id = parse_int(request.POST.get('route_id')) - entity.route_id = route_id if entity.entity_type == 'part': pm_id = parse_int(request.POST.get('planned_material_id')) @@ -2272,6 +5719,8 @@ class ProductInfoView(LoginRequiredMixin, TemplateView): if entity.entity_type in ['product', 'assembly']: passport, _ = AssemblyPassport.objects.get_or_create(entity_id=entity.id) + passport.requires_welding = bool(request.POST.get('requires_welding')) + passport.requires_painting = bool(request.POST.get('requires_painting')) passport.weight_kg = parse_float(request.POST.get('weight_kg')) passport.coating = (request.POST.get('coating') or '').strip()[:200] passport.coating_color = (request.POST.get('coating_color') or '').strip()[:100] @@ -2308,26 +5757,26 @@ class ProductInfoView(LoginRequiredMixin, TemplateView): passport.save() messages.success(request, 'Сохранено.') - return redirect(next_url) + return redirect(stay_url) class WriteOffsView(LoginRequiredMixin, TemplateView): template_name = 'shiftflow/writeoffs.html' def dispatch(self, request, *args, **kwargs): - profile = getattr(request.user, 'profile', None) - role = profile.role if profile else ('admin' if request.user.is_superuser else 'operator') - if role not in ['admin', 'clerk', 'observer']: + roles = get_user_group_roles(request.user) + if not has_any_role(roles, ['admin', 'clerk', 'observer', 'prod_head', 'director']): return redirect('registry') return super().dispatch(request, *args, **kwargs) def get_context_data(self, **kwargs): ctx = super().get_context_data(**kwargs) - profile = getattr(self.request.user, 'profile', None) - role = profile.role if profile else ('admin' if self.request.user.is_superuser else 'operator') + roles = get_user_group_roles(self.request.user) + role = primary_role(roles) ctx['user_role'] = role - ctx['can_edit'] = role in ['admin', 'clerk'] + ctx['user_roles'] = sorted(roles) + ctx['can_edit'] = has_any_role(roles, ['admin', 'clerk', 'prod_head']) start_date = (self.request.GET.get('start_date') or '').strip() end_date = (self.request.GET.get('end_date') or '').strip() @@ -2356,6 +5805,7 @@ class WriteOffsView(LoginRequiredMixin, TemplateView): 'consumptions__stock_item__material', 'results__stock_item__material', 'results__stock_item__entity', + 'remnants__material', ) ) @@ -2403,38 +5853,23 @@ class WriteOffsView(LoginRequiredMixin, TemplateView): return ctx def post(self, request, *args, **kwargs): - profile = getattr(request.user, 'profile', None) - role = profile.role if profile else ('admin' if request.user.is_superuser else 'operator') - if role not in ['admin', 'clerk']: + roles = get_user_group_roles(request.user) + if not has_any_role(roles, ['admin', 'clerk', 'prod_head']): return redirect('writeoffs') - ids = request.POST.getlist('item_ids') - item_ids = [int(x) for x in ids if x.isdigit()] - if not item_ids: - messages.error(request, 'Не выбрано ни одного сменного задания.') + ids = request.POST.getlist('report_ids') + report_ids = [int(x) for x in ids if x.isdigit()] + if not report_ids: + messages.error(request, 'Не выбрано ни одного производственного отчёта.') return redirect('writeoffs') - Item.objects.filter(id__in=item_ids).update(is_synced_1c=True) - messages.success(request, f'Отмечено в 1С: {len(item_ids)}.') + updated = CuttingSession.objects.filter(id__in=report_ids, is_synced_1c=False).update( + is_synced_1c=True, + synced_1c_at=timezone.now(), + synced_1c_by_id=request.user.id, + ) + messages.success(request, f'Отмечено «выгружено в 1С»: {updated}.') + start_date = (request.POST.get('start_date') or '').strip() end_date = (request.POST.get('end_date') or '').strip() - return redirect(f"{reverse_lazy('writeoffs')}?start_date={start_date}&end_date={end_date}") - - if not consumptions: - messages.error(request, 'Заполни списание: укажи, какие единицы на складе использованы и в каком количестве.') - return redirect(f"{reverse_lazy('closing')}?machine_id={machine_id}&material_id={material_id}") - - try: - apply_closing( - user_id=request.user.id, - machine_id=int(machine_id), - material_id=int(material_id), - item_actions=item_actions, - consumptions=consumptions, - remnants=remnants, - ) - messages.success(request, 'Закрытие выполнено.') - except Exception as e: - messages.error(request, f'Ошибка закрытия: {e}') - - return redirect(f"{reverse_lazy('closing')}?machine_id={machine_id}&material_id={material_id}") \ No newline at end of file + return redirect(f"{reverse_lazy('writeoffs')}?start_date={start_date}&end_date={end_date}") \ No newline at end of file diff --git a/static/css/style.css b/static/css/style.css index 541764d..4c1da6f 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -5,24 +5,26 @@ body { flex-direction: column; min-height: 100vh; /* Убрали общее центрирование, чтобы реестр был сверху */ - justify-content: flex-start; + justify-content: flex-start; } /* Навбар и Футер: жестко фиксируем цвет для обеих тем */ -.navbar, .footer-custom { +.navbar, +.footer-custom { /* Темный графит, который хорошо смотрится и там, и там */ - background-color: #2c3034 !important; + background-color: #2c3034 !important; border-bottom: 1px solid #3d4246 !important; - border-top: 1px solid #3d4246 !important; /* Для футера */ + border-top: 1px solid #3d4246 !important; + /* Для футера */ } /* Принудительно светлый текст для футера и навбара */ -.navbar .nav-link, -.navbar .navbar-brand, -.footer-custom span, +.navbar .nav-link, +.navbar .navbar-brand, +.footer-custom span, .footer-custom strong, .footer-custom .text-muted { - color: #e9ecef !important; + color: #e9ecef !important; } /* Состояние активной ссылки в меню */ @@ -32,7 +34,9 @@ body { } /* Цвет ссылок в темном навбаре, чтобы не сливались */ -.navbar .nav-link, .navbar .navbar-brand, .navbar .text-reset { +.navbar .nav-link, +.navbar .navbar-brand, +.navbar .text-reset { color: #e9ecef !important; } @@ -46,27 +50,48 @@ body { /* Подсветка при наведении */ .clickable-row:hover { - background-color: rgba(255, 193, 7, 0.05) !important; /* Легкий отсвет нашего акцента */ + background-color: rgba(255, 193, 7, 0.05) !important; + /* Легкий отсвет нашего акцента */ } /* --- ТЕМЫ --- */ [data-bs-theme="dark"] { - --bs-body-bg: #121212; /* Глубокий черный фон */ - --bs-body-color: #e9ecef; /* Светло-серый текст */ - --bs-accent: #ffc107; /* Желтый акцент (Amber) */ + --bs-body-bg: #121212; + /* Глубокий черный фон */ + --bs-body-color: #e9ecef; + /* Светло-серый текст */ + --bs-accent: #ffc107; + /* Желтый акцент (Amber) */ } [data-bs-theme="light"] { - --bs-body-bg: #f8f9fa; /* Почти белый фон */ - --bs-body-color: #212529; /* Темный текст */ - --bs-accent: #0d6efd; /* Синий акцент для светлой темы */ + --bs-body-bg: #f8f9fa; + /* Почти белый фон */ + --bs-body-color: #212529; + /* Темный текст */ + --bs-accent: #0d6efd; + /* Синий акцент для светлой темы */ } -[data-bs-theme="dark"] input[type="date"] { color-scheme: dark; } -[data-bs-theme="dark"] .form-control[type="date"] { background-color: #1e1e1e; border-color: #3d4246; color: #e9ecef; } -[data-bs-theme="dark"] input[type="date"]::-webkit-calendar-picker-indicator { filter: invert(1) brightness(1.4) contrast(1.2); opacity: 0.95; } -[data-bs-theme="light"] input[type="date"] { color-scheme: light; } +[data-bs-theme="dark"] input[type="date"] { + color-scheme: dark; +} + +[data-bs-theme="dark"] .form-control[type="date"] { + background-color: #1e1e1e; + border-color: #3d4246; + color: #e9ecef; +} + +[data-bs-theme="dark"] input[type="date"]::-webkit-calendar-picker-indicator { + filter: invert(1) brightness(1.4) contrast(1.2); + opacity: 0.95; +} + +[data-bs-theme="light"] input[type="date"] { + color-scheme: light; +} /* --- ТАБЛИЦА И КАРТОЧКИ --- */ @@ -75,7 +100,8 @@ body { background-color: #1e1e1e !important; color: var(--bs-accent) !important; font-size: 0.9rem; - text-transform: uppercase; /* Все буквы заглавные */ + text-transform: uppercase; + /* Все буквы заглавные */ } /* Фикс для таблиц в светлой теме */ @@ -88,7 +114,9 @@ body { /* --- ВСПОМОГАТЕЛЬНЫЕ КЛАССЫ --- */ /* Текст акцентного цвета */ -.text-accent { color: var(--bs-accent) !important; } +.text-accent { + color: var(--bs-accent) !important; +} /* Кнопка с контуром акцентного цвета */ .btn-outline-accent { @@ -96,7 +124,7 @@ body { border-color: var(--bs-accent) !important; } -.btn-check:checked + .btn-outline-accent, +.btn-check:checked+.btn-outline-accent, .btn-outline-accent.active, .btn-outline-accent:active { background-color: var(--bs-accent) !important; @@ -104,14 +132,14 @@ body { } [data-bs-theme="dark"] .btn-outline-accent:hover, -[data-bs-theme="dark"] .btn-check:checked + .btn-outline-accent, +[data-bs-theme="dark"] .btn-check:checked+.btn-outline-accent, [data-bs-theme="dark"] .btn-outline-accent.active, [data-bs-theme="dark"] .btn-outline-accent:active { color: #212529 !important; } [data-bs-theme="light"] .btn-outline-accent:hover, -[data-bs-theme="light"] .btn-check:checked + .btn-outline-accent, +[data-bs-theme="light"] .btn-check:checked+.btn-outline-accent, [data-bs-theme="light"] .btn-outline-accent.active, [data-bs-theme="light"] .btn-outline-accent:active { color: #ffffff !important; @@ -126,9 +154,33 @@ body { } /* Специальный класс для центрирования окна логина (вернем его только там) */ +.sf-attention { + animation: sfAttentionPulse 1.6s ease-in-out infinite; +} + +@keyframes sfAttentionPulse { + + 0%, + 100% { + box-shadow: 0 0 0 rgba(255, 193, 7, 0); + } + + 50% { + box-shadow: 0 0 0.9rem rgba(255, 193, 7, 0.35); + } +} + +@media (prefers-reduced-motion: reduce) { + .sf-attention { + animation: none; + } +} + .flex-center-center { display: flex; flex-grow: 1; - align-items: center; /* Центр по вертикали */ - justify-content: center; /* Центр по горизонтали */ + align-items: center; + /* Центр по вертикали */ + justify-content: center; + /* Центр по горизонтали */ } \ No newline at end of file diff --git a/templates/components/_add_to_deal.html b/templates/components/_add_to_deal.html new file mode 100644 index 0000000..a9d854e --- /dev/null +++ b/templates/components/_add_to_deal.html @@ -0,0 +1,60 @@ +{% if can_add_to_deal %} + + + +{% endif %} \ No newline at end of file diff --git a/templates/components/_navbar copy.html b/templates/components/_navbar copy.html deleted file mode 100644 index 27a6989..0000000 --- a/templates/components/_navbar copy.html +++ /dev/null @@ -1,61 +0,0 @@ - \ No newline at end of file diff --git a/templates/components/_navbar.html b/templates/components/_navbar.html index da7b989..fb33827 100644 --- a/templates/components/_navbar.html +++ b/templates/components/_navbar.html @@ -14,11 +14,24 @@ + + {% if user_role in 'admin,supply,observer,clerk,prod_head,director' %} + + {% endif %} {% if user_role in 'admin,technologist,master,clerk,observer' %} + + {% if user_role in 'admin,technologist,master,clerk' %} + + {% endif %} + @@ -27,24 +40,51 @@ {% endif %} - {% if user_role in 'admin,technologist,observer' or request.user.is_superuser %} + {% if user_role in 'admin,technologist,master,clerk,observer,prod_head,director' %} {% endif %} - {% if user_role in 'admin,master,operator,observer' %} + {% if user_role == 'admin' or user_role == 'technologist' or user_role == 'observer' or request.user.is_superuser %} {% endif %} - {% if user_role in 'admin,clerk,observer' %} + {% if user_role in 'admin,master,operator,prod_head' %} + + {% endif %} + + {% if user_role in 'admin,clerk,observer,prod_head,director' %} {% endif %} + + {% if user_role == 'admin' %}