Compare commits

..

11 Commits

Author SHA1 Message Date
909ba05b5d Доработал закрытие, техоперации автоматом переключаются, добавил выгрузку сменных заданий
All checks were successful
Deploy MES Core / deploy (push) Successful in 14s
2026-04-25 11:58:11 +03:00
6fd01c9a6e Добавил редактирование материалла на странице склада
All checks were successful
Deploy MES Core / deploy (push) Successful in 12s
2026-04-23 08:49:26 +03:00
963dc7105a ДО формируются под сделку
All checks were successful
Deploy MES Core / deploy (push) Successful in 12s
2026-04-23 08:36:57 +03:00
ae9c747c78 Выводим коментарие/если есть в список сменок
All checks were successful
Deploy MES Core / deploy (push) Successful in 16s
2026-04-23 07:45:40 +03:00
248f6987c8 Добавил при закрытии позиции сохранение в факт.выполнено для красивости прогресбаров
All checks were successful
Deploy MES Core / deploy (push) Successful in 12s
2026-04-23 00:04:01 +03:00
ede5358015 Починили закрытие сварки, доработали интерфейс
All checks were successful
Deploy MES Core / deploy (push) Successful in 13s
2026-04-22 23:43:58 +03:00
f60503d962 Отслеживаем компоненты без материала и техпроцесса
All checks were successful
Deploy MES Core / deploy (push) Successful in 12s
2026-04-22 09:04:51 +03:00
6d13e5a321 Ввел логику сделок через партии, дополнение 2
All checks were successful
Deploy MES Core / deploy (push) Successful in 12s
2026-04-22 08:58:23 +03:00
da8ef32769 Ввел логику сделок через партии дополнения
All checks were successful
Deploy MES Core / deploy (push) Successful in 11s
2026-04-22 08:41:29 +03:00
6da7b775c7 Ввел логику сделок через партии
All checks were successful
Deploy MES Core / deploy (push) Successful in 4m16s
2026-04-22 08:35:07 +03:00
3efd8e5060 Поднастроил правила и ввел чейнджлог 2026-04-16 23:43:58 +03:00
37 changed files with 3348 additions and 465 deletions

View File

