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

This commit is contained in:
2026-04-22 08:35:07 +03:00
parent 3efd8e5060
commit 6da7b775c7
14 changed files with 489 additions and 45 deletions

View File

@@ -12,10 +12,11 @@
- -
### Changed ### Changed
- - Производственные задачи и прогресс техпроцесса ведутся в разрезе партий поставки (серий) для одной сделки.
### Fixed ### Fixed
- - Запуск «В производство» блокируется, если в BOM есть узлы без техпроцесса (EntityOperation seq=1), чтобы компоненты не попадали в «без техпроцесса».
- Повторный запуск в производство по новой серии не увеличивает объём в уже закрытых задачах прошлых серий.
## [0.7.1] - 2026-04-16 ## [0.7.1] - 2026-04-16
### Added ### Added

View File

@@ -30,7 +30,7 @@ if os.path.exists(env_file):
# читаем переменную окружения # читаем переменную окружения
ENV_TYPE = os.getenv('ENV_TYPE', 'local') ENV_TYPE = os.getenv('ENV_TYPE', 'local')
APP_VERSION = '0.7.1' APP_VERSION = '0.8.0'
# Настройки безопасности # Настройки безопасности
# DEBUG будет True везде, кроме сервера # DEBUG будет True везде, кроме сервера

View File

@@ -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')},
),
]

View File

@@ -97,6 +97,14 @@ class ProductionTask(models.Model):
""" """
deal = models.ForeignKey(Deal, on_delete=models.CASCADE, verbose_name="Сделка") 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="КД (изделие/деталь)") 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="Б/ч") drawing_name = models.CharField("Название детали", max_length=255, blank=True, default="Б/ч")
@@ -227,20 +235,28 @@ class DealBatchItem(models.Model):
class DealEntityProgress(models.Model): class DealEntityProgress(models.Model):
"""Текущая операция техпроцесса для пары (сделка, сущность). """Текущая операция техпроцесса для пары (сделка, партия, сущность).
Комментарий: current_seq=1 означает «выполняем 1-ю операцию в EntityOperation». Комментарий: current_seq=1 означает «выполняем 1-ю операцию в EntityOperation».
Когда current_seq больше числа операций — сущность для сделки считается прошедшей техпроцесс. Когда current_seq больше числа операций — сущность для партии считается прошедшей техпроцесс.
""" """
deal = models.ForeignKey(Deal, on_delete=models.CASCADE, verbose_name='Сделка') 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='Сущность') entity = models.ForeignKey('manufacturing.ProductEntity', on_delete=models.PROTECT, verbose_name='Сущность')
current_seq = models.PositiveSmallIntegerField('Текущая операция (порядок)', default=1) current_seq = models.PositiveSmallIntegerField('Текущая операция (порядок)', default=1)
class Meta: class Meta:
verbose_name = 'Прогресс по операции' verbose_name = 'Прогресс по операции'
verbose_name_plural = 'Прогресс по операциям' verbose_name_plural = 'Прогресс по операциям'
unique_together = ('deal', 'entity') unique_together = ('deal', 'delivery_batch', 'entity')
def __str__(self): def __str__(self):
return f"{self.deal.number}: {self.entity} -> {self.current_seq}" return f"{self.deal.number}: {self.entity} -> {self.current_seq}"
@@ -309,6 +325,14 @@ class ProcurementRequirement(models.Model):
class WorkItem(models.Model): class WorkItem(models.Model):
deal = models.ForeignKey(Deal, on_delete=models.CASCADE, verbose_name='Сделка') 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='Сущность') entity = models.ForeignKey('manufacturing.ProductEntity', on_delete=models.PROTECT, verbose_name='Сущность')
# Комментарий: operation — основной признак операции (расширяемый справочник). # Комментарий: operation — основной признак операции (расширяемый справочник).

View File

