diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3526247..5cc88e3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -13,6 +13,7 @@
### Changed
- Производственные задачи и прогресс техпроцесса ведутся в разрезе партий поставки (серий) для одной сделки.
+- Улучшено сообщение о блокировке запуска «В производство» при отсутствии техпроцесса: показывается модалка и отдельная страница со списком проблемных позиций.
### Fixed
- Запуск «В производство» блокируется, если в BOM есть узлы без техпроцесса (EntityOperation seq=1), чтобы компоненты не попадали в «без техпроцесса».
diff --git a/shiftflow/admin.py b/shiftflow/admin.py
index 20f917b..b372474 100644
--- a/shiftflow/admin.py
+++ b/shiftflow/admin.py
@@ -8,6 +8,7 @@ from .models import (
Company,
CuttingSession,
Deal,
+ DealDeliveryBatch,
DealItem,
DxfPreviewJob,
DxfPreviewSettings,
@@ -28,6 +29,7 @@ _models_to_reregister = (
Company,
CuttingSession,
Deal,
+ DealDeliveryBatch,
DealItem,
DxfPreviewJob,
DxfPreviewSettings,
@@ -77,6 +79,14 @@ class DealAdmin(admin.ModelAdmin):
list_filter = ('status', 'company')
inlines = (DealItemInline,)
+# --- Настройка отображения Партий поставки ---
+@admin.register(DealDeliveryBatch)
+class DealDeliveryBatchAdmin(admin.ModelAdmin):
+ list_display = ('deal', 'due_date', 'name', 'is_default', 'created_at')
+ list_filter = ('is_default', 'due_date', 'deal')
+ search_fields = ('deal__number', 'name')
+ autocomplete_fields = ('deal',)
+
# --- Задания на производство (База) ---
"""
Панель администрирования Заданий на производство
diff --git a/shiftflow/templates/shiftflow/missing_techprocess.html b/shiftflow/templates/shiftflow/missing_techprocess.html
new file mode 100644
index 0000000..87b308b
--- /dev/null
+++ b/shiftflow/templates/shiftflow/missing_techprocess.html
@@ -0,0 +1,54 @@
+{% extends 'base.html' %}
+
+{% block content %}
+
{% empty %}
- | Задач нет |
+ | Задач нет |
{% endfor %}
@@ -504,6 +518,51 @@
+{% if missing_tech_process_rows %}
+
+
+
+
+
+
Добавь техпроцесс (операция seq=1) для позиций ниже и повтори запуск.
+
+
+
+
+
+
+ {% for r in missing_tech_process_rows %}
+
+ | {{ r.entity.get_entity_type_display }} |
+ {{ r.entity.drawing_number|default:"—" }} |
+ {{ r.entity.name }} |
+
+ Открыть
+ |
+
+ {% endfor %}
+
+
+
+
+
+
+
+
+{% endif %}
@@ -1048,6 +1107,17 @@ document.addEventListener('DOMContentLoaded', function () {
});
});
+{% if missing_tech_process_autoshow and missing_tech_process_rows %}
+document.addEventListener('DOMContentLoaded', function () {
+ const el = document.getElementById('missingTechProcessModal');
+ if (!el) return;
+ try {
+ const m = new bootstrap.Modal(el);
+ m.show();
+ } catch (e) {
+ }
+});
+{% endif %}
{% endblock %}
diff --git a/shiftflow/urls.py b/shiftflow/urls.py
index e3f348c..dad8690 100644
--- a/shiftflow/urls.py
+++ b/shiftflow/urls.py
@@ -5,6 +5,7 @@ from .views import (
CustomersView,
DealDetailView,
DealPlanningView,
+ DealMissingTechProcessView,
DealUpsertView,
DealBatchActionView,
DealItemUpsertView,
@@ -70,6 +71,7 @@ urlpatterns = [
# Сделки
path('planning/', PlanningView.as_view(), name='planning'),
path('planning/deal//', DealPlanningView.as_view(), name='planning_deal'),
+ path('planning/deal//missing-tech/', DealMissingTechProcessView.as_view(), name='deal_missing_tech_process'),
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'),
diff --git a/shiftflow/views.py b/shiftflow/views.py
index 4fc5b1a..a2e1d43 100644
--- a/shiftflow/views.py
+++ b/shiftflow/views.py
@@ -2022,6 +2022,30 @@ class DealPlanningView(LoginRequiredMixin, TemplateView):
deal = get_object_or_404(Deal.objects.select_related('company'), pk=self.kwargs['pk'])
context['deal'] = deal
+ missing_ctx = None
+ raw_missing = self.request.session.get('sf_missing_tech_process')
+ if raw_missing and int(raw_missing.get('deal_id') or 0) == int(deal.id):
+ entity_ids = [int(x) for x in (raw_missing.get('entity_ids') or []) if str(x).isdigit() or isinstance(x, int)]
+ qs = ProductEntity.objects.filter(id__in=entity_ids).order_by('entity_type', 'drawing_number', 'name', 'id')
+ items = []
+ for e in qs:
+ items.append({
+ 'entity': e,
+ 'url': f"{reverse_lazy('product_info', kwargs={'pk': int(e.id)})}?next={self.request.get_full_path()}" ,
+ })
+
+ missing_ctx = {
+ 'items': items,
+ 'details_url': str(reverse_lazy('deal_missing_tech_process', kwargs={'pk': int(deal.id)})),
+ 'autoshow': not bool(raw_missing.get('shown')),
+ }
+
+ if not bool(raw_missing.get('shown')):
+ raw_missing['shown'] = True
+ self.request.session['sf_missing_tech_process'] = raw_missing
+
+ context['missing_tech_process'] = missing_ctx
+
di = list(
DealItem.objects.select_related('entity', 'entity__assembly_passport')
.filter(deal=deal)
@@ -2252,6 +2276,42 @@ class DealPlanningView(LoginRequiredMixin, TemplateView):
return redirect('planning_deal', pk=deal_id)
+class DealMissingTechProcessView(LoginRequiredMixin, TemplateView):
+ template_name = 'shiftflow/missing_techprocess.html'
+
+ def dispatch(self, request, *args, **kwargs):
+ roles = get_user_roles(request.user)
+ if not has_any_role(roles, ['admin', 'technologist', 'prod_head']):
+ return redirect('registry')
+ return super().dispatch(request, *args, **kwargs)
+
+ def get_context_data(self, **kwargs):
+ ctx = super().get_context_data(**kwargs)
+ roles = get_user_roles(self.request.user)
+ ctx['user_roles'] = sorted(roles)
+ ctx['user_role'] = primary_role(roles)
+
+ deal = get_object_or_404(Deal.objects.select_related('company'), pk=int(self.kwargs['pk']))
+ ctx['deal'] = deal
+
+ raw = self.request.session.get('sf_missing_tech_process')
+ entity_ids = []
+ if raw and int(raw.get('deal_id') or 0) == int(deal.id):
+ entity_ids = [int(x) for x in (raw.get('entity_ids') or []) if str(x).isdigit() or isinstance(x, int)]
+
+ qs = ProductEntity.objects.filter(id__in=entity_ids).order_by('entity_type', 'drawing_number', 'name', 'id')
+ rows = []
+ next_url = str(reverse_lazy('planning_deal', kwargs={'pk': int(deal.id)}))
+ for e in qs:
+ rows.append({
+ 'entity': e,
+ 'url': f"{reverse_lazy('product_info', kwargs={'pk': int(e.id)})}?next={next_url}",
+ })
+
+ ctx['items'] = rows
+ return ctx
+
+
class TaskItemsView(LoginRequiredMixin, TemplateView):
template_name = 'shiftflow/task_items.html'
@@ -3442,18 +3502,16 @@ class DealBatchActionView(LoginRequiredMixin, View):
return redirect(next_url)
except ExplosionValidationError as ev:
if getattr(ev, 'missing_route_entity_ids', None):
- bad = list(
- ProductEntity.objects.filter(id__in=list(ev.missing_route_entity_ids)).values_list('id', 'drawing_number', 'name')
+ missing_ids = sorted({int(x) for x in (ev.missing_route_entity_ids or [])})
+ request.session['sf_missing_tech_process'] = {
+ 'deal_id': int(deal_id),
+ 'entity_ids': missing_ids,
+ 'shown': False,
+ }
+ messages.error(
+ request,
+ 'Нельзя запустить в производство: у части позиций отсутствует техпроцесс (операция seq=1).',
)
- if bad:
- preview = ", ".join([f"/products/{eid}/info/ — {dn or '—'} {nm}" for eid, dn, nm in bad[:5]])
- more = '' if len(bad) <= 5 else f" и ещё {len(bad)-5}"
- messages.error(
- request,
- f'Нельзя запустить в производство: нет техпроцесса (операция seq=1) у: {preview}{more}. Добавь техпроцесс и повтори запуск.',
- )
- else:
- messages.error(request, 'Нельзя запустить в производство: у части позиций отсутствует техпроцесс. Добавь техпроцесс и повтори запуск.')
return redirect(next_url)
bad = list(ProductEntity.objects.filter(id__in=list(getattr(ev, 'missing_material_ids', []) or [])).values_list('drawing_number', 'name'))