This commit is contained in:
root 2026-01-14 07:26:49 +03:00
parent edb4df7e37
commit b14f293a45
6 changed files with 1782 additions and 7532 deletions

7468
bp.txt

File diff suppressed because one or more lines are too long

296
docs/ACCESS.md Normal file
View File

@ -0,0 +1,296 @@
# Справка по методам проверки прав доступа
## AccessService — доступ через сервис
AccessService подключается автоматически в BaseController как `$this->access`.
```php
// В контроллере:
if (!$this->access->can('create', 'clients')) {
return $this->forbiddenResponse('Нет прав для создания клиентов');
}
```
---
## Методы проверки ролей
### Проверка конкретной роли
```php
// Одной роли
$this->access->isRole('owner');
// Нескольких ролей
$this->access->isRole(['owner', 'admin']);
```
### Удобные методы для часто используемых проверок
```php
// Владелец организации
$this->access->isOwner();
// Администратор или владелец
$this->access->isAdmin();
// Менеджер, администратор или владелец
$this->access->isManagerOrHigher();
```
### Системные роли (суперадмин)
```php
// Суперадмин (доступ к панели суперадмина)
$this->access->isSuperadmin();
// Системный админ или суперадмин
$this->access->isSystemAdmin();
// Проверка произвольной системной роли
$this->access->isSystemRole('admin');
```
---
## Методы проверки прав на действия
### Универсальный метод can()
```php
// Проверка конкретного действия над ресурсом
$this->access->can('view', 'clients'); // Просмотр клиентов
$this->access->can('create', 'clients'); // Создание клиентов
$this->access->can('edit', 'clients'); // Редактирование клиентов
$this->access->can('delete', 'clients'); // Удаление своих клиентов
$this->access->can('delete_any', 'clients'); // Удаление любых клиентов
```
### Краткие методы для действий
```php
$this->access->canView('clients'); // Эквивалент can('view', 'clients')
$this->access->canCreate('clients'); // Эквивалент can('create', 'clients')
$this->access->canEdit('clients'); // Эквивалент can('edit', 'clients')
$this->access->canDelete('clients'); // Эквивалент can('delete', 'clients')
$this->access->canDelete('clients', true); // Эквивалент can('delete_any', 'clients')
```
### Права на специальные операции
```php
// Управление пользователями организации
$this->access->canManageUsers();
// Управление модулями (подписки)
$this->access->canManageModules();
// Просмотр финансовой информации
$this->access->canViewFinance();
// Удаление организации
$this->access->canDeleteOrganization();
// Передача прав владельца
$this->access->canTransferOwnership();
```
---
## Доступные ресурсы и действия
### Стандартные ресурсы модулей
| Ресурс | Описание | Доступные действия |
|------------|----------------|-------------------------|
| `clients` | Клиенты CRM | view, create, edit, delete, delete_any |
| `deals` | Сделки CRM | view, create, edit, delete, delete_any |
| `bookings` | Записи на приём | view, create, edit, delete, delete_any |
| `projects` | Проекты Proof | view, create, edit, delete, delete_any |
| `tasks` | Задачи | view, create, edit, delete, delete_any |
| `users` | Пользователи | view, create, edit, delete |
---
## Матрица прав по ролям
| Ресурс | Владелец | Администратор | Менеджер | Гость |
|-------------|----------|---------------|----------|-------|
| Клиенты | Полный | Полный | Полный | Просмотр |
| Сделки | Полный | Полный | Полный | Просмотр |
| Записи | Полный | Полный | Полный | Просмотр |
| Проекты | Полный | Полный | Полный | Просмотр |
| Задачи | Полный | Полный | Полный | Просмотр |
| Пользователи| Полный | Просмотр, создание, редактирование | Только просмотр | Просмотр |
| Модули | Полный | Управление | — | — |
| Финансы | Полный | Просмотр | — | — |
---
## Использование в Twig-шаблонах
Хелпер `access` автоматически доступен в шаблонах через TwigGlobalsExtension.
### Проверка ролей
```twig
{# Проверка роли пользователя #}
{% if access.isRole('owner') %}
<p>Вы владелец организации</p>
{% endif %}
{% if access.isRole(['owner', 'admin']) %}
<p>Вы администратор или владелец</p>
{% endif %}
{# Удобные проверки #}
{% if access.isOwner() %}
Кнопка "Удалить организацию"
{% endif %}
{% if access.isAdmin() %}
Кнопка "Управление пользователями"
{% endif %}
```
### Проверка действий
```twig
{# Кнопка создания (видима только если есть право create) #}
{% if access.canCreate('clients') %}
<a href="{{ url('/clients/new') }}" class="btn btn-primary">
Добавить клиента
</a>
{% endif %}
{# Кнопка редактирования #}
{% if access.canEdit('clients') %}
<a href="{{ url('/clients/edit/' ~ client.id) }}">Редактировать</a>
{% endif %}
{# Кнопка удаления #}
{% if access.canDelete('clients') %}
<a href="{{ url('/clients/delete/' ~ client.id) }}">Удалить</a>
{% endif %}
```
### Проверка специальных прав
```twig
{# Управление пользователями #}
{% if access.canManageUsers() %}
<a href="{{ url('/organizations/' ~ currentOrg.id ~ '/users') }}">
Управление пользователями
</a>
{% endif %}
{# Управление модулями #}
{% if access.canManageModules() %}
<a href="{{ url('/modules') }}">Управление подписками</a>
{% endif %}
{# Удаление организации (только владелец) #}
{% if access.canDeleteOrganization() %}
<button class="btn btn-danger" data-bs-toggle="modal" data-bs-target="#deleteOrgModal">
Удалить организацию
</button>
{% endif %}
```
---
## Примеры использования в контроллерах
### Базовый шаблон проверки
```php
public function index()
{
// Проверка права на просмотр
if (!$this->access->canView('clients')) {
return $this->forbiddenResponse('Нет прав для просмотра клиентов');
}
// ... логика метода
}
```
### Проверка нескольких условий
```php
public function delete($id)
{
// Право на удаление
if (!$this->access->canDelete('clients')) {
return $this->forbiddenResponse('Нет прав для удаления');
}
// Дополнительная проверка: только владелец может удалять
if (!$this->access->isOwner()) {
return $this->forbiddenResponse('Только владелец может удалять');
}
// ... логика удаления
}
```
### Условное выполнение в зависимости от роли
```php
public function update($id, $data)
{
// Менеджер может только редактировать свои записи
// Админ и владелец — любые записи
$canEdit = $this->access->isRole('manager')
? $this->isOwnerOfRecord($id) // своя запись?
: true; // любую запись
if (!$canEdit) {
return $this->forbiddenResponse('Можно редактировать только свои записи');
}
// ... обновление
}
```
---
## Важные примечания
1. **Методы возвращают boolean** — используйте в условиях `if`
2. **Проверка всегда идёт для текущей организации** из сессии (`active_org_id`)
3. **Для личного пространства** (`type = 'personal'`) метод `canManageUsers()` возвращает `false` — в личном пространстве нет других пользователей
4. **Системные роли** (`system_role`) проверяются отдельно от ролей организации:
- `isRole()` и `isOwner()`, `isAdmin()` — для организации
- `isSuperadmin()`, `isSystemAdmin()` — для всей системы
5. **Кэширование** — `AccessService` кэширует membership в рамках одного запроса, но не между запросами. При переключении организации кэш сбрасывается автоматически.
---
## Получение текстового названия роли
```php
// В контроллере
$roleLabel = $this->access->getRoleLabel('admin'); // "Администратор"
// В шаблоне
{{ access.getRoleLabel(currentMembership.role) }}
```
---
## Список всех ролей
```php
// Получение всех ролей с описаниями
$roles = \App\Services\AccessService::getAllRoles();
// [
// 'owner' => ['label' => 'Владелец', 'description' => 'Полный доступ', 'level' => 100],
// 'admin' => ['label' => 'Администратор', 'description' => 'Управление пользователями', 'level' => 75],
// ...
// ]
```

119
docs/ARCHITECTURE.md Normal file
View File

