Добавил страницу отгрузки, подправил логику генерации сменных заданий. Организовал редактирование позици сделок
All checks were successful
Deploy MES Core / deploy (push) Successful in 29s

This commit is contained in:
2026-04-14 07:27:54 +03:00
parent 69edd3fa97
commit 49e9080d0e
14 changed files with 2056 additions and 564 deletions

View File

@@ -0,0 +1,285 @@
import logging
from typing import Dict
from django.db import transaction
from django.db.models import Sum
from django.db.models.functions import Coalesce
from django.utils import timezone
from manufacturing.models import EntityOperation
from shiftflow.models import DealItem, WorkItem
from warehouse.models import Material, StockItem, TransferLine, TransferRecord
from warehouse.services.transfers import receive_transfer
logger = logging.getLogger('mes')
def build_shipment_rows(
*,
deal_id: int,
shipping_location_id: int,
) -> tuple[list[dict], list[dict]]:
"""
Формирует данные для интерфейса отгрузки по сделке:
- Список деталей (Entities), готовых к отгрузке и уже отгруженных.
- Список давальческого сырья (Materials), доступного для возврата/отгрузки.
"""
deal_items = list(
DealItem.objects.select_related('entity')
.filter(deal_id=int(deal_id))
.order_by('entity__entity_type', 'entity__drawing_number', 'entity__name', 'id')
)
entities = [it.entity for it in deal_items if it.entity_id and it.entity]
ent_ids = [int(e.id) for e in entities if e]
entity_ops = list(
EntityOperation.objects.select_related('operation')
.filter(entity_id__in=ent_ids)
.order_by('entity_id', 'seq', 'id')
)
route_codes: dict[int, list[str]] = {}
last_code: dict[int, str] = {}
for eo in entity_ops:
if not eo.operation_id or not eo.operation:
continue
code = (eo.operation.code or '').strip()
if not code:
continue
route_codes.setdefault(int(eo.entity_id), []).append(code)
# Определяем последнюю операцию в маршруте для каждой детали
for eid, codes in route_codes.items():
if codes:
last_code[int(eid)] = str(codes[-1])
wi_qs = WorkItem.objects.select_related('operation').filter(deal_id=int(deal_id), entity_id__in=ent_ids)
done_by: dict[tuple[int, str], int] = {}
done_total_by_entity: dict[int, int] = {}
# Подсчитываем количество выполненных деталей по операциям
for wi in wi_qs:
op_code = ''
if getattr(wi, 'operation_id', None) and getattr(wi, 'operation', None):
op_code = (wi.operation.code or '').strip()
if not op_code:
op_code = (wi.stage or '').strip()
if not op_code:
continue
eid = int(wi.entity_id)
done = int(wi.quantity_done or 0)
done_by[(eid, str(op_code))] = done_by.get((eid, str(op_code)), 0) + done
done_total_by_entity[eid] = done_total_by_entity.get(eid, 0) + done
shipped_by = {
int(r['entity_id']): float(r['s'] or 0.0)
for r in StockItem.objects.filter(
is_archived=False,
location_id=int(shipping_location_id),
deal_id=int(deal_id),
entity_id__in=ent_ids,
)
.values('entity_id')
.annotate(s=Coalesce(Sum('quantity'), 0.0))
}
ent_avail = {
int(r['entity_id']): float(r['s'] or 0.0)
for r in StockItem.objects.filter(
deal_id=int(deal_id),
is_archived=False,
quantity__gt=0,
entity_id__in=ent_ids,
)
.exclude(location_id=int(shipping_location_id))
.values('entity_id')
.annotate(s=Coalesce(Sum('quantity'), 0.0))
}
entity_rows = []
for di in deal_items:
e = di.entity
if not e:
continue
need = int(di.quantity or 0)
if need <= 0:
continue
eid = int(e.id)
last = last_code.get(eid)
# Количество готовых деталей: берем по последней операции маршрута,
# либо общее количество, если маршрут не задан
ready_done = int(done_by.get((eid, str(last)), 0) or 0) if last else int(done_total_by_entity.get(eid, 0) or 0)
ready_val = min(need, ready_done)
# Сколько уже отгружено на склад отгрузки
shipped_val = int(shipped_by.get(eid, 0.0) or 0.0)
shipped_val = min(need, shipped_val)
remaining_ready = int(max(0, ready_val - shipped_val))
if remaining_ready <= 0:
continue
entity_rows.append({
'entity': e,
'available': float(ent_avail.get(eid, 0.0) or 0.0),
'ready': int(ready_val),
'shipped': int(shipped_val),
'remaining_ready': int(remaining_ready),
})
mat_rows = list(
StockItem.objects.filter(
deal_id=int(deal_id),
is_archived=False,
quantity__gt=0,
material_id__isnull=False,
is_customer_supplied=True,
)
.exclude(location_id=int(shipping_location_id))
.values('material_id')
.annotate(available=Coalesce(Sum('quantity'), 0.0))
.order_by('material_id')
)
mat_ids = [int(r['material_id']) for r in mat_rows if r.get('material_id')]
mats = {m.id: m for m in Material.objects.filter(id__in=mat_ids)}
material_rows = []
for r in mat_rows:
mid = int(r['material_id'])
m = mats.get(mid)
if not m:
continue
material_rows.append({
'material': m,
'available': float(r.get('available') or 0.0),
})
return entity_rows, material_rows
@transaction.atomic
def create_shipment_transfers(
*,
deal_id: int,
shipping_location_id: int,
entity_qty: Dict[int, int],
material_qty: Dict[int, float],
user_id: int,
) -> list[int]:
"""
Создает документы перемещения (TransferRecord) на склад отгрузки
для указанных деталей и давальческого сырья.
"""
logger.info(
'fn:start create_shipment_transfers deal_id=%s shipping_location_id=%s user_id=%s',
deal_id, shipping_location_id, user_id
)
now = timezone.now()
transfers_by_location: dict[int, TransferRecord] = {}
def get_transfer(from_location_id: int) -> TransferRecord:
tr = transfers_by_location.get(int(from_location_id))
if tr:
return tr
tr = TransferRecord.objects.create(
from_location_id=int(from_location_id),
to_location_id=int(shipping_location_id),
sender_id=int(user_id),
receiver_id=int(user_id),
occurred_at=now,
status='received',
received_at=now,
is_applied=False,
)
transfers_by_location[int(from_location_id)] = tr
return tr
def alloc_stock_lines(qs, need_qty: float) -> None:
"""
Резервирует необходимое количество из доступных складских остатков.
Использует select_for_update для предотвращения гонок данных.
"""
remaining = float(need_qty)
if remaining <= 0:
return
items = list(qs.select_for_update().order_by('location_id', 'created_at', 'id'))
for si in items:
if remaining <= 0:
break
if int(si.location_id) == int(shipping_location_id):
continue
si_qty = float(si.quantity or 0.0)
if si_qty <= 0:
continue
if si.unique_id:
if remaining < si_qty:
raise ValueError('Нельзя частично отгружать позицию с маркировкой (unique_id).')
take = si_qty
else:
take = min(remaining, si_qty)
tr = get_transfer(int(si.location_id))
TransferLine.objects.create(transfer=tr, stock_item=si, quantity=float(take))
remaining -= float(take)
if remaining > 0:
raise ValueError('Недостаточно количества на складах для отгрузки.')
for ent_id, qty in (entity_qty or {}).items():
q = int(qty or 0)
if q <= 0:
continue
alloc_stock_lines(
StockItem.objects.filter(
deal_id=int(deal_id),
is_archived=False,
quantity__gt=0,
entity_id=int(ent_id),
).select_related('entity', 'location'),
float(q),
)
for mat_id, qty in (material_qty or {}).items():
q = float(qty or 0.0)
if q <= 0:
continue
alloc_stock_lines(
StockItem.objects.filter(
deal_id=int(deal_id),
is_archived=False,
quantity__gt=0,
material_id=int(mat_id),
is_customer_supplied=True,
).select_related('material', 'location'),
float(q),
)
ids = []
for tr in transfers_by_location.values():
receive_transfer(int(tr.id), int(user_id))
ids.append(int(tr.id))
ids.sort()
logger.info(
'fn:done create_shipment_transfers deal_id=%s transfers=%s',
deal_id,
ids,
)
return ids