SparePartIssueService.php 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232
  1. <?php
  2. namespace App\Services;
  3. use App\Models\InventoryMovement;
  4. use App\Models\Reservation;
  5. use App\Models\SparePartOrder;
  6. use Illuminate\Support\Facades\DB;
  7. /**
  8. * Сервис списания (отгрузки) запчастей.
  9. *
  10. * Списание возможно ТОЛЬКО при наличии активного резерва.
  11. * При списании:
  12. * - Уменьшается available_qty партии
  13. * - Закрывается резерв (статус = issued)
  14. * - Создаётся движение типа issue
  15. */
  16. class SparePartIssueService
  17. {
  18. /**
  19. * Списать запчасть по резерву
  20. *
  21. * @param Reservation $reservation Резерв для списания
  22. * @param string $note Примечание
  23. * @return IssueResult
  24. * @throws \Exception
  25. */
  26. public function issueReservation(Reservation $reservation, string $note = ''): IssueResult
  27. {
  28. if (!$reservation->isActive()) {
  29. throw new \InvalidArgumentException("Резерв не активен, списание невозможно");
  30. }
  31. return DB::transaction(function () use ($reservation, $note) {
  32. // Блокируем партию для update
  33. $order = SparePartOrder::lockForUpdate()->findOrFail($reservation->spare_part_order_id);
  34. // Проверка наличия (на случай ошибки в данных)
  35. if ($order->available_qty < $reservation->reserved_qty) {
  36. throw new \RuntimeException(
  37. "Недостаточно товара в партии. Доступно: {$order->available_qty}, " .
  38. "требуется: {$reservation->reserved_qty}"
  39. );
  40. }
  41. // 1. Уменьшение остатка партии
  42. $order->available_qty -= $reservation->reserved_qty;
  43. // 2. Смена статуса партии если полностью отгружена
  44. if ($order->available_qty === 0) {
  45. $order->status = SparePartOrder::STATUS_SHIPPED;
  46. }
  47. $order->save();
  48. // 3. Закрытие резерва
  49. $reservation->status = Reservation::STATUS_ISSUED;
  50. $reservation->save();
  51. // 4. Создание движения типа issue
  52. $movement = InventoryMovement::create([
  53. 'spare_part_order_id' => $order->id,
  54. 'spare_part_id' => $reservation->spare_part_id,
  55. 'qty' => $reservation->reserved_qty,
  56. 'movement_type' => InventoryMovement::TYPE_ISSUE,
  57. 'source_type' => InventoryMovement::SOURCE_RECLAMATION,
  58. 'source_id' => $reservation->reclamation_id,
  59. 'with_documents' => $reservation->with_documents,
  60. 'user_id' => auth()->id(),
  61. 'note' => $note ?: "Списание по резерву для рекламации #{$reservation->reclamation_id}",
  62. ]);
  63. return new IssueResult(
  64. issued: $reservation->reserved_qty,
  65. reservation: $reservation,
  66. movement: $movement,
  67. order: $order
  68. );
  69. });
  70. }
  71. /**
  72. * Списать все активные резервы для рекламации
  73. *
  74. * @param int $reclamationId ID рекламации
  75. * @param int|null $sparePartId ID запчасти (опционально)
  76. * @param bool|null $withDocuments Тип документов (опционально)
  77. * @return array<IssueResult>
  78. */
  79. public function issueForReclamation(
  80. int $reclamationId,
  81. ?int $sparePartId = null,
  82. ?bool $withDocuments = null
  83. ): array {
  84. $query = Reservation::query()
  85. ->where('reclamation_id', $reclamationId)
  86. ->where('status', Reservation::STATUS_ACTIVE);
  87. if ($sparePartId !== null) {
  88. $query->where('spare_part_id', $sparePartId);
  89. }
  90. if ($withDocuments !== null) {
  91. $query->where('with_documents', $withDocuments);
  92. }
  93. $reservations = $query->get();
  94. $results = [];
  95. foreach ($reservations as $reservation) {
  96. $results[] = $this->issueReservation($reservation);
  97. }
  98. return $results;
  99. }
  100. /**
  101. * Прямое списание без резерва (для ручных операций)
  102. *
  103. * ВНИМАНИЕ: Использовать только для ручных корректировок!
  104. * Для рекламаций всегда использовать резервирование.
  105. *
  106. * @param SparePartOrder $order Партия
  107. * @param int $quantity Количество
  108. * @param string $note Примечание
  109. * @return IssueResult
  110. */
  111. public function directIssue(SparePartOrder $order, int $quantity, string $note): IssueResult
  112. {
  113. if ($order->available_qty < $quantity) {
  114. throw new \InvalidArgumentException(
  115. "Недостаточно товара в партии. Доступно: {$order->available_qty}, " .
  116. "запрошено: {$quantity}"
  117. );
  118. }
  119. return DB::transaction(function () use ($order, $quantity, $note) {
  120. // Блокируем для update
  121. $order = SparePartOrder::lockForUpdate()->find($order->id);
  122. $order->available_qty -= $quantity;
  123. if ($order->available_qty === 0) {
  124. $order->status = SparePartOrder::STATUS_SHIPPED;
  125. }
  126. $order->save();
  127. $movement = InventoryMovement::create([
  128. 'spare_part_order_id' => $order->id,
  129. 'spare_part_id' => $order->spare_part_id,
  130. 'qty' => $quantity,
  131. 'movement_type' => InventoryMovement::TYPE_ISSUE,
  132. 'source_type' => InventoryMovement::SOURCE_MANUAL,
  133. 'source_id' => null,
  134. 'with_documents' => $order->with_documents,
  135. 'user_id' => auth()->id(),
  136. 'note' => $note,
  137. ]);
  138. return new IssueResult(
  139. issued: $quantity,
  140. reservation: null,
  141. movement: $movement,
  142. order: $order
  143. );
  144. });
  145. }
  146. /**
  147. * Коррекция остатка партии (инвентаризация)
  148. *
  149. * @param SparePartOrder $order Партия
  150. * @param int $newQuantity Новый физический остаток
  151. * @param string $note Причина коррекции
  152. * @return InventoryMovement
  153. */
  154. public function correctInventory(SparePartOrder $order, int $newQuantity, string $note): InventoryMovement
  155. {
  156. if ($newQuantity < 0) {
  157. throw new \InvalidArgumentException("Остаток не может быть отрицательным");
  158. }
  159. return DB::transaction(function () use ($order, $newQuantity, $note) {
  160. $order = SparePartOrder::lockForUpdate()->find($order->id);
  161. $diff = $newQuantity - $order->available_qty;
  162. if ($diff === 0) {
  163. throw new \InvalidArgumentException("Количество не изменилось");
  164. }
  165. $order->available_qty = $newQuantity;
  166. if ($order->available_qty === 0 && $order->status === SparePartOrder::STATUS_IN_STOCK) {
  167. $order->status = SparePartOrder::STATUS_SHIPPED;
  168. } elseif ($order->available_qty > 0 && $order->status === SparePartOrder::STATUS_SHIPPED) {
  169. $order->status = SparePartOrder::STATUS_IN_STOCK;
  170. }
  171. $order->save();
  172. $movementType = $diff > 0
  173. ? InventoryMovement::TYPE_CORRECTION_PLUS
  174. : InventoryMovement::TYPE_CORRECTION_MINUS;
  175. return InventoryMovement::create([
  176. 'spare_part_order_id' => $order->id,
  177. 'spare_part_id' => $order->spare_part_id,
  178. 'qty' => abs($diff),
  179. 'movement_type' => $movementType,
  180. 'source_type' => InventoryMovement::SOURCE_INVENTORY,
  181. 'source_id' => null,
  182. 'with_documents' => $order->with_documents,
  183. 'user_id' => auth()->id(),
  184. 'note' => $note,
  185. ]);
  186. });
  187. }
  188. }
  189. /**
  190. * Результат операции списания
  191. */
  192. class IssueResult
  193. {
  194. public function __construct(
  195. public readonly int $issued,
  196. public readonly ?Reservation $reservation,
  197. public readonly InventoryMovement $movement,
  198. public readonly SparePartOrder $order
  199. ) {}
  200. }