Bladeren bron

Merge branch 'flex-roles' into dev

Alexander Musikhin 3 dagen geleden
bovenliggende
commit
9675410164
42 gewijzigde bestanden met toevoegingen van 4015 en 288 verwijderingen
  1. 99 5
      app/Helpers/roles.php
  2. 257 0
      app/Http/Controllers/Admin/RoleController.php
  3. 8 1
      app/Http/Controllers/ImportController.php
  4. 86 11
      app/Http/Controllers/ProductController.php
  5. 89 14
      app/Http/Controllers/UserController.php
  6. 41 0
      app/Http/Middleware/EnsureRoutePermission.php
  7. 19 0
      app/Http/Middleware/EnsureUserHasAnyPermission.php
  8. 19 0
      app/Http/Middleware/EnsureUserHasPermission.php
  9. 12 2
      app/Http/Middleware/EnsureUserHasRole.php
  10. 42 2
      app/Http/Requests/StoreProductRequest.php
  11. 3 0
      app/Http/Requests/User/StoreUser.php
  12. 70 0
      app/Models/Permission.php
  13. 69 1
      app/Models/Role.php
  14. 70 0
      app/Models/User.php
  15. 7 0
      app/Providers/AppServiceProvider.php
  16. 218 0
      app/Services/Access/AccessService.php
  17. 48 0
      app/Services/Access/FieldAccessService.php
  18. 6 0
      bootstrap/app.php
  19. 231 0
      config/access.php
  20. 250 0
      config/access_routes.php
  21. 27 0
      database/migrations/2026_05_14_000001_create_roles_table.php
  22. 35 0
      database/migrations/2026_05_14_000002_create_permissions_table.php
  23. 25 0
      database/migrations/2026_05_14_000003_create_role_permissions_table.php
  24. 27 0
      database/migrations/2026_05_14_000004_create_user_permissions_table.php
  25. 26 0
      database/migrations/2026_05_14_000005_add_role_id_to_users_table.php
  26. 218 0
      database/seeders/RbacSeeder.php
  27. 154 0
      docs/flex_roles_access_inventory.md
  28. 947 181
      docs/flex_roles_plan.md
  29. 107 0
      resources/views/admin/roles/edit.blade.php
  30. 50 0
      resources/views/admin/roles/index.blade.php
  31. 65 0
      resources/views/admin/roles/partials/permissions-table.blade.php
  32. 60 47
      resources/views/catalog/edit.blade.php
  33. 5 1
      resources/views/catalog/index.blade.php
  34. 45 20
      resources/views/layouts/menu.blade.php
  35. 52 1
      resources/views/users/edit.blade.php
  36. 1 1
      resources/views/users/index.blade.php
  37. 13 1
      routes/web.php
  38. 178 0
      tests/Feature/AdminRoleControllerTest.php
  39. 98 0
      tests/Feature/CatalogFieldAccessTest.php
  40. 106 0
      tests/Feature/RoutePermissionMiddlewareTest.php
  41. 22 0
      tests/Feature/UserControllerTest.php
  42. 110 0
      tests/Unit/Services/AccessServiceTest.php

+ 99 - 5
app/Helpers/roles.php

@@ -1,11 +1,57 @@
 <?php
 
 use App\Models\Role;
+use Illuminate\Database\QueryException;
+use Illuminate\Support\Facades\Schema;
 
 if(!function_exists('getRoles')){
     function getRoles($key = null): array|string
     {
         $roles = Role::NAMES;
+
+        try {
+            if (Schema::hasTable('roles')) {
+                $roles = Role::query()
+                    ->where('is_active', true)
+                    ->orderBy('sort')
+                    ->orderBy('name')
+                    ->pluck('name', 'slug')
+                    ->all() ?: $roles;
+            }
+        } catch (QueryException) {
+            $roles = Role::NAMES;
+        }
+
+        if($key && isset($roles[$key])){
+            return $roles[$key];
+        } else {
+            return $roles;
+        }
+    }
+}
+
+if(!function_exists('getRoleIdOptions')){
+    function getRoleIdOptions($key = null): array|string
+    {
+        $roles = [];
+
+        try {
+            if (Schema::hasTable('roles')) {
+                $roles = Role::query()
+                    ->where('is_active', true)
+                    ->orderBy('sort')
+                    ->orderBy('name')
+                    ->pluck('name', 'id')
+                    ->all();
+            }
+        } catch (QueryException) {
+            $roles = [];
+        }
+
+        if (!$roles) {
+            $roles = getRoles();
+        }
+
         if($key && isset($roles[$key])){
             return $roles[$key];
         } else {
@@ -20,17 +66,65 @@ if(!function_exists('hasRole')){
         if(!$user) $user = auth()->user();
         if(!$user) return false;
 
-        $roles = explode(',', $roles);
-        $effectiveRoles = Role::effectiveRoles($user->role);
-
-        return count(array_intersect($roles, $effectiveRoles)) > 0;
+        return $user->hasRole($roles);
     }
 }
 
 if(!function_exists('roleName')) {
     function roleName($role): string
     {
-        return Role::NAMES[$role];
+        return Role::NAMES[$role] ?? (string) $role;
+    }
+}
+
+if(!function_exists('hasPermission')) {
+    function hasPermission(string $permission, $user = null): bool
+    {
+        if(!$user) $user = auth()->user();
+        if(!$user) return false;
+
+        return $user->hasPermission($permission);
+    }
+}
+
+if(!function_exists('hasAnyPermission')) {
+    function hasAnyPermission(array|string $permissions, $user = null): bool
+    {
+        if(!$user) $user = auth()->user();
+        if(!$user) return false;
+
+        return $user->hasAnyPermission($permissions);
+    }
+}
+
+if(!function_exists('hasAccess')) {
+    function hasAccess(string $permission, array|string|null $legacyRoles = null, $user = null): bool
+    {
+        if(!$user) $user = auth()->user();
+        if(!$user) return false;
+
+        return $user->hasPermission($permission)
+            || ($legacyRoles !== null && $user->hasRole($legacyRoles));
+    }
+}
+
+if(!function_exists('canViewField')) {
+    function canViewField(string $module, string $field, ?string $entity = null, $user = null): bool
+    {
+        if(!$user) $user = auth()->user();
+        if(!$user) return false;
+
+        return $user->canViewField($module, $field, $entity);
+    }
+}
+
+if(!function_exists('canUpdateField')) {
+    function canUpdateField(string $module, string $field, ?string $entity = null, $user = null): bool
+    {
+        if(!$user) $user = auth()->user();
+        if(!$user) return false;
+
+        return $user->canUpdateField($module, $field, $entity);
     }
 }
 

+ 257 - 0
app/Http/Controllers/Admin/RoleController.php

@@ -0,0 +1,257 @@
+<?php
+
+namespace App\Http\Controllers\Admin;
+
+use App\Http\Controllers\Controller;
+use App\Models\Permission;
+use App\Models\Role;
+use App\Models\User;
+use App\Services\Access\AccessService;
+use Illuminate\Http\RedirectResponse;
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Str;
+use Illuminate\View\View;
+
+class RoleController extends Controller
+{
+    public function index(): View
+    {
+        return view('admin.roles.index', [
+            'active' => 'roles',
+            'title' => 'Роли и права',
+            'roles' => Role::query()
+                ->withCount(['users' => fn ($query) => $query->withTrashed()])
+                ->orderBy('sort')
+                ->orderBy('name')
+                ->get(),
+        ]);
+    }
+
+    public function create(): View
+    {
+        return view('admin.roles.edit', $this->formData(new Role(), []));
+    }
+
+    public function store(Request $request, AccessService $accessService): RedirectResponse
+    {
+        $validated = $this->validateRole($request);
+
+        $role = DB::transaction(function () use ($validated, $request, $accessService) {
+            $role = Role::query()->create([
+                'slug' => $validated['slug'],
+                'name' => $validated['name'],
+                'description' => $validated['description'] ?? null,
+                'is_system' => false,
+                'is_active' => (bool) ($validated['is_active'] ?? true),
+                'sort' => (int) ($validated['sort'] ?? 100),
+            ]);
+
+            $this->syncRolePermissions($role, $request->input('permission_effects', []));
+            $accessService->bumpCacheVersion();
+
+            return $role;
+        });
+
+        return redirect()
+            ->route('admin.roles.edit', $role)
+            ->with('success', 'Роль создана.');
+    }
+
+    public function storeFromUser(User $user, Request $request, AccessService $accessService): RedirectResponse
+    {
+        $validated = $request->validate([
+            'name' => ['required', 'string', 'min:2', 'max:255'],
+            'slug' => ['nullable', 'string', 'alpha_dash', 'max:80', 'unique:roles,slug'],
+            'description' => ['nullable', 'string', 'max:1000'],
+        ]);
+
+        $slug = $validated['slug'] ?: Str::slug($validated['name']);
+        if (!$slug) {
+            $slug = 'role_' . $user->id;
+        }
+
+        $slug = $this->uniqueRoleSlug($slug);
+
+        $role = DB::transaction(function () use ($validated, $slug, $user, $accessService) {
+            $role = Role::query()->create([
+                'slug' => $slug,
+                'name' => $validated['name'],
+                'description' => $validated['description'] ?? 'Создана из совокупности прав пользователя ' . $user->name,
+                'is_system' => false,
+                'is_active' => true,
+                'sort' => 100,
+            ]);
+
+            $sync = [];
+            foreach ($accessService->getEffectivePermissions($user) as $permissionSlug => $effect) {
+                $sync[$permissionSlug] = $effect;
+            }
+
+            $this->syncRolePermissions($role, $sync);
+            $accessService->bumpCacheVersion();
+
+            return $role;
+        });
+
+        return redirect()
+            ->route('admin.roles.edit', $role)
+            ->with('success', 'Роль создана из прав пользователя.');
+    }
+
+    public function copy(Role $role, AccessService $accessService): RedirectResponse
+    {
+        $copy = DB::transaction(function () use ($role, $accessService) {
+            $role->load('permissions');
+
+            $copy = Role::query()->create([
+                'slug' => $this->uniqueRoleSlug($role->slug . '_copy'),
+                'name' => 'Копия ' . $role->name,
+                'description' => $role->description,
+                'is_system' => false,
+                'is_active' => true,
+                'sort' => (int) ($role->sort ?? 100),
+            ]);
+
+            $sync = [];
+            foreach ($role->permissions as $permission) {
+                $sync[$permission->id] = ['effect' => $permission->pivot->effect];
+            }
+
+            $copy->permissions()->sync($sync);
+            $accessService->bumpCacheVersion();
+
+            return $copy;
+        });
+
+        return redirect()
+            ->route('admin.roles.edit', $copy)
+            ->with('success', 'Роль скопирована.');
+    }
+
+    public function edit(Role $role): View
+    {
+        $effects = $role->permissions()
+            ->pluck('role_permissions.effect', 'permissions.id')
+            ->all();
+
+        return view('admin.roles.edit', $this->formData($role, $effects));
+    }
+
+    public function update(Role $role, Request $request, AccessService $accessService): RedirectResponse
+    {
+        $validated = $this->validateRole($request, $role);
+
+        DB::transaction(function () use ($role, $validated, $request, $accessService) {
+            $role->update([
+                'name' => $validated['name'],
+                'description' => $validated['description'] ?? null,
+                'is_active' => $role->slug === Role::ADMIN ? true : (bool) ($validated['is_active'] ?? false),
+                'sort' => (int) ($validated['sort'] ?? $role->sort),
+            ]);
+
+            if ($role->slug === Role::ADMIN) {
+                $this->syncRolePermissions(
+                    $role,
+                    Permission::query()->pluck('slug')->mapWithKeys(fn (string $slug): array => [$slug => 'allow'])->all()
+                );
+            } else {
+                $this->syncRolePermissions($role, $request->input('permission_effects', []));
+            }
+
+            $accessService->bumpCacheVersion();
+        });
+
+        return redirect()
+            ->route('admin.roles.edit', $role)
+            ->with('success', 'Роль сохранена.');
+    }
+
+    public function destroy(Role $role, AccessService $accessService): RedirectResponse
+    {
+        if ($role->slug === Role::ADMIN) {
+            return redirect()
+                ->route('admin.roles.index')
+                ->with('danger', 'Роль администратора нельзя удалить.');
+        }
+
+        $linkedUsers = User::withTrashed()
+            ->where('role_id', $role->id)
+            ->orWhere('role', $role->slug)
+            ->count();
+
+        if ($linkedUsers > 0) {
+            return redirect()
+                ->route('admin.roles.edit', $role)
+                ->with('danger', 'Роль нельзя удалить, пока на неё ссылается хотя бы один пользователь.');
+        }
+
+        $role->delete();
+        $accessService->bumpCacheVersion();
+
+        return redirect()
+            ->route('admin.roles.index')
+            ->with('success', 'Роль удалена.');
+    }
+
+    private function formData(Role $role, array $effects): array
+    {
+        return [
+            'active' => 'roles',
+            'title' => $role->exists ? 'Редактирование роли' : 'Создание роли',
+            'role' => $role,
+            'permissionGroups' => Permission::getGroupedForUi(),
+            'permissionEffects' => $effects,
+        ];
+    }
+
+    private function validateRole(Request $request, ?Role $role = null): array
+    {
+        $roleId = $role?->id ?: 'NULL';
+
+        return $request->validate([
+            'slug' => [
+                $role?->exists ? 'nullable' : 'required',
+                'string',
+                'alpha_dash',
+                'max:80',
+                'unique:roles,slug,' . $roleId,
+            ],
+            'name' => ['required', 'string', 'min:2', 'max:255'],
+            'description' => ['nullable', 'string', 'max:1000'],
+            'is_active' => ['nullable', 'boolean'],
+            'sort' => ['nullable', 'integer', 'min:0', 'max:100000'],
+            'permission_effects' => ['nullable', 'array'],
+            'permission_effects.*' => ['nullable', 'in:none,allow,deny'],
+        ]);
+    }
+
+    private function syncRolePermissions(Role $role, array $effects): void
+    {
+        $permissions = Permission::query()
+            ->whereIn('id', array_filter(array_keys($effects), 'is_numeric'))
+            ->orWhereIn('slug', array_filter(array_keys($effects), fn ($key): bool => !is_numeric($key)))
+            ->get();
+
+        $sync = [];
+        foreach ($permissions as $permission) {
+            $effect = $effects[$permission->id] ?? $effects[$permission->slug] ?? null;
+            if (in_array($effect, ['allow', 'deny'], true)) {
+                $sync[$permission->id] = ['effect' => $effect];
+            }
+        }
+
+        $role->permissions()->sync($sync);
+    }
+
+    private function uniqueRoleSlug(string $slug): string
+    {
+        $baseSlug = $slug;
+        $counter = 2;
+        while (Role::query()->where('slug', $slug)->exists()) {
+            $slug = $baseSlug . '_' . $counter++;
+        }
+
+        return $slug;
+    }
+}

+ 8 - 1
app/Http/Controllers/ImportController.php

@@ -4,6 +4,7 @@ namespace App\Http\Controllers;
 
 use App\Jobs\Import\ImportJob;
 use App\Models\Import;
+use App\Services\Access\AccessService;
 use Illuminate\Http\Request;
 use Illuminate\Support\Facades\Log;
 use Illuminate\Support\Facades\Storage;
@@ -56,7 +57,7 @@ class ImportController extends Controller
         return view('import.index', $this->data);
     }
 
-    public function store(Request $request)
+    public function store(Request $request, AccessService $accessService)
     {
         // validate data
         $request->validate([
@@ -64,6 +65,12 @@ class ImportController extends Controller
             'import_file' => 'required|file',
         ]);
 
+        if ($request->type === 'catalog') {
+            $accessService->assertCan($request->user(), 'catalog.import');
+        } else {
+            $accessService->assertCan($request->user(), 'import.create');
+        }
+
         // load and save file
         $path = Str::random(2) . '/' . Str::uuid() . '.' .$request->file('import_file')->getClientOriginalExtension();
         Storage::disk('upload')->put($path, $request->file('import_file')->getContent());

+ 86 - 11
app/Http/Controllers/ProductController.php

@@ -6,6 +6,7 @@ use App\Http\Requests\StoreProductRequest;
 use App\Jobs\Export\ExportCatalog;
 use App\Models\File;
 use App\Models\Product;
+use App\Services\Access\FieldAccessService;
 use App\Services\FileService;
 use Illuminate\Http\RedirectResponse;
 use Illuminate\Http\Request;
@@ -57,15 +58,25 @@ class ProductController extends Controller
         ],
     ];
 
-    public function index(Request $request)
+    public function index(Request $request, FieldAccessService $fieldAccess)
     {
         session(['gp_products' => $request->query()]);
         $nav = $this->startNavigationContext($request);
         $model = new Product;
+        $readableFields = $this->readableCatalogFields($request, $fieldAccess);
+        $request = $this->sanitizeCatalogListRequest($request, $readableFields);
+        $this->data['header'] = $fieldAccess->visibleHeaders($request->user(), 'catalog', $this->data['header']);
+        $this->data['searchFields'] = array_values(array_filter(
+            $this->data['searchFields'],
+            fn (string $field): bool => in_array($field, $readableFields, true)
+        ));
         // fill filters
-        $this->createFilters($model, 'type_tz', 'type', 'certificate_id');
-        $this->createRangeFilters($model, 'nomenclature_number', 'product_price', 'installation_price', 'total_price');
-        $this->createDateFilters($model, 'certificate_date', 'created_at');
+        $this->createFilters($model, ...array_values(array_intersect(['type_tz', 'type', 'certificate_id'], $readableFields)));
+        $this->createRangeFilters($model, ...array_values(array_intersect(['nomenclature_number', 'product_price', 'installation_price', 'total_price'], $readableFields)));
+        $this->createDateFilters($model, ...array_values(array_intersect(['certificate_date', 'created_at'], $readableFields)));
+        $this->data['filters'] = $this->filterCatalogControls($this->data['filters'] ?? [], $readableFields);
+        $this->data['ranges'] = $this->filterCatalogControls($this->data['ranges'] ?? [], $readableFields);
+        $this->data['dates'] = $this->filterCatalogControls($this->data['dates'] ?? [], $readableFields);
 
         // create request
         $q = $model::query();
@@ -81,7 +92,7 @@ class ProductController extends Controller
         return view('catalog.index', $this->data);
     }
 
-    public function show(Request $request, int $product)
+    public function show(Request $request, int $product, FieldAccessService $fieldAccess)
     {
         $nav = $this->resolveNavToken($request);
         $this->rememberNavigation($request, $nav);
@@ -92,10 +103,11 @@ class ProductController extends Controller
             route('catalog.index', session('gp_products'))
         );
         $this->data['product'] = Product::query()->withoutGlobalScope(\App\Models\Scopes\YearScope::class)->find($product);
+        $this->prepareCatalogFieldAccess($request, $fieldAccess);
         return view('catalog.edit', $this->data);
     }
 
-    public function create(Request $request)
+    public function create(Request $request, FieldAccessService $fieldAccess)
     {
         $nav = $this->resolveNavToken($request);
         $this->rememberNavigation($request, $nav);
@@ -106,12 +118,13 @@ class ProductController extends Controller
             route('catalog.index', session('gp_products'))
         );
         $this->data['product'] = null;
+        $this->prepareCatalogFieldAccess($request, $fieldAccess);
         return view('catalog.edit', $this->data);
     }
 
