Compare commits

..

10 Commits

Author SHA1 Message Date
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
30 changed files with 2351 additions and 392 deletions

View File

@@ -1,90 +1,71 @@
# AI_RULES — правила работы ассистента в проекте MES_Core # AI_RULES — MES_Core
Роль: Ты Senior Django Backend Developer. Роль: 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)Комментарии ### Транзакции и гонки (warehouse/shiftflow)
- В Python/бекенде: - Любые операции списания/начисления/перемещений — в transaction.atomic().
- добавляй поясняющие комментарии там, где есть бизнес-логика, транзакции, конкурентность, фоновые задачи, сложные алгоритмы (BOM, списания, начисления). - Изменяемые складские остатки блокировать через select_for_update().
- комментарии должны быть нейтральными и описывать поведение/причину, без личных формулировок. - Избегать N+1: select_related/prefetch_related, bulk операции — только где безопасно.
- В HTML-шаблонах Django:
- не добавляй template-комментарии {# ... #} .
## 5) Стиль и конвенции проекта ### Роли и доступ
- Смотри на соседние файлы и придерживайся уже принятого стиля (структура, именование, импорты, форматирование). - Роли приложения — Django Groups (мульти‑роли). Имена групп совпадают с кодами ролей: operator/master/technologist/clerk/supply/prod_head/director/observer/admin.
- Не вводи новые библиотеки/фреймворки, пока не проверил, что они уже используются в проекте. - Проверка доступа во вьюхах: has_any_role(roles, [...]). primary_role — только для UI.
- Для UI-таблиц: - На этапе миграции допускается fallback на EmployeeProfile.role, но новые правки ориентировать на группы.
- если добавляешь новую таблицу — по умолчанию делай её сортируемой (если не мешает UX).
- Использовать 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) Безопасность и секреты ## SHOULD — правила, которые желательно соблюдать
- Никогда не логируй и не печатай в stdout:
- SECRET_KEY
- пароли БД
- токены
- В логи допускаются только технические сообщения, ошибки и диагностические данные без секретов.
- В models.py всегда использовать on_delete=models.PROTECT для важных справочников (Металл, Сделки), чтобы нельзя было случайно удалить историю.
## 7) Логи и фоновые задачи
- Для долгих операций (рендер превью, массовые обновления, BOM explosion для больших заказов):
- не блокируй HTTP-ответ
- Использовать модуль threading для запуска таких задач в отдельном потоке.
- Обязательно оборачивать фоновую функцию в try/except и логировать ошибки в БД или файл, так как ошибки в потоках не видны во вьюхе. ### Комментарии
- Логи фоновых задач должны быть: - Python/бекенд: добавлять поясняющие комментариии там, где они нужны, без личных формулировок.
- с датой/временем
- доступны из интерфейса “Обслуживание сервера” (tail)
- очищаемы кнопкой (если задача не running)
## 8) Транзакции и гонки данных (warehouse/shiftflow) - Везде добавлять докстринги (docstrings) для функций, классов, модулей, и т.д.
- Все операции списания/начисления на складе делай в transaction.atomic() .
- На изменяемые складские остатки используй select_for_update() чтобы избежать гонок.
- Для массовых операций избегай N+1:
- select_related / prefetch_related
- bulk update/create там, где это безопасно.
## 9) Роли и доступ (Django Groups) - Везде добавлять комментарии к коду, где они нужны, без личных формулировок.
- Использовать Django Groups как роли приложения (мульти-роли).
- Имена групп должны совпадать с кодами ролей, используемых в коде, например: - Django HTMLшаблоны: не добавлять templateкомментарии ({# ... #}).
- operator
- master ### Стиль и конвенции
- technologist - Держаться стиля соседних файлов (структура, именование, импорты, форматирование).
- clerk - Не добавлять новые библиотеки/фреймворки, пока не подтверждено, что они уже используются.
- supply
- prod_head ### UI
- director - Если добавляется новая UIтаблица — по умолчанию делать сортируемой (если это не мешает UX).
- observer
- admin
- Назначение ролей в Django admin:
- Users → выбрать пользователя → поле Groups → добавить нужные группы → Save.
- Примечание: на этапе миграции допускается fallback на EmployeeProfile.role, чтобы при деплое до раздачи групп доступ не "слетал".
### Назначение станков и цехов пользователю ### Назначение станков и цехов пользователю
- Привязка станков/цехов делается через профиль сотрудника: - Привязка через профиль сотрудника:
- Shiftflow → Employee profiles → выбрать профиль пользователя.
- Machines: закреплённые станки (для операторов). - Machines: закреплённые станки (для операторов).
- Allowed workshops: доступные цеха (ограничение видимости/действий). - Allowed workshops: доступные цеха (ограничение видимости/действий).
- Is readonly: режим "только просмотр". - Is readonly: режим только просмотр.
Правило для новых внутренних функций (как договор):
- Всегда берём логгер logger = logging.getLogger('mes')
- Перед выполнением — logger.info('fn:start ...', ключевые параметры)
- После успешного выполнения — logger.info('fn:done ...', ключевые результаты)
- На важных шагах — logger.info('fn:step ...', детали)
- Исключение — с context: logger.exception('fn:error ...') — не глотаем, пробрасываем дальше

33
CHANGELOG.md Normal file
View File

@@ -0,0 +1,33 @@
# Changelog
Все заметные изменения в этом проекте документируются в этом файле.
Формат — по мотивам “Keep a Changelog”, версионирование — SemVer (пока 0.x).
- UI/шаблоны/стили/тексты → увеличиваем PATCH (x.y.Z)
- Логика/вьюхи/сервисы/модели/миграции/доступы → увеличиваем MINOR (x.Y.0)
- Изменения, влияющие на данные/совместимость → обсуждаем MAJOR (X.0.0), даже если проект ещё в 0.x
## [Unreleased]
### Added
- Журнал отгрузки: список документов перемещения на «Склад отгруженных позиций».
### Changed
- Отгрузка: можно добавлять несколько сделок в одну сессию отгрузки, выбирать позиции и подтверждать общий список.
- Журнал отгрузки: добавлены фильтр по периоду (по умолчанию 2 недели) и поиск по сделкам (номер/описание/заказчик), убран столбец «Куда».
- Списание / Производство: в блоках «Списано» и «Остаток ДО» выводится масса материалов (по размерам и «Масса на ед. учёта»); если масса не задана — показывается прочерк.
- Закрытие: деловой остаток (ДО) может наследовать сделку от списанного сырья (отключается чекбоксом) и доступен к отгрузке как сырьё по сделке.
- Реестр заданий: комментарий сменного задания/операции отображается под наименованием.
- Склады: по клику по строке сырья/ДО открывается модальное окно редактирования позиции.
- Паспорта изделий/компонентов: ссылки на PDF/DXF/картинки отображаются иконками и открываются в новой вкладке.
- Паспорта изделий/сборок: блок «Состав» перенесён в верхнюю часть страницы, в таблицу состава добавлена колонка «Файлы».
- Производственные задачи и прогресс техпроцесса ведутся в разрезе партий поставки (серий) для одной сделки.
- Улучшено сообщение о блокировке запуска «В производство» при отсутствии техпроцесса или материала: показывается модалка и отдельная страница со списком проблемных позиций.
### Fixed
- Починено закрытие сборок/изделий на странице «Закрыть сборку»: выбор поста доступен и сохраняется, списание/выпуск выполняются.
- Запуск «В производство» блокируется, если в 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') ENV_TYPE = os.getenv('ENV_TYPE', 'local')
APP_VERSION = '0.7.1' APP_VERSION = '0.8.9'
# Настройки безопасности # Настройки безопасности
# DEBUG будет True везде, кроме сервера # DEBUG будет True везде, кроме сервера

View File

@@ -8,6 +8,7 @@ from .models import (
Company, Company,
CuttingSession, CuttingSession,
Deal, Deal,
DealDeliveryBatch,
DealItem, DealItem,
DxfPreviewJob, DxfPreviewJob,
DxfPreviewSettings, DxfPreviewSettings,
@@ -28,6 +29,7 @@ _models_to_reregister = (
Company, Company,
CuttingSession, CuttingSession,
Deal, Deal,
DealDeliveryBatch,
DealItem, DealItem,
DxfPreviewJob, DxfPreviewJob,
DxfPreviewSettings, DxfPreviewSettings,
@@ -77,6 +79,14 @@ class DealAdmin(admin.ModelAdmin):
list_filter = ('status', 'company') list_filter = ('status', 'company')
inlines = (DealItemInline,) 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) @admin.register(WorkItem)
class WorkItemAdmin(admin.ModelAdmin): class WorkItemAdmin(admin.ModelAdmin):
list_display = ('date', 'deal', 'entity', 'operation', 'workshop', 'machine', 'quantity_plan', 'quantity_done', 'status') list_display = ('date', 'deal', 'delivery_batch', 'entity', 'operation', 'workshop', 'machine', 'quantity_plan', 'quantity_done', 'status')
list_filter = ('date', 'status', 'workshop', 'machine', 'operation') list_filter = ('date', 'status', 'workshop', 'machine', 'operation', 'delivery_batch')
search_fields = ('deal__number', 'entity__name', 'entity__drawing_number', 'operation__name', 'operation__code') 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) @admin.register(DealEntityProgress)
class DealEntityProgressAdmin(admin.ModelAdmin): 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') search_fields = ('deal__number', 'entity__name', 'entity__drawing_number')
autocomplete_fields = ('deal', 'entity') autocomplete_fields = ('deal', 'delivery_batch', 'entity')
@admin.register(Workshop) @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="Сделка") 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="КД (изделие/деталь)") 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="Б/ч") drawing_name = models.CharField("Название детали", max_length=255, blank=True, default="Б/ч")
@@ -227,20 +235,28 @@ class DealBatchItem(models.Model):
class DealEntityProgress(models.Model): class DealEntityProgress(models.Model):
"""Текущая операция техпроцесса для пары (сделка, сущность). """Текущая операция техпроцесса для пары (сделка, партия, сущность).
Комментарий: current_seq=1 означает «выполняем 1-ю операцию в EntityOperation». Комментарий: current_seq=1 означает «выполняем 1-ю операцию в EntityOperation».
Когда current_seq больше числа операций — сущность для сделки считается прошедшей техпроцесс. Когда current_seq больше числа операций — сущность для партии считается прошедшей техпроцесс.
""" """
deal = models.ForeignKey(Deal, on_delete=models.CASCADE, verbose_name='Сделка') 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='Сущность') entity = models.ForeignKey('manufacturing.ProductEntity', on_delete=models.PROTECT, verbose_name='Сущность')
current_seq = models.PositiveSmallIntegerField('Текущая операция (порядок)', default=1) current_seq = models.PositiveSmallIntegerField('Текущая операция (порядок)', default=1)
class Meta: class Meta:
verbose_name = 'Прогресс по операции' verbose_name = 'Прогресс по операции'
verbose_name_plural = 'Прогресс по операциям' verbose_name_plural = 'Прогресс по операциям'
unique_together = ('deal', 'entity') unique_together = ('deal', 'delivery_batch', 'entity')
def __str__(self): def __str__(self):
return f"{self.deal.number}: {self.entity} -> {self.current_seq}" return f"{self.deal.number}: {self.entity} -> {self.current_seq}"
@@ -309,6 +325,14 @@ class ProcurementRequirement(models.Model):
class WorkItem(models.Model): class WorkItem(models.Model):
deal = models.ForeignKey(Deal, on_delete=models.CASCADE, verbose_name='Сделка') 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='Сущность') entity = models.ForeignKey('manufacturing.ProductEntity', on_delete=models.PROTECT, verbose_name='Сущность')
# Комментарий: operation — основной признак операции (расширяемый справочник). # Комментарий: operation — основной признак операции (расширяемый справочник).

