From cddbfeadde02011b8e7cbdd1a4b4f7cc98a8a1bb Mon Sep 17 00:00:00 2001 From: ackFromRedmi Date: Thu, 2 Apr 2026 23:52:04 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D0=BB=20?= =?UTF-8?q?=D0=BF=D1=80=D0=B5=D0=B2=D1=8C=D1=8E=D1=88=D0=BA=D0=B8=20=D0=B4?= =?UTF-8?q?=D1=85=D1=84=20=D0=B8=20=D0=BD=D0=B0=D1=81=D1=82=D1=80=D0=BE?= =?UTF-8?q?=D0=B9=D0=BA=D0=B8=20=D1=81=D0=B5=D1=80=D0=B2=D0=B5=D1=80=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .vscode/launch.json | 20 ++ .vscode/settings.json | 42 ++- requirements.txt | Bin 300 -> 748 bytes shiftflow/forms.py | 24 +- .../0010_productiontask_preview_image.py | 18 ++ .../0011_productiontask_blank_dimensions.py | 18 ++ .../migrations/0012_dxfpreviewsettings.py | 28 ++ shiftflow/models.py | 49 +++- .../templates/shiftflow/item_detail.html | 68 ++++- .../templates/shiftflow/maintenance.html | 72 +++++ .../templates/shiftflow/task_create.html | 4 +- shiftflow/urls.py | 2 + shiftflow/views.py | 273 +++++++++++++++++- templates/components/_navbar.html | 8 +- 14 files changed, 612 insertions(+), 14 deletions(-) create mode 100644 .vscode/launch.json create mode 100644 shiftflow/migrations/0010_productiontask_preview_image.py create mode 100644 shiftflow/migrations/0011_productiontask_blank_dimensions.py create mode 100644 shiftflow/migrations/0012_dxfpreviewsettings.py create mode 100644 shiftflow/templates/shiftflow/maintenance.html diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..642259b --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,20 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Django: Runserver", + "type": "python", + "request": "launch", + "program": "${workspaceFolder}/manage.py", + "args": [ + "runserver" + ], + "django": true, + "justMyCode": true, + // Это заставит сервер перезапускаться при изменении кода + "autoReload": { + "enable": true + } + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index 7e68766..20fa176 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,3 +1,43 @@ { - "python-envs.pythonProjects": [] + // --- ПИТОН И АВТОМАТИКА --- + "python.analysis.typeCheckingMode": "basic", // Подсказки по типам данных + "editor.formatOnSave": true, // Форматировать код при сохранении (маст-хэв!) + "python.formatting.provider": "black", // Использовать Black для красоты кода + "editor.codeActionsOnSave": { + "source.organizeImports": "explicit" // Сам расставит импорты по алфавиту + }, + + // --- DJANGO И HTML --- + "files.associations": { + "**/*.html": "django-html", // Чтобы VS Code понимал синтаксис {% if %} + "**/templates/**/*.html": "django-html" + }, + "emmet.includeLanguages": { + "django-html": "html" // Чтобы Emmet (развертывание тегов через Tab) работал в шаблонах + }, + + // --- ИНТЕРФЕЙС И КОМФОРТ --- + "editor.bracketPairColorization.enabled": true, // Цветные скобочки (чтобы не путаться в вложенности) + "editor.guide.bracketPairs": "active", + "editor.fontSize": 14, // Подбери под свои глаза + "editor.tabSize": 4, + "editor.renderWhitespace": "boundary", // Видеть лишние пробелы в конце строк + "files.autoSave": "onFocusChange", // Сохранять файл, когда переключаешься в браузер + + // --- ЧИСТОТА В ПРОЕКТЕ --- + "files.exclude": { + "**/.git": true, + "**/__pycache__": true, // Прячем мусорные папки питона + "**/*.pyc": true, + "**/node_modules": true, + "**/.DS_Store": true + }, + + // --- ТЕРМИНАЛ --- + "terminal.integrated.fontSize": 13, + "terminal.integrated.cursorStyle": "line", + + // --- ГИТ --- + "git.autofetch": true, // Проверять обновления в репозитории самостоятльно + "git.confirmSync": false } \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 3128fff827c15b017a424dcd649cc15c15150be0..5c9eb9dc46712789509bfc487e3cec5d6258c51c 100644 GIT binary patch literal 748 zcmYk4%~FFv5QO_|m5-7XAwTNDo9|&^AQ(dygiQiIyxKjx5Q<_COmFu$GyMH#=uqN_ z76lG?<+H{TD?U0O@qj&QIC!2Hsu~QYQeDb*)C|;B^k}V`*zI)kc*7Smx|DQL#*P(f z!tH!kUC_hfYDCJFI=Cql39`Rv`VTvMuHfG1f^E5q?h-qWBh8}yIcxn@x zlDbagl(d6}-z&?wS#4##$R$uTq|^p#DofTDq>xj_?!vyLec`Lk2FHO~)w@|{*yxfD zqsHTtu8xj5)t*zh@Dxp846f?4p{`>s5XrTVtV(^FSUY_4_LyCmM)x(-t>t<$t7A># eor%5WIwKX!eW$D0CcOO>V$332!al-I)y+RKG;Xf| delta 28 kcmaFEx`t_j%ES!6i9b{(w=t?rKE-G-xr9k&@;N3s0H_)Y!T -
+ {% csrf_token %} @@ -52,7 +52,7 @@
Материал - {{ item.task.material.name }} + {{ item.task.material.full_name|default:item.task.material.name }}
План @@ -60,9 +60,67 @@
-
- {% if item.task.drawing_file %}DXF{% endif %} - {% if item.task.extra_drawing %}PDF{% endif %} +
+
+ Размер детали + {{ item.task.size_value|default:"-" }} +
+
+ Габариты заготовки + {{ item.task.blank_dimensions|default:"—" }} +
+
+ +
+
Превью
+
+
+
+ {% if item.task.preview_image %} + Превью DXF + {% else %} +
+ {% endif %} +
+
+
+
+
+ {% if item.task.drawing_file %} + + + + {% else %} + + + + {% endif %} +
DXF/STEP
+
+ {% if user_role == 'admin' %} + + {% endif %} +
+ +
+
+ {% if item.task.extra_drawing %} + + + + {% else %} + + + + {% endif %} +
PDF
+
+ {% if user_role == 'admin' %} + + {% endif %} +
+
+
diff --git a/shiftflow/templates/shiftflow/maintenance.html b/shiftflow/templates/shiftflow/maintenance.html new file mode 100644 index 0000000..0842d1d --- /dev/null +++ b/shiftflow/templates/shiftflow/maintenance.html @@ -0,0 +1,72 @@ +{% extends 'base.html' %} +{% load l10n %} + +{% block content %} +
+
+

