StockInventoryService.php 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321
  1. <?php
  2. declare(strict_types=1);
  3. namespace App\Services;
  4. use App\Models\CommonCatalogItem;
  5. use App\Models\StockMovement;
  6. use App\Models\StockOrder;
  7. use App\Models\StockReservation;
  8. use App\Models\User;
  9. use DomainException;
  10. use Illuminate\Database\Eloquent\Collection;
  11. use Illuminate\Support\Facades\DB;
  12. class StockInventoryService
  13. {
  14. /** @return array{physical: int, reserved: int, available: int, ordered: int} */
  15. public function summary(CommonCatalogItem $item): array
  16. {
  17. $physical = (int) $item->stockOrders()
  18. ->where('status', StockOrder::STATUS_IN_STOCK)
  19. ->sum('available_quantity');
  20. $reserved = (int) $item->stockReservations()->active()->sum('quantity');
  21. $ordered = (int) $item->stockOrders()
  22. ->where('status', StockOrder::STATUS_ORDERED)
  23. ->sum('ordered_quantity');
  24. return [
  25. 'physical' => $physical,
  26. 'reserved' => $reserved,
  27. 'available' => max(0, $physical - $reserved),
  28. 'ordered' => $ordered,
  29. ];
  30. }
  31. /** @return Collection<int, StockReservation> */
  32. public function reserve(
  33. CommonCatalogItem $item,
  34. User $manager,
  35. int $quantity,
  36. ?string $note,
  37. User $actor,
  38. ): Collection {
  39. return DB::transaction(function () use ($item, $manager, $quantity, $note, $actor): Collection {
  40. $orders = StockOrder::query()
  41. ->where('common_catalog_item_id', $item->id)
  42. ->where('status', StockOrder::STATUS_IN_STOCK)
  43. ->where('available_quantity', '>', 0)
  44. ->withSum(['reservations as active_reservations_sum_quantity' => fn ($query) => $query->active()], 'quantity')
  45. ->orderBy('created_at')
  46. ->orderBy('id')
  47. ->lockForUpdate()
  48. ->get();
  49. $available = $orders->sum(fn (StockOrder $order): int => $order->free_quantity);
  50. if ($quantity > $available) {
  51. throw new DomainException('Такого кол-ва нет в наличии');
  52. }
  53. $remaining = $quantity;
  54. $reservations = new Collection;
  55. foreach ($orders as $order) {
  56. if ($remaining === 0) {
  57. break;
  58. }
  59. $reservedQuantity = min($remaining, $order->free_quantity);
  60. if ($reservedQuantity === 0) {
  61. continue;
  62. }
  63. $reservation = StockReservation::query()->create([
  64. 'common_catalog_item_id' => $item->id,
  65. 'stock_order_id' => $order->id,
  66. 'manager_id' => $manager->id,
  67. 'quantity' => $reservedQuantity,
  68. 'status' => StockReservation::STATUS_ACTIVE,
  69. 'note' => $note,
  70. 'created_by' => $actor->id,
  71. ]);
  72. $this->movement(
  73. StockMovement::TYPE_RESERVE,
  74. $item->id,
  75. $order->id,
  76. $reservation->id,
  77. $reservedQuantity,
  78. $actor->id,
  79. $note,
  80. );
  81. $reservations->push($reservation);
  82. $remaining -= $reservedQuantity;
  83. }
  84. return $reservations;
  85. });
  86. }
  87. public function issue(StockReservation $reservation, User $actor, ?string $note = null): void
  88. {
  89. DB::transaction(function () use ($reservation, $actor, $note): void {
  90. $lockedReservation = StockReservation::query()->lockForUpdate()->findOrFail($reservation->id);
  91. if (! $lockedReservation->isActive()) {
  92. throw new DomainException('Бронь не активна');
  93. }
  94. $order = StockOrder::query()->lockForUpdate()->findOrFail($lockedReservation->stock_order_id);
  95. if ($order->available_quantity < $lockedReservation->quantity) {
  96. throw new DomainException('Недостаточно остатка в заказе');
  97. }
  98. $order->available_quantity -= $lockedReservation->quantity;
  99. if ($order->available_quantity === 0) {
  100. $order->status = StockOrder::STATUS_SHIPPED;
  101. }
  102. $order->save();
  103. $lockedReservation->update([
  104. 'status' => StockReservation::STATUS_ISSUED,
  105. 'issued_at' => now(),
  106. ]);
  107. $this->movement(
  108. StockMovement::TYPE_ISSUE,
  109. $lockedReservation->common_catalog_item_id,
  110. $order->id,
  111. $lockedReservation->id,
  112. $lockedReservation->quantity,
  113. $actor->id,
  114. $note ?: $lockedReservation->note,
  115. );
  116. });
  117. }
  118. public function cancel(StockReservation $reservation, User $actor, ?string $note = null): void
  119. {
  120. DB::transaction(function () use ($reservation, $actor, $note): void {
  121. $lockedReservation = StockReservation::query()->lockForUpdate()->findOrFail($reservation->id);
  122. if (! $lockedReservation->isActive()) {
  123. throw new DomainException('Бронь не активна');
  124. }
  125. $lockedReservation->update([
  126. 'status' => StockReservation::STATUS_CANCELLED,
  127. 'cancelled_at' => now(),
  128. ]);
  129. $this->movement(
  130. StockMovement::TYPE_RESERVE_CANCEL,
  131. $lockedReservation->common_catalog_item_id,
  132. $lockedReservation->stock_order_id,
  133. $lockedReservation->id,
  134. $lockedReservation->quantity,
  135. $actor->id,
  136. $note ?: $lockedReservation->note,
  137. );
  138. });
  139. }
  140. /** @param array<string, mixed> $data */
  141. public function createOrder(array $data, User $actor): StockOrder
  142. {
  143. return DB::transaction(function () use ($data, $actor): StockOrder {
  144. $order = StockOrder::query()->create([
  145. ...$data,
  146. 'available_quantity' => $data['status'] === StockOrder::STATUS_IN_STOCK
  147. ? (int) $data['ordered_quantity']
  148. : 0,
  149. 'user_id' => $actor->id,
  150. ]);
  151. if ($order->status === StockOrder::STATUS_IN_STOCK) {
  152. $this->movement(
  153. StockMovement::TYPE_RECEIPT,
  154. $order->common_catalog_item_id,
  155. $order->id,
  156. null,
  157. $order->ordered_quantity,
  158. $actor->id,
  159. $order->note,
  160. );
  161. }
  162. return $order;
  163. });
  164. }
  165. /** @param array<string, mixed> $data */
  166. public function updateOrder(StockOrder $order, array $data, User $actor): StockOrder
  167. {
  168. return DB::transaction(function () use ($order, $data, $actor): StockOrder {
  169. $lockedOrder = StockOrder::query()->lockForUpdate()->findOrFail($order->id);
  170. $hasReservations = $lockedOrder->reservations()->exists();
  171. $hasIssues = $lockedOrder->movements()->where('movement_type', StockMovement::TYPE_ISSUE)->exists();
  172. $hasOperations = $hasReservations || $hasIssues;
  173. if ($hasOperations && (
  174. (int) $data['common_catalog_item_id'] !== $lockedOrder->common_catalog_item_id
  175. || (int) $data['ordered_quantity'] !== $lockedOrder->ordered_quantity
  176. )) {
  177. throw new DomainException('Нельзя изменить позицию или количество заказа с историей операций');
  178. }
  179. $oldStatus = $lockedOrder->status;
  180. $newStatus = (string) $data['status'];
  181. $oldAvailable = $lockedOrder->available_quantity;
  182. if ($oldStatus === StockOrder::STATUS_SHIPPED && $newStatus !== StockOrder::STATUS_SHIPPED) {
  183. throw new DomainException('Отгруженный заказ нельзя вернуть в работу. Создайте новый заказ.');
  184. }
  185. if ($lockedOrder->reservations()->active()->exists() && $newStatus !== StockOrder::STATUS_IN_STOCK) {
  186. throw new DomainException('Нельзя изменить статус заказа с активной бронью');
  187. }
  188. if ($newStatus === StockOrder::STATUS_IN_STOCK) {
  189. if ($oldStatus !== StockOrder::STATUS_IN_STOCK || ! $hasOperations) {
  190. $data['available_quantity'] = (int) $data['ordered_quantity'];
  191. }
  192. } else {
  193. $data['available_quantity'] = 0;
  194. }
  195. $lockedOrder->update($data);
  196. if ($oldStatus !== StockOrder::STATUS_IN_STOCK && $lockedOrder->status === StockOrder::STATUS_IN_STOCK) {
  197. $this->movement(
  198. StockMovement::TYPE_RECEIPT,
  199. $lockedOrder->common_catalog_item_id,
  200. $lockedOrder->id,
  201. null,
  202. $lockedOrder->available_quantity,
  203. $actor->id,
  204. $lockedOrder->note,
  205. );
  206. } elseif ($oldStatus === StockOrder::STATUS_IN_STOCK
  207. && $lockedOrder->status === StockOrder::STATUS_IN_STOCK
  208. && ! $hasOperations
  209. && $lockedOrder->available_quantity !== $oldAvailable) {
  210. $difference = $lockedOrder->available_quantity - $oldAvailable;
  211. $this->movement(
  212. $difference > 0 ? StockMovement::TYPE_RECEIPT : StockMovement::TYPE_ISSUE,
  213. $lockedOrder->common_catalog_item_id,
  214. $lockedOrder->id,
  215. null,
  216. abs($difference),
  217. $actor->id,
  218. 'Корректировка количества складского заказа',
  219. );
  220. } elseif ($oldStatus === StockOrder::STATUS_IN_STOCK
  221. && $lockedOrder->status === StockOrder::STATUS_SHIPPED
  222. && $oldAvailable > 0) {
  223. $this->movement(
  224. StockMovement::TYPE_ISSUE,
  225. $lockedOrder->common_catalog_item_id,
  226. $lockedOrder->id,
  227. null,
  228. $oldAvailable,
  229. $actor->id,
  230. 'Заказ отмечен как отгруженный',
  231. );
  232. }
  233. return $lockedOrder;
  234. });
  235. }
  236. public function setOrderInStock(string $orderNumber, User $actor): int
  237. {
  238. return DB::transaction(function () use ($orderNumber, $actor): int {
  239. $orders = StockOrder::query()
  240. ->where('order_number', $orderNumber)
  241. ->where('status', StockOrder::STATUS_ORDERED)
  242. ->orderBy('id')
  243. ->lockForUpdate()
  244. ->get();
  245. if ($orders->isEmpty()) {
  246. throw new DomainException('Заказ со статусом «Заказан» не найден');
  247. }
  248. foreach ($orders as $order) {
  249. $order->update([
  250. 'status' => StockOrder::STATUS_IN_STOCK,
  251. 'available_quantity' => $order->ordered_quantity,
  252. ]);
  253. $this->movement(
  254. StockMovement::TYPE_RECEIPT,
  255. $order->common_catalog_item_id,
  256. $order->id,
  257. null,
  258. $order->ordered_quantity,
  259. $actor->id,
  260. 'Весь заказ отмечен как поступивший на склад',
  261. );
  262. }
  263. return $orders->count();
  264. });
  265. }
  266. private function movement(
  267. string $type,
  268. int $itemId,
  269. ?int $orderId,
  270. ?int $reservationId,
  271. int $quantity,
  272. int $userId,
  273. ?string $note,
  274. ): StockMovement {
  275. return StockMovement::query()->create([
  276. 'common_catalog_item_id' => $itemId,
  277. 'stock_order_id' => $orderId,
  278. 'stock_reservation_id' => $reservationId,
  279. 'movement_type' => $type,
  280. 'quantity' => $quantity,
  281. 'user_id' => $userId,
  282. 'note' => $note,
  283. ]);
  284. }
  285. }