1
0

18 Commits a55bc00ef6 ... 1cd1038028

Autor SHA1 Mensagem Data
  Alexander Musikhin 1cd1038028 Reserve remaining reclamation quantity from stock há 2 semanas atrás
  Alexander Musikhin 03e83ee095 Move reclamation reservation reassignment to separate action há 2 semanas atrás
  Alexander Musikhin 88c48a6d99 Fix handed over MAF sum calculation in reports há 2 semanas atrás
  Alexander Musikhin b604fe18d9 Add autosave on blur for reclamation fields há 2 semanas atrás
  Alexander Musikhin 766148be55 Fix spare parts catalog filters and sorting há 2 semanas atrás
  Alexander Musikhin eb599a6dfe Fix spare parts filter and sorting state há 2 semanas atrás
  Alexander Musikhin 5312015457 Move min stock row to bottom in spare part card há 2 semanas atrás
  Alexander Musikhin 88071f325c Add order prefix to used parts in schedule há 2 semanas atrás
  Alexander Musikhin bf7b5ce896 Fix MAF move modal long order names há 2 semanas atrás
  Alexander Musikhin d0df5829a1 Filter and search MAF order bulk stock modal há 2 semanas atrás
  Alexander Musikhin f8e7317961 Block in-mount status without linked MAF orders há 2 semanas atrás
  Alexander Musikhin 290be109e2 Сделать поля формы ТН обязательными há 2 semanas atrás
  Alexander Musikhin 119ed5521d fix test clear db há 2 semanas atrás
  Alexander Musikhin e48a51a2c1 Обновить формирование и нумерацию ТН há 2 semanas atrás
  Alexander Musikhin 935026429e Fix spare part order stock receipt action há 2 semanas atrás
  Alexander Musikhin 944cb1f341 Add bulk spare part order stocking há 2 semanas atrás
  Alexander Musikhin 7dd5d41a38 Adjust schedule table separators há 2 semanas atrás
  Alexander Musikhin df1b9cf70f Fix reclamation report spare parts totals há 2 semanas atrás
49 ficheiros alterados com 1781 adições e 220 exclusões
  1. 4 0
      AGENTS.md
  2. 3 0
      CLAUDE.md
  3. 4 0
      Makefile
  4. 9 0
      app/Http/Controllers/Admin/AdminSettingsController.php
  5. 24 0
      app/Http/Controllers/Controller.php
  6. 22 1
      app/Http/Controllers/FilterController.php
  7. 8 4
      app/Http/Controllers/MafOrderController.php
  8. 29 1
      app/Http/Controllers/OrderController.php
  9. 7 6
      app/Http/Controllers/PricingCodeController.php
  10. 60 29
      app/Http/Controllers/ReportController.php
  11. 12 11
      app/Http/Controllers/ScheduleController.php
  12. 31 41
      app/Http/Controllers/SparePartController.php
  13. 36 0
      app/Http/Controllers/SparePartOrderController.php
  14. 25 6
      app/Http/Controllers/SparePartReservationController.php
  15. 1 0
      app/Http/Requests/CreteTtnRequest.php
  16. 23 8
      app/Models/Order.php
  17. 1 0
      app/Models/Setting.php
  18. 17 0
      app/Models/SparePartOrdersView.php
  19. 27 2
      app/Models/Ttn.php
  20. 2 1
      app/Services/Export/ExportYearDataService.php
  21. 9 2
      app/Services/GenerateDocumentsService.php
  22. 1 0
      app/Services/Import/ImportYearDataService.php
  23. 2 38
      app/Services/SparePartIssueService.php
  24. 176 0
      app/Services/SparePartReservationService.php
  25. 22 0
      database/migrations/2026_04_21_120000_add_departure_date_to_ttns_table.php
  26. 23 0
      resources/sass/app.scss
  27. 14 0
      resources/views/admin/settings/index.blade.php
  28. 1 1
      resources/views/layouts/menu.blade.php
  29. 143 6
      resources/views/maf_orders/index.blade.php
  30. 59 8
      resources/views/orders/show.blade.php
  31. 30 5
      resources/views/partials/table.blade.php
  32. 4 4
      resources/views/pricing_codes/index.blade.php
  33. 85 20
      resources/views/reclamations/edit.blade.php
  34. 1 1
      resources/views/reports/index.blade.php
  35. 7 7
      resources/views/schedule/index.blade.php
  36. 7 4
      resources/views/spare_part_orders/edit.blade.php
  37. 6 6
      resources/views/spare_parts/edit.blade.php
  38. 47 4
      resources/views/spare_parts/index.blade.php
  39. 2 0
      routes/web.php
  40. 51 0
      tests/Feature/AdminSettingsControllerTest.php
  41. 139 0
      tests/Feature/OrderControllerTest.php
  42. 92 0
      tests/Feature/ReportControllerTest.php
  43. 103 0
      tests/Feature/ScheduleControllerTest.php
  44. 99 0
      tests/Feature/SparePartOrderControllerTest.php
  45. 15 4
      tests/Feature/SparePartReservationControllerTest.php
  46. 16 0
      tests/TestCase.php
  47. 42 0
      tests/Unit/Models/TtnTest.php
  48. 43 0
      tests/Unit/Services/GenerateDocumentsServiceTest.php
  49. 197 0
      tests/Unit/Services/SparePartReservationServiceTest.php

+ 4 - 0
AGENTS.md

@@ -17,6 +17,10 @@ Stroyprofit CRM is a Laravel 11 application for managing orders of building stru
 - **Documents:** PHPSpreadsheet (Excel templates in /templates/)
 - **DevOps:** Docker Compose + Nginx + PHP-FPM
 
+## Development Notes
+- Tests, Artisan commands, and related project checks should be run inside containers.
+- Preferred commands: `make test` for tests and `docker compose exec app ...` for ad-hoc commands inside the application container.
+
 ## Project Structure
 ```
 strprfcrm/

+ 3 - 0
CLAUDE.md

@@ -16,6 +16,9 @@ make install              # Полная установка с миграция
 make application          # Bash в контейнер приложения
 ```
 
