Переглянути джерело

1. Карточка запчасти (spare_parts/edit.blade.php)

  - Остатки — таблица с физическими, зарезервированными и свободными остатками (с/без документов)
  - Активные резервы — таблица с информацией о рекламациях и партиях
  - Открытые дефициты — таблица с нехваткой по рекламациям
  - Партии на складе — список партий с доступным остатком
  - История движений — последние 50 движений с подсветкой по типу

  2. Карточка рекламации (reclamations/edit.blade.php)

  - Бейджи с количеством резервов и дефицитов в заголовке секции запчастей
  - Активные резервы — таблица с кнопками "Списать" и "Отменить"
  - Списанные — таблица уже списанных запчастей
  - Дефициты — таблица с нехваткой

  3. Контроль наличия (spare_parts/index.blade.php)

  - Открытые дефициты — детальная таблица всех дефицитов с рекламациями
  - Запчасти с дефицитами — сгруппировано по запчастям
  - Ниже минимального остатка — с указанием сколько нужно заказать

  4. Исправление формы добавления запчасти

  - Поле ввода теперь разблокируется при добавлении новой строки
Alexander Musikhin 2 днів тому
батько
коміт
5b575cbae6
30 змінених файлів з 3699 додано та 178 видалено
  1. 226 0
      .claude/spare_parts.md
  2. 117 32
      app/Http/Controllers/ReclamationController.php
  3. 5 0
      app/Http/Controllers/SparePartInventoryController.php
  4. 62 19
      app/Http/Controllers/SparePartOrderController.php
  5. 134 0
      app/Http/Controllers/SparePartReservationController.php
  6. 155 0
      app/Models/InventoryMovement.php
  7. 33 1
      app/Models/Reclamation.php
  8. 115 0
      app/Models/Reservation.php
  9. 186 0
      app/Models/Shortage.php
  10. 194 22
      app/Models/SparePart.php
  11. 151 26
      app/Models/SparePartOrder.php
  12. 9 1
      app/Models/SparePartOrdersView.php
  13. 41 0
      app/Observers/SparePartOrderObserver.php
  14. 5 0
      app/Providers/AppServiceProvider.php
  15. 220 0
      app/Services/ShortageService.php
  16. 154 40
      app/Services/SparePartInventoryService.php
  17. 232 0
      app/Services/SparePartIssueService.php
  18. 386 0
      app/Services/SparePartReservationService.php
  19. 83 0
      database/migrations/2026_01_24_200000_create_inventory_movements_table.php
  20. 78 0
      database/migrations/2026_01_24_200001_create_reservations_table.php
  21. 75 0
      database/migrations/2026_01_24_200002_create_shortages_table.php
  22. 83 0
      database/migrations/2026_01_24_200003_refactor_spare_part_orders_table.php
  23. 42 0
      database/migrations/2026_01_24_200004_add_reservation_to_reclamation_spare_part.php
  24. 124 0
      database/migrations/2026_01_24_200005_migrate_shipments_to_movements.php
  25. 63 0
      database/migrations/2026_01_24_200006_recreate_spare_part_orders_view.php
  26. 205 3
      resources/views/reclamations/edit.blade.php
  27. 118 16
      resources/views/spare_part_orders/edit.blade.php
  28. 281 0
      resources/views/spare_parts/edit.blade.php
  29. 110 18
      resources/views/spare_parts/index.blade.php
  30. 12 0
      routes/web.php

+ 226 - 0
.claude/spare_parts.md

@@ -0,0 +1,226 @@
+Ты — архитектор-разработчик ERP-системы с опытом проектирования складского и операционного учёта.
+
+Задача:
+Перепроектировать логику учёта запчастей, резервирования и списания из рекламаций так, чтобы система была:
+
+консистентной,
+
+масштабируемой,
+
+безопасной с точки зрения остатков,
+
+без отрицательных физических остатков.
+
+Упрощённые, временные и компромиссные решения не допускаются.
+Проектировать необходимо «как правильно».
+
+1. Архитектурные ограничения (обязательные)
+
+Каталог запчастей — справочник, а не источник остатков.
+
+В каталоге запрещено хранить:
+
+фактические остатки,
+
+зарезервированные остатки,
+
+отрицательные значения.
+
+Физические остатки не могут быть отрицательными ни при каких сценариях.
+
+Любое изменение количества должно быть выражено через операции (движения).
+
+Нехватка запчастей учитывается отдельной сущностью, а не минусами.
+
+2. Доменные сущности (обязательны к проектированию)
+   2.1. Part (Каталог запчастей)
+
+Назначение: справочник.
+
+Поля:
+
+part_id / sku
+
+цены и тарифы
+
+нормативные коды
+
+min_stock
+
+метаданные
+
+❌ Не хранит количества.
+
+2.2. PartOrder (Заказ / партия)
+
+Назначение: физическое поступление.
+
+Поля:
+
+order_id
+
+part_id
+
+ordered_qty
+
+available_qty
+
+with_documents (bool)
+
+status (ordered / in_stock / issued)
+
+Инвариант:
+
+available_qty >= 0
+
+2.3. InventoryMovement (Движение)
+
+Назначение: единственный механизм изменения остатков.
+
+Поля:
+
+movement_id
+
+part_id
+
+qty
+
+movement_type
+(receipt, reserve, issue, reserve_cancel)
+
+source_type (order / reclamation)
+
+source_id
+
+with_documents
+
+timestamp
+
+Остатки рассчитываются агрегированием движений.
+
+2.4. Reservation (логическая модель)
+
+Реализуется через движения типа reserve.
+
+Назначение:
+
+уменьшение свободного остатка,
+
+предотвращение двойного использования,
+
+отделение резерва от физического списания.
+
+2.5. Shortage (Дефицит)
+
+Назначение: фиксация неудовлетворённой потребности.
+
+Поля:
+
+shortage_id
+
+part_id
+
+with_documents
+
+required_qty
+
+reserved_qty
+
+missing_qty
+
+reclamation_id
+
+status (open / closed)
+
+❗ Не влияет напрямую на физический остаток.
+
+3. Алгоритм: добавление детали в рекламацию
+
+Пользователь выбирает part_id, количество и with_documents.
+
+Система рассчитывает:
+
+свободный остаток = физический остаток − активные резервы.
+
+Выполняется:
+
+резервирование доступного количества (reserve movement),
+
+создание Shortage, если required_qty > free_qty.
+
+Физический остаток не уменьшается.
+
+4. Алгоритм: списание (отгрузка)
+
+Списание возможно только при наличии резерва.
+
+При списании:
+
+создаётся движение issue,
+
+резерв закрывается или уменьшается.
+
+Остаток партии уменьшается, но не уходит в минус.
+
+5. Алгоритм: закрытие дефицита
+
+При создании PartOrder:
+
+система ищет открытые Shortage
+по part_id + with_documents.
+
+Поступившее количество:
+
+резервируется под дефициты (FIFO / по дате),
+
+уменьшает missing_qty.
+
+При missing_qty = 0:
+
+дефицит закрывается (status = closed).
+
+6. Контроль наличия (read-only представление)
+
+Формируется автоматически, без ручного редактирования.
+
+6.1. Критический уровень
+
+Показывать все Shortage со статусом open.
+
+6.2. Минимальный остаток
+
+Показывать Part, для которых:
+
+free_stock < min_stock
+
+7. Явные запреты (должны быть обеспечены кодом)
+
+Запрещено:
+
+хранить остатки в каталоге,
+
+использовать отрицательные значения,
+
+списывать детали напрямую из рекламации,
+
+корректировать остатки без InventoryMovement.
+
+8. Ожидаемый результат от тебя
+
+Нужно предоставить:
+
+Схему сущностей и связей (ER / логическую модель).
+
+Описание ключевых инвариантов и бизнес-ограничений.
+
+Псевдокод или пошаговые алгоритмы:
+
+резервирования,
+
+списания,
+
+закрытия дефицита.
+
+Перечень существующих механизмов, которые должны быть удалены или переписаны.
+
+Фокус на корректности модели и безопасности данных, а не на UI.

+ 117 - 32
app/Http/Controllers/ReclamationController.php

@@ -17,6 +17,7 @@ use App\Models\ReclamationView;
 use App\Models\Role;
 use App\Models\User;
 use App\Services\FileService;
+use App\Services\SparePartReservationService;
 use Illuminate\Http\Request;
 use Illuminate\Support\Carbon;
 use Illuminate\Support\Facades\Storage;
@@ -212,7 +213,7 @@ class ReclamationController extends Controller
         $quantity = $request->validated('quantity');
         $withDocuments = $request->validated('with_documents');
 
-        $inventoryService = app(\App\Services\SparePartInventoryService::class);
+        $reservationService = app(SparePartReservationService::class);
 
         foreach ($names as $key => $name) {
             if (!$name) continue;
@@ -222,23 +223,58 @@ class ReclamationController extends Controller
                 $sparePart = \App\Models\SparePart::where('article', $name)->first();
 
                 if ($sparePart) {
-                    // Автоматическое списание
+                    // Резервирование вместо прямого списания
                     $withDocs = isset($withDocuments[$key]) && $withDocuments[$key];
-
-                    $inventoryService->deductForReclamation(
-                        $name,
-                        (int)$quantity[$key],
-                        $withDocs,
-                        $reclamation->id
-                    );
-
-                    // Сохраняем в pivot
-                    $reclamation->spareParts()->syncWithoutDetaching([
-                        $sparePart->id => [
-                            'quantity' => $quantity[$key],
-                            'with_documents' => $withDocs,
-                        ]
-                    ]);
+                    $qty = (int)$quantity[$key];
+
+                    // Получаем текущее количество в pivot
+                    $currentPivot = $reclamation->spareParts()->find($sparePart->id);
+                    $currentQty = $currentPivot?->pivot->quantity ?? 0;
+                    $diff = $qty - $currentQty;
+
+                    if ($diff > 0) {
+                        // Нужно зарезервировать дополнительное количество
+                        $result = $reservationService->reserve(
+                            $sparePart->id,
+                            $diff,
+                            $withDocs,
+                            $reclamation->id
+                        );
+
+                        // Обновляем pivot с учётом результата
+                        $reclamation->spareParts()->syncWithoutDetaching([
+                            $sparePart->id => [
+                                'quantity' => $qty,
+                                'with_documents' => $withDocs,
+                                'status' => $result->isFullyReserved() ? 'reserved' : 'pending',
+                                'reserved_qty' => $currentQty + $result->reserved,
+                            ]
+                        ]);
+                    } elseif ($diff < 0) {
+                        // Уменьшение — отменяем часть резерва
+                        $reservationService->adjustReservation(
+                            $reclamation->id,
+                            $sparePart->id,
+                            $withDocs,
+                            $qty
+                        );
+
+                        $reclamation->spareParts()->syncWithoutDetaching([
+                            $sparePart->id => [
+                                'quantity' => $qty,
+                                'with_documents' => $withDocs,
+                                'reserved_qty' => $qty,
+                            ]
+                        ]);
+                    } else {
+                        // Количество не изменилось, возможно изменился with_documents
+                        $reclamation->spareParts()->syncWithoutDetaching([
+                            $sparePart->id => [
+                                'quantity' => $qty,
+                                'with_documents' => $withDocs,
+                            ]
+                        ]);
+                    }
                 } else {
                     // Обычная деталь
                     ReclamationDetail::query()->updateOrCreate(
@@ -248,10 +284,23 @@ class ReclamationController extends Controller
                 }
             } else {
                 // Удаление
-                ReclamationDetail::query()
-                    ->where('reclamation_id', $reclamation->id)
-                    ->where('name', $name)
-                    ->delete();
+                // Проверяем, является ли это запчастью — отменяем резервы
+                $sparePartToRemove = \App\Models\SparePart::where('article', $name)->first();
+                if ($sparePartToRemove) {
+                    // Отменяем все резервы для этой запчасти в рекламации
+                    $reservationService->cancelForReclamation(
+                        $reclamation->id,
+                        $sparePartToRemove->id
+                    );
+                    // Удаляем связь
+                    $reclamation->spareParts()->detach($sparePartToRemove->id);
+                } else {
+                    // Обычная деталь
+                    ReclamationDetail::query()
+                        ->where('reclamation_id', $reclamation->id)
+                        ->where('name', $name)
+                        ->delete();
+                }
             }
         }
 
@@ -262,11 +311,27 @@ class ReclamationController extends Controller
     {
         $rows = $request->validated('rows') ?? [];
 
-        $inventoryService = app(\App\Services\SparePartInventoryService::class);
+        $reservationService = app(SparePartReservationService::class);
 
         // Получаем текущие привязки для сравнения
         $currentSpareParts = $reclamation->spareParts->keyBy('id');
 
+        // Определяем какие запчасти были удалены
+        $newSparePartIds = collect($rows)->pluck('spare_part_id')->filter()->toArray();
+        $removedIds = $currentSpareParts->keys()->diff($newSparePartIds);
+
+        // Отменяем резервы для удалённых запчастей
+        foreach ($removedIds as $removedId) {
+            $current = $currentSpareParts->get($removedId);
+            if ($current) {
+                $reservationService->cancelForReclamation(
+                    $reclamation->id,
+                    $removedId,
+                    $current->pivot->with_documents
+                );
+            }
+        }
+
         // Собираем новые привязки
         $newSpareParts = [];
 
@@ -281,24 +346,44 @@ class ReclamationController extends Controller
 
             // Проверяем, изменилось ли количество
             $currentQty = $currentSpareParts->get($sparePartId)?->pivot->quantity ?? 0;
+            $currentReserved = $currentSpareParts->get($sparePartId)?->pivot->reserved_qty ?? 0;
             $diff = $quantity - $currentQty;
 
+            $status = 'pending';
+            $reservedQty = $currentReserved;
+
             if ($diff > 0) {
-                // Нужно списать дополнительное количество
-                $sparePart = \App\Models\SparePart::find($sparePartId);
-                if ($sparePart) {
-                    $inventoryService->deductForReclamation(
-                        $sparePart->article,
-                        $diff,
-                        $withDocs,
-                        $reclamation->id
-                    );
-                }
+                // Нужно зарезервировать дополнительное количество
+                $result = $reservationService->reserve(
+                    $sparePartId,
+                    $diff,
+                    $withDocs,
+                    $reclamation->id
+                );
+
+                $reservedQty = $currentReserved + $result->reserved;
+                $status = $reservedQty >= $quantity ? 'reserved' : 'pending';
+            } elseif ($diff < 0) {
+                // Уменьшение — отменяем часть резерва
+                $reservationService->adjustReservation(
+                    $reclamation->id,
+                    $sparePartId,
+                    $withDocs,
+                    $quantity
+                );
+
+                $reservedQty = $quantity;
+                $status = 'reserved';
+            } else {
+                // Количество не изменилось
+                $status = $currentReserved >= $quantity ? 'reserved' : 'pending';
             }
 
             $newSpareParts[$sparePartId] = [
                 'quantity' => $quantity,
                 'with_documents' => $withDocs,
+                'status' => $status,
+                'reserved_qty' => $reservedQty,
             ];
         }
 

+ 5 - 0
app/Http/Controllers/SparePartInventoryController.php

@@ -21,6 +21,11 @@ class SparePartInventoryController extends Controller
     {
         $this->data['critical_shortages'] = $this->inventoryService->getCriticalShortages();
         $this->data['below_min_stock'] = $this->inventoryService->getBelowMinStock();
+        $this->data['open_shortages'] = \App\Models\Shortage::query()
+            ->where('status', \App\Models\Shortage::STATUS_OPEN)
+            ->with(['sparePart', 'reclamation'])
+            ->orderBy('created_at', 'asc')
+            ->get();
         $this->data['tab'] = 'inventory';
 
         return view('spare_parts.index', $this->data);

+ 62 - 19
app/Http/Controllers/SparePartOrderController.php

@@ -7,6 +7,7 @@ use App\Http\Requests\StoreSparePartOrderRequest;
 use App\Models\SparePart;
 use App\Models\SparePartOrder;
 use App\Models\SparePartOrdersView;
+use App\Services\SparePartIssueService;
 use Illuminate\Http\RedirectResponse;
 use Illuminate\Http\Request;
 
@@ -23,7 +24,7 @@ class SparePartOrderController extends Controller
             'source_text' => 'Источник заказа',
             'status' => 'Статус',
             'ordered_quantity' => 'Заказано',
-            'remaining_quantity' => 'Остаток',
+            'available_qty' => 'Остаток',
             'with_documents' => 'С документами',
             'note' => 'Примечание',
             'user_name' => 'Менеджер',
@@ -38,6 +39,10 @@ class SparePartOrderController extends Controller
         'routeName' => 'spare_part_orders.show',
     ];
 
+    public function __construct(
+        protected SparePartIssueService $issueService
+    ) {}
+
     public function index(Request $request)
     {
         session(['gp_spare_part_orders' => $request->query()]);
@@ -45,7 +50,7 @@ class SparePartOrderController extends Controller
 
         // Фильтры
         $this->createFilters($model, 'year', 'article', 'status', 'with_documents');
-        $this->createRangeFilters($model, 'ordered_quantity', 'remaining_quantity');
+        $this->createRangeFilters($model, 'ordered_quantity', 'available_qty');
         $this->createDateFilters($model, 'created_at');
 
         // Запрос
@@ -69,8 +74,8 @@ class SparePartOrderController extends Controller
             $q->where('status', $request->get('status'));
         }
 
