Ввел логику сделок через партии, дополнение 2
All checks were successful
Deploy MES Core / deploy (push) Successful in 12s

This commit is contained in:
2026-04-22 08:58:23 +03:00
parent da8ef32769
commit 6d13e5a321
6 changed files with 207 additions and 12 deletions

View File

@@ -13,6 +13,7 @@
### Changed ### Changed
- Производственные задачи и прогресс техпроцесса ведутся в разрезе партий поставки (серий) для одной сделки. - Производственные задачи и прогресс техпроцесса ведутся в разрезе партий поставки (серий) для одной сделки.
- Улучшено сообщение о блокировке запуска «В производство» при отсутствии техпроцесса: показывается модалка и отдельная страница со списком проблемных позиций.
### Fixed ### Fixed
- Запуск «В производство» блокируется, если в BOM есть узлы без техпроцесса (EntityOperation seq=1), чтобы компоненты не попадали в «без техпроцесса». - Запуск «В производство» блокируется, если в BOM есть узлы без техпроцесса (EntityOperation seq=1), чтобы компоненты не попадали в «без техпроцесса».

View File

@@ -8,6 +8,7 @@ from .models import (
Company, Company,
CuttingSession, CuttingSession,
Deal, Deal,
DealDeliveryBatch,
DealItem, DealItem,
DxfPreviewJob, DxfPreviewJob,
DxfPreviewSettings, DxfPreviewSettings,
@@ -28,6 +29,7 @@ _models_to_reregister = (
Company, Company,
CuttingSession, CuttingSession,
Deal, Deal,
DealDeliveryBatch,
DealItem, DealItem,
DxfPreviewJob, DxfPreviewJob,
DxfPreviewSettings, DxfPreviewSettings,
@@ -77,6 +79,14 @@ class DealAdmin(admin.ModelAdmin):
list_filter = ('status', 'company') list_filter = ('status', 'company')
inlines = (DealItemInline,) 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',)
# --- Задания на производство (База) --- # --- Задания на производство (База) ---
""" """
Панель администрирования Заданий на производство Панель администрирования Заданий на производство

View File

@@ -0,0 +1,54 @@
{% extends 'base.html' %}
{% block content %}
<div class="card shadow border-secondary mb-3">
<div class="card-header border-secondary py-3 d-flex justify-content-between align-items-center">
<div>
<h3 class="text-accent mb-1">
<i class="bi bi-exclamation-triangle me-2"></i>Позиции без техпроцесса
</h3>
<div class="small text-muted">
Сделка {{ deal.number }}
{% if deal.company %} · {{ deal.company.name }}{% endif %}
{% if deal.description %} · {{ deal.description }}{% endif %}
</div>
</div>
<a class="btn btn-outline-secondary btn-sm" href="{% url 'planning_deal' deal.id %}">
<i class="bi bi-arrow-left me-1"></i>Назад к сделке
</a>
</div>
<div class="card-body">
{% if items %}
<div class="text-muted mb-2">Для запуска в производство нужен техпроцесс (операция seq=1).</div>
<div class="table-responsive">
<table class="table table-hover align-middle mb-0" data-sortable="1">
<thead>
<tr class="table-custom-header">
<th style="width:120px;">Тип</th>
<th style="width:200px;">Обозначение</th>
<th>Наименование</th>
<th data-sort="false" class="text-end" style="width:160px;"></th>
</tr>
</thead>
<tbody>
{% for r in items %}
<tr>
<td class="small text-muted">{{ r.entity.get_entity_type_display }}</td>
<td class="fw-bold">{{ r.entity.drawing_number|default:"—" }}</td>
<td>{{ r.entity.name }}</td>
<td class="text-end">
<a class="btn btn-outline-accent btn-sm" href="{{ r.url }}" target="_blank">Открыть паспорт</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-muted">Нет данных: список проблемных позиций не найден (или уже очищен).</div>
{% endif %}
</div>
</div>
{% endblock %}

View File

@@ -316,6 +316,7 @@
<thead> <thead>
<tr class="table-custom-header"> <tr class="table-custom-header">
<th>Позиция</th> <th>Позиция</th>
<th>Партия</th>
<th>Операция</th> <th>Операция</th>
<th data-sort="false" style="width: 160px;">Прогресс</th> <th data-sort="false" style="width: 160px;">Прогресс</th>
<th class="text-center">Заказано / Сделано / В смене</th> <th class="text-center">Заказано / Сделано / В смене</th>
@@ -339,6 +340,19 @@
{% if t.material %}{{ t.material.full_name|default:t.material.name }}{% else %}—{% endif %} {% if t.material %}{{ t.material.full_name|default:t.material.name }}{% else %}—{% endif %}
</div> </div>
</td> </td>
<td class="small">
{% if t.delivery_batch_id and t.delivery_batch %}
<div class="fw-bold">{{ t.delivery_batch.due_date|date:"d.m.Y" }}</div>
<div class="text-muted">
{{ t.delivery_batch.name|default:"—" }}
{% if t.delivery_batch.is_default %}
<span class="badge bg-secondary ms-1">по умолчанию</span>
{% endif %}
</div>
{% else %}
<span class="text-muted"></span>
{% endif %}
</td>
<td class="small">{{ t.current_operation_name|default:"—" }}</td> <td class="small">{{ t.current_operation_name|default:"—" }}</td>
<td> <td>
<div class="progress bg-secondary-subtle border border-secondary sf-progress" style="height: 10px;" data-done-width="{{ t.done_width }}" data-plan-width="{{ t.plan_width }}" title="Сделано: {{ t.done_pct }}% · В смене: {{ t.plan_pct }}%"> <div class="progress bg-secondary-subtle border border-secondary sf-progress" style="height: 10px;" data-done-width="{{ t.done_width }}" data-plan-width="{{ t.plan_width }}" title="Сделано: {{ t.done_pct }}% · В смене: {{ t.plan_pct }}%">
@@ -390,7 +404,7 @@
</td> </td>
</tr> </tr>
{% empty %} {% empty %}
<tr><td colspan="7" class="text-center p-4 text-muted">Задач нет</td></tr> <tr><td colspan="8" class="text-center p-4 text-muted">Задач нет</td></tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
@@ -504,6 +518,51 @@
</div> </div>
</div> </div>
{% if missing_tech_process_rows %}
<div class="modal fade" id="missingTechProcessModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content border-secondary">
<div class="modal-header border-secondary">
<h5 class="modal-title">Нельзя запустить: нет техпроцесса</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
</div>
<div class="modal-body">
<div class="text-muted mb-2">Добавь техпроцесс (операция seq=1) для позиций ниже и повтори запуск.</div>
<div class="table-responsive">
<table class="table table-sm table-hover mb-0 align-middle">
<thead>
<tr class="table-custom-header">
<th style="width:110px;">Тип</th>
<th style="width:180px;">Обозначение</th>
<th>Наименование</th>
<th data-sort="false" class="text-end" style="width:140px;"></th>
</tr>
</thead>
<tbody>
{% for r in missing_tech_process_rows %}
<tr>
<td class="small text-muted">{{ r.entity.get_entity_type_display }}</td>
<td class="fw-bold">{{ r.entity.drawing_number|default:"—" }}</td>
<td>{{ r.entity.name }}</td>
<td class="text-end">
<a class="btn btn-outline-secondary btn-sm" href="{{ r.url }}" target="_blank">Открыть</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<div class="modal-footer border-secondary">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Закрыть</button>
{% if missing_tech_process_details_url %}
<a class="btn btn-outline-accent" href="{{ missing_tech_process_details_url }}" target="_blank">Подробнее</a>
{% endif %}
</div>
</div>
</div>
</div>
{% endif %}
<!-- productInfoModal удалён: паспорт компонента открывается отдельной страницей --> <!-- productInfoModal удалён: паспорт компонента открывается отдельной страницей -->
<div class="d-none" id="productInfoModal" tabindex="-1" aria-hidden="true"> <div class="d-none" id="productInfoModal" tabindex="-1" aria-hidden="true">
@@ -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 %}
</script> </script>
{% endblock %} {% endblock %}

View File

@@ -5,6 +5,7 @@ from .views import (
CustomersView, CustomersView,
DealDetailView, DealDetailView,
DealPlanningView, DealPlanningView,
DealMissingTechProcessView,
DealUpsertView, DealUpsertView,
DealBatchActionView, DealBatchActionView,
DealItemUpsertView, DealItemUpsertView,
@@ -70,6 +71,7 @@ urlpatterns = [
# Сделки # Сделки
path('planning/', PlanningView.as_view(), name='planning'), path('planning/', PlanningView.as_view(), name='planning'),
path('planning/deal/<int:pk>/', DealPlanningView.as_view(), name='planning_deal'), path('planning/deal/<int:pk>/', DealPlanningView.as_view(), name='planning_deal'),
path('planning/deal/<int:pk>/missing-tech/', DealMissingTechProcessView.as_view(), name='deal_missing_tech_process'),
path('planning/task/<int:pk>/items/', TaskItemsView.as_view(), name='task_items'), path('planning/task/<int:pk>/items/', TaskItemsView.as_view(), name='task_items'),
path('customers/', CustomersView.as_view(), name='customers'), path('customers/', CustomersView.as_view(), name='customers'),
path('customers/<int:pk>/', CustomerDealsView.as_view(), name='customer_deals'), path('customers/<int:pk>/', CustomerDealsView.as_view(), name='customer_deals'),

View File

@@ -2022,6 +2022,30 @@ class DealPlanningView(LoginRequiredMixin, TemplateView):
deal = get_object_or_404(Deal.objects.select_related('company'), pk=self.kwargs['pk']) deal = get_object_or_404(Deal.objects.select_related('company'), pk=self.kwargs['pk'])
context['deal'] = deal 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( di = list(
DealItem.objects.select_related('entity', 'entity__assembly_passport') DealItem.objects.select_related('entity', 'entity__assembly_passport')
.filter(deal=deal) .filter(deal=deal)
@@ -2252,6 +2276,42 @@ class DealPlanningView(LoginRequiredMixin, TemplateView):
return redirect('planning_deal', pk=deal_id) 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): class TaskItemsView(LoginRequiredMixin, TemplateView):
template_name = 'shiftflow/task_items.html' template_name = 'shiftflow/task_items.html'
@@ -3442,18 +3502,16 @@ class DealBatchActionView(LoginRequiredMixin, View):
return redirect(next_url) return redirect(next_url)
except ExplosionValidationError as ev: except ExplosionValidationError as ev:
if getattr(ev, 'missing_route_entity_ids', None): if getattr(ev, 'missing_route_entity_ids', None):
bad = list( missing_ids = sorted({int(x) for x in (ev.missing_route_entity_ids or [])})
ProductEntity.objects.filter(id__in=list(ev.missing_route_entity_ids)).values_list('id', 'drawing_number', 'name') 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) return redirect(next_url)
bad = list(ProductEntity.objects.filter(id__in=list(getattr(ev, 'missing_material_ids', []) or [])).values_list('drawing_number', 'name')) bad = list(ProductEntity.objects.filter(id__in=list(getattr(ev, 'missing_material_ids', []) or [])).values_list('drawing_number', 'name'))