diff --git a/CHANGELOG.md b/CHANGELOG.md index ce46b89..3526247 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,10 +12,11 @@ - ### Changed -- +- Производственные задачи и прогресс техпроцесса ведутся в разрезе партий поставки (серий) для одной сделки. ### Fixed -- +- Запуск «В производство» блокируется, если в BOM есть узлы без техпроцесса (EntityOperation seq=1), чтобы компоненты не попадали в «без техпроцесса». +- Повторный запуск в производство по новой серии не увеличивает объём в уже закрытых задачах прошлых серий. ## [0.7.1] - 2026-04-16 ### Added diff --git a/core/settings.py b/core/settings.py index ee3e185..6814aa3 100644 --- a/core/settings.py +++ b/core/settings.py @@ -30,7 +30,7 @@ if os.path.exists(env_file): # читаем переменную окружения ENV_TYPE = os.getenv('ENV_TYPE', 'local') -APP_VERSION = '0.7.1' +APP_VERSION = '0.8.0' # Настройки безопасности # DEBUG будет True везде, кроме сервера diff --git a/shiftflow/migrations/0035_alter_dealentityprogress_unique_together_and_more.py b/shiftflow/migrations/0035_alter_dealentityprogress_unique_together_and_more.py new file mode 100644 index 0000000..6cd6674 --- /dev/null +++ b/shiftflow/migrations/0035_alter_dealentityprogress_unique_together_and_more.py @@ -0,0 +1,38 @@ +# Generated by Django 6.0.3 on 2026-04-22 05:13 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('manufacturing', '0007_remove_productentity_route_delete_routestub'), + ('shiftflow', '0034_workitem_quantity_reported_and_more'), + ] + + operations = [ + migrations.AlterUniqueTogether( + name='dealentityprogress', + unique_together=set(), + ), + migrations.AddField( + model_name='dealentityprogress', + name='delivery_batch', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='entity_progress', to='shiftflow.dealdeliverybatch', verbose_name='Партия поставки'), + ), + migrations.AddField( + model_name='productiontask', + name='delivery_batch', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='production_tasks', to='shiftflow.dealdeliverybatch', verbose_name='Партия поставки'), + ), + migrations.AddField( + model_name='workitem', + name='delivery_batch', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='workitems', to='shiftflow.dealdeliverybatch', verbose_name='Партия поставки'), + ), + migrations.AlterUniqueTogether( + name='dealentityprogress', + unique_together={('deal', 'delivery_batch', 'entity')}, + ), + ] diff --git a/shiftflow/models.py b/shiftflow/models.py index cda6786..6a1f0be 100644 --- a/shiftflow/models.py +++ b/shiftflow/models.py @@ -97,6 +97,14 @@ class ProductionTask(models.Model): """ deal = models.ForeignKey(Deal, on_delete=models.CASCADE, verbose_name="Сделка") + delivery_batch = models.ForeignKey( + 'shiftflow.DealDeliveryBatch', + on_delete=models.PROTECT, + null=True, + blank=True, + related_name='production_tasks', + verbose_name='Партия поставки', + ) entity = models.ForeignKey('manufacturing.ProductEntity', on_delete=models.PROTECT, null=True, blank=True, verbose_name="КД (изделие/деталь)") drawing_name = models.CharField("Название детали", max_length=255, blank=True, default="Б/ч") @@ -227,20 +235,28 @@ class DealBatchItem(models.Model): class DealEntityProgress(models.Model): - """Текущая операция техпроцесса для пары (сделка, сущность). + """Текущая операция техпроцесса для пары (сделка, партия, сущность). Комментарий: current_seq=1 означает «выполняем 1-ю операцию в EntityOperation». - Когда current_seq больше числа операций — сущность для сделки считается прошедшей техпроцесс. + Когда current_seq больше числа операций — сущность для партии считается прошедшей техпроцесс. """ deal = models.ForeignKey(Deal, on_delete=models.CASCADE, verbose_name='Сделка') + delivery_batch = models.ForeignKey( + 'shiftflow.DealDeliveryBatch', + on_delete=models.PROTECT, + null=True, + blank=True, + related_name='entity_progress', + verbose_name='Партия поставки', + ) entity = models.ForeignKey('manufacturing.ProductEntity', on_delete=models.PROTECT, verbose_name='Сущность') current_seq = models.PositiveSmallIntegerField('Текущая операция (порядок)', default=1) class Meta: verbose_name = 'Прогресс по операции' verbose_name_plural = 'Прогресс по операциям' - unique_together = ('deal', 'entity') + unique_together = ('deal', 'delivery_batch', 'entity') def __str__(self): return f"{self.deal.number}: {self.entity} -> {self.current_seq}" @@ -309,6 +325,14 @@ class ProcurementRequirement(models.Model): class WorkItem(models.Model): deal = models.ForeignKey(Deal, on_delete=models.CASCADE, verbose_name='Сделка') + delivery_batch = models.ForeignKey( + 'shiftflow.DealDeliveryBatch', + on_delete=models.PROTECT, + null=True, + blank=True, + related_name='workitems', + verbose_name='Партия поставки', + ) entity = models.ForeignKey('manufacturing.ProductEntity', on_delete=models.PROTECT, verbose_name='Сущность') # Комментарий: operation — основной признак операции (расширяемый справочник). diff --git a/shiftflow/services/bom_explosion.py b/shiftflow/services/bom_explosion.py index eedb415..626d1e9 100644 --- a/shiftflow/services/bom_explosion.py +++ b/shiftflow/services/bom_explosion.py @@ -8,7 +8,7 @@ from django.db import models, transaction from django.db.models import Sum from django.db.models.functions import Coalesce -from manufacturing.models import BOM, ProductEntity +from manufacturing.models import BOM, EntityOperation, ProductEntity from shiftflow.models import Deal, DealItem, ProcurementRequirement, ProductionTask from warehouse.models import StockItem @@ -16,9 +16,19 @@ logger = logging.getLogger('mes') class ExplosionValidationError(Exception): - def __init__(self, missing_material_ids: list[int]): - super().__init__('missing_material') + def __init__( + self, + *, + missing_material_ids: list[int] | None = None, + missing_route_entity_ids: list[int] | None = None, + ): self.missing_material_ids = [int(x) for x in (missing_material_ids or [])] + self.missing_route_entity_ids = [int(x) for x in (missing_route_entity_ids or [])] + + if self.missing_route_entity_ids: + super().__init__('missing_tech_process') + else: + super().__init__('missing_material') @dataclass(frozen=True) @@ -338,6 +348,8 @@ def explode_deal( def explode_roots_additive( deal_id: int, roots: list[tuple[int, int]], + *, + delivery_batch_id: int | None = None, ) -> ExplosionStats: """Additive BOM Explosion для запуска в производство по частям. @@ -370,13 +382,34 @@ def explode_roots_additive( .filter(id__in=list(required_nodes.keys())) } - missing = [ + missing_material = [ int(e.id) for e in entities.values() - if (getattr(e, 'entity_type', '') == 'part' and not getattr(e, 'planned_material_id', None) and int(required_nodes.get(int(e.id), 0) or 0) > 0) + if ( + (getattr(e, 'entity_type', '') == 'part') + and not getattr(e, 'planned_material_id', None) + and int(required_nodes.get(int(e.id), 0) or 0) > 0 + ) ] - if missing: - raise ExplosionValidationError(missing) + if missing_material: + raise ExplosionValidationError(missing_material_ids=missing_material) + + internal_types = {'part', 'assembly', 'product'} + internal_ids = [ + int(e.id) + for e in entities.values() + if ( + (getattr(e, 'entity_type', '') or '').strip() in internal_types + and int(required_nodes.get(int(e.id), 0) or 0) > 0 + ) + ] + if internal_ids: + routed = set( + EntityOperation.objects.filter(entity_id__in=internal_ids, seq=1).values_list('entity_id', flat=True) + ) + missing_route = sorted({int(x) for x in internal_ids} - {int(x) for x in routed}) + if missing_route: + raise ExplosionValidationError(missing_route_entity_ids=missing_route) tasks_created = 0 tasks_updated = 0 @@ -408,6 +441,7 @@ def explode_roots_additive( pt, created = ProductionTask.objects.get_or_create( deal=deal, + delivery_batch_id=(int(delivery_batch_id) if delivery_batch_id else None), entity=entity, defaults=defaults, ) @@ -427,8 +461,9 @@ def explode_roots_additive( tasks_updated += 1 logger.info( - 'explode_roots_additive: deal_id=%s roots=%s nodes=%s tasks_created=%s tasks_updated=%s skipped_no_material=%s skipped_supply=%s', + 'explode_roots_additive: deal_id=%s batch_id=%s roots=%s nodes=%s tasks_created=%s tasks_updated=%s skipped_no_material=%s skipped_supply=%s', deal_id, + delivery_batch_id, roots, len(required_nodes), tasks_created, @@ -443,6 +478,8 @@ def explode_roots_additive( def rollback_roots_additive( deal_id: int, roots: list[tuple[int, int]], + *, + delivery_batch_id: int | None = None, ) -> ExplosionStats: """Откат additive BOM Explosion. @@ -485,7 +522,11 @@ def rollback_roots_additive( skipped_supply += 1 continue - pt = ProductionTask.objects.filter(deal=deal, entity=entity).first() + pt = ProductionTask.objects.filter( + deal=deal, + delivery_batch_id=(int(delivery_batch_id) if delivery_batch_id else None), + entity=entity, + ).first() if not pt: missing_tasks += 1 continue @@ -501,8 +542,9 @@ def rollback_roots_additive( tasks_updated += 1 logger.info( - 'rollback_roots_additive: deal_id=%s roots=%s nodes=%s tasks_updated=%s skipped_supply=%s missing_tasks=%s', + 'rollback_roots_additive: deal_id=%s batch_id=%s roots=%s nodes=%s tasks_updated=%s skipped_supply=%s missing_tasks=%s', deal_id, + delivery_batch_id, roots, len(required_nodes), tasks_updated, diff --git a/shiftflow/services/closing.py b/shiftflow/services/closing.py index 537c42a..629d24a 100644 --- a/shiftflow/services/closing.py +++ b/shiftflow/services/closing.py @@ -185,7 +185,12 @@ def apply_closing_workitems( if fact <= 0: raise RuntimeError('При частичном закрытии факт должен быть больше 0.') - pt = ProductionTask.objects.filter(deal_id=wi.deal_id, entity_id=wi.entity_id).first() + pt_qs = ProductionTask.objects.filter(deal_id=wi.deal_id, entity_id=wi.entity_id) + if getattr(wi, 'delivery_batch_id', None): + pt_qs = pt_qs.filter(delivery_batch_id=wi.delivery_batch_id) + else: + pt_qs = pt_qs.filter(delivery_batch_id__isnull=True) + pt = pt_qs.first() if not pt: raise RuntimeError('Не найден ProductionTask для задания.') diff --git a/shiftflow/templates/shiftflow/planning_deal.html b/shiftflow/templates/shiftflow/planning_deal.html index 4ebfc85..49ebec2 100644 --- a/shiftflow/templates/shiftflow/planning_deal.html +++ b/shiftflow/templates/shiftflow/planning_deal.html @@ -373,6 +373,7 @@ data-bs-toggle="modal" data-bs-target="#workItemModal" data-entity-id="{{ t.entity_id }}" + data-batch-id="{{ t.delivery_batch_id|default:'' }}" data-operation-id="{{ t.current_operation_id }}" data-workshop-id="{{ t.current_workshop_id|default:'' }}" data-workshop-name="{{ t.current_workshop_name|default:'' }}" @@ -458,6 +459,7 @@ + +{{ next|json_script:"productInfoNext" }} + {% endblock %} \ No newline at end of file diff --git a/shiftflow/templates/shiftflow/product_info_casting.html b/shiftflow/templates/shiftflow/product_info_casting.html index b9b0766..4a3b96e 100644 --- a/shiftflow/templates/shiftflow/product_info_casting.html +++ b/shiftflow/templates/shiftflow/product_info_casting.html @@ -275,4 +275,44 @@ + +{{ next|json_script:"productInfoNext" }} + {% endblock %} \ No newline at end of file diff --git a/shiftflow/templates/shiftflow/product_info_external.html b/shiftflow/templates/shiftflow/product_info_external.html index c5dfae3..fec3f91 100644 --- a/shiftflow/templates/shiftflow/product_info_external.html +++ b/shiftflow/templates/shiftflow/product_info_external.html @@ -24,7 +24,7 @@
-
+ {% csrf_token %} @@ -64,4 +64,44 @@
+ +{{ next|json_script:"productInfoNext" }} + {% endblock %} \ No newline at end of file diff --git a/shiftflow/templates/shiftflow/product_info_outsourced.html b/shiftflow/templates/shiftflow/product_info_outsourced.html index 49cec3f..67ab267 100644 --- a/shiftflow/templates/shiftflow/product_info_outsourced.html +++ b/shiftflow/templates/shiftflow/product_info_outsourced.html @@ -275,4 +275,44 @@ + +{{ next|json_script:"productInfoNext" }} + {% endblock %} \ No newline at end of file diff --git a/shiftflow/templates/shiftflow/product_info_part.html b/shiftflow/templates/shiftflow/product_info_part.html index 7168030..98e4a7b 100644 --- a/shiftflow/templates/shiftflow/product_info_part.html +++ b/shiftflow/templates/shiftflow/product_info_part.html @@ -310,4 +310,44 @@ + +{{ next|json_script:"productInfoNext" }} + {% endblock %} \ No newline at end of file diff --git a/shiftflow/templates/shiftflow/product_info_purchased.html b/shiftflow/templates/shiftflow/product_info_purchased.html index 1f6ba27..c2ac232 100644 --- a/shiftflow/templates/shiftflow/product_info_purchased.html +++ b/shiftflow/templates/shiftflow/product_info_purchased.html @@ -270,4 +270,44 @@ + +{{ next|json_script:"productInfoNext" }} + {% endblock %} \ No newline at end of file diff --git a/shiftflow/views.py b/shiftflow/views.py index 67d87ee..02c72e3 100644 --- a/shiftflow/views.py +++ b/shiftflow/views.py @@ -962,9 +962,20 @@ class WorkItemUpdateView(LoginRequiredMixin, View): wi.save(update_fields=list(dict.fromkeys(changed_fields))) - # Комментарий: автоматический переход на следующую операцию по маршруту для пары (сделка, сущность). - # Сдвигаем только когда выполнено количество по позиции сделки. - if ordered_qty is not None: + # Комментарий: автоматический переход на следующую операцию по маршруту. + # Для партийного производства двигаем прогресс в рамках партии, ориентируясь на объём серии. + target_qty = None + if getattr(wi, 'delivery_batch_id', None): + target_qty = ProductionTask.objects.filter( + deal_id=wi.deal_id, + delivery_batch_id=wi.delivery_batch_id, + entity_id=wi.entity_id, + ).values_list('quantity_ordered', flat=True).first() + else: + deal_item = DealItem.objects.filter(deal_id=wi.deal_id, entity_id=wi.entity_id).first() + target_qty = int(deal_item.quantity) if deal_item else None + + if target_qty is not None: op_code = None if getattr(wi, 'operation_id', None): op_code = Operation.objects.filter(pk=wi.operation_id).values_list('code', flat=True).first() @@ -972,13 +983,26 @@ class WorkItemUpdateView(LoginRequiredMixin, View): op_code = (wi.stage or '').strip() if op_code: - progress, _ = DealEntityProgress.objects.get_or_create(deal_id=wi.deal_id, entity_id=wi.entity_id, defaults={'current_seq': 1}) + progress, _ = DealEntityProgress.objects.get_or_create( + deal_id=wi.deal_id, + delivery_batch_id=(int(wi.delivery_batch_id) if getattr(wi, 'delivery_batch_id', None) else None), + entity_id=wi.entity_id, + defaults={'current_seq': 1}, + ) cur = int(progress.current_seq or 1) cur_eo = EntityOperation.objects.select_related('operation').filter(entity_id=wi.entity_id, seq=cur).first() if cur_eo and cur_eo.operation and cur_eo.operation.code == op_code: - total_done = WorkItem.objects.filter(deal_id=wi.deal_id, entity_id=wi.entity_id).filter(Q(operation__code=op_code) | Q(stage=op_code)).aggregate(s=Coalesce(Sum('quantity_done'), 0))['s'] - if int(total_done or 0) >= int(ordered_qty): + wi_qs = WorkItem.objects.filter(deal_id=wi.deal_id, entity_id=wi.entity_id).filter( + Q(operation__code=op_code) | Q(stage=op_code) + ) + if getattr(wi, 'delivery_batch_id', None): + wi_qs = wi_qs.filter(delivery_batch_id=wi.delivery_batch_id) + else: + wi_qs = wi_qs.filter(delivery_batch_id__isnull=True) + + total_done = wi_qs.aggregate(s=Coalesce(Sum('quantity_done'), 0))['s'] + if int(total_done or 0) >= int(target_qty): progress.current_seq = cur + 1 progress.save(update_fields=['current_seq']) @@ -1396,9 +1420,18 @@ class WorkItemOpClosingView(LoginRequiredMixin, TemplateView): wi.status = 'planned' wi.save(update_fields=['quantity_done', 'status']) - deal_item = DealItem.objects.filter(deal_id=wi.deal_id, entity_id=wi.entity_id).first() - ordered_qty = int(deal_item.quantity) if deal_item else None - if ordered_qty is not None: + target_qty = None + if getattr(wi, 'delivery_batch_id', None): + target_qty = ProductionTask.objects.filter( + deal_id=wi.deal_id, + delivery_batch_id=wi.delivery_batch_id, + entity_id=wi.entity_id, + ).values_list('quantity_ordered', flat=True).first() + else: + deal_item = DealItem.objects.filter(deal_id=wi.deal_id, entity_id=wi.entity_id).first() + target_qty = int(deal_item.quantity) if deal_item else None + + if target_qty is not None: op_code = None if getattr(wi, 'operation_id', None): op_code = Operation.objects.filter(pk=wi.operation_id).values_list('code', flat=True).first() @@ -1406,12 +1439,25 @@ class WorkItemOpClosingView(LoginRequiredMixin, TemplateView): op_code = (wi.stage or '').strip() if op_code: - progress, _ = DealEntityProgress.objects.get_or_create(deal_id=wi.deal_id, entity_id=wi.entity_id, defaults={'current_seq': 1}) + progress, _ = DealEntityProgress.objects.get_or_create( + deal_id=wi.deal_id, + delivery_batch_id=(int(wi.delivery_batch_id) if getattr(wi, 'delivery_batch_id', None) else None), + entity_id=wi.entity_id, + defaults={'current_seq': 1}, + ) cur = int(progress.current_seq or 1) cur_eo = EntityOperation.objects.select_related('operation').filter(entity_id=wi.entity_id, seq=cur).first() if cur_eo and cur_eo.operation and cur_eo.operation.code == op_code: - total_done = WorkItem.objects.filter(deal_id=wi.deal_id, entity_id=wi.entity_id).filter(Q(operation__code=op_code) | Q(stage=op_code)).aggregate(s=Coalesce(Sum('quantity_done'), 0))['s'] - if int(total_done or 0) >= int(ordered_qty): + wi_qs = WorkItem.objects.filter(deal_id=wi.deal_id, entity_id=wi.entity_id).filter( + Q(operation__code=op_code) | Q(stage=op_code) + ) + if getattr(wi, 'delivery_batch_id', None): + wi_qs = wi_qs.filter(delivery_batch_id=wi.delivery_batch_id) + else: + wi_qs = wi_qs.filter(delivery_batch_id__isnull=True) + + total_done = wi_qs.aggregate(s=Coalesce(Sum('quantity_done'), 0))['s'] + if int(total_done or 0) >= int(target_qty): progress.current_seq = cur + 1 progress.save(update_fields=['current_seq']) @@ -2052,14 +2098,22 @@ class DealPlanningView(LoginRequiredMixin, TemplateView): tasks = list( ProductionTask.objects.filter(deal=deal) - .select_related('material', 'entity') + .select_related('material', 'entity', 'delivery_batch') .order_by('-id') ) task_entity_ids = {int(x.entity_id) for x in tasks if getattr(x, 'entity_id', None)} + task_batch_ids = {int(x.delivery_batch_id) for x in tasks if getattr(x, 'delivery_batch_id', None)} + + progress_qs = DealEntityProgress.objects.filter(deal=deal, entity_id__in=list(task_entity_ids)) + if task_batch_ids: + progress_qs = progress_qs.filter(Q(delivery_batch_id__in=list(task_batch_ids)) | Q(delivery_batch_id__isnull=True)) + else: + progress_qs = progress_qs.filter(delivery_batch_id__isnull=True) + progress_task_map = { - int(p.entity_id): int(p.current_seq or 1) - for p in DealEntityProgress.objects.filter(deal=deal, entity_id__in=list(task_entity_ids)) + ((int(p.delivery_batch_id) if getattr(p, 'delivery_batch_id', None) else None), int(p.entity_id)): int(p.current_seq or 1) + for p in progress_qs } ops_task_map = {} for eo in ( @@ -2078,7 +2132,8 @@ class DealPlanningView(LoginRequiredMixin, TemplateView): if not getattr(t, 'entity_id', None): continue - seq = int(progress_task_map.get(int(t.entity_id), 1) or 1) + batch_key = int(t.delivery_batch_id) if getattr(t, 'delivery_batch_id', None) else None + seq = int(progress_task_map.get((batch_key, int(t.entity_id)), 1) or 1) eo = ops_task_map.get((int(t.entity_id), seq)) if not eo: continue @@ -2093,8 +2148,12 @@ class DealPlanningView(LoginRequiredMixin, TemplateView): wi_qs = wi_qs.filter(workshop_id__in=allowed_ws) wi_sums = { - (int(r['entity_id']), int(r['operation_id'])): (int(r['planned'] or 0), int(r['done'] or 0)) - for r in wi_qs.values('entity_id', 'operation_id').annotate( + ( + (int(r['delivery_batch_id']) if r['delivery_batch_id'] is not None else None), + int(r['entity_id']), + int(r['operation_id']), + ): (int(r['planned'] or 0), int(r['done'] or 0)) + for r in wi_qs.values('delivery_batch_id', 'entity_id', 'operation_id').annotate( planned=Coalesce(Sum('quantity_plan'), 0), done=Coalesce(Sum('quantity_done'), 0), ) @@ -2108,7 +2167,11 @@ class DealPlanningView(LoginRequiredMixin, TemplateView): need = int(t.quantity_ordered or 0) key = None if getattr(t, 'entity_id', None) and getattr(t, 'current_operation_id', None): - key = (int(t.entity_id), int(t.current_operation_id)) + key = ( + (int(t.delivery_batch_id) if getattr(t, 'delivery_batch_id', None) else None), + int(t.entity_id), + int(t.current_operation_id), + ) planned_qty, done_qty = wi_sums.get(key, (0, 0)) if key else (0, 0) planned_qty = int(planned_qty or 0) @@ -2649,6 +2712,7 @@ class WorkItemPlanAddView(LoginRequiredMixin, View): return int(s) if s.isdigit() else None deal_id = parse_int(request.POST.get('deal_id')) + delivery_batch_id = parse_int(request.POST.get('delivery_batch_id')) entity_id = parse_int(request.POST.get('entity_id')) operation_id = parse_int(request.POST.get('operation_id')) machine_id = parse_int(request.POST.get('machine_id')) @@ -2703,7 +2767,11 @@ class WorkItemPlanAddView(LoginRequiredMixin, View): progress_map = { int(p.entity_id): int(p.current_seq or 1) - for p in DealEntityProgress.objects.filter(deal_id=int(deal_id), entity_id__in=node_ids) + for p in DealEntityProgress.objects.filter( + deal_id=int(deal_id), + delivery_batch_id=(int(delivery_batch_id) if delivery_batch_id else None), + entity_id__in=node_ids, + ) } ops_map = { @@ -2718,6 +2786,7 @@ class WorkItemPlanAddView(LoginRequiredMixin, View): if int(c_id) == int(entity_id): WorkItem.objects.create( deal_id=deal_id, + delivery_batch_id=(int(delivery_batch_id) if delivery_batch_id else None), entity_id=entity_id, operation_id=operation_id, workshop_id=resolved_workshop_id, @@ -2739,6 +2808,7 @@ class WorkItemPlanAddView(LoginRequiredMixin, View): cur_op = eo.operation WorkItem.objects.create( deal_id=deal_id, + delivery_batch_id=(int(delivery_batch_id) if delivery_batch_id else None), entity_id=int(c_id), operation_id=int(cur_op.id), workshop_id=(int(cur_op.workshop_id) if getattr(cur_op, 'workshop_id', None) else None), @@ -2758,6 +2828,7 @@ class WorkItemPlanAddView(LoginRequiredMixin, View): else: wi = WorkItem.objects.create( deal_id=int(deal_id), + delivery_batch_id=(int(delivery_batch_id) if delivery_batch_id else None), entity_id=int(entity_id), operation_id=int(operation_id), workshop_id=resolved_workshop_id, @@ -3281,7 +3352,11 @@ class DealBatchActionView(LoginRequiredMixin, View): messages.error(request, 'Нельзя откатить: по этой позиции уже есть постановка в смену (план/факт).') return redirect(next_url) - stats = rollback_roots_additive(int(deal_id), [(int(bi.entity_id), int(qty))]) + stats = rollback_roots_additive( + int(deal_id), + [(int(bi.entity_id), int(qty))], + delivery_batch_id=int(bi.batch_id), + ) bi.started_qty = started - int(qty) bi.save(update_fields=['started_qty']) @@ -3336,7 +3411,11 @@ class DealBatchActionView(LoginRequiredMixin, View): logger.info('start_batch_item_production: qty_exceeds_remaining remaining=%s started=%s total=%s', remaining, started, total) return redirect(next_url) - stats = explode_roots_additive(int(deal_id), [(int(bi.entity_id), int(qty))]) + stats = explode_roots_additive( + int(deal_id), + [(int(bi.entity_id), int(qty))], + delivery_batch_id=int(bi.batch_id), + ) bi.started_qty = started + int(qty) bi.save(update_fields=['started_qty']) @@ -3354,11 +3433,22 @@ class DealBatchActionView(LoginRequiredMixin, View): ) return redirect(next_url) except ExplosionValidationError as ev: - try: - from manufacturing.models import ProductEntity - bad = list(ProductEntity.objects.filter(id__in=list(ev.missing_material_ids)).values_list('drawing_number', 'name')) - except Exception: - bad = [] + 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') + ) + 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')) if bad: preview = ", ".join([f"{dn or '—'} {nm}" for dn, nm in bad[:5]]) more = '' if len(bad) <= 5 else f" и ещё {len(bad)-5}"