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; } /** * Прямое списание без резерва (для ручных операций) * * ВНИМАНИЕ: Использовать только для ручных корректировок! * Для рекламаций всегда использовать резервирование. * * @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, ]); }); } } /** * Результат операции списания */ class IssueResult { public function __construct( public readonly int $issued, public readonly ?Reservation $reservation, public readonly InventoryMovement $movement, public readonly SparePartOrder $order ) {} }