-        if ($request->has('remaining_quantity_min')) {
-            $q->where('remaining_quantity', '>=', $request->get('remaining_quantity_min'));
+        if ($request->has('available_qty_min')) {
+            $q->where('available_qty', '>=', $request->get('available_qty_min'));
         }
 
         $this->acceptFilters($q, $request);
@@ -89,7 +94,11 @@ class SparePartOrderController extends Controller
     public function show(Request $request, SparePartOrder $sparePartOrder)
     {
         $this->data['previous_url'] = $request->get('previous_url');
-        $this->data['spare_part_order'] = $sparePartOrder->load(['sparePart', 'shipments.user', 'shipments.reclamation']);
+        $this->data['spare_part_order'] = $sparePartOrder->load([
+            'sparePart',
+            'movements.user',
+            'reservations.reclamation',
+        ]);
         $this->data['spare_parts'] = SparePart::orderBy('article')->get();
         return view('spare_part_orders.edit', $this->data);
     }
@@ -106,6 +115,7 @@ class SparePartOrderController extends Controller
         $data = $request->validated();
         $data['user_id'] = auth()->id();
 
+        // Observer автоматически обработает дефициты при создании партии со статусом in_stock
         SparePartOrder::create($data);
 
         $previous_url = $request->get('previous_url') ?? route('spare_part_orders.index', session('gp_spare_part_orders'));
@@ -114,6 +124,7 @@ class SparePartOrderController extends Controller
 
     public function update(StoreSparePartOrderRequest $request, SparePartOrder $sparePartOrder): RedirectResponse
     {
+        // Observer автоматически обработает дефициты при смене статуса на in_stock
         $sparePartOrder->update($request->validated());
 
         $previous_url = $request->get('previous_url') ?? route('spare_part_orders.index', session('gp_spare_part_orders'));
@@ -122,42 +133,74 @@ class SparePartOrderController extends Controller
 
     public function destroy(SparePartOrder $sparePartOrder): RedirectResponse
     {
+        // Проверяем, есть ли активные резервы
+        if ($sparePartOrder->reservations()->where('status', 'active')->exists()) {
+            return redirect()->route('spare_part_orders.show', $sparePartOrder)
+                ->with(['error' => 'Невозможно удалить заказ с активными резервами!']);
+        }
+
         $sparePartOrder->delete();
 
         return redirect()->route('spare_part_orders.index', session('gp_spare_part_orders'))
             ->with(['success' => 'Заказ детали успешно удалён!']);
     }
 
+    /**
+     * Прямое списание (отгрузка) без резерва
+     */
     public function ship(ShipSparePartOrderRequest $request, SparePartOrder $sparePartOrder): RedirectResponse
     {
         $validated = $request->validated();
 
-        if ($validated['quantity'] > $sparePartOrder->remaining_quantity) {
-            return redirect()->route('spare_part_orders.show', $sparePartOrder)
-                ->with(['error' => 'Количество отгрузки превышает остаток!']);
-        }
-
-        $success = $sparePartOrder->shipQuantity(
-            $validated['quantity'],
-            $validated['note'],
-            null,
-            auth()->id()
-        );
+        try {
+            $this->issueService->directIssue(
+                $sparePartOrder,
+                $validated['quantity'],
+                $validated['note']
+            );
 
-        if ($success) {
             return redirect()->route('spare_part_orders.show', $sparePartOrder)
                 ->with(['success' => 'Отгрузка успешно выполнена!']);
-        } else {
+        } catch (\InvalidArgumentException $e) {
             return redirect()->route('spare_part_orders.show', $sparePartOrder)
-                ->with(['error' => 'Ошибка отгрузки!']);
+                ->with(['error' => $e->getMessage()]);
         }
     }
 
+    /**
+     * Изменить статус на "На складе"
+     */
     public function setInStock(SparePartOrder $sparePartOrder): RedirectResponse
     {
+        // Observer автоматически обработает дефициты при смене статуса
         $sparePartOrder->update(['status' => SparePartOrder::STATUS_IN_STOCK]);
 
         return redirect()->route('spare_part_orders.show', $sparePartOrder)
             ->with(['success' => 'Статус изменён на "На складе"!']);
     }
+
+    /**
+     * Коррекция остатка (инвентаризация)
+     */
+    public function correct(Request $request, SparePartOrder $sparePartOrder): RedirectResponse
+    {
+        $validated = $request->validate([
+            'new_quantity' => 'required|integer|min:0',
+            'reason' => 'required|string|max:500',
+        ]);
+
+        try {
+            $this->issueService->correctInventory(
+                $sparePartOrder,
+                $validated['new_quantity'],
+                $validated['reason']
+            );
+
+            return redirect()->route('spare_part_orders.show', $sparePartOrder)
+                ->with(['success' => 'Коррекция выполнена!']);
+        } catch (\InvalidArgumentException $e) {
+            return redirect()->route('spare_part_orders.show', $sparePartOrder)
+                ->with(['error' => $e->getMessage()]);
+        }
+    }
 }

+ 134 - 0
app/Http/Controllers/SparePartReservationController.php

@@ -0,0 +1,134 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Models\Reservation;
+use App\Models\Shortage;
+use App\Services\SparePartIssueService;
+use App\Services\SparePartReservationService;
+use Illuminate\Http\JsonResponse;
+use Illuminate\Http\RedirectResponse;
+use Illuminate\Http\Request;
+
+/**
+ * Контроллер управления резервами запчастей
+ */
+class SparePartReservationController extends Controller
+{
+    public function __construct(
+        protected SparePartReservationService $reservationService,
+        protected SparePartIssueService $issueService
+    ) {}
+
+    /**
+     * Список резервов для рекламации (API)
+     */
+    public function forReclamation(int $reclamationId): JsonResponse
+    {
+        $reservations = $this->reservationService->getReservationsForReclamation($reclamationId);
+
+        return response()->json([
+            'reservations' => $reservations->map(fn($r) => [
+                'id' => $r->id,
+                'spare_part_id' => $r->spare_part_id,
+                'article' => $r->sparePart->article,
+                'used_in_maf' => $r->sparePart->used_in_maf,
+                'reserved_qty' => $r->reserved_qty,
+                'with_documents' => $r->with_documents,
+                'status' => $r->status,
+                'order_id' => $r->spare_part_order_id,
+                'created_at' => $r->created_at->format('d.m.Y H:i'),
+            ]),
+        ]);
+    }
+
+    /**
+     * Дефициты для рекламации (API)
+     */
+    public function shortagesForReclamation(int $reclamationId): JsonResponse
+    {
+        $shortages = Shortage::query()
+            ->where('reclamation_id', $reclamationId)
+            ->with('sparePart')
+            ->get();
+
+        return response()->json([
+            'shortages' => $shortages->map(fn($s) => [
+                'id' => $s->id,
+                'spare_part_id' => $s->spare_part_id,
+                'article' => $s->sparePart->article,
+                'used_in_maf' => $s->sparePart->used_in_maf,
+                'required_qty' => $s->required_qty,
+                'reserved_qty' => $s->reserved_qty,
+                'missing_qty' => $s->missing_qty,
+                'with_documents' => $s->with_documents,
+                'status' => $s->status,
+                'coverage_percent' => $s->coverage_percent,
+                'created_at' => $s->created_at->format('d.m.Y H:i'),
+            ]),
+        ]);
+    }
+
+    /**
+     * Отменить резерв
+     */
+    public function cancel(Request $request, Reservation $reservation): RedirectResponse
+    {
+        $reason = $request->input('reason', 'Отмена пользователем');
+
+        if (!$reservation->isActive()) {
+            return back()->with(['error' => 'Резерв не активен!']);
+        }
+
+        $this->reservationService->cancelReservation($reservation, $reason);
+
+        return back()->with(['success' => 'Резерв отменён!']);
+    }
+
+    /**
+     * Списать резерв (выполнить отгрузку)
+     */
+    public function issue(Request $request, Reservation $reservation): RedirectResponse
+    {
+        $note = $request->input('note', '');
+
+        if (!$reservation->isActive()) {
+            return back()->with(['error' => 'Резерв не активен!']);
+        }
+
+        try {
+            $this->issueService->issueReservation($reservation, $note);
+            return back()->with(['success' => 'Списание выполнено!']);
+        } catch (\Exception $e) {
+            return back()->with(['error' => $e->getMessage()]);
+        }
+    }
+
+    /**
+     * Списать все резервы для рекламации
+     */
+    public function issueAllForReclamation(Request $request, int $reclamationId): RedirectResponse
+    {
+        $results = $this->issueService->issueForReclamation($reclamationId);
+
+        $totalIssued = array_sum(array_map(fn($r) => $r->issued, $results));
+
+        return back()->with([
+            'success' => "Списано запчастей: {$totalIssued} шт."
+        ]);
+    }
+
+    /**
+     * Отменить все резервы для рекламации
+     */
+    public function cancelAllForReclamation(Request $request, int $reclamationId): RedirectResponse
+    {
+        $reason = $request->input('reason', 'Массовая отмена');
+
+        $totalCancelled = $this->reservationService->cancelForReclamation($reclamationId);
+
+        return back()->with([
+            'success' => "Отменено резервов на {$totalCancelled} шт."
+        ]);
+    }
+}

+ 155 - 0
app/Models/InventoryMovement.php

@@ -0,0 +1,155 @@
+<?php
+
+namespace App\Models;
+
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
+
+/**
+ * Движение запчастей — единственный механизм изменения остатков.
+ *
+ * Все операции фиксируются здесь для полного аудита:
+ * - receipt: поступление на склад
+ * - reserve: резервирование под рекламацию
+ * - issue: списание (отгрузка)
+ * - reserve_cancel: отмена резерва
+ * - correction: инвентаризационная коррекция
+ */
+class InventoryMovement extends Model
+{
+    // Типы движений
+    const TYPE_RECEIPT = 'receipt';
+    const TYPE_RESERVE = 'reserve';
+    const TYPE_ISSUE = 'issue';
+    const TYPE_RESERVE_CANCEL = 'reserve_cancel';
+    const TYPE_CORRECTION_PLUS = 'correction_plus';   // Увеличение при инвентаризации
+    const TYPE_CORRECTION_MINUS = 'correction_minus'; // Уменьшение при инвентаризации
+
+    /** @deprecated Используйте TYPE_CORRECTION_PLUS или TYPE_CORRECTION_MINUS */
+    const TYPE_CORRECTION = 'correction';
+
+    const TYPE_NAMES = [
+        self::TYPE_RECEIPT => 'Поступление',
+        self::TYPE_RESERVE => 'Резервирование',
+        self::TYPE_ISSUE => 'Списание',
+        self::TYPE_RESERVE_CANCEL => 'Отмена резерва',
+        self::TYPE_CORRECTION => 'Коррекция',
+        self::TYPE_CORRECTION_PLUS => 'Коррекция (+)',
+        self::TYPE_CORRECTION_MINUS => 'Коррекция (-)',
+    ];
+
+    // Типы источников
+    const SOURCE_ORDER = 'order';
+    const SOURCE_RECLAMATION = 'reclamation';
+    const SOURCE_MANUAL = 'manual';
+    const SOURCE_SHORTAGE_FULFILLMENT = 'shortage_fulfillment';
+    const SOURCE_INVENTORY = 'inventory';
+
+    protected $fillable = [
+        'spare_part_order_id',
+        'spare_part_id',
+        'qty',
+        'movement_type',
+        'source_type',
+        'source_id',
+        'with_documents',
+        'user_id',
+        'note',
+    ];
+
+    protected $casts = [
+        'qty' => 'integer',
+        'with_documents' => 'boolean',
+        'source_id' => 'integer',
+    ];
+
+    // Отношения
+    public function sparePartOrder(): BelongsTo
+    {
+        return $this->belongsTo(SparePartOrder::class);
+    }
+
+    public function sparePart(): BelongsTo
+    {
+        return $this->belongsTo(SparePart::class);
+    }
+
+    public function user(): BelongsTo
+    {
+        return $this->belongsTo(User::class);
+    }
+
+    // Аксессоры
+    public function getTypeNameAttribute(): string
+    {
+        return self::TYPE_NAMES[$this->movement_type] ?? $this->movement_type;
+    }
+
+    /**
+     * Получить источник движения (полиморфная связь)
+     */
+    public function getSourceAttribute(): ?Model
+    {
+        if (!$this->source_type || !$this->source_id) {
+            return null;
+        }
+
+        return match ($this->source_type) {
+            self::SOURCE_RECLAMATION => Reclamation::find($this->source_id),
+            self::SOURCE_SHORTAGE_FULFILLMENT => Shortage::find($this->source_id),
+            default => null,
+        };
+    }
+
+    // Scopes
+    public function scopeOfType($query, string $type)
+    {
+        return $query->where('movement_type', $type);
+    }
+
+    public function scopeForSparePart($query, int $sparePartId)
+    {
+        return $query->where('spare_part_id', $sparePartId);
+    }
+
+    public function scopeWithDocuments($query, bool $withDocs = true)
+    {
+        return $query->where('with_documents', $withDocs);
+    }
+
+    /**
+     * Движения, которые увеличивают доступный остаток
+     */
+    public function scopeIncreasing($query)
+    {
+        return $query->whereIn('movement_type', [
+            self::TYPE_RECEIPT,
+            self::TYPE_RESERVE_CANCEL,
+            self::TYPE_CORRECTION_PLUS,
+        ]);
+    }
+
+    /**
+     * Движения, которые уменьшают доступный остаток
+     */
+    public function scopeDecreasing($query)
+    {
+        return $query->whereIn('movement_type', [
+            self::TYPE_RESERVE,
+            self::TYPE_ISSUE,
+            self::TYPE_CORRECTION_MINUS,
+        ]);
+    }
+
+    /**
+     * Проверка типа коррекции
+     */
+    public function isCorrection(): bool
+    {
+        return in_array($this->movement_type, [
+            self::TYPE_CORRECTION,
+            self::TYPE_CORRECTION_PLUS,
+            self::TYPE_CORRECTION_MINUS,
+        ]);
+    }
+}

+ 33 - 1
app/Models/Reclamation.php

@@ -109,8 +109,40 @@ class Reclamation extends Model
     public function spareParts(): BelongsToMany
     {
         return $this->belongsToMany(SparePart::class, 'reclamation_spare_part')
-            ->withPivot('quantity', 'with_documents')
+            ->withPivot('quantity', 'with_documents', 'status', 'reserved_qty', 'issued_qty')
             ->withTimestamps();
     }
 
+    /**
+     * Резервы запчастей для этой рекламации
+     */
+    public function sparePartReservations(): HasMany
+    {
+        return $this->hasMany(Reservation::class);
+    }
+
+    /**
+     * Дефициты запчастей для этой рекламации
+     */
+    public function sparePartShortages(): HasMany
+    {
+        return $this->hasMany(Shortage::class);
+    }
+
+    /**
+     * Активные резервы запчастей
+     */
+    public function activeReservations(): HasMany
+    {
+        return $this->hasMany(Reservation::class)->where('status', Reservation::STATUS_ACTIVE);
+    }
+
+    /**
+     * Открытые дефициты запчастей
+     */
+    public function openShortages(): HasMany
+    {
+        return $this->hasMany(Shortage::class)->where('status', Shortage::STATUS_OPEN);
+    }
+
 }

+ 115 - 0
app/Models/Reservation.php

@@ -0,0 +1,115 @@
+<?php
+
+namespace App\Models;
+
+use App\Models\Scopes\YearScope;
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
+use Illuminate\Database\Eloquent\Relations\HasOne;
+
+/**
+ * Резерв запчасти под рекламацию.
+ *
+ * Резерв уменьшает свободный остаток, но НЕ физический.
+ * Физический остаток уменьшается только при списании (issue).
+ *
+ * Жизненный цикл:
+ * 1. active - создан при добавлении запчасти в рекламацию
+ * 2. issued - запчасть списана (отгружена)
+ * 3. cancelled - резерв отменён (запчасть удалена из рекламации)
+ */
+class Reservation extends Model
+{
+    const STATUS_ACTIVE = 'active';
+    const STATUS_ISSUED = 'issued';
+    const STATUS_CANCELLED = 'cancelled';
+
+    const STATUS_NAMES = [
+        self::STATUS_ACTIVE => 'Активен',
+        self::STATUS_ISSUED => 'Списано',
+        self::STATUS_CANCELLED => 'Отменён',
+    ];
+
+    protected $fillable = [
+        'spare_part_id',
+        'spare_part_order_id',
+        'reclamation_id',
+        'reserved_qty',
+        'with_documents',
+        'status',
+        'movement_id',
+    ];
+
+    protected $casts = [
+        'reserved_qty' => 'integer',
+        'with_documents' => 'boolean',
+    ];
+
+    // Отношения
+    public function sparePart(): BelongsTo
+    {
+        return $this->belongsTo(SparePart::class);
+    }
+
+    public function sparePartOrder(): BelongsTo
+    {
+        return $this->belongsTo(SparePartOrder::class)->withoutGlobalScope(YearScope::class);
+    }
+
+    public function reclamation(): BelongsTo
+    {
+        return $this->belongsTo(Reclamation::class);
+    }
+
+    public function movement(): BelongsTo
+    {
+        return $this->belongsTo(InventoryMovement::class, 'movement_id');
+    }
+
+    // Аксессоры
+    public function getStatusNameAttribute(): string
+    {
+        return self::STATUS_NAMES[$this->status] ?? $this->status;
+    }
+
+    public function isActive(): bool
+    {
+        return $this->status === self::STATUS_ACTIVE;
+    }
+
+    public function isIssued(): bool
+    {
+        return $this->status === self::STATUS_ISSUED;
+    }
+
+    public function isCancelled(): bool
+    {
+        return $this->status === self::STATUS_CANCELLED;
+    }
+
+    // Scopes
+    public function scopeActive($query)
+    {
+        return $query->where('status', self::STATUS_ACTIVE);
+    }
+
+    public function scopeForReclamation($query, int $reclamationId)
+    {
+        return $query->where('reclamation_id', $reclamationId);
+    }
+
+    public function scopeForSparePart($query, int $sparePartId)
+    {
+        return $query->where('spare_part_id', $sparePartId);
+    }
+
+    public function scopeWithDocuments($query, bool $withDocs = true)
+    {
+        return $query->where('with_documents', $withDocs);
+    }
+
+    public function scopeFromOrder($query, int $orderId)
+    {
+        return $query->where('spare_part_order_id', $orderId);
+    }
+}

+ 186 - 0
app/Models/Shortage.php

@@ -0,0 +1,186 @@
+<?php
+
+namespace App\Models;
+
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
+use Illuminate\Database\Eloquent\Relations\HasMany;
+
+/**
+ * Дефицит (нехватка) запчасти.
+ *
+ * Создаётся когда при резервировании недостаточно свободного остатка.
+ * НЕ влияет на физический остаток (вместо отрицательных значений).
+ *
+ * При поступлении новой партии система автоматически
+ * резервирует под открытые дефициты (FIFO по дате создания).
+ */
+class Shortage extends Model
+{
+    const STATUS_OPEN = 'open';
+    const STATUS_CLOSED = 'closed';
+
+    const STATUS_NAMES = [
+        self::STATUS_OPEN => 'Открыт',
+        self::STATUS_CLOSED => 'Закрыт',
+    ];
+
+    protected $fillable = [
+        'spare_part_id',
+        'reclamation_id',
+        'with_documents',
+        'required_qty',
+        'reserved_qty',
+        'missing_qty',
+        'status',
+        'note',
+    ];
+
+    protected $casts = [
+        'required_qty' => 'integer',
+        'reserved_qty' => 'integer',
+        'missing_qty' => 'integer',
+        'with_documents' => 'boolean',
+    ];
+
+    // Отношения
+    public function sparePart(): BelongsTo
+    {
+        return $this->belongsTo(SparePart::class);
+    }
+
+    public function reclamation(): BelongsTo
+    {
+        return $this->belongsTo(Reclamation::class);
+    }
+
+    /**
+     * Получить резервы, связанные с этим дефицитом
+     *
+     * Примечание: Не использовать для eager loading!
+     * Для получения резервов используйте метод getRelatedReservations()
+     */
+    public function getRelatedReservations(): \Illuminate\Database\Eloquent\Collection
+    {
+        return Reservation::query()
+            ->where('reclamation_id', $this->reclamation_id)
+            ->where('spare_part_id', $this->spare_part_id)
+            ->where('with_documents', $this->with_documents)
+            ->get();
+    }
+
+    /**
+     * Резервы для той же рекламации (для базовой связи)
+     */
+    public function reclamationReservations(): HasMany
+    {
+        return $this->hasMany(Reservation::class, 'reclamation_id', 'reclamation_id');
+    }
+
+    // Аксессоры
+    public function getStatusNameAttribute(): string
+    {
+        return self::STATUS_NAMES[$this->status] ?? $this->status;
+    }
+
+    public function isOpen(): bool
+    {
+        return $this->status === self::STATUS_OPEN;
+    }
+
+    public function isClosed(): bool
+    {
+        return $this->status === self::STATUS_CLOSED;
+    }
+
+    /**
+     * Процент покрытия дефицита
+     */
+    public function getCoveragePercentAttribute(): float
+    {
+        if ($this->required_qty === 0) {
+            return 100;
+        }
+        return round(($this->reserved_qty / $this->required_qty) * 100, 1);
+    }
+
+    // Scopes
+    public function scopeOpen($query)
+    {
+        return $query->where('status', self::STATUS_OPEN);
+    }
+
+    public function scopeClosed($query)
+    {
+        return $query->where('status', self::STATUS_CLOSED);
+    }
+
+    public function scopeForSparePart($query, int $sparePartId)
+    {
+        return $query->where('spare_part_id', $sparePartId);
+    }
+
+    public function scopeWithDocuments($query, bool $withDocs = true)
+    {
+        return $query->where('with_documents', $withDocs);
+    }
+
+    public function scopeForReclamation($query, int $reclamationId)
+    {
+        return $query->where('reclamation_id', $reclamationId);
+    }
+
+    /**
+     * Дефициты с недостачей (для автозакрытия при поступлении)
+     */
+    public function scopeWithMissing($query)
+    {
+        return $query->where('missing_qty', '>', 0);
+    }
+
+    /**
+     * FIFO сортировка для обработки
+     */
+    public function scopeOldestFirst($query)
+    {
+        return $query->orderBy('created_at', 'asc');
+    }
+
+    // Методы
+    /**
+     * Добавить резерв к дефициту (при поступлении партии)
+     */
+    public function addReserved(int $qty): void
+    {
+        $this->reserved_qty += $qty;
+        $this->missing_qty = max(0, $this->required_qty - $this->reserved_qty);
+
+        if ($this->missing_qty === 0) {
+            $this->status = self::STATUS_CLOSED;
+        }
+
+        $this->save();
+    }
+
+    /**
+     * Пересчитать дефицит на основе резервов
+     */
+    public function recalculate(): void
+    {
+        $totalReserved = Reservation::query()
+            ->where('reclamation_id', $this->reclamation_id)
+            ->where('spare_part_id', $this->spare_part_id)
+            ->where('with_documents', $this->with_documents)
+            ->whereIn('status', [Reservation::STATUS_ACTIVE, Reservation::STATUS_ISSUED])
+            ->sum('reserved_qty');
+
+        $this->reserved_qty = $totalReserved;
+        $this->missing_qty = max(0, $this->required_qty - $this->reserved_qty);
+
+        if ($this->missing_qty === 0) {
+            $this->status = self::STATUS_CLOSED;
+        }
+
+        $this->save();
+    }
+}

+ 194 - 22
app/Models/SparePart.php

@@ -10,6 +10,16 @@ use Illuminate\Database\Eloquent\Relations\BelongsToMany;
 use Illuminate\Database\Eloquent\Relations\HasMany;
 use Illuminate\Database\Eloquent\SoftDeletes;
 
+/**
+ * Каталог запчастей — СПРАВОЧНИК.
+ *
+ * ВАЖНО: Эта модель НЕ хранит остатки!
+ * Остатки рассчитываются на основе:
+ * - SparePartOrder.available_qty (физический остаток)
+ * - Reservation (активные резервы)
+ *
+ * Свободный остаток = Физический - Зарезервировано
+ */
 class SparePart extends Model
 {
     use SoftDeletes;
@@ -29,6 +39,16 @@ class SparePart extends Model
         'min_stock',
     ];
 
+    /**
+     * Атрибуты для автоматической сериализации.
+     *
+     * ВАЖНО: Подробные остатки (physical_stock_*, reserved_*, free_stock_*) НЕ включены сюда,
+     * т.к. согласно спецификации каталог не хранит остатки.
+     * Для получения детальных остатков используйте SparePartInventoryService::getStockInfo()
+     * или явно вызывайте соответствующие аксессоры.
+     *
+     * Атрибуты quantity_* оставлены для обратной совместимости с UI.
+     */
     protected $appends = [
         'purchase_price',
         'customer_price',
@@ -37,12 +57,13 @@ class SparePart extends Model
         'customer_price_txt',
         'expertise_price_txt',
         'image',
-        'quantity_without_docs',
-        'quantity_with_docs',
-        'total_quantity',
         'tsn_number_description',
         'pricing_code_description',
         'pricing_codes_list',
+        // Обратная совместимость для UI (отображаются как свободный остаток)
+        'quantity_without_docs',
+        'quantity_with_docs',
+        'total_quantity',
     ];
 
     // Аксессоры для цен (копейки -> рубли)
@@ -108,7 +129,8 @@ class SparePart extends Model
         return Attribute::make(get: fn() => $path);
     }
 
