Конкретно пересмотрел логику работы. Легаси вынесена в архив
All checks were successful
Deploy MES Core / deploy (push) Successful in 13s

This commit is contained in:
2026-04-13 07:36:57 +03:00
parent 86215c9fa8
commit 28537447f8
80 changed files with 10246 additions and 684 deletions

View File

@@ -0,0 +1,202 @@
import logging
from django.db import transaction
from django.db.models import Q, Case, When, Value, IntegerField
from django.utils import timezone
from warehouse.models import StockItem
from shiftflow.models import WorkItem, CuttingSession, ProductionReportConsumption, ProductionReportStockResult
from shiftflow.services.bom_explosion import _build_bom_graph
from shiftflow.services.kitting import get_work_location_for_workitem
from manufacturing.models import EntityOperation
def get_first_operation_id(entity_id: int) -> int | None:
op_id = (
EntityOperation.objects.filter(entity_id=int(entity_id))
.order_by('seq', 'id')
.values_list('operation_id', flat=True)
.first()
)
return int(op_id) if op_id else None
logger = logging.getLogger('mes')
def get_assembly_closing_info(workitem: WorkItem) -> dict:
"""
Возвращает информацию о том, сколько сборок можно выпустить и
какие компоненты для этого нужны.
"""
first_op_id = get_first_operation_id(int(workitem.entity_id))
if first_op_id and getattr(workitem, 'operation_id', None) and int(workitem.operation_id) != int(first_op_id):
return {'error': 'Списание комплектации выполняется только на первой операции техпроцесса. Для этой операции закрывай только факт выполнения.', 'is_first_operation': False}
to_location = get_work_location_for_workitem(workitem)
if not to_location:
return {'error': 'Не определён склад участка для этого задания.'}
# Считаем BOM 1-го уровня
adjacency = _build_bom_graph({workitem.entity_id})
children = adjacency.get(workitem.entity_id) or []
if not children:
return {'error': 'Спецификация пуста. Нечего списывать.', 'to_location': to_location}
bom_req = {} # entity_id -> qty_per_1
for child_id, qty in children:
bom_req[child_id] = bom_req.get(child_id, 0) + qty
component_ids = list(bom_req.keys())
stocks = StockItem.objects.filter(
location=to_location,
entity_id__in=component_ids,
is_archived=False,
quantity__gt=0
).filter(Q(deal_id=workitem.deal_id) | Q(deal_id__isnull=True))
stock_by_entity = {}
for s in stocks:
stock_by_entity[s.entity_id] = stock_by_entity.get(s.entity_id, 0) + s.quantity
max_possible = float('inf')
components_info = []
from manufacturing.models import ProductEntity
entities = {e.id: e for e in ProductEntity.objects.filter(id__in=component_ids)}
for eid, req_qty in bom_req.items():
avail = float(stock_by_entity.get(eid, 0))
can_make = int(avail // float(req_qty)) if req_qty > 0 else 0
if can_make < max_possible:
max_possible = can_make
components_info.append({
'entity': entities.get(eid),
'req_per_1': float(req_qty),
'available': avail,
'max_possible': can_make
})
if max_possible == float('inf'):
max_possible = 0
components_info.sort(key=lambda x: (str(x['entity'].entity_type or ''), str(x['entity'].name or '')) if x['entity'] else ('', ''))
# Ограничиваем max_possible тем, что реально осталось собрать по заданию
remaining = max(0, (workitem.quantity_plan or 0) - (workitem.quantity_done or 0))
if max_possible > remaining:
max_possible = remaining
return {
'to_location': to_location,
'max_possible': int(max_possible),
'components': components_info,
'error': None,
'is_first_operation': True,
}
@transaction.atomic
def apply_assembly_closing(workitem_id: int, fact_qty: int, user_id: int) -> bool:
logger.info('assembly_closing:start workitem_id=%s qty=%s user_id=%s', workitem_id, fact_qty, user_id)
workitem = WorkItem.objects.select_for_update(of=('self',)).get(id=int(workitem_id))
first_op_id = get_first_operation_id(int(workitem.entity_id))
if first_op_id and getattr(workitem, 'operation_id', None) and int(workitem.operation_id) != int(first_op_id):
raise ValueError('Списание комплектации выполняется только на первой операции техпроцесса.')
if fact_qty <= 0:
raise ValueError('Количество должно быть больше 0.')
info = get_assembly_closing_info(workitem)
if info.get('error'):
raise ValueError(info['error'])
if fact_qty > info['max_possible']:
raise ValueError(f'Недостаточно компонентов на участке. Максимум можно собрать: {info["max_possible"]} шт.')
to_location = info['to_location']
if not getattr(workitem, 'machine_id', None):
raise ValueError('Для закрытия сборки требуется выбрать пост (станок) в сменном задании.')
report = CuttingSession.objects.create(
operator_id=int(user_id),
machine_id=int(workitem.machine_id),
used_stock_item=None,
date=timezone.localdate(),
is_closed=True,
)
logger.info('assembly_closing:report_created id=%s', report.id)
# Списываем компоненты 1-го уровня
adjacency = _build_bom_graph({workitem.entity_id})
children = adjacency.get(workitem.entity_id) or []
bom_req = {}
for child_id, qty in children:
bom_req[child_id] = bom_req.get(child_id, 0) + qty
for eid, req_qty in bom_req.items():
total_needed = float(req_qty * fact_qty)
# Приоритет "сделка", потом "свободные", FIFO
qs = StockItem.objects.select_for_update().filter(
location=to_location,
entity_id=eid,
is_archived=False,
quantity__gt=0
).filter(Q(deal_id=workitem.deal_id) | Q(deal_id__isnull=True)).annotate(
prio=Case(
When(deal_id=workitem.deal_id, then=Value(0)),
default=Value(1),
output_field=IntegerField(),
)
).order_by('prio', 'created_at', 'id')
rem = total_needed
for si in qs:
if rem <= 0:
break
take = min(rem, float(si.quantity))
ProductionReportConsumption.objects.create(
report=report,
material=None,
stock_item=si,
quantity=float(take),
)
si.quantity = float(si.quantity) - take
if si.quantity <= 0.0001:
si.quantity = 0
si.is_archived = True
si.archived_at = timezone.now()
si.save(update_fields=['quantity', 'is_archived', 'archived_at'])
rem -= take
if rem > 0.0001:
raise ValueError(f'Непредвиденная нехватка компонента ID {eid} при списании. Нужно еще: {rem}')
# Выпуск готовой сборки
produced = StockItem.objects.create(
entity_id=workitem.entity_id,
deal_id=workitem.deal_id,
location=to_location,
quantity=float(fact_qty),
is_customer_supplied=False,
)
ProductionReportStockResult.objects.create(report=report, stock_item=produced, kind='finished')
# Двигаем техпроцесс
workitem.quantity_done = (workitem.quantity_done or 0) + fact_qty
if workitem.quantity_done >= workitem.quantity_plan:
workitem.status = 'done'
workitem.save(update_fields=['quantity_done', 'status'])
logger.info(
'assembly_closing:done workitem_id=%s qty=%s deal_id=%s location_id=%s user_id=%s report_id=%s',
workitem.id,
fact_qty,
workitem.deal_id,
to_location.id,
user_id,
report.id,
)
return True