|
|
@@ -0,0 +1,386 @@
|
|
|
+<?php
|
|
|
+
|
|
|
+namespace App\Services;
|
|
|
+
|
|
|
+use App\Models\InventoryMovement;
|
|
|
+use App\Models\Reservation;
|
|
|
+use App\Models\Shortage;
|
|
|
+use App\Models\SparePart;
|
|
|
+use App\Models\SparePartOrder;
|
|
|
+use Illuminate\Support\Collection;
|
|
|
+use Illuminate\Support\Facades\DB;
|
|
|
+
|
|
|
+/**
|
|
|
+ * Сервис резервирования запчастей.
|
|
|
+ *
|
|
|
+ * Реализует алгоритм резервирования с учётом:
|
|
|
+ * - FIFO по партиям (первыми резервируются старые поступления)
|
|
|
+ * - Разделения по типу документов (with_documents)
|
|
|
+ * - Создания дефицитов при нехватке
|
|
|
+ */
|
|
|
+class SparePartReservationService
|
|
|
+{
|
|
|
+ /**
|
|
|
+ * Результат резервирования
|
|
|
+ */
|
|
|
+ public function __construct(
|
|
|
+ private readonly ShortageService $shortageService
|
|
|
+ ) {}
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Зарезервировать запчасть для рекламации
|
|
|
+ *
|
|
|
+ * @param int $sparePartId ID запчасти
|
|
|
+ * @param int $quantity Требуемое количество
|
|
|
+ * @param bool $withDocuments С документами или без
|
|
|
+ * @param int $reclamationId ID рекламации
|
|
|
+ * @return ReservationResult
|
|
|
+ */
|
|
|
+ public function reserve(
|
|
|
+ int $sparePartId,
|
|
|
+ int $quantity,
|
|
|
+ bool $withDocuments,
|
|
|
+ int $reclamationId
|
|
|
+ ): ReservationResult {
|
|
|
+ return DB::transaction(function () use ($sparePartId, $quantity, $withDocuments, $reclamationId) {
|
|
|
+
|
|
|
+ $sparePart = SparePart::findOrFail($sparePartId);
|
|
|
+
|
|
|
+ // 1. Расчёт свободного остатка
|
|
|
+ $physicalStock = SparePartOrder::query()
|
|
|
+ ->where('spare_part_id', $sparePartId)
|
|
|
+ ->where('with_documents', $withDocuments)
|
|
|
+ ->where('status', SparePartOrder::STATUS_IN_STOCK)
|
|
|
+ ->sum('available_qty');
|
|
|
+
|
|
|
+ $activeReserves = Reservation::query()
|
|
|
+ ->where('spare_part_id', $sparePartId)
|
|
|
+ ->where('with_documents', $withDocuments)
|
|
|
+ ->where('status', Reservation::STATUS_ACTIVE)
|
|
|
+ ->sum('reserved_qty');
|
|
|
+
|
|
|
+ $freeStock = $physicalStock - $activeReserves;
|
|
|
+
|
|
|
+ // 2. Определение сколько можем зарезервировать
|
|
|
+ $canReserve = min($quantity, max(0, $freeStock));
|
|
|
+ $missing = $quantity - $canReserve;
|
|
|
+
|
|
|
+ $reservations = collect();
|
|
|
+ $shortage = null;
|
|
|
+
|
|
|
+ // 3. Резервирование доступного количества (FIFO по партиям)
|
|
|
+ if ($canReserve > 0) {
|
|
|
+ $remainingToReserve = $canReserve;
|
|
|
+
|
|
|
+ $orders = SparePartOrder::query()
|
|
|
+ ->where('spare_part_id', $sparePartId)
|
|
|
+ ->where('with_documents', $withDocuments)
|
|
|
+ ->where('status', SparePartOrder::STATUS_IN_STOCK)
|
|
|
+ ->where('available_qty', '>', 0)
|
|
|
+ ->orderBy('created_at', 'asc')
|
|
|
+ ->lockForUpdate()
|
|
|
+ ->get();
|
|
|
+
|
|
|
+ foreach ($orders as $order) {
|
|
|
+ if ($remainingToReserve <= 0) break;
|
|
|
+
|
|
|
+ // Сколько уже зарезервировано из этой партии
|
|
|
+ $alreadyReserved = Reservation::query()
|
|
|
+ ->where('spare_part_order_id', $order->id)
|
|
|
+ ->where('status', Reservation::STATUS_ACTIVE)
|
|
|
+ ->sum('reserved_qty');
|
|
|
+
|
|
|
+ $freeInOrder = $order->available_qty - $alreadyReserved;
|
|
|
+ $toReserve = min($remainingToReserve, $freeInOrder);
|
|
|
+
|
|
|
+ if ($toReserve > 0) {
|
|
|
+ // Создаём движение типа reserve
|
|
|
+ $movement = InventoryMovement::create([
|
|
|
+ 'spare_part_order_id' => $order->id,
|
|
|
+ 'spare_part_id' => $sparePartId,
|
|
|
+ 'qty' => $toReserve,
|
|
|
+ 'movement_type' => InventoryMovement::TYPE_RESERVE,
|
|
|
+ 'source_type' => InventoryMovement::SOURCE_RECLAMATION,
|
|
|
+ 'source_id' => $reclamationId,
|
|
|
+ 'with_documents' => $withDocuments,
|
|
|
+ 'user_id' => auth()->id(),
|
|
|
+ 'note' => "Резервирование для рекламации #{$reclamationId}",
|
|
|
+ ]);
|
|
|
+
|
|
|
+ // Создаём резерв
|
|
|
+ $reservation = Reservation::create([
|
|
|
+ 'spare_part_id' => $sparePartId,
|
|
|
+ 'spare_part_order_id' => $order->id,
|
|
|
+ 'reclamation_id' => $reclamationId,
|
|
|
+ 'reserved_qty' => $toReserve,
|
|
|
+ 'with_documents' => $withDocuments,
|
|
|
+ 'status' => Reservation::STATUS_ACTIVE,
|
|
|
+ 'movement_id' => $movement->id,
|
|
|
+ ]);
|
|
|
+
|
|
|
+ $reservations->push($reservation);
|
|
|
+ $remainingToReserve -= $toReserve;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 4. Создание дефицита при нехватке
|
|
|
+ if ($missing > 0) {
|
|
|
+ $shortage = Shortage::create([
|
|
|
+ 'spare_part_id' => $sparePartId,
|
|
|
+ 'reclamation_id' => $reclamationId,
|
|
|
+ 'with_documents' => $withDocuments,
|
|
|
+ 'required_qty' => $quantity,
|
|
|
+ 'reserved_qty' => $canReserve,
|
|
|
+ 'missing_qty' => $missing,
|
|
|
+ 'status' => Shortage::STATUS_OPEN,
|
|
|
+ 'note' => "Дефицит при резервировании для рекламации #{$reclamationId}",
|
|
|
+ ]);
|
|
|
+ }
|
|
|
+
|
|
|
+ return new ReservationResult(
|
|
|
+ reserved: $canReserve,
|
|
|
+ missing: $missing,
|
|
|
+ reservations: $reservations,
|
|
|
+ shortage: $shortage
|
|
|
+ );
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Отменить резервы для рекламации
|
|
|
+ *
|
|
|
+ * @param int $reclamationId ID рекламации
|
|
|
+ * @param int|null $sparePartId ID запчасти (опционально, если нужно отменить для конкретной)
|
|
|
+ * @param bool|null $withDocuments Тип документов (опционально)
|
|
|
+ * @return int Количество отменённых единиц
|
|
|
+ */
|
|
|
+ public function cancelForReclamation(
|
|
|
+ int $reclamationId,
|
|
|
+ ?int $sparePartId = null,
|
|
|
+ ?bool $withDocuments = null
|
|
|
+ ): int {
|
|
|
+ return DB::transaction(function () use ($reclamationId, $sparePartId, $withDocuments) {
|
|
|
+ $query = Reservation::query()
|
|
|
+ ->where('reclamation_id', $reclamationId)
|
|
|
+ ->where('status', Reservation::STATUS_ACTIVE);
|
|
|
+
|
|
|
+ if ($sparePartId !== null) {
|
|
|
+ $query->where('spare_part_id', $sparePartId);
|
|
|
+ }
|
|
|
+
|
|
|
+ if ($withDocuments !== null) {
|
|
|
+ $query->where('with_documents', $withDocuments);
|
|
|
+ }
|
|
|
+
|
|
|
+ $reservations = $query->lockForUpdate()->get();
|
|
|
+ $totalCancelled = 0;
|
|
|
+
|
|
|
+ foreach ($reservations as $reservation) {
|
|
|
+ $this->cancelReservation($reservation, "Отмена резерва для рекламации #{$reclamationId}");
|
|
|
+ $totalCancelled += $reservation->reserved_qty;
|
|
|
+ }
|
|
|
+
|
|
|
+ // Также закрываем связанные дефициты
|
|
|
+ $shortageQuery = Shortage::query()
|
|
|
+ ->where('reclamation_id', $reclamationId)
|
|
|
+ ->where('status', Shortage::STATUS_OPEN);
|
|
|
+
|
|
|
+ if ($sparePartId !== null) {
|
|
|
+ $shortageQuery->where('spare_part_id', $sparePartId);
|
|
|
+ }
|
|
|
+
|
|
|
+ if ($withDocuments !== null) {
|
|
|
+ $shortageQuery->where('with_documents', $withDocuments);
|
|
|
+ }
|
|
|
+
|
|
|
+ $shortageQuery->update(['status' => Shortage::STATUS_CLOSED]);
|
|
|
+
|
|
|
+ return $totalCancelled;
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Отменить конкретный резерв
|
|
|
+ */
|
|
|
+ public function cancelReservation(Reservation $reservation, string $reason = ''): bool
|
|
|
+ {
|
|
|
+ if (!$reservation->isActive()) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+
|
|
|
+ return DB::transaction(function () use ($reservation, $reason) {
|
|
|
+ $reservation->status = Reservation::STATUS_CANCELLED;
|
|
|
+ $reservation->save();
|
|
|
+
|
|
|
+ // Создаём движение отмены
|
|
|
+ InventoryMovement::create([
|
|
|
+ 'spare_part_order_id' => $reservation->spare_part_order_id,
|
|
|
+ 'spare_part_id' => $reservation->spare_part_id,
|
|
|
+ 'qty' => $reservation->reserved_qty,
|
|
|
+ 'movement_type' => InventoryMovement::TYPE_RESERVE_CANCEL,
|
|
|
+ 'source_type' => InventoryMovement::SOURCE_RECLAMATION,
|
|
|
+ 'source_id' => $reservation->reclamation_id,
|
|
|
+ 'with_documents' => $reservation->with_documents,
|
|
|
+ 'user_id' => auth()->id(),
|
|
|
+ 'note' => $reason,
|
|
|
+ ]);
|
|
|
+
|
|
|
+ return true;
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Изменить количество резерва для рекламации
|
|
|
+ *
|
|
|
+ * @param int $reclamationId ID рекламации
|
|
|
+ * @param int $sparePartId ID запчасти
|
|
|
+ * @param bool $withDocuments Тип документов
|
|
|
+ * @param int $newQuantity Новое требуемое количество
|
|
|
+ * @return ReservationResult
|
|
|
+ */
|
|
|
+ public function adjustReservation(
|
|
|
+ int $reclamationId,
|
|
|
+ int $sparePartId,
|
|
|
+ bool $withDocuments,
|
|
|
+ int $newQuantity
|
|
|
+ ): ReservationResult {
|
|
|
+ return DB::transaction(function () use ($reclamationId, $sparePartId, $withDocuments, $newQuantity) {
|
|
|
+
|
|
|
+ // Получаем текущие резервы
|
|
|
+ $currentReserved = Reservation::query()
|
|
|
+ ->where('reclamation_id', $reclamationId)
|
|
|
+ ->where('spare_part_id', $sparePartId)
|
|
|
+ ->where('with_documents', $withDocuments)
|
|
|
+ ->where('status', Reservation::STATUS_ACTIVE)
|
|
|
+ ->sum('reserved_qty');
|
|
|
+
|
|
|
+ $diff = $newQuantity - $currentReserved;
|
|
|
+
|
|
|
+ if ($diff > 0) {
|
|
|
+ // Нужно дорезервировать
|
|
|
+ return $this->reserve($sparePartId, $diff, $withDocuments, $reclamationId);
|
|
|
+ } elseif ($diff < 0) {
|
|
|
+ // Нужно освободить часть резерва
|
|
|
+ $toRelease = abs($diff);
|
|
|
+ $released = 0;
|
|
|
+
|
|
|
+ // Освобождаем в обратном порядке FIFO (сначала новые)
|
|
|
+ $reservations = Reservation::query()
|
|
|
+ ->where('reclamation_id', $reclamationId)
|
|
|
+ ->where('spare_part_id', $sparePartId)
|
|
|
+ ->where('with_documents', $withDocuments)
|
|
|
+ ->where('status', Reservation::STATUS_ACTIVE)
|
|
|
+ ->orderBy('created_at', 'desc')
|
|
|
+ ->lockForUpdate()
|
|
|
+ ->get();
|
|
|
+
|
|
|
+ foreach ($reservations as $reservation) {
|
|
|
+ if ($released >= $toRelease) break;
|
|
|
+
|
|
|
+ $releaseFromThis = min($toRelease - $released, $reservation->reserved_qty);
|
|
|
+
|
|
|
+ if ($releaseFromThis === $reservation->reserved_qty) {
|
|
|
+ // Отменяем весь резерв
|
|
|
+ $this->cancelReservation($reservation, "Уменьшение резерва");
|
|
|
+ } else {
|
|
|
+ // Частичная отмена — создаём новый резерв на остаток
|
|
|
+ $reservation->reserved_qty -= $releaseFromThis;
|
|
|
+ $reservation->save();
|
|
|
+
|
|
|
+ // Движение частичной отмены
|
|
|
+ InventoryMovement::create([
|
|
|
+ 'spare_part_order_id' => $reservation->spare_part_order_id,
|
|
|
+ 'spare_part_id' => $sparePartId,
|
|
|
+ 'qty' => $releaseFromThis,
|
|
|
+ 'movement_type' => InventoryMovement::TYPE_RESERVE_CANCEL,
|
|
|
+ 'source_type' => InventoryMovement::SOURCE_RECLAMATION,
|
|
|
+ 'source_id' => $reclamationId,
|
|
|
+ 'with_documents' => $withDocuments,
|
|
|
+ 'user_id' => auth()->id(),
|
|
|
+ 'note' => "Частичная отмена резерва",
|
|
|
+ ]);
|
|
|
+ }
|
|
|
+
|
|
|
+ $released += $releaseFromThis;
|
|
|
+ }
|
|
|
+
|
|
|
+ // Обновляем дефицит если есть
|
|
|
+ $shortage = Shortage::query()
|
|
|
+ ->where('reclamation_id', $reclamationId)
|
|
|
+ ->where('spare_part_id', $sparePartId)
|
|
|
+ ->where('with_documents', $withDocuments)
|
|
|
+ ->where('status', Shortage::STATUS_OPEN)
|
|
|
+ ->first();
|
|
|
+
|
|
|
+ if ($shortage) {
|
|
|
+ $shortage->required_qty = $newQuantity;
|
|
|
+ $shortage->recalculate();
|
|
|
+ }
|
|
|
+
|
|
|
+ // Пересчитываем фактически зарезервированное количество после изменений
|
|
|
+ $actualReserved = Reservation::query()
|
|
|
+ ->where('reclamation_id', $reclamationId)
|
|
|
+ ->where('spare_part_id', $sparePartId)
|
|
|
+ ->where('with_documents', $withDocuments)
|
|
|
+ ->where('status', Reservation::STATUS_ACTIVE)
|
|
|
+ ->sum('reserved_qty');
|
|
|
+
|
|
|
+ return new ReservationResult(
|
|
|
+ reserved: (int) $actualReserved,
|
|
|
+ missing: max(0, $newQuantity - $actualReserved),
|
|
|
+ reservations: collect(),
|
|
|
+ shortage: $shortage
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ // diff = 0, ничего не делаем
|
|
|
+ return new ReservationResult(
|
|
|
+ reserved: $currentReserved,
|
|
|
+ missing: 0,
|
|
|
+ reservations: collect(),
|
|
|
+ shortage: null
|
|
|
+ );
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Получить все активные резервы для рекламации
|
|
|
+ */
|
|
|
+ public function getReservationsForReclamation(int $reclamationId): Collection
|
|
|
+ {
|
|
|
+ return Reservation::query()
|
|
|
+ ->where('reclamation_id', $reclamationId)
|
|
|
+ ->where('status', Reservation::STATUS_ACTIVE)
|
|
|
+ ->with(['sparePart', 'sparePartOrder'])
|
|
|
+ ->get();
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * Результат операции резервирования
|
|
|
+ */
|
|
|
+class ReservationResult
|
|
|
+{
|
|
|
+ public function __construct(
|
|
|
+ public readonly int $reserved,
|
|
|
+ public readonly int $missing,
|
|
|
+ public readonly Collection $reservations,
|
|
|
+ public readonly ?Shortage $shortage
|
|
|
+ ) {}
|
|
|
+
|
|
|
+ public function isFullyReserved(): bool
|
|
|
+ {
|
|
|
+ return $this->missing === 0;
|
|
|
+ }
|
|
|
+
|
|
|
+ public function hasShortage(): bool
|
|
|
+ {
|
|
|
+ return $this->shortage !== null;
|
|
|
+ }
|
|
|
+
|
|
|
+ public function getTotalRequested(): int
|
|
|
+ {
|
|
|
+ return $this->reserved + $this->missing;
|
|
|
+ }
|
|
|
+}
|