Browse Source

reclamations/spare-parts: selectable source order for issue flow

Alexander Musikhin 2 tuần trước cách đây
mục cha
commit
8a5d1b7df0

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

@@ -91,13 +91,18 @@ class SparePartReservationController extends Controller
     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 {
-            $this->issueService->issueReservation($reservation, $note);
+            if ($selectedOrderId > 0) {
+                $this->issueService->issueReservationFromSelectedOrder($reservation, $selectedOrderId, $note);
+            } else {
+                $this->issueService->issueReservation($reservation, $note);
+            }
             return back()->with(['success' => 'Списание выполнено!']);
         } catch (\Exception $e) {
             return back()->with(['error' => $e->getMessage()]);

+ 38 - 0
app/Models/ReclamationSparePart.php

@@ -0,0 +1,38 @@
+<?php
+
+namespace App\Models;
+
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
+
+class ReclamationSparePart extends Model
+{
+    protected $table = 'reclamation_spare_part';
+
+    protected $fillable = [
+        'reclamation_id',
+        'spare_part_id',
+        'quantity',
+        'with_documents',
+        'status',
+        'reserved_qty',
+        'issued_qty',
+    ];
+
+    protected $casts = [
+        'quantity' => 'integer',
+        'with_documents' => 'boolean',
+        'reserved_qty' => 'integer',
+        'issued_qty' => 'integer',
+    ];
+
+    public function reclamation(): BelongsTo
+    {
+        return $this->belongsTo(Reclamation::class);
+    }
+
+    public function sparePart(): BelongsTo
+    {
+        return $this->belongsTo(SparePart::class);
+    }
+}

+ 167 - 0
app/Services/SparePartIssueService.php

@@ -3,7 +3,9 @@
 namespace App\Services;
 
 use App\Models\InventoryMovement;
+use App\Models\ReclamationSparePart;
 use App\Models\Reservation;
+use App\Models\Shortage;
 use App\Models\SparePartOrder;
 use Illuminate\Support\Facades\DB;
 
@@ -114,6 +116,92 @@ class SparePartIssueService
         return $results;
     }
 