-    public function store(StoreProductRequest $request)
+    public function store(StoreProductRequest $request, FieldAccessService $fieldAccess)
     {
-        Product::create($request->validated());
+        Product::create($fieldAccess->filterValidatedPayload($request->user(), 'catalog', $request->validated()));
         $nav = $this->resolveNavToken($request);
         $backUrl = $this->navigationParentUrl(
             $nav,
@@ -120,9 +133,9 @@ class ProductController extends Controller
         return redirect()->to($backUrl);
     }
 
-    public function update(StoreProductRequest $request, Product $product)
+    public function update(StoreProductRequest $request, Product $product, FieldAccessService $fieldAccess)
     {
-        $product->update($request->validated());
+        $product->update($fieldAccess->filterValidatedPayload($request->user(), 'catalog', $request->validated()));
         $nav = $this->resolveNavToken($request);
         $backUrl = $this->navigationParentUrl(
             $nav,
@@ -168,8 +181,12 @@ class ProductController extends Controller
     public function search(Request $request): array
     {
         $s = $request->get('s');
-        $searchFields = $this->data['searchFields'];
+        $searchFields = app(FieldAccessService::class)->filterReadableFields($request->user(), 'catalog', $this->data['searchFields']);
         $ret = [];
+        if (!$searchFields) {
+            return $ret;
+        }
+
         if($s) {
             $result = Product::query()->where(function ($query) use ($searchFields, $s) {
                 foreach ($searchFields as $searchField) {
@@ -240,5 +257,63 @@ class ProductController extends Controller
         return redirect()->route('catalog.show', $this->withNav(['product' => $product], $nav));
     }
 
+    private function prepareCatalogFieldAccess(Request $request, FieldAccessService $fieldAccess): void
+    {
+        $fields = array_keys(config('access.catalog.fields', []));
+
+        $this->data['catalogReadableFields'] = array_fill_keys(
+            $fieldAccess->filterReadableFields($request->user(), 'catalog', $fields),
+            true
+        );
+        $this->data['catalogWritableFields'] = [];
+        foreach ($fields as $field) {
+            $this->data['catalogWritableFields'][$field] = $request->user()->canUpdateField('catalog', $field);
+        }
+    }
+
+    private function readableCatalogFields(Request $request, FieldAccessService $fieldAccess): array
+    {
+        return $fieldAccess->filterReadableFields(
+            $request->user(),
+            'catalog',
+            array_keys(config('access.catalog.fields', []))
+        );
+    }
+
+    private function sanitizeCatalogListRequest(Request $request, array $readableFields): Request
+    {
+        if ($request->filled('sortBy') && !in_array($this->normalizeCatalogField($request->string('sortBy')->toString()), $readableFields, true)) {
+            $request->merge(['sortBy' => Product::DEFAULT_SORT_BY]);
+        }
+
+        if ($request->has('filters') && is_array($request->input('filters'))) {
+            $request->merge([
+                'filters' => array_filter(
+                    $request->input('filters'),
+                    fn ($value, string $field): bool => in_array($this->normalizeCatalogField($field), $readableFields, true),
+                    ARRAY_FILTER_USE_BOTH
+                ),
+            ]);
+        }
+
+        return $request;
+    }
+
+    private function filterCatalogControls(array $controls, array $readableFields): array
+    {
+        return array_filter(
+            $controls,
+            fn (string $field): bool => in_array($this->normalizeCatalogField($field), $readableFields, true),
+            ARRAY_FILTER_USE_KEY
+        );
+    }
+
+    private function normalizeCatalogField(string $field): string
+    {
+        $field = preg_replace('/_(from|to)$/', '', $field) ?: $field;
+
+        return str_ends_with($field, '_txt') ? substr($field, 0, -4) : $field;
+    }
+
 
 }

+ 89 - 14
app/Http/Controllers/UserController.php

@@ -6,12 +6,16 @@ use App\Http\Requests\User\DeleteUser;
 use App\Http\Requests\User\StoreProfile;
 use App\Http\Requests\User\StoreUser;
 use App\Models\Order;
+use App\Models\Permission;
 use App\Models\Reclamation;
 use App\Models\ReclamationStatus;
+use App\Models\Role;
 use App\Models\User;
 use App\Models\UserNotificationSetting;
+use App\Services\Access\AccessService;
 use Illuminate\Http\Request;
 use Illuminate\Support\Facades\Auth;
+use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Facades\Hash;
 
 class UserController extends Controller
@@ -85,6 +89,7 @@ class UserController extends Controller
         );
         $this->data['user'] = null;
         $this->prepareNotificationSettingsData(null);
+        $this->preparePermissionSettingsData(null);
         return view('users.edit', $this->data);
     }
 
@@ -95,8 +100,18 @@ class UserController extends Controller
     {
         $validated = $request->validated();
         $settingsData = $this->extractNotificationSettings($request);
+        $permissionEffects = $validated['permission_effects'] ?? [];
 
         unset($validated['notification_settings']);
+        unset($validated['permission_effects']);
+
+        if (!empty($validated['role_id'])) {
+            $role = Role::query()->find($validated['role_id']);
+            $validated['role'] = $role?->slug ?? $validated['role'] ?? Role::MANAGER;
+        } elseif (!empty($validated['role'])) {
+            $role = Role::query()->where('slug', $validated['role'])->first();
+            $validated['role_id'] = $role?->id;
+        }
 
         if(!empty($validated['password'])) {
             $validated['password'] = Hash::make($validated['password']);
@@ -105,21 +120,27 @@ class UserController extends Controller
         }
 
         $user = null;
-        if(isset($validated['id'])) {
-            User::query()
-                ->where('id', $validated['id'])
-                ->update($validated);
-            $user = User::query()->find($validated['id']);
-        } else {
-            $user = User::query()->create($validated);
-        }
+        DB::transaction(function () use ($validated, $settingsData, $permissionEffects, &$user) {
+            if(isset($validated['id'])) {
+                User::query()
+                    ->where('id', $validated['id'])
+                    ->update($validated);
+                $user = User::query()->find($validated['id']);
+            } else {
+                $user = User::query()->create($validated);
+            }
 
-        if ($user) {
-            UserNotificationSetting::query()->updateOrCreate(
-                ['user_id' => $user->id],
-                $settingsData,
-            );
-        }
+            if ($user) {
+                UserNotificationSetting::query()->updateOrCreate(
+                    ['user_id' => $user->id],
+                    $settingsData,
+                );
+
+                $this->syncUserPermissionOverrides($user, $permissionEffects);
+            }
+        });
+
+        app(AccessService::class)->bumpCacheVersion();
 
         return redirect()->route('user.index')->with(['success' => 'Пользователь ' . $validated['name'] . ' сохранён!']);
     }
@@ -142,6 +163,7 @@ class UserController extends Controller
             ->withTrashed()
             ->first();
         $this->prepareNotificationSettingsData($this->data['user']);
+        $this->preparePermissionSettingsData($this->data['user']);
 
         return view('users.edit', $this->data);
     }
@@ -244,6 +266,8 @@ class UserController extends Controller
             return redirect()->route('login')->with(['danger' => 'Не удалось вернуться к исходному пользователю.']);
         }
 
+        abort_unless($impersonator->resolvedRoleSlug() === Role::ADMIN, 403);
+
         Auth::login($impersonator);
         $request->session()->forget('impersonator_id');
         $request->session()->regenerate();
@@ -280,6 +304,57 @@ class UserController extends Controller
         $this->data['notificationSettings'] = $settings->toArray();
     }
 
+    private function preparePermissionSettingsData(?User $user): void
+    {
+        $this->data['roles'] = Role::query()
+            ->where('is_active', true)
+            ->orderBy('sort')
+            ->orderBy('name')
+            ->pluck('name', 'id')
+            ->all();
+
+        $this->data['permissionGroups'] = Permission::getGroupedForUi();
+        $this->data['permissionEffects'] = [];
+        $this->data['rolePermissionEffects'] = [];
+
+        if (!$user) {
+            return;
+        }
+
+        $this->data['permissionEffects'] = $user->permissions()
+            ->pluck('user_permissions.effect', 'permissions.id')
+            ->all();
+
+        if ($user->roleModel) {
+            $this->data['rolePermissionEffects'] = $user->roleModel
+                ->permissions()
+                ->pluck('role_permissions.effect', 'permissions.id')
+                ->all();
+        }
+    }
+
+    private function syncUserPermissionOverrides(User $user, array $effects): void
+    {
+        if ($user->resolvedRoleSlug() === Role::ADMIN) {
+            $user->permissions()->detach();
+            return;
+        }
+
+        $permissions = Permission::query()
+            ->whereIn('id', array_filter(array_keys($effects), 'is_numeric'))
+            ->get();
+
+        $sync = [];
+        foreach ($permissions as $permission) {
+            $effect = $effects[$permission->id] ?? null;
+            if (in_array($effect, ['allow', 'deny'], true)) {
+                $sync[$permission->id] = ['effect' => $effect];
+            }
+        }
+
+        $user->permissions()->sync($sync);
+    }
+
     private function extractNotificationSettings(Request $request): array
     {
         $input = $request->input('notification_settings', []);

+ 41 - 0
app/Http/Middleware/EnsureRoutePermission.php

@@ -0,0 +1,41 @@
+<?php
+
+namespace App\Http\Middleware;
+
+use App\Services\Access\AccessService;
+use Closure;
+use Illuminate\Http\Request;
+use Symfony\Component\HttpFoundation\Response;
+
+class EnsureRoutePermission
+{
+    public function __construct(private readonly AccessService $accessService)
+    {
+    }
+
+    public function handle(Request $request, Closure $next): Response
+    {
+        $user = $request->user();
+        $routeName = $request->route()?->getName();
+
+        $routePermission = $this->accessService->routePermission($routeName);
+
+        if (!$user || !$routeName || !$routePermission) {
+            return $next($request);
+        }
+
+        // Compatibility while tests and old runtime paths still create users with only legacy role slugs.
+        if (!$user->role_id) {
+            return $next($request);
+        }
+
+        abort_unless(
+            is_array($routePermission)
+                ? $this->accessService->canAny($user, $routePermission)
+                : $this->accessService->can($user, $routePermission),
+            403
+        );
+
+        return $next($request);
+    }
+}

+ 19 - 0
app/Http/Middleware/EnsureUserHasAnyPermission.php

@@ -0,0 +1,19 @@
+<?php
+
+namespace App\Http\Middleware;
+
+use Closure;
+use Illuminate\Http\Request;
+use Symfony\Component\HttpFoundation\Response;
+
+class EnsureUserHasAnyPermission
+{
+    public function handle(Request $request, Closure $next, string $permissions): Response
+    {
+        if ($request->user()?->hasAnyPermission(explode(',', $permissions))) {
+            return $next($request);
+        }
+
+        abort(403);
+    }
+}

+ 19 - 0
app/Http/Middleware/EnsureUserHasPermission.php

@@ -0,0 +1,19 @@
+<?php
+
+namespace App\Http\Middleware;
+
+use Closure;
+use Illuminate\Http\Request;
+use Symfony\Component\HttpFoundation\Response;
+
+class EnsureUserHasPermission
+{
+    public function handle(Request $request, Closure $next, string $permission): Response
+    {
+        if ($request->user()?->hasPermission($permission)) {
+            return $next($request);
+        }
+
+        abort(403);
+    }
+}

+ 12 - 2
app/Http/Middleware/EnsureUserHasRole.php

@@ -2,13 +2,17 @@
 
 namespace App\Http\Middleware;
 
-use App\Models\Role;
+use App\Services\Access\AccessService;
 use Closure;
 use Illuminate\Http\Request;
 use Symfony\Component\HttpFoundation\Response;
 
 class EnsureUserHasRole
 {
+    public function __construct(private readonly AccessService $accessService)
+    {
+    }
+
     /**
      * Handle an incoming request.
      *
@@ -19,9 +23,15 @@ class EnsureUserHasRole
      */
     public function handle(Request $request, Closure $next, ... $roles): Response
     {
-        if (count(array_intersect($roles, Role::effectiveRoles($request->user()->role))) > 0) {
+        $user = $request->user();
+
+        $routeName = $request->route()?->getName();
+        $hasRoutePermission = $user && $this->accessService->canAccessRoute($user, $routeName);
+
+        if ($user?->hasRole($roles) || $hasRoutePermission) {
             return $next($request);
         }
+
         abort(403);
     }
 }

+ 42 - 2
app/Http/Requests/StoreProductRequest.php

@@ -2,6 +2,7 @@
 
 namespace App\Http\Requests;
 
+use App\Services\Access\AccessService;
 use Illuminate\Foundation\Http\FormRequest;
 
 class StoreProductRequest extends FormRequest
@@ -11,7 +12,37 @@ class StoreProductRequest extends FormRequest
      */
     public function authorize(): bool
     {
-        return auth()->check();
+        if (!auth()->check()) {
+            return false;
+        }
+
+        if (!$this->routeIs('catalog.store')) {
+            return true;
+        }
+
+        $requiredFields = [
+            'article',
+            'name_tz',
+            'type_tz',
+            'nomenclature_number',
+            'sizes',
+            'manufacturer',
+            'unit',
+            'type',
+            'product_price',
+            'installation_price',
+            'total_price',
+            'manufacturer_name',
+        ];
+
+        $access = app(AccessService::class);
+        foreach ($requiredFields as $field) {
+            if (!$access->canUpdateField($this->user(), 'catalog', $field)) {
+                return false;
+            }
+        }
+
+        return true;
     }
 
     /**
@@ -21,7 +52,7 @@ class StoreProductRequest extends FormRequest
      */
     public function rules(): array
     {
-        return [
+        $rules = [
             'article'               => 'required|string',
             'name_tz'               => 'required|string',
             'type_tz'               => 'required|string',
@@ -46,5 +77,14 @@ class StoreProductRequest extends FormRequest
             'volume'                => 'nullable|nullable',
             'places'                => 'nullable|integer',
         ];
+
+        $access = app(AccessService::class);
+        foreach (array_keys($rules) as $field) {
+            if (!$access->canUpdateField($this->user(), 'catalog', $field)) {
+                unset($rules[$field]);
+            }
+        }
+
+        return $rules;
     }
 }

+ 3 - 0
app/Http/Requests/User/StoreUser.php

@@ -31,10 +31,13 @@ class StoreUser extends FormRequest
             'phone'     => 'nullable|string',
             'password'  => 'required_without:id|nullable|string|min:4',
             'role'      => 'nullable|string|in:' . implode(',', Role::VALID_ROLES),
+            'role_id'   => 'nullable|integer|exists:roles,id',
             'color'     => 'nullable|string',
             'notification_email' => 'nullable|email',
 
             'notification_settings' => 'nullable|array',
+            'permission_effects' => 'nullable|array',
+            'permission_effects.*' => 'nullable|in:none,allow,deny',
         ];
     }
 

+ 70 - 0
app/Models/Permission.php

@@ -0,0 +1,70 @@
+<?php
+
+namespace App\Models;
+
+use Illuminate\Database\Eloquent\Builder;
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Relations\BelongsToMany;
+use Illuminate\Support\Collection;
+
+class Permission extends Model
+{
+    public const TYPE_ACTION = 'action';
+    public const TYPE_FIELD = 'field';
+
+    protected $fillable = [
+        'slug',
+        'name',
+        'description',
+        'module',
+        'entity',
+        'field',
+        'action',
+        'type',
+        'group',
+        'sort',
+        'is_system',
+    ];
+
+    protected function casts(): array
+    {
+        return [
+            'is_system' => 'boolean',
+        ];
+    }
+
+    public function roles(): BelongsToMany
+    {
+        return $this->belongsToMany(Role::class, 'role_permissions')
+            ->withPivot('effect')
+            ->withTimestamps();
+    }
+
+    public function users(): BelongsToMany
+    {
+        return $this->belongsToMany(User::class, 'user_permissions')
+            ->withPivot(['effect', 'reason', 'expires_at'])
+            ->withTimestamps();
+    }
+
+    public function scopeActionPermissions(Builder $query): Builder
+    {
+        return $query->where('type', self::TYPE_ACTION);
+    }
+
+    public function scopeFieldPermissions(Builder $query): Builder
+    {
+        return $query->where('type', self::TYPE_FIELD);
+    }
+
+    public static function getGroupedForUi(): Collection
+    {
+        return self::query()
+            ->orderBy('sort')
+            ->orderBy('module')
+            ->orderBy('type')
+            ->orderBy('slug')
+            ->get()
+            ->groupBy('group');
+    }
+}

+ 69 - 1
app/Models/Role.php

@@ -2,7 +2,11 @@
 
 namespace App\Models;
 
-class Role
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Relations\BelongsToMany;
+use Illuminate\Database\Eloquent\Relations\HasMany;
+
+class Role extends Model
 {
     const ADMIN = 'admin';
     const MANAGER = 'manager';
@@ -26,6 +30,70 @@ class Role
         self::ASSISTANT_HEAD => 'Помощник рук.',
     ];
 
+    protected $fillable = [
+        'slug',
+        'name',
+        'description',
+        'is_system',
+        'is_active',
+        'sort',
+    ];
+
+    protected function casts(): array
+    {
+        return [
+            'is_system' => 'boolean',
+            'is_active' => 'boolean',
+        ];
+    }
+
+    public function permissions(): BelongsToMany
+    {
+        return $this->belongsToMany(Permission::class, 'role_permissions')
+            ->withPivot('effect')
+            ->withTimestamps();
+    }
+
+    public function users(): HasMany
+    {
+        return $this->hasMany(User::class);
+    }
+
+    public function hasPermission(string $permission): bool
+    {
+        return app(\App\Services\Access\AccessService::class)->roleHasPermission($this, $permission);
+    }
+
+    public function givePermission(string $permission, string $effect = 'allow'): void
+    {
+        $permissionModel = Permission::query()->where('slug', $permission)->firstOrFail();
+
+        $this->permissions()->syncWithoutDetaching([
+            $permissionModel->id => ['effect' => $effect],
+        ]);
+
+        app(\App\Services\Access\AccessService::class)->bumpCacheVersion();
+    }
+
+    public function syncPermissions(array $permissions): void
+    {
+        $sync = [];
+        foreach ($permissions as $permission => $effect) {
+            if (is_int($permission)) {
+                $permission = $effect;
+                $effect = 'allow';
+            }
+
+            $permissionModel = Permission::query()->where('slug', $permission)->first();
+            if ($permissionModel) {
+                $sync[$permissionModel->id] = ['effect' => $effect];
+            }
+        }
+
+        $this->permissions()->sync($sync);
+        app(\App\Services\Access\AccessService::class)->bumpCacheVersion();
+    }
+
     public static function effectiveRoles(string $role): array
     {
         return match ($role) {

+ 70 - 0
app/Models/User.php

@@ -4,6 +4,8 @@ namespace App\Models;
 
 use Illuminate\Contracts\Auth\MustVerifyEmail;
 use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
+use Illuminate\Database\Eloquent\Relations\BelongsToMany;
 use Illuminate\Database\Eloquent\Relations\HasMany;
 use Illuminate\Database\Eloquent\SoftDeletes;
 use Illuminate\Foundation\Auth\User as Authenticatable;
@@ -28,6 +30,7 @@ class User extends Authenticatable implements MustVerifyEmail
         'phone',
         'password',
         'role',
+        'role_id',
         'color',
         'token_fcm',
     ];
@@ -80,6 +83,73 @@ class User extends Authenticatable implements MustVerifyEmail
         return $this->userNotifications()->whereNull('read_at');
     }
 
+    public function roleModel(): BelongsTo
+    {
+        return $this->belongsTo(Role::class, 'role_id');
+    }
+
+    public function permissions(): BelongsToMany
+    {
+        return $this->belongsToMany(Permission::class, 'user_permissions')
+            ->withPivot(['effect', 'reason', 'expires_at'])
+            ->withTimestamps();
+    }
+
+    public function hasRole(string|array $roles): bool
+    {
+        $roles = is_array($roles) ? $roles : explode(',', $roles);
+        $roles = array_map('trim', $roles);
+
+        $role = $this->resolvedRoleSlug();
+        if (!$role) {
+            return false;
+        }
+
+        return count(array_intersect($roles, Role::effectiveRoles($role))) > 0;
+    }
+
+    public function hasPermission(string $permission): bool
+    {
+        return app(\App\Services\Access\AccessService::class)->can($this, $permission);
+    }
+
+    public function hasAnyPermission(array|string $permissions): bool
+    {
+        $permissions = is_array($permissions) ? $permissions : explode(',', $permissions);
+
+        return app(\App\Services\Access\AccessService::class)->canAny($this, $permissions);
+    }
+
+    public function canViewField(string $module, string $field, ?string $entity = null): bool
+    {
+        return app(\App\Services\Access\AccessService::class)->canViewField($this, $module, $field, $entity);
+    }
+
+    public function canUpdateField(string $module, string $field, ?string $entity = null): bool
+    {
+        return app(\App\Services\Access\AccessService::class)->canUpdateField($this, $module, $field, $entity);
+    }
+
+    public function getEffectivePermissions(): \Illuminate\Support\Collection
+    {
+        return app(\App\Services\Access\AccessService::class)->getEffectivePermissions($this);
+    }
+
+    public function resolvedRoleSlug(): ?string
+    {
+        if ($this->getAttribute('role_id')) {
+            $role = $this->relationLoaded('roleModel')
+                ? $this->roleModel
+                : $this->roleModel()->first();
+
+            if ($role) {
+                return $role->slug;
+            }
+        }
+
+        return $this->role;
+    }
+
     public static function assignUniqueFcmToken(int $userId, string $token): void
     {
         DB::transaction(function () use ($userId, $token) {

+ 7 - 0
app/Providers/AppServiceProvider.php

@@ -6,6 +6,7 @@ use App\Models\SparePartOrder;
 use App\Observers\SparePartOrderObserver;
 use App\Services\DiskSpaceMonitor;
 use Illuminate\Pagination\Paginator;
+use Illuminate\Support\Facades\Blade;
 use Illuminate\Support\Facades\URL;
 use Illuminate\Support\Facades\View;
 use Illuminate\Support\ServiceProvider;
@@ -30,6 +31,12 @@ class AppServiceProvider extends ServiceProvider
             URL::forceScheme('https');
         }
 
+        Blade::if('role', fn($roles) => hasRole($roles));
+        Blade::if('permission', fn($permission) => hasPermission($permission));
+        Blade::if('anypermission', fn($permissions) => hasAnyPermission($permissions));
+        Blade::if('fieldView', fn($module, $field, $entity = null) => canViewField($module, $field, $entity));
+        Blade::if('fieldUpdate', fn($module, $field, $entity = null) => canUpdateField($module, $field, $entity));
+
         // Регистрация Observer для автоматической обработки дефицитов
         SparePartOrder::observe(SparePartOrderObserver::class);
 

+ 218 - 0
app/Services/Access/AccessService.php

@@ -0,0 +1,218 @@
+<?php
+
+namespace App\Services\Access;
+
+use App\Models\Permission;
+use App\Models\Role;
+use App\Models\User;
+use Illuminate\Database\QueryException;
+use Illuminate\Support\Collection;
+use Illuminate\Support\Facades\Cache;
+use Illuminate\Support\Facades\Schema;
+
+class AccessService
+{
+    private const CACHE_VERSION_KEY = 'permissions:version';
+
+    public function can(User $user, string $permission): bool
+    {
+        if ($this->isDirectAdmin($user)) {
+            return true;
+        }
+
+        $effect = $this->getEffectivePermissions($user)->get($permission);
+
+        return $effect === 'allow';
+    }
+
+    public function canAny(User $user, array $permissions): bool
+    {
+        foreach ($permissions as $permission) {
+            if ($this->can($user, trim((string) $permission))) {
+                return true;
+            }
+        }
+
+        return false;
+    }
+
+    public function canViewField(User $user, string $module, string $field, ?string $entity = null): bool
+    {
+        return $this->can($user, $this->fieldPermissionSlug($module, $field, 'view'));
+    }
+
+    public function canUpdateField(User $user, string $module, string $field, ?string $entity = null): bool
+    {
+        return $this->can($user, $this->fieldPermissionSlug($module, $field, 'update'));
+    }
+
+    public function filterReadableFields(User $user, string $module, array $fields, ?string $entity = null): array
+    {
+        return array_values(array_filter(
+            $fields,
+            fn (string $field): bool => $this->canViewField($user, $module, $field, $entity)
+        ));
+    }
+
+    public function filterWritableData(User $user, string $module, array $data, ?string $entity = null): array
+    {
+        return array_filter(
+            $data,
+            fn (string $field): bool => $this->canUpdateField($user, $module, $field, $entity),
+            ARRAY_FILTER_USE_KEY
+        );
+    }
+
+    public function assertCan(User $user, string $permission): void
+    {
+        abort_unless($this->can($user, $permission), 403);
+    }
+
+    public function routePermission(?string $routeName): array|string|null
+    {
+        if (!$routeName) {
+            return null;
+        }
+
+        $exact = config('access_routes.exact', [])[$routeName] ?? null;
+        if ($exact) {
+            return $exact;
+        }
+
+        foreach (config('access_routes.prefixes', []) as $prefix => $permissions) {
+            if (!str_starts_with($routeName, $prefix)) {
+                continue;
+            }
+
+            $suffix = substr($routeName, strlen($prefix));
+
+            return $permissions[$suffix] ?? $permissions['*'] ?? null;
+        }
+
+        return null;
+    }
+
+    public function canAccessRoute(User $user, ?string $routeName): bool
+    {
+        $permission = $this->routePermission($routeName);
+
+        if ($permission === null) {
+            return false;
+        }
+
+        return is_array($permission)
+            ? $this->canAny($user, $permission)
+            : $this->can($user, $permission);
+    }
+
+    public function roleHasPermission(Role $role, string $permission): bool
+    {
+        if ($role->slug === Role::ADMIN) {
+            return true;
+        }
+
+        $permissionModel = $role->permissions()
+            ->where('slug', $permission)
+            ->first();
+
+        return $permissionModel?->pivot?->effect === 'allow';
+    }
+
+    public function getEffectivePermissions(User $user): Collection
+    {
+        if ($this->isDirectAdmin($user)) {
+            try {
+                return Permission::query()->pluck('slug')->mapWithKeys(fn (string $slug): array => [$slug => 'allow']);
+            } catch (QueryException) {
+                return collect();
+            }
+        }
+
+        return Cache::remember(
+            $this->cacheKey($user),
+            now()->addHour(),
+            fn (): Collection => $this->resolveEffectivePermissions($user)
+        );
+    }
+
+    public function bumpCacheVersion(): void
+    {
+        Cache::forever(self::CACHE_VERSION_KEY, $this->cacheVersion() + 1);
+    }
+
+    private function resolveEffectivePermissions(User $user): Collection
+    {
+        if (!$this->tablesExist()) {
+            return collect();
+        }
+
+        $effects = collect();
+        $denied = collect();
+
+        $role = $user->roleModel()->with('permissions')->first();
+        if ($role) {
+            foreach ($role->permissions as $permission) {
+                $effect = $permission->pivot->effect;
+                if ($effect === 'deny') {
+                    $denied->put($permission->slug, true);
+                    $effects->put($permission->slug, 'deny');
+                    continue;
+                }
+
+                if (!$denied->has($permission->slug)) {
+                    $effects->put($permission->slug, $effect);
+                }
+            }
+        }
+
+        $userPermissions = $user->permissions()
+            ->where(function ($query) {
+                $query->whereNull('expires_at')
+                    ->orWhere('expires_at', '>', now());
+            })
+            ->get();
+
+        foreach ($userPermissions as $permission) {
+            $effect = $permission->pivot->effect;
+            if ($effect === 'deny') {
+                $denied->put($permission->slug, true);
+                $effects->put($permission->slug, 'deny');
+                continue;
+            }
+
+            if (!$denied->has($permission->slug)) {
+                $effects->put($permission->slug, $effect);
+            }
+        }
+
+        return $effects;
+    }
+
+    private function isDirectAdmin(User $user): bool
+    {
+        return $user->resolvedRoleSlug() === Role::ADMIN;
+    }
+
+    private function fieldPermissionSlug(string $module, string $field, string $action): string
+    {
+        return "{$module}.fields.{$field}.{$action}";
+    }
+
+    private function cacheKey(User $user): string
+    {
+        return 'permissions:user:' . $user->id . ':v' . $this->cacheVersion();
+    }
+
+    private function cacheVersion(): int
+    {
+        return (int) Cache::get(self::CACHE_VERSION_KEY, 1);
+    }
+
+    private function tablesExist(): bool
+    {
+        return Schema::hasTable('roles')
+            && Schema::hasTable('permissions')
+            && Schema::hasTable('role_permissions')
+            && Schema::hasTable('user_permissions');
+    }
+}

+ 48 - 0
app/Services/Access/FieldAccessService.php

@@ -0,0 +1,48 @@
+<?php
+
+namespace App\Services\Access;
+
+use App\Models\User;
+
+class FieldAccessService
+{
+    public function __construct(private readonly AccessService $accessService)
+    {
+    }
+
+    public function visibleHeaders(User $user, string $module, array $headers): array
+    {
+        return array_filter(
+            $headers,
+            fn (string $field): bool => $this->accessService->canViewField($user, $module, $this->normalizeField($field)),
+            ARRAY_FILTER_USE_KEY
+        );
+    }
+
+    public function filterValidatedPayload(User $user, string $module, array $validated): array
+    {
+        return $this->accessService->filterWritableData($user, $module, $validated);
+    }
+
+    public function filterReadableFields(User $user, string $module, array $fields): array
+    {
+        return $this->accessService->filterReadableFields($user, $module, $fields);
+    }
+
+    public function filterExportColumns(User $user, string $module, array $columns): array
+    {
+        return $columns;
+    }
+
+    public function filterImportPayload(User $user, string $module, array $row): array
+    {
+        return $row;
+    }
+
+    private function normalizeField(string $field): string
+    {
+        return str_ends_with($field, '_txt')
+            ? substr($field, 0, -4)
+            : $field;
+    }
+}

+ 6 - 0
bootstrap/app.php

@@ -1,6 +1,9 @@
 <?php
 
 use App\Http\Middleware\CatchTokenFcmMiddleware;
+use App\Http\Middleware\EnsureUserHasAnyPermission;
+use App\Http\Middleware\EnsureUserHasPermission;
+use App\Http\Middleware\EnsureRoutePermission;
 use App\Http\Middleware\EnsureUserHasRole;
 use App\Http\Middleware\TrackLastWebPageMiddleware;
 use Illuminate\Foundation\Application;
@@ -17,6 +20,9 @@ return Application::configure(basePath: dirname(__DIR__))
     ->withMiddleware(function (Middleware $middleware) {
         $middleware->alias([
             'role' => EnsureUserHasRole::class,
+            'permission' => EnsureUserHasPermission::class,
+            'permission.any' => EnsureUserHasAnyPermission::class,
+            'route.permission' => EnsureRoutePermission::class,
         ]);
         $middleware->append(TrackLastWebPageMiddleware::class);
         $middleware->append(CatchTokenFcmMiddleware::class);

+ 231 - 0
config/access.php

@@ -0,0 +1,231 @@
+<?php
+
+return [
+    'orders' => [
+        'name' => 'Площадки',
+        'entity' => 'order',
+        'actions' => [
+            'view' => 'Просмотр',
+            'create' => 'Создание',
+            'update' => 'Редактирование',
+            'delete' => 'Удаление',
+            'export' => 'Экспорт',
+            'photos.upload' => 'Загрузка фото',
+            'photos.delete' => 'Удаление фото',
+            'documents.upload' => 'Загрузка документов',
+            'documents.delete' => 'Удаление документов',
+            'documents.generate' => 'Генерация документов',
+            'maf.manage' => 'Управление МАФ',
+            'ttn.create' => 'Создание ТТН',
+            'contractor_specification.create' => 'Спецификация подрядчика',
+            'chat.create' => 'Сообщения',
+            'chat.delete' => 'Удаление сообщений',
+        ],
+    ],
+    'reclamations' => [
+        'name' => 'Рекламации',
+        'entity' => 'reclamation',
+        'actions' => [
+            'view' => 'Просмотр',
+            'create' => 'Создание',
+            'update' => 'Редактирование',
+            'delete' => 'Удаление',
+            'export' => 'Экспорт',
+            'status.update' => 'Изменение статуса',
+            'documents.upload' => 'Загрузка документов',
+            'documents.delete' => 'Удаление документов',
+            'documents.generate' => 'Генерация документов',
+            'photos.upload' => 'Загрузка фото',
+            'photos.delete' => 'Удаление фото',
+            'act.upload' => 'Загрузка акта',
+            'act.delete' => 'Удаление акта',
+            'spare_parts.manage' => 'Управление запчастями',
+            'chat.create' => 'Сообщения',
+            'chat.delete' => 'Удаление сообщений',
+        ],
+    ],
+    'schedule' => [
+        'name' => 'График монтажей',
+        'entity' => 'schedule',
+        'actions' => [
+            'view' => 'Просмотр',
+            'create' => 'Создание',
+            'update' => 'Редактирование',
+            'delete' => 'Удаление',
+            'export' => 'Экспорт',
+        ],
+    ],
+    'catalog' => [
+        'name' => 'Каталог',
+        'entity' => 'product',
+        'actions' => [
+            'view' => 'Просмотр',
+            'create' => 'Создание',
+            'update' => 'Редактирование',
+            'delete' => 'Удаление',
+            'import' => 'Импорт',
+            'export' => 'Экспорт',
+            'certificates.upload' => 'Загрузка сертификата',
+            'certificates.delete' => 'Удаление сертификата',
+            'thumbnail.upload' => 'Загрузка изображения',
+            'search' => 'Поиск',
+        ],
+        'fields' => [
+            'image' => 'Картинка',
+            'id' => 'ID',
+            'article' => 'Артикул',
+            'nomenclature_number' => 'Номер номенклатуры',
+            'manufacturer' => 'Производитель',
+            'unit' => 'Ед. изм.',
+            'name_tz' => 'Наименование ТЗ',
+            'type_tz' => 'Тип по ТЗ',
+            'type' => 'Тип',
+            'manufacturer_name' => 'Наименование производителя',
+            'sizes' => 'Размеры',
+            'product_price' => 'Цена товара',
+            'installation_price' => 'Цена установки',
+            'total_price' => 'Итоговая цена',
+            'note' => 'Примечания',
+            'created_at' => 'Дата создания',
+            'certificate_id' => 'Сертификат',
+            'passport_name' => 'Наименование по паспорту',
+            'statement_name' => 'Наименование в ведомости',
+            'service_life' => 'Срок службы',
+            'certificate_number' => 'Номер сертификата',
+            'certificate_date' => 'Дата сертификата',
+            'certificate_issuer' => 'Орган сертификации',
+            'certificate_type' => 'Вид сертификации',
+            'weight' => 'Вес',
+            'volume' => 'Объем',
+            'places' => 'Мест',
+        ],
+    ],
+    'maf' => [
+        'name' => 'МАФ',
+        'entity' => 'product_sku',
+        'actions' => [
+            'view' => 'Просмотр',
+            'update' => 'Редактирование',
+            'import' => 'Импорт',
+            'export' => 'Экспорт',
+            'passports.upload' => 'Загрузка паспорта',
+            'passports.delete' => 'Удаление паспорта',
+        ],
+    ],
+    'maf_orders' => [
+        'name' => 'Заказы МАФ',
+        'entity' => 'maf_order',
+        'actions' => [
+            'view' => 'Просмотр',
+            'create' => 'Создание',
+            'update' => 'Редактирование',
+            'delete' => 'Удаление',
+            'stock.manage' => 'Управление остатками',
+        ],
+    ],
+    'contracts' => [
+        'name' => 'Договоры',
+        'entity' => 'contract',
+        'actions' => ['view' => 'Просмотр', 'create' => 'Создание', 'update' => 'Редактирование', 'delete' => 'Удаление'],
+    ],
+    'contractors' => [
+        'name' => 'Подрядчики',
+        'entity' => 'contractor',
+        'actions' => [
+            'view' => 'Просмотр',
+            'create' => 'Создание',
+            'update' => 'Редактирование',
+            'delete' => 'Удаление',
+            'prices.update' => 'Редактирование цен',
+            'prices.import' => 'Импорт цен',
+            'prices.export' => 'Экспорт цен',
+        ],
+    ],
+    'responsibles' => [
+        'name' => 'Ответственные',
+        'entity' => 'responsible',
+        'actions' => ['view' => 'Просмотр', 'create' => 'Создание', 'update' => 'Редактирование', 'delete' => 'Удаление'],
+    ],
+    'reports' => [
+        'name' => 'Отчеты',
+        'actions' => ['view' => 'Просмотр'],
+    ],
+    'users' => [
+        'name' => 'Пользователи',
+        'entity' => 'user',
+        'actions' => [
+            'view' => 'Просмотр',
+            'create' => 'Создание',
+            'update' => 'Редактирование',
+            'delete' => 'Удаление',
+            'restore' => 'Восстановление',
+            'impersonate' => 'Вход под пользователем',
+            'permissions.manage' => 'Управление правами',
+        ],
+    ],
+    'profile' => [
+        'name' => 'Профиль',
+        'actions' => ['view' => 'Просмотр', 'update' => 'Редактирование', 'delete' => 'Удаление'],
+    ],
+    'import' => [
+        'name' => 'Импорт',
+        'actions' => ['view' => 'Просмотр', 'create' => 'Создание'],
+    ],
+    'admin' => [
+        'name' => 'Администрирование',
+        'actions' => [
+            'settings.view' => 'Настройки: просмотр',
+            'settings.update' => 'Настройки: редактирование',
+            'clear_data.view' => 'Удаление данных: просмотр',
+            'clear_data.delete' => 'Удаление данных',
+            'year_data.view' => 'Данные года: просмотр',
+            'year_data.import' => 'Данные года: импорт',
+            'year_data.export' => 'Данные года: экспорт',
+            'year_data.download' => 'Данные года: скачивание',
+            'notification_logs.view' => 'Журнал уведомлений',
+            'roles' => 'Роли и права',
+        ],
+    ],
+    'districts' => [
+        'name' => 'Округа',
+        'actions' => ['view' => 'Просмотр', 'create' => 'Создание', 'update' => 'Редактирование', 'delete' => 'Удаление', 'restore' => 'Восстановление', 'import' => 'Импорт', 'export' => 'Экспорт'],
+    ],
+    'areas' => [
+        'name' => 'Районы',
+        'actions' => ['view' => 'Просмотр', 'create' => 'Создание', 'update' => 'Редактирование', 'delete' => 'Удаление', 'restore' => 'Восстановление', 'import' => 'Импорт', 'export' => 'Экспорт', 'ajax.view' => 'Ajax просмотр'],
+    ],
+    'notifications' => [
+        'name' => 'Уведомления',
+        'actions' => ['view' => 'Просмотр', 'mark_read' => 'Отметка прочтения'],
+    ],
+    'spare_parts' => [
+        'name' => 'Запчасти',
+        'entity' => 'spare_part',
+        'actions' => ['view' => 'Просмотр', 'create' => 'Создание', 'update' => 'Редактирование', 'delete' => 'Удаление', 'import' => 'Импорт', 'export' => 'Экспорт', 'image.upload' => 'Загрузка изображения', 'search' => 'Поиск'],
+    ],
+    'spare_part_orders' => [
+        'name' => 'Заказы запчастей',
+        'entity' => 'spare_part_order',
+        'actions' => ['view' => 'Просмотр', 'create' => 'Создание', 'update' => 'Редактирование', 'delete' => 'Удаление', 'ship' => 'Отгрузка', 'stock.manage' => 'Управление остатками', 'correct' => 'Корректировка'],
+    ],
+    'spare_part_reservations' => [
+        'name' => 'Резервы запчастей',
+        'actions' => ['view' => 'Просмотр', 'manage' => 'Управление', 'issue' => 'Выдача', 'cancel' => 'Отмена', 'reassign' => 'Переназначение'],
+    ],
+    'spare_part_inventory' => [
+        'name' => 'Контроль наличия',
+        'actions' => ['view' => 'Просмотр'],
+    ],
+    'pricing_codes' => [
+        'name' => 'Шифры расценок',
+        'actions' => ['view' => 'Просмотр', 'create' => 'Создание', 'update' => 'Редактирование', 'delete' => 'Удаление', 'search' => 'Поиск'],
+    ],
+    'chat_messages' => [
+        'name' => 'Чат',
+        'actions' => ['create' => 'Создание сообщений', 'delete' => 'Удаление сообщений', 'notify' => 'Уведомления из чата'],
+    ],
+    'filters' => [
+        'name' => 'Фильтры',
+        'actions' => ['view' => 'Просмотр'],
+    ],
+];

+ 250 - 0
config/access_routes.php

@@ -0,0 +1,250 @@
+<?php
+
+return [
+    'exact' => [
+        'area.ajax-get-areas-by-district' => 'areas.ajax.view',
+        'getFilters' => 'filters.view',
+        'notifications.index' => 'notifications.view',
+        'notifications.read-all' => 'notifications.mark_read',
+        'notifications.read' => 'notifications.mark_read',
+        'notifications.unread-count' => 'notifications.view',
+        'product.search' => 'catalog.search',
+        'pricing_codes.get_description' => 'pricing_codes.search',
+        'pricing_codes.search' => 'pricing_codes.search',
+        'user.profile' => 'profile.view',
+        'profile.store' => 'profile.update',
+        'profile.delete' => 'profile.delete',
+    ],
+
+    'prefixes' => [
+        'admin.area.' => [
+            'index' => 'areas.view',
+            'show' => 'areas.view',
+            'store' => ['areas.create', 'areas.update'],
+            'destroy' => 'areas.delete',
+            'undelete' => 'areas.restore',
+            'import' => 'areas.import',
+            'export' => 'areas.export',
+        ],
+        'admin.district.' => [
+            'index' => 'districts.view',
+            'show' => 'districts.view',
+            'store' => ['districts.create', 'districts.update'],
+            'destroy' => 'districts.delete',
+            'undelete' => 'districts.restore',
+            'import' => 'districts.import',
+            'export' => 'districts.export',
+        ],
+        'admin.roles.' => [
+            '*' => 'admin.roles',
+        ],
+        'admin.settings.' => [
+            'index' => 'admin.settings.view',
+            'store' => 'admin.settings.update',
+        ],
+        'admin.notifications.' => [
+            '*' => 'admin.notification_logs.view',
+        ],
+        'catalog.' => [
+            'index' => 'catalog.view',
+            'show' => 'catalog.view',
+            'create' => 'catalog.create',
+            'store' => 'catalog.create',
+            'update' => 'catalog.update',
+            'delete' => 'catalog.delete',
+            'export' => 'catalog.export',
+            'upload-certificate' => 'catalog.certificates.upload',
+            'delete-certificate' => 'catalog.certificates.delete',
+            'upload-thumbnail' => 'catalog.thumbnail.upload',
+        ],
+        'contract.' => [
+            'index' => 'contracts.view',
+            'show' => 'contracts.view',
+            'create' => 'contracts.create',
+            'store' => 'contracts.create',
+            'update' => 'contracts.update',
+            'delete' => 'contracts.delete',
+        ],
+        'contractors.' => [
+            'index' => 'contractors.view',
+            'show' => 'contractors.view',
+            'create' => 'contractors.create',
+            'store' => 'contractors.create',
+            'update' => 'contractors.update',
+            'prices.update' => 'contractors.prices.update',
+            'prices.import' => 'contractors.prices.import',
+            'prices.export' => 'contractors.prices.export',
+        ],
+        'clear-data.' => [
+            'index' => 'admin.clear_data.view',
+            'stats' => 'admin.clear_data.view',
+            'destroy' => 'admin.clear_data.delete',
+        ],
+        'import.' => [
+            'index' => 'import.view',
+            'show' => 'import.view',
+            'create' => ['import.create', 'catalog.import'],
+        ],
+        'maf_order.' => [
+            'index' => 'maf_orders.view',
+            'show' => 'maf_orders.view',
+            'store' => 'maf_orders.create',
+            'update' => 'maf_orders.update',
+            'delete' => 'maf_orders.delete',
+            'set_in_stock' => 'maf_orders.stock.manage',
+            'set_order_in_stock' => 'maf_orders.stock.manage',
+        ],
+        'mafs.' => [
+            'import' => 'maf.import',
+            'export' => 'maf.export',
+        ],
+        'order.' => [
+            'index' => 'orders.view',
+            'show' => 'orders.view',
+            'search' => 'orders.view',
+            'create' => 'orders.create',
+            'store' => 'orders.create',
+            'edit' => 'orders.update',
+            'update' => 'orders.update',
+            'destroy' => 'orders.delete',
+            'export' => 'orders.export',
+            'export-one' => 'orders.export',
+            'upload-photo' => 'orders.photos.upload',
+            'delete-photo' => 'orders.photos.delete',
+            'delete-all-photos' => 'orders.photos.delete',
+            'upload-document' => 'orders.documents.upload',
+            'delete-document' => 'orders.documents.delete',
+            'delete-all-documents' => 'orders.documents.delete',
+            'upload-statement' => 'orders.documents.upload',
+            'delete-statement' => 'orders.documents.delete',
+            'delete-all-statements' => 'orders.documents.delete',
+            'generate-installation-pack' => 'orders.documents.generate',
+            'generate-handover-pack' => 'orders.documents.generate',
+            'generate-photos-pack' => 'orders.documents.generate',
+            'download-tech-docs' => 'orders.documents.generate',
+            'get-maf' => 'orders.maf.manage',
+            'revert-maf' => 'orders.maf.manage',
+            'move-maf' => 'orders.maf.manage',
+            'create-ttn' => 'orders.ttn.create',
+            'contractor-specification' => 'orders.contractor_specification.create',
+            'chat-messages.store' => 'orders.chat.create',
+            'chat-messages.delete' => 'orders.chat.delete',
+            'generate-reclamation-pack' => 'reclamations.documents.generate',
+        ],
+        'pricing_codes.' => [
+            'index' => 'pricing_codes.view',
+            'store' => 'pricing_codes.create',
+            'update' => 'pricing_codes.update',
+            'destroy' => 'pricing_codes.delete',
+        ],
+        'product-sku.' => [
+            'upload-passport' => 'maf.passports.upload',
+            'delete-passport' => 'maf.passports.delete',
+        ],
+        'product_sku.' => [
+            'index' => 'maf.view',
+            'show' => 'maf.view',
+            'update' => 'maf.update',
+        ],
+        'reclamations.' => [
+            'index' => 'reclamations.view',
+            'show' => 'reclamations.view',
+            'create' => 'reclamations.create',
+            'update' => 'reclamations.update',
+            'delete' => 'reclamations.delete',
+            'export' => 'reclamations.export',
+            'update-status' => 'reclamations.status.update',
+            'upload-document' => 'reclamations.documents.upload',
+            'delete-document' => 'reclamations.documents.delete',
+            'upload-photo-before' => 'reclamations.photos.upload',
+            'upload-photo-after' => 'reclamations.photos.upload',
+            'delete-photo-before' => 'reclamations.photos.delete',
+            'delete-photo-after' => 'reclamations.photos.delete',
+            'upload-act' => 'reclamations.act.upload',
+            'delete-act' => 'reclamations.act.delete',
+            'update-details' => 'reclamations.update',
+            'update-spare-parts' => 'reclamations.spare_parts.manage',
+            'chat-messages.store' => 'reclamations.chat.create',
+            'chat-messages.delete' => 'reclamations.chat.delete',
+            'generate-reclamation-payment-pack' => 'reclamations.documents.generate',
+            'generate-photos-before-pack' => 'reclamations.documents.generate',
+            'generate-photos-after-pack' => 'reclamations.documents.generate',
+        ],
+        'reclamation.' => [
+            'generate-reclamation-payment-pack' => 'reclamations.documents.generate',
+            'generate-photos-before-pack' => 'reclamations.documents.generate',
+            'generate-photos-after-pack' => 'reclamations.documents.generate',
+        ],
+        'reports.' => [
+            '*' => 'reports.view',
+        ],
+        'responsible.' => [
+            'index' => 'responsibles.view',
+            'show' => 'responsibles.view',
+            'store' => 'responsibles.create',
+            'update' => 'responsibles.update',
+            'destroy' => 'responsibles.delete',
+        ],
+        'schedule.' => [
+            'index' => 'schedule.view',
+            'create-from-order' => 'schedule.create',
+            'create-from-reclamation' => 'schedule.create',
+            'update' => 'schedule.update',
+            'delete' => 'schedule.delete',
+            'export' => 'schedule.export',
+        ],
+        'spare_part_inventory.' => [
+            '*' => 'spare_part_inventory.view',
+        ],
+        'spare_part_orders.' => [
+            'index' => 'spare_part_orders.view',
+            'show' => 'spare_part_orders.view',
+            'create' => 'spare_part_orders.create',
+            'store' => 'spare_part_orders.create',
+            'update' => 'spare_part_orders.update',
+            'destroy' => 'spare_part_orders.delete',
+            'ship' => 'spare_part_orders.ship',
+            'set_in_stock' => 'spare_part_orders.stock.manage',
+            'set_order_in_stock' => 'spare_part_orders.stock.manage',
+            'correct' => 'spare_part_orders.correct',
+        ],
+        'spare_part_reservations.' => [
+            'for_reclamation' => 'spare_part_reservations.view',
+            'shortages_for_reclamation' => 'spare_part_reservations.view',
+            'cancel' => 'spare_part_reservations.cancel',
+            'cancel_all' => 'spare_part_reservations.cancel',
+            'issue' => 'spare_part_reservations.issue',
+            'issue_all' => 'spare_part_reservations.issue',
+            'reassign' => 'spare_part_reservations.reassign',
+        ],
+        'spare_parts.' => [
+            'index' => 'spare_parts.view',
+            'show' => 'spare_parts.view',
+            'help' => 'spare_parts.view',
+            'create' => 'spare_parts.create',
+            'store' => 'spare_parts.create',
+            'update' => 'spare_parts.update',
+            'destroy' => 'spare_parts.delete',
+            'import' => 'spare_parts.import',
+            'export' => 'spare_parts.export',
+            'upload_image' => 'spare_parts.image.upload',
+            'search' => 'spare_parts.search',
+        ],
+        'user.' => [
+            'index' => 'users.view',
+            'show' => 'users.view',
+            'create' => 'users.create',
+            'store' => ['users.create', 'users.update'],
+            'destroy' => 'users.delete',
+            'undelete' => 'users.restore',
+            'impersonate' => 'users.impersonate',
+        ],
+        'year-data.' => [
+            'index' => 'admin.year_data.view',
+            'stats' => 'admin.year_data.view',
+            'import' => 'admin.year_data.import',
+            'export' => 'admin.year_data.export',
+            'download' => 'admin.year_data.download',
+        ],
+    ],
+];

+ 27 - 0
database/migrations/2026_05_14_000001_create_roles_table.php

@@ -0,0 +1,27 @@
+<?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::create('roles', function (Blueprint $table) {
+            $table->id();
+            $table->string('slug')->unique();
+            $table->string('name');
+            $table->text('description')->nullable();
+            $table->boolean('is_system')->default(false);
+            $table->boolean('is_active')->default(true);
+            $table->unsignedInteger('sort')->default(100);
+            $table->timestamps();
+        });
+    }
+
+    public function down(): void
+    {
+        Schema::dropIfExists('roles');
+    }
+};

+ 35 - 0
database/migrations/2026_05_14_000002_create_permissions_table.php

@@ -0,0 +1,35 @@
+<?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::create('permissions', function (Blueprint $table) {
+            $table->id();
+            $table->string('slug')->unique();
+            $table->string('name');
+            $table->text('description')->nullable();
+            $table->string('module');
+            $table->string('entity')->nullable();
+            $table->string('field')->nullable();
+            $table->string('action');
+            $table->string('type')->default('action');
+            $table->string('group')->nullable();
+            $table->unsignedInteger('sort')->default(100);
+            $table->boolean('is_system')->default(true);
+            $table->timestamps();
+
+            $table->index(['module', 'type']);
+            $table->index(['module', 'field']);
+        });
+    }
+
+    public function down(): void
+    {
+        Schema::dropIfExists('permissions');
+    }
+};

+ 25 - 0
database/migrations/2026_05_14_000003_create_role_permissions_table.php

@@ -0,0 +1,25 @@
+<?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::create('role_permissions', function (Blueprint $table) {
+            $table->foreignId('role_id')->constrained()->cascadeOnDelete();
+            $table->foreignId('permission_id')->constrained()->cascadeOnDelete();
+            $table->string('effect')->default('allow');
+            $table->timestamps();
+
+            $table->primary(['role_id', 'permission_id']);
+        });
+    }
+
+    public function down(): void
+    {
+        Schema::dropIfExists('role_permissions');
+    }
+};

