Selaa lähdekoodia

inline edit and upload

Alexander Musikhin 3 päivää sitten
vanhempi
sitoutus
3e86a4fadd

+ 36 - 2
app/Http/Controllers/ProductSKUController.php

@@ -9,6 +9,7 @@ use App\Models\File;
 use App\Models\MafView;
 use App\Models\ProductSKU;
 use App\Services\FileService;
+use Illuminate\Http\JsonResponse;
 use Illuminate\Http\Request;
 use Illuminate\Support\Facades\Storage;
 use Illuminate\Support\Str;
@@ -92,6 +93,37 @@ class ProductSKUController extends Controller
         return redirect($url);
     }
 
+    public function inlineUpdate(Request $request, ProductSKU $product_sku): JsonResponse
+    {
+        $validated = $request->validate([
+            'field' => ['required', 'string', 'in:rfid,factory_number,manufacture_date,statement_number,statement_date,upd_number'],
+            'value' => ['nullable', 'string', 'max:255'],
+        ]);
+
+        $field = $validated['field'];
+        abort_unless($request->user()?->canUpdateField('maf', $field), 403);
+
+        $rules = in_array($field, ['manufacture_date', 'statement_date'], true)
+            ? ['value' => ['nullable', 'date']]
+            : ['value' => ['nullable', 'string', 'max:255']];
+
+        $data = $request->validate($rules);
+        $value = $data['value'] ?? null;
+        if ($value === '') {
+            $value = null;
+        }
+
+        $product_sku->update([$field => $value]);
+
+        return response()->json([
+            'field' => $field,
+            'value' => $product_sku->{$field},
+            'human_value' => str_ends_with($field, '_date') && $product_sku->{$field}
+                ? DateHelper::getHumanDate($product_sku->{$field}, true)
+                : $product_sku->{$field},
+        ]);
+    }
+
     public function show(Request $request, int $product_sku)
     {
         $this->data['product_sku'] = ProductSKU::query()->withoutGlobalScope(\App\Models\Scopes\YearScope::class)->find($product_sku);
@@ -108,6 +140,8 @@ class ProductSKUController extends Controller
 
     public function uploadPassport(Request $request, ProductSKU $product_sku, FileService $fileService)
     {
+        abort_unless($request->user()?->hasPermission('maf.passports.upload'), 403);
+
         $data = $request->validate([
             'passport' => 'file',
         ]);
@@ -117,11 +151,11 @@ class ProductSKUController extends Controller
             $product_sku->update(['passport_id' => $f->id]);
         } catch (Throwable $e) {
             report($e);
-            return $this->redirectToProductSkuShow($request, $product_sku)
+            return redirect()->back()
                 ->with(['error' => 'Ошибка загрузки паспорта. Проверьте имя файла и повторите попытку.']);
         }
 
-        return $this->redirectToProductSkuShow($request, $product_sku)
+        return redirect()->back()
             ->with(['success' => 'Паспорт успешно загружен!']);
     }
 

+ 5 - 2
app/Http/Controllers/UserController.php

@@ -100,6 +100,7 @@ class UserController extends Controller
     {
         $validated = $request->validated();
         $settingsData = $this->extractNotificationSettings($request);
+        $hasPermissionEffects = array_key_exists('permission_effects', $validated);
         $permissionEffects = $validated['permission_effects'] ?? [];
 
         unset($validated['notification_settings']);
@@ -120,7 +121,7 @@ class UserController extends Controller
         }
 
         $user = null;
