Files
MES_Core/shiftflow/services/kitting.py
2026-04-13 07:36:57 +03:00

268 lines
9.1 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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}