+ 27 - 0
database/migrations/2026_05_14_000004_create_user_permissions_table.php

@@ -0,0 +1,27 @@
+<?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::create('user_permissions', function (Blueprint $table) {
+            $table->foreignId('user_id')->constrained()->cascadeOnDelete();
+            $table->foreignId('permission_id')->constrained()->cascadeOnDelete();
+            $table->string('effect')->default('allow');
+            $table->text('reason')->nullable();
+            $table->timestamp('expires_at')->nullable();
+            $table->timestamps();
+
+            $table->primary(['user_id', 'permission_id']);
+        });
+    }
+
+    public function down(): void
+    {
+        Schema::dropIfExists('user_permissions');
+    }
+};

+ 26 - 0
database/migrations/2026_05_14_000005_add_role_id_to_users_table.php

@@ -0,0 +1,26 @@
+<?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('users', function (Blueprint $table) {
+            $table->foreignId('role_id')
+                ->nullable()
+                ->after('role')
+                ->constrained('roles')
+                ->nullOnDelete();
+        });
+    }
+
+    public function down(): void
+    {
+        Schema::table('users', function (Blueprint $table) {
+            $table->dropConstrainedForeignId('role_id');
+        });
+    }
+};

+ 218 - 0
database/seeders/RbacSeeder.php