-        DB::transaction(function () use ($validated, $settingsData, $permissionEffects, &$user) {
+        DB::transaction(function () use ($validated, $settingsData, $hasPermissionEffects, $permissionEffects, &$user) {
             if(isset($validated['id'])) {
                 User::query()
                     ->where('id', $validated['id'])
@@ -136,7 +137,9 @@ class UserController extends Controller
                     $settingsData,
                 );
 
-                $this->syncUserPermissionOverrides($user, $permissionEffects);
+                if ($hasPermissionEffects) {
+                    $this->syncUserPermissionOverrides($user, $permissionEffects);
+                }
             }
         });
 

+ 8 - 0
config/access.php

@@ -111,6 +111,14 @@ return [
             'passports.upload' => 'Загрузка паспорта',
             'passports.delete' => 'Удаление паспорта',
         ],
+        'fields' => [
+            'rfid' => 'RFID',
+            'factory_number' => 'Номер фабрики',
+            'manufacture_date' => 'Дата производства',
+            'statement_number' => 'Номер ведомости',
+            'statement_date' => 'Дата ведомости',
+            'upd_number' => 'Номер УПД',
+        ],
     ],
     'maf_orders' => [
         'name' => 'Заказы МАФ',

+ 8 - 0
config/access_routes.php

@@ -145,6 +145,14 @@ return [
             'index' => 'maf.view',
             'show' => 'maf.view',
             'update' => 'maf.update',
+            'inline-update' => [
+                'maf.fields.rfid.update',
+                'maf.fields.factory_number.update',
+                'maf.fields.manufacture_date.update',
+                'maf.fields.statement_number.update',
+                'maf.fields.statement_date.update',
+                'maf.fields.upd_number.update',
+            ],
         ],
         'reclamations.' => [
             'index' => 'reclamations.view',

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

@@ -46,7 +46,7 @@
         'contractors.view',
         'contracts.view',
         'users.view',
-        'admin.roles',
+{{--        'admin.roles',--}}
         'admin.settings.view',
         'districts.view',
         'areas.view',
@@ -64,7 +64,7 @@
                 @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.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

+ 132 - 4
resources/views/orders/show.blade.php

@@ -286,9 +286,45 @@
                                                 {{ $p->maf_order?->order_number }}
                                             @endif
                                         </td>
-                                        <td>{{ $p->rfid }}</td>
-                                        <td>{{ $p->factory_number }}</td>
-                                        <td>{{ $p->manufacture_date }}</td>
+                                        <td>
+                                            @if(canUpdateField('maf', 'rfid'))
+                                                <input
+                                                    type="text"
+                                                    class="form-control form-control-sm inline-product-sku-field"
+                                                    data-url="{{ route('product_sku.inline-update', $p->id) }}"
+                                                    data-field="rfid"
+                                                    value="{{ $p->rfid }}"
+                                                >
+                                            @else
+                                                {{ $p->rfid }}
+                                            @endif
+                                        </td>
+                                        <td>
+                                            @if(canUpdateField('maf', 'factory_number'))
+                                                <input
+                                                    type="text"
+                                                    class="form-control form-control-sm inline-product-sku-field"
+                                                    data-url="{{ route('product_sku.inline-update', $p->id) }}"
+                                                    data-field="factory_number"
+                                                    value="{{ $p->factory_number }}"
+                                                >
+                                            @else
+                                                {{ $p->factory_number }}
+                                            @endif
+                                        </td>
+                                        <td>
+                                            @if(canUpdateField('maf', 'manufacture_date'))
+                                                <input
+                                                    type="date"
+                                                    class="form-control form-control-sm inline-product-sku-field"
+                                                    data-url="{{ route('product_sku.inline-update', $p->id) }}"
+                                                    data-field="manufacture_date"
+                                                    value="{{ $p->manufacture_date }}"
+                                                >
+                                            @else
+                                                {{ $p->manufacture_date }}
+                                            @endif
+                                        </td>
                                         <td class="text-center">
                                             @if($p->maf_order?->order_number)
                                                 <i class="bi bi-check-all text-success fw-bold"></i>
@@ -303,8 +339,19 @@
                                         <td class="text-center">
                                             @if($p->passport)
                                                 <i class="bi bi-check text-success fw-bold"></i>
+                                            @elseif(hasPermission('maf.passports.upload'))
+                                                <button
+                                                    type="button"
+                                                    class="btn btn-sm btn-outline-primary upload-maf-passport"
+                                                    data-bs-toggle="modal"
+                                                    data-bs-target="#uploadMafPassportModal"
+                                                    data-action="{{ route('product-sku.upload-passport', ['product_sku' => $p, 'nav' => $nav ?? null]) }}"
+                                                    data-maf="{{ e($p->product->article) }}"
+                                                >
+                                                    Загрузить
+                                                </button>
                                             @else
-                                                <i class="bi bi-x text-danger fw-bold"></i>
+                                                <span class="text-muted">-</span>
                                             @endif
                                         </td>
                                     </tr>
@@ -312,6 +359,29 @@
                                 </tbody>
                             </table>
                         </div>
+                        @if(hasPermission('maf.passports.upload'))
+                            <div class="modal fade" id="uploadMafPassportModal" tabindex="-1" aria-labelledby="uploadMafPassportModalLabel" aria-hidden="true">
+                                <div class="modal-dialog">
+                                    <div class="modal-content">
+                                        <div class="modal-header">
+                                            <h1 class="modal-title fs-5" id="uploadMafPassportModalLabel">Загрузить паспорт</h1>
+                                            <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
+                                        </div>
+                                        <form method="post" enctype="multipart/form-data" id="uploadMafPassportForm">
+                                            @csrf
+                                            <div class="modal-body">
+                                                <div class="small text-muted mb-2" id="uploadMafPassportName"></div>
+                                                @include('partials.input', ['title' => 'Файл паспорта', 'name' => 'passport', 'type' => 'file', 'required' => true])
+                                            </div>
+                                            <div class="modal-footer">
+                                                <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Закрыть</button>
+                                                <button type="submit" class="btn btn-primary">Загрузить</button>
+                                            </div>
+                                        </form>
+                                    </div>
+                                </div>
+                            </div>
+                        @endif
                         <div>
                             @if(hasRole('admin'))
                                 <a href="{{ route('order.get-maf', $order) }}"
@@ -703,5 +773,63 @@
                 }, 3000);
             });
         });
