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 @@
+
@@ -941,12 +943,14 @@ document.addEventListener('DOMContentLoaded', function () {
modal.addEventListener('shown.bs.modal', function (event) {
const btn = event.relatedTarget;
const entityId = btn.getAttribute('data-entity-id') || '';
+ const batchId = btn.getAttribute('data-batch-id') || '';
const opId = btn.getAttribute('data-operation-id') || '';
const name = btn.getAttribute('data-task-name') || '';
const opName = btn.getAttribute('data-operation-name') || '';
const rem = btn.getAttribute('data-task-rem');
document.getElementById('wiEntityId').value = entityId;
+ document.getElementById('wiBatchId').value = batchId;
document.getElementById('wiOperationId').value = opId;
document.getElementById('wiTitle').textContent = name;
diff --git a/shiftflow/templates/shiftflow/product_info_assembly.html b/shiftflow/templates/shiftflow/product_info_assembly.html
index ba68b5b..128d0b6 100644
--- a/shiftflow/templates/shiftflow/product_info_assembly.html
+++ b/shiftflow/templates/shiftflow/product_info_assembly.html
@@ -594,4 +594,44 @@
+
+{{ 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 @@
-
+
+{{ 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}"