Добавил превьюшки дхф и настройки сервера
All checks were successful
Deploy MES Core / deploy (push) Successful in 3m32s
All checks were successful
Deploy MES Core / deploy (push) Successful in 3m32s
This commit is contained in:
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
|
||||||
}
|
}
|
||||||
BIN
requirements.txt
BIN
requirements.txt
Binary file not shown.
@@ -26,4 +26,26 @@ class ProductionTaskCreateForm(forms.Form):
|
|||||||
queryset=Material.objects.all().order_by("full_name"),
|
queryset=Material.objects.all().order_by("full_name"),
|
||||||
required=True,
|
required=True,
|
||||||
empty_label="— выбрать —",
|
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'
|
||||||
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',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -64,13 +64,15 @@ class ProductionTask(models.Model):
|
|||||||
Создается технологом или мастером на основе заказа.
|
Создается технологом или мастером на основе заказа.
|
||||||
"""
|
"""
|
||||||
deal = models.ForeignKey(Deal, on_delete=models.CASCADE, verbose_name="Сделка")
|
deal = models.ForeignKey(Deal, on_delete=models.CASCADE, verbose_name="Сделка")
|
||||||
|
|
||||||
drawing_name = models.CharField("Название детали", max_length=255, blank=True, default="Б/ч")
|
drawing_name = models.CharField("Название детали", max_length=255, blank=True, default="Б/ч")
|
||||||
size_value = models.FloatField("Размер детали", help_text="Длина (мм) или Толщина (мм)")
|
size_value = models.FloatField("Размер детали", help_text="Длина (мм) или Толщина (мм)")
|
||||||
|
|
||||||
drawing_file = models.FileField("Исходник (DXF/IGES)", upload_to="drawings/%Y/%m/", blank=True, null=True)
|
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)
|
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="Материал")
|
material = models.ForeignKey(WarehouseMaterial, on_delete=models.PROTECT, verbose_name="Материал")
|
||||||
quantity_ordered = models.PositiveIntegerField("Заказано всего, шт")
|
quantity_ordered = models.PositiveIntegerField("Заказано всего, шт")
|
||||||
is_bend = models.BooleanField("Гибка", default=False)
|
is_bend = models.BooleanField("Гибка", default=False)
|
||||||
@@ -84,6 +86,47 @@ class ProductionTask(models.Model):
|
|||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.drawing_name} (Заказ {self.deal.number})"
|
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",
|
||||||
|
)
|
||||||
|
|
||||||
|
updated_at = models.DateTimeField("Обновлено", auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "Настройки превью DXF"
|
||||||
|
verbose_name_plural = "Настройки превью DXF"
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return "Настройки превью DXF"
|
||||||
|
|
||||||
|
|
||||||
class Item(models.Model):
|
class Item(models.Model):
|
||||||
"""
|
"""
|
||||||
Единица сменного задания. Определяет КТО, КОГДА и СКОЛЬКО сделал.
|
Единица сменного задания. Определяет КТО, КОГДА и СКОЛЬКО сделал.
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form method="post" id="mainForm" class="card-body p-4">
|
<form method="post" enctype="multipart/form-data" id="mainForm" class="card-body p-4">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<input type="hidden" name="next" value="{{ back_url }}">
|
<input type="hidden" name="next" value="{{ back_url }}">
|
||||||
|
|
||||||
@@ -52,7 +52,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<small class="text-muted d-block">Материал</small>
|
<small class="text-muted d-block">Материал</small>
|
||||||
<strong>{{ item.task.material.name }}</strong>
|
<strong>{{ item.task.material.full_name|default:item.task.material.name }}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<small class="text-muted d-block">План</small>
|
<small class="text-muted d-block">План</small>
|
||||||
@@ -60,9 +60,67 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-4 d-flex gap-2">
|
<div class="row g-3 mb-4 border-bottom border-secondary pb-3 text-body">
|
||||||
{% if item.task.drawing_file %}<a href="{{ item.task.drawing_file.url }}" target="_blank" class="btn btn-outline-info btn-sm">DXF</a>{% endif %}
|
<div class="col-md-6">
|
||||||
{% if item.task.extra_drawing %}<a href="{{ item.task.extra_drawing.url }}" target="_blank" class="btn btn-outline-danger btn-sm">PDF</a>{% endif %}
|
<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>
|
</div>
|
||||||
|
|
||||||
<input type="hidden" name="status" id="id_status" value="{{ item.status }}">
|
<input type="hidden" name="status" id="id_status" value="{{ item.status }}">
|
||||||
|
|||||||
72
shiftflow/templates/shiftflow/maintenance.html
Normal file
72
shiftflow/templates/shiftflow/maintenance.html
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
{% 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">
|
||||||
|
<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-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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="text-muted small">
|
||||||
|
Пакетное обновление пробегает по сделкам в статусах «Зашла» и «В работе».
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if messages %}
|
||||||
|
<div class="mt-3">
|
||||||
|
{% for message in messages %}
|
||||||
|
<div class="alert alert-{{ message.tags }}">{{ message }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -35,7 +35,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="row g-3">
|
<div class="row g-3">
|
||||||
<div class="col-md-8">
|
<div class="col-12">
|
||||||
<label class="form-label small text-muted">{{ form.drawing_name.label }}</label>
|
<label class="form-label small text-muted">{{ form.drawing_name.label }}</label>
|
||||||
{{ form.drawing_name }}
|
{{ form.drawing_name }}
|
||||||
{% for e in form.drawing_name.errors %}<div class="text-danger small">{{ e }}</div>{% endfor %}
|
{% for e in form.drawing_name.errors %}<div class="text-danger small">{{ e }}</div>{% endfor %}
|
||||||
@@ -50,7 +50,7 @@
|
|||||||
{{ form.size_value }}
|
{{ form.size_value }}
|
||||||
{% for e in form.size_value.errors %}<div class="text-danger small">{{ e }}</div>{% endfor %}
|
{% for e in form.size_value.errors %}<div class="text-danger small">{{ e }}</div>{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-8 d-flex align-items-end">
|
<div class="col-md-4 d-flex align-items-end">
|
||||||
<div class="form-check form-switch">
|
<div class="form-check form-switch">
|
||||||
{{ form.is_bend }}
|
{{ form.is_bend }}
|
||||||
<label class="form-check-label ms-2">{{ form.is_bend.label }}</label>
|
<label class="form-check-label ms-2">{{ form.is_bend.label }}</label>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from .views import (
|
|||||||
DealUpsertView,
|
DealUpsertView,
|
||||||
IndexView,
|
IndexView,
|
||||||
ItemUpdateView,
|
ItemUpdateView,
|
||||||
|
MaintenanceView,
|
||||||
MaterialCategoryUpsertView,
|
MaterialCategoryUpsertView,
|
||||||
MaterialDetailView,
|
MaterialDetailView,
|
||||||
MaterialUpsertView,
|
MaterialUpsertView,
|
||||||
@@ -32,6 +33,7 @@ urlpatterns = [
|
|||||||
path('planning/task/<int:pk>/items/', TaskItemsView.as_view(), name='task_items'),
|
path('planning/task/<int:pk>/items/', TaskItemsView.as_view(), name='task_items'),
|
||||||
path('customers/', CustomersView.as_view(), name='customers'),
|
path('customers/', CustomersView.as_view(), name='customers'),
|
||||||
path('customers/<int:pk>/', CustomerDealsView.as_view(), name='customer_deals'),
|
path('customers/<int:pk>/', CustomerDealsView.as_view(), name='customer_deals'),
|
||||||
|
path('maintenance/', MaintenanceView.as_view(), name='maintenance'),
|
||||||
path('planning/add/', PlanningAddView.as_view(), name='planning_add'),
|
path('planning/add/', PlanningAddView.as_view(), name='planning_add'),
|
||||||
path('planning/task/add/', ProductionTaskCreateView.as_view(), name='task_add'),
|
path('planning/task/add/', ProductionTaskCreateView.as_view(), name='task_add'),
|
||||||
path('planning/deal/<int:pk>/json/', DealDetailView.as_view(), name='deal_json'),
|
path('planning/deal/<int:pk>/json/', DealDetailView.as_view(), name='deal_json'),
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from urllib.parse import urlsplit
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
|
import os
|
||||||
|
from django.contrib import messages
|
||||||
|
from django.core.files.base import ContentFile
|
||||||
|
|
||||||
from django.db.models import Case, ExpressionWrapper, F, IntegerField, Sum, Value, When
|
from django.db.models import Case, ExpressionWrapper, F, IntegerField, Sum, Value, When
|
||||||
from django.db.models.functions import Coalesce
|
from django.db.models.functions import Coalesce
|
||||||
from django.http import JsonResponse
|
from django.http import JsonResponse
|
||||||
@@ -14,7 +18,176 @@ from django.utils import timezone
|
|||||||
from warehouse.models import Material, MaterialCategory, SteelGrade
|
from warehouse.models import Material, MaterialCategory, SteelGrade
|
||||||
|
|
||||||
from .forms import ProductionTaskCreateForm
|
from .forms import ProductionTaskCreateForm
|
||||||
from .models import Company, Deal, Item, Machine, ProductionTask
|
from .models import Company, Deal, DxfPreviewSettings, EmployeeProfile, Item, Machine, ProductionTask
|
||||||
|
|
||||||
|
|
||||||
|
def _get_dxf_preview_settings() -> DxfPreviewSettings:
|
||||||
|
"""Возвращает (и при необходимости создаёт) настройки превью DXF.
|
||||||
|
|
||||||
|
Мы храним настройки в БД, чтобы админ мог менять их в интерфейсе.
|
||||||
|
Ожидаем одну запись (singleton), используем pk=1.
|
||||||
|
"""
|
||||||
|
obj, _ = DxfPreviewSettings.objects.get_or_create(pk=1)
|
||||||
|
return obj
|
||||||
|
|
||||||
|
|
||||||
|
def _render_dxf_preview_png(
|
||||||
|
dxf_path: str,
|
||||||
|
*,
|
||||||
|
line_color: str,
|
||||||
|
lineweight_scaling: float,
|
||||||
|
min_lineweight_mm: float,
|
||||||
|
keep_original_colors: bool,
|
||||||
|
) -> bytes:
|
||||||
|
"""Рендерит DXF в PNG (байты) с заданными параметрами.
|
||||||
|
|
||||||
|
Зачем это нужно:
|
||||||
|
- браузер не умеет стабильно показывать DXF как "превью";
|
||||||
|
- поэтому мы генерируем PNG на сервере и уже её показываем в интерфейсе.
|
||||||
|
|
||||||
|
Требуемые зависимости:
|
||||||
|
- ezdxf
|
||||||
|
- matplotlib (backend Agg)
|
||||||
|
|
||||||
|
Если зависимости не установлены — бросаем исключение с понятным текстом.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Важно: используем headless-backend, чтобы рендер работал без GUI (на сервере/в Docker).
|
||||||
|
import matplotlib
|
||||||
|
matplotlib.use('Agg')
|
||||||
|
import matplotlib.pyplot as plt
|
||||||
|
|
||||||
|
# ezdxf читает DXF и умеет отрисовывать через drawing add-on.
|
||||||
|
from ezdxf import recover
|
||||||
|
from ezdxf.addons.drawing import RenderContext, Frontend
|
||||||
|
from ezdxf.addons.drawing.matplotlib import MatplotlibBackend
|
||||||
|
from ezdxf.addons.drawing import config as draw_config
|
||||||
|
except Exception as e:
|
||||||
|
raise RuntimeError('Не установлены зависимости для превью DXF: ezdxf и matplotlib') from e
|
||||||
|
|
||||||
|
if not dxf_path or not os.path.exists(dxf_path):
|
||||||
|
raise FileNotFoundError('DXF файл не найден')
|
||||||
|
|
||||||
|
# Безопасное чтение DXF (recover умеет поднимать часть повреждённых файлов).
|
||||||
|
doc, auditor = recover.readfile(dxf_path)
|
||||||
|
if auditor and getattr(auditor, 'has_errors', False):
|
||||||
|
# Даже при ошибках структуры часто удаётся получить картинку — поэтому не прерываем.
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Настройка итогового вида превью:
|
||||||
|
# - прозрачный фон (чтобы хорошо смотрелось на тёмной теме)
|
||||||
|
# - цвет/толщина линии задаются настройками (см. «Обслуживание сервера»)
|
||||||
|
|
||||||
|
# Конвертируем мм в единицы ezdxf (min_lineweight хранится в 1/300 inch).
|
||||||
|
# Формула: мм / 25.4 * 300
|
||||||
|
min_lineweight = int(max(0.0, float(min_lineweight_mm)) / 25.4 * 300)
|
||||||
|
|
||||||
|
# Конфигурация рендера: управляем толщиной линий.
|
||||||
|
cfg = draw_config.Configuration(
|
||||||
|
lineweight_scaling=float(lineweight_scaling),
|
||||||
|
min_lineweight=min_lineweight,
|
||||||
|
)
|
||||||
|
|
||||||
|
class PreviewFrontend(Frontend):
|
||||||
|
"""Переопределяет свойства сущностей перед отрисовкой.
|
||||||
|
|
||||||
|
Если keep_original_colors=True — оставляем цвета DXF.
|
||||||
|
Иначе принудительно красим все линии в заданный line_color.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def override_properties(self, entity, properties) -> None:
|
||||||
|
if not keep_original_colors:
|
||||||
|
properties.color = line_color
|
||||||
|
|
||||||
|
fig = plt.figure(figsize=(5, 3), dpi=160)
|
||||||
|
ax = fig.add_axes([0, 0, 1, 1])
|
||||||
|
ax.set_axis_off()
|
||||||
|
ax.margins(0)
|
||||||
|
|
||||||
|
# Прозрачность фона фигуры и осей.
|
||||||
|
fig.patch.set_alpha(0)
|
||||||
|
ax.set_facecolor((0, 0, 0, 0))
|
||||||
|
|
||||||
|
ctx = RenderContext(doc)
|
||||||
|
out = MatplotlibBackend(ax)
|
||||||
|
PreviewFrontend(ctx, out, config=cfg).draw_layout(doc.modelspace(), finalize=True)
|
||||||
|
|
||||||
|
import io
|
||||||
|
buf = io.BytesIO()
|
||||||
|
fig.savefig(
|
||||||
|
buf,
|
||||||
|
format='png',
|
||||||
|
dpi=160,
|
||||||
|
bbox_inches='tight',
|
||||||
|
pad_inches=0.02,
|
||||||
|
transparent=True,
|
||||||
|
)
|
||||||
|
plt.close(fig)
|
||||||
|
buf.seek(0)
|
||||||
|
return buf.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_dxf_dimensions(dxf_path: str) -> str:
|
||||||
|
"""Возвращает строку габаритов заготовки вида «300х456 мм».
|
||||||
|
|
||||||
|
Используем ezdxf.bbox.extents(), который корректно учитывает дуги/сплайны и вложенные блоки.
|
||||||
|
Пытаемся учитывать единицы: если в заголовке $INSUNITS указаны дюймы/см/м — конвертируем в мм.
|
||||||
|
"""
|
||||||
|
import ezdxf
|
||||||
|
from ezdxf import bbox as dzbbox
|
||||||
|
|
||||||
|
doc = ezdxf.readfile(dxf_path)
|
||||||
|
msp = doc.modelspace()
|
||||||
|
|
||||||
|
# Коэффициент перевода в мм по $INSUNITS
|
||||||
|
units = int(doc.header.get('$INSUNITS', 0) or 0)
|
||||||
|
factor = {1: 25.4, 4: 1.0, 5: 10.0, 6: 1000.0}.get(units, 1.0)
|
||||||
|
|
||||||
|
extent = dzbbox.extents(msp, cache=dzbbox.Cache())
|
||||||
|
if not extent.has_data:
|
||||||
|
return ''
|
||||||
|
(min_x, min_y, _), (max_x, max_y, _) = extent.min, extent.max
|
||||||
|
width = (max_x - min_x) * factor
|
||||||
|
height = (max_y - min_y) * factor
|
||||||
|
return f"{round(width, 3)}х{round(height, 3)} мм"
|
||||||
|
|
||||||
|
|
||||||
|
def _update_task_preview(task: ProductionTask) -> bool:
|
||||||
|
"""Обновляет превью PNG и габариты из DXF для одной детали.
|
||||||
|
|
||||||
|
Использует текущие настройки из DxfPreviewSettings.
|
||||||
|
"""
|
||||||
|
if not task.drawing_file:
|
||||||
|
return False
|
||||||
|
|
||||||
|
name = (task.drawing_file.name or '').lower()
|
||||||
|
if not name.endswith('.dxf'):
|
||||||
|
# Если не DXF — превью не делаем и очищаем габариты
|
||||||
|
task.preview_image = None
|
||||||
|
task.blank_dimensions = ''
|
||||||
|
task.save(update_fields=['preview_image', 'blank_dimensions'])
|
||||||
|
return False
|
||||||
|
|
||||||
|
dxf_path = getattr(task.drawing_file, 'path', '')
|
||||||
|
settings = _get_dxf_preview_settings()
|
||||||
|
png_bytes = _render_dxf_preview_png(
|
||||||
|
dxf_path,
|
||||||
|
line_color=settings.line_color,
|
||||||
|
lineweight_scaling=settings.lineweight_scaling,
|
||||||
|
min_lineweight_mm=settings.min_lineweight,
|
||||||
|
keep_original_colors=settings.keep_original_colors,
|
||||||
|
)
|
||||||
|
dims = ''
|
||||||
|
try:
|
||||||
|
dims = _extract_dxf_dimensions(dxf_path)
|
||||||
|
except Exception:
|
||||||
|
dims = ''
|
||||||
|
|
||||||
|
filename = f"task_{task.id}_preview.png"
|
||||||
|
task.preview_image.save(filename, ContentFile(png_bytes), save=False)
|
||||||
|
task.blank_dimensions = dims
|
||||||
|
task.save(update_fields=['preview_image', 'blank_dimensions'])
|
||||||
|
return True
|
||||||
|
|
||||||
# Класс главной страницы (роутер)
|
# Класс главной страницы (роутер)
|
||||||
class IndexView(TemplateView):
|
class IndexView(TemplateView):
|
||||||
@@ -362,6 +535,71 @@ class TaskItemsView(LoginRequiredMixin, TemplateView):
|
|||||||
return context
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
class MaintenanceView(LoginRequiredMixin, TemplateView):
|
||||||
|
template_name = 'shiftflow/maintenance.html'
|
||||||
|
|
||||||
|
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 != 'admin':
|
||||||
|
return redirect('registry')
|
||||||
|
return super().dispatch(request, *args, **kwargs)
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
context['user_role'] = 'admin'
|
||||||
|
|
||||||
|
# Подтягиваем текущие настройки генерации превью, чтобы отрисовать форму.
|
||||||
|
s = _get_dxf_preview_settings()
|
||||||
|
context['dxf_settings'] = s
|
||||||
|
return context
|
||||||
|
|
||||||
|
def post(self, request, *args, **kwargs):
|
||||||
|
# На странице обслуживания есть 2 действия:
|
||||||
|
# 1) сохранить настройки превью
|
||||||
|
# 2) сохранить настройки и обновить превью по сделкам в статусах lead/work
|
||||||
|
action = (request.POST.get('action') or '').strip()
|
||||||
|
|
||||||
|
# Сохраняем настройки (даже если жмём «Обновить» — чтобы применить их сразу).
|
||||||
|
s = _get_dxf_preview_settings()
|
||||||
|
s.line_color = (request.POST.get('line_color') or s.line_color).strip() or s.line_color
|
||||||
|
try:
|
||||||
|
s.lineweight_scaling = float(request.POST.get('lineweight_scaling', s.lineweight_scaling))
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
s.min_lineweight = float(request.POST.get('min_lineweight', s.min_lineweight))
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
s.keep_original_colors = bool(request.POST.get('keep_original_colors'))
|
||||||
|
s.save()
|
||||||
|
|
||||||
|
if action != 'update_previews':
|
||||||
|
messages.success(request, 'Настройки превью сохранены.')
|
||||||
|
return redirect('maintenance')
|
||||||
|
|
||||||
|
# Обновляем превью только для сделок в статусах «Зашла» и «В работе».
|
||||||
|
deal_statuses = ['lead', 'work']
|
||||||
|
tasks = ProductionTask.objects.select_related('deal').filter(deal__status__in=deal_statuses)
|
||||||
|
|
||||||
|
updated = 0
|
||||||
|
skipped = 0
|
||||||
|
errors = 0
|
||||||
|
|
||||||
|
for task in tasks:
|
||||||
|
try:
|
||||||
|
if _update_task_preview(task):
|
||||||
|
updated += 1
|
||||||
|
else:
|
||||||
|
skipped += 1
|
||||||
|
except Exception:
|
||||||
|
errors += 1
|
||||||
|
|
||||||
|
messages.success(request, f"Превью обновлены: {updated}. Пропущено: {skipped}. Ошибок: {errors}.")
|
||||||
|
return redirect('maintenance')
|
||||||
|
|
||||||
|
|
||||||
class CustomersView(LoginRequiredMixin, TemplateView):
|
class CustomersView(LoginRequiredMixin, TemplateView):
|
||||||
template_name = 'shiftflow/customers.html'
|
template_name = 'shiftflow/customers.html'
|
||||||
|
|
||||||
@@ -734,8 +972,41 @@ class ItemUpdateView(LoginRequiredMixin, UpdateView):
|
|||||||
return redirect('registry')
|
return redirect('registry')
|
||||||
|
|
||||||
if role in ['admin', 'technologist']:
|
if role in ['admin', 'technologist']:
|
||||||
|
# Действие формы (обычное сохранение или закрытие позиции)
|
||||||
action = request.POST.get('action', 'save')
|
action = request.POST.get('action', 'save')
|
||||||
|
|
||||||
|
# Админ может заменить файлы у детали прямо из карточки пункта сменки.
|
||||||
|
# Файлы лежат на ProductionTask (основании), а не на Item.
|
||||||
|
if role == 'admin' and self.object.task_id:
|
||||||
|
# Админ может заменить файлы детали. После замены:
|
||||||
|
# - сбрасываем превью;
|
||||||
|
# - пытаемся сразу извлечь габариты из DXF.
|
||||||
|
task = self.object.task
|
||||||
|
drawing_file = request.FILES.get('drawing_file')
|
||||||
|
extra_drawing = request.FILES.get('extra_drawing')
|
||||||
|
changed = False
|
||||||
|
if drawing_file is not None:
|
||||||
|
task.drawing_file = drawing_file
|
||||||
|
changed = True
|
||||||
|
if extra_drawing is not None:
|
||||||
|
task.extra_drawing = extra_drawing
|
||||||
|
changed = True
|
||||||
|
if changed:
|
||||||
|
task.preview_image = None
|
||||||
|
# Переcчёт габаритов, если это DXF
|
||||||
|
dims = ''
|
||||||
|
try:
|
||||||
|
if drawing_file is not None and (drawing_file.name or '').lower().endswith('.dxf'):
|
||||||
|
# временно сохраняем файл на объекте до .save()
|
||||||
|
pass
|
||||||
|
path = getattr(task.drawing_file, 'path', '')
|
||||||
|
if path and path.lower().endswith('.dxf'):
|
||||||
|
dims = _extract_dxf_dimensions(path)
|
||||||
|
except Exception:
|
||||||
|
dims = ''
|
||||||
|
task.blank_dimensions = dims
|
||||||
|
task.save()
|
||||||
|
|
||||||
machine_id = request.POST.get('machine')
|
machine_id = request.POST.get('machine')
|
||||||
if machine_id and machine_id.isdigit():
|
if machine_id and machine_id.isdigit():
|
||||||
self.object.machine_id = int(machine_id)
|
self.object.machine_id = int(machine_id)
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
<a class="nav-link {% if request.resolver_match.url_name == 'customers' or request.resolver_match.url_name == 'customer_deals' %}active{% endif %}" href="{% url 'customers' %}">Заказчик</a>
|
<a class="nav-link {% if request.resolver_match.url_name == 'customers' or request.resolver_match.url_name == 'customer_deals' %}active{% endif %}" href="{% url 'customers' %}">Заказчик</a>
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if user_role in 'admin,technologist,master,operator' %}
|
{% if user_role in 'admin,technologist,master,operator' %}
|
||||||
<li class="nav-item"><a class="nav-link" href="#">Закрытие</a></li>
|
<li class="nav-item"><a class="nav-link" href="#">Закрытие</a></li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -31,6 +31,12 @@
|
|||||||
{% if user_role in 'admin,technologist,clerk' %}
|
{% if user_role in 'admin,technologist,clerk' %}
|
||||||
<li class="nav-item"><a class="nav-link" href="#">Списание</a></li>
|
<li class="nav-item"><a class="nav-link" href="#">Списание</a></li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{% if user_role == 'admin' %}
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link {% if request.resolver_match.url_name == 'maintenance' %}active{% endif %}" href="{% url 'maintenance' %}">Обслуживание сервера</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div class="d-flex align-items-center gap-2 mt-lg-0 mt-3">
|
<div class="d-flex align-items-center gap-2 mt-lg-0 mt-3">
|
||||||
|
|||||||
Reference in New Issue
Block a user