Конкретно пересмотрел логику работы. Легаси вынесена в архив
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:
@@ -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)
|
||||
Reference in New Issue
Block a user