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)