| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321 |
- <?php
- declare(strict_types=1);
- namespace App\Services;
- use App\Models\CommonCatalogItem;
- use App\Models\StockMovement;
- use App\Models\StockOrder;
- use App\Models\StockReservation;
- use App\Models\User;
- use DomainException;
- use Illuminate\Database\Eloquent\Collection;
- use Illuminate\Support\Facades\DB;
- class StockInventoryService
- {
- /** @return array{physical: int, reserved: int, available: int, ordered: int} */
- public function summary(CommonCatalogItem $item): array
- {
- $physical = (int) $item->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<int, StockReservation> */
- 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<string, mixed> $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<string, mixed> $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,
- ]);
- }
- }
|