SparePartReservationService.php 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386
  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. * - FIFO по партиям (первыми резервируются старые поступления)
  15. * - Разделения по типу документов (with_documents)
  16. * - Создания дефицитов при нехватке
  17. */
  18. class SparePartReservationService
  19. {
  20. /**
  21. * Результат резервирования
  22. */
  23. public function __construct(
  24. private readonly ShortageService $shortageService
  25. ) {}
  26. /**
  27. * Зарезервировать запчасть для рекламации
  28. *
  29. * @param int $sparePartId ID запчасти
  30. * @param int $quantity Требуемое количество
  31. * @param bool $withDocuments С документами или без
  32. * @param int $reclamationId ID рекламации
  33. * @return ReservationResult
  34. */
  35. public function reserve(
  36. int $sparePartId,
  37. int $quantity,
  38. bool $withDocuments,
  39. int $reclamationId
  40. ): ReservationResult {
  41. return DB::transaction(function () use ($sparePartId, $quantity, $withDocuments, $reclamationId) {
  42. $sparePart = SparePart::findOrFail($sparePartId);
  43. // 1. Расчёт свободного остатка
  44. $physicalStock = SparePartOrder::query()
  45. ->where('spare_part_id', $sparePartId)
  46. ->where('with_documents', $withDocuments)
  47. ->where('status', SparePartOrder::STATUS_IN_STOCK)
  48. ->sum('available_qty');
  49. $activeReserves = Reservation::query()
  50. ->where('spare_part_id', $sparePartId)
  51. ->where('with_documents', $withDocuments)
  52. ->where('status', Reservation::STATUS_ACTIVE)
  53. ->sum('reserved_qty');
  54. $freeStock = $physicalStock - $activeReserves;
  55. // 2. Определение сколько можем зарезервировать
  56. $canReserve = min($quantity, max(0, $freeStock));
  57. $missing = $quantity - $canReserve;
  58. $reservations = collect();
  59. $shortage = null;
  60. // 3. Резервирование доступного количества (FIFO по партиям)
  61. if ($canReserve > 0) {
  62. $remainingToReserve = $canReserve;
  63. $orders = SparePartOrder::query()
  64. ->where('spare_part_id', $sparePartId)
  65. ->where('with_documents', $withDocuments)
  66. ->where('status', SparePartOrder::STATUS_IN_STOCK)
  67. ->where('available_qty', '>', 0)
  68. ->orderBy('created_at', 'asc')
  69. ->lockForUpdate()
  70. ->get();
  71. foreach ($orders as $order) {
  72. if ($remainingToReserve <= 0) break;
  73. // Сколько уже зарезервировано из этой партии
  74. $alreadyReserved = Reservation::query()
  75. ->where('spare_part_order_id', $order->id)
  76. ->where('status', Reservation::STATUS_ACTIVE)
  77. ->sum('reserved_qty');
  78. $freeInOrder = $order->available_qty - $alreadyReserved;
  79. $toReserve = min($remainingToReserve, $freeInOrder);
  80. if ($toReserve > 0) {
  81. // Создаём движение типа reserve
  82. $movement = InventoryMovement::create([
  83. 'spare_part_order_id' => $order->id,
  84. 'spare_part_id' => $sparePartId,
  85. 'qty' => $toReserve,
  86. 'movement_type' => InventoryMovement::TYPE_RESERVE,
  87. 'source_type' => InventoryMovement::SOURCE_RECLAMATION,
  88. 'source_id' => $reclamationId,
  89. 'with_documents' => $withDocuments,
  90. 'user_id' => auth()->id(),
  91. 'note' => "Резервирование для рекламации #{$reclamationId}",
  92. ]);
  93. // Создаём резерв
  94. $reservation = Reservation::create([
  95. 'spare_part_id' => $sparePartId,
  96. 'spare_part_order_id' => $order->id,
  97. 'reclamation_id' => $reclamationId,
  98. 'reserved_qty' => $toReserve,
  99. 'with_documents' => $withDocuments,
  100. 'status' => Reservation::STATUS_ACTIVE,
  101. 'movement_id' => $movement->id,
  102. ]);
  103. $reservations->push($reservation);
  104. $remainingToReserve -= $toReserve;
  105. }
  106. }
  107. }
  108. // 4. Создание дефицита при нехватке
  109. if ($missing > 0) {
  110. $shortage = Shortage::create([
  111. 'spare_part_id' => $sparePartId,
  112. 'reclamation_id' => $reclamationId,
  113. 'with_documents' => $withDocuments,
  114. 'required_qty' => $quantity,
  115. 'reserved_qty' => $canReserve,
  116. 'missing_qty' => $missing,
  117. 'status' => Shortage::STATUS_OPEN,
  118. 'note' => "Дефицит при резервировании для рекламации #{$reclamationId}",
  119. ]);
  120. }
  121. return new ReservationResult(
  122. reserved: $canReserve,
  123. missing: $missing,
  124. reservations: $reservations,
  125. shortage: $shortage
  126. );
  127. });
  128. }
  129. /**
  130. * Отменить резервы для рекламации
  131. *
  132. * @param int $reclamationId ID рекламации
  133. * @param int|null $sparePartId ID запчасти (опционально, если нужно отменить для конкретной)
  134. * @param bool|null $withDocuments Тип документов (опционально)
  135. * @return int Количество отменённых единиц
  136. */
  137. public function cancelForReclamation(
  138. int $reclamationId,
  139. ?int $sparePartId = null,
  140. ?bool $withDocuments = null
  141. ): int {
  142. return DB::transaction(function () use ($reclamationId, $sparePartId, $withDocuments) {
  143. $query = Reservation::query()
  144. ->where('reclamation_id', $reclamationId)
  145. ->where('status', Reservation::STATUS_ACTIVE);
  146. if ($sparePartId !== null) {
  147. $query->where('spare_part_id', $sparePartId);
  148. }
  149. if ($withDocuments !== null) {
  150. $query->where('with_documents', $withDocuments);
  151. }
  152. $reservations = $query->lockForUpdate()->get();
  153. $totalCancelled = 0;
  154. foreach ($reservations as $reservation) {
  155. $this->cancelReservation($reservation, "Отмена резерва для рекламации #{$reclamationId}");
  156. $totalCancelled += $reservation->reserved_qty;
  157. }
  158. // Также закрываем связанные дефициты
  159. $shortageQuery = Shortage::query()
  160. ->where('reclamation_id', $reclamationId)
  161. ->where('status', Shortage::STATUS_OPEN);
  162. if ($sparePartId !== null) {
  163. $shortageQuery->where('spare_part_id', $sparePartId);
  164. }
  165. if ($withDocuments !== null) {
  166. $shortageQuery->where('with_documents', $withDocuments);
  167. }
  168. $shortageQuery->update(['status' => Shortage::STATUS_CLOSED]);
  169. return $totalCancelled;
  170. });
  171. }
  172. /**
  173. * Отменить конкретный резерв
  174. */
  175. public function cancelReservation(Reservation $reservation, string $reason = ''): bool
  176. {
  177. if (!$reservation->isActive()) {
  178. return false;
  179. }
  180. return DB::transaction(function () use ($reservation, $reason) {
  181. $reservation->status = Reservation::STATUS_CANCELLED;
  182. $reservation->save();
  183. // Создаём движение отмены
  184. InventoryMovement::create([
  185. 'spare_part_order_id' => $reservation->spare_part_order_id,
  186. 'spare_part_id' => $reservation->spare_part_id,
  187. 'qty' => $reservation->reserved_qty,
  188. 'movement_type' => InventoryMovement::TYPE_RESERVE_CANCEL,
  189. 'source_type' => InventoryMovement::SOURCE_RECLAMATION,
  190. 'source_id' => $reservation->reclamation_id,
  191. 'with_documents' => $reservation->with_documents,
  192. 'user_id' => auth()->id(),
  193. 'note' => $reason,
  194. ]);
  195. return true;
  196. });
  197. }
  198. /**
  199. * Изменить количество резерва для рекламации
  200. *
  201. * @param int $reclamationId ID рекламации
  202. * @param int $sparePartId ID запчасти
  203. * @param bool $withDocuments Тип документов
  204. * @param int $newQuantity Новое требуемое количество
  205. * @return ReservationResult
  206. */
  207. public function adjustReservation(
  208. int $reclamationId,
  209. int $sparePartId,
  210. bool $withDocuments,
  211. int $newQuantity
  212. ): ReservationResult {
  213. return DB::transaction(function () use ($reclamationId, $sparePartId, $withDocuments, $newQuantity) {
  214. // Получаем текущие резервы
  215. $currentReserved = Reservation::query()
  216. ->where('reclamation_id', $reclamationId)
  217. ->where('spare_part_id', $sparePartId)
  218. ->where('with_documents', $withDocuments)
  219. ->where('status', Reservation::STATUS_ACTIVE)
  220. ->sum('reserved_qty');
  221. $diff = $newQuantity - $currentReserved;
  222. if ($diff > 0) {
  223. // Нужно дорезервировать
  224. return $this->reserve($sparePartId, $diff, $withDocuments, $reclamationId);
  225. } elseif ($diff < 0) {
  226. // Нужно освободить часть резерва
  227. $toRelease = abs($diff);
  228. $released = 0;
  229. // Освобождаем в обратном порядке FIFO (сначала новые)
  230. $reservations = Reservation::query()
  231. ->where('reclamation_id', $reclamationId)
  232. ->where('spare_part_id', $sparePartId)
  233. ->where('with_documents', $withDocuments)
  234. ->where('status', Reservation::STATUS_ACTIVE)
  235. ->orderBy('created_at', 'desc')
  236. ->lockForUpdate()
  237. ->get();
  238. foreach ($reservations as $reservation) {
  239. if ($released >= $toRelease) break;
  240. $releaseFromThis = min($toRelease - $released, $reservation->reserved_qty);
  241. if ($releaseFromThis === $reservation->reserved_qty) {
  242. // Отменяем весь резерв
  243. $this->cancelReservation($reservation, "Уменьшение резерва");
  244. } else {
  245. // Частичная отмена — создаём новый резерв на остаток
  246. $reservation->reserved_qty -= $releaseFromThis;
  247. $reservation->save();
  248. // Движение частичной отмены
  249. InventoryMovement::create([
  250. 'spare_part_order_id' => $reservation->spare_part_order_id,
  251. 'spare_part_id' => $sparePartId,
  252. 'qty' => $releaseFromThis,
  253. 'movement_type' => InventoryMovement::TYPE_RESERVE_CANCEL,
  254. 'source_type' => InventoryMovement::SOURCE_RECLAMATION,
  255. 'source_id' => $reclamationId,
  256. 'with_documents' => $withDocuments,
  257. 'user_id' => auth()->id(),
  258. 'note' => "Частичная отмена резерва",
  259. ]);
  260. }
  261. $released += $releaseFromThis;
  262. }
  263. // Обновляем дефицит если есть
  264. $shortage = Shortage::query()
  265. ->where('reclamation_id', $reclamationId)
  266. ->where('spare_part_id', $sparePartId)
  267. ->where('with_documents', $withDocuments)
  268. ->where('status', Shortage::STATUS_OPEN)
  269. ->first();
  270. if ($shortage) {
  271. $shortage->required_qty = $newQuantity;
  272. $shortage->recalculate();
  273. }
  274. // Пересчитываем фактически зарезервированное количество после изменений
  275. $actualReserved = Reservation::query()
  276. ->where('reclamation_id', $reclamationId)
  277. ->where('spare_part_id', $sparePartId)
  278. ->where('with_documents', $withDocuments)
  279. ->where('status', Reservation::STATUS_ACTIVE)
  280. ->sum('reserved_qty');
  281. return new ReservationResult(
  282. reserved: (int) $actualReserved,
  283. missing: max(0, $newQuantity - $actualReserved),
  284. reservations: collect(),
  285. shortage: $shortage
  286. );
  287. }
  288. // diff = 0, ничего не делаем
  289. return new ReservationResult(
  290. reserved: $currentReserved,
  291. missing: 0,
  292. reservations: collect(),
  293. shortage: null
  294. );
  295. });
  296. }
  297. /**
  298. * Получить все активные резервы для рекламации
  299. */
  300. public function getReservationsForReclamation(int $reclamationId): Collection
  301. {
  302. return Reservation::query()
  303. ->where('reclamation_id', $reclamationId)
  304. ->where('status', Reservation::STATUS_ACTIVE)
  305. ->with(['sparePart', 'sparePartOrder'])
  306. ->get();
  307. }
  308. }
  309. /**
  310. * Результат операции резервирования
  311. */
  312. class ReservationResult
  313. {
  314. public function __construct(
  315. public readonly int $reserved,
  316. public readonly int $missing,
  317. public readonly Collection $reservations,
  318. public readonly ?Shortage $shortage
  319. ) {}
  320. public function isFullyReserved(): bool
  321. {
  322. return $this->missing === 0;
  323. }
  324. public function hasShortage(): bool
  325. {
  326. return $this->shortage !== null;
  327. }
  328. public function getTotalRequested(): int
  329. {
  330. return $this->reserved + $this->missing;
  331. }
  332. }