From b76ce4913f97b3b39262335ad60f54d9d4295b21 Mon Sep 17 00:00:00 2001 From: ackFromRedmi Date: Fri, 3 Apr 2026 01:10:05 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A1=D0=BE=D1=80=D1=82=D0=B8=D1=80=D0=BE?= =?UTF-8?q?=D0=B2=D0=BA=D0=B0=20=D0=B2=20=D1=82=D0=B0=D0=B1=D0=BB=D0=B8?= =?UTF-8?q?=D1=86=D0=B0=D1=85=20=D0=B8=20=D0=BF=D0=BE=D0=BF=D1=8B=D1=82?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=BF=D1=80=D0=B8=D1=81=D1=82=D1=80=D1=83=D0=BD?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20=D0=B3=D0=B5=D0=BD=D0=B5=D1=80=D0=B0=D1=86?= =?UTF-8?q?=D0=B8=D1=8E=20=D0=BF=D1=80=D0=B5=D0=B2=D1=8C=D1=8E=D1=88=D0=B5?= =?UTF-8?q?=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- shiftflow/management/__init__.py | 0 shiftflow/management/commands/__init__.py | 0 .../management/commands/dxf_preview_job.py | 162 +++++++++++++ shiftflow/migrations/0013_dxfpreviewjob.py | 38 +++ ...el_requested_dxfpreviewjob_pid_and_more.py | 33 +++ shiftflow/models.py | 64 +++++ .../templates/shiftflow/customer_deals.html | 2 +- shiftflow/templates/shiftflow/customers.html | 2 +- .../templates/shiftflow/maintenance.html | 53 +++++ .../shiftflow/partials/_items_table.html | 14 +- shiftflow/templates/shiftflow/planning.html | 2 +- .../templates/shiftflow/planning_deal.html | 8 +- .../templates/shiftflow/task_create.html | 48 +++- shiftflow/urls.py | 2 + shiftflow/views.py | 224 ++++++++++++++++-- templates/base.html | 104 ++++++++ 16 files changed, 722 insertions(+), 34 deletions(-) create mode 100644 shiftflow/management/__init__.py create mode 100644 shiftflow/management/commands/__init__.py create mode 100644 shiftflow/management/commands/dxf_preview_job.py create mode 100644 shiftflow/migrations/0013_dxfpreviewjob.py create mode 100644 shiftflow/migrations/0014_dxfpreviewjob_cancel_requested_dxfpreviewjob_pid_and_more.py diff --git a/shiftflow/management/__init__.py b/shiftflow/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/shiftflow/management/commands/__init__.py b/shiftflow/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/shiftflow/management/commands/dxf_preview_job.py b/shiftflow/management/commands/dxf_preview_job.py new file mode 100644 index 0000000..97e1dea --- /dev/null +++ b/shiftflow/management/commands/dxf_preview_job.py @@ -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() \ No newline at end of file diff --git a/shiftflow/migrations/0013_dxfpreviewjob.py b/shiftflow/migrations/0013_dxfpreviewjob.py new file mode 100644 index 0000000..d3646ea --- /dev/null +++ b/shiftflow/migrations/0013_dxfpreviewjob.py @@ -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'], + }, + ), + ] diff --git a/shiftflow/migrations/0014_dxfpreviewjob_cancel_requested_dxfpreviewjob_pid_and_more.py b/shiftflow/migrations/0014_dxfpreviewjob_cancel_requested_dxfpreviewjob_pid_and_more.py new file mode 100644 index 0000000..b901db0 --- /dev/null +++ b/shiftflow/migrations/0014_dxfpreviewjob_cancel_requested_dxfpreviewjob_pid_and_more.py @@ -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='Статус'), + ), + ] diff --git a/shiftflow/models.py b/shiftflow/models.py index 30ad510..45a6271 100644 --- a/shiftflow/models.py +++ b/shiftflow/models.py @@ -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): """ Единица сменного задания. Определяет КТО, КОГДА и СКОЛЬКО сделал. diff --git a/shiftflow/templates/shiftflow/customer_deals.html b/shiftflow/templates/shiftflow/customer_deals.html index 2de318e..aeabf0a 100644 --- a/shiftflow/templates/shiftflow/customer_deals.html +++ b/shiftflow/templates/shiftflow/customer_deals.html @@ -38,7 +38,7 @@
- +
diff --git a/shiftflow/templates/shiftflow/customers.html b/shiftflow/templates/shiftflow/customers.html index 70a8149..98a0c81 100644 --- a/shiftflow/templates/shiftflow/customers.html +++ b/shiftflow/templates/shiftflow/customers.html @@ -13,7 +13,7 @@
-
Сделка
+
diff --git a/shiftflow/templates/shiftflow/maintenance.html b/shiftflow/templates/shiftflow/maintenance.html index 0842d1d..3ecc70d 100644 --- a/shiftflow/templates/shiftflow/maintenance.html +++ b/shiftflow/templates/shiftflow/maintenance.html @@ -17,6 +17,13 @@ DXF
+
+
Статус: {% if last_job %}{{ last_job.get_status_display }}{% endif %}
+
Обработано: {% if last_job %}{{ last_job.processed }}{% endif %}/{% if last_job %}{{ last_job.total }}{% endif %}
+
Обновлено: {% if last_job %}{{ last_job.updated }}{% endif %} · Пропущено: {% if last_job %}{{ last_job.skipped }}{% endif %} · Ошибок: {% if last_job %}{{ last_job.errors }}{% endif %}
+
{% if last_job %}{{ last_job.last_message }}{% endif %}
+
+
{% csrf_token %} @@ -42,6 +49,11 @@
+
+ + +
+
+
@@ -67,6 +82,44 @@ {% endfor %}
{% endif %} + + {% endblock %} \ No newline at end of file diff --git a/shiftflow/templates/shiftflow/partials/_items_table.html b/shiftflow/templates/shiftflow/partials/_items_table.html index 738899b..997ceb9 100644 --- a/shiftflow/templates/shiftflow/partials/_items_table.html +++ b/shiftflow/templates/shiftflow/partials/_items_table.html @@ -1,18 +1,18 @@
-
Заказчик
+
- + - - - + + + - - + + diff --git a/shiftflow/templates/shiftflow/planning.html b/shiftflow/templates/shiftflow/planning.html index 3af8a79..ef2e9c4 100644 --- a/shiftflow/templates/shiftflow/planning.html +++ b/shiftflow/templates/shiftflow/planning.html @@ -28,7 +28,7 @@
-
ДатаДата Сделка Станок НаименованиеГабаритыПрогрессПлан / ФактГабаритыПрогрессПлан / Факт МатериалФайлыФайлы Статус
+
diff --git a/shiftflow/templates/shiftflow/planning_deal.html b/shiftflow/templates/shiftflow/planning_deal.html index 0c1f85c..1961ac1 100644 --- a/shiftflow/templates/shiftflow/planning_deal.html +++ b/shiftflow/templates/shiftflow/planning_deal.html @@ -30,17 +30,17 @@
-
Сделка
+
- + - - + + diff --git a/shiftflow/templates/shiftflow/task_create.html b/shiftflow/templates/shiftflow/task_create.html index 5707d7c..d0405d0 100644 --- a/shiftflow/templates/shiftflow/task_create.html +++ b/shiftflow/templates/shiftflow/task_create.html @@ -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); }); } diff --git a/shiftflow/urls.py b/shiftflow/urls.py index 91094dc..9a23ad2 100644 --- a/shiftflow/urls.py +++ b/shiftflow/urls.py @@ -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//', 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//json/', DealDetailView.as_view(), name='deal_json'), diff --git a/shiftflow/views.py b/shiftflow/views.py index 1e45b60..787de47 100644 --- a/shiftflow/views.py +++ b/shiftflow/views.py @@ -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) diff --git a/templates/base.html b/templates/base.html index c1cb72c..cd56d9f 100644 --- a/templates/base.html +++ b/templates/base.html @@ -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); });
Деталь Материал РазмерПрогрессПрогресс Надо / Сделано / В плане ОсталосьФайлыДействияФайлыДействия