SparePartIssueService.php 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363
  1. <?php
  2. namespace App\Services;
  3. use App\Models\InventoryMovement;
  4. use App\Models\ReclamationSparePart;
  5. use App\Models\Reservation;
  6. use App\Models\Shortage;
  7. use App\Models\SparePartOrder;
  8. use Illuminate\Support\Facades\DB;
  9. /**
  10. * Сервис списания (отгрузки) запчастей.
  11. *
  12. * Списание возможно ТОЛЬКО при наличии активного резерва.
  13. * При списании:
  14. * - Уменьшается available_qty партии
  15. * - Закрывается резерв (статус = issued)
  16. * - Создаётся движение типа issue
  17. */
  18. class SparePartIssueService
  19. {
  20. /**
  21. * Списать запчасть по резерву
  22. *
  23. * @param Reservation $reservation Резерв для списания
  24. * @param string $note Примечание
  25. * @return IssueResult
  26. * @throws \Exception
  27. */
  28. public function issueReservation(Reservation $reservation, string $note = ''): IssueResult
  29. {
  30. if (!$reservation->isActive()) {
  31. throw new \InvalidArgumentException("Резерв не активен, списание невозможно");
  32. }
  33. return DB::transaction(function () use ($reservation, $note) {
  34. // Блокируем партию для update
  35. $order = SparePartOrder::lockForUpdate()->findOrFail($reservation->spare_part_order_id);
  36. // Проверка наличия (на случай ошибки в данных)
  37. if ($order->available_qty < $reservation->reserved_qty) {
  38. throw new \RuntimeException(
  39. "Недостаточно товара в партии. Доступно: {$order->available_qty}, " .
  40. "требуется: {$reservation->reserved_qty}"
  41. );
  42. }
  43. // 1. Уменьшение остатка партии
  44. $order->available_qty -= $reservation->reserved_qty;
  45. // 2. Смена статуса партии если полностью отгружена
  46. if ($order->available_qty === 0) {
  47. $order->status = SparePartOrder::STATUS_SHIPPED;
  48. }
  49. $order->save();
  50. // 3. Закрытие резерва
  51. $reservation->status = Reservation::STATUS_ISSUED;
  52. $reservation->save();
  53. // 4. Создание движения типа issue
  54. $movement = InventoryMovement::create([
  55. 'spare_part_order_id' => $order->id,
  56. 'spare_part_id' => $reservation->spare_part_id,
  57. 'qty' => $reservation->reserved_qty,
  58. 'movement_type' => InventoryMovement::TYPE_ISSUE,
  59. 'source_type' => InventoryMovement::SOURCE_RECLAMATION,
  60. 'source_id' => $reservation->reclamation_id,
  61. 'with_documents' => $reservation->with_documents,
  62. 'user_id' => auth()->id(),
  63. 'note' => $note ?: "Списание по резерву для рекламации #{$reservation->reclamation_id}",
  64. ]);
  65. return new IssueResult(
  66. issued: $reservation->reserved_qty,
  67. reservation: $reservation,
  68. movement: $movement,
  69. order: $order
  70. );
  71. });
  72. }
  73. /**
  74. * Списать все активные резервы для рекламации
  75. *
  76. * @param int $reclamationId ID рекламации
  77. * @param int|null $sparePartId ID запчасти (опционально)
  78. * @param bool|null $withDocuments Тип документов (опционально)
  79. * @return array<IssueResult>
  80. */
  81. public function issueForReclamation(
  82. int $reclamationId,
  83. ?int $sparePartId = null,
  84. ?bool $withDocuments = null
  85. ): array {
  86. $query = Reservation::query()
  87. ->where('reclamation_id', $reclamationId)
  88. ->where('status', Reservation::STATUS_ACTIVE);
  89. if ($sparePartId !== null) {
  90. $query->where('spare_part_id', $sparePartId);
  91. }
  92. if ($withDocuments !== null) {
  93. $query->where('with_documents', $withDocuments);
  94. }
  95. $reservations = $query->get();
  96. $results = [];
  97. foreach ($reservations as $reservation) {
  98. $results[] = $this->issueReservation($reservation);
  99. }
  100. return $results;
  101. }
  102. public function issueReservationFromSelectedOrder(
  103. Reservation $reservation,
  104. int $selectedOrderId,
  105. string $note = ''
  106. ): IssueResult {
  107. if (!$reservation->isActive()) {
  108. throw new \InvalidArgumentException('Резерв не активен, списание невозможно');
  109. }
  110. if ((int) $reservation->spare_part_order_id === $selectedOrderId) {
  111. $result = $this->issueReservation($reservation, $note);
  112. app(SparePartReservationService::class)->syncPivotRowForReservation($reservation);
  113. return $result;
  114. }
  115. return DB::transaction(function () use ($reservation, $selectedOrderId, $note) {
  116. $reservation = Reservation::query()->lockForUpdate()->findOrFail($reservation->id);
  117. $selectedOrder = SparePartOrder::query()->lockForUpdate()->findOrFail($selectedOrderId);
  118. if (!$reservation->isActive()) {
  119. throw new \InvalidArgumentException('Резерв не активен, списание невозможно');
  120. }
  121. if ((int) $selectedOrder->spare_part_id !== (int) $reservation->spare_part_id) {
  122. throw new \InvalidArgumentException('Выбран заказ другой запчасти');
  123. }
  124. if ((bool) $selectedOrder->with_documents !== (bool) $reservation->with_documents) {
  125. throw new \InvalidArgumentException('Выбран заказ с другим признаком документов');
  126. }
  127. if ($selectedOrder->status !== SparePartOrder::STATUS_IN_STOCK) {
  128. throw new \InvalidArgumentException('Списание возможно только из партии на складе');
  129. }
  130. $alreadyReservedOnSelected = Reservation::query()
  131. ->where('spare_part_order_id', $selectedOrder->id)
  132. ->where('status', Reservation::STATUS_ACTIVE)
  133. ->sum('reserved_qty');
  134. $freeQty = max(0, (int) $selectedOrder->available_qty - (int) $alreadyReservedOnSelected);
  135. if ($freeQty < 1) {
  136. throw new \RuntimeException('В выбранном заказе нет доступного остатка');
  137. }
  138. $requestedQty = (int) $reservation->reserved_qty;
  139. $issueQty = min($requestedQty, $freeQty);
  140. app(SparePartReservationService::class)
  141. ->cancelReservation($reservation, 'Перенос резерва на другой заказ для списания');
  142. $reserveMovement = InventoryMovement::create([
  143. 'spare_part_order_id' => $selectedOrder->id,
  144. 'spare_part_id' => $reservation->spare_part_id,
  145. 'qty' => $issueQty,
  146. 'movement_type' => InventoryMovement::TYPE_RESERVE,
  147. 'source_type' => InventoryMovement::SOURCE_RECLAMATION,
  148. 'source_id' => $reservation->reclamation_id,
  149. 'with_documents' => $reservation->with_documents,
  150. 'user_id' => auth()->id(),
  151. 'note' => "Резервирование для списания из выбранного заказа #{$selectedOrder->id}",
  152. ]);
  153. $selectedReservation = Reservation::create([
  154. 'spare_part_id' => $reservation->spare_part_id,
  155. 'spare_part_order_id' => $selectedOrder->id,
  156. 'reclamation_id' => $reservation->reclamation_id,
  157. 'reserved_qty' => $issueQty,
  158. 'with_documents' => $reservation->with_documents,
  159. 'status' => Reservation::STATUS_ACTIVE,
  160. 'movement_id' => $reserveMovement->id,
  161. ]);
  162. $result = $this->issueReservation($selectedReservation, $note);
  163. if ($issueQty < $requestedQty) {
  164. $this->splitPivotRowAfterPartialIssue($reservation, $issueQty, $requestedQty - $issueQty);
  165. } else {
  166. app(SparePartReservationService::class)->syncPivotRowForReservation($reservation);
  167. }
  168. return $result;
  169. });
  170. }
  171. /**
  172. * Прямое списание без резерва (для ручных операций)
  173. *
  174. * ВНИМАНИЕ: Использовать только для ручных корректировок!
  175. * Для рекламаций всегда использовать резервирование.
  176. *
  177. * @param SparePartOrder $order Партия
  178. * @param int $quantity Количество
  179. * @param string $note Примечание
  180. * @return IssueResult
  181. */
  182. public function directIssue(SparePartOrder $order, int $quantity, string $note): IssueResult
  183. {
  184. if ($order->available_qty < $quantity) {
  185. throw new \InvalidArgumentException(
  186. "Недостаточно товара в партии. Доступно: {$order->available_qty}, " .
  187. "запрошено: {$quantity}"
  188. );
  189. }
  190. return DB::transaction(function () use ($order, $quantity, $note) {
  191. // Блокируем для update
  192. $order = SparePartOrder::lockForUpdate()->find($order->id);
  193. $order->available_qty -= $quantity;
  194. if ($order->available_qty === 0) {
  195. $order->status = SparePartOrder::STATUS_SHIPPED;
  196. }
  197. $order->save();
  198. $movement = InventoryMovement::create([
  199. 'spare_part_order_id' => $order->id,
  200. 'spare_part_id' => $order->spare_part_id,
  201. 'qty' => $quantity,
  202. 'movement_type' => InventoryMovement::TYPE_ISSUE,
  203. 'source_type' => InventoryMovement::SOURCE_MANUAL,
  204. 'source_id' => null,
  205. 'with_documents' => $order->with_documents,
  206. 'user_id' => auth()->id(),
  207. 'note' => $note,
  208. ]);
  209. return new IssueResult(
  210. issued: $quantity,
  211. reservation: null,
  212. movement: $movement,
  213. order: $order
  214. );
  215. });
  216. }
  217. /**
  218. * Коррекция остатка партии (инвентаризация)
  219. *
  220. * @param SparePartOrder $order Партия
  221. * @param int $newQuantity Новый физический остаток
  222. * @param string $note Причина коррекции
  223. * @return InventoryMovement
  224. */
  225. public function correctInventory(SparePartOrder $order, int $newQuantity, string $note): InventoryMovement
  226. {
  227. if ($newQuantity < 0) {
  228. throw new \InvalidArgumentException("Остаток не может быть отрицательным");
  229. }
  230. return DB::transaction(function () use ($order, $newQuantity, $note) {
  231. $order = SparePartOrder::lockForUpdate()->find($order->id);
  232. $diff = $newQuantity - $order->available_qty;
  233. if ($diff === 0) {
  234. throw new \InvalidArgumentException("Количество не изменилось");
  235. }
  236. $order->available_qty = $newQuantity;
  237. if ($order->available_qty === 0 && $order->status === SparePartOrder::STATUS_IN_STOCK) {
  238. $order->status = SparePartOrder::STATUS_SHIPPED;
  239. } elseif ($order->available_qty > 0 && $order->status === SparePartOrder::STATUS_SHIPPED) {
  240. $order->status = SparePartOrder::STATUS_IN_STOCK;
  241. }
  242. $order->save();
  243. $movementType = $diff > 0
  244. ? InventoryMovement::TYPE_CORRECTION_PLUS
  245. : InventoryMovement::TYPE_CORRECTION_MINUS;
  246. return InventoryMovement::create([
  247. 'spare_part_order_id' => $order->id,
  248. 'spare_part_id' => $order->spare_part_id,
  249. 'qty' => abs($diff),
  250. 'movement_type' => $movementType,
  251. 'source_type' => InventoryMovement::SOURCE_INVENTORY,
  252. 'source_id' => null,
  253. 'with_documents' => $order->with_documents,
  254. 'user_id' => auth()->id(),
  255. 'note' => $note,
  256. ]);
  257. });
  258. }
  259. private function splitPivotRowAfterPartialIssue(Reservation $reservation, int $issuedQty, int $remainingQty): void
  260. {
  261. $row = ReclamationSparePart::query()
  262. ->where('reclamation_id', $reservation->reclamation_id)
  263. ->where('spare_part_id', $reservation->spare_part_id)
  264. ->where('with_documents', $reservation->with_documents)
  265. ->where('quantity', '>=', $reservation->reserved_qty)
  266. ->orderBy('id')
  267. ->first();
  268. if (!$row) {
  269. return;
  270. }
  271. $row->update([
  272. 'quantity' => $issuedQty,
  273. 'reserved_qty' => 0,
  274. 'issued_qty' => $issuedQty,
  275. 'status' => 'issued',
  276. ]);
  277. ReclamationSparePart::query()->create([
  278. 'reclamation_id' => $row->reclamation_id,
  279. 'spare_part_id' => $row->spare_part_id,
  280. 'quantity' => $remainingQty,
  281. 'with_documents' => $row->with_documents,
  282. 'status' => 'pending',
  283. 'reserved_qty' => 0,
  284. 'issued_qty' => 0,
  285. ]);
  286. Shortage::query()->create([
  287. 'spare_part_id' => $row->spare_part_id,
  288. 'reclamation_id' => $row->reclamation_id,
  289. 'with_documents' => $row->with_documents,
  290. 'required_qty' => $remainingQty,
  291. 'reserved_qty' => 0,
  292. 'missing_qty' => $remainingQty,
  293. 'status' => Shortage::STATUS_OPEN,
  294. 'note' => 'Остаток после частичного списания из выбранного заказа',
  295. ]);
  296. }
  297. }
  298. /**
  299. * Результат операции списания
  300. */
  301. class IssueResult
  302. {
  303. public function __construct(
  304. public readonly int $issued,
  305. public readonly ?Reservation $reservation,
  306. public readonly InventoryMovement $movement,
  307. public readonly SparePartOrder $order
  308. ) {}
  309. }