Сортировка в таблицах и попытка приструнить генерацию превьюшек
All checks were successful
Deploy MES Core / deploy (push) Successful in 13s

This commit is contained in:
2026-04-03 01:10:05 +03:00
parent cddbfeadde
commit b76ce4913f
16 changed files with 722 additions and 34 deletions

View File

View File

@@ -0,0 +1,162 @@
import multiprocessing
import os
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 = ""
job.save(update_fields=["status", "started_at", "finished_at", "last_message"])
# Берём настройки таймаута из БД.
settings, _ = DxfPreviewSettings.objects.get_or_create(pk=1)
per_task_timeout = int(getattr(settings, 'per_task_timeout_sec', 45) or 45)
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
# Таймаут обработки одной детали (сек).
# Если конкретный DXF «залип» — задача не должна блокироваться навсегда.
per_task_timeout = 45
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()
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
close_old_connections()
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:
DxfPreviewJob.objects.filter(pk=job_id).update(
status="failed",
finished_at=timezone.now(),
last_message="Задача завершилась с ошибкой (см. логи процесса).",
)
finally:
close_old_connections()

View File

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

View File

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

View File

@@ -117,6 +117,12 @@ class DxfPreviewSettings(models.Model):
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:
@@ -127,6 +133,64 @@ class DxfPreviewSettings(models.Model):
return "Настройки превью DXF"
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):
"""
Единица сменного задания. Определяет КТО, КОГДА и СКОЛЬКО сделал.

View File

@@ -38,7 +38,7 @@
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0 align-middle">
<table class="table table-hover mb-0 align-middle" data-sortable="1">
<thead>
<tr class="table-custom-header">
<th>Сделка</th>

View File

@@ -13,7 +13,7 @@
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0 align-middle">
<table class="table table-hover mb-0 align-middle" data-sortable="1">
<thead>
<tr class="table-custom-header">
<th>Заказчик</th>

View File

@@ -17,6 +17,13 @@
<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>
<form method="post" class="row g-3 align-items-end">
{% csrf_token %}
@@ -42,6 +49,11 @@
</div>
</div>
<div class="col-md-3">
<label class="small text-muted">Таймаут на 1 DXF (сек)</label>
<input type="number" step="1" min="5" name="per_task_timeout_sec" class="form-control border-secondary" 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>Сохранить настройки
@@ -49,6 +61,9 @@
<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>
</div>
<div class="col-12">
@@ -67,6 +82,44 @@
{% 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');
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 (data.job.status === 'running' || data.job.status === 'queued') {
setTimeout(tick, 3000);
}
} catch (e) {
}
}
setTimeout(tick, 400);
})();
</script>
</div>
</div>
{% endblock %}

View File

@@ -1,18 +1,18 @@
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0 align-middle">
<table class="table table-hover mb-0 align-middle" data-sortable="1">
<thead>
<tr class="table-custom-header">
<th>Дата</th>
<th data-sort-type="date">Дата</th>
<th>Сделка</th>
<th>Станок</th>
<th>Наименование</th>
<th>Габариты</th>
<th style="width: 160px;">Прогресс</th>
<th>План / Факт</th>
<th data-sort-type="number">Габариты</th>
<th data-sort="false" style="width: 160px;">Прогресс</th>
<th data-sort-type="number">План / Факт</th>
<th>Материал</th>
<th class="text-center">Файлы</th>
<th class="text-center">1С</th>
<th data-sort="false" class="text-center">Файлы</th>
<th data-sort="false" class="text-center">1С</th>
<th>Статус</th>
</tr>
</thead>

View File

@@ -28,7 +28,7 @@
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0 align-middle">
<table class="table table-hover mb-0 align-middle" data-sortable="1">
<thead>
<tr class="table-custom-header">
<th>Сделка</th>

View File

@@ -30,17 +30,17 @@
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0 align-middle">
<table class="table table-hover mb-0 align-middle" data-sortable="1">
<thead>
<tr class="table-custom-header">
<th>Деталь</th>
<th>Материал</th>
<th>Размер</th>
<th style="width: 160px;">Прогресс</th>
<th data-sort="false" style="width: 160px;">Прогресс</th>
<th class="text-center">Надо / Сделано / В плане</th>
<th class="text-center">Осталось</th>
<th class="text-center">Файлы</th>
<th class="text-end">Действия</th>
<th data-sort="false" class="text-center">Файлы</th>
<th data-sort="false" class="text-end">Действия</th>
</tr>
</thead>
<tbody>

View File

@@ -312,6 +312,8 @@ document.addEventListener('DOMContentLoaded', function () {
const drawingFile = document.getElementById('id_drawing_file');
const extraDrawing = document.getElementById('id_extra_drawing');
const drawingName = document.getElementById('id_drawing_name');
const qtyOrdered = document.getElementById('id_quantity_ordered');
const sizeValue = document.getElementById('id_size_value');
const fillFromSource = document.getElementById('fillNameFromSource');
const fillFromPdf = document.getElementById('fillNameFromPdf');
@@ -378,12 +380,46 @@ document.addEventListener('DOMContentLoaded', function () {
if (fillFromPdf) fillFromPdf.disabled = !hasPdf;
}
function tryFillFromDxfFilename(filename) {
if (!filename) return false;
const isDxf = filename.toLowerCase().endsWith('.dxf');
if (!isDxf) return false;
// Поддерживаем паттерн в имени файла вида "-s2n45".
// На практике встречаются варианты: "s2n45", "-s2_n45", "_s2-n45" и т.п.
// Поэтому ищем "s<число> ... n<целое>" в любом месте имени, разрешая разделители между ними.
const base = filenameBase(filename);
const normalized = base.replace(/\s+/g, '');
const m = normalized.match(/s([0-9]+(?:[\.,][0-9]+)?)[^0-9a-zA-Z]*n([0-9]+)/);
if (!m) return false;
const s = (m[1] || '').replace(',', '.');
const n = (m[2] || '').trim();
// По твоей просьбе: перезатираем значения, даже если пользователь что-то уже вводил.
if (sizeValue) {
sizeValue.value = s;
sizeValue.dispatchEvent(new Event('input', { bubbles: true }));
}
if (qtyOrdered) {
qtyOrdered.value = n;
qtyOrdered.dispatchEvent(new Event('input', { bubbles: true }));
}
return true;
}
if (drawingFile) drawingFile.addEventListener('change', function () {
updateFileButtons();
if (!drawingName) return;
if (drawingName.value && drawingName.value.trim() !== '') return;
if (!drawingFile.files || drawingFile.files.length === 0) return;
drawingName.value = filenameBase(drawingFile.files[0].name);
const fname = drawingFile.files[0].name;
// По твоей просьбе: при выборе исходника перезаполняем «Наименование» из имени файла.
if (drawingName) drawingName.value = filenameBase(fname);
// Пытаемся распарсить «Размер» и «Требуется» из имени DXF.
tryFillFromDxfFilename(fname);
});
if (extraDrawing) extraDrawing.addEventListener('change', updateFileButtons);
updateFileButtons();
@@ -391,8 +427,12 @@ document.addEventListener('DOMContentLoaded', function () {
if (fillFromSource) {
fillFromSource.addEventListener('click', function () {
if (!drawingFile || !drawingFile.files || drawingFile.files.length === 0) return;
const base = filenameBase(drawingFile.files[0].name);
const fname = drawingFile.files[0].name;
const base = filenameBase(fname);
// Кнопка также перезаполняет значения из имени исходника.
if (drawingName) drawingName.value = base;
tryFillFromDxfFilename(fname);
});
}

View File

@@ -8,6 +8,7 @@ from .views import (
DealUpsertView,
IndexView,
ItemUpdateView,
MaintenanceStatusView,
MaintenanceView,
MaterialCategoryUpsertView,
MaterialDetailView,
@@ -34,6 +35,7 @@ urlpatterns = [
path('customers/', CustomersView.as_view(), name='customers'),
path('customers/<int:pk>/', CustomerDealsView.as_view(), name='customer_deals'),
path('maintenance/', MaintenanceView.as_view(), name='maintenance'),
path('maintenance/status/', MaintenanceStatusView.as_view(), name='maintenance_status'),
path('planning/add/', PlanningAddView.as_view(), name='planning_add'),
path('planning/task/add/', ProductionTaskCreateView.as_view(), name='task_add'),
path('planning/deal/<int:pk>/json/', DealDetailView.as_view(), name='deal_json'),

View File

@@ -2,8 +2,12 @@ from datetime import datetime
from urllib.parse import urlsplit
import os
import subprocess
import sys
from django.contrib import messages
from django.core.files.base import ContentFile
from django.db import close_old_connections
from django.db.models import Case, ExpressionWrapper, F, IntegerField, Sum, Value, When
from django.db.models.functions import Coalesce
@@ -18,7 +22,7 @@ from django.utils import timezone
from warehouse.models import Material, MaterialCategory, SteelGrade
from .forms import ProductionTaskCreateForm
from .models import Company, Deal, DxfPreviewSettings, EmployeeProfile, Item, Machine, ProductionTask
from .models import Company, Deal, DxfPreviewJob, DxfPreviewSettings, EmployeeProfile, Item, Machine, ProductionTask
def _get_dxf_preview_settings() -> DxfPreviewSettings:
@@ -156,6 +160,9 @@ def _update_task_preview(task: ProductionTask) -> bool:
"""Обновляет превью PNG и габариты из DXF для одной детали.
Использует текущие настройки из DxfPreviewSettings.
Важно: функция может выполняться "тяжело" (рендер + bbox), поэтому её удобно
вызывать из фонового потока, чтобы не блокировать HTTP-ответ.
"""
if not task.drawing_file:
return False
@@ -535,6 +542,138 @@ class TaskItemsView(LoginRequiredMixin, TemplateView):
return context
def _run_dxf_preview_job(job_id: int) -> None:
"""Выполняет задачу пакетной регенерации превью в фоне.
Пишем прогресс в DxfPreviewJob, чтобы UI мог показывать результаты.
"""
try:
close_old_connections()
job = DxfPreviewJob.objects.get(pk=job_id)
except Exception:
return
job.status = 'running'
job.started_at = timezone.now()
job.last_message = ''
job.save(update_fields=['status', 'started_at', 'last_message'])
# Берём только сделки в статусах «Зашла» и «В работе»
deal_statuses = ['lead', 'work']
qs = ProductionTask.objects.select_related('deal').filter(deal__status__in=deal_statuses)
try:
total = qs.count()
except Exception:
total = 0
job.total = total
job.processed = 0
job.updated = 0
job.skipped = 0
job.errors = 0
job.save(update_fields=['total', 'processed', 'updated', 'skipped', 'errors'])
# iterator() уменьшает потребление памяти на больших выборках
processed = 0
updated = 0
skipped = 0
errors = 0
for task in qs.iterator(chunk_size=50):
try:
if _update_task_preview(task):
updated += 1
else:
skipped += 1
except Exception:
errors += 1
processed += 1
# Обновляем прогресс периодически, чтобы не делать save() на каждую запись
if processed % 10 == 0:
DxfPreviewJob.objects.filter(pk=job_id).update(
processed=processed,
updated=updated,
skipped=skipped,
errors=errors,
)
status = 'done' if errors == 0 else 'done'
last_message = f"Превью обновлены: {updated}. Пропущено: {skipped}. Ошибок: {errors}."
DxfPreviewJob.objects.filter(pk=job_id).update(
status=status,
finished_at=timezone.now(),
processed=processed,
updated=updated,
skipped=skipped,
errors=errors,
last_message=last_message,
)
try:
close_old_connections()
except Exception:
pass
def _mark_stale_preview_jobs() -> None:
"""Помечает «залипшие» задачи превью как failed.
Почему это нужно:
- генерация превью запускается в фоне (поток/процесс);
- если сервер перезапустили или процесс был убит, job может навсегда остаться в queued/running;
- из-за этого UI пишет «уже запущено» и прогресс не двигается.
Правило:
- если job в queued/running и нет finished_at, и он слишком долго не двигается — считаем его умершим.
"""
now = timezone.now()
# Лимит «жизнеспособности» задачи. Можно подстроить.
stale_after = timezone.timedelta(minutes=5)
qs = DxfPreviewJob.objects.filter(status__in=['queued', 'running'], finished_at__isnull=True)
for job in qs[:10]:
# queued без started_at тоже может остаться после рестарта
ref_time = job.started_at or job.created_at
if ref_time and (now - ref_time) > stale_after:
job.status = 'failed'
job.finished_at = now
job.last_message = 'Задача помечена как зависшая (сервер был перезапущен или процесс остановлен).'
job.save(update_fields=['status', 'finished_at', 'last_message'])
class MaintenanceStatusView(LoginRequiredMixin, View):
def get(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 JsonResponse({'error': 'forbidden'}, status=403)
# Перед отдачей статуса пытаемся снять «залипшие» задачи.
_mark_stale_preview_jobs()
job = DxfPreviewJob.objects.order_by('-id').first()
if not job:
return JsonResponse({'job': None})
return JsonResponse({
'job': {
'id': job.id,
'status': job.status,
'status_label': job.get_status_display(),
'total': job.total,
'processed': job.processed,
'updated': job.updated,
'skipped': job.skipped,
'errors': job.errors,
'cancel_requested': job.cancel_requested,
'last_message': job.last_message,
}
})
class MaintenanceView(LoginRequiredMixin, TemplateView):
template_name = 'shiftflow/maintenance.html'
@@ -553,6 +692,9 @@ class MaintenanceView(LoginRequiredMixin, TemplateView):
# Подтягиваем текущие настройки генерации превью, чтобы отрисовать форму.
s = _get_dxf_preview_settings()
context['dxf_settings'] = s
# Последняя фоновая задача (для вывода статуса на странице)
context['last_job'] = DxfPreviewJob.objects.order_by('-id').first()
return context
def post(self, request, *args, **kwargs):
@@ -573,30 +715,63 @@ class MaintenanceView(LoginRequiredMixin, TemplateView):
except ValueError:
pass
s.keep_original_colors = bool(request.POST.get('keep_original_colors'))
# Таймаут на обработку одной детали (в секундах).
# Используется в management-команде, чтобы «плохой» DXF не блокировал всю задачу.
try:
s.per_task_timeout_sec = int(request.POST.get('per_task_timeout_sec', s.per_task_timeout_sec))
except ValueError:
pass
s.save()
if action == 'cancel_job':
# Мягкая остановка: помечаем текущую задачу флагом cancel_requested.
# Воркер завершит работу после текущей детали и поставит статус cancelled.
job = DxfPreviewJob.objects.filter(status__in=['queued', 'running'], finished_at__isnull=True).order_by('-id').first()
if not job:
messages.info(request, 'Активной задачи нет.')
return redirect('maintenance')
job.cancel_requested = True
job.last_message = 'Запрошена остановка. Ожидаем завершения текущей детали.'
job.save(update_fields=['cancel_requested', 'last_message'])
messages.success(request, 'Остановка запрошена.')
return redirect('maintenance')
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)
# Перед проверкой «уже запущено» снимаем залипшие задачи (например после перезапуска сервера).
_mark_stale_preview_jobs()
updated = 0
skipped = 0
errors = 0
# Если уже есть выполняющаяся задача — не запускаем вторую, чтобы не перегружать сервер.
running = DxfPreviewJob.objects.filter(status__in=['queued', 'running'], finished_at__isnull=True).exists()
if running:
messages.warning(request, 'Обновление уже запущено. Дождись завершения текущей задачи.')
return redirect('maintenance')
for task in tasks:
try:
if _update_task_preview(task):
updated += 1
else:
skipped += 1
except Exception:
errors += 1
# Запускаем регенерацию в отдельном процессе через management-команду.
# Причина: рендер DXF и bbox нагружают CPU и могут «тормозить» веб‑процесс из-за GIL,
# даже если запускать в потоке.
job = DxfPreviewJob.objects.create(status='queued', created_by=request.user)
try:
subprocess.Popen(
[sys.executable, 'manage.py', 'dxf_preview_job', str(job.id)],
cwd=os.path.dirname(os.path.dirname(__file__)),
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
close_fds=True,
)
messages.success(request, 'Запущено обновление превью DXF в фоне. Прогресс обновляется ниже.')
except Exception:
job.status = 'failed'
job.last_message = 'Не удалось запустить фоновый процесс генерации превью.'
job.save(update_fields=['status', 'last_message'])
messages.error(request, 'Не удалось запустить обновление превью DXF.')
messages.success(request, f"Превью обновлены: {updated}. Пропущено: {skipped}. Ошибок: {errors}.")
return redirect('maintenance')
@@ -731,6 +906,23 @@ class ProductionTaskCreateView(LoginRequiredMixin, FormView):
task.save()
# Генерация превью/габаритов может занимать время (особенно на больших DXF).
# Поэтому запускаем её в фоне и НЕ блокируем сохранение/редирект.
def _bg(task_id: int) -> None:
try:
close_old_connections()
t = ProductionTask.objects.get(pk=task_id)
_update_task_preview(t)
except Exception:
pass
finally:
try:
close_old_connections()
except Exception:
pass
threading.Thread(target=_bg, args=(task.id,), daemon=True).start()
next_url = (self.request.POST.get('next') or '').strip()
if next_url.startswith('/'):
return redirect(next_url)

View File

@@ -38,10 +38,114 @@
localStorage.setItem('theme', newTheme);
updateThemeIcon(newTheme);
}
function sfParseDate(text) {
const s = (text || '').trim();
if (!s) return null;
if (/^\d{4}-\d{2}-\d{2}$/.test(s)) {
const d = new Date(s + 'T00:00:00');
return isNaN(d.getTime()) ? null : d;
}
const m = s.match(/^(\d{1,2})\.(\d{1,2})\.(\d{2}|\d{4})$/);
if (m) {
const dd = parseInt(m[1], 10);
const mm = parseInt(m[2], 10) - 1;
let yy = parseInt(m[3], 10);
if (yy < 100) yy += 2000;
const d = new Date(yy, mm, dd);
return isNaN(d.getTime()) ? null : d;
}
return null;
}
function sfParseNumber(text) {
const s = (text || '').toString().trim();
if (!s) return null;
const cleaned = s
.replace(/\s+/g, '')
.replace(/,/g, '.')
.replace(/[^0-9.\-]/g, '');
if (!cleaned || cleaned === '-' || cleaned === '.') return null;
const n = parseFloat(cleaned);
return isNaN(n) ? null : n;
}
function sfMakeSortable(table) {
const thead = table.querySelector('thead');
const tbody = table.querySelector('tbody');
if (!thead || !tbody) return;
const ths = Array.from(thead.querySelectorAll('th'));
ths.forEach((th, idx) => {
if ((th.getAttribute('data-sort') || '').toLowerCase() === 'false') return;
th.style.cursor = 'pointer';
th.addEventListener('click', () => {
const cur = table.getAttribute('data-sort-col');
const sameCol = cur !== null && String(idx) === String(cur);
const dir = sameCol && table.getAttribute('data-sort-dir') === 'asc' ? 'desc' : 'asc';
table.setAttribute('data-sort-col', String(idx));
table.setAttribute('data-sort-dir', dir);
const rows = Array.from(tbody.querySelectorAll('tr'));
// Комментарий: сортировка делается на клиенте. Мы просто переупорядочиваем строки в tbody.
// Это работает для всех таблиц, где разметка уже готова, без переписывания вьюх.
rows.sort((a, b) => {
const aCell = a.children[idx];
const bCell = b.children[idx];
const aText = (aCell ? aCell.textContent : '').trim();
const bText = (bCell ? bCell.textContent : '').trim();
const type = (th.getAttribute('data-sort-type') || '').toLowerCase();
if (type === 'number') {
const an = sfParseNumber(aText);
const bn = sfParseNumber(bText);
if (an === null && bn === null) return 0;
if (an === null) return dir === 'asc' ? 1 : -1;
if (bn === null) return dir === 'asc' ? -1 : 1;
return dir === 'asc' ? (an - bn) : (bn - an);
}
if (type === 'date') {
const ad = sfParseDate(aText);
const bd = sfParseDate(bText);
const at = ad ? ad.getTime() : null;
const bt = bd ? bd.getTime() : null;
if (at === null && bt === null) return 0;
if (at === null) return dir === 'asc' ? 1 : -1;
if (bt === null) return dir === 'asc' ? -1 : 1;
return dir === 'asc' ? (at - bt) : (bt - at);
}
// Попытка автоматически понять тип
const an = sfParseNumber(aText);
const bn = sfParseNumber(bText);
if (an !== null && bn !== null) return dir === 'asc' ? (an - bn) : (bn - an);
const ad = sfParseDate(aText);
const bd = sfParseDate(bText);
if (ad && bd) return dir === 'asc' ? (ad.getTime() - bd.getTime()) : (bd.getTime() - ad.getTime());
const cmp = aText.localeCompare(bText, 'ru', { numeric: true, sensitivity: 'base' });
return dir === 'asc' ? cmp : -cmp;
});
const frag = document.createDocumentFragment();
rows.forEach(r => frag.appendChild(r));
tbody.appendChild(frag);
});
});
}
document.addEventListener('DOMContentLoaded', () => {
const savedTheme = localStorage.getItem('theme') || 'dark';
document.documentElement.setAttribute('data-bs-theme', savedTheme);
updateThemeIcon(savedTheme);
// Включаем сортировку для таблиц, которые явно помечены data-sortable="1".
document.querySelectorAll('table[data-sortable="1"]').forEach(sfMakeSortable);
});
</script>
</body>