View File

@@ -185,9 +185,10 @@ 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_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: if workitem.quantity_done >= workitem.quantity_plan:
workitem.status = 'done' workitem.status = 'done'
workitem.save(update_fields=['quantity_done', 'status']) workitem.save(update_fields=['quantity_done', 'quantity_reported', 'status'])
logger.info( logger.info(
'assembly_closing:done workitem_id=%s qty=%s deal_id=%s location_id=%s user_id=%s report_id=%s', '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 import Sum
from django.db.models.functions import Coalesce 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 shiftflow.models import Deal, DealItem, ProcurementRequirement, ProductionTask
from warehouse.models import StockItem from warehouse.models import StockItem
@@ -16,9 +16,19 @@ logger = logging.getLogger('mes')
class ExplosionValidationError(Exception): class ExplosionValidationError(Exception):
def __init__(self, missing_material_ids: list[int]): def __init__(
super().__init__('missing_material') 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_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) @dataclass(frozen=True)
@@ -338,6 +348,8 @@ def explode_deal(
def explode_roots_additive( def explode_roots_additive(
deal_id: int, deal_id: int,
roots: list[tuple[int, int]], roots: list[tuple[int, int]],
*,
delivery_batch_id: int | None = None,
) -> ExplosionStats: ) -> ExplosionStats:
"""Additive BOM Explosion для запуска в производство по частям. """Additive BOM Explosion для запуска в производство по частям.
@@ -370,13 +382,34 @@ def explode_roots_additive(
.filter(id__in=list(required_nodes.keys())) .filter(id__in=list(required_nodes.keys()))
} }
missing = [ missing_material = [
int(e.id) int(e.id)
for e in entities.values() 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: if missing_material:
raise ExplosionValidationError(missing) 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_created = 0
tasks_updated = 0 tasks_updated = 0
@@ -408,6 +441,7 @@ def explode_roots_additive(
pt, created = ProductionTask.objects.get_or_create( pt, created = ProductionTask.objects.get_or_create(
deal=deal, deal=deal,
delivery_batch_id=(int(delivery_batch_id) if delivery_batch_id else None),
entity=entity, entity=entity,
defaults=defaults, defaults=defaults,
) )
@@ -427,8 +461,9 @@ def explode_roots_additive(
tasks_updated += 1 tasks_updated += 1
logger.info( 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, deal_id,
delivery_batch_id,
roots, roots,
len(required_nodes), len(required_nodes),
tasks_created, tasks_created,
@@ -443,6 +478,8 @@ def explode_roots_additive(
def rollback_roots_additive( def rollback_roots_additive(
deal_id: int, deal_id: int,
roots: list[tuple[int, int]], roots: list[tuple[int, int]],
*,
delivery_batch_id: int | None = None,
) -> ExplosionStats: ) -> ExplosionStats:
"""Откат additive BOM Explosion. """Откат additive BOM Explosion.
@@ -485,7 +522,11 @@ def rollback_roots_additive(
skipped_supply += 1 skipped_supply += 1
continue 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: if not pt:
missing_tasks += 1 missing_tasks += 1
continue continue
@@ -501,8 +542,9 @@ def rollback_roots_additive(
tasks_updated += 1 tasks_updated += 1
logger.info( 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, deal_id,
delivery_batch_id,
roots, roots,
len(required_nodes), len(required_nodes),
tasks_updated, tasks_updated,

View File

@@ -24,6 +24,7 @@ def apply_closing(
item_actions: dict[int, dict], item_actions: dict[int, dict],
consumptions: dict[int, float], consumptions: dict[int, float],
remnants: list[dict], remnants: list[dict],
inherit_deal_for_remnants: bool = False,
) -> None: ) -> 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)) 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 +94,7 @@ def apply_closing(
) )
logger.info('apply_closing:close_session id=%s', report.id) 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: for it in items:
spec = item_actions.get(it.id) or {} spec = item_actions.get(it.id) or {}
@@ -142,6 +143,7 @@ def apply_closing_workitems(
item_actions: dict[int, dict], # workitem_id -> {'action': 'done'|'partial', 'fact': int} item_actions: dict[int, dict], # workitem_id -> {'action': 'done'|'partial', 'fact': int}
consumptions: dict[int, float], consumptions: dict[int, float],
remnants: list[dict], remnants: list[dict],
inherit_deal_for_remnants: bool = False,
) -> None: ) -> 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)) 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 +187,12 @@ def apply_closing_workitems(
if fact <= 0: if fact <= 0:
raise RuntimeError('При частичном закрытии факт должен быть больше 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: if not pt:
raise RuntimeError('Не найден ProductionTask для задания.') raise RuntimeError('Не найден ProductionTask для задания.')
@@ -193,13 +200,14 @@ def apply_closing_workitems(
created_shift += 1 created_shift += 1
wi.quantity_done = done_total + fact 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: if wi.quantity_done >= plan_total:
wi.status = 'done' wi.status = 'done'
elif wi.quantity_done > 0: elif wi.quantity_done > 0:
wi.status = 'leftover' wi.status = 'leftover'
else: else:
wi.status = 'planned' wi.status = 'planned'
wi.save(update_fields=['quantity_done', 'status']) wi.save(update_fields=['quantity_done', 'quantity_reported', 'status'])
for stock_item_id, qty in consumptions.items(): for stock_item_id, qty in consumptions.items():
if qty and float(qty) > 0: if qty and float(qty) > 0:
@@ -218,5 +226,5 @@ def apply_closing_workitems(
unique_id=None, 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) logger.info('apply_closing_workitems:done report=%s shift_items=%s', report.id, created_shift)

View File

@@ -17,7 +17,7 @@ logger = logging.getLogger('mes')
@transaction.atomic @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 (транзакция склада). Закрытие CuttingSession (транзакция склада).
@@ -56,6 +56,8 @@ def close_cutting_session(session_id: int) -> None:
raise RuntimeError('Не задан склад цеха для станка (Цех -> Склад цеха).') raise RuntimeError('Не задан склад цеха для станка (Цех -> Склад цеха).')
consumed_material_ids: set[int] = set() consumed_material_ids: set[int] = set()
consumed_deal_ids: set[int] = set()
consumed_is_customer_supplied = False
consumptions = list( consumptions = list(
ProductionReportConsumption.objects.select_related('material', 'stock_item', 'stock_item__material', 'stock_item__location') 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) 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) 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: if not si.material_id:
raise RuntimeError('В списании сырья указана позиция склада без material.') 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) 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) 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: if not used.material_id:
raise RuntimeError('Взятый материал должен ссылаться на сырьё (material), а не на готовую деталь (entity).') 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') 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')) remnants = list(ProductionReportRemnant.objects.filter(report=session).select_related('material'))
for r in remnants: for r in remnants:
created = StockItem.objects.create( created = StockItem.objects.create(
material=r.material, material=r.material,
deal_id=remnant_deal_id,
location=work_location, location=work_location,
quantity=float(r.quantity), quantity=float(r.quantity),
is_remnant=True, is_remnant=True,
is_customer_supplied=bool(consumed_is_customer_supplied),
current_length=r.current_length, current_length=r.current_length,
current_width=r.current_width, current_width=r.current_width,
unique_id=r.unique_id, unique_id=r.unique_id,

View File

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

View File

@@ -77,22 +77,30 @@
</div> </div>
</div> </div>
<form method="post" action=""> <form method="post" action="{% url 'assembly_closing' workitem.id %}">
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="action" value="close"> <input type="hidden" name="action" value="close">
<div class="row align-items-end g-2"> <div class="row align-items-end g-2">
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label text-muted small mb-1">Фактически собрано (шт.)</label> <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>
<div class="col-md-6"> <div class="col-md-6">
{% if workitem.machine_id %} {% 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> </button>
{% else %} {% 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> </button>
{% endif %} {% endif %}
@@ -113,10 +121,10 @@
<div class="modal-body"> <div class="modal-body">
{% if workshop_machines %} {% if workshop_machines %}
<label class="form-label small text-muted mb-1">Пост</label> <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> <option value="">— выбрать —</option>
{% for m in workshop_machines %} {% 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 %} {% endfor %}
</select> </select>
{% else %} {% else %}

View File

@@ -132,7 +132,13 @@
<div class="card shadow border-secondary"> <div class="card shadow border-secondary">
<div class="card-header border-secondary py-3 d-flex justify-content-between align-items-center"> <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> <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> <button type="button" class="btn btn-outline-accent btn-sm" id="addRemnantBtn" {% if not can_edit %}disabled{% endif %}>Добавить ДО</button>
</div> </div>
<div class="table-responsive"> <div class="table-responsive">

View File

@@ -135,7 +135,13 @@
<div class="card shadow border-secondary"> <div class="card shadow border-secondary">
<div class="card-header border-secondary py-3 d-flex justify-content-between align-items-center"> <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> <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> <button type="button" class="btn btn-outline-accent btn-sm" id="addRemnantBtn" {% if not can_edit %}disabled{% endif %}>Добавить ДО</button>
</div> </div>
<div class="table-responsive"> <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 }} {{ wi.entity.drawing_number }}
{% endif %} {% endif %}
{{ wi.entity.name }} {{ 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>
<td class="small text-muted"> <td class="small text-muted">
{% if wi.entity.planned_material %} {% if wi.entity.planned_material %}

View File

@@ -316,6 +316,7 @@
<thead> <thead>
<tr class="table-custom-header"> <tr class="table-custom-header">
<th>Позиция</th> <th>Позиция</th>
<th>Партия</th>
<th>Операция</th> <th>Операция</th>
<th data-sort="false" style="width: 160px;">Прогресс</th> <th data-sort="false" style="width: 160px;">Прогресс</th>
<th class="text-center">Заказано / Сделано / В смене</th> <th class="text-center">Заказано / Сделано / В смене</th>
@@ -339,6 +340,19 @@
{% if t.material %}{{ t.material.full_name|default:t.material.name }}{% else %}—{% endif %} {% if t.material %}{{ t.material.full_name|default:t.material.name }}{% else %}—{% endif %}
</div> </div>
</td> </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 class="small">{{ t.current_operation_name|default:"—" }}</td>
<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 }}%"> <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-toggle="modal"
data-bs-target="#workItemModal" data-bs-target="#workItemModal"
data-entity-id="{{ t.entity_id }}" data-entity-id="{{ t.entity_id }}"
data-batch-id="{{ t.delivery_batch_id|default:'' }}"
data-operation-id="{{ t.current_operation_id }}" data-operation-id="{{ t.current_operation_id }}"
data-workshop-id="{{ t.current_workshop_id|default:'' }}" data-workshop-id="{{ t.current_workshop_id|default:'' }}"
data-workshop-name="{{ t.current_workshop_name|default:'' }}" data-workshop-name="{{ t.current_workshop_name|default:'' }}"
@@ -389,7 +404,7 @@
</td> </td>
</tr> </tr>
{% empty %} {% 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 %} {% endfor %}
</tbody> </tbody>
</table> </table>
@@ -458,6 +473,7 @@
</div> </div>
<div class="modal-body"> <div class="modal-body">
<input type="hidden" name="entity_id" id="wiEntityId"> <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"> <input type="hidden" name="operation_id" id="wiOperationId">
<div class="small text-muted mb-2" id="wiTitle"></div> <div class="small text-muted mb-2" id="wiTitle"></div>
@@ -502,6 +518,97 @@
</div> </div>
</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 удалён: паспорт компонента открывается отдельной страницей --> <!-- productInfoModal удалён: паспорт компонента открывается отдельной страницей -->
<div class="d-none" id="productInfoModal" tabindex="-1" aria-hidden="true"> <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) { modal.addEventListener('shown.bs.modal', function (event) {
const btn = event.relatedTarget; const btn = event.relatedTarget;
const entityId = btn.getAttribute('data-entity-id') || ''; const entityId = btn.getAttribute('data-entity-id') || '';
const batchId = btn.getAttribute('data-batch-id') || '';
const opId = btn.getAttribute('data-operation-id') || ''; const opId = btn.getAttribute('data-operation-id') || '';
const name = btn.getAttribute('data-task-name') || ''; const name = btn.getAttribute('data-task-name') || '';
const opName = btn.getAttribute('data-operation-name') || ''; const opName = btn.getAttribute('data-operation-name') || '';
const rem = btn.getAttribute('data-task-rem'); const rem = btn.getAttribute('data-task-rem');
document.getElementById('wiEntityId').value = entityId; document.getElementById('wiEntityId').value = entityId;
document.getElementById('wiBatchId').value = batchId;
document.getElementById('wiOperationId').value = opId; document.getElementById('wiOperationId').value = opId;
document.getElementById('wiTitle').textContent = name; 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> </script>
{% endblock %} {% endblock %}

View File

@@ -25,6 +25,8 @@
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="container-fluid p-0"> <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"> <form method="post" action="{% url 'product_info' entity.id %}" enctype="multipart/form-data" id="product-info-form">
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="action" value="save"> <input type="hidden" name="action" value="save">
@@ -32,22 +34,12 @@
<input type="hidden" name="trail" value="{{ request.GET.trail|default:'' }}"> <input type="hidden" name="trail" value="{{ request.GET.trail|default:'' }}">
<div class="row g-2"> <div class="row g-2">
<div class="col-md-2"> <div class="col-md-6">
<label class="form-label">Тип</label> <label class="form-label">Тип</label>
<div class="mt-1"><span class="badge bg-secondary">{{ entity.get_entity_type_display }}</span></div> <div class="mt-1"><span class="badge bg-secondary">{{ entity.get_entity_type_display }}</span></div>
</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-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">
<label class="form-label">Заполнен</label> <label class="form-label">Заполнен</label>
<div class="form-check mt-2"> <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 %}> <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> </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> <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 %}> <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>
<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" 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">
<label class="form-label">Площадь покрытия, м²</label> <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 %}> <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>
<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> <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 %} {% 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 %} {% 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>
<div class="col-md-4"> <div class="col-12">
<label class="form-label">DXF/IGES/STEP</label> <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 %} {% 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 %} {% 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>
<div class="col-md-4"> <div class="col-12">
<label class="form-label">Картинка</label> <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 %} {% 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 %} {% 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>
{% if not can_edit %} {% if not can_edit %}
@@ -287,6 +301,92 @@
</script> </script>
</div> </div>
{% endif %} {% 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"> <div class="mt-4">
@@ -358,72 +458,6 @@
{% endif %} {% endif %}
</div> </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 %} {% if can_edit %}
<div class="modal fade" id="bomAddModal" tabindex="-1" aria-hidden="true"> <div class="modal fade" id="bomAddModal" tabindex="-1" aria-hidden="true">
@@ -594,4 +628,44 @@
</div> </div>
</div> </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 %} {% endblock %}

View File

@@ -82,26 +82,38 @@
<div class="col-md-4"> <div class="col-md-4">
<label class="form-label">Чертёж (PDF)</label> <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 %} {% 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 %} {% 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>
<div class="col-md-4"> <div class="col-md-4">
<label class="form-label">DXF/IGES/STEP</label> <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 %} {% 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 %} {% 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>
<div class="col-md-4"> <div class="col-md-4">
<label class="form-label">Картинка</label> <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 %} {% 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 %} {% 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>
<div class="col-12 d-flex justify-content-end mt-2"> <div class="col-12 d-flex justify-content-end mt-2">
@@ -275,4 +287,44 @@
</div> </div>
</div> </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 %} {% endblock %}

View File

@@ -24,7 +24,7 @@
</div> </div>
</div> </div>
<div class="card-body"> <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 %} {% csrf_token %}
<input type="hidden" name="action" value="save"> <input type="hidden" name="action" value="save">
<input type="hidden" name="next" value="{{ next }}"> <input type="hidden" name="next" value="{{ next }}">
@@ -64,4 +64,44 @@
</form> </form>
</div> </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 %} {% endblock %}

