Конкретно пересмотрел логику работы. Легаси вынесена в архив
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
|
||||
@@ -1,15 +1,24 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass
|
||||
|
||||
from django.db import transaction
|
||||
from django.db import models, transaction
|
||||
from django.db.models import Sum
|
||||
from django.db.models.functions import Coalesce
|
||||
|
||||
from manufacturing.models import BOM, ProductEntity
|
||||
from shiftflow.models import Deal, DealItem, MaterialRequirement, ProductionTask
|
||||
from warehouse.models import Location, StockItem
|
||||
from shiftflow.models import Deal, DealItem, ProcurementRequirement, ProductionTask
|
||||
from warehouse.models import StockItem
|
||||
|
||||
logger = logging.getLogger('mes')
|
||||
|
||||
|
||||
class ExplosionValidationError(Exception):
|
||||
def __init__(self, missing_material_ids: list[int]):
|
||||
super().__init__('missing_material')
|
||||
self.missing_material_ids = [int(x) for x in (missing_material_ids or [])]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -21,7 +30,10 @@ class ExplosionStats:
|
||||
- сколько ProductionTask создано/обновлено (по leaf-деталям)
|
||||
|
||||
req_*:
|
||||
- сколько MaterialRequirement создано/обновлено (по сырью)
|
||||
- сколько ProcurementRequirement создано/обновлено (по потребностям снабжения)
|
||||
|
||||
Примечание:
|
||||
- потребность по сырью (лист/профиль) сейчас не считаем автоматически — будет вводиться вручную.
|
||||
"""
|
||||
|
||||
tasks_created: int
|
||||
@@ -151,19 +163,40 @@ def _explode_to_leaves(
|
||||
return memo[entity_id]
|
||||
|
||||
|
||||
def _accumulate_requirements(
|
||||
entity_id: int,
|
||||
multiplier: int,
|
||||
adjacency: dict[int, list[tuple[int, int]]],
|
||||
visiting: set[int],
|
||||
out: dict[int, int],
|
||||
) -> None:
|
||||
if entity_id in visiting:
|
||||
raise RuntimeError("Цикл в BOM: спецификация зациклена.")
|
||||
visiting.add(entity_id)
|
||||
|
||||
out[int(entity_id)] = int(out.get(int(entity_id), 0) or 0) + int(multiplier)
|
||||
|
||||
for child_id, qty in adjacency.get(int(entity_id), []) or []:
|
||||
_accumulate_requirements(int(child_id), int(multiplier) * int(qty), adjacency, visiting, out)
|
||||
|
||||
visiting.remove(entity_id)
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def explode_deal(
|
||||
deal_id: int,
|
||||
*,
|
||||
central_location_name: str = "Центральный склад",
|
||||
create_tasks: bool = False,
|
||||
create_procurement: bool = True,
|
||||
) -> ExplosionStats:
|
||||
"""
|
||||
BOM Explosion:
|
||||
- берём состав сделки (DealItem)
|
||||
- рекурсивно обходим BOM
|
||||
- считаем суммарное количество leaf-деталей
|
||||
- создаём/обновляем ProductionTask (deal + entity)
|
||||
- создаём/обновляем MaterialRequirement по нормам расхода и остаткам на центральном складе
|
||||
"""BOM Explosion по сделке.
|
||||
|
||||
Используется в двух режимах:
|
||||
- create_procurement=True: пересчитать потребности снабжения (покупное/литьё/аутсорс)
|
||||
- create_tasks=True: создать/обновить ProductionTask по внутреннему производству
|
||||
|
||||
Примечание: потребность по сырью (MaterialRequirement) здесь не считаем автоматически.
|
||||
"""
|
||||
deal = Deal.objects.select_for_update().get(pk=deal_id)
|
||||
|
||||
@@ -191,28 +224,200 @@ def explode_deal(
|
||||
tasks_created = 0
|
||||
tasks_updated = 0
|
||||
|
||||
for entity_id, qty in required_leaves.items():
|
||||
if create_tasks:
|
||||
for entity_id, qty in required_leaves.items():
|
||||
entity = leaf_entities.get(entity_id)
|
||||
if not entity:
|
||||
continue
|
||||
|
||||
if not entity.planned_material_id:
|
||||
continue
|
||||
|
||||
pt, created = ProductionTask.objects.get_or_create(
|
||||
deal=deal,
|
||||
entity=entity,
|
||||
defaults={
|
||||
"drawing_name": entity.name or "Б/ч",
|
||||
"size_value": 0,
|
||||
"material": entity.planned_material,
|
||||
"quantity_ordered": int(qty),
|
||||
"is_bend": False,
|
||||
},
|
||||
)
|
||||
if created:
|
||||
tasks_created += 1
|
||||
else:
|
||||
changed = False
|
||||
if pt.quantity_ordered != int(qty):
|
||||
pt.quantity_ordered = int(qty)
|
||||
changed = True
|
||||
if not pt.material_id and entity.planned_material_id:
|
||||
pt.material = entity.planned_material
|
||||
changed = True
|
||||
if changed:
|
||||
pt.save(update_fields=["quantity_ordered", "material"])
|
||||
tasks_updated += 1
|
||||
|
||||
req_created = 0
|
||||
req_updated = 0
|
||||
seen_component_ids: set[int] = set()
|
||||
|
||||
if not create_procurement:
|
||||
return ExplosionStats(tasks_created, tasks_updated, 0, 0)
|
||||
|
||||
for entity_id, qty_parts in required_leaves.items():
|
||||
entity = leaf_entities.get(entity_id)
|
||||
if not entity:
|
||||
continue
|
||||
|
||||
# Комментарий: потребность снабжения считаем только для покупного/литья/аутсорса.
|
||||
et = (entity.entity_type or '').strip()
|
||||
if et not in ['purchased', 'casting', 'outsourced']:
|
||||
continue
|
||||
|
||||
seen_component_ids.add(int(entity.id))
|
||||
|
||||
required_qty = int(qty_parts or 0)
|
||||
|
||||
# Комментарий: снабжение работает с поштучными позициями.
|
||||
# StockItem.quantity в БД float (универсальная единица), поэтому здесь приводим к int.
|
||||
# Разрешены:
|
||||
# - свободные (deal is null)
|
||||
# - уже закреплённые за этой же сделкой (deal = deal)
|
||||
available_raw = (
|
||||
StockItem.objects.filter(entity=entity, is_archived=False)
|
||||
.filter(models.Q(deal__isnull=True) | models.Q(deal=deal))
|
||||
.aggregate(v=Coalesce(Sum("quantity"), 0.0))["v"]
|
||||
)
|
||||
available = int(available_raw or 0)
|
||||
|
||||
to_buy = max(0, int(required_qty) - int(available))
|
||||
|
||||
if to_buy > 0:
|
||||
pr, created = ProcurementRequirement.objects.get_or_create(
|
||||
deal=deal,
|
||||
component=entity,
|
||||
defaults={"required_qty": int(to_buy), "status": "to_order"},
|
||||
)
|
||||
if created:
|
||||
req_created += 1
|
||||
else:
|
||||
pr.required_qty = int(to_buy)
|
||||
|
||||
# Комментарий: если снабженец уже отметил «Заказано», пересчёт не должен сбрасывать статус назад.
|
||||
if pr.status != 'ordered':
|
||||
pr.status = 'to_order'
|
||||
|
||||
pr.save(update_fields=["required_qty", "status"])
|
||||
req_updated += 1
|
||||
else:
|
||||
updated = ProcurementRequirement.objects.filter(deal=deal, component=entity).update(
|
||||
required_qty=0,
|
||||
status='closed',
|
||||
)
|
||||
if updated:
|
||||
req_updated += int(updated)
|
||||
|
||||
# Комментарий: если компонент исчез из сделки/спецификации — закрываем устаревшие строки,
|
||||
# чтобы при повторном «вскрытии» данные обновлялись, а не накапливались.
|
||||
qs_stale = ProcurementRequirement.objects.filter(
|
||||
deal=deal,
|
||||
component__entity_type__in=['purchased', 'casting', 'outsourced'],
|
||||
)
|
||||
if seen_component_ids:
|
||||
qs_stale = qs_stale.exclude(component_id__in=list(seen_component_ids))
|
||||
|
||||
updated = qs_stale.update(required_qty=0, status='closed')
|
||||
if updated:
|
||||
req_updated += int(updated)
|
||||
|
||||
return ExplosionStats(tasks_created, tasks_updated, req_created, req_updated)
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def explode_roots_additive(
|
||||
deal_id: int,
|
||||
roots: list[tuple[int, int]],
|
||||
) -> ExplosionStats:
|
||||
"""Additive BOM Explosion для запуска в производство по частям.
|
||||
|
||||
roots: список (root_entity_id, qty_to_start).
|
||||
|
||||
В отличие от explode_deal:
|
||||
- не пересчитывает всю сделку
|
||||
- увеличивает quantity_ordered у ProductionTask по leaf-деталям на добавленный объём.
|
||||
|
||||
Примечание: MaterialRequirement здесь намеренно не трогаем — её лучше считать отдельной процедурой
|
||||
по всей сделке/партии, чтобы не накапливать ошибки при многократных инкрементах.
|
||||
"""
|
||||
deal = Deal.objects.select_for_update().get(pk=deal_id)
|
||||
|
||||
roots = [(int(eid), int(q)) for eid, q in (roots or []) if int(q or 0) > 0]
|
||||
if not roots:
|
||||
return ExplosionStats(0, 0, 0, 0)
|
||||
|
||||
root_ids = {eid for eid, _ in roots}
|
||||
adjacency = _build_bom_graph(root_ids)
|
||||
|
||||
required_nodes: dict[int, int] = {}
|
||||
|
||||
for root_entity_id, root_qty in roots:
|
||||
_accumulate_requirements(int(root_entity_id), int(root_qty), adjacency, set(), required_nodes)
|
||||
|
||||
entities = {
|
||||
e.id: e
|
||||
for e in ProductEntity.objects.select_related("planned_material", "planned_material__category")
|
||||
.filter(id__in=list(required_nodes.keys()))
|
||||
}
|
||||
|
||||
missing = [
|
||||
int(e.id)
|
||||
for e in entities.values()
|
||||
if (getattr(e, 'entity_type', '') == 'part' and not getattr(e, 'planned_material_id', None) and int(required_nodes.get(int(e.id), 0) or 0) > 0)
|
||||
]
|
||||
if missing:
|
||||
raise ExplosionValidationError(missing)
|
||||
|
||||
tasks_created = 0
|
||||
tasks_updated = 0
|
||||
skipped_no_material = 0
|
||||
skipped_supply = 0
|
||||
|
||||
for entity_id, qty in required_nodes.items():
|
||||
entity = entities.get(int(entity_id))
|
||||
if not entity:
|
||||
continue
|
||||
|
||||
et = (entity.entity_type or '').strip()
|
||||
if et in ['purchased', 'casting', 'outsourced']:
|
||||
skipped_supply += 1
|
||||
continue
|
||||
|
||||
allow_no_material = et in ['assembly', 'product']
|
||||
if not allow_no_material and not entity.planned_material_id:
|
||||
skipped_no_material += 1
|
||||
continue
|
||||
|
||||
defaults = {
|
||||
"drawing_name": entity.name or "Б/ч",
|
||||
"size_value": 0,
|
||||
"material": entity.planned_material if entity.planned_material_id else None,
|
||||
"quantity_ordered": int(qty),
|
||||
"is_bend": False,
|
||||
}
|
||||
|
||||
pt, created = ProductionTask.objects.get_or_create(
|
||||
deal=deal,
|
||||
entity=entity,
|
||||
defaults={
|
||||
"drawing_name": entity.name or "Б/ч",
|
||||
"size_value": 0,
|
||||
"material": entity.planned_material,
|
||||
"quantity_ordered": int(qty),
|
||||
"is_bend": False,
|
||||
},
|
||||
defaults=defaults,
|
||||
)
|
||||
if created:
|
||||
tasks_created += 1
|
||||
else:
|
||||
changed = False
|
||||
if pt.quantity_ordered != int(qty):
|
||||
pt.quantity_ordered = int(qty)
|
||||
new_qty = int(pt.quantity_ordered or 0) + int(qty)
|
||||
if pt.quantity_ordered != new_qty:
|
||||
pt.quantity_ordered = new_qty
|
||||
changed = True
|
||||
if not pt.material_id and entity.planned_material_id:
|
||||
pt.material = entity.planned_material
|
||||
@@ -221,45 +426,14 @@ def explode_deal(
|
||||
pt.save(update_fields=["quantity_ordered", "material"])
|
||||
tasks_updated += 1
|
||||
|
||||
central, _ = Location.objects.get_or_create(
|
||||
name=central_location_name,
|
||||
defaults={"is_production_area": False},
|
||||
logger.info(
|
||||
'explode_roots_additive: deal_id=%s roots=%s nodes=%s tasks_created=%s tasks_updated=%s skipped_no_material=%s skipped_supply=%s',
|
||||
deal_id,
|
||||
roots,
|
||||
len(required_nodes),
|
||||
tasks_created,
|
||||
tasks_updated,
|
||||
skipped_no_material,
|
||||
skipped_supply,
|
||||
)
|
||||
|
||||
req_created = 0
|
||||
req_updated = 0
|
||||
|
||||
for entity_id, qty_parts in required_leaves.items():
|
||||
entity = leaf_entities.get(entity_id)
|
||||
if not entity or not entity.planned_material_id:
|
||||
continue
|
||||
|
||||
per_unit, unit = _norm_and_unit(entity)
|
||||
if not per_unit:
|
||||
continue
|
||||
|
||||
required_qty = float(qty_parts) * float(per_unit)
|
||||
|
||||
available = (
|
||||
StockItem.objects.filter(location=central, material=entity.planned_material)
|
||||
.aggregate(v=Coalesce(Sum("quantity"), 0.0))["v"]
|
||||
)
|
||||
to_buy = max(0.0, required_qty - float(available or 0.0))
|
||||
if to_buy <= 0:
|
||||
continue
|
||||
|
||||
mr, created = MaterialRequirement.objects.get_or_create(
|
||||
deal=deal,
|
||||
material=entity.planned_material,
|
||||
unit=unit,
|
||||
defaults={"required_qty": to_buy, "status": "needed"},
|
||||
)
|
||||
if created:
|
||||
req_created += 1
|
||||
else:
|
||||
mr.required_qty = to_buy
|
||||
mr.status = "needed"
|
||||
mr.save(update_fields=["required_qty", "status"])
|
||||
req_updated += 1
|
||||
|
||||
return ExplosionStats(tasks_created, tasks_updated, req_created, req_updated)
|
||||
return ExplosionStats(tasks_created, tasks_updated, 0, 0)
|
||||
@@ -1,4 +1,5 @@
|
||||
from django.db import transaction
|
||||
from django.db.models import F
|
||||
from django.utils import timezone
|
||||
import logging
|
||||
|
||||
@@ -130,3 +131,92 @@ def apply_closing(
|
||||
)
|
||||
|
||||
logger.info('apply_closing:done report=%s', report.id)
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def apply_closing_workitems(
|
||||
*,
|
||||
user_id: int,
|
||||
machine_id: int,
|
||||
material_id: int,
|
||||
item_actions: dict[int, dict], # workitem_id -> {'action': 'done'|'partial', 'fact': int}
|
||||
consumptions: dict[int, float],
|
||||
remnants: list[dict],
|
||||
) -> None:
|
||||
logger.info('apply_closing_workitems:start user=%s machine=%s material=%s workitems=%s cons=%s rem=%s', user_id, machine_id, material_id, list(item_actions.keys()), list(consumptions.keys()), len(remnants))
|
||||
|
||||
from shiftflow.models import WorkItem, ProductionTask
|
||||
|
||||
wis = list(
|
||||
WorkItem.objects.select_for_update(of=("self",))
|
||||
.select_related('deal', 'entity', 'machine')
|
||||
.filter(id__in=list(item_actions.keys()), machine_id=machine_id, status__in=['planned'], entity__planned_material_id=material_id)
|
||||
.filter(quantity_done__lt=F('quantity_plan'))
|
||||
)
|
||||
if not wis:
|
||||
raise RuntimeError('Не найдено сменных заданий для закрытия.')
|
||||
|
||||
report = CuttingSession.objects.create(
|
||||
operator_id=user_id,
|
||||
machine_id=machine_id,
|
||||
used_stock_item=None,
|
||||
date=timezone.localdate(),
|
||||
is_closed=False,
|
||||
)
|
||||
|
||||
created_shift = 0
|
||||
for wi in wis:
|
||||
spec = item_actions.get(wi.id) or {}
|
||||
action = (spec.get('action') or '').strip()
|
||||
fact = int(spec.get('fact') or 0)
|
||||
if action not in ['done', 'partial']:
|
||||
continue
|
||||
|
||||
plan_total = int(wi.quantity_plan or 0)
|
||||
done_total = int(wi.quantity_done or 0)
|
||||
remaining = max(0, plan_total - done_total)
|
||||
if remaining <= 0:
|
||||
continue
|
||||
|
||||
if action == 'done':
|
||||
fact = remaining
|
||||
else:
|
||||
fact = max(0, min(fact, remaining))
|
||||
if fact <= 0:
|
||||
raise RuntimeError('При частичном закрытии факт должен быть больше 0.')
|
||||
|
||||
pt = ProductionTask.objects.filter(deal_id=wi.deal_id, entity_id=wi.entity_id).first()
|
||||
if not pt:
|
||||
raise RuntimeError('Не найден ProductionTask для задания.')
|
||||
|
||||
ShiftItem.objects.create(session=report, task=pt, quantity_fact=fact)
|
||||
created_shift += 1
|
||||
|
||||
wi.quantity_done = done_total + fact
|
||||
if wi.quantity_done >= plan_total:
|
||||
wi.status = 'done'
|
||||
elif wi.quantity_done > 0:
|
||||
wi.status = 'leftover'
|
||||
else:
|
||||
wi.status = 'planned'
|
||||
wi.save(update_fields=['quantity_done', 'status'])
|
||||
|
||||
for stock_item_id, qty in consumptions.items():
|
||||
if qty and float(qty) > 0:
|
||||
ProductionReportConsumption.objects.create(report=report, stock_item_id=stock_item_id, material_id=None, quantity=float(qty))
|
||||
|
||||
for r in remnants:
|
||||
qty = float(r.get('quantity') or 0)
|
||||
if qty <= 0:
|
||||
continue
|
||||
ProductionReportRemnant.objects.create(
|
||||
report=report,
|
||||
material_id=material_id,
|
||||
quantity=qty,
|
||||
current_length=r.get('current_length'),
|
||||
current_width=r.get('current_width'),
|
||||
unique_id=None,
|
||||
)
|
||||
|
||||
close_cutting_session(report.id)
|
||||
logger.info('apply_closing_workitems:done report=%s shift_items=%s', report.id, created_shift)
|
||||
|
||||
268
shiftflow/services/kitting.py
Normal file
268
shiftflow/services/kitting.py
Normal file
@@ -0,0 +1,268 @@
|
||||
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}
|
||||
Reference in New Issue
Block a user