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 @@