Конкретно пересмотрел логику работы. Легаси вынесена в архив
All checks were successful
Deploy MES Core / deploy (push) Successful in 13s

This commit is contained in:
2026-04-13 07:36:57 +03:00
parent 86215c9fa8
commit 28537447f8
80 changed files with 10246 additions and 684 deletions

View File

@@ -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 %}

View File

@@ -8,18 +8,13 @@
# 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/бекенде:
@@ -27,19 +22,17 @@
- комментарии должны быть нейтральными и описывать поведение/причину, без личных формулировок.
- В HTML-шаблонах Django:
- не добавляй template-комментарии {# ... #} .
- В остальных местах:
- не добавляй комментарии “для красоты”; только там, где они реально помогают поддержке.
## 5) Стиль и конвенции проекта
- Смотри на соседние файлы и придерживайся уже принятого стиля (структура, именование, импорты, форматирование).
- Не вводи новые библиотеки/фреймворки, пока не проверил, что они уже используются в проекте.
- Для UI-таблиц:
- если добавляешь новую таблицу — по умолчанию делай её сортируемой (если не мешает UX).
- для колонок с кнопками/прогрессом/иконками отключай сортировку.
- Использовать Service Layer: сложная логика живет в services.py, вьюхи остаются тонкими.
- Импорты моделей из других приложений — через строковые ссылки в полях ('app.Model') для избежания циклических импортов.
## 6) Безопасность и секреты
- Никогда не логируй и не печатай в stdout:
- SECRET_KEY
@@ -57,13 +50,36 @@
- с датой/временем
- доступны из интерфейса “Обслуживание сервера” (tail)
- очищаемы кнопкой (если задача не running)
## 8) Транзакции и гонки данных (warehouse/shiftflow)
- Все операции списания/начисления на складе делай в transaction.atomic() .
- На изменяемые складские остатки используй select_for_update() чтобы избежать гонок.
- Для массовых операций избегай N+1:
- 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: режим "только просмотр".
Правило для новых внутренних функций (как договор):

View File

@@ -67,4 +67,45 @@ git push origin main
Если сайт по адресу `192.168.1.108` не открывается:
1. Проверь логи контейнеров: `docker compose logs -f`.
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`

38
TODO.md
View File

@@ -5,6 +5,13 @@
- По клику на строку открывать карточку «Единица на складе» (read-only для observer, редактирование для admin/technologist/master/clerk):
- правка: сделка, давальческий, размеры (лист/хлыст), количество, примечание (если добавим)
- отображение: история перемещений/приходов/отгрузок (если потребуется).
- Реализовать инвентаризацию складов участков/цехов:
- сценарий: фактический пересчёт, ввод корректировок (излишек/недостача), фиксация причины
- хранить историю инвентаризаций и разницы по позициям
- права: master/clerk/admin, read-only для observer
## Доступы (UI)
- Доработать видимость и действия для разных ролей/цехов: фильтрация по allowed_workshops, замещение, read-only руководитель.
## Списание (UI)
- Доработать страницу «Списание»: фильтры, удобная сводка по материалам/изделиям и отметка «внесено в 1С».
@@ -13,4 +20,33 @@
- Пересмотреть расчёт потребности: уйти от м²/мм, формировать пачки DXF по материалам/толщинам и прокат по длинам (для nesting/ручного расчёта).
## Изделия (Сборка)
- Проработать интерфейс сборки изделия: редактирование состава, паспорт узла, маршруты, сварные швы, быстрые переходы по уровням.
- Проработать интерфейс сборки изделия: редактирование состава, паспорт узла, маршруты, сварные швы, быстрые переходы по уровням.
# TODO: Миграция сменных заданий на WorkItem
- WorkItem как единая сущность сменных назначений:
- operation/workshop обязательны; machine — опционально
- plan/done/status/date — общие поля
- запись создаётся в planning_deal (кнопка «В смену»)
- Переход от Item к WorkItem:
- Экраны «Реестр сменных заданий» и «Закрытие смены»
- читать и отображать WorkItem
- для резки предусмотреть учёт списаний/остатков; временно можно дублировать Item ← WorkItem (мост)
- Datamigration:
- перенести исторические 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
View 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 ...') — не глотаем, пробрасываем дальше

View File

@@ -1,12 +1,21 @@
from django.contrib import admin
from .models import BOM, ProductEntity, RouteStub
from .models import BOM, EntityOperation, Operation, ProductEntity
@admin.register(RouteStub)
class RouteStubAdmin(admin.ModelAdmin):
list_display = ('name',)
search_fields = ('name',)
@admin.register(Operation)
class OperationAdmin(admin.ModelAdmin):
list_display = ('name', 'code', 'workshop')
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):
@@ -31,8 +40,8 @@ class ProductEntityAdmin(admin.ModelAdmin):
)
list_filter = ('entity_type', 'planned_material__category')
search_fields = ('drawing_number', 'name', 'planned_material__name', 'planned_material__full_name')
autocomplete_fields = ('planned_material', 'route')
inlines = (BOMChildInline,)
autocomplete_fields = ('planned_material',)
inlines = (EntityOperationInline, BOMChildInline,)
@admin.register(BOM)

View File

@@ -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='Требуется сварка'),
),
]

View 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')},
},
),
]

View File

@@ -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',
),
]

View File

@@ -1,14 +1,30 @@
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:
verbose_name = "Маршрут"
verbose_name_plural = "Маршруты"
verbose_name = 'Операция'
verbose_name_plural = 'Операции'
def __str__(self):
return self.name
@@ -48,13 +64,6 @@ class ProductEntity(models.Model):
blank=True,
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_length_mm = models.FloatField("Норма: длина заготовки (мм/шт)", null=True, blank=True)
@@ -74,6 +83,23 @@ class ProductEntity(models.Model):
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):
"""Спецификация (BOM): parent состоит из child в количестве quantity."""
@@ -103,6 +129,9 @@ class BOM(models.Model):
class AssemblyPassport(models.Model):
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)
coating = models.CharField('Покрытие', max_length=200, blank=True, default='')
coating_color = models.CharField('Цвет', max_length=100, blank=True, default='')

View File

@@ -20,8 +20,35 @@ from .models import (
ProductionTask,
ShiftItem,
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)
class CompanyAdmin(admin.ModelAdmin):
@@ -119,6 +146,21 @@ class ItemAdmin(admin.ModelAdmin):
return obj.task.drawing_name if obj.task else "-"
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)
class WorkshopAdmin(admin.ModelAdmin):
list_display = ('name', 'location')
@@ -261,5 +303,5 @@ class DxfPreviewJobAdmin(admin.ModelAdmin):
@admin.register(EmployeeProfile)
class EmployeeProfileAdmin(admin.ModelAdmin):
list_display = ('user', 'role')
filter_horizontal = ('machines',)
list_display = ('user', 'role', 'is_readonly')
filter_horizontal = ('machines', 'allowed_workshops')

106
shiftflow/authz.py Normal file
View 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

View File

@@ -5,14 +5,14 @@ from shiftflow.services.bom_explosion import explode_deal
class Command(BaseCommand):
help = "BOM Explosion для сделки: генерирует ProductionTask и MaterialRequirement."
help = "BOM Explosion для сделки: генерирует ProductionTask и пересчитывает снабжение."
def add_arguments(self, parser):
parser.add_argument("deal_id", type=int)
def handle(self, *args, **options):
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.style.SUCCESS(

View 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='Плановая отгрузка'),
),
]

View 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': 'План работ',
},
),
]

View File

@@ -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='Должность'),
),
]

View File

@@ -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')},
},
),
]

View 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')},
},
),
]

View 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='Запущено в производство, шт'),
),
]

View 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='Дефолтная партия (остаток)'),
),
]

View 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',
),
]

View 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='Материал'),
),
]

View 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='Срок отгрузки'),
),
]

View File

@@ -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='Статус'),
),
]

View 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')},
},
),
]

View File

@@ -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='Потребность (к закупке), шт'),
),
]

View File

@@ -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С'),
),
]

View File

@@ -35,7 +35,12 @@ class Workshop(models.Model):
class Machine(models.Model):
"""Список производственных участков (станков).
"""Справочник производственных постов (ресурсов).
Терминология UI:
- в интерфейсе используется слово «Пост», чтобы одинаково обозначать станок, линию,
камеру, рабочее место или бригаду (как единицу планирования у мастера).
- в базе и коде модель остаётся Machine, чтобы не ломать существующие связи.
Источник склада для операций выработки/списаний:
- предпочитаем склад цеха (Machine.workshop.location)
@@ -45,6 +50,7 @@ class Machine(models.Model):
MACHINE_TYPE_CHOICES = [
('linear', 'Линейный'),
('sheet', 'Листовой'),
('post', 'Пост'),
]
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')
company = models.ForeignKey(Company, on_delete=models.PROTECT, verbose_name="Заказчик", null=True, blank=True)
description = models.TextField("Описание сделки", blank=True, help_text="Общая информация по заказу")
due_date = models.DateField("Срок отгрузки", null=True, blank=True)
def __str__(self):
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)
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("Заказано всего, шт")
is_bend = models.BooleanField("Гибка", default=False)
@@ -161,7 +168,10 @@ class DxfPreviewSettings(models.Model):
class DealItem(models.Model):
"""Состав сделки: что заказал клиент (точка входа для BOM Explosion)."""
"""Состав сделки: что заказал клиент.
Примечание: при поставках частями используем DealDeliveryBatch/DealBatchItem.
"""
deal = models.ForeignKey(Deal, related_name='items', on_delete=models.CASCADE, 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}"
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):
"""Потребность в закупке сырья для сделки.
@@ -212,6 +282,65 @@ class MaterialRequirement(models.Model):
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):
"""Производственный отчет (основа для списания/начисления).
@@ -238,6 +367,17 @@ class CuttingSession(models.Model):
created_at = models.DateTimeField(auto_now_add=True)
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:
verbose_name = 'Производственный отчет'
verbose_name_plural = 'Производственные отчеты'
@@ -392,7 +532,6 @@ class Item(models.Model):
('done', 'Выполнено'),
('partial', 'Частично'),
('leftover', 'Недодел'),
('imported', 'Импортировано'),
]
# --- Ссылка на основу (временно null=True для миграции старых данных) ---
@@ -432,14 +571,23 @@ class EmployeeProfile(models.Model):
('operator', 'Оператор'),
('clerk', 'Учетчик'),
('observer', 'Наблюдатель'),
('manager', 'Руководитель'),
]
# Связь 1 к 1 со стандартным юзером Django
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='Должность')
# Комментарий: режим для руководителя/наблюдателя — видит всё, но любые изменения запрещены.
is_readonly = models.BooleanField('Только просмотр', default=False)
# Привязка станков (можно выбрать несколько для одного оператора)
machines = models.ManyToManyField('Machine', blank=True, verbose_name='Закрепленные станки')
# Комментарий: ограничение видимости/действий по цехам.
# Если список пустой — считаем, что доступ не ограничен (админ/технолог/руководитель).
allowed_workshops = models.ManyToManyField('Workshop', blank=True, verbose_name='Доступные цеха')
def __str__(self):
return f"{self.user.username} - {self.get_role_display()}"

View 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

View File

@@ -1,15 +1,24 @@
from __future__ import annotations
import logging
from collections import defaultdict
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.functions import Coalesce
from manufacturing.models import BOM, ProductEntity
from shiftflow.models import Deal, DealItem, MaterialRequirement, ProductionTask
from warehouse.models import Location, StockItem
from shiftflow.models import Deal, DealItem, ProcurementRequirement, ProductionTask
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)
@@ -21,7 +30,10 @@ class ExplosionStats:
- сколько ProductionTask создано/обновлено (по leaf-деталям)
req_*:
- сколько MaterialRequirement создано/обновлено (по сырью)
- сколько ProcurementRequirement создано/обновлено (по потребностям снабжения)
Примечание:
- потребность по сырью (лист/профиль) сейчас не считаем автоматически — будет вводиться вручную.
"""
tasks_created: int
@@ -151,19 +163,40 @@ def _explode_to_leaves(
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
def explode_deal(
deal_id: int,
*,
central_location_name: str = "Центральный склад",
create_tasks: bool = False,
create_procurement: bool = True,
) -> ExplosionStats:
"""
BOM Explosion:
- берём состав сделки (DealItem)
- рекурсивно обходим BOM
- считаем суммарное количество leaf-деталей
- создаём/обновляем ProductionTask (deal + entity)
- создаём/обновляем MaterialRequirement по нормам расхода и остаткам на центральном складе
"""BOM Explosion по сделке.
Используется в двух режимах:
- create_procurement=True: пересчитать потребности снабжения (покупное/литьё/аутсорс)
- create_tasks=True: создать/обновить ProductionTask по внутреннему производству
Примечание: потребность по сырью (MaterialRequirement) здесь не считаем автоматически.
"""
deal = Deal.objects.select_for_update().get(pk=deal_id)
@@ -191,28 +224,200 @@ def explode_deal(
tasks_created = 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)
if not entity:
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(
deal=deal,
entity=entity,
defaults={
"drawing_name": entity.name or "Б/ч",
"size_value": 0,
"material": entity.planned_material,
"quantity_ordered": int(qty),
"is_bend": False,
},
defaults=defaults,
)
if created:
tasks_created += 1
else:
changed = False
if pt.quantity_ordered != int(qty):
pt.quantity_ordered = int(qty)
new_qty = int(pt.quantity_ordered or 0) + int(qty)
if pt.quantity_ordered != new_qty:
pt.quantity_ordered = new_qty
changed = True
if not pt.material_id and entity.planned_material_id:
pt.material = entity.planned_material
@@ -221,45 +426,14 @@ def explode_deal(
pt.save(update_fields=["quantity_ordered", "material"])
tasks_updated += 1
central, _ = Location.objects.get_or_create(
name=central_location_name,
defaults={"is_production_area": False},
logger.info(
'explode_roots_additive: deal_id=%s roots=%s nodes=%s tasks_created=%s tasks_updated=%s skipped_no_material=%s skipped_supply=%s',
deal_id,
roots,
len(required_nodes),
tasks_created,
tasks_updated,
skipped_no_material,
skipped_supply,
)
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)
return ExplosionStats(tasks_created, tasks_updated, 0, 0)

View File

@@ -1,4 +1,5 @@
from django.db import transaction
from django.db.models import F
from django.utils import timezone
import logging
@@ -130,3 +131,92 @@ def apply_closing(
)
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)

View 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}

View 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 %}

View File

@@ -48,27 +48,27 @@
<th>Дата</th>
<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 %}
{% for wi in workitems %}
<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 class="small">{{ wi.date|date:"d.m.Y" }}</td>
<td><span class="text-accent fw-bold">{{ wi.deal.number }}</span></td>
<td class="fw-bold">{{ wi.entity.drawing_number|default:"—" }} {{ wi.entity.name }}</td>
<td>{{ wi.remaining }}</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 %}>
<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 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>
<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="{{ wi.id }}" data-action="partial" data-plan="{{ wi.remaining }}" {% if not can_edit %}disabled{% endif %}>Частично</button>
<input type="hidden" id="ca_{{ wi.id }}" name="close_action_{{ wi.id }}" value="">
<span class="small text-muted" id="modeLabel_{{ wi.id }}"></span>
</div>
</td>
</tr>

View 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 %}

View File

@@ -86,6 +86,10 @@
<option value="done">Завершена</option>
</select>
</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">
<label class="form-label small text-muted">Описание</label>
<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 dealStatus = document.getElementById('dealStatus');
const dealDescription = document.getElementById('dealDescription');
const dealDueDate = document.getElementById('dealDueDate');
const dealSaveBtn = document.getElementById('dealSaveBtn');
function getCookie(name) {
@@ -139,6 +144,7 @@ document.addEventListener('DOMContentLoaded', function () {
dealModal.addEventListener('show.bs.modal', function () {
if (dealNumber) dealNumber.value = '';
if (dealDescription) dealDescription.value = '';
if (dealDueDate) dealDueDate.value = '';
if (dealStatus) dealStatus.value = 'work';
});
}
@@ -150,6 +156,7 @@ document.addEventListener('DOMContentLoaded', function () {
status: dealStatus ? dealStatus.value : 'work',
company_id: '{{ company.id }}',
description: (dealDescription ? dealDescription.value : ''),
due_date: dealDueDate ? dealDueDate.value : '',
};
await postForm('{% url "deal_upsert" %}', payload);
window.location.reload();

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View File

@@ -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()">
<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()">
<label class="btn btn-outline-success btn-sm" for="s_closed">Завершено</label>
{% else %}
@@ -32,29 +35,14 @@
<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-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()">
<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 %}
</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">
<label class="small text-muted mb-1 fw-bold">С:</label>
@@ -95,8 +83,7 @@
const data = {
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),
start_date: s ? s.value : '',
is_synced: (form.querySelector('select[name="is_synced"]')||{}).value || ''
start_date: s ? s.value : ''
};
try { localStorage.setItem('registry_filters', JSON.stringify(data)); } catch(_){}
}
@@ -122,8 +109,6 @@
}
if (s) s.value = data.start_date || weekAgo;
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"]');
if (filtered) filtered.value = '1';
form.submit();

View 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>

View File

@@ -88,6 +88,10 @@
<button type="button" class="btn btn-outline-accent btn-sm" id="openCompanyModalBtn">Создать</button>
</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">
<label class="form-label small text-muted">Описание</label>
<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 dealCompany = document.getElementById('dealCompany');
const dealDescription = document.getElementById('dealDescription');
const dealDueDate = document.getElementById('dealDueDate');
const dealSaveBtn = document.getElementById('dealSaveBtn');
const openCompanyModalBtn = document.getElementById('openCompanyModalBtn');
@@ -234,6 +239,7 @@ document.addEventListener('DOMContentLoaded', function () {
dealId.value = '';
dealNumber.value = '';
dealDescription.value = '';
if (dealDueDate) dealDueDate.value = '';
if (dealStatus) dealStatus.value = 'work';
if (dealCompany) dealCompany.value = '';
});
@@ -268,6 +274,7 @@ document.addEventListener('DOMContentLoaded', function () {
status: dealStatus ? dealStatus.value : 'work',
company_id: dealCompany.value,
description: dealDescription.value,
due_date: dealDueDate ? dealDueDate.value : '',
};
await postForm('{% url "deal_upsert" %}', payload);
window.location.reload();

View File

@@ -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">
{{ deal.get_status_display }}
</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' %}">
<i class="bi bi-arrow-left me-1"></i>Назад
</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' %}
<a class="btn btn-outline-accent btn-sm" href="{% url 'task_add' %}?deal={{ deal.id }}&next={% url 'planning_deal' deal.id %}">
<i class="bi bi-plus-lg me-1"></i>Добавить деталь
</a>
<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>Добавить задание
</button>
{% endif %}
</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>Материал</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 tasks %}
<tr class="task-row" style="cursor:pointer" data-href="{% url 'task_items' t.id %}">
<td class="fw-bold">{{ t.drawing_name|default:"Б/ч" }}</td>
<td class="small text-muted">{{ t.material.full_name|default:t.material.name }}</td>
<td class="small">{{ t.size_value }}</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' %}
<button
type="button"
class="btn btn-outline-accent btn-sm"
data-bs-toggle="modal"
data-bs-target="#addToPlanModal"
data-task-id="{{ t.id }}"
data-task-name="{{ t.drawing_name|default:'Б/ч' }}"
data-task-rem="{{ t.remaining_qty }}"
>
<i class="bi bi-plus-lg me-1"></i>В план
</button>
{% endif %}
</td>
</tr>
{% empty %}
<tr><td colspan="8" class="text-center p-5 text-muted">Деталей не найдено</td></tr>
{% endfor %}
</tbody>
</table>
<div class="p-3">
<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>
<div class="small text-muted">Изделие / СБ / Деталь</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 data-sort="false" style="width: 160px;">Прогресс</th>
<th class="text-center">Заказано / Сделано / В плане</th>
<th class="text-center">Осталось</th>
<th data-sort="false" class="text-end">В производство</th>
</tr>
</thead>
<tbody>
{% for it in deal_items %}
<tr class="deal-entity-row" role="button" data-href="{% url 'product_info' it.entity.id %}?next={{ request.get_full_path|urlencode }}">
<td>
<div class="fw-bold">{{ it.entity.drawing_number|default:"—" }} {{ it.entity.name }}</div>
<div class="small text-muted">{{ it.entity.get_entity_type_display }}</div>
</td>
<td>
<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 }}">
<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">{{ it.quantity }}</span> /
<span class="text-success">{{ it.done_qty }}</span> /
<span class="text-warning">{{ it.planned_qty }}</span>
</td>
<td class="text-center">{{ it.remaining_qty }}</td>
<td class="text-end" onclick="event.stopPropagation();">
{% if user_role in 'admin,technologist' %}
<button
type="button"
class="btn btn-outline-accent btn-sm"
data-bs-toggle="modal"
data-bs-target="#startProductionModal"
data-entity-id="{{ it.entity.id }}"
data-entity-label="{{ it.entity.drawing_number|default:'—' }} {{ it.entity.name }}"
>
<i class="bi bi-play-fill me-1"></i>В производство
</button>
{% else %}
<button type="button" class="btn btn-outline-secondary btn-sm" disabled>В производство</button>
{% endif %}
</td>
</tr>
{% empty %}
<tr><td colspan="5" class="text-center text-muted py-4">Пока нет позиций</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</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 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">
<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 %}
<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 }}">
<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>
</div>
<div class="modal-body">
<input type="hidden" name="task_id" id="modalTaskId">
<div class="small text-muted mb-2" id="modalTaskTitle"></div>
<div class="small text-muted mb-2" id="spTitle"></div>
<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">
<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 %}
<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>
{% endfor %}
</div>
</div>
<div class="mb-2">
<label class="form-label small text-muted">Сколько в план (шт)</label>
<input type="number" min="1" class="form-control border-secondary" name="quantity_plan" id="modalQty" required>
<label class="form-label small text-muted">Сколько в смену (шт)</label>
<input type="number" min="1" class="form-control border-secondary" name="quantity_plan" id="wiQty" required>
</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 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>
<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>
</form>
</div>
</div>
<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.querySelectorAll('tr.task-row[data-href]').forEach(function (row) {
row.addEventListener('click', function (e) {
@@ -155,22 +781,39 @@ document.addEventListener('DOMContentLoaded', function () {
if (planEl) planEl.style.width = `${plan}%`;
});
const modal = document.getElementById('addToPlanModal');
const modal = document.getElementById('workItemModal');
if (!modal) return;
modal.addEventListener('shown.bs.modal', function (event) {
const btn = event.relatedTarget;
const taskId = btn.getAttribute('data-task-id');
const name = btn.getAttribute('data-task-name');
const entityId = btn.getAttribute('data-entity-id') || '';
const opId = btn.getAttribute('data-operation-id') || '';
const name = btn.getAttribute('data-task-name') || '';
const opName = btn.getAttribute('data-operation-name') || '';
const rem = btn.getAttribute('data-task-rem');
document.getElementById('modalTaskId').value = taskId;
document.getElementById('modalTaskTitle').textContent = name;
document.getElementById('modalHint').textContent = rem !== null ? `Осталось: ${rem} шт` : '';
document.getElementById('wiEntityId').value = entityId;
document.getElementById('wiOperationId').value = opId;
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 = '';
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;
if (rem && !isNaN(parseInt(rem, 10))) {
remInt = Math.max(1, parseInt(rem, 10));
@@ -186,27 +829,65 @@ document.addEventListener('DOMContentLoaded', function () {
qty.onkeydown = function (e) {
if (e.key === 'Enter') {
e.preventDefault();
const form = document.querySelector('#addToPlanModal form');
const form = document.querySelector('#workItemModal form');
if (form) form.requestSubmit();
}
};
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 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;
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 => {
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 (_) {}
}
};
});
});
});
</script>
{% endblock %}
{% endblock %}

View 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 %}

View 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 %}

View 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">&nbsp;</td>
</tr>
{% endfor %}
</tbody>
</table>
{% empty %}
<p class="text-center">Нет данных для печати по выбранным фильтрам.</p>
{% endfor %}
</body>
</html>

View File

@@ -4,6 +4,18 @@
<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="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>
<div class="small text-muted mt-1">{{ entity.get_entity_type_display }}</div>
</div>
@@ -14,8 +26,8 @@
<i class="bi bi-plus-lg me-1"></i>Добавить
</button>
{% endif %}
{% if parent_id %}
<a class="btn btn-outline-secondary btn-sm" href="{% url 'product_detail' parent_id %}">Назад</a>
{% if back_url %}
<a class="btn btn-outline-secondary btn-sm" href="{{ back_url }}">Назад</a>
{% else %}
<a class="btn btn-outline-secondary btn-sm" href="{% url 'products' %}">Назад</a>
{% endif %}
@@ -36,10 +48,20 @@
</thead>
<tbody>
{% 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="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>
{% if ln.child.passport_filled %}
<i class="bi bi-check-circle-fill text-success" title="Заполнено"></i>
@@ -59,7 +81,9 @@
</td>
<td class="text-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">
{% csrf_token %}
@@ -225,20 +249,6 @@
</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' %}
<script>
document.addEventListener('DOMContentLoaded', () => {
@@ -252,30 +262,10 @@
<script>
document.addEventListener('DOMContentLoaded', () => {
const modalEl = document.getElementById('productInfoModal');
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 => {
document.querySelectorAll('tr.product-row[data-href]').forEach(tr => {
tr.addEventListener('click', () => {
const url = tr.getAttribute('data-info-url');
if (!url) return;
openInfo(url);
const url = tr.getAttribute('data-href');
if (url) window.location.href = url;
});
});
});

View File

@@ -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">
<form method="post" action="{% url 'product_info' entity.id %}" enctype="multipart/form-data">
{% csrf_token %}
<input type="hidden" name="action" value="save">
<input type="hidden" name="next" value="{{ next }}">
<input type="hidden" name="trail" value="{{ request.GET.trail|default:'' }}">
<div class="row g-2">
<div class="col-md-4">
@@ -36,14 +63,35 @@
{% endif %}
</div>
{% if not can_edit %}
<div class="col-md-6">
<label class="form-label">Маршрут</label>
<select class="form-select bg-body text-body border-secondary" name="route_id" {% if not can_edit %}disabled{% endif %}>
<option value="">— не указано —</option>
{% for r in routes %}
<option value="{{ r.id }}" {% if entity.route_id == r.id %}selected{% endif %}>{{ r.name }}</option>
{% endfor %}
</select>
<label class="form-label">Техпроцесс</label>
<div class="form-control bg-body text-body border-secondary">
{% if entity_ops %}
{% for eo in entity_ops %}
{{ eo.seq }}. {{ eo.operation.name }}{% if eo.operation.workshop %} ({{ eo.operation.workshop.name }}){% endif %}{% if not forloop.last %} · {% endif %}
{% endfor %}
{% 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 class="col-md-3">
@@ -80,15 +128,82 @@
</form>
{% if can_edit %}
<form method="post" action="{% url 'product_info' entity.id %}" class="mt-3 d-flex gap-2 align-items-center">
{% csrf_token %}
<input type="hidden" name="action" value="create_route">
<input type="hidden" name="next" value="{{ next }}">
<input class="form-control bg-body text-body border-secondary" name="route_name" placeholder="Новый маршрут">
<button class="btn btn-outline-secondary" type="submit">Добавить маршрут</button>
</form>
<div class="mt-3">
<div class="fw-bold mb-2">Операции техпроцесса</div>
<div class="table-responsive">
<table class="table table-hover mb-0 align-middle">
<thead>
<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 %}
<div class="mt-4">
<div class="fw-bold mb-2">Сварные швы</div>
@@ -110,9 +225,9 @@
<td>{{ s.leg_mm }}</td>
<td>{{ s.length_mm }}</td>
<td>{{ s.quantity }}</td>
<td class="text-end">
<td class="text-end" onclick="event.stopPropagation();">
{% 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 %}
<input type="hidden" name="action" value="delete_weld_seam">
<input type="hidden" name="next" value="{{ next }}">
@@ -157,4 +272,233 @@
</form>
{% 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>
{% endblock %}

View File

@@ -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">
<form method="post" action="{% url 'product_info' entity.id %}" enctype="multipart/form-data">
{% csrf_token %}
<input type="hidden" name="action" value="save">
<input type="hidden" name="next" value="{{ next }}">
<input type="hidden" name="trail" value="{{ request.GET.trail|default:'' }}">
<div class="row g-2">
<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 %}>
</div>
{% if not can_edit %}
<div class="col-md-3">
<label class="form-label">Маршрут</label>
<select class="form-select bg-body text-body border-secondary" name="route_id" {% if not can_edit %}disabled{% endif %}>
<option value="">— не указано —</option>
{% for r in routes %}
<option value="{{ r.id }}" {% if entity.route_id == r.id %}selected{% endif %}>{{ r.name }}</option>
{% endfor %}
</select>
<label class="form-label">Техпроцесс</label>
<div class="form-control bg-body text-body border-secondary">
{% if entity_ops %}
{% for eo in entity_ops %}
{{ eo.seq }}. {{ eo.operation.name }}{% if eo.operation.workshop %} ({{ eo.operation.workshop.name }}){% endif %}{% if not forloop.last %} · {% endif %}
{% endfor %}
{% else %}
{% endif %}
</div>
</div>
{% endif %}
<div class="col-md-6">
<label class="form-label">Чертёж (PDF)</label>
@@ -73,12 +105,81 @@
</form>
{% if can_edit %}
<form method="post" action="{% url 'product_info' entity.id %}" class="mt-3 d-flex gap-2 align-items-center">
{% csrf_token %}
<input type="hidden" name="action" value="create_route">
<input type="hidden" name="next" value="{{ next }}">
<input class="form-control bg-body text-body border-secondary" name="route_name" placeholder="Новый маршрут">
<button class="btn btn-outline-secondary" type="submit">Добавить маршрут</button>
</form>
<div class="mt-3">
<div class="fw-bold mb-2">Операции техпроцесса</div>
<div class="table-responsive">
<table class="table table-hover mb-0 align-middle">
<thead>
<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 %}
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -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">
{% csrf_token %}
<input type="hidden" name="action" value="save">
<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="col-md-4">
@@ -33,4 +61,7 @@
{% endif %}
</div>
</div>
</form>
</form>
</div>
</div>
{% endblock %}

View File

@@ -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">
<form method="post" action="{% url 'product_info' entity.id %}" enctype="multipart/form-data">
{% csrf_token %}
<input type="hidden" name="action" value="save">
<input type="hidden" name="next" value="{{ next }}">
<input type="hidden" name="trail" value="{{ request.GET.trail|default:'' }}">
<div class="row g-2">
<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 %}>
</div>
{% if not can_edit %}
<div class="col-md-6">
<label class="form-label">Маршрут</label>
<select class="form-select bg-body text-body border-secondary" name="route_id" {% if not can_edit %}disabled{% endif %}>
<option value="">— не указано —</option>
{% for r in routes %}
<option value="{{ r.id }}" {% if entity.route_id == r.id %}selected{% endif %}>{{ r.name }}</option>
{% endfor %}
</select>
<label class="form-label">Техпроцесс</label>
<div class="form-control bg-body text-body border-secondary">
{% if entity_ops %}
{% for eo in entity_ops %}
{{ eo.seq }}. {{ eo.operation.name }}{% if eo.operation.workshop %} ({{ eo.operation.workshop.name }}){% endif %}{% if not forloop.last %} · {% endif %}
{% endfor %}
{% else %}
— не указан —
{% endif %}
</div>
</div>
{% endif %}
<div class="col-md-6">
<label class="form-label">Чертёж/ТЗ (PDF)</label>
@@ -65,12 +97,81 @@
</form>
{% if can_edit %}
<form method="post" action="{% url 'product_info' entity.id %}" class="mt-3 d-flex gap-2 align-items-center">
{% csrf_token %}
<input type="hidden" name="action" value="create_route">
<input type="hidden" name="next" value="{{ next }}">
<input class="form-control bg-body text-body border-secondary" name="route_name" placeholder="Новый маршрут">
<button class="btn btn-outline-secondary" type="submit">Добавить маршрут</button>
</form>
<div class="mt-3">
<div class="fw-bold mb-2">Операции техпроцесса</div>
<div class="table-responsive">
<table class="table table-hover mb-0 align-middle">
<thead>
<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 %}
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -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">
<form method="post" action="{% url 'product_info' entity.id %}" enctype="multipart/form-data">
{% csrf_token %}
<input type="hidden" name="action" value="save">
<input type="hidden" name="next" value="{{ next }}">
<input type="hidden" name="trail" value="{{ request.GET.trail|default:'' }}">
<div class="row g-2">
<div class="col-md-4">
@@ -38,15 +65,20 @@
</select>
</div>
{% if not can_edit %}
<div class="col-md-6">
<label class="form-label">Маршрут</label>
<select class="form-select bg-body text-body border-secondary" name="route_id" {% if not can_edit %}disabled{% endif %}>
<option value="">— не указано —</option>
{% for r in routes %}
<option value="{{ r.id }}" {% if entity.route_id == r.id %}selected{% endif %}>{{ r.name }}</option>
{% endfor %}
</select>
<label class="form-label">Техпроцесс</label>
<div class="form-control bg-body text-body border-secondary">
{% if entity_ops %}
{% for eo in entity_ops %}
{{ eo.seq }}. {{ eo.operation.name }}{% if eo.operation.workshop %} ({{ eo.operation.workshop.name }}){% endif %}{% if not forloop.last %} · {% endif %}
{% endfor %}
{% else %}
— не указан —
{% endif %}
</div>
</div>
{% endif %}
<div class="col-md-3">
<label class="form-label">Толщина, мм</label>
@@ -116,12 +148,81 @@
</form>
{% if can_edit %}
<form method="post" action="{% url 'product_info' entity.id %}" class="mt-3 d-flex gap-2 align-items-center">
{% csrf_token %}
<input type="hidden" name="action" value="create_route">
<input type="hidden" name="next" value="{{ next }}">
<input class="form-control bg-body text-body border-secondary" name="route_name" placeholder="Новый маршрут">
<button class="btn btn-outline-secondary" type="submit">Добавить маршрут</button>
</form>
<div class="mt-3">
<div class="fw-bold mb-2">Операции техпроцесса</div>
<div class="table-responsive">
<table class="table table-hover mb-0 align-middle">
<thead>
<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 %}
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -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">
<form method="post" action="{% url 'product_info' entity.id %}" enctype="multipart/form-data">
{% csrf_token %}
<input type="hidden" name="action" value="save">
<input type="hidden" name="next" value="{{ next }}">
<input type="hidden" name="trail" value="{{ request.GET.trail|default:'' }}">
<div class="row g-2">
<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 %}>
</div>
{% if not can_edit %}
<div class="col-md-6">
<label class="form-label">Маршрут</label>
<select class="form-select bg-body text-body border-secondary" name="route_id" {% if not can_edit %}disabled{% endif %}>
<option value="">— не указано —</option>
{% for r in routes %}
<option value="{{ r.id }}" {% if entity.route_id == r.id %}selected{% endif %}>{{ r.name }}</option>
{% endfor %}
</select>
<label class="form-label">Техпроцесс</label>
<div class="form-control bg-body text-body border-secondary">
{% if entity_ops %}
{% for eo in entity_ops %}
{{ eo.seq }}. {{ eo.operation.name }}{% if eo.operation.workshop %} ({{ eo.operation.workshop.name }}){% endif %}{% if not forloop.last %} · {% endif %}
{% endfor %}
{% else %}
— не указан —
{% endif %}
</div>
</div>
{% endif %}
<div class="col-md-6">
<label class="form-label">Чертёж/паспорт (PDF)</label>
@@ -68,12 +100,81 @@
</form>
{% if can_edit %}
<form method="post" action="{% url 'product_info' entity.id %}" class="mt-3 d-flex gap-2 align-items-center">
{% csrf_token %}
<input type="hidden" name="action" value="create_route">
<input type="hidden" name="next" value="{{ next }}">
<input class="form-control bg-body text-body border-secondary" name="route_name" placeholder="Новый маршрут">
<button class="btn btn-outline-secondary" type="submit">Добавить маршрут</button>
</form>
<div class="mt-3">
<div class="fw-bold mb-2">Операции техпроцесса</div>
<div class="table-responsive">
<table class="table table-hover mb-0 align-middle">
<thead>
<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 %}
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -12,16 +12,20 @@
</button>
{% endif %}
<form class="d-flex 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;">
<option value="" {% if not entity_type %}selected{% endif %}>Все типы</option>
<option value="product" {% if entity_type == 'product' %}selected{% endif %}>Готовое изделие</option>
<option value="assembly" {% if entity_type == 'assembly' %}selected{% endif %}>Сборочная единица</option>
<option value="part" {% if entity_type == 'part' %}selected{% endif %}>Деталь</option>
<option value="purchased" {% if entity_type == 'purchased' %}selected{% endif %}>Покупное</option>
<option value="casting" {% if entity_type == 'casting' %}selected{% endif %}>Литьё</option>
<option value="outsourced" {% if entity_type == 'outsourced' %}selected{% endif %}>Аутсорс</option>
</select>
<form class="d-flex flex-wrap gap-2 align-items-center" method="get" action="{% url 'products' %}">
<div class="btn-group" role="group" aria-label="Фильтр типов">
<input type="checkbox" class="btn-check" id="tProduct" name="types" value="product" {% if 'product' in entity_types %}checked{% endif %}>
<label class="btn btn-outline-secondary btn-sm" for="tProduct">Изделие</label>
<input type="checkbox" class="btn-check" id="tAssembly" name="types" value="assembly" {% if 'assembly' in entity_types %}checked{% endif %}>
<label class="btn btn-outline-secondary btn-sm" for="tAssembly">СБ</label>
<input type="checkbox" class="btn-check" id="tPart" name="types" value="part" {% if 'part' in entity_types %}checked{% endif %}>
<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;">
<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 data-sort="false"></th>
</tr>
</thead>
<tbody>
{% 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="fw-bold">{{ p.drawing_number|default:"—" }}</td>
<td>{{ p.name }}</td>
@@ -62,11 +65,6 @@
<i class="bi bi-circle text-muted" title="Не заполнено"></i>
{% endif %}
</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>
{% empty %}
<tr><td colspan="5" class="text-center text-muted py-4">Пока ничего нет</td></tr>
@@ -115,46 +113,32 @@
</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>
document.addEventListener('DOMContentLoaded', () => {
const modalEl = document.getElementById('productInfoModal');
const bodyEl = document.getElementById('productInfoBody');
if (!modalEl || !bodyEl) return;
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 modal = new bootstrap.Modal(modalEl);
const form = document.querySelector('form[action="{% url 'products' %}"][method="get"]');
if (!form) return;
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>';
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) {
const productBox = boxes.find(b => b.value === 'product');
if (productBox) productBox.checked = true;
}
}
document.querySelectorAll('tr.product-row[data-info-url]').forEach(tr => {
tr.addEventListener('click', () => {
const url = tr.getAttribute('data-info-url');
if (!url) return;
openInfo(url);
form.querySelectorAll('input[type="checkbox"][name="types"]').forEach(cb => {
cb.addEventListener('change', () => {
ensureAtLeastOneTypeChecked();
form.submit();
});
});
});

View File

@@ -7,12 +7,12 @@
<div class="card-header border-secondary py-3 d-flex justify-content-between align-items-center">
<h3 class="text-accent mb-0"><i class="bi bi-list-task me-2"></i>Реестр заданий</h3>
{% if user_role in 'admin,technologist,master' %}
<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>Печать
</a>
{% endif %}
</div>
{% include 'shiftflow/partials/_items_table.html' with items=items %}
{% include 'shiftflow/partials/_workitems_table.html' with workitems=workitems %}
</div>
{% endblock %}

View 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>

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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>

View 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 %}

View 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 %}

View File

@@ -32,14 +32,30 @@
<div class="small text-muted">По производственным отчетам</div>
</div>
<div class="card-body">
{% for card in report_cards %}
<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="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="d-flex flex-wrap justify-content-between gap-2 align-items-center">
<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 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 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 %})
{{ c.quantity|floatformat:"-g" }} шт
</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 %}
<li>{{ c.material }} {{ c.quantity|floatformat:"-g" }} шт</li>
{% else %}
@@ -81,10 +103,14 @@
<div class="col-lg-4">
<div class="small text-muted fw-bold mb-1">Остаток ДО</div>
{% if card.remnants %}
{% if card.report.remnants.all %}
<ul class="mb-0">
{% for k,v in card.remnants.items %}
<li>{{ k }}: {{ v }} шт</li>
{% 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 %}
@@ -93,79 +119,16 @@
</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>Факт</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>
{% empty %}
<div class="text-muted">За выбранный период отчётов нет.</div>
{% endfor %}
</div>
{% if can_edit %}
<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 %}>
Отметить выбранные как «Списано в 1С»
</button>
<button type="submit" class="btn btn-outline-accent">Отметить выбранные как «Выгружено в 1С»</button>
</div>
{% endif %}
</form>
</div>
{% endblock %}

View File

@@ -6,28 +6,57 @@ from .views import (
DealDetailView,
DealPlanningView,
DealUpsertView,
DealBatchActionView,
DealItemUpsertView,
DirectoriesView,
SupplyCatalogView,
LocationsCatalogView,
WorkshopsCatalogView,
MachinesCatalogView,
EntitiesSearchView,
IndexView,
ItemUpdateView,
MaintenanceStatusView,
MaintenanceView,
MaterialCategoriesCatalogView,
MaterialCategoryUpsertView,
MaterialDetailView,
MaterialUpsertView,
MaterialsCatalogView,
PlanningAddView,
PlanningView,
PlanningStagesView,
ProductionTaskCreateView,
WeldingPlanAddView,
PaintingPlanAddView,
WorkItemPlanAddView,
WorkItemUpdateView,
RegistryPrintView,
WorkItemDetailView,
WorkItemEntityListView,
WorkItemOpClosingView,
WorkItemKittingView,
WorkItemKittingPrintView,
AssemblyClosingView,
WorkItemRegistryPrintView,
RegistryView,
SteelGradesCatalogView,
SteelGradeUpsertView,
TaskItemsView,
ClosingView,
ClosingWorkItemsView,
ProductDetailView,
ProductEntityPreviewUpdateView,
ProductInfoView,
ProductsView,
WriteOffsView,
LegacyClosingView,
LegacyRegistryView,
LegacyWriteOffsView,
WarehouseReceiptCreateView,
WarehouseStocksView,
WarehouseTransferCreateView,
ProcurementDashboardView,
)
urlpatterns = [
@@ -36,6 +65,7 @@ urlpatterns = [
# Реестр
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/deal/<int:pk>/', DealPlanningView.as_view(), name='planning_deal'),
@@ -46,25 +76,55 @@ urlpatterns = [
path('maintenance/status/', MaintenanceStatusView.as_view(), name='maintenance_status'),
path('planning/add/', PlanningAddView.as_view(), name='planning_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/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/material/<int:pk>/json/', MaterialDetailView.as_view(), name='material_json'),
path('planning/material/upsert/', MaterialUpsertView.as_view(), name='material_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('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/workitems/print/', WorkItemRegistryPrintView.as_view(), name='registry_workitems_print'),
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/transfer/', WarehouseTransferCreateView.as_view(), name='warehouse_transfer'),
path('warehouse/receipt/', WarehouseReceiptCreateView.as_view(), name='warehouse_receipt'),
path('closing/', ClosingView.as_view(), name='closing'),
path('closing/workitems/', ClosingWorkItemsView.as_view(), name='closing_workitems'),
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/<int:pk>/', ProductDetailView.as_view(), name='product_detail'),
path('products/<int:pk>/info/', ProductInfoView.as_view(), name='product_info'),
path('products/<int:pk>/preview/update/', ProductEntityPreviewUpdateView.as_view(), name='product_preview_update'),
]

File diff suppressed because it is too large Load Diff

View File

@@ -5,24 +5,26 @@ body {
flex-direction: column;
min-height: 100vh;
/* Убрали общее центрирование, чтобы реестр был сверху */
justify-content: flex-start;
justify-content: flex-start;
}
/* Навбар и Футер: жестко фиксируем цвет для обеих тем */
.navbar, .footer-custom {
.navbar,
.footer-custom {
/* Темный графит, который хорошо смотрится и там, и там */
background-color: #2c3034 !important;
background-color: #2c3034 !important;
border-bottom: 1px solid #3d4246 !important;
border-top: 1px solid #3d4246 !important; /* Для футера */
border-top: 1px solid #3d4246 !important;
/* Для футера */
}
/* Принудительно светлый текст для футера и навбара */
.navbar .nav-link,
.navbar .navbar-brand,
.footer-custom span,
.navbar .nav-link,
.navbar .navbar-brand,
.footer-custom span,
.footer-custom strong,
.footer-custom .text-muted {
color: #e9ecef !important;
color: #e9ecef !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;
}
@@ -46,27 +50,48 @@ body {
/* Подсветка при наведении */
.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"] {
--bs-body-bg: #121212; /* Глубокий черный фон */
--bs-body-color: #e9ecef; /* Светло-серый текст */
--bs-accent: #ffc107; /* Желтый акцент (Amber) */
--bs-body-bg: #121212;
/* Глубокий черный фон */
--bs-body-color: #e9ecef;
/* Светло-серый текст */
--bs-accent: #ffc107;
/* Желтый акцент (Amber) */
}
[data-bs-theme="light"] {
--bs-body-bg: #f8f9fa; /* Почти белый фон */
--bs-body-color: #212529; /* Темный текст */
--bs-accent: #0d6efd; /* Синий акцент для светлой темы */
--bs-body-bg: #f8f9fa;
/* Почти белый фон */
--bs-body-color: #212529;
/* Темный текст */
--bs-accent: #0d6efd;
/* Синий акцент для светлой темы */
}
[data-bs-theme="dark"] input[type="date"] { color-scheme: dark; }
[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; }
[data-bs-theme="dark"] input[type="date"] {
color-scheme: dark;
}
[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;
color: var(--bs-accent) !important;
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 {
@@ -96,7 +124,7 @@ body {
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 {
background-color: var(--bs-accent) !important;
@@ -104,14 +132,14 @@ body {
}
[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 {
color: #212529 !important;
}
[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 {
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 {
display: flex;
flex-grow: 1;
align-items: center; /* Центр по вертикали */
justify-content: center; /* Центр по горизонтали */
align-items: center;
/* Центр по вертикали */
justify-content: center;
/* Центр по горизонтали */
}

View 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 %}

View File

@@ -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>

View File

@@ -14,11 +14,24 @@
<li class="nav-item">
<a class="nav-link {% if request.resolver_match.url_name == 'registry' %}active{% endif %}" href="{% url 'registry' %}">Реестр</a>
</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' %}
<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>
</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">
<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>
@@ -27,24 +40,51 @@
</li>
{% 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">
<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>
{% 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">
<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>
{% 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">
<a class="nav-link {% if request.resolver_match.url_name == 'writeoffs' %}active{% endif %}" href="{% url 'writeoffs' %}">Списание</a>
</li>
{% 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' %}
<li class="nav-item">
<a class="nav-link {% if request.resolver_match.url_name == 'maintenance' %}active{% endif %}" href="{% url 'maintenance' %}">Обслуживание сервера</a>

View 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='Масса на ед. учёта'),
),
]

View File

@@ -43,6 +43,7 @@ class Material(models.Model):
steel_grade = models.ForeignKey(SteelGrade, on_delete=models.PROTECT, verbose_name="Марка стали", null=True, blank=True)
name = models.CharField("Наименование (размер/характеристики)", max_length=255)
full_name = models.CharField("Полное наименование", max_length=500, blank=True, help_text="Генерируется автоматически")
mass_per_unit = models.FloatField("Масса на ед. учёта", null=True, blank=True)
class Meta:
verbose_name = "Материал (номенклатура)"

View File

@@ -57,9 +57,11 @@ def receive_transfer(transfer_id: int, receiver_id: int) -> None:
StockItem.objects.create(
material=src.material,
entity=src.entity,
deal=src.deal,
location_id=tr.to_location_id,
quantity=float(ln.quantity),
is_remnant=src.is_remnant,
is_customer_supplied=src.is_customer_supplied,
current_length=src.current_length,
current_width=src.current_width,
)