import logging from typing import Dict from django.db import transaction from django.db.models import Sum from django.db.models.functions import Coalesce from django.utils import timezone from manufacturing.models import EntityOperation from shiftflow.models import DealItem, WorkItem from warehouse.models import Material, StockItem, TransferLine, TransferRecord from warehouse.services.transfers import receive_transfer logger = logging.getLogger('mes') def build_shipment_rows( *, deal_id: int, shipping_location_id: int, ) -> tuple[list[dict], list[dict]]: """ Формирует данные для интерфейса отгрузки по сделке: - Список деталей (Entities), готовых к отгрузке и уже отгруженных. - Список давальческого сырья (Materials), доступного для возврата/отгрузки. """ deal_items = list( DealItem.objects.select_related('entity') .filter(deal_id=int(deal_id)) .order_by('entity__entity_type', 'entity__drawing_number', 'entity__name', 'id') ) entities = [it.entity for it in deal_items if it.entity_id and it.entity] ent_ids = [int(e.id) for e in entities if e] entity_ops = list( EntityOperation.objects.select_related('operation') .filter(entity_id__in=ent_ids) .order_by('entity_id', 'seq', 'id') ) route_codes: dict[int, list[str]] = {} last_code: dict[int, str] = {} for eo in entity_ops: if not eo.operation_id or not eo.operation: continue code = (eo.operation.code or '').strip() if not code: continue route_codes.setdefault(int(eo.entity_id), []).append(code) # Определяем последнюю операцию в маршруте для каждой детали for eid, codes in route_codes.items(): if codes: last_code[int(eid)] = str(codes[-1]) wi_qs = WorkItem.objects.select_related('operation').filter(deal_id=int(deal_id), entity_id__in=ent_ids) done_by: dict[tuple[int, str], int] = {} done_total_by_entity: dict[int, int] = {} # Подсчитываем количество выполненных деталей по операциям for wi in wi_qs: op_code = '' if getattr(wi, 'operation_id', None) and getattr(wi, 'operation', None): op_code = (wi.operation.code or '').strip() if not op_code: op_code = (wi.stage or '').strip() if not op_code: continue eid = int(wi.entity_id) done = int(wi.quantity_done or 0) done_by[(eid, str(op_code))] = done_by.get((eid, str(op_code)), 0) + done done_total_by_entity[eid] = done_total_by_entity.get(eid, 0) + done shipped_by = { int(r['entity_id']): float(r['s'] or 0.0) for r in StockItem.objects.filter( is_archived=False, location_id=int(shipping_location_id), deal_id=int(deal_id), entity_id__in=ent_ids, ) .values('entity_id') .annotate(s=Coalesce(Sum('quantity'), 0.0)) } ent_avail = { int(r['entity_id']): float(r['s'] or 0.0) for r in StockItem.objects.filter( deal_id=int(deal_id), is_archived=False, quantity__gt=0, entity_id__in=ent_ids, ) .exclude(location_id=int(shipping_location_id)) .values('entity_id') .annotate(s=Coalesce(Sum('quantity'), 0.0)) } entity_rows = [] for di in deal_items: e = di.entity if not e: continue need = int(di.quantity or 0) if need <= 0: continue eid = int(e.id) last = last_code.get(eid) # Количество готовых деталей: берем по последней операции маршрута, # либо общее количество, если маршрут не задан ready_done = int(done_by.get((eid, str(last)), 0) or 0) if last else int(done_total_by_entity.get(eid, 0) or 0) ready_val = min(need, ready_done) # Сколько уже отгружено на склад отгрузки shipped_val = int(shipped_by.get(eid, 0.0) or 0.0) shipped_val = min(need, shipped_val) remaining_ready = int(max(0, ready_val - shipped_val)) if remaining_ready <= 0: continue entity_rows.append({ 'entity': e, 'available': float(ent_avail.get(eid, 0.0) or 0.0), 'ready': int(ready_val), 'shipped': int(shipped_val), 'remaining_ready': int(remaining_ready), }) mat_rows = list( StockItem.objects.filter( deal_id=int(deal_id), is_archived=False, quantity__gt=0, material_id__isnull=False, is_customer_supplied=True, ) .exclude(location_id=int(shipping_location_id)) .values('material_id') .annotate(available=Coalesce(Sum('quantity'), 0.0)) .order_by('material_id') ) mat_ids = [int(r['material_id']) for r in mat_rows if r.get('material_id')] mats = {m.id: m for m in Material.objects.filter(id__in=mat_ids)} material_rows = [] for r in mat_rows: mid = int(r['material_id']) m = mats.get(mid) if not m: continue material_rows.append({ 'material': m, 'available': float(r.get('available') or 0.0), }) return entity_rows, material_rows @transaction.atomic def create_shipment_transfers( *, deal_id: int, shipping_location_id: int, entity_qty: Dict[int, int], material_qty: Dict[int, float], user_id: int, ) -> list[int]: """ Создает документы перемещения (TransferRecord) на склад отгрузки для указанных деталей и давальческого сырья. """ logger.info( 'fn:start create_shipment_transfers deal_id=%s shipping_location_id=%s user_id=%s', deal_id, shipping_location_id, user_id ) now = timezone.now() transfers_by_location: dict[int, TransferRecord] = {} def get_transfer(from_location_id: int) -> TransferRecord: tr = transfers_by_location.get(int(from_location_id)) if tr: return tr tr = TransferRecord.objects.create( from_location_id=int(from_location_id), to_location_id=int(shipping_location_id), sender_id=int(user_id), receiver_id=int(user_id), occurred_at=now, status='received', received_at=now, is_applied=False, ) transfers_by_location[int(from_location_id)] = tr return tr def alloc_stock_lines(qs, need_qty: float) -> None: """ Резервирует необходимое количество из доступных складских остатков. Использует select_for_update для предотвращения гонок данных. """ remaining = float(need_qty) if remaining <= 0: return items = list(qs.select_for_update().order_by('location_id', 'created_at', 'id')) for si in items: if remaining <= 0: break if int(si.location_id) == int(shipping_location_id): continue si_qty = float(si.quantity or 0.0) if si_qty <= 0: continue if si.unique_id: if remaining < si_qty: raise ValueError('Нельзя частично отгружать позицию с маркировкой (unique_id).') take = si_qty else: take = min(remaining, si_qty) tr = get_transfer(int(si.location_id)) TransferLine.objects.create(transfer=tr, stock_item=si, quantity=float(take)) remaining -= float(take) if remaining > 0: raise ValueError('Недостаточно количества на складах для отгрузки.') for ent_id, qty in (entity_qty or {}).items(): q = int(qty or 0) if q <= 0: continue alloc_stock_lines( StockItem.objects.filter( deal_id=int(deal_id), is_archived=False, quantity__gt=0, entity_id=int(ent_id), ).select_related('entity', 'location'), float(q), ) for mat_id, qty in (material_qty or {}).items(): q = float(qty or 0.0) if q <= 0: continue alloc_stock_lines( StockItem.objects.filter( deal_id=int(deal_id), is_archived=False, quantity__gt=0, material_id=int(mat_id), is_customer_supplied=True, ).select_related('material', 'location'), float(q), ) ids = [] for tr in transfers_by_location.values(): receive_transfer(int(tr.id), int(user_id)) ids.append(int(tr.id)) ids.sort() logger.info( 'fn:done create_shipment_transfers deal_id=%s transfers=%s', deal_id, ids, ) return ids