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