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

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')
APP_VERSION = '0.7.1'
APP_VERSION = '0.8.9'
# Настройки безопасности
# DEBUG будет True везде, кроме сервера

View File

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

View File

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

View File

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

View File

@@ -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_reported = max(int(workitem.quantity_reported or 0), int(workitem.quantity_done or 0))
if workitem.quantity_done >= workitem.quantity_plan:
workitem.status = 'done'
workitem.save(update_fields=['quantity_done', 'status'])
workitem.save(update_fields=['quantity_done', 'quantity_reported', 'status'])
logger.info(
'assembly_closing:done workitem_id=%s qty=%s deal_id=%s location_id=%s user_id=%s report_id=%s',

View File

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

View File

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

View File

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

View File

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

View File

@@ -77,22 +77,30 @@
</div>
</div>
<form method="post" action="">
<form method="post" action="{% url 'assembly_closing' workitem.id %}">
{% csrf_token %}
<input type="hidden" name="action" value="close">
<div class="row align-items-end g-2">
<div class="col-md-6">
<label class="form-label text-muted small mb-1">Фактически собрано (шт.)</label>
<input type="number" class="form-control border-secondary" name="fact_qty" min="1" max="{{ max_possible }}" value="{{ max_possible }}" {% if max_possible == 0 %}disabled{% endif %}>
<input
type="number"
class="form-control border-secondary"
name="fact_qty"
min="1"
{% if max_possible and max_possible > 0 %}max="{{ max_possible }}"{% endif %}
value="{% if max_possible and max_possible > 0 %}{{ max_possible }}{% else %}1{% endif %}"
required
>
</div>
<div class="col-md-6">
{% if workitem.machine_id %}
<button type="submit" class="btn btn-warning w-100" {% if max_possible == 0 %}disabled{% endif %}>
<button type="submit" class="btn btn-warning w-100">
Списать компоненты и закрыть сборку
</button>
{% else %}
<button type="button" class="btn btn-warning w-100" data-bs-toggle="modal" data-bs-target="#selectMachineModal" {% if max_possible == 0 %}disabled{% endif %}>
<button type="button" class="btn btn-warning w-100" data-bs-toggle="modal" data-bs-target="#selectMachineModal">
Выбрать пост и закрыть
</button>
{% endif %}
@@ -113,10 +121,10 @@
<div class="modal-body">
{% if workshop_machines %}
<label class="form-label small text-muted mb-1">Пост</label>
<select class="form-select border-secondary" name="machine_id" required>
<select class="form-select border-secondary" name="machine_id" {% if not workitem.machine_id %}required{% else %}disabled{% endif %}>
<option value="">— выбрать —</option>
{% for m in workshop_machines %}
<option value="{{ m.id }}">{{ m.name }}</option>
<option value="{{ m.id }}">{% if m.workshop %}{{ m.workshop.name }} · {% endif %}{{ m.name }}</option>
{% endfor %}
</select>
{% else %}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,6 +5,8 @@ from .views import (
CustomersView,
DealDetailView,
DealPlanningView,
DealMissingTechProcessView,
DealMissingMaterialView,
DealUpsertView,
DealBatchActionView,
DealItemUpsertView,
@@ -54,9 +56,11 @@ from .views import (
LegacyRegistryView,
LegacyWriteOffsView,
WarehouseReceiptCreateView,
WarehouseStockItemUpdateView,
WarehouseStocksView,
WarehouseTransferCreateView,
ProcurementDashboardView,
ShippingJournalView,
ShippingView,
)
@@ -70,6 +74,8 @@ urlpatterns = [
# Сделки
path('planning/', PlanningView.as_view(), name='planning'),
path('planning/deal/<int:pk>/', DealPlanningView.as_view(), name='planning_deal'),
path('planning/deal/<int:pk>/missing-tech/', DealMissingTechProcessView.as_view(), name='deal_missing_tech_process'),
path('planning/deal/<int:pk>/missing-material/', DealMissingMaterialView.as_view(), name='deal_missing_material'),
path('planning/task/<int:pk>/items/', TaskItemsView.as_view(), name='task_items'),
path('customers/', CustomersView.as_view(), name='customers'),
path('customers/<int:pk>/', CustomerDealsView.as_view(), name='customer_deals'),
@@ -114,6 +120,7 @@ urlpatterns = [
path('workitems/<int:deal_id>/<int:entity_id>/', WorkItemEntityListView.as_view(), name='workitem_entity_list'),
path('warehouse/stocks/', WarehouseStocksView.as_view(), name='warehouse_stocks'),
path('warehouse/stock-item/update/', WarehouseStockItemUpdateView.as_view(), name='warehouse_stockitem_update'),
path('warehouse/transfer/', WarehouseTransferCreateView.as_view(), name='warehouse_transfer'),
path('warehouse/receipt/', WarehouseReceiptCreateView.as_view(), name='warehouse_receipt'),
@@ -122,6 +129,7 @@ urlpatterns = [
path('writeoffs/', WriteOffsView.as_view(), name='writeoffs'),
path('procurement/', ProcurementDashboardView.as_view(), name='procurement'),
path('shipping/', ShippingView.as_view(), name='shipping'),
path('shipping/journal/', ShippingJournalView.as_view(), name='shipping_journal'),
path('legacy/closing/', LegacyClosingView.as_view(), name='legacy_closing'),
path('legacy/writeoffs/', LegacyWriteOffsView.as_view(), name='legacy_writeoffs'),

File diff suppressed because it is too large Load Diff

View File

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