+Все тесты, `artisan`-команды и прочие проектные проверки запускать внутри контейнера.
+Предпочтительно использовать `make test`, а для точечных команд `docker compose exec app ...`.
+
 ### База данных
 ```bash
 make db-migrate           # Применить миграции

+ 4 - 0
Makefile

@@ -154,15 +154,19 @@ scheduler-log: ## Лог планировщика
 	$(compose) logs app-schedule -f
 
 test: ## Run tests
+	$(application) php artisan config:clear
 	$(application) php artisan test
 
 test-unit: ## Run unit tests only
+	$(application) php artisan config:clear
 	$(application) php artisan test --testsuite=Unit
 
 test-feature: ## Run feature tests only
+	$(application) php artisan config:clear
 	$(application) php artisan test --testsuite=Feature
 
 test-coverage: ## Run tests with coverage report (requires Xdebug or PCOV)
+	$(application) php artisan config:clear
 	$(application) php artisan test --coverage
 
 test-setup: ## Create test database (crm_testing)

+ 9 - 0
app/Http/Controllers/Admin/AdminSettingsController.php

@@ -29,6 +29,10 @@ class AdminSettingsController extends Controller
             'reclamationActRepresentativeUserId' => Setting::getInt(
                 Setting::KEY_RECLAMATION_ACT_REPRESENTATIVE_USER_ID
             ),
+            'ttnNextNumber' => Setting::getInt(
+                Setting::KEY_TTN_NEXT_NUMBER,
+                \App\Models\Ttn::getNextTtnNumber()
+            ),
         ]);
     }
 
@@ -37,6 +41,7 @@ class AdminSettingsController extends Controller
         $data = $request->validate([
             'default_maf_order_user_id' => ['nullable', 'integer', 'exists:users,id'],
             'reclamation_act_representative_user_id' => ['nullable', 'integer', 'exists:users,id'],
+            'ttn_next_number' => ['required', 'integer', 'min:1'],
         ]);
 
         Setting::set(
@@ -47,6 +52,10 @@ class AdminSettingsController extends Controller
             Setting::KEY_RECLAMATION_ACT_REPRESENTATIVE_USER_ID,
             $data['reclamation_act_representative_user_id'] ?? null
         );
+        Setting::set(
+            Setting::KEY_TTN_NEXT_NUMBER,
+            $data['ttn_next_number']
+        );
 
         return back()->with('success', 'Настройки сохранены.');
     }

+ 24 - 0
app/Http/Controllers/Controller.php

@@ -213,6 +213,30 @@ class Controller extends BaseController
                     continue;
                 }
 
+                if (($this->data['id'] ?? null) === 'spare_parts' && $filterName === 'pricing_codes_list') {
+                    $values = explode('||', $filterValue);
+                    $nonEmptyValues = array_values(array_filter($values, static fn ($value) => $value !== '-пусто-'));
+                    $includeEmpty = in_array('-пусто-', $values, true);
+
+                    $query->where(function (Builder $q) use ($nonEmptyValues, $includeEmpty) {
+                        if (!empty($nonEmptyValues)) {
+                            $q->whereHas('pricingCodes', function (Builder $pricingCodesQuery) use ($nonEmptyValues) {
+                                $pricingCodesQuery->whereIn('code', $nonEmptyValues);
+                            });
+                        }
+
+                        if ($includeEmpty) {
+                            if (!empty($nonEmptyValues)) {
+                                $q->orWhereDoesntHave('pricingCodes');
+                            } else {
+                                $q->whereDoesntHave('pricingCodes');
+                            }
+                        }
+                    });
+
+                    continue;
+                }
+
                 // Резолвим виртуальные столбцы и значения
                 [$dbColumn, $dbValue] = $this->resolveFilterColumn($filterName, $filterValue);
 

+ 22 - 1
app/Http/Controllers/FilterController.php

@@ -2,6 +2,7 @@
 
 namespace App\Http\Controllers;
 
+use App\Models\SparePartsView;
 use App\Http\Requests\FilterRequest;
 use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Facades\Schema;
@@ -110,6 +111,26 @@ class FilterController extends Controller
         }
         $gp = session('gp_' . $table);
 
+        if ($table === 'spare_parts' && $column === 'pricing_codes_list') {
+            $result = DB::table('pricing_codes as pc')
+                ->join('spare_part_pricing_code as sppc', 'sppc.pricing_code_id', '=', 'pc.id')
+                ->select('pc.code')
+                ->distinct()
+                ->orderBy('pc.code')
+                ->pluck('pc.code')
+                ->toArray();
+
+            $hasEmptyValue = SparePartsView::query()
+                ->doesntHave('pricingCodes')
+                ->exists();
+
+            if ($hasEmptyValue) {
+                array_unshift($result, '-пусто-');
+            }
+
+            return response()->json($result, 200, [], JSON_UNESCAPED_UNICODE|JSON_PRETTY_PRINT);
+        }
+
         $dbTable = self::DB_TABLES[$table];
 
         // Определяем реальный столбец БД
@@ -147,7 +168,7 @@ class FilterController extends Controller
             // Конвертация цен из копеек в рубли для отображения
             if (str_ends_with($dbColumn, '_price')) {
                 $result = array_map(function ($val) {
-                    if ($val === null) {
+                    if ($val === null || $val === '-пусто-') {
                         return $val;
                     }
                     return $val / 100;

+ 8 - 4
app/Http/Controllers/MafOrderController.php

@@ -52,12 +52,14 @@ class MafOrderController extends Controller
         $this->applyStableSorting($q);
 
         $this->data['maf_orders'] = $q->paginate($this->data['per_page'])->withQueryString();
-        $this->data['order_numbers'] = MafOrder::query()
+        $this->data['orderedOrderNumbers'] = MafOrder::query()
             ->whereNotNull('order_number')
             ->where('order_number', '!=', '')
+            ->where('status', 'заказан')
             ->orderBy('order_number')
             ->distinct()
-            ->pluck('order_number');
+            ->pluck('order_number')
+            ->values();
 
         return view('maf_orders.index', $this->data);
     }
@@ -105,11 +107,13 @@ class MafOrderController extends Controller
         ]);
 
         $orderNumber = trim((string) $validated['bulk_order_number']);
-        $query = MafOrder::query()->where('order_number', $orderNumber);
+        $query = MafOrder::query()
+            ->where('order_number', $orderNumber)
+            ->where('status', 'заказан');
 
         if (!$query->exists()) {
             throw ValidationException::withMessages([
-                'bulk_order_number' => 'Заказ не найден в выбранном году.',
+                'bulk_order_number' => 'Заказ со статусом "Заказан" не найден в выбранном году.',
             ]);
         }
 

+ 29 - 1
app/Http/Controllers/OrderController.php

@@ -157,6 +157,12 @@ class OrderController extends Controller
         if(isset($data['id'])) {
             $order = Order::query()->where('id', $data['id'])->first();
             $status = $order->order_status_id;
+            $statusErrors = $this->validateMountStatusChange($order, $data['order_status_id'] ?? null);
+
+            if ($statusErrors !== []) {
+                return $this->mountStatusValidationFailed($request, $statusErrors);
+            }
+
             $order->update($data);
             $order->refresh();
             if($order->order_status_id != $status) {
@@ -260,6 +266,27 @@ class OrderController extends Controller
         return redirect()->route('order.show', ['order' => $order, 'previous_url' => $request->get('previous_url')]);
     }
 
+    private function validateMountStatusChange(Order $order, mixed $newStatusId): array
+    {
+        if ((int) $newStatusId !== Order::STATUS_IN_MOUNT) {
+            return [];
+        }
+
+        return $order->getMountStatusErrors();
+    }
+
+    private function mountStatusValidationFailed(Request $request, array $errors)
+    {
+        if ($request->expectsJson() || $request->ajax()) {
+            return response()->json([
+                'message' => $errors[0],
+                'errors' => $errors,
+            ], 422);
+        }
+
+        return redirect()->back()->withInput()->with(['danger' => $errors]);
+    }
+
     /**
      * Display the specified resource.
      */
@@ -671,10 +698,11 @@ class OrderController extends Controller
     {
         $ttn = Ttn::query()->create([
             'year' => date('Y'),
-            'ttn_number'    => Ttn::getTtnNumber() + 1,
+            'ttn_number'    => Ttn::reserveNextTtnNumber(),
             'ttn_number_suffix' => 'И',
             'order_number'  => $request->order_number,
             'order_date'    => $request->order_date,
+            'departure_date' => $request->departure_date,
             'order_sum'     => $request->order_sum,
             'skus'          => json_encode($request->skus),
         ]);

+ 7 - 6
app/Http/Controllers/PricingCodeController.php

@@ -23,14 +23,15 @@ class PricingCodeController extends Controller
 
     public function index(Request $request)
     {
+        session(['gp_pricing_codes' => $request->query()]);
         $this->setSortAndOrderBy(new PricingCode(), $request);
 
-        $sortBy = $request->get('sortBy', 'code');
+        $sortBy = $this->data['sortBy'] ?? 'code';
         $allowedSort = ['id', 'type', 'code', 'description'];
         if (!in_array($sortBy, $allowedSort, true)) {
             $sortBy = 'code';
         }
-        $orderBy = $request->get('order') === 'desc' ? 'desc' : 'asc';
+        $orderBy = $this->data['orderBy'] ?? 'asc';
 
         $q = PricingCode::query();
 
@@ -72,13 +73,13 @@ class PricingCodeController extends Controller
             ->exists();
 
         if ($exists) {
-            return redirect()->route('pricing_codes.index')
+            return redirect()->route('pricing_codes.index', session('gp_pricing_codes', []))
                 ->with(['error' => 'Такой код уже существует для данного типа!']);
         }
 
         PricingCode::create($request->only(['type', 'code', 'description']));
 
-        return redirect()->route('pricing_codes.index')
+        return redirect()->route('pricing_codes.index', session('gp_pricing_codes', []))
             ->with(['success' => 'Код расценки успешно добавлен!']);
     }
 
@@ -90,7 +91,7 @@ class PricingCodeController extends Controller
 
         $pricingCode->update(['description' => $request->get('description')]);
 
-        return redirect()->route('pricing_codes.index')
+        return redirect()->route('pricing_codes.index', session('gp_pricing_codes', []))
             ->with(['success' => 'Расшифровка успешно обновлена!']);
     }
 
@@ -98,7 +99,7 @@ class PricingCodeController extends Controller
     {
         $pricingCode->delete();
 
-        return redirect()->route('pricing_codes.index')
+        return redirect()->route('pricing_codes.index', session('gp_pricing_codes', []))
             ->with(['success' => 'Код расценки успешно удалён!']);
     }
 

+ 60 - 29
app/Http/Controllers/ReportController.php

@@ -12,6 +12,7 @@ use App\Models\ProductSKU;
 use App\Models\Reclamation;
 use App\Models\ReclamationStatus;
 use App\Models\User;
+use Illuminate\Database\Eloquent\Collection;
 use Illuminate\Http\Request;
 use Illuminate\Support\Str;
 
@@ -28,6 +29,7 @@ class ReportController extends Controller
     {
         $mountStatuses = [5, 7, 8, 9, 10];
         $doneStatuses = [9, 10];
+        $handedOverStatus = Order::STATUS_HANDED_OVER;
         $objectTypes = ObjectType::query()->get()->pluck('name', 'id')->toArray();
         $this->data['objectTypes'] = $objectTypes;
         $user_ids = Order::query()->distinct()->get('user_id')->pluck('user_id')->toArray();
@@ -52,15 +54,9 @@ class ReportController extends Controller
             $query->whereIn('order_status_id', $mountStatuses);
         })->count();
 
-        // общая сумма mount
-        $this->data['totalMountSum'] = Price::format(
-            ProductSKU::query()->
-            whereHas('order', function ($query) use ($mountStatuses) {
-                $query->whereIn('order_status_id', $mountStatuses);
-            })
-            ->withSum('product', 'total_price')
-            ->get()
-            ->sum('product_sum_total_price')
+        // сумма сданных МАФ по площадкам со статусом "Сдана"
+        $this->data['totalHandedOverSum'] = Price::format(
+            $this->getDoneSumByStatus($handedOverStatus)
         );
 
         // done by managers
@@ -134,7 +130,15 @@ class ReportController extends Controller
             $this->data['reclamations'][$reclamationStatus] = Reclamation::query()->where('status_id', '=', $reclamationStatusId)->count();
         }
         $this->data['reclamationMafs'] = $this->data['reclamationDetails'] = [];
-        foreach (Reclamation::all() as $reclamation) {
+        $reclamations = Reclamation::query()
+            ->with([
+                'skus.product',
+                'details',
+                'spareParts',
+            ])
+            ->get();
+
+        foreach ($reclamations as $reclamation) {
             foreach ($reclamation->skus as $sku) {
                 $a = $sku->product->article;
                 if(isset($this->data['reclamationMafs'][$a])){
@@ -154,6 +158,8 @@ class ReportController extends Controller
             }
         }
 
+        $this->appendReclamationSparePartsToReport($reclamations);
+
 
         // svod
         $orderStatuses = OrderStatus::query()->get()->pluck('name', 'id')->toArray();
@@ -173,13 +179,7 @@ class ReportController extends Controller
         );
         // общая сумма done
         $this->data['totalDoneSum'] = Price::format(
-            ProductSKU::query()->
-            whereHas('order', function ($query) use ($doneStatuses) {
-                $query->whereIn('order_status_id', $doneStatuses);
-            })
-                ->withSum('product', 'total_price')
-                ->get()
-                ->sum('product_sum_total_price') / 100
+            $this->getDoneSumByStatus($handedOverStatus)
         );
 
         $districts = District::query()->get()->pluck('shortname', 'id')->toArray();
@@ -223,7 +223,7 @@ class ReportController extends Controller
             $this->data['byDistrict'][$district] = [
                 'name'  => $district,
                 'totalSum' => $this->getDistrictSum($districtId),
-                'doneSum' => $this->getDistrictSum($districtId, $doneStatuses),
+                'doneSum' => $this->getDistrictSum($districtId, [$handedOverStatus]),
                 'totalOrders' => $totalOrders,
                 'totalMafs' => $totalMafs,
                 'readyOrders' => $readyOrders,
@@ -263,6 +263,26 @@ class ReportController extends Controller
         return view('reports.index', $this->data);
     }
 
+    private function appendReclamationSparePartsToReport(Collection $reclamations): void
+    {
+        foreach ($reclamations as $reclamation) {
+            foreach ($reclamation->spareParts as $sparePart) {
+                $article = trim((string) $sparePart->article);
+                $quantity = (int) ($sparePart->pivot?->quantity ?? 0);
+
+                if ($article === '' || $quantity < 1) {
+                    continue;
+                }
+
+                if (isset($this->data['reclamationDetails'][$article])) {
+                    $this->data['reclamationDetails'][$article] += $quantity;
+                } else {
+                    $this->data['reclamationDetails'][$article] = $quantity;
+                }
+            }
+        }
+    }
+
     private function getOrderCount($districtId, $status = null, $type = null)
     {
         $q = Order::query()->where('district_id', '=', $districtId);
@@ -300,19 +320,30 @@ class ReportController extends Controller
     private function getDistrictSum($districtId, $done = false)
     {
         return Price::format(
-            ProductSKU::query()->
-            whereHas('order', function ($query) use ($done, $districtId) {
-                $query->where('district_id', $districtId);
-                if($done){
-                    $query->whereIn('order_status_id', $done);
-                }
-            })
-            ->withSum('product', 'total_price')
-            ->get()
-            ->sum('product_sum_total_price') / 100
+            $this->getDoneSumByStatus($done ?: null, $districtId)
         );
 
-}
+    }
+
+    private function getDoneSumByStatus(array|int|null $statuses = null, ?int $districtId = null): float
+    {
+        $query = ProductSKU::query()
+            ->join('orders', 'orders.id', '=', 'products_sku.order_id')
+            ->join('products', 'products.id', '=', 'products_sku.product_id')
+            ->where('orders.year', year())
+            ->where('products.year', year());
+
+        if ($districtId !== null) {
+            $query->where('orders.district_id', $districtId);
+        }
+
+        if ($statuses !== null && $statuses !== false) {
+            $statuses = is_array($statuses) ? $statuses : [$statuses];
+            $query->whereIn('orders.order_status_id', $statuses);
+        }
+
+        return ((float) $query->sum('products.total_price')) / 100;
+    }
 
 
 }

+ 12 - 11
app/Http/Controllers/ScheduleController.php

@@ -158,15 +158,6 @@ class ScheduleController extends Controller
     {
         $validated = $request->validated();
 
-        // delete all auto schedules for this order
-        if(isset($validated['delete_old_records'])) {
-            Schedule::query()
-                ->where('order_id', $validated['order_id'])
-                ->where('source', 'Площадки')
-                ->where('manual', false)
-                ->delete();
-        }
-
         // create all records in schedule
         $order = Order::query()
             ->where('id', $validated['order_id'])
@@ -176,11 +167,21 @@ class ScheduleController extends Controller
         if(!$order->brigadier_id) $errors[] = 'Не указан бригадир!';
         if(!$order->installation_date) $errors[] = 'Не указана дата монтажа!';
         if(!$order->install_days) $errors[] = 'Не указан срок монтажа!';
+        $errors = array_merge($errors, $order->getMountStatusErrors());
 
         if($errors) {
             return redirect()->route('order.show', $order->id)->with(['danger' => $errors]);
         }
 
+        // delete all auto schedules for this order
+        if(isset($validated['delete_old_records'])) {
+            Schedule::query()
+                ->where('order_id', $validated['order_id'])
+                ->where('source', 'Площадки')
+                ->where('manual', false)
+                ->delete();
+        }
+
         if(empty($validated['skus'])) {
             foreach ($order->products_sku as $psku)
                 $validated['skus'][] = $psku->id;
@@ -219,7 +220,7 @@ class ScheduleController extends Controller
                     'mafs' => $mafsText,
                     'mafs_count' => $mafsCount,
                     'brigadier_id' => $order->brigadier_id,
-                    'comment'   => $validated['comment'],
+                    'comment'   => $validated['comment'] ?? null,
                 ]);
             if($first && isset($validated['send_notifications'])) {
                 $first = false;
@@ -314,7 +315,7 @@ class ScheduleController extends Controller
 
                 $orderNumber = $reservation->sparePartOrder?->display_order_number ?? '-';
 
-                return $article . ' - ' . $orderNumber;
+                return $article . ' - з.' . $orderNumber;
             })
             ->filter()
             ->values();

+ 31 - 41
app/Http/Controllers/SparePartController.php

@@ -10,6 +10,7 @@ use App\Models\SparePart;
 use App\Models\SparePartsView;
 use Illuminate\Http\RedirectResponse;
 use Illuminate\Http\Request;
+use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Facades\Cache;
 use Illuminate\Support\Facades\File;
 use Illuminate\Support\Facades\Log;
@@ -63,13 +64,24 @@ class SparePartController extends Controller
 
         // Фильтры
         $this->createFilters($model, 'used_in_maf');
-
-        // Для range фильтров нужно использовать реальные поля БД (без _txt)
-        // но заголовки брать из header с _txt
-//        $this->createRangeFiltersForPrices($model, 'customer_price', 'expertise_price', 'min_stock');
-//        if (hasRole('admin')) {
-//            $this->createRangeFiltersForPrices($model, 'purchase_price');
-//        }
+        $this->data['filters']['customer_price_txt'] = [
+            'title' => $this->data['header']['customer_price_txt'],
+            'values' => [],
+        ];
+        $this->data['filters']['expertise_price_txt'] = [
+            'title' => $this->data['header']['expertise_price_txt'],
+            'values' => [],
+        ];
+        $this->data['filters']['pricing_codes_list'] = [
+            'title' => $this->data['header']['pricing_codes_list'],
+            'values' => [],
+        ];
+        if (hasRole('admin')) {
+            $this->data['filters']['purchase_price_txt'] = [
+                'title' => $this->data['header']['purchase_price_txt'],
+                'values' => [],
+            ];
+        }
 
         // Запрос
         $q = $model::query()->with('pricingCodes');
@@ -78,7 +90,18 @@ class SparePartController extends Controller
         $this->acceptSearch($q, $request);
 
         $this->setSortAndOrderBy($model, $request);
-        $this->applyStableSorting($q);
+        if ($request->get('sortBy') === 'pricing_codes_list') {
+            $this->data['sortBy'] = 'pricing_codes_list';
+            $q->orderBy(
+                DB::table('spare_part_pricing_code as sppc')
+                    ->join('pricing_codes as pc', 'pc.id', '=', 'sppc.pricing_code_id')
+                    ->selectRaw('MIN(pc.code)')
+                    ->whereColumn('sppc.spare_part_id', 'spare_parts_view.id'),
+                $this->data['orderBy'] ?? 'asc'
+            )->orderBy('id', $this->data['orderBy'] ?? 'asc');
+        } else {
+            $this->applyStableSorting($q);
+        }
 
         $this->data['spare_parts'] = $q->paginate($this->data['per_page'])->withQueryString();
         $this->data['strings'] = $this->data['spare_parts'];
@@ -303,37 +326,4 @@ class SparePartController extends Controller
         return response()->json($spareParts);
     }
 
-    /**
-     * Создание range фильтров для полей с ценами
-     * Использует правильные заголовки из header (_txt версии)
-     */
-    protected function createRangeFiltersForPrices(SparePart $model, string ...$columnNames): void
-    {
-        foreach ($columnNames as $columnName) {
-            // Определяем ключ заголовка
-            $headerKey = str_ends_with($columnName, '_price') ? $columnName . '_txt' : $columnName;
-
-            // Проверяем, есть ли заголовок
-            if (!isset($this->data['header'][$headerKey])) {
-                continue;
-            }
-
-            if (str_ends_with($columnName, '_price')) {
-                $min = $model::query()->min($columnName);
-                $max = $model::query()->max($columnName);
-
-                $this->data['ranges'][$columnName] = [
-                    'title' => $this->data['header'][$headerKey],
-                    'min' => $min ? $min / 100 : 0,
-                    'max' => $max ? $max / 100 : 0,
-                ];
-            } else {
-                $this->data['ranges'][$columnName] = [
-                    'title' => $this->data['header'][$headerKey],
-                    'min' => $model::query()->min($columnName) ?? 0,
-                    'max' => $model::query()->max($columnName) ?? 0,
-                ];
-            }
-        }
-    }
 }

+ 36 - 0
app/Http/Controllers/SparePartOrderController.php

@@ -10,6 +10,7 @@ use App\Models\SparePartOrdersView;
 use App\Services\SparePartIssueService;
 use Illuminate\Http\RedirectResponse;
 use Illuminate\Http\Request;
+use Illuminate\Validation\ValidationException;
 
 class SparePartOrderController extends Controller
 {
@@ -88,6 +89,13 @@ class SparePartOrderController extends Controller
         $this->applyStableSorting($q);
 
         $this->data['spare_part_orders'] = $q->paginate($this->data['per_page'])->withQueryString();
+        $this->data['order_numbers'] = SparePartOrder::query()
+            ->where('status', SparePartOrder::STATUS_ORDERED)
+            ->whereNotNull('order_number')
+            ->where('order_number', '!=', '')
+            ->orderBy('order_number')
+            ->distinct()
+            ->pluck('order_number');
         $this->data['strings'] = $this->data['spare_part_orders'];
         $this->data['tab'] = 'orders';
 
@@ -197,6 +205,34 @@ class SparePartOrderController extends Controller
             ->with(['success' => 'Статус изменён на "На складе"!']);
     }
 
+    /**
+     * Изменить статус всех заказов с одинаковым номером на "На складе"
+     */
+    public function setOrderInStock(Request $request): RedirectResponse
+    {
+        $validated = $request->validate([
+            'bulk_order_number' => 'required|string',
+        ]);
+
+        $orderNumber = trim((string) $validated['bulk_order_number']);
+        $query = SparePartOrder::query()
+            ->where('status', SparePartOrder::STATUS_ORDERED)
+            ->where('order_number', $orderNumber);
+
+        if (!$query->exists()) {
+            throw ValidationException::withMessages([
+                'bulk_order_number' => 'Заказ со статусом "Заказано" не найден в выбранном году.',
+            ]);
+        }
+
+        $query->update([
+            'status' => SparePartOrder::STATUS_IN_STOCK,
+        ]);
+
+        return redirect()->route('spare_part_orders.index', session('gp_spare_part_orders'))
+            ->with(['success' => 'Все позиции заказа переведены в статус "На складе"!']);
+    }
+
     /**
      * Коррекция остатка (инвентаризация)
      */

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

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

+ 1 - 0
app/Http/Requests/CreteTtnRequest.php

@@ -24,6 +24,7 @@ class CreteTtnRequest extends FormRequest
         return [
             'order_number'  => ['required', 'string'],
             'order_date'    => ['required', 'date'],
+            'departure_date' => ['required', 'date'],
             'order_sum'     => ['required', 'string'],
             'skus'          => ['required', 'array'],
         ];

+ 23 - 8
app/Models/Order.php

@@ -231,13 +231,7 @@ class Order extends Model
             && ($this->brigadier_id !== null)
             && ($this->installation_date !== null)
         ) {
-            $allMafConnected = true;
-            foreach ($this->products_sku as $sku) {
-                if($sku->maf_order) continue;
-                $allMafConnected = false;
-            }
-
-            if($allMafConnected) {
+            if ($this->canBeMovedToMountStatus()) {
                 $this->update(['order_status_id' => self::STATUS_IN_MOUNT]);
             }
         }
@@ -295,12 +289,33 @@ class Order extends Model
     {
         $errors = [];
         foreach($this->products_sku as $sku) {
-            if($sku->maf_order_id) continue;
+            $orderNumber = trim((string) ($sku->maf_order?->order_number ?? ''));
+            if($sku->maf_order_id && $orderNumber !== '') continue;
             $errors[] = 'МАФ ' . $sku->product->article . 'не имеет номера заказа МАФ!';
         }
         return $errors;
     }
 
+    public function getMountStatusErrors(): array
+    {
+        foreach ($this->products_sku as $sku) {
+            $orderNumber = trim((string) ($sku->maf_order?->order_number ?? ''));
+
+            if ($sku->maf_order_id !== null && $orderNumber !== '') {
+                continue;
+            }
+
+            return ['МАФ не привязан к заказу'];
+        }
+
+        return [];
+    }
+
+    public function canBeMovedToMountStatus(): bool
+    {
+        return $this->getMountStatusErrors() === [];
+    }
+
     public function canCreateHandover(): array
     {
         $errors = [];

+ 1 - 0
app/Models/Setting.php

@@ -8,6 +8,7 @@ class Setting extends Model
 {
     public const KEY_DEFAULT_MAF_ORDER_USER_ID = 'default_maf_order_user_id';
     public const KEY_RECLAMATION_ACT_REPRESENTATIVE_USER_ID = 'reclamation_act_representative_user_id';
+    public const KEY_TTN_NEXT_NUMBER = 'ttn_next_number';
 
     protected $fillable = [
         'key',

+ 17 - 0
app/Models/SparePartOrdersView.php

@@ -10,6 +10,23 @@ class SparePartOrdersView extends Model
 
     public $timestamps = false;
 
+    protected $fillable = [
+        'id',
+        'order_number',
+        'article',
+        'source_text',
+        'status',
+        'status_name',
+        'ordered_quantity',
+        'available_qty',
+        'with_documents',
+        'with_documents_text',
+        'note',
+        'user_name',
+        'created_at',
+        'spare_part_id',
+    ];
+
     const DEFAULT_SORT_BY = 'created_at';
 
     const STATUS_NAMES = [

+ 27 - 2
app/Models/Ttn.php

@@ -4,6 +4,7 @@ namespace App\Models;
 
 use Illuminate\Database\Eloquent\Model;
 use Illuminate\Database\Eloquent\Relations\BelongsTo;
+use Illuminate\Support\Facades\DB;
 
 class Ttn extends Model
 {
@@ -13,6 +14,7 @@ class Ttn extends Model
         'ttn_number_suffix',
         'order_number',
         'order_date',
+        'departure_date',
         'order_sum',
         'skus',
         'file_id',
@@ -23,8 +25,31 @@ class Ttn extends Model
         return $this->belongsTo(File::class);
     }
 
-    public static function getTtnNumber(): int
+    public static function getNextTtnNumber(): int
     {
-        return Ttn::query()->where('year', now()->year)->orderBy('ttn_number', 'desc')->first()?->ttn_number ?? 0;
+        return Setting::getInt(Setting::KEY_TTN_NEXT_NUMBER, static::detectNextTtnNumber()) ?? 1;
+    }
+
+    public static function reserveNextTtnNumber(): int
+    {
+        return DB::transaction(static function (): int {
+            $setting = Setting::query()
+                ->lockForUpdate()
+                ->firstOrNew(['key' => Setting::KEY_TTN_NEXT_NUMBER]);
+
+            $nextNumber = (int) ($setting->value ?: static::detectNextTtnNumber());
+
+            $setting->value = (string) ($nextNumber + 1);
+            $setting->save();
+
+            return $nextNumber;
+        });
+    }
+
+    private static function detectNextTtnNumber(): int
+    {
+        return (int) static::query()
+            ->where('year', now()->year)
+            ->max('ttn_number') + 1;
     }
 }

+ 2 - 1
app/Services/Export/ExportYearDataService.php

@@ -532,7 +532,7 @@ class ExportYearDataService
 
         $headers = [
             'id', 'ttn_number', 'ttn_number_suffix', 'order_number', 'order_date',
-            'order_sum', 'skus', 'file_path', 'created_at', 'updated_at'
+            'departure_date', 'order_sum', 'skus', 'file_path', 'created_at', 'updated_at'
         ];
 
         $this->writeRow($sheet, 1, $headers);
@@ -550,6 +550,7 @@ class ExportYearDataService
                 $ttn->ttn_number_suffix,
                 $ttn->order_number,
                 $ttn->order_date,
+                $ttn->departure_date,
                 $ttn->order_sum,
                 $ttn->skus,
                 $filePath,

+ 9 - 2
app/Services/GenerateDocumentsService.php

@@ -729,7 +729,8 @@ class GenerateDocumentsService
             $places += $sku->product->places;
         }
 
-        $installationDate = ($order->installation_date) ? DateHelper::getHumanDate($order->installation_date, true) : '-';
+        $departureDate = $ttn->departure_date ?? $order->installation_date;
+        $installationDate = $departureDate ? DateHelper::getHumanDate($departureDate, true) : '-';
         $ttnNumber = ($ttn->ttn_number_suffix) ? $ttn->ttn_number . '-' . $ttn->ttn_number_suffix : $ttn->ttn_number;
 
         $inputFileType = 'Xlsx';
@@ -774,7 +775,13 @@ class GenerateDocumentsService
         $sheet->setCellValue('AS89', $ttn->order_sum);
 
         // save file
-        $fileName = 'ТН №' . $ttn->ttn_number . ' от ' . DateHelper::getHumanDate($ttn->order_date) . '.xlsx';
+        $addressSuffix = trim((string) ($order->object_address ?? ''));
+        $safeAddressSuffix = str_replace(['\\', '/', ':', '*', '?', '"', '<', '>', '|'], ' ', $addressSuffix);
+        $fileName = 'ТН №' . $ttn->ttn_number . ' от ' . DateHelper::getHumanDate($departureDate ?? $ttn->order_date);
+        if ($safeAddressSuffix !== '') {
+            $fileName .= ' (' . preg_replace('/\s+/', ' ', $safeAddressSuffix) . ')';
+        }
+        $fileName .= '.xlsx';
         $writer = new Xlsx($spreadsheet);
         $fd = 'ttn/' . $ttn->year;
         Storage::disk('public')->makeDirectory($fd);

+ 1 - 0
app/Services/Import/ImportYearDataService.php

@@ -1011,6 +1011,7 @@ class ImportYearDataService
                 'ttn_number_suffix' => $this->getStringValue($row, $headerMap, 'ttn_number_suffix', ''),
                 'order_number' => $this->getStringValue($row, $headerMap, 'order_number', ''),
                 'order_date' => $this->getValue($row, $headerMap, 'order_date'),
+                'departure_date' => $this->getValue($row, $headerMap, 'departure_date'),
                 'order_sum' => $this->getNumericValue($row, $headerMap, 'order_sum', 0),
                 'skus' => $this->getValue($row, $headerMap, 'skus'),
                 'file_id' => $fileId,

+ 2 - 38
app/Services/SparePartIssueService.php

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

+ 176 - 0
app/Services/SparePartReservationService.php

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

+ 22 - 0
database/migrations/2026_04_21_120000_add_departure_date_to_ttns_table.php

@@ -0,0 +1,22 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    public function up(): void
+    {
+        Schema::table('ttns', function (Blueprint $table) {
+            $table->date('departure_date')->nullable()->after('order_date');
+        });
+    }
+
+    public function down(): void
+    {
+        Schema::table('ttns', function (Blueprint $table) {
+            $table->dropColumn('departure_date');
+        });
+    }
+};

+ 23 - 0
resources/sass/app.scss

@@ -1021,6 +1021,29 @@ td p {
   white-space: nowrap;
 }
 
+.schedule-week-table {
+  thead th {
+    border-top: 1px solid #000;
+    border-bottom: 1px solid #000;
+  }
+
+  tbody td {
+    border-bottom-color: #000;
+  }
+
+  tbody tr.schedule-week-address-separator td {
+    border-bottom-width: 1px;
+  }
+
+  tbody tr.schedule-week-day-end td {
+    border-bottom-width: 2px;
+  }
+
+  tbody td.schedule-week-day-cell {
+    border-bottom: 2px solid #000;
+  }
+}
+
 // Table sortable header styles (from reports/index.blade.php)
 .sortable-header {
   cursor: pointer;

+ 14 - 0
resources/views/admin/settings/index.blade.php

@@ -23,6 +23,20 @@
             'value' => old('reclamation_act_representative_user_id', $reclamationActRepresentativeUserId),
             'first_empty' => true,
         ])
+        @include('partials.input', [
+            'name' => 'ttn_next_number',
+            'title' => 'Следующий номер ТН',
+            'type' => 'number',
+            'min' => 1,
+            'step' => 1,
+            'required' => true,
+            'value' => old('ttn_next_number', $ttnNextNumber),
+        ])
+        <div class="row mb-3">
+            <div class="offset-md-4 col-md-8">
+                <div class="form-text">Следующая созданная ТН получит именно этот номер.</div>
+            </div>
+        </div>
 
         @include('partials.submit', ['name' => 'Сохранить'])
     </form>

+ 1 - 1
resources/views/layouts/menu.blade.php

@@ -15,7 +15,7 @@
 
     @if(hasRole('admin,manager'))
         <li class="nav-item"><a class="nav-link @if($active == 'spare_parts') active @endif"
-                                href="{{ route('spare_parts.index') }}">Запчасти</a></li>
+                                href="{{ route('spare_parts.index', session('gp_spare_parts', [])) }}">Запчасти</a></li>
     @endif
 
     <li class="nav-item"><a class="nav-link @if($active == 'schedule') active @endif"

+ 143 - 6
resources/views/maf_orders/index.blade.php

@@ -72,12 +72,18 @@
                         </div>
                         <div class="modal-body">
                             <label for="bulk_order_number" class="form-label">Номер заказа</label>
-                            <select class="form-select @error('bulk_order_number') is-invalid @enderror" id="bulk_order_number" name="bulk_order_number" required>
-                                <option value="">Выберите номер заказа</option>
-                                @foreach($order_numbers as $order_number)
-                                    <option value="{{ $order_number }}" @selected(old('bulk_order_number') === $order_number)>{{ $order_number }}</option>
-                                @endforeach
-                            </select>
+                            <div class="position-relative">
+                                <input type="text"
+                                       class="form-control @error('bulk_order_number') is-invalid @enderror"
+                                       id="bulk_order_number"
+                                       name="bulk_order_number"
+                                       value="{{ old('bulk_order_number') }}"
+                                       placeholder="Начните вводить номер заказа"
+                                       autocomplete="off"
+                                       required>
+                                <div class="autocomplete-dropdown autocomplete-dropdown--order" id="bulk_order_number_dropdown"></div>
+                            </div>
+                            <div class="form-text">Доступны только заказы со статусом «Заказан».</div>
                             @error('bulk_order_number')
                             <div class="invalid-feedback d-block">{{ $message }}</div>
                             @enderror
@@ -101,6 +107,137 @@
             $('#sku_form').slideDown();
         });
 
+        waitForJQuery(function () {
+            const $modal = $('#setOrderInStockModal');
+            const $input = $modal.find('#bulk_order_number');
+            const $dropdown = $modal.find('#bulk_order_number_dropdown');
+            const orderNumbers = @json(($orderedOrderNumbers ?? collect())->values());
+            let currentFocus = -1;
+
+            function escapeHtml(text) {
+                return String(text)
+                    .replace(/&/g, '&amp;')
+                    .replace(/</g, '&lt;')
+                    .replace(/>/g, '&gt;')
+                    .replace(/"/g, '&quot;')
+                    .replace(/'/g, '&#039;');
+            }
+
+            function escapeRegex(str) {
+                return String(str).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+            }
+
+            function highlightMatch(text, query) {
+                if (!text || !query) {
+                    return escapeHtml(text || '');
+                }
+
+                const safeText = escapeHtml(text);
+                const regex = new RegExp('(' + escapeRegex(query) + ')', 'gi');
+                return safeText.replace(regex, '<strong>$1</strong>');
+            }
+
+            function hideDropdown() {
+                $dropdown.hide();
+                currentFocus = -1;
+            }
+
+            function showDropdown(items, query) {
+                $dropdown.empty();
+                currentFocus = -1;
+
+                if (!items.length) {
+                    $dropdown.html('<div class="autocomplete-item text-muted">Ничего не найдено</div>');
+                    $dropdown.show();
+                    return;
+                }
+
+                items.forEach(function (item) {
+                    $dropdown.append(
+                        '<div class="autocomplete-item" data-value="' + escapeHtml(item) + '">' +
+                        '<div class="article">' + highlightMatch(item, query) + '</div>' +
+                        '</div>'
+                    );
+                });
+
+                $dropdown.show();
+            }
+
+            function selectValue(value) {
+                $input.val(value).removeClass('is-invalid');
+                hideDropdown();
+            }
+
+            function setActive($items) {
+                $items.removeClass('active');
+                if (currentFocus >= 0 && currentFocus < $items.length) {
+                    const $active = $items.eq(currentFocus);
+                    $active.addClass('active');
+                    $active[0].scrollIntoView({block: 'nearest'});
+                }
+            }
+
+            function searchOrderNumbers(query) {
+                const normalized = String(query || '').trim().toLowerCase();
+
+                if (normalized.length < 1) {
+                    showDropdown(orderNumbers.slice(0, 50), '');
+                    return;
+                }
+
+                const filtered = orderNumbers.filter(function (item) {
+                    return String(item).toLowerCase().includes(normalized);
+                });
+
+                showDropdown(filtered.slice(0, 50), normalized);
+            }
+
+            $input.on('input focus', function () {
+                searchOrderNumbers($(this).val());
+            });
+
+            $input.on('keydown', function (e) {
+                const $items = $dropdown.find('.autocomplete-item:not(.text-muted)');
+
+                if (e.key === 'ArrowDown') {
+                    e.preventDefault();
+                    currentFocus++;
+                    if (currentFocus >= $items.length) {
+                        currentFocus = 0;
+                    }
+                    setActive($items);
+                } else if (e.key === 'ArrowUp') {
+                    e.preventDefault();
+                    currentFocus--;
+                    if (currentFocus < 0) {
+                        currentFocus = $items.length - 1;
+                    }
+                    setActive($items);
+                } else if (e.key === 'Enter') {
+                    if ($items.length > 0 && currentFocus > -1) {
+                        e.preventDefault();
+                        selectValue(String($items.eq(currentFocus).data('value') || ''));
+                    }
+                } else if (e.key === 'Escape') {
+                    hideDropdown();
+                }
+            });
+
+            $dropdown.on('click', '.autocomplete-item:not(.text-muted)', function () {
+                selectValue(String($(this).data('value') || ''));
+            });
+
+            $(document).on('click', function (e) {
+                if (!$(e.target).closest('#bulk_order_number, #bulk_order_number_dropdown').length) {
+                    hideDropdown();
+                }
+            });
+
+            $modal.on('hidden.bs.modal', function () {
+                hideDropdown();
+            });
+        });
+
         @if($errors->has('bulk_order_number') && hasRole('admin,assistant_head'))
             const setOrderInStockModalElement = document.getElementById('setOrderInStockModal');
             if (setOrderInStockModalElement) {

+ 59 - 8
resources/views/orders/show.blade.php

@@ -406,9 +406,17 @@
                         <form action="{{ route('order.create-ttn') }}" method="post" id="ttnForm">
                             @csrf
                             <div>
-                                <input type="text" class="form-control mb-2" name="order_number" placeholder="Номер заказа">
-                                <input type="date" class="form-control mb-2" name="order_date" placeholder="Дата заказа" value="{{ date('Y-m-d') }}">
-                                <input type="number" class="form-control mb-2" name="order_sum" placeholder="Сумма заказа" value="0">
+                                <label for="ttn_order_number" class="form-label">Номер заявки</label>
+                                <input type="text" class="form-control mb-3" id="ttn_order_number" name="order_number" placeholder="Номер заявки" required>
+
+                                <label for="ttn_order_date" class="form-label">Дата заявки</label>
+                                <input type="date" class="form-control mb-3" id="ttn_order_date" name="order_date" value="{{ date('Y-m-d') }}" required>
+
+                                <label for="ttn_departure_date" class="form-label">Дата выезда</label>
+                                <input type="date" class="form-control mb-3" id="ttn_departure_date" name="departure_date" value="{{ $order->installation_date ?? '' }}" required>
+
+                                <label for="ttn_order_sum" class="form-label">Сумма</label>
+                                <input type="number" class="form-control mb-3" id="ttn_order_sum" name="order_sum" placeholder="Сумма" value="0" required>
                                 <button type="button" class="btn btn-primary" id="createTtn">Создать ТН</button>
                             </div>
                         </form>
@@ -417,6 +425,18 @@
             </div>
         </div>
 
+        <style>
+            #select_order {
+                max-width: 100%;
+                overflow-x: auto;
+            }
+
+            #select_order option {
+                white-space: normal;
+                word-break: break-word;
+            }
+        </style>
+
         <!-- Модальное окно переноса -->
         <div class="modal fade" id="moveModal" tabindex="-1" aria-labelledby="moveModalLabel" aria-hidden="true">
             <div class="modal-dialog modal-fullscreen-sm-down modal-lg">
@@ -455,7 +475,11 @@
                     function (data) {
                         $('#select_order').children().remove()
                         $.each(data, function (id, name) {
-                            $('#select_order').append('<option value=\'' + id + '\'>' + name + '</option>');
+                            $('#select_order').append($('<option>', {
+                                value: id,
+                                text: name,
+                                title: name,
+                            }));
                         });
                     }
                 );
@@ -529,6 +553,12 @@
             });
 
             $('#createTtn').on('click', function () {
+                const ttnForm = document.getElementById('ttnForm');
+                if (!ttnForm.reportValidity()) {
+                    return;
+                }
+
+                $('#ttnForm input[name="skus[]"]').remove();
                 let ids = Array();
                 $('.check-maf').each(function () {
                     if ($(this).prop('checked')) {
@@ -538,16 +568,22 @@
                 });
 
                 if (ids.length) {
-                    $('#ttnForm').submit();
+                    ttnForm.submit();
                 } else {
                     customAlert('Нужно выбрать МАФ для ТН!');
                 }
             });
         @endif
 
+        $('.update-once').on('focus', function () {
+            $(this).data('previous-value', $(this).val());
+        });
+
         $('.update-once').on('change', function () {
-            let v = $(this).val();
-            let n = $(this).attr('name');
+            let $field = $(this);
+            let v = $field.val();
+            let n = $field.attr('name');
+            let previousValue = $field.data('previous-value');
             $.post(
                 '{{ route('order.update', $order->id) }}',
                 {
@@ -556,6 +592,7 @@
                     [n]: v
                 },
                 function () {
+                    $field.data('previous-value', v);
                     $('.alerts').append(
                         '<div class="main-alert alert alert-success" role="alert">Площадка обновлена!</div>'
                     );
@@ -565,7 +602,21 @@
                         })
                     }, 3000);
                 }
-            );
+            ).fail(function (xhr) {
+                if (previousValue !== undefined) {
+                    $field.val(previousValue);
+                }
+
+                let errorText = xhr.responseJSON?.message || 'Не удалось обновить площадку!';
+                $('.alerts').append(
+                    '<div class="main-alert alert alert-danger" role="alert">' + errorText + '</div>'
+                );
+                setTimeout(function () {
+                    $('.main-alert').fadeTo(2000, 500).slideUp(500, function () {
+                        $(".main-alert").slideUp(500);
+                    })
+                }, 3000);
+            });
         });
     </script>
 @endpush

+ 30 - 5
resources/views/partials/table.blade.php

@@ -21,6 +21,10 @@
         <thead class="table-head-shadow">
         <tr>
             @foreach($header as $headerName => $headerTitle)
+                @php
+                    $normalizedHeaderName = str_replace('_txt', '', $headerName);
+                    $isCurrentSortColumn = $headerName === $sortBy || $normalizedHeaderName === $sortBy;
+                @endphp
                 <th scope="col" class="bg-primary-subtle column_{{ $headerName }}">
                     <div class="d-flex align-items-center justify-content-between">
 
@@ -28,7 +32,7 @@
                             {{ $headerTitle }}
                         </div>
                         <div class="text-center mx-1 @if($headerName !== 'actions') cursor-pointer @endif" data-name="{{ $headerName }}">
-                            @if($headerName !== 'actions' && ($headerName== $sortBy))
+                            @if($headerName !== 'actions' && $isCurrentSortColumn)
                                 @if($orderBy === 'asc')
                                     <i class="bi bi-arrow-down-square-fill text-primary"></i>
                                 @else
@@ -68,7 +72,7 @@
                                     bi-funnel
                                 @endif
                                 " id="{{$headerName}}"></i>
-                                @include('partials.newFilterElement', ['id' => $headerName, 'data' => $data, 'type' => $type, 'table' => $id, 'isSort' => $headerName == $sortBy, '$orderBy' => $orderBy])
+                                @include('partials.newFilterElement', ['id' => $headerName, 'data' => $data, 'type' => $type, 'table' => $id, 'isSort' => $isCurrentSortColumn, '$orderBy' => $orderBy])
                             </div>
                         @endif
                     </div>
@@ -585,9 +589,15 @@
             document.location.href = currentUrl.href;
         });
 
+        $('.change-order-status').on('focus', function () {
+            $(this).data('previous-value', $(this).val());
+        });
+
         $('.change-order-status').on('change', function () {
-            let orderStatusId = $(this).val();
-            let orderId = $(this).attr('data-order-id');
+            let $select = $(this);
+            let orderStatusId = $select.val();
+            let orderId = $select.attr('data-order-id');
+            let previousValue = $select.data('previous-value');
 
             $.post(
                 '{{ route('order.update') }}',
@@ -597,6 +607,7 @@
                     order_status_id: orderStatusId
                 },
                 function () {
+                    $select.data('previous-value', orderStatusId);
                     $('.alerts').append(
                         '<div class="main-alert alert alert-success" role="alert">Обновлён статус площадки!</div>'
                     );
@@ -606,7 +617,21 @@
                         })
                     }, 3000);
                 }
-            );
+            ).fail(function (xhr) {
+                if (previousValue !== undefined) {
+                    $select.val(previousValue);
+                }
+
+                let errorText = xhr.responseJSON?.message || 'Не удалось обновить статус площадки!';
+                $('.alerts').append(
+                    '<div class="main-alert alert alert-danger" role="alert">' + errorText + '</div>'
+                );
+                setTimeout(function () {
+                    $('.main-alert').fadeTo(2000, 500).slideUp(500, function () {
+                        $(".main-alert").slideUp(500);
+                    })
+                }, 3000);
+            });
         });
 
         $('.change-reclamation-status').on('change', function () {

+ 4 - 4
resources/views/pricing_codes/index.blade.php

@@ -9,12 +9,12 @@
             {{-- Навигация --}}
             <ul class="nav nav-tabs mb-3">
                 <li class="nav-item">
-                    <a class="nav-link" href="{{ route('spare_parts.index') }}">
+                    <a class="nav-link" href="{{ route('spare_parts.index', session('gp_spare_parts', [])) }}">
                         Каталог
                     </a>
                 </li>
                 <li class="nav-item">
-                    <a class="nav-link" href="{{ route('spare_part_orders.index') }}">
+                    <a class="nav-link" href="{{ route('spare_part_orders.index', session('gp_spare_part_orders', [])) }}">
                         Заказы деталей
                     </a>
                 </li>
@@ -24,13 +24,13 @@
                     </a>
                 </li>
                 <li class="nav-item">
-                    <a class="nav-link active" href="{{ route('pricing_codes.index') }}">
+                    <a class="nav-link active" href="{{ route('pricing_codes.index', session('gp_pricing_codes', [])) }}">
                         Справочник расшифровок
                     </a>
                 </li>
                 <li class="nav-item">
                     <a class="nav-link {{ ($tab ?? '') === 'help' ? 'active' : '' }}"
-                       href="{{ route('spare_parts.help') }}">
+                       href="{{ route('spare_parts.help', session('gp_spare_parts', [])) }}">
                         <i class="bi bi-question-circle"></i> Справка
                     </a>
                 </li>

+ 85 - 20
resources/views/reclamations/edit.blade.php

@@ -43,19 +43,19 @@
                     <input type="hidden" name="previous_url" value="{{ $previous_url ?? '' }}">
 
                     @include('partials.link', ['title' => 'Площадка', 'href' => route('order.show', ['order' => $reclamation->order_id, 'sync_year' => 1]), 'text' => $reclamation->order->common_name ?? ''])
-                    @include('partials.select', ['name' => 'status_id', 'title' => 'Статус', 'options' => $statuses, 'value' => $reclamation->status_id ?? old('status_id'), 'disabled' => !hasRole('admin,manager')])
-                    @include('partials.select', ['name' => 'user_id', 'title' => 'Менеджер', 'options' => $users, 'value' => $reclamation->user_id ?? old('user_id') ?? auth()->user()->id, 'disabled' => !hasRole('admin,manager')])
+                    @include('partials.select', ['name' => 'status_id', 'title' => 'Статус', 'options' => $statuses, 'value' => $reclamation->status_id ?? old('status_id'), 'disabled' => !hasRole('admin,manager'), 'classes' => ['update-once']])
+                    @include('partials.select', ['name' => 'user_id', 'title' => 'Менеджер', 'options' => $users, 'value' => $reclamation->user_id ?? old('user_id') ?? auth()->user()->id, 'disabled' => !hasRole('admin,manager'), 'classes' => ['update-once']])
                     @include('partials.input', ['name' => 'maf_installation_year', 'title' => 'Год установки МАФ', 'type' => 'text', 'value' => $reclamation->order->year, 'disabled' => true])
-                    @include('partials.input', ['name' => 'create_date', 'title' => 'Дата создания', 'type' => 'date', 'required' => true, 'value' => $reclamation->create_date ?? date('Y-m-d'), 'disabled' => !hasRole('admin,manager')])
-                    @include('partials.input', ['name' => 'finish_date', 'title' => 'Дата завершения', 'type' => 'date', 'required' => true, 'value' => $reclamation->finish_date ?? date('Y-m-d', strtotime('+30 days')), 'disabled' => !hasRole('admin,manager')])
-                    @include('partials.select', ['name' => 'brigadier_id', 'title' => 'Бригадир', 'options' => $brigadiers, 'value' => $reclamation->brigadier_id ?? old('brigadier_id'), 'disabled' => !hasRole('admin,manager'), 'first_empty' => true])
-                    @include('partials.input', ['name' => 'start_work_date', 'title' => 'Дата начала работ', 'type' => 'date', 'value' => $reclamation->start_work_date, 'disabled' => !hasRole('admin,manager')])
-                    @include('partials.input', ['name' => 'work_days', 'title' => 'Срок работ, дней', 'type' => 'number', 'min' => 1, 'value' => $reclamation->work_days, 'disabled' => !hasRole('admin,manager')])
-                    @include('partials.select', ['name' => 'reason', 'title' => 'Причина', 'size' => 6, 'value' => $reclamation->reason ?? '', 'options' => ['Вандализм', 'Гарантия', 'Сервисное обслуживание'], 'key_as_val' => true, 'disabled' => !hasRole('admin,manager')])
-                    @include('partials.input', ['name' => 'factory_reclamation_number', 'title' => '№ рекламации на фабрике', 'type' => 'text', 'value' => $reclamation->factory_reclamation_number ?? '', 'disabled' => !hasRole('admin,manager')])
-                    @include('partials.textarea', ['name' => 'guarantee', 'title' => 'Гарантии', 'size' => 6, 'value' => $reclamation->guarantee ?? '', 'disabled' => !hasRole('admin,manager')])
-                    @include('partials.textarea', ['name' => 'whats_done', 'title' => 'Что сделано', 'size' => 6, 'value' => $reclamation->whats_done ?? '', 'disabled' => !hasRole('admin,manager')])
-                    @include('partials.textarea', ['name' => 'comment', 'title' => 'Комментарий', 'size' => 6, 'value' => $reclamation->comment ?? '', 'disabled' => !hasRole('admin,manager')])
+                    @include('partials.input', ['name' => 'create_date', 'title' => 'Дата создания', 'type' => 'date', 'required' => true, 'value' => $reclamation->create_date ?? date('Y-m-d'), 'disabled' => !hasRole('admin,manager'), 'classes' => ['update-once']])
+                    @include('partials.input', ['name' => 'finish_date', 'title' => 'Дата завершения', 'type' => 'date', 'required' => true, 'value' => $reclamation->finish_date ?? date('Y-m-d', strtotime('+30 days')), 'disabled' => !hasRole('admin,manager'), 'classes' => ['update-once']])
+                    @include('partials.select', ['name' => 'brigadier_id', 'title' => 'Бригадир', 'options' => $brigadiers, 'value' => $reclamation->brigadier_id ?? old('brigadier_id'), 'disabled' => !hasRole('admin,manager'), 'first_empty' => true, 'classes' => ['update-once']])
+                    @include('partials.input', ['name' => 'start_work_date', 'title' => 'Дата начала работ', 'type' => 'date', 'value' => $reclamation->start_work_date, 'disabled' => !hasRole('admin,manager'), 'classes' => ['update-once']])
+                    @include('partials.input', ['name' => 'work_days', 'title' => 'Срок работ, дней', 'type' => 'number', 'min' => 1, 'value' => $reclamation->work_days, 'disabled' => !hasRole('admin,manager'), 'classes' => ['update-once']])
+                    @include('partials.select', ['name' => 'reason', 'title' => 'Причина', 'size' => 6, 'value' => $reclamation->reason ?? '', 'options' => ['Вандализм', 'Гарантия', 'Сервисное обслуживание'], 'key_as_val' => true, 'disabled' => !hasRole('admin,manager'), 'classes' => ['update-once']])
+                    @include('partials.input', ['name' => 'factory_reclamation_number', 'title' => '№ рекламации на фабрике', 'type' => 'text', 'value' => $reclamation->factory_reclamation_number ?? '', 'disabled' => !hasRole('admin,manager'), 'classes' => ['update-once']])
+                    @include('partials.textarea', ['name' => 'guarantee', 'title' => 'Гарантии', 'size' => 6, 'value' => $reclamation->guarantee ?? '', 'disabled' => !hasRole('admin,manager'), 'classes' => ['update-once']])
+                    @include('partials.textarea', ['name' => 'whats_done', 'title' => 'Что сделано', 'size' => 6, 'value' => $reclamation->whats_done ?? '', 'disabled' => !hasRole('admin,manager'), 'classes' => ['update-once']])
+                    @include('partials.textarea', ['name' => 'comment', 'title' => 'Комментарий', 'size' => 6, 'value' => $reclamation->comment ?? '', 'disabled' => !hasRole('admin,manager'), 'classes' => ['update-once']])
                     @include('partials.submit', ['name' => 'Сохранить', 'offset' => 5, 'disabled' => !hasRole('admin,manager'), 'backurl' => route('reclamations.index', session('gp_reclamations'))])
                 </form>
             </div>
@@ -318,21 +318,28 @@
                                                         <td class="text-end">
                                                             @if($issueCandidateOrders->count() > 1)
                                                                 <button type="button"
-                                                                        class="btn btn-sm btn-success"
-                                                                        title="Выбрать заказ для списания"
+                                                                        class="btn btn-sm btn-outline-primary"
+                                                                        title="Изменить заказ резерва"
                                                                         data-bs-toggle="modal"
-                                                                        data-bs-target="#issueReservationModal-{{ $reservation->id }}">
-                                                                    <i class="bi bi-check-lg"></i>
+                                                                        data-bs-target="#reassignReservationModal-{{ $reservation->id }}">
+                                                                    <i class="bi bi-pencil"></i>
                                                                 </button>
 
-                                                                <div class="modal fade" id="issueReservationModal-{{ $reservation->id }}" tabindex="-1" aria-labelledby="issueReservationModalLabel-{{ $reservation->id }}" aria-hidden="true">
+                                                                <form action="{{ route('spare_part_reservations.issue', $reservation) }}" method="POST" class="d-inline">
+                                                                    @csrf
+                                                                    <button type="submit" class="btn btn-sm btn-success" title="Списать">
+                                                                        <i class="bi bi-check-lg"></i>
+                                                                    </button>
+                                                                </form>
+
+                                                                <div class="modal fade" id="reassignReservationModal-{{ $reservation->id }}" tabindex="-1" aria-labelledby="reassignReservationModalLabel-{{ $reservation->id }}" aria-hidden="true">
                                                                     <div class="modal-dialog modal-dialog-scrollable">
                                                                         <div class="modal-content">
                                                                             <div class="modal-header">
-                                                                                <h1 class="modal-title fs-5" id="issueReservationModalLabel-{{ $reservation->id }}">Выбор заказа для списания</h1>
+                                                                                <h1 class="modal-title fs-5" id="reassignReservationModalLabel-{{ $reservation->id }}">Выбор заказа для резерва</h1>
                                                                                 <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
                                                                             </div>
-                                                                            <form action="{{ route('spare_part_reservations.issue', $reservation) }}" method="POST">
+                                                                            <form action="{{ route('spare_part_reservations.reassign', $reservation) }}" method="POST">
                                                                                 @csrf
                                                                                 <div class="modal-body">
                                                                                     <div class="mb-2 small text-muted">
@@ -365,7 +372,7 @@
                                                                                 </div>
                                                                                 <div class="modal-footer">
                                                                                     <button type="button" class="btn btn-outline-secondary btn-sm" data-bs-dismiss="modal">Закрыть</button>
-                                                                                    <button type="submit" class="btn btn-success btn-sm">Списать</button>
+                                                                                    <button type="submit" class="btn btn-primary btn-sm">Сохранить резерв</button>
                                                                                 </div>
                                                                             </form>
                                                                         </div>
@@ -851,6 +858,64 @@
             $(target).toggleClass('d-none', $(this).val() !== 'user');
         }).trigger('change');
 
+        $('.update-once').on('focus', function () {
+            $(this).data('previous-value', $(this).val());
+        });
+
+        $('.update-once').on('change', function () {
+            let $field = $(this);
+            let value = $field.val();
+            let fieldName = $field.attr('name');
+            let previousValue = $field.data('previous-value');
+
+            $.post(
+                '{{ route('reclamations.update', $reclamation) }}',
+                {
+                    '_token': '{{ csrf_token() }}',
+                    order_id: '{{ $reclamation->order_id }}',
+                    previous_url: '{{ $previous_url ?? '' }}',
+                    user_id: $('#user_id').val(),
+                    status_id: $('#status_id').val(),
+                    create_date: $('#create_date').val(),
+                    finish_date: $('#finish_date').val(),
+                    brigadier_id: $('#brigadier_id').val(),
+                    start_work_date: $('#start_work_date').val(),
+                    work_days: $('#work_days').val(),
+                    reason: $('#reason').val(),
+                    factory_reclamation_number: $('#factory_reclamation_number').val(),
+                    guarantee: $('#guarantee').val(),
+                    whats_done: $('#whats_done').val(),
+                    comment: $('#comment').val(),
+                    [fieldName]: value
+                },
+                function () {
+                    $field.data('previous-value', value);
+                    $('.alerts').append(
+                        '<div class="main-alert alert alert-success" role="alert">Рекламация обновлена!</div>'
+                    );
+                    setTimeout(function () {
+                        $('.main-alert').fadeTo(2000, 500).slideUp(500, function () {
+                            $('.main-alert').slideUp(500);
+                        });
+                    }, 3000);
+                }
+            ).fail(function (xhr) {
+                if (previousValue !== undefined) {
+                    $field.val(previousValue);
+                }
+
+                let errorText = xhr.responseJSON?.message || 'Не удалось обновить рекламацию!';
+                $('.alerts').append(
+                    '<div class="main-alert alert alert-danger" role="alert">' + errorText + '</div>'
+                );
+                setTimeout(function () {
+                    $('.main-alert').fadeTo(2000, 500).slideUp(500, function () {
+                        $('.main-alert').slideUp(500);
+                    });
+                }, 3000);
+            });
+        });
+
         // Инициализация для существующих строк
         $('.spare-part-row').not('.spare-part-template').each(function() {
             initSparePartAutocomplete($(this));

+ 1 - 1
resources/views/reports/index.blade.php

@@ -125,7 +125,7 @@
                 </div>
                 <div class="col-xl-4">
                     <div class="text-center text-success fs-4 fw-bold">
-                        {!! $totalMountSum !!}
+                        {!! $totalHandedOverSum !!}
                     </div>
                     <div class="text-center">
                         Сдано на сумму

+ 7 - 7
resources/views/schedule/index.blade.php

@@ -68,7 +68,7 @@
 
         @if($activeTab === 'week')
         <div class="table-responsive js-subtable-scroll">
-        <table class="table">
+        <table class="table schedule-week-table">
             <thead>
             <tr>
                 <th class="text-center vertical">День недели</th>
@@ -95,17 +95,17 @@
             @foreach($schedules as $dow => $schs)
                 @if($schs)
                     @foreach($schs as $schedule)
-                        <tr>
+                        <tr class="{{ $loop->last ? 'schedule-week-day-end' : 'schedule-week-address-separator' }}">
                             @if($loop->first)
                                 <td rowspan="{{ count($schs) }}"
-                                    class="vertical">{{ \App\Helpers\DateHelper::getHumanDayOfWeek($dow) }}
+                                    class="vertical schedule-week-day-cell">{{ \App\Helpers\DateHelper::getHumanDayOfWeek($dow) }}
                                     @if(hasRole('admin'))
                                         <i class="bi bi-calendar-plus text-primary ms-2 createSchedule"
                                            title="Новая запись" data-schedule-date="{{ $dow }}"></i>
                                     @endif
                                 </td>
                                 <td rowspan="{{ count($schs) }}"
-                                    class="vertical">{{ \App\Helpers\DateHelper::getHumanDate($dow) }}</td>
+                                    class="vertical schedule-week-day-cell">{{ \App\Helpers\DateHelper::getHumanDate($dow) }}</td>
                             @endif
                             <td style="background: {{ $schedule->brigadier->color }}"
                                 class="align-middle code-{{ $schedule->id }}">
@@ -179,16 +179,16 @@
                         </tr>
                     @endforeach
                 @else
-                    <tr>
+                    <tr class="schedule-week-day-end">
                         <td rowspan="1"
-                            class="vertical">{{ \App\Helpers\DateHelper::getHumanDayOfWeek($dow) }}
+                            class="vertical schedule-week-day-cell">{{ \App\Helpers\DateHelper::getHumanDayOfWeek($dow) }}
                             @if(hasRole('admin'))
                                 <i class="bi bi-calendar-plus text-primary ms-2 createSchedule"
                                    title="Новая запись" data-schedule-date="{{ $dow }}"></i>
                             @endif
                         </td>
                         <td rowspan="1"
-                            class="vertical">{{ \App\Helpers\DateHelper::getHumanDate($dow) }}</td>
+                            class="vertical schedule-week-day-cell">{{ \App\Helpers\DateHelper::getHumanDate($dow) }}</td>
                     </tr>
                 @endif
             @endforeach

+ 7 - 4
resources/views/spare_part_orders/edit.blade.php

@@ -101,10 +101,7 @@
                     <a href="{{ $previous_url ?? route('spare_part_orders.index') }}" class="btn btn-sm btn-secondary">Назад</a>
 
                     @if($spare_part_order && $spare_part_order->status === 'ordered' && hasRole('admin,manager'))
-                        <form action="{{ route('spare_part_orders.set_in_stock', $spare_part_order) }}" method="POST" class="d-inline">
-                            @csrf
-                            <button type="submit" class="btn btn-sm btn-info">Поступило на склад</button>
-                        </form>
+                        <button type="submit" class="btn btn-sm btn-info" form="set-in-stock-form">Поступило на склад</button>
                     @endif
 
                     @if($spare_part_order && hasRole('admin'))
@@ -218,6 +215,12 @@
     </div>
 </div>
 
+@if($spare_part_order && $spare_part_order->status === 'ordered' && hasRole('admin,manager'))
+    <form id="set-in-stock-form" action="{{ route('spare_part_orders.set_in_stock', $spare_part_order) }}" method="POST" class="d-none">
+        @csrf
+    </form>
+@endif
+
 @if($spare_part_order && hasRole('admin,manager'))
     {{-- Модальное окно отгрузки --}}
     <div class="modal fade" id="shipModal" tabindex="-1" aria-labelledby="shipModalLabel" aria-hidden="true">

+ 6 - 6
resources/views/spare_parts/edit.blade.php

@@ -259,12 +259,6 @@
                                         <td class="text-center">{{ $spare_part->reserved_with_docs }}</td>
                                         <td class="text-center fw-bold">{{ $spare_part->total_reserved }}</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
                                     <tr class="table-info">
                                         <td>Заказано</td>
                                         <td class="text-center">{{ $spare_part->ordered_without_docs }}</td>
@@ -277,6 +271,12 @@
                                         <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>

+ 47 - 4
resources/views/spare_parts/index.blade.php

@@ -10,13 +10,13 @@
             <ul class="nav nav-tabs mb-3">
                 <li class="nav-item">
                     <a class="nav-link {{ ($tab ?? 'catalog') === 'catalog' ? 'active' : '' }}"
-                       href="{{ route('spare_parts.index') }}">
+                       href="{{ route('spare_parts.index', session('gp_spare_parts', [])) }}">
                         Каталог
                     </a>
                 </li>
                 <li class="nav-item">
                     <a class="nav-link {{ ($tab ?? '') === 'orders' ? 'active' : '' }}"
-                       href="{{ route('spare_part_orders.index') }}">
+                       href="{{ route('spare_part_orders.index', session('gp_spare_part_orders', [])) }}">
                         Заказы деталей
                     </a>
                 </li>
@@ -28,14 +28,14 @@
                 </li>
                 @if(hasRole('admin'))
                 <li class="nav-item">
-                    <a class="nav-link" href="{{ route('pricing_codes.index') }}">
+                    <a class="nav-link" href="{{ route('pricing_codes.index', session('gp_pricing_codes', [])) }}">
                         Справочник расшифровок
                     </a>
                 </li>
                 @endif
                 <li class="nav-item">
                     <a class="nav-link {{ ($tab ?? '') === 'help' ? 'active' : '' }}"
-                       href="{{ route('spare_parts.help') }}">
+                       href="{{ route('spare_parts.help', session('gp_spare_parts', [])) }}">
                         <i class="bi bi-question-circle"></i> Справка
                     </a>
                 </li>
@@ -82,6 +82,9 @@
                     <div class="mb-3">
                         @if(hasRole('admin,manager'))
                             <a href="{{ route('spare_part_orders.create') }}" class="btn btn-sm btn-primary">Создать заказ</a>
+                            <button type="button" class="btn btn-sm btn-outline-primary" data-bs-toggle="modal" data-bs-target="#setOrderInStockModal">
+                                Отгрузить весь заказ
+                            </button>
                         @endif
                     </div>
 
@@ -307,6 +310,38 @@
 </div>
 @endif
 
+@if(hasRole('admin,manager') && ($tab ?? '') === 'orders')
+<div class="modal fade" id="setOrderInStockModal" tabindex="-1" aria-labelledby="setOrderInStockModalLabel" aria-hidden="true">
+    <div class="modal-dialog">
+        <div class="modal-content">
+            <form action="{{ route('spare_part_orders.set_order_in_stock') }}" method="POST">
+                @csrf
+                <div class="modal-header">
+                    <h1 class="modal-title fs-5" id="setOrderInStockModalLabel">Отгрузить весь заказ</h1>
+                    <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
+                </div>
+                <div class="modal-body">
+                    <label for="bulk_order_number" class="form-label">Номер заказа</label>
+                    <select class="form-select @error('bulk_order_number') is-invalid @enderror" id="bulk_order_number" name="bulk_order_number" required>
+                        <option value="">Выберите номер заказа</option>
+                        @foreach($order_numbers ?? [] as $order_number)
+                            <option value="{{ $order_number }}" @selected(old('bulk_order_number') === $order_number)>{{ $order_number }}</option>
+                        @endforeach
+                    </select>
+                    @error('bulk_order_number')
+                    <div class="invalid-feedback d-block">{{ $message }}</div>
+                    @enderror
+                </div>
+                <div class="modal-footer">
+                    <button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Отмена</button>
+                    <button type="submit" class="btn btn-primary btn-sm">Отгрузить заказ</button>
+                </div>
+            </form>
+        </div>
+    </div>
+</div>
+@endif
+
 @push('scripts')
 <script type="module">
     function waitForJQuery(callback) {
@@ -333,6 +368,14 @@
 
             window.location.href = url;
         });
+
+        @if($errors->has('bulk_order_number') && hasRole('admin,manager') && ($tab ?? '') === 'orders')
+            const setOrderInStockModalElement = document.getElementById('setOrderInStockModal');
+            if (setOrderInStockModalElement) {
+                const setOrderInStockModal = new bootstrap.Modal(setOrderInStockModalElement);
+                setOrderInStockModal.show();
+            }
+        @endif
     });
 </script>
 @endpush

+ 2 - 0
routes/web.php

@@ -333,6 +333,7 @@ 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');
         Route::post('/{sparePartOrder}/set-in-stock', [SparePartOrderController::class, 'setInStock'])->name('set_in_stock');
+        Route::post('/set-order-in-stock', [SparePartOrderController::class, 'setOrderInStock'])->name('set_order_in_stock');
         Route::post('/{sparePartOrder}/correct', [SparePartOrderController::class, 'correct'])->name('correct')->middleware('role:admin');
     });
 
@@ -341,6 +342,7 @@ Route::middleware('auth:web')->group(function () {
         Route::get('/reclamation/{reclamationId}', [SparePartReservationController::class, 'forReclamation'])->name('for_reclamation');
         Route::get('/shortages/{reclamationId}', [SparePartReservationController::class, 'shortagesForReclamation'])->name('shortages_for_reclamation');
         Route::post('/{reservation}/cancel', [SparePartReservationController::class, 'cancel'])->name('cancel');
+        Route::post('/{reservation}/reassign', [SparePartReservationController::class, 'reassign'])->name('reassign');
         Route::post('/{reservation}/issue', [SparePartReservationController::class, 'issue'])->name('issue');
         Route::post('/issue-all/{reclamationId}', [SparePartReservationController::class, 'issueAllForReclamation'])->name('issue_all');
         Route::post('/cancel-all/{reclamationId}', [SparePartReservationController::class, 'cancelAllForReclamation'])->name('cancel_all');

+ 51 - 0
tests/Feature/AdminSettingsControllerTest.php

@@ -0,0 +1,51 @@
+<?php
+
+namespace Tests\Feature;
+
+use App\Models\Role;
+use App\Models\Setting;
+use App\Models\User;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Tests\TestCase;
+
+class AdminSettingsControllerTest extends TestCase
+{
+    use RefreshDatabase;
+
+    protected $seed = true;
+
+    private User $adminUser;
+    private User $managerUser;
+
+    protected function setUp(): void
+    {
+        parent::setUp();
+
+        $this->adminUser = User::factory()->create(['role' => Role::ADMIN]);
+        $this->managerUser = User::factory()->create(['role' => Role::MANAGER]);
+    }
+
+    public function test_admin_can_store_ttn_next_number_setting(): void
+    {
+        $response = $this->actingAs($this->adminUser)
+            ->post(route('admin.settings.store'), [
+                'default_maf_order_user_id' => null,
+                'reclamation_act_representative_user_id' => null,
+                'ttn_next_number' => 25,
+            ]);
+
+        $response->assertRedirect();
+        $response->assertSessionHas('success');
+        $this->assertSame(25, Setting::getInt(Setting::KEY_TTN_NEXT_NUMBER));
+    }
+
+    public function test_manager_cannot_store_admin_settings(): void
+    {
+        $response = $this->actingAs($this->managerUser)
+            ->post(route('admin.settings.store'), [
+                'ttn_next_number' => 25,
+            ]);
+
+        $response->assertStatus(403);
+    }
+}

+ 139 - 0
tests/Feature/OrderControllerTest.php

@@ -3,6 +3,7 @@
 namespace Tests\Feature;
 
 use App\Jobs\GenerateInstallationPack;
+use App\Jobs\GenerateTtnPack;
 use App\Models\Dictionary\Area;
 use App\Models\Dictionary\District;
 use App\Models\File;
@@ -13,6 +14,8 @@ use App\Models\OrderStatus;
 use App\Models\Product;
 use App\Models\ProductSKU;
 use App\Models\Role;
+use App\Models\Setting;
+use App\Models\Ttn;
 use App\Models\User;
 use Illuminate\Foundation\Testing\RefreshDatabase;
 use Illuminate\Http\UploadedFile;
@@ -251,6 +254,101 @@ class OrderControllerTest extends TestCase
         ]);
     }
 
+    public function test_cannot_set_in_mount_status_when_order_has_unlinked_maf(): void
+    {
+        $product = Product::factory()->create();
+        $order = Order::factory()->readyToMount()->withBrigadier($this->brigadierUser)->create([
+            'installation_date' => '2026-04-25',
+        ]);
+
+        ProductSKU::factory()->create([
+            'order_id' => $order->id,
+            'product_id' => $product->id,
+            'maf_order_id' => null,
+        ]);
+
+        $response = $this->actingAs($this->adminUser)
+            ->post(route('order.update'), [
+                'id' => $order->id,
+                'order_status_id' => Order::STATUS_IN_MOUNT,
+            ]);
+
+        $response->assertRedirect();
+        $response->assertSessionHas('danger', function ($messages) {
+            return in_array('МАФ не привязан к заказу', (array) $messages, true);
+        });
+
+        $this->assertDatabaseHas('orders', [
+            'id' => $order->id,
+            'order_status_id' => Order::STATUS_READY_TO_MOUNT,
+        ]);
+    }
+
+    public function test_ajax_cannot_set_in_mount_status_when_maf_order_number_is_empty(): void
+    {
+        $product = Product::factory()->create();
+        $order = Order::factory()->readyToMount()->withBrigadier($this->brigadierUser)->create([
+            'installation_date' => '2026-04-25',
+        ]);
+        $mafOrder = MafOrder::factory()->create([
+            'product_id' => $product->id,
+            'order_number' => '',
+        ]);
+
+        ProductSKU::factory()->create([
+            'order_id' => $order->id,
+            'product_id' => $product->id,
+            'maf_order_id' => $mafOrder->id,
+        ]);
+
+        $response = $this->actingAs($this->adminUser)
+            ->withHeader('X-Requested-With', 'XMLHttpRequest')
+            ->post(route('order.update'), [
+                'id' => $order->id,
+                'order_status_id' => Order::STATUS_IN_MOUNT,
+            ]);
+
+        $response->assertStatus(422);
+        $response->assertJson([
+            'message' => 'МАФ не привязан к заказу',
+        ]);
+
+        $this->assertDatabaseHas('orders', [
+            'id' => $order->id,
+            'order_status_id' => Order::STATUS_READY_TO_MOUNT,
+        ]);
+    }
+
+    public function test_can_set_in_mount_status_when_all_mafs_are_linked_to_order_numbers(): void
+    {
+        $product = Product::factory()->create();
+        $order = Order::factory()->readyToMount()->withBrigadier($this->brigadierUser)->create([
+            'installation_date' => '2026-04-25',
+        ]);
+        $mafOrder = MafOrder::factory()->create([
+            'product_id' => $product->id,
+            'order_number' => 'MO-7788',
+        ]);
+
+        ProductSKU::factory()->create([
+            'order_id' => $order->id,
+            'product_id' => $product->id,
+            'maf_order_id' => $mafOrder->id,
+        ]);
+
+        $response = $this->actingAs($this->adminUser)
+            ->post(route('order.update'), [
+                'id' => $order->id,
+                'order_status_id' => Order::STATUS_IN_MOUNT,
+            ]);
+
+        $response->assertRedirect();
+        $this->assertDatabaseHas('orders', [
+            'id' => $order->id,
+            'order_status_id' => Order::STATUS_IN_MOUNT,
+        ]);
+    }
+
     public function test_manager_can_update_ready_date(): void
     {
         $order = Order::factory()->create([
@@ -879,6 +977,47 @@ class OrderControllerTest extends TestCase
         Bus::assertNotDispatched(GenerateInstallationPack::class);
     }
 
+    public function test_admin_can_create_ttn_with_departure_date_and_increment_counter(): void
+    {
+        Bus::fake();
+
+        Setting::set(Setting::KEY_TTN_NEXT_NUMBER, 10);
+
+        $product = Product::factory()->create();
+        $order = Order::factory()->create([
+            'installation_date' => '2026-04-15',
+        ]);
+        $sku = ProductSKU::factory()->create([
+            'order_id' => $order->id,
+            'product_id' => $product->id,
+        ]);
+
+        $response = $this->actingAs($this->adminUser)
+            ->post(route('order.create-ttn'), [
+                'order_number' => 'З-100',
+                'order_date' => '2026-04-07',
+                'departure_date' => '2026-04-08',
+                'order_sum' => '150000',
+                'skus' => [$sku->id],
+            ]);
+
+        $response->assertRedirect();
+        $response->assertSessionHas('success');
+
+        $this->assertDatabaseHas('ttns', [
+            'ttn_number' => 10,
+            'order_number' => 'З-100',
+            'order_date' => '2026-04-07',
+            'departure_date' => '2026-04-08',
+            'order_sum' => '150000',
+        ]);
+        $this->assertSame(11, Setting::getInt(Setting::KEY_TTN_NEXT_NUMBER));
+
+        $ttn = Ttn::query()->where('ttn_number', 10)->firstOrFail();
+        $this->assertSame([(string) $sku->id], array_map('strval', json_decode($ttn->skus, true)));
+        Bus::assertDispatched(GenerateTtnPack::class);
+    }
+
     // ==================== Export ====================
 
     public function test_can_export_orders(): void

+ 92 - 0
tests/Feature/ReportControllerTest.php

@@ -2,7 +2,14 @@
 
 namespace Tests\Feature;
 
+use App\Helpers\Price;
+use App\Models\Order;
+use App\Models\Product;
+use App\Models\ProductSKU;
+use App\Models\Reclamation;
+use App\Models\ReclamationDetail;
 use App\Models\Role;
+use App\Models\SparePart;
 use App\Models\User;
 use Illuminate\Foundation\Testing\RefreshDatabase;
 use Tests\TestCase;
@@ -78,6 +85,40 @@ class ReportControllerTest extends TestCase
         $response->assertViewHas('reclamationStatuses');
     }
 
+    public function test_reports_index_aggregates_reclamation_details_and_spare_parts(): void
+    {
+        $order = Order::factory()->create(['user_id' => $this->adminUser->id]);
+        $reclamation = Reclamation::factory()->create([
+            'order_id' => $order->id,
+            'user_id' => $this->adminUser->id,
+        ]);
+
+        ReclamationDetail::query()->create([
+            'reclamation_id' => $reclamation->id,
+            'name' => 'Петля',
+            'quantity' => 2,
+        ]);
+
+        $sparePart = SparePart::factory()->create([
+            'article' => 'SP-REPORT-001',
+        ]);
+
+        $reclamation->spareParts()->attach($sparePart->id, [
+            'quantity' => 3,
+            'with_documents' => false,
+            'status' => 'reserved',
+            'reserved_qty' => 3,
+            'issued_qty' => 0,
+        ]);
+
+        $response = $this->actingAs($this->adminUser)->get(route('reports.index'));
+
+        $response->assertViewHas('reclamationDetails', function (array $details) {
+            return ($details['Петля'] ?? null) === 2
+                && ($details['SP-REPORT-001'] ?? null) === 3;
+        });
+    }
+
     public function test_reports_index_has_by_district_data(): void
     {
         $response = $this->actingAs($this->adminUser)->get(route('reports.index'));
@@ -90,4 +131,55 @@ class ReportControllerTest extends TestCase
         $response->assertViewHas('totalSum');
         $response->assertViewHas('totalDoneSum');
     }
+
+    public function test_reports_index_counts_done_sum_only_for_handed_over_sites(): void
+    {
+        $handedOverOrder = Order::factory()->create([
+            'user_id' => $this->adminUser->id,
+            'order_status_id' => Order::STATUS_HANDED_OVER,
+        ]);
+        $handedOverProduct = Product::factory()->create([
+            'total_price' => 1000,
+        ]);
+        ProductSKU::factory()
+            ->forOrder($handedOverOrder)
+            ->forProduct($handedOverProduct)
+            ->create();
+
+        $inMountOrder = Order::factory()->create([
+            'user_id' => $this->adminUser->id,
+            'order_status_id' => Order::STATUS_IN_MOUNT,
+        ]);
+        $inMountProduct = Product::factory()->create([
+            'total_price' => 2000,
+        ]);
+        ProductSKU::factory()
+            ->forOrder($inMountOrder)
+            ->forProduct($inMountProduct)
+            ->create();
+
+        $handedOverWithNotesOrder = Order::factory()->create([
+            'user_id' => $this->adminUser->id,
+            'order_status_id' => Order::STATUS_HANDED_OVER_WITH_NOTES,
+        ]);
+        $handedOverWithNotesProduct = Product::factory()->create([
+            'total_price' => 3000,
+        ]);
+        ProductSKU::factory()
+            ->forOrder($handedOverWithNotesOrder)
+            ->forProduct($handedOverWithNotesProduct)
+            ->create();
+
+        $response = $this->actingAs($this->adminUser)->get(route('reports.index'));
+
+        $expectedSum = Price::format(1000);
+
+        $response->assertViewHas('totalHandedOverSum', $expectedSum);
+        $response->assertViewHas('totalDoneSum', $expectedSum);
+        $response->assertViewHas('byDistrict', function (array $byDistrict) use ($handedOverOrder, $expectedSum) {
+            $districtName = $handedOverOrder->district->shortname;
+
+            return ($byDistrict[$districtName]['doneSum'] ?? null) === $expectedSum;
+        });
+    }
 }

+ 103 - 0
tests/Feature/ScheduleControllerTest.php

@@ -4,7 +4,10 @@ namespace Tests\Feature;
 
 use App\Models\Dictionary\Area;
 use App\Models\Dictionary\District;
+use App\Models\MafOrder;
 use App\Models\Order;
+use App\Models\Product;
+use App\Models\ProductSKU;
 use App\Models\Reclamation;
 use App\Models\Role;
 use App\Models\Schedule;
@@ -281,6 +284,106 @@ class ScheduleControllerTest extends TestCase
         ]);
     }
 
+    public function test_cannot_create_schedule_from_order_when_order_has_unlinked_maf(): void
+    {
+        $product = Product::factory()->create();
+        $order = Order::factory()->readyToMount()->withBrigadier($this->brigadierUser)->create([
+            'installation_date' => '2026-04-22',
+            'install_days' => 2,
+        ]);
+
+        ProductSKU::factory()->create([
+            'order_id' => $order->id,
+            'product_id' => $product->id,
+            'maf_order_id' => null,
+        ]);
+
+        $response = $this->actingAs($this->adminUser)
+            ->post(route('schedule.create-from-order'), [
+                'order_id' => $order->id,
+                'delete_old_records' => '1',
+            ]);
+
+        $response->assertRedirect(route('order.show', $order->id));
+        $response->assertSessionHas('danger', function ($messages) {
+            return in_array('МАФ не привязан к заказу', (array) $messages, true);
+        });
+        $this->assertDatabaseCount('schedules', 0);
+    }
+
+    public function test_cannot_create_schedule_from_order_when_maf_order_number_is_empty(): void
+    {
+        $product = Product::factory()->create();
+        $order = Order::factory()->readyToMount()->withBrigadier($this->brigadierUser)->create([
+            'installation_date' => '2026-04-22',
+            'install_days' => 2,
+        ]);
+        $mafOrder = MafOrder::factory()->create([
+            'product_id' => $product->id,
+            'order_number' => '',
+        ]);
+
+        ProductSKU::factory()->create([
+            'order_id' => $order->id,
+            'product_id' => $product->id,
+            'maf_order_id' => $mafOrder->id,
+        ]);
+
+        Schedule::factory()->create([
+            'order_id' => $order->id,
+            'source' => 'Площадки',
+            'manual' => false,
+            'brigadier_id' => $this->brigadierUser->id,
+            'object_address' => $order->object_address,
+            'installation_date' => '2026-04-20',
+        ]);
+
+        $response = $this->actingAs($this->adminUser)
+            ->post(route('schedule.create-from-order'), [
+                'order_id' => $order->id,
+                'delete_old_records' => '1',
+            ]);
+
+        $response->assertRedirect(route('order.show', $order->id));
+        $response->assertSessionHas('danger', function ($messages) {
+            return in_array('МАФ не привязан к заказу', (array) $messages, true);
+        });
+        $this->assertDatabaseCount('schedules', 1);
+    }
+
+    public function test_can_create_schedule_from_order_when_all_mafs_are_linked_to_order_numbers(): void
+    {
+        $product = Product::factory()->create();
+        $order = Order::factory()->readyToMount()->withBrigadier($this->brigadierUser)->create([
+            'installation_date' => '2026-04-22',
+            'install_days' => 2,
+        ]);
+        $mafOrder = MafOrder::factory()->create([
+            'product_id' => $product->id,
+            'order_number' => 'MO-2026-1',
+        ]);
+
+        ProductSKU::factory()->create([
+            'order_id' => $order->id,
+            'product_id' => $product->id,
+            'maf_order_id' => $mafOrder->id,
+        ]);
+
+        $response = $this->actingAs($this->adminUser)
+            ->post(route('schedule.create-from-order'), [
+                'order_id' => $order->id,
+                'comment' => '',
+            ]);
+
+        $response->assertRedirect(route('schedule.index'));
+        $this->assertDatabaseCount('schedules', 2);
+        $this->assertDatabaseHas('schedules', [
+            'order_id' => $order->id,
+            'source' => 'Площадки',
+            'manual' => false,
+        ]);
+    }
+
     // ==================== Delete ====================
 
     public function test_admin_can_delete_schedule(): void

+ 99 - 0
tests/Feature/SparePartOrderControllerTest.php

@@ -70,6 +70,36 @@ class SparePartOrderControllerTest extends TestCase
         $response->assertViewIs('spare_parts.index');
     }
 
+    public function test_index_passes_only_ordered_order_numbers_to_view(): void
+    {
+        SparePartOrder::factory()->ordered()->create([
+            'order_number' => 'SP-BULK-001',
+        ]);
+        SparePartOrder::factory()->ordered()->create([
+            'order_number' => 'SP-BULK-001',
+        ]);
+        SparePartOrder::factory()->ordered()->create([
+            'order_number' => 'SP-BULK-002',
+        ]);
+        SparePartOrder::factory()->inStock()->create([
+            'order_number' => 'SP-IN-STOCK-001',
+        ]);
+        SparePartOrder::factory()->ordered()->create([
+            'order_number' => '',
+        ]);
+        SparePartOrder::factory()->ordered()->create([
+            'order_number' => null,
+        ]);
+
+        $response = $this->actingAs($this->adminUser)
+            ->get(route('spare_part_orders.index'));
+
+        $response->assertStatus(200);
+        $response->assertViewHas('order_numbers', function ($orderNumbers) {
+            return $orderNumbers->values()->all() === ['SP-BULK-001', 'SP-BULK-002'];
+        });
+    }
+
     // ==================== Show ====================
 
     public function test_admin_can_view_spare_part_order(): void
@@ -94,6 +124,19 @@ class SparePartOrderControllerTest extends TestCase
         $response->assertViewIs('spare_part_orders.edit');
     }
 
+    public function test_ordered_spare_part_order_page_renders_set_in_stock_button_with_separate_form(): void
+    {
+        $sparePartOrder = SparePartOrder::factory()->ordered()->create();
+
+        $response = $this->actingAs($this->managerUser)
+            ->get(route('spare_part_orders.show', $sparePartOrder));
+
+        $response->assertStatus(200);
+        $response->assertSee('form="set-in-stock-form"', false);
+        $response->assertSee('id="set-in-stock-form"', false);
+        $response->assertSee(route('spare_part_orders.set_in_stock', $sparePartOrder), false);
+    }
+
     public function test_guest_cannot_view_spare_part_order(): void
     {
         $sparePartOrder = SparePartOrder::factory()->create();
@@ -319,6 +362,62 @@ class SparePartOrderControllerTest extends TestCase
         $response->assertRedirect(route('login'));
     }
 
+    public function test_admin_can_set_whole_order_in_stock_by_order_number(): void
+    {
+        $first = SparePartOrder::factory()->ordered()->create([
+            'order_number' => 'SP-BULK-100',
+        ]);
+        $second = SparePartOrder::factory()->ordered()->create([
+            'order_number' => 'SP-BULK-100',
+        ]);
+        $sameNumberInStock = SparePartOrder::factory()->inStock()->create([
+            'order_number' => 'SP-BULK-100',
+        ]);
+        $other = SparePartOrder::factory()->ordered()->create([
+            'order_number' => 'SP-BULK-200',
+        ]);
+
+        $response = $this->actingAs($this->adminUser)
+            ->post(route('spare_part_orders.set_order_in_stock'), [
+                'bulk_order_number' => 'SP-BULK-100',
+            ]);
+
+        $response->assertRedirect(route('spare_part_orders.index'));
+        $response->assertSessionHas('success');
+        $this->assertDatabaseHas('spare_part_orders', [
+            'id' => $first->id,
+            'status' => SparePartOrder::STATUS_IN_STOCK,
+        ]);
+        $this->assertDatabaseHas('spare_part_orders', [
+            'id' => $second->id,
+            'status' => SparePartOrder::STATUS_IN_STOCK,
+        ]);
+        $this->assertDatabaseHas('spare_part_orders', [
+            'id' => $sameNumberInStock->id,
+            'status' => SparePartOrder::STATUS_IN_STOCK,
+        ]);
+        $this->assertDatabaseHas('spare_part_orders', [
+            'id' => $other->id,
+            'status' => SparePartOrder::STATUS_ORDERED,
+        ]);
+    }
+
+    public function test_bulk_set_order_in_stock_validates_selected_number_has_ordered_rows(): void
+    {
+        SparePartOrder::factory()->inStock()->create([
+            'order_number' => 'SP-NOT-ORDERED',
+        ]);
+
+        $response = $this->from(route('spare_part_orders.index'))
+            ->actingAs($this->adminUser)
+            ->post(route('spare_part_orders.set_order_in_stock'), [
+                'bulk_order_number' => 'SP-NOT-ORDERED',
+            ]);
+
+        $response->assertRedirect(route('spare_part_orders.index'));
+        $response->assertSessionHasErrors('bulk_order_number');
+    }
+
     // ==================== Ship ====================
 
     public function test_admin_can_ship_spare_part_order(): void

+ 15 - 4
tests/Feature/SparePartReservationControllerTest.php

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

+ 16 - 0
tests/TestCase.php

@@ -4,6 +4,7 @@ namespace Tests;
 
 use Illuminate\Foundation\Http\Middleware\ValidateCsrfToken;
 use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
+use RuntimeException;
 
 abstract class TestCase extends BaseTestCase
 {
@@ -11,6 +12,21 @@ abstract class TestCase extends BaseTestCase
     {
         parent::setUp();
 
+        $this->guardAgainstProductionDatabase();
+
         $this->withoutMiddleware(ValidateCsrfToken::class);
     }
+
+    private function guardAgainstProductionDatabase(): void
+    {
+        $database = config('database.connections.'.config('database.default').'.database');
+
+        if ($database !== 'crm_testing') {
+            throw new RuntimeException(sprintf(
+                'Refusing to run tests against database "%s". Expected "crm_testing". '.
+                'Likely cause: stale bootstrap/cache/config.php. Run "php artisan config:clear".',
+                $database
+            ));
+        }
+    }
 }

+ 42 - 0
tests/Unit/Models/TtnTest.php

@@ -0,0 +1,42 @@
+<?php
+
+namespace Tests\Unit\Models;
+
+use App\Models\Setting;
+use App\Models\Ttn;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Tests\TestCase;
+
+class TtnTest extends TestCase
+{
+    use RefreshDatabase;
+
+    public function test_reserve_next_ttn_number_uses_existing_setting_and_increments_it(): void
+    {
+        Setting::set(Setting::KEY_TTN_NEXT_NUMBER, 10);
+
+        $reservedNumber = Ttn::reserveNextTtnNumber();
+
+        $this->assertSame(10, $reservedNumber);
+        $this->assertSame(11, Setting::getInt(Setting::KEY_TTN_NEXT_NUMBER));
+    }
+
+    public function test_reserve_next_ttn_number_falls_back_to_max_number_for_current_year(): void
+    {
+        Ttn::query()->create([
+            'year' => now()->year,
+            'ttn_number' => 13,
+            'ttn_number_suffix' => 'И',
+            'order_number' => 'З-13',
+            'order_date' => '2026-04-01',
+            'departure_date' => '2026-04-02',
+            'order_sum' => '1000',
+            'skus' => json_encode([1], JSON_THROW_ON_ERROR),
+        ]);
+
+        $reservedNumber = Ttn::reserveNextTtnNumber();
+
+        $this->assertSame(14, $reservedNumber);
+        $this->assertSame(15, Setting::getInt(Setting::KEY_TTN_NEXT_NUMBER));
+    }
+}

+ 43 - 0
tests/Unit/Services/GenerateDocumentsServiceTest.php

@@ -8,6 +8,7 @@ use App\Models\Order;
 use App\Models\Product;
 use App\Models\ProductSKU;
 use App\Models\Reclamation;
+use App\Models\Ttn;
 use App\Models\User;
 use App\Services\GenerateDocumentsService;
 use Illuminate\Foundation\Testing\RefreshDatabase;
@@ -222,4 +223,46 @@ class GenerateDocumentsServiceTest extends TestCase
             $this->assertTrue(true);
         }
     }
+
+    public function test_generate_ttn_pack_uses_departure_date_and_address_in_filename(): void
+    {
+        if (!file_exists('./templates/Ttn.xlsx')) {
+            $this->markTestSkipped('Excel template Ttn.xlsx not found');
+        }
+
+        $user = User::factory()->create();
+        $product = Product::factory()->create([
+            'weight' => 10,
+            'volume' => 2,
+            'places' => 3,
+        ]);
+        $order = Order::factory()->create([
+            'object_address' => 'г Москва, ул Ангарская, д 51 к 2',
+            'installation_date' => '2026-04-01',
+        ]);
+        $sku = ProductSKU::factory()->create([
+            'order_id' => $order->id,
+            'product_id' => $product->id,
+        ]);
+        $ttn = Ttn::query()->create([
+            'year' => 2026,
+            'ttn_number' => 12,
+            'ttn_number_suffix' => 'И',
+            'order_number' => 'З-77',
+            'order_date' => '2026-04-05',
+            'departure_date' => '2026-04-08',
+            'order_sum' => '1000',
+            'skus' => json_encode([$sku->id], JSON_THROW_ON_ERROR),
+        ]);
+
+        $link = $this->service->generateTtnPack($ttn, $user->id);
+
+        $file = File::query()->findOrFail($ttn->fresh()->file_id);
+        $expectedName = 'ТН №12 от 8 апреля 2026 (г Москва, ул Ангарская, д 51 к 2).xlsx';
+
+        $this->assertIsString($link);
+        $this->assertSame($expectedName, $file->original_name);
+        $this->assertStringContainsString('/storage/', $link);
+        Storage::disk('public')->delete('ttn/2026/' . $expectedName);
+    }
 }

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

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