-    // Отношения
+    // ========== ОТНОШЕНИЯ ==========
+
     public function product(): BelongsTo
     {
         return $this->belongsTo(Product::class);
@@ -122,7 +144,7 @@ class SparePart extends Model
     public function reclamations(): BelongsToMany
     {
         return $this->belongsToMany(Reclamation::class, 'reclamation_spare_part')
-            ->withPivot('quantity', 'with_documents')
+            ->withPivot('quantity', 'with_documents', 'status', 'reserved_qty', 'issued_qty')
             ->withTimestamps();
     }
 
@@ -132,46 +154,164 @@ class SparePart extends Model
             ->withTimestamps();
     }
 
-    // ВЫЧИСЛЯЕМЫЕ ПОЛЯ (с учетом текущего года!)
-    // Примечание: YearScope автоматически применяется к SparePartOrder,
-    // поэтому явный фильтр по году НЕ нужен
-    public function getQuantityWithoutDocsAttribute(): int
+    public function reservations(): HasMany
+    {
+        return $this->hasMany(Reservation::class);
+    }
+
+    public function shortages(): HasMany
+    {
+        return $this->hasMany(Shortage::class);
+    }
+
+    public function movements(): HasMany
+    {
+        return $this->hasMany(InventoryMovement::class);
+    }
+
+    // ========== ВЫЧИСЛЯЕМЫЕ ПОЛЯ ОСТАТКОВ ==========
+    // Примечание: YearScope автоматически применяется к SparePartOrder
+
+    /**
+     * Физический остаток БЕЗ документов
+     */
+    public function getPhysicalStockWithoutDocsAttribute(): int
     {
         return (int) ($this->orders()
             ->where('status', SparePartOrder::STATUS_IN_STOCK)
             ->where('with_documents', false)
-            ->sum('remaining_quantity') ?? 0);
+            ->sum('available_qty') ?? 0);
     }
 
-    public function getQuantityWithDocsAttribute(): int
+    /**
+     * Физический остаток С документами
+     */
+    public function getPhysicalStockWithDocsAttribute(): int
     {
         return (int) ($this->orders()
             ->where('status', SparePartOrder::STATUS_IN_STOCK)
             ->where('with_documents', true)
-            ->sum('remaining_quantity') ?? 0);
+            ->sum('available_qty') ?? 0);
+    }
+
+    /**
+     * Зарезервировано БЕЗ документов
+     */
+    public function getReservedWithoutDocsAttribute(): int
+    {
+        return (int) (Reservation::query()
+            ->where('spare_part_id', $this->id)
+            ->where('with_documents', false)
+            ->where('status', Reservation::STATUS_ACTIVE)
+            ->sum('reserved_qty') ?? 0);
+    }
+
+    /**
+     * Зарезервировано С документами
+     */
+    public function getReservedWithDocsAttribute(): int
+    {
+        return (int) (Reservation::query()
+            ->where('spare_part_id', $this->id)
+            ->where('with_documents', true)
+            ->where('status', Reservation::STATUS_ACTIVE)
+            ->sum('reserved_qty') ?? 0);
+    }
+
+    /**
+     * Свободный остаток БЕЗ документов
+     */
+    public function getFreeStockWithoutDocsAttribute(): int
+    {
+        return $this->physical_stock_without_docs - $this->reserved_without_docs;
+    }
+
+    /**
+     * Свободный остаток С документами
+     */
+    public function getFreeStockWithDocsAttribute(): int
+    {
+        return $this->physical_stock_with_docs - $this->reserved_with_docs;
+    }
+
+    /**
+     * Общий физический остаток
+     */
+    public function getTotalPhysicalStockAttribute(): int
+    {
+        return $this->physical_stock_without_docs + $this->physical_stock_with_docs;
+    }
+
+    /**
+     * Общее зарезервировано
+     */
+    public function getTotalReservedAttribute(): int
+    {
+        return $this->reserved_without_docs + $this->reserved_with_docs;
+    }
+
+    /**
+     * Общий свободный остаток
+     */
+    public function getTotalFreeStockAttribute(): int
+    {
+        return $this->total_physical_stock - $this->total_reserved;
+    }
+
+    // ========== ОБРАТНАЯ СОВМЕСТИМОСТЬ ==========
+    // Старые атрибуты для совместимости с существующим кодом
+
+    public function getQuantityWithoutDocsAttribute(): int
+    {
+        return $this->free_stock_without_docs;
+    }
+
+    public function getQuantityWithDocsAttribute(): int
+    {
+        return $this->free_stock_with_docs;
     }
 
     public function getTotalQuantityAttribute(): int
     {
-        // Общее количество — сумма всех остатков на складе за текущий год,
-        // независимо от наличия документов
-        return (int) ($this->orders()
-            ->where('status', SparePartOrder::STATUS_IN_STOCK)
-            ->sum('remaining_quantity') ?? 0);
+        return $this->total_free_stock;
     }
 
-    // Методы проверки
-    public function hasCriticalShortage(): bool
+    // ========== МЕТОДЫ ПРОВЕРКИ ==========
+
+    /**
+     * Есть ли открытые дефициты?
+     */
+    public function hasOpenShortages(): bool
     {
-        return $this->quantity_without_docs < 0 || $this->quantity_with_docs < 0;
+        return $this->shortages()->open()->exists();
     }
 
+    /**
+     * Количество в открытых дефицитах
+     */
+    public function getOpenShortagesQtyAttribute(): int
+    {
+        return (int) ($this->shortages()->open()->sum('missing_qty') ?? 0);
+    }
+
+    /**
+     * Ниже минимального остатка?
+     */
     public function isBelowMinStock(): bool
     {
-        return $this->total_quantity < $this->min_stock && $this->total_quantity >= 0;
+        return $this->total_free_stock < $this->min_stock;
     }
 
-    // Расшифровки из справочника
+    /**
+     * @deprecated Используйте hasOpenShortages()
+     */
+    public function hasCriticalShortage(): bool
+    {
+        return $this->hasOpenShortages();
+    }
+
+    // ========== РАСШИФРОВКИ ==========
+
     protected function tsnNumberDescription(): Attribute
     {
         return Attribute::make(
@@ -196,4 +336,36 @@ class SparePart extends Model
     {
         return $this->pricingCodes->pluck('code')->implode(', ');
     }
+
+    // ========== ВСПОМОГАТЕЛЬНЫЕ МЕТОДЫ ==========
+
+    /**
+     * Получить свободный остаток для конкретного типа документов
+     */
+    public function getFreeStock(bool $withDocuments): int
+    {
+        return $withDocuments
+            ? $this->free_stock_with_docs
+            : $this->free_stock_without_docs;
+    }
+
+    /**
+     * Получить физический остаток для конкретного типа документов
+     */
+    public function getPhysicalStock(bool $withDocuments): int
+    {
+        return $withDocuments
+            ? $this->physical_stock_with_docs
+            : $this->physical_stock_without_docs;
+    }
+
+    /**
+     * Получить зарезервировано для конкретного типа документов
+     */
+    public function getReserved(bool $withDocuments): int
+    {
+        return $withDocuments
+            ? $this->reserved_with_docs
+            : $this->reserved_without_docs;
+    }
 }

+ 151 - 26
app/Models/SparePartOrder.php

@@ -10,6 +10,17 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
 use Illuminate\Database\Eloquent\Relations\MorphTo;
 use Illuminate\Database\Eloquent\SoftDeletes;
 
+/**
+ * Партия запчастей (заказ/поступление).
+ *
+ * Представляет физическое поступление запчастей на склад.
+ * Поле available_qty всегда >= 0 (enforced CHECK constraint).
+ *
+ * Жизненный цикл:
+ * 1. ordered - заказано у поставщика
+ * 2. in_stock - получено на склад
+ * 3. shipped - полностью отгружено (available_qty = 0)
+ */
 #[ScopedBy([YearScope::class])]
 class SparePartOrder extends Model
 {
@@ -35,7 +46,7 @@ class SparePartOrder extends Model
         'sourceable_type',
         'status',
         'ordered_quantity',
-        'remaining_quantity',
+        'available_qty',
         'with_documents',
         'note',
         'user_id',
@@ -44,7 +55,7 @@ class SparePartOrder extends Model
     protected $casts = [
         'with_documents' => 'boolean',
         'ordered_quantity' => 'integer',
-        'remaining_quantity' => 'integer',
+        'available_qty' => 'integer',
         'year' => 'integer',
     ];
 
@@ -56,20 +67,21 @@ class SparePartOrder extends Model
             if (!isset($model->year)) {
                 $model->year = year();
             }
-            if (!isset($model->remaining_quantity)) {
-                $model->remaining_quantity = $model->ordered_quantity;
+            if (!isset($model->available_qty)) {
+                $model->available_qty = $model->ordered_quantity;
             }
         });
 
-        // Автосмена статуса
+        // Автосмена статуса при полной отгрузке
         static::updating(function ($model) {
-            if ($model->remaining_quantity === 0 && $model->status !== self::STATUS_SHIPPED) {
+            if ($model->available_qty === 0 && $model->status === self::STATUS_IN_STOCK) {
                 $model->status = self::STATUS_SHIPPED;
             }
         });
     }
 
-    // Отношения
+    // ========== ОТНОШЕНИЯ ==========
+
     public function sparePart(): BelongsTo
     {
         return $this->belongsTo(SparePart::class);
@@ -85,16 +97,40 @@ class SparePartOrder extends Model
         return $this->morphTo();
     }
 
+    /**
+     * Резервы из этой партии
+     */
+    public function reservations(): HasMany
+    {
+        return $this->hasMany(Reservation::class, 'spare_part_order_id');
+    }
+
+    /**
+     * Движения по этой партии
+     */
+    public function movements(): HasMany
+    {
+        return $this->hasMany(InventoryMovement::class, 'spare_part_order_id');
+    }
+
+    /**
+     * @deprecated Используйте movements()
+     */
     public function shipments(): HasMany
     {
         return $this->hasMany(SparePartOrderShipment::class);
     }
 
-    // Scopes
+    // ========== SCOPES ==========
+
     public function scopeInStock($query)
     {
-        return $query->where('status', self::STATUS_IN_STOCK)
-            ->where('remaining_quantity', '>', 0);
+        return $query->where('status', self::STATUS_IN_STOCK);
+    }
+
+    public function scopeWithAvailable($query)
+    {
+        return $query->where('available_qty', '>', 0);
     }
 
     public function scopeWithDocuments($query, bool $withDocs = true)
@@ -102,33 +138,122 @@ class SparePartOrder extends Model
         return $query->where('with_documents', $withDocs);
     }
 