@@ -1,90 +1,71 @@
# AI_RULES — правила работы ассистента в проекте MES_Core
Роль: Ты Senior Django Backend Developer.
# AI_RULES — MES_Core
Роль: Senior Django Backend Developer.
Контекст: Разрабатывается MES/ERP система для металлообрабатывающего завода. Архитектура БД разделена на 3 приложения: warehouse, manufacturing, shiftflow.
Контекст: miniMES / оперативное управление производством для металлообработки. Приложения: warehouse, manufacturing, shiftflow.
Задача: Разработать слой бизнес-логики (сервисы и CBV Views) для реализации сквозного процесса производства.
## MUST — правила, которые нельзя нарушать
# AI_RULES — правила работы ассистента в проекте MES_Core
### Коммуникация
- Писать по‑русски.
## 1) Коммуникация
- Пиши по-русски всегда.
### Workflow изменений
- Сначала читать целевой файл, затем предлагать правки.
- Сложная логика живёт в services (service layer), views остаются тонкими.
- Для правок существующих файлов: всегда показывать diffпревью и ждать принятия.
## 2) Изменения в коде
- Сначала читай файл и только потом предлагай правки (чтобы не ломать стиль и импорты).
### Новые файлы
- Для новых файлов всегда давать: полное имя + абсолютный путь + полный контент одним блоком.
## 3) Создание новых файлов
- Для новых файлов звсегда указывай: полное имя, абсолютный путь и весь контент в одном блоке.
### Безопасность
- Никогда не логировать/не печатать: SECRET_KEY, пароли БД, токены.
- В logs — только тех. сообщения/ошибки/диагностика без секретов.
- Для важных справочников/истории в models использовать on_delete=models.PROTECT.
## 4)Комментарии
- В Python/бекенде:
- добавляй поясняющие комментарии там, где есть бизнес-логика, транзакции, конкурентность, фоновые задачи, сложные алгоритмы (BOM, списания, начисления).
- комментарии должны быть нейтральными и описывать поведение/причину, без личных формулировок.
- В HTML-шаблонах Django:
- не добавляй template-комментарии {# ... #} .
### Транзакции и гонки (warehouse/shiftflow)
- Любые операции списания/начисления/перемещений — в transaction.atomic().
- Изменяемые складские остатки блокировать через select_for_update().
- Избегать N+1: select_related/prefetch_related, bulk операции — только где безопасно.
## 5) Стиль и конвенции проекта
- Смотри на соседние файлы и придерживайся уже принятого стиля (структура, именование, импорты, форматирование).
- Не вводи новые библиотеки/фреймворки, пока не проверил, что они уже используются в проекте.
- Для UI-таблиц:
- если добавляешь новую таблицу — по умолчанию делай её сортируемой (если не мешает UX).
### Роли и доступ
- Роли приложения — Django Groups (мульти‑роли). Имена групп совпадают с кодами ролей: operator/master/technologist/clerk/supply/prod_head/director/observer/admin.
- Проверка доступа во вьюхах: has_any_role(roles, [...]). primary_role — только для UI.
- На этапе миграции допускается fallback на EmployeeProfile.role, но новые правки ориентировать на группы.
- Использовать Service Layer: сложная логика живет в services.py, вьюхи остаются тонкими.
### Логи
- Для внутренних функций/сервисов: logger = logging.getLogger('mes').
- Перед выполнением: logger.info('fn:start ...').
- После успеха: logger.info('fn:done ...').
- Ошибки: logger.exception('fn:error ...') и пробрасывать дальше.
- Импорты моделей из других приложений — через строковые ссылки в полях ('app.Model') для избежания циклических импортов.
### Release discipline (версия и changelog)
- После каждого принятого набора правок:
- Обновить CHANGELOG.md в секции [Unreleased] (Added/Changed/Fixed).
- В конце ответа всегда писать: “Нужно ли поднять версию? Рекомендация: …”.
- Правило bump (SemVer):
- UI/шаблоны/стили/тексты → PATCH (x.y.Z).
- Логика/вьюхи/сервисы/модели/миграции/доступы → MINOR (x.Y.0).
- Изменения данных/совместимости → обсудить MAJOR (X.0.0), даже если проект ещё в 0.x.
## 6) Безопасность и секреты
- Никогда не логируй и не печатай в stdout:
- SECRET_KEY
- пароли БД
- токены
- В логи допускаются только технические сообщения, ошибки и диагностические данные без секретов.
- В models.py всегда использовать on_delete=models.PROTECT для важных справочников (Металл, Сделки), чтобы нельзя было случайно удалить историю.
## 7) Логи и фоновые задачи
- Для долгих операций (рендер превью, массовые обновления, BOM explosion для больших заказов):
- не блокируй HTTP-ответ
- Использовать модуль threading для запуска таких задач в отдельном потоке.
## SHOULD — правила, которые желательно соблюдать
- Обязательно оборачивать фоновую функцию в try/except и логировать ошибки в БД или файл, так как ошибки в потоках не видны во вьюхе.
- Логи фоновых задач должны быть:
- с датой/временем
- доступны из интерфейса “Обслуживание сервера” (tail)
- очищаемы кнопкой (если задача не running)
### Комментарии
- Python/бекенд: добавлять поясняющие комментариии там, где они нужны, без личных формулировок.
## 8) Транзакции и гонки данных (warehouse/shiftflow)
- Все операции списания/начисления на складе делай в transaction.atomic() .
- На изменяемые складские остатки используй select_for_update() чтобы избежать гонок.
- Для массовых операций избегай N+1:
- select_related / prefetch_related
- bulk update/create там, где это безопасно.
- Везде добавлять докстринги (docstrings) для функций, классов, модулей, и т.д.
## 9) Роли и доступ (Django Groups)
- Использовать Django Groups как роли приложения (мульти-роли).
- Имена групп должны совпадать с кодами ролей, используемых в коде, например:
- operator
- master
- technologist
- clerk
- supply
- prod_head
- director
- observer
- admin
- Назначение ролей в Django admin:
- Users → выбрать пользователя → поле Groups → добавить нужные группы → Save.
- Примечание: на этапе миграции допускается fallback на EmployeeProfile.role, чтобы при деплое до раздачи групп доступ не "слетал".
- Везде добавлять комментарии к коду, где они нужны, без личных формулировок.
- Django HTMLшаблоны: не добавлять templateкомментарии ({# ... #}).
### Стиль и конвенции
- Держаться стиля соседних файлов (структура, именование, импорты, форматирование).
- Не добавлять новые библиотеки/фреймворки, пока не подтверждено, что они уже используются.
### UI
- Если добавляется новая UIтаблица — по умолчанию делать сортируемой (если это не мешает UX).
### Назначение станков и цехов пользователю
- Привязка станков/цехов делается через профиль сотрудника:
- Shiftflow → Employee profiles → выбрать профиль пользователя.
- Привязка через профиль сотрудника:
- Machines: закреплённые станки (для операторов).
- Allowed workshops: доступные цеха (ограничение видимости/действий).
- Is readonly: режим "только просмотр".
Правило для новых внутренних функций (как договор):
- Всегда берём логгер logger = logging.getLogger('mes')
- Перед выполнением — logger.info('fn:start ...', ключевые параметры)
- После успешного выполнения — logger.info('fn:done ...', ключевые результаты)
- На важных шагах — logger.info('fn:step ...', детали)
- Исключение — с context: logger.exception('fn:error ...') — не глотаем, пробрасываем дальше
- Is readonly: режим только просмотр.

44
CHANGELOG.md Normal file
View File

@@ -0,0 +1,44 @@
# Changelog
Все заметные изменения в этом проекте документируются в этом файле.
Формат — по мотивам “Keep a Changelog”, версионирование — SemVer (пока 0.x).
- UI/шаблоны/стили/тексты → увеличиваем PATCH (x.y.Z)
- Логика/вьюхи/сервисы/модели/миграции/доступы → увеличиваем MINOR (x.Y.0)
- Изменения, влияющие на данные/совместимость → обсуждаем MAJOR (X.0.0), даже если проект ещё в 0.x
## [Unreleased]
### Added
- Журнал отгрузки: список документов перемещения на «Склад отгруженных позиций».
- Реестр заданий: выгрузка сменного задания в архив ZIP (HTML как в «Печать», TXT и manifest) с прикреплением файлов КД по материалам.
### Changed
- Версия приложения: 0.9.3.
- Отгрузка: можно добавлять несколько сделок в одну сессию отгрузки, выбирать позиции и подтверждать общий список.
- Печать реестра WorkItem: добавлен вывод длины заготовки для позиций без чертежей (типовой случай ленточнопилы); добавлена опциональная плашка «Сформировано …» для экспортируемого HTML.
- Сменные задания: кнопка «Комплектация» показывается только на первой операции сборки/изделия, добавлена кнопка «Доп. расходы» (в разработке).
- Журнал отгрузки: добавлены фильтр по периоду (по умолчанию 2 недели) и поиск по сделкам (номер/описание/заказчик), убран столбец «Куда».
- Списание / Производство: в блоках «Списано» и «Остаток ДО» выводится масса материалов (по размерам и «Масса на ед. учёта»); если масса не задана — показывается прочерк.
- Закрытие: деловой остаток (ДО) может наследовать сделку от списанного сырья (отключается чекбоксом) и доступен к отгрузке как сырьё по сделке.
- Реестр заданий: комментарий сменного задания/операции отображается под наименованием.
- Склады: по клику по строке сырья/ДО открывается модальное окно редактирования позиции.
- Паспорта изделий/компонентов: ссылки на PDF/DXF/картинки отображаются иконками и открываются в новой вкладке.
- Паспорта изделий/сборок: блок «Состав» перенесён в верхнюю часть страницы, в таблицу состава добавлена колонка «Файлы».
- Производственные задачи и прогресс техпроцесса ведутся в разрезе партий поставки (серий) для одной сделки.
- Улучшено сообщение о блокировке запуска «В производство» при отсутствии техпроцесса или материала: показывается модалка и отдельная страница со списком проблемных позиций.
### Fixed
- Выгрузка сменного задания: имена файлов КД формируются с суффиксом nXX как количеством деталей к изготовлению (а не порядковым номером файла).
- Закрытие первой операции детали: запрещено закрытие без выбранного поста/станка (чтобы не было закрытия без списания сырья).
- Закрытие сборки/изделия: после закрытия выполняется возврат в реестр.
- Закрытие последующих операций (например, покраски): после полного закрытия выполняется возврат в реестр.
- Закрытие последующих операций: при нехватке изделий на складе участка показывается окно перемещения на участок.
- Починено закрытие сборок/изделий на странице «Закрыть сборку»: выбор поста доступен и сохраняется, списание/выпуск выполняются.
- Закрытие сборки/изделия: при закрытии первой операции корректно продвигается текущая операция техпроцесса (переход на следующую, например на покраску).
- Закрытие операций: при полном выполнении операции автоматически продвигается маршрут и создаётся следующее сменное задание (например, после резки — гибка, после сварки — покраска).
- Запуск «В производство» блокируется, если в BOM есть узлы без техпроцесса (EntityOperation seq=1), чтобы компоненты не попадали в «без техпроцесса».
- Повторный запуск в производство по новой серии не увеличивает объём в уже закрытых задачах прошлых серий.
## [0.7.1] - 2026-04-16
### Added
- Введён CHANGELOG.md и процесс ведения истории изменений.

View File

@@ -30,7 +30,7 @@ if os.path.exists(env_file):
# читаем переменную окружения
ENV_TYPE = os.getenv('ENV_TYPE', 'local')
APP_VERSION = '0.7.1'
APP_VERSION = '0.9.3'
# Настройки безопасности
# DEBUG будет True везде, кроме сервера

View File

@@ -8,6 +8,7 @@ from .models import (
Company,
CuttingSession,
Deal,
DealDeliveryBatch,
DealItem,
DxfPreviewJob,
DxfPreviewSettings,
@@ -28,6 +29,7 @@ _models_to_reregister = (
Company,
CuttingSession,
Deal,
DealDeliveryBatch,
DealItem,
DxfPreviewJob,
DxfPreviewSettings,
@@ -77,6 +79,14 @@ class DealAdmin(admin.ModelAdmin):
list_filter = ('status', 'company')
inlines = (DealItemInline,)
# --- Настройка отображения Партий поставки ---
@admin.register(DealDeliveryBatch)
class DealDeliveryBatchAdmin(admin.ModelAdmin):
list_display = ('deal', 'due_date', 'name', 'is_default', 'created_at')
list_filter = ('is_default', 'due_date', 'deal')
search_fields = ('deal__number', 'name')
autocomplete_fields = ('deal',)
# --- Задания на производство (База) ---
"""
Панель администрирования Заданий на производство
@@ -148,17 +158,17 @@ class ItemAdmin(admin.ModelAdmin):
@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')
list_display = ('date', 'deal', 'delivery_batch', 'entity', 'operation', 'workshop', 'machine', 'quantity_plan', 'quantity_done', 'status')
list_filter = ('date', 'status', 'workshop', 'machine', 'operation', 'delivery_batch')
search_fields = ('deal__number', 'entity__name', 'entity__drawing_number', 'operation__name', 'operation__code')
autocomplete_fields = ('deal', 'entity', 'operation', 'workshop', 'machine')
autocomplete_fields = ('deal', 'delivery_batch', 'entity', 'operation', 'workshop', 'machine')
@admin.register(DealEntityProgress)
class DealEntityProgressAdmin(admin.ModelAdmin):
list_display = ('deal', 'entity', 'current_seq')
list_display = ('deal', 'delivery_batch', 'entity', 'current_seq')
search_fields = ('deal__number', 'entity__name', 'entity__drawing_number')
autocomplete_fields = ('deal', 'entity')
autocomplete_fields = ('deal', 'delivery_batch', 'entity')
@admin.register(Workshop)

View File

@@ -0,0 +1,38 @@
# Generated by Django 6.0.3 on 2026-04-22 05:13
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('manufacturing', '0007_remove_productentity_route_delete_routestub'),
('shiftflow', '0034_workitem_quantity_reported_and_more'),
]
operations = [
migrations.AlterUniqueTogether(
name='dealentityprogress',
unique_together=set(),
),
migrations.AddField(
model_name='dealentityprogress',
name='delivery_batch',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='entity_progress', to='shiftflow.dealdeliverybatch', verbose_name='Партия поставки'),
),
migrations.AddField(
model_name='productiontask',
name='delivery_batch',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='production_tasks', to='shiftflow.dealdeliverybatch', verbose_name='Партия поставки'),
),
migrations.AddField(
model_name='workitem',
name='delivery_batch',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='workitems', to='shiftflow.dealdeliverybatch', verbose_name='Партия поставки'),
),
migrations.AlterUniqueTogether(
name='dealentityprogress',
unique_together={('deal', 'delivery_batch', 'entity')},
),
]

View File

@@ -97,6 +97,14 @@ class ProductionTask(models.Model):
"""
deal = models.ForeignKey(Deal, on_delete=models.CASCADE, verbose_name="Сделка")
delivery_batch = models.ForeignKey(
'shiftflow.DealDeliveryBatch',
on_delete=models.PROTECT,
null=True,
blank=True,
related_name='production_tasks',
verbose_name='Партия поставки',
)
entity = models.ForeignKey('manufacturing.ProductEntity', on_delete=models.PROTECT, null=True, blank=True, verbose_name="КД (изделие/деталь)")
drawing_name = models.CharField("Название детали", max_length=255, blank=True, default="Б/ч")
@@ -227,20 +235,28 @@ class DealBatchItem(models.Model):
class DealEntityProgress(models.Model):
"""Текущая операция техпроцесса для пары (сделка, сущность).
"""Текущая операция техпроцесса для пары (сделка, партия, сущность).
Комментарий: current_seq=1 означает «выполняем 1-ю операцию в EntityOperation».
Когда current_seq больше числа операций — сущность для сделки считается прошедшей техпроцесс.
Когда current_seq больше числа операций — сущность для партии считается прошедшей техпроцесс.
"""
deal = models.ForeignKey(Deal, on_delete=models.CASCADE, verbose_name='Сделка')
delivery_batch = models.ForeignKey(
'shiftflow.DealDeliveryBatch',
on_delete=models.PROTECT,
null=True,
blank=True,
related_name='entity_progress',
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')
unique_together = ('deal', 'delivery_batch', 'entity')
def __str__(self):
return f"{self.deal.number}: {self.entity} -> {self.current_seq}"
@@ -309,6 +325,14 @@ class ProcurementRequirement(models.Model):
class WorkItem(models.Model):
deal = models.ForeignKey(Deal, on_delete=models.CASCADE, verbose_name='Сделка')
delivery_batch = models.ForeignKey(
'shiftflow.DealDeliveryBatch',
on_delete=models.PROTECT,
null=True,
blank=True,
related_name='workitems',
verbose_name='Партия поставки',
)
entity = models.ForeignKey('manufacturing.ProductEntity', on_delete=models.PROTECT, verbose_name='Сущность')
# Комментарий: operation — основной признак операции (расширяемый справочник).

View File

@@ -1,12 +1,22 @@
import logging
from django.db import transaction
from django.db.models import Q, Case, When, Value, IntegerField
from django.db.models import Q, Case, When, Value, IntegerField, Sum
from django.db.models.functions import Coalesce
from django.utils import timezone
from warehouse.models import StockItem
from shiftflow.models import WorkItem, CuttingSession, ProductionReportConsumption, ProductionReportStockResult
from shiftflow.models import (
DealEntityProgress,
DealItem,
ProductionTask,
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
from shiftflow.services.route_flow import advance_progress_and_generate_next_workitem
from manufacturing.models import EntityOperation, Operation
def get_first_operation_id(entity_id: int) -> int | None:
@@ -185,9 +195,63 @@ def apply_assembly_closing(workitem_id: int, fact_qty: int, user_id: int) -> boo
# Двигаем техпроцесс
workitem.quantity_done = (workitem.quantity_done or 0) + fact_qty
workitem.quantity_reported = max(int(workitem.quantity_reported or 0), int(workitem.quantity_done or 0))
if workitem.quantity_done >= workitem.quantity_plan:
workitem.status = 'done'
workitem.save(update_fields=['quantity_done', 'status'])
workitem.save(update_fields=['quantity_done', 'quantity_reported', 'status'])
advance_progress_and_generate_next_workitem(workitem_id=int(workitem.id))
target_qty = None
if getattr(workitem, 'delivery_batch_id', None):
target_qty = ProductionTask.objects.filter(
deal_id=workitem.deal_id,
delivery_batch_id=workitem.delivery_batch_id,
entity_id=workitem.entity_id,
).values_list('quantity_ordered', flat=True).first()
else:
di = DealItem.objects.filter(deal_id=workitem.deal_id, entity_id=workitem.entity_id).first()
target_qty = int(di.quantity) if di else None
if target_qty is not None:
op_code = ''
if getattr(workitem, 'operation_id', None):
op_code = (Operation.objects.filter(pk=workitem.operation_id).values_list('code', flat=True).first() or '').strip()
if not op_code:
op_code = (workitem.stage or '').strip()
if op_code:
progress = (
DealEntityProgress.objects.select_for_update(of=('self',))
.filter(
deal_id=workitem.deal_id,
delivery_batch_id=(int(workitem.delivery_batch_id) if getattr(workitem, 'delivery_batch_id', None) else None),
entity_id=workitem.entity_id,
)
.first()
)
if not progress:
progress = DealEntityProgress.objects.create(
deal_id=workitem.deal_id,
delivery_batch_id=(int(workitem.delivery_batch_id) if getattr(workitem, 'delivery_batch_id', None) else None),
entity_id=workitem.entity_id,
current_seq=1,
)
cur = int(progress.current_seq or 1)
cur_eo = EntityOperation.objects.select_related('operation').filter(entity_id=workitem.entity_id, seq=cur).first()
if cur_eo and cur_eo.operation and (cur_eo.operation.code or '').strip() == op_code:
wi_qs = WorkItem.objects.filter(deal_id=workitem.deal_id, entity_id=workitem.entity_id).filter(
Q(operation__code=op_code) | Q(stage=op_code)
)
if getattr(workitem, 'delivery_batch_id', None):
wi_qs = wi_qs.filter(delivery_batch_id=workitem.delivery_batch_id)
else:
wi_qs = wi_qs.filter(delivery_batch_id__isnull=True)
total_done = wi_qs.aggregate(s=Coalesce(Sum('quantity_done'), 0))['s']
if int(total_done or 0) >= int(target_qty):
progress.current_seq = cur + 1
progress.save(update_fields=['current_seq'])
logger.info(
'assembly_closing:done workitem_id=%s qty=%s deal_id=%s location_id=%s user_id=%s report_id=%s',

View File

@@ -8,7 +8,7 @@ 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 manufacturing.models import BOM, EntityOperation, ProductEntity
from shiftflow.models import Deal, DealItem, ProcurementRequirement, ProductionTask
from warehouse.models import StockItem
@@ -16,9 +16,19 @@ logger = logging.getLogger('mes')
class ExplosionValidationError(Exception):
def __init__(self, missing_material_ids: list[int]):
super().__init__('missing_material')
def __init__(
self,
*,
missing_material_ids: list[int] | None = None,
missing_route_entity_ids: list[int] | None = None,
):
self.missing_material_ids = [int(x) for x in (missing_material_ids or [])]
self.missing_route_entity_ids = [int(x) for x in (missing_route_entity_ids or [])]
if self.missing_route_entity_ids:
super().__init__('missing_tech_process')
else:
super().__init__('missing_material')
@dataclass(frozen=True)
@@ -338,6 +348,8 @@ def explode_deal(
def explode_roots_additive(
deal_id: int,
roots: list[tuple[int, int]],
*,
delivery_batch_id: int | None = None,
) -> ExplosionStats:
"""Additive BOM Explosion для запуска в производство по частям.
@@ -370,13 +382,34 @@ def explode_roots_additive(
.filter(id__in=list(required_nodes.keys()))
}
missing = [
missing_material = [
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 (
(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)
if missing_material:
raise ExplosionValidationError(missing_material_ids=missing_material)
internal_types = {'part', 'assembly', 'product'}
internal_ids = [
int(e.id)
for e in entities.values()
if (
(getattr(e, 'entity_type', '') or '').strip() in internal_types
and int(required_nodes.get(int(e.id), 0) or 0) > 0
)
]
if internal_ids:
routed = set(
EntityOperation.objects.filter(entity_id__in=internal_ids, seq=1).values_list('entity_id', flat=True)
)
missing_route = sorted({int(x) for x in internal_ids} - {int(x) for x in routed})
if missing_route:
raise ExplosionValidationError(missing_route_entity_ids=missing_route)
tasks_created = 0
tasks_updated = 0
@@ -408,6 +441,7 @@ def explode_roots_additive(
pt, created = ProductionTask.objects.get_or_create(
deal=deal,
delivery_batch_id=(int(delivery_batch_id) if delivery_batch_id else None),
entity=entity,
defaults=defaults,
)
@@ -427,8 +461,9 @@ def explode_roots_additive(
tasks_updated += 1
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',
'explode_roots_additive: deal_id=%s batch_id=%s roots=%s nodes=%s tasks_created=%s tasks_updated=%s skipped_no_material=%s skipped_supply=%s',
deal_id,
delivery_batch_id,
roots,
len(required_nodes),
tasks_created,
@@ -443,6 +478,8 @@ def explode_roots_additive(
def rollback_roots_additive(
deal_id: int,
roots: list[tuple[int, int]],
*,
delivery_batch_id: int | None = None,
) -> ExplosionStats:
"""Откат additive BOM Explosion.
@@ -485,7 +522,11 @@ def rollback_roots_additive(
skipped_supply += 1
continue
pt = ProductionTask.objects.filter(deal=deal, entity=entity).first()
pt = ProductionTask.objects.filter(
deal=deal,
delivery_batch_id=(int(delivery_batch_id) if delivery_batch_id else None),
entity=entity,
).first()
if not pt:
missing_tasks += 1
continue
@@ -501,8 +542,9 @@ def rollback_roots_additive(
tasks_updated += 1
logger.info(
'rollback_roots_additive: deal_id=%s roots=%s nodes=%s tasks_updated=%s skipped_supply=%s missing_tasks=%s',
'rollback_roots_additive: deal_id=%s batch_id=%s roots=%s nodes=%s tasks_updated=%s skipped_supply=%s missing_tasks=%s',
deal_id,
delivery_batch_id,
roots,
len(required_nodes),
tasks_updated,

View File

@@ -11,6 +11,7 @@ from shiftflow.models import (
ShiftItem,
)
from shiftflow.services.sessions import close_cutting_session
from shiftflow.services.route_flow import advance_progress_and_generate_next_workitem
logger = logging.getLogger('mes')
@@ -24,6 +25,7 @@ def apply_closing(
item_actions: dict[int, dict],
consumptions: dict[int, float],
remnants: list[dict],
inherit_deal_for_remnants: bool = False,
) -> None:
logger.info('apply_closing:start user=%s machine=%s material=%s items=%s consumptions=%s remnants=%s', user_id, machine_id, material_id, list(item_actions.keys()), list(consumptions.keys()), len(remnants))
@@ -93,7 +95,7 @@ def apply_closing(
)
logger.info('apply_closing:close_session id=%s', report.id)
close_cutting_session(report.id)
close_cutting_session(report.id, inherit_deal_for_remnants=bool(inherit_deal_for_remnants))
for it in items:
spec = item_actions.get(it.id) or {}
@@ -142,6 +144,7 @@ def apply_closing_workitems(
item_actions: dict[int, dict], # workitem_id -> {'action': 'done'|'partial', 'fact': int}
consumptions: dict[int, float],
remnants: list[dict],
inherit_deal_for_remnants: bool = False,
) -> 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))
@@ -185,7 +188,12 @@ def apply_closing_workitems(
if fact <= 0:
raise RuntimeError('При частичном закрытии факт должен быть больше 0.')
pt = ProductionTask.objects.filter(deal_id=wi.deal_id, entity_id=wi.entity_id).first()
pt_qs = ProductionTask.objects.filter(deal_id=wi.deal_id, entity_id=wi.entity_id)
if getattr(wi, 'delivery_batch_id', None):
pt_qs = pt_qs.filter(delivery_batch_id=wi.delivery_batch_id)
else:
pt_qs = pt_qs.filter(delivery_batch_id__isnull=True)
pt = pt_qs.first()
if not pt:
raise RuntimeError('Не найден ProductionTask для задания.')
@@ -193,13 +201,15 @@ def apply_closing_workitems(
created_shift += 1
wi.quantity_done = done_total + fact
wi.quantity_reported = max(int(wi.quantity_reported or 0), int(wi.quantity_done or 0))
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'])
wi.save(update_fields=['quantity_done', 'quantity_reported', 'status'])
advance_progress_and_generate_next_workitem(workitem_id=int(wi.id))
for stock_item_id, qty in consumptions.items():
if qty and float(qty) > 0:
@@ -218,5 +228,5 @@ def apply_closing_workitems(
unique_id=None,
)
close_cutting_session(report.id)
close_cutting_session(report.id, inherit_deal_for_remnants=bool(inherit_deal_for_remnants))
logger.info('apply_closing_workitems:done report=%s shift_items=%s', report.id, created_shift)

View File

@@ -0,0 +1,134 @@
import logging
from django.db import transaction
from django.db.models import Q, Sum
from django.db.models.functions import Coalesce
from django.utils import timezone
from manufacturing.models import EntityOperation
from shiftflow.models import DealEntityProgress, DealItem, ProductionTask, WorkItem
logger = logging.getLogger('mes')
def _workitem_op_code(wi: WorkItem) -> str:
if getattr(wi, 'operation_id', None) and getattr(wi, 'operation', None):
code = (wi.operation.code or '').strip()
if code:
return code
return (wi.stage or '').strip()
def _target_qty_for_workitem(wi: WorkItem) -> int | None:
if getattr(wi, 'delivery_batch_id', None):
qty = (
ProductionTask.objects.filter(
deal_id=wi.deal_id,
delivery_batch_id=wi.delivery_batch_id,
entity_id=wi.entity_id,
)
.values_list('quantity_ordered', flat=True)
.first()
)
return int(qty) if qty is not None else None
di = DealItem.objects.filter(deal_id=wi.deal_id, entity_id=wi.entity_id).first()
return int(di.quantity) if di else None
@transaction.atomic
def advance_progress_and_generate_next_workitem(*, workitem_id: int) -> int | None:
wi = (
WorkItem.objects.select_for_update(of=('self',))
.select_related('operation')
.filter(id=int(workitem_id))
.first()
)
if not wi:
return None
op_code = _workitem_op_code(wi)
if not op_code:
return None
target_qty = _target_qty_for_workitem(wi)
if target_qty is None:
return None
progress, _ = DealEntityProgress.objects.select_for_update(of=('self',)).get_or_create(
deal_id=wi.deal_id,
delivery_batch_id=(int(wi.delivery_batch_id) if getattr(wi, 'delivery_batch_id', None) else None),
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 not cur_eo or not cur_eo.operation:
return None
cur_code = (cur_eo.operation.code or '').strip()
if cur_code != op_code:
return None
wi_qs = WorkItem.objects.filter(deal_id=wi.deal_id, entity_id=wi.entity_id).filter(Q(operation__code=op_code) | Q(stage=op_code))
if getattr(wi, 'delivery_batch_id', None):
wi_qs = wi_qs.filter(delivery_batch_id=wi.delivery_batch_id)
else:
wi_qs = wi_qs.filter(delivery_batch_id__isnull=True)
total_done = wi_qs.aggregate(s=Coalesce(Sum('quantity_done'), 0))['s']
if int(total_done or 0) < int(target_qty):
return None
progress.current_seq = cur + 1
progress.save(update_fields=['current_seq'])
next_eo = (
EntityOperation.objects.select_related('operation', 'operation__workshop')
.filter(entity_id=wi.entity_id, seq=int(progress.current_seq))
.first()
)
if not next_eo or not next_eo.operation:
return None
next_op = next_eo.operation
next_code = (next_op.code or '').strip()
planned_qs = WorkItem.objects.filter(deal_id=wi.deal_id, entity_id=wi.entity_id)
if getattr(wi, 'delivery_batch_id', None):
planned_qs = planned_qs.filter(delivery_batch_id=wi.delivery_batch_id)
else:
planned_qs = planned_qs.filter(delivery_batch_id__isnull=True)
planned_total = planned_qs.filter(Q(operation_id=next_op.id) | Q(operation__code=next_code) | Q(stage=next_code)).aggregate(
s=Coalesce(Sum('quantity_plan'), 0)
)['s']
remaining_to_plan = max(0, int(target_qty) - int(planned_total or 0))
if remaining_to_plan <= 0:
return None
created = WorkItem.objects.create(
deal_id=wi.deal_id,
delivery_batch_id=(int(wi.delivery_batch_id) if getattr(wi, 'delivery_batch_id', None) else None),
entity_id=wi.entity_id,
operation_id=next_op.id,
stage=(next_code or next_op.name or '')[:32],
workshop_id=(int(next_op.workshop_id) if getattr(next_op, 'workshop_id', None) else None),
machine_id=None,
quantity_plan=int(remaining_to_plan),
quantity_done=0,
status='planned',
date=timezone.localdate(),
)
logger.info(
'route_flow:created_next_workitem id=%s deal_id=%s batch_id=%s entity_id=%s op=%s qty=%s',
created.id,
created.deal_id,
getattr(created, 'delivery_batch_id', None),
created.entity_id,
next_code or '',
created.quantity_plan,
)
return int(created.id)

View File

@@ -17,7 +17,7 @@ logger = logging.getLogger('mes')
@transaction.atomic
def close_cutting_session(session_id: int) -> None:
def close_cutting_session(session_id: int, *, inherit_deal_for_remnants: bool = False) -> None:
"""
Закрытие CuttingSession (транзакция склада).
@@ -56,6 +56,8 @@ def close_cutting_session(session_id: int) -> None:
raise RuntimeError('Не задан склад цеха для станка (Цех -> Склад цеха).')
consumed_material_ids: set[int] = set()
consumed_deal_ids: set[int] = set()
consumed_is_customer_supplied = False
consumptions = list(
ProductionReportConsumption.objects.select_related('material', 'stock_item', 'stock_item__material', 'stock_item__location')
@@ -72,6 +74,11 @@ def close_cutting_session(session_id: int) -> None:
si = StockItem.objects.select_for_update(of=('self',)).select_related('material', 'location').get(pk=c.stock_item_id)
logger.info('close_cutting_session:consume stock_item=%s qty=%s before=%s', si.id, c.quantity, si.quantity)
if getattr(si, 'deal_id', None):
consumed_deal_ids.add(int(si.deal_id))
if bool(getattr(si, 'is_customer_supplied', False)):
consumed_is_customer_supplied = True
if not si.material_id:
raise RuntimeError('В списании сырья указана позиция склада без material.')
@@ -129,6 +136,11 @@ def close_cutting_session(session_id: int) -> None:
used = StockItem.objects.select_for_update(of=('self',)).select_related('material', 'location').get(pk=session.used_stock_item_id)
logger.info('close_cutting_session:used stock_item=%s before=%s', used.id, used.quantity)
if getattr(used, 'deal_id', None):
consumed_deal_ids.add(int(used.deal_id))
if bool(getattr(used, 'is_customer_supplied', False)):
consumed_is_customer_supplied = True
if not used.material_id:
raise RuntimeError('Взятый материал должен ссылаться на сырьё (material), а не на готовую деталь (entity).')
@@ -191,13 +203,19 @@ def close_cutting_session(session_id: int) -> None:
)
ProductionReportStockResult.objects.create(report=session, stock_item=created, kind='finished')
remnant_deal_id = None
if inherit_deal_for_remnants and len(consumed_deal_ids) == 1:
remnant_deal_id = int(next(iter(consumed_deal_ids)))
remnants = list(ProductionReportRemnant.objects.filter(report=session).select_related('material'))
for r in remnants:
created = StockItem.objects.create(
material=r.material,
deal_id=remnant_deal_id,
location=work_location,
quantity=float(r.quantity),
is_remnant=True,
is_customer_supplied=bool(consumed_is_customer_supplied),
current_length=r.current_length,
current_width=r.current_width,
unique_id=r.unique_id,

View File

@@ -140,7 +140,6 @@ def build_shipment_rows(
is_archived=False,
quantity__gt=0,
material_id__isnull=False,
is_customer_supplied=True,
)
.exclude(location_id=int(shipping_location_id))
.values('material_id')
@@ -264,7 +263,6 @@ def create_shipment_transfers(
is_archived=False,
quantity__gt=0,
material_id=int(mat_id),
is_customer_supplied=True,
).select_related('material', 'location'),
float(q),
)

View File

@@ -0,0 +1,449 @@
"""
Сервис выгрузки сменного задания из реестра WorkItem в ZIP.
Задача:
- отдать оператору офлайн-пакет без сохранения на диск (всё в памяти);
- внутри архива:
- HTML (строго тот же шаблон, что и кнопка «Печать»),
- TXT (текстовая версия),
- manifest.txt,
- файлы КД (DXF/IGES/STEP/PDF) разложенные по папкам материала.
- выгружаем только сделки в статусе «В работе».
Особенности:
- опциональная транслитерация имён файлов/папок для совместимости с “железом” и Windows-путями;
- для типового случая ленточнопилы (нет чертежей/IGES) в HTML/TXT должна быть видна длина заготовки,
если она хранится в ProductEntity.blank_length_mm.
"""
from __future__ import annotations
import io
import logging
import re
import zipfile
from dataclasses import dataclass
from datetime import datetime
from django.template.loader import render_to_string
from django.utils import timezone
from shiftflow.models import WorkItem
logger = logging.getLogger("mes")
_RU_TRANSLIT_MAP = {
"а": "a",
"б": "b",
"в": "v",
"г": "g",
"д": "d",
"е": "e",
"ё": "yo",
"ж": "zh",
"з": "z",
"и": "i",
"й": "y",
"к": "k",
"л": "l",
"м": "m",
"н": "n",
"о": "o",
"п": "p",
"р": "r",
"с": "s",
"т": "t",
"у": "u",
"ф": "f",
"х": "h",
"ц": "ts",
"ч": "ch",
"ш": "sh",
"щ": "sch",
"ъ": "",
"ы": "y",
"ь": "",
"э": "e",
"ю": "yu",
"я": "ya",
}
_INVALID_WIN_CHARS_RE = re.compile(r'[<>:"/\\|?*\x00-\x1F]')
_MULTISPACE_RE = re.compile(r"\s+")
_MULTI_DOTS_RE = re.compile(r"\.{2,}")
def _transliterate_ru(s: str) -> str:
"""
Простейшая транслитерация RU->LAT для имён файлов.
Почему не slugify:
- стандартный slugify без сторонних библиотек не транслитерирует кириллицу (вырезает),
а нам важно сохранить читаемость.
"""
out: list[str] = []
for ch in (s or ""):
low = ch.lower()
if low in _RU_TRANSLIT_MAP:
t = _RU_TRANSLIT_MAP[low]
out.append(t.upper() if ch.isupper() and t else t)
else:
out.append(ch)
return "".join(out)
def _sanitize_fs_component(s: str) -> str:
"""
Делает строку безопасной как имя файла/папки (под Windows).
Правила:
- вычищаем недопустимые символы;
- нормализуем пробелы;
- не допускаем пустых имён;
- убираем точки/пробелы на конце (Windows).
"""
s = (s or "").strip()
s = _INVALID_WIN_CHARS_RE.sub("_", s)
s = _MULTISPACE_RE.sub(" ", s).strip()
s = _MULTI_DOTS_RE.sub(".", s)
s = s.strip(" .")
return s or "unnamed"
def _maybe_translit_and_sanitize(s: str, *, translit: bool) -> str:
"""
Применяет транслитерацию (если включено) и затем sanitizing.
"""
if translit:
s = _transliterate_ru(s)
return _sanitize_fs_component(s)
@dataclass(frozen=True)
class _ExportContext:
"""
Контекст выгрузки для форматов HTML/TXT и manifest.
"""
printed_at: datetime
print_date: datetime.date
export_bar_text: str
only_work_deals: bool
translit: bool
def _parse_print_date_from_request(request) -> datetime.date:
"""
Определяет «дату печати» так же, как это обычно делается в печатных формах:
берём конец периода, иначе начало, иначе сегодня.
"""
start_date = (request.GET.get("start_date") or "").strip()
end_date = (request.GET.get("end_date") or "").strip()
raw = end_date or start_date
if raw:
try:
return datetime.strptime(raw, "%Y-%m-%d").date()
except ValueError:
return timezone.localdate()
return timezone.localdate()
def _fetch_workitems_by_ids(*, workitem_ids: list[int]) -> list[WorkItem]:
"""Возвращает список WorkItem по id с нужными связями для рендера/экспорта."""
ids = [int(x) for x in (workitem_ids or []) if int(x) > 0]
if not ids:
return []
return list(
WorkItem.objects.select_related(
'deal',
'entity',
'entity__planned_material',
'operation',
'machine',
'workshop',
)
.filter(id__in=ids)
.order_by('-date', 'deal__number', 'id')
)
def _group_workitems(rows: list[WorkItem]) -> list[dict]:
"""
Группирует WorkItem так же, как печатная форма:
ключ = (цех, станок/пост), значение = список работ.
"""
groups: dict[tuple[str, str], dict] = {}
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)
return list(groups.values())
def _render_html(*, request, groups: list[dict], ctx: _ExportContext) -> str:
"""
Рендерит HTML строго тем же шаблоном, что и кнопка «Печать».
"""
context = {
"groups": groups,
"printed_at": ctx.printed_at,
"print_date": ctx.print_date,
"export_bar_text": ctx.export_bar_text,
}
return render_to_string("shiftflow/registry_workitems_print.html", context, request=request)
def _render_txt(*, groups: list[dict], ctx: _ExportContext) -> str:
"""
Формирует TXT-версию выгрузки.
Принцип:
- читаемо в блокноте;
- фиксированный порядок колонок;
- добавляем длину заготовки, если у сущности нет файла КД и задан blank_length_mm.
"""
lines: list[str] = []
lines.append("СМЕННОЕ ЗАДАНИЕ (выгрузка из MES)")
lines.append(f"{ctx.export_bar_text}")
if ctx.only_work_deals:
lines.append("Ограничение: только сделки в статусе «В работе».")
lines.append("")
for g in groups:
ws = (g.get("workshop") or "").strip()
m = (g.get("machine") or "").strip()
header = f"Цех: {ws}"
if m:
header += f" | Станок/пост: {m}"
lines.append(header)
lines.append("Дата\tСделка\tОперация\tПозиция\tМатериал\tПлан\tФакт")
for wi in (g.get("items") or []):
deal_no = getattr(getattr(wi, "deal", None), "number", "") or "-"
op_name = (getattr(getattr(wi, "operation", None), "name", "") or getattr(wi, "stage", "") or "").strip()
ent = getattr(wi, "entity", None)
dno = (getattr(ent, "drawing_number", "") or "").strip()
ename = (getattr(ent, "name", "") or "").strip()
pos = f"{dno} {ename}".strip() if dno or ename else ""
has_kd = bool(getattr(ent, "dxf_file", None))
length_mm = getattr(ent, "blank_length_mm", None)
if (not has_kd) and (not dno) and length_mm:
pos = f"{pos} | Длина: {int(round(float(length_mm)))} мм"
mat = getattr(ent, "planned_material", None)
mat_label = ""
if mat:
mat_label = (getattr(mat, "full_name", "") or getattr(mat, "name", "") or "").strip()
dt = wi.date.strftime("%d.%m.%y") if getattr(wi, "date", None) else ""
plan = str(int(getattr(wi, "quantity_plan", 0) or 0))
fact = str(int(getattr(wi, "quantity_done", 0) or 0))
lines.append(f"{dt}\t{deal_no}\t{op_name}\t{pos}\t{mat_label}\t{plan}\t{fact}")
lines.append("")
return "\n".join(lines).strip() + "\n"
def _iter_entity_kd_files(rows: list[WorkItem]) -> list[tuple[str, object]]:
"""
Собирает список файлов КД по всем уникальным сущностям в выгрузке.
Возвращаем список (kind, file_field):
- kind нужен для подсказок/manifest, но имя файла берём из исходного поля.
"""
seen: set[int] = set()
out: list[tuple[str, object]] = []
for wi in rows:
ent = getattr(wi, "entity", None)
if not ent:
continue
eid = int(getattr(ent, "id", 0) or 0)
if eid <= 0 or eid in seen:
continue
seen.add(eid)
dxf = getattr(ent, "dxf_file", None)
pdf = getattr(ent, "pdf_main", None)
if dxf:
out.append(("dxf_iges_step", dxf))
if pdf:
out.append(("pdf", pdf))
return out
def _zip_write_filefield(zf: zipfile.ZipFile, arc_path: str, file_field) -> int:
"""
Записывает FileField в архив.
Возвращает размер в байтах (для manifest).
"""
file_field.open("rb")
try:
data = file_field.read()
finally:
try:
file_field.close()
except Exception:
pass
zf.writestr(arc_path, data)
return len(data)
def build_workitem_registry_export_zip(
*,
request,
workitem_ids: list[int],
translit: bool,
only_work_deals: bool,
) -> tuple[bytes, str]:
"""
Главная точка входа сервиса.
Возвращает:
- zip_bytes: содержимое архива (в памяти)
- filename: имя файла для Content-Disposition
"""
logger.info(
"fn:start build_workitem_registry_export_zip user_id=%s workitems=%s translit=%s only_work_deals=%s",
getattr(getattr(request, "user", None), "id", None),
len(workitem_ids or []),
int(bool(translit)),
int(bool(only_work_deals)),
)
try:
printed_at = timezone.now()
print_date = _parse_print_date_from_request(request)
export_bar_text = f"Сформировано: {printed_at.strftime('%d.%m.%Y %H:%M')}"
ctx = _ExportContext(
printed_at=printed_at,
print_date=print_date,
export_bar_text=export_bar_text,
only_work_deals=bool(only_work_deals),
translit=bool(translit),
)
rows = _fetch_workitems_by_ids(workitem_ids=list(workitem_ids or []))
groups = _group_workitems(rows)
html = _render_html(request=request, groups=groups, ctx=ctx)
txt = _render_txt(groups=groups, ctx=ctx)
safe_ts = printed_at.strftime("%Y%m%d_%H%M%S")
base_name = f"shift_task_{safe_ts}"
if translit:
base_name = _maybe_translit_and_sanitize(base_name, translit=True)
zip_filename = f"{base_name}.zip"
buf = io.BytesIO()
manifest_lines: list[str] = []
manifest_lines.append("MES export manifest")
manifest_lines.append(f"generated_at={printed_at.isoformat()}")
manifest_lines.append(f"only_work_deals={int(bool(only_work_deals))}")
manifest_lines.append(f"translit={int(bool(translit))}")
manifest_lines.append(f"rows={len(rows)}")
manifest_lines.append(f"groups={len(groups)}")
manifest_lines.append("")
with zipfile.ZipFile(buf, "w", compression=zipfile.ZIP_DEFLATED) as zf:
html_name = "shift_task.html"
txt_name = "shift_task.txt"
man_name = "manifest.txt"
zf.writestr(html_name, html.encode("utf-8"))
zf.writestr(txt_name, txt.encode("utf-8"))
kd_entries: list[str] = []
entities: dict[int, object] = {}
entity_plan_qty: dict[int, int] = {}
for wi in rows:
ent = getattr(wi, 'entity', None)
if not ent:
continue
eid = int(getattr(ent, 'id', 0) or 0)
if eid <= 0:
continue
if eid not in entities:
entities[eid] = ent
qty = int(getattr(wi, 'quantity_plan', 0) or 0)
entity_plan_qty[eid] = int(entity_plan_qty.get(eid, 0) or 0) + max(0, qty)
for eid, ent in entities.items():
mat = getattr(ent, 'planned_material', None)
mat_label = 'Без_материала'
if mat:
mat_label = (getattr(mat, 'full_name', '') or getattr(mat, 'name', '') or 'Без_материала').strip() or 'Без_материала'
folder = _maybe_translit_and_sanitize(mat_label, translit=bool(translit))
dno = (getattr(ent, 'drawing_number', '') or '').strip()
ename = (getattr(ent, 'name', '') or '').strip()
base = f"{dno} {ename}".strip() or f"entity_{getattr(ent, 'id', '')}"
base = _maybe_translit_and_sanitize(base, translit=bool(translit))
plan_n = int(entity_plan_qty.get(int(eid), 0) or 0)
suffix = f" n{plan_n}" if plan_n > 0 else ""
for ff in [getattr(ent, 'dxf_file', None), getattr(ent, 'pdf_main', None)]:
if not ff:
continue
src_name = (getattr(ff, 'name', '') or '').strip()
ext = ''
if '.' in src_name:
ext = '.' + src_name.split('.')[-1].lower()
arc_name = f"{base}{suffix}{ext}"
arc_name = _maybe_translit_and_sanitize(arc_name, translit=False)
arc_path = f"kd/{folder}/{arc_name}"
try:
size = _zip_write_filefield(zf, arc_path, ff)
kd_entries.append(f"{arc_path}\t{size}")
except Exception:
logger.exception('fn:error export_kd_file arc_path=%s', arc_path)
kd_entries.append(f"{arc_path}\tERROR")
manifest_lines.append("files:")
manifest_lines.append(f"- {html_name}")
manifest_lines.append(f"- {txt_name}")
manifest_lines.append(f"- {man_name}")
manifest_lines.append("")
manifest_lines.append("kd_files:")
if kd_entries:
manifest_lines.extend(kd_entries)
else:
manifest_lines.append("(none)")
zf.writestr(man_name, ("\n".join(manifest_lines).strip() + "\n").encode("utf-8"))
buf.seek(0)
data = buf.getvalue()
logger.info(
"fn:done build_workitem_registry_export_zip bytes=%s rows=%s groups=%s",
len(data),
len(rows),
len(groups),
)
return data, zip_filename
except Exception:
logger.exception("fn:error build_workitem_registry_export_zip")
raise

View File

@@ -8,8 +8,16 @@
<h3 class="text-accent mb-0">
<i class="bi bi-check2-square me-2"></i>Закрытие сборки
</h3>
<div class="d-flex gap-2">
<a class="btn btn-outline-accent btn-sm" href="{% url 'workitem_kitting' workitem.id %}?next={{ request.get_full_path|urlencode }}">
<i class="bi bi-box-seam me-1"></i>Комплектация
</a>
<button type="button" class="btn btn-outline-secondary btn-sm" disabled title="В разработке">
<i class="bi bi-basket3 me-1"></i>Доп. расходы
</button>
<a class="btn btn-outline-secondary btn-sm" href="{% url 'workitem_detail' workitem.id %}">Назад к заданию</a>
</div>
</div>
<div class="card-body p-4">
<div class="mb-4">
@@ -77,22 +85,30 @@
</div>
</div>
<form method="post" action="">
<form method="post" action="{% url 'assembly_closing' workitem.id %}">
{% csrf_token %}
<input type="hidden" name="action" value="close">
<div class="row align-items-end g-2">
<div class="col-md-6">
<label class="form-label text-muted small mb-1">Фактически собрано (шт.)</label>
<input type="number" class="form-control border-secondary" name="fact_qty" min="1" max="{{ max_possible }}" value="{{ max_possible }}" {% if max_possible == 0 %}disabled{% endif %}>
<input
type="number"
class="form-control border-secondary"
name="fact_qty"
min="1"
{% if max_possible and max_possible > 0 %}max="{{ max_possible }}"{% endif %}
value="{% if max_possible and max_possible > 0 %}{{ max_possible }}{% else %}1{% endif %}"
required
>
</div>
<div class="col-md-6">
{% if workitem.machine_id %}
<button type="submit" class="btn btn-warning w-100" {% if max_possible == 0 %}disabled{% endif %}>
<button type="submit" class="btn btn-warning w-100">
Списать компоненты и закрыть сборку
</button>
{% else %}
<button type="button" class="btn btn-warning w-100" data-bs-toggle="modal" data-bs-target="#selectMachineModal" {% if max_possible == 0 %}disabled{% endif %}>
<button type="button" class="btn btn-warning w-100" data-bs-toggle="modal" data-bs-target="#selectMachineModal">
Выбрать пост и закрыть
</button>
{% endif %}
@@ -113,10 +129,10 @@
<div class="modal-body">
{% if workshop_machines %}
<label class="form-label small text-muted mb-1">Пост</label>
<select class="form-select border-secondary" name="machine_id" required>
<select class="form-select border-secondary" name="machine_id" {% if not workitem.machine_id %}required{% else %}disabled{% endif %}>
<option value="">— выбрать —</option>
{% for m in workshop_machines %}
<option value="{{ m.id }}">{{ m.name }}</option>
<option value="{{ m.id }}">{% if m.workshop %}{{ m.workshop.name }} · {% endif %}{{ m.name }}</option>
{% endfor %}
</select>
{% else %}

View File

@@ -132,7 +132,13 @@
<div class="card shadow border-secondary">
<div class="card-header border-secondary py-3 d-flex justify-content-between align-items-center">
<div class="d-flex flex-wrap gap-3 align-items-center">
<h5 class="mb-0">Остаток ДО</h5>
<div class="form-check mb-0">
<input class="form-check-input" type="checkbox" name="inherit_deal_for_remnants" id="inheritDealForRemnants" value="1" checked>
<label class="form-check-label" for="inheritDealForRemnants">Привязать ДО к сделке сырья</label>
</div>
</div>
<button type="button" class="btn btn-outline-accent btn-sm" id="addRemnantBtn" {% if not can_edit %}disabled{% endif %}>Добавить ДО</button>
</div>
<div class="table-responsive">

View File

@@ -135,7 +135,13 @@
<div class="card shadow border-secondary">
<div class="card-header border-secondary py-3 d-flex justify-content-between align-items-center">
<div class="d-flex flex-wrap gap-3 align-items-center">
<h5 class="mb-0">Остаток ДО</h5>
<div class="form-check mb-0">
<input class="form-check-input" type="checkbox" name="inherit_deal_for_remnants" id="inheritDealForRemnants" value="1" checked>
<label class="form-check-label" for="inheritDealForRemnants">Привязать ДО к сделке сырья</label>
</div>
</div>
<button type="button" class="btn btn-outline-accent btn-sm" id="addRemnantBtn" {% if not can_edit %}disabled{% endif %}>Добавить ДО</button>
</div>
<div class="table-responsive">

View File

@@ -0,0 +1,54 @@
{% extends 'base.html' %}
{% block content %}
<div class="card shadow border-secondary mb-3">
<div class="card-header border-secondary py-3 d-flex justify-content-between align-items-center">
<div>
<h3 class="text-accent mb-1">
<i class="bi bi-exclamation-triangle me-2"></i>{{ page_title|default:"Проблемные позиции" }}
</h3>
<div class="small text-muted">
Сделка {{ deal.number }}
{% if deal.company %} · {{ deal.company.name }}{% endif %}
{% if deal.description %} · {{ deal.description }}{% endif %}
</div>
</div>
<a class="btn btn-outline-secondary btn-sm" href="{% url 'planning_deal' deal.id %}">
<i class="bi bi-arrow-left me-1"></i>Назад к сделке
</a>
</div>
<div class="card-body">
{% if items %}
<div class="text-muted mb-2">{{ page_hint|default:"" }}</div>
<div class="table-responsive">
<table class="table table-hover align-middle mb-0" data-sortable="1">
<thead>
<tr class="table-custom-header">
<th style="width:120px;">Тип</th>
<th style="width:200px;">Обозначение</th>
<th>Наименование</th>
<th data-sort="false" class="text-end" style="width:160px;"></th>
</tr>
</thead>
<tbody>
{% for r in items %}
<tr>
<td class="small text-muted">{{ r.entity.get_entity_type_display }}</td>
<td class="fw-bold">{{ r.entity.drawing_number|default:"—" }}</td>
<td>{{ r.entity.name }}</td>
<td class="text-end">
<a class="btn btn-outline-accent btn-sm" href="{{ r.url }}" target="_blank">Открыть паспорт</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-muted">Нет данных: список проблемных позиций не найден (или уже очищен).</div>
{% endif %}
</div>
</div>
{% endblock %}

View File

@@ -44,6 +44,11 @@
{{ wi.entity.drawing_number }}
{% endif %}
{{ wi.entity.name }}
{% if wi.comment %}
<div class="alert alert-warning py-1 px-2 mt-1 mb-0 small">
{{ wi.comment|linebreaksbr }}
</div>
{% endif %}
</td>
<td class="small text-muted">
{% if wi.entity.planned_material %}

View File

@@ -316,6 +316,7 @@
<thead>
<tr class="table-custom-header">
<th>Позиция</th>
<th>Партия</th>
<th>Операция</th>
<th data-sort="false" style="width: 160px;">Прогресс</th>
<th class="text-center">Заказано / Сделано / В смене</th>
@@ -339,6 +340,19 @@
{% if t.material %}{{ t.material.full_name|default:t.material.name }}{% else %}—{% endif %}
</div>
</td>
<td class="small">
{% if t.delivery_batch_id and t.delivery_batch %}
<div class="fw-bold">{{ t.delivery_batch.due_date|date:"d.m.Y" }}</div>
<div class="text-muted">
{{ t.delivery_batch.name|default:"—" }}
{% if t.delivery_batch.is_default %}
<span class="badge bg-secondary ms-1">по умолчанию</span>
{% endif %}
</div>
{% else %}
<span class="text-muted"></span>
{% endif %}
</td>
<td class="small">{{ t.current_operation_name|default:"—" }}</td>
<td>
<div class="progress bg-secondary-subtle border border-secondary sf-progress" style="height: 10px;" data-done-width="{{ t.done_width }}" data-plan-width="{{ t.plan_width }}" title="Сделано: {{ t.done_pct }}% · В смене: {{ t.plan_pct }}%">
@@ -373,6 +387,7 @@
data-bs-toggle="modal"
data-bs-target="#workItemModal"
data-entity-id="{{ t.entity_id }}"
data-batch-id="{{ t.delivery_batch_id|default:'' }}"
data-operation-id="{{ t.current_operation_id }}"
data-workshop-id="{{ t.current_workshop_id|default:'' }}"
data-workshop-name="{{ t.current_workshop_name|default:'' }}"
@@ -389,7 +404,7 @@
</td>
</tr>
{% empty %}
<tr><td colspan="7" class="text-center p-4 text-muted">Задач нет</td></tr>
<tr><td colspan="8" class="text-center p-4 text-muted">Задач нет</td></tr>
{% endfor %}
</tbody>
</table>
@@ -458,6 +473,7 @@
</div>
<div class="modal-body">
<input type="hidden" name="entity_id" id="wiEntityId">
<input type="hidden" name="delivery_batch_id" id="wiBatchId">
<input type="hidden" name="operation_id" id="wiOperationId">
<div class="small text-muted mb-2" id="wiTitle"></div>
@@ -502,6 +518,97 @@
</div>
</div>
{% if missing_tech_process_rows %}
<div class="modal fade" id="missingTechProcessModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content border-secondary">
<div class="modal-header border-secondary">
<h5 class="modal-title">Нельзя запустить: нет техпроцесса</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
</div>
<div class="modal-body">
<div class="text-muted mb-2">Добавь техпроцесс (операция seq=1) для позиций ниже и повтори запуск.</div>
<div class="table-responsive">
<table class="table table-sm table-hover mb-0 align-middle">
<thead>
<tr class="table-custom-header">
<th style="width:110px;">Тип</th>
<th style="width:180px;">Обозначение</th>
<th>Наименование</th>
<th data-sort="false" class="text-end" style="width:140px;"></th>
</tr>
</thead>
<tbody>
{% for r in missing_tech_process_rows %}
<tr>
<td class="small text-muted">{{ r.entity.get_entity_type_display }}</td>
<td class="fw-bold">{{ r.entity.drawing_number|default:"—" }}</td>
<td>{{ r.entity.name }}</td>
<td class="text-end">
<a class="btn btn-outline-secondary btn-sm" href="{{ r.url }}" target="_blank">Открыть</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<div class="modal-footer border-secondary">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Закрыть</button>
{% if missing_tech_process_details_url %}
<a class="btn btn-outline-accent" href="{{ missing_tech_process_details_url }}" target="_blank">Подробнее</a>
{% endif %}
</div>
</div>
</div>
</div>
{% endif %}
{% if missing_material_rows %}
<div class="modal fade" id="missingMaterialModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content border-secondary">
<div class="modal-header border-secondary">
<h5 class="modal-title">Нельзя запустить: нет материала</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
</div>
<div class="modal-body">
<div class="text-muted mb-2">Заполни material в паспорте(ах) деталей ниже и повтори запуск.</div>
<div class="table-responsive">
<table class="table table-sm table-hover mb-0 align-middle">
<thead>
<tr class="table-custom-header">
<th style="width:110px;">Тип</th>
<th style="width:180px;">Обозначение</th>
<th>Наименование</th>
<th data-sort="false" class="text-end" style="width:140px;"></th>
</tr>
</thead>
<tbody>
{% for r in missing_material_rows %}
<tr>
<td class="small text-muted">{{ r.entity.get_entity_type_display }}</td>
<td class="fw-bold">{{ r.entity.drawing_number|default:"—" }}</td>
<td>{{ r.entity.name }}</td>
<td class="text-end">
<a class="btn btn-outline-secondary btn-sm" href="{{ r.url }}" target="_blank">Открыть</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<div class="modal-footer border-secondary">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Закрыть</button>
{% if missing_material_details_url %}
<a class="btn btn-outline-accent" href="{{ missing_material_details_url }}" target="_blank">Подробнее</a>
{% endif %}
</div>
</div>
</div>
</div>
{% endif %}
<!-- productInfoModal удалён: паспорт компонента открывается отдельной страницей -->
<div class="d-none" id="productInfoModal" tabindex="-1" aria-hidden="true">
@@ -941,12 +1048,14 @@ document.addEventListener('DOMContentLoaded', function () {
modal.addEventListener('shown.bs.modal', function (event) {
const btn = event.relatedTarget;
const entityId = btn.getAttribute('data-entity-id') || '';
const batchId = btn.getAttribute('data-batch-id') || '';
const opId = btn.getAttribute('data-operation-id') || '';
const name = btn.getAttribute('data-task-name') || '';
const opName = btn.getAttribute('data-operation-name') || '';
const rem = btn.getAttribute('data-task-rem');
document.getElementById('wiEntityId').value = entityId;
document.getElementById('wiBatchId').value = batchId;
document.getElementById('wiOperationId').value = opId;
document.getElementById('wiTitle').textContent = name;
@@ -1044,6 +1153,29 @@ document.addEventListener('DOMContentLoaded', function () {
});
});
{% if missing_tech_process_autoshow and missing_tech_process_rows %}
document.addEventListener('DOMContentLoaded', function () {
const el = document.getElementById('missingTechProcessModal');
if (!el) return;
try {
const m = new bootstrap.Modal(el);
m.show();
} catch (e) {
}
});
{% endif %}
{% if missing_material_autoshow and missing_material_rows %}
document.addEventListener('DOMContentLoaded', function () {
const el = document.getElementById('missingMaterialModal');
if (!el) return;
try {
const m = new bootstrap.Modal(el);
m.show();
} catch (e) {
}
});
{% endif %}
</script>
{% endblock %}

View File

@@ -25,6 +25,8 @@
</div>
<div class="card-body">
<div class="container-fluid p-0">
<div class="row g-3">
<div class="col-lg-5">
<form method="post" action="{% url 'product_info' entity.id %}" enctype="multipart/form-data" id="product-info-form">
{% csrf_token %}
<input type="hidden" name="action" value="save">
@@ -32,22 +34,12 @@
<input type="hidden" name="trail" value="{{ request.GET.trail|default:'' }}">
<div class="row g-2">
<div class="col-md-2">
<div class="col-md-6">
<label class="form-label">Тип</label>
<div class="mt-1"><span class="badge bg-secondary">{{ entity.get_entity_type_display }}</span></div>
</div>
<div class="col-md-3">
<label class="form-label">Обозначение</label>
<input class="form-control bg-body text-body border-secondary" name="drawing_number" value="{{ entity.drawing_number }}" {% if not can_edit %}disabled{% endif %}>
</div>
<div class="col-md-5">
<label class="form-label">Наименование</label>
<input class="form-control bg-body text-body border-secondary" name="name" value="{{ entity.name }}" {% if not can_edit %}disabled{% endif %}>
</div>
<div class="col-md-2">
<div class="col-md-6">
<label class="form-label">Заполнен</label>
<div class="form-check mt-2">
<input class="form-check-input" type="checkbox" name="passport_filled" id="pf" {% if entity.passport_filled %}checked{% endif %} {% if not can_edit %}disabled{% endif %}>
@@ -55,48 +47,70 @@
</div>
</div>
<div class="col-md-3">
<div class="col-md-6">
<label class="form-label">Обозначение</label>
<input class="form-control bg-body text-body border-secondary" name="drawing_number" value="{{ entity.drawing_number }}" {% if not can_edit %}disabled{% endif %}>
</div>
<div class="col-md-6">
<label class="form-label">Наименование</label>
<input class="form-control bg-body text-body border-secondary" name="name" value="{{ entity.name }}" {% if not can_edit %}disabled{% endif %}>
</div>
<div class="col-md-6">
<label class="form-label">Масса, кг</label>
<input class="form-control bg-body text-body border-secondary" name="weight_kg" value="{% if passport and passport.weight_kg %}{{ passport.weight_kg }}{% endif %}" inputmode="decimal" {% if not can_edit %}disabled{% endif %}>
</div>
<div class="col-md-3">
<label class="form-label">Покрытие</label>
<input class="form-control bg-body text-body border-secondary" name="coating" value="{% if passport %}{{ passport.coating }}{% endif %}" {% if not can_edit %}disabled{% endif %}>
</div>
<div class="col-md-3">
<label class="form-label">Цвет</label>
<input class="form-control bg-body text-body border-secondary" name="coating_color" value="{% if passport %}{{ passport.coating_color }}{% endif %}" {% if not can_edit %}disabled{% endif %}>
</div>
<div class="col-md-3">
<div class="col-md-6">
<label class="form-label">Площадь покрытия, м²</label>
<input class="form-control bg-body text-body border-secondary" name="coating_area_m2" value="{% if passport and passport.coating_area_m2 %}{{ passport.coating_area_m2 }}{% endif %}" inputmode="decimal" {% if not can_edit %}disabled{% endif %}>
</div>
<div class="col-md-4">
<div class="col-md-6">
<label class="form-label">Покрытие</label>
<input class="form-control bg-body text-body border-secondary" name="coating" value="{% if passport %}{{ passport.coating }}{% endif %}" {% if not can_edit %}disabled{% endif %}>
</div>
<div class="col-md-6">
<label class="form-label">Цвет</label>
<input class="form-control bg-body text-body border-secondary" name="coating_color" value="{% if passport %}{{ passport.coating_color }}{% endif %}" {% if not can_edit %}disabled{% endif %}>
</div>
<div class="col-12">
<label class="form-label">Чертёж (PDF)</label>
<input class="form-control bg-body text-body border-secondary" type="file" name="pdf_main" accept="application/pdf" {% if not can_edit %}disabled{% endif %}>
<div class="d-flex gap-2 align-items-center">
{% if entity.pdf_main %}
<div class="small mt-1"><a href="{{ entity.pdf_main.url }}" target="_blank">Открыть текущий</a></div>
<a href="{{ entity.pdf_main.url }}" target="_blank" rel="noopener" class="btn btn-sm btn-outline-danger p-1" title="Чертёж PDF">
<i class="bi bi-file-pdf"></i>
</a>
{% endif %}
<input class="form-control bg-body text-body border-secondary" type="file" name="pdf_main" accept="application/pdf" {% if not can_edit %}disabled{% endif %}>
</div>
</div>
<div class="col-md-4">
<div class="col-12">
<label class="form-label">DXF/IGES/STEP</label>
<input class="form-control bg-body text-body border-secondary" type="file" name="dxf_file" accept=".dxf,.iges,.igs,.step,.stp" {% if not can_edit %}disabled{% endif %}>
<div class="d-flex gap-2 align-items-center">
{% if entity.dxf_file %}
<div class="small mt-1"><a href="{{ entity.dxf_file.url }}" target="_blank">Открыть текущий</a></div>
<a href="{{ entity.dxf_file.url }}" target="_blank" rel="noopener" class="btn btn-sm btn-outline-info p-1" title="DXF/IGES/STEP">
<i class="bi bi-file-earmark-code"></i>
</a>
{% endif %}
<input class="form-control bg-body text-body border-secondary" type="file" name="dxf_file" accept=".dxf,.iges,.igs,.step,.stp" {% if not can_edit %}disabled{% endif %}>
</div>
</div>
<div class="col-md-4">
<div class="col-12">
<label class="form-label">Картинка</label>
<input class="form-control bg-body text-body border-secondary" type="file" name="preview" accept="image/*" {% if not can_edit %}disabled{% endif %}>
<div class="d-flex gap-2 align-items-center">
{% if entity.preview %}
<div class="small mt-1"><a href="{{ entity.preview.url }}" target="_blank">Открыть текущую</a></div>
<a href="{{ entity.preview.url }}" target="_blank" rel="noopener" class="btn btn-sm btn-outline-success p-1" title="Картинка">
<i class="bi bi-file-earmark-image"></i>
</a>
{% endif %}
<input class="form-control bg-body text-body border-secondary" type="file" name="preview" accept="image/*" {% if not can_edit %}disabled{% endif %}>
</div>
</div>
{% if not can_edit %}
@@ -287,6 +301,92 @@
</script>
</div>
{% endif %}
</div>
<div class="col-lg-7">
<div class="border border-secondary rounded p-2">
<div class="d-flex justify-content-between align-items-center mb-2">
<div class="fw-bold">Состав</div>
{% if can_edit %}
<button type="button" class="btn btn-outline-accent btn-sm" data-bs-toggle="modal" data-bs-target="#bomAddModal">Добавить компонент</button>
{% endif %}
</div>
<div class="table-responsive">
<table class="table table-hover mb-0 align-middle">
<thead>
<tr class="table-custom-header">
<th>Тип</th>
<th>Обозначение</th>
<th>Наименование</th>
<th data-sort="false" class="text-center" style="width:110px;">Файлы</th>
<th class="text-center" style="width:120px;">Заполнено</th>
<th class="text-center">Кол-во</th>
<th data-sort="false" class="text-end"></th>
</tr>
</thead>
<tbody>
{% for ln in bom_lines %}
<tr role="button" style="cursor:pointer" onclick="window.location.href='{% url 'product_info' ln.child.id %}?next={{ request.get_full_path|urlencode }}&trail={{ trail_child|urlencode }}';">
<td class="small text-muted">{{ ln.child.get_entity_type_display }}</td>
<td class="fw-bold">{{ ln.child.drawing_number|default:"—" }}</td>
<td>{{ ln.child.name }}</td>
<td class="text-center" onclick="event.stopPropagation();">
{% if ln.child.dxf_file %}
<a href="{{ ln.child.dxf_file.url }}" target="_blank" rel="noopener" class="btn btn-sm btn-outline-info p-1 stop-prop" title="DXF/IGES/STEP">
<i class="bi bi-file-earmark-code"></i>
</a>
{% endif %}
{% if ln.child.pdf_main %}
<a href="{{ ln.child.pdf_main.url }}" target="_blank" rel="noopener" class="btn btn-sm btn-outline-danger p-1 stop-prop" title="Чертёж PDF">
<i class="bi bi-file-pdf"></i>
</a>
{% endif %}
{% if ln.child.preview %}
<a href="{{ ln.child.preview.url }}" target="_blank" rel="noopener" class="btn btn-sm btn-outline-success p-1 stop-prop" title="Картинка">
<i class="bi bi-file-earmark-image"></i>
</a>
{% endif %}
</td>
<td class="text-center">
{% if ln.child.passport_filled %}
<span class="badge bg-success">Да</span>
{% else %}
<span class="badge bg-secondary">Нет</span>
{% endif %}
</td>
<td class="text-center" style="max-width:220px;" onclick="event.stopPropagation();">
<form method="post" action="{% url 'product_info' entity.id %}" class="d-flex gap-2 align-items-center justify-content-center" onclick="event.stopPropagation();">
{% csrf_token %}
<input type="hidden" name="action" value="bom_update_qty">
<input type="hidden" name="bom_id" value="{{ ln.id }}">
<input type="hidden" name="trail" value="{{ request.GET.trail|default:'' }}">
<input type="hidden" name="next" value="{{ next }}">
<input class="form-control form-control-sm bg-body text-body border-secondary" name="quantity" value="{{ ln.quantity }}" {% if not can_edit %}disabled{% endif %}>
<button class="btn btn-outline-secondary btn-sm" type="submit" {% if not can_edit %}disabled{% endif %}>OK</button>
</form>
</td>
<td class="text-end">
{% if can_edit %}
<form method="post" action="{% url 'product_info' entity.id %}">
{% csrf_token %}
<input type="hidden" name="action" value="bom_delete_line">
<input type="hidden" name="bom_id" value="{{ ln.id }}">
<input type="hidden" name="trail" value="{{ request.GET.trail|default:'' }}">
<input type="hidden" name="next" value="{{ next }}">
<button class="btn btn-outline-secondary btn-sm" type="submit">Удалить</button>
</form>
{% endif %}
</td>
</tr>
{% empty %}
<tr><td colspan="7" class="text-center text-muted py-4">Пока нет компонентов</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
<div class="mt-4">
@@ -358,72 +458,6 @@
{% endif %}
</div>
<hr class="border-secondary my-4">
<div class="mt-4">
<div class="d-flex justify-content-between align-items-center mb-2">
<div class="fw-bold">Состав</div>
{% if can_edit %}
<button type="button" class="btn btn-outline-accent btn-sm" data-bs-toggle="modal" data-bs-target="#bomAddModal">Добавить компонент</button>
{% endif %}
</div>
<div class="table-responsive">
<table class="table table-hover mb-0 align-middle">
<thead>
<tr class="table-custom-header">
<th>Тип</th>
<th>Обозначение</th>
<th>Наименование</th>
<th class="text-center" style="width:120px;">Заполнено</th>
<th class="text-center">Кол-во</th>
<th data-sort="false" class="text-end"></th>
</tr>
</thead>
<tbody>
{% for ln in bom_lines %}
<tr role="button" style="cursor:pointer" onclick="window.location.href='{% url 'product_info' ln.child.id %}?next={{ request.get_full_path|urlencode }}&trail={{ trail_child|urlencode }}';">
<td class="small text-muted">{{ ln.child.get_entity_type_display }}</td>
<td class="fw-bold">{{ ln.child.drawing_number|default:"—" }}</td>
<td>{{ ln.child.name }}</td>
<td class="text-center">
{% if ln.child.passport_filled %}
<span class="badge bg-success">Да</span>
{% else %}
<span class="badge bg-secondary">Нет</span>
{% endif %}
</td>
<td class="text-center" style="max-width:220px;" onclick="event.stopPropagation();">
<form method="post" action="{% url 'product_info' entity.id %}" class="d-flex gap-2 align-items-center justify-content-center" onclick="event.stopPropagation();">
{% csrf_token %}
<input type="hidden" name="action" value="bom_update_qty">
<input type="hidden" name="bom_id" value="{{ ln.id }}">
<input type="hidden" name="trail" value="{{ request.GET.trail|default:'' }}">
<input type="hidden" name="next" value="{{ next }}">
<input class="form-control form-control-sm bg-body text-body border-secondary" name="quantity" value="{{ ln.quantity }}" {% if not can_edit %}disabled{% endif %}>
<button class="btn btn-outline-secondary btn-sm" type="submit" {% if not can_edit %}disabled{% endif %}>OK</button>
</form>
</td>
<td class="text-end">
{% if can_edit %}
<form method="post" action="{% url 'product_info' entity.id %}">
{% csrf_token %}
<input type="hidden" name="action" value="bom_delete_line">
<input type="hidden" name="bom_id" value="{{ ln.id }}">
<input type="hidden" name="trail" value="{{ request.GET.trail|default:'' }}">
<input type="hidden" name="next" value="{{ next }}">
<button class="btn btn-outline-secondary btn-sm" type="submit">Удалить</button>
</form>
{% endif %}
</td>
</tr>
{% empty %}
<tr><td colspan="6" class="text-center text-muted py-4">Пока нет компонентов</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% if can_edit %}
<div class="modal fade" id="bomAddModal" tabindex="-1" aria-hidden="true">
@@ -594,4 +628,44 @@
</div>
</div>
</div>
{{ next|json_script:"productInfoNext" }}
<script>
document.addEventListener('DOMContentLoaded', function () {
const form = document.getElementById('product-info-form');
function getNextUrl() {
const el = document.getElementById('productInfoNext');
return el ? JSON.parse(el.textContent || '""') : '';
}
function submitForm() {
if (!form) return;
if (form.requestSubmit) {
form.requestSubmit();
} else {
form.submit();
}
}
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') {
if (e.defaultPrevented) return;
if (document.querySelector('.modal.show')) return;
const url = getNextUrl();
if (url) {
e.preventDefault();
window.location.href = url;
}
return;
}
const key = (e.key || '').toLowerCase();
if ((e.ctrlKey || e.metaKey) && key === 's') {
e.preventDefault();
submitForm();
}
}, true);
});
</script>
{% endblock %}

View File

@@ -82,26 +82,38 @@
<div class="col-md-4">
<label class="form-label">Чертёж (PDF)</label>
<input class="form-control bg-body text-body border-secondary" type="file" name="pdf_main" accept="application/pdf" {% if not can_edit %}disabled{% endif %}>
<div class="input-group">
{% if entity.pdf_main %}
<div class="small mt-1"><a href="{{ entity.pdf_main.url }}" target="_blank">Открыть текущий</a></div>
<a href="{{ entity.pdf_main.url }}" target="_blank" rel="noopener" class="btn btn-outline-danger" title="Чертёж PDF">
<i class="bi bi-file-pdf"></i>
</a>
{% endif %}
<input class="form-control bg-body text-body border-secondary" type="file" name="pdf_main" accept="application/pdf" {% if not can_edit %}disabled{% endif %}>
</div>
</div>
<div class="col-md-4">
<label class="form-label">DXF/IGES/STEP</label>
<input class="form-control bg-body text-body border-secondary" type="file" name="dxf_file" accept=".dxf,.iges,.igs,.step,.stp" {% if not can_edit %}disabled{% endif %}>
<div class="input-group">
{% if entity.dxf_file %}
<div class="small mt-1"><a href="{{ entity.dxf_file.url }}" target="_blank">Открыть текущий</a></div>
<a href="{{ entity.dxf_file.url }}" target="_blank" rel="noopener" class="btn btn-outline-info" title="DXF/IGES/STEP">
<i class="bi bi-file-earmark-code"></i>
</a>
{% endif %}
<input class="form-control bg-body text-body border-secondary" type="file" name="dxf_file" accept=".dxf,.iges,.igs,.step,.stp" {% if not can_edit %}disabled{% endif %}>
</div>
</div>
<div class="col-md-4">
<label class="form-label">Картинка</label>
<input class="form-control bg-body text-body border-secondary" type="file" name="preview" accept="image/*" {% if not can_edit %}disabled{% endif %}>
<div class="input-group">
{% if entity.preview %}
<div class="small mt-1"><a href="{{ entity.preview.url }}" target="_blank">Открыть текущую</a></div>
<a href="{{ entity.preview.url }}" target="_blank" rel="noopener" class="btn btn-outline-success" title="Картинка">
<i class="bi bi-file-earmark-image"></i>
</a>
{% endif %}
<input class="form-control bg-body text-body border-secondary" type="file" name="preview" accept="image/*" {% if not can_edit %}disabled{% endif %}>
</div>
</div>
<div class="col-12 d-flex justify-content-end mt-2">
@@ -275,4 +287,44 @@
</div>
</div>
</div>
{{ next|json_script:"productInfoNext" }}
<script>
document.addEventListener('DOMContentLoaded', function () {
const form = document.getElementById('product-info-form');
function getNextUrl() {
const el = document.getElementById('productInfoNext');
return el ? JSON.parse(el.textContent || '""') : '';
}
function submitForm() {
if (!form) return;
if (form.requestSubmit) {
form.requestSubmit();
} else {
form.submit();
}
}
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') {
if (e.defaultPrevented) return;
if (document.querySelector('.modal.show')) return;
const url = getNextUrl();
if (url) {
e.preventDefault();
window.location.href = url;
}
return;
}
const key = (e.key || '').toLowerCase();
if ((e.ctrlKey || e.metaKey) && key === 's') {
e.preventDefault();
submitForm();
}
}, true);
});
</script>
{% endblock %}

View File

@@ -24,7 +24,7 @@
</div>
</div>
<div class="card-body">
<form method="post" action="{% url 'product_info' entity.id %}" class="container-fluid p-0">
<form method="post" action="{% url 'product_info' entity.id %}" class="container-fluid p-0" id="product-info-form">
{% csrf_token %}
<input type="hidden" name="action" value="save">
<input type="hidden" name="next" value="{{ next }}">
@@ -64,4 +64,44 @@
</form>
</div>
</div>
{{ next|json_script:"productInfoNext" }}
<script>
document.addEventListener('DOMContentLoaded', function () {
const form = document.getElementById('product-info-form');
function getNextUrl() {
const el = document.getElementById('productInfoNext');
return el ? JSON.parse(el.textContent || '""') : '';
}
function submitForm() {
if (!form) return;
if (form.requestSubmit) {
form.requestSubmit();
} else {
form.submit();
}
}
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') {
if (e.defaultPrevented) return;
if (document.querySelector('.modal.show')) return;
const url = getNextUrl();
if (url) {
e.preventDefault();
window.location.href = url;
}
return;
}
const key = (e.key || '').toLowerCase();
if ((e.ctrlKey || e.metaKey) && key === 's') {
e.preventDefault();
submitForm();
}
}, true);
});
</script>
{% endblock %}

View File

@@ -57,26 +57,38 @@
<div class="col-md-4">
<label class="form-label">Чертёж/ТЗ (PDF)</label>
<input class="form-control bg-body text-body border-secondary" type="file" name="pdf_main" accept="application/pdf" {% if not can_edit %}disabled{% endif %}>
<div class="input-group">
{% if entity.pdf_main %}
<div class="small mt-1"><a href="{{ entity.pdf_main.url }}" target="_blank">Открыть текущий</a></div>
<a href="{{ entity.pdf_main.url }}" target="_blank" rel="noopener" class="btn btn-outline-danger" title="Чертёж/ТЗ PDF">
<i class="bi bi-file-pdf"></i>
</a>
{% endif %}
<input class="form-control bg-body text-body border-secondary" type="file" name="pdf_main" accept="application/pdf" {% if not can_edit %}disabled{% endif %}>
</div>
</div>
<div class="col-md-4">
<label class="form-label">DXF/IGES/STEP</label>
<input class="form-control bg-body text-body border-secondary" type="file" name="dxf_file" accept=".dxf,.iges,.igs,.step,.stp" {% if not can_edit %}disabled{% endif %}>
<div class="input-group">
{% if entity.dxf_file %}
<div class="small mt-1"><a href="{{ entity.dxf_file.url }}" target="_blank">Открыть текущий</a></div>
<a href="{{ entity.dxf_file.url }}" target="_blank" rel="noopener" class="btn btn-outline-info" title="DXF/IGES/STEP">
<i class="bi bi-file-earmark-code"></i>
</a>
{% endif %}
<input class="form-control bg-body text-body border-secondary" type="file" name="dxf_file" accept=".dxf,.iges,.igs,.step,.stp" {% if not can_edit %}disabled{% endif %}>
</div>
</div>
<div class="col-md-4">
<label class="form-label">Картинка</label>
<input class="form-control bg-body text-body border-secondary" type="file" name="preview" accept="image/*" {% if not can_edit %}disabled{% endif %}>
<div class="input-group">
{% if entity.preview %}
<div class="small mt-1"><a href="{{ entity.preview.url }}" target="_blank">Открыть текущую</a></div>
<a href="{{ entity.preview.url }}" target="_blank" rel="noopener" class="btn btn-outline-success" title="Картинка">
<i class="bi bi-file-earmark-image"></i>
</a>
{% endif %}
<input class="form-control bg-body text-body border-secondary" type="file" name="preview" accept="image/*" {% if not can_edit %}disabled{% endif %}>
</div>
</div>
{% if not can_edit %}
@@ -275,4 +287,44 @@
</div>
</div>
</div>
{{ next|json_script:"productInfoNext" }}
<script>
document.addEventListener('DOMContentLoaded', function () {
const form = document.getElementById('product-info-form');
function getNextUrl() {
const el = document.getElementById('productInfoNext');
return el ? JSON.parse(el.textContent || '""') : '';
}
function submitForm() {
if (!form) return;
if (form.requestSubmit) {
form.requestSubmit();
} else {
form.submit();
}
}
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') {
if (e.defaultPrevented) return;
if (document.querySelector('.modal.show')) return;
const url = getNextUrl();
if (url) {
e.preventDefault();
window.location.href = url;
}
return;
}
const key = (e.key || '').toLowerCase();
if ((e.ctrlKey || e.metaKey) && key === 's') {
e.preventDefault();
submitForm();
}
}, true);
});
</script>
{% endblock %}

