Ввел логику сделок через партии, дополнение 2
All checks were successful
Deploy MES Core / deploy (push) Successful in 12s
All checks were successful
Deploy MES Core / deploy (push) Successful in 12s
This commit is contained in:
@@ -13,6 +13,7 @@
|
|||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
- Производственные задачи и прогресс техпроцесса ведутся в разрезе партий поставки (серий) для одной сделки.
|
- Производственные задачи и прогресс техпроцесса ведутся в разрезе партий поставки (серий) для одной сделки.
|
||||||
|
- Улучшено сообщение о блокировке запуска «В производство» при отсутствии техпроцесса: показывается модалка и отдельная страница со списком проблемных позиций.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
- Запуск «В производство» блокируется, если в BOM есть узлы без техпроцесса (EntityOperation seq=1), чтобы компоненты не попадали в «без техпроцесса».
|
- Запуск «В производство» блокируется, если в BOM есть узлы без техпроцесса (EntityOperation seq=1), чтобы компоненты не попадали в «без техпроцесса».
|
||||||
|
|||||||
@@ -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',)
|
||||||
|
|
||||||
# --- Задания на производство (База) ---
|
# --- Задания на производство (База) ---
|
||||||
"""
|
"""
|
||||||
Панель администрирования Заданий на производство
|
Панель администрирования Заданий на производство
|
||||||
|
|||||||
54
shiftflow/templates/shiftflow/missing_techprocess.html
Normal file
54
shiftflow/templates/shiftflow/missing_techprocess.html
Normal 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 %}
|
||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|||||||
@@ -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'))
|
||||||
|
|||||||
Reference in New Issue
Block a user