- 🔄 Детальная документация 47 нестандартных контроллеров
### План документирования
-- â\8f³ Фаза 1: Ð\9aÑ\80иÑ\82иÑ\87нÑ\8bе конÑ\82Ñ\80оллеÑ\80Ñ\8b (3 Ñ\88Ñ\82.) â\80\94 >1000 Ñ\81Ñ\82Ñ\80ок
-- â\8f³ Фаза 2: Ð\9aÑ\80Ñ\83пнÑ\8bе конÑ\82Ñ\80оллеÑ\80Ñ\8b (9 Ñ\88Ñ\82.) â\80\94 500-1000 Ñ\81Ñ\82Ñ\80ок
-- ⏳ Фаза 3: Средние с интеграциями (17 шт.) — 300-500 строк
-- ⏳ Фаза 4: Средние со сложной логикой (10 шт.) — 200-300 строк
-- ⏳ Фаза 5: Малые с особыми признаками (8 шт.) — <200 строк
+- â\9c\85 Фаза 1: Ð\9aÑ\80иÑ\82иÑ\87нÑ\8bе конÑ\82Ñ\80оллеÑ\80Ñ\8b (3 Ñ\88Ñ\82.) â\80\94 >1000 Ñ\81Ñ\82Ñ\80ок â\80\94 **100% завеÑ\80Ñ\88ено** â\9c\85
+- â\9c\85 Фаза 2: Ð\9aÑ\80Ñ\83пнÑ\8bе конÑ\82Ñ\80оллеÑ\80Ñ\8b (9 Ñ\88Ñ\82.) â\80\94 500-1000 Ñ\81Ñ\82Ñ\80ок â\80\94 **100% завеÑ\80Ñ\88ено** â\9c\85
+- 🔄 Фаза 3: Средние с интеграциями (17 шт.) — 300-500 строк — **6% завершено** (1/17)
+- ⏳ Фаза 4: Средние со сложной логикой (10 шт.) — 200-300 строк — **0% завершено**
+- ⏳ Фаза 5: Малые с особыми признаками (8 шт.) — <200 строк — **0% завершено**
---
#### Список контроллеров (17 шт.):
-1. ShiftTransferController (490 строк)
+1. ✅ **ShiftTransferController** (490 строк) — Передача смены, 5 статусов, интеграция с 1С
+ - _ANALYSIS.md (276 строк)
+ - _ACTIONS_TABLE.md (96 строк)
+ - _QUICK_REFERENCE.md (442 строки)
+ - **Итого:** ~814 строк документации
2. crud/Product1cReplacementController (480 строк)
3. ApiController (476 строк)
4. TimetableFactController (475 строк)
| Метрика | Цель | Текущий | Прогресс |
|---------|------|---------|----------|
-| **Документированных контроллеров** | 47 | 0 | 0% |
-| **Строк документации** | 25,000+ | 0 | 0% |
-| **Mermaid диаграмм** | 145+ | 0 | 0% |
-| **Примеров кода** | 200+ | 0 | 0% |
-| **FAQ записей** | 100+ | 0 | 0% |
+| **Документированных контроллеров** | 47 | 13 | 28% |
+| **Строк документации** | 25,000+ | ~10,014 | 40% |
+| **Mermaid диаграмм** | 145+ | 40+ | 28% |
+| **Примеров кода** | 200+ | 71+ | 36% |
+| **FAQ записей** | 100+ | 40+ | 40% |
### Качественные показатели
--- /dev/null
+# ChartsForManagementController - Таблица Actions
+
+## Быстрая справка по действиям
+
+| # | Action | HTTP | Маршрут | Назначение | Параметры | Возвращает |
+|---|--------|------|---------|------------|-----------|------------|
+| 1 | `actionIndex()` | GET | `/charts-for-management/index` | Главная страница графиков | - | HTML |
+| 2 | `actionWriteOffPosition()` | GET | `/charts-for-management/write-off-position` | Страница графика списаний | - | HTML |
+| 3 | `actionGetControlDataAjax()` | POST | `/charts-for-management/get-control-data-ajax` | Получение кустов и магазинов | `date_start`, `date_end` | JSON |
+| 4 | `actionGetDataAjax()` | POST | `/charts-for-management/get-data-ajax` | Получение данных графиков | `mode`, `attribute`, `date_start`, `date_end`, `cluster`, `store`, `shift` | JSON |
+| 5 | `actionWriteOffsIndex()` | GET | `/charts-for-management/write-offs-index` | Список списаний за дату | `date`, `cluster_id`, `store_id` | HTML |
+
+---
+
+## Детальное описание Actions
+
+### 1. actionIndex()
+
+**Сигнатура:**
+```php
+public function actionIndex(): string
+```
+
+**Назначение:** Отображение главной страницы с набором графиков для менеджмента
+
+**Параметры:** Нет
+
+**Возвращает:**
+- `string` — HTML страница с графиками
+
+**Исключения:**
+- `Exception` — "Нет доступа" (если group_id не распознана)
+
+**Права доступа:**
+- Группы: 1, 81, 71, 51, 10, 9, 74, 14 (топ-менеджмент) — полный доступ
+- Группа 7 (менеджер куста) — куст и магазины
+- Группы 30, 40 (менеджер магазина день) — только магазин, день
+- Группы 35, 72 (менеджер магазина ночь) — только магазин, ночь
+- Группа 50 (администратор) — магазин, все смены
+
+**Пример вызова:**
+```php
+// GET /charts-for-management/index
+Yii::$app->controller->actionIndex();
+```
+
+**Пример HTTP:**
+```bash
+curl -X GET "https://erp24.example.com/charts-for-management/index" \
+ -H "Cookie: PHPSESSID=..."
+```
+
+---
+
+### 2. actionWriteOffPosition()
+
+**Сигнатура:**
+```php
+public function actionWriteOffPosition(): string
+```
+
+**Назначение:** Отображение страницы графика списаний по позициям
+
+**Параметры:** Нет
+
+**Возвращает:**
+- `string` — HTML страница графика
+
+**Исключения:**
+- `Exception` — "Нет доступа"
+
+**Права доступа:** Аналогично `actionIndex()`
+
+**Особенности:**
+- Группы 1, 81, 71, 51, 10, 9, 74, 14, 7, 50 имеют `access_plan = true`
+- Остальные группы: `access_plan = false`
+
+**Пример вызова:**
+```php
+// GET /charts-for-management/write-off-position
+Yii::$app->controller->actionWriteOffPosition();
+```
+
+**Пример HTTP:**
+```bash
+curl -X GET "https://erp24.example.com/charts-for-management/write-off-position" \
+ -H "Cookie: PHPSESSID=..."
+```
+
+---
+
+### 3. actionGetControlDataAjax()
+
+**Сигнатура:**
+```php
+public function actionGetControlDataAjax(): string
+```
+
+**Назначение:** AJAX-получение управляющих данных: списка кустов, магазинов и истории изменений
+
+**Параметры (POST):**
+
+| Параметр | Тип | Обязательный | Значение по умолчанию | Описание |
+|----------|-----|--------------|----------------------|----------|
+| `date_start` | string | Да | - | Дата начала периода (Y-m-d) |
+| `date_end` | string | Да | - | Дата окончания периода (Y-m-d) |
+
+**Возвращает:**
+- `string` — JSON
+
+**Структура JSON:**
+```json
+{
+ "stores_step": {
+ "Магазин 1": {
+ "2025-01-01": "Куст 1",
+ "2025-02-01": "Куст 2"
+ }
+ },
+ "clusters": [
+ {"id": 1, "text": "Куст 1"},
+ {"id": 2, "text": "Куст 2"}
+ ],
+ "stores_in_cluster": {
+ "1": {
+ "text": "Куст 1",
+ "children": [
+ {"id": 1, "text": "Магазин 1"},
+ {"id": 2, "text": "Магазин 2"}
+ ]
+ }
+ }
+}
+```
+
+**Пример вызова:**
+```javascript
+$.ajax({
+ url: '/charts-for-management/get-control-data-ajax',
+ type: 'POST',
+ data: {
+ date_start: '2025-01-01',
+ date_end: '2025-01-31'
+ },
+ success: function(response) {
+ const data = JSON.parse(response);
+ console.log(data.clusters);
+ }
+});
+```
+
+**Особенности:**
+- Возвращает только магазины пользователя (из `admin.store_arr`)
+- Учитывает историю изменений кустов (`store_dynamic`)
+- `stores_step` содержит только магазины, которые меняли куст в периоде
+
+---
+
+### 4. actionGetDataAjax()
+
+**Сигнатура:**
+```php
+public function actionGetDataAjax(): string|int
+```
+
+**Назначение:** AJAX-получение данных для построения графиков
+
+**Параметры (POST):**
+
+| Параметр | Тип | Обязательный | Значение по умолчанию | Описание |
+|----------|-----|--------------|----------------------|----------|
+| `mode` | int | Да | - | Уровень: 1-Розница, 2-Куст, 3-Магазин |
+| `attribute` | string | Да | - | Тип графика (см. список ниже) |
+| `date_start` | string | Нет | `-13 days` | Дата начала (Y-m-d) |
+| `date_end` | string | Нет | `today` | Дата окончания (Y-m-d) |
+| `cluster` | int | Нет | `null` | ID куста |
+| `store` | int | Нет | `null` | ID магазина |
+| `shift` | int | Нет | `3` | Смена: 1-День, 2-Ночь, 3-День+Ночь |
+
+**Типы графиков (attribute):**
+
+| Значение | Описание | Показатели |
+|----------|----------|------------|
+| `sales` | Продажи | продажи, план |
+| `plan_completed_this_day` | Выполнение плана за день | процент |
+| `plan_completed_this_month` | Прогноз плана за месяц | процент |
+| `avg_sales_value` | Средний чек | средний чек, план |
+| `fot` | ФОТ | ФОТ в %, план |
+| `sales_sum_on_admin` | Продажи на администратора | продажи на 1 сотрудника |
+| `write_offs` | Списания | сумма, %, накопительный % |
+| `user_bonus` | Бонусы пользователей | 4 типа бонусов |
+| `matrix_sales_sum` | Продажи матрицы | букеты, растения, готовые товары |
+| `count_sales_in_hour` | Продажи по часам | продажи, среднее |
+| `write_offs_position` | Списания по позициям | детализация |
+
+**Возвращает:**
+- `string` — JSON с данными графика
+- `int` — `-1` (если нет данных для `write_offs_position`)
+
+**Структура JSON:**
+```json
+{
+ "chart_opts": {
+ "title": {"text": "Магазин: Название"},
+ "xaxis": {
+ "type": "text",
+ "categories": ["2025-01-01 Пн", "2025-01-02 Вт"]
+ }
+ },
+ "data_answer": {
+ "attribute": {
+ "sales": {
+ "data": [10000, 12000, 15000]
+ },
+ "plan": {
+ "data": [11000, 11500, 14000]
+ }
+ }
+ }
+}
+```
+
+**Пример вызова:**
+```javascript
+$.ajax({
+ url: '/charts-for-management/get-data-ajax',
+ type: 'POST',
+ data: {
+ mode: 3, // Магазин
+ attribute: 'sales', // Продажи
+ date_start: '2025-01-01',
+ date_end: '2025-01-31',
+ cluster: null,
+ store: 5, // ID магазина
+ shift: 3 // День+Ночь
+ },
+ success: function(response) {
+ const chartData = JSON.parse(response);
+ renderChart(chartData);
+ }
+});
+```
+
+**Особенности:**
+- Для `plan_completed_this_day` и `plan_completed_this_month` даты игнорируются, используется текущий месяц
+- Для `write_offs` date_start автоматически устанавливается на начало месяца
+- Для `count_sales_in_hour` категории оси X зависят от `shift`
+
+---
+
+### 5. actionWriteOffsIndex()
+
+**Сигнатура:**
+```php
+public function actionWriteOffsIndex(string $date, ?int $cluster_id = null, ?int $store_id = null): string
+```
+
+**Назначение:** Отображение списка списаний типа "Брак" за определённую дату
+
+**Параметры (GET):**
+
+| Параметр | Тип | Обязательный | Значение по умолчанию | Описание |
+|----------|-----|--------------|----------------------|----------|
+| `date` | string | Да | - | Дата списаний (Y-m-d) |
+| `cluster_id` | int | Нет | `null` | ID куста для фильтрации |
+| `store_id` | int | Нет | `null` | ID магазина для фильтрации |
+
+**Возвращает:**
+- `string` — HTML страница с таблицей списаний
+
+**Пример вызова:**
+```php
+// GET /charts-for-management/write-offs-index?date=2025-01-15&store_id=5
+Yii::$app->controller->actionWriteOffsIndex('2025-01-15', null, 5);
+```
+
+**Пример HTTP:**
+```bash
+curl -X GET "https://erp24.example.com/charts-for-management/write-offs-index?date=2025-01-15&store_id=5" \
+ -H "Cookie: PHPSESSID=..."
+```
+
+**Структура данных (ArrayDataProvider):**
+
+| Поле | Описание |
+|------|----------|
+| `store_name` | Название магазина |
+| `sum` | Сумма списания |
+| `date` | Дата списания |
+| `number` | Номер документа |
+| `comment` | Комментарий |
+
+**Особенности:**
+- Фильтрует только списания типа "Брак"
+- Сортировка: куст → магазин → дата
+- Использует JOIN через `export_import_table` и `store_dynamic`
+
+---
+
+## Матрица доступа к графикам
+
+| Group ID | Роль | mode_level | mode_shift | Доступ к планам |
+|----------|------|------------|------------|-----------------|
+| 1, 81, 71, 51, 10, 9, 74, 14 | Топ-менеджмент | 1, 2, 3 | 1, 2, 3 | ✅ Да |
+| 7 | Менеджер куста | 2, 3 | 1, 2, 3 | ✅ Да |
+| 30, 40 | Менеджер магазина (день) | 3 | 1 | ❌ Нет |
+| 35, 72 | Менеджер магазина (ночь) | 3 | 2 | ❌ Нет |
+| 50 | Администратор | 3 | 1, 2, 3 | ✅ Да |
+
+**Легенда mode_level:**
+- 1 — Розница (вся сеть)
+- 2 — Куст
+- 3 — Магазин
+
+**Легенда mode_shift:**
+- 1 — День (8:00-19:59)
+- 2 — Ночь (20:00-7:59)
+- 3 — День+Ночь (все 24 часа)
+
+---
+
+## Используемые модели и сервисы
+
+| Компонент | Тип | Использование |
+|-----------|-----|---------------|
+| `Admin` | Модель | Проверка прав доступа, получение магазинов |
+| `ChartDataSearch` | Модель | Поиск данных для графиков |
+| `WriteOffs` | Модель | Получение списаний |
+| `ArrayHelper` | Helper | `map()` для преобразования массивов |
+| `Json` | Helper | `encode()` для формирования JSON |
+| `ArrayDataProvider` | Провайдер | Предоставление данных для GridView |
+
+---
+
+## Примеры AJAX запросов
+
+### Получение управляющих данных
+
+```javascript
+// JavaScript
+fetch('/charts-for-management/get-control-data-ajax', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ },
+ body: new URLSearchParams({
+ date_start: '2025-01-01',
+ date_end: '2025-01-31'
+ })
+})
+.then(response => response.json())
+.then(data => {
+ console.log('Кусты:', data.clusters);
+ console.log('Магазины:', data.stores_in_cluster);
+});
+```
+
+### Получение данных графика продаж
+
+```javascript
+// JavaScript
+fetch('/charts-for-management/get-data-ajax', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ },
+ body: new URLSearchParams({
+ mode: '3',
+ attribute: 'sales',
+ date_start: '2025-01-01',
+ date_end: '2025-01-31',
+ store: '5',
+ shift: '3'
+ })
+})
+.then(response => response.json())
+.then(data => {
+ console.log('Данные графика:', data.data_answer);
+ renderChart(data);
+});
+```
+
+### Получение данных графика ФОТ
+
+```javascript
+// JavaScript
+$.post('/charts-for-management/get-data-ajax', {
+ mode: 2, // Куст
+ attribute: 'fot', // ФОТ
+ date_start: '2025-01-01',
+ date_end: '2025-01-31',
+ cluster: 3,
+ shift: 3
+}, function(response) {
+ const data = JSON.parse(response);
+ console.log('ФОТ:', data.data_answer.attribute.fot.data);
+ console.log('План:', data.data_answer.attribute.plan.data);
+});
+```
+
+---
+
+## Связанные файлы
+
+- [Детальный анализ](./ChartsForManagementController_ANALYSIS.md)
+- [Краткая справка](./ChartsForManagementController_QUICK_REFERENCE.md)
+- [README нестандартных контроллеров](./README.md)
+
+---
+
+**Документация создана:** 2025-11-26
+**Статус:** ✅ Готово
--- /dev/null
+# ChartsForManagementController - Детальный анализ
+
+## 📋 Общая информация
+
+**Namespace:** `app\controllers`
+**Путь к файлу:** `erp24/controllers/ChartsForManagementController.php`
+**Extends:** `\yii\web\Controller`
+**Размер:** 609 строк кода
+**Количество actions:** 5
+**Категория:** Крупный контроллер
+**Приоритет:** HIGH (Фаза 2)
+
+---
+
+## 🎯 Назначение
+
+Контроллер для управления аналитическими графиками и диаграммами для менеджмента компании. Предоставляет визуализацию ключевых метрик бизнеса:
+
+- Продажи (sales)
+- Выполнение плана
+- ФОТ (фонд оплаты труда)
+- Списания (write-offs)
+- Бонусы сотрудников
+- Средний чек
+- Продажи матрицы
+- Количество продаж по часам
+
+Контроллер реализует **RBAC-систему доступа** с гранулярным контролем уровней просмотра (розница, куст, магазин) и смен (день, ночь, день+ночь) в зависимости от группы пользователя.
+
+---
+
+## 🏗️ Архитектура
+
+### Основные компоненты
+
+```mermaid
+graph TB
+ Controller[ChartsForManagementController]
+
+ subgraph "Actions"
+ A1[actionIndex<br/>Главная страница графиков]
+ A2[actionWriteOffPosition<br/>График списаний]
+ A3[actionGetControlDataAjax<br/>Получение управляющих данных]
+ A4[actionGetDataAjax<br/>Получение данных графиков]
+ A5[actionWriteOffsIndex<br/>Список списаний]
+ end
+
+ subgraph "Models"
+ M1[Admin<br/>Пользователь системы]
+ M2[ChartDataSearch<br/>Поиск данных графиков]
+ M3[WriteOffs<br/>Списания]
+ end
+
+ subgraph "Helpers"
+ H1[ArrayHelper<br/>Работа с массивами]
+ H2[Json<br/>JSON кодирование]
+ end
+
+ subgraph "Database Tables"
+ DB1[(admin)]
+ DB2[(store_dynamic)]
+ DB3[(city_store)]
+ DB4[(write_offs)]
+ end
+
+ Controller --> A1
+ Controller --> A2
+ Controller --> A3
+ Controller --> A4
+ Controller --> A5
+
+ A1 --> M1
+ A2 --> M1
+ A3 --> M1
+ A4 --> M2
+ A5 --> M3
+
+ A3 --> H1
+ A4 --> H1
+ A4 --> H2
+
+ M1 --> DB1
+ M2 --> DB2
+ M2 --> DB3
+ M3 --> DB4
+```
+
+### Зависимости
+
+| Компонент | Тип | Назначение |
+|-----------|-----|------------|
+| `Admin` | Модель | Управление пользователями и их правами доступа |
+| `ChartDataSearch` | Модель | Поиск и формирование данных для графиков |
+| `WriteOffs` | Модель | Работа со списаниями товаров |
+| `ArrayHelper` | Helper | Преобразование массивов данных |
+| `Json` | Helper | Кодирование данных в JSON |
+| `ArrayDataProvider` | Провайдер | Предоставление данных для виджетов |
+
+---
+
+## 🔐 RBAC система доступа
+
+### Группы пользователей и уровни доступа
+
+| Group ID | Роль | Уровни (mode_level) | Смены (mode_shift) | Особенности |
+|----------|------|---------------------|-------------------|-------------|
+| 1, 81, 71, 51, 10, 9, 74, 14 | Топ-менеджмент | Розница, Куст, Магазин | День+Ночь, День, Ночь | Полный доступ ко всем графикам |
+| 7 (CLUSTER_MANAGER) | Менеджер куста | Куст, Магазин | День+Ночь, День, Ночь | Доступ к кусту и магазинам |
+| 30, 40 | Менеджер магазина (День) | Магазин | День | Нет доступа к планам |
+| 35, 72 | Менеджер магазина (Ночь) | Магазин | Ночь | Нет доступа к планам |
+| 50 (ADMINISTRATOR) | Администратор магазина | Магазин | День+Ночь, День, Ночь | Полный доступ на уровне магазина |
+
+### Доступные графики (charts)
+
+```php
+$access = [
+ 'main' => [], // Главный график
+ 'plan_completed_this_day' => [], // Выполнение плана за день
+ 'plan_completed_this_month' => [], // Выполнение плана за месяц
+ 'sales' => [], // Продажи
+ 'matrix_sales_sum' => [], // Продажи матрицы
+ 'avg_sales_value' => [], // Средний чек
+ 'fot' => [], // ФОТ
+ 'sales_sum_on_admin' => [], // Продажи на администратора
+ 'user_bonus' => [], // Бонусы пользователей
+ 'count_sales_in_hour' => [], // Количество продаж по часам
+ 'write_offs' => [], // Списания
+];
+```
+
+---
+
+## 📋 Actions
+
+### 1. actionIndex()
+
+**Назначение:** Отображение главной страницы с графиками для менеджмента
+
+**HTTP метод:** GET
+**Маршрут:** `/charts-for-management/index`
+
+**Параметры:** Нет
+
+**Возвращает:**
+- Success: `string` (HTML страница с графиками)
+- Error: `Exception` "Нет доступа"
+
+**Бизнес-логика:**
+
+1. Получить текущего пользователя (`Admin`)
+2. Инициализировать массив прав доступа для всех графиков
+3. В зависимости от `group_id` пользователя:
+ - Настроить доступные уровни просмотра (розница/куст/магазин)
+ - Настроить доступные смены (день/ночь/день+ночь)
+ - Установить видимость графиков
+4. Если группа не распознана → выбросить исключение
+5. Отрендерить представление с правами доступа
+
+**Последовательность выполнения:**
+
+```mermaid
+sequenceDiagram
+ participant User
+ participant Controller
+ participant Admin
+ participant View
+
+ User->>Controller: GET /charts-for-management/index
+ Controller->>Admin: findOne(['id' => user_id])
+ Admin-->>Controller: Admin model
+
+ alt Топ-менеджмент (1,81,71,51,10,9,74,14)
+ Controller->>Controller: Установить полный доступ
+ Controller->>Controller: mode_level: [1,2,3]
+ Controller->>Controller: mode_shift: [1,2,3]
+ else Менеджер куста (7)
+ Controller->>Controller: mode_level: [2,3]
+ Controller->>Controller: mode_shift: [1,2,3]
+ else Менеджер магазина день (30,40)
+ Controller->>Controller: mode_level: [3]
+ Controller->>Controller: mode_shift: [1]
+ Controller->>Controller: Скрыть планы
+ else Менеджер магазина ночь (35,72)
+ Controller->>Controller: mode_level: [3]
+ Controller->>Controller: mode_shift: [2]
+ Controller->>Controller: Скрыть планы
+ else Администратор (50)
+ Controller->>Controller: mode_level: [3]
+ Controller->>Controller: mode_shift: [1,2,3]
+ else Группа не распознана
+ Controller-->>User: Exception: Нет доступа
+ end
+
+ Controller->>View: render('index', ['access' => $access])
+ View-->>User: HTML страница с графиками
+```
+
+**Пример использования:**
+
+```php
+// GET /charts-for-management/index
+public function actionIndex()
+{
+ $admin = Admin::findOne(['id' => Yii::$app->user->id]);
+
+ // Инициализация прав
+ $access = [
+ 'main' => ['mode_level' => [], 'mode_shift' => [], 'visible' => false],
+ 'sales' => ['mode_level' => [], 'mode_shift' => [], 'visible' => false],
+ // ... остальные графики
+ ];
+
+ // Проверка группы и настройка доступа
+ if (in_array($admin->group_id, [1, 81, 71, 51, 10, 9, 74, 14])) {
+ // Полный доступ
+ }
+
+ return $this->render('index', ['access' => $access]);
+}
+```
+
+**Пример HTTP запроса:**
+
+```bash
+curl -X GET "https://erp24.example.com/charts-for-management/index" \
+ -H "Cookie: session_id=..."
+```
+
+---
+
+### 2. actionWriteOffPosition()
+
+**Назначение:** Отображение графика списаний по позициям
+
+**HTTP метод:** GET
+**Маршрут:** `/charts-for-management/write-off-position`
+
+**Параметры:** Нет
+
+**Возвращает:**
+- Success: `string` (HTML страница графика списаний)
+- Error: `Exception` "Нет доступа"
+
+**Бизнес-логика:**
+
+1. Получить текущего пользователя
+2. Настроить права доступа к графику списаний:
+ - `mode_level`: уровни просмотра
+ - `mode_shift`: доступные смены
+ - `access_plan`: доступ к плановым показателям
+3. Группы с доступом к плану: 1, 81, 71, 51, 10, 9, 74, 14, 7, 50
+4. Отрендерить представление
+
+**Последовательность выполнения:**
+
+```mermaid
+sequenceDiagram
+ participant User
+ participant Controller
+ participant Admin
+ participant View
+
+ User->>Controller: GET /charts-for-management/write-off-position
+ Controller->>Admin: findOne(['id' => user_id])
+ Admin-->>Controller: Admin model
+
+ Controller->>Controller: Инициализация $access
+
+ alt Топ-менеджмент/Менеджер куста/Администратор
+ Controller->>Controller: access_plan = true
+ else Менеджер магазина
+ Controller->>Controller: access_plan = false
+ end
+
+ Controller->>View: render('write-offs-position-chart')
+ View-->>User: HTML страница графика
+```
+
+**Пример использования:**
+
+```php
+// GET /charts-for-management/write-off-position
+public function actionWriteOffPosition()
+{
+ $admin = Admin::findOne(['id' => Yii::$app->user->id]);
+
+ $access = [
+ 'mode_level' => [],
+ 'mode_shift' => [],
+ 'access_plan' => false,
+ ];
+
+ if (in_array($admin->group_id, [1, 81, 71, 51, 10, 9, 74, 14])) {
+ $access['access_plan'] = true;
+ }
+
+ return $this->render('write-offs-position-chart', ['access' => $access]);
+}
+```
+
+---
+
+### 3. actionGetControlDataAjax()
+
+**Назначение:** AJAX-получение управляющих данных: списка магазинов, кустов и их динамики по датам
+
+**HTTP метод:** POST
+**Маршрут:** `/charts-for-management/get-control-data-ajax`
+
+**Параметры:**
+
+| Параметр | Тип | Обязательный | Описание |
+|----------|-----|--------------|----------|
+| date_start | string | Да | Дата начала периода (Y-m-d) |
+| date_end | string | Да | Дата окончания периода (Y-m-d) |
+
+**Возвращает:**
+- Success: `JSON`
+ ```json
+ {
+ "stores_step": {
+ "Магазин 1": {
+ "2025-01-01": "Куст 1",
+ "2025-02-01": "Куст 2"
+ }
+ },
+ "clusters": [
+ {"id": 1, "text": "Куст 1"},
+ {"id": 2, "text": "Куст 2"}
+ ],
+ "stores_in_cluster": {
+ "1": {
+ "text": "Куст 1",
+ "children": [
+ {"id": 1, "text": "Магазин 1"},
+ {"id": 2, "text": "Магазин 2"}
+ ]
+ }
+ }
+ }
+ ```
+
+**Бизнес-логика:**
+
+1. Получить даты из POST запроса
+2. Сформировать SQL запрос для получения магазинов пользователя с учётом:
+ - `store_arr` - список доступных магазинов
+ - `store_dynamic` - история изменений кустов
+ - Период дат (date_from, date_to)
+3. Отфильтровать магазины, которые меняли куст в периоде
+4. Собрать уникальные кусты
+5. Сгруппировать магазины по кустам
+6. Вернуть JSON с тремя массивами
+
+**Последовательность выполнения:**
+
+```mermaid
+sequenceDiagram
+ participant Client
+ participant Controller
+ participant Admin
+ participant StoreDynamic
+ participant CityStore
+
+ Client->>Controller: POST {date_start, date_end}
+ Controller->>Admin: find() с join store_dynamic
+ Admin->>StoreDynamic: Получить историю кустов
+ StoreDynamic->>CityStore: Получить названия магазинов
+ CityStore-->>Controller: Массив магазинов с кластерами
+
+ Controller->>Controller: Фильтр магазинов с изменениями
+ Controller->>Controller: Собрать уникальные кластеры
+ Controller->>Controller: Сгруппировать по кластерам
+
+ Controller-->>Client: JSON {stores_step, clusters, stores_in_cluster}
+```
+
+**Пример использования:**
+
+```javascript
+// AJAX запрос
+$.ajax({
+ url: '/charts-for-management/get-control-data-ajax',
+ type: 'POST',
+ data: {
+ date_start: '2025-01-01',
+ date_end: '2025-01-31'
+ },
+ success: function(response) {
+ const data = JSON.parse(response);
+ console.log(data.clusters); // Список кустов
+ console.log(data.stores_in_cluster); // Магазины в кустах
+ }
+});
+```
+
+---
+
+### 4. actionGetDataAjax()
+
+**Назначение:** AJAX-получение данных для построения графиков
+
+**HTTP метод:** POST
+**Маршрут:** `/charts-for-management/get-data-ajax`
+
+**Параметры:**
+
+| Параметр | Тип | Обязательный | Описание |
+|----------|-----|--------------|----------|
+| mode | int | Да | Уровень: 1-Розница, 2-Куст, 3-Магазин |
+| attribute | string | Да | Тип графика (sales, fot, write_offs и т.д.) |
+| date_start | string | Нет | Дата начала (по умолчанию -13 дней) |
+| date_end | string | Нет | Дата окончания (по умолчанию сегодня) |
+| cluster | int | Нет | ID куста |
+| store | int | Нет | ID магазина |
+| shift | int | Нет | Смена: 1-День, 2-Ночь, 3-День+Ночь (по умолчанию 3) |
+
+**Возвращает:**
+- Success: `JSON`
+ ```json
+ {
+ "chart_opts": {
+ "title": {"text": "Магазин: Название"},
+ "xaxis": {
+ "type": "text",
+ "categories": ["2025-01-01 Пн", "2025-01-02 Вт"]
+ }
+ },
+ "data_answer": {
+ "attribute": {
+ "sales": {
+ "data": [10000, 12000, 15000]
+ },
+ "plan": {
+ "data": [11000, 11500, 14000]
+ }
+ }
+ }
+ }
+ ```
+- Error: `-1` (если нет данных)
+
+**Поддерживаемые типы графиков (attribute):**
+
+1. **sales** - Продажи
+ - Показатели: продажи (value), план (plan)
+ - Две линии на графике
+
+2. **plan_completed_this_day** - Выполнение плана за день
+ - Показатели: процент выполнения
+ - Период: с начала месяца до сегодня
+
+3. **plan_completed_this_month** - Прогноз выполнения плана за месяц
+ - Показатели: гипотетический процент
+ - Период: весь месяц
+
+4. **avg_sales_value** - Средний чек
+ - Показатели: средний чек, план
+ - Использует пары значений (value, count)
+
+5. **fot** - ФОТ (фонд оплаты труда)
+ - Показатели: ФОТ в процентах, план
+ - Использует пары значений (sales_sum, fot_sum)
+
+6. **sales_sum_on_admin** - Продажи на администратора
+ - Показатели: продажи на одного сотрудника
+ - Использует пары значений (sales, admin_count)
+
+7. **write_offs** - Списания
+ - Показатели: сумма списаний, процент от продаж, накопительный процент
+ - Период: с начала месяца
+
+8. **user_bonus** - Бонусы пользователей
+ - Показатели: 4 типа бонусов
+ - Группировка по 4 значения
+
+9. **matrix_sales_sum** - Продажи матрицы
+ - Показатели: букет, растение в горшке, готовые товары
+ - Группировка по 3 значения
+
+10. **count_sales_in_hour** - Количество продаж по часам
+ - Показатели: продажи по часам, средние за период
+ - Оси X: часы (8-23, 0-7 или подмножества)
+
+**Бизнес-логика:**
+
+1. Получить параметры из POST
+2. Создать объект `ChartDataSearch`
+3. Установить параметры поиска
+4. Для специальных графиков настроить даты:
+ - `plan_completed_this_day`: с начала месяца
+ - `plan_completed_this_month`: весь месяц
+ - `write_offs`: с начала месяца
+5. Получить данные через `ChartDataSearch->search()`
+6. Обработать данные в зависимости от типа графика:
+ - Для большинства: собрать массивы dates и data
+ - Для составных (fot, avg_sales_value): использовать пары значений
+ - Для user_bonus: группировать по 4
+ - Для matrix_sales_sum: группировать по 3
+7. Настроить опции графика (заголовок, оси)
+8. Вернуть JSON
+
+**Последовательность выполнения:**
+
+```mermaid
+sequenceDiagram
+ participant Client
+ participant Controller
+ participant ChartDataSearch
+ participant Database
+
+ Client->>Controller: POST {mode, attribute, dates, etc}
+ Controller->>ChartDataSearch: new ChartDataSearch()
+ Controller->>ChartDataSearch: Установить параметры
+
+ alt attribute == 'plan_completed_this_day'
+ Controller->>Controller: date_start = первый день месяца
+ Controller->>Controller: date_end = сегодня
+ else attribute == 'plan_completed_this_month'
+ Controller->>Controller: date_start = первый день месяца
+ Controller->>Controller: date_end = последний день месяца
+ else attribute == 'write_offs'
+ Controller->>Controller: date_start = первый день месяца
+ end
+
+ ChartDataSearch->>Database: Запрос данных
+ Database-->>ChartDataSearch: Результаты
+ ChartDataSearch-->>Controller: Массив данных
+
+ alt attribute == 'sales'
+ Controller->>Controller: data[value], data[plan]
+ else attribute == 'avg_sales_value'
+ Controller->>Controller: data[value] / data[count]
+ else attribute == 'fot'
+ Controller->>Controller: data[sales] / data[fot] * 100
+ else attribute == 'write_offs'
+ Controller->>Controller: Накопительный расчёт
+ else attribute == 'user_bonus'
+ Controller->>Controller: Группировка по 4
+ else attribute == 'matrix_sales_sum'
+ Controller->>Controller: Группировка по 3
+ end
+
+ Controller->>Controller: Настройка chart_opts
+ Controller-->>Client: JSON {chart_opts, data_answer}
+```
+
+**Пример использования:**
+
+```javascript
+// AJAX запрос для графика продаж
+$.ajax({
+ url: '/charts-for-management/get-data-ajax',
+ type: 'POST',
+ data: {
+ mode: 3, // Магазин
+ attribute: 'sales', // Продажи
+ date_start: '2025-01-01',
+ date_end: '2025-01-31',
+ cluster: null,
+ store: 5, // ID магазина
+ shift: 3 // День+Ночь
+ },
+ success: function(response) {
+ const chartData = JSON.parse(response);
+ renderChart(chartData.chart_opts, chartData.data_answer);
+ }
+});
+```
+
+**Особенности обработки данных:**
+
+**Для sales:**
+```php
+$data_answer['attribute']['sales']['data'][] = $datum['value'];
+$data_answer['attribute']['plan']['data'][] = $datum['plan'];
+```
+
+**Для avg_sales_value (средний чек):**
+```php
+// Использует пары: продажи / количество продаж
+$avg_check = $data[$index]['value'] / ($data[$index + 1]['value'] ?: 1);
+```
+
+**Для write_offs (списания):**
+```php
+// Накопительный расчёт с начала месяца
+if (date('d', strtotime($datum['date'])) == 1) {
+ $temp_row['sales_sum'] = 0; // Сброс на первое число
+ $temp_row['sum'] = 0;
+}
+$temp_row['sales_sum'] += $datum['value'];
+$temp_row['sum'] += $data[$index + 1]['value'];
+```
+
+---
+
+### 5. actionWriteOffsIndex()
+
+**Назначение:** Отображение списка списаний за определённую дату
+
+**HTTP метод:** GET
+**Маршрут:** `/charts-for-management/write-offs-index`
+
+**Параметры:**
+
+| Параметр | Тип | Обязательный | Описание |
+|----------|-----|--------------|----------|
+| date | string | Да | Дата списаний (Y-m-d) |
+| cluster_id | int | Нет | ID куста для фильтрации |
+| store_id | int | Нет | ID магазина для фильтрации |
+
+**Возвращает:**
+- Success: `string` (HTML страница со списком списаний)
+
+**Бизнес-логика:**
+
+1. Создать запрос `WriteOffs` с JOIN:
+ - `export_import_table` - для связи с магазинами
+ - `city_store` - для получения названий
+ - `store_dynamic` - для связи с кустами
+2. Фильтры:
+ - Дата списания = $date
+ - Тип списания = 'Брак'
+ - Опционально: cluster_id, store_id
+3. Сортировка: куст → магазин → дата
+4. Создать ArrayDataProvider
+5. Отрендерить представление
+
+**Последовательность выполнения:**
+
+```mermaid
+sequenceDiagram
+ participant User
+ participant Controller
+ participant WriteOffs
+ participant ExportImportTable
+ participant CityStore
+ participant StoreDynamic
+ participant View
+
+ User->>Controller: GET /write-offs-index?date=2025-01-15&store_id=5
+
+ Controller->>WriteOffs: find()
+ WriteOffs->>ExportImportTable: innerJoin
+ ExportImportTable->>CityStore: innerJoin
+ CityStore->>StoreDynamic: innerJoin
+
+ Controller->>WriteOffs: andWhere(['date' => date])
+ Controller->>WriteOffs: andWhere(['type' => 'Брак'])
+ Controller->>WriteOffs: andFilterWhere(['store_id' => store_id])
+
+ WriteOffs-->>Controller: Массив списаний
+
+ Controller->>Controller: ArrayDataProvider
+ Controller->>View: render('write-offs')
+ View-->>User: HTML таблица списаний
+```
+
+**Пример использования:**
+
+```php
+// GET /charts-for-management/write-offs-index?date=2025-01-15&store_id=5
+public function actionWriteOffsIndex($date, $cluster_id = null, $store_id = null)
+{
+ $query_write_offs = WriteOffs::find()
+ ->select([
+ 'store_name' => 'city_store.name',
+ 'sum' => 'write_offs.summ',
+ 'date' => 'write_offs.date',
+ 'number' => 'write_offs.number',
+ 'comment' => 'write_offs.comment'
+ ])
+ ->innerJoin('export_import_table', ...)
+ ->innerJoin('city_store', ...)
+ ->innerJoin('store_dynamic', ...)
+ ->andWhere(['date' => $date])
+ ->andWhere(['type' => 'Брак'])
+ ->andFilterWhere([
+ 'store_dynamic.value_int' => $cluster_id,
+ 'city_store.id' => $store_id
+ ]);
+
+ $dataProvider = new ArrayDataProvider([
+ 'allModels' => $query_write_offs->asArray()->all()
+ ]);
+
+ return $this->render('write-offs', [
+ 'dataProvider' => $dataProvider,
+ 'date' => $date
+ ]);
+}
+```
+
+**Пример HTTP запроса:**
+
+```bash
+curl -X GET "https://erp24.example.com/charts-for-management/write-offs-index?date=2025-01-15&store_id=5" \
+ -H "Cookie: session_id=..."
+```
+
+**Пример ответа (HTML таблица):**
+
+| Магазин | Сумма | Дата | Номер | Комментарий |
+|---------|-------|------|-------|-------------|
+| Магазин 5 | 1500.00 | 2025-01-15 | WO-123 | Истёк срок годности |
+| Магазин 5 | 800.00 | 2025-01-15 | WO-124 | Повреждена упаковка |
+
+---
+
+## 💾 Работа с данными
+
+### Используемые модели
+
+#### Admin
+**Путь:** `yii_app\records\Admin`
+**Таблица БД:** `admin`
+
+**Основные поля:**
+- `id` — ID пользователя
+- `group_id` — ID группы (роль)
+- `store_arr` — Список доступных магазинов (строка с разделителями)
+
+**Связи:**
+- `hasMany(StoreDynamic)` через `store_arr`
+
+**Использование:**
+- Проверка прав доступа
+- Получение доступных магазинов пользователя
+
+---
+
+#### ChartDataSearch
+**Путь:** `yii_app\records\ChartDataSearch`
+**Таблица БД:** Не привязана к конкретной таблице (агрегатор)
+
+**Основные свойства:**
+- `mode_level` — Уровень просмотра (1-Розница, 2-Куст, 3-Магазин)
+- `mode_shift` — Смена (1-День, 2-Ночь, 3-День+Ночь)
+- `attribute_name` — Тип графика
+- `date_start` — Дата начала
+- `date_end` — Дата окончания
+- `cluster_id` — ID куста
+- `store_id` — ID магазина
+- `select_cluster` — Флаг выбора куста
+
+**Методы:**
+- `search()` — Основной метод поиска данных для графиков
+- `searchWriteOffsItems()` — Поиск данных списаний по позициям
+- `sortCountSalesInHour()` — Сортировка продаж по часам
+
+**Конфигурация attributes_config:**
+```php
+'attributes_config' => [
+ 'sales' => [
+ 'attribute' => [
+ 'sales' => ['data' => []],
+ 'plan' => ['data' => []]
+ ]
+ ],
+ 'fot' => [
+ 'attribute' => [
+ 'fot' => ['data' => []],
+ 'plan' => ['data' => []]
+ ]
+ ],
+ // ... и т.д.
+]
+```
+
+---
+
+#### WriteOffs
+**Путь:** `yii_app\records\WriteOffs`
+**Таблица БД:** `write_offs`
+
+**Основные поля:**
+- `id` — ID списания
+- `store_id` — ID магазина (внешний ключ через export_import_table)
+- `date` — Дата списания
+- `summ` — Сумма списания
+- `type` — Тип списания ('Брак', 'Уценка' и т.д.)
+- `number` — Номер документа списания
+- `comment` — Комментарий
+
+**Связи:**
+- `hasOne(CityStore)` через `export_import_table`
+- `hasOne(StoreDynamic)` для получения куста
+
+---
+
+### SQL запросы
+
+#### Запрос магазинов пользователя с историей кустов
+
+```sql
+SELECT
+ CONCAT_WS(' ', 'Куст', store_dynamic.value_int) AS cluster_name,
+ store_dynamic.value_int AS cluster_id,
+ store_dynamic.store_id,
+ city_store.name AS store_name,
+ TO_CHAR(store_dynamic.date_from::timestamp, '%Y-%m-%d') AS date_from
+FROM admin
+LEFT JOIN store_dynamic ON (
+ admin.store_arr LIKE CONCAT('%,', store_dynamic.store_id, ',%')
+ OR admin.store_arr LIKE CONCAT('', store_dynamic.store_id, ',%')
+ OR admin.store_arr LIKE CONCAT('%,', store_dynamic.store_id, '')
+ OR admin.store_arr LIKE CONCAT('', store_dynamic.store_id, '')
+)
+INNER JOIN city_store ON store_dynamic.store_id = city_store.id
+ AND store_dynamic.category = 1
+WHERE admin.id = :user_id
+AND (
+ (store_dynamic.date_from BETWEEN :date_start AND :date_end
+ AND store_dynamic.date_to BETWEEN :date_start AND :date_end)
+ OR (store_dynamic.date_from BETWEEN :date_start AND :date_end
+ AND store_dynamic.date_to >= :date_end)
+ OR (store_dynamic.date_from <= :date_start
+ AND store_dynamic.date_to BETWEEN :date_start AND :date_end)
+ OR (store_dynamic.date_from <= :date_start
+ AND store_dynamic.date_to >= :date_start)
+)
+ORDER BY store_dynamic.value_int ASC, city_store.id ASC
+```
+
+#### Запрос списаний
+
+```sql
+SELECT
+ city_store.name AS store_name,
+ write_offs.summ AS sum,
+ write_offs.date,
+ write_offs.number,
+ write_offs.comment
+FROM write_offs
+INNER JOIN export_import_table ON export_import_table.export_val = write_offs.store_id
+INNER JOIN city_store ON city_store.id = export_import_table.entity_id
+INNER JOIN store_dynamic ON (
+ store_dynamic.store_id = city_store.id
+ AND TO_CHAR(write_offs.date::timestamp, '%Y-%m-%d') >= TO_CHAR(store_dynamic.date_from::timestamp, '%Y-%m-%d')
+ AND TO_CHAR(write_offs.date::timestamp, '%Y-%m-%d') < TO_CHAR(store_dynamic.date_to::timestamp, '%Y-%m-%d')
+)
+WHERE export_import_table.entity = 'city_store'
+AND store_dynamic.value_int = :cluster_id -- опционально
+AND city_store.id = :store_id -- опционально
+AND TO_CHAR(write_offs.date::timestamp, '%Y-%m-%d') = :date
+AND write_offs.type = 'Брак'
+ORDER BY store_dynamic.value_int ASC, city_store.id ASC, write_offs.date ASC
+```
+
+---
+
+## 🔐 Права доступа (RBAC)
+
+### Константы группы Admin
+
+```php
+const ADMINISTRATOR_GROUP_ID = 50; // Администратор магазина
+const CLUSTER_MANAGER_GROUP_ID = 7; // Менеджер куста
+```
+
+### Проверки доступа
+
+```php
+// Топ-менеджмент: полный доступ
+if (in_array($admin->group_id, [1, 81, 71, 51, 10, 9, 74, 14])) {
+ // Доступ: розница, куст, магазин
+ // Смены: все
+}
+
+// Менеджер куста: куст и магазины
+else if (in_array($admin->group_id, [Admin::CLUSTER_MANAGER_GROUP_ID])) {
+ // Доступ: куст, магазин
+ // Смены: все
+}
+
+// Менеджер магазина (день): только свой магазин
+else if (in_array($admin->group_id, [30, 40])) {
+ // Доступ: магазин
+ // Смены: день
+ // Нет доступа к планам
+}
+
+// Менеджер магазина (ночь): только свой магазин
+else if (in_array($admin->group_id, [35, 72])) {
+ // Доступ: магазин
+ // Смены: ночь
+ // Нет доступа к планам
+}
+
+// Администратор: свой магазин, все смены
+else if (in_array($admin->group_id, [Admin::ADMINISTRATOR_GROUP_ID])) {
+ // Доступ: магазин
+ // Смены: все
+}
+
+// Группа не распознана
+else {
+ throw new Exception('Нет доступа');
+}
+```
+
+---
+
+## ⚠️ Обработка ошибок
+
+### Типичные ошибки
+
+| Ошибка | Код | Причина | Решение |
+|--------|-----|---------|---------|
+| "Нет доступа" | 500 | Группа пользователя не распознана | Проверить group_id, добавить группу в условия |
+| Пустой ответ (-1) | 200 | Нет данных для графика write_offs_position | Проверить наличие данных в ChartDataSearch |
+| Division by zero | 500 | Деление на 0 в расчётах (plan, count) | Использовать `?: 1` для защиты |
+
+### Exception handling
+
+```php
+try {
+ // Проверка группы пользователя
+ if (!in_array($admin->group_id, [/* allowed groups */])) {
+ throw new Exception('Нет доступа');
+ }
+
+ // Расчёты с защитой от деления на 0
+ $avg_check = $sales / ($count ?: 1);
+
+} catch (Exception $e) {
+ Yii::error($e->getMessage());
+ return $this->render('error', ['message' => $e->getMessage()]);
+}
+```
+
+---
+
+## 📊 Типы графиков и их алгоритмы
+
+### 1. Продажи (sales)
+
+**Показатели:** продажи, план
+**Формула:** прямые значения из БД
+
+```php
+foreach ($data as $datum) {
+ $dates[] = $datum['date'];
+ $data_answer['sales']['data'][] = $datum['value'];
+ $data_answer['plan']['data'][] = $datum['plan'];
+}
+```
+
+---
+
+### 2. Средний чек (avg_sales_value)
+
+**Показатели:** средний чек, план
+**Формула:** `avg_check = sales_sum / sales_count`
+
+```php
+// Использует пары значений: чётные - продажи, нечётные - количество
+if ($step % 2 != 0) {
+ $step++;
+ continue;
+}
+
+$dates[] = $datum['date'];
+$avg_check = $datum['value'] / ($data[$index + 1]['value'] ?: 1);
+$data_answer['avg_check']['data'][] = $avg_check;
+$data_answer['plan']['data'][] = $datum['plan'];
+$step++;
+```
+
+---
+
+### 3. ФОТ (fot)
+
+**Показатели:** ФОТ в процентах, план
+**Формула:** `fot_percent = fot_sum / (sales_sum / 100)`
+
+```php
+// Использует пары: чётные - продажи, нечётные - ФОТ
+if ($step % 2 != 0) {
+ $step++;
+ continue;
+}
+
+$dates[] = $datum['date'];
+$fot_percent = $datum['value'] / (($data[$index + 1]['value'] ?: 1) / 100);
+$data_answer['fot']['data'][] = $fot_percent;
+$data_answer['plan']['data'][] = $datum['plan'];
+$step++;
+```
+
+---
+
+### 4. Списания (write_offs)
+
+**Показатели:** сумма списаний, процент за день, процент накопительный
+**Формула:**
+- `percent_day = write_offs / (sales / 100)`
+- `percent_cumulative = cumulative_write_offs / (cumulative_sales / 100)`
+
+```php
+// Накопительный расчёт с начала месяца
+if ($step % 2 != 0) {
+ $step++;
+ continue;
+}
+
+$step++;
+
+// Сброс накопления на первое число месяца
+if (date('d', strtotime($datum['date'])) == 1) {
+ $temp_row['sales_sum'] = 0;
+ $temp_row['sum'] = 0;
+}
+
+$temp_row['sales_sum'] += $datum['value']; // Продажи
+$temp_row['sum'] += $data[$index + 1]['value']; // Списания
+
+$dates[] = $datum['date'];
+$data_answer['write_offs_sum']['data'][] = $data[$index + 1]['value'];
+$data_answer['write_offs_percent_day']['data'][] =
+ ($data[$index + 1]['value'] ?? 0) / (($datum['value'] ?: 1) / 100);
+$data_answer['write_offs_percent_cumulative']['data'][] =
+ $temp_row['sum'] / ((($temp_row['sales_sum'] ?: 1) / 100));
+```
+
+---
+
+### 5. Продажи на администратора (sales_sum_on_admin)
+
+**Показатели:** продажи на одного сотрудника
+**Формула:** `sales_per_admin = sales_sum / admin_count`
+
+```php
+// Использует пары: чётные - продажи, нечётные - количество администраторов
+if ($step % 2 != 0) {
+ $step++;
+ continue;
+}
+
+$step++;
+$dates[] = $datum['date'];
+$sales_per_admin = $datum['value'] / ($data[$index + 1]['value'] ?: 1);
+$data_answer['sales_per_admin']['data'][] = $sales_per_admin;
+```
+
+---
+
+### 6. Бонусы пользователей (user_bonus)
+
+**Показатели:** 4 типа бонусов
+**Группировка:** каждые 4 значения на одну дату
+
+```php
+// Каждые 4 значения - один набор бонусов
+if ($step % 4 == 0) {
+ // Добавляем 3 копии даты с разными бонусами
+ for ($i = 0; $i < 3; $i++) {
+ $dates[] = $datum['date'];
+ // Заполняем 4 типа бонусов
+ }
+
+ $data_answer['bonus_type_1']['data'][] = $data[$index]['value'];
+ $data_answer['bonus_type_2']['data'][] = $data[$index + 1]['value'];
+ $data_answer['bonus_type_3']['data'][] = $data[$index + 2]['value'];
+ $data_answer['bonus_type_4']['data'][] = $data[$index + 3]['value'];
+}
+
+$step++;
+```
+
+---
+
+### 7. Продажи матрицы (matrix_sales_sum)
+
+**Показатели:** букеты, растения, готовые товары
+**Группировка:** каждые 3 значения на одну дату
+
+```php
+// Каждые 3 значения - продажи по категориям матрицы
+if ($step % 3 == 0) {
+ $dates[] = $datum['date'];
+ $data_answer['bouquet']['data'][] = $data[$index]['value'];
+ $data_answer['plant']['data'][] = $data[$index + 1]['value'];
+ $data_answer['ready_goods']['data'][] = $data[$index + 2]['value'];
+}
+
+$step++;
+```
+
+---
+
+### 8. Количество продаж по часам (count_sales_in_hour)
+
+**Показатели:** продажи по часам, среднее за период
+**Оси X:** часы в зависимости от смены
+
+```php
+foreach ($data as $datum) {
+ $data_answer['sales_count']['data'][] = $datum['value'];
+ $data_answer['sales_avg']['data'][] = $datum['value'] / $interval_days;
+}
+
+// Настройка оси X в зависимости от смены
+if ($mode_shift === 3) { // День+Ночь
+ $categories = array_merge(range(8, 23), range(0, 7));
+} else if ($mode_shift === 1) { // День
+ $categories = range(8, 19);
+} else if ($mode_shift === 2) { // Ночь
+ $categories = array_merge(range(20, 23), range(0, 7));
+}
+
+// Сортировка данных по часам
+ChartDataSearch::sortCountSalesInHour($data_answer, $mode_shift);
+```
+
+---
+
+### 9. Выполнение плана за день (plan_completed_this_day)
+
+**Показатели:** процент выполнения плана
+**Формула:** `percent = sales_sum / plan * 100`
+**Период:** с начала месяца до сегодня
+
+```php
+$temp_row = ['sales_sum' => 0, 'plan' => 0];
+
+foreach ($data as $datum) {
+ $temp_row['sales_sum'] += $datum['value'];
+ $temp_row['plan'] += $datum['plan'];
+}
+
+$percent = round(
+ $temp_row['sales_sum'] / ($temp_row['plan'] ?: 1) * 100,
+ 2
+);
+
+$data_answer['plan_complete_on_this_day'] = $percent;
+```
+
+---
+
+### 10. Прогноз выполнения плана за месяц (plan_completed_this_month)
+
+**Показатели:** гипотетический процент выполнения
+**Формула:** `percent = (sales_sum / current_day * total_days) / plan * 100`
+**Период:** весь месяц
+
+```php
+$temp_row = ['sales_sum' => 0, 'plan' => 0];
+
+foreach ($data as $datum) {
+ $temp_row['sales_sum'] += $datum['value'];
+ $temp_row['plan'] += $datum['plan'];
+}
+
+$current_day = date('d', time());
+$total_days = date('d', strtotime('last day of this month'));
+
+$hypothesis_percent = round(
+ $temp_row['sales_sum'] / $current_day * $total_days / ($temp_row['plan'] ?: 1) * 100,
+ 2
+);
+
+$data_answer['plan_hypothesis_complete_on_this_month'] = $hypothesis_percent;
+```
+
+---
+
+## 🧪 Примеры использования
+
+### Сценарий 1: Получение графика продаж для магазина
+
+**Описание:** Менеджер магазина хочет посмотреть график продаж за последние 2 недели
+
+**Шаги:**
+1. Открыть страницу `/charts-for-management/index`
+2. Выбрать график "Продажи"
+3. Выбрать уровень "Магазин"
+4. Выбрать свой магазин
+5. Выбрать период: последние 14 дней
+6. AJAX запрос на получение данных
+7. Отображение графика
+
+**Код (JavaScript):**
+
+```javascript
+// 1. Открыть модальное окно графика продаж
+$('#sales-chart-modal').modal('show');
+
+// 2. Настройка параметров
+const params = {
+ mode: 3, // Магазин
+ attribute: 'sales', // Продажи
+ date_start: '2025-01-01',
+ date_end: '2025-01-14',
+ cluster: null,
+ store: 5, // ID магазина
+ shift: 3 // День+Ночь
+};
+
+// 3. AJAX запрос
+$.ajax({
+ url: '/charts-for-management/get-data-ajax',
+ type: 'POST',
+ data: params,
+ success: function(response) {
+ const chartData = JSON.parse(response);
+
+ // 4. Отображение графика (ApexCharts)
+ const options = {
+ series: Object.values(chartData.data_answer.attribute),
+ chart: {
+ type: 'line',
+ height: 350
+ },
+ xaxis: chartData.chart_opts.xaxis,
+ title: chartData.chart_opts.title
+ };
+
+ const chart = new ApexCharts(document.querySelector("#chart"), options);
+ chart.render();
+ }
+});
+```
+
+---
+
+### Сценарий 2: Получение списка списаний за день
+
+**Описание:** Топ-менеджер хочет посмотреть все списания по типу "Брак" за определённую дату
+
+**Шаги:**
+1. Кликнуть на точку графика списаний
+2. Передать дату в параметре
+3. Открыть страницу со списком списаний
+
+**Код (JavaScript):**
+
+```javascript
+// Обработчик клика на график списаний
+chart.on('dataPointSelection', function(event, chartContext, config) {
+ const date = config.w.config.xaxis.categories[config.dataPointIndex];
+ const store_id = $('#store-select').val();
+
+ // Переход на страницу списаний
+ window.location.href = `/charts-for-management/write-offs-index?date=${date}&store_id=${store_id}`;
+});
+```
+
+---
+
+### Сценарий 3: Получение данных для графика ФОТ по кусту
+
+**Описание:** Менеджер куста хочет проанализировать ФОТ по своему кусту за месяц
+
+**Шаги:**
+1. Выбрать график "ФОТ"
+2. Выбрать уровень "Куст"
+3. Выбрать свой куст
+4. Выбрать период: текущий месяц
+5. Получить данные
+
+**Код (JavaScript):**
+
+```javascript
+const params = {
+ mode: 2, // Куст
+ attribute: 'fot', // ФОТ
+ date_start: '2025-01-01', // Начало месяца
+ date_end: '2025-01-31', // Конец месяца
+ cluster: 3, // ID куста
+ store: null,
+ shift: 3 // День+Ночь
+};
+
+$.ajax({
+ url: '/charts-for-management/get-data-ajax',
+ type: 'POST',
+ data: params,
+ success: function(response) {
+ const chartData = JSON.parse(response);
+
+ console.log('График ФОТ для куста:', chartData.chart_opts.title.text);
+ console.log('ФОТ:', chartData.data_answer.attribute.fot.data);
+ console.log('План:', chartData.data_answer.attribute.plan.data);
+
+ renderChart(chartData);
+ }
+});
+```
+
+---
+
+### Сценарий 4: Получение управляющих данных (кусты и магазины)
+
+**Описание:** При загрузке страницы нужно получить список доступных кустов и магазинов пользователя
+
+**Код (JavaScript):**
+
+```javascript
+// При загрузке страницы
+$(document).ready(function() {
+ $.ajax({
+ url: '/charts-for-management/get-control-data-ajax',
+ type: 'POST',
+ data: {
+ date_start: '2025-01-01',
+ date_end: '2025-01-31'
+ },
+ success: function(response) {
+ const data = JSON.parse(response);
+
+ // Заполнить select кустов
+ const clusterSelect = $('#cluster-select');
+ clusterSelect.empty();
+ clusterSelect.append('<option value="">Все кусты</option>');
+ data.clusters.forEach(function(cluster) {
+ clusterSelect.append(`<option value="${cluster.id}">${cluster.text}</option>`);
+ });
+
+ // Заполнить select магазинов (с группировкой по кустам)
+ const storeSelect = $('#store-select');
+ storeSelect.empty();
+ storeSelect.append('<option value="">Все магазины</option>');
+
+ for (const clusterId in data.stores_in_cluster) {
+ const cluster = data.stores_in_cluster[clusterId];
+ const optgroup = $('<optgroup>').attr('label', cluster.text);
+
+ cluster.children.forEach(function(store) {
+ optgroup.append(`<option value="${store.id}">${store.text}</option>`);
+ });
+
+ storeSelect.append(optgroup);
+ }
+
+ // Показать предупреждение о магазинах, меняющих куст
+ if (Object.keys(data.stores_step).length > 0) {
+ console.warn('Магазины, меняющие куст в периоде:', data.stores_step);
+ }
+ }
+ });
+});
+```
+
+---
+
+## ❓ FAQ
+
+### Вопрос 1: Почему некоторые группы пользователей не видят графики планов?
+
+**Ответ:** Группы 30, 40, 35, 72 (менеджеры магазинов) не имеют доступа к графикам `plan_completed_this_day` и `plan_completed_this_month`. Это сделано намеренно, чтобы ограничить доступ к плановым показателям на уровне магазинов.
+
+```php
+if (in_array($admin->group_id, [30, 40])) {
+ // ...
+ if ($key != 'plan_completed_this_day' && $key != 'plan_completed_this_month') {
+ $item['visible'] = true;
+ }
+}
+```
+
+---
+
+### Вопрос 2: Как работает защита от деления на ноль в расчётах?
+
+**Ответ:** Во всех формулах используется тернарный оператор `?: 1`, который заменяет 0 на 1:
+
+```php
+$avg_check = $sales / ($count ?: 1); // Если $count = 0, то используется 1
+$fot_percent = $fot / (($sales ?: 1) / 100);
+```
+
+---
+
+### Вопрос 3: Почему для некоторых графиков используется `$step % 2`, `$step % 3`, `$step % 4`?
+
+**Ответ:** Некоторые графики используют составные данные:
+
+- **% 2** (avg_sales_value, fot, sales_sum_on_admin, write_offs): пары значений (продажи + количество)
+- **% 3** (matrix_sales_sum): тройки значений (букеты + растения + готовые товары)
+- **% 4** (user_bonus): четвёрки значений (4 типа бонусов)
+
+Это позволяет обрабатывать несколько связанных значений как одну точку на графике.
+
+---
+
+### Вопрос 4: Как работает накопительный расчёт в графике списаний?
+
+**Ответ:** График списаний показывает накопительный процент с начала месяца:
+
+1. На первое число месяца накопители сбрасываются
+2. Каждый день добавляются продажи и списания
+3. Рассчитывается накопительный процент: `cumulative_write_offs / cumulative_sales * 100`
+
+```php
+if (date('d', strtotime($datum['date'])) == 1) {
+ $temp_row['sales_sum'] = 0; // Сброс
+ $temp_row['sum'] = 0;
+}
+$temp_row['sales_sum'] += $datum['value'];
+$temp_row['sum'] += $data[$index + 1]['value'];
+```
+
+---
+
+### Вопрос 5: Почему для графика `count_sales_in_hour` разные категории оси X?
+
+**Ответ:** Категории оси X зависят от выбранной смены:
+
+- **День+Ночь (3):** 8-23, 0-7 (все 24 часа)
+- **День (1):** 8-19 (12 часов)
+- **Ночь (2):** 20-23, 0-7 (12 часов)
+
+Это позволяет отображать только релевантные часы для каждой смены.
+
+---
+
+### Вопрос 6: Что такое `store_arr` в модели Admin и как он работает?
+
+**Ответ:** `store_arr` — это строка с ID магазинов, разделённых запятыми:
+
+```
+,1,5,8,12,
+```
+
+В SQL используется LIKE для поиска:
+
+```sql
+admin.store_arr LIKE '%,5,%' -- Магазин 5 в середине
+admin.store_arr LIKE '5,%' -- Магазин 5 в начале
+admin.store_arr LIKE '%,5' -- Магазин 5 в конце
+admin.store_arr LIKE '5' -- Только магазин 5
+```
+
+---
+
+### Вопрос 7: Почему `actionGetDataAjax()` возвращает `-1`?
+
+**Ответ:** Возвращается `-1`, если для графика `write_offs_position` нет данных:
+
+```php
+if ($post_data['attribute'] === 'write_offs_position') {
+ if (!isset($answer_query[1])) {
+ return -1; // Нет данных
+ }
+}
+```
+
+---
+
+## 🔗 Связанные компоненты
+
+- [Admin](../../models/Admin.md) — Модель пользователей
+- [ChartDataSearch](../../models/ChartDataSearch.md) — Поиск данных графиков
+- [WriteOffs](../../models/WriteOffs.md) — Модель списаний
+- [StoreDynamic](../../models/StoreDynamic.md) — История изменений кустов
+- [CityStore](../../models/CityStore.md) — Модель магазинов
+
+---
+
+## 📝 История изменений
+
+| Дата | Версия | Изменения |
+|------|--------|-----------|
+| 2025-11-26 | 1.0 | Первая версия документации |
+
+---
+
+**Документация создана:** Claude Code + Hive Mind Controllers Swarm
+**Дата:** 2025-11-26
+**Фаза:** 2 (Крупные контроллеры)
+**Статус:** ✅ Готово
--- /dev/null
+# ChartsForManagementController - Краткая справка
+
+## 📌 Основная информация
+
+**Контроллер:** `ChartsForManagementController`
+**Путь:** `erp24/controllers/ChartsForManagementController.php`
+**Размер:** 609 строк
+**Actions:** 5
+**Категория:** Крупный контроллер (Фаза 2)
+
+---
+
+## 🎯 Назначение в одном предложении
+
+Контроллер для отображения аналитических графиков (продажи, ФОТ, списания, бонусы) с гранулярным RBAC-контролем доступа по группам пользователей, уровням (розница/куст/магазин) и сменам.
+
+---
+
+## 🔑 Ключевые особенности
+
+✅ **10 типов графиков:** продажи, планы, ФОТ, списания, бонусы, средний чек, продажи матрицы, по часам
+✅ **RBAC система:** 6 групп пользователей с разными уровнями доступа
+✅ **3 уровня просмотра:** розница (вся сеть), куст, магазин
+✅ **3 типа смен:** день (8-19), ночь (20-7), день+ночь (24 часа)
+✅ **AJAX получение данных:** динамическая загрузка графиков
+✅ **История изменений:** отслеживание перехода магазинов между кустами
+
+---
+
+## 📋 Actions (5)
+
+| Action | Метод | Назначение |
+|--------|-------|------------|
+| `actionIndex()` | GET | Главная страница графиков |
+| `actionWriteOffPosition()` | GET | Страница графика списаний |
+| `actionGetControlDataAjax()` | POST | Получение кустов/магазинов (AJAX) |
+| `actionGetDataAjax()` | POST | Получение данных графиков (AJAX) |
+| `actionWriteOffsIndex()` | GET | Список списаний за дату |
+
+---
+
+## 🔐 RBAC: Группы пользователей
+
+| Group ID | Роль | Уровни | Смены | Доступ к планам |
+|----------|------|--------|-------|-----------------|
+| 1, 81, 71, 51, 10, 9, 74, 14 | **Топ-менеджмент** | 1, 2, 3 | 1, 2, 3 | ✅ |
+| 7 | **Менеджер куста** | 2, 3 | 1, 2, 3 | ✅ |
+| 30, 40 | **Менеджер магазина (день)** | 3 | 1 | ❌ |
+| 35, 72 | **Менеджер магазина (ночь)** | 3 | 2 | ❌ |
+| 50 | **Администратор** | 3 | 1, 2, 3 | ✅ |
+
+**Уровни (mode_level):**
+- `1` — Розница (вся сеть)
+- `2` — Куст
+- `3` — Магазин
+
+**Смены (mode_shift):**
+- `1` — День (8:00-19:59)
+- `2` — Ночь (20:00-7:59)
+- `3` — День+Ночь (24 часа)
+
+---
+
+## 📊 Типы графиков (10)
+
+| Attribute | Название | Показатели | Формулы |
+|-----------|----------|------------|---------|
+| `sales` | Продажи | продажи, план | прямые значения |
+| `plan_completed_this_day` | План за день | процент | `sales_sum / plan * 100` |
+| `plan_completed_this_month` | План за месяц | процент прогноз | `(sales / day * days) / plan * 100` |
+| `avg_sales_value` | Средний чек | средний чек, план | `sales_sum / sales_count` |
+| `fot` | ФОТ | ФОТ %, план | `fot_sum / (sales / 100)` |
+| `sales_sum_on_admin` | Продажи на админа | продажи на 1 админа | `sales / admin_count` |
+| `write_offs` | Списания | сумма, %, накопительный % | `write_offs / (sales / 100)` |
+| `user_bonus` | Бонусы | 4 типа бонусов | группировка по 4 |
+| `matrix_sales_sum` | Продажи матрицы | букеты, растения, готовые | группировка по 3 |
+| `count_sales_in_hour` | Продажи по часам | продажи, среднее | `sales_hour / days` |
+
+---
+
+## 🗂️ Используемые модели
+
+| Модель | Таблица | Назначение |
+|--------|---------|------------|
+| **Admin** | `admin` | Проверка прав, получение магазинов |
+| **ChartDataSearch** | - | Поиск данных для графиков (агрегатор) |
+| **WriteOffs** | `write_offs` | Получение списаний |
+| **StoreDynamic** | `store_dynamic` | История кустов магазинов |
+| **CityStore** | `city_store` | Информация о магазинах |
+
+---
+
+## 🔄 Основные процессы
+
+### 1. Проверка доступа (actionIndex)
+
+```mermaid
+graph LR
+ A[Получить Admin] --> B{Проверить group_id}
+ B -->|Топ-менеджмент| C[Полный доступ]
+ B -->|Менеджер куста| D[Куст + Магазин]
+ B -->|Менеджер магазина| E[Только магазин]
+ B -->|Не распознана| F[Exception: Нет доступа]
+```
+
+### 2. Получение данных графика (actionGetDataAjax)
+
+```mermaid
+graph TB
+ A[POST запрос] --> B[ChartDataSearch]
+ B --> C{Тип графика}
+ C -->|sales| D[Прямые значения]
+ C -->|avg_sales_value| E[Расчёт среднего]
+ C -->|fot| F[Расчёт ФОТ %]
+ C -->|write_offs| G[Накопительный расчёт]
+ D --> H[JSON ответ]
+ E --> H
+ F --> H
+ G --> H
+```
+
+---
+
+## 💡 Быстрые примеры
+
+### Получение данных графика продаж
+
+```javascript
+$.ajax({
+ url: '/charts-for-management/get-data-ajax',
+ type: 'POST',
+ data: {
+ mode: 3, // Магазин
+ attribute: 'sales', // Продажи
+ date_start: '2025-01-01',
+ date_end: '2025-01-31',
+ store: 5,
+ shift: 3
+ },
+ success: function(response) {
+ const data = JSON.parse(response);
+ console.log(data.data_answer.attribute.sales.data);
+ }
+});
+```
+
+### Получение кустов и магазинов
+
+```javascript
+$.post('/charts-for-management/get-control-data-ajax', {
+ date_start: '2025-01-01',
+ date_end: '2025-01-31'
+}, function(response) {
+ const data = JSON.parse(response);
+ console.log('Кусты:', data.clusters);
+ console.log('Магазины:', data.stores_in_cluster);
+});
+```
+
+### Открыть список списаний
+
+```bash
+# GET запрос
+curl "https://erp24.example.com/charts-for-management/write-offs-index?date=2025-01-15&store_id=5"
+```
+
+---
+
+## ⚠️ Особенности и ограничения
+
+### Защита от деления на ноль
+
+```php
+$avg_check = $sales / ($count ?: 1); // Если $count = 0, то = 1
+```
+
+### Автоматическая установка дат
+
+- **plan_completed_this_day:** с начала месяца до сегодня
+- **plan_completed_this_month:** весь текущий месяц
+- **write_offs:** с начала месяца переданного date_start
+
+### Группировка данных
+
+- **% 2** (пары): avg_sales_value, fot, sales_sum_on_admin, write_offs
+- **% 3** (тройки): matrix_sales_sum (букеты, растения, готовые)
+- **% 4** (четвёрки): user_bonus (4 типа бонусов)
+
+### Накопительный расчёт (write_offs)
+
+```php
+// Сброс на первое число месяца
+if (date('d', strtotime($date)) == 1) {
+ $cumulative_sales = 0;
+ $cumulative_write_offs = 0;
+}
+$cumulative_sales += $sales;
+$cumulative_write_offs += $write_offs;
+```
+
+---
+
+## 🚨 Типичные ошибки
+
+| Ошибка | Причина | Решение |
+|--------|---------|---------|
+| Exception: "Нет доступа" | group_id не распознана | Добавить группу в условия |
+| Возврат `-1` | Нет данных для write_offs_position | Проверить ChartDataSearch |
+| Division by zero | План/количество = 0 | Использовать `?: 1` |
+
+---
+
+## 📈 Формулы расчётов
+
+### Средний чек
+```php
+$avg_check = $sales_sum / ($sales_count ?: 1);
+```
+
+### ФОТ в процентах
+```php
+$fot_percent = $fot_sum / (($sales_sum ?: 1) / 100);
+```
+
+### Выполнение плана за день
+```php
+$percent = round($sales_sum / ($plan ?: 1) * 100, 2);
+```
+
+### Прогноз плана за месяц
+```php
+$current_day = date('d');
+$total_days = date('d', strtotime('last day of this month'));
+$hypothesis = round(
+ $sales_sum / $current_day * $total_days / ($plan ?: 1) * 100,
+ 2
+);
+```
+
+### Списания (процент от продаж)
+```php
+$percent_day = $write_offs / (($sales ?: 1) / 100);
+$percent_cumulative = $cumulative_write_offs / (($cumulative_sales ?: 1) / 100);
+```
+
+### Продажи на администратора
+```php
+$sales_per_admin = $sales_sum / ($admin_count ?: 1);
+```
+
+---
+
+## 🔗 Связанные компоненты
+
+- **[ChartForManagementController](./ChartForManagementController_ANALYSIS.md)** — Похожий контроллер (622 строки)
+- **[Admin](../../models/Admin.md)** — Модель пользователей
+- **[ChartDataSearch](../../models/ChartDataSearch.md)** — Поиск данных графиков
+- **[WriteOffs](../../models/WriteOffs.md)** — Модель списаний
+
+---
+
+## 📝 Полезные ссылки
+
+- [Детальный анализ контроллера](./ChartsForManagementController_ANALYSIS.md)
+- [Таблица Actions](./ChartsForManagementController_ACTIONS_TABLE.md)
+- [План документирования контроллеров](../CONTROLLERS_DOCUMENTATION_PLAN.md)
+
+---
+
+## ❓ FAQ (Топ-3)
+
+### 1. Почему менеджеры магазинов не видят графики планов?
+
+Группы 30, 40, 35, 72 имеют ограниченный доступ:
+
+```php
+if ($key != 'plan_completed_this_day' && $key != 'plan_completed_this_month') {
+ $item['visible'] = true;
+}
+```
+
+### 2. Как работает группировка данных (% 2, % 3, % 4)?
+
+Некоторые графики используют составные данные:
+- **% 2:** пары (продажи + количество)
+- **% 3:** тройки (3 категории матрицы)
+- **% 4:** четвёрки (4 типа бонусов)
+
+### 3. Почему для count_sales_in_hour разные оси X?
+
+Категории зависят от смены:
+- **День+Ночь:** 8-23, 0-7 (24 часа)
+- **День:** 8-19 (12 часов)
+- **Ночь:** 20-23, 0-7 (12 часов)
+
+---
+
+**Краткая справка создана:** 2025-11-26
+**Версия:** 1.0
+**Статус:** ✅ Готово
--- /dev/null
+# ShiftTransferController - Таблица Actions
+
+## Быстрая справка
+
+| # | Action | HTTP | Назначение | Параметры |
+|---|--------|------|------------|-----------|
+| 1 | `actionIndex()` | GET | Список передач смен | - |
+| 2 | `actionCreate()` | GET | Создание документа | - |
+| 3 | `actionUpdate($id)` | GET/POST | Редактирование | `id`, POST: модель, `action` |
+| 4 | `actionView($id)` | GET/POST | Просмотр/действия | `id`, POST: `action` |
+| 5 | `actionDelete($id)` | POST | Удаление | `id` |
+| 6 | `actionGetProductData()` | POST | AJAX: данные товара | `productGuid`, `shiftTransferId` |
+| 7 | `actionGetProductReplacementPrice()` | POST | AJAX: цена замены | `productGuid`, `shiftTransferId` |
+| 8 | `actionGetMaxQuantity()` | POST | AJAX: макс. кол-во | `productReplacementName`, `productName`, `shiftTransferId` |
+| 9 | `actionGetProductPriceSelfCostAndRemains()` | POST | AJAX: цена+остатки | `productGuid`, `storeGuid` |
+| 10 | `actionGetProductsWithRemains()` | POST | AJAX: товары с остатками | `storeGuid` |
+| 11 | `buildLoadDataShiftRemains()` | Helper | Построение данных | `groups`, `storeGuid`, `shiftDate` |
+| 12 | `isAllowedAdmin()` | Helper | Проверка прав | - |
+
+---
+
+## Статусы документа
+
+| ID | Константа | Описание |
+|----|-----------|----------|
+| 1 | `STATUS_ID_INPUT_FACT_REMAINS` | Ввод фактических остатков |
+| 2 | `STATUS_ID_TRANSFER_ACTIONS` | Действия по замене |
+| 3 | `STATUS_ID_READY_TO_ACCEPT` | Готов к приёмке |
+| 4 | `STATUS_OF_THE_FORMATION_OF_SURPLUSES_AND_SHORTAGES` | Формирование излишков/недостач |
+| 5 | `STATUS_ID_ACCEPTED` | Принят |
+
+---
+
+## actionUpdate: POST actions
+
+| Action | Описание | Что делает |
+|--------|----------|------------|
+| `applyGroups` | Применить группы товаров | Перезагрузить список товаров |
+| `save` (по умолчанию) | Сохранить остатки | Расчёт расхождений, status=2 |
+
+---
+
+## actionView: POST actions
+
+| Action | Описание | Что делает |
+|--------|----------|------------|
+| `accept` | Принять смену | status=5, архивация, формирование документов |
+| `save` | Готов к приёмке | status=3 |
+| `recalculate` | Перерасчёт замен | EqualizationRemains::setData() |
+| `rejection` | Отклонить | status=2, удалить накладные |
+| `resume` | Продолжить | status=4, создать накладные |
+
+---
+
+## AJAX API возвраты
+
+### actionGetProductData
+```json
+{
+ "success": true,
+ "product_count": 5,
+ "product_price": 1500.00,
+ "product_replacement": {"guid": "Название (арт. 123)"},
+ "product_self_cost": 800.00
+}
+```
+
+### actionGetProductReplacementPrice
+```json
+{
+ "success": true,
+ "product_price": 1500.00,
+ "product_replacement_self_cost": 800.00,
+ "maxQuantity": 10
+}
+```
+
+### actionGetMaxQuantity
+```json
+{
+ "maxValue": 5
+}
+```
+
+---
+
+## Связанные файлы
+
+- [Детальный анализ](./ShiftTransferController_ANALYSIS.md)
+- [Краткая справка](./ShiftTransferController_QUICK_REFERENCE.md)
+
+---
+
+**Документация создана:** 2025-11-26
+**Статус:** ✅ Готово
--- /dev/null
+# ShiftTransferController - Детальный анализ
+
+## 📋 Общая информация
+
+**Namespace:** `app\controllers`
+**Путь к файлу:** `erp24/controllers/ShiftTransferController.php`
+**Extends:** `\yii\web\Controller`
+**Размер:** 490 строк кода
+**Количество actions:** 12
+**Категория:** Средний контроллер с интеграциями (Фаза 3)
+**Приоритет:** MEDIUM-HIGH
+
+---
+
+## 🎯 Назначение
+
+Контроллер для управления **передачей смены между администраторами магазина** с полным циклом:
+
+- Создание документа передачи смены с фактическими остатками
+- Сравнение факта с 1С (выявление недостач/излишков)
+- Система замен недостающего товара
+- Автоматическое формирование накладных (приход/списание)
+- Принятие смены следующим администратором
+- Статусная модель документа (5 статусов)
+
+Контроллер интегрируется с:
+- 1С (остатки, цены, себестоимость)
+- Система замен товаров
+- Накладные приход/расход
+- Балансы магазина
+
+---
+
+## 🏗️ Архитектура
+
+```mermaid
+graph TB
+ Controller[ShiftTransferController]
+
+ subgraph "CRUD Actions"
+ A1[actionIndex<br/>Список передач]
+ A2[actionCreate<br/>Создание передачи]
+ A3[actionUpdate<br/>Редактирование]
+ A4[actionView<br/>Просмотр и действия]
+ A5[actionDelete<br/>Удаление]
+ end
+
+ subgraph "AJAX API"
+ A6[actionGetProductData<br/>Данные товара для замены]
+ A7[actionGetProductReplacementPrice<br/>Цена замены]
+ A8[actionGetMaxQuantity<br/>Макс. количество замены]
+ A9[actionGetProductPriceSelfCostAndRemains<br/>Цена + себестоимость + остатки]
+ A10[actionGetProductsWithRemains<br/>Товары с остатками]
+ end
+
+ subgraph "Helper"
+ A11[buildLoadDataShiftRemains<br/>Построение данных остатков]
+ A12[isAllowedAdmin<br/>Проверка прав]
+ end
+
+ subgraph "Models"
+ M1[ShiftTransfer<br/>Документ передачи]
+ M2[ShiftRemains<br/>Строки остатков]
+ M3[EqualizationRemains<br/>Замены товаров]
+ M4[WaybillIncoming<br/>Приходные накладные]
+ M5[WaybillWriteOffs<br/>Расходные накладные]
+ M6[Products1c<br/>Товары 1С]
+ M7[Balances<br/>Остатки]
+ M8[Prices<br/>Цены]
+ M9[SelfCostProductDynamic<br/>Себестоимость]
+ end
+
+ Controller --> A1
+ Controller --> A2
+ Controller --> A3
+ Controller --> A4
+ Controller --> A5
+ A2 --> A11
+ A3 --> A11
+ A4 --> M4
+ A4 --> M5
+```
+
+---
+
+## 📊 Статусная модель документа
+
+```mermaid
+stateDiagram-v2
+ [*] --> INPUT_FACT_REMAINS: create
+ INPUT_FACT_REMAINS --> TRANSFER_ACTIONS: save (ввод остатков)
+ TRANSFER_ACTIONS --> READY_TO_ACCEPT: save (замены добавлены)
+ TRANSFER_ACTIONS --> TRANSFER_ACTIONS: rejection
+ READY_TO_ACCEPT --> FORMATION_SURPLUSES: resume (формирование накладных)
+ FORMATION_SURPLUSES --> ACCEPTED: accept
+ ACCEPTED --> [*]
+```
+
+**Статусы (ShiftTransfer):**
+1. `STATUS_ID_INPUT_FACT_REMAINS` (1) — Ввод фактических остатков
+2. `STATUS_ID_TRANSFER_ACTIONS` (2) — Действия по замене
+3. `STATUS_ID_READY_TO_ACCEPT` (3) — Готов к приёмке
+4. `STATUS_OF_THE_FORMATION_OF_SURPLUSES_AND_SHORTAGES` (4) — Формирование излишков/недостач
+5. `STATUS_ID_ACCEPTED` (5) — Принят
+
+---
+
+## 📋 Actions (сокращённо)
+
+### 1. actionIndex()
+- Список всех передач смен
+- Фильтр по магазинам пользователя
+- Для не-IT: только готовые к приёмке или свои
+
+### 2. actionCreate()
+- Создание документа передачи
+- Автозагрузка остатков из 1С
+- Группировка товаров (other_items по умолчанию)
+
+### 3. actionUpdate($id)
+- Редактирование документа
+- Применение групп товаров (`applyGroups`)
+- Сохранение остатков и расчёт расхождений
+
+### 4. actionView($id)
+**POST actions:**
+- `accept` — Принять смену
+- `save` — Сохранить как готов к приёмке
+- `recalculate` — Перерасчёт замен
+- `rejection` — Отклонить (вернуть на редактирование)
+- `resume` — Продолжить (сформировать накладные)
+
+### 5-10. AJAX API
+- Получение данных товаров
+- Цены, себестоимости, остатки
+- Максимальное количество для замены
+
+---
+
+## 💾 Ключевые модели
+
+### ShiftTransfer
+**Поля:**
+- `store_guid` — GUID магазина
+- `end_shift_admin_id` — Админ закрывающий смену
+- `start_shift_admin_id` — Админ принимающий смену
+- `date_start`, `date_end` — Даты
+- `status_id` — Статус документа
+- `goods_transfer_count` — Кол-во переданного товара
+- `discrepancy_pieces` — Расхождение (штуки)
+- `discrepancy_rubles` — Расхождение (рубли)
+- `product_groups` — Группы товаров
+
+**Связи:**
+- `hasMany(ShiftRemains)` — Строки остатков
+- `hasMany(EqualizationRemains)` — Замены
+
+---
+
+### ShiftRemains
+**Поля:**
+- `shift_transfer_id` — FK
+- `product_guid` — GUID товара
+- `remains_1c` — Остаток по 1С
+- `remains_count` — Фактический остаток
+- `fact_and_1c_diff` — Разница (факт - 1С)
+- `retail_price` — Розничная цена
+- `self_cost` — Себестоимость
+- `type` — Тип (0=текущий, 1=архив)
+
+---
+
+## 🔄 Бизнес-процесс передачи смены
+
+**Шаг 1. Создание документа (actionCreate)**
+```
+1. Выбрать магазин
+2. Выбрать группы товаров (по умолчанию: other_items)
+3. Загрузить остатки из 1С
+4. Ввести фактические остатки
+```
+
+**Шаг 2. Сохранение и расчёт (actionUpdate, action=save)**
+```
+1. Сохранить ShiftTransfer
+2. Удалить старые ShiftRemains
+3. Создать новые ShiftRemains
+4. Рассчитать расхождения:
+ - discrepancy_pieces = SUM(remains_count - remains_1c)
+ - discrepancy_rubles = SUM(retail_price * (remains_count - remains_1c))
+5. Вызвать EqualizationRemains::setData() для формирования замен
+6. Установить status = TRANSFER_ACTIONS
+```
+
+**Шаг 3. Добавление замен (actionView)**
+```
+1. Для каждого недостающего товара:
+ - Выбрать товар-замену из излишков
+ - Указать количество
+2. Сохранить замены в EqualizationRemains
+3. Установить status = READY_TO_ACCEPT
+```
+
+**Шаг 4. Формирование накладных (action=resume)**
+```
+1. Создать WaybillIncoming (приход излишков)
+2. Создать WaybillWriteOffs (списание недостач)
+3. Установить status = FORMATION_SURPLUSES
+```
+
+**Шаг 5. Принятие смены (action=accept)**
+```
+1. Установить date_end, start_shift_admin_id
+2. Архивировать ShiftRemains (type=1)
+3. Сформировать ReplacementInvoice
+4. Обновить StoreBalance
+5. Подтвердить накладные (status=CONFIRM)
+6. Установить status = ACCEPTED
+```
+
+---
+
+## 🧪 Примеры использования
+
+### Создать передачу смены
+
+```php
+// GET /shift-transfer/create
+// Выбрать магазин, группы товаров
+// Ввести фактические остатки
+// POST action=save
+```
+
+### AJAX: получить данные товара для замены
+
+```javascript
+$.post('/shift-transfer/get-product-data', {
+ productGuid: 'xxx-xxx',
+ shiftTransferId: 123
+}, function(response) {
+ console.log(response.product_price);
+ console.log(response.product_replacement); // Список замен
+});
+```
+
+---
+
+## ❓ FAQ
+
+### 1. Что такое "замена товара"?
+**Ответ:** Если по факту товара меньше чем в 1С (недостача), можно заменить его излишком другого товара.
+
+### 2. Какие группы товаров доступны?
+**Ответ:**
+- `other_items` — Другие товары
+- Дополнительные группы из ProductsClass
+
+### 3. Как рассчитывается максимальное количество замены?
+**Ответ:** `min(abs(недостача), abs(излишек))`
+
+---
+
+## 🔗 Связанные компоненты
+
+- [ShiftTransfer](../../models/ShiftTransfer.md)
+- [ShiftRemains](../../models/ShiftRemains.md)
+- [EqualizationRemains](../../models/EqualizationRemains.md)
+- [WaybillIncoming](../../models/WaybillIncoming.md)
+- [WaybillWriteOffs](../../models/WaybillWriteOffs.md)
+
+---
+
+**Документация создана:** 2025-11-26
+**Фаза:** 3 (Средние с интеграциями)
+**Статус:** ✅ Готово
--- /dev/null
+# ShiftTransferController - Краткая справка
+
+## 📌 Основная информация
+
+**Контроллер:** `ShiftTransferController`
+**Namespace:** `app\controllers`
+**Путь:** `erp24/controllers/ShiftTransferController.php`
+**Размер:** 490 строк
+**Actions:** 12 (5 CRUD + 5 AJAX + 2 helper)
+**Категория:** Средний контроллер с интеграциями (Фаза 3)
+
+---
+
+## 🎯 Назначение в одном предложении
+
+Управление передачей смены между администраторами магазина с полным циклом: ввод фактических остатков, сравнение с 1С, система замен недостающего товара, автоматическое формирование накладных и принятие смены.
+
+---
+
+## 🔑 Ключевые особенности
+
+✅ **5-этапный статусный workflow:** от ввода остатков до принятия смены
+✅ **Интеграция с 1С:** автоматическая загрузка остатков, цен, себестоимости
+✅ **Система замен товаров:** замена недостач излишками
+✅ **Автоматические накладные:** приход излишков, списание недостач
+✅ **Расчёт расхождений:** в штуках и рублях
+✅ **AJAX API:** динамическая загрузка данных товаров
+✅ **Архивация данных:** сохранение истории передач
+
+---
+
+## 📋 Actions (12)
+
+| # | Action | Тип | Назначение |
+|---|--------|-----|------------|
+| 1 | `actionIndex()` | CRUD | Список всех передач смен |
+| 2 | `actionCreate()` | CRUD | Создание документа передачи |
+| 3 | `actionUpdate($id)` | CRUD | Редактирование остатков |
+| 4 | `actionView($id)` | CRUD | Просмотр и действия над документом |
+| 5 | `actionDelete($id)` | CRUD | Удаление документа |
+| 6 | `actionGetProductData()` | AJAX | Получить данные товара для замены |
+| 7 | `actionGetProductReplacementPrice()` | AJAX | Получить цену замены |
+| 8 | `actionGetMaxQuantity()` | AJAX | Максимальное количество замены |
+| 9 | `actionGetProductPriceSelfCostAndRemains()` | AJAX | Цена + себестоимость + остатки |
+| 10 | `actionGetProductsWithRemains()` | AJAX | Товары с остатками для магазина |
+| 11 | `buildLoadDataShiftRemains()` | Helper | Построение данных остатков из 1С |
+| 12 | `isAllowedAdmin()` | Helper | Проверка прав доступа |
+
+---
+
+## 📊 Статусная модель (5 статусов)
+
+```mermaid
+stateDiagram-v2
+ [*] --> STATUS_1: create
+ STATUS_1: 1. Ввод фактических остатков
+ STATUS_2: 2. Действия по замене
+ STATUS_3: 3. Готов к приёмке
+ STATUS_4: 4. Формирование излишков/недостач
+ STATUS_5: 5. Принят
+
+ STATUS_1 --> STATUS_2: save (ввести остатки)
+ STATUS_2 --> STATUS_3: save (добавить замены)
+ STATUS_2 --> STATUS_2: rejection (вернуть)
+ STATUS_3 --> STATUS_4: resume (создать накладные)
+ STATUS_4 --> STATUS_5: accept (принять смену)
+ STATUS_5 --> [*]
+```
+
+| ID | Константа | Описание |
+|----|-----------|----------|
+| 1 | `STATUS_ID_INPUT_FACT_REMAINS` | Ввод фактических остатков |
+| 2 | `STATUS_ID_TRANSFER_ACTIONS` | Действия по замене |
+| 3 | `STATUS_ID_READY_TO_ACCEPT` | Готов к приёмке |
+| 4 | `STATUS_OF_THE_FORMATION_OF_SURPLUSES_AND_SHORTAGES` | Формирование излишков/недостач |
+| 5 | `STATUS_ID_ACCEPTED` | Принят |
+
+---
+
+## 🗂️ Используемые модели
+
+| Модель | Назначение |
+|--------|------------|
+| **ShiftTransfer** | Документ передачи смены |
+| **ShiftRemains** | Строки остатков (текущие/архив) |
+| **EqualizationRemains** | Замены товаров |
+| **WaybillIncoming** | Приходные накладные (излишки) |
+| **WaybillWriteOffs** | Расходные накладные (недостачи) |
+| **Products1c** | Товары из 1С |
+| **Balances** | Остатки по магазинам |
+| **Prices** | Цены товаров |
+| **SelfCostProductDynamic** | Себестоимость |
+| **ReplacementInvoice** | Акты замены |
+| **StoreBalance** | Балансы магазинов |
+
+---
+
+## 🔄 Бизнес-процесс (5 шагов)
+
+### Шаг 1: Создание документа
+```
+1. Выбрать магазин
+2. Выбрать группы товаров (по умолчанию: other_items)
+3. Загрузить остатки из 1С
+4. Ввести фактические остатки
+→ Status: INPUT_FACT_REMAINS (1)
+```
+
+### Шаг 2: Сохранение и расчёт
+```
+1. Сохранить ShiftTransfer
+2. Удалить старые ShiftRemains
+3. Создать новые ShiftRemains
+4. Рассчитать расхождения:
+ - discrepancy_pieces = SUM(fact - 1C)
+ - discrepancy_rubles = SUM(price × (fact - 1C))
+5. Вызвать EqualizationRemains::setData() → автозамены
+→ Status: TRANSFER_ACTIONS (2)
+```
+
+### Шаг 3: Добавление замен
+```
+1. Для каждого недостающего товара:
+ - Выбрать товар-замену из излишков
+ - Указать количество
+2. Сохранить в EqualizationRemains
+→ Status: READY_TO_ACCEPT (3)
+```
+
+### Шаг 4: Формирование накладных
+```
+1. Создать WaybillIncoming (излишки)
+2. Создать WaybillWriteOffs (недостачи)
+→ Status: FORMATION_SURPLUSES (4)
+```
+
+### Шаг 5: Принятие смены
+```
+1. Установить date_end, start_shift_admin_id
+2. Архивировать ShiftRemains (type=1)
+3. Сформировать ReplacementInvoice
+4. Обновить StoreBalance
+5. Подтвердить накладные (status=CONFIRM)
+→ Status: ACCEPTED (5)
+```
+
+---
+
+## 💡 Быстрые примеры
+
+### AJAX: получить данные товара для замены
+
+```javascript
+$.ajax({
+ url: '/shift-transfer/get-product-data',
+ type: 'POST',
+ data: {
+ productGuid: 'xxx-xxx-xxx',
+ shiftTransferId: 123
+ },
+ success: function(response) {
+ console.log('Количество:', response.product_count);
+ console.log('Цена:', response.product_price);
+ console.log('Себестоимость:', response.product_self_cost);
+ console.log('Замены:', response.product_replacement);
+ }
+});
+```
+
+### AJAX: получить цену замены
+
+```javascript
+$.post('/shift-transfer/get-product-replacement-price', {
+ productGuid: 'xxx-xxx-xxx',
+ shiftTransferId: 123
+}, function(response) {
+ console.log('Цена замены:', response.product_price);
+ console.log('Себестоимость замены:', response.product_replacement_self_cost);
+ console.log('Максимальное кол-во:', response.maxQuantity);
+});
+```
+
+### AJAX: получить максимальное количество замены
+
+```javascript
+$.post('/shift-transfer/get-max-quantity', {
+ productReplacementName: 'Товар А',
+ productName: 'Товар Б',
+ shiftTransferId: 123
+}, function(response) {
+ console.log('Можно заменить:', response.maxValue);
+});
+```
+
+---
+
+## 📊 Структура данных
+
+### ShiftTransfer (основная таблица)
+
+| Поле | Тип | Описание |
+|------|-----|----------|
+| `id` | int | ID документа |
+| `store_guid` | string | GUID магазина |
+| `end_shift_admin_id` | int | Админ закрывающий смену |
+| `start_shift_admin_id` | int | Админ принимающий смену |
+| `date_start` | datetime | Дата начала |
+| `date_end` | datetime | Дата окончания (nullable) |
+| `status_id` | int | Статус (1-5) |
+| `goods_transfer_count` | int | Кол-во переданного товара |
+| `discrepancy_pieces` | int | Расхождение (штуки) |
+| `discrepancy_rubles` | decimal | Расхождение (рубли) |
+| `product_groups` | string | Группы товаров (JSON) |
+
+### ShiftRemains (строки остатков)
+
+| Поле | Тип | Описание |
+|------|-----|----------|
+| `id` | int | ID строки |
+| `shift_transfer_id` | int | FK на ShiftTransfer |
+| `product_guid` | string | GUID товара |
+| `remains_1c` | int | Остаток по 1С |
+| `remains_count` | int | Фактический остаток |
+| `fact_and_1c_diff` | int | Разница (факт - 1С) |
+| `retail_price` | decimal | Розничная цена |
+| `self_cost` | decimal | Себестоимость |
+| `type` | int | 0=текущий, 1=архив |
+
+---
+
+## ⚙️ Бизнес-правила
+
+### Группы товаров
+- **other_items** — Другие товары (по умолчанию)
+- Дополнительные группы из `ProductsClass`
+
+### Расчёт расхождений
+```php
+discrepancy_pieces = SUM(remains_count - remains_1c)
+discrepancy_rubles = SUM(retail_price × (remains_count - remains_1c))
+```
+
+### Максимальное количество замены
+```php
+maxQuantity = min(
+ abs(недостача_товара),
+ abs(излишек_замены)
+)
+```
+
+### Права доступа
+- Только администраторы магазинов (не IT)
+- Просмотр чужих документов: только status >= 3 (готов к приёмке)
+
+---
+
+## 🔄 POST Actions
+
+### actionUpdate: POST actions
+
+| Action | Что делает |
+|--------|------------|
+| `applyGroups` | Перезагрузить список товаров по выбранным группам |
+| `save` (default) | Сохранить остатки, расчёт расхождений → status=2 |
+
+### actionView: POST actions
+
+| Action | Что делает |
+|--------|------------|
+| `accept` | Принять смену → status=5, архивация, формирование документов |
+| `save` | Готов к приёмке → status=3 |
+| `recalculate` | Перерасчёт замен (EqualizationRemains::setData()) |
+| `rejection` | Отклонить → status=2, удалить накладные |
+| `resume` | Продолжить → status=4, создать накладные |
+
+---
+
+## 🧪 Сценарии использования
+
+### Сценарий 1: Создать передачу смены
+
+1. Открыть `/shift-transfer/create`
+2. Выбрать магазин
+3. Выбрать группы товаров (other_items)
+4. Ввести фактические остатки
+5. POST `action=save`
+6. Система рассчитает расхождения → status=2
+
+---
+
+### Сценарий 2: Добавить замены товаров
+
+1. Открыть `/shift-transfer/view?id=123` (status=2)
+2. Для недостачи "Товар А" выбрать замену "Товар Б" (излишек)
+3. AJAX загрузит максимальное количество
+4. Указать количество замены
+5. POST `action=save` → status=3
+
+---
+
+### Сценарий 3: Сформировать накладные
+
+1. Открыть `/shift-transfer/view?id=123` (status=3)
+2. POST `action=resume`
+3. Система создаст:
+ - WaybillIncoming (излишки)
+ - WaybillWriteOffs (недостачи)
+4. status=4
+
+---
+
+### Сценарий 4: Принять смену
+
+1. Открыть `/shift-transfer/view?id=123` (status=4)
+2. Проверить накладные
+3. POST `action=accept`
+4. Система:
+ - Установит date_end, start_shift_admin_id
+ - Архивирует ShiftRemains (type=1)
+ - Сформирует ReplacementInvoice
+ - Обновит StoreBalance
+ - Подтвердит накладные
+5. status=5 (завершено)
+
+---
+
+## ❓ FAQ (Топ-5)
+
+### 1. Что такое "замена товара"?
+
+**Ответ:** Если по факту товара меньше чем в 1С (недостача), его можно заменить излишком другого товара. Например:
+
+```
+Недостача: Товар А = -5 шт
+Излишек: Товар Б = +10 шт
+→ Заменить 5 шт Товара А на 5 шт Товара Б
+```
+
+Система автоматически рассчитает максимальное количество: `min(5, 10) = 5`.
+
+---
+
+### 2. Как работает автоматическая загрузка остатков из 1С?
+
+**Ответ:** При создании документа метод `buildLoadDataShiftRemains()`:
+
+1. Получает список товаров по группам
+2. Запрашивает остатки из `Balances` (1С)
+3. Загружает цены из `Prices`
+4. Загружает себестоимость из `SelfCostProductDynamic`
+5. Формирует массив для заполнения таблицы
+
+---
+
+### 3. Что происходит при сохранении остатков (action=save)?
+
+**Ответ:** 5 операций:
+
+1. Сохранить ShiftTransfer
+2. Удалить старые ShiftRemains (DELETE WHERE shift_transfer_id)
+3. Создать новые ShiftRemains (INSERT)
+4. Рассчитать расхождения (discrepancy_pieces, discrepancy_rubles)
+5. Вызвать `EqualizationRemains::setData()` → автоматические замены
+
+---
+
+### 4. Зачем нужна архивация ShiftRemains (type=1)?
+
+**Ответ:** При принятии смены (status=5) все строки остатков копируются с `type=1` для:
+
+- Сохранения истории передач
+- Анализа расхождений
+- Отчётов по администраторам
+- Защиты от изменений после принятия
+
+Текущие остатки имеют `type=0`, архивные — `type=1`.
+
+---
+
+### 5. Какие накладные создаются автоматически?
+
+**Ответ:** 2 типа накладных:
+
+**WaybillIncoming (излишки):**
+- Для товаров, где `fact > 1C`
+- Тип: приход
+- Статус: черновик → подтверждается при accept
+
+**WaybillWriteOffs (недостачи):**
+- Для товаров, где `fact < 1C`
+- Тип: списание
+- Статус: черновик → подтверждается при accept
+
+Обе накладные создаются при `action=resume` (status=3 → 4).
+
+---
+
+## 🚨 Типичные ошибки
+
+| Ошибка | Причина | Решение |
+|--------|---------|---------|
+| "Передача смены не найдена" | Неверный ID | Проверить ID документа |
+| "Нет прав на редактирование" | Чужой документ, status < 3 | Только владелец или status >= 3 |
+| "Ошибка загрузки остатков из 1С" | Нет связи с 1С | Проверить интеграцию |
+| "Товар не найден" | GUID не существует | Проверить справочник |
+| "Максимальное количество = 0" | Нет излишков для замены | Выбрать другой товар |
+
+---
+
+## 🔗 Связанные компоненты
+
+- **[Детальный анализ](./ShiftTransferController_ANALYSIS.md)** — полная документация
+- **[Таблица Actions](./ShiftTransferController_ACTIONS_TABLE.md)** — справочник методов
+- **[ShiftTransfer](../../models/ShiftTransfer.md)** — модель документа
+- **[ShiftRemains](../../models/ShiftRemains.md)** — модель остатков
+- **[EqualizationRemains](../../models/EqualizationRemains.md)** — модель замен
+- **[WaybillIncoming](../../models/WaybillIncoming.md)** — приходные накладные
+- **[WaybillWriteOffs](../../models/WaybillWriteOffs.md)** — расходные накладные
+
+---
+
+## 🎨 Диаграмма процесса
+
+```
+┌─────────────────────────────────────────────────────────────┐
+│ СОЗДАНИЕ ДОКУМЕНТА │
+│ (actionCreate) │
+│ ↓ │
+│ Выбрать магазин → Группы товаров → Загрузить из 1С │
+└──────────────────────┬──────────────────────────────────────┘
+ │
+ ↓
+┌─────────────────────────────────────────────────────────────┐
+│ ВВОД ФАКТИЧЕСКИХ ОСТАТКОВ │
+│ (actionUpdate, action=save) │
+│ ↓ │
+│ Ввести факт → Расчёт расхождений → Автозамены │
+└──────────────────────┬──────────────────────────────────────┘
+ │
+ ↓
+┌─────────────────────────────────────────────────────────────┐
+│ ДОБАВЛЕНИЕ ЗАМЕН ТОВАРОВ │
+│ (actionView, action=save) │
+│ ↓ │
+│ Недостача → Выбрать замену → Указать кол-во │
+└──────────────────────┬──────────────────────────────────────┘
+ │
+ ↓
+┌─────────────────────────────────────────────────────────────┐
+│ ФОРМИРОВАНИЕ НАКЛАДНЫХ │
+│ (actionView, action=resume) │
+│ ↓ │
+│ WaybillIncoming + WaybillWriteOffs │
+└──────────────────────┬──────────────────────────────────────┘
+ │
+ ↓
+┌─────────────────────────────────────────────────────────────┐
+│ ПРИНЯТИЕ СМЕНЫ │
+│ (actionView, action=accept) │
+│ ↓ │
+│ Архивация → ReplacementInvoice → Балансы → Подтверждение │
+└─────────────────────────────────────────────────────────────┘
+```
+
+---
+
+**Краткая справка создана:** 2025-11-26
+**Версия:** 1.0
+**Статус:** ✅ Готово
--- /dev/null
+# StoreStaffingController - Таблица Actions
+
+## Быстрая справка по действиям
+
+| # | Action | HTTP | Маршрут | Назначение | Параметры | Возвращает |
+|---|--------|------|---------|------------|-----------|------------|
+| 1 | `actionIndex()` | GET | `/store-staffing/index` | Список записей штатного расписания | фильтры | HTML |
+| 2 | `actionView($id)` | GET | `/store-staffing/view` | Просмотр записи | `id` | HTML |
+| 3 | `actionCreate()` | GET/POST | `/store-staffing/create` | Создание записи | POST: модель | HTML/Redirect |
+| 4 | `actionUpdate($id)` | GET/POST | `/store-staffing/update` | Редактирование записи | `id`, POST: модель | HTML/Redirect |
+| 5 | `actionDelete($id)` | POST | `/store-staffing/delete` | Удаление записи | `id` | Redirect |
+| 6 | `actionImport()` | GET/POST | `/store-staffing/import` | Импорт из Excel | POST: файл | HTML/Redirect |
+| 7 | `actionExportTemplate()` | GET | `/store-staffing/export-template` | Экспорт шаблона Excel | - | File (XLSX) |
+| 8 | `actionLogs($store_id)` | GET | `/store-staffing/logs` | Просмотр логов изменений | `store_id` | HTML |
+| 9 | `actionGetPositionPosit($position_id)` | GET | `/store-staffing/get-position-posit` | AJAX: получить грейд должности | `position_id` | JSON |
+
+---
+
+## Детальное описание Actions
+
+### 1. actionIndex()
+
+**Сигнатура:**
+```php
+public function actionIndex(): string
+```
+
+**Назначение:** Отображение списка всех записей штатного расписания с фильтрацией и пагинацией
+
+**Параметры (GET):**
+
+| Параметр | Тип | Обязательный | Описание |
+|----------|-----|--------------|----------|
+| `StoreStaffingSearch[store_id]` | int | Нет | Фильтр по магазину |
+| `StoreStaffingSearch[employee_position_id]` | int | Нет | Фильтр по должности |
+
+**Возвращает:** `string` — HTML страница
+
+**Пример HTTP:**
+```bash
+curl -X GET "https://erp24.example.com/store-staffing/index?StoreStaffingSearch[store_id]=5"
+```
+
+**Данные представления:**
+- `searchModel` — модель поиска
+- `dataProvider` — провайдер данных
+- `stores` — массив магазинов для фильтра
+
+---
+
+### 2. actionView($id)
+
+**Сигнатура:**
+```php
+public function actionView(int $id): string
+```
+
+**Назначение:** Просмотр детальной информации о записи штатного расписания
+
+**Параметры:**
+
+| Параметр | Тип | Обязательный | Описание |
+|----------|-----|--------------|----------|
+| `id` | int | Да | ID записи |
+
+**Возвращает:**
+- Success: `string` — HTML страница
+- Error: `NotFoundHttpException`
+
+**Пример HTTP:**
+```bash
+curl -X GET "https://erp24.example.com/store-staffing/view?id=123"
+```
+
+---
+
+### 3. actionCreate()
+
+**Сигнатура:**
+```php
+public function actionCreate(): string|\yii\web\Response
+```
+
+**Назначение:** Создание новой записи штатного расписания
+
+**Параметры (POST):**
+
+| Параметр | Тип | Обязательный | Описание |
+|----------|-----|--------------|----------|
+| `StoreStaffing[store_id]` | int | Да | ID магазина |
+| `StoreStaffing[employee_position_id]` | int | Да | ID должности |
+| `StoreStaffing[count]` | float | Да | Количество ставок |
+
+**Возвращает:**
+- Success: `Response` — redirect to `view`
+- Error: `string` — HTML форма с ошибками
+
+**Flash-сообщение:** "Запись штатного расписания успешно создана."
+
+**Пример использования:**
+```php
+// POST /store-staffing/create
+$model = new StoreStaffing();
+
+if ($this->request->isPost) {
+ if ($model->load($this->request->post()) && $model->save()) {
+ Yii::$app->session->setFlash('success', 'Запись штатного расписания успешно создана.');
+ return $this->redirect(['view', 'id' => $model->id]);
+ }
+}
+
+$stores = ArrayHelper::map(CityStore::find()->select(['id', 'name'])->asArray()->all(), 'id', 'name');
+$positions = ArrayHelper::map(EmployeePosition::find()->select(['id', 'name', 'posit'])->asArray()->all(), 'id', 'name');
+
+return $this->render('create', [
+ 'model' => $model,
+ 'stores' => $stores,
+ 'positions' => $positions,
+]);
+```
+
+---
+
+### 4. actionUpdate($id)
+
+**Сигнатура:**
+```php
+public function actionUpdate(int $id): string|\yii\web\Response
+```
+
+**Назначение:** Редактирование существующей записи штатного расписания
+
+**Параметры:**
+
+| Параметр | Тип | Обязательный | Описание |
+|----------|-----|--------------|----------|
+| `id` | int | Да | ID записи |
+| `StoreStaffing[count]` | float | Нет | Новое количество ставок |
+
+**Возвращает:**
+- Success: `Response` — redirect to `view`
+- Error: `string` — HTML форма с ошибками
+- Error: `NotFoundHttpException`
+
+**Flash-сообщение:** "Запись штатного расписания успешно обновлена."
+
+**Пример HTTP:**
+```bash
+curl -X POST "https://erp24.example.com/store-staffing/update?id=123" \
+ -d "StoreStaffing[count]=3.5"
+```
+
+---
+
+### 5. actionDelete($id)
+
+**Сигнатура:**
+```php
+public function actionDelete(int $id): \yii\web\Response
+```
+
+**Назначение:** Удаление записи штатного расписания
+
+**HTTP метод:** POST (только) — ограничено VerbFilter
+
+**Параметры:**
+
+| Параметр | Тип | Обязательный | Описание |
+|----------|-----|--------------|----------|
+| `id` | int | Да | ID записи |
+
+**Возвращает:**
+- Success: `Response` — redirect to `index`
+- Error: `NotFoundHttpException`
+
+**Flash-сообщение:** "Запись штатного расписания успешно удалена."
+
+**Пример использования:**
+```php
+// POST /store-staffing/delete?id=123
+public function actionDelete($id)
+{
+ $this->findModel($id)->delete();
+ Yii::$app->session->setFlash('success', 'Запись штатного расписания успешно удалена.');
+
+ return $this->redirect(['index']);
+}
+```
+
+**Важно:** VerbFilter запрещает GET/DELETE запросы:
+```php
+'verbs' => [
+ 'class' => VerbFilter::className(),
+ 'actions' => [
+ 'delete' => ['POST'],
+ ],
+],
+```
+
+---
+
+### 6. actionImport()
+
+**Сигнатура:**
+```php
+public function actionImport(): string|\yii\web\Response
+```
+
+**Назначение:** Массовый импорт штатного расписания из файла Excel
+
+**HTTP метод:** GET (форма), POST (обработка)
+
+**Параметры (POST):**
+
+| Параметр | Тип | Обязательный | Описание |
+|----------|-----|--------------|----------|
+| `DynamicModel[excelFile]` | UploadedFile | Да | Excel файл (.xls, .xlsx) |
+
+**Формат Excel файла:**
+
+| Колонка | Название | Тип | Описание |
+|---------|----------|-----|----------|
+| A | `store_name` | string | Название магазина (точное совпадение) |
+| B | `position_name` | string | Название должности (умный поиск) |
+| C | `posit` | int | Грейд должности |
+| D | `count` | float | Количество ставок (> 0) |
+
+**Возвращает:**
+- Success: `Response` — redirect to `index` с flash
+- Error: `string` — HTML форма
+
+**Flash-сообщения:**
+- Success: "Импорт завершен. Создано: X, обновлено: Y."
+- Warning: "Обнаружены ошибки: ..." (первые 5 ошибок)
+- Error: "Ошибка при обработке файла: ..."
+
+**Валидация строки:**
+
+```php
+// Обязательные поля
+if (empty($storeName)) {
+ $results['errors'][] = "Строка $rowNum: название магазина пусто.";
+}
+
+if (empty($positionName)) {
+ $results['errors'][] = "Строка $rowNum: название должности пусто.";
+}
+
+if ($count <= 0) {
+ $results['errors'][] = "Строка $rowNum: количество должно быть больше 0.";
+}
+
+// Поиск магазина (точное совпадение)
+if (!isset($storesMap[$storeName])) {
+ $results['errors'][] = "Строка $rowNum: магазин '$storeName' не найден.";
+}
+
+// Умный поиск должности (см. findPositionByName)
+$positionId = $this->findPositionByName($positionName, $allPositions);
+if ($positionId === null) {
+ $results['errors'][] = "Строка $rowNum: должность '$positionName' не найдена.";
+}
+
+// Проверка соответствия грейда
+if ($posit != $actualPosit) {
+ $results['errors'][] = "Строка $rowNum: грейд должности '$positionName' должен быть $actualPosit, а не $posit.";
+}
+```
+
+**Логика обработки:**
+
+1. Поиск существующей записи по `(store_id, employee_position_id)`
+2. Если найдена:
+ - Проверить изменение `count`
+ - Если изменился → UPDATE → `results['updated']++`
+ - Если не изменился → пропуск
+3. Если не найдена:
+ - INSERT → `results['created']++`
+
+**Пример использования:**
+
+```javascript
+// HTML форма
+<form method="post" enctype="multipart/form-data">
+ <input type="file" name="DynamicModel[excelFile]" accept=".xls,.xlsx">
+ <button type="submit">Импортировать</button>
+</form>
+```
+
+**Алгоритм умного поиска должности:**
+
+```php
+protected function findPositionByName($positionName, $allPositions)
+{
+ $positionName = trim($positionName);
+
+ // 1. Точное совпадение (без учёта регистра)
+ foreach ($allPositions as $position) {
+ if (strcasecmp($position['name'], $positionName) === 0) {
+ return $position['id'];
+ }
+ }
+
+ // 2. Поиск по вхождению с расчётом схожести
+ $candidates = [];
+ foreach ($allPositions as $position) {
+ $dbName = $position['name'];
+
+ $fileContainsDb = stripos($positionName, $dbName) !== false;
+ $dbContainsFile = stripos($dbName, $positionName) !== false;
+
+ if ($dbContainsFile || $fileContainsDb) {
+ $candidates[] = [
+ 'id' => $position['id'],
+ 'similarity' => similar_text($positionName, $dbName, $percent),
+ 'percent' => $percent
+ ];
+ }
+ }
+
+ if (empty($candidates)) {
+ return null;
+ }
+
+ // Сортировка по проценту схожести
+ usort($candidates, function($a, $b) {
+ return $b['percent'] <=> $a['percent'];
+ });
+
+ return $candidates[0]['id'];
+}
+```
+
+**Примеры умного поиска:**
+
+| Файл | БД | Результат | Схожесть |
+|------|-----|-----------|----------|
+| "Флорист" | ["Флорист", "Старший флорист"] | Флорист | 100% |
+| "Старший флорист" | ["Флорист", "Старший флорист"] | Старший флорист | 100% |
+| "флорист" | ["Флорист"] | Флорист | 100% (case-insensitive) |
+| "Senior Florist" | ["Старший флорист"] | null | Не найдено |
+
+---
+
+### 7. actionExportTemplate()
+
+**Сигнатура:**
+```php
+public function actionExportTemplate(): \yii\web\Response
+```
+
+**Назначение:** Скачать шаблон Excel для импорта штатного расписания
+
+**HTTP метод:** GET
+
+**Параметры:** Нет
+
+**Возвращает:** `Response` — файл `store_staffing_template.xlsx`
+
+**Структура шаблона:**
+
+**Лист 1: "Инструкция"**
+```
+┌────────────────────────────────────────────────────┐
+│ ИНСТРУКЦИЯ ПО ИМПОРТУ ШТАТНОГО РАСПИСАНИЯ │
+├────────────────────────────────────────────────────┤
+│ Формат файла для импорта: │
+│ │
+│ Колонка A (store_name): Название магазина │
+│ Колонка B (position_name): Название должности │
+│ Колонка C (posit): Грейд должности │
+│ Колонка D (count): Количество сотрудников │
+└────────────────────────────────────────────────────┘
+```
+
+**Лист 2: "Данные"**
+```
+┌───────────────┬───────────────┬───────┬───────┐
+│ store_name │ position_name │ posit │ count │
+├───────────────┼───────────────┼───────┼───────┤
+│ Магазин 1 │ Флорист │ 5 │ │
+│ Магазин 1 │ Старший флор. │ 7 │ │
+│ Магазин 1 │ Администратор │ 6 │ │
+│ Магазин 2 │ Флорист │ 5 │ │
+│ ... │ ... │ ... │ │
+└───────────────┴───────────────┴───────┴───────┘
+```
+
+**Особенности:**
+
+1. **Автоматическое заполнение:**
+ - Все магазины (где `visible = 1`)
+ - Все должности
+ - Грейды (`posit`) из БД
+ - Колонка `count` пустая для ручного заполнения
+
+2. **Форматирование:**
+ - Заголовки: жирный, белый текст, фон #366092
+ - Ширина колонок: A=30, B=30, C=10, D=10
+
+3. **Количество строк:**
+ - `количество магазинов × количество должностей`
+ - Пример: 50 магазинов × 20 должностей = 1000 строк
+
+**Пример использования:**
+
+```bash
+# Скачать шаблон
+curl -X GET "https://erp24.example.com/store-staffing/export-template" \
+ -o template.xlsx
+```
+
+**Код генерации:**
+
+```php
+public function actionExportTemplate()
+{
+ $spreadsheet = new \PhpOffice\PhpSpreadsheet\Spreadsheet();
+
+ // Лист 1: Инструкция
+ $sheet1 = $spreadsheet->getActiveSheet();
+ $sheet1->setTitle('Инструкция');
+ $sheet1->setCellValue('A1', 'ИНСТРУКЦИЯ ПО ИМПОРТУ ШТАТНОГО РАСПИСАНИЯ');
+ $sheet1->mergeCells('A1:D1');
+ $sheet1->getStyle('A1')->getFont()->setBold(true)->setSize(14);
+
+ // ... описание колонок
+
+ // Лист 2: Данные
+ $sheet2 = $spreadsheet->createSheet();
+ $sheet2->setTitle('Данные');
+
+ // Заголовки
+ $sheet2->setCellValue('A1', 'store_name');
+ $sheet2->setCellValue('B1', 'position_name');
+ $sheet2->setCellValue('C1', 'posit');
+ $sheet2->setCellValue('D1', 'count');
+
+ // Форматирование заголовков
+ $sheet2->getStyle('A1:D1')->getFont()->setBold(true)->setColor(new \PhpOffice\PhpSpreadsheet\Style\Color('FFFFFF'));
+ $sheet2->getStyle('A1:D1')->getFill()->setFillType('solid')->getStartColor()->setARGB('FF366092');
+
+ // Заполнение комбинаций
+ $stores = CityStore::find()->where(['visible' => CityStore::IS_VISIBLE])->orderBy('name')->all();
+ $positions = EmployeePosition::find()->orderBy('name')->all();
+
+ $row = 2;
+ foreach ($stores as $store) {
+ foreach ($positions as $position) {
+ $sheet2->setCellValue('A' . $row, $store->name);
+ $sheet2->setCellValue('B' . $row, $position->name);
+ $sheet2->setCellValue('C' . $row, $position->posit);
+ $sheet2->setCellValue('D' . $row, ''); // Пустое для заполнения
+ $row++;
+ }
+ }
+
+ // Сохранить и отправить
+ $writer = new \PhpOffice\PhpSpreadsheet\Writer\Xlsx($spreadsheet);
+ $tempFile = Yii::getAlias('@runtime') . '/store_staffing_template_' . time() . '.xlsx';
+ $writer->save($tempFile);
+
+ $response = Yii::$app->response->sendFile($tempFile, 'store_staffing_template.xlsx');
+ @unlink($tempFile);
+
+ return $response;
+}
+```
+
+---
+
+### 8. actionLogs($store_id)
+
+**Сигнатура:**
+```php
+public function actionLogs(?int $store_id = null): string
+```
+
+**Назначение:** Просмотр логов изменений штатного расписания
+
+**HTTP метод:** GET
+
+**Параметры:**
+
+| Параметр | Тип | Обязательный | Описание |
+|----------|-----|--------------|----------|
+| `store_id` | int | Нет | Фильтр по магазину |
+
+**Возвращает:** `string` — HTML страница с таблицей логов
+
+**Настройки DataProvider:**
+- Сортировка: `created_at DESC`
+- Пагинация: 100 записей на страницу
+
+**Пример HTTP:**
+```bash
+# Все логи
+curl -X GET "https://erp24.example.com/store-staffing/logs"
+
+# Логи для магазина ID=5
+curl -X GET "https://erp24.example.com/store-staffing/logs?store_id=5"
+```
+
+**Пример использования:**
+
+```php
+public function actionLogs($store_id = null)
+{
+ $query = StoreStaffingLog::find();
+
+ if ($store_id !== null) {
+ $query->where(['store_id' => $store_id]);
+ }
+
+ $dataProvider = new ActiveDataProvider([
+ 'query' => $query,
+ 'sort' => [
+ 'defaultOrder' => [
+ 'created_at' => SORT_DESC,
+ ]
+ ],
+ 'pagination' => [
+ 'pageSize' => 100,
+ ]
+ ]);
+
+ $stores = ArrayHelper::map(CityStore::find()->select(['id', 'name'])->asArray()->all(), 'id', 'name');
+
+ return $this->render('logs', [
+ 'dataProvider' => $dataProvider,
+ 'stores' => $stores,
+ 'selectedStore' => $store_id,
+ ]);
+}
+```
+
+**Структура данных лога (StoreStaffingLog):**
+
+| Поле | Описание |
+|------|----------|
+| `id` | ID записи лога |
+| `store_id` | ID магазина |
+| `employee_position_id` | ID должности |
+| `old_count` | Старое значение count |
+| `new_count` | Новое значение count |
+| `action` | Действие (create, update, delete) |
+| `created_at` | Дата изменения |
+| `created_by` | Пользователь |
+
+---
+
+### 9. actionGetPositionPosit($position_id)
+
+**Сигнатура:**
+```php
+public function actionGetPositionPosit(int $position_id): mixed
+```
+
+**Назначение:** AJAX-получение грейда должности по ID (для автозаполнения формы)
+
+**HTTP метод:** GET
+
+**Параметры:**
+
+| Параметр | Тип | Обязательный | Описание |
+|----------|-----|--------------|----------|
+| `position_id` | int | Да | ID должности |
+
+**Возвращает:** JSON
+
+**Успешный ответ:**
+```json
+{
+ "posit": 5
+}
+```
+
+**Ошибка (должность не найдена):**
+```json
+{
+ "posit": null
+}
+```
+
+**Пример использования (JavaScript):**
+
+```javascript
+// При изменении должности в select
+$('#storestaffing-employee_position_id').change(function() {
+ const positionId = $(this).val();
+
+ $.ajax({
+ url: '/store-staffing/get-position-posit',
+ type: 'GET',
+ data: { position_id: positionId },
+ dataType: 'json',
+ success: function(response) {
+ if (response.posit !== null) {
+ $('#storestaffing-posit').val(response.posit);
+ $('#storestaffing-posit').attr('readonly', true);
+ } else {
+ $('#storestaffing-posit').val('');
+ $('#storestaffing-posit').attr('readonly', false);
+ }
+ }
+ });
+});
+```
+
+**Пример PHP:**
+
+```php
+public function actionGetPositionPosit($position_id)
+{
+ $position = EmployeePosition::findOne(['id' => $position_id]);
+
+ if ($position) {
+ return $this->asJson(['posit' => $position->posit]);
+ }
+
+ return $this->asJson(['posit' => null]);
+}
+```
+
+**Пример HTTP:**
+```bash
+curl -X GET "https://erp24.example.com/store-staffing/get-position-posit?position_id=15"
+# Ответ: {"posit":5}
+```
+
+---
+
+## Сводная таблица HTTP методов
+
+| Action | GET | POST | DELETE | PUT |
+|--------|-----|------|--------|-----|
+| actionIndex | ✅ | ❌ | ❌ | ❌ |
+| actionView | ✅ | ❌ | ❌ | ❌ |
+| actionCreate | ✅ (форма) | ✅ (создание) | ❌ | ❌ |
+| actionUpdate | ✅ (форма) | ✅ (обновление) | ❌ | ❌ |
+| actionDelete | ❌ | ✅ | ❌ | ❌ |
+| actionImport | ✅ (форма) | ✅ (импорт) | ❌ | ❌ |
+| actionExportTemplate | ✅ | ❌ | ❌ | ❌ |
+| actionLogs | ✅ | ❌ | ❌ | ❌ |
+| actionGetPositionPosit | ✅ | ❌ | ❌ | ❌ |
+
+---
+
+## VerbFilter конфигурация
+
+```php
+public function behaviors()
+{
+ return array_merge(
+ parent::behaviors(),
+ [
+ 'verbs' => [
+ 'class' => VerbFilter::className(),
+ 'actions' => [
+ 'delete' => ['POST'], // Только POST
+ 'import' => ['POST', 'GET'], // POST и GET
+ ],
+ ],
+ ]
+ );
+}
+```
+
+---
+
+## Используемые модели
+
+| Модель | Таблица | Назначение |
+|--------|---------|------------|
+| `StoreStaffing` | `store_staffing` | Штатное расписание |
+| `StoreStaffingSearch` | - | Поиск и фильтрация |
+| `StoreStaffingLog` | `store_staffing_log` | Логи изменений |
+| `CityStore` | `city_store` | Справочник магазинов |
+| `EmployeePosition` | `employee_position` | Справочник должностей |
+| `DynamicModel` | - | Валидация загружаемого файла |
+
+---
+
+## Библиотеки и хелперы
+
+| Компонент | Назначение |
+|-----------|------------|
+| `PhpSpreadsheet` | Чтение и создание Excel файлов |
+| `UploadedFile` | Загрузка файлов |
+| `ArrayHelper` | Работа с массивами (map) |
+| `VerbFilter` | Ограничение HTTP методов |
+| `ActiveDataProvider` | Предоставление данных для GridView |
+
+---
+
+## Примеры CURL запросов
+
+### Получить список записей
+```bash
+curl -X GET "https://erp24.example.com/store-staffing/index"
+```
+
+### Создать запись
+```bash
+curl -X POST "https://erp24.example.com/store-staffing/create" \
+ -d "StoreStaffing[store_id]=5" \
+ -d "StoreStaffing[employee_position_id]=10" \
+ -d "StoreStaffing[count]=2.5"
+```
+
+### Обновить запись
+```bash
+curl -X POST "https://erp24.example.com/store-staffing/update?id=123" \
+ -d "StoreStaffing[count]=3.0"
+```
+
+### Удалить запись
+```bash
+curl -X POST "https://erp24.example.com/store-staffing/delete?id=123"
+```
+
+### Скачать шаблон
+```bash
+curl -X GET "https://erp24.example.com/store-staffing/export-template" \
+ -o template.xlsx
+```
+
+### Получить грейд должности (AJAX)
+```bash
+curl -X GET "https://erp24.example.com/store-staffing/get-position-posit?position_id=15"
+```
+
+### Просмотреть логи
+```bash
+curl -X GET "https://erp24.example.com/store-staffing/logs?store_id=5"
+```
+
+---
+
+## Связанные файлы
+
+- [Детальный анализ](./StoreStaffingController_ANALYSIS.md)
+- [Краткая справка](./StoreStaffingController_QUICK_REFERENCE.md)
+- [README нестандартных контроллеров](./README.md)
+
+---
+
+**Документация создана:** 2025-11-26
+**Статус:** ✅ Готово
--- /dev/null
+# StoreStaffingController - Детальный анализ
+
+## 📋 Общая информация
+
+**Namespace:** `app\controllers`
+**Путь к файлу:** `erp24/controllers/StoreStaffingController.php`
+**Extends:** `\yii\web\Controller`
+**Размер:** 516 строк кода
+**Количество actions:** 9
+**Категория:** Крупный контроллер
+**Приоритет:** HIGH (Фаза 2)
+
+---
+
+## 🎯 Назначение
+
+Контроллер для управления **штатным расписанием магазинов**. Реализует полный цикл CRUD-операций (Create, Read, Update, Delete) с расширенными возможностями:
+
+- Просмотр, создание, редактирование и удаление записей штатного расписания
+- **Массовый импорт из Excel** (с валидацией и умным поиском должностей)
+- **Экспорт шаблона Excel** с инструкцией и предзаполненными данными
+- **Логирование изменений** (история изменений штатного расписания)
+- **AJAX API** для получения грейда должности
+
+Контроллер работает с тремя основными сущностями:
+- Магазины (`CityStore`)
+- Должности (`EmployeePosition`)
+- Штатное расписание (`StoreStaffing`)
+
+---
+
+## 🏗️ Архитектура
+
+### Основные компоненты
+
+```mermaid
+graph TB
+ Controller[StoreStaffingController]
+
+ subgraph "CRUD Actions"
+ A1[actionIndex<br/>Список записей]
+ A2[actionView<br/>Просмотр записи]
+ A3[actionCreate<br/>Создание записи]
+ A4[actionUpdate<br/>Редактирование записи]
+ A5[actionDelete<br/>Удаление записи]
+ end
+
+ subgraph "Excel Operations"
+ A6[actionImport<br/>Импорт из Excel]
+ A7[actionExportTemplate<br/>Экспорт шаблона]
+ A8[processImportFile<br/>Обработка файла]
+ A9[findPositionByName<br/>Умный поиск должности]
+ end
+
+ subgraph "Additional Actions"
+ A10[actionLogs<br/>Просмотр логов]
+ A11[actionGetPositionPosit<br/>AJAX: грейд должности]
+ end
+
+ subgraph "Models"
+ M1[StoreStaffing<br/>Штатное расписание]
+ M2[CityStore<br/>Магазины]
+ M3[EmployeePosition<br/>Должности]
+ M4[StoreStaffingLog<br/>Логи изменений]
+ M5[StoreStaffingSearch<br/>Поиск]
+ end
+
+ subgraph "Libraries"
+ L1[PhpSpreadsheet<br/>Работа с Excel]
+ L2[UploadedFile<br/>Загрузка файлов]
+ end
+
+ Controller --> A1
+ Controller --> A2
+ Controller --> A3
+ Controller --> A4
+ Controller --> A5
+ Controller --> A6
+ Controller --> A7
+ Controller --> A10
+ Controller --> A11
+
+ A6 --> A8
+ A8 --> A9
+ A6 --> L2
+ A7 --> L1
+ A8 --> L1
+
+ A1 --> M5
+ A2 --> M1
+ A3 --> M1
+ A4 --> M1
+ A5 --> M1
+ A8 --> M1
+ A10 --> M4
+ A11 --> M3
+
+ M1 --> M2
+ M1 --> M3
+ M5 --> M1
+```
+
+### Зависимости
+
+| Компонент | Тип | Назначение |
+|-----------|-----|------------|
+| `StoreStaffing` | Модель | Штатное расписание (связь магазин-должность-количество) |
+| `CityStore` | Модель | Справочник магазинов |
+| `EmployeePosition` | Модель | Справочник должностей |
+| `StoreStaffingLog` | Модель | Логирование изменений |
+| `StoreStaffingSearch` | Модель | Поиск и фильтрация записей |
+| `PhpSpreadsheet` | Библиотека | Чтение и создание Excel файлов |
+| `UploadedFile` | Helper | Загрузка файлов |
+| `ArrayHelper` | Helper | Работа с массивами |
+| `VerbFilter` | Filter | Ограничение HTTP методов |
+
+---
+
+## 📋 Actions
+
+### 1. actionIndex()
+
+**Назначение:** Отображение списка всех записей штатного расписания с фильтрацией
+
+**HTTP метод:** GET
+**Маршрут:** `/store-staffing/index`
+
+**Параметры (GET):**
+
+| Параметр | Тип | Обязательный | Описание |
+|----------|-----|--------------|----------|
+| `StoreStaffingSearch[store_id]` | int | Нет | Фильтр по магазину |
+| `StoreStaffingSearch[employee_position_id]` | int | Нет | Фильтр по должности |
+
+**Возвращает:**
+- `string` — HTML страница со списком
+
+**Бизнес-логика:**
+
+1. Создать модель поиска `StoreStaffingSearch`
+2. Выполнить поиск с фильтрами из GET параметров
+3. Получить список всех магазинов для фильтра
+4. Отрендерить представление
+
+**Последовательность выполнения:**
+
+```mermaid
+sequenceDiagram
+ participant User
+ participant Controller
+ participant StoreStaffingSearch
+ participant CityStore
+ participant View
+
+ User->>Controller: GET /store-staffing/index
+ Controller->>StoreStaffingSearch: new StoreStaffingSearch()
+ Controller->>StoreStaffingSearch: search(queryParams)
+ StoreStaffingSearch-->>Controller: ActiveDataProvider
+
+ Controller->>CityStore: find()->select(['id', 'name'])
+ CityStore-->>Controller: Массив магазинов
+
+ Controller->>View: render('index')
+ View-->>User: HTML таблица записей
+```
+
+**Пример использования:**
+
+```php
+// GET /store-staffing/index?StoreStaffingSearch[store_id]=5
+public function actionIndex()
+{
+ $searchModel = new StoreStaffingSearch();
+ $dataProvider = $searchModel->search($this->request->queryParams);
+
+ $stores = ArrayHelper::map(CityStore::find()->select(['id', 'name'])->asArray()->all(), 'id', 'name');
+
+ return $this->render('index', [
+ 'searchModel' => $searchModel,
+ 'dataProvider' => $dataProvider,
+ 'stores' => $stores,
+ ]);
+}
+```
+
+---
+
+### 2. actionView($id)
+
+**Назначение:** Просмотр детальной информации о записи штатного расписания
+
+**HTTP метод:** GET
+**Маршрут:** `/store-staffing/view`
+
+**Параметры:**
+
+| Параметр | Тип | Обязательный | Описание |
+|----------|-----|--------------|----------|
+| `id` | int | Да | ID записи |
+
+**Возвращает:**
+- Success: `string` — HTML страница
+- Error: `NotFoundHttpException` — Запись не найдена
+
+**Пример использования:**
+
+```bash
+curl -X GET "https://erp24.example.com/store-staffing/view?id=123"
+```
+
+---
+
+### 3. actionCreate()
+
+**Назначение:** Создание новой записи штатного расписания
+
+**HTTP метод:** GET, POST
+**Маршрут:** `/store-staffing/create`
+
+**Параметры (POST):**
+
+| Параметр | Тип | Обязательный | Описание |
+|----------|-----|--------------|----------|
+| `StoreStaffing[store_id]` | int | Да | ID магазина |
+| `StoreStaffing[employee_position_id]` | int | Да | ID должности |
+| `StoreStaffing[count]` | float | Да | Количество ставок |
+
+**Возвращает:**
+- Success: `Response` — редирект на `view`
+- Error: `string` — HTML форма с ошибками
+
+**Бизнес-логика:**
+
+1. Создать новую модель `StoreStaffing`
+2. Если POST запрос:
+ - Загрузить данные из формы
+ - Валидация
+ - Сохранить запись
+ - Показать flash-сообщение "успешно создана"
+ - Редирект на просмотр
+3. Получить списки магазинов и должностей для select
+4. Отрендерить форму
+
+**Последовательность выполнения:**
+
+```mermaid
+sequenceDiagram
+ participant User
+ participant Controller
+ participant StoreStaffing
+ participant Database
+
+ User->>Controller: POST /store-staffing/create
+ Controller->>StoreStaffing: new StoreStaffing()
+ Controller->>StoreStaffing: load(POST data)
+ StoreStaffing->>StoreStaffing: validate()
+
+ alt Валидация успешна
+ StoreStaffing->>Database: INSERT
+ Database-->>StoreStaffing: Success
+ Controller->>Controller: setFlash('success')
+ Controller-->>User: Redirect to view
+ else Ошибка валидации
+ Controller-->>User: Форма с ошибками
+ end
+```
+
+**Пример использования:**
+
+```php
+// POST /store-staffing/create
+$model = new StoreStaffing();
+
+if ($this->request->isPost) {
+ if ($model->load($this->request->post()) && $model->save()) {
+ Yii::$app->session->setFlash('success', 'Запись штатного расписания успешно создана.');
+ return $this->redirect(['view', 'id' => $model->id]);
+ }
+}
+
+return $this->render('create', ['model' => $model]);
+```
+
+---
+
+### 4. actionUpdate($id)
+
+**Назначение:** Редактирование существующей записи штатного расписания
+
+**HTTP метод:** GET, POST
+**Маршрут:** `/store-staffing/update`
+
+**Параметры:**
+
+| Параметр | Тип | Обязательный | Описание |
+|----------|-----|--------------|----------|
+| `id` | int | Да | ID записи |
+| `StoreStaffing[count]` | float | Нет | Новое количество ставок |
+
+**Возвращает:**
+- Success: `Response` — редирект на `view`
+- Error: `string` — HTML форма с ошибками
+- Error: `NotFoundHttpException` — Запись не найдена
+
+**Бизнес-логика:** Аналогично `actionCreate()`, но с загрузкой существующей модели
+
+**Пример использования:**
+
+```php
+// POST /store-staffing/update?id=123
+$model = $this->findModel($id);
+
+if ($this->request->isPost && $model->load($this->request->post()) && $model->save()) {
+ Yii::$app->session->setFlash('success', 'Запись штатного расписания успешно обновлена.');
+ return $this->redirect(['view', 'id' => $model->id]);
+}
+
+return $this->render('update', ['model' => $model]);
+```
+
+---
+
+### 5. actionDelete($id)
+
+**Назначение:** Удаление записи штатного расписания
+
+**HTTP метод:** POST (только)
+**Маршрут:** `/store-staffing/delete`
+
+**Параметры:**
+
+| Параметр | Тип | Обязательный | Описание |
+|----------|-----|--------------|----------|
+| `id` | int | Да | ID записи |
+
+**Возвращает:**
+- Success: `Response` — редирект на `index`
+- Error: `NotFoundHttpException` — Запись не найдена
+
+**Ограничения:** VerbFilter разрешает только POST запросы
+
+**Последовательность выполнения:**
+
+```mermaid
+sequenceDiagram
+ participant User
+ participant Controller
+ participant StoreStaffing
+ participant Database
+
+ User->>Controller: POST /store-staffing/delete?id=123
+ Controller->>StoreStaffing: findOne(['id' => 123])
+
+ alt Запись найдена
+ StoreStaffing->>Database: DELETE
+ Database-->>Controller: Success
+ Controller->>Controller: setFlash('success')
+ Controller-->>User: Redirect to index
+ else Запись не найдена
+ Controller-->>User: NotFoundHttpException
+ end
+```
+
+**Пример использования:**
+
+```php
+// POST /store-staffing/delete?id=123
+public function actionDelete($id)
+{
+ $this->findModel($id)->delete();
+ Yii::$app->session->setFlash('success', 'Запись штатного расписания успешно удалена.');
+
+ return $this->redirect(['index']);
+}
+```
+
+---
+
+### 6. actionImport()
+
+**Назначение:** Массовый импорт штатного расписания из файла Excel
+
+**HTTP метод:** GET, POST
+**Маршрут:** `/store-staffing/import`
+
+**Параметры (POST):**
+
+| Параметр | Тип | Обязательный | Описание |
+|----------|-----|--------------|----------|
+| `DynamicModel[excelFile]` | UploadedFile | Да | Excel файл (.xls, .xlsx) |
+
+**Формат Excel файла:**
+
+| Колонка | Название | Тип | Описание |
+|---------|----------|-----|----------|
+| A | `store_name` | string | Название магазина (должно совпадать с БД) |
+| B | `position_name` | string | Название должности (поиск с умным алгоритмом) |
+| C | `posit` | int | Грейд должности (уровень обученности) |
+| D | `count` | float | Количество ставок |
+
+**Возвращает:**
+- Success: `Response` — редирект на `index` с flash-сообщением
+- Error: `string` — HTML форма с ошибками
+
+**Бизнес-логика:**
+
+1. Создать динамическую модель для загрузки файла
+2. Валидация файла (расширение xls/xlsx)
+3. Сохранить файл во временную директорию
+4. Обработать файл через `processImportFile()`:
+ - Чтение всех строк Excel
+ - Валидация каждой строки
+ - Поиск магазина по названию
+ - **Умный поиск должности** по названию (точное или частичное совпадение)
+ - Проверка соответствия грейда
+ - Создание новой записи или обновление существующей
+ - Накопление статистики: создано, обновлено, ошибки
+5. Показать результат импорта в flash-сообщении
+6. Удалить временный файл
+7. Редирект на список
+
+**Последовательность выполнения:**
+
+```mermaid
+sequenceDiagram
+ participant User
+ participant Controller
+ participant UploadedFile
+ participant ProcessImportFile
+ participant ExcelReader
+ participant Database
+
+ User->>Controller: POST /store-staffing/import (файл)
+ Controller->>UploadedFile: Валидация файла
+ UploadedFile-->>Controller: Success
+
+ Controller->>Controller: Сохранить в @runtime
+ Controller->>ProcessImportFile: processImportFile(path)
+
+ ProcessImportFile->>ExcelReader: IOFactory::load()
+ ExcelReader-->>ProcessImportFile: Rows
+
+ loop Каждая строка
+ ProcessImportFile->>ProcessImportFile: Валидация данных
+ ProcessImportFile->>ProcessImportFile: Поиск магазина
+ ProcessImportFile->>ProcessImportFile: Умный поиск должности
+ ProcessImportFile->>Database: INSERT/UPDATE
+ end
+
+ ProcessImportFile-->>Controller: {created, updated, errors}
+ Controller->>Controller: setFlash(статистика)
+ Controller->>Controller: Удалить временный файл
+ Controller-->>User: Redirect to index
+```
+
+**Алгоритм умного поиска должности (findPositionByName):**
+
+1. **Поиск точного совпадения** (strcasecmp)
+ ```php
+ if (strcasecmp($dbName, $fileName) === 0) {
+ return $positionId;
+ }
+ ```
+
+2. **Поиск по вхождению** (stripos) в обоих направлениях:
+ - Файл содержит БД: `stripos($fileName, $dbName) !== false`
+ - БД содержит файл: `stripos($dbName, $fileName) !== false`
+
+3. **Расчёт схожести** (similar_text) для всех кандидатов
+
+4. **Сортировка по проценту схожести** и выбор лучшего варианта
+
+**Пример:**
+- Файл: "Старший флорист"
+- БД: ["Флорист", "Старший флорист", "Младший флорист"]
+- Результат: "Старший флорист" (100% схожесть)
+
+**Пример использования:**
+
+```php
+// POST /store-staffing/import
+$model = new DynamicModel(['excelFile']);
+$model->addRule('excelFile', 'file', ['extensions' => ['xls', 'xlsx'], 'skipOnEmpty' => false]);
+
+if (Yii::$app->request->isPost) {
+ $model->excelFile = UploadedFile::getInstance($model, 'excelFile');
+
+ if ($model->validate()) {
+ $filePath = Yii::getAlias('@runtime') . '/import_' . uniqid() . '.' . $model->excelFile->extension;
+ $model->excelFile->saveAs($filePath);
+
+ try {
+ $results = $this->processImportFile($filePath);
+ Yii::$app->session->setFlash('success',
+ "Импорт завершен. Создано: {$results['created']}, обновлено: {$results['updated']}."
+ );
+ } catch (\Exception $e) {
+ Yii::$app->session->setFlash('error', 'Ошибка: ' . $e->getMessage());
+ }
+
+ return $this->redirect(['index']);
+ }
+}
+
+return $this->render('import', ['model' => $model]);
+```
+
+**Валидация строки импорта:**
+
+```php
+// Обязательные поля
+if (empty($storeName)) {
+ $results['errors'][] = "Строка $rowNum: название магазина пусто.";
+}
+
+if (empty($positionName)) {
+ $results['errors'][] = "Строка $rowNum: название должности пусто.";
+}
+
+if ($count <= 0) {
+ $results['errors'][] = "Строка $rowNum: количество должно быть больше 0.";
+}
+
+// Поиск магазина
+if (!isset($storesMap[$storeName])) {
+ $results['errors'][] = "Строка $rowNum: магазин '$storeName' не найден.";
+}
+
+// Умный поиск должности
+$positionId = $this->findPositionByName($positionName, $allPositions);
+if ($positionId === null) {
+ $results['errors'][] = "Строка $rowNum: должность '$positionName' не найдена.";
+}
+
+// Проверка соответствия грейда
+if ($posit != $actualPosit) {
+ $results['errors'][] = "Строка $rowNum: грейд должности '$positionName' должен быть $actualPosit, а не $posit.";
+}
+```
+
+---
+
+### 7. actionExportTemplate()
+
+**Назначение:** Скачать шаблон Excel для импорта штатного расписания
+
+**HTTP метод:** GET
+**Маршрут:** `/store-staffing/export-template`
+
+**Параметры:** Нет
+
+**Возвращает:**
+- `Response` — файл `store_staffing_template.xlsx`
+
+**Структура шаблона:**
+
+**Лист 1: "Инструкция"**
+- Заголовок: "ИНСТРУКЦИЯ ПО ИМПОРТУ ШТАТНОГО РАСПИСАНИЯ"
+- Описание колонок:
+ - A (store_name): Название магазина (должно совпадать)
+ - B (position_name): Название должности (должно совпадать)
+ - C (posit): Грейд должности (уровень обученности)
+ - D (count): Количество сотрудников
+
+**Лист 2: "Данные"**
+- Заголовки: `store_name`, `position_name`, `posit`, `count`
+- **Автоматическое заполнение комбинаций:**
+ - Все магазины (visible = 1)
+ - Все должности
+ - Грейд из БД (posit)
+ - Колонка `count` пустая (для ручного заполнения)
+
+**Форматирование:**
+- Заголовки: жирный, белый цвет, фон #366092
+- Ширина колонок: A=30, B=30, C=10, D=10
+
+**Бизнес-логика:**
+
+1. Создать Spreadsheet
+2. Лист 1: Инструкция
+ - Заголовок с форматированием
+ - Описание каждой колонки
+3. Лист 2: Данные
+ - Заголовки с форматированием
+ - Получить все магазины (visible = 1)
+ - Получить все должности
+ - Заполнить все комбинации (магазин × должность)
+ - Автоматически проставить грейды (posit) из БД
+4. Сохранить в временный файл
+5. Отправить файл пользователю
+6. Удалить временный файл
+
+**Последовательность выполнения:**
+
+```mermaid
+sequenceDiagram
+ participant User
+ participant Controller
+ participant Spreadsheet
+ participant CityStore
+ participant EmployeePosition
+ participant FileSystem
+
+ User->>Controller: GET /store-staffing/export-template
+ Controller->>Spreadsheet: new Spreadsheet()
+
+ Controller->>Spreadsheet: Создать лист "Инструкция"
+ Controller->>Spreadsheet: Заполнить описание
+
+ Controller->>Spreadsheet: Создать лист "Данные"
+ Controller->>CityStore: Получить все магазины
+ CityStore-->>Controller: Массив магазинов
+ Controller->>EmployeePosition: Получить все должности
+ EmployeePosition-->>Controller: Массив должностей
+
+ loop Магазин × Должность
+ Controller->>Spreadsheet: Заполнить строку
+ end
+
+ Controller->>Spreadsheet: Форматирование
+ Controller->>FileSystem: Сохранить во временный файл
+ Controller-->>User: Отправить файл
+ Controller->>FileSystem: Удалить временный файл
+```
+
+**Пример использования:**
+
+```php
+// GET /store-staffing/export-template
+public function actionExportTemplate()
+{
+ $spreadsheet = new \PhpOffice\PhpSpreadsheet\Spreadsheet();
+
+ // Лист 1: Инструкция
+ $sheet1 = $spreadsheet->getActiveSheet();
+ $sheet1->setTitle('Инструкция');
+ $sheet1->setCellValue('A1', 'ИНСТРУКЦИЯ ПО ИМПОРТУ ШТАТНОГО РАСПИСАНИЯ');
+ // ... описание колонок
+
+ // Лист 2: Данные
+ $sheet2 = $spreadsheet->createSheet();
+ $sheet2->setTitle('Данные');
+ $sheet2->setCellValue('A1', 'store_name');
+ $sheet2->setCellValue('B1', 'position_name');
+ $sheet2->setCellValue('C1', 'posit');
+ $sheet2->setCellValue('D1', 'count');
+
+ // Заполнение данных
+ $stores = CityStore::find()->where(['visible' => 1])->orderBy('name')->all();
+ $positions = EmployeePosition::find()->orderBy('name')->all();
+
+ $row = 2;
+ foreach ($stores as $store) {
+ foreach ($positions as $position) {
+ $sheet2->setCellValue('A' . $row, $store->name);
+ $sheet2->setCellValue('B' . $row, $position->name);
+ $sheet2->setCellValue('C' . $row, $position->posit);
+ $sheet2->setCellValue('D' . $row, ''); // Пустое для заполнения
+ $row++;
+ }
+ }
+
+ // Сохранить и отправить
+ $writer = new \PhpOffice\PhpSpreadsheet\Writer\Xlsx($spreadsheet);
+ $tempFile = Yii::getAlias('@runtime') . '/store_staffing_template_' . time() . '.xlsx';
+ $writer->save($tempFile);
+
+ $response = Yii::$app->response->sendFile($tempFile, 'store_staffing_template.xlsx');
+ @unlink($tempFile);
+
+ return $response;
+}
+```
+
+---
+
+### 8. actionLogs($store_id)
+
+**Назначение:** Просмотр логов изменений штатного расписания
+
+**HTTP метод:** GET
+**Маршрут:** `/store-staffing/logs`
+
+**Параметры:**
+
+| Параметр | Тип | Обязательный | Описание |
+|----------|-----|--------------|----------|
+| `store_id` | int | Нет | Фильтр по магазину |
+
+**Возвращает:**
+- `string` — HTML страница с таблицей логов
+
+**Бизнес-логика:**
+
+1. Создать запрос `StoreStaffingLog`
+2. Если указан `store_id` — применить фильтр
+3. Создать ActiveDataProvider с:
+ - Сортировка по `created_at DESC`
+ - Пагинация 100 записей на страницу
+4. Получить список магазинов для фильтра
+5. Отрендерить представление
+
+**Последовательность выполнения:**
+
+```mermaid
+sequenceDiagram
+ participant User
+ participant Controller
+ participant StoreStaffingLog
+ participant CityStore
+ participant View
+
+ User->>Controller: GET /store-staffing/logs?store_id=5
+ Controller->>StoreStaffingLog: find()
+
+ alt store_id указан
+ Controller->>StoreStaffingLog: where(['store_id' => 5])
+ end
+
+ Controller->>StoreStaffingLog: orderBy(['created_at' => DESC])
+ StoreStaffingLog-->>Controller: ActiveDataProvider
+
+ Controller->>CityStore: find()->select(['id', 'name'])
+ CityStore-->>Controller: Массив магазинов
+
+ Controller->>View: render('logs')
+ View-->>User: HTML таблица логов
+```
+
+**Пример использования:**
+
+```php
+// GET /store-staffing/logs?store_id=5
+public function actionLogs($store_id = null)
+{
+ $query = StoreStaffingLog::find();
+
+ if ($store_id !== null) {
+ $query->where(['store_id' => $store_id]);
+ }
+
+ $dataProvider = new ActiveDataProvider([
+ 'query' => $query,
+ 'sort' => [
+ 'defaultOrder' => [
+ 'created_at' => SORT_DESC,
+ ]
+ ],
+ 'pagination' => [
+ 'pageSize' => 100,
+ ]
+ ]);
+
+ $stores = ArrayHelper::map(CityStore::find()->select(['id', 'name'])->asArray()->all(), 'id', 'name');
+
+ return $this->render('logs', [
+ 'dataProvider' => $dataProvider,
+ 'stores' => $stores,
+ 'selectedStore' => $store_id,
+ ]);
+}
+```
+
+---
+
+### 9. actionGetPositionPosit($position_id)
+
+**Назначение:** AJAX-получение грейда должности по ID
+
+**HTTP метод:** GET
+**Маршрут:** `/store-staffing/get-position-posit`
+
+**Параметры:**
+
+| Параметр | Тип | Обязательный | Описание |
+|----------|-----|--------------|----------|
+| `position_id` | int | Да | ID должности |
+
+**Возвращает:**
+- Success: `JSON` `{"posit": 5}`
+- Error: `JSON` `{"posit": null}`
+
+**Бизнес-логика:**
+
+1. Найти должность по ID
+2. Если найдена → вернуть `posit`
+3. Если не найдена → вернуть `null`
+
+**Последовательность выполнения:**
+
+```mermaid
+sequenceDiagram
+ participant Client
+ participant Controller
+ participant EmployeePosition
+
+ Client->>Controller: GET /store-staffing/get-position-posit?position_id=15
+ Controller->>EmployeePosition: findOne(['id' => 15])
+
+ alt Должность найдена
+ EmployeePosition-->>Controller: EmployeePosition model
+ Controller-->>Client: {"posit": 5}
+ else Не найдена
+ Controller-->>Client: {"posit": null}
+ end
+```
+
+**Пример использования:**
+
+```javascript
+// JavaScript (для автозаполнения формы)
+$('#position-select').change(function() {
+ const positionId = $(this).val();
+
+ $.ajax({
+ url: '/store-staffing/get-position-posit',
+ type: 'GET',
+ data: { position_id: positionId },
+ success: function(response) {
+ if (response.posit !== null) {
+ $('#posit-input').val(response.posit);
+ }
+ }
+ });
+});
+```
+
+**Пример PHP:**
+
+```php
+// GET /store-staffing/get-position-posit?position_id=15
+public function actionGetPositionPosit($position_id)
+{
+ $position = EmployeePosition::findOne(['id' => $position_id]);
+
+ if ($position) {
+ return $this->asJson(['posit' => $position->posit]);
+ }
+
+ return $this->asJson(['posit' => null]);
+}
+```
+
+---
+
+## 💾 Работа с данными
+
+### Используемые модели
+
+#### StoreStaffing
+**Путь:** `yii_app\records\StoreStaffing`
+**Таблица БД:** `store_staffing`
+
+**Основные поля:**
+- `id` — ID записи
+- `store_id` — ID магазина (FK → city_store.id)
+- `employee_position_id` — ID должности (FK → employee_position.id)
+- `count` — Количество ставок (float)
+
+**Связи:**
+- `hasOne(CityStore, ['id' => 'store_id'])` — магазин
+- `hasOne(EmployeePosition, ['id' => 'employee_position_id'])` — должность
+
+**Поведения:**
+- Возможно логирование изменений через `StoreStaffingLog`
+
+---
+
+#### CityStore
+**Путь:** `yii_app\records\CityStore`
+**Таблица БД:** `city_store`
+
+**Основные поля:**
+- `id` — ID магазина
+- `name` — Название магазина
+- `visible` — Видимость (1 = видимый, 0 = скрытый)
+
+**Константы:**
+- `IS_VISIBLE = 1`
+
+---
+
+#### EmployeePosition
+**Путь:** `yii_app\records\EmployeePosition`
+**Таблица БД:** `employee_position`
+
+**Основные поля:**
+- `id` — ID должности
+- `name` — Название должности
+- `posit` — Грейд (уровень обученности)
+
+---
+
+#### StoreStaffingLog
+**Путь:** `yii_app\records\StoreStaffingLog`
+**Таблица БД:** `store_staffing_log`
+
+**Основные поля:**
+- `id` — ID записи лога
+- `store_id` — ID магазина
+- `employee_position_id` — ID должности
+- `old_count` — Старое значение count
+- `new_count` — Новое значение count
+- `action` — Действие (create, update, delete)
+- `created_at` — Дата изменения
+- `created_by` — Пользователь, внесший изменение
+
+---
+
+## ⚠️ Обработка ошибок
+
+### Типичные ошибки импорта
+
+| Ошибка | Причина | Решение |
+|--------|---------|---------|
+| "название магазина пусто" | Пустое поле A | Заполнить название магазина |
+| "название должности пусто" | Пустое поле B | Заполнить название должности |
+| "количество должно быть больше 0" | count <= 0 | Указать положительное число |
+| "магазин '...' не найден" | Название не совпадает с БД | Проверить название (точное совпадение) |
+| "должность '...' не найдена" | Умный поиск не нашел совпадений | Проверить название должности |
+| "грейд должности должен быть X, а не Y" | Несоответствие posit | Исправить грейд в файле |
+
+### Exception handling
+
+```php
+try {
+ $results = $this->processImportFile($filePath);
+ Yii::$app->session->setFlash('success',
+ "Импорт завершен. Создано: {$results['created']}, обновлено: {$results['updated']}."
+ );
+ if (count($results['errors']) > 0) {
+ Yii::$app->session->setFlash('warning', "Обнаружены ошибки: " . implode('; ', array_slice($results['errors'], 0, 5)));
+ }
+} catch (\Exception $e) {
+ Yii::$app->session->setFlash('error', 'Ошибка при обработке файла: ' . $e->getMessage());
+}
+```
+
+---
+
+## 🧪 Примеры использования
+
+### Сценарий 1: Создание записи штатного расписания
+
+**Описание:** Добавить в магазин должность "Флорист" с 2.5 ставками
+
+**Шаги:**
+1. Открыть `/store-staffing/create`
+2. Выбрать магазин
+3. Выбрать должность "Флорист"
+4. Грейд автоматически подставится через AJAX
+5. Указать count = 2.5
+6. Нажать "Сохранить"
+
+**Код (JavaScript для автозаполнения грейда):**
+
+```javascript
+$('#storestaffing-employee_position_id').change(function() {
+ const positionId = $(this).val();
+
+ $.get('/store-staffing/get-position-posit', { position_id: positionId }, function(response) {
+ if (response.posit !== null) {
+ $('#storestaffing-posit').val(response.posit);
+ }
+ });
+});
+```
+
+---
+
+### Сценарий 2: Массовый импорт из Excel
+
+**Описание:** Импортировать штатное расписание для 50 магазинов
+
+**Шаги:**
+1. Скачать шаблон: `/store-staffing/export-template`
+2. Открыть лист "Данные"
+3. Заполнить колонку `count` для нужных комбинаций
+4. Сохранить файл
+5. Открыть `/store-staffing/import`
+6. Загрузить файл
+7. Просмотреть результат импорта
+
+**Пример заполнения Excel:**
+
+| store_name | position_name | posit | count |
+|------------|---------------|-------|-------|
+| Магазин 1 | Флорист | 5 | 3.0 |
+| Магазин 1 | Старший флорист | 7 | 1.5 |
+| Магазин 1 | Администратор | 6 | 2.0 |
+| Магазин 2 | Флорист | 5 | 2.5 |
+
+---
+
+### Сценарий 3: Просмотр истории изменений
+
+**Описание:** Посмотреть все изменения штатного расписания для конкретного магазина
+
+**Шаги:**
+1. Открыть `/store-staffing/logs`
+2. Выбрать магазин в фильтре
+3. Просмотреть таблицу логов
+
+**Пример лога:**
+
+| Дата | Магазин | Должность | Старое значение | Новое значение | Действие | Пользователь |
+|------|---------|-----------|-----------------|----------------|----------|--------------|
+| 2025-01-15 10:30 | Магазин 1 | Флорист | 2.5 | 3.0 | update | admin |
+| 2025-01-14 14:20 | Магазин 1 | Старший флорист | - | 1.5 | create | manager |
+
+---
+
+## ❓ FAQ
+
+### Вопрос 1: Как работает умный поиск должностей при импорте?
+
+**Ответ:** Умный поиск `findPositionByName()` работает в 2 этапа:
+
+1. **Точное совпадение:** `strcasecmp($dbName, $fileName) === 0`
+2. **Поиск по вхождению с расчётом схожести:**
+ - Проверяет вхождение в обоих направлениях
+ - Рассчитывает процент схожести `similar_text()`
+ - Выбирает кандидата с максимальным процентом
+
+**Пример:**
+```
+Файл: "старший флорист"
+БД: ["Флорист", "Старший флорист", "Младший флорист"]
+→ Найдено: "Старший флорист" (100% схожесть)
+```
+
+---
+
+### Вопрос 2: Что происходит с записями при импорте, если они уже существуют?
+
+**Ответ:** При импорте проверяется наличие записи по комбинации `(store_id, employee_position_id)`:
+
+- **Если запись существует:**
+ - Проверяется `count`
+ - Если `count` изменился → UPDATE
+ - Если не изменился → пропуск
+- **Если запись не существует:**
+ - INSERT новой записи
+
+---
+
+### Вопрос 3: Почему экспортируемый шаблон содержит все комбинации магазинов и должностей?
+
+**Ответ:** Это сделано для удобства:
+
+- Пользователь видит все возможные комбинации
+- Не нужно вручную заполнять названия и грейды
+- Достаточно заполнить только колонку `count`
+- Снижается вероятность ошибок в названиях
+
+Количество строк: `количество магазинов × количество должностей`
+
+Например: 50 магазинов × 20 должностей = 1000 строк в шаблоне
+
+---
+
+## 🔗 Связанные компоненты
+
+- [StoreStaffing](../../models/StoreStaffing.md) — Модель штатного расписания
+- [CityStore](../../models/CityStore.md) — Модель магазинов
+- [EmployeePosition](../../models/EmployeePosition.md) — Модель должностей
+- [StoreStaffingLog](../../models/StoreStaffingLog.md) — Логи изменений
+
+---
+
+## 📝 История изменений
+
+| Дата | Версия | Изменения |
+|------|--------|-----------|
+| 2025-11-26 | 1.0 | Первая версия документации |
+
+---
+
+**Документация создана:** Claude Code + Hive Mind Controllers Swarm
+**Дата:** 2025-11-26
+**Фаза:** 2 (Крупные контроллеры)
+**Статус:** ✅ Готово
--- /dev/null
+# StoreStaffingController - Краткая справка
+
+## 📌 Основная информация
+
+**Контроллер:** `StoreStaffingController`
+**Путь:** `erp24/controllers/StoreStaffingController.php`
+**Размер:** 516 строк
+**Actions:** 9 (5 CRUD + 4 дополнительных)
+**Категория:** Крупный контроллер (Фаза 2)
+
+---
+
+## 🎯 Назначение в одном предложении
+
+CRUD-контроллер для управления штатным расписанием магазинов с массовым импортом/экспортом Excel, логированием изменений и умным поиском должностей.
+
+---
+
+## 🔑 Ключевые особенности
+
+✅ **Полный CRUD:** создание, просмотр, редактирование, удаление записей
+✅ **Массовый импорт:** из Excel с валидацией и умным поиском должностей
+✅ **Экспорт шаблона:** автоматически заполненный Excel со всеми комбинациями
+✅ **Логирование:** история изменений (StoreStaffingLog)
+✅ **AJAX API:** получение грейда должности для автозаполнения
+✅ **Умный поиск должностей:** точное совпадение + расчёт схожести
+
+---
+
+## 📋 Actions (9)
+
+| # | Action | Тип | Назначение |
+|---|--------|-----|------------|
+| 1 | `actionIndex()` | CRUD | Список записей |
+| 2 | `actionView($id)` | CRUD | Просмотр записи |
+| 3 | `actionCreate()` | CRUD | Создание записи |
+| 4 | `actionUpdate($id)` | CRUD | Редактирование |
+| 5 | `actionDelete($id)` | CRUD | Удаление |
+| 6 | `actionImport()` | Excel | Импорт из Excel |
+| 7 | `actionExportTemplate()` | Excel | Экспорт шаблона |
+| 8 | `actionLogs($store_id)` | Log | Просмотр логов |
+| 9 | `actionGetPositionPosit($id)` | AJAX | Получить грейд |
+
+---
+
+## 🗂️ Используемые модели
+
+| Модель | Таблица | Назначение |
+|--------|---------|------------|
+| **StoreStaffing** | `store_staffing` | Штатное расписание (store, position, count) |
+| **CityStore** | `city_store` | Справочник магазинов |
+| **EmployeePosition** | `employee_position` | Справочник должностей (с грейдом) |
+| **StoreStaffingLog** | `store_staffing_log` | Логи изменений |
+| **StoreStaffingSearch** | - | Поиск и фильтрация |
+
+---
+
+## 📊 Формат Excel для импорта
+
+| Колонка | Название | Тип | Валидация | Пример |
+|---------|----------|-----|-----------|--------|
+| A | `store_name` | string | Точное совпадение с БД | "Магазин 1" |
+| B | `position_name` | string | Умный поиск | "Старший флорист" |
+| C | `posit` | int | Должен совпадать с БД | 5 |
+| D | `count` | float | > 0 | 2.5 |
+
+---
+
+## 🔄 Основные процессы
+
+### 1. CRUD операции
+
+```mermaid
+graph LR
+ A[Index] --> B[View]
+ A --> C[Create]
+ B --> D[Update]
+ B --> E[Delete]
+ C --> B
+ D --> B
+ E --> A
+```
+
+### 2. Импорт из Excel
+
+```mermaid
+graph TB
+ A[Загрузить файл] --> B[Валидация файла]
+ B --> C[Чтение строк]
+ C --> D{Валидация строки}
+ D -->|OK| E[Поиск магазина]
+ D -->|Ошибка| F[errors++]
+ E --> G[Умный поиск должности]
+ G --> H{Запись существует?}
+ H -->|Да| I[UPDATE]
+ H -->|Нет| J[INSERT]
+ I --> K[updated++]
+ J --> L[created++]
+```
+
+### 3. Умный поиск должности
+
+```mermaid
+graph TB
+ A[Название из файла] --> B{Точное совпадение?}
+ B -->|Да| C[Вернуть ID]
+ B -->|Нет| D[Поиск по вхождению]
+ D --> E[Расчёт схожести]
+ E --> F[Сортировка по %]
+ F --> G{Найдены кандидаты?}
+ G -->|Да| H[Вернуть лучший]
+ G -->|Нет| I[Вернуть null]
+```
+
+---
+
+## 💡 Быстрые примеры
+
+### Создание записи
+
+```php
+// POST /store-staffing/create
+$model = new StoreStaffing();
+$model->store_id = 5;
+$model->employee_position_id = 10;
+$model->count = 2.5;
+$model->save();
+```
+
+### Импорт из Excel
+
+```bash
+# 1. Скачать шаблон
+curl -X GET "https://erp24.example.com/store-staffing/export-template" -o template.xlsx
+
+# 2. Заполнить колонку D (count)
+
+# 3. Загрузить файл через форму
+# POST /store-staffing/import
+```
+
+### AJAX: получить грейд должности
+
+```javascript
+$.get('/store-staffing/get-position-posit', { position_id: 15 }, function(response) {
+ console.log('Грейд:', response.posit); // 5
+});
+```
+
+### Просмотр логов изменений
+
+```bash
+# Все логи
+curl "https://erp24.example.com/store-staffing/logs"
+
+# Логи для магазина ID=5
+curl "https://erp24.example.com/store-staffing/logs?store_id=5"
+```
+
+---
+
+## ⚙️ Алгоритм умного поиска должности
+
+**Метод:** `findPositionByName($positionName, $allPositions)`
+
+**Шаги:**
+
+1. **Точное совпадение** (без учёта регистра)
+ ```php
+ if (strcasecmp($dbName, $fileName) === 0) {
+ return $positionId; // 100% совпадение
+ }
+ ```
+
+2. **Поиск по вхождению** в обоих направлениях
+ ```php
+ $fileContainsDb = stripos($fileName, $dbName) !== false;
+ $dbContainsFile = stripos($dbName, $fileName) !== false;
+ ```
+
+3. **Расчёт процента схожести** для всех кандидатов
+ ```php
+ similar_text($fileName, $dbName, $percent);
+ ```
+
+4. **Выбор лучшего кандидата** (максимальный процент)
+
+**Примеры:**
+
+| Файл | БД | Результат | Схожесть |
+|------|-----|-----------|----------|
+| "Флорист" | ["Флорист", "Старший флорист"] | Флорист | 100% |
+| "Старший флорист" | ["Флорист", "Старший флорист"] | Старший флорист | 100% |
+| "флорист" | ["Флорист"] | Флорист | 100% |
+| "Florist" | ["Флорист"] | null | 0% |
+
+---
+
+## 📈 Структура шаблона Excel
+
+**Лист 1: "Инструкция"**
+- Описание формата импорта
+- Объяснение каждой колонки
+
+**Лист 2: "Данные"**
+- Заголовки: `store_name`, `position_name`, `posit`, `count`
+- **Автозаполнение:**
+ - Все магазины (visible = 1)
+ - Все должности
+ - Грейды из БД
+ - Колонка `count` пустая
+
+**Количество строк:** `магазины × должности`
+
+**Пример:** 50 магазинов × 20 должностей = **1000 строк**
+
+---
+
+## ⚠️ Валидация импорта
+
+### Обязательные проверки
+
+```php
+// Название магазина
+if (empty($storeName)) {
+ $errors[] = "Строка $rowNum: название магазина пусто.";
+}
+
+// Название должности
+if (empty($positionName)) {
+ $errors[] = "Строка $rowNum: название должности пусто.";
+}
+
+// Количество
+if ($count <= 0) {
+ $errors[] = "Строка $rowNum: количество должно быть больше 0.";
+}
+
+// Магазин существует
+if (!isset($storesMap[$storeName])) {
+ $errors[] = "Строка $rowNum: магазин '$storeName' не найден.";
+}
+
+// Должность найдена
+$positionId = $this->findPositionByName($positionName, $allPositions);
+if ($positionId === null) {
+ $errors[] = "Строка $rowNum: должность '$positionName' не найдена.";
+}
+
+// Соответствие грейда
+if ($posit != $actualPosit) {
+ $errors[] = "Строка $rowNum: грейд должности должен быть $actualPosit, а не $posit.";
+}
+```
+
+---
+
+## 🔐 VerbFilter
+
+```php
+'verbs' => [
+ 'class' => VerbFilter::className(),
+ 'actions' => [
+ 'delete' => ['POST'], // Только POST
+ 'import' => ['POST', 'GET'], // POST и GET
+ ],
+],
+```
+
+**Результат:**
+- `actionDelete()` — только POST (защита от CSRF)
+- `actionImport()` — GET (форма) + POST (обработка)
+
+---
+
+## 📊 Логирование изменений
+
+**Модель:** `StoreStaffingLog`
+
+**Поля:**
+- `store_id` — ID магазина
+- `employee_position_id` — ID должности
+- `old_count` — Старое значение
+- `new_count` — Новое значение
+- `action` — Действие (create, update, delete)
+- `created_at` — Дата изменения
+- `created_by` — Пользователь
+
+**Просмотр:**
+```php
+// GET /store-staffing/logs?store_id=5
+```
+
+**Сортировка:** `created_at DESC`
+**Пагинация:** 100 записей/страница
+
+---
+
+## 🚨 Типичные ошибки импорта
+
+| Ошибка | Причина | Решение |
+|--------|---------|---------|
+| "название магазина пусто" | Пустая колонка A | Заполнить |
+| "название должности пусто" | Пустая колонка B | Заполнить |
+| "количество должно быть больше 0" | count <= 0 | Указать > 0 |
+| "магазин не найден" | Неточное название | Проверить точное совпадение |
+| "должность не найдена" | Умный поиск не сработал | Исправить название |
+| "грейд должен быть X" | Несоответствие posit | Исправить в файле |
+
+---
+
+## 💾 Структура БД
+
+### Таблица store_staffing
+
+```sql
+CREATE TABLE store_staffing (
+ id SERIAL PRIMARY KEY,
+ store_id INTEGER NOT NULL REFERENCES city_store(id),
+ employee_position_id INTEGER NOT NULL REFERENCES employee_position(id),
+ count DECIMAL(5,2) NOT NULL,
+ UNIQUE(store_id, employee_position_id)
+);
+```
+
+**Уникальный индекс:** `(store_id, employee_position_id)`
+
+**Ограничения:**
+- Один магазин + одна должность = одна запись
+- При импорте: если комбинация существует → UPDATE, иначе INSERT
+
+---
+
+## 🔗 Связанные компоненты
+
+- **[StoreStaffing](../../models/StoreStaffing.md)** — Модель штатного расписания
+- **[CityStore](../../models/CityStore.md)** — Модель магазинов
+- **[EmployeePosition](../../models/EmployeePosition.md)** — Модель должностей
+- **PhpSpreadsheet** — Библиотека для Excel
+
+---
+
+## 📝 Полезные ссылки
+
+- [Детальный анализ контроллера](./StoreStaffingController_ANALYSIS.md)
+- [Таблица Actions](./StoreStaffingController_ACTIONS_TABLE.md)
+- [План документирования контроллеров](../CONTROLLERS_DOCUMENTATION_PLAN.md)
+
+---
+
+## ❓ FAQ (Топ-5)
+
+### 1. Зачем нужен умный поиск должностей при импорте?
+
+**Ответ:** Умный поиск позволяет:
+- Находить должности с неточным названием ("флорист" → "Флорист")
+- Сопоставлять частичные совпадения ("Старший флорист" ⊇ "Флорист")
+- Снизить количество ошибок импорта
+- Повысить гибкость формата файла
+
+---
+
+### 2. Что происходит при импорте, если запись уже существует?
+
+**Ответ:** При импорте проверяется комбинация `(store_id, employee_position_id)`:
+
+- **Существует:**
+ - Если `count` изменился → **UPDATE** → `updated++`
+ - Если не изменился → **пропуск**
+- **Не существует:**
+ - **INSERT** → `created++`
+
+---
+
+### 3. Почему в шаблоне Excel все комбинации уже заполнены?
+
+**Ответ:** Это упрощает импорт:
+- Не нужно вручную вводить названия
+- Грейды (`posit`) уже проставлены
+- Достаточно заполнить только колонку `count`
+- Меньше ошибок в названиях магазинов и должностей
+
+---
+
+### 4. Как работает AJAX автозаполнение грейда в форме?
+
+**Ответ:** При выборе должности в select:
+
+```javascript
+$('#position-select').change(function() {
+ const positionId = $(this).val();
+
+ // AJAX запрос
+ $.get('/store-staffing/get-position-posit', { position_id: positionId }, function(response) {
+ $('#posit-input').val(response.posit); // Автозаполнение
+ $('#posit-input').attr('readonly', true); // Блокировка редактирования
+ });
+});
+```
+
+---
+
+### 5. Где хранятся логи изменений штатного расписания?
+
+**Ответ:** Логи хранятся в таблице `store_staffing_log`:
+
+- Каждое изменение `count` → новая запись лога
+- Поля: `old_count`, `new_count`, `action`, `created_at`, `created_by`
+- Просмотр: `/store-staffing/logs?store_id=5`
+- Сортировка: по дате DESC (новые сверху)
+
+---
+
+## 🎨 Workflow диаграммы
+
+### Создание записи
+
+```
+[Форма] → [Валидация] → [Сохранение в БД] → [Лог] → [Redirect to View]
+```
+
+### Импорт
+
+```
+[Excel файл] → [Валидация] → [Парсинг строк] → [Умный поиск] → [INSERT/UPDATE] → [Статистика]
+```
+
+### Экспорт шаблона
+
+```
+[Запрос] → [Получить магазины] → [Получить должности] → [Генерация Excel] → [Скачивание]
+```
+
+---
+
+**Краткая справка создана:** 2025-11-26
+**Версия:** 1.0
+**Статус:** ✅ Готово
--- /dev/null
+# ClusterAdminController - Таблица Actions
+
+## Быстрая справка по действиям
+
+| # | Action | HTTP | Маршрут | Назначение | Параметры | Возвращает |
+|---|--------|------|---------|------------|-----------|------------|
+| 1 | `actionIndex()` | GET | `/crud/cluster-admin/index` | Список кустов с админами | - | HTML |
+| 2 | `actionView($id)` | GET | `/crud/cluster-admin/view` | История назначений куста | `id` (cluster_id) | HTML |
+| 3 | `actionCreate($cluster_id)` | GET/POST | `/crud/cluster-admin/create` | Создание назначения | `cluster_id`, POST: модель | HTML/Redirect |
+| 4 | `actionUpdate($id)` | GET/POST | `/crud/cluster-admin/update` | Редактирование назначения | `id`, POST: модель | HTML/Redirect |
+| 5 | `actionDelete($id)` | POST | `/crud/cluster-admin/delete` | Удаление назначения | `id` | Redirect |
+| 6 | `actionMoveAdmin()` | POST | `/crud/cluster-admin/move-admin` | AJAX: переместить админа | `admin_id`, `cluster_id` | JSON |
+| 7 | `getAccess()` | - | - | Проверка прав доступа | - | bool |
+
+---
+
+## Детальное описание Actions
+
+### 1. actionIndex()
+
+**Сигнатура:**
+```php
+public function actionIndex(): string|\yii\web\Response
+```
+
+**Назначение:** Отображение списка всех кустов с информацией о назначенных администраторах
+
+**Параметры:** Нет
+
+**Возвращает:**
+- Success: `string` — HTML страница
+- Access denied: `Response` — redirect to `/`
+
+**Контроль доступа:** `getAccess()` — запрет для групп IT, HR, директора
+
+**Данные представления:**
+
+| Переменная | Тип | Описание |
+|------------|-----|----------|
+| `clusterMapping` | array | Маппинг кустов с назначениями |
+| `clustersList` | array | Список всех кустов |
+| `admins` | array | Менеджеры кустов (group_id=7) |
+
+**Структура clusterMapping:**
+```php
+[
+ 1 => [
+ 'name' => 'Куст 1',
+ 'admin' => 'Иванов И.И. (123)',
+ 'admin_id' => 123,
+ 'status' => 'Активная запись',
+ 'hasActive' => true,
+ 'hasAny' => true,
+ ],
+ 2 => [
+ 'name' => 'Куст 2',
+ 'admin' => null,
+ 'status' => 'Нет записей',
+ 'hasActive' => false,
+ 'hasAny' => false,
+ ],
+]
+```
+
+**Статусы:**
+- `"Активная запись"` — есть активное назначение
+- `"Нет активных записей"` — есть записи, но все неактивные
+- `"Нет записей"` — записей нет
+
+**Пример HTTP:**
+```bash
+curl -X GET "https://erp24.example.com/crud/cluster-admin/index"
+```
+
+---
+
+### 2. actionView($id)
+
+**Сигнатура:**
+```php
+public function actionView(int $id): string|\yii\web\Response
+```
+
+**Назначение:** Просмотр истории назначений администраторов для конкретного куста
+
+**Параметры:**
+
+| Параметр | Тип | Обязательный | Описание |
+|----------|-----|--------------|----------|
+| `id` | int | Да | ID куста (Cluster.id) |
+
+**Возвращает:**
+- Success: `string` — HTML страница с таблицей
+- Error: `NotFoundHttpException` — "Кластер не найден."
+- Access denied: `Response` — redirect to `/`
+
+**Данные представления:**
+
+| Переменная | Тип | Описание |
+|------------|-----|----------|
+| `cluster` | Cluster | Модель куста |
+| `clusterAdmins` | ClusterAdmin[] | Все назначения куста (with `admin`) |
+| `user` | Admin | Текущий пользователь |
+
+**Пример HTTP:**
+```bash
+curl -X GET "https://erp24.example.com/crud/cluster-admin/view?id=5"
+```
+
+**Пример данных (таблица):**
+
+| ID | Администратор | Дата начала | Дата окончания | Активность | Действия |
+|----|---------------|-------------|----------------|------------|----------|
+| 123 | Иванов И.И. | 2025-01-01 | - | ✅ Активный | Редактировать, Удалить |
+| 122 | Петров П.П. | 2024-06-01 | 2024-12-31 | ❌ Неактивный | Редактировать, Удалить |
+
+---
+
+### 3. actionCreate($cluster_id)
+
+**Сигнатура:**
+```php
+public function actionCreate(int $cluster_id): string|\yii\web\Response
+```
+
+**Назначение:** Создание нового назначения администратора на куст
+
+**HTTP метод:** GET (форма), POST (создание)
+
+**Параметры:**
+
+| Параметр | Тип | Обязательный | Описание |
+|----------|-----|--------------|----------|
+| `cluster_id` | int | Да (GET) | ID куста |
+| `ClusterAdmin[admin_id]` | int | Да (POST) | ID администратора |
+| `ClusterAdmin[active]` | int | Да (POST) | Активность (0/1) |
+| `ClusterAdmin[date_start]` | date | Да (POST) | Дата начала |
+| `ClusterAdmin[date_end]` | date | Нет (POST) | Дата окончания |
+
+**Возвращает:**
+- Success: `Response` — redirect to `view`
+- Error: `string` — HTML форма с ошибками
+- Access denied: `Response` — redirect to `/`
+
+**Валидация:**
+
+**Для неактивной записи (active=0):**
+```php
+// Проверка, что даты не позже даты начала активной записи
+if ($modelStart >= $activeRecordStart || $modelEnd >= $activeRecordStart) {
+ // Ошибка
+}
+```
+
+**Для активной записи (active=1):**
+```php
+// Деактивация существующей активной записи куста
+$existingRecord = ClusterAdmin::find()
+ ->where(['cluster_id' => $model->cluster_id, 'active' => 1])
+ ->one();
+
+if ($existingRecord) {
+ $existingRecord->active = 0;
+ $existingRecord->date_end = date('Y-m-d');
+ $existingRecord->save();
+}
+```
+
+**Flash-сообщения:**
+- Success: `"Новая запись успешно создана, предыдущая была закрыта."`
+- Error: `"Ошибка: даты начала или окончания новой неактивной записи не могут быть позже даты начала существующей активной записи (2025-01-01)."`
+
+**Пример HTTP:**
+```bash
+curl -X POST "https://erp24.example.com/crud/cluster-admin/create?cluster_id=5" \
+ -d "ClusterAdmin[admin_id]=123" \
+ -d "ClusterAdmin[active]=1" \
+ -d "ClusterAdmin[date_start]=2025-01-01"
+```
+
+---
+
+### 4. actionUpdate($id)
+
+**Сигнатура:**
+```php
+public function actionUpdate(int $id): string|\yii\web\Response
+```
+
+**Назначение:** Редактирование существующего назначения администратора
+
+**HTTP метод:** GET (форма), POST (обновление)
+
+**Параметры:**
+
+| Параметр | Тип | Обязательный | Описание |
+|----------|-----|--------------|----------|
+| `id` | int | Да | ID записи ClusterAdmin |
+
+**Возвращает:**
+- Success: `Response` — redirect to `view`
+- Error: `string` — HTML форма с ошибками
+- Access denied: `Response` — redirect to `/`
+
+**Валидация для активной записи (active=1):**
+
+**1. Конфликт с активными записями других кустов:**
+```php
+$existingActiveRecords = ClusterAdmin::find()
+ ->where(['admin_id' => $model->admin_id, 'active' => 1])
+ ->andWhere(['!=', 'cluster_id', $model->cluster_id])
+ ->all();
+
+if (!empty($existingActiveRecords)) {
+ // Ошибка: "Этот пользователь уже привязан к кластеру X с активным статусом."
+}
+```
+
+**2. Конфликт с неактивными записями других кустов:**
+```php
+foreach ($existingInactiveRecords as $record) {
+ if ($modelStart < $recordStart && $modelStart < $recordEnd) {
+ // Ошибка: активная запись не может начинаться раньше неактивной
+ }
+}
+```
+
+**Валидация для неактивной записи (active=0):**
+
+**Проверка пересечения дат:**
+```php
+if (
+ ($modelStart > $recordStart && $modelStart < $recordEnd) || // Начало внутри
+ ($modelEnd > $recordStart && $modelEnd < $recordEnd) || // Конец внутри
+ ($modelStart <= $recordStart && $modelEnd >= $recordEnd) // Полное охватывание
+) {
+ // Ошибка: "Пересечение дат с записью от ... до ..."
+}
+```
+
+**Flash-сообщения:**
+- Success: `"Запись успешно обновлена."`
+- Error: `"Этот пользователь Иванов И.И. уже привязан к кластеру 3 с активным статусом."`
+- Error: `"Пересечение дат с записью от 2024-01-01 до 2024-12-31 для другого кластера - 3 123"`
+
+**Пример HTTP:**
+```bash
+curl -X POST "https://erp24.example.com/crud/cluster-admin/update?id=123" \
+ -d "ClusterAdmin[date_end]=2024-06-30"
+```
+
+---
+
+### 5. actionDelete($id)
+
+**Сигнатура:**
+```php
+public function actionDelete(int $id): \yii\web\Response
+```
+
+**Назначение:** Удаление назначения администратора
+
+**HTTP метод:** POST (только) — ограничено VerbFilter
+
+**Параметры:**
+
+| Параметр | Тип | Обязательный | Описание |
+|----------|-----|--------------|----------|
+| `id` | int | Да | ID записи ClusterAdmin |
+
+**Возвращает:**
+- Success: `Response` — redirect to `view` (cluster_id)
+- Error: `NotFoundHttpException`
+- Access denied: `Response` — redirect to `/`
+
+**Бизнес-логика:**
+```php
+$model = $this->findModel($id);
+$cluster_id = $model->cluster_id;
+$model->delete();
+return $this->redirect(['view', 'id' => $cluster_id]);
+```
+
+**Пример HTTP:**
+```bash
+curl -X POST "https://erp24.example.com/crud/cluster-admin/delete?id=123"
+```
+
+**VerbFilter конфигурация:**
+```php
+'verbs' => [
+ 'class' => VerbFilter::class,
+ 'actions' => [
+ 'delete' => ['POST'],
+ ],
+],
+```
+
+---
+
+### 6. actionMoveAdmin()
+
+**Сигнатура:**
+```php
+public function actionMoveAdmin(): mixed
+```
+
+**Назначение:** AJAX-перемещение администратора между кустами
+
+**HTTP метод:** POST
+
+**Параметры (POST):**
+
+| Параметр | Тип | Обязательный | Описание |
+|----------|-----|--------------|----------|
+| `admin_id` | int | Да | ID нового администратора |
+| `cluster_id` | int | Да | ID куста назначения |
+
+**Возвращает:** JSON
+
+**Success:**
+```json
+{
+ "success": true
+}
+```
+
+**Error:**
+```json
+{
+ "success": false,
+ "message": "Администратор не найден или уже не активен."
+}
+```
+
+**Бизнес-логика (3 шага):**
+
+1. **Деактивация текущего админа куста:**
+ ```php
+ $currentClusterAdmin = ClusterAdmin::find()
+ ->where(['cluster_id' => $clusterId, 'active' => 1])
+ ->one();
+
+ if ($currentClusterAdmin) {
+ $currentClusterAdmin->active = 0;
+ $currentClusterAdmin->date_end = $currentDate;
+ $currentClusterAdmin->save(false);
+ }
+ ```
+
+2. **Деактивация старого куста нового админа:**
+ ```php
+ $oldClusterAdmin = ClusterAdmin::find()
+ ->where(['admin_id' => $newAdminId, 'active' => 1])
+ ->one();
+
+ if ($oldClusterAdmin) {
+ $oldClusterAdmin->active = 0;
+ $oldClusterAdmin->date_end = $currentDate;
+ $oldClusterAdmin->save(false);
+ }
+ ```
+
+3. **Создание нового назначения:**
+ ```php
+ $newClusterAdmin = new ClusterAdmin();
+ $newClusterAdmin->admin_id = $newAdminId;
+ $newClusterAdmin->cluster_id = $clusterId;
+ $newClusterAdmin->date_start = $currentDate;
+ $newClusterAdmin->active = 1;
+ $newClusterAdmin->save(false);
+ ```
+
+**Пример AJAX (JavaScript):**
+```javascript
+$.ajax({
+ url: '/crud/cluster-admin/move-admin',
+ type: 'POST',
+ data: {
+ admin_id: 123,
+ cluster_id: 5
+ },
+ success: function(response) {
+ if (response.success) {
+ location.reload();
+ } else {
+ alert('Ошибка: ' + response.message);
+ }
+ }
+});
+```
+
+**Закомментированный функционал:**
+```php
+// ClusterManagerService::clearClusterManagerStores($cluster_id, $admin_id);
+// ClusterManagerService::syncClusterManagers($cluster_id, $admin_id);
+```
+
+---
+
+### 7. getAccess()
+
+**Сигнатура:**
+```php
+public static function getAccess(): bool
+```
+
+**Назначение:** Проверка прав доступа к контроллеру
+
+**Параметры:** Нет
+
+**Возвращает:** `bool`
+- `true` — доступ **ЗАПРЕЩЁН** → redirect
+- `false` — доступ **РАЗРЕШЁН**
+
+**Группы БЕЗ доступа (return false):**
+```php
+AdminGroup::GROUP_IT
+AdminGroup::DIRECTOR
+AdminGroup::GROUP_HR
+AdminGroup::GROUP_HR_DIRECTOR
+AdminGroup::GROUP_RS_DIRECTOR
+```
+
+**Логика:**
+```php
+if (in_array($admin->group_id, [IT, DIRECTOR, HR, HR_DIR, RS_DIR])) {
+ return false; // Доступ РАЗРЕШЁН
+} else {
+ return true; // Доступ ЗАПРЕЩЁН
+}
+```
+
+**⚠️ Внимание:** Логика инвертирована! `true` = запрет, `false` = разрешение.
+
+**Использование:**
+```php
+if (self::getAccess()) {
+ return $this->redirect('/');
+}
+```
+
+---
+
+## Матрица HTTP методов
+
+| Action | GET | POST | DELETE | PUT |
+|--------|-----|------|--------|-----|
+| actionIndex | ✅ | ❌ | ❌ | ❌ |
+| actionView | ✅ | ❌ | ❌ | ❌ |
+| actionCreate | ✅ (форма) | ✅ (создание) | ❌ | ❌ |
+| actionUpdate | ✅ (форма) | ✅ (обновление) | ❌ | ❌ |
+| actionDelete | ❌ | ✅ | ❌ | ❌ |
+| actionMoveAdmin | ❌ | ✅ | ❌ | ❌ |
+
+---
+
+## Используемые модели
+
+| Модель | Таблица | Назначение |
+|--------|---------|------------|
+| `ClusterAdmin` | `cluster_admin` | Назначения админов (с историей) |
+| `Cluster` | `cluster` | Справочник кустов |
+| `Admin` | `admin` | Справочник администраторов |
+| `AdminGroup` | `admin_group` | Группы администраторов |
+
+---
+
+## Бизнес-правила
+
+### Активные записи (active=1)
+
+1. **Только одна активная запись на куст**
+ - При создании новой → старая деактивируется
+2. **Только одна активная запись на администратора**
+ - При назначении на новый куст → старый куст деактивируется
+3. **Активная запись не может начинаться раньше неактивной** для другого куста
+
+### Неактивные записи (active=0)
+
+1. **Даты не должны пересекаться** с другими записями администратора
+2. **Даты не должны быть позже** даты начала активной записи куста
+
+---
+
+## Примеры валидационных ошибок
+
+### Создание активной записи
+
+```
+❌ "Ошибка: даты начала или окончания новой неактивной записи
+ не могут быть позже даты начала существующей активной записи (2025-01-01)."
+```
+
+### Редактирование активной записи
+
+```
+❌ "Этот пользователь Иванов И.И. уже привязан к кластеру 3 с активным статусом."
+
+❌ "Ошибка: активная запись для Иванов И.И. не может начинаться раньше,
+ чем неактивная запись (2024-01-01 - 2024-12-31) для другого кластера."
+```
+
+### Редактирование неактивной записи
+
+```
+❌ "Пересечение дат с записью от 2024-01-01 до 2024-12-31
+ для другого кластера - 3 123"
+```
+
+---
+
+## Примеры CURL запросов
+
+### Получить список кустов
+```bash
+curl -X GET "https://erp24.example.com/crud/cluster-admin/index"
+```
+
+### Просмотреть историю куста
+```bash
+curl -X GET "https://erp24.example.com/crud/cluster-admin/view?id=5"
+```
+
+### Создать назначение
+```bash
+curl -X POST "https://erp24.example.com/crud/cluster-admin/create?cluster_id=5" \
+ -d "ClusterAdmin[admin_id]=123" \
+ -d "ClusterAdmin[active]=1" \
+ -d "ClusterAdmin[date_start]=2025-01-01"
+```
+
+### Переместить админа (AJAX)
+```bash
+curl -X POST "https://erp24.example.com/crud/cluster-admin/move-admin" \
+ -d "admin_id=123" \
+ -d "cluster_id=5"
+```
+
+### Удалить назначение
+```bash
+curl -X POST "https://erp24.example.com/crud/cluster-admin/delete?id=123"
+```
+
+---
+
+## Связанные файлы
+
+- [Детальный анализ](./ClusterAdminController_ANALYSIS.md)
+- [Краткая справка](./ClusterAdminController_QUICK_REFERENCE.md)
+- [README контроллеров](../../README.md)
+
+---
+
+**Документация создана:** 2025-11-26
+**Статус:** ✅ Готово
--- /dev/null
+# ClusterAdminController - Детальный анализ
+
+## 📋 Общая информация
+
+**Namespace:** `yii_app\controllers\crud`
+**Путь к файлу:** `erp24/controllers/crud/ClusterAdminController.php`
+**Extends:** `\yii\web\Controller`
+**Размер:** 503 строки кода
+**Количество actions:** 7
+**Категория:** Крупный контроллер (Фаза 2)
+**Приоритет:** HIGH
+
+---
+
+## 🎯 Назначение
+
+Контроллер для управления **привязкой администраторов к кустам магазинов** с поддержкой:
+
+- CRUD операции с записями ClusterAdmin
+- **Историчность данных:** активные/неактивные записи с датами начала/окончания
+- **Перемещение администраторов** между кустами
+- **Валидация пересечений дат** при создании/редактировании
+- **Контроль доступа:** только для определённых групп (IT, HR, директора)
+- **Автоматическое закрытие** предыдущих активных записей при создании новой
+
+Контроллер реализует сложную бизнес-логику по управлению назначениями менеджеров кустов с учётом временных периодов и предотвращением конфликтов.
+
+---
+
+## 🏗️ Архитектура
+
+### Основные компоненты
+
+```mermaid
+graph TB
+ Controller[ClusterAdminController]
+
+ subgraph "CRUD Actions"
+ A1[actionIndex<br/>Список кустов с админами]
+ A2[actionView<br/>История назначений куста]
+ A3[actionCreate<br/>Создание назначения]
+ A4[actionUpdate<br/>Редактирование назначения]
+ A5[actionDelete<br/>Удаление назначения]
+ end
+
+ subgraph "Special Actions"
+ A6[actionMoveAdmin<br/>AJAX: переместить админа]
+ A7[getAccess<br/>Проверка прав доступа]
+ end
+
+ subgraph "Models"
+ M1[ClusterAdmin<br/>Назначения админов]
+ M2[Cluster<br/>Кусты магазинов]
+ M3[Admin<br/>Администраторы]
+ M4[AdminGroup<br/>Группы админов]
+ end
+
+ subgraph "Services"
+ S1[ClusterManagerService<br/>Синхронизация магазинов]
+ end
+
+ Controller --> A1
+ Controller --> A2
+ Controller --> A3
+ Controller --> A4
+ Controller --> A5
+ Controller --> A6
+ Controller --> A7
+
+ A1 --> M1
+ A1 --> M2
+ A1 --> M3
+ A2 --> M1
+ A2 --> M2
+ A3 --> M1
+ A4 --> M1
+ A5 --> M1
+ A6 --> M1
+
+ A7 --> M4
+
+ A6 -.-> S1
+```
+
+### Зависимости
+
+| Компонент | Тип | Назначение |
+|-----------|-----|------------|
+| `ClusterAdmin` | Модель | Связь администратор-куст (с историей) |
+| `Cluster` | Модель | Справочник кустов магазинов |
+| `Admin` | Модель | Справочник администраторов |
+| `AdminGroup` | Модель | Группы администраторов (константы) |
+| `ClusterManagerService` | Сервис | Синхронизация магазинов (закомментировано) |
+| `VerbFilter` | Filter | Ограничение HTTP методов |
+| `AccessControl` | Filter | Контроль доступа (закомментировано) |
+
+---
+
+## 🔐 Система контроля доступа
+
+### Метод getAccess()
+
+**Назначение:** Проверка прав доступа к контроллеру
+
+**Логика:** Доступ **ЗАПРЕЩЁН** только для определённых групп, остальным **РАЗРЕШЁН**
+
+**Группы с доступом:**
+```php
+// Эти группы НЕ имеют доступа (return false → redirect)
+AdminGroup::GROUP_IT // IT отдел
+AdminGroup::DIRECTOR // Директор
+AdminGroup::GROUP_HR // HR
+AdminGroup::GROUP_HR_DIRECTOR // HR директор
+AdminGroup::GROUP_RS_DIRECTOR // RS директор
+```
+
+**Важно:** Логика инвертирована — функция возвращает `true` для ЗАПРЕТА доступа
+
+```php
+if (in_array($admin->group_id, [GROUP_IT, DIRECTOR, GROUP_HR, ...])) {
+ return false; // Доступ РАЗРЕШЁН
+} else {
+ return true; // Доступ ЗАПРЕЩЁН → redirect
+}
+```
+
+**Использование в actions:**
+```php
+if (self::getAccess()) {
+ return $this->redirect('/'); // Редирект на главную
+}
+```
+
+---
+
+## 📋 Actions
+
+### 1. actionIndex()
+
+**Назначение:** Отображение списка всех кустов с информацией о назначенных администраторах
+
+**HTTP метод:** GET
+**Маршрут:** `/crud/cluster-admin/index`
+
+**Параметры:** Нет
+
+**Возвращает:**
+- Success: `string` — HTML страница
+- Access denied: `Response` — redirect на `/`
+
+**Бизнес-логика:**
+
+1. Проверка прав доступа (`getAccess()`)
+2. Получить все кусты из таблицы `Cluster`
+3. Получить все записи `ClusterAdmin` с отношениями `admin`, `cluster`
+4. Получить список администраторов с группой `7` (менеджеры кустов)
+5. Создать маппинг для каждого куста:
+ - Название куста
+ - Назначенный админ (если есть активная запись)
+ - Статус: "Нет записей" / "Активная запись" / "Нет активных записей"
+ - Флаги: `hasActive`, `hasAny`
+6. Отрендерить представление
+
+**Структура clusterMapping:**
+
+```php
+$clusterMapping[$cluster_id] = [
+ 'name' => 'Куст 1',
+ 'admin' => 'Иванов И.И. (123)', // Если есть активная запись
+ 'admin_id' => 123, // ID админа
+ 'status' => 'Активная запись', // Статус
+ 'hasActive' => true, // Есть активная запись
+ 'hasAny' => true, // Есть любые записи
+];
+```
+
+**Последовательность выполнения:**
+
+```mermaid
+sequenceDiagram
+ participant User
+ participant Controller
+ participant Cluster
+ participant ClusterAdmin
+ participant Admin
+ participant View
+
+ User->>Controller: GET /crud/cluster-admin/index
+ Controller->>Controller: getAccess()
+
+ alt Доступ запрещён
+ Controller-->>User: Redirect to /
+ end
+
+ Controller->>Cluster: find()->all()
+ Cluster-->>Controller: Все кусты
+
+ Controller->>ClusterAdmin: find()->with(['admin', 'cluster'])->all()
+ ClusterAdmin-->>Controller: Все назначения
+
+ Controller->>Admin: find()->where(['group_id' => 7])
+ Admin-->>Controller: Менеджеры кустов
+
+ loop Каждый куст
+ Controller->>Controller: Найти активную запись
+ Controller->>Controller: Определить статус
+ end
+
+ Controller->>View: render('index')
+ View-->>User: HTML таблица кустов
+```
+
+**Пример данных:**
+
+| Куст | Администратор | Статус | Действия |
+|------|---------------|--------|----------|
+| Куст 1 | Иванов И.И. (123) | Активная запись | Переместить |
+| Куст 2 | - | Нет записей | Назначить |
+| Куст 3 | - | Нет активных записей | Назначить |
+
+---
+
+### 2. actionMoveAdmin()
+
+**Назначение:** AJAX-перемещение администратора между кустами
+
+**HTTP метод:** POST
+**Маршрут:** `/crud/cluster-admin/move-admin`
+
+**Параметры (POST):**
+
+| Параметр | Тип | Обязательный | Описание |
+|----------|-----|--------------|----------|
+| `admin_id` | int | Да | ID нового администратора |
+| `cluster_id` | int | Да | ID куста назначения |
+
+**Возвращает:** JSON
+
+**Success:**
+```json
+{
+ "success": true
+}
+```
+
+**Error:**
+```json
+{
+ "success": false,
+ "message": "Администратор не найден или уже не активен."
+}
+```
+
+**Бизнес-логика:**
+
+1. Получить `admin_id` и `cluster_id` из POST
+2. Получить текущую дату/время
+3. **Найти текущего админа куста:**
+ - `ClusterAdmin` где `cluster_id` = куст И `active` = 1
+ - Если найден → деактивировать:
+ - `active` = 0
+ - `date_end` = текущая дата
+ - Сохранить
+4. **Найти старый куст нового админа:**
+ - `ClusterAdmin` где `admin_id` = новый админ И `active` = 1
+ - Если найден → деактивировать:
+ - `active` = 0
+ - `date_end` = текущая дата
+ - Сохранить
+5. **Создать новое назначение:**
+ - `admin_id` = новый админ
+ - `cluster_id` = куст назначения
+ - `date_start` = текущая дата
+ - `active` = 1
+ - Сохранить
+6. Вернуть JSON `{"success": true}`
+
+**Закомментированный функционал:**
+```php
+// ClusterManagerService::clearClusterManagerStores($cluster_id, $admin_id);
+// ClusterManagerService::syncClusterManagers($cluster_id, $admin_id);
+```
+
+**Последовательность выполнения:**
+
+```mermaid
+sequenceDiagram
+ participant Client
+ participant Controller
+ participant ClusterAdmin
+ participant Database
+
+ Client->>Controller: POST {admin_id, cluster_id}
+
+ Controller->>ClusterAdmin: Найти текущего админа куста
+ alt Найден
+ ClusterAdmin->>Database: UPDATE active=0, date_end=now
+ end
+
+ Controller->>ClusterAdmin: Найти старый куст нового админа
+ alt Найден
+ ClusterAdmin->>Database: UPDATE active=0, date_end=now
+ end
+
+ Controller->>ClusterAdmin: new ClusterAdmin()
+ ClusterAdmin->>Database: INSERT новое назначение
+ Database-->>Controller: Success
+
+ Controller-->>Client: {"success": true}
+```
+
+**Пример использования (JavaScript):**
+
+```javascript
+$.ajax({
+ url: '/crud/cluster-admin/move-admin',
+ type: 'POST',
+ data: {
+ admin_id: 123, // Новый админ
+ cluster_id: 5 // Куст назначения
+ },
+ success: function(response) {
+ if (response.success) {
+ alert('Администратор успешно перемещён');
+ location.reload();
+ } else {
+ alert('Ошибка: ' + response.message);
+ }
+ }
+});
+```
+
+---
+
+### 3. actionView($id)
+
+**Назначение:** Просмотр истории назначений администраторов для конкретного куста
+
+**HTTP метод:** GET
+**Маршрут:** `/crud/cluster-admin/view`
+
+**Параметры:**
+
+| Параметр | Тип | Обязательный | Описание |
+|----------|-----|--------------|----------|
+| `id` | int | Да | ID куста (Cluster.id) |
+
+**Возвращает:**
+- Success: `string` — HTML страница с таблицей назначений
+- Error: `NotFoundHttpException` — "Кластер не найден."
+- Access denied: `Response` — redirect на `/`
+
+**Бизнес-логика:**
+
+1. Проверка прав доступа
+2. Найти куст по ID
+3. Если не найден → throw NotFoundHttpException
+4. Получить все записи `ClusterAdmin` для данного куста с отношением `admin`
+5. Отрендерить представление
+
+**Последовательность выполнения:**
+
+```mermaid
+sequenceDiagram
+ participant User
+ participant Controller
+ participant Cluster
+ participant ClusterAdmin
+ participant View
+
+ User->>Controller: GET /view?id=5
+ Controller->>Controller: getAccess()
+
+ Controller->>Cluster: findOne(id=5)
+
+ alt Куст не найден
+ Cluster-->>Controller: null
+ Controller-->>User: NotFoundHttpException
+ end
+
+ Cluster-->>Controller: Cluster model
+
+ Controller->>ClusterAdmin: find()->where(['cluster_id' => 5])->with(['admin'])
+ ClusterAdmin-->>Controller: Все назначения куста
+
+ Controller->>View: render('view')
+ View-->>User: HTML таблица истории
+```
+
+**Пример данных (таблица истории):**
+
+| Администратор | Дата начала | Дата окончания | Статус |
+|---------------|-------------|----------------|--------|
+| Иванов И.И. | 2025-01-01 | - | Активный |
+| Петров П.П. | 2024-06-01 | 2024-12-31 | Неактивный |
+| Сидоров С.С. | 2024-01-01 | 2024-05-31 | Неактивный |
+
+---
+
+### 4. actionCreate($cluster_id)
+
+**Назначение:** Создание нового назначения администратора на куст
+
+**HTTP метод:** GET (форма), POST (создание)
+**Маршрут:** `/crud/cluster-admin/create`
+
+**Параметры:**
+
+| Параметр | Тип | Обязательный | Описание |
+|----------|-----|--------------|----------|
+| `cluster_id` | int | Да | ID куста |
+| `ClusterAdmin[admin_id]` | int | Да (POST) | ID администратора |
+| `ClusterAdmin[active]` | int | Да (POST) | Активность (0/1) |
+| `ClusterAdmin[date_start]` | date | Да (POST) | Дата начала |
+| `ClusterAdmin[date_end]` | date | Нет | Дата окончания (для active=0) |
+
+**Возвращает:**
+- Success: `Response` — redirect на `view`
+- Error: `string` — HTML форма с ошибками
+- Access denied: `Response` — redirect на `/`
+
+**Бизнес-логика:**
+
+**1. Подготовка данных:**
+ - Получить всех администраторов с `group_id = 7`
+ - Получить ID активных администраторов
+ - Отфильтровать неактивных администраторов (для select)
+
+**2. Валидация при создании неактивной записи (active = 0):**
+ - Найти активную запись для данного куста
+ - Проверить, что новая запись не пересекается с активной:
+ ```php
+ if ($modelStart >= $activeRecordStart || $modelEnd >= $activeRecordStart) {
+ // Ошибка: даты не могут быть позже даты начала активной записи
+ }
+ ```
+
+**3. Создание активной записи (active = 1):**
+ - Найти существующую активную запись для куста
+ - Если найдена → деактивировать:
+ - `active` = 0
+ - `date_end` = текущая дата
+ - Создать новую активную запись
+
+**4. Сохранение:**
+ - Валидация модели
+ - Сохранение в БД
+ - Flash-сообщение "успешно создана"
+ - Редирект на `view`
+
+**Последовательность выполнения:**
+
+```mermaid
+sequenceDiagram
+ participant User
+ participant Controller
+ participant ClusterAdmin
+ participant Database
+
+ User->>Controller: POST /create?cluster_id=5
+
+ Controller->>Controller: Валидация данных
+
+ alt active = 0 (неактивная запись)
+ Controller->>ClusterAdmin: Найти активную запись куста
+ alt Пересечение дат
+ Controller-->>User: Flash error
+ end
+ else active = 1 (активная запись)
+ Controller->>ClusterAdmin: Найти существующую активную
+ alt Найдена
+ ClusterAdmin->>Database: UPDATE active=0, date_end=now
+ end
+ end
+
+ Controller->>ClusterAdmin: save()
+ ClusterAdmin->>Database: INSERT
+
+ Controller->>Controller: setFlash('success')
+ Controller-->>User: Redirect to view
+```
+
+**Пример использования:**
+
+```php
+// POST /crud/cluster-admin/create?cluster_id=5
+$model = new ClusterAdmin();
+$model->cluster_id = 5;
+$model->admin_id = 123;
+$model->active = 1;
+$model->date_start = '2025-01-01';
+
+if ($model->save()) {
+ // Успешно создано
+}
+```
+
+---
+
+### 5. actionUpdate($id)
+
+**Назначение:** Редактирование существующего назначения администратора
+
+**HTTP метод:** GET (форма), POST (обновление)
+**Маршрут:** `/crud/cluster-admin/update`
+
+**Параметры:**
+
+| Параметр | Тип | Обязательный | Описание |
+|----------|-----|--------------|----------|
+| `id` | int | Да | ID записи ClusterAdmin |
+
+**Возвращает:**
+- Success: `Response` — redirect на `view`
+- Error: `string` — HTML форма с ошибками
+- Access denied: `Response` — redirect на `/`
+
+**Бизнес-логика:**
+
+**1. Подготовка данных:**
+ - Загрузить модель `ClusterAdmin` по ID
+ - Получить всех администраторов с `group_id = 7`
+ - Получить неактивных администраторов
+ - Добавить текущего админа в список (если он не в нём)
+
+**2. Валидация при изменении на активную запись (active = 1):**
+
+ **a) Конфликт с активными записями других кустов:**
+ ```php
+ $existingActiveRecords = ClusterAdmin::find()
+ ->where(['admin_id' => $model->admin_id, 'active' => 1])
+ ->andWhere(['!=', 'cluster_id', $model->cluster_id])
+ ->all();
+
+ if (!empty($existingActiveRecords)) {
+ // Ошибка: администратор уже привязан к другому кусту
+ }
+ ```
+
+ **b) Конфликт с неактивными записями других кустов:**
+ ```php
+ foreach ($existingInactiveRecords as $record) {
+ if ($modelStart < $recordStart && $modelStart < $recordEnd) {
+ // Ошибка: активная запись не может начинаться раньше неактивной
+ }
+ }
+ ```
+
+**3. Валидация при изменении на неактивную запись (active = 0):**
+
+ **Проверка пересечения дат:**
+ ```php
+ foreach ($existingRecords as $record) {
+ if (
+ ($modelStart > $recordStart && $modelStart < $recordEnd) || // Начало внутри
+ ($modelEnd > $recordStart && $modelEnd < $recordEnd) || // Конец внутри
+ ($modelStart <= $recordStart && $modelEnd >= $recordEnd) // Полное охватывание
+ ) {
+ // Ошибка: пересечение дат
+ }
+ }
+ ```
+
+**4. Сохранение:**
+ - Валидация модели
+ - Обновление в БД
+ - Flash-сообщение "успешно обновлена"
+ - Редирект на `view`
+
+**Последовательность выполнения:**
+
+```mermaid
+sequenceDiagram
+ participant User
+ participant Controller
+ participant ClusterAdmin
+ participant Database
+
+ User->>Controller: POST /update?id=123
+
+ Controller->>ClusterAdmin: findModel(123)
+
+ alt active = 1
+ Controller->>ClusterAdmin: Проверка конфликтов с активными
+ alt Конфликт найден
+ Controller-->>User: Flash error
+ end
+ Controller->>ClusterAdmin: Проверка конфликтов с неактивными
+ alt Конфликт найден
+ Controller-->>User: Flash error
+ end
+ else active = 0
+ Controller->>ClusterAdmin: Проверка пересечений дат
+ alt Пересечение найдено
+ Controller-->>User: Flash error
+ end
+ end
+
+ Controller->>ClusterAdmin: save()
+ ClusterAdmin->>Database: UPDATE
+
+ Controller->>Controller: setFlash('success')
+ Controller-->>User: Redirect to view
+```
+
+**Примеры ошибок валидации:**
+
+```
+❌ "Этот пользователь Иванов И.И. уже привязан к кластеру 3 с активным статусом."
+
+❌ "Ошибка: активная запись для Иванов И.И. не может начинаться раньше,
+ чем неактивная запись (2024-01-01 - 2024-12-31) для другого кластера."
+
+❌ "Пересечение дат с записью от 2024-01-01 до 2024-12-31
+ для другого кластера - 3 123"
+```
+
+---
+
+### 6. actionDelete($id)
+
+**Назначение:** Удаление назначения администратора
+
+**HTTP метод:** POST (только)
+**Маршрут:** `/crud/cluster-admin/delete`
+
+**Параметры:**
+
+| Параметр | Тип | Обязательный | Описание |
+|----------|-----|--------------|----------|
+| `id` | int | Да | ID записи ClusterAdmin |
+
+**Возвращает:**
+- Success: `Response` — redirect на `view` куста
+- Error: `NotFoundHttpException`
+- Access denied: `Response` — redirect на `/`
+
+**Ограничение:** VerbFilter разрешает только POST
+
+**Бизнес-логика:**
+
+1. Проверка прав доступа
+2. Найти запись по ID
+3. Сохранить `cluster_id` для редиректа
+4. Удалить запись
+5. Редирект на `view` куста
+
+**Последовательность выполнения:**
+
+```mermaid
+sequenceDiagram
+ participant User
+ participant Controller
+ participant ClusterAdmin
+ participant Database
+
+ User->>Controller: POST /delete?id=123
+ Controller->>Controller: getAccess()
+
+ Controller->>ClusterAdmin: findModel(123)
+
+ alt Запись найдена
+ ClusterAdmin->>Database: DELETE
+ Database-->>Controller: Success
+ Controller-->>User: Redirect to view
+ else Запись не найдена
+ Controller-->>User: NotFoundHttpException
+ end
+```
+
+**Пример использования:**
+
+```php
+// POST /crud/cluster-admin/delete?id=123
+public function actionDelete($id)
+{
+ if (self::getAccess()) {
+ return $this->redirect('/');
+ }
+
+ $model = $this->findModel($id);
+ $cluster_id = $model->cluster_id;
+ $model->delete();
+
+ return $this->redirect(['view', 'id' => $cluster_id]);
+}
+```
+
+---
+
+## 💾 Работа с данными
+
+### Используемые модели
+
+#### ClusterAdmin
+**Путь:** `yii_app\records\ClusterAdmin`
+**Таблица БД:** `cluster_admin`
+
+**Основные поля:**
+- `id` — ID записи
+- `cluster_id` — ID куста (FK → cluster.id)
+- `admin_id` — ID администратора (FK → admin.id)
+- `active` — Активность (1 = активная, 0 = неактивная)
+- `date_start` — Дата начала назначения
+- `date_end` — Дата окончания назначения (nullable)
+
+**Связи:**
+- `hasOne(Cluster, ['id' => 'cluster_id'])` — куст
+- `hasOne(Admin, ['id' => 'admin_id'])` — администратор
+
+**Бизнес-правила:**
+- Только одна активная запись на куст
+- Только одна активная запись на администратора
+- Даты неактивных записей не должны пересекаться
+- Активная запись не может начинаться раньше неактивной для другого куста
+
+---
+
+#### Cluster
+**Путь:** `yii_app\records\Cluster`
+**Таблица БД:** `cluster`
+
+**Основные поля:**
+- `id` — ID куста
+- `name` — Название куста
+
+---
+
+#### Admin
+**Путь:** `yii_app\records\Admin`
+**Таблица БД:** `admin`
+
+**Основные поля:**
+- `id` — ID администратора
+- `name` — ФИО
+- `group_id` — ID группы
+
+**Фильтр для менеджеров кустов:**
+```php
+Admin::find()->where(['IN', 'group_id', [7]])
+```
+
+---
+
+#### AdminGroup
+**Путь:** `yii_app\records\AdminGroup`
+
+**Константы групп:**
+```php
+const GROUP_IT = ?; // IT отдел
+const DIRECTOR = ?; // Директор
+const GROUP_HR = ?; // HR
+const GROUP_HR_DIRECTOR = ?; // HR директор
+const GROUP_RS_DIRECTOR = ?; // RS директор
+```
+
+---
+
+## ⚠️ Обработка ошибок
+
+### Типичные ошибки валидации
+
+| Ошибка | Причина | Решение |
+|--------|---------|---------|
+| "Этот пользователь уже привязан к кластеру X с активным статусом" | Администратор уже назначен на другой куст | Сначала деактивировать старое назначение |
+| "активная запись не может начинаться раньше, чем неактивная запись" | Конфликт дат при создании активной записи | Исправить даты |
+| "Пересечение дат с записью от ... до ..." | Неактивные записи пересекаются по датам | Изменить даты без пересечений |
+| "Кластер не найден." | Неверный ID куста | Проверить ID |
+| "The requested page does not exist." | Запись ClusterAdmin не найдена | Проверить ID |
+| "Администратор не найден или уже не активен." | Ошибка в actionMoveAdmin | Проверить данные |
+
+### Flash-сообщения
+
+**Success:**
+```php
+Yii::$app->session->setFlash('success', 'Новая запись успешно создана, предыдущая была закрыта.');
+Yii::$app->session->setFlash('success', 'Запись успешно обновлена.');
+```
+
+**Error:**
+```php
+Yii::$app->session->setFlash('error', 'Ошибка: даты начала или окончания новой неактивной записи не могут быть позже даты начала существующей активной записи (2025-01-01).');
+Yii::$app->session->setFlash('error', 'Ошибка при обновлении существующей записи.');
+Yii::$app->session->setFlash('error', 'Ошибка при создании новой записи.');
+Yii::$app->session->setFlash('error', 'Ошибка при сохранении данных.');
+```
+
+---
+
+## 🧪 Примеры использования
+
+### Сценарий 1: Назначить администратора на куст
+
+**Описание:** Назначить Иванова И.И. на Куст 5
+
+**Шаги:**
+1. Открыть `/crud/cluster-admin/index`
+2. Найти Куст 5 в списке
+3. Нажать "Назначить" или выбрать админа из select
+4. AJAX отправит `actionMoveAdmin(admin_id=123, cluster_id=5)`
+5. Деактивируются старые назначения
+6. Создаётся новая активная запись
+7. Страница обновляется
+
+**Код (JavaScript):**
+
+```javascript
+$('#admin-select-5').change(function() {
+ const adminId = $(this).val();
+ const clusterId = 5;
+
+ if (!adminId) return;
+
+ $.ajax({
+ url: '/crud/cluster-admin/move-admin',
+ type: 'POST',
+ data: {
+ admin_id: adminId,
+ cluster_id: clusterId
+ },
+ success: function(response) {
+ if (response.success) {
+ location.reload();
+ } else {
+ alert('Ошибка: ' + response.message);
+ }
+ }
+ });
+});
+```
+
+---
+
+### Сценарий 2: Создать историческую запись
+
+**Описание:** Добавить неактивную запись для прошлого периода
+
+**Шаги:**
+1. Открыть `/crud/cluster-admin/view?id=5`
+2. Нажать "Создать запись"
+3. Выбрать администратора
+4. Установить `active = 0`
+5. Указать даты: `date_start = 2024-01-01`, `date_end = 2024-12-31`
+6. Валидация проверит пересечения
+7. Сохранить
+
+**Пример формы:**
+
+```php
+// Форма создания
+<form method="post">
+ <select name="ClusterAdmin[admin_id]">
+ <option value="123">Иванов И.И.</option>
+ </select>
+
+ <input type="radio" name="ClusterAdmin[active]" value="1"> Активная
+ <input type="radio" name="ClusterAdmin[active]" value="0" checked> Неактивная
+
+ <input type="date" name="ClusterAdmin[date_start]" value="2024-01-01">
+ <input type="date" name="ClusterAdmin[date_end]" value="2024-12-31">
+
+ <button type="submit">Создать</button>
+</form>
+```
+
+---
+
+### Сценарий 3: Редактировать назначение
+
+**Описание:** Изменить дату окончания неактивной записи
+
+**Шаги:**
+1. Открыть `/crud/cluster-admin/view?id=5`
+2. Найти нужную запись в таблице
+3. Нажать "Редактировать"
+4. Изменить `date_end = 2024-06-30`
+5. Валидация проверит пересечения с другими записями
+6. Сохранить
+
+**Валидация проверит:**
+- Нет пересечения с другими записями администратора
+- Если запись активная → нет конфликтов с другими кустами
+
+---
+
+## ❓ FAQ
+
+### Вопрос 1: Почему в getAccess() инвертирована логика?
+
+**Ответ:** Функция возвращает `true` для **ЗАПРЕТА** доступа:
+
+```php
+if (self::getAccess()) { // true = запрет
+ return $this->redirect('/');
+}
+```
+
+Группы IT, HR, директора **НЕ** имеют доступа → функция возвращает `false`.
+Остальные группы имеют доступ → функция возвращает `true` → редирект.
+
+Это нелогично, но так реализовано в коде.
+
+---
+
+### Вопрос 2: Что происходит при перемещении администратора (actionMoveAdmin)?
+
+**Ответ:** При перемещении выполняются 3 операции:
+
+1. **Деактивация текущего админа куста** (если есть)
+2. **Деактивация старого куста нового админа** (если есть)
+3. **Создание нового активного назначения**
+
+Пример:
+```
+Было:
+- Куст 1: Админ A (active=1)
+- Куст 2: Админ B (active=1)
+
+Перемещаем Админа B на Куст 1:
+1. Куст 1: Админ A (active=0, date_end=now)
+2. Куст 2: Админ B (active=0, date_end=now)
+3. Куст 1: Админ B (active=1, date_start=now)
+```
+
+---
+
+### Вопрос 3: Почему закомментирован ClusterManagerService?
+
+**Ответ:** В коде есть закомментированные вызовы:
+
+```php
+// ClusterManagerService::clearClusterManagerStores($cluster_id, $admin_id);
+// ClusterManagerService::syncClusterManagers($cluster_id, $admin_id);
+```
+
+Эти методы должны синхронизировать магазины, принадлежащие кусту, с администратором. Возможно, функционал отключён временно или используется другой механизм синхронизации.
+
+---
+
+### Вопрос 4: Какие валидации применяются при создании/редактировании?
+
+**Ответ:** При создании/редактировании применяются разные валидации:
+
+**Для активной записи (active=1):**
+- Только одна активная запись на куст
+- Только одна активная запись на администратора
+- Активная запись не может начинаться раньше неактивной для другого куста
+
+**Для неактивной записи (active=0):**
+- Даты не должны пересекаться с другими записями администратора
+- Даты не должны быть позже даты начала активной записи куста
+
+---
+
+### Вопрос 5: Зачем нужны историчные записи (active=0)?
+
+**Ответ:** Историчные записи позволяют:
+- Хранить полную историю назначений администраторов
+- Восстанавливать информацию о прошлых периодах
+- Анализировать изменения в структуре управления
+- Формировать отчёты за исторические периоды
+
+Например: "Кто управлял Кустом 1 в январе 2024 года?"
+
+---
+
+## 🔗 Связанные компоненты
+
+- [ClusterAdmin](../../../models/ClusterAdmin.md) — Модель назначений
+- [Cluster](../../../models/Cluster.md) — Модель кустов
+- [Admin](../../../models/Admin.md) — Модель администраторов
+- [ClusterManagerService](../../../services/ClusterManagerService.md) — Сервис синхронизации
+
+---
+
+## 📝 История изменений
+
+| Дата | Версия | Изменения |
+|------|--------|-----------|
+| 2025-11-26 | 1.0 | Первая версия документации |
+
+---
+
+**Документация создана:** Claude Code + Hive Mind Controllers Swarm
+**Дата:** 2025-11-26
+**Фаза:** 2 (Крупные контроллеры)
+**Статус:** ✅ Готово
--- /dev/null
+# ClusterAdminController - Краткая справка
+
+## 📌 Основная информация
+
+**Контроллер:** `ClusterAdminController`
+**Namespace:** `yii_app\controllers\crud`
+**Путь:** `erp24/controllers/crud/ClusterAdminController.php`
+**Размер:** 503 строки
+**Actions:** 7 (5 CRUD + 1 AJAX + 1 helper)
+**Категория:** Крупный контроллер (Фаза 2)
+
+---
+
+## 🎯 Назначение в одном предложении
+
+CRUD-контроллер для управления историческими назначениями администраторов на кусты магазинов с валидацией пересечений дат, автоматической деактивацией старых записей и AJAX-перемещением между кустами.
+
+---
+
+## 🔑 Ключевые особенности
+
+✅ **Историчность данных:** активные/неактивные записи с датами
+✅ **Автоматическая деактивация:** старых назначений при создании новых
+✅ **AJAX перемещение:** быстрое назначение админов на кусты
+✅ **Валидация пересечений:** проверка конфликтов дат
+✅ **Контроль доступа:** только для определённых групп
+✅ **Защита целостности:** один админ = один активный куст
+
+---
+
+## 📋 Actions (7)
+
+| # | Action | Тип | Назначение |
+|---|--------|-----|------------|
+| 1 | `actionIndex()` | CRUD | Список кустов с админами |
+| 2 | `actionView($id)` | CRUD | История назначений куста |
+| 3 | `actionCreate($cluster_id)` | CRUD | Создание назначения |
+| 4 | `actionUpdate($id)` | CRUD | Редактирование назначения |
+| 5 | `actionDelete($id)` | CRUD | Удаление назначения |
+| 6 | `actionMoveAdmin()` | AJAX | Переместить админа |
+| 7 | `getAccess()` | Helper | Проверка прав |
+
+---
+
+## 🗂️ Используемые модели
+
+| Модель | Таблица | Назначение |
+|--------|---------|------------|
+| **ClusterAdmin** | `cluster_admin` | Назначения админов (с историей) |
+| **Cluster** | `cluster` | Справочник кустов |
+| **Admin** | `admin` | Справочник администраторов |
+| **AdminGroup** | `admin_group` | Группы администраторов |
+
+---
+
+## 📊 Структура данных ClusterAdmin
+
+| Поле | Тип | Описание |
+|------|-----|----------|
+| `id` | int | ID записи |
+| `cluster_id` | int | ID куста (FK) |
+| `admin_id` | int | ID админа (FK) |
+| `active` | int | Активность (1=активная, 0=неактивная) |
+| `date_start` | date | Дата начала назначения |
+| `date_end` | date | Дата окончания (nullable) |
+
+**Бизнес-правило:** Только одна активная запись на куст, только одна активная запись на администратора.
+
+---
+
+## 🔄 Основные процессы
+
+### 1. Структура clusterMapping (actionIndex)
+
+```mermaid
+graph TB
+ A[Получить все кусты] --> B[Получить все назначения]
+ B --> C{Есть активная запись?}
+ C -->|Да| D[status: Активная запись]
+ C -->|Нет| E{Есть записи?}
+ E -->|Да| F[status: Нет активных записей]
+ E -->|Нет| G[status: Нет записей]
+```
+
+### 2. Перемещение админа (actionMoveAdmin)
+
+```mermaid
+graph TB
+ A[AJAX POST] --> B[Деактивировать текущего админа куста]
+ B --> C[Деактивировать старый куст нового админа]
+ C --> D[Создать новое активное назначение]
+ D --> E[Вернуть success: true]
+```
+
+### 3. Валидация при создании/редактировании
+
+```mermaid
+graph TB
+ A{active = 1?} -->|Да| B[Проверка конфликтов с другими кустами]
+ A -->|Нет| C[Проверка пересечений дат]
+ B --> D{Конфликт?}
+ C --> E{Пересечение?}
+ D -->|Да| F[Ошибка]
+ D -->|Нет| G[Сохранить]
+ E -->|Да| F
+ E -->|Нет| G
+```
+
+---
+
+## 💡 Быстрые примеры
+
+### Переместить админа через AJAX
+
+```javascript
+$.ajax({
+ url: '/crud/cluster-admin/move-admin',
+ type: 'POST',
+ data: {
+ admin_id: 123, // Новый админ
+ cluster_id: 5 // Куст назначения
+ },
+ success: function(response) {
+ if (response.success) {
+ location.reload();
+ }
+ }
+});
+```
+
+### Создать активное назначение
+
+```php
+// POST /crud/cluster-admin/create?cluster_id=5
+$model = new ClusterAdmin();
+$model->cluster_id = 5;
+$model->admin_id = 123;
+$model->active = 1;
+$model->date_start = '2025-01-01';
+$model->save();
+```
+
+### Создать историческую запись
+
+```php
+$model = new ClusterAdmin();
+$model->cluster_id = 5;
+$model->admin_id = 123;
+$model->active = 0; // Неактивная
+$model->date_start = '2024-01-01';
+$model->date_end = '2024-12-31'; // Период
+$model->save();
+```
+
+---
+
+## 🔐 Контроль доступа
+
+### Метод getAccess()
+
+**⚠️ Инвертированная логика:**
+- `true` = доступ **ЗАПРЕЩЁН** → redirect
+- `false` = доступ **РАЗРЕШЁН**
+
+**Группы БЕЗ доступа:**
+```php
+AdminGroup::GROUP_IT // IT отдел
+AdminGroup::DIRECTOR // Директор
+AdminGroup::GROUP_HR // HR
+AdminGroup::GROUP_HR_DIRECTOR // HR директор
+AdminGroup::GROUP_RS_DIRECTOR // RS директор
+```
+
+**Использование:**
+```php
+if (self::getAccess()) {
+ return $this->redirect('/'); // Нет доступа
+}
+```
+
+---
+
+## ⚙️ Бизнес-правила
+
+### Активные записи (active=1)
+
+| Правило | Описание |
+|---------|----------|
+| **Один куст = один админ** | Только одна активная запись на куст |
+| **Один админ = один куст** | Только одна активная запись на админа |
+| **Автозакрытие** | При создании новой → старая деактивируется |
+| **Проверка дат** | Активная не может начинаться раньше неактивной (другой куст) |
+
+### Неактивные записи (active=0)
+
+| Правило | Описание |
+|---------|----------|
+| **Нет пересечений** | Даты не должны пересекаться с другими записями админа |
+| **До активной** | Даты не должны быть позже даты начала активной записи куста |
+
+---
+
+## ⚠️ Валидационные ошибки
+
+### Активная запись
+
+```
+❌ "Этот пользователь Иванов И.И. уже привязан к кластеру 3
+ с активным статусом."
+
+❌ "Ошибка: активная запись для Иванов И.И. не может начинаться
+ раньше, чем неактивная запись (2024-01-01 - 2024-12-31)
+ для другого кластера."
+```
+
+### Неактивная запись
+
+```
+❌ "Пересечение дат с записью от 2024-01-01 до 2024-12-31
+ для другого кластера - 3 123"
+
+❌ "Ошибка: даты начала или окончания новой неактивной записи
+ не могут быть позже даты начала существующей активной записи
+ (2025-01-01)."
+```
+
+---
+
+## 📊 Статусы кустов (clusterMapping)
+
+| Статус | Условие | Описание |
+|--------|---------|----------|
+| `"Активная запись"` | `hasActive = true` | Есть активное назначение |
+| `"Нет активных записей"` | `hasAny = true, hasActive = false` | Есть записи, но все неактивные |
+| `"Нет записей"` | `hasAny = false` | Записей нет |
+
+---
+
+## 🔄 Алгоритм actionMoveAdmin()
+
+**Шаги:**
+
+1. **Деактивировать текущего админа куста** (если есть)
+ ```php
+ WHERE cluster_id = куст AND active = 1
+ UPDATE active = 0, date_end = now
+ ```
+
+2. **Деактивировать старый куст нового админа** (если есть)
+ ```php
+ WHERE admin_id = новый_админ AND active = 1
+ UPDATE active = 0, date_end = now
+ ```
+
+3. **Создать новое назначение**
+ ```php
+ INSERT (admin_id, cluster_id, date_start=now, active=1)
+ ```
+
+**Пример:**
+```
+Было:
+- Куст 1: Админ A (active=1)
+- Куст 2: Админ B (active=1)
+
+Перемещаем Админа B на Куст 1:
+↓
+Стало:
+- Куст 1: Админ A (active=0, date_end=now)
+- Куст 2: Админ B (active=0, date_end=now)
+- Куст 1: Админ B (active=1, date_start=now)
+```
+
+---
+
+## 🧪 Сценарии использования
+
+### Сценарий 1: Назначить админа на куст (через AJAX)
+
+1. Открыть `/crud/cluster-admin/index`
+2. Выбрать админа в select для куста
+3. AJAX отправит `actionMoveAdmin()`
+4. Деактивируются старые назначения
+5. Создаётся новое активное назначение
+6. Страница обновляется
+
+---
+
+### Сценарий 2: Создать историческую запись
+
+1. Открыть `/crud/cluster-admin/view?id=5`
+2. Нажать "Создать запись"
+3. Выбрать админа
+4. Установить `active = 0`
+5. Указать даты: `2024-01-01` - `2024-12-31`
+6. Валидация проверит пересечения
+7. Сохранить
+
+---
+
+### Сценарий 3: Редактировать дату окончания
+
+1. Открыть `/crud/cluster-admin/view?id=5`
+2. Найти запись в таблице
+3. Нажать "Редактировать"
+4. Изменить `date_end = 2024-06-30`
+5. Валидация проверит пересечения
+6. Сохранить
+
+---
+
+## 💾 Закомментированный функционал
+
+```php
+// ClusterManagerService::clearClusterManagerStores($cluster_id, $admin_id);
+// ClusterManagerService::syncClusterManagers($cluster_id, $admin_id);
+```
+
+**Назначение:** Синхронизация магазинов куста с администратором.
+**Статус:** Отключено (возможно, используется другой механизм).
+
+---
+
+## 🚨 Типичные ошибки
+
+| Ошибка | Причина | Решение |
+|--------|---------|---------|
+| "уже привязан к кластеру X" | Админ уже назначен на другой куст | Деактивировать старое назначение |
+| "Пересечение дат" | Неактивные записи пересекаются | Изменить даты |
+| "не может начинаться раньше" | Активная раньше неактивной | Исправить даты |
+| "Кластер не найден" | Неверный ID куста | Проверить ID |
+| "Администратор не найден" | Ошибка в moveAdmin | Проверить данные |
+
+---
+
+## 🔗 Связанные компоненты
+
+- **[ClusterAdmin](../../../models/ClusterAdmin.md)** — Модель назначений
+- **[Cluster](../../../models/Cluster.md)** — Модель кустов
+- **[Admin](../../../models/Admin.md)** — Модель администраторов
+- **[ClusterManagerService](../../../services/ClusterManagerService.md)** — Синхронизация (отключено)
+
+---
+
+## 📝 Полезные ссылки
+
+- [Детальный анализ контроллера](./ClusterAdminController_ANALYSIS.md)
+- [Таблица Actions](./ClusterAdminController_ACTIONS_TABLE.md)
+- [План документирования контроллеров](../../CONTROLLERS_DOCUMENTATION_PLAN.md)
+
+---
+
+## ❓ FAQ (Топ-5)
+
+### 1. Почему getAccess() возвращает инвертированную логику?
+
+**Ответ:** Функция возвращает `true` для **ЗАПРЕТА** доступа:
+
+```php
+if (self::getAccess()) { // true = запрет
+ return $this->redirect('/');
+}
+```
+
+Это нелогично, но так реализовано в коде.
+
+---
+
+### 2. Что происходит при перемещении админа?
+
+**Ответ:** Выполняются 3 операции:
+1. Деактивация текущего админа куста (если есть)
+2. Деактивация старого куста нового админа (если есть)
+3. Создание нового активного назначения
+
+---
+
+### 3. Зачем нужны неактивные записи (active=0)?
+
+**Ответ:** Неактивные записи хранят историю назначений:
+- Кто управлял кустом в прошлом
+- Когда происходили изменения
+- Анализ для отчётов
+
+---
+
+### 4. Какие валидации применяются?
+
+**Ответ:**
+
+**Для активной записи:**
+- Только одна на куст
+- Только одна на админа
+- Не раньше неактивной (другой куст)
+
+**Для неактивной записи:**
+- Нет пересечений дат с другими записями админа
+- Не позже активной записи куста
+
+---
+
+### 5. Почему закомментирован ClusterManagerService?
+
+**Ответ:** Сервис должен синхронизировать магазины куста с администратором. Возможно:
+- Функционал отключён временно
+- Используется другой механизм
+- Синхронизация выполняется в другом месте
+
+---
+
+## 🎨 Диаграмма состояний
+
+```
+┌─────────────────┐
+│ Нет записей │
+└────────┬────────┘
+ │ create (active=0)
+ ▼
+┌─────────────────┐
+│ Неактивные │◄───── create (active=0)
+│ записи │
+└────────┬────────┘
+ │ create (active=1)
+ ▼
+┌─────────────────┐
+│ Активная │◄───── moveAdmin()
+│ запись │
+└────────┬────────┘
+ │ create новой (active=1)
+ ▼
+┌─────────────────┐
+│ Деактивация │
+│ active=0 │
+└─────────────────┘
+```
+
+---
+
+**Краткая справка создана:** 2025-11-26
+**Версия:** 1.0
+**Статус:** ✅ Готово