@ -0,0 +1,119 @@
# Архитектура системы
## 1. Организации и типы пространств
### 1.1. Концепция организации в системе
Центральным понятием архитектуры системы является организация (`organization`), которая представляет собой контейнер для всех бизнес-данных и определяет границы доступа между различными пользователями и командами. Все данные системы, за исключением глобальных настроек и системных конфигураций, принадлежат конкретной организации и изолированы на уровне базы данных. Каждая запись в таблицах организационного пространства содержит поле `organization_id`, которое связывает её с конкретной организацией и обеспечивает автоматическую фильтрацию данных при работе с моделями.
Организация в системе имеет несколько ключевых характеристик, определяющих её поведение и ограничения. К таким характеристикам относятся тип организации, статус подписки на модули, список пользователей с их ролями и дата создания. Информация об организации хранится в таблице `organizations` и доступна через соответствующую модель `OrganizationModel`. При аутентификации пользователя система определяет текущую организацию из сессии и использует её контекст для всех последующих операций с данными. Связь пользователя с организацией хранится в таблице `organization_users`, которая определяет роль пользователя в данной организации.
### 1.2. Типы организаций
Система поддерживает два основных типа организаций, каждый из которых имеет свои особенности и ограничения. Первый тип — это стандартная организация, предназначенная для компаний и команд, которым необходимо совместная работа нескольких пользователей. Стандартная организация может содержать неограниченное количество пользователей с различными ролями (Owner, Admin, Manager), имеет полный доступ ко всем функциям системы и может оформлять подписки на модули для всей команды. Этот тип организации является основным сценарием использования системы для бизнес-целей.
Второй тип — личная организация (personal organization), которая создаётся для индивидуальных пользователей, таких как физические лица, индивидуальные предприниматели или самозанятые. Личная организация имеет принципиальное отличие от стандартной: в неё невозможно приглашать других пользователей. Владелец личной организации является её единственным участником и автоматически получает роль Owner. Личные организации используют ту же самую архитектуру и механизмы, что и стандартные организации, но имеют специальный флаг `is_personal = true`, который блокирует функционал приглашения пользователей и управления составом команды. Этот подход обеспечивает единообразие кода и позволяет использовать общие компоненты для обоих типов организаций.
### 1.3. Мультитенантность и изоляция данных
Изоляция данных между организациями обеспечивается на нескольких уровнях архитектуры системы, что гарантирует безопасность и конфиденциальность данных каждой организации. На уровне базы данных каждая таблица организационного пространства содержит поле `organization_id`, которое является внешним ключом к таблице организаций. Это поле имеет ограничение внешнего ключа и обеспечивает ссылочную целостность данных. При создании новой записи система автоматически устанавливает `organization_id` из текущей сессии пользователя, что гарантирует принадлежность записи к конкретной организации и исключает возможность случайного или намеренного создания записей в другой организации.
На уровне приложения изоляция обеспечивается через trait `TenantScopedModel`, который автоматически добавляет условие `WHERE organization_id = session->get('organization_id')` ко всем запросам выборки. Это происходит прозрачно для разработчика — при использовании модели с этим trait не требуется явная фильтрация по организации. Trait также перехватывает методы создания, обновления и удаления, автоматически устанавливая `organization_id` для новых записей и предотвращая модификацию записей других организаций. Попытка обновить запись с `organization_id`, отличным от текущей сессии, приведёт к исключению `OrganizationMismatchException`. Важно понимать, что `TenantScopedModel` обеспечивает только фильтрацию данных — проверка прав доступа должна выполняться отдельно через `AccessService`.
## 2. Система ролей
### 2.1. Роли и их иерархия
Система реализует иерархическую модель ролей с чётким распределением полномочий между участниками организации. Роль **Owner** (Владелец) является высшей ролью в организации и принадлежит пользователю, который создал организацию. Владелец имеет полный доступ ко всем функциям системы без ограничений, включая управление подпиской организации, удаление организации, назначение и снятие ролей с других пользователей. Владелец не может быть удалён из организации и всегда сохраняет свои полномочия независимо от изменений в подписке. Система гарантирует, что в каждой организации есть как минимум один владелец, и эта роль не может быть передана или снята последним владельцем.
Роль **Admin** (Администратор) предоставляется ключевым сотрудникам для управления повседневными операциями организации. Администратор имеет доступ ко всем функциям модулей, на которые оформлена подписка, за исключением финансовых операций и управления подпиской. Администратор может управлять пользователями организации (приглашать, удалять, изменять роли), но не может удалить владельца или изменить его роль. Администратор также не может управлять биллингом организации. Роль Admin предназначена для делегирования административных полномочий без передачи прав на изменение структуры организации или финансовых обязательств.
Роль **Manager** (Менеджер) предоставляется сотрудникам, которые работают с клиентами и сделками. Менеджер имеет доступ к функциям в рамках своих прав, определённых матрицей разрешений. Менеджер может создавать и редактировать клиентов, сделки и задачи, но не может удалять критически важные данные или управлять пользователями организации. Точный набор прав менеджера зависит от настроек конкретного модуля и может быть настроен индивидуально. Роль Manager является базовой ролью для обычных пользователей организации, которые несут ответственность за выполнение рабочих задач, но не управляют командой или системными настройками.
### 2.2. Матрица разрешений
Матрица разрешений определяет, какие действия доступны пользователям с каждой ролью для различных ресурсов системы. Матрица хранится в конфигурации сервиса `AccessService` и представляет собой ассоциативный массив, где ключом является название ресурса (например, `clients`, `deals`, `invoices`), а значением — массив допустимых действий для каждой роли. Каждое действие описывается строкой (`view`, `create`, `edit`, `delete`), которая соответствует методу проверки в сервисе доступа. При проверке прав сервис сравнивает роль пользователя и запрашиваемое действие с матрицей разрешений.
Сервис `AccessService` предоставляет набор методов для проверки разрешений: `canView($resource)`, `canCreate($resource)`, `canEdit($resource)`, `canDelete($resource)` и `isOwner()`. Метод `canView` проверяет возможность просмотра ресурса, `canCreate` — создания новых записей, `canEdit` — редактирования существующих записей, `canDelete` — удаления записей. Метод `isOwner` проверяет, является ли текущий пользователь владельцем организации, и возвращает `true` для владельцев всех организаций. При отсутствии явного разрешения в матрице для данной роли и ресурса метод возвращает `false`, что приводит к генерации исключения `AccessDeniedException` или возврату ошибки в контроллере.
## 3. Роутинг и структура URL
### 3.1. Организация маршрутов
Система использует стандартный роутинг CodeIgniter 4 с дополнительной логикой фильтрации и проверки доступа. Базовые маршруты определены в файле `app/Config/Routes.php` и следуют RESTful-паттернам с учётом специфики мультитенантности. Все маршруты, связанные с организационным пространством, проходят через фильтры аутентификации и проверки организации. Фильтр `auth` проверяет наличие авторизованного пользователя в сессии и перенаправляет на страницу входа при отсутствии. Фильтр `tenant` проверяет корректность контекста организации и валидность подписок на модули.
Структура URL строится по принципу `/{module}/{controller}/{action}/{id?}`. Модули организованы в отдельные директории внутри `app/Modules/`, что позволяет добавлять новую функциональность без изменения основного ядра. Роуты модулей регистрируются через метод `$routes->group('modules', ['filter' => 'auth'], function ($routes) {...})`, который применяет фильтр аутентификации ко всем маршрутам внутри группы. Это обеспечивает централизованную проверку авторизации и упрощает управление доступом. Вложенные группы маршрутов используются для дополнительной логики, такой как проверка подписки на модуль или применение специфичных бизнес-правил.
### 3.2. Контекст организации в URL
Роутинг в системе построен таким образом, что URL не содержит явного указания организации — контекст определяется из сессии пользователя. Это обеспечивает безопасность и предотвращает попытки получить доступ к данным чужой организации через манипуляцию URL. При переключении между организациями система обновляет значения `organization_id` и `role` в сессии пользователя, сохраняя при этом `user_id` неизменным. Это позволяет пользователю иметь доступ к нескольким организациям и работать с данными каждой из них в изолированном контексте без необходимости повторной аутентификации.
Все контроллеры организационного пространства автоматически получают доступ к `organization_id` через `TenantScopedModel` и не требуют явного указания организации в URL. При попытке доступа к данным другой организации система автоматически вернёт ошибку 403 или перенаправит на страницу ошибки. Важно понимать, что при смене организации все загруженные в память данные становятся неактуальными и должны быть перезагружены в контексте новой организации. На практике это означает, что после переключения организации пользователю может потребоваться обновить страницу или система автоматически перенаправит его на соответствующую страницу.
## 4. Типичные паттерны разработки
### 4.1. Создание модели организационного пространства
Создание новой модели организационного пространства требует следования установленным паттернам для обеспечения корректной работы мультитенантности. Модель должна наследоваться от базового класса `CodeIgniter\Model` или `App\Models\BaseModel` и использовать trait `TenantScopedModel`. В классе модели определяются защищённые свойства `$table`, `$primaryKey`, `$allowedFields` и `$useTimestamps` в соответствии со структурой таблицы базы данных. Trait `TenantScopedModel` автоматически обрабатывает поля `organization_id` и временные метки (`created_at`, `updated_at`, `deleted_at` для мягкого удаления).
При реализации модели необходимо добавить поля организации и временных меток в массив `$allowedFields`, чтобы они автоматически заполнялись при создании и обновлении записей. Trait перехватывает метод `insert()` и автоматически устанавливает значение `organization_id` из сессии. Методы `update()` и `delete()` автоматически добавляют условие `WHERE organization_id = session->get('organization_id')` для предотвращения модификации записей других организаций. При использовании метода `withDeleted()` для получения мягко удалённых записей фильтрация по организации также применяется. После создания модель готова к использованию в контроллерах с автоматической фильтрацией по организации.
### 4.2. Проверка прав доступа в контроллерах
Проверка прав доступа в контроллерах осуществляется через сервис `AccessService`, который предоставляет набор методов для проверки разрешений. Типичный паттерн проверки прав в контроллере выглядит следующим образом: в начале метода контроллера вызывается проверка соответствующего разрешения, и если проверка не проходит, генерируется исключение или возвращается ошибка. Например, метод создания нового клиента начинается с проверки `if (!$this->access->canCreate('clients')) throw new \Exception('Access denied');`. Это обеспечивает централизованный контроль доступа и предотвращает несанкционированные действия.
Все проверки прав доступа логируются через `EventManager` для аудита и отладки. При необходимости можно добавить дополнительные условия в проверку, например, проверить, что пользователь является владельцем конкретной записи, а не просто имеет право на редактирование этого типа ресурсов. Для этого используется метод `isOwner()` в сочетании с дополнительной проверкой идентификатора записи. Документация по доступным методам и ресурсам находится в файле `ACCESS_HELP.md`, а подробное описание матрицы разрешений — в исходном коде `AccessService`.
### 4.3. Проверка прав доступа в шаблонах Twig
Проверка прав доступа в шаблонах Twig осуществляется через глобальные переменные, доступные во всех шаблонах. Объект `access` автоматически передаётся в каждый шаблон и содержит все методы проверки разрешений. Это позволяет использовать проверку прав непосредственно в разметке шаблона для скрытия или отображения элементов интерфейса. Синтаксис проверки в Twig аналогичен синтаксису в контроллерах: `{% if access.canEdit('clients') %} ... {% endif %}`.
Практическое применение проверки прав в шаблонах включает скрытие кнопок редактирования и удаления для пользователей без соответствующих прав, отображение информационных сообщений о недоступных функциях, адаптацию интерфейса под роли пользователей. Например, кнопка удаления клиента оборачивается в условие `{% if access.canDelete('clients') %}<button>...</button>{% endif %}`, и пользователи без права удаления не увидят эту кнопку. Это обеспечивает интуитивно понятный интерфейс и предотвращает попытки выполнить недоступные действия. Комбинирование проверок позволяет создавать сложные условия отображения элементов в зависимости от нескольких факторов.
## 5. Система событий
### 5.1. Типы событий
Система событий позволяет расширять функциональность модулей без модификации их исходного кода. События делятся на два типа: системные события (`systemOn`) и модульные события (`moduleOn`). Системные события срабатывают глобально для всей системы и используются для сквозной функциональности, такой как логирование, аудит, интеграции. Модульные события срабатывают только при активной подписке на соответствующий модуль, что позволяет создавать расширения для модулей, которые могут быть включены или выключены в зависимости от подписки организации.
Именование событий следует паттерну `{resource}.{action}`, например, `clients.create`, `deals.update`, `invoices.delete`. При срабатывании события обработчики получают контекст, который обычно включает модель, действие и связанные данные. Контекст события позволяет обработчикам получить доступ к изменённым данным, выполнить дополнительные операции или отменить действие (для событий, поддерживающих отмену). Система событий поддерживает приоритеты обработчиков, что позволяет контролировать порядок выполнения нескольких обработчиков одного события.
### 5.2. Подписка на события
Подписка на события осуществляется через сервис `EventManager` методом `on($eventName, $callback, $priority)`. Callback-функция получает контекст события и может выполнять произвольные операции. Пример подписки на событие создания клиента: `$events->on('clients.create', function ($context) { ... });`. События можно использовать для автоматической отправки уведомлений, синхронизации данных с внешними системами, ведения аудита действий пользователей. Подписка на события обычно выполняется в сервис-провайдерах или при инициализации модулей.
Модульные события требуют дополнительной проверки подписки на модуль. При использовании метода `moduleOn($moduleName, $eventName, $callback)` система автоматически проверяет, активна ли подписка организации на указанный модуль, и регистрирует обработчик только при наличии подписки. Это позволяет создавать расширения для модулей, которые устанавливаются отдельно и активируются только при наличии лицензии. Документация по событиям и их использованию находится в файле `EVENT_MANAGER_HELP.md`.
## 6. Компонент динамических таблиц
### 6.1. Обзор компонента
Компонент динамических таблиц (`DataTable`) позволяет создавать интерактивные таблицы данных с сортировкой, фильтрацией, пагинацией и действиями. Компонент состоит из двух частей: серверной (PHP-контроллер, возвращающий данные в стандартизированном формате) и клиентской (JavaScript-класс, выполняющий AJAX-запросы и отображение таблицы). Серверная часть должна возвращать данные в формате JSON с ключами `data` (массив записей), `recordsTotal` (общее количество записей), `recordsFiltered` (количество записей после фильтрации) и `draw` (счётчик запросов для защиты от CSRF).
Клиентская часть компонента инициализируется для каждой таблицы на странице и автоматически загружает данные при отображении. Компонент поддерживает серверную пагинацию, сортировку по колонкам, текстовый поиск и фильтрацию по дополнительным параметрам. Для каждой строки таблицы могут отображаться кнопки действий (редактирование, удаление, просмотр), которые настраиваются через конфигурацию компонента. Компонент также поддерживает массовые действия с использованием чекбоксов в первой колонке таблицы.
### 6.2. Использование в контроллере и шаблоне
Для использования компонента в контроллере необходимо подготовить данные в стандартизированном формате JSON и передать их в шаблон. Контроллер должен получить параметры запроса (номер страницы, количество записей, параметры сортировки, поисковый запрос), выполнить запрос к базе данных с применением фильтров, и вернуть результат в требуемом формате. Параметры `page` и `perPage` используются для расчёта смещения (`offset`) при пагинации, параметры `orderBy` и `orderDir` — для сортировки, параметр `search` — для текстового поиска.
В шаблоне Twig компонент подключается через макросы из файла `table.twig`, который находится в директории макросов модуля. Для отображения таблицы вызывается макрос `table.render()` с передачей конфигурационного объекта, содержащего параметры отображения и URL для загрузки данных. Конфигурация включает определение колонок (заголовок, поле данных, формат отображения), наличие чекбоксов для массовых действий, кнопки действий для каждой строки. Документация по параметрам и использованию компонента находится в файле `DATATABLE_HELP.md`.
## 7. Развитие системы
### 7.1. Создание нового модуля
Создание нового модуля требует следования установленной структуре директорий и файловой организации. Модуль размещается в директории `app/Modules/{ModuleName}/` и содержит поддиректории для контроллеров (`Controllers/`), моделей (`Models/`), представлений (`Views/`) и ресурсов (`Assets/`). Каждый модуль должен иметь файл конфигурации подписки `Modules/{ModuleName}/Config/Subscription.php`, который определяет название модуля, описание, стоимость и зависимости от других модулей. Файл конфигурации также определяет события, которые генерирует модуль, и может содержать настройки по умолчанию.
После создания структуры модуля необходимо зарегистрировать его роуты в файле `app/Config/Routes.php` внутри группы модулей с применением фильтра аутентификации. Контроллеры модуля должны наследоваться от `BaseController` для обеспечения доступа к общим сервисам и функциональности. Модели организационного пространства должны использовать trait `TenantScopedModel`. После создания модуль автоматически появится в списке доступных модулей в админке, и его можно будет активировать через систему подписок. При разработке модуля рекомендуется использовать существующие модули (Clients, Deals) в качестве образца структуры и паттернов.
### 7.2. Интеграция внешних сервисов
Интеграция внешнего сервиса осуществляется через создание адаптера в директории `app/Services/External/` и подписку на соответствующие события системы. Адаптер инкапсулирует логику взаимодействия с внешним API, включая аутентификацию, обработку ошибок и преобразование данных. После создания адаптера необходимо зарегистрировать его как сервис в `app/Config/Services.php` для удобного доступа из других частей приложения. Адаптеры должны быть независимыми от контекста организации и обрабатывать аутентификацию внешних сервисов через конфигурацию системы.
Связывание внешнего сервиса с системой осуществляется через подписку на события. Например, для отправки SMS при создании заказа подписываемся на событие `orders.create` и вызываем метод адаптера отправки SMS. Это позволяет добавлять и удалять интеграции без изменения бизнес-логики модулей. Для сложных интеграций рекомендуется создавать отдельные классы-коннекторы с методами для каждого типа операции и использовать их из обработчиков событий. Все обращения к внешним сервисам должны быть обёрнуты в try-catch блоки для корректной обработки ошибок и логирования сбоев интеграции.
### 7.3. Система подписок модулей
Система подписок управляет доступом к функциональности модулей в зависимости от оплаченного плана организации. Каждый модуль имеет файл конфигурации, который определяет его стоимость, название, описание и требуемые разрешения. При активации модуля система проверяет, что у организации достаточно средств или соответствующий план подписки, и создаёт запись в таблице подписок. Статус подписки хранится в базе данных и проверяется при каждом доступе к функциональности модуля. Информация о текущих подписках доступна через `ModuleSubscriptionService`, который предоставляет методы для проверки статуса модуля, получения списка активных подписок и управления ими.
Доступ к модульным событиям и функциональности автоматически ограничивается статусом подписки. При неактивной подписке попытка доступа к модулю возвращает ошибку 403 или перенаправляет на страницу оплаты. Система также обеспечивает автоматическое списание средств при истечении срока подписки и уведомление пользователей о необходимости продления. Для личных организаций доступен упрощённый набор модулей, оптимизированный для индивидуального использования, без функционала командной работы.

