stockOrders() ->where('status', StockOrder::STATUS_IN_STOCK) ->sum('available_quantity'); $reserved = (int) $item->stockReservations()->active()->sum('quantity'); $ordered = (int) $item->stockOrders() ->where('status', StockOrder::STATUS_ORDERED) ->sum('ordered_quantity'); return [ 'physical' => $physical, 'reserved' => $reserved, 'available' => max(0, $physical - $reserved), 'ordered' => $ordered, ]; } /** @return Collection */ public function reserve( CommonCatalogItem $item, User $manager, int $quantity, ?string $note, User $actor, ): Collection { return DB::transaction(function () use ($item, $manager, $quantity, $note, $actor): Collection { $orders = StockOrder::query() ->where('common_catalog_item_id', $item->id) ->where('status', StockOrder::STATUS_IN_STOCK) ->where('available_quantity', '>', 0) ->withSum(['reservations as active_reservations_sum_quantity' => fn ($query) => $query->active()], 'quantity') ->orderBy('created_at') ->orderBy('id') ->lockForUpdate() ->get(); $available = $orders->sum(fn (StockOrder $order): int => $order->free_quantity); if ($quantity > $available) { throw new DomainException('Такого кол-ва нет в наличии'); } $remaining = $quantity; $reservations = new Collection; foreach ($orders as $order) { if ($remaining === 0) { break; } $reservedQuantity = min($remaining, $order->free_quantity); if ($reservedQuantity === 0) { continue; } $reservation = StockReservation::query()->create([ 'common_catalog_item_id' => $item->id, 'stock_order_id' => $order->id, 'manager_id' => $manager->id, 'quantity' => $reservedQuantity, 'status' => StockReservation::STATUS_ACTIVE, 'note' => $note, 'created_by' => $actor->id, ]); $this->movement( StockMovement::TYPE_RESERVE, $item->id, $order->id, $reservation->id, $reservedQuantity, $actor->id, $note, ); $reservations->push($reservation); $remaining -= $reservedQuantity; } return $reservations; }); } public function issue(StockReservation $reservation, User $actor, ?string $note = null): void { DB::transaction(function () use ($reservation, $actor, $note): void { $lockedReservation = StockReservation::query()->lockForUpdate()->findOrFail($reservation->id); if (! $lockedReservation->isActive()) { throw new DomainException('Бронь не активна'); } $order = StockOrder::query()->lockForUpdate()->findOrFail($lockedReservation->stock_order_id); if ($order->available_quantity < $lockedReservation->quantity) { throw new DomainException('Недостаточно остатка в заказе'); } $order->available_quantity -= $lockedReservation->quantity; if ($order->available_quantity === 0) { $order->status = StockOrder::STATUS_SHIPPED; } $order->save(); $lockedReservation->update([ 'status' => StockReservation::STATUS_ISSUED, 'issued_at' => now(), ]); $this->movement( StockMovement::TYPE_ISSUE, $lockedReservation->common_catalog_item_id, $order->id, $lockedReservation->id, $lockedReservation->quantity, $actor->id, $note ?: $lockedReservation->note, ); }); } public function cancel(StockReservation $reservation, User $actor, ?string $note = null): void { DB::transaction(function () use ($reservation, $actor, $note): void { $lockedReservation = StockReservation::query()->lockForUpdate()->findOrFail($reservation->id); if (! $lockedReservation->isActive()) { throw new DomainException('Бронь не активна'); } $lockedReservation->update([ 'status' => StockReservation::STATUS_CANCELLED, 'cancelled_at' => now(), ]); $this->movement( StockMovement::TYPE_RESERVE_CANCEL, $lockedReservation->common_catalog_item_id, $lockedReservation->stock_order_id, $lockedReservation->id, $lockedReservation->quantity, $actor->id, $note ?: $lockedReservation->note, ); }); } /** @param array $data */ public function createOrder(array $data, User $actor): StockOrder { return DB::transaction(function () use ($data, $actor): StockOrder { $order = StockOrder::query()->create([ ...$data, 'available_quantity' => $data['status'] === StockOrder::STATUS_IN_STOCK ? (int) $data['ordered_quantity'] : 0, 'user_id' => $actor->id, ]); if ($order->status === StockOrder::STATUS_IN_STOCK) { $this->movement( StockMovement::TYPE_RECEIPT, $order->common_catalog_item_id, $order->id, null, $order->ordered_quantity, $actor->id, $order->note, ); } return $order; }); } /** @param array $data */ public function updateOrder(StockOrder $order, array $data, User $actor): StockOrder { return DB::transaction(function () use ($order, $data, $actor): StockOrder { $lockedOrder = StockOrder::query()->lockForUpdate()->findOrFail($order->id); $hasReservations = $lockedOrder->reservations()->exists(); $hasIssues = $lockedOrder->movements()->where('movement_type', StockMovement::TYPE_ISSUE)->exists(); $hasOperations = $hasReservations || $hasIssues; if ($hasOperations && ( (int) $data['common_catalog_item_id'] !== $lockedOrder->common_catalog_item_id || (int) $data['ordered_quantity'] !== $lockedOrder->ordered_quantity )) { throw new DomainException('Нельзя изменить позицию или количество заказа с историей операций'); } $oldStatus = $lockedOrder->status; $newStatus = (string) $data['status']; $oldAvailable = $lockedOrder->available_quantity; if ($oldStatus === StockOrder::STATUS_SHIPPED && $newStatus !== StockOrder::STATUS_SHIPPED) { throw new DomainException('Отгруженный заказ нельзя вернуть в работу. Создайте новый заказ.'); } if ($lockedOrder->reservations()->active()->exists() && $newStatus !== StockOrder::STATUS_IN_STOCK) { throw new DomainException('Нельзя изменить статус заказа с активной бронью'); } if ($newStatus === StockOrder::STATUS_IN_STOCK) { if ($oldStatus !== StockOrder::STATUS_IN_STOCK || ! $hasOperations) { $data['available_quantity'] = (int) $data['ordered_quantity']; } } else { $data['available_quantity'] = 0; } $lockedOrder->update($data); if ($oldStatus !== StockOrder::STATUS_IN_STOCK && $lockedOrder->status === StockOrder::STATUS_IN_STOCK) { $this->movement( StockMovement::TYPE_RECEIPT, $lockedOrder->common_catalog_item_id, $lockedOrder->id, null, $lockedOrder->available_quantity, $actor->id, $lockedOrder->note, ); } elseif ($oldStatus === StockOrder::STATUS_IN_STOCK && $lockedOrder->status === StockOrder::STATUS_IN_STOCK && ! $hasOperations && $lockedOrder->available_quantity !== $oldAvailable) { $difference = $lockedOrder->available_quantity - $oldAvailable; $this->movement( $difference > 0 ? StockMovement::TYPE_RECEIPT : StockMovement::TYPE_ISSUE, $lockedOrder->common_catalog_item_id, $lockedOrder->id, null, abs($difference), $actor->id, 'Корректировка количества складского заказа', ); } elseif ($oldStatus === StockOrder::STATUS_IN_STOCK && $lockedOrder->status === StockOrder::STATUS_SHIPPED && $oldAvailable > 0) { $this->movement( StockMovement::TYPE_ISSUE, $lockedOrder->common_catalog_item_id, $lockedOrder->id, null, $oldAvailable, $actor->id, 'Заказ отмечен как отгруженный', ); } return $lockedOrder; }); } public function setOrderInStock(string $orderNumber, User $actor): int { return DB::transaction(function () use ($orderNumber, $actor): int { $orders = StockOrder::query() ->where('order_number', $orderNumber) ->where('status', StockOrder::STATUS_ORDERED) ->orderBy('id') ->lockForUpdate() ->get(); if ($orders->isEmpty()) { throw new DomainException('Заказ со статусом «Заказан» не найден'); } foreach ($orders as $order) { $order->update([ 'status' => StockOrder::STATUS_IN_STOCK, 'available_quantity' => $order->ordered_quantity, ]); $this->movement( StockMovement::TYPE_RECEIPT, $order->common_catalog_item_id, $order->id, null, $order->ordered_quantity, $actor->id, 'Весь заказ отмечен как поступивший на склад', ); } return $orders->count(); }); } private function movement( string $type, int $itemId, ?int $orderId, ?int $reservationId, int $quantity, int $userId, ?string $note, ): StockMovement { return StockMovement::query()->create([ 'common_catalog_item_id' => $itemId, 'stock_order_id' => $orderId, 'stock_reservation_id' => $reservationId, 'movement_type' => $type, 'quantity' => $quantity, 'user_id' => $userId, 'note' => $note, ]); } }