Конкретно пересмотрел логику работы. Легаси вынесена в архив
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

@@ -0,0 +1,202 @@
import logging
from django.db import transaction
from django.db.models import Q, Case, When, Value, IntegerField
from django.utils import timezone
from warehouse.models import StockItem
from shiftflow.models import WorkItem, CuttingSession, ProductionReportConsumption, ProductionReportStockResult
from shiftflow.services.bom_explosion import _build_bom_graph
from shiftflow.services.kitting import get_work_location_for_workitem
from manufacturing.models import EntityOperation
def get_first_operation_id(entity_id: int) -> int | None:
op_id = (
EntityOperation.objects.filter(entity_id=int(entity_id))
.order_by('seq', 'id')
.values_list('operation_id', flat=True)
.first()
)
return int(op_id) if op_id else None
logger = logging.getLogger('mes')
def get_assembly_closing_info(workitem: WorkItem) -> dict:
"""
Возвращает информацию о том, сколько сборок можно выпустить и
какие компоненты для этого нужны.
"""
first_op_id = get_first_operation_id(int(workitem.entity_id))
if first_op_id and getattr(workitem, 'operation_id', None) and int(workitem.operation_id) != int(first_op_id):
return {'error': 'Списание комплектации выполняется только на первой операции техпроцесса. Для этой операции закрывай только факт выполнения.', 'is_first_operation': False}
to_location = get_work_location_for_workitem(workitem)
if not to_location:
return {'error': 'Не определён склад участка для этого задания.'}
# Считаем BOM 1-го уровня
adjacency = _build_bom_graph({workitem.entity_id})
children = adjacency.get(workitem.entity_id) or []
if not children:
return {'error': 'Спецификация пуста. Нечего списывать.', 'to_location': to_location}
bom_req = {} # entity_id -> qty_per_1
for child_id, qty in children:
bom_req[child_id] = bom_req.get(child_id, 0) + qty
component_ids = list(bom_req.keys())
stocks = StockItem.objects.filter(
location=to_location,
entity_id__in=component_ids,
is_archived=False,
quantity__gt=0
).filter(Q(deal_id=workitem.deal_id) | Q(deal_id__isnull=True))
stock_by_entity = {}
for s in stocks:
stock_by_entity[s.entity_id] = stock_by_entity.get(s.entity_id, 0) + s.quantity
max_possible = float('inf')
components_info = []
from manufacturing.models import ProductEntity
entities = {e.id: e for e in ProductEntity.objects.filter(id__in=component_ids)}
for eid, req_qty in bom_req.items():
avail = float(stock_by_entity.get(eid, 0))
can_make = int(avail // float(req_qty)) if req_qty > 0 else 0
if can_make < max_possible:
max_possible = can_make
components_info.append({
'entity': entities.get(eid),
'req_per_1': float(req_qty),
'available': avail,
'max_possible': can_make
})
if max_possible == float('inf'):
max_possible = 0
components_info.sort(key=lambda x: (str(x['entity'].entity_type or ''), str(x['entity'].name or '')) if x['entity'] else ('', ''))
# Ограничиваем max_possible тем, что реально осталось собрать по заданию
remaining = max(0, (workitem.quantity_plan or 0) - (workitem.quantity_done or 0))
if max_possible > remaining:
max_possible = remaining
return {
'to_location': to_location,
'max_possible': int(max_possible),
'components': components_info,
'error': None,
'is_first_operation': True,
}
@transaction.atomic
def apply_assembly_closing(workitem_id: int, fact_qty: int, user_id: int) -> bool:
logger.info('assembly_closing:start workitem_id=%s qty=%s user_id=%s', workitem_id, fact_qty, user_id)
workitem = WorkItem.objects.select_for_update(of=('self',)).get(id=int(workitem_id))
first_op_id = get_first_operation_id(int(workitem.entity_id))
if first_op_id and getattr(workitem, 'operation_id', None) and int(workitem.operation_id) != int(first_op_id):
raise ValueError('Списание комплектации выполняется только на первой операции техпроцесса.')
if fact_qty <= 0:
raise ValueError('Количество должно быть больше 0.')
info = get_assembly_closing_info(workitem)
if info.get('error'):
raise ValueError(info['error'])
if fact_qty > info['max_possible']:
raise ValueError(f'Недостаточно компонентов на участке. Максимум можно собрать: {info["max_possible"]} шт.')
to_location = info['to_location']
if not getattr(workitem, 'machine_id', None):
raise ValueError('Для закрытия сборки требуется выбрать пост (станок) в сменном задании.')
report = CuttingSession.objects.create(
operator_id=int(user_id),
machine_id=int(workitem.machine_id),
used_stock_item=None,
date=timezone.localdate(),
is_closed=True,
)
logger.info('assembly_closing:report_created id=%s', report.id)
# Списываем компоненты 1-го уровня
adjacency = _build_bom_graph({workitem.entity_id})
children = adjacency.get(workitem.entity_id) or []
bom_req = {}
for child_id, qty in children:
bom_req[child_id] = bom_req.get(child_id, 0) + qty
for eid, req_qty in bom_req.items():
total_needed = float(req_qty * fact_qty)
# Приоритет "сделка", потом "свободные", FIFO
qs = StockItem.objects.select_for_update().filter(
location=to_location,
entity_id=eid,
is_archived=False,
quantity__gt=0
).filter(Q(deal_id=workitem.deal_id) | Q(deal_id__isnull=True)).annotate(
prio=Case(
When(deal_id=workitem.deal_id, then=Value(0)),
default=Value(1),
output_field=IntegerField(),
)
).order_by('prio', 'created_at', 'id')
rem = total_needed
for si in qs:
if rem <= 0:
break
take = min(rem, float(si.quantity))
ProductionReportConsumption.objects.create(
report=report,
material=None,
stock_item=si,
quantity=float(take),
)
si.quantity = float(si.quantity) - take
if si.quantity <= 0.0001:
si.quantity = 0
si.is_archived = True
si.archived_at = timezone.now()
si.save(update_fields=['quantity', 'is_archived', 'archived_at'])
rem -= take
if rem > 0.0001:
raise ValueError(f'Непредвиденная нехватка компонента ID {eid} при списании. Нужно еще: {rem}')
# Выпуск готовой сборки
produced = StockItem.objects.create(
entity_id=workitem.entity_id,
deal_id=workitem.deal_id,
location=to_location,
quantity=float(fact_qty),
is_customer_supplied=False,
)
ProductionReportStockResult.objects.create(report=report, stock_item=produced, kind='finished')
# Двигаем техпроцесс
workitem.quantity_done = (workitem.quantity_done or 0) + fact_qty
if workitem.quantity_done >= workitem.quantity_plan:
workitem.status = 'done'
workitem.save(update_fields=['quantity_done', 'status'])
logger.info(
'assembly_closing:done workitem_id=%s qty=%s deal_id=%s location_id=%s user_id=%s report_id=%s',
workitem.id,
fact_qty,
workitem.deal_id,
to_location.id,
user_id,
report.id,
)
return True

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)