+    public function issueReservationFromSelectedOrder(
+        Reservation $reservation,
+        int $selectedOrderId,
+        string $note = ''
+    ): IssueResult {
+        if (!$reservation->isActive()) {
+            throw new \InvalidArgumentException('Резерв не активен, списание невозможно');
+        }
+
+        if ((int) $reservation->spare_part_order_id === $selectedOrderId) {
+            $result = $this->issueReservation($reservation, $note);
+            $this->syncPivotRowForReservation($reservation);
+
+            return $result;
+        }
+
+        return DB::transaction(function () use ($reservation, $selectedOrderId, $note) {
+            $reservation = Reservation::query()->lockForUpdate()->findOrFail($reservation->id);
+            $selectedOrder = SparePartOrder::query()->lockForUpdate()->findOrFail($selectedOrderId);
+
+            if (!$reservation->isActive()) {
+                throw new \InvalidArgumentException('Резерв не активен, списание невозможно');
+            }
+
+            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;
+            $issueQty = min($requestedQty, $freeQty);
+
+            app(SparePartReservationService::class)
+                ->cancelReservation($reservation, 'Перенос резерва на другой заказ для списания');
+
+            $reserveMovement = InventoryMovement::create([
+                'spare_part_order_id' => $selectedOrder->id,
+                'spare_part_id' => $reservation->spare_part_id,
+                'qty' => $issueQty,
+                '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' => $issueQty,
+                'with_documents' => $reservation->with_documents,
+                'status' => Reservation::STATUS_ACTIVE,
+                'movement_id' => $reserveMovement->id,
+            ]);
+
+            $result = $this->issueReservation($selectedReservation, $note);
+
+            if ($issueQty < $requestedQty) {
+                $this->splitPivotRowAfterPartialIssue($reservation, $issueQty, $requestedQty - $issueQty);
+            } else {
+                $this->syncPivotRowForReservation($reservation);
+            }
+
+            return $result;
+        });
+    }
+
     /**
      * Прямое списание без резерва (для ручных операций)
      *
@@ -216,6 +304,85 @@ 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()
+            ->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' => $issuedQty,
+            'reserved_qty' => 0,
+            'issued_qty' => $issuedQty,
+            'status' => 'issued',
+        ]);
+
+        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' => 'Остаток после частичного списания из выбранного заказа',
+        ]);
+    }
 }
 
 /**

+ 74 - 4
resources/views/reclamations/edit.blade.php

@@ -266,6 +266,19 @@
                                         </thead>
                                         <tbody>
                                             @foreach($reservations->where('status', 'active') as $reservation)
+                                                @php
+                                                    $issueCandidateOrders = \App\Models\SparePartOrder::query()
+                                                        ->where('spare_part_id', $reservation->spare_part_id)
+                                                        ->where('with_documents', $reservation->with_documents)
+                                                        ->where(function ($query) use ($reservation) {
+                                                            $query->where(function ($inner) {
+                                                                $inner->where('status', \App\Models\SparePartOrder::STATUS_IN_STOCK)
+                                                                    ->where('available_qty', '>', 0);
+                                                            })->orWhere('id', $reservation->spare_part_order_id);
+                                                        })
+                                                        ->orderBy('created_at')
+                                                        ->get();
+                                                @endphp
                                                 <tr>
                                                     <td>
                                                         @if($reservation->sparePart)
@@ -303,12 +316,69 @@
                                                     </td>
                                                     @if(hasRole('admin,manager'))
                                                         <td class="text-end">
-                                                            <form action="{{ route('spare_part_reservations.issue', $reservation) }}" method="POST" class="d-inline">
-                                                                @csrf
-                                                                <button type="submit" class="btn btn-sm btn-success" title="Списать">
+                                                            @if($issueCandidateOrders->count() > 1)
+                                                                <button type="button"
+                                                                        class="btn btn-sm btn-success"
+                                                                        title="Выбрать заказ для списания"
+                                                                        data-bs-toggle="modal"
+                                                                        data-bs-target="#issueReservationModal-{{ $reservation->id }}">
                                                                     <i class="bi bi-check-lg"></i>
                                                                 </button>
-                                                            </form>
+
+                                                                <div class="modal fade" id="issueReservationModal-{{ $reservation->id }}" tabindex="-1" aria-labelledby="issueReservationModalLabel-{{ $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>
+                                                                                <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
+                                                                            </div>
+                                                                            <form action="{{ route('spare_part_reservations.issue', $reservation) }}" method="POST">
+                                                                                @csrf
+                                                                                <div class="modal-body">
+                                                                                    <div class="mb-2 small text-muted">
+                                                                                        {{ $reservation->sparePart?->article ?? 'Запчасть' }}, {{ $reservation->reserved_qty }} шт.
+                                                                                    </div>
+                                                                                    @foreach($issueCandidateOrders as $candidateOrder)
+                                                                                        @php
+                                                                                            $activeReservedForCandidate = \App\Models\Reservation::query()
+                                                                                                ->where('spare_part_order_id', $candidateOrder->id)
+                                                                                                ->where('status', \App\Models\Reservation::STATUS_ACTIVE)
+                                                                                                ->sum('reserved_qty');
+                                                                                            $freeCandidateQty = max(0, (int) $candidateOrder->available_qty - (int) $activeReservedForCandidate);
+                                                                                            $isCurrentCandidate = (int) $candidateOrder->id === (int) $reservation->spare_part_order_id;
+                                                                                        @endphp
+                                                                                        <label class="form-check mb-2">
+                                                                                            <input class="form-check-input"
+                                                                                                   type="radio"
+                                                                                                   name="selected_order_id"
+                                                                                                   value="{{ $candidateOrder->id }}"
+                                                                                                   @checked($isCurrentCandidate)>
+                                                                                            <span class="form-check-label">
+                                                                                                {{ $candidateOrder->display_order_number }}
+                                                                                                @if($isCurrentCandidate)
+                                                                                                    <span class="text-muted">, текущий резерв</span>
+                                                                                                @endif
+                                                                                                <span class="text-muted">, доступно {{ $freeCandidateQty }}</span>
+                                                                                            </span>
+                                                                                        </label>
+                                                                                    @endforeach
+                                                                                </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>
+                                                                                </div>
+                                                                            </form>
+                                                                        </div>
+                                                                    </div>
+                                                                </div>
+                                                            @else
+                                                                <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>
+                                                            @endif
                                                             <form action="{{ route('spare_part_reservations.cancel', $reservation) }}" method="POST" class="d-inline">
                                                                 @csrf
                                                                 <button type="submit" class="btn btn-sm btn-outline-warning" title="Отменить резерв">

+ 57 - 0
tests/Feature/SparePartReservationControllerTest.php

@@ -313,6 +313,63 @@ class SparePartReservationControllerTest extends TestCase
         $response->assertSessionHas('success');
     }
 
+    public function test_admin_can_issue_reservation_from_selected_order(): void
+    {
+        $sparePart = SparePart::factory()->create();
+        $oldOrder = SparePartOrder::factory()->inStock()->forSparePart($sparePart)->create([
+            'ordered_quantity' => 10,
+            'available_qty' => 10,
+            'with_documents' => false,
+        ]);
+        $selectedOrder = SparePartOrder::factory()->inStock()->forSparePart($sparePart)->create([
+            'ordered_quantity' => 7,
+            'available_qty' => 7,
+            'with_documents' => false,
+        ]);
+        $reclamation = Reclamation::factory()->create();
+
+        \App\Models\ReclamationSparePart::query()->create([
+            'reclamation_id' => $reclamation->id,
+            'spare_part_id' => $sparePart->id,
+            'quantity' => 4,
+            'with_documents' => false,
+            'status' => 'reserved',
+            'reserved_qty' => 4,
+            'issued_qty' => 0,
+        ]);
+
+        $reservation = Reservation::factory()->create([
+            'spare_part_id' => $sparePart->id,
+            'spare_part_order_id' => $oldOrder->id,
+            'reclamation_id' => $reclamation->id,
+            'reserved_qty' => 4,
+            'with_documents' => false,
+            'status' => Reservation::STATUS_ACTIVE,
+        ]);
+
+        $response = $this->actingAs($this->adminUser)
+            ->post(route('spare_part_reservations.issue', $reservation), [
+                'selected_order_id' => $selectedOrder->id,
+            ]);
+
+        $response->assertRedirect();
+        $response->assertSessionHas('success');
+        $this->assertDatabaseHas('reservations', [
+            'id' => $reservation->id,
+            'status' => Reservation::STATUS_CANCELLED,
+        ]);
+        $this->assertDatabaseHas('reservations', [
+            'spare_part_order_id' => $selectedOrder->id,
+            'reclamation_id' => $reclamation->id,
+            'reserved_qty' => 4,
+            'status' => Reservation::STATUS_ISSUED,
+        ]);
+        $this->assertDatabaseHas('spare_part_orders', [
+            'id' => $selectedOrder->id,
+            'available_qty' => 3,
+        ]);
+    }
+
     public function test_cannot_issue_inactive_reservation(): void
     {
         $reservation = Reservation::factory()->cancelled()->create();

+ 106 - 0
tests/Unit/Services/SparePartIssueServiceTest.php

@@ -367,6 +367,112 @@ class SparePartIssueServiceTest extends TestCase
         $this->assertEquals(3, $results[0]->issued);
     }
 
+    public function test_issue_reservation_from_selected_order_cancels_old_reservation_and_issues_from_new_order(): 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();
+        $newOrder = SparePartOrder::factory()->inStock()->withDocuments(false)->withQuantity(8)->forSparePart($sparePart)->create();
+
+        \App\Models\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();
+
+        $result = $this->service->issueReservationFromSelectedOrder($reservation, $newOrder->id);
+
+        $this->assertEquals(5, $result->issued);
+        $this->assertDatabaseHas('reservations', [
+            'id' => $reservation->id,
+            'status' => Reservation::STATUS_CANCELLED,
+        ]);
+        $this->assertDatabaseHas('reservations', [
+            'spare_part_order_id' => $newOrder->id,
+            'reclamation_id' => $reclamation->id,
+            'reserved_qty' => 5,
+            'status' => Reservation::STATUS_ISSUED,
+        ]);
+        $this->assertDatabaseHas('spare_part_orders', [
+            'id' => $newOrder->id,
+            'available_qty' => 3,
+        ]);
+    }
+
+    public function test_issue_reservation_from_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();
+        $newOrder = SparePartOrder::factory()->inStock()->withDocuments(true)->withQuantity(2)->forSparePart($sparePart)->create([
+            'available_qty' => 2,
+        ]);
+
+        \App\Models\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();
+
+        $result = $this->service->issueReservationFromSelectedOrder($reservation, $newOrder->id);
+
+        $this->assertEquals(2, $result->issued);
+        $this->assertDatabaseHas('reclamation_spare_part', [
+            'reclamation_id' => $reclamation->id,
+            'spare_part_id' => $sparePart->id,
+            'quantity' => 2,
+            'with_documents' => true,
+            'status' => 'issued',
+            'issued_qty' => 2,
+        ]);
+        $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' => \App\Models\Shortage::STATUS_OPEN,
+        ]);
+    }
+
     // ==================== directIssue ====================
 
     public function test_direct_issue_decreases_available_qty(): void