]> gitweb.erp-flowers.ru Git - erp24_rep/yii-erp24/.git/commitdiff
Контроллеры продолжение
authorfomichev <vladimir.fomichev@erp-flowers.ru>
Wed, 26 Nov 2025 14:04:22 +0000 (17:04 +0300)
committerfomichev <vladimir.fomichev@erp-flowers.ru>
Wed, 26 Nov 2025 14:04:22 +0000 (17:04 +0300)
13 files changed:
erp24/docs/controllers/CONTROLLERS_DOCUMENTATION_PLAN.md
erp24/docs/controllers/non-standard/ChartsForManagementController_ACTIONS_TABLE.md [new file with mode: 0644]
erp24/docs/controllers/non-standard/ChartsForManagementController_ANALYSIS.md [new file with mode: 0644]
erp24/docs/controllers/non-standard/ChartsForManagementController_QUICK_REFERENCE.md [new file with mode: 0644]
erp24/docs/controllers/non-standard/ShiftTransferController_ACTIONS_TABLE.md [new file with mode: 0644]
erp24/docs/controllers/non-standard/ShiftTransferController_ANALYSIS.md [new file with mode: 0644]
erp24/docs/controllers/non-standard/ShiftTransferController_QUICK_REFERENCE.md [new file with mode: 0644]
erp24/docs/controllers/non-standard/StoreStaffingController_ACTIONS_TABLE.md [new file with mode: 0644]
erp24/docs/controllers/non-standard/StoreStaffingController_ANALYSIS.md [new file with mode: 0644]
erp24/docs/controllers/non-standard/StoreStaffingController_QUICK_REFERENCE.md [new file with mode: 0644]
erp24/docs/controllers/non-standard/crud/ClusterAdminController_ACTIONS_TABLE.md [new file with mode: 0644]
erp24/docs/controllers/non-standard/crud/ClusterAdminController_ANALYSIS.md [new file with mode: 0644]
erp24/docs/controllers/non-standard/crud/ClusterAdminController_QUICK_REFERENCE.md [new file with mode: 0644]

index 88b37b81643d79d41f6435f14f9fcffbac3afbee..32c7abf2243bae25aa01c625b14ffd80da948787 100644 (file)
 - 🔄 Детальная документация 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% завершено**
 
 ---
 
@@ -175,7 +175,11 @@ graph TB
 
 #### Список контроллеров (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 строк)
@@ -580,11 +584,11 @@ try {
 
 | Метрика | Цель | Текущий | Прогресс |
 |---------|------|---------|----------|
-| **Документированных контроллеров** | 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% |
 
 ### Качественные показатели
 
diff --git a/erp24/docs/controllers/non-standard/ChartsForManagementController_ACTIONS_TABLE.md b/erp24/docs/controllers/non-standard/ChartsForManagementController_ACTIONS_TABLE.md
new file mode 100644 (file)
index 0000000..35c4543
--- /dev/null
@@ -0,0 +1,412 @@
+# 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
+**Статус:** ✅ Готово
diff --git a/erp24/docs/controllers/non-standard/ChartsForManagementController_ANALYSIS.md b/erp24/docs/controllers/non-standard/ChartsForManagementController_ANALYSIS.md
new file mode 100644 (file)
index 0000000..6f74e74
--- /dev/null
@@ -0,0 +1,1474 @@
+# 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 (Крупные контроллеры)
+**Статус:** ✅ Готово
diff --git a/erp24/docs/controllers/non-standard/ChartsForManagementController_QUICK_REFERENCE.md b/erp24/docs/controllers/non-standard/ChartsForManagementController_QUICK_REFERENCE.md
new file mode 100644 (file)
index 0000000..e9ecc5b
--- /dev/null
@@ -0,0 +1,300 @@
+# 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
+**Статус:** ✅ Готово
diff --git a/erp24/docs/controllers/non-standard/ShiftTransferController_ACTIONS_TABLE.md b/erp24/docs/controllers/non-standard/ShiftTransferController_ACTIONS_TABLE.md
new file mode 100644 (file)
index 0000000..bed14ac
--- /dev/null
@@ -0,0 +1,95 @@
+# 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
+**Статус:** ✅ Готово
diff --git a/erp24/docs/controllers/non-standard/ShiftTransferController_ANALYSIS.md b/erp24/docs/controllers/non-standard/ShiftTransferController_ANALYSIS.md
new file mode 100644 (file)
index 0000000..da38ed4
--- /dev/null
@@ -0,0 +1,275 @@
+# 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 (Средние с интеграциями)
+**Статус:** ✅ Готово
diff --git a/erp24/docs/controllers/non-standard/ShiftTransferController_QUICK_REFERENCE.md b/erp24/docs/controllers/non-standard/ShiftTransferController_QUICK_REFERENCE.md
new file mode 100644 (file)
index 0000000..49616f6
--- /dev/null
@@ -0,0 +1,469 @@
+# 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
+**Статус:** ✅ Готово
diff --git a/erp24/docs/controllers/non-standard/StoreStaffingController_ACTIONS_TABLE.md b/erp24/docs/controllers/non-standard/StoreStaffingController_ACTIONS_TABLE.md
new file mode 100644 (file)
index 0000000..5ed92c7
--- /dev/null
@@ -0,0 +1,749 @@
+# 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
+**Статус:** ✅ Готово
diff --git a/erp24/docs/controllers/non-standard/StoreStaffingController_ANALYSIS.md b/erp24/docs/controllers/non-standard/StoreStaffingController_ANALYSIS.md
new file mode 100644 (file)
index 0000000..a056542
--- /dev/null
@@ -0,0 +1,1082 @@
+# 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 (Крупные контроллеры)
+**Статус:** ✅ Готово
diff --git a/erp24/docs/controllers/non-standard/StoreStaffingController_QUICK_REFERENCE.md b/erp24/docs/controllers/non-standard/StoreStaffingController_QUICK_REFERENCE.md
new file mode 100644 (file)
index 0000000..59c2758
--- /dev/null
@@ -0,0 +1,438 @@
+# 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
+**Статус:** ✅ Готово
diff --git a/erp24/docs/controllers/non-standard/crud/ClusterAdminController_ACTIONS_TABLE.md b/erp24/docs/controllers/non-standard/crud/ClusterAdminController_ACTIONS_TABLE.md
new file mode 100644 (file)
index 0000000..60ce5f0
--- /dev/null
@@ -0,0 +1,556 @@
+# 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
+**Статус:** ✅ Готово
diff --git a/erp24/docs/controllers/non-standard/crud/ClusterAdminController_ANALYSIS.md b/erp24/docs/controllers/non-standard/crud/ClusterAdminController_ANALYSIS.md
new file mode 100644 (file)
index 0000000..5e8065f
--- /dev/null
@@ -0,0 +1,987 @@
+# 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 (Крупные контроллеры)
+**Статус:** ✅ Готово
diff --git a/erp24/docs/controllers/non-standard/crud/ClusterAdminController_QUICK_REFERENCE.md b/erp24/docs/controllers/non-standard/crud/ClusterAdminController_QUICK_REFERENCE.md
new file mode 100644 (file)
index 0000000..8eb6405
--- /dev/null
@@ -0,0 +1,441 @@
+# 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
+**Статус:** ✅ Готово