All checks were successful
Deploy MES Core / deploy (push) Successful in 29s
513 lines
18 KiB
Python
513 lines
18 KiB
Python
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) |