Files
MES_Core/shiftflow/services/bom_explosion.py

513 lines
18 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.

from __future__ import annotations
import logging
from collections import defaultdict
from dataclasses import dataclass
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, 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)
class ExplosionStats:
"""
Сводка результата BOM Explosion.
tasks_*:
- сколько ProductionTask создано/обновлено (по leaf-деталям)
req_*:
- сколько ProcurementRequirement создано/обновлено (по потребностям снабжения)
Примечание:
- потребность по сырью (лист/профиль) сейчас не считаем автоматически — будет вводиться вручную.
"""
tasks_created: int
tasks_updated: int
req_created: int
req_updated: int
def _category_kind(material_category_name: str) -> str:
"""
Определение типа материала по названию категории.
Возвращает:
- 'sheet' для листовых материалов
- 'linear' для профилей/труб/круга
- 'unknown' если не удалось определить
"""
s = (material_category_name or "").strip().lower()
if "лист" in s:
return "sheet"
if any(k in s for k in ["труба", "проф", "круг", "швел", "угол", "балк", "квадрат"]):
return "linear"
return "unknown"
def _norm_and_unit(entity: ProductEntity) -> tuple[float | None, str]:
"""
Возвращает норму расхода и единицу измерения для MaterialRequirement.
Логика:
- для листа берём blank_area_m2 (м²/шт)
- для линейного берём blank_length_mm (мм/шт)
Если категория не распознана, но одна из норм задана — используем заданную.
"""
if not entity.planned_material_id or not getattr(entity.planned_material, "category_id", None):
if entity.blank_area_m2:
return float(entity.blank_area_m2), "m2"
if entity.blank_length_mm:
return float(entity.blank_length_mm), "mm"
return None, "pcs"
kind = _category_kind(entity.planned_material.category.name)
if kind == "sheet":
return (float(entity.blank_area_m2) if entity.blank_area_m2 else None), "m2"
if kind == "linear":
return (float(entity.blank_length_mm) if entity.blank_length_mm else None), "mm"
if entity.blank_area_m2:
return float(entity.blank_area_m2), "m2"
if entity.blank_length_mm:
return float(entity.blank_length_mm), "mm"
return None, "pcs"
def _build_bom_graph(root_entity_ids: set[int]) -> dict[int, list[tuple[int, int]]]:
"""
Строит граф BOM в памяти для заданного множества root entity.
Возвращает:
adjacency[parent_id] = [(child_id, qty), ...]
"""
adjacency: dict[int, list[tuple[int, int]]] = defaultdict(list)
frontier = set(root_entity_ids)
seen = set()
while frontier:
batch = frontier - seen
if not batch:
break
seen |= batch
rows = BOM.objects.filter(parent_id__in=batch).values_list("parent_id", "child_id", "quantity")
next_frontier = set()
for parent_id, child_id, qty in rows:
q = int(qty or 0)
if q <= 0:
continue
adjacency[int(parent_id)].append((int(child_id), q))
next_frontier.add(int(child_id))
frontier |= next_frontier
return adjacency
def _explode_to_leaves(
entity_id: int,
adjacency: dict[int, list[tuple[int, int]]],
memo: dict[int, dict[int, int]],
visiting: set[int],
) -> dict[int, int]:
"""
Возвращает разложение entity_id в leaf-детали в виде:
{ leaf_entity_id: multiplier_for_one_unit }
"""
if entity_id in memo:
return memo[entity_id]
if entity_id in visiting:
raise RuntimeError("Цикл в BOM: спецификация зациклена.")
visiting.add(entity_id)
children = adjacency.get(entity_id) or []
if not children:
memo[entity_id] = {entity_id: 1}
visiting.remove(entity_id)
return memo[entity_id]
out: dict[int, int] = defaultdict(int)
for child_id, qty in children:
child_map = _explode_to_leaves(child_id, adjacency, memo, visiting)
for leaf_id, leaf_qty in child_map.items():
out[leaf_id] += int(qty) * int(leaf_qty)
memo[entity_id] = dict(out)
visiting.remove(entity_id)
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 по сделке.
Используется в двух режимах:
- create_procurement=True: пересчитать потребности снабжения (покупное/литьё/аутсорс)
- create_tasks=True: создать/обновить ProductionTask по внутреннему производству
Примечание: потребность по сырью (MaterialRequirement) здесь не считаем автоматически.
"""
deal = Deal.objects.select_for_update().get(pk=deal_id)
deal_items = list(DealItem.objects.select_related("entity").filter(deal=deal))
if not deal_items:
return ExplosionStats(0, 0, 0, 0)
root_ids = {di.entity_id for di in deal_items}
adjacency = _build_bom_graph(root_ids)
memo: dict[int, dict[int, int]] = {}
required_leaves: dict[int, int] = defaultdict(int)
for di in deal_items:
leaf_map = _explode_to_leaves(di.entity_id, adjacency, memo, set())
for leaf_id, qty_per_unit in leaf_map.items():
required_leaves[leaf_id] += int(di.quantity) * int(qty_per_unit)
leaf_entities = {
e.id: e
for e in ProductEntity.objects.select_related("planned_material", "planned_material__category")
.filter(id__in=list(required_leaves.keys()))
}
tasks_created = 0
tasks_updated = 0
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=defaults,
)
if created:
tasks_created += 1
else:
changed = False
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
changed = True
if changed:
pt.save(update_fields=["quantity_ordered", "material"])
tasks_updated += 1
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,
)
return ExplosionStats(tasks_created, tasks_updated, 0, 0)
@transaction.atomic
def rollback_roots_additive(
deal_id: int,
roots: list[tuple[int, int]],
) -> ExplosionStats:
"""Откат additive BOM Explosion.
Используется для сценария "запустили в производство, но в смену ещё не поставили":
- уменьшает started_qty у строки партии (делается во вьюхе)
- уменьшает quantity_ordered у ProductionTask по всем узлам BOM пропорционально откату
Ограничение: откат должен быть запрещён, если по сущности уже есть план/факт в WorkItem.
"""
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()))
}
tasks_updated = 0
skipped_supply = 0
missing_tasks = 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
pt = ProductionTask.objects.filter(deal=deal, entity=entity).first()
if not pt:
missing_tasks += 1
continue
old = int(pt.quantity_ordered or 0)
new_qty = old - int(qty)
if new_qty < 0:
new_qty = 0
if new_qty != old:
pt.quantity_ordered = int(new_qty)
pt.save(update_fields=['quantity_ordered'])
tasks_updated += 1
logger.info(
'rollback_roots_additive: deal_id=%s roots=%s nodes=%s tasks_updated=%s skipped_supply=%s missing_tasks=%s',
deal_id,
roots,
len(required_nodes),
tasks_updated,
skipped_supply,
missing_tasks,
)
return ExplosionStats(0, tasks_updated, 0, 0)