+
+        $('.inline-product-sku-field').on('focus', function () {
+            $(this).data('previous-value', $(this).val());
+        });
+
+        $('.inline-product-sku-field').on('blur', function () {
+            let $input = $(this);
+            let value = $input.val();
+            let previousValue = $input.data('previous-value');
+
+            if (value === previousValue) {
+                return;
+            }
+
+            $input.prop('disabled', true);
+
+            $.post(
+                $input.attr('data-url'),
+                {
+                    '_token': '{{ csrf_token() }}',
+                    field: $input.attr('data-field'),
+                    value: value
+                },
+                function (response) {
+                    $input.data('previous-value', response.value ?? '');
+                    $input.val(response.value ?? '');
+                    $('.alerts').append(
+                        '<div class="main-alert alert alert-success" role="alert">Поле МАФ обновлено!</div>'
+                    );
+                    setTimeout(function () {
+                        $('.main-alert').fadeTo(2000, 500).slideUp(500, function () {
+                            $(".main-alert").slideUp(500);
+                        })
+                    }, 3000);
+                }
+            ).fail(function (xhr) {
+                if (previousValue !== undefined) {
+                    $input.val(previousValue);
+                }
+
+                let errorText = xhr.responseJSON?.message || 'Не удалось обновить поле МАФ!';
+                $('.alerts').append(
+                    '<div class="main-alert alert alert-danger" role="alert">' + errorText + '</div>'
+                );
+                setTimeout(function () {
+                    $('.main-alert').fadeTo(2000, 500).slideUp(500, function () {
+                        $(".main-alert").slideUp(500);
+                    })
+                }, 3000);
+            }).always(function () {
+                $input.prop('disabled', false);
+            });
+        });
+
+        $('.upload-maf-passport').on('click', function () {
+            $('#uploadMafPassportForm').attr('action', $(this).attr('data-action'));
+            $('#uploadMafPassportName').text($(this).attr('data-maf') || '');
+        });
     </script>
 @endpush

+ 69 - 0
resources/views/partials/table.blade.php

@@ -146,6 +146,22 @@
                             @else
                                 {!! $string->$relation?->name; !!}
                             @endif
+                        @elseif($id === 'product_sku' && in_array($headerName, ['rfid', 'factory_number', 'manufacture_date', 'statement_number', 'statement_date', 'upd_number'], true))
+                            @if(canUpdateField('maf', $headerName))
+                                <input
+                                    type="{{ str_ends_with($headerName, '_date') ? 'date' : 'text' }}"
+                                    class="form-control form-control-sm inline-product-sku-field"
+                                    data-url="{{ route('product_sku.inline-update', $string->id) }}"
+                                    data-field="{{ $headerName }}"
+                                    value="{{ $string->$headerName }}"
+                                >
+                            @elseif(str_ends_with($headerName, '_date') && ($string->$headerName))
+                                {{ \App\Helpers\DateHelper::getHumanDate($string->$headerName, true) }}
+                            @else
+                                <p title="{{ $string->$headerName }}">
+                                    {{ \Illuminate\Support\Str::words($string->$headerName, config('app.words_in_table_cell_limit'), ' ...') }}
+                                </p>
+                            @endif
                         @elseif(str_ends_with($headerName, '_date') && ($string->$headerName))
                             {{ \App\Helpers\DateHelper::getHumanDate($string->$headerName, true) }}
                         @elseif(str_contains($headerName, 'image') && $string->$headerName)
