Конкретно пересмотрел логику работы. Легаси вынесена в архив
All checks were successful
Deploy MES Core / deploy (push) Successful in 13s
All checks were successful
Deploy MES Core / deploy (push) Successful in 13s
This commit is contained in:
@@ -0,0 +1,129 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="card shadow border-secondary mb-3">
|
||||||
|
<div class="card-header border-secondary py-3 d-flex flex-wrap justify-content-between align-items-center gap-2">
|
||||||
|
<h3 class="text-accent mb-0"><i class="bi bi-activity me-2"></i>Марки стали</h3>
|
||||||
|
|
||||||
|
<div class="d-flex flex-wrap gap-2 align-items-center">
|
||||||
|
<form method="get" class="d-flex gap-2 align-items-center">
|
||||||
|
<input class="form-control form-control-sm bg-body text-body border-secondary" name="q" value="{{ q }}" placeholder="Поиск...">
|
||||||
|
<button class="btn btn-outline-secondary btn-sm" type="submit"><i class="bi bi-search me-1"></i>Найти</button>
|
||||||
|
<a class="btn btn-outline-secondary btn-sm" href="{% url 'steel_grades_catalog' %}"><i class="bi bi-arrow-counterclockwise me-1"></i>Сброс</a>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{% if can_edit %}
|
||||||
|
<button class="btn btn-outline-accent btn-sm" type="button" data-bs-toggle="modal" data-bs-target="#gradeModal" onclick="openGradeCreate()">
|
||||||
|
<i class="bi bi-plus-lg me-1"></i>Создать
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover mb-0 align-middle">
|
||||||
|
<thead>
|
||||||
|
<tr class="table-custom-header">
|
||||||
|
<th>Название</th>
|
||||||
|
<th>ГОСТ</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for g in grades %}
|
||||||
|
<tr role="button" {% if can_edit %}onclick="openGradeEdit(this)"{% endif %}
|
||||||
|
data-id="{{ g.id }}" data-name="{{ g.name }}" data-gost="{{ g.gost_standard }}">
|
||||||
|
<td class="fw-bold">{{ g.name }}</td>
|
||||||
|
<td>{{ g.gost_standard|default:"—" }}</td>
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr><td colspan="2" class="text-center text-muted py-4">Нет данных</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<a class="btn btn-outline-secondary btn-sm" href="{% url 'directories' %}">Назад</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal fade" id="gradeModal" tabindex="-1" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-lg">
|
||||||
|
<form class="modal-content border-secondary" onsubmit="event.preventDefault(); saveGrade();">
|
||||||
|
<div class="modal-header border-secondary">
|
||||||
|
<h5 class="modal-title" id="gradeModalTitle">Марка</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-body">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" id="gradeId">
|
||||||
|
|
||||||
|
<div class="row g-2">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Название</label>
|
||||||
|
<input class="form-control bg-body text-body border-secondary" id="gradeName" required {% 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" id="gradeGost" {% if not can_edit %}disabled{% endif %}>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-footer border-secondary">
|
||||||
|
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Отмена</button>
|
||||||
|
{% if can_edit %}
|
||||||
|
<button type="submit" class="btn btn-outline-accent">Сохранить</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function getCookie(name) {
|
||||||
|
const value = `; ${document.cookie}`;
|
||||||
|
const parts = value.split(`; ${name}=`);
|
||||||
|
if (parts.length === 2) return parts.pop().split(';').shift();
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function openGradeCreate() {
|
||||||
|
document.getElementById('gradeModalTitle').textContent = 'Марка стали (создание)';
|
||||||
|
document.getElementById('gradeId').value = '';
|
||||||
|
document.getElementById('gradeName').value = '';
|
||||||
|
document.getElementById('gradeGost').value = '';
|
||||||
|
new bootstrap.Modal(document.getElementById('gradeModal')).show();
|
||||||
|
}
|
||||||
|
|
||||||
|
function openGradeEdit(tr) {
|
||||||
|
document.getElementById('gradeModalTitle').textContent = 'Марка стали (правка)';
|
||||||
|
document.getElementById('gradeId').value = tr.getAttribute('data-id') || '';
|
||||||
|
document.getElementById('gradeName').value = tr.getAttribute('data-name') || '';
|
||||||
|
document.getElementById('gradeGost').value = tr.getAttribute('data-gost') || '';
|
||||||
|
new bootstrap.Modal(document.getElementById('gradeModal')).show();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveGrade() {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('id', document.getElementById('gradeId').value);
|
||||||
|
fd.append('name', document.getElementById('gradeName').value);
|
||||||
|
fd.append('gost_standard', document.getElementById('gradeGost').value);
|
||||||
|
|
||||||
|
const res = await fetch("{% url 'steel_grade_upsert' %}", {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: { 'X-CSRFToken': getCookie('csrftoken') },
|
||||||
|
body: fd,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
alert('Не удалось сохранить марку стали');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -8,18 +8,13 @@
|
|||||||
# AI_RULES — правила работы ассистента в проекте MES_Core
|
# AI_RULES — правила работы ассистента в проекте MES_Core
|
||||||
|
|
||||||
## 1) Коммуникация
|
## 1) Коммуникация
|
||||||
- Пиши по-русски (если пользователь пишет по-русски).
|
- Пиши по-русски всегда.
|
||||||
- Не используй формулировки вида «по твоей просьбе», «добавил для тебя», «как договаривались» в комментариях к коду.
|
|
||||||
- Если предлагаешь новые файлы — всегда указывай: полное имя, абсолютный путь и весь контент в одном блоке.
|
|
||||||
|
|
||||||
## 2) Изменения в коде
|
## 2) Изменения в коде
|
||||||
- Любые правки существующих файлов показывай через diff-превью (SEARCH/REPLACE).
|
|
||||||
- Не вставляй “просто код” для существующих файлов без diff-превью.
|
|
||||||
- Сначала читай файл и только потом предлагай правки (чтобы не ломать стиль и импорты).
|
- Сначала читай файл и только потом предлагай правки (чтобы не ломать стиль и импорты).
|
||||||
- При создании новых файлов заголовок блока должен быть: язык + путь, например: python MES_Core/warehouse/services.py
|
|
||||||
|
|
||||||
## 3) Создание новых файлов
|
## 3) Создание новых файлов
|
||||||
- Для новых файлов заголовок блока должен быть: язык + путь, например: python MES_Core/warehouse/services.py
|
- Для новых файлов звсегда указывай: полное имя, абсолютный путь и весь контент в одном блоке.
|
||||||
|
|
||||||
## 4)Комментарии
|
## 4)Комментарии
|
||||||
- В Python/бекенде:
|
- В Python/бекенде:
|
||||||
@@ -27,19 +22,17 @@
|
|||||||
- комментарии должны быть нейтральными и описывать поведение/причину, без личных формулировок.
|
- комментарии должны быть нейтральными и описывать поведение/причину, без личных формулировок.
|
||||||
- В HTML-шаблонах Django:
|
- В HTML-шаблонах Django:
|
||||||
- не добавляй template-комментарии {# ... #} .
|
- не добавляй template-комментарии {# ... #} .
|
||||||
- В остальных местах:
|
|
||||||
- не добавляй комментарии “для красоты”; только там, где они реально помогают поддержке.
|
|
||||||
|
|
||||||
## 5) Стиль и конвенции проекта
|
## 5) Стиль и конвенции проекта
|
||||||
- Смотри на соседние файлы и придерживайся уже принятого стиля (структура, именование, импорты, форматирование).
|
- Смотри на соседние файлы и придерживайся уже принятого стиля (структура, именование, импорты, форматирование).
|
||||||
- Не вводи новые библиотеки/фреймворки, пока не проверил, что они уже используются в проекте.
|
- Не вводи новые библиотеки/фреймворки, пока не проверил, что они уже используются в проекте.
|
||||||
- Для UI-таблиц:
|
- Для UI-таблиц:
|
||||||
- если добавляешь новую таблицу — по умолчанию делай её сортируемой (если не мешает UX).
|
- если добавляешь новую таблицу — по умолчанию делай её сортируемой (если не мешает UX).
|
||||||
- для колонок с кнопками/прогрессом/иконками отключай сортировку.
|
|
||||||
|
|
||||||
- Использовать Service Layer: сложная логика живет в services.py, вьюхи остаются тонкими.
|
- Использовать Service Layer: сложная логика живет в services.py, вьюхи остаются тонкими.
|
||||||
|
|
||||||
- Импорты моделей из других приложений — через строковые ссылки в полях ('app.Model') для избежания циклических импортов.
|
- Импорты моделей из других приложений — через строковые ссылки в полях ('app.Model') для избежания циклических импортов.
|
||||||
|
|
||||||
## 6) Безопасность и секреты
|
## 6) Безопасность и секреты
|
||||||
- Никогда не логируй и не печатай в stdout:
|
- Никогда не логируй и не печатай в stdout:
|
||||||
- SECRET_KEY
|
- SECRET_KEY
|
||||||
@@ -57,6 +50,7 @@
|
|||||||
- с датой/временем
|
- с датой/временем
|
||||||
- доступны из интерфейса “Обслуживание сервера” (tail)
|
- доступны из интерфейса “Обслуживание сервера” (tail)
|
||||||
- очищаемы кнопкой (если задача не running)
|
- очищаемы кнопкой (если задача не running)
|
||||||
|
|
||||||
## 8) Транзакции и гонки данных (warehouse/shiftflow)
|
## 8) Транзакции и гонки данных (warehouse/shiftflow)
|
||||||
- Все операции списания/начисления на складе делай в transaction.atomic() .
|
- Все операции списания/начисления на складе делай в transaction.atomic() .
|
||||||
- На изменяемые складские остатки используй select_for_update() чтобы избежать гонок.
|
- На изменяемые складские остатки используй select_for_update() чтобы избежать гонок.
|
||||||
@@ -64,6 +58,28 @@
|
|||||||
- select_related / prefetch_related
|
- select_related / prefetch_related
|
||||||
- bulk update/create там, где это безопасно.
|
- bulk update/create там, где это безопасно.
|
||||||
|
|
||||||
|
## 9) Роли и доступ (Django Groups)
|
||||||
|
- Использовать Django Groups как роли приложения (мульти-роли).
|
||||||
|
- Имена групп должны совпадать с кодами ролей, используемых в коде, например:
|
||||||
|
- operator
|
||||||
|
- master
|
||||||
|
- technologist
|
||||||
|
- clerk
|
||||||
|
- supply
|
||||||
|
- prod_head
|
||||||
|
- director
|
||||||
|
- observer
|
||||||
|
- admin
|
||||||
|
- Назначение ролей в Django admin:
|
||||||
|
- Users → выбрать пользователя → поле Groups → добавить нужные группы → Save.
|
||||||
|
- Примечание: на этапе миграции допускается fallback на EmployeeProfile.role, чтобы при деплое до раздачи групп доступ не "слетал".
|
||||||
|
|
||||||
|
### Назначение станков и цехов пользователю
|
||||||
|
- Привязка станков/цехов делается через профиль сотрудника:
|
||||||
|
- Shiftflow → Employee profiles → выбрать профиль пользователя.
|
||||||
|
- Machines: закреплённые станки (для операторов).
|
||||||
|
- Allowed workshops: доступные цеха (ограничение видимости/действий).
|
||||||
|
- Is readonly: режим "только просмотр".
|
||||||
|
|
||||||
Правило для новых внутренних функций (как договор):
|
Правило для новых внутренних функций (как договор):
|
||||||
|
|
||||||
|
|||||||
41
README.md
41
README.md
@@ -68,3 +68,44 @@ git push origin main
|
|||||||
1. Проверь логи контейнеров: `docker compose logs -f`.
|
1. Проверь логи контейнеров: `docker compose logs -f`.
|
||||||
2. Убедись, что порты в `docker-compose.yml` стоят `80:80`.
|
2. Убедись, что порты в `docker-compose.yml` стоят `80:80`.
|
||||||
3. Перезапусти всё одной командой: `docker compose up -d --build`.
|
3. Перезапусти всё одной командой: `docker compose up -d --build`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 👤 Роли и доступ (Django Admin)
|
||||||
|
|
||||||
|
В проекте используются **Django Groups как роли** (можно назначать несколько ролей одному пользователю).
|
||||||
|
|
||||||
|
## 1) Роли (Groups)
|
||||||
|
Имена групп должны совпадать с кодами ролей:
|
||||||
|
- `operator`
|
||||||
|
- `master`
|
||||||
|
- `technologist`
|
||||||
|
- `clerk`
|
||||||
|
- `supply`
|
||||||
|
- `prod_head`
|
||||||
|
- `director`
|
||||||
|
- `observer`
|
||||||
|
- `admin`
|
||||||
|
|
||||||
|
Важно:
|
||||||
|
- Название группы — это "код роли" и используется прямо в коде (чувствительно к регистру).
|
||||||
|
- Писать строчными латиницей, без пробелов.
|
||||||
|
- Для панели снабженца используется группа `supply` (экран `/procurement/`).
|
||||||
|
|
||||||
|
Как выдать роль пользователю:
|
||||||
|
1. Открой Django admin: `/admin/`
|
||||||
|
2. `Users` → выбери пользователя
|
||||||
|
3. Поле `Groups` → добавь нужные группы
|
||||||
|
4. `Save`
|
||||||
|
|
||||||
|
Примечание: на переходном этапе может использоваться fallback на `EmployeeProfile.role`, чтобы при деплое до раздачи групп доступ не "слетал".
|
||||||
|
|
||||||
|
## 2) Назначение станков и цехов пользователю
|
||||||
|
Станки/цеха назначаются через профиль сотрудника (Shiftflow):
|
||||||
|
1. Django admin: `/admin/`
|
||||||
|
2. `Shiftflow` → `Employee profiles` → выбрать профиль пользователя
|
||||||
|
3. Поля:
|
||||||
|
- `Machines` — закреплённые станки (обычно для операторов)
|
||||||
|
- `Allowed workshops` — доступные цеха (ограничение видимости/действий)
|
||||||
|
- `Is readonly` — режим "только просмотр" (удобно для руководителя/наблюдателя)
|
||||||
|
4. `Save`
|
||||||
36
TODO.md
36
TODO.md
@@ -5,6 +5,13 @@
|
|||||||
- По клику на строку открывать карточку «Единица на складе» (read-only для observer, редактирование для admin/technologist/master/clerk):
|
- По клику на строку открывать карточку «Единица на складе» (read-only для observer, редактирование для admin/technologist/master/clerk):
|
||||||
- правка: сделка, давальческий, размеры (лист/хлыст), количество, примечание (если добавим)
|
- правка: сделка, давальческий, размеры (лист/хлыст), количество, примечание (если добавим)
|
||||||
- отображение: история перемещений/приходов/отгрузок (если потребуется).
|
- отображение: история перемещений/приходов/отгрузок (если потребуется).
|
||||||
|
- Реализовать инвентаризацию складов участков/цехов:
|
||||||
|
- сценарий: фактический пересчёт, ввод корректировок (излишек/недостача), фиксация причины
|
||||||
|
- хранить историю инвентаризаций и разницы по позициям
|
||||||
|
- права: master/clerk/admin, read-only для observer
|
||||||
|
|
||||||
|
## Доступы (UI)
|
||||||
|
- Доработать видимость и действия для разных ролей/цехов: фильтрация по allowed_workshops, замещение, read-only руководитель.
|
||||||
|
|
||||||
## Списание (UI)
|
## Списание (UI)
|
||||||
- Доработать страницу «Списание»: фильтры, удобная сводка по материалам/изделиям и отметка «внесено в 1С».
|
- Доработать страницу «Списание»: фильтры, удобная сводка по материалам/изделиям и отметка «внесено в 1С».
|
||||||
@@ -14,3 +21,32 @@
|
|||||||
|
|
||||||
## Изделия (Сборка)
|
## Изделия (Сборка)
|
||||||
- Проработать интерфейс сборки изделия: редактирование состава, паспорт узла, маршруты, сварные швы, быстрые переходы по уровням.
|
- Проработать интерфейс сборки изделия: редактирование состава, паспорт узла, маршруты, сварные швы, быстрые переходы по уровням.
|
||||||
|
|
||||||
|
# TODO: Миграция сменных заданий на WorkItem
|
||||||
|
|
||||||
|
- WorkItem как единая сущность сменных назначений:
|
||||||
|
- operation/workshop обязательны; machine — опционально
|
||||||
|
- plan/done/status/date — общие поля
|
||||||
|
- запись создаётся в planning_deal (кнопка «В смену»)
|
||||||
|
|
||||||
|
- Переход от Item к WorkItem:
|
||||||
|
- Экраны «Реестр сменных заданий» и «Закрытие смены»
|
||||||
|
- читать и отображать WorkItem
|
||||||
|
- для резки предусмотреть учёт списаний/остатков; временно можно дублировать Item ← WorkItem (мост)
|
||||||
|
- Data‑migration:
|
||||||
|
- перенести исторические Item → WorkItem (deal, entity, date, machine, qty_plan, qty_fact, status)
|
||||||
|
- восстановить operation/workshop по EntityOperation + DealEntityProgress/историческим правилам
|
||||||
|
- Постепенное отключение Item:
|
||||||
|
- заменить все места создания Item на WorkItem
|
||||||
|
- после стабилизации убрать Item из UI и сервисов
|
||||||
|
|
||||||
|
- Прогресс/план по сделке:
|
||||||
|
- верхняя таблица «Позиции сделки»: Надо / Запущено / Осталось (по DealBatchItem.started_qty)
|
||||||
|
- факт (Сделано) — от WorkItem.quantity_done на последней операции техпроцесса
|
||||||
|
|
||||||
|
- Снабжение:
|
||||||
|
- покупное/литьё/аутсорс — не создавать ProductionTask, вести учёт как ProcurementRequirement
|
||||||
|
- вывести сводку потребностей для снабженца (группировка по сделке/позиции/сроку)
|
||||||
|
|
||||||
|
- Логи и диагностика:
|
||||||
|
- единый логгер `mes` для всех сервисных действий (включая explode_roots_additive и start_batch_item_production)
|
||||||
74
main copy.md
Normal file
74
main copy.md
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
# AI_RULES — правила работы ассистента в проекте MES_Core
|
||||||
|
Роль: Ты Senior Django Backend Developer.
|
||||||
|
|
||||||
|
Контекст: Разрабатывается MES/ERP система для металлообрабатывающего завода. Архитектура БД разделена на 3 приложения: warehouse, manufacturing, shiftflow.
|
||||||
|
|
||||||
|
Задача: Разработать слой бизнес-логики (сервисы и CBV Views) для реализации сквозного процесса производства.
|
||||||
|
|
||||||
|
# AI_RULES — правила работы ассистента в проекте MES_Core
|
||||||
|
|
||||||
|
## 1) Коммуникация
|
||||||
|
- Пиши по-русски (если пользователь пишет по-русски).
|
||||||
|
- Не используй формулировки вида «по твоей просьбе», «добавил для тебя», «как договаривались» в комментариях к коду.
|
||||||
|
- Если предлагаешь новые файлы — всегда указывай: полное имя, абсолютный путь и весь контент в одном блоке.
|
||||||
|
|
||||||
|
## 2) Изменения в коде
|
||||||
|
- Любые правки существующих файлов показывай через diff-превью (SEARCH/REPLACE).
|
||||||
|
- Не вставляй “просто код” для существующих файлов без diff-превью.
|
||||||
|
- Сначала читай файл и только потом предлагай правки (чтобы не ломать стиль и импорты).
|
||||||
|
- При создании новых файлов заголовок блока должен быть: язык + путь, например: python MES_Core/warehouse/services.py
|
||||||
|
|
||||||
|
## 3) Создание новых файлов
|
||||||
|
- Для новых файлов заголовок блока должен быть: язык + путь, например: python MES_Core/warehouse/services.py
|
||||||
|
|
||||||
|
## 4)Комментарии
|
||||||
|
- В Python/бекенде:
|
||||||
|
- добавляй поясняющие комментарии там, где есть бизнес-логика, транзакции, конкурентность, фоновые задачи, сложные алгоритмы (BOM, списания, начисления).
|
||||||
|
- комментарии должны быть нейтральными и описывать поведение/причину, без личных формулировок.
|
||||||
|
- В HTML-шаблонах Django:
|
||||||
|
- не добавляй template-комментарии {# ... #} .
|
||||||
|
- В остальных местах:
|
||||||
|
- не добавляй комментарии “для красоты”; только там, где они реально помогают поддержке.
|
||||||
|
|
||||||
|
## 5) Стиль и конвенции проекта
|
||||||
|
- Смотри на соседние файлы и придерживайся уже принятого стиля (структура, именование, импорты, форматирование).
|
||||||
|
- Не вводи новые библиотеки/фреймворки, пока не проверил, что они уже используются в проекте.
|
||||||
|
- Для UI-таблиц:
|
||||||
|
- если добавляешь новую таблицу — по умолчанию делай её сортируемой (если не мешает UX).
|
||||||
|
- для колонок с кнопками/прогрессом/иконками отключай сортировку.
|
||||||
|
|
||||||
|
- Использовать Service Layer: сложная логика живет в services.py, вьюхи остаются тонкими.
|
||||||
|
|
||||||
|
- Импорты моделей из других приложений — через строковые ссылки в полях ('app.Model') для избежания циклических импортов.
|
||||||
|
## 6) Безопасность и секреты
|
||||||
|
- Никогда не логируй и не печатай в stdout:
|
||||||
|
- SECRET_KEY
|
||||||
|
- пароли БД
|
||||||
|
- токены
|
||||||
|
- В логи допускаются только технические сообщения, ошибки и диагностические данные без секретов.
|
||||||
|
- В models.py всегда использовать on_delete=models.PROTECT для важных справочников (Металл, Сделки), чтобы нельзя было случайно удалить историю.
|
||||||
|
## 7) Логи и фоновые задачи
|
||||||
|
- Для долгих операций (рендер превью, массовые обновления, BOM explosion для больших заказов):
|
||||||
|
- не блокируй HTTP-ответ
|
||||||
|
- Использовать модуль threading для запуска таких задач в отдельном потоке.
|
||||||
|
|
||||||
|
- Обязательно оборачивать фоновую функцию в try/except и логировать ошибки в БД или файл, так как ошибки в потоках не видны во вьюхе.
|
||||||
|
- Логи фоновых задач должны быть:
|
||||||
|
- с датой/временем
|
||||||
|
- доступны из интерфейса “Обслуживание сервера” (tail)
|
||||||
|
- очищаемы кнопкой (если задача не running)
|
||||||
|
## 8) Транзакции и гонки данных (warehouse/shiftflow)
|
||||||
|
- Все операции списания/начисления на складе делай в transaction.atomic() .
|
||||||
|
- На изменяемые складские остатки используй select_for_update() чтобы избежать гонок.
|
||||||
|
- Для массовых операций избегай N+1:
|
||||||
|
- select_related / prefetch_related
|
||||||
|
- bulk update/create там, где это безопасно.
|
||||||
|
|
||||||
|
|
||||||
|
Правило для новых внутренних функций (как договор):
|
||||||
|
|
||||||
|
- Всегда берём логгер logger = logging.getLogger('mes')
|
||||||
|
- Перед выполнением — logger.info('fn:start ...', ключевые параметры)
|
||||||
|
- После успешного выполнения — logger.info('fn:done ...', ключевые результаты)
|
||||||
|
- На важных шагах — logger.info('fn:step ...', детали)
|
||||||
|
- Исключение — с context: logger.exception('fn:error ...') — не глотаем, пробрасываем дальше
|
||||||
@@ -1,12 +1,21 @@
|
|||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
|
||||||
from .models import BOM, ProductEntity, RouteStub
|
from .models import BOM, EntityOperation, Operation, ProductEntity
|
||||||
|
|
||||||
|
|
||||||
@admin.register(RouteStub)
|
@admin.register(Operation)
|
||||||
class RouteStubAdmin(admin.ModelAdmin):
|
class OperationAdmin(admin.ModelAdmin):
|
||||||
list_display = ('name',)
|
list_display = ('name', 'code', 'workshop')
|
||||||
search_fields = ('name',)
|
search_fields = ('name', 'code')
|
||||||
|
list_filter = ('workshop',)
|
||||||
|
autocomplete_fields = ('workshop',)
|
||||||
|
|
||||||
|
|
||||||
|
class EntityOperationInline(admin.TabularInline):
|
||||||
|
model = EntityOperation
|
||||||
|
fields = ('seq', 'operation')
|
||||||
|
autocomplete_fields = ('operation',)
|
||||||
|
extra = 5
|
||||||
|
|
||||||
|
|
||||||
class BOMChildInline(admin.TabularInline):
|
class BOMChildInline(admin.TabularInline):
|
||||||
@@ -31,8 +40,8 @@ class ProductEntityAdmin(admin.ModelAdmin):
|
|||||||
)
|
)
|
||||||
list_filter = ('entity_type', 'planned_material__category')
|
list_filter = ('entity_type', 'planned_material__category')
|
||||||
search_fields = ('drawing_number', 'name', 'planned_material__name', 'planned_material__full_name')
|
search_fields = ('drawing_number', 'name', 'planned_material__name', 'planned_material__full_name')
|
||||||
autocomplete_fields = ('planned_material', 'route')
|
autocomplete_fields = ('planned_material',)
|
||||||
inlines = (BOMChildInline,)
|
inlines = (EntityOperationInline, BOMChildInline,)
|
||||||
|
|
||||||
|
|
||||||
@admin.register(BOM)
|
@admin.register(BOM)
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Generated by Django 6.0.3 on 2026-04-07 18:11
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('manufacturing', '0004_castingpassport_outsourcedpassport_partpassport_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='assemblypassport',
|
||||||
|
name='requires_painting',
|
||||||
|
field=models.BooleanField(default=False, verbose_name='Требуется покраска'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='assemblypassport',
|
||||||
|
name='requires_welding',
|
||||||
|
field=models.BooleanField(default=False, verbose_name='Требуется сварка'),
|
||||||
|
),
|
||||||
|
]
|
||||||
43
manufacturing/migrations/0006_operation_entityoperation.py
Normal file
43
manufacturing/migrations/0006_operation_entityoperation.py
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
# Generated by Django 6.0.3 on 2026-04-08 18:00
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('manufacturing', '0005_assemblypassport_requires_painting_and_more'),
|
||||||
|
('shiftflow', '0022_employeeprofile_allowed_workshops_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Operation',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('name', models.CharField(max_length=200, unique=True, verbose_name='Операция')),
|
||||||
|
('code', models.SlugField(help_text='Стабильный идентификатор (например welding, painting, laser_cutting).', max_length=64, unique=True, verbose_name='Код')),
|
||||||
|
('workshop', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='shiftflow.workshop', verbose_name='Цех по умолчанию')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Операция',
|
||||||
|
'verbose_name_plural': 'Операции',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='EntityOperation',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('seq', models.PositiveSmallIntegerField(default=1, verbose_name='Порядок')),
|
||||||
|
('entity', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='operations', to='manufacturing.productentity', verbose_name='Сущность')),
|
||||||
|
('operation', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='manufacturing.operation', verbose_name='Операция')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Операция сущности',
|
||||||
|
'verbose_name_plural': 'Операции сущностей',
|
||||||
|
'ordering': ('entity', 'seq', 'id'),
|
||||||
|
'unique_together': {('entity', 'seq')},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# Generated by Django 6.0.3 on 2026-04-08 18:37
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('manufacturing', '0006_operation_entityoperation'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='productentity',
|
||||||
|
name='route',
|
||||||
|
),
|
||||||
|
migrations.DeleteModel(
|
||||||
|
name='RouteStub',
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -1,14 +1,30 @@
|
|||||||
from django.db import models
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
class RouteStub(models.Model):
|
class Operation(models.Model):
|
||||||
"""Маршрут (пока заглушка под техпроцессы)."""
|
"""Операция техпроцесса.
|
||||||
|
|
||||||
name = models.CharField("Маршрут", max_length=200, unique=True)
|
Комментарий: справочник расширяется без изменений кода.
|
||||||
|
"""
|
||||||
|
|
||||||
|
name = models.CharField('Операция', max_length=200, unique=True)
|
||||||
|
code = models.SlugField(
|
||||||
|
'Код',
|
||||||
|
max_length=64,
|
||||||
|
unique=True,
|
||||||
|
help_text='Стабильный идентификатор (например welding, painting, laser_cutting).',
|
||||||
|
)
|
||||||
|
workshop = models.ForeignKey(
|
||||||
|
'shiftflow.Workshop',
|
||||||
|
on_delete=models.PROTECT,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name='Цех по умолчанию',
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = "Маршрут"
|
verbose_name = 'Операция'
|
||||||
verbose_name_plural = "Маршруты"
|
verbose_name_plural = 'Операции'
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
@@ -48,13 +64,6 @@ class ProductEntity(models.Model):
|
|||||||
blank=True,
|
blank=True,
|
||||||
verbose_name="Заложенный материал",
|
verbose_name="Заложенный материал",
|
||||||
)
|
)
|
||||||
route = models.ForeignKey(
|
|
||||||
RouteStub,
|
|
||||||
on_delete=models.SET_NULL,
|
|
||||||
null=True,
|
|
||||||
blank=True,
|
|
||||||
verbose_name="Маршрут",
|
|
||||||
)
|
|
||||||
|
|
||||||
blank_area_m2 = models.FloatField("Норма: площадь заготовки (м²/шт)", null=True, blank=True)
|
blank_area_m2 = models.FloatField("Норма: площадь заготовки (м²/шт)", null=True, blank=True)
|
||||||
blank_length_mm = models.FloatField("Норма: длина заготовки (мм/шт)", null=True, blank=True)
|
blank_length_mm = models.FloatField("Норма: длина заготовки (мм/шт)", null=True, blank=True)
|
||||||
@@ -74,6 +83,23 @@ class ProductEntity(models.Model):
|
|||||||
return base if base else self.name
|
return base if base else self.name
|
||||||
|
|
||||||
|
|
||||||
|
class EntityOperation(models.Model):
|
||||||
|
"""Операции техпроцесса для конкретной сущности (деталь/сборка/изделие)."""
|
||||||
|
|
||||||
|
entity = models.ForeignKey(ProductEntity, on_delete=models.CASCADE, related_name='operations', verbose_name='Сущность')
|
||||||
|
operation = models.ForeignKey(Operation, on_delete=models.PROTECT, verbose_name='Операция')
|
||||||
|
seq = models.PositiveSmallIntegerField('Порядок', default=1)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = 'Операция сущности'
|
||||||
|
verbose_name_plural = 'Операции сущностей'
|
||||||
|
ordering = ('entity', 'seq', 'id')
|
||||||
|
unique_together = ('entity', 'seq')
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.entity}: {self.seq}. {self.operation}"
|
||||||
|
|
||||||
|
|
||||||
class BOM(models.Model):
|
class BOM(models.Model):
|
||||||
"""Спецификация (BOM): parent состоит из child в количестве quantity."""
|
"""Спецификация (BOM): parent состоит из child в количестве quantity."""
|
||||||
|
|
||||||
@@ -103,6 +129,9 @@ class BOM(models.Model):
|
|||||||
class AssemblyPassport(models.Model):
|
class AssemblyPassport(models.Model):
|
||||||
entity = models.OneToOneField(ProductEntity, on_delete=models.CASCADE, related_name='assembly_passport')
|
entity = models.OneToOneField(ProductEntity, on_delete=models.CASCADE, related_name='assembly_passport')
|
||||||
|
|
||||||
|
requires_welding = models.BooleanField('Требуется сварка', default=False)
|
||||||
|
requires_painting = models.BooleanField('Требуется покраска', default=False)
|
||||||
|
|
||||||
weight_kg = models.FloatField('Масса, кг', null=True, blank=True)
|
weight_kg = models.FloatField('Масса, кг', null=True, blank=True)
|
||||||
coating = models.CharField('Покрытие', max_length=200, blank=True, default='')
|
coating = models.CharField('Покрытие', max_length=200, blank=True, default='')
|
||||||
coating_color = models.CharField('Цвет', max_length=100, blank=True, default='')
|
coating_color = models.CharField('Цвет', max_length=100, blank=True, default='')
|
||||||
|
|||||||
@@ -20,8 +20,35 @@ from .models import (
|
|||||||
ProductionTask,
|
ProductionTask,
|
||||||
ShiftItem,
|
ShiftItem,
|
||||||
Workshop,
|
Workshop,
|
||||||
|
WorkItem,
|
||||||
|
DealEntityProgress,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
_models_to_reregister = (
|
||||||
|
Company,
|
||||||
|
CuttingSession,
|
||||||
|
Deal,
|
||||||
|
DealItem,
|
||||||
|
DxfPreviewJob,
|
||||||
|
DxfPreviewSettings,
|
||||||
|
EmployeeProfile,
|
||||||
|
Item,
|
||||||
|
Machine,
|
||||||
|
MaterialRequirement,
|
||||||
|
ProductionReportConsumption,
|
||||||
|
ProductionReportRemnant,
|
||||||
|
ProductionTask,
|
||||||
|
ShiftItem,
|
||||||
|
Workshop,
|
||||||
|
WorkItem,
|
||||||
|
DealEntityProgress,
|
||||||
|
)
|
||||||
|
for _m in _models_to_reregister:
|
||||||
|
try:
|
||||||
|
admin.site.unregister(_m)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
# --- Настройка отображения Компаний ---
|
# --- Настройка отображения Компаний ---
|
||||||
@admin.register(Company)
|
@admin.register(Company)
|
||||||
class CompanyAdmin(admin.ModelAdmin):
|
class CompanyAdmin(admin.ModelAdmin):
|
||||||
@@ -119,6 +146,21 @@ class ItemAdmin(admin.ModelAdmin):
|
|||||||
return obj.task.drawing_name if obj.task else "-"
|
return obj.task.drawing_name if obj.task else "-"
|
||||||
get_drawing.short_description = 'Деталь'
|
get_drawing.short_description = 'Деталь'
|
||||||
|
|
||||||
|
@admin.register(WorkItem)
|
||||||
|
class WorkItemAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('date', 'deal', 'entity', 'operation', 'workshop', 'machine', 'quantity_plan', 'quantity_done', 'status')
|
||||||
|
list_filter = ('date', 'status', 'workshop', 'machine', 'operation')
|
||||||
|
search_fields = ('deal__number', 'entity__name', 'entity__drawing_number', 'operation__name', 'operation__code')
|
||||||
|
autocomplete_fields = ('deal', 'entity', 'operation', 'workshop', 'machine')
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(DealEntityProgress)
|
||||||
|
class DealEntityProgressAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('deal', 'entity', 'current_seq')
|
||||||
|
search_fields = ('deal__number', 'entity__name', 'entity__drawing_number')
|
||||||
|
autocomplete_fields = ('deal', 'entity')
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Workshop)
|
@admin.register(Workshop)
|
||||||
class WorkshopAdmin(admin.ModelAdmin):
|
class WorkshopAdmin(admin.ModelAdmin):
|
||||||
list_display = ('name', 'location')
|
list_display = ('name', 'location')
|
||||||
@@ -261,5 +303,5 @@ class DxfPreviewJobAdmin(admin.ModelAdmin):
|
|||||||
|
|
||||||
@admin.register(EmployeeProfile)
|
@admin.register(EmployeeProfile)
|
||||||
class EmployeeProfileAdmin(admin.ModelAdmin):
|
class EmployeeProfileAdmin(admin.ModelAdmin):
|
||||||
list_display = ('user', 'role')
|
list_display = ('user', 'role', 'is_readonly')
|
||||||
filter_horizontal = ('machines',)
|
filter_horizontal = ('machines', 'allowed_workshops')
|
||||||
106
shiftflow/authz.py
Normal file
106
shiftflow/authz.py
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Iterable
|
||||||
|
|
||||||
|
|
||||||
|
ROLE_PRIORITY = [
|
||||||
|
'admin',
|
||||||
|
'prod_head',
|
||||||
|
'technologist',
|
||||||
|
'master',
|
||||||
|
'clerk',
|
||||||
|
'supply',
|
||||||
|
'manager',
|
||||||
|
'operator',
|
||||||
|
'observer',
|
||||||
|
'director',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_group_roles(user) -> set[str]:
|
||||||
|
"""Возвращает роли пользователя только из Django Groups.
|
||||||
|
|
||||||
|
Используется для экранов, где включён строгий доступ "только по группам".
|
||||||
|
EmployeeProfile.role здесь намеренно не учитывается.
|
||||||
|
|
||||||
|
Правило: superuser получает роль admin.
|
||||||
|
"""
|
||||||
|
|
||||||
|
roles: set[str] = set()
|
||||||
|
if not user or not getattr(user, 'is_authenticated', False):
|
||||||
|
return roles
|
||||||
|
|
||||||
|
if getattr(user, 'is_superuser', False):
|
||||||
|
roles.add('admin')
|
||||||
|
|
||||||
|
try:
|
||||||
|
roles |= set(user.groups.values_list('name', flat=True))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return roles
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_roles(user) -> set[str]:
|
||||||
|
"""Возвращает множество ролей пользователя.
|
||||||
|
|
||||||
|
Источник ролей (вариант A, плавная миграция):
|
||||||
|
- Django Groups: позволяет назначать несколько ролей одному пользователю.
|
||||||
|
- Fallback на EmployeeProfile.role: чтобы при деплое и до раздачи групп система
|
||||||
|
продолжала работать по старой модели (одна роль).
|
||||||
|
|
||||||
|
Правило: superuser всегда получает роль admin независимо от групп/профиля.
|
||||||
|
"""
|
||||||
|
|
||||||
|
roles: set[str] = set() # Изначально множество пустое
|
||||||
|
# 1. Проверяем, что пользователь авторизован
|
||||||
|
if not user or not getattr(user, 'is_authenticated', False):
|
||||||
|
return roles
|
||||||
|
# 2. Проверяем, что пользователь не superuser
|
||||||
|
if getattr(user, 'is_superuser', False):
|
||||||
|
roles.add('admin')
|
||||||
|
# 3. Проверяем, что у пользователя есть хотя бы одна группа
|
||||||
|
try:
|
||||||
|
roles |= set(user.groups.values_list('name', flat=True))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# 4. Проверяем, что у пользователя есть роль в EmployeeProfile
|
||||||
|
profile = getattr(user, 'profile', None)
|
||||||
|
if profile and getattr(profile, 'role', None):
|
||||||
|
roles.add(str(profile.role))
|
||||||
|
|
||||||
|
return roles
|
||||||
|
|
||||||
|
|
||||||
|
def primary_role(roles: Iterable[str]) -> str:
|
||||||
|
"""Выбирает "основную" роль для отображения в UI.
|
||||||
|
|
||||||
|
Примечание: права доступа должны проверяться по всем ролям (has_any_role).
|
||||||
|
primary_role используется только для:
|
||||||
|
- подписи/лейбла "роль пользователя" в шаблонах
|
||||||
|
- дефолтного поведения, где требуется один статус (например, оформление UI)
|
||||||
|
"""
|
||||||
|
|
||||||
|
s = set(roles or [])
|
||||||
|
for r in ROLE_PRIORITY:
|
||||||
|
if r in s:
|
||||||
|
return r
|
||||||
|
return 'operator'
|
||||||
|
|
||||||
|
|
||||||
|
def has_any_role(roles: Iterable[str], required: Iterable[str]) -> bool:
|
||||||
|
"""Проверяет, что у пользователя есть хотя бы одна роль из required.
|
||||||
|
|
||||||
|
Используется во вьюхах для разрешений вида:
|
||||||
|
roles = get_user_roles(request.user)
|
||||||
|
if not has_any_role(roles, ['admin', 'master']):
|
||||||
|
return redirect('registry')
|
||||||
|
"""
|
||||||
|
|
||||||
|
s = set(roles or [])
|
||||||
|
for r in required or []:
|
||||||
|
if r in s:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
@@ -5,14 +5,14 @@ from shiftflow.services.bom_explosion import explode_deal
|
|||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
help = "BOM Explosion для сделки: генерирует ProductionTask и MaterialRequirement."
|
help = "BOM Explosion для сделки: генерирует ProductionTask и пересчитывает снабжение."
|
||||||
|
|
||||||
def add_arguments(self, parser):
|
def add_arguments(self, parser):
|
||||||
parser.add_argument("deal_id", type=int)
|
parser.add_argument("deal_id", type=int)
|
||||||
|
|
||||||
def handle(self, *args, **options):
|
def handle(self, *args, **options):
|
||||||
deal_id = int(options["deal_id"])
|
deal_id = int(options["deal_id"])
|
||||||
stats = explode_deal(deal_id)
|
stats = explode_deal(deal_id, create_tasks=True, create_procurement=True)
|
||||||
|
|
||||||
self.stdout.write(
|
self.stdout.write(
|
||||||
self.style.SUCCESS(
|
self.style.SUCCESS(
|
||||||
|
|||||||
18
shiftflow/migrations/0020_dealitem_due_date.py
Normal file
18
shiftflow/migrations/0020_dealitem_due_date.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 6.0.3 on 2026-04-08 03:44
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('shiftflow', '0019_alter_employeeprofile_role'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='dealitem',
|
||||||
|
name='due_date',
|
||||||
|
field=models.DateField(blank=True, null=True, verbose_name='Плановая отгрузка'),
|
||||||
|
),
|
||||||
|
]
|
||||||
35
shiftflow/migrations/0021_workitem.py
Normal file
35
shiftflow/migrations/0021_workitem.py
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# Generated by Django 6.0.3 on 2026-04-08 03:54
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
import django.utils.timezone
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('manufacturing', '0005_assemblypassport_requires_painting_and_more'),
|
||||||
|
('shiftflow', '0020_dealitem_due_date'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='WorkItem',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('stage', models.CharField(choices=[('cutting', 'Резка'), ('welding', 'Сварка'), ('painting', 'Покраска')], max_length=16, verbose_name='Стадия')),
|
||||||
|
('quantity_plan', models.PositiveIntegerField(default=0, verbose_name='В план, шт')),
|
||||||
|
('quantity_done', models.PositiveIntegerField(default=0, verbose_name='Сделано, шт')),
|
||||||
|
('status', models.CharField(default='planned', max_length=16, verbose_name='Статус')),
|
||||||
|
('date', models.DateField(default=django.utils.timezone.localdate, verbose_name='Дата')),
|
||||||
|
('deal', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='shiftflow.deal', verbose_name='Сделка')),
|
||||||
|
('entity', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='manufacturing.productentity', verbose_name='Сущность')),
|
||||||
|
('machine', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='shiftflow.machine', verbose_name='Станок/участок')),
|
||||||
|
('workshop', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='shiftflow.workshop', verbose_name='Цех')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'План работ',
|
||||||
|
'verbose_name_plural': 'План работ',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# Generated by Django 6.0.3 on 2026-04-08 16:55
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('shiftflow', '0021_workitem'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='employeeprofile',
|
||||||
|
name='allowed_workshops',
|
||||||
|
field=models.ManyToManyField(blank=True, to='shiftflow.workshop', verbose_name='Доступные цеха'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='employeeprofile',
|
||||||
|
name='is_readonly',
|
||||||
|
field=models.BooleanField(default=False, verbose_name='Только просмотр'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='employeeprofile',
|
||||||
|
name='role',
|
||||||
|
field=models.CharField(choices=[('admin', 'Администратор'), ('technologist', 'Технолог'), ('master', 'Мастер'), ('operator', 'Оператор'), ('clerk', 'Учетчик'), ('observer', 'Наблюдатель'), ('manager', 'Руководитель')], default='operator', max_length=20, verbose_name='Должность'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# Generated by Django 6.0.3 on 2026-04-08 18:00
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('manufacturing', '0006_operation_entityoperation'),
|
||||||
|
('shiftflow', '0022_employeeprofile_allowed_workshops_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='workitem',
|
||||||
|
name='operation',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='manufacturing.operation', verbose_name='Операция'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='workitem',
|
||||||
|
name='stage',
|
||||||
|
field=models.CharField(blank=True, default='', max_length=32, verbose_name='Стадия'),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='DealEntityProgress',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('current_seq', models.PositiveSmallIntegerField(default=1, verbose_name='Текущая операция (порядок)')),
|
||||||
|
('deal', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='shiftflow.deal', verbose_name='Сделка')),
|
||||||
|
('entity', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='manufacturing.productentity', verbose_name='Сущность')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Прогресс по операции',
|
||||||
|
'verbose_name_plural': 'Прогресс по операциям',
|
||||||
|
'unique_together': {('deal', 'entity')},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
45
shiftflow/migrations/0024_dealdeliverybatch_dealbatchitem.py
Normal file
45
shiftflow/migrations/0024_dealdeliverybatch_dealbatchitem.py
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
# Generated by Django 6.0.3 on 2026-04-08 21:33
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('manufacturing', '0007_remove_productentity_route_delete_routestub'),
|
||||||
|
('shiftflow', '0023_workitem_operation_alter_workitem_stage_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='DealDeliveryBatch',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('name', models.CharField(blank=True, default='', max_length=120, verbose_name='Название')),
|
||||||
|
('due_date', models.DateField(verbose_name='Плановая отгрузка')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Создано')),
|
||||||
|
('deal', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='delivery_batches', to='shiftflow.deal', verbose_name='Сделка')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Партия поставки',
|
||||||
|
'verbose_name_plural': 'Партии поставки',
|
||||||
|
'ordering': ('deal', 'due_date', 'id'),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='DealBatchItem',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('quantity', models.PositiveIntegerField(verbose_name='Количество, шт')),
|
||||||
|
('entity', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='manufacturing.productentity', verbose_name='Изделие/деталь')),
|
||||||
|
('batch', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='shiftflow.dealdeliverybatch', verbose_name='Партия')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Строка партии',
|
||||||
|
'verbose_name_plural': 'Строки партий',
|
||||||
|
'ordering': ('batch', 'entity__entity_type', 'entity__drawing_number', 'entity__name', 'id'),
|
||||||
|
'unique_together': {('batch', 'entity')},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
18
shiftflow/migrations/0025_dealbatchitem_started_qty.py
Normal file
18
shiftflow/migrations/0025_dealbatchitem_started_qty.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 6.0.3 on 2026-04-09 04:16
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('shiftflow', '0024_dealdeliverybatch_dealbatchitem'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='dealbatchitem',
|
||||||
|
name='started_qty',
|
||||||
|
field=models.PositiveIntegerField(default=0, verbose_name='Запущено в производство, шт'),
|
||||||
|
),
|
||||||
|
]
|
||||||
18
shiftflow/migrations/0026_dealdeliverybatch_is_default.py
Normal file
18
shiftflow/migrations/0026_dealdeliverybatch_is_default.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 6.0.3 on 2026-04-09 10:22
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('shiftflow', '0025_dealbatchitem_started_qty'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='dealdeliverybatch',
|
||||||
|
name='is_default',
|
||||||
|
field=models.BooleanField(default=False, verbose_name='Дефолтная партия (остаток)'),
|
||||||
|
),
|
||||||
|
]
|
||||||
17
shiftflow/migrations/0027_remove_dealitem_due_date.py
Normal file
17
shiftflow/migrations/0027_remove_dealitem_due_date.py
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# Generated by Django 6.0.3 on 2026-04-09 10:28
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('shiftflow', '0026_dealdeliverybatch_is_default'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='dealitem',
|
||||||
|
name='due_date',
|
||||||
|
),
|
||||||
|
]
|
||||||
20
shiftflow/migrations/0028_alter_productiontask_material.py
Normal file
20
shiftflow/migrations/0028_alter_productiontask_material.py
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# Generated by Django 6.0.3 on 2026-04-09 11:38
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('shiftflow', '0027_remove_dealitem_due_date'),
|
||||||
|
('warehouse', '0014_material_mass_per_unit'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='productiontask',
|
||||||
|
name='material',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='warehouse.material', verbose_name='Материал'),
|
||||||
|
),
|
||||||
|
]
|
||||||
18
shiftflow/migrations/0029_deal_due_date.py
Normal file
18
shiftflow/migrations/0029_deal_due_date.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 6.0.3 on 2026-04-09 12:20
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('shiftflow', '0028_alter_productiontask_material'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='deal',
|
||||||
|
name='due_date',
|
||||||
|
field=models.DateField(blank=True, null=True, verbose_name='Срок отгрузки'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# Generated by Django 6.0.3 on 2026-04-11 05:25
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('shiftflow', '0029_deal_due_date'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='workitem',
|
||||||
|
name='comment',
|
||||||
|
field=models.TextField(blank=True, default='', verbose_name='Комментарий'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='item',
|
||||||
|
name='status',
|
||||||
|
field=models.CharField(choices=[('work', 'В работе'), ('done', 'Выполнено'), ('partial', 'Частично'), ('leftover', 'Недодел')], default='work', max_length=10, verbose_name='Статус'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='workitem',
|
||||||
|
name='status',
|
||||||
|
field=models.CharField(choices=[('planned', 'В работе'), ('leftover', 'Недодел'), ('done', 'Закрыта')], default='planned', max_length=16, verbose_name='Статус'),
|
||||||
|
),
|
||||||
|
]
|
||||||
30
shiftflow/migrations/0031_procurementrequirement.py
Normal file
30
shiftflow/migrations/0031_procurementrequirement.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# Generated by Django 6.0.3 on 2026-04-11 20:08
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('manufacturing', '0007_remove_productentity_route_delete_routestub'),
|
||||||
|
('shiftflow', '0030_workitem_comment_alter_item_status_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='ProcurementRequirement',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('required_qty', models.FloatField(verbose_name='Потребность (к закупке)')),
|
||||||
|
('status', models.CharField(choices=[('to_order', 'К заказу'), ('ordered', 'Заказано'), ('closed', 'Закрыто')], default='to_order', max_length=20, verbose_name='Статус')),
|
||||||
|
('component', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='manufacturing.productentity', verbose_name='Компонент (покупное/литье)')),
|
||||||
|
('deal', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='shiftflow.deal', verbose_name='Сделка')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Потребность снабжения',
|
||||||
|
'verbose_name_plural': 'Потребности снабжения',
|
||||||
|
'unique_together': {('deal', 'component')},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 6.0.3 on 2026-04-11 20:41
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('shiftflow', '0031_procurementrequirement'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='procurementrequirement',
|
||||||
|
name='required_qty',
|
||||||
|
field=models.PositiveIntegerField(verbose_name='Потребность (к закупке), шт'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# Generated by Django 6.0.3 on 2026-04-12 09:22
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('shiftflow', '0032_alter_procurementrequirement_required_qty'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='cuttingsession',
|
||||||
|
name='is_synced_1c',
|
||||||
|
field=models.BooleanField(default=False, verbose_name='Выгружено в 1С'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='cuttingsession',
|
||||||
|
name='synced_1c_at',
|
||||||
|
field=models.DateTimeField(blank=True, null=True, verbose_name='Выгружено в 1С (время)'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='cuttingsession',
|
||||||
|
name='synced_1c_by',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='synced_cutting_sessions', to=settings.AUTH_USER_MODEL, verbose_name='Выгрузил в 1С'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -35,7 +35,12 @@ class Workshop(models.Model):
|
|||||||
|
|
||||||
|
|
||||||
class Machine(models.Model):
|
class Machine(models.Model):
|
||||||
"""Список производственных участков (станков).
|
"""Справочник производственных постов (ресурсов).
|
||||||
|
|
||||||
|
Терминология UI:
|
||||||
|
- в интерфейсе используется слово «Пост», чтобы одинаково обозначать станок, линию,
|
||||||
|
камеру, рабочее место или бригаду (как единицу планирования у мастера).
|
||||||
|
- в базе и коде модель остаётся Machine, чтобы не ломать существующие связи.
|
||||||
|
|
||||||
Источник склада для операций выработки/списаний:
|
Источник склада для операций выработки/списаний:
|
||||||
- предпочитаем склад цеха (Machine.workshop.location)
|
- предпочитаем склад цеха (Machine.workshop.location)
|
||||||
@@ -45,6 +50,7 @@ class Machine(models.Model):
|
|||||||
MACHINE_TYPE_CHOICES = [
|
MACHINE_TYPE_CHOICES = [
|
||||||
('linear', 'Линейный'),
|
('linear', 'Линейный'),
|
||||||
('sheet', 'Листовой'),
|
('sheet', 'Листовой'),
|
||||||
|
('post', 'Пост'),
|
||||||
]
|
]
|
||||||
|
|
||||||
name = models.CharField("Название станка", max_length=100)
|
name = models.CharField("Название станка", max_length=100)
|
||||||
@@ -74,6 +80,7 @@ class Deal(models.Model):
|
|||||||
status = models.CharField("Статус", max_length=10, choices=STATUS_CHOICES, default='work')
|
status = models.CharField("Статус", max_length=10, choices=STATUS_CHOICES, default='work')
|
||||||
company = models.ForeignKey(Company, on_delete=models.PROTECT, verbose_name="Заказчик", null=True, blank=True)
|
company = models.ForeignKey(Company, on_delete=models.PROTECT, verbose_name="Заказчик", null=True, blank=True)
|
||||||
description = models.TextField("Описание сделки", blank=True, help_text="Общая информация по заказу")
|
description = models.TextField("Описание сделки", blank=True, help_text="Общая информация по заказу")
|
||||||
|
due_date = models.DateField("Срок отгрузки", null=True, blank=True)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"Сделка №{self.number} ({self.company})"
|
return f"Сделка №{self.number} ({self.company})"
|
||||||
@@ -100,7 +107,7 @@ class ProductionTask(models.Model):
|
|||||||
preview_image = models.ImageField("Превью DXF (PNG)", upload_to="task_previews/%Y/%m/", blank=True, null=True)
|
preview_image = models.ImageField("Превью DXF (PNG)", upload_to="task_previews/%Y/%m/", blank=True, null=True)
|
||||||
blank_dimensions = models.CharField("Габариты заготовки", max_length=64, blank=True, default="")
|
blank_dimensions = models.CharField("Габариты заготовки", max_length=64, blank=True, default="")
|
||||||
|
|
||||||
material = models.ForeignKey(WarehouseMaterial, on_delete=models.PROTECT, verbose_name="Материал")
|
material = models.ForeignKey(WarehouseMaterial, on_delete=models.PROTECT, verbose_name="Материал", null=True, blank=True)
|
||||||
quantity_ordered = models.PositiveIntegerField("Заказано всего, шт")
|
quantity_ordered = models.PositiveIntegerField("Заказано всего, шт")
|
||||||
is_bend = models.BooleanField("Гибка", default=False)
|
is_bend = models.BooleanField("Гибка", default=False)
|
||||||
|
|
||||||
@@ -161,7 +168,10 @@ class DxfPreviewSettings(models.Model):
|
|||||||
|
|
||||||
|
|
||||||
class DealItem(models.Model):
|
class DealItem(models.Model):
|
||||||
"""Состав сделки: что заказал клиент (точка входа для BOM Explosion)."""
|
"""Состав сделки: что заказал клиент.
|
||||||
|
|
||||||
|
Примечание: при поставках частями используем DealDeliveryBatch/DealBatchItem.
|
||||||
|
"""
|
||||||
|
|
||||||
deal = models.ForeignKey(Deal, related_name='items', on_delete=models.CASCADE, verbose_name='Сделка')
|
deal = models.ForeignKey(Deal, related_name='items', on_delete=models.CASCADE, verbose_name='Сделка')
|
||||||
entity = models.ForeignKey('manufacturing.ProductEntity', on_delete=models.PROTECT, verbose_name='Изделие/деталь')
|
entity = models.ForeignKey('manufacturing.ProductEntity', on_delete=models.PROTECT, verbose_name='Изделие/деталь')
|
||||||
@@ -176,6 +186,66 @@ class DealItem(models.Model):
|
|||||||
return f"{self.deal.number}: {self.entity} x{self.quantity}"
|
return f"{self.deal.number}: {self.entity} x{self.quantity}"
|
||||||
|
|
||||||
|
|
||||||
|
class DealDeliveryBatch(models.Model):
|
||||||
|
"""Партия поставки по сделке (поставка частями)."""
|
||||||
|
|
||||||
|
deal = models.ForeignKey(Deal, on_delete=models.CASCADE, related_name='delivery_batches', verbose_name='Сделка')
|
||||||
|
name = models.CharField('Название', max_length=120, blank=True, default='')
|
||||||
|
due_date = models.DateField('Плановая отгрузка')
|
||||||
|
is_default = models.BooleanField('Дефолтная партия (остаток)', default=False)
|
||||||
|
created_at = models.DateTimeField('Создано', auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = 'Партия поставки'
|
||||||
|
verbose_name_plural = 'Партии поставки'
|
||||||
|
ordering = ('deal', 'due_date', 'id')
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
label = self.name.strip() or f"Партия {self.id}"
|
||||||
|
return f"{self.deal.number}: {label} ({self.due_date:%d.%m.%Y})"
|
||||||
|
|
||||||
|
|
||||||
|
class DealBatchItem(models.Model):
|
||||||
|
"""Строка партии поставки: что и сколько отгружаем в эту дату.
|
||||||
|
|
||||||
|
started_qty — сколько уже запущено в производство по этой партии.
|
||||||
|
"""
|
||||||
|
|
||||||
|
batch = models.ForeignKey(DealDeliveryBatch, on_delete=models.CASCADE, related_name='items', verbose_name='Партия')
|
||||||
|
entity = models.ForeignKey('manufacturing.ProductEntity', on_delete=models.PROTECT, verbose_name='Изделие/деталь')
|
||||||
|
quantity = models.PositiveIntegerField('Количество, шт')
|
||||||
|
started_qty = models.PositiveIntegerField('Запущено в производство, шт', default=0)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = 'Строка партии'
|
||||||
|
verbose_name_plural = 'Строки партий'
|
||||||
|
unique_together = ('batch', 'entity')
|
||||||
|
ordering = ('batch', 'entity__entity_type', 'entity__drawing_number', 'entity__name', 'id')
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.batch}: {self.entity} x{self.quantity}"
|
||||||
|
|
||||||
|
|
||||||
|
class DealEntityProgress(models.Model):
|
||||||
|
"""Текущая операция техпроцесса для пары (сделка, сущность).
|
||||||
|
|
||||||
|
Комментарий: current_seq=1 означает «выполняем 1-ю операцию в EntityOperation».
|
||||||
|
Когда current_seq больше числа операций — сущность для сделки считается прошедшей техпроцесс.
|
||||||
|
"""
|
||||||
|
|
||||||
|
deal = models.ForeignKey(Deal, on_delete=models.CASCADE, verbose_name='Сделка')
|
||||||
|
entity = models.ForeignKey('manufacturing.ProductEntity', on_delete=models.PROTECT, verbose_name='Сущность')
|
||||||
|
current_seq = models.PositiveSmallIntegerField('Текущая операция (порядок)', default=1)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = 'Прогресс по операции'
|
||||||
|
verbose_name_plural = 'Прогресс по операциям'
|
||||||
|
unique_together = ('deal', 'entity')
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.deal.number}: {self.entity} -> {self.current_seq}"
|
||||||
|
|
||||||
|
|
||||||
class MaterialRequirement(models.Model):
|
class MaterialRequirement(models.Model):
|
||||||
"""Потребность в закупке сырья для сделки.
|
"""Потребность в закупке сырья для сделки.
|
||||||
|
|
||||||
@@ -212,6 +282,65 @@ class MaterialRequirement(models.Model):
|
|||||||
return f"{self.deal.number}: {self.material} -> {self.required_qty} {self.unit}"
|
return f"{self.deal.number}: {self.material} -> {self.required_qty} {self.unit}"
|
||||||
|
|
||||||
|
|
||||||
|
class ProcurementRequirement(models.Model):
|
||||||
|
"""
|
||||||
|
Потребность в закупке покупных комплектующих, литья и кооперации для сделки.
|
||||||
|
Рассчитывается при взрыве BOM (с учетом свободных остатков на складах).
|
||||||
|
"""
|
||||||
|
STATUS_CHOICES = [
|
||||||
|
('to_order', 'К заказу'),
|
||||||
|
('ordered', 'Заказано'),
|
||||||
|
('closed', 'Закрыто'),
|
||||||
|
]
|
||||||
|
|
||||||
|
deal = models.ForeignKey(Deal, on_delete=models.CASCADE, verbose_name='Сделка')
|
||||||
|
component = models.ForeignKey('manufacturing.ProductEntity', on_delete=models.PROTECT, verbose_name='Компонент (покупное/литье)')
|
||||||
|
required_qty = models.PositiveIntegerField('Потребность (к закупке), шт')
|
||||||
|
status = models.CharField('Статус', max_length=20, choices=STATUS_CHOICES, default='to_order')
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = 'Потребность снабжения'
|
||||||
|
verbose_name_plural = 'Потребности снабжения'
|
||||||
|
unique_together = ('deal', 'component')
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.deal.number}: {self.component} -> {self.required_qty}"
|
||||||
|
|
||||||
|
|
||||||
|
class WorkItem(models.Model):
|
||||||
|
deal = models.ForeignKey(Deal, on_delete=models.CASCADE, verbose_name='Сделка')
|
||||||
|
entity = models.ForeignKey('manufacturing.ProductEntity', on_delete=models.PROTECT, verbose_name='Сущность')
|
||||||
|
|
||||||
|
# Комментарий: operation — основной признак операции (расширяемый справочник).
|
||||||
|
operation = models.ForeignKey('manufacturing.Operation', on_delete=models.PROTECT, null=True, blank=True, verbose_name='Операция')
|
||||||
|
|
||||||
|
# Комментарий: stage оставляем строкой для совместимости с текущими фильтрами/экраном, но без choices.
|
||||||
|
stage = models.CharField('Стадия', max_length=32, blank=True, default='')
|
||||||
|
|
||||||
|
machine = models.ForeignKey('shiftflow.Machine', on_delete=models.PROTECT, null=True, blank=True, verbose_name='Станок/участок')
|
||||||
|
workshop = models.ForeignKey('shiftflow.Workshop', on_delete=models.PROTECT, null=True, blank=True, verbose_name='Цех')
|
||||||
|
|
||||||
|
quantity_plan = models.PositiveIntegerField('В план, шт', default=0)
|
||||||
|
quantity_done = models.PositiveIntegerField('Сделано, шт', default=0)
|
||||||
|
|
||||||
|
STATUS_CHOICES = [
|
||||||
|
('planned', 'В работе'),
|
||||||
|
('leftover', 'Недодел'),
|
||||||
|
('done', 'Закрыта'),
|
||||||
|
]
|
||||||
|
|
||||||
|
status = models.CharField('Статус', max_length=16, choices=STATUS_CHOICES, default='planned')
|
||||||
|
date = models.DateField('Дата', default=timezone.localdate)
|
||||||
|
comment = models.TextField('Комментарий', blank=True, default='')
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = 'План работ'
|
||||||
|
verbose_name_plural = 'План работ'
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.deal.number}: {self.entity} [{self.stage}] {self.quantity_plan}"
|
||||||
|
|
||||||
|
|
||||||
class CuttingSession(models.Model):
|
class CuttingSession(models.Model):
|
||||||
"""Производственный отчет (основа для списания/начисления).
|
"""Производственный отчет (основа для списания/начисления).
|
||||||
|
|
||||||
@@ -238,6 +367,17 @@ class CuttingSession(models.Model):
|
|||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
is_closed = models.BooleanField('Отчет закрыт', default=False)
|
is_closed = models.BooleanField('Отчет закрыт', default=False)
|
||||||
|
|
||||||
|
is_synced_1c = models.BooleanField('Выгружено в 1С', default=False)
|
||||||
|
synced_1c_at = models.DateTimeField('Выгружено в 1С (время)', null=True, blank=True)
|
||||||
|
synced_1c_by = models.ForeignKey(
|
||||||
|
User,
|
||||||
|
on_delete=models.PROTECT,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name='synced_cutting_sessions',
|
||||||
|
verbose_name='Выгрузил в 1С',
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = 'Производственный отчет'
|
verbose_name = 'Производственный отчет'
|
||||||
verbose_name_plural = 'Производственные отчеты'
|
verbose_name_plural = 'Производственные отчеты'
|
||||||
@@ -392,7 +532,6 @@ class Item(models.Model):
|
|||||||
('done', 'Выполнено'),
|
('done', 'Выполнено'),
|
||||||
('partial', 'Частично'),
|
('partial', 'Частично'),
|
||||||
('leftover', 'Недодел'),
|
('leftover', 'Недодел'),
|
||||||
('imported', 'Импортировано'),
|
|
||||||
]
|
]
|
||||||
|
|
||||||
# --- Ссылка на основу (временно null=True для миграции старых данных) ---
|
# --- Ссылка на основу (временно null=True для миграции старых данных) ---
|
||||||
@@ -432,14 +571,23 @@ class EmployeeProfile(models.Model):
|
|||||||
('operator', 'Оператор'),
|
('operator', 'Оператор'),
|
||||||
('clerk', 'Учетчик'),
|
('clerk', 'Учетчик'),
|
||||||
('observer', 'Наблюдатель'),
|
('observer', 'Наблюдатель'),
|
||||||
|
('manager', 'Руководитель'),
|
||||||
]
|
]
|
||||||
|
|
||||||
# Связь 1 к 1 со стандартным юзером Django
|
# Связь 1 к 1 со стандартным юзером Django
|
||||||
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='profile', verbose_name='Пользователь')
|
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='profile', verbose_name='Пользователь')
|
||||||
role = models.CharField(max_length=20, choices=ROLE_CHOICES, default='operator', verbose_name='Должность')
|
role = models.CharField(max_length=20, choices=ROLE_CHOICES, default='operator', verbose_name='Должность')
|
||||||
|
|
||||||
|
# Комментарий: режим для руководителя/наблюдателя — видит всё, но любые изменения запрещены.
|
||||||
|
is_readonly = models.BooleanField('Только просмотр', default=False)
|
||||||
|
|
||||||
# Привязка станков (можно выбрать несколько для одного оператора)
|
# Привязка станков (можно выбрать несколько для одного оператора)
|
||||||
machines = models.ManyToManyField('Machine', blank=True, verbose_name='Закрепленные станки')
|
machines = models.ManyToManyField('Machine', blank=True, verbose_name='Закрепленные станки')
|
||||||
|
|
||||||
|
# Комментарий: ограничение видимости/действий по цехам.
|
||||||
|
# Если список пустой — считаем, что доступ не ограничен (админ/технолог/руководитель).
|
||||||
|
allowed_workshops = models.ManyToManyField('Workshop', blank=True, verbose_name='Доступные цеха')
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.user.username} - {self.get_role_display()}"
|
return f"{self.user.username} - {self.get_role_display()}"
|
||||||
|
|
||||||
|
|||||||
202
shiftflow/services/assembly_closing.py
Normal file
202
shiftflow/services/assembly_closing.py
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
import logging
|
||||||
|
from django.db import transaction
|
||||||
|
from django.db.models import Q, Case, When, Value, IntegerField
|
||||||
|
from django.utils import timezone
|
||||||
|
from warehouse.models import StockItem
|
||||||
|
from shiftflow.models import WorkItem, CuttingSession, ProductionReportConsumption, ProductionReportStockResult
|
||||||
|
from shiftflow.services.bom_explosion import _build_bom_graph
|
||||||
|
from shiftflow.services.kitting import get_work_location_for_workitem
|
||||||
|
from manufacturing.models import EntityOperation
|
||||||
|
|
||||||
|
|
||||||
|
def get_first_operation_id(entity_id: int) -> int | None:
|
||||||
|
op_id = (
|
||||||
|
EntityOperation.objects.filter(entity_id=int(entity_id))
|
||||||
|
.order_by('seq', 'id')
|
||||||
|
.values_list('operation_id', flat=True)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
return int(op_id) if op_id else None
|
||||||
|
|
||||||
|
logger = logging.getLogger('mes')
|
||||||
|
|
||||||
|
def get_assembly_closing_info(workitem: WorkItem) -> dict:
|
||||||
|
"""
|
||||||
|
Возвращает информацию о том, сколько сборок можно выпустить и
|
||||||
|
какие компоненты для этого нужны.
|
||||||
|
"""
|
||||||
|
first_op_id = get_first_operation_id(int(workitem.entity_id))
|
||||||
|
if first_op_id and getattr(workitem, 'operation_id', None) and int(workitem.operation_id) != int(first_op_id):
|
||||||
|
return {'error': 'Списание комплектации выполняется только на первой операции техпроцесса. Для этой операции закрывай только факт выполнения.', 'is_first_operation': False}
|
||||||
|
|
||||||
|
to_location = get_work_location_for_workitem(workitem)
|
||||||
|
if not to_location:
|
||||||
|
return {'error': 'Не определён склад участка для этого задания.'}
|
||||||
|
|
||||||
|
# Считаем BOM 1-го уровня
|
||||||
|
adjacency = _build_bom_graph({workitem.entity_id})
|
||||||
|
children = adjacency.get(workitem.entity_id) or []
|
||||||
|
|
||||||
|
if not children:
|
||||||
|
return {'error': 'Спецификация пуста. Нечего списывать.', 'to_location': to_location}
|
||||||
|
|
||||||
|
bom_req = {} # entity_id -> qty_per_1
|
||||||
|
for child_id, qty in children:
|
||||||
|
bom_req[child_id] = bom_req.get(child_id, 0) + qty
|
||||||
|
|
||||||
|
component_ids = list(bom_req.keys())
|
||||||
|
stocks = StockItem.objects.filter(
|
||||||
|
location=to_location,
|
||||||
|
entity_id__in=component_ids,
|
||||||
|
is_archived=False,
|
||||||
|
quantity__gt=0
|
||||||
|
).filter(Q(deal_id=workitem.deal_id) | Q(deal_id__isnull=True))
|
||||||
|
|
||||||
|
stock_by_entity = {}
|
||||||
|
for s in stocks:
|
||||||
|
stock_by_entity[s.entity_id] = stock_by_entity.get(s.entity_id, 0) + s.quantity
|
||||||
|
|
||||||
|
max_possible = float('inf')
|
||||||
|
components_info = []
|
||||||
|
|
||||||
|
from manufacturing.models import ProductEntity
|
||||||
|
entities = {e.id: e for e in ProductEntity.objects.filter(id__in=component_ids)}
|
||||||
|
|
||||||
|
for eid, req_qty in bom_req.items():
|
||||||
|
avail = float(stock_by_entity.get(eid, 0))
|
||||||
|
can_make = int(avail // float(req_qty)) if req_qty > 0 else 0
|
||||||
|
if can_make < max_possible:
|
||||||
|
max_possible = can_make
|
||||||
|
|
||||||
|
components_info.append({
|
||||||
|
'entity': entities.get(eid),
|
||||||
|
'req_per_1': float(req_qty),
|
||||||
|
'available': avail,
|
||||||
|
'max_possible': can_make
|
||||||
|
})
|
||||||
|
|
||||||
|
if max_possible == float('inf'):
|
||||||
|
max_possible = 0
|
||||||
|
|
||||||
|
components_info.sort(key=lambda x: (str(x['entity'].entity_type or ''), str(x['entity'].name or '')) if x['entity'] else ('', ''))
|
||||||
|
|
||||||
|
# Ограничиваем max_possible тем, что реально осталось собрать по заданию
|
||||||
|
remaining = max(0, (workitem.quantity_plan or 0) - (workitem.quantity_done or 0))
|
||||||
|
if max_possible > remaining:
|
||||||
|
max_possible = remaining
|
||||||
|
|
||||||
|
return {
|
||||||
|
'to_location': to_location,
|
||||||
|
'max_possible': int(max_possible),
|
||||||
|
'components': components_info,
|
||||||
|
'error': None,
|
||||||
|
'is_first_operation': True,
|
||||||
|
}
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def apply_assembly_closing(workitem_id: int, fact_qty: int, user_id: int) -> bool:
|
||||||
|
logger.info('assembly_closing:start workitem_id=%s qty=%s user_id=%s', workitem_id, fact_qty, user_id)
|
||||||
|
workitem = WorkItem.objects.select_for_update(of=('self',)).get(id=int(workitem_id))
|
||||||
|
|
||||||
|
first_op_id = get_first_operation_id(int(workitem.entity_id))
|
||||||
|
if first_op_id and getattr(workitem, 'operation_id', None) and int(workitem.operation_id) != int(first_op_id):
|
||||||
|
raise ValueError('Списание комплектации выполняется только на первой операции техпроцесса.')
|
||||||
|
if fact_qty <= 0:
|
||||||
|
raise ValueError('Количество должно быть больше 0.')
|
||||||
|
|
||||||
|
info = get_assembly_closing_info(workitem)
|
||||||
|
if info.get('error'):
|
||||||
|
raise ValueError(info['error'])
|
||||||
|
|
||||||
|
if fact_qty > info['max_possible']:
|
||||||
|
raise ValueError(f'Недостаточно компонентов на участке. Максимум можно собрать: {info["max_possible"]} шт.')
|
||||||
|
|
||||||
|
to_location = info['to_location']
|
||||||
|
|
||||||
|
if not getattr(workitem, 'machine_id', None):
|
||||||
|
raise ValueError('Для закрытия сборки требуется выбрать пост (станок) в сменном задании.')
|
||||||
|
|
||||||
|
report = CuttingSession.objects.create(
|
||||||
|
operator_id=int(user_id),
|
||||||
|
machine_id=int(workitem.machine_id),
|
||||||
|
used_stock_item=None,
|
||||||
|
date=timezone.localdate(),
|
||||||
|
is_closed=True,
|
||||||
|
)
|
||||||
|
logger.info('assembly_closing:report_created id=%s', report.id)
|
||||||
|
|
||||||
|
# Списываем компоненты 1-го уровня
|
||||||
|
adjacency = _build_bom_graph({workitem.entity_id})
|
||||||
|
children = adjacency.get(workitem.entity_id) or []
|
||||||
|
bom_req = {}
|
||||||
|
for child_id, qty in children:
|
||||||
|
bom_req[child_id] = bom_req.get(child_id, 0) + qty
|
||||||
|
|
||||||
|
for eid, req_qty in bom_req.items():
|
||||||
|
total_needed = float(req_qty * fact_qty)
|
||||||
|
|
||||||
|
# Приоритет "сделка", потом "свободные", FIFO
|
||||||
|
qs = StockItem.objects.select_for_update().filter(
|
||||||
|
location=to_location,
|
||||||
|
entity_id=eid,
|
||||||
|
is_archived=False,
|
||||||
|
quantity__gt=0
|
||||||
|
).filter(Q(deal_id=workitem.deal_id) | Q(deal_id__isnull=True)).annotate(
|
||||||
|
prio=Case(
|
||||||
|
When(deal_id=workitem.deal_id, then=Value(0)),
|
||||||
|
default=Value(1),
|
||||||
|
output_field=IntegerField(),
|
||||||
|
)
|
||||||
|
).order_by('prio', 'created_at', 'id')
|
||||||
|
|
||||||
|
rem = total_needed
|
||||||
|
for si in qs:
|
||||||
|
if rem <= 0:
|
||||||
|
break
|
||||||
|
take = min(rem, float(si.quantity))
|
||||||
|
|
||||||
|
ProductionReportConsumption.objects.create(
|
||||||
|
report=report,
|
||||||
|
material=None,
|
||||||
|
stock_item=si,
|
||||||
|
quantity=float(take),
|
||||||
|
)
|
||||||
|
|
||||||
|
si.quantity = float(si.quantity) - take
|
||||||
|
if si.quantity <= 0.0001:
|
||||||
|
si.quantity = 0
|
||||||
|
si.is_archived = True
|
||||||
|
si.archived_at = timezone.now()
|
||||||
|
si.save(update_fields=['quantity', 'is_archived', 'archived_at'])
|
||||||
|
rem -= take
|
||||||
|
|
||||||
|
if rem > 0.0001:
|
||||||
|
raise ValueError(f'Непредвиденная нехватка компонента ID {eid} при списании. Нужно еще: {rem}')
|
||||||
|
|
||||||
|
# Выпуск готовой сборки
|
||||||
|
produced = StockItem.objects.create(
|
||||||
|
entity_id=workitem.entity_id,
|
||||||
|
deal_id=workitem.deal_id,
|
||||||
|
location=to_location,
|
||||||
|
quantity=float(fact_qty),
|
||||||
|
is_customer_supplied=False,
|
||||||
|
)
|
||||||
|
ProductionReportStockResult.objects.create(report=report, stock_item=produced, kind='finished')
|
||||||
|
|
||||||
|
# Двигаем техпроцесс
|
||||||
|
workitem.quantity_done = (workitem.quantity_done or 0) + fact_qty
|
||||||
|
if workitem.quantity_done >= workitem.quantity_plan:
|
||||||
|
workitem.status = 'done'
|
||||||
|
workitem.save(update_fields=['quantity_done', 'status'])
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
'assembly_closing:done workitem_id=%s qty=%s deal_id=%s location_id=%s user_id=%s report_id=%s',
|
||||||
|
workitem.id,
|
||||||
|
fact_qty,
|
||||||
|
workitem.deal_id,
|
||||||
|
to_location.id,
|
||||||
|
user_id,
|
||||||
|
report.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
return True
|
||||||
@@ -1,15 +1,24 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from django.db import transaction
|
from django.db import models, transaction
|
||||||
from django.db.models import Sum
|
from django.db.models import Sum
|
||||||
from django.db.models.functions import Coalesce
|
from django.db.models.functions import Coalesce
|
||||||
|
|
||||||
from manufacturing.models import BOM, ProductEntity
|
from manufacturing.models import BOM, ProductEntity
|
||||||
from shiftflow.models import Deal, DealItem, MaterialRequirement, ProductionTask
|
from shiftflow.models import Deal, DealItem, ProcurementRequirement, ProductionTask
|
||||||
from warehouse.models import Location, StockItem
|
from warehouse.models import StockItem
|
||||||
|
|
||||||
|
logger = logging.getLogger('mes')
|
||||||
|
|
||||||
|
|
||||||
|
class ExplosionValidationError(Exception):
|
||||||
|
def __init__(self, missing_material_ids: list[int]):
|
||||||
|
super().__init__('missing_material')
|
||||||
|
self.missing_material_ids = [int(x) for x in (missing_material_ids or [])]
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -21,7 +30,10 @@ class ExplosionStats:
|
|||||||
- сколько ProductionTask создано/обновлено (по leaf-деталям)
|
- сколько ProductionTask создано/обновлено (по leaf-деталям)
|
||||||
|
|
||||||
req_*:
|
req_*:
|
||||||
- сколько MaterialRequirement создано/обновлено (по сырью)
|
- сколько ProcurementRequirement создано/обновлено (по потребностям снабжения)
|
||||||
|
|
||||||
|
Примечание:
|
||||||
|
- потребность по сырью (лист/профиль) сейчас не считаем автоматически — будет вводиться вручную.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
tasks_created: int
|
tasks_created: int
|
||||||
@@ -151,19 +163,40 @@ def _explode_to_leaves(
|
|||||||
return memo[entity_id]
|
return memo[entity_id]
|
||||||
|
|
||||||
|
|
||||||
|
def _accumulate_requirements(
|
||||||
|
entity_id: int,
|
||||||
|
multiplier: int,
|
||||||
|
adjacency: dict[int, list[tuple[int, int]]],
|
||||||
|
visiting: set[int],
|
||||||
|
out: dict[int, int],
|
||||||
|
) -> None:
|
||||||
|
if entity_id in visiting:
|
||||||
|
raise RuntimeError("Цикл в BOM: спецификация зациклена.")
|
||||||
|
visiting.add(entity_id)
|
||||||
|
|
||||||
|
out[int(entity_id)] = int(out.get(int(entity_id), 0) or 0) + int(multiplier)
|
||||||
|
|
||||||
|
for child_id, qty in adjacency.get(int(entity_id), []) or []:
|
||||||
|
_accumulate_requirements(int(child_id), int(multiplier) * int(qty), adjacency, visiting, out)
|
||||||
|
|
||||||
|
visiting.remove(entity_id)
|
||||||
|
|
||||||
|
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def explode_deal(
|
def explode_deal(
|
||||||
deal_id: int,
|
deal_id: int,
|
||||||
*,
|
*,
|
||||||
central_location_name: str = "Центральный склад",
|
central_location_name: str = "Центральный склад",
|
||||||
|
create_tasks: bool = False,
|
||||||
|
create_procurement: bool = True,
|
||||||
) -> ExplosionStats:
|
) -> ExplosionStats:
|
||||||
"""
|
"""BOM Explosion по сделке.
|
||||||
BOM Explosion:
|
|
||||||
- берём состав сделки (DealItem)
|
Используется в двух режимах:
|
||||||
- рекурсивно обходим BOM
|
- create_procurement=True: пересчитать потребности снабжения (покупное/литьё/аутсорс)
|
||||||
- считаем суммарное количество leaf-деталей
|
- create_tasks=True: создать/обновить ProductionTask по внутреннему производству
|
||||||
- создаём/обновляем ProductionTask (deal + entity)
|
|
||||||
- создаём/обновляем MaterialRequirement по нормам расхода и остаткам на центральном складе
|
Примечание: потребность по сырью (MaterialRequirement) здесь не считаем автоматически.
|
||||||
"""
|
"""
|
||||||
deal = Deal.objects.select_for_update().get(pk=deal_id)
|
deal = Deal.objects.select_for_update().get(pk=deal_id)
|
||||||
|
|
||||||
@@ -191,28 +224,200 @@ def explode_deal(
|
|||||||
tasks_created = 0
|
tasks_created = 0
|
||||||
tasks_updated = 0
|
tasks_updated = 0
|
||||||
|
|
||||||
for entity_id, qty in required_leaves.items():
|
if create_tasks:
|
||||||
|
for entity_id, qty in required_leaves.items():
|
||||||
|
entity = leaf_entities.get(entity_id)
|
||||||
|
if not entity:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not entity.planned_material_id:
|
||||||
|
continue
|
||||||
|
|
||||||
|
pt, created = ProductionTask.objects.get_or_create(
|
||||||
|
deal=deal,
|
||||||
|
entity=entity,
|
||||||
|
defaults={
|
||||||
|
"drawing_name": entity.name or "Б/ч",
|
||||||
|
"size_value": 0,
|
||||||
|
"material": entity.planned_material,
|
||||||
|
"quantity_ordered": int(qty),
|
||||||
|
"is_bend": False,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if created:
|
||||||
|
tasks_created += 1
|
||||||
|
else:
|
||||||
|
changed = False
|
||||||
|
if pt.quantity_ordered != int(qty):
|
||||||
|
pt.quantity_ordered = int(qty)
|
||||||
|
changed = True
|
||||||
|
if not pt.material_id and entity.planned_material_id:
|
||||||
|
pt.material = entity.planned_material
|
||||||
|
changed = True
|
||||||
|
if changed:
|
||||||
|
pt.save(update_fields=["quantity_ordered", "material"])
|
||||||
|
tasks_updated += 1
|
||||||
|
|
||||||
|
req_created = 0
|
||||||
|
req_updated = 0
|
||||||
|
seen_component_ids: set[int] = set()
|
||||||
|
|
||||||
|
if not create_procurement:
|
||||||
|
return ExplosionStats(tasks_created, tasks_updated, 0, 0)
|
||||||
|
|
||||||
|
for entity_id, qty_parts in required_leaves.items():
|
||||||
entity = leaf_entities.get(entity_id)
|
entity = leaf_entities.get(entity_id)
|
||||||
if not entity:
|
if not entity:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# Комментарий: потребность снабжения считаем только для покупного/литья/аутсорса.
|
||||||
|
et = (entity.entity_type or '').strip()
|
||||||
|
if et not in ['purchased', 'casting', 'outsourced']:
|
||||||
|
continue
|
||||||
|
|
||||||
|
seen_component_ids.add(int(entity.id))
|
||||||
|
|
||||||
|
required_qty = int(qty_parts or 0)
|
||||||
|
|
||||||
|
# Комментарий: снабжение работает с поштучными позициями.
|
||||||
|
# StockItem.quantity в БД float (универсальная единица), поэтому здесь приводим к int.
|
||||||
|
# Разрешены:
|
||||||
|
# - свободные (deal is null)
|
||||||
|
# - уже закреплённые за этой же сделкой (deal = deal)
|
||||||
|
available_raw = (
|
||||||
|
StockItem.objects.filter(entity=entity, is_archived=False)
|
||||||
|
.filter(models.Q(deal__isnull=True) | models.Q(deal=deal))
|
||||||
|
.aggregate(v=Coalesce(Sum("quantity"), 0.0))["v"]
|
||||||
|
)
|
||||||
|
available = int(available_raw or 0)
|
||||||
|
|
||||||
|
to_buy = max(0, int(required_qty) - int(available))
|
||||||
|
|
||||||
|
if to_buy > 0:
|
||||||
|
pr, created = ProcurementRequirement.objects.get_or_create(
|
||||||
|
deal=deal,
|
||||||
|
component=entity,
|
||||||
|
defaults={"required_qty": int(to_buy), "status": "to_order"},
|
||||||
|
)
|
||||||
|
if created:
|
||||||
|
req_created += 1
|
||||||
|
else:
|
||||||
|
pr.required_qty = int(to_buy)
|
||||||
|
|
||||||
|
# Комментарий: если снабженец уже отметил «Заказано», пересчёт не должен сбрасывать статус назад.
|
||||||
|
if pr.status != 'ordered':
|
||||||
|
pr.status = 'to_order'
|
||||||
|
|
||||||
|
pr.save(update_fields=["required_qty", "status"])
|
||||||
|
req_updated += 1
|
||||||
|
else:
|
||||||
|
updated = ProcurementRequirement.objects.filter(deal=deal, component=entity).update(
|
||||||
|
required_qty=0,
|
||||||
|
status='closed',
|
||||||
|
)
|
||||||
|
if updated:
|
||||||
|
req_updated += int(updated)
|
||||||
|
|
||||||
|
# Комментарий: если компонент исчез из сделки/спецификации — закрываем устаревшие строки,
|
||||||
|
# чтобы при повторном «вскрытии» данные обновлялись, а не накапливались.
|
||||||
|
qs_stale = ProcurementRequirement.objects.filter(
|
||||||
|
deal=deal,
|
||||||
|
component__entity_type__in=['purchased', 'casting', 'outsourced'],
|
||||||
|
)
|
||||||
|
if seen_component_ids:
|
||||||
|
qs_stale = qs_stale.exclude(component_id__in=list(seen_component_ids))
|
||||||
|
|
||||||
|
updated = qs_stale.update(required_qty=0, status='closed')
|
||||||
|
if updated:
|
||||||
|
req_updated += int(updated)
|
||||||
|
|
||||||
|
return ExplosionStats(tasks_created, tasks_updated, req_created, req_updated)
|
||||||
|
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def explode_roots_additive(
|
||||||
|
deal_id: int,
|
||||||
|
roots: list[tuple[int, int]],
|
||||||
|
) -> ExplosionStats:
|
||||||
|
"""Additive BOM Explosion для запуска в производство по частям.
|
||||||
|
|
||||||
|
roots: список (root_entity_id, qty_to_start).
|
||||||
|
|
||||||
|
В отличие от explode_deal:
|
||||||
|
- не пересчитывает всю сделку
|
||||||
|
- увеличивает quantity_ordered у ProductionTask по leaf-деталям на добавленный объём.
|
||||||
|
|
||||||
|
Примечание: MaterialRequirement здесь намеренно не трогаем — её лучше считать отдельной процедурой
|
||||||
|
по всей сделке/партии, чтобы не накапливать ошибки при многократных инкрементах.
|
||||||
|
"""
|
||||||
|
deal = Deal.objects.select_for_update().get(pk=deal_id)
|
||||||
|
|
||||||
|
roots = [(int(eid), int(q)) for eid, q in (roots or []) if int(q or 0) > 0]
|
||||||
|
if not roots:
|
||||||
|
return ExplosionStats(0, 0, 0, 0)
|
||||||
|
|
||||||
|
root_ids = {eid for eid, _ in roots}
|
||||||
|
adjacency = _build_bom_graph(root_ids)
|
||||||
|
|
||||||
|
required_nodes: dict[int, int] = {}
|
||||||
|
|
||||||
|
for root_entity_id, root_qty in roots:
|
||||||
|
_accumulate_requirements(int(root_entity_id), int(root_qty), adjacency, set(), required_nodes)
|
||||||
|
|
||||||
|
entities = {
|
||||||
|
e.id: e
|
||||||
|
for e in ProductEntity.objects.select_related("planned_material", "planned_material__category")
|
||||||
|
.filter(id__in=list(required_nodes.keys()))
|
||||||
|
}
|
||||||
|
|
||||||
|
missing = [
|
||||||
|
int(e.id)
|
||||||
|
for e in entities.values()
|
||||||
|
if (getattr(e, 'entity_type', '') == 'part' and not getattr(e, 'planned_material_id', None) and int(required_nodes.get(int(e.id), 0) or 0) > 0)
|
||||||
|
]
|
||||||
|
if missing:
|
||||||
|
raise ExplosionValidationError(missing)
|
||||||
|
|
||||||
|
tasks_created = 0
|
||||||
|
tasks_updated = 0
|
||||||
|
skipped_no_material = 0
|
||||||
|
skipped_supply = 0
|
||||||
|
|
||||||
|
for entity_id, qty in required_nodes.items():
|
||||||
|
entity = entities.get(int(entity_id))
|
||||||
|
if not entity:
|
||||||
|
continue
|
||||||
|
|
||||||
|
et = (entity.entity_type or '').strip()
|
||||||
|
if et in ['purchased', 'casting', 'outsourced']:
|
||||||
|
skipped_supply += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
allow_no_material = et in ['assembly', 'product']
|
||||||
|
if not allow_no_material and not entity.planned_material_id:
|
||||||
|
skipped_no_material += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
defaults = {
|
||||||
|
"drawing_name": entity.name or "Б/ч",
|
||||||
|
"size_value": 0,
|
||||||
|
"material": entity.planned_material if entity.planned_material_id else None,
|
||||||
|
"quantity_ordered": int(qty),
|
||||||
|
"is_bend": False,
|
||||||
|
}
|
||||||
|
|
||||||
pt, created = ProductionTask.objects.get_or_create(
|
pt, created = ProductionTask.objects.get_or_create(
|
||||||
deal=deal,
|
deal=deal,
|
||||||
entity=entity,
|
entity=entity,
|
||||||
defaults={
|
defaults=defaults,
|
||||||
"drawing_name": entity.name or "Б/ч",
|
|
||||||
"size_value": 0,
|
|
||||||
"material": entity.planned_material,
|
|
||||||
"quantity_ordered": int(qty),
|
|
||||||
"is_bend": False,
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
if created:
|
if created:
|
||||||
tasks_created += 1
|
tasks_created += 1
|
||||||
else:
|
else:
|
||||||
changed = False
|
changed = False
|
||||||
if pt.quantity_ordered != int(qty):
|
new_qty = int(pt.quantity_ordered or 0) + int(qty)
|
||||||
pt.quantity_ordered = int(qty)
|
if pt.quantity_ordered != new_qty:
|
||||||
|
pt.quantity_ordered = new_qty
|
||||||
changed = True
|
changed = True
|
||||||
if not pt.material_id and entity.planned_material_id:
|
if not pt.material_id and entity.planned_material_id:
|
||||||
pt.material = entity.planned_material
|
pt.material = entity.planned_material
|
||||||
@@ -221,45 +426,14 @@ def explode_deal(
|
|||||||
pt.save(update_fields=["quantity_ordered", "material"])
|
pt.save(update_fields=["quantity_ordered", "material"])
|
||||||
tasks_updated += 1
|
tasks_updated += 1
|
||||||
|
|
||||||
central, _ = Location.objects.get_or_create(
|
logger.info(
|
||||||
name=central_location_name,
|
'explode_roots_additive: deal_id=%s roots=%s nodes=%s tasks_created=%s tasks_updated=%s skipped_no_material=%s skipped_supply=%s',
|
||||||
defaults={"is_production_area": False},
|
deal_id,
|
||||||
|
roots,
|
||||||
|
len(required_nodes),
|
||||||
|
tasks_created,
|
||||||
|
tasks_updated,
|
||||||
|
skipped_no_material,
|
||||||
|
skipped_supply,
|
||||||
)
|
)
|
||||||
|
return ExplosionStats(tasks_created, tasks_updated, 0, 0)
|
||||||
req_created = 0
|
|
||||||
req_updated = 0
|
|
||||||
|
|
||||||
for entity_id, qty_parts in required_leaves.items():
|
|
||||||
entity = leaf_entities.get(entity_id)
|
|
||||||
if not entity or not entity.planned_material_id:
|
|
||||||
continue
|
|
||||||
|
|
||||||
per_unit, unit = _norm_and_unit(entity)
|
|
||||||
if not per_unit:
|
|
||||||
continue
|
|
||||||
|
|
||||||
required_qty = float(qty_parts) * float(per_unit)
|
|
||||||
|
|
||||||
available = (
|
|
||||||
StockItem.objects.filter(location=central, material=entity.planned_material)
|
|
||||||
.aggregate(v=Coalesce(Sum("quantity"), 0.0))["v"]
|
|
||||||
)
|
|
||||||
to_buy = max(0.0, required_qty - float(available or 0.0))
|
|
||||||
if to_buy <= 0:
|
|
||||||
continue
|
|
||||||
|
|
||||||
mr, created = MaterialRequirement.objects.get_or_create(
|
|
||||||
deal=deal,
|
|
||||||
material=entity.planned_material,
|
|
||||||
unit=unit,
|
|
||||||
defaults={"required_qty": to_buy, "status": "needed"},
|
|
||||||
)
|
|
||||||
if created:
|
|
||||||
req_created += 1
|
|
||||||
else:
|
|
||||||
mr.required_qty = to_buy
|
|
||||||
mr.status = "needed"
|
|
||||||
mr.save(update_fields=["required_qty", "status"])
|
|
||||||
req_updated += 1
|
|
||||||
|
|
||||||
return ExplosionStats(tasks_created, tasks_updated, req_created, req_updated)
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
|
from django.db.models import F
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
@@ -130,3 +131,92 @@ def apply_closing(
|
|||||||
)
|
)
|
||||||
|
|
||||||
logger.info('apply_closing:done report=%s', report.id)
|
logger.info('apply_closing:done report=%s', report.id)
|
||||||
|
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def apply_closing_workitems(
|
||||||
|
*,
|
||||||
|
user_id: int,
|
||||||
|
machine_id: int,
|
||||||
|
material_id: int,
|
||||||
|
item_actions: dict[int, dict], # workitem_id -> {'action': 'done'|'partial', 'fact': int}
|
||||||
|
consumptions: dict[int, float],
|
||||||
|
remnants: list[dict],
|
||||||
|
) -> None:
|
||||||
|
logger.info('apply_closing_workitems:start user=%s machine=%s material=%s workitems=%s cons=%s rem=%s', user_id, machine_id, material_id, list(item_actions.keys()), list(consumptions.keys()), len(remnants))
|
||||||
|
|
||||||
|
from shiftflow.models import WorkItem, ProductionTask
|
||||||
|
|
||||||
|
wis = list(
|
||||||
|
WorkItem.objects.select_for_update(of=("self",))
|
||||||
|
.select_related('deal', 'entity', 'machine')
|
||||||
|
.filter(id__in=list(item_actions.keys()), machine_id=machine_id, status__in=['planned'], entity__planned_material_id=material_id)
|
||||||
|
.filter(quantity_done__lt=F('quantity_plan'))
|
||||||
|
)
|
||||||
|
if not wis:
|
||||||
|
raise RuntimeError('Не найдено сменных заданий для закрытия.')
|
||||||
|
|
||||||
|
report = CuttingSession.objects.create(
|
||||||
|
operator_id=user_id,
|
||||||
|
machine_id=machine_id,
|
||||||
|
used_stock_item=None,
|
||||||
|
date=timezone.localdate(),
|
||||||
|
is_closed=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
created_shift = 0
|
||||||
|
for wi in wis:
|
||||||
|
spec = item_actions.get(wi.id) or {}
|
||||||
|
action = (spec.get('action') or '').strip()
|
||||||
|
fact = int(spec.get('fact') or 0)
|
||||||
|
if action not in ['done', 'partial']:
|
||||||
|
continue
|
||||||
|
|
||||||
|
plan_total = int(wi.quantity_plan or 0)
|
||||||
|
done_total = int(wi.quantity_done or 0)
|
||||||
|
remaining = max(0, plan_total - done_total)
|
||||||
|
if remaining <= 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if action == 'done':
|
||||||
|
fact = remaining
|
||||||
|
else:
|
||||||
|
fact = max(0, min(fact, remaining))
|
||||||
|
if fact <= 0:
|
||||||
|
raise RuntimeError('При частичном закрытии факт должен быть больше 0.')
|
||||||
|
|
||||||
|
pt = ProductionTask.objects.filter(deal_id=wi.deal_id, entity_id=wi.entity_id).first()
|
||||||
|
if not pt:
|
||||||
|
raise RuntimeError('Не найден ProductionTask для задания.')
|
||||||
|
|
||||||
|
ShiftItem.objects.create(session=report, task=pt, quantity_fact=fact)
|
||||||
|
created_shift += 1
|
||||||
|
|
||||||
|
wi.quantity_done = done_total + fact
|
||||||
|
if wi.quantity_done >= plan_total:
|
||||||
|
wi.status = 'done'
|
||||||
|
elif wi.quantity_done > 0:
|
||||||
|
wi.status = 'leftover'
|
||||||
|
else:
|
||||||
|
wi.status = 'planned'
|
||||||
|
wi.save(update_fields=['quantity_done', 'status'])
|
||||||
|
|
||||||
|
for stock_item_id, qty in consumptions.items():
|
||||||
|
if qty and float(qty) > 0:
|
||||||
|
ProductionReportConsumption.objects.create(report=report, stock_item_id=stock_item_id, material_id=None, quantity=float(qty))
|
||||||
|
|
||||||
|
for r in remnants:
|
||||||
|
qty = float(r.get('quantity') or 0)
|
||||||
|
if qty <= 0:
|
||||||
|
continue
|
||||||
|
ProductionReportRemnant.objects.create(
|
||||||
|
report=report,
|
||||||
|
material_id=material_id,
|
||||||
|
quantity=qty,
|
||||||
|
current_length=r.get('current_length'),
|
||||||
|
current_width=r.get('current_width'),
|
||||||
|
unique_id=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
close_cutting_session(report.id)
|
||||||
|
logger.info('apply_closing_workitems:done report=%s shift_items=%s', report.id, created_shift)
|
||||||
|
|||||||
268
shiftflow/services/kitting.py
Normal file
268
shiftflow/services/kitting.py
Normal file
@@ -0,0 +1,268 @@
|
|||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from django.db import transaction
|
||||||
|
from django.db.models import Case, IntegerField, Q, Value, When
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from manufacturing.models import ProductEntity
|
||||||
|
from warehouse.models import Location, StockItem, TransferLine, TransferRecord
|
||||||
|
from warehouse.services.transfers import receive_transfer
|
||||||
|
|
||||||
|
from shiftflow.services.bom_explosion import _accumulate_requirements, _build_bom_graph
|
||||||
|
|
||||||
|
logger = logging.getLogger('mes')
|
||||||
|
|
||||||
|
|
||||||
|
def _session_key(workitem_id: int) -> str:
|
||||||
|
return f'kitting_draft_workitem_{int(workitem_id)}'
|
||||||
|
|
||||||
|
|
||||||
|
def get_kitting_draft(session: Any, workitem_id: int) -> list[dict]:
|
||||||
|
key = _session_key(workitem_id)
|
||||||
|
raw = session.get(key)
|
||||||
|
if isinstance(raw, list):
|
||||||
|
out = []
|
||||||
|
for x in raw:
|
||||||
|
if not isinstance(x, dict):
|
||||||
|
continue
|
||||||
|
out.append({
|
||||||
|
'entity_id': int(x.get('entity_id') or 0),
|
||||||
|
'from_location_id': int(x.get('from_location_id') or 0),
|
||||||
|
'quantity': int(x.get('quantity') or 0),
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def clear_kitting_draft(session: Any, workitem_id: int) -> None:
|
||||||
|
key = _session_key(workitem_id)
|
||||||
|
if key in session:
|
||||||
|
del session[key]
|
||||||
|
session.modified = True
|
||||||
|
|
||||||
|
|
||||||
|
def add_kitting_line(session: Any, workitem_id: int, entity_id: int, from_location_id: int, quantity: int) -> None:
|
||||||
|
workitem_id = int(workitem_id)
|
||||||
|
entity_id = int(entity_id)
|
||||||
|
from_location_id = int(from_location_id)
|
||||||
|
quantity = int(quantity)
|
||||||
|
|
||||||
|
if workitem_id <= 0 or entity_id <= 0 or from_location_id <= 0 or quantity <= 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
key = _session_key(workitem_id)
|
||||||
|
draft = get_kitting_draft(session, workitem_id)
|
||||||
|
|
||||||
|
merged = False
|
||||||
|
for ln in draft:
|
||||||
|
if int(ln.get('entity_id') or 0) == entity_id and int(ln.get('from_location_id') or 0) == from_location_id:
|
||||||
|
ln['quantity'] = int(ln.get('quantity') or 0) + quantity
|
||||||
|
merged = True
|
||||||
|
break
|
||||||
|
|
||||||
|
if not merged:
|
||||||
|
draft.append({'entity_id': entity_id, 'from_location_id': from_location_id, 'quantity': quantity})
|
||||||
|
|
||||||
|
session[key] = draft
|
||||||
|
session.modified = True
|
||||||
|
|
||||||
|
|
||||||
|
def remove_kitting_line(session: Any, workitem_id: int, entity_id: int, from_location_id: int, quantity: int) -> None:
|
||||||
|
workitem_id = int(workitem_id)
|
||||||
|
entity_id = int(entity_id)
|
||||||
|
from_location_id = int(from_location_id)
|
||||||
|
quantity = int(quantity)
|
||||||
|
|
||||||
|
if workitem_id <= 0 or entity_id <= 0 or from_location_id <= 0 or quantity <= 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
key = _session_key(workitem_id)
|
||||||
|
draft = get_kitting_draft(session, workitem_id)
|
||||||
|
|
||||||
|
out = []
|
||||||
|
for ln in draft:
|
||||||
|
if int(ln.get('entity_id') or 0) == entity_id and int(ln.get('from_location_id') or 0) == from_location_id:
|
||||||
|
cur = int(ln.get('quantity') or 0)
|
||||||
|
cur = max(0, cur - quantity)
|
||||||
|
if cur > 0:
|
||||||
|
ln['quantity'] = cur
|
||||||
|
out.append(ln)
|
||||||
|
continue
|
||||||
|
out.append(ln)
|
||||||
|
|
||||||
|
session[key] = out
|
||||||
|
session.modified = True
|
||||||
|
|
||||||
|
|
||||||
|
def get_work_location_for_workitem(workitem) -> Location | None:
|
||||||
|
m = getattr(workitem, 'machine', None)
|
||||||
|
if m and getattr(m, 'workshop_id', None) and getattr(getattr(m, 'workshop', None), 'location_id', None):
|
||||||
|
return m.workshop.location
|
||||||
|
if m and getattr(m, 'location_id', None):
|
||||||
|
return m.location
|
||||||
|
w = getattr(workitem, 'workshop', None)
|
||||||
|
if w and getattr(w, 'location_id', None):
|
||||||
|
return w.location
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def build_kitting_requirements(root_entity_id: int, qty_to_make: int) -> dict[int, int]:
|
||||||
|
"""Потребность на комплектацию для сборки/изделия.
|
||||||
|
|
||||||
|
Комментарий: для ручной комплектации мастеру важны прямые компоненты (1 уровень BOM),
|
||||||
|
включая подсборки. Глубину дерева раскрываем отдельными заданиями на подсборки.
|
||||||
|
"""
|
||||||
|
root_entity_id = int(root_entity_id)
|
||||||
|
qty_to_make = int(qty_to_make or 0)
|
||||||
|
if root_entity_id <= 0 or qty_to_make <= 0:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
adjacency = _build_bom_graph({root_entity_id})
|
||||||
|
children = adjacency.get(root_entity_id) or []
|
||||||
|
|
||||||
|
out: dict[int, int] = {}
|
||||||
|
for child_id, per1 in children:
|
||||||
|
cid = int(child_id)
|
||||||
|
need = int(per1 or 0) * qty_to_make
|
||||||
|
if cid <= 0 or need <= 0:
|
||||||
|
continue
|
||||||
|
out[cid] = int(out.get(cid, 0) or 0) + need
|
||||||
|
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def build_kitting_leaf_requirements(root_entity_id: int, qty_to_make: int) -> dict[int, int]:
|
||||||
|
"""Совместимость: старое имя оставлено, сейчас возвращает потребность 1-го уровня BOM."""
|
||||||
|
return build_kitting_requirements(root_entity_id, qty_to_make)
|
||||||
|
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def _apply_one_transfer(
|
||||||
|
*,
|
||||||
|
deal_id: int,
|
||||||
|
component_entity_id: int,
|
||||||
|
from_location_id: int,
|
||||||
|
to_location_id: int,
|
||||||
|
quantity: int,
|
||||||
|
user_id: int,
|
||||||
|
) -> int:
|
||||||
|
deal_id = int(deal_id)
|
||||||
|
component_entity_id = int(component_entity_id)
|
||||||
|
from_location_id = int(from_location_id)
|
||||||
|
to_location_id = int(to_location_id)
|
||||||
|
quantity = int(quantity)
|
||||||
|
user_id = int(user_id)
|
||||||
|
|
||||||
|
if quantity <= 0:
|
||||||
|
raise RuntimeError('Количество должно быть больше 0.')
|
||||||
|
|
||||||
|
if from_location_id == to_location_id:
|
||||||
|
raise RuntimeError('Склад-источник и склад назначения совпадают.')
|
||||||
|
|
||||||
|
# Комментарий: двигаем только "под сделку" и свободные остатки.
|
||||||
|
# Приоритет: под сделку -> свободные, затем FIFO по поступлению.
|
||||||
|
qs = (
|
||||||
|
StockItem.objects.select_for_update()
|
||||||
|
.filter(is_archived=False, quantity__gt=0)
|
||||||
|
.filter(location_id=from_location_id, entity_id=component_entity_id)
|
||||||
|
.filter(Q(deal_id=deal_id) | Q(deal_id__isnull=True))
|
||||||
|
.annotate(
|
||||||
|
prio=Case(
|
||||||
|
When(deal_id=deal_id, then=Value(0)),
|
||||||
|
default=Value(1),
|
||||||
|
output_field=IntegerField(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.order_by('prio', 'created_at', 'id')
|
||||||
|
)
|
||||||
|
|
||||||
|
remaining = float(quantity)
|
||||||
|
picked: list[tuple[int, float]] = []
|
||||||
|
for si in qs:
|
||||||
|
if remaining <= 0:
|
||||||
|
break
|
||||||
|
avail = float(si.quantity or 0)
|
||||||
|
if avail <= 0:
|
||||||
|
continue
|
||||||
|
take = min(remaining, avail)
|
||||||
|
if take <= 0:
|
||||||
|
continue
|
||||||
|
picked.append((int(si.id), float(take)))
|
||||||
|
remaining -= take
|
||||||
|
|
||||||
|
if remaining > 0:
|
||||||
|
# Комментарий: допускаем расхождения с фактом (по данным базы может быть меньше, чем нужно по месту).
|
||||||
|
# Для продолжения процесса создаем "виртуальный" остаток под сделку на складе-источнике и перемещаем его.
|
||||||
|
phantom = StockItem.objects.create(
|
||||||
|
entity_id=int(component_entity_id),
|
||||||
|
deal_id=int(deal_id),
|
||||||
|
location_id=int(from_location_id),
|
||||||
|
quantity=float(remaining),
|
||||||
|
)
|
||||||
|
picked.append((int(phantom.id), float(remaining)))
|
||||||
|
logger.warning(
|
||||||
|
'kitting_transfer: phantom_created deal_id=%s entity_id=%s from_location=%s qty=%s',
|
||||||
|
deal_id,
|
||||||
|
component_entity_id,
|
||||||
|
from_location_id,
|
||||||
|
float(remaining),
|
||||||
|
)
|
||||||
|
remaining = 0.0
|
||||||
|
|
||||||
|
tr = TransferRecord.objects.create(
|
||||||
|
from_location_id=from_location_id,
|
||||||
|
to_location_id=to_location_id,
|
||||||
|
sender_id=user_id,
|
||||||
|
receiver_id=user_id,
|
||||||
|
occurred_at=timezone.now(),
|
||||||
|
status='received',
|
||||||
|
received_at=timezone.now(),
|
||||||
|
is_applied=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
for sid, qty in picked:
|
||||||
|
TransferLine.objects.create(transfer=tr, stock_item_id=sid, quantity=float(qty))
|
||||||
|
|
||||||
|
receive_transfer(tr.id, user_id)
|
||||||
|
logger.info(
|
||||||
|
'kitting_transfer: ok tr_id=%s deal_id=%s component=%s from=%s to=%s qty=%s',
|
||||||
|
tr.id, deal_id, component_entity_id, from_location_id, to_location_id, quantity
|
||||||
|
)
|
||||||
|
return int(tr.id)
|
||||||
|
|
||||||
|
|
||||||
|
def apply_kitting_draft(
|
||||||
|
*,
|
||||||
|
session: Any,
|
||||||
|
workitem_id: int,
|
||||||
|
deal_id: int,
|
||||||
|
to_location_id: int,
|
||||||
|
user_id: int,
|
||||||
|
) -> dict[str, int]:
|
||||||
|
draft = get_kitting_draft(session, int(workitem_id))
|
||||||
|
if not draft:
|
||||||
|
return {'applied': 0, 'errors': 0}
|
||||||
|
|
||||||
|
applied = 0
|
||||||
|
errors = 0
|
||||||
|
|
||||||
|
for ln in draft:
|
||||||
|
try:
|
||||||
|
_apply_one_transfer(
|
||||||
|
deal_id=int(deal_id),
|
||||||
|
component_entity_id=int(ln.get('entity_id') or 0),
|
||||||
|
from_location_id=int(ln.get('from_location_id') or 0),
|
||||||
|
to_location_id=int(to_location_id),
|
||||||
|
quantity=int(ln.get('quantity') or 0),
|
||||||
|
user_id=int(user_id),
|
||||||
|
)
|
||||||
|
applied += 1
|
||||||
|
except Exception:
|
||||||
|
errors += 1
|
||||||
|
logger.exception('kitting_transfer:error deal_id=%s workitem_id=%s line=%s', deal_id, workitem_id, ln)
|
||||||
|
|
||||||
|
if errors == 0:
|
||||||
|
clear_kitting_draft(session, int(workitem_id))
|
||||||
|
|
||||||
|
return {'applied': applied, 'errors': errors}
|
||||||
141
shiftflow/templates/shiftflow/assembly_closing.html
Normal file
141
shiftflow/templates/shiftflow/assembly_closing.html
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-lg-8">
|
||||||
|
<div class="card shadow-sm border-secondary mb-4">
|
||||||
|
<div class="card-header border-secondary d-flex justify-content-between align-items-center py-3">
|
||||||
|
<h3 class="text-accent mb-0">
|
||||||
|
<i class="bi bi-check2-square me-2"></i>Закрытие сборки
|
||||||
|
</h3>
|
||||||
|
<a class="btn btn-outline-secondary btn-sm" href="{% url 'workitem_detail' workitem.id %}">Назад к заданию</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<div class="mb-4">
|
||||||
|
<h5 class="fw-bold">{{ workitem.entity.drawing_number|default:"—" }} {{ workitem.entity.name }}</h5>
|
||||||
|
<div class="text-muted small">Сделка № {{ workitem.deal.number }}</div>
|
||||||
|
<div class="text-muted small">План: {{ workitem.quantity_plan }} шт. · Собрано: {{ workitem.quantity_done }} шт.</div>
|
||||||
|
<div class="text-muted small">Осталось собрать: <strong>{{ remaining }}</strong> шт.</div>
|
||||||
|
{% if to_location %}
|
||||||
|
<div class="text-muted small mt-2">Участок сборки (склад): <strong>{{ to_location.name }}</strong></div>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-danger small mt-2 fw-bold">Участок сборки не определен! Закрытие невозможно.</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="text-muted small mt-2">
|
||||||
|
Пост для отчёта:
|
||||||
|
{% if workitem.machine_id %}
|
||||||
|
<strong>{{ workitem.machine.name }}</strong>
|
||||||
|
{% else %}
|
||||||
|
<strong class="text-warning">не выбран</strong>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if error %}
|
||||||
|
<div class="alert alert-warning border-warning">
|
||||||
|
{{ error }}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<h6 class="fw-bold border-bottom border-secondary pb-2 mb-3">Наличие компонентов на участке</h6>
|
||||||
|
<div class="table-responsive mb-4">
|
||||||
|
<table class="table table-sm table-hover align-middle">
|
||||||
|
<thead class="table-custom-header">
|
||||||
|
<tr>
|
||||||
|
<th>Компонент</th>
|
||||||
|
<th class="text-center">Нужно на 1 шт</th>
|
||||||
|
<th class="text-center">Есть на участке</th>
|
||||||
|
<th class="text-center">Хватит на сборок</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for c in components %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<div class="fw-bold">{{ c.entity.drawing_number|default:"—" }} {{ c.entity.name }}</div>
|
||||||
|
<div class="small text-muted">{{ c.entity.get_entity_type_display }}</div>
|
||||||
|
</td>
|
||||||
|
<td class="text-center">{{ c.req_per_1 }}</td>
|
||||||
|
<td class="text-center">{{ c.available|floatformat:2 }}</td>
|
||||||
|
<td class="text-center fw-bold {% if c.max_possible == 0 %}text-danger{% else %}text-success{% endif %}">
|
||||||
|
{{ c.max_possible }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="4" class="text-center text-muted">Спецификация пуста или не найдена.</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="alert alert-info border-info d-flex justify-content-between align-items-center">
|
||||||
|
<div>
|
||||||
|
<strong>Максимум можно закрыть сейчас:</strong> {{ max_possible }} шт.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="post" action="">
|
||||||
|
{% 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 %}>
|
||||||
|
</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>
|
||||||
|
{% 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>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="small text-muted mt-2">
|
||||||
|
При закрытии компоненты будут списаны со склада участка <strong>{{ to_location.name }}</strong>, а готовая сборка будет оприходована на этот же участок. Производственный отчёт привязывается к выбранному посту.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal fade" id="selectMachineModal" 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="Close"></button>
|
||||||
|
</div>
|
||||||
|
<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>
|
||||||
|
<option value="">— выбрать —</option>
|
||||||
|
{% for m in workshop_machines %}
|
||||||
|
<option value="{{ m.id }}">{{ m.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
{% else %}
|
||||||
|
<div class="alert alert-warning border-warning mb-0">
|
||||||
|
В этом цехе нет постов. Создай пост в «Справочники → Производство → Посты/станки» и привяжи его к этому цеху.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</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-warning" {% if not workshop_machines %}disabled{% endif %}>Закрыть</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -48,27 +48,27 @@
|
|||||||
<th>Дата</th>
|
<th>Дата</th>
|
||||||
<th>Сделка</th>
|
<th>Сделка</th>
|
||||||
<th>Деталь</th>
|
<th>Деталь</th>
|
||||||
<th>План</th>
|
<th>К закрытию</th>
|
||||||
<th data-sort="false">Факт</th>
|
<th data-sort="false">Факт</th>
|
||||||
<th data-sort="false">Режим</th>
|
<th data-sort="false">Режим</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for it in items %}
|
{% for wi in workitems %}
|
||||||
<tr>
|
<tr>
|
||||||
<td class="small">{{ it.date|date:"d.m.Y" }}</td>
|
<td class="small">{{ wi.date|date:"d.m.Y" }}</td>
|
||||||
<td><span class="text-accent fw-bold">{{ it.task.deal.number }}</span></td>
|
<td><span class="text-accent fw-bold">{{ wi.deal.number }}</span></td>
|
||||||
<td class="fw-bold">{{ it.task.drawing_name }}</td>
|
<td class="fw-bold">{{ wi.entity.drawing_number|default:"—" }} {{ wi.entity.name }}</td>
|
||||||
<td>{{ it.quantity_plan }}</td>
|
<td>{{ wi.remaining }}</td>
|
||||||
<td style="max-width:140px;">
|
<td style="max-width:140px;">
|
||||||
<input class="form-control form-control-sm border-secondary" type="number" min="0" max="{{ it.quantity_plan }}" name="fact_{{ it.id }}" id="fact_{{ it.id }}" value="{{ it.quantity_fact }}" {% if not can_edit %}disabled{% endif %}>
|
<input class="form-control form-control-sm border-secondary" type="number" min="0" max="{{ wi.remaining }}" name="fact_{{ wi.id }}" id="fact_{{ wi.id }}" value="0" {% if not can_edit %}disabled{% endif %}>
|
||||||
</td>
|
</td>
|
||||||
<td style="min-width:260px;">
|
<td style="min-width:260px;">
|
||||||
<div class="d-flex gap-2 align-items-center flex-wrap">
|
<div class="d-flex gap-2 align-items-center flex-wrap">
|
||||||
<button type="button" class="btn btn-sm btn-outline-success closing-set-action" data-item-id="{{ it.id }}" data-action="done" data-plan="{{ it.quantity_plan }}" {% if not can_edit %}disabled{% endif %}>Полностью</button>
|
<button type="button" class="btn btn-sm btn-outline-success closing-set-action" data-item-id="{{ wi.id }}" data-action="done" data-plan="{{ wi.remaining }}" {% if not can_edit %}disabled{% endif %}>Полностью</button>
|
||||||
<button type="button" class="btn btn-sm btn-outline-warning closing-set-action" data-item-id="{{ it.id }}" data-action="partial" data-plan="{{ it.quantity_plan }}" {% if not can_edit %}disabled{% endif %}>Частично</button>
|
<button type="button" class="btn btn-sm btn-outline-warning closing-set-action" data-item-id="{{ wi.id }}" data-action="partial" data-plan="{{ wi.remaining }}" {% if not can_edit %}disabled{% endif %}>Частично</button>
|
||||||
<input type="hidden" id="ca_{{ it.id }}" name="close_action_{{ it.id }}" value="">
|
<input type="hidden" id="ca_{{ wi.id }}" name="close_action_{{ wi.id }}" value="">
|
||||||
<span class="small text-muted" id="modeLabel_{{ it.id }}"></span>
|
<span class="small text-muted" id="modeLabel_{{ wi.id }}"></span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
78
shiftflow/templates/shiftflow/closing_workitems.html
Normal file
78
shiftflow/templates/shiftflow/closing_workitems.html
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="card shadow border-secondary mb-3">
|
||||||
|
<div class="card-header border-secondary py-3 d-flex flex-wrap gap-2 justify-content-between align-items-center">
|
||||||
|
<h3 class="text-accent mb-0">
|
||||||
|
<i class="bi bi-check2-square me-2"></i>Закрытие · Мои сменные задания
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<form class="d-flex flex-wrap gap-2 align-items-end" method="get">
|
||||||
|
<div>
|
||||||
|
<label class="form-label small text-muted mb-1">Поиск</label>
|
||||||
|
<input class="form-control form-control-sm bg-body text-body border-secondary" name="q" value="{{ q }}" placeholder="Сделка / КД / станок / цех">
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-outline-accent btn-sm" type="submit">
|
||||||
|
<i class="bi bi-search me-1"></i>Показать
|
||||||
|
</button>
|
||||||
|
<a class="btn btn-outline-accent btn-sm" href="{% url 'closing_workitems' %}">
|
||||||
|
<i class="bi bi-arrow-counterclockwise me-1"></i>Сброс
|
||||||
|
</a>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover mb-0 align-middle">
|
||||||
|
<thead>
|
||||||
|
<tr class="table-custom-header">
|
||||||
|
<th style="width:110px;">Сделка</th>
|
||||||
|
<th>КД</th>
|
||||||
|
<th class="text-center" style="width:180px;">Операция</th>
|
||||||
|
<th class="text-center" style="width:160px;">Цех/Пост</th>
|
||||||
|
<th class="text-center" style="width:90px;">План</th>
|
||||||
|
<th class="text-center" style="width:90px;">Факт</th>
|
||||||
|
<th class="text-center" style="width:110px;">Остаток</th>
|
||||||
|
<th class="text-center" style="width:120px;">Действие</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for wi in workitems %}
|
||||||
|
<tr>
|
||||||
|
<td class="fw-bold">
|
||||||
|
<a class="text-decoration-none" href="{% url 'planning_deal' wi.deal.id %}">{{ wi.deal.number }}</a>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="fw-bold">
|
||||||
|
<a class="text-decoration-none text-reset" href="{% url 'workitem_detail' wi.id %}">
|
||||||
|
{{ wi.entity.drawing_number|default:"—" }} {{ wi.entity.name }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="small text-muted">{{ wi.entity.get_entity_type_display }}</div>
|
||||||
|
</td>
|
||||||
|
<td class="text-center">
|
||||||
|
{% if wi.operation %}{{ wi.operation.name }}{% else %}{{ wi.stage|default:"—" }}{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="text-center">
|
||||||
|
{% if wi.machine %}{{ wi.machine.name }}{% elif wi.workshop %}{{ wi.workshop.name }}{% else %}—{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="text-center">{{ wi.quantity_plan }}</td>
|
||||||
|
<td class="text-center">{{ wi.quantity_done }}</td>
|
||||||
|
<td class="text-center fw-bold {% if wi.remaining > 0 %}text-warning{% else %}text-success{% endif %}">
|
||||||
|
{{ wi.remaining }}
|
||||||
|
</td>
|
||||||
|
<td class="text-center">
|
||||||
|
<a class="btn btn-outline-warning btn-sm" href="{{ wi.close_url }}">
|
||||||
|
Закрыть
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="8" class="text-center text-muted py-4">Нет активных сменных заданий.</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -86,6 +86,10 @@
|
|||||||
<option value="done">Завершена</option>
|
<option value="done">Завершена</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label small text-muted">Срок отгрузки</label>
|
||||||
|
<input type="date" class="form-control border-secondary" id="dealDueDate">
|
||||||
|
</div>
|
||||||
<div class="mb-0">
|
<div class="mb-0">
|
||||||
<label class="form-label small text-muted">Описание</label>
|
<label class="form-label small text-muted">Описание</label>
|
||||||
<textarea class="form-control border-secondary" rows="3" id="dealDescription"></textarea>
|
<textarea class="form-control border-secondary" rows="3" id="dealDescription"></textarea>
|
||||||
@@ -112,6 +116,7 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||||||
const dealNumber = document.getElementById('dealNumber');
|
const dealNumber = document.getElementById('dealNumber');
|
||||||
const dealStatus = document.getElementById('dealStatus');
|
const dealStatus = document.getElementById('dealStatus');
|
||||||
const dealDescription = document.getElementById('dealDescription');
|
const dealDescription = document.getElementById('dealDescription');
|
||||||
|
const dealDueDate = document.getElementById('dealDueDate');
|
||||||
const dealSaveBtn = document.getElementById('dealSaveBtn');
|
const dealSaveBtn = document.getElementById('dealSaveBtn');
|
||||||
|
|
||||||
function getCookie(name) {
|
function getCookie(name) {
|
||||||
@@ -139,6 +144,7 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||||||
dealModal.addEventListener('show.bs.modal', function () {
|
dealModal.addEventListener('show.bs.modal', function () {
|
||||||
if (dealNumber) dealNumber.value = '';
|
if (dealNumber) dealNumber.value = '';
|
||||||
if (dealDescription) dealDescription.value = '';
|
if (dealDescription) dealDescription.value = '';
|
||||||
|
if (dealDueDate) dealDueDate.value = '';
|
||||||
if (dealStatus) dealStatus.value = 'work';
|
if (dealStatus) dealStatus.value = 'work';
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -150,6 +156,7 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||||||
status: dealStatus ? dealStatus.value : 'work',
|
status: dealStatus ? dealStatus.value : 'work',
|
||||||
company_id: '{{ company.id }}',
|
company_id: '{{ company.id }}',
|
||||||
description: (dealDescription ? dealDescription.value : ''),
|
description: (dealDescription ? dealDescription.value : ''),
|
||||||
|
due_date: dealDueDate ? dealDueDate.value : '',
|
||||||
};
|
};
|
||||||
await postForm('{% url "deal_upsert" %}', payload);
|
await postForm('{% url "deal_upsert" %}', payload);
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
|
|||||||
36
shiftflow/templates/shiftflow/directories.html
Normal file
36
shiftflow/templates/shiftflow/directories.html
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="card shadow border-secondary">
|
||||||
|
<div class="card-header border-secondary py-3 d-flex justify-content-between align-items-center">
|
||||||
|
<h3 class="text-accent mb-0"><i class="bi bi-journals me-2"></i>Справочники</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex flex-wrap gap-2">
|
||||||
|
<a class="btn btn-outline-accent" href="{% url 'products' %}">
|
||||||
|
<i class="bi bi-diagram-3 me-2"></i>Номенклатура изделий
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a class="btn btn-outline-accent" href="{% url 'supply_catalog' %}">
|
||||||
|
<i class="bi bi-box-seam me-2"></i>Номенклатура снабжения (покупное/аутсорс)
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3">
|
||||||
|
<div class="btn-group" role="group" aria-label="Материалы">
|
||||||
|
<a class="btn btn-outline-accent" href="{% url 'materials_catalog' %}">Материалы</a>
|
||||||
|
<a class="btn btn-outline-accent" href="{% url 'material_categories_catalog' %}">Категории материалов</a>
|
||||||
|
<a class="btn btn-outline-accent" href="{% url 'steel_grades_catalog' %}">Марки стали</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3">
|
||||||
|
<div class="btn-group" role="group" aria-label="Производство">
|
||||||
|
<a class="btn btn-outline-accent" href="{% url 'locations_catalog' %}">Склады</a>
|
||||||
|
<a class="btn btn-outline-accent" href="{% url 'workshops_catalog' %}">Цеха</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
291
shiftflow/templates/shiftflow/legacy_closing.html
Normal file
291
shiftflow/templates/shiftflow/legacy_closing.html
Normal file
@@ -0,0 +1,291 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="card border-secondary mb-3 shadow-sm">
|
||||||
|
<div class="card-body py-2">
|
||||||
|
<form method="get" class="row g-2 align-items-center">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="small text-muted mb-1 fw-bold">Станок:</label>
|
||||||
|
<select class="form-select form-select-sm bg-body text-body border-secondary" name="machine_id" onchange="this.form.submit()">
|
||||||
|
<option value="">— выбрать —</option>
|
||||||
|
{% for m in machines %}
|
||||||
|
<option value="{{ m.id }}" {% if selected_machine_id == m.id|stringformat:"s" %}selected{% endif %}>{{ m.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="small text-muted mb-1 fw-bold">Материал:</label>
|
||||||
|
<select class="form-select form-select-sm bg-body text-body border-secondary" name="material_id" onchange="this.form.submit()">
|
||||||
|
<option value="">— выбрать —</option>
|
||||||
|
{% for mat in materials %}
|
||||||
|
<option value="{{ mat.id }}" {% if selected_material_id == mat.id|stringformat:"s" %}selected{% endif %}>{{ mat.full_name|default:mat.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-2 text-end mt-auto">
|
||||||
|
<a class="btn btn-outline-secondary btn-sm w-100" href="{% url 'legacy_closing' %}">Сброс</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="machine_id" value="{{ selected_machine_id }}">
|
||||||
|
<input type="hidden" name="material_id" value="{{ selected_material_id }}">
|
||||||
|
|
||||||
|
<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-0"><i class="bi bi-archive me-2"></i>Архив / Закрытие</h3>
|
||||||
|
<div class="small text-muted">Legacy: Item</div>
|
||||||
|
</div>
|
||||||
|
</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>План</th>
|
||||||
|
<th data-sort="false">Факт</th>
|
||||||
|
<th data-sort="false">Режим</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for it in items %}
|
||||||
|
<tr>
|
||||||
|
<td class="small">{{ it.date|date:"d.m.Y" }}</td>
|
||||||
|
<td><span class="text-accent fw-bold">{{ it.task.deal.number }}</span></td>
|
||||||
|
<td class="fw-bold">{{ it.task.drawing_name }}</td>
|
||||||
|
<td>{{ it.quantity_plan }}</td>
|
||||||
|
<td style="max-width:140px;">
|
||||||
|
<input class="form-control form-control-sm border-secondary" type="number" min="0" max="{{ it.quantity_plan }}" name="fact_{{ it.id }}" id="fact_{{ it.id }}" value="{{ it.quantity_fact }}" {% if not can_edit %}disabled{% endif %}>
|
||||||
|
</td>
|
||||||
|
<td style="min-width:260px;">
|
||||||
|
<div class="d-flex gap-2 align-items-center flex-wrap">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-success closing-set-action" data-item-id="{{ it.id }}" data-action="done" data-plan="{{ it.quantity_plan }}" {% if not can_edit %}disabled{% endif %}>Полностью</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-warning closing-set-action" data-item-id="{{ it.id }}" data-action="partial" data-plan="{{ it.quantity_plan }}" {% if not can_edit %}disabled{% endif %}>Частично</button>
|
||||||
|
<input type="hidden" id="ca_{{ it.id }}" name="close_action_{{ it.id }}" value="">
|
||||||
|
<span class="small text-muted" id="modeLabel_{{ it.id }}"></span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr><td colspan="6" class="text-center text-muted py-4">Выбери станок и материал</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card shadow border-secondary mb-3">
|
||||||
|
<div class="card-header border-secondary py-3">
|
||||||
|
<h5 class="mb-0">Списание со склада цеха (единицы)</h5>
|
||||||
|
</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>Размеры</th>
|
||||||
|
<th>Доступно</th>
|
||||||
|
<th data-sort="false">Использовано</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for s in stock_items %}
|
||||||
|
<tr>
|
||||||
|
<td class="small">{% if s.created_at %}{{ s.created_at|date:"d.m.Y H:i" }}{% endif %}</td>
|
||||||
|
<td>
|
||||||
|
{% if s.deal_id %}
|
||||||
|
<span class="text-accent fw-bold">{{ s.deal.number }}</span>
|
||||||
|
{% else %}
|
||||||
|
—
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>{{ s }}</td>
|
||||||
|
<td>
|
||||||
|
{% if s.current_length and s.current_width %}
|
||||||
|
{{ s.current_length|floatformat:"-g" }} × {{ s.current_width|floatformat:"-g" }} мм
|
||||||
|
{% elif s.current_length %}
|
||||||
|
{{ s.current_length|floatformat:"-g" }} мм
|
||||||
|
{% else %}
|
||||||
|
—
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>{{ s.quantity }}</td>
|
||||||
|
<td style="max-width:140px;">
|
||||||
|
<input class="form-control form-control-sm border-secondary" name="consume_{{ s.id }}" placeholder="0" {% if not can_edit %}disabled{% endif %}>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr><td colspan="5" class="text-center text-muted py-4">Нет единиц на складе для выбранного материала</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card shadow border-secondary">
|
||||||
|
<div class="card-header border-secondary py-3 d-flex justify-content-between align-items-center">
|
||||||
|
<h5 class="mb-0">Остаток ДО</h5>
|
||||||
|
<button type="button" class="btn btn-outline-accent btn-sm" id="addRemnantBtn" {% if not can_edit %}disabled{% endif %}>Добавить ДО</button>
|
||||||
|
</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"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="remnantBody">
|
||||||
|
<tr id="remnantEmptyRow">
|
||||||
|
<td colspan="4" class="text-center text-muted py-4">ДО не добавлены</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-end mt-3">
|
||||||
|
<button type="submit" class="btn btn-outline-accent" {% if not can_edit %}disabled{% endif %}>Сохранить</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const canEdit = {% if can_edit %}true{% else %}false{% endif %};
|
||||||
|
|
||||||
|
document.querySelectorAll('.closing-set-action').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
if (!canEdit) return;
|
||||||
|
|
||||||
|
const itemId = btn.getAttribute('data-item-id');
|
||||||
|
const action = btn.getAttribute('data-action');
|
||||||
|
const plan = parseInt(btn.getAttribute('data-plan') || '0', 10) || 0;
|
||||||
|
|
||||||
|
const hidden = document.getElementById('ca_' + itemId);
|
||||||
|
const fact = document.getElementById('fact_' + itemId);
|
||||||
|
const label = document.getElementById('modeLabel_' + itemId);
|
||||||
|
|
||||||
|
if (hidden) hidden.value = action;
|
||||||
|
|
||||||
|
const cell = btn.closest('td');
|
||||||
|
if (cell) {
|
||||||
|
cell.querySelectorAll('.closing-set-action').forEach(b => {
|
||||||
|
const a = b.getAttribute('data-action');
|
||||||
|
if (a === 'done') {
|
||||||
|
b.classList.remove('btn-success');
|
||||||
|
b.classList.add('btn-outline-success');
|
||||||
|
}
|
||||||
|
if (a === 'partial') {
|
||||||
|
b.classList.remove('btn-warning');
|
||||||
|
b.classList.add('btn-outline-warning');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'done') {
|
||||||
|
btn.classList.remove('btn-outline-success');
|
||||||
|
btn.classList.add('btn-success');
|
||||||
|
if (fact) {
|
||||||
|
fact.value = String(plan);
|
||||||
|
fact.readOnly = true;
|
||||||
|
}
|
||||||
|
if (label) label.textContent = 'Выбрано: полностью';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'partial') {
|
||||||
|
btn.classList.remove('btn-outline-warning');
|
||||||
|
btn.classList.add('btn-warning');
|
||||||
|
if (fact) {
|
||||||
|
fact.readOnly = false;
|
||||||
|
fact.focus();
|
||||||
|
fact.select();
|
||||||
|
}
|
||||||
|
if (label) label.textContent = 'Выбрано: частично';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const addBtn = document.getElementById('addRemnantBtn');
|
||||||
|
const body = document.getElementById('remnantBody');
|
||||||
|
const emptyRow = document.getElementById('remnantEmptyRow');
|
||||||
|
|
||||||
|
function renumberRemnants() {
|
||||||
|
const rows = Array.from(body.querySelectorAll('tr[data-remnant-row="1"]'));
|
||||||
|
rows.forEach((tr, idx) => {
|
||||||
|
const qty = tr.querySelector('input[data-field="qty"]');
|
||||||
|
const len = tr.querySelector('input[data-field="len"]');
|
||||||
|
const wid = tr.querySelector('input[data-field="wid"]');
|
||||||
|
if (qty) qty.name = 'remnant_qty_' + idx;
|
||||||
|
if (len) len.name = 'remnant_len_' + idx;
|
||||||
|
if (wid) wid.name = 'remnant_wid_' + idx;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (emptyRow) {
|
||||||
|
emptyRow.style.display = rows.length ? 'none' : '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addRemnantRow() {
|
||||||
|
if (!canEdit) return;
|
||||||
|
|
||||||
|
const rows = Array.from(body.querySelectorAll('tr[data-remnant-row="1"]'));
|
||||||
|
if (rows.length >= 50) return;
|
||||||
|
|
||||||
|
const tr = document.createElement('tr');
|
||||||
|
tr.setAttribute('data-remnant-row', '1');
|
||||||
|
|
||||||
|
tr.innerHTML = `
|
||||||
|
<td style="max-width:180px;">
|
||||||
|
<input class="form-control form-control-sm border-secondary" data-field="qty" inputmode="decimal" placeholder="Кол-во" required>
|
||||||
|
</td>
|
||||||
|
<td style="max-width:180px;">
|
||||||
|
<input class="form-control form-control-sm border-secondary" data-field="len" inputmode="decimal" placeholder="Длина (мм)">
|
||||||
|
</td>
|
||||||
|
<td style="max-width:180px;">
|
||||||
|
<input class="form-control form-control-sm border-secondary" data-field="wid" inputmode="decimal" placeholder="Ширина (мм)">
|
||||||
|
</td>
|
||||||
|
<td class="text-end">
|
||||||
|
<button type="button" class="btn btn-outline-secondary btn-sm" data-action="remove">Удалить</button>
|
||||||
|
</td>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const rm = tr.querySelector('button[data-action="remove"]');
|
||||||
|
if (rm) {
|
||||||
|
rm.addEventListener('click', () => {
|
||||||
|
tr.remove();
|
||||||
|
renumberRemnants();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
body.appendChild(tr);
|
||||||
|
renumberRemnants();
|
||||||
|
|
||||||
|
const first = tr.querySelector('input[data-field="qty"]');
|
||||||
|
if (first) {
|
||||||
|
first.focus();
|
||||||
|
first.select();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (addBtn) {
|
||||||
|
addBtn.addEventListener('click', addRemnantRow);
|
||||||
|
}
|
||||||
|
|
||||||
|
renumberRemnants();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
21
shiftflow/templates/shiftflow/legacy_registry.html
Normal file
21
shiftflow/templates/shiftflow/legacy_registry.html
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% include 'shiftflow/partials/_filter.html' %}
|
||||||
|
|
||||||
|
<div class="card shadow border-secondary">
|
||||||
|
<div class="card-header border-secondary py-3 d-flex justify-content-between align-items-center">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-accent mb-0"><i class="bi bi-archive me-2"></i>Архив / Реестр</h3>
|
||||||
|
<div class="small text-muted">Legacy: Item</div>
|
||||||
|
</div>
|
||||||
|
{% if user_role in 'admin,technologist,master' %}
|
||||||
|
<a class="btn btn-outline-secondary btn-sm" target="_blank" href="{% url 'registry_print' %}?{{ request.GET.urlencode }}">
|
||||||
|
<i class="bi bi-printer me-1"></i>Печать
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% include 'shiftflow/partials/_items_table.html' with items=items %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
173
shiftflow/templates/shiftflow/legacy_writeoffs.html
Normal file
173
shiftflow/templates/shiftflow/legacy_writeoffs.html
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="card border-secondary mb-3 shadow-sm">
|
||||||
|
<div class="card-body py-2">
|
||||||
|
<form method="get" class="row g-2 align-items-end">
|
||||||
|
<div class="col-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 }}">
|
||||||
|
</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 }}">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-auto">
|
||||||
|
<button type="submit" class="btn btn-outline-secondary btn-sm">
|
||||||
|
<i class="bi bi-funnel me-1"></i>Показать
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-auto">
|
||||||
|
<a href="{% url 'legacy_writeoffs' %}?reset=1" class="btn btn-outline-secondary btn-sm">
|
||||||
|
<i class="bi bi-arrow-counterclockwise me-1"></i>Сброс
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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-0"><i class="bi bi-archive me-2"></i>Архив / Списание / Производство</h3>
|
||||||
|
<div class="small text-muted">По производственным отчетам</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-body">
|
||||||
|
{% for card in report_cards %}
|
||||||
|
<div class="border border-secondary rounded p-3 mb-3">
|
||||||
|
<div class="d-flex flex-wrap justify-content-between gap-2">
|
||||||
|
<div class="fw-bold">
|
||||||
|
{{ card.report.date|date:"d.m.Y" }} — {{ card.report.machine }} — {{ card.report.operator }}
|
||||||
|
<span class="text-muted small ms-2">#{{ card.report.id }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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 %}
|
||||||
|
<ul class="mb-0">
|
||||||
|
{% for c in card.report.consumptions.all %}
|
||||||
|
{% 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 %})
|
||||||
|
{{ c.quantity|floatformat:"-g" }} шт
|
||||||
|
</li>
|
||||||
|
{% elif c.material_id %}
|
||||||
|
<li>{{ c.material }} {{ c.quantity|floatformat:"-g" }} шт</li>
|
||||||
|
{% else %}
|
||||||
|
<li>— {{ c.quantity|floatformat:"-g" }} шт</li>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-muted small">—</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-lg-4">
|
||||||
|
<div class="small text-muted fw-bold mb-1">Произведено</div>
|
||||||
|
{% if card.produced %}
|
||||||
|
<ul class="mb-0">
|
||||||
|
{% for k,v in card.produced.items %}
|
||||||
|
<li>{{ k }}: {{ v }} шт</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-muted small">—</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-lg-4">
|
||||||
|
<div class="small text-muted fw-bold mb-1">Остаток ДО</div>
|
||||||
|
{% if card.report.remnants.all %}
|
||||||
|
<ul class="mb-0">
|
||||||
|
{% for r in card.report.remnants.all %}
|
||||||
|
<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" }} шт
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-muted small">—</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% empty %}
|
||||||
|
<div class="text-muted">За выбранный период отчётов нет.</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card shadow border-secondary">
|
||||||
|
<div class="card-header border-secondary py-3 d-flex justify-content-between align-items-center">
|
||||||
|
<h3 class="text-accent mb-0"><i class="bi bi-check2-square me-2"></i>Сменные задания (1С)</h3>
|
||||||
|
<div class="small text-muted">Отметка «Списано в 1С»</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="post" class="mb-0">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="start_date" value="{{ start_date }}">
|
||||||
|
<input type="hidden" name="end_date" value="{{ end_date }}">
|
||||||
|
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover mb-0 align-middle">
|
||||||
|
<thead>
|
||||||
|
<tr class="table-custom-header">
|
||||||
|
<th data-sort="false"></th>
|
||||||
|
<th>Дата</th>
|
||||||
|
<th>Сделка</th>
|
||||||
|
<th>Станок</th>
|
||||||
|
<th>Позиция</th>
|
||||||
|
<th>План / Факт</th>
|
||||||
|
<th data-sort="false" class="text-center">1С</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for it in items %}
|
||||||
|
<tr>
|
||||||
|
<td style="width:40px;">
|
||||||
|
{% if can_edit %}
|
||||||
|
<input type="checkbox" class="form-check-input" name="item_ids" value="{{ it.id }}">
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="small">{{ it.date|date:"d.m.Y" }}</td>
|
||||||
|
<td><span class="text-accent fw-bold">{{ it.task.deal.number|default:"-" }}</span></td>
|
||||||
|
<td><span class="badge bg-dark border border-secondary">{{ it.machine.name }}</span></td>
|
||||||
|
<td class="fw-bold">{{ it.task.drawing_name|default:"—" }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="text-info fw-bold">{{ it.quantity_plan }}</span> /
|
||||||
|
<span class="text-success">{{ it.quantity_fact }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="text-center">
|
||||||
|
{% if it.is_synced_1c %}
|
||||||
|
<i class="bi bi-check-circle-fill text-success" title="Учтено"></i>
|
||||||
|
{% else %}
|
||||||
|
<i class="bi bi-clock-history text-muted" title="Ожидает"></i>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr><td colspan="7" class="text-center text-muted py-4">Нет сменных заданий за период</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if can_edit %}
|
||||||
|
<div class="card-body border-top border-secondary d-flex justify-content-end">
|
||||||
|
<button type="submit" class="btn btn-outline-accent">
|
||||||
|
<i class="bi bi-save me-2"></i>Сохранить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
72
shiftflow/templates/shiftflow/locations_catalog.html
Normal file
72
shiftflow/templates/shiftflow/locations_catalog.html
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="card shadow border-secondary mb-3">
|
||||||
|
<div class="card-header border-secondary py-3 d-flex flex-wrap gap-2 justify-content-between align-items-center">
|
||||||
|
<h3 class="text-accent mb-0"><i class="bi bi-boxes me-2"></i>Справочник · Склады</h3>
|
||||||
|
<a class="btn btn-outline-secondary btn-sm" href="{% url 'directories' %}">Назад</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if can_edit %}
|
||||||
|
<div class="card-body border-bottom border-secondary">
|
||||||
|
<form method="post" class="row g-2 align-items-end">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="action" value="create">
|
||||||
|
|
||||||
|
<div class="col-md-7">
|
||||||
|
<label class="form-label small text-muted mb-1">Название склада</label>
|
||||||
|
<input class="form-control border-secondary" name="name" placeholder="Напр: Центральный склад" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-2">
|
||||||
|
<button class="btn btn-outline-accent w-100" type="submit">Создать</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover mb-0 align-middle" data-sortable="1">
|
||||||
|
<thead>
|
||||||
|
<tr class="table-custom-header">
|
||||||
|
<th class="text-center" style="width:80px;" data-sort-type="number">№</th>
|
||||||
|
<th>Склад</th>
|
||||||
|
<th class="text-center" style="width:140px;">Действия</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for l in locations %}
|
||||||
|
<tr>
|
||||||
|
<td class="text-center">{{ forloop.counter }}</td>
|
||||||
|
|
||||||
|
<td class="fw-bold">
|
||||||
|
{% if can_edit %}
|
||||||
|
<form method="post" class="row g-2 align-items-center">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="action" value="update">
|
||||||
|
<input type="hidden" name="location_id" value="{{ l.id }}">
|
||||||
|
<div class="col-12">
|
||||||
|
<input class="form-control form-control-sm border-secondary" name="name" value="{{ l.name }}">
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
{{ l.name }}
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td class="text-center">
|
||||||
|
{% if can_edit %}
|
||||||
|
<button class="btn btn-outline-accent btn-sm" type="submit">Сохранить</button>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
—
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr><td colspan="3" class="text-center text-muted py-4">Складов нет.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
158
shiftflow/templates/shiftflow/machines_catalog.html
Normal file
158
shiftflow/templates/shiftflow/machines_catalog.html
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
{% 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-0"><i class="bi bi-building me-2"></i>{{ workshop.name }}</h3>
|
||||||
|
<div class="small text-muted">Цех · ID {{ workshop.id }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<a class="btn btn-outline-secondary btn-sm" href="{% url 'workshops_catalog' %}">Назад</a>
|
||||||
|
{% if can_edit %}
|
||||||
|
<button class="btn btn-outline-accent btn-sm" type="button" data-bs-toggle="modal" data-bs-target="#addMachineModal">
|
||||||
|
<i class="bi bi-plus-circle me-1"></i>Добавить пост
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-body border-bottom border-secondary">
|
||||||
|
{% if can_edit %}
|
||||||
|
<form method="post" class="row g-2 align-items-end">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="action" value="update_workshop">
|
||||||
|
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label small text-muted mb-1">Наименование цеха</label>
|
||||||
|
<input class="form-control border-secondary" name="name" value="{{ workshop.name }}">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label small text-muted mb-1">Склад цеха</label>
|
||||||
|
<select class="form-select border-secondary" name="location_id">
|
||||||
|
<option value="">— не задан —</option>
|
||||||
|
{% for l in locations %}
|
||||||
|
<option value="{{ l.id }}" {% if workshop.location_id == l.id %}selected{% endif %}>{{ l.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-2">
|
||||||
|
<button class="btn btn-outline-accent w-100" type="submit">Сохранить</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<div class="row g-2">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="small text-muted">Цех</div>
|
||||||
|
<div class="fw-bold">{{ workshop.name }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="small text-muted">Склад цеха</div>
|
||||||
|
<div class="fw-bold">{{ workshop.location.name|default:"—" }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover mb-0 align-middle" data-sortable="1">
|
||||||
|
<thead>
|
||||||
|
<tr class="table-custom-header">
|
||||||
|
<th class="text-center" style="width:80px;" data-sort-type="number">№</th>
|
||||||
|
<th class="text-center" style="width:90px;" data-sort-type="number">ID</th>
|
||||||
|
<th>Пост/станок</th>
|
||||||
|
<th class="text-center" style="width:200px;">Тип</th>
|
||||||
|
<th class="text-center" style="width:220px;">Действия</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for m in machines %}
|
||||||
|
<tr>
|
||||||
|
<td class="text-center">{{ forloop.counter }}</td>
|
||||||
|
<td class="text-center text-muted">{{ m.id }}</td>
|
||||||
|
|
||||||
|
<td class="fw-bold">
|
||||||
|
{% if can_edit %}
|
||||||
|
<form method="post" class="row g-2 align-items-center">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="action" value="update_machine">
|
||||||
|
<input type="hidden" name="machine_id" value="{{ m.id }}">
|
||||||
|
<div class="col-12">
|
||||||
|
<input class="form-control form-control-sm border-secondary" name="name" value="{{ m.name }}">
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
{{ m.name }}
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td class="text-center">
|
||||||
|
{% if can_edit %}
|
||||||
|
<select class="form-select form-select-sm border-secondary" name="machine_type">
|
||||||
|
{% for k, v in machine_types %}
|
||||||
|
<option value="{{ k }}" {% if m.machine_type == k %}selected{% endif %}>{{ v }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
{% else %}
|
||||||
|
{{ m.get_machine_type_display }}
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td class="text-center">
|
||||||
|
{% if can_edit %}
|
||||||
|
<div class="d-flex justify-content-center gap-2">
|
||||||
|
<button class="btn btn-outline-accent btn-sm" type="submit">Сохранить</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" class="m-0">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="action" value="delete_machine">
|
||||||
|
<input type="hidden" name="machine_id" value="{{ m.id }}">
|
||||||
|
<button class="btn btn-outline-secondary btn-sm" type="submit">Удалить</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
—
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr><td colspan="5" class="text-center text-muted py-4">Постов нет.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if can_edit %}
|
||||||
|
<div class="modal fade" id="addMachineModal" tabindex="-1" aria-hidden="true">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content border-secondary">
|
||||||
|
<form method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="action" value="create_machine">
|
||||||
|
<div class="modal-header border-secondary">
|
||||||
|
<h5 class="modal-title">Добавить пост</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<label class="form-label small text-muted mb-1">Название</label>
|
||||||
|
<input class="form-control border-secondary mb-3" name="name" placeholder="Напр: Сварка-1" required>
|
||||||
|
|
||||||
|
<label class="form-label small text-muted mb-1">Тип</label>
|
||||||
|
<select class="form-select border-secondary" name="machine_type">
|
||||||
|
{% for k, v in machine_types %}
|
||||||
|
<option value="{{ k }}">{{ v }}</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">Добавить</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
149
shiftflow/templates/shiftflow/material_categories_catalog.html
Normal file
149
shiftflow/templates/shiftflow/material_categories_catalog.html
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="card shadow border-secondary mb-3">
|
||||||
|
<div class="card-header border-secondary py-3 d-flex flex-wrap justify-content-between align-items-center gap-2">
|
||||||
|
<div>
|
||||||
|
<nav aria-label="breadcrumb" class="small">
|
||||||
|
<ol class="breadcrumb mb-1">
|
||||||
|
<li class="breadcrumb-item"><a class="text-decoration-none" href="{% url 'directories' %}">Справочники</a></li>
|
||||||
|
<li class="breadcrumb-item active" aria-current="page">Категории материалов</li>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
<h3 class="text-accent mb-0"><i class="bi bi-tags me-2"></i>Категории материалов</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex flex-wrap gap-2 align-items-center">
|
||||||
|
<form method="get" class="d-flex gap-2 align-items-center">
|
||||||
|
<input class="form-control form-control-sm bg-body text-body border-secondary" name="q" value="{{ q }}" placeholder="Поиск...">
|
||||||
|
<button class="btn btn-outline-accent btn-sm" type="submit"><i class="bi bi-search me-1"></i>Найти</button>
|
||||||
|
<a class="btn btn-outline-accent btn-sm" href="{% url 'material_categories_catalog' %}"><i class="bi bi-arrow-counterclockwise me-1"></i>Сброс</a>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<a class="btn btn-outline-accent btn-sm" href="{% url 'directories' %}"><i class="bi bi-arrow-left me-1"></i>Назад</a>
|
||||||
|
|
||||||
|
{% if can_edit %}
|
||||||
|
<button class="btn btn-outline-accent btn-sm" type="button" data-bs-toggle="modal" data-bs-target="#catModal" onclick="openCatCreate()">
|
||||||
|
<i class="bi bi-plus-lg me-1"></i>Создать
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for c in categories %}
|
||||||
|
<tr role="button" {% if can_edit %}onclick="openCatEdit(this)"{% endif %}
|
||||||
|
data-id="{{ c.id }}" data-name="{{ c.name }}" data-gost="{{ c.gost_standard }}" data-form="{{ c.form_factor }}">
|
||||||
|
<td class="fw-bold">{{ c.name }}</td>
|
||||||
|
<td>{{ c.gost_standard|default:"—" }}</td>
|
||||||
|
<td class="small text-muted">{{ c.get_form_factor_display }}</td>
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr><td colspan="3" class="text-center text-muted py-4">Нет данных</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div class="modal fade" id="catModal" tabindex="-1" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-lg">
|
||||||
|
<form class="modal-content border-secondary" onsubmit="event.preventDefault(); saveCat();">
|
||||||
|
<div class="modal-header border-secondary">
|
||||||
|
<h5 class="modal-title" id="catModalTitle">Категория</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-body">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" id="catId">
|
||||||
|
|
||||||
|
<div class="row g-2">
|
||||||
|
<div class="col-md-5">
|
||||||
|
<label class="form-label">Название</label>
|
||||||
|
<input class="form-control bg-body text-body border-secondary" id="catName" required {% if not can_edit %}disabled{% endif %}>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label">ГОСТ</label>
|
||||||
|
<input class="form-control bg-body text-body border-secondary" id="catGost" {% if not can_edit %}disabled{% endif %}>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">Форма</label>
|
||||||
|
<select class="form-select bg-body text-body border-secondary" id="catForm" {% if not can_edit %}disabled{% endif %}>
|
||||||
|
<option value="sheet">Лист</option>
|
||||||
|
<option value="bar">Прокат/хлыст</option>
|
||||||
|
<option value="other">Прочее</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-footer border-secondary">
|
||||||
|
<button type="button" class="btn btn-outline-accent" data-bs-dismiss="modal">Отмена</button>
|
||||||
|
{% if can_edit %}
|
||||||
|
<button type="submit" class="btn btn-outline-accent">Сохранить</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function getCookie(name) {
|
||||||
|
const value = `; ${document.cookie}`;
|
||||||
|
const parts = value.split(`; ${name}=`);
|
||||||
|
if (parts.length === 2) return parts.pop().split(';').shift();
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCatCreate() {
|
||||||
|
document.getElementById('catModalTitle').textContent = 'Категория (создание)';
|
||||||
|
document.getElementById('catId').value = '';
|
||||||
|
document.getElementById('catName').value = '';
|
||||||
|
document.getElementById('catGost').value = '';
|
||||||
|
document.getElementById('catForm').value = 'other';
|
||||||
|
new bootstrap.Modal(document.getElementById('catModal')).show();
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCatEdit(tr) {
|
||||||
|
document.getElementById('catModalTitle').textContent = 'Категория (правка)';
|
||||||
|
document.getElementById('catId').value = tr.getAttribute('data-id') || '';
|
||||||
|
document.getElementById('catName').value = tr.getAttribute('data-name') || '';
|
||||||
|
document.getElementById('catGost').value = tr.getAttribute('data-gost') || '';
|
||||||
|
document.getElementById('catForm').value = tr.getAttribute('data-form') || 'other';
|
||||||
|
new bootstrap.Modal(document.getElementById('catModal')).show();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveCat() {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('id', document.getElementById('catId').value);
|
||||||
|
fd.append('name', document.getElementById('catName').value);
|
||||||
|
fd.append('gost_standard', document.getElementById('catGost').value);
|
||||||
|
fd.append('form_factor', document.getElementById('catForm').value);
|
||||||
|
|
||||||
|
const res = await fetch("{% url 'material_category_upsert' %}", {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: { 'X-CSRFToken': getCookie('csrftoken') },
|
||||||
|
body: fd,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
alert('Не удалось сохранить категорию');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
179
shiftflow/templates/shiftflow/materials_catalog.html
Normal file
179
shiftflow/templates/shiftflow/materials_catalog.html
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="card shadow border-secondary mb-3">
|
||||||
|
<div class="card-header border-secondary py-3 d-flex flex-wrap justify-content-between align-items-center gap-2">
|
||||||
|
<div>
|
||||||
|
<nav aria-label="breadcrumb" class="small">
|
||||||
|
<ol class="breadcrumb mb-1">
|
||||||
|
<li class="breadcrumb-item"><a class="text-decoration-none" href="{% url 'directories' %}">Справочники</a></li>
|
||||||
|
<li class="breadcrumb-item active" aria-current="page">Материалы</li>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
<h3 class="text-accent mb-0"><i class="bi bi-box-seam me-2"></i>Материалы</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex flex-wrap gap-2 align-items-center">
|
||||||
|
<form method="get" class="d-flex gap-2 align-items-center">
|
||||||
|
<input class="form-control form-control-sm bg-body text-body border-secondary" name="q" value="{{ q }}" placeholder="Поиск...">
|
||||||
|
<button class="btn btn-outline-accent btn-sm" type="submit"><i class="bi bi-search me-1"></i>Найти</button>
|
||||||
|
<a class="btn btn-outline-accent btn-sm" href="{% url 'materials_catalog' %}"><i class="bi bi-arrow-counterclockwise me-1"></i>Сброс</a>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<a class="btn btn-outline-accent btn-sm" href="{% url 'directories' %}"><i class="bi bi-arrow-left me-1"></i>Назад</a>
|
||||||
|
|
||||||
|
{% if can_edit %}
|
||||||
|
<button class="btn btn-outline-accent btn-sm" type="button" data-bs-toggle="modal" data-bs-target="#materialModal" onclick="openMaterialCreate()">
|
||||||
|
<i class="bi bi-plus-lg me-1"></i>Создать
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</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>Полное имя</th>
|
||||||
|
<th>Масса</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for r in rows %}
|
||||||
|
<tr role="button" {% if can_edit %}onclick="openMaterialEdit({{ r.m.id }})"{% endif %}>
|
||||||
|
<td>{{ r.m.category.name }}</td>
|
||||||
|
<td>{{ r.m.steel_grade.name|default:"—" }}</td>
|
||||||
|
<td class="fw-bold">{{ r.m.name }}</td>
|
||||||
|
<td class="small text-muted">{{ r.m.full_name }}</td>
|
||||||
|
<td>
|
||||||
|
{% if r.m.mass_per_unit %}
|
||||||
|
{{ r.m.mass_per_unit|floatformat:"-g" }} {{ r.unit }}
|
||||||
|
{% else %}
|
||||||
|
—
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr><td colspan="5" class="text-center text-muted py-4">Нет данных</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div class="modal fade" id="materialModal" tabindex="-1" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-lg">
|
||||||
|
<form class="modal-content border-secondary" onsubmit="event.preventDefault(); saveMaterial();">
|
||||||
|
<div class="modal-header border-secondary">
|
||||||
|
<h5 class="modal-title" id="materialModalTitle">Материал</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-body">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" id="materialId">
|
||||||
|
|
||||||
|
<div class="row g-2">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Категория</label>
|
||||||
|
<select class="form-select bg-body text-body border-secondary" id="materialCategory" required {% if not can_edit %}disabled{% endif %}>
|
||||||
|
<option value="">— выбрать —</option>
|
||||||
|
{% for c in categories %}
|
||||||
|
<option value="{{ c.id }}">{{ c.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Марка стали</label>
|
||||||
|
<select class="form-select bg-body text-body border-secondary" id="materialGrade" {% if not can_edit %}disabled{% endif %}>
|
||||||
|
<option value="">— не выбрана —</option>
|
||||||
|
{% for g in grades %}
|
||||||
|
<option value="{{ g.id }}">{{ g.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-8">
|
||||||
|
<label class="form-label">Наименование</label>
|
||||||
|
<input class="form-control bg-body text-body border-secondary" id="materialName" required {% if not can_edit %}disabled{% endif %}>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label">Масса на ед. учёта</label>
|
||||||
|
<input class="form-control bg-body text-body border-secondary" id="materialMassPerUnit" inputmode="decimal" placeholder="Напр. 78.5" {% if not can_edit %}disabled{% endif %}>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-footer border-secondary">
|
||||||
|
<button type="button" class="btn btn-outline-accent" data-bs-dismiss="modal">Отмена</button>
|
||||||
|
{% if can_edit %}
|
||||||
|
<button type="submit" class="btn btn-outline-accent">Сохранить</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function getCookie(name) {
|
||||||
|
const value = `; ${document.cookie}`;
|
||||||
|
const parts = value.split(`; ${name}=`);
|
||||||
|
if (parts.length === 2) return parts.pop().split(';').shift();
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function openMaterialCreate() {
|
||||||
|
document.getElementById('materialModalTitle').textContent = 'Материал (создание)';
|
||||||
|
document.getElementById('materialId').value = '';
|
||||||
|
document.getElementById('materialCategory').value = '';
|
||||||
|
document.getElementById('materialGrade').value = '';
|
||||||
|
document.getElementById('materialName').value = '';
|
||||||
|
document.getElementById('materialMassPerUnit').value = '';
|
||||||
|
new bootstrap.Modal(document.getElementById('materialModal')).show();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openMaterialEdit(id) {
|
||||||
|
const url = "{% url 'material_json' 1 %}".replace('/1/json/', `/${id}/json/`);
|
||||||
|
const res = await fetch(url, { credentials: 'same-origin' });
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
document.getElementById('materialModalTitle').textContent = 'Материал (правка)';
|
||||||
|
document.getElementById('materialId').value = data.id;
|
||||||
|
document.getElementById('materialCategory').value = data.category_id || '';
|
||||||
|
document.getElementById('materialGrade').value = data.steel_grade_id || '';
|
||||||
|
document.getElementById('materialName').value = data.name || '';
|
||||||
|
document.getElementById('materialMassPerUnit').value = (data.mass_per_unit ?? '');
|
||||||
|
|
||||||
|
new bootstrap.Modal(document.getElementById('materialModal')).show();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveMaterial() {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('id', document.getElementById('materialId').value);
|
||||||
|
fd.append('category_id', document.getElementById('materialCategory').value);
|
||||||
|
fd.append('steel_grade_id', document.getElementById('materialGrade').value);
|
||||||
|
fd.append('name', document.getElementById('materialName').value);
|
||||||
|
fd.append('mass_per_unit', document.getElementById('materialMassPerUnit').value);
|
||||||
|
|
||||||
|
const res = await fetch("{% url 'material_upsert' %}", {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: { 'X-CSRFToken': getCookie('csrftoken') },
|
||||||
|
body: fd,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
alert('Не удалось сохранить материал');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -25,6 +25,9 @@
|
|||||||
<input type="checkbox" class="btn-check" name="statuses" id="s_work" value="work" {% if 'work' in selected_statuses %}checked{% endif %} onchange="this.form.submit()">
|
<input type="checkbox" class="btn-check" name="statuses" id="s_work" value="work" {% if 'work' in selected_statuses %}checked{% endif %} onchange="this.form.submit()">
|
||||||
<label class="btn btn-outline-primary btn-sm" for="s_work">В работе</label>
|
<label class="btn btn-outline-primary btn-sm" for="s_work">В работе</label>
|
||||||
|
|
||||||
|
<input type="checkbox" class="btn-check" name="statuses" id="s_leftover" value="leftover" {% if 'leftover' in selected_statuses %}checked{% endif %} onchange="this.form.submit()">
|
||||||
|
<label class="btn btn-outline-secondary btn-sm" for="s_leftover">Недодел</label>
|
||||||
|
|
||||||
<input type="checkbox" class="btn-check" name="statuses" id="s_closed" value="closed" {% if 'closed' in selected_statuses %}checked{% endif %} onchange="this.form.submit()">
|
<input type="checkbox" class="btn-check" name="statuses" id="s_closed" value="closed" {% if 'closed' in selected_statuses %}checked{% endif %} onchange="this.form.submit()">
|
||||||
<label class="btn btn-outline-success btn-sm" for="s_closed">Завершено</label>
|
<label class="btn btn-outline-success btn-sm" for="s_closed">Завершено</label>
|
||||||
{% else %}
|
{% else %}
|
||||||
@@ -32,29 +35,14 @@
|
|||||||
<label class="btn btn-outline-primary btn-sm" for="s_work">В работе</label>
|
<label class="btn btn-outline-primary btn-sm" for="s_work">В работе</label>
|
||||||
|
|
||||||
<input type="checkbox" class="btn-check" name="statuses" id="s_leftover" value="leftover" {% if 'leftover' in selected_statuses %}checked{% endif %} onchange="this.form.submit()">
|
<input type="checkbox" class="btn-check" name="statuses" id="s_leftover" value="leftover" {% if 'leftover' in selected_statuses %}checked{% endif %} onchange="this.form.submit()">
|
||||||
<label class="btn btn-outline-danger btn-sm" for="s_leftover">Недодел</label>
|
<label class="btn btn-outline-secondary btn-sm" for="s_leftover">Недодел</label>
|
||||||
|
|
||||||
<input type="checkbox" class="btn-check" name="statuses" id="s_closed" value="closed" {% if 'closed' in selected_statuses %}checked{% endif %} onchange="this.form.submit()">
|
<input type="checkbox" class="btn-check" name="statuses" id="s_closed" value="closed" {% if 'closed' in selected_statuses %}checked{% endif %} onchange="this.form.submit()">
|
||||||
<label class="btn btn-outline-success btn-sm" for="s_closed">Завершено</label>
|
<label class="btn btn-outline-success btn-sm" for="s_closed">Завершено</label>
|
||||||
|
|
||||||
{% if user_role in 'admin,technologist' %}
|
|
||||||
<input type="checkbox" class="btn-check" name="statuses" id="s_imported" value="imported" {% if 'imported' in selected_statuses %}checked{% endif %} onchange="this.form.submit()">
|
|
||||||
<label class="btn btn-outline-accent btn-sm" for="s_imported">Импорт</label>
|
|
||||||
{% endif %}
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if user_role in 'admin,technologist,clerk' %}
|
|
||||||
<div class="col-md-auto">
|
|
||||||
<label class="small text-muted mb-1 fw-bold">Учёт 1С:</label>
|
|
||||||
<select name="is_synced" class="form-select form-select-sm bg-body text-body border-secondary registry-filter-1c" onchange="this.form.submit()">
|
|
||||||
<option value="" {% if not is_synced %}selected{% endif %}>Все</option>
|
|
||||||
<option value="1" {% if is_synced == '1' %}selected{% endif %}>Учтено</option>
|
|
||||||
<option value="0" {% if is_synced == '0' %}selected{% endif %}>Ожидает</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<div class="col-md-auto">
|
<div class="col-md-auto">
|
||||||
<label class="small text-muted mb-1 fw-bold">С:</label>
|
<label class="small text-muted mb-1 fw-bold">С:</label>
|
||||||
@@ -95,8 +83,7 @@
|
|||||||
const data = {
|
const data = {
|
||||||
statuses: Array.from(form.querySelectorAll('input[name="statuses"]:checked')).map(i=>i.value),
|
statuses: Array.from(form.querySelectorAll('input[name="statuses"]:checked')).map(i=>i.value),
|
||||||
m_ids: Array.from(form.querySelectorAll('input[name="m_ids"]:checked')).map(i=>i.value),
|
m_ids: Array.from(form.querySelectorAll('input[name="m_ids"]:checked')).map(i=>i.value),
|
||||||
start_date: s ? s.value : '',
|
start_date: s ? s.value : ''
|
||||||
is_synced: (form.querySelector('select[name="is_synced"]')||{}).value || ''
|
|
||||||
};
|
};
|
||||||
try { localStorage.setItem('registry_filters', JSON.stringify(data)); } catch(_){}
|
try { localStorage.setItem('registry_filters', JSON.stringify(data)); } catch(_){}
|
||||||
}
|
}
|
||||||
@@ -122,8 +109,6 @@
|
|||||||
}
|
}
|
||||||
if (s) s.value = data.start_date || weekAgo;
|
if (s) s.value = data.start_date || weekAgo;
|
||||||
if (e) e.value = today;
|
if (e) e.value = today;
|
||||||
const sel = form.querySelector('select[name="is_synced"]');
|
|
||||||
if (sel && data.is_synced !== undefined) sel.value = data.is_synced;
|
|
||||||
const filtered = form.querySelector('input[name="filtered"]');
|
const filtered = form.querySelector('input[name="filtered"]');
|
||||||
if (filtered) filtered.value = '1';
|
if (filtered) filtered.value = '1';
|
||||||
form.submit();
|
form.submit();
|
||||||
|
|||||||
93
shiftflow/templates/shiftflow/partials/_workitems_table.html
Normal file
93
shiftflow/templates/shiftflow/partials/_workitems_table.html
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
<div class="card-body p-0">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover mb-0 align-middle" data-sortable="1">
|
||||||
|
<thead>
|
||||||
|
<tr class="table-custom-header">
|
||||||
|
<th data-sort-type="date">Дата</th>
|
||||||
|
<th>Сделка</th>
|
||||||
|
<th>Цех/Пост</th>
|
||||||
|
<th>Наименование</th>
|
||||||
|
<th>Материал</th>
|
||||||
|
<th data-sort="false" class="text-center">Файлы</th>
|
||||||
|
<th data-sort="false" style="width: 160px;">Прогресс</th>
|
||||||
|
<th data-sort-type="number">План / Факт</th>
|
||||||
|
<th>Статус</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for wi in workitems %}
|
||||||
|
<tr class="workitem-row" data-href="{% url 'workitem_detail' wi.id %}">
|
||||||
|
<td class="small">{{ wi.date|date:"d.m.y" }}</td>
|
||||||
|
<td><span class="text-accent fw-bold">{{ wi.deal.number|default:"-" }}</span></td>
|
||||||
|
<td>
|
||||||
|
{% if wi.machine %}
|
||||||
|
<span class="badge bg-dark border border-secondary">{{ wi.workshop.name|default:"—" }}/{{ wi.machine.name }}</span>
|
||||||
|
{% elif wi.workshop %}
|
||||||
|
<span class="badge bg-dark border border-secondary">{{ wi.workshop.name }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-secondary">—</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="fw-bold">
|
||||||
|
{{ wi.entity.drawing_number|default:"—" }} {{ wi.entity.name }}
|
||||||
|
</td>
|
||||||
|
<td class="small text-muted">
|
||||||
|
{% if wi.entity.planned_material %}
|
||||||
|
{{ wi.entity.planned_material.full_name|default:wi.entity.planned_material.name }}
|
||||||
|
{% else %}
|
||||||
|
—
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="text-center">
|
||||||
|
{% if wi.entity.dxf_file %}
|
||||||
|
<a href="{{ wi.entity.dxf_file.url }}" target="_blank" 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 wi.entity.pdf_main %}
|
||||||
|
<a href="{{ wi.entity.pdf_main.url }}" target="_blank" class="btn btn-sm btn-outline-danger p-1 stop-prop" title="Чертёж PDF">
|
||||||
|
<i class="bi bi-file-pdf"></i>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="progress bg-secondary-subtle border border-secondary sf-item-progress"
|
||||||
|
style="height: 10px;"
|
||||||
|
data-fact-width="{{ wi.fact_width|default:0 }}"
|
||||||
|
title="Факт: {{ wi.fact_pct|default:0 }}%">
|
||||||
|
<div class="progress-bar bg-warning sf-item-progress-bar"></div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="text-info fw-bold">{{ wi.quantity_plan }}</span> /
|
||||||
|
<span class="text-success">{{ wi.quantity_done }}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if wi.status == 'done' %}
|
||||||
|
<span class="badge bg-success">{{ wi.get_status_display }}</span>
|
||||||
|
{% elif wi.status == 'leftover' %}
|
||||||
|
<span class="badge bg-secondary">{{ wi.get_status_display }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-primary">{{ wi.get_status_display }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr><td colspan="10" class="text-center p-5 text-muted">Записей WorkItem нет</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener("DOMContentLoaded", function() {
|
||||||
|
document.querySelectorAll(".workitem-row").forEach(row => {
|
||||||
|
row.addEventListener("click", function(e) {
|
||||||
|
if (e.target.closest('.stop-prop')) return;
|
||||||
|
const href = this.dataset.href;
|
||||||
|
if (href) window.location.href = href;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -88,6 +88,10 @@
|
|||||||
<button type="button" class="btn btn-outline-accent btn-sm" id="openCompanyModalBtn">Создать</button>
|
<button type="button" class="btn btn-outline-accent btn-sm" id="openCompanyModalBtn">Создать</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label small text-muted">Срок отгрузки</label>
|
||||||
|
<input type="date" class="form-control border-secondary" id="dealDueDate">
|
||||||
|
</div>
|
||||||
<div class="mb-0">
|
<div class="mb-0">
|
||||||
<label class="form-label small text-muted">Описание</label>
|
<label class="form-label small text-muted">Описание</label>
|
||||||
<textarea class="form-control border-secondary" rows="3" id="dealDescription"></textarea>
|
<textarea class="form-control border-secondary" rows="3" id="dealDescription"></textarea>
|
||||||
@@ -157,6 +161,7 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||||||
const dealStatus = document.getElementById('dealStatus');
|
const dealStatus = document.getElementById('dealStatus');
|
||||||
const dealCompany = document.getElementById('dealCompany');
|
const dealCompany = document.getElementById('dealCompany');
|
||||||
const dealDescription = document.getElementById('dealDescription');
|
const dealDescription = document.getElementById('dealDescription');
|
||||||
|
const dealDueDate = document.getElementById('dealDueDate');
|
||||||
const dealSaveBtn = document.getElementById('dealSaveBtn');
|
const dealSaveBtn = document.getElementById('dealSaveBtn');
|
||||||
const openCompanyModalBtn = document.getElementById('openCompanyModalBtn');
|
const openCompanyModalBtn = document.getElementById('openCompanyModalBtn');
|
||||||
|
|
||||||
@@ -234,6 +239,7 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||||||
dealId.value = '';
|
dealId.value = '';
|
||||||
dealNumber.value = '';
|
dealNumber.value = '';
|
||||||
dealDescription.value = '';
|
dealDescription.value = '';
|
||||||
|
if (dealDueDate) dealDueDate.value = '';
|
||||||
if (dealStatus) dealStatus.value = 'work';
|
if (dealStatus) dealStatus.value = 'work';
|
||||||
if (dealCompany) dealCompany.value = '';
|
if (dealCompany) dealCompany.value = '';
|
||||||
});
|
});
|
||||||
@@ -268,6 +274,7 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||||||
status: dealStatus ? dealStatus.value : 'work',
|
status: dealStatus ? dealStatus.value : 'work',
|
||||||
company_id: dealCompany.value,
|
company_id: dealCompany.value,
|
||||||
description: dealDescription.value,
|
description: dealDescription.value,
|
||||||
|
due_date: dealDueDate ? dealDueDate.value : '',
|
||||||
};
|
};
|
||||||
await postForm('{% url "deal_upsert" %}', payload);
|
await postForm('{% url "deal_upsert" %}', payload);
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
|
|||||||
@@ -17,126 +17,752 @@
|
|||||||
<span class="badge {% if deal.status == 'work' %}bg-primary{% elif deal.status == 'done' %}bg-success{% else %}bg-secondary{% endif %} align-self-center">
|
<span class="badge {% if deal.status == 'work' %}bg-primary{% elif deal.status == 'done' %}bg-success{% else %}bg-secondary{% endif %} align-self-center">
|
||||||
{{ deal.get_status_display }}
|
{{ deal.get_status_display }}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
|
{% if deal.status == 'lead' and user_role in 'admin,prod_head,technologist,master,clerk' %}
|
||||||
|
<form method="post" class="d-inline">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="action" value="set_work">
|
||||||
|
<button type="submit" class="btn btn-outline-accent btn-sm">
|
||||||
|
<i class="bi bi-arrow-right-circle me-1"></i>В работу
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<a class="btn btn-outline-secondary btn-sm" href="{% url 'planning' %}">
|
<a class="btn btn-outline-secondary btn-sm" href="{% url 'planning' %}">
|
||||||
<i class="bi bi-arrow-left me-1"></i>Назад
|
<i class="bi bi-arrow-left me-1"></i>Назад
|
||||||
</a>
|
</a>
|
||||||
|
{% if user_role in 'admin,prod_head,technologist,master' %}
|
||||||
|
<form method="post" class="d-inline">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="action" value="explode_deal">
|
||||||
|
<button type="submit" class="btn btn-outline-warning btn-sm" title="Пересчитать потребности снабжения">
|
||||||
|
<i class="bi bi-lightning me-1"></i>Вскрыть BOM
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
{% if user_role in 'admin,technologist' %}
|
{% if user_role in 'admin,technologist' %}
|
||||||
<a class="btn btn-outline-accent btn-sm" href="{% url 'task_add' %}?deal={{ deal.id }}&next={% url 'planning_deal' deal.id %}">
|
<button type="button" class="btn btn-outline-accent btn-sm" data-bs-toggle="modal" data-bs-target="#dealItemModal">
|
||||||
<i class="bi bi-plus-lg me-1"></i>Добавить деталь
|
<i class="bi bi-plus-lg me-1"></i>Добавить задание
|
||||||
</a>
|
</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card-body p-0">
|
<div class="card-body p-0">
|
||||||
<div class="table-responsive">
|
|
||||||
<table class="table table-hover mb-0 align-middle" data-sortable="1">
|
<div class="p-3">
|
||||||
<thead>
|
<div class="card border-secondary mb-3">
|
||||||
<tr class="table-custom-header">
|
<div class="card-header border-secondary py-2 d-flex justify-content-between align-items-center">
|
||||||
<th>Деталь</th>
|
<strong>Позиции сделки</strong>
|
||||||
<th>Материал</th>
|
<div class="small text-muted">Изделие / СБ / Деталь</div>
|
||||||
<th>Размер</th>
|
</div>
|
||||||
<th data-sort="false" style="width: 160px;">Прогресс</th>
|
|
||||||
<th class="text-center">Надо / Сделано / В плане</th>
|
<div class="table-responsive">
|
||||||
<th class="text-center">Осталось</th>
|
<table class="table table-hover mb-0 align-middle" data-sortable="1">
|
||||||
<th data-sort="false" class="text-center">Файлы</th>
|
<thead>
|
||||||
<th data-sort="false" class="text-end">Действия</th>
|
<tr class="table-custom-header">
|
||||||
</tr>
|
<th>Позиция</th>
|
||||||
</thead>
|
<th data-sort="false" style="width: 160px;">Прогресс</th>
|
||||||
<tbody>
|
<th class="text-center">Заказано / Сделано / В плане</th>
|
||||||
{% for t in tasks %}
|
<th class="text-center">Осталось</th>
|
||||||
<tr class="task-row" style="cursor:pointer" data-href="{% url 'task_items' t.id %}">
|
<th data-sort="false" class="text-end">В производство</th>
|
||||||
<td class="fw-bold">{{ t.drawing_name|default:"Б/ч" }}</td>
|
</tr>
|
||||||
<td class="small text-muted">{{ t.material.full_name|default:t.material.name }}</td>
|
</thead>
|
||||||
<td class="small">{{ t.size_value }}</td>
|
<tbody>
|
||||||
<td>
|
{% for it in deal_items %}
|
||||||
<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 }}%">
|
<tr class="deal-entity-row" role="button" data-href="{% url 'product_info' it.entity.id %}?next={{ request.get_full_path|urlencode }}">
|
||||||
<div class="progress-bar bg-success sf-progress-done"></div>
|
<td>
|
||||||
<div class="progress-bar bg-warning sf-progress-plan"></div>
|
<div class="fw-bold">{{ it.entity.drawing_number|default:"—" }} {{ it.entity.name }}</div>
|
||||||
</div>
|
<div class="small text-muted">{{ it.entity.get_entity_type_display }}</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="text-center">
|
<td>
|
||||||
<span class="text-info fw-bold">{{ t.quantity_ordered }}</span> /
|
<div class="progress bg-secondary-subtle border border-secondary sf-progress" style="height: 10px;" data-done-width="{{ it.done_width }}" data-plan-width="{{ it.plan_width }}" title="Сделано: {{ it.done_qty }} · В плане: {{ it.planned_qty }}">
|
||||||
<span class="text-success">{{ t.done_qty }}</span> /
|
<div class="progress-bar bg-success sf-progress-done"></div>
|
||||||
<span class="text-warning">{{ t.planned_qty }}</span>
|
<div class="progress-bar bg-warning sf-progress-plan"></div>
|
||||||
</td>
|
</div>
|
||||||
<td class="text-center">{{ t.remaining_qty }}</td>
|
</td>
|
||||||
<td class="text-center">
|
<td class="text-center">
|
||||||
{% if t.drawing_file %}
|
<span class="text-info fw-bold">{{ it.quantity }}</span> /
|
||||||
<a href="{{ t.drawing_file.url }}" target="_blank" class="btn btn-sm btn-outline-info p-1 stop-prop" title="DXF/IGES">
|
<span class="text-success">{{ it.done_qty }}</span> /
|
||||||
<i class="bi bi-file-earmark-code"></i>
|
<span class="text-warning">{{ it.planned_qty }}</span>
|
||||||
</a>
|
</td>
|
||||||
{% endif %}
|
<td class="text-center">{{ it.remaining_qty }}</td>
|
||||||
{% if t.extra_drawing %}
|
<td class="text-end" onclick="event.stopPropagation();">
|
||||||
<a href="{{ t.extra_drawing.url }}" target="_blank" class="btn btn-sm btn-outline-danger p-1 stop-prop" title="Чертеж PDF">
|
{% if user_role in 'admin,technologist' %}
|
||||||
<i class="bi bi-file-pdf"></i>
|
<button
|
||||||
</a>
|
type="button"
|
||||||
{% endif %}
|
class="btn btn-outline-accent btn-sm"
|
||||||
</td>
|
data-bs-toggle="modal"
|
||||||
<td class="text-end">
|
data-bs-target="#startProductionModal"
|
||||||
{% if user_role in 'admin,technologist' %}
|
data-entity-id="{{ it.entity.id }}"
|
||||||
<button
|
data-entity-label="{{ it.entity.drawing_number|default:'—' }} {{ it.entity.name }}"
|
||||||
type="button"
|
>
|
||||||
class="btn btn-outline-accent btn-sm"
|
<i class="bi bi-play-fill me-1"></i>В производство
|
||||||
data-bs-toggle="modal"
|
</button>
|
||||||
data-bs-target="#addToPlanModal"
|
{% else %}
|
||||||
data-task-id="{{ t.id }}"
|
<button type="button" class="btn btn-outline-secondary btn-sm" disabled>В производство</button>
|
||||||
data-task-name="{{ t.drawing_name|default:'Б/ч' }}"
|
{% endif %}
|
||||||
data-task-rem="{{ t.remaining_qty }}"
|
</td>
|
||||||
>
|
</tr>
|
||||||
<i class="bi bi-plus-lg me-1"></i>В план
|
{% empty %}
|
||||||
</button>
|
<tr><td colspan="5" class="text-center text-muted py-4">Пока нет позиций</td></tr>
|
||||||
{% endif %}
|
{% endfor %}
|
||||||
</td>
|
</tbody>
|
||||||
</tr>
|
</table>
|
||||||
{% empty %}
|
</div>
|
||||||
<tr><td colspan="8" class="text-center p-5 text-muted">Деталей не найдено</td></tr>
|
</div>
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="p-3 pt-0">
|
||||||
|
<div class="card border-secondary mb-3">
|
||||||
|
<div class="card-header border-secondary py-2 d-flex justify-content-between align-items-center">
|
||||||
|
<strong>Партии поставки</strong>
|
||||||
|
{% if user_role in 'admin,technologist' %}
|
||||||
|
<button type="button" class="btn btn-outline-accent btn-sm" data-bs-toggle="modal" data-bs-target="#dealBatchModal">
|
||||||
|
<i class="bi bi-plus-lg me-1"></i>Добавить партию
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover mb-0 align-middle">
|
||||||
|
<thead>
|
||||||
|
<tr class="table-custom-header">
|
||||||
|
<th style="width:160px;">Отгрузка</th>
|
||||||
|
<th>Партия</th>
|
||||||
|
<th style="width:220px;">Запущено</th>
|
||||||
|
<th>Состав партии</th>
|
||||||
|
<th data-sort="false" class="text-end"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for b in delivery_batches %}
|
||||||
|
<tr>
|
||||||
|
<td class="fw-bold">{{ b.due_date|date:"d.m.Y" }}</td>
|
||||||
|
<td>
|
||||||
|
{{ b.name|default:"—" }}
|
||||||
|
{% if b.is_default %}
|
||||||
|
<span class="badge bg-secondary ms-2">по умолчанию</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="small text-muted mb-1">{{ b.total_started }} / {{ b.total_qty }} (осталось {{ b.total_remaining }})</div>
|
||||||
|
<div class="progress bg-secondary-subtle border border-secondary" style="height: 10px;">
|
||||||
|
<div class="progress-bar bg-warning" style="width: {{ b.started_pct }}%"></div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if b.items_list %}
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm mb-0 align-middle">
|
||||||
|
<thead>
|
||||||
|
<tr class="table-custom-header">
|
||||||
|
<th style="width:110px;">Тип</th>
|
||||||
|
<th style="width:160px;">Обозначение</th>
|
||||||
|
<th>Наименование</th>
|
||||||
|
<th class="text-center" style="width:80px;">Кол-во</th>
|
||||||
|
<th class="text-center" style="width:90px;">Запущено</th>
|
||||||
|
<th class="text-center" style="width:90px;">Осталось</th>
|
||||||
|
<th style="width:160px;">Прогресс</th>
|
||||||
|
<th data-sort="false" class="text-end" style="width:160px;"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for bi in b.items_list %}
|
||||||
|
<tr>
|
||||||
|
<td class="small text-muted">{{ bi.entity.get_entity_type_display }}</td>
|
||||||
|
<td class="fw-bold">{{ bi.entity.drawing_number|default:"—" }}</td>
|
||||||
|
<td>{{ bi.entity.name }}</td>
|
||||||
|
<td class="text-center">{{ bi.quantity }}</td>
|
||||||
|
<td class="text-center">{{ bi.started_qty }}</td>
|
||||||
|
<td class="text-center">{{ bi.remaining_to_start }}</td>
|
||||||
|
<td>
|
||||||
|
<div class="progress bg-secondary-subtle border border-secondary" style="height: 10px;">
|
||||||
|
<div class="progress-bar bg-warning" style="width: {{ bi.started_pct }}%"></div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="text-end">
|
||||||
|
{% if user_role in 'admin,technologist' and not b.is_default %}
|
||||||
|
<button type="button" class="btn btn-outline-secondary btn-sm" data-bs-toggle="modal" data-bs-target="#dealBatchItemModal" data-batch-id="{{ b.id }}">Добавить</button>
|
||||||
|
<form method="post" action="{% url 'deal_batch_action' %}" class="d-inline">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="action" value="delete_batch_item">
|
||||||
|
<input type="hidden" name="deal_id" value="{{ deal.id }}">
|
||||||
|
<input type="hidden" name="item_id" value="{{ bi.id }}">
|
||||||
|
<input type="hidden" name="next" value="{{ request.get_full_path }}">
|
||||||
|
<button class="btn btn-outline-secondary btn-sm" type="submit">Удалить</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-muted">Пусто</div>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="text-end">
|
||||||
|
{% if user_role in 'admin,technologist' and not b.is_default %}
|
||||||
|
<button type="button" class="btn btn-outline-secondary btn-sm" data-bs-toggle="modal" data-bs-target="#dealBatchItemModal" data-batch-id="{{ b.id }}">Добавить</button>
|
||||||
|
<form method="post" action="{% url 'deal_batch_action' %}" class="d-inline">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="action" value="delete_batch">
|
||||||
|
<input type="hidden" name="deal_id" value="{{ deal.id }}">
|
||||||
|
<input type="hidden" name="batch_id" value="{{ b.id }}">
|
||||||
|
<input type="hidden" name="next" value="{{ request.get_full_path }}">
|
||||||
|
<button class="btn btn-outline-secondary btn-sm" type="submit">Удалить</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr><td colspan="5" class="text-center text-muted py-3">Партий пока нет</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% for g in workshop_task_groups %}
|
||||||
|
<div class="card border-secondary mb-3">
|
||||||
|
<div class="card-header border-secondary py-2 d-flex justify-content-between align-items-center">
|
||||||
|
<strong>{{ g.name }}</strong>
|
||||||
|
<div class="small text-muted">{{ g.tasks|length }} задач</div>
|
||||||
|
</div>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover mb-0 align-middle" data-sortable="1">
|
||||||
|
<thead>
|
||||||
|
<tr class="table-custom-header">
|
||||||
|
<th>Позиция</th>
|
||||||
|
<th>Операция</th>
|
||||||
|
<th data-sort="false" style="width: 160px;">Прогресс</th>
|
||||||
|
<th class="text-center">Заказано / Сделано / В смене</th>
|
||||||
|
<th class="text-center">Осталось</th>
|
||||||
|
<th data-sort="false" class="text-center">Файлы</th>
|
||||||
|
<th data-sort="false" class="text-end">Действия</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for t in g.tasks %}
|
||||||
|
<tr class="task-row" style="cursor:pointer" {% if t.entity_id %}data-href="{% url 'product_info' t.entity_id %}?next={{ request.get_full_path|urlencode }}"{% endif %}>
|
||||||
|
<td>
|
||||||
|
<div class="fw-bold">
|
||||||
|
{% if t.entity %}
|
||||||
|
{{ t.entity.drawing_number|default:"—" }} {{ t.entity.name }}
|
||||||
|
{% else %}
|
||||||
|
{{ t.drawing_name|default:"Б/ч" }}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="small text-muted">
|
||||||
|
{% if t.material %}{{ t.material.full_name|default:t.material.name }}{% else %}—{% endif %}
|
||||||
|
</div>
|
||||||
|
</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 }}%">
|
||||||
|
<div class="progress-bar bg-success sf-progress-done"></div>
|
||||||
|
<div class="progress-bar bg-warning sf-progress-plan"></div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="text-center">
|
||||||
|
<span class="text-info fw-bold">{{ t.quantity_ordered }}</span> /
|
||||||
|
<span class="text-success">{{ t.done_qty }}</span> /
|
||||||
|
<span class="text-warning">{{ t.planned_qty }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="text-center">{{ t.remaining_qty }}</td>
|
||||||
|
<td class="text-center">
|
||||||
|
{% if t.drawing_file %}
|
||||||
|
<a href="{{ t.drawing_file.url }}" target="_blank" class="btn btn-sm btn-outline-info p-1 stop-prop" title="DXF/IGES">
|
||||||
|
<i class="bi bi-file-earmark-code"></i>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
{% if t.extra_drawing %}
|
||||||
|
<a href="{{ t.extra_drawing.url }}" target="_blank" class="btn btn-sm btn-outline-danger p-1 stop-prop" title="Чертеж PDF">
|
||||||
|
<i class="bi bi-file-pdf"></i>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="text-end">
|
||||||
|
{% if user_role in 'admin,technologist' %}
|
||||||
|
{% if t.current_operation_id and t.entity_id %}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-outline-accent btn-sm"
|
||||||
|
data-bs-toggle="modal"
|
||||||
|
data-bs-target="#workItemModal"
|
||||||
|
data-entity-id="{{ t.entity_id }}"
|
||||||
|
data-operation-id="{{ t.current_operation_id }}"
|
||||||
|
data-workshop-id="{{ t.current_workshop_id|default:'' }}"
|
||||||
|
data-workshop-name="{{ t.current_workshop_name|default:'' }}"
|
||||||
|
data-task-name="{% if t.entity %}{{ t.entity.drawing_number|default:'—' }} {{ t.entity.name }}{% else %}{{ t.drawing_name|default:'Б/ч' }}{% endif %}"
|
||||||
|
data-operation-name="{{ t.current_operation_name|default:'' }}"
|
||||||
|
data-task-rem="{{ t.remaining_qty }}"
|
||||||
|
>
|
||||||
|
<i class="bi bi-plus-lg me-1"></i>В смену
|
||||||
|
</button>
|
||||||
|
{% else %}
|
||||||
|
<button type="button" class="btn btn-outline-secondary btn-sm" disabled>В смену</button>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr><td colspan="7" class="text-center p-4 text-muted">Задач нет</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% empty %}
|
||||||
|
<div class="text-center p-5 text-muted">Задач нет</div>
|
||||||
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="modal fade" id="addToPlanModal" tabindex="-1" aria-hidden="true">
|
<div class="modal fade" id="startProductionModal" tabindex="-1" aria-hidden="true">
|
||||||
<div class="modal-dialog">
|
<div class="modal-dialog">
|
||||||
<form method="post" action="{% url 'planning_add' %}" class="modal-content border-secondary">
|
<form method="post" action="{% url 'deal_batch_action' %}" class="modal-content border-secondary">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="action" value="start_batch_item_production">
|
||||||
|
<input type="hidden" name="deal_id" value="{{ deal.id }}">
|
||||||
<input type="hidden" name="next" value="{{ request.get_full_path }}">
|
<input type="hidden" name="next" value="{{ request.get_full_path }}">
|
||||||
|
|
||||||
<div class="modal-header border-secondary">
|
<div class="modal-header border-secondary">
|
||||||
<h5 class="modal-title">Добавить в план</h5>
|
<h5 class="modal-title">Запуск в производство</h5>
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<input type="hidden" name="task_id" id="modalTaskId">
|
<div class="small text-muted mb-2" id="spTitle"></div>
|
||||||
<div class="small text-muted mb-2" id="modalTaskTitle"></div>
|
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label small text-muted d-block">Станок</label>
|
<label class="form-label">Партия</label>
|
||||||
|
<select class="form-select bg-body text-body border-secondary" name="item_id" id="spBatchItem" required>
|
||||||
|
{% for b in delivery_batches %}
|
||||||
|
{% for bi in b.items_list %}
|
||||||
|
{% if bi.remaining_to_start > 0 %}
|
||||||
|
<option value="{{ bi.id }}" data-entity-id="{{ bi.entity_id }}" data-rem="{{ bi.remaining_to_start }}">
|
||||||
|
{{ b.due_date|date:"d.m.Y" }}{% if b.name %} · {{ b.name }}{% endif %} — осталось {{ bi.remaining_to_start }} шт
|
||||||
|
</option>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<div class="form-text">Если списка нет — сначала создай партию и добавь туда позицию сделки.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Количество, шт</label>
|
||||||
|
<input class="form-control bg-body text-body border-secondary" type="number" min="1" name="quantity" id="spQty" required>
|
||||||
|
</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" id="spSubmit">Запустить</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal fade" id="workItemModal" tabindex="-1" aria-hidden="true">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<form method="post" action="{% url 'workitem_add' %}" class="modal-content border-secondary">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="deal_id" value="{{ deal.id }}">
|
||||||
|
<input type="hidden" name="next" value="{{ request.get_full_path }}">
|
||||||
|
|
||||||
|
<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">
|
||||||
|
<input type="hidden" name="entity_id" id="wiEntityId">
|
||||||
|
<input type="hidden" name="operation_id" id="wiOperationId">
|
||||||
|
|
||||||
|
<div class="small text-muted mb-2" id="wiTitle"></div>
|
||||||
|
<div class="small text-muted mb-3" id="wiOp"></div>
|
||||||
|
|
||||||
|
<input type="hidden" name="workshop_id" id="wiWorkshopId">
|
||||||
|
|
||||||
|
<div class="small text-muted mb-3" id="wiWorkshopLabel"></div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label small text-muted d-block">Пост (опционально)</label>
|
||||||
<div class="d-flex flex-wrap gap-1" id="machineToggleGroup">
|
<div class="d-flex flex-wrap gap-1" id="machineToggleGroup">
|
||||||
|
<input type="radio" class="btn-check" name="machine_id" id="m_none" value="">
|
||||||
|
<label class="btn btn-outline-secondary btn-sm" for="m_none">Без станка</label>
|
||||||
|
|
||||||
{% for m in machines %}
|
{% for m in machines %}
|
||||||
<input type="radio" class="btn-check" name="machine_id" id="m_{{ m.id }}" value="{{ m.id }}" required>
|
<input type="radio" class="btn-check" name="machine_id" id="m_{{ m.id }}" value="{{ m.id }}" data-workshop-id="{{ m.workshop_id|default:'' }}">
|
||||||
<label class="btn btn-outline-accent btn-sm" for="m_{{ m.id }}">{{ m.name }}</label>
|
<label class="btn btn-outline-accent btn-sm" for="m_{{ m.id }}">{{ m.name }}</label>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-2">
|
<div class="mb-2">
|
||||||
<label class="form-label small text-muted">Сколько в план (шт)</label>
|
<label class="form-label small text-muted">Сколько в смену (шт)</label>
|
||||||
<input type="number" min="1" class="form-control border-secondary" name="quantity_plan" id="modalQty" required>
|
<input type="number" min="1" class="form-control border-secondary" name="quantity_plan" id="wiQty" required>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="small text-muted" id="modalHint"></div>
|
<div class="form-check mb-3">
|
||||||
|
<input class="form-check-input border-secondary" type="checkbox" name="recursive_bom" id="recursiveBomCheck" checked>
|
||||||
|
<label class="form-check-label small text-muted" for="recursiveBomCheck">
|
||||||
|
Включить в смену все дочерние компоненты (по всем операциям, строго по БОМу)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="small text-muted" id="wiHint"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer border-secondary">
|
<div class="modal-footer border-secondary">
|
||||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Отмена</button>
|
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Отмена</button>
|
||||||
<button type="submit" class="btn btn-outline-accent">Добавить</button>
|
<button type="submit" class="btn btn-outline-accent" id="wiSubmit">Добавить</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- productInfoModal удалён: паспорт компонента открывается отдельной страницей -->
|
||||||
|
<div class="d-none" id="productInfoModal" tabindex="-1" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-xl">
|
||||||
|
<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" id="productInfoBody">
|
||||||
|
<div class="text-muted">Загрузка...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal fade" id="dealBatchModal" tabindex="-1" aria-hidden="true">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<form method="post" action="{% url 'deal_batch_action' %}" class="modal-content border-secondary">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="action" value="create_batch">
|
||||||
|
<input type="hidden" name="deal_id" value="{{ deal.id }}">
|
||||||
|
<input type="hidden" name="next" value="{{ request.get_full_path }}">
|
||||||
|
|
||||||
|
<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="row g-2">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Отгрузка</label>
|
||||||
|
<input class="form-control bg-body text-body border-secondary" type="date" name="due_date" required>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Название (опц.)</label>
|
||||||
|
<input class="form-control bg-body text-body border-secondary" name="name" placeholder="Напр. Партия 1">
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<div class="modal fade" id="dealBatchItemModal" tabindex="-1" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-lg">
|
||||||
|
<form method="post" action="{% url 'deal_batch_action' %}" class="modal-content border-secondary">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="action" value="add_batch_item">
|
||||||
|
<input type="hidden" name="deal_id" value="{{ deal.id }}">
|
||||||
|
<input type="hidden" name="next" value="{{ request.get_full_path }}">
|
||||||
|
|
||||||
|
<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="row g-2 align-items-end">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label">Партия</label>
|
||||||
|
<select class="form-select bg-body text-body border-secondary" name="batch_id" id="batchSelect" required>
|
||||||
|
{% for b in delivery_batches %}
|
||||||
|
{% if not b.is_default %}
|
||||||
|
<option value="{{ b.id }}">{{ b.due_date|date:"d.m.Y" }}{% if b.name %} · {{ b.name }}{% endif %}</option>
|
||||||
|
{% endif %}
|
||||||
|
{% empty %}
|
||||||
|
<option value="">Сначала создай партию</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Позиция сделки</label>
|
||||||
|
<select class="form-select bg-body text-body border-secondary" name="entity_id" id="biEntitySelect" required {% if not deal_items %}disabled{% endif %}>
|
||||||
|
{% if deal_items %}
|
||||||
|
{% for it in deal_items %}
|
||||||
|
<option value="{{ it.entity.id }}" data-rem="{{ it.remaining_to_allocate|default:0 }}">{{ it.entity.drawing_number|default:"—" }} {{ it.entity.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<option value="">Сначала добавь позиции сделки</option>
|
||||||
|
{% endif %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<label class="form-label">Кол-во, шт</label>
|
||||||
|
<input class="form-control bg-body text-body border-secondary" name="quantity" id="biQty" value="1" min="1" required>
|
||||||
|
<div class="form-text" id="biQtyHint"></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" {% if not delivery_batches or not deal_items %}disabled{% endif %}>Добавить</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal fade" id="dealItemModal" tabindex="-1" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-lg">
|
||||||
|
<form method="post" action="{% url 'deal_item_upsert' %}" class="modal-content border-secondary">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="deal_id" value="{{ deal.id }}">
|
||||||
|
<input type="hidden" name="next" value="{{ request.get_full_path }}">
|
||||||
|
<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="row g-2 align-items-end">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">Тип</label>
|
||||||
|
<select class="form-select bg-body text-body border-secondary" id="diType">
|
||||||
|
<option value="product">Изделие</option>
|
||||||
|
<option value="assembly">Сборочная единица</option>
|
||||||
|
<option value="part" selected>Деталь</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">Обозначение</label>
|
||||||
|
<input class="form-control bg-body text-body border-secondary" id="diDn" placeholder="Опционально">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label">Наименование</label>
|
||||||
|
<input class="form-control bg-body text-body border-secondary" id="diName" placeholder="Напр. Основание">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2 d-grid">
|
||||||
|
<button type="button" class="btn btn-outline-secondary" id="diSearchBtn">Поиск</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-2 mt-2">
|
||||||
|
<div class="col-md-8">
|
||||||
|
<label class="form-label">Найдено</label>
|
||||||
|
<select class="form-select bg-body text-body border-secondary" id="diFound"></select>
|
||||||
|
<input type="hidden" name="entity_id" id="diEntityId" required>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label">Кол-во, шт</label>
|
||||||
|
<input class="form-control bg-body text-body border-secondary" name="quantity" id="diQty" value="1" required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer border-secondary">
|
||||||
|
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Отмена</button>
|
||||||
|
<button type="submit" class="btn btn-outline-accent">Сохранить</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
document.querySelectorAll('tr.deal-entity-row[data-href]').forEach(tr => {
|
||||||
|
tr.addEventListener('click', (e) => {
|
||||||
|
if (e.target && (e.target.closest('button') || e.target.closest('a') || e.target.closest('form') || e.target.closest('input'))) return;
|
||||||
|
const url = tr.getAttribute('data-href');
|
||||||
|
if (url) window.location.href = url;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const spModal = document.getElementById('startProductionModal');
|
||||||
|
const spTitle = document.getElementById('spTitle');
|
||||||
|
const spSelect = document.getElementById('spBatchItem');
|
||||||
|
const spQty = document.getElementById('spQty');
|
||||||
|
const spSubmit = document.getElementById('spSubmit');
|
||||||
|
|
||||||
|
function spApplyFilter(entityId) {
|
||||||
|
if (!spSelect) return;
|
||||||
|
let firstVisible = null;
|
||||||
|
Array.from(spSelect.options).forEach(opt => {
|
||||||
|
const eid = opt.getAttribute('data-entity-id');
|
||||||
|
const visible = eid && String(eid) === String(entityId);
|
||||||
|
opt.hidden = !visible;
|
||||||
|
opt.disabled = !visible;
|
||||||
|
if (visible && !firstVisible) firstVisible = opt;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (firstVisible) {
|
||||||
|
spSelect.value = firstVisible.value;
|
||||||
|
const rem = parseInt(firstVisible.getAttribute('data-rem') || '0', 10) || 0;
|
||||||
|
if (spQty) {
|
||||||
|
spQty.max = rem > 0 ? String(rem) : '';
|
||||||
|
spQty.value = rem > 0 ? String(rem) : '';
|
||||||
|
}
|
||||||
|
if (spSubmit) spSubmit.disabled = false;
|
||||||
|
} else {
|
||||||
|
if (spQty) {
|
||||||
|
spQty.value = '';
|
||||||
|
spQty.removeAttribute('max');
|
||||||
|
}
|
||||||
|
if (spSubmit) spSubmit.disabled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (spSelect) {
|
||||||
|
spSelect.addEventListener('change', () => {
|
||||||
|
const opt = spSelect.options[spSelect.selectedIndex];
|
||||||
|
const rem = opt ? (parseInt(opt.getAttribute('data-rem') || '0', 10) || 0) : 0;
|
||||||
|
if (spQty) {
|
||||||
|
spQty.max = rem > 0 ? String(rem) : '';
|
||||||
|
spQty.value = rem > 0 ? String(rem) : '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (spModal) {
|
||||||
|
spModal.addEventListener('shown.bs.modal', (event) => {
|
||||||
|
const btn = event.relatedTarget;
|
||||||
|
const eid = btn ? btn.getAttribute('data-entity-id') : '';
|
||||||
|
const label = btn ? btn.getAttribute('data-entity-label') : '';
|
||||||
|
if (spTitle) spTitle.textContent = label || '';
|
||||||
|
spApplyFilter(eid);
|
||||||
|
if (spQty) spQty.focus({ preventScroll: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const biModal = document.getElementById('dealBatchItemModal');
|
||||||
|
const batchSelect = document.getElementById('batchSelect');
|
||||||
|
const entitySelect = document.getElementById('biEntitySelect');
|
||||||
|
const qtyEl = document.getElementById('biQty');
|
||||||
|
const hintEl = document.getElementById('biQtyHint');
|
||||||
|
|
||||||
|
function syncRemainingHint() {
|
||||||
|
if (!entitySelect || !qtyEl) return;
|
||||||
|
const opt = entitySelect.options[entitySelect.selectedIndex];
|
||||||
|
const rem = opt ? (parseInt(opt.getAttribute('data-rem') || '0', 10) || 0) : 0;
|
||||||
|
qtyEl.max = rem > 0 ? String(rem) : '';
|
||||||
|
if (rem > 0 && (parseInt(qtyEl.value || '0', 10) || 0) > rem) {
|
||||||
|
qtyEl.value = String(rem);
|
||||||
|
}
|
||||||
|
if (hintEl) {
|
||||||
|
hintEl.textContent = rem > 0 ? `Доступно к распределению: ${rem} шт` : 'Доступно к распределению: 0 шт';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entitySelect) {
|
||||||
|
entitySelect.addEventListener('change', syncRemainingHint);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll('[data-bs-target="#dealBatchItemModal"][data-batch-id]').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const bid = btn.getAttribute('data-batch-id');
|
||||||
|
if (batchSelect && bid) batchSelect.value = String(bid);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (biModal) {
|
||||||
|
biModal.addEventListener('shown.bs.modal', () => {
|
||||||
|
syncRemainingHint();
|
||||||
|
if (entitySelect) entitySelect.focus({ preventScroll: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const modalEl = document.getElementById('dealItemModal');
|
||||||
|
const formEl = modalEl ? modalEl.querySelector('form') : null;
|
||||||
|
|
||||||
|
const typeEl = document.getElementById('diType');
|
||||||
|
const dnEl = document.getElementById('diDn');
|
||||||
|
const nameEl = document.getElementById('diName');
|
||||||
|
const foundEl = document.getElementById('diFound');
|
||||||
|
const idEl = document.getElementById('diEntityId');
|
||||||
|
const btn = document.getElementById('diSearchBtn');
|
||||||
|
|
||||||
|
if (!typeEl || !dnEl || !nameEl || !foundEl || !idEl) return;
|
||||||
|
|
||||||
|
function setSelectedFromFound() {
|
||||||
|
idEl.value = foundEl.value || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function search(opts = { focusFound: false }) {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
entity_type: (typeEl.value || ''),
|
||||||
|
q_dn: (dnEl.value || ''),
|
||||||
|
q_name: (nameEl.value || ''),
|
||||||
|
});
|
||||||
|
const res = await fetch('{% url "entities_search" %}?' + params.toString(), { credentials: 'same-origin' });
|
||||||
|
const data = await res.json();
|
||||||
|
const items = (data && data.results) || [];
|
||||||
|
|
||||||
|
foundEl.innerHTML = '';
|
||||||
|
items.forEach(it => {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = String(it.id);
|
||||||
|
opt.textContent = `${it.type} | ${it.drawing_number || '—'} ${it.name || ''}`;
|
||||||
|
foundEl.appendChild(opt);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (items.length) {
|
||||||
|
foundEl.value = String(items[0].id);
|
||||||
|
setSelectedFromFound();
|
||||||
|
if (opts && opts.focusFound) {
|
||||||
|
foundEl.focus({ preventScroll: true });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
idEl.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
foundEl.onchange = setSelectedFromFound;
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (btn) btn.onclick = () => { search({ focusFound: true }); };
|
||||||
|
|
||||||
|
const onEnterSearch = (e) => {
|
||||||
|
if (e.key !== 'Enter') return;
|
||||||
|
e.preventDefault();
|
||||||
|
search({ focusFound: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
dnEl.addEventListener('keydown', onEnterSearch);
|
||||||
|
nameEl.addEventListener('keydown', onEnterSearch);
|
||||||
|
|
||||||
|
foundEl.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key !== 'Enter') return;
|
||||||
|
e.preventDefault();
|
||||||
|
setSelectedFromFound();
|
||||||
|
if (idEl.value && formEl) {
|
||||||
|
formEl.requestSubmit();
|
||||||
|
} else {
|
||||||
|
search({ focusFound: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (modalEl) {
|
||||||
|
modalEl.addEventListener('shown.bs.modal', () => {
|
||||||
|
setTimeout(() => {
|
||||||
|
dnEl.focus({ preventScroll: true });
|
||||||
|
dnEl.select();
|
||||||
|
}, 0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
document.addEventListener('DOMContentLoaded', function () {
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
document.querySelectorAll('tr.task-row[data-href]').forEach(function (row) {
|
document.querySelectorAll('tr.task-row[data-href]').forEach(function (row) {
|
||||||
row.addEventListener('click', function (e) {
|
row.addEventListener('click', function (e) {
|
||||||
@@ -155,22 +781,39 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||||||
if (planEl) planEl.style.width = `${plan}%`;
|
if (planEl) planEl.style.width = `${plan}%`;
|
||||||
});
|
});
|
||||||
|
|
||||||
const modal = document.getElementById('addToPlanModal');
|
const modal = document.getElementById('workItemModal');
|
||||||
if (!modal) return;
|
if (!modal) return;
|
||||||
|
|
||||||
modal.addEventListener('shown.bs.modal', function (event) {
|
modal.addEventListener('shown.bs.modal', function (event) {
|
||||||
const btn = event.relatedTarget;
|
const btn = event.relatedTarget;
|
||||||
const taskId = btn.getAttribute('data-task-id');
|
const entityId = btn.getAttribute('data-entity-id') || '';
|
||||||
const name = btn.getAttribute('data-task-name');
|
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');
|
const rem = btn.getAttribute('data-task-rem');
|
||||||
|
|
||||||
document.getElementById('modalTaskId').value = taskId;
|
document.getElementById('wiEntityId').value = entityId;
|
||||||
document.getElementById('modalTaskTitle').textContent = name;
|
document.getElementById('wiOperationId').value = opId;
|
||||||
document.getElementById('modalHint').textContent = rem !== null ? `Осталось: ${rem} шт` : '';
|
|
||||||
|
|
||||||
const qty = document.getElementById('modalQty');
|
document.getElementById('wiTitle').textContent = name;
|
||||||
|
document.getElementById('wiOp').textContent = opName ? `Операция: ${opName}` : 'Операция: —';
|
||||||
|
|
||||||
|
const hint = document.getElementById('wiHint');
|
||||||
|
if (hint) hint.textContent = rem !== null ? `Осталось: ${rem} шт` : '';
|
||||||
|
|
||||||
|
const qty = document.getElementById('wiQty');
|
||||||
qty.value = '';
|
qty.value = '';
|
||||||
|
|
||||||
|
if (!opId) {
|
||||||
|
const submit = document.getElementById('wiSubmit');
|
||||||
|
if (submit) submit.disabled = true;
|
||||||
|
if (hint) hint.textContent = 'У этой позиции не задан техпроцесс (операции). Добавь операции в паспорте.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const submit = document.getElementById('wiSubmit');
|
||||||
|
if (submit) submit.disabled = false;
|
||||||
|
|
||||||
let remInt = null;
|
let remInt = null;
|
||||||
if (rem && !isNaN(parseInt(rem, 10))) {
|
if (rem && !isNaN(parseInt(rem, 10))) {
|
||||||
remInt = Math.max(1, parseInt(rem, 10));
|
remInt = Math.max(1, parseInt(rem, 10));
|
||||||
@@ -186,24 +829,62 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||||||
qty.onkeydown = function (e) {
|
qty.onkeydown = function (e) {
|
||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const form = document.querySelector('#addToPlanModal form');
|
const form = document.querySelector('#workItemModal form');
|
||||||
if (form) form.requestSubmit();
|
if (form) form.requestSubmit();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const radios = Array.from(document.querySelectorAll('input[name="machine_id"]'));
|
const radios = Array.from(document.querySelectorAll('input[name="machine_id"]'));
|
||||||
|
const noneRadio = document.getElementById('m_none');
|
||||||
|
const wsIdEl = document.getElementById('wiWorkshopId');
|
||||||
|
const wsLabelEl = document.getElementById('wiWorkshopLabel');
|
||||||
|
|
||||||
const savedMachine = (() => { try { return localStorage.getItem('planning_machine_id'); } catch (_) { return null; } })();
|
const savedMachine = (() => { try { return localStorage.getItem('planning_machine_id'); } catch (_) { return null; } })();
|
||||||
|
|
||||||
|
const wsId = btn.getAttribute('data-workshop-id') || '';
|
||||||
|
const wsName = btn.getAttribute('data-workshop-name') || '';
|
||||||
|
|
||||||
|
if (wsIdEl) wsIdEl.value = wsId;
|
||||||
|
if (wsLabelEl) {
|
||||||
|
wsLabelEl.textContent = wsId ? `Цех: ${wsName || '—'}` : 'Цех: —';
|
||||||
|
}
|
||||||
|
|
||||||
|
function setMachineVisible(radio, visible) {
|
||||||
|
const lbl = document.querySelector(`label[for="${radio.id}"]`);
|
||||||
|
if (lbl) lbl.style.display = visible ? '' : 'none';
|
||||||
|
radio.style.display = visible ? '' : 'none';
|
||||||
|
radio.disabled = !visible;
|
||||||
|
if (!visible && radio.checked) radio.checked = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
radios.forEach(r => {
|
||||||
|
if (r.id === 'm_none') {
|
||||||
|
setMachineVisible(r, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const mWs = r.getAttribute('data-workshop-id') || '';
|
||||||
|
setMachineVisible(r, !wsId || (mWs && String(mWs) === String(wsId)));
|
||||||
|
});
|
||||||
|
|
||||||
let selected = null;
|
let selected = null;
|
||||||
if (savedMachine) {
|
if (savedMachine) {
|
||||||
selected = radios.find(r => r.value === savedMachine);
|
selected = radios.find(r => r.value === savedMachine && !r.disabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selected) {
|
||||||
|
selected.checked = true;
|
||||||
|
} else if (noneRadio) {
|
||||||
|
noneRadio.checked = true;
|
||||||
}
|
}
|
||||||
if (!selected && radios.length) selected = radios[0];
|
|
||||||
if (selected) selected.checked = true;
|
|
||||||
|
|
||||||
radios.forEach(r => {
|
radios.forEach(r => {
|
||||||
r.onchange = function () {
|
r.onchange = function () {
|
||||||
try { localStorage.setItem('planning_machine_id', r.value); } catch (_) {}
|
if (!r.checked) return;
|
||||||
|
if (r.value) {
|
||||||
|
try { localStorage.setItem('planning_machine_id', r.value); } catch (_) {}
|
||||||
|
} else {
|
||||||
|
try { localStorage.removeItem('planning_machine_id'); } catch (_) {}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
86
shiftflow/templates/shiftflow/planning_stages.html
Normal file
86
shiftflow/templates/shiftflow/planning_stages.html
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="card shadow border-secondary mb-3">
|
||||||
|
<div class="card-header border-secondary py-3 d-flex flex-wrap gap-2 justify-content-between align-items-center">
|
||||||
|
<h3 class="text-accent mb-0"><i class="bi bi-graph-up-arrow me-2"></i>План (сделки)</h3>
|
||||||
|
|
||||||
|
<form class="d-flex flex-wrap gap-2 align-items-end" method="get">
|
||||||
|
<div>
|
||||||
|
<label class="form-label small text-muted mb-1">Поиск</label>
|
||||||
|
<input class="form-control form-control-sm bg-body text-body border-secondary" name="q" value="{{ q }}" placeholder="№ сделки / заказчик">
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-outline-accent btn-sm" type="submit"><i class="bi bi-search me-1"></i>Показать</button>
|
||||||
|
<a class="btn btn-outline-accent btn-sm" href="{% url 'planning_stages' %}"><i class="bi bi-arrow-counterclockwise me-1"></i>Сброс</a>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-3">
|
||||||
|
{% for c in deal_cards %}
|
||||||
|
<div class="card border-secondary mb-3">
|
||||||
|
<div class="card-header border-secondary py-2 d-flex flex-wrap gap-2 justify-content-between align-items-center">
|
||||||
|
<div class="fw-bold">
|
||||||
|
<a class="text-decoration-none" href="{% url 'planning_deal' c.deal.id %}">{{ c.deal.number }}</a>
|
||||||
|
<span class="text-muted small ms-2">{{ c.deal.company.name|default:"—" }}</span>
|
||||||
|
{% if c.deal.due_date %}<span class="badge bg-secondary ms-2">{{ c.deal.due_date|date:"d.m.Y" }}</span>{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover mb-0 align-middle">
|
||||||
|
<thead>
|
||||||
|
<tr class="table-custom-header">
|
||||||
|
<th>Позиция</th>
|
||||||
|
<th class="text-center" style="width:120px;">Заказано</th>
|
||||||
|
{% for op in op_columns %}
|
||||||
|
<th class="text-center" style="min-width:140px;">
|
||||||
|
{{ op.name }}
|
||||||
|
<div class="small text-muted">{{ op.code }}</div>
|
||||||
|
</th>
|
||||||
|
{% endfor %}
|
||||||
|
<th class="text-center" style="width:140px;">Готово</th>
|
||||||
|
<th class="text-center" style="width:140px;">Отгружено</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for r in c.rows %}
|
||||||
|
<tr style="cursor:pointer" onclick="window.location.href='{% url 'planning_deal' c.deal.id %}';">
|
||||||
|
<td class="fw-bold">{{ r.entity }}</td>
|
||||||
|
<td class="text-center">{{ r.need }}</td>
|
||||||
|
{% for cell in r.op_cells %}
|
||||||
|
<td class="text-center">
|
||||||
|
{% if cell.has %}
|
||||||
|
<div>{{ cell.done }}/{{ r.need }}</div>
|
||||||
|
<div class="progress bg-secondary-subtle border border-secondary mt-1" style="height:6px;">
|
||||||
|
<div class="progress-bar bg-warning" role="progressbar" style="width: {{ cell.pct }}%;"></div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
—
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
{% endfor %}
|
||||||
|
<td class="text-center">
|
||||||
|
<div>{{ r.ready }}/{{ r.need }}</div>
|
||||||
|
<div class="progress bg-secondary-subtle border border-secondary mt-1" style="height:6px;">
|
||||||
|
<div class="progress-bar bg-success" role="progressbar" style="width: {{ r.ready_pct }}%;"></div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="text-center">
|
||||||
|
<div>{{ r.shipped }}/{{ r.need }}</div>
|
||||||
|
<div class="progress bg-secondary-subtle border border-secondary mt-1" style="height:6px;">
|
||||||
|
<div class="progress-bar bg-primary" role="progressbar" style="width: {{ r.shipped_pct }}%;"></div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% empty %}
|
||||||
|
<div class="text-muted">Нет сделок по выбранным фильтрам.</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
218
shiftflow/templates/shiftflow/procurement_dashboard.html
Normal file
218
shiftflow/templates/shiftflow/procurement_dashboard.html
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="card shadow border-secondary">
|
||||||
|
<div class="card-header border-secondary py-3 d-flex justify-content-between align-items-center">
|
||||||
|
<div class="w-100">
|
||||||
|
<div class="d-flex flex-wrap align-items-center gap-3 justify-content-between">
|
||||||
|
<h3 class="text-accent mb-0"><i class="bi bi-truck me-2"></i>Снабжение</h3>
|
||||||
|
|
||||||
|
<form method="get" class="d-flex flex-wrap align-items-center gap-2">
|
||||||
|
<input type="hidden" name="filtered" value="1">
|
||||||
|
|
||||||
|
<div class="d-flex w-100 mb-2 gap-2">
|
||||||
|
<input type="text" class="form-control form-control-sm border-secondary" name="q" placeholder="Сделка / компонент" value="{{ q }}" style="max-width: 300px;">
|
||||||
|
<button type="submit" class="btn btn-outline-accent btn-sm">
|
||||||
|
<i class="bi bi-search me-1"></i>Применить
|
||||||
|
</button>
|
||||||
|
<a class="btn btn-outline-secondary btn-sm" href="{% url 'procurement' %}">Сброс</a>
|
||||||
|
|
||||||
|
<div class="ms-auto">
|
||||||
|
<button type="submit" name="print" value="1" class="btn btn-outline-secondary btn-sm" formtarget="_blank">
|
||||||
|
<i class="bi bi-printer me-1"></i>Печать
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex align-items-center gap-1">
|
||||||
|
<input type="checkbox" class="btn-check" name="grouped" id="grouped" value="1" {% if grouped %}checked{% endif %} onchange="this.form.submit()">
|
||||||
|
<label class="btn btn-outline-accent btn-sm" for="grouped"><i class="bi bi-layers me-1"></i>Группировать</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex flex-wrap align-items-center gap-1">
|
||||||
|
<span class="small text-muted me-1">Тип:</span>
|
||||||
|
{% for code, label in type_choices %}
|
||||||
|
<input type="checkbox" class="btn-check" name="types" id="type_{{ code }}" value="{{ code }}" {% if code in selected_types %}checked{% endif %} onchange="this.form.submit()">
|
||||||
|
<label class="btn btn-sm {% if code == 'raw' %}btn-outline-info{% elif code == 'purchased' %}btn-outline-primary{% elif code == 'casting' %}btn-outline-secondary{% elif code == 'outsourced' %}btn-outline-warning{% else %}btn-outline-secondary{% endif %}" for="type_{{ code }}">{{ label }}</label>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex flex-wrap align-items-center gap-1">
|
||||||
|
<span class="small text-muted me-1">Статус:</span>
|
||||||
|
{% for code, label in status_choices %}
|
||||||
|
<input type="checkbox" class="btn-check" name="statuses" id="st_{{ code }}" value="{{ code }}" {% if code in selected_statuses %}checked{% endif %} onchange="this.form.submit()">
|
||||||
|
<label class="btn btn-sm {% if code == 'to_order' %}btn-outline-danger{% elif code == 'ordered' %}btn-outline-warning{% else %}btn-outline-success{% endif %}" for="st_{{ code }}">{{ label }}</label>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover mb-0 align-middle" data-sortable="1">
|
||||||
|
<thead>
|
||||||
|
<tr class="table-custom-header">
|
||||||
|
<th>Компонент</th>
|
||||||
|
<th style="width: 140px;">Тип</th>
|
||||||
|
<th style="width: 140px;">К заказу</th>
|
||||||
|
<th style="width: 220px;">Сделки</th>
|
||||||
|
<th style="width: 140px;">Статус</th>
|
||||||
|
<th style="width: 70px;" data-sort="false"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for r in requirements %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<div class="fw-semibold">{{ r.component_label }}</div>
|
||||||
|
</td>
|
||||||
|
<td class="small">
|
||||||
|
{% if r.type == 'raw' %}Сырьё{% elif r.type == 'purchased' %}Покупное{% elif r.type == 'casting' %}Литьё{% elif r.type == 'outsourced' %}Аутсорс{% else %}—{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="fw-semibold">
|
||||||
|
{{ r.required_qty }}{% if r.kind == 'raw' %} {{ r.unit }}{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if r.deals and r.deals|length > 0 %}
|
||||||
|
{% for dn in r.deals|slice:":3" %}
|
||||||
|
<span class="badge bg-secondary">{{ dn }}</span>
|
||||||
|
{% endfor %}
|
||||||
|
{% if r.deals|length > 3 %}
|
||||||
|
<span class="badge bg-secondary" title="{{ r.deals|join:", " }}">…</span>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted">—</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if r.status == 'to_order' %}
|
||||||
|
<span class="badge bg-danger">К заказу</span>
|
||||||
|
{% elif r.status == 'ordered' %}
|
||||||
|
<span class="badge bg-warning text-dark">Заказано</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-success">Закрыто</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="text-end">
|
||||||
|
{% if can_edit and not grouped and r.kind == 'component' %}
|
||||||
|
{% if r.status == 'to_order' %}
|
||||||
|
<form method="post" class="d-inline">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="action" value="mark_ordered">
|
||||||
|
<input type="hidden" name="pr_id" value="{{ r.obj_id }}">
|
||||||
|
<input type="hidden" name="next" value="{{ request.get_full_path }}">
|
||||||
|
<button type="submit" class="btn btn-outline-accent btn-sm" title="Отметить как заказано">+</button>
|
||||||
|
</form>
|
||||||
|
{% elif r.status == 'ordered' %}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-outline-accent btn-sm"
|
||||||
|
title="Оформить приход"
|
||||||
|
data-bs-toggle="modal"
|
||||||
|
data-bs-target="#receiveModal"
|
||||||
|
data-pr-id="{{ r.obj_id }}"
|
||||||
|
data-label="{{ r.component_label }}"
|
||||||
|
data-deal-id="{{ r.deal_id }}"
|
||||||
|
data-deal-number="{{ r.deals.0 }}"
|
||||||
|
data-max="{{ r.required_qty }}"
|
||||||
|
>+</button>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted">—</span>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted">—</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr><td colspan="6" class="text-center p-5 text-muted">Потребностей не найдено</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal fade" id="receiveModal" tabindex="-1" aria-hidden="true">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<form method="post" class="modal-content border-secondary">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="action" value="receive_component">
|
||||||
|
<input type="hidden" name="next" value="{{ request.get_full_path }}">
|
||||||
|
<input type="hidden" name="pr_id" id="receivePrId">
|
||||||
|
|
||||||
|
<div class="modal-header border-secondary">
|
||||||
|
<h5 class="modal-title">Приход компонента</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="mb-2 small text-muted" id="receiveLabel"></div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Склад</label>
|
||||||
|
<select class="form-select border-secondary" name="location_id" required>
|
||||||
|
{% for loc in locations %}
|
||||||
|
<option value="{{ loc.id }}">{{ loc.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Сделка (опционально)</label>
|
||||||
|
<select class="form-select border-secondary" name="deal_id" id="receiveDealId">
|
||||||
|
<option value="">Свободно</option>
|
||||||
|
{% for d in deals %}
|
||||||
|
<option value="{{ d.id }}">{{ d.number }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Количество (шт)</label>
|
||||||
|
<input class="form-control border-secondary" name="quantity" id="receiveQty" placeholder="Напр. 100" required>
|
||||||
|
<div class="form-text">Количество уменьшит потребность выбранной строки. При достижении 0 статус станет «Закрыто».</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>
|
||||||
|
(function () {
|
||||||
|
const modal = document.getElementById('receiveModal');
|
||||||
|
if (!modal) return;
|
||||||
|
|
||||||
|
const prId = document.getElementById('receivePrId');
|
||||||
|
const label = document.getElementById('receiveLabel');
|
||||||
|
const dealId = document.getElementById('receiveDealId');
|
||||||
|
const qty = document.getElementById('receiveQty');
|
||||||
|
|
||||||
|
modal.addEventListener('show.bs.modal', function (event) {
|
||||||
|
const btn = event.relatedTarget;
|
||||||
|
if (!btn) return;
|
||||||
|
const id = btn.getAttribute('data-pr-id') || '';
|
||||||
|
const text = btn.getAttribute('data-label') || '';
|
||||||
|
const max = btn.getAttribute('data-max') || '';
|
||||||
|
const dId = btn.getAttribute('data-deal-id') || '';
|
||||||
|
const dNum = btn.getAttribute('data-deal-number') || '';
|
||||||
|
|
||||||
|
if (prId) prId.value = id;
|
||||||
|
if (label) label.textContent = (text ? ('Компонент: ' + text) : '') + (dNum ? (' · Сделка: ' + dNum) : '');
|
||||||
|
if (dealId) dealId.value = dId;
|
||||||
|
if (qty) {
|
||||||
|
qty.value = max || '';
|
||||||
|
qty.focus();
|
||||||
|
qty.select();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
70
shiftflow/templates/shiftflow/procurement_print.html
Normal file
70
shiftflow/templates/shiftflow/procurement_print.html
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Список к закупке</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: Arial, sans-serif; font-size: 12px; line-height: 1.4; color: #333; margin: 20px; }
|
||||||
|
h2 { font-size: 18px; margin-bottom: 20px; text-align: center; }
|
||||||
|
h3 { font-size: 14px; margin-top: 20px; margin-bottom: 10px; border-bottom: 1px solid #ccc; padding-bottom: 5px; }
|
||||||
|
table { width: 100%; border-collapse: collapse; margin-bottom: 20px; }
|
||||||
|
th, td { border: 1px solid #ddd; padding: 6px 8px; }
|
||||||
|
th { background-color: #f5f5f5; font-weight: bold; text-align: center; }
|
||||||
|
td { text-align: left; vertical-align: top; }
|
||||||
|
.text-center { text-align: center; }
|
||||||
|
.text-right { text-align: right; }
|
||||||
|
.marks { height: 26px; }
|
||||||
|
@media print {
|
||||||
|
body { margin: 0; }
|
||||||
|
button { display: none; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div style="text-align: right; margin-bottom: 20px; display: flex; gap: 10px; justify-content: flex-end;">
|
||||||
|
<button onclick="window.print()" style="padding: 5px 15px; cursor: pointer;">Распечатать</button>
|
||||||
|
<button onclick="try { window.close(); } catch (e) {} if (!window.closed) { window.history.back(); }" style="padding: 5px 15px; cursor: pointer;">Закрыть</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Список к закупке от {% now "d.m.Y" %}</h2>
|
||||||
|
|
||||||
|
{% for type_key, items in print_data.items %}
|
||||||
|
<h3>
|
||||||
|
{% for code, label in type_labels.items %}
|
||||||
|
{% if code == type_key %}{{ label }}{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</h3>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width: 6%;">№ п/п</th>
|
||||||
|
<th style="width: 24%;">Сделка(и)</th>
|
||||||
|
<th style="width: 42%;">Наименование</th>
|
||||||
|
<th style="width: 10%;">Кол-во, шт</th>
|
||||||
|
<th style="width: 18%;">Отметки</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for item in items %}
|
||||||
|
<tr>
|
||||||
|
<td class="text-right">{{ forloop.counter }}</td>
|
||||||
|
<td>
|
||||||
|
{% if item.deals %}
|
||||||
|
{{ item.deals|join:", " }}
|
||||||
|
{% else %}
|
||||||
|
-
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>{{ item.component_label }}</td>
|
||||||
|
<td class="text-right">{{ item.required_qty }}</td>
|
||||||
|
<td class="marks"> </td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% empty %}
|
||||||
|
<p class="text-center">Нет данных для печати по выбранным фильтрам.</p>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -4,6 +4,18 @@
|
|||||||
<div class="card shadow border-secondary mb-3">
|
<div class="card shadow border-secondary mb-3">
|
||||||
<div class="card-header border-secondary py-3 d-flex flex-wrap gap-2 justify-content-between align-items-center">
|
<div class="card-header border-secondary py-3 d-flex flex-wrap gap-2 justify-content-between align-items-center">
|
||||||
<div>
|
<div>
|
||||||
|
<nav aria-label="breadcrumb" class="mb-1">
|
||||||
|
<ol class="breadcrumb mb-0">
|
||||||
|
<li class="breadcrumb-item"><a href="{% url 'products' %}">Изделия</a></li>
|
||||||
|
{% for bc in breadcrumbs %}
|
||||||
|
{% if forloop.last %}
|
||||||
|
<li class="breadcrumb-item active" aria-current="page">{{ bc.label }}</li>
|
||||||
|
{% else %}
|
||||||
|
<li class="breadcrumb-item"><a href="{{ bc.url }}">{{ bc.label }}</a></li>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
<h3 class="text-accent mb-0"><i class="bi bi-diagram-3 me-2"></i>{{ entity }}</h3>
|
<h3 class="text-accent mb-0"><i class="bi bi-diagram-3 me-2"></i>{{ entity }}</h3>
|
||||||
<div class="small text-muted mt-1">{{ entity.get_entity_type_display }}</div>
|
<div class="small text-muted mt-1">{{ entity.get_entity_type_display }}</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -14,8 +26,8 @@
|
|||||||
<i class="bi bi-plus-lg me-1"></i>Добавить
|
<i class="bi bi-plus-lg me-1"></i>Добавить
|
||||||
</button>
|
</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if parent_id %}
|
{% if back_url %}
|
||||||
<a class="btn btn-outline-secondary btn-sm" href="{% url 'product_detail' parent_id %}">Назад</a>
|
<a class="btn btn-outline-secondary btn-sm" href="{{ back_url }}">Назад</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
<a class="btn btn-outline-secondary btn-sm" href="{% url 'products' %}">Назад</a>
|
<a class="btn btn-outline-secondary btn-sm" href="{% url 'products' %}">Назад</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -36,10 +48,20 @@
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for ln in lines %}
|
{% for ln in lines %}
|
||||||
<tr class="product-row" role="button" data-info-url="{% url 'product_info' ln.child.id %}">
|
<tr class="product-row" role="button" data-href="{% url 'product_info' ln.child.id %}?next={{ request.get_full_path|urlencode }}">
|
||||||
<td class="small text-muted">{{ ln.child.get_entity_type_display }}</td>
|
<td class="small text-muted">{{ ln.child.get_entity_type_display }}</td>
|
||||||
<td class="fw-bold">{{ ln.child.drawing_number|default:"—" }}</td>
|
<td class="fw-bold">{{ ln.child.drawing_number|default:"—" }}</td>
|
||||||
<td>{{ ln.child.name }}</td>
|
<td>
|
||||||
|
{{ ln.child.name }}
|
||||||
|
{% if ln.child.entity_type == 'product' or ln.child.entity_type == 'assembly' %}
|
||||||
|
{% if ln.child.assembly_passport and ln.child.assembly_passport.requires_welding %}
|
||||||
|
<span class="badge bg-secondary ms-2">Сварка</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if ln.child.assembly_passport and ln.child.assembly_passport.requires_painting %}
|
||||||
|
<span class="badge bg-secondary ms-1">Покраска</span>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{% if ln.child.passport_filled %}
|
{% if ln.child.passport_filled %}
|
||||||
<i class="bi bi-check-circle-fill text-success" title="Заполнено"></i>
|
<i class="bi bi-check-circle-fill text-success" title="Заполнено"></i>
|
||||||
@@ -59,7 +81,9 @@
|
|||||||
</td>
|
</td>
|
||||||
<td class="text-end">
|
<td class="text-end">
|
||||||
<div class="d-flex gap-2 justify-content-end">
|
<div class="d-flex gap-2 justify-content-end">
|
||||||
<a class="btn btn-outline-accent btn-sm" href="{% url 'product_detail' ln.child.id %}?parent={{ entity.id }}" onclick="event.stopPropagation();">Состав</a>
|
{% if ln.child.entity_type == 'product' or ln.child.entity_type == 'assembly' %}
|
||||||
|
<a class="btn btn-outline-accent btn-sm" href="{% url 'product_detail' ln.child.id %}?trail={{ trail_child }}" onclick="event.stopPropagation();">Состав</a>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<form method="post">
|
<form method="post">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
@@ -225,20 +249,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="modal fade" id="productInfoModal" tabindex="-1" aria-hidden="true">
|
|
||||||
<div class="modal-dialog modal-xl">
|
|
||||||
<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" id="productInfoBody">
|
|
||||||
<div class="text-muted">Загрузка...</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if request.GET.open == '1' %}
|
{% if request.GET.open == '1' %}
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
@@ -252,30 +262,10 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
const modalEl = document.getElementById('productInfoModal');
|
document.querySelectorAll('tr.product-row[data-href]').forEach(tr => {
|
||||||
const bodyEl = document.getElementById('productInfoBody');
|
|
||||||
if (!modalEl || !bodyEl) return;
|
|
||||||
|
|
||||||
const modal = new bootstrap.Modal(modalEl);
|
|
||||||
|
|
||||||
async function openInfo(url) {
|
|
||||||
bodyEl.innerHTML = '<div class="text-muted">Загрузка...</div>';
|
|
||||||
modal.show();
|
|
||||||
try {
|
|
||||||
const nextUrl = encodeURIComponent(window.location.pathname + window.location.search);
|
|
||||||
const sep = url.includes('?') ? '&' : '?';
|
|
||||||
const res = await fetch(url + sep + 'next=' + nextUrl, { credentials: 'same-origin' });
|
|
||||||
bodyEl.innerHTML = await res.text();
|
|
||||||
} catch (e) {
|
|
||||||
bodyEl.innerHTML = '<div class="alert alert-danger">Не удалось загрузить информацию.</div>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
document.querySelectorAll('tr.product-row[data-info-url]').forEach(tr => {
|
|
||||||
tr.addEventListener('click', () => {
|
tr.addEventListener('click', () => {
|
||||||
const url = tr.getAttribute('data-info-url');
|
const url = tr.getAttribute('data-href');
|
||||||
if (!url) return;
|
if (url) window.location.href = url;
|
||||||
openInfo(url);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,8 +1,35 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="card shadow border-secondary mb-3">
|
||||||
|
<div class="card-header border-secondary py-2 d-flex flex-wrap gap-2 justify-content-between align-items-center">
|
||||||
|
<div>
|
||||||
|
<nav aria-label="breadcrumb">
|
||||||
|
<ol class="breadcrumb mb-0">
|
||||||
|
<li class="breadcrumb-item"><a href="{% url 'products' %}">Изделия</a></li>
|
||||||
|
{% for bc in breadcrumbs %}
|
||||||
|
{% if forloop.last %}
|
||||||
|
<li class="breadcrumb-item active" aria-current="page">{{ bc.label }}</li>
|
||||||
|
{% else %}
|
||||||
|
<li class="breadcrumb-item"><a href="{{ bc.url }}">{{ bc.label }}</a></li>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
<div class="small text-muted">{{ entity.get_entity_type_display }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
{% include 'components/_add_to_deal.html' %}
|
||||||
|
<a class="btn btn-outline-secondary btn-sm" href="{{ next }}">Назад</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
<div class="container-fluid p-0">
|
<div class="container-fluid p-0">
|
||||||
<form method="post" action="{% url 'product_info' entity.id %}" enctype="multipart/form-data">
|
<form method="post" action="{% url 'product_info' entity.id %}" enctype="multipart/form-data">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<input type="hidden" name="action" value="save">
|
<input type="hidden" name="action" value="save">
|
||||||
<input type="hidden" name="next" value="{{ next }}">
|
<input type="hidden" name="next" value="{{ next }}">
|
||||||
|
<input type="hidden" name="trail" value="{{ request.GET.trail|default:'' }}">
|
||||||
|
|
||||||
<div class="row g-2">
|
<div class="row g-2">
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
@@ -36,14 +63,35 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if not can_edit %}
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<label class="form-label">Маршрут</label>
|
<label class="form-label">Техпроцесс</label>
|
||||||
<select class="form-select bg-body text-body border-secondary" name="route_id" {% if not can_edit %}disabled{% endif %}>
|
<div class="form-control bg-body text-body border-secondary">
|
||||||
<option value="">— не указано —</option>
|
{% if entity_ops %}
|
||||||
{% for r in routes %}
|
{% for eo in entity_ops %}
|
||||||
<option value="{{ r.id }}" {% if entity.route_id == r.id %}selected{% endif %}>{{ r.name }}</option>
|
{{ eo.seq }}. {{ eo.operation.name }}{% if eo.operation.workshop %} ({{ eo.operation.workshop.name }}){% endif %}{% if not forloop.last %} · {% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
{% else %}
|
||||||
|
— не указан —
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">Сварка</label>
|
||||||
|
<div class="form-check mt-2">
|
||||||
|
<input class="form-check-input" type="checkbox" name="requires_welding" id="rw" {% if passport and passport.requires_welding %}checked{% endif %} {% if not can_edit %}disabled{% endif %}>
|
||||||
|
<label class="form-check-label" for="rw">Требуется сварка</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">Покраска</label>
|
||||||
|
<div class="form-check mt-2">
|
||||||
|
<input class="form-check-input" type="checkbox" name="requires_painting" id="rp" {% if passport and passport.requires_painting %}checked{% endif %} {% if not can_edit %}disabled{% endif %}>
|
||||||
|
<label class="form-check-label" for="rp">Требуется покраска</label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-md-3">
|
<div class="col-md-3">
|
||||||
@@ -80,15 +128,82 @@
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
{% if can_edit %}
|
{% if can_edit %}
|
||||||
<form method="post" action="{% url 'product_info' entity.id %}" class="mt-3 d-flex gap-2 align-items-center">
|
<div class="mt-3">
|
||||||
{% csrf_token %}
|
<div class="fw-bold mb-2">Операции техпроцесса</div>
|
||||||
<input type="hidden" name="action" value="create_route">
|
|
||||||
<input type="hidden" name="next" value="{{ next }}">
|
<div class="table-responsive">
|
||||||
<input class="form-control bg-body text-body border-secondary" name="route_name" placeholder="Новый маршрут">
|
<table class="table table-hover mb-0 align-middle">
|
||||||
<button class="btn btn-outline-secondary" type="submit">Добавить маршрут</button>
|
<thead>
|
||||||
</form>
|
<tr class="table-custom-header">
|
||||||
|
<th style="width:70px;">№</th>
|
||||||
|
<th>Операция</th>
|
||||||
|
<th>Цех</th>
|
||||||
|
<th data-sort="false" class="text-end"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for eo in entity_ops %}
|
||||||
|
<tr>
|
||||||
|
<td class="text-muted">{{ eo.seq }}</td>
|
||||||
|
<td class="fw-bold">{{ eo.operation.name }}</td>
|
||||||
|
<td class="small text-muted">{% if eo.operation.workshop %}{{ eo.operation.workshop.name }}{% else %}—{% endif %}</td>
|
||||||
|
<td class="text-end">
|
||||||
|
<div class="btn-group btn-group-sm" role="group">
|
||||||
|
<form method="post" action="{% url 'product_info' entity.id %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="action" value="move_entity_operation">
|
||||||
|
<input type="hidden" name="direction" value="up">
|
||||||
|
<input type="hidden" name="entity_operation_id" value="{{ eo.id }}">
|
||||||
|
<input type="hidden" name="next" value="{{ next }}">
|
||||||
|
<button class="btn btn-outline-secondary" type="submit">↑</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="{% url 'product_info' entity.id %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="action" value="move_entity_operation">
|
||||||
|
<input type="hidden" name="direction" value="down">
|
||||||
|
<input type="hidden" name="entity_operation_id" value="{{ eo.id }}">
|
||||||
|
<input type="hidden" name="next" value="{{ next }}">
|
||||||
|
<button class="btn btn-outline-secondary" type="submit">↓</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="{% url 'product_info' entity.id %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="action" value="delete_entity_operation">
|
||||||
|
<input type="hidden" name="entity_operation_id" value="{{ eo.id }}">
|
||||||
|
<input type="hidden" name="next" value="{{ next }}">
|
||||||
|
<button class="btn btn-outline-secondary" type="submit">Удалить</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr><td colspan="4" class="text-center text-muted py-3">Операции не добавлены</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="post" action="{% url 'product_info' entity.id %}" class="row g-2 mt-2">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="action" value="add_entity_operation">
|
||||||
|
<input type="hidden" name="next" value="{{ next }}">
|
||||||
|
|
||||||
|
<div class="col-md-10">
|
||||||
|
<label class="form-label">Добавить операцию</label>
|
||||||
|
<select class="form-select bg-body text-body border-secondary" name="operation_id">
|
||||||
|
<option value="">— выбери —</option>
|
||||||
|
{% for op in operations %}
|
||||||
|
<option value="{{ op.id }}">{{ op.name }}{% if op.workshop %} ({{ op.workshop.name }}){% endif %}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2 d-flex align-items-end justify-content-end">
|
||||||
|
<button class="btn btn-outline-accent" type="submit">+</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
<div class="fw-bold mb-2">Сварные швы</div>
|
<div class="fw-bold mb-2">Сварные швы</div>
|
||||||
|
|
||||||
@@ -110,9 +225,9 @@
|
|||||||
<td>{{ s.leg_mm }}</td>
|
<td>{{ s.leg_mm }}</td>
|
||||||
<td>{{ s.length_mm }}</td>
|
<td>{{ s.length_mm }}</td>
|
||||||
<td>{{ s.quantity }}</td>
|
<td>{{ s.quantity }}</td>
|
||||||
<td class="text-end">
|
<td class="text-end" onclick="event.stopPropagation();">
|
||||||
{% if can_edit %}
|
{% if can_edit %}
|
||||||
<form method="post" action="{% url 'product_info' entity.id %}">
|
<form method="post" action="{% url 'product_info' entity.id %}" onclick="event.stopPropagation();">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<input type="hidden" name="action" value="delete_weld_seam">
|
<input type="hidden" name="action" value="delete_weld_seam">
|
||||||
<input type="hidden" name="next" value="{{ next }}">
|
<input type="hidden" name="next" value="{{ next }}">
|
||||||
@@ -157,4 +272,233 @@
|
|||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<hr class="border-secondary my-4">
|
||||||
|
|
||||||
|
<div class="mt-4">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||||
|
<div class="fw-bold">Состав</div>
|
||||||
|
{% if can_edit %}
|
||||||
|
<button type="button" class="btn btn-outline-accent btn-sm" data-bs-toggle="modal" data-bs-target="#bomAddModal">Добавить компонент</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover mb-0 align-middle">
|
||||||
|
<thead>
|
||||||
|
<tr class="table-custom-header">
|
||||||
|
<th>Тип</th>
|
||||||
|
<th>Обозначение</th>
|
||||||
|
<th>Наименование</th>
|
||||||
|
<th class="text-center">Кол-во</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" 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="5" 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">
|
||||||
|
<div class="modal-dialog modal-xl">
|
||||||
|
<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="border border-secondary rounded p-3 mb-3">
|
||||||
|
<div class="fw-bold mb-2">Найти существующее</div>
|
||||||
|
<div class="row g-2 align-items-end">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">Тип</label>
|
||||||
|
<select class="form-select bg-body text-body border-secondary" id="bomType">
|
||||||
|
<option value="assembly">Сборочная единица</option>
|
||||||
|
<option value="part" selected>Деталь</option>
|
||||||
|
<option value="purchased">Покупное</option>
|
||||||
|
<option value="casting">Литьё</option>
|
||||||
|
<option value="outsourced">Аутсорс</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">Обозначение</label>
|
||||||
|
<input class="form-control bg-body text-body border-secondary" id="bomDn" placeholder="Опционально">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label">Наименование</label>
|
||||||
|
<input class="form-control bg-body text-body border-secondary" id="bomName" placeholder="Опционально">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2 d-grid">
|
||||||
|
<button type="button" class="btn btn-outline-secondary" id="bomSearchBtn">Поиск</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="post" action="{% url 'product_info' entity.id %}" class="row g-2 align-items-end mt-2">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="action" value="bom_add_existing">
|
||||||
|
<input type="hidden" name="trail" value="{{ request.GET.trail|default:'' }}">
|
||||||
|
<input type="hidden" name="next" value="{{ next }}">
|
||||||
|
|
||||||
|
<div class="col-md-8">
|
||||||
|
<label class="form-label">Найдено</label>
|
||||||
|
<select class="form-select bg-body text-body border-secondary" id="bomFound"></select>
|
||||||
|
<input type="hidden" name="child_id" id="bomChildId" required>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<label class="form-label">Кол-во, шт</label>
|
||||||
|
<input class="form-control bg-body text-body border-secondary" name="quantity" value="1" required>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2 d-grid">
|
||||||
|
<button class="btn btn-outline-accent" type="submit">Добавить</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="border border-secondary rounded p-3">
|
||||||
|
<div class="fw-bold mb-2">Создать новое и добавить</div>
|
||||||
|
<form method="post" action="{% url 'product_info' entity.id %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="action" value="bom_create_and_add">
|
||||||
|
<input type="hidden" name="trail" value="{{ request.GET.trail|default:'' }}">
|
||||||
|
<input type="hidden" name="next" value="{{ next }}">
|
||||||
|
|
||||||
|
<div class="row g-2">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label">Тип</label>
|
||||||
|
<select class="form-select bg-body text-body border-secondary" name="child_type" id="bomCreateType" required>
|
||||||
|
<option value="assembly">Сборочная единица</option>
|
||||||
|
<option value="part" selected>Деталь</option>
|
||||||
|
<option value="purchased">Покупное</option>
|
||||||
|
<option value="casting">Литьё</option>
|
||||||
|
<option value="outsourced">Аутсорс</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label">Кол-во</label>
|
||||||
|
<input class="form-control bg-body text-body border-secondary" name="quantity" value="1" required>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Обозначение</label>
|
||||||
|
<input class="form-control bg-body text-body border-secondary" name="drawing_number" placeholder="Опционально">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Наименование</label>
|
||||||
|
<input class="form-control bg-body text-body border-secondary" name="name" required>
|
||||||
|
</div>
|
||||||
|
<div class="col-12" id="bomPlannedMaterialBlock">
|
||||||
|
<label class="form-label">Материал (для детали)</label>
|
||||||
|
<select class="form-select bg-body text-body border-secondary" name="planned_material_id" id="bomPlannedMaterialSelect">
|
||||||
|
<option value="">— не указано —</option>
|
||||||
|
{% for m in materials %}
|
||||||
|
<option value="{{ m.id }}">{{ m.full_name|default:m.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 d-flex justify-content-end mt-2">
|
||||||
|
<button class="btn btn-outline-accent" type="submit">Создать и добавить</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const typeEl = document.getElementById('bomType');
|
||||||
|
const dnEl = document.getElementById('bomDn');
|
||||||
|
const nameEl = document.getElementById('bomName');
|
||||||
|
const foundEl = document.getElementById('bomFound');
|
||||||
|
const idEl = document.getElementById('bomChildId');
|
||||||
|
const btn = document.getElementById('bomSearchBtn');
|
||||||
|
|
||||||
|
function setSelected() {
|
||||||
|
if (idEl) idEl.value = (foundEl && foundEl.value) ? String(foundEl.value) : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function search() {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
entity_type: (typeEl && typeEl.value) || '',
|
||||||
|
q_dn: (dnEl && dnEl.value) || '',
|
||||||
|
q_name: (nameEl && nameEl.value) || '',
|
||||||
|
});
|
||||||
|
const res = await fetch('{% url "entities_search" %}?' + params.toString(), { credentials: 'same-origin' });
|
||||||
|
const data = await res.json();
|
||||||
|
const items = (data && data.results) || [];
|
||||||
|
foundEl.innerHTML = '';
|
||||||
|
items.forEach(it => {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = String(it.id);
|
||||||
|
opt.textContent = `${it.type} | ${it.drawing_number || '—'} ${it.name || ''}`;
|
||||||
|
foundEl.appendChild(opt);
|
||||||
|
});
|
||||||
|
if (items.length) {
|
||||||
|
foundEl.value = String(items[0].id);
|
||||||
|
setSelected();
|
||||||
|
} else {
|
||||||
|
if (idEl) idEl.value = '';
|
||||||
|
}
|
||||||
|
foundEl.onchange = setSelected;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (btn) btn.addEventListener('click', search);
|
||||||
|
|
||||||
|
const createType = document.getElementById('bomCreateType');
|
||||||
|
const block = document.getElementById('bomPlannedMaterialBlock');
|
||||||
|
const matSel = document.getElementById('bomPlannedMaterialSelect');
|
||||||
|
function syncMat() {
|
||||||
|
const t = (createType && createType.value) || '';
|
||||||
|
const isPart = t === 'part';
|
||||||
|
if (block) block.style.display = isPart ? '' : 'none';
|
||||||
|
if (matSel) {
|
||||||
|
matSel.disabled = !isPart;
|
||||||
|
if (!isPart) matSel.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (createType) {
|
||||||
|
createType.addEventListener('change', syncMat);
|
||||||
|
syncMat();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -1,8 +1,35 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="card shadow border-secondary mb-3">
|
||||||
|
<div class="card-header border-secondary py-2 d-flex flex-wrap gap-2 justify-content-between align-items-center">
|
||||||
|
<div>
|
||||||
|
<nav aria-label="breadcrumb">
|
||||||
|
<ol class="breadcrumb mb-0">
|
||||||
|
<li class="breadcrumb-item"><a href="{% url 'products' %}">Изделия</a></li>
|
||||||
|
{% for bc in breadcrumbs %}
|
||||||
|
{% if forloop.last %}
|
||||||
|
<li class="breadcrumb-item active" aria-current="page">{{ bc.label }}</li>
|
||||||
|
{% else %}
|
||||||
|
<li class="breadcrumb-item"><a href="{{ bc.url }}">{{ bc.label }}</a></li>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
<div class="small text-muted">{{ entity.get_entity_type_display }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
{% include 'components/_add_to_deal.html' %}
|
||||||
|
<a class="btn btn-outline-secondary btn-sm" href="{{ next }}">Назад</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
<div class="container-fluid p-0">
|
<div class="container-fluid p-0">
|
||||||
<form method="post" action="{% url 'product_info' entity.id %}" enctype="multipart/form-data">
|
<form method="post" action="{% url 'product_info' entity.id %}" enctype="multipart/form-data">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<input type="hidden" name="action" value="save">
|
<input type="hidden" name="action" value="save">
|
||||||
<input type="hidden" name="next" value="{{ next }}">
|
<input type="hidden" name="next" value="{{ next }}">
|
||||||
|
<input type="hidden" name="trail" value="{{ request.GET.trail|default:'' }}">
|
||||||
|
|
||||||
<div class="row g-2">
|
<div class="row g-2">
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
@@ -38,15 +65,20 @@
|
|||||||
<input class="form-control bg-body text-body border-secondary" name="mass_kg" value="{% if passport and passport.mass_kg %}{{ passport.mass_kg }}{% endif %}" inputmode="decimal" {% if not can_edit %}disabled{% endif %}>
|
<input class="form-control bg-body text-body border-secondary" name="mass_kg" value="{% if passport and passport.mass_kg %}{{ passport.mass_kg }}{% endif %}" inputmode="decimal" {% if not can_edit %}disabled{% endif %}>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if not can_edit %}
|
||||||
<div class="col-md-3">
|
<div class="col-md-3">
|
||||||
<label class="form-label">Маршрут</label>
|
<label class="form-label">Техпроцесс</label>
|
||||||
<select class="form-select bg-body text-body border-secondary" name="route_id" {% if not can_edit %}disabled{% endif %}>
|
<div class="form-control bg-body text-body border-secondary">
|
||||||
<option value="">— не указано —</option>
|
{% if entity_ops %}
|
||||||
{% for r in routes %}
|
{% for eo in entity_ops %}
|
||||||
<option value="{{ r.id }}" {% if entity.route_id == r.id %}selected{% endif %}>{{ r.name }}</option>
|
{{ eo.seq }}. {{ eo.operation.name }}{% if eo.operation.workshop %} ({{ eo.operation.workshop.name }}){% endif %}{% if not forloop.last %} · {% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
{% else %}
|
||||||
|
—
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<label class="form-label">Чертёж (PDF)</label>
|
<label class="form-label">Чертёж (PDF)</label>
|
||||||
@@ -73,12 +105,81 @@
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
{% if can_edit %}
|
{% if can_edit %}
|
||||||
<form method="post" action="{% url 'product_info' entity.id %}" class="mt-3 d-flex gap-2 align-items-center">
|
<div class="mt-3">
|
||||||
{% csrf_token %}
|
<div class="fw-bold mb-2">Операции техпроцесса</div>
|
||||||
<input type="hidden" name="action" value="create_route">
|
|
||||||
<input type="hidden" name="next" value="{{ next }}">
|
<div class="table-responsive">
|
||||||
<input class="form-control bg-body text-body border-secondary" name="route_name" placeholder="Новый маршрут">
|
<table class="table table-hover mb-0 align-middle">
|
||||||
<button class="btn btn-outline-secondary" type="submit">Добавить маршрут</button>
|
<thead>
|
||||||
</form>
|
<tr class="table-custom-header">
|
||||||
|
<th style="width:70px;">№</th>
|
||||||
|
<th>Операция</th>
|
||||||
|
<th>Цех</th>
|
||||||
|
<th data-sort="false" class="text-end"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for eo in entity_ops %}
|
||||||
|
<tr>
|
||||||
|
<td class="text-muted">{{ eo.seq }}</td>
|
||||||
|
<td class="fw-bold">{{ eo.operation.name }}</td>
|
||||||
|
<td class="small text-muted">{% if eo.operation.workshop %}{{ eo.operation.workshop.name }}{% else %}—{% endif %}</td>
|
||||||
|
<td class="text-end">
|
||||||
|
<div class="btn-group btn-group-sm" role="group">
|
||||||
|
<form method="post" action="{% url 'product_info' entity.id %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="action" value="move_entity_operation">
|
||||||
|
<input type="hidden" name="direction" value="up">
|
||||||
|
<input type="hidden" name="entity_operation_id" value="{{ eo.id }}">
|
||||||
|
<input type="hidden" name="next" value="{{ next }}">
|
||||||
|
<button class="btn btn-outline-secondary" type="submit">↑</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="{% url 'product_info' entity.id %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="action" value="move_entity_operation">
|
||||||
|
<input type="hidden" name="direction" value="down">
|
||||||
|
<input type="hidden" name="entity_operation_id" value="{{ eo.id }}">
|
||||||
|
<input type="hidden" name="next" value="{{ next }}">
|
||||||
|
<button class="btn btn-outline-secondary" type="submit">↓</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="{% url 'product_info' entity.id %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="action" value="delete_entity_operation">
|
||||||
|
<input type="hidden" name="entity_operation_id" value="{{ eo.id }}">
|
||||||
|
<input type="hidden" name="next" value="{{ next }}">
|
||||||
|
<button class="btn btn-outline-secondary" type="submit">Удалить</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr><td colspan="4" class="text-center text-muted py-3">Операции не добавлены</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="post" action="{% url 'product_info' entity.id %}" class="row g-2 mt-2">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="action" value="add_entity_operation">
|
||||||
|
<input type="hidden" name="next" value="{{ next }}">
|
||||||
|
|
||||||
|
<div class="col-md-10">
|
||||||
|
<label class="form-label">Добавить операцию</label>
|
||||||
|
<select class="form-select bg-body text-body border-secondary" name="operation_id">
|
||||||
|
<option value="">— выбери —</option>
|
||||||
|
{% for op in operations %}
|
||||||
|
<option value="{{ op.id }}">{{ op.name }}{% if op.workshop %} ({{ op.workshop.name }}){% endif %}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2 d-flex align-items-end justify-content-end">
|
||||||
|
<button class="btn btn-outline-accent" type="submit">+</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -1,7 +1,35 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="card shadow border-secondary mb-3">
|
||||||
|
<div class="card-header border-secondary py-2 d-flex flex-wrap gap-2 justify-content-between align-items-center">
|
||||||
|
<div>
|
||||||
|
<nav aria-label="breadcrumb">
|
||||||
|
<ol class="breadcrumb mb-0">
|
||||||
|
<li class="breadcrumb-item"><a href="{% url 'products' %}">Изделия</a></li>
|
||||||
|
{% for bc in breadcrumbs %}
|
||||||
|
{% if forloop.last %}
|
||||||
|
<li class="breadcrumb-item active" aria-current="page">{{ bc.label }}</li>
|
||||||
|
{% else %}
|
||||||
|
<li class="breadcrumb-item"><a href="{{ bc.url }}">{{ bc.label }}</a></li>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
<div class="small text-muted">{{ entity.get_entity_type_display }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
{% include 'components/_add_to_deal.html' %}
|
||||||
|
<a class="btn btn-outline-secondary btn-sm" href="{{ next }}">Назад</a>
|
||||||
|
</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">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<input type="hidden" name="action" value="save">
|
<input type="hidden" name="action" value="save">
|
||||||
<input type="hidden" name="next" value="{{ next }}">
|
<input type="hidden" name="next" value="{{ next }}">
|
||||||
|
<input type="hidden" name="trail" value="{{ request.GET.trail|default:'' }}">
|
||||||
|
<input type="hidden" name="trail" value="{{ request.GET.trail|default:'' }}">
|
||||||
|
|
||||||
<div class="row g-2">
|
<div class="row g-2">
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
@@ -34,3 +62,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -1,8 +1,35 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="card shadow border-secondary mb-3">
|
||||||
|
<div class="card-header border-secondary py-2 d-flex flex-wrap gap-2 justify-content-between align-items-center">
|
||||||
|
<div>
|
||||||
|
<nav aria-label="breadcrumb">
|
||||||
|
<ol class="breadcrumb mb-0">
|
||||||
|
<li class="breadcrumb-item"><a href="{% url 'products' %}">Изделия</a></li>
|
||||||
|
{% for bc in breadcrumbs %}
|
||||||
|
{% if forloop.last %}
|
||||||
|
<li class="breadcrumb-item active" aria-current="page">{{ bc.label }}</li>
|
||||||
|
{% else %}
|
||||||
|
<li class="breadcrumb-item"><a href="{{ bc.url }}">{{ bc.label }}</a></li>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
<div class="small text-muted">{{ entity.get_entity_type_display }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
{% include 'components/_add_to_deal.html' %}
|
||||||
|
<a class="btn btn-outline-secondary btn-sm" href="{{ next }}">Назад</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
<div class="container-fluid p-0">
|
<div class="container-fluid p-0">
|
||||||
<form method="post" action="{% url 'product_info' entity.id %}" enctype="multipart/form-data">
|
<form method="post" action="{% url 'product_info' entity.id %}" enctype="multipart/form-data">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<input type="hidden" name="action" value="save">
|
<input type="hidden" name="action" value="save">
|
||||||
<input type="hidden" name="next" value="{{ next }}">
|
<input type="hidden" name="next" value="{{ next }}">
|
||||||
|
<input type="hidden" name="trail" value="{{ request.GET.trail|default:'' }}">
|
||||||
|
|
||||||
<div class="row g-2">
|
<div class="row g-2">
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
@@ -28,15 +55,20 @@
|
|||||||
<input class="form-control bg-body text-body border-secondary" name="name" value="{{ entity.name }}" {% if not can_edit %}disabled{% endif %}>
|
<input class="form-control bg-body text-body border-secondary" name="name" value="{{ entity.name }}" {% if not can_edit %}disabled{% endif %}>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if not can_edit %}
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<label class="form-label">Маршрут</label>
|
<label class="form-label">Техпроцесс</label>
|
||||||
<select class="form-select bg-body text-body border-secondary" name="route_id" {% if not can_edit %}disabled{% endif %}>
|
<div class="form-control bg-body text-body border-secondary">
|
||||||
<option value="">— не указано —</option>
|
{% if entity_ops %}
|
||||||
{% for r in routes %}
|
{% for eo in entity_ops %}
|
||||||
<option value="{{ r.id }}" {% if entity.route_id == r.id %}selected{% endif %}>{{ r.name }}</option>
|
{{ eo.seq }}. {{ eo.operation.name }}{% if eo.operation.workshop %} ({{ eo.operation.workshop.name }}){% endif %}{% if not forloop.last %} · {% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
{% else %}
|
||||||
|
— не указан —
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<label class="form-label">Чертёж/ТЗ (PDF)</label>
|
<label class="form-label">Чертёж/ТЗ (PDF)</label>
|
||||||
@@ -65,12 +97,81 @@
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
{% if can_edit %}
|
{% if can_edit %}
|
||||||
<form method="post" action="{% url 'product_info' entity.id %}" class="mt-3 d-flex gap-2 align-items-center">
|
<div class="mt-3">
|
||||||
{% csrf_token %}
|
<div class="fw-bold mb-2">Операции техпроцесса</div>
|
||||||
<input type="hidden" name="action" value="create_route">
|
|
||||||
<input type="hidden" name="next" value="{{ next }}">
|
<div class="table-responsive">
|
||||||
<input class="form-control bg-body text-body border-secondary" name="route_name" placeholder="Новый маршрут">
|
<table class="table table-hover mb-0 align-middle">
|
||||||
<button class="btn btn-outline-secondary" type="submit">Добавить маршрут</button>
|
<thead>
|
||||||
</form>
|
<tr class="table-custom-header">
|
||||||
|
<th style="width:70px;">№</th>
|
||||||
|
<th>Операция</th>
|
||||||
|
<th>Цех</th>
|
||||||
|
<th data-sort="false" class="text-end"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for eo in entity_ops %}
|
||||||
|
<tr>
|
||||||
|
<td class="text-muted">{{ eo.seq }}</td>
|
||||||
|
<td class="fw-bold">{{ eo.operation.name }}</td>
|
||||||
|
<td class="small text-muted">{% if eo.operation.workshop %}{{ eo.operation.workshop.name }}{% else %}—{% endif %}</td>
|
||||||
|
<td class="text-end">
|
||||||
|
<div class="btn-group btn-group-sm" role="group">
|
||||||
|
<form method="post" action="{% url 'product_info' entity.id %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="action" value="move_entity_operation">
|
||||||
|
<input type="hidden" name="direction" value="up">
|
||||||
|
<input type="hidden" name="entity_operation_id" value="{{ eo.id }}">
|
||||||
|
<input type="hidden" name="next" value="{{ next }}">
|
||||||
|
<button class="btn btn-outline-secondary" type="submit">↑</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="{% url 'product_info' entity.id %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="action" value="move_entity_operation">
|
||||||
|
<input type="hidden" name="direction" value="down">
|
||||||
|
<input type="hidden" name="entity_operation_id" value="{{ eo.id }}">
|
||||||
|
<input type="hidden" name="next" value="{{ next }}">
|
||||||
|
<button class="btn btn-outline-secondary" type="submit">↓</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="{% url 'product_info' entity.id %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="action" value="delete_entity_operation">
|
||||||
|
<input type="hidden" name="entity_operation_id" value="{{ eo.id }}">
|
||||||
|
<input type="hidden" name="next" value="{{ next }}">
|
||||||
|
<button class="btn btn-outline-secondary" type="submit">Удалить</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr><td colspan="4" class="text-center text-muted py-3">Операции не добавлены</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="post" action="{% url 'product_info' entity.id %}" class="row g-2 mt-2">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="action" value="add_entity_operation">
|
||||||
|
<input type="hidden" name="next" value="{{ next }}">
|
||||||
|
|
||||||
|
<div class="col-md-10">
|
||||||
|
<label class="form-label">Добавить операцию</label>
|
||||||
|
<select class="form-select bg-body text-body border-secondary" name="operation_id">
|
||||||
|
<option value="">— выбери —</option>
|
||||||
|
{% for op in operations %}
|
||||||
|
<option value="{{ op.id }}">{{ op.name }}{% if op.workshop %} ({{ op.workshop.name }}){% endif %}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2 d-flex align-items-end justify-content-end">
|
||||||
|
<button class="btn btn-outline-accent" type="submit">+</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -1,8 +1,35 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="card shadow border-secondary mb-3">
|
||||||
|
<div class="card-header border-secondary py-2 d-flex flex-wrap gap-2 justify-content-between align-items-center">
|
||||||
|
<div>
|
||||||
|
<nav aria-label="breadcrumb">
|
||||||
|
<ol class="breadcrumb mb-0">
|
||||||
|
<li class="breadcrumb-item"><a href="{% url 'products' %}">Изделия</a></li>
|
||||||
|
{% for bc in breadcrumbs %}
|
||||||
|
{% if forloop.last %}
|
||||||
|
<li class="breadcrumb-item active" aria-current="page">{{ bc.label }}</li>
|
||||||
|
{% else %}
|
||||||
|
<li class="breadcrumb-item"><a href="{{ bc.url }}">{{ bc.label }}</a></li>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
<div class="small text-muted">{{ entity.get_entity_type_display }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
{% include 'components/_add_to_deal.html' %}
|
||||||
|
<a class="btn btn-outline-secondary btn-sm" href="{{ next }}">Назад</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
<div class="container-fluid p-0">
|
<div class="container-fluid p-0">
|
||||||
<form method="post" action="{% url 'product_info' entity.id %}" enctype="multipart/form-data">
|
<form method="post" action="{% url 'product_info' entity.id %}" enctype="multipart/form-data">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<input type="hidden" name="action" value="save">
|
<input type="hidden" name="action" value="save">
|
||||||
<input type="hidden" name="next" value="{{ next }}">
|
<input type="hidden" name="next" value="{{ next }}">
|
||||||
|
<input type="hidden" name="trail" value="{{ request.GET.trail|default:'' }}">
|
||||||
|
|
||||||
<div class="row g-2">
|
<div class="row g-2">
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
@@ -38,15 +65,20 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if not can_edit %}
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<label class="form-label">Маршрут</label>
|
<label class="form-label">Техпроцесс</label>
|
||||||
<select class="form-select bg-body text-body border-secondary" name="route_id" {% if not can_edit %}disabled{% endif %}>
|
<div class="form-control bg-body text-body border-secondary">
|
||||||
<option value="">— не указано —</option>
|
{% if entity_ops %}
|
||||||
{% for r in routes %}
|
{% for eo in entity_ops %}
|
||||||
<option value="{{ r.id }}" {% if entity.route_id == r.id %}selected{% endif %}>{{ r.name }}</option>
|
{{ eo.seq }}. {{ eo.operation.name }}{% if eo.operation.workshop %} ({{ eo.operation.workshop.name }}){% endif %}{% if not forloop.last %} · {% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
{% else %}
|
||||||
|
— не указан —
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<div class="col-md-3">
|
<div class="col-md-3">
|
||||||
<label class="form-label">Толщина, мм</label>
|
<label class="form-label">Толщина, мм</label>
|
||||||
@@ -116,12 +148,81 @@
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
{% if can_edit %}
|
{% if can_edit %}
|
||||||
<form method="post" action="{% url 'product_info' entity.id %}" class="mt-3 d-flex gap-2 align-items-center">
|
<div class="mt-3">
|
||||||
{% csrf_token %}
|
<div class="fw-bold mb-2">Операции техпроцесса</div>
|
||||||
<input type="hidden" name="action" value="create_route">
|
|
||||||
<input type="hidden" name="next" value="{{ next }}">
|
<div class="table-responsive">
|
||||||
<input class="form-control bg-body text-body border-secondary" name="route_name" placeholder="Новый маршрут">
|
<table class="table table-hover mb-0 align-middle">
|
||||||
<button class="btn btn-outline-secondary" type="submit">Добавить маршрут</button>
|
<thead>
|
||||||
</form>
|
<tr class="table-custom-header">
|
||||||
|
<th style="width:70px;">№</th>
|
||||||
|
<th>Операция</th>
|
||||||
|
<th>Цех</th>
|
||||||
|
<th data-sort="false" class="text-end"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for eo in entity_ops %}
|
||||||
|
<tr>
|
||||||
|
<td class="text-muted">{{ eo.seq }}</td>
|
||||||
|
<td class="fw-bold">{{ eo.operation.name }}</td>
|
||||||
|
<td class="small text-muted">{% if eo.operation.workshop %}{{ eo.operation.workshop.name }}{% else %}—{% endif %}</td>
|
||||||
|
<td class="text-end">
|
||||||
|
<div class="btn-group btn-group-sm" role="group">
|
||||||
|
<form method="post" action="{% url 'product_info' entity.id %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="action" value="move_entity_operation">
|
||||||
|
<input type="hidden" name="direction" value="up">
|
||||||
|
<input type="hidden" name="entity_operation_id" value="{{ eo.id }}">
|
||||||
|
<input type="hidden" name="next" value="{{ next }}">
|
||||||
|
<button class="btn btn-outline-secondary" type="submit">↑</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="{% url 'product_info' entity.id %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="action" value="move_entity_operation">
|
||||||
|
<input type="hidden" name="direction" value="down">
|
||||||
|
<input type="hidden" name="entity_operation_id" value="{{ eo.id }}">
|
||||||
|
<input type="hidden" name="next" value="{{ next }}">
|
||||||
|
<button class="btn btn-outline-secondary" type="submit">↓</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="{% url 'product_info' entity.id %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="action" value="delete_entity_operation">
|
||||||
|
<input type="hidden" name="entity_operation_id" value="{{ eo.id }}">
|
||||||
|
<input type="hidden" name="next" value="{{ next }}">
|
||||||
|
<button class="btn btn-outline-secondary" type="submit">Удалить</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr><td colspan="4" class="text-center text-muted py-3">Операции не добавлены</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="post" action="{% url 'product_info' entity.id %}" class="row g-2 mt-2">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="action" value="add_entity_operation">
|
||||||
|
<input type="hidden" name="next" value="{{ next }}">
|
||||||
|
|
||||||
|
<div class="col-md-10">
|
||||||
|
<label class="form-label">Добавить операцию</label>
|
||||||
|
<select class="form-select bg-body text-body border-secondary" name="operation_id">
|
||||||
|
<option value="">— выбери —</option>
|
||||||
|
{% for op in operations %}
|
||||||
|
<option value="{{ op.id }}">{{ op.name }}{% if op.workshop %} ({{ op.workshop.name }}){% endif %}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2 d-flex align-items-end justify-content-end">
|
||||||
|
<button class="btn btn-outline-accent" type="submit">+</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -1,8 +1,35 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="card shadow border-secondary mb-3">
|
||||||
|
<div class="card-header border-secondary py-2 d-flex flex-wrap gap-2 justify-content-between align-items-center">
|
||||||
|
<div>
|
||||||
|
<nav aria-label="breadcrumb">
|
||||||
|
<ol class="breadcrumb mb-0">
|
||||||
|
<li class="breadcrumb-item"><a href="{% url 'products' %}">Изделия</a></li>
|
||||||
|
{% for bc in breadcrumbs %}
|
||||||
|
{% if forloop.last %}
|
||||||
|
<li class="breadcrumb-item active" aria-current="page">{{ bc.label }}</li>
|
||||||
|
{% else %}
|
||||||
|
<li class="breadcrumb-item"><a href="{{ bc.url }}">{{ bc.label }}</a></li>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
<div class="small text-muted">{{ entity.get_entity_type_display }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
{% include 'components/_add_to_deal.html' %}
|
||||||
|
<a class="btn btn-outline-secondary btn-sm" href="{{ next }}">Назад</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
<div class="container-fluid p-0">
|
<div class="container-fluid p-0">
|
||||||
<form method="post" action="{% url 'product_info' entity.id %}" enctype="multipart/form-data">
|
<form method="post" action="{% url 'product_info' entity.id %}" enctype="multipart/form-data">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<input type="hidden" name="action" value="save">
|
<input type="hidden" name="action" value="save">
|
||||||
<input type="hidden" name="next" value="{{ next }}">
|
<input type="hidden" name="next" value="{{ next }}">
|
||||||
|
<input type="hidden" name="trail" value="{{ request.GET.trail|default:'' }}">
|
||||||
|
|
||||||
<div class="row g-2">
|
<div class="row g-2">
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
@@ -33,15 +60,20 @@
|
|||||||
<input class="form-control bg-body text-body border-secondary" name="gost" value="{% if passport %}{{ passport.gost }}{% endif %}" {% if not can_edit %}disabled{% endif %}>
|
<input class="form-control bg-body text-body border-secondary" name="gost" value="{% if passport %}{{ passport.gost }}{% endif %}" {% if not can_edit %}disabled{% endif %}>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if not can_edit %}
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<label class="form-label">Маршрут</label>
|
<label class="form-label">Техпроцесс</label>
|
||||||
<select class="form-select bg-body text-body border-secondary" name="route_id" {% if not can_edit %}disabled{% endif %}>
|
<div class="form-control bg-body text-body border-secondary">
|
||||||
<option value="">— не указано —</option>
|
{% if entity_ops %}
|
||||||
{% for r in routes %}
|
{% for eo in entity_ops %}
|
||||||
<option value="{{ r.id }}" {% if entity.route_id == r.id %}selected{% endif %}>{{ r.name }}</option>
|
{{ eo.seq }}. {{ eo.operation.name }}{% if eo.operation.workshop %} ({{ eo.operation.workshop.name }}){% endif %}{% if not forloop.last %} · {% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
{% else %}
|
||||||
|
— не указан —
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<label class="form-label">Чертёж/паспорт (PDF)</label>
|
<label class="form-label">Чертёж/паспорт (PDF)</label>
|
||||||
@@ -68,12 +100,81 @@
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
{% if can_edit %}
|
{% if can_edit %}
|
||||||
<form method="post" action="{% url 'product_info' entity.id %}" class="mt-3 d-flex gap-2 align-items-center">
|
<div class="mt-3">
|
||||||
{% csrf_token %}
|
<div class="fw-bold mb-2">Операции техпроцесса</div>
|
||||||
<input type="hidden" name="action" value="create_route">
|
|
||||||
<input type="hidden" name="next" value="{{ next }}">
|
<div class="table-responsive">
|
||||||
<input class="form-control bg-body text-body border-secondary" name="route_name" placeholder="Новый маршрут">
|
<table class="table table-hover mb-0 align-middle">
|
||||||
<button class="btn btn-outline-secondary" type="submit">Добавить маршрут</button>
|
<thead>
|
||||||
</form>
|
<tr class="table-custom-header">
|
||||||
|
<th style="width:70px;">№</th>
|
||||||
|
<th>Операция</th>
|
||||||
|
<th>Цех</th>
|
||||||
|
<th data-sort="false" class="text-end"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for eo in entity_ops %}
|
||||||
|
<tr>
|
||||||
|
<td class="text-muted">{{ eo.seq }}</td>
|
||||||
|
<td class="fw-bold">{{ eo.operation.name }}</td>
|
||||||
|
<td class="small text-muted">{% if eo.operation.workshop %}{{ eo.operation.workshop.name }}{% else %}—{% endif %}</td>
|
||||||
|
<td class="text-end">
|
||||||
|
<div class="btn-group btn-group-sm" role="group">
|
||||||
|
<form method="post" action="{% url 'product_info' entity.id %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="action" value="move_entity_operation">
|
||||||
|
<input type="hidden" name="direction" value="up">
|
||||||
|
<input type="hidden" name="entity_operation_id" value="{{ eo.id }}">
|
||||||
|
<input type="hidden" name="next" value="{{ next }}">
|
||||||
|
<button class="btn btn-outline-secondary" type="submit">↑</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="{% url 'product_info' entity.id %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="action" value="move_entity_operation">
|
||||||
|
<input type="hidden" name="direction" value="down">
|
||||||
|
<input type="hidden" name="entity_operation_id" value="{{ eo.id }}">
|
||||||
|
<input type="hidden" name="next" value="{{ next }}">
|
||||||
|
<button class="btn btn-outline-secondary" type="submit">↓</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="{% url 'product_info' entity.id %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="action" value="delete_entity_operation">
|
||||||
|
<input type="hidden" name="entity_operation_id" value="{{ eo.id }}">
|
||||||
|
<input type="hidden" name="next" value="{{ next }}">
|
||||||
|
<button class="btn btn-outline-secondary" type="submit">Удалить</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr><td colspan="4" class="text-center text-muted py-3">Операции не добавлены</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="post" action="{% url 'product_info' entity.id %}" class="row g-2 mt-2">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="action" value="add_entity_operation">
|
||||||
|
<input type="hidden" name="next" value="{{ next }}">
|
||||||
|
|
||||||
|
<div class="col-md-10">
|
||||||
|
<label class="form-label">Добавить операцию</label>
|
||||||
|
<select class="form-select bg-body text-body border-secondary" name="operation_id">
|
||||||
|
<option value="">— выбери —</option>
|
||||||
|
{% for op in operations %}
|
||||||
|
<option value="{{ op.id }}">{{ op.name }}{% if op.workshop %} ({{ op.workshop.name }}){% endif %}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2 d-flex align-items-end justify-content-end">
|
||||||
|
<button class="btn btn-outline-accent" type="submit">+</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -12,16 +12,20 @@
|
|||||||
</button>
|
</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<form class="d-flex gap-2 align-items-center" method="get" action="{% url 'products' %}">
|
<form class="d-flex flex-wrap gap-2 align-items-center" method="get" action="{% url 'products' %}">
|
||||||
<select class="form-select form-select-sm bg-body text-body border-secondary" name="entity_type" style="min-width: 220px;">
|
<div class="btn-group" role="group" aria-label="Фильтр типов">
|
||||||
<option value="" {% if not entity_type %}selected{% endif %}>Все типы</option>
|
<input type="checkbox" class="btn-check" id="tProduct" name="types" value="product" {% if 'product' in entity_types %}checked{% endif %}>
|
||||||
<option value="product" {% if entity_type == 'product' %}selected{% endif %}>Готовое изделие</option>
|
<label class="btn btn-outline-secondary btn-sm" for="tProduct">Изделие</label>
|
||||||
<option value="assembly" {% if entity_type == 'assembly' %}selected{% endif %}>Сборочная единица</option>
|
|
||||||
<option value="part" {% if entity_type == 'part' %}selected{% endif %}>Деталь</option>
|
<input type="checkbox" class="btn-check" id="tAssembly" name="types" value="assembly" {% if 'assembly' in entity_types %}checked{% endif %}>
|
||||||
<option value="purchased" {% if entity_type == 'purchased' %}selected{% endif %}>Покупное</option>
|
<label class="btn btn-outline-secondary btn-sm" for="tAssembly">СБ</label>
|
||||||
<option value="casting" {% if entity_type == 'casting' %}selected{% endif %}>Литьё</option>
|
|
||||||
<option value="outsourced" {% if entity_type == 'outsourced' %}selected{% endif %}>Аутсорс</option>
|
<input type="checkbox" class="btn-check" id="tPart" name="types" value="part" {% if 'part' in entity_types %}checked{% endif %}>
|
||||||
</select>
|
<label class="btn btn-outline-secondary btn-sm" for="tPart">Деталь</label>
|
||||||
|
|
||||||
|
<input type="checkbox" class="btn-check" id="tCasting" name="types" value="casting" {% if 'casting' in entity_types %}checked{% endif %}>
|
||||||
|
<label class="btn btn-outline-secondary btn-sm" for="tCasting">Литьё</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<input class="form-control form-control-sm bg-body text-body border-secondary" name="q" value="{{ q }}" placeholder="Поиск (обозначение/наименование)" style="min-width: 320px;">
|
<input class="form-control form-control-sm bg-body text-body border-secondary" name="q" value="{{ q }}" placeholder="Поиск (обозначение/наименование)" style="min-width: 320px;">
|
||||||
<button class="btn btn-outline-secondary btn-sm" type="submit"><i class="bi bi-search me-1"></i>Найти</button>
|
<button class="btn btn-outline-secondary btn-sm" type="submit"><i class="bi bi-search me-1"></i>Найти</button>
|
||||||
@@ -39,12 +43,11 @@
|
|||||||
<th>Наименование</th>
|
<th>Наименование</th>
|
||||||
<th>Материал</th>
|
<th>Материал</th>
|
||||||
<th>Заполнен</th>
|
<th>Заполнен</th>
|
||||||
<th data-sort="false"></th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for p in products %}
|
{% for p in products %}
|
||||||
<tr class="product-row" role="button" data-info-url="{% url 'product_info' p.id %}">
|
<tr class="product-row" role="button" data-href="{% url 'product_info' p.id %}?next={{ request.get_full_path|urlencode }}">
|
||||||
<td class="small text-muted">{{ p.get_entity_type_display }}</td>
|
<td class="small text-muted">{{ p.get_entity_type_display }}</td>
|
||||||
<td class="fw-bold">{{ p.drawing_number|default:"—" }}</td>
|
<td class="fw-bold">{{ p.drawing_number|default:"—" }}</td>
|
||||||
<td>{{ p.name }}</td>
|
<td>{{ p.name }}</td>
|
||||||
@@ -62,11 +65,6 @@
|
|||||||
<i class="bi bi-circle text-muted" title="Не заполнено"></i>
|
<i class="bi bi-circle text-muted" title="Не заполнено"></i>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="text-end">
|
|
||||||
<a class="btn btn-outline-accent btn-sm" href="{% url 'product_detail' p.id %}" onclick="event.stopPropagation();">
|
|
||||||
{% if p.entity_type == 'product' or p.entity_type == 'assembly' %}Состав{% else %}Открыть{% endif %}
|
|
||||||
</a>
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
{% empty %}
|
{% empty %}
|
||||||
<tr><td colspan="5" class="text-center text-muted py-4">Пока ничего нет</td></tr>
|
<tr><td colspan="5" class="text-center text-muted py-4">Пока ничего нет</td></tr>
|
||||||
@@ -115,46 +113,32 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="modal fade" id="productInfoModal" tabindex="-1" aria-hidden="true">
|
|
||||||
<div class="modal-dialog modal-xl">
|
|
||||||
<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" id="productInfoBody">
|
|
||||||
<div class="text-muted">Загрузка...</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
const modalEl = document.getElementById('productInfoModal');
|
document.querySelectorAll('tr.product-row[data-href]').forEach(tr => {
|
||||||
const bodyEl = document.getElementById('productInfoBody');
|
tr.addEventListener('click', () => {
|
||||||
if (!modalEl || !bodyEl) return;
|
const url = tr.getAttribute('data-href');
|
||||||
|
if (url) window.location.href = url;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
const modal = new bootstrap.Modal(modalEl);
|
const form = document.querySelector('form[action="{% url 'products' %}"][method="get"]');
|
||||||
|
if (!form) return;
|
||||||
|
|
||||||
async function openInfo(url) {
|
function ensureAtLeastOneTypeChecked() {
|
||||||
bodyEl.innerHTML = '<div class="text-muted">Загрузка...</div>';
|
const boxes = Array.from(form.querySelectorAll('input[type="checkbox"][name="types"]'));
|
||||||
modal.show();
|
if (!boxes.length) return;
|
||||||
try {
|
const anyChecked = boxes.some(b => b.checked);
|
||||||
const nextUrl = encodeURIComponent(window.location.pathname + window.location.search);
|
if (!anyChecked) {
|
||||||
const sep = url.includes('?') ? '&' : '?';
|
const productBox = boxes.find(b => b.value === 'product');
|
||||||
const res = await fetch(url + sep + 'next=' + nextUrl, { credentials: 'same-origin' });
|
if (productBox) productBox.checked = true;
|
||||||
bodyEl.innerHTML = await res.text();
|
|
||||||
} catch (e) {
|
|
||||||
bodyEl.innerHTML = '<div class="alert alert-danger">Не удалось загрузить информацию.</div>';
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
document.querySelectorAll('tr.product-row[data-info-url]').forEach(tr => {
|
form.querySelectorAll('input[type="checkbox"][name="types"]').forEach(cb => {
|
||||||
tr.addEventListener('click', () => {
|
cb.addEventListener('change', () => {
|
||||||
const url = tr.getAttribute('data-info-url');
|
ensureAtLeastOneTypeChecked();
|
||||||
if (!url) return;
|
form.submit();
|
||||||
openInfo(url);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,12 +7,12 @@
|
|||||||
<div class="card-header border-secondary py-3 d-flex justify-content-between align-items-center">
|
<div class="card-header border-secondary py-3 d-flex justify-content-between align-items-center">
|
||||||
<h3 class="text-accent mb-0"><i class="bi bi-list-task me-2"></i>Реестр заданий</h3>
|
<h3 class="text-accent mb-0"><i class="bi bi-list-task me-2"></i>Реестр заданий</h3>
|
||||||
{% if user_role in 'admin,technologist,master' %}
|
{% if user_role in 'admin,technologist,master' %}
|
||||||
<a class="btn btn-outline-secondary btn-sm" target="_blank" href="{% url 'registry_print' %}?{{ request.GET.urlencode }}">
|
<a class="btn btn-outline-secondary btn-sm" target="_blank" href="{% url 'registry_workitems_print' %}?{{ request.GET.urlencode }}">
|
||||||
<i class="bi bi-printer me-1"></i>Печать
|
<i class="bi bi-printer me-1"></i>Печать
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% include 'shiftflow/partials/_items_table.html' with items=items %}
|
{% include 'shiftflow/partials/_workitems_table.html' with workitems=workitems %}
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
86
shiftflow/templates/shiftflow/registry_workitems_print.html
Normal file
86
shiftflow/templates/shiftflow/registry_workitems_print.html
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
{% load static %}
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Реестр заданий (WorkItem)</title>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="{% static 'css/style.css' %}">
|
||||||
|
<style>
|
||||||
|
body { background: #fff; color: #000; }
|
||||||
|
.print-table { width: 100%; border-collapse: collapse; }
|
||||||
|
.print-table th, .print-table td { border: 1px solid #000; padding: 4px 6px; font-size: 12px; vertical-align: top; }
|
||||||
|
.print-header { display: flex; justify-content: space-between; align-items: flex-end; margin-bottom: 8px; }
|
||||||
|
.print-title { font-size: 16px; font-weight: 700; margin: 0; }
|
||||||
|
.print-meta { font-size: 12px; }
|
||||||
|
.center { text-align: center; }
|
||||||
|
@media print {
|
||||||
|
.no-print { display: none !important; }
|
||||||
|
.page { page-break-after: always; }
|
||||||
|
.page:last-child { page-break-after: auto; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container-fluid my-3 no-print d-flex gap-2">
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" onclick="window.print()">Печать</button>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" onclick="window.close()">Закрыть</button>
|
||||||
|
<div class="ms-auto small text-muted">
|
||||||
|
{{ printed_at|date:"d.m.Y H:i" }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% for g in groups %}
|
||||||
|
<div class="container-fluid page my-3">
|
||||||
|
<div class="print-header">
|
||||||
|
<div>
|
||||||
|
<h1 class="print-title">Реестр заданий</h1>
|
||||||
|
<div class="print-meta">
|
||||||
|
Цех: <strong>{{ g.workshop }}</strong>
|
||||||
|
{% if g.machine %} · Станок: <strong>{{ g.machine }}</strong>{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="print-meta text-end">
|
||||||
|
{% if print_date %}Дата: <strong>{{ print_date|date:"d.m.y" }}</strong>{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table class="print-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width:80px;">Дата</th>
|
||||||
|
<th style="width:90px;">Сделка</th>
|
||||||
|
<th style="width:180px;">Операция</th>
|
||||||
|
<th>Позиция</th>
|
||||||
|
<th style="width:160px;">Материал</th>
|
||||||
|
<th style="width:80px;" class="center">План</th>
|
||||||
|
<th style="width:80px;" class="center">Факт</th>
|
||||||
|
<th style="width:90px;">Статус</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for wi in g.items %}
|
||||||
|
<tr>
|
||||||
|
<td class="center">{{ wi.date|date:"d.m.y" }}</td>
|
||||||
|
<td class="center">{{ wi.deal.number|default:"-" }}</td>
|
||||||
|
<td>{{ wi.operation.name|default:wi.stage|default:"—" }}</td>
|
||||||
|
<td>{{ wi.entity.drawing_number|default:"—" }} {{ wi.entity.name }}</td>
|
||||||
|
<td>
|
||||||
|
{% if wi.entity.planned_material %}
|
||||||
|
{{ wi.entity.planned_material.full_name|default:wi.entity.planned_material.name }}
|
||||||
|
{% else %}
|
||||||
|
—
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="center">{{ wi.quantity_plan }}</td>
|
||||||
|
<td class="center">{{ wi.quantity_done }}</td>
|
||||||
|
<td>{{ wi.status|default:"planned" }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
135
shiftflow/templates/shiftflow/steel_grades_catalog.html
Normal file
135
shiftflow/templates/shiftflow/steel_grades_catalog.html
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="card shadow border-secondary mb-3">
|
||||||
|
<div class="card-header border-secondary py-3 d-flex flex-wrap justify-content-between align-items-center gap-2">
|
||||||
|
<div>
|
||||||
|
<nav aria-label="breadcrumb" class="small">
|
||||||
|
<ol class="breadcrumb mb-1">
|
||||||
|
<li class="breadcrumb-item"><a class="text-decoration-none" href="{% url 'directories' %}">Справочники</a></li>
|
||||||
|
<li class="breadcrumb-item active" aria-current="page">Марки стали</li>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
<h3 class="text-accent mb-0"><i class="bi bi-grid-3x3-gap me-2"></i>Марки стали</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex flex-wrap gap-2 align-items-center">
|
||||||
|
<form method="get" class="d-flex gap-2 align-items-center">
|
||||||
|
<input class="form-control form-control-sm bg-body text-body border-secondary" name="q" value="{{ q }}" placeholder="Поиск...">
|
||||||
|
<button class="btn btn-outline-accent btn-sm" type="submit"><i class="bi bi-search me-1"></i>Найти</button>
|
||||||
|
<a class="btn btn-outline-accent btn-sm" href="{% url 'steel_grades_catalog' %}"><i class="bi bi-arrow-counterclockwise me-1"></i>Сброс</a>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<a class="btn btn-outline-accent btn-sm" href="{% url 'directories' %}"><i class="bi bi-arrow-left me-1"></i>Назад</a>
|
||||||
|
|
||||||
|
{% if can_edit %}
|
||||||
|
<button class="btn btn-outline-accent btn-sm" type="button" data-bs-toggle="modal" data-bs-target="#gradeModal" onclick="openGradeCreate()">
|
||||||
|
<i class="bi bi-plus-lg me-1"></i>Создать
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover mb-0 align-middle">
|
||||||
|
<thead>
|
||||||
|
<tr class="table-custom-header">
|
||||||
|
<th>Название</th>
|
||||||
|
<th>ГОСТ/ТУ</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for g in grades %}
|
||||||
|
<tr role="button" {% if can_edit %}onclick="openGradeEdit(this)"{% endif %}
|
||||||
|
data-id="{{ g.id }}" data-name="{{ g.name }}" data-gost="{{ g.gost_standard }}">
|
||||||
|
<td class="fw-bold">{{ g.name }}</td>
|
||||||
|
<td>{{ g.gost_standard|default:"—" }}</td>
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr><td colspan="2" class="text-center text-muted py-4">Нет данных</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal fade" id="gradeModal" tabindex="-1" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-lg">
|
||||||
|
<form class="modal-content border-secondary" onsubmit="event.preventDefault(); saveGrade();">
|
||||||
|
<div class="modal-header border-secondary">
|
||||||
|
<h5 class="modal-title" id="gradeModalTitle">Марка стали</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-body">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" id="gradeId">
|
||||||
|
|
||||||
|
<div class="row g-2">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Название</label>
|
||||||
|
<input class="form-control bg-body text-body border-secondary" id="gradeName" required {% 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" id="gradeGost" {% if not can_edit %}disabled{% endif %}>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-footer border-secondary">
|
||||||
|
<button type="button" class="btn btn-outline-accent" data-bs-dismiss="modal">Отмена</button>
|
||||||
|
{% if can_edit %}
|
||||||
|
<button type="submit" class="btn btn-outline-accent">Сохранить</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function getCookie(name) {
|
||||||
|
const value = `; ${document.cookie}`;
|
||||||
|
const parts = value.split(`; ${name}=`);
|
||||||
|
if (parts.length === 2) return parts.pop().split(';').shift();
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function openGradeCreate() {
|
||||||
|
document.getElementById('gradeModalTitle').textContent = 'Марка стали (создание)';
|
||||||
|
document.getElementById('gradeId').value = '';
|
||||||
|
document.getElementById('gradeName').value = '';
|
||||||
|
document.getElementById('gradeGost').value = '';
|
||||||
|
new bootstrap.Modal(document.getElementById('gradeModal')).show();
|
||||||
|
}
|
||||||
|
|
||||||
|
function openGradeEdit(tr) {
|
||||||
|
document.getElementById('gradeModalTitle').textContent = 'Марка стали (правка)';
|
||||||
|
document.getElementById('gradeId').value = tr.getAttribute('data-id') || '';
|
||||||
|
document.getElementById('gradeName').value = tr.getAttribute('data-name') || '';
|
||||||
|
document.getElementById('gradeGost').value = tr.getAttribute('data-gost') || '';
|
||||||
|
new bootstrap.Modal(document.getElementById('gradeModal')).show();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveGrade() {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('id', document.getElementById('gradeId').value);
|
||||||
|
fd.append('name', document.getElementById('gradeName').value);
|
||||||
|
fd.append('gost_standard', document.getElementById('gradeGost').value);
|
||||||
|
|
||||||
|
const res = await fetch("{% url 'steel_grade_upsert' %}", {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: { 'X-CSRFToken': getCookie('csrftoken') },
|
||||||
|
body: fd,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
alert('Не удалось сохранить марку стали');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
141
shiftflow/templates/shiftflow/supply_catalog.html
Normal file
141
shiftflow/templates/shiftflow/supply_catalog.html
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="card shadow border-secondary mb-3">
|
||||||
|
<div class="card-header border-secondary py-3 d-flex flex-wrap gap-2 justify-content-between align-items-center">
|
||||||
|
<div>
|
||||||
|
<nav aria-label="breadcrumb" class="small">
|
||||||
|
<ol class="breadcrumb mb-1">
|
||||||
|
<li class="breadcrumb-item"><a class="text-decoration-none" href="{% url 'directories' %}">Справочники</a></li>
|
||||||
|
<li class="breadcrumb-item active" aria-current="page">Номенклатура снабжения</li>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
<h3 class="text-accent mb-0"><i class="bi bi-box-seam me-2"></i>Номенклатура снабжения</h3>
|
||||||
|
<div class="small text-muted">Покупное / Аутсорс</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex flex-wrap gap-2 align-items-center">
|
||||||
|
<a class="btn btn-outline-accent btn-sm" href="{% url 'directories' %}"><i class="bi bi-arrow-left me-1"></i>Назад</a>
|
||||||
|
|
||||||
|
{% if can_edit %}
|
||||||
|
<button type="button" class="btn btn-outline-accent btn-sm" data-bs-toggle="modal" data-bs-target="#createSupplyModal">
|
||||||
|
<i class="bi bi-plus-lg me-1"></i>Создать
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form class="d-flex flex-wrap gap-2 align-items-center" method="get" action="{% url 'supply_catalog' %}">
|
||||||
|
<div class="btn-group" role="group" aria-label="Фильтр типов">
|
||||||
|
{% for code, label in type_choices %}
|
||||||
|
<input type="checkbox" class="btn-check" id="t{{ code }}" name="types" value="{{ code }}" {% if code in entity_types %}checked{% endif %}>
|
||||||
|
<label class="btn btn-outline-secondary btn-sm" for="t{{ code }}">{{ label }}</label>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input class="form-control form-control-sm bg-body text-body border-secondary" name="q" value="{{ q }}" placeholder="Поиск (обозначение/наименование)" style="min-width: 320px;">
|
||||||
|
<button class="btn btn-outline-accent btn-sm" type="submit"><i class="bi bi-search me-1"></i>Найти</button>
|
||||||
|
<a class="btn btn-outline-accent btn-sm" href="{% url 'supply_catalog' %}"><i class="bi bi-arrow-counterclockwise me-1"></i>Сброс</a>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</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>Заполнен</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for p in items %}
|
||||||
|
<tr class="product-row" role="button" data-href="{% url 'product_info' p.id %}?next={{ request.get_full_path|urlencode }}">
|
||||||
|
<td class="small text-muted">{{ p.get_entity_type_display }}</td>
|
||||||
|
<td class="fw-bold">{{ p.drawing_number|default:"—" }}</td>
|
||||||
|
<td>{{ p.name }}</td>
|
||||||
|
<td>
|
||||||
|
{% if p.passport_filled %}
|
||||||
|
<i class="bi bi-check-circle-fill text-success" title="Заполнено"></i>
|
||||||
|
{% else %}
|
||||||
|
<i class="bi bi-circle text-muted" title="Не заполнено"></i>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr><td colspan="4" class="text-center text-muted py-4">Пока ничего нет</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal fade" id="createSupplyModal" tabindex="-1" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-lg">
|
||||||
|
<form method="post" action="{% url 'supply_catalog' %}" class="modal-content border-secondary">
|
||||||
|
{% csrf_token %}
|
||||||
|
<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="row g-2">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label">Тип</label>
|
||||||
|
<select class="form-select" name="entity_type" required>
|
||||||
|
<option value="purchased">Покупное</option>
|
||||||
|
<option value="outsourced">Аутсорс</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label">Обозначение/Артикул</label>
|
||||||
|
<input class="form-control" name="drawing_number" placeholder="Напр. МАСКА-001">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label">Наименование</label>
|
||||||
|
<input class="form-control" name="name" placeholder="Напр. Маска сварочная хамелеон" required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-footer border-secondary">
|
||||||
|
<button type="button" class="btn btn-outline-accent" data-bs-dismiss="modal">Отмена</button>
|
||||||
|
<button type="submit" class="btn btn-outline-accent">Создать</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
document.querySelectorAll('tr.product-row[data-href]').forEach(tr => {
|
||||||
|
tr.addEventListener('click', () => {
|
||||||
|
const url = tr.getAttribute('data-href');
|
||||||
|
if (url) window.location.href = url;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const form = document.querySelector('form[action="{% url 'supply_catalog' %}"][method="get"]');
|
||||||
|
if (!form) return;
|
||||||
|
|
||||||
|
function ensureAtLeastOneTypeChecked() {
|
||||||
|
const boxes = Array.from(form.querySelectorAll('input[type="checkbox"][name="types"]'));
|
||||||
|
if (!boxes.length) return;
|
||||||
|
const anyChecked = boxes.some(b => b.checked);
|
||||||
|
if (!anyChecked) {
|
||||||
|
boxes[0].checked = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
form.querySelectorAll('input[type="checkbox"][name="types"]').forEach(cb => {
|
||||||
|
cb.addEventListener('change', () => {
|
||||||
|
ensureAtLeastOneTypeChecked();
|
||||||
|
form.submit();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
308
shiftflow/templates/shiftflow/workitem_detail.html
Normal file
308
shiftflow/templates/shiftflow/workitem_detail.html
Normal file
@@ -0,0 +1,308 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-lg-10 col-xl-8">
|
||||||
|
<div class="card shadow-sm border-secondary mb-4">
|
||||||
|
<div class="card-header border-secondary d-flex justify-content-between align-items-center py-3">
|
||||||
|
<h3 class="text-accent mb-0">
|
||||||
|
<i class="bi bi-info-circle me-2"></i>
|
||||||
|
<a href="{% url 'workitem_entity_list' workitem.deal.id workitem.entity.id %}" class="text-decoration-none text-reset">
|
||||||
|
{{ workitem.entity.drawing_number|default:"—" }} {{ workitem.entity.name }}
|
||||||
|
</a>
|
||||||
|
{% if can_edit_entity %}
|
||||||
|
<a class="ms-2 text-decoration-none" href="{% url 'product_info' workitem.entity.id %}?next={{ request.path|urlencode }}" title="Открыть паспорт">
|
||||||
|
<i class="bi bi-pencil-square"></i>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</h3>
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<a href="{% url 'planning_deal' workitem.deal.id %}" class="text-decoration-none">
|
||||||
|
<span class="badge bg-secondary">Сделка № {{ workitem.deal.number }}</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{% if user_role in 'admin,master,operator,prod_head' and close_url %}
|
||||||
|
<a class="btn btn-outline-warning btn-sm" href="{{ close_url }}">
|
||||||
|
<i class="bi bi-check2-square me-1"></i>{{ close_label|default:'Закрыть' }}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if user_role in 'admin,technologist,master,clerk' %}
|
||||||
|
{% if workitem.entity.entity_type == 'product' or workitem.entity.entity_type == 'assembly' %}
|
||||||
|
<a class="btn btn-outline-accent btn-sm" href="{% url 'workitem_kitting' workitem.id %}?next={{ request.get_full_path|urlencode }}">
|
||||||
|
<i class="bi bi-box-seam me-1"></i>Комплектация
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<form method="post" action="{% url 'workitem_update' %}" class="mb-4" id="workitemForm">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="workitem_id" value="{{ workitem.id }}">
|
||||||
|
<input type="hidden" name="next" value="{{ request.get_full_path }}">
|
||||||
|
|
||||||
|
<div class="row g-3 mb-4 border-bottom border-secondary pb-3 text-body">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<small class="text-muted d-block">Дата</small>
|
||||||
|
{% if user_role in 'admin,technologist' %}
|
||||||
|
<input type="date" class="form-control border-secondary" name="date" value="{{ workitem.date|date:'Y-m-d' }}">
|
||||||
|
{% else %}
|
||||||
|
<strong>{{ workitem.date|date:"d.m.Y" }}</strong>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<small class="text-muted d-block">Цех/Пост</small>
|
||||||
|
{% if user_role in 'admin,technologist,master' %}
|
||||||
|
<select name="machine_id" class="form-select border-secondary">
|
||||||
|
<option value="">— без станка —</option>
|
||||||
|
{% for m in machines %}
|
||||||
|
<option value="{{ m.id }}" {% if workitem.machine_id == m.id %}selected{% endif %}>{{ m.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
{% else %}
|
||||||
|
<strong>
|
||||||
|
{% if workitem.machine %}{{ workitem.machine.name }}{% elif workitem.workshop %}{{ workitem.workshop.name }}{% else %}—{% endif %}
|
||||||
|
</strong>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<small class="text-muted d-block">Операция</small>
|
||||||
|
<strong>{% if workitem.operation %}{{ workitem.operation.name }}{% else %}{{ workitem.stage|default:"—" }}{% endif %}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-3 mb-4 border-bottom border-secondary pb-3 text-body">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<small class="text-muted d-block">Материал (паспорт)</small>
|
||||||
|
<strong>
|
||||||
|
{% if workitem.entity.planned_material %}
|
||||||
|
{{ workitem.entity.planned_material.full_name|default:workitem.entity.planned_material.name }}
|
||||||
|
{% else %}
|
||||||
|
—
|
||||||
|
{% endif %}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<small class="text-muted d-block">План</small>
|
||||||
|
{% if user_role in 'admin,technologist' %}
|
||||||
|
<input type="number" min="0" class="form-control border-secondary" name="quantity_plan" value="{{ workitem.quantity_plan }}">
|
||||||
|
{% else %}
|
||||||
|
<strong class="text-info fs-5">{{ workitem.quantity_plan }} шт.</strong>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<small class="text-muted d-block">Факт</small>
|
||||||
|
{% if user_role in 'admin,technologist,master,operator' %}
|
||||||
|
<input type="number" min="0" class="form-control border-secondary" name="quantity_done" id="wiDone" value="{{ workitem.quantity_done }}">
|
||||||
|
{% else %}
|
||||||
|
<strong class="text-success fs-5">{{ workitem.quantity_done }} шт.</strong>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-3 mb-4 border-bottom border-secondary pb-3 text-body">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<small class="text-muted d-block">Статус задания</small>
|
||||||
|
{% if user_role in 'admin,technologist' %}
|
||||||
|
<select class="form-select border-secondary" name="workitem_status">
|
||||||
|
{% for val, label in workitem_status_choices %}
|
||||||
|
<option value="{{ val }}" {% if workitem.status == val %}selected{% endif %}>{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
{% else %}
|
||||||
|
<strong>{{ workitem.get_status_display }}</strong>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12">
|
||||||
|
{% if workitem.comment %}
|
||||||
|
<div class="alert alert-warning border border-warning sf-attention mb-3">
|
||||||
|
<div class="d-flex align-items-start gap-2">
|
||||||
|
<i class="bi bi-exclamation-triangle"></i>
|
||||||
|
<div class="fw-bold">{{ workitem.comment }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if user_role in 'admin,technologist,master' %}
|
||||||
|
<small class="text-muted d-block">Комментарий</small>
|
||||||
|
<textarea class="form-control border-secondary" name="comment" rows="2" placeholder="Указания/заметки">{{ workitem.comment }}</textarea>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{% if workitem.entity.entity_type == 'part' %}
|
||||||
|
<div class="mb-4">
|
||||||
|
<div class="small text-muted mb-2">Превью</div>
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-8">
|
||||||
|
<div class="border border-secondary rounded p-2" style="height: 200px; overflow: hidden;">
|
||||||
|
{% if workitem.entity.preview %}
|
||||||
|
<img src="{{ workitem.entity.preview.url }}" alt="Превью" style="max-width:100%; max-height:100%; object-fit:contain; display:block; margin:0 auto;">
|
||||||
|
{% else %}
|
||||||
|
<div style="width:100%; height:100%;"></div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="d-flex align-items-center gap-2 mb-2">
|
||||||
|
{% if workitem.entity.dxf_file %}
|
||||||
|
<a href="{{ workitem.entity.dxf_file.url }}" target="_blank" class="btn btn-sm btn-outline-info p-1" title="DXF/STEP">
|
||||||
|
<i class="bi bi-file-earmark-code"></i>
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<span class="btn btn-sm btn-outline-secondary p-1 disabled" title="DXF/STEP">
|
||||||
|
<i class="bi bi-file-earmark-code"></i>
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
<div class="small text-muted">DXF/STEP</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="d-flex align-items-center gap-2 mb-2">
|
||||||
|
{% if workitem.entity.pdf_main %}
|
||||||
|
<a href="{{ workitem.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>
|
||||||
|
{% else %}
|
||||||
|
<span class="btn btn-sm btn-outline-secondary p-1 disabled" title="PDF">
|
||||||
|
<i class="bi bi-file-pdf"></i>
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
<div class="small text-muted">PDF</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if can_edit_entity and workitem.entity.dxf_file %}
|
||||||
|
<form method="post" action="{% url 'product_preview_update' workitem.entity.id %}" class="mt-3">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="next" value="{{ request.path }}">
|
||||||
|
<button type="submit" class="btn btn-outline-secondary btn-sm">
|
||||||
|
{% if workitem.entity.preview %}Обновить превью DXF{% else %}Сгенерировать превью DXF{% endif %}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="mb-4">
|
||||||
|
<div class="small text-muted mb-2">Файлы</div>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
{% if workitem.entity.dxf_file %}
|
||||||
|
<a href="{{ workitem.entity.dxf_file.url }}" target="_blank" class="btn btn-sm btn-outline-info" title="DXF/STEP">
|
||||||
|
<i class="bi bi-file-earmark-code me-1"></i>DXF/STEP
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
{% if workitem.entity.pdf_main %}
|
||||||
|
<a href="{{ workitem.entity.pdf_main.url }}" target="_blank" class="btn btn-sm btn-outline-danger" title="PDF">
|
||||||
|
<i class="bi bi-file-pdf me-1"></i>PDF
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
{% if not workitem.entity.dxf_file and not workitem.entity.pdf_main %}
|
||||||
|
<div class="text-muted">—</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if workitem.entity.entity_type == 'product' or workitem.entity.entity_type == 'assembly' %}
|
||||||
|
<div class="mb-4">
|
||||||
|
<div class="fw-bold mb-2">Паспорт сборки</div>
|
||||||
|
<div class="row g-2">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="small text-muted">Масса, кг</div>
|
||||||
|
<div>{{ passport.weight_kg|default:"—" }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="small text-muted">Покрытие</div>
|
||||||
|
<div>{{ passport.coating|default:"—" }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="small text-muted">Цвет</div>
|
||||||
|
<div>{{ passport.coating_color|default:"—" }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="small text-muted">Площадь покрытия, м²</div>
|
||||||
|
<div>{{ passport.coating_area_m2|default:"—" }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="small text-muted">Сварка</div>
|
||||||
|
<div>{% if passport.requires_welding %}Да{% else %}Нет{% endif %}</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="small text-muted">Покраска</div>
|
||||||
|
<div>{% if passport.requires_painting %}Да{% else %}Нет{% endif %}</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="small text-muted">Технические требования</div>
|
||||||
|
<div class="border border-secondary rounded p-2 bg-body">{{ passport.technical_requirements|default:"—"|linebreaksbr }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<div class="fw-bold mb-2">Сварочные швы</div>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm table-hover mb-0 align-middle">
|
||||||
|
<thead>
|
||||||
|
<tr class="table-custom-header">
|
||||||
|
<th>Наименование</th>
|
||||||
|
<th class="text-center" style="width:120px;">Катет, мм</th>
|
||||||
|
<th class="text-center" style="width:120px;">Длина, мм</th>
|
||||||
|
<th class="text-center" style="width:120px;">Кол-во</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for s in welding_seams %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ s.name }}</td>
|
||||||
|
<td class="text-center">{{ s.leg_mm }}</td>
|
||||||
|
<td class="text-center">{{ s.length_mm }}</td>
|
||||||
|
<td class="text-center">{{ s.quantity }}</td>
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr><td colspan="4" class="text-center text-muted py-3">Швов нет</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-between mt-4">
|
||||||
|
<a href="{{ back_url }}" class="btn btn-outline-secondary">Назад</a>
|
||||||
|
{% if user_role in 'admin,technologist,master,operator' %}
|
||||||
|
<button type="button" class="btn btn-outline-accent px-4 fw-bold" onclick="document.getElementById('workitemForm')?.requestSubmit()">
|
||||||
|
<i class="bi bi-save me-2"></i>Сохранить
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
const done = document.getElementById('wiDone');
|
||||||
|
const form = document.getElementById('workitemForm');
|
||||||
|
if (done) {
|
||||||
|
done.focus({ preventScroll: true });
|
||||||
|
done.select();
|
||||||
|
done.addEventListener('keydown', function (e) {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (form) form.requestSubmit();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
29
shiftflow/templates/shiftflow/workitem_entity_list.html
Normal file
29
shiftflow/templates/shiftflow/workitem_entity_list.html
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
{% 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-list-task me-2"></i>{{ entity.drawing_number|default:"—" }} {{ entity.name }}
|
||||||
|
</h3>
|
||||||
|
<div class="small text-muted">
|
||||||
|
Сделка {{ deal.number }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
{% if can_edit_entity %}
|
||||||
|
<a class="btn btn-outline-secondary btn-sm" href="{% url 'product_info' entity.id %}?next={{ request.path|urlencode }}">
|
||||||
|
<i class="bi bi-pencil-square me-1"></i>Паспорт
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
<a class="btn btn-outline-secondary btn-sm" href="{{ back_url }}">
|
||||||
|
<i class="bi bi-arrow-left me-1"></i>Назад
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% include 'shiftflow/partials/_workitems_table.html' with workitems=workitems %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
117
shiftflow/templates/shiftflow/workitem_kitting.html
Normal file
117
shiftflow/templates/shiftflow/workitem_kitting.html
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-accent mb-1">
|
||||||
|
<i class="bi bi-box-seam me-2"></i>Комплектация
|
||||||
|
</h3>
|
||||||
|
<div class="small text-muted">
|
||||||
|
Сделка № {{ workitem.deal.number }} · {{ workitem.entity.drawing_number|default:"—" }} {{ workitem.entity.name }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<a class="btn btn-outline-secondary btn-sm" href="{{ back_url }}">
|
||||||
|
<i class="bi bi-arrow-left me-1"></i>Назад
|
||||||
|
</a>
|
||||||
|
{% if draft %}
|
||||||
|
<a class="btn btn-outline-accent btn-sm" href="{% url 'workitem_kitting_print' workitem.id %}" target="_blank" rel="noopener">
|
||||||
|
<i class="bi bi-printer me-1"></i>Печать
|
||||||
|
</a>
|
||||||
|
<form method="post" class="d-inline">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="action" value="clear">
|
||||||
|
<input type="hidden" name="next" value="{{ request.get_full_path }}">
|
||||||
|
<button class="btn btn-outline-secondary btn-sm" type="submit">Очистить лист</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card shadow-sm border-secondary">
|
||||||
|
<div class="card-header border-secondary d-flex justify-content-between align-items-center">
|
||||||
|
<div class="fw-bold">Потребные компоненты</div>
|
||||||
|
<div class="small text-muted">
|
||||||
|
{% if to_location %}
|
||||||
|
Куда: {{ to_location.name }} · Кол-во: {{ qty_to_make }} шт
|
||||||
|
{% else %}
|
||||||
|
Склад участка не определён
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover mb-0 align-middle">
|
||||||
|
<thead>
|
||||||
|
<tr class="table-custom-header">
|
||||||
|
<th>Компонент</th>
|
||||||
|
<th class="text-center" style="width:110px;">Нужно</th>
|
||||||
|
<th class="text-center" style="width:130px;">Есть на участке</th>
|
||||||
|
<th class="text-center" style="width:130px;">К перемещению</th>
|
||||||
|
<th class="text-center" style="width:110px;">Не хватает</th>
|
||||||
|
<th style="width:380px;">Откуда взять</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for r in rows %}
|
||||||
|
<tr>
|
||||||
|
<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">{{ r.need }}</td>
|
||||||
|
<td class="text-center">{{ r.have_to }}</td>
|
||||||
|
<td class="text-center">{{ r.to_move }}</td>
|
||||||
|
<td class="text-center">
|
||||||
|
{% if r.missing > 0 %}
|
||||||
|
<span class="text-danger fw-bold">{{ r.missing }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-success">0</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if r.sources %}
|
||||||
|
<div class="d-grid gap-2">
|
||||||
|
{% for s in r.sources %}
|
||||||
|
<form method="post" class="border border-secondary rounded p-2">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="entity_id" value="{{ r.entity.id }}">
|
||||||
|
<input type="hidden" name="from_location_id" value="{{ s.location.id }}">
|
||||||
|
<input type="hidden" name="next" value="{{ request.get_full_path }}">
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<div>
|
||||||
|
<div class="fw-bold">{{ s.location.name }}</div>
|
||||||
|
<div class="small text-muted">Доступно: {{ s.available }}{% if s.selected %} · В перемещении: {{ s.selected }}{% endif %}</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<input class="form-control form-control-sm border-secondary" style="width:110px;" type="number" min="1" name="quantity" value="{% if r.missing > 0 %}{{ r.missing }}{% else %}1{% endif %}">
|
||||||
|
<button class="btn btn-outline-accent btn-sm" type="submit" name="action" value="add_line">В перемещение</button>
|
||||||
|
<button class="btn btn-outline-secondary btn-sm" type="submit" name="action" value="remove_line" {% if not s.selected %}disabled{% endif %}>Откатить</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-muted">Нет остатков (под сделку/свободных)</div>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr><td colspan="6" class="text-center text-muted py-4">Нет потребных компонентов (или кол-во = 0)</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-footer border-secondary small text-muted">
|
||||||
|
В таблице показываются только остатки под эту сделку и свободные. «Не хватает» пересчитывается с учетом колонки «К перемещению».
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
75
shiftflow/templates/shiftflow/workitem_kitting_print.html
Normal file
75
shiftflow/templates/shiftflow/workitem_kitting_print.html
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Перемещение</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: Arial, sans-serif; background: #fff; color: #000; }
|
||||||
|
.page { width: 210mm; margin: 10mm auto; }
|
||||||
|
.actions { display: flex; gap: 8px; justify-content: flex-end; margin-bottom: 8px; }
|
||||||
|
.btn { border: 1px solid #000; background: #fff; padding: 6px 10px; font-size: 12px; cursor: pointer; }
|
||||||
|
h1 { font-size: 18px; margin: 0 0 6px 0; }
|
||||||
|
.meta { font-size: 12px; margin: 0 0 12px 0; }
|
||||||
|
.block-title { font-weight: 700; margin: 14px 0 6px; }
|
||||||
|
table { width: 100%; border-collapse: collapse; }
|
||||||
|
th, td { border: 1px solid #000; padding: 6px 8px; font-size: 12px; }
|
||||||
|
th { text-align: center; font-weight: 700; }
|
||||||
|
td.num, th.num { text-align: right; width: 14mm; }
|
||||||
|
td.qty, th.qty { text-align: right; width: 30mm; }
|
||||||
|
td.note, th.note { width: 45mm; }
|
||||||
|
td.name { text-align: left; }
|
||||||
|
@media print { .actions { display:none; } .page { margin: 0; width: auto; } }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="page">
|
||||||
|
<div class="actions">
|
||||||
|
<button type="button" class="btn" onclick="window.close()">Закрыть</button>
|
||||||
|
<form method="post" style="margin:0">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button type="submit" name="action" value="apply" class="btn">Принять</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" style="margin:0">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button type="submit" name="action" value="apply_print" class="btn">Распечатать</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1>Перемещение на {{ to_location.name }}</h1>
|
||||||
|
<div class="meta">Сделка № {{ workitem.deal.number }} · {{ workitem.entity.drawing_number|default:"—" }} {{ workitem.entity.name }} · {{ printed_at|date:"d.m.Y H:i" }}</div>
|
||||||
|
|
||||||
|
{% for g in groups %}
|
||||||
|
<div class="block-title">С {{ g.from_location.name }}</div>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="num">№</th>
|
||||||
|
<th>Наименование</th>
|
||||||
|
<th class="qty">К перемещению, шт</th>
|
||||||
|
<th class="note">Отметка</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for it in g.items %}
|
||||||
|
<tr>
|
||||||
|
<td class="num">{{ forloop.counter }}</td>
|
||||||
|
<td class="name">{{ it.entity.drawing_number|default:"—" }} {{ it.entity.name }}</td>
|
||||||
|
<td class="qty">{{ it.quantity }}</td>
|
||||||
|
<td class="note"></td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% empty %}
|
||||||
|
<div class="meta">Лист перемещения пуст.</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if auto_print %}
|
||||||
|
<script>
|
||||||
|
window.print();
|
||||||
|
</script>
|
||||||
|
{% endif %}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
47
shiftflow/templates/shiftflow/workitem_op_closing.html
Normal file
47
shiftflow/templates/shiftflow/workitem_op_closing.html
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-lg-8">
|
||||||
|
<div class="card shadow-sm border-secondary mb-4">
|
||||||
|
<div class="card-header border-secondary d-flex justify-content-between align-items-center py-3">
|
||||||
|
<h3 class="text-accent mb-0">
|
||||||
|
<i class="bi bi-check2-square me-2"></i>Закрытие операции
|
||||||
|
</h3>
|
||||||
|
<a class="btn btn-outline-secondary btn-sm" href="{% url 'workitem_detail' workitem.id %}">Назад к заданию</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="fw-bold">{{ workitem.entity.drawing_number|default:"—" }} {{ workitem.entity.name }}</div>
|
||||||
|
<div class="small text-muted">Сделка № {{ workitem.deal.number }}</div>
|
||||||
|
<div class="small text-muted">
|
||||||
|
Операция: {% if workitem.operation %}{{ workitem.operation.name }}{% else %}{{ workitem.stage|default:"—" }}{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="small text-muted">План: {{ workitem.quantity_plan }} · Факт: {{ workitem.quantity_done }} · Остаток: <strong>{{ remaining }}</strong></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="alert alert-info border-info">
|
||||||
|
Эта операция не списывает сырьё/комплектующие. Здесь фиксируется только факт выполнения.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
<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="{{ remaining }}" value="{{ remaining }}" {% if remaining == 0 %}disabled{% endif %}>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<button type="submit" class="btn btn-warning w-100" {% if remaining == 0 %}disabled{% endif %}>
|
||||||
|
Закрыть операцию
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
43
shiftflow/templates/shiftflow/workshops_catalog.html
Normal file
43
shiftflow/templates/shiftflow/workshops_catalog.html
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="card shadow border-secondary mb-3">
|
||||||
|
<div class="card-header border-secondary py-3 d-flex flex-wrap gap-2 justify-content-between align-items-center">
|
||||||
|
<h3 class="text-accent mb-0"><i class="bi bi-building me-2"></i>Справочник · Цеха</h3>
|
||||||
|
<a class="btn btn-outline-secondary btn-sm" href="{% url 'directories' %}">Назад</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover mb-0 align-middle" data-sortable="1">
|
||||||
|
<thead>
|
||||||
|
<tr class="table-custom-header">
|
||||||
|
<th class="text-center" style="width:80px;" data-sort-type="number">№</th>
|
||||||
|
<th class="text-center" style="width:90px;" data-sort-type="number">ID</th>
|
||||||
|
<th>Цех</th>
|
||||||
|
<th>Склад цеха</th>
|
||||||
|
<th>Посты/станки</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for ws in workshops %}
|
||||||
|
<tr style="cursor:pointer" onclick="window.location.href='{% url 'machines_catalog' %}?workshop_id={{ ws.id }}';">
|
||||||
|
<td class="text-center">{{ forloop.counter }}</td>
|
||||||
|
<td class="text-center text-muted">{{ ws.id }}</td>
|
||||||
|
<td class="fw-bold">{{ ws.name }}</td>
|
||||||
|
<td>{{ ws.location.name|default:"—" }}</td>
|
||||||
|
<td class="small">
|
||||||
|
{% if ws.machine_labels %}
|
||||||
|
{{ ws.machine_labels }}
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted">—</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr><td colspan="5" class="text-center text-muted py-4">Цехов нет.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -32,14 +32,30 @@
|
|||||||
<div class="small text-muted">По производственным отчетам</div>
|
<div class="small text-muted">По производственным отчетам</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card-body">
|
<form method="post" class="mb-0">
|
||||||
{% for card in report_cards %}
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="start_date" value="{{ start_date }}">
|
||||||
|
<input type="hidden" name="end_date" value="{{ end_date }}">
|
||||||
|
|
||||||
|
<div class="card-body">
|
||||||
|
{% for card in report_cards %}
|
||||||
<div class="border border-secondary rounded p-3 mb-3">
|
<div class="border border-secondary rounded p-3 mb-3">
|
||||||
<div class="d-flex flex-wrap justify-content-between gap-2">
|
<div class="d-flex flex-wrap justify-content-between gap-2 align-items-center">
|
||||||
<div class="fw-bold">
|
<div class="fw-bold">
|
||||||
{{ card.report.date|date:"d.m.Y" }} — {{ card.report.machine }} — {{ card.report.operator }}
|
{{ card.report.date|date:"d.m.Y" }} — {{ card.report.machine }} — {{ card.report.operator }}
|
||||||
<span class="text-muted small ms-2">#{{ card.report.id }}</span>
|
<span class="text-muted small ms-2">#{{ card.report.id }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
{% if card.report.is_synced_1c %}
|
||||||
|
<span class="badge bg-success">Выгружено в 1С</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-secondary">Не выгружено</span>
|
||||||
|
{% if can_edit %}
|
||||||
|
<input class="form-check-input" type="checkbox" name="report_ids" value="{{ card.report.id }}" title="Отметить выгружено в 1С">
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row g-3 mt-1">
|
<div class="row g-3 mt-1">
|
||||||
@@ -54,6 +70,12 @@
|
|||||||
({% if c.stock_item.current_length and c.stock_item.current_width %}{{ c.stock_item.current_length|floatformat:"-g" }}×{{ c.stock_item.current_width|floatformat:"-g" }}{% elif c.stock_item.current_length %}{{ c.stock_item.current_length|floatformat:"-g" }}{% else %}—{% endif %})
|
({% if c.stock_item.current_length and c.stock_item.current_width %}{{ c.stock_item.current_length|floatformat:"-g" }}×{{ c.stock_item.current_width|floatformat:"-g" }}{% elif c.stock_item.current_length %}{{ c.stock_item.current_length|floatformat:"-g" }}{% else %}—{% endif %})
|
||||||
{{ c.quantity|floatformat:"-g" }} шт
|
{{ c.quantity|floatformat:"-g" }} шт
|
||||||
</li>
|
</li>
|
||||||
|
{% elif c.stock_item_id and c.stock_item.entity_id %}
|
||||||
|
<li>
|
||||||
|
{{ c.stock_item.entity }}
|
||||||
|
{% if c.stock_item.deal_id %}<span class="text-muted">(сделка № {{ c.stock_item.deal.number }})</span>{% endif %}
|
||||||
|
{{ c.quantity|floatformat:"-g" }} шт
|
||||||
|
</li>
|
||||||
{% elif c.material_id %}
|
{% elif c.material_id %}
|
||||||
<li>{{ c.material }} {{ c.quantity|floatformat:"-g" }} шт</li>
|
<li>{{ c.material }} {{ c.quantity|floatformat:"-g" }} шт</li>
|
||||||
{% else %}
|
{% else %}
|
||||||
@@ -81,10 +103,14 @@
|
|||||||
|
|
||||||
<div class="col-lg-4">
|
<div class="col-lg-4">
|
||||||
<div class="small text-muted fw-bold mb-1">Остаток ДО</div>
|
<div class="small text-muted fw-bold mb-1">Остаток ДО</div>
|
||||||
{% if card.remnants %}
|
{% if card.report.remnants.all %}
|
||||||
<ul class="mb-0">
|
<ul class="mb-0">
|
||||||
{% for k,v in card.remnants.items %}
|
{% for r in card.report.remnants.all %}
|
||||||
<li>{{ k }}: {{ v }} шт</li>
|
<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" }} шт
|
||||||
|
</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
{% else %}
|
{% else %}
|
||||||
@@ -93,79 +119,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% empty %}
|
{% empty %}
|
||||||
<div class="text-muted">За выбранный период отчётов нет.</div>
|
<div class="text-muted">За выбранный период отчётов нет.</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card shadow border-secondary">
|
|
||||||
<div class="card-header border-secondary py-3 d-flex justify-content-between align-items-center">
|
|
||||||
<h3 class="text-accent mb-0"><i class="bi bi-check2-square me-2"></i>Сменные задания (1С)</h3>
|
|
||||||
<div class="small text-muted">Отметка «Списано в 1С»</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form method="post" class="mb-0">
|
|
||||||
{% csrf_token %}
|
|
||||||
<input type="hidden" name="start_date" value="{{ start_date }}">
|
|
||||||
<input type="hidden" name="end_date" value="{{ end_date }}">
|
|
||||||
|
|
||||||
<div class="table-responsive">
|
|
||||||
<table class="table table-hover mb-0 align-middle">
|
|
||||||
<thead>
|
|
||||||
<tr class="table-custom-header">
|
|
||||||
<th data-sort="false"></th>
|
|
||||||
<th>Дата</th>
|
|
||||||
<th>Сделка</th>
|
|
||||||
<th>Станок</th>
|
|
||||||
<th>Деталь</th>
|
|
||||||
<th>Статус</th>
|
|
||||||
<th>Факт</th>
|
|
||||||
<th>1С</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{% for it in items %}
|
|
||||||
<tr>
|
|
||||||
<td style="width:40px;">
|
|
||||||
{% if can_edit and not it.is_synced_1c %}
|
|
||||||
<input class="form-check-input" type="checkbox" name="item_ids" value="{{ it.id }}">
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
<td class="small">{{ it.date|date:"d.m.Y" }}</td>
|
|
||||||
<td>
|
|
||||||
{% if it.task.deal_id %}
|
|
||||||
<span class="text-accent fw-bold">{{ it.task.deal.number }}</span>
|
|
||||||
{% else %}
|
|
||||||
—
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
<td>{{ it.machine }}</td>
|
|
||||||
<td>
|
|
||||||
<a href="{% url 'item_detail' it.id %}" class="text-decoration-none">{{ it.task.drawing_name }}</a>
|
|
||||||
</td>
|
|
||||||
<td>{{ it.get_status_display }}</td>
|
|
||||||
<td>{{ it.quantity_fact }}</td>
|
|
||||||
<td>
|
|
||||||
{% if it.is_synced_1c %}
|
|
||||||
<span class="badge bg-success">Да</span>
|
|
||||||
{% else %}
|
|
||||||
<span class="badge bg-secondary">Нет</span>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% empty %}
|
|
||||||
<tr><td colspan="8" class="text-center text-muted py-4">Нет закрытых заданий за период</td></tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if can_edit %}
|
||||||
<div class="card-footer border-secondary d-flex justify-content-end">
|
<div class="card-footer border-secondary d-flex justify-content-end">
|
||||||
<button type="submit" class="btn btn-outline-accent" {% if not can_edit %}disabled{% endif %}>
|
<button type="submit" class="btn btn-outline-accent">Отметить выбранные как «Выгружено в 1С»</button>
|
||||||
Отметить выбранные как «Списано в 1С»
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -6,28 +6,57 @@ from .views import (
|
|||||||
DealDetailView,
|
DealDetailView,
|
||||||
DealPlanningView,
|
DealPlanningView,
|
||||||
DealUpsertView,
|
DealUpsertView,
|
||||||
|
DealBatchActionView,
|
||||||
|
DealItemUpsertView,
|
||||||
|
DirectoriesView,
|
||||||
|
SupplyCatalogView,
|
||||||
|
LocationsCatalogView,
|
||||||
|
WorkshopsCatalogView,
|
||||||
|
MachinesCatalogView,
|
||||||
|
EntitiesSearchView,
|
||||||
IndexView,
|
IndexView,
|
||||||
ItemUpdateView,
|
ItemUpdateView,
|
||||||
MaintenanceStatusView,
|
MaintenanceStatusView,
|
||||||
MaintenanceView,
|
MaintenanceView,
|
||||||
|
MaterialCategoriesCatalogView,
|
||||||
MaterialCategoryUpsertView,
|
MaterialCategoryUpsertView,
|
||||||
MaterialDetailView,
|
MaterialDetailView,
|
||||||
MaterialUpsertView,
|
MaterialUpsertView,
|
||||||
|
MaterialsCatalogView,
|
||||||
PlanningAddView,
|
PlanningAddView,
|
||||||
PlanningView,
|
PlanningView,
|
||||||
|
PlanningStagesView,
|
||||||
ProductionTaskCreateView,
|
ProductionTaskCreateView,
|
||||||
|
WeldingPlanAddView,
|
||||||
|
PaintingPlanAddView,
|
||||||
|
WorkItemPlanAddView,
|
||||||
|
WorkItemUpdateView,
|
||||||
RegistryPrintView,
|
RegistryPrintView,
|
||||||
|
WorkItemDetailView,
|
||||||
|
WorkItemEntityListView,
|
||||||
|
WorkItemOpClosingView,
|
||||||
|
WorkItemKittingView,
|
||||||
|
WorkItemKittingPrintView,
|
||||||
|
AssemblyClosingView,
|
||||||
|
WorkItemRegistryPrintView,
|
||||||
RegistryView,
|
RegistryView,
|
||||||
|
SteelGradesCatalogView,
|
||||||
SteelGradeUpsertView,
|
SteelGradeUpsertView,
|
||||||
TaskItemsView,
|
TaskItemsView,
|
||||||
ClosingView,
|
ClosingView,
|
||||||
|
ClosingWorkItemsView,
|
||||||
ProductDetailView,
|
ProductDetailView,
|
||||||
|
ProductEntityPreviewUpdateView,
|
||||||
ProductInfoView,
|
ProductInfoView,
|
||||||
ProductsView,
|
ProductsView,
|
||||||
WriteOffsView,
|
WriteOffsView,
|
||||||
|
LegacyClosingView,
|
||||||
|
LegacyRegistryView,
|
||||||
|
LegacyWriteOffsView,
|
||||||
WarehouseReceiptCreateView,
|
WarehouseReceiptCreateView,
|
||||||
WarehouseStocksView,
|
WarehouseStocksView,
|
||||||
WarehouseTransferCreateView,
|
WarehouseTransferCreateView,
|
||||||
|
ProcurementDashboardView,
|
||||||
)
|
)
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
@@ -36,6 +65,7 @@ urlpatterns = [
|
|||||||
|
|
||||||
# Реестр
|
# Реестр
|
||||||
path('registry/', RegistryView.as_view(), name='registry'),
|
path('registry/', RegistryView.as_view(), name='registry'),
|
||||||
|
path('legacy/registry/', LegacyRegistryView.as_view(), name='legacy_registry'),
|
||||||
# Сделки
|
# Сделки
|
||||||
path('planning/', PlanningView.as_view(), name='planning'),
|
path('planning/', PlanningView.as_view(), name='planning'),
|
||||||
path('planning/deal/<int:pk>/', DealPlanningView.as_view(), name='planning_deal'),
|
path('planning/deal/<int:pk>/', DealPlanningView.as_view(), name='planning_deal'),
|
||||||
@@ -46,25 +76,55 @@ urlpatterns = [
|
|||||||
path('maintenance/status/', MaintenanceStatusView.as_view(), name='maintenance_status'),
|
path('maintenance/status/', MaintenanceStatusView.as_view(), name='maintenance_status'),
|
||||||
path('planning/add/', PlanningAddView.as_view(), name='planning_add'),
|
path('planning/add/', PlanningAddView.as_view(), name='planning_add'),
|
||||||
path('planning/task/add/', ProductionTaskCreateView.as_view(), name='task_add'),
|
path('planning/task/add/', ProductionTaskCreateView.as_view(), name='task_add'),
|
||||||
|
path('planning/stages/', PlanningStagesView.as_view(), name='planning_stages'),
|
||||||
|
path('planning/welding/add/', WeldingPlanAddView.as_view(), name='welding_plan_add'),
|
||||||
|
path('planning/painting/add/', PaintingPlanAddView.as_view(), name='painting_plan_add'),
|
||||||
|
path('planning/workitem/add/', WorkItemPlanAddView.as_view(), name='workitem_add'),
|
||||||
|
path('planning/workitem/update/', WorkItemUpdateView.as_view(), name='workitem_update'),
|
||||||
path('planning/deal/<int:pk>/json/', DealDetailView.as_view(), name='deal_json'),
|
path('planning/deal/<int:pk>/json/', DealDetailView.as_view(), name='deal_json'),
|
||||||
path('planning/deal/upsert/', DealUpsertView.as_view(), name='deal_upsert'),
|
path('planning/deal/upsert/', DealUpsertView.as_view(), name='deal_upsert'),
|
||||||
|
path('planning/deal/batch/action/', DealBatchActionView.as_view(), name='deal_batch_action'),
|
||||||
|
path('planning/deal/item/upsert/', DealItemUpsertView.as_view(), name='deal_item_upsert'),
|
||||||
|
path('entities/search/', EntitiesSearchView.as_view(), name='entities_search'),
|
||||||
path('planning/company/upsert/', CompanyUpsertView.as_view(), name='company_upsert'),
|
path('planning/company/upsert/', CompanyUpsertView.as_view(), name='company_upsert'),
|
||||||
path('planning/material/<int:pk>/json/', MaterialDetailView.as_view(), name='material_json'),
|
path('planning/material/<int:pk>/json/', MaterialDetailView.as_view(), name='material_json'),
|
||||||
path('planning/material/upsert/', MaterialUpsertView.as_view(), name='material_upsert'),
|
path('planning/material/upsert/', MaterialUpsertView.as_view(), name='material_upsert'),
|
||||||
path('planning/material-category/upsert/', MaterialCategoryUpsertView.as_view(), name='material_category_upsert'),
|
path('planning/material-category/upsert/', MaterialCategoryUpsertView.as_view(), name='material_category_upsert'),
|
||||||
path('planning/steel-grade/upsert/', SteelGradeUpsertView.as_view(), name='steel_grade_upsert'),
|
path('planning/steel-grade/upsert/', SteelGradeUpsertView.as_view(), name='steel_grade_upsert'),
|
||||||
|
|
||||||
|
path('directories/', DirectoriesView.as_view(), name='directories'),
|
||||||
|
path('directories/supply/', SupplyCatalogView.as_view(), name='supply_catalog'),
|
||||||
|
path('directories/locations/', LocationsCatalogView.as_view(), name='locations_catalog'),
|
||||||
|
path('directories/workshops/', WorkshopsCatalogView.as_view(), name='workshops_catalog'),
|
||||||
|
path('directories/machines/', MachinesCatalogView.as_view(), name='machines_catalog'),
|
||||||
|
path('directories/materials/', MaterialsCatalogView.as_view(), name='materials_catalog'),
|
||||||
|
path('directories/material-categories/', MaterialCategoriesCatalogView.as_view(), name='material_categories_catalog'),
|
||||||
|
path('directories/steel-grades/', SteelGradesCatalogView.as_view(), name='steel_grades_catalog'),
|
||||||
|
|
||||||
# Печать сменного листа
|
# Печать сменного листа
|
||||||
path('registry/print/', RegistryPrintView.as_view(), name='registry_print'),
|
path('registry/print/', RegistryPrintView.as_view(), name='registry_print'),
|
||||||
|
path('registry/workitems/print/', WorkItemRegistryPrintView.as_view(), name='registry_workitems_print'),
|
||||||
path('item/<int:pk>/', ItemUpdateView.as_view(), name='item_detail'),
|
path('item/<int:pk>/', ItemUpdateView.as_view(), name='item_detail'),
|
||||||
|
path('workitem/<int:pk>/', WorkItemDetailView.as_view(), name='workitem_detail'),
|
||||||
|
path('workitem/<int:pk>/op_closing/', WorkItemOpClosingView.as_view(), name='workitem_op_closing'),
|
||||||
|
path('workitem/<int:pk>/kitting/', WorkItemKittingView.as_view(), name='workitem_kitting'),
|
||||||
|
path('workitem/<int:pk>/kitting/print/', WorkItemKittingPrintView.as_view(), name='workitem_kitting_print'),
|
||||||
|
path('workitem/<int:pk>/assembly_closing/', AssemblyClosingView.as_view(), name='assembly_closing'),
|
||||||
|
path('workitems/<int:deal_id>/<int:entity_id>/', WorkItemEntityListView.as_view(), name='workitem_entity_list'),
|
||||||
|
|
||||||
path('warehouse/stocks/', WarehouseStocksView.as_view(), name='warehouse_stocks'),
|
path('warehouse/stocks/', WarehouseStocksView.as_view(), name='warehouse_stocks'),
|
||||||
path('warehouse/transfer/', WarehouseTransferCreateView.as_view(), name='warehouse_transfer'),
|
path('warehouse/transfer/', WarehouseTransferCreateView.as_view(), name='warehouse_transfer'),
|
||||||
path('warehouse/receipt/', WarehouseReceiptCreateView.as_view(), name='warehouse_receipt'),
|
path('warehouse/receipt/', WarehouseReceiptCreateView.as_view(), name='warehouse_receipt'),
|
||||||
|
|
||||||
path('closing/', ClosingView.as_view(), name='closing'),
|
path('closing/', ClosingView.as_view(), name='closing'),
|
||||||
|
path('closing/workitems/', ClosingWorkItemsView.as_view(), name='closing_workitems'),
|
||||||
path('writeoffs/', WriteOffsView.as_view(), name='writeoffs'),
|
path('writeoffs/', WriteOffsView.as_view(), name='writeoffs'),
|
||||||
|
path('procurement/', ProcurementDashboardView.as_view(), name='procurement'),
|
||||||
|
path('legacy/closing/', LegacyClosingView.as_view(), name='legacy_closing'),
|
||||||
|
path('legacy/writeoffs/', LegacyWriteOffsView.as_view(), name='legacy_writeoffs'),
|
||||||
|
|
||||||
path('products/', ProductsView.as_view(), name='products'),
|
path('products/', ProductsView.as_view(), name='products'),
|
||||||
path('products/<int:pk>/', ProductDetailView.as_view(), name='product_detail'),
|
path('products/<int:pk>/', ProductDetailView.as_view(), name='product_detail'),
|
||||||
path('products/<int:pk>/info/', ProductInfoView.as_view(), name='product_info'),
|
path('products/<int:pk>/info/', ProductInfoView.as_view(), name='product_info'),
|
||||||
|
path('products/<int:pk>/preview/update/', ProductEntityPreviewUpdateView.as_view(), name='product_preview_update'),
|
||||||
]
|
]
|
||||||
3667
shiftflow/views.py
3667
shiftflow/views.py
File diff suppressed because it is too large
Load Diff
@@ -9,11 +9,13 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Навбар и Футер: жестко фиксируем цвет для обеих тем */
|
/* Навбар и Футер: жестко фиксируем цвет для обеих тем */
|
||||||
.navbar, .footer-custom {
|
.navbar,
|
||||||
|
.footer-custom {
|
||||||
/* Темный графит, который хорошо смотрится и там, и там */
|
/* Темный графит, который хорошо смотрится и там, и там */
|
||||||
background-color: #2c3034 !important;
|
background-color: #2c3034 !important;
|
||||||
border-bottom: 1px solid #3d4246 !important;
|
border-bottom: 1px solid #3d4246 !important;
|
||||||
border-top: 1px solid #3d4246 !important; /* Для футера */
|
border-top: 1px solid #3d4246 !important;
|
||||||
|
/* Для футера */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Принудительно светлый текст для футера и навбара */
|
/* Принудительно светлый текст для футера и навбара */
|
||||||
@@ -32,7 +34,9 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Цвет ссылок в темном навбаре, чтобы не сливались */
|
/* Цвет ссылок в темном навбаре, чтобы не сливались */
|
||||||
.navbar .nav-link, .navbar .navbar-brand, .navbar .text-reset {
|
.navbar .nav-link,
|
||||||
|
.navbar .navbar-brand,
|
||||||
|
.navbar .text-reset {
|
||||||
color: #e9ecef !important;
|
color: #e9ecef !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,27 +50,48 @@ body {
|
|||||||
|
|
||||||
/* Подсветка при наведении */
|
/* Подсветка при наведении */
|
||||||
.clickable-row:hover {
|
.clickable-row:hover {
|
||||||
background-color: rgba(255, 193, 7, 0.05) !important; /* Легкий отсвет нашего акцента */
|
background-color: rgba(255, 193, 7, 0.05) !important;
|
||||||
|
/* Легкий отсвет нашего акцента */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- ТЕМЫ --- */
|
/* --- ТЕМЫ --- */
|
||||||
|
|
||||||
[data-bs-theme="dark"] {
|
[data-bs-theme="dark"] {
|
||||||
--bs-body-bg: #121212; /* Глубокий черный фон */
|
--bs-body-bg: #121212;
|
||||||
--bs-body-color: #e9ecef; /* Светло-серый текст */
|
/* Глубокий черный фон */
|
||||||
--bs-accent: #ffc107; /* Желтый акцент (Amber) */
|
--bs-body-color: #e9ecef;
|
||||||
|
/* Светло-серый текст */
|
||||||
|
--bs-accent: #ffc107;
|
||||||
|
/* Желтый акцент (Amber) */
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-bs-theme="light"] {
|
[data-bs-theme="light"] {
|
||||||
--bs-body-bg: #f8f9fa; /* Почти белый фон */
|
--bs-body-bg: #f8f9fa;
|
||||||
--bs-body-color: #212529; /* Темный текст */
|
/* Почти белый фон */
|
||||||
--bs-accent: #0d6efd; /* Синий акцент для светлой темы */
|
--bs-body-color: #212529;
|
||||||
|
/* Темный текст */
|
||||||
|
--bs-accent: #0d6efd;
|
||||||
|
/* Синий акцент для светлой темы */
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-bs-theme="dark"] input[type="date"] { color-scheme: dark; }
|
[data-bs-theme="dark"] input[type="date"] {
|
||||||
[data-bs-theme="dark"] .form-control[type="date"] { background-color: #1e1e1e; border-color: #3d4246; color: #e9ecef; }
|
color-scheme: dark;
|
||||||
[data-bs-theme="dark"] input[type="date"]::-webkit-calendar-picker-indicator { filter: invert(1) brightness(1.4) contrast(1.2); opacity: 0.95; }
|
}
|
||||||
[data-bs-theme="light"] input[type="date"] { color-scheme: light; }
|
|
||||||
|
[data-bs-theme="dark"] .form-control[type="date"] {
|
||||||
|
background-color: #1e1e1e;
|
||||||
|
border-color: #3d4246;
|
||||||
|
color: #e9ecef;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-bs-theme="dark"] input[type="date"]::-webkit-calendar-picker-indicator {
|
||||||
|
filter: invert(1) brightness(1.4) contrast(1.2);
|
||||||
|
opacity: 0.95;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-bs-theme="light"] input[type="date"] {
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
|
||||||
/* --- ТАБЛИЦА И КАРТОЧКИ --- */
|
/* --- ТАБЛИЦА И КАРТОЧКИ --- */
|
||||||
|
|
||||||
@@ -75,7 +100,8 @@ body {
|
|||||||
background-color: #1e1e1e !important;
|
background-color: #1e1e1e !important;
|
||||||
color: var(--bs-accent) !important;
|
color: var(--bs-accent) !important;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
text-transform: uppercase; /* Все буквы заглавные */
|
text-transform: uppercase;
|
||||||
|
/* Все буквы заглавные */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Фикс для таблиц в светлой теме */
|
/* Фикс для таблиц в светлой теме */
|
||||||
@@ -88,7 +114,9 @@ body {
|
|||||||
/* --- ВСПОМОГАТЕЛЬНЫЕ КЛАССЫ --- */
|
/* --- ВСПОМОГАТЕЛЬНЫЕ КЛАССЫ --- */
|
||||||
|
|
||||||
/* Текст акцентного цвета */
|
/* Текст акцентного цвета */
|
||||||
.text-accent { color: var(--bs-accent) !important; }
|
.text-accent {
|
||||||
|
color: var(--bs-accent) !important;
|
||||||
|
}
|
||||||
|
|
||||||
/* Кнопка с контуром акцентного цвета */
|
/* Кнопка с контуром акцентного цвета */
|
||||||
.btn-outline-accent {
|
.btn-outline-accent {
|
||||||
@@ -96,7 +124,7 @@ body {
|
|||||||
border-color: var(--bs-accent) !important;
|
border-color: var(--bs-accent) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-check:checked + .btn-outline-accent,
|
.btn-check:checked+.btn-outline-accent,
|
||||||
.btn-outline-accent.active,
|
.btn-outline-accent.active,
|
||||||
.btn-outline-accent:active {
|
.btn-outline-accent:active {
|
||||||
background-color: var(--bs-accent) !important;
|
background-color: var(--bs-accent) !important;
|
||||||
@@ -104,14 +132,14 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
[data-bs-theme="dark"] .btn-outline-accent:hover,
|
[data-bs-theme="dark"] .btn-outline-accent:hover,
|
||||||
[data-bs-theme="dark"] .btn-check:checked + .btn-outline-accent,
|
[data-bs-theme="dark"] .btn-check:checked+.btn-outline-accent,
|
||||||
[data-bs-theme="dark"] .btn-outline-accent.active,
|
[data-bs-theme="dark"] .btn-outline-accent.active,
|
||||||
[data-bs-theme="dark"] .btn-outline-accent:active {
|
[data-bs-theme="dark"] .btn-outline-accent:active {
|
||||||
color: #212529 !important;
|
color: #212529 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-bs-theme="light"] .btn-outline-accent:hover,
|
[data-bs-theme="light"] .btn-outline-accent:hover,
|
||||||
[data-bs-theme="light"] .btn-check:checked + .btn-outline-accent,
|
[data-bs-theme="light"] .btn-check:checked+.btn-outline-accent,
|
||||||
[data-bs-theme="light"] .btn-outline-accent.active,
|
[data-bs-theme="light"] .btn-outline-accent.active,
|
||||||
[data-bs-theme="light"] .btn-outline-accent:active {
|
[data-bs-theme="light"] .btn-outline-accent:active {
|
||||||
color: #ffffff !important;
|
color: #ffffff !important;
|
||||||
@@ -126,9 +154,33 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Специальный класс для центрирования окна логина (вернем его только там) */
|
/* Специальный класс для центрирования окна логина (вернем его только там) */
|
||||||
|
.sf-attention {
|
||||||
|
animation: sfAttentionPulse 1.6s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes sfAttentionPulse {
|
||||||
|
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
box-shadow: 0 0 0 rgba(255, 193, 7, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
box-shadow: 0 0 0.9rem rgba(255, 193, 7, 0.35);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.sf-attention {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.flex-center-center {
|
.flex-center-center {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
align-items: center; /* Центр по вертикали */
|
align-items: center;
|
||||||
justify-content: center; /* Центр по горизонтали */
|
/* Центр по вертикали */
|
||||||
|
justify-content: center;
|
||||||
|
/* Центр по горизонтали */
|
||||||
}
|
}
|
||||||
60
templates/components/_add_to_deal.html
Normal file
60
templates/components/_add_to_deal.html
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
{% if can_add_to_deal %}
|
||||||
|
<button type="button" class="btn btn-outline-accent btn-sm" data-bs-toggle="modal" data-bs-target="#addToDealModal{{ entity.id }}">
|
||||||
|
Добавить в сделку
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="modal fade" id="addToDealModal{{ entity.id }}" tabindex="-1" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-lg">
|
||||||
|
<form method="post" action="{% url 'deal_item_upsert' %}" class="modal-content border-secondary">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="entity_id" value="{{ entity.id }}">
|
||||||
|
<input type="hidden" name="next" value="{{ request.get_full_path }}">
|
||||||
|
|
||||||
|
<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="row g-2">
|
||||||
|
<div class="col-md-8">
|
||||||
|
<label class="form-label">Сделка (Зашла)</label>
|
||||||
|
<select class="form-select bg-body text-body border-secondary" name="deal_id" required {% if not deals_for_add %}disabled{% endif %}>
|
||||||
|
{% if deals_for_add %}
|
||||||
|
{% for d in deals_for_add %}
|
||||||
|
<option value="{{ d.id }}">{{ d.number }}{% if d.company %} ({{ d.company }}){% endif %}</option>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<option value="">Нет сделок в статусе «Зашла»</option>
|
||||||
|
{% endif %}
|
||||||
|
</select>
|
||||||
|
{% if not deals_for_add %}
|
||||||
|
<div class="form-text text-danger">Нечего добавить: нет сделок в статусе «Зашла».</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label">Кол-во, шт</label>
|
||||||
|
<input class="form-control bg-body text-body border-secondary" type="number" name="quantity" value="1" min="1" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Отгрузка (опц.)</label>
|
||||||
|
<input class="form-control bg-body text-body border-secondary" type="date" name="due_date">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Компонент</label>
|
||||||
|
<input class="form-control bg-body text-body border-secondary" value="{{ entity }}" disabled>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-footer border-secondary">
|
||||||
|
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Отмена</button>
|
||||||
|
<button type="submit" class="btn btn-outline-accent">Добавить</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
<nav class="navbar navbar-expand-lg border-bottom shadow-sm">
|
|
||||||
<div class="container-fluid">
|
|
||||||
<a class="navbar-brand fw-bold text-accent" href="/">
|
|
||||||
<i class="bi bi-gear-fill me-2"></i>ShiftFlow
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<button class="navbar-toggler text-light border-secondary" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
|
||||||
<span class="navbar-toggler-icon" style="filter: invert(1);"></span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div class="collapse navbar-collapse">
|
|
||||||
<ul class="navbar-nav me-auto">
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link {% if request.resolver_match.url_name == 'items_list' %}active{% endif %}" href="{% url 'registry' %}">Реестр</a>
|
|
||||||
</li>
|
|
||||||
|
|
||||||
{% if user_role in 'admin,technologist' %}
|
|
||||||
<li class="nav-item"><a class="nav-link" href="#">Планирование</a></li>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if user_role in 'admin,technologist,master,operator' %}
|
|
||||||
<li class="nav-item"><a class="nav-link" href="#">Закрытие</a></li>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if user_role in 'admin,technologist,clerk' %}
|
|
||||||
<li class="nav-item"><a class="nav-link" href="#">Списание</a></li>
|
|
||||||
{% endif %}
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<div class="d-flex align-items-center gap-3">
|
|
||||||
<span class="badge bg-secondary opacity-75 px-3 py-2 fw-normal">
|
|
||||||
<i class="bi bi-person-circle me-1"></i>
|
|
||||||
{% if user_role == 'admin' %}Админ
|
|
||||||
{% elif user_role == 'technologist' %}Технолог
|
|
||||||
{% elif user_role == 'master' %}Мастер
|
|
||||||
{% elif user_role == 'operator' %}Оператор
|
|
||||||
{% elif user_role == 'clerk' %}Учетчик
|
|
||||||
{% endif %}
|
|
||||||
({{ request.user.username|upper }})
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{% if user_role == 'admin' %}
|
|
||||||
<a href="/admin/" class="btn btn-link text-decoration-none text-reset p-0" title="Админка">
|
|
||||||
<i class="bi bi-shield-lock fs-5"></i>
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<button class="btn btn-link text-reset p-0" onclick="toggleTheme()" title="Сменить тему">
|
|
||||||
<i id="theme-icon" class="bi fs-5"></i>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<form action="{% url 'logout' %}" method="post" class="d-inline">
|
|
||||||
{% csrf_token %}
|
|
||||||
<button type="submit" class="btn btn-link text-danger p-0 ms-2" title="Выйти">
|
|
||||||
<i class="bi bi-box-arrow-right fs-5"></i>
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
@@ -15,10 +15,23 @@
|
|||||||
<a class="nav-link {% if request.resolver_match.url_name == 'registry' %}active{% endif %}" href="{% url 'registry' %}">Реестр</a>
|
<a class="nav-link {% if request.resolver_match.url_name == 'registry' %}active{% endif %}" href="{% url 'registry' %}">Реестр</a>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
|
{% if user_role in 'admin,supply,observer,clerk,prod_head,director' %}
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link {% if request.resolver_match.url_name == 'procurement' %}active{% endif %}" href="{% url 'procurement' %}">Снабжение</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% if user_role in 'admin,technologist,master,clerk,observer' %}
|
{% if user_role in 'admin,technologist,master,clerk,observer' %}
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link {% if request.resolver_match.url_name == 'planning' or request.resolver_match.url_name == 'planning_deal' %}active{% endif %}" href="{% url 'planning' %}">Сделки</a>
|
<a class="nav-link {% if request.resolver_match.url_name == 'planning' or request.resolver_match.url_name == 'planning_deal' %}active{% endif %}" href="{% url 'planning' %}">Сделки</a>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
|
{% if user_role in 'admin,technologist,master,clerk' %}
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link {% if request.resolver_match.url_name == 'planning_stages' %}active{% endif %}" href="{% url 'planning_stages' %}">План</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link {% if request.resolver_match.url_name == 'customers' or request.resolver_match.url_name == 'customer_deals' %}active{% endif %}" href="{% url 'customers' %}">Заказчик</a>
|
<a class="nav-link {% if request.resolver_match.url_name == 'customers' or request.resolver_match.url_name == 'customer_deals' %}active{% endif %}" href="{% url 'customers' %}">Заказчик</a>
|
||||||
</li>
|
</li>
|
||||||
@@ -27,24 +40,51 @@
|
|||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if user_role in 'admin,technologist,observer' or request.user.is_superuser %}
|
{% if user_role in 'admin,technologist,master,clerk,observer,prod_head,director' %}
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link {% if request.resolver_match.url_name == 'products' %}active{% endif %}" href="{% url 'products' %}">Изделия</a>
|
<a class="nav-link {% if request.resolver_match.url_name == 'directories' or request.resolver_match.url_name == 'materials_catalog' or request.resolver_match.url_name == 'material_categories_catalog' or request.resolver_match.url_name == 'steel_grades_catalog' or request.resolver_match.url_name == 'supply_catalog' or request.resolver_match.url_name == 'locations_catalog' or request.resolver_match.url_name == 'workshops_catalog' or request.resolver_match.url_name == 'workshop_detail' or request.resolver_match.url_name == 'machines_catalog' %}active{% endif %}" href="{% url 'directories' %}">Справочники</a>
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if user_role in 'admin,master,operator,observer' %}
|
{% if user_role == 'admin' or user_role == 'technologist' or user_role == 'observer' or request.user.is_superuser %}
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link {% if request.resolver_match.url_name == 'closing' %}active{% endif %}" href="{% url 'closing' %}">Закрытие</a>
|
<a class="nav-link {% if request.resolver_match.url_name == 'products' or request.resolver_match.url_name == 'product_detail' or request.resolver_match.url_name == 'product_info' %}active{% endif %}" href="{% url 'products' %}">Изделия</a>
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if user_role in 'admin,clerk,observer' %}
|
{% if user_role in 'admin,master,operator,prod_head' %}
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link {% if request.resolver_match.url_name == 'closing_workitems' or request.resolver_match.url_name == 'closing' %}active{% endif %}" href="{% url 'closing_workitems' %}">Закрытие</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if user_role in 'admin,clerk,observer,prod_head,director' %}
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link {% if request.resolver_match.url_name == 'writeoffs' %}active{% endif %}" href="{% url 'writeoffs' %}">Списание</a>
|
<a class="nav-link {% if request.resolver_match.url_name == 'writeoffs' %}active{% endif %}" href="{% url 'writeoffs' %}">Списание</a>
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
<li class="nav-item dropdown">
|
||||||
|
<a class="nav-link dropdown-toggle {% if request.resolver_match.url_name == 'legacy_registry' or request.resolver_match.url_name == 'legacy_closing' %}active{% endif %}" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||||
|
Архив
|
||||||
|
</a>
|
||||||
|
<ul class="dropdown-menu">
|
||||||
|
<li>
|
||||||
|
<a class="dropdown-item" href="{% url 'legacy_registry' %}">Архив / Реестр</a>
|
||||||
|
</li>
|
||||||
|
{% if user_role in 'admin,master,operator,prod_head' %}
|
||||||
|
<li>
|
||||||
|
<a class="dropdown-item" href="{% url 'legacy_closing' %}">Архив / Закрытие</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
{% if user_role in 'admin,clerk,observer,prod_head,director' %}
|
||||||
|
<li>
|
||||||
|
<a class="dropdown-item" href="{% url 'legacy_writeoffs' %}">Архив / Списание</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
|
||||||
{% if user_role == 'admin' %}
|
{% if user_role == 'admin' %}
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link {% if request.resolver_match.url_name == 'maintenance' %}active{% endif %}" href="{% url 'maintenance' %}">Обслуживание сервера</a>
|
<a class="nav-link {% if request.resolver_match.url_name == 'maintenance' %}active{% endif %}" href="{% url 'maintenance' %}">Обслуживание сервера</a>
|
||||||
|
|||||||
18
warehouse/migrations/0014_material_mass_per_unit.py
Normal file
18
warehouse/migrations/0014_material_mass_per_unit.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 6.0.3 on 2026-04-07 17:04
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('warehouse', '0013_stockitem_archived_at_stockitem_is_archived'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='material',
|
||||||
|
name='mass_per_unit',
|
||||||
|
field=models.FloatField(blank=True, null=True, verbose_name='Масса на ед. учёта'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -43,6 +43,7 @@ class Material(models.Model):
|
|||||||
steel_grade = models.ForeignKey(SteelGrade, on_delete=models.PROTECT, verbose_name="Марка стали", null=True, blank=True)
|
steel_grade = models.ForeignKey(SteelGrade, on_delete=models.PROTECT, verbose_name="Марка стали", null=True, blank=True)
|
||||||
name = models.CharField("Наименование (размер/характеристики)", max_length=255)
|
name = models.CharField("Наименование (размер/характеристики)", max_length=255)
|
||||||
full_name = models.CharField("Полное наименование", max_length=500, blank=True, help_text="Генерируется автоматически")
|
full_name = models.CharField("Полное наименование", max_length=500, blank=True, help_text="Генерируется автоматически")
|
||||||
|
mass_per_unit = models.FloatField("Масса на ед. учёта", null=True, blank=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = "Материал (номенклатура)"
|
verbose_name = "Материал (номенклатура)"
|
||||||
|
|||||||
@@ -57,9 +57,11 @@ def receive_transfer(transfer_id: int, receiver_id: int) -> None:
|
|||||||
StockItem.objects.create(
|
StockItem.objects.create(
|
||||||
material=src.material,
|
material=src.material,
|
||||||
entity=src.entity,
|
entity=src.entity,
|
||||||
|
deal=src.deal,
|
||||||
location_id=tr.to_location_id,
|
location_id=tr.to_location_id,
|
||||||
quantity=float(ln.quantity),
|
quantity=float(ln.quantity),
|
||||||
is_remnant=src.is_remnant,
|
is_remnant=src.is_remnant,
|
||||||
|
is_customer_supplied=src.is_customer_supplied,
|
||||||
current_length=src.current_length,
|
current_length=src.current_length,
|
||||||
current_width=src.current_width,
|
current_width=src.current_width,
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user