Compare commits
34 Commits
12c07a8108
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 2603c8f51c | |||
| 9006f4c5ab | |||
| 49e9080d0e | |||
| 69edd3fa97 | |||
| ecc0193d0a | |||
| fa881877d7 | |||
| 28537447f8 | |||
| 86215c9fa8 | |||
| a238c83b04 | |||
| eb708a3ab7 | |||
| e88b861f68 | |||
| 0e8497ab1f | |||
| e1dd002102 | |||
| 9ad109e02a | |||
| 1fe05d41f6 | |||
| b76ce4913f | |||
| cddbfeadde | |||
| 9554d47301 | |||
| 7cb00792ca | |||
| d0289f6aec | |||
| c2778d9ec8 | |||
| c9ff66a36b | |||
| 78d4a1a04f | |||
| ff0b791a24 | |||
| 6013d5854b | |||
| 7ef7409c7a | |||
| fc469aaac4 | |||
| 191d06d7d3 | |||
| 641abfff5e | |||
|
|
b256bec04b | ||
|
|
a4ba577206 | ||
| f86f0bfcd4 | |||
| f759c2c17e | |||
| fba195db9c |
@@ -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 %}
|
||||
3
.env
3
.env
@@ -1,12 +1,11 @@
|
||||
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
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -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
90
.trae/rules/main.md
Normal 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
20
.vscode/launch.json
vendored
Normal 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
42
.vscode/settings.json
vendored
@@ -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
65
AI_RULES.md
Normal 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 там, где это безопасно.
|
||||
38
Dockerfile
38
Dockerfile
@@ -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
110
README.md
@@ -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
52
TODO.md
Normal 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 (мост)
|
||||
- Data‑migration:
|
||||
- перенести исторические Item → WorkItem (deal, entity, date, machine, qty_plan, qty_fact, status)
|
||||
- восстановить operation/workshop по EntityOperation + DealEntityProgress/историческим правилам
|
||||
- Постепенное отключение Item:
|
||||
- заменить все места создания Item на WorkItem
|
||||
- после стабилизации убрать Item из UI и сервисов
|
||||
|
||||
- Прогресс/план по сделке:
|
||||
- верхняя таблица «Позиции сделки»: Надо / Запущено / Осталось (по DealBatchItem.started_qty)
|
||||
- факт (Сделано) — от WorkItem.quantity_done на последней операции техпроцесса
|
||||
|
||||
- Снабжение:
|
||||
- покупное/литьё/аутсорс — не создавать ProductionTask, вести учёт как ProcurementRequirement
|
||||
- вывести сводку потребностей для снабженца (группировка по сделке/позиции/сроку)
|
||||
|
||||
- Логи и диагностика:
|
||||
- единый логгер `mes` для всех сервисных действий (включая explode_roots_additive и start_batch_item_production)
|
||||
@@ -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')}) ---")
|
||||
|
||||
|
||||
|
||||
17
core/urls.py
17
core/urls.py
@@ -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)
|
||||
@@ -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:
|
||||
- "80:80"
|
||||
- "80:80" # Единственная "дырка" в мир: порт 80 сервера -> порт 80 контейнера
|
||||
depends_on:
|
||||
- web
|
||||
- web # Nginx запустится только после Django
|
||||
|
||||
# Описание "жестких дисков" (Volumes), которые живут дольше контейнеров
|
||||
volumes:
|
||||
postgres_data:
|
||||
staticfiles:
|
||||
mediafiles:
|
||||
postgres_data: # Для данных БД
|
||||
staticfiles: # Для CSS, JS и картинок интерфейса (collectstatic)
|
||||
mediafiles: # Для загруженных тобой чертежей и фото
|
||||
@@ -1,8 +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 "$@"
|
||||
50
exempl/manufacturing/models.py
Normal file
50
exempl/manufacturing/models.py
Normal 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)"
|
||||
90
exempl/shiftflow/models.py
Normal file
90
exempl/shiftflow/models.py
Normal 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
104
exempl/warehouse/models.py
Normal 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
74
main copy.md
Normal file
@@ -0,0 +1,74 @@
|
||||
# AI_RULES — правила работы ассистента в проекте MES_Core
|
||||
Роль: Ты Senior Django Backend Developer.
|
||||
|
||||
Контекст: Разрабатывается MES/ERP система для металлообрабатывающего завода. Архитектура БД разделена на 3 приложения: warehouse, manufacturing, shiftflow.
|
||||
|
||||
Задача: Разработать слой бизнес-логики (сервисы и CBV Views) для реализации сквозного процесса производства.
|
||||
|
||||
# AI_RULES — правила работы ассистента в проекте MES_Core
|
||||
|
||||
## 1) Коммуникация
|
||||
- Пиши по-русски (если пользователь пишет по-русски).
|
||||
- Не используй формулировки вида «по твоей просьбе», «добавил для тебя», «как договаривались» в комментариях к коду.
|
||||
- Если предлагаешь новые файлы — всегда указывай: полное имя, абсолютный путь и весь контент в одном блоке.
|
||||
|
||||
## 2) Изменения в коде
|
||||
- Любые правки существующих файлов показывай через diff-превью (SEARCH/REPLACE).
|
||||
- Не вставляй “просто код” для существующих файлов без diff-превью.
|
||||
- Сначала читай файл и только потом предлагай правки (чтобы не ломать стиль и импорты).
|
||||
- При создании новых файлов заголовок блока должен быть: язык + путь, например: python MES_Core/warehouse/services.py
|
||||
|
||||
## 3) Создание новых файлов
|
||||
- Для новых файлов заголовок блока должен быть: язык + путь, например: python MES_Core/warehouse/services.py
|
||||
|
||||
## 4)Комментарии
|
||||
- В Python/бекенде:
|
||||
- добавляй поясняющие комментарии там, где есть бизнес-логика, транзакции, конкурентность, фоновые задачи, сложные алгоритмы (BOM, списания, начисления).
|
||||
- комментарии должны быть нейтральными и описывать поведение/причину, без личных формулировок.
|
||||
- В HTML-шаблонах Django:
|
||||
- не добавляй template-комментарии {# ... #} .
|
||||
- В остальных местах:
|
||||
- не добавляй комментарии “для красоты”; только там, где они реально помогают поддержке.
|
||||
|
||||
## 5) Стиль и конвенции проекта
|
||||
- Смотри на соседние файлы и придерживайся уже принятого стиля (структура, именование, импорты, форматирование).
|
||||
- Не вводи новые библиотеки/фреймворки, пока не проверил, что они уже используются в проекте.
|
||||
- Для UI-таблиц:
|
||||
- если добавляешь новую таблицу — по умолчанию делай её сортируемой (если не мешает UX).
|
||||
- для колонок с кнопками/прогрессом/иконками отключай сортировку.
|
||||
|
||||
- Использовать Service Layer: сложная логика живет в services.py, вьюхи остаются тонкими.
|
||||
|
||||
- Импорты моделей из других приложений — через строковые ссылки в полях ('app.Model') для избежания циклических импортов.
|
||||
## 6) Безопасность и секреты
|
||||
- Никогда не логируй и не печатай в stdout:
|
||||
- SECRET_KEY
|
||||
- пароли БД
|
||||
- токены
|
||||
- В логи допускаются только технические сообщения, ошибки и диагностические данные без секретов.
|
||||
- В models.py всегда использовать on_delete=models.PROTECT для важных справочников (Металл, Сделки), чтобы нельзя было случайно удалить историю.
|
||||
## 7) Логи и фоновые задачи
|
||||
- Для долгих операций (рендер превью, массовые обновления, BOM explosion для больших заказов):
|
||||
- не блокируй HTTP-ответ
|
||||
- Использовать модуль threading для запуска таких задач в отдельном потоке.
|
||||
|
||||
- Обязательно оборачивать фоновую функцию в try/except и логировать ошибки в БД или файл, так как ошибки в потоках не видны во вьюхе.
|
||||
- Логи фоновых задач должны быть:
|
||||
- с датой/временем
|
||||
- доступны из интерфейса “Обслуживание сервера” (tail)
|
||||
- очищаемы кнопкой (если задача не running)
|
||||
## 8) Транзакции и гонки данных (warehouse/shiftflow)
|
||||
- Все операции списания/начисления на складе делай в transaction.atomic() .
|
||||
- На изменяемые складские остатки используй select_for_update() чтобы избежать гонок.
|
||||
- Для массовых операций избегай N+1:
|
||||
- select_related / prefetch_related
|
||||
- bulk update/create там, где это безопасно.
|
||||
|
||||
|
||||
Правило для новых внутренних функций (как договор):
|
||||
|
||||
- Всегда берём логгер logger = logging.getLogger('mes')
|
||||
- Перед выполнением — logger.info('fn:start ...', ключевые параметры)
|
||||
- После успешного выполнения — logger.info('fn:done ...', ключевые результаты)
|
||||
- На важных шагах — logger.info('fn:step ...', детали)
|
||||
- Исключение — с context: logger.exception('fn:error ...') — не глотаем, пробрасываем дальше
|
||||
0
manufacturing/__init__.py
Normal file
0
manufacturing/__init__.py
Normal file
52
manufacturing/admin.py
Normal file
52
manufacturing/admin.py
Normal 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
7
manufacturing/apps.py
Normal 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)'
|
||||
61
manufacturing/migrations/0001_initial.py
Normal file
61
manufacturing/migrations/0001_initial.py
Normal 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')},
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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='Тип'),
|
||||
),
|
||||
]
|
||||
@@ -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': 'Сварные швы',
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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': 'Паспорта покупного',
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 6.0.3 on 2026-04-07 18:11
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('manufacturing', '0004_castingpassport_outsourcedpassport_partpassport_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='assemblypassport',
|
||||
name='requires_painting',
|
||||
field=models.BooleanField(default=False, verbose_name='Требуется покраска'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='assemblypassport',
|
||||
name='requires_welding',
|
||||
field=models.BooleanField(default=False, verbose_name='Требуется сварка'),
|
||||
),
|
||||
]
|
||||
43
manufacturing/migrations/0006_operation_entityoperation.py
Normal file
43
manufacturing/migrations/0006_operation_entityoperation.py
Normal file
@@ -0,0 +1,43 @@
|
||||
# Generated by Django 6.0.3 on 2026-04-08 18:00
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('manufacturing', '0005_assemblypassport_requires_painting_and_more'),
|
||||
('shiftflow', '0022_employeeprofile_allowed_workshops_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Operation',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=200, unique=True, verbose_name='Операция')),
|
||||
('code', models.SlugField(help_text='Стабильный идентификатор (например welding, painting, laser_cutting).', max_length=64, unique=True, verbose_name='Код')),
|
||||
('workshop', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='shiftflow.workshop', verbose_name='Цех по умолчанию')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Операция',
|
||||
'verbose_name_plural': 'Операции',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='EntityOperation',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('seq', models.PositiveSmallIntegerField(default=1, verbose_name='Порядок')),
|
||||
('entity', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='operations', to='manufacturing.productentity', verbose_name='Сущность')),
|
||||
('operation', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='manufacturing.operation', verbose_name='Операция')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Операция сущности',
|
||||
'verbose_name_plural': 'Операции сущностей',
|
||||
'ordering': ('entity', 'seq', 'id'),
|
||||
'unique_together': {('entity', 'seq')},
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,20 @@
|
||||
# Generated by Django 6.0.3 on 2026-04-08 18:37
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('manufacturing', '0006_operation_entityoperation'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='productentity',
|
||||
name='route',
|
||||
),
|
||||
migrations.DeleteModel(
|
||||
name='RouteStub',
|
||||
),
|
||||
]
|
||||
0
manufacturing/migrations/__init__.py
Normal file
0
manufacturing/migrations/__init__.py
Normal file
225
manufacturing/models.py
Normal file
225
manufacturing/models.py
Normal 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
3
manufacturing/tests.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
3
manufacturing/views.py
Normal file
3
manufacturing/views.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.shortcuts import render
|
||||
|
||||
# Create your views here.
|
||||
@@ -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;
|
||||
|
||||
BIN
requirements.txt
BIN
requirements.txt
Binary file not shown.
BIN
requirements.txtсЫВпывмЫВ
Normal file
BIN
requirements.txtсЫВпывмЫВ
Normal file
Binary file not shown.
@@ -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
106
shiftflow/authz.py
Normal file
@@ -0,0 +1,106 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Iterable
|
||||
|
||||
|
||||
ROLE_PRIORITY = [
|
||||
'admin',
|
||||
'prod_head',
|
||||
'technologist',
|
||||
'master',
|
||||
'clerk',
|
||||
'supply',
|
||||
'manager',
|
||||
'operator',
|
||||
'observer',
|
||||
'director',
|
||||
]
|
||||
|
||||
|
||||
def get_user_group_roles(user) -> set[str]:
|
||||
"""Возвращает роли пользователя только из Django Groups.
|
||||
|
||||
Используется для экранов, где включён строгий доступ "только по группам".
|
||||
EmployeeProfile.role здесь намеренно не учитывается.
|
||||
|
||||
Правило: superuser получает роль admin.
|
||||
"""
|
||||
|
||||
roles: set[str] = set()
|
||||
if not user or not getattr(user, 'is_authenticated', False):
|
||||
return roles
|
||||
|
||||
if getattr(user, 'is_superuser', False):
|
||||
roles.add('admin')
|
||||
|
||||
try:
|
||||
roles |= set(user.groups.values_list('name', flat=True))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return roles
|
||||
|
||||
|
||||
def get_user_roles(user) -> set[str]:
|
||||
"""Возвращает множество ролей пользователя.
|
||||
|
||||
Источник ролей (вариант A, плавная миграция):
|
||||
- Django Groups: позволяет назначать несколько ролей одному пользователю.
|
||||
- Fallback на EmployeeProfile.role: чтобы при деплое и до раздачи групп система
|
||||
продолжала работать по старой модели (одна роль).
|
||||
|
||||
Правило: superuser всегда получает роль admin независимо от групп/профиля.
|
||||
"""
|
||||
|
||||
roles: set[str] = set() # Изначально множество пустое
|
||||
# 1. Проверяем, что пользователь авторизован
|
||||
if not user or not getattr(user, 'is_authenticated', False):
|
||||
return roles
|
||||
# 2. Проверяем, что пользователь не superuser
|
||||
if getattr(user, 'is_superuser', False):
|
||||
roles.add('admin')
|
||||
# 3. Проверяем, что у пользователя есть хотя бы одна группа
|
||||
try:
|
||||
roles |= set(user.groups.values_list('name', flat=True))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# 4. Проверяем, что у пользователя есть роль в EmployeeProfile
|
||||
profile = getattr(user, 'profile', None)
|
||||
if profile and getattr(profile, 'role', None):
|
||||
roles.add(str(profile.role))
|
||||
|
||||
return roles
|
||||
|
||||
|
||||
def primary_role(roles: Iterable[str]) -> str:
|
||||
"""Выбирает "основную" роль для отображения в UI.
|
||||
|
||||
Примечание: права доступа должны проверяться по всем ролям (has_any_role).
|
||||
primary_role используется только для:
|
||||
- подписи/лейбла "роль пользователя" в шаблонах
|
||||
- дефолтного поведения, где требуется один статус (например, оформление UI)
|
||||
"""
|
||||
|
||||
s = set(roles or [])
|
||||
for r in ROLE_PRIORITY:
|
||||
if r in s:
|
||||
return r
|
||||
return 'operator'
|
||||
|
||||
|
||||
def has_any_role(roles: Iterable[str], required: Iterable[str]) -> bool:
|
||||
"""Проверяет, что у пользователя есть хотя бы одна роль из required.
|
||||
|
||||
Используется во вьюхах для разрешений вида:
|
||||
roles = get_user_roles(request.user)
|
||||
if not has_any_role(roles, ['admin', 'master']):
|
||||
return redirect('registry')
|
||||
"""
|
||||
|
||||
s = set(roles or [])
|
||||
for r in required or []:
|
||||
if r in s:
|
||||
return True
|
||||
return False
|
||||
19
shiftflow/context_processors.py
Normal file
19
shiftflow/context_processors.py
Normal 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
51
shiftflow/forms.py
Normal 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'
|
||||
0
shiftflow/management/__init__.py
Normal file
0
shiftflow/management/__init__.py
Normal file
0
shiftflow/management/commands/__init__.py
Normal file
0
shiftflow/management/commands/__init__.py
Normal file
177
shiftflow/management/commands/dxf_preview_job.py
Normal file
177
shiftflow/management/commands/dxf_preview_job.py
Normal 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()
|
||||
29
shiftflow/management/commands/shiftflow_explode_deal.py
Normal file
29
shiftflow/management/commands/shiftflow_explode_deal.py
Normal 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-деталях.')
|
||||
@@ -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='Материал'),
|
||||
),
|
||||
]
|
||||
29
shiftflow/migrations/0003_employeeprofile.py
Normal file
29
shiftflow/migrations/0003_employeeprofile.py
Normal 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': 'Профили сотрудников',
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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='Задание'),
|
||||
),
|
||||
]
|
||||
@@ -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',
|
||||
),
|
||||
]
|
||||
@@ -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='Статус'),
|
||||
),
|
||||
]
|
||||
18
shiftflow/migrations/0007_machine_machine_type.py
Normal file
18
shiftflow/migrations/0007_machine_machine_type.py
Normal 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='Тип станка'),
|
||||
),
|
||||
]
|
||||
19
shiftflow/migrations/0008_alter_item_date.py
Normal file
19
shiftflow/migrations/0008_alter_item_date.py
Normal 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='Дата смены'),
|
||||
),
|
||||
]
|
||||
18
shiftflow/migrations/0009_deal_status.py
Normal file
18
shiftflow/migrations/0009_deal_status.py
Normal 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='Статус'),
|
||||
),
|
||||
]
|
||||
18
shiftflow/migrations/0010_productiontask_preview_image.py
Normal file
18
shiftflow/migrations/0010_productiontask_preview_image.py
Normal 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)'),
|
||||
),
|
||||
]
|
||||
18
shiftflow/migrations/0011_productiontask_blank_dimensions.py
Normal file
18
shiftflow/migrations/0011_productiontask_blank_dimensions.py
Normal 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='Габариты заготовки'),
|
||||
),
|
||||
]
|
||||
28
shiftflow/migrations/0012_dxfpreviewsettings.py
Normal file
28
shiftflow/migrations/0012_dxfpreviewsettings.py
Normal 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',
|
||||
},
|
||||
),
|
||||
]
|
||||
38
shiftflow/migrations/0013_dxfpreviewjob.py
Normal file
38
shiftflow/migrations/0013_dxfpreviewjob.py
Normal 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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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='Статус'),
|
||||
),
|
||||
]
|
||||
@@ -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')},
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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')},
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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='Цех'),
|
||||
),
|
||||
]
|
||||
@@ -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')},
|
||||
),
|
||||
]
|
||||
18
shiftflow/migrations/0019_alter_employeeprofile_role.py
Normal file
18
shiftflow/migrations/0019_alter_employeeprofile_role.py
Normal 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='Должность'),
|
||||
),
|
||||
]
|
||||
18
shiftflow/migrations/0020_dealitem_due_date.py
Normal file
18
shiftflow/migrations/0020_dealitem_due_date.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 6.0.3 on 2026-04-08 03:44
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('shiftflow', '0019_alter_employeeprofile_role'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='dealitem',
|
||||
name='due_date',
|
||||
field=models.DateField(blank=True, null=True, verbose_name='Плановая отгрузка'),
|
||||
),
|
||||
]
|
||||
35
shiftflow/migrations/0021_workitem.py
Normal file
35
shiftflow/migrations/0021_workitem.py
Normal file
@@ -0,0 +1,35 @@
|
||||
# Generated by Django 6.0.3 on 2026-04-08 03:54
|
||||
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('manufacturing', '0005_assemblypassport_requires_painting_and_more'),
|
||||
('shiftflow', '0020_dealitem_due_date'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='WorkItem',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('stage', models.CharField(choices=[('cutting', 'Резка'), ('welding', 'Сварка'), ('painting', 'Покраска')], max_length=16, verbose_name='Стадия')),
|
||||
('quantity_plan', models.PositiveIntegerField(default=0, verbose_name='В план, шт')),
|
||||
('quantity_done', models.PositiveIntegerField(default=0, verbose_name='Сделано, шт')),
|
||||
('status', models.CharField(default='planned', max_length=16, verbose_name='Статус')),
|
||||
('date', models.DateField(default=django.utils.timezone.localdate, verbose_name='Дата')),
|
||||
('deal', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='shiftflow.deal', verbose_name='Сделка')),
|
||||
('entity', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='manufacturing.productentity', verbose_name='Сущность')),
|
||||
('machine', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='shiftflow.machine', verbose_name='Станок/участок')),
|
||||
('workshop', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='shiftflow.workshop', verbose_name='Цех')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'План работ',
|
||||
'verbose_name_plural': 'План работ',
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,28 @@
|
||||
# Generated by Django 6.0.3 on 2026-04-08 16:55
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('shiftflow', '0021_workitem'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='employeeprofile',
|
||||
name='allowed_workshops',
|
||||
field=models.ManyToManyField(blank=True, to='shiftflow.workshop', verbose_name='Доступные цеха'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='employeeprofile',
|
||||
name='is_readonly',
|
||||
field=models.BooleanField(default=False, verbose_name='Только просмотр'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='employeeprofile',
|
||||
name='role',
|
||||
field=models.CharField(choices=[('admin', 'Администратор'), ('technologist', 'Технолог'), ('master', 'Мастер'), ('operator', 'Оператор'), ('clerk', 'Учетчик'), ('observer', 'Наблюдатель'), ('manager', 'Руководитель')], default='operator', max_length=20, verbose_name='Должность'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,39 @@
|
||||
# Generated by Django 6.0.3 on 2026-04-08 18:00
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('manufacturing', '0006_operation_entityoperation'),
|
||||
('shiftflow', '0022_employeeprofile_allowed_workshops_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='workitem',
|
||||
name='operation',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='manufacturing.operation', verbose_name='Операция'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='workitem',
|
||||
name='stage',
|
||||
field=models.CharField(blank=True, default='', max_length=32, verbose_name='Стадия'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='DealEntityProgress',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('current_seq', models.PositiveSmallIntegerField(default=1, verbose_name='Текущая операция (порядок)')),
|
||||
('deal', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='shiftflow.deal', verbose_name='Сделка')),
|
||||
('entity', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='manufacturing.productentity', verbose_name='Сущность')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Прогресс по операции',
|
||||
'verbose_name_plural': 'Прогресс по операциям',
|
||||
'unique_together': {('deal', 'entity')},
|
||||
},
|
||||
),
|
||||
]
|
||||
45
shiftflow/migrations/0024_dealdeliverybatch_dealbatchitem.py
Normal file
45
shiftflow/migrations/0024_dealdeliverybatch_dealbatchitem.py
Normal file
@@ -0,0 +1,45 @@
|
||||
# Generated by Django 6.0.3 on 2026-04-08 21:33
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('manufacturing', '0007_remove_productentity_route_delete_routestub'),
|
||||
('shiftflow', '0023_workitem_operation_alter_workitem_stage_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='DealDeliveryBatch',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(blank=True, default='', max_length=120, verbose_name='Название')),
|
||||
('due_date', models.DateField(verbose_name='Плановая отгрузка')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Создано')),
|
||||
('deal', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='delivery_batches', to='shiftflow.deal', verbose_name='Сделка')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Партия поставки',
|
||||
'verbose_name_plural': 'Партии поставки',
|
||||
'ordering': ('deal', 'due_date', 'id'),
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='DealBatchItem',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('quantity', models.PositiveIntegerField(verbose_name='Количество, шт')),
|
||||
('entity', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='manufacturing.productentity', verbose_name='Изделие/деталь')),
|
||||
('batch', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='shiftflow.dealdeliverybatch', verbose_name='Партия')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Строка партии',
|
||||
'verbose_name_plural': 'Строки партий',
|
||||
'ordering': ('batch', 'entity__entity_type', 'entity__drawing_number', 'entity__name', 'id'),
|
||||
'unique_together': {('batch', 'entity')},
|
||||
},
|
||||
),
|
||||
]
|
||||
18
shiftflow/migrations/0025_dealbatchitem_started_qty.py
Normal file
18
shiftflow/migrations/0025_dealbatchitem_started_qty.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 6.0.3 on 2026-04-09 04:16
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('shiftflow', '0024_dealdeliverybatch_dealbatchitem'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='dealbatchitem',
|
||||
name='started_qty',
|
||||
field=models.PositiveIntegerField(default=0, verbose_name='Запущено в производство, шт'),
|
||||
),
|
||||
]
|
||||
18
shiftflow/migrations/0026_dealdeliverybatch_is_default.py
Normal file
18
shiftflow/migrations/0026_dealdeliverybatch_is_default.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 6.0.3 on 2026-04-09 10:22
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('shiftflow', '0025_dealbatchitem_started_qty'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='dealdeliverybatch',
|
||||
name='is_default',
|
||||
field=models.BooleanField(default=False, verbose_name='Дефолтная партия (остаток)'),
|
||||
),
|
||||
]
|
||||
17
shiftflow/migrations/0027_remove_dealitem_due_date.py
Normal file
17
shiftflow/migrations/0027_remove_dealitem_due_date.py
Normal file
@@ -0,0 +1,17 @@
|
||||
# Generated by Django 6.0.3 on 2026-04-09 10:28
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('shiftflow', '0026_dealdeliverybatch_is_default'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='dealitem',
|
||||
name='due_date',
|
||||
),
|
||||
]
|
||||
20
shiftflow/migrations/0028_alter_productiontask_material.py
Normal file
20
shiftflow/migrations/0028_alter_productiontask_material.py
Normal file
@@ -0,0 +1,20 @@
|
||||
# Generated by Django 6.0.3 on 2026-04-09 11:38
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('shiftflow', '0027_remove_dealitem_due_date'),
|
||||
('warehouse', '0014_material_mass_per_unit'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='productiontask',
|
||||
name='material',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='warehouse.material', verbose_name='Материал'),
|
||||
),
|
||||
]
|
||||
18
shiftflow/migrations/0029_deal_due_date.py
Normal file
18
shiftflow/migrations/0029_deal_due_date.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 6.0.3 on 2026-04-09 12:20
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('shiftflow', '0028_alter_productiontask_material'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='deal',
|
||||
name='due_date',
|
||||
field=models.DateField(blank=True, null=True, verbose_name='Срок отгрузки'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,28 @@
|
||||
# Generated by Django 6.0.3 on 2026-04-11 05:25
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('shiftflow', '0029_deal_due_date'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='workitem',
|
||||
name='comment',
|
||||
field=models.TextField(blank=True, default='', verbose_name='Комментарий'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='item',
|
||||
name='status',
|
||||
field=models.CharField(choices=[('work', 'В работе'), ('done', 'Выполнено'), ('partial', 'Частично'), ('leftover', 'Недодел')], default='work', max_length=10, verbose_name='Статус'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='workitem',
|
||||
name='status',
|
||||
field=models.CharField(choices=[('planned', 'В работе'), ('leftover', 'Недодел'), ('done', 'Закрыта')], default='planned', max_length=16, verbose_name='Статус'),
|
||||
),
|
||||
]
|
||||
30
shiftflow/migrations/0031_procurementrequirement.py
Normal file
30
shiftflow/migrations/0031_procurementrequirement.py
Normal file
@@ -0,0 +1,30 @@
|
||||
# Generated by Django 6.0.3 on 2026-04-11 20:08
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('manufacturing', '0007_remove_productentity_route_delete_routestub'),
|
||||
('shiftflow', '0030_workitem_comment_alter_item_status_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ProcurementRequirement',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('required_qty', models.FloatField(verbose_name='Потребность (к закупке)')),
|
||||
('status', models.CharField(choices=[('to_order', 'К заказу'), ('ordered', 'Заказано'), ('closed', 'Закрыто')], default='to_order', max_length=20, verbose_name='Статус')),
|
||||
('component', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='manufacturing.productentity', verbose_name='Компонент (покупное/литье)')),
|
||||
('deal', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='shiftflow.deal', verbose_name='Сделка')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Потребность снабжения',
|
||||
'verbose_name_plural': 'Потребности снабжения',
|
||||
'unique_together': {('deal', 'component')},
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 6.0.3 on 2026-04-11 20:41
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('shiftflow', '0031_procurementrequirement'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='procurementrequirement',
|
||||
name='required_qty',
|
||||
field=models.PositiveIntegerField(verbose_name='Потребность (к закупке), шт'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,31 @@
|
||||
# Generated by Django 6.0.3 on 2026-04-12 09:22
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('shiftflow', '0032_alter_procurementrequirement_required_qty'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='cuttingsession',
|
||||
name='is_synced_1c',
|
||||
field=models.BooleanField(default=False, verbose_name='Выгружено в 1С'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='cuttingsession',
|
||||
name='synced_1c_at',
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name='Выгружено в 1С (время)'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='cuttingsession',
|
||||
name='synced_1c_by',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='synced_cutting_sessions', to=settings.AUTH_USER_MODEL, verbose_name='Выгрузил в 1С'),
|
||||
),
|
||||
]
|
||||
@@ -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
171
shiftflow/popup_views.py
Normal 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
|
||||
14
shiftflow/services/__init__.py
Normal file
14
shiftflow/services/__init__.py
Normal file
@@ -0,0 +1,14 @@
|
||||
"""
|
||||
Сервисный слой приложения shiftflow.
|
||||
|
||||
Здесь живёт бизнес-логика, которую можно вызывать из:
|
||||
- view (HTTP)
|
||||
- admin
|
||||
- management commands
|
||||
- фоновых воркеров
|
||||
|
||||
Принцип:
|
||||
- сервисы не зависят от шаблонов/HTML,
|
||||
- сервисы работают с ORM и транзакциями,
|
||||
- сервисы содержат правила заводской логики (MES/ERP).
|
||||
"""
|
||||
202
shiftflow/services/assembly_closing.py
Normal file
202
shiftflow/services/assembly_closing.py
Normal file
@@ -0,0 +1,202 @@
|
||||
import logging
|
||||
from django.db import transaction
|
||||
from django.db.models import Q, Case, When, Value, IntegerField
|
||||
from django.utils import timezone
|
||||
from warehouse.models import StockItem
|
||||
from shiftflow.models import WorkItem, CuttingSession, ProductionReportConsumption, ProductionReportStockResult
|
||||
from shiftflow.services.bom_explosion import _build_bom_graph
|
||||
from shiftflow.services.kitting import get_work_location_for_workitem
|
||||
from manufacturing.models import EntityOperation
|
||||
|
||||
|
||||
def get_first_operation_id(entity_id: int) -> int | None:
|
||||
op_id = (
|
||||
EntityOperation.objects.filter(entity_id=int(entity_id))
|
||||
.order_by('seq', 'id')
|
||||
.values_list('operation_id', flat=True)
|
||||
.first()
|
||||
)
|
||||
return int(op_id) if op_id else None
|
||||
|
||||
logger = logging.getLogger('mes')
|
||||
|
||||
def get_assembly_closing_info(workitem: WorkItem) -> dict:
|
||||
"""
|
||||
Возвращает информацию о том, сколько сборок можно выпустить и
|
||||
какие компоненты для этого нужны.
|
||||
"""
|
||||
first_op_id = get_first_operation_id(int(workitem.entity_id))
|
||||
if first_op_id and getattr(workitem, 'operation_id', None) and int(workitem.operation_id) != int(first_op_id):
|
||||
return {'error': 'Списание комплектации выполняется только на первой операции техпроцесса. Для этой операции закрывай только факт выполнения.', 'is_first_operation': False}
|
||||
|
||||
to_location = get_work_location_for_workitem(workitem)
|
||||
if not to_location:
|
||||
return {'error': 'Не определён склад участка для этого задания.'}
|
||||
|
||||
# Считаем BOM 1-го уровня
|
||||
adjacency = _build_bom_graph({workitem.entity_id})
|
||||
children = adjacency.get(workitem.entity_id) or []
|
||||
|
||||
if not children:
|
||||
return {'error': 'Спецификация пуста. Нечего списывать.', 'to_location': to_location}
|
||||
|
||||
bom_req = {} # entity_id -> qty_per_1
|
||||
for child_id, qty in children:
|
||||
bom_req[child_id] = bom_req.get(child_id, 0) + qty
|
||||
|
||||
component_ids = list(bom_req.keys())
|
||||
stocks = StockItem.objects.filter(
|
||||
location=to_location,
|
||||
entity_id__in=component_ids,
|
||||
is_archived=False,
|
||||
quantity__gt=0
|
||||
).filter(Q(deal_id=workitem.deal_id) | Q(deal_id__isnull=True))
|
||||
|
||||
stock_by_entity = {}
|
||||
for s in stocks:
|
||||
stock_by_entity[s.entity_id] = stock_by_entity.get(s.entity_id, 0) + s.quantity
|
||||
|
||||
max_possible = float('inf')
|
||||
components_info = []
|
||||
|
||||
from manufacturing.models import ProductEntity
|
||||
entities = {e.id: e for e in ProductEntity.objects.filter(id__in=component_ids)}
|
||||
|
||||
for eid, req_qty in bom_req.items():
|
||||
avail = float(stock_by_entity.get(eid, 0))
|
||||
can_make = int(avail // float(req_qty)) if req_qty > 0 else 0
|
||||
if can_make < max_possible:
|
||||
max_possible = can_make
|
||||
|
||||
components_info.append({
|
||||
'entity': entities.get(eid),
|
||||
'req_per_1': float(req_qty),
|
||||
'available': avail,
|
||||
'max_possible': can_make
|
||||
})
|
||||
|
||||
if max_possible == float('inf'):
|
||||
max_possible = 0
|
||||
|
||||
components_info.sort(key=lambda x: (str(x['entity'].entity_type or ''), str(x['entity'].name or '')) if x['entity'] else ('', ''))
|
||||
|
||||
# Ограничиваем max_possible тем, что реально осталось собрать по заданию
|
||||
remaining = max(0, (workitem.quantity_plan or 0) - (workitem.quantity_done or 0))
|
||||
if max_possible > remaining:
|
||||
max_possible = remaining
|
||||
|
||||
return {
|
||||
'to_location': to_location,
|
||||
'max_possible': int(max_possible),
|
||||
'components': components_info,
|
||||
'error': None,
|
||||
'is_first_operation': True,
|
||||
}
|
||||
|
||||
@transaction.atomic
|
||||
def apply_assembly_closing(workitem_id: int, fact_qty: int, user_id: int) -> bool:
|
||||
logger.info('assembly_closing:start workitem_id=%s qty=%s user_id=%s', workitem_id, fact_qty, user_id)
|
||||
workitem = WorkItem.objects.select_for_update(of=('self',)).get(id=int(workitem_id))
|
||||
|
||||
first_op_id = get_first_operation_id(int(workitem.entity_id))
|
||||
if first_op_id and getattr(workitem, 'operation_id', None) and int(workitem.operation_id) != int(first_op_id):
|
||||
raise ValueError('Списание комплектации выполняется только на первой операции техпроцесса.')
|
||||
if fact_qty <= 0:
|
||||
raise ValueError('Количество должно быть больше 0.')
|
||||
|
||||
info = get_assembly_closing_info(workitem)
|
||||
if info.get('error'):
|
||||
raise ValueError(info['error'])
|
||||
|
||||
if fact_qty > info['max_possible']:
|
||||
raise ValueError(f'Недостаточно компонентов на участке. Максимум можно собрать: {info["max_possible"]} шт.')
|
||||
|
||||
to_location = info['to_location']
|
||||
|
||||
if not getattr(workitem, 'machine_id', None):
|
||||
raise ValueError('Для закрытия сборки требуется выбрать пост (станок) в сменном задании.')
|
||||
|
||||
report = CuttingSession.objects.create(
|
||||
operator_id=int(user_id),
|
||||
machine_id=int(workitem.machine_id),
|
||||
used_stock_item=None,
|
||||
date=timezone.localdate(),
|
||||
is_closed=True,
|
||||
)
|
||||
logger.info('assembly_closing:report_created id=%s', report.id)
|
||||
|
||||
# Списываем компоненты 1-го уровня
|
||||
adjacency = _build_bom_graph({workitem.entity_id})
|
||||
children = adjacency.get(workitem.entity_id) or []
|
||||
bom_req = {}
|
||||
for child_id, qty in children:
|
||||
bom_req[child_id] = bom_req.get(child_id, 0) + qty
|
||||
|
||||
for eid, req_qty in bom_req.items():
|
||||
total_needed = float(req_qty * fact_qty)
|
||||
|
||||
# Приоритет "сделка", потом "свободные", FIFO
|
||||
qs = StockItem.objects.select_for_update().filter(
|
||||
location=to_location,
|
||||
entity_id=eid,
|
||||
is_archived=False,
|
||||
quantity__gt=0
|
||||
).filter(Q(deal_id=workitem.deal_id) | Q(deal_id__isnull=True)).annotate(
|
||||
prio=Case(
|
||||
When(deal_id=workitem.deal_id, then=Value(0)),
|
||||
default=Value(1),
|
||||
output_field=IntegerField(),
|
||||
)
|
||||
).order_by('prio', 'created_at', 'id')
|
||||
|
||||
rem = total_needed
|
||||
for si in qs:
|
||||
if rem <= 0:
|
||||
break
|
||||
take = min(rem, float(si.quantity))
|
||||
|
||||
ProductionReportConsumption.objects.create(
|
||||
report=report,
|
||||
material=None,
|
||||
stock_item=si,
|
||||
quantity=float(take),
|
||||
)
|
||||
|
||||
si.quantity = float(si.quantity) - take
|
||||
if si.quantity <= 0.0001:
|
||||
si.quantity = 0
|
||||
si.is_archived = True
|
||||
si.archived_at = timezone.now()
|
||||
si.save(update_fields=['quantity', 'is_archived', 'archived_at'])
|
||||
rem -= take
|
||||
|
||||
if rem > 0.0001:
|
||||
raise ValueError(f'Непредвиденная нехватка компонента ID {eid} при списании. Нужно еще: {rem}')
|
||||
|
||||
# Выпуск готовой сборки
|
||||
produced = StockItem.objects.create(
|
||||
entity_id=workitem.entity_id,
|
||||
deal_id=workitem.deal_id,
|
||||
location=to_location,
|
||||
quantity=float(fact_qty),
|
||||
is_customer_supplied=False,
|
||||
)
|
||||
ProductionReportStockResult.objects.create(report=report, stock_item=produced, kind='finished')
|
||||
|
||||
# Двигаем техпроцесс
|
||||
workitem.quantity_done = (workitem.quantity_done or 0) + fact_qty
|
||||
if workitem.quantity_done >= workitem.quantity_plan:
|
||||
workitem.status = 'done'
|
||||
workitem.save(update_fields=['quantity_done', 'status'])
|
||||
|
||||
logger.info(
|
||||
'assembly_closing:done workitem_id=%s qty=%s deal_id=%s location_id=%s user_id=%s report_id=%s',
|
||||
workitem.id,
|
||||
fact_qty,
|
||||
workitem.deal_id,
|
||||
to_location.id,
|
||||
user_id,
|
||||
report.id,
|
||||
)
|
||||
|
||||
return True
|
||||
513
shiftflow/services/bom_explosion.py
Normal file
513
shiftflow/services/bom_explosion.py
Normal 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)
|
||||
222
shiftflow/services/closing.py
Normal file
222
shiftflow/services/closing.py
Normal 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)
|
||||
268
shiftflow/services/kitting.py
Normal file
268
shiftflow/services/kitting.py
Normal file
@@ -0,0 +1,268 @@
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from django.db import transaction
|
||||
from django.db.models import Case, IntegerField, Q, Value, When
|
||||
from django.utils import timezone
|
||||
|
||||
from manufacturing.models import ProductEntity
|
||||
from warehouse.models import Location, StockItem, TransferLine, TransferRecord
|
||||
from warehouse.services.transfers import receive_transfer
|
||||
|
||||
from shiftflow.services.bom_explosion import _accumulate_requirements, _build_bom_graph
|
||||
|
||||
logger = logging.getLogger('mes')
|
||||
|
||||
|
||||
def _session_key(workitem_id: int) -> str:
|
||||
return f'kitting_draft_workitem_{int(workitem_id)}'
|
||||
|
||||
|
||||
def get_kitting_draft(session: Any, workitem_id: int) -> list[dict]:
|
||||
key = _session_key(workitem_id)
|
||||
raw = session.get(key)
|
||||
if isinstance(raw, list):
|
||||
out = []
|
||||
for x in raw:
|
||||
if not isinstance(x, dict):
|
||||
continue
|
||||
out.append({
|
||||
'entity_id': int(x.get('entity_id') or 0),
|
||||
'from_location_id': int(x.get('from_location_id') or 0),
|
||||
'quantity': int(x.get('quantity') or 0),
|
||||
})
|
||||
return out
|
||||
return []
|
||||
|
||||
|
||||
def clear_kitting_draft(session: Any, workitem_id: int) -> None:
|
||||
key = _session_key(workitem_id)
|
||||
if key in session:
|
||||
del session[key]
|
||||
session.modified = True
|
||||
|
||||
|
||||
def add_kitting_line(session: Any, workitem_id: int, entity_id: int, from_location_id: int, quantity: int) -> None:
|
||||
workitem_id = int(workitem_id)
|
||||
entity_id = int(entity_id)
|
||||
from_location_id = int(from_location_id)
|
||||
quantity = int(quantity)
|
||||
|
||||
if workitem_id <= 0 or entity_id <= 0 or from_location_id <= 0 or quantity <= 0:
|
||||
return
|
||||
|
||||
key = _session_key(workitem_id)
|
||||
draft = get_kitting_draft(session, workitem_id)
|
||||
|
||||
merged = False
|
||||
for ln in draft:
|
||||
if int(ln.get('entity_id') or 0) == entity_id and int(ln.get('from_location_id') or 0) == from_location_id:
|
||||
ln['quantity'] = int(ln.get('quantity') or 0) + quantity
|
||||
merged = True
|
||||
break
|
||||
|
||||
if not merged:
|
||||
draft.append({'entity_id': entity_id, 'from_location_id': from_location_id, 'quantity': quantity})
|
||||
|
||||
session[key] = draft
|
||||
session.modified = True
|
||||
|
||||
|
||||
def remove_kitting_line(session: Any, workitem_id: int, entity_id: int, from_location_id: int, quantity: int) -> None:
|
||||
workitem_id = int(workitem_id)
|
||||
entity_id = int(entity_id)
|
||||
from_location_id = int(from_location_id)
|
||||
quantity = int(quantity)
|
||||
|
||||
if workitem_id <= 0 or entity_id <= 0 or from_location_id <= 0 or quantity <= 0:
|
||||
return
|
||||
|
||||
key = _session_key(workitem_id)
|
||||
draft = get_kitting_draft(session, workitem_id)
|
||||
|
||||
out = []
|
||||
for ln in draft:
|
||||
if int(ln.get('entity_id') or 0) == entity_id and int(ln.get('from_location_id') or 0) == from_location_id:
|
||||
cur = int(ln.get('quantity') or 0)
|
||||
cur = max(0, cur - quantity)
|
||||
if cur > 0:
|
||||
ln['quantity'] = cur
|
||||
out.append(ln)
|
||||
continue
|
||||
out.append(ln)
|
||||
|
||||
session[key] = out
|
||||
session.modified = True
|
||||
|
||||
|
||||
def get_work_location_for_workitem(workitem) -> Location | None:
|
||||
m = getattr(workitem, 'machine', None)
|
||||
if m and getattr(m, 'workshop_id', None) and getattr(getattr(m, 'workshop', None), 'location_id', None):
|
||||
return m.workshop.location
|
||||
if m and getattr(m, 'location_id', None):
|
||||
return m.location
|
||||
w = getattr(workitem, 'workshop', None)
|
||||
if w and getattr(w, 'location_id', None):
|
||||
return w.location
|
||||
return None
|
||||
|
||||
|
||||
def build_kitting_requirements(root_entity_id: int, qty_to_make: int) -> dict[int, int]:
|
||||
"""Потребность на комплектацию для сборки/изделия.
|
||||
|
||||
Комментарий: для ручной комплектации мастеру важны прямые компоненты (1 уровень BOM),
|
||||
включая подсборки. Глубину дерева раскрываем отдельными заданиями на подсборки.
|
||||
"""
|
||||
root_entity_id = int(root_entity_id)
|
||||
qty_to_make = int(qty_to_make or 0)
|
||||
if root_entity_id <= 0 or qty_to_make <= 0:
|
||||
return {}
|
||||
|
||||
adjacency = _build_bom_graph({root_entity_id})
|
||||
children = adjacency.get(root_entity_id) or []
|
||||
|
||||
out: dict[int, int] = {}
|
||||
for child_id, per1 in children:
|
||||
cid = int(child_id)
|
||||
need = int(per1 or 0) * qty_to_make
|
||||
if cid <= 0 or need <= 0:
|
||||
continue
|
||||
out[cid] = int(out.get(cid, 0) or 0) + need
|
||||
|
||||
return out
|
||||
|
||||
|
||||
def build_kitting_leaf_requirements(root_entity_id: int, qty_to_make: int) -> dict[int, int]:
|
||||
"""Совместимость: старое имя оставлено, сейчас возвращает потребность 1-го уровня BOM."""
|
||||
return build_kitting_requirements(root_entity_id, qty_to_make)
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def _apply_one_transfer(
|
||||
*,
|
||||
deal_id: int,
|
||||
component_entity_id: int,
|
||||
from_location_id: int,
|
||||
to_location_id: int,
|
||||
quantity: int,
|
||||
user_id: int,
|
||||
) -> int:
|
||||
deal_id = int(deal_id)
|
||||
component_entity_id = int(component_entity_id)
|
||||
from_location_id = int(from_location_id)
|
||||
to_location_id = int(to_location_id)
|
||||
quantity = int(quantity)
|
||||
user_id = int(user_id)
|
||||
|
||||
if quantity <= 0:
|
||||
raise RuntimeError('Количество должно быть больше 0.')
|
||||
|
||||
if from_location_id == to_location_id:
|
||||
raise RuntimeError('Склад-источник и склад назначения совпадают.')
|
||||
|
||||
# Комментарий: двигаем только "под сделку" и свободные остатки.
|
||||
# Приоритет: под сделку -> свободные, затем FIFO по поступлению.
|
||||
qs = (
|
||||
StockItem.objects.select_for_update()
|
||||
.filter(is_archived=False, quantity__gt=0)
|
||||
.filter(location_id=from_location_id, entity_id=component_entity_id)
|
||||
.filter(Q(deal_id=deal_id) | Q(deal_id__isnull=True))
|
||||
.annotate(
|
||||
prio=Case(
|
||||
When(deal_id=deal_id, then=Value(0)),
|
||||
default=Value(1),
|
||||
output_field=IntegerField(),
|
||||
)
|
||||
)
|
||||
.order_by('prio', 'created_at', 'id')
|
||||
)
|
||||
|
||||
remaining = float(quantity)
|
||||
picked: list[tuple[int, float]] = []
|
||||
for si in qs:
|
||||
if remaining <= 0:
|
||||
break
|
||||
avail = float(si.quantity or 0)
|
||||
if avail <= 0:
|
||||
continue
|
||||
take = min(remaining, avail)
|
||||
if take <= 0:
|
||||
continue
|
||||
picked.append((int(si.id), float(take)))
|
||||
remaining -= take
|
||||
|
||||
if remaining > 0:
|
||||
# Комментарий: допускаем расхождения с фактом (по данным базы может быть меньше, чем нужно по месту).
|
||||
# Для продолжения процесса создаем "виртуальный" остаток под сделку на складе-источнике и перемещаем его.
|
||||
phantom = StockItem.objects.create(
|
||||
entity_id=int(component_entity_id),
|
||||
deal_id=int(deal_id),
|
||||
location_id=int(from_location_id),
|
||||
quantity=float(remaining),
|
||||
)
|
||||
picked.append((int(phantom.id), float(remaining)))
|
||||
logger.warning(
|
||||
'kitting_transfer: phantom_created deal_id=%s entity_id=%s from_location=%s qty=%s',
|
||||
deal_id,
|
||||
component_entity_id,
|
||||
from_location_id,
|
||||
float(remaining),
|
||||
)
|
||||
remaining = 0.0
|
||||
|
||||
tr = TransferRecord.objects.create(
|
||||
from_location_id=from_location_id,
|
||||
to_location_id=to_location_id,
|
||||
sender_id=user_id,
|
||||
receiver_id=user_id,
|
||||
occurred_at=timezone.now(),
|
||||
status='received',
|
||||
received_at=timezone.now(),
|
||||
is_applied=False,
|
||||
)
|
||||
|
||||
for sid, qty in picked:
|
||||
TransferLine.objects.create(transfer=tr, stock_item_id=sid, quantity=float(qty))
|
||||
|
||||
receive_transfer(tr.id, user_id)
|
||||
logger.info(
|
||||
'kitting_transfer: ok tr_id=%s deal_id=%s component=%s from=%s to=%s qty=%s',
|
||||
tr.id, deal_id, component_entity_id, from_location_id, to_location_id, quantity
|
||||
)
|
||||
return int(tr.id)
|
||||
|
||||
|
||||
def apply_kitting_draft(
|
||||
*,
|
||||
session: Any,
|
||||
workitem_id: int,
|
||||
deal_id: int,
|
||||
to_location_id: int,
|
||||
user_id: int,
|
||||
) -> dict[str, int]:
|
||||
draft = get_kitting_draft(session, int(workitem_id))
|
||||
if not draft:
|
||||
return {'applied': 0, 'errors': 0}
|
||||
|
||||
applied = 0
|
||||
errors = 0
|
||||
|
||||
for ln in draft:
|
||||
try:
|
||||
_apply_one_transfer(
|
||||
deal_id=int(deal_id),
|
||||
component_entity_id=int(ln.get('entity_id') or 0),
|
||||
from_location_id=int(ln.get('from_location_id') or 0),
|
||||
to_location_id=int(to_location_id),
|
||||
quantity=int(ln.get('quantity') or 0),
|
||||
user_id=int(user_id),
|
||||
)
|
||||
applied += 1
|
||||
except Exception:
|
||||
errors += 1
|
||||
logger.exception('kitting_transfer:error deal_id=%s workitem_id=%s line=%s', deal_id, workitem_id, ln)
|
||||
|
||||
if errors == 0:
|
||||
clear_kitting_draft(session, int(workitem_id))
|
||||
|
||||
return {'applied': applied, 'errors': errors}
|
||||
209
shiftflow/services/sessions.py
Normal file
209
shiftflow/services/sessions.py
Normal 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)
|
||||
285
shiftflow/services/shipping.py
Normal file
285
shiftflow/services/shipping.py
Normal 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
|
||||
141
shiftflow/templates/shiftflow/assembly_closing.html
Normal file
141
shiftflow/templates/shiftflow/assembly_closing.html
Normal file
@@ -0,0 +1,141 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-8">
|
||||
<div class="card shadow-sm border-secondary mb-4">
|
||||
<div class="card-header border-secondary d-flex justify-content-between align-items-center py-3">
|
||||
<h3 class="text-accent mb-0">
|
||||
<i class="bi bi-check2-square me-2"></i>Закрытие сборки
|
||||
</h3>
|
||||
<a class="btn btn-outline-secondary btn-sm" href="{% url 'workitem_detail' workitem.id %}">Назад к заданию</a>
|
||||
</div>
|
||||
|
||||
<div class="card-body p-4">
|
||||
<div class="mb-4">
|
||||
<h5 class="fw-bold">{{ workitem.entity.drawing_number|default:"—" }} {{ workitem.entity.name }}</h5>
|
||||
<div class="text-muted small">Сделка № {{ workitem.deal.number }}</div>
|
||||
<div class="text-muted small">План: {{ workitem.quantity_plan }} шт. · Собрано: {{ workitem.quantity_done }} шт.</div>
|
||||
<div class="text-muted small">Осталось собрать: <strong>{{ remaining }}</strong> шт.</div>
|
||||
{% if to_location %}
|
||||
<div class="text-muted small mt-2">Участок сборки (склад): <strong>{{ to_location.name }}</strong></div>
|
||||
{% else %}
|
||||
<div class="text-danger small mt-2 fw-bold">Участок сборки не определен! Закрытие невозможно.</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="text-muted small mt-2">
|
||||
Пост для отчёта:
|
||||
{% if workitem.machine_id %}
|
||||
<strong>{{ workitem.machine.name }}</strong>
|
||||
{% else %}
|
||||
<strong class="text-warning">не выбран</strong>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if error %}
|
||||
<div class="alert alert-warning border-warning">
|
||||
{{ error }}
|
||||
</div>
|
||||
{% else %}
|
||||
<h6 class="fw-bold border-bottom border-secondary pb-2 mb-3">Наличие компонентов на участке</h6>
|
||||
<div class="table-responsive mb-4">
|
||||
<table class="table table-sm table-hover align-middle">
|
||||
<thead class="table-custom-header">
|
||||
<tr>
|
||||
<th>Компонент</th>
|
||||
<th class="text-center">Нужно на 1 шт</th>
|
||||
<th class="text-center">Есть на участке</th>
|
||||
<th class="text-center">Хватит на сборок</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for c in components %}
|
||||
<tr>
|
||||
<td>
|
||||
<div class="fw-bold">{{ c.entity.drawing_number|default:"—" }} {{ c.entity.name }}</div>
|
||||
<div class="small text-muted">{{ c.entity.get_entity_type_display }}</div>
|
||||
</td>
|
||||
<td class="text-center">{{ c.req_per_1 }}</td>
|
||||
<td class="text-center">{{ c.available|floatformat:2 }}</td>
|
||||
<td class="text-center fw-bold {% if c.max_possible == 0 %}text-danger{% else %}text-success{% endif %}">
|
||||
{{ c.max_possible }}
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="4" class="text-center text-muted">Спецификация пуста или не найдена.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-info border-info d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<strong>Максимум можно закрыть сейчас:</strong> {{ max_possible }} шт.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="post" action="">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="close">
|
||||
|
||||
<div class="row align-items-end g-2">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label text-muted small mb-1">Фактически собрано (шт.)</label>
|
||||
<input type="number" class="form-control border-secondary" name="fact_qty" min="1" max="{{ max_possible }}" value="{{ max_possible }}" {% if max_possible == 0 %}disabled{% endif %}>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
{% if workitem.machine_id %}
|
||||
<button type="submit" class="btn btn-warning w-100" {% if max_possible == 0 %}disabled{% endif %}>
|
||||
Списать компоненты и закрыть сборку
|
||||
</button>
|
||||
{% else %}
|
||||
<button type="button" class="btn btn-warning w-100" data-bs-toggle="modal" data-bs-target="#selectMachineModal" {% if max_possible == 0 %}disabled{% endif %}>
|
||||
Выбрать пост и закрыть
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="small text-muted mt-2">
|
||||
При закрытии компоненты будут списаны со склада участка <strong>{{ to_location.name }}</strong>, а готовая сборка будет оприходована на этот же участок. Производственный отчёт привязывается к выбранному посту.
|
||||
</div>
|
||||
|
||||
<div class="modal fade" id="selectMachineModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content border-secondary">
|
||||
<div class="modal-header border-secondary">
|
||||
<h5 class="modal-title">Выбери пост для производственного отчёта</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
{% if workshop_machines %}
|
||||
<label class="form-label small text-muted mb-1">Пост</label>
|
||||
<select class="form-select border-secondary" name="machine_id" required>
|
||||
<option value="">— выбрать —</option>
|
||||
{% for m in workshop_machines %}
|
||||
<option value="{{ m.id }}">{{ m.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% else %}
|
||||
<div class="alert alert-warning border-warning mb-0">
|
||||
В этом цехе нет постов. Создай пост в «Справочники → Производство → Посты/станки» и привяжи его к этому цеху.
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="modal-footer border-secondary">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Отмена</button>
|
||||
<button type="submit" class="btn btn-warning" {% if not workshop_machines %}disabled{% endif %}>Закрыть</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
288
shiftflow/templates/shiftflow/closing.html
Normal file
288
shiftflow/templates/shiftflow/closing.html
Normal 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 %}
|
||||
78
shiftflow/templates/shiftflow/closing_workitems.html
Normal file
78
shiftflow/templates/shiftflow/closing_workitems.html
Normal file
@@ -0,0 +1,78 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card shadow border-secondary mb-3">
|
||||
<div class="card-header border-secondary py-3 d-flex flex-wrap gap-2 justify-content-between align-items-center">
|
||||
<h3 class="text-accent mb-0">
|
||||
<i class="bi bi-check2-square me-2"></i>Закрытие · Мои сменные задания
|
||||
</h3>
|
||||
|
||||
<form class="d-flex flex-wrap gap-2 align-items-end" method="get">
|
||||
<div>
|
||||
<label class="form-label small text-muted mb-1">Поиск</label>
|
||||
<input class="form-control form-control-sm bg-body text-body border-secondary" name="q" value="{{ q }}" placeholder="Сделка / КД / станок / цех">
|
||||
</div>
|
||||
<button class="btn btn-outline-accent btn-sm" type="submit">
|
||||
<i class="bi bi-search me-1"></i>Показать
|
||||
</button>
|
||||
<a class="btn btn-outline-accent btn-sm" href="{% url 'closing_workitems' %}">
|
||||
<i class="bi bi-arrow-counterclockwise me-1"></i>Сброс
|
||||
</a>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0 align-middle">
|
||||
<thead>
|
||||
<tr class="table-custom-header">
|
||||
<th style="width:110px;">Сделка</th>
|
||||
<th>КД</th>
|
||||
<th class="text-center" style="width:180px;">Операция</th>
|
||||
<th class="text-center" style="width:160px;">Цех/Пост</th>
|
||||
<th class="text-center" style="width:90px;">План</th>
|
||||
<th class="text-center" style="width:90px;">Факт</th>
|
||||
<th class="text-center" style="width:110px;">Остаток</th>
|
||||
<th class="text-center" style="width:120px;">Действие</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for wi in workitems %}
|
||||
<tr>
|
||||
<td class="fw-bold">
|
||||
<a class="text-decoration-none" href="{% url 'planning_deal' wi.deal.id %}">{{ wi.deal.number }}</a>
|
||||
</td>
|
||||
<td>
|
||||
<div class="fw-bold">
|
||||
<a class="text-decoration-none text-reset" href="{% url 'workitem_detail' wi.id %}">
|
||||
{{ wi.entity.drawing_number|default:"—" }} {{ wi.entity.name }}
|
||||
</a>
|
||||
</div>
|
||||
<div class="small text-muted">{{ wi.entity.get_entity_type_display }}</div>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
{% if wi.operation %}{{ wi.operation.name }}{% else %}{{ wi.stage|default:"—" }}{% endif %}
|
||||
</td>
|
||||
<td class="text-center">
|
||||
{% if wi.machine %}{{ wi.machine.name }}{% elif wi.workshop %}{{ wi.workshop.name }}{% else %}—{% endif %}
|
||||
</td>
|
||||
<td class="text-center">{{ wi.quantity_plan }}</td>
|
||||
<td class="text-center">{{ wi.quantity_done }}</td>
|
||||
<td class="text-center fw-bold {% if wi.remaining > 0 %}text-warning{% else %}text-success{% endif %}">
|
||||
{{ wi.remaining }}
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<a class="btn btn-outline-warning btn-sm" href="{{ wi.close_url }}">
|
||||
Закрыть
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="8" class="text-center text-muted py-4">Нет активных сменных заданий.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
167
shiftflow/templates/shiftflow/customer_deals.html
Normal file
167
shiftflow/templates/shiftflow/customer_deals.html
Normal 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 %}
|
||||
117
shiftflow/templates/shiftflow/customers.html
Normal file
117
shiftflow/templates/shiftflow/customers.html
Normal 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 %}
|
||||
36
shiftflow/templates/shiftflow/directories.html
Normal file
36
shiftflow/templates/shiftflow/directories.html
Normal file
@@ -0,0 +1,36 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card shadow border-secondary">
|
||||
<div class="card-header border-secondary py-3 d-flex justify-content-between align-items-center">
|
||||
<h3 class="text-accent mb-0"><i class="bi bi-journals me-2"></i>Справочники</h3>
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<a class="btn btn-outline-accent" href="{% url 'products' %}">
|
||||
<i class="bi bi-diagram-3 me-2"></i>Номенклатура изделий
|
||||
</a>
|
||||
|
||||
<a class="btn btn-outline-accent" href="{% url 'supply_catalog' %}">
|
||||
<i class="bi bi-box-seam me-2"></i>Номенклатура снабжения (покупное/аутсорс)
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<div class="btn-group" role="group" aria-label="Материалы">
|
||||
<a class="btn btn-outline-accent" href="{% url 'materials_catalog' %}">Материалы</a>
|
||||
<a class="btn btn-outline-accent" href="{% url 'material_categories_catalog' %}">Категории материалов</a>
|
||||
<a class="btn btn-outline-accent" href="{% url 'steel_grades_catalog' %}">Марки стали</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<div class="btn-group" role="group" aria-label="Производство">
|
||||
<a class="btn btn-outline-accent" href="{% url 'locations_catalog' %}">Склады</a>
|
||||
<a class="btn btn-outline-accent" href="{% url 'workshops_catalog' %}">Цеха</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
120
shiftflow/templates/shiftflow/item_detail copy.html
Normal file
120
shiftflow/templates/shiftflow/item_detail copy.html
Normal 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 %}
|
||||
211
shiftflow/templates/shiftflow/item_detail.html
Normal file
211
shiftflow/templates/shiftflow/item_detail.html
Normal 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 %}
|
||||
14
shiftflow/templates/shiftflow/landing.html
Normal file
14
shiftflow/templates/shiftflow/landing.html
Normal 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 %}
|
||||
291
shiftflow/templates/shiftflow/legacy_closing.html
Normal file
291
shiftflow/templates/shiftflow/legacy_closing.html
Normal file
@@ -0,0 +1,291 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card border-secondary mb-3 shadow-sm">
|
||||
<div class="card-body py-2">
|
||||
<form method="get" class="row g-2 align-items-center">
|
||||
<div class="col-md-4">
|
||||
<label class="small text-muted mb-1 fw-bold">Станок:</label>
|
||||
<select class="form-select form-select-sm bg-body text-body border-secondary" name="machine_id" onchange="this.form.submit()">
|
||||
<option value="">— выбрать —</option>
|
||||
{% for m in machines %}
|
||||
<option value="{{ m.id }}" {% if selected_machine_id == m.id|stringformat:"s" %}selected{% endif %}>{{ m.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<label class="small text-muted mb-1 fw-bold">Материал:</label>
|
||||
<select class="form-select form-select-sm bg-body text-body border-secondary" name="material_id" onchange="this.form.submit()">
|
||||
<option value="">— выбрать —</option>
|
||||
{% for mat in materials %}
|
||||
<option value="{{ mat.id }}" {% if selected_material_id == mat.id|stringformat:"s" %}selected{% endif %}>{{ mat.full_name|default:mat.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-md-2 text-end mt-auto">
|
||||
<a class="btn btn-outline-secondary btn-sm w-100" href="{% url 'legacy_closing' %}">Сброс</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="machine_id" value="{{ selected_machine_id }}">
|
||||
<input type="hidden" name="material_id" value="{{ selected_material_id }}">
|
||||
|
||||
<div class="card shadow border-secondary mb-3">
|
||||
<div class="card-header border-secondary py-3 d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h3 class="text-accent mb-0"><i class="bi bi-archive me-2"></i>Архив / Закрытие</h3>
|
||||
<div class="small text-muted">Legacy: Item</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0 align-middle">
|
||||
<thead>
|
||||
<tr class="table-custom-header">
|
||||
<th>Дата</th>
|
||||
<th>Сделка</th>
|
||||
<th>Деталь</th>
|
||||
<th>План</th>
|
||||
<th data-sort="false">Факт</th>
|
||||
<th data-sort="false">Режим</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for it in items %}
|
||||
<tr>
|
||||
<td class="small">{{ it.date|date:"d.m.Y" }}</td>
|
||||
<td><span class="text-accent fw-bold">{{ it.task.deal.number }}</span></td>
|
||||
<td class="fw-bold">{{ it.task.drawing_name }}</td>
|
||||
<td>{{ it.quantity_plan }}</td>
|
||||
<td style="max-width:140px;">
|
||||
<input class="form-control form-control-sm border-secondary" type="number" min="0" max="{{ it.quantity_plan }}" name="fact_{{ it.id }}" id="fact_{{ it.id }}" value="{{ it.quantity_fact }}" {% if not can_edit %}disabled{% endif %}>
|
||||
</td>
|
||||
<td style="min-width:260px;">
|
||||
<div class="d-flex gap-2 align-items-center flex-wrap">
|
||||
<button type="button" class="btn btn-sm btn-outline-success closing-set-action" data-item-id="{{ it.id }}" data-action="done" data-plan="{{ it.quantity_plan }}" {% if not can_edit %}disabled{% endif %}>Полностью</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-warning closing-set-action" data-item-id="{{ it.id }}" data-action="partial" data-plan="{{ it.quantity_plan }}" {% if not can_edit %}disabled{% endif %}>Частично</button>
|
||||
<input type="hidden" id="ca_{{ it.id }}" name="close_action_{{ it.id }}" value="">
|
||||
<span class="small text-muted" id="modeLabel_{{ it.id }}"></span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="6" class="text-center text-muted py-4">Выбери станок и материал</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card shadow border-secondary mb-3">
|
||||
<div class="card-header border-secondary py-3">
|
||||
<h5 class="mb-0">Списание со склада цеха (единицы)</h5>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0 align-middle">
|
||||
<thead>
|
||||
<tr class="table-custom-header">
|
||||
<th>Поступление</th>
|
||||
<th>Сделка</th>
|
||||
<th>Единица</th>
|
||||
<th>Размеры</th>
|
||||
<th>Доступно</th>
|
||||
<th data-sort="false">Использовано</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for s in stock_items %}
|
||||
<tr>
|
||||
<td class="small">{% if s.created_at %}{{ s.created_at|date:"d.m.Y H:i" }}{% endif %}</td>
|
||||
<td>
|
||||
{% if s.deal_id %}
|
||||
<span class="text-accent fw-bold">{{ s.deal.number }}</span>
|
||||
{% else %}
|
||||
—
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ s }}</td>
|
||||
<td>
|
||||
{% if s.current_length and s.current_width %}
|
||||
{{ s.current_length|floatformat:"-g" }} × {{ s.current_width|floatformat:"-g" }} мм
|
||||
{% elif s.current_length %}
|
||||
{{ s.current_length|floatformat:"-g" }} мм
|
||||
{% else %}
|
||||
—
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ s.quantity }}</td>
|
||||
<td style="max-width:140px;">
|
||||
<input class="form-control form-control-sm border-secondary" name="consume_{{ s.id }}" placeholder="0" {% if not can_edit %}disabled{% endif %}>
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="5" class="text-center text-muted py-4">Нет единиц на складе для выбранного материала</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card shadow border-secondary">
|
||||
<div class="card-header border-secondary py-3 d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0">Остаток ДО</h5>
|
||||
<button type="button" class="btn btn-outline-accent btn-sm" id="addRemnantBtn" {% if not can_edit %}disabled{% endif %}>Добавить ДО</button>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0 align-middle">
|
||||
<thead>
|
||||
<tr class="table-custom-header">
|
||||
<th>Кол-во</th>
|
||||
<th>Длина (мм)</th>
|
||||
<th>Ширина (мм)</th>
|
||||
<th data-sort="false"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="remnantBody">
|
||||
<tr id="remnantEmptyRow">
|
||||
<td colspan="4" class="text-center text-muted py-4">ДО не добавлены</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-end mt-3">
|
||||
<button type="submit" class="btn btn-outline-accent" {% if not can_edit %}disabled{% endif %}>Сохранить</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const canEdit = {% if can_edit %}true{% else %}false{% endif %};
|
||||
|
||||
document.querySelectorAll('.closing-set-action').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
if (!canEdit) return;
|
||||
|
||||
const itemId = btn.getAttribute('data-item-id');
|
||||
const action = btn.getAttribute('data-action');
|
||||
const plan = parseInt(btn.getAttribute('data-plan') || '0', 10) || 0;
|
||||
|
||||
const hidden = document.getElementById('ca_' + itemId);
|
||||
const fact = document.getElementById('fact_' + itemId);
|
||||
const label = document.getElementById('modeLabel_' + itemId);
|
||||
|
||||
if (hidden) hidden.value = action;
|
||||
|
||||
const cell = btn.closest('td');
|
||||
if (cell) {
|
||||
cell.querySelectorAll('.closing-set-action').forEach(b => {
|
||||
const a = b.getAttribute('data-action');
|
||||
if (a === 'done') {
|
||||
b.classList.remove('btn-success');
|
||||
b.classList.add('btn-outline-success');
|
||||
}
|
||||
if (a === 'partial') {
|
||||
b.classList.remove('btn-warning');
|
||||
b.classList.add('btn-outline-warning');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (action === 'done') {
|
||||
btn.classList.remove('btn-outline-success');
|
||||
btn.classList.add('btn-success');
|
||||
if (fact) {
|
||||
fact.value = String(plan);
|
||||
fact.readOnly = true;
|
||||
}
|
||||
if (label) label.textContent = 'Выбрано: полностью';
|
||||
}
|
||||
|
||||
if (action === 'partial') {
|
||||
btn.classList.remove('btn-outline-warning');
|
||||
btn.classList.add('btn-warning');
|
||||
if (fact) {
|
||||
fact.readOnly = false;
|
||||
fact.focus();
|
||||
fact.select();
|
||||
}
|
||||
if (label) label.textContent = 'Выбрано: частично';
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const addBtn = document.getElementById('addRemnantBtn');
|
||||
const body = document.getElementById('remnantBody');
|
||||
const emptyRow = document.getElementById('remnantEmptyRow');
|
||||
|
||||
function renumberRemnants() {
|
||||
const rows = Array.from(body.querySelectorAll('tr[data-remnant-row="1"]'));
|
||||
rows.forEach((tr, idx) => {
|
||||
const qty = tr.querySelector('input[data-field="qty"]');
|
||||
const len = tr.querySelector('input[data-field="len"]');
|
||||
const wid = tr.querySelector('input[data-field="wid"]');
|
||||
if (qty) qty.name = 'remnant_qty_' + idx;
|
||||
if (len) len.name = 'remnant_len_' + idx;
|
||||
if (wid) wid.name = 'remnant_wid_' + idx;
|
||||
});
|
||||
|
||||
if (emptyRow) {
|
||||
emptyRow.style.display = rows.length ? 'none' : '';
|
||||
}
|
||||
}
|
||||
|
||||
function addRemnantRow() {
|
||||
if (!canEdit) return;
|
||||
|
||||
const rows = Array.from(body.querySelectorAll('tr[data-remnant-row="1"]'));
|
||||
if (rows.length >= 50) return;
|
||||
|
||||
const tr = document.createElement('tr');
|
||||
tr.setAttribute('data-remnant-row', '1');
|
||||
|
||||
tr.innerHTML = `
|
||||
<td style="max-width:180px;">
|
||||
<input class="form-control form-control-sm border-secondary" data-field="qty" inputmode="decimal" placeholder="Кол-во" required>
|
||||
</td>
|
||||
<td style="max-width:180px;">
|
||||
<input class="form-control form-control-sm border-secondary" data-field="len" inputmode="decimal" placeholder="Длина (мм)">
|
||||
</td>
|
||||
<td style="max-width:180px;">
|
||||
<input class="form-control form-control-sm border-secondary" data-field="wid" inputmode="decimal" placeholder="Ширина (мм)">
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" data-action="remove">Удалить</button>
|
||||
</td>
|
||||
`;
|
||||
|
||||
const rm = tr.querySelector('button[data-action="remove"]');
|
||||
if (rm) {
|
||||
rm.addEventListener('click', () => {
|
||||
tr.remove();
|
||||
renumberRemnants();
|
||||
});
|
||||
}
|
||||
|
||||
body.appendChild(tr);
|
||||
renumberRemnants();
|
||||
|
||||
const first = tr.querySelector('input[data-field="qty"]');
|
||||
if (first) {
|
||||
first.focus();
|
||||
first.select();
|
||||
}
|
||||
}
|
||||
|
||||
if (addBtn) {
|
||||
addBtn.addEventListener('click', addRemnantRow);
|
||||
}
|
||||
|
||||
renumberRemnants();
|
||||
});
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
21
shiftflow/templates/shiftflow/legacy_registry.html
Normal file
21
shiftflow/templates/shiftflow/legacy_registry.html
Normal file
@@ -0,0 +1,21 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block content %}
|
||||
{% include 'shiftflow/partials/_filter.html' %}
|
||||
|
||||
<div class="card shadow border-secondary">
|
||||
<div class="card-header border-secondary py-3 d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h3 class="text-accent mb-0"><i class="bi bi-archive me-2"></i>Архив / Реестр</h3>
|
||||
<div class="small text-muted">Legacy: Item</div>
|
||||
</div>
|
||||
{% if user_role in 'admin,technologist,master' %}
|
||||
<a class="btn btn-outline-secondary btn-sm" target="_blank" href="{% url 'registry_print' %}?{{ request.GET.urlencode }}">
|
||||
<i class="bi bi-printer me-1"></i>Печать
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% include 'shiftflow/partials/_items_table.html' with items=items %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
173
shiftflow/templates/shiftflow/legacy_writeoffs.html
Normal file
173
shiftflow/templates/shiftflow/legacy_writeoffs.html
Normal file
@@ -0,0 +1,173 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card border-secondary mb-3 shadow-sm">
|
||||
<div class="card-body py-2">
|
||||
<form method="get" class="row g-2 align-items-end">
|
||||
<div class="col-md-auto">
|
||||
<label class="small text-muted mb-1 fw-bold">Период (с):</label>
|
||||
<input type="date" name="start_date" class="form-control form-control-sm bg-body text-body border-secondary" value="{{ start_date }}">
|
||||
</div>
|
||||
<div class="col-md-auto">
|
||||
<label class="small text-muted mb-1 fw-bold">Период (по):</label>
|
||||
<input type="date" name="end_date" class="form-control form-control-sm bg-body text-body border-secondary" value="{{ end_date }}">
|
||||
</div>
|
||||
<div class="col-md-auto">
|
||||
<button type="submit" class="btn btn-outline-secondary btn-sm">
|
||||
<i class="bi bi-funnel me-1"></i>Показать
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-md-auto">
|
||||
<a href="{% url 'legacy_writeoffs' %}?reset=1" class="btn btn-outline-secondary btn-sm">
|
||||
<i class="bi bi-arrow-counterclockwise me-1"></i>Сброс
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card shadow border-secondary mb-3">
|
||||
<div class="card-header border-secondary py-3 d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h3 class="text-accent mb-0"><i class="bi bi-archive me-2"></i>Архив / Списание / Производство</h3>
|
||||
<div class="small text-muted">По производственным отчетам</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
{% for card in report_cards %}
|
||||
<div class="border border-secondary rounded p-3 mb-3">
|
||||
<div class="d-flex flex-wrap justify-content-between gap-2">
|
||||
<div class="fw-bold">
|
||||
{{ card.report.date|date:"d.m.Y" }} — {{ card.report.machine }} — {{ card.report.operator }}
|
||||
<span class="text-muted small ms-2">#{{ card.report.id }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3 mt-1">
|
||||
<div class="col-lg-4">
|
||||
<div class="small text-muted fw-bold mb-1">Списано</div>
|
||||
{% if card.report.consumptions.all %}
|
||||
<ul class="mb-0">
|
||||
{% for c in card.report.consumptions.all %}
|
||||
{% if c.stock_item_id and c.stock_item.material_id %}
|
||||
<li>
|
||||
{{ c.stock_item.material.full_name|default:c.stock_item.material.name }}
|
||||
({% if c.stock_item.current_length and c.stock_item.current_width %}{{ c.stock_item.current_length|floatformat:"-g" }}×{{ c.stock_item.current_width|floatformat:"-g" }}{% elif c.stock_item.current_length %}{{ c.stock_item.current_length|floatformat:"-g" }}{% else %}—{% endif %})
|
||||
{{ c.quantity|floatformat:"-g" }} шт
|
||||
</li>
|
||||
{% elif c.material_id %}
|
||||
<li>{{ c.material }} {{ c.quantity|floatformat:"-g" }} шт</li>
|
||||
{% else %}
|
||||
<li>— {{ c.quantity|floatformat:"-g" }} шт</li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<div class="text-muted small">—</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="col-lg-4">
|
||||
<div class="small text-muted fw-bold mb-1">Произведено</div>
|
||||
{% if card.produced %}
|
||||
<ul class="mb-0">
|
||||
{% for k,v in card.produced.items %}
|
||||
<li>{{ k }}: {{ v }} шт</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<div class="text-muted small">—</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="col-lg-4">
|
||||
<div class="small text-muted fw-bold mb-1">Остаток ДО</div>
|
||||
{% if card.report.remnants.all %}
|
||||
<ul class="mb-0">
|
||||
{% for r in card.report.remnants.all %}
|
||||
<li>
|
||||
{{ r.material.full_name|default:r.material.name|default:r.material }}
|
||||
({% if r.current_length and r.current_width %}{{ r.current_length|floatformat:"-g" }}×{{ r.current_width|floatformat:"-g" }}{% elif r.current_length %}{{ r.current_length|floatformat:"-g" }}{% else %}—{% endif %})
|
||||
{{ r.quantity|floatformat:"-g" }} шт
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<div class="text-muted small">—</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% empty %}
|
||||
<div class="text-muted">За выбранный период отчётов нет.</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card shadow border-secondary">
|
||||
<div class="card-header border-secondary py-3 d-flex justify-content-between align-items-center">
|
||||
<h3 class="text-accent mb-0"><i class="bi bi-check2-square me-2"></i>Сменные задания (1С)</h3>
|
||||
<div class="small text-muted">Отметка «Списано в 1С»</div>
|
||||
</div>
|
||||
|
||||
<form method="post" class="mb-0">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="start_date" value="{{ start_date }}">
|
||||
<input type="hidden" name="end_date" value="{{ end_date }}">
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0 align-middle">
|
||||
<thead>
|
||||
<tr class="table-custom-header">
|
||||
<th data-sort="false"></th>
|
||||
<th>Дата</th>
|
||||
<th>Сделка</th>
|
||||
<th>Станок</th>
|
||||
<th>Позиция</th>
|
||||
<th>План / Факт</th>
|
||||
<th data-sort="false" class="text-center">1С</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for it in items %}
|
||||
<tr>
|
||||
<td style="width:40px;">
|
||||
{% if can_edit %}
|
||||
<input type="checkbox" class="form-check-input" name="item_ids" value="{{ it.id }}">
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="small">{{ it.date|date:"d.m.Y" }}</td>
|
||||
<td><span class="text-accent fw-bold">{{ it.task.deal.number|default:"-" }}</span></td>
|
||||
<td><span class="badge bg-dark border border-secondary">{{ it.machine.name }}</span></td>
|
||||
<td class="fw-bold">{{ it.task.drawing_name|default:"—" }}</td>
|
||||
<td>
|
||||
<span class="text-info fw-bold">{{ it.quantity_plan }}</span> /
|
||||
<span class="text-success">{{ it.quantity_fact }}</span>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
{% if it.is_synced_1c %}
|
||||
<i class="bi bi-check-circle-fill text-success" title="Учтено"></i>
|
||||
{% else %}
|
||||
<i class="bi bi-clock-history text-muted" title="Ожидает"></i>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="7" class="text-center text-muted py-4">Нет сменных заданий за период</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{% if can_edit %}
|
||||
<div class="card-body border-top border-secondary d-flex justify-content-end">
|
||||
<button type="submit" class="btn btn-outline-accent">
|
||||
<i class="bi bi-save me-2"></i>Сохранить
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
72
shiftflow/templates/shiftflow/locations_catalog.html
Normal file
72
shiftflow/templates/shiftflow/locations_catalog.html
Normal file
@@ -0,0 +1,72 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card shadow border-secondary mb-3">
|
||||
<div class="card-header border-secondary py-3 d-flex flex-wrap gap-2 justify-content-between align-items-center">
|
||||
<h3 class="text-accent mb-0"><i class="bi bi-boxes me-2"></i>Справочник · Склады</h3>
|
||||
<a class="btn btn-outline-secondary btn-sm" href="{% url 'directories' %}">Назад</a>
|
||||
</div>
|
||||
|
||||
{% if can_edit %}
|
||||
<div class="card-body border-bottom border-secondary">
|
||||
<form method="post" class="row g-2 align-items-end">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="create">
|
||||
|
||||
<div class="col-md-7">
|
||||
<label class="form-label small text-muted mb-1">Название склада</label>
|
||||
<input class="form-control border-secondary" name="name" placeholder="Напр: Центральный склад" required>
|
||||
</div>
|
||||
|
||||
<div class="col-md-2">
|
||||
<button class="btn btn-outline-accent w-100" type="submit">Создать</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0 align-middle" data-sortable="1">
|
||||
<thead>
|
||||
<tr class="table-custom-header">
|
||||
<th class="text-center" style="width:80px;" data-sort-type="number">№</th>
|
||||
<th>Склад</th>
|
||||
<th class="text-center" style="width:140px;">Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for l in locations %}
|
||||
<tr>
|
||||
<td class="text-center">{{ forloop.counter }}</td>
|
||||
|
||||
<td class="fw-bold">
|
||||
{% if can_edit %}
|
||||
<form method="post" class="row g-2 align-items-center">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="update">
|
||||
<input type="hidden" name="location_id" value="{{ l.id }}">
|
||||
<div class="col-12">
|
||||
<input class="form-control form-control-sm border-secondary" name="name" value="{{ l.name }}">
|
||||
</div>
|
||||
{% else %}
|
||||
{{ l.name }}
|
||||
{% endif %}
|
||||
</td>
|
||||
|
||||
<td class="text-center">
|
||||
{% if can_edit %}
|
||||
<button class="btn btn-outline-accent btn-sm" type="submit">Сохранить</button>
|
||||
</form>
|
||||
{% else %}
|
||||
—
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="3" class="text-center text-muted py-4">Складов нет.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
158
shiftflow/templates/shiftflow/machines_catalog.html
Normal file
158
shiftflow/templates/shiftflow/machines_catalog.html
Normal file
@@ -0,0 +1,158 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card shadow border-secondary mb-3">
|
||||
<div class="card-header border-secondary py-3 d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h3 class="text-accent mb-0"><i class="bi bi-building me-2"></i>{{ workshop.name }}</h3>
|
||||
<div class="small text-muted">Цех · ID {{ workshop.id }}</div>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<a class="btn btn-outline-secondary btn-sm" href="{% url 'workshops_catalog' %}">Назад</a>
|
||||
{% if can_edit %}
|
||||
<button class="btn btn-outline-accent btn-sm" type="button" data-bs-toggle="modal" data-bs-target="#addMachineModal">
|
||||
<i class="bi bi-plus-circle me-1"></i>Добавить пост
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-body border-bottom border-secondary">
|
||||
{% if can_edit %}
|
||||
<form method="post" class="row g-2 align-items-end">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="update_workshop">
|
||||
|
||||
<div class="col-md-6">
|
||||
<label class="form-label small text-muted mb-1">Наименование цеха</label>
|
||||
<input class="form-control border-secondary" name="name" value="{{ workshop.name }}">
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<label class="form-label small text-muted mb-1">Склад цеха</label>
|
||||
<select class="form-select border-secondary" name="location_id">
|
||||
<option value="">— не задан —</option>
|
||||
{% for l in locations %}
|
||||
<option value="{{ l.id }}" {% if workshop.location_id == l.id %}selected{% endif %}>{{ l.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-md-2">
|
||||
<button class="btn btn-outline-accent w-100" type="submit">Сохранить</button>
|
||||
</div>
|
||||
</form>
|
||||
{% else %}
|
||||
<div class="row g-2">
|
||||
<div class="col-md-6">
|
||||
<div class="small text-muted">Цех</div>
|
||||
<div class="fw-bold">{{ workshop.name }}</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="small text-muted">Склад цеха</div>
|
||||
<div class="fw-bold">{{ workshop.location.name|default:"—" }}</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0 align-middle" data-sortable="1">
|
||||
<thead>
|
||||
<tr class="table-custom-header">
|
||||
<th class="text-center" style="width:80px;" data-sort-type="number">№</th>
|
||||
<th class="text-center" style="width:90px;" data-sort-type="number">ID</th>
|
||||
<th>Пост/станок</th>
|
||||
<th class="text-center" style="width:200px;">Тип</th>
|
||||
<th class="text-center" style="width:220px;">Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for m in machines %}
|
||||
<tr>
|
||||
<td class="text-center">{{ forloop.counter }}</td>
|
||||
<td class="text-center text-muted">{{ m.id }}</td>
|
||||
|
||||
<td class="fw-bold">
|
||||
{% if can_edit %}
|
||||
<form method="post" class="row g-2 align-items-center">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="update_machine">
|
||||
<input type="hidden" name="machine_id" value="{{ m.id }}">
|
||||
<div class="col-12">
|
||||
<input class="form-control form-control-sm border-secondary" name="name" value="{{ m.name }}">
|
||||
</div>
|
||||
{% else %}
|
||||
{{ m.name }}
|
||||
{% endif %}
|
||||
</td>
|
||||
|
||||
<td class="text-center">
|
||||
{% if can_edit %}
|
||||
<select class="form-select form-select-sm border-secondary" name="machine_type">
|
||||
{% for k, v in machine_types %}
|
||||
<option value="{{ k }}" {% if m.machine_type == k %}selected{% endif %}>{{ v }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% else %}
|
||||
{{ m.get_machine_type_display }}
|
||||
{% endif %}
|
||||
</td>
|
||||
|
||||
<td class="text-center">
|
||||
{% if can_edit %}
|
||||
<div class="d-flex justify-content-center gap-2">
|
||||
<button class="btn btn-outline-accent btn-sm" type="submit">Сохранить</button>
|
||||
</form>
|
||||
<form method="post" class="m-0">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="delete_machine">
|
||||
<input type="hidden" name="machine_id" value="{{ m.id }}">
|
||||
<button class="btn btn-outline-secondary btn-sm" type="submit">Удалить</button>
|
||||
</form>
|
||||
</div>
|
||||
{% else %}
|
||||
—
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="5" class="text-center text-muted py-4">Постов нет.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if can_edit %}
|
||||
<div class="modal fade" id="addMachineModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content border-secondary">
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="create_machine">
|
||||
<div class="modal-header border-secondary">
|
||||
<h5 class="modal-title">Добавить пост</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<label class="form-label small text-muted mb-1">Название</label>
|
||||
<input class="form-control border-secondary mb-3" name="name" placeholder="Напр: Сварка-1" required>
|
||||
|
||||
<label class="form-label small text-muted mb-1">Тип</label>
|
||||
<select class="form-select border-secondary" name="machine_type">
|
||||
{% for k, v in machine_types %}
|
||||
<option value="{{ k }}">{{ v }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="modal-footer border-secondary">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Отмена</button>
|
||||
<button type="submit" class="btn btn-outline-accent">Добавить</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
167
shiftflow/templates/shiftflow/maintenance.html
Normal file
167
shiftflow/templates/shiftflow/maintenance.html
Normal 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 %}
|
||||
149
shiftflow/templates/shiftflow/material_categories_catalog.html
Normal file
149
shiftflow/templates/shiftflow/material_categories_catalog.html
Normal file
@@ -0,0 +1,149 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card shadow border-secondary mb-3">
|
||||
<div class="card-header border-secondary py-3 d-flex flex-wrap justify-content-between align-items-center gap-2">
|
||||
<div>
|
||||
<nav aria-label="breadcrumb" class="small">
|
||||
<ol class="breadcrumb mb-1">
|
||||
<li class="breadcrumb-item"><a class="text-decoration-none" href="{% url 'directories' %}">Справочники</a></li>
|
||||
<li class="breadcrumb-item active" aria-current="page">Категории материалов</li>
|
||||
</ol>
|
||||
</nav>
|
||||
<h3 class="text-accent mb-0"><i class="bi bi-tags me-2"></i>Категории материалов</h3>
|
||||
</div>
|
||||
|
||||
<div class="d-flex flex-wrap gap-2 align-items-center">
|
||||
<form method="get" class="d-flex gap-2 align-items-center">
|
||||
<input class="form-control form-control-sm bg-body text-body border-secondary" name="q" value="{{ q }}" placeholder="Поиск...">
|
||||
<button class="btn btn-outline-accent btn-sm" type="submit"><i class="bi bi-search me-1"></i>Найти</button>
|
||||
<a class="btn btn-outline-accent btn-sm" href="{% url 'material_categories_catalog' %}"><i class="bi bi-arrow-counterclockwise me-1"></i>Сброс</a>
|
||||
</form>
|
||||
|
||||
<a class="btn btn-outline-accent btn-sm" href="{% url 'directories' %}"><i class="bi bi-arrow-left me-1"></i>Назад</a>
|
||||
|
||||
{% if can_edit %}
|
||||
<button class="btn btn-outline-accent btn-sm" type="button" data-bs-toggle="modal" data-bs-target="#catModal" onclick="openCatCreate()">
|
||||
<i class="bi bi-plus-lg me-1"></i>Создать
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0 align-middle">
|
||||
<thead>
|
||||
<tr class="table-custom-header">
|
||||
<th>Название</th>
|
||||
<th>ГОСТ</th>
|
||||
<th>Форма</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for c in categories %}
|
||||
<tr role="button" {% if can_edit %}onclick="openCatEdit(this)"{% endif %}
|
||||
data-id="{{ c.id }}" data-name="{{ c.name }}" data-gost="{{ c.gost_standard }}" data-form="{{ c.form_factor }}">
|
||||
<td class="fw-bold">{{ c.name }}</td>
|
||||
<td>{{ c.gost_standard|default:"—" }}</td>
|
||||
<td class="small text-muted">{{ c.get_form_factor_display }}</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="3" class="text-center text-muted py-4">Нет данных</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="modal fade" id="catModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<form class="modal-content border-secondary" onsubmit="event.preventDefault(); saveCat();">
|
||||
<div class="modal-header border-secondary">
|
||||
<h5 class="modal-title" id="catModalTitle">Категория</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" id="catId">
|
||||
|
||||
<div class="row g-2">
|
||||
<div class="col-md-5">
|
||||
<label class="form-label">Название</label>
|
||||
<input class="form-control bg-body text-body border-secondary" id="catName" required {% if not can_edit %}disabled{% endif %}>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">ГОСТ</label>
|
||||
<input class="form-control bg-body text-body border-secondary" id="catGost" {% if not can_edit %}disabled{% endif %}>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Форма</label>
|
||||
<select class="form-select bg-body text-body border-secondary" id="catForm" {% if not can_edit %}disabled{% endif %}>
|
||||
<option value="sheet">Лист</option>
|
||||
<option value="bar">Прокат/хлыст</option>
|
||||
<option value="other">Прочее</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer border-secondary">
|
||||
<button type="button" class="btn btn-outline-accent" data-bs-dismiss="modal">Отмена</button>
|
||||
{% if can_edit %}
|
||||
<button type="submit" class="btn btn-outline-accent">Сохранить</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function getCookie(name) {
|
||||
const value = `; ${document.cookie}`;
|
||||
const parts = value.split(`; ${name}=`);
|
||||
if (parts.length === 2) return parts.pop().split(';').shift();
|
||||
return '';
|
||||
}
|
||||
|
||||
function openCatCreate() {
|
||||
document.getElementById('catModalTitle').textContent = 'Категория (создание)';
|
||||
document.getElementById('catId').value = '';
|
||||
document.getElementById('catName').value = '';
|
||||
document.getElementById('catGost').value = '';
|
||||
document.getElementById('catForm').value = 'other';
|
||||
new bootstrap.Modal(document.getElementById('catModal')).show();
|
||||
}
|
||||
|
||||
function openCatEdit(tr) {
|
||||
document.getElementById('catModalTitle').textContent = 'Категория (правка)';
|
||||
document.getElementById('catId').value = tr.getAttribute('data-id') || '';
|
||||
document.getElementById('catName').value = tr.getAttribute('data-name') || '';
|
||||
document.getElementById('catGost').value = tr.getAttribute('data-gost') || '';
|
||||
document.getElementById('catForm').value = tr.getAttribute('data-form') || 'other';
|
||||
new bootstrap.Modal(document.getElementById('catModal')).show();
|
||||
}
|
||||
|
||||
async function saveCat() {
|
||||
const fd = new FormData();
|
||||
fd.append('id', document.getElementById('catId').value);
|
||||
fd.append('name', document.getElementById('catName').value);
|
||||
fd.append('gost_standard', document.getElementById('catGost').value);
|
||||
fd.append('form_factor', document.getElementById('catForm').value);
|
||||
|
||||
const res = await fetch("{% url 'material_category_upsert' %}", {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'X-CSRFToken': getCookie('csrftoken') },
|
||||
body: fd,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
alert('Не удалось сохранить категорию');
|
||||
return;
|
||||
}
|
||||
|
||||
window.location.reload();
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user