@@ -0,0 +1,218 @@
+<?php
+
+namespace Database\Seeders;
+
+use App\Models\Permission;
+use App\Models\Role;
+use App\Models\User;
+use App\Services\Access\AccessService;
+use Illuminate\Database\Seeder;
+use Illuminate\Support\Facades\DB;
+
+class RbacSeeder extends Seeder
+{
+    public function run(): void
+    {
+        DB::transaction(function () {
+            $roles = $this->seedRoles();
+            $permissions = $this->seedPermissions();
+
+            $this->backfillUserRoles($roles);
+            $this->seedRolePermissions($roles, $permissions);
+        });
+
+        app(AccessService::class)->bumpCacheVersion();
+    }
+
+    private function seedRoles(): array
+    {
+        $roles = [];
+        foreach (Role::NAMES as $slug => $name) {
+            $roles[$slug] = Role::query()->updateOrCreate(
+                ['slug' => $slug],
+                [
+                    'name' => $name,
+                    'is_system' => true,
+                    'is_active' => true,
+                    'sort' => array_search($slug, Role::VALID_ROLES, true) + 10,
+                ],
+            );
+        }
+
+        return $roles;
+    }
+
+    private function seedPermissions(): array
+    {
+        $permissions = [];
+        $sort = 10;
+
+        foreach (config('access', []) as $module => $definition) {
+            foreach (($definition['actions'] ?? []) as $action => $name) {
+                $slug = "{$module}.{$action}";
+                $permissions[$slug] = Permission::query()->updateOrCreate(
+                    ['slug' => $slug],
+                    [
+                        'name' => $name,
+                        'module' => $module,
+                        'entity' => $definition['entity'] ?? null,
+                        'field' => null,
+                        'action' => $action,
+                        'type' => Permission::TYPE_ACTION,
+                        'group' => $definition['name'] ?? $module,
+                        'sort' => $sort++,
+                        'is_system' => true,
+                    ],
+                );
+            }
+
+            foreach (($definition['fields'] ?? []) as $field => $name) {
+                foreach (['view' => 'Просмотр', 'update' => 'Редактирование'] as $action => $actionName) {
+                    $slug = "{$module}.fields.{$field}.{$action}";
+                    $permissions[$slug] = Permission::query()->updateOrCreate(
+                        ['slug' => $slug],
+                        [
+                            'name' => "{$name}: {$actionName}",
+                            'module' => $module,
+                            'entity' => $definition['entity'] ?? null,
+                            'field' => $field,
+                            'action' => $action,
+                            'type' => Permission::TYPE_FIELD,
+                            'group' => ($definition['name'] ?? $module) . ' / Поля',
+                            'sort' => $sort++,
+                            'is_system' => true,
+                        ],
+                    );
+                }
+            }
+        }
+
+        return $permissions;
+    }
+
+    private function backfillUserRoles(array $roles): void
+    {
+        foreach ($roles as $slug => $role) {
+            User::query()
+                ->where('role', $slug)
+                ->whereNull('role_id')
+                ->withTrashed()
+                ->update(['role_id' => $role->id]);
+        }
+    }
+
+    private function seedRolePermissions(array $roles, array $permissions): void
+    {
+        $all = array_keys($permissions);
+        $auth = [
+            'orders.view',
+            'orders.photos.upload',
+            'orders.documents.generate',
+            'orders.chat.create',
+            'reclamations.view',
+            'reclamations.photos.upload',
+            'reclamations.chat.create',
+            'schedule.view',
+            'profile.view',
+            'profile.update',
+            'profile.delete',
+            'notifications.view',
+            'notifications.mark_read',
+            'areas.ajax.view',
+            'catalog.search',
+            'pricing_codes.search',
+            'filters.view',
+        ];
+
+        $manager = array_merge($auth, [
+            'orders.update',
+            'orders.export',
+            'orders.documents.upload',
+            'orders.documents.delete',
+            'orders.documents.generate',
+            'orders.photos.delete',
+            'reclamations.create',
+            'reclamations.update',
+            'reclamations.export',
+            'reclamations.status.update',
+            'reclamations.documents.upload',
+            'reclamations.documents.delete',
+            'reclamations.documents.generate',
+            'reclamations.photos.delete',
+            'reclamations.act.upload',
+            'reclamations.act.delete',
+            'reclamations.spare_parts.manage',
+            'catalog.view',
+            'maf.view',
+            'maf.update',
+            'maf.passports.upload',
+            'contracts.view',
+            'contracts.create',
+            'contracts.update',
+            'contracts.delete',
+            'responsibles.view',
+            'responsibles.create',
+            'responsibles.update',
+            'responsibles.delete',
+            'reports.view',
+            'spare_parts.view',
+            'spare_parts.search',
+            'spare_part_orders.view',
+            'spare_part_orders.create',
+            'spare_part_orders.update',
+            'spare_part_orders.ship',
+            'spare_part_orders.stock.manage',
+            'spare_part_reservations.view',
+            'spare_part_reservations.manage',
+            'spare_part_reservations.issue',
+            'spare_part_reservations.cancel',
+            'spare_part_reservations.reassign',
+            'spare_part_inventory.view',
+            'chat_messages.create',
+            'chat_messages.notify',
+        ]);
+
+        $manager = array_merge($manager, $this->fieldPermissions('catalog', ['view'], $permissions));
+
+        $brigadier = array_merge($auth, [
+            'reclamations.act.upload',
+        ]);
+
+        $warehouseHead = array_merge($auth, [
+            'reclamations.act.upload',
+        ]);
+
+        $rolePermissions = [
+            Role::ADMIN => $all,
+            Role::ASSISTANT_HEAD => $all,
+            Role::MANAGER => $manager,
+            Role::BRIGADIER => $brigadier,
+            Role::WAREHOUSE_HEAD => $warehouseHead,
+        ];
+
+        foreach ($rolePermissions as $roleSlug => $permissionSlugs) {
+            $role = $roles[$roleSlug] ?? null;
+            if (!$role) {
+                continue;
+            }
+
+            $sync = [];
+            foreach (array_unique($permissionSlugs) as $permissionSlug) {
+                if (isset($permissions[$permissionSlug])) {
+                    $sync[$permissions[$permissionSlug]->id] = ['effect' => 'allow'];
+                }
+            }
+
+            $role->permissions()->sync($sync);
+        }
+    }
+
+    private function fieldPermissions(string $module, array $actions, array $permissions): array
+    {
+        return array_values(array_filter(
+            array_keys($permissions),
+            fn (string $slug): bool => str_starts_with($slug, "{$module}.fields.")
+                && in_array(substr($slug, strrpos($slug, '.') + 1), $actions, true)
+        ));
+    }
+}

+ 154 - 0
docs/flex_roles_access_inventory.md

@@ -0,0 +1,154 @@
+# RBAC inventory from current code and DB
+
+Source date: 2026-05-14.
+
+This document is the baseline for RBAC migration. The first migration must preserve the current effective access of the five existing roles.
+
+## DB role snapshot
+
+Collected from `users.role` with soft-deleted users included.
+
+| Role | Total users | Active | Deleted |
+|------|-------------|--------|---------|
+| `admin` | 4 | 3 | 1 |
+| `assistant_head` | 2 | 2 | 0 |
+| `brigadier` | 9 | 7 | 2 |
+| `manager` | 5 | 4 | 1 |
+| `warehouse_head` | 2 | 2 | 0 |
+
+Role deletion rule for new RBAC: a role cannot be deleted while any `users.role_id` points to it, including soft-deleted users.
+
+## Current role semantics
+
+Current role checks are not a simple equality check.
+
+`Role::effectiveRoles()` currently expands:
+
+```text
+assistant_head -> assistant_head, admin, manager
+all other roles -> themselves
+```
+
+Consequences:
+
+- `assistant_head` passes `hasRole('admin')`.
+- `assistant_head` passes route middleware `role:admin`.
+- `assistant_head` passes `hasRole('admin,manager')`.
+- direct DB queries like `where('role', Role::ADMIN)` do not include `assistant_head`.
+
+During RBAC migration `assistant_head` must receive its own explicit permissions equivalent to current route/helper access. Do not implement role inheritance.
+
+## User-facing modules found in controllers/routes
+
+| Module key | Current table id / controller source | Main controller(s) |
+|------------|--------------------------------------|--------------------|
+| `orders` | `orders` | `OrderController` |
+| `reclamations` | `reclamations` | `ReclamationController` |
+| `schedule` | `schedule` | `ScheduleController` |
+| `catalog` | `products` | `ProductController` |
+| `maf` | `product_sku` | `ProductSKUController` |
+| `maf_orders` | `maf_order` | `MafOrderController` |
+| `contracts` | `contracts` | `ContractController` |
+| `contractors` | controller-level `$id` | `ContractorController` |
+| `responsibles` | `responsibles` | `ResponsibleController` |
+| `reports` | `reports` | `ReportController` |
+| `users` | `users` | `UserController` |
+| `profile` | no table id | `UserController` |
+| `import` | `import` | `ImportController` |
+| `admin_settings` | no table id | `AdminSettingsController` |
+| `districts` | controller-level `$id` | `AdminDistrictController` |
+| `areas` | controller-level `$id`, ajax `areas` route | `AdminAreaController`, `AreaController` |
+| `clear_data` | no table id | `ClearDataController` |
+| `year_data` | no table id | `YearDataController` |
+| `notifications` | `notifications` | `UserNotificationController` |
+| `notification_logs` | `notification_logs` | `AdminNotificationLogController` |
+| `spare_parts` | `spare_parts` | `SparePartController` |
+| `spare_part_orders` | `spare_part_orders` | `SparePartOrderController` |
+| `spare_part_reservations` | ajax/list endpoints | `SparePartReservationController` |
+| `spare_part_inventory` | `spare_part_inventory` | `SparePartInventoryController` |
+| `pricing_codes` | `pricing_codes` | `PricingCodeController` |
+| `chat_messages` | no table id | `ChatMessageController` |
+| `filters` | no table id | `FilterController` |
+
+## Current route/module access baseline
+
+Legend:
+
+- `auth` means any authenticated user.
+- `admin` includes `assistant_head` through `effectiveRoles()` when checked through `hasRole()` or `role:*`.
+- `object-level` means additional record-level filtering/checking exists and must remain in Policies/scopes.
+
+| Module | Current access from routes/code | RBAC module permissions to seed |
+|--------|---------------------------------|---------------------------------|
+| `orders` | `index`, `show`, photo upload, photo pack, tech docs, chat create: `auth`. Edit/store/update/document upload/statement upload/photo/document/statement delete/document generation/reports group: `admin,manager`. Delete, create, export-one, MAF attach/revert/move, TTN, bulk photo/document/statement delete: `admin`. Contractor specification: `admin,assistant_head`. Brigadier and warehouse have object-level filtering in controller. | `orders.view`, `orders.create`, `orders.update`, `orders.delete`, `orders.export`, `orders.photos.upload`, `orders.photos.delete`, `orders.documents.upload`, `orders.documents.delete`, `orders.documents.generate`, `orders.maf.manage`, `orders.ttn.create`, `orders.contractor_specification.create`, `orders.chat.create`, `orders.chat.delete` |
+| `reclamations` | `index`, `show`, photo before/after upload, chat create: `auth`. Create/update/status/document upload/details/spare parts/delete photos/generate packs: `admin,manager`. Upload act: `admin,manager,brigadier,warehouse_head`. Delete act/document/export: `admin,manager`. Delete reclamation/chat message: `admin`. Brigadier and warehouse have object-level filtering/checking in controller. | `reclamations.view`, `reclamations.create`, `reclamations.update`, `reclamations.delete`, `reclamations.export`, `reclamations.status.update`, `reclamations.documents.upload`, `reclamations.documents.delete`, `reclamations.documents.generate`, `reclamations.photos.upload`, `reclamations.photos.delete`, `reclamations.act.upload`, `reclamations.act.delete`, `reclamations.spare_parts.manage`, `reclamations.chat.create`, `reclamations.chat.delete` |
+| `schedule` | Index: `auth`. Create from order/reclamation, update, export, delete: `admin`. Brigadier sees own records through object-level filtering and has controller checks for some actions. | `schedule.view`, `schedule.create`, `schedule.update`, `schedule.delete`, `schedule.export` |
+| `catalog` | Index/show: `admin,manager`. Create/store/update/delete/export/delete certificate: `admin`. Upload certificate/thumbnail currently in `admin,manager` route group, but edit UI shows upload only for `admin`; seed as `admin` to match visible UI or audit before migration. Product search endpoint: `auth`. | `catalog.view`, `catalog.create`, `catalog.update`, `catalog.delete`, `catalog.export`, `catalog.certificates.upload`, `catalog.certificates.delete`, `catalog.thumbnail.upload`, `catalog.search` |
+| `maf` | Product SKU index/show/update/upload passport: `admin,manager`. Import/export/delete passport: `admin`. Blade edit fields mostly admin-only. | `maf.view`, `maf.update`, `maf.import`, `maf.export`, `maf.passports.upload`, `maf.passports.delete` |
+| `maf_orders` | All routes are inside `role:admin`, therefore `admin` and `assistant_head` currently pass middleware. Blade also uses `hasRole('admin,assistant_head')`. | `maf_orders.view`, `maf_orders.create`, `maf_orders.update`, `maf_orders.delete`, `maf_orders.stock.manage` |
+| `contracts` | Routes are in `role:admin,manager` group, but `StoreContractRequest` currently authorizes only `admin`. Effective write behavior is stricter than routes. | `contracts.view`, `contracts.create`, `contracts.update`, `contracts.delete` |
+| `contractors` | Routes are inside parent `admin,manager` group and child `role:admin,assistant_head`. Effective access: `admin` and `assistant_head`; `manager` does not pass child middleware. | `contractors.view`, `contractors.create`, `contractors.update`, `contractors.delete`, `contractors.prices.update`, `contractors.prices.import`, `contractors.prices.export` |
+| `responsibles` | Routes and FormRequest: `admin,manager`. | `responsibles.view`, `responsibles.create`, `responsibles.update`, `responsibles.delete` |
+| `reports` | Route: `admin,manager`. | `reports.view` |
+| `users` | Admin route group. Create/update/delete/restore/impersonate: `admin` through middleware/FormRequest. | `users.view`, `users.create`, `users.update`, `users.delete`, `users.restore`, `users.impersonate`, `users.permissions.manage` |
+| `profile` | View/update/delete own profile: `auth`; object-level own-user rules. | `profile.view`, `profile.update`, `profile.delete` |
+| `import` | Admin route group. | `import.view`, `import.create` |
+| `admin_settings` | Admin route group. | `admin.settings.view`, `admin.settings.update` |
+| `districts` | Admin route group with CRUD/import/export/restore. | `districts.view`, `districts.create`, `districts.update`, `districts.delete`, `districts.restore`, `districts.import`, `districts.export` |
+| `areas` | Admin dictionary route group with CRUD/import/export/restore. Public ajax `areas/{district_id?}` is `auth`. | `areas.view`, `areas.create`, `areas.update`, `areas.delete`, `areas.restore`, `areas.import`, `areas.export`, `areas.ajax.view` |
+| `clear_data` | Admin route group. | `admin.clear_data.view`, `admin.clear_data.delete` |
+| `year_data` | Admin route group with import/export/download/stats. | `admin.year_data.view`, `admin.year_data.import`, `admin.year_data.export`, `admin.year_data.download` |
+| `notifications` | User notifications: `auth`. | `notifications.view`, `notifications.mark_read` |
+| `notification_logs` | Admin route group. | `admin.notification_logs.view` |
+| `spare_parts` | Index/help/search/show: `admin,manager`. Create/store/update/delete/export/import/upload image: `admin`. Some edit fields readonly for non-admin/non-manager. | `spare_parts.view`, `spare_parts.create`, `spare_parts.update`, `spare_parts.delete`, `spare_parts.import`, `spare_parts.export`, `spare_parts.image.upload`, `spare_parts.search` |
+| `spare_part_orders` | Index/create/show/store/update/ship/set stock: `admin,manager`. Delete/correct: `admin`. | `spare_part_orders.view`, `spare_part_orders.create`, `spare_part_orders.update`, `spare_part_orders.delete`, `spare_part_orders.ship`, `spare_part_orders.stock.manage`, `spare_part_orders.correct` |
+| `spare_part_reservations` | All reservation routes: `admin,manager`. | `spare_part_reservations.view`, `spare_part_reservations.manage`, `spare_part_reservations.issue`, `spare_part_reservations.cancel`, `spare_part_reservations.reassign` |
+| `spare_part_inventory` | Route: `admin,manager`. | `spare_part_inventory.view` |
+| `pricing_codes` | CRUD routes: `admin`. `get-description` and `search` are currently `auth` without role restriction. | `pricing_codes.view`, `pricing_codes.create`, `pricing_codes.update`, `pricing_codes.delete`, `pricing_codes.search` |
+| `chat_messages` | Create for orders/reclamations: `auth` plus object-level checks. Delete: `admin`. Notifications from chat UI: `admin,manager`. | `chat_messages.create`, `chat_messages.delete`, `chat_messages.notify` |
+| `filters` | `get-filters`: `auth`; must be filtered by field visibility. | `filters.view` |
+
+## Seed matrix for built-in roles
+
+The seeder must create these role permissions first, then manual RBAC changes can adjust them later.
+
+| Role | Seed rule |
+|------|-----------|
+| `admin` | All action permissions allow. All field `view` and `update` permissions allow. Deny is forbidden. |
+| `assistant_head` | Materialize current `effectiveRoles()` behavior: all permissions that `admin` and `manager` receive through `role:*`/`hasRole()`, plus direct `assistant_head` checks. Do not create runtime inheritance. |
+| `manager` | All permissions currently guarded by `admin,manager`, plus all `auth` permissions that are available to every authenticated user. Do not include admin-only permissions. |
+| `brigadier` | All `auth` permissions available to every authenticated user, plus `reclamations.act.upload`; preserve object-level restrictions for own orders/reclamations/schedule. |
+| `warehouse_head` | All `auth` permissions available to every authenticated user, plus `reclamations.act.upload`; preserve current warehouse object-level filtering in orders/reclamations/chat controllers. |
+
+## Field ACL policy decisions
+
+User decisions for implementation:
+
+- Field permissions use only `view` and `update`.
+- Export and import are controlled by action permissions. If a user has export/import action access, the export/import may include the whole document/data set for that action.
+- Document access is all-or-nothing. If a user has document permission, return the full document without field masking.
+- Hidden required fields are not validated as required for that user.
+- Readonly fields may be displayed, but backend must remove them from update payload.
+- If create requires a field the user cannot set, deny create unless a safe default exists.
+- Hidden fields must also be removed from filters, sorting, and search server-side.
+- `assistant_head` keeps current effective access on migration; permissions can be manually reduced later.
+- Roles cannot be deleted while any user, including soft-deleted users, references the role.
+- Permissions are cached; invalidate cache on role, permission, role assignment, and user permission changes.
+- A baseline test must be added before/with migration to prove built-in roles keep current access.
+
+## Current DB/user migration notes
+
+- Existing `users.role` values are valid and match the five system roles.
+- `users.role_id` can be backfilled directly by `roles.slug = users.role`.
+- Soft-deleted users must also receive `role_id`.
+- Role deletion checks must include soft-deleted users.
+
+## Known inconsistencies to preserve initially
+
+These are not blockers, but they must not be "fixed" accidentally during the migration:
+
+- `assistant_head` passes `role:admin`, but direct `where('role', Role::ADMIN)` queries do not include it.
+- `contracts` routes allow `admin,manager`, but `StoreContractRequest` allows only `admin`.
+- `catalog.upload-certificate` and `catalog.upload-thumbnail` routes are in `admin,manager` group, while Blade only exposes controls to `admin`.
+- Some FormRequests use `auth()->check()` while routes are stricter. Keep route-level effective behavior during migration.
+- `pricing-codes/get-description` and `pricing-codes/search` are role-unrestricted inside auth group even though CRUD is admin-only.

+ 947 - 181
docs/flex_roles_plan.md

@@ -1,104 +1,467 @@
-# План внедрения гибкой ролевой модели (RBAC)
+# План внедрения гибкой ролевой модели RBAC + Field-Level ACL
 
-## Обзор
+## Цель
 
-Внедрение системы RBAC (Role-Based Access Control) для Stroyprofit CRM с возможностью создания новых ролей и назначения им прав через UI.
+Внедрить гибкую модель доступа для Stroyprofit CRM:
 
-## Текущее состояние
+- роли создаются и настраиваются через UI;
+- пользователю можно назначить роль и индивидуальные переопределения;
+- доступ проверяется не только к сущности/действию, но и к конкретному полю сущности;
+- UI, контроллеры, FormRequest, импорт, экспорт, ajax и сервисы используют единый механизм доступа;
+- существующие проверки `hasRole()` и middleware `role:*` продолжают работать во время миграции.
 
-| Компонент | Описание |
-|-----------|----------|
-| Хранение ролей | Поле `role` (string) в таблице `users` |
-| Класс Role | Статический класс с константами `app/Models/Role.php` |
-| Проверка ролей | Helper функция `hasRole()` в `app/Helpers/roles.php` |
-| Middleware | `EnsureUserHasRole` - проверка через `in_array()` |
-| Точки проверки | 155+ вызовов `hasRole()` в Blade, контроллерах, FormRequest |
+Примеры целевого поведения:
 
-**Текущие роли:** admin, manager, brigadier
+- показать каталог, но скрыть цены;
+- показать каталог, разрешить редактировать продукт, но запретить менять `name_tz`;
+- разрешить менеджеру видеть рекламации, но скрыть часть полей или запретить конкретные действия;
+- дать конкретному пользователю дополнительное право без создания отдельной роли;
+- явно запретить пользователю право, даже если оно есть у роли.
+
+Baseline текущего доступа из кода и БД зафиксирован в `docs/flex_roles_access_inventory.md`. Реализация должна использовать его как источник стартовой матрицы permissions для системных ролей.
+
+---
+
+## Текущее состояние кода
+
+| Компонент | Текущее состояние |
+|-----------|-------------------|
+| Хранение роли | `users.role` string в таблице `users` |
+| Роли | `app/Models/Role.php` - статический класс с константами |
+| Текущие роли | `admin`, `manager`, `brigadier`, `warehouse_head`, `assistant_head` |
+| Legacy-наследование ролей | `Role::effectiveRoles()`: `assistant_head` сейчас получает `assistant_head`, `admin`, `manager` |
+| Helper | `app/Helpers/roles.php`: `getRoles()`, `hasRole()`, `roleName()` |
+| Middleware | `EnsureUserHasRole`, alias `role` |
+| Проверки | `hasRole()` в Blade, контроллерах, FormRequest; role middleware в `routes/web.php` |
+| Прямые запросы | Есть `User::where('role', ...)` / `whereIn('role', ...)` в уведомлениях, чатах, контроллерах |
+| UI таблиц | Общий `resources/views/partials/table.blade.php` выводит поля из `$header` без field ACL |
+| Каталог | Цены выводятся через `product_price_txt`, `installation_price_txt`, `total_price_txt`; редактирование сейчас в основном ограничено `admin` |
+
+Важно: целевая модель не должна использовать наследование ролей. Но стартовая матрица прав должна сохранить фактические права всех базовых ролей такими же, как сейчас, включая текущее поведение `assistant_head`.
+
+---
+
+## Основные принципы
+
+1. **Deny имеет приоритет над allow.** Если пользователю или роли явно запрещено право, оно запрещено даже при наличии другого разрешения.
+2. **User override сильнее role permissions.** Индивидуальные настройки пользователя применяются поверх роли.
+3. **Admin получает все права по умолчанию**, но системные критичные действия всё равно должны иметь защиту от удаления последнего администратора и самоблокировки.
+4. **Проверка на сервере обязательна.** Скрытие кнопки или поля в Blade не является защитой.
+5. **Полевая модель применяется ко всем каналам:** Blade, таблицы, формы, POST/PUT payload, импорт, экспорт, ajax, генерация документов, API/JSON.
+6. **Права должны быть сгруппированы для UI.** Настройка ролей не должна быть плоским списком из сотен чекбоксов.
+7. **Обратная совместимость нужна на переходный период.** `hasRole()`, `role:*`, `Role::ADMIN` и старые Blade-проверки нельзя ломать одним шагом.
+8. **Наследование ролей не используется в целевой модели.** Для похожих наборов прав используется копирование роли с дальнейшим независимым редактированием.
+9. **Роль admin неизменяемая по разрешениям.** У администратора всегда все permissions allow; убирать разрешения, ставить deny или индивидуально запрещать права admin-пользователю нельзя.
 
 ---
 
-## Фаза 1: База данных
+## Термины
 
-### Миграции
+| Термин | Пример | Назначение |
+|--------|--------|------------|
+| Module | `catalog`, `orders` | Логический модуль CRM |
+| Entity | `product`, `order` | Сущность доменной модели |
+| Action permission | `catalog.view`, `catalog.update` | Доступ к действию над модулем/сущностью |
+| Field permission | `catalog.fields.product_price.view` | Доступ к полю |
+| Scope | `view`, `create`, `update`, `delete`, `export`, `import` | Тип действия |
+| Effect | `allow`, `deny` | Разрешение или явный запрет |
+| Role override | Настройки роли | Базовая матрица доступа |
+| User override | Настройки пользователя | Исключения для конкретного пользователя |
 
-**1.1 Таблица `roles`**
+---
+
+## Архитектура доступа
+
+### 1. Action permissions
+
+Права уровня модуля/сущности:
+
+```text
+catalog.view
+catalog.create
+catalog.update
+catalog.delete
+catalog.import
+catalog.export
+catalog.certificates.upload
+catalog.certificates.delete
+orders.view
+orders.create
+orders.update
+orders.delete
+orders.photos.upload
+orders.photos.delete
+orders.documents.generate
 ```
-database/migrations/2026_02_03_000001_create_roles_table.php
+
+Action permission отвечает за доступ к маршруту или операции в целом.
+
+### 2. Field permissions
+
+Права уровня поля:
+
+```text
+catalog.fields.article.view
+catalog.fields.article.update
+catalog.fields.name_tz.view
+catalog.fields.name_tz.update
+catalog.fields.product_price.view
+catalog.fields.product_price.update
+catalog.fields.installation_price.view
+catalog.fields.installation_price.update
+catalog.fields.total_price.view
+catalog.fields.total_price.update
 ```
