Конкретно пересмотрел логику работы. Легаси вынесена в архив
All checks were successful
Deploy MES Core / deploy (push) Successful in 13s
All checks were successful
Deploy MES Core / deploy (push) Successful in 13s
This commit is contained in:
202
shiftflow/services/assembly_closing.py
Normal file
202
shiftflow/services/assembly_closing.py
Normal file
@@ -0,0 +1,202 @@
|
||||
import logging
|
||||
from django.db import transaction
|
||||
from django.db.models import Q, Case, When, Value, IntegerField
|
||||
from django.utils import timezone
|
||||
from warehouse.models import StockItem
|
||||
from shiftflow.models import WorkItem, CuttingSession, ProductionReportConsumption, ProductionReportStockResult
|
||||
from shiftflow.services.bom_explosion import _build_bom_graph
|
||||
from shiftflow.services.kitting import get_work_location_for_workitem
|
||||
from manufacturing.models import EntityOperation
|
||||
|
||||
|
||||
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
|
||||
if workitem.quantity_done >= workitem.quantity_plan:
|
||||
workitem.status = 'done'
|
||||
workitem.save(update_fields=['quantity_done', 'status'])
|
||||
|
||||
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
|
||||
Reference in New Issue
Block a user