-    // Метод списания
+    public function scopeForSparePart($query, int $sparePartId)
+    {
+        return $query->where('spare_part_id', $sparePartId);
+    }
+
+    /**
+     * Партии доступные для резервирования (FIFO)
+     */
+    public function scopeAvailableForReservation($query, int $sparePartId, bool $withDocuments)
+    {
+        return $query->where('spare_part_id', $sparePartId)
+            ->where('with_documents', $withDocuments)
+            ->where('status', self::STATUS_IN_STOCK)
+            ->where('available_qty', '>', 0)
+            ->orderBy('created_at', 'asc');
+    }
+
+    // ========== ВЫЧИСЛЯЕМЫЕ ПОЛЯ ==========
+
+    public function getStatusNameAttribute(): string
+    {
+        return self::STATUS_NAMES[$this->status] ?? $this->status;
+    }
+
+    /**
+     * Сколько зарезервировано из этой партии
+     */
+    public function getReservedQtyAttribute(): int
+    {
+        return (int) ($this->reservations()
+            ->where('status', Reservation::STATUS_ACTIVE)
+            ->sum('reserved_qty') ?? 0);
+    }
+
+    /**
+     * Свободно для резервирования (физический остаток минус активные резервы)
+     */
+    public function getFreeQtyAttribute(): int
+    {
+        return max(0, $this->available_qty - $this->reserved_qty);
+    }
+
+    /**
+     * Сколько было списано (через движения issue)
+     */
+    public function getIssuedQtyAttribute(): int
+    {
+        return (int) ($this->movements()
+            ->where('movement_type', InventoryMovement::TYPE_ISSUE)
+            ->sum('qty') ?? 0);
+    }
+
+    // ========== МЕТОДЫ ==========
+
+    /**
+     * Можно ли зарезервировать указанное количество?
+     */
+    public function canReserve(int $quantity): bool
+    {
+        return $this->free_qty >= $quantity;
+    }
+
+    /**
+     * Проверка статуса
+     */
+    public function isInStock(): bool
+    {
+        return $this->status === self::STATUS_IN_STOCK;
+    }
+
+    public function isOrdered(): bool
+    {
+        return $this->status === self::STATUS_ORDERED;
+    }
+
+    public function isShipped(): bool
+    {
+        return $this->status === self::STATUS_SHIPPED;
+    }
+
+    // ========== ОБРАТНАЯ СОВМЕСТИМОСТЬ ==========
+
+    /**
+     * @deprecated Поле переименовано в available_qty
+     */
+    public function getRemainingQuantityAttribute(): int
+    {
+        return $this->available_qty;
+    }
+
+    /**
+     * @deprecated Используйте SparePartIssueService::issue()
+     */
     public function shipQuantity(int $quantity, string $note, ?int $reclamationId = null, ?int $userId = null): bool
     {
-        if ($quantity > $this->remaining_quantity) {
+        // Оставляем для обратной совместимости, но рекомендуется использовать сервис
+        if ($quantity > $this->available_qty) {
             return false;
         }
 
-        // Уменьшаем остаток
-        $this->remaining_quantity -= $quantity;
+        $this->available_qty -= $quantity;
         $this->save();
 
-        // Создаем запись в истории
-        SparePartOrderShipment::create([
+        // Создаём движение для аудита
+        InventoryMovement::create([
             'spare_part_order_id' => $this->id,
-            'quantity' => $quantity,
-            'note' => $note,
-            'reclamation_id' => $reclamationId,
+            'spare_part_id' => $this->spare_part_id,
+            'qty' => $quantity,
+            'movement_type' => InventoryMovement::TYPE_ISSUE,
+            'source_type' => $reclamationId ? InventoryMovement::SOURCE_RECLAMATION : InventoryMovement::SOURCE_MANUAL,
+            'source_id' => $reclamationId,
+            'with_documents' => $this->with_documents,
             'user_id' => $userId ?? auth()->id(),
-            'is_automatic' => $reclamationId !== null,
+            'note' => $note,
         ]);
 
         return true;
     }
-
-    // Получить имя статуса
-    public function getStatusNameAttribute(): string
-    {
-        return self::STATUS_NAMES[$this->status] ?? $this->status;
-    }
 }

+ 9 - 1
app/Models/SparePartOrdersView.php

@@ -15,7 +15,15 @@ class SparePartOrdersView extends Model
     protected $casts = [
         'with_documents' => 'boolean',
         'ordered_quantity' => 'integer',
-        'remaining_quantity' => 'integer',
+        'available_qty' => 'integer',
         'year' => 'integer',
     ];
+
+    /**
+     * @deprecated Используйте available_qty
+     */
+    public function getRemainingQuantityAttribute(): int
+    {
+        return $this->available_qty ?? 0;
+    }
 }

+ 41 - 0
app/Observers/SparePartOrderObserver.php

@@ -0,0 +1,41 @@
+<?php
+
+namespace App\Observers;
+
+use App\Models\SparePartOrder;
+use App\Services\ShortageService;
+
+/**
+ * Observer для автоматической обработки дефицитов при изменении статуса партии.
+ *
+ * Согласно спецификации п.5: "При создании PartOrder система ищет открытые Shortage
+ * по part_id + with_documents и резервирует под них."
+ */
+class SparePartOrderObserver
+{
+    public function __construct(
+        private readonly ShortageService $shortageService
+    ) {}
+
+    /**
+     * При обновлении партии — проверяем смену статуса на in_stock
+     */
+    public function updated(SparePartOrder $order): void
+    {
+        // Проверяем, изменился ли статус на in_stock
+        if ($order->wasChanged('status') &&
+            $order->status === SparePartOrder::STATUS_IN_STOCK) {
+            $this->shortageService->processPartOrderReceipt($order);
+        }
+    }
+
+    /**
+     * При создании партии со статусом in_stock — сразу обрабатываем дефициты
+     */
+    public function created(SparePartOrder $order): void
+    {
+        if ($order->status === SparePartOrder::STATUS_IN_STOCK) {
+            $this->shortageService->processPartOrderReceipt($order);
+        }
+    }
+}

+ 5 - 0
app/Providers/AppServiceProvider.php

@@ -2,6 +2,8 @@
 
 namespace App\Providers;
 
+use App\Models\SparePartOrder;
+use App\Observers\SparePartOrderObserver;
 use Illuminate\Pagination\Paginator;
 use Illuminate\Support\Facades\URL;
 use Illuminate\Support\ServiceProvider;
@@ -25,5 +27,8 @@ class AppServiceProvider extends ServiceProvider
         if(config('app.force_https')) {
             URL::forceScheme('https');
         }
+
+        // Регистрация Observer для автоматической обработки дефицитов
+        SparePartOrder::observe(SparePartOrderObserver::class);
     }
 }

+ 220 - 0
app/Services/ShortageService.php

@@ -0,0 +1,220 @@
+<?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;
+
+/**
+ * Сервис управления дефицитами запчастей.
+ *
+ * Дефицит создаётся когда при резервировании недостаточно свободного остатка.
+ * При поступлении новой партии система автоматически резервирует под открытые дефициты.
+ */
+class ShortageService
+{
+    /**
+     * Обработать поступление партии — закрыть дефициты
+     *
+     * Вызывается при изменении статуса партии на in_stock.
+     * Автоматически резервирует под открытые дефициты (FIFO по дате).
+     *
+     * @param SparePartOrder $order Партия
+     * @return array Информация о закрытых дефицитах
+     */
+    public function processPartOrderReceipt(SparePartOrder $order): array
+    {
+        if ($order->status !== SparePartOrder::STATUS_IN_STOCK) {
+            return [];
+        }
+
+        return DB::transaction(function () use ($order) {
+            $results = [];
+
+            // Поиск открытых дефицитов для этой запчасти
+            $shortages = Shortage::query()
+                ->where('spare_part_id', $order->spare_part_id)
+                ->where('with_documents', $order->with_documents)
+                ->where('status', Shortage::STATUS_OPEN)
+                ->where('missing_qty', '>', 0)
+                ->orderBy('created_at', 'asc') // FIFO по дате дефицита
+                ->lockForUpdate()
+                ->get();
+
+            if ($shortages->isEmpty()) {
+                return [];
+            }
+
+            // Сколько свободно в партии (с учётом уже созданных резервов)
+            $alreadyReserved = Reservation::query()
+                ->where('spare_part_order_id', $order->id)
+                ->where('status', Reservation::STATUS_ACTIVE)
+                ->sum('reserved_qty');
+
+            $availableQty = $order->available_qty - $alreadyReserved;
+
+            foreach ($shortages as $shortage) {
+                if ($availableQty <= 0) break;
+
+                // Сколько можем покрыть
+                $toCover = min($availableQty, $shortage->missing_qty);
+
+                // Создаём движение резерва
+                $movement = InventoryMovement::create([
+                    'spare_part_order_id' => $order->id,
+                    'spare_part_id' => $order->spare_part_id,
+                    'qty' => $toCover,
+                    'movement_type' => InventoryMovement::TYPE_RESERVE,
+                    'source_type' => InventoryMovement::SOURCE_SHORTAGE_FULFILLMENT,
+                    'source_id' => $shortage->id,
+                    'with_documents' => $order->with_documents,
+                    'user_id' => auth()->id(),
+                    'note' => "Автоматическое резервирование при поступлении для закрытия дефицита #{$shortage->id}",
+                ]);
+
+                // Создаём резерв под дефицит
+                $reservation = Reservation::create([
+                    'spare_part_id' => $order->spare_part_id,
+                    'spare_part_order_id' => $order->id,
+                    'reclamation_id' => $shortage->reclamation_id,
+                    'reserved_qty' => $toCover,
+                    'with_documents' => $order->with_documents,
+                    'status' => Reservation::STATUS_ACTIVE,
+                    'movement_id' => $movement->id,
+                ]);
+
+                // Обновляем дефицит
+                $shortage->addReserved($toCover);
+
+                $results[] = [
+                    'shortage_id' => $shortage->id,
+                    'reclamation_id' => $shortage->reclamation_id,
+                    'covered_qty' => $toCover,
+                    'remaining_missing' => $shortage->missing_qty,
+                    'shortage_closed' => $shortage->isClosed(),
+                    'reservation_id' => $reservation->id,
+                ];
+
+                $availableQty -= $toCover;
+            }
+
+            return $results;
+        });
+    }
+
+    /**
+     * Получить все открытые дефициты
+     */
+    public function getOpenShortages(): Collection
+    {
+        return Shortage::query()
+            ->where('status', Shortage::STATUS_OPEN)
+            ->with(['sparePart', 'reclamation'])
+            ->orderBy('created_at', 'asc')
+            ->get();
+    }
+
+    /**
+     * Получить открытые дефициты для конкретной запчасти
+     */
+    public function getShortagesForSparePart(int $sparePartId, ?bool $withDocuments = null): Collection
+    {
+        $query = Shortage::query()
+            ->where('spare_part_id', $sparePartId)
+            ->where('status', Shortage::STATUS_OPEN);
+
+        if ($withDocuments !== null) {
+            $query->where('with_documents', $withDocuments);
+        }
+
+        return $query->orderBy('created_at', 'asc')->get();
+    }
+
+    /**
+     * Получить сумму недостачи по запчасти
+     */
+    public function getTotalMissing(int $sparePartId, ?bool $withDocuments = null): int
+    {
+        $query = Shortage::query()
+            ->where('spare_part_id', $sparePartId)
+            ->where('status', Shortage::STATUS_OPEN);
+
+        if ($withDocuments !== null) {
+            $query->where('with_documents', $withDocuments);
+        }
+
+        return (int) $query->sum('missing_qty');
+    }
+
+    /**
+     * Получить запчасти с критическими дефицитами (для контроля наличия)
+     */
+    public function getCriticalShortages(): Collection
+    {
+        return SparePart::query()
+            ->whereHas('shortages', function ($query) {
+                $query->where('status', Shortage::STATUS_OPEN)
+                    ->where('missing_qty', '>', 0);
+            })
+            ->with(['shortages' => function ($query) {
+                $query->where('status', Shortage::STATUS_OPEN);
+            }])
+            ->get()
+            ->map(function ($sparePart) {
+                $sparePart->shortage_details = $sparePart->shortages->map(function ($shortage) {
+                    return [
+                        'reclamation_id' => $shortage->reclamation_id,
+                        'with_documents' => $shortage->with_documents,
+                        'missing_qty' => $shortage->missing_qty,
+                        'created_at' => $shortage->created_at,
+                    ];
+                });
+                return $sparePart;
+            });
+    }
+
+    /**
+     * Закрыть дефицит вручную (например, при отмене рекламации)
+     */
+    public function closeShortage(Shortage $shortage, string $reason = ''): bool
+    {
+        if ($shortage->isClosed()) {
+            return false;
+        }
+
+        $shortage->status = Shortage::STATUS_CLOSED;
+        $shortage->note = ($shortage->note ? $shortage->note . "\n" : '') .
+            "Закрыто вручную: " . ($reason ?: 'без указания причины');
+        $shortage->save();
+
+        return true;
+    }
+
+    /**
+     * Пересчитать все дефициты для рекламации
+     */
+    public function recalculateForReclamation(int $reclamationId): void
+    {
+        $shortages = Shortage::query()
+            ->where('reclamation_id', $reclamationId)
+            ->where('status', Shortage::STATUS_OPEN)
+            ->get();
+
+        foreach ($shortages as $shortage) {
+            $shortage->recalculate();
+        }
+    }
+
+    /**
+     * Рассчитать сколько нужно заказать для покрытия всех дефицитов
+     */
+    public function calculateOrderQuantity(int $sparePartId, bool $withDocuments): int
+    {
+        return $this->getTotalMissing($sparePartId, $withDocuments);
+    }
+}

+ 154 - 40
app/Services/SparePartInventoryService.php

@@ -2,64 +2,78 @@
 
 namespace App\Services;
 
+use App\Models\Reservation;
+use App\Models\Shortage;
 use App\Models\SparePart;
 use App\Models\SparePartOrder;
 use Illuminate\Support\Collection;
 