-- `id`, `slug` (unique), `name`, `description`, `is_system` (boolean), `timestamps`
 
-**1.2 Таблица `permissions`**
+Field permission отвечает за:
+
+- отображение поля в таблице;
+- отображение поля в карточке/форме;
+- возможность редактировать поле;
+- участие поля в фильтрах, сортировке и поиске;
+- разрешение обновления поля при POST/PUT.
+
+Field-level ACL использует только `view` и `update`. Экспорт, импорт и документы регулируются action permissions: если пользователь получил доступ к экспорту/импорту/документу, он получает полный результат этой операции без field masking.
+
+### 3. Effective permissions
+
+Итоговые права пользователя вычисляются так:
+
+1. системная роль admin -> all allow, кроме явных защит бизнес-логики;
+2. права роли;
+3. индивидуальные `user_permissions`;
+4. индивидуальные deny перекрывают allow;
+5. role deny перекрывает role allow;
+6. отсутствие allow означает запрет.
+
+Для производительности итоговые права можно кэшировать по ключу:
+
+```text
+permissions:user:{id}:v{updated_at_hash}
 ```
-database/migrations/2026_02_03_000002_create_permissions_table.php
+
+Permissions нужно кэшировать. Сброс кэша обязателен при:
+
+- изменении роли;
+- изменении `role_permissions`;
+- изменении `user_permissions`;
+- изменении роли пользователя (`users.role_id` или legacy `users.role`);
+- изменении системного справочника permissions;
+- запуске сидера RBAC.
+
+---
+
+## База данных
+
+### 1. `roles`
+
+```text
+id
+slug unique
+name
+description nullable
+is_system boolean default false
+is_active boolean default true
+sort integer default 100
+timestamps
 ```
-- `id`, `slug` (unique), `name`, `module`, `action`, `description`, `timestamps`
 
-**1.3 Связь ролей и прав `role_has_permissions`**
+Системные роли при миграции:
+
+- `admin`
+- `manager`
+- `brigadier`
+- `warehouse_head`
+- `assistant_head`
+
+### 2. `permissions`
+
+```text
+id
+slug unique
+name
+description nullable
+module
+entity nullable
+field nullable
+action
+type enum: action, field
+group nullable
+sort integer default 100
+is_system boolean default true
+timestamps
 ```
-database/migrations/2026_02_03_000003_create_role_has_permissions_table.php
+
+Примеры:
+
+| slug | module | entity | field | action | type | group |
+|------|--------|--------|-------|--------|------|-------|
+| `catalog.view` | catalog | product | null | view | action | Каталог |
+| `catalog.update` | catalog | product | null | update | action | Каталог |
+| `catalog.fields.product_price.view` | catalog | product | product_price | view | field | Каталог / Поля |
+| `catalog.fields.product_price.update` | catalog | product | product_price | update | field | Каталог / Поля |
+
+### 3. `role_permissions`
+
+```text
+role_id foreign
+permission_id foreign
+effect enum: allow, deny
+timestamps
+primary(role_id, permission_id)
 ```
-- `role_id` (FK), `permission_id` (FK), составной PK
 
-**1.4 Добавление `role_id` в `users`**
+`effect` нужен, чтобы роль могла иметь явный запрет, а не только отсутствие разрешения.
+
+### 4. `user_permissions`
+
+```text
+user_id foreign
+permission_id foreign
+effect enum: allow, deny
+reason nullable
+expires_at nullable
+timestamps
+primary(user_id, permission_id)
 ```
-database/migrations/2026_02_03_000004_add_role_id_to_users_table.php
+
+`expires_at` необязателен, но полезен для временного доступа.
+
+### 5. `users.role_id`
+
+Добавить:
+
+```text
+role_id foreign nullable
 ```
-- `role_id` (FK nullable) - связь с таблицей roles
+
+На переходный период оставить `users.role`.
+
+Миграция:
+
+1. создать роли по текущим slug;
+2. заполнить `users.role_id` по `users.role`;
+3. сохранить `users.role` для обратной совместимости;
+4. позже, отдельной фазой, удалить зависимость кода от `users.role`.
+
+### 6. Наследование ролей
+
+Таблица наследования ролей не создаётся.
+
+Причины:
+
+- источник права становится менее очевидным для администратора;
+- `allow/deny` на нескольких уровнях сложнее объяснять и тестировать;
+- при field-level ACL количество комбинаций резко растёт;
+- тот же результат проще получить копированием роли.
+
+В UI нужно добавить действия "Скопировать роль" и "Создать роль из пользователя". Новая роль получает независимую копию разрешений исходной роли или итоговых разрешений выбранного пользователя, после чего редактируется отдельно.
+
+Текущее поведение `Role::effectiveRoles()` используется только как источник для миграции стартовой матрицы. После миграции у `assistant_head` должен быть собственный полный набор permissions, эквивалентный текущему фактическому доступу, без runtime-наследования `admin` и `manager`.
 
 ---
 
-## Фаза 2: Модели
+## Модели и сервисы
+
+### `app/Models/Role.php`
+
+Преобразовать в Eloquent model, но сохранить:
+
+- `ADMIN`
+- `MANAGER`
+- `BRIGADIER`
+- `WAREHOUSE_HEAD`
+- `ASSISTANT_HEAD`
+- `VALID_ROLES`
+- `NAMES`
+- `effectiveRoles()`
+
+`effectiveRoles()` оставить только для legacy fallback и проверки обратной совместимости. Новая логика доступа не должна строиться на наследовании ролей.
+
+Связи:
+
+```php
+permissions(): BelongsToMany
+users(): HasMany
+```
+
+Методы:
+
+```php
+hasPermission(string $permission): bool
+givePermission(string $permission, string $effect = 'allow'): void
+syncPermissions(array $permissions): void
+```
+
+### `app/Models/Permission.php`
 
-### 2.1 Обновление `app/Models/Role.php`
+Связи:
 
-Преобразовать из статического класса в Eloquent модель:
-- Сохранить константы для обратной совместимости (ADMIN, MANAGER, BRIGADIER)
-- Добавить связь `permissions()` (belongsToMany)
-- Добавить связь `users()` (hasMany)
-- Методы: `hasPermission()`, `givePermissions()`, `syncPermissions()`
+```php
+roles(): BelongsToMany
+users(): BelongsToMany
+```
 
-### 2.2 Новая модель `app/Models/Permission.php`
+Методы:
 
-- Связь `roles()` (belongsToMany)
-- Статический метод `getGroupedByModule()`
+```php
+getGroupedForUi(): Collection
+actionPermissions(): Builder
+fieldPermissions(): Builder
+```
 
-### 2.3 Обновление `app/Models/User.php`
+### `app/Models/User.php`
 
 Добавить:
-- Поле `role_id` в `$fillable`
-- Связь `roleModel()` (belongsTo Role)
-- Метод `hasRole()` - сначала проверяет `role_id`, fallback на `role`
-- Метод `hasPermission()` - админ имеет все права
-- Метод `hasAnyPermission()`
-- Метод `getAllPermissions()`
+
+```php
+role_id
+roleModel(): BelongsTo
+permissions(): BelongsToMany
+hasRole(string|array $roles): bool
+hasPermission(string $permission): bool
+hasAnyPermission(array|string $permissions): bool
+canViewField(string $module, string $field, ?string $entity = null): bool
+canUpdateField(string $module, string $field, ?string $entity = null): bool
+getEffectivePermissions(): Collection
+```
+
+`hasRole()` на переходный период:
+
+1. если есть `role_id`, проверять slug роли из БД;
+2. fallback на `users.role`;
+3. учитывать `Role::effectiveRoles()`.
+
+### `app/Services/Access/AccessService.php`
+
+Единая точка принятия решений:
+
+```php
+can(User $user, string $permission): bool
+canAny(User $user, array $permissions): bool
+canViewField(User $user, string $module, string $field, ?string $entity = null): bool
+canUpdateField(User $user, string $module, string $field, ?string $entity = null): bool
+filterReadableFields(User $user, string $module, array $fields, ?string $entity = null): array
+filterWritableData(User $user, string $module, array $data, ?string $entity = null): array
+assertCan(User $user, string $permission): void
+```
+
+Вся логика allow/deny/user override/cache должна жить здесь, а не размазываться по Blade и контроллерам.
+
+### `app/Services/Access/FieldAccessService.php`
+
+Можно выделить отдельно, если `AccessService` станет слишком большим:
+
+```php
+visibleHeaders(User $user, string $module, array $headers): array
+filterExportColumns(User $user, string $module, array $columns): array
+filterImportPayload(User $user, string $module, array $row): array
+filterValidatedPayload(User $user, string $module, array $validated): array
+```
+
+---
+
+## Helpers и Blade
+
+### `app/Helpers/roles.php`
+
+Обновить и расширить:
+
+```php
+getRoles($key = null)
+hasRole($roles, $user = null): bool
+roleName($role): string
+hasPermission(string $permission, $user = null): bool
+hasAnyPermission(array|string $permissions, $user = null): bool
+canViewField(string $module, string $field, ?string $entity = null, $user = null): bool
+canUpdateField(string $module, string $field, ?string $entity = null, $user = null): bool
+```
+
+### Blade directives
+
+В `AppServiceProvider`:
+
+```php
+Blade::if('role', fn($roles) => hasRole($roles));
+Blade::if('permission', fn($permission) => hasPermission($permission));
+Blade::if('anypermission', fn($permissions) => hasAnyPermission($permissions));
+Blade::if('fieldView', fn($module, $field, $entity = null) => canViewField($module, $field, $entity));
+Blade::if('fieldUpdate', fn($module, $field, $entity = null) => canUpdateField($module, $field, $entity));
+```
+
+Blade используется только для отображения. Серверная проверка всё равно обязательна.
 
 ---
 
-## Фаза 3: Helper функции
+## Видимость элементов интерфейса
+
+Все элементы интерфейса должны отображаться или скрываться на основе effective permissions текущего пользователя.
+
+Проверять нужно не только страницы целиком, но и отдельные элементы:
+
+- пункты основного меню;
+- ссылки в меню пользователя и административном меню;
+- кнопки создания, редактирования, удаления, восстановления;
+- кнопки импорта, экспорта, генерации документов;
+- кнопки загрузки/удаления файлов, фото, сертификатов, паспортов;
+- вкладки, табы, панели и блоки страницы;
+- модальные окна и кнопки, которые их открывают;
+- массовые действия;
+- inline-действия в строках таблиц;
+- dropdown/actions меню;
+- ajax-кнопки смены статуса;
+- ссылки на карточки сущностей, если у пользователя нет права просмотра;
+- поля таблиц и формы.
+
+Правила:
+
+- меню модуля показывается при наличии `{module}.view` или другого права, которое открывает полезный сценарий внутри модуля;
+- кнопка действия показывается только при наличии соответствующего action permission;
+- поле формы показывается при наличии `{module}.fields.{field}.view`;
+- поле формы доступно для редактирования только при наличии `{module}.fields.{field}.update` и action permission на редактирование сущности;
+- если действие недоступно, предпочтительно скрывать элемент, а не показывать disabled, кроме случаев, где disabled нужен для объяснения состояния;
+- для `admin` все элементы доступны, кроме ограничений бизнес-логики;
+- скрытие элемента интерфейса не заменяет middleware, FormRequest, Policy или проверку в сервисе.
 
-### Обновление `app/Helpers/roles.php`
+Для повторяемых элементов желательно использовать общие Blade-компоненты/partials, чтобы проверки не расходились:
 
-- `getRoles()` - сначала из БД, fallback на константы
-- `hasRole()` - использовать метод модели User
-- `hasPermission()` - новая функция
-- `hasAnyPermission()` - новая функция
-- `roleName()` - сначала из БД, fallback на константы
+```blade
+@permission('catalog.create')
+    <a href="{{ route('catalog.create') }}" class="btn btn-sm btn-primary">Добавить</a>
+@endpermission
+
+@fieldView('catalog', 'product_price')
+    ...
+@endfieldView
+```
 
 ---
 
-## Фаза 4: Middleware
+## Middleware
+
+### Обновить `EnsureUserHasRole`
 
-### 4.1 Обновить `app/Http/Middleware/EnsureUserHasRole.php`
-- Использовать метод `$user->hasRole()` вместо прямой проверки
+Использовать `$request->user()->hasRole($roles)`.
 
-### 4.2 Новый `app/Http/Middleware/EnsureUserHasPermission.php`
-- Использование: `middleware('permission:orders.create')`
+### Новый `EnsureUserHasPermission`
 
-### 4.3 Новый `app/Http/Middleware/EnsureUserHasAnyPermission.php`
-- Использование: `middleware('permission.any:orders.create,orders.update')`
+Пример:
+
+```php
+Route::post('catalog/{product}', [ProductController::class, 'update'])
+    ->middleware('permission:catalog.update');
+```
+
+### Новый `EnsureUserHasAnyPermission`
+
+Пример:
+
+```php
+Route::middleware('permission.any:orders.view,reclamations.view')->group(...);
+```
+
+### Регистрация в `bootstrap/app.php`
 
-### 4.4 Регистрация в `bootstrap/app.php`
 ```php
 $middleware->alias([
     'role' => EnsureUserHasRole::class,
@@ -109,181 +472,584 @@ $middleware->alias([
 
 ---
 
-## Фаза 5: Blade директивы
+## UI настройки доступа
+
+### Общие требования
+
+UI должен быть удобным при большом числе прав:
+
+- группировка по модулям;
+- внутри модуля отдельные секции: "Действия", "Поля: просмотр", "Поля: редактирование", "Файлы/документы", "Импорт/экспорт";
+- поиск по названию права, slug, полю;
+- быстрые действия: "разрешить всё в модуле", "запретить всё в модуле", "только просмотр", "сбросить";
+- три состояния для каждого права: не задано, allow, deny;
+- визуально показывать источник права: роль или пользовательское переопределение;
+- предупреждать о конфликте allow/deny;
+- показывать итоговый effective access для выбранной роли/пользователя.
+- действие "Скопировать роль" для создания похожей роли без наследования.
+- действие "Создать роль из пользователя" на основе итоговой совокупности разрешений пользователя.
+- удаление роли доступно только если к роли не привязан ни один пользователь.
+- разрешения можно менять у всех ролей, кроме `admin`.
+
+### Структура группировки
+
+Пример для каталога:
+
+```text
+Каталог
+  Действия
+    Просмотр каталога
+    Создание товара
+    Редактирование товара
+    Удаление товара
+    Импорт
+    Экспорт
+    Загрузка сертификата
+    Удаление сертификата
+  Поля: просмотр
+    Артикул
+    Наименование по ТЗ
+    Цена товара
+    Цена установки
+    Итоговая цена
+  Поля: редактирование
+    Артикул
+    Наименование по ТЗ
+    Цена товара
+    Цена установки
+    Итоговая цена
+```
+
+### Управление ролями
+
+Контроллер:
+
+```text
+app/Http/Controllers/Admin/RoleController.php
+```
 
-### Добавить в `app/Providers/AppServiceProvider.php`
+Маршруты:
 
 ```php
-Blade::if('role', fn($roles) => hasRole($roles));
-Blade::if('permission', fn($permission) => hasPermission($permission));
-Blade::if('anypermission', fn($permissions) => hasAnyPermission($permissions));
+Route::prefix('admin/roles')
+    ->name('admin.roles.')
+    ->middleware('permission:admin.roles')
+    ->group(function () {
+        Route::get('', [RoleController::class, 'index'])->name('index');
+        Route::get('create', [RoleController::class, 'create'])->name('create');
+        Route::post('', [RoleController::class, 'store'])->name('store');
+	    Route::get('{role}', [RoleController::class, 'edit'])->name('edit');
+	    Route::put('{role}', [RoleController::class, 'update'])->name('update');
+	    Route::delete('{role}', [RoleController::class, 'destroy'])->name('destroy');
+	    Route::post('{role}/permissions', [RoleController::class, 'syncPermissions'])->name('permissions.sync');
+	    Route::post('{role}/copy', [RoleController::class, 'copy'])->name('copy');
+	    Route::post('from-user/{user}', [RoleController::class, 'storeFromUser'])->name('store-from-user');
+	});
 ```
 
-**Использование:**
-```blade
-@role('admin')...@endrole
-@permission('orders.delete')...@endpermission
+Views:
+
+```text
+resources/views/admin/roles/index.blade.php
+resources/views/admin/roles/edit.blade.php
+resources/views/admin/roles/partials/permission-matrix.blade.php
 ```
 
----
+Правила управления ролями:
+
+- `admin` нельзя удалить, деактивировать, переименовать по slug или изменить по permissions;
+- для `admin` не показывать чекбоксы снятия permissions и deny-состояния, либо показывать их disabled;
+- для `admin` нельзя сохранять deny в `role_permissions`;
+- роль можно удалить только если `users.role_id` не содержит ссылок на неё;
+- перед удалением роли проверять пользователей с учётом soft-deleted записей;
+- если роль используется, UI должен показать список или количество пользователей, из-за которых удаление недоступно;
+- системные роли можно защищать от удаления slug, но permissions всех системных ролей, кроме `admin`, можно менять.
+
+Копирование роли:
+
+- создаёт новую роль с новым `slug` и `name`;
+- копирует все `role_permissions` исходной роли;
+- новая роль не связана с исходной;
+- дальнейшее изменение исходной роли не влияет на копию.
+
+Создание роли из пользователя:
 
-## Фаза 6: Сидер данных
+- берёт `getEffectivePermissions()` выбранного пользователя после применения роли и индивидуальных overrides;
+- создаёт новую роль с этими permissions как собственными `role_permissions`;
+- `allow` записывается для всех итогово разрешённых permissions;
+- итогово запрещённые permissions не записываются, кроме случаев, где для новой роли нужен явный deny;
+- индивидуальные `user_permissions` исходного пользователя в новую роль не переносятся как связь, а материализуются в матрицу роли;
+- после создания роли можно назначить её другим пользователям без копирования индивидуальных overrides.
 
-### `database/seeders/RbacSeeder.php`
+### Управление индивидуальными правами пользователя
 
-1. Создать 73 права (permissions) по модулям
-2. Создать системные роли (admin, manager, brigadier) с `is_system=true`
-3. Назначить права ролям
-4. Мигрировать существующих пользователей: заполнить `role_id` на основе `role`
+В форме пользователя добавить вкладки:
+
+- "Профиль";
+- "Роль";
+- "Индивидуальные права";
+- "Итоговый доступ".
+
+Важно: пользовательские права не заменяют роль, а накладываются поверх неё.
+
+Для пользователя с ролью `admin` запрещено создавать индивидуальные deny. Если пользователь является администратором, его итоговый доступ всегда all allow.
 
 ---
 
-## Фаза 7: UI управления ролями
+## Карта модулей и полей
+
+Нужен единый источник прав и полей, чтобы не собирать permissions вручную в разных местах.
 
-### 7.1 Контроллер `app/Http/Controllers/Admin/RoleController.php`
-- CRUD для ролей
-- Назначение прав через чекбоксы
+### Вариант: config/access.php
 
-### 7.2 Маршруты (добавить в `routes/web.php`)
 ```php
-Route::prefix('admin/roles')->middleware('role:admin')->group(function(){
-    Route::get('', [RoleController::class, 'index'])->name('admin.roles.index');
-    Route::get('create', [RoleController::class, 'create'])->name('admin.roles.create');
-    Route::post('', [RoleController::class, 'store'])->name('admin.roles.store');
-    Route::get('{role}', [RoleController::class, 'show'])->name('admin.roles.show');
-    Route::put('{role}', [RoleController::class, 'update'])->name('admin.roles.update');
-    Route::delete('{role}', [RoleController::class, 'destroy'])->name('admin.roles.destroy');
-});
-```
-
-### 7.3 Views
-- `resources/views/admin/roles/index.blade.php` - список ролей
-- `resources/views/admin/roles/edit.blade.php` - создание/редактирование с чекбоксами прав
-
-### 7.4 Меню
-Добавить в `resources/views/layouts/menu.blade.php`:
-```blade
-<li><a href="{{ route('admin.roles.index') }}">Роли и права</a></li>
+return [
+    'catalog' => [
+        'name' => 'Каталог',
+        'entity' => 'product',
+        'actions' => [
+            'view' => 'Просмотр',
+            'create' => 'Создание',
+            'update' => 'Редактирование',
+            'delete' => 'Удаление',
+            'import' => 'Импорт',
+            'export' => 'Экспорт',
+            'certificates.upload' => 'Загрузка сертификата',
+            'certificates.delete' => 'Удаление сертификата',
+        ],
+        'fields' => [
+            'article' => 'Артикул',
+            'nomenclature_number' => 'Номер номенклатуры',
+            'name_tz' => 'Наименование по ТЗ',
+            'type_tz' => 'Тип по ТЗ',
+            'manufacturer' => 'Производитель',
+            'unit' => 'Ед. изм.',
+            'type' => 'Тип',
+            'manufacturer_name' => 'Наименование производителя',
+            'sizes' => 'Размеры',
+            'product_price' => 'Цена товара',
+            'installation_price' => 'Цена установки',
+            'total_price' => 'Итоговая цена',
+            'note' => 'Примечания',
+            'certificate_id' => 'Сертификат',
+            'passport_name' => 'Наименование по паспорту',
+            'statement_name' => 'Наименование в ведомости',
+            'service_life' => 'Срок службы',
+            'certificate_number' => 'Номер сертификата',
+            'certificate_date' => 'Дата сертификата',
+            'certificate_issuer' => 'Орган сертификации',
+            'certificate_type' => 'Вид сертификации',
+            'weight' => 'Вес',
+            'volume' => 'Объем',
+            'places' => 'Мест',
+        ],
+    ],
+];
 ```
 
+Сидер должен создавать permissions из этого конфига.
+
 ---
 
-## Фаза 8: Обновление формы пользователя
+## Применение field-level ACL в коде
+
+### Меню и действия
+
+Переделать существующие Blade-проверки `hasRole()` для меню и action-кнопок на permissions:
+
+```blade
+@permission('catalog.view')
+    <a href="{{ route('catalog.index') }}">Каталог</a>
+@endpermission
+
+@permission('catalog.export')
+    <button type="button" data-bs-toggle="modal" data-bs-target="#exportModal">Экспорт</button>
+@endpermission
+```
+
+Особое внимание:
+
+- `resources/views/layouts/menu.blade.php`;
+- action toolbar в index/edit views;
+- `resources/views/partials/table.blade.php` для inline-действий;
+- модалки импорта/экспорта;
+- кнопки загрузки и удаления файлов.
+
+### Таблицы
+
+Сейчас общий partial таблиц выводит все `$header`. Нужно фильтровать `$header` до передачи во view или внутри helper:
+
+```php
+$this->data['header'] = app(FieldAccessService::class)
+    ->visibleHeaders($request->user(), 'catalog', $this->data['header']);
+```
+
+Для полей с accessor `*_txt` нужна связь с реальным полем:
+
+```text
+product_price_txt -> product_price
+installation_price_txt -> installation_price
+total_price_txt -> total_price
+```
+
+### Формы
+
+Для каждого input:
+
+- если нет `field.view` - не показывать значение;
+- если есть `field.view`, но нет `field.update` - показывать readonly/disabled;
+- если нет `catalog.update` - вся форма readonly;
+- hidden required-поля не валидируются как required для пользователя без `field.view`;
+- readonly/disabled-поля не приходят в request или могут быть подделаны, поэтому backend не должен полагаться на disabled;
+- если create требует поле, которое пользователь не может заполнить, create нужно запретить целиком, кроме случаев с безопасным default.
+
+### FormRequest и сохранение
+
+Нельзя просто делать:
+
+```php
+$product->update($request->validated());
+```
+
+Нужно:
+
+```php
+$data = app(AccessService::class)
+    ->filterWritableData($request->user(), 'catalog', $request->validated(), 'product');
+
+$product->update($data);
+```
 
-### Обновить `app/Http/Requests/User/StoreUser.php`
-- Заменить `role` на `role_id`
+Для `create` можно использовать `catalog.fields.*.create` или считать, что `create` использует `update`-права полей. Лучше явно добавить field action `create`, если создание должно отличаться от редактирования.
 
-### Обновить view редактирования пользователя
-- Выпадающий список ролей из БД вместо констант
+### Фильтры, сортировка, поиск
+
+Если поле скрыто, пользователь не должен:
+
+- видеть фильтр по нему;
+- сортировать по нему;
+- искать по нему, если поиск раскрывает наличие значения;
+- получать side-channel через количество результатов.
+
+Для каталога это особенно важно для цен.
+
+### Экспорт
+
+Экспорт регулируется action permission, например `catalog.export`. Если пользователь имеет право на экспорт, экспорт может содержать полный набор полей для этой операции. Назначение export permission считается ответственностью администратора ролей.
+
+### Импорт
+
+Импорт регулируется action permission, например `catalog.import`. Если пользователь имеет право на импорт, импорт может принимать полный набор колонок для этой операции. Назначение import permission считается ответственностью администратора ролей.
+
+### Ajax и search endpoints
+
+Проверить маршруты:
+
+- `product/search`;
+- `order-search`;
+- `pricing-codes/get-description`;
+- `pricing-codes/search`;
+- любые endpoint'ы статусов, файлов, чатов.
+
+Они должны проверять action permission и не отдавать скрытые поля.
+
+### Документы и генерация файлов
+
+Документы и сгенерированные файлы выдаются целиком. Если пользователь имеет action permission на документ, например `orders.documents.generate`, он получает полный документ без маскирования отдельных полей.
 
 ---
 