View File

@@ -107,26 +107,38 @@
<div class="col-md-4">
<label class="form-label">Чертёж (PDF)</label>
<input class="form-control bg-body text-body border-secondary" type="file" name="pdf_main" accept="application/pdf" {% if not can_edit %}disabled{% endif %}>
<div class="d-flex gap-2 align-items-center">
{% if entity.pdf_main %}
<div class="small mt-1"><a href="{{ entity.pdf_main.url }}" target="_blank">Открыть текущий</a></div>
<a href="{{ entity.pdf_main.url }}" target="_blank" class="btn btn-sm btn-outline-danger p-1" title="PDF">
<i class="bi bi-file-pdf"></i>
</a>
{% endif %}
<input class="form-control bg-body text-body border-secondary" type="file" name="pdf_main" accept="application/pdf" {% if not can_edit %}disabled{% endif %}>
</div>
</div>
<div class="col-md-4">
<label class="form-label">DXF/IGES/STEP</label>
<input class="form-control bg-body text-body border-secondary" type="file" name="dxf_file" accept=".dxf,.iges,.igs,.step,.stp" {% if not can_edit %}disabled{% endif %}>
<div class="d-flex gap-2 align-items-center">
{% if entity.dxf_file %}
<div class="small mt-1"><a href="{{ entity.dxf_file.url }}" target="_blank">Открыть текущий</a></div>
<a href="{{ entity.dxf_file.url }}" target="_blank" class="btn btn-sm btn-outline-info p-1" title="DXF/IGES/STEP">
<i class="bi bi-file-earmark-code"></i>
</a>
{% endif %}
<input class="form-control bg-body text-body border-secondary" type="file" name="dxf_file" accept=".dxf,.iges,.igs,.step,.stp" {% if not can_edit %}disabled{% endif %}>
</div>
</div>
<div class="col-md-4">
<label class="form-label">Картинка</label>
<input class="form-control bg-body text-body border-secondary" type="file" name="preview" accept="image/*" {% if not can_edit %}disabled{% endif %}>
<div class="d-flex gap-2 align-items-center">
{% if entity.preview %}
<div class="small mt-1"><a href="{{ entity.preview.url }}" target="_blank">Открыть текущую</a></div>
<a href="{{ entity.preview.url }}" target="_blank" class="btn btn-sm btn-outline-success p-1" title="Картинка">
<i class="bi bi-file-earmark-image"></i>
</a>
{% endif %}
<input class="form-control bg-body text-body border-secondary" type="file" name="preview" accept="image/*" {% if not can_edit %}disabled{% endif %}>
</div>
</div>
<div class="col-12">
@@ -310,4 +322,44 @@
</div>
</div>
</div>
{{ next|json_script:"productInfoNext" }}
<script>
document.addEventListener('DOMContentLoaded', function () {
const form = document.getElementById('product-info-form');
function getNextUrl() {
const el = document.getElementById('productInfoNext');
return el ? JSON.parse(el.textContent || '""') : '';
}
function submitForm() {
if (!form) return;
if (form.requestSubmit) {
form.requestSubmit();
} else {
form.submit();
}
}
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') {
if (e.defaultPrevented) return;
if (document.querySelector('.modal.show')) return;
const url = getNextUrl();
if (url) {
e.preventDefault();
window.location.href = url;
}
return;
}
const key = (e.key || '').toLowerCase();
if ((e.ctrlKey || e.metaKey) && key === 's') {
e.preventDefault();
submitForm();
}
}, true);
});
</script>
{% endblock %}