+/**
+ * Сервис контроля наличия запчастей.
+ *
+ * Предоставляет методы для:
+ * - Получения критических дефицитов
+ * - Получения запчастей ниже минимального остатка
+ * - Расчёта рекомендованного количества для заказа
+ *
+ * ВАЖНО: Для резервирования и списания используйте
+ * SparePartReservationService и SparePartIssueService.
+ */
 class SparePartInventoryService
 {
+    public function __construct(
+        private readonly SparePartReservationService $reservationService,
+        private readonly ShortageService $shortageService
+    ) {}
+
     /**
-     * Автоматическое списание запчасти для рекламации
+     * Зарезервировать запчасть для рекламации
+     *
+     * @deprecated Используйте SparePartReservationService::reserve()
      */
-    public function deductForReclamation(
+    public function reserveForReclamation(
         string $article,
         int $quantity,
         bool $withDocuments,
         int $reclamationId
-    ): bool {
+    ): ReservationResult {
         $sparePart = SparePart::where('article', $article)->first();
         if (!$sparePart) {
-            return false;
+            return new ReservationResult(
+                reserved: 0,
+                missing: $quantity,
+                reservations: collect(),
+                shortage: null
+            );
         }
 
-        // Ищем заказ для списания (FIFO - первый созданный)
-        $order = SparePartOrder::where('spare_part_id', $sparePart->id)
-            ->where('status', SparePartOrder::STATUS_IN_STOCK)
-            ->where('with_documents', $withDocuments)
-            ->where('remaining_quantity', '>=', $quantity)
-            ->orderBy('created_at', 'asc')
-            ->first();
-
-        if ($order) {
-            // Списываем
-            $note = "Автоматическое списание для рекламации: #{$reclamationId}";
-            return $order->shipQuantity($quantity, $note, $reclamationId, null);
-        } else {
-            // Создаём виртуальный заказ с недостачей
-            // Статус IN_STOCK чтобы учитывалось в вычисляемых полях!
-            SparePartOrder::create([
-                'spare_part_id' => $sparePart->id,
-                'source_text' => "Автоспискание для рекламации #{$reclamationId}",
-                'status' => SparePartOrder::STATUS_IN_STOCK,
-                'ordered_quantity' => 0,
-                'remaining_quantity' => -$quantity,
-                'with_documents' => $withDocuments,
-                'note' => "Недостача, созданная автоматически",
-                'user_id' => auth()->id() ?? 1,
-            ]);
-
-            return false;
-        }
+        return $this->reservationService->reserve(
+            $sparePart->id,
+            $quantity,
+            $withDocuments,
+            $reclamationId
+        );
+    }
+
+    /**
+     * @deprecated Используйте SparePartReservationService::reserve()
+     */
+    public function deductForReclamation(
+        string $article,
+        int $quantity,
+        bool $withDocuments,
+        int $reclamationId
+    ): bool {
+        $result = $this->reserveForReclamation($article, $quantity, $withDocuments, $reclamationId);
+        return $result->reserved > 0;
     }
 
     /**
-     * Получить список запчастей с критическим недостатком (отрицательное количество)
+     * Получить список запчастей с критическим дефицитом (открытые дефициты)
      */
     public function getCriticalShortages(): Collection
     {
-        return SparePart::all()->filter(function ($sparePart) {
-            return $sparePart->hasCriticalShortage();
-        });
+        return $this->shortageService->getCriticalShortages();
     }
 
     /**
@@ -73,16 +87,116 @@ class SparePartInventoryService
     }
 
     /**
-     * Рассчитать сколько нужно заказать для компенсации недостачи
+     * Рассчитать сколько нужно заказать для покрытия дефицита
      */
     public function calculateOrderQuantity(SparePart $sparePart, bool $withDocuments): int
     {
-        $quantity = $withDocuments ? $sparePart->quantity_with_docs : $sparePart->quantity_without_docs;
+        return $this->shortageService->calculateOrderQuantity($sparePart->id, $withDocuments);
+    }
 
-        if ($quantity >= 0) {
+    /**
+     * Рассчитать сколько нужно заказать для достижения минимального остатка
+     */
+    public function calculateMinStockOrderQuantity(SparePart $sparePart): int
+    {
+        $freeStock = $sparePart->total_free_stock;
+        $minStock = $sparePart->min_stock;
+
+        if ($freeStock >= $minStock) {
             return 0;
         }
 
-        return abs($quantity);
+        return $minStock - $freeStock;
+    }
+
+    /**
+     * Получить полную информацию по остаткам запчасти
+     */
+    public function getStockInfo(SparePart $sparePart): array
+    {
+        return [
+            'spare_part_id' => $sparePart->id,
+            'article' => $sparePart->article,
+
+            // Физические остатки
+            'physical_without_docs' => $sparePart->physical_stock_without_docs,
+            'physical_with_docs' => $sparePart->physical_stock_with_docs,
+            'physical_total' => $sparePart->total_physical_stock,
+
+            // Зарезервировано
+            'reserved_without_docs' => $sparePart->reserved_without_docs,
+            'reserved_with_docs' => $sparePart->reserved_with_docs,
+            'reserved_total' => $sparePart->total_reserved,
+
+            // Свободно
+            'free_without_docs' => $sparePart->free_stock_without_docs,
+            'free_with_docs' => $sparePart->free_stock_with_docs,
+            'free_total' => $sparePart->total_free_stock,
+
+            // Дефициты
+            'has_shortages' => $sparePart->hasOpenShortages(),
+            'shortage_qty' => $sparePart->open_shortages_qty,
+
+            // Минимальный остаток
+            'min_stock' => $sparePart->min_stock,
+            'below_min_stock' => $sparePart->isBelowMinStock(),
+
+            // Рекомендации
+            'recommended_order_without_docs' => $this->calculateOrderQuantity($sparePart, false),
+            'recommended_order_with_docs' => $this->calculateOrderQuantity($sparePart, true),
+            'recommended_for_min_stock' => $this->calculateMinStockOrderQuantity($sparePart),
+        ];
+    }
+
+    /**
+     * Получить сводку по всем запчастям
+     */
+    public function getInventorySummary(): array
+    {
+        $spareParts = SparePart::all();
+
+        $criticalCount = 0;
+        $belowMinCount = 0;
+        $totalPhysical = 0;
+        $totalReserved = 0;
+
+        foreach ($spareParts as $sparePart) {
+            if ($sparePart->hasOpenShortages()) {
+                $criticalCount++;
+            }
+            if ($sparePart->isBelowMinStock()) {
+                $belowMinCount++;
+            }
+            $totalPhysical += $sparePart->total_physical_stock;
+            $totalReserved += $sparePart->total_reserved;
+        }
+
+        return [
+            'total_spare_parts' => $spareParts->count(),
+            'critical_shortages_count' => $criticalCount,
+            'below_min_stock_count' => $belowMinCount,
+            'total_physical_stock' => $totalPhysical,
+            'total_reserved' => $totalReserved,
+            'total_free' => $totalPhysical - $totalReserved,
+        ];
+    }
+
+    /**
+     * Получить резервы для рекламации
+     */
+    public function getReservationsForReclamation(int $reclamationId): Collection
+    {
+        return $this->reservationService->getReservationsForReclamation($reclamationId);
+    }
+
+    /**
+     * Получить дефициты для рекламации
+     */
+    public function getShortagesForReclamation(int $reclamationId): Collection
+    {
+        return Shortage::query()
+            ->where('reclamation_id', $reclamationId)
+            ->with('sparePart')
+            ->get();
     }
 }

+ 232 - 0
app/Services/SparePartIssueService.php

@@ -0,0 +1,232 @@
+<?php
+
+namespace App\Services;
+
+use App\Models\InventoryMovement;
+use App\Models\Reservation;
+use App\Models\SparePartOrder;
+use Illuminate\Support\Facades\DB;
+
+/**
+ * Сервис списания (отгрузки) запчастей.
+ *
+ * Списание возможно ТОЛЬКО при наличии активного резерва.
+ * При списании:
+ * - Уменьшается available_qty партии
+ * - Закрывается резерв (статус = issued)
+ * - Создаётся движение типа issue
+ */
+class SparePartIssueService
+{
+    /**
+     * Списать запчасть по резерву
+     *
+     * @param Reservation $reservation Резерв для списания
+     * @param string $note Примечание
+     * @return IssueResult
+     * @throws \Exception
+     */
+    public function issueReservation(Reservation $reservation, string $note = ''): IssueResult
+    {
+        if (!$reservation->isActive()) {
+            throw new \InvalidArgumentException("Резерв не активен, списание невозможно");
+        }
+
+        return DB::transaction(function () use ($reservation, $note) {
+            // Блокируем партию для update
+            $order = SparePartOrder::lockForUpdate()->findOrFail($reservation->spare_part_order_id);
+
+            // Проверка наличия (на случай ошибки в данных)
+            if ($order->available_qty < $reservation->reserved_qty) {
+                throw new \RuntimeException(
+                    "Недостаточно товара в партии. Доступно: {$order->available_qty}, " .
+                    "требуется: {$reservation->reserved_qty}"
+                );
+            }
+
+            // 1. Уменьшение остатка партии
+            $order->available_qty -= $reservation->reserved_qty;
+
+            // 2. Смена статуса партии если полностью отгружена
+            if ($order->available_qty === 0) {
+                $order->status = SparePartOrder::STATUS_SHIPPED;
+            }
+            $order->save();
+
+            // 3. Закрытие резерва
+            $reservation->status = Reservation::STATUS_ISSUED;
+            $reservation->save();
+
+            // 4. Создание движения типа issue
+            $movement = InventoryMovement::create([
+                'spare_part_order_id' => $order->id,
+                'spare_part_id' => $reservation->spare_part_id,
+                'qty' => $reservation->reserved_qty,
+                'movement_type' => InventoryMovement::TYPE_ISSUE,
+                'source_type' => InventoryMovement::SOURCE_RECLAMATION,
+                'source_id' => $reservation->reclamation_id,
+                'with_documents' => $reservation->with_documents,
+                'user_id' => auth()->id(),
+                'note' => $note ?: "Списание по резерву для рекламации #{$reservation->reclamation_id}",
+            ]);
+
+            return new IssueResult(
+                issued: $reservation->reserved_qty,
+                reservation: $reservation,
+                movement: $movement,
+                order: $order
+            );
+        });
+    }
+
+    /**
+     * Списать все активные резервы для рекламации
+     *
+     * @param int $reclamationId ID рекламации
+     * @param int|null $sparePartId ID запчасти (опционально)
+     * @param bool|null $withDocuments Тип документов (опционально)
+     * @return array<IssueResult>
+     */
+    public function issueForReclamation(
+        int $reclamationId,
+        ?int $sparePartId = null,
+        ?bool $withDocuments = null
+    ): array {
+        $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->get();
+        $results = [];
+
+        foreach ($reservations as $reservation) {
+            $results[] = $this->issueReservation($reservation);
+        }
+
+        return $results;
+    }
+
+    /**
+     * Прямое списание без резерва (для ручных операций)
+     *
+     * ВНИМАНИЕ: Использовать только для ручных корректировок!
+     * Для рекламаций всегда использовать резервирование.
+     *
+     * @param SparePartOrder $order Партия
+     * @param int $quantity Количество
+     * @param string $note Примечание
+     * @return IssueResult
+     */
+    public function directIssue(SparePartOrder $order, int $quantity, string $note): IssueResult
+    {
+        if ($order->available_qty < $quantity) {
+            throw new \InvalidArgumentException(
+                "Недостаточно товара в партии. Доступно: {$order->available_qty}, " .
+                "запрошено: {$quantity}"
+            );
+        }
+
+        return DB::transaction(function () use ($order, $quantity, $note) {
+            // Блокируем для update
+            $order = SparePartOrder::lockForUpdate()->find($order->id);
+
+            $order->available_qty -= $quantity;
+
+            if ($order->available_qty === 0) {
+                $order->status = SparePartOrder::STATUS_SHIPPED;
+            }
+            $order->save();
+
+            $movement = InventoryMovement::create([
+                'spare_part_order_id' => $order->id,
+                'spare_part_id' => $order->spare_part_id,
+                'qty' => $quantity,
+                'movement_type' => InventoryMovement::TYPE_ISSUE,
+                'source_type' => InventoryMovement::SOURCE_MANUAL,
+                'source_id' => null,
+                'with_documents' => $order->with_documents,
+                'user_id' => auth()->id(),
+                'note' => $note,
+            ]);
+
+            return new IssueResult(
+                issued: $quantity,
+                reservation: null,
+                movement: $movement,
+                order: $order
+            );
+        });
+    }
+
+    /**
+     * Коррекция остатка партии (инвентаризация)
+     *
+     * @param SparePartOrder $order Партия
+     * @param int $newQuantity Новый физический остаток
+     * @param string $note Причина коррекции
+     * @return InventoryMovement
+     */
+    public function correctInventory(SparePartOrder $order, int $newQuantity, string $note): InventoryMovement
+    {
+        if ($newQuantity < 0) {
+            throw new \InvalidArgumentException("Остаток не может быть отрицательным");
+        }
+
+        return DB::transaction(function () use ($order, $newQuantity, $note) {
+            $order = SparePartOrder::lockForUpdate()->find($order->id);
+
+            $diff = $newQuantity - $order->available_qty;
+
+            if ($diff === 0) {
+                throw new \InvalidArgumentException("Количество не изменилось");
+            }
+
+            $order->available_qty = $newQuantity;
+
+            if ($order->available_qty === 0 && $order->status === SparePartOrder::STATUS_IN_STOCK) {
+                $order->status = SparePartOrder::STATUS_SHIPPED;
+            } elseif ($order->available_qty > 0 && $order->status === SparePartOrder::STATUS_SHIPPED) {
+                $order->status = SparePartOrder::STATUS_IN_STOCK;
+            }
+
+            $order->save();
+
+            $movementType = $diff > 0
+                ? InventoryMovement::TYPE_CORRECTION_PLUS
+                : InventoryMovement::TYPE_CORRECTION_MINUS;
+
+            return InventoryMovement::create([
+                'spare_part_order_id' => $order->id,
+                'spare_part_id' => $order->spare_part_id,
+                'qty' => abs($diff),
+                'movement_type' => $movementType,
+                'source_type' => InventoryMovement::SOURCE_INVENTORY,
+                'source_id' => null,
+                'with_documents' => $order->with_documents,
+                'user_id' => auth()->id(),
+                'note' => $note,
+            ]);
+        });
+    }
+}
+
+/**
+ * Результат операции списания
+ */
+class IssueResult
+{
+    public function __construct(
+        public readonly int $issued,
+        public readonly ?Reservation $reservation,
+        public readonly InventoryMovement $movement,
+        public readonly SparePartOrder $order
+    ) {}
+}

+ 386 - 0
app/Services/SparePartReservationService.php

@@ -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;
+    }
+}

+ 83 - 0
database/migrations/2026_01_24_200000_create_inventory_movements_table.php

@@ -0,0 +1,83 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    /**
+     * Run the migrations.
+     *
+     * Таблица движений запчастей — единственный механизм изменения остатков.
+     * Все операции (поступление, резерв, списание, отмена резерва, коррекция)
+     * фиксируются здесь для полного аудита.
+     */
+    public function up(): void
+    {
+        if (Schema::hasTable('inventory_movements')) {
+            // Таблица уже существует, проверяем наличие новых типов движений
+            // и добавляем их если нужно (MySQL не позволяет легко изменить ENUM)
+            return;
+        }
+
+        Schema::create('inventory_movements', function (Blueprint $table) {
+            $table->id();
+
+            // Связь с партией (может быть null для некоторых типов движений)
+            $table->foreignId('spare_part_order_id')
+                ->nullable()
+                ->constrained('spare_part_orders')
+                ->nullOnDelete();
+
+            // Связь с запчастью (обязательна)
+            $table->foreignId('spare_part_id')
+                ->constrained('spare_parts')
+                ->restrictOnDelete();
+
+            // Количество (всегда положительное, направление определяется типом)
+            $table->unsignedInteger('qty');
+
+            // Тип движения
+            $table->enum('movement_type', [
+                'receipt',          // Поступление на склад
+                'reserve',          // Резервирование
+                'issue',            // Списание (отгрузка)
+                'reserve_cancel',   // Отмена резерва
+                'correction',       // Коррекция (deprecated, для совместимости)
+                'correction_plus',  // Увеличение при инвентаризации
+                'correction_minus', // Уменьшение при инвентаризации
+            ]);
+
+            // Источник операции (что вызвало движение)
+            $table->string('source_type')->nullable(); // reclamation, manual, shortage_fulfillment, inventory
+            $table->unsignedBigInteger('source_id')->nullable();
+
+            // Признак наличия документов
+            $table->boolean('with_documents')->default(false);
+
+            // Аудит
+            $table->foreignId('user_id')
+                ->nullable()
+                ->constrained('users')
+                ->nullOnDelete();
+
+            $table->text('note')->nullable();
+            $table->timestamps();
+
+            // Индексы для быстрого расчёта остатков
+            $table->index(['spare_part_id', 'movement_type', 'with_documents']);
+            $table->index(['spare_part_order_id', 'movement_type']);
+            $table->index(['source_type', 'source_id']);
+            $table->index('created_at');
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     */
+    public function down(): void
+    {
+        Schema::dropIfExists('inventory_movements');
+    }
+};

+ 78 - 0
database/migrations/2026_01_24_200001_create_reservations_table.php

@@ -0,0 +1,78 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    /**
+     * Run the migrations.
+     *
+     * Таблица резервов — логическая сущность для отслеживания
+     * зарезервированных запчастей под конкретные рекламации.
+     *
+     * Резерв уменьшает свободный остаток, но НЕ физический.
+     * Физический остаток уменьшается только при списании (issue).
+     */
+    public function up(): void
+    {
+        Schema::create('reservations', function (Blueprint $table) {
+            $table->id();
+
+            // Какая запчасть зарезервирована
+            $table->foreignId('spare_part_id')
+                ->constrained('spare_parts')
+                ->restrictOnDelete();
+
+            // Из какой партии (для FIFO учёта)
+            $table->foreignId('spare_part_order_id')
+                ->constrained('spare_part_orders')
+                ->restrictOnDelete();
+
+            // Для какой рекламации
+            $table->foreignId('reclamation_id')
+                ->constrained('reclamations')
+                ->cascadeOnDelete();
+
+            // Количество зарезервировано
+            $table->unsignedInteger('reserved_qty');
+
+            // Признак документов
+            $table->boolean('with_documents')->default(false);
+
+            // Статус резерва
+            $table->enum('status', [
+                'active',     // Активный резерв
+                'issued',     // Списано (резерв реализован)
+                'cancelled',  // Отменён
+            ])->default('active');
+
+            // Связь с движением (для аудита)
+            $table->foreignId('movement_id')
+                ->nullable()
+                ->constrained('inventory_movements')
+                ->nullOnDelete();
+
+            $table->timestamps();
+            // Индексы
+            $table->index(['spare_part_id', 'with_documents', 'status']);
+            $table->index(['spare_part_order_id', 'status']);
+            $table->index(['reclamation_id', 'status']);
+            $table->index('status');
+        });
+
+        // CHECK constraint для положительного количества
+        // MySQL 8.0.16+ поддерживает CHECK constraints
+        DB::statement('ALTER TABLE reservations ADD CONSTRAINT chk_reserved_qty_positive CHECK (reserved_qty > 0)');
+    }
+
+    /**
+     * Reverse the migrations.
+     */
+    public function down(): void
+    {
+        Schema::dropIfExists('reservations');
+    }
+};

+ 75 - 0
database/migrations/2026_01_24_200002_create_shortages_table.php

@@ -0,0 +1,75 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    /**
+     * Run the migrations.
+     *
+     * Таблица дефицитов — фиксация неудовлетворённой потребности.
+     *
+     * Создаётся когда при резервировании недостаточно свободного остатка.
+     * НЕ влияет на физический остаток (вместо отрицательных значений).
+     *
+     * При поступлении новой партии система автоматически
+     * резервирует под открытые дефициты (FIFO).
+     */
+    public function up(): void
+    {
+        Schema::create('shortages', function (Blueprint $table) {
+            $table->id();
+
+            // Какой запчасти не хватает
+            $table->foreignId('spare_part_id')
+                ->constrained('spare_parts')
+                ->restrictOnDelete();
+
+            // Для какой рекламации
+            $table->foreignId('reclamation_id')
+                ->constrained('reclamations')
+                ->cascadeOnDelete();
+
+            // Признак документов
+            $table->boolean('with_documents')->default(false);
+
+            // Сколько требовалось всего
+            $table->unsignedInteger('required_qty');
+
+            // Сколько удалось зарезервировать (на момент создания дефицита)
+            $table->unsignedInteger('reserved_qty')->default(0);
+
+            // Сколько ещё не хватает (required - reserved)
+            $table->unsignedInteger('missing_qty');
+
+            // Статус дефицита
+            $table->enum('status', [
+                'open',    // Ожидает закрытия (есть missing_qty > 0)
+                'closed',  // Полностью закрыт (missing_qty = 0)
+            ])->default('open');
+
+            $table->text('note')->nullable();
+            $table->timestamps();
+
+            // Индексы
+            $table->index(['spare_part_id', 'with_documents', 'status']);
+            $table->index(['reclamation_id']);
+            $table->index(['status', 'created_at']); // Для FIFO обработки
+        });
+
+        // CHECK constraints
+        DB::statement('ALTER TABLE shortages ADD CONSTRAINT chk_missing_qty_non_negative CHECK (missing_qty >= 0)');
+        DB::statement('ALTER TABLE shortages ADD CONSTRAINT chk_reserved_lte_required CHECK (reserved_qty <= required_qty)');
+    }
+
+    /**
+     * Reverse the migrations.
+     */
+    public function down(): void
+    {
+        Schema::dropIfExists('shortages');
+    }
+};