@@ -678,6 +694,59 @@
             );
         });
 
+        $('.inline-product-sku-field').on('focus', function () {
+            $(this).data('previous-value', $(this).val());
+        });
+
+        $('.inline-product-sku-field').on('blur', function () {
+            let $input = $(this);
+            let value = $input.val();
+            let previousValue = $input.data('previous-value');
+
+            if (value === previousValue) {
+                return;
+            }
+
+            $input.prop('disabled', true);
+
+            $.post(
+                $input.attr('data-url'),
+                {
+                    '_token' : '{{ csrf_token() }}',
+                    field: $input.attr('data-field'),
+                    value: value
+                },
+                function (response) {
+                    $input.data('previous-value', response.value ?? '');
+                    $input.val(response.value ?? '');
+                    $('.alerts').append(
+                        '<div class="main-alert alert alert-success" role="alert">Поле МАФ обновлено!</div>'
+                    );
+                    setTimeout(function () {
+                        $('.main-alert').fadeTo(2000, 500).slideUp(500, function () {
+                            $(".main-alert").slideUp(500);
+                        })
+                    }, 3000);
+                }
+            ).fail(function (xhr) {
+                if (previousValue !== undefined) {
+                    $input.val(previousValue);
+                }
+
+                let errorText = xhr.responseJSON?.message || 'Не удалось обновить поле МАФ!';
+                $('.alerts').append(
+                    '<div class="main-alert alert alert-danger" role="alert">' + errorText + '</div>'
+                );
+                setTimeout(function () {
+                    $('.main-alert').fadeTo(2000, 500).slideUp(500, function () {
+                        $(".main-alert").slideUp(500);
+                    })
+                }, 3000);
+            }).always(function () {
+                $input.prop('disabled', false);
+            });
+        });
+
         function updateMainTableScrollHeight() {
             const tableScrollElement = document.querySelector('.js-main-table-scroll');
             if (!tableScrollElement) {

+ 44 - 44
resources/views/users/edit.blade.php

@@ -18,9 +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>
+{{--                    <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">
@@ -70,45 +70,45 @@
                         ])
                     </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 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)--}}
+{{--                    <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">
@@ -135,10 +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>
+{{--                <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>

+ 2 - 0
routes/web.php

@@ -212,6 +212,8 @@ Route::middleware(['auth:web', 'route.permission'])->group(function () {
 
         // Склад (МАФ)
         Route::get('product_sku', [ProductSKUController::class, 'index'])->name('product_sku.index');
+        Route::post('product_sku/{product_sku}/inline-update', [ProductSKUController::class, 'inlineUpdate'])
+            ->name('product_sku.inline-update');
         Route::get('product_sku/{product_sku}', [ProductSKUController::class, 'show'])->name('product_sku.show');
         Route::post('product_sku/update/{product_sku}', [ProductSKUController::class, 'update'])->name('product_sku.update');
 

+ 45 - 0
tests/Feature/OrderControllerTest.php

@@ -493,6 +493,51 @@ class OrderControllerTest extends TestCase
         $response->assertSee('Тверской район');
     }
 
+    public function test_order_details_render_inline_maf_fields_and_passport_upload_by_permissions(): void
+    {
+        $order = Order::factory()->create();
+        $product = Product::factory()->create(['article' => 'MAF-INLINE-001']);
+        $sku = ProductSKU::factory()->create([
+            'order_id' => $order->id,
+            'product_id' => $product->id,
+            'passport_id' => null,
+            'rfid' => 'RFID-ORDER-OLD',
+            'factory_number' => 'FN-ORDER-OLD',
+            'manufacture_date' => '2026-05-19',
+        ]);
+
+        $response = $this->actingAs($this->adminUser)
+            ->get(route('order.show', $order));
+
+        $response->assertOk();
+        $response->assertSee('inline-product-sku-field', false);
+        $response->assertSee('data-field="rfid"', false);
+        $response->assertSee('data-field="factory_number"', false);
+        $response->assertSee('data-field="manufacture_date"', false);
+        $response->assertSee(route('product_sku.inline-update', $sku->id), false);
+        $response->assertSee('upload-maf-passport', false);
+        $response->assertSee(route('product-sku.upload-passport', ['product_sku' => $sku]), false);
+    }
+
+    public function test_order_details_hide_inline_maf_controls_without_permissions(): void
+    {
+        $order = Order::factory()->create();
+        $product = Product::factory()->create(['article' => 'MAF-NO-INLINE-001']);
+        $sku = ProductSKU::factory()->create([
+            'order_id' => $order->id,
+            'product_id' => $product->id,
+            'passport_id' => null,
+            'rfid' => 'RFID-READONLY',
+        ]);
+
+        $response = $this->actingAs($this->managerUser)
+            ->get(route('order.show', $order));
+
+        $response->assertOk();
+        $response->assertDontSee(route('product_sku.inline-update', $sku->id), false);
+        $response->assertDontSee(route('product-sku.upload-passport', ['product_sku' => $sku]), false);
+    }
+
     public function test_brigadier_cannot_view_handed_over_order_details(): void
     {
         $order = Order::factory()->create([

+ 182 - 3
tests/Feature/ProductSKUControllerTest.php

@@ -4,12 +4,16 @@ namespace Tests\Feature;
 
 use App\Jobs\ExportMafJob;
 use App\Models\Order;
+use App\Models\Permission;
 use App\Models\Product;
 use App\Models\ProductSKU;
 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 Illuminate\Support\Facades\Storage;
 use Tests\TestCase;
 
 class ProductSKUControllerTest extends TestCase
@@ -21,14 +25,26 @@ class ProductSKUControllerTest extends TestCase
     private User $adminUser;
     private User $managerUser;
     private User $brigadierUser;
+    private User $assistantHeadUser;
 
     protected function setUp(): void
     {
         parent::setUp();
 
-        $this->adminUser = User::factory()->create(['role' => Role::ADMIN]);
-        $this->managerUser = User::factory()->create(['role' => Role::MANAGER]);
-        $this->brigadierUser = User::factory()->create(['role' => Role::BRIGADIER]);
+        $this->seed(RbacSeeder::class);
+
+        $this->adminUser = $this->createUserWithRole(Role::ADMIN);
+        $this->managerUser = $this->createUserWithRole(Role::MANAGER);
+        $this->brigadierUser = $this->createUserWithRole(Role::BRIGADIER);
+        $this->assistantHeadUser = $this->createUserWithRole(Role::ASSISTANT_HEAD);
+    }
+
+    private function createUserWithRole(string $roleSlug): User
+    {
+        return User::factory()->create([
+            'role' => $roleSlug,
+            'role_id' => Role::query()->where('slug', $roleSlug)->value('id'),
+        ]);
     }
 
     // ==================== Guest redirects ====================
@@ -328,6 +344,169 @@ class ProductSKUControllerTest extends TestCase
         ]));
     }
 
+    public function test_admin_can_inline_update_product_sku_field(): void
+    {
+        $sku = ProductSKU::factory()->create(['rfid' => 'OLD-RFID']);
+
+        $response = $this->actingAs($this->adminUser)
+            ->postJson(route('product_sku.inline-update', $sku), [
+                'field' => 'rfid',
+                'value' => 'NEW-RFID',
+            ]);
+
+        $response->assertOk()
+            ->assertJson([
+                'field' => 'rfid',
+                'value' => 'NEW-RFID',
+            ]);
+
+        $this->assertDatabaseHas('products_sku', [
+            'id' => $sku->id,
+            'rfid' => 'NEW-RFID',
+        ]);
+    }
+
+    public function test_assistant_head_can_inline_update_product_sku_date_field(): void
+    {
+        $sku = ProductSKU::factory()->create(['manufacture_date' => null]);
+
+        $response = $this->actingAs($this->assistantHeadUser)
+            ->postJson(route('product_sku.inline-update', $sku), [
+                'field' => 'manufacture_date',
+                'value' => '2026-05-19',
+            ]);
+
+        $response->assertOk()
+            ->assertJson([
+                'field' => 'manufacture_date',
+                'value' => '2026-05-19',
+            ]);
+
+        $this->assertDatabaseHas('products_sku', [
+            'id' => $sku->id,
+            'manufacture_date' => '2026-05-19',
+        ]);
+    }
+
+    public function test_user_with_maf_field_update_permission_can_inline_update_product_sku_field(): void
+    {
+        $role = Role::query()->create([
+            'slug' => 'maf_inline_editor',
+            'name' => 'Редактор МАФ в таблице',
+            'is_system' => false,
+            'is_active' => true,
+            'sort' => 100,
+        ]);
+        $permission = Permission::query()->where('slug', 'maf.fields.statement_number.update')->firstOrFail();
+        $role->permissions()->sync([
+            $permission->id => ['effect' => 'allow'],
+        ]);
+        $user = User::factory()->create([
+            'role' => $role->slug,
+            'role_id' => $role->id,
+        ]);
+        $sku = ProductSKU::factory()->create(['statement_number' => 'OLD-STAT']);
+
+        $response = $this->actingAs($user)
+            ->postJson(route('product_sku.inline-update', $sku), [
+                'field' => 'statement_number',
+                'value' => 'NEW-STAT',
+            ]);
+
+        $response->assertOk()
+            ->assertJson([
+                'field' => 'statement_number',
+                'value' => 'NEW-STAT',
+            ]);
+
+        $this->assertDatabaseHas('products_sku', [
+            'id' => $sku->id,
+            'statement_number' => 'NEW-STAT',
+        ]);
+    }
+
+    public function test_manager_cannot_inline_update_product_sku_field(): void
+    {
+        $sku = ProductSKU::factory()->create(['upd_number' => 'UPD-OLD']);
+
+        $response = $this->actingAs($this->managerUser)
+            ->postJson(route('product_sku.inline-update', $sku), [
+                'field' => 'upd_number',
+                'value' => 'UPD-NEW',
+            ]);
+
+        $response->assertForbidden();
+
+        $this->assertDatabaseHas('products_sku', [
+            'id' => $sku->id,
+            'upd_number' => 'UPD-OLD',
+        ]);
+    }
+
+    public function test_inline_update_rejects_unapproved_product_sku_field(): void
+    {
+        $sku = ProductSKU::factory()->create(['comment' => 'Old comment']);
+
+        $response = $this->actingAs($this->adminUser)
+            ->postJson(route('product_sku.inline-update', $sku), [
+                'field' => 'comment',
+                'value' => 'New comment',
+            ]);
+
+        $response->assertUnprocessable();
+
+        $this->assertDatabaseHas('products_sku', [
+            'id' => $sku->id,
+            'comment' => 'Old comment',
+        ]);
+    }
+
+    public function test_user_with_passport_upload_permission_can_upload_product_sku_passport(): void
+    {
+        Storage::fake('public');
+
+        $role = Role::query()->create([
+            'slug' => 'maf_passport_loader',
+            'name' => 'Загрузка паспортов МАФ',
+            'is_system' => false,
+            'is_active' => true,
+            'sort' => 100,
+        ]);
+        $permission = Permission::query()->where('slug', 'maf.passports.upload')->firstOrFail();
+        $role->permissions()->sync([
+            $permission->id => ['effect' => 'allow'],
+        ]);
+        $user = User::factory()->create([
+            'role' => $role->slug,
+            'role_id' => $role->id,
+        ]);
+        $sku = ProductSKU::factory()->create(['passport_id' => null]);
+
+        $response = $this->actingAs($user)
+            ->from(route('order.show', $sku->order))
+            ->post(route('product-sku.upload-passport', $sku), [
+                'passport' => UploadedFile::fake()->create('passport.pdf', 10, 'application/pdf'),
+            ]);
+
+        $response->assertRedirect(route('order.show', $sku->order));
+        $this->assertNotNull($sku->fresh()->passport_id);
+    }
+
+    public function test_user_without_passport_upload_permission_cannot_upload_product_sku_passport(): void
+    {
+        Storage::fake('public');
+
+        $sku = ProductSKU::factory()->create(['passport_id' => null]);
+
+        $response = $this->actingAs($this->brigadierUser)
+            ->post(route('product-sku.upload-passport', $sku), [
+                'passport' => UploadedFile::fake()->create('passport.pdf', 10, 'application/pdf'),
+            ]);
+
+        $response->assertForbidden();
+        $this->assertNull($sku->fresh()->passport_id);
+    }
+
     // ==================== Export ====================
 
     public function test_admin_can_export_mafs(): void