View File

@@ -77,27 +77,40 @@
<div class="col-md-4">
<label class="form-label">Чертёж/паспорт (PDF)</label>
<input class="form-control bg-body text-body border-secondary" type="file" name="pdf_main" accept="application/pdf" {% if not can_edit %}disabled{% endif %}>
<div class="d-flex gap-2 align-items-center">
{% if entity.pdf_main %}
<div class="small mt-1"><a href="{{ entity.pdf_main.url }}" target="_blank">Открыть текущий</a></div>
<a href="{{ entity.pdf_main.url }}" target="_blank" class="btn btn-sm btn-outline-danger p-1" title="PDF">
<i class="bi bi-file-pdf"></i>
</a>
{% endif %}
<input class="form-control bg-body text-body border-secondary" type="file" name="pdf_main" accept="application/pdf" {% if not can_edit %}disabled{% endif %}>
</div>
</div>
<div class="col-md-4">
<label class="form-label">DXF/IGES/STEP</label>
<input class="form-control bg-body text-body border-secondary" type="file" name="dxf_file" accept=".dxf,.iges,.igs,.step,.stp" {% if not can_edit %}disabled{% endif %}>
<div class="d-flex gap-2 align-items-center">
{% if entity.dxf_file %}
<div class="small mt-1"><a href="{{ entity.dxf_file.url }}" target="_blank">Открыть текущий</a></div>
<a href="{{ entity.dxf_file.url }}" target="_blank" class="btn btn-sm btn-outline-info p-1" title="DXF/IGES/STEP">
<i class="bi bi-file-earmark-code"></i>
</a>
{% endif %}
<input class="form-control bg-body text-body border-secondary" type="file" name="dxf_file" accept=".dxf,.iges,.igs,.step,.stp" {% if not can_edit %}disabled{% endif %}>
</div>
</div>
<div class="col-md-4">
<label class="form-label">Картинка</label>
<input class="form-control bg-body text-body border-secondary" type="file" name="preview" accept="image/*" {% if not can_edit %}disabled{% endif %}>
<div class="d-flex gap-2 align-items-center">
{% if entity.preview %}
<div class="small mt-1"><a href="{{ entity.preview.url }}" target="_blank">Открыть текущую</a></div>
<a href="{{ entity.preview.url }}" target="_blank" class="btn btn-sm btn-outline-success p-1" title="Картинка">
<i class="bi bi-file-earmark-image"></i>
</a>
{% endif %}
<input class="form-control bg-body text-body border-secondary" type="file" name="preview" accept="image/*" {% if not can_edit %}disabled{% endif %}>
</div>
</div>
<div class="col-12 d-flex justify-content-end mt-2">
{% if can_edit %}
@@ -270,4 +283,44 @@
</div>
</div>
</div>
{{ next|json_script:"productInfoNext" }}
<script>
document.addEventListener('DOMContentLoaded', function () {
const form = document.getElementById('product-info-form');
function getNextUrl() {
const el = document.getElementById('productInfoNext');
return el ? JSON.parse(el.textContent || '""') : '';
}
function submitForm() {
if (!form) return;
if (form.requestSubmit) {
form.requestSubmit();
} else {
form.submit();
}
}
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') {
if (e.defaultPrevented) return;
if (document.querySelector('.modal.show')) return;
const url = getNextUrl();
if (url) {
e.preventDefault();
window.location.href = url;
}
return;
}
const key = (e.key || '').toLowerCase();
if ((e.ctrlKey || e.metaKey) && key === 's') {
e.preventDefault();
submitForm();
}
}, true);
});
</script>
{% endblock %}

