Огромная замена логики
All checks were successful
Deploy MES Core / deploy (push) Successful in 11s

This commit is contained in:
2026-04-06 08:06:37 +03:00
parent 0e8497ab1f
commit e88b861f68
48 changed files with 3833 additions and 175 deletions

View File

@@ -0,0 +1,14 @@
"""
Сервисный слой приложения shiftflow.
Здесь живёт бизнес-логика, которую можно вызывать из:
- view (HTTP)
- admin
- management commands
- фоновых воркеров
Принцип:
- сервисы не зависят от шаблонов/HTML,
- сервисы работают с ORM и транзакциями,
- сервисы содержат правила заводской логики (MES/ERP).
"""

View File

@@ -0,0 +1,265 @@
from __future__ import annotations
from collections import defaultdict
from dataclasses import dataclass
from django.db import 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
@dataclass(frozen=True)
class ExplosionStats:
"""
Сводка результата BOM Explosion.
tasks_*:
- сколько ProductionTask создано/обновлено (по leaf-деталям)
req_*:
- сколько MaterialRequirement создано/обновлено (по сырью)
"""
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]
@transaction.atomic
def explode_deal(
deal_id: int,
*,
central_location_name: str = "Центральный склад",
) -> ExplosionStats:
"""
BOM Explosion:
- берём состав сделки (DealItem)
- рекурсивно обходим BOM
- считаем суммарное количество leaf-деталей
- создаём/обновляем ProductionTask (deal + entity)
- создаём/обновляем 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
for entity_id, qty in required_leaves.items():
entity = leaf_entities.get(entity_id)
if not entity:
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
central, _ = Location.objects.get_or_create(
name=central_location_name,
defaults={"is_production_area": False},
)
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)

View File

@@ -0,0 +1,120 @@
from django.db import transaction
from django.utils import timezone
from shiftflow.models import (
CuttingSession,
Item,
ProductionReportConsumption,
ProductionReportRemnant,
ShiftItem,
)
from shiftflow.services.sessions import close_cutting_session
from warehouse.models import StockItem
@transaction.atomic
def apply_closing(
*,
user_id: int,
machine_id: int,
material_id: int,
item_actions: dict[int, dict],
consumptions: dict[int, float],
remnants: list[dict],
) -> None:
items = list(
Item.objects.select_for_update(of=('self',))
.select_related('task', 'task__deal', 'task__material', 'machine')
.filter(id__in=list(item_actions.keys()), machine_id=machine_id, status='work', task__material_id=material_id)
)
if not items:
raise RuntimeError('Не найдено пунктов сменки для закрытия.')
report = CuttingSession.objects.create(
operator_id=user_id,
machine_id=machine_id,
used_stock_item=None,
date=timezone.localdate(),
is_closed=False,
)
for it in items:
spec = item_actions.get(it.id) or {}
action = (spec.get('action') or '').strip()
fact = int(spec.get('fact') or 0)
if action not in ['done', 'partial']:
continue
plan = int(it.quantity_plan or 0)
if plan <= 0:
continue
if action == 'done':
fact = plan
else:
fact = max(0, min(fact, plan))
if fact <= 0:
raise RuntimeError('При частичном закрытии факт должен быть больше 0.')
ShiftItem.objects.create(session=report, task=it.task, quantity_fact=fact)
for stock_item_id, qty in consumptions.items():
if qty <= 0:
continue
ProductionReportConsumption.objects.create(
report=report,
stock_item_id=stock_item_id,
material_id=None,
quantity=float(qty),
)
for r in remnants:
qty = float(r.get('quantity') or 0)
if qty <= 0:
continue
ProductionReportRemnant.objects.create(
report=report,
material_id=material_id,
quantity=qty,
current_length=r.get('current_length'),
current_width=r.get('current_width'),
unique_id=None,
)
close_cutting_session(report.id)
for it in items:
spec = item_actions.get(it.id) or {}
action = (spec.get('action') or '').strip()
fact = int(spec.get('fact') or 0)
if action not in ['done', 'partial']:
continue
plan = int(it.quantity_plan or 0)
if plan <= 0:
continue
if action == 'done':
it.quantity_fact = plan
it.status = 'done'
it.save(update_fields=['quantity_fact', 'status'])
continue
fact = max(0, min(fact, plan))
residual = plan - fact
it.quantity_fact = fact
it.status = 'partial'
it.save(update_fields=['quantity_fact', 'status'])
if residual > 0:
Item.objects.create(
task=it.task,
date=it.date,
machine=it.machine,
quantity_plan=residual,
quantity_fact=0,
status='leftover',
is_synced_1c=False,
)

View File

@@ -0,0 +1,191 @@
from django.db import transaction
from manufacturing.models import ProductEntity
from shiftflow.models import (
CuttingSession,
ProductionReportConsumption,
ProductionReportRemnant,
ProductionReportStockResult,
ShiftItem,
)
from warehouse.models import StockItem
@transaction.atomic
def close_cutting_session(session_id: int) -> None:
"""
Закрытие CuttingSession (транзакция склада).
A) Списать сырьё:
- уменьшаем used_stock_item.quantity на 1
- если стало 0 -> удаляем
B) Начислить готовые детали:
- для каждого ShiftItem создаём StockItem(entity=..., location=machine.location, quantity=quantity_fact)
- если использованный материал не совпадает с planned_material КД -> material_substitution=True
"""
session = (
CuttingSession.objects.select_for_update(of=('self',))
.select_related(
"machine",
"machine__location",
"machine__workshop",
"machine__workshop__location",
"used_stock_item",
"used_stock_item__material",
)
.get(pk=session_id)
)
if session.is_closed:
return
work_location = None
if getattr(session.machine, 'workshop_id', None) and getattr(session.machine.workshop, 'location_id', None):
work_location = session.machine.workshop.location
elif session.machine.location_id:
work_location = session.machine.location
if not work_location:
raise RuntimeError('Не задан склад цеха для станка (Цех -> Склад цеха).')
consumed_material_ids: set[int] = set()
consumptions = list(
ProductionReportConsumption.objects.select_related('material', 'stock_item', 'stock_item__material', 'stock_item__location')
.filter(report=session)
)
if consumptions:
for c in consumptions:
need = float(c.quantity)
if need <= 0:
continue
if c.stock_item_id:
si = StockItem.objects.select_for_update(of=('self',)).select_related('material', 'location').get(pk=c.stock_item_id)
if not si.material_id:
raise RuntimeError('В списании сырья указана позиция склада без material.')
if si.location_id != work_location.id:
raise RuntimeError('Списывать сырьё можно только со склада цеха станка.')
if need > float(si.quantity):
raise RuntimeError('Недостаточно количества в выбранной складской позиции.')
si.quantity = float(si.quantity) - need
if si.quantity == 0:
si.delete()
else:
si.save(update_fields=['quantity'])
consumed_material_ids.add(int(si.material_id))
continue
if not c.material_id:
raise RuntimeError('В списании сырья не указан материал.')
consumed_material_ids.add(int(c.material_id))
qs = (
StockItem.objects.select_for_update(of=('self',))
.select_related('material', 'location')
.filter(location=work_location, material_id=c.material_id, entity__isnull=True)
.order_by('id')
)
for si in qs:
if need <= 0:
break
take = min(float(si.quantity), need)
si.quantity = float(si.quantity) - take
need -= take
if si.quantity == 0:
si.delete()
else:
si.save(update_fields=['quantity'])
if need > 0:
raise RuntimeError('Недостаточно сырья на складе цеха станка для списания.')
else:
if not session.used_stock_item_id:
raise RuntimeError('Не заполнено списание сырья: добавь строки «Списание сырья» или укажи legacy поле «Взятый материал».')
used = StockItem.objects.select_for_update(of=('self',)).select_related('material', 'location').get(pk=session.used_stock_item_id)
if not used.material_id:
raise RuntimeError('Взятый материал должен ссылаться на сырьё (material), а не на готовую деталь (entity).')
if used.location_id != work_location.id:
raise RuntimeError('Списывать сырьё можно только со склада цеха станка.')
used.quantity = float(used.quantity) - 1.0
if used.quantity < 0:
raise RuntimeError('Недостаточно сырья для списания.')
if used.quantity == 0:
used.delete()
else:
used.save(update_fields=['quantity'])
consumed_material_ids.add(int(used.material_id))
items = list(
ShiftItem.objects.select_related("task", "task__entity", "task__entity__planned_material", "task__material")
.filter(session=session)
)
for it in items:
if it.quantity_fact <= 0:
continue
task = it.task
planned_material = None
if task.entity_id and getattr(task.entity, 'planned_material_id', None):
planned_material = task.entity.planned_material
elif getattr(task, 'material_id', None):
planned_material = task.material
if planned_material and consumed_material_ids:
it.material_substitution = planned_material.id not in consumed_material_ids
else:
it.material_substitution = False
it.save(update_fields=['material_substitution'])
if not task.entity_id:
name = (getattr(task, 'drawing_name', '') or '').strip() or 'Без названия'
pe = ProductEntity.objects.create(
name=name[:255],
drawing_number=f"AUTO-{task.id}",
entity_type='part',
planned_material=planned_material,
)
task.entity = pe
task.save(update_fields=['entity'])
created = StockItem.objects.create(
entity=task.entity,
deal_id=getattr(task, 'deal_id', None),
location=work_location,
quantity=float(it.quantity_fact),
)
ProductionReportStockResult.objects.create(report=session, stock_item=created, kind='finished')
remnants = list(ProductionReportRemnant.objects.filter(report=session).select_related('material'))
for r in remnants:
created = StockItem.objects.create(
material=r.material,
location=work_location,
quantity=float(r.quantity),
is_remnant=True,
current_length=r.current_length,
current_width=r.current_width,
unique_id=r.unique_id,
)
ProductionReportStockResult.objects.create(report=session, stock_item=created, kind='remnant')
session.is_closed = True
session.save(update_fields=["is_closed"])