import logging from django.db import transaction from django.db.models import Q, Case, When, Value, IntegerField, Sum from django.db.models.functions import Coalesce from django.utils import timezone from warehouse.models import StockItem from shiftflow.models import ( DealEntityProgress, DealItem, ProductionTask, WorkItem, CuttingSession, ProductionReportConsumption, ProductionReportStockResult, ) from shiftflow.services.bom_explosion import _build_bom_graph from shiftflow.services.kitting import get_work_location_for_workitem from shiftflow.services.route_flow import advance_progress_and_generate_next_workitem from manufacturing.models import EntityOperation, Operation 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 workitem.quantity_reported = max(int(workitem.quantity_reported or 0), int(workitem.quantity_done or 0)) if workitem.quantity_done >= workitem.quantity_plan: workitem.status = 'done' workitem.save(update_fields=['quantity_done', 'quantity_reported', 'status']) advance_progress_and_generate_next_workitem(workitem_id=int(workitem.id)) target_qty = None if getattr(workitem, 'delivery_batch_id', None): target_qty = ProductionTask.objects.filter( deal_id=workitem.deal_id, delivery_batch_id=workitem.delivery_batch_id, entity_id=workitem.entity_id, ).values_list('quantity_ordered', flat=True).first() else: di = DealItem.objects.filter(deal_id=workitem.deal_id, entity_id=workitem.entity_id).first() target_qty = int(di.quantity) if di else None if target_qty is not None: op_code = '' if getattr(workitem, 'operation_id', None): op_code = (Operation.objects.filter(pk=workitem.operation_id).values_list('code', flat=True).first() or '').strip() if not op_code: op_code = (workitem.stage or '').strip() if op_code: progress = ( DealEntityProgress.objects.select_for_update(of=('self',)) .filter( deal_id=workitem.deal_id, delivery_batch_id=(int(workitem.delivery_batch_id) if getattr(workitem, 'delivery_batch_id', None) else None), entity_id=workitem.entity_id, ) .first() ) if not progress: progress = DealEntityProgress.objects.create( deal_id=workitem.deal_id, delivery_batch_id=(int(workitem.delivery_batch_id) if getattr(workitem, 'delivery_batch_id', None) else None), entity_id=workitem.entity_id, current_seq=1, ) cur = int(progress.current_seq or 1) cur_eo = EntityOperation.objects.select_related('operation').filter(entity_id=workitem.entity_id, seq=cur).first() if cur_eo and cur_eo.operation and (cur_eo.operation.code or '').strip() == op_code: wi_qs = WorkItem.objects.filter(deal_id=workitem.deal_id, entity_id=workitem.entity_id).filter( Q(operation__code=op_code) | Q(stage=op_code) ) if getattr(workitem, 'delivery_batch_id', None): wi_qs = wi_qs.filter(delivery_batch_id=workitem.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']) 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