Обслуживание сервера

+
+ +
+
+ Здесь настраиваем и обслуживаем генерацию превью DXF (PNG) на сервере. +
+ +
+
+ DXF +
+
+ + {% csrf_token %} + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+
+ + +
+
+ +
+ + +
+ +
+
+ Пакетное обновление пробегает по сделкам в статусах «Зашла» и «В работе». +
+
+ +
+
+ + {% if messages %} +
+ {% for message in messages %} +
{{ message }}
+ {% endfor %} +
+ {% endif %} +
+
+{% endblock %} \ No newline at end of file diff --git a/shiftflow/templates/shiftflow/task_create.html b/shiftflow/templates/shiftflow/task_create.html index 6a381cd..5707d7c 100644 --- a/shiftflow/templates/shiftflow/task_create.html +++ b/shiftflow/templates/shiftflow/task_create.html @@ -35,7 +35,7 @@
-
+
{{ form.drawing_name }} {% for e in form.drawing_name.errors %}
{{ e }}
{% endfor %} @@ -50,7 +50,7 @@ {{ form.size_value }} {% for e in form.size_value.errors %}
{{ e }}
{% endfor %}
-
+
{{ form.is_bend }} diff --git a/shiftflow/urls.py b/shiftflow/urls.py index 5807423..91094dc 100644 --- a/shiftflow/urls.py +++ b/shiftflow/urls.py @@ -8,6 +8,7 @@ from .views import ( DealUpsertView, IndexView, ItemUpdateView, + MaintenanceView, MaterialCategoryUpsertView, MaterialDetailView, MaterialUpsertView, @@ -32,6 +33,7 @@ urlpatterns = [ path('planning/task//items/', TaskItemsView.as_view(), name='task_items'), path('customers/', CustomersView.as_view(), name='customers'), path('customers//', CustomerDealsView.as_view(), name='customer_deals'), + path('maintenance/', MaintenanceView.as_view(), name='maintenance'), 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 4a6efed..1e45b60 100644 --- a/shiftflow/views.py +++ b/shiftflow/views.py @@ -1,6 +1,10 @@ from datetime import datetime from urllib.parse import urlsplit +import os +from django.contrib import messages +from django.core.files.base import ContentFile + from django.db.models import Case, ExpressionWrapper, F, IntegerField, Sum, Value, When from django.db.models.functions import Coalesce from django.http import JsonResponse @@ -14,7 +18,176 @@ from django.utils import timezone from warehouse.models import Material, MaterialCategory, SteelGrade from .forms import ProductionTaskCreateForm -from .models import Company, Deal, Item, Machine, ProductionTask +from .models import Company, Deal, DxfPreviewSettings, EmployeeProfile, Item, Machine, ProductionTask + + +def _get_dxf_preview_settings() -> DxfPreviewSettings: + """Возвращает (и при необходимости создаёт) настройки превью DXF. + + Мы храним настройки в БД, чтобы админ мог менять их в интерфейсе. + Ожидаем одну запись (singleton), используем pk=1. + """ + obj, _ = DxfPreviewSettings.objects.get_or_create(pk=1) + return obj + + +def _render_dxf_preview_png( + dxf_path: str, + *, + line_color: str, + lineweight_scaling: float, + min_lineweight_mm: float, + keep_original_colors: bool, +) -> bytes: + """Рендерит DXF в PNG (байты) с заданными параметрами. + + Зачем это нужно: + - браузер не умеет стабильно показывать DXF как "превью"; + - поэтому мы генерируем PNG на сервере и уже её показываем в интерфейсе. + + Требуемые зависимости: + - ezdxf + - matplotlib (backend Agg) + + Если зависимости не установлены — бросаем исключение с понятным текстом. + """ + try: + # Важно: используем headless-backend, чтобы рендер работал без GUI (на сервере/в Docker). + import matplotlib + matplotlib.use('Agg') + import matplotlib.pyplot as plt + + # ezdxf читает DXF и умеет отрисовывать через drawing add-on. + from ezdxf import recover + from ezdxf.addons.drawing import RenderContext, Frontend + from ezdxf.addons.drawing.matplotlib import MatplotlibBackend + from ezdxf.addons.drawing import config as draw_config + except Exception as e: + raise RuntimeError('Не установлены зависимости для превью DXF: ezdxf и matplotlib') from e + + if not dxf_path or not os.path.exists(dxf_path): + raise FileNotFoundError('DXF файл не найден') + + # Безопасное чтение DXF (recover умеет поднимать часть повреждённых файлов). + doc, auditor = recover.readfile(dxf_path) + if auditor and getattr(auditor, 'has_errors', False): + # Даже при ошибках структуры часто удаётся получить картинку — поэтому не прерываем. + pass + + # Настройка итогового вида превью: + # - прозрачный фон (чтобы хорошо смотрелось на тёмной теме) + # - цвет/толщина линии задаются настройками (см. «Обслуживание сервера») + + # Конвертируем мм в единицы ezdxf (min_lineweight хранится в 1/300 inch). + # Формула: мм / 25.4 * 300 + min_lineweight = int(max(0.0, float(min_lineweight_mm)) / 25.4 * 300) + + # Конфигурация рендера: управляем толщиной линий. + cfg = draw_config.Configuration( + lineweight_scaling=float(lineweight_scaling), + min_lineweight=min_lineweight, + ) + + class PreviewFrontend(Frontend): + """Переопределяет свойства сущностей перед отрисовкой. + + Если keep_original_colors=True — оставляем цвета DXF. + Иначе принудительно красим все линии в заданный line_color. + """ + + def override_properties(self, entity, properties) -> None: + if not keep_original_colors: + properties.color = line_color + + fig = plt.figure(figsize=(5, 3), dpi=160) + ax = fig.add_axes([0, 0, 1, 1]) + ax.set_axis_off() + ax.margins(0) + + # Прозрачность фона фигуры и осей. + fig.patch.set_alpha(0) + ax.set_facecolor((0, 0, 0, 0)) + + ctx = RenderContext(doc) + out = MatplotlibBackend(ax) + PreviewFrontend(ctx, out, config=cfg).draw_layout(doc.modelspace(), finalize=True) + + import io + buf = io.BytesIO() + fig.savefig( + buf, + format='png', + dpi=160, + bbox_inches='tight', + pad_inches=0.02, + transparent=True, + ) + plt.close(fig) + buf.seek(0) + return buf.getvalue() + + +def _extract_dxf_dimensions(dxf_path: str) -> str: + """Возвращает строку габаритов заготовки вида «300х456 мм». + + Используем ezdxf.bbox.extents(), который корректно учитывает дуги/сплайны и вложенные блоки. + Пытаемся учитывать единицы: если в заголовке $INSUNITS указаны дюймы/см/м — конвертируем в мм. + """ + import ezdxf + from ezdxf import bbox as dzbbox + + doc = ezdxf.readfile(dxf_path) + msp = doc.modelspace() + + # Коэффициент перевода в мм по $INSUNITS + units = int(doc.header.get('$INSUNITS', 0) or 0) + factor = {1: 25.4, 4: 1.0, 5: 10.0, 6: 1000.0}.get(units, 1.0) + + extent = dzbbox.extents(msp, cache=dzbbox.Cache()) + if not extent.has_data: + return '' + (min_x, min_y, _), (max_x, max_y, _) = extent.min, extent.max + width = (max_x - min_x) * factor + height = (max_y - min_y) * factor + return f"{round(width, 3)}х{round(height, 3)} мм" + + +def _update_task_preview(task: ProductionTask) -> bool: + """Обновляет превью PNG и габариты из DXF для одной детали. + + Использует текущие настройки из DxfPreviewSettings. + """ + if not task.drawing_file: + return False + + name = (task.drawing_file.name or '').lower() + if not name.endswith('.dxf'): + # Если не DXF — превью не делаем и очищаем габариты + task.preview_image = None + task.blank_dimensions = '' + task.save(update_fields=['preview_image', 'blank_dimensions']) + return False + + dxf_path = getattr(task.drawing_file, 'path', '') + settings = _get_dxf_preview_settings() + png_bytes = _render_dxf_preview_png( + dxf_path, + line_color=settings.line_color, + lineweight_scaling=settings.lineweight_scaling, + min_lineweight_mm=settings.min_lineweight, + keep_original_colors=settings.keep_original_colors, + ) + dims = '' + try: + dims = _extract_dxf_dimensions(dxf_path) + except Exception: + dims = '' + + filename = f"task_{task.id}_preview.png" + task.preview_image.save(filename, ContentFile(png_bytes), save=False) + task.blank_dimensions = dims + task.save(update_fields=['preview_image', 'blank_dimensions']) + return True # Класс главной страницы (роутер) class IndexView(TemplateView): @@ -362,6 +535,71 @@ class TaskItemsView(LoginRequiredMixin, TemplateView): return context +class MaintenanceView(LoginRequiredMixin, TemplateView): + template_name = 'shiftflow/maintenance.html' + + def dispatch(self, request, *args, **kwargs): + profile = getattr(request.user, 'profile', None) + role = profile.role if profile else ('admin' if request.user.is_superuser else 'operator') + # Обслуживание сервера доступно только админу + if role != 'admin': + return redirect('registry') + return super().dispatch(request, *args, **kwargs) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context['user_role'] = 'admin' + + # Подтягиваем текущие настройки генерации превью, чтобы отрисовать форму. + s = _get_dxf_preview_settings() + context['dxf_settings'] = s + return context + + def post(self, request, *args, **kwargs): + # На странице обслуживания есть 2 действия: + # 1) сохранить настройки превью + # 2) сохранить настройки и обновить превью по сделкам в статусах lead/work + action = (request.POST.get('action') or '').strip() + + # Сохраняем настройки (даже если жмём «Обновить» — чтобы применить их сразу). + s = _get_dxf_preview_settings() + s.line_color = (request.POST.get('line_color') or s.line_color).strip() or s.line_color + try: + s.lineweight_scaling = float(request.POST.get('lineweight_scaling', s.lineweight_scaling)) + except ValueError: + pass + try: + s.min_lineweight = float(request.POST.get('min_lineweight', s.min_lineweight)) + except ValueError: + pass + s.keep_original_colors = bool(request.POST.get('keep_original_colors')) + s.save() + + if action != 'update_previews': + messages.success(request, 'Настройки превью сохранены.') + return redirect('maintenance') + + # Обновляем превью только для сделок в статусах «Зашла» и «В работе». + deal_statuses = ['lead', 'work'] + tasks = ProductionTask.objects.select_related('deal').filter(deal__status__in=deal_statuses) + + updated = 0 + skipped = 0 + errors = 0 + + for task in tasks: + try: + if _update_task_preview(task): + updated += 1 + else: + skipped += 1 + except Exception: + errors += 1 + + messages.success(request, f"Превью обновлены: {updated}. Пропущено: {skipped}. Ошибок: {errors}.") + return redirect('maintenance') + + class CustomersView(LoginRequiredMixin, TemplateView): template_name = 'shiftflow/customers.html' @@ -734,8 +972,41 @@ class ItemUpdateView(LoginRequiredMixin, UpdateView): return redirect('registry') if role in ['admin', 'technologist']: + # Действие формы (обычное сохранение или закрытие позиции) action = request.POST.get('action', 'save') + # Админ может заменить файлы у детали прямо из карточки пункта сменки. + # Файлы лежат на ProductionTask (основании), а не на Item. + if role == 'admin' and self.object.task_id: + # Админ может заменить файлы детали. После замены: + # - сбрасываем превью; + # - пытаемся сразу извлечь габариты из DXF. + task = self.object.task + drawing_file = request.FILES.get('drawing_file') + extra_drawing = request.FILES.get('extra_drawing') + changed = False + if drawing_file is not None: + task.drawing_file = drawing_file + changed = True + if extra_drawing is not None: + task.extra_drawing = extra_drawing + changed = True + if changed: + task.preview_image = None + # Переcчёт габаритов, если это DXF + dims = '' + try: + if drawing_file is not None and (drawing_file.name or '').lower().endswith('.dxf'): + # временно сохраняем файл на объекте до .save() + pass + path = getattr(task.drawing_file, 'path', '') + if path and path.lower().endswith('.dxf'): + dims = _extract_dxf_dimensions(path) + except Exception: + dims = '' + task.blank_dimensions = dims + task.save() + machine_id = request.POST.get('machine') if machine_id and machine_id.isdigit(): self.object.machine_id = int(machine_id) diff --git a/templates/components/_navbar.html b/templates/components/_navbar.html index a38a1cb..9f1eba5 100644 --- a/templates/components/_navbar.html +++ b/templates/components/_navbar.html @@ -23,7 +23,7 @@ Заказчик {% endif %} - + {% if user_role in 'admin,technologist,master,operator' %} {% endif %} @@ -31,6 +31,12 @@ {% if user_role in 'admin,technologist,clerk' %} {% endif %} + + {% if user_role == 'admin' %} + + {% endif %}