All checks were successful
Deploy MES Core / deploy (push) Successful in 13s
202 lines
8.1 KiB
Python
202 lines
8.1 KiB
Python
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 |