This commit is contained in:
14
shiftflow/services/__init__.py
Normal file
14
shiftflow/services/__init__.py
Normal file
@@ -0,0 +1,14 @@
|
||||
"""
|
||||
Сервисный слой приложения shiftflow.
|
||||
|
||||
Здесь живёт бизнес-логика, которую можно вызывать из:
|
||||
- view (HTTP)
|
||||
- admin
|
||||
- management commands
|
||||
- фоновых воркеров
|
||||
|
||||
Принцип:
|
||||
- сервисы не зависят от шаблонов/HTML,
|
||||
- сервисы работают с ORM и транзакциями,
|
||||
- сервисы содержат правила заводской логики (MES/ERP).
|
||||
"""
|
||||
265
shiftflow/services/bom_explosion.py
Normal file
265
shiftflow/services/bom_explosion.py
Normal 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)
|
||||
120
shiftflow/services/closing.py
Normal file
120
shiftflow/services/closing.py
Normal 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,
|
||||
)
|
||||
191
shiftflow/services/sessions.py
Normal file
191
shiftflow/services/sessions.py
Normal 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"])
|
||||
Reference in New Issue
Block a user