Browse Source

Move reclamation reservation reassignment to separate action

Alexander Musikhin 3 days ago
parent
commit
03e83ee095

+ 25 - 6
app/Http/Controllers/SparePartReservationController.php

@@ -85,24 +85,43 @@ class SparePartReservationController extends Controller
         return back()->with(['success' => 'Резерв отменён!']);
     }
 
+    /**
+     * Перенести резерв на другой заказ без списания
+     */
+    public function reassign(Request $request, Reservation $reservation): RedirectResponse
+    {
+        $selectedOrderId = (int) $request->input('selected_order_id', 0);
+
+        if (!$reservation->isActive()) {
+            return back()->with(['error' => 'Резерв не активен!']);
+        }
+
+        if ($selectedOrderId < 1) {
+            return back()->with(['error' => 'Не выбран заказ для переноса резерва!']);
+        }
+
+        try {
+            $this->reservationService->reassignReservationToSelectedOrder($reservation, $selectedOrderId);
+
+            return back()->with(['success' => 'Резерв обновлён!']);
+        } catch (\Exception $e) {
+            return back()->with(['error' => $e->getMessage()]);
+        }
+    }
+
     /**
      * Списать резерв (выполнить отгрузку)
      */
     public function issue(Request $request, Reservation $reservation): RedirectResponse
     {
         $note = $request->input('note', '');
-        $selectedOrderId = (int) $request->input('selected_order_id', 0);
 
         if (!$reservation->isActive()) {
             return back()->with(['error' => 'Резерв не активен!']);
         }
 
         try {
-            if ($selectedOrderId > 0) {
-                $this->issueService->issueReservationFromSelectedOrder($reservation, $selectedOrderId, $note);
-            } else {
-                $this->issueService->issueReservation($reservation, $note);
-            }
+            $this->issueService->issueReservation($reservation, $note);
             return back()->with(['success' => 'Списание выполнено!']);
         } catch (\Exception $e) {
             return back()->with(['error' => $e->getMessage()]);

+ 2 - 38
app/Services/SparePartIssueService.php

@@ -127,7 +127,7 @@ class SparePartIssueService
 
         if ((int) $reservation->spare_part_order_id === $selectedOrderId) {
             $result = $this->issueReservation($reservation, $note);
-            $this->syncPivotRowForReservation($reservation);
+            app(SparePartReservationService::class)->syncPivotRowForReservation($reservation);
 
             return $result;
         }
@@ -195,7 +195,7 @@ class SparePartIssueService
             if ($issueQty < $requestedQty) {
                 $this->splitPivotRowAfterPartialIssue($reservation, $issueQty, $requestedQty - $issueQty);
             } else {
-                $this->syncPivotRowForReservation($reservation);
+                app(SparePartReservationService::class)->syncPivotRowForReservation($reservation);
             }
 
             return $result;
@@ -305,42 +305,6 @@ class SparePartIssueService
         });
     }
 
-    private function syncPivotRowForReservation(Reservation $reservation): void
-    {
-        $row = ReclamationSparePart::query()
-            ->where('reclamation_id', $reservation->reclamation_id)
-            ->where('spare_part_id', $reservation->spare_part_id)
-            ->where('with_documents', $reservation->with_documents)
-            ->orderBy('id')
-            ->first();
-
-        if (!$row) {
-            return;
-        }
-
-        $activeQty = (int) Reservation::query()
-            ->where('reclamation_id', $reservation->reclamation_id)
-            ->where('spare_part_id', $reservation->spare_part_id)
-            ->where('with_documents', $reservation->with_documents)
-            ->where('status', Reservation::STATUS_ACTIVE)
-            ->sum('reserved_qty');
-
-        $issuedQty = (int) Reservation::query()
-            ->where('reclamation_id', $reservation->reclamation_id)
-            ->where('spare_part_id', $reservation->spare_part_id)
-            ->where('with_documents', $reservation->with_documents)
-            ->where('status', Reservation::STATUS_ISSUED)
-            ->sum('reserved_qty');
-
-        $row->update([
-            'reserved_qty' => $activeQty,
-            'issued_qty' => min($row->quantity, $issuedQty),
-            'status' => $issuedQty >= $row->quantity
-                ? 'issued'
-                : ($activeQty > 0 ? 'reserved' : 'pending'),
-        ]);
-    }
-
     private function splitPivotRowAfterPartialIssue(Reservation $reservation, int $issuedQty, int $remainingQty): void
     {
         $row = ReclamationSparePart::query()

+ 166 - 0
app/Services/SparePartReservationService.php

@@ -3,6 +3,7 @@
 namespace App\Services;
 
 use App\Models\InventoryMovement;
+use App\Models\ReclamationSparePart;
 use App\Models\Reservation;
 use App\Models\Shortage;
 use App\Models\SparePart;
@@ -355,6 +356,171 @@ class SparePartReservationService
             ->with(['sparePart', 'sparePartOrder'])
             ->get();
     }