756
docs/DATATABLE.md Normal file
View File

@ -0,0 +1,756 @@
# Компонент динамических таблиц DataTable
## Общее описание
Компонент DataTable представляет собой универсальную систему для отображения интерактивных таблиц с поддержкой AJAX-загрузки данных, сортировки по столбцам, поиска и пагинации. Система построена на трёх уровнях: серверная часть (контроллер с методом `prepareTableData`), клиентская часть (JavaScript-модуль DataTable) и уровень представления (компоненты Twig).
Архитектура компонента обеспечивает бесшовную работу как при серверном рендеринге (первичная загрузка страницы), так и при AJAX-обновлениях (фильтрация, сортировка, пагинация). При серверном рендеринге таблица отображается сразу с данными, при этом JavaScript автоматически определяет наличие данных и пропускает избыточный AJAX-запрос. При любых действиях пользователя (сортировка, фильтрация, переход по страницам) данные подгружаются через AJAX, а клиентский модуль обновляет только tbody и tfoot, сохраняя заголовок таблицы неизменным.
---
## Структура компонентов
### Файловая структура
Компонент таблицы состоит из нескольких файлов, организованных по функциональному признаку. JavaScript-модуль расположен в `public/assets/js/modules/DataTable.js` и отвечает за все интерактивные взаимодействия на клиенте. Стили находятся в `public/assets/css/modules/data-table.css` и обеспечивают визуальное оформление элементов управления таблицей. Шаблоны Twig размещены в директории `app/Views/components/table/` и включают основной компонент таблицы, заголовок, пагинацию и макросы для рендеринга действий.
Основные файлы компонента:
- `table.twig` — универсальный компонент таблицы, включающий заголовок, тело и футер с пагинацией
- `table_header.twig` — переиспользуемый заголовок с поддержкой сортировки и поиска
- `pagination.twig` — компонент пагинации с навигацией по страницам
- `ajax_table.twig` — упрощённый tbody для AJAX-ответов без заголовка
- `macros.twig` — Twig-макросы для рендеринга кнопок действий
### Интеграция с BaseController
Класс BaseController предоставляет готовую инфраструктуру для работы с таблицами через методы `getTableConfig()`, `prepareTableData()`, `renderTable()` и `table()`. Метод `getTableConfig()` возвращает конфигурацию таблицы, определяющую модель данных, колонки, правила поиска и сортировки, а также действия для каждой строки. Метод `prepareTableData()` выполняет всю логику обработки параметров запроса (пагинация, сортировка, фильтрация), формирует данные и возвращает их в структурированном виде для передачи в шаблон. Метод `renderTable()` принимает конфигурацию и возвращает HTML-код таблицы. Метод `table()` является HTTP-обработчиком для AJAX-запросов, который возвращает только tbody и tfoot без заголовка.
---
## Подключение в контроллере
### Конфигурация таблицы
Каждый контроллер модуля должен определить конфигурацию таблицы через метод `getTableConfig()`. Конфигурация представляет собой ассоциативный массив с обязательными и опциональными параметрами. Обязательными параметрами являются `model` (экземпляр модели для выборки данных) и `columns` (описание колонок таблицы). Опциональные параметры позволяют настроить поведение поиска, сортировки, действий и отображения пустого состояния.
```php
class Clients extends BaseController
{
protected ClientModel $clientModel;
public function __construct()
{
$this->clientModel = new ClientModel();
}
protected function getTableConfig(): array
{
return [
'id' => 'clients-table',
'url' => '/clients/table',
'model' => $this->clientModel,
'columns' => [
'name' => ['label' => 'Имя / Название', 'width' => '40%'],
'email' => ['label' => 'Email', 'width' => '25%'],
'phone' => ['label' => 'Телефон', 'width' => '20%'],
'created_at' => ['label' => 'Создан', 'width' => '15%'],
],
'searchable' => ['name', 'email', 'phone'],
'sortable' => ['name', 'email', 'phone', 'created_at'],
'defaultSort' => 'name',
'order' => 'asc',
'actions' => ['label' => 'Действия', 'width' => '15%'],
'actionsConfig' => [
[
'label' => 'Редактировать',
'url' => '/clients/edit/{id}',
'icon' => 'fa-solid fa-pen',
'class' => 'btn-outline-primary',
'type' => 'edit',
],
[
'label' => 'Удалить',
'url' => '/clients/delete/{id}',
'icon' => 'fa-solid fa-trash',
'class' => 'btn-outline-danger',
'type' => 'delete',
'confirm' => 'Вы уверены, что хотите удалить этого клиента?',
],
],
'emptyMessage' => 'Клиентов пока нет',
'emptyIcon' => 'fa-solid fa-users',
'emptyActionUrl' => base_url('/clients/new'),
'emptyActionLabel' => 'Добавить клиента',
'emptyActionIcon' => 'fa-solid fa-plus',
'can_edit' => $this->access->canEdit('clients'),
'can_delete' => $this->access->canDelete('clients'),
];
}
}
```
### Параметры конфигурации
Параметр `id` задаёт уникальный идентификатор контейнера таблицы и используется для инициализации JavaScript-модуля. Параметр `url` определяет endpoint для AJAX-загрузки данных. Параметр `model` указывает экземпляр модели CodeIgniter, которая используется для запроса данных. Модель автоматически фильтруется по организации через трейт `TenantScopedModel` при его наличии.
Параметр `columns` описывает структуру колонок таблицы. Ключ массива соответствует имени поля в данных, а значение — ассоциативному массиву с параметрами отображения. Параметр `label` задаёт заголовок колонки, параметр `width` — ширину колонки в процентах или пикселях. Опционально можно указать `placeholder` для поля поиска, `searchTitle` для tooltip-подсказки и `align` для CSS-класса выравнивания содержимого.
Параметр `searchable` определяет массив имён колонок, по которым разрешён поиск. Эти колонки получат иконку поиска в заголовке. Параметр `sortable` определяет массив имён колонок, по которым разрешена сортировка. При клике по заголовку сортируемой колонки таблица пересортируется по этому полю. Параметры `defaultSort` и `order` задают поле и направление сортировки по умолчанию.
### Методы контроллера для таблицы
Основной метод для отображения страницы с таблицей выглядит следующим образом:
```php
public function index()
{
// Проверка прав доступа
if (!$this->access->canView('clients')) {
return $this->forbiddenResponse('Нет прав для просмотра клиентов');
}
$config = $this->getTableConfig();
return $this->renderTwig('@Clients/index', [
'title' => 'Клиенты',
'tableHtml' => $this->renderTable($config),
'can_create' => $this->access->canCreate('clients'),
'can_edit' => $this->access->canEdit('clients'),
'can_delete' => $this->access->canDelete('clients'),
]);
}
```
Метод для AJAX-загрузки данных таблицы использует встроенную логику BaseController:
```php
public function table(?array $config = null, ?string $pageUrl = null)
{
// Проверка прав доступа
if (!$this->access->canView('clients')) {
return $this->forbiddenResponse('Нет прав для просмотра клиентов');
}
return parent::table($config, '/clients');
}
```
Метод `table()` автоматически определяет тип запроса (обычный или AJAX) и возвращает либо полную таблицу, либо только tbody и tfoot соответственно. Для определения типа запроса используется заголовок `X-Requested-With` или параметр `format=partial`.
---
## Подключение в шаблоне
### Базовое подключение
Для подключения таблицы в шаблоне Twig используется компонент `table.twig`. Компонент принимает данные из метода `renderTable()` контроллера, который возвращает полностью сформированный HTML:
```twig
{# app/Modules/Clients/Views/index.twig #}
{% extends 'layouts/base.twig' %}
{% block title %}Клиенты{% endblock %}
{% block content %}
<div class="page-header d-flex justify-content-between align-items-center mb-4">
<h1 class="page-title">Клиенты</h1>
{% if can_create %}
<a href="{{ url('/clients/new') }}" class="btn btn-primary">
<i class="fa-solid fa-plus me-2"></i>Добавить клиента
</a>
{% endif %}
</div>
<div class="card">
<div class="card-body p-0">
{{ tableHtml|raw }}
</div>
</div>
{% endblock %}
{% block scripts %}
{{ parent() }}
<script src="{{ url('/assets/js/modules/DataTable.js') }}"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
new DataTable('clients-table', {
url: '/clients/table',
perPage: 10,
debounceTime: 300,
preserveSearchOnSort: true
});
});
</script>
{% endblock %}
```
### Прямое использование компонента
При необходимости таблицу можно подключить напрямую через `include`, передав все параметры вручную:
```twig
{% from '@components/table/macros.twig' import render_actions %}
<div class="table-responsive">
{{ include('@components/table/table.twig', {
id: 'my-table',
url: '/my-module/table',
perPage: 25,
sort: sort|default(''),
order: order|default('asc'),
filters: filters|default({}),
items: items,
pagerDetails: pagerDetails,
columns: {
name: { label: 'Название', width: '40%' },
email: { label: 'Email', width: '30%' },
status: { label: 'Статус', width: '20%' },
},
actions: { label: 'Действия', width: '10%' },
actionsConfig: [
{ label: 'Ред.', url: '/edit/{id}', icon: 'fa-solid fa-pen', class: 'btn-outline-primary' },
],
can_edit: can_edit|default(true),
can_delete: can_delete|default(true),
emptyMessage: 'Записей не найдено',
emptyIcon: 'fa-solid fa-inbox',
emptyActionUrl: url('/create'),
emptyActionLabel: 'Создать',
emptyActionIcon: 'fa-solid fa-plus',
onRowClick: 'openClientDetails',
tableClass: 'table-sm',
}) }}
</div>
```
### Поддержка render_cell и render_actions
В шаблонах Twig доступны глобальные функции для рендеринга ячеек и действий. Функция `render_cell()` автоматически обрабатывает значение ячейки в зависимости от типа данных:
```twig
{# Ячейка с автоматическим форматированием #}
<td>{{ render_cell(item, 'name')|raw }}</td>
<td>{{ render_cell(item, 'email')|raw }}</td>
<td>{{ render_cell(item, 'price')|raw }}</td>
<td>{{ render_cell(item, 'created_at')|raw }}</td>
{# Ячейка с кастомным классом #}
{{ render_cell(item, 'status', { class: 'badge bg-success' })|raw }}
```
Функция `render_actions()` рендерит кнопки действий для строки таблицы:
```twig
{% set actions = [
{ label: 'Ред.', url: '/edit/' ~ item.id, icon: 'fa-solid fa-pen', class: 'btn-outline-primary' },
{ label: 'Удалить', url: '/delete/' ~ item.id, icon: 'fa-solid fa-trash', class: 'btn-outline-danger' },
] %}
{{ render_actions(actions)|raw }}
```
---
## Конфигурация колонок
### Параметры колонки
Каждая колонка описывается массивом с возможными параметрами. Обязательным параметром является только `label`, остальные опциональны:
```php
'columns' => [
'name' => [
'label' => 'Название',
'width' => '40%',
'placeholder' => 'Поиск по названию',
'searchTitle' => 'Нажмите для поиска',
'align' => 'text-start',
],
'price' => [
'label' => 'Цена',
'width' => '15%',
'align' => 'text-end',
],
'status' => [
'label' => 'Статус',
'width' => '15%',
],
]
```
Параметр `width` задаёт ширину колонки и может быть указан в процентах или пикселях. Рекомендуется использовать проценты для адаптивности или комбинировать фиксированные и относительные значения. Сумма ширин всех колонок обычно должна составлять 100% с учётом колонки действий.
### Поля searchable и sortable
Массив `searchable` определяет поля, по которым разрешён поиск. При указании поля в этом массиве в заголовке колонки появится иконка поиска, при клике на которую отобразится поле ввода:
```php
'searchable' => ['name', 'email', 'phone', 'company'],
```
Массив `sortable` определяет поля, по которым разрешена сортировка. При клике по заголовку сортируемой колонки таблица пересортируется по этому полю, при повторном клике направление сортировки меняется на противоположное:
```php
'sortable' => ['name', 'email', 'phone', 'created_at', 'price'],
```
Важно: имена полей в `searchable` и `sortable` должны соответствовать ключам массива `columns` и именам полей в базе данных.
---
## Конфигурация действий
### Структура actionsConfig
Параметр `actionsConfig` определяет кнопки действий для каждой строки таблицы. Каждое действие описывается массивом с параметрами:
```php
'actionsConfig' => [
[
'label' => '',
'url' => '/clients/edit/{id}',
'icon' => 'fa-solid fa-pen',
'class' => 'btn-outline-primary',
'title' => 'Редактировать',
'type' => 'edit',
'confirm' => null,
],
[
'label' => '',
'url' => '/clients/delete/{id}',
'icon' => 'fa-solid fa-trash',
'class' => 'btn-outline-danger',
'title' => 'Удалить',
'type' => 'delete',
'confirm' => 'Вы уверены?',
],
],
```
Параметр `url` поддерживает подстановку `{id}` для автоматической замены на идентификатор записи. Параметр `type` используется для фильтрации действий по правам доступа: действия с `type === 'edit'` показываются только при `can_edit === true`, действия с `type === 'delete'` — только при `can_delete === true`. Параметр `confirm` добавляет подтверждение действия через стандартный `confirm()` в JavaScript.
### Кастомные действия
Помимо типовых действий редактирования и удаления, можно определять кастомные действия:
```php
'actionsConfig' => [
[
'label' => 'Просмотр',
'url' => '/clients/view/{id}',
'icon' => 'fa-solid fa-eye',
'class' => 'btn-outline-secondary',
'title' => 'Просмотр клиента',
],
[
'label' => 'Создать сделку',
'url' => '/deals/create?client_id={id}',
'icon' => 'fa-solid fa-file-contract',
'class' => 'btn-outline-success',
'title' => 'Создать сделку',
],
[
'label' => 'Записать',
'url' => '/bookings/new?client_id={id}',
'icon' => 'fa-solid fa-calendar',
'class' => 'btn-outline-primary',
'title' => 'Запись на приём',
],
],
```
---
## Клиентская инициализация
### Базовая инициализация
JavaScript-модуль DataTable инициализируется для каждой таблицы на странице. При инициализации передаются параметры конфигурации:
```javascript
document.addEventListener('DOMContentLoaded', function() {
new DataTable('clients-table', {
url: '/clients/table',
perPage: 10,
debounceTime: 300,
preserveSearchOnSort: true
});
});
```
Параметр `url` задаёт endpoint для AJAX-загрузки данных. Параметр `perPage` определяет количество записей на странице по умолчанию. Параметр `debounceTime` задаёт задержку в миллисекундах перед выполнением поиска (защита от частых запросов при вводе). Параметр `preserveSearchOnSort` определяет, сохранять ли видимость полей поиска при сортировке.
### Методы DataTable
После инициализации экземпляр DataTable предоставляет методы для программного управления таблицей:
```javascript
const table = new DataTable('my-table', options);
// Установка фильтра
table.setFilter('name', 'Поисковый запрос');
// Установка количества записей на странице
table.setPerPage(25);
// Переход на конкретную страницу
table.goToPage(3);
// Перезагрузка данных
table.loadData();
```
---
## Пустое состояние и действия
### Конфигурация пустого состояния
При отсутствии данных в таблице отображается пустое состояние с возможностью действия. Параметры конфигурации:
```php
'emptyMessage' => 'Клиентов пока нет',
'emptyIcon' => 'fa-solid fa-users',
'emptyActionUrl' => base_url('/clients/new'),
'emptyActionLabel' => 'Добавить клиента',
'emptyActionIcon' => 'fa-solid fa-plus',
```
Параметр `emptyMessage` задаёт текст сообщения. Параметр `emptyIcon` указывает FontAwesome-иконку для отображения над сообщением. Параметры `emptyActionUrl`, `emptyActionLabel` и `emptyActionIcon` определяют кнопку действия при пустом состоянии.
### Условное скрытие действия
Кнопка действия при пустом состоянии отображается только если у пользователя есть право на создание:
```php
'emptyActionUrl' => $this->access->canCreate('clients')
? base_url('/clients/new')
: null,
'emptyActionLabel' => $this->access->canCreate('clients')
? 'Добавить клиента'
: null,
```
---
## Обработка special fieldMap
### Проблема несоответствия имён полей
При работе с моделями часто возникает ситуация, когда имя поля в базе данных отличается от имени свойства в Twig-шаблоне или имени параметра для фильтрации. Например, поле `client_name` в базе данных должно отображаться как «Клиент» и фильтроваться по параметру `client`. Для решения этой проблемы используется параметр `fieldMap`:
```php
protected function getTableConfig(): array
{
return [
'id' => 'deals-table',
'url' => '/deals/table',
'model' => $this->dealModel,
'columns' => [
'client_name' => ['label' => 'Клиент', 'width' => '30%'],
'title' => ['label' => 'Сделка', 'width' => '25%'],
'amount' => ['label' => 'Сумма', 'width' => '15%'],
'stage' => ['label' => 'Этап', 'width' => '15%'],
'created_at' => ['label' => 'Создан', 'width' => '15%'],
],
'searchable' => ['client_name', 'title', 'stage'],
'sortable' => ['client_name', 'title', 'amount', 'stage', 'created_at'],
'defaultSort' => 'created_at',
'order' => 'desc',
// fieldMap для маппинга параметров фильтрации на реальные поля
'fieldMap' => [
'client' => 'client_name', // filters[client] -> client_name
'stage' => 'stage',
],
// ... остальные параметры
];
}
```
При использовании `fieldMap` параметры фильтрации из URL (`filters[client]`) автоматически маппятся на реальное поле базы данных (`client_name`). Это позволяет использовать понятные имена параметров в URL при сохранении корректных имён полей в запросе к базе данных.
---
## Кастомные scope для запросов
### Использование callable scope
Когда стандартной фильтрации недостаточно (например, нужны JOIN-ы с другими таблицами или сложные условия), можно использовать параметр `scope`. Это callable-функция, которая получает builder и полностью контролирует формирование запроса:
```php
protected function getTableConfig(): array
{
return [
'id' => 'deals-table',
'url' => '/deals/table',
'model' => $this->dealModel,
// ... columns, searchable, sortable и т.д.
// Кастомный scope для сложных запросов
'scope' => function($builder) {
$builder->resetQuery();
$builder->select('d.*, c.name as client_name, c.email as client_email')
->from('deals d')
->join('clients c', 'c.id = d.client_id', 'left')
->where('d.organization_id', session()->get('active_org_id'));
// Дополнительная фильтрация по статусу
$status = $this->request->getGet('filters[status]');
if ($status && $status !== 'all') {
$builder->where('d.status', $status);
}
// Фильтрация по диапазону дат
$dateFrom = $this->request->getGet('filters[date_from]');
$dateTo = $this->request->getGet('filters[date_to]');
if ($dateFrom) {
$builder->where('d.created_at >=', $dateFrom);
}
if ($dateTo) {
$builder->where('d.created_at <=', $dateTo . ' 23:59:59');
}
},
// fieldMap для JOIN-полей
'fieldMap' => [
'client' => 'c.name',
'client_email' => 'c.email',
],
];
}
```
При использовании `scope` параметр `model` игнорируется для построения запроса, и `scope` полностью контролирует SELECT, FROM, JOIN и WHERE. Параметры сортировки и фильтрации всё ещё применяются к builder после выполнения scope, поэтому в `fieldMap` нужно указывать полные имена полей с алиасами таблиц.
---
## Практические примеры
### Пример 1: Таблица клиентов
```php
class Clients extends BaseController
{
protected ClientModel $clientModel;
public function __construct()
{
$this->clientModel = new ClientModel();
}
public function index()
{
if (!$this->access->canView('clients')) {
return $this->forbiddenResponse('Нет прав для просмотра');
}
return $this->renderTwig('@Clients/index', [
'title' => 'Клиенты',
'tableHtml' => $this->renderTable($this->getTableConfig()),
'can_create' => $this->access->canCreate('clients'),
]);
}
public function table()
{
if (!$this->access->canView('clients')) {
return $this->forbiddenResponse('Нет прав');
}
return parent::table($this->getTableConfig(), '/clients');
}
protected function getTableConfig(): array
{
return [
'id' => 'clients-table',
'url' => '/clients/table',
'model' => $this->clientModel,
'columns' => [
'name' => ['label' => 'Имя / Название', 'width' => '35%'],
'email' => ['label' => 'Email', 'width' => '25%'],
'phone' => ['label' => 'Телефон', 'width' => '20%'],
'source' => ['label' => 'Источник', 'width' => '10%'],
'created_at' => ['label' => 'Создан', 'width' => '10%'],
],
'searchable' => ['name', 'email', 'phone'],
'sortable' => ['name', 'email', 'phone', 'source', 'created_at'],
'defaultSort' => 'created_at',
'order' => 'desc',
'actions' => ['label' => '', 'width' => '5%'],
'actionsConfig' => [
[
'label' => '',
'url' => '/clients/edit/{id}',
'icon' => 'fa-solid fa-pen',
'class' => 'btn-outline-primary btn-sm',
'title' => 'Редактировать',
'type' => 'edit',
],
[
'label' => '',
'url' => '/clients/delete/{id}',
'icon' => 'fa-solid fa-trash',
'class' => 'btn-outline-danger btn-sm',
'title' => 'Удалить',
'type' => 'delete',
'confirm' => 'Удалить клиента?',
],
],
'emptyMessage' => 'Клиентов пока нет',
'emptyIcon' => 'fa-solid fa-users',
'emptyActionUrl' => $this->access->canCreate('clients') ? '/clients/new' : null,
'emptyActionLabel' => $this->access->canCreate('clients') ? 'Добавить клиента' : null,
'emptyActionIcon' => 'fa-solid fa-plus',
'can_edit' => $this->access->canEdit('clients'),
'can_delete' => $this->access->canDelete('clients'),
];
}
}
```
### Пример 2: Таблица с кастомным рендерингом ячеек
```php
protected function getTableConfig(): array
{
return [
'id' => 'deals-table',
'url' => '/deals/table',
'model' => $this->dealModel,
'columns' => [
'client_name' => ['label' => 'Клиент', 'width' => '25%'],
'title' => ['label' => 'Сделка', 'width' => '25%'],
'amount' => ['label' => 'Сумма', 'width' => '15%'],
'stage' => ['label' => 'Этап', 'width' => '15%'],
'status' => ['label' => 'Статус', 'width' => '10%'],
'created_at' => ['label' => 'Создан', 'width' => '10%'],
],
'searchable' => ['client_name', 'title', 'stage'],
'sortable' => ['client_name', 'title', 'amount', 'stage', 'created_at'],
'defaultSort' => 'created_at',
'order' => 'desc',
'actions' => ['label' => '', 'width' => '5%'],
'actionsConfig' => [
[
'label' => '',
'url' => '/deals/edit/{id}',
'icon' => 'fa-solid fa-pen',
'class' => 'btn-outline-primary btn-sm',
'type' => 'edit',
],
],
'can_edit' => $this->access->canEdit('deals'),
];
}
```
В шаблоне Twig можно добавить кастомный рендеринг ячеек через Twig-фильтры:
```twig
{# В шаблоне ячейки с форматированием #}
<td>
<strong>{{ item.title }}</strong>
{% if item.description %}
<br><small class="text-muted">{{ item.description|slice(0, 50) }}...</small>
{% endif %}
</td>
<td class="text-end">
{{ item.amount|number_format(0, ',', ' ') }} ₽
</td>
<td>
{% if item.status == 'active' %}
<span class="badge bg-success">Активна</span>
{% elseif item.status == 'won' %}
<span class="badge bg-primary">Выиграна</span>
{% elseif item.status == 'lost' %}
<span class="badge bg-danger">Проиграна</span>
{% endif %}
</td>
```
---
## Проверка при создании модуля
### Чек-лист при добавлении новой таблицы
При создании нового модуля с таблицей необходимо выполнить следующие действия:
**В контроллере:**
- Определить метод `getTableConfig()` с обязательными параметрами (`id`, `url`, `model`, `columns`)
- Указать `searchable` и `sortable` массивы с корректными именами полей
- Настроить `actionsConfig` с кнопками действий и проверкой прав
- Добавить метод `table()` для AJAX-загрузки данных
- Вызвать `parent::table()` для использования встроенной логики
- Проверить права доступа перед вызовом родительского метода
**В шаблоне:**
- Подключить DataTable.js в блоке `scripts`
- Инициализировать DataTable с корректным `id` и `url`
- Передать `tableHtml` из контроллера в шаблон
- Убедиться, что CSS стили таблицы подключены
**Модель:**
- Использовать трейт `TenantScopedModel` для автоматической фильтрации по организации
- Убедиться, что модель имеет поле `organization_id`
- Проверить, что модель возвращает данные в ожидаемом формате
---
## Типичные ошибки и их устранение
### Таблица не загружается
Если данные не загружаются, проверьте следующее:
- URL в конфигурации и при инициализации DataTable должны совпадать
- Метод `table()` контроллера должен вызывать `parent::table()`
- Модель должна использовать трейт `TenantScopedModel` или обрабатывать фильтрацию вручную
- Проверьте консоль браузера на наличие ошибок JavaScript
- Убедитесь, что CSRF-токен передаётся корректно
### Сортировка не работает
Если сортировка не работает:
- Поле должно быть указано в массиве `sortable`
- Имя поля в `sortable` должно соответствовать ключу в `columns` и имени поля в базе данных
- Для JOIN-запросов используйте алиасы таблиц в `sortable` (`c.name` вместо `client_name`)
### Поиск не работает
Если поиск не работает:
- Поле должно быть указано в массиве `searchable`
- При использовании JOIN проверьте `fieldMap` для маппинга параметров
- Убедитесь, что в контроллере используется метод `like()` для фильтрации
### Действия не отображаются
Если кнопки действий не отображаются:
- Проверьте `can_edit` и `can_delete` в конфигурации
- Убедитесь, что `type` действия соответствует проверяемому праву (`'edit'` или `'delete'`)
- Проверьте параметр `actions` в конфигурации (должен быть `{label: 'Действия'}` или `true`)
---
## Сводка параметров конфигурации
| Параметр | Тип | Обязательный | Описание |
|----------|-----|--------------|----------|
| `id` | string | Да | Идентификатор контейнера таблицы |
| `url` | string | Да | URL для AJAX-загрузки |
| `model` | Model | Да | Экземпляр модели CodeIgniter |
| `columns` | array | Да | Конфигурация колонок |
| `searchable` | array | Нет | Поля для поиска |
| `sortable` | array | Нет | Поля для сортировки |
| `defaultSort` | string | Нет | Поле сортировки по умолчанию |
| `order` | string | Нет | Направление сортировки по умолчанию |
| `actions` | array\|bool | Нет | Конфигурация колонки действий |
| `actionsConfig` | array | Нет | Кнопки действий |
| `emptyMessage` | string | Нет | Сообщение при отсутствии данных |
| `emptyIcon` | string | Нет | Иконка при пустом состоянии |
| `emptyActionUrl` | string | Нет | URL действия при пустом состоянии |
| `emptyActionLabel` | string | Нет | Текст кнопки действия |
| `can_edit` | bool | Нет | Разрешено ли редактирование |
| `can_delete` | bool | Нет | Разрешено ли удаление |
| `fieldMap` | array | Нет | Маппинг параметров фильтрации |
| `scope` | callable | Нет | Кастомный запрос к базе данных |

