فهرست منبع

refactored spare parts

Alexander Musikhin 2 هفته پیش
والد
کامیت
16cf697bf7

+ 1 - 1
app/Http/Controllers/PricingCodeController.php

@@ -9,7 +9,7 @@ use Illuminate\Http\Request;
 class PricingCodeController extends Controller
 {
     protected array $data = [
-        'active' => 'spare_parts',
+        'active' => 'pricing_codes',
         'title' => 'Справочник расшифровок',
         'id' => 'pricing_codes',
         'header' => [

+ 2 - 0
app/Http/Controllers/SparePartController.php

@@ -128,6 +128,8 @@ class SparePartController extends Controller
 
     public function help()
     {
+        $this->data['active'] = 'spare_parts_help';
+        $this->data['title'] = 'Справка по модулю «Запчасти»';
         $markdownPath = base_path('docs/spare-parts.md');
         $markdown = File::exists($markdownPath) ? File::get($markdownPath) : '# Справка не найдена';
         $this->data['helpContent'] = Str::markdown($markdown, [

+ 1 - 1
app/Http/Controllers/SparePartInventoryController.php

@@ -8,7 +8,7 @@ use Illuminate\Http\Request;
 class SparePartInventoryController extends Controller
 {
     protected array $data = [
-        'active' => 'spare_parts',
+        'active' => 'spare_part_inventory',
         'title' => 'Контроль наличия запчастей',
         'id' => 'spare_part_inventory',
     ];

+ 5 - 5
app/Http/Controllers/SparePartOrderController.php

@@ -15,8 +15,8 @@ use Illuminate\Validation\ValidationException;
 class SparePartOrderController extends Controller
 {
     protected array $data = [
-        'active' => 'spare_parts',
-        'title' => 'Заказы деталей',
+        'active' => 'spare_part_orders',
+        'title' => 'Заказы запчастей',
         'id' => 'spare_part_orders',
         'header' => [
             'id' => 'ID',
@@ -149,7 +149,7 @@ class SparePartOrderController extends Controller
             $nav,
             route('spare_part_orders.index', session('gp_spare_part_orders'))
         );
-        return redirect()->to($backUrl)->with(['success' => 'Заказ детали успешно создан!']);
+        return redirect()->to($backUrl)->with(['success' => 'Заказ запчасти успешно создан!']);
     }
 
     public function update(StoreSparePartOrderRequest $request, SparePartOrder $sparePartOrder): RedirectResponse
@@ -162,7 +162,7 @@ class SparePartOrderController extends Controller
             $nav,
             route('spare_part_orders.index', session('gp_spare_part_orders'))
         );
-        return redirect()->to($backUrl)->with(['success' => 'Заказ детали успешно обновлён!']);
+        return redirect()->to($backUrl)->with(['success' => 'Заказ запчасти успешно обновлён!']);
     }
 
     public function destroy(Request $request, SparePartOrder $sparePartOrder): RedirectResponse
@@ -176,7 +176,7 @@ class SparePartOrderController extends Controller
         $sparePartOrder->delete();
 
         return redirect()->route('spare_part_orders.index', session('gp_spare_part_orders'))
-            ->with(['success' => 'Заказ детали успешно удалён!']);
+            ->with(['success' => 'Заказ запчасти успешно удалён!']);
     }
 
     /**

+ 15 - 15
docs/refactor/plan.md

@@ -69,8 +69,8 @@
 
 | Этап | Статус | Почему |
 |---|---|---|
-| 1. Реорганизация меню и заглушки | Готов к реализации | Решены структура меню, права для заглушек и сохранение текущих маршрутов. |
-| 2. Адаптация существующих разделов после переноса | Готов к реализации | ДКР, запчасти, администратор и текущий график монтажей уже существуют; нужны проверки и точечные правки без смены логики. |
+| 1. Реорганизация меню и заглушки | Реализован и проверен | Новое меню и заглушки приняты по результатам пользовательской проверки. |
+| 2. Адаптация существующих разделов после переноса | Реализован, ожидает пользовательской проверки | Рабочие разделы проверены, навигация запчастей и доступ к справочнику уточнены, регрессионные тесты проходят. |
 | 3. Рекламации: вкладки и источник | Готов к реализации | Решены вкладки `Все`/`ДКР`, поле источника и запрет платежных документов для `Прочее`. |
 | 4. Общий каталог: ядро | Готов к реализации | Есть маршрутный префикс, техническое имя, шаблон Excel, состав колонок и предварительные типы данных. |
 | 5. Документация | Готов к реализации | Решены дерево папок, наследование прав, версии и хранение файлов. |
@@ -82,7 +82,7 @@
 
 ## Этап 1. Реорганизация меню и заглушки
 
-Статус: **реализован, ожидает пользовательской проверки**.
+Статус: **реализован и проверен пользователем**.
 
 - [x] Проверить текущие permissions для всех существующих пунктов меню.
 - [x] Для новых пунктов-заглушек открыть доступ всем авторизованным пользователям.
@@ -100,24 +100,24 @@
 - [x] Добавить заглушки `Документация`, `Каталог общий`, `Калькуляции`.
 - [x] Проверить, что выбранный год не скрывает и не фильтрует новые модули.
 - [x] Переименовать группу `Администрирование` в `Администратор`.
-- [ ] Проверить отображение меню для основных ролей.
+- [x] Проверить отображение меню для основных ролей.
 
 ## Этап 2. Адаптация существующих разделов после переноса
 
-Статус: **готов к реализации**.
+Статус: **реализован, ожидает пользовательской проверки**.
 
 Задача этапа — проверить существующие рабочие разделы после изменения меню и внести точечные правки без изменения бизнес-логики.
 
-- [ ] Проверить, что разделы ДКР открываются из группы `ДКР`.
-- [ ] Проверить, что каталог ДКР остался внутри группы `ДКР`.
-- [ ] Проверить, что текущий `График монтажей` открывается из группы `Графики`.
-- [ ] Проверить, что раздел `Запчасти` работает в новой структуре меню без изменения бизнес-логики.
-- [ ] Проверить вкладки `Каталог`, `Заказы запчастей`, `Контроль наличия`, `Справочник расшифровок`, `Справка`.
-- [ ] Проверить, что `Запчасти` используются как общий модуль, включая ДКР.
-- [ ] Проверить, что меню `Администратор` содержит текущие пункты администрирования.
-- [ ] Проверить, что `Подрядчики` всегда отображаются внутри группы `Администратор`.
-- [ ] Проверить доступы для пользователей, ролей и настроек.
-- [ ] При необходимости добавить права для новых разделов Manager 2.0.
+- [x] Проверить, что разделы ДКР открываются из группы `ДКР`.
+- [x] Проверить, что каталог ДКР остался внутри группы `ДКР`.
+- [x] Проверить, что текущий `График монтажей` открывается из группы `Графики`.
+- [x] Проверить, что раздел `Запчасти` работает в новой структуре меню без изменения бизнес-логики.
+- [x] Проверить вкладки `Каталог`, `Заказы запчастей`, `Контроль наличия`, `Справочник расшифровок`, `Справка`.
+- [x] Проверить, что `Запчасти` используются как общий модуль, включая ДКР.
+- [x] Проверить, что меню `Администратор` содержит текущие пункты администрирования.
+- [x] Проверить, что `Подрядчики` всегда отображаются внутри группы `Администратор`.
+- [x] Проверить доступы для пользователей, ролей и настроек.
+- [x] Проверить необходимость новых прав: на этом этапе новые права не потребовались.
 
 ## Этап 3. Рекламации: вкладки и источник
 

+ 8 - 8
docs/refactor/tz-admin.md

@@ -129,14 +129,14 @@
 
 ## 8. Этапы реализации
 
-- [ ] Переименовать группу меню `Администрирование` в `Администратор`.
-- [ ] Сохранить все текущие подпункты.
-- [ ] Перенести `Подрядчики` внутрь группы `Администратор` без отдельного верхнего пункта.
-- [ ] Проверить отображение для администратора.
-- [ ] Проверить отображение для пользователя с частичными административными правами.
-- [ ] Проверить, что пользователь только с `contractors.view` видит группу `Администратор` и пункт `Подрядчики` внутри нее.
-- [ ] Проверить маршруты всех подпунктов.
-- [ ] Проверить, что текущие права работают без изменений.
+- [x] Переименовать группу меню `Администрирование` в `Администратор`.
+- [x] Сохранить все текущие подпункты.
+- [x] Перенести `Подрядчики` внутрь группы `Администратор` без отдельного верхнего пункта.
+- [x] Проверить отображение для администратора.
+- [x] Проверить отображение для пользователя с частичными административными правами.
+- [x] Проверить, что пользователь только с `contractors.view` видит группу `Администратор` и пункт `Подрядчики` внутри нее.
+- [x] Проверить маршруты всех подпунктов.
+- [x] Проверить, что текущие права работают без изменений.
 
 ## 9. Критерии приемки
 

+ 9 - 9
docs/refactor/tz-dkr.md

@@ -208,15 +208,15 @@
 
 ## 14. Этапы реализации
 
-- [ ] Проверить текущие маршруты и permissions разделов ДКР.
-- [ ] Добавить верхний пункт меню `ДКР`.
-- [ ] Перенести пункты `Площадки`, `МАФ`, `Каталог`, `Отчеты`, `Ответственные`, `Заказы МАФ` внутрь `ДКР`.
-- [ ] Проверить, что `catalog.index` доступен как каталог ДКР.
-- [ ] Проверить активное состояние меню для каждого подраздела ДКР.
-- [ ] Проверить отображение меню для ролей с разным набором прав.
-- [ ] Проверить, что существующие страницы ДКР открываются по прежним маршрутам.
-- [ ] Проверить связи площадок с рекламациями.
-- [ ] Проверить связи площадок с графиком монтажей.
+- [x] Проверить текущие маршруты и permissions разделов ДКР.
+- [x] Добавить верхний пункт меню `ДКР`.
+- [x] Перенести пункты `Площадки`, `МАФ`, `Каталог`, `Отчеты`, `Ответственные`, `Заказы МАФ` внутрь `ДКР`.
+- [x] Проверить, что `catalog.index` доступен как каталог ДКР.
+- [x] Проверить активное состояние меню для каждого подраздела ДКР.
+- [x] Проверить отображение меню для ролей с разным набором прав.
+- [x] Проверить, что существующие страницы ДКР открываются по прежним маршрутам.
+- [x] Проверить связи площадок с рекламациями.
+- [x] Проверить связи площадок с графиком монтажей.
 
 ## 15. Критерии приемки
 

+ 12 - 12
docs/refactor/tz-schedules.md

@@ -264,18 +264,18 @@
 
 ## 11. Этапы реализации
 
-- [ ] Проверить текущие маршруты `schedule.*`.
-- [ ] Проверить текущие permissions графика монтажей.
-- [ ] Добавить верхнюю группу меню `Графики`.
-- [ ] Перенести текущий пункт `График монтажей` внутрь `Графики`.
-- [ ] Оставить маршрут `schedule.index` без переименования.
-- [ ] Добавить страницу-заглушку `График заказов`.
-- [ ] Добавить страницу-заглушку `График доставок`.
-- [ ] Проверить активное состояние меню для `Графики`.
-- [ ] Проверить, что текущий график монтажей открывается и работает.
-- [ ] Проверить создание записи графика из площадки/заказа.
-- [ ] Проверить создание записи графика из рекламации.
-- [ ] Проверить экспорт графика монтажей.
+- [x] Проверить текущие маршруты `schedule.*`.
+- [x] Проверить текущие permissions графика монтажей.
+- [x] Добавить верхнюю группу меню `Графики`.
+- [x] Перенести текущий пункт `График монтажей` внутрь `Графики`.
+- [x] Оставить маршрут `schedule.index` без переименования.
+- [x] Добавить страницу-заглушку `График заказов`.
+- [x] Добавить страницу-заглушку `График доставок`.
+- [x] Проверить активное состояние меню для `Графики`.
+- [x] Проверить, что текущий график монтажей открывается и работает.
+- [x] Проверить создание записи графика из площадки/заказа.
+- [x] Проверить создание записи графика из рекламации.
+- [x] Проверить экспорт графика монтажей.
 - [ ] Составить отдельный список полей для будущего графика заказов.
 - [ ] Составить отдельный список полей для будущего графика доставок.
 - [ ] Реализовать карточку заказа с выбором одного или нескольких МАФ.

+ 13 - 13
docs/refactor/tz-spare-parts.md

@@ -171,19 +171,19 @@
 
 ## 12. Этапы реализации
 
-- [ ] Проверить текущие маршруты `spare_parts.*`.
-- [ ] Проверить текущие маршруты `spare_part_orders.*`.
-- [ ] Проверить текущие маршруты `spare_part_inventory.index`.
-- [ ] Проверить текущие маршруты `pricing_codes.*`.
-- [ ] Перенести меню `Запчасти` в новую структуру без изменения маршрутов.
-- [ ] Переименовать пункт `Заказы деталей` в `Заказы запчастей`.
-- [ ] Открыть пункт `Справочник расшифровок` всем пользователям с доступом к модулю `Запчасти`.
-- [ ] Проверить каталог запчастей.
-- [ ] Проверить заказы запчастей.
-- [ ] Проверить контроль наличия.
-- [ ] Проверить справочник расшифровок.
-- [ ] Проверить связь с рекламациями.
-- [ ] Зафиксировать логику модуля как основу для `Склад наличие`.
+- [x] Проверить текущие маршруты `spare_parts.*`.
+- [x] Проверить текущие маршруты `spare_part_orders.*`.
+- [x] Проверить текущие маршруты `spare_part_inventory.index`.
+- [x] Проверить текущие маршруты `pricing_codes.*`.
+- [x] Перенести меню `Запчасти` в новую структуру без изменения маршрутов.
+- [x] Переименовать пункт `Заказы деталей` в `Заказы запчастей`.
+- [x] Открыть пункт `Справочник расшифровок` всем пользователям с доступом к модулю `Запчасти`.
+- [x] Проверить каталог запчастей.
+- [x] Проверить заказы запчастей.
+- [x] Проверить контроль наличия.
+- [x] Проверить справочник расшифровок.
+- [x] Проверить связь с рекламациями.
+- [x] Зафиксировать логику модуля как основу для `Склад наличие`.
 
 ## 13. Критерии приемки
 

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

@@ -22,6 +22,7 @@
             'spare_part_orders',
             'spare_part_inventory',
             'pricing_codes',
+            'spare_parts_help',
         ], true);
 
         $stockActive = in_array($active ?? '', ['stock', 'stock_orders'], true);
@@ -97,7 +98,7 @@
                 @if(hasPermission('spare_part_orders.view'))<li><a class="dropdown-item @if(($active ?? '') === 'spare_part_orders') active @endif" href="{{ route('spare_part_orders.index') }}">Заказы запчастей</a></li>@endif
                 @if(hasPermission('spare_part_inventory.view'))<li><a class="dropdown-item @if(($active ?? '') === 'spare_part_inventory') active @endif" href="{{ route('spare_part_inventory.index') }}">Контроль наличия</a></li>@endif
                 <li><a class="dropdown-item @if(($active ?? '') === 'pricing_codes') active @endif" href="{{ route('pricing_codes.index') }}">Справочник расшифровок</a></li>
-                <li><a class="dropdown-item" href="{{ route('spare_parts.help') }}">Справка</a></li>
+                <li><a class="dropdown-item @if(($active ?? '') === 'spare_parts_help') active @endif" href="{{ route('spare_parts.help') }}">Справка</a></li>
             </ul>
         </li>
     @endif

+ 28 - 22
resources/views/partials/table.blade.php

@@ -215,30 +215,36 @@
                                 <span class="badge bg-primary">Шифр расценки</span>
                             @endif
                         @elseif($id === 'pricing_codes' && $headerName === 'description')
-                            <div class="d-flex justify-content-between align-items-center">
-                                <span class="description-text-{{ $string->id }}">{{ $string->description }}</span>
-                                <button type="button" class="btn btn-sm btn-link edit-description"
-                                        data-id="{{ $string->id }}"
-                                        data-description="{{ $string->description }}">
-                                    <i class="bi bi-pencil"></i>
-                                </button>
-                            </div>
-                            <form action="{{ route('pricing_codes.update', $string) }}" method="POST" class="edit-form-{{ $string->id }} is-hidden">
-                                @csrf
-                                @method('PUT')
-                                <div class="input-group input-group-sm">
-                                    <input type="text" name="description" class="form-control" value="{{ $string->description }}">
-                                    <button type="submit" class="btn btn-success">Сохранить</button>
-                                    <button type="button" class="btn btn-secondary cancel-edit" data-id="{{ $string->id }}">Отмена</button>
+                            @if(hasPermission('pricing_codes.update'))
+                                <div class="d-flex justify-content-between align-items-center">
+                                    <span class="description-text-{{ $string->id }}">{{ $string->description }}</span>
+                                    <button type="button" class="btn btn-sm btn-link edit-description"
+                                            data-id="{{ $string->id }}"
+                                            data-description="{{ $string->description }}">
+                                        <i class="bi bi-pencil"></i>
+                                    </button>
                                 </div>
-                            </form>
+                                <form action="{{ route('pricing_codes.update', $string) }}" method="POST" class="edit-form-{{ $string->id }} is-hidden">
+                                    @csrf
+                                    @method('PUT')
+                                    <div class="input-group input-group-sm">
+                                        <input type="text" name="description" class="form-control" value="{{ $string->description }}">
+                                        <button type="submit" class="btn btn-success">Сохранить</button>
+                                        <button type="button" class="btn btn-secondary cancel-edit" data-id="{{ $string->id }}">Отмена</button>
+                                    </div>
+                                </form>
+                            @else
+                                {{ $string->description }}
+                            @endif
                         @elseif($id === 'pricing_codes' && $headerName === 'actions')
-                            <form action="{{ route('pricing_codes.destroy', $string) }}" method="POST" class="d-inline js-confirm-submit"
-                                  data-confirm-message="Удалить код {{ $string->code }}?">
-                                @csrf
-                                @method('DELETE')
-                                <button type="submit" class="btn btn-sm btn-danger">Удалить</button>
-                            </form>
+                            @if(hasPermission('pricing_codes.delete'))
+                                <form action="{{ route('pricing_codes.destroy', $string) }}" method="POST" class="d-inline js-confirm-submit"
+                                      data-confirm-message="Удалить код {{ $string->code }}?">
+                                    @csrf
+                                    @method('DELETE')
+                                    <button type="submit" class="btn btn-sm btn-danger">Удалить</button>
+                                </form>
+                            @endif
                         @elseif($id === 'contractor_prices' && $headerName === 'status_name')
                             @if($string->available)
                                 <span class="badge text-bg-success">Доступен</span>

+ 23 - 15
resources/views/pricing_codes/index.blade.php

@@ -13,16 +13,20 @@
                         Каталог
                     </a>
                 </li>
-                <li class="nav-item">
-                    <a class="nav-link" href="{{ route('spare_part_orders.index', session('gp_spare_part_orders', [])) }}">
-                        Заказы деталей
-                    </a>
-                </li>
-                <li class="nav-item">
-                    <a class="nav-link" href="{{ route('spare_part_inventory.index') }}">
-                        Контроль наличия
-                    </a>
-                </li>
+                @if(hasPermission('spare_part_orders.view'))
+                    <li class="nav-item">
+                        <a class="nav-link" href="{{ route('spare_part_orders.index', session('gp_spare_part_orders', [])) }}">
+                            Заказы запчастей
+                        </a>
+                    </li>
+                @endif
+                @if(hasPermission('spare_part_inventory.view'))
+                    <li class="nav-item">
+                        <a class="nav-link" href="{{ route('spare_part_inventory.index') }}">
+                            Контроль наличия
+                        </a>
+                    </li>
+                @endif
                 <li class="nav-item">
                     <a class="nav-link active" href="{{ route('pricing_codes.index', session('gp_pricing_codes', [])) }}">
                         Справочник расшифровок
@@ -37,11 +41,13 @@
             </ul>
 
             {{-- Кнопки управления --}}
-            <div class="mb-3">
-                <button type="button" class="btn btn-sm btn-primary" data-bs-toggle="modal" data-bs-target="#addCodeModal">
-                    Добавить код
-                </button>
-            </div>
+            @if(hasPermission('pricing_codes.create'))
+                <div class="mb-3">
+                    <button type="button" class="btn btn-sm btn-primary" data-bs-toggle="modal" data-bs-target="#addCodeModal">
+                        Добавить код
+                    </button>
+                </div>
+            @endif
 
             @if(isset($pricing_codes))
                 @include('partials.table', [
@@ -63,6 +69,7 @@
     </div>
 </div>
 
+@if(hasPermission('pricing_codes.create'))
 {{-- Модальное окно добавления кода --}}
 <div class="modal fade" id="addCodeModal" tabindex="-1" aria-labelledby="addCodeModalLabel" aria-hidden="true">
     <div class="modal-dialog">
@@ -98,6 +105,7 @@
         </div>
     </div>
 </div>
+@endif
 
 @push('scripts')
 <script type="module">

+ 16 - 14
resources/views/spare_parts/index.blade.php

@@ -14,25 +14,27 @@
                         Каталог
                     </a>
                 </li>
-                <li class="nav-item">
-                    <a class="nav-link {{ ($tab ?? '') === 'orders' ? 'active' : '' }}"
-                       href="{{ route('spare_part_orders.index', session('gp_spare_part_orders', [])) }}">
-                        Заказы деталей
-                    </a>
-                </li>
-                <li class="nav-item">
-                    <a class="nav-link {{ ($tab ?? '') === 'inventory' ? 'active' : '' }}"
-                       href="{{ route('spare_part_inventory.index') }}">
-                        Контроль наличия
-                    </a>
-                </li>
-                @if(hasAnyPermission(['spare_parts.create', 'spare_parts.import', 'spare_parts.export', 'spare_parts.delete']))
+                @if(hasPermission('spare_part_orders.view'))
+                    <li class="nav-item">
+                        <a class="nav-link {{ ($tab ?? '') === 'orders' ? 'active' : '' }}"
+                           href="{{ route('spare_part_orders.index', session('gp_spare_part_orders', [])) }}">
+                            Заказы запчастей
+                        </a>
+                    </li>
+                @endif
+                @if(hasPermission('spare_part_inventory.view'))
+                    <li class="nav-item">
+                        <a class="nav-link {{ ($tab ?? '') === 'inventory' ? 'active' : '' }}"
+                           href="{{ route('spare_part_inventory.index') }}">
+                            Контроль наличия
+                        </a>
+                    </li>
+                @endif
                 <li class="nav-item">
                     <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', session('gp_spare_parts', [])) }}">

+ 1 - 1
routes/web.php

@@ -385,7 +385,7 @@ Route::middleware(['auth:web', 'route.permission'])->group(function () {
         Route::post('/{sparePart}/upload-image', [SparePartController::class, 'uploadImage'])->name('upload_image');
     });
 
-    // Заказы деталей
+    // Заказы запчастей
     Route::prefix('spare-part-orders')->name('spare_part_orders.')->group(function () {
         Route::get('/', [SparePartOrderController::class, 'index'])->name('index');
         Route::get('/create', [SparePartOrderController::class, 'create'])->name('create');

+ 156 - 0
tests/Feature/ManagerMenuTest.php

@@ -0,0 +1,156 @@
+<?php
+
+namespace Tests\Feature;
+
+use App\Models\Permission;
+use App\Models\PricingCode;
+use App\Models\Role;
+use App\Models\User;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Tests\TestCase;
+
+class ManagerMenuTest extends TestCase
+{
+    use RefreshDatabase;
+
+    protected $seed = true;
+
+    public function test_admin_sees_existing_sections_in_new_menu_groups(): void
+    {
+        $user = $this->createSystemRoleUser(Role::ADMIN);
+
+        $response = $this->actingAs($user)->get(route('common-catalog.index'));
+
+        $response->assertOk()
+            ->assertSeeText('ДКР')
+            ->assertSee(route('order.index'), false)
+            ->assertSee(route('product_sku.index'), false)
+            ->assertSee(route('catalog.index'), false)
+            ->assertSee(route('reports.index'), false)
+            ->assertSee(route('responsible.index'), false)
+            ->assertSee(route('maf_order.index'), false)
+            ->assertSeeText('Графики')
+            ->assertSee(route('schedule.index'), false)
+            ->assertSeeText('Запчасти')
+            ->assertSee(route('spare_parts.index'), false)
+            ->assertSee(route('spare_part_orders.index'), false)
+            ->assertSee(route('spare_part_inventory.index'), false)
+            ->assertSee(route('pricing_codes.index'), false)
+            ->assertSeeText('Администратор')
+            ->assertSee(route('contractors.index'), false)
+            ->assertSee(route('contract.index'), false)
+            ->assertSee(route('user.index'), false)
+            ->assertSee(route('admin.roles.index'), false)
+            ->assertSee(route('admin.settings.index'), false)
+            ->assertSee(route('admin.district.index'), false)
+            ->assertSee(route('admin.area.index'), false)
+            ->assertSee(route('admin.notifications.log'), false)
+            ->assertSee(route('import.index'), false)
+            ->assertSee(route('year-data.index'), false)
+            ->assertSee(route('clear-data.index'), false)
+            ->assertDontSeeText('Администрирование');
+
+        $this->assertSame(1, substr_count($response->getContent(), '>Подрядчики</a>'));
+    }
+
+    public function test_contractor_viewer_sees_contractors_only_inside_admin_group(): void
+    {
+        $user = $this->createUserWithPermissions('contractor_viewer', ['contractors.view']);
+
+        $response = $this->actingAs($user)->get(route('common-catalog.index'));
+
+        $response->assertOk()
+            ->assertSeeText('Администратор')
+            ->assertSee(route('contractors.index'), false);
+
+        $this->assertSame(1, substr_count($response->getContent(), '>Подрядчики</a>'));
+    }
+
+    public function test_spare_parts_viewer_can_read_pricing_codes_without_edit_controls(): void
+    {
+        $user = $this->createUserWithPermissions('spare_parts_reader', ['spare_parts.view']);
+        $pricingCode = PricingCode::query()->create([
+            'type' => PricingCode::TYPE_PRICING_CODE,
+            'code' => 'TEST-01',
+            'description' => 'Тестовая расшифровка',
+        ]);
+
+        $response = $this->actingAs($user)->get(route('pricing_codes.index'));
+
+        $response->assertOk()
+            ->assertSeeText('Справочник расшифровок')
+            ->assertSeeText('TEST-01')
+            ->assertSeeText('Тестовая расшифровка')
+            ->assertSee(route('spare_parts.index'), false)
+            ->assertSee(route('spare_parts.help'), false)
+            ->assertDontSee(route('spare_part_orders.index'), false)
+            ->assertDontSee(route('spare_part_inventory.index'), false)
+            ->assertDontSee('data-bs-target="#addCodeModal"', false)
+            ->assertDontSee('action="'.route('pricing_codes.update', $pricingCode).'"', false)
+            ->assertDontSee('action="'.route('pricing_codes.destroy', $pricingCode).'"', false)
+            ->assertDontSee('data-confirm-message=', false);
+
+        $this->actingAs($user)
+            ->post(route('pricing_codes.store'), [
+                'type' => PricingCode::TYPE_PRICING_CODE,
+                'code' => 'TEST-02',
+            ])
+            ->assertForbidden();
+    }
+
+    public function test_spare_parts_subsections_set_their_own_active_menu_item(): void
+    {
+        $user = $this->createSystemRoleUser(Role::ADMIN);
+
+        $this->actingAs($user)
+            ->get(route('spare_part_orders.index'))
+            ->assertOk()
+            ->assertViewHas('active', 'spare_part_orders')
+            ->assertSeeText('Заказы запчастей');
+
+        $this->actingAs($user)
+            ->get(route('spare_part_inventory.index'))
+            ->assertOk()
+            ->assertViewHas('active', 'spare_part_inventory');
+
+        $this->actingAs($user)
+            ->get(route('pricing_codes.index'))
+            ->assertOk()
+            ->assertViewHas('active', 'pricing_codes');
+
+        $this->actingAs($user)
+            ->get(route('spare_parts.help'))
+            ->assertOk()
+            ->assertViewHas('active', 'spare_parts_help');
+    }
+
+    private function createSystemRoleUser(string $slug): User
+    {
+        $role = Role::query()->where('slug', $slug)->firstOrFail();
+
+        return User::factory()->create([
+            'role' => $role->slug,
+            'role_id' => $role->id,
+        ]);
+    }
+
+    private function createUserWithPermissions(string $slug, array $permissionSlugs): User
+    {
+        $role = Role::query()->create([
+            'slug' => $slug,
+            'name' => $slug,
+            'is_system' => false,
+            'is_active' => true,
+        ]);
+        $permissions = Permission::query()
+            ->whereIn('slug', $permissionSlugs)
+            ->pluck('id')
+            ->mapWithKeys(fn (int $id): array => [$id => ['effect' => 'allow']]);
+        $role->permissions()->sync($permissions);
+
+        return User::factory()->create([
+            'role' => $role->slug,
+            'role_id' => $role->id,
+        ]);
+    }
+}