View File

@@ -6,10 +6,15 @@
<div class="card shadow border-secondary">
<div class="card-header border-secondary py-3 d-flex justify-content-between align-items-center">
<h3 class="text-accent mb-0"><i class="bi bi-list-task me-2"></i>Реестр заданий</h3>
{% if user_role in 'admin,technologist,master' %}
{% if user_role in 'admin,technologist,master,operator' %}
<div class="d-flex gap-2">
<a class="btn btn-outline-secondary btn-sm" target="_blank" href="{% url 'registry_workitems_print' %}?{{ request.GET.urlencode }}">
<i class="bi bi-printer me-1"></i>Печать
</a>
<a class="btn btn-outline-secondary btn-sm" href="{% url 'registry_workitems_download' %}?{{ request.GET.urlencode }}">
<i class="bi bi-download me-1"></i>Скачать
</a>
</div>
{% endif %}
</div>

View File

@@ -0,0 +1,35 @@
{% extends 'base.html' %}
{% block content %}
<div class="card shadow border-secondary">
<div class="card-header border-secondary py-3 d-flex justify-content-between align-items-center">
<h3 class="text-accent mb-0"><i class="bi bi-download me-2"></i>Скачать сменное задание</h3>
<a class="btn btn-outline-secondary btn-sm" href="{% url 'registry' %}?{{ request.GET.urlencode }}">
<i class="bi bi-arrow-left me-1"></i>В реестр
</a>
</div>
<div class="card-body">
<div class="alert alert-info">
В выгрузку попадают <strong>задания из текущего фильтра реестра</strong> (как на экране).
Дополнительно: только <strong>задания «В работе»</strong> и только <strong>сделки «В работе»</strong>.
HTML формируется тем же шаблоном, что и кнопка <strong>«Печать»</strong>.
</div>
<form method="post" action="?{{ request.GET.urlencode }}">
{% csrf_token %}
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" value="1" id="translit" name="translit">
<label class="form-check-label" for="translit">
Транслитерировать имена файлов и папок (для совместимости)
</label>
</div>
<button type="submit" class="btn btn-primary">
<i class="bi bi-archive me-1"></i>Подготовить и скачать архив
</button>
</form>
</div>
</div>
{% endblock %}