View File

@@ -1,4 +1,5 @@
from django.db import transaction
from django.db.models import F
from django.utils import timezone
import logging
@@ -130,3 +131,92 @@ def apply_closing(
)
logger.info('apply_closing:done report=%s', report.id)
@transaction.atomic
def apply_closing_workitems(
*,
user_id: int,
machine_id: int,
material_id: int,
item_actions: dict[int, dict], # workitem_id -> {'action': 'done'|'partial', 'fact': int}
consumptions: dict[int, float],
remnants: list[dict],
) -> None:
logger.info('apply_closing_workitems:start user=%s machine=%s material=%s workitems=%s cons=%s rem=%s', user_id, machine_id, material_id, list(item_actions.keys()), list(consumptions.keys()), len(remnants))
from shiftflow.models import WorkItem, ProductionTask
wis = list(
WorkItem.objects.select_for_update(of=("self",))
.select_related('deal', 'entity', 'machine')
.filter(id__in=list(item_actions.keys()), machine_id=machine_id, status__in=['planned'], entity__planned_material_id=material_id)
.filter(quantity_done__lt=F('quantity_plan'))
)
if not wis:
raise RuntimeError('Не найдено сменных заданий для закрытия.')
report = CuttingSession.objects.create(
operator_id=user_id,
machine_id=machine_id,
used_stock_item=None,
date=timezone.localdate(),
is_closed=False,
)
created_shift = 0
for wi in wis:
spec = item_actions.get(wi.id) or {}
action = (spec.get('action') or '').strip()
fact = int(spec.get('fact') or 0)
if action not in ['done', 'partial']:
continue
plan_total = int(wi.quantity_plan or 0)
done_total = int(wi.quantity_done or 0)
remaining = max(0, plan_total - done_total)
if remaining <= 0:
continue
if action == 'done':
fact = remaining
else:
fact = max(0, min(fact, remaining))
if fact <= 0:
raise RuntimeError('При частичном закрытии факт должен быть больше 0.')
pt = ProductionTask.objects.filter(deal_id=wi.deal_id, entity_id=wi.entity_id).first()
if not pt:
raise RuntimeError('Не найден ProductionTask для задания.')
ShiftItem.objects.create(session=report, task=pt, quantity_fact=fact)
created_shift += 1
wi.quantity_done = done_total + fact
if wi.quantity_done >= plan_total:
wi.status = 'done'
elif wi.quantity_done > 0:
wi.status = 'leftover'
else:
wi.status = 'planned'
wi.save(update_fields=['quantity_done', 'status'])
for stock_item_id, qty in consumptions.items():
if qty and float(qty) > 0:
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)
logger.info('apply_closing_workitems:done report=%s shift_items=%s', report.id, created_shift)

