status !== SparePartOrder::STATUS_IN_STOCK) { return []; } return DB::transaction(function () use ($order) { $results = []; // Поиск открытых дефицитов для этой запчасти $shortages = Shortage::query() ->where('spare_part_id', $order->spare_part_id) ->where('with_documents', $order->with_documents) ->where('status', Shortage::STATUS_OPEN) ->where('missing_qty', '>', 0) ->orderBy('created_at', 'asc') // FIFO по дате дефицита ->lockForUpdate() ->get(); if ($shortages->isEmpty()) { return []; } // Сколько свободно в партии (с учётом уже созданных резервов) $alreadyReserved = Reservation::query() ->where('spare_part_order_id', $order->id) ->where('status', Reservation::STATUS_ACTIVE) ->sum('reserved_qty'); $availableQty = $order->available_qty - $alreadyReserved; foreach ($shortages as $shortage) { if ($availableQty <= 0) break; // Сколько можем покрыть $toCover = min($availableQty, $shortage->missing_qty); // Создаём движение резерва $movement = InventoryMovement::create([ 'spare_part_order_id' => $order->id, 'spare_part_id' => $order->spare_part_id, 'qty' => $toCover, 'movement_type' => InventoryMovement::TYPE_RESERVE, 'source_type' => InventoryMovement::SOURCE_SHORTAGE_FULFILLMENT, 'source_id' => $shortage->id, 'with_documents' => $order->with_documents, 'user_id' => auth()->id(), 'note' => "Автоматическое резервирование при поступлении для закрытия дефицита #{$shortage->id}", ]); // Создаём резерв под дефицит $reservation = Reservation::create([ 'spare_part_id' => $order->spare_part_id, 'spare_part_order_id' => $order->id, 'reclamation_id' => $shortage->reclamation_id, 'reserved_qty' => $toCover, 'with_documents' => $order->with_documents, 'status' => Reservation::STATUS_ACTIVE, 'movement_id' => $movement->id, ]); // Обновляем дефицит $shortage->addReserved($toCover); $results[] = [ 'shortage_id' => $shortage->id, 'reclamation_id' => $shortage->reclamation_id, 'covered_qty' => $toCover, 'remaining_missing' => $shortage->missing_qty, 'shortage_closed' => $shortage->isClosed(), 'reservation_id' => $reservation->id, ]; $availableQty -= $toCover; } return $results; }); } /** * Получить все открытые дефициты */ public function getOpenShortages(): Collection { return Shortage::query() ->where('status', Shortage::STATUS_OPEN) ->with(['sparePart', 'reclamation']) ->orderBy('created_at', 'asc') ->get(); } /** * Получить открытые дефициты для конкретной запчасти */ public function getShortagesForSparePart(int $sparePartId, ?bool $withDocuments = null): Collection { $query = Shortage::query() ->where('spare_part_id', $sparePartId) ->where('status', Shortage::STATUS_OPEN); if ($withDocuments !== null) { $query->where('with_documents', $withDocuments); } return $query->orderBy('created_at', 'asc')->get(); } /** * Получить сумму недостачи по запчасти */ public function getTotalMissing(int $sparePartId, ?bool $withDocuments = null): int { $query = Shortage::query() ->where('spare_part_id', $sparePartId) ->where('status', Shortage::STATUS_OPEN); if ($withDocuments !== null) { $query->where('with_documents', $withDocuments); } return (int) $query->sum('missing_qty'); } /** * Получить запчасти с критическими дефицитами (для контроля наличия) */ public function getCriticalShortages(): Collection { return SparePart::query() ->whereHas('shortages', function ($query) { $query->where('status', Shortage::STATUS_OPEN) ->where('missing_qty', '>', 0); }) ->with(['shortages' => function ($query) { $query->where('status', Shortage::STATUS_OPEN); }]) ->get() ->map(function ($sparePart) { $sparePart->shortage_details = $sparePart->shortages->map(function ($shortage) { return [ 'reclamation_id' => $shortage->reclamation_id, 'with_documents' => $shortage->with_documents, 'missing_qty' => $shortage->missing_qty, 'created_at' => $shortage->created_at, ]; }); return $sparePart; }); } /** * Закрыть дефицит вручную (например, при отмене рекламации) */ public function closeShortage(Shortage $shortage, string $reason = ''): bool { if ($shortage->isClosed()) { return false; } $shortage->status = Shortage::STATUS_CLOSED; $shortage->note = ($shortage->note ? $shortage->note . "\n" : '') . "Закрыто вручную: " . ($reason ?: 'без указания причины'); $shortage->save(); return true; } /** * Пересчитать все дефициты для рекламации */ public function recalculateForReclamation(int $reclamationId): void { $shortages = Shortage::query() ->where('reclamation_id', $reclamationId) ->where('status', Shortage::STATUS_OPEN) ->get(); foreach ($shortages as $shortage) { $shortage->recalculate(); } } /** * Рассчитать сколько нужно заказать для покрытия всех дефицитов */ public function calculateOrderQuantity(int $sparePartId, bool $withDocuments): int { return $this->getTotalMissing($sparePartId, $withDocuments); } }