View File

@@ -31,6 +31,14 @@
</div>
</div>
{% if export_bar_text %}
<div class="container-fluid my-3">
<div class="alert alert-info py-2 mb-0">
{{ export_bar_text }}
</div>
</div>
{% endif %}
{% for g in groups %}
<div class="container-fluid page my-3">
<div class="print-header">
@@ -64,7 +72,12 @@
<td class="center">{{ wi.date|date:"d.m.y" }}</td>
<td class="center">{{ wi.deal.number|default:"-" }}</td>
<td>{{ wi.operation.name|default:wi.stage|default:"—" }}</td>
<td>{{ wi.entity.drawing_number|default:"—" }} {{ wi.entity.name }}</td>
<td>
{{ wi.entity.drawing_number|default:"—" }} {{ wi.entity.name }}
{% if wi.entity.blank_length_mm and not wi.entity.dxf_file and not wi.entity.pdf_main %}
<div class="small text-muted">Длина: {{ wi.entity.blank_length_mm|floatformat:0 }} мм</div>
{% endif %}
</td>
<td>
{% if wi.entity.planned_material %}
{{ wi.entity.planned_material.full_name|default:wi.entity.planned_material.name }}

View File

@@ -0,0 +1,206 @@
{% extends 'base.html' %}
{% block content %}
<div class="card shadow border-secondary mb-3">
<div class="card-header border-secondary py-3 d-flex justify-content-between align-items-center">
<h3 class="text-accent mb-0"><i class="bi bi-truck me-2"></i>Отгрузка</h3>
<div class="d-flex gap-2">
<a class="btn btn-outline-secondary btn-sm" href="{{ journal_url }}" target="_blank">
<i class="bi bi-journal-text me-1"></i>Журнал отгрузки
</a>
<button type="button" class="btn btn-outline-accent btn-sm" data-bs-toggle="modal" data-bs-target="#addDealModal">
<i class="bi bi-plus-lg me-1"></i>Добавить к отгрузке сделку
</button>
</div>
</div>
<div class="card-body">
<form method="post" id="shippingCartForm">
{% csrf_token %}
<input type="hidden" name="action" id="shippingAction" value="">
<input type="hidden" name="remove_deal_id" id="removeDealId" value="">
{% if not cart %}
<div class="text-muted">Добавь сделку к отгрузке, чтобы выбрать готовые позиции.</div>
{% endif %}
{% for b in cart %}
<div class="card border-secondary mb-3">
<div class="card-header border-secondary py-2 d-flex justify-content-between align-items-center">
<div class="fw-bold">Сделка №{{ b.deal.number }}{% if b.deal.company %} · {{ b.deal.company.name }}{% endif %}</div>
<button type="button" class="btn btn-outline-secondary btn-sm js-remove-deal" data-deal-id="{{ b.deal.id }}">Убрать</button>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead>
<tr class="table-custom-header">
<th data-sort="false" style="width:44px;"></th>
<th>Позиция</th>
<th class="text-center" style="width:120px;">Доступно</th>
<th class="text-center" style="width:180px;">К отгрузке</th>
</tr>
</thead>
<tbody>
{% for r in b.entity_rows %}
<tr>
<td class="text-center"><input class="form-check-input border-secondary js-pick" type="checkbox" data-target="#qty_d{{ b.deal.id }}_ent_{{ r.entity.id }}"></td>
<td><div class="fw-bold">{{ r.entity.drawing_number|default:"—" }} {{ r.entity.name }}</div><div class="small text-muted">{{ r.entity.get_entity_type_display }}</div></td>
<td class="text-center fw-bold">{{ r.remaining_ready }}</td>
<td class="text-center">
<input id="qty_d{{ b.deal.id }}_ent_{{ r.entity.id }}" class="form-control bg-body text-body border-secondary ship-qty" type="number" min="0" step="1" max="{{ r.remaining_ready }}" name="d{{ b.deal.id }}_ent_{{ r.entity.id }}" value="0" data-deal="№{{ b.deal.number }}" data-label="{{ r.entity.drawing_number|default:'—' }} {{ r.entity.name }}" disabled>
</td>
</tr>
{% empty %}
<tr><td colspan="4" class="text-center text-muted py-4">Нет готовых позиций к отгрузке</td></tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="border-top border-secondary"></div>
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead>
<tr class="table-custom-header">
<th data-sort="false" style="width:44px;"></th>
<th>Сырьё</th>
<th class="text-center" style="width:120px;">Доступно</th>
<th class="text-center" style="width:180px;">К отгрузке</th>
</tr>
</thead>
<tbody>
{% for r in b.material_rows %}
<tr>
<td class="text-center"><input class="form-check-input border-secondary js-pick" type="checkbox" data-target="#qty_d{{ b.deal.id }}_mat_{{ r.material.id }}"></td>
<td><div class="fw-bold">{{ r.material.full_name|default:r.material.name }}</div><div class="small text-muted">Сырьё</div></td>
<td class="text-center fw-bold">{{ r.available|floatformat:"-g" }}</td>
<td class="text-center">
<input id="qty_d{{ b.deal.id }}_mat_{{ r.material.id }}" class="form-control bg-body text-body border-secondary ship-qty" type="number" min="0" step="0.001" max="{{ r.available|floatformat:'-g' }}" name="d{{ b.deal.id }}_mat_{{ r.material.id }}" value="0" data-deal="№{{ b.deal.number }}" data-label="Сырьё: {{ r.material.full_name|default:r.material.name }}" disabled>
</td>
</tr>
{% empty %}
<tr><td colspan="4" class="text-center text-muted py-4">Нет сырья к отгрузке</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endfor %}
<div class="d-flex justify-content-end mt-3">
{% if can_edit %}
<button type="button" class="btn btn-outline-accent" data-bs-toggle="modal" data-bs-target="#shipConfirmModal" id="shipOpenConfirm">Отгрузить</button>
{% else %}
<button type="button" class="btn btn-outline-secondary" disabled>Отгрузить</button>
{% endif %}
</div>
<div class="modal fade" id="addDealModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content border-secondary">
<div class="modal-header border-secondary"><h5 class="modal-title">Добавить сделку к отгрузке</h5><button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button></div>
<div class="modal-body">
<label class="form-label">Сделка (статус: В работе)</label>
<select class="form-select bg-body text-body border-secondary" name="add_deal_id">
<option value="">— выбери —</option>
{% for d in available_deals %}
<option value="{{ d.id }}">№{{ d.number }}{% if d.company %} · {{ d.company.name }}{% endif %}</option>
{% endfor %}
</select>
</div>
<div class="modal-footer border-secondary">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Отмена</button>
<button type="submit" class="btn btn-outline-accent js-action" data-action="add_deal">Добавить</button>
</div>
</div>
</div>
</div>
<div class="modal fade" id="shipConfirmModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content border-secondary">
<div class="modal-header border-secondary"><h5 class="modal-title">Подтвердить отгрузку</h5><button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button></div>
<div class="modal-body">
<div class="small text-muted mb-2">Проверь итоговый список к отгрузке:</div>
<div id="shipSummary" class="border border-secondary rounded p-2"></div>
<div id="shipSummaryEmpty" class="text-muted d-none">Нечего отгружать (везде 0).</div>
</div>
<div class="modal-footer border-secondary">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Отмена</button>
<button type="submit" class="btn btn-outline-accent js-action" data-action="ship" id="shipConfirmBtn">Принять отгрузку</button>
</div>
</div>
</div>
</div>
</form>
<script>
document.addEventListener('DOMContentLoaded', () => {
const form = document.getElementById('shippingCartForm');
const actionEl = document.getElementById('shippingAction');
const removeDealId = document.getElementById('removeDealId');
const summary = document.getElementById('shipSummary');
const empty = document.getElementById('shipSummaryEmpty');
const confirmBtn = document.getElementById('shipConfirmBtn');
function setAction(a){ if(actionEl) actionEl.value = a || ''; }
document.querySelectorAll('.js-action').forEach(btn => {
btn.addEventListener('click', () => setAction(btn.getAttribute('data-action') || ''));
});
document.querySelectorAll('.js-remove-deal').forEach(btn => {
btn.addEventListener('click', () => {
const id = btn.getAttribute('data-deal-id') || '';
if (!id) return;
if (removeDealId) removeDealId.value = id;
setAction('remove_deal');
form?.requestSubmit();
});
});
document.querySelectorAll('.js-pick').forEach(chk => {
chk.addEventListener('change', () => {
const target = chk.getAttribute('data-target');
const inp = target ? document.querySelector(target) : null;
if (!inp) return;
if (chk.checked) {
inp.disabled = false;
const max = inp.getAttribute('max');
const v = (max && parseInt(max, 10) > 0) ? String(parseInt(max, 10)) : '1';
if (!inp.value || inp.value === '0') inp.value = v;
} else {
inp.value = '0';
inp.disabled = true;
}
});
});
document.getElementById('shipOpenConfirm')?.addEventListener('click', () => {
const inputs = Array.from(document.querySelectorAll('#shippingCartForm .ship-qty'));
const rows = [];
inputs.forEach(inp => {
if (inp.disabled) return;
const raw = (inp.value || '').toString().trim();
const val = parseFloat(raw.replace(',', '.'));
if (!val || val <= 0) return;
rows.push({ deal: inp.getAttribute('data-deal') || '', label: inp.getAttribute('data-label') || '', val });
});
if (!rows.length) {
summary.innerHTML = '';
empty.classList.remove('d-none');
if (confirmBtn) confirmBtn.disabled = true;
return;
}
empty.classList.add('d-none');
if (confirmBtn) confirmBtn.disabled = false;
summary.innerHTML = rows.map(r => `<div class="d-flex justify-content-between gap-2"><div>${r.deal} · ${r.label}</div><div class="fw-bold">${r.val}</div></div>`).join('');
});
});
</script>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,90 @@
{% extends 'base.html' %}
{% block content %}
<div class="card shadow border-secondary mb-3">
<div class="card-header border-secondary py-3 d-flex justify-content-between align-items-center">
<div>
<h3 class="text-accent mb-1"><i class="bi bi-journal-text me-2"></i>Журнал отгрузки</h3>
<div class="small text-muted">Склад отгрузки: {{ shipping_location.name }}</div>
</div>
<a class="btn btn-outline-secondary btn-sm" href="{% url 'shipping' %}">
<i class="bi bi-arrow-left me-1"></i>Назад
</a>
</div>
<div class="card-body">
<form method="get" class="row g-2 align-items-end mb-3">
<input type="hidden" name="filtered" value="1">
<div class="col-md-5">
<label class="small text-muted mb-1 fw-bold">Поиск (сделка):</label>
<input
type="text"
name="q"
value="{{ q }}"
class="form-control form-control-sm bg-body text-body border-secondary"
placeholder="№ сделки, описание, заказчик"
onchange="this.form.submit()"
>
</div>
<div class="col-md-auto ms-md-auto">
<label class="small text-muted mb-1 fw-bold">Период (с):</label>
<input type="date" name="start_date" class="form-control form-control-sm bg-body text-body border-secondary" value="{{ start_date }}" onchange="this.form.submit()">
</div>
<div class="col-md-auto">
<label class="small text-muted mb-1 fw-bold">Период (по):</label>
<input type="date" name="end_date" class="form-control form-control-sm bg-body text-body border-secondary" value="{{ end_date }}" onchange="this.form.submit()">
</div>
<div class="col-md-auto">
<a href="{% url 'shipping_journal' %}?reset=1" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-arrow-counterclockwise me-1"></i>Сброс
</a>
</div>
</form>
<div class="table-responsive">
<table class="table table-hover align-middle mb-0" data-sortable="1">
<thead>
<tr class="table-custom-header">
<th style="width:170px;">Дата</th>
<th style="width:120px;">Документ</th>
<th>Сделки</th>
<th>Откуда</th>
<th style="width:160px;">Кто</th>
<th data-sort="false" class="text-end" style="width:140px;"></th>
</tr>
</thead>
<tbody>
{% for r in rows %}
<tr>
<td class="small">{{ r.transfer.occurred_at|date:"d.m.Y H:i" }}</td>
<td class="fw-bold">№{{ r.transfer.id }}</td>
<td class="small">
{% if r.deals %}
{% for d in r.deals %}
<div>
<span class="fw-bold">№{{ d.number }}</span>
{% if d.description %} — {{ d.description }}{% endif %}
</div>
{% endfor %}
{% else %}
<span class="text-muted"></span>
{% endif %}
</td>
<td class="small">{{ r.transfer.from_location.name }}</td>
<td class="small">{{ r.transfer.sender.username|default:"—" }}</td>
<td class="text-end">
<a class="btn btn-outline-secondary btn-sm" href="{{ r.admin_url }}" target="_blank">Открыть</a>
</td>
</tr>
{% empty %}
<tr><td colspan="6" class="text-center text-muted py-4">Пока нет отгрузок</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endblock %}