+ 83 - 0
database/migrations/2026_01_24_200003_refactor_spare_part_orders_table.php

@@ -0,0 +1,83 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+use Illuminate\Support\Facades\DB;
+
+return new class extends Migration
+{
+    /**
+     * Run the migrations.
+     *
+     * Рефакторинг таблицы spare_part_orders:
+     * - Переименование remaining_quantity → available_qty
+     * - Добавление CHECK constraint для неотрицательности
+     */
+    public function up(): void
+    {
+        // Проверяем, была ли миграция уже частично выполнена
+        $hasAvailableQty = Schema::hasColumn('spare_part_orders', 'available_qty');
+        $hasRemainingQty = Schema::hasColumn('spare_part_orders', 'remaining_quantity');
+
+        if ($hasAvailableQty && !$hasRemainingQty) {
+            // Миграция уже выполнена
+            return;
+        }
+
+        if ($hasRemainingQty) {
+            // 1. Сначала обнуляем отрицательные значения (они будут мигрированы в shortages)
+            DB::statement('UPDATE spare_part_orders SET remaining_quantity = 0 WHERE remaining_quantity < 0');
+
+            // 2. Переименовываем колонку
+            Schema::table('spare_part_orders', function (Blueprint $table) {
+                $table->renameColumn('remaining_quantity', 'available_qty');
+            });
+        }
+
+        // 3. Добавляем CHECK constraint для неотрицательности (если ещё нет)
+        try {
+            DB::statement('ALTER TABLE spare_part_orders ADD CONSTRAINT chk_available_qty_non_negative CHECK (available_qty >= 0)');
+        } catch (\Exception $e) {
+            // Constraint уже существует
+        }
+
+        // 4. Обновляем индекс
+        Schema::table('spare_part_orders', function (Blueprint $table) {
+            // Отключаем проверку внешних ключей
+            DB::statement('SET FOREIGN_KEY_CHECKS=0');
+
+            // Удаляем старый индекс если существует
+            $indexName = 'spare_part_orders_spare_part_id_status_remaining_quantity_index';
+            $indexes = DB::select("SHOW INDEX FROM spare_part_orders WHERE Key_name = ?", [$indexName]);
+            if (!empty($indexes)) {
+                $table->dropIndex($indexName);
+            }
+
+            // Проверяем, есть ли уже новый индекс
+            $newIndexName = 'spare_part_orders_spare_part_id_status_available_qty_index';
+            $newIndexes = DB::select("SHOW INDEX FROM spare_part_orders WHERE Key_name = ?", [$newIndexName]);
+            if (empty($newIndexes)) {
+                $table->index(['spare_part_id', 'status', 'available_qty']);
+            }
+
+            // Включаем обратно
+            DB::statement('SET FOREIGN_KEY_CHECKS=1');
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     */
+    public function down(): void
+    {
+        // Убираем constraint
+        DB::statement('ALTER TABLE spare_part_orders DROP CONSTRAINT IF EXISTS chk_available_qty_non_negative');
+
+        Schema::table('spare_part_orders', function (Blueprint $table) {
+            $table->dropIndex(['spare_part_id', 'status', 'available_qty']);
+            $table->renameColumn('available_qty', 'remaining_quantity');
+            $table->index(['spare_part_id', 'status', 'remaining_quantity']);
+        });
+    }
+};

+ 42 - 0
database/migrations/2026_01_24_200004_add_reservation_to_reclamation_spare_part.php

@@ -0,0 +1,42 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    /**
+     * Run the migrations.
+     *
+     * Добавляем связи с резервом и дефицитом в pivot таблицу рекламаций.
+     */
+    public function up(): void
+    {
+        Schema::table('reclamation_spare_part', function (Blueprint $table) {
+            // Статус строки в рекламации
+            $table->enum('status', [
+                'pending',   // Ожидает резервирования
+                'reserved',  // Зарезервировано (частично или полностью)
+                'issued',    // Списано
+                'cancelled', // Отменено
+            ])->default('pending')->after('with_documents');
+
+            // Сколько фактически зарезервировано
+            $table->unsignedInteger('reserved_qty')->default(0)->after('status');
+
+            // Сколько фактически списано
+            $table->unsignedInteger('issued_qty')->default(0)->after('reserved_qty');
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     */
+    public function down(): void
+    {
+        Schema::table('reclamation_spare_part', function (Blueprint $table) {
+            $table->dropColumn(['status', 'reserved_qty', 'issued_qty']);
+        });
+    }
+};

+ 124 - 0
database/migrations/2026_01_24_200005_migrate_shipments_to_movements.php

@@ -0,0 +1,124 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Support\Facades\DB;
+
+return new class extends Migration
+{
+    /**
+     * Run the migrations.
+     *
+     * Миграция данных:
+     * 1. Перенос shipments в inventory_movements
+     * 2. Конвертация отрицательных остатков в shortages
+     * 3. Обновление pivot таблицы reclamation_spare_part
+     */
+    public function up(): void
+    {
+        // 1. Миграция shipments в movements
+        $shipments = DB::table('spare_part_order_shipments')
+            ->join('spare_part_orders', 'spare_part_order_shipments.spare_part_order_id', '=', 'spare_part_orders.id')
+            ->select([
+                'spare_part_order_shipments.*',
+                'spare_part_orders.spare_part_id',
+                'spare_part_orders.with_documents',
+            ])
+            ->get();
+
+        foreach ($shipments as $shipment) {
+            DB::table('inventory_movements')->insert([
+                'spare_part_order_id' => $shipment->spare_part_order_id,
+                'spare_part_id' => $shipment->spare_part_id,
+                'qty' => $shipment->quantity,
+                'movement_type' => 'issue',
+                'source_type' => $shipment->reclamation_id ? 'reclamation' : 'manual',
+                'source_id' => $shipment->reclamation_id,
+                'with_documents' => $shipment->with_documents,
+                'user_id' => $shipment->user_id,
+                'note' => $shipment->note ?? ($shipment->is_automatic ? 'Автоматическое списание (мигрировано)' : 'Ручное списание (мигрировано)'),
+                'created_at' => $shipment->created_at,
+                'updated_at' => $shipment->updated_at,
+            ]);
+        }
+
+        // 2. Находим "виртуальные заказы" с отрицательным остатком (до рефакторинга)
+        // Примечание: колонка уже переименована в available_qty и обнулена в предыдущей миграции
+        // Но нам нужно создать shortages на основе данных в pivot таблице,
+        // где quantity есть, но не было реальной партии
+
+        // Получаем записи из pivot где запчасть привязана к рекламации
+        $pivotRecords = DB::table('reclamation_spare_part')
+            ->join('spare_parts', 'reclamation_spare_part.spare_part_id', '=', 'spare_parts.id')
+            ->select([
+                'reclamation_spare_part.*',
+                'spare_parts.article',
+            ])
+            ->get();
+
+        foreach ($pivotRecords as $pivot) {
+            // Проверяем, есть ли достаточно резервов/списаний для этой привязки
+            $totalIssued = DB::table('inventory_movements')
+                ->where('source_type', 'reclamation')
+                ->where('source_id', $pivot->reclamation_id)
+                ->where('spare_part_id', $pivot->spare_part_id)
+                ->where('with_documents', $pivot->with_documents)
+                ->where('movement_type', 'issue')
+                ->sum('qty');
+
+            $missing = $pivot->quantity - $totalIssued;
+
+            if ($missing > 0) {
+                // Создаём shortage для недостающего количества
+                DB::table('shortages')->insert([
+                    'spare_part_id' => $pivot->spare_part_id,
+                    'reclamation_id' => $pivot->reclamation_id,
+                    'with_documents' => $pivot->with_documents,
+                    'required_qty' => $pivot->quantity,
+                    'reserved_qty' => $totalIssued,
+                    'missing_qty' => $missing,
+                    'status' => 'open',
+                    'note' => 'Создано при миграции данных',
+                    'created_at' => $pivot->created_at ?? now(),
+                    'updated_at' => now(),
+                ]);
+            }
+
+            // Обновляем pivot с новыми полями
+            DB::table('reclamation_spare_part')
+                ->where('id', $pivot->id)
+                ->update([
+                    'status' => $totalIssued >= $pivot->quantity ? 'issued' : ($totalIssued > 0 ? 'reserved' : 'pending'),
+                    'reserved_qty' => 0, // Старые данные были issue, не reserve
+                    'issued_qty' => $totalIssued,
+                ]);
+        }
+
+        // 3. Удаляем виртуальные заказы с нулевым ordered_quantity
+        // (они были созданы для учёта недостач)
+        DB::table('spare_part_orders')
+            ->where('ordered_quantity', 0)
+            ->delete();
+    }
+
+    /**
+     * Reverse the migrations.
+     */
+    public function down(): void
+    {
+        // Удаляем созданные при миграции данные
+        DB::table('inventory_movements')
+            ->where('note', 'like', '%мигрировано%')
+            ->delete();
+
+        DB::table('shortages')
+            ->where('note', 'Создано при миграции данных')
+            ->delete();
+
+        // Сбрасываем новые поля в pivot
+        DB::table('reclamation_spare_part')->update([
+            'status' => 'pending',
+            'reserved_qty' => 0,
+            'issued_qty' => 0,
+        ]);
+    }
+};

+ 63 - 0
database/migrations/2026_01_24_200006_recreate_spare_part_orders_view.php

@@ -0,0 +1,63 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Support\Facades\DB;
+
+return new class extends Migration
+{
+    /**
+     * Run the migrations.
+     *
+     * Пересоздание view после переименования remaining_quantity → available_qty
+     */
+    public function up(): void
+    {
+        DB::statement("DROP VIEW IF EXISTS spare_part_orders_view");
+
+        DB::statement("
+            CREATE VIEW spare_part_orders_view AS
+            SELECT
+                spo.id,
+                spo.year,
+                spo.spare_part_id,
+                spo.source_text,
+                spo.sourceable_id,
+                spo.sourceable_type,
+                spo.status,
+                spo.ordered_quantity,
+                spo.available_qty,
+                spo.with_documents,
+                spo.note,
+                spo.user_id,
+                spo.deleted_at,
+                spo.created_at,
+                spo.updated_at,
+                sp.article,
+                u.name as user_name
+            FROM spare_part_orders spo
+            LEFT JOIN spare_parts sp ON sp.id = spo.spare_part_id
+            LEFT JOIN users u ON u.id = spo.user_id
+            WHERE spo.deleted_at IS NULL
+        ");
+    }
+
+    /**
+     * Reverse the migrations.
+     */
+    public function down(): void
+    {
+        DB::statement("DROP VIEW IF EXISTS spare_part_orders_view");
+
+        DB::statement("
+            CREATE VIEW spare_part_orders_view AS
+            SELECT
+                spo.*,
+                sp.article,
+                u.name as user_name
+            FROM spare_part_orders spo
+            LEFT JOIN spare_parts sp ON sp.id = spo.spare_part_id
+            LEFT JOIN users u ON u.id = spo.user_id
+            WHERE spo.deleted_at IS NULL
+        ");
+    }
+};

+ 205 - 3
resources/views/reclamations/edit.blade.php

@@ -97,7 +97,19 @@
                     </table>
                 </div>
                 <div class="spare-parts">
-                    <a href="#spare_parts" data-bs-toggle="collapse">Запчасти ({{ $reclamation->spareParts->count() }})</a>
+                    @php
+                        $activeReservationsCount = $reclamation->activeReservations()->count();
+                        $openShortagesCount = $reclamation->openShortages()->count();
+                    @endphp
+                    <a href="#spare_parts" data-bs-toggle="collapse">
+                        Запчасти ({{ $reclamation->spareParts->count() }})
+                        @if($activeReservationsCount > 0)
+                            <span class="badge bg-warning" title="Активных резервов">{{ $activeReservationsCount }} резерв</span>
+                        @endif
+                        @if($openShortagesCount > 0)
+                            <span class="badge bg-danger" title="Открытых дефицитов">{{ $openShortagesCount }} дефицит</span>
+                        @endif
+                    </a>
                     <form method="post" action="{{ route('reclamations.update-spare-parts', $reclamation) }}"
                           class="my-2 collapse" id="spare_parts">
                         @csrf
@@ -209,6 +221,196 @@
                             @dump($errors->all())
                         @endif
                     </form>
+
+                    {{-- Резервы и дефициты --}}
+                    @php
+                        $reservations = $reclamation->sparePartReservations()
+                            ->with('sparePart', 'sparePartOrder')
+                            ->orderByDesc('created_at')
+                            ->get();
+                        $shortages = $reclamation->sparePartShortages()
+                            ->with('sparePart')
+                            ->orderByDesc('created_at')
+                            ->get();
+                    @endphp
+
+                    @if($reservations->count() > 0 || $shortages->count() > 0)
+                        <div class="mt-3 p-2 border rounded bg-light">
+                            {{-- Активные резервы --}}
+                            @if($reservations->where('status', 'active')->count() > 0)
+                                <div class="mb-3">
+                                    <strong class="text-warning"><i class="bi bi-bookmark-fill"></i> Активные резервы</strong>
+                                    <table class="table table-sm table-striped mt-1 mb-0">
+                                        <thead>
+                                            <tr>
+                                                <th>Запчасть</th>
+                                                <th class="text-center">Кол-во</th>
+                                                <th class="text-center">С док.</th>
+                                                <th>Партия</th>
+                                                @if(hasRole('admin,manager'))
+                                                    <th></th>
+                                                @endif
+                                            </tr>
+                                        </thead>
+                                        <tbody>
+                                            @foreach($reservations->where('status', 'active') as $reservation)
+                                                <tr>
+                                                    <td>
+                                                        @if($reservation->sparePart)
+                                                            <a href="{{ route('spare_parts.show', $reservation->sparePart->id) }}">
+                                                                {{ $reservation->sparePart->article }}
+                                                            </a>
+                                                        @else
+                                                            -
+                                                        @endif
+                                                    </td>
+                                                    <td class="text-center">{{ $reservation->reserved_qty }}</td>
+                                                    <td class="text-center">
+                                                        @if($reservation->with_documents)
+                                                            <i class="bi bi-check-circle text-success"></i>
+                                                        @else
+                                                            <i class="bi bi-x-circle text-muted"></i>
+                                                        @endif
+                                                    </td>
+                                                    <td>
+                                                        @if($reservation->sparePartOrder)
+                                                            <a href="{{ route('spare_part_orders.show', $reservation->sparePartOrder->id) }}">
+                                                                #{{ $reservation->sparePartOrder->id }}
+                                                            </a>
+                                                        @else
+                                                            -
+                                                        @endif
+                                                    </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="Списать">
+                                                                    <i class="bi bi-check-lg"></i>
+                                                                </button>
+                                                            </form>
+                                                            <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="Отменить резерв">
+                                                                    <i class="bi bi-x-lg"></i>
+                                                                </button>
+                                                            </form>
+                                                        </td>
+                                                    @endif
+                                                </tr>
+                                            @endforeach
+                                        </tbody>
+                                    </table>
+                                    @if(hasRole('admin,manager') && $reservations->where('status', 'active')->count() > 1)
+                                        <div class="text-end mt-1">
+                                            <form action="{{ route('spare_part_reservations.issue_all', $reclamation->id) }}" method="POST" class="d-inline">
+                                                @csrf
+                                                <button type="submit" class="btn btn-sm btn-success">Списать все</button>
+                                            </form>
+                                            <form action="{{ route('spare_part_reservations.cancel_all', $reclamation->id) }}" method="POST" class="d-inline">
+                                                @csrf
+                                                <button type="submit" class="btn btn-sm btn-outline-warning">Отменить все резервы</button>
+                                            </form>
+                                        </div>
+                                    @endif
+                                </div>
+                            @endif
+
+                            {{-- Списанные --}}
+                            @if($reservations->where('status', 'issued')->count() > 0)
+                                <div class="mb-3">
+                                    <strong class="text-success"><i class="bi bi-check-circle-fill"></i> Списано</strong>
+                                    <table class="table table-sm table-success mt-1 mb-0">
+                                        <thead>
+                                            <tr>
+                                                <th>Запчасть</th>
+                                                <th class="text-center">Кол-во</th>
+                                                <th class="text-center">С док.</th>
+                                                <th>Партия</th>
+                                                <th>Дата</th>
+                                            </tr>
+                                        </thead>
+                                        <tbody>
+                                            @foreach($reservations->where('status', 'issued') as $reservation)
+                                                <tr>
+                                                    <td>
+                                                        @if($reservation->sparePart)
+                                                            <a href="{{ route('spare_parts.show', $reservation->sparePart->id) }}">
+                                                                {{ $reservation->sparePart->article }}
+                                                            </a>
+                                                        @else
+                                                            -
+                                                        @endif
+                                                    </td>
+                                                    <td class="text-center">{{ $reservation->reserved_qty }}</td>
+                                                    <td class="text-center">
+                                                        @if($reservation->with_documents)
+                                                            <i class="bi bi-check-circle text-success"></i>
+                                                        @else
+                                                            <i class="bi bi-x-circle text-muted"></i>
+                                                        @endif
+                                                    </td>
+                                                    <td>
+                                                        @if($reservation->sparePartOrder)
+                                                            <a href="{{ route('spare_part_orders.show', $reservation->sparePartOrder->id) }}">
+                                                                #{{ $reservation->sparePartOrder->id }}
+                                                            </a>
+                                                        @else
+                                                            -
+                                                        @endif
+                                                    </td>
+                                                    <td>{{ $reservation->updated_at->format('d.m.Y H:i') }}</td>
+                                                </tr>
+                                            @endforeach
+                                        </tbody>
+                                    </table>
+                                </div>
+                            @endif
+
+                            {{-- Открытые дефициты --}}
+                            @if($shortages->where('status', 'open')->count() > 0)
+                                <div class="mb-3">
+                                    <strong class="text-danger"><i class="bi bi-exclamation-triangle-fill"></i> Дефициты (нехватка)</strong>
+                                    <table class="table table-sm table-danger mt-1 mb-0">
+                                        <thead>
+                                            <tr>
+                                                <th>Запчасть</th>
+                                                <th class="text-center">Требуется</th>
+                                                <th class="text-center">Зарезервировано</th>
+                                                <th class="text-center">Не хватает</th>
+                                                <th class="text-center">С док.</th>
+                                            </tr>
+                                        </thead>
+                                        <tbody>
+                                            @foreach($shortages->where('status', 'open') as $shortage)
+                                                <tr>
+                                                    <td>
+                                                        @if($shortage->sparePart)
+                                                            <a href="{{ route('spare_parts.show', $shortage->sparePart->id) }}">
+                                                                {{ $shortage->sparePart->article }}
+                                                            </a>
+                                                        @else
+                                                            -
+                                                        @endif
+                                                    </td>
+                                                    <td class="text-center">{{ $shortage->required_qty }}</td>
+                                                    <td class="text-center">{{ $shortage->reserved_qty }}</td>
+                                                    <td class="text-center fw-bold">{{ $shortage->missing_qty }}</td>
+                                                    <td class="text-center">
+                                                        @if($shortage->with_documents)
+                                                            <i class="bi bi-check-circle text-success"></i>
+                                                        @else
+                                                            <i class="bi bi-x-circle text-muted"></i>
+                                                        @endif
+                                                    </td>
+                                                </tr>
+                                            @endforeach
+                                        </tbody>
+                                    </table>
+                                </div>
+                            @endif
+                        </div>
+                    @endif
                 </div>
                 <hr>
                 <div class="documents">
@@ -577,8 +779,8 @@
                 $(this).prop('disabled', false);
             });
 
-            // Сбрасываем значения
-            newRow.find('.spare-part-search').val('');
+            // Сбрасываем значения и разблокируем поле поиска
+            newRow.find('.spare-part-search').val('').prop('disabled', false);
             newRow.find('.spare-part-id').val('');
             newRow.find('input[type="number"]').val('');
             newRow.find('input[type="checkbox"]').prop('checked', false);

+ 118 - 16
resources/views/spare_part_orders/edit.blade.php

@@ -65,7 +65,10 @@
                 @if($spare_part_order)
                     <div class="mb-3">
                         <label class="form-label">Остаток</label>
-                        <input type="text" class="form-control" value="{{ $spare_part_order->remaining_quantity }}" readonly>
+                        <input type="text" class="form-control" value="{{ $spare_part_order->available_qty }}" readonly>
+                        @if($spare_part_order->reserved_qty > 0)
+                            <small class="text-warning">Зарезервировано: {{ $spare_part_order->reserved_qty }} шт.</small>
+                        @endif
                     </div>
                 @endif
 
@@ -106,42 +109,96 @@
 
         @if($spare_part_order)
             <div class="col-md-6">
-                <h3>История списаний</h3>
+                {{-- Резервы --}}
+                @if($spare_part_order->reservations->where('status', 'active')->count() > 0)
+                    <h3>Активные резервы</h3>
+                    <table class="table table-sm table-striped">
+                        <thead>
+                            <tr>
+                                <th>Кол-во</th>
+                                <th>Рекламация</th>
+                                <th>Дата</th>
+                                <th></th>
+                            </tr>
+                        </thead>
+                        <tbody>
+                            @foreach($spare_part_order->reservations->where('status', 'active') as $reservation)
+                                <tr>
+                                    <td>{{ $reservation->reserved_qty }}</td>
+                                    <td>
+                                        @if($reservation->reclamation)
+                                            <a href="{{ route('reclamations.show', $reservation->reclamation->id) }}">#{{ $reservation->reclamation->id }}</a>
+                                        @else
+                                            -
+                                        @endif
+                                    </td>
+                                    <td>{{ $reservation->created_at->format('d.m.Y H:i') }}</td>
+                                    <td>
+                                        @if(hasRole('admin,manager'))
+                                            <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="fas fa-check"></i>
+                                                </button>
+                                            </form>
+                                            <form action="{{ route('spare_part_reservations.cancel', $reservation) }}" method="POST" class="d-inline">
+                                                @csrf
+                                                <button type="submit" class="btn btn-sm btn-warning" title="Отменить резерв">
+                                                    <i class="fas fa-times"></i>
+                                                </button>
+                                            </form>
+                                        @endif
+                                    </td>
+                                </tr>
+                            @endforeach
+                        </tbody>
+                    </table>
+                @endif
+
+                {{-- История движений --}}
+                <h3>История движений</h3>
 
-                @if($spare_part_order->status === 'in_stock' && $spare_part_order->remaining_quantity > 0 && hasRole('admin,manager'))
+                @if($spare_part_order->status === 'in_stock' && $spare_part_order->free_qty > 0 && hasRole('admin,manager'))
                     <button type="button" class="btn btn-warning mb-3" data-bs-toggle="modal" data-bs-target="#shipModal">
                         Отгрузить
                     </button>
+                    @if(hasRole('admin'))
+                        <button type="button" class="btn btn-secondary mb-3" data-bs-toggle="modal" data-bs-target="#correctModal">
+                            Коррекция
+                        </button>
+                    @endif
                 @endif
 
-                @if($spare_part_order->shipments->count() > 0)
+                @if($spare_part_order->movements->count() > 0)
                     <table class="table table-striped">
                         <thead>
                             <tr>
                                 <th>Дата</th>
-                                <th>Количество</th>
+                                <th>Тип</th>
+                                <th>Кол-во</th>
                                 <th>Примечание</th>
                                 <th>Пользователь</th>
                             </tr>
                         </thead>
                         <tbody>
-                            @foreach($spare_part_order->shipments as $shipment)
-                                <tr class="{{ $shipment->is_automatic ? 'table-warning' : '' }}">
-                                    <td>{{ $shipment->created_at->format('d.m.Y H:i') }}</td>
-                                    <td>{{ $shipment->quantity }}</td>
+                            @foreach($spare_part_order->movements->sortByDesc('created_at') as $movement)
+                                <tr class="@if($movement->movement_type === 'issue') table-danger @elseif($movement->movement_type === 'reserve') table-warning @elseif($movement->movement_type === 'reserve_cancel') table-info @endif">
+                                    <td>{{ $movement->created_at->format('d.m.Y H:i') }}</td>
+                                    <td>{{ $movement->type_name }}</td>
+                                    <td>{{ $movement->qty }}</td>
                                     <td>
-                                        {{ $shipment->note }}
-                                        @if($shipment->reclamation)
-                                            <br><small><a href="{{ route('reclamations.show', $shipment->reclamation->id) }}">Рекламация #{{ $shipment->reclamation->id }}</a></small>
+                                        {{ $movement->note }}
+                                        @if($movement->source_type === 'reclamation' && $movement->source_id)
+                                            <br><small><a href="{{ route('reclamations.show', $movement->source_id) }}">Рекламация #{{ $movement->source_id }}</a></small>
                                         @endif
                                     </td>
-                                    <td>{{ $shipment->user->name ?? '-' }}</td>
+                                    <td>{{ $movement->user->name ?? '-' }}</td>
                                 </tr>
                             @endforeach
                         </tbody>
                     </table>
                 @else
-                    <p class="text-muted">Списаний пока нет</p>
+                    <p class="text-muted">Движений пока нет</p>
                 @endif
             </div>
         @endif
@@ -161,13 +218,17 @@
                     </div>
                     <div class="modal-body">
                         <div class="alert alert-info">
-                            Доступно: {{ $spare_part_order->remaining_quantity }} шт.
+                            Доступно: {{ $spare_part_order->available_qty }} шт.
+                            @if($spare_part_order->reserved_qty > 0)
+                                <br>Зарезервировано: {{ $spare_part_order->reserved_qty }} шт.
+                                <br><strong>Свободно: {{ $spare_part_order->free_qty }} шт.</strong>
+                            @endif
                         </div>
 
                         <div class="mb-3">
                             <label for="quantity" class="form-label">Количество <span class="text-danger">*</span></label>
                             <input type="number" class="form-control" id="quantity" name="quantity"
-                                   min="1" max="{{ $spare_part_order->remaining_quantity }}" required>
+                                   min="1" max="{{ $spare_part_order->free_qty }}" required>
                         </div>
 
                         <div class="mb-3">
@@ -186,6 +247,42 @@
 @endif
 
 @if($spare_part_order && hasRole('admin'))
+    {{-- Модальное окно коррекции --}}
+    <div class="modal fade" id="correctModal" tabindex="-1">
+        <div class="modal-dialog">
+            <div class="modal-content">
+                <form action="{{ route('spare_part_orders.correct', $spare_part_order) }}" method="POST">
+                    @csrf
+                    <div class="modal-header">
+                        <h5 class="modal-title">Коррекция остатка</h5>
+                        <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
+                    </div>
+                    <div class="modal-body">
+                        <div class="alert alert-warning">
+                            Текущий остаток: {{ $spare_part_order->available_qty }} шт.
+                        </div>
+
+                        <div class="mb-3">
+                            <label for="new_quantity" class="form-label">Новый остаток <span class="text-danger">*</span></label>
+                            <input type="number" class="form-control" id="new_quantity" name="new_quantity"
+                                   min="0" value="{{ $spare_part_order->available_qty }}" required>
+                        </div>
+
+                        <div class="mb-3">
+                            <label for="reason" class="form-label">Причина коррекции <span class="text-danger">*</span></label>
+                            <textarea class="form-control" id="reason" name="reason" rows="3" required
+                                      placeholder="Укажите причину изменения остатка"></textarea>
+                        </div>
+                    </div>
+                    <div class="modal-footer">
+                        <button type="submit" class="btn btn-primary">Применить</button>
+                        <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
+                    </div>
+                </form>
+            </div>
+        </div>
+    </div>
+
     {{-- Модальное окно удаления --}}
     <div class="modal fade" id="deleteModal" tabindex="-1">
         <div class="modal-dialog">
@@ -196,6 +293,11 @@
                 </div>
                 <div class="modal-body">
                     Вы действительно хотите удалить заказ #{{ $spare_part_order->id }}?
+                    @if($spare_part_order->reservations->where('status', 'active')->count() > 0)
+                        <div class="alert alert-danger mt-2">
+                            Внимание: есть активные резервы!
+                        </div>
+                    @endif
                 </div>
                 <div class="modal-footer">
                     <form action="{{ route('spare_part_orders.destroy', $spare_part_order) }}" method="POST">

+ 281 - 0
resources/views/spare_parts/edit.blade.php

@@ -228,6 +228,287 @@
                     </div>
                 </div>
             </form>
+
+            @if($spare_part)
+                <hr class="my-4">
+
+                {{-- Остатки --}}
+                <div class="row mb-4">
+                    <div class="col-12">
+                        <h4>Остатки</h4>
+                        <div class="table-responsive">
+                            <table class="table table-sm table-bordered">
+                                <thead class="table-light">
+                                    <tr>
+                                        <th></th>
+                                        <th class="text-center">Без документов</th>
+                                        <th class="text-center">С документами</th>
+                                        <th class="text-center">Всего</th>
+                                    </tr>
+                                </thead>
+                                <tbody>
+                                    <tr>
+                                        <td>Физический остаток</td>
+                                        <td class="text-center">{{ $spare_part->physical_stock_without_docs }}</td>
+                                        <td class="text-center">{{ $spare_part->physical_stock_with_docs }}</td>
+                                        <td class="text-center fw-bold">{{ $spare_part->total_physical_stock }}</td>
+                                    </tr>
+                                    <tr class="table-warning">
+                                        <td>Зарезервировано</td>
+                                        <td class="text-center">{{ $spare_part->reserved_without_docs }}</td>
+                                        <td class="text-center">{{ $spare_part->reserved_with_docs }}</td>
+                                        <td class="text-center fw-bold">{{ $spare_part->total_reserved }}</td>
+                                    </tr>
+                                    <tr class="table-success">
+                                        <td>Свободный остаток</td>
+                                        <td class="text-center">{{ $spare_part->free_stock_without_docs }}</td>
+                                        <td class="text-center">{{ $spare_part->free_stock_with_docs }}</td>
+                                        <td class="text-center fw-bold">{{ $spare_part->total_free_stock }}</td>
+                                    </tr>
+                                    @if($spare_part->min_stock > 0)
+                                        <tr class="@if($spare_part->total_free_stock < $spare_part->min_stock) table-danger @endif">
+                                            <td>Минимальный остаток</td>
+                                            <td colspan="3" class="text-center">{{ $spare_part->min_stock }}</td>
+                                        </tr>
+                                    @endif
+                                </tbody>
+                            </table>
+                        </div>
+                    </div>
+                </div>
+
+                {{-- Активные резервы --}}
+                @php
+                    $activeReservations = $spare_part->reservations()
+                        ->where('status', \App\Models\Reservation::STATUS_ACTIVE)
+                        ->with('reclamation', 'sparePartOrder')
+                        ->orderByDesc('created_at')
+                        ->get();
+                @endphp
+                @if($activeReservations->count() > 0)
+                    <div class="row mb-4">
+                        <div class="col-12">
+                            <h4>Активные резервы <span class="badge bg-warning">{{ $activeReservations->count() }}</span></h4>
+                            <div class="table-responsive">
+                                <table class="table table-sm table-striped">
+                                    <thead>
+                                        <tr>
+                                            <th>Рекламация</th>
+                                            <th>Партия</th>
+                                            <th class="text-center">Кол-во</th>
+                                            <th class="text-center">С док.</th>
+                                            <th>Дата</th>
+                                        </tr>
+                                    </thead>
+                                    <tbody>
+                                        @foreach($activeReservations as $reservation)
+                                            <tr>
+                                                <td>
+                                                    @if($reservation->reclamation)
+                                                        <a href="{{ route('reclamations.show', $reservation->reclamation->id) }}">
+                                                            #{{ $reservation->reclamation->id }}
+                                                        </a>
+                                                    @else
+                                                        -
+                                                    @endif
+                                                </td>
+                                                <td>
+                                                    @if($reservation->sparePartOrder)
+                                                        <a href="{{ route('spare_part_orders.show', $reservation->sparePartOrder->id) }}">
+                                                            #{{ $reservation->sparePartOrder->id }}
+                                                        </a>
+                                                    @else
+                                                        -
+                                                    @endif
+                                                </td>
+                                                <td class="text-center">{{ $reservation->reserved_qty }}</td>
+                                                <td class="text-center">
+                                                    @if($reservation->with_documents)
+                                                        <i class="bi bi-check-circle text-success"></i>
+                                                    @else
+                                                        <i class="bi bi-x-circle text-muted"></i>
+                                                    @endif
+                                                </td>
+                                                <td>{{ $reservation->created_at->format('d.m.Y H:i') }}</td>
+                                            </tr>
+                                        @endforeach
+                                    </tbody>
+                                </table>
+                            </div>
+                        </div>
+                    </div>
+                @endif
+
+                {{-- Открытые дефициты --}}
+                @php
+                    $openShortages = $spare_part->shortages()
+                        ->where('status', \App\Models\Shortage::STATUS_OPEN)
+                        ->with('reclamation')
+                        ->orderByDesc('created_at')
+                        ->get();
+                @endphp
+                @if($openShortages->count() > 0)
+                    <div class="row mb-4">
+                        <div class="col-12">
+                            <h4 class="text-danger">Открытые дефициты <span class="badge bg-danger">{{ $openShortages->count() }}</span></h4>
+                            <div class="table-responsive">
+                                <table class="table table-sm table-danger">
+                                    <thead>
+                                        <tr>
+                                            <th>Рекламация</th>
+                                            <th class="text-center">Требуется</th>
+                                            <th class="text-center">Зарезервировано</th>
+                                            <th class="text-center">Не хватает</th>
+                                            <th class="text-center">С док.</th>
+                                            <th>Дата</th>
+                                        </tr>
+                                    </thead>
+                                    <tbody>
+                                        @foreach($openShortages as $shortage)
+                                            <tr>
+                                                <td>
+                                                    @if($shortage->reclamation)
+                                                        <a href="{{ route('reclamations.show', $shortage->reclamation->id) }}">
+                                                            #{{ $shortage->reclamation->id }}
+                                                        </a>
+                                                    @else
+                                                        -
+                                                    @endif
+                                                </td>
+                                                <td class="text-center">{{ $shortage->required_qty }}</td>
+                                                <td class="text-center">{{ $shortage->reserved_qty }}</td>
+                                                <td class="text-center fw-bold">{{ $shortage->missing_qty }}</td>
+                                                <td class="text-center">
+                                                    @if($shortage->with_documents)
+                                                        <i class="bi bi-check-circle text-success"></i>
+                                                    @else
+                                                        <i class="bi bi-x-circle text-muted"></i>
+                                                    @endif
+                                                </td>
+                                                <td>{{ $shortage->created_at->format('d.m.Y H:i') }}</td>
+                                            </tr>
+                                        @endforeach
+                                    </tbody>
+                                </table>
+                            </div>
+                        </div>
+                    </div>
+                @endif
+
+                {{-- Партии (заказы) на складе --}}
+                @php
+                    $ordersInStock = $spare_part->orders()
+                        ->where('status', \App\Models\SparePartOrder::STATUS_IN_STOCK)
+                        ->where('available_qty', '>', 0)
+                        ->orderByDesc('created_at')
+                        ->get();
+                @endphp
+                @if($ordersInStock->count() > 0)
+                    <div class="row mb-4">
+                        <div class="col-12">
+                            <h4>Партии на складе <span class="badge bg-info">{{ $ordersInStock->count() }}</span></h4>
+                            <div class="table-responsive">
+                                <table class="table table-sm table-striped">
+                                    <thead>
+                                        <tr>
+                                            <th>ID</th>
+                                            <th>Источник</th>
+                                            <th class="text-center">Остаток</th>
+                                            <th class="text-center">С док.</th>
+                                            <th>Дата поступления</th>
+                                        </tr>
+                                    </thead>
+                                    <tbody>
+                                        @foreach($ordersInStock as $order)
+                                            <tr>
+                                                <td>
+                                                    <a href="{{ route('spare_part_orders.show', $order->id) }}">#{{ $order->id }}</a>
+                                                </td>
+                                                <td>{{ $order->source_text ?: '-' }}</td>
+                                                <td class="text-center">{{ $order->available_qty }}</td>
+                                                <td class="text-center">
+                                                    @if($order->with_documents)
+                                                        <i class="bi bi-check-circle text-success"></i>
+                                                    @else
+                                                        <i class="bi bi-x-circle text-muted"></i>
+                                                    @endif
+                                                </td>
+                                                <td>{{ $order->updated_at->format('d.m.Y') }}</td>
+                                            </tr>
+                                        @endforeach
+                                    </tbody>
+                                </table>
+                            </div>
+                        </div>
+                    </div>
+                @endif
+
+                {{-- История движений --}}
+                @php
+                    $movements = $spare_part->movements()
+                        ->with('user', 'sparePartOrder')
+                        ->orderByDesc('created_at')
+                        ->limit(50)
+                        ->get();
+                @endphp
+                <div class="row mb-4">
+                    <div class="col-12">
+                        <h4>История движений <span class="badge bg-secondary">{{ $spare_part->movements()->count() }}</span></h4>
+                        @if($movements->count() > 0)
+                            <div class="table-responsive">
+                                <table class="table table-sm table-striped">
+                                    <thead>
+                                        <tr>
+                                            <th>Дата</th>
+                                            <th>Тип</th>
+                                            <th>Партия</th>
+                                            <th class="text-center">Кол-во</th>
+                                            <th class="text-center">С док.</th>
+                                            <th>Примечание</th>
+                                            <th>Пользователь</th>
+                                        </tr>
+                                    </thead>
+                                    <tbody>
+                                        @foreach($movements as $movement)
+                                            <tr class="@if($movement->movement_type === 'issue') table-danger @elseif($movement->movement_type === 'reserve') table-warning @elseif($movement->movement_type === 'receipt') table-success @elseif($movement->movement_type === 'reserve_cancel') table-info @endif">
+                                                <td>{{ $movement->created_at->format('d.m.Y H:i') }}</td>
+                                                <td>{{ $movement->type_name }}</td>
+                                                <td>
+                                                    @if($movement->sparePartOrder)
+                                                        <a href="{{ route('spare_part_orders.show', $movement->sparePartOrder->id) }}">#{{ $movement->sparePartOrder->id }}</a>
+                                                    @else
+                                                        -
+                                                    @endif
+                                                </td>
+                                                <td class="text-center">{{ $movement->qty }}</td>
+                                                <td class="text-center">
+                                                    @if($movement->with_documents)
+                                                        <i class="bi bi-check-circle text-success"></i>
+                                                    @else
+                                                        <i class="bi bi-x-circle text-muted"></i>
+                                                    @endif
+                                                </td>
+                                                <td>
+                                                    {{ $movement->note }}
+                                                    @if($movement->source_type === 'reclamation' && $movement->source_id)
+                                                        <br><small><a href="{{ route('reclamations.show', $movement->source_id) }}">Рекламация #{{ $movement->source_id }}</a></small>
+                                                    @endif
+                                                </td>
+                                                <td>{{ $movement->user->name ?? '-' }}</td>
+                                            </tr>
+                                        @endforeach
+                                    </tbody>
+                                </table>
+                            </div>
+                            @if($spare_part->movements()->count() > 50)
+                                <p class="text-muted">Показаны последние 50 движений из {{ $spare_part->movements()->count() }}</p>
+                            @endif
+                        @else
+                            <p class="text-muted">Движений пока нет</p>
+                        @endif
+                    </div>
+                </div>
+            @endif
         </div>
     </div>
 </div>

+ 110 - 18
resources/views/spare_parts/index.blade.php

@@ -95,7 +95,75 @@
 
             @elseif($tab === 'inventory')
                 {{-- Контроль наличия --}}
-                <h4 class="text-danger">Критический недостаток</h4>
+
+                {{-- Открытые дефициты --}}
+                <h4 class="text-danger"><i class="bi bi-exclamation-triangle-fill"></i> Открытые дефициты
+                    @if(isset($open_shortages) && $open_shortages->count() > 0)
+                        <span class="badge bg-danger">{{ $open_shortages->count() }}</span>
+                    @endif
+                </h4>
+                @if(isset($open_shortages) && $open_shortages->count() > 0)
+                    <div class="table-responsive">
+                        <table class="table table-danger table-striped">
+                            <thead>
+                                <tr>
+                                    <th>Картинка</th>
+                                    <th>Артикул</th>
+                                    <th>Рекламация</th>
+                                    <th class="text-center">Требуется</th>
+                                    <th class="text-center">Зарезервировано</th>
+                                    <th class="text-center">Не хватает</th>
+                                    <th class="text-center">С док.</th>
+                                    <th>Дата создания</th>
+                                </tr>
+                            </thead>
+                            <tbody>
+                                @foreach($open_shortages as $shortage)
+                                    <tr>
+                                        <td>
+                                            @if($shortage->sparePart && $shortage->sparePart->image)
+                                                <img src="{{ $shortage->sparePart->image }}" alt="{{ $shortage->sparePart->article }}" style="max-width: 50px;">
+                                            @endif
+                                        </td>
+                                        <td>
+                                            @if($shortage->sparePart)
+                                                <a href="{{ route('spare_parts.show', $shortage->sparePart->id) }}">{{ $shortage->sparePart->article }}</a>
+                                                @if($shortage->sparePart->used_in_maf)
+                                                    <br><small class="text-muted">{{ $shortage->sparePart->used_in_maf }}</small>
+                                                @endif
+                                            @else
+                                                -
+                                            @endif
+                                        </td>
+                                        <td>
+                                            @if($shortage->reclamation)
+                                                <a href="{{ route('reclamations.show', $shortage->reclamation->id) }}">#{{ $shortage->reclamation->id }}</a>
+                                            @else
+                                                -
+                                            @endif
+                                        </td>
+                                        <td class="text-center">{{ $shortage->required_qty }}</td>
+                                        <td class="text-center">{{ $shortage->reserved_qty }}</td>
+                                        <td class="text-center fw-bold">{{ $shortage->missing_qty }}</td>
+                                        <td class="text-center">
+                                            @if($shortage->with_documents)
+                                                <i class="bi bi-check-circle text-success"></i> Да
+                                            @else
+                                                <i class="bi bi-x-circle text-muted"></i> Нет
+                                            @endif
+                                        </td>
+                                        <td>{{ $shortage->created_at->format('d.m.Y H:i') }}</td>
+                                    </tr>
+                                @endforeach
+                            </tbody>
+                        </table>
+                    </div>
+                @else
+                    <p class="text-muted">Нет открытых дефицитов</p>
+                @endif
+
+                {{-- Запчасти с критическим недостатком --}}
+                <h4 class="text-danger mt-4">Запчасти с дефицитами</h4>
                 @if(isset($critical_shortages) && $critical_shortages->count() > 0)
                     <div class="table-responsive">
                         <table class="table table-danger table-striped">
@@ -103,9 +171,9 @@
                                 <tr>
                                     <th>Картинка</th>
                                     <th>Артикул</th>
-                                    <th>Кол-во без док</th>
-                                    <th>Кол-во с док</th>
-                                    <th>Примечание</th>
+                                    <th class="text-center">Свободно без док</th>
+                                    <th class="text-center">Свободно с док</th>
+                                    <th>Дефициты</th>
                                 </tr>
                             </thead>
                             <tbody>
@@ -116,20 +184,39 @@
                                                 <img src="{{ $sp->image }}" alt="{{ $sp->article }}" style="max-width: 50px;">
                                             @endif
                                         </td>
-                                        <td><a href="{{ route('spare_parts.show', $sp->id) }}">{{ $sp->article }}</a></td>
-                                        <td>{{ $sp->quantity_without_docs }}</td>
-                                        <td>{{ $sp->quantity_with_docs }}</td>
-                                        <td>Отсутствие детали</td>
+                                        <td>
+                                            <a href="{{ route('spare_parts.show', $sp->id) }}">{{ $sp->article }}</a>
+                                            @if($sp->used_in_maf)
+                                                <br><small class="text-muted">{{ $sp->used_in_maf }}</small>
+                                            @endif
+                                        </td>
+                                        <td class="text-center">{{ $sp->quantity_without_docs }}</td>
+                                        <td class="text-center">{{ $sp->quantity_with_docs }}</td>
+                                        <td>
+                                            @if(isset($sp->shortage_details))
+                                                @foreach($sp->shortage_details as $detail)
+                                                    <div class="small">
+                                                        <a href="{{ route('reclamations.show', $detail['reclamation_id']) }}">Рекл. #{{ $detail['reclamation_id'] }}</a>:
+                                                        не хватает <strong>{{ $detail['missing_qty'] }}</strong> шт.
+                                                        @if($detail['with_documents'])
+                                                            (с док.)
+                                                        @else
+                                                            (без док.)
+                                                        @endif
+                                                    </div>
+                                                @endforeach
+                                            @endif
+                                        </td>
                                     </tr>
                                 @endforeach
                             </tbody>
                         </table>
                     </div>
                 @else
-                    <p class="text-muted">Нет запчастей с критическим недостатком</p>
+                    <p class="text-muted">Нет запчастей с дефицитами</p>
                 @endif
 
-                <h4 class="text-warning mt-4">Ниже минимального остатка</h4>
+                <h4 class="text-warning mt-4"><i class="bi bi-exclamation-circle-fill"></i> Ниже минимального остатка</h4>
                 @if(isset($below_min_stock) && $below_min_stock->count() > 0)
                     <div class="table-responsive">
                         <table class="table table-warning table-striped">
@@ -137,9 +224,9 @@
                                 <tr>
                                     <th>Картинка</th>
                                     <th>Артикул</th>
-                                    <th>Текущий остаток</th>
-                                    <th>Минимальный остаток</th>
-                                    <th>Примечание</th>
+                                    <th class="text-center">Текущий остаток</th>
+                                    <th class="text-center">Минимальный остаток</th>
+                                    <th class="text-center">Нужно заказать</th>
                                 </tr>
                             </thead>
                             <tbody>
@@ -150,10 +237,15 @@
                                                 <img src="{{ $sp->image }}" alt="{{ $sp->article }}" style="max-width: 50px;">
                                             @endif
                                         </td>
-                                        <td><a href="{{ route('spare_parts.show', $sp->id) }}">{{ $sp->article }}</a></td>
-                                        <td>{{ $sp->total_quantity }}</td>
-                                        <td>{{ $sp->min_stock }}</td>
-                                        <td>Достигнут лимит минимального остатка ({{ $sp->min_stock }} шт)</td>
+                                        <td>
+                                            <a href="{{ route('spare_parts.show', $sp->id) }}">{{ $sp->article }}</a>
+                                            @if($sp->used_in_maf)
+                                                <br><small class="text-muted">{{ $sp->used_in_maf }}</small>
+                                            @endif
+                                        </td>
+                                        <td class="text-center">{{ $sp->total_quantity }}</td>
+                                        <td class="text-center">{{ $sp->min_stock }}</td>
+                                        <td class="text-center fw-bold">{{ max(0, $sp->min_stock - $sp->total_quantity) }}</td>
                                     </tr>
                                 @endforeach
                             </tbody>
@@ -217,7 +309,7 @@
 
             let url = "{{ route('spare_part_orders.index') }}" +
                       "?spare_part_id=" + sparePartId +
-                      "&status=in_stock&remaining_quantity_min=1";
+                      "&status=in_stock&available_qty_min=1";
 
             if (withDocs !== 'all') {
                 url += "&with_documents=" + withDocs;

+ 12 - 0
routes/web.php

@@ -18,6 +18,7 @@ use App\Http\Controllers\ScheduleController;
 use App\Http\Controllers\SparePartController;
 use App\Http\Controllers\SparePartInventoryController;
 use App\Http\Controllers\SparePartOrderController;
+use App\Http\Controllers\SparePartReservationController;
 use App\Http\Controllers\UserController;
 use App\Models\PricingCode;
 use App\Models\Role;
@@ -255,6 +256,17 @@ Route::middleware('auth:web')->group(function () {
         Route::delete('/{sparePartOrder}', [SparePartOrderController::class, 'destroy'])->name('destroy')->middleware('role:admin');
         Route::post('/{sparePartOrder}/ship', [SparePartOrderController::class, 'ship'])->name('ship')->middleware('role:admin,manager');
         Route::post('/{sparePartOrder}/set-in-stock', [SparePartOrderController::class, 'setInStock'])->name('set_in_stock')->middleware('role:admin,manager');
+        Route::post('/{sparePartOrder}/correct', [SparePartOrderController::class, 'correct'])->name('correct')->middleware('role:admin');
+    });
+
+    // Резервы запчастей
+    Route::prefix('spare-part-reservations')->name('spare_part_reservations.')->middleware('role:admin,manager')->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}/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');
     });
 
     // Контроль наличия