Compare commits

...

36 Commits

Author SHA1 Message Date
2603c8f51c Подправил фильтр цехов на странице реестра
All checks were successful
Deploy MES Core / deploy (push) Successful in 11s
2026-04-14 08:09:23 +03:00
9006f4c5ab Добавил фильтр статуса на страницу списания
All checks were successful
Deploy MES Core / deploy (push) Successful in 12s
2026-04-14 07:43:36 +03:00
49e9080d0e Добавил страницу отгрузки, подправил логику генерации сменных заданий. Организовал редактирование позици сделок
All checks were successful
Deploy MES Core / deploy (push) Successful in 29s
2026-04-14 07:27:54 +03:00
69edd3fa97 Переезд на схему нового доступа
All checks were successful
Deploy MES Core / deploy (push) Successful in 11s
2026-04-13 08:26:07 +03:00
ecc0193d0a Выводим размеры сырья на складах
All checks were successful
Deploy MES Core / deploy (push) Successful in 11s
2026-04-13 08:14:57 +03:00
fa881877d7 Дал мастеру доступ к пунктам
All checks were successful
Deploy MES Core / deploy (push) Successful in 11s
2026-04-13 08:10:21 +03:00
28537447f8 Конкретно пересмотрел логику работы. Легаси вынесена в архив
All checks were successful
Deploy MES Core / deploy (push) Successful in 13s
2026-04-13 07:36:57 +03:00
86215c9fa8 Открыл админу изделия, доработал списание
All checks were successful
Deploy MES Core / deploy (push) Successful in 10s
2026-04-07 12:57:43 +03:00
a238c83b04 ДОбавил изделия и заполнение спецификции изделия
All checks were successful
Deploy MES Core / deploy (push) Successful in 3m27s
2026-04-07 12:09:46 +03:00
eb708a3ab7 Исправил закрытие сделки. добавил черновик страницы для списания в 1С
All checks were successful
Deploy MES Core / deploy (push) Successful in 13s
2026-04-06 21:15:43 +03:00
e88b861f68 Огромная замена логики
All checks were successful
Deploy MES Core / deploy (push) Successful in 11s
2026-04-06 08:06:37 +03:00
0e8497ab1f Подправил превью при добавлении, и автооределение тошщины с кол-вом
All checks were successful
Deploy MES Core / deploy (push) Successful in 9s
2026-04-03 02:46:38 +03:00
e1dd002102 Уменьшили версию нумпая для генерации превьюшек
All checks were successful
Deploy MES Core / deploy (push) Successful in 2m12s
2026-04-03 02:19:10 +03:00
9ad109e02a Не работали матплотлиб и ездхф ставим библиотеки при сборке образа
All checks were successful
Deploy MES Core / deploy (push) Successful in 3m7s
2026-04-03 02:08:46 +03:00
1fe05d41f6 Пытаемся угомонить превьюшки
All checks were successful
Deploy MES Core / deploy (push) Successful in 10s
2026-04-03 01:58:23 +03:00
b76ce4913f Сортировка в таблицах и попытка приструнить генерацию превьюшек
All checks were successful
Deploy MES Core / deploy (push) Successful in 13s
2026-04-03 01:10:05 +03:00
cddbfeadde Добавил превьюшки дхф и настройки сервера
All checks were successful
Deploy MES Core / deploy (push) Successful in 3m32s
2026-04-02 23:52:04 +03:00
9554d47301 Открыл мастеру возможность просмотра сделок и потребность в деталях, подправил окно редактирования позиции сделки, подправил работу фильтра
All checks were successful
Deploy MES Core / deploy (push) Successful in 10s
2026-04-02 22:05:41 +03:00
7cb00792ca Статус выполнеия задания на деталь, мастер может менять станки
All checks were successful
Deploy MES Core / deploy (push) Successful in 9s
2026-04-02 08:21:09 +03:00
d0289f6aec Фильтры сохраняются, мастер получил расширенные возможности
All checks were successful
Deploy MES Core / deploy (push) Successful in 10s
2026-04-01 01:04:46 +03:00
c2778d9ec8 Теперь сделки на странице планирования
All checks were successful
Deploy MES Core / deploy (push) Successful in 10s
2026-03-31 08:31:54 +03:00
c9ff66a36b Создали планирование
All checks were successful
Deploy MES Core / deploy (push) Successful in 12s
2026-03-30 01:39:22 +03:00
78d4a1a04f Доработали апдейт пунктов заданий
All checks were successful
Deploy MES Core / deploy (push) Successful in 9s
2026-03-30 00:18:00 +03:00
ff0b791a24 Доработали генерацию сменных заданий
All checks were successful
Deploy MES Core / deploy (push) Successful in 10s
2026-03-29 23:19:13 +03:00
6013d5854b Доработали фильт в реестре заданий
All checks were successful
Deploy MES Core / deploy (push) Successful in 10s
2026-03-29 20:29:05 +03:00
7ef7409c7a Поменял логику определения окружения дев/сервер прибив сервер в докер компосе
All checks were successful
Deploy MES Core / deploy (push) Successful in 9s
2026-03-29 16:19:59 +03:00
fc469aaac4 Поменял структуру моделей и сервер
All checks were successful
Deploy MES Core / deploy (push) Successful in 10s
2026-03-29 16:05:51 +03:00
191d06d7d3 Поменял структуру моделей
Some checks failed
Deploy MES Core / deploy (push) Failing after 1s
2026-03-29 16:04:02 +03:00
641abfff5e Оживил с китайским другом кнопку гамбургера
All checks were successful
Deploy MES Core / deploy (push) Successful in 9s
2026-03-29 11:47:59 +03:00
tertelius
b256bec04b добавил детальный вид итема, пока недопиленый
All checks were successful
Deploy MES Core / deploy (push) Successful in 8s
2026-03-29 02:49:28 +03:00
tertelius
a4ba577206 начал работать с интерфейсом
All checks were successful
Deploy MES Core / deploy (push) Successful in 9s
2026-03-29 00:27:29 +03:00
f86f0bfcd4 Подправил админку под новые модели
All checks were successful
Deploy MES Core / deploy (push) Successful in 8s
2026-03-28 11:51:50 +03:00
f759c2c17e Обновил модели: добавил Компании и Габариты
All checks were successful
Deploy MES Core / deploy (push) Successful in 9s
2026-03-28 11:49:01 +03:00
fba195db9c Подправил докерфайл сборки контейнера
All checks were successful
Deploy MES Core / deploy (push) Successful in 2m19s
2026-03-28 10:11:52 +03:00
12c07a8108 подправил порты
All checks were successful
Deploy MES Core / deploy (push) Successful in 9s
2026-03-27 22:21:43 +03:00
5648e13ef6 подправил порты 2026-03-27 22:19:22 +03:00
168 changed files with 23079 additions and 65 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 %}

9
.env
View File

@@ -1,12 +1,15 @@
SECRET_KEY='django-insecure-9p*t_(vtwo144=u)ie8qzb31a8%i(&1w4$0vq#udautexj^vms'
# Настройки базы данных
DB_NAME=prodman_db
DB_USER=prodman_user
DB_PASS=prodman_password_zwE45t!
# Настройки Django
ENV_TYPE=server
DB_HOST=db
SECRET_KEY='django-insecure-9p*t_(vtwo144=u)ie8qzb31a8%i(&1w4$0vq#udautexj^vms'
# todo потом установить домен для продакшена
ALLOWED_HOSTS=192.168.1.108,shiftflow.tertelius.space
CSRF_ORIGINS=http://192.168.1.108,https://shiftflow.tertelius.space
CSRF_ORIGINS=http://192.168.1.108,https://shiftflow.tertelius.space
DJANGO_SUPERUSER_USERNAME=ack
DJANGO_SUPERUSER_PASSWORD=123
DJANGO_SUPERUSER_EMAIL=admin@test.ru

3
.gitignore vendored
View File

@@ -3,6 +3,7 @@
__pycache__/
*.py[cod]
*$py.class
logs/
# C extensions
*.so
@@ -61,6 +62,8 @@ cover/
local_settings.py
db.sqlite3
db.sqlite3-journal
# Media
media/
# Flask stuff:
instance/

90
.trae/rules/main.md Normal file
View File

@@ -0,0 +1,90 @@
# AI_RULES — правила работы ассистента в проекте MES_Core
Роль: Ты Senior Django Backend Developer.
Контекст: Разрабатывается MES/ERP система для металлообрабатывающего завода. Архитектура БД разделена на 3 приложения: warehouse, manufacturing, shiftflow.
Задача: Разработать слой бизнес-логики (сервисы и CBV Views) для реализации сквозного процесса производства.
# AI_RULES — правила работы ассистента в проекте MES_Core
## 1) Коммуникация
- Пиши по-русски всегда.
## 2) Изменения в коде
- Сначала читай файл и только потом предлагай правки (чтобы не ломать стиль и импорты).
## 3) Создание новых файлов
- Для новых файлов звсегда указывай: полное имя, абсолютный путь и весь контент в одном блоке.
## 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 там, где это безопасно.
## 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: режим "только просмотр".
Правило для новых внутренних функций (как договор):
- Всегда берём логгер logger = logging.getLogger('mes')
- Перед выполнением — logger.info('fn:start ...', ключевые параметры)
- После успешного выполнения — logger.info('fn:done ...', ключевые результаты)
- На важных шагах — logger.info('fn:step ...', детали)
- Исключение — с context: logger.exception('fn:error ...') — не глотаем, пробрасываем дальше

20
.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,20 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Django: Runserver",
"type": "python",
"request": "launch",
"program": "${workspaceFolder}/manage.py",
"args": [
"runserver"
],
"django": true,
"justMyCode": true,
// Это заставит сервер перезапускаться при изменении кода
"autoReload": {
"enable": true
}
}
]
}

42
.vscode/settings.json vendored
View File

@@ -1,3 +1,43 @@
{
"python-envs.pythonProjects": []
// --- ПИТОН И АВТОМАТИКА ---
"python.analysis.typeCheckingMode": "basic", // Подсказки по типам данных
"editor.formatOnSave": true, // Форматировать код при сохранении (маст-хэв!)
"python.formatting.provider": "black", // Использовать Black для красоты кода
"editor.codeActionsOnSave": {
"source.organizeImports": "explicit" // Сам расставит импорты по алфавиту
},
// --- DJANGO И HTML ---
"files.associations": {
"**/*.html": "django-html", // Чтобы VS Code понимал синтаксис {% if %}
"**/templates/**/*.html": "django-html"
},
"emmet.includeLanguages": {
"django-html": "html" // Чтобы Emmet (развертывание тегов через Tab) работал в шаблонах
},
// --- ИНТЕРФЕЙС И КОМФОРТ ---
"editor.bracketPairColorization.enabled": true, // Цветные скобочки (чтобы не путаться в вложенности)
"editor.guide.bracketPairs": "active",
"editor.fontSize": 14, // Подбери под свои глаза
"editor.tabSize": 4,
"editor.renderWhitespace": "boundary", // Видеть лишние пробелы в конце строк
"files.autoSave": "onFocusChange", // Сохранять файл, когда переключаешься в браузер
// --- ЧИСТОТА В ПРОЕКТЕ ---
"files.exclude": {
"**/.git": true,
"**/__pycache__": true, // Прячем мусорные папки питона
"**/*.pyc": true,
"**/node_modules": true,
"**/.DS_Store": true
},
// --- ТЕРМИНАЛ ---
"terminal.integrated.fontSize": 13,
"terminal.integrated.cursorStyle": "line",
// --- ГИТ ---
"git.autofetch": true, // Проверять обновления в репозитории самостоятльно
"git.confirmSync": false
}

65
AI_RULES.md Normal file
View File

@@ -0,0 +1,65 @@
# 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 там, где это безопасно.

View File