View File

@@ -0,0 +1,268 @@
import logging
from typing import Any
from django.db import transaction
from django.db.models import Case, IntegerField, Q, Value, When
from django.utils import timezone
from manufacturing.models import ProductEntity
from warehouse.models import Location, StockItem, TransferLine, TransferRecord
from warehouse.services.transfers import receive_transfer
from shiftflow.services.bom_explosion import _accumulate_requirements, _build_bom_graph
logger = logging.getLogger('mes')
def _session_key(workitem_id: int) -> str:
return f'kitting_draft_workitem_{int(workitem_id)}'
def get_kitting_draft(session: Any, workitem_id: int) -> list[dict]:
key = _session_key(workitem_id)
raw = session.get(key)
if isinstance(raw, list):
out = []
for x in raw:
if not isinstance(x, dict):
continue
out.append({
'entity_id': int(x.get('entity_id') or 0),
'from_location_id': int(x.get('from_location_id') or 0),
'quantity': int(x.get('quantity') or 0),
})
return out
return []
def clear_kitting_draft(session: Any, workitem_id: int) -> None:
key = _session_key(workitem_id)
if key in session:
del session[key]
session.modified = True
def add_kitting_line(session: Any, workitem_id: int, entity_id: int, from_location_id: int, quantity: int) -> None:
workitem_id = int(workitem_id)
entity_id = int(entity_id)
from_location_id = int(from_location_id)
quantity = int(quantity)
if workitem_id <= 0 or entity_id <= 0 or from_location_id <= 0 or quantity <= 0:
return
key = _session_key(workitem_id)
draft = get_kitting_draft(session, workitem_id)
merged = False
for ln in draft:
if int(ln.get('entity_id') or 0) == entity_id and int(ln.get('from_location_id') or 0) == from_location_id:
ln['quantity'] = int(ln.get('quantity') or 0) + quantity
merged = True
break
if not merged:
draft.append({'entity_id': entity_id, 'from_location_id': from_location_id, 'quantity': quantity})
session[key] = draft
session.modified = True
def remove_kitting_line(session: Any, workitem_id: int, entity_id: int, from_location_id: int, quantity: int) -> None:
workitem_id = int(workitem_id)
entity_id = int(entity_id)
from_location_id = int(from_location_id)
quantity = int(quantity)
if workitem_id <= 0 or entity_id <= 0 or from_location_id <= 0 or quantity <= 0:
return
key = _session_key(workitem_id)
draft = get_kitting_draft(session, workitem_id)
out = []
for ln in draft:
if int(ln.get('entity_id') or 0) == entity_id and int(ln.get('from_location_id') or 0) == from_location_id:
cur = int(ln.get('quantity') or 0)
cur = max(0, cur - quantity)
if cur > 0:
ln['quantity'] = cur
out.append(ln)
continue
out.append(ln)
session[key] = out
session.modified = True
def get_work_location_for_workitem(workitem) -> Location | None:
m = getattr(workitem, 'machine', None)
if m and getattr(m, 'workshop_id', None) and getattr(getattr(m, 'workshop', None), 'location_id', None):
return m.workshop.location
if m and getattr(m, 'location_id', None):
return m.location
w = getattr(workitem, 'workshop', None)
if w and getattr(w, 'location_id', None):
return w.location
return None
def build_kitting_requirements(root_entity_id: int, qty_to_make: int) -> dict[int, int]:
"""Потребность на комплектацию для сборки/изделия.
Комментарий: для ручной комплектации мастеру важны прямые компоненты (1 уровень BOM),
включая подсборки. Глубину дерева раскрываем отдельными заданиями на подсборки.
"""
root_entity_id = int(root_entity_id)
qty_to_make = int(qty_to_make or 0)
if root_entity_id <= 0 or qty_to_make <= 0:
return {}
adjacency = _build_bom_graph({root_entity_id})
children = adjacency.get(root_entity_id) or []
out: dict[int, int] = {}
for child_id, per1 in children:
cid = int(child_id)
need = int(per1 or 0) * qty_to_make
if cid <= 0 or need <= 0:
continue
out[cid] = int(out.get(cid, 0) or 0) + need
return out
def build_kitting_leaf_requirements(root_entity_id: int, qty_to_make: int) -> dict[int, int]:
"""Совместимость: старое имя оставлено, сейчас возвращает потребность 1-го уровня BOM."""
return build_kitting_requirements(root_entity_id, qty_to_make)
@transaction.atomic
def _apply_one_transfer(
*,
deal_id: int,
component_entity_id: int,
from_location_id: int,
to_location_id: int,
quantity: int,
user_id: int,
) -> int:
deal_id = int(deal_id)
component_entity_id = int(component_entity_id)
from_location_id = int(from_location_id)
to_location_id = int(to_location_id)
quantity = int(quantity)
user_id = int(user_id)
if quantity <= 0:
raise RuntimeError('Количество должно быть больше 0.')
if from_location_id == to_location_id:
raise RuntimeError('Склад-источник и склад назначения совпадают.')
# Комментарий: двигаем только "под сделку" и свободные остатки.
# Приоритет: под сделку -> свободные, затем FIFO по поступлению.
qs = (
StockItem.objects.select_for_update()
.filter(is_archived=False, quantity__gt=0)
.filter(location_id=from_location_id, entity_id=component_entity_id)
.filter(Q(deal_id=deal_id) | Q(deal_id__isnull=True))
.annotate(
prio=Case(
When(deal_id=deal_id, then=Value(0)),
default=Value(1),
output_field=IntegerField(),
)
)
.order_by('prio', 'created_at', 'id')
)
remaining = float(quantity)
picked: list[tuple[int, float]] = []
for si in qs:
if remaining <= 0:
break
avail = float(si.quantity or 0)
if avail <= 0:
continue
take = min(remaining, avail)
if take <= 0:
continue
picked.append((int(si.id), float(take)))
remaining -= take
if remaining > 0:
# Комментарий: допускаем расхождения с фактом (по данным базы может быть меньше, чем нужно по месту).
# Для продолжения процесса создаем "виртуальный" остаток под сделку на складе-источнике и перемещаем его.
phantom = StockItem.objects.create(
entity_id=int(component_entity_id),
deal_id=int(deal_id),
location_id=int(from_location_id),
quantity=float(remaining),
)
picked.append((int(phantom.id), float(remaining)))
logger.warning(
'kitting_transfer: phantom_created deal_id=%s entity_id=%s from_location=%s qty=%s',
deal_id,
component_entity_id,
from_location_id,
float(remaining),
)
remaining = 0.0
tr = TransferRecord.objects.create(
from_location_id=from_location_id,
to_location_id=to_location_id,
sender_id=user_id,
receiver_id=user_id,
occurred_at=timezone.now(),
status='received',
received_at=timezone.now(),
is_applied=False,
)
for sid, qty in picked:
TransferLine.objects.create(transfer=tr, stock_item_id=sid, quantity=float(qty))
receive_transfer(tr.id, user_id)
logger.info(
'kitting_transfer: ok tr_id=%s deal_id=%s component=%s from=%s to=%s qty=%s',
tr.id, deal_id, component_entity_id, from_location_id, to_location_id, quantity
)
return int(tr.id)
def apply_kitting_draft(
*,
session: Any,
workitem_id: int,
deal_id: int,
to_location_id: int,
user_id: int,
) -> dict[str, int]:
draft = get_kitting_draft(session, int(workitem_id))
if not draft:
return {'applied': 0, 'errors': 0}
applied = 0
errors = 0
for ln in draft:
try:
_apply_one_transfer(
deal_id=int(deal_id),
component_entity_id=int(ln.get('entity_id') or 0),
from_location_id=int(ln.get('from_location_id') or 0),
to_location_id=int(to_location_id),
quantity=int(ln.get('quantity') or 0),
user_id=int(user_id),
)
applied += 1
except Exception:
errors += 1
logger.exception('kitting_transfer:error deal_id=%s workitem_id=%s line=%s', deal_id, workitem_id, ln)
if errors == 0:
clear_kitting_draft(session, int(workitem_id))
return {'applied': applied, 'errors': errors}