View File

@@ -57,26 +57,38 @@
<div class="col-md-4"> <div class="col-md-4">
<label class="form-label">Чертёж/ТЗ (PDF)</label> <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 %} {% 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 %} {% 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>
<div class="col-md-4"> <div class="col-md-4">
<label class="form-label">DXF/IGES/STEP</label> <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 %} {% 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 %} {% 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>
<div class="col-md-4"> <div class="col-md-4">
<label class="form-label">Картинка</label> <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 %} {% 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 %} {% 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>
{% if not can_edit %} {% if not can_edit %}
@@ -275,4 +287,44 @@
</div> </div>
</div> </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 %} {% endblock %}

View File

@@ -107,26 +107,38 @@
<div class="col-md-4"> <div class="col-md-4">
<label class="form-label">Чертёж (PDF)</label> <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 %} {% 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 %} {% 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>
<div class="col-md-4"> <div class="col-md-4">
<label class="form-label">DXF/IGES/STEP</label> <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 %} {% 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 %} {% 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>
<div class="col-md-4"> <div class="col-md-4">
<label class="form-label">Картинка</label> <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 %} {% 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 %} {% 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>
<div class="col-12"> <div class="col-12">
@@ -310,4 +322,44 @@
</div> </div>
</div> </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 %} {% endblock %}