-## Список прав (73 права)
+## Модули и стартовый набор прав
+
+Минимальный набор action permissions:
 
-| Модуль | Права |
-|--------|-------|
-| orders | view, create, update, delete, export, documents, photos, maf, generate |
-| reclamations | view, create, update, delete, documents, photos, spare_parts |
-| maf | view, update, import, export, passports |
-| maf_orders | view, create, update, delete |
-| catalog | view, create, update, delete, import, export, certificates |
+| Модуль | Action permissions |
+|--------|--------------------|
+| orders | view, create, update, delete, export, documents.generate, documents.upload, documents.delete, photos.upload, photos.delete, maf.manage, ttn.create |
+| reclamations | view, create, update, delete, export, documents.generate, documents.upload, documents.delete, photos.upload, photos.delete, act.upload, act.delete, spare_parts.manage |
+| maf | view, update, import, export, passports.upload, passports.delete |
+| maf_orders | view, create, update, delete, stock.manage |
+| catalog | view, create, update, delete, import, export, certificates.upload, certificates.delete, thumbnail.upload |
 | schedule | view, create, update, delete, export |
-| spare_parts | view, create, update, delete, import, export |
-| spare_part_orders | view, create, update, delete, ship |
-| spare_part_reservations | view, manage |
+| spare_parts | view, create, update, delete, import, export, image.upload |
+| spare_part_orders | view, create, update, delete, ship, stock.manage, correct |
+| spare_part_reservations | view, manage, issue, cancel, reassign |
 | spare_part_inventory | view |
 | contracts | view, create, update, delete |
-| users | view, create, update, delete |
+| contractors | view, create, update, delete, prices.update, prices.import, prices.export |
+| users | view, create, update, delete, restore, impersonate, permissions.manage |
 | responsibles | view, create, update, delete |
 | reports | view |
 | import | view, create |
-| districts | view, manage |
-| areas | view, manage |
-| pricing_codes | view, manage |
-| admin | clear_data, year_data, roles |
+| districts | view, create, update, delete, restore, import, export |
+| areas | view, create, update, delete, restore, import, export |
+| pricing_codes | view, create, update, delete, search |
+| notifications | view, mark_read |
+| admin | settings, clear_data, year_data, roles |
+
+Для каждого модуля с табличными данными дополнительно генерируются field permissions:
+
+```text
+{module}.fields.{field}.view
+{module}.fields.{field}.update
+```
+
+Экспорт, импорт и документы не имеют field-level masking и регулируются action permissions.
 
 ---
 
-## Права по ролям (по умолчанию)
+## Дефолтные роли
+
+После применения RBAC все базовые роли должны получить такие же фактические права, как в текущем коде до миграции. Это обязательное требование миграции: пользователь с той же базовой ролью не должен внезапно получить меньше или больше доступа.
+
+Стартовая матрица прав формируется не "на глаз", а по аудиту текущих источников доступа:
+
+- `routes/web.php` middleware `role:*`;
+- `hasRole()` в Blade;
+- `hasRole()` в контроллерах и FormRequest;
+- `Role::effectiveRoles()`;
+- прямые выборки `User::where('role', ...)` / `whereIn('role', ...)`;
+- object-level ограничения в контроллерах.
+
+Для спорных мест права фиксируются по текущему фактическому поведению, а изменение бизнес-логики выносится в отдельную задачу после внедрения RBAC.
 
 ### Admin
-Все права (*)
+
+- все action permissions allow;
+- все field permissions allow;
+- нельзя удалить/деактивировать последнего admin;
+- нельзя изменить permissions роли `admin`;
+- нельзя поставить deny роли `admin`;
+- нельзя снять разрешения с роли `admin`;
+- нельзя поставить индивидуальный deny пользователю с ролью `admin`;
+- нельзя снять с самого себя роль `admin`, если это последний активный администратор.
 
 ### Manager
-- orders: view, create, update, documents, photos, generate
-- reclamations: view, create, update, documents, photos, spare_parts
-- maf: view, update
-- catalog: view
-- schedule: view, create, update
-- spare_parts: view
-- spare_part_orders: view, create, update
-- spare_part_reservations: view, manage
-- spare_part_inventory: view
-- contracts: view, create, update
-- responsibles: view, create, update
-- reports: view
+
+База должна соответствовать текущему поведению `role:admin,manager`:
+
+- orders: view, update, documents/photos where currently allowed, export where route allows manager;
+- reclamations: view, create, update, export, documents/photos/act according to current routes;
+- catalog: view;
+- contracts: view, create, update, delete currently route group allows manager, но нужно проверить бизнес-ожидание;
+- responsibles: view, create, update, delete;
+- reports: view;
+- spare_parts: view;
+- spare_part_orders: view, create, update, ship, stock.manage;
+- spare_part_reservations: view, manage;
+- spare_part_inventory: view;
+- schedule: view.
+
+Поля цен каталога для manager нужно задать явно:
+
+- если менеджер должен видеть цены: allow `catalog.fields.*price*.view`;
+- если нет: deny `catalog.fields.product_price.view`, `installation_price.view`, `total_price.view` и соответствующие `*_txt`.
 
 ### Brigadier
-- orders: view, photos
-- reclamations: view, photos
-- schedule: view
+
+База по текущему коду:
+
+- orders: view, photos.upload, photos.view;
+- reclamations: view, photos.upload, act.upload;
+- schedule: view только своих записей, если применяется объектное ограничение.
+
+Важно: часть текущих ограничений не role-based, а object-level: бригадир видит свои заказы/рекламации. Это не заменяется RBAC и должно остаться отдельной проверкой.
+
+### Warehouse Head
+
+Сейчас используется минимум в рекламациях для upload act и в уведомлениях. Нужно отдельно согласовать набор прав:
+
+- reclamations.act.upload;
+- spare_parts/spare_part_orders/spare_part_inventory по фактической бизнес-роли склада.
+
+### Assistant Head
+
+Текущее `effectiveRoles()` даёт `assistant_head` права `admin` и `manager`.
+
+В целевой модели наследования нет. Поэтому миграция должна создать для `assistant_head` собственную полную матрицу permissions, которая повторяет текущий фактический доступ этой роли. После этого `assistant_head` редактируется как самостоятельная роль.
+
+Если текущий доступ `assistant_head` через admin считается слишком широким, это нужно менять отдельным бизнес-решением после фиксации базовой миграции, а не в рамках технического переноса RBAC.
 
 ---
 
-## Порядок выполнения
-
-1. Создать миграции (4 файла)
-2. Создать модель Permission
-3. Обновить модель Role (сохранить константы)
-4. Обновить модель User
-5. Обновить helpers/roles.php
-6. Обновить EnsureUserHasRole middleware
-7. Создать новые middleware (permission, permission.any)
-8. Зарегистрировать middleware в bootstrap/app.php
-9. Добавить Blade директивы в AppServiceProvider
-10. Создать RbacSeeder
-11. Запустить: `php artisan migrate && php artisan db:seed --class=RbacSeeder`
-12. Создать RoleController
-13. Создать views для ролей
-14. Обновить меню
-15. Обновить форму пользователя (role → role_id)
+## Object-level ограничения
+
+RBAC/field ACL не заменяет ограничения по конкретной записи:
+
+- бригадир видит только свои заказы/рекламации;
+- пользователь не должен редактировать чужой профиль;
+- нельзя удалить самого себя;
+- нельзя удалить последнего admin;
+- soft-deleted пользователи и impersonation имеют отдельные ограничения;
+- доступ к файлам должен проверять принадлежность файла сущности и право на действие.
+
+Для этого нужны Policies:
+
+```text
+OrderPolicy
+ReclamationPolicy
+ProductPolicy
+SchedulePolicy
+UserPolicy
+```
+
+Policies должны использовать `AccessService`, но также проверять ownership/status/state.
 
 ---
 
-## Обратная совместимость
+## Миграция текущего кода
+
+### Фаза 1. Инфраструктура
+
+1. Создать migrations: `roles`, `permissions`, `role_permissions`, `user_permissions`, `users.role_id`.
+2. Создать `Permission` model.
+3. Преобразовать `Role` в Eloquent model с сохранением констант и fallback.
+4. Добавить методы в `User`.
+5. Создать `AccessService` и `FieldAccessService`.
+6. Обновить helpers.
+7. Добавить middleware `permission` и `permission.any`.
+8. Добавить Blade directives.
+9. Добавить `config/access.php`.
+10. Создать сидер permissions из `config/access.php`.
+
+### Фаза 2. Сидер и обратная совместимость
+
+1. Создать системные роли: все 5 текущих ролей.
+2. Заполнить `role_id` у пользователей по `users.role`.
+3. Создать стартовую матрицу прав по `docs/flex_roles_access_inventory.md`, полностью повторяющую текущий фактический доступ базовых ролей.
+4. Для `assistant_head` развернуть текущее `effectiveRoles()` в собственный набор permissions без наследования.
+5. Сохранить `users.role`, `Role::NAMES`, `Role::VALID_ROLES`, `hasRole()`.
+6. Добавить тесты, что базовые роли после миграции имеют тот же доступ, что до миграции.
+
+### Фаза 3. UI ролей и пользователей
+
+1. CRUD ролей.
+2. Permission matrix с группировкой.
+3. Индивидуальные права пользователя.
+4. Экран effective access.
+5. Кнопка "Скопировать роль" для создания независимой роли на основе существующей.
+6. Кнопка "Создать роль из пользователя" на основе effective permissions выбранного пользователя.
+7. Удаление роли только при отсутствии привязанных пользователей, включая soft-deleted.
+8. Защита системных ролей от удаления slug и критичных изменений.
+9. Жёсткая защита permissions роли `admin`: нельзя снять allow, поставить deny или сохранить ограничивающие user overrides для admin-пользователей.
+
+### Фаза 4. Перевод routes/FormRequest
+
+1. Постепенно заменить `role:*` на `permission:*`.
+2. FormRequest `authorize()` перевести на permissions.
+3. Контроллеры с ручным `hasRole()` перевести на `AccessService`.
+4. Прямые `where('role')` заменить на scope/helpers, работающие с `role_id` и fallback.
+
+### Фаза 5. Field-level ACL
+
+1. Начать с `catalog`, потому что там есть явный кейс цен.
+2. Фильтровать headers таблиц.
+3. Фильтровать поля формы.
+4. Фильтровать update payload.
+5. Фильтровать фильтры/сортировки/поиск.
+6. Проверить action permissions для export/import; field masking для export/import не применяется.
+7. Покрыть тестами.
+8. Расширить на orders, reclamations, spare_parts, users и остальные модули.
+
+### Фаза 6. Очистка legacy
+
+После полного перевода:
+
+1. убрать прямое использование `users.role` из запросов;
+2. оставить `users.role` как computed/fallback или удалить отдельной миграцией;
+3. заменить `Role::NAMES` на БД/кэш;
+4. удалить устаревшие проверки `hasRole()` там, где они больше не нужны.
+
+---
 
-- Функция `hasRole()` продолжает работать
-- Middleware `role:admin,manager` работает без изменений
-- Blade шаблоны с `@if(hasRole('admin'))` работают
-- Постепенная миграция на `hasPermission()` опциональна
+## Особые риски
+
+1. **Скрытие поля только в Blade не защищает данные.** Нужно фильтровать backend payload.
+2. **Фильтры и сортировка по скрытым полям раскрывают данные косвенно.** Для цен это критично.
+3. **Импорт может обновить скрытые поля.** Это принято как ответственность администратора ролей: import permission даёт полный импорт.
+4. **Экспорт может раскрыть скрытые поля.** Это принято как ответственность администратора ролей: export permission даёт полный экспорт.
+5. **`assistant_head` сейчас получает права через `effectiveRoles()`.** При переносе нужно развернуть это в явную матрицу permissions, иначе можно случайно расширить или урезать доступ.
+6. **Прямые запросы по `users.role` сломают новые роли.** Их нужно заменить.
+7. **Кэш permissions может устареть.** Нужна централизованная инвалидация.
+8. **Индивидуальные deny могут заблокировать пользователя от UI ролей.** Нужна защита от самоблокировки.
+9. **Object-level правила не выражаются простым RBAC.** Нужны Policies/scopes.
+10. **Документы могут содержать скрытые поля.** Это принято как all-or-nothing: document action permission отдаёт полный документ.
+11. **Удаление роли может оставить пользователей без доступа.** Удалять роль можно только после проверки отсутствия связанных пользователей.
+12. **Ограничение admin ломает аварийный доступ.** Для роли `admin` запрещены deny и снятие permissions.
 
 ---
 
-## Критические файлы для изменения
-
-| Файл | Действие |
-|------|----------|
-| `app/Models/Role.php` | Переработать в Eloquent модель |
-| `app/Models/User.php` | Добавить role_id, методы hasPermission |
-| `app/Models/Permission.php` | Создать новую модель |
-| `app/Helpers/roles.php` | Обновить с поддержкой БД |
-| `app/Http/Middleware/EnsureUserHasRole.php` | Использовать метод модели |
-| `bootstrap/app.php` | Зарегистрировать новые middleware |
-| `app/Providers/AppServiceProvider.php` | Добавить Blade директивы |
-| `database/seeders/RbacSeeder.php` | Создать сидер |
-| `app/Http/Controllers/Admin/RoleController.php` | Создать контроллер |
-| `resources/views/admin/roles/*.blade.php` | Создать views |
-| `resources/views/layouts/menu.blade.php` | Добавить пункт меню |
-| `routes/web.php` | Добавить маршруты ролей |
+## Минимальный MVP
+
+Чтобы получить пользу без полного переписывания:
+
+1. Ввести таблицы roles/permissions/user_permissions.
+2. Сохранить `users.role` fallback.
+3. Реализовать `AccessService`.
+4. Сделать UI ролей и индивидуальных прав.
+5. Перевести каталог:
+   - `catalog.view/create/update/delete/import/export`;
+   - field ACL `view/update` для всех полей каталога;
+   - особенно `product_price`, `installation_price`, `total_price` и `*_txt`.
+6. Защитить `ProductController::store/update`, `StoreProductRequest`, catalog table, export/import.
+7. После проверки паттерна переносить остальные модули.
 
 ---
 
-## Верификация
+## Тесты
+
+Нужны feature/unit тесты:
+
+- admin имеет все права;
+- manager видит каталог, но не видит цены при deny;
+- manager не может обновить `name_tz`, если нет `catalog.fields.name_tz.update`;
+- скрытое required-поле не валидируется как required для пользователя без `field.view`;
+- readonly-поле удаляется из update payload при отсутствии `field.update`;
+- запрещённое поле не попадает в таблицу;
+- запрещённое поле нельзя использовать в filters/sort/search;
+- export/import/documents работают по action permission и отдают полный результат операции;
+- user allow добавляет право поверх роли;
+- user deny перекрывает role allow;
+- `hasRole('admin,manager')` работает с `role_id` и fallback `role`;
+- все 5 базовых ролей после миграции сохраняют текущий фактический доступ;
+- `assistant_head` получает тот же фактический доступ через собственные permissions, без role inheritance;
+- копирование роли создаёт независимую роль с теми же permissions;
+- создание роли из пользователя материализует effective permissions пользователя в новую независимую роль;
+- роль с привязанными пользователями нельзя удалить;
+- роль без привязанных пользователей можно удалить;
+- permissions роли `admin` нельзя изменить;
+- deny для роли `admin` и admin-пользователя нельзя сохранить;
+- brigadier object-level ограничения не ломаются.
+
+---
+
+## Команды
+
+Запускать внутри контейнеров:
+
+```bash
+docker compose exec app php artisan migrate
+docker compose exec app php artisan db:seed --class=RbacSeeder
+make test
+```
+
+---
 
-После внедрения:
-1. Проверить авторизацию существующих пользователей
-2. Создать новую роль через UI
-3. Назначить права новой роли
-4. Создать пользователя с новой ролью
-5. Проверить доступ к разделам согласно правам
-6. Проверить что системные роли нельзя удалить
+## Критерии готовности
+
+- роли и права настраиваются через UI;
+- права сгруппированы по модулям и полям;
+- есть индивидуальные права пользователя с allow/deny;
+- есть создание роли копированием существующей роли;
+- есть создание роли из итоговых разрешений пользователя;
+- роль удаляется только если к ней не привязан ни один пользователь;
+- permissions всех ролей, кроме `admin`, можно менять;
+- у роли `admin` нельзя убрать права или поставить deny;
+- пункты меню, кнопки, вкладки, модалки, inline-действия и поля UI отображаются по permissions пользователя;
+- field-level ACL работает для просмотра и редактирования;
+- каталог может быть показан без цен;
+- продукт можно редактировать с запретом изменения отдельных полей;
+- export/import/documents доступны только по соответствующим action permissions и отдают полный результат операции;
+- legacy role checks не ломают текущий функционал;
+- прямые проверки `users.role` заменены или имеют совместимый fallback;
+- основные сценарии покрыты тестами.

+ 107 - 0
resources/views/admin/roles/edit.blade.php

@@ -0,0 +1,107 @@
+@extends('layouts.app')
+
+@section('content')
+    <div class="row mb-2">
+        <div class="col-md-7">
+            <h3>{{ $role->exists ? 'Роль: ' . $role->name : 'Новая роль' }}</h3>
+        </div>
+        <div class="col-md-5 text-end">
+            <a href="{{ route('admin.roles.index') }}" class="btn btn-sm btn-outline-secondary">Назад</a>
+        </div>
+    </div>
+
+    <form action="{{ $role->exists ? route('admin.roles.update', $role) : route('admin.roles.store') }}" method="post">
+        @csrf
+        @if($role->exists)
+            @method('PUT')
+        @endif
+
+        <div class="row">
+            <div class="col-lg-5 col-xl-4">
+                @include('partials.input', [
+                    'name' => 'name',
+                    'title' => 'Название',
+                    'required' => true,
+                    'value' => old('name', $role->name),
+                ])
+
+                @include('partials.input', [
+                    'name' => 'slug',
+                    'title' => 'Код',
+                    'required' => !$role->exists,
+                    'value' => old('slug', $role->slug),
+                    'disabled' => $role->exists,
+                ])
+                @if($role->exists)
+                    <input type="hidden" name="slug" value="{{ $role->slug }}">
+                @endif
+
+                @include('partials.input', [
+                    'name' => 'description',
+                    'title' => 'Описание',
+                    'value' => old('description', $role->description),
+                ])
+
+                @include('partials.input', [
+                    'name' => 'sort',
+                    'type' => 'number',
+                    'title' => 'Сортировка',
+                    'value' => old('sort', $role->sort ?? 100),
+                ])
+
+                <div class="row mb-2">
+                    <label class="col-form-label small col-md-4 text-md-end" for="is_active">Активна</label>
+                    <div class="col-md-8">
+                        <input type="hidden" name="is_active" value="0">
+                        <div class="form-check mt-2">
+                            <input class="form-check-input" type="checkbox" name="is_active" value="1" id="is_active"
+                                   @checked(old('is_active', $role->exists ? $role->is_active : true))
+                                   @disabled($role->slug === \App\Models\Role::ADMIN)>
+                        </div>
+                    </div>
+                </div>
+
+                <div class="text-end mb-3">
+                    <button type="submit" class="btn btn-sm btn-primary">Сохранить</button>
+                </div>
+            </div>
+
+            <div class="col-lg-7 col-xl-8">
+                @if($role->slug === \App\Models\Role::ADMIN)
+                    <div class="alert alert-warning py-2">
+                        Администратору всегда доступны все права. Убирать права или ставить deny нельзя.
+                    </div>
+                @endif
+
+                @include('admin.roles.partials.permissions-table', [
+                    'permissionGroups' => $permissionGroups,
+                    'permissionEffects' => $permissionEffects,
+                    'inputName' => 'permission_effects',
+                    'adminLocked' => $role->slug === \App\Models\Role::ADMIN,
+                    'inheritLabel' => 'Нет',
+                ])
+            </div>
+        </div>
+    </form>
+
+    @if($role->exists && $role->slug !== \App\Models\Role::ADMIN)
+        <form action="{{ route('admin.roles.destroy', $role) }}" method="post" class="d-none" id="delete-role">
+            @csrf
+            @method('DELETE')
+        </form>
+
+        <div class="text-end mt-3">
+            <a href="#" class="btn btn-sm btn-outline-danger delete-role">Удалить роль</a>
+        </div>
+    @endif
+@endsection
+
+@push('scripts')
+    <script type="module">
+        $('.delete-role').on('click', function () {
+            customConfirm('Удалить роль? Это возможно только если на неё не ссылается ни один пользователь.', function () {
+                $('#delete-role').submit();
+            });
+        });
+    </script>
+@endpush

+ 50 - 0
resources/views/admin/roles/index.blade.php

@@ -0,0 +1,50 @@
+@extends('layouts.app')
+
+@section('content')
+    <div class="row mb-2">
+        <div class="col-md-6">
+            <h3>Роли и права</h3>
+        </div>
+        <div class="col-md-6 text-end">
+            <a href="{{ route('admin.roles.create') }}" class="btn btn-sm btn-primary">Добавить роль</a>
+        </div>
+    </div>
+
+    <div class="table-responsive">
+        <table class="table table-sm table-hover align-middle">
+            <thead>
+            <tr>
+                <th>Роль</th>
+                <th>Код</th>
+                <th>Тип</th>
+                <th>Активна</th>
+                <th>Пользователей</th>
+                <th></th>
+            </tr>
+            </thead>
+            <tbody>
+            @foreach($roles as $role)
+                <tr>
+                    <td>
+                        <a href="{{ route('admin.roles.edit', $role) }}">{{ $role->name }}</a>
+                        @if($role->description)
+                            <div class="text-muted small">{{ $role->description }}</div>
+                        @endif
+                    </td>
+                    <td><code>{{ $role->slug }}</code></td>
+                    <td>{{ $role->is_system ? 'Базовая' : 'Пользовательская' }}</td>
+                    <td>{{ $role->is_active ? 'Да' : 'Нет' }}</td>
+                    <td>{{ $role->users_count }}</td>
+                    <td class="text-end">
+                        <a href="{{ route('admin.roles.edit', $role) }}" class="btn btn-sm btn-outline-primary">Открыть</a>
+                        <form action="{{ route('admin.roles.copy', $role) }}" method="post" class="d-inline">
+                            @csrf
+                            <button type="submit" class="btn btn-sm btn-outline-secondary">Копировать</button>
+                        </form>
+                    </td>
+                </tr>
+            @endforeach
+            </tbody>
+        </table>
+    </div>
+@endsection

+ 65 - 0
resources/views/admin/roles/partials/permissions-table.blade.php

@@ -0,0 +1,65 @@
+@php
+    $effects = old($inputName, $permissionEffects ?? []);
+    $adminLocked = $adminLocked ?? false;
+    $inheritLabel = $inheritLabel ?? 'Наследовать';
+@endphp
+
+<div class="accordion" id="permissionsAccordion">
+    @foreach($permissionGroups as $group => $permissions)
+        @php($groupId = 'permissions-' . \Illuminate\Support\Str::slug($group ?: 'other') . '-' . $loop->index)
+        <div class="accordion-item">
+            <h2 class="accordion-header" id="{{ $groupId }}-heading">
+                <button class="accordion-button @if(!$loop->first) collapsed @endif" type="button"
+                        data-bs-toggle="collapse"
+                        data-bs-target="#{{ $groupId }}"
+                        aria-expanded="{{ $loop->first ? 'true' : 'false' }}"
+                        aria-controls="{{ $groupId }}">
+                    {{ $group ?: 'Прочее' }}
+                    <span class="text-muted small ms-2">{{ $permissions->count() }}</span>
+                </button>
+            </h2>
+            <div id="{{ $groupId }}" class="accordion-collapse collapse @if($loop->first) show @endif"
+                 aria-labelledby="{{ $groupId }}-heading">
+                <div class="accordion-body p-0">
+                    <div class="table-responsive">
+                        <table class="table table-sm align-middle mb-0">
+                            <thead>
+                            <tr>
+                                <th>Право</th>
+                                <th>Код</th>
+                                <th class="text-end">Доступ</th>
+                            </tr>
+                            </thead>
+                            <tbody>
+                            @foreach($permissions as $permission)
+                                @php($value = $adminLocked ? 'allow' : ($effects[$permission->id] ?? 'none'))
+                                <tr>
+                                    <td>
+                                        {{ $permission->name }}
+                                        @if($permission->type === \App\Models\Permission::TYPE_FIELD)
+                                            <span class="badge text-bg-light">поле</span>
+                                        @endif
+                                    </td>
+                                    <td><code>{{ $permission->slug }}</code></td>
+                                    <td class="text-end" style="min-width: 180px;">
+                                        <select name="{{ $inputName }}[{{ $permission->id }}]"
+                                                class="form-select form-select-sm"
+                                                @disabled($adminLocked)>
+                                            <option value="none" @selected($value === 'none')>{{ $inheritLabel }}</option>
+                                            <option value="allow" @selected($value === 'allow')>Разрешить</option>
+                                            <option value="deny" @selected($value === 'deny') @disabled($adminLocked)>Запретить</option>
+                                        </select>
+                                        @if($adminLocked)
+                                            <input type="hidden" name="{{ $inputName }}[{{ $permission->id }}]" value="allow">
+                                        @endif
+                                    </td>
+                                </tr>
+                            @endforeach
+                            </tbody>
+                        </table>
+                    </div>
+                </div>
+            </div>
+        </div>
+    @endforeach
+</div>

