isActive()) { throw new \InvalidArgumentException("Резерв не активен, списание невозможно"); } return DB::transaction(function () use ($reservation, $note) { // Блокируем партию для update $order = SparePartOrder::lockForUpdate()->findOrFail($reservation->spare_part_order_id); // Проверка наличия (на случай ошибки в данных) if ($order->available_qty < $reservation->reserved_qty) { throw new \RuntimeException( "Недостаточно товара в партии. Доступно: {$order->available_qty}, " . "требуется: {$reservation->reserved_qty}" ); } // 1. Уменьшение остатка партии $order->available_qty -= $reservation->reserved_qty; // 2. Смена статуса партии если полностью отгружена if ($order->available_qty === 0) { $order->status = SparePartOrder::STATUS_SHIPPED; } $order->save(); // 3. Закрытие резерва $reservation->status = Reservation::STATUS_ISSUED; $reservation->save(); // 4. Создание движения типа issue $movement = InventoryMovement::create([ 'spare_part_order_id' => $order->id, 'spare_part_id' => $reservation->spare_part_id, 'qty' => $reservation->reserved_qty, 'movement_type' => InventoryMovement::TYPE_ISSUE, 'source_type' => InventoryMovement::SOURCE_RECLAMATION, 'source_id' => $reservation->reclamation_id, 'with_documents' => $reservation->with_documents, 'user_id' => auth()->id(), 'note' => $note ?: "Списание по резерву для рекламации #{$reservation->reclamation_id}", ]); return new IssueResult( issued: $reservation->reserved_qty, reservation: $reservation, movement: $movement, order: $order ); }); } /** * Списать все активные резервы для рекламации * * @param int $reclamationId ID рекламации * @param int|null $sparePartId ID запчасти (опционально) * @param bool|null $withDocuments Тип документов (опционально) * @return array */ public function issueForReclamation( int $reclamationId, ?int $sparePartId = null, ?bool $withDocuments = null ): array { $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->get(); $results = []; foreach ($reservations as $reservation) { $results[] = $this->issueReservation($reservation); } return $results; } public function issueReservationFromSelectedOrder( Reservation $reservation, int $selectedOrderId, string $note = '' ): IssueResult { if (!$reservation->isActive()) { throw new \InvalidArgumentException('Резерв не активен, списание невозможно'); } if ((int) $reservation->spare_part_order_id === $selectedOrderId) { $result = $this->issueReservation($reservation, $note); $this->syncPivotRowForReservation($reservation); return $result; } return DB::transaction(function () use ($reservation, $selectedOrderId, $note) { $reservation = Reservation::query()->lockForUpdate()->findOrFail($reservation->id); $selectedOrder = SparePartOrder::query()->lockForUpdate()->findOrFail($selectedOrderId); if (!$reservation->isActive()) { throw new \InvalidArgumentException('Резерв не активен, списание невозможно'); } if ((int) $selectedOrder->spare_part_id !== (int) $reservation->spare_part_id) { throw new \InvalidArgumentException('Выбран заказ другой запчасти'); } if ((bool) $selectedOrder->with_documents !== (bool) $reservation->with_documents) { throw new \InvalidArgumentException('Выбран заказ с другим признаком документов'); } if ($selectedOrder->status !== SparePartOrder::STATUS_IN_STOCK) { throw new \InvalidArgumentException('Списание возможно только из партии на складе'); } $alreadyReservedOnSelected = Reservation::query() ->where('spare_part_order_id', $selectedOrder->id) ->where('status', Reservation::STATUS_ACTIVE) ->sum('reserved_qty'); $freeQty = max(0, (int) $selectedOrder->available_qty - (int) $alreadyReservedOnSelected); if ($freeQty < 1) { throw new \RuntimeException('В выбранном заказе нет доступного остатка'); } $requestedQty = (int) $reservation->reserved_qty; $issueQty = min($requestedQty, $freeQty); app(SparePartReservationService::class) ->cancelReservation($reservation, 'Перенос резерва на другой заказ для списания'); $reserveMovement = InventoryMovement::create([ 'spare_part_order_id' => $selectedOrder->id, 'spare_part_id' => $reservation->spare_part_id, 'qty' => $issueQty, 'movement_type' => InventoryMovement::TYPE_RESERVE, 'source_type' => InventoryMovement::SOURCE_RECLAMATION, 'source_id' => $reservation->reclamation_id, 'with_documents' => $reservation->with_documents, 'user_id' => auth()->id(), 'note' => "Резервирование для списания из выбранного заказа #{$selectedOrder->id}", ]); $selectedReservation = Reservation::create([ 'spare_part_id' => $reservation->spare_part_id, 'spare_part_order_id' => $selectedOrder->id, 'reclamation_id' => $reservation->reclamation_id, 'reserved_qty' => $issueQty, 'with_documents' => $reservation->with_documents, 'status' => Reservation::STATUS_ACTIVE, 'movement_id' => $reserveMovement->id, ]); $result = $this->issueReservation($selectedReservation, $note); if ($issueQty < $requestedQty) { $this->splitPivotRowAfterPartialIssue($reservation, $issueQty, $requestedQty - $issueQty); } else { $this->syncPivotRowForReservation($reservation); } return $result; }); } /** * Прямое списание без резерва (для ручных операций) * * ВНИМАНИЕ: Использовать только для ручных корректировок! * Для рекламаций всегда использовать резервирование. * * @param SparePartOrder $order Партия * @param int $quantity Количество * @param string $note Примечание * @return IssueResult */ public function directIssue(SparePartOrder $order, int $quantity, string $note): IssueResult { if ($order->available_qty < $quantity) { throw new \InvalidArgumentException( "Недостаточно товара в партии. Доступно: {$order->available_qty}, " . "запрошено: {$quantity}" ); } return DB::transaction(function () use ($order, $quantity, $note) { // Блокируем для update $order = SparePartOrder::lockForUpdate()->find($order->id); $order->available_qty -= $quantity; if ($order->available_qty === 0) { $order->status = SparePartOrder::STATUS_SHIPPED; } $order->save(); $movement = InventoryMovement::create([ 'spare_part_order_id' => $order->id, 'spare_part_id' => $order->spare_part_id, 'qty' => $quantity, 'movement_type' => InventoryMovement::TYPE_ISSUE, 'source_type' => InventoryMovement::SOURCE_MANUAL, 'source_id' => null, 'with_documents' => $order->with_documents, 'user_id' => auth()->id(), 'note' => $note, ]); return new IssueResult( issued: $quantity, reservation: null, movement: $movement, order: $order ); }); } /** * Коррекция остатка партии (инвентаризация) * * @param SparePartOrder $order Партия * @param int $newQuantity Новый физический остаток * @param string $note Причина коррекции * @return InventoryMovement */ public function correctInventory(SparePartOrder $order, int $newQuantity, string $note): InventoryMovement { if ($newQuantity < 0) { throw new \InvalidArgumentException("Остаток не может быть отрицательным"); } return DB::transaction(function () use ($order, $newQuantity, $note) { $order = SparePartOrder::lockForUpdate()->find($order->id); $diff = $newQuantity - $order->available_qty; if ($diff === 0) { throw new \InvalidArgumentException("Количество не изменилось"); } $order->available_qty = $newQuantity; if ($order->available_qty === 0 && $order->status === SparePartOrder::STATUS_IN_STOCK) { $order->status = SparePartOrder::STATUS_SHIPPED; } elseif ($order->available_qty > 0 && $order->status === SparePartOrder::STATUS_SHIPPED) { $order->status = SparePartOrder::STATUS_IN_STOCK; } $order->save(); $movementType = $diff > 0 ? InventoryMovement::TYPE_CORRECTION_PLUS : InventoryMovement::TYPE_CORRECTION_MINUS; return InventoryMovement::create([ 'spare_part_order_id' => $order->id, 'spare_part_id' => $order->spare_part_id, 'qty' => abs($diff), 'movement_type' => $movementType, 'source_type' => InventoryMovement::SOURCE_INVENTORY, 'source_id' => null, 'with_documents' => $order->with_documents, 'user_id' => auth()->id(), 'note' => $note, ]); }); } private function syncPivotRowForReservation(Reservation $reservation): void { $row = ReclamationSparePart::query() ->where('reclamation_id', $reservation->reclamation_id) ->where('spare_part_id', $reservation->spare_part_id) ->where('with_documents', $reservation->with_documents) ->orderBy('id') ->first(); if (!$row) { return; } $activeQty = (int) Reservation::query() ->where('reclamation_id', $reservation->reclamation_id) ->where('spare_part_id', $reservation->spare_part_id) ->where('with_documents', $reservation->with_documents) ->where('status', Reservation::STATUS_ACTIVE) ->sum('reserved_qty'); $issuedQty = (int) Reservation::query() ->where('reclamation_id', $reservation->reclamation_id) ->where('spare_part_id', $reservation->spare_part_id) ->where('with_documents', $reservation->with_documents) ->where('status', Reservation::STATUS_ISSUED) ->sum('reserved_qty'); $row->update([ 'reserved_qty' => $activeQty, 'issued_qty' => min($row->quantity, $issuedQty), 'status' => $issuedQty >= $row->quantity ? 'issued' : ($activeQty > 0 ? 'reserved' : 'pending'), ]); } private function splitPivotRowAfterPartialIssue(Reservation $reservation, int $issuedQty, int $remainingQty): void { $row = ReclamationSparePart::query() ->where('reclamation_id', $reservation->reclamation_id) ->where('spare_part_id', $reservation->spare_part_id) ->where('with_documents', $reservation->with_documents) ->where('quantity', '>=', $reservation->reserved_qty) ->orderBy('id') ->first(); if (!$row) { return; } $row->update([ 'quantity' => $issuedQty, 'reserved_qty' => 0, 'issued_qty' => $issuedQty, 'status' => 'issued', ]); ReclamationSparePart::query()->create([ 'reclamation_id' => $row->reclamation_id, 'spare_part_id' => $row->spare_part_id, 'quantity' => $remainingQty, 'with_documents' => $row->with_documents, 'status' => 'pending', 'reserved_qty' => 0, 'issued_qty' => 0, ]); Shortage::query()->create([ 'spare_part_id' => $row->spare_part_id, 'reclamation_id' => $row->reclamation_id, 'with_documents' => $row->with_documents, 'required_qty' => $remainingQty, 'reserved_qty' => 0, 'missing_qty' => $remainingQty, 'status' => Shortage::STATUS_OPEN, 'note' => 'Остаток после частичного списания из выбранного заказа', ]); } } /** * Результат операции списания */ class IssueResult { public function __construct( public readonly int $issued, public readonly ?Reservation $reservation, public readonly InventoryMovement $movement, public readonly SparePartOrder $order ) {} }