@@ -1,6 +1,42 @@
# Используем "легкую" версию Python на базе Debian Bookworm.
# slim — это баланс между размером образа и наличием нужных утилит.
FROM python:3.12-slim
# Указываем рабочую папку внутри контейнера. Все последующие команды будут выполняться в ней.
WORKDIR /app
# Настройки окружения:
# 1. Запрещаем Python писать файлы .pyc (байткод) на диск, чтобы не мусорить.
ENV PYTHONDONTWRITEBYTECODE=1
# 2. Отключаем буферизацию логов. Так ты сразу увидишь ошибки в `docker logs`, а не будешь их ждать.
ENV PYTHONUNBUFFERED=1
# Ставим системные зависимости:
# apt-get update — обновляем списки пакетов.
# gcc и libpq-dev — необходимы для сборки библиотеки psycopg2 (драйвер для Postgres).
# rm -rf /var/lib/apt/lists/* — удаляем кэш установщика, чтобы уменьшить размер образа.
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc libpq-dev \
libfreetype6 libpng16-16 \
fonts-dejavu-core \
&& rm -rf /var/lib/apt/lists/*
# Сначала копируем только список зависимостей.
# Это нужно для "кэширования слоев": если ты не менял библиотеки, Docker не будет переустанавливать их заново при сборке.
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Теперь копируем весь остальной код проекта в контейнер.
COPY . .
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "core.wsgi:application"]
# Даем права на выполнение нашему скрипту запуска.
# Без этого контейнер может упасть с ошибкой "Permission denied".
RUN chmod +x /app/entrypoint.sh
# ENTRYPOINT — это команда, которая выполняется ВСЕГДА при старте.
# Наш скрипт подготовит базу (миграции) и соберет статику.
ENTRYPOINT ["/app/entrypoint.sh"]
# CMD — это основная команда процесса.
# Запускаем Gunicorn, привязываем его к порту 8000 и ставим 3 рабочих процесса для скорости.
CMD ["gunicorn", "core.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "3"]

110
README.md
View File

@@ -1,3 +1,111 @@
# mes_core
Система управления производством
Система управления производством (ShiftFlow MES)
## 📥 Как пушить код (Шпаргалка)
Если внес изменения в проект и готов отправить их на сервер `ProdServ`:
1. **Подготовь файлы**:
git add .
2. **Запечатай изменения**:
git commit -m "Подправил докерфайл сборки контейнера"
3. **Отправляй в полет**:
git push origin main
### 💊 Таблетка: Если не пушится (сброс авторизации)
Если Git "забыл" пароль или выдает ошибку Permission Denied:
# Очищаем старые привязки
```bash
git config --global --unset credential.helper
```
# Устанавливаем менеджер заново (для Windows)
```bash
git config --global credential.helper manager
```
# Снова пушим и вводим логин/пароль в окне
```bash
git push origin main
```
# 🚀 ShiftFlow MES - Инструкция по деплою (Production)
### 🧩 Общая архитектура
Система работает в связке из 3-х контейнеров:
1. **db**: PostgreSQL 15 (хранит все данные).
2. **web**: Django + Gunicorn (логика приложения).
3. **nginx**: Веб-сервер (раздает статику и проксирует запросы на Django).
---
### 🛠 Как это работает (Автоматизация)
Мы используем **CI/CD через Gitea Actions**. Тебе не нужно заходить на сервер руками.
1. **Push в main**: Как только ты пушишь код, раннер на `ProdServ` просыпается.
2. **Сборка**: Docker пересобирает образ `web`, если изменились `requirements.txt` или код.
3. **Запуск**:
- `entrypoint.sh` автоматически накатывает миграции (`migrate`).
- `collectstatic` собирает все стили в общую папку для Nginx.
- Создается суперпользователь (если его еще нет) из данных `.env`.
---
### 📁 Важные папки на сервере `ProdServ`
Проект живет здесь: `/home/ack/projects/mes_core/`
* **staticfiles/** — здесь лежат стили и скрипты. Если админка стала "белой", проверь права этой папки (`chmod 755`).
* **media/** — здесь будут лежать загруженные фото и файлы.
* **.env** — здесь лежат все пароли. **Никогда не удаляй его!**
---
### 🆘 Что делать, если "все упало"?
Если сайт по адресу `192.168.1.108` не открывается:
1. Проверь логи контейнеров: `docker compose logs -f`.
2. Убедись, что порты в `docker-compose.yml` стоят `80:80`.
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`

52
TODO.md Normal file
View File

@@ -0,0 +1,52 @@
# TODO (MES_Core)
## Склады (UI)
- Доработать сортировку по дате «Поступление» (стабильно сортировать как datetime, а не как текст).
- По клику на строку открывать карточку «Единица на складе» (read-only для observer, редактирование для admin/technologist/master/clerk):
- правка: сделка, давальческий, размеры (лист/хлыст), количество, примечание (если добавим)
- отображение: история перемещений/приходов/отгрузок (если потребуется).
- Реализовать инвентаризацию складов участков/цехов:
- сценарий: фактический пересчёт, ввод корректировок (излишек/недостача), фиксация причины
- хранить историю инвентаризаций и разницы по позициям
- права: master/clerk/admin, read-only для observer
## Доступы (UI)
- Доработать видимость и действия для разных ролей/цехов: фильтрация по allowed_workshops, замещение, read-only руководитель.
## Списание (UI)
- Доработать страницу «Списание»: фильтры, удобная сводка по материалам/изделиям и отметка «внесено в 1С».
## Потребность (Материалы)
- Пересмотреть расчёт потребности: уйти от м²/мм, формировать пачки 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)

View File

@@ -13,19 +13,26 @@ https://docs.djangoproject.com/en/6.0/ref/settings/
import os
from pathlib import Path
import environ
import logging
from logging.handlers import RotatingFileHandler
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
env = environ.Env()
environ.Env.read_env() # Читаем файл .env
# environ.Env.read_env() # Читаем файл .env
# Явно указываем путь к файлу в корне
env_file = os.path.join(BASE_DIR, ".env")
if os.path.exists(env_file):
environ.Env.read_env(env_file)
# читаем переменную окружения
ENV_TYPE = os.getenv('ENV_TYPE', 'local')
# Настройки безопасности
# DEBUG будет True везде, кроме сервера
# DEBUG будет True везде, кроме сервера
DEBUG = ENV_TYPE != 'server'
@@ -54,7 +61,9 @@ INSTALLED_APPS = [
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'shiftflow', # Вот это допиши обязательно!
'shiftflow',
'warehouse',
'manufacturing',
]
MIDDLEWARE = [
@@ -81,6 +90,8 @@ TEMPLATES = [
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
'shiftflow.context_processors.env_info',
'shiftflow.context_processors.authz_info',
],
},
},
@@ -164,9 +175,56 @@ USE_TZ = True
STATIC_URL = 'static/'
STATIC_ROOT = BASE_DIR / 'staticfiles'
STATICFILES_DIRS = [BASE_DIR / 'static']
MEDIA_URL = 'media/'
MEDIA_ROOT = BASE_DIR / 'media'
# Логирование (единый файл на сервер)
LOG_DIR = BASE_DIR / 'logs'
try:
os.makedirs(LOG_DIR, exist_ok=True)
except Exception:
pass
LOGGING = {
'version': 1,
'disable_existing_loggers': False,
'formatters': {
'simple': {
'format': '%(asctime)s [%(levelname)s] %(name)s: %(message)s'
},
},
'handlers': {
'mes_file': {
'class': 'logging.handlers.RotatingFileHandler',
'level': 'INFO',
'filename': str(LOG_DIR / 'mes.log'),
'maxBytes': 5 * 1024 * 1024,
'backupCount': 3,
'encoding': 'utf-8',
'formatter': 'simple',
},
'console': {
'class': 'logging.StreamHandler',
'level': 'INFO',
'formatter': 'simple',
},
},
'loggers': {
'mes': {
'handlers': ['mes_file', 'console'],
'level': 'INFO',
'propagate': False,
},
},
}
# Куда переходим после логина
LOGIN_REDIRECT_URL = '/' # Куда идем после входа
LOGOUT_REDIRECT_URL = '/' # Куда идем после выхода
# Доверяем прокси-серверу передавать заголовки
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
@@ -179,4 +237,5 @@ SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
CSRF_TRUSTED_ORIGINS = env.list('CSRF_ORIGINS', default=['http://localhost'])
print(f"--- РАБОТАЕМ НА БАЗЕ: {DATABASES['default']['NAME']} (HOST: {DATABASES['default'].get('HOST', 'localhost')}) ---")

View File

@@ -15,8 +15,23 @@ Including another URLconf
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
"""
from django.contrib import admin
from django.urls import path
from django.urls import path, include
from django.views.generic import RedirectView
from django.templatetags.static import static as static_url
from django.conf.urls.static import static # <--- Добавьте эту строку
from core import settings
urlpatterns = [
path('favicon.ico', RedirectView.as_view(url=static_url('favicon.svg'), permanent=True)),
path('admin/', admin.site.urls),
# Добавь эту строку, она подключит login, logout и прочие стандартные пути
path('accounts/', include('django.contrib.auth.urls')),
# Подключаем урлы нашего приложения
path('', include('shiftflow.urls')),
]
# Вместо if settings.DEBUG: не забываем from django.conf.urls.static import static # <--- Добавьте эту строку
if settings.ENV_TYPE in ['local', 'dev']:
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)

View File

@@ -1,41 +1,55 @@
name: prodman
name: prodman # Имя проекта, которое будет префиксом для всех контейнеров и сетей
services:
# --- БАЗА ДАННЫХ ---
db:
image: postgres:15-alpine
restart: unless-stopped
image: postgres:15-alpine # Легкий образ Postgres на базе Alpine Linux
restart: unless-stopped # Перезапускать всегда, кроме случаев, когда ты сам его выключил
environment:
# Данные тянутся из твоего файла .env
- POSTGRES_DB=${DB_NAME}
- POSTGRES_USER=${DB_USER}
- POSTGRES_PASSWORD=${DB_PASS}
volumes:
# Храним базу в именованном томе, чтобы данные не пропали при удалении контейнера
- postgres_data:/var/lib/postgresql/data
# --- ПРИЛОЖЕНИЕ (DJANGO) ---
web:
build: .
build: . # Собирает образ из Dockerfile в текущей папке
restart: unless-stopped
environment:
- ENV_TYPE=server
env_file:
- .env
- .env # Прокидывает все секреты и настройки внутрь Python
# - ENV_TYPE=server
volumes:
# Общие папки для статики и картинок. Сюда Django их складывает.
- staticfiles:/app/staticfiles
- mediafiles:/app/media
expose:
- "8000"
- "8000" # Открывает порт ТОЛЬКО внутри сети Docker для Nginx
depends_on:
- db
- db # Сначала запустится база, потом приложение
# --- ВЕБ-СЕРВЕР (ФАСАД) ---
nginx:
image: nginx:1.25-alpine
restart: unless-stopped
volumes:
# Основной конфиг маршрутизации
- ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
# Читаем статику и медиа, которые подготовил контейнер 'web'
# :ro (read-only) — защита: даже если Nginx взломают, файлы не удалят
- staticfiles:/app/staticfiles:ro
- mediafiles:/app/media:ro
ports:
- "8085:80"
- "80:80" # Единственная "дырка" в мир: порт 80 сервера -> порт 80 контейнера
depends_on:
- web
- web # Nginx запустится только после Django
# Описание "жестких дисков" (Volumes), которые живут дольше контейнеров
volumes:
postgres_data:
staticfiles:
mediafiles:
postgres_data: # Для данных БД
staticfiles: # Для CSS, JS и картинок интерфейса (collectstatic)
mediafiles: # Для загруженных тобой чертежей и фото

View File

@@ -1,7 +1,15 @@
#!/bin/sh
# Собираем статику в папку, которую увидит Nginx
echo "Collecting static files..."
python manage.py collectstatic --noinput
# Миграции
echo "Applying database migrations..."
python manage.py migrate --noinput
# Создаем админа (из переменных .env)
python manage.py createsuperuser --no-input || true
echo "Starting Gunicorn..."
exec "$@"

View File

@@ -0,0 +1,50 @@
from django.db import models
class RouteStub(models.Model):
"""Заглушка для будущего модуля техпроцессов."""
name = models.CharField("Маршрут (напр. Лазер-Гибка-Сварка)", max_length=200, unique=True)
def __str__(self): return self.name
class ProductEntity(models.Model):
"""
Универсальный паспорт Детали или Сборки.
Логика Вьюх:
Это "Чертеж". Он не привязан к конкретному заказу (Сделке).
planned_material - это то, что задумал конструктор. Оператор по факту может взять другое сырье.
"""
ENTITY_TYPE = [('product', 'Готовое изделие'), ('assembly', 'Сборочная единица'), ('part', 'Деталь')]
name = models.CharField("Наименование", max_length=255)
drawing_number = models.CharField("Обозначение/Чертеж", max_length=100, blank=True)
entity_type = models.CharField("Тип", max_length=15, choices=ENTITY_TYPE, default='part')
planned_material = models.ForeignKey('warehouse.Material', on_delete=models.PROTECT, null=True, blank=True, verbose_name="Заложенный материал")
route = models.ForeignKey(RouteStub, on_delete=models.SET_NULL, null=True, blank=True, verbose_name="Маршрут")
weight_unit = models.FloatField("Вес 1 шт, кг", default=0.0)
surface_area = models.FloatField("Площадь поверхности, м2", default=0.0)
dxf_file = models.FileField("Исходник (DXF/IGES)", upload_to="drawings/dxf/%Y/%m/", blank=True, null=True)
pdf_main = models.FileField("Доп. чертеж (PDF)", upload_to="drawings/pdf/%Y/%m/", blank=True, null=True)
preview = models.ImageField("Превью", upload_to="previews/%Y/%m/", blank=True, null=True)
class Meta:
verbose_name = "КД (Изделие/Деталь)"; verbose_name_plural = "Конструкторская документация"
def __str__(self): return f"{self.drawing_number} {self.name}".strip()
class BOM(models.Model):
"""
Спецификация (Bill of Materials). Состав изделия.
Логика Вьюх:
При создании заказа на 5 "Лавок", система рекурсивно ищет все BOM, где Лавка = parent,
чтобы создать потребность в материалах (child) умноженную на quantity.
"""
parent = models.ForeignKey(ProductEntity, related_name='components', on_delete=models.CASCADE, verbose_name="Куда входит (Сборка)")
child = models.ForeignKey(ProductEntity, related_name='used_in', on_delete=models.CASCADE, verbose_name="Что входит (Деталь)")
quantity = models.PositiveIntegerField("Кол-во в сборке", default=1)
class Meta:
unique_together = ('parent', 'child')
verbose_name = "Спецификация (BOM)"; verbose_name_plural = "Спецификации (BOM)"

View File

@@ -0,0 +1,90 @@
from django.db import models
from django.utils import timezone
from django.contrib.auth.models import User
# --- СПРАВОЧНИКИ ---
class Company(models.Model):
name = models.CharField("Название компании", max_length=255, unique=True)
def __str__(self): return self.name
class Machine(models.Model):
name = models.CharField("Название станка", max_length=100)
def __str__(self): return self.name
class EmployeeProfile(models.Model):
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='profile')
role = models.CharField("Должность", max_length=20, default='operator')
machines = models.ManyToManyField(Machine, blank=True)
def __str__(self): return self.user.username
# --- КОНТУР ЗАКАЗОВ (СДЕЛКИ И ПОТРЕБНОСТИ) ---
class Deal(models.Model):
"""Сделка. Контейнер заказа клиента."""
number = models.CharField("№ Сделки", max_length=100, unique=True)
company = models.ForeignKey(Company, on_delete=models.PROTECT, null=True, blank=True)
status = models.CharField("Статус", max_length=20, default='lead')
def __str__(self): return f"Сделка №{self.number}"
class DealItem(models.Model):
"""
Что заказал клиент (точка входа MRP).
Логика Вьюх:
Менеджер вносит сюда 5 шт "Лавок". На основе этого генерируются MaterialRequirement и ProductionTask.
"""
deal = models.ForeignKey(Deal, related_name='items', on_delete=models.CASCADE)
entity = models.ForeignKey('manufacturing.ProductEntity', on_delete=models.PROTECT, verbose_name="Изделие")
quantity = models.PositiveIntegerField("Заказано, шт")
class MaterialRequirement(models.Model):
"""
Потребность в закупке сырья для Сделки.
Логика Вьюх:
Генерируется автоматически после "взрыва" спецификации (BOM).
Снабженец видит это в своем АРМ и организует приход.
"""
STATUS = [('needed', 'К закупке'), ('ordered', 'В пути'), ('fulfilled', 'Обеспечено')]
deal = models.ForeignKey(Deal, on_delete=models.CASCADE)
material = models.ForeignKey('warehouse.Material', on_delete=models.PROTECT)
required_qty = models.FloatField("Нужно докупить (шт/м/кг)")
status = models.CharField(max_length=20, choices=STATUS, default='needed')
# --- ПРОИЗВОДСТВЕННЫЙ КОНТУР ---
class ProductionTask(models.Model):
"""
Сменное задание (План на производство детали).
Логика: Связывает Сделку и КД (ProductEntity).
"""
deal = models.ForeignKey(Deal, on_delete=models.CASCADE, verbose_name="Сделка")
entity = models.ForeignKey('manufacturing.ProductEntity', on_delete=models.CASCADE, verbose_name="Что делать")
quantity_ordered = models.PositiveIntegerField("План, шт")
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self): return f"{self.entity.name} (Заказ {self.deal.number})"
class CuttingSession(models.Model):
"""
Сессия переработки (Основа для списания/начисления).
Логика Вьюх:
Оператор создает сессию. Указывает, какой StockItem (Лист/Хлыст) он взял со своего участка.
В рамках сессии он закрывает пункты (ShiftItem).
При закрытии сессии Вьюха: 1) списывает used_stock_item, 2) начисляет новые StockItem с готовыми деталями, 3) начисляет ДО.
"""
operator = models.ForeignKey(User, on_delete=models.PROTECT)
machine = models.ForeignKey(Machine, on_delete=models.PROTECT)
used_stock_item = models.ForeignKey('warehouse.StockItem', on_delete=models.PROTECT, verbose_name="Взятый со склада материал")
date = models.DateField("Дата", default=timezone.localdate)
created_at = models.DateTimeField(auto_now_add=True)
is_closed = models.BooleanField("Сессия закрыта", default=False)
class ShiftItem(models.Model):
"""
Конкретный пункт отчета в рамках сессии.
(Замена старой модели Item).
"""
session = models.ForeignKey(CuttingSession, related_name='tasks', on_delete=models.CASCADE)
task = models.ForeignKey(ProductionTask, on_delete=models.PROTECT, verbose_name="Плановое задание")
quantity_fact = models.PositiveIntegerField("Изготовлено (Факт), шт", default=0)
# Флаг для контроля отклонений (если взяли Ст3 вместо Ст10)
material_substitution = models.BooleanField("Замена материала по факту", default=False)

104
exempl/warehouse/models.py Normal file
View File

@@ -0,0 +1,104 @@
from django.db import models
from django.contrib.auth.models import User
class MaterialCategory(models.Model):
"""Категория сырья (Лист, Труба, Круг)."""
name = models.CharField("Название категории", max_length=100, unique=True)
gost_standard = models.CharField("ГОСТ", max_length=255, blank=True)
class Meta:
verbose_name = "Категория материала"
verbose_name_plural = "Категории материалов"
def __str__(self): return self.name
class SteelGrade(models.Model):
"""Марка стали."""
name = models.CharField("Марка стали", max_length=100, unique=True)
gost_standard = models.CharField("ГОСТ/ТУ", max_length=255, blank=True)
class Meta:
verbose_name = "Марка стали"; verbose_name_plural = "Марки стали"
def __str__(self): return self.name
class Material(models.Model):
"""
Справочник закупаемого сырья (Номенклатура).
Логика: Это только "идея" материала, а не физический объект на полке.
Для листа заполняем thickness, для трубы width (сечение), для всех length (стандартная длина).
"""
category = models.ForeignKey(MaterialCategory, on_delete=models.PROTECT, verbose_name="Категория")
steel_grade = models.ForeignKey(SteelGrade, on_delete=models.PROTECT, null=True, blank=True)
name = models.CharField("Наименование", max_length=255)
thickness = models.FloatField("Толщина (S), мм", null=True, blank=True)
width = models.FloatField("Ширина/Сечение (B), мм", null=True, blank=True)
length = models.FloatField("Длина (L), мм", null=True, blank=True)
class Meta:
verbose_name = "Номенклатура (Сырье)"; verbose_name_plural = "Номенклатура (Сырье)"
def __str__(self): return f"{self.category.name} {self.name} {self.steel_grade.name if self.steel_grade else ''}"
class Location(models.Model):
"""Склады и участки (Центральный, Лазер, Сварка, СГП)."""
name = models.CharField("Место хранения", max_length=100, unique=True)
is_production_area = models.BooleanField("Это производственный участок", default=False)
class Meta:
verbose_name = "Склад/Участок"; verbose_name_plural = "Склады и участки"
def __str__(self): return self.name
class StockItem(models.Model):
"""
Универсальная физическая единица на складе.
Логика Вьюх:
1. Если это сырье: заполнен material, пусто entity.
2. Если это готовая деталь: заполнен entity, пусто material.
3. Если is_remnant=True, то current_length/width показывают реальный размер куска.
При списании в CuttingSession количество здесь уменьшается. Если 0 - можно удалять или скрывать.
"""
material = models.ForeignKey(Material, on_delete=models.PROTECT, null=True, blank=True, verbose_name="Сырье")
entity = models.ForeignKey('manufacturing.ProductEntity', on_delete=models.PROTECT, null=True, blank=True, verbose_name="Произведенная сущность")
location = models.ForeignKey(Location, on_delete=models.PROTECT, verbose_name="Где находится")
quantity = models.FloatField("Количество (шт/м/кг)")
# Для деловых остатков
is_remnant = models.BooleanField("Деловой остаток", default=False)
current_length = models.FloatField("Текущая длина, мм", null=True, blank=True)
current_width = models.FloatField("Текущая ширина, мм", null=True, blank=True)
unique_id = models.CharField("ID/Маркировка (для ДО)", max_length=50, unique=True, null=True, blank=True)
class Meta:
verbose_name = "Единица на складе"; verbose_name_plural = "Остатки на складах"
def __str__(self):
obj = self.entity if self.entity else self.material
return f"{obj} | {self.quantity} ед. | {self.location}"
class TransferRecord(models.Model):
"""
Документ перемещения (Вариант Б: строгий учет).
Логика Вьюх:
Создается "Отправителем" (статус sent).
"Получатель" видит его в своем интерфейсе и жмет "Принять" (статус received).
В этот момент у связанных StockItem меняется location на to_location.
"""
STATUS_CHOICES = [('sent', 'В пути'), ('received', 'Принято'), ('discrepancy', 'Расхождение')]
items = models.ManyToManyField(StockItem, verbose_name="Перемещаемые объекты")
from_location = models.ForeignKey(Location, related_name='outgoing', on_delete=models.PROTECT)
to_location = models.ForeignKey(Location, related_name='incoming', on_delete=models.PROTECT)
sender = models.ForeignKey(User, related_name='sent_transfers', on_delete=models.PROTECT)
receiver = models.ForeignKey(User, related_name='received_transfers', on_delete=models.PROTECT, null=True, blank=True)
status = models.CharField("Статус", max_length=20, choices=STATUS_CHOICES, default='sent')
created_at = models.DateTimeField(auto_now_add=True)
received_at = models.DateTimeField(null=True, blank=True)
class Meta:
verbose_name = "Перемещение"; verbose_name_plural = "Перемещения"

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

52
manufacturing/admin.py Normal file
View File

@@ -0,0 +1,52 @@
from django.contrib import admin
from .models import BOM, EntityOperation, Operation, ProductEntity
@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):
"""Состав изделия/сборки (строки BOM) прямо в карточке ProductEntity."""
model = BOM
fk_name = 'parent'
fields = ('child', 'quantity')
autocomplete_fields = ('child',)
extra = 10
@admin.register(ProductEntity)
class ProductEntityAdmin(admin.ModelAdmin):
list_display = (
'drawing_number',
'name',
'entity_type',
'planned_material',
'blank_area_m2',
'blank_length_mm',
)
list_filter = ('entity_type', 'planned_material__category')
search_fields = ('drawing_number', 'name', 'planned_material__name', 'planned_material__full_name')
autocomplete_fields = ('planned_material',)
inlines = (EntityOperationInline, BOMChildInline,)
@admin.register(BOM)
class BOMAdmin(admin.ModelAdmin):
list_display = ('parent', 'child', 'quantity')
search_fields = ('parent__name', 'parent__drawing_number', 'child__name', 'child__drawing_number')
list_filter = ('parent',)
autocomplete_fields = ('parent', 'child')

7
manufacturing/apps.py Normal file
View File

@@ -0,0 +1,7 @@
from django.apps import AppConfig
class ManufacturingConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'manufacturing'
verbose_name = 'Производство (КД/BOM)'

View File

@@ -0,0 +1,61 @@
# Generated by Django 6.0.3 on 2026-04-04 15:14
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('warehouse', '0003_alter_material_full_name'),
]
operations = [
migrations.CreateModel(
name='RouteStub',
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='Маршрут')),
],
options={
'verbose_name': 'Маршрут',
'verbose_name_plural': 'Маршруты',
},
),
migrations.CreateModel(
name='ProductEntity',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255, verbose_name='Наименование')),
('drawing_number', models.CharField(blank=True, default='', max_length=100, verbose_name='Обозначение/Чертёж')),
('entity_type', models.CharField(choices=[('product', 'Готовое изделие'), ('assembly', 'Сборочная единица'), ('part', 'Деталь')], default='part', max_length=15, verbose_name='Тип')),
('blank_area_m2', models.FloatField(blank=True, null=True, verbose_name='Норма: площадь заготовки (м²/шт)')),
('blank_length_mm', models.FloatField(blank=True, null=True, verbose_name='Норма: длина заготовки (мм/шт)')),
('dxf_file', models.FileField(blank=True, null=True, upload_to='drawings/%Y/%m/', verbose_name='Исходник (DXF/IGES/STEP)')),
('pdf_main', models.FileField(blank=True, null=True, upload_to='drawings_pdf/%Y/%m/', verbose_name='Чертёж (PDF)')),
('preview', models.ImageField(blank=True, null=True, upload_to='previews/%Y/%m/', verbose_name='Превью')),
('planned_material', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='warehouse.material', verbose_name='Заложенный материал')),
('route', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='manufacturing.routestub', verbose_name='Маршрут')),
],
options={
'verbose_name': 'КД (изделие/деталь)',
'verbose_name_plural': 'КД (изделия/детали)',
},
),
migrations.CreateModel(
name='BOM',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('quantity', models.PositiveIntegerField(default=1, verbose_name='Кол-во в сборке')),
('child', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='used_in', to='manufacturing.productentity', verbose_name='Что входит (деталь)')),
('parent', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='components', to='manufacturing.productentity', verbose_name='Куда входит (сборка)')),
],
options={
'verbose_name': 'Спецификация (BOM)',
'verbose_name_plural': 'Спецификации (BOM)',
'unique_together': {('parent', 'child')},
},
),
]

View File

@@ -0,0 +1,23 @@
# Generated by Django 6.0.3 on 2026-04-07 04:39
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('manufacturing', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='productentity',
name='passport_filled',
field=models.BooleanField(default=False, verbose_name='Паспорт заполнен'),
),
migrations.AlterField(
model_name='productentity',
name='entity_type',
field=models.CharField(choices=[('product', 'Готовое изделие'), ('assembly', 'Сборочная единица'), ('part', 'Деталь'), ('purchased', 'Покупное'), ('casting', 'Литьё'), ('outsourced', 'Аутсорс')], default='part', max_length=15, verbose_name='Тип'),
),
]

View File

@@ -0,0 +1,45 @@
# Generated by Django 6.0.3 on 2026-04-07 08:56
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('manufacturing', '0002_productentity_passport_filled_and_more'),
]
operations = [
migrations.CreateModel(
name='AssemblyPassport',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('weight_kg', models.FloatField(blank=True, null=True, verbose_name='Масса, кг')),
('coating', models.CharField(blank=True, default='', max_length=200, verbose_name='Покрытие')),
('coating_color', models.CharField(blank=True, default='', max_length=100, verbose_name='Цвет')),
('coating_area_m2', models.FloatField(blank=True, null=True, verbose_name='Площадь покрытия, м²')),
('technical_requirements', models.TextField(blank=True, default='', verbose_name='Технические требования')),
('entity', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='assembly_passport', to='manufacturing.productentity')),
],
options={
'verbose_name': 'Паспорт сборки/изделия',
'verbose_name_plural': 'Паспорта сборок/изделий',
},
),
migrations.CreateModel(
name='WeldingSeam',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255, verbose_name='Наименование')),
('leg_mm', models.FloatField(verbose_name='Катет, мм')),
('length_mm', models.FloatField(verbose_name='Длина, мм')),
('quantity', models.PositiveIntegerField(default=1, verbose_name='Кол-во')),
('passport', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='welding_seams', to='manufacturing.assemblypassport')),
],
options={
'verbose_name': 'Сварной шов',
'verbose_name_plural': 'Сварные швы',
},
),
]

View File

@@ -0,0 +1,70 @@
# Generated by Django 6.0.3 on 2026-04-07 09:07
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('manufacturing', '0003_assemblypassport_weldingseam'),
]
operations = [
migrations.CreateModel(
name='CastingPassport',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('casting_material', models.CharField(blank=True, default='', max_length=200, verbose_name='Материал литья')),
('mass_kg', models.FloatField(blank=True, null=True, verbose_name='Масса, кг')),
('entity', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='casting_passport', to='manufacturing.productentity')),
],
options={
'verbose_name': 'Паспорт литья',
'verbose_name_plural': 'Паспорта литья',
},
),
migrations.CreateModel(
name='OutsourcedPassport',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('technical_requirements', models.TextField(blank=True, default='', verbose_name='Технические требования')),
('notes', models.TextField(blank=True, default='', verbose_name='Пояснения')),
('entity', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='outsourced_passport', to='manufacturing.productentity')),
],
options={
'verbose_name': 'Паспорт аутсорса',
'verbose_name_plural': 'Паспорта аутсорса',
},
),
migrations.CreateModel(
name='PartPassport',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('thickness_mm', models.FloatField(blank=True, null=True, verbose_name='Толщина, мм')),
('length_mm', models.FloatField(blank=True, null=True, verbose_name='Длина, мм')),
('mass_kg', models.FloatField(blank=True, null=True, verbose_name='Масса, кг')),
('cut_length_mm', models.FloatField(blank=True, null=True, verbose_name='Длина реза, мм')),
('pierce_count', models.PositiveIntegerField(blank=True, null=True, verbose_name='Кол-во врезок')),
('engraving', models.TextField(blank=True, default='', verbose_name='Гравировка')),
('technical_requirements', models.TextField(blank=True, default='', verbose_name='Технические требования')),
('entity', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='part_passport', to='manufacturing.productentity')),
],
options={
'verbose_name': 'Паспорт детали',
'verbose_name_plural': 'Паспорта деталей',
},
),
migrations.CreateModel(
name='PurchasedPassport',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('gost', models.CharField(blank=True, default='', max_length=255, verbose_name='ГОСТ/ТУ')),
('entity', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='purchased_passport', to='manufacturing.productentity')),
],
options={
'verbose_name': 'Паспорт покупного',
'verbose_name_plural': 'Паспорта покупного',
},
),
]

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

225
manufacturing/models.py Normal file
View File

@@ -0,0 +1,225 @@
from django.db import models
class Operation(models.Model):
"""Операция техпроцесса.
Комментарий: справочник расширяется без изменений кода.
"""
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 = 'Операции'
def __str__(self):
return self.name
class ProductEntity(models.Model):
"""Паспорт детали/сборки/изделия (КД).
planned_material:
- материал, заложенный в КД (для расчёта потребности и контроля замен при раскрое).
Нормы расхода (для BOM Explosion и MaterialRequirement):
- для листовой детали: blank_area_m2 (м² на 1 шт)
- для линейной (профиль/труба/круг): blank_length_mm (мм на 1 шт)
Примечание:
- категорию типа (лист/профиль) определяем по planned_material.category.
"""
ENTITY_TYPE = [
('product', 'Готовое изделие'),
('assembly', 'Сборочная единица'),
('part', 'Деталь'),
('purchased', 'Покупное'),
('casting', 'Литьё'),
('outsourced', 'Аутсорс'),
]
name = models.CharField("Наименование", max_length=255)
drawing_number = models.CharField("Обозначение/Чертёж", max_length=100, blank=True, default="")
entity_type = models.CharField("Тип", max_length=15, choices=ENTITY_TYPE, default='part')
planned_material = models.ForeignKey(
'warehouse.Material',
on_delete=models.PROTECT,
null=True,
blank=True,
verbose_name="Заложенный материал",
)
blank_area_m2 = models.FloatField("Норма: площадь заготовки (м²/шт)", null=True, blank=True)
blank_length_mm = models.FloatField("Норма: длина заготовки (мм/шт)", null=True, blank=True)
dxf_file = models.FileField("Исходник (DXF/IGES/STEP)", upload_to="drawings/%Y/%m/", blank=True, null=True)
pdf_main = models.FileField("Чертёж (PDF)", upload_to="drawings_pdf/%Y/%m/", blank=True, null=True)
preview = models.ImageField("Превью", upload_to="previews/%Y/%m/", blank=True, null=True)
passport_filled = models.BooleanField('Паспорт заполнен', default=False)
class Meta:
verbose_name = "КД (изделие/деталь)"
verbose_name_plural = "КД (изделия/детали)"
def __str__(self):
base = f"{self.drawing_number} {self.name}".strip()
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."""
parent = models.ForeignKey(
ProductEntity,
related_name='components',
on_delete=models.CASCADE,
verbose_name="Куда входит (сборка)",
)
child = models.ForeignKey(
ProductEntity,
related_name='used_in',
on_delete=models.CASCADE,
verbose_name="Что входит (деталь)",
)
quantity = models.PositiveIntegerField("Кол-во в сборке", default=1)
class Meta:
unique_together = ('parent', 'child')
verbose_name = "Спецификация (BOM)"
verbose_name_plural = "Спецификации (BOM)"
def __str__(self):
return f"{self.parent} -> {self.child} x{self.quantity}"
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='')
coating_area_m2 = models.FloatField('Площадь покрытия, м²', null=True, blank=True)
technical_requirements = models.TextField('Технические требования', blank=True, default='')
class Meta:
verbose_name = 'Паспорт сборки/изделия'
verbose_name_plural = 'Паспорта сборок/изделий'
def __str__(self):
return str(self.entity)
class WeldingSeam(models.Model):
passport = models.ForeignKey(AssemblyPassport, related_name='welding_seams', on_delete=models.CASCADE)
name = models.CharField('Наименование', max_length=255)
leg_mm = models.FloatField('Катет, мм')
length_mm = models.FloatField('Длина, мм')
quantity = models.PositiveIntegerField('Кол-во', default=1)
class Meta:
verbose_name = 'Сварной шов'
verbose_name_plural = 'Сварные швы'
def __str__(self):
return f"{self.name}"
class PartPassport(models.Model):
entity = models.OneToOneField(ProductEntity, on_delete=models.CASCADE, related_name='part_passport')
thickness_mm = models.FloatField('Толщина, мм', null=True, blank=True)
length_mm = models.FloatField('Длина, мм', null=True, blank=True)
mass_kg = models.FloatField('Масса, кг', null=True, blank=True)
cut_length_mm = models.FloatField('Длина реза, мм', null=True, blank=True)
pierce_count = models.PositiveIntegerField('Кол-во врезок', null=True, blank=True)
engraving = models.TextField('Гравировка', blank=True, default='')
technical_requirements = models.TextField('Технические требования', blank=True, default='')
class Meta:
verbose_name = 'Паспорт детали'
verbose_name_plural = 'Паспорта деталей'
def __str__(self):
return str(self.entity)
class PurchasedPassport(models.Model):
entity = models.OneToOneField(ProductEntity, on_delete=models.CASCADE, related_name='purchased_passport')
gost = models.CharField('ГОСТ/ТУ', max_length=255, blank=True, default='')
class Meta:
verbose_name = 'Паспорт покупного'
verbose_name_plural = 'Паспорта покупного'
def __str__(self):
return str(self.entity)
class CastingPassport(models.Model):
entity = models.OneToOneField(ProductEntity, on_delete=models.CASCADE, related_name='casting_passport')
casting_material = models.CharField('Материал литья', max_length=200, blank=True, default='')
mass_kg = models.FloatField('Масса, кг', null=True, blank=True)
class Meta:
verbose_name = 'Паспорт литья'
verbose_name_plural = 'Паспорта литья'
def __str__(self):
return str(self.entity)
class OutsourcedPassport(models.Model):
entity = models.OneToOneField(ProductEntity, on_delete=models.CASCADE, related_name='outsourced_passport')
technical_requirements = models.TextField('Технические требования', blank=True, default='')
notes = models.TextField('Пояснения', blank=True, default='')
class Meta:
verbose_name = 'Паспорт аутсорса'
verbose_name_plural = 'Паспорта аутсорса'
def __str__(self):
return str(self.entity)

3
manufacturing/tests.py Normal file
View File

@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

3
manufacturing/views.py Normal file
View File

@@ -0,0 +1,3 @@
from django.shortcuts import render
# Create your views here.

View File

@@ -1,39 +1,54 @@
# Описываем группу серверов, куда Nginx будет перекидывать запросы.
# 'web' — это имя сервиса из нашего docker-compose.yml.
upstream django_app {
server web:8000;
}
server {
# Слушаем стандартный 80-й порт (HTTP).
listen 80;
# Добавляем конкретный домен и IP сервера
# Список имен, на которые будет откликаться сервер.
# Если зайти по другому IP, Nginx может выдать ошибку.
server_name shiftflow.tertelius.space 192.168.1.108 localhost;
# Максимальный размер загружаемого файла
# Увеличиваем лимит загрузки (по умолчанию в Nginx всего 1МБ).
# 100М — чтобы ты мог спокойно грузить тяжелые чертежи или фото станков.
client_max_body_size 100M;
# Сжатие (Gzip) — ускорит загрузку интерфейса
# Включаем Gzip-сжатие. Nginx будет сжимать текстовые файлы перед отправкой,
# что ускорит загрузку интерфейса, особенно на слабом интернете.
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml;
# Главный блок: всё, что не статика, летит в Django.
location / {
proxy_pass http://django_app;
# Передаем оригинальный хост (важно для ALLOWED_HOSTS)
# Передаем оригинальный домен/IP (нужно для ALLOWED_HOSTS в Django).
proxy_set_header Host $host;
# Передаем реальный IP пользователя (чтобы в логах видеть, кто зашел).
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# КРИТИЧНО для CSRF защиты в Django
# Передаем протокол (http или https).
# КРИТИЧНО для CSRF защиты, чтобы Django не ругался при входе в админку.
proxy_set_header X-Forwarded-Proto $scheme;
proxy_redirect off;
}
# Раздача статики (CSS, JS, картинки интерфейса).
# Nginx сам лезет в папку, не беспокоя Django — это очень быстро.
location /static/ {
# Путь ВНУТРИ контейнера Nginx (куда мы примонтировали волюм).
alias /app/staticfiles/;
# Заставляем браузер кэшировать стили на 30 дней, чтобы не качать их каждый раз.
expires 30d;
add_header Cache-Control "public, no-transform";
}
# Раздача медиа-файлов (чертежи, фото продукции).
location /media/ {
alias /app/media/;
expires 30d;

Binary file not shown.

Binary file not shown.

View File

@@ -1,12 +1,307 @@
from django.contrib import admin
from .models import Machine, Item
import os
from django.contrib import admin, messages
from shiftflow.services.sessions import close_cutting_session
from warehouse.models import StockItem
from .models import (
Company,
CuttingSession,
Deal,
DealItem,
DxfPreviewJob,
DxfPreviewSettings,
EmployeeProfile,
Item,
Machine,
MaterialRequirement,
ProductionReportConsumption,
ProductionReportRemnant,
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):
"""
Панель администрирования Компаний
"""
list_display = ('name', 'description') # Что видим в общем списке
search_fields = ('name',) # Поиск по имени
class DealItemInline(admin.TabularInline):
model = DealItem
fields = ('entity', 'quantity')
autocomplete_fields = ('entity',)
extra = 10
# --- Настройка отображения Сделок ---
@admin.register(Deal)
class DealAdmin(admin.ModelAdmin):
"""
Панель администрирования Сделок
"""
list_display = ('number', 'id', 'status', 'company')
list_display_links = ('number',)
search_fields = ('number', 'company__name')
list_filter = ('status', 'company')
inlines = (DealItemInline,)
# --- Задания на производство (База) ---
"""
Панель администрирования Заданий на производство
"""
@admin.register(ProductionTask)
class ProductionTaskAdmin(admin.ModelAdmin):
list_display = ('drawing_name', 'deal', 'entity', 'material', 'quantity_ordered', 'created_at')
search_fields = ('drawing_name', 'deal__number', 'entity__name', 'entity__drawing_number')
list_filter = ('deal', 'material', 'is_bend')
autocomplete_fields = ('deal', 'entity', 'material')
"""
Панель администрирования Сделочных элементов
"""
@admin.register(DealItem)
class DealItemAdmin(admin.ModelAdmin):
"""
Панель администрирования Сделочных элементов
"""
list_display = ('deal', 'entity', 'quantity')
search_fields = ('deal__number', 'entity__name', 'entity__drawing_number')
list_filter = ('deal',)
autocomplete_fields = ('deal', 'entity')
@admin.register(MaterialRequirement)
class MaterialRequirementAdmin(admin.ModelAdmin):
"""
Панель администрирования Требований к Материалам
"""
list_display = ('deal', 'material', 'required_qty', 'unit', 'status')
search_fields = ('deal__number', 'material__name', 'material__full_name')
list_filter = ('status', 'unit', 'material__category')
autocomplete_fields = ('deal', 'material')
"""
Панель администрирования Сменных задания (Выполнение)
"""
@admin.register(Item)
class ItemAdmin(admin.ModelAdmin):
# Что видим в общем списке (используем task__ для доступа к полям базы)
list_display = ('date', 'machine', 'get_deal', 'get_drawing', 'quantity_plan', 'quantity_fact', 'status', 'is_synced_1c')
# Фильтры справа
list_filter = ('date', 'machine', 'status', 'is_synced_1c', 'task__deal')
# Поиск по номеру сделки и названию детали через связь task
search_fields = ('task__drawing_name', 'task__deal__number')
# Группируем поля в форме редактирования
fieldsets = (
('Связь с заданием', {
'fields': ('task', 'date', 'machine')
}),
('Исполнение', {
'fields': ('quantity_plan', 'quantity_fact', 'status', 'is_synced_1c')
}),
('Отходы и материалы', {
'fields': ('material_taken', 'usable_waste', 'scrap_weight')
}),
)
def get_deal(self, obj):
return obj.task.deal if obj.task else "-"
get_deal.short_description = 'Сделка'
def get_drawing(self, obj):
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')
search_fields = ('name',)
list_filter = ('location',)
@admin.register(Machine)
class MachineAdmin(admin.ModelAdmin):
list_display = ('name',)
list_display = ('name', 'machine_type', 'workshop', 'location')
list_display_links = ('name',)
list_filter = ('machine_type', 'workshop')
search_fields = ('name',)
fields = ('name', 'machine_type', 'workshop', 'location')
@admin.register(Item)
class ItemAdmin(admin.ModelAdmin):
list_display = ('date', 'machine', 'deal', 'drawing_name', 'quantity_plan', 'quantity_fact', 'status')
list_filter = ('date', 'machine', 'status')
search_fields = ('deal', 'drawing_name')
class ProductionReportLineInline(admin.TabularInline):
model = ShiftItem
fk_name = 'session'
fields = ('task', 'quantity_fact', 'material_substitution')
extra = 5
class ProductionReportConsumptionInline(admin.TabularInline):
model = ProductionReportConsumption
fk_name = 'report'
fields = ('stock_item', 'quantity')
autocomplete_fields = ('stock_item',)
extra = 3
def formfield_for_foreignkey(self, db_field, request, **kwargs):
if db_field.name == 'stock_item':
report = getattr(request, '_production_report_obj', None)
if report and getattr(report, 'machine_id', None):
machine = report.machine
work_location = None
if getattr(machine, 'workshop_id', None) and getattr(machine.workshop, 'location_id', None):
work_location = machine.workshop.location
elif getattr(machine, 'location_id', None):
work_location = machine.location
if work_location:
kwargs['queryset'] = StockItem.objects.filter(location=work_location, material__isnull=False)
else:
kwargs['queryset'] = StockItem.objects.none()
return super().formfield_for_foreignkey(db_field, request, **kwargs)
class ProductionReportRemnantInline(admin.TabularInline):
model = ProductionReportRemnant
fk_name = 'report'
fields = ('material', 'quantity', 'current_length', 'current_width')
autocomplete_fields = ('material',)
extra = 3
@admin.register(CuttingSession)
class CuttingSessionAdmin(admin.ModelAdmin):
"""
Панель администрирования Производственных отчетов.
Ограничение по складу:
- списание сырья доступно только со склада цеха выбранного станка.
"""
list_display = ('date', 'id', 'machine', 'operator', 'used_stock_item', 'is_closed')
list_display_links = ('date',)
list_filter = ('date', 'machine', 'is_closed')
search_fields = ('operator__username',)
actions = ('action_close_sessions',)
inlines = (ProductionReportLineInline, ProductionReportConsumptionInline, ProductionReportRemnantInline)
def get_form(self, request, obj=None, **kwargs):
request._production_report_obj = obj
return super().get_form(request, obj, **kwargs)
def formfield_for_foreignkey(self, db_field, request, **kwargs):
if db_field.name == 'used_stock_item':
report = getattr(request, '_production_report_obj', None)
if report and getattr(report, 'machine_id', None):
machine = report.machine
work_location = None
if getattr(machine, 'workshop_id', None) and getattr(machine.workshop, 'location_id', None):
work_location = machine.workshop.location
elif getattr(machine, 'location_id', None):
work_location = machine.location
if work_location:
kwargs['queryset'] = StockItem.objects.filter(location=work_location, material__isnull=False)
else:
kwargs['queryset'] = StockItem.objects.none()
return super().formfield_for_foreignkey(db_field, request, **kwargs)
@admin.action(description='Закрыть производственный отчет')
def action_close_sessions(self, request, queryset):
ok = 0
skipped = 0
failed = 0
for s in queryset:
try:
if s.is_closed:
skipped += 1
continue
close_cutting_session(s.id)
ok += 1
except Exception as e:
failed += 1
self.message_user(request, f'Отчет id={s.id}: {e}', level=messages.ERROR)
if ok:
self.message_user(request, f'Закрыто: {ok}.', level=messages.SUCCESS)
if skipped:
self.message_user(request, f'Пропущено (уже закрыто): {skipped}.', level=messages.WARNING)
if failed:
self.message_user(request, f'Ошибок: {failed}.', level=messages.ERROR)
@admin.register(ShiftItem)
class ShiftItemAdmin(admin.ModelAdmin):
list_display = ('session', 'task', 'quantity_fact', 'material_substitution')
list_filter = ('material_substitution',)
autocomplete_fields = ('session', 'task')
class DxfPreviewSettingsAdmin(admin.ModelAdmin):
list_display = (
'line_color',
'lineweight_scaling',
'min_lineweight',
'keep_original_colors',
'per_task_timeout_sec',
'updated_at',
)
@admin.register(DxfPreviewJob)
class DxfPreviewJobAdmin(admin.ModelAdmin):
list_display = ('id', 'status', 'created_by', 'processed', 'total', 'updated', 'errors', 'started_at', 'finished_at')
list_filter = ('status',)
search_fields = ('last_message',)
@admin.register(EmployeeProfile)
class EmployeeProfileAdmin(admin.ModelAdmin):
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

@@ -0,0 +1,19 @@
from django.conf import settings
from shiftflow.authz import get_user_roles, primary_role
def env_info(request):
return {
'ENV_TYPE': getattr(settings, 'ENV_TYPE', 'local')
}
def authz_info(request):
roles = get_user_roles(getattr(request, 'user', None))
profile = getattr(getattr(request, 'user', None), 'profile', None)
return {
'user_roles': sorted(roles),
'user_role': primary_role(roles),
'is_readonly': bool(getattr(profile, 'is_readonly', False)) if profile else False,
}

51
shiftflow/forms.py Normal file
View File

@@ -0,0 +1,51 @@
from django import forms
from warehouse.models import Material
from .models import Deal
class ProductionTaskCreateForm(forms.Form):
drawing_name = forms.CharField(label="Наименование детали", max_length=255, required=False)
quantity_ordered = forms.IntegerField(label="Требуется (шт)", min_value=1)
size_value = forms.FloatField(label="Размер (мм)", min_value=0)
is_bend = forms.BooleanField(label="Гибка", required=False)
drawing_file = forms.FileField(label="Исходник (DXF/IGES)", required=False)
extra_drawing = forms.FileField(label="Доп. чертеж (PDF)", required=False)
deal = forms.ModelChoiceField(
label="Сделка",
queryset=Deal.objects.all().order_by("number"),
required=True,
empty_label="— выбрать —",
)
material = forms.ModelChoiceField(
label="Материал",
queryset=Material.objects.all().order_by("full_name"),
required=True,
empty_label="— выбрать —",
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
existing = self.fields["drawing_name"].widget.attrs.get("class", "")
self.fields["drawing_name"].widget.attrs["class"] = (existing + " w-100").strip()
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Приводим поля формы к единому Bootstrap-оформлению.
# Это решает проблему, когда input «Наименование» выглядит как стандартный HTML и не занимает всю ширину.
for name, field in self.fields.items():
w = field.widget
if isinstance(w, forms.CheckboxInput):
w.attrs.setdefault('class', 'form-check-input')
elif isinstance(w, (forms.Select, forms.SelectMultiple)):
w.attrs.setdefault('class', 'form-select border-secondary')
else:
w.attrs.setdefault('class', 'form-control border-secondary')
# Явно делаем поле «Наименование детали» растягиваемым на всю ширину.
self.fields['drawing_name'].widget.attrs['class'] += ' w-100'

View File

View File

@@ -0,0 +1,177 @@
import logging
import multiprocessing
import os
import sys
from django.core.management.base import BaseCommand
from django.db import close_old_connections
from django.utils import timezone
def _run_one_task_preview(task_id: int, out_q: "multiprocessing.Queue") -> None:
"""Обрабатывает одну деталь в отдельном процессе.
Зачем отдельный процесс:
- некоторые DXF/рендер могут «залипать» (бесконечно долго обрабатываться);
- поток внутри веб/команды не спасает от GIL и зависаний библиотеки;
- процесс можно принудительно завершить по таймауту.
Результат кладём в очередь, чтобы родитель понял: ok/skip/error.
"""
try:
# В дочернем процессе нужно инициализировать Django, чтобы работать с ORM.
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings')
import django
django.setup()
from shiftflow.models import ProductionTask
from shiftflow.views import _update_task_preview
task = ProductionTask.objects.get(pk=task_id)
ok = bool(_update_task_preview(task))
out_q.put(('ok', ok))
except Exception as e:
out_q.put(('err', str(e)))
class Command(BaseCommand):
help = "Пакетная регенерация превью DXF и габаритов по активным сделкам."
def add_arguments(self, parser):
parser.add_argument("job_id", type=int)
def handle(self, *args, **options):
job_id = int(options["job_id"])
close_old_connections()
from shiftflow.models import DxfPreviewJob, DxfPreviewSettings, ProductionTask
try:
job = DxfPreviewJob.objects.get(pk=job_id)
except DxfPreviewJob.DoesNotExist:
return
job.status = "running"
job.started_at = timezone.now()
job.finished_at = None
job.last_message = ""
try:
job.pid = os.getpid()
job.save(update_fields=["status", "started_at", "finished_at", "last_message", "pid"])
except Exception:
job.save(update_fields=["status", "started_at", "finished_at", "last_message"])
logger = logging.getLogger('dxf_preview_job')
if not logger.handlers:
handler = logging.StreamHandler(sys.stdout)
handler.setFormatter(logging.Formatter('%(asctime)s %(levelname)s %(message)s', '%Y-%m-%d %H:%M:%S'))
logger.addHandler(handler)
logger.setLevel(logging.INFO)
logger.info('start job=%s pid=%s', job_id, os.getpid())
# Берём настройки таймаута из БД.
settings, _ = DxfPreviewSettings.objects.get_or_create(pk=1)
per_task_timeout = int(getattr(settings, 'per_task_timeout_sec', 45) or 45)
per_task_timeout = max(10, min(50, per_task_timeout))
deal_statuses = ["lead", "work"]
qs = ProductionTask.objects.select_related("deal").filter(deal__status__in=deal_statuses)
total = qs.count()
DxfPreviewJob.objects.filter(pk=job_id).update(
total=total,
processed=0,
updated=0,
skipped=0,
errors=0,
)
processed = 0
updated = 0
skipped = 0
errors = 0
logger.info('per_task_timeout=%ss', per_task_timeout)
try:
for task in qs.iterator(chunk_size=50):
processed += 1
# Пишем “живой” статус до тяжёлой операции, чтобы UI видел движение.
DxfPreviewJob.objects.filter(pk=job_id).update(
processed=processed,
updated=updated,
skipped=skipped,
errors=errors,
last_message=f"Обработка {processed}/{total}: {task.drawing_name} (сделка {task.deal.number})",
)
# Поддержка мягкой отмены: админ нажал «Прервать», выходим после текущей детали.
if DxfPreviewJob.objects.filter(pk=job_id, cancel_requested=True).exists():
DxfPreviewJob.objects.filter(pk=job_id).update(
status='cancelled',
finished_at=timezone.now(),
last_message='Задача остановлена пользователем.',
)
return
# Обрабатываем одну деталь в отдельном процессе и ждём не больше per_task_timeout.
# Важно: НЕ вызываем close_old_connections() внутри qs.iterator(), иначе Django может закрыть курсор,
# и итерация по QuerySet упадёт с ошибкой "cursor already closed".
q: multiprocessing.Queue = multiprocessing.Queue(maxsize=1)
p = multiprocessing.Process(target=_run_one_task_preview, args=(task.id, q))
p.start()
p.join(per_task_timeout)
if p.is_alive():
# DXF/рендер завис — убиваем процесс и учитываем как ошибку.
p.terminate()
p.join(5)
errors += 1
DxfPreviewJob.objects.filter(pk=job_id).update(
processed=processed,
updated=updated,
skipped=skipped,
errors=errors,
last_message=f"Таймаут {per_task_timeout}с: {task.drawing_name} (сделка {task.deal.number})",
)
else:
try:
status, payload = q.get_nowait()
except Exception:
status, payload = ('err', 'no_result')
if status == 'ok':
if payload:
updated += 1
else:
skipped += 1
else:
errors += 1
logger.error('error task=%s name=%s deal=%s: %s', task.id, task.drawing_name, task.deal.number, payload)
DxfPreviewJob.objects.filter(pk=job_id).update(
processed=processed,
updated=updated,
skipped=skipped,
errors=errors,
)
DxfPreviewJob.objects.filter(pk=job_id).update(
status="done",
finished_at=timezone.now(),
last_message=f"Готово. Обновлены: {updated}. Пропущено: {skipped}. Ошибок: {errors}.",
)
except Exception:
logger.exception('fatal error')
DxfPreviewJob.objects.filter(pk=job_id).update(
status="failed",
finished_at=timezone.now(),
last_message="Задача завершилась с ошибкой (см. лог на странице обслуживания).",
)
finally:
close_old_connections()

View File

@@ -0,0 +1,29 @@
from django.core.management.base import BaseCommand
from shiftflow.models import DealItem
from shiftflow.services.bom_explosion import explode_deal
class Command(BaseCommand):
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, create_tasks=True, create_procurement=True)
self.stdout.write(
self.style.SUCCESS(
f"OK deal={deal_id} tasks_created={stats.tasks_created} tasks_updated={stats.tasks_updated} "
f"req_created={stats.req_created} req_updated={stats.req_updated}"
)
)
if stats.tasks_created == 0 and stats.tasks_updated == 0 and stats.req_created == 0 and stats.req_updated == 0:
di_count = DealItem.objects.filter(deal_id=deal_id).count()
if di_count == 0:
self.stdout.write('Подсказка: в сделке нет позиций (DealItem). Добавь DealItem и повтори команду.')
else:
self.stdout.write('Подсказка: проверь заполнение BOM и норм расхода (blank_area_m2/blank_length_mm) на leaf-деталях.')

View File

@@ -0,0 +1,141 @@
# Generated by Django 6.0.3 on 2026-03-28 08:48
import django.db.models.deletion
import django.utils.timezone
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('shiftflow', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Company',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255, unique=True, verbose_name='Название компании')),
('description', models.TextField(blank=True, verbose_name='Краткое описание / Примечание')),
],
options={
'verbose_name': 'Компания',
'verbose_name_plural': 'Компании',
},
),
migrations.CreateModel(
name='Material',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255, unique=True, verbose_name='Наименование')),
],
options={
'verbose_name': 'Материал',
'verbose_name_plural': 'Материалы',
},
),
migrations.AlterModelOptions(
name='item',
options={'ordering': ['-date', 'deal'], 'verbose_name': 'Позиция', 'verbose_name_plural': 'Сменное задание'},
),
migrations.RemoveField(
model_name='item',
name='dim_value',
),
migrations.RemoveField(
model_name='item',
name='priority',
),
migrations.AddField(
model_name='item',
name='drawing_file',
field=models.FileField(blank=True, null=True, upload_to='drawings/%Y/%m/', verbose_name='Исходник (DXF/STEP)'),
),
migrations.AddField(
model_name='item',
name='extra_drawing',
field=models.FileField(blank=True, null=True, upload_to='extra_drawings/%Y/%m/', verbose_name='Доп. чертеж (PDF)'),
),
migrations.AddField(
model_name='item',
name='is_bend',
field=models.BooleanField(default=False, verbose_name='Гибка'),
),
migrations.AddField(
model_name='item',
name='is_synced_1c',
field=models.BooleanField(default=False, verbose_name='Учтено в 1С'),
),
migrations.AddField(
model_name='item',
name='material_taken',
field=models.TextField(blank=True, help_text='Напр: 3 трубы по 12м', verbose_name='Взятый материал'),
),
migrations.AddField(
model_name='item',
name='operator',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='Исполнитель'),
),
migrations.AddField(
model_name='item',
name='scrap_weight',
field=models.FloatField(default=0.0, verbose_name='Лом (кг)'),
),
migrations.AddField(
model_name='item',
name='size_value',
field=models.FloatField(default=0, help_text='Длина (мм) или Толщина (мм)', verbose_name='Размер детали'),
preserve_default=False,
),
migrations.AddField(
model_name='item',
name='usable_waste',
field=models.TextField(blank=True, help_text='Напр: кусок 1500мм', verbose_name='Деловой отход'),
),
migrations.AlterField(
model_name='item',
name='date',
field=models.DateField(default=django.utils.timezone.now, verbose_name='Дата задания'),
),
migrations.AlterField(
model_name='item',
name='drawing_name',
field=models.CharField(blank=True, default='Б/ч', max_length=255, 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='machine',
name='name',
field=models.CharField(max_length=100, verbose_name='Название станка'),
),
migrations.CreateModel(
name='Deal',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('number', models.CharField(max_length=100, unique=True, verbose_name='№ Сделки')),
('description', models.TextField(blank=True, help_text='Общая информация по заказу', verbose_name='Описание сделки')),
('company', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='shiftflow.company', verbose_name='Заказчик')),
],
options={
'verbose_name': 'Сделка',
'verbose_name_plural': 'Сделки',
},
),
migrations.AlterField(
model_name='item',
name='deal',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='shiftflow.deal', verbose_name='Сделка'),
),
migrations.AlterField(
model_name='item',
name='material',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='shiftflow.material', verbose_name='Материал'),
),
]

View File

@@ -0,0 +1,29 @@
# Generated by Django 6.0.3 on 2026-03-28 15:05
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('shiftflow', '0002_company_material_alter_item_options_and_more'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='EmployeeProfile',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('role', models.CharField(choices=[('admin', 'Администратор'), ('technologist', 'Технолог'), ('master', 'Мастер'), ('operator', 'Оператор'), ('clerk', 'Учетчик')], default='operator', max_length=20, verbose_name='Должность')),
('machines', models.ManyToManyField(blank=True, to='shiftflow.machine', verbose_name='Закрепленные станки')),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='profile', to=settings.AUTH_USER_MODEL, verbose_name='Пользователь')),
],
options={
'verbose_name': 'Профиль сотрудника',
'verbose_name_plural': 'Профили сотрудников',
},
),
]

View File

@@ -0,0 +1,86 @@
# Generated by Django 6.0.3 on 2026-03-29 12:51
import django.db.models.deletion
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('shiftflow', '0003_employeeprofile'),
]
operations = [
migrations.AlterModelOptions(
name='item',
options={'ordering': ['-date', 'task__deal'], 'verbose_name': 'Позиция сменки', 'verbose_name_plural': 'Реестр сменных заданий'},
),
migrations.RemoveField(
model_name='item',
name='deal',
),
migrations.RemoveField(
model_name='item',
name='drawing_file',
),
migrations.RemoveField(
model_name='item',
name='drawing_name',
),
migrations.RemoveField(
model_name='item',
name='extra_drawing',
),
migrations.RemoveField(
model_name='item',
name='is_bend',
),
migrations.RemoveField(
model_name='item',
name='material',
),
migrations.RemoveField(
model_name='item',
name='operator',
),
migrations.RemoveField(
model_name='item',
name='size_value',
),
migrations.AlterField(
model_name='item',
name='date',
field=models.DateField(default=django.utils.timezone.now, verbose_name='Дата смены'),
),
migrations.AlterField(
model_name='item',
name='quantity_plan',
field=models.PositiveIntegerField(verbose_name='План на смену, шт'),
),
migrations.CreateModel(
name='ProductionTask',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('drawing_name', models.CharField(blank=True, default='Б/ч', max_length=255, verbose_name='Название детали')),
('size_value', models.FloatField(help_text='Длина (мм) или Толщина (мм)', verbose_name='Размер детали')),
('drawing_file', models.FileField(blank=True, null=True, upload_to='drawings/%Y/%m/', verbose_name='Исходник (DXF/IGES)')),
('extra_drawing', models.FileField(blank=True, null=True, upload_to='extra_drawings/%Y/%m/', verbose_name='Доп. чертеж (PDF)')),
('quantity_ordered', models.PositiveIntegerField(verbose_name='Заказано всего, шт')),
('is_bend', models.BooleanField(default=False, verbose_name='Гибка')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
('deal', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='shiftflow.deal', verbose_name='Сделка')),
('material', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='shiftflow.material', verbose_name='Материал')),
],
options={
'verbose_name': 'Задание на деталь',
'verbose_name_plural': 'Задания на детали',
'ordering': ['-created_at'],
},
),
migrations.AddField(
model_name='item',
name='task',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='items', to='shiftflow.productiontask', verbose_name='Задание'),
),
]

View File

@@ -0,0 +1,23 @@
# Generated by Django 6.0.3 on 2026-03-29 14:16
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('shiftflow', '0004_alter_item_options_remove_item_deal_and_more'),
('warehouse', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='productiontask',
name='material',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='warehouse.material', verbose_name='Материал'),
),
migrations.DeleteModel(
name='Material',
),
]

View File

@@ -0,0 +1,22 @@
# Generated by Django 6.0.3 on 2026-03-29 16:51
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('shiftflow', '0005_alter_productiontask_material_delete_material'),
]
operations = [
migrations.AlterModelOptions(
name='item',
options={'ordering': ['-date', 'task__deal'], 'verbose_name': 'Пункт сменки', 'verbose_name_plural': 'Реестр сменных заданий'},
),
migrations.AlterField(
model_name='item',
name='status',
field=models.CharField(choices=[('work', 'В работе'), ('done', 'Выполнено'), ('partial', 'Частично'), ('leftover', 'Недодел'), ('imported', 'Импортировано')], default='work', max_length=10, verbose_name='Статус'),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 6.0.3 on 2026-03-29 19:24
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('shiftflow', '0006_alter_item_options_alter_item_status'),
]
operations = [
migrations.AddField(
model_name='machine',
name='machine_type',
field=models.CharField(choices=[('linear', 'Линейный'), ('sheet', 'Листовой')], default='linear', max_length=10, verbose_name='Тип станка'),
),
]

View File

@@ -0,0 +1,19 @@
# Generated by Django 6.0.3 on 2026-03-29 21:15
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('shiftflow', '0007_machine_machine_type'),
]
operations = [
migrations.AlterField(
model_name='item',
name='date',
field=models.DateField(default=django.utils.timezone.localdate, verbose_name='Дата смены'),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 6.0.3 on 2026-03-31 05:09
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('shiftflow', '0008_alter_item_date'),
]
operations = [
migrations.AddField(
model_name='deal',
name='status',
field=models.CharField(choices=[('lead', 'Зашла'), ('work', 'В работе'), ('done', 'Завершена')], default='work', max_length=10, verbose_name='Статус'),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 6.0.3 on 2026-04-02 19:42
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('shiftflow', '0009_deal_status'),
]
operations = [
migrations.AddField(
model_name='productiontask',
name='preview_image',
field=models.ImageField(blank=True, null=True, upload_to='task_previews/%Y/%m/', verbose_name='Превью DXF (PNG)'),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 6.0.3 on 2026-04-02 20:04
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('shiftflow', '0010_productiontask_preview_image'),
]
operations = [
migrations.AddField(
model_name='productiontask',
name='blank_dimensions',
field=models.CharField(blank=True, default='', max_length=64, verbose_name='Габариты заготовки'),
),
]

View File

@@ -0,0 +1,28 @@
# Generated by Django 6.0.3 on 2026-04-02 20:30
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('shiftflow', '0011_productiontask_blank_dimensions'),
]
operations = [
migrations.CreateModel(
name='DxfPreviewSettings',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('line_color', models.CharField(default='#006400', help_text='Напр: #006400 (тёмно-зелёный)', max_length=16, verbose_name='Цвет линий превью (HEX)')),
('lineweight_scaling', models.FloatField(default=1.0, help_text='1.0 = как в DXF, 2.0 = толще, 0.5 = тоньше', verbose_name='Коэффициент толщины линий')),
('min_lineweight', models.FloatField(default=0.1, help_text='Если в DXF нет lineweight — используем минимум, чтобы линии были видимы', verbose_name='Минимальная толщина (мм)')),
('keep_original_colors', models.BooleanField(default=False, help_text='Если включено — не перекрашиваем линии, берём цвета из DXF', verbose_name='Оставить цвета оригинальные')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Обновлено')),
],
options={
'verbose_name': 'Настройки превью DXF',
'verbose_name_plural': 'Настройки превью DXF',
},
),
]

View File

@@ -0,0 +1,38 @@
# Generated by Django 6.0.3 on 2026-04-02 21:33
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('shiftflow', '0012_dxfpreviewsettings'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='DxfPreviewJob',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('status', models.CharField(choices=[('queued', 'В очереди'), ('running', 'Выполняется'), ('done', 'Готово'), ('failed', 'Ошибка')], default='queued', max_length=16, verbose_name='Статус')),
('started_at', models.DateTimeField(blank=True, null=True, verbose_name='Начато')),
('finished_at', models.DateTimeField(blank=True, null=True, verbose_name='Завершено')),
('total', models.PositiveIntegerField(default=0, verbose_name='Всего задач')),
('processed', models.PositiveIntegerField(default=0, verbose_name='Обработано')),
('updated', models.PositiveIntegerField(default=0, verbose_name='Обновлено')),
('skipped', models.PositiveIntegerField(default=0, verbose_name='Пропущено')),
('errors', models.PositiveIntegerField(default=0, verbose_name='Ошибок')),
('last_message', models.CharField(blank=True, default='', max_length=255, verbose_name='Сообщение')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Создано')),
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='Запустил')),
],
options={
'verbose_name': 'Задача превью DXF',
'verbose_name_plural': 'Задачи превью DXF',
'ordering': ['-id'],
},
),
]

View File

@@ -0,0 +1,33 @@
# Generated by Django 6.0.3 on 2026-04-02 22:06
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('shiftflow', '0013_dxfpreviewjob'),
]
operations = [
migrations.AddField(
model_name='dxfpreviewjob',
name='cancel_requested',
field=models.BooleanField(default=False, help_text='Если включено — воркер завершит задачу после текущей детали', verbose_name='Запрошена остановка'),
),
migrations.AddField(
model_name='dxfpreviewjob',
name='pid',
field=models.PositiveIntegerField(blank=True, help_text='Номер процесса, который выполняет задачу (для диагностики)', null=True, verbose_name='PID процесса'),
),
migrations.AddField(
model_name='dxfpreviewsettings',
name='per_task_timeout_sec',
field=models.PositiveIntegerField(default=45, help_text='Если конкретный DXF завис — убиваем обработку этой детали и идём дальше', verbose_name='Таймаут на 1 DXF (сек)'),
),
migrations.AlterField(
model_name='dxfpreviewjob',
name='status',
field=models.CharField(choices=[('queued', 'В очереди'), ('running', 'Выполняется'), ('done', 'Готово'), ('failed', 'Ошибка'), ('cancelled', 'Остановлено')], default='queued', max_length=16, verbose_name='Статус'),
),
]

View File

@@ -0,0 +1,88 @@
# Generated by Django 6.0.3 on 2026-04-04 15:14
import django.db.models.deletion
import django.utils.timezone
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('manufacturing', '0001_initial'),
('shiftflow', '0014_dxfpreviewjob_cancel_requested_dxfpreviewjob_pid_and_more'),
('warehouse', '0004_location_stockitem_transferrecord'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='machine',
name='location',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='warehouse.location', verbose_name='Склад участка'),
),
migrations.AddField(
model_name='productiontask',
name='entity',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='manufacturing.productentity', verbose_name='КД (изделие/деталь)'),
),
migrations.CreateModel(
name='CuttingSession',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('date', models.DateField(default=django.utils.timezone.localdate, verbose_name='Дата')),
('created_at', models.DateTimeField(auto_now_add=True)),
('is_closed', models.BooleanField(default=False, verbose_name='Сессия закрыта')),
('machine', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='shiftflow.machine', verbose_name='Станок')),
('operator', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL, verbose_name='Оператор')),
('used_stock_item', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='warehouse.stockitem', verbose_name='Взятый материал')),
],
options={
'verbose_name': 'Сессия раскроя',
'verbose_name_plural': 'Сессии раскроя',
},
),
migrations.CreateModel(
name='MaterialRequirement',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('required_qty', models.FloatField(verbose_name='Нужно докупить')),
('unit', models.CharField(choices=[('m2', 'м²'), ('mm', 'мм'), ('pcs', 'шт')], default='pcs', max_length=8, verbose_name='Ед. изм.')),
('status', models.CharField(choices=[('needed', 'К закупке'), ('ordered', 'В пути'), ('fulfilled', 'Обеспечено')], default='needed', max_length=20, verbose_name='Статус')),
('deal', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='shiftflow.deal', verbose_name='Сделка')),
('material', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='warehouse.material', verbose_name='Материал')),
],
options={
'verbose_name': 'Потребность',
'verbose_name_plural': 'Потребности',
},
),
migrations.CreateModel(
name='ShiftItem',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('quantity_fact', models.PositiveIntegerField(default=0, verbose_name='Изготовлено (факт), шт')),
('material_substitution', models.BooleanField(default=False, verbose_name='Замена материала по факту')),
('session', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tasks', to='shiftflow.cuttingsession')),
('task', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='shiftflow.productiontask', verbose_name='Плановое задание')),
],
options={
'verbose_name': 'Пункт сессии',
'verbose_name_plural': 'Пункты сессий',
},
),
migrations.CreateModel(
name='DealItem',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('quantity', models.PositiveIntegerField(verbose_name='Заказано, шт')),
('deal', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', 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,77 @@
# Generated by Django 6.0.3 on 2026-04-05 07:29
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('shiftflow', '0015_machine_location_productiontask_entity_and_more'),
('warehouse', '0004_location_stockitem_transferrecord'),
]
operations = [
migrations.AlterModelOptions(
name='cuttingsession',
options={'verbose_name': 'Производственный отчет', 'verbose_name_plural': 'Производственные отчеты'},
),
migrations.AlterModelOptions(
name='shiftitem',
options={'verbose_name': 'Фиксация выработки', 'verbose_name_plural': 'Фиксации выработки'},
),
migrations.AlterField(
model_name='cuttingsession',
name='is_closed',
field=models.BooleanField(default=False, verbose_name='Отчет закрыт'),
),
migrations.AlterField(
model_name='cuttingsession',
name='used_stock_item',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='warehouse.stockitem', verbose_name='Взятый материал (legacy)'),
),
migrations.CreateModel(
name='ProductionReportRemnant',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('quantity', models.FloatField(default=1.0, verbose_name='Количество (ед.)')),
('current_length', models.FloatField(blank=True, null=True, verbose_name='Текущая длина, мм')),
('current_width', models.FloatField(blank=True, null=True, verbose_name='Текущая ширина, мм')),
('unique_id', models.CharField(blank=True, max_length=50, null=True, verbose_name='ID/Маркировка')),
('material', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='warehouse.material', verbose_name='Материал')),
('report', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='remnants', to='shiftflow.cuttingsession', verbose_name='Производственный отчет')),
],
options={
'verbose_name': 'Деловой остаток',
'verbose_name_plural': 'Деловые остатки',
},
),
migrations.CreateModel(
name='ProductionReportConsumption',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('quantity', models.FloatField(verbose_name='Списано (ед.)')),
('report', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='consumptions', to='shiftflow.cuttingsession', verbose_name='Производственный отчет')),
('stock_item', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='warehouse.stockitem', verbose_name='Сырье (позиция склада)')),
],
options={
'verbose_name': 'Списание сырья',
'verbose_name_plural': 'Списание сырья',
'unique_together': {('report', 'stock_item')},
},
),
migrations.CreateModel(
name='ProductionReportStockResult',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('kind', models.CharField(choices=[('finished', 'Готовая деталь'), ('remnant', 'Деловой остаток')], max_length=16, verbose_name='Тип')),
('report', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='results', to='shiftflow.cuttingsession', verbose_name='Производственный отчет')),
('stock_item', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='warehouse.stockitem', verbose_name='Созданная позиция склада')),
],
options={
'verbose_name': 'Результат отчета',
'verbose_name_plural': 'Результаты отчета',
'unique_together': {('report', 'stock_item')},
},
),
]

View File

@@ -0,0 +1,37 @@
# Generated by Django 6.0.3 on 2026-04-05 08:35
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('shiftflow', '0016_alter_cuttingsession_options_alter_shiftitem_options_and_more'),
('warehouse', '0005_alter_stockitem_options'),
]
operations = [
migrations.AlterField(
model_name='machine',
name='location',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='warehouse.location', verbose_name='Склад участка (устаревает)'),
),
migrations.CreateModel(
name='Workshop',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=120, unique=True, verbose_name='Цех')),
('location', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='warehouse.location', verbose_name='Склад цеха')),
],
options={
'verbose_name': 'Цех',
'verbose_name_plural': 'Цеха',
},
),
migrations.AddField(
model_name='machine',
name='workshop',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='shiftflow.workshop', verbose_name='Цех'),
),
]

View File

@@ -0,0 +1,33 @@
# Generated by Django 6.0.3 on 2026-04-05 09:19
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('shiftflow', '0017_alter_machine_location_workshop_machine_workshop'),
('warehouse', '0006_alter_stockitem_options'),
]
operations = [
migrations.AlterUniqueTogether(
name='productionreportconsumption',
unique_together=set(),
),
migrations.AddField(
model_name='productionreportconsumption',
name='material',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='warehouse.material', verbose_name='Материал'),
),
migrations.AlterField(
model_name='productionreportconsumption',
name='stock_item',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='warehouse.stockitem', verbose_name='Сырье (позиция склада, legacy)'),
),
migrations.AlterUniqueTogether(
name='productionreportconsumption',
unique_together={('report', 'material')},
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 6.0.3 on 2026-04-06 04:28
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('shiftflow', '0018_alter_productionreportconsumption_unique_together_and_more'),
]
operations = [
migrations.AlterField(
model_name='employeeprofile',
name='role',
field=models.CharField(choices=[('admin', 'Администратор'), ('technologist', 'Технолог'), ('master', 'Мастер'), ('operator', 'Оператор'), ('clerk', 'Учетчик'), ('observer', 'Наблюдатель')], default='operator', max_length=20, verbose_name='Должность'),
),
]

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

@@ -1,40 +1,596 @@
from django.db import models
# Create your models here.
from django.db import models
from django.utils import timezone
from django.contrib.auth.models import User
from warehouse.models import Material as WarehouseMaterial
class Company(models.Model):
"""
Справочник контрагентов/заказчиков.
Позволяет группировать сделки по компаниям и избегать дублей в названиях.
"""
name = models.CharField("Название компании", max_length=255, unique=True)
description = models.TextField("Краткое описание / Примечание", blank=True)
def __str__(self): return self.name
class Meta:
verbose_name = "Компания"; verbose_name_plural = "Компании"
class Workshop(models.Model):
"""Цех/участок верхнего уровня.
Логика доступа к складу:
- оператор и станок работают со складом цеха;
- перемещения между складами (центральный <-> цех <-> следующий цех) выполняются через «Перемещение».
"""
name = models.CharField('Цех', max_length=120, unique=True)
location = models.ForeignKey('warehouse.Location', on_delete=models.PROTECT, null=True, blank=True, verbose_name='Склад цеха')
class Meta:
verbose_name = 'Цех'
verbose_name_plural = 'Цеха'
def __str__(self):
return self.name
class Machine(models.Model):
name = models.CharField("Станок", max_length=100) # Лентопил, Труборез, Лазер
def __str__(self): return self.name
"""Справочник производственных постов (ресурсов).
Терминология UI:
- в интерфейсе используется слово «Пост», чтобы одинаково обозначать станок, линию,
камеру, рабочее место или бригаду (как единицу планирования у мастера).
- в базе и коде модель остаётся Machine, чтобы не ломать существующие связи.
Источник склада для операций выработки/списаний:
- предпочитаем склад цеха (Machine.workshop.location)
- поле Machine.location оставлено для совместимости (если цех не задан)
"""
MACHINE_TYPE_CHOICES = [
('linear', 'Линейный'),
('sheet', 'Листовой'),
('post', 'Пост'),
]
name = models.CharField("Название станка", max_length=100)
machine_type = models.CharField("Тип станка", max_length=10, choices=MACHINE_TYPE_CHOICES, default='linear')
workshop = models.ForeignKey('shiftflow.Workshop', on_delete=models.PROTECT, null=True, blank=True, verbose_name='Цех')
location = models.ForeignKey('warehouse.Location', on_delete=models.PROTECT, null=True, blank=True, verbose_name="Склад участка (устаревает)")
def __str__(self):
return self.name
class Meta:
verbose_name = "Станок"; verbose_name_plural = "Станки"
class Item(models.Model):
class Deal(models.Model):
"""
Заказ или проект. Номер парсится из пути к файлам.
Служит контейнером для группы деталей (позиций).
"""
STATUS_CHOICES = [
('new', 'В задании'),
('lead', 'Зашла'),
('work', 'В работе'),
('done', 'Выполнено'),
('done', 'Завершена'),
]
date = models.DateField("Дата", default=timezone.now)
machine = models.ForeignKey(Machine, on_delete=models.PROTECT, verbose_name="Станок")
deal = models.CharField("№ Сделки", max_length=100) # Твои "Переходники" или заказы
drawing_name = models.CharField("Чертеж / Деталь", max_length=255)
# Характеристики из твоих файлов
material = models.CharField("Материал", max_length=255) # Труба 180х32, MS 12.00mm и т.д.
dim_value = models.FloatField("Размер (мм)", help_text="Длина реза или толщина листа")
quantity_plan = models.PositiveIntegerField("План, шт")
quantity_fact = models.PositiveIntegerField("Факт, шт", default=0)
priority = models.PositiveIntegerField("Приоритет", default=10)
status = models.CharField("Статус", max_length=10, choices=STATUS_CHOICES, default='new')
class Meta:
verbose_name = "Позиция"; verbose_name_plural = "Сменное задание"
ordering = ['-date', 'priority']
number = models.CharField("№ Сделки", max_length=100, unique=True)
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.drawing_name} ({self.deal})"
return f"Сделка №{self.number} ({self.company})"
class Meta:
verbose_name = "Сделка"; verbose_name_plural = "Сделки"
class ProductionTask(models.Model):
"""План производства детали по сделке.
Переходный этап:
- сейчас в задаче ещё есть legacy-поля (drawing_name, файлы, material), чтобы не сломать UI;
- целевая модель: task.entity -> manufacturing.ProductEntity, а файлы/превью живут на entity.
"""
deal = models.ForeignKey(Deal, on_delete=models.CASCADE, verbose_name="Сделка")
entity = models.ForeignKey('manufacturing.ProductEntity', on_delete=models.PROTECT, null=True, blank=True, verbose_name="КД (изделие/деталь)")
drawing_name = models.CharField("Название детали", max_length=255, blank=True, default="Б/ч")
size_value = models.FloatField("Размер детали", help_text="Длина (мм) или Толщина (мм)")
drawing_file = models.FileField("Исходник (DXF/IGES)", upload_to="drawings/%Y/%m/", blank=True, null=True)
extra_drawing = models.FileField("Доп. чертеж (PDF)", upload_to="extra_drawings/%Y/%m/", blank=True, null=True)
preview_image = models.ImageField("Превью DXF (PNG)", upload_to="task_previews/%Y/%m/", blank=True, null=True)
blank_dimensions = models.CharField("Габариты заготовки", max_length=64, blank=True, default="")
material = models.ForeignKey(WarehouseMaterial, on_delete=models.PROTECT, verbose_name="Материал", null=True, blank=True)
quantity_ordered = models.PositiveIntegerField("Заказано всего, шт")
is_bend = models.BooleanField("Гибка", default=False)
created_at = models.DateTimeField("Дата создания", auto_now_add=True)
class Meta:
verbose_name = "Задание на деталь"; verbose_name_plural = "Задания на детали"
ordering = ['-created_at']
def __str__(self):
return f"{self.drawing_name} (Заказ {self.deal.number})"
class DxfPreviewSettings(models.Model):
"""Настройки генерации превью для DXF.
Храним в БД, чтобы админ мог менять параметры через страницу «Обслуживание сервера»
без правок кода.
Сделано как singleton: ожидается одна строка (обычно pk=1).
"""
line_color = models.CharField(
"Цвет линий превью (HEX)",
max_length=16,
default="#006400",
help_text="Напр: #006400 (тёмно-зелёный)",
)
lineweight_scaling = models.FloatField(
"Коэффициент толщины линий",
default=1.0,
help_text="1.0 = как в DXF, 2.0 = толще, 0.5 = тоньше",
)
min_lineweight = models.FloatField(
"Минимальная толщина (мм)",
default=0.1,
help_text="Если в DXF нет lineweight — используем минимум, чтобы линии были видимы",
)
keep_original_colors = models.BooleanField(
"Оставить цвета оригинальные",
default=False,
help_text="Если включено — не перекрашиваем линии, берём цвета из DXF",
)
per_task_timeout_sec = models.PositiveIntegerField(
"Таймаут на 1 DXF (сек)",
default=45,
help_text="Если конкретный DXF завис — убиваем обработку этой детали и идём дальше",
)
updated_at = models.DateTimeField("Обновлено", auto_now=True)
class Meta:
verbose_name = "Настройки превью DXF"
verbose_name_plural = "Настройки превью DXF"
def __str__(self):
return "Настройки превью DXF"
class DealItem(models.Model):
"""Состав сделки: что заказал клиент.
Примечание: при поставках частями используем 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='Изделие/деталь')
quantity = models.PositiveIntegerField('Заказано, шт')
class Meta:
verbose_name = 'Позиция сделки'
verbose_name_plural = 'Позиции сделки'
unique_together = ('deal', 'entity')
def __str__(self):
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):
"""Потребность в закупке сырья для сделки.
required_qty хранит величину в unit:
- для листа: m2
- для профиля/трубы: mm
Статус отражает этап обеспечения.
"""
STATUS_CHOICES = [
('needed', 'К закупке'),
('ordered', 'В пути'),
('fulfilled', 'Обеспечено'),
]
UNIT_CHOICES = [
('m2', 'м²'),
('mm', 'мм'),
('pcs', 'шт'),
]
deal = models.ForeignKey(Deal, on_delete=models.CASCADE, verbose_name='Сделка')
material = models.ForeignKey(WarehouseMaterial, on_delete=models.PROTECT, verbose_name='Материал')
required_qty = models.FloatField('Нужно докупить')
unit = models.CharField('Ед. изм.', max_length=8, choices=UNIT_CHOICES, default='pcs')
status = models.CharField('Статус', max_length=20, choices=STATUS_CHOICES, default='needed')
class Meta:
verbose_name = 'Потребность'
verbose_name_plural = 'Потребности'
def __str__(self):
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):
"""Производственный отчет (основа для списания/начисления).
Основная идея документа:
- оператор фиксирует выработку по нескольким плановым заданиям за смену;
- списание сырья на участке может включать несколько позиций (листы/хлысты/куски);
- по итогам могут появляться несколько деловых остатков.
used_stock_item — legacy-поле для упрощённого случая «списали 1 единицу сырья».
Для реального списания используем ProductionReportConsumption.
"""
operator = models.ForeignKey(User, on_delete=models.PROTECT, verbose_name='Оператор')
machine = models.ForeignKey(Machine, on_delete=models.PROTECT, verbose_name='Станок')
used_stock_item = models.ForeignKey(
'warehouse.StockItem',
on_delete=models.PROTECT,
null=True,
blank=True,
verbose_name='Взятый материал (legacy)',
)
date = models.DateField('Дата', default=timezone.localdate)
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 = 'Производственные отчеты'
def __str__(self):
return f"{self.date} {self.machine} ({self.operator})"
class ProductionReportConsumption(models.Model):
"""Строка списания сырья в рамках производственного отчёта.
Переходная схема:
- целевой ввод делается по номенклатуре (material);
- legacy-поле stock_item оставлено временно, чтобы мигрировать существующие записи.
После переноса данных stock_item будет удалён, а material станет обязательным.
"""
report = models.ForeignKey(CuttingSession, related_name='consumptions', on_delete=models.CASCADE, verbose_name='Производственный отчет')
material = models.ForeignKey(WarehouseMaterial, on_delete=models.PROTECT, null=True, blank=True, verbose_name='Материал')
stock_item = models.ForeignKey('warehouse.StockItem', on_delete=models.PROTECT, null=True, blank=True, verbose_name='Сырье (позиция склада, legacy)')
quantity = models.FloatField('Списано (ед.)')
class Meta:
verbose_name = 'Списание сырья'
verbose_name_plural = 'Списание сырья'
unique_together = ('report', 'material')
def __str__(self):
return f"{self.report_id}: {self.material} - {self.quantity}"
class ProductionReportRemnant(models.Model):
"""Деловой остаток, который нужно начислить по итогам производственного отчёта."""
report = models.ForeignKey(CuttingSession, related_name='remnants', on_delete=models.CASCADE, verbose_name='Производственный отчет')
material = models.ForeignKey(WarehouseMaterial, on_delete=models.PROTECT, verbose_name='Материал')
quantity = models.FloatField('Количество (ед.)', default=1.0)
current_length = models.FloatField('Текущая длина, мм', null=True, blank=True)
current_width = models.FloatField('Текущая ширина, мм', null=True, blank=True)
unique_id = models.CharField('ID/Маркировка', max_length=50, null=True, blank=True)
class Meta:
verbose_name = 'Деловой остаток'
verbose_name_plural = 'Деловые остатки'
def __str__(self):
return f"{self.report_id}: {self.material}"
class ProductionReportStockResult(models.Model):
"""След созданных складских позиций по отчету (готовые детали и деловые остатки)."""
KIND_CHOICES = [
('finished', 'Готовая деталь'),
('remnant', 'Деловой остаток'),
]
report = models.ForeignKey(CuttingSession, related_name='results', on_delete=models.CASCADE, verbose_name='Производственный отчет')
stock_item = models.ForeignKey('warehouse.StockItem', on_delete=models.PROTECT, verbose_name='Созданная позиция склада')
kind = models.CharField('Тип', max_length=16, choices=KIND_CHOICES)
class Meta:
verbose_name = 'Результат отчета'
verbose_name_plural = 'Результаты отчета'
unique_together = ('report', 'stock_item')
def __str__(self):
return f"{self.report_id}: {self.stock_item_id}"
class ShiftItem(models.Model):
"""Фиксация выработки в рамках производственного отчёта."""
session = models.ForeignKey(CuttingSession, related_name='tasks', on_delete=models.CASCADE)
task = models.ForeignKey(ProductionTask, on_delete=models.PROTECT, verbose_name='Плановое задание')
quantity_fact = models.PositiveIntegerField('Изготовлено (факт), шт', default=0)
material_substitution = models.BooleanField('Замена материала по факту', default=False)
class Meta:
verbose_name = 'Фиксация выработки'
verbose_name_plural = 'Фиксации выработки'
def __str__(self):
return f"{self.session} -> {self.task}"
class DxfPreviewJob(models.Model):
"""Фоновая задача пакетной регенерации превью DXF.
Зачем нужна:
- генерация превью и bbox может быть тяжёлой и в синхронном POST «вешает» ответ;
- поэтому мы запускаем задачу в фоне и пишем прогресс в БД;
- UI может показывать статус/счётчики без ожидания завершения.
Важно:
- это не Celery и не очередь, а простой «фон» для текущего процесса Django;
- для продакшена лучше вынести в полноценный воркер, но этот вариант уже убирает зависания UI.
"""
STATUS_CHOICES = [
('queued', 'В очереди'),
('running', 'Выполняется'),
('done', 'Готово'),
('failed', 'Ошибка'),
('cancelled', 'Остановлено'),
]
status = models.CharField("Статус", max_length=16, choices=STATUS_CHOICES, default='queued')
created_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, verbose_name="Запустил")
cancel_requested = models.BooleanField(
"Запрошена остановка",
default=False,
help_text="Если включено — воркер завершит задачу после текущей детали",
)
pid = models.PositiveIntegerField(
"PID процесса",
null=True,
blank=True,
help_text="Номер процесса, который выполняет задачу (для диагностики)",
)
started_at = models.DateTimeField("Начато", null=True, blank=True)
finished_at = models.DateTimeField("Завершено", null=True, blank=True)
total = models.PositiveIntegerField("Всего задач", default=0)
processed = models.PositiveIntegerField("Обработано", default=0)
updated = models.PositiveIntegerField("Обновлено", default=0)
skipped = models.PositiveIntegerField("Пропущено", default=0)
errors = models.PositiveIntegerField("Ошибок", default=0)
last_message = models.CharField("Сообщение", max_length=255, blank=True, default='')
created_at = models.DateTimeField("Создано", auto_now_add=True)
class Meta:
verbose_name = "Задача превью DXF"
verbose_name_plural = "Задачи превью DXF"
ordering = ['-id']
def __str__(self):
return f"DXF превью: {self.get_status_display()}"
class Item(models.Model):
"""
Единица сменного задания. Определяет КТО, КОГДА и СКОЛЬКО сделал.
"""
STATUS_CHOICES = [
('work', 'В работе'),
('done', 'Выполнено'),
('partial', 'Частично'),
('leftover', 'Недодел'),
]
# --- Ссылка на основу (временно null=True для миграции старых данных) ---
task = models.ForeignKey(ProductionTask, on_delete=models.CASCADE, related_name='items', verbose_name="Задание", null=True, blank=True)
# --- Смена (заполняет мастер) ---
date = models.DateField("Дата смены", default=timezone.localdate)
machine = models.ForeignKey(Machine, on_delete=models.PROTECT, verbose_name="Станок")
quantity_plan = models.PositiveIntegerField("План на смену, шт")
# --- Исполнение (заполняет оператор) ---
quantity_fact = models.PositiveIntegerField("Факт, шт", default=0)
material_taken = models.TextField("Взятый материал", blank=True, help_text="Напр: 3 трубы по 12м")
usable_waste = models.TextField("Деловой отход", blank=True, help_text="Напр: кусок 1500мм")
scrap_weight = models.FloatField("Лом (кг)", default=0.0)
# --- Статусы и учет ---
status = models.CharField("Статус", max_length=10, choices=STATUS_CHOICES, default='work')
is_synced_1c = models.BooleanField("Учтено в 1С", default=False)
class Meta:
verbose_name = "Пункт сменки"; verbose_name_plural = "Реестр сменных заданий"
ordering = ['-date', 'task__deal']
def __str__(self):
if self.task:
return f"{self.task.drawing_name} - {self.date}"
return f"Без задания - {self.date}"
class EmployeeProfile(models.Model):
ROLE_CHOICES = [
('admin', 'Администратор'),
('technologist', 'Технолог'),
('master', 'Мастер'),
('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()}"
class Meta:
verbose_name = 'Профиль сотрудника'
verbose_name_plural = 'Профили сотрудников'

171
shiftflow/popup_views.py Normal file
View File

@@ -0,0 +1,171 @@
from django import forms
from django.contrib.auth.mixins import LoginRequiredMixin
from django.shortcuts import redirect
from django.template.response import TemplateResponse
from django.views.generic import CreateView, UpdateView
from warehouse.models import Material, MaterialCategory, SteelGrade
from .models import Company, Deal
class _PopupRoleMixin(LoginRequiredMixin):
def dispatch(self, request, *args, **kwargs):
profile = getattr(request.user, "profile", None)
role = profile.role if profile else ("admin" if request.user.is_superuser else "operator")
if role not in ["admin", "technologist"]:
return redirect("registry")
return super().dispatch(request, *args, **kwargs)
def get_target(self):
return (self.request.GET.get("target") or self.request.POST.get("target") or "").strip()
def form_valid(self, form):
self.object = form.save()
return TemplateResponse(
self.request,
"shiftflow/popup_done.html",
{"target": self.get_target(), "value": self.object.pk, "label": self.get_popup_label()},
)
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx["target"] = self.get_target()
return ctx
def get_popup_label(self):
return str(self.object)
class _BootstrapModelForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
for name, field in self.fields.items():
widget = field.widget
if isinstance(widget, (forms.Select, forms.SelectMultiple)):
cls = "form-select border-secondary"
elif isinstance(widget, forms.CheckboxInput):
cls = "form-check-input"
else:
cls = "form-control border-secondary"
widget.attrs["class"] = cls
class DealForm(_BootstrapModelForm):
class Meta:
model = Deal
fields = ["number", "status", "company", "description"]
class CompanyForm(_BootstrapModelForm):
class Meta:
model = Company
fields = ["name", "description"]
class MaterialForm(_BootstrapModelForm):
class Meta:
model = Material
fields = ["category", "steel_grade", "name"]
class MaterialCategoryForm(_BootstrapModelForm):
class Meta:
model = MaterialCategory
fields = ["name", "gost_standard"]
class SteelGradeForm(_BootstrapModelForm):
class Meta:
model = SteelGrade
fields = ["name", "gost_standard"]
class DealPopupCreateView(_PopupRoleMixin, CreateView):
template_name = "shiftflow/popup_form.html"
model = Deal
form_class = DealForm
def get_popup_label(self):
return self.object.number
class DealPopupUpdateView(_PopupRoleMixin, UpdateView):
template_name = "shiftflow/popup_form.html"
model = Deal
form_class = DealForm
def get_popup_label(self):
return self.object.number
class CompanyPopupCreateView(_PopupRoleMixin, CreateView):
template_name = "shiftflow/popup_form.html"
model = Company
form_class = CompanyForm
def get_popup_label(self):
return self.object.name
class CompanyPopupUpdateView(_PopupRoleMixin, UpdateView):
template_name = "shiftflow/popup_form.html"
model = Company
form_class = CompanyForm
def get_popup_label(self):
return self.object.name
class MaterialPopupCreateView(_PopupRoleMixin, CreateView):
template_name = "shiftflow/popup_form.html"
model = Material
form_class = MaterialForm
def get_popup_label(self):
return self.object.full_name
class MaterialPopupUpdateView(_PopupRoleMixin, UpdateView):
template_name = "shiftflow/popup_form.html"
model = Material
form_class = MaterialForm
def get_popup_label(self):
return self.object.full_name
class MaterialCategoryPopupCreateView(_PopupRoleMixin, CreateView):
template_name = "shiftflow/popup_form.html"
model = MaterialCategory
form_class = MaterialCategoryForm
def get_popup_label(self):
return self.object.name
class MaterialCategoryPopupUpdateView(_PopupRoleMixin, UpdateView):
template_name = "shiftflow/popup_form.html"
model = MaterialCategory
form_class = MaterialCategoryForm
def get_popup_label(self):
return self.object.name
class SteelGradePopupCreateView(_PopupRoleMixin, CreateView):
template_name = "shiftflow/popup_form.html"
model = SteelGrade
form_class = SteelGradeForm
def get_popup_label(self):
return self.object.name
class SteelGradePopupUpdateView(_PopupRoleMixin, UpdateView):
template_name = "shiftflow/popup_form.html"
model = SteelGrade
form_class = SteelGradeForm
def get_popup_label(self):
return self.object.name

View File

@@ -0,0 +1,14 @@
"""
Сервисный слой приложения shiftflow.
Здесь живёт бизнес-логика, которую можно вызывать из:
- view (HTTP)
- admin
- management commands
- фоновых воркеров
Принцип:
- сервисы не зависят от шаблонов/HTML,
- сервисы работают с ORM и транзакциями,
- сервисы содержат правила заводской логики (MES/ERP).
"""

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

@@ -0,0 +1,513 @@
from __future__ import annotations
import logging
from collections import defaultdict
from dataclasses import dataclass
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, 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)
class ExplosionStats:
"""
Сводка результата BOM Explosion.
tasks_*:
- сколько ProductionTask создано/обновлено (по leaf-деталям)
req_*:
- сколько ProcurementRequirement создано/обновлено (по потребностям снабжения)
Примечание:
- потребность по сырью (лист/профиль) сейчас не считаем автоматически — будет вводиться вручную.
"""
tasks_created: int
tasks_updated: int
req_created: int
req_updated: int
def _category_kind(material_category_name: str) -> str:
"""
Определение типа материала по названию категории.
Возвращает:
- 'sheet' для листовых материалов
- 'linear' для профилей/труб/круга
- 'unknown' если не удалось определить
"""
s = (material_category_name or "").strip().lower()
if "лист" in s:
return "sheet"
if any(k in s for k in ["труба", "проф", "круг", "швел", "угол", "балк", "квадрат"]):
return "linear"
return "unknown"
def _norm_and_unit(entity: ProductEntity) -> tuple[float | None, str]:
"""
Возвращает норму расхода и единицу измерения для MaterialRequirement.
Логика:
- для листа берём blank_area_m2 (м²/шт)
- для линейного берём blank_length_mm (мм/шт)
Если категория не распознана, но одна из норм задана — используем заданную.
"""
if not entity.planned_material_id or not getattr(entity.planned_material, "category_id", None):
if entity.blank_area_m2:
return float(entity.blank_area_m2), "m2"
if entity.blank_length_mm:
return float(entity.blank_length_mm), "mm"
return None, "pcs"
kind = _category_kind(entity.planned_material.category.name)
if kind == "sheet":
return (float(entity.blank_area_m2) if entity.blank_area_m2 else None), "m2"
if kind == "linear":
return (float(entity.blank_length_mm) if entity.blank_length_mm else None), "mm"
if entity.blank_area_m2:
return float(entity.blank_area_m2), "m2"
if entity.blank_length_mm:
return float(entity.blank_length_mm), "mm"
return None, "pcs"
def _build_bom_graph(root_entity_ids: set[int]) -> dict[int, list[tuple[int, int]]]:
"""
Строит граф BOM в памяти для заданного множества root entity.
Возвращает:
adjacency[parent_id] = [(child_id, qty), ...]
"""
adjacency: dict[int, list[tuple[int, int]]] = defaultdict(list)
frontier = set(root_entity_ids)
seen = set()
while frontier:
batch = frontier - seen
if not batch:
break
seen |= batch
rows = BOM.objects.filter(parent_id__in=batch).values_list("parent_id", "child_id", "quantity")
next_frontier = set()
for parent_id, child_id, qty in rows:
q = int(qty or 0)
if q <= 0:
continue
adjacency[int(parent_id)].append((int(child_id), q))
next_frontier.add(int(child_id))
frontier |= next_frontier
return adjacency
def _explode_to_leaves(
entity_id: int,
adjacency: dict[int, list[tuple[int, int]]],
memo: dict[int, dict[int, int]],
visiting: set[int],
) -> dict[int, int]:
"""
Возвращает разложение entity_id в leaf-детали в виде:
{ leaf_entity_id: multiplier_for_one_unit }
"""
if entity_id in memo:
return memo[entity_id]
if entity_id in visiting:
raise RuntimeError("Цикл в BOM: спецификация зациклена.")
visiting.add(entity_id)
children = adjacency.get(entity_id) or []
if not children:
memo[entity_id] = {entity_id: 1}
visiting.remove(entity_id)
return memo[entity_id]
out: dict[int, int] = defaultdict(int)
for child_id, qty in children:
child_map = _explode_to_leaves(child_id, adjacency, memo, visiting)
for leaf_id, leaf_qty in child_map.items():
out[leaf_id] += int(qty) * int(leaf_qty)
memo[entity_id] = dict(out)
visiting.remove(entity_id)
return memo[entity_id]
def _accumulate_requirements(
entity_id: int,
multiplier: int,
adjacency: dict[int, list[tuple[int, int]]],
visiting: set[int],
out: dict[int, int],
) -> None:
if entity_id in visiting:
raise RuntimeError("Цикл в BOM: спецификация зациклена.")
visiting.add(entity_id)
out[int(entity_id)] = int(out.get(int(entity_id), 0) or 0) + int(multiplier)
for child_id, qty in adjacency.get(int(entity_id), []) or []:
_accumulate_requirements(int(child_id), int(multiplier) * int(qty), adjacency, visiting, out)
visiting.remove(entity_id)
@transaction.atomic
def explode_deal(
deal_id: int,
*,
central_location_name: str = "Центральный склад",
create_tasks: bool = False,
create_procurement: bool = True,
) -> ExplosionStats:
"""BOM Explosion по сделке.
Используется в двух режимах:
- create_procurement=True: пересчитать потребности снабжения (покупное/литьё/аутсорс)
- create_tasks=True: создать/обновить ProductionTask по внутреннему производству
Примечание: потребность по сырью (MaterialRequirement) здесь не считаем автоматически.
"""
deal = Deal.objects.select_for_update().get(pk=deal_id)
deal_items = list(DealItem.objects.select_related("entity").filter(deal=deal))
if not deal_items:
return ExplosionStats(0, 0, 0, 0)
root_ids = {di.entity_id for di in deal_items}
adjacency = _build_bom_graph(root_ids)
memo: dict[int, dict[int, int]] = {}
required_leaves: dict[int, int] = defaultdict(int)
for di in deal_items:
leaf_map = _explode_to_leaves(di.entity_id, adjacency, memo, set())
for leaf_id, qty_per_unit in leaf_map.items():
required_leaves[leaf_id] += int(di.quantity) * int(qty_per_unit)
leaf_entities = {
e.id: e
for e in ProductEntity.objects.select_related("planned_material", "planned_material__category")
.filter(id__in=list(required_leaves.keys()))
}
tasks_created = 0
tasks_updated = 0
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=defaults,
)
if created:
tasks_created += 1
else:
changed = False
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
changed = True
if changed:
pt.save(update_fields=["quantity_ordered", "material"])
tasks_updated += 1
logger.info(
'explode_roots_additive: deal_id=%s roots=%s nodes=%s tasks_created=%s tasks_updated=%s skipped_no_material=%s skipped_supply=%s',
deal_id,
roots,
len(required_nodes),
tasks_created,
tasks_updated,
skipped_no_material,
skipped_supply,
)
return ExplosionStats(tasks_created, tasks_updated, 0, 0)
@transaction.atomic
def rollback_roots_additive(
deal_id: int,
roots: list[tuple[int, int]],
) -> ExplosionStats:
"""Откат additive BOM Explosion.
Используется для сценария "запустили в производство, но в смену ещё не поставили":
- уменьшает started_qty у строки партии (делается во вьюхе)
- уменьшает quantity_ordered у ProductionTask по всем узлам BOM пропорционально откату
Ограничение: откат должен быть запрещён, если по сущности уже есть план/факт в WorkItem.
"""
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()))
}
tasks_updated = 0
skipped_supply = 0
missing_tasks = 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
pt = ProductionTask.objects.filter(deal=deal, entity=entity).first()
if not pt:
missing_tasks += 1
continue
old = int(pt.quantity_ordered or 0)
new_qty = old - int(qty)
if new_qty < 0:
new_qty = 0
if new_qty != old:
pt.quantity_ordered = int(new_qty)
pt.save(update_fields=['quantity_ordered'])
tasks_updated += 1
logger.info(
'rollback_roots_additive: deal_id=%s roots=%s nodes=%s tasks_updated=%s skipped_supply=%s missing_tasks=%s',
deal_id,
roots,
len(required_nodes),
tasks_updated,
skipped_supply,
missing_tasks,
)
return ExplosionStats(0, tasks_updated, 0, 0)

View File

@@ -0,0 +1,222 @@
from django.db import transaction
from django.db.models import F
from django.utils import timezone
import logging
from shiftflow.models import (
CuttingSession,
Item,
ProductionReportConsumption,
ProductionReportRemnant,
ShiftItem,
)
from shiftflow.services.sessions import close_cutting_session
logger = logging.getLogger('mes')
@transaction.atomic
def apply_closing(
*,
user_id: int,
machine_id: int,
material_id: int,
item_actions: dict[int, dict],
consumptions: dict[int, float],
remnants: list[dict],
) -> None:
logger.info('apply_closing:start user=%s machine=%s material=%s items=%s consumptions=%s remnants=%s', user_id, machine_id, material_id, list(item_actions.keys()), list(consumptions.keys()), len(remnants))
items = list(
Item.objects.select_for_update(of=('self',))
.select_related('task', 'task__deal', 'task__material', 'machine')
.filter(id__in=list(item_actions.keys()), machine_id=machine_id, status='work', task__material_id=material_id)
)
if not items:
logger.error('apply_closing:no_items machine=%s material=%s', machine_id, material_id)
raise RuntimeError('Не найдено пунктов сменки для закрытия.')
report = CuttingSession.objects.create(
operator_id=user_id,
machine_id=machine_id,
used_stock_item=None,
date=timezone.localdate(),
is_closed=False,
)
logger.info('apply_closing:report_created id=%s', report.id)
logger.info('apply_closing:update_items start items=%s', [it.id for it in items])
for it in items:
spec = item_actions.get(it.id) or {}
action = (spec.get('action') or '').strip()
fact = int(spec.get('fact') or 0)
if action not in ['done', 'partial']:
continue
plan = int(it.quantity_plan or 0)
if plan <= 0:
continue
if action == 'done':
fact = plan
else:
fact = max(0, min(fact, plan))
if fact <= 0:
raise RuntimeError('При частичном закрытии факт должен быть больше 0.')
ShiftItem.objects.create(session=report, task=it.task, quantity_fact=fact)
logger.info('apply_closing:consumption_count=%s', len(consumptions))
for stock_item_id, qty in consumptions.items():
if qty <= 0:
continue
ProductionReportConsumption.objects.create(
report=report,
stock_item_id=stock_item_id,
material_id=None,
quantity=float(qty),
)
logger.info('apply_closing:remnants_count=%s', len(remnants))
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,
)
logger.info('apply_closing:close_session id=%s', report.id)
close_cutting_session(report.id)
for it in items:
spec = item_actions.get(it.id) or {}
action = (spec.get('action') or '').strip()
fact = int(spec.get('fact') or 0)
if action not in ['done', 'partial']:
continue
plan = int(it.quantity_plan or 0)
if plan <= 0:
continue
if action == 'done':
it.quantity_fact = plan
it.status = 'done'
it.save(update_fields=['quantity_fact', 'status'])
continue
fact = max(0, min(fact, plan))
residual = plan - fact
it.quantity_fact = fact
it.status = 'partial'
it.save(update_fields=['quantity_fact', 'status'])
if residual > 0:
Item.objects.create(
task=it.task,
date=it.date,
machine=it.machine,
quantity_plan=residual,
quantity_fact=0,
status='leftover',
is_synced_1c=False,
)
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,209 @@
from django.db import transaction
import logging
from django.utils import timezone
from manufacturing.models import ProductEntity
from shiftflow.models import (
CuttingSession,
ProductionReportConsumption,
ProductionReportRemnant,
ProductionReportStockResult,
ShiftItem,
)
from warehouse.models import StockItem
logger = logging.getLogger('mes')
@transaction.atomic
def close_cutting_session(session_id: int) -> None:
"""
Закрытие CuttingSession (транзакция склада).
A) Списать сырьё:
- уменьшаем used_stock_item.quantity на 1
- если стало 0 -> удаляем
B) Начислить готовые детали:
- для каждого ShiftItem создаём StockItem(entity=..., location=machine.location, quantity=quantity_fact)
- если использованный материал не совпадает с planned_material КД -> material_substitution=True
"""
logger.info('close_cutting_session:start id=%s', session_id)
session = (
CuttingSession.objects.select_for_update(of=('self',))
.select_related(
"machine",
"machine__location",
"machine__workshop",
"machine__workshop__location",
"used_stock_item",
"used_stock_item__material",
)
.get(pk=session_id)
)
if session.is_closed:
return
work_location = None
if getattr(session.machine, 'workshop_id', None) and getattr(session.machine.workshop, 'location_id', None):
work_location = session.machine.workshop.location
elif session.machine.location_id:
work_location = session.machine.location
if not work_location:
raise RuntimeError('Не задан склад цеха для станка (Цех -> Склад цеха).')
consumed_material_ids: set[int] = set()
consumptions = list(
ProductionReportConsumption.objects.select_related('material', 'stock_item', 'stock_item__material', 'stock_item__location')
.filter(report=session)
)
if consumptions:
for c in consumptions:
need = float(c.quantity)
if need <= 0:
continue
if c.stock_item_id:
si = StockItem.objects.select_for_update(of=('self',)).select_related('material', 'location').get(pk=c.stock_item_id)
logger.info('close_cutting_session:consume stock_item=%s qty=%s before=%s', si.id, c.quantity, si.quantity)
if not si.material_id:
raise RuntimeError('В списании сырья указана позиция склада без material.')
if si.location_id != work_location.id:
raise RuntimeError('Списывать сырьё можно только со склада цеха станка.')
if need > float(si.quantity):
raise RuntimeError('Недостаточно количества в выбранной складской позиции.')
si.quantity = float(si.quantity) - need
if si.quantity == 0:
si.is_archived = True
si.archived_at = timezone.now()
si.save(update_fields=['quantity', 'is_archived', 'archived_at'])
logger.info('close_cutting_session:archived stock_item=%s', si.id)
else:
si.save(update_fields=['quantity'])
consumed_material_ids.add(int(si.material_id))
continue
if not c.material_id:
raise RuntimeError('В списании сырья не указан материал.')
consumed_material_ids.add(int(c.material_id))
qs = (
StockItem.objects.select_for_update(of=('self',))
.select_related('material', 'location')
.filter(location=work_location, material_id=c.material_id, entity__isnull=True)
.order_by('id')
)
for si in qs:
if need <= 0:
break
take = min(float(si.quantity), need)
si.quantity = float(si.quantity) - take
need -= take
if si.quantity == 0:
si.is_archived = True
si.archived_at = timezone.now()
si.save(update_fields=['quantity', 'is_archived', 'archived_at'])
logger.info('close_cutting_session:archived stock_item=%s', si.id)
else:
si.save(update_fields=['quantity'])
if need > 0:
raise RuntimeError('Недостаточно сырья на складе цеха станка для списания.')
else:
if not session.used_stock_item_id:
raise RuntimeError('Не заполнено списание сырья: добавь строки «Списание сырья» или укажи legacy поле «Взятый материал».')
used = StockItem.objects.select_for_update(of=('self',)).select_related('material', 'location').get(pk=session.used_stock_item_id)
logger.info('close_cutting_session:used stock_item=%s before=%s', used.id, used.quantity)
if not used.material_id:
raise RuntimeError('Взятый материал должен ссылаться на сырьё (material), а не на готовую деталь (entity).')
if used.location_id != work_location.id:
raise RuntimeError('Списывать сырьё можно только со склада цеха станка.')
used.quantity = float(used.quantity) - 1.0
if used.quantity < 0:
raise RuntimeError('Недостаточно сырья для списания.')
if used.quantity == 0:
used.is_archived = True
used.archived_at = timezone.now()
used.save(update_fields=['quantity', 'is_archived', 'archived_at'])
logger.info('close_cutting_session:archived used=%s', used.id)
else:
used.save(update_fields=['quantity'])
consumed_material_ids.add(int(used.material_id))
items = list(
ShiftItem.objects.select_related("task", "task__entity", "task__entity__planned_material", "task__material")
.filter(session=session)
)
for it in items:
if it.quantity_fact <= 0:
continue
task = it.task
planned_material = None
if task.entity_id and getattr(task.entity, 'planned_material_id', None):
planned_material = task.entity.planned_material
elif getattr(task, 'material_id', None):
planned_material = task.material
if planned_material and consumed_material_ids:
it.material_substitution = planned_material.id not in consumed_material_ids
else:
it.material_substitution = False
it.save(update_fields=['material_substitution'])
if not task.entity_id:
name = (getattr(task, 'drawing_name', '') or '').strip() or 'Без названия'
pe = ProductEntity.objects.create(
name=name[:255],
drawing_number=f"AUTO-{task.id}",
entity_type='part',
planned_material=planned_material,
)
task.entity = pe
task.save(update_fields=['entity'])
created = StockItem.objects.create(
entity=task.entity,
deal_id=getattr(task, 'deal_id', None),
location=work_location,
quantity=float(it.quantity_fact),
)
ProductionReportStockResult.objects.create(report=session, stock_item=created, kind='finished')
remnants = list(ProductionReportRemnant.objects.filter(report=session).select_related('material'))
for r in remnants:
created = StockItem.objects.create(
material=r.material,
location=work_location,
quantity=float(r.quantity),
is_remnant=True,
current_length=r.current_length,
current_width=r.current_width,
unique_id=r.unique_id,
)
ProductionReportStockResult.objects.create(report=session, stock_item=created, kind='remnant')
session.is_closed = True
session.save(update_fields=["is_closed"])
logger.info('close_cutting_session:done id=%s', session_id)

View File

@@ -0,0 +1,285 @@
import logging
from typing import Dict
from django.db import transaction
from django.db.models import Sum
from django.db.models.functions import Coalesce
from django.utils import timezone
from manufacturing.models import EntityOperation
from shiftflow.models import DealItem, WorkItem
from warehouse.models import Material, StockItem, TransferLine, TransferRecord
from warehouse.services.transfers import receive_transfer
logger = logging.getLogger('mes')
def build_shipment_rows(
*,
deal_id: int,
shipping_location_id: int,
) -> tuple[list[dict], list[dict]]:
"""
Формирует данные для интерфейса отгрузки по сделке:
- Список деталей (Entities), готовых к отгрузке и уже отгруженных.
- Список давальческого сырья (Materials), доступного для возврата/отгрузки.
"""
deal_items = list(
DealItem.objects.select_related('entity')
.filter(deal_id=int(deal_id))
.order_by('entity__entity_type', 'entity__drawing_number', 'entity__name', 'id')
)
entities = [it.entity for it in deal_items if it.entity_id and it.entity]
ent_ids = [int(e.id) for e in entities if e]
entity_ops = list(
EntityOperation.objects.select_related('operation')
.filter(entity_id__in=ent_ids)
.order_by('entity_id', 'seq', 'id')
)
route_codes: dict[int, list[str]] = {}
last_code: dict[int, str] = {}
for eo in entity_ops:
if not eo.operation_id or not eo.operation:
continue
code = (eo.operation.code or '').strip()
if not code:
continue
route_codes.setdefault(int(eo.entity_id), []).append(code)
# Определяем последнюю операцию в маршруте для каждой детали
for eid, codes in route_codes.items():
if codes:
last_code[int(eid)] = str(codes[-1])
wi_qs = WorkItem.objects.select_related('operation').filter(deal_id=int(deal_id), entity_id__in=ent_ids)
done_by: dict[tuple[int, str], int] = {}
done_total_by_entity: dict[int, int] = {}
# Подсчитываем количество выполненных деталей по операциям
for wi in wi_qs:
op_code = ''
if getattr(wi, 'operation_id', None) and getattr(wi, 'operation', None):
op_code = (wi.operation.code or '').strip()
if not op_code:
op_code = (wi.stage or '').strip()
if not op_code:
continue
eid = int(wi.entity_id)
done = int(wi.quantity_done or 0)
done_by[(eid, str(op_code))] = done_by.get((eid, str(op_code)), 0) + done
done_total_by_entity[eid] = done_total_by_entity.get(eid, 0) + done
shipped_by = {
int(r['entity_id']): float(r['s'] or 0.0)
for r in StockItem.objects.filter(
is_archived=False,
location_id=int(shipping_location_id),
deal_id=int(deal_id),
entity_id__in=ent_ids,
)
.values('entity_id')
.annotate(s=Coalesce(Sum('quantity'), 0.0))
}
ent_avail = {
int(r['entity_id']): float(r['s'] or 0.0)
for r in StockItem.objects.filter(
deal_id=int(deal_id),
is_archived=False,
quantity__gt=0,
entity_id__in=ent_ids,
)
.exclude(location_id=int(shipping_location_id))
.values('entity_id')
.annotate(s=Coalesce(Sum('quantity'), 0.0))
}
entity_rows = []
for di in deal_items:
e = di.entity
if not e:
continue
need = int(di.quantity or 0)
if need <= 0:
continue
eid = int(e.id)
last = last_code.get(eid)
# Количество готовых деталей: берем по последней операции маршрута,
# либо общее количество, если маршрут не задан
ready_done = int(done_by.get((eid, str(last)), 0) or 0) if last else int(done_total_by_entity.get(eid, 0) or 0)
ready_val = min(need, ready_done)
# Сколько уже отгружено на склад отгрузки
shipped_val = int(shipped_by.get(eid, 0.0) or 0.0)
shipped_val = min(need, shipped_val)
remaining_ready = int(max(0, ready_val - shipped_val))
if remaining_ready <= 0:
continue
entity_rows.append({
'entity': e,
'available': float(ent_avail.get(eid, 0.0) or 0.0),
'ready': int(ready_val),
'shipped': int(shipped_val),
'remaining_ready': int(remaining_ready),
})
mat_rows = list(
StockItem.objects.filter(
deal_id=int(deal_id),
is_archived=False,
quantity__gt=0,
material_id__isnull=False,
is_customer_supplied=True,
)
.exclude(location_id=int(shipping_location_id))
.values('material_id')
.annotate(available=Coalesce(Sum('quantity'), 0.0))
.order_by('material_id')
)
mat_ids = [int(r['material_id']) for r in mat_rows if r.get('material_id')]
mats = {m.id: m for m in Material.objects.filter(id__in=mat_ids)}
material_rows = []
for r in mat_rows:
mid = int(r['material_id'])
m = mats.get(mid)
if not m:
continue
material_rows.append({
'material': m,
'available': float(r.get('available') or 0.0),
})
return entity_rows, material_rows
@transaction.atomic
def create_shipment_transfers(
*,
deal_id: int,
shipping_location_id: int,
entity_qty: Dict[int, int],
material_qty: Dict[int, float],
user_id: int,
) -> list[int]:
"""
Создает документы перемещения (TransferRecord) на склад отгрузки
для указанных деталей и давальческого сырья.
"""
logger.info(
'fn:start create_shipment_transfers deal_id=%s shipping_location_id=%s user_id=%s',
deal_id, shipping_location_id, user_id
)
now = timezone.now()
transfers_by_location: dict[int, TransferRecord] = {}
def get_transfer(from_location_id: int) -> TransferRecord:
tr = transfers_by_location.get(int(from_location_id))
if tr:
return tr
tr = TransferRecord.objects.create(
from_location_id=int(from_location_id),
to_location_id=int(shipping_location_id),
sender_id=int(user_id),
receiver_id=int(user_id),
occurred_at=now,
status='received',
received_at=now,
is_applied=False,
)
transfers_by_location[int(from_location_id)] = tr
return tr
def alloc_stock_lines(qs, need_qty: float) -> None:
"""
Резервирует необходимое количество из доступных складских остатков.
Использует select_for_update для предотвращения гонок данных.
"""
remaining = float(need_qty)
if remaining <= 0:
return
items = list(qs.select_for_update().order_by('location_id', 'created_at', 'id'))
for si in items:
if remaining <= 0:
break
if int(si.location_id) == int(shipping_location_id):
continue
si_qty = float(si.quantity or 0.0)
if si_qty <= 0:
continue
if si.unique_id:
if remaining < si_qty:
raise ValueError('Нельзя частично отгружать позицию с маркировкой (unique_id).')
take = si_qty
else:
take = min(remaining, si_qty)
tr = get_transfer(int(si.location_id))
TransferLine.objects.create(transfer=tr, stock_item=si, quantity=float(take))
remaining -= float(take)
if remaining > 0:
raise ValueError('Недостаточно количества на складах для отгрузки.')
for ent_id, qty in (entity_qty or {}).items():
q = int(qty or 0)
if q <= 0:
continue
alloc_stock_lines(
StockItem.objects.filter(
deal_id=int(deal_id),
is_archived=False,
quantity__gt=0,
entity_id=int(ent_id),
).select_related('entity', 'location'),
float(q),
)
for mat_id, qty in (material_qty or {}).items():
q = float(qty or 0.0)
if q <= 0:
continue
alloc_stock_lines(
StockItem.objects.filter(
deal_id=int(deal_id),
is_archived=False,
quantity__gt=0,
material_id=int(mat_id),
is_customer_supplied=True,
).select_related('material', 'location'),
float(q),
)
ids = []
for tr in transfers_by_location.values():
receive_transfer(int(tr.id), int(user_id))
ids.append(int(tr.id))
ids.sort()
logger.info(
'fn:done create_shipment_transfers deal_id=%s transfers=%s',
deal_id,
ids,
)
return ids

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

@@ -0,0 +1,288 @@
{% 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 '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">
<h3 class="text-accent mb-0"><i class="bi bi-check2-square me-2"></i>Закрытие</h3>
</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 wi in workitems %}
<tr>
<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="{{ 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="{{ 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>
{% 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,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

@@ -0,0 +1,167 @@
{% 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="d-flex align-items-center gap-3">
<div>
<h3 class="text-accent mb-0"><i class="bi bi-briefcase me-2"></i>Сделки</h3>
<div class="small text-muted">{{ company.name }}</div>
</div>
<form method="get" class="d-flex align-items-center gap-2">
<span class="small text-muted">Статус:</span>
<div class="d-flex flex-wrap gap-1">
<input type="radio" class="btn-check" name="status" id="cust_s_work" value="work" {% if selected_status == 'work' %}checked{% endif %} onchange="this.form.submit()">
<label class="btn btn-outline-primary btn-sm" for="cust_s_work">В работе</label>
<input type="radio" class="btn-check" name="status" id="cust_s_lead" value="lead" {% if selected_status == 'lead' %}checked{% endif %} onchange="this.form.submit()">
<label class="btn btn-outline-secondary btn-sm" for="cust_s_lead">Зашла</label>
<input type="radio" class="btn-check" name="status" id="cust_s_done" value="done" {% if selected_status == 'done' %}checked{% endif %} onchange="this.form.submit()">
<label class="btn btn-outline-success btn-sm" for="cust_s_done">Завершена</label>
</div>
</form>
</div>
<div class="d-flex gap-2">
{% if user_role in 'admin,technologist' %}
<button type="button" class="btn btn-outline-accent btn-sm" data-bs-toggle="modal" data-bs-target="#dealModal">
<i class="bi bi-plus-lg me-1"></i>Создать сделку
</button>
{% endif %}
<a class="btn btn-outline-secondary btn-sm" href="{% url 'customers' %}">
<i class="bi bi-arrow-left me-1"></i>К заказчикам
</a>
</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 style="width: 140px;">Статус</th>
</tr>
</thead>
<tbody>
{% for d in deals %}
<tr class="planning-row" style="cursor:pointer" data-href="{% url 'planning_deal' d.id %}">
<td><span class="text-accent fw-bold">{{ d.number }}</span></td>
<td class="small text-muted">{{ d.description|default:"" }}</td>
<td>
<span class="badge {% if d.status == 'work' %}bg-primary{% elif d.status == 'done' %}bg-success{% else %}bg-secondary{% endif %}">
{{ d.get_status_display }}
</span>
</td>
</tr>
{% empty %}
<tr><td colspan="3" class="text-center p-5 text-muted">Сделок не найдено</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
<div class="modal fade" id="dealModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content border-secondary">
<div class="modal-header border-secondary">
<h5 class="modal-title">Сделка</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label small text-muted">№ Сделки</label>
<input type="text" class="form-control border-secondary" id="dealNumber">
</div>
<div class="mb-3">
<label class="form-label small text-muted">Статус</label>
<select class="form-select border-secondary" id="dealStatus">
<option value="lead">Зашла</option>
<option value="work" selected>В работе</option>
<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>
</div>
</div>
<div class="modal-footer border-secondary">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Отмена</button>
<button type="button" class="btn btn-outline-accent" id="dealSaveBtn">Сохранить</button>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function () {
document.querySelectorAll('tr.planning-row[data-href]').forEach(function (row) {
row.addEventListener('click', function () {
const href = row.getAttribute('data-href');
if (href) window.location.href = href;
});
});
const dealModal = document.getElementById('dealModal');
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) {
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) return parts.pop().split(';').shift();
return '';
}
async function postForm(url, data) {
const csrftoken = getCookie('csrftoken');
const res = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
'X-CSRFToken': csrftoken,
},
body: new URLSearchParams(data).toString(),
});
if (!res.ok) throw new Error('request_failed');
return await res.json();
}
if (dealModal) {
dealModal.addEventListener('show.bs.modal', function () {
if (dealNumber) dealNumber.value = '';
if (dealDescription) dealDescription.value = '';
if (dealDueDate) dealDueDate.value = '';
if (dealStatus) dealStatus.value = 'work';
});
}
if (dealSaveBtn) {
dealSaveBtn.addEventListener('click', async function () {
const payload = {
number: (dealNumber ? dealNumber.value : ''),
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();
});
}
});
</script>
{% endblock %}

View File

@@ -0,0 +1,117 @@
{% 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-building me-2"></i>Заказчики</h3>
{% if user_role in 'admin,technologist,clerk,manager' %}
<button type="button" class="btn btn-outline-accent btn-sm" data-bs-toggle="modal" data-bs-target="#companyModal">
<i class="bi bi-plus-lg me-1"></i>Добавить заказчика
</button>
{% endif %}
</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>
</tr>
</thead>
<tbody>
{% for c in companies %}
<tr class="customer-row" style="cursor:pointer" data-href="{% url 'customer_deals' c.id %}">
<td class="fw-bold">{{ c.name }}</td>
<td class="small text-muted">{{ c.description|default:"" }}</td>
</tr>
{% empty %}
<tr><td colspan="2" class="text-center p-5 text-muted">Заказчиков не найдено</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
<div class="modal fade" id="companyModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content border-secondary">
<div class="modal-header border-secondary">
<h5 class="modal-title">Заказчик</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label small text-muted">Название</label>
<input type="text" class="form-control border-secondary" id="companyName">
</div>
<div class="mb-0">
<label class="form-label small text-muted">Примечание</label>
<textarea class="form-control border-secondary" rows="3" id="companyDescription"></textarea>
</div>
</div>
<div class="modal-footer border-secondary">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Отмена</button>
<button type="button" class="btn btn-outline-accent" id="companySaveBtn">Сохранить</button>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function () {
document.querySelectorAll('tr.customer-row[data-href]').forEach(function (row) {
row.addEventListener('click', function () {
const href = row.getAttribute('data-href');
if (href) window.location.href = href;
});
});
const companyModal = document.getElementById('companyModal');
const companyName = document.getElementById('companyName');
const companyDescription = document.getElementById('companyDescription');
const companySaveBtn = document.getElementById('companySaveBtn');
function getCookie(name) {
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) return parts.pop().split(';').shift();
return '';
}
async function postForm(url, data) {
const csrftoken = getCookie('csrftoken');
const res = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
'X-CSRFToken': csrftoken,
},
body: new URLSearchParams(data).toString(),
});
if (!res.ok) throw new Error('request_failed');
return await res.json();
}
if (companyModal) {
companyModal.addEventListener('show.bs.modal', function () {
if (companyName) companyName.value = '';
if (companyDescription) companyDescription.value = '';
});
}
if (companySaveBtn) {
companySaveBtn.addEventListener('click', async function () {
const payload = {
name: (companyName ? companyName.value : ''),
description: (companyDescription ? companyDescription.value : ''),
};
await postForm('{% url "company_upsert" %}', payload);
window.location.reload();
});
}
});
</script>
{% endblock %}

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,120 @@
{% extends 'base.html' %}
{% block title %}ShiftFlow | {{ item.drawing_name|default:"Б/ч" }}{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-lg-10 col-xl-8">
<div class="card shadow bg-dark text-light 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>
Деталь: {{ item.drawing_name|default:"Без чертежа" }}
</h3>
<span class="badge bg-secondary opacity-75 fw-normal">ID: {{ item.id }}</span>
</div>
<form method="post" class="card-body p-4">
{% csrf_token %}
<div class="row g-3 mb-4 border-bottom border-secondary pb-4">
<div class="col-md-4 col-6">
<label class="small text-muted">Дата задания:</label>
<div class="fw-bold">{{ item.date|date:"d.m.Y" }}</div>
</div>
<div class="col-md-4 col-6">
<label class="small text-muted">Станок:</label>
<div class="fw-bold"><i class="bi bi-cpu me-1"></i>{{ item.machine.name }}</div>
</div>
<div class="col-md-4 col-12">
<label class="small text-muted">Сделка/Заказ:</label>
<div class="fw-bold text-accent fs-5">№ {{ item.deal.number }}</div>
</div>
<div class="col-md-12">
<label class="small text-muted">Материал / Габариты:</label>
<div class="fw-bold small">{{ item.material.name }} (s{{ item.size_value|default:"-" }})</div>
</div>
</div>
<div class="mb-4">
<h5 class="text-accent mb-3"><i class="bi bi-files me-2"></i>Файлы задания</h5>
<div class="d-flex gap-2">
{% if item.drawing_file %}
<a href="{{ item.drawing_file.url }}" target="_blank" class="btn btn-outline-info">
<i class="bi bi-file-earmark-code me-2"></i>Открыть DXF/STEP
</a>
{% else %}
<button type="button" class="btn btn-sm btn-outline-secondary p-1" disabled><i class="bi bi-file-earmark-code me-1"></i>DXF: нет файла</button>
{% endif %}
{% if item.extra_drawing %}
<a href="{{ item.extra_drawing.url }}" target="_blank" class="btn btn-outline-danger">
<i class="bi bi-file-pdf me-2"></i>Открыть Чертеж PDF
</a>
{% else %}
<button type="button" class="btn btn-sm btn-outline-secondary p-1" disabled><i class="bi bi-file-pdf me-1"></i>PDF: нет файла</button>
{% endif %}
</div>
</div>
<div class="mb-4">
<h5 class="text-accent mb-3"><i class="bi bi-vector-pen me-2"></i>Фактическое исполнение</h5>
<div class="row g-3">
<div class="col-md-4 col-6">
<label class="form-label small text-muted">Заказано штук (План):</label>
<input type="number" class="form-control form-control-lg fw-bold bg-secondary text-info" value="{{ item.quantity_plan }}" readonly disabled>
</div>
<div class="col-md-4 col-6">
<label for="id_quantity_fact" class="form-label small text-muted">Изготовлено штук (Факт):</label>
<input type="number" name="quantity_fact" id="id_quantity_fact" class="form-control form-control-lg bg-dark text-light border-secondary" value="{{ item.quantity_fact }}" min="0" required>
</div>
<div class="col-md-4 col-12 d-flex align-items-end">
<div class="form-check form-switch bg-dark border border-secondary p-3 rounded shadow-sm w-100 h-100 d-flex align-items-center justify-content-between">
<label class="form-check-label ms-1 text-light small" for="id_is_synced_1c">Списано в 1С?</label>
<input class="form-check-input form-switch-lg stop-prop" type="checkbox" name="is_synced_1c" id="id_is_synced_1c" {% if item.is_synced_1c %}checked{% endif %}>
</div>
</div>
</div>
</div>
<div class="mb-5">
<label for="id_status" class="form-label small text-muted">Текущий статус задания:</label>
<select name="status" id="id_status" class="form-select bg-dark text-light border-secondary form-select-lg">
{% for choice_val, choice_label in form.fields.status.choices %}
<option value="{{ choice_val }}" {% if item.status == choice_val %}selected{% endif %}>
{{ choice_label }}
</option>
{% endfor %}
</select>
</div>
<div class="card-footer border-secondary bg-dark p-0 pt-4 d-flex justify-content-between">
<a href="{% url 'registry' %}" class="btn btn-outline-secondary btn-lg px-4">
<i class="bi bi-arrow-left me-2"></i>В реестр
</a>
<button type="submit" class="btn btn-outline-accent btn-lg px-5 fw-bold">
<i class="bi bi-save me-2"></i>Сохранить изменения
</button>
</div>
{% if form.errors %}
<div class="alert alert-danger mt-4 small mb-0 p-3 shadow-sm border-danger">
<h6 class="fw-bold mb-2">Обнаружены ошибки:</h6>
<ul class="mb-0">
{% for field, errors in form.errors.items %}
{% for error in errors %}
<li>{{ field|upper }}: {{ error }}</li>
{% endfor %}
{% endfor %}
</ul>
</div>
{% endif %}
</form>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,211 @@
{% extends 'base.html' %}
{% load l10n %}
{% 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">
{% if user_role == 'operator' %}
<i class="bi bi-info-circle me-2"></i>{{ item.task.drawing_name|default:"Без названия" }}
{% else %}
<a href="{% url 'task_items' item.task.id %}" class="text-decoration-none text-reset">
<i class="bi bi-info-circle me-2"></i>{{ item.task.drawing_name|default:"Без названия" }}
</a>
{% endif %}
</h3>
{% if user_role == 'operator' %}
<span class="badge bg-secondary">Сделка № {{ item.task.deal.number }}</span>
{% else %}
<a href="{% url 'planning_deal' item.task.deal.id %}" class="text-decoration-none">
<span class="badge bg-secondary">Сделка № {{ item.task.deal.number }}</span>
</a>
{% endif %}
</div>
<form method="post" enctype="multipart/form-data" id="mainForm" class="card-body p-4">
{% csrf_token %}
<input type="hidden" name="next" value="{{ back_url }}">
{% if errors %}
<div class="alert alert-danger mb-4">
{% for e in errors %}
<div>{{ e }}</div>
{% endfor %}
</div>
{% endif %}
<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 == 'master' %}
<select name="machine" class="form-select border-secondary">
{% for m in machines %}
<option value="{{ m.id }}" {% if item.machine.id == m.id %}selected{% endif %}>{{ m.name }}</option>
{% endfor %}
</select>
{% else %}
<strong>{{ item.machine.name }}</strong>
{% endif %}
</div>
<div class="col-md-4">
<small class="text-muted d-block">Материал</small>
<strong>{{ item.task.material.full_name|default:item.task.material.name }}</strong>
</div>
<div class="col-md-4">
<small class="text-muted d-block">План</small>
<strong class="text-info fs-5">{{ item.quantity_plan }} шт.</strong>
</div>
</div>
<div class="row g-3 mb-4 border-bottom border-secondary pb-3 text-body">
<div class="col-md-6">
<small class="text-muted d-block">Размер детали</small>
<strong>{{ item.task.size_value|default:"-" }}</strong>
</div>
<div class="col-md-6">
<small class="text-muted d-block">Габариты заготовки</small>
<strong>{{ item.task.blank_dimensions|default:"—" }}</strong>
</div>
</div>
<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 item.task.preview_image %}
<img src="{{ item.task.preview_image.url }}" alt="Превью DXF" 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 item.task.drawing_file %}
<a href="{{ item.task.drawing_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>
{% if user_role == 'admin' %}
<input type="file" name="drawing_file" class="form-control border-secondary" accept=".dxf,.iges,.igs,.step,.stp">
{% endif %}
</div>
<div>
<div class="d-flex align-items-center gap-2 mb-2">
{% if item.task.extra_drawing %}
<a href="{{ item.task.extra_drawing.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>
{% if user_role == 'admin' %}
<input type="file" name="extra_drawing" class="form-control border-secondary" accept="application/pdf">
{% endif %}
</div>
</div>
</div>
</div>
<input type="hidden" name="status" id="id_status" value="{{ item.status }}">
{% if user_role in 'operator,master' %}
{% if item.status == 'work' %}
<div class="bg-body-tertiary p-3 rounded border mb-4 text-center">
<div class="row g-3 text-start">
<div class="col-md-4">
<label class="small text-muted">Факт (шт)</label>
<input type="number" name="quantity_fact" id="id_quantity_fact" class="form-control border-secondary" value="{{ item.quantity_fact }}" max="{{ item.quantity_plan }}">
</div>
</div>
</div>
{% else %}
<div class="alert alert-success">Статус: {{ item.get_status_display }}. Сделано: {{ item.quantity_fact }} шт.</div>
<input type="hidden" name="quantity_fact" value="{{ item.quantity_fact }}">
{% endif %}
{% endif %}
{% if user_role in 'admin,technologist' %}
<div class="row g-3 mb-4">
<div class="col-md-4">
<label class="small text-muted">Дата смены</label>
<input type="date" name="date" class="form-control border-secondary" value="{{ item.date|date:'Y-m-d' }}">
</div>
<div class="col-md-4">
<label class="small text-muted">Станок</label>
<select name="machine" class="form-select border-secondary">
{% for m in machines %}
<option value="{{ m.id }}" {% if item.machine.id == m.id %}selected{% endif %}>{{ m.name }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-4">
<label class="small text-muted">План на смену (шт)</label>
<input type="number" name="quantity_plan" class="form-control border-secondary" value="{{ item.quantity_plan }}">
</div>
<div class="col-md-6">
<label class="small text-muted">Статус задания</label>
<select name="status" class="form-select border-secondary">
{% for val, name in form.fields.status.choices %}
<option value="{{ val }}" {% if item.status == val %}selected{% endif %}>{{ name }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-6">
<label class="small text-muted">Факт (шт)</label>
<input type="number" name="quantity_fact" class="form-control border-secondary" value="{{ item.quantity_fact }}">
</div>
</div>
<div class="form-check form-switch p-3 rounded border border-warning mb-4 bg-body-tertiary d-flex justify-content-between align-items-center">
<label class="form-check-label fw-bold ms-2" for="sync1c">Списано в 1С</label>
<input class="form-check-input ms-0" style="width: 3em; height: 1.5em;" type="checkbox" name="is_synced_1c" id="sync1c" {% if item.is_synced_1c %}checked{% endif %}>
</div>
{% endif %}
{% if user_role == 'clerk' %}
{% if item.status == 'done' or item.status == 'partial' %}
<div class="form-check form-switch p-3 rounded border border-warning mb-4 bg-body-tertiary d-flex justify-content-between align-items-center">
<label class="form-check-label fw-bold ms-2" for="sync1c">Списано в 1С</label>
<input class="form-check-input ms-0" style="width: 3em; height: 1.5em;" type="checkbox" name="is_synced_1c" id="sync1c" {% if item.is_synced_1c %}checked{% endif %}>
</div>
{% else %}
<div class="text-muted small mb-4"><i class="bi bi-info-circle me-1"></i>Списание будет доступно после закрытия.</div>
{% endif %}
<input type="hidden" name="quantity_fact" value="{{ item.quantity_fact }}">
{% endif %}
<div class="d-flex justify-content-between mt-4">
<a href="{{ back_url }}" class="btn btn-outline-secondary">Назад</a>
<div class="d-flex gap-2">
<input type="hidden" name="action" id="actionField" value="save">
<button type="submit" class="btn btn-outline-accent px-4 fw-bold" onclick="document.getElementById('actionField').value='save'">
<i class="bi bi-save me-2"></i>Сохранить
</button>
</div>
</div>
</form>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,14 @@
{% extends 'base.html' %}
{% block content %}
<div class="flex-center-center">
<div class="text-center">
<h1 class="text-accent mb-4 display-3 fw-bold">
<i class="bi bi-gear-fill me-3"></i>ShiftFlow
</h1>
<a href="{% url 'login' %}" class="btn btn-lg btn-outline-accent px-5 py-3 fw-bold shadow">
ВОЙТИ В СИСТЕМУ
</a>
</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,167 @@
{% extends 'base.html' %}
{% load l10n %}
{% 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-tools me-2"></i>Обслуживание сервера</h3>
</div>
<div class="card-body">
<div class="text-muted small mb-3">
Здесь настраиваем и обслуживаем генерацию превью DXF (PNG) на сервере.
</div>
<div class="card border-secondary mb-3">
<div class="card-header border-secondary py-2">
<strong>DXF</strong>
</div>
<div class="card-body">
<div class="mb-3" id="jobBox" {% if not last_job %}style="display:none"{% endif %}>
<div class="small text-muted">Статус: <span id="jobStatus">{% if last_job %}{{ last_job.get_status_display }}{% endif %}</span></div>
<div class="small text-muted">Обработано: <span id="jobProcessed">{% if last_job %}{{ last_job.processed }}{% endif %}</span>/<span id="jobTotal">{% if last_job %}{{ last_job.total }}{% endif %}</span></div>
<div class="small text-muted">Обновлено: <span id="jobUpdated">{% if last_job %}{{ last_job.updated }}{% endif %}</span> · Пропущено: <span id="jobSkipped">{% if last_job %}{{ last_job.skipped }}{% endif %}</span> · Ошибок: <span id="jobErrors">{% if last_job %}{{ last_job.errors }}{% endif %}</span></div>
<div class="small text-muted" id="jobMessage">{% if last_job %}{{ last_job.last_message }}{% endif %}</div>
<div class="small text-muted mt-2">Лог</div>
<div class="small text-muted" id="jobLogPath">{% if last_job and last_job.log_path %}Файл: {{ last_job.log_path }}{% endif %}</div>
<pre id="jobLog" class="border border-secondary rounded p-2 mb-0" style="max-height: 220px; overflow:auto; white-space: pre-wrap;">{% if last_job %}{{ last_job.log_tail }}{% endif %}</pre>
</div>
<form method="post" class="row g-3 align-items-end">
{% csrf_token %}
<div class="col-md-3">
<label class="small text-muted">Цвет превью</label>
<input type="color" name="line_color" class="form-control form-control-color border-secondary" value="{{ dxf_settings.line_color|default:'#006400'|slice:':7' }}">
</div>
<div class="col-md-3">
<label class="small text-muted">Толщина линии (коэфф.)</label>
<input type="number" step="0.1" min="0.1" name="lineweight_scaling" class="form-control border-secondary" value="{{ dxf_settings.lineweight_scaling|default_if_none:1.0|unlocalize }}">
</div>
<div class="col-md-3">
<label class="small text-muted">Мин. толщина (мм)</label>
<input type="number" step="0.05" min="0" name="min_lineweight" class="form-control border-secondary" value="{{ dxf_settings.min_lineweight|default_if_none:0.1|unlocalize }}">
</div>
<div class="col-md-3">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="keepColors" name="keep_original_colors" {% if dxf_settings.keep_original_colors %}checked{% endif %}>
<label class="form-check-label" for="keepColors">Оставить оригинальные цвета</label>
</div>
</div>
<div class="col-md-6">
<label class="small text-muted d-flex justify-content-between">
<span>Таймаут на 1 DXF (сек)</span>
<span id="timeoutValue">{{ dxf_settings.per_task_timeout_sec|default_if_none:45|unlocalize }}</span>
</label>
<input type="range" min="10" max="50" step="1" name="per_task_timeout_sec" id="timeoutRange" class="form-range" value="{{ dxf_settings.per_task_timeout_sec|default_if_none:45|unlocalize }}">
</div>
<div class="col-12 d-flex gap-2">
<button type="submit" class="btn btn-outline-accent" name="action" value="save_settings">
<i class="bi bi-save me-2"></i>Сохранить настройки
</button>
<button type="submit" class="btn btn-outline-accent" name="action" value="update_previews">
<i class="bi bi-arrow-repeat me-2"></i>Обновить превьюшки DXF
</button>
<button type="submit" class="btn btn-outline-warning" name="action" value="cancel_job">
<i class="bi bi-stop-circle me-2"></i>Прервать
</button>
<button type="submit" class="btn btn-outline-secondary" name="action" value="clear_dxf_job_log">
<i class="bi bi-eraser me-2"></i>Очистить лог DXF
</button>
</div>
<div class="col-12">
<div class="text-muted small">
Пакетное обновление пробегает по сделкам в статусах «Зашла» и «В работе».
</div>
</div>
</form>
</div>
</div>
<div class="card border-secondary mb-3">
<div class="card-header border-secondary py-2">
<strong>Логи</strong>
</div>
<div class="card-body">
<div class="small text-muted mb-2">Файл: {{ log_path|default:"—" }}</div>
<pre class="border border-secondary rounded p-2 mb-3" style="max-height: 260px; overflow:auto; white-space: pre-wrap;">{{ log_tail|default:"" }}</pre>
<form method="post" class="d-flex gap-2">
{% csrf_token %}
<button type="submit" class="btn btn-outline-secondary" name="action" value="refresh_log">
<i class="bi bi-arrow-repeat me-2"></i>Обновить
</button>
<button type="submit" class="btn btn-outline-secondary" name="action" value="clear_log">
<i class="bi bi-eraser me-2"></i>Очистить
</button>
</form>
</div>
</div>
{% if messages %}
<div class="mt-3">
{% for message in messages %}
<div class="alert alert-{{ message.tags }}">{{ message }}</div>
{% endfor %}
</div>
{% endif %}
<script>
(function () {
const box = document.getElementById('jobBox');
const statusEl = document.getElementById('jobStatus');
const totalEl = document.getElementById('jobTotal');
const processedEl = document.getElementById('jobProcessed');
const updatedEl = document.getElementById('jobUpdated');
const skippedEl = document.getElementById('jobSkipped');
const errorsEl = document.getElementById('jobErrors');
const msgEl = document.getElementById('jobMessage');
const logEl = document.getElementById('jobLog');
const logPathEl = document.getElementById('jobLogPath');
const timeoutRange = document.getElementById('timeoutRange');
const timeoutValue = document.getElementById('timeoutValue');
if (timeoutRange && timeoutValue) {
timeoutValue.textContent = timeoutRange.value;
timeoutRange.addEventListener('input', function () {
timeoutValue.textContent = timeoutRange.value;
});
}
async function tick() {
try {
const res = await fetch('{% url "maintenance_status" %}', { method: 'GET', headers: { 'Accept': 'application/json' } });
if (!res.ok) return;
const data = await res.json();
if (!data || !data.job) return;
if (box) box.style.display = '';
if (statusEl) statusEl.textContent = data.job.status_label || data.job.status || '';
if (totalEl) totalEl.textContent = String(data.job.total ?? '');
if (processedEl) processedEl.textContent = String(data.job.processed ?? '');
if (updatedEl) updatedEl.textContent = String(data.job.updated ?? '');
if (skippedEl) skippedEl.textContent = String(data.job.skipped ?? '');
if (errorsEl) errorsEl.textContent = String(data.job.errors ?? '');
if (msgEl) msgEl.textContent = data.job.last_message || '';
if (logEl) logEl.textContent = data.job.log_tail || '';
if (logPathEl) logPathEl.textContent = data.job.log_path ? `Файл: ${data.job.log_path}` : '';
if (data.job.status === 'running' || data.job.status === 'queued') {
setTimeout(tick, 3000);
}
} catch (e) {
}
}
setTimeout(tick, 400);
})();
</script>
</div>
</div>
{% 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 %}

Some files were not shown because too many files have changed in this diff Show More