+ 60 - 47
resources/views/catalog/edit.blade.php

@@ -1,32 +1,43 @@
 @extends('layouts.app')
 
 @section('content')
+    @php
+        $catalogReadableFields = $catalogReadableFields ?? [];
+        $catalogWritableFields = $catalogWritableFields ?? [];
+        $canView = static fn (string $field): bool => (bool)($catalogReadableFields[$field] ?? false);
+        $canUpdate = static fn (string $field): bool => (bool)($catalogWritableFields[$field] ?? false);
+        $visibleName = ($product && $canView('article') && $canView('nomenclature_number')) ? $product->common_name : 'МАФ';
+    @endphp
     <div class="px-3">
         <div class="row mb-2">
             <div class="col-md-6 d-flex align-items-center">
-                <h3 class="mb-0">МАФ {{ $product->common_name ?? 'Новый МАФ' }} ({{ $product->year ?? year() }})</h3>
+                <h3 class="mb-0">МАФ {{ $product ? $visibleName : 'Новый МАФ' }} ({{ $product->year ?? year() }})</h3>
             </div>
             <div class="col-md-6 d-flex align-items-center justify-content-end action-toolbar">
-                @if(isset($product) && hasRole('admin'))
-                    @if($product->image)
+                @if(isset($product))
+                    @if($canView('image') && $product->image)
                         <a href="{{ $product->image }}" data-toggle="lightbox" data-gallery="photos" data-size="fullscreen">
                             <img src="{{ $product->image }}" alt="Миниатюра" class="img-thumbnail img-max-40">
                         </a>
                     @endif
-                    <button class="btn btn-sm text-success" onclick="$('#upl-thumb').trigger('click');" title="Загрузить изображение"><i class="bi bi-image"></i> Изображение</button>
+                    @if(hasAccess('catalog.thumbnail.upload', 'admin'))
+                        <button class="btn btn-sm text-success" onclick="$('#upl-thumb').trigger('click');" title="Загрузить изображение"><i class="bi bi-image"></i> Изображение</button>
 
-                    <form action="{{ route('catalog.upload-thumbnail', ['product' => $product, 'nav' => $nav ?? null]) }}" class="visually-hidden" method="POST" enctype="multipart/form-data">
-                        @csrf
-                        <input type="file" name="thumbnail" accept=".jpg,.jpeg" onchange="$(this).parent().submit()" required id="upl-thumb" />
-                    </form>
+                        <form action="{{ route('catalog.upload-thumbnail', ['product' => $product, 'nav' => $nav ?? null]) }}" class="visually-hidden" method="POST" enctype="multipart/form-data">
+                            @csrf
+                            <input type="file" name="thumbnail" accept=".jpg,.jpeg" onchange="$(this).parent().submit()" required id="upl-thumb" />
+                        </form>
+                    @endif
 
-                    <button class="btn btn-sm text-success" onclick="$('#upl-cert').trigger('click');"><i class="bi bi-plus-circle-fill"></i> Загрузить сертификат</button>
+                    @if(hasAccess('catalog.certificates.upload', 'admin'))
+                        <button class="btn btn-sm text-success" onclick="$('#upl-cert').trigger('click');"><i class="bi bi-plus-circle-fill"></i> Загрузить сертификат</button>
 
-                    <form action="{{ route('catalog.upload-certificate', ['product' => $product, 'nav' => $nav ?? null]) }}" class="visually-hidden" method="POST" enctype="multipart/form-data">
-                        @csrf
-                        <input type="file" name="certificate" onchange="$(this).parent().submit()" required id="upl-cert" />
+                        <form action="{{ route('catalog.upload-certificate', ['product' => $product, 'nav' => $nav ?? null]) }}" class="visually-hidden" method="POST" enctype="multipart/form-data">
+                            @csrf
+                            <input type="file" name="certificate" onchange="$(this).parent().submit()" required id="upl-cert" />
 
-                    </form>
+                        </form>
+                    @endif
                 @endif
             </div>
         </div>
@@ -37,51 +48,53 @@
                 <div class="row">
                     <div class="col-xl-6">
                         @include('partials.input', ['name' => 'year', 'title' => 'Год', 'value' => $product->year ?? year(), 'disabled' => true])
-                        @include('partials.input', ['name' => 'article', 'title' => 'Артикул', 'required' => true, 'value' => $product->article ?? '', 'disabled' => !hasRole('admin'), 'disabled' => !hasRole('admin')])
-                        @include('partials.input', ['name' => 'nomenclature_number', 'title' => 'Номер номенклатуры', 'required' => true, 'value' => $product->nomenclature_number ?? '', 'disabled' => !hasRole('admin')])
-                        @include('partials.input', ['name' => 'name_tz', 'title' => 'Наименование по ТЗ', 'required' => true, 'value' => $product->name_tz ?? '', 'disabled' => !hasRole('admin')])
-                        @include('partials.input', ['name' => 'type_tz', 'title' => 'Тип по ТЗ', 'required' => true, 'value' => $product->type_tz ?? '', 'disabled' => !hasRole('admin')])
-                        @include('partials.input', ['name' => 'unit', 'title' => 'Ед. изм.', 'required' => true, 'value' => $product->unit ?? '', 'disabled' => !hasRole('admin')])
-                        @include('partials.input', ['name' => 'manufacturer', 'title' => 'Производитель', 'required' => true, 'value' => $product->manufacturer ?? '', 'disabled' => !hasRole('admin')])
-                        @include('partials.input', ['name' => 'type', 'title' => 'Тип', 'value' => $product->type ?? '', 'disabled' => !hasRole('admin')])
-                        @include('partials.input', ['name' => 'manufacturer_name', 'title' => 'Наименование производителя', 'required' => true, 'value' => $product->manufacturer_name ?? '', 'disabled' => !hasRole('admin')])
-                        @include('partials.input', ['name' => 'sizes', 'title' => 'Размеры', 'required' => true, 'value' => $product->sizes ?? '', 'disabled' => !hasRole('admin')])
-                        @include('partials.input', ['name' => 'product_price', 'type' => 'number', 'title' => 'Цена товара', 'required' => true, 'value' => $product->product_price ?? '', 'disabled' => !hasRole('admin')])
-                        @include('partials.input', ['name' => 'installation_price', 'type' => 'number', 'title' => 'Цена установки', 'required' => true, 'value' => $product->installation_price ?? '', 'disabled' => !hasRole('admin')])
-                        @include('partials.input', ['name' => 'total_price', 'type' => 'number', 'title' => 'Итоговая цена', 'required' => true, 'value' => $product->total_price ?? '', 'disabled' => !hasRole('admin')])
-                        @include('partials.input', ['name' => 'passport_name', 'title' => 'Наименование по паспорту', 'value' => $product->passport_name ?? '', 'disabled' => !hasRole('admin')])
-                        @include('partials.input', ['name' => 'statement_name', 'title' => 'Наименование в ведомости', 'value' => $product->statement_name ?? '', 'disabled' => !hasRole('admin')])
-                        @include('partials.input', ['name' => 'service_life', 'title' => 'Срок службы', 'type' => 'number', 'value' => $product?->service_life, 'disabled' => !hasRole('admin')])
+                        @if($canView('article')) @include('partials.input', ['name' => 'article', 'title' => 'Артикул', 'required' => true, 'value' => $product->article ?? '', 'disabled' => !$canUpdate('article')]) @endif
+                        @if($canView('nomenclature_number')) @include('partials.input', ['name' => 'nomenclature_number', 'title' => 'Номер номенклатуры', 'required' => true, 'value' => $product->nomenclature_number ?? '', 'disabled' => !$canUpdate('nomenclature_number')]) @endif
+                        @if($canView('name_tz')) @include('partials.input', ['name' => 'name_tz', 'title' => 'Наименование по ТЗ', 'required' => true, 'value' => $product->name_tz ?? '', 'disabled' => !$canUpdate('name_tz')]) @endif
+                        @if($canView('type_tz')) @include('partials.input', ['name' => 'type_tz', 'title' => 'Тип по ТЗ', 'required' => true, 'value' => $product->type_tz ?? '', 'disabled' => !$canUpdate('type_tz')]) @endif
+                        @if($canView('unit')) @include('partials.input', ['name' => 'unit', 'title' => 'Ед. изм.', 'required' => true, 'value' => $product->unit ?? '', 'disabled' => !$canUpdate('unit')]) @endif
+                        @if($canView('manufacturer')) @include('partials.input', ['name' => 'manufacturer', 'title' => 'Производитель', 'required' => true, 'value' => $product->manufacturer ?? '', 'disabled' => !$canUpdate('manufacturer')]) @endif
+                        @if($canView('type')) @include('partials.input', ['name' => 'type', 'title' => 'Тип', 'value' => $product->type ?? '', 'disabled' => !$canUpdate('type')]) @endif
+                        @if($canView('manufacturer_name')) @include('partials.input', ['name' => 'manufacturer_name', 'title' => 'Наименование производителя', 'required' => true, 'value' => $product->manufacturer_name ?? '', 'disabled' => !$canUpdate('manufacturer_name')]) @endif
+                        @if($canView('sizes')) @include('partials.input', ['name' => 'sizes', 'title' => 'Размеры', 'required' => true, 'value' => $product->sizes ?? '', 'disabled' => !$canUpdate('sizes')]) @endif
+                        @if($canView('product_price')) @include('partials.input', ['name' => 'product_price', 'type' => 'number', 'title' => 'Цена товара', 'required' => true, 'value' => $product->product_price ?? '', 'disabled' => !$canUpdate('product_price')]) @endif
+                        @if($canView('installation_price')) @include('partials.input', ['name' => 'installation_price', 'type' => 'number', 'title' => 'Цена установки', 'required' => true, 'value' => $product->installation_price ?? '', 'disabled' => !$canUpdate('installation_price')]) @endif
+                        @if($canView('total_price')) @include('partials.input', ['name' => 'total_price', 'type' => 'number', 'title' => 'Итоговая цена', 'required' => true, 'value' => $product->total_price ?? '', 'disabled' => !$canUpdate('total_price')]) @endif
+                        @if($canView('passport_name')) @include('partials.input', ['name' => 'passport_name', 'title' => 'Наименование по паспорту', 'value' => $product->passport_name ?? '', 'disabled' => !$canUpdate('passport_name')]) @endif
+                        @if($canView('statement_name')) @include('partials.input', ['name' => 'statement_name', 'title' => 'Наименование в ведомости', 'value' => $product->statement_name ?? '', 'disabled' => !$canUpdate('statement_name')]) @endif
+                        @if($canView('service_life')) @include('partials.input', ['name' => 'service_life', 'title' => 'Срок службы', 'type' => 'number', 'value' => $product?->service_life, 'disabled' => !$canUpdate('service_life')]) @endif
 
                         <input type="hidden" name="nav" value="{{ $nav ?? '' }}">
                     </div>
                     <div class="col-xl-6">
-                        @if($product?->certificate)
+                        @if($canView('certificate_id') && $product?->certificate)
                             @include('partials.input', ['name' => 'cert', 'title' => 'Сертификат', 'value' => $product->certificate->original_name, 'disabled' => true])
                         @endif
 
-                        @include('partials.input', ['name' => 'certificate_number', 'title' => 'Номер сертификата', 'value' => $product?->certificate_number, 'disabled' => !hasRole('admin')])
-                        @include('partials.input', ['name' => 'certificate_date', 'title' => 'Дата сертификата', 'type' => 'date', 'value' => $product?->certificate_date, 'disabled' => !hasRole('admin')])
-                        @include('partials.input', ['name' => 'certificate_issuer', 'title' => 'Орган сертификации', 'value' => $product?->certificate_issuer, 'disabled' => !hasRole('admin')])
-                        @include('partials.input', ['name' => 'certificate_type', 'title' => 'Вид сертификации', 'value' => $product?->certificate_type, 'disabled' => !hasRole('admin')])
-                        @include('partials.input', ['name' => 'weight', 'title' => 'Вес', 'value' => $product?->weight,  'type' => 'number', 'step' => '0.01', 'disabled' => !hasRole('admin'), 'required' => true])
-                        @include('partials.input', ['name' => 'volume', 'title' => 'Объём', 'value' => $product?->volume, 'type' => 'number', 'step' => '0.01', 'disabled' => !hasRole('admin'), 'required' => true])
-                        @include('partials.input', ['name' => 'places', 'title' => 'Кол-во мест', 'value' => $product?->places, 'type' => 'number', 'step' => '1', 'disabled' => !hasRole('admin'), 'required' => true])
+                        @if($canView('certificate_number')) @include('partials.input', ['name' => 'certificate_number', 'title' => 'Номер сертификата', 'value' => $product?->certificate_number, 'disabled' => !$canUpdate('certificate_number')]) @endif
+                        @if($canView('certificate_date')) @include('partials.input', ['name' => 'certificate_date', 'title' => 'Дата сертификата', 'type' => 'date', 'value' => $product?->certificate_date, 'disabled' => !$canUpdate('certificate_date')]) @endif
+                        @if($canView('certificate_issuer')) @include('partials.input', ['name' => 'certificate_issuer', 'title' => 'Орган сертификации', 'value' => $product?->certificate_issuer, 'disabled' => !$canUpdate('certificate_issuer')]) @endif
+                        @if($canView('certificate_type')) @include('partials.input', ['name' => 'certificate_type', 'title' => 'Вид сертификации', 'value' => $product?->certificate_type, 'disabled' => !$canUpdate('certificate_type')]) @endif
+                        @if($canView('weight')) @include('partials.input', ['name' => 'weight', 'title' => 'Вес', 'value' => $product?->weight,  'type' => 'number', 'step' => '0.01', 'disabled' => !$canUpdate('weight'), 'required' => true]) @endif
+                        @if($canView('volume')) @include('partials.input', ['name' => 'volume', 'title' => 'Объём', 'value' => $product?->volume, 'type' => 'number', 'step' => '0.01', 'disabled' => !$canUpdate('volume'), 'required' => true]) @endif
+                        @if($canView('places')) @include('partials.input', ['name' => 'places', 'title' => 'Кол-во мест', 'value' => $product?->places, 'type' => 'number', 'step' => '1', 'disabled' => !$canUpdate('places'), 'required' => true]) @endif
 
-                        <div class="row mb-2">
-                            <label for="note" class="col-form-label my-1">
-                                Примечание <sup>*</sup>
-                            </label>
-                            <div>
-                                <textarea name="note" id="note" rows="15" @disabled(!hasRole('admin')) class="form-control @error('note') is-invalid @enderror" required>{{ old('note', $product->note ?? '') }}</textarea>
-                                @error('note')
-                                    <span class="invalid-feedback" role="alert"><strong>{{ $message }}</strong></span>
-                                @enderror
+                        @if($canView('note'))
+                            <div class="row mb-2">
+                                <label for="note" class="col-form-label my-1">
+                                    Примечание <sup>*</sup>
+                                </label>
+                                <div>
+                                    <textarea name="note" id="note" rows="15" @disabled(!$canUpdate('note')) class="form-control @error('note') is-invalid @enderror" required>{{ old('note', $product->note ?? '') }}</textarea>
+                                    @error('note')
+                                        <span class="invalid-feedback" role="alert"><strong>{{ $message }}</strong></span>
+                                    @enderror
+                                </div>
                             </div>
-                        </div>
+                        @endif
                     </div>
                     <div class="col-12">
-                        @include('partials.submit', ['deleteDisabled' => (!isset($product) || $product->hasRelations() || !hasRole('admin')), 'disabled' => !hasRole('admin'), 'offset' => 6, 'delete' => ['form_id' => 'deleteProduct'], 'back_url' => $back_url ?? route('catalog.index', session('gp_products'))])
+                        @include('partials.submit', ['deleteDisabled' => (!isset($product) || $product->hasRelations() || !hasAccess('catalog.delete', 'admin')), 'disabled' => !(($product && hasAccess('catalog.update', 'admin')) || (!$product && hasAccess('catalog.create', 'admin'))), 'offset' => 6, 'delete' => ['form_id' => 'deleteProduct'], 'back_url' => $back_url ?? route('catalog.index', session('gp_products'))])
                     </div>
                 </div>
 

+ 5 - 1
resources/views/catalog/index.blade.php

@@ -10,15 +10,19 @@
             @include('partials.year-switcher')
         </div>
         <div class="col-auto col-md-4 text-md-end page-header-actions">
-            @if(hasRole('admin'))
+            @if(hasAccess('catalog.import', 'admin'))
                 <button type="button" class="btn btn-sm mb-1 btn-primary page-action-btn" data-bs-toggle="modal" data-bs-target="#importModal" aria-label="Импорт">
                     <i class="bi bi-upload page-action-btn__icon"></i>
                     <span class="page-action-btn__label">Импорт</span>
                 </button>
+            @endif
+            @if(hasAccess('catalog.export', 'admin'))
                 <button type="button" class="btn btn-sm mb-1 btn-primary page-action-btn" data-bs-toggle="modal" data-bs-target="#exportModal" aria-label="Экспорт">
                     <i class="bi bi-download page-action-btn__icon"></i>
                     <span class="page-action-btn__label">Экспорт</span>
                 </button>
+            @endif
+            @if(hasAccess('catalog.create', 'admin'))
                 <a href="{{ route('catalog.create') }}" class="btn btn-sm mb-1 btn-primary page-action-btn" aria-label="Добавить">
                     <i class="bi bi-plus-lg page-action-btn__icon"></i>
                     <span class="page-action-btn__label">Добавить</span>

+ 45 - 20
resources/views/layouts/menu.blade.php

@@ -1,52 +1,77 @@
 @if(auth()->check())
-    <li class="nav-item"><a class="nav-link @if($active == 'orders') active @endif" href="{{ route('order.index', session('gp_orders')) }}">Площадки</a></li>
+    @if(hasAccess('orders.view', \App\Models\Role::VALID_ROLES))
+        <li class="nav-item"><a class="nav-link @if($active == 'orders') active @endif" href="{{ route('order.index', session('gp_orders')) }}">Площадки</a></li>
+    @endif
 
-    @if(hasRole('admin,manager'))
+    @if(hasAccess('maf.view', 'admin,manager'))
         <li class="nav-item"><a class="nav-link @if($active == 'product_sku') active @endif"
                                 href="{{ route('product_sku.index', session('gp_product_sku')) }}">МАФ</a></li>
+    @endif
+    @if(hasAccess('catalog.view', 'admin,manager'))
         <li class="nav-item"><a class="nav-link @if($active == 'catalog') active @endif"
                                 href="{{ route('catalog.index', session('gp_products')) }}">Каталог</a></li>
+    @endif
+    @if(hasAccess('reports.view', 'admin,manager'))
         <li class="nav-item"><a class="nav-link @if($active == 'reports') active @endif"
                                 href="{{ route('reports.index', session('gp_reports')) }}">Отчёты</a></li>
+    @endif
+    @if(hasAccess('responsibles.view', 'admin,manager'))
         <li class="nav-item"><a class="nav-link @if($active == 'responsibles') active @endif" href="{{ route('responsible.index', session('gp_responsibles')) }}">Ответственные</a></li>
     @endif
-    <li class="nav-item"><a class="nav-link @if($active == 'reclamations') active @endif"
-                            href="{{ route('reclamations.index', session('gp_reclamations')) }}">Рекламации</a></li>
+    @if(hasAccess('reclamations.view', \App\Models\Role::VALID_ROLES))
+        <li class="nav-item"><a class="nav-link @if($active == 'reclamations') active @endif"
+                                href="{{ route('reclamations.index', session('gp_reclamations')) }}">Рекламации</a></li>
+    @endif
 
-    @if(hasRole('admin,manager'))
+    @if(hasAccess('spare_parts.view', 'admin,manager'))
         <li class="nav-item"><a class="nav-link @if($active == 'spare_parts') active @endif"
                                 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"
-                            href="{{ route('schedule.index', session('gp_schedule')) }}">График монтажей</a></li>
+    @if(hasAccess('schedule.view', \App\Models\Role::VALID_ROLES))
+        <li class="nav-item"><a class="nav-link @if($active == 'schedule') active @endif"
+                                href="{{ route('schedule.index', session('gp_schedule')) }}">График монтажей</a></li>
+    @endif
 
 
-    @if(hasRole('admin'))
+    @if(hasAccess('maf_orders.view', 'admin'))
         <li class="nav-item"><a class="nav-link @if($active == 'maf_order') active @endif"
                                 href="{{ route('maf_order.index', session('gp_maf_order')) }}">Заказы МАФ</a></li>
     @endif
-    @if(auth()->user()?->role === \App\Models\Role::ASSISTANT_HEAD)
+    @if(hasAccess('contractors.view', \App\Models\Role::ASSISTANT_HEAD))
         <li class="nav-item"><a class="nav-link @if($active == 'contractors') active @endif"
                                 href="{{ route('contractors.index', session('gp_contractors')) }}">Подрядчики</a></li>
     @endif
-    @if(auth()->user()?->role === \App\Models\Role::ADMIN)
+    @if(hasRole('admin') || hasAnyPermission([
+        'contractors.view',
+        'contracts.view',
+        'users.view',
+        'admin.roles',
+        'admin.settings.view',
+        'districts.view',
+        'areas.view',
+        'admin.notification_logs.view',
+        'import.view',
+        'admin.year_data.view',
+        'admin.clear_data.view',
+    ]))
         <li class="nav-item dropdown">
             <a id="navbarDropdown" class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown"
                aria-haspopup="true" aria-expanded="false">
                 Администрирование
             </a>
             <ul class="dropdown-menu dropdown-menu-end">
-                <li class="dropdown-item"><a class="nav-link" href="{{ route('contractors.index', session('gp_contractors')) }}">Подрядчики</a></li>
-                <li class="dropdown-item"><a class="nav-link" href="{{ route('contract.index', session('gp_contracts')) }}">Договоры</a></li>
-                <li class="dropdown-item"><a class="nav-link" href="{{ route('user.index', session('gp_users')) }}">Пользователи</a></li>
-                <li class="dropdown-item"><a class="nav-link" href="{{ route('admin.settings.index') }}">Настройки</a></li>
-                <li class="dropdown-item"><a class="nav-link" href="{{ route('admin.district.index') }}">Округа</a></li>
-                <li class="dropdown-item"><a class="nav-link" href="{{ route('admin.area.index') }}">Районы</a></li>
-                <li class="dropdown-item"><a class="nav-link" href="{{ route('admin.notifications.log') }}">Журнал уведомлений</a></li>
-                <li class="dropdown-item"><a class="nav-link" href="{{ route('import.index', session('gp_import')) }}">Импорт</a></li>
-                <li class="dropdown-item"><a class="nav-link" href="{{ route('year-data.index') }}">Экспорт/Импорт года</a></li>
-                <li class="dropdown-item"><a class="nav-link" href="{{ route('clear-data.index') }}">Удалить данные</a></li>
+                @if(hasAccess('contractors.view', 'admin'))<li class="dropdown-item"><a class="nav-link" href="{{ route('contractors.index', session('gp_contractors')) }}">Подрядчики</a></li>@endif
+                @if(hasAccess('contracts.view', 'admin'))<li class="dropdown-item"><a class="nav-link" href="{{ route('contract.index', session('gp_contracts')) }}">Договоры</a></li>@endif
+                @if(hasAccess('users.view', 'admin'))<li class="dropdown-item"><a class="nav-link" href="{{ route('user.index', session('gp_users')) }}">Пользователи</a></li>@endif
+                @if(hasAccess('admin.roles', 'admin'))<li class="dropdown-item"><a class="nav-link" href="{{ route('admin.roles.index') }}">Роли и права</a></li>@endif
+                @if(hasAccess('admin.settings.view', 'admin'))<li class="dropdown-item"><a class="nav-link" href="{{ route('admin.settings.index') }}">Настройки</a></li>@endif
+                @if(hasAccess('districts.view', 'admin'))<li class="dropdown-item"><a class="nav-link" href="{{ route('admin.district.index') }}">Округа</a></li>@endif
+                @if(hasAccess('areas.view', 'admin'))<li class="dropdown-item"><a class="nav-link" href="{{ route('admin.area.index') }}">Районы</a></li>@endif
+                @if(hasAccess('admin.notification_logs.view', 'admin'))<li class="dropdown-item"><a class="nav-link" href="{{ route('admin.notifications.log') }}">Журнал уведомлений</a></li>@endif
+                @if(hasAccess('import.view', 'admin'))<li class="dropdown-item"><a class="nav-link" href="{{ route('import.index', session('gp_import')) }}">Импорт</a></li>@endif
+                @if(hasAccess('admin.year_data.view', 'admin'))<li class="dropdown-item"><a class="nav-link" href="{{ route('year-data.index') }}">Экспорт/Импорт года</a></li>@endif
+                @if(hasAccess('admin.clear_data.view', 'admin'))<li class="dropdown-item"><a class="nav-link" href="{{ route('clear-data.index') }}">Удалить данные</a></li>@endif
             </ul>
         </li>
 

