Сортировка в таблицах и попытка приструнить генерацию превьюшек
All checks were successful
Deploy MES Core / deploy (push) Successful in 13s
All checks were successful
Deploy MES Core / deploy (push) Successful in 13s
This commit is contained in:
0
shiftflow/management/__init__.py
Normal file
0
shiftflow/management/__init__.py
Normal file
0
shiftflow/management/commands/__init__.py
Normal file
0
shiftflow/management/commands/__init__.py
Normal file
162
shiftflow/management/commands/dxf_preview_job.py
Normal file
162
shiftflow/management/commands/dxf_preview_job.py
Normal 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()
|
||||
38
shiftflow/migrations/0013_dxfpreviewjob.py
Normal file
38
shiftflow/migrations/0013_dxfpreviewjob.py
Normal file
@@ -0,0 +1,38 @@
|
||||
# Generated by Django 6.0.3 on 2026-04-02 21:33
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('shiftflow', '0012_dxfpreviewsettings'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='DxfPreviewJob',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('status', models.CharField(choices=[('queued', 'В очереди'), ('running', 'Выполняется'), ('done', 'Готово'), ('failed', 'Ошибка')], default='queued', max_length=16, verbose_name='Статус')),
|
||||
('started_at', models.DateTimeField(blank=True, null=True, verbose_name='Начато')),
|
||||
('finished_at', models.DateTimeField(blank=True, null=True, verbose_name='Завершено')),
|
||||
('total', models.PositiveIntegerField(default=0, verbose_name='Всего задач')),
|
||||
('processed', models.PositiveIntegerField(default=0, verbose_name='Обработано')),
|
||||
('updated', models.PositiveIntegerField(default=0, verbose_name='Обновлено')),
|
||||
('skipped', models.PositiveIntegerField(default=0, verbose_name='Пропущено')),
|
||||
('errors', models.PositiveIntegerField(default=0, verbose_name='Ошибок')),
|
||||
('last_message', models.CharField(blank=True, default='', max_length=255, verbose_name='Сообщение')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Создано')),
|
||||
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='Запустил')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Задача превью DXF',
|
||||
'verbose_name_plural': 'Задачи превью DXF',
|
||||
'ordering': ['-id'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,33 @@
|
||||
# Generated by Django 6.0.3 on 2026-04-02 22:06
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('shiftflow', '0013_dxfpreviewjob'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='dxfpreviewjob',
|
||||
name='cancel_requested',
|
||||
field=models.BooleanField(default=False, help_text='Если включено — воркер завершит задачу после текущей детали', verbose_name='Запрошена остановка'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='dxfpreviewjob',
|
||||
name='pid',
|
||||
field=models.PositiveIntegerField(blank=True, help_text='Номер процесса, который выполняет задачу (для диагностики)', null=True, verbose_name='PID процесса'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='dxfpreviewsettings',
|
||||
name='per_task_timeout_sec',
|
||||
field=models.PositiveIntegerField(default=45, help_text='Если конкретный DXF завис — убиваем обработку этой детали и идём дальше', verbose_name='Таймаут на 1 DXF (сек)'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='dxfpreviewjob',
|
||||
name='status',
|
||||
field=models.CharField(choices=[('queued', 'В очереди'), ('running', 'Выполняется'), ('done', 'Готово'), ('failed', 'Ошибка'), ('cancelled', 'Остановлено')], default='queued', max_length=16, verbose_name='Статус'),
|
||||
),
|
||||
]
|
||||
@@ -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):
|
||||
"""
|
||||
Единица сменного задания. Определяет КТО, КОГДА и СКОЛЬКО сделал.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user