+
+    public function reassignReservationToSelectedOrder(
+        Reservation $reservation,
+        int $selectedOrderId
+    ): Reservation {
+        if (!$reservation->isActive()) {
+            throw new \InvalidArgumentException('Резерв не активен, перенос невозможен');
+        }
+
+        return DB::transaction(function () use ($reservation, $selectedOrderId) {
+            $reservation = Reservation::query()->lockForUpdate()->findOrFail($reservation->id);
+            $selectedOrder = SparePartOrder::query()->lockForUpdate()->findOrFail($selectedOrderId);
+
+            if (!$reservation->isActive()) {
+                throw new \InvalidArgumentException('Резерв не активен, перенос невозможен');
+            }
+
+            if ((int) $reservation->spare_part_order_id === $selectedOrderId) {
+                return $reservation;
+            }
+
+            if ((int) $selectedOrder->spare_part_id !== (int) $reservation->spare_part_id) {
+                throw new \InvalidArgumentException('Выбран заказ другой запчасти');
+            }
+
+            if ((bool) $selectedOrder->with_documents !== (bool) $reservation->with_documents) {
+                throw new \InvalidArgumentException('Выбран заказ с другим признаком документов');
+            }
+
+            if ($selectedOrder->status !== SparePartOrder::STATUS_IN_STOCK) {
+                throw new \InvalidArgumentException('Резервирование возможно только на партии на складе');
+            }
+
+            $alreadyReservedOnSelected = Reservation::query()
+                ->where('spare_part_order_id', $selectedOrder->id)
+                ->where('status', Reservation::STATUS_ACTIVE)
+                ->sum('reserved_qty');
+
+            $freeQty = max(0, (int) $selectedOrder->available_qty - (int) $alreadyReservedOnSelected);
+            if ($freeQty < 1) {
+                throw new \RuntimeException('В выбранном заказе нет доступного остатка');
+            }
+
+            $requestedQty = (int) $reservation->reserved_qty;
+            $reassignedQty = min($requestedQty, $freeQty);
+
+            $this->cancelReservation($reservation, 'Перенос резерва на другой заказ');
+
+            $reserveMovement = InventoryMovement::create([
+                'spare_part_order_id' => $selectedOrder->id,
+                'spare_part_id' => $reservation->spare_part_id,
+                'qty' => $reassignedQty,
+                'movement_type' => InventoryMovement::TYPE_RESERVE,
+                'source_type' => InventoryMovement::SOURCE_RECLAMATION,
+                'source_id' => $reservation->reclamation_id,
+                'with_documents' => $reservation->with_documents,
+                'user_id' => auth()->id(),
+                'note' => "Перенос резерва на заказ #{$selectedOrder->id}",
+            ]);
+
+            $selectedReservation = Reservation::create([
+                'spare_part_id' => $reservation->spare_part_id,
+                'spare_part_order_id' => $selectedOrder->id,
+                'reclamation_id' => $reservation->reclamation_id,
+                'reserved_qty' => $reassignedQty,
+                'with_documents' => $reservation->with_documents,
+                'status' => Reservation::STATUS_ACTIVE,
+                'movement_id' => $reserveMovement->id,
+            ]);
+
+            if ($reassignedQty < $requestedQty) {
+                $this->splitPivotRowAfterPartialReassignment(
+                    $reservation,
+                    $reassignedQty,
+                    $requestedQty - $reassignedQty
+                );
+            } else {
+                $this->syncPivotRowForReservation($selectedReservation);
+            }
+
+            return $selectedReservation;
+        });
+    }
+
+    public function syncPivotRowForReservation(Reservation $reservation): void
+    {
+        $row = ReclamationSparePart::query()
+            ->where('reclamation_id', $reservation->reclamation_id)
+            ->where('spare_part_id', $reservation->spare_part_id)
+            ->where('with_documents', $reservation->with_documents)
+            ->orderBy('id')
+            ->first();
+
+        if (!$row) {
+            return;
+        }
+
+        $activeQty = (int) Reservation::query()
+            ->where('reclamation_id', $reservation->reclamation_id)
+            ->where('spare_part_id', $reservation->spare_part_id)
+            ->where('with_documents', $reservation->with_documents)
+            ->where('status', Reservation::STATUS_ACTIVE)
+            ->sum('reserved_qty');
+
+        $issuedQty = (int) Reservation::query()
+            ->where('reclamation_id', $reservation->reclamation_id)
+            ->where('spare_part_id', $reservation->spare_part_id)
+            ->where('with_documents', $reservation->with_documents)
+            ->where('status', Reservation::STATUS_ISSUED)
+            ->sum('reserved_qty');
+
+        $row->update([
+            'reserved_qty' => $activeQty,
+            'issued_qty' => min($row->quantity, $issuedQty),
+            'status' => $issuedQty >= $row->quantity
+                ? 'issued'
+                : ($activeQty > 0 ? 'reserved' : 'pending'),
+        ]);
+    }
+
+    private function splitPivotRowAfterPartialReassignment(
+        Reservation $reservation,
+        int $reservedQty,
+        int $remainingQty
+    ): void {
+        $row = ReclamationSparePart::query()
+            ->where('reclamation_id', $reservation->reclamation_id)
+            ->where('spare_part_id', $reservation->spare_part_id)
+            ->where('with_documents', $reservation->with_documents)
+            ->where('quantity', '>=', $reservation->reserved_qty)
+            ->orderBy('id')
+            ->first();
+
+        if (!$row) {
+            return;
+        }
+
+        $row->update([
+            'quantity' => $reservedQty,
+            'reserved_qty' => $reservedQty,
+            'issued_qty' => 0,
+            'status' => 'reserved',
+        ]);
+
+        ReclamationSparePart::query()->create([
+            'reclamation_id' => $row->reclamation_id,
+            'spare_part_id' => $row->spare_part_id,
+            'quantity' => $remainingQty,
+            'with_documents' => $row->with_documents,
+            'status' => 'pending',
+            'reserved_qty' => 0,
+            'issued_qty' => 0,
+        ]);
+
+        Shortage::query()->create([
+            'spare_part_id' => $row->spare_part_id,
+            'reclamation_id' => $row->reclamation_id,
+            'with_documents' => $row->with_documents,
+            'required_qty' => $remainingQty,
+            'reserved_qty' => 0,
+            'missing_qty' => $remainingQty,
+            'status' => Shortage::STATUS_OPEN,
+            'note' => 'Остаток после частичного переноса резерва на выбранный заказ',
+        ]);
+    }
 }
 
 /**

+ 15 - 8
resources/views/reclamations/edit.blade.php

@@ -318,21 +318,28 @@
                                                         <td class="text-end">
                                                             @if($issueCandidateOrders->count() > 1)
                                                                 <button type="button"
-                                                                        class="btn btn-sm btn-success"
-                                                                        title="Выбрать заказ для списания"
+                                                                        class="btn btn-sm btn-outline-primary"
+                                                                        title="Изменить заказ резерва"
                                                                         data-bs-toggle="modal"
-                                                                        data-bs-target="#issueReservationModal-{{ $reservation->id }}">
-                                                                    <i class="bi bi-check-lg"></i>
+                                                                        data-bs-target="#reassignReservationModal-{{ $reservation->id }}">
+                                                                    <i class="bi bi-pencil"></i>
                                                                 </button>
 
-                                                                <div class="modal fade" id="issueReservationModal-{{ $reservation->id }}" tabindex="-1" aria-labelledby="issueReservationModalLabel-{{ $reservation->id }}" aria-hidden="true">
+                                                                <form action="{{ route('spare_part_reservations.issue', $reservation) }}" method="POST" class="d-inline">
+                                                                    @csrf
+                                                                    <button type="submit" class="btn btn-sm btn-success" title="Списать">
+                                                                        <i class="bi bi-check-lg"></i>
+                                                                    </button>
+                                                                </form>
+
+                                                                <div class="modal fade" id="reassignReservationModal-{{ $reservation->id }}" tabindex="-1" aria-labelledby="reassignReservationModalLabel-{{ $reservation->id }}" aria-hidden="true">
                                                                     <div class="modal-dialog modal-dialog-scrollable">
                                                                         <div class="modal-content">
                                                                             <div class="modal-header">
-                                                                                <h1 class="modal-title fs-5" id="issueReservationModalLabel-{{ $reservation->id }}">Выбор заказа для списания</h1>
+                                                                                <h1 class="modal-title fs-5" id="reassignReservationModalLabel-{{ $reservation->id }}">Выбор заказа для резерва</h1>
                                                                                 <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
                                                                             </div>
-                                                                            <form action="{{ route('spare_part_reservations.issue', $reservation) }}" method="POST">
+                                                                            <form action="{{ route('spare_part_reservations.reassign', $reservation) }}" method="POST">
                                                                                 @csrf
                                                                                 <div class="modal-body">
                                                                                     <div class="mb-2 small text-muted">
@@ -365,7 +372,7 @@
                                                                                 </div>
                                                                                 <div class="modal-footer">
                                                                                     <button type="button" class="btn btn-outline-secondary btn-sm" data-bs-dismiss="modal">Закрыть</button>
-                                                                                    <button type="submit" class="btn btn-success btn-sm">Списать</button>
+                                                                                    <button type="submit" class="btn btn-primary btn-sm">Сохранить резерв</button>
                                                                                 </div>
                                                                             </form>
                                                                         </div>

+ 1 - 0
routes/web.php

@@ -342,6 +342,7 @@ Route::middleware('auth:web')->group(function () {
         Route::get('/reclamation/{reclamationId}', [SparePartReservationController::class, 'forReclamation'])->name('for_reclamation');
         Route::get('/shortages/{reclamationId}', [SparePartReservationController::class, 'shortagesForReclamation'])->name('shortages_for_reclamation');
         Route::post('/{reservation}/cancel', [SparePartReservationController::class, 'cancel'])->name('cancel');
+        Route::post('/{reservation}/reassign', [SparePartReservationController::class, 'reassign'])->name('reassign');
         Route::post('/{reservation}/issue', [SparePartReservationController::class, 'issue'])->name('issue');
         Route::post('/issue-all/{reclamationId}', [SparePartReservationController::class, 'issueAllForReclamation'])->name('issue_all');
         Route::post('/cancel-all/{reclamationId}', [SparePartReservationController::class, 'cancelAllForReclamation'])->name('cancel_all');

+ 15 - 4
tests/Feature/SparePartReservationControllerTest.php

@@ -313,7 +313,7 @@ class SparePartReservationControllerTest extends TestCase
         $response->assertSessionHas('success');
     }
 
-    public function test_admin_can_issue_reservation_from_selected_order(): void
+    public function test_admin_can_reassign_reservation_to_selected_order(): void
     {
         $sparePart = SparePart::factory()->create();
         $oldOrder = SparePartOrder::factory()->inStock()->forSparePart($sparePart)->create([
@@ -348,7 +348,7 @@ class SparePartReservationControllerTest extends TestCase
         ]);
 
         $response = $this->actingAs($this->adminUser)
-            ->post(route('spare_part_reservations.issue', $reservation), [
+            ->post(route('spare_part_reservations.reassign', $reservation), [
                 'selected_order_id' => $selectedOrder->id,
             ]);
 
@@ -362,14 +362,25 @@ class SparePartReservationControllerTest extends TestCase
             'spare_part_order_id' => $selectedOrder->id,
             'reclamation_id' => $reclamation->id,
             'reserved_qty' => 4,
-            'status' => Reservation::STATUS_ISSUED,
+            'status' => Reservation::STATUS_ACTIVE,
         ]);
         $this->assertDatabaseHas('spare_part_orders', [
             'id' => $selectedOrder->id,
-            'available_qty' => 3,
+            'available_qty' => 7,
         ]);
     }
 
+    public function test_cannot_reassign_reservation_without_selected_order(): void
+    {
+        $reservation = Reservation::factory()->active()->create();
+
+        $response = $this->actingAs($this->adminUser)
+            ->post(route('spare_part_reservations.reassign', $reservation));
+
+        $response->assertRedirect();
+        $response->assertSessionHas('error');
+    }
+
     public function test_cannot_issue_inactive_reservation(): void
     {
         $reservation = Reservation::factory()->cancelled()->create();

+ 121 - 0
tests/Unit/Services/SparePartReservationServiceTest.php

@@ -4,10 +4,12 @@ namespace Tests\Unit\Services;
 
 use App\Models\InventoryMovement;
 use App\Models\Reclamation;
+use App\Models\ReclamationSparePart;
 use App\Models\Reservation;
 use App\Models\Shortage;
 use App\Models\SparePart;
 use App\Models\SparePartOrder;
+use App\Models\User;
 use App\Services\ReservationResult;
 use App\Services\ShortageService;
 use App\Services\SparePartReservationService;
@@ -520,4 +522,123 @@ class SparePartReservationServiceTest extends TestCase
             'status' => Reservation::STATUS_ISSUED,
         ]);
     }
+
+    public function test_reassign_reservation_to_selected_order_moves_active_reservation_without_issue(): void
+    {
+        $user = User::factory()->create();
+        $this->actingAs($user);
+
+        $sparePart = SparePart::factory()->create();
+        $reclamation = Reclamation::factory()->create();
+        $oldOrder = SparePartOrder::factory()->inStock()->withDocuments(false)->withQuantity(10)->forSparePart($sparePart)->create();
+        $selectedOrder = SparePartOrder::factory()->inStock()->withDocuments(false)->withQuantity(8)->forSparePart($sparePart)->create();
+
+        ReclamationSparePart::query()->create([
+            'reclamation_id' => $reclamation->id,
+            'spare_part_id' => $sparePart->id,
+            'quantity' => 5,
+            'with_documents' => false,
+            'status' => 'reserved',
+            'reserved_qty' => 5,
+            'issued_qty' => 0,
+        ]);
+
+        $reservation = Reservation::factory()
+            ->active()
+            ->withQuantity(5)
+            ->withDocuments(false)
+            ->fromOrder($oldOrder)
+            ->forReclamation($reclamation)
+            ->create();
+
+        $newReservation = $this->service->reassignReservationToSelectedOrder($reservation, $selectedOrder->id);
+
+        $this->assertSame(Reservation::STATUS_ACTIVE, $newReservation->status);
+        $this->assertSame($selectedOrder->id, $newReservation->spare_part_order_id);
+        $this->assertDatabaseHas('reservations', [
+            'id' => $reservation->id,
+            'status' => Reservation::STATUS_CANCELLED,
+        ]);
+        $this->assertDatabaseHas('reservations', [
+            'id' => $newReservation->id,
+            'reserved_qty' => 5,
+            'status' => Reservation::STATUS_ACTIVE,
+        ]);
+        $this->assertDatabaseMissing('reservations', [
+            'id' => $newReservation->id,
+            'status' => Reservation::STATUS_ISSUED,
+        ]);
+        $this->assertDatabaseHas('reclamation_spare_part', [
+            'reclamation_id' => $reclamation->id,
+            'spare_part_id' => $sparePart->id,
+            'quantity' => 5,
+            'status' => 'reserved',
+            'reserved_qty' => 5,
+            'issued_qty' => 0,
+        ]);
+        $this->assertDatabaseHas('spare_part_orders', [
+            'id' => $selectedOrder->id,
+            'available_qty' => 8,
+        ]);
+    }
+
+    public function test_reassign_reservation_to_selected_order_splits_row_when_selected_order_has_partial_quantity(): void
+    {
+        $user = User::factory()->create();
+        $this->actingAs($user);
+
+        $sparePart = SparePart::factory()->create();
+        $reclamation = Reclamation::factory()->create();
+        $oldOrder = SparePartOrder::factory()->inStock()->withDocuments(true)->withQuantity(10)->forSparePart($sparePart)->create();
+        $selectedOrder = SparePartOrder::factory()->inStock()->withDocuments(true)->withQuantity(2)->forSparePart($sparePart)->create([
+            'available_qty' => 2,
+        ]);
+
+        ReclamationSparePart::query()->create([
+            'reclamation_id' => $reclamation->id,
+            'spare_part_id' => $sparePart->id,
+            'quantity' => 5,
+            'with_documents' => true,
+            'status' => 'reserved',
+            'reserved_qty' => 5,
+            'issued_qty' => 0,
+        ]);
+
+        $reservation = Reservation::factory()
+            ->active()
+            ->withQuantity(5)
+            ->withDocuments(true)
+            ->fromOrder($oldOrder)
+            ->forReclamation($reclamation)
+            ->create();
+
+        $newReservation = $this->service->reassignReservationToSelectedOrder($reservation, $selectedOrder->id);
+
+        $this->assertSame(2, $newReservation->reserved_qty);
+        $this->assertDatabaseHas('reclamation_spare_part', [
+            'reclamation_id' => $reclamation->id,
+            'spare_part_id' => $sparePart->id,
+            'quantity' => 2,
+            'with_documents' => true,
+            'status' => 'reserved',
+            'reserved_qty' => 2,
+            'issued_qty' => 0,
+        ]);
+        $this->assertDatabaseHas('reclamation_spare_part', [
+            'reclamation_id' => $reclamation->id,
+            'spare_part_id' => $sparePart->id,
+            'quantity' => 3,
+            'with_documents' => true,
+            'status' => 'pending',
+            'reserved_qty' => 0,
+            'issued_qty' => 0,
+        ]);
+        $this->assertDatabaseHas('shortages', [
+            'reclamation_id' => $reclamation->id,
+            'spare_part_id' => $sparePart->id,
+            'missing_qty' => 3,
+            'with_documents' => true,
+            'status' => Shortage::STATUS_OPEN,
+        ]);
+    }
 }