All checks were successful
Deploy MES Core / deploy (push) Successful in 13s
268 lines
9.1 KiB
Python
268 lines
9.1 KiB
Python
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} |