where('spare_part_id', $sparePartId) ->where('with_documents', $withDocuments) ->where('status', SparePartOrder::STATUS_IN_STOCK) ->sum('available_qty'); $activeReserves = Reservation::query() ->where('spare_part_id', $sparePartId) ->where('with_documents', $withDocuments) ->where('status', Reservation::STATUS_ACTIVE) ->sum('reserved_qty'); $freeStock = $physicalStock - $activeReserves; // 2. Определение сколько можем зарезервировать $canReserve = min($quantity, max(0, $freeStock)); $missing = $quantity - $canReserve; $reservations = collect(); $shortage = null; // 3. Резервирование доступного количества (FIFO по партиям) if ($canReserve > 0) { $remainingToReserve = $canReserve; $orders = SparePartOrder::query() ->where('spare_part_id', $sparePartId) ->where('with_documents', $withDocuments) ->where('status', SparePartOrder::STATUS_IN_STOCK) ->where('available_qty', '>', 0) ->orderBy('created_at', 'asc') ->lockForUpdate() ->get(); foreach ($orders as $order) { if ($remainingToReserve <= 0) break; // Сколько уже зарезервировано из этой партии $alreadyReserved = Reservation::query() ->where('spare_part_order_id', $order->id) ->where('status', Reservation::STATUS_ACTIVE) ->sum('reserved_qty'); $freeInOrder = $order->available_qty - $alreadyReserved; $toReserve = min($remainingToReserve, $freeInOrder); if ($toReserve > 0) { // Создаём движение типа reserve $movement = InventoryMovement::create([ 'spare_part_order_id' => $order->id, 'spare_part_id' => $sparePartId, 'qty' => $toReserve, 'movement_type' => InventoryMovement::TYPE_RESERVE, 'source_type' => InventoryMovement::SOURCE_RECLAMATION, 'source_id' => $reclamationId, 'with_documents' => $withDocuments, 'user_id' => auth()->id(), 'note' => "Резервирование для рекламации #{$reclamationId}", ]); // Создаём резерв $reservation = Reservation::create([ 'spare_part_id' => $sparePartId, 'spare_part_order_id' => $order->id, 'reclamation_id' => $reclamationId, 'reserved_qty' => $toReserve, 'with_documents' => $withDocuments, 'status' => Reservation::STATUS_ACTIVE, 'movement_id' => $movement->id, ]); $reservations->push($reservation); $remainingToReserve -= $toReserve; } } } // 4. Создание дефицита при нехватке if ($missing > 0) { $shortage = Shortage::create([ 'spare_part_id' => $sparePartId, 'reclamation_id' => $reclamationId, 'with_documents' => $withDocuments, 'required_qty' => $quantity, 'reserved_qty' => $canReserve, 'missing_qty' => $missing, 'status' => Shortage::STATUS_OPEN, 'note' => "Дефицит при резервировании для рекламации #{$reclamationId}", ]); } return new ReservationResult( reserved: $canReserve, missing: $missing, reservations: $reservations, shortage: $shortage ); }); } /** * Отменить резервы для рекламации * * @param int $reclamationId ID рекламации * @param int|null $sparePartId ID запчасти (опционально, если нужно отменить для конкретной) * @param bool|null $withDocuments Тип документов (опционально) * @return int Количество отменённых единиц */ public function cancelForReclamation( int $reclamationId, ?int $sparePartId = null, ?bool $withDocuments = null ): int { return DB::transaction(function () use ($reclamationId, $sparePartId, $withDocuments) { $query = Reservation::query() ->where('reclamation_id', $reclamationId) ->where('status', Reservation::STATUS_ACTIVE); if ($sparePartId !== null) { $query->where('spare_part_id', $sparePartId); } if ($withDocuments !== null) { $query->where('with_documents', $withDocuments); } $reservations = $query->lockForUpdate()->get(); $totalCancelled = 0; foreach ($reservations as $reservation) { $this->cancelReservation($reservation, "Отмена резерва для рекламации #{$reclamationId}"); $totalCancelled += $reservation->reserved_qty; } // Также закрываем связанные дефициты $shortageQuery = Shortage::query() ->where('reclamation_id', $reclamationId) ->where('status', Shortage::STATUS_OPEN); if ($sparePartId !== null) { $shortageQuery->where('spare_part_id', $sparePartId); } if ($withDocuments !== null) { $shortageQuery->where('with_documents', $withDocuments); } $shortageQuery->update(['status' => Shortage::STATUS_CLOSED]); return $totalCancelled; }); } /** * Отменить конкретный резерв */ public function cancelReservation(Reservation $reservation, string $reason = ''): bool { if (!$reservation->isActive()) { return false; } return DB::transaction(function () use ($reservation, $reason) { $reservation->status = Reservation::STATUS_CANCELLED; $reservation->save(); // Создаём движение отмены InventoryMovement::create([ 'spare_part_order_id' => $reservation->spare_part_order_id, 'spare_part_id' => $reservation->spare_part_id, 'qty' => $reservation->reserved_qty, 'movement_type' => InventoryMovement::TYPE_RESERVE_CANCEL, 'source_type' => InventoryMovement::SOURCE_RECLAMATION, 'source_id' => $reservation->reclamation_id, 'with_documents' => $reservation->with_documents, 'user_id' => auth()->id(), 'note' => $reason, ]); return true; }); } /** * Изменить количество резерва для рекламации * * @param int $reclamationId ID рекламации * @param int $sparePartId ID запчасти * @param bool $withDocuments Тип документов * @param int $newQuantity Новое требуемое количество * @return ReservationResult */ public function adjustReservation( int $reclamationId, int $sparePartId, bool $withDocuments, int $newQuantity ): ReservationResult { return DB::transaction(function () use ($reclamationId, $sparePartId, $withDocuments, $newQuantity) { // Получаем текущие резервы $currentReserved = Reservation::query() ->where('reclamation_id', $reclamationId) ->where('spare_part_id', $sparePartId) ->where('with_documents', $withDocuments) ->where('status', Reservation::STATUS_ACTIVE) ->sum('reserved_qty'); $diff = $newQuantity - $currentReserved; if ($diff > 0) { // Нужно дорезервировать return $this->reserve($sparePartId, $diff, $withDocuments, $reclamationId); } elseif ($diff < 0) { // Нужно освободить часть резерва $toRelease = abs($diff); $released = 0; // Освобождаем в обратном порядке FIFO (сначала новые) $reservations = Reservation::query() ->where('reclamation_id', $reclamationId) ->where('spare_part_id', $sparePartId) ->where('with_documents', $withDocuments) ->where('status', Reservation::STATUS_ACTIVE) ->orderBy('created_at', 'desc') ->lockForUpdate() ->get(); foreach ($reservations as $reservation) { if ($released >= $toRelease) break; $releaseFromThis = min($toRelease - $released, $reservation->reserved_qty); if ($releaseFromThis === $reservation->reserved_qty) { // Отменяем весь резерв $this->cancelReservation($reservation, "Уменьшение резерва"); } else { // Частичная отмена — создаём новый резерв на остаток $reservation->reserved_qty -= $releaseFromThis; $reservation->save(); // Движение частичной отмены InventoryMovement::create([ 'spare_part_order_id' => $reservation->spare_part_order_id, 'spare_part_id' => $sparePartId, 'qty' => $releaseFromThis, 'movement_type' => InventoryMovement::TYPE_RESERVE_CANCEL, 'source_type' => InventoryMovement::SOURCE_RECLAMATION, 'source_id' => $reclamationId, 'with_documents' => $withDocuments, 'user_id' => auth()->id(), 'note' => "Частичная отмена резерва", ]); } $released += $releaseFromThis; } // Обновляем дефицит если есть $shortage = Shortage::query() ->where('reclamation_id', $reclamationId) ->where('spare_part_id', $sparePartId) ->where('with_documents', $withDocuments) ->where('status', Shortage::STATUS_OPEN) ->first(); if ($shortage) { $shortage->required_qty = $newQuantity; $shortage->recalculate(); } // Пересчитываем фактически зарезервированное количество после изменений $actualReserved = Reservation::query() ->where('reclamation_id', $reclamationId) ->where('spare_part_id', $sparePartId) ->where('with_documents', $withDocuments) ->where('status', Reservation::STATUS_ACTIVE) ->sum('reserved_qty'); return new ReservationResult( reserved: (int) $actualReserved, missing: max(0, $newQuantity - $actualReserved), reservations: collect(), shortage: $shortage ); } // diff = 0, ничего не делаем return new ReservationResult( reserved: $currentReserved, missing: 0, reservations: collect(), shortage: null ); }); } /** * Получить все активные резервы для рекламации */ public function getReservationsForReclamation(int $reclamationId): Collection { return Reservation::query() ->where('reclamation_id', $reclamationId) ->where('status', Reservation::STATUS_ACTIVE) ->with(['sparePart', 'sparePartOrder']) ->get(); } } /** * Результат операции резервирования */ class ReservationResult { public function __construct( public readonly int $reserved, public readonly int $missing, public readonly Collection $reservations, public readonly ?Shortage $shortage ) {} public function isFullyReserved(): bool { return $this->missing === 0; } public function hasShortage(): bool { return $this->shortage !== null; } public function getTotalRequested(): int { return $this->reserved + $this->missing; } }