View File

@@ -97,7 +97,7 @@
</thead>
<tbody>
{% for it in items %}
<tr>
<tr{% if it.material_id %} class="js-stock-edit" role="button" tabindex="0" data-stock-item-id="{{ it.id }}" data-location="{{ it.location }}" data-location-id="{{ it.location_id }}" data-material-id="{{ it.material_id }}" data-name="{{ it.material.full_name|default:it.material.name }}" data-deal-id="{{ it.deal_id|default:'' }}" data-quantity="{{ it.quantity }}" data-current-length="{{ it.current_length|default:'' }}" data-current-width="{{ it.current_width|default:'' }}" data-unique-id="{{ it.unique_id|default:'' }}" data-is-remnant="{% if it.is_remnant %}1{% endif %}" data-is-customer-supplied="{% if it.is_customer_supplied %}1{% endif %}" data-ff="{{ it.material.category.form_factor|default:'' }}"{% endif %}>
<td>{{ it.location }}</td>
<td>{% if it.created_at %}{{ it.created_at|date:"d.m.Y H:i" }}{% endif %}</td>
<td>
@@ -327,6 +327,90 @@
</div>
</div>
<div class="modal fade" id="stockEditModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg">
<form method="post" action="{% url 'warehouse_stockitem_update' %}" class="modal-content border-secondary">
{% csrf_token %}
<input type="hidden" name="next" value="{{ request.get_full_path }}">
<input type="hidden" name="stock_item_id" id="stockEditId">
<div class="modal-header border-secondary">
<div>
<h5 class="modal-title">Редактирование позиции</h5>
<div class="small text-muted" id="stockEditInfo"></div>
</div>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
</div>
<div class="modal-body">
<div class="row g-2">
<div class="col-md-7">
<label class="form-label">Материал</label>
<select class="form-select" name="material_id" id="stockEditMaterial" required>
{% for m in materials %}
<option value="{{ m.id }}" data-ff="{{ m.category.form_factor|default:'' }}">{{ m.full_name|default:m.name }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-5">
<label class="form-label">Склад</label>
<select class="form-select" name="location_id" id="stockEditLocation" required>
{% for loc in locations %}
<option value="{{ loc.id }}">{{ loc }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-6">
<label class="form-label">Сделка</label>
<select class="form-select" name="deal_id" id="stockEditDeal">
<option value="">— не указано —</option>
{% for d in deals %}
<option value="{{ d.id }}">{{ d.number }}{% if d.company_id %} — {{ d.company.name }}{% endif %}{% if d.description %} — {{ d.description }}{% endif %}</option>
{% endfor %}
</select>
</div>
<div class="col-md-6">
<label class="form-label">Маркировка (ID)</label>
<input class="form-control" name="unique_id" id="stockEditUniqueId" placeholder="Напр. ШТ-001">
</div>
<div class="col-md-4">
<label class="form-label">Кол-во</label>
<input class="form-control" name="quantity" id="stockEditQty" inputmode="decimal" required>
</div>
<div class="col-md-4">
<label class="form-label">Длина (мм)</label>
<input class="form-control" name="current_length" id="stockEditLen" inputmode="decimal">
</div>
<div class="col-md-4">
<label class="form-label">Ширина (мм)</label>
<input class="form-control" name="current_width" id="stockEditWid" inputmode="decimal">
</div>
<div class="col-12 mt-1">
<div class="d-flex flex-wrap gap-4">
<div class="form-check">
<input class="form-check-input" type="checkbox" name="is_remnant" id="stockEditIsRemnant" value="1">
<label class="form-check-label" for="stockEditIsRemnant">ДО</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" name="is_customer_supplied" id="stockEditIsCustomerSupplied" value="1">
<label class="form-check-label" for="stockEditIsCustomerSupplied">Давальческий</label>
</div>
</div>
</div>
</div>
</div>
<div class="modal-footer border-secondary">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Отмена</button>
<button type="submit" class="btn btn-outline-accent">Сохранить</button>
</div>
</form>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', () => {
const modal = document.getElementById('transferModal');
@@ -547,6 +631,69 @@
receiptMaterial.addEventListener('change', applyReceiptDefaults);
applyReceiptDefaults();
}
const editModal = document.getElementById('stockEditModal');
if (editModal) {
const editId = document.getElementById('stockEditId');
const editInfo = document.getElementById('stockEditInfo');
const editMaterial = document.getElementById('stockEditMaterial');
const editLocation = document.getElementById('stockEditLocation');
const editDeal = document.getElementById('stockEditDeal');
const editUniqueId = document.getElementById('stockEditUniqueId');
const editQty = document.getElementById('stockEditQty');
const editLen = document.getElementById('stockEditLen');
const editWid = document.getElementById('stockEditWid');
const editIsRemnant = document.getElementById('stockEditIsRemnant');
const editIsCustomerSupplied = document.getElementById('stockEditIsCustomerSupplied');
function applyEditFF() {
if (!editMaterial || !editWid) return;
const opt = editMaterial.options[editMaterial.selectedIndex];
const ff = (opt && opt.getAttribute('data-ff') || '').toLowerCase();
if (ff === 'bar') {
editWid.value = '';
editWid.disabled = true;
} else {
editWid.disabled = false;
}
}
if (editMaterial) {
editMaterial.addEventListener('change', applyEditFF);
}
document.querySelectorAll('tr.js-stock-edit').forEach((row) => {
row.addEventListener('click', (e) => {
if (e.target && e.target.closest('button, a, input, select, label')) return;
const ds = row.dataset || {};
if (editId) editId.value = ds.stockItemId || '';
if (editInfo) editInfo.textContent = `Позиция #${ds.stockItemId || ''}`;
if (editMaterial) editMaterial.value = ds.materialId || '';
if (editLocation) editLocation.value = ds.locationId || '';
if (editDeal) editDeal.value = ds.dealId || '';
if (editUniqueId) editUniqueId.value = ds.uniqueId || '';
if (editQty) editQty.value = ds.quantity || '';
if (editLen) editLen.value = ds.currentLength || '';
if (editWid) editWid.value = ds.currentWidth || '';
if (editIsRemnant) editIsRemnant.checked = (ds.isRemnant || '') === '1';
if (editIsCustomerSupplied) editIsCustomerSupplied.checked = (ds.isCustomerSupplied || '') === '1';
applyEditFF();
const m = bootstrap.Modal.getOrCreateInstance(editModal);
m.show();
});
row.addEventListener('keydown', (e) => {
if (e.key !== 'Enter' && e.key !== ' ') return;
e.preventDefault();
row.click();
});
});
}
});
</script>
{% endblock %}

