Alexander Musikhin 1 неделя назад
Родитель
Сommit
fce62f5235
2 измененных файлов с 1101 добавлено и 181 удалено
  1. 154 0
      docs/flex_roles_access_inventory.md
  2. 947 181
      docs/flex_roles_plan.md

+ 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;
+- основные сценарии покрыты тестами.