+ 52 - 1
resources/views/users/edit.blade.php

@@ -18,6 +18,9 @@
                     <li class="nav-item" role="presentation">
                         <button class="nav-link" id="notifications-tab" data-bs-toggle="tab" data-bs-target="#notifications-pane" type="button" role="tab">Уведомления</button>
                     </li>
+                    <li class="nav-item" role="presentation">
+                        <button class="nav-link" id="permissions-tab" data-bs-toggle="tab" data-bs-target="#permissions-pane" type="button" role="tab">Права</button>
+                    </li>
                 </ul>
 
                 <div class="tab-content">
@@ -38,7 +41,12 @@
 
                         @include('partials.input', ['name' => 'password', 'type' => 'password', 'title' => 'Пароль'])
 
-                        @include('partials.select', ['name' => 'role', 'title' => 'Роль', 'options' => getRoles(), 'value' => $user->role ?? \App\Models\Role::MANAGER])
+                        @include('partials.select', [
+                            'name' => 'role_id',
+                            'title' => 'Роль',
+                            'options' => $roles ?? getRoleIdOptions(),
+                            'value' => old('role_id', $user->role_id ?? null),
+                        ])
 
                         @include('partials.input', ['name' => 'color', 'title' => 'Цвет', 'value' => $user->color ?? '#FFFFFF', 'type' => 'color'])
 
@@ -61,8 +69,47 @@
                             ],
                         ])
                     </div>
+
+                    <div class="tab-pane fade" id="permissions-pane" role="tabpanel" aria-labelledby="permissions-tab">
+                        @if(($user?->resolvedRoleSlug()) === \App\Models\Role::ADMIN)
+                            <div class="alert alert-warning py-2">
+                                Администратору всегда доступны все права. Пользовательские deny и снятие прав не применяются.
+                            </div>
+                        @else
+                            <div class="mb-2 text-muted small">
+                                Значение "По роли" оставляет право как в выбранной роли. Allow или deny здесь переопределяют роль только для этого пользователя.
+                            </div>
+                        @endif
+
+                        @include('admin.roles.partials.permissions-table', [
+                            'permissionGroups' => $permissionGroups ?? collect(),
+                            'permissionEffects' => $permissionEffects ?? [],
+                            'inputName' => 'permission_effects',
+                            'adminLocked' => ($user?->resolvedRoleSlug()) === \App\Models\Role::ADMIN,
+                            'inheritLabel' => 'По роли',
+                        ])
+                    </div>
                 </div>
 
+                @if($user)
+                    <div class="mt-3 border-top pt-3">
+                        <div class="row">
+                            <label class="col-form-label small col-md-4 text-md-end">Создать роль из прав</label>
+                            <div class="col-md-8">
+                                <div class="input-group input-group-sm mb-2">
+                                    <input type="text" name="name" class="form-control" form="create-role-from-user"
+                                           placeholder="Название новой роли">
+                                    <input type="text" name="slug" class="form-control" form="create-role-from-user"
+                                           placeholder="code">
+                                    <button type="submit" class="btn btn-outline-primary" form="create-role-from-user">
+                                        Создать роль
+                                    </button>
+                                </div>
+                            </div>
+                        </div>
+                    </div>
+                @endif
+
                 @if($user && !is_null($user->deleted_at))
                     <div class="col-12 text-center">
                         <div class="text-danger">ПОЛЬЗОВАТЕЛЬ УДАЛЁН!!!</div>
@@ -88,6 +135,10 @@
                 <form action="{{ route('user.impersonate', $user->id) }}" method="post" class="d-none" id="impersonate-user">
                     @csrf
                 </form>
+                <form action="{{ route('admin.roles.store-from-user', $user) }}" method="post" class="d-none" id="create-role-from-user">
+                    @csrf
+                    <input type="hidden" name="description" value="Создана из прав пользователя {{ $user->name }}">
+                </form>
             @endif
         </div>
     </div>

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

@@ -40,7 +40,7 @@
                         @include('partials.input', ['name' => 'name', 'title' => 'Имя', 'required' => true])
                         @include('partials.input', ['name' => 'phone', 'title' => 'Телефон'])
                         @include('partials.input', ['name' => 'password', 'type' => 'password', 'title' => 'Пароль', 'required' => true])
-                        @include('partials.select', ['name' => 'role', 'title' => 'Роль', 'options' => getRoles(), 'value' => $user->role ?? \App\Models\Role::MANAGER])
+                        @include('partials.select', ['name' => 'role_id', 'title' => 'Роль', 'options' => getRoleIdOptions(), 'value' => null])
 
                         @include('partials.submit', ['name' => 'Добавить', 'back_url' => route('user.index', session('gp_users', []))])
 

+ 13 - 1
routes/web.php

@@ -3,6 +3,7 @@
 use App\Http\Controllers\Admin\AdminAreaController;
 use App\Http\Controllers\Admin\AdminDistrictController;
 use App\Http\Controllers\Admin\AdminNotificationLogController;
+use App\Http\Controllers\Admin\RoleController;
 use App\Http\Controllers\Admin\AdminSettingsController;
 use App\Http\Controllers\ChatMessageController;
 use App\Http\Controllers\AreaController;
@@ -47,7 +48,7 @@ Route::get('/home', function () {
     return redirect()->route('order.index');
 })->name('home');
 
-Route::middleware('auth:web')->group(function () {
+Route::middleware(['auth:web', 'route.permission'])->group(function () {
     // set current year for display
     Route::get('set-year', function (Request $request) {
         if ($request->has('year')) {
@@ -80,6 +81,17 @@ Route::middleware('auth:web')->group(function () {
             Route::post('undelete/{user}', [UserController::class, 'undelete'])->name('user.undelete');
         });
 
+        Route::prefix('roles')->name('admin.roles.')->group(function () {
+            Route::get('', [RoleController::class, 'index'])->name('index');
+            Route::get('create', [RoleController::class, 'create'])->name('create');
+            Route::post('', [RoleController::class, 'store'])->name('store');
+            Route::post('from-user/{user}', [RoleController::class, 'storeFromUser'])->name('store-from-user');
+            Route::post('{role}/copy', [RoleController::class, 'copy'])->name('copy');
+            Route::get('{role}', [RoleController::class, 'edit'])->name('edit');
+            Route::put('{role}', [RoleController::class, 'update'])->name('update');
+            Route::delete('{role}', [RoleController::class, 'destroy'])->name('destroy');
+        });
+
         Route::prefix('import')->group(function (){
             Route::get('', [ImportController::class, 'index'])->name('import.index');
             Route::get('{import}', [ImportController::class, 'show'])->name('import.show');

+ 178 - 0
tests/Feature/AdminRoleControllerTest.php

@@ -0,0 +1,178 @@
+<?php
+
+namespace Tests\Feature;
+
+use App\Models\Permission;
+use App\Models\Role;
+use App\Models\User;
+use Database\Seeders\RbacSeeder;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Tests\TestCase;
+
+class AdminRoleControllerTest extends TestCase
+{
+    use RefreshDatabase;
+
+    private User $adminUser;
+
+    protected function setUp(): void
+    {
+        parent::setUp();
+
+        $this->adminUser = User::factory()->create(['role' => Role::ADMIN]);
+        $this->seed(RbacSeeder::class);
+        $this->adminUser->refresh();
+    }
+
+    public function test_admin_can_open_roles_index(): void
+    {
+        $this->actingAs($this->adminUser)
+            ->get(route('admin.roles.index'))
+            ->assertOk()
+            ->assertSee('Роли и права');
+    }
+
+    public function test_admin_role_update_keeps_all_permissions_allowed(): void
+    {
+        $adminRole = Role::query()->where('slug', Role::ADMIN)->firstOrFail();
+        $permission = Permission::query()->where('slug', 'catalog.delete')->firstOrFail();
+
+        $this->actingAs($this->adminUser)
+            ->put(route('admin.roles.update', $adminRole), [
+                'slug' => Role::ADMIN,
+                'name' => 'Админ',
+                'is_active' => '0',
+                'permission_effects' => [
+                    $permission->id => 'deny',
+                ],
+            ])
+            ->assertRedirect(route('admin.roles.edit', $adminRole));
+
+        $this->assertDatabaseHas('roles', [
+            'id' => $adminRole->id,
+            'is_active' => true,
+        ]);
+        $this->assertDatabaseHas('role_permissions', [
+            'role_id' => $adminRole->id,
+            'permission_id' => $permission->id,
+            'effect' => 'allow',
+        ]);
+    }
+
+    public function test_role_cannot_be_deleted_while_any_user_references_it(): void
+    {
+        $role = Role::query()->where('slug', Role::MANAGER)->firstOrFail();
+        User::factory()->create([
+            'role' => Role::MANAGER,
+            'role_id' => $role->id,
+            'deleted_at' => now(),
+        ]);
+
+        $this->actingAs($this->adminUser)
+            ->delete(route('admin.roles.destroy', $role))
+            ->assertRedirect(route('admin.roles.edit', $role));
+
+        $this->assertDatabaseHas('roles', ['id' => $role->id]);
+    }
+
+    public function test_role_can_be_created_from_user_effective_permissions(): void
+    {
+        $managerRole = Role::query()->where('slug', Role::MANAGER)->firstOrFail();
+        $user = User::factory()->create([
+            'role' => Role::MANAGER,
+            'role_id' => $managerRole->id,
+        ]);
+        $permission = Permission::query()->where('slug', 'catalog.view')->firstOrFail();
+
+        $user->permissions()->syncWithoutDetaching([
+            $permission->id => ['effect' => 'deny'],
+        ]);
+
+        $this->actingAs($this->adminUser)
+            ->post(route('admin.roles.store-from-user', $user), [
+                'name' => 'Тестовая роль',
+                'slug' => 'test_custom_role',
+            ])
+            ->assertRedirect();
+
+        $role = Role::query()->where('slug', 'test_custom_role')->firstOrFail();
+        $this->assertDatabaseHas('role_permissions', [
+            'role_id' => $role->id,
+            'permission_id' => $permission->id,
+            'effect' => 'deny',
+        ]);
+    }
+
+    public function test_user_permission_override_can_deny_role_permission(): void
+    {
+        $managerRole = Role::query()->where('slug', Role::MANAGER)->firstOrFail();
+        $user = User::factory()->create([
+            'role' => Role::MANAGER,
+            'role_id' => $managerRole->id,
+        ]);
+        $permission = Permission::query()->where('slug', 'catalog.view')->firstOrFail();
+
+        $this->actingAs($this->adminUser)
+            ->post(route('user.store'), [
+                'id' => $user->id,
+                'email' => $user->email,
+                'name' => $user->name,
+                'role_id' => $managerRole->id,
+                'permission_effects' => [
+                    $permission->id => 'deny',
+                ],
+            ])
+            ->assertRedirect(route('user.index'));
+
+        $this->assertDatabaseHas('user_permissions', [
+            'user_id' => $user->id,
+            'permission_id' => $permission->id,
+            'effect' => 'deny',
+        ]);
+        $this->assertFalse($user->refresh()->hasPermission('catalog.view'));
+    }
+
+    public function test_custom_role_can_pass_legacy_role_middleware_by_route_permission(): void
+    {
+        $permission = Permission::query()->where('slug', 'admin.roles')->firstOrFail();
+        $role = Role::query()->create([
+            'slug' => 'permissions_operator',
+            'name' => 'Оператор прав',
+            'is_system' => false,
+            'is_active' => true,
+        ]);
+        $role->permissions()->sync([
+            $permission->id => ['effect' => 'allow'],
+        ]);
+        $user = User::factory()->create([
+            'role' => $role->slug,
+            'role_id' => $role->id,
+        ]);
+
+        $this->actingAs($user)
+            ->get(route('admin.roles.index'))
+            ->assertOk()
+            ->assertSee('Роли и права');
+    }
+
+    public function test_role_can_be_copied_with_permissions(): void
+    {
+        $managerRole = Role::query()->where('slug', Role::MANAGER)->firstOrFail();
+        $permission = Permission::query()->where('slug', 'catalog.view')->firstOrFail();
+
+        $this->actingAs($this->adminUser)
+            ->post(route('admin.roles.copy', $managerRole))
+            ->assertRedirect();
+
+        $copy = Role::query()
+            ->where('slug', 'manager_copy')
+            ->firstOrFail();
+
+        $this->assertFalse($copy->is_system);
+        $this->assertDatabaseHas('role_permissions', [
+            'role_id' => $copy->id,
+            'permission_id' => $permission->id,
+            'effect' => 'allow',
+        ]);
+    }
+}

+ 98 - 0
tests/Feature/CatalogFieldAccessTest.php

@@ -0,0 +1,98 @@
+<?php
+
+namespace Tests\Feature;
+
+use App\Models\Permission;
+use App\Models\Product;
+use App\Models\Role;
+use App\Models\User;
+use Database\Seeders\RbacSeeder;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Tests\TestCase;
+
+class CatalogFieldAccessTest extends TestCase
+{
+    use RefreshDatabase;
+
+    protected function setUp(): void
+    {
+        parent::setUp();
+
+        $this->seed(RbacSeeder::class);
+    }
+
+    public function test_catalog_index_hides_denied_field_columns(): void
+    {
+        $role = $this->makeRoleWithPermissions([
+            'catalog.view' => 'allow',
+            'catalog.fields.article.view' => 'allow',
+            'catalog.fields.nomenclature_number.view' => 'allow',
+            'catalog.fields.name_tz.view' => 'allow',
+            'catalog.fields.product_price.view' => 'deny',
+            'catalog.fields.product_price.update' => 'deny',
+        ]);
+        $user = User::factory()->create(['role' => $role->slug, 'role_id' => $role->id]);
+        Product::factory()->create([
+            'article' => 'ACL-001',
+            'name_tz' => 'Открытое название',
+            'product_price' => 12345,
+        ]);
+
+        $this->actingAs($user)
+            ->get(route('catalog.index'))
+            ->assertOk()
+            ->assertSee('Открытое название')
+            ->assertDontSee('Цена товара')
+            ->assertDontSee('12 345', false);
+    }
+
+    public function test_catalog_update_strips_denied_fields_from_payload(): void
+    {
+        $role = $this->makeRoleWithPermissions([
+            'catalog.view' => 'allow',
+            'catalog.update' => 'allow',
+            'catalog.fields.name_tz.view' => 'allow',
+            'catalog.fields.name_tz.update' => 'allow',
+            'catalog.fields.product_price.view' => 'allow',
+            'catalog.fields.product_price.update' => 'deny',
+        ]);
+        $user = User::factory()->create(['role' => $role->slug, 'role_id' => $role->id]);
+        $product = Product::factory()->create([
+            'name_tz' => 'Старое название',
+            'product_price' => 100,
+        ]);
+
+        $this->actingAs($user)
+            ->post(route('catalog.update', $product), [
+                'name_tz' => 'Новое название',
+                'product_price' => 999999,
+            ])
+            ->assertRedirect();
+
+        $product->refresh();
+        $this->assertSame('Новое название', $product->name_tz);
+        $this->assertSame(100.0, $product->product_price);
+    }
+
+    private function makeRoleWithPermissions(array $effects): Role
+    {
+        $role = Role::query()->create([
+            'slug' => 'catalog_acl_' . uniqid(),
+            'name' => 'Catalog ACL',
+            'is_system' => false,
+            'is_active' => true,
+        ]);
+
+        $permissions = Permission::query()
+            ->whereIn('slug', array_keys($effects))
+            ->get();
+
+        $sync = [];
+        foreach ($permissions as $permission) {
+            $sync[$permission->id] = ['effect' => $effects[$permission->slug]];
+        }
+        $role->permissions()->sync($sync);
+
+        return $role;
+    }
+}

+ 106 - 0
tests/Feature/RoutePermissionMiddlewareTest.php

@@ -0,0 +1,106 @@
+<?php
+
+namespace Tests\Feature;
+
+use App\Models\Permission;
+use App\Models\Role;
+use App\Models\User;
+use Database\Seeders\RbacSeeder;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Illuminate\Http\UploadedFile;
+use Illuminate\Support\Facades\Bus;
+use Tests\TestCase;
+
+class RoutePermissionMiddlewareTest extends TestCase
+{
+    use RefreshDatabase;
+
+    protected function setUp(): void
+    {
+        parent::setUp();
+
+        $this->seed(RbacSeeder::class);
+    }
+
+    public function test_mapped_auth_route_requires_permission_for_rbac_user(): void
+    {
+        $role = Role::query()->create([
+            'slug' => 'no_orders',
+            'name' => 'No orders',
+            'is_system' => false,
+            'is_active' => true,
+        ]);
+        $user = User::factory()->create(['role' => $role->slug, 'role_id' => $role->id]);
+
+        $this->actingAs($user)
+            ->get(route('order.index'))
+            ->assertForbidden();
+    }
+
+    public function test_mapped_auth_route_allows_permission_for_rbac_user(): void
+    {
+        $permission = Permission::query()->where('slug', 'orders.view')->firstOrFail();
+        $role = Role::query()->create([
+            'slug' => 'orders_viewer',
+            'name' => 'Orders viewer',
+            'is_system' => false,
+            'is_active' => true,
+        ]);
+        $role->permissions()->sync([
+            $permission->id => ['effect' => 'allow'],
+        ]);
+        $user = User::factory()->create(['role' => $role->slug, 'role_id' => $role->id]);
+
+        $this->actingAs($user)
+            ->get(route('order.index'))
+            ->assertOk();
+    }
+
+    public function test_catalog_import_requires_catalog_import_permission(): void
+    {
+        Bus::fake();
+
+        $importPermission = Permission::query()->where('slug', 'import.create')->firstOrFail();
+        $role = Role::query()->create([
+            'slug' => 'generic_importer',
+            'name' => 'Generic importer',
+            'is_system' => false,
+            'is_active' => true,
+        ]);
+        $role->permissions()->sync([
+            $importPermission->id => ['effect' => 'allow'],
+        ]);
+        $user = User::factory()->create(['role' => $role->slug, 'role_id' => $role->id]);
+
+        $this->actingAs($user)
+            ->post(route('import.create'), [
+                'type' => 'catalog',
+                'import_file' => UploadedFile::fake()->create('catalog.xlsx', 10),
+            ])
+            ->assertForbidden();
+    }
+
+    public function test_catalog_import_allows_catalog_import_permission(): void
+    {
+        Bus::fake();
+
+        $permission = Permission::query()->where('slug', 'catalog.import')->firstOrFail();
+        $role = Role::query()->create([
+            'slug' => 'catalog_importer',
+            'name' => 'Catalog importer',
+            'is_system' => false,
+            'is_active' => true,
+        ]);
+        $role->permissions()->sync([
+            $permission->id => ['effect' => 'allow'],
+        ]);
+        $user = User::factory()->create(['role' => $role->slug, 'role_id' => $role->id]);
+
+        $this->actingAs($user)
+            ->post(route('import.create'), [
+                'type' => 'catalog',
+                'import_file' => UploadedFile::fake()->create('catalog.xlsx', 10),
+            ])
+            ->assertRedirect(route('import.index'));
+    }
+}

+ 22 - 0
tests/Feature/UserControllerTest.php

@@ -251,4 +251,26 @@ class UserControllerTest extends TestCase
         $response->assertRedirect(route('home'));
         $response->assertSessionHas('impersonator_id', $this->adminUser->id);
     }
+
+    public function test_impersonated_session_can_leave_only_to_admin(): void
+    {
+        $targetUser = User::factory()->create(['role' => Role::MANAGER]);
+
+        $response = $this->actingAs($targetUser)
+            ->withSession(['impersonator_id' => $this->adminUser->id])
+            ->post(route('user.impersonate.leave'));
+
+        $response->assertRedirect(route('user.index'));
+    }
+
+    public function test_impersonated_session_cannot_leave_to_non_admin(): void
+    {
+        $targetUser = User::factory()->create(['role' => Role::BRIGADIER]);
+
+        $response = $this->actingAs($targetUser)
+            ->withSession(['impersonator_id' => $this->managerUser->id])
+            ->post(route('user.impersonate.leave'));
+
+        $response->assertForbidden();
+    }
 }

+ 110 - 0
tests/Unit/Services/AccessServiceTest.php

@@ -0,0 +1,110 @@
+<?php
+
+namespace Tests\Unit\Services;
+
+use App\Models\Permission;
+use App\Models\Role;
+use App\Models\User;
+use App\Services\Access\AccessService;
+use Database\Seeders\RbacSeeder;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Tests\TestCase;
+
+class AccessServiceTest extends TestCase
+{
+    use RefreshDatabase;
+
+    public function test_rbac_seeder_backfills_user_role_id(): void
+    {
+        $user = User::factory()->create(['role' => Role::MANAGER]);
+
+        $this->seed(RbacSeeder::class);
+
+        $user->refresh();
+
+        $this->assertNotNull($user->role_id);
+        $this->assertSame(Role::MANAGER, $user->roleModel->slug);
+    }
+
+    public function test_direct_admin_has_all_permissions(): void
+    {
+        $admin = User::factory()->create(['role' => Role::ADMIN]);
+
+        $this->seed(RbacSeeder::class);
+        $admin->refresh();
+
+        $this->assertTrue(app(AccessService::class)->can($admin, 'catalog.delete'));
+        $this->assertTrue(app(AccessService::class)->can($admin, 'catalog.fields.product_price.update'));
+    }
+
+    public function test_manager_has_seeded_permissions_but_not_admin_only_permissions(): void
+    {
+        $manager = User::factory()->create(['role' => Role::MANAGER]);
+
+        $this->seed(RbacSeeder::class);
+        $manager->refresh();
+
+        $this->assertTrue(app(AccessService::class)->can($manager, 'catalog.view'));
+        $this->assertTrue(app(AccessService::class)->can($manager, 'catalog.fields.product_price.view'));
+        $this->assertFalse(app(AccessService::class)->can($manager, 'catalog.import'));
+        $this->assertFalse(app(AccessService::class)->can($manager, 'catalog.update'));
+    }
+
+    public function test_role_deny_overrides_user_allow(): void
+    {
+        $this->seed(RbacSeeder::class);
+
+        $permission = Permission::query()->where('slug', 'catalog.view')->firstOrFail();
+        $role = Role::query()->create([
+            'slug' => 'deny_catalog',
+            'name' => 'Deny catalog',
+            'is_system' => false,
+            'is_active' => true,
+        ]);
+        $role->permissions()->sync([
+            $permission->id => ['effect' => 'deny'],
+        ]);
+        $user = User::factory()->create(['role' => $role->slug, 'role_id' => $role->id]);
+        $user->permissions()->sync([
+            $permission->id => ['effect' => 'allow'],
+        ]);
+        app(AccessService::class)->bumpCacheVersion();
+
+        $this->assertFalse(app(AccessService::class)->can($user, 'catalog.view'));
+    }
+
+    public function test_exact_route_permission_lookup_supports_dotted_route_names(): void
+    {
+        $this->assertSame(
+            'catalog.search',
+            app(AccessService::class)->routePermission('product.search')
+        );
+    }
+
+    public function test_user_deny_overrides_role_allow(): void
+    {
+        $manager = User::factory()->create(['role' => Role::MANAGER]);
+
+        $this->seed(RbacSeeder::class);
+        $manager->refresh();
+
+        $permission = Permission::query()->where('slug', 'catalog.view')->firstOrFail();
+        $manager->permissions()->syncWithoutDetaching([
+            $permission->id => ['effect' => 'deny'],
+        ]);
+        app(AccessService::class)->bumpCacheVersion();
+
+        $this->assertFalse(app(AccessService::class)->can($manager, 'catalog.view'));
+    }
+
+    public function test_assistant_head_has_materialized_admin_permissions_without_runtime_inheritance(): void
+    {
+        $assistantHead = User::factory()->create(['role' => Role::ASSISTANT_HEAD]);
+
+        $this->seed(RbacSeeder::class);
+        $assistantHead->refresh();
+
+        $this->assertTrue($assistantHead->hasRole(Role::ADMIN));
+        $this->assertTrue(app(AccessService::class)->can($assistantHead, 'maf_orders.delete'));
+    }
+}