View File

@@ -77,27 +77,40 @@
<div class="col-md-4"> <div class="col-md-4">
<label class="form-label">Чертёж/паспорт (PDF)</label> <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 %} {% 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 %} {% 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>
<div class="col-md-4"> <div class="col-md-4">
<label class="form-label">DXF/IGES/STEP</label> <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 %} {% 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 %} {% 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>
<div class="col-md-4"> <div class="col-md-4">
<label class="form-label">Картинка</label> <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 %} {% 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 %} {% 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>
<div class="col-12 d-flex justify-content-end mt-2"> <div class="col-12 d-flex justify-content-end mt-2">
{% if can_edit %} {% if can_edit %}
@@ -270,4 +283,44 @@
</div> </div>
</div> </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 %} {% endblock %}

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> </thead>
<tbody> <tbody>
{% for it in items %} {% 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>{{ it.location }}</td>
<td>{% if it.created_at %}{{ it.created_at|date:"d.m.Y H:i" }}{% endif %}</td> <td>{% if it.created_at %}{{ it.created_at|date:"d.m.Y H:i" }}{% endif %}</td>
<td> <td>
@@ -327,6 +327,90 @@
</div> </div>
</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> <script>
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
const modal = document.getElementById('transferModal'); const modal = document.getElementById('transferModal');
@@ -547,6 +631,69 @@
receiptMaterial.addEventListener('change', applyReceiptDefaults); receiptMaterial.addEventListener('change', applyReceiptDefaults);
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> </script>
{% endblock %} {% endblock %}

View File

@@ -76,15 +76,15 @@
<div class="row g-3 mt-1"> <div class="row g-3 mt-1">
<div class="col-lg-4"> <div class="col-lg-4">
<div class="small text-muted fw-bold mb-1">Списано</div> <div class="small text-muted fw-bold mb-1">Списано</div>
{% if card.report.consumptions.all %} {% if card.consumption_rows %}
<ul class="mb-0"> <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 %} {% if c.stock_item_id and c.stock_item.material_id %}
<li> <li>
{{ c.stock_item.material.full_name|default:c.stock_item.material.name }} {{ 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.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 %} {% 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> </li>
{% elif c.stock_item_id and c.stock_item.entity_id %} {% elif c.stock_item_id and c.stock_item.entity_id %}
<li> <li>
@@ -119,13 +119,13 @@
<div class="col-lg-4"> <div class="col-lg-4">
<div class="small text-muted fw-bold mb-1">Остаток ДО</div> <div class="small text-muted fw-bold mb-1">Остаток ДО</div>
{% if card.report.remnants.all %} {% if card.remnant_rows %}
<ul class="mb-0"> <ul class="mb-0">
{% for r in card.report.remnants.all %} {% for r in card.remnant_rows %}
<li> <li>
{{ r.material.full_name|default:r.material.name|default:r.material }} {{ 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 %}) ({% 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> </li>
{% endfor %} {% endfor %}
</ul> </ul>

View File

@@ -5,6 +5,8 @@ from .views import (
CustomersView, CustomersView,
DealDetailView, DealDetailView,
DealPlanningView, DealPlanningView,
DealMissingTechProcessView,
DealMissingMaterialView,
DealUpsertView, DealUpsertView,
DealBatchActionView, DealBatchActionView,
DealItemUpsertView, DealItemUpsertView,
@@ -54,9 +56,11 @@ from .views import (
LegacyRegistryView, LegacyRegistryView,
LegacyWriteOffsView, LegacyWriteOffsView,
WarehouseReceiptCreateView, WarehouseReceiptCreateView,
WarehouseStockItemUpdateView,
WarehouseStocksView, WarehouseStocksView,
WarehouseTransferCreateView, WarehouseTransferCreateView,
ProcurementDashboardView, ProcurementDashboardView,
ShippingJournalView,
ShippingView, ShippingView,
) )
@@ -70,6 +74,8 @@ urlpatterns = [
# Сделки # Сделки
path('planning/', PlanningView.as_view(), name='planning'), path('planning/', PlanningView.as_view(), name='planning'),
path('planning/deal/<int:pk>/', DealPlanningView.as_view(), name='planning_deal'), 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('planning/task/<int:pk>/items/', TaskItemsView.as_view(), name='task_items'),
path('customers/', CustomersView.as_view(), name='customers'), path('customers/', CustomersView.as_view(), name='customers'),
path('customers/<int:pk>/', CustomerDealsView.as_view(), name='customer_deals'), path('customers/<int:pk>/', CustomerDealsView.as_view(), name='customer_deals'),
@@ -114,6 +120,7 @@ urlpatterns = [
path('workitems/<int:deal_id>/<int:entity_id>/', WorkItemEntityListView.as_view(), name='workitem_entity_list'), 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/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/transfer/', WarehouseTransferCreateView.as_view(), name='warehouse_transfer'),
path('warehouse/receipt/', WarehouseReceiptCreateView.as_view(), name='warehouse_receipt'), path('warehouse/receipt/', WarehouseReceiptCreateView.as_view(), name='warehouse_receipt'),
@@ -122,6 +129,7 @@ urlpatterns = [
path('writeoffs/', WriteOffsView.as_view(), name='writeoffs'), path('writeoffs/', WriteOffsView.as_view(), name='writeoffs'),
path('procurement/', ProcurementDashboardView.as_view(), name='procurement'), path('procurement/', ProcurementDashboardView.as_view(), name='procurement'),
path('shipping/', ShippingView.as_view(), name='shipping'), 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/closing/', LegacyClosingView.as_view(), name='legacy_closing'),
path('legacy/writeoffs/', LegacyWriteOffsView.as_view(), name='legacy_writeoffs'), 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' %} {% if user_role in 'admin,clerk,manager,prod_head,director,observer,technologist' %}
<li class="nav-item"> <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> </li>
{% endif %} {% endif %}