ShortageService.php 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220
  1. <?php
  2. namespace App\Services;
  3. use App\Models\InventoryMovement;
  4. use App\Models\Reservation;
  5. use App\Models\Shortage;
  6. use App\Models\SparePart;
  7. use App\Models\SparePartOrder;
  8. use Illuminate\Support\Collection;
  9. use Illuminate\Support\Facades\DB;
  10. /**
  11. * Сервис управления дефицитами запчастей.
  12. *
  13. * Дефицит создаётся когда при резервировании недостаточно свободного остатка.
  14. * При поступлении новой партии система автоматически резервирует под открытые дефициты.
  15. */
  16. class ShortageService
  17. {
  18. /**
  19. * Обработать поступление партии — закрыть дефициты
  20. *
  21. * Вызывается при изменении статуса партии на in_stock.
  22. * Автоматически резервирует под открытые дефициты (FIFO по дате).
  23. *
  24. * @param SparePartOrder $order Партия
  25. * @return array Информация о закрытых дефицитах
  26. */
  27. public function processPartOrderReceipt(SparePartOrder $order): array
  28. {
  29. if ($order->status !== SparePartOrder::STATUS_IN_STOCK) {
  30. return [];
  31. }
  32. return DB::transaction(function () use ($order) {
  33. $results = [];
  34. // Поиск открытых дефицитов для этой запчасти
  35. $shortages = Shortage::query()
  36. ->where('spare_part_id', $order->spare_part_id)
  37. ->where('with_documents', $order->with_documents)
  38. ->where('status', Shortage::STATUS_OPEN)
  39. ->where('missing_qty', '>', 0)
  40. ->orderBy('created_at', 'asc') // FIFO по дате дефицита
  41. ->lockForUpdate()
  42. ->get();
  43. if ($shortages->isEmpty()) {
  44. return [];
  45. }
  46. // Сколько свободно в партии (с учётом уже созданных резервов)
  47. $alreadyReserved = Reservation::query()
  48. ->where('spare_part_order_id', $order->id)
  49. ->where('status', Reservation::STATUS_ACTIVE)
  50. ->sum('reserved_qty');
  51. $availableQty = $order->available_qty - $alreadyReserved;
  52. foreach ($shortages as $shortage) {
  53. if ($availableQty <= 0) break;
  54. // Сколько можем покрыть
  55. $toCover = min($availableQty, $shortage->missing_qty);
  56. // Создаём движение резерва
  57. $movement = InventoryMovement::create([
  58. 'spare_part_order_id' => $order->id,
  59. 'spare_part_id' => $order->spare_part_id,
  60. 'qty' => $toCover,
  61. 'movement_type' => InventoryMovement::TYPE_RESERVE,
  62. 'source_type' => InventoryMovement::SOURCE_SHORTAGE_FULFILLMENT,
  63. 'source_id' => $shortage->id,
  64. 'with_documents' => $order->with_documents,
  65. 'user_id' => auth()->id(),
  66. 'note' => "Автоматическое резервирование при поступлении для закрытия дефицита #{$shortage->id}",
  67. ]);
  68. // Создаём резерв под дефицит
  69. $reservation = Reservation::create([
  70. 'spare_part_id' => $order->spare_part_id,
  71. 'spare_part_order_id' => $order->id,
  72. 'reclamation_id' => $shortage->reclamation_id,
  73. 'reserved_qty' => $toCover,
  74. 'with_documents' => $order->with_documents,
  75. 'status' => Reservation::STATUS_ACTIVE,
  76. 'movement_id' => $movement->id,
  77. ]);
  78. // Обновляем дефицит
  79. $shortage->addReserved($toCover);
  80. $results[] = [
  81. 'shortage_id' => $shortage->id,
  82. 'reclamation_id' => $shortage->reclamation_id,
  83. 'covered_qty' => $toCover,
  84. 'remaining_missing' => $shortage->missing_qty,
  85. 'shortage_closed' => $shortage->isClosed(),
  86. 'reservation_id' => $reservation->id,
  87. ];
  88. $availableQty -= $toCover;
  89. }
  90. return $results;
  91. });
  92. }
  93. /**
  94. * Получить все открытые дефициты
  95. */
  96. public function getOpenShortages(): Collection
  97. {
  98. return Shortage::query()
  99. ->where('status', Shortage::STATUS_OPEN)
  100. ->with(['sparePart', 'reclamation'])
  101. ->orderBy('created_at', 'asc')
  102. ->get();
  103. }
  104. /**
  105. * Получить открытые дефициты для конкретной запчасти
  106. */
  107. public function getShortagesForSparePart(int $sparePartId, ?bool $withDocuments = null): Collection
  108. {
  109. $query = Shortage::query()
  110. ->where('spare_part_id', $sparePartId)
  111. ->where('status', Shortage::STATUS_OPEN);
  112. if ($withDocuments !== null) {
  113. $query->where('with_documents', $withDocuments);
  114. }
  115. return $query->orderBy('created_at', 'asc')->get();
  116. }
  117. /**
  118. * Получить сумму недостачи по запчасти
  119. */
  120. public function getTotalMissing(int $sparePartId, ?bool $withDocuments = null): int
  121. {
  122. $query = Shortage::query()
  123. ->where('spare_part_id', $sparePartId)
  124. ->where('status', Shortage::STATUS_OPEN);
  125. if ($withDocuments !== null) {
  126. $query->where('with_documents', $withDocuments);
  127. }
  128. return (int) $query->sum('missing_qty');
  129. }
  130. /**
  131. * Получить запчасти с критическими дефицитами (для контроля наличия)
  132. */
  133. public function getCriticalShortages(): Collection
  134. {
  135. return SparePart::query()
  136. ->whereHas('shortages', function ($query) {
  137. $query->where('status', Shortage::STATUS_OPEN)
  138. ->where('missing_qty', '>', 0);
  139. })
  140. ->with(['shortages' => function ($query) {
  141. $query->where('status', Shortage::STATUS_OPEN);
  142. }])
  143. ->get()
  144. ->map(function ($sparePart) {
  145. $sparePart->shortage_details = $sparePart->shortages->map(function ($shortage) {
  146. return [
  147. 'reclamation_id' => $shortage->reclamation_id,
  148. 'with_documents' => $shortage->with_documents,
  149. 'missing_qty' => $shortage->missing_qty,
  150. 'created_at' => $shortage->created_at,
  151. ];
  152. });
  153. return $sparePart;
  154. });
  155. }
  156. /**
  157. * Закрыть дефицит вручную (например, при отмене рекламации)
  158. */
  159. public function closeShortage(Shortage $shortage, string $reason = ''): bool
  160. {
  161. if ($shortage->isClosed()) {
  162. return false;
  163. }
  164. $shortage->status = Shortage::STATUS_CLOSED;
  165. $shortage->note = ($shortage->note ? $shortage->note . "\n" : '') .
  166. "Закрыто вручную: " . ($reason ?: 'без указания причины');
  167. $shortage->save();
  168. return true;
  169. }
  170. /**
  171. * Пересчитать все дефициты для рекламации
  172. */
  173. public function recalculateForReclamation(int $reclamationId): void
  174. {
  175. $shortages = Shortage::query()
  176. ->where('reclamation_id', $reclamationId)
  177. ->where('status', Shortage::STATUS_OPEN)
  178. ->get();
  179. foreach ($shortages as $shortage) {
  180. $shortage->recalculate();
  181. }
  182. }
  183. /**
  184. * Рассчитать сколько нужно заказать для покрытия всех дефицитов
  185. */
  186. public function calculateOrderQuantity(int $sparePartId, bool $withDocuments): int
  187. {
  188. return $this->getTotalMissing($sparePartId, $withDocuments);
  189. }
  190. }