Compare commits
10 Commits
c558eb1416
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 6fd01c9a6e | |||
| 963dc7105a | |||
| ae9c747c78 | |||
| 248f6987c8 | |||
| ede5358015 | |||
| f60503d962 | |||
| 6d13e5a321 | |||
| da8ef32769 | |||
| 6da7b775c7 | |||
| 3efd8e5060 |
@@ -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.
|
||||
Контекст: mini‑MES / оперативное управление производством для металлообработки. Приложения: 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
33
CHANGELOG.md
Normal 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 и процесс ведения истории изменений.
|
||||
@@ -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 везде, кроме сервера
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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')},
|
||||
),
|
||||
]
|
||||
@@ -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 — основной признак операции (расширяемый справочник).
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -132,7 +132,13 @@
|
||||
|
||||
<div class="card shadow border-secondary">
|
||||
<div class="card-header border-secondary py-3 d-flex justify-content-between align-items-center">
|
||||
<div class="d-flex flex-wrap gap-3 align-items-center">
|
||||
<h5 class="mb-0">Остаток ДО</h5>
|
||||
<div class="form-check mb-0">
|
||||
<input class="form-check-input" type="checkbox" name="inherit_deal_for_remnants" id="inheritDealForRemnants" value="1" checked>
|
||||
<label class="form-check-label" for="inheritDealForRemnants">Привязать ДО к сделке сырья</label>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-outline-accent btn-sm" id="addRemnantBtn" {% if not can_edit %}disabled{% endif %}>Добавить ДО</button>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
|
||||
@@ -135,7 +135,13 @@
|
||||
|
||||
<div class="card shadow border-secondary">
|
||||
<div class="card-header border-secondary py-3 d-flex justify-content-between align-items-center">
|
||||
<div class="d-flex flex-wrap gap-3 align-items-center">
|
||||
<h5 class="mb-0">Остаток ДО</h5>
|
||||
<div class="form-check mb-0">
|
||||
<input class="form-check-input" type="checkbox" name="inherit_deal_for_remnants" id="inheritDealForRemnants" value="1" checked>
|
||||
<label class="form-check-label" for="inheritDealForRemnants">Привязать ДО к сделке сырья</label>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-outline-accent btn-sm" id="addRemnantBtn" {% if not can_edit %}disabled{% endif %}>Добавить ДО</button>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
|
||||
54
shiftflow/templates/shiftflow/missing_techprocess.html
Normal file
54
shiftflow/templates/shiftflow/missing_techprocess.html
Normal 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 %}
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -25,6 +25,8 @@
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="container-fluid p-0">
|
||||
<div class="row g-3">
|
||||
<div class="col-lg-5">
|
||||
<form method="post" action="{% url 'product_info' entity.id %}" enctype="multipart/form-data" id="product-info-form">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="save">
|
||||
@@ -32,22 +34,12 @@
|
||||
<input type="hidden" name="trail" value="{{ request.GET.trail|default:'' }}">
|
||||
|
||||
<div class="row g-2">
|
||||
<div class="col-md-2">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Тип</label>
|
||||
<div class="mt-1"><span class="badge bg-secondary">{{ entity.get_entity_type_display }}</span></div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Обозначение</label>
|
||||
<input class="form-control bg-body text-body border-secondary" name="drawing_number" value="{{ entity.drawing_number }}" {% if not can_edit %}disabled{% endif %}>
|
||||
</div>
|
||||
|
||||
<div class="col-md-5">
|
||||
<label class="form-label">Наименование</label>
|
||||
<input class="form-control bg-body text-body border-secondary" name="name" value="{{ entity.name }}" {% if not can_edit %}disabled{% endif %}>
|
||||
</div>
|
||||
|
||||
<div class="col-md-2">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Заполнен</label>
|
||||
<div class="form-check mt-2">
|
||||
<input class="form-check-input" type="checkbox" name="passport_filled" id="pf" {% if entity.passport_filled %}checked{% endif %} {% if not can_edit %}disabled{% endif %}>
|
||||
@@ -55,48 +47,70 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Обозначение</label>
|
||||
<input class="form-control bg-body text-body border-secondary" name="drawing_number" value="{{ entity.drawing_number }}" {% if not can_edit %}disabled{% endif %}>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Наименование</label>
|
||||
<input class="form-control bg-body text-body border-secondary" name="name" value="{{ entity.name }}" {% if not can_edit %}disabled{% endif %}>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Масса, кг</label>
|
||||
<input class="form-control bg-body text-body border-secondary" name="weight_kg" value="{% if passport and passport.weight_kg %}{{ passport.weight_kg }}{% endif %}" inputmode="decimal" {% if not can_edit %}disabled{% endif %}>
|
||||
</div>
|
||||
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Покрытие</label>
|
||||
<input class="form-control bg-body text-body border-secondary" name="coating" value="{% if passport %}{{ passport.coating }}{% endif %}" {% if not can_edit %}disabled{% endif %}>
|
||||
</div>
|
||||
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Цвет</label>
|
||||
<input class="form-control bg-body text-body border-secondary" name="coating_color" value="{% if passport %}{{ passport.coating_color }}{% endif %}" {% if not can_edit %}disabled{% endif %}>
|
||||
</div>
|
||||
|
||||
<div class="col-md-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Площадь покрытия, м²</label>
|
||||
<input class="form-control bg-body text-body border-secondary" name="coating_area_m2" value="{% if passport and passport.coating_area_m2 %}{{ passport.coating_area_m2 }}{% endif %}" inputmode="decimal" {% if not can_edit %}disabled{% endif %}>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Покрытие</label>
|
||||
<input class="form-control bg-body text-body border-secondary" name="coating" value="{% if passport %}{{ passport.coating }}{% endif %}" {% if not can_edit %}disabled{% endif %}>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Цвет</label>
|
||||
<input class="form-control bg-body text-body border-secondary" name="coating_color" value="{% if passport %}{{ passport.coating_color }}{% endif %}" {% if not can_edit %}disabled{% endif %}>
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<label class="form-label">Чертёж (PDF)</label>
|
||||
<input class="form-control bg-body text-body border-secondary" type="file" name="pdf_main" accept="application/pdf" {% if not can_edit %}disabled{% endif %}>
|
||||
<div class="d-flex gap-2 align-items-center">
|
||||
{% if entity.pdf_main %}
|
||||
<div class="small mt-1"><a href="{{ entity.pdf_main.url }}" target="_blank">Открыть текущий</a></div>
|
||||
<a href="{{ entity.pdf_main.url }}" target="_blank" rel="noopener" class="btn btn-sm btn-outline-danger p-1" title="Чертёж PDF">
|
||||
<i class="bi bi-file-pdf"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
<input class="form-control bg-body text-body border-secondary" type="file" name="pdf_main" accept="application/pdf" {% if not can_edit %}disabled{% endif %}>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<div class="col-12">
|
||||
<label class="form-label">DXF/IGES/STEP</label>
|
||||
<input class="form-control bg-body text-body border-secondary" type="file" name="dxf_file" accept=".dxf,.iges,.igs,.step,.stp" {% if not can_edit %}disabled{% endif %}>
|
||||
<div class="d-flex gap-2 align-items-center">
|
||||
{% if entity.dxf_file %}
|
||||
<div class="small mt-1"><a href="{{ entity.dxf_file.url }}" target="_blank">Открыть текущий</a></div>
|
||||
<a href="{{ entity.dxf_file.url }}" target="_blank" rel="noopener" class="btn btn-sm btn-outline-info p-1" title="DXF/IGES/STEP">
|
||||
<i class="bi bi-file-earmark-code"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
<input class="form-control bg-body text-body border-secondary" type="file" name="dxf_file" accept=".dxf,.iges,.igs,.step,.stp" {% if not can_edit %}disabled{% endif %}>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<div class="col-12">
|
||||
<label class="form-label">Картинка</label>
|
||||
<input class="form-control bg-body text-body border-secondary" type="file" name="preview" accept="image/*" {% if not can_edit %}disabled{% endif %}>
|
||||
<div class="d-flex gap-2 align-items-center">
|
||||
{% if entity.preview %}
|
||||
<div class="small mt-1"><a href="{{ entity.preview.url }}" target="_blank">Открыть текущую</a></div>
|
||||
<a href="{{ entity.preview.url }}" target="_blank" rel="noopener" class="btn btn-sm btn-outline-success p-1" title="Картинка">
|
||||
<i class="bi bi-file-earmark-image"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
<input class="form-control bg-body text-body border-secondary" type="file" name="preview" accept="image/*" {% if not can_edit %}disabled{% endif %}>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if not can_edit %}
|
||||
@@ -287,6 +301,92 @@
|
||||
</script>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="col-lg-7">
|
||||
<div class="border border-secondary rounded p-2">
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<div class="fw-bold">Состав</div>
|
||||
{% if can_edit %}
|
||||
<button type="button" class="btn btn-outline-accent btn-sm" data-bs-toggle="modal" data-bs-target="#bomAddModal">Добавить компонент</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0 align-middle">
|
||||
<thead>
|
||||
<tr class="table-custom-header">
|
||||
<th>Тип</th>
|
||||
<th>Обозначение</th>
|
||||
<th>Наименование</th>
|
||||
<th data-sort="false" class="text-center" style="width:110px;">Файлы</th>
|
||||
<th class="text-center" style="width:120px;">Заполнено</th>
|
||||
<th class="text-center">Кол-во</th>
|
||||
<th data-sort="false" class="text-end"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for ln in bom_lines %}
|
||||
<tr role="button" style="cursor:pointer" onclick="window.location.href='{% url 'product_info' ln.child.id %}?next={{ request.get_full_path|urlencode }}&trail={{ trail_child|urlencode }}';">
|
||||
<td class="small text-muted">{{ ln.child.get_entity_type_display }}</td>
|
||||
<td class="fw-bold">{{ ln.child.drawing_number|default:"—" }}</td>
|
||||
<td>{{ ln.child.name }}</td>
|
||||
<td class="text-center" onclick="event.stopPropagation();">
|
||||
{% if ln.child.dxf_file %}
|
||||
<a href="{{ ln.child.dxf_file.url }}" target="_blank" rel="noopener" class="btn btn-sm btn-outline-info p-1 stop-prop" title="DXF/IGES/STEP">
|
||||
<i class="bi bi-file-earmark-code"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if ln.child.pdf_main %}
|
||||
<a href="{{ ln.child.pdf_main.url }}" target="_blank" rel="noopener" class="btn btn-sm btn-outline-danger p-1 stop-prop" title="Чертёж PDF">
|
||||
<i class="bi bi-file-pdf"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if ln.child.preview %}
|
||||
<a href="{{ ln.child.preview.url }}" target="_blank" rel="noopener" class="btn btn-sm btn-outline-success p-1 stop-prop" title="Картинка">
|
||||
<i class="bi bi-file-earmark-image"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-center">
|
||||
{% if ln.child.passport_filled %}
|
||||
<span class="badge bg-success">Да</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">Нет</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-center" style="max-width:220px;" onclick="event.stopPropagation();">
|
||||
<form method="post" action="{% url 'product_info' entity.id %}" class="d-flex gap-2 align-items-center justify-content-center" onclick="event.stopPropagation();">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="bom_update_qty">
|
||||
<input type="hidden" name="bom_id" value="{{ ln.id }}">
|
||||
<input type="hidden" name="trail" value="{{ request.GET.trail|default:'' }}">
|
||||
<input type="hidden" name="next" value="{{ next }}">
|
||||
<input class="form-control form-control-sm bg-body text-body border-secondary" name="quantity" value="{{ ln.quantity }}" {% if not can_edit %}disabled{% endif %}>
|
||||
<button class="btn btn-outline-secondary btn-sm" type="submit" {% if not can_edit %}disabled{% endif %}>OK</button>
|
||||
</form>
|
||||
</td>
|
||||
<td class="text-end">
|
||||
{% if can_edit %}
|
||||
<form method="post" action="{% url 'product_info' entity.id %}">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="bom_delete_line">
|
||||
<input type="hidden" name="bom_id" value="{{ ln.id }}">
|
||||
<input type="hidden" name="trail" value="{{ request.GET.trail|default:'' }}">
|
||||
<input type="hidden" name="next" value="{{ next }}">
|
||||
<button class="btn btn-outline-secondary btn-sm" type="submit">Удалить</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="7" class="text-center text-muted py-4">Пока нет компонентов</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="mt-4">
|
||||
@@ -358,72 +458,6 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<hr class="border-secondary my-4">
|
||||
|
||||
<div class="mt-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<div class="fw-bold">Состав</div>
|
||||
{% if can_edit %}
|
||||
<button type="button" class="btn btn-outline-accent btn-sm" data-bs-toggle="modal" data-bs-target="#bomAddModal">Добавить компонент</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0 align-middle">
|
||||
<thead>
|
||||
<tr class="table-custom-header">
|
||||
<th>Тип</th>
|
||||
<th>Обозначение</th>
|
||||
<th>Наименование</th>
|
||||
<th class="text-center" style="width:120px;">Заполнено</th>
|
||||
<th class="text-center">Кол-во</th>
|
||||
<th data-sort="false" class="text-end"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for ln in bom_lines %}
|
||||
<tr role="button" style="cursor:pointer" onclick="window.location.href='{% url 'product_info' ln.child.id %}?next={{ request.get_full_path|urlencode }}&trail={{ trail_child|urlencode }}';">
|
||||
<td class="small text-muted">{{ ln.child.get_entity_type_display }}</td>
|
||||
<td class="fw-bold">{{ ln.child.drawing_number|default:"—" }}</td>
|
||||
<td>{{ ln.child.name }}</td>
|
||||
<td class="text-center">
|
||||
{% if ln.child.passport_filled %}
|
||||
<span class="badge bg-success">Да</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">Нет</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-center" style="max-width:220px;" onclick="event.stopPropagation();">
|
||||
<form method="post" action="{% url 'product_info' entity.id %}" class="d-flex gap-2 align-items-center justify-content-center" onclick="event.stopPropagation();">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="bom_update_qty">
|
||||
<input type="hidden" name="bom_id" value="{{ ln.id }}">
|
||||
<input type="hidden" name="trail" value="{{ request.GET.trail|default:'' }}">
|
||||
<input type="hidden" name="next" value="{{ next }}">
|
||||
<input class="form-control form-control-sm bg-body text-body border-secondary" name="quantity" value="{{ ln.quantity }}" {% if not can_edit %}disabled{% endif %}>
|
||||
<button class="btn btn-outline-secondary btn-sm" type="submit" {% if not can_edit %}disabled{% endif %}>OK</button>
|
||||
</form>
|
||||
</td>
|
||||
<td class="text-end">
|
||||
{% if can_edit %}
|
||||
<form method="post" action="{% url 'product_info' entity.id %}">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="bom_delete_line">
|
||||
<input type="hidden" name="bom_id" value="{{ ln.id }}">
|
||||
<input type="hidden" name="trail" value="{{ request.GET.trail|default:'' }}">
|
||||
<input type="hidden" name="next" value="{{ next }}">
|
||||
<button class="btn btn-outline-secondary btn-sm" type="submit">Удалить</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="6" class="text-center text-muted py-4">Пока нет компонентов</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if can_edit %}
|
||||
<div class="modal fade" id="bomAddModal" tabindex="-1" aria-hidden="true">
|
||||
@@ -594,4 +628,44 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{ next|json_script:"productInfoNext" }}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
const form = document.getElementById('product-info-form');
|
||||
|
||||
function getNextUrl() {
|
||||
const el = document.getElementById('productInfoNext');
|
||||
return el ? JSON.parse(el.textContent || '""') : '';
|
||||
}
|
||||
|
||||
function submitForm() {
|
||||
if (!form) return;
|
||||
if (form.requestSubmit) {
|
||||
form.requestSubmit();
|
||||
} else {
|
||||
form.submit();
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Escape') {
|
||||
if (e.defaultPrevented) return;
|
||||
if (document.querySelector('.modal.show')) return;
|
||||
const url = getNextUrl();
|
||||
if (url) {
|
||||
e.preventDefault();
|
||||
window.location.href = url;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const key = (e.key || '').toLowerCase();
|
||||
if ((e.ctrlKey || e.metaKey) && key === 's') {
|
||||
e.preventDefault();
|
||||
submitForm();
|
||||
}
|
||||
}, true);
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -82,26 +82,38 @@
|
||||
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Чертёж (PDF)</label>
|
||||
<input class="form-control bg-body text-body border-secondary" type="file" name="pdf_main" accept="application/pdf" {% if not can_edit %}disabled{% endif %}>
|
||||
<div class="input-group">
|
||||
{% if entity.pdf_main %}
|
||||
<div class="small mt-1"><a href="{{ entity.pdf_main.url }}" target="_blank">Открыть текущий</a></div>
|
||||
<a href="{{ entity.pdf_main.url }}" target="_blank" rel="noopener" class="btn btn-outline-danger" title="Чертёж PDF">
|
||||
<i class="bi bi-file-pdf"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
<input class="form-control bg-body text-body border-secondary" type="file" name="pdf_main" accept="application/pdf" {% if not can_edit %}disabled{% endif %}>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">DXF/IGES/STEP</label>
|
||||
<input class="form-control bg-body text-body border-secondary" type="file" name="dxf_file" accept=".dxf,.iges,.igs,.step,.stp" {% if not can_edit %}disabled{% endif %}>
|
||||
<div class="input-group">
|
||||
{% if entity.dxf_file %}
|
||||
<div class="small mt-1"><a href="{{ entity.dxf_file.url }}" target="_blank">Открыть текущий</a></div>
|
||||
<a href="{{ entity.dxf_file.url }}" target="_blank" rel="noopener" class="btn btn-outline-info" title="DXF/IGES/STEP">
|
||||
<i class="bi bi-file-earmark-code"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
<input class="form-control bg-body text-body border-secondary" type="file" name="dxf_file" accept=".dxf,.iges,.igs,.step,.stp" {% if not can_edit %}disabled{% endif %}>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Картинка</label>
|
||||
<input class="form-control bg-body text-body border-secondary" type="file" name="preview" accept="image/*" {% if not can_edit %}disabled{% endif %}>
|
||||
<div class="input-group">
|
||||
{% if entity.preview %}
|
||||
<div class="small mt-1"><a href="{{ entity.preview.url }}" target="_blank">Открыть текущую</a></div>
|
||||
<a href="{{ entity.preview.url }}" target="_blank" rel="noopener" class="btn btn-outline-success" title="Картинка">
|
||||
<i class="bi bi-file-earmark-image"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
<input class="form-control bg-body text-body border-secondary" type="file" name="preview" accept="image/*" {% if not can_edit %}disabled{% endif %}>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 d-flex justify-content-end mt-2">
|
||||
@@ -275,4 +287,44 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{ next|json_script:"productInfoNext" }}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
const form = document.getElementById('product-info-form');
|
||||
|
||||
function getNextUrl() {
|
||||
const el = document.getElementById('productInfoNext');
|
||||
return el ? JSON.parse(el.textContent || '""') : '';
|
||||
}
|
||||
|
||||
function submitForm() {
|
||||
if (!form) return;
|
||||
if (form.requestSubmit) {
|
||||
form.requestSubmit();
|
||||
} else {
|
||||
form.submit();
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Escape') {
|
||||
if (e.defaultPrevented) return;
|
||||
if (document.querySelector('.modal.show')) return;
|
||||
const url = getNextUrl();
|
||||
if (url) {
|
||||
e.preventDefault();
|
||||
window.location.href = url;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const key = (e.key || '').toLowerCase();
|
||||
if ((e.ctrlKey || e.metaKey) && key === 's') {
|
||||
e.preventDefault();
|
||||
submitForm();
|
||||
}
|
||||
}, true);
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -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 %}
|
||||
@@ -57,26 +57,38 @@
|
||||
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Чертёж/ТЗ (PDF)</label>
|
||||
<input class="form-control bg-body text-body border-secondary" type="file" name="pdf_main" accept="application/pdf" {% if not can_edit %}disabled{% endif %}>
|
||||
<div class="input-group">
|
||||
{% if entity.pdf_main %}
|
||||
<div class="small mt-1"><a href="{{ entity.pdf_main.url }}" target="_blank">Открыть текущий</a></div>
|
||||
<a href="{{ entity.pdf_main.url }}" target="_blank" rel="noopener" class="btn btn-outline-danger" title="Чертёж/ТЗ PDF">
|
||||
<i class="bi bi-file-pdf"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
<input class="form-control bg-body text-body border-secondary" type="file" name="pdf_main" accept="application/pdf" {% if not can_edit %}disabled{% endif %}>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">DXF/IGES/STEP</label>
|
||||
<input class="form-control bg-body text-body border-secondary" type="file" name="dxf_file" accept=".dxf,.iges,.igs,.step,.stp" {% if not can_edit %}disabled{% endif %}>
|
||||
<div class="input-group">
|
||||
{% if entity.dxf_file %}
|
||||
<div class="small mt-1"><a href="{{ entity.dxf_file.url }}" target="_blank">Открыть текущий</a></div>
|
||||
<a href="{{ entity.dxf_file.url }}" target="_blank" rel="noopener" class="btn btn-outline-info" title="DXF/IGES/STEP">
|
||||
<i class="bi bi-file-earmark-code"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
<input class="form-control bg-body text-body border-secondary" type="file" name="dxf_file" accept=".dxf,.iges,.igs,.step,.stp" {% if not can_edit %}disabled{% endif %}>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Картинка</label>
|
||||
<input class="form-control bg-body text-body border-secondary" type="file" name="preview" accept="image/*" {% if not can_edit %}disabled{% endif %}>
|
||||
<div class="input-group">
|
||||
{% if entity.preview %}
|
||||
<div class="small mt-1"><a href="{{ entity.preview.url }}" target="_blank">Открыть текущую</a></div>
|
||||
<a href="{{ entity.preview.url }}" target="_blank" rel="noopener" class="btn btn-outline-success" title="Картинка">
|
||||
<i class="bi bi-file-earmark-image"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
<input class="form-control bg-body text-body border-secondary" type="file" name="preview" accept="image/*" {% if not can_edit %}disabled{% endif %}>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if not can_edit %}
|
||||
@@ -275,4 +287,44 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{ next|json_script:"productInfoNext" }}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
const form = document.getElementById('product-info-form');
|
||||
|
||||
function getNextUrl() {
|
||||
const el = document.getElementById('productInfoNext');
|
||||
return el ? JSON.parse(el.textContent || '""') : '';
|
||||
}
|
||||
|
||||
function submitForm() {
|
||||
if (!form) return;
|
||||
if (form.requestSubmit) {
|
||||
form.requestSubmit();
|
||||
} else {
|
||||
form.submit();
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Escape') {
|
||||
if (e.defaultPrevented) return;
|
||||
if (document.querySelector('.modal.show')) return;
|
||||
const url = getNextUrl();
|
||||
if (url) {
|
||||
e.preventDefault();
|
||||
window.location.href = url;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const key = (e.key || '').toLowerCase();
|
||||
if ((e.ctrlKey || e.metaKey) && key === 's') {
|
||||
e.preventDefault();
|
||||
submitForm();
|
||||
}
|
||||
}, true);
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -107,26 +107,38 @@
|
||||
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Чертёж (PDF)</label>
|
||||
<input class="form-control bg-body text-body border-secondary" type="file" name="pdf_main" accept="application/pdf" {% if not can_edit %}disabled{% endif %}>
|
||||
<div class="d-flex gap-2 align-items-center">
|
||||
{% if entity.pdf_main %}
|
||||
<div class="small mt-1"><a href="{{ entity.pdf_main.url }}" target="_blank">Открыть текущий</a></div>
|
||||
<a href="{{ entity.pdf_main.url }}" target="_blank" class="btn btn-sm btn-outline-danger p-1" title="PDF">
|
||||
<i class="bi bi-file-pdf"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
<input class="form-control bg-body text-body border-secondary" type="file" name="pdf_main" accept="application/pdf" {% if not can_edit %}disabled{% endif %}>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">DXF/IGES/STEP</label>
|
||||
<input class="form-control bg-body text-body border-secondary" type="file" name="dxf_file" accept=".dxf,.iges,.igs,.step,.stp" {% if not can_edit %}disabled{% endif %}>
|
||||
<div class="d-flex gap-2 align-items-center">
|
||||
{% if entity.dxf_file %}
|
||||
<div class="small mt-1"><a href="{{ entity.dxf_file.url }}" target="_blank">Открыть текущий</a></div>
|
||||
<a href="{{ entity.dxf_file.url }}" target="_blank" class="btn btn-sm btn-outline-info p-1" title="DXF/IGES/STEP">
|
||||
<i class="bi bi-file-earmark-code"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
<input class="form-control bg-body text-body border-secondary" type="file" name="dxf_file" accept=".dxf,.iges,.igs,.step,.stp" {% if not can_edit %}disabled{% endif %}>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Картинка</label>
|
||||
<input class="form-control bg-body text-body border-secondary" type="file" name="preview" accept="image/*" {% if not can_edit %}disabled{% endif %}>
|
||||
<div class="d-flex gap-2 align-items-center">
|
||||
{% if entity.preview %}
|
||||
<div class="small mt-1"><a href="{{ entity.preview.url }}" target="_blank">Открыть текущую</a></div>
|
||||
<a href="{{ entity.preview.url }}" target="_blank" class="btn btn-sm btn-outline-success p-1" title="Картинка">
|
||||
<i class="bi bi-file-earmark-image"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
<input class="form-control bg-body text-body border-secondary" type="file" name="preview" accept="image/*" {% if not can_edit %}disabled{% endif %}>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
@@ -310,4 +322,44 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{ next|json_script:"productInfoNext" }}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
const form = document.getElementById('product-info-form');
|
||||
|
||||
function getNextUrl() {
|
||||
const el = document.getElementById('productInfoNext');
|
||||
return el ? JSON.parse(el.textContent || '""') : '';
|
||||
}
|
||||
|
||||
function submitForm() {
|
||||
if (!form) return;
|
||||
if (form.requestSubmit) {
|
||||
form.requestSubmit();
|
||||
} else {
|
||||
form.submit();
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Escape') {
|
||||
if (e.defaultPrevented) return;
|
||||
if (document.querySelector('.modal.show')) return;
|
||||
const url = getNextUrl();
|
||||
if (url) {
|
||||
e.preventDefault();
|
||||
window.location.href = url;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const key = (e.key || '').toLowerCase();
|
||||
if ((e.ctrlKey || e.metaKey) && key === 's') {
|
||||
e.preventDefault();
|
||||
submitForm();
|
||||
}
|
||||
}, true);
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -77,27 +77,40 @@
|
||||
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Чертёж/паспорт (PDF)</label>
|
||||
<input class="form-control bg-body text-body border-secondary" type="file" name="pdf_main" accept="application/pdf" {% if not can_edit %}disabled{% endif %}>
|
||||
<div class="d-flex gap-2 align-items-center">
|
||||
{% if entity.pdf_main %}
|
||||
<div class="small mt-1"><a href="{{ entity.pdf_main.url }}" target="_blank">Открыть текущий</a></div>
|
||||
<a href="{{ entity.pdf_main.url }}" target="_blank" class="btn btn-sm btn-outline-danger p-1" title="PDF">
|
||||
<i class="bi bi-file-pdf"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
<input class="form-control bg-body text-body border-secondary" type="file" name="pdf_main" accept="application/pdf" {% if not can_edit %}disabled{% endif %}>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">DXF/IGES/STEP</label>
|
||||
<input class="form-control bg-body text-body border-secondary" type="file" name="dxf_file" accept=".dxf,.iges,.igs,.step,.stp" {% if not can_edit %}disabled{% endif %}>
|
||||
<div class="d-flex gap-2 align-items-center">
|
||||
{% if entity.dxf_file %}
|
||||
<div class="small mt-1"><a href="{{ entity.dxf_file.url }}" target="_blank">Открыть текущий</a></div>
|
||||
<a href="{{ entity.dxf_file.url }}" target="_blank" class="btn btn-sm btn-outline-info p-1" title="DXF/IGES/STEP">
|
||||
<i class="bi bi-file-earmark-code"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
<input class="form-control bg-body text-body border-secondary" type="file" name="dxf_file" accept=".dxf,.iges,.igs,.step,.stp" {% if not can_edit %}disabled{% endif %}>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Картинка</label>
|
||||
<input class="form-control bg-body text-body border-secondary" type="file" name="preview" accept="image/*" {% if not can_edit %}disabled{% endif %}>
|
||||
<div class="d-flex gap-2 align-items-center">
|
||||
{% if entity.preview %}
|
||||
<div class="small mt-1"><a href="{{ entity.preview.url }}" target="_blank">Открыть текущую</a></div>
|
||||
<a href="{{ entity.preview.url }}" target="_blank" class="btn btn-sm btn-outline-success p-1" title="Картинка">
|
||||
<i class="bi bi-file-earmark-image"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
<input class="form-control bg-body text-body border-secondary" type="file" name="preview" accept="image/*" {% if not can_edit %}disabled{% endif %}>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="col-12 d-flex justify-content-end mt-2">
|
||||
{% if can_edit %}
|
||||
@@ -270,4 +283,44 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{ next|json_script:"productInfoNext" }}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
const form = document.getElementById('product-info-form');
|
||||
|
||||
function getNextUrl() {
|
||||
const el = document.getElementById('productInfoNext');
|
||||
return el ? JSON.parse(el.textContent || '""') : '';
|
||||
}
|
||||
|
||||
function submitForm() {
|
||||
if (!form) return;
|
||||
if (form.requestSubmit) {
|
||||
form.requestSubmit();
|
||||
} else {
|
||||
form.submit();
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Escape') {
|
||||
if (e.defaultPrevented) return;
|
||||
if (document.querySelector('.modal.show')) return;
|
||||
const url = getNextUrl();
|
||||
if (url) {
|
||||
e.preventDefault();
|
||||
window.location.href = url;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const key = (e.key || '').toLowerCase();
|
||||
if ((e.ctrlKey || e.metaKey) && key === 's') {
|
||||
e.preventDefault();
|
||||
submitForm();
|
||||
}
|
||||
}, true);
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
206
shiftflow/templates/shiftflow/shipping_cart.html
Normal file
206
shiftflow/templates/shiftflow/shipping_cart.html
Normal 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 %}
|
||||
90
shiftflow/templates/shiftflow/shipping_journal.html
Normal file
90
shiftflow/templates/shiftflow/shipping_journal.html
Normal 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 %}
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
|
||||
@@ -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'),
|
||||
|
||||
|
||||
1041
shiftflow/views.py
1041
shiftflow/views.py
File diff suppressed because it is too large
Load Diff
@@ -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 %}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user