View File

@@ -28,12 +28,18 @@
{% endif %}
{% if user_role in 'admin,technologist,master,clerk' %}
{% if workitem.entity.entity_type == 'product' or workitem.entity.entity_type == 'assembly' %}
{% if is_first_operation and workitem.entity.entity_type == 'product' or is_first_operation and workitem.entity.entity_type == 'assembly' %}
<a class="btn btn-outline-accent btn-sm" href="{% url 'workitem_kitting' workitem.id %}?next={{ request.get_full_path|urlencode }}">
<i class="bi bi-box-seam me-1"></i>Комплектация
</a>
{% endif %}
{% endif %}
{% if user_role in 'admin,technologist,master,operator,prod_head' %}
<button type="button" class="btn btn-outline-secondary btn-sm" disabled title="В разработке">
<i class="bi bi-basket3 me-1"></i>Доп. расходы
</button>
{% endif %}
</div>
</div>

View File

@@ -8,8 +8,13 @@
<h3 class="text-accent mb-0">
<i class="bi bi-check2-square me-2"></i>Закрытие операции
</h3>
<div class="d-flex gap-2">
<button type="button" class="btn btn-outline-secondary btn-sm" disabled title="В разработке">
<i class="bi bi-basket3 me-1"></i>Доп. расходы
</button>
<a class="btn btn-outline-secondary btn-sm" href="{% url 'workitem_detail' workitem.id %}">Назад к заданию</a>
</div>
</div>
<div class="card-body p-4">
<div class="mb-3">
@@ -25,6 +30,65 @@
Эта операция не списывает сырьё/комплектующие. Здесь фиксируется только факт выполнения.
</div>
{% if move_hint and work_location %}
<div class="alert alert-warning border-warning">
На складе участка <strong>{{ work_location.name }}</strong> не хватает изделий для закрытия операции.
Нужно: <strong>{{ move_hint.needed }}</strong> · доступно: <strong>{{ move_hint.available }}</strong> · не хватает: <strong>{{ move_hint.missing }}</strong>.
</div>
<div class="d-flex gap-2 mb-3">
<button type="button" class="btn btn-outline-accent" data-bs-toggle="modal" data-bs-target="#moveToWorkLocationModal" {% if not move_candidates %}disabled{% endif %}>
<i class="bi bi-arrow-left-right me-1"></i>Переместить на участок
</button>
{% if not move_candidates %}
<div class="small text-muted align-self-center">Нет доступных позиций для перемещения</div>
{% endif %}
</div>
<div class="modal fade" id="moveToWorkLocationModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg">
<form method="post" action="{% url 'warehouse_transfer' %}" class="modal-content border-secondary">
{% csrf_token %}
<input type="hidden" name="next" value="{{ request.get_full_path }}">
<input type="hidden" name="to_location_id" value="{{ work_location.id }}">
<div class="modal-header border-secondary">
<h5 class="modal-title">Перемещение на участок</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
</div>
<div class="modal-body">
<div class="mb-2">
<div class="fw-bold">{{ workitem.entity.drawing_number|default:"—" }} {{ workitem.entity.name }}</div>
<div class="small text-muted">Склад участка: {{ work_location.name }}</div>
</div>
<div class="row g-2">
<div class="col-md-8">
<label class="form-label">Откуда (позиция склада)</label>
<select class="form-select" name="stock_item_id" required>
{% for c in move_candidates %}
<option value="{{ c.id }}">{{ c.location.name }} · доступно {{ c.quantity|floatformat:"-g" }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-4">
<label class="form-label">Количество</label>
<input class="form-control" name="quantity" inputmode="decimal" value="{{ move_hint.missing }}" required>
</div>
</div>
</div>
<div class="modal-footer border-secondary">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Отмена</button>
<button type="submit" class="btn btn-outline-accent">Переместить</button>
</div>
</form>
</div>
</div>
{% endif %}
<form method="post">
{% csrf_token %}
<div class="row align-items-end g-2">

View File

@@ -76,15 +76,15 @@
<div class="row g-3 mt-1">
<div class="col-lg-4">
<div class="small text-muted fw-bold mb-1">Списано</div>
{% if card.report.consumptions.all %}
{% if card.consumption_rows %}
<ul class="mb-0">
{% for c in card.report.consumptions.all %}
{% for c in card.consumption_rows %}
{% if c.stock_item_id and c.stock_item.material_id %}
<li>
{{ 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 %})
{% if c.stock_item.deal_id %}<span class="text-muted">(сделка № {{ c.stock_item.deal.number }})</span>{% endif %}
{{ c.quantity|floatformat:"-g" }} шт
{{ c.quantity|floatformat:"-g" }} шт — масса {% if c.mass_kg or c.mass_kg == 0 %}{{ c.mass_kg|floatformat:1 }}{% else %}—{% endif %} кг
</li>
{% elif c.stock_item_id and c.stock_item.entity_id %}
<li>
@@ -119,13 +119,13 @@
<div class="col-lg-4">
<div class="small text-muted fw-bold mb-1">Остаток ДО</div>
{% if card.report.remnants.all %}
{% if card.remnant_rows %}
<ul class="mb-0">
{% for r in card.report.remnants.all %}
{% for r in card.remnant_rows %}
<li>
{{ 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" }} шт
{{ r.quantity|floatformat:"-g" }} шт — масса {% if r.mass_kg or r.mass_kg == 0 %}{{ r.mass_kg|floatformat:1 }}{% else %}—{% endif %} кг
</li>
{% endfor %}
</ul>

View File

@@ -5,6 +5,8 @@ from .views import (
CustomersView,
DealDetailView,
DealPlanningView,
DealMissingTechProcessView,
DealMissingMaterialView,
DealUpsertView,
DealBatchActionView,
DealItemUpsertView,
@@ -39,6 +41,7 @@ from .views import (
WorkItemKittingPrintView,
AssemblyClosingView,
WorkItemRegistryPrintView,
WorkItemRegistryDownloadView,
RegistryView,
SteelGradesCatalogView,
SteelGradeUpsertView,
@@ -54,9 +57,11 @@ from .views import (
LegacyRegistryView,
LegacyWriteOffsView,
WarehouseReceiptCreateView,
WarehouseStockItemUpdateView,
WarehouseStocksView,
WarehouseTransferCreateView,
ProcurementDashboardView,
ShippingJournalView,
ShippingView,
)
@@ -70,6 +75,8 @@ urlpatterns = [
# Сделки
path('planning/', PlanningView.as_view(), name='planning'),
path('planning/deal/<int:pk>/', DealPlanningView.as_view(), name='planning_deal'),
path('planning/deal/<int:pk>/missing-tech/', DealMissingTechProcessView.as_view(), name='deal_missing_tech_process'),
path('planning/deal/<int:pk>/missing-material/', DealMissingMaterialView.as_view(), name='deal_missing_material'),
path('planning/task/<int:pk>/items/', TaskItemsView.as_view(), name='task_items'),
path('customers/', CustomersView.as_view(), name='customers'),
path('customers/<int:pk>/', CustomerDealsView.as_view(), name='customer_deals'),
@@ -105,6 +112,7 @@ urlpatterns = [
# Печать сменного листа
path('registry/print/', RegistryPrintView.as_view(), name='registry_print'),
path('registry/workitems/print/', WorkItemRegistryPrintView.as_view(), name='registry_workitems_print'),
path('registry/workitems/download/', WorkItemRegistryDownloadView.as_view(), name='registry_workitems_download'),
path('item/<int:pk>/', ItemUpdateView.as_view(), name='item_detail'),
path('workitem/<int:pk>/', WorkItemDetailView.as_view(), name='workitem_detail'),
path('workitem/<int:pk>/op_closing/', WorkItemOpClosingView.as_view(), name='workitem_op_closing'),
@@ -114,6 +122,7 @@ urlpatterns = [
path('workitems/<int:deal_id>/<int:entity_id>/', WorkItemEntityListView.as_view(), name='workitem_entity_list'),
path('warehouse/stocks/', WarehouseStocksView.as_view(), name='warehouse_stocks'),
path('warehouse/stock-item/update/', WarehouseStockItemUpdateView.as_view(), name='warehouse_stockitem_update'),
path('warehouse/transfer/', WarehouseTransferCreateView.as_view(), name='warehouse_transfer'),
path('warehouse/receipt/', WarehouseReceiptCreateView.as_view(), name='warehouse_receipt'),
@@ -122,6 +131,7 @@ urlpatterns = [
path('writeoffs/', WriteOffsView.as_view(), name='writeoffs'),
path('procurement/', ProcurementDashboardView.as_view(), name='procurement'),
path('shipping/', ShippingView.as_view(), name='shipping'),
path('shipping/journal/', ShippingJournalView.as_view(), name='shipping_journal'),
path('legacy/closing/', LegacyClosingView.as_view(), name='legacy_closing'),
path('legacy/writeoffs/', LegacyWriteOffsView.as_view(), name='legacy_writeoffs'),

File diff suppressed because it is too large Load Diff

View File

@@ -28,7 +28,7 @@
{% if user_role in 'admin,clerk,manager,prod_head,director,observer,technologist' %}
<li class="nav-item">
<a class="nav-link {% if request.resolver_match.url_name == 'shipping' %}active{% endif %}" href="{% url 'shipping' %}">Отгрузка</a>
<a class="nav-link {% if request.resolver_match.url_name == 'shipping' or request.resolver_match.url_name == 'shipping_journal' %}active{% endif %}" href="{% url 'shipping' %}">Отгрузка</a>
</li>
{% endif %}