611
docs/EVENTS.md Normal file
View File

@ -0,0 +1,611 @@
# Справка по системе событий EventManager
## Общее описание
EventManager — это сервис для работы с событиями в системе «Бизнес.Точка». Он является обёрткой над встроенной системой событий CodeIgniter 4 и предоставляет два типа подписок на события:
- **moduleOn()** — обработчик выполняется только при наличии активной подписки на модуль
- **systemOn()** — обработчик выполняется всегда, без проверки статуса подписки
EventManager используется для создания интеграций между модулями, когда действия в одном модуле должны автоматически вызывать события в другом. Например, при создании клиента в CRM может автоматически создаваться задача в модуле Tasks.
---
## Подключение EventManager
EventManager подключается как сервис через `service('eventManager')`:
```php
$eventManager = service('eventManager');
```
Доступно в контроллерах через BaseController:
```php
// В контроллере:
$em = service('eventManager');
```
---
## Основные методы
### forModule() — привязка к модулю
Метод `forModule()` привязывает все последующие вызовы `moduleOn()` к указанному модулю. Это означает, что подписки на события будут создаваться только если организация имеет активную подписку на этот модуль.
```php
$em = service('eventManager');
// Привязываем события к модулю CRM
$em->forModule('crm');
```
После вызова `forModule()` все события, добавленные через `moduleOn()`, будут проверять подписку организации на модуль CRM. Если подписка не активна — обработчик не будет выполнен.
```php
// Цепочка вызовов
service('eventManager')
->forModule('crm')
->moduleOn('client.created', function($client) {
// Этот код выполнится только если подписка на CRM активна
});
```
**Важно:** Метод `forModule()` необходимо вызвать перед `moduleOn()`, иначе будет выброшено исключение.
---
### moduleOn() — подписка с проверкой модуля
Метод `moduleOn()` создаёт подписку на событие, которая выполняется только при соблюдении условий:
1. Модуль существует в конфигурации `BusinessModules`
2. Модуль глобально включён в настройках
3. Организация имеет активную подписку на модуль
```php
$em = service('eventManager');
$em->forModule('crm');
$em->moduleOn('client.created', function($client) {
// Обработчик выполнится только если CRM подписка активна
log_message('debug', 'Клиент создан: ' . $client['name']);
});
```
**Сигнатура метода:**
```php
public function moduleOn(
string $event, // Имя события
callable $callback, // Обработчик события
int $priority = 100 // Приоритет выполнения
): bool
```
**Возвращаемое значение:**
- `true` — подписка создана, обработчик будет выполнен
- `false` — подписка не создана (модуль не активен, отключён или не существует)
**Параметр `$callback`:**
Обработчик события получает параметры, переданные при вызове события:
```php
$em->forModule('crm');
$em->moduleOn('client.created', function($client, $userId) {
echo 'Создан клиент ' . $client['name'] . ' пользователем ' . $userId;
});
// Где-то в коде:
Events::trigger('client.created', $clientData, $currentUserId);
```
**Приоритет выполнения:**
Параметр `$priority` определяет порядок выполнения обработчиков. Меньшее значение — более высокий приоритет:
```php
// Выполнится раньше (приоритет 50)
$em->moduleOn('client.created', function($client) {
// Логирование
}, 50);
// Выполнится позже (приоритет 100, значение по умолчанию)
$em->moduleOn('client.created', function($client) {
// Отправка уведомлений
});
```
---
### systemOn() — подписка без проверки модуля
Метод `systemOn()` создаёт подписку на событие без проверки статуса подписки. Обработчик будет выполнен всегда, независимо от того, какие модули активированы у организации.
Используется для системных событий, которые должны работать для всех организаций:
```php
$em = service('eventManager');
// Этот обработчик выполнится для всех организаций
$em->systemOn('user.login', function($user) {
log_message('info', 'Пользователь вошёл: ' . $user['email']);
});
// Для отправки email-уведомлений при любом действии
$em->systemOn('email.send', function($to, $subject, $body) {
// Логирование отправки
});
```
**Сигнатура метода:**
```php
public function systemOn(
string $event,
callable $callback,
int $priority = 100
): void
```
---
### off() — отписка от события
Метод `off()` удаляет подписку на событие:
```php
$em = service('eventManager');
// Удаление всех обработчиков события
$em->off('client.created');
// Удаление конкретного обработчика
$em->off('client.created', $specificCallback);
```
---
### currentModuleActive() — проверка статуса модуля
Метод `currentModuleActive()` возвращает `true` если текущий модуль (установленный через `forModule()`) активен для организации.
Используется внутри обработчиков для проверки:
```php
$em->service('eventManager');
$em->forModule('tasks');
$em->moduleOn('deal.won', function($deal) {
// Проверяем, активен ли модуль Tasks
if ($em->currentModuleActive()) {
// Создаём задачу
createTaskForDeal($deal);
}
});
```
---
### getCurrentModuleCode() — получение кода модуля
Метод `getCurrentModuleCode()` возвращает код модуля, установленного через `forModule()`:
```php
$em = service('eventManager');
$em->forModule('crm');
$code = $em->getCurrentModuleCode(); // Вернёт 'crm'
```
---
## Встроенные события системы
### События пользователя
```php
// После успешной регистрации пользователя
Events::trigger('user.registered', $user);
// После подтверждения email
Events::trigger('user.verified', $user);
// При каждом входе в систему
Events::trigger('user.login', $user);
// При выходе из системы
Events::trigger('user.logout', $user);
// При смене пароля
Events::trigger('user.passwordChanged', $user);
// При изменении профиля
Events::trigger('user.profileUpdated', $user, $oldData);
```
### События организации
```php
// При создании организации
Events::trigger('organization.created', $organization);
// При изменении данных организации
Events::trigger('organization.updated', $organization, $changes);
// При удалении организации (до удаления)
Events::trigger('organization.deleting', $organization);
// После удаления организации
Events::trigger('organization.deleted', $organizationId);
// При присоединении пользователя к организации
Events::trigger('organization.userJoined', $organization, $user, $role);
// При выходе пользователя из организации
Events::trigger('organization.userLeft', $organization, $user);
// При изменении роли пользователя
Events::trigger('organization.userRoleChanged', $organization, $user, $oldRole, $newRole);
```
### События модуля Клиенты
```php
// При создании клиента
Events::trigger('client.created', $client, $userId);
// При обновлении клиента
Events::trigger('client.updated', $client, $changes, $userId);
// При удалении клиента
Events::trigger('client.deleted', $clientId, $userId);
// При импорте клиентов
Events::trigger('client.imported', $clients, $userId);
```
---
## Примеры интеграции модулей
### Пример 1: Создание задачи при создании клиента
```php
// В модуле Tasks — файл bootstrap или Config/Events.php
service('eventManager')
->forModule('tasks')
->moduleOn('client.created', function($client, $userId) {
// Создаём задачу на первичный контакт
$taskModel = new \App\Modules\Tasks\Models\TaskModel();
$taskModel->insert([
'organization_id' => $client['organization_id'],
'title' => 'Первый контакт с клиентом: ' . $client['name'],
'description' => 'Необходимо связаться с клиентом для уточнения потребностей',
'assigned_to' => $userId,
'status' => 'todo',
'priority' => 'medium',
'due_at' => date('Y-m-d H:i:s', strtotime('+1 day')),
'created_by' => $userId,
]);
});
```
### Пример 2: Автоматический переход сделки при завершении задачи
```php
// В модуле CRM
service('eventManager')
->forModule('crm')
->moduleOn('task.completed', function($task, $userId) {
if ($task['related_type'] === 'deal' && $task['related_id']) {
$dealModel = new \App\Modules\CRM\Models\DealModel();
// Получаем сделку
$deal = $dealModel->find($task['related_id']);
if ($deal && $deal['stage'] === 'proposal') {
// Переводим сделку на следующий этап
$dealModel->update($deal['id'], [
'stage' => 'negotiation',
'updated_at' => date('Y-m-d H:i:s'),
]);
// Логируем переход
log_message('info', 'Сделка #' . $deal['id'] . ' переведена на этап переговоров после завершения задачи');
}
}
});
```
### Пример 3: Уведомление при записи на приём
```php
// В модуле Booking
service('eventManager')
->forModule('booking')
->moduleOn('booking.created', function($booking, $client, $userId) {
// Отправляем уведомление клиенту
$emailService = service('email');
$emailService->send(
$client['email'],
'Подтверждение записи',
'Уважаемый ' . $client['name'] . ',
Ваша запись на ' . $booking['service_name'] . ' подтверждена на ' .
date('d.m.Y в H:i', strtotime($booking['starts_at']))
);
// Создаём задачу для менеджера
$taskModel = new \App\Modules\Tasks\Models\TaskModel();
$taskModel->insert([
'organization_id' => $booking['organization_id'],
'title' => 'Подготовка к записи: ' . $client['name'],
'description' => 'Клиент записан на услугу ' . $booking['service_name'],
'assigned_to' => $booking['staff_id'],
'status' => 'todo',
'priority' => 'normal',
'due_at' => $booking['starts_at'],
]);
});
```
### Пример 4: Создание проекта Proof при выигрыше сделки
```php
// В модуле CRM
service('eventManager')
->forModule('crm')
->moduleOn('deal.won', function($deal, $userId) {
// Проверяем, активен ли модуль Proof
if (service('moduleSubscription')->isModuleActive('proof')) {
$projectModel = new \App\Modules\Proof\Models\ProjectModel();
$projectModel->insert([
'organization_id' => $deal['organization_id'],
'client_id' => $deal['client_id'],
'title' => 'Проект по сделке #' . $deal['id'],
'description' => 'Автоматически создан при выигрыше сделки',
'status' => 'active',
'created_by' => $userId,
]);
}
});
```
---
## Правила именования событий
Для консистентности системы используйте следующие правила именования событий:
### Формат: `сущность.действие`
| Сущность | Действия | Пример события |
|----------|----------|----------------|
| user | registered, verified, login, logout, passwordChanged, profileUpdated | `user.login` |
| organization | created, updated, deleting, deleted, userJoined, userLeft, userRoleChanged | `organization.created` |
| client | created, updated, deleted, imported | `client.created` |
| deal | created, updated, deleted, won, lost | `deal.won` |
| booking | created, updated, cancelled, completed | `booking.created` |
| task | created, updated, deleted, started, completed | `task.completed` |
| project | created, updated, deleted, archived | `project.created` |
| file | uploaded, deleted, approved, rejected | `file.uploaded` |
| email | send, sent, failed | `email.send` |
### Группы событий модулей
- **CRM:** `client.*`, `deal.*`, `pipeline.*`
- **Booking:** `booking.*`, `service.*`, `staff.*`
- **Proof:** `project.*`, `file.*`, `comment.*`
- **Tasks:** `task.*`, `board.*`, `comment.*`
---
## Вызов событий в коде
Для вызова события используйте `Events::trigger()` из CodeIgniter 4:
```php
use CodeIgniter\Events\Events;
// Простой вызов
Events::trigger('client.created', $clientData);
// С несколькими параметрами
Events::trigger('deal.won', $deal, $userId);
// С именованными параметрами (начиная с CI 4.3+)
Events::trigger('client.updated', [
'client' => $clientData,
'changes' => $changes,
'userId' => $userId,
]);
```
---
## Порядок инициализации событий
События модулей должны инициализироваться в файле `app/Config/Events.php`:
```php
<?php
use CodeIgniter\Events\Events;
use CodeIgniter\Shield\Exceptions\RuntimeException;
/*
* --------------------------------------------------------------------
* Application Events
* --------------------------------------------------------------------
* Events::on() methods to register listeners for application events.
*/
// Загрузка событий модулей
if (is_file(APPPATH . 'Modules/Tasks/Config/Events.php')) {
require_once APPPATH . 'Modules/Tasks/Config/Events.php';
}
if (is_file(APPPATH . 'Modules/CRM/Config/Events.php')) {
require_once APPPATH . 'Modules/CRM/Config/Events.php';
}
if (is_file(APPPATH . 'Modules/Booking/Config/Events.php')) {
require_once APPPATH . 'Modules/Booking/Config/Events.php';
}
if (is_file(APPPATH . 'Modules/Proof/Config/Events.php')) {
require_once APPPATH . 'Modules/Proof/Config/Events.php';
}
```
Каждый модуль создаёт свой файл `Config/Events.php`:
```php
<?php
// app/Modules/CRM/Config/Events.php
use CodeIgniter\Events\Events;
use App\Services\EventManager;
if (!function_exists('register_crm_events')) {
function register_crm_events(): void
{
$em = service('eventManager');
// Интеграция CRM → Tasks
$em->forModule('crm');
$em->moduleOn('client.created', function($client, $userId) {
// Логика создания задачи
});
// Интеграция CRM → Proof
$em->moduleOn('deal.won', function($deal, $userId) {
// Логика создания проекта
});
}
}
register_crm_events();
```
---
## Логирование событий
EventManager автоматически логирует информацию о подписках и выполнении событий:
- **Подписка создана:** `"EventManager: Subscribed to event 'client.created' for module 'crm'"`
- **Модуль отключён:** `"EventManager: Module 'crm' is disabled globally"`
- **Подписка не активна:** `"EventManager: Organization subscription not active for module 'crm'"`
- **Системное событие:** `"EventManager: System event subscribed: 'user.login'"`
Уровень логирования — `debug` для информации и `error` для ошибок конфигурации.
---
## Тестирование событий
### Ручное тестирование в разработке
```php
// В контроллере для тестирования
public function testEvent()
{
$testClient = [
'id' => 999,
'name' => 'Тестовый клиент',
'email' => 'test@example.com',
'organization_id' => session()->get('active_org_id'),
];
// Вызываем событие напрямую
Events::trigger('client.created', $testClient, session()->get('user_id'));
return 'Событие вызвано, проверьте логи';
}
```
### Отладка подписок
```php
// Получение всех обработчиков события
$handlers = Events::listeners('client.created');
foreach ($handlers as $handler) {
log_message('debug', 'Handler: ' . print_r($handler, true));
}
```
---
## Типичные ошибки и их устранение
### Ошибка: "Module code not set"
```php
// Неправильно:
$em->moduleOn('client.created', $callback);
// Правильно:
$em->forModule('crm')->moduleOn('client.created', $callback);
```
### Событие не срабатывает
Возможные причины:
1. Модуль не активирован для организации
2. Модуль отключён глобально в конфигурации
3. Ошибка в имени события
4. Исключение в обработчике блокирует выполнение
Проверка:
```php
// Проверка статуса модуля
$em = service('eventManager');
$em->forModule('crm');
if ($em->currentModuleActive()) {
echo 'Модуль активен';
} else {
echo 'Модуль не активен';
}
```
### Конфликты приоритетов
При использовании нескольких обработчиков одного события убедитесь в корректном порядке выполнения:
```php
// Сначала сохраняем данные
$em->moduleOn('deal.won', function($deal) {
saveDealToArchive($deal);
}, 10); // Выполнится первым
// Затем отправляем уведомления
$em->moduleOn('deal.won', function($deal) {
sendDealWonNotification($deal);
}, 100); // Выполнится вторым
```
---
## Сводка методов
| Метод | Описание | Возвращает |
|-------|----------|------------|
| `forModule($code)` | Привязать к модулю | `$this` |
| `moduleOn($event, $callback, $priority)` | Подписка с проверкой | `bool` |
| `systemOn($event, $callback, $priority)` | Системная подписка | `void` |
| `off($event, $callback)` | Отписка | `void` |
| `currentModuleActive()` | Проверка модуля | `bool` |
| `getCurrentModuleCode()` | Код модуля | `string\|null` |

