Bladeren bron

Enforce catalog field ACL

Alexander Musikhin 1 week geleden
bovenliggende
commit
1d3b92144c

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

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

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

@@ -24,6 +24,11 @@ class FieldAccessService
         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;

+ 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('import.create', '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>

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