Конкретно пересмотрел логику работы. Легаси вынесена в архив
All checks were successful
Deploy MES Core / deploy (push) Successful in 13s

This commit is contained in:
2026-04-13 07:36:57 +03:00
parent 86215c9fa8
commit 28537447f8
80 changed files with 10246 additions and 684 deletions

View File

@@ -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)