@@ -8,7 +8,7 @@ from django.db import models, transaction
from django.db.models import Sum from django.db.models import Sum
from django.db.models.functions import Coalesce 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 shiftflow.models import Deal, DealItem, ProcurementRequirement, ProductionTask
from warehouse.models import StockItem from warehouse.models import StockItem
@@ -16,9 +16,19 @@ logger = logging.getLogger('mes')
class ExplosionValidationError(Exception): class ExplosionValidationError(Exception):
def __init__(self, missing_material_ids: list[int]): def __init__(
super().__init__('missing_material') 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_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) @dataclass(frozen=True)
@@ -338,6 +348,8 @@ def explode_deal(
def explode_roots_additive( def explode_roots_additive(
deal_id: int, deal_id: int,
roots: list[tuple[int, int]], roots: list[tuple[int, int]],
*,
delivery_batch_id: int | None = None,
) -> ExplosionStats: ) -> ExplosionStats:
"""Additive BOM Explosion для запуска в производство по частям. """Additive BOM Explosion для запуска в производство по частям.
@@ -370,13 +382,34 @@ def explode_roots_additive(
.filter(id__in=list(required_nodes.keys())) .filter(id__in=list(required_nodes.keys()))
} }
missing = [ missing_material = [
int(e.id) int(e.id)
for e in entities.values() 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: if missing_material:
raise ExplosionValidationError(missing) 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_created = 0
tasks_updated = 0 tasks_updated = 0
@@ -408,6 +441,7 @@ def explode_roots_additive(
pt, created = ProductionTask.objects.get_or_create( pt, created = ProductionTask.objects.get_or_create(
deal=deal, deal=deal,
delivery_batch_id=(int(delivery_batch_id) if delivery_batch_id else None),
entity=entity, entity=entity,
defaults=defaults, defaults=defaults,
) )
@@ -427,8 +461,9 @@ def explode_roots_additive(
tasks_updated += 1 tasks_updated += 1
logger.info( 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, deal_id,
delivery_batch_id,
roots, roots,
len(required_nodes), len(required_nodes),
tasks_created, tasks_created,
@@ -443,6 +478,8 @@ def explode_roots_additive(
def rollback_roots_additive( def rollback_roots_additive(
deal_id: int, deal_id: int,
roots: list[tuple[int, int]], roots: list[tuple[int, int]],
*,
delivery_batch_id: int | None = None,
) -> ExplosionStats: ) -> ExplosionStats:
"""Откат additive BOM Explosion. """Откат additive BOM Explosion.
@@ -485,7 +522,11 @@ def rollback_roots_additive(
skipped_supply += 1 skipped_supply += 1
continue 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: if not pt:
missing_tasks += 1 missing_tasks += 1
continue continue
@@ -501,8 +542,9 @@ def rollback_roots_additive(
tasks_updated += 1 tasks_updated += 1
logger.info( 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, deal_id,
delivery_batch_id,
roots, roots,
len(required_nodes), len(required_nodes),
tasks_updated, tasks_updated,

View File

@@ -185,7 +185,12 @@ def apply_closing_workitems(
if fact <= 0: if fact <= 0:
raise RuntimeError('При частичном закрытии факт должен быть больше 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: if not pt:
raise RuntimeError('Не найден ProductionTask для задания.') raise RuntimeError('Не найден ProductionTask для задания.')

View File

@@ -373,6 +373,7 @@
data-bs-toggle="modal" data-bs-toggle="modal"
data-bs-target="#workItemModal" data-bs-target="#workItemModal"
data-entity-id="{{ t.entity_id }}" data-entity-id="{{ t.entity_id }}"
data-batch-id="{{ t.delivery_batch_id|default:'' }}"
data-operation-id="{{ t.current_operation_id }}" data-operation-id="{{ t.current_operation_id }}"
data-workshop-id="{{ t.current_workshop_id|default:'' }}" data-workshop-id="{{ t.current_workshop_id|default:'' }}"
data-workshop-name="{{ t.current_workshop_name|default:'' }}" data-workshop-name="{{ t.current_workshop_name|default:'' }}"
@@ -458,6 +459,7 @@
</div> </div>
<div class="modal-body"> <div class="modal-body">
<input type="hidden" name="entity_id" id="wiEntityId"> <input type="hidden" name="entity_id" id="wiEntityId">
<input type="hidden" name="delivery_batch_id" id="wiBatchId">
<input type="hidden" name="operation_id" id="wiOperationId"> <input type="hidden" name="operation_id" id="wiOperationId">
<div class="small text-muted mb-2" id="wiTitle"></div> <div class="small text-muted mb-2" id="wiTitle"></div>
@@ -941,12 +943,14 @@ document.addEventListener('DOMContentLoaded', function () {
modal.addEventListener('shown.bs.modal', function (event) { modal.addEventListener('shown.bs.modal', function (event) {
const btn = event.relatedTarget; const btn = event.relatedTarget;
const entityId = btn.getAttribute('data-entity-id') || ''; const entityId = btn.getAttribute('data-entity-id') || '';
const batchId = btn.getAttribute('data-batch-id') || '';
const opId = btn.getAttribute('data-operation-id') || ''; const opId = btn.getAttribute('data-operation-id') || '';
const name = btn.getAttribute('data-task-name') || ''; const name = btn.getAttribute('data-task-name') || '';
const opName = btn.getAttribute('data-operation-name') || ''; const opName = btn.getAttribute('data-operation-name') || '';
const rem = btn.getAttribute('data-task-rem'); const rem = btn.getAttribute('data-task-rem');
document.getElementById('wiEntityId').value = entityId; document.getElementById('wiEntityId').value = entityId;
document.getElementById('wiBatchId').value = batchId;
document.getElementById('wiOperationId').value = opId; document.getElementById('wiOperationId').value = opId;
document.getElementById('wiTitle').textContent = name; document.getElementById('wiTitle').textContent = name;

View File

@@ -594,4 +594,44 @@
</div> </div>
</div> </div>
</div> </div>
{{ next|json_script:"productInfoNext" }}
<script>
document.addEventListener('DOMContentLoaded', function () {
const form = document.getElementById('product-info-form');
function getNextUrl() {
const el = document.getElementById('productInfoNext');
return el ? JSON.parse(el.textContent || '""') : '';
}
function submitForm() {
if (!form) return;
if (form.requestSubmit) {
form.requestSubmit();
} else {
form.submit();
}
}
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') {
if (e.defaultPrevented) return;
if (document.querySelector('.modal.show')) return;
const url = getNextUrl();
if (url) {
e.preventDefault();
window.location.href = url;
}
return;
}
const key = (e.key || '').toLowerCase();
if ((e.ctrlKey || e.metaKey) && key === 's') {
e.preventDefault();
submitForm();
}
}, true);
});
</script>
{% endblock %} {% endblock %}

View File

@@ -275,4 +275,44 @@
</div> </div>
</div> </div>
</div> </div>
{{ next|json_script:"productInfoNext" }}
<script>
document.addEventListener('DOMContentLoaded', function () {
const form = document.getElementById('product-info-form');
function getNextUrl() {
const el = document.getElementById('productInfoNext');
return el ? JSON.parse(el.textContent || '""') : '';
}
function submitForm() {
if (!form) return;
if (form.requestSubmit) {
form.requestSubmit();
} else {
form.submit();
}
}
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') {
if (e.defaultPrevented) return;
if (document.querySelector('.modal.show')) return;
const url = getNextUrl();
if (url) {
e.preventDefault();
window.location.href = url;
}
return;
}
const key = (e.key || '').toLowerCase();
if ((e.ctrlKey || e.metaKey) && key === 's') {
e.preventDefault();
submitForm();
}
}, true);
});
</script>
{% endblock %} {% endblock %}

View File

@@ -24,7 +24,7 @@
</div> </div>
</div> </div>
<div class="card-body"> <div class="card-body">
<form method="post" action="{% url 'product_info' entity.id %}" class="container-fluid p-0"> <form method="post" action="{% url 'product_info' entity.id %}" class="container-fluid p-0" id="product-info-form">
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="action" value="save"> <input type="hidden" name="action" value="save">
<input type="hidden" name="next" value="{{ next }}"> <input type="hidden" name="next" value="{{ next }}">
@@ -64,4 +64,44 @@
</form> </form>
</div> </div>
</div> </div>
{{ next|json_script:"productInfoNext" }}
<script>
document.addEventListener('DOMContentLoaded', function () {
const form = document.getElementById('product-info-form');
function getNextUrl() {
const el = document.getElementById('productInfoNext');
return el ? JSON.parse(el.textContent || '""') : '';
}
function submitForm() {
if (!form) return;
if (form.requestSubmit) {
form.requestSubmit();
} else {
form.submit();
}
}
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') {
if (e.defaultPrevented) return;
if (document.querySelector('.modal.show')) return;
const url = getNextUrl();
if (url) {
e.preventDefault();
window.location.href = url;
}
return;
}
const key = (e.key || '').toLowerCase();
if ((e.ctrlKey || e.metaKey) && key === 's') {
e.preventDefault();
submitForm();
}
}, true);
});
</script>
{% endblock %} {% endblock %}

View File

@@ -275,4 +275,44 @@
</div> </div>
</div> </div>
</div> </div>
{{ next|json_script:"productInfoNext" }}
<script>
document.addEventListener('DOMContentLoaded', function () {
const form = document.getElementById('product-info-form');
function getNextUrl() {
const el = document.getElementById('productInfoNext');
return el ? JSON.parse(el.textContent || '""') : '';
}
function submitForm() {
if (!form) return;
if (form.requestSubmit) {
form.requestSubmit();
} else {
form.submit();
}
}
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') {
if (e.defaultPrevented) return;
if (document.querySelector('.modal.show')) return;
const url = getNextUrl();
if (url) {
e.preventDefault();
window.location.href = url;
}
return;
}
const key = (e.key || '').toLowerCase();
if ((e.ctrlKey || e.metaKey) && key === 's') {
e.preventDefault();
submitForm();
}
}, true);
});
</script>
{% endblock %} {% endblock %}

View File

@@ -310,4 +310,44 @@
</div> </div>
</div> </div>
</div> </div>
{{ next|json_script:"productInfoNext" }}
<script>
document.addEventListener('DOMContentLoaded', function () {
const form = document.getElementById('product-info-form');
function getNextUrl() {
const el = document.getElementById('productInfoNext');
return el ? JSON.parse(el.textContent || '""') : '';
}
function submitForm() {
if (!form) return;
if (form.requestSubmit) {
form.requestSubmit();
} else {
form.submit();
}
}
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') {
if (e.defaultPrevented) return;
if (document.querySelector('.modal.show')) return;
const url = getNextUrl();
if (url) {
e.preventDefault();
window.location.href = url;
}
return;
}
const key = (e.key || '').toLowerCase();
if ((e.ctrlKey || e.metaKey) && key === 's') {
e.preventDefault();
submitForm();
}
}, true);
});
</script>
{% endblock %} {% endblock %}

View File

@@ -270,4 +270,44 @@
</div> </div>
</div> </div>
</div> </div>
{{ next|json_script:"productInfoNext" }}
<script>
document.addEventListener('DOMContentLoaded', function () {
const form = document.getElementById('product-info-form');
function getNextUrl() {
const el = document.getElementById('productInfoNext');
return el ? JSON.parse(el.textContent || '""') : '';
}
function submitForm() {
if (!form) return;
if (form.requestSubmit) {
form.requestSubmit();
} else {
form.submit();
}
}
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') {
if (e.defaultPrevented) return;
if (document.querySelector('.modal.show')) return;
const url = getNextUrl();
if (url) {
e.preventDefault();
window.location.href = url;
}
return;
}
const key = (e.key || '').toLowerCase();
if ((e.ctrlKey || e.metaKey) && key === 's') {
e.preventDefault();
submitForm();
}
}, true);
});
</script>
{% endblock %} {% endblock %}

View File

@@ -962,9 +962,20 @@ class WorkItemUpdateView(LoginRequiredMixin, View):
wi.save(update_fields=list(dict.fromkeys(changed_fields))) 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 op_code = None
if getattr(wi, 'operation_id', None): if getattr(wi, 'operation_id', None):
op_code = Operation.objects.filter(pk=wi.operation_id).values_list('code', flat=True).first() 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() op_code = (wi.stage or '').strip()
if op_code: 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 = int(progress.current_seq or 1)
cur_eo = EntityOperation.objects.select_related('operation').filter(entity_id=wi.entity_id, seq=cur).first() 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: 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'] wi_qs = WorkItem.objects.filter(deal_id=wi.deal_id, entity_id=wi.entity_id).filter(
if int(total_done or 0) >= int(ordered_qty): 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.current_seq = cur + 1
progress.save(update_fields=['current_seq']) progress.save(update_fields=['current_seq'])
@@ -1396,9 +1420,18 @@ class WorkItemOpClosingView(LoginRequiredMixin, TemplateView):
wi.status = 'planned' wi.status = 'planned'
wi.save(update_fields=['quantity_done', 'status']) wi.save(update_fields=['quantity_done', 'status'])
deal_item = DealItem.objects.filter(deal_id=wi.deal_id, entity_id=wi.entity_id).first() target_qty = None
ordered_qty = int(deal_item.quantity) if deal_item else None if getattr(wi, 'delivery_batch_id', None):
if ordered_qty is not 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 op_code = None
if getattr(wi, 'operation_id', None): if getattr(wi, 'operation_id', None):
op_code = Operation.objects.filter(pk=wi.operation_id).values_list('code', flat=True).first() 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() op_code = (wi.stage or '').strip()
if op_code: 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 = int(progress.current_seq or 1)
cur_eo = EntityOperation.objects.select_related('operation').filter(entity_id=wi.entity_id, seq=cur).first() 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: 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'] wi_qs = WorkItem.objects.filter(deal_id=wi.deal_id, entity_id=wi.entity_id).filter(
if int(total_done or 0) >= int(ordered_qty): 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.current_seq = cur + 1
progress.save(update_fields=['current_seq']) progress.save(update_fields=['current_seq'])
@@ -2052,14 +2098,22 @@ class DealPlanningView(LoginRequiredMixin, TemplateView):
tasks = list( tasks = list(
ProductionTask.objects.filter(deal=deal) ProductionTask.objects.filter(deal=deal)
.select_related('material', 'entity') .select_related('material', 'entity', 'delivery_batch')
.order_by('-id') .order_by('-id')
) )
task_entity_ids = {int(x.entity_id) for x in tasks if getattr(x, 'entity_id', None)} 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 = { progress_task_map = {
int(p.entity_id): int(p.current_seq or 1) ((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 DealEntityProgress.objects.filter(deal=deal, entity_id__in=list(task_entity_ids)) for p in progress_qs
} }
ops_task_map = {} ops_task_map = {}
for eo in ( for eo in (
@@ -2078,7 +2132,8 @@ class DealPlanningView(LoginRequiredMixin, TemplateView):
if not getattr(t, 'entity_id', None): if not getattr(t, 'entity_id', None):
continue 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)) eo = ops_task_map.get((int(t.entity_id), seq))
if not eo: if not eo:
continue continue
@@ -2093,8 +2148,12 @@ class DealPlanningView(LoginRequiredMixin, TemplateView):
wi_qs = wi_qs.filter(workshop_id__in=allowed_ws) wi_qs = wi_qs.filter(workshop_id__in=allowed_ws)
wi_sums = { 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), planned=Coalesce(Sum('quantity_plan'), 0),
done=Coalesce(Sum('quantity_done'), 0), done=Coalesce(Sum('quantity_done'), 0),
) )
@@ -2108,7 +2167,11 @@ class DealPlanningView(LoginRequiredMixin, TemplateView):
need = int(t.quantity_ordered or 0) need = int(t.quantity_ordered or 0)
key = None key = None
if getattr(t, 'entity_id', None) and getattr(t, 'current_operation_id', 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, done_qty = wi_sums.get(key, (0, 0)) if key else (0, 0)
planned_qty = int(planned_qty or 0) planned_qty = int(planned_qty or 0)
@@ -2649,6 +2712,7 @@ class WorkItemPlanAddView(LoginRequiredMixin, View):
return int(s) if s.isdigit() else None return int(s) if s.isdigit() else None
deal_id = parse_int(request.POST.get('deal_id')) 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')) entity_id = parse_int(request.POST.get('entity_id'))
operation_id = parse_int(request.POST.get('operation_id')) operation_id = parse_int(request.POST.get('operation_id'))
machine_id = parse_int(request.POST.get('machine_id')) machine_id = parse_int(request.POST.get('machine_id'))
@@ -2703,7 +2767,11 @@ class WorkItemPlanAddView(LoginRequiredMixin, View):
progress_map = { progress_map = {
int(p.entity_id): int(p.current_seq or 1) 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 = { ops_map = {
@@ -2718,6 +2786,7 @@ class WorkItemPlanAddView(LoginRequiredMixin, View):
if int(c_id) == int(entity_id): if int(c_id) == int(entity_id):
WorkItem.objects.create( WorkItem.objects.create(
deal_id=deal_id, deal_id=deal_id,
delivery_batch_id=(int(delivery_batch_id) if delivery_batch_id else None),
entity_id=entity_id, entity_id=entity_id,
operation_id=operation_id, operation_id=operation_id,
workshop_id=resolved_workshop_id, workshop_id=resolved_workshop_id,
@@ -2739,6 +2808,7 @@ class WorkItemPlanAddView(LoginRequiredMixin, View):
cur_op = eo.operation cur_op = eo.operation
WorkItem.objects.create( WorkItem.objects.create(
deal_id=deal_id, deal_id=deal_id,
delivery_batch_id=(int(delivery_batch_id) if delivery_batch_id else None),
entity_id=int(c_id), entity_id=int(c_id),
operation_id=int(cur_op.id), operation_id=int(cur_op.id),
workshop_id=(int(cur_op.workshop_id) if getattr(cur_op, 'workshop_id', None) else None), 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: else:
wi = WorkItem.objects.create( wi = WorkItem.objects.create(
deal_id=int(deal_id), deal_id=int(deal_id),
delivery_batch_id=(int(delivery_batch_id) if delivery_batch_id else None),
entity_id=int(entity_id), entity_id=int(entity_id),
operation_id=int(operation_id), operation_id=int(operation_id),
workshop_id=resolved_workshop_id, workshop_id=resolved_workshop_id,
@@ -3281,7 +3352,11 @@ class DealBatchActionView(LoginRequiredMixin, View):
messages.error(request, 'Нельзя откатить: по этой позиции уже есть постановка в смену (план/факт).') messages.error(request, 'Нельзя откатить: по этой позиции уже есть постановка в смену (план/факт).')
return redirect(next_url) 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.started_qty = started - int(qty)
bi.save(update_fields=['started_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) logger.info('start_batch_item_production: qty_exceeds_remaining remaining=%s started=%s total=%s', remaining, started, total)
return redirect(next_url) 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.started_qty = started + int(qty)
bi.save(update_fields=['started_qty']) bi.save(update_fields=['started_qty'])
@@ -3354,11 +3433,22 @@ class DealBatchActionView(LoginRequiredMixin, View):
) )
return redirect(next_url) return redirect(next_url)
except ExplosionValidationError as ev: except ExplosionValidationError as ev:
try: if getattr(ev, 'missing_route_entity_ids', None):
from manufacturing.models import ProductEntity bad = list(
bad = list(ProductEntity.objects.filter(id__in=list(ev.missing_material_ids)).values_list('drawing_number', 'name')) ProductEntity.objects.filter(id__in=list(ev.missing_route_entity_ids)).values_list('id', 'drawing_number', 'name')
except Exception: )
bad = [] 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: if bad:
preview = ", ".join([f"{dn or ''} {nm}" for dn, nm in bad[:5]]) preview = ", ".join([f"{dn or ''} {nm}" for dn, nm in bad[:5]])
more = '' if len(bad) <= 5 else f" и ещё {len(bad)-5}" more = '' if len(bad) <= 5 else f" и ещё {len(bad)-5}"