View File

@ -1,64 +0,0 @@
Примеры использования событийной системы
Для создания интеграций между модулями, которые должны работать только при наличии активной подписки, используется метод moduleOn(). Ниже приведен пример инициализации событий в файле модуля, например в app/Modules/Crm/Config/Events.php:
php
<?php
namespace App\Modules\Crm\Config;
use service('eventManager');
/**
* Регистрация событий модуля CRM
* События будут выполняться только при активной подписке на CRM
*/
$em = service('eventManager');
$em->forModule('crm');
// При создании нового клиента отправляем приветственное письмо
$em->moduleOn('clients.created', function($client) {
$emailService = service('email');
$emailService->sendWelcomeEmail($client->email, $client->name);
});
// При изменении статуса сделки обновляем метрики
$em->moduleOn('deals.status_changed', function($deal, $oldStatus, $newStatus) {
service('analytics')->trackDealStatusChange($deal->id, $oldStatus, $newStatus);
});
// Логируем все действия с клиентами
$em->moduleOn('clients.*', function($event, $data) {
service('audit')->log('crm_client_activity', $data);
});
Для системных событий, которые должны выполняться всегда независимо от статуса подписки, используется метод systemOn(). Такие события подходят для сквозной функциональности, например, логирования, аудита или сбора аналитики:
php
<?php
use service('eventManager');
$em = service('eventManager');
// Системное событие для логирования всех запросов к базе данных
$em->systemOn('DBQuery', function($query) {
if (ENVIRONMENT === 'development') {
log_message('debug', 'DB Query: ' . $query);
}
});
// Системное событие для записи активности пользователя
$em->systemOn('user.login', function($user) {
service('activityLogger')->logLogin($user->id);
});
Архитектурные преимущества решения
Разделение событий на два типа обеспечивает гибкость при проектировании интеграций между модулями. События типа moduleOn() гарантируют, что бизнес-логика модуля выполняется только для организаций, которые оплатили доступ к этому модулю, что защищает коммерческие интересы и предотвращает несанкционированное использование функциональности. События типа systemOn() позволяют реализовывать сквозную функциональность, которая должна присутствовать в системе независимо от того, какие модули оплачены организацией, например, общие уведомления, аудит безопасности или интеграция с внешними системами мониторинга.
Кэширование результата проверки статуса модуля в рамках одного запроса обеспечивает высокую производительность событийной системы. При множественных подписках на события одного модуля проверка подписки выполняется только один раз, а затем результат кэшируется в свойстве $moduleActive.