import logging from typing import Any from django.db import transaction from django.db.models import Case, IntegerField, Q, Value, When from django.utils import timezone from manufacturing.models import ProductEntity from warehouse.models import Location, StockItem, TransferLine, TransferRecord from warehouse.services.transfers import receive_transfer from shiftflow.services.bom_explosion import _accumulate_requirements, _build_bom_graph logger = logging.getLogger('mes') def _session_key(workitem_id: int) -> str: return f'kitting_draft_workitem_{int(workitem_id)}' def get_kitting_draft(session: Any, workitem_id: int) -> list[dict]: key = _session_key(workitem_id) raw = session.get(key) if isinstance(raw, list): out = [] for x in raw: if not isinstance(x, dict): continue out.append({ 'entity_id': int(x.get('entity_id') or 0), 'from_location_id': int(x.get('from_location_id') or 0), 'quantity': int(x.get('quantity') or 0), }) return out return [] def clear_kitting_draft(session: Any, workitem_id: int) -> None: key = _session_key(workitem_id) if key in session: del session[key] session.modified = True def add_kitting_line(session: Any, workitem_id: int, entity_id: int, from_location_id: int, quantity: int) -> None: workitem_id = int(workitem_id) entity_id = int(entity_id) from_location_id = int(from_location_id) quantity = int(quantity) if workitem_id <= 0 or entity_id <= 0 or from_location_id <= 0 or quantity <= 0: return key = _session_key(workitem_id) draft = get_kitting_draft(session, workitem_id) merged = False for ln in draft: if int(ln.get('entity_id') or 0) == entity_id and int(ln.get('from_location_id') or 0) == from_location_id: ln['quantity'] = int(ln.get('quantity') or 0) + quantity merged = True break if not merged: draft.append({'entity_id': entity_id, 'from_location_id': from_location_id, 'quantity': quantity}) session[key] = draft session.modified = True def remove_kitting_line(session: Any, workitem_id: int, entity_id: int, from_location_id: int, quantity: int) -> None: workitem_id = int(workitem_id) entity_id = int(entity_id) from_location_id = int(from_location_id) quantity = int(quantity) if workitem_id <= 0 or entity_id <= 0 or from_location_id <= 0 or quantity <= 0: return key = _session_key(workitem_id) draft = get_kitting_draft(session, workitem_id) out = [] for ln in draft: if int(ln.get('entity_id') or 0) == entity_id and int(ln.get('from_location_id') or 0) == from_location_id: cur = int(ln.get('quantity') or 0) cur = max(0, cur - quantity) if cur > 0: ln['quantity'] = cur out.append(ln) continue out.append(ln) session[key] = out session.modified = True def get_work_location_for_workitem(workitem) -> Location | None: m = getattr(workitem, 'machine', None) if m and getattr(m, 'workshop_id', None) and getattr(getattr(m, 'workshop', None), 'location_id', None): return m.workshop.location if m and getattr(m, 'location_id', None): return m.location w = getattr(workitem, 'workshop', None) if w and getattr(w, 'location_id', None): return w.location return None def build_kitting_requirements(root_entity_id: int, qty_to_make: int) -> dict[int, int]: """Потребность на комплектацию для сборки/изделия. Комментарий: для ручной комплектации мастеру важны прямые компоненты (1 уровень BOM), включая подсборки. Глубину дерева раскрываем отдельными заданиями на подсборки. """ root_entity_id = int(root_entity_id) qty_to_make = int(qty_to_make or 0) if root_entity_id <= 0 or qty_to_make <= 0: return {} adjacency = _build_bom_graph({root_entity_id}) children = adjacency.get(root_entity_id) or [] out: dict[int, int] = {} for child_id, per1 in children: cid = int(child_id) need = int(per1 or 0) * qty_to_make if cid <= 0 or need <= 0: continue out[cid] = int(out.get(cid, 0) or 0) + need return out def build_kitting_leaf_requirements(root_entity_id: int, qty_to_make: int) -> dict[int, int]: """Совместимость: старое имя оставлено, сейчас возвращает потребность 1-го уровня BOM.""" return build_kitting_requirements(root_entity_id, qty_to_make) @transaction.atomic def _apply_one_transfer( *, deal_id: int, component_entity_id: int, from_location_id: int, to_location_id: int, quantity: int, user_id: int, ) -> int: deal_id = int(deal_id) component_entity_id = int(component_entity_id) from_location_id = int(from_location_id) to_location_id = int(to_location_id) quantity = int(quantity) user_id = int(user_id) if quantity <= 0: raise RuntimeError('Количество должно быть больше 0.') if from_location_id == to_location_id: raise RuntimeError('Склад-источник и склад назначения совпадают.') # Комментарий: двигаем только "под сделку" и свободные остатки. # Приоритет: под сделку -> свободные, затем FIFO по поступлению. qs = ( StockItem.objects.select_for_update() .filter(is_archived=False, quantity__gt=0) .filter(location_id=from_location_id, entity_id=component_entity_id) .filter(Q(deal_id=deal_id) | Q(deal_id__isnull=True)) .annotate( prio=Case( When(deal_id=deal_id, then=Value(0)), default=Value(1), output_field=IntegerField(), ) ) .order_by('prio', 'created_at', 'id') ) remaining = float(quantity) picked: list[tuple[int, float]] = [] for si in qs: if remaining <= 0: break avail = float(si.quantity or 0) if avail <= 0: continue take = min(remaining, avail) if take <= 0: continue picked.append((int(si.id), float(take))) remaining -= take if remaining > 0: # Комментарий: допускаем расхождения с фактом (по данным базы может быть меньше, чем нужно по месту). # Для продолжения процесса создаем "виртуальный" остаток под сделку на складе-источнике и перемещаем его. phantom = StockItem.objects.create( entity_id=int(component_entity_id), deal_id=int(deal_id), location_id=int(from_location_id), quantity=float(remaining), ) picked.append((int(phantom.id), float(remaining))) logger.warning( 'kitting_transfer: phantom_created deal_id=%s entity_id=%s from_location=%s qty=%s', deal_id, component_entity_id, from_location_id, float(remaining), ) remaining = 0.0 tr = TransferRecord.objects.create( from_location_id=from_location_id, to_location_id=to_location_id, sender_id=user_id, receiver_id=user_id, occurred_at=timezone.now(), status='received', received_at=timezone.now(), is_applied=False, ) for sid, qty in picked: TransferLine.objects.create(transfer=tr, stock_item_id=sid, quantity=float(qty)) receive_transfer(tr.id, user_id) logger.info( 'kitting_transfer: ok tr_id=%s deal_id=%s component=%s from=%s to=%s qty=%s', tr.id, deal_id, component_entity_id, from_location_id, to_location_id, quantity ) return int(tr.id) def apply_kitting_draft( *, session: Any, workitem_id: int, deal_id: int, to_location_id: int, user_id: int, ) -> dict[str, int]: draft = get_kitting_draft(session, int(workitem_id)) if not draft: return {'applied': 0, 'errors': 0} applied = 0 errors = 0 for ln in draft: try: _apply_one_transfer( deal_id=int(deal_id), component_entity_id=int(ln.get('entity_id') or 0), from_location_id=int(ln.get('from_location_id') or 0), to_location_id=int(to_location_id), quantity=int(ln.get('quantity') or 0), user_id=int(user_id), ) applied += 1 except Exception: errors += 1 logger.exception('kitting_transfer:error deal_id=%s workitem_id=%s line=%s', deal_id, workitem_id, ln) if errors == 0: clear_kitting_draft(session, int(workitem_id)) return {'applied': applied, 'errors': errors}