]> gitweb.erp-flowers.ru Git - erp24_rep/yii-erp24/.git/commitdiff
Контроллеры продолжение
authorfomichev <vladimir.fomichev@erp-flowers.ru>
Wed, 26 Nov 2025 07:41:28 +0000 (10:41 +0300)
committerfomichev <vladimir.fomichev@erp-flowers.ru>
Wed, 26 Nov 2025 07:41:28 +0000 (10:41 +0300)
16 files changed:
erp24/docs/controllers/non-standard/CategoryPlanController_ACTIONS_TABLE.md [new file with mode: 0644]
erp24/docs/controllers/non-standard/CategoryPlanController_ANALYSIS.md [new file with mode: 0644]
erp24/docs/controllers/non-standard/CategoryPlanController_QUICK_REFERENCE.md [new file with mode: 0644]
erp24/docs/controllers/non-standard/ChartForManagementController_ACTIONS_TABLE.md [new file with mode: 0644]
erp24/docs/controllers/non-standard/ChartForManagementController_ANALYSIS.md [new file with mode: 0644]
erp24/docs/controllers/non-standard/ClusterLinkEditController_ACTIONS_TABLE.md [new file with mode: 0644]
erp24/docs/controllers/non-standard/ClusterLinkEditController_ANALYSIS.md [new file with mode: 0644]
erp24/docs/controllers/non-standard/ClusterLinkEditController_QUICK_REFERENCE.md [new file with mode: 0644]
erp24/docs/controllers/non-standard/MatrixBouquetActualityController_ACTIONS_TABLE.md [new file with mode: 0644]
erp24/docs/controllers/non-standard/MatrixBouquetActualityController_ANALYSIS.md [new file with mode: 0644]
erp24/docs/controllers/non-standard/MatrixBouquetActualityController_QUICK_REFERENCE.md [new file with mode: 0644]
erp24/docs/controllers/non-standard/MatrixErpController_ACTIONS_TABLE.md [new file with mode: 0644]
erp24/docs/controllers/non-standard/MatrixErpController_ANALYSIS.md [new file with mode: 0644]
erp24/docs/controllers/non-standard/Products1cNomenclatureActualityController_ACTIONS_TABLE.md [new file with mode: 0644]
erp24/docs/controllers/non-standard/Products1cNomenclatureActualityController_ANALYSIS.md [new file with mode: 0644]
erp24/docs/controllers/non-standard/Products1cNomenclatureActualityController_QUICK_REFERENCE.md [new file with mode: 0644]

diff --git a/erp24/docs/controllers/non-standard/CategoryPlanController_ACTIONS_TABLE.md b/erp24/docs/controllers/non-standard/CategoryPlanController_ACTIONS_TABLE.md
new file mode 100644 (file)
index 0000000..1e955b0
--- /dev/null
@@ -0,0 +1,1347 @@
+# CategoryPlanController - Таблица Actions
+
+## Обзор
+
+**CategoryPlanController** содержит 8 действий для управления планами продаж и списаний по категориям товаров.
+
+---
+
+## Сводная таблица Actions
+
+| # | Action | HTTP Method | Route | Параметры | Возвращает | Назначение |
+|---|--------|-------------|-------|-----------|------------|-----------|
+| 1 | `actionIndex` | GET | `/category-plan/index` | year, month, store_id, filters, delete | HTML / JSON | Главная страница планирования по категориям |
+| 2 | `actionGetStores` | POST | `/category-plan/get-stores` | city_id, region_id, raion_id, store_type_id, territorial_manager_id, kshf_id | JSON | AJAX получение списка магазинов с фильтрацией |
+| 3 | `actionSaveFields` | POST | `/category-plan/save-fields` | year, month, store_id, type, offline, internet_shop, write_offs | String "ok" | Сохранение планов по одной категории |
+| 4 | `actionShowHistoryData` | GET/POST | `/category-plan/show-history-data` | storeId, month, category, subcategory, species | HTML | Анализ исторических данных продаж |
+| 5 | `actionGetSubcategories` | GET | `/category-plan/get-subcategories` | category | JSON | AJAX получение подкатегорий для категории |
+| 6 | `actionGetSpecies` | GET | `/category-plan/get-species` | subcategory | JSON | AJAX получение видов для подкатегории |
+| 7 | `actionRebuild` | GET | `/category-plan/rebuild` | year, month, store_id | JSON | Запуск фоновой задачи пересчета автопланограммы |
+| 8 | `actionCheckTask` | GET | `/category-plan/check-task` | - | JSON | Проверка статуса фоновой задачи пересчета |
+
+---
+
+## 1. actionIndex()
+
+### Описание
+
+Главная страница планирования по категориям товаров. Выполняет автоматический расчет планов на основе исторических данных продаж и списаний за последние 3 месяца с взвешиванием (более свежие месяцы имеют больший вес).
+
+### HTTP Method
+
+**GET**
+
+### Route
+
+`/category-plan/index`
+
+### Параметры запроса (GET)
+
+| Параметр | Тип | Обязательный | По умолчанию | Описание |
+|----------|-----|--------------|--------------|----------|
+| `year` | int | Нет | Текущий год | Год планирования |
+| `month` | int | Нет | Текущий месяц | Месяц планирования (1-12) |
+| `store_id` | int | Нет | null | ID магазина |
+| `city_id` | int | Нет | null | ID города (фильтр) |
+| `region_id` | int | Нет | null | ID региона (фильтр) |
+| `raion_id` | int | Нет | null | ID района (фильтр) |
+| `store_type_id` | int | Нет | null | Тип магазина (фильтр) |
+| `territory_manager_id` | int | Нет | null | ID территориального менеджера |
+| `kshf_id` | int | Нет | null | ID куста/шефа флористов |
+| `isEditable` | bool | Нет | 0 | Принудительное включение редактирования |
+| `delete` | string | Нет | - | Если "1", удаляет планы для выбранного периода/магазина |
+
+### Возвращаемые данные
+
+#### HTML (обычный запрос)
+
+Рендерит представление `views/category-plan/index.php` с переменными:
+
+| Переменная | Тип | Описание |
+|------------|-----|----------|
+| `model` | DynamicModel | Модель фильтров с текущими значениями |
+| `years` | array | Список доступных лет (текущий год и 3 года назад) |
+| `stores` | array | Список магазинов `[id => name]` |
+| `table` | array | Исторические продажи офлайн `[store_id][category] => sum` |
+| `tableOnline` | array | Исторические продажи онлайн `[store_id][category] => sum` |
+| `tableWriteOffs` | array | Исторические списания `[store_id][category] => sum` |
+| `types` | array | Список категорий (отсортированный) |
+| `salesWriteOffsPlan` | SalesWriteOffsPlan | Общие планы магазина |
+| `isEditable` | bool | Флаг возможности редактирования |
+| `categoryPlan` | array | Существующие планы по категориям `[category] => [...]` |
+
+#### JSON (при delete=1 и AJAX)
+
+```json
+{
+  "status": "ok",
+  "message": "Удалено 6 записей.",
+  "data": "Recalc"
+}
+```
+
+### Алгоритм работы
+
+#### 1. Проверка возможности редактирования
+
+```php
+$deadline = date('Y-m-d', strtotime("{$year}-{$month}-27 -2 months"));
+$isEditable = date('Y-m-d') < $deadline;
+```
+
+**Правило**: Редактирование доступно до 27 числа за 2 месяца до планируемого периода.
+
+**Пример**:
+- Планируется: 2025 февраль
+- Дедлайн: 2024 декабрь 27
+- Сегодня: 2024 ноябрь 26 → Редактирование **разрешено**
+
+#### 2. Удаление планов (если delete=1)
+
+```sql
+DELETE FROM category_plan
+WHERE year = ? AND month = ? AND store_id = ?
+```
+
+После удаления:
+- Устанавливается флаг в кеше: `Yii::$app->cache->set('needRecalc', 'Recalc', 3600)`
+- Удаляется задача из кеша: `Yii::$app->cache->delete('apRecalculateTask')`
+
+#### 3. Расчет планов (если выбран магазин)
+
+##### 3.1. Офлайн-продажи
+
+**Период анализа**: с -4 до -2 месяцев от планируемого
+
+**Источники данных**:
+
+1. Категорийные продажи (через `AutoPlannogrammaService`):
+
+```php
+$sales = $service->getMonthCategoryShareOrWriteOff(
+    $currentDate->format('Y-m-d'),
+    ['sales_type' => 'offline', 'store_id' => $store_id],
+    AutoPlannogrammaService::TYPE_SALES
+);
+```
+
+2. Продажи "Матрица" (прямой SQL-запрос):
+
+```sql
+SELECT
+  COUNT(*) as cnt,
+  SUM(CASE WHEN operation='Продажа' THEN sp.summ
+           WHEN operation='Возврат' THEN -sp.summ
+           ELSE 0 END) as total,
+  s.store_id,
+  p1c.type as type,
+  TO_CHAR(s.date, 'YYYY-MM') as month
+FROM sales s
+LEFT JOIN sales_products sp ON s.id = sp.check_id
+LEFT JOIN products_1c p1c ON p1c.id = sp.product_id
+WHERE s.date BETWEEN ? AND ?
+  AND order_id IN ('', '0')
+  AND p1c.type = 'Матрица'
+  AND s.store_id = ?
+GROUP BY s.store_id, TO_CHAR(s.date, 'YYYY-MM'), p1c.type
+ORDER BY month ASC, type ASC
+```
+
+**Взвешивание по месяцам**:
+
+```php
+$weights = [
+  '2024-08' => 1,  // -4 месяца
+  '2024-09' => 2,  // -3 месяца
+  '2024-10' => 3   // -2 месяца
+];
+
+$table[$store_id][$type] += $weights[$month] * $total;
+```
+
+**Расчет плана**:
+
+```php
+$offlinePlannedSales = CategoryPlanController::calculatePlannedSales(
+    $table,
+    $salesWriteOffsPlan->offline_sales_plan
+);
+```
+
+##### 3.2. Онлайн-продажи магазина
+
+Аналогично офлайн, но:
+- Фильтр: `order_id NOT IN ('', '0')`
+- План: `$salesWriteOffsPlan->online_sales_shop_plan`
+
+##### 3.3. Онлайн-продажи маркетплейсов
+
+Использует те же данные, что и онлайн-магазин, но:
+- План: `$salesWriteOffsPlan->online_sales_marketplace_plan`
+
+##### 3.4. Списания
+
+**Источники данных**:
+
+1. Категорийные списания (через `AutoPlannogrammaService`):
+
+```php
+$writeOffs = $service->getMonthCategoryShareOrWriteOff(
+    $currentDate->format('Y-m-d'),
+    ['store_id' => $store_id],
+    AutoPlannogrammaService::TYPE_WRITE_OFFS
+);
+```
+
+2. Списания "Матрица":
+
+```sql
+SELECT
+  SUM(wop.summ) as total,
+  ex.entity_id AS store_id,
+  p1c.type as p1ctype,
+  TO_CHAR(wo.date, 'YYYY-MM') as month
+FROM write_offs wo
+LEFT JOIN write_offs_products wop ON wop.write_offs_id = wo.id
+LEFT JOIN products_1c p1c ON p1c.id = wop.product_id
+LEFT JOIN export_import_table ex ON ex.export_val = wo.store_id
+WHERE wo.date BETWEEN ? AND ?
+  AND wo.type = 'брак'
+  AND p1c.type = 'Матрица'
+  AND wo.store_id = ?
+GROUP BY month, ex.entity_id, p1c.type
+```
+
+**Расчет плана**:
+
+```php
+$plannedWriteOffs = CategoryPlanController::calculatePlannedSales(
+    $tableWriteOffs,
+    $salesWriteOffsPlan->write_offs_plan
+);
+```
+
+#### 4. Создание записей CategoryPlan
+
+Для каждой категории:
+
+```php
+if (!isset($categoryPlan[$type])) {
+    $categoryPlanNew = new CategoryPlan;
+    $categoryPlanNew->year = $year;
+    $categoryPlanNew->month = $month;
+    $categoryPlanNew->store_id = $store_id;
+    $categoryPlanNew->category = $type;
+    $categoryPlanNew->offline = $offlinePlannedSales[$store_id][$type] ?? 0;
+    $categoryPlanNew->internet_shop = $onlinePlannedSales[$store_id][$type] ?? 0;
+    $categoryPlanNew->marketplace = $onlineMarketPlannedSales[$store_id][$type] ?? 0;
+    $categoryPlanNew->write_offs = $plannedWriteOffs[$store_id][$type] ?? 0;
+    $categoryPlanNew->save();
+
+    // При создании первой записи:
+    if ($recalcFlag) {
+        // Создание задачи пересчета автопланограммы
+        $log = new ScriptLauncherLog();
+        $log->source = 'CronController';
+        $log->category = 'autoplannogramma';
+        $log->prefix = 'actionAutoplannogrammaRecalculate';
+        $log->name = 'taskApRecalculate';
+        $log->context = json_encode($cacheValue);
+        $log->year = $year;
+        $log->month = $month;
+        $log->active = 1;
+        $log->progress = 0;
+        $log->status = 1;
+        $log->date_start = date('Y-m-d H:i:s');
+        $log->save();
+
+        Yii::$app->cache->delete('needRecalc');
+    }
+}
+```
+
+#### 5. Сортировка категорий
+
+```php
+$order = [
+    'Срезка' => 1,
+    'Сухоцветы' => 2,
+    'Горшечные_растения' => 3,
+    'Сопутствующие_товары' => 4,
+    'Упаковка' => 5,
+    'Матрица' => 6,
+];
+
+usort($types, function ($a, $b) use ($order) {
+    return ($order[$a] ?? 999) <=> ($order[$b] ?? 999);
+});
+```
+
+### Примеры использования
+
+#### Пример 1: Загрузка страницы с расчетом планов
+
+**Запрос**:
+
+```
+GET /category-plan/index?year=2025&month=2&store_id=1
+```
+
+**Результат**:
+- Рассчитываются планы на февраль 2025 для магазина #1
+- Анализируются продажи: октябрь 2024 (вес ×1), ноябрь 2024 (вес ×2), декабрь 2024 (вес ×3)
+- Создаются записи `CategoryPlan` для всех категорий
+- Запускается задача пересчета автопланограммы
+- Отображается HTML-страница с таблицей планов
+
+#### Пример 2: Удаление планов через AJAX
+
+**Запрос**:
+
+```
+GET /category-plan/index?year=2025&month=2&store_id=1&delete=1
+```
+
+**Ответ**:
+
+```json
+{
+  "status": "ok",
+  "message": "Удалено 6 записей.",
+  "data": "Recalc"
+}
+```
+
+#### Пример 3: Проверка возможности редактирования
+
+**Сценарий**:
+- Сегодня: 2024-12-28
+- Планируется: 2025-02
+- Дедлайн: 2024-12-27
+
+**Результат**: `isEditable = false` (дедлайн прошел)
+
+### Flash-сообщения
+
+| Тип | Сообщение | Условие |
+|-----|-----------|---------|
+| `error` | "Не установлен план для магазина" | Отсутствует `offline_sales_plan` в `SalesWriteOffsPlan` |
+| `error` | "Не установлен план для магазина" | Отсутствует `online_sales_shop_plan` в `SalesWriteOffsPlan` |
+| `error` | "Не установлен план для магазина" | Отсутствует `online_sales_marketplace_plan` в `SalesWriteOffsPlan` |
+| `error` | "Не установлен план для магазина" | Отсутствует `write_offs_plan` в `SalesWriteOffsPlan` |
+| `success` | "Удалено {count} записей." | Успешное удаление планов |
+| `info` | "Ничего не удалено." | Удаление выполнено, но записей не найдено |
+
+### Связанные модели
+
+- `CategoryPlan` — чтение/создание
+- `SalesWriteOffsPlan` — чтение
+- `Sales` — чтение
+- `WriteOffs` — чтение
+- `CityStore` — чтение
+- `Products1cNomenclature` — чтение
+- `ExportImportTable` — чтение
+- `ScriptLauncherLog` — создание
+
+### Связанные сервисы
+
+- `AutoPlannogrammaService::getMonthCategoryShareOrWriteOff()`
+- `CategoryPlanController::calculatePlannedSales()`
+
+---
+
+## 2. actionGetStores()
+
+### Описание
+
+AJAX-метод для получения списка магазинов с фильтрацией по территориальным параметрам.
+
+### HTTP Method
+
+**POST**
+
+### Route
+
+`/category-plan/get-stores`
+
+### Параметры запроса (POST)
+
+| Параметр | Тип | Обязательный | Описание |
+|----------|-----|--------------|----------|
+| `city_id` | int | Нет | ID города |
+| `region_id` | int | Нет | ID региона |
+| `raion_id` | int | Нет | ID района |
+| `store_type_id` | int | Нет | Тип магазина |
+| `territorial_manager_id` | int | Нет | ID территориального менеджера |
+| `kshf_id` | int | Нет | ID куста/шефа флористов |
+
+### Возвращаемые данные (JSON)
+
+```json
+{
+  "1": "Магазин А",
+  "2": "Магазин Б",
+  "5": "Магазин В"
+}
+```
+
+**Формат**: `{ store_id: store_name }`
+
+### Алгоритм работы
+
+```php
+$query = CityStore::find()->andWhere(['visible' => 1]);
+
+// Фильтрация по городу
+if (!empty($data['city_id'])) {
+    $storeIds = CityStoreParams::find()
+        ->andWhere(['address_city' => $data['city_id']])
+        ->select('store_id')
+        ->column();
+    $query->andWhere(['id' => $storeIds]);
+}
+
+// Фильтрация по региону
+if (!empty($data['region_id'])) {
+    $storeIds = CityStoreParams::find()
+        ->andWhere(['address_region' => $data['region_id']])
+        ->select('store_id')
+        ->column();
+    $query->andWhere(['id' => $storeIds]);
+}
+
+// Фильтрация по району
+if (!empty($data['raion_id'])) {
+    $storeIds = CityStoreParams::find()
+        ->andWhere(['address_district' => $data['raion_id']])
+        ->select('store_id')
+        ->column();
+    $query->andWhere(['id' => $storeIds]);
+}
+
+// Фильтрация по типу магазина
+if (!empty($data['store_type_id'])) {
+    $storeIds = CityStoreParams::find()
+        ->andWhere(['store_type' => $data['store_type_id']])
+        ->select('store_id')
+        ->column();
+    $query->andWhere(['id' => $storeIds]);
+}
+
+// Фильтрация по территориальному менеджеру
+if (!empty($territorialManager)) {
+    $storeIds = StoreDynamic::find()
+        ->select('store_id')
+        ->where(['category' => 3, 'active' => 1, 'value_int' => $territorialManager])
+        ->column();
+    $query->andWhere(['in', 'id', $storeIds ?: [-1]]);
+}
+
+// Фильтрация по КШФ
+if (!empty($bushChefFlorist)) {
+    $storeIds = StoreDynamic::find()
+        ->select('store_id')
+        ->where(['category' => 2, 'active' => 1, 'value_int' => $bushChefFlorist])
+        ->column();
+    $query->andWhere(['in', 'id', $storeIds ?: [-1]]);
+}
+
+$stores = $query->all();
+return ArrayHelper::map($stores, 'id', 'name');
+```
+
+### Пример использования
+
+**Запрос**:
+
+```javascript
+$.ajax({
+    url: '/category-plan/get-stores',
+    type: 'POST',
+    data: {
+        city_id: 5,
+        region_id: 2,
+        territorial_manager_id: 10
+    },
+    success: function(data) {
+        console.log(data);
+        // { "1": "Магазин А", "3": "Магазин Б" }
+    }
+});
+```
+
+### Связанные модели
+
+- `CityStore`
+- `CityStoreParams`
+- `StoreDynamic`
+
+---
+
+## 3. actionSaveFields()
+
+### Описание
+
+Сохранение измененных значений планов по одной категории товаров.
+
+### HTTP Method
+
+**POST**
+
+### Route
+
+`/category-plan/save-fields`
+
+### Параметры запроса (POST)
+
+| Параметр | Тип | Обязательный | Описание |
+|----------|-----|--------------|----------|
+| `year` | int | Да | Год планирования |
+| `month` | int | Да | Месяц планирования (1-12) |
+| `store_id` | int | Да | ID магазина |
+| `type` | string | Да | Название категории (например, "Срезка") |
+| `offline` | float | Да | План офлайн-продаж (в рублях) |
+| `internet_shop` | float | Да | План интернет-магазина (в рублях) |
+| `write_offs` | float | Да | План списаний (в рублях) |
+
+> **ВАЖНО**: Параметр `marketplace` не принимается и не обновляется этим действием.
+
+### Возвращаемые данные
+
+**Успех**: Строка `"ok"`
+
+**Ошибка**: Exception с JSON ошибок валидации
+
+### Алгоритм работы
+
+```php
+// 1. Поиск существующей записи
+$categoryPlan = CategoryPlan::find()
+    ->where([
+        'year' => $year,
+        'month' => $month,
+        'store_id' => $storeId,
+        'category' => $productType
+    ])
+    ->one();
+
+// 2. Создание новой записи, если не найдена
+if (!$categoryPlan) {
+    $categoryPlan = new CategoryPlan();
+    $categoryPlan->year = $year;
+    $categoryPlan->month = $month;
+    $categoryPlan->store_id = $storeId;
+    $categoryPlan->category = $productType;
+    $categoryPlan->created_by = Yii::$app->user->id;
+    $categoryPlan->created_at = date('Y-m-d H:i:s');
+}
+
+// 3. Обновление полей
+$categoryPlan->offline = $offline;
+$categoryPlan->internet_shop = $internet_shop;
+$categoryPlan->write_offs = $write_offs;
+$categoryPlan->updated_by = Yii::$app->user->id;
+$categoryPlan->updated_at = date('Y-m-d H:i:s');
+
+// 4. Сохранение
+$categoryPlan->save();
+
+// 5. Проверка ошибок
+if ($categoryPlan->getErrors()) {
+    throw new \Exception(Json::encode($categoryPlan->getErrors()));
+}
+
+return 'ok';
+```
+
+### Пример использования
+
+**Запрос**:
+
+```javascript
+$.ajax({
+    url: '/category-plan/save-fields',
+    type: 'POST',
+    data: {
+        year: 2025,
+        month: 2,
+        store_id: 1,
+        type: 'Срезка',
+        offline: 150000.50,
+        internet_shop: 75000.25,
+        write_offs: 5000.00
+    },
+    success: function(response) {
+        console.log(response); // "ok"
+    },
+    error: function(xhr) {
+        console.error(xhr.responseText);
+    }
+});
+```
+
+### Связанные модели
+
+- `CategoryPlan` — создание/обновление
+
+---
+
+## 4. actionShowHistoryData()
+
+### Описание
+
+Страница анализа исторических данных продаж с детализацией по категориям, подкатегориям и видам товаров. Показывает товары с историей продаж и товары без истории с расчетом медианных значений.
+
+### HTTP Method
+
+**GET** (загрузка формы), **POST** (отправка формы)
+
+### Route
+
+`/category-plan/show-history-data`
+
+### Параметры запроса (POST)
+
+| Параметр | Тип | Обязательный | Описание |
+|----------|-----|--------------|----------|
+| `DynamicModel[storeId]` | int | Да | ID магазина |
+| `DynamicModel[month]` | int | Да | Месяц для анализа (1-12) |
+| `DynamicModel[category]` | string | Да | Категория товара (например, "Срезка") |
+| `DynamicModel[subcategory]` | string | Нет | Подкатегория товара (например, "Розы") |
+| `DynamicModel[species]` | string | Нет | Вид товара (например, "Роза кустовая") |
+
+### Возвращаемые данные (HTML)
+
+Рендерит представление `views/category-plan/show-history-data.php` с переменными:
+
+| Переменная | Тип | Описание |
+|------------|-----|----------|
+| `result` | array | Результаты расчета долей (товары с историей и без) |
+| `weighted` | array | Взвешенные медианные значения для товаров без истории |
+| `productSalesShare` | array | Доли продаж товаров с историей |
+| `model` | DynamicModel | Модель формы |
+| `storeList` | array | Список всех магазинов `[id => name]` |
+| `monthsList` | array | Названия месяцев `[1 => 'Январь', ...]` |
+| `categoryList` | array | Список категорий `[category => category]` |
+| `subcategoryList` | array | Список подкатегорий (загружается после выбора категории) |
+| `speciesList` | array | Список видов (загружается после выбора подкатегории) |
+
+### Алгоритм работы
+
+```php
+// 1. Загрузка справочников
+$storeList = ArrayHelper::map(
+    CityStore::find()->select(['id', 'name'])->orderBy('name')->asArray()->all(),
+    'id',
+    'name'
+);
+
+$monthsList = HtmlHelper::getMonthNames();
+
+$categoryList = ArrayHelper::map(
+    Products1cNomenclature::find()
+        ->select('category')
+        ->distinct()
+        ->orderBy('category')
+        ->asArray()->all(),
+    'category',
+    'category'
+);
+
+// 2. При отправке формы
+if ($request->isPost) {
+    $post = $request->post();
+
+    // 2.1. Загрузка подкатегорий для выбранной категории
+    if (!empty($post['DynamicModel']['category'])) {
+        $subcategoryList = ArrayHelper::map(
+            Products1cNomenclature::find()
+                ->select('subcategory')->distinct()
+                ->where(['category' => $post['DynamicModel']['category']])
+                ->orderBy('subcategory')
+                ->asArray()->all(),
+            'subcategory',
+            'subcategory'
+        );
+    }
+
+    // 2.2. Загрузка видов для выбранной подкатегории
+    if (!empty($post['DynamicModel']['subcategory'])) {
+        $speciesList = ArrayHelper::map(
+            Products1cNomenclature::find()
+                ->select('species')->distinct()
+                ->where(['subcategory' => $post['DynamicModel']['subcategory']])
+                ->orderBy('species')
+                ->asArray()->all(),
+            'species',
+            'species'
+        );
+    }
+
+    // 2.3. Валидация и расчет
+    if ($model->validate()) {
+        // Расчет исторических долей
+        $result = StorePlanService::calculateHistoricalShare(
+            $storeId,
+            $selectedMonth,
+            $selectedYear,
+            $category,
+            $subcategory,
+            $species
+        );
+
+        // Расчет медианных значений для товаров без истории
+        $weightedResults = StorePlanService::calculateMedianSalesForProductsWithoutHistoryExtended(
+            $storeId,
+            $selectedMonth,
+            $selectedYear,
+            $result['without_history']
+        );
+
+        // Расчет долей продаж товаров с историей
+        $productSalesShare = StorePlanService::calculateProductSalesShareProductsWithHistory(
+            $storeId,
+            $selectedMonth,
+            $result['with_history']
+        );
+    }
+}
+```
+
+### Структура результата
+
+#### result
+
+```php
+[
+    'with_history' => [
+        [
+            'product_id' => 123,
+            'product_name' => 'Роза кустовая 50см',
+            'category' => 'Срезка',
+            'subcategory' => 'Розы',
+            'species' => 'Роза кустовая',
+            'sales_sum' => 50000.00,
+            'sales_count' => 150
+        ],
+        // ...
+    ],
+    'without_history' => [
+        [
+            'product_id' => 456,
+            'product_name' => 'Тюльпан красный 40см',
+            'category' => 'Срезка',
+            'subcategory' => 'Тюльпаны',
+            'species' => 'Тюльпан одноголовый',
+            // Нет sales_sum и sales_count
+        ],
+        // ...
+    ]
+]
+```
+
+#### weightedResults
+
+```php
+[
+    456 => 12000.50,  // product_id => median_sales
+    789 => 8500.75,
+    // ...
+]
+```
+
+#### productSalesShare
+
+```php
+[
+    123 => 0.25,  // product_id => share (25%)
+    124 => 0.15,  // 15%
+    // ...
+]
+```
+
+### Пример использования
+
+**Запрос**:
+
+```html
+POST /category-plan/show-history-data
+
+DynamicModel[storeId]=1
+DynamicModel[month]=2
+DynamicModel[category]=Срезка
+DynamicModel[subcategory]=Розы
+```
+
+**Результат**:
+- HTML-страница с таблицами:
+  - Товары с историей продаж (с суммами и долями)
+  - Товары без истории (с медианными значениями)
+
+### Связанные модели
+
+- `CityStore`
+- `Products1cNomenclature`
+
+### Связанные сервисы
+
+- `StorePlanService::calculateHistoricalShare()`
+- `StorePlanService::calculateMedianSalesForProductsWithoutHistoryExtended()`
+- `StorePlanService::calculateProductSalesShareProductsWithHistory()`
+- `HtmlHelper::getMonthNames()`
+
+---
+
+## 5. actionGetSubcategories()
+
+### Описание
+
+AJAX-метод получения списка подкатегорий для выбранной категории.
+
+### HTTP Method
+
+**GET**
+
+### Route
+
+`/category-plan/get-subcategories?category={category}`
+
+### Параметры запроса (GET)
+
+| Параметр | Тип | Обязательный | Описание |
+|----------|-----|--------------|----------|
+| `category` | string | Да | Название категории (например, "Срезка") |
+
+### Возвращаемые данные (JSON)
+
+```json
+[
+  { "id": "Розы", "name": "Розы" },
+  { "id": "Тюльпаны", "name": "Тюльпаны" },
+  { "id": "Хризантемы", "name": "Хризантемы" }
+]
+```
+
+**Формат**: Массив объектов `{ id, name }`
+
+### Алгоритм работы
+
+```php
+Yii::$app->response->format = Response::FORMAT_JSON;
+
+$subcategories = Products1cNomenclature::find()
+    ->select('subcategory')
+    ->distinct()
+    ->where(['category' => $category])
+    ->orderBy('subcategory')
+    ->asArray()
+    ->all();
+
+$out = [];
+foreach ($subcategories as $item) {
+    if (!empty($item['subcategory'])) {
+        $out[] = ['id' => $item['subcategory'], 'name' => $item['subcategory']];
+    }
+}
+
+return $out;
+```
+
+### Пример использования
+
+**Запрос**:
+
+```javascript
+$.ajax({
+    url: '/category-plan/get-subcategories',
+    type: 'GET',
+    data: { category: 'Срезка' },
+    success: function(data) {
+        console.log(data);
+        // [
+        //   { "id": "Розы", "name": "Розы" },
+        //   { "id": "Тюльпаны", "name": "Тюльпаны" }
+        // ]
+    }
+});
+```
+
+### Связанные модели
+
+- `Products1cNomenclature`
+
+---
+
+## 6. actionGetSpecies()
+
+### Описание
+
+AJAX-метод получения списка видов для выбранной подкатегории.
+
+### HTTP Method
+
+**GET**
+
+### Route
+
+`/category-plan/get-species?subcategory={subcategory}`
+
+### Параметры запроса (GET)
+
+| Параметр | Тип | Обязательный | Описание |
+|----------|-----|--------------|----------|
+| `subcategory` | string | Да | Название подкатегории (например, "Розы") |
+
+### Возвращаемые данные (JSON)
+
+```json
+[
+  { "id": "Роза кустовая", "name": "Роза кустовая" },
+  { "id": "Роза пионовидная", "name": "Роза пионовидная" },
+  { "id": "Роза одноголовая", "name": "Роза одноголовая" }
+]
+```
+
+**Формат**: Массив объектов `{ id, name }`
+
+### Алгоритм работы
+
+```php
+Yii::$app->response->format = Response::FORMAT_JSON;
+
+$species = Products1cNomenclature::find()
+    ->select('species')
+    ->distinct()
+    ->where(['subcategory' => $subcategory])
+    ->orderBy('species')
+    ->asArray()
+    ->all();
+
+$out = [];
+foreach ($species as $item) {
+    if (!empty($item['species'])) {
+        $out[] = ['id' => $item['species'], 'name' => $item['species']];
+    }
+}
+
+return $out;
+```
+
+### Пример использования
+
+**Запрос**:
+
+```javascript
+$.ajax({
+    url: '/category-plan/get-species',
+    type: 'GET',
+    data: { subcategory: 'Розы' },
+    success: function(data) {
+        console.log(data);
+        // [
+        //   { "id": "Роза кустовая", "name": "Роза кустовая" },
+        //   { "id": "Роза пионовидная", "name": "Роза пионовидная" }
+        // ]
+    }
+});
+```
+
+### Связанные модели
+
+- `Products1cNomenclature`
+
+---
+
+## 7. actionRebuild()
+
+### Описание
+
+Запуск фоновой задачи пересчета автопланограммы для выбранного магазина и периода.
+
+### HTTP Method
+
+**GET**
+
+### Route
+
+`/category-plan/rebuild?year={year}&month={month}&store_id={store_id}`
+
+### Параметры запроса (GET)
+
+| Параметр | Тип | Обязательный | Описание |
+|----------|-----|--------------|----------|
+| `year` | int | Да | Год планирования |
+| `month` | int | Да | Месяц планирования (1-12) |
+| `store_id` | int | Да | ID магазина |
+
+### Возвращаемые данные (JSON)
+
+#### Если задача уже выполняется
+
+```json
+{
+  "status": "running"
+}
+```
+
+#### Если задача создана успешно
+
+```json
+{
+  "status": "started",
+  "data": {
+    "taskName": "taskApRecalculate",
+    "year": 2025,
+    "month": 2,
+    "storeId": 1,
+    "info": "corrected",
+    "status": "pending",
+    "startTime": "2024-11-26 12:00:00",
+    "progress": 0,
+    "error": null
+  }
+}
+```
+
+### Алгоритм работы
+
+```php
+$year  = (int)Yii::$app->request->get('year');
+$month = (int)Yii::$app->request->get('month');
+$store = (int)Yii::$app->request->get('store_id');
+
+$taskName = "taskApRecalculate";
+
+// 1. Проверка наличия активной задачи
+$scriptLauncherLog = ScriptLauncherLog::find()
+    ->andWhere(['name' => $taskName])
+    ->orderBy(['created_at' => SORT_DESC])
+    ->asArray()
+    ->limit(1)
+    ->one();
+
+if ($scriptLauncherLog && $scriptLauncherLog['active'] == 1 && $scriptLauncherLog['status'] == 1) {
+    return $this->asJson(['status' => 'running' ]);
+}
+
+// 2. Создание новой задачи
+$cacheValue = [
+    'taskName'  => $taskName,
+    'year'      => $year,
+    'month'     => $month,
+    'storeId'   => $store,
+    'info'      => 'corrected',
+    'status'    => 'pending',
+    'startTime' => date('Y-m-d H:i:s'),
+    'progress'  => 0,
+    'error'     => null,
+];
+
+$log = new ScriptLauncherLog();
+$log->source = 'CronController';
+$log->category = 'autoplannogramma';
+$log->prefix = 'actionAutoplannogrammaRecalculate';
+$log->name = $taskName;
+$log->context = json_encode($cacheValue, JSON_UNESCAPED_UNICODE);
+$log->year = (int)$cacheValue['year'];
+$log->month = (int)$cacheValue['month'];
+$log->active = 1;
+$log->progress = 0;
+$log->status = 1;
+$log->date_start = date('Y-m-d H:i:s');
+
+if (!$log->save()) {
+    Yii::error(json_encode($log->getErrors(), JSON_UNESCAPED_UNICODE));
+    LogService::apiErrorLog(json_encode(["error_id" => 8, "error" => $log->getErrors()], JSON_UNESCAPED_UNICODE));
+}
+
+return $this->asJson(['status' => 'started', 'data' => $cacheValue ]);
+```
+
+### Пример использования
+
+**Запрос**:
+
+```javascript
+$.ajax({
+    url: '/category-plan/rebuild',
+    type: 'GET',
+    data: {
+        year: 2025,
+        month: 2,
+        store_id: 1
+    },
+    success: function(response) {
+        if (response.status === 'started') {
+            console.log('Задача запущена:', response.data);
+            // Запуск проверки статуса
+            checkTaskStatus();
+        } else if (response.status === 'running') {
+            console.log('Задача уже выполняется');
+        }
+    }
+});
+```
+
+### Связанные модели
+
+- `ScriptLauncherLog` — создание
+
+### Связанные сервисы
+
+- `LogService::apiErrorLog()`
+
+---
+
+## 8. actionCheckTask()
+
+### Описание
+
+Проверка статуса выполнения фоновой задачи пересчета автопланограммы.
+
+### HTTP Method
+
+**GET**
+
+### Route
+
+`/category-plan/check-task`
+
+### Параметры запроса
+
+Нет параметров.
+
+### Возвращаемые данные (JSON)
+
+#### Если задача не найдена
+
+```json
+{
+  "status": "not_found"
+}
+```
+
+#### Если задача найдена
+
+```json
+{
+  "status": "pending",
+  "progress": 0,
+  "error": null,
+  "start": "2024-11-26 12:00:00"
+}
+```
+
+или
+
+```json
+{
+  "status": "running",
+  "progress": 75,
+  "error": null,
+  "start": "2024-11-26 12:00:00"
+}
+```
+
+или
+
+```json
+{
+  "status": "completed",
+  "progress": 100,
+  "error": null,
+  "start": "2024-11-26 12:00:00"
+}
+```
+
+или
+
+```json
+{
+  "status": "error",
+  "progress": 50,
+  "error": "Ошибка при расчете планограммы",
+  "start": "2024-11-26 12:00:00"
+}
+```
+
+### Алгоритм работы
+
+```php
+$scriptLauncherLog = ScriptLauncherLog::find()
+    ->andWhere(['name' => "taskApRecalculate"])
+    ->orderBy(['created_at' => SORT_DESC])
+    ->limit(1)
+    ->one();
+
+$task = json_decode($scriptLauncherLog->context, true );
+
+if (!$task) {
+    return $this->asJson(['status' => 'not_found']);
+}
+
+return $this->asJson([
+    'status'   => $task['status'],
+    'progress' => $task['progress'],
+    'error'    => $task['error'],
+    'start'    => $scriptLauncherLog->date_start,
+]);
+```
+
+### Пример использования
+
+**Запрос**:
+
+```javascript
+function checkTaskStatus() {
+    $.ajax({
+        url: '/category-plan/check-task',
+        type: 'GET',
+        success: function(response) {
+            console.log('Статус:', response.status);
+            console.log('Прогресс:', response.progress + '%');
+
+            if (response.status === 'running' || response.status === 'pending') {
+                // Продолжить проверку через 5 секунд
+                setTimeout(checkTaskStatus, 5000);
+            } else if (response.status === 'completed') {
+                console.log('Задача завершена успешно!');
+            } else if (response.status === 'error') {
+                console.error('Ошибка:', response.error);
+            }
+        }
+    });
+}
+
+checkTaskStatus();
+```
+
+### Связанные модели
+
+- `ScriptLauncherLog` — чтение
+
+---
+
+## Вспомогательные методы
+
+### calculatePlannedSales()
+
+**Сигнатура**:
+
+```php
+public static function calculatePlannedSales(array $salesByCategory, float $planLevel): array
+```
+
+**Назначение**: Расчет плановых продаж/списаний по категориям на основе долей и общего плана.
+
+**Параметры**:
+
+| Параметр | Тип | Описание |
+|----------|-----|----------|
+| `$salesByCategory` | array | Массив продаж по категориям `[store_id => [category => sum, ...], ...]` |
+| `$planLevel` | float | Общий план для магазина (в рублях) |
+
+**Возвращает**: Массив плановых продаж `[store_id => [category => plannedSale, ...], ...]`
+
+**Алгоритм**:
+
+```php
+$offlinePlannedSales = [];
+
+foreach ($salesByCategory as $store => $types) {
+    // 1. Суммируем все категории кроме "Матрица"
+    $sumCat = 0.0;
+    foreach ($types as $type => $sum) {
+        if ($type === 'Матрица') {
+            continue;
+        }
+        $sumCat += $sum;
+    }
+
+    // 2. Если сумма нулевая, пропускаем магазин
+    if ($sumCat <= 0) {
+        $offlinePlannedSales[$store] = [];
+        continue;
+    }
+
+    // 3. Рассчитываем долю и план для каждой категории
+    foreach ($types as $type => $sum) {
+        $share = round($sum / $sumCat, 4);
+        $offlinePlannedSales[$store][$type] = $planLevel * $share;
+    }
+}
+
+return $offlinePlannedSales;
+```
+
+**Пример использования**:
+
+```php
+$salesByCategory = [
+    1 => [
+        'Срезка' => 100000,
+        'Горшечные_растения' => 50000,
+        'Сопутствующие_товары' => 30000,
+        'Матрица' => 20000
+    ]
+];
+
+$planLevel = 200000;
+
+$result = CategoryPlanController::calculatePlannedSales($salesByCategory, $planLevel);
+
+// Результат:
+[
+    1 => [
+        'Срезка' => 111111.11,           // 200000 × (100000 / 180000)
+        'Горшечные_растения' => 55555.56, // 200000 × (50000 / 180000)
+        'Сопутствующие_товары' => 33333.33, // 200000 × (30000 / 180000)
+        'Матрица' => 22222.22            // 200000 × (20000 / 180000)
+    ]
+]
+```
+
+> **ВАЖНО**: Категория "Матрица" исключается из суммы `$sumCat`, но получает долю от плана.
+
+---
+
+## Сводная информация
+
+### Статистика
+
+| Параметр | Значение |
+|----------|----------|
+| Всего actions | 8 |
+| GET actions | 5 |
+| POST actions | 1 |
+| GET/POST actions | 1 |
+| AJAX actions | 5 |
+| HTML actions | 2 |
+| Статические методы | 1 |
+
+### Категории товаров
+
+1. Срезка
+2. Сухоцветы
+3. Горшечные_растения
+4. Сопутствующие_товары
+5. Упаковка
+6. Матрица
+
+### Типы планов
+
+1. Офлайн-продажи (`offline`)
+2. Интернет-магазин (`internet_shop`)
+3. Маркетплейсы (`marketplace`)
+4. Списания (`write_offs`)
+
+---
+
+## Связь с другими модулями
+
+| Модуль | Тип связи | Описание |
+|--------|-----------|----------|
+| **AutoPlannogrammaController** | Триггер пересчета | При создании планов запускается пересчет автопланограммы |
+| **SalesWriteOffsPlanController** | Зависимость | Использует общие планы магазина для распределения |
+| **CronController** | Фоновые задачи | Выполняет задачи пересчета из `ScriptLauncherLog` |
+| **1С** | Интеграция | Чтение данных продаж и списаний |
diff --git a/erp24/docs/controllers/non-standard/CategoryPlanController_ANALYSIS.md b/erp24/docs/controllers/non-standard/CategoryPlanController_ANALYSIS.md
new file mode 100644 (file)
index 0000000..1d7e8f2
--- /dev/null
@@ -0,0 +1,740 @@
+# CategoryPlanController - Полный анализ
+
+## Метаданные
+
+| Параметр | Значение |
+|----------|----------|
+| **Namespace** | `app\controllers` |
+| **Extends** | `yii\web\Controller` |
+| **Размер файла** | 713 строк |
+| **Приоритет** | 1 (критичный) |
+| **Путь** | `erp24/controllers/CategoryPlanController.php` |
+
+## Назначение и бизнес-цель
+
+**CategoryPlanController** — контроллер для управления планированием продаж и списаний по категориям товаров в разрезе магазинов. Основная бизнес-задача — рассчитать и сохранить плановые показатели продаж (офлайн, интернет-магазин, маркетплейсы) и списаний для каждой категории товаров (срезка, сухоцветы, горшечные растения, сопутствующие товары, упаковка, матрица).
+
+### Ключевые бизнес-функции:
+
+1. **Автоматический расчет планов** — на основе исторических данных продаж за 3 месяца с взвешиванием
+2. **Распределение плана по категориям** — пропорционально долям продаж каждой категории
+3. **Управление планами** — создание, редактирование, удаление планов по категориям
+4. **Анализ исторических данных** — расчет долей продаж товаров с историей и без истории
+5. **Интеграция с автопланограммой** — запуск пересчета автопланограммы при изменении планов
+
+## Access Control (RBAC)
+
+**Нет явных правил RBAC** в коде контроллера. Доступ не ограничен через `behaviors()` или `beforeAction()`.
+
+> ⚠️ **ВАЖНО**: Отсутствие контроля доступа означает, что все действия контроллера доступны всем авторизованным пользователям. Рекомендуется добавить проверку прав доступа.
+
+## Архитектура и зависимости
+
+### Используемые модели (ActiveRecord)
+
+| Модель | Назначение |
+|--------|-----------|
+| **CategoryPlan** | Хранение планов по категориям (год, месяц, магазин, категория, офлайн, интернет-магазин, маркетплейс, списания) |
+| **SalesWriteOffsPlan** | Общие планы продаж и списаний для магазина (офлайн, онлайн-магазин, маркетплейсы, списания) |
+| **Sales** | Данные продаж из 1С |
+| **WriteOffs** | Данные списаний из 1С |
+| **WriteOffsErp** | Типы списаний в ERP |
+| **CityStore** | Справочник магазинов |
+| **CityStoreParams** | Параметры магазинов (регион, город, район, тип) |
+| **StoreDynamic** | Динамические параметры магазинов (территориальный менеджер, КШФ) |
+| **Products1cNomenclature** | Номенклатура товаров 1С (категории, подкатегории, виды) |
+| **ExportImportTable** | Таблица соответствия ID между ERP и 1С |
+| **ScriptLauncherLog** | Логи запуска фоновых задач |
+
+### Используемые сервисы
+
+| Сервис | Назначение |
+|--------|-----------|
+| **AutoPlannogrammaService** | Расчет долей категорий в продажах и списаниях |
+| **StorePlanService** | Расчет исторических долей продаж товаров, медианных значений для товаров без истории |
+| **LogService** | Логирование ошибок API |
+
+### Используемые jobs
+
+| Job | Назначение |
+|-----|-----------|
+| **RebuildAutoplannogramJob** | Фоновая задача пересчета автопланограммы (импортируется, но не используется напрямую) |
+
+### Используемые helpers
+
+| Helper | Назначение |
+|--------|-----------|
+| **HtmlHelper** | Получение списка месяцев (`getMonthNames()`) |
+
+## Список actions и их назначение
+
+| # | Action | HTTP Method | Доступ | Назначение |
+|---|--------|-------------|--------|-----------|
+| 1 | `actionIndex` | GET | Public | Главная страница с расчетом и отображением планов по категориям |
+| 2 | `actionGetStores` | POST (AJAX) | Public | Получение списка магазинов с фильтрацией по региону, городу, району, типу, ТМ, КШФ |
+| 3 | `actionSaveFields` | POST (AJAX) | Public | Сохранение измененных значений планов по категориям |
+| 4 | `actionShowHistoryData` | GET/POST | Public | Анализ исторических данных продаж с разбивкой по категориям/подкатегориям/видам |
+| 5 | `actionGetSubcategories` | GET (AJAX) | Public | Получение списка подкатегорий для выбранной категории |
+| 6 | `actionGetSpecies` | GET (AJAX) | Public | Получение списка видов для выбранной подкатегории |
+| 7 | `actionRebuild` | GET (AJAX) | Public | Запуск фоновой задачи пересчета автопланограммы |
+| 8 | `actionCheckTask` | GET (AJAX) | Public | Проверка статуса выполнения фоновой задачи пересчета |
+
+### Вспомогательные методы
+
+| Метод | Тип | Назначение |
+|-------|-----|-----------|
+| `calculatePlannedSales()` | static public | Расчет плановых продаж по категориям на основе долей и общего плана |
+
+## Детальное описание actions
+
+### 1. actionIndex()
+
+**Назначение**: Главная страница планирования по категориям. Выполняет автоматический расчет планов на основе исторических данных.
+
+**Параметры (GET)**:
+
+- `year` (int) — год планирования (по умолчанию текущий год)
+- `month` (int) — месяц планирования (по умолчанию текущий месяц)
+- `store_id` (int) — ID магазина
+- `city_id` (int) — ID города (для фильтрации)
+- `region_id` (int) — ID региона (для фильтрации)
+- `raion_id` (int) — ID района (для фильтрации)
+- `store_type_id` (int) — тип магазина
+- `territory_manager_id` (int) — ID территориального менеджера
+- `kshf_id` (int) — ID куста/шефа флористов
+- `isEditable` (bool) — флаг принудительного включения редактирования
+- `delete` (string) — если "1", удаляет все планы для выбранных год/месяц/магазин
+
+**Алгоритм работы**:
+
+1. **Проверка возможности редактирования**:
+   - Дедлайн = 27 число за 2 месяца до планируемого периода
+   - Если текущая дата < дедлайн → редактирование разрешено
+   - Если `isEditable=1` → редактирование разрешено принудительно
+
+2. **Удаление планов** (если `delete=1`):
+   - Удаляет все записи `category_plan` для выбранного периода и магазина
+   - Устанавливает флаг пересчета в кеше
+   - Возвращает сообщение об успехе
+
+3. **Расчет планов** (если выбран магазин, год и месяц):
+
+   **3.1. Расчет для офлайн-продаж**:
+   - Период анализа: 3 месяца (с -4 до -2 месяцев от планируемого)
+   - Запрос категорийных продаж через `AutoPlannogrammaService::getMonthCategoryShareOrWriteOff()`
+   - Запрос продаж "Матрица" через таблицу `Sales` с фильтром `order_id IN ('', '0')`
+   - Взвешивание продаж: месяц -4 × 1, месяц -3 × 2, месяц -2 × 3
+   - Расчет плановых продаж через `calculatePlannedSales()` на основе `offline_sales_plan`
+
+   **3.2. Расчет для онлайн-продаж магазина**:
+   - Аналогичный алгоритм
+   - Фильтр продаж: `order_id NOT IN ('', '0')`
+   - Расчет на основе `online_sales_shop_plan`
+
+   **3.3. Расчет для онлайн-продаж маркетплейсов**:
+   - Использует те же данные, что и онлайн-продажи магазина
+   - Расчет на основе `online_sales_marketplace_plan`
+
+   **3.4. Расчет для списаний**:
+   - Запрос списаний через `AutoPlannogrammaService::getMonthCategoryShareOrWriteOff()`
+   - Запрос списаний "Матрица" через таблицу `WriteOffs` с типом `WRITE_OFFS_TYPE_BRAK`
+   - Взвешивание аналогично продажам
+   - Расчет на основе `write_offs_plan`
+
+4. **Создание записей CategoryPlan**:
+   - Для каждой категории создается запись, если её ещё нет
+   - Сохраняются: `offline`, `internet_shop`, `marketplace`, `write_offs`
+   - При сохранении первой записи создается задача пересчета автопланограммы
+
+5. **Сортировка категорий**:
+   - Срезка → Сухоцветы → Горшечные_растения → Сопутствующие_товары → Упаковка → Матрица
+
+6. **Рендер представления** с данными:
+   - `model` — модель фильтров
+   - `years` — список доступных лет
+   - `stores` — список магазинов
+   - `table` — исторические продажи офлайн
+   - `tableOnline` — исторические продажи онлайн
+   - `tableWriteOffs` — исторические списания
+   - `types` — список категорий
+   - `salesWriteOffsPlan` — общие планы магазина
+   - `isEditable` — флаг редактирования
+   - `categoryPlan` — существующие планы по категориям
+
+**Возвращает**: HTML страницу или JSON (при AJAX-запросе на удаление)
+
+**Связанные модели**:
+- `CategoryPlan` — создание/чтение
+- `SalesWriteOffsPlan` — чтение общих планов
+- `Sales`, `WriteOffs` — чтение исторических данных
+- `ScriptLauncherLog` — создание задачи пересчета
+
+**Важные детали**:
+- Использует кеш для флага `needRecalc`
+- Взвешивание данных по месяцам (более свежие месяцы имеют больший вес)
+- Автоматическое создание задачи пересчета автопланограммы
+
+---
+
+### 2. actionGetStores()
+
+**Назначение**: AJAX-метод для получения списка магазинов с учетом фильтров.
+
+**Параметры (POST)**:
+
+- `city_id` (int) — ID города
+- `region_id` (int) — ID региона
+- `raion_id` (int) — ID района
+- `store_type_id` (int) — тип магазина
+- `territorial_manager_id` (int) — ID территориального менеджера
+- `kshf_id` (int) — ID куста/шефа флористов
+
+**Алгоритм**:
+
+1. Базовый запрос: `CityStore::find()->andWhere(['visible' => 1])`
+2. Фильтрация по городу/региону/району через `CityStoreParams`
+3. Фильтрация по территориальному менеджеру через `StoreDynamic` (category=3)
+4. Фильтрация по КШФ через `StoreDynamic` (category=2)
+
+**Возвращает**: JSON `{ store_id: store_name, ... }`
+
+**Формат ответа**:
+
+```json
+{
+  "1": "Магазин 1",
+  "2": "Магазин 2"
+}
+```
+
+---
+
+### 3. actionSaveFields()
+
+**Назначение**: Сохранение измененных значений планов по одной категории.
+
+**Параметры (POST)**:
+
+- `year` (int) — год
+- `month` (int) — месяц
+- `store_id` (int) — ID магазина
+- `type` (string) — название категории
+- `offline` (float) — план офлайн-продаж
+- `internet_shop` (float) — план интернет-магазина
+- `write_offs` (float) — план списаний
+
+**Алгоритм**:
+
+1. Поиск существующей записи `CategoryPlan`
+2. Если не найдена — создание новой записи
+3. Обновление полей: `offline`, `internet_shop`, `write_offs`
+4. Автоматическое обновление `updated_by` и `updated_at` через Blameable/Timestamp behaviors
+5. Сохранение
+
+**Возвращает**: Строку "ok" или Exception с ошибками валидации
+
+**Важно**: Не обновляет поле `marketplace`, только три указанных поля.
+
+---
+
+### 4. actionShowHistoryData()
+
+**Назначение**: Анализ исторических данных продаж с детализацией по категориям, подкатегориям и видам.
+
+**Параметры (GET/POST)**:
+
+- `storeId` (int, required) — ID магазина
+- `month` (int, required) — месяц для анализа
+- `category` (string, required) — категория товара
+- `subcategory` (string, optional) — подкатегория товара
+- `species` (string, optional) — вид товара
+
+**Алгоритм**:
+
+1. **Формирование списков фильтров**:
+   - `storeList` — все магазины
+   - `monthsList` — названия месяцев
+   - `categoryList` — уникальные категории из `Products1cNomenclature`
+
+2. **При отправке формы**:
+   - Если выбрана категория → загружаются подкатегории
+   - Если выбрана подкатегория → загружаются виды
+   - Валидация модели
+
+3. **Расчет данных** (через `StorePlanService`):
+   - `calculateHistoricalShare()` — расчет долей товаров с историей и без истории
+   - `calculateMedianSalesForProductsWithoutHistoryExtended()` — расчет медианных продаж для товаров без истории
+   - `calculateProductSalesShareProductsWithHistory()` — расчет долей продаж товаров с историей
+
+**Возвращает**: HTML страницу с результатами анализа
+
+**Структура результата**:
+
+```php
+[
+  'result' => [
+    'with_history' => [...],    // Товары с историей продаж
+    'without_history' => [...]  // Товары без истории
+  ],
+  'weighted' => [...],          // Взвешенные медианные значения
+  'productSalesShare' => [...]  // Доли продаж товаров
+]
+```
+
+---
+
+### 5. actionGetSubcategories()
+
+**Назначение**: AJAX-метод получения подкатегорий для выбранной категории.
+
+**Параметры (GET)**:
+
+- `category` (string, required) — название категории
+
+**Алгоритм**:
+
+1. Запрос уникальных подкатегорий из `Products1cNomenclature`
+2. Фильтрация по категории
+3. Сортировка по алфавиту
+
+**Возвращает**: JSON массив объектов `{ id, name }`
+
+**Пример ответа**:
+
+```json
+[
+  { "id": "Розы", "name": "Розы" },
+  { "id": "Тюльпаны", "name": "Тюльпаны" }
+]
+```
+
+---
+
+### 6. actionGetSpecies()
+
+**Назначение**: AJAX-метод получения видов для выбранной подкатегории.
+
+**Параметры (GET)**:
+
+- `subcategory` (string, required) — название подкатегории
+
+**Алгоритм**: Аналогично `actionGetSubcategories()`, но фильтрация по `subcategory`
+
+**Возвращает**: JSON массив объектов `{ id, name }`
+
+---
+
+### 7. actionRebuild()
+
+**Назначение**: Запуск фоновой задачи пересчета автопланограммы.
+
+**Параметры (GET)**:
+
+- `year` (int) — год
+- `month` (int) — месяц
+- `store_id` (int) — ID магазина
+
+**Алгоритм**:
+
+1. **Проверка наличия активной задачи**:
+   - Поиск последней записи `ScriptLauncherLog` с именем `taskApRecalculate`
+   - Если задача активна (`active=1`, `status=1`) → возврат `{ status: 'running' }`
+
+2. **Создание новой задачи**:
+   - Создание записи `ScriptLauncherLog` с контекстом:
+     - `taskName`: "taskApRecalculate"
+     - `year`, `month`, `storeId`
+     - `info`: "corrected"
+     - `status`: "pending"
+     - `progress`: 0
+
+**Возвращает**: JSON
+
+```json
+{
+  "status": "running"  // если задача уже выполняется
+}
+```
+
+или
+
+```json
+{
+  "status": "started",
+  "data": {
+    "taskName": "taskApRecalculate",
+    "year": 2024,
+    "month": 11,
+    "storeId": 1,
+    "info": "corrected",
+    "status": "pending",
+    "startTime": "2024-11-26 12:00:00",
+    "progress": 0,
+    "error": null
+  }
+}
+```
+
+---
+
+### 8. actionCheckTask()
+
+**Назначение**: Проверка статуса выполнения фоновой задачи пересчета.
+
+**Параметры**: Нет
+
+**Алгоритм**:
+
+1. Поиск последней записи `ScriptLauncherLog` с именем `taskApRecalculate`
+2. Декодирование поля `context` (JSON)
+3. Возврат статуса задачи
+
+**Возвращает**: JSON
+
+```json
+{
+  "status": "pending|running|completed|error",
+  "progress": 75,
+  "error": null,
+  "start": "2024-11-26 12:00:00"
+}
+```
+
+или
+
+```json
+{
+  "status": "not_found"
+}
+```
+
+---
+
+## Вспомогательный метод: calculatePlannedSales()
+
+**Сигнатура**:
+
+```php
+public static function calculatePlannedSales(array $salesByCategory, float $planLevel): array
+```
+
+**Назначение**: Расчет плановых продаж/списаний по категориям на основе долей и общего плана.
+
+**Параметры**:
+
+- `$salesByCategory` — массив вида `[store_id => [category => sum, ...], ...]`
+- `$planLevel` — общий план для магазина (в рублях)
+
+**Алгоритм**:
+
+1. Для каждого магазина:
+   - Суммируются продажи всех категорий (кроме "Матрица")
+   - Если сумма ≤ 0 → возврат пустого массива для магазина
+2. Для каждой категории:
+   - Расчет доли: `share = round(sum / sumCat, 4)`
+   - Расчет плана: `plan = planLevel × share`
+
+**Возвращает**: Массив `[store_id => [category => plannedSale, ...], ...]`
+
+**Пример**:
+
+```php
+// Вход:
+$salesByCategory = [
+  1 => [
+    'Срезка' => 100000,
+    'Горшечные_растения' => 50000,
+    'Сопутствующие_товары' => 30000,
+    'Матрица' => 20000  // Исключается из расчета
+  ]
+];
+$planLevel = 200000;
+
+// Выход:
+[
+  1 => [
+    'Срезка' => 111111.11,           // 200000 × (100000 / 180000)
+    'Горшечные_растения' => 55555.56, // 200000 × (50000 / 180000)
+    'Сопутствующие_товары' => 33333.33, // 200000 × (30000 / 180000)
+    'Матрица' => 22222.22            // 200000 × (20000 / 180000)
+  ]
+]
+```
+
+> **ВАЖНО**: Категория "Матрица" исключается из суммы `sumCat`, но получает долю от плана.
+
+---
+
+## Бизнес-логика и правила
+
+### 1. Категории товаров
+
+Система работает с 6 категориями (в порядке приоритета):
+
+1. **Срезка** — срезанные цветы
+2. **Сухоцветы** — сухие цветы и композиции
+3. **Горшечные_растения** — комнатные растения
+4. **Сопутствующие_товары** — открытки, игрушки, подарки
+5. **Упаковка** — упаковочные материалы
+6. **Матрица** — товары из категории "Матрица" (специальная категория в 1С)
+
+### 2. Типы продаж и списаний
+
+Для каждой категории рассчитываются 4 типа планов:
+
+1. **Офлайн-продажи** (`offline`) — продажи в магазине (`order_id IN ('', '0')`)
+2. **Интернет-магазин** (`internet_shop`) — продажи через собственный интернет-магазин (`order_id NOT IN ('', '0')`)
+3. **Маркетплейсы** (`marketplace`) — продажи через маркетплейсы (отдельный план)
+4. **Списания** (`write_offs`) — списания бракованных товаров
+
+### 3. Расчет на основе истории
+
+**Период анализа**: 3 месяца (с -4 до -2 месяцев относительно планируемого)
+
+**Взвешивание данных**:
+- Месяц -4 (самый старый): вес × 1
+- Месяц -3: вес × 2
+- Месяц -2 (самый свежий): вес × 3
+
+**Обоснование**: Более свежие данные имеют больший вес при прогнозировании.
+
+### 4. Редактируемость планов
+
+**Правило**: Планы можно редактировать до 27 числа за 2 месяца до планируемого периода.
+
+**Пример**:
+- Планируем: 2025 февраль
+- Дедлайн редактирования: 2024 декабрь 27
+
+**Исключение**: Флаг `isEditable=1` разрешает редактирование в любое время.
+
+### 5. Пересчет автопланограммы
+
+**Триггер**: Создание первой записи `CategoryPlan` для периода/магазина
+
+**Механизм**:
+1. Устанавливается флаг `needRecalc` в кеше
+2. Создается запись `ScriptLauncherLog` с контекстом задачи
+3. Фоновая задача (CronController) забирает задачу и выполняет пересчет
+4. После пересчета флаг `needRecalc` удаляется из кеша
+
+### 6. Распределение плана по категориям
+
+**Алгоритм**:
+1. Общий план магазина берется из `SalesWriteOffsPlan`
+2. Рассчитывается доля каждой категории в исторических продажах
+3. План распределяется пропорционально долям
+
+**Формула**:
+```
+Доля категории = Сумма продаж категории / Сумма продаж всех категорий (кроме "Матрица")
+План категории = Общий план × Доля категории
+```
+
+---
+
+## Интеграции и связи с другими модулями
+
+### 1. Связь с AutoPlannogrammaController
+
+- `CategoryPlanController` создает планы по категориям
+- `AutoPlannogrammaController` использует эти планы для расчета автопланограммы
+- При изменении планов инициируется пересчет автопланограммы
+
+### 2. Связь с SalesWriteOffsPlanController
+
+- `SalesWriteOffsPlanController` создает общие планы магазина
+- `CategoryPlanController` использует эти планы для распределения по категориям
+- Без общего плана невозможно рассчитать планы категорий
+
+### 3. Связь с CronController
+
+- `CategoryPlanController` создает задачи пересчета (`ScriptLauncherLog`)
+- `CronController` выполняет фоновые задачи пересчета автопланограммы
+
+### 4. Интеграция с 1С
+
+- Чтение продаж из таблицы `Sales` (выгрузка из 1С)
+- Чтение списаний из таблицы `WriteOffs` (выгрузка из 1С)
+- Использование `ExportImportTable` для сопоставления ID магазинов
+
+---
+
+## Важные особенности и паттерны
+
+### 1. Взвешенный расчет исторических данных
+
+```php
+$weights = [];
+foreach ($mnths as $ind => $month) {
+    $weights[$month] = $ind + 1;
+}
+
+// Применение весов:
+$table[$store_id][$type] = ($table[$store_id][$type] ?? 0) + $weights[$month] * $total;
+```
+
+Более свежие месяцы получают больший вес (1, 2, 3).
+
+### 2. Автоматическое создание записей
+
+При загрузке страницы с выбранным магазином автоматически создаются записи `CategoryPlan` для всех найденных категорий.
+
+### 3. Кеширование и флаги пересчета
+
+Используется кеш Yii для координации фоновых задач:
+
+```php
+Yii::$app->cache->set('needRecalc', 'Recalc', 3600);
+Yii::$app->cache->delete('apRecalculateTask');
+```
+
+### 4. Разделение продаж офлайн/онлайн
+
+**Офлайн**: `order_id IN ('', '0')`
+**Онлайн**: `order_id NOT IN ('', '0')`
+
+### 5. Исключение категории "Матрица" из расчета долей
+
+Категория "Матрица" учитывается в данных, но исключается из суммы при расчете долей:
+
+```php
+foreach ($types as $type => $sum) {
+    if ($type === 'Матрица') {
+        continue;
+    }
+    $sumCat += $sum;
+}
+```
+
+---
+
+## Потенциальные проблемы и риски
+
+### 1. Отсутствие RBAC
+
+❌ Нет контроля доступа — любой авторизованный пользователь может изменять планы
+
+**Рекомендация**: Добавить проверку ролей (например, только руководители магазинов и менеджеры)
+
+### 2. Нет транзакций при массовом создании
+
+При создании множества записей `CategoryPlan` в цикле нет транзакции.
+
+**Риск**: Если произойдет ошибка на середине, часть записей будет создана, часть — нет.
+
+**Рекомендация**: Обернуть создание в транзакцию.
+
+### 3. Жестко заданные периоды анализа
+
+Период анализа (-4 до -2 месяцев) зашит в коде.
+
+**Риск**: Невозможно изменить логику без правки кода.
+
+**Рекомендация**: Вынести параметры в конфигурацию.
+
+### 4. Отсутствие обработки ошибок при AJAX-запросах
+
+Методы `actionSaveFields()`, `actionGetStores()` не обрабатывают исключения.
+
+**Риск**: При ошибке пользователь не получит понятного сообщения.
+
+### 5. Повторяющийся код расчета
+
+Расчет продаж офлайн, онлайн и списаний использует почти идентичный код (копипаста).
+
+**Рекомендация**: Вынести логику в отдельный метод или сервис.
+
+### 6. Неявное поведение при обновлении планов
+
+Метод `actionSaveFields()` не обновляет поле `marketplace`, что может быть неочевидно.
+
+---
+
+## Рекомендации по улучшению
+
+### 1. Добавить RBAC
+
+```php
+public function behaviors()
+{
+    return [
+        'access' => [
+            'class' => AccessControl::class,
+            'rules' => [
+                [
+                    'allow' => true,
+                    'roles' => ['categoryPlanManager'],
+                ],
+            ],
+        ],
+    ];
+}
+```
+
+### 2. Использовать транзакции
+
+```php
+$transaction = Yii::$app->db->beginTransaction();
+try {
+    foreach ($types as $type) {
+        // Создание CategoryPlan
+    }
+    $transaction->commit();
+} catch (\Exception $e) {
+    $transaction->rollBack();
+    throw $e;
+}
+```
+
+### 3. Вынести расчет в сервис
+
+Создать `CategoryPlanService` с методами:
+
+- `calculateOfflinePlan()`
+- `calculateOnlinePlan()`
+- `calculateWriteOffsPlan()`
+
+### 4. Улучшить обработку ошибок
+
+```php
+public function actionSaveFields()
+{
+    try {
+        // Логика
+        return $this->asJson(['status' => 'ok']);
+    } catch (\Exception $e) {
+        Yii::error($e->getMessage());
+        return $this->asJson(['status' => 'error', 'message' => $e->getMessage()]);
+    }
+}
+```
+
+### 5. Добавить валидацию входных данных
+
+```php
+$model = DynamicModel::validateData($data, [
+    [['year', 'month', 'store_id'], 'required'],
+    [['year'], 'integer', 'min' => 2020, 'max' => 2100],
+    [['month'], 'integer', 'min' => 1, 'max' => 12],
+]);
+```
+
+---
+
+## Связь с документацией
+
+- [Модель CategoryPlan](/docs/models/CategoryPlan.md)
+- [Модель SalesWriteOffsPlan](/docs/models/SalesWriteOffsPlan.md)
+- [Сервис AutoPlannogrammaService](/docs/services/AutoPlannogrammaService.md)
+- [Сервис StorePlanService](/docs/services/StorePlanService.md)
+- [Контроллер AutoPlannogrammaController](/docs/controllers/non-standard/AutoPlannogrammaController_ANALYSIS.md)
+- [Контроллер SalesWriteOffsPlanController](/docs/controllers/standard/SalesWriteOffsPlanController_ANALYSIS.md)
+
+---
+
+## Changelog
+
+| Дата | Автор | Изменения |
+|------|-------|-----------|
+| 2024-11-26 | Claude Code | Создание документации |
diff --git a/erp24/docs/controllers/non-standard/CategoryPlanController_QUICK_REFERENCE.md b/erp24/docs/controllers/non-standard/CategoryPlanController_QUICK_REFERENCE.md
new file mode 100644 (file)
index 0000000..ca26590
--- /dev/null
@@ -0,0 +1,699 @@
+# CategoryPlanController - Краткая справка
+
+## Быстрый обзор
+
+**CategoryPlanController** — контроллер для автоматического расчета и управления планами продаж и списаний по категориям товаров (срезка, сухоцветы, горшечные растения, сопутствующие товары, упаковка, матрица) в разрезе магазинов.
+
+---
+
+## Основная информация
+
+| Параметр | Значение |
+|----------|----------|
+| **Namespace** | `app\controllers` |
+| **Путь к файлу** | `erp24/controllers/CategoryPlanController.php` |
+| **Размер** | 713 строк |
+| **Actions** | 8 |
+| **Основная модель** | `CategoryPlan` |
+| **Основной сервис** | `AutoPlannogrammaService`, `StorePlanService` |
+
+---
+
+## Бизнес-задача
+
+Автоматически рассчитать плановые показатели продаж (офлайн, интернет-магазин, маркетплейсы) и списаний для каждой категории товаров на основе исторических данных за последние 3 месяца с учетом взвешивания (более свежие данные имеют больший вес).
+
+**Формула планирования**:
+```
+План категории = Общий план магазина × Доля категории в продажах
+```
+
+**Взвешивание по месяцам**:
+- Месяц -4 (старый): вес ×1
+- Месяц -3: вес ×2
+- Месяц -2 (свежий): вес ×3
+
+---
+
+## Основные use cases
+
+### 1. Автоматический расчет планов по категориям
+
+**Сценарий**: Менеджер хочет рассчитать планы на февраль 2025 для магазина #1
+
+**Действие**: Открыть `/category-plan/index?year=2025&month=2&store_id=1`
+
+**Результат**:
+1. Система анализирует продажи за октябрь, ноябрь, декабрь 2024
+2. Применяет веса: октябрь ×1, ноябрь ×2, декабрь ×3
+3. Рассчитывает доли категорий
+4. Распределяет общий план пропорционально долям
+5. Создает записи `CategoryPlan` для всех категорий
+6. Запускает фоновую задачу пересчета автопланограммы
+
+### 2. Ручное редактирование планов
+
+**Сценарий**: Менеджер корректирует план по категории "Срезка"
+
+**Действие**: AJAX-запрос на сохранение
+
+```javascript
+$.ajax({
+    url: '/category-plan/save-fields',
+    type: 'POST',
+    data: {
+        year: 2025,
+        month: 2,
+        store_id: 1,
+        type: 'Срезка',
+        offline: 150000,
+        internet_shop: 75000,
+        write_offs: 5000
+    }
+});
+```
+
+**Результат**: Обновляется запись `CategoryPlan`
+
+### 3. Удаление планов и пересчет
+
+**Сценарий**: Менеджер хочет удалить планы и пересчитать заново
+
+**Действие**: Открыть `/category-plan/index?year=2025&month=2&store_id=1&delete=1`
+
+**Результат**:
+1. Удаляются все записи `CategoryPlan` для периода/магазина
+2. Устанавливается флаг пересчета в кеше
+3. При следующем открытии страницы планы создаются заново
+
+### 4. Анализ исторических данных
+
+**Сценарий**: Менеджер хочет увидеть доли товаров в категории "Срезка" → "Розы"
+
+**Действие**: Открыть `/category-plan/show-history-data`, заполнить форму
+
+**Результат**:
+- Список товаров с историей продаж (с суммами и долями)
+- Список товаров без истории (с медианными значениями)
+- Взвешенные медианные значения
+
+### 5. Запуск пересчета автопланограммы
+
+**Сценарий**: Менеджер изменил планы и хочет пересчитать автопланограмму
+
+**Действие**: Кнопка "Пересчитать автопланограмму"
+
+```javascript
+$.ajax({
+    url: '/category-plan/rebuild',
+    type: 'GET',
+    data: {
+        year: 2025,
+        month: 2,
+        store_id: 1
+    }
+});
+```
+
+**Результат**: Создается фоновая задача пересчета
+
+### 6. Проверка статуса пересчета
+
+**Сценарий**: Менеджер отслеживает прогресс пересчета
+
+**Действие**: Периодический AJAX-запрос
+
+```javascript
+setInterval(function() {
+    $.ajax({
+        url: '/category-plan/check-task',
+        type: 'GET',
+        success: function(response) {
+            console.log('Прогресс:', response.progress + '%');
+        }
+    });
+}, 5000);
+```
+
+**Результат**: Получение статуса задачи (pending, running, completed, error)
+
+---
+
+## Ключевые actions
+
+| Action | Назначение | Когда использовать |
+|--------|-----------|-------------------|
+| `actionIndex` | Главная страница планирования | Просмотр и автоматический расчет планов |
+| `actionSaveFields` | Сохранение изменений | Ручная корректировка планов |
+| `actionShowHistoryData` | Анализ исторических данных | Детальный анализ продаж по товарам |
+| `actionRebuild` | Запуск пересчета | После изменения планов |
+| `actionCheckTask` | Проверка статуса задачи | Отслеживание прогресса пересчета |
+| `actionGetStores` | Получение списка магазинов | Фильтрация по региону/городу/менеджеру |
+| `actionGetSubcategories` | Получение подкатегорий | Каскадный выбор в форме анализа |
+| `actionGetSpecies` | Получение видов | Каскадный выбор в форме анализа |
+
+---
+
+## Workflow диаграмма
+
+```mermaid
+graph TD
+    A[Менеджер открывает страницу планирования] --> B{Магазин выбран?}
+    B -->|Нет| C[Показать форму выбора]
+    B -->|Да| D{Планы существуют?}
+
+    D -->|Нет| E[Получить общий план магазина из SalesWriteOffsPlan]
+    E --> F[Запросить продажи за 3 месяца]
+    F --> G[Применить взвешивание: месяц-4 ×1, месяц-3 ×2, месяц-2 ×3]
+    G --> H[Рассчитать доли категорий]
+    H --> I[Распределить план по категориям]
+    I --> J[Создать записи CategoryPlan]
+    J --> K[Установить флаг needRecalc в кеш]
+    K --> L[Создать задачу пересчета автопланограммы]
+    L --> M[Показать таблицу планов]
+
+    D -->|Да| N[Загрузить существующие планы]
+    N --> M
+
+    M --> O{Менеджер редактирует?}
+    O -->|Да| P[AJAX: actionSaveFields]
+    P --> Q[Обновить CategoryPlan]
+    Q --> M
+
+    O -->|Удалить| R[Запрос с delete=1]
+    R --> S[Удалить все планы для периода/магазина]
+    S --> T[Установить флаг needRecalc]
+    T --> D
+
+    O -->|Пересчитать| U[actionRebuild]
+    U --> V{Задача уже запущена?}
+    V -->|Да| W[Вернуть status: running]
+    V -->|Нет| X[Создать ScriptLauncherLog]
+    X --> Y[Вернуть status: started]
+
+    Y --> Z[CronController забирает задачу]
+    Z --> AA[Пересчет автопланограммы]
+    AA --> AB[Обновление progress в ScriptLauncherLog]
+    AB --> AC{Завершено?}
+    AC -->|Да| AD[status: completed]
+    AC -->|Ошибка| AE[status: error]
+
+    M --> AF[Менеджер проверяет статус: actionCheckTask]
+    AF --> AG[Вернуть progress и status]
+```
+
+---
+
+## Типичные сценарии использования
+
+### Сценарий 1: Ежемесячное планирование
+
+**Когда**: В конце каждого месяца (до дедлайна 27 числа за 2 месяца)
+
+**Шаги**:
+1. Открыть `/category-plan/index`
+2. Выбрать год, месяц (например, февраль 2025)
+3. Выбрать магазин
+4. Система автоматически рассчитает планы
+5. Просмотреть результаты
+6. При необходимости скорректировать вручную
+7. Дождаться завершения пересчета автопланограммы
+
+**Результат**: Планы по категориям созданы и автопланограмма пересчитана
+
+---
+
+### Сценарий 2: Корректировка планов после изменения стратегии
+
+**Когда**: Руководство изменило стратегию продаж (например, увеличить долю онлайн-продаж)
+
+**Шаги**:
+1. Открыть страницу планирования с выбранным магазином
+2. Вручную изменить значения в таблице
+3. Сохранить изменения (автоматически через AJAX)
+4. Запустить пересчет автопланограммы кнопкой "Пересчитать"
+5. Отслеживать прогресс пересчета
+
+**Результат**: Планы обновлены, автопланограмма пересчитана
+
+---
+
+### Сценарий 3: Анализ перед планированием
+
+**Когда**: Перед утверждением планов нужно проанализировать товарную структуру
+
+**Шаги**:
+1. Открыть `/category-plan/show-history-data`
+2. Выбрать магазин, месяц, категорию
+3. Опционально выбрать подкатегорию и вид
+4. Отправить форму
+5. Изучить:
+   - Товары с историей продаж (с точными суммами)
+   - Товары без истории (с медианными значениями)
+   - Доли товаров в категории
+
+**Результат**: Понимание структуры продаж для принятия решений
+
+---
+
+### Сценарий 4: Массовое планирование для сети магазинов
+
+**Когда**: Нужно создать планы для всех магазинов региона
+
+**Шаги**:
+1. Открыть страницу планирования
+2. Выбрать фильтры: регион, город, тип магазина
+3. AJAX-запрос `actionGetStores` вернет список магазинов
+4. Для каждого магазина:
+   - Открыть страницу с `store_id`
+   - Система автоматически создаст планы
+   - Перейти к следующему магазину
+
+**Результат**: Планы созданы для всех магазинов региона
+
+---
+
+## Важные особенности
+
+### 1. Категории товаров
+
+Система работает с 6 категориями (в порядке приоритета):
+
+```php
+[
+    'Срезка' => 1,
+    'Сухоцветы' => 2,
+    'Горшечные_растения' => 3,
+    'Сопутствующие_товары' => 4,
+    'Упаковка' => 5,
+    'Матрица' => 6,
+]
+```
+
+### 2. Типы продаж и списаний
+
+Для каждой категории 4 типа планов:
+
+| Тип | Поле в БД | Описание |
+|-----|-----------|----------|
+| Офлайн-продажи | `offline` | Продажи в физическом магазине |
+| Интернет-магазин | `internet_shop` | Продажи через собственный сайт |
+| Маркетплейсы | `marketplace` | Продажи через маркетплейсы |
+| Списания | `write_offs` | Списания бракованных товаров |
+
+### 3. Правило редактирования
+
+**Дедлайн**: 27 число за 2 месяца до планируемого периода
+
+**Пример**:
+- Планируется: **2025 февраль**
+- Дедлайн: **2024 декабрь 27**
+- Сегодня: **2024 ноябрь 26** → ✅ Редактирование **разрешено**
+- Сегодня: **2024 декабрь 28** → ❌ Редактирование **запрещено**
+
+**Исключение**: Параметр `isEditable=1` разрешает редактирование в любое время
+
+### 4. Взвешивание исторических данных
+
+```php
+// Пример для планирования февраля 2025:
+$weights = [
+    '2024-10' => 1,  // Октябрь (месяц -4)
+    '2024-11' => 2,  // Ноябрь (месяц -3)
+    '2024-12' => 3   // Декабрь (месяц -2)
+];
+
+// Расчет взвешенных продаж:
+$weightedSales =
+    $sales_oct * 1 +
+    $sales_nov * 2 +
+    $sales_dec * 3;
+```
+
+**Обоснование**: Более свежие данные имеют больший вес при прогнозировании
+
+### 5. Исключение категории "Матрица"
+
+При расчете долей категория "Матрица" **исключается** из суммы:
+
+```php
+foreach ($types as $type => $sum) {
+    if ($type === 'Матрица') {
+        continue;  // Не включать в сумму
+    }
+    $sumCat += $sum;
+}
+
+// Но затем "Матрица" получает долю от плана:
+foreach ($types as $type => $sum) {
+    $share = round($sum / $sumCat, 4);
+    $plan[$type] = $planLevel * $share;  // Включая "Матрица"
+}
+```
+
+---
+
+## Примеры кода
+
+### Пример 1: Расчет плановых продаж
+
+```php
+// Исходные данные
+$salesByCategory = [
+    1 => [  // Магазин #1
+        'Срезка' => 100000,
+        'Горшечные_растения' => 50000,
+        'Сопутствующие_товары' => 30000,
+        'Матрица' => 20000
+    ]
+];
+
+$planLevel = 200000;  // Общий план магазина
+
+// Расчет
+$result = CategoryPlanController::calculatePlannedSales($salesByCategory, $planLevel);
+
+// Результат:
+[
+    1 => [
+        'Срезка' => 111111.11,
+        'Горшечные_растения' => 55555.56,
+        'Сопутствующие_товары' => 33333.33,
+        'Матрица' => 22222.22
+    ]
+]
+
+// Проверка:
+// Сумма без "Матрица": 100000 + 50000 + 30000 = 180000
+// Доля "Срезка": 100000 / 180000 = 0.5556
+// План "Срезка": 200000 × 0.5556 = 111111.11 ✅
+```
+
+### Пример 2: Создание плана по категории
+
+```php
+use yii_app\records\CategoryPlan;
+
+$categoryPlan = new CategoryPlan();
+$categoryPlan->year = 2025;
+$categoryPlan->month = 2;
+$categoryPlan->store_id = 1;
+$categoryPlan->category = 'Срезка';
+$categoryPlan->offline = 111111.11;
+$categoryPlan->internet_shop = 55555.56;
+$categoryPlan->marketplace = 33333.33;
+$categoryPlan->write_offs = 5000.00;
+$categoryPlan->created_by = Yii::$app->user->id;
+$categoryPlan->created_at = date('Y-m-d H:i:s');
+$categoryPlan->updated_by = Yii::$app->user->id;
+$categoryPlan->updated_at = date('Y-m-d H:i:s');
+
+if ($categoryPlan->save()) {
+    echo "План сохранен";
+} else {
+    print_r($categoryPlan->getErrors());
+}
+```
+
+### Пример 3: AJAX сохранение изменений
+
+```javascript
+// Frontend (JavaScript)
+$('.plan-input').on('change', function() {
+    var year = $('#year').val();
+    var month = $('#month').val();
+    var storeId = $('#store_id').val();
+    var type = $(this).data('category');
+    var offline = $('input[data-category="' + type + '"][data-type="offline"]').val();
+    var internetShop = $('input[data-category="' + type + '"][data-type="internet_shop"]').val();
+    var writeOffs = $('input[data-category="' + type + '"][data-type="write_offs"]').val();
+
+    $.ajax({
+        url: '/category-plan/save-fields',
+        type: 'POST',
+        data: {
+            year: year,
+            month: month,
+            store_id: storeId,
+            type: type,
+            offline: offline,
+            internet_shop: internetShop,
+            write_offs: writeOffs
+        },
+        success: function(response) {
+            if (response === 'ok') {
+                showNotification('Сохранено', 'success');
+            }
+        },
+        error: function(xhr) {
+            showNotification('Ошибка: ' + xhr.responseText, 'error');
+        }
+    });
+});
+```
+
+### Пример 4: Запуск и отслеживание пересчета
+
+```javascript
+// Запуск пересчета
+function rebuildAutoplannogramma(year, month, storeId) {
+    $.ajax({
+        url: '/category-plan/rebuild',
+        type: 'GET',
+        data: {
+            year: year,
+            month: month,
+            store_id: storeId
+        },
+        success: function(response) {
+            if (response.status === 'started') {
+                console.log('Пересчет запущен');
+                checkTaskProgress();
+            } else if (response.status === 'running') {
+                console.log('Пересчет уже выполняется');
+                checkTaskProgress();
+            }
+        }
+    });
+}
+
+// Отслеживание прогресса
+function checkTaskProgress() {
+    var intervalId = setInterval(function() {
+        $.ajax({
+            url: '/category-plan/check-task',
+            type: 'GET',
+            success: function(response) {
+                if (response.status === 'not_found') {
+                    clearInterval(intervalId);
+                    console.log('Задача не найдена');
+                } else if (response.status === 'completed') {
+                    clearInterval(intervalId);
+                    console.log('Пересчет завершен');
+                    showNotification('Автопланограмма пересчитана', 'success');
+                    location.reload();
+                } else if (response.status === 'error') {
+                    clearInterval(intervalId);
+                    console.error('Ошибка:', response.error);
+                    showNotification('Ошибка пересчета: ' + response.error, 'error');
+                } else {
+                    // running или pending
+                    console.log('Прогресс:', response.progress + '%');
+                    updateProgressBar(response.progress);
+                }
+            }
+        });
+    }, 5000);  // Проверка каждые 5 секунд
+}
+
+// Использование:
+rebuildAutoplannogramma(2025, 2, 1);
+```
+
+### Пример 5: Каскадный выбор категория → подкатегория → вид
+
+```javascript
+// Загрузка подкатегорий при выборе категории
+$('#category').on('change', function() {
+    var category = $(this).val();
+
+    $.ajax({
+        url: '/category-plan/get-subcategories',
+        type: 'GET',
+        data: { category: category },
+        success: function(data) {
+            var $subcategory = $('#subcategory');
+            $subcategory.empty();
+            $subcategory.append('<option value="">Выберите подкатегорию</option>');
+
+            $.each(data, function(i, item) {
+                $subcategory.append('<option value="' + item.id + '">' + item.name + '</option>');
+            });
+
+            // Очистить виды
+            $('#species').empty().append('<option value="">Выберите вид</option>');
+        }
+    });
+});
+
+// Загрузка видов при выборе подкатегории
+$('#subcategory').on('change', function() {
+    var subcategory = $(this).val();
+
+    $.ajax({
+        url: '/category-plan/get-species',
+        type: 'GET',
+        data: { subcategory: subcategory },
+        success: function(data) {
+            var $species = $('#species');
+            $species.empty();
+            $species.append('<option value="">Выберите вид</option>');
+
+            $.each(data, function(i, item) {
+                $species.append('<option value="' + item.id + '">' + item.name + '</option>');
+            });
+        }
+    });
+});
+```
+
+---
+
+## FAQ
+
+### Q1: Почему планы не создаются?
+
+**A**: Проверьте:
+1. Выбран ли магазин, год и месяц
+2. Существует ли запись `SalesWriteOffsPlan` для магазина и периода
+3. Заполнены ли поля `offline_sales_plan`, `online_sales_shop_plan`, `online_sales_marketplace_plan`, `write_offs_plan` в `SalesWriteOffsPlan`
+4. Есть ли данные продаж за анализируемый период (3 месяца назад)
+
+### Q2: Как рассчитывается доля категории?
+
+**A**:
+```
+Доля = Взвешенные продажи категории / Сумма взвешенных продаж всех категорий (кроме "Матрица")
+```
+
+Пример:
+- Срезка: 100000 × 1 + 120000 × 2 + 130000 × 3 = 730000
+- Горшечные: 50000 × 1 + 60000 × 2 + 70000 × 3 = 380000
+- Сумма (без "Матрица"): 730000 + 380000 = 1110000
+- Доля Срезка: 730000 / 1110000 = 65.77%
+- План Срезка: 200000 × 0.6577 = 131540
+
+### Q3: Почему категория "Матрица" обрабатывается отдельно?
+
+**A**: Категория "Матрица" имеет специфическую бизнес-логику в 1С. При расчете долей она исключается из суммы, но затем получает свою долю от плана. Это связано с тем, что "Матрица" может иметь нестабильные объемы продаж.
+
+### Q4: Что делать, если дедлайн редактирования прошел?
+
+**A**: Есть 2 варианта:
+1. Использовать параметр `isEditable=1` для принудительного включения редактирования
+2. Обратиться к администратору для изменения планов через БД
+
+### Q5: Как часто нужно пересчитывать автопланограмму?
+
+**A**: Автопланограмма пересчитывается автоматически при:
+- Создании новых планов по категориям
+- Удалении планов
+- Ручном запуске через кнопку "Пересчитать"
+
+Не требуется пересчет при простом редактировании значений планов.
+
+### Q6: Что означает статус задачи "pending"?
+
+**A**: Статусы задачи:
+- **pending** — задача создана, ожидает выполнения
+- **running** — задача выполняется
+- **completed** — задача завершена успешно
+- **error** — произошла ошибка при выполнении
+
+### Q7: Можно ли удалить планы только для одной категории?
+
+**A**: Нет, действие `delete=1` удаляет планы для всех категорий выбранного магазина и периода. Для удаления одной категории нужно использовать прямой SQL-запрос или удалить через админ-панель.
+
+### Q8: Как работает взвешивание продаж?
+
+**A**: Более свежие данные получают больший вес:
+
+```php
+// Для планирования февраля 2025:
+Октябрь 2024 (месяц -4): вес = 1
+Ноябрь 2024 (месяц -3): вес = 2
+Декабрь 2024 (месяц -2): вес = 3
+
+// Расчет:
+Взвешенные продажи = Продажи_окт × 1 + Продажи_ноя × 2 + Продажи_дек × 3
+```
+
+Это позволяет учитывать сезонные тренды и давать больший вес актуальным данным.
+
+### Q9: Почему не сохраняется поле `marketplace` через `actionSaveFields`?
+
+**A**: Метод `actionSaveFields()` обновляет только 3 поля:
+- `offline`
+- `internet_shop`
+- `write_offs`
+
+Поле `marketplace` не включено в логику сохранения. Это может быть ограничением или особенностью бизнес-логики. Для обновления `marketplace` нужно использовать прямое обновление модели `CategoryPlan`.
+
+### Q10: Как проверить, какие данные используются для расчета?
+
+**A**: Используйте действие `actionShowHistoryData`:
+1. Откройте `/category-plan/show-history-data`
+2. Выберите магазин, месяц, категорию
+3. Изучите таблицы:
+   - Товары с историей (реальные продажи)
+   - Товары без истории (медианные значения)
+   - Доли товаров
+
+Это позволит понять, на основе каких данных рассчитываются планы.
+
+---
+
+## Связь с другими модулями
+
+```mermaid
+graph LR
+    A[CategoryPlanController] --> B[CategoryPlan Model]
+    A --> C[SalesWriteOffsPlan Model]
+    A --> D[AutoPlannogrammaService]
+    A --> E[StorePlanService]
+    A --> F[Sales Model]
+    A --> G[WriteOffs Model]
+
+    A -.Триггер.-> H[AutoPlannogrammaController]
+    C -.Зависимость.-> I[SalesWriteOffsPlanController]
+    A -.Фоновые задачи.-> J[CronController]
+
+    F -.Выгрузка.-> K[1С]
+    G -.Выгрузка.-> K
+
+    style A fill:#e1f5ff
+    style B fill:#ffe1e1
+    style C fill:#ffe1e1
+    style D fill:#e1ffe1
+    style E fill:#e1ffe1
+```
+
+---
+
+## Полезные ссылки
+
+- [Полный анализ CategoryPlanController](/docs/controllers/non-standard/CategoryPlanController_ANALYSIS.md)
+- [Таблица Actions CategoryPlanController](/docs/controllers/non-standard/CategoryPlanController_ACTIONS_TABLE.md)
+- [Модель CategoryPlan](/docs/models/CategoryPlan.md)
+- [Модель SalesWriteOffsPlan](/docs/models/SalesWriteOffsPlan.md)
+- [Сервис AutoPlannogrammaService](/docs/services/AutoPlannogrammaService.md)
+- [Сервис StorePlanService](/docs/services/StorePlanService.md)
+- [Контроллер AutoPlannogrammaController](/docs/controllers/non-standard/AutoPlannogrammaController_ANALYSIS.md)
+
+---
+
+## Changelog
+
+| Дата | Автор | Изменения |
+|------|-------|-----------|
+| 2024-11-26 | Claude Code | Создание краткой справки |
diff --git a/erp24/docs/controllers/non-standard/ChartForManagementController_ACTIONS_TABLE.md b/erp24/docs/controllers/non-standard/ChartForManagementController_ACTIONS_TABLE.md
new file mode 100644 (file)
index 0000000..d40e77f
--- /dev/null
@@ -0,0 +1,913 @@
+# ChartForManagementController - Таблица Actions
+
+## Общая информация
+
+| Параметр | Значение |
+|----------|----------|
+| **Контроллер** | ChartForManagementController |
+| **Namespace** | yii_app\controllers |
+| **Базовый путь** | /chart-for-management |
+| **Всего actions** | 5 |
+| **HTTP методы** | GET, POST (AJAX) |
+
+---
+
+## Таблица всех Actions
+
+| # | Action | Метод | URL | Тип | Доступ | Описание |
+|---|--------|-------|-----|-----|--------|----------|
+| 1 | actionIndex | GET | /chart-for-management/index | View | Роли: 1,81,71,51,10,9,74,14,7,30,40,35,72,50 | Главный дашборд с графиками для руководства |
+| 2 | actionWriteOffPosition | GET | /chart-for-management/write-off-position | View | Роли: 1,81,71,51,10,9,74,14,7,30,40,35,72,50 | Страница анализа списаний по позициям |
+| 3 | actionGetControlDataAjax | POST | /chart-for-management/get-control-data-ajax | AJAX/JSON | Auth required | Получение контрольных данных (магазины, кусты) |
+| 4 | actionGetDataAjax | POST | /chart-for-management/get-data-ajax | AJAX/JSON | Auth required | Получение данных для построения графиков |
+| 5 | actionWriteOffsIndex | GET | /chart-for-management/write-offs-index | View | Auth required | Детальная таблица списаний за конкретную дату |
+
+---
+
+## Детальное описание Actions
+
+### 1. actionIndex()
+
+#### Описание
+Главная страница дашборда аналитики для руководства. Формирует список доступных графиков в зависимости от роли пользователя.
+
+#### HTTP метод
+`GET`
+
+#### URL
+```
+/chart-for-management/index
+```
+
+#### Параметры
+Нет параметров (использует Yii::$app->user->id)
+
+#### Возврат
+**Тип:** HTML View
+**View:** `views/chart-for-management/index.php`
+
+**Передаваемые данные:**
+```php
+[
+    'access' => [
+        'main' => [
+            'mode_level' => [1 => 'Розница', 2 => 'Куст', 3 => 'Магазин'],
+            'mode_shift' => [1 => 'День', 2 => 'Ночь', 3 => 'День+Ночь', 4 => 'Сутки'],
+            'visible' => true
+        ],
+        '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' => [...]
+    ]
+]
+```
+
+#### RBAC
+**Проверка:** group_id текущего пользователя
+
+**Разрешенные роли:**
+- 1, 81, 71, 51, 10, 9, 74, 14: Топ-менеджмент (полный доступ)
+- 7: Кластер-менеджер (куст/магазин)
+- 30, 40: Администратор дневной смены (только магазин, день)
+- 35, 72: Администратор ночной смены (только магазин, ночь)
+- 50: Администратор магазина (полный доступ к магазину)
+
+**Исключение:** `yii\db\Exception` с сообщением "Нет доступа" для других ролей
+
+#### Пример использования
+
+**Request:**
+```
+GET /chart-for-management/index
+Cookie: PHPSESSID=...
+```
+
+**Response:**
+```html
+<!-- HTML страница с дашбордом графиков -->
+<div class="charts-dashboard">
+    <div class="chart-container" data-type="sales">...</div>
+    <div class="chart-container" data-type="fot">...</div>
+    ...
+</div>
+```
+
+---
+
+### 2. actionWriteOffPosition()
+
+#### Описание
+Специализированная страница для анализа списаний по товарным позициям. Предоставляет детальную визуализацию списаний с возможностью планирования.
+
+#### HTTP метод
+`GET`
+
+#### URL
+```
+/chart-for-management/write-off-position
+```
+
+#### Параметры
+Нет параметров (использует Yii::$app->user->id)
+
+#### Возврат
+**Тип:** HTML View
+**View:** `views/chart-for-management/write-offs-position-chart.php`
+
+**Передаваемые данные:**
+```php
+[
+    'access' => [
+        'mode_level' => [1 => 'Розница', 2 => 'Куст', 3 => 'Магазин'],
+        'mode_shift' => [1 => 'День', 2 => 'Ночь', 3 => 'День+Ночь'],
+        'access_plan' => true  // только для ролей с правом планирования
+    ]
+]
+```
+
+#### RBAC
+**Проверка:** group_id текущего пользователя
+
+**Разрешенные роли с доступом к планам:**
+- 1, 81, 71, 51, 10, 9, 74, 14: Топ-менеджмент
+- 7: Кластер-менеджер
+- 50: Администратор магазина
+
+**Без доступа к планам:**
+- 30, 40: Администратор дневной смены
+- 35, 72: Администратор ночной смены
+
+#### Пример использования
+
+**Request:**
+```
+GET /chart-for-management/write-off-position
+Cookie: PHPSESSID=...
+```
+
+**Response:**
+```html
+<!-- HTML страница с графиком списаний -->
+<div class="write-offs-chart">
+    <div id="chart"></div>
+    <div class="filters">...</div>
+</div>
+```
+
+---
+
+### 3. actionGetControlDataAjax()
+
+#### Описание
+AJAX endpoint для получения контрольных данных: списка магазинов, кустов, их иерархии и истории изменений кустов магазинов.
+
+#### HTTP метод
+`POST` (AJAX)
+
+#### URL
+```
+/chart-for-management/get-control-data-ajax
+```
+
+#### Параметры
+
+**POST Body (application/x-www-form-urlencoded):**
+
+| Параметр | Тип | Обязательный | Описание | Пример |
+|----------|-----|--------------|----------|---------|
+| date_start | string | Да | Начальная дата периода | "2024-01-01" |
+| date_end | string | Да | Конечная дата периода | "2024-01-31" |
+
+#### Возврат
+**Тип:** JSON
+
+**Структура:**
+```json
+{
+  "stores_step": {
+    "Магазин Центральный": {
+      "2024-01-01": "Куст 5",
+      "2024-02-01": "Куст 7"
+    },
+    "Магазин Северный": {
+      "2024-01-15": "Куст 3"
+    }
+  },
+  "clusters": [
+    {"id": 3, "text": "Куст 3"},
+    {"id": 5, "text": "Куст 5"},
+    {"id": 7, "text": "Куст 7"}
+  ],
+  "stores_in_cluster": {
+    "3": {
+      "text": "Куст 3",
+      "children": [
+        {"id": 42, "text": "Магазин Северный"},
+        {"id": 55, "text": "Магазин Южный"}
+      ]
+    },
+    "5": {
+      "text": "Куст 5",
+      "children": [
+        {"id": 10, "text": "Магазин Центральный"}
+      ]
+    }
+  }
+}
+```
+
+**Поля ответа:**
+- `stores_step`: магазины, которые меняли куст в указанный период (ключ: название магазина, значение: массив дата → куст)
+- `clusters`: уникальные кусты, доступные пользователю
+- `stores_in_cluster`: иерархия куст → магазины для Select2
+
+#### Логика
+1. Загружает магазины из `admin.store_arr` текущего пользователя
+2. Делает LEFT JOIN с `store_dynamic` для определения кустов
+3. Фильтрует по пересечению временных диапазонов
+4. Формирует массив магазинов, которые меняли куст (`stores_step`)
+5. Извлекает уникальные кусты
+6. Группирует магазины по кустам
+
+#### Пример использования
+
+**Request:**
+```javascript
+$.ajax({
+    url: '/chart-for-management/get-control-data-ajax',
+    method: 'POST',
+    data: {
+        date_start: '2024-01-01',
+        date_end: '2024-01-31'
+    },
+    success: function(response) {
+        let data = JSON.parse(response);
+        // data.clusters - список кустов
+        // data.stores_in_cluster - иерархия для Select2
+        // data.stores_step - предупреждение о смене кустов
+    }
+});
+```
+
+---
+
+### 4. actionGetDataAjax()
+
+#### Описание
+Основной AJAX endpoint для получения данных графиков. Поддерживает 10+ типов аналитических графиков с различными режимами агрегации и фильтрации.
+
+#### HTTP метод
+`POST` (AJAX)
+
+#### URL
+```
+/chart-for-management/get-data-ajax
+```
+
+#### Параметры
+
+**POST Body (application/x-www-form-urlencoded):**
+
+| Параметр | Тип | Обязательный | Описание | Возможные значения | Пример |
+|----------|-----|--------------|----------|-------------------|---------|
+| mode | int | Да | Уровень агрегации | 1=Розница, 2=Куст, 3=Магазин | 3 |
+| attribute | string | Да | Тип графика | см. список ниже | "sales" |
+| date_start | string | Нет | Начальная дата | Y-m-d | "2024-01-01" |
+| date_end | string | Нет | Конечная дата | Y-m-d | "2024-01-15" |
+| cluster | int | Нет | ID куста (при mode=2) | | 5 |
+| store | int | Нет | ID магазина (при mode=3) | | 42 |
+| shift | int | Нет | Смена | 1=День, 2=Ночь, 3=День+Ночь, 4=Сутки | 3 |
+
+**Типы графиков (attribute):**
+1. `sales` - Продажи и план
+2. `plan_completed_this_day` - Выполнение плана за текущий день
+3. `plan_completed_this_month` - Прогноз выполнения плана за месяц
+4. `avg_sales_value` - Средний чек
+5. `fot` - ФОТ (фонд оплаты труда)
+6. `sales_sum_on_admin` - Продажи на администратора
+7. `count_sales_in_hour` - Продажи по часам
+8. `user_bonus` - Бонусы персонала
+9. `matrix_sales_sum` - Продажи по матрице
+10. `write_offs` - Списания
+11. `write_offs_position` - Списания по позициям
+
+#### Возврат
+
+**Тип:** JSON
+
+**Структура для графиков (все кроме plan_completed_*):**
+```json
+{
+  "chart_opts": {
+    "xaxis": {
+      "type": "text",
+      "categories": ["2024-01-01 Пн", "2024-01-02 Вт", "2024-01-03 Ср"]
+    },
+    "title": {
+      "text": "Магазин: Центральный"
+    }
+  },
+  "data_answer": {
+    "attribute": {
+      "sales_sum": {
+        "data": [1000, 1200, 1500],
+        "name": "Продажи",
+        "type": "line"
+      },
+      "plan": {
+        "data": [1100, 1100, 1100],
+        "name": "План",
+        "type": "line"
+      }
+    }
+  }
+}
+```
+
+**Структура для plan_completed_this_day:**
+```json
+{
+  "chart_opts": {...},
+  "data_answer": {
+    "attribute": {
+      "plan_complete_on_this_day": 95.67
+    }
+  }
+}
+```
+
+**Структура для plan_completed_this_month:**
+```json
+{
+  "chart_opts": {...},
+  "data_answer": {
+    "attribute": {
+      "plan_hypothesis_complete_on_this_month": 102.34
+    }
+  }
+}
+```
+
+**При ошибке (write_offs_position без данных):**
+```
+-1
+```
+
+#### Примеры использования
+
+##### Пример 1: Получение графика продаж
+
+**Request:**
+```javascript
+$.ajax({
+    url: '/chart-for-management/get-data-ajax',
+    method: 'POST',
+    data: {
+        mode: 3,              // Уровень магазина
+        attribute: 'sales',   // График продаж
+        date_start: '2024-01-01',
+        date_end: '2024-01-15',
+        store: 42,            // ID магазина
+        shift: 3              // День+Ночь
+    },
+    success: function(response) {
+        let data = JSON.parse(response);
+        // Отрисовка графика с помощью ApexCharts/Chart.js
+        renderChart(data.chart_opts, data.data_answer);
+    }
+});
+```
+
+**Response:**
+```json
+{
+  "chart_opts": {
+    "xaxis": {
+      "type": "text",
+      "categories": ["2024-01-01 Пн", "2024-01-02 Вт", ..., "2024-01-15 Пн"]
+    },
+    "title": {
+      "text": "Магазин: Центральный"
+    }
+  },
+  "data_answer": {
+    "attribute": {
+      "sales_sum": {
+        "data": [12450, 13200, 11800, 14300, 15100, 13900, 16200, 12800, 13500, 14100, 15300, 13700, 14800, 15900, 16500],
+        "name": "Продажи",
+        "type": "line"
+      },
+      "plan": {
+        "data": [14000, 14000, 14000, 14000, 14000, 14000, 14000, 14000, 14000, 14000, 14000, 14000, 14000, 14000, 14000],
+        "name": "План",
+        "type": "line"
+      }
+    }
+  }
+}
+```
+
+##### Пример 2: Получение среднего чека
+
+**Request:**
+```javascript
+$.ajax({
+    url: '/chart-for-management/get-data-ajax',
+    method: 'POST',
+    data: {
+        mode: 2,                      // Уровень куста
+        attribute: 'avg_sales_value', // Средний чек
+        date_start: '2024-01-01',
+        date_end: '2024-01-07',
+        cluster: 5,                   // ID куста
+        shift: 1                      // День
+    },
+    success: function(response) {
+        let data = JSON.parse(response);
+        console.log(data);
+    }
+});
+```
+
+**Response:**
+```json
+{
+  "chart_opts": {
+    "xaxis": {
+      "type": "text",
+      "categories": ["2024-01-01 Пн", "2024-01-02 Вт", "2024-01-03 Ср", "2024-01-04 Чт", "2024-01-05 Пт", "2024-01-06 Сб", "2024-01-07 Вс"]
+    },
+    "title": {
+      "text": "Куст 5"
+    }
+  },
+  "data_answer": {
+    "attribute": {
+      "avg_sales_value": {
+        "data": [245.5, 252.3, 238.7, 260.1, 255.8, 280.4, 275.6],
+        "name": "Средний чек",
+        "type": "line"
+      },
+      "plan_avg_sales_value": {
+        "data": [250, 250, 250, 250, 250, 250, 250],
+        "name": "План",
+        "type": "line"
+      }
+    }
+  }
+}
+```
+
+##### Пример 3: Продажи по часам
+
+**Request:**
+```javascript
+$.ajax({
+    url: '/chart-for-management/get-data-ajax',
+    method: 'POST',
+    data: {
+        mode: 3,
+        attribute: 'count_sales_in_hour',
+        date_start: '2024-01-01',
+        date_end: '2024-01-07',
+        store: 42,
+        shift: 1  // Только день (8-19)
+    },
+    success: function(response) {
+        let data = JSON.parse(response);
+        console.log(data);
+    }
+});
+```
+
+**Response:**
+```json
+{
+  "chart_opts": {
+    "xaxis": {
+      "type": "text",
+      "categories": [8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19]
+    },
+    "title": {
+      "text": "Магазин: Центральный"
+    }
+  },
+  "data_answer": {
+    "attribute": {
+      "count_sales": {
+        "data": [45, 67, 89, 102, 110, 98, 85, 92, 105, 88, 70, 55],
+        "name": "Всего продаж",
+        "type": "bar"
+      },
+      "avg_count_sales": {
+        "data": [6.4, 9.6, 12.7, 14.6, 15.7, 14.0, 12.1, 13.1, 15.0, 12.6, 10.0, 7.9],
+        "name": "Среднее в день",
+        "type": "line"
+      }
+    }
+  }
+}
+```
+
+##### Пример 4: Выполнение плана за текущий день
+
+**Request:**
+```javascript
+$.ajax({
+    url: '/chart-for-management/get-data-ajax',
+    method: 'POST',
+    data: {
+        mode: 1,  // Розница
+        attribute: 'plan_completed_this_day',
+        shift: 3
+    },
+    success: function(response) {
+        let data = JSON.parse(response);
+        console.log('План выполнен на: ' + data.data_answer.attribute.plan_complete_on_this_day + '%');
+    }
+});
+```
+
+**Response:**
+```json
+{
+  "chart_opts": null,
+  "data_answer": {
+    "attribute": {
+      "plan_complete_on_this_day": 95.67
+    }
+  }
+}
+```
+
+##### Пример 5: Списания
+
+**Request:**
+```javascript
+$.ajax({
+    url: '/chart-for-management/get-data-ajax',
+    method: 'POST',
+    data: {
+        mode: 3,
+        attribute: 'write_offs',
+        date_start: '2024-01-01',  // Будет выравнен на начало месяца
+        date_end: '2024-01-31',
+        store: 42
+    },
+    success: function(response) {
+        let data = JSON.parse(response);
+        console.log(data);
+    }
+});
+```
+
+**Response:**
+```json
+{
+  "chart_opts": {
+    "xaxis": {
+      "type": "text",
+      "categories": ["2024-01-01 Пн", "2024-01-02 Вт", ..., "2024-01-31 Ср"]
+    },
+    "title": {
+      "text": "Магазин: Центральный"
+    }
+  },
+  "data_answer": {
+    "attribute": {
+      "write_offs_sum": {
+        "data": [150, 200, 180, 220, 170, ...],
+        "name": "Списания, руб",
+        "type": "bar"
+      },
+      "write_offs_percent_day": {
+        "data": [1.2, 1.5, 1.3, 1.6, 1.4, ...],
+        "name": "% от продаж (день)",
+        "type": "line"
+      },
+      "write_offs_percent_month": {
+        "data": [1.2, 1.35, 1.33, 1.38, 1.37, ...],
+        "name": "% от продаж (месяц)",
+        "type": "line"
+      }
+    }
+  }
+}
+```
+
+---
+
+### 5. actionWriteOffsIndex()
+
+#### Описание
+Детальная таблица списаний типа "Брак" за конкретную дату с возможностью фильтрации по кусту и магазину.
+
+#### HTTP метод
+`GET`
+
+#### URL
+```
+/chart-for-management/write-offs-index
+```
+
+#### Параметры
+
+**GET Query Parameters:**
+
+| Параметр | Тип | Обязательный | Описание | Пример |
+|----------|-----|--------------|----------|---------|
+| date | string | Да | Дата списаний | "2024-01-15" |
+| cluster_id | int | Нет | ID куста для фильтрации | 5 |
+| store_id | int | Нет | ID магазина для фильтрации | 42 |
+
+#### Возврат
+**Тип:** HTML View
+**View:** `views/chart-for-management/write-offs.php`
+
+**Передаваемые данные:**
+```php
+[
+    'dataProvider' => ArrayDataProvider,  // пагинация таблицы
+    'date' => '2024-01-15'
+]
+```
+
+**Структура данных в dataProvider:**
+```php
+[
+    [
+        'store_name' => 'Магазин Центральный',
+        'sum' => 150.50,
+        'date' => '2024-01-15 14:30:00',
+        'number' => 'WO-2024-001',
+        'comment' => 'Брак товара при приемке'
+    ],
+    [
+        'store_name' => 'Магазин Северный',
+        'sum' => 220.00,
+        'date' => '2024-01-15 16:45:00',
+        'number' => 'WO-2024-002',
+        'comment' => 'Истек срок годности'
+    ]
+]
+```
+
+#### Логика
+1. Загружает все списания типа "Брак" за указанную дату
+2. JOIN с `export_import_table` для связи store_id из 1С с ERP
+3. JOIN с `city_store` для названия магазина
+4. JOIN с `store_dynamic` для определения куста на дату списания
+5. Фильтрует по cluster_id и store_id (если указаны)
+6. Сортирует по кусту → магазину → дате
+
+#### Пример использования
+
+##### Пример 1: Все списания за дату
+
+**Request:**
+```
+GET /chart-for-management/write-offs-index?date=2024-01-15
+Cookie: PHPSESSID=...
+```
+
+**Response:**
+```html
+<div class="write-offs-table">
+    <h1>Списания за 2024-01-15</h1>
+    <table class="table">
+        <thead>
+            <tr>
+                <th>Магазин</th>
+                <th>Сумма</th>
+                <th>Время</th>
+                <th>Номер</th>
+                <th>Комментарий</th>
+            </tr>
+        </thead>
+        <tbody>
+            <tr>
+                <td>Магазин Центральный</td>
+                <td>150.50 руб</td>
+                <td>14:30:00</td>
+                <td>WO-2024-001</td>
+                <td>Брак товара при приемке</td>
+            </tr>
+            ...
+        </tbody>
+    </table>
+</div>
+```
+
+##### Пример 2: Списания по конкретному магазину
+
+**Request:**
+```
+GET /chart-for-management/write-offs-index?date=2024-01-15&store_id=42
+```
+
+**Response:**
+```html
+<!-- Таблица списаний только магазина с ID 42 -->
+```
+
+##### Пример 3: Списания по кусту
+
+**Request:**
+```
+GET /chart-for-management/write-offs-index?date=2024-01-15&cluster_id=5
+```
+
+**Response:**
+```html
+<!-- Таблица списаний всех магазинов куста 5 -->
+```
+
+---
+
+## Матрица доступов к Actions
+
+| Action | Топ-менеджмент (1,81,71...) | Кластер-менеджер (7) | Админ дневной (30,40) | Админ ночной (35,72) | Админ магазина (50) |
+|--------|-------------------------------|----------------------|-----------------------|----------------------|---------------------|
+| **actionIndex** | ✅ Полный доступ | ✅ Куст/Магазин | ✅ Магазин, День | ✅ Магазин, Ночь | ✅ Полный доступ к магазину |
+| **actionWriteOffPosition** | ✅ Полный + планы | ✅ Куст/Магазин + планы | ✅ Магазин, День | ✅ Магазин, Ночь | ✅ Магазин + планы |
+| **actionGetControlDataAjax** | ✅ | ✅ | ✅ | ✅ | ✅ |
+| **actionGetDataAjax** | ✅ | ✅ | ✅ (без планов) | ✅ (без планов) | ✅ |
+| **actionWriteOffsIndex** | ✅ | ✅ | ✅ | ✅ | ✅ |
+
+---
+
+## Типы графиков и их параметры
+
+| Attribute | Название | Уровни | Смены | Парность данных | Возврат |
+|-----------|----------|--------|-------|-----------------|---------|
+| sales | Продажи | 1,2,3 | 1,2,3,4 | Нет | Временной ряд |
+| plan_completed_this_day | Выполнение плана за день | 1,2,3 | 1,2,3 | Нет | Одно число (%) |
+| plan_completed_this_month | Прогноз выполнения за месяц | 1,2,3 | 1,2,3 | Нет | Одно число (%) |
+| avg_sales_value | Средний чек | 1,2,3 | 1,2,3,4 | Да (продажи/кол-во) | Временной ряд |
+| fot | ФОТ | 1,2,3 | 1,2,3 | Да (зарплата/продажи) | Временной ряд |
+| sales_sum_on_admin | Продажи на админа | 1,2,3 | 1,2,3,4 | Да (продажи/админы) | Временной ряд |
+| count_sales_in_hour | Продажи по часам | 1,2,3 | 1,2,3,4 | Нет | Почасовой ряд |
+| user_bonus | Бонусы персонала | 1,2,3 | 1,2,3,4 | Да (группы по 4) | Временной ряд |
+| matrix_sales_sum | Продажи по матрице | 1,2,3 | 1,2,3,4 | Да (группы по 3) | Временной ряд |
+| write_offs | Списания | 1,2,3 | 4 (принудительно) | Да (продажи/списания) | Временной ряд |
+| write_offs_position | Списания по позициям | 1,2,3 | 1,2,3 | Нет | Таблица + график |
+
+---
+
+## Коды ошибок и исключения
+
+| Код | Тип | Сообщение | Причина | Решение |
+|-----|-----|-----------|---------|---------|
+| 403 | Exception | Нет доступа | group_id пользователя не входит в разрешенные | Проверить роль пользователя |
+| -1 | JSON | -1 | Нет данных для write_offs_position | Проверить наличие данных за период |
+| 500 | Server Error | Query failed | Ошибка SQL-запроса | Проверить логи, структуру БД |
+
+---
+
+## JavaScript интеграция
+
+### Пример полной интеграции
+
+```javascript
+// Инициализация дашборда
+$(document).ready(function() {
+    // 1. Загрузка контрольных данных
+    loadControlData();
+
+    // 2. Инициализация графиков
+    initCharts();
+});
+
+// Загрузка магазинов и кустов
+function loadControlData() {
+    $.ajax({
+        url: '/chart-for-management/get-control-data-ajax',
+        method: 'POST',
+        data: {
+            date_start: $('#date_start').val(),
+            date_end: $('#date_end').val()
+        },
+        success: function(response) {
+            let data = JSON.parse(response);
+
+            // Инициализация Select2 для кустов
+            $('#cluster_select').select2({
+                data: data.clusters
+            });
+
+            // Инициализация Select2 для магазинов
+            $('#store_select').select2({
+                data: data.stores_in_cluster
+            });
+
+            // Предупреждение о смене кустов
+            if (Object.keys(data.stores_step).length > 0) {
+                showWarning('Некоторые магазины меняли куст в выбранном периоде', data.stores_step);
+            }
+        }
+    });
+}
+
+// Загрузка данных графика
+function loadChartData(attribute) {
+    $.ajax({
+        url: '/chart-for-management/get-data-ajax',
+        method: 'POST',
+        data: {
+            mode: $('#mode_select').val(),
+            attribute: attribute,
+            date_start: $('#date_start').val(),
+            date_end: $('#date_end').val(),
+            cluster: $('#cluster_select').val(),
+            store: $('#store_select').val(),
+            shift: $('#shift_select').val()
+        },
+        success: function(response) {
+            let data = JSON.parse(response);
+
+            // Проверка на ошибку
+            if (data === -1) {
+                showError('Нет данных за выбранный период');
+                return;
+            }
+
+            // Отрисовка графика
+            renderChart(attribute, data);
+        }
+    });
+}
+
+// Отрисовка графика с помощью ApexCharts
+function renderChart(attribute, data) {
+    let series = [];
+
+    // Преобразование data_answer в series для ApexCharts
+    for (let key in data.data_answer.attribute) {
+        series.push({
+            name: data.data_answer.attribute[key].name,
+            type: data.data_answer.attribute[key].type || 'line',
+            data: data.data_answer.attribute[key].data
+        });
+    }
+
+    let options = {
+        series: series,
+        chart: {
+            height: 350,
+            type: 'line',
+            stacked: false
+        },
+        xaxis: data.chart_opts.xaxis,
+        title: data.chart_opts.title,
+        stroke: {
+            width: [2, 2, 2],
+            curve: 'smooth'
+        }
+    };
+
+    let chart = new ApexCharts(document.querySelector('#chart-' + attribute), options);
+    chart.render();
+}
+
+// Обработчик клика на точку графика списаний
+function handleWriteOffClick(date, cluster_id, store_id) {
+    window.location.href = '/chart-for-management/write-offs-index?date=' + date +
+                           (cluster_id ? '&cluster_id=' + cluster_id : '') +
+                           (store_id ? '&store_id=' + store_id : '');
+}
+```
+
+---
+
+## Рекомендации по использованию
+
+### Для фронтенд-разработчиков
+
+1. **Используйте debounce** для AJAX-запросов при изменении фильтров
+2. **Кешируйте** результаты запросов для одинаковых параметров
+3. **Показывайте loader** во время загрузки данных
+4. **Обрабатывайте ошибки** (сеть, сервер, нет данных)
+5. **Используйте библиотеки** для графиков: ApexCharts, Chart.js, Highcharts
+
+### Для бэкенд-разработчиков
+
+1. **Добавьте валидацию** всех POST параметров
+2. **Используйте FormModel** для валидации данных
+3. **Добавьте rate limiting** для защиты от DDoS
+4. **Логируйте** долгие запросы (> 1 секунда)
+5. **Используйте кеш** (Redis) для популярных запросов
+
+### Для тестировщиков
+
+1. **Тестируйте** каждую роль пользователя отдельно
+2. **Проверяйте** граничные условия (пустые данные, некорректные даты)
+3. **Тестируйте** производительность на больших датасетах
+4. **Проверяйте** корректность математических расчетов (средний чек, %)
+5. **Тестируйте** смену кустов магазинов в выбранном периоде
diff --git a/erp24/docs/controllers/non-standard/ChartForManagementController_ANALYSIS.md b/erp24/docs/controllers/non-standard/ChartForManagementController_ANALYSIS.md
new file mode 100644 (file)
index 0000000..f39b564
--- /dev/null
@@ -0,0 +1,741 @@
+# ChartForManagementController - Аналитический анализ
+
+## 1. Назначение и бизнес-цель
+
+**ChartForManagementController** - специализированный контроллер для формирования графиков и аналитики в реальном времени для руководящего состава компании (топ-менеджмент, кластер-менеджеры, администраторы магазинов).
+
+### Основные бизнес-задачи:
+- Визуализация ключевых показателей эффективности (KPI) по продажам
+- Мониторинг выполнения планов по уровням (розница/куст/магазин)
+- Анализ списаний и брака
+- Контроль бонусов персонала
+- Отслеживание среднего чека и FOT (фонд оплаты труда)
+- Аналитика продаж по часам и сменам
+
+### Целевая аудитория:
+- Топ-менеджмент (group_id: 1, 81, 71, 51, 10, 9, 74, 14)
+- Кластер-менеджеры (group_id: 7)
+- Администраторы дневных смен (group_id: 30, 40)
+- Администраторы ночных смен (group_id: 35, 72)
+- Администраторы магазинов (group_id: 50)
+
+---
+
+## 2. Метаданные контроллера
+
+| Параметр | Значение |
+|----------|----------|
+| **Namespace** | `yii_app\controllers` |
+| **Extends** | `yii\web\Controller` |
+| **Файл** | `/erp24/controllers/ChartForManagementController.php` |
+| **Строк кода** | 622 |
+| **Приоритет** | 1 (высокий) |
+| **RBAC** | Да (через проверку group_id пользователя) |
+
+---
+
+## 3. Действия (Actions)
+
+### 3.1 actionIndex()
+**Назначение:** Главная страница с дашбордом графиков
+
+**Доступ:**
+- Проверяет group_id текущего администратора
+- Настраивает доступные типы графиков в зависимости от роли
+
+**Логика доступа:**
+```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' => [],                    // Списания
+];
+```
+
+**Режимы просмотра:**
+- `mode_level`: Уровень агрегации (1-Розница, 2-Куст, 3-Магазин)
+- `mode_shift`: Смена (1-День, 2-Ночь, 3-День+Ночь, 4-Сутки)
+
+**Возврат:** Рендер view `index` с массивом доступных графиков
+
+---
+
+### 3.2 actionWriteOffPosition()
+**Назначение:** Страница анализа списаний по позициям
+
+**Доступ:**
+- Топ-менеджмент: полный доступ
+- Кластер-менеджеры: уровень куст/магазин
+- Администраторы смен: только свой магазин и своя смена
+- Администраторы магазинов (group_id: 50): полный доступ к магазину
+
+**Особенности:**
+- `access_plan`: флаг доступа к плановым показателям (только для руководителей)
+
+**Возврат:** Рендер view `write-offs-position-chart` с настройками доступа
+
+---
+
+### 3.3 actionGetControlDataAjax()
+**Назначение:** AJAX получение контрольных данных (магазины, кусты, даты)
+
+**POST параметры:**
+- `date_start`: начальная дата периода
+- `date_end`: конечная дата периода
+
+**Логика:**
+1. Загружает магазины, доступные текущему пользователю из `admin.store_arr`
+2. Определяет кусты через `store_dynamic` с учетом временных диапазонов
+3. Фильтрует магазины, которые меняли куст в указанный период (`steps_stores`)
+4. Формирует иерархию: Кусты → Магазины
+
+**SQL-логика:**
+- LEFT JOIN по `store_dynamic` для определения принадлежности магазина к кусту
+- INNER JOIN с `city_store` (только категория 1 - активные магазины)
+- Сложное условие на даты: проверка пересечения диапазонов
+
+**Возврат (JSON):**
+```json
+{
+  "stores_step": {
+    "Магазин X": {
+      "2024-01-01": "Куст 1",
+      "2024-02-01": "Куст 2"
+    }
+  },
+  "clusters": [
+    {"id": 1, "text": "Куст 1"},
+    {"id": 2, "text": "Куст 2"}
+  ],
+  "stores_in_cluster": {
+    "1": {
+      "text": "Куст 1",
+      "children": [
+        {"id": 10, "text": "Магазин A"},
+        {"id": 11, "text": "Магазин B"}
+      ]
+    }
+  }
+}
+```
+
+---
+
+### 3.4 actionGetDataAjax()
+**Назначение:** AJAX получение данных для построения графиков
+
+**POST параметры:**
+| Параметр | Тип | Описание |
+|----------|-----|----------|
+| `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-День+Ночь, 4-Сутки) |
+
+**Поддерживаемые графики:**
+
+#### sales (Продажи)
+- Отображает продажи и план по дням
+- Данные: `value` (продажи), `plan` (план)
+
+#### plan_completed_this_day (Выполнение плана за сегодня)
+- Рассчитывает % выполнения плана с начала месяца до сегодня
+- Формула: `(sum_sales / sum_plan) * 100`
+
+#### plan_completed_this_month (Прогноз выполнения плана за месяц)
+- Экстраполирует текущие продажи на весь месяц
+- Формула: `(sum_sales / days_passed * days_in_month / plan) * 100`
+
+#### avg_sales_value (Средний чек)
+- Средний чек = Продажи / Количество продаж
+- Данные идут парами: [продажи, количество]
+- Обработка: каждый нечетный элемент пропускается
+
+#### fot (ФОТ - Фонд оплаты труда)
+- FOT = (Зарплата / Продажи) * 100
+- Данные парами: [зарплата, продажи]
+
+#### sales_sum_on_admin (Продажи на администратора)
+- Средние продажи на одного администратора
+- Формула: продажи / количество админов
+
+#### count_sales_in_hour (Продажи по часам)
+- Распределение продаж по часам дня
+- Режимы:
+  - День: 8-19 часов
+  - Ночь: 20-7 часов
+  - День+Ночь: 8-7 часов
+  - Сутки: 0-23 часа
+- Показывает: общее количество и среднее за день
+
+#### user_bonus (Бонусы персонала)
+- Данные группами по 4 значения (типы бонусов)
+- Визуализация: stacked bar chart
+
+#### matrix_sales_sum (Продажи по матрице)
+- Данные группами по 3 значения
+- Категории продаж по продуктовой матрице
+
+#### write_offs (Списания)
+- Сумма списаний и % от продаж
+- Данные парами: [продажи, списания]
+- Накопительный итог с начала месяца
+- Формула %: `(списания / продажи) * 100`
+
+**Возврат (JSON):**
+```json
+{
+  "chart_opts": {
+    "xaxis": {
+      "type": "text",
+      "categories": ["2024-01-01 Пн", "2024-01-02 Вт"]
+    },
+    "title": {
+      "text": "Магазин: Центральный"
+    }
+  },
+  "data_answer": {
+    "attribute": {
+      "sales_sum": {
+        "data": [1000, 1200, 1500],
+        "name": "Продажи",
+        "type": "line"
+      },
+      "plan": {
+        "data": [1100, 1100, 1100],
+        "name": "План",
+        "type": "line"
+      }
+    }
+  }
+}
+```
+
+---
+
+### 3.5 actionWriteOffsIndex()
+**Назначение:** Детальная таблица списаний за конкретную дату
+
+**GET параметры:**
+- `date` (обязательный): дата в формате Y-m-d
+- `cluster_id` (опциональный): фильтр по кусту
+- `store_id` (опциональный): фильтр по магазину
+
+**Логика:**
+1. Загружает все списания типа "Брак" за указанную дату
+2. Джойнит с `export_import_table` для связи 1С ↔ ERP
+3. Джойнит с `city_store` для названия магазина
+4. Джойнит с `store_dynamic` для определения куста на дату списания
+5. Сортирует по кусту → магазину → дате
+
+**Возврат:** Рендер view `write-offs` с ArrayDataProvider
+
+---
+
+## 4. Используемые модели
+
+### 4.1 Admin (yii_app\records\Admin)
+**Использование:**
+- Проверка group_id для RBAC
+- Получение списка доступных магазинов (`store_arr`)
+- Констаны групп: `CLUSTER_MANAGER_GROUP_ID`, `ADMINISTRATOR_GROUP_ID`
+
+### 4.2 ChartDataSearch (yii_app\records\ChartDataSearch)
+**Использование:**
+- Основная модель для получения данных графиков
+- Свойства:
+  - `mode_level`: уровень агрегации (1/2/3)
+  - `attribute_name`: тип графика
+  - `date_start`, `date_end`: диапазон дат
+  - `cluster_id`, `store_id`: фильтры
+  - `mode_shift`: смена (1/2/3/4)
+  - `select_cluster`: флаг выбора куста
+  - `attributes_config`: конфигурация всех типов графиков
+  - `day_of_week`: массив дней недели
+
+**Методы:**
+- `search()`: основной метод получения данных
+- `searchWriteOffsItems()`: получение данных по списаниям позиций
+- `sortCountSalesInHour()`: сортировка данных продаж по часам
+
+### 4.3 WriteOffs (yii_app\records\WriteOffs)
+**Использование:**
+- Получение данных о списаниях
+- Поля: `summ`, `date`, `number`, `comment`, `store_id`, `type`
+
+---
+
+## 5. Используемые сервисы
+
+Контроллер **не использует** отдельные сервисные классы. Вся бизнес-логика инкапсулирована в:
+- Самом контроллере (обработка данных для разных типов графиков)
+- Модели `ChartDataSearch` (SQL-запросы и агрегация данных)
+
+---
+
+## 6. API интеграции
+
+Контроллер **не является** REST API, но предоставляет **AJAX endpoints**:
+
+### 6.1 POST /chart-for-management/get-control-data-ajax
+**Описание:** Получение контрольных данных (магазины, кусты)
+
+**Запрос:**
+```json
+{
+  "date_start": "2024-01-01",
+  "date_end": "2024-01-31"
+}
+```
+
+**Ответ:** см. раздел 3.3
+
+### 6.2 POST /chart-for-management/get-data-ajax
+**Описание:** Получение данных для графика
+
+**Запрос:**
+```json
+{
+  "mode": 3,
+  "attribute": "sales",
+  "date_start": "2024-01-01",
+  "date_end": "2024-01-15",
+  "cluster": 5,
+  "store": 42,
+  "shift": 3
+}
+```
+
+**Ответ:** см. раздел 3.4
+
+---
+
+## 7. Бизнес-логика формирования графиков
+
+### 7.1 Система доступов (RBAC through group_id)
+
+**Матрица доступов:**
+
+| Group ID | Роль | Уровни | Смены | Графики планов |
+|----------|------|--------|-------|----------------|
+| 1, 81, 71, 51, 10, 9, 74, 14 | Топ-менеджмент | Розница, Куст, Магазин | Все (включая Сутки для некоторых) | Да |
+| 7 | Кластер-менеджер | Куст, Магазин | День, Ночь, День+Ночь | Да |
+| 30, 40 | Администратор дневной смены | Магазин | День | Нет |
+| 35, 72 | Администратор ночной смены | Магазин | Ночь | Нет |
+| 50 | Администратор магазина | Магазин | День, Ночь, День+Ночь | Да |
+
+**Особые ограничения:**
+- Администраторы смен (30, 40, 35, 72) **НЕ видят** графики `plan_completed_this_day` и `plan_completed_this_month`
+- Топ-менеджмент видит режим "Сутки" только для графиков: `avg_sales_value`, `sales`, `user_bonus`, `matrix_sales_sum`, `count_sales_in_hour`
+
+### 7.2 Обработка временных диапазонов
+
+**Специальная обработка дат:**
+
+1. **plan_completed_this_day:**
+   - Переопределяет `date_start` на начало текущего месяца
+   - `date_end` = сегодня
+
+2. **plan_completed_this_month:**
+   - `date_start` = первый день текущего месяца
+   - `date_end` = последний день текущего месяца
+
+3. **write_offs:**
+   - `date_start` выравнивается на первое число месяца
+   - `mode_shift` принудительно = 4 (сутки)
+
+### 7.3 Алгоритм обработки парных данных
+
+Многие графики используют **парную обработку данных**:
+
+```php
+// Пример: avg_sales_value
+if ($step % 2 != 0) {
+    $step++;
+    continue; // пропускаем нечетные индексы
+}
+
+// Четный индекс: продажи
+// Нечетный индекс: количество продаж
+$avg_check = $data[$index]['value'] / ($data[$index + 1]['value'] != 0 ? $data[$index + 1]['value'] : 1);
+```
+
+**Графики с парной обработкой:**
+- `avg_sales_value`: [продажи, количество] → средний чек
+- `fot`: [зарплата, продажи] → % ФОТ
+- `sales_sum_on_admin`: [продажи, количество админов] → продажи на админа
+- `write_offs`: [продажи, списания] → сумма и % списаний
+
+### 7.4 Алгоритм обработки групповых данных
+
+**user_bonus (группы по 4):**
+```php
+if ($step % 4 == 0) {
+    // Создаем 3 точки на графике для одной даты
+    // Точка 1: бонус типа 1
+    // Точка 2: бонус типа 2
+    // Точка 3: бонусы типов 3 и 4 (stacked)
+}
+```
+
+**matrix_sales_sum (группы по 3):**
+```php
+if ($step % 3 == 0) {
+    // Продажи по 3 категориям матрицы
+    $categories = [$data[$index], $data[$index+1], $data[$index+2]];
+}
+```
+
+### 7.5 Накопительные расчеты (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']; // списания
+
+// Текущий % списаний от продаж за месяц
+$percent_month = $temp_row['sum'] / ($temp_row['sales_sum'] / 100);
+```
+
+### 7.6 Формирование осей X (xaxis)
+
+**Для дневных графиков:**
+```php
+$dates[] = $datum['date'] . ' ' . $chart_data_search->day_of_week[date('w', strtotime($datum['date']))];
+// Результат: "2024-01-15 Пн"
+```
+
+**Для почасовых графиков (count_sales_in_hour):**
+- День (shift=1): `[8, 9, 10, ..., 19]`
+- Ночь (shift=2): `[20, 21, 22, 23, 0, 1, 2, ..., 7]`
+- День+Ночь (shift=3): `[8, 9, ..., 23, 0, 1, ..., 7]`
+- Сутки (shift=4): `[0, 1, 2, ..., 23]`
+
+### 7.7 Формирование заголовков графиков
+
+```php
+if ($chart_data_search->mode_level === 2) {
+    $chart_opts['title'] = ['text' => 'Куст ' . $chart_data_search->cluster_id];
+} else if ($chart_data_search->mode_level === 3) {
+    $chart_opts['title'] = ['text' => 'Магазин: ' . $data[0]['store_name']];
+} else {
+    $chart_opts['title'] = ['text' => 'Розница'];
+}
+```
+
+---
+
+## 8. Зависимости и импорты
+
+```php
+use yii\data\ArrayDataProvider;    // для пагинации таблиц
+use yii\db\Exception;               // исключения БД
+use yii\db\Expression;              // SQL выражения
+use yii\helpers\ArrayHelper;        // работа с массивами
+use yii\helpers\Json;               // JSON encode/decode
+use yii\web\Controller;             // базовый контроллер
+use yii_app\records\Admin;          // модель администратора
+use yii_app\records\ChartDataSearch; // модель поиска данных графиков
+use yii_app\records\WriteOffs;      // модель списаний
+```
+
+---
+
+## 9. Связь с таблицами БД
+
+### Прямые запросы:
+- **admin**: доступ пользователя, группы, магазины
+- **store_dynamic**: принадлежность магазинов к кустам во времени
+- **city_store**: данные магазинов
+- **write_offs**: списания
+- **export_import_table**: связь сущностей ERP ↔ 1С
+
+### Через ChartDataSearch (косвенно):
+- **statistics**: основная таблица метрик (продажи, планы, FOT и т.д.)
+- **various**: дополнительные метрики
+
+---
+
+## 10. Потенциальные проблемы и риски
+
+### 10.1 Производительность
+- Сложные JOIN-запросы с временными диапазонами
+- Обработка больших объемов данных в PHP (парная/групповая логика)
+- Отсутствие кеширования результатов
+
+**Рекомендация:** Использовать материализованные представления или кеш для популярных запросов
+
+### 10.2 Безопасность
+- Проверка доступа через `group_id` вместо полноценного RBAC
+- Отсутствие валидации входных данных (POST параметров)
+- SQL Injection защищен через Query Builder, но Expression может быть уязвим
+
+**Рекомендация:** Внедрить RBAC, добавить валидацию всех входных параметров
+
+### 10.3 Поддерживаемость
+- Вся логика обработки разных типов графиков в одном методе (actionGetDataAjax)
+- Хардкод group_id (магические числа)
+- Сложная вложенная логика обработки данных
+
+**Рекомендация:** Разбить на отдельные методы по типам графиков, использовать Strategy паттерн
+
+### 10.4 Масштабируемость
+- Жесткая привязка к структуре БД
+- Хардкод типов графиков и их конфигурации
+- Сложность добавления новых типов графиков
+
+**Рекомендация:** Вынести конфигурацию графиков в отдельные классы/файлы
+
+---
+
+## 11. Примеры использования
+
+### Сценарий 1: Топ-менеджер просматривает продажи по рознице
+```
+1. Пользователь (group_id=1) заходит на /chart-for-management/index
+2. Видит дашборд со всеми доступными графиками
+3. Выбирает график "Продажи"
+4. Настраивает:
+   - Уровень: Розница (mode=1)
+   - Период: последние 14 дней
+   - Смена: День+Ночь (shift=3)
+5. JavaScript отправляет POST /chart-for-management/get-data-ajax
+6. Получает JSON с данными продаж и плана
+7. Отрисовывает график с помощью ApexCharts/Chart.js
+```
+
+### Сценарий 2: Администратор магазина анализирует списания
+```
+1. Пользователь (group_id=50) заходит на /chart-for-management/write-off-position
+2. Выбирает свой магазин (фильтр автоматический)
+3. Настраивает период: текущий месяц
+4. Видит график списаний с % от продаж
+5. Кликает на точку графика с высоким %
+6. Переходит на /chart-for-management/write-offs-index?date=2024-01-15&store_id=42
+7. Видит детальную таблицу всех списаний за день
+```
+
+### Сценарий 3: Кластер-менеджер сравнивает магазины
+```
+1. Пользователь (group_id=7) заходит на дашборд
+2. Выбирает график "Средний чек"
+3. Переключается между магазинами своего куста
+4. Сравнивает показатели
+5. Выбирает график "Продажи на администратора"
+6. Анализирует эффективность персонала
+```
+
+---
+
+## 12. Рекомендации по улучшению
+
+### 12.1 Архитектурные
+1. Вынести логику формирования графиков в отдельный сервис `ChartBuilderService`
+2. Создать Strategy классы для каждого типа графика: `SalesChartStrategy`, `FOTChartStrategy` и т.д.
+3. Использовать полноценный RBAC вместо проверки group_id
+4. Добавить слой кеширования (Redis) для популярных запросов
+
+### 12.2 Производительность
+1. Создать материализованные представления для агрегированных данных
+2. Добавить индексы на: `store_dynamic.date_from`, `store_dynamic.date_to`, `write_offs.date`
+3. Оптимизировать SQL-запросы (убрать избыточные JOIN)
+4. Использовать очереди для тяжелых расчетов
+
+### 12.3 Безопасность
+1. Добавить валидацию всех POST параметров через модели форм
+2. Использовать RBAC permissions вместо жестких проверок group_id
+3. Добавить rate limiting для AJAX endpoints
+4. Логировать все обращения к аналитике
+
+### 12.4 Код
+1. Разбить `actionGetDataAjax()` на отдельные методы по типам графиков
+2. Вынести константы group_id в класс или конфигурацию
+3. Использовать DTO (Data Transfer Objects) для передачи данных
+4. Добавить PHPDoc для всех методов
+
+---
+
+## 13. Диаграммы
+
+### 13.1 Архитектура контроллера
+
+```mermaid
+graph TB
+    User[Пользователь] --> Index[actionIndex]
+    User --> WriteOff[actionWriteOffPosition]
+
+    Index --> CheckAccess[Проверка group_id]
+    WriteOff --> CheckAccess
+
+    CheckAccess --> RenderDash[Рендер Dashboard]
+
+    RenderDash --> AJAX1[AJAX: getControlDataAjax]
+    RenderDash --> AJAX2[AJAX: getDataAjax]
+
+    AJAX1 --> LoadStores[Загрузка магазинов/кустов]
+    LoadStores --> ReturnJSON1[JSON: Иерархия магазинов]
+
+    AJAX2 --> ChartSearch[ChartDataSearch]
+    ChartSearch --> ProcessData[Обработка данных по типу графика]
+    ProcessData --> ReturnJSON2[JSON: Данные графика]
+
+    RenderDash --> DetailLink[Клик на график]
+    DetailLink --> WriteOffTable[actionWriteOffsIndex]
+    WriteOffTable --> TableView[Таблица списаний]
+```
+
+### 13.2 Процесс получения данных графика
+
+```mermaid
+sequenceDiagram
+    participant U as User Browser
+    participant C as Controller
+    participant M as ChartDataSearch
+    participant DB as Database
+
+    U->>C: POST /get-data-ajax
+    Note over U,C: {attribute: "sales", mode: 3, date_start, date_end, store: 42, shift: 3}
+
+    C->>C: Validate POST data
+    C->>M: new ChartDataSearch()
+    C->>M: Set properties from POST
+
+    alt attribute === "write_offs_position"
+        M->>DB: searchWriteOffsItems()
+    else other attributes
+        M->>DB: search()
+    end
+
+    DB-->>M: Raw data array
+
+    M-->>C: Return data
+
+    C->>C: Process data by attribute type
+    Note over C: Парная/групповая обработка<br/>Расчет агрегатов<br/>Формирование осей
+
+    C->>C: Build chart_opts (xaxis, title)
+    C->>C: Build data_answer (series)
+
+    C-->>U: JSON {chart_opts, data_answer}
+
+    U->>U: Render chart with ApexCharts
+```
+
+### 13.3 Матрица доступов
+
+```mermaid
+graph LR
+    A[Все пользователи] --> B{group_id?}
+
+    B -->|1,81,71,51,10,9,74,14| C[Топ-менеджмент]
+    B -->|7| D[Кластер-менеджер]
+    B -->|30,40| E[Админ дневной смены]
+    B -->|35,72| F[Админ ночной смены]
+    B -->|50| G[Админ магазина]
+    B -->|другое| H[Exception: Нет доступа]
+
+    C --> C1[Уровни: 1,2,3]
+    C --> C2[Смены: 1,2,3,4*]
+    C --> C3[Все графики]
+
+    D --> D1[Уровни: 2,3]
+    D --> D2[Смены: 1,2,3]
+    D --> D3[Все графики]
+
+    E --> E1[Уровни: 3]
+    E --> E2[Смены: 1]
+    E --> E3[Без планов]
+
+    F --> F1[Уровни: 3]
+    F --> F2[Смены: 2]
+    F --> F3[Без планов]
+
+    G --> G1[Уровни: 3]
+    G --> G2[Смены: 1,2,3]
+    G --> G3[Все графики]
+```
+
+### 13.4 Типы графиков и их обработка
+
+```mermaid
+flowchart TD
+    Start[Получены данные из БД] --> CheckType{Тип графика?}
+
+    CheckType -->|sales| Simple[Простая обработка]
+    CheckType -->|plan_completed_this_day| Aggregate[Агрегация за период]
+    CheckType -->|plan_completed_this_month| Aggregate
+    CheckType -->|count_sales_in_hour| Hourly[Почасовая обработка]
+
+    CheckType -->|avg_sales_value| Paired[Парная обработка]
+    CheckType -->|fot| Paired
+    CheckType -->|sales_sum_on_admin| Paired
+    CheckType -->|write_offs| PairedCum[Парная + накопительная]
+
+    CheckType -->|user_bonus| Group4[Группы по 4]
+    CheckType -->|matrix_sales_sum| Group3[Группы по 3]
+
+    Simple --> BuildAxis[Формирование осей X/Y]
+    Aggregate --> Calc[Расчет итогов]
+    Calc --> Return[Возврат одного значения]
+
+    Hourly --> Sort[Сортировка по часам]
+    Sort --> BuildAxis
+
+    Paired --> Loop[Цикл с step % 2]
+    Loop --> Divide[Деление value[i] / value[i+1]]
+    Divide --> BuildAxis
+
+    PairedCum --> LoopCum[Цикл с накоплением]
+    LoopCum --> CheckMonth{1-е число?}
+    CheckMonth -->|Да| Reset[Сброс накопителей]
+    CheckMonth -->|Нет| Accumulate[Накопление]
+    Reset --> Accumulate
+    Accumulate --> BuildAxis
+
+    Group4 --> Loop4[Цикл step % 4]
+    Loop4 --> Triple[3 точки на дату]
+    Triple --> BuildAxis
+
+    Group3 --> Loop3[Цикл step % 3]
+    Loop3 --> Single[1 точка на дату]
+    Single --> BuildAxis
+
+    BuildAxis --> AddTitle[Добавление заголовка]
+    AddTitle --> JSON[JSON ответ]
+```
+
+---
+
+## Заключение
+
+**ChartForManagementController** - это критически важный компонент системы аналитики ERP24, обеспечивающий руководство компании инструментами для принятия управленческих решений на основе данных в реальном времени.
+
+**Ключевые особенности:**
+- Гибкая система доступов на основе ролей
+- 10+ типов аналитических графиков
+- Многоуровневая агрегация (розница/куст/магазин)
+- Поддержка временных срезов (смены, дни, месяцы)
+- Интеграция с системой планирования
+
+**Требует внимания:**
+- Рефакторинг для улучшения поддерживаемости
+- Оптимизация производительности SQL-запросов
+- Внедрение полноценного RBAC
+- Добавление кеширования
diff --git a/erp24/docs/controllers/non-standard/ClusterLinkEditController_ACTIONS_TABLE.md b/erp24/docs/controllers/non-standard/ClusterLinkEditController_ACTIONS_TABLE.md
new file mode 100644 (file)
index 0000000..66d4a97
--- /dev/null
@@ -0,0 +1,922 @@
+# ClusterLinkEditController - Таблица Actions
+
+## Обзор всех действий
+
+Контроллер содержит 10 actions, обеспечивающих полный цикл управления кластерами и связями магазинов.
+
+## Сводная таблица
+
+| Action | HTTP Method | URL Pattern | Параметры | Возвращает | Назначение |
+|--------|-------------|-------------|-----------|------------|------------|
+| `actionIndex` | GET | `/cluster-link-edit/index` | `date` (опц.) | HTML страница | Список всех кластеров с агрегированными данными |
+| `actionView` | GET | `/cluster-link-edit/view` | `year` (опц.) | HTML страница | Календарное планирование кластеров по годам |
+| `actionViewAll` | GET | `/cluster-link-edit/view-all` | `id`, `manager` (опц.), `date` (опц.) | HTML страница | Детальный просмотр одного кластера |
+| `actionMoveStore` | POST | `/cluster-link-edit/move-store` | `id`, `new_cluster_id` | JSON | Перемещение магазина между кластерами |
+| `actionDeleteStore` | POST | `/cluster-link-edit/delete-store` | `id`, `cluster_id` | Redirect | Удаление магазина из кластера |
+| `actionAddStore` | POST | `/cluster-link-edit/add-store` | `id`, `store_id` | Redirect | Добавление магазина в кластер |
+| `actionClusterStoreUpdate` | GET/POST | `/cluster-link-edit/cluster-store-update` | массив планирования | HTML/Redirect | Массовое обновление календарного планирования |
+| `actionCreate` | GET/POST | `/cluster-link-edit/create` | модель Cluster | HTML/Redirect | Создание нового кластера |
+| `actionUpdate` | GET/POST | `/cluster-link-edit/update` | `id`, модель Cluster | HTML/Redirect | Редактирование кластера |
+| `actionDelete` | POST | `/cluster-link-edit/delete` | `id` | Redirect | Удаление кластера |
+
+---
+
+## Детальное описание каждого Action
+
+### 1. actionIndex
+
+**Назначение:** Отображение списка всех кластеров с агрегированной информацией о магазинах и менеджерах.
+
+#### Параметры запроса:
+
+| Параметр | Тип | Обязательный | Описание | Пример |
+|----------|-----|--------------|----------|--------|
+| `date` | string | Нет | Дата для просмотра исторических данных (формат: Y-m-d H:i:s) | `2024-01-15 00:00:00` |
+| `ClusterSearch[name]` | string | Нет | Фильтр по имени кластера | `Центральный` |
+| `ClusterSearch[id]` | integer | Нет | Фильтр по ID кластера | `5` |
+
+#### Возвращаемые данные:
+
+```php
+[
+    'searchModel' => ClusterSearch,      // Модель поиска
+    'dataProvider' => ActiveDataProvider, // Провайдер данных кластеров
+    'storeCounts' => [                   // Количество магазинов в каждом кластере
+        1 => 15,
+        2 => 23,
+        3 => 18,
+    ],
+    'lastUpdates' => [                   // Дата последнего обновления
+        1 => '2024-01-15 10:30:00',
+        2 => '2024-01-16 14:20:00',
+    ],
+    'storeLists' => [                    // Списки ID магазинов
+        1 => '5,12,18,25,34',
+        2 => '8,15,21,27,33',
+    ],
+    'clusterToManager' => [              // Назначенные менеджеры
+        1 => 'Иванов И.И.',
+        2 => 'Петров П.П.',
+    ],
+    'currentDate' => '2024-01-15 00:00:00',
+]
+```
+
+#### SQL запросы:
+
+**Для дат до 2023-05-19:**
+```sql
+SELECT
+    value_int AS cluster_id,
+    string_agg(store_id::text, ',') AS stores,
+    COUNT(store_id) AS store_count,
+    MIN(date_from) AS first_update
+FROM store_dynamic
+WHERE date_from < '2024-09-12 00:00:00'
+  AND date_to > '2024-09-12 00:00:00'
+  AND category = 1
+GROUP BY value_int
+```
+
+**Для текущих дат:**
+```sql
+SELECT
+    value_int AS cluster_id,
+    string_agg(store_id::text, ',') AS stores,
+    COUNT(store_id) AS store_count,
+    MAX(date_from) AS last_update
+FROM store_dynamic
+WHERE active = 1
+  AND category = 1
+  AND date_from <= :currentDate
+  AND date_to > :currentDate
+GROUP BY value_int
+```
+
+#### Пример использования:
+
+```php
+// Просмотр текущего состояния
+GET /cluster-link-edit/index
+
+// Просмотр на конкретную дату
+GET /cluster-link-edit/index?ClusterSearch[date]=2024-01-15
+
+// Поиск по имени
+GET /cluster-link-edit/index?ClusterSearch[name]=Центральный
+```
+
+---
+
+### 2. actionView
+
+**Назначение:** Календарное планирование кластеров с разбивкой по неделям и месяцам.
+
+#### Параметры запроса:
+
+| Параметр | Тип | Обязательный | Описание | Пример |
+|----------|-----|--------------|----------|--------|
+| `year` | integer | Нет | Год для просмотра планирования | `2024` |
+
+#### Возвращаемые данные:
+
+```php
+[
+    'model' => [],                    // Массив записей ClusterCalendar
+    'years' => [                      // Доступные годы
+        2023 => 2023,
+        2024 => 2024,
+        2025 => 2025,
+        2026 => 2026,
+        2027 => 2027,
+        2028 => 2028,
+        2029 => 2029,
+        2030 => 2030,
+    ],
+    'year' => 2024,                   // Текущий год
+    'stores' => [                     // Справочник магазинов
+        1 => 'Магазин на Ленина',
+        2 => 'Магазин на Пушкина',
+    ],
+    'clusters' => [                   // Справочник кластеров
+        1 => 'Центральный',
+        2 => 'Северный',
+    ],
+    'monthNames' => [                 // Названия месяцев
+        1 => 'Январь',
+        2 => 'Февраль',
+    ],
+    'inputDate' => [                  // Матрица [магазин][месяц][неделя] = кластер
+        1 => [
+            1 => [1 => 5, 2 => 5, 3 => 5, 4 => 7],
+            2 => [1 => 7, 2 => 7, 3 => 7, 4 => 7],
+        ],
+    ],
+]
+```
+
+#### Структура данных inputDate:
+
+```php
+$inputDate[$storeId][$month][$week] = $clusterId;
+
+// Пример: Магазин 1 в январе (месяц 1):
+// неделя 1-3: кластер 5
+// неделя 4: кластер 7
+$inputDate[1][1][1] = 5;
+$inputDate[1][1][2] = 5;
+$inputDate[1][1][3] = 5;
+$inputDate[1][1][4] = 7;
+```
+
+#### Алгоритм построения матрицы:
+
+```mermaid
+graph TD
+    A[Получить ClusterCalendar за год] --> B[Получить интервалы недель]
+    B --> C[Для каждой записи календаря]
+    C --> D{Интервал попадает<br/>в неделю?}
+    D -->|Да| E[inputDate стор/месяц/неделя = кластер]
+    D -->|Нет| F[Следующий интервал]
+    E --> F
+    F --> G{Есть еще интервалы?}
+    G -->|Да| C
+    G -->|Нет| H[Вернуть матрицу]
+```
+
+#### Пример использования:
+
+```php
+// Просмотр планирования на текущий год
+GET /cluster-link-edit/view
+
+// Просмотр на 2025 год
+GET /cluster-link-edit/view?year=2025
+```
+
+---
+
+### 3. actionViewAll
+
+**Назначение:** Детальный просмотр одного кластера со списком всех магазинов на указанную дату.
+
+#### Параметры запроса:
+
+| Параметр | Тип | Обязательный | Описание | Пример |
+|----------|-----|--------------|----------|--------|
+| `id` | integer | Да | ID кластера | `5` |
+| `manager` | string | Нет | Имя менеджера кластера | `Иванов И.И.` |
+| `date` | string | Нет | Дата для просмотра (Y-m-d H:i:s) | `2024-01-15 00:00:00` |
+
+#### Возвращаемые данные:
+
+```php
+[
+    'model' => Cluster,               // Модель кластера
+    'dataProvider' => ArrayDataProvider, // Список магазинов
+    'clustersList' => [               // Справочник всех кластеров
+        1 => 'Центральный',
+        2 => 'Северный',
+    ],
+    'currentDate' => '2024-01-15 00:00:00',
+    'clusterManager' => 'Иванов И.И.',
+    'storeNames' => [                 // Названия магазинов
+        1 => 'Магазин на Ленина',
+        2 => 'Магазин на Пушкина',
+    ],
+]
+```
+
+#### Структура данных магазинов:
+
+```php
+[
+    [
+        'id' => 123,                  // ID записи в StoreDynamic
+        'store_id' => 45,             // ID магазина
+        'cluster_id' => 5,            // ID кластера
+        'date_from' => '2024-01-01 00:00:00',
+        'date_to' => '2100-01-01 00:00:00',
+        'active' => 1,
+        'category' => 1,
+    ],
+    // ...
+]
+```
+
+#### Логика выборки магазинов:
+
+```mermaid
+graph TD
+    A{Дата < 2023-05-19?} -->|Да| B[Исторический режим]
+    A -->|Нет| C[Стандартный режим]
+
+    B --> D[date_from < 2024-09-12<br/>AND date_to > 2024-09-12]
+    C --> E[date_from <= requestDate<br/>AND date_to > requestDate<br/>AND active = 1]
+
+    D --> F[Вернуть магазины]
+    E --> F
+```
+
+#### Пример использования:
+
+```php
+// Просмотр кластера 5 на текущую дату
+GET /cluster-link-edit/view-all?id=5
+
+// Просмотр на историческую дату
+GET /cluster-link-edit/view-all?id=5&date=2024-01-15
+
+// С указанием менеджера
+GET /cluster-link-edit/view-all?id=5&manager=Иванов
+```
+
+---
+
+### 4. actionMoveStore
+
+**Назначение:** AJAX-перемещение магазина из одного кластера в другой с сохранением истории.
+
+#### Параметры запроса:
+
+| Параметр | Тип | Обязательный | Описание | Пример |
+|----------|-----|--------------|----------|--------|
+| `id` | integer | Да | ID записи StoreDynamic | `123` |
+| `new_cluster_id` | integer | Да | ID нового кластера | `7` |
+
+#### HTTP метод: POST
+
+#### Возвращает: JSON
+
+**Успешный ответ:**
+```json
+{
+    "success": true
+}
+```
+
+**Ошибка:**
+```json
+{
+    "success": false,
+    "message": "Record not found"
+}
+```
+
+#### Алгоритм работы:
+
+```mermaid
+sequenceDiagram
+    participant Client
+    participant Action
+    participant StoreDynamic
+    participant ClusterManagerService
+
+    Client->>Action: POST {id, new_cluster_id}
+    Action->>StoreDynamic: Найти запись (id, active=1)
+
+    alt Запись найдена
+        StoreDynamic-->>Action: Запись
+        Action->>StoreDynamic: UPDATE: date_to=NOW, active=0
+        Action->>StoreDynamic: INSERT: новая запись
+        Action->>ClusterManagerService: syncClusterManagers()
+        ClusterManagerService-->>Action: OK
+        Action-->>Client: {success: true}
+    else Запись не найдена
+        StoreDynamic-->>Action: null
+        Action-->>Client: {success: false}
+    end
+```
+
+#### Код изменения:
+
+```php
+// 1. Закрываем старую запись
+$dynamicEntry->date_to = date('Y-m-d H:i:s');
+$dynamicEntry->active = 0;
+$dynamicEntry->save(false);
+
+// 2. Создаем новую запись
+$newDynamicEntry = new StoreDynamic();
+$newDynamicEntry->value_type = 'int';
+$newDynamicEntry->value_int = $newClusterId;
+$newDynamicEntry->store_id = $dynamicEntry->store_id;
+$newDynamicEntry->date_from = date('Y-m-d H:i:s');
+$newDynamicEntry->date_to = '2100-01-01 00:00:00';
+$newDynamicEntry->active = 1;
+$newDynamicEntry->category = 1;
+$newDynamicEntry->save(false);
+
+// 3. Синхронизируем менеджеров
+ClusterManagerService::syncClusterManagers();
+```
+
+#### Пример AJAX-запроса:
+
+```javascript
+$.ajax({
+    url: '/cluster-link-edit/move-store',
+    method: 'POST',
+    data: {
+        id: 123,
+        new_cluster_id: 7,
+        _csrf: $('meta[name="csrf-token"]').attr('content')
+    },
+    success: function(response) {
+        if (response.success) {
+            alert('Магазин перемещен');
+            location.reload();
+        } else {
+            alert('Ошибка: ' + response.message);
+        }
+    }
+});
+```
+
+---
+
+### 5. actionDeleteStore
+
+**Назначение:** Удаление (деактивация) магазина из кластера.
+
+#### Параметры запроса:
+
+| Параметр | Тип | Обязательный | Описание | Пример |
+|----------|-----|--------------|----------|--------|
+| `id` | integer | Да | ID записи StoreDynamic | `123` |
+| `cluster_id` | integer | Да | ID кластера (для валидации) | `5` |
+
+#### HTTP метод: POST
+
+#### Возвращает: Redirect на `view-all`
+
+#### Flash-сообщения:
+
+```php
+// Успех
+Yii::$app->session->setFlash('success', 'Магазин успешно удален из куста.');
+
+// Ошибка
+Yii::$app->session->setFlash('error', 'Не удалось удалить магазин.');
+```
+
+#### Алгоритм работы:
+
+```php
+// 1. Найти активную запись
+$dynamicEntry = StoreDynamic::find()
+    ->where(['id' => $id, 'value_int' => $cluster_id])
+    ->andWhere(['<=', 'date_from', $currentDate])
+    ->andWhere(['>', 'date_to', $currentDate])
+    ->one();
+
+// 2. Закрыть запись (не удалять!)
+if ($dynamicEntry) {
+    $dynamicEntry->date_to = date('Y-m-d H:i:s');
+    $dynamicEntry->active = 0;
+    $dynamicEntry->save(false);
+
+    // 3. Синхронизировать менеджеров
+    ClusterManagerService::syncClusterManagers();
+}
+
+// 4. Редирект
+return $this->redirect(['view-all', 'id' => $cluster_id]);
+```
+
+#### Важно:
+
+- **Не физическое удаление!** Запись остается в БД с `active = 0`
+- Сохраняется полная история изменений
+- Магазин может быть добавлен обратно через `actionAddStore`
+
+#### Пример использования:
+
+```php
+// POST запрос
+POST /cluster-link-edit/delete-store?id=123&cluster_id=5
+```
+
+---
+
+### 6. actionAddStore
+
+**Назначение:** Добавление магазина в кластер.
+
+#### Параметры запроса:
+
+| Параметр | Тип | Обязательный | Описание | Пример |
+|----------|-----|--------------|----------|--------|
+| `id` | integer | Да | ID кластера (в URL) | `5` |
+| `store_id` | integer | Да | ID магазина (в POST) | `42` |
+
+#### HTTP метод: POST
+
+#### Возвращает: Redirect на `view-all`
+
+#### Алгоритм работы:
+
+```mermaid
+graph TD
+    A[POST actionAddStore] --> B{Есть активная запись<br/>для магазина?}
+    B -->|Да| C[Закрыть старую запись:<br/>date_to = NOW, active = 0]
+    B -->|Нет| D[Создать новую запись]
+    C --> D
+    D --> E[Установить параметры:<br/>cluster_id, store_id,<br/>date_from = NOW]
+    E --> F[Сохранить запись]
+    F --> G[syncClusterManagers]
+    G --> H[Flash: success]
+    H --> I[Redirect view-all]
+```
+
+#### Код добавления:
+
+```php
+// 1. Закрыть существующую привязку (если есть)
+$existingEntry = StoreDynamic::find()
+    ->where(['store_id' => $storeId])
+    ->andWhere(['<=', 'date_from', $currentDate])
+    ->andWhere(['>', 'date_to', $currentDate])
+    ->andWhere(['active' => 1])
+    ->one();
+
+if ($existingEntry) {
+    $existingEntry->date_to = $currentDate;
+    $existingEntry->active = 0;
+    $existingEntry->save(false);
+}
+
+// 2. Создать новую привязку
+$newDynamicEntry = new StoreDynamic();
+$newDynamicEntry->value_int = $id;              // cluster_id
+$newDynamicEntry->value_type = 'int';
+$newDynamicEntry->store_id = $storeId;
+$newDynamicEntry->date_from = $currentDate;
+$newDynamicEntry->date_to = '2100-01-01 00:00:00';
+$newDynamicEntry->active = 1;
+$newDynamicEntry->category = 1;
+$newDynamicEntry->save(false);
+
+// 3. Синхронизация
+ClusterManagerService::syncClusterManagers();
+```
+
+#### Пример использования:
+
+```html
+<form method="post" action="/cluster-link-edit/add-store?id=5">
+    <input type="hidden" name="_csrf" value="<?= Yii::$app->request->csrfToken ?>">
+    <select name="store_id">
+        <option value="42">Магазин на Ленина</option>
+        <option value="43">Магазин на Пушкина</option>
+    </select>
+    <button type="submit">Добавить</button>
+</form>
+```
+
+---
+
+### 7. actionClusterStoreUpdate
+
+**Назначение:** Массовое обновление календарного планирования кластеров по неделям.
+
+#### HTTP методы: GET (форма), POST (сохранение)
+
+#### Параметры GET:
+
+Нет дополнительных параметров.
+
+#### Параметры POST:
+
+Сложная структура данных в формате:
+
+```php
+[
+    'year' => 2024,
+    'store_1__month_1__weekMonth_1' => 5,  // Магазин 1, январь, неделя 1 → кластер 5
+    'store_1__month_1__weekMonth_2' => 5,
+    'store_1__month_1__weekMonth_3' => 7,  // Переход в кластер 7
+    'store_2__month_1__weekMonth_1' => 3,
+    // ...
+]
+```
+
+#### Алгоритм обработки POST:
+
+```mermaid
+graph TD
+    A[POST данные] --> B[Парсинг ключей<br/>store_X__month_Y__weekMonth_Z]
+    B --> C[Группировка по магазинам]
+    C --> D[Определение интервалов:<br/>объединение последовательных<br/>недель с одним кластером]
+    D --> E[Конвертация недель<br/>в даты date_from/date_to]
+    E --> F[Удаление старых данных<br/>ClusterCalendar]
+    F --> G[Пакетная вставка<br/>новых записей]
+    G --> H[Redirect на view]
+```
+
+#### Пример обработки:
+
+**Входные данные:**
+```php
+POST:
+store_1__month_1__weekMonth_1: "5"
+store_1__month_1__weekMonth_2: "5"
+store_1__month_1__weekMonth_3: "5"
+store_1__month_1__weekMonth_4: "7"
+```
+
+**После парсинга:**
+```php
+$inputData = [
+    'store_1__month_1__weekMonth_1' => [
+        'store' => 1,
+        'month' => 1,
+        'weekMonth' => 1,
+        'year' => 2024,
+        'cluster' => 5
+    ],
+    // ...
+]
+```
+
+**После группировки:**
+```php
+$inputDataByStore = [
+    1 => [  // Магазин 1
+        ['store' => 1, 'month' => 1, 'weekMonth' => 1, 'cluster' => 5],
+        ['store' => 1, 'month' => 1, 'weekMonth' => 2, 'cluster' => 5],
+        ['store' => 1, 'month' => 1, 'weekMonth' => 3, 'cluster' => 5],
+        ['store' => 1, 'month' => 1, 'weekMonth' => 4, 'cluster' => 7],
+    ]
+]
+```
+
+**После определения интервалов:**
+```php
+$intervals = [
+    1 => [  // Магазин 1
+        1 => [  // Интервал 1
+            'year' => 2024,
+            'startMonth' => 1,
+            'startWeekMonth' => 1,
+            'monthFinish' => 1,
+            'weekMonthFinish' => 3,
+            'cluster' => 5
+        ],
+        2 => [  // Интервал 2
+            'year' => 2024,
+            'startMonth' => 1,
+            'startWeekMonth' => 4,
+            'monthFinish' => 1,
+            'weekMonthFinish' => 4,
+            'cluster' => 7
+        ]
+    ]
+]
+```
+
+**Финальные записи для ClusterCalendar:**
+```php
+$clusterSetList = [
+    [
+        'store' => 1,
+        'cluster' => 5,
+        'dateFrom' => '2024-01-01 00:00:00',
+        'dateTo' => '2024-01-21 23:59:59',
+        'year' => 2024,
+    ],
+    [
+        'store' => 1,
+        'cluster' => 7,
+        'dateFrom' => '2024-01-22 00:00:00',
+        'dateTo' => '2024-01-31 23:59:59',
+        'year' => 2024,
+    ],
+]
+```
+
+#### Код сохранения:
+
+```php
+if (!empty($clusterSetList)) {
+    // Удалить все старые записи за год
+    ClusterCalendar::deleteAll(['year' => $yearPost]);
+
+    // Создать новые записи
+    foreach ($clusterSetList as $item) {
+        $ClusterCalendar = new ClusterCalendar();
+        $ClusterCalendar->setClusterId($item['cluster'])
+            ->setDateFrom($item['dateFrom'])
+            ->setDateTo($item['dateTo'])
+            ->setYear($item['year']);
+
+        $ClusterCalendar->value_type = 'int';
+        $ClusterCalendar->value_int = $item['store'];
+        $ClusterCalendar->category_id = 1;
+        $ClusterCalendar->created_admin_id = $adminId;
+        $ClusterCalendar->created_at = date("Y-m-d H:i:s");
+
+        if ($ClusterCalendar->validate()) {
+            $ClusterCalendar->save();
+        }
+    }
+}
+```
+
+#### Важные детали:
+
+- **Полная перезапись** — все данные за год удаляются и создаются заново
+- **Атомарность не гарантирована** — нет транзакции (потенциальная проблема)
+- **Валидация** — выполняется перед сохранением каждой записи
+- **Оптимизация интервалов** — последовательные недели с одним кластером объединяются
+
+---
+
+### 8. actionCreate
+
+**Назначение:** Создание нового кластера.
+
+#### HTTP методы: GET (форма), POST (сохранение)
+
+#### Параметры POST:
+
+```php
+[
+    'Cluster' => [
+        'name' => 'Новый кластер',
+        'active' => 1,
+        // другие поля модели Cluster
+    ]
+]
+```
+
+#### Возвращает:
+
+- **GET:** HTML форма создания
+- **POST успешно:** Redirect на `index`
+- **POST ошибка:** HTML форма с ошибками валидации
+
+#### Код action:
+
+```php
+public function actionCreate()
+{
+    $model = new Cluster();
+
+    if ($model->load(Yii::$app->request->post()) && $model->validate()) {
+        if ($model->save()) {
+            return $this->redirect(['index']);
+        } else {
+            Yii::$app->session->setFlash('error', 'Не удалось сохранить данные.');
+        }
+    }
+
+    return $this->render('/cluster_link_edit/create', [
+        'model' => $model,
+    ]);
+}
+```
+
+#### Пример формы:
+
+```php
+<?php $form = ActiveForm::begin(); ?>
+    <?= $form->field($model, 'name')->textInput(['maxlength' => true]) ?>
+    <?= $form->field($model, 'active')->checkbox() ?>
+
+    <div class="form-group">
+        <?= Html::submitButton('Создать', ['class' => 'btn btn-success']) ?>
+    </div>
+<?php ActiveForm::end(); ?>
+```
+
+---
+
+### 9. actionUpdate
+
+**Назначение:** Редактирование существующего кластера.
+
+#### HTTP методы: GET (форма), POST (сохранение)
+
+#### Параметры запроса:
+
+| Параметр | Тип | Обязательный | Описание | Пример |
+|----------|-----|--------------|----------|--------|
+| `id` | integer | Да | ID кластера | `5` |
+
+#### Параметры POST:
+
+```php
+[
+    'Cluster' => [
+        'name' => 'Обновленное имя',
+        'active' => 1,
+    ]
+]
+```
+
+#### Возвращаемые данные (GET):
+
+```php
+[
+    'model' => Cluster,               // Модель кластера
+    'stores' => [                     // Справочник магазинов
+        1 => 'Магазин на Ленина',
+        2 => 'Магазин на Пушкина',
+    ],
+    'clusterManager' => [             // Список менеджеров кластеров
+        [
+            'id' => 10,
+            'name' => 'Иванов И.И.',
+            'group_id' => 71,
+        ],
+    ],
+]
+```
+
+#### Код action:
+
+```php
+public function actionUpdate($id)
+{
+    $model = $this->findModel($id);
+
+    $stores = CityStore::getNames();
+    $clusterManager = Admin::getAdmins(null, Admin::CLUSTER_MANAGER_GROUP_ID);
+
+    if ($this->request->isPost && $model->load($this->request->post()) && $model->save()) {
+        return $this->redirect(['view', 'id' => $model->id]);
+    }
+
+    return $this->render('/cluster_link_edit/update', [
+        'model' => $model,
+        'stores' => $stores,
+        'clusterManager' => $clusterManager,
+    ]);
+}
+```
+
+#### Пример использования:
+
+```php
+// GET: Открыть форму редактирования
+GET /cluster-link-edit/update?id=5
+
+// POST: Сохранить изменения
+POST /cluster-link-edit/update?id=5
+Данные: Cluster[name]=Новое имя
+```
+
+---
+
+### 10. actionDelete
+
+**Назначение:** Удаление кластера.
+
+#### HTTP метод: POST (только!)
+
+#### Параметры запроса:
+
+| Параметр | Тип | Обязательный | Описание | Пример |
+|----------|-----|--------------|----------|--------|
+| `id` | integer | Да | ID кластера | `5` |
+
+#### Возвращает: Redirect на `index`
+
+#### Код action:
+
+```php
+public function actionDelete($id)
+{
+    $this->findModel($id)->delete();
+    return $this->redirect(['index']);
+}
+```
+
+#### Важно:
+
+- **Физическое удаление** — запись удаляется из таблицы `cluster`
+- **Каскадное удаление** — зависит от настроек БД
+- **Проверка связей** — не выполняется (потенциальная проблема)
+
+#### Рекомендации:
+
+```php
+// Проверить, есть ли магазины в кластере
+$storeCount = StoreDynamic::find()
+    ->where(['value_int' => $id, 'active' => 1])
+    ->count();
+
+if ($storeCount > 0) {
+    Yii::$app->session->setFlash('error',
+        'Невозможно удалить кластер, содержащий магазины.');
+    return $this->redirect(['index']);
+}
+
+// Soft delete вместо физического
+$model->active = 0;
+$model->save(false);
+```
+
+#### Пример использования:
+
+```php
+// POST запрос с подтверждением
+POST /cluster-link-edit/delete?id=5
+```
+
+---
+
+## Вспомогательные методы
+
+### findModel($id)
+
+**Назначение:** Поиск модели кластера по ID с генерацией 404 при отсутствии.
+
+```php
+protected function findModel($id)
+{
+    if (($model = Cluster::findOne(['id' => $id])) !== null) {
+        return $model;
+    }
+
+    throw new NotFoundHttpException('The requested page does not exist.');
+}
+```
+
+**Использование:**
+- `actionViewAll`
+- `actionUpdate`
+- `actionDelete`
+
+---
+
+## Матрица зависимостей actions
+
+| Action | Используемые модели | Сервисы | Helpers |
+|--------|---------------------|---------|---------|
+| `actionIndex` | Cluster, ClusterSearch, StoreDynamic, ClusterAdmin | - | ArrayHelper |
+| `actionView` | ClusterCalendar, CityStore, Cluster, PlanStore | - | DateHelper, ArrayHelper |
+| `actionViewAll` | Cluster, StoreDynamic, CityStore | - | ArrayHelper |
+| `actionMoveStore` | StoreDynamic | ClusterManagerService | - |
+| `actionDeleteStore` | StoreDynamic | ClusterManagerService | - |
+| `actionAddStore` | StoreDynamic | ClusterManagerService | - |
+| `actionClusterStoreUpdate` | ClusterCalendar, CityStore, Cluster, Admin, PlanStore | - | DateHelper, ArrayHelper |
+| `actionCreate` | Cluster | - | - |
+| `actionUpdate` | Cluster, CityStore, Admin | - | ArrayHelper |
+| `actionDelete` | Cluster | - | - |
+
+---
+
+## Связанные документы
+
+- [ClusterLinkEditController_ANALYSIS.md](./ClusterLinkEditController_ANALYSIS.md) — полный анализ контроллера
+- [ClusterLinkEditController_QUICK_REFERENCE.md](./ClusterLinkEditController_QUICK_REFERENCE.md) — краткий справочник
+- [ClusterManagerService](../../services/ClusterManagerService.md) — сервис синхронизации менеджеров
+- [StoreDynamic Model](../../models/StoreDynamic.md) — модель темпоральных данных
+- [ClusterCalendar Model](../../models/ClusterCalendar.md) — модель календарного планирования
+
+---
+
+**Дата создания:** 2025-11-26
+**Версия:** 1.0
diff --git a/erp24/docs/controllers/non-standard/ClusterLinkEditController_ANALYSIS.md b/erp24/docs/controllers/non-standard/ClusterLinkEditController_ANALYSIS.md
new file mode 100644 (file)
index 0000000..3d14c21
--- /dev/null
@@ -0,0 +1,486 @@
+# ClusterLinkEditController - Полный анализ
+
+## Назначение и бизнес-цель
+
+**ClusterLinkEditController** — специализированный контроллер для управления связями между магазинами и кластерами в системе ERP24. Контроллер обеспечивает полный цикл управления кластерами: создание, просмотр, редактирование структуры кластеров и назначение магазинов с поддержкой темпоральных данных (исторические изменения с привязкой ко времени).
+
+### Основные бизнес-задачи:
+- **Управление составом кластеров** — динамическое добавление/удаление магазинов
+- **Темпоральное хранение данных** — история изменений состава кластеров с точной датой
+- **Планирование структуры кластеров** — недельное/месячное планирование на год вперед
+- **Назначение менеджеров** — привязка кластерных менеджеров к кластерам
+- **Миграция магазинов** — перемещение магазинов между кластерами с сохранением истории
+
+## Метаданные контроллера
+
+| Параметр | Значение |
+|----------|----------|
+| **Namespace** | `app\controllers` |
+| **Extends** | `yii\web\Controller` |
+| **Размер файла** | 729 строк |
+| **Приоритет документации** | 1 (высокий) |
+| **Путь к файлу** | `/erp24/controllers/ClusterLinkEditController.php` |
+
+## RBAC и контроль доступа
+
+### Access Control Rules
+
+Контроллер использует расширенный механизм контроля доступа через `matchCallback`:
+
+```php
+'access' => [
+    'class' => AccessControl::class,
+    'rules' => [
+        [
+            'allow' => true,
+            'roles' => ['@'], // Только авторизованные пользователи
+            'matchCallback' => function ($rule, $action) {
+                $user = Yii::$app->user;
+
+                // Доступ по ID пользователя
+                if (in_array($user->id, [1, 3])) {
+                    return true;
+                }
+
+                // Доступ по group_id
+                $groupId = $user->identity->group_id ?? null;
+                if (in_array($groupId, [71, 81, 10])) {
+                    return true;
+                }
+
+                return false;
+            }
+        ],
+    ],
+],
+```
+
+### Права доступа:
+
+| Тип | Значение | Описание |
+|-----|----------|----------|
+| **User ID** | 1, 3 | Суперадминистраторы |
+| **Group ID** | 71 | Кластерные менеджеры (основная группа) |
+| **Group ID** | 81 | Администраторы кластеров (расширенные права) |
+| **Group ID** | 10 | Системные администраторы |
+
+### HTTP Verbs Filter
+
+```php
+'verbs' => [
+    'class' => VerbFilter::class,
+    'actions' => [
+        'delete' => ['POST'], // Удаление только через POST
+    ],
+],
+```
+
+## Полный список actions (10 действий)
+
+| № | Action | HTTP Method | Назначение |
+|---|--------|-------------|------------|
+| 1 | `actionIndex` | GET | Список всех кластеров с агрегированными данными |
+| 2 | `actionView` | GET | Просмотр календарного планирования кластеров (по годам) |
+| 3 | `actionViewAll` | GET | Детальный просмотр одного кластера с магазинами |
+| 4 | `actionMoveStore` | POST | Перемещение магазина между кластерами (AJAX) |
+| 5 | `actionDeleteStore` | POST | Удаление магазина из кластера |
+| 6 | `actionAddStore` | POST | Добавление магазина в кластер |
+| 7 | `actionClusterStoreUpdate` | GET/POST | Массовое обновление календарного планирования |
+| 8 | `actionCreate` | GET/POST | Создание нового кластера |
+| 9 | `actionUpdate` | GET/POST | Редактирование кластера |
+| 10 | `actionDelete` | POST | Удаление кластера |
+
+## Используемые модели
+
+### Основные ActiveRecord модели:
+
+| Модель | Namespace | Назначение |
+|--------|-----------|------------|
+| **Cluster** | `yii_app\records\Cluster` | Справочник кластеров |
+| **ClusterAdmin** | `yii_app\records\ClusterAdmin` | Связь кластеров и менеджеров |
+| **ClusterCalendar** | `yii_app\records\ClusterCalendar` | Календарное планирование кластеров |
+| **CityStore** | `yii_app\records\CityStore` | Справочник магазинов |
+| **StoreDynamic** | `yii_app\records\StoreDynamic` | Темпоральные данные магазинов |
+| **PlanStore** | `yii_app\records\PlanStore` | Плановые данные магазинов |
+| **Admin** | `yii_app\records\Admin` | Справочник администраторов |
+
+### Search модели:
+
+| Модель | Назначение |
+|--------|------------|
+| **ClusterSearch** | Поиск и фильтрация кластеров |
+
+## Используемые сервисы
+
+| Сервис | Namespace | Назначение |
+|--------|-----------|------------|
+| **ClusterManagerService** | `yii_app\services\ClusterManagerService` | Синхронизация менеджеров кластеров |
+| **ExportImportService** | `yii_app\services\ExportImportService` | Экспорт/импорт данных |
+
+### Важные вызовы сервисов:
+
+```php
+// Синхронизация менеджеров после изменения состава кластера
+ClusterManagerService::syncClusterManagers();
+```
+
+Этот вызов происходит после:
+- Перемещения магазина (`actionMoveStore`)
+- Удаления магазина (`actionDeleteStore`)
+- Добавления магазина (`actionAddStore`)
+
+## Используемые helpers
+
+| Helper | Namespace | Использование |
+|--------|-----------|---------------|
+| **DateHelper** | `yii_app\helpers\DateHelper` | Расчет интервалов недель/месяцев |
+| **ArrayHelper** | `yii\helpers\ArrayHelper` | Работа с массивами данных |
+
+### Ключевые методы DateHelper:
+
+```php
+// Получение интервалов недель для года
+DateHelper::getIntervals($year);
+
+// Получение начальной даты недели
+DateHelper::getStartDay($year, $month, $week);
+
+// Получение конечной даты недели
+DateHelper::getFinishDay($year, $month, $week);
+```
+
+## Темпоральная логика данных
+
+### Концепция хранения истории
+
+Контроллер работает с **темпоральной моделью данных** через таблицу `StoreDynamic`:
+
+```
+┌──────────────────────────────────────────────────────────┐
+│ StoreDynamic - История принадлежности магазинов          │
+├──────────────┬──────────┬────────────┬────────────────────┤
+│ store_id     │ cluster  │ date_from  │ date_to            │
+├──────────────┼──────────┼────────────┼────────────────────┤
+│ 1            │ 5        │ 2023-01-01 │ 2023-06-15 (old)   │
+│ 1            │ 7        │ 2023-06-15 │ 2100-01-01 (active)│
+└──────────────┴──────────┴────────────┴────────────────────┘
+```
+
+### Важные временные точки:
+
+| Константа | Значение | Назначение |
+|-----------|----------|------------|
+| `$earliestDateFrom` | `2023-05-19 00:00:00` | Самая ранняя доступная дата |
+| `$specialDate` | `2024-09-12 00:00:00` | Специальная дата миграции/изменений |
+| `$futureDate` | `2100-01-01 00:00:00` | Дата "бесконечности" для активных записей |
+
+### Логика работы с датами:
+
+```php
+// Если запрашиваемая дата < 2023-05-19
+if (strtotime($requestDate) < strtotime($earliestDateFrom)) {
+    // Используем специальную логику для исторических данных
+    // Ищем данные относительно $specialDate
+} else {
+    // Стандартная логика - данные на текущую дату
+    // Фильтр: date_from <= $requestDate AND date_to > $requestDate
+}
+```
+
+## Бизнес-логика управления кластерами
+
+### 1. Перемещение магазина (Move Store)
+
+**Workflow:**
+
+```mermaid
+graph TD
+    A[POST actionMoveStore] --> B{Найти активную запись<br/>StoreDynamic}
+    B -->|Найдена| C[Закрыть текущую запись:<br/>date_to = NOW, active = 0]
+    C --> D[Создать новую запись:<br/>новый cluster_id,<br/>date_from = NOW]
+    D --> E[syncClusterManagers]
+    E --> F[Вернуть JSON success]
+    B -->|Не найдена| G[Вернуть JSON error]
+```
+
+**Ключевые моменты:**
+- Не удаляем старую запись — помечаем как неактивную
+- Создаем новую запись с `date_from = NOW()`
+- Синхронизируем менеджеров после изменения
+- AJAX-обработка без перезагрузки страницы
+
+### 2. Удаление магазина (Delete Store)
+
+```php
+// Логика удаления - фактически "закрытие" записи
+$dynamicEntry->date_to = $currentDate;
+$dynamicEntry->active = 0;
+$dynamicEntry->save(false);
+```
+
+**Важно:** Удаление = деактивация с установкой `date_to`, а не физическое удаление!
+
+### 3. Добавление магазина (Add Store)
+
+**Workflow:**
+
+```mermaid
+graph TD
+    A[POST actionAddStore] --> B{Есть активная запись<br/>для магазина?}
+    B -->|Да| C[Закрыть старую запись]
+    C --> D[Создать новую запись<br/>с новым cluster_id]
+    B -->|Нет| D
+    D --> E[syncClusterManagers]
+    E --> F[Редирект на view-all]
+```
+
+### 4. Календарное планирование (Cluster Store Update)
+
+Самое сложное действие контроллера — управление недельным планированием кластеров.
+
+**Структура данных:**
+
+```
+Год → Месяц → Неделя → Магазин → Кластер
+
+Пример POST данных:
+store_1__month_1__weekMonth_1: "5"  // Магазин 1 в кластере 5
+store_1__month_1__weekMonth_2: "5"
+store_1__month_1__weekMonth_3: "7"  // Переход в кластер 7
+```
+
+**Алгоритм обработки:**
+
+1. **Парсинг POST данных** — разбор ключей вида `store_X__month_Y__weekMonth_Z`
+2. **Группировка по магазинам** — `$inputDataByStore`
+3. **Определение интервалов** — объединение последовательных недель с одинаковым кластером
+4. **Конвертация в даты** — преобразование недель в `date_from` / `date_to`
+5. **Удаление старых данных** — `ClusterCalendar::deleteAll(['year' => $year])`
+6. **Создание новых записей** — пакетная вставка в `ClusterCalendar`
+
+**Пример интервала:**
+
+```php
+[
+    'store' => 1,
+    'cluster' => 5,
+    'dateFrom' => '2024-01-01 00:00:00',
+    'dateTo' => '2024-01-31 23:59:59',
+    'year' => 2024,
+]
+```
+
+## Зависимости и связи с другими модулями
+
+### Внешние зависимости:
+
+```mermaid
+graph LR
+    A[ClusterLinkEditController] --> B[ClusterManagerService]
+    A --> C[StoreDynamic Model]
+    A --> D[ClusterCalendar Model]
+    A --> E[Cluster Model]
+    A --> F[CityStore Model]
+    A --> G[ClusterAdmin Model]
+    A --> H[Admin Model]
+    A --> I[DateHelper]
+
+    B --> J[Синхронизация менеджеров]
+    C --> K[Темпоральные данные]
+    D --> L[Календарное планирование]
+```
+
+### Связи с другими контроллерами:
+
+| Контроллер | Связь | Описание |
+|------------|-------|----------|
+| **ClusterController** | Читает те же модели | Основной контроллер кластеров (CRUD) |
+| **StoreController** | Модель CityStore | Управление магазинами |
+| **AdminController** | Модель Admin | Управление менеджерами |
+
+### Представления (Views):
+
+Контроллер использует нестандартные пути к представлениям:
+
+```php
+'/cluster_link_edit/index'              // actionIndex
+'/cluster_link_store_edit/view'         // actionView
+'/cluster_link_edit/view-all'           // actionViewAll
+'/cluster_link_store_edit/update'       // actionClusterStoreUpdate
+'/cluster_link_edit/create'             // actionCreate
+'/cluster_link_edit/update'             // actionUpdate
+```
+
+## Особенности реализации
+
+### 1. Агрегация данных в actionIndex
+
+Контроллер выполняет сложные SQL-запросы с агрегацией:
+
+```sql
+SELECT
+    value_int AS cluster_id,
+    string_agg(store_id::text, ',') AS stores,
+    COUNT(store_id) AS store_count,
+    MAX(date_from) AS last_update
+FROM store_dynamic
+WHERE active = 1
+  AND category = 1
+  AND date_from <= :currentDate
+  AND date_to > :currentDate
+GROUP BY value_int
+```
+
+**Результат:** Быстрая выборка количества магазинов в каждом кластере без JOIN.
+
+### 2. Специальная обработка исторических дат
+
+```php
+if (strtotime($currentDate) < strtotime($earliestDateFrom)) {
+    // Логика для дат до 2023-05-19
+    // Используем $specialDate для фильтрации
+} else {
+    // Стандартная логика
+}
+```
+
+**Причина:** Миграция данных или изменение структуры в определенную дату.
+
+### 3. Двойная форма представления данных
+
+- **StoreDynamic** — фактическое состояние магазинов (реальное время)
+- **ClusterCalendar** — плановое состояние магазинов (календарное планирование)
+
+### 4. Сохранение без валидации
+
+```php
+$dynamicEntry->save(false); // Пропуск валидации
+```
+
+**Риски:** Возможность записи невалидных данных. Требует осторожности.
+
+## Потенциальные проблемы и рекомендации
+
+### Проблемы:
+
+1. **Отсутствие транзакций** — операции изменения данных не обернуты в транзакции
+2. **save(false)** — пропуск валидации может привести к некорректным данным
+3. **Жестко заданные даты** — `$specialDate`, `$earliestDateFrom` заданы в коде
+4. **Дублирование логики** — логика работы с датами повторяется в нескольких actions
+5. **Отсутствие обработки ошибок** — нет try/catch блоков
+
+### Рекомендации:
+
+```php
+// Использовать транзакции
+$transaction = Yii::$app->db->beginTransaction();
+try {
+    // Операции с данными
+    $transaction->commit();
+} catch (\Exception $e) {
+    $transaction->rollBack();
+    throw $e;
+}
+
+// Перенести константы в конфигурацию
+'params' => [
+    'cluster' => [
+        'earliestDateFrom' => '2023-05-19 00:00:00',
+        'specialDate' => '2024-09-12 00:00:00',
+    ]
+]
+
+// Создать отдельный сервис для темпоральной логики
+ClusterTemporalService::getActiveStores($clusterId, $date);
+```
+
+## Диаграмма основного workflow
+
+```mermaid
+sequenceDiagram
+    participant User
+    participant Controller
+    participant StoreDynamic
+    participant ClusterManagerService
+    participant DB
+
+    User->>Controller: POST /move-store
+    Controller->>StoreDynamic: Найти активную запись
+    StoreDynamic-->>Controller: Запись найдена
+
+    Controller->>DB: UPDATE: date_to = NOW, active = 0
+    DB-->>Controller: OK
+
+    Controller->>DB: INSERT: новая запись
+    DB-->>Controller: OK
+
+    Controller->>ClusterManagerService: syncClusterManagers()
+    ClusterManagerService->>DB: Обновить привязки менеджеров
+    DB-->>ClusterManagerService: OK
+    ClusterManagerService-->>Controller: Синхронизация завершена
+
+    Controller-->>User: JSON: {success: true}
+```
+
+## Примеры использования
+
+### 1. Просмотр кластера на определенную дату
+
+```php
+GET /cluster-link-edit/view-all?id=5&date=2024-01-15
+```
+
+**Ответ:** Список магазинов, которые были в кластере 5 на 15 января 2024 года.
+
+### 2. AJAX перемещение магазина
+
+```javascript
+$.ajax({
+    url: '/cluster-link-edit/move-store',
+    method: 'POST',
+    data: {
+        id: 123,              // ID записи StoreDynamic
+        new_cluster_id: 7     // Новый кластер
+    },
+    success: function(response) {
+        if (response.success) {
+            // Обновить UI
+        }
+    }
+});
+```
+
+### 3. Добавление магазина в кластер
+
+```php
+POST /cluster-link-edit/add-store?id=5
+Данные: store_id=42
+```
+
+**Результат:** Магазин 42 добавлен в кластер 5 с текущего момента.
+
+## Метрики и статистика
+
+| Метрика | Значение |
+|---------|----------|
+| Количество actions | 10 |
+| Количество используемых моделей | 8 |
+| Количество сервисов | 2 |
+| Сложность бизнес-логики | Высокая |
+| Использование AJAX | Да (actionMoveStore) |
+| Поддержка временных данных | Да |
+| Количество SQL-запросов на страницу | 3-7 |
+
+## Связанные документы
+
+- [ClusterController](./ClusterController_ANALYSIS.md) — основной CRUD контроллер кластеров
+- [StoreDynamic Model](../../models/StoreDynamic.md) — модель темпоральных данных
+- [ClusterManagerService](../../services/ClusterManagerService.md) — сервис синхронизации менеджеров
+- [DateHelper](../../helpers/DateHelper.md) — вспомогательный класс для работы с датами
+- [Архитектура темпоральных данных](../../architecture/temporal-data.md)
+
+---
+
+**Дата создания документации:** 2025-11-26
+**Версия контроллера:** актуально на момент документирования
+**Автор анализа:** Code Analyst Worker 1
diff --git a/erp24/docs/controllers/non-standard/ClusterLinkEditController_QUICK_REFERENCE.md b/erp24/docs/controllers/non-standard/ClusterLinkEditController_QUICK_REFERENCE.md
new file mode 100644 (file)
index 0000000..0204d14
--- /dev/null
@@ -0,0 +1,542 @@
+# ClusterLinkEditController - Краткий справочник
+
+## Общая информация
+
+**Контроллер:** `ClusterLinkEditController`
+**Namespace:** `app\controllers`
+**Extends:** `yii\web\Controller`
+**Путь:** `/erp24/controllers/ClusterLinkEditController.php`
+
+**Назначение:** Управление связями магазинов и кластеров с поддержкой темпоральных данных.
+
+## Быстрый доступ к actions
+
+| Action | URL | Метод | Краткое описание |
+|--------|-----|-------|------------------|
+| `actionIndex` | `/cluster-link-edit/index` | GET | Список кластеров |
+| `actionView` | `/cluster-link-edit/view` | GET | Календарь планирования |
+| `actionViewAll` | `/cluster-link-edit/view-all?id={id}` | GET | Детали кластера |
+| `actionMoveStore` | `/cluster-link-edit/move-store` | POST | Переместить магазин |
+| `actionDeleteStore` | `/cluster-link-edit/delete-store` | POST | Удалить магазин |
+| `actionAddStore` | `/cluster-link-edit/add-store?id={id}` | POST | Добавить магазин |
+| `actionCreate` | `/cluster-link-edit/create` | GET/POST | Создать кластер |
+| `actionUpdate` | `/cluster-link-edit/update?id={id}` | GET/POST | Редактировать кластер |
+| `actionDelete` | `/cluster-link-edit/delete?id={id}` | POST | Удалить кластер |
+
+## Контроль доступа
+
+### Разрешенные пользователи:
+
+```php
+// По ID пользователя
+[1, 3]  // Суперадминистраторы
+
+// По group_id
+[71, 81, 10]  // Кластерные менеджеры, администраторы кластеров, системные администраторы
+```
+
+### Проверка в коде:
+
+```php
+if (in_array(Yii::$app->user->id, [1, 3])) {
+    // Доступ разрешен
+}
+
+$groupId = Yii::$app->user->identity->group_id;
+if (in_array($groupId, [71, 81, 10])) {
+    // Доступ разрешен
+}
+```
+
+## Основные use cases
+
+### Use Case 1: Просмотр кластеров на дату
+
+```php
+// Текущее состояние
+GET /cluster-link-edit/index
+
+// Историческое состояние
+GET /cluster-link-edit/index?ClusterSearch[date]=2024-01-15
+```
+
+**Результат:** Список кластеров с количеством магазинов и назначенными менеджерами.
+
+### Use Case 2: Перемещение магазина между кластерами
+
+```javascript
+// AJAX запрос
+$.ajax({
+    url: '/cluster-link-edit/move-store',
+    method: 'POST',
+    data: {
+        id: 123,              // ID записи StoreDynamic
+        new_cluster_id: 7,    // Новый кластер
+        _csrf: '...'
+    },
+    success: function(response) {
+        if (response.success) {
+            console.log('Магазин перемещен');
+        }
+    }
+});
+```
+
+**Результат:** Магазин перемещен из текущего кластера в кластер 7 с текущей даты.
+
+### Use Case 3: Добавление магазина в кластер
+
+```php
+// POST запрос
+POST /cluster-link-edit/add-store?id=5
+Данные: store_id=42
+
+// Результат: Магазин 42 добавлен в кластер 5
+```
+
+### Use Case 4: Удаление магазина из кластера
+
+```php
+// POST запрос
+POST /cluster-link-edit/delete-store?id=123&cluster_id=5
+
+// Результат: Магазин удален из кластера (деактивирован)
+```
+
+### Use Case 5: Календарное планирование
+
+```php
+// Открыть форму планирования на 2024 год
+GET /cluster-link-edit/view?year=2024
+
+// Заполнить форму и сохранить
+POST /cluster-link-edit/cluster-store-update
+Данные: массив планирования по неделям
+```
+
+## Темпоральная модель данных
+
+### Концепция
+
+```
+┌─────────────────────────────────────────────────────────────┐
+│ Магазин может быть в разных кластерах в разное время        │
+└─────────────────────────────────────────────────────────────┘
+
+Магазин 1:
+[2023-01-01 ... 2023-06-15) → Кластер 5
+[2023-06-15 ... 2024-03-20) → Кластер 7
+[2024-03-20 ... 2100-01-01) → Кластер 5 (активная)
+```
+
+### Ключевые даты
+
+| Константа | Значение | Назначение |
+|-----------|----------|------------|
+| `$earliestDateFrom` | `2023-05-19` | Самая ранняя дата |
+| `$specialDate` | `2024-09-12` | Дата миграции/изменений |
+| `$futureDate` | `2100-01-01` | Дата "бесконечности" |
+
+### Логика выборки
+
+```mermaid
+graph TD
+    A{Дата запроса} -->|< 2023-05-19| B[Исторический режим]
+    A -->|>= 2023-05-19| C[Стандартный режим]
+
+    B --> D[Использовать specialDate<br/>для фильтрации]
+    C --> E[date_from <= дата<br/>AND date_to > дата<br/>AND active = 1]
+```
+
+## Основной workflow
+
+```mermaid
+graph TD
+    A[Пользователь] -->|1. Просмотр| B[actionIndex]
+    B --> C{Выбор действия}
+
+    C -->|Детали кластера| D[actionViewAll]
+    C -->|Планирование| E[actionView]
+    C -->|Создать кластер| F[actionCreate]
+
+    D -->|Переместить магазин| G[actionMoveStore]
+    D -->|Удалить магазин| H[actionDeleteStore]
+    D -->|Добавить магазин| I[actionAddStore]
+
+    G --> J[Обновить StoreDynamic]
+    H --> J
+    I --> J
+
+    J --> K[syncClusterManagers]
+    K --> L[Обновить UI]
+
+    E -->|Сохранить план| M[actionClusterStoreUpdate]
+    M --> N[Обновить ClusterCalendar]
+```
+
+## Используемые модели
+
+### Основные модели
+
+```php
+use yii_app\records\Cluster;           // Справочник кластеров
+use yii_app\records\ClusterAdmin;      // Менеджеры кластеров
+use yii_app\records\ClusterCalendar;   // Календарное планирование
+use yii_app\records\CityStore;         // Справочник магазинов
+use yii_app\records\StoreDynamic;      // Темпоральные данные магазинов
+use yii_app\records\PlanStore;         // Плановые данные
+use yii_app\records\Admin;             // Администраторы
+```
+
+### Ключевые связи
+
+```mermaid
+erDiagram
+    Cluster ||--o{ StoreDynamic : "содержит"
+    Cluster ||--o{ ClusterCalendar : "планируется"
+    Cluster ||--o{ ClusterAdmin : "управляется"
+    CityStore ||--o{ StoreDynamic : "история"
+    Admin ||--o{ ClusterAdmin : "управляет"
+
+    StoreDynamic {
+        int id
+        int store_id
+        int value_int "cluster_id"
+        datetime date_from
+        datetime date_to
+        int active
+    }
+
+    ClusterCalendar {
+        int id
+        int cluster_id
+        int value_int "store_id"
+        datetime date_from
+        datetime date_to
+        int year
+    }
+```
+
+## Сервисы
+
+### ClusterManagerService
+
+**Метод:** `syncClusterManagers()`
+
+**Назначение:** Синхронизация назначений менеджеров кластеров после изменения состава.
+
+**Вызывается после:**
+- `actionMoveStore`
+- `actionDeleteStore`
+- `actionAddStore`
+
+```php
+// Использование
+ClusterManagerService::syncClusterManagers();
+```
+
+## Helpers
+
+### DateHelper
+
+```php
+// Получить интервалы недель для года
+$intervals = DateHelper::getIntervals($year);
+// [
+//     ['month' => 1, 'week' => 1, 'start' => '2024-01-01', 'finish' => '2024-01-07'],
+//     ['month' => 1, 'week' => 2, 'start' => '2024-01-08', 'finish' => '2024-01-14'],
+//     ...
+// ]
+
+// Начало недели
+$startDay = DateHelper::getStartDay($year, $month, $week);
+
+// Конец недели
+$finishDay = DateHelper::getFinishDay($year, $month, $week);
+```
+
+### ArrayHelper
+
+```php
+// Преобразование в map
+$storeNames = ArrayHelper::map(CityStore::find()->all(), 'id', 'name');
+
+// Извлечение колонки
+$ids = ArrayHelper::getColumn($admins, 'id');
+```
+
+## Примеры кода
+
+### Пример 1: Получить активные магазины кластера
+
+```php
+$clusterId = 5;
+$currentDate = date('Y-m-d H:i:s');
+
+$stores = StoreDynamic::find()
+    ->select(['store_id', 'date_from', 'date_to'])
+    ->where(['value_int' => $clusterId])
+    ->andWhere(['<=', 'date_from', $currentDate])
+    ->andWhere(['>', 'date_to', $currentDate])
+    ->andWhere(['active' => 1, 'category' => 1])
+    ->all();
+
+foreach ($stores as $store) {
+    echo "Магазин: {$store->store_id}, с {$store->date_from}\n";
+}
+```
+
+### Пример 2: Переместить магазин программно
+
+```php
+$storeId = 42;
+$oldClusterId = 5;
+$newClusterId = 7;
+$currentDate = date('Y-m-d H:i:s');
+
+// 1. Найти активную запись
+$currentEntry = StoreDynamic::find()
+    ->where(['store_id' => $storeId, 'value_int' => $oldClusterId])
+    ->andWhere(['active' => 1])
+    ->one();
+
+if ($currentEntry) {
+    // 2. Закрыть текущую запись
+    $currentEntry->date_to = $currentDate;
+    $currentEntry->active = 0;
+    $currentEntry->save(false);
+
+    // 3. Создать новую запись
+    $newEntry = new StoreDynamic();
+    $newEntry->store_id = $storeId;
+    $newEntry->value_type = 'int';
+    $newEntry->value_int = $newClusterId;
+    $newEntry->date_from = $currentDate;
+    $newEntry->date_to = '2100-01-01 00:00:00';
+    $newEntry->active = 1;
+    $newEntry->category = 1;
+    $newEntry->save(false);
+
+    // 4. Синхронизировать менеджеров
+    ClusterManagerService::syncClusterManagers();
+}
+```
+
+### Пример 3: Получить менеджера кластера
+
+```php
+$clusterId = 5;
+
+$clusterAdmin = ClusterAdmin::find()
+    ->where(['cluster_id' => $clusterId])
+    ->andWhere(['date_end' => '2100-01-01'])  // Активная запись
+    ->one();
+
+if ($clusterAdmin) {
+    $managerName = $clusterAdmin->admin->name;
+    echo "Менеджер кластера: {$managerName}";
+} else {
+    echo "Менеджер не назначен";
+}
+```
+
+### Пример 4: Создать плановую запись в календаре
+
+```php
+$storeId = 42;
+$clusterId = 5;
+$dateFrom = '2024-01-01 00:00:00';
+$dateTo = '2024-01-31 23:59:59';
+$year = 2024;
+$adminId = Yii::$app->session->get('admin_id');
+
+$calendar = new ClusterCalendar();
+$calendar->setClusterId($clusterId)
+    ->setDateFrom($dateFrom)
+    ->setDateTo($dateTo)
+    ->setYear($year);
+
+$calendar->value_type = 'int';
+$calendar->value_int = $storeId;
+$calendar->category_id = 1;
+$calendar->created_admin_id = $adminId;
+$calendar->created_at = date('Y-m-d H:i:s');
+
+if ($calendar->validate()) {
+    $calendar->save();
+    echo "Плановая запись создана";
+}
+```
+
+## Диаграмма процесса перемещения магазина
+
+```mermaid
+sequenceDiagram
+    participant UI as Интерфейс
+    participant Ctrl as Controller
+    participant SD as StoreDynamic
+    participant CMS as ClusterManagerService
+    participant DB as База данных
+
+    UI->>Ctrl: POST /move-store<br/>{id: 123, new_cluster_id: 7}
+    Ctrl->>SD: findOne({id: 123, active: 1})
+    SD->>DB: SELECT * FROM store_dynamic WHERE...
+    DB-->>SD: Запись найдена
+    SD-->>Ctrl: StoreDynamic объект
+
+    Ctrl->>SD: date_to = NOW, active = 0
+    SD->>DB: UPDATE store_dynamic SET...
+    DB-->>SD: OK
+
+    Ctrl->>SD: CREATE new StoreDynamic
+    SD->>DB: INSERT INTO store_dynamic...
+    DB-->>SD: OK
+
+    Ctrl->>CMS: syncClusterManagers()
+    CMS->>DB: UPDATE cluster_admin...
+    DB-->>CMS: OK
+    CMS-->>Ctrl: Sync complete
+
+    Ctrl-->>UI: {success: true}
+    UI->>UI: Обновить UI
+```
+
+## FAQ
+
+### Q1: Как работает темпоральное хранение данных?
+
+**A:** Каждое изменение привязки магазина к кластеру создает новую запись в `StoreDynamic` с `date_from` и `date_to`. Старая запись закрывается установкой `date_to = NOW()` и `active = 0`. Это позволяет отслеживать полную историю изменений.
+
+### Q2: Почему используется save(false)?
+
+**A:** `save(false)` пропускает валидацию для ускорения операций. **Риск:** Возможна запись невалидных данных. В production рекомендуется использовать транзакции и полную валидацию.
+
+### Q3: В чем разница между StoreDynamic и ClusterCalendar?
+
+**A:**
+- **StoreDynamic** — фактическое состояние магазинов (реальное время)
+- **ClusterCalendar** — плановое состояние магазинов (календарное планирование)
+
+### Q4: Что такое specialDate и зачем она нужна?
+
+**A:** `$specialDate = '2024-09-12'` — специальная дата миграции или важного изменения в структуре данных. Для дат до `$earliestDateFrom` используется логика с привязкой к `$specialDate`.
+
+### Q5: Как синхронизируются менеджеры кластеров?
+
+**A:** После изменения состава кластера вызывается `ClusterManagerService::syncClusterManagers()`, который автоматически пересчитывает и обновляет привязки менеджеров к кластерам.
+
+### Q6: Можно ли восстановить удаленный магазин?
+
+**A:** Да, "удаление" — это деактивация записи (`active = 0`). Магазин можно добавить обратно через `actionAddStore`, при этом создастся новая активная запись.
+
+### Q7: Как обрабатываются ошибки?
+
+**A:** Контроллер использует минимальную обработку ошибок:
+- Flash-сообщения (`setFlash`)
+- JSON-ответы для AJAX
+- 404 исключения для ненайденных моделей
+
+**Рекомендация:** Добавить try/catch блоки и транзакции.
+
+### Q8: Почему actionDelete делает физическое удаление?
+
+**A:** Это потенциальная проблема. Рекомендуется заменить на soft delete:
+
+```php
+$model->active = 0;
+$model->save(false);
+```
+
+### Q9: Как работает календарное планирование?
+
+**A:** Форма предоставляет сетку "магазин × неделя". Каждая ячейка — кластер для магазина на конкретную неделю. При сохранении последовательные недели с одинаковым кластером объединяются в интервалы.
+
+### Q10: Какие права нужны для доступа?
+
+**A:**
+- User ID: 1, 3 (суперадминистраторы)
+- Group ID: 71 (кластерные менеджеры)
+- Group ID: 81 (администраторы кластеров)
+- Group ID: 10 (системные администраторы)
+
+## Потенциальные проблемы
+
+### 1. Отсутствие транзакций
+
+```php
+// Проблема: операции не атомарны
+$dynamicEntry->save(false);
+$newEntry->save(false);
+
+// Решение: использовать транзакции
+$transaction = Yii::$app->db->beginTransaction();
+try {
+    // Операции
+    $transaction->commit();
+} catch (\Exception $e) {
+    $transaction->rollBack();
+    throw $e;
+}
+```
+
+### 2. save(false) без валидации
+
+```php
+// Проблема: пропуск валидации
+$model->save(false);
+
+// Решение: всегда валидировать
+if ($model->validate()) {
+    $model->save();
+}
+```
+
+### 3. Жестко заданные даты
+
+```php
+// Проблема: даты в коде
+$earliestDateFrom = '2023-05-19 00:00:00';
+
+// Решение: конфигурация
+Yii::$app->params['cluster']['earliestDateFrom']
+```
+
+### 4. Отсутствие проверки связей при удалении
+
+```php
+// Проблема: удаление без проверки
+$this->findModel($id)->delete();
+
+// Решение: проверить связи
+$storeCount = StoreDynamic::find()
+    ->where(['value_int' => $id, 'active' => 1])
+    ->count();
+
+if ($storeCount > 0) {
+    throw new \Exception('Кластер содержит магазины');
+}
+```
+
+## Рекомендации по улучшению
+
+1. **Использовать транзакции** для всех изменений данных
+2. **Создать сервис** `ClusterTemporalService` для работы с темпоральными данными
+3. **Добавить валидацию** перед всеми сохранениями
+4. **Реализовать soft delete** для кластеров
+5. **Вынести константы** в конфигурацию
+6. **Добавить обработку ошибок** с try/catch
+7. **Создать тесты** для критичных операций
+
+## Связанные документы
+
+- [ClusterLinkEditController_ANALYSIS.md](./ClusterLinkEditController_ANALYSIS.md) — полный анализ
+- [ClusterLinkEditController_ACTIONS_TABLE.md](./ClusterLinkEditController_ACTIONS_TABLE.md) — таблица actions
+- [ClusterManagerService](../../services/ClusterManagerService.md) — сервис синхронизации
+- [StoreDynamic Model](../../models/StoreDynamic.md) — модель темпоральных данных
+- [Архитектура темпоральных данных](../../architecture/temporal-data.md)
+
+---
+
+**Дата создания:** 2025-11-26
+**Версия:** 1.0
+**Автор:** Code Analyst Worker 1
diff --git a/erp24/docs/controllers/non-standard/MatrixBouquetActualityController_ACTIONS_TABLE.md b/erp24/docs/controllers/non-standard/MatrixBouquetActualityController_ACTIONS_TABLE.md
new file mode 100644 (file)
index 0000000..479c55f
--- /dev/null
@@ -0,0 +1,663 @@
+# MatrixBouquetActualityController - Таблица Actions
+
+## Обзор
+
+Контроллер предоставляет 6 публичных actions для управления актуальностью букетов в матрице продаж.
+
+## Таблица всех actions
+
+| # | Action | Route | HTTP Method | Доступ | Назначение |
+|---|--------|-------|-------------|--------|-----------|
+| 1 | `actionIndex` | `/matrix-bouquet-actuality/index` | GET, POST | Restricted | Главная страница с фильтрацией и массовым редактированием |
+| 2 | `actionView` | `/matrix-bouquet-actuality/view` | GET | Restricted | Просмотр одной записи актуальности |
+| 3 | `actionCreate` | `/matrix-bouquet-actuality/create` | GET, POST | Restricted | Создание новой записи актуальности |
+| 4 | `actionUpdate` | `/matrix-bouquet-actuality/update` | GET, POST | Restricted | Обновление существующей записи |
+| 5 | `actionDelete` | `/matrix-bouquet-actuality/delete` | POST | Restricted | Удаление записи (через форму) |
+| 6 | `actionAjaxDelete` | `/matrix-bouquet-actuality/ajax-delete` | POST, GET | Restricted | AJAX удаление записи |
+
+---
+
+## 1. actionIndex()
+
+### Описание
+
+Главная страница управления актуальностью букетов. Отображает список букетов с их периодами актуальности, поддерживает множественную фильтрацию и массовое редактирование периодов.
+
+### HTTP Method
+
+- **GET** — отображение страницы с фильтрацией
+- **POST** — массовое сохранение периодов актуальности
+
+### Параметры (GET)
+
+| Параметр | Тип | Обязательный | Описание | Пример |
+|----------|-----|--------------|----------|--------|
+| `group_id` | int | Нет | ID группы матрицы (родительский тип) | `15` |
+| `subgroup_id` | int | Нет | ID подгруппы матрицы (дочерний тип) | `28` |
+| `is_archive` | int | Нет | Фильтр архивных записей (0 или 1) | `1` |
+| `date_from` | string | Нет | Дата начала периода в формате YYYY-MM | `2024-03` |
+| `date_to` | string | Нет | Дата окончания периода в формате YYYY-MM | `2024-06` |
+| `onlyActive` | boolean | Нет | Показать только букеты с периодами актуальности | `1` |
+| `onlyInactive` | boolean | Нет | Показать только букеты без периодов актуальности | `1` |
+| `BouquetCompositionSearch[bouquet_name]` | string | Нет | Поиск по названию букета (case-insensitive) | `Роза` |
+
+### Параметры (POST)
+
+| Параметр | Тип | Обязательный | Описание | Пример |
+|----------|-----|--------------|----------|--------|
+| `actuality[]` | array | Да | Массив с данными для массового сохранения | См. ниже |
+| `actuality[i][guid]` | string | Да | Уникальный идентификатор строки | `abc-123-456` |
+| `actuality[i][bouquet_id]` | int | Да | ID букета | `45` |
+| `actuality[i][id]` | int | Нет | ID существующей записи (если редактируется) | `123` |
+| `actuality[i][from]` | string | Да | Дата начала в формате YYYY-MM | `2024-03` |
+| `actuality[i][to]` | string | Да | Дата окончания в формате YYYY-MM | `2024-06` |
+
+### Возвращает
+
+- **GET**: HTML-страница с таблицей букетов и их периодами актуальности
+- **POST**: редирект на `/matrix-bouquet-actuality/index` с flash-сообщением
+
+### Пример GET запроса
+
+```http
+GET /matrix-bouquet-actuality/index?group_id=15&date_from=2024-03&date_to=2024-03&onlyActive=1
+```
+
+**Результат**: все букеты группы 15 (включая подгруппы), у которых есть периоды актуальности, пересекающиеся с мартом 2024.
+
+### Пример POST запроса
+
+```http
+POST /matrix-bouquet-actuality/index
+Content-Type: application/x-www-form-urlencoded
+
+actuality[0][guid]=abc-123-456&
+actuality[0][bouquet_id]=45&
+actuality[0][from]=2024-03&
+actuality[0][to]=2024-06&
+actuality[1][guid]=def-789-012&
+actuality[1][bouquet_id]=46&
+actuality[1][from]=2024-04&
+actuality[1][to]=2024-08
+```
+
+**Результат**: создание/обновление периодов с автоматическим объединением пересекающихся диапазонов.
+
+### Особенности
+
+1. **Автоматическое объединение периодов** — при сохранении система автоматически объединяет пересекающиеся или смежные диапазоны дат
+2. **Рекурсивная фильтрация по группам** — при выборе группы автоматически включаются все её подгруппы
+3. **Преобразование месяцев в полные диапазоны** — `2024-03` становится `2024-03-01 00:00:00 ... 2024-03-31 23:59:59`
+4. **Пагинация** — 1000 записей на страницу
+5. **Сортировка** — по умолчанию по названию букета
+
+### Алгоритм работы (POST)
+
+```mermaid
+flowchart TD
+    Start[POST запрос] --> CheckAccess{Проверка доступа}
+    CheckAccess -->|Нет доступа| Redirect[Редирект на главную]
+    CheckAccess -->|Доступ разрешен| ParseData[Парсинг данных actuality]
+
+    ParseData --> Validate{Валидация дат}
+    Validate -->|Невалидные| Skip[Пропустить запись]
+    Validate -->|Валидные| CheckID{Есть ID?}
+
+    CheckID -->|Да| UpdateExisting[Обновить существующую запись]
+    CheckID -->|Нет| FindOverlaps{Есть пересечения?}
+
+    FindOverlaps -->|Нет| CreateNew[Создать новую запись]
+    FindOverlaps -->|Да| MergeWithExisting[Объединить с существующими]
+
+    UpdateExisting --> MergeNeighbors[Объединить с соседними]
+    MergeWithExisting --> MergeNeighbors
+
+    MergeNeighbors --> IterativeMerge[Итеративное объединение]
+    IterativeMerge --> DeleteMerged[Удалить объединенные]
+
+    CreateNew --> Success[Успех]
+    DeleteMerged --> Success
+
+    Success --> Flash[Flash-сообщение]
+    Flash --> RefreshPage[Обновление страницы]
+```
+
+---
+
+## 2. actionView($id)
+
+### Описание
+
+Просмотр детальной информации об одной записи актуальности букета.
+
+### HTTP Method
+
+**GET**
+
+### Параметры
+
+| Параметр | Тип | Обязательный | Описание | Пример |
+|----------|-----|--------------|----------|--------|
+| `id` | int | Да | ID записи MatrixBouquetActuality | `123` |
+
+### Возвращает
+
+HTML-страница с детальной информацией о записи (букет, даты, статус архивности, создатель, даты создания/обновления).
+
+### Исключения
+
+- **NotFoundHttpException** (404) — если запись с указанным ID не найдена
+
+### Пример запроса
+
+```http
+GET /matrix-bouquet-actuality/view?id=123
+```
+
+### Пример ответа
+
+HTML-страница с данными:
+
+- **ID записи**: 123
+- **GUID**: abc-123-456
+- **Букет**: "Букет роз микс 15 шт"
+- **Дата начала**: 2024-03-01 00:00:00
+- **Дата окончания**: 2024-06-30 23:59:59
+- **Архивная**: Нет
+- **Создана**: 2024-02-15 10:30:00 (пользователь #5)
+- **Обновлена**: 2024-02-20 14:45:00 (пользователь #5)
+
+---
+
+## 3. actionCreate()
+
+### Описание
+
+Создание новой записи актуальности букета через стандартную форму.
+
+### HTTP Method
+
+- **GET** — отображение формы создания
+- **POST** — сохранение новой записи
+
+### Параметры (POST)
+
+| Параметр | Тип | Обязательный | Описание | Пример |
+|----------|-----|--------------|----------|--------|
+| `MatrixBouquetActuality[bouquet_id]` | int | Да | ID букета | `45` |
+| `MatrixBouquetActuality[date_from]` | datetime | Да | Дата начала актуальности | `2024-03-01 00:00:00` |
+| `MatrixBouquetActuality[date_to]` | datetime | Да | Дата окончания актуальности | `2024-06-30 23:59:59` |
+| `MatrixBouquetActuality[is_archive]` | int | Нет | Флаг архивности (0/1) | `0` |
+| `MatrixBouquetActuality[guid]` | string | Нет | Уникальный идентификатор | `abc-123-456` |
+
+### Возвращает
+
+- **GET**: HTML-форма создания
+- **POST (успех)**: редирект на `actionView` с ID созданной записи
+- **POST (ошибка)**: HTML-форма с ошибками валидации
+
+### Пример GET запроса
+
+```http
+GET /matrix-bouquet-actuality/create
+```
+
+### Пример POST запроса
+
+```http
+POST /matrix-bouquet-actuality/create
+Content-Type: application/x-www-form-urlencoded
+
+MatrixBouquetActuality[bouquet_id]=45&
+MatrixBouquetActuality[date_from]=2024-03-01 00:00:00&
+MatrixBouquetActuality[date_to]=2024-06-30 23:59:59&
+MatrixBouquetActuality[is_archive]=0&
+MatrixBouquetActuality[guid]=abc-123-456
+```
+
+### Особенности
+
+- **Автоматическое заполнение полей**: `created_at`, `created_by` устанавливаются автоматически
+- **Валидация**: проверка на обязательность полей, формат дат, существование букета
+- **Не выполняется автоматическое объединение** — для этого используйте массовое редактирование в `actionIndex`
+
+---
+
+## 4. actionUpdate($id)
+
+### Описание
+
+Обновление существующей записи актуальности букета.
+
+### HTTP Method
+
+- **GET** — отображение формы редактирования
+- **POST** — сохранение изменений
+
+### Параметры
+
+| Параметр | Тип | Обязательный | Описание | Пример |
+|----------|-----|--------------|----------|--------|
+| `id` | int | Да | ID записи для обновления | `123` |
+
+### Параметры (POST)
+
+Аналогичны `actionCreate()`, но ID берется из URL.
+
+### Возвращает
+
+- **GET**: HTML-форма редактирования с текущими данными
+- **POST (успех)**: редирект на `actionView` с ID обновленной записи
+- **POST (ошибка)**: HTML-форма с ошибками валидации
+
+### Исключения
+
+- **NotFoundHttpException** (404) — если запись с указанным ID не найдена
+
+### Пример GET запроса
+
+```http
+GET /matrix-bouquet-actuality/update?id=123
+```
+
+### Пример POST запроса
+
+```http
+POST /matrix-bouquet-actuality/update?id=123
+Content-Type: application/x-www-form-urlencoded
+
+MatrixBouquetActuality[date_from]=2024-04-01 00:00:00&
+MatrixBouquetActuality[date_to]=2024-07-31 23:59:59
+```
+
+### Особенности
+
+- **Обновление метаданных**: `updated_at`, `updated_by` обновляются автоматически
+- **Не выполняется автоматическое объединение** — для этого используйте массовое редактирование в `actionIndex`
+
+---
+
+## 5. actionDelete($id)
+
+### Описание
+
+Удаление записи актуальности букета через POST-запрос (стандартное удаление из формы с CSRF-защитой).
+
+### HTTP Method
+
+**POST** (ограничено через VerbFilter)
+
+### Параметры
+
+| Параметр | Тип | Обязательный | Описание | Пример |
+|----------|-----|--------------|----------|--------|
+| `id` | int | Да | ID записи для удаления | `123` |
+
+### Возвращает
+
+Редирект на `/matrix-bouquet-actuality/index`
+
+### Исключения
+
+- **NotFoundHttpException** (404) — если запись с указанным ID не найдена
+- **MethodNotAllowedHttpException** (405) — если используется не POST метод
+
+### Пример запроса
+
+```http
+POST /matrix-bouquet-actuality/delete?id=123
+Content-Type: application/x-www-form-urlencoded
+
+_csrf=...
+```
+
+### Особенности
+
+- **CSRF-защита** — требуется валидный CSRF-токен
+- **Безвозвратное удаление** — запись удаляется из базы данных (не soft delete)
+- **Каскадное удаление** — нет, связанные записи не удаляются
+
+---
+
+## 6. actionAjaxDelete()
+
+### Описание
+
+Удаление записи актуальности букета через AJAX с возвратом JSON-ответа.
+
+### HTTP Method
+
+**POST** (рекомендуется), **GET** (поддерживается)
+
+### Параметры
+
+| Параметр | Тип | Обязательный | Описание | Пример |
+|----------|-----|--------------|----------|--------|
+| `id` | int | Да | ID записи для удаления (из POST или GET) | `123` |
+
+### Формат ответа
+
+**Content-Type**: `application/json`
+
+### Возвращает (успех)
+
+```json
+{
+    "success": true,
+    "message": "Запись успешно удалена"
+}
+```
+
+**HTTP Status**: 200
+
+### Возвращает (ошибка)
+
+```json
+{
+    "success": false,
+    "message": "Текст ошибки"
+}
+```
+
+**HTTP Status**: 200 (даже при ошибке!)
+
+### Исключения
+
+- **BadRequestHttpException** (400) — если параметр `id` не передан
+
+### Пример запроса (POST)
+
+```http
+POST /matrix-bouquet-actuality/ajax-delete
+Content-Type: application/x-www-form-urlencoded
+
+id=123
+```
+
+### Пример запроса (GET)
+
+```http
+GET /matrix-bouquet-actuality/ajax-delete?id=123
+```
+
+### Пример использования (JavaScript)
+
+```javascript
+$.ajax({
+    url: '/matrix-bouquet-actuality/ajax-delete',
+    method: 'POST',
+    data: { id: 123 },
+    dataType: 'json',
+    success: function(response) {
+        if (response.success) {
+            alert(response.message);
+            // Удалить строку из таблицы
+            $('#row-123').remove();
+        } else {
+            alert('Ошибка: ' + response.message);
+        }
+    },
+    error: function(xhr, status, error) {
+        alert('Ошибка AJAX: ' + error);
+    }
+});
+```
+
+### Особенности
+
+- **Нет CSRF-проверки** для GET-запросов (потенциальная уязвимость!)
+- **Рекомендуется использовать POST** с CSRF-токеном
+- **Безвозвратное удаление** — запись удаляется из базы данных
+- **Удобно для AJAX-интерфейсов** — не требует перезагрузки страницы
+
+---
+
+## Вспомогательные методы
+
+### findModel($id)
+
+**Назначение**: Поиск модели MatrixBouquetActuality по первичному ключу.
+
+**Параметры**:
+
+- `$id` (int) — ID записи
+
+**Возвращает**: объект `MatrixBouquetActuality`
+
+**Исключения**:
+
+- `NotFoundHttpException` — если модель не найдена
+
+**Используется в**:
+
+- `actionView()`
+- `actionUpdate()`
+- `actionDelete()`
+- `actionAjaxDelete()`
+
+---
+
+### checkAccess()
+
+**Назначение**: Проверка прав доступа текущего пользователя.
+
+**Параметры**: нет
+
+**Возвращает**:
+
+- `null` — если доступ разрешен
+- `Response` — HTML-ответ с главной страницей при отсутствии доступа
+
+**Разрешенные группы**:
+
+- `AdminGroup::GROUP_IT`
+- `AdminGroup::GROUP_BUSH_CHEF_FLORIST`
+- `AdminGroup::GROUP_BUSH_DIRECTOR`
+- `AdminGroup::GROUP_RS_DIRECTOR`
+
+**Используется в**: всех actions контроллера
+
+---
+
+### processBatchActuality($post)
+
+**Назначение**: Обработка массового сохранения периодов актуальности с автоматическим объединением пересекающихся диапазонов.
+
+**Параметры**:
+
+- `$post` (array) — массив данных из формы
+
+**Возвращает**: void
+
+**Алгоритм**:
+
+1. Валидация дат (пропуск невалидных)
+2. Преобразование месяцев в полные диапазоны
+3. Проверка наличия `id`:
+   - Если `id` задан — обновление существующей записи
+   - Если `id` не задан — создание новой или объединение с существующими
+4. Поиск пересекающихся периодов
+5. Объединение диапазонов (расширение до границ всех соседей)
+6. Итеративное объединение до полной консолидации
+7. Удаление объединенных записей
+
+**Используется в**: `actionIndex()` (POST)
+
+**Особенности**:
+
+- **Расширение границ на ±1 секунду** для корректного поиска примыкающих периодов
+- **Итеративный алгоритм** — повторяется до тех пор, пока не останется пересечений
+- **Логирование ошибок** через `Yii::error()` и `Yii::warning()`
+- **Atomic operations** — каждая запись обрабатывается независимо
+
+---
+
+### getMatrixTypeDescendantsIds($rootId)
+
+**Назначение**: Рекурсивное получение всех ID потомков типа матрицы.
+
+**Параметры**:
+
+- `$rootId` (int) — ID корневого типа матрицы
+
+**Возвращает**: array — список ID всех потомков (включая сам $rootId)
+
+**Алгоритм**:
+
+1. Загрузка всех типов матрицы (id, parent_id)
+2. Построение массива `$byParent[parent_id] => [child_ids]`
+3. Итеративный обход дерева через стек
+4. Возврат всех найденных ID
+
+**Используется в**: `actionIndex()` (для фильтрации по группам)
+
+**Пример**:
+
+```php
+// Иерархия:
+// Группа 1
+//   ├── Подгруппа 2
+//   │   └── Подгруппа 4
+//   └── Подгруппа 3
+
+getMatrixTypeDescendantsIds(1); // => [1, 2, 3, 4]
+getMatrixTypeDescendantsIds(2); // => [2, 4]
+```
+
+---
+
+## Диаграмма взаимодействия actions
+
+```mermaid
+graph TB
+    subgraph "Public Actions"
+        Index[actionIndex<br/>GET/POST]
+        View[actionView<br/>GET]
+        Create[actionCreate<br/>GET/POST]
+        Update[actionUpdate<br/>GET/POST]
+        Delete[actionDelete<br/>POST]
+        AjaxDelete[actionAjaxDelete<br/>POST/GET]
+    end
+
+    subgraph "Helper Methods"
+        CheckAccess[checkAccess]
+        FindModel[findModel]
+        ProcessBatch[processBatchActuality]
+        GetDescendants[getMatrixTypeDescendantsIds]
+    end
+
+    subgraph "Database"
+        MBA[(MatrixBouquetActuality)]
+        BC[(BouquetComposition)]
+        MT[(MatrixType)]
+    end
+
+    Index --> CheckAccess
+    Index --> ProcessBatch
+    Index --> GetDescendants
+    Index --> BC
+    Index --> MT
+    Index --> MBA
+
+    View --> CheckAccess
+    View --> FindModel
+    FindModel --> MBA
+
+    Create --> CheckAccess
+    Create --> MBA
+
+    Update --> CheckAccess
+    Update --> FindModel
+    Update --> MBA
+
+    Delete --> CheckAccess
+    Delete --> FindModel
+    Delete --> MBA
+
+    AjaxDelete --> FindModel
+    AjaxDelete --> MBA
+
+    ProcessBatch --> MBA
+    GetDescendants --> MT
+
+    style Index fill:#e1f5ff
+    style ProcessBatch fill:#fff4e1
+    style CheckAccess fill:#ffe1e1
+```
+
+---
+
+## Сравнительная таблица CRUD-операций
+
+| Операция | Standard Action | AJAX Action | Особенности |
+|----------|----------------|-------------|-------------|
+| **Create** | `actionCreate` | — | Форма с валидацией, редирект на view |
+| **Read** | `actionView` | — | Просмотр одной записи |
+| **Update** | `actionUpdate` | — | Форма с валидацией, редирект на view |
+| **Delete** | `actionDelete` | `actionAjaxDelete` | Standard: POST only, редирект; AJAX: POST/GET, JSON |
+| **List** | `actionIndex` (GET) | — | Фильтрация, пагинация, сортировка |
+| **Batch Update** | `actionIndex` (POST) | — | Массовое редактирование, объединение периодов |
+
+---
+
+## Таблица доступа по группам
+
+| Action | IT | Руководитель букетных флористов | Директор букетного направления | Директор розничных продаж | Остальные |
+|--------|----|--------------------------------|-------------------------------|---------------------------|-----------|
+| `actionIndex` | ✅ | ✅ | ✅ | ✅ | ❌ (редирект на главную) |
+| `actionView` | ✅ | ✅ | ✅ | ✅ | ❌ (редирект на главную) |
+| `actionCreate` | ✅ | ✅ | ✅ | ✅ | ❌ (редирект на главную) |
+| `actionUpdate` | ✅ | ✅ | ✅ | ✅ | ❌ (редирект на главную) |
+| `actionDelete` | ✅ | ✅ | ✅ | ✅ | ❌ (редирект на главную) |
+| `actionAjaxDelete` | ✅ | ✅ | ✅ | ✅ | ❌ (редирект на главную) |
+
+---
+
+## Примеры использования
+
+### Пример 1: Фильтрация букетов группы с периодами в марте 2024
+
+```bash
+curl -X GET "http://erp24.local/matrix-bouquet-actuality/index?group_id=15&date_from=2024-03&date_to=2024-03&onlyActive=1"
+```
+
+### Пример 2: Массовое создание периодов актуальности
+
+```bash
+curl -X POST "http://erp24.local/matrix-bouquet-actuality/index" \
+  -d "actuality[0][guid]=abc-123-456" \
+  -d "actuality[0][bouquet_id]=45" \
+  -d "actuality[0][from]=2024-03" \
+  -d "actuality[0][to]=2024-06" \
+  -d "actuality[1][guid]=def-789-012" \
+  -d "actuality[1][bouquet_id]=46" \
+  -d "actuality[1][from]=2024-04" \
+  -d "actuality[1][to]=2024-08"
+```
+
+### Пример 3: AJAX-удаление периода
+
+```javascript
+fetch('/matrix-bouquet-actuality/ajax-delete', {
+    method: 'POST',
+    headers: {
+        'Content-Type': 'application/x-www-form-urlencoded',
+    },
+    body: 'id=123'
+})
+.then(response => response.json())
+.then(data => {
+    if (data.success) {
+        console.log(data.message);
+        document.getElementById('row-123').remove();
+    } else {
+        console.error(data.message);
+    }
+});
+```
+
+---
+
+## Заключение
+
+**MatrixBouquetActualityController** предоставляет полный набор CRUD-операций для управления периодами актуальности букетов с дополнительной функциональностью массового редактирования и автоматического объединения пересекающихся диапазонов. Все actions защищены проверкой прав доступа, доступны только для ограниченного круга пользователей.
diff --git a/erp24/docs/controllers/non-standard/MatrixBouquetActualityController_ANALYSIS.md b/erp24/docs/controllers/non-standard/MatrixBouquetActualityController_ANALYSIS.md
new file mode 100644 (file)
index 0000000..a71d2a5
--- /dev/null
@@ -0,0 +1,1022 @@
+# MatrixBouquetActualityController - Полный анализ
+
+## Метаданные
+
+| Параметр | Значение |
+|----------|----------|
+| **Namespace** | `app\controllers` |
+| **Extends** | `yii\web\Controller` |
+| **Размер файла** | 651 строка |
+| **Приоритет** | 1 (критичный) |
+| **Путь** | `erp24/controllers/MatrixBouquetActualityController.php` |
+
+## Назначение и бизнес-цель
+
+**MatrixBouquetActualityController** — контроллер для управления актуальностью букетов в матрице продаж. Основная бизнес-задача — определение периодов актуальности букетов (временных диапазонов, когда букет доступен для продажи), с возможностью массового редактирования и автоматического объединения пересекающихся периодов.
+
+### Ключевые бизнес-функции:
+
+1. **Управление периодами актуальности букетов** — определение дат начала и окончания актуальности букетов
+2. **Массовое редактирование** — возможность пакетного обновления периодов актуальности для нескольких букетов
+3. **Автоматическое объединение периодов** — при создании или изменении периода система автоматически объединяет пересекающиеся или смежные диапазоны дат
+4. **Фильтрация букетов** — по группам матриц, подгруппам, статусу актуальности, архивному статусу, датам
+5. **Интеграция с матрицей типов** — связь букетов с иерархией типов матрицы (группы/подгруппы)
+
+### Бизнес-логика актуализации матрицы букетов
+
+Контроллер реализует сложную логику управления временными диапазонами:
+
+- **Создание новых периодов** — если для букета не существует периодов в заданном диапазоне
+- **Обновление существующих периодов** — расширение или изменение границ существующих периодов
+- **Объединение пересекающихся периодов** — автоматическое слияние периодов, которые пересекаются или примыкают друг к другу
+- **Итеративное объединение** — повторное объединение до тех пор, пока не останется смежных периодов
+- **Поддержка архивирования** — возможность работы с архивными периодами актуальности
+
+## Access Control (RBAC)
+
+**Жесткое ограничение доступа** через метод `checkAccess()`, вызываемый в начале каждого action.
+
+### Разрешенные группы:
+
+| Группа | Константа | Описание |
+|--------|-----------|----------|
+| **IT** | `AdminGroup::GROUP_IT` | Группа IT-специалистов |
+| **Руководитель букетных флористов** | `AdminGroup::GROUP_BUSH_CHEF_FLORIST` | Шеф-флорист |
+| **Директор букетного направления** | `AdminGroup::GROUP_BUSH_DIRECTOR` | Директор букетного направления |
+| **Директор розничных продаж** | `AdminGroup::GROUP_RS_DIRECTOR` | Директор розничных продаж |
+
+**Поведение при отсутствии доступа**: Перенаправление на главную страницу сайта (`/site/index`).
+
+## Архитектура и зависимости
+
+### Используемые модели (ActiveRecord)
+
+| Модель | Назначение |
+|--------|-----------|
+| **MatrixBouquetActuality** | Хранение периодов актуальности букетов (bouquet_id, date_from, date_to, is_archive, guid) |
+| **MatrixBouquetActualitySearch** | Модель поиска для MatrixBouquetActuality (не используется в коде) |
+| **BouquetComposition** | Состав букетов, основная сущность букета |
+| **BouquetCompositionSearch** | Модель поиска букетов по имени |
+| **BouquetCompositionMatrixTypeHistory** | История связей букетов с типами матрицы |
+| **MatrixType** | Типы матрицы (иерархия групп и подгрупп букетов) |
+| **AdminGroup** | Группы администраторов для контроля доступа |
+
+### Связи моделей
+
+```mermaid
+erDiagram
+    BouquetComposition ||--o{ MatrixBouquetActuality : "имеет периоды"
+    BouquetComposition ||--o{ BouquetCompositionMatrixTypeHistory : "принадлежит типам"
+    MatrixType ||--o{ BouquetCompositionMatrixTypeHistory : "содержит букеты"
+    MatrixType ||--o{ MatrixType : "parent_id"
+    BouquetComposition ||--o| Price : "имеет цену"
+
+    BouquetComposition {
+        int id PK
+        string name
+        text description
+    }
+
+    MatrixBouquetActuality {
+        int id PK
+        string guid
+        int bouquet_id FK
+        datetime date_from
+        datetime date_to
+        int is_archive
+        datetime created_at
+        int created_by
+        datetime updated_at
+        int updated_by
+    }
+
+    BouquetCompositionMatrixTypeHistory {
+        int id PK
+        int bouquet_id FK
+        int matrix_type_id FK
+        boolean is_active
+    }
+
+    MatrixType {
+        int id PK
+        string name
+        int parent_id FK
+        int deleted
+    }
+```
+
+### Используемые сервисы
+
+Контроллер **не использует внешние сервисы**, вся бизнес-логика реализована внутри контроллера.
+
+### Используемые helpers
+
+| Helper | Метод | Назначение |
+|--------|-------|-----------|
+| **ArrayHelper** | `map()` | Преобразование массивов для dropdown-списков |
+
+## Список actions и их назначение
+
+| # | Action | HTTP Method | Доступ | Назначение |
+|---|--------|-------------|--------|-----------|
+| 1 | `actionIndex` | GET/POST | Restricted | Главная страница с фильтрацией и массовым редактированием актуальности букетов |
+| 2 | `actionView` | GET | Restricted | Просмотр одной записи актуальности букета |
+| 3 | `actionCreate` | GET/POST | Restricted | Создание новой записи актуальности букета |
+| 4 | `actionUpdate` | GET/POST | Restricted | Обновление существующей записи актуальности |
+| 5 | `actionDelete` | POST | Restricted | Удаление записи актуальности (через форму) |
+| 6 | `actionAjaxDelete` | POST/GET (AJAX) | Restricted | Удаление записи актуальности через AJAX |
+
+### Вспомогательные методы
+
+| Метод | Тип | Назначение |
+|-------|-----|-----------|
+| `findModel($id)` | protected | Поиск модели MatrixBouquetActuality по ID |
+| `checkAccess()` | private | Проверка прав доступа пользователя |
+| `processBatchActuality($post)` | protected | Обработка массового сохранения диапазонов актуальности |
+| `getMatrixTypeDescendantsIds($rootId)` | private | Рекурсивное получение всех потомков типа матрицы |
+
+## Детальное описание actions
+
+### 1. actionIndex()
+
+**Назначение**: Главная страница управления актуальностью букетов. Отображает список букетов с их периодами актуальности, поддерживает фильтрацию и массовое редактирование.
+
+**Параметры (GET)**:
+
+- `group_id` (int) — ID группы матрицы (родительский тип)
+- `subgroup_id` (int) — ID подгруппы матрицы (дочерний тип)
+- `is_archive` (int) — Фильтр архивных записей (0/1)
+- `date_from` (string) — Дата начала периода в формате `YYYY-MM`
+- `date_to` (string) — Дата окончания периода в формате `YYYY-MM`
+- `onlyActive` (boolean) — Показать только букеты с периодами актуальности
+- `onlyInactive` (boolean) — Показать только букеты без периодов актуальности
+- `BouquetCompositionSearch[bouquet_name]` (string) — Поиск по названию букета (case-insensitive ILIKE)
+
+**Параметры (POST)**:
+
+- `actuality[]` — массив с данными для массового сохранения:
+  - `guid` (string) — уникальный идентификатор строки
+  - `bouquet_id` (int) — ID букета
+  - `id` (int|null) — ID существующей записи актуальности (если редактируется)
+  - `from` (string) — дата начала в формате `YYYY-MM`
+  - `to` (string) — дата окончания в формате `YYYY-MM`
+
+**Алгоритм работы**:
+
+1. **Проверка доступа** через `checkAccess()`
+2. **Инициализация фильтров** — создание DynamicModel для параметров фильтрации
+3. **Обработка POST-запроса** — если есть данные `actuality`, вызывается `processBatchActuality()`
+4. **Построение запроса**:
+   - Базовый запрос к `BouquetComposition` с join к таблице цен
+   - Применение фильтра по названию букета (ILIKE)
+   - Фильтрация по группе/подгруппе через иерархию MatrixType
+   - Фильтрация по наличию/отсутствию периодов актуальности
+   - Фильтрация по датам с учетом пересечений диапазонов
+5. **Загрузка связанных данных** — eager loading периодов актуальности с сортировкой по date_from
+6. **Преобразование данных** — создание массива строк, где каждая строка — букет + его период актуальности (или null)
+7. **Создание ArrayDataProvider** с пагинацией (1000 записей на странице)
+8. **Загрузка справочников** — списки групп и подгрупп MatrixType для фильтров
+9. **Рендеринг представления** `index.php`
+
+**Возвращает**: HTML-страницу с таблицей букетов и их периодами актуальности
+
+**Особенности**:
+
+- Если ни один фильтр не применен, показываются все букеты без фильтрации по датам
+- При применении фильтров используются сложные EXISTS/NOT EXISTS подзапросы
+- Даты конвертируются в полный месяц (с 1-го числа 00:00:00 по последнее число 23:59:59)
+- Поддерживается рекурсивная выборка подгрупп через `getMatrixTypeDescendantsIds()`
+
+**Пример использования**:
+
+```php
+// Фильтрация букетов группы 15 с периодами актуальности в марте 2024
+GET /matrix-bouquet-actuality/index?group_id=15&date_from=2024-03&date_to=2024-03&onlyActive=1
+
+// Массовое сохранение периодов актуальности
+POST /matrix-bouquet-actuality/index
+actuality[0][guid] = "abc-123"
+actuality[0][bouquet_id] = 45
+actuality[0][from] = "2024-03"
+actuality[0][to] = "2024-06"
+```
+
+### 2. actionView($id)
+
+**Назначение**: Просмотр детальной информации об одной записи актуальности букета.
+
+**Параметры**:
+
+- `$id` (int) — ID записи MatrixBouquetActuality
+
+**Алгоритм**:
+
+1. Проверка доступа через `checkAccess()`
+2. Поиск модели через `findModel($id)`
+3. Рендеринг представления `view.php`
+
+**Возвращает**: HTML-страницу с детальной информацией о записи
+
+**Исключения**:
+
+- `NotFoundHttpException` — если запись не найдена
+
+### 3. actionCreate()
+
+**Назначение**: Создание новой записи актуальности букета через стандартную форму.
+
+**Параметры (POST)**:
+
+- `MatrixBouquetActuality[bouquet_id]` (int) — ID букета
+- `MatrixBouquetActuality[date_from]` (datetime) — дата начала актуальности
+- `MatrixBouquetActuality[date_to]` (datetime) — дата окончания актуальности
+- `MatrixBouquetActuality[is_archive]` (int) — флаг архивности (0/1)
+- `MatrixBouquetActuality[guid]` (string) — уникальный идентификатор
+
+**Алгоритм**:
+
+1. Проверка доступа через `checkAccess()`
+2. Создание новой модели `MatrixBouquetActuality`
+3. **GET-запрос**: загрузка значений по умолчанию, рендеринг формы
+4. **POST-запрос**:
+   - Загрузка данных из POST
+   - Сохранение модели
+   - Редирект на `actionView` при успехе
+5. Рендеринг представления `create.php`
+
+**Возвращает**:
+
+- GET: HTML-форму создания
+- POST: редирект на просмотр созданной записи
+
+### 4. actionUpdate($id)
+
+**Назначение**: Обновление существующей записи актуальности букета.
+
+**Параметры**:
+
+- `$id` (int) — ID записи для обновления
+- POST-параметры аналогичны `actionCreate()`
+
+**Алгоритм**:
+
+1. Проверка доступа через `checkAccess()`
+2. Поиск модели через `findModel($id)`
+3. **GET-запрос**: рендеринг формы с текущими данными
+4. **POST-запрос**:
+   - Загрузка данных из POST
+   - Сохранение изменений
+   - Редирект на `actionView` при успехе
+5. Рендеринг представления `update.php`
+
+**Возвращает**:
+
+- GET: HTML-форму редактирования
+- POST: редирект на просмотр обновленной записи
+
+### 5. actionDelete($id)
+
+**Назначение**: Удаление записи актуальности букета через POST-запрос (стандартное удаление из формы).
+
+**Параметры**:
+
+- `$id` (int) — ID записи для удаления
+
+**HTTP Method**: POST (ограничено через VerbFilter)
+
+**Алгоритм**:
+
+1. Проверка доступа через `checkAccess()`
+2. Поиск модели через `findModel($id)`
+3. Удаление модели
+4. Редирект на `actionIndex`
+
+**Возвращает**: Редирект на список актуальности букетов
+
+**Исключения**:
+
+- `NotFoundHttpException` — если запись не найдена
+- `MethodNotAllowedHttpException` — если используется не POST метод
+
+### 6. actionAjaxDelete()
+
+**Назначение**: Удаление записи актуальности через AJAX с возвратом JSON-ответа.
+
+**Параметры (POST/GET)**:
+
+- `id` (int) — ID записи для удаления (из POST или GET)
+
+**Формат ответа**: JSON
+
+**Алгоритм**:
+
+1. Установка формата ответа `Response::FORMAT_JSON`
+2. Получение ID из POST или GET параметров
+3. Валидация наличия параметра `id`
+4. Поиск модели через `findModel($id)`
+5. Удаление модели
+6. Возврат JSON-ответа с результатом
+
+**Возвращает**:
+
+- **Успех**:
+```json
+{
+    "success": true,
+    "message": "Запись успешно удалена"
+}
+```
+
+- **Ошибка**:
+```json
+{
+    "success": false,
+    "message": "Текст ошибки"
+}
+```
+
+**Исключения**:
+
+- `BadRequestHttpException` — если параметр `id` не передан
+
+## Вспомогательные методы
+
+### findModel($id)
+
+**Назначение**: Поиск модели MatrixBouquetActuality по первичному ключу.
+
+**Параметры**:
+
+- `$id` (int) — ID записи
+
+**Возвращает**: объект `MatrixBouquetActuality`
+
+**Исключения**:
+
+- `NotFoundHttpException` — если модель не найдена
+
+**Код**:
+
+```php
+protected function findModel($id)
+{
+    if (($model = MatrixBouquetActuality::findOne(['id' => $id])) !== null) {
+        return $model;
+    }
+    throw new NotFoundHttpException('The requested page does not exist.');
+}
+```
+
+### checkAccess()
+
+**Назначение**: Проверка прав доступа текущего пользователя к функционалу контроллера.
+
+**Параметры**: нет
+
+**Возвращает**:
+
+- `null` — если доступ разрешен
+- `Response` — HTML-ответ с главной страницей при отсутствии доступа
+
+**Алгоритм**:
+
+1. Получение `group_id` текущего пользователя
+2. Проверка принадлежности к разрешенным группам
+3. Если доступ запрещен — рендеринг главной страницы `/site/index`
+
+**Код**:
+
+```php
+private function checkAccess()
+{
+    $groupId = Yii::$app->user->identity->group_id;
+
+    if (!in_array($groupId, [
+        AdminGroup::GROUP_IT,
+        AdminGroup::GROUP_BUSH_CHEF_FLORIST,
+        AdminGroup::GROUP_BUSH_DIRECTOR,
+        AdminGroup::GROUP_RS_DIRECTOR,
+    ], true)) {
+        return $this->render('/site/index');
+    }
+
+    return null;
+}
+```
+
+### processBatchActuality($post)
+
+**Назначение**: Обработка массового сохранения периодов актуальности с автоматическим объединением пересекающихся диапазонов.
+
+**Параметры**:
+
+- `$post` (array) — массив данных из формы, где каждый элемент содержит:
+  - `guid` (string) — уникальный идентификатор строки
+  - `bouquet_id` (int) — ID букета
+  - `id` (int|null) — ID существующей записи (если обновление)
+  - `from` (string) — дата начала в формате `YYYY-MM`
+  - `to` (string) — дата окончания в формате `YYYY-MM`
+
+**Возвращает**: void (модифицирует базу данных)
+
+**Алгоритм**:
+
+#### 1. Валидация и подготовка дат
+
+```php
+foreach ($post as $row) {
+    // Пропуск записей без дат
+    if (empty($row['from']) || empty($row['to'])) continue;
+
+    // Парсинг дат из формата YYYY-MM
+    $fromDate = DateTime::createFromFormat('Y-m', $row['from']);
+    $toDate = DateTime::createFromFormat('Y-m', $row['to']);
+
+    // Пропуск невалидных дат
+    if (!$fromDate || !$toDate) continue;
+
+    // Установка полного месяца
+    $fromDate->setDate(Y, m, 1)->setTime(0, 0, 0);
+    $toDate->modify('last day of this month')->setTime(23, 59, 59);
+
+    // Проверка корректности диапазона
+    if ($from > $to) {
+        Yii::warning("GUID {$guid}: пропускаем — from > to");
+        continue;
+    }
+}
+```
+
+#### 2. Обновление существующей записи (если `id` задан)
+
+```php
+if ($id) {
+    // Поиск мастер-записи
+    $master = MatrixBouquetActuality::findOne(['id' => $id, 'bouquet_id' => $bouquetId]);
+
+    // Обновление диапазона
+    $master->date_from = $from;
+    $master->date_to = $to;
+    $master->updated_at = $now;
+    $master->updated_by = $userId;
+    $master->save();
+
+    // Поиск пересекающихся записей
+    $neighbors = MatrixBouquetActuality::find()
+        ->where(['bouquet_id' => $bouquetId])
+        ->andWhere(['<>', 'id', $master->id])
+        ->andWhere('date_to >= :fromAdj', [':fromAdj' => $fromAdj])
+        ->andWhere('date_from <= :toAdj', [':toAdj' => $toAdj])
+        ->all();
+
+    // Объединение диапазонов
+    if (!empty($neighbors)) {
+        // Расширение мастер-записи до границ всех соседей
+        foreach ($neighbors as $nei) {
+            if ($nei->date_from < $master->date_from)
+                $master->date_from = $nei->date_from;
+            if ($nei->date_to > $master->date_to)
+                $master->date_to = $nei->date_to;
+        }
+        $master->save();
+
+        // Удаление объединенных записей
+        foreach ($neighbors as $nei) {
+            $nei->delete();
+        }
+    }
+
+    // Итеративное объединение до полной консолидации
+    while (true) {
+        // Поиск новых пересечений
+        $more = MatrixBouquetActuality::find()
+            ->where(['bouquet_id' => $bouquetId])
+            ->andWhere(['<>', 'id', $master->id])
+            ->andWhere('date_to >= :leftBound')
+            ->andWhere('date_from <= :rightBound')
+            ->all();
+
+        if (empty($more)) break;
+
+        // Расширение и удаление
+        foreach ($more as $nei) {
+            if ($nei->date_from < $master->date_from)
+                $master->date_from = $nei->date_from;
+            if ($nei->date_to > $master->date_to)
+                $master->date_to = $nei->date_to;
+        }
+        $master->save();
+        foreach ($more as $nei) {
+            $nei->delete();
+        }
+    }
+}
+```
+
+#### 3. Создание новой записи (если `id` не задан)
+
+```php
+else {
+    // Поиск пересекающихся периодов
+    $actualities = MatrixBouquetActuality::find()
+        ->where(['bouquet_id' => $bouquetId])
+        ->andWhere('date_to >= :fromAdj', [':fromAdj' => $fromAdj])
+        ->andWhere('date_from <= :toAdj', [':toAdj' => $toAdj])
+        ->all();
+
+    if (empty($actualities)) {
+        // Создание новой записи
+        $new = new MatrixBouquetActuality([
+            'guid' => $guid,
+            'bouquet_id' => $bouquetId,
+            'date_from' => $from,
+            'date_to' => $to,
+            'is_archive' => 0,
+            'created_at' => $now,
+            'created_by' => $userId,
+        ]);
+        $new->save();
+    } else {
+        // Объединение с существующими
+        $master = array_shift($actualities);
+        $master->date_from = $from;
+        $master->date_to = $to;
+        $master->save();
+
+        foreach ($actualities as $actuality) {
+            $actuality->delete();
+        }
+
+        // Итеративное объединение
+        while (true) {
+            $neighbors = MatrixBouquetActuality::find()
+                ->where(['bouquet_id' => $bouquetId])
+                ->andWhere(['<>', 'id', $master->id])
+                ->andWhere('date_to >= :leftBound')
+                ->andWhere('date_from <= :rightBound')
+                ->all();
+
+            if (empty($neighbors)) break;
+
+            foreach ($neighbors as $nei) {
+                if ($nei->date_from < $master->date_from)
+                    $master->date_from = $nei->date_from;
+                if ($nei->date_to > $master->date_to)
+                    $master->date_to = $nei->date_to;
+            }
+            $master->save();
+            foreach ($neighbors as $nei) {
+                $nei->delete();
+            }
+        }
+    }
+}
+```
+
+**Особенности алгоритма**:
+
+1. **Расширение диапазонов на 1 секунду** (`$fromAdj`, `$toAdj`) для корректного поиска примыкающих периодов
+2. **Двухэтапное объединение** — сначала прямые пересечения, затем итеративное объединение до полной консолидации
+3. **Логирование ошибок** через `Yii::error()` и `Yii::warning()`
+4. **Atomic operations** — каждая запись обрабатывается независимо, ошибка в одной не блокирует другие
+
+**Пример**:
+
+```php
+// Исходные периоды для букета 45:
+// Запись 1: 2024-01-01 ... 2024-03-31
+// Запись 2: 2024-05-01 ... 2024-07-31
+
+// Массовое сохранение периода 2024-02-01 ... 2024-06-30:
+$post = [
+    [
+        'guid' => 'abc-123',
+        'bouquet_id' => 45,
+        'from' => '2024-02',
+        'to' => '2024-06'
+    ]
+];
+processBatchActuality($post);
+
+// Результат: одна запись 2024-01-01 ... 2024-07-31
+// (объединены все три периода)
+```
+
+### getMatrixTypeDescendantsIds($rootId)
+
+**Назначение**: Рекурсивное получение всех ID потомков типа матрицы (для фильтрации по группам и подгруппам).
+
+**Параметры**:
+
+- `$rootId` (int) — ID корневого типа матрицы
+
+**Возвращает**: array — список ID всех потомков (включая сам $rootId)
+
+**Алгоритм**:
+
+1. Загрузка всех типов матрицы (id, parent_id) кроме удаленных
+2. Построение массива `$byParent[parent_id] => [child_ids]`
+3. Итеративный обход дерева через стек:
+   - Начало с `$rootId`
+   - Добавление текущего узла в результат
+   - Добавление всех дочерних узлов в стек
+4. Возврат всех найденных ID
+
+**Код**:
+
+```php
+private function getMatrixTypeDescendantsIds(int $rootId): array
+{
+    $all = MatrixType::find()
+        ->select(['id','parent_id'])
+        ->andWhere(['<>', 'deleted', 1])
+        ->asArray()
+        ->all();
+
+    $byParent = [];
+    foreach ($all as $r) {
+        $byParent[(int)$r['parent_id']][] = (int)$r['id'];
+    }
+
+    $out = [];
+    $stack = [$rootId];
+    while ($stack) {
+        $id = array_pop($stack);
+        if (in_array($id, $out, true)) continue;
+        $out[] = $id;
+        if (!empty($byParent[$id])) {
+            foreach ($byParent[$id] as $cid)
+                $stack[] = $cid;
+        }
+    }
+    return $out;
+}
+```
+
+**Пример**:
+
+```php
+// Иерархия:
+// Группа 1
+//   ├── Подгруппа 2
+//   │   └── Подгруппа 4
+//   └── Подгруппа 3
+
+getMatrixTypeDescendantsIds(1); // => [1, 2, 3, 4]
+getMatrixTypeDescendantsIds(2); // => [2, 4]
+```
+
+## Behaviors
+
+### VerbFilter
+
+Ограничивает HTTP-методы для определенных actions:
+
+```php
+'verbs' => [
+    'class' => VerbFilter::className(),
+    'actions' => [
+        'delete' => ['POST'],
+    ],
+]
+```
+
+**Эффект**: `actionDelete()` доступен только через POST-запрос.
+
+## Бизнес-логика и workflow
+
+### Workflow управления актуальностью букетов
+
+```mermaid
+flowchart TD
+    Start[Пользователь открывает страницу] --> CheckAccess{Проверка прав доступа}
+    CheckAccess -->|Нет доступа| Redirect[Редирект на главную]
+    CheckAccess -->|Доступ разрешен| ApplyFilters[Применение фильтров]
+
+    ApplyFilters --> LoadBouquets[Загрузка букетов с периодами]
+    LoadBouquets --> DisplayGrid[Отображение таблицы]
+
+    DisplayGrid --> UserAction{Действие пользователя}
+
+    UserAction -->|Массовое редактирование| BatchSave[processBatchActuality]
+    UserAction -->|Создать период| CreateForm[actionCreate]
+    UserAction -->|Редактировать| UpdateForm[actionUpdate]
+    UserAction -->|Удалить| DeleteAction[actionDelete/actionAjaxDelete]
+
+    BatchSave --> ValidateDates{Валидация дат}
+    ValidateDates -->|Невалидные| Skip[Пропустить запись]
+    ValidateDates -->|Валидные| CheckExisting{Есть ID?}
+
+    CheckExisting -->|Да| UpdateExisting[Обновить существующую]
+    CheckExisting -->|Нет| CheckOverlaps{Есть пересечения?}
+
+    CheckOverlaps -->|Нет| CreateNew[Создать новую запись]
+    CheckOverlaps -->|Да| MergeNew[Объединить с существующими]
+
+    UpdateExisting --> FindNeighbors[Найти пересекающиеся]
+    FindNeighbors --> MergeLoop[Итеративное объединение]
+
+    MergeNew --> MergeLoop
+    MergeLoop --> DeleteMerged[Удалить объединенные]
+
+    CreateNew --> Success[Успех]
+    DeleteMerged --> Success
+
+    Success --> Refresh[Обновление страницы]
+    Refresh --> DisplayGrid
+```
+
+### Алгоритм объединения пересекающихся периодов
+
+```mermaid
+flowchart TD
+    Start[Начало объединения] --> SetMasterBounds[Установить границы мастер-записи]
+    SetMasterBounds --> ExpandBounds[Расширить границы на ±1 секунду]
+
+    ExpandBounds --> FindNeighbors{Найти пересекающиеся<br/>записи}
+    FindNeighbors -->|Нет соседей| End[Завершение]
+    FindNeighbors -->|Есть соседи| ExtendMaster[Расширить мастер до<br/>границ соседей]
+
+    ExtendMaster --> UpdateMaster[Сохранить мастер-запись]
+    UpdateMaster --> DeleteNeighbors[Удалить соседние записи]
+
+    DeleteNeighbors --> ExpandBounds
+```
+
+### Сценарий фильтрации букетов
+
+```mermaid
+sequenceDiagram
+    participant User as Пользователь
+    participant Controller as MatrixBouquetActualityController
+    participant BC as BouquetComposition
+    participant MBA as MatrixBouquetActuality
+    participant MT as MatrixType
+
+    User->>Controller: GET /index?group_id=15&date_from=2024-03&onlyActive=1
+    Controller->>Controller: checkAccess()
+    Controller->>Controller: Инициализация фильтров
+
+    Controller->>MT: getMatrixTypeDescendantsIds(15)
+    MT-->>Controller: [15, 16, 17, 18]
+
+    Controller->>BC: find() + фильтры
+    BC->>MBA: JOIN actualities
+    BC-->>Controller: Список букетов с периодами
+
+    Controller->>Controller: Преобразование в ArrayDataProvider
+    Controller-->>User: Рендеринг таблицы
+```
+
+## Примеры использования
+
+### Пример 1: Фильтрация букетов группы с периодами в марте 2024
+
+```php
+// URL
+GET /matrix-bouquet-actuality/index?group_id=15&date_from=2024-03&date_to=2024-03&onlyActive=1
+
+// Результат: все букеты группы 15 (и её подгрупп),
+// у которых есть периоды актуальности, пересекающиеся с мартом 2024
+```
+
+### Пример 2: Массовое создание периодов актуальности
+
+```php
+// POST запрос
+POST /matrix-bouquet-actuality/index
+
+actuality[0][guid] = "abc-123-456"
+actuality[0][bouquet_id] = 45
+actuality[0][from] = "2024-03"
+actuality[0][to] = "2024-06"
+
+actuality[1][guid] = "def-789-012"
+actuality[1][bouquet_id] = 46
+actuality[1][from] = "2024-04"
+actuality[1][to] = "2024-08"
+
+// Результат: создание/обновление периодов с автоматическим объединением
+```
+
+### Пример 3: Обновление существующего периода с объединением
+
+```php
+// Исходное состояние:
+// Букет 45: [2024-01-01 ... 2024-02-28], [2024-05-01 ... 2024-06-30]
+
+// POST запрос
+POST /matrix-bouquet-actuality/index
+
+actuality[0][id] = 123
+actuality[0][bouquet_id] = 45
+actuality[0][from] = "2024-02"
+actuality[0][to] = "2024-05"
+
+// Результат:
+// Букет 45: [2024-01-01 ... 2024-06-30]
+// (все три периода объединены в один)
+```
+
+### Пример 4: AJAX-удаление периода
+
+```javascript
+// JavaScript
+$.ajax({
+    url: '/matrix-bouquet-actuality/ajax-delete',
+    method: 'POST',
+    data: { id: 123 },
+    dataType: 'json',
+    success: function(response) {
+        if (response.success) {
+            alert(response.message); // "Запись успешно удалена"
+            location.reload();
+        }
+    }
+});
+```
+
+### Пример 5: Поиск букетов без периодов актуальности в определенной группе
+
+```php
+// URL
+GET /matrix-bouquet-actuality/index?group_id=10&onlyInactive=1
+
+// Результат: все букеты группы 10,
+// у которых НЕТ ни одного периода актуальности
+```
+
+## Потенциальные проблемы и рекомендации
+
+### Проблемы
+
+1. **Производительность при больших объемах данных**
+   - Итеративное объединение периодов может выполняться долго при множественных пересечениях
+   - Запросы EXISTS/NOT EXISTS в фильтрах могут быть медленными
+
+2. **Отсутствие транзакций**
+   - `processBatchActuality()` не использует транзакции, что может привести к частичным сохранениям при ошибках
+
+3. **Дублирование кода**
+   - Логика объединения периодов повторяется для случаев с `id` и без `id`
+
+4. **Недостаточная валидация**
+   - Нет проверки на отрицательные ID букетов
+   - Нет валидации формата GUID
+
+5. **N+1 запросы**
+   - При загрузке букетов с периодами может возникнуть проблема N+1 (частично решена через `with(['actualities'])`)
+
+### Рекомендации
+
+1. **Добавить транзакции**:
+```php
+protected function processBatchActuality(array $post)
+{
+    $transaction = Yii::$app->db->beginTransaction();
+    try {
+        // ... логика обработки ...
+        $transaction->commit();
+    } catch (\Exception $e) {
+        $transaction->rollBack();
+        throw $e;
+    }
+}
+```
+
+2. **Вынести логику объединения в отдельный метод**:
+```php
+protected function mergePeriods(MatrixBouquetActuality $master, int $bouquetId): void
+{
+    // Общая логика итеративного объединения
+}
+```
+
+3. **Добавить индексы**:
+```sql
+CREATE INDEX idx_matrix_bouquet_actuality_bouquet_dates
+ON matrix_bouquet_actuality(bouquet_id, date_from, date_to);
+
+CREATE INDEX idx_matrix_bouquet_actuality_is_archive
+ON matrix_bouquet_actuality(is_archive);
+```
+
+4. **Использовать Batch Insert для массового создания**:
+```php
+Yii::$app->db->createCommand()->batchInsert(
+    'matrix_bouquet_actuality',
+    ['guid', 'bouquet_id', 'date_from', 'date_to', 'created_at', 'created_by'],
+    $rows
+)->execute();
+```
+
+5. **Добавить валидацию GUID**:
+```php
+if (!preg_match('/^[a-zA-Z0-9\-]+$/', $row['guid'])) {
+    continue;
+}
+```
+
+6. **Кэширование справочников**:
+```php
+$groups = Yii::$app->cache->getOrSet('matrix_types_groups', function() {
+    return MatrixType::find()
+        ->select(['id','name'])
+        ->where(['parent_id' => null])
+        ->andWhere(['<>', 'deleted', 1])
+        ->asArray()
+        ->all();
+}, 3600);
+```
+
+## Связи с другими компонентами системы
+
+### Зависимости от других контроллеров
+
+Нет прямых зависимостей от других контроллеров.
+
+### Зависимости от сервисов
+
+Контроллер не использует внешние сервисы, вся логика реализована внутри.
+
+### Зависимости от jobs
+
+Нет зависимостей от фоновых задач.
+
+### Зависимости от helpers
+
+- `yii\helpers\ArrayHelper::map()` — преобразование массивов для dropdown
+
+### Используется в
+
+Контроллер не вызывается напрямую из других компонентов, только через HTTP-запросы из UI.
+
+## Диаграмма архитектуры
+
+```mermaid
+graph TB
+    subgraph "MatrixBouquetActualityController"
+        Index[actionIndex<br/>Фильтрация и массовое<br/>редактирование]
+        View[actionView<br/>Просмотр]
+        Create[actionCreate<br/>Создание]
+        Update[actionUpdate<br/>Обновление]
+        Delete[actionDelete<br/>Удаление]
+        AjaxDelete[actionAjaxDelete<br/>AJAX удаление]
+
+        CheckAccess[checkAccess<br/>Проверка прав]
+        FindModel[findModel<br/>Поиск модели]
+        ProcessBatch[processBatchActuality<br/>Массовое сохранение]
+        GetDescendants[getMatrixTypeDescendantsIds<br/>Получение потомков]
+    end
+
+    subgraph "Models"
+        MBA[MatrixBouquetActuality]
+        BC[BouquetComposition]
+        MT[MatrixType]
+        BCMTH[BouquetCompositionMatrixTypeHistory]
+        AG[AdminGroup]
+    end
+
+    Index --> CheckAccess
+    View --> CheckAccess
+    Create --> CheckAccess
+    Update --> CheckAccess
+    Delete --> CheckAccess
+
+    Index --> ProcessBatch
+    Index --> GetDescendants
+    View --> FindModel
+    Update --> FindModel
+    Delete --> FindModel
+    AjaxDelete --> FindModel
+
+    ProcessBatch --> MBA
+    Index --> BC
+    Index --> MT
+    GetDescendants --> MT
+    CheckAccess --> AG
+
+    BC --> MBA
+    BC --> BCMTH
+    MT --> BCMTH
+
+    style Index fill:#e1f5ff
+    style ProcessBatch fill:#fff4e1
+    style CheckAccess fill:#ffe1e1
+```
+
+## Заключение
+
+**MatrixBouquetActualityController** — это специализированный контроллер для управления временными периодами актуальности букетов в матрице продаж. Основная уникальность контроллера — сложная логика автоматического объединения пересекающихся и смежных временных диапазонов, которая обеспечивает отсутствие дублирований и пересечений периодов для одного букета.
+
+### Ключевые особенности:
+
+1. **Строгий контроль доступа** — только 4 группы пользователей
+2. **Интеллектуальное объединение периодов** — автоматическая консолидация пересекающихся диапазонов
+3. **Массовое редактирование** — возможность обновления множества периодов за один запрос
+4. **Гибкая фильтрация** — по группам, датам, статусу актуальности, архивности
+5. **Иерархическая навигация** — поддержка групп и подгрупп матрицы типов
+
+### Применение:
+
+Контроллер используется для планирования ассортимента букетов, определения сезонности продуктов, управления актуальностью предложений в различные периоды года.
diff --git a/erp24/docs/controllers/non-standard/MatrixBouquetActualityController_QUICK_REFERENCE.md b/erp24/docs/controllers/non-standard/MatrixBouquetActualityController_QUICK_REFERENCE.md
new file mode 100644 (file)
index 0000000..5215add
--- /dev/null
@@ -0,0 +1,800 @@
+# MatrixBouquetActualityController - Краткая справка
+
+## Общая информация
+
+**Контроллер**: `MatrixBouquetActualityController`
+**Namespace**: `app\controllers`
+**Extends**: `yii\web\Controller`
+**Путь**: `/Users/vladfo/development/yii-erp24/erp24/controllers/MatrixBouquetActualityController.php`
+
+## Назначение
+
+Управление временными периодами актуальности букетов в матрице продаж. Контроллер позволяет определять, когда букет доступен для продажи, с автоматическим объединением пересекающихся периодов.
+
+## Бизнес-функции
+
+1. ✅ **Определение периодов актуальности** — задание дат начала и окончания для каждого букета
+2. ✅ **Массовое редактирование** — одновременное обновление периодов для множества букетов
+3. ✅ **Автоматическое объединение** — консолидация пересекающихся и смежных временных диапазонов
+4. ✅ **Фильтрация** — по группам матрицы, датам, статусу актуальности, архивности
+5. ✅ **Иерархическая навигация** — работа с группами и подгруппами типов матрицы
+
+---
+
+## Quick Access
+
+### Routes
+
+| Route | Method | Описание |
+|-------|--------|----------|
+| `/matrix-bouquet-actuality/index` | GET | Список букетов с фильтрацией |
+| `/matrix-bouquet-actuality/index` | POST | Массовое сохранение периодов |
+| `/matrix-bouquet-actuality/view?id={id}` | GET | Просмотр одной записи |
+| `/matrix-bouquet-actuality/create` | GET/POST | Создание записи |
+| `/matrix-bouquet-actuality/update?id={id}` | GET/POST | Обновление записи |
+| `/matrix-bouquet-actuality/delete?id={id}` | POST | Удаление записи |
+| `/matrix-bouquet-actuality/ajax-delete` | POST/GET | AJAX удаление |
+
+### Доступ
+
+**Разрешенные группы**:
+- IT (`AdminGroup::GROUP_IT`)
+- Руководитель букетных флористов (`AdminGroup::GROUP_BUSH_CHEF_FLORIST`)
+- Директор букетного направления (`AdminGroup::GROUP_BUSH_DIRECTOR`)
+- Директор розничных продаж (`AdminGroup::GROUP_RS_DIRECTOR`)
+
+**Поведение при отсутствии доступа**: Редирект на главную страницу `/site/index`.
+
+---
+
+## Use Cases
+
+### Use Case 1: Установка сезонности букета
+
+**Задача**: Определить, что букет "Тюльпаны микс" актуален только с марта по май 2024.
+
+**Решение**:
+
+```http
+POST /matrix-bouquet-actuality/index
+Content-Type: application/x-www-form-urlencoded
+
+actuality[0][guid]=tulips-spring-2024
+actuality[0][bouquet_id]=127
+actuality[0][from]=2024-03
+actuality[0][to]=2024-05
+```
+
+**Результат**: Создана запись с периодом `2024-03-01 00:00:00` до `2024-05-31 23:59:59`.
+
+---
+
+### Use Case 2: Расширение периода актуальности
+
+**Задача**: Букет "Пионы" был актуален с мая по июнь, теперь нужно продлить до июля.
+
+**Текущее состояние**:
+- Запись #45: `2024-05-01 ... 2024-06-30`
+
+**Решение**:
+
+```http
+POST /matrix-bouquet-actuality/index
+
+actuality[0][id]=45
+actuality[0][guid]=peonies-2024
+actuality[0][bouquet_id]=88
+actuality[0][from]=2024-05
+actuality[0][to]=2024-07
+```
+
+**Результат**: Запись #45 обновлена: `2024-05-01 ... 2024-07-31`.
+
+---
+
+### Use Case 3: Объединение пересекающихся периодов
+
+**Задача**: У букета "Розы красные" два периода: март-апрель и май-июнь. Нужно добавить период апрель-май, что должно объединить все три в один.
+
+**Текущее состояние**:
+- Запись #10: `2024-03-01 ... 2024-04-30`
+- Запись #11: `2024-05-01 ... 2024-06-30`
+
+**Решение**:
+
+```http
+POST /matrix-bouquet-actuality/index
+
+actuality[0][guid]=roses-bridge
+actuality[0][bouquet_id]=55
+actuality[0][from]=2024-04
+actuality[0][to]=2024-05
+```
+
+**Результат**: Одна запись `2024-03-01 ... 2024-06-30` (записи #10 и #11 удалены).
+
+---
+
+### Use Case 4: Фильтрация букетов группы без периодов актуальности
+
+**Задача**: Найти все букеты группы "Розы", у которых НЕ заданы периоды актуальности.
+
+**Решение**:
+
+```http
+GET /matrix-bouquet-actuality/index?group_id=15&onlyInactive=1
+```
+
+**Результат**: Список всех букетов группы "Розы" (ID=15) и её подгрупп, у которых нет ни одного периода актуальности.
+
+---
+
+### Use Case 5: Анализ актуальности на конкретный период
+
+**Задача**: Показать все букеты, которые были актуальны в марте 2024.
+
+**Решение**:
+
+```http
+GET /matrix-bouquet-actuality/index?date_from=2024-03&date_to=2024-03&onlyActive=1
+```
+
+**Результат**: Список всех букетов, у которых есть периоды актуальности, пересекающиеся с мартом 2024.
+
+---
+
+### Use Case 6: AJAX-удаление периода
+
+**Задача**: Удалить период актуальности через AJAX без перезагрузки страницы.
+
+**Решение**:
+
+```javascript
+fetch('/matrix-bouquet-actuality/ajax-delete', {
+    method: 'POST',
+    headers: {
+        'Content-Type': 'application/x-www-form-urlencoded',
+    },
+    body: 'id=123'
+})
+.then(response => response.json())
+.then(data => {
+    if (data.success) {
+        document.getElementById('row-123').remove();
+        showNotification('Период актуальности удален');
+    } else {
+        showError(data.message);
+    }
+});
+```
+
+**Результат**: Запись удалена, строка убрана из таблицы.
+
+---
+
+## Диаграммы
+
+### Диаграмма 1: Workflow управления актуальностью
+
+```mermaid
+flowchart TD
+    Start[Пользователь открывает<br/>страницу] --> CheckAccess{Проверка<br/>прав доступа}
+    CheckAccess -->|Нет доступа| Redirect[Редирект на<br/>главную страницу]
+    CheckAccess -->|Доступ разрешен| ApplyFilters[Применение<br/>фильтров]
+
+    ApplyFilters --> LoadData{Фильтры<br/>применены?}
+    LoadData -->|Нет| LoadAll[Загрузка всех<br/>букетов]
+    LoadData -->|Да| LoadFiltered[Загрузка<br/>отфильтрованных<br/>букетов]
+
+    LoadAll --> DisplayGrid[Отображение<br/>таблицы]
+    LoadFiltered --> DisplayGrid
+
+    DisplayGrid --> UserAction{Действие<br/>пользователя}
+
+    UserAction -->|Массовое<br/>редактирование| BatchSave[processBatchActuality]
+    UserAction -->|Создать период| CreateForm[actionCreate]
+    UserAction -->|Редактировать| UpdateForm[actionUpdate]
+    UserAction -->|Удалить| DeleteAction[actionDelete/<br/>actionAjaxDelete]
+
+    BatchSave --> ValidateDates{Валидация<br/>дат}
+    ValidateDates -->|Невалидные| Skip[Пропустить<br/>запись]
+    ValidateDates -->|Валидные| CheckExisting{Есть ID?}
+
+    CheckExisting -->|Да| UpdateExisting[Обновить<br/>существующую]
+    CheckExisting -->|Нет| CheckOverlaps{Есть<br/>пересечения?}
+
+    CheckOverlaps -->|Нет| CreateNew[Создать<br/>новую запись]
+    CheckOverlaps -->|Да| MergeNew[Объединить<br/>с существующими]
+
+    UpdateExisting --> FindNeighbors[Найти<br/>пересекающиеся]
+    FindNeighbors --> MergeLoop[Итеративное<br/>объединение]
+
+    MergeNew --> MergeLoop
+    MergeLoop --> DeleteMerged[Удалить<br/>объединенные]
+
+    CreateNew --> Success[Успех]
+    DeleteMerged --> Success
+
+    Success --> Refresh[Flash-сообщение +<br/>обновление страницы]
+    Refresh --> DisplayGrid
+
+    style CheckAccess fill:#ffe1e1
+    style BatchSave fill:#fff4e1
+    style MergeLoop fill:#e1f5ff
+    style Success fill:#e1ffe1
+```
+
+---
+
+### Диаграмма 2: Алгоритм объединения периодов
+
+```mermaid
+flowchart TD
+    Start[Начало объединения<br/>для букета] --> SetMaster[Установить<br/>мастер-запись]
+    SetMaster --> ExpandBounds[Расширить границы<br/>на ±1 секунду]
+
+    ExpandBounds --> FindNeighbors{Найти<br/>пересекающиеся<br/>записи}
+    FindNeighbors -->|Нет соседей| End[Завершение]
+    FindNeighbors -->|Есть соседи| ExtendMaster[Расширить мастер<br/>до границ соседей]
+
+    ExtendMaster --> CheckBounds{Границы<br/>изменились?}
+    CheckBounds -->|Нет| DeleteNeighbors[Удалить соседние<br/>записи]
+    CheckBounds -->|Да| UpdateMaster[Сохранить<br/>мастер-запись]
+
+    UpdateMaster --> DeleteNeighbors
+    DeleteNeighbors --> ExpandBounds
+
+    style SetMaster fill:#e1f5ff
+    style ExtendMaster fill:#fff4e1
+    style End fill:#e1ffe1
+```
+
+---
+
+### Диаграмма 3: Фильтрация по иерархии групп
+
+```mermaid
+graph TD
+    User[Пользователь выбирает<br/>группу MatrixType ID=1] --> Controller[actionIndex]
+    Controller --> GetDescendants[getMatrixTypeDescendantsIds<br/>rootId=1]
+
+    GetDescendants --> LoadAll[Загрузить все типы<br/>MatrixType]
+    LoadAll --> BuildTree[Построить массив<br/>byParent]
+
+    BuildTree --> Traverse[Обход дерева<br/>через стек]
+    Traverse --> CollectIDs[Собрать все ID<br/>потомков]
+
+    CollectIDs --> Result[Результат:<br/>1, 2, 3, 4, 5]
+
+    Result --> FindHistory[Найти историю<br/>BouquetCompositionMatrixTypeHistory<br/>для ID 1,2,3,4,5]
+    FindHistory --> ExtractBouquets[Извлечь уникальные<br/>bouquet_id]
+
+    ExtractBouquets --> FilterBouquets[Фильтровать<br/>BouquetComposition<br/>по bouquet_id]
+
+    FilterBouquets --> DisplayResults[Отобразить<br/>отфильтрованные букеты]
+
+    style GetDescendants fill:#e1f5ff
+    style BuildTree fill:#fff4e1
+    style DisplayResults fill:#e1ffe1
+```
+
+---
+
+### Диаграмма 4: Структура данных
+
+```mermaid
+erDiagram
+    BouquetComposition ||--o{ MatrixBouquetActuality : "имеет периоды"
+    BouquetComposition ||--o{ BouquetCompositionMatrixTypeHistory : "принадлежит типам"
+    MatrixType ||--o{ BouquetCompositionMatrixTypeHistory : "содержит букеты"
+    MatrixType ||--o{ MatrixType : "parent_id (иерархия)"
+    BouquetComposition ||--o| Price : "имеет цену"
+
+    BouquetComposition {
+        int id PK
+        string name
+        text description
+    }
+
+    MatrixBouquetActuality {
+        int id PK
+        string guid UK
+        int bouquet_id FK
+        datetime date_from
+        datetime date_to
+        int is_archive
+        datetime created_at
+        int created_by
+        datetime updated_at
+        int updated_by
+    }
+
+    BouquetCompositionMatrixTypeHistory {
+        int id PK
+        int bouquet_id FK
+        int matrix_type_id FK
+        boolean is_active
+    }
+
+    MatrixType {
+        int id PK
+        string name
+        int parent_id FK
+        int deleted
+    }
+
+    Price {
+        int id PK
+        int bouquet_id FK
+        decimal price
+    }
+```
+
+---
+
+### Диаграмма 5: Sequence - Массовое сохранение
+
+```mermaid
+sequenceDiagram
+    participant User as Пользователь
+    participant Controller as MatrixBouquetActualityController
+    participant Batch as processBatchActuality
+    participant MBA as MatrixBouquetActuality
+
+    User->>Controller: POST /index<br/>actuality[] data
+    Controller->>Controller: checkAccess()
+    Controller->>Batch: processBatchActuality($post)
+
+    loop Для каждой записи в $post
+        Batch->>Batch: Валидация дат
+        alt Даты невалидны
+            Batch->>Batch: continue (пропустить)
+        else Даты валидны
+            Batch->>Batch: Преобразовать месяцы<br/>в полные диапазоны
+
+            alt ID задан (обновление)
+                Batch->>MBA: findOne(['id' => $id])
+                MBA-->>Batch: $master
+                Batch->>MBA: Обновить date_from, date_to
+                Batch->>MBA: findAll (пересекающиеся)
+                MBA-->>Batch: $neighbors[]
+
+                loop Итеративное объединение
+                    Batch->>Batch: Расширить границы мастера
+                    Batch->>MBA: save($master)
+                    Batch->>MBA: delete($neighbors)
+                    Batch->>MBA: findAll (новые пересечения)
+                    alt Нет новых пересечений
+                        Batch->>Batch: break
+                    end
+                end
+
+            else ID не задан (создание)
+                Batch->>MBA: findAll (пересекающиеся)
+                MBA-->>Batch: $actualities[]
+
+                alt Нет пересечений
+                    Batch->>MBA: new MatrixBouquetActuality
+                    Batch->>MBA: save()
+                else Есть пересечения
+                    Batch->>Batch: Взять первую как мастера
+                    Batch->>MBA: Обновить мастера
+                    Batch->>MBA: delete($остальные)
+
+                    loop Итеративное объединение
+                        Batch->>MBA: findAll (новые пересечения)
+                        alt Есть новые
+                            Batch->>Batch: Расширить мастера
+                            Batch->>MBA: save($master)
+                            Batch->>MBA: delete($новые)
+                        else Нет новых
+                            Batch->>Batch: break
+                        end
+                    end
+                end
+            end
+        end
+    end
+
+    Batch-->>Controller: void
+    Controller->>User: Flash: "Данные успешно сохранены"
+    Controller->>User: redirect()->refresh()
+```
+
+---
+
+## Code Examples
+
+### Пример 1: Создание периода актуальности через форму
+
+```php
+// Controller action
+public function actionMyCustomCreate()
+{
+    $model = new MatrixBouquetActuality();
+    $model->bouquet_id = 45;
+    $model->date_from = '2024-03-01 00:00:00';
+    $model->date_to = '2024-06-30 23:59:59';
+    $model->is_archive = 0;
+    $model->guid = 'custom-guid-' . uniqid();
+    $model->created_at = date('Y-m-d H:i:s');
+    $model->created_by = Yii::$app->user->id;
+
+    if ($model->save()) {
+        Yii::$app->session->setFlash('success', 'Период актуальности создан');
+        return $this->redirect(['view', 'id' => $model->id]);
+    } else {
+        Yii::$app->session->setFlash('error', 'Ошибка: ' . json_encode($model->errors));
+    }
+}
+```
+
+---
+
+### Пример 2: Программное объединение периодов
+
+```php
+// Объединение всех периодов для букета 45
+$bouquetId = 45;
+$periods = MatrixBouquetActuality::find()
+    ->where(['bouquet_id' => $bouquetId])
+    ->orderBy(['date_from' => SORT_ASC])
+    ->all();
+
+if (count($periods) > 1) {
+    $master = array_shift($periods);
+    $master->date_from = min(array_column($periods, 'date_from'));
+    $master->date_to = max(array_column($periods, 'date_to'));
+    $master->updated_at = date('Y-m-d H:i:s');
+    $master->updated_by = Yii::$app->user->id;
+    $master->save();
+
+    foreach ($periods as $period) {
+        $period->delete();
+    }
+}
+```
+
+---
+
+### Пример 3: Проверка актуальности букета на дату
+
+```php
+// Проверить, актуален ли букет 45 на 15 марта 2024
+$bouquetId = 45;
+$checkDate = '2024-03-15 12:00:00';
+
+$isActive = MatrixBouquetActuality::find()
+    ->where(['bouquet_id' => $bouquetId])
+    ->andWhere(['<=', 'date_from', $checkDate])
+    ->andWhere(['>=', 'date_to', $checkDate])
+    ->exists();
+
+if ($isActive) {
+    echo "Букет актуален на {$checkDate}";
+} else {
+    echo "Букет НЕ актуален на {$checkDate}";
+}
+```
+
+---
+
+### Пример 4: Получение всех актуальных букетов на текущую дату
+
+```php
+$now = date('Y-m-d H:i:s');
+
+$activeBouquets = BouquetComposition::find()
+    ->alias('bc')
+    ->joinWith(['actualities a' => function($query) use ($now) {
+        $query->andWhere(['<=', 'a.date_from', $now])
+              ->andWhere(['>=', 'a.date_to', $now])
+              ->andWhere(['a.is_archive' => 0]);
+    }])
+    ->all();
+
+foreach ($activeBouquets as $bouquet) {
+    echo $bouquet->name . "\n";
+}
+```
+
+---
+
+### Пример 5: AJAX-фильтрация через JavaScript
+
+```javascript
+// Применение фильтров через AJAX
+function applyFilters() {
+    const groupId = $('#filter-group-id').val();
+    const dateFrom = $('#filter-date-from').val();
+    const dateTo = $('#filter-date-to').val();
+    const onlyActive = $('#filter-only-active').is(':checked') ? 1 : 0;
+
+    const params = new URLSearchParams({
+        group_id: groupId,
+        date_from: dateFrom,
+        date_to: dateTo,
+        onlyActive: onlyActive
+    });
+
+    window.location.href = `/matrix-bouquet-actuality/index?${params.toString()}`;
+}
+
+// Привязка к кнопке
+$('#btn-apply-filters').on('click', applyFilters);
+```
+
+---
+
+### Пример 6: Массовое создание периодов через консольную команду
+
+```php
+// console/controllers/BouquetActualityController.php
+namespace console\controllers;
+
+use yii\console\Controller;
+use yii_app\records\MatrixBouquetActuality;
+use yii_app\records\BouquetComposition;
+
+class BouquetActualityController extends Controller
+{
+    /**
+     * Создать периоды актуальности для всех букетов на весь год
+     * php yii bouquet-actuality/create-year-periods 2024
+     */
+    public function actionCreateYearPeriods($year)
+    {
+        $bouquets = BouquetComposition::find()->all();
+        $dateFrom = "{$year}-01-01 00:00:00";
+        $dateTo = "{$year}-12-31 23:59:59";
+
+        foreach ($bouquets as $bouquet) {
+            // Проверить, есть ли уже период
+            $exists = MatrixBouquetActuality::find()
+                ->where(['bouquet_id' => $bouquet->id])
+                ->andWhere(['>=', 'date_to', $dateFrom])
+                ->andWhere(['<=', 'date_from', $dateTo])
+                ->exists();
+
+            if (!$exists) {
+                $actuality = new MatrixBouquetActuality([
+                    'bouquet_id' => $bouquet->id,
+                    'date_from' => $dateFrom,
+                    'date_to' => $dateTo,
+                    'is_archive' => 0,
+                    'guid' => "year-{$year}-bouquet-{$bouquet->id}",
+                    'created_at' => date('Y-m-d H:i:s'),
+                    'created_by' => 1,
+                ]);
+                $actuality->save();
+                $this->stdout("Создан период для букета {$bouquet->name}\n");
+            }
+        }
+
+        $this->stdout("Готово!\n");
+    }
+}
+```
+
+---
+
+## FAQ
+
+### Q1: Как работает автоматическое объединение периодов?
+
+**A**: При сохранении периода система:
+1. Расширяет границы нового периода на ±1 секунду
+2. Ищет все записи, которые пересекаются с расширенными границами
+3. Объединяет их, расширяя мастер-запись до самых дальних границ
+4. Удаляет объединенные записи
+5. Повторяет процесс, пока не останется пересечений
+
+**Пример**:
+```
+Исходные периоды:
+- 2024-01-01 ... 2024-02-28
+- 2024-05-01 ... 2024-06-30
+
+Новый период: 2024-02-01 ... 2024-05-31
+
+Результат: 2024-01-01 ... 2024-06-30 (все три объединены)
+```
+
+---
+
+### Q2: Можно ли создать пересекающиеся периоды для одного букета?
+
+**A**: Нет, система автоматически объединяет пересекающиеся периоды. Если вам нужны раздельные периоды, создайте их с гарантированным разрывом (например, 2024-01-01...2024-02-28 и 2024-03-02...2024-04-30).
+
+---
+
+### Q3: Почему фильтр по группе показывает букеты из подгрупп?
+
+**A**: Фильтр по группе использует метод `getMatrixTypeDescendantsIds()`, который рекурсивно собирает все ID потомков. Это позволяет показать все букеты, принадлежащие группе и её подгруппам.
+
+**Чтобы показать только конкретную группу**, используйте фильтр `subgroup_id` вместо `group_id`.
+
+---
+
+### Q4: Как удалить все периоды актуальности для букета?
+
+**A**: Через SQL:
+```sql
+DELETE FROM matrix_bouquet_actuality WHERE bouquet_id = 45;
+```
+
+Или через PHP:
+```php
+MatrixBouquetActuality::deleteAll(['bouquet_id' => 45]);
+```
+
+---
+
+### Q5: Можно ли задать периоды с точностью до дня, а не до месяца?
+
+**A**: Да, через форму создания (`actionCreate`) или обновления (`actionUpdate`). В форме можно задать точные даты и время.
+
+**Однако**, при массовом редактировании через `actionIndex` (POST) используются только месяцы (YYYY-MM), которые автоматически расширяются до полного месяца.
+
+---
+
+### Q6: Что происходит при обновлении периода с ID, если есть пересечения?
+
+**A**: Система обновляет запись с заданным ID, затем ищет все пересекающиеся записи и объединяет их с обновленной записью. Все пересекающиеся записи удаляются, остается только одна обновленная запись с расширенными границами.
+
+---
+
+### Q7: Почему в фильтре дат используются месяцы, а не полные даты?
+
+**A**: Это упрощает пользовательский интерфейс для сезонного планирования. Большинство периодов актуальности букетов соответствуют месяцам или кварталам, а не конкретным дням.
+
+**Для точной фильтрации** можно использовать прямые SQL-запросы или расширить фильтры.
+
+---
+
+### Q8: Как проверить, кто создал/обновил период актуальности?
+
+**A**: Используйте `actionView` для просмотра метаданных:
+- `created_at`, `created_by` — дата и пользователь создания
+- `updated_at`, `updated_by` — дата и пользователь последнего обновления
+
+---
+
+### Q9: Можно ли восстановить удаленные периоды?
+
+**A**: Нет, удаление безвозвратное (hard delete). Контроллер не использует soft delete. Рекомендуется использовать флаг `is_archive` вместо удаления для сохранения истории.
+
+---
+
+### Q10: Как ограничить доступ к контроллеру для других групп пользователей?
+
+**A**: Добавьте нужные группы в массив проверки метода `checkAccess()`:
+
+```php
+private function checkAccess()
+{
+    $groupId = Yii::$app->user->identity->group_id;
+
+    if (!in_array($groupId, [
+        AdminGroup::GROUP_IT,
+        AdminGroup::GROUP_BUSH_CHEF_FLORIST,
+        AdminGroup::GROUP_BUSH_DIRECTOR,
+        AdminGroup::GROUP_RS_DIRECTOR,
+        AdminGroup::GROUP_YOUR_NEW_GROUP, // Добавьте здесь
+    ], true)) {
+        return $this->render('/site/index');
+    }
+
+    return null;
+}
+```
+
+---
+
+## Рекомендации и Best Practices
+
+### ✅ Рекомендуется
+
+1. **Используйте массовое редактирование** для обновления периодов — оно автоматически объединяет пересечения
+2. **Задавайте периоды месяцами** для удобства планирования
+3. **Проверяйте фильтры** перед массовым сохранением, чтобы не изменить лишние записи
+4. **Используйте is_archive** вместо удаления для сохранения истории
+5. **Кэшируйте справочники** MatrixType для ускорения фильтрации
+
+### ❌ Не рекомендуется
+
+1. **Создавать периоды вручную через SQL** — можете нарушить целостность данных
+2. **Использовать GET для удаления** (через `actionAjaxDelete`) — потенциальная уязвимость CSRF
+3. **Игнорировать проверку доступа** — все actions защищены, не обходите её
+4. **Создавать большое количество мелких периодов** — используйте объединение
+5. **Удалять периоды без архивирования** — лучше использовать флаг `is_archive`
+
+---
+
+## Производительность
+
+### Оптимизации
+
+1. **Индексы**:
+```sql
+CREATE INDEX idx_mba_bouquet_dates ON matrix_bouquet_actuality(bouquet_id, date_from, date_to);
+CREATE INDEX idx_mba_is_archive ON matrix_bouquet_actuality(is_archive);
+CREATE INDEX idx_mba_guid ON matrix_bouquet_actuality(guid);
+```
+
+2. **Eager Loading**:
+```php
+// Вместо N+1 запросов
+$bouquets = BouquetComposition::find()
+    ->with(['actualities', 'priceRel'])
+    ->all();
+```
+
+3. **Кэширование справочников**:
+```php
+$groups = Yii::$app->cache->getOrSet('matrix_types_groups', function() {
+    return MatrixType::find()
+        ->select(['id','name'])
+        ->where(['parent_id' => null])
+        ->asArray()
+        ->all();
+}, 3600);
+```
+
+### Узкие места
+
+1. **Итеративное объединение** — может быть медленным при множественных пересечениях
+2. **EXISTS/NOT EXISTS подзапросы** — могут тормозить на больших таблицах
+3. **Отсутствие транзакций** — риск частичных сохранений при ошибках
+
+---
+
+## Связанные компоненты
+
+### Models
+- `MatrixBouquetActuality` — основная модель
+- `BouquetComposition` — модель букетов
+- `MatrixType` — иерархия типов матрицы
+- `BouquetCompositionMatrixTypeHistory` — история связей букетов с типами
+
+### Views
+- `views/matrix-bouquet-actuality/index.php` — список с фильтрацией
+- `views/matrix-bouquet-actuality/view.php` — просмотр одной записи
+- `views/matrix-bouquet-actuality/create.php` — форма создания
+- `views/matrix-bouquet-actuality/update.php` — форма редактирования
+- `views/matrix-bouquet-actuality/_form.php` — общая форма
+
+### Migrations
+```sql
+-- Создание таблицы (пример)
+CREATE TABLE matrix_bouquet_actuality (
+    id SERIAL PRIMARY KEY,
+    guid VARCHAR(255) UNIQUE,
+    bouquet_id INT REFERENCES bouquet_composition(id),
+    date_from TIMESTAMP NOT NULL,
+    date_to TIMESTAMP NOT NULL,
+    is_archive INT DEFAULT 0,
+    created_at TIMESTAMP,
+    created_by INT,
+    updated_at TIMESTAMP,
+    updated_by INT
+);
+```
+
+---
+
+## Заключение
+
+**MatrixBouquetActualityController** — это мощный инструмент для управления сезонностью и актуальностью букетов в матрице продаж. Основная уникальность — автоматическое объединение пересекающихся периодов, что гарантирует отсутствие дублирований и упрощает планирование.
+
+### Ключевые преимущества:
+
+✅ Автоматическое объединение пересекающихся периодов
+✅ Массовое редактирование с валидацией
+✅ Гибкая фильтрация по группам, датам, статусу
+✅ Строгий контроль доступа
+✅ Иерархическая навигация по типам матрицы
+
+### Применение:
+
+Используется для планирования ассортимента букетов, определения сезонности продуктов, управления актуальностью предложений в различные периоды года.
diff --git a/erp24/docs/controllers/non-standard/MatrixErpController_ACTIONS_TABLE.md b/erp24/docs/controllers/non-standard/MatrixErpController_ACTIONS_TABLE.md
new file mode 100644 (file)
index 0000000..6d2d051
--- /dev/null
@@ -0,0 +1,1022 @@
+# MatrixErpController - Таблица всех Actions
+
+## Обзор
+
+Контроллер содержит **16 методов** (7 публичных actions, 1 статический метод, 8 вспомогательных методов).
+
+---
+
+## Публичные Actions
+
+| # | Action | HTTP | Назначение | RBAC | Параметры |
+|---|--------|------|-----------|------|-----------|
+| 1 | `actionIndex()` | GET, POST | Список товаров матрицы с фильтрацией и автосинхронизацией | ❌ Нет | `activeFilter`, `feedActiveFilter`, `nameFilter`, `groupNameFilter`, `articuleFilter` |
+| 2 | `actionView($id)` | GET | Просмотр детальной информации о товаре | ❌ Нет | `$id` (int) |
+| 3 | `actionCreate()` | GET, POST | Создание нового товара в матрице | ❌ Нет | POST: поля MatrixErp |
+| 4 | `actionUpdate($id)` | GET, POST | Редактирование товара и его свойств | ❌ Нет | `$id` (int) |
+| 5 | `actionDelete($id)` | POST | Мягкое удаление товара (soft delete) | ❌ Нет | `$id` (int) |
+| 6 | `actionToggleFeedActive()` | POST (AJAX) | Переключение активности товара в фиде | ❌ Нет | `id` (int), `is_feed_active` (int) |
+| 7 | `actionParseFlowwowCards()` | GET, POST | Импорт товаров из HTML-файлов Flowwow | ❌ Нет | `files[]`, `category`, `subcategory` |
+
+---
+
+## Вспомогательные методы
+
+| # | Метод | Видимость | Назначение | Параметры | Возврат |
+|---|-------|-----------|-----------|-----------|---------|
+| 8 | `fillInGuidsFromMarketplaceAndAdditional()` | public static | Синхронизация товаров из Products1c в MatrixErp | - | void |
+| 9 | `findModel($id)` | protected | Поиск товара по ID с выбросом исключения | `$id` (int) | MatrixErp |
+| 10 | `processFile($file, $category, $subcategory)` | protected | Обработка одного HTML-файла | `$file` (UploadedFile), `$category` (string), `$subcategory` (string) | array |
+| 11 | `normalizeParsed($parsed)` | protected | Нормализация результатов парсинга | `$parsed` (mixed) | array |
+| 12 | `isAssoc($arr)` | protected | Проверка ассоциативности массива | `$arr` (array) | bool |
+| 13 | `extractArticule($name)` | protected | Извлечение артикула из названия | `$name` (string) | string\|null |
+| 14 | `upsertProperty($matrix, $matrixProduct, $category, $subcategory)` | protected | Создание/обновление свойств товара | `$matrix` (MatrixErp), `$matrixProduct` (array), `$category` (string), `$subcategory` (string) | bool |
+| 15 | `saveMatrixMediaForMatrixProperty($info, $name, $matrixProp, $adminId)` | private | Сохранение медиа-файла в Files + MatrixErpMedia | `$info` (array), `$name` (string), `$matrixProp` (MatrixErpProperty), `$adminId` (int) | void |
+| 16 | `purgeMatrixMedia($matrixProp, $names)` | private | Удаление медиа-файлов указанных типов | `$matrixProp` (MatrixErpProperty), `$names` (array) | void |
+
+---
+
+## Детальное описание Actions
+
+### 1. actionIndex() — Список товаров матрицы
+
+**Сигнатура**:
+```php
+public function actionIndex(): string
+```
+
+**HTTP-методы**: GET, POST
+
+**Параметры запроса** (POST, опциональные):
+
+| Параметр | Тип | Описание | Пример |
+|----------|-----|----------|--------|
+| `activeFilter` | int | Фильтр по активности (0, 1) | `1` |
+| `feedActiveFilter` | int | Фильтр по активности в фиде (0, 1) | `1` |
+| `nameFilter` | string | Поиск по названию (LIKE) | `"Букет роз"` |
+| `groupNameFilter` | string | Фильтр по группе товаров | `"marketplace"` |
+| `articuleFilter` | string | Поиск по артикулу (LIKE) | `"FW0125"` |
+
+**Возвращает**: HTML-страница со списком товаров
+
+**Особенности**:
+- Автоматически вызывает `fillInGuidsFromMarketplaceAndAdditional()` для синхронизации товаров
+- По умолчанию показывает только активные товары (`active=1`) из группы `marketplace`
+- Использует DynamicModel для фильтрации
+- Заполняет `componentsArray` для каждого товара
+
+**Пример использования**:
+```php
+// GET-запрос: показать все активные товары marketplace
+// /matrix-erp/index
+
+// POST-запрос: фильтр по артикулу
+POST /matrix-erp/index
+{
+    "activeFilter": 1,
+    "articuleFilter": "FW01"
+}
+```
+
+**Связанные модели**:
+- MatrixErp (основная)
+- MatrixErpProperty (JOIN)
+- MatrixErpMedia (JOIN)
+- MatrixErpSearch (не используется напрямую)
+
+**Связанное представление**: `/erp24/views/matrix_erp/index.php`
+
+---
+
+### 2. actionView($id) — Просмотр товара
+
+**Сигнатура**:
+```php
+public function actionView(int $id): string
+```
+
+**HTTP-методы**: GET
+
+**Параметры**:
+
+| Параметр | Тип | Обязательность | Описание |
+|----------|-----|----------------|----------|
+| `$id` | int | Обязательный | ID товара в таблице matrix_erp |
+
+**Возвращает**: HTML-страница с детальной информацией о товаре
+
+**Исключения**:
+- `NotFoundHttpException` — если товар не найден
+
+**Особенности**:
+- Автоматически создает пустую модель MatrixErpProperty, если свойства не существуют
+- Загружает все медиа-файлы с сортировкой по `foto_order`
+- JOIN с таблицей Files для получения URL файлов
+
+**Пример использования**:
+```php
+// GET-запрос: просмотр товара с ID=123
+// /matrix-erp/view?id=123
+```
+
+**Связанные модели**:
+- MatrixErp (основная)
+- MatrixErpProperty (по GUID)
+- MatrixErpMedia (по GUID с JOIN Files)
+
+**Связанное представление**: `/erp24/views/matrix_erp/view.php`
+
+**Данные, передаваемые в представление**:
+```php
+[
+    'model' => MatrixErp,
+    'modelProperty' => MatrixErpProperty,
+    'matrixErpMedia' => MatrixErpMedia[] // массив с relation 'file'
+]
+```
+
+---
+
+### 3. actionCreate() — Создание товара
+
+**Сигнатура**:
+```php
+public function actionCreate(): string|\yii\web\Response
+```
+
+**HTTP-методы**: GET, POST
+
+**Параметры** (POST):
+
+| Параметр | Тип | Обязательность | Описание |
+|----------|-----|----------------|----------|
+| `MatrixErp[guid]` | string | Обязательный | GUID товара (уникальный) |
+| `MatrixErp[name]` | string | Обязательный | Название товара |
+| `MatrixErp[articule]` | string | Опциональный | Артикул товара |
+| `MatrixErp[code]` | string | Опциональный | Код товара |
+| `MatrixErp[group_name]` | string | Опциональный | Группа товара |
+| `MatrixErp[components]` | text | Опциональный | Комплектация товара |
+| `MatrixErp[parent_id]` | string | Опциональный | ID родительского товара |
+| `MatrixErp[active]` | int | Опциональный | Активность (0, 1) |
+| `MatrixErp[is_feed_active]` | int | Опциональный | Активность в фиде (0, 1) |
+| `MatrixErp[date_from]` | string | Обязательный | Дата начала действия |
+
+**Возвращает**:
+- GET: HTML-форма создания
+- POST (успех): Редирект на `actionView` созданного товара
+- POST (ошибка): HTML-форма с ошибками валидации
+
+**Пример использования**:
+```php
+// GET-запрос: форма создания
+// /matrix-erp/create
+
+// POST-запрос: создание товара
+POST /matrix-erp/create
+{
+    "MatrixErp": {
+        "guid": "{GUID-1234}",
+        "name": "Букет роз 'Романтика'",
+        "articule": "FW0125",
+        "code": "BQ001",
+        "group_name": "marketplace",
+        "components": "15 роз, зелень",
+        "active": 1,
+        "is_feed_active": 1,
+        "date_from": "2024-01-01"
+    }
+}
+```
+
+**Связанные модели**:
+- MatrixErp (создаваемая)
+
+**Связанное представление**: `/erp24/views/matrix_erp/create.php`
+
+---
+
+### 4. actionUpdate($id) — Обновление товара
+
+**Сигнатура**:
+```php
+public function actionUpdate(int $id): string|\yii\web\Response
+```
+
+**HTTP-методы**: GET, POST
+
+**Параметры URL**:
+
+| Параметр | Тип | Обязательность | Описание |
+|----------|-----|----------------|----------|
+| `$id` | int | Обязательный | ID товара в таблице matrix_erp |
+
+**Параметры POST** (опциональные, при редактировании):
+- Все поля MatrixErp
+- Все поля MatrixErpProperty
+- Изображение (через DynamicModel с валидацией)
+
+**Возвращает**:
+- GET: HTML-форма редактирования
+- POST (успех): Редирект на `actionView`
+- POST (ошибка): HTML-форма с ошибками
+
+**Исключения**:
+- `NotFoundHttpException` — если товар не найден
+
+**Особенности**:
+- Автоматически создает MatrixErpProperty, если не существует
+- Загружает цены маркетплейсов (MarketplacePrices) для отображения
+- Поддержка загрузки изображения через DynamicModel
+
+**Пример использования**:
+```php
+// GET-запрос: форма редактирования товара с ID=123
+// /matrix-erp/update?id=123
+
+// POST-запрос: обновление названия и активности
+POST /matrix-erp/update?id=123
+{
+    "MatrixErp": {
+        "name": "Букет роз 'Романтика' Premium",
+        "active": 1
+    },
+    "MatrixErpProperty": {
+        "description": "Роскошный букет из красных роз...",
+        "display_name": "Романтика Premium",
+        "flowwow_category": "Букеты",
+        "flowwow_subcategory": "Розы"
+    }
+}
+```
+
+**Связанные модели**:
+- MatrixErp (редактируемая)
+- MatrixErpProperty (редактируемая или создаваемая)
+- MarketplacePrices (только чтение)
+
+**Связанное представление**: `/erp24/views/matrix_erp/update.php`
+
+**Данные, передаваемые в представление**:
+```php
+[
+    'modelMatrixErp' => MatrixErp,
+    'modelMatrixErpProperty' => MatrixErpProperty,
+    'filterModel' => DynamicModel, // для загрузки изображения
+    'marketplacePrices' => MarketplacePrices[] // цены на площадках
+]
+```
+
+---
+
+### 5. actionDelete($id) — Мягкое удаление товара
+
+**Сигнатура**:
+```php
+public function actionDelete(int $id): \yii\web\Response
+```
+
+**HTTP-методы**: POST (ограничено через VerbFilter)
+
+**Параметры**:
+
+| Параметр | Тип | Обязательность | Описание |
+|----------|-----|----------------|----------|
+| `$id` | int | Обязательный | ID товара в таблице matrix_erp |
+
+**Возвращает**: Редирект на `actionIndex`
+
+**Алгоритм**:
+1. Поиск товара по ID
+2. Установка полей:
+   - `active = 0`
+   - `deleted_at = текущая дата`
+   - `deleted_by = ID текущего пользователя`
+   - `date_to = deleted_at`
+3. Сохранение модели
+4. Редирект на список товаров
+
+**Особенности**:
+- Реализовано **soft delete** — данные не удаляются физически
+- VerbFilter ограничивает доступ только POST-запросами
+- Не проверяет наличие связанных записей (свойства, медиа остаются в БД)
+
+**Пример использования**:
+```php
+// POST-запрос: удаление товара с ID=123
+POST /matrix-erp/delete?id=123
+```
+
+**Связанные модели**:
+- MatrixErp (помечается как удаленная)
+
+**Рекомендации**:
+- ⚠️ Добавить проверку прав доступа (RBAC)
+- ⚠️ Рассмотреть каскадное удаление связанных данных (MatrixErpProperty, MatrixErpMedia)
+- ⚠️ Добавить подтверждение удаления (AJAX-диалог)
+
+---
+
+### 6. actionToggleFeedActive() — AJAX-переключение активности в фиде
+
+**Сигнатура**:
+```php
+public function actionToggleFeedActive(): array
+```
+
+**HTTP-методы**: POST (AJAX)
+
+**Content-Type ответа**: `application/json`
+
+**Параметры запроса** (POST):
+
+| Параметр | Тип | Обязательность | Описание |
+|----------|-----|----------------|----------|
+| `id` | int | Обязательный | ID товара в таблице matrix_erp |
+| `is_feed_active` | int | Обязательный | Новое значение активности (0 или 1) |
+
+**Возвращает** (JSON):
+
+**Успех**:
+```json
+{
+  "success": true,
+  "message": "Активность фида для товара \"Букет роз\" включена"
+}
+```
+
+**Ошибки**:
+
+1. Отсутствует параметр `id`:
+```json
+{
+  "success": false,
+  "message": "ID записи не указан"
+}
+```
+
+2. Товар не найден:
+```json
+{
+  "success": false,
+  "message": "Запись не найдена"
+}
+```
+
+3. Ошибка сохранения:
+```json
+{
+  "success": false,
+  "message": "Ошибка при сохранении: {\"is_feed_active\":[\"Значение должно быть целым числом.\"]}"
+}
+```
+
+**Пример использования** (JavaScript):
+```javascript
+$.ajax({
+    url: '/matrix-erp/toggle-feed-active',
+    method: 'POST',
+    data: {
+        id: 123,
+        is_feed_active: 1
+    },
+    dataType: 'json',
+    success: function(response) {
+        if (response.success) {
+            alert(response.message);
+        } else {
+            alert('Ошибка: ' + response.message);
+        }
+    }
+});
+```
+
+**Связанные модели**:
+- MatrixErp (обновляется поле `is_feed_active`)
+
+**Особенности**:
+- Используется для AJAX-переключения чекбоксов в таблице
+- Не требует перезагрузки страницы
+- Возвращает понятное сообщение для пользователя
+
+---
+
+### 7. actionParseFlowwowCards() — Импорт товаров из HTML
+
+**Сигнатура**:
+```php
+public function actionParseFlowwowCards(): string|\yii\web\Response
+```
+
+**HTTP-методы**: GET, POST
+
+**Параметры** (POST):
+
+| Параметр | Тип | Обязательность | Описание |
+|----------|-----|----------------|----------|
+| `HtmlImportForm[files][]` | UploadedFile[] | Обязательный | HTML-файлы с карточками товаров |
+| `HtmlImportForm[category]` | string | Обязательный | Категория Flowwow (например, "Букеты") |
+| `HtmlImportForm[subcategory]` | string | Обязательный | Подкатегория Flowwow (например, "Розы") |
+
+**Возвращает**:
+- GET: HTML-форма загрузки файлов
+- POST: Обновленная страница с результатами импорта
+
+**Алгоритм**:
+1. GET: Рендер формы `/matrix_erp/parse-flowwow-cards`
+2. POST:
+   - Загрузка файлов через `UploadedFile::getInstances()`
+   - Валидация формы HtmlImportForm
+   - Для каждого файла: вызов `processFile()`
+   - Сохранение результатов в сессию (`importResults`)
+   - Обновление страницы (refresh)
+   - Отображение результатов из сессии
+
+**Результаты импорта** (массив в сессии):
+```php
+[
+    [
+        'file' => 'product_card_1.html',
+        'status' => 'ok',
+        'articule' => 'FW0125',
+        'guid' => '{GUID-1234}',
+        'errors' => []
+    ],
+    [
+        'file' => 'product_card_2.html',
+        'status' => 'error',
+        'articule' => 'FW0126',
+        'guid' => null,
+        'errors' => [
+            'Товар с артикулом FW0126 не найден в MatrixErp.',
+            'Не удалось сохранить свойства для GUID {GUID-5678}.'
+        ]
+    ]
+]
+```
+
+**Пример использования**:
+```php
+// GET-запрос: форма загрузки
+// /matrix-erp/parse-flowwow-cards
+
+// POST-запрос: загрузка 3 HTML-файлов
+POST /matrix-erp/parse-flowwow-cards
+Content-Type: multipart/form-data
+
+HtmlImportForm[files][]: file1.html
+HtmlImportForm[files][]: file2.html
+HtmlImportForm[files][]: file3.html
+HtmlImportForm[category]: "Букеты"
+HtmlImportForm[subcategory]: "Розы"
+```
+
+**Связанные модели**:
+- HtmlImportForm (форма)
+- MatrixErp (поиск по артикулу)
+- MatrixErpProperty (обновление/создание)
+- MatrixErpMedia (загрузка медиа)
+- Files (сохранение файлов)
+- Images (обработка изображений)
+
+**Связанные сервисы**:
+- ProductParserService (парсинг HTML)
+- FileService (загрузка файлов с URL)
+- MarketplaceService (генерация ссылок)
+
+**Связанное представление**: `/erp24/views/matrix_erp/parse-flowwow-cards.php`
+
+**Обработка ошибок**:
+- Все исключения перехватываются и добавляются в массив ошибок
+- Ошибки логируются через `Yii::error()`
+- Если парсер вернул пустой результат — добавляется ошибка
+- Если артикул не найден — добавляется ошибка
+- Если товар не существует в MatrixErp — добавляется ошибка
+
+**Рекомендации**:
+- ✅ Добавить превью перед импортом (показать список товаров для подтверждения)
+- ✅ Реализовать прогресс-бар для AJAX-загрузки файлов по одному
+- ✅ Добавить возможность импорта с созданием новых товаров (сейчас только обновление)
+
+---
+
+## Вспомогательные методы
+
+### 8. fillInGuidsFromMarketplaceAndAdditional()
+
+**Сигнатура**:
+```php
+public static function fillInGuidsFromMarketplaceAndAdditional(): void
+```
+
+**Видимость**: public static
+
+**Назначение**: Синхронизация товаров из Products1c в MatrixErp с группой 'marketplace'.
+
+**Алгоритм**:
+1. Получить все товары MatrixErp с группировкой по `guid → group_name[]`
+2. Получить товары маркетплейса через `MarketplaceService::getMarketplaceProducts()`
+3. Для каждого товара Products1c:
+   - Проверить существование в MatrixErp с группой 'marketplace'
+   - Если не существует:
+     - Создать новую запись MatrixErp
+     - Скопировать поля: guid, name, parent_id, code, articule, components
+     - Установить: `group_name = 'marketplace'`, `date_from = now()`
+     - Сохранить
+     - Увеличить счетчик новых
+4. Если новых записей > 0:
+   - Установить Flash-сообщение: "Новых записей в matrix-erp {count}"
+
+**Особенности**:
+- Вызывается автоматически в `actionIndex()` при каждом открытии списка
+- Не обновляет существующие товары (только добавляет новые)
+- Выбрасывает исключение при ошибках валидации
+
+**Пример результата**:
+```
+Flash-сообщение: "Новых записей в matrix-erp 15"
+```
+
+**Связанные модели**:
+- MatrixErp (создание новых записей)
+- Products1c (источник данных через MarketplaceService)
+
+**Рекомендации**:
+- ⚠️ Вынести в консольную команду (cron) для оптимизации
+- ⚠️ Использовать `batchInsert()` для массовой вставки
+- ⚠️ Добавить кнопку "Синхронизировать" вместо автоматического вызова
+
+---
+
+### 9. findModel($id)
+
+**Сигнатура**:
+```php
+protected function findModel(int $id): MatrixErp
+```
+
+**Видимость**: protected
+
+**Назначение**: Поиск товара по ID с выбросом исключения при отсутствии.
+
+**Параметры**:
+- `$id` (int) — ID товара
+
+**Возвращает**: MatrixErp
+
+**Исключения**:
+- `NotFoundHttpException('The requested page does not exist.')` — если товар не найден
+
+**Пример использования**:
+```php
+$model = $this->findModel(123);
+```
+
+---
+
+### 10. processFile($file, $category, $subcategory)
+
+**Сигнатура**:
+```php
+protected function processFile(UploadedFile $file, string $category, string $subcategory): array
+```
+
+**Видимость**: protected
+
+**Назначение**: Обработка одного HTML-файла с карточкой товара.
+
+**Параметры**:
+- `$file` (UploadedFile) — загруженный HTML-файл
+- `$category` (string) — категория Flowwow
+- `$subcategory` (string) — подкатегория Flowwow
+
+**Возвращает** (array):
+```php
+[
+    'file' => 'filename.html',
+    'status' => 'ok'|'error',
+    'articule' => 'FW0125',
+    'guid' => '{GUID-1234}',
+    'errors' => ['Ошибка 1', 'Ошибка 2']
+]
+```
+
+**Алгоритм**:
+1. Чтение HTML из временного файла
+2. Парсинг через ProductParserService
+3. Нормализация результатов (`normalizeParsed()`)
+4. Проверка на пустой результат
+5. Для каждого товара:
+   - Извлечение артикула (`extractArticule()`)
+   - Поиск MatrixErp по артикулу
+   - Если найден: обновление свойств (`upsertProperty()`)
+   - Если не найден: добавление ошибки
+
+**Обработка ошибок**:
+- Все исключения перехватываются через `try-catch`
+- Ошибки добавляются в массив `errors`
+- Ошибки логируются через `Yii::error()`
+
+---
+
+### 11. normalizeParsed($parsed)
+
+**Сигнатура**:
+```php
+protected function normalizeParsed($parsed): array
+```
+
+**Видимость**: protected
+
+**Назначение**: Приведение результатов парсинга к единому формату массива товаров.
+
+**Параметры**:
+- `$parsed` (mixed) — результат парсинга
+
+**Возвращает**: array
+
+**Логика**:
+- Если `$parsed` пустой → `[]`
+- Если `$parsed` — ассоциативный массив с ключом 'name' → `[$parsed]` (один товар)
+- Иначе → `array_values((array)$parsed)` (массив товаров)
+
+**Примеры**:
+```php
+// Входные данные: один товар (ассоциативный массив)
+$parsed = ['name' => 'Букет', 'price' => 1000];
+// Результат: [['name' => 'Букет', 'price' => 1000]]
+
+// Входные данные: массив товаров
+$parsed = [
+    ['name' => 'Букет 1', 'price' => 1000],
+    ['name' => 'Букет 2', 'price' => 2000]
+];
+// Результат: тот же массив с переиндексацией
+```
+
+---
+
+### 12. isAssoc($arr)
+
+**Сигнатура**:
+```php
+protected function isAssoc(array $arr): bool
+```
+
+**Видимость**: protected
+
+**Назначение**: Проверка, является ли массив ассоциативным.
+
+**Параметры**:
+- `$arr` (array) — проверяемый массив
+
+**Возвращает**: bool
+
+**Логика**:
+- Пустой массив → `false`
+- Ключи совпадают с `range(0, count-1)` → `false` (индексный)
+- Иначе → `true` (ассоциативный)
+
+**Примеры**:
+```php
+isAssoc([]) // false
+isAssoc([1, 2, 3]) // false
+isAssoc(['a' => 1, 'b' => 2]) // true
+isAssoc([0 => 'a', 2 => 'b']) // true (пропущен индекс 1)
+```
+
+---
+
+### 13. extractArticule($name)
+
+**Сигнатура**:
+```php
+protected function extractArticule(string $name): ?string
+```
+
+**Видимость**: protected
+
+**Назначение**: Извлечение артикула из названия товара (формат `Название (АРТИКУЛ)`).
+
+**Параметры**:
+- `$name` (string) — название товара
+
+**Возвращает**: string|null
+
+**Алгоритм**:
+1. Разделить строку по символу `(`
+2. Взять вторую часть (индекс 1)
+3. Разделить по символу `)`
+4. Взять первую часть (индекс 0)
+5. Убрать пробелы (`trim()`)
+
+**Примеры**:
+```php
+extractArticule("Букет роз (FW0125)") // "FW0125"
+extractArticule("Букет роз (FW0125) красный") // "FW0125"
+extractArticule("Букет роз") // Exception (нет скобок)
+extractArticule("") // null
+```
+
+**Ограничения**:
+- ⚠️ Не обрабатывает случаи, когда скобок нет (выбрасывает Exception)
+- ⚠️ Не валидирует формат артикула
+
+**Рекомендация**:
+```php
+protected function extractArticule(string $name): ?string
+{
+    if (preg_match('/\(([^)]+)\)/', $name, $matches)) {
+        return trim($matches[1]);
+    }
+    return null;
+}
+```
+
+---
+
+### 14. upsertProperty($matrix, $matrixProduct, $category, $subcategory)
+
+**Сигнатура**:
+```php
+protected function upsertProperty(
+    MatrixErp $matrix,
+    array $matrixProduct,
+    string $category,
+    string $subcategory
+): bool
+```
+
+**Видимость**: protected
+
+**Назначение**: Создание или обновление свойств товара (MatrixErpProperty) и медиа-файлов.
+
+**Параметры**:
+- `$matrix` (MatrixErp) — модель товара
+- `$matrixProduct` (array) — распарсенные данные товара
+  - `name` (string) — название
+  - `description` (string) — описание
+  - `image_url` (string) — URL главного изображения
+  - `image_urls` (array) — массив URL дополнительных изображений
+  - `video_url` (string) — URL видео
+  - `properties['Размер']['ширина']` (float) — ширина
+  - `properties['Размер']['высота']` (float) — высота
+- `$category` (string) — категория Flowwow
+- `$subcategory` (string) — подкатегория Flowwow
+
+**Возвращает**: bool (успешность сохранения MatrixErpProperty)
+
+**Алгоритм**:
+1. **Поиск/создание MatrixErpProperty**:
+   - Поиск по `guid = $matrix->guid`
+   - Если не найдено: создание новой модели
+   - Установка `created_at`, `created_admin_id` или `updated_at`, `updated_admin_id`
+
+2. **Загрузка главного изображения**:
+   - `FileService::downloadAsUploadedFile($matrixProduct['image_url'])`
+   - Валидация: `Images::isImageFile()` (png, jpg, jpeg, webp, gif)
+   - Загрузка: `Images::loadImage()`
+   - Сохранение: `image_id`, `external_image_url` (через MarketplaceService)
+   - Удаление старого файла
+
+3. **Загрузка дополнительных фото** (если есть `image_urls`):
+   - Удаление старых фото: `purgeMatrixMedia(['foto'])`
+   - Для каждого URL:
+     - Скачивание: `FileService::saveFromUrlToUploads()`
+     - Проверка типа: `fileType === 'image'`
+     - Сохранение: `saveMatrixMediaForMatrixProperty($info, 'foto', ...)`
+
+4. **Загрузка видео** (если есть `video_url`):
+   - Удаление старого видео: `purgeMatrixMedia(['video'])`
+   - Скачивание: `FileService::saveFromUrlToUploads()`
+   - Сохранение: `saveMatrixMediaForMatrixProperty($info, 'video', ...)`
+
+5. **Обновление текстовых полей**:
+   - `display_name = $matrixProduct['name']`
+   - `description = $matrixProduct['description']`
+   - `product_url = MarketplaceService::getProductLinkByGuid()`
+   - `flowwow_category = $category`
+   - `flowwow_subcategory = $subcategory`
+   - `yandex_category = "Цветы, букеты, композиции"`
+   - `width = $matrixProduct['properties']['Размер']['ширина']` (если есть)
+   - `height = $matrixProduct['properties']['Размер']['высота']` (если есть)
+
+6. **Сохранение**: `$matrixProductProperty->save()`
+
+**Обработка ошибок**:
+- Ошибки загрузки файлов логируются через `Yii::error()`
+- При ошибке валидации изображения → `return false`
+- При отсутствии файла и URL → `return false`
+
+**Особенности**:
+- Использует `seen` массив для предотвращения дублирования URL
+- Поддерживает загрузку из внешних источников (CORS через `originPolicy`)
+- Автоматически генерирует публичные ссылки на изображения и карточки
+
+---
+
+### 15. saveMatrixMediaForMatrixProperty($info, $name, $matrixProp, $adminId)
+
+**Сигнатура**:
+```php
+private function saveMatrixMediaForMatrixProperty(
+    array $info,
+    string $name,
+    MatrixErpProperty $matrixProp,
+    int $adminId
+): void
+```
+
+**Видимость**: private
+
+**Назначение**: Сохранение медиа-файла (фото/видео) в таблицы Files и MatrixErpMedia.
+
+**Параметры**:
+- `$info` (array) — информация о файле
+  - `fileType` (string) — тип файла ('image' или 'video')
+  - `target_base_file` (string) — путь к сохраненному файлу
+- `$name` (string) — имя медиа ('foto' или 'video')
+- `$matrixProp` (MatrixErpProperty) — свойства товара
+- `$adminId` (int) — ID администратора
+
+**Алгоритм**:
+1. Создание записи Files:
+   - `entity = 'matrix_media'`
+   - `entity_id = $matrixProp->id`
+   - `file_type = $info['fileType']`
+   - `url = $info['target_base_file']`
+   - `created_at = now()`
+2. Создание записи MatrixErpMedia:
+   - `guid = $matrixProp->guid`
+   - `file_id = $file->id`
+   - `name = $name`
+   - `created_admin_id = $adminId`
+   - `date = now()`
+   - `created_at = now()`
+
+**Обработка ошибок**:
+- Ошибки сохранения логируются через `Yii::error()`
+
+**Связанные модели**:
+- Files (создается)
+- MatrixErpMedia (создается)
+
+---
+
+### 16. purgeMatrixMedia($matrixProp, $names)
+
+**Сигнатура**:
+```php
+private function purgeMatrixMedia(
+    MatrixErpProperty $matrixProp,
+    array $names = ['foto', 'video']
+): void
+```
+
+**Видимость**: private
+
+**Назначение**: Удаление всех медиа-файлов указанных типов (foto/video) для товара.
+
+**Параметры**:
+- `$matrixProp` (MatrixErpProperty) — свойства товара
+- `$names` (array) — массив типов медиа (например, `['foto']`, `['video']`, `['foto', 'video']`)
+
+**Алгоритм**:
+1. Фильтрация и уникализация массива `$names`
+2. Если массив пустой → выход
+3. Получение всех `file_id` через JOIN:
+   - Таблицы: `MatrixErpMedia` JOIN `Files`
+   - Условия:
+     - `mm.guid = $matrixProp->guid`
+     - `mm.name IN $names`
+     - `f.entity = 'matrix_media'`
+     - `f.entity_id = $matrixProp->id`
+4. Удаление записей MatrixErpMedia
+5. Удаление записей Files
+
+**Особенности**:
+- Физическое удаление из БД (не soft delete)
+- Используется перед загрузкой новых медиа-файлов
+- Предотвращает дублирование файлов
+
+**Пример использования**:
+```php
+// Удалить все фото
+$this->purgeMatrixMedia($matrixProp, ['foto']);
+
+// Удалить все видео
+$this->purgeMatrixMedia($matrixProp, ['video']);
+
+// Удалить все медиа
+$this->purgeMatrixMedia($matrixProp, ['foto', 'video']);
+```
+
+**Связанные модели**:
+- MatrixErpMedia (удаление)
+- Files (удаление)
+
+---
+
+## Итоговая статистика
+
+| Категория | Количество |
+|-----------|------------|
+| **Публичные actions** | 7 |
+| **Статические методы** | 1 |
+| **Protected методы** | 6 |
+| **Private методы** | 2 |
+| **Всего методов** | 16 |
+| **HTTP GET actions** | 4 (index, view, create, update) |
+| **HTTP POST actions** | 5 (index, create, update, delete, toggle, parse) |
+| **AJAX actions** | 1 (toggle-feed-active) |
+| **CRUD operations** | 5 (index, view, create, update, delete) |
+| **Специализированные** | 2 (parse, sync) |
+
+---
+
+## Зависимости между методами
+
+```mermaid
+graph TD
+    A[actionIndex] --> B[fillInGuidsFromMarketplaceAndAdditional]
+    C[actionView] --> D[findModel]
+    E[actionUpdate] --> D
+    F[actionDelete] --> D
+
+    G[actionParseFlowwowCards] --> H[processFile]
+    H --> I[normalizeParsed]
+    H --> J[extractArticule]
+    H --> K[upsertProperty]
+
+    I --> L[isAssoc]
+
+    K --> M[purgeMatrixMedia]
+    K --> N[saveMatrixMediaForMatrixProperty]
+
+    style A fill:#e1f5ff
+    style G fill:#ffe1e1
+    style K fill:#fff5e1
+```
+
+---
+
+## Рекомендации по использованию
+
+### Типичные сценарии
+
+1. **Просмотр списка товаров**:
+   ```
+   GET /matrix-erp/index
+   ```
+
+2. **Фильтрация товаров**:
+   ```
+   POST /matrix-erp/index
+   {activeFilter: 1, groupNameFilter: "marketplace"}
+   ```
+
+3. **Просмотр товара**:
+   ```
+   GET /matrix-erp/view?id=123
+   ```
+
+4. **Создание товара**:
+   ```
+   GET /matrix-erp/create (форма)
+   POST /matrix-erp/create (сохранение)
+   ```
+
+5. **Редактирование товара**:
+   ```
+   GET /matrix-erp/update?id=123 (форма)
+   POST /matrix-erp/update?id=123 (сохранение)
+   ```
+
+6. **Удаление товара**:
+   ```
+   POST /matrix-erp/delete?id=123
+   ```
+
+7. **Переключение активности в фиде (AJAX)**:
+   ```javascript
+   $.post('/matrix-erp/toggle-feed-active', {id: 123, is_feed_active: 1})
+   ```
+
+8. **Импорт товаров из HTML**:
+   ```
+   GET /matrix-erp/parse-flowwow-cards (форма)
+   POST /matrix-erp/parse-flowwow-cards (загрузка файлов)
+   ```
+
+### Ограничения и предостережения
+
+⚠️ **RBAC отсутствует** — все actions доступны авторизованным пользователям
+
+⚠️ **Синхронизация при каждом запросе** — `fillInGuidsFromMarketplaceAndAdditional()` вызывается при открытии списка
+
+⚠️ **Нет транзакций** — импорт может завершиться частично при ошибке
+
+⚠️ **Отсутствует пагинация** — список товаров загружается полностью
+
+⚠️ **Физическое удаление медиа** — `purgeMatrixMedia()` удаляет файлы без возможности восстановления
+
+⚠️ **Нет валидации URL** — при импорте не проверяется валидность URL изображений и видео
diff --git a/erp24/docs/controllers/non-standard/MatrixErpController_ANALYSIS.md b/erp24/docs/controllers/non-standard/MatrixErpController_ANALYSIS.md
new file mode 100644 (file)
index 0000000..435e602
--- /dev/null
@@ -0,0 +1,1013 @@
+# MatrixErpController - Полный анализ
+
+## Метаданные
+
+| Параметр | Значение |
+|----------|----------|
+| **Namespace** | `app\controllers` |
+| **Extends** | `yii\web\Controller` |
+| **Размер файла** | 630 строк |
+| **Приоритет** | 1 (критичный) |
+| **Путь** | `erp24/controllers/MatrixErpController.php` |
+
+## Назначение и бизнес-цель
+
+**MatrixErpController** — контроллер для управления матрицей товаров ERP (Matrix ERP Product Management). Основная бизнес-задача — централизованное управление товарами, их свойствами, медиа-контентом (фото/видео) и интеграция с маркетплейсами (Yandex Market, Flowwow и др.).
+
+### Ключевые бизнес-функции:
+
+1. **Управление матрицей товаров** — CRUD операции для товаров из различных источников (marketplace, 1С, внешние источники)
+2. **Синхронизация с маркетплейсами** — автоматическая загрузка товаров из Products1c в матрицу ERP
+3. **Импорт товаров из HTML** — парсинг карточек товаров с сайтов (например, Flowwow) с автоматическим извлечением характеристик, изображений и видео
+4. **Управление медиа-контентом** — загрузка, хранение и организация фотографий и видео товаров
+5. **Управление свойствами товаров** — описание, размеры, категории, SEO-параметры для маркетплейсов
+6. **Контроль активности в фидах** — управление публикацией товаров в фиды маркетплейсов
+7. **Связь с ценообразованием** — интеграция с MarketplacePrices для управления ценами на разных площадках
+
+### Бизнес-логика матрицы товаров
+
+Контроллер реализует сложную логику управления товарной матрицей:
+
+- **Автоматическая синхронизация товаров** — периодическое обновление матрицы из каталога маркетплейса (Products1c)
+- **Импорт из внешних источников** — загрузка HTML-файлов карточек товаров с парсингом всех характеристик
+- **Связь товаров по GUID** — единая идентификация товара через GUID для связи основной записи, свойств и медиа
+- **Извлечение артикулов** — автоматическое определение артикула из названия товара (формат `FW0125`)
+- **Управление медиа-файлами** — загрузка изображений и видео с внешних URL, оптимизация и хранение
+- **Категоризация для маркетплейсов** — назначение категорий Flowwow, Yandex для корректной публикации
+- **Upsert-логика свойств** — создание новых или обновление существующих свойств товаров без дублирования
+
+## Access Control (RBAC)
+
+**Отсутствует явный контроль доступа** — контроллер не имеет метода `checkAccess()` и не проверяет роли пользователей.
+
+### Рекомендации по безопасности:
+
+⚠️ **ВНИМАНИЕ**: Контроллер доступен всем авторизованным пользователям. Необходимо добавить RBAC для ограничения доступа к критичным операциям:
+
+- `actionCreate`, `actionUpdate`, `actionDelete` — только для менеджеров каталога
+- `actionParseFlowwowCards` — только для контент-менеджеров
+- `fillInGuidsFromMarketplaceAndAdditional` — только для системных администраторов
+
+## Архитектура и зависимости
+
+### Используемые модели (ActiveRecord)
+
+| Модель | Назначение |
+|--------|-----------|
+| **MatrixErp** | Основная модель товара матрицы (guid, name, articule, code, parent_id, active, is_feed_active) |
+| **MatrixErpProperty** | Свойства товара (описание, изображение, размеры, категории, SEO-данные) |
+| **MatrixErpMedia** | Медиа-файлы товара (фото/видео с порядком сортировки) |
+| **MatrixErpSearch** | Модель поиска товаров в матрице |
+| **Products1c** | Товары из 1С для синхронизации с матрицей |
+| **MarketplacePrices** | Цены товаров на различных маркетплейсах |
+| **Files** | Файлы (изображения, видео) для товаров |
+| **Images** | Модель для обработки и загрузки изображений |
+| **HtmlImportForm** | Форма для импорта HTML-файлов с карточками товаров |
+
+### Используемые сервисы
+
+| Сервис | Назначение |
+|--------|-----------|
+| **MarketplaceService** | Работа с маркетплейсами (получение товаров, генерация ссылок на изображения и карточки) |
+| **FileService** | Загрузка файлов с внешних URL, сохранение в систему, определение типов файлов |
+| **ProductParserService** | Парсинг HTML-карточек товаров, извлечение характеристик, изображений, видео |
+
+### Связи моделей
+
+```mermaid
+erDiagram
+    MatrixErp ||--o| MatrixErpProperty : "guid"
+    MatrixErp ||--o{ MatrixErpMedia : "guid"
+    MatrixErp ||--o{ MarketplacePrices : "matrix_erp_id"
+    MatrixErp ||--o| Products1c : "guid (source)"
+    MatrixErpProperty ||--o| Images : "image_id"
+    MatrixErpProperty ||--o{ MatrixErpMedia : "guid (media files)"
+    MatrixErpMedia ||--|| Files : "file_id"
+
+    MatrixErp {
+        int id PK
+        string guid UK
+        string parent_id
+        string name
+        string articule
+        string code
+        string components
+        string group_name
+        int active
+        int is_feed_active
+        datetime date_from
+        datetime date_to
+    }
+
+    MatrixErpProperty {
+        int id PK
+        string guid FK
+        text description
+        int image_id FK
+        string display_name
+        string external_image_url
+        string product_url
+        string yandex_category
+        string flowwow_category
+        string flowwow_subcategory
+        float length
+        float width
+        float height
+        float weight
+    }
+
+    MatrixErpMedia {
+        int id PK
+        string guid FK
+        int file_id FK
+        string name
+        int foto_order
+    }
+
+    MarketplacePrices {
+        int id PK
+        int matrix_erp_id FK
+        string marketplace
+        decimal price
+    }
+
+    Products1c {
+        string id PK
+        string name
+        string articule
+        string code
+        string components
+    }
+
+    Files {
+        int id PK
+        string entity
+        int entity_id
+        string file_type
+        string url
+    }
+
+    Images {
+        int id PK
+        string url
+        int width
+        int height
+    }
+```
+
+### Внешние библиотеки
+
+| Библиотека | Назначение |
+|-----------|-----------|
+| **GuzzleHttp\Client** | HTTP-клиент для загрузки файлов с внешних URL (импортируется, но напрямую не используется) |
+| **DOMDocument / DOMXPath** | Парсинг XML/HTML (импортируется, но не используется напрямую — делегируется в ProductParserService) |
+| **Symfony\Component\DomCrawler\Crawler** | Парсинг HTML (импортируется, но не используется напрямую) |
+| **voku\helper\HtmlDomParser** | Парсинг HTML (импортируется, но не используется напрямую) |
+
+## Все Actions контроллера
+
+### 1. `actionIndex()` — Список товаров матрицы
+
+**HTTP-метод**: GET, POST
+
+**Назначение**: Отображение списка товаров матрицы с фильтрацией и автоматической синхронизацией с маркетплейсом.
+
+**Алгоритм**:
+1. Вызов `fillInGuidsFromMarketplaceAndAdditional()` — синхронизация товаров из Products1c
+2. Создание динамической модели фильтра (DynamicModel) с полями: activeFilter, feedActiveFilter, nameFilter, groupNameFilter, articuleFilter
+3. Построение запроса с JOIN к MatrixErpProperty и MatrixErpMedia
+4. Применение фильтров (по умолчанию: `group_name = 'marketplace'`, `active = 1`)
+5. Выполнение запроса, заполнение `componentsArray` для каждого товара
+6. Рендер представления `/matrix_erp/index`
+
+**Параметры запроса** (POST):
+- `activeFilter` (int) — фильтр по активности (0 или 1)
+- `feedActiveFilter` (int) — фильтр по активности в фиде (0 или 1)
+- `nameFilter` (string) — поиск по названию товара (LIKE)
+- `groupNameFilter` (string) — фильтр по группе товаров
+- `articuleFilter` (string) — поиск по артикулу (LIKE)
+
+**Возвращает**: HTML-страница со списком товаров матрицы
+
+**Особенности**:
+- По умолчанию показываются только активные товары из группы marketplace
+- Поддержка фильтрации по нескольким параметрам одновременно
+- Автоматическая синхронизация товаров при каждом открытии страницы
+
+---
+
+### 2. `actionView($id)` — Просмотр товара
+
+**HTTP-метод**: GET
+
+**Назначение**: Отображение детальной информации о товаре, его свойствах и медиа-файлах.
+
+**Параметры**:
+- `$id` (int) — ID товара в таблице matrix_erp
+
+**Алгоритм**:
+1. Поиск модели MatrixErp по ID (через `findModel()`)
+2. Поиск свойств товара (MatrixErpProperty) по GUID, если не найдено — создание пустой модели
+3. Получение всех медиа-файлов товара (MatrixErpMedia) с файлами, отсортированных по foto_order
+4. Рендер представления `/matrix_erp/view`
+
+**Возвращает**: HTML-страница с детальной информацией о товаре
+
+**Исключения**:
+- `NotFoundHttpException` — если товар не найден
+
+---
+
+### 3. `actionCreate()` — Создание нового товара
+
+**HTTP-метод**: GET, POST
+
+**Назначение**: Создание нового товара в матрице.
+
+**Алгоритм**:
+1. GET: Создание новой модели MatrixErp, загрузка дефолтных значений, рендер формы
+2. POST: Загрузка данных из запроса, валидация, сохранение, редирект на `actionView`
+
+**Параметры запроса** (POST):
+- Все поля модели MatrixErp (guid, name, articule, code, group_name, components и т.д.)
+
+**Возвращает**:
+- GET: HTML-форма создания товара
+- POST: Редирект на страницу просмотра созданного товара
+
+---
+
+### 4. `actionUpdate($id)` — Обновление товара
+
+**HTTP-метод**: GET, POST
+
+**Назначение**: Редактирование товара и его свойств, загрузка изображений, управление ценами на маркетплейсах.
+
+**Параметры**:
+- `$id` (int) — ID товара
+
+**Алгоритм**:
+1. Поиск модели MatrixErp по ID
+2. Поиск или создание модели MatrixErpProperty по GUID товара
+3. Создание динамической модели для загрузки изображения (DynamicModel с валидацией image)
+4. Получение цен маркетплейсов для товара (MarketplacePrices)
+5. Рендер формы редактирования
+
+**Возвращает**: HTML-форма редактирования товара с полями:
+- Основная информация (MatrixErp)
+- Свойства товара (MatrixErpProperty)
+- Загрузка изображений
+- Цены на маркетплейсах (MarketplacePrices)
+
+**Исключения**:
+- `NotFoundHttpException` — если товар не найден
+
+---
+
+### 5. `actionDelete($id)` — Удаление товара (soft delete)
+
+**HTTP-метод**: POST
+
+**Назначение**: Мягкое удаление товара (установка флагов deleted_at, deleted_by, active=0).
+
+**Параметры**:
+- `$id` (int) — ID товара
+
+**Алгоритм**:
+1. Поиск товара по ID
+2. Установка флагов:
+   - `active = 0`
+   - `deleted_at = текущая дата`
+   - `deleted_by = ID текущего пользователя`
+   - `date_to = deleted_at`
+3. Сохранение модели
+4. Редирект на `actionIndex`
+
+**Возвращает**: Редирект на список товаров
+
+**Особенности**:
+- Реализовано мягкое удаление (soft delete), данные не удаляются физически
+- Поведение VerbFilter ограничивает метод только POST-запросами
+
+---
+
+### 6. `actionToggleFeedActive()` — Переключение активности в фиде (AJAX)
+
+**HTTP-метод**: POST
+
+**Назначение**: AJAX-обработчик для изменения статуса активности товара в фидах маркетплейсов.
+
+**Параметры запроса** (POST):
+- `id` (int) — ID товара
+- `is_feed_active` (int) — новое значение активности (0 или 1)
+
+**Алгоритм**:
+1. Валидация наличия ID
+2. Поиск товара по ID
+3. Обновление поля `is_feed_active`
+4. Сохранение модели
+5. Возврат JSON-ответа
+
+**Возвращает** (JSON):
+```json
+{
+  "success": true|false,
+  "message": "Активность фида для товара \"Название\" включена|отключена"
+}
+```
+
+**Коды ошибок**:
+- `success: false, message: "ID записи не указан"` — отсутствует параметр id
+- `success: false, message: "Запись не найдена"` — товар не существует
+- `success: false, message: "Ошибка при сохранении: ..."` — ошибка валидации или сохранения
+
+---
+
+### 7. `actionParseFlowwowCards()` — Импорт товаров из HTML-файлов
+
+**HTTP-метод**: GET, POST
+
+**Назначение**: Массовый импорт товаров из HTML-файлов карточек Flowwow с парсингом характеристик, изображений и видео.
+
+**Алгоритм**:
+1. GET: Отображение формы загрузки файлов
+2. POST:
+   - Загрузка файлов через UploadedFile
+   - Валидация формы HtmlImportForm
+   - Обработка каждого файла через `processFile()`
+   - Сохранение результатов в сессию
+   - Обновление страницы для отображения результатов
+
+**Параметры запроса** (POST):
+- `HtmlImportForm[files]` (array of UploadedFile) — HTML-файлы карточек товаров
+- `HtmlImportForm[category]` (string) — категория Flowwow
+- `HtmlImportForm[subcategory]` (string) — подкатегория Flowwow
+
+**Возвращает**:
+- GET: HTML-форма загрузки файлов
+- POST: Обновленная страница с результатами импорта
+
+**Результаты импорта** (массив):
+```php
+[
+  [
+    'file' => 'filename.html',
+    'status' => 'ok'|'error',
+    'articule' => 'FW0125',
+    'guid' => '{guid}',
+    'errors' => []
+  ],
+  // ...
+]
+```
+
+---
+
+### 8. `fillInGuidsFromMarketplaceAndAdditional()` — Синхронизация товаров из маркетплейса
+
+**Видимость**: public static
+
+**Назначение**: Автоматическая загрузка новых товаров из Products1c в матрицу ERP.
+
+**Алгоритм**:
+1. Получение всех существующих товаров из MatrixErp с группировкой по GUID и group_name
+2. Получение товаров маркетплейса через `MarketplaceService::getMarketplaceProducts()`
+3. Для каждого товара из маркетплейса:
+   - Проверка существования в матрице с группой 'marketplace'
+   - Если не существует — создание новой записи MatrixErp:
+     - guid, name, parent_id, code, articule, components — из Products1c
+     - group_name = 'marketplace'
+     - date_from = текущая дата
+   - Сохранение модели
+4. Если добавлены новые записи — установка Flash-сообщения с количеством
+
+**Возвращает**: void
+
+**Исключения**:
+- `\Exception` — если возникли ошибки валидации при сохранении
+
+**Особенности**:
+- Вызывается автоматически в `actionIndex()` при каждом открытии списка товаров
+- Добавляет только новые товары, не обновляет существующие
+
+---
+
+### 9. `findModel($id)` — Поиск товара по ID
+
+**Видимость**: protected
+
+**Назначение**: Вспомогательный метод для поиска товара по ID с выбросом исключения при отсутствии.
+
+**Параметры**:
+- `$id` (int) — ID товара
+
+**Возвращает**: MatrixErp
+
+**Исключения**:
+- `NotFoundHttpException` — если товар не найден
+
+---
+
+### 10. `processFile(UploadedFile $file, string $category, string $subcategory)` — Обработка HTML-файла
+
+**Видимость**: protected
+
+**Назначение**: Парсинг одного HTML-файла с карточкой товара и импорт данных в матрицу.
+
+**Параметры**:
+- `$file` (UploadedFile) — загруженный HTML-файл
+- `$category` (string) — категория Flowwow
+- `$subcategory` (string) — подкатегория Flowwow
+
+**Алгоритм**:
+1. Чтение содержимого HTML-файла
+2. Парсинг через ProductParserService
+3. Нормализация результатов парсинга
+4. Для каждого распарсенного товара:
+   - Извлечение артикула из названия (формат `(FW0125)`)
+   - Поиск товара в MatrixErp по артикулу
+   - Если найден — обновление свойств через `upsertProperty()`
+   - Если не найден — добавление ошибки
+
+**Возвращает** (array):
+```php
+[
+  'file' => 'filename.html',
+  'status' => 'ok'|'error',
+  'articule' => 'FW0125',
+  'guid' => '{guid}',
+  'errors' => ['Ошибка 1', 'Ошибка 2']
+]
+```
+
+**Обработка ошибок**:
+- Ошибки парсинга логируются через `Yii::error()`
+- Все исключения перехватываются и добавляются в массив ошибок
+
+---
+
+### 11. `normalizeParsed($parsed)` — Нормализация результатов парсинга
+
+**Видимость**: protected
+
+**Назначение**: Приведение результатов парсинга к единому формату массива товаров.
+
+**Параметры**:
+- `$parsed` (mixed) — результат парсинга (массив или объект)
+
+**Возвращает**: array — массив товаров
+
+**Логика**:
+- Если результат — ассоциативный массив с ключом 'name' → оборачивается в массив (один товар)
+- Если результат — массив массивов → возвращается как есть
+- Если результат пустой → возвращается пустой массив
+
+---
+
+### 12. `isAssoc(array $arr)` — Проверка ассоциативности массива
+
+**Видимость**: protected
+
+**Назначение**: Определение, является ли массив ассоциативным (не индексный).
+
+**Параметры**:
+- `$arr` (array) — проверяемый массив
+
+**Возвращает**: bool
+
+**Логика**:
+- Пустой массив → false
+- Ключи массива совпадают с `range(0, count-1)` → false (индексный)
+- Иначе → true (ассоциативный)
+
+---
+
+### 13. `extractArticule(string $name)` — Извлечение артикула из названия
+
+**Видимость**: protected
+
+**Назначение**: Парсинг артикула из названия товара (формат `Название (FW0125)`).
+
+**Параметры**:
+- `$name` (string) — название товара
+
+**Возвращает**: string|null — артикул или null
+
+**Алгоритм**:
+1. Разделение строки по символу `(`
+2. Извлечение текста до первого символа `)`
+3. Удаление пробелов
+
+**Пример**:
+- Вход: `"Букет роз (FW0125) красный"`
+- Выход: `"FW0125"`
+
+---
+
+### 14. `upsertProperty(MatrixErp $matrix, array $matrixProduct, string $category, string $subcategory)` — Обновление свойств товара
+
+**Видимость**: protected
+
+**Назначение**: Создание или обновление свойств товара (MatrixErpProperty) и связанных медиа-файлов.
+
+**Параметры**:
+- `$matrix` (MatrixErp) — модель товара
+- `$matrixProduct` (array) — распарсенные данные товара
+  - `name` — название
+  - `description` — описание
+  - `image_url` — URL главного изображения
+  - `image_urls` — массив URL дополнительных изображений
+  - `video_url` — URL видео
+  - `properties['Размер']['ширина']` — ширина
+  - `properties['Размер']['высота']` — высота
+- `$category` (string) — категория Flowwow
+- `$subcategory` (string) — подкатегория Flowwow
+
+**Алгоритм**:
+1. Поиск или создание MatrixErpProperty по GUID
+2. **Загрузка главного изображения**:
+   - Скачивание через `FileService::downloadAsUploadedFile()`
+   - Валидация типа файла (png, jpg, jpeg, webp, gif)
+   - Загрузка в Images
+   - Сохранение image_id и external_image_url
+   - Удаление старого файла (если был)
+3. **Загрузка дополнительных фото** (если есть `image_urls`):
+   - Удаление старых фото через `purgeMatrixMedia(['foto'])`
+   - Для каждого URL:
+     - Скачивание через `FileService::saveFromUrlToUploads()`
+     - Сохранение в Files (entity='matrix_media')
+     - Создание записи MatrixErpMedia (name='foto')
+4. **Загрузка видео** (если есть `video_url`):
+   - Удаление старых видео через `purgeMatrixMedia(['video'])`
+   - Скачивание через `FileService::saveFromUrlToUploads()`
+   - Сохранение в Files и MatrixErpMedia (name='video')
+5. **Обновление полей**:
+   - `display_name`, `description` — из распарсенных данных
+   - `product_url` — генерация через `MarketplaceService::getProductLinkByGuid()`
+   - `flowwow_category`, `flowwow_subcategory` — из параметров
+   - `yandex_category` — константа "Цветы, букеты, композиции"
+   - `width`, `height` — из properties (если есть)
+6. Сохранение MatrixErpProperty
+
+**Возвращает**: bool — успешность сохранения
+
+**Обработка ошибок**:
+- Ошибки загрузки файлов логируются через `Yii::error()`
+- При ошибке валидации изображения возвращается false
+- При отсутствии файла и URL возвращается false
+
+---
+
+### 15. `saveMatrixMediaForMatrixProperty(array $info, string $name, $matrixProp, $adminId)` — Сохранение медиа-файла
+
+**Видимость**: private
+
+**Назначение**: Сохранение медиа-файла (фото/видео) в таблицы Files и MatrixErpMedia.
+
+**Параметры**:
+- `$info` (array) — информация о файле
+  - `fileType` — тип файла ('image' или 'video')
+  - `target_base_file` — путь к сохраненному файлу
+- `$name` (string) — имя медиа ('foto' или 'video')
+- `$matrixProp` (MatrixErpProperty) — свойства товара
+- `$adminId` (int) — ID администратора
+
+**Алгоритм**:
+1. Создание записи Files:
+   - `entity = 'matrix_media'`
+   - `entity_id = $matrixProp->id`
+   - `file_type = $info['fileType']`
+   - `url = $info['target_base_file']`
+2. Создание записи MatrixErpMedia:
+   - `guid = $matrixProp->guid`
+   - `file_id = $file->id`
+   - `name = $name` ('foto' или 'video')
+   - `created_admin_id = $adminId`
+
+**Возвращает**: void
+
+**Обработка ошибок**:
+- Ошибки сохранения логируются через `Yii::error()`
+
+---
+
+### 16. `purgeMatrixMedia(MatrixErpProperty $matrixProp, array $names)` — Удаление медиа-файлов
+
+**Видимость**: private
+
+**Назначение**: Удаление всех медиа-файлов указанных типов (foto/video) для товара.
+
+**Параметры**:
+- `$matrixProp` (MatrixErpProperty) — свойства товара
+- `$names` (array) — массив типов медиа ['foto', 'video']
+
+**Алгоритм**:
+1. Получение всех file_id через JOIN MatrixErpMedia и Files
+2. Фильтрация по:
+   - `guid = $matrixProp->guid`
+   - `name IN $names`
+   - `entity = 'matrix_media'`
+   - `entity_id = $matrixProp->id`
+3. Удаление записей MatrixErpMedia
+4. Удаление записей Files
+
+**Возвращает**: void
+
+**Особенности**:
+- Используется при обновлении медиа-файлов для предотвращения дублирования
+- Физическое удаление из БД (не soft delete)
+
+---
+
+## Бизнес-логика и workflow
+
+### Workflow импорта товаров из HTML
+
+```mermaid
+sequenceDiagram
+    participant User
+    participant Controller as MatrixErpController
+    participant Parser as ProductParserService
+    participant FileService
+    participant MatrixErp
+    participant MatrixErpProperty
+    participant MatrixErpMedia
+    participant Files
+
+    User->>Controller: Загрузка HTML файлов
+    Controller->>Controller: actionParseFlowwowCards()
+
+    loop Для каждого файла
+        Controller->>Controller: processFile()
+        Controller->>Parser: parseProductHtml(html)
+        Parser-->>Controller: parsed data
+
+        Controller->>Controller: extractArticule(name)
+        Controller->>MatrixErp: findByArticule()
+        MatrixErp-->>Controller: model or null
+
+        alt Товар найден
+            Controller->>Controller: upsertProperty()
+
+            Controller->>FileService: downloadAsUploadedFile(image_url)
+            FileService-->>Controller: UploadedFile
+            Controller->>Images: loadImage()
+            Images-->>Controller: image_id
+
+            Controller->>Controller: purgeMatrixMedia(['foto'])
+            Controller->>MatrixErpMedia: deleteAll()
+            Controller->>Files: deleteAll()
+
+            loop Для каждого изображения
+                Controller->>FileService: saveFromUrlToUploads(img_url)
+                FileService-->>Controller: file info
+                Controller->>Files: create()
+                Controller->>MatrixErpMedia: create(name='foto')
+            end
+
+            alt Есть видео
+                Controller->>Controller: purgeMatrixMedia(['video'])
+                Controller->>FileService: saveFromUrlToUploads(video_url)
+                FileService-->>Controller: file info
+                Controller->>Files: create()
+                Controller->>MatrixErpMedia: create(name='video')
+            end
+
+            Controller->>MatrixErpProperty: save()
+        else Товар не найден
+            Controller->>Controller: Добавить ошибку
+        end
+    end
+
+    Controller-->>User: Результаты импорта
+```
+
+### Workflow синхронизации товаров из маркетплейса
+
+```mermaid
+flowchart TD
+    A[Пользователь открывает список товаров] --> B[actionIndex]
+    B --> C[fillInGuidsFromMarketplaceAndAdditional]
+    C --> D[Получить существующие товары MatrixErp]
+    C --> E[Получить товары из Products1c]
+    D --> F{Для каждого товара Products1c}
+    E --> F
+    F --> G{Товар существует<br/>в MatrixErp<br/>с group_name='marketplace'?}
+    G -->|Нет| H[Создать новый MatrixErp]
+    H --> I[guid, name, articule, code<br/>components, parent_id<br/>из Products1c]
+    I --> J[group_name = 'marketplace']
+    J --> K[date_from = now]
+    K --> L[Сохранить MatrixErp]
+    L --> M{Ошибки валидации?}
+    M -->|Да| N[Выбросить Exception]
+    M -->|Нет| O[Увеличить счетчик новых]
+    G -->|Да| P[Пропустить]
+    O --> F
+    P --> F
+    F -->|Все обработаны| Q{Новых записей > 0?}
+    Q -->|Да| R[Установить Flash-сообщение]
+    Q -->|Нет| S[Без сообщения]
+    R --> T[Построить запрос с фильтрами]
+    S --> T
+    T --> U[Выполнить запрос]
+    U --> V[Заполнить componentsArray]
+    V --> W[Рендер /matrix_erp/index]
+```
+
+### Workflow обновления свойств товара (upsertProperty)
+
+```mermaid
+flowchart TD
+    A[upsertProperty] --> B{MatrixErpProperty<br/>существует?}
+    B -->|Нет| C[Создать новый<br/>MatrixErpProperty]
+    B -->|Да| D[Загрузить существующий]
+    C --> E[Установить created_at,<br/>created_admin_id]
+    D --> F[Установить updated_at,<br/>updated_admin_id]
+    E --> G[Скачать главное изображение<br/>downloadAsUploadedFile]
+    F --> G
+    G --> H{Файл получен?}
+    H -->|Нет| I[Ошибка: не указана ссылка<br/>return false]
+    H -->|Да| J{Файл - изображение?}
+    J -->|Нет| K[Ошибка: неверный тип<br/>return false]
+    J -->|Да| L[Загрузить в Images]
+    L --> M[Установить image_id,<br/>external_image_url]
+    M --> N[Удалить старый файл]
+    N --> O{Есть image_urls?}
+    O -->|Да| P[purgeMatrixMedia foto]
+    P --> Q[Для каждого URL:<br/>скачать и сохранить]
+    Q --> R[Создать Files + MatrixErpMedia]
+    O -->|Нет| S{Есть video_url?}
+    R --> S
+    S -->|Да| T[purgeMatrixMedia video]
+    T --> U[Скачать видео]
+    U --> V[Создать Files + MatrixErpMedia]
+    S -->|Нет| W[Обновить текстовые поля]
+    V --> W
+    W --> X[display_name, description<br/>из parsed data]
+    X --> Y[product_url через<br/>MarketplaceService]
+    Y --> Z[Категории Flowwow, Yandex]
+    Z --> AA[Размеры: width, height]
+    AA --> AB[Сохранить MatrixErpProperty]
+    AB --> AC{Сохранено успешно?}
+    AC -->|Да| AD[return true]
+    AC -->|Нет| AE[return false]
+```
+
+## Константы
+
+| Константа | Значение | Назначение |
+|-----------|----------|-----------|
+| `OUT_DIR` | `/www/api2/json` | Директория для выходных JSON-файлов (закомментировано `__DIR__ . "/../json"`) |
+
+**Примечание**: Константа `OUT_DIR` определена, но не используется в коде контроллера.
+
+## Behaviors (поведения)
+
+### VerbFilter
+
+**Класс**: `yii\filters\VerbFilter`
+
+**Ограничения**:
+- `delete` — только POST-запросы
+
+**Цель**: Защита от случайного удаления товаров через GET-запросы.
+
+---
+
+## Связь с другими компонентами системы
+
+### Интеграция с маркетплейсами
+
+1. **MarketplaceService::getMarketplaceProducts()**
+   - Возвращает товары из Products1c для синхронизации
+   - Вызывается в `fillInGuidsFromMarketplaceAndAdditional()`
+
+2. **MarketplaceService::getProductImageUrl($imageId)**
+   - Генерирует публичную ссылку на изображение товара
+   - Используется при импорте изображений
+
+3. **MarketplaceService::getProductLinkByGuid($guid)**
+   - Генерирует ссылку на карточку товара в API
+   - Сохраняется в MatrixErpProperty->product_url
+
+### Работа с файлами
+
+1. **FileService::downloadAsUploadedFile($url)**
+   - Скачивает файл с внешнего URL как UploadedFile
+   - Используется для главного изображения
+
+2. **FileService::saveFromUrlToUploads($url, $adminId)**
+   - Скачивает файл и сохраняет в директорию uploads
+   - Возвращает информацию о файле (path, type)
+   - Используется для дополнительных фото и видео
+
+### Парсинг HTML
+
+1. **ProductParserService::parseProductHtml($html)**
+   - Парсит HTML карточки товара
+   - Извлекает: name, description, image_url, image_urls, video_url, properties
+   - Возвращает массив или объект с данными товара
+
+## Рекомендации по улучшению
+
+### Безопасность
+
+1. **Добавить RBAC-контроль** — ограничить доступ к критичным операциям:
+   ```php
+   public function beforeAction($action)
+   {
+       if (!parent::beforeAction($action)) {
+           return false;
+       }
+
+       $allowedActions = ['index', 'view'];
+       if (!in_array($action->id, $allowedActions)) {
+           if (!Yii::$app->user->can('manageMatrixErp')) {
+               throw new ForbiddenHttpException('Access denied');
+           }
+       }
+
+       return true;
+   }
+   ```
+
+2. **Валидация файлов** — проверка размера и MIME-типов загружаемых HTML-файлов:
+   ```php
+   public function rules()
+   {
+       return [
+           [['files'], 'file', 'maxFiles' => 10, 'maxSize' => 1024 * 1024 * 5],
+       ];
+   }
+   ```
+
+### Производительность
+
+1. **Оптимизация синхронизации** — не вызывать `fillInGuidsFromMarketplaceAndAdditional()` при каждом открытии списка:
+   - Вынести в консольную команду (cron)
+   - Или добавить кнопку "Синхронизировать" вместо автоматического вызова
+
+2. **Батчинг при синхронизации** — использовать `batchInsert()` для массовой вставки:
+   ```php
+   if (!empty($newRecords)) {
+       Yii::$app->db->createCommand()->batchInsert(
+           MatrixErp::tableName(),
+           ['guid', 'name', 'articule', 'code', 'components', 'parent_id', 'group_name', 'date_from'],
+           $newRecords
+       )->execute();
+   }
+   ```
+
+3. **Индексы БД** — добавить составной индекс:
+   ```sql
+   CREATE INDEX idx_matrix_erp_guid_group ON matrix_erp(guid, group_name);
+   ```
+
+### Код и архитектура
+
+1. **Вынести бизнес-логику в сервис** — создать `MatrixErpService`:
+   ```php
+   class MatrixErpService
+   {
+       public function syncFromMarketplace(): int
+       public function importFromHtml(array $files, string $category, string $subcategory): array
+       public function updateProductProperties(MatrixErp $matrix, array $data): bool
+   }
+   ```
+
+2. **Рефакторинг upsertProperty** — разбить метод на подметоды:
+   - `updateMainImage()`
+   - `updateAdditionalImages()`
+   - `updateVideo()`
+   - `updateTextFields()`
+
+3. **Удаление неиспользуемых импортов** — библиотеки для парсинга HTML не используются напрямую:
+   ```php
+   // Удалить:
+   use GuzzleHttp\Client;
+   use GuzzleHttp\Exception\RequestException;
+   use Symfony\Component\DomCrawler\Crawler;
+   use voku\helper\HtmlDomParser;
+   use DOMDocument;
+   use DOMXPath;
+   ```
+
+4. **Использовать константу OUT_DIR** — если не используется, удалить или использовать для экспорта:
+   ```php
+   public function actionExportJson()
+   {
+       $data = MatrixErp::find()->with('matrixProperty')->all();
+       file_put_contents(self::OUT_DIR . '/matrix_erp.json', Json::encode($data));
+   }
+   ```
+
+### Обработка ошибок
+
+1. **Транзакции при импорте** — обернуть `upsertProperty()` в транзакцию:
+   ```php
+   $transaction = Yii::$app->db->beginTransaction();
+   try {
+       $this->upsertProperty($matrix, $p, $category, $subcategory);
+       $transaction->commit();
+   } catch (\Exception $e) {
+       $transaction->rollBack();
+       throw $e;
+   }
+   ```
+
+2. **Детальное логирование** — добавить контекст в логи:
+   ```php
+   Yii::error("Failed to download image: {$imgUrl}", __METHOD__, [
+       'guid' => $matrixProp->guid,
+       'articule' => $matrix->articule,
+       'exception' => $e->getMessage()
+   ]);
+   ```
+
+### Юзабилити
+
+1. **Прогресс-бар для импорта** — использовать AJAX для отображения прогресса:
+   ```javascript
+   // Загружать файлы по одному через AJAX
+   // Обновлять прогресс-бар после каждого файла
+   ```
+
+2. **Превью перед импортом** — показывать список товаров перед импортом:
+   ```php
+   public function actionPreviewImport()
+   {
+       // Парсить файлы, но не сохранять
+       // Показать таблицу: артикул, название, найден/не найден
+       // Кнопка "Подтвердить импорт"
+   }
+   ```
+
+3. **Экспорт в Excel** — добавить возможность экспорта матрицы:
+   ```php
+   public function actionExportExcel()
+   {
+       // Использовать PhpSpreadsheet
+       // Экспорт товаров с properties и media
+   }
+   ```
+
+## Связанные файлы
+
+### Представления (Views)
+- `/erp24/views/matrix_erp/index.php` — список товаров матрицы
+- `/erp24/views/matrix_erp/view.php` — детальная информация о товаре
+- `/erp24/views/matrix_erp/create.php` — форма создания товара
+- `/erp24/views/matrix_erp/update.php` — форма редактирования товара
+- `/erp24/views/matrix_erp/parse-flowwow-cards.php` — форма импорта из HTML
+
+### Модели (Models)
+- `/erp24/records/MatrixErp.php` — основная модель товара
+- `/erp24/records/MatrixErpProperty.php` — свойства товара
+- `/erp24/records/MatrixErpMedia.php` — медиа-файлы товара
+- `/erp24/records/MatrixErpSearch.php` — модель поиска
+- `/erp24/records/Products1c.php` — товары из 1С
+- `/erp24/records/MarketplacePrices.php` — цены на маркетплейсах
+- `/erp24/records/Files.php` — файлы системы
+- `/erp24/records/Images.php` — изображения
+
+### Сервисы (Services)
+- `/erp24/services/MarketplaceService.php` — работа с маркетплейсами
+- `/erp24/services/FileService.php` — работа с файлами
+- `/erp24/services/ProductParserService.php` — парсинг карточек товаров
+
+### Формы (Forms)
+- `/erp24/models/HtmlImportForm.php` — форма импорта HTML
+
+### Миграции (Migrations)
+- `m*_create_matrix_erp_table.php` — создание таблицы matrix_erp
+- `m*_create_matrix_erp_property_table.php` — создание таблицы matrix_erp_property
+- `m*_create_matrix_erp_media_table.php` — создание таблицы matrix_erp_media
+- `m*_add_is_feed_active_to_matrix_erp.php` — добавление поля is_feed_active
+
+---
+
+## Итоги анализа
+
+**MatrixErpController** — критически важный контроллер для управления товарной матрицей с функциями:
+
+✅ **Реализовано**:
+- CRUD операции для товаров матрицы
+- Автоматическая синхронизация с Products1c
+- Импорт товаров из HTML с парсингом
+- Управление медиа-контентом (фото/видео)
+- Интеграция с маркетплейсами
+- AJAX-переключение активности в фидах
+
+⚠️ **Требует улучшения**:
+- Отсутствует RBAC-контроль доступа
+- Неоптимальная синхронизация при каждом открытии списка
+- Отсутствуют транзакции при импорте
+- Дублирование кода в методах работы с медиа
+- Отсутствует превью перед импортом
+
+🔧 **Рекомендуется**:
+- Вынести бизнес-логику в MatrixErpService
+- Добавить RBAC для критичных операций
+- Оптимизировать синхронизацию (cron, батчинг)
+- Добавить транзакции и детальное логирование
+- Реализовать превью импорта и экспорт в Excel
diff --git a/erp24/docs/controllers/non-standard/Products1cNomenclatureActualityController_ACTIONS_TABLE.md b/erp24/docs/controllers/non-standard/Products1cNomenclatureActualityController_ACTIONS_TABLE.md
new file mode 100644 (file)
index 0000000..9725a79
--- /dev/null
@@ -0,0 +1,877 @@
+# Products1cNomenclatureActualityController - Таблица actions
+
+## Полный список actions
+
+| # | Action | HTTP | Доступ | Назначение | Параметры | Возврат |
+|---|--------|------|--------|-----------|-----------|---------|
+| 1 | `actionIndex` | GET/POST | Public | Главная страница с фильтрацией и массовым редактированием актуальности | См. детали ниже | HTML или Redirect |
+| 2 | `actionAddActivity` | GET/POST | Public | Автоматическое добавление актуальности на основе истории продаж | `historyDays`, `intervalMonths`, `startFrom` | HTML |
+| 3 | `actionView` | GET | Public | Просмотр одной записи актуальности | `id` | HTML |
+| 4 | `actionCreate` | GET/POST | Public | Создание новой записи актуальности | POST: модель `Products1cNomenclatureActuality` | HTML или Redirect |
+| 5 | `actionUpdate` | GET/POST | Public | Редактирование записи актуальности | `id`, POST: атрибуты модели | HTML или Redirect |
+| 6 | `actionDelete` | POST | Public | Удаление записи актуальности | `id` | Redirect |
+| 7 | `actionAjaxDelete` | POST/GET | Public | AJAX-удаление с JSON-ответом | `id` | JSON |
+
+---
+
+## 1. actionIndex()
+
+### Назначение
+Главная страница управления актуальностью номенклатуры. Многоуровневая фильтрация товаров и массовое редактирование периодов актуальности.
+
+### HTTP методы
+- **GET** — отображение страницы с фильтрацией
+- **POST** — массовое сохранение актуальности
+
+### Параметры GET
+
+| Параметр | Тип | Обязательный | Значение по умолчанию | Описание |
+|----------|-----|--------------|----------------------|----------|
+| `category` | string | Нет | - | Категория товара (например, "Срезка", "Горшечные_растения") |
+| `subcategory` | string | Нет | - | Подкатегория товара |
+| `species` | string | Нет | - | Вид товара |
+| `type` | string | Нет | - | Тип товара (из дополнительных характеристик) |
+| `color` | string | Нет | - | Цвет товара |
+| `sort` | string | Нет | - | Сорт товара |
+| `size` | string | Нет | - | Размер товара |
+| `date_from` | string (Y-m) | Нет | - | Начало периода фильтрации актуальности |
+| `date_to` | string (Y-m) | Нет | - | Окончание периода фильтрации актуальности |
+| `onlyActive` | boolean | Нет | false | Показывать только товары с актуальностью |
+| `onlyInactive` | boolean | Нет | false | Показывать только товары без актуальности |
+
+### Параметры POST
+
+| Параметр | Тип | Обязательный | Описание |
+|----------|-----|--------------|----------|
+| `actuality` | array | Да | Массив записей актуальности для массового сохранения |
+| `actuality[N][id]` | int | Нет | ID существующей записи (для обновления) |
+| `actuality[N][guid]` | string | Да | GUID товара из `products_1c_nomenclature.id` |
+| `actuality[N][from]` | string (Y-m) | Да | Начало периода актуальности (формат: 2024-01) |
+| `actuality[N][to]` | string (Y-m) | Да | Окончание периода актуальности (формат: 2024-03) |
+
+### Возврат
+
+**GET**:
+- **Тип**: HTML
+- **View**: `index`
+- **Переменные**:
+  - `filter` (DynamicModel) — модель фильтра с загруженными значениями
+  - `dataProvider` (ActiveDataProvider | ArrayDataProvider) — провайдер данных с товарами и их актуальностью
+  - `categories` (array) — список категорий для фильтра
+  - `subcategories` (array) — список подкатегорий для фильтра
+  - `species` (array) — список видов для фильтра
+  - `types` (array) — список типов для фильтра
+  - `colors` (array) — список цветов для фильтра
+  - `sorts` (array) — список сортов для фильтра
+  - `sizes` (array) — список размеров для фильтра
+
+**POST**:
+- **Тип**: Redirect
+- **Flash-сообщение**: "Данные по актуальности успешно сохранены."
+- **Redirect**: `$this->refresh()` — обновление текущей страницы
+
+### Примеры использования
+
+#### Пример 1: Фильтрация роз с актуальностью в марте 2024
+
+**Запрос**:
+```http
+GET /products-1c-nomenclature-actuality/index?category=Срезка&species=Роза&date_from=2024-03&date_to=2024-03&onlyActive=1
+```
+
+**Результат**: Список всех роз, имеющих актуальность в марте 2024 года.
+
+#### Пример 2: Поиск неактуальных товаров в текущем месяце
+
+**Запрос**:
+```http
+GET /products-1c-nomenclature-actuality/index?date_from=2024-03&date_to=2024-03&onlyInactive=1
+```
+
+**Результат**: Список товаров без актуальности в марте 2024.
+
+#### Пример 3: Массовое сохранение актуальности
+
+**POST-запрос**:
+```http
+POST /products-1c-nomenclature-actuality/index
+Content-Type: application/x-www-form-urlencoded
+
+actuality[0][guid]=guid-rose-1&actuality[0][from]=2024-03&actuality[0][to]=2024-05&
+actuality[1][guid]=guid-tulip-1&actuality[1][from]=2024-02&actuality[1][to]=2024-04
+```
+
+**Результат**:
+- Создаются/обновляются записи актуальности
+- Пересекающиеся периоды автоматически объединяются
+- Flash-сообщение: "Данные по актуальности успешно сохранены."
+- Redirect на текущую страницу
+
+#### Пример 4: Фильтрация по характеристикам
+
+**Запрос**:
+```http
+GET /products-1c-nomenclature-actuality/index?category=Срезка&species=Роза&color=Красный&sort=Red Naomi&size=70
+```
+
+**Результат**: Список роз Red Naomi красного цвета размера 70 см.
+
+### Алгоритм работы
+
+```mermaid
+flowchart TD
+    Start([Начало: actionIndex]) --> LoadFilter[Загрузка фильтров из GET]
+
+    LoadFilter --> CheckPost{POST<br/>запрос?}
+
+    CheckPost -->|Да| ProcessBatch[processBatchActuality<br/>массовое сохранение]
+    ProcessBatch --> FlashSuccess[Flash: успех]
+    FlashSuccess --> Refresh[Redirect refresh]
+    Refresh --> End([Конец])
+
+    CheckPost -->|Нет| HasFilters{Фильтры<br/>заполнены?}
+
+    HasFilters -->|Нет| EmptyProvider[Пустой DataProvider<br/>query 0=1]
+    EmptyProvider --> PrepareDropdowns[Подготовка списков<br/>для фильтров]
+    PrepareDropdowns --> RenderView[Render index view]
+    RenderView --> End
+
+    HasFilters -->|Да| BuildQuery[Построение запроса<br/>Products1cNomenclature]
+
+    BuildQuery --> FilterCategory[Фильтр по категории,<br/>подкатегории, виду]
+    FilterCategory --> FilterCharacteristics[Фильтр по характеристикам<br/>через Additional Characteristics]
+    FilterCharacteristics --> FilterActuality[Фильтр по наличию<br/>актуальности]
+    FilterActuality --> FilterDates[Фильтр по датам<br/>актуальности]
+
+    FilterDates --> LoadProducts[Загрузка товаров<br/>with actualities]
+    LoadProducts --> BuildRows[Формирование строк<br/>product + actuality]
+
+    BuildRows --> CreateProvider[ArrayDataProvider<br/>1000 записей на страницу]
+    CreateProvider --> PrepareDropdowns
+
+    style Start fill:#e1f5e1
+    style End fill:#ffe1e1
+    style ProcessBatch fill:#fff4e1
+    style BuildQuery fill:#e1e5ff
+```
+
+### Особенности
+
+1. **Cascading фильтры**: Subcategories зависят от category, species от subcategory
+2. **Поддержка рус/англ**: Характеристики могут быть на русском и английском ('type'/'тип')
+3. **Пагинация**: 50 записей без фильтров, 1000 записей с фильтрами
+4. **Автоматическое объединение**: При сохранении пересекающиеся периоды объединяются
+5. **Dynamic Model**: Фильтр создается динамически, без отдельной модели
+
+---
+
+## 2. actionAddActivity()
+
+### Назначение
+Автоматическое добавление актуальности товарам на основе анализа истории продаж.
+
+### HTTP методы
+- **GET** — отображение формы или обработка с параметрами
+- **POST** — не используется напрямую
+
+### Параметры GET
+
+| Параметр | Тип | Обязательный | Значение по умолчанию | Описание |
+|----------|-----|--------------|----------------------|----------|
+| `historyDays` | int | Нет | 14 | Количество дней истории продаж для анализа |
+| `intervalMonths` | int | Нет | 4 | Количество месяцев актуальности в обе стороны от стартовой даты |
+| `startFrom` | string (Y-m-d) | Нет | today | Стартовая дата анализа |
+
+### Возврат
+
+**Без параметров**:
+- **Тип**: HTML
+- **View**: `add-activity`
+- **Переменные**:
+  - `historyDays` (int) — значение для формы
+  - `intervalMonths` (int) — значение для формы
+  - `startFrom` (string) — значение для формы
+
+**С параметрами**:
+- **Тип**: HTML
+- **View**: `add-activity`
+- **Flash-сообщение**: "Обновлено актуальностей для N товаров" или "Нет товаров за указанный период"
+
+### Примеры использования
+
+#### Пример 1: Актуальность для товаров, проданных за последнюю неделю
+
+**Запрос**:
+```http
+GET /products-1c-nomenclature-actuality/add-activity?historyDays=7&intervalMonths=3&startFrom=2024-03-31
+```
+
+**Алгоритм**:
+1. Анализ продаж с 2024-03-24 по 2024-03-31
+2. Поиск товаров с продажами за этот период
+3. Установка актуальности с 2023-12-01 по 2024-06-30 (3 месяца назад и вперед)
+
+**SQL-запрос**:
+```sql
+SELECT DISTINCT sp.product_id
+FROM sales s
+INNER JOIN sales_products sp ON s.id = sp.check_id
+INNER JOIN products_1c_nomenclature p1c ON p1c.id = sp.product_id
+WHERE s.date BETWEEN '2024-03-24 00:00:00' AND '2024-03-31 23:59:59'
+GROUP BY sp.product_id
+```
+
+**Результат**:
+- Найдено 150 товаров с продажами
+- Для каждого товара создана/обновлена актуальность:
+  ```
+  guid: product-1, date_from: 2023-12-01 00:00:00, date_to: 2024-06-30 23:59:59
+  guid: product-2, date_from: 2023-12-01 00:00:00, date_to: 2024-06-30 23:59:59
+  ...
+  ```
+- Flash-сообщение: "Обновлено актуальностей для 150 товаров."
+
+#### Пример 2: Актуальность для товаров за 30 дней с широким интервалом
+
+**Запрос**:
+```http
+GET /products-1c-nomenclature-actuality/add-activity?historyDays=30&intervalMonths=6&startFrom=2024-03-15
+```
+
+**Алгоритм**:
+1. Анализ продаж с 2024-02-14 по 2024-03-15
+2. Установка актуальности с 2023-09-01 по 2024-09-30 (6 месяцев назад и вперед)
+
+**Результат**: Товары с продажами за последний месяц получают актуальность на целый год.
+
+#### Пример 3: Нет товаров с продажами
+
+**Запрос**:
+```http
+GET /products-1c-nomenclature-actuality/add-activity?historyDays=7&intervalMonths=3&startFrom=2020-01-01
+```
+
+**Результат**:
+- SQL-запрос не вернул товаров (нет продаж в 2020 году)
+- Flash-сообщение: "Нет товаров за указанный период."
+- Render формы с сохранением параметров
+
+### Алгоритм работы
+
+```mermaid
+flowchart TD
+    Start([Начало: actionAddActivity]) --> CheckParams{Параметры<br/>указаны?}
+
+    CheckParams -->|Нет| RenderForm[Render формы<br/>со значениями по умолчанию]
+    RenderForm --> End([Конец])
+
+    CheckParams -->|Да| CalcPeriod[Расчет периода анализа<br/>startDate = startFrom - historyDays<br/>endDate = startFrom]
+
+    CalcPeriod --> QuerySales[SQL: поиск товаров<br/>с продажами за период]
+
+    QuerySales --> HasProducts{Есть<br/>товары?}
+
+    HasProducts -->|Нет| FlashInfo[Flash: Нет товаров]
+    FlashInfo --> RenderFormWithParams[Render формы<br/>с параметрами]
+    RenderFormWithParams --> End
+
+    HasProducts -->|Да| CalcActuality[Расчет актуальности<br/>from = startFrom - intervalMonths<br/>to = startFrom + intervalMonths]
+
+    CalcActuality --> PrepareRows[Формирование массива<br/>[guid, from, to]]
+
+    PrepareRows --> ProcessBatch[processBatchActuality<br/>массовое сохранение]
+
+    ProcessBatch --> FlashSuccess[Flash: Обновлено N товаров]
+    FlashSuccess --> RenderFormWithParams
+
+    style Start fill:#e1f5e1
+    style End fill:#ffe1e1
+    style ProcessBatch fill:#fff4e1
+    style QuerySales fill:#e1e5ff
+```
+
+### Особенности
+
+1. **Анализ реальных продаж**: Используется таблица `sales` с реальными чеками
+2. **Автоматический расчет периода**: Актуальность устанавливается симметрично относительно `startFrom`
+3. **Округление до месяца**: Начало периода → первый день месяца, окончание → последний день месяца
+4. **Интеграция с объединением**: Вызывается `processBatchActuality()` для умного объединения периодов
+5. **Идемпотентность**: Можно запускать многократно, периоды будут объединяться
+
+### Бизнес-сценарии
+
+**Сценарий 1: Еженедельная актуализация**
+```bash
+# Каждую неделю запускать
+curl "http://erp.example.com/products-1c-nomenclature-actuality/add-activity?historyDays=7&intervalMonths=3"
+```
+
+**Сценарий 2: Сезонная актуализация**
+```bash
+# Перед началом сезона
+curl "http://erp.example.com/products-1c-nomenclature-actuality/add-activity?historyDays=30&intervalMonths=6&startFrom=2024-03-01"
+```
+
+**Сценарий 3: Исторический анализ**
+```bash
+# Актуализация на основе прошлого года
+curl "http://erp.example.com/products-1c-nomenclature-actuality/add-activity?historyDays=365&intervalMonths=12&startFrom=2023-12-31"
+```
+
+---
+
+## 3. actionView($id)
+
+### Назначение
+Просмотр деталей одной записи актуальности.
+
+### HTTP методы
+- **GET**
+
+### Параметры
+
+| Параметр | Тип | Обязательный | Описание |
+|----------|-----|--------------|----------|
+| `id` | int | Да | ID записи `Products1cNomenclatureActuality` |
+
+### Возврат
+
+- **Тип**: HTML
+- **View**: `view`
+- **Переменные**:
+  - `model` (Products1cNomenclatureActuality) — модель записи актуальности
+
+### Исключения
+
+- **NotFoundHttpException** (404) — если запись с указанным `id` не найдена
+
+### Примеры использования
+
+**Запрос**:
+```http
+GET /products-1c-nomenclature-actuality/view?id=123
+```
+
+**Результат**: HTML-страница с деталями записи актуальности (GUID товара, даты, информация о создании/обновлении).
+
+---
+
+## 4. actionCreate()
+
+### Назначение
+Создание новой записи актуальности через форму.
+
+### HTTP методы
+- **GET** — отображение формы
+- **POST** — сохранение новой записи
+
+### Параметры GET
+
+Нет параметров.
+
+### Параметры POST
+
+| Параметр | Тип | Обязательный | Описание |
+|----------|-----|--------------|----------|
+| `Products1cNomenclatureActuality[guid]` | string | Да | GUID товара из `products_1c_nomenclature.id` |
+| `Products1cNomenclatureActuality[date_from]` | datetime | Да | Начало периода актуальности |
+| `Products1cNomenclatureActuality[date_to]` | datetime | Да | Окончание периода актуальности |
+
+### Возврат
+
+**GET**:
+- **Тип**: HTML
+- **View**: `create`
+- **Переменные**:
+  - `model` (Products1cNomenclatureActuality) — пустая модель с дефолтными значениями
+
+**POST (успех)**:
+- **Тип**: Redirect
+- **URL**: `/products-1c-nomenclature-actuality/view?id={id}`
+
+**POST (ошибка)**:
+- **Тип**: HTML
+- **View**: `create`
+- **Переменные**:
+  - `model` (Products1cNomenclatureActuality) — модель с ошибками валидации
+
+### Примеры использования
+
+**GET-запрос**:
+```http
+GET /products-1c-nomenclature-actuality/create
+```
+
+**Результат**: Форма создания записи актуальности.
+
+**POST-запрос**:
+```http
+POST /products-1c-nomenclature-actuality/create
+Content-Type: application/x-www-form-urlencoded
+
+Products1cNomenclatureActuality[guid]=abc-123-def&
+Products1cNomenclatureActuality[date_from]=2024-03-01 00:00:00&
+Products1cNomenclatureActuality[date_to]=2024-05-31 23:59:59
+```
+
+**Результат (успех)**:
+- Создана запись в БД
+- Redirect на `/products-1c-nomenclature-actuality/view?id=456`
+
+**Результат (ошибка)**:
+- Render формы с сообщениями об ошибках (например, "GUID не может быть пустым")
+
+---
+
+## 5. actionUpdate($id)
+
+### Назначение
+Редактирование существующей записи актуальности.
+
+### HTTP методы
+- **GET** — отображение формы
+- **POST** — сохранение изменений
+
+### Параметры GET
+
+| Параметр | Тип | Обязательный | Описание |
+|----------|-----|--------------|----------|
+| `id` | int | Да | ID записи `Products1cNomenclatureActuality` |
+
+### Параметры POST
+
+| Параметр | Тип | Обязательный | Описание |
+|----------|-----|--------------|----------|
+| `id` | int | Да | ID записи (в URL) |
+| `Products1cNomenclatureActuality[guid]` | string | Да | GUID товара |
+| `Products1cNomenclatureActuality[date_from]` | datetime | Да | Начало периода актуальности |
+| `Products1cNomenclatureActuality[date_to]` | datetime | Да | Окончание периода актуальности |
+
+### Возврат
+
+**GET**:
+- **Тип**: HTML
+- **View**: `update`
+- **Переменные**:
+  - `model` (Products1cNomenclatureActuality) — загруженная модель
+
+**POST (успех)**:
+- **Тип**: Redirect
+- **URL**: `/products-1c-nomenclature-actuality/view?id={id}`
+
+**POST (ошибка)**:
+- **Тип**: HTML
+- **View**: `update`
+- **Переменные**:
+  - `model` (Products1cNomenclatureActuality) — модель с ошибками валидации
+
+### Исключения
+
+- **NotFoundHttpException** (404) — если запись с указанным `id` не найдена
+
+### Примеры использования
+
+**GET-запрос**:
+```http
+GET /products-1c-nomenclature-actuality/update?id=123
+```
+
+**Результат**: Форма редактирования записи актуальности (поля заполнены текущими значениями).
+
+**POST-запрос**:
+```http
+POST /products-1c-nomenclature-actuality/update?id=123
+Content-Type: application/x-www-form-urlencoded
+
+Products1cNomenclatureActuality[guid]=abc-123-def&
+Products1cNomenclatureActuality[date_from]=2024-03-01 00:00:00&
+Products1cNomenclatureActuality[date_to]=2024-06-30 23:59:59
+```
+
+**Результат (успех)**:
+- Обновлена запись в БД
+- Redirect на `/products-1c-nomenclature-actuality/view?id=123`
+
+---
+
+## 6. actionDelete($id)
+
+### Назначение
+Удаление записи актуальности (стандартный CRUD).
+
+### HTTP методы
+- **POST** (ограничено VerbFilter)
+
+### Параметры
+
+| Параметр | Тип | Обязательный | Описание |
+|----------|-----|--------------|----------|
+| `id` | int | Да | ID записи `Products1cNomenclatureActuality` |
+
+### Возврат
+
+- **Тип**: Redirect
+- **URL**: `/products-1c-nomenclature-actuality/index`
+
+### Исключения
+
+- **NotFoundHttpException** (404) — если запись с указанным `id` не найдена
+- **MethodNotAllowedHttpException** (405) — если используется GET вместо POST
+
+### Примеры использования
+
+**HTML-форма**:
+```html
+<form method="POST" action="/products-1c-nomenclature-actuality/delete?id=123">
+    <input type="hidden" name="_csrf" value="<?= Yii::$app->request->csrfToken ?>">
+    <button type="submit">Удалить</button>
+</form>
+```
+
+**Результат**:
+- Запись удалена из БД
+- Redirect на `/products-1c-nomenclature-actuality/index`
+
+---
+
+## 7. actionAjaxDelete()
+
+### Назначение
+AJAX-удаление записи актуальности с JSON-ответом.
+
+### HTTP методы
+- **POST** (рекомендуется)
+- **GET** (поддерживается для AJAX)
+
+### Параметры
+
+| Параметр | Тип | Обязательный | Источник | Описание |
+|----------|-----|--------------|----------|----------|
+| `id` | int | Да | POST или GET | ID записи `Products1cNomenclatureActuality` |
+
+### Возврат
+
+**Формат**: JSON (`Response::FORMAT_JSON`)
+
+**Успех**:
+```json
+{
+  "success": true,
+  "message": "Запись успешно удалена"
+}
+```
+
+**Ошибка**:
+```json
+{
+  "success": false,
+  "message": "The requested page does not exist."
+}
+```
+
+### Исключения
+
+- **BadRequestHttpException** (400) — если параметр `id` отсутствует
+
+### Примеры использования
+
+#### Пример 1: jQuery AJAX
+
+**JavaScript**:
+```javascript
+function deleteActuality(id) {
+    if (!confirm('Удалить запись актуальности?')) {
+        return;
+    }
+
+    $.ajax({
+        url: '/products-1c-nomenclature-actuality/ajax-delete',
+        type: 'POST',
+        data: { id: id },
+        dataType: 'json',
+        success: function(response) {
+            if (response.success) {
+                alert(response.message);
+                // Удалить строку из таблицы
+                $('tr[data-id="' + id + '"]').fadeOut(function() {
+                    $(this).remove();
+                });
+            } else {
+                alert('Ошибка: ' + response.message);
+            }
+        },
+        error: function(xhr, status, error) {
+            alert('Ошибка сервера: ' + error);
+        }
+    });
+}
+```
+
+**HTML**:
+```html
+<button onclick="deleteActuality(123)" class="btn btn-danger">
+    <i class="fa fa-trash"></i> Удалить
+</button>
+```
+
+#### Пример 2: Fetch API
+
+**JavaScript**:
+```javascript
+async function deleteActuality(id) {
+    if (!confirm('Удалить запись актуальности?')) {
+        return;
+    }
+
+    try {
+        const response = await fetch('/products-1c-nomenclature-actuality/ajax-delete', {
+            method: 'POST',
+            headers: {
+                'Content-Type': 'application/x-www-form-urlencoded',
+            },
+            body: new URLSearchParams({ id: id })
+        });
+
+        const result = await response.json();
+
+        if (result.success) {
+            alert(result.message);
+            document.querySelector(`tr[data-id="${id}"]`).remove();
+        } else {
+            alert('Ошибка: ' + result.message);
+        }
+    } catch (error) {
+        alert('Ошибка сервера: ' + error.message);
+    }
+}
+```
+
+#### Пример 3: cURL (тестирование)
+
+**Bash**:
+```bash
+curl -X POST "http://erp.example.com/products-1c-nomenclature-actuality/ajax-delete" \
+     -H "Content-Type: application/x-www-form-urlencoded" \
+     -d "id=123"
+```
+
+**Ответ**:
+```json
+{
+  "success": true,
+  "message": "Запись успешно удалена"
+}
+```
+
+### Особенности
+
+1. **Поддержка GET и POST**: Для удобства AJAX-запросов
+2. **JSON-формат**: Автоматическая сериализация ответа
+3. **Try-catch**: Обработка всех исключений с возвратом JSON
+4. **CSRF-защита**: Yii2 автоматически проверяет CSRF-токен для POST-запросов
+
+---
+
+## Вспомогательные методы
+
+### processBatchActuality(array $post)
+
+**Назначение**: Массовая обработка актуальности с автоматическим объединением пересекающихся периодов.
+
+**Тип**: `protected`
+
+**Вызывается из**:
+- `actionIndex()` (POST)
+- `actionAddActivity()`
+
+**Параметры**:
+```php
+$post = [
+    [
+        'id' => 123, // optional, для обновления существующей записи
+        'guid' => 'abc-def-123',
+        'from' => '2024-03', // Y-m
+        'to' => '2024-05',   // Y-m
+    ],
+    // ... другие записи
+];
+```
+
+**Алгоритм**:
+1. Валидация и преобразование дат (Y-m → datetime)
+2. Расчет границ пересечения (±1 секунда)
+3. Поиск пересекающихся/смежных периодов
+4. Объединение периодов (минимальный from, максимальный to)
+5. Удаление поглощенных записей
+6. Рекурсивное объединение до отсутствия пересечений
+
+**Особенности**:
+- Атомарность: ошибка в одной записи не блокирует остальные
+- Рекурсивность: цикл `while(true)` для полного объединения
+- Аудит: сохранение `created_by`, `updated_by`, `created_at`, `updated_at`
+
+**Пример**:
+```php
+// Исходные данные
+$post = [
+    ['guid' => 'abc', 'from' => '2024-01', 'to' => '2024-03'],
+    ['guid' => 'abc', 'from' => '2024-02', 'to' => '2024-04'],
+];
+
+// Результат в БД
+// guid: abc, date_from: 2024-01-01 00:00:00, date_to: 2024-04-30 23:59:59
+```
+
+---
+
+### findModel($id)
+
+**Назначение**: Поиск модели по ID с выбросом NotFoundHttpException.
+
+**Тип**: `protected`
+
+**Вызывается из**:
+- `actionView()`
+- `actionUpdate()`
+- `actionDelete()`
+- `actionAjaxDelete()`
+
+**Параметры**:
+- `$id` (int) — ID записи
+
+**Возврат**:
+- `Products1cNomenclatureActuality` — найденная модель
+
+**Исключения**:
+- `NotFoundHttpException` (404) — если модель не найдена
+
+**Код**:
+```php
+protected function findModel($id)
+{
+    if (($model = Products1cNomenclatureActuality::findOne(['id' => $id])) !== null) {
+        return $model;
+    }
+
+    throw new NotFoundHttpException('The requested page does not exist.');
+}
+```
+
+---
+
+## Сводная таблица HTTP-методов и доступа
+
+| Action | GET | POST | PUT | DELETE | AJAX | RBAC |
+|--------|-----|------|-----|--------|------|------|
+| `actionIndex` | ✅ | ✅ | ❌ | ❌ | Частично | Нет |
+| `actionAddActivity` | ✅ | ❌ | ❌ | ❌ | ❌ | Нет |
+| `actionView` | ✅ | ❌ | ❌ | ❌ | ❌ | Нет |
+| `actionCreate` | ✅ | ✅ | ❌ | ❌ | ❌ | Нет |
+| `actionUpdate` | ✅ | ✅ | ❌ | ❌ | ❌ | Нет |
+| `actionDelete` | ❌ | ✅ | ❌ | ❌ | ❌ | Нет |
+| `actionAjaxDelete` | ✅* | ✅ | ❌ | ❌ | ✅ | Нет |
+
+_*GET поддерживается для AJAX, но рекомендуется использовать POST_
+
+---
+
+## Типовые сценарии использования
+
+### Сценарий 1: Установка сезонной актуальности
+
+**Задача**: Установить актуальность для всех тюльпанов на весенний период (февраль-май).
+
+**Шаги**:
+1. `GET /products-1c-nomenclature-actuality/index?category=Срезка&species=Тюльпан`
+2. В таблице для каждого тюльпана установить `from=2024-02`, `to=2024-05`
+3. `POST /products-1c-nomenclature-actuality/index` с массивом `actuality`
+
+**Результат**: Все тюльпаны актуальны с 2024-02-01 по 2024-05-31.
+
+### Сценарий 2: Автоматическое обновление актуальности
+
+**Задача**: Еженедельно обновлять актуальность товаров, которые продавались.
+
+**Cron-задача**:
+```bash
+0 2 * * 1 curl "http://erp.example.com/products-1c-nomenclature-actuality/add-activity?historyDays=7&intervalMonths=3"
+```
+
+**Результат**: Каждую неделю товары с продажами получают актуальность на 6 месяцев (3 назад + 3 вперед).
+
+### Сценарий 3: Поиск неактуальных товаров
+
+**Задача**: Найти товары, которые не актуальны в текущем месяце, для исключения из планирования.
+
+**Запрос**:
+```
+GET /products-1c-nomenclature-actuality/index?date_from=2024-03&date_to=2024-03&onlyInactive=1
+```
+
+**Результат**: Список товаров без актуальности в марте 2024.
+
+### Сценарий 4: AJAX-удаление из таблицы
+
+**Задача**: Удалить запись актуальности без перезагрузки страницы.
+
+**JavaScript**:
+```javascript
+$('.btn-delete-actuality').on('click', function() {
+    const id = $(this).data('id');
+    const row = $(this).closest('tr');
+
+    if (!confirm('Удалить запись?')) return;
+
+    $.post('/products-1c-nomenclature-actuality/ajax-delete', {id: id}, function(response) {
+        if (response.success) {
+            row.fadeOut(function() { $(this).remove(); });
+            toastr.success(response.message);
+        } else {
+            toastr.error(response.message);
+        }
+    });
+});
+```
+
+**Результат**: Строка исчезает из таблицы без перезагрузки страницы.
+
+---
+
+## Рекомендации по использованию
+
+### 1. Производительность
+
+- **Используйте фильтры**: Избегайте загрузки всех товаров без фильтров
+- **Пагинация**: При больших выборках используйте пагинацию
+- **Индексы БД**: Убедитесь, что есть индексы на `guid`, `date_from`, `date_to`
+
+### 2. Безопасность
+
+- **RBAC**: Добавьте контроль доступа для критичных actions (create, update, delete)
+- **CSRF**: Всегда используйте CSRF-токены для POST-запросов
+- **Валидация**: Проверяйте, что `date_from <= date_to`
+
+### 3. UX
+
+- **AJAX**: Используйте `actionAjaxDelete()` для лучшего UX
+- **Flash-сообщения**: Всегда информируйте пользователя о результате операции
+- **Подтверждение удаления**: Запрашивайте подтверждение перед удалением
+
+### 4. Мониторинг
+
+- **Логирование**: Логируйте массовые операции в `processBatchActuality()`
+- **Ошибки**: Мониторьте ошибки валидации и сохранения
+- **Производительность**: Отслеживайте время выполнения `actionAddActivity()`
+
+---
+
+## Заключение
+
+**Products1cNomenclatureActualityController** предоставляет полный набор actions для управления актуальностью номенклатуры:
+
+- **CRUD-операции**: стандартные view, create, update, delete
+- **Массовые операции**: `actionIndex()` с фильтрацией и пакетным сохранением
+- **Автоматизация**: `actionAddActivity()` для определения актуальности по продажам
+- **AJAX**: `actionAjaxDelete()` для асинхронного удаления
+
+Контроллер обеспечивает гибкое и удобное управление временными периодами актуальности товаров с автоматическим объединением пересекающихся интервалов.
diff --git a/erp24/docs/controllers/non-standard/Products1cNomenclatureActualityController_ANALYSIS.md b/erp24/docs/controllers/non-standard/Products1cNomenclatureActualityController_ANALYSIS.md
new file mode 100644 (file)
index 0000000..9200873
--- /dev/null
@@ -0,0 +1,1436 @@
+# Products1cNomenclatureActualityController - Полный анализ
+
+## Метаданные
+
+| Параметр | Значение |
+|----------|----------|
+| **Namespace** | `app\controllers` |
+| **Extends** | `yii\web\Controller` |
+| **Размер файла** | 670 строк |
+| **Приоритет** | 1 (критичный) |
+| **Путь** | `erp24/controllers/Products1cNomenclatureActualityController.php` |
+
+## Назначение и бизнес-цель
+
+**Products1cNomenclatureActualityController** — контроллер для управления актуальностью номенклатуры товаров, импортированной из 1С. Основная бизнес-задача — определить и управлять временными периодами актуальности каждой позиции номенклатуры для правильного планирования закупок, продаж и логистики.
+
+### Ключевые бизнес-функции:
+
+1. **Управление периодами актуальности** — определение временных интервалов (от-до), когда товар актуален для продажи
+2. **Массовое обновление актуальности** — пакетное изменение периодов актуальности для множества товаров
+3. **Автоматическое добавление актуальности** — определение актуальности товаров на основе истории продаж
+4. **Фильтрация номенклатуры** — многоуровневая фильтрация по категориям, подкатегориям, видам, типам, цветам, сортам, размерам
+5. **Объединение пересекающихся периодов** — автоматическое слияние перекрывающихся или смежных периодов актуальности
+6. **Интеграция с историей продаж** — анализ продаж за указанный период для определения активных товаров
+
+### Бизнес-контекст:
+
+Контроллер решает проблему управления сезонностью и актуальностью товаров:
+- Цветы имеют сезонность (например, тюльпаны актуальны весной)
+- Некоторые товары актуальны круглый год
+- Необходимо планировать закупки только актуальных товаров
+- Система автоматически определяет актуальность по истории продаж
+
+## Access Control (RBAC)
+
+**Нет явных правил RBAC** в коде контроллера. Доступ не ограничен через `behaviors()` или `beforeAction()`.
+
+> ⚠️ **ВАЖНО**: Отсутствие контроля доступа означает, что все действия контроллера доступны всем авторизованным пользователям. Рекомендуется добавить проверку прав доступа, так как контроллер управляет критичными бизнес-данными.
+
+### Behaviors
+
+**VerbFilter**: Ограничивает HTTP-методы для действий
+
+```php
+'verbs' => [
+    'class' => VerbFilter::className(),
+    'actions' => [
+        'delete' => ['POST'],
+    ],
+]
+```
+
+- `delete` — только POST
+
+## Архитектура и зависимости
+
+### Используемые модели (ActiveRecord)
+
+| Модель | Namespace | Назначение |
+|--------|-----------|-----------|
+| **Products1cNomenclatureActuality** | `yii_app\records` | Хранение периодов актуальности номенклатуры (guid, date_from, date_to) |
+| **Products1cNomenclature** | `yii_app\records` | Номенклатура товаров из 1С (категории, подкатегории, виды, характеристики) |
+| **Products1cAdditionalCharacteristics** | `yii_app\records` | Дополнительные характеристики номенклатуры (цвет, размер, сорт, тип) |
+| **Products1cPropType** | `yii_app\records` | Типы свойств номенклатуры |
+| **Products1cNomenclatureActualitySearch** | `yii_app\records` | Search-модель для фильтрации актуальности |
+| **Sales** | `yii_app\api3\modules\v1\models` | Данные продаж для анализа активности товаров |
+
+### Используемые компоненты Yii2
+
+| Компонент | Назначение |
+|-----------|-----------|
+| **yii\db\Query** | Построение SQL-запросов |
+| **yii\web\Response** | Форматирование ответов (JSON для AJAX) |
+| **yii\base\DynamicModel** | Динамические модели для фильтров |
+| **yii\data\ActiveDataProvider** | Провайдер данных для моделей |
+| **yii\data\ArrayDataProvider** | Провайдер данных для массивов |
+| **yii\filters\VerbFilter** | Фильтр HTTP-методов |
+| **yii\web\NotFoundHttpException** | Исключение 404 |
+| **yii\web\BadRequestHttpException** | Исключение 400 |
+
+### Структура данных
+
+**Таблица `products_1c_nomenclature_actuality`**:
+
+```sql
+- id (int, PK)
+- guid (string, FK → products_1c_nomenclature.id)
+- date_from (datetime) — начало периода актуальности
+- date_to (datetime) — окончание периода актуальности
+- created_at (datetime)
+- created_by (int, FK → user.id)
+- updated_at (datetime)
+- updated_by (int, FK → user.id)
+```
+
+**Связи**:
+- `Products1cNomenclatureActuality.guid → Products1cNomenclature.id` (1:M)
+- `Products1cNomenclature.actualities` — все периоды актуальности для товара
+
+## Список actions и их назначение
+
+| # | Action | HTTP Method | Доступ | Назначение |
+|---|--------|-------------|--------|-----------|
+| 1 | `actionIndex` | GET/POST | Public | Главная страница с фильтрацией и массовым редактированием актуальности |
+| 2 | `actionAddActivity` | GET/POST | Public | Автоматическое добавление актуальности на основе истории продаж |
+| 3 | `actionView` | GET | Public | Просмотр одной записи актуальности |
+| 4 | `actionCreate` | GET/POST | Public | Создание новой записи актуальности |
+| 5 | `actionUpdate` | GET/POST | Public | Редактирование записи актуальности |
+| 6 | `actionDelete` | POST | Public | Удаление записи актуальности |
+| 7 | `actionAjaxDelete` | POST/GET (AJAX) | Public | AJAX-удаление записи актуальности с JSON-ответом |
+
+### Вспомогательные методы
+
+| Метод | Тип | Назначение |
+|-------|-----|-----------|
+| `processBatchActuality(array $post)` | protected | Массовая обработка актуальности с объединением пересекающихся периодов |
+| `findModel($id)` | protected | Поиск записи по ID с выбросом NotFoundHttpException |
+
+## Детальное описание actions
+
+### 1. actionIndex()
+
+**Назначение**: Главная страница управления актуальностью номенклатуры. Предоставляет мощную систему фильтрации и массового редактирования периодов актуальности.
+
+**Параметры (GET)**:
+
+- `category` (string) — категория товара (например, "Срезка", "Горшечные_растения")
+- `subcategory` (string) — подкатегория товара
+- `species` (string) — вид товара
+- `type` (string) — тип товара (из дополнительных характеристик)
+- `color` (string) — цвет товара (из дополнительных характеристик)
+- `sort` (string) — сорт товара (из дополнительных характеристик)
+- `size` (string) — размер товара (из дополнительных характеристик)
+- `date_from` (string, Y-m) — начало периода фильтрации актуальности
+- `date_to` (string, Y-m) — окончание периода фильтрации актуальности
+- `onlyActive` (bool) — показывать только товары с актуальностью
+- `onlyInactive` (bool) — показывать только товары без актуальности
+
+**Параметры (POST)**:
+
+- `actuality` (array) — массив записей актуальности для массового сохранения
+  - `id` (int, optional) — ID существующей записи (для обновления)
+  - `guid` (string) — GUID товара
+  - `from` (string, Y-m) — начало периода актуальности
+  - `to` (string, Y-m) — окончание периода актуальности
+
+**Алгоритм работы**:
+
+1. **Создание динамической модели фильтра**:
+   - Создается `DynamicModel` с атрибутами фильтрации
+   - Загружаются параметры из GET-запроса
+
+2. **Обработка POST-запроса** (массовое сохранение):
+   - Если пришел POST с массивом `actuality`
+   - Вызывается `processBatchActuality()` для обработки
+   - Устанавливается flash-сообщение об успехе
+   - Страница обновляется
+
+3. **Построение запроса данных**:
+
+   **3.1. Без фильтров**:
+   - Возвращается пустой `ActiveDataProvider` (query с условием `0=1`)
+
+   **3.2. С фильтрами**:
+
+   - **Фильтрация по категориям** (category, subcategory, species):
+     ```php
+     Products1cNomenclature::find()
+         ->andWhere(['category' => $category])
+         ->andWhere(['subcategory' => $subcategory])
+         ->andWhere(['species' => $species])
+     ```
+
+   - **Фильтрация по характеристикам** (type, color, sort, size):
+     ```php
+     // Поиск ID свойств через Products1cPropType
+     $propIds = Products1cPropType::find()
+         ->where(['name' => ['type', 'тип']]) // поддержка рус/англ
+         ->column();
+
+     // Поиск товаров с нужным значением характеристики
+     $ids = Products1cAdditionalCharacteristics::find()
+         ->where(['property_id' => $propIds, 'value' => $value])
+         ->column();
+
+     $query->andWhere(['n.id' => $ids]);
+     ```
+
+   - **Фильтрация по наличию актуальности**:
+     ```php
+     // Только с актуальностью
+     if (onlyActive) {
+         $query->andWhere(['exists',
+             Products1cNomenclatureActuality::find()
+                 ->where('guid = n.id')
+         ]);
+     }
+
+     // Только без актуальности
+     if (onlyInactive) {
+         $query->andWhere(['not exists',
+             Products1cNomenclatureActuality::find()
+                 ->where('guid = n.id')
+         ]);
+     }
+     ```
+
+   - **Фильтрация по датам актуальности**:
+     ```php
+     // Преобразование Y-m в datetime
+     $dateFrom = new DateTime("{$filter->date_from}-01");
+     $dateFrom->setTime(0, 0, 0);
+
+     $dateTo = new DateTime("{$filter->date_to}-01");
+     $dateTo->modify('last day of this month')->setTime(23, 59, 59);
+
+     // Поиск пересечений периодов
+     $dateExists = Products1cNomenclatureActuality::find()
+         ->where('a.guid = n.id')
+         ->andWhere(['>=', 'a.date_to', $dateFrom])
+         ->andWhere(['<=', 'a.date_from', $dateTo]);
+
+     $query->andWhere(['exists', $dateExists]);
+     ```
+
+4. **Формирование результатов**:
+   - Запрос товаров с `with(['actualities'])` для загрузки связанных периодов
+   - Для каждого товара создается строка на каждый период актуальности:
+     ```php
+     foreach ($products as $product) {
+         foreach ($product->actualities as $actuality) {
+             $rows[] = [
+                 'product' => $product,
+                 'actuality' => $actuality,
+             ];
+         }
+     }
+     ```
+   - Если у товара нет актуальности, создается строка с `actuality = null`
+
+5. **Формирование списков для фильтров**:
+   - `categories` — уникальные категории из номенклатуры
+   - `subcategories` — подкатегории для выбранной категории
+   - `species` — виды для выбранной подкатегории
+   - `types`, `colors`, `sorts`, `sizes` — значения из `Products1cAdditionalCharacteristics`
+
+**Возврат**:
+- Render `index` view с:
+  - `filter` — модель фильтра
+  - `dataProvider` — провайдер данных (товары × периоды актуальности)
+  - Списки значений для фильтров
+
+**Особенности**:
+- Поддержка русских и английских названий свойств (например, 'type'/'тип')
+- Cascading фильтры (subcategories зависят от category, species от subcategory)
+- Сортировка по названию товара
+- Пагинация: 50 записей (без фильтров), 1000 записей (с фильтрами)
+
+---
+
+### 2. actionAddActivity()
+
+**Назначение**: Автоматическое добавление актуальности товарам на основе истории продаж. Анализирует продажи за указанный период и создает периоды актуальности для проданных товаров.
+
+**Параметры (GET)**:
+
+- `historyDays` (int) — количество дней истории продаж для анализа (по умолчанию 14)
+- `intervalMonths` (int) — количество месяцев актуальности в обе стороны от стартовой даты (по умолчанию 4)
+- `startFrom` (string, Y-m-d) — стартовая дата анализа (по умолчанию сегодня)
+
+**Алгоритм работы**:
+
+1. **Отображение формы** (если параметры не указаны):
+   - Render `add-activity` view с формой ввода параметров
+   - Значения по умолчанию: `historyDays=14`, `intervalMonths=4`, `startFrom=today`
+
+2. **Обработка запроса** (если параметры указаны):
+
+   **2.1. Расчет периода анализа продаж**:
+   ```php
+   $endDate = $startFrom; // например, 2024-01-15
+   $startDate = $startFrom - $historyDays; // например, 2024-01-01
+   ```
+
+   **2.2. Поиск товаров с продажами**:
+   ```php
+   $productIds = (new Query())
+       ->select('sp.product_id')
+       ->from('sales')
+       ->innerJoin('sales_products', 's.id = sp.check_id')
+       ->innerJoin('products_1c_nomenclature', 'p1c.id = sp.product_id')
+       ->andWhere(['between', 's.date', "$startDate 00:00:00", "$endDate 23:59:59"])
+       ->groupBy('sp.product_id')
+       ->column();
+   ```
+   - Анализируются реальные продажи из таблицы `sales`
+   - Учитываются только товары из номенклатуры 1С
+
+   **2.3. Проверка наличия товаров**:
+   - Если нет товаров → flash-сообщение "Нет товаров за указанный период"
+   - Render формы с сохранением параметров
+
+   **2.4. Расчет периода актуальности**:
+   ```php
+   $now = new DateTime($endDate);
+
+   // Начало актуальности: -4 месяца от endDate, начало месяца
+   $fromStr = (clone $now)
+       ->modify("-{$intervalMonths} months")
+       ->modify('first day of this month')
+       ->setTime(0, 0, 0)
+       ->format('Y-m-d H:i:s');
+
+   // Окончание актуальности: +4 месяца от endDate, конец месяца
+   $toStr = (clone $now)
+       ->modify("+{$intervalMonths} months")
+       ->modify('last day of this month')
+       ->setTime(23, 59, 59)
+       ->format('Y-m-d H:i:s');
+   ```
+
+   Пример:
+   - `endDate = 2024-01-15`
+   - `intervalMonths = 4`
+   - `fromStr = 2023-09-01 00:00:00`
+   - `toStr = 2024-05-31 23:59:59`
+
+   **2.5. Формирование массива актуальности**:
+   ```php
+   foreach ($productIds as $pid) {
+       $rows[] = [
+           'guid' => $pid,
+           'from' => '2023-09', // Y-m
+           'to' => '2024-05',   // Y-m
+       ];
+   }
+   ```
+
+   **2.6. Массовое сохранение**:
+   - Вызов `processBatchActuality($rows)` для создания/обновления актуальности
+   - Flash-сообщение: "Обновлено актуальностей для N товаров"
+
+**Возврат**:
+- Render `add-activity` view с результатами
+
+**Пример использования**:
+
+Чтобы установить актуальность для всех товаров, проданных за последние 7 дней:
+```
+GET /products-1c-nomenclature-actuality/add-activity?historyDays=7&intervalMonths=3&startFrom=2024-01-31
+```
+
+Результат:
+- Анализируются продажи с 2024-01-24 по 2024-01-31
+- Для проданных товаров устанавливается актуальность с 2023-10-01 по 2024-04-30
+
+**Бизнес-сценарий**:
+- Товары, которые продавались недавно, скорее всего будут актуальны в ближайшие месяцы
+- Система автоматически расширяет период актуальности на N месяцев назад и вперед
+- Используется для первоначального заполнения актуальности или периодического обновления
+
+---
+
+### 3. actionView($id)
+
+**Назначение**: Просмотр одной записи актуальности.
+
+**Параметры**:
+- `id` (int) — ID записи `Products1cNomenclatureActuality`
+
+**Алгоритм**:
+- Вызов `findModel($id)` для поиска записи
+- Если запись не найдена → NotFoundHttpException (404)
+- Render `view` view с моделью
+
+**Возврат**:
+- HTML-страница с деталями записи актуальности
+
+---
+
+### 4. actionCreate()
+
+**Назначение**: Создание новой записи актуальности.
+
+**Параметры (POST)**:
+- `Products1cNomenclatureActuality[guid]` (string) — GUID товара
+- `Products1cNomenclatureActuality[date_from]` (datetime) — начало актуальности
+- `Products1cNomenclatureActuality[date_to]` (datetime) — окончание актуальности
+
+**Алгоритм**:
+
+1. **GET-запрос**:
+   - Создается новая модель `Products1cNomenclatureActuality`
+   - Загружаются значения по умолчанию
+   - Render `create` view с формой
+
+2. **POST-запрос**:
+   - Загрузка данных из POST: `$model->load($this->request->post())`
+   - Валидация и сохранение: `$model->save()`
+   - Если успех → redirect на `view` с ID новой записи
+   - Если ошибка → render формы с ошибками валидации
+
+**Возврат**:
+- GET: HTML-форма создания
+- POST: редирект на `view` или форма с ошибками
+
+---
+
+### 5. actionUpdate($id)
+
+**Назначение**: Редактирование существующей записи актуальности.
+
+**Параметры**:
+- `id` (int) — ID записи
+- POST: атрибуты модели `Products1cNomenclatureActuality`
+
+**Алгоритм**:
+
+1. **GET-запрос**:
+   - Поиск записи через `findModel($id)`
+   - Render `update` view с формой
+
+2. **POST-запрос**:
+   - Загрузка данных: `$model->load($this->request->post())`
+   - Сохранение: `$model->save()`
+   - Если успех → redirect на `view`
+   - Если ошибка → render формы с ошибками
+
+**Возврат**:
+- GET: HTML-форма редактирования
+- POST: редирект на `view` или форма с ошибками
+
+---
+
+### 6. actionDelete($id)
+
+**Назначение**: Удаление записи актуальности (стандартный CRUD).
+
+**HTTP Method**: POST (ограничено VerbFilter)
+
+**Параметры**:
+- `id` (int) — ID записи
+
+**Алгоритм**:
+- Поиск записи через `findModel($id)`
+- Удаление: `$model->delete()`
+- Redirect на `index`
+
+**Возврат**:
+- Редирект на страницу списка
+
+---
+
+### 7. actionAjaxDelete()
+
+**Назначение**: AJAX-удаление записи актуальности с JSON-ответом.
+
+**HTTP Method**: POST или GET (для AJAX)
+
+**Параметры**:
+- `id` (int, POST/GET) — ID записи
+
+**Алгоритм**:
+
+1. **Установка формата ответа**:
+   ```php
+   Yii::$app->response->format = Response::FORMAT_JSON;
+   ```
+
+2. **Проверка наличия параметра**:
+   - Если `id` отсутствует → BadRequestHttpException (400)
+
+3. **Попытка удаления**:
+   ```php
+   try {
+       $model = $this->findModel($id);
+       $model->delete();
+
+       return [
+           'success' => true,
+           'message' => 'Запись успешно удалена',
+       ];
+   } catch (\Throwable $e) {
+       return [
+           'success' => false,
+           'message' => $e->getMessage(),
+       ];
+   }
+   ```
+
+**Возврат**:
+- JSON с `success` и `message`
+
+**Пример ответа**:
+```json
+{
+  "success": true,
+  "message": "Запись успешно удалена"
+}
+```
+
+**Использование в JS**:
+```javascript
+$.post('/products-1c-nomenclature-actuality/ajax-delete', {id: 123}, function(response) {
+    if (response.success) {
+        alert(response.message);
+        // удалить строку из таблицы
+    }
+});
+```
+
+---
+
+## Вспомогательные методы
+
+### processBatchActuality(array $post)
+
+**Назначение**: Массовая обработка и сохранение периодов актуальности с автоматическим объединением пересекающихся или смежных интервалов.
+
+**Тип**: `protected`
+
+**Параметры**:
+- `$post` (array) — массив записей актуальности
+  - `id` (int, optional) — ID существующей записи (для обновления)
+  - `guid` (string) — GUID товара
+  - `from` (string, Y-m) — начало периода
+  - `to` (string, Y-m) — окончание периода
+
+**Алгоритм**:
+
+### 1. Валидация и преобразование дат
+
+Для каждой записи в `$post`:
+
+```php
+// Пропуск записей без дат
+if (empty($row['from']) || empty($row['to'])) {
+    continue;
+}
+
+// Преобразование Y-m в datetime
+$fromDate = DateTime::createFromFormat('Y-m', $row['from']);
+$toDate = DateTime::createFromFormat('Y-m', $row['to']);
+
+if (!$fromDate || !$toDate) {
+    continue; // невалидный формат
+}
+
+// Установка времени
+$fromDate->setDate($year, $month, 1)->setTime(0, 0, 0);
+// Например: 2024-01-01 00:00:00
+
+$toDate->modify('last day of this month')->setTime(23, 59, 59);
+// Например: 2024-03-31 23:59:59
+
+// Проверка логики
+if ($from > $to) {
+    Yii::warning("GUID {$guid}: пропускаем — from > to");
+    continue;
+}
+```
+
+### 2. Расчет границ пересечения
+
+```php
+// Расширенные границы для поиска пересечений
+$fromAdj = (clone $fromDate)->modify('-1 second')->format('Y-m-d H:i:s');
+// Например: 2024-01-01 00:00:00 → 2023-12-31 23:59:59
+
+$toAdj = (clone $toDate)->modify('+1 second')->format('Y-m-d H:i:s');
+// Например: 2024-03-31 23:59:59 → 2024-04-01 00:00:00
+```
+
+Это позволяет находить смежные периоды (например, 2024-01 и 2024-02).
+
+### 3. Обработка существующей записи (если указан `id`)
+
+**3.1. Обновление записи**:
+```php
+$master = Products1cNomenclatureActuality::findOne(['id' => $id, 'guid' => $guid]);
+
+$master->date_from = $from;
+$master->date_to = $to;
+$master->updated_at = now();
+$master->updated_by = Yii::$app->user->id;
+$master->save();
+```
+
+**3.2. Поиск пересекающихся соседей**:
+```php
+$neighbors = Products1cNomenclatureActuality::find()
+    ->where(['guid' => $guid])
+    ->andWhere(['<>', 'id', $master->id]) // исключить саму запись
+    ->andWhere('date_to >= :fromAdj', [':fromAdj' => $fromAdj])
+    ->andWhere('date_from <= :toAdj', [':toAdj' => $toAdj])
+    ->orderBy(['date_from' => SORT_ASC])
+    ->all();
+```
+
+Пример пересечения:
+```
+Master:    [2024-01-01 ====== 2024-03-31]
+Neighbor1:         [2024-02-01 ====== 2024-04-30]
+Neighbor2: [2023-12-01 ====== 2024-01-15]
+
+Результат объединения:
+           [2023-12-01 ==================== 2024-04-30]
+```
+
+**3.3. Объединение периодов**:
+```php
+if (!empty($neighbors)) {
+    $minFrom = $master->date_from;
+    $maxTo = $master->date_to;
+
+    foreach ($neighbors as $nei) {
+        if ($nei->date_from < $minFrom) $minFrom = $nei->date_from;
+        if ($nei->date_to > $maxTo) $maxTo = $nei->date_to;
+    }
+
+    $master->date_from = $minFrom;
+    $master->date_to = $maxTo;
+    $master->save();
+
+    // Удаление поглощенных записей
+    foreach ($neighbors as $nei) {
+        $nei->delete();
+    }
+}
+```
+
+**3.4. Рекурсивное объединение** (цикл `while(true)`):
+- После объединения могут появиться новые пересечения
+- Цикл повторяется до тех пор, пока не останется пересечений
+- Пример:
+  ```
+  Итерация 1: [2024-01] + [2024-02] = [2024-01 to 2024-02]
+  Итерация 2: [2024-01 to 2024-02] + [2024-03] = [2024-01 to 2024-03]
+  Итерация 3: нет пересечений → выход
+  ```
+
+### 4. Создание новой записи (если `id` не указан)
+
+**4.1. Поиск пересекающихся записей**:
+```php
+$hits = Products1cNomenclatureActuality::find()
+    ->where(['guid' => $guid])
+    ->andWhere('date_to >= :fromAdj', [':fromAdj' => $fromAdj])
+    ->andWhere('date_from <= :toAdj', [':toAdj' => $toAdj])
+    ->orderBy(['date_from' => SORT_ASC])
+    ->all();
+```
+
+**4.2. Нет пересечений** → создание новой записи:
+```php
+if (empty($hits)) {
+    $new = new Products1cNomenclatureActuality([
+        'guid' => $guid,
+        'date_from' => $from,
+        'date_to' => $to,
+        'created_at' => now(),
+        'created_by' => Yii::$app->user->id,
+    ]);
+    $new->save();
+    continue;
+}
+```
+
+**4.3. Есть пересечения** → объединение:
+```php
+// Расчет общих границ
+$minFrom = $from;
+$maxTo = $to;
+
+foreach ($hits as $h) {
+    if ($h->date_from < $minFrom) $minFrom = $h->date_from;
+    if ($h->date_to > $maxTo) $maxTo = $h->date_to;
+}
+
+// Обновление первой записи (master)
+$master = array_shift($hits);
+$master->date_from = $minFrom;
+$master->date_to = $maxTo;
+$master->updated_at = now();
+$master->updated_by = Yii::$app->user->id;
+$master->save();
+
+// Удаление дубликатов
+foreach ($hits as $dup) {
+    $dup->delete();
+}
+```
+
+**4.4. Рекурсивное объединение**:
+- Аналогично п. 3.4
+- Цикл `while(true)` для полного объединения всех пересечений
+
+### 5. Обработка ошибок
+
+```php
+if (!$master->save()) {
+    Yii::error("Ошибка обновления GUID={$guid}, id={$id}: "
+        . json_encode($master->getErrors(), JSON_UNESCAPED_UNICODE));
+    continue; // пропускаем запись, продолжаем обработку остальных
+}
+```
+
+**Ключевые особенности метода**:
+
+1. **Автоматическое объединение** — пересекающиеся или смежные периоды автоматически сливаются в один
+2. **Рекурсивность** — цикл `while(true)` гарантирует полное объединение всех пересечений
+3. **Атомарность** — каждая запись обрабатывается независимо, ошибка в одной не блокирует остальные
+4. **Проверка границ** — расширение границ на ±1 секунду для поиска смежных периодов
+5. **Аудит изменений** — сохраняется `created_by`, `updated_by`, `created_at`, `updated_at`
+
+**Пример работы**:
+
+Исходные данные:
+```php
+$post = [
+    ['guid' => 'abc123', 'from' => '2024-01', 'to' => '2024-02'],
+    ['guid' => 'abc123', 'from' => '2024-02', 'to' => '2024-03'],
+    ['guid' => 'abc123', 'from' => '2024-05', 'to' => '2024-06'],
+];
+```
+
+Результат в БД:
+```
+guid: abc123, date_from: 2024-01-01 00:00:00, date_to: 2024-03-31 23:59:59
+guid: abc123, date_from: 2024-05-01 00:00:00, date_to: 2024-06-30 23:59:59
+```
+
+Первые две записи объединены, третья осталась отдельной (нет пересечения).
+
+---
+
+### findModel($id)
+
+**Назначение**: Поиск модели по ID с выбросом NotFoundHttpException.
+
+**Тип**: `protected`
+
+**Параметры**:
+- `$id` (int) — ID записи
+
+**Алгоритм**:
+```php
+$model = Products1cNomenclatureActuality::findOne(['id' => $id]);
+
+if ($model !== null) {
+    return $model;
+}
+
+throw new NotFoundHttpException('The requested page does not exist.');
+```
+
+**Возврат**:
+- `Products1cNomenclatureActuality` — найденная модель
+- `NotFoundHttpException` — если модель не найдена (404)
+
+**Использование**:
+```php
+// В actions
+$model = $this->findModel($id);
+```
+
+---
+
+## Интеграция с 1С
+
+### Структура данных из 1С
+
+Контроллер работает с номенклатурой, импортированной из 1С:
+
+**Таблица `products_1c_nomenclature`**:
+- `id` (GUID) — уникальный идентификатор номенклатуры в 1С
+- `location` — путь в иерархии 1С
+- `name` — название товара
+- `type_num` — тип номенклатуры (без квадратных скобок)
+- `category` — категория (Срезка, Горшечные_растения, Сухоцветы, и т.д.)
+- `subcategory` — подкатегория
+- `species` — вид
+- `sort`, `type`, `size`, `color`, `measure` — характеристики товара
+
+**Таблица `products_1c_additional_characteristics`**:
+- `product_id` (FK → products_1c_nomenclature.id)
+- `property_id` (FK → products_1c_prop_type.id)
+- `value` — значение характеристики
+
+**Таблица `products_1c_prop_type`**:
+- `id`
+- `name` — название типа свойства (поддержка рус/англ: 'type'/'тип', 'color'/'цвет')
+
+### Связь с продажами
+
+**Таблица `sales`**:
+- `id` — ID чека
+- `date` — дата продажи
+- Связь с `sales_products.check_id`
+
+**Таблица `sales_products`**:
+- `check_id` (FK → sales.id)
+- `product_id` (FK → products_1c_nomenclature.id)
+
+Используется в `actionAddActivity()` для определения активных товаров.
+
+---
+
+## Бизнес-логика актуализации
+
+### Принципы работы
+
+1. **Период актуальности** — временной интервал, когда товар актуален для продажи:
+   - `date_from` — начало периода (первый день месяца, 00:00:00)
+   - `date_to` — окончание периода (последний день месяца, 23:59:59)
+   - Формат ввода: `Y-m` (2024-01)
+   - Формат хранения: `Y-m-d H:i:s` (2024-01-01 00:00:00)
+
+2. **Автоматическое объединение**:
+   - Если для одного товара создаются пересекающиеся периоды, они автоматически объединяются
+   - Пример:
+     ```
+     [2024-01 to 2024-03] + [2024-02 to 2024-05] = [2024-01 to 2024-05]
+     ```
+
+3. **Смежные периоды**:
+   - Периоды, идущие подряд, также объединяются
+   - Пример:
+     ```
+     [2024-01 to 2024-01] + [2024-02 to 2024-02] = [2024-01 to 2024-02]
+     ```
+
+4. **Множественные периоды**:
+   - Один товар может иметь несколько несмежных периодов актуальности
+   - Пример:
+     ```
+     [2024-01 to 2024-03]  // весна
+     [2024-09 to 2024-11]  // осень
+     ```
+
+### Типовые сценарии использования
+
+#### Сценарий 1: Сезонные товары
+
+**Проблема**: Тюльпаны актуальны только весной (февраль-май).
+
+**Решение**:
+1. Открыть `actionIndex()`
+2. Отфильтровать товары:
+   - `category = "Срезка"`
+   - `subcategory = "Луковичные"`
+   - `species = "Тюльпан"`
+3. Массово установить актуальность:
+   - `from = 2024-02`
+   - `to = 2024-05`
+
+**Результат**: Все тюльпаны будут актуальны с 2024-02-01 по 2024-05-31.
+
+#### Сценарий 2: Автоматическое определение актуальности
+
+**Проблема**: Неизвестно, какие товары актуальны в текущем сезоне.
+
+**Решение**:
+1. Открыть `actionAddActivity()`
+2. Установить параметры:
+   - `historyDays = 30` (анализ продаж за последние 30 дней)
+   - `intervalMonths = 6` (установить актуальность на 6 месяцев назад и вперед)
+   - `startFrom = 2024-03-31` (анализировать до конца марта)
+3. Нажать "Выполнить"
+
+**Результат**:
+- Анализируются продажи с 2024-03-01 по 2024-03-31
+- Для всех проданных товаров устанавливается актуальность с 2023-09-01 по 2024-09-30
+
+#### Сценарий 3: Корректировка существующей актуальности
+
+**Проблема**: Актуальность розы "Red Naomi" нужно продлить до декабря.
+
+**Решение**:
+1. Открыть `actionIndex()`
+2. Отфильтровать:
+   - `category = "Срезка"`
+   - `species = "Роза"`
+   - `sort = "Red Naomi"`
+3. Найти запись с актуальностью
+4. Изменить `to` с `2024-10` на `2024-12`
+5. Сохранить
+
+**Результат**: Период актуальности автоматически объединится с существующими периодами.
+
+#### Сценарий 4: Удаление неактуальных товаров
+
+**Проблема**: Нужно найти товары, которые не актуальны в текущем месяце.
+
+**Решение**:
+1. Открыть `actionIndex()`
+2. Установить фильтры:
+   - `date_from = 2024-03`
+   - `date_to = 2024-03`
+   - `onlyInactive = true`
+3. Просмотреть список товаров без актуальности в марте 2024
+
+**Результат**: Список товаров, которые не нужно закупать/планировать в марте.
+
+### Бизнес-правила
+
+1. **Минимальная гранулярность** — месяц (нельзя установить актуальность на конкретный день)
+2. **Автоматическое округление** — начало периода → первый день месяца, окончание → последний день месяца
+3. **Объединение периодов** — система автоматически предотвращает дублирование и пересечения
+4. **Массовое редактирование** — можно изменить актуальность для множества товаров одновременно
+5. **Связь с продажами** — система анализирует реальные продажи для определения актуальности
+
+---
+
+## Диаграммы
+
+### 1. Архитектура контроллера
+
+```mermaid
+graph TB
+    subgraph "Controller Layer"
+        Controller[Products1cNomenclatureActualityController]
+    end
+
+    subgraph "Actions"
+        Index[actionIndex<br/>Фильтрация + массовое редактирование]
+        AddActivity[actionAddActivity<br/>Автоопределение по продажам]
+        CRUD[CRUD actions<br/>view, create, update, delete]
+        AjaxDelete[actionAjaxDelete<br/>AJAX-удаление]
+    end
+
+    subgraph "Helper Methods"
+        ProcessBatch[processBatchActuality<br/>Объединение периодов]
+        FindModel[findModel<br/>Поиск по ID]
+    end
+
+    subgraph "Models"
+        Actuality[Products1cNomenclatureActuality<br/>Периоды актуальности]
+        Nomenclature[Products1cNomenclature<br/>Номенклатура 1С]
+        Characteristics[Products1cAdditionalCharacteristics<br/>Характеристики]
+        PropType[Products1cPropType<br/>Типы свойств]
+        Sales[Sales<br/>Продажи]
+    end
+
+    Controller --> Index
+    Controller --> AddActivity
+    Controller --> CRUD
+    Controller --> AjaxDelete
+
+    Index --> ProcessBatch
+    Index --> Nomenclature
+    Index --> Actuality
+    Index --> Characteristics
+    Index --> PropType
+
+    AddActivity --> Sales
+    AddActivity --> Nomenclature
+    AddActivity --> ProcessBatch
+
+    CRUD --> FindModel
+    CRUD --> Actuality
+
+    AjaxDelete --> FindModel
+    AjaxDelete --> Actuality
+
+    ProcessBatch --> Actuality
+    FindModel --> Actuality
+
+    Nomenclature --> Actuality
+    Nomenclature --> Characteristics
+    Characteristics --> PropType
+```
+
+### 2. Процесс объединения периодов (processBatchActuality)
+
+```mermaid
+flowchart TD
+    Start([Начало: массив записей]) --> Loop{Для каждой<br/>записи}
+
+    Loop -->|Запись| ValidateDates[Валидация дат<br/>Y-m → datetime]
+    ValidateDates --> CheckDates{Даты<br/>валидны?}
+
+    CheckDates -->|Нет| Loop
+    CheckDates -->|Да| CheckLogic{from <= to?}
+
+    CheckLogic -->|Нет| Loop
+    CheckLogic -->|Да| CalcBounds[Расчет границ<br/>fromAdj = from - 1s<br/>toAdj = to + 1s]
+
+    CalcBounds --> HasID{id<br/>указан?}
+
+    HasID -->|Да| UpdateExisting[Обновить<br/>существующую запись]
+    UpdateExisting --> FindNeighborsUpdate[Найти пересекающихся<br/>соседей]
+    FindNeighborsUpdate --> HasNeighborsUpdate{Есть<br/>соседи?}
+
+    HasNeighborsUpdate -->|Нет| Loop
+    HasNeighborsUpdate -->|Да| MergeUpdate[Объединить границы<br/>minFrom, maxTo]
+    MergeUpdate --> DeleteNeighborsUpdate[Удалить<br/>поглощенные записи]
+    DeleteNeighborsUpdate --> RecursiveUpdate{Есть новые<br/>пересечения?}
+
+    RecursiveUpdate -->|Да| MergeUpdate
+    RecursiveUpdate -->|Нет| Loop
+
+    HasID -->|Нет| FindHits[Найти пересечения<br/>для guid]
+    FindHits --> HasHits{Есть<br/>пересечения?}
+
+    HasHits -->|Нет| CreateNew[Создать новую запись]
+    CreateNew --> Loop
+
+    HasHits -->|Да| CalcMinMax[Расчет общих границ<br/>minFrom, maxTo]
+    CalcMinMax --> SelectMaster[Выбрать master<br/>первая запись]
+    SelectMaster --> UpdateMaster[Обновить master<br/>новыми границами]
+    UpdateMaster --> DeleteDups[Удалить дубликаты]
+    DeleteDups --> RecursiveCreate{Есть новые<br/>пересечения?}
+
+    RecursiveCreate -->|Да| CalcMinMax
+    RecursiveCreate -->|Нет| Loop
+
+    Loop -->|Нет записей| End([Конец])
+
+    style Start fill:#e1f5e1
+    style End fill:#ffe1e1
+    style ProcessBatch fill:#e1e5ff
+    style MergeUpdate fill:#fff4e1
+    style MergeCreate fill:#fff4e1
+    style RecursiveUpdate fill:#f0e1ff
+    style RecursiveCreate fill:#f0e1ff
+```
+
+### 3. Workflow actionAddActivity
+
+```mermaid
+sequenceDiagram
+    actor User
+    participant Controller as Products1cNomenclatureActualityController
+    participant Sales as Sales Table
+    participant Nomenclature as Products1cNomenclature
+    participant Actuality as Products1cNomenclatureActuality
+    participant ProcessBatch as processBatchActuality()
+
+    User->>Controller: GET /add-activity?historyDays=14&intervalMonths=4
+
+    Controller->>Controller: Расчет периода анализа<br/>startDate = now - 14 days<br/>endDate = now
+
+    Controller->>Sales: Запрос товаров с продажами<br/>за период startDate-endDate
+    Sales-->>Controller: Список product_id
+
+    alt Нет товаров
+        Controller-->>User: Flash: "Нет товаров"
+    else Есть товары
+        Controller->>Controller: Расчет периода актуальности<br/>from = now - 4 months (начало месяца)<br/>to = now + 4 months (конец месяца)
+
+        Controller->>Controller: Формирование массива<br/>[{guid, from, to}, ...]
+
+        Controller->>ProcessBatch: processBatchActuality($rows)
+
+        loop Для каждого товара
+            ProcessBatch->>Actuality: Найти пересечения
+            Actuality-->>ProcessBatch: Существующие периоды
+
+            alt Нет пересечений
+                ProcessBatch->>Actuality: Создать новую запись
+            else Есть пересечения
+                ProcessBatch->>ProcessBatch: Объединить периоды
+                ProcessBatch->>Actuality: Обновить master-запись
+                ProcessBatch->>Actuality: Удалить дубликаты
+            end
+        end
+
+        ProcessBatch-->>Controller: Готово
+        Controller-->>User: Flash: "Обновлено N товаров"
+    end
+```
+
+### 4. Структура данных
+
+```mermaid
+erDiagram
+    PRODUCTS_1C_NOMENCLATURE ||--o{ PRODUCTS_1C_NOMENCLATURE_ACTUALITY : "has many"
+    PRODUCTS_1C_NOMENCLATURE ||--o{ PRODUCTS_1C_ADDITIONAL_CHARACTERISTICS : "has many"
+    PRODUCTS_1C_PROP_TYPE ||--o{ PRODUCTS_1C_ADDITIONAL_CHARACTERISTICS : "defines"
+    PRODUCTS_1C_NOMENCLATURE ||--o{ SALES_PRODUCTS : "sold in"
+    SALES ||--o{ SALES_PRODUCTS : "contains"
+
+    PRODUCTS_1C_NOMENCLATURE {
+        string id PK "GUID из 1С"
+        string location "Путь в иерархии 1С"
+        string name "Название товара"
+        string type_num "Тип номенклатуры"
+        string category "Категория"
+        string subcategory "Подкатегория"
+        string species "Вид"
+        string sort "Сорт"
+        string type "Тип"
+        int size "Размер"
+        string measure "Единица измерения"
+        string color "Цвет"
+    }
+
+    PRODUCTS_1C_NOMENCLATURE_ACTUALITY {
+        int id PK
+        string guid FK "→ products_1c_nomenclature.id"
+        datetime date_from "Начало актуальности"
+        datetime date_to "Окончание актуальности"
+        datetime created_at
+        int created_by
+        datetime updated_at
+        int updated_by
+    }
+
+    PRODUCTS_1C_ADDITIONAL_CHARACTERISTICS {
+        int id PK
+        string product_id FK "→ products_1c_nomenclature.id"
+        int property_id FK "→ products_1c_prop_type.id"
+        string value "Значение характеристики"
+    }
+
+    PRODUCTS_1C_PROP_TYPE {
+        int id PK
+        string name "Название свойства (рус/англ)"
+    }
+
+    SALES {
+        int id PK
+        datetime date "Дата продажи"
+    }
+
+    SALES_PRODUCTS {
+        int id PK
+        int check_id FK "→ sales.id"
+        string product_id FK "→ products_1c_nomenclature.id"
+    }
+```
+
+---
+
+## Примеры использования
+
+### Пример 1: Установка актуальности для всех роз на весну
+
+**Задача**: Установить актуальность для всех роз с марта по май 2024.
+
+**Шаги**:
+
+1. Открыть страницу: `/products-1c-nomenclature-actuality/index`
+
+2. Установить фильтры:
+   - Category: "Срезка"
+   - Subcategory: (выбрать нужную)
+   - Species: "Роза"
+
+3. В таблице результатов для каждого товара:
+   - Заполнить "from": `2024-03`
+   - Заполнить "to": `2024-05`
+
+4. Нажать "Сохранить"
+
+**Результат в БД**:
+```sql
+INSERT INTO products_1c_nomenclature_actuality
+(guid, date_from, date_to, created_at, created_by)
+VALUES
+('guid-rose-1', '2024-03-01 00:00:00', '2024-05-31 23:59:59', NOW(), 1),
+('guid-rose-2', '2024-03-01 00:00:00', '2024-05-31 23:59:59', NOW(), 1),
+...
+```
+
+### Пример 2: Автоматическое определение актуальных товаров
+
+**Задача**: Найти все товары, проданные за последние 7 дней, и установить им актуальность на ближайшие 3 месяца.
+
+**HTTP-запрос**:
+```
+GET /products-1c-nomenclature-actuality/add-activity?historyDays=7&intervalMonths=3&startFrom=2024-03-15
+```
+
+**Логика**:
+
+1. Анализ продаж:
+   ```
+   startDate: 2024-03-08
+   endDate: 2024-03-15
+   ```
+
+2. SQL-запрос:
+   ```sql
+   SELECT DISTINCT sp.product_id
+   FROM sales s
+   INNER JOIN sales_products sp ON s.id = sp.check_id
+   INNER JOIN products_1c_nomenclature p1c ON p1c.id = sp.product_id
+   WHERE s.date BETWEEN '2024-03-08 00:00:00' AND '2024-03-15 23:59:59'
+   ```
+
+3. Расчет актуальности:
+   ```
+   from: 2024-03-15 - 3 months = 2023-12-01 00:00:00
+   to: 2024-03-15 + 3 months = 2024-06-30 23:59:59
+   ```
+
+4. Сохранение:
+   ```php
+   processBatchActuality([
+       ['guid' => 'product-1', 'from' => '2023-12', 'to' => '2024-06'],
+       ['guid' => 'product-2', 'from' => '2023-12', 'to' => '2024-06'],
+       ...
+   ]);
+   ```
+
+**Результат**: Все проданные товары автоматически получают актуальность на 6 месяцев (3 назад + 3 вперед).
+
+### Пример 3: AJAX-удаление записи
+
+**JavaScript**:
+```javascript
+function deleteActuality(id) {
+    if (!confirm('Удалить запись актуальности?')) {
+        return;
+    }
+
+    $.ajax({
+        url: '/products-1c-nomenclature-actuality/ajax-delete',
+        type: 'POST',
+        data: { id: id },
+        dataType: 'json',
+        success: function(response) {
+            if (response.success) {
+                alert(response.message);
+                // Удалить строку из таблицы
+                $('tr[data-id="' + id + '"]').remove();
+            } else {
+                alert('Ошибка: ' + response.message);
+            }
+        },
+        error: function() {
+            alert('Ошибка сервера');
+        }
+    });
+}
+```
+
+**Вызов**:
+```html
+<button onclick="deleteActuality(123)">Удалить</button>
+```
+
+**Ответ сервера**:
+```json
+{
+  "success": true,
+  "message": "Запись успешно удалена"
+}
+```
+
+### Пример 4: Объединение пересекающихся периодов
+
+**Исходные данные**:
+
+Товар `guid-tulip-1` имеет:
+- Период 1: 2024-01-01 to 2024-03-31
+- Период 2: 2024-05-01 to 2024-06-30
+
+Пользователь добавляет:
+- Период 3: 2024-02-01 to 2024-05-31
+
+**POST-запрос**:
+```php
+POST /products-1c-nomenclature-actuality/index
+actuality[0][guid] = guid-tulip-1
+actuality[0][from] = 2024-02
+actuality[0][to] = 2024-05
+```
+
+**Обработка `processBatchActuality()`**:
+
+1. Найдены пересечения:
+   ```
+   Новый:    [2024-02-01 ======== 2024-05-31]
+   Период 1: [2024-01-01 ======== 2024-03-31] ✓ пересекается
+   Период 2: [2024-05-01 ======== 2024-06-30] ✓ пересекается
+   ```
+
+2. Расчет общих границ:
+   ```
+   minFrom = min(2024-01-01, 2024-02-01, 2024-05-01) = 2024-01-01
+   maxTo = max(2024-03-31, 2024-05-31, 2024-06-30) = 2024-06-30
+   ```
+
+3. Объединение:
+   - Обновить Период 1: 2024-01-01 to 2024-06-30
+   - Удалить Период 2
+
+**Результат в БД**:
+```
+Товар guid-tulip-1:
+- Период: 2024-01-01 00:00:00 to 2024-06-30 23:59:59
+```
+
+Все три периода объединены в один непрерывный.
+
+---
+
+## Потенциальные проблемы и рекомендации
+
+### 1. Отсутствие RBAC
+
+**Проблема**: Все действия доступны всем авторизованным пользователям.
+
+**Рекомендация**: Добавить контроль доступа:
+```php
+public function behaviors()
+{
+    return array_merge(
+        parent::behaviors(),
+        [
+            'access' => [
+                'class' => AccessControl::className(),
+                'rules' => [
+                    [
+                        'allow' => true,
+                        'actions' => ['index', 'view'],
+                        'roles' => ['@'], // все авторизованные
+                    ],
+                    [
+                        'allow' => true,
+                        'actions' => ['create', 'update', 'delete', 'ajax-delete', 'add-activity'],
+                        'roles' => ['admin', 'category_manager'], // только администраторы и менеджеры категорий
+                    ],
+                ],
+            ],
+        ]
+    );
+}
+```
+
+### 2. Производительность при большом объеме данных
+
+**Проблема**:
+- `actionIndex()` загружает все товары с актуальностью в память
+- `ArrayDataProvider` с 1000 записями на страницу
+- Множественные запросы в `processBatchActuality()`
+
+**Рекомендация**:
+- Использовать `ActiveDataProvider` вместо `ArrayDataProvider`
+- Добавить индексы на `products_1c_nomenclature_actuality.guid`, `date_from`, `date_to`
+- Оптимизировать `processBatchActuality()` через batch-операции
+
+### 3. Рекурсивное объединение периодов
+
+**Проблема**: Цикл `while(true)` в `processBatchActuality()` может зациклиться при некорректных данных.
+
+**Рекомендация**: Добавить счетчик итераций:
+```php
+$maxIterations = 100;
+$iteration = 0;
+
+while (true) {
+    if (++$iteration > $maxIterations) {
+        Yii::error("Max iterations exceeded for GUID={$guid}");
+        break;
+    }
+
+    // ... логика объединения ...
+
+    if (empty($more)) break;
+}
+```
+
+### 4. Валидация дат
+
+**Проблема**: Нет проверки, что `date_from` и `date_to` не в далеком прошлом или будущем.
+
+**Рекомендация**: Добавить валидацию:
+```php
+// В Products1cNomenclatureActuality::rules()
+['date_from', 'compare', 'compareAttribute' => 'date_to', 'operator' => '<='],
+['date_from', 'date', 'min' => date('Y-m-d', strtotime('-5 years'))],
+['date_to', 'date', 'max' => date('Y-m-d', strtotime('+5 years'))],
+```
+
+### 5. Транзакции в processBatchActuality()
+
+**Проблема**: Множественные операции INSERT/UPDATE/DELETE не обернуты в транзакцию. При ошибке может остаться частично обработанный массив.
+
+**Рекомендация**: Обернуть в транзакцию:
+```php
+protected function processBatchActuality(array $post)
+{
+    $transaction = Yii::$app->db->beginTransaction();
+    try {
+        // ... вся логика ...
+
+        $transaction->commit();
+    } catch (\Exception $e) {
+        $transaction->rollBack();
+        Yii::error("Transaction failed: " . $e->getMessage());
+        throw $e;
+    }
+}
+```
+
+### 6. Логирование
+
+**Проблема**: Используется только `Yii::warning()` и `Yii::error()`, нет структурированного логирования.
+
+**Рекомендация**: Использовать категории логов:
+```php
+Yii::info("Processing actuality for GUID={$guid}", __METHOD__);
+Yii::warning("Invalid date range for GUID={$guid}", __METHOD__);
+Yii::error("Failed to save GUID={$guid}: " . json_encode($errors), __METHOD__);
+```
+
+---
+
+## Заключение
+
+**Products1cNomenclatureActualityController** — критически важный контроллер для управления актуальностью номенклатуры товаров из 1С. Его основные сильные стороны:
+
+### Преимущества:
+1. **Мощная фильтрация** — многоуровневая фильтрация по категориям, характеристикам, датам
+2. **Автоматическое объединение** — умное слияние пересекающихся периодов актуальности
+3. **Интеграция с продажами** — автоматическое определение актуальности по истории продаж
+4. **Массовые операции** — возможность обработки множества товаров одновременно
+5. **Гибкость** — поддержка множественных периодов актуальности для одного товара
+
+### Зоны улучшения:
+1. Добавить RBAC для контроля доступа
+2. Оптимизировать производительность при больших объемах
+3. Добавить транзакции в `processBatchActuality()`
+4. Улучшить валидацию и обработку ошибок
+5. Добавить ограничитель итераций в рекурсивных циклах
+
+### Бизнес-ценность:
+- Позволяет планировать закупки только актуальных товаров
+- Учитывает сезонность номенклатуры
+- Автоматизирует процесс определения актуальности
+- Предотвращает дублирование периодов актуальности
+- Обеспечивает точность данных для систем планирования и прогнозирования
diff --git a/erp24/docs/controllers/non-standard/Products1cNomenclatureActualityController_QUICK_REFERENCE.md b/erp24/docs/controllers/non-standard/Products1cNomenclatureActualityController_QUICK_REFERENCE.md
new file mode 100644 (file)
index 0000000..92f314f
--- /dev/null
@@ -0,0 +1,1046 @@
+# Products1cNomenclatureActualityController - Краткая справка
+
+## Общая информация
+
+**Контроллер**: `Products1cNomenclatureActualityController`
+**Namespace**: `app\controllers`
+**Путь**: `erp24/controllers/Products1cNomenclatureActualityController.php`
+**Размер**: 670 строк
+**Тип**: Нестандартный контроллер с бизнес-логикой
+
+### Назначение
+
+Управление актуальностью номенклатуры товаров из 1С — определение временных периодов, когда товар актуален для продажи, закупки и планирования.
+
+### Ключевые возможности
+
+✅ Установка периодов актуальности для товаров
+✅ Многоуровневая фильтрация номенклатуры
+✅ Массовое редактирование актуальности
+✅ Автоматическое определение по продажам
+✅ Объединение пересекающихся периодов
+✅ AJAX-операции
+
+---
+
+## Быстрый старт
+
+### Установка актуальности вручную
+
+```php
+// 1. Открыть страницу с фильтрацией
+GET /products-1c-nomenclature-actuality/index?category=Срезка&species=Роза
+
+// 2. В форме заполнить периоды актуальности
+// 3. Отправить POST-запрос
+POST /products-1c-nomenclature-actuality/index
+actuality[0][guid] = guid-rose-1
+actuality[0][from] = 2024-03
+actuality[0][to] = 2024-05
+```
+
+### Автоматическое определение актуальности
+
+```php
+// Анализ продаж за 7 дней, установка актуальности на 3 месяца
+GET /products-1c-nomenclature-actuality/add-activity?historyDays=7&intervalMonths=3
+```
+
+---
+
+## Actions - краткий обзор
+
+| Action | Назначение | Основные параметры |
+|--------|-----------|-------------------|
+| **index** | Фильтрация и массовое редактирование | `category`, `species`, `date_from`, `date_to` |
+| **addActivity** | Автодобавление по продажам | `historyDays`, `intervalMonths`, `startFrom` |
+| **view** | Просмотр записи | `id` |
+| **create** | Создание записи | POST: `guid`, `date_from`, `date_to` |
+| **update** | Редактирование записи | `id`, POST: атрибуты |
+| **delete** | Удаление записи | `id` |
+| **ajaxDelete** | AJAX-удаление | `id` |
+
+---
+
+## Основные use cases
+
+### Use Case 1: Сезонная актуальность
+
+**Задача**: Установить актуальность для всех тюльпанов на весну (февраль-май).
+
+**Решение**:
+
+```http
+# Шаг 1: Фильтрация тюльпанов
+GET /products-1c-nomenclature-actuality/index?category=Срезка&species=Тюльпан
+
+# Шаг 2: Массовое сохранение
+POST /products-1c-nomenclature-actuality/index
+actuality[0][guid] = guid-tulip-1
+actuality[0][from] = 2024-02
+actuality[0][to] = 2024-05
+
+actuality[1][guid] = guid-tulip-2
+actuality[1][from] = 2024-02
+actuality[1][to] = 2024-05
+```
+
+**Результат**: Все тюльпаны актуальны с 2024-02-01 00:00:00 по 2024-05-31 23:59:59.
+
+---
+
+### Use Case 2: Автоматическая актуализация по продажам
+
+**Задача**: Найти товары, проданные за последнюю неделю, и установить им актуальность.
+
+**Решение**:
+
+```http
+GET /products-1c-nomenclature-actuality/add-activity?historyDays=7&intervalMonths=3&startFrom=2024-03-31
+```
+
+**Алгоритм**:
+1. Анализ продаж с 2024-03-24 по 2024-03-31
+2. SQL-запрос товаров с продажами
+3. Установка актуальности с 2023-12-01 по 2024-06-30
+
+**SQL**:
+```sql
+SELECT DISTINCT sp.product_id
+FROM sales s
+INNER JOIN sales_products sp ON s.id = sp.check_id
+INNER JOIN products_1c_nomenclature p1c ON p1c.id = sp.product_id
+WHERE s.date BETWEEN '2024-03-24 00:00:00' AND '2024-03-31 23:59:59'
+```
+
+**Результат**: Все проданные товары получают актуальность на 6 месяцев (3 назад + 3 вперед).
+
+---
+
+### Use Case 3: Поиск неактуальных товаров
+
+**Задача**: Найти товары, не актуальные в текущем месяце.
+
+**Решение**:
+
+```http
+GET /products-1c-nomenclature-actuality/index?date_from=2024-03&date_to=2024-03&onlyInactive=1
+```
+
+**Результат**: Список товаров без актуальности в марте 2024. Эти товары не следует закупать и планировать.
+
+---
+
+### Use Case 4: Корректировка актуальности
+
+**Задача**: Продлить актуальность розы "Red Naomi" до декабря.
+
+**Решение**:
+
+```http
+# Шаг 1: Найти товар
+GET /products-1c-nomenclature-actuality/index?category=Срезка&species=Роза&sort=Red Naomi
+
+# Шаг 2: Обновить запись
+POST /products-1c-nomenclature-actuality/index
+actuality[0][id] = 123
+actuality[0][guid] = guid-red-naomi
+actuality[0][from] = 2024-03
+actuality[0][to] = 2024-12
+```
+
+**Результат**: Период актуальности автоматически объединится с существующими периодами.
+
+---
+
+### Use Case 5: AJAX-удаление
+
+**Задача**: Удалить запись актуальности без перезагрузки страницы.
+
+**JavaScript**:
+
+```javascript
+function deleteActuality(id) {
+    if (!confirm('Удалить запись актуальности?')) {
+        return;
+    }
+
+    $.ajax({
+        url: '/products-1c-nomenclature-actuality/ajax-delete',
+        type: 'POST',
+        data: { id: id },
+        dataType: 'json',
+        success: function(response) {
+            if (response.success) {
+                alert(response.message);
+                $('tr[data-id="' + id + '"]').fadeOut().remove();
+            } else {
+                alert('Ошибка: ' + response.message);
+            }
+        }
+    });
+}
+```
+
+**HTML**:
+
+```html
+<button onclick="deleteActuality(123)" class="btn btn-danger">
+    Удалить
+</button>
+```
+
+---
+
+## Диаграммы
+
+### 1. Workflow - Массовое редактирование
+
+```mermaid
+sequenceDiagram
+    actor User
+    participant Browser
+    participant Controller as Products1cNomenclatureActualityController
+    participant ProcessBatch as processBatchActuality()
+    participant DB as Database
+
+    User->>Browser: Открыть страницу с фильтрами
+    Browser->>Controller: GET /index?category=Срезка&species=Роза
+    Controller->>DB: SELECT номенклатура + актуальность
+    DB-->>Controller: Список товаров
+    Controller-->>Browser: Render таблица с товарами
+
+    User->>Browser: Заполнить периоды актуальности
+    User->>Browser: Нажать "Сохранить"
+
+    Browser->>Controller: POST /index<br/>actuality[{guid, from, to}, ...]
+    Controller->>ProcessBatch: processBatchActuality($post)
+
+    loop Для каждой записи
+        ProcessBatch->>ProcessBatch: Валидация дат (Y-m → datetime)
+        ProcessBatch->>ProcessBatch: Расчет границ (±1 сек)
+        ProcessBatch->>DB: Найти пересекающиеся периоды
+        DB-->>ProcessBatch: Существующие записи
+
+        alt Нет пересечений
+            ProcessBatch->>DB: INSERT новая запись
+        else Есть пересечения
+            ProcessBatch->>ProcessBatch: Объединить границы (min/max)
+            ProcessBatch->>DB: UPDATE master-запись
+            ProcessBatch->>DB: DELETE поглощенные записи
+        end
+
+        ProcessBatch->>ProcessBatch: Рекурсивное объединение<br/>(while есть пересечения)
+    end
+
+    ProcessBatch-->>Controller: Готово
+    Controller->>Controller: Flash: "Успешно сохранено"
+    Controller-->>Browser: Redirect /index (refresh)
+    Browser-->>User: Обновленная таблица
+```
+
+---
+
+### 2. Workflow - Автоматическое добавление
+
+```mermaid
+flowchart TD
+    Start([User: actionAddActivity]) --> InputParams[Ввод параметров:<br/>historyDays=7<br/>intervalMonths=3<br/>startFrom=2024-03-31]
+
+    InputParams --> CalcPeriod[Расчет периода анализа:<br/>startDate = 2024-03-24<br/>endDate = 2024-03-31]
+
+    CalcPeriod --> QuerySales[SQL: поиск товаров<br/>с продажами за период]
+
+    QuerySales --> HasProducts{Найдены<br/>товары?}
+
+    HasProducts -->|Нет| FlashInfo[Flash: Нет товаров]
+    FlashInfo --> End([Конец])
+
+    HasProducts -->|Да| CalcActuality[Расчет актуальности:<br/>from = 2023-12-01 -4 months<br/>to = 2024-06-30 +3 months]
+
+    CalcActuality --> PrepareData[Формирование массива:<br/>[{guid, from, to}, ...]]
+
+    PrepareData --> ProcessBatch[processBatchActuality]
+
+    ProcessBatch --> Loop{Для каждого<br/>товара}
+
+    Loop -->|Запись| FindIntersections[Найти пересечения<br/>для guid]
+
+    FindIntersections --> HasIntersections{Есть<br/>пересечения?}
+
+    HasIntersections -->|Нет| CreateNew[Создать новую запись]
+    CreateNew --> Loop
+
+    HasIntersections -->|Да| MergeRanges[Объединить периоды:<br/>minFrom, maxTo]
+    MergeRanges --> UpdateMaster[Обновить master-запись]
+    UpdateMaster --> DeleteDups[Удалить дубликаты]
+    DeleteDups --> RecursiveMerge{Есть новые<br/>пересечения?}
+
+    RecursiveMerge -->|Да| MergeRanges
+    RecursiveMerge -->|Нет| Loop
+
+    Loop -->|Конец| FlashSuccess[Flash: Обновлено N товаров]
+    FlashSuccess --> End
+
+    style Start fill:#e1f5e1
+    style End fill:#ffe1e1
+    style ProcessBatch fill:#fff4e1
+    style MergeRanges fill:#e1e5ff
+    style QuerySales fill:#f0e1ff
+```
+
+---
+
+### 3. Структура данных и связи
+
+```mermaid
+erDiagram
+    PRODUCTS_1C_NOMENCLATURE ||--o{ PRODUCTS_1C_NOMENCLATURE_ACTUALITY : "has many actualities"
+    PRODUCTS_1C_NOMENCLATURE ||--o{ PRODUCTS_1C_ADDITIONAL_CHARACTERISTICS : "has characteristics"
+    PRODUCTS_1C_PROP_TYPE ||--o{ PRODUCTS_1C_ADDITIONAL_CHARACTERISTICS : "defines property"
+    PRODUCTS_1C_NOMENCLATURE ||--o{ SALES_PRODUCTS : "sold in"
+    SALES ||--o{ SALES_PRODUCTS : "contains products"
+
+    PRODUCTS_1C_NOMENCLATURE {
+        string id PK "GUID из 1С"
+        string name "Название товара"
+        string category "Категория"
+        string subcategory "Подкатегория"
+        string species "Вид"
+        string sort "Сорт"
+        string type "Тип"
+        int size "Размер"
+        string color "Цвет"
+    }
+
+    PRODUCTS_1C_NOMENCLATURE_ACTUALITY {
+        int id PK
+        string guid FK "→ nomenclature.id"
+        datetime date_from "Начало актуальности"
+        datetime date_to "Конец актуальности"
+        datetime created_at
+        int created_by
+        datetime updated_at
+        int updated_by
+    }
+
+    PRODUCTS_1C_ADDITIONAL_CHARACTERISTICS {
+        int id PK
+        string product_id FK "→ nomenclature.id"
+        int property_id FK "→ prop_type.id"
+        string value "Значение"
+    }
+
+    PRODUCTS_1C_PROP_TYPE {
+        int id PK
+        string name "Название свойства"
+    }
+
+    SALES {
+        int id PK
+        datetime date "Дата продажи"
+    }
+
+    SALES_PRODUCTS {
+        int id PK
+        int check_id FK "→ sales.id"
+        string product_id FK "→ nomenclature.id"
+    }
+```
+
+---
+
+### 4. Процесс объединения периодов
+
+```mermaid
+graph TB
+    subgraph "Исходные периоды"
+        P1[Период 1:<br/>2024-01-01 to 2024-03-31]
+        P2[Период 2:<br/>2024-02-15 to 2024-04-30]
+        P3[Период 3:<br/>2024-05-01 to 2024-06-30]
+    end
+
+    subgraph "Проверка пересечений"
+        Check1{P1 и P2<br/>пересекаются?}
+        Check2{P2 и P3<br/>пересекаются?}
+    end
+
+    subgraph "Объединение"
+        Merge1[Объединить P1 + P2:<br/>2024-01-01 to 2024-04-30]
+        Keep[Оставить P3:<br/>2024-05-01 to 2024-06-30]
+    end
+
+    subgraph "Результат в БД"
+        Result1[Период A:<br/>2024-01-01 to 2024-04-30]
+        Result2[Период B:<br/>2024-05-01 to 2024-06-30]
+    end
+
+    P1 --> Check1
+    P2 --> Check1
+    Check1 -->|Да| Merge1
+
+    P2 --> Check2
+    P3 --> Check2
+    Check2 -->|Нет| Keep
+
+    Merge1 --> Result1
+    Keep --> Result2
+
+    style Check1 fill:#fff4e1
+    style Merge1 fill:#e1f5e1
+    style Result1 fill:#e1e5ff
+    style Result2 fill:#e1e5ff
+```
+
+---
+
+### 5. Каскадная фильтрация
+
+```mermaid
+graph LR
+    subgraph "Уровень 1: Категория"
+        Cat1[Срезка]
+        Cat2[Горшечные_растения]
+        Cat3[Сухоцветы]
+    end
+
+    subgraph "Уровень 2: Подкатегория"
+        Sub1[Луковичные]
+        Sub2[Кустовые]
+        Sub3[Одноголовые]
+    end
+
+    subgraph "Уровень 3: Вид"
+        Spec1[Тюльпан]
+        Spec2[Роза]
+        Spec3[Хризантема]
+    end
+
+    subgraph "Уровень 4: Характеристики"
+        Char1[Цвет: Красный]
+        Char2[Сорт: Red Naomi]
+        Char3[Размер: 70]
+    end
+
+    Cat1 --> Sub1
+    Cat1 --> Sub2
+
+    Sub1 --> Spec1
+    Sub2 --> Spec2
+
+    Spec2 --> Char1
+    Spec2 --> Char2
+    Spec2 --> Char3
+
+    style Cat1 fill:#e1f5e1
+    style Sub1 fill:#fff4e1
+    style Spec1 fill:#e1e5ff
+    style Spec2 fill:#e1e5ff
+    style Char2 fill:#f0e1ff
+```
+
+---
+
+## Примеры кода
+
+### Пример 1: Массовое сохранение актуальности
+
+**PHP (контроллер)**:
+
+```php
+public function actionIndex()
+{
+    // Получение POST-данных
+    if (Yii::$app->request->isPost && $post = Yii::$app->request->post('actuality', [])) {
+        // Массовая обработка
+        $this->processBatchActuality($post);
+
+        // Flash-сообщение
+        Yii::$app->session->setFlash('success', 'Данные по актуальности успешно сохранены.');
+
+        // Обновление страницы
+        return $this->refresh();
+    }
+
+    // ... остальная логика ...
+}
+```
+
+**HTML (форма)**:
+
+```html
+<form method="POST" action="/products-1c-nomenclature-actuality/index">
+    <input type="hidden" name="<?= Yii::$app->request->csrfParam ?>"
+           value="<?= Yii::$app->request->csrfToken ?>">
+
+    <?php foreach ($products as $index => $product): ?>
+        <div class="row">
+            <input type="hidden" name="actuality[<?= $index ?>][guid]"
+                   value="<?= $product->id ?>">
+
+            <label><?= $product->name ?></label>
+
+            <input type="month" name="actuality[<?= $index ?>][from]"
+                   placeholder="От (YYYY-MM)">
+
+            <input type="month" name="actuality[<?= $index ?>][to]"
+                   placeholder="До (YYYY-MM)">
+        </div>
+    <?php endforeach; ?>
+
+    <button type="submit">Сохранить</button>
+</form>
+```
+
+---
+
+### Пример 2: Объединение периодов
+
+**PHP (processBatchActuality)**:
+
+```php
+protected function processBatchActuality(array $post)
+{
+    $userId = Yii::$app->user->id;
+    $now = date('Y-m-d H:i:s');
+
+    foreach ($post as $row) {
+        // Валидация дат
+        if (empty($row['from']) || empty($row['to'])) {
+            continue;
+        }
+
+        // Преобразование Y-m → datetime
+        $fromDate = \DateTime::createFromFormat('Y-m', $row['from']);
+        $toDate = \DateTime::createFromFormat('Y-m', $row['to']);
+
+        if (!$fromDate || !$toDate) {
+            continue;
+        }
+
+        // Установка времени
+        $fromDate->setDate((int)$fromDate->format('Y'), (int)$fromDate->format('m'), 1)
+            ->setTime(0, 0, 0);
+        $toDate->modify('last day of this month')->setTime(23, 59, 59);
+
+        $from = $fromDate->format('Y-m-d H:i:s');
+        $to = $toDate->format('Y-m-d H:i:s');
+
+        // Расширенные границы для поиска пересечений
+        $fromAdj = (clone $fromDate)->modify('-1 second')->format('Y-m-d H:i:s');
+        $toAdj = (clone $toDate)->modify('+1 second')->format('Y-m-d H:i:s');
+
+        $guid = $row['guid'];
+
+        // Поиск пересекающихся периодов
+        $hits = Products1cNomenclatureActuality::find()
+            ->where(['guid' => $guid])
+            ->andWhere('date_to >= :fromAdj', [':fromAdj' => $fromAdj])
+            ->andWhere('date_from <= :toAdj', [':toAdj' => $toAdj])
+            ->orderBy(['date_from' => SORT_ASC])
+            ->all();
+
+        if (empty($hits)) {
+            // Нет пересечений → создать новую запись
+            $new = new Products1cNomenclatureActuality([
+                'guid' => $guid,
+                'date_from' => $from,
+                'date_to' => $to,
+                'created_at' => $now,
+                'created_by' => $userId,
+            ]);
+            $new->save();
+            continue;
+        }
+
+        // Объединение периодов
+        $minFrom = $from;
+        $maxTo = $to;
+
+        foreach ($hits as $h) {
+            if ($h->date_from < $minFrom) {
+                $minFrom = $h->date_from;
+            }
+            if ($h->date_to > $maxTo) {
+                $maxTo = $h->date_to;
+            }
+        }
+
+        // Обновление master-записи
+        $master = array_shift($hits);
+        $master->date_from = $minFrom;
+        $master->date_to = $maxTo;
+        $master->updated_at = $now;
+        $master->updated_by = $userId;
+        $master->save();
+
+        // Удаление дубликатов
+        foreach ($hits as $dup) {
+            $dup->delete();
+        }
+    }
+}
+```
+
+---
+
+### Пример 3: AJAX-удаление
+
+**JavaScript**:
+
+```javascript
+// jQuery
+$('.btn-delete-actuality').on('click', function(e) {
+    e.preventDefault();
+
+    const id = $(this).data('id');
+    const row = $(this).closest('tr');
+
+    if (!confirm('Удалить запись актуальности?')) {
+        return;
+    }
+
+    $.ajax({
+        url: '/products-1c-nomenclature-actuality/ajax-delete',
+        type: 'POST',
+        data: { id: id },
+        dataType: 'json',
+        success: function(response) {
+            if (response.success) {
+                // Удалить строку с анимацией
+                row.fadeOut(400, function() {
+                    $(this).remove();
+                });
+
+                // Уведомление
+                toastr.success(response.message);
+            } else {
+                toastr.error(response.message);
+            }
+        },
+        error: function(xhr, status, error) {
+            toastr.error('Ошибка сервера: ' + error);
+        }
+    });
+});
+```
+
+**PHP (контроллер)**:
+
+```php
+public function actionAjaxDelete()
+{
+    Yii::$app->response->format = Response::FORMAT_JSON;
+
+    $request = Yii::$app->request;
+    $id = $request->post('id') ?? $request->get('id');
+
+    if (empty($id)) {
+        throw new BadRequestHttpException('Missing parameter: id');
+    }
+
+    try {
+        $model = $this->findModel($id);
+        $model->delete();
+
+        return [
+            'success' => true,
+            'message' => 'Запись успешно удалена',
+        ];
+    } catch (\Throwable $e) {
+        return [
+            'success' => false,
+            'message' => $e->getMessage(),
+        ];
+    }
+}
+```
+
+---
+
+### Пример 4: Фильтрация по характеристикам
+
+**PHP**:
+
+```php
+// Поиск ID свойств "цвет" (поддержка рус/англ)
+$propIds = Products1cPropType::find()
+    ->select('id')
+    ->andWhere(['name' => ['цвет', 'color']])
+    ->column();
+
+// Поиск товаров с красным цветом
+$productIds = Products1cAdditionalCharacteristics::find()
+    ->select('product_id')
+    ->distinct()
+    ->where(['property_id' => $propIds, 'value' => 'Красный'])
+    ->column();
+
+// Фильтрация номенклатуры
+$query->andWhere(['n.id' => $productIds]);
+```
+
+---
+
+### Пример 5: Автоматическое добавление актуальности
+
+**PHP**:
+
+```php
+public function actionAddActivity()
+{
+    $request = Yii::$app->request;
+
+    $historyDays = $request->get('historyDays'); // 14
+    $intervalMonths = $request->get('intervalMonths'); // 4
+    $startFrom = $request->get('startFrom', date('Y-m-d')); // 2024-03-31
+
+    if ($historyDays === null || $intervalMonths === null) {
+        // Отображение формы
+        return $this->render('add-activity', [
+            'historyDays' => $historyDays ?? 14,
+            'intervalMonths' => $intervalMonths ?? 4,
+            'startFrom' => $startFrom,
+        ]);
+    }
+
+    // Расчет периода анализа
+    $endDate = date('Y-m-d', strtotime($startFrom));
+    $startDate = date('Y-m-d', strtotime("-{$historyDays} days", strtotime($endDate)));
+
+    // Поиск товаров с продажами
+    $productIds = (new Query())
+        ->select('sp.product_id')
+        ->from(['s' => 'sales'])
+        ->innerJoin(['sp' => 'sales_products'], 's.id = sp.check_id')
+        ->innerJoin(['p1c' => 'products_1c_nomenclature'], 'p1c.id = sp.product_id')
+        ->andWhere(['between', 's.date', "{$startDate} 00:00:00", "{$endDate} 23:59:59"])
+        ->groupBy('sp.product_id')
+        ->column();
+
+    if (empty($productIds)) {
+        Yii::$app->session->setFlash('info', 'Нет товаров за указанный период.');
+        return $this->render('add-activity', [
+            'historyDays' => $historyDays,
+            'intervalMonths' => $intervalMonths,
+            'startFrom' => $startFrom,
+        ]);
+    }
+
+    // Расчет периода актуальности
+    $now = new \DateTime($endDate);
+    $fromStr = (clone $now)
+        ->modify("-{$intervalMonths} months")
+        ->modify('first day of this month')->setTime(0, 0, 0)
+        ->format('Y-m-d H:i:s');
+    $toStr = (clone $now)
+        ->modify("+{$intervalMonths} months")
+        ->modify('last day of this month')->setTime(23, 59, 59)
+        ->format('Y-m-d H:i:s');
+
+    // Формирование массива
+    $rows = [];
+    foreach ($productIds as $pid) {
+        $rows[] = [
+            'guid' => $pid,
+            'from' => date('Y-m', strtotime($fromStr)),
+            'to' => date('Y-m', strtotime($toStr)),
+        ];
+    }
+
+    // Массовое сохранение
+    $this->processBatchActuality($rows);
+
+    Yii::$app->session->setFlash(
+        'success',
+        "Обновлено актуальностей для " . count($rows) . " товаров."
+    );
+
+    return $this->render('add-activity', [
+        'historyDays' => $historyDays,
+        'intervalMonths' => $intervalMonths,
+        'startFrom' => $startFrom,
+    ]);
+}
+```
+
+---
+
+## FAQ
+
+### Q1: Как работает объединение периодов?
+
+**A**: Метод `processBatchActuality()` автоматически находит пересекающиеся или смежные периоды и объединяет их в один:
+
+```
+Исходные периоды:
+[2024-01-01 to 2024-03-31]
+[2024-02-15 to 2024-04-30]
+
+Результат:
+[2024-01-01 to 2024-04-30]
+```
+
+Алгоритм:
+1. Расширяет границы на ±1 секунду для поиска смежных периодов
+2. Находит все пересекающиеся записи
+3. Вычисляет минимальный `from` и максимальный `to`
+4. Обновляет master-запись и удаляет остальные
+5. Повторяет процесс рекурсивно до отсутствия пересечений
+
+---
+
+### Q2: Почему используется формат Y-m вместо datetime?
+
+**A**: Для упрощения ввода пользователем. Актуальность всегда устанавливается на полные месяцы:
+
+- Ввод: `2024-03`
+- Хранение: `2024-03-01 00:00:00` до `2024-03-31 23:59:59`
+
+Это соответствует бизнес-логике планирования по месяцам.
+
+---
+
+### Q3: Можно ли установить актуальность на конкретный день?
+
+**A**: Нет, минимальная гранулярность — месяц. Это сделано специально для упрощения планирования и исключения микроуправления.
+
+Если нужна актуальность на конкретные даты, можно напрямую работать с моделью `Products1cNomenclatureActuality`:
+
+```php
+$actuality = new Products1cNomenclatureActuality([
+    'guid' => 'abc-123',
+    'date_from' => '2024-03-15 00:00:00',
+    'date_to' => '2024-03-20 23:59:59',
+]);
+$actuality->save();
+```
+
+---
+
+### Q4: Что произойдет, если создать перекрывающиеся периоды?
+
+**A**: Они автоматически объединятся в один непрерывный период.
+
+**Пример**:
+
+```php
+$post = [
+    ['guid' => 'abc', 'from' => '2024-01', 'to' => '2024-03'],
+    ['guid' => 'abc', 'from' => '2024-02', 'to' => '2024-04'],
+    ['guid' => 'abc', 'from' => '2024-03', 'to' => '2024-05'],
+];
+
+processBatchActuality($post);
+
+// Результат в БД:
+// guid: abc, date_from: 2024-01-01 00:00:00, date_to: 2024-05-31 23:59:59
+```
+
+---
+
+### Q5: Как работает фильтрация по характеристикам?
+
+**A**: Характеристики (цвет, размер, сорт, тип) хранятся в отдельной таблице `products_1c_additional_characteristics`. Фильтрация работает через JOIN:
+
+```sql
+-- Найти ID свойства "цвет"
+SELECT id FROM products_1c_prop_type WHERE name IN ('цвет', 'color')
+
+-- Найти товары с красным цветом
+SELECT DISTINCT product_id
+FROM products_1c_additional_characteristics
+WHERE property_id IN (prop_ids) AND value = 'Красный'
+
+-- Фильтровать номенклатуру
+SELECT * FROM products_1c_nomenclature WHERE id IN (product_ids)
+```
+
+Поддерживаются русские и английские названия свойств.
+
+---
+
+### Q6: Как часто нужно запускать actionAddActivity?
+
+**A**: Рекомендуется:
+
+- **Еженедельно** — для актуализации активных товаров:
+  ```bash
+  0 2 * * 1 curl "http://erp/add-activity?historyDays=7&intervalMonths=3"
+  ```
+
+- **Перед началом сезона** — для массовой актуализации:
+  ```bash
+  curl "http://erp/add-activity?historyDays=30&intervalMonths=6&startFrom=2024-03-01"
+  ```
+
+- **По требованию** — для анализа конкретных периодов
+
+---
+
+### Q7: Есть ли ограничение на количество периодов для одного товара?
+
+**A**: Нет жесткого ограничения. Один товар может иметь несколько несмежных периодов:
+
+```
+Роза "Red Naomi":
+- [2024-02-01 to 2024-05-31]  // весна
+- [2024-08-01 to 2024-10-31]  // осень
+- [2024-12-01 to 2024-12-31]  // новый год
+```
+
+---
+
+### Q8: Можно ли отменить массовое сохранение?
+
+**A**: Нет, операция не обернута в транзакцию (см. рекомендации в ANALYSIS.md). При ошибке в одной записи остальные будут обработаны.
+
+**Рекомендация**: Добавить транзакцию:
+
+```php
+$transaction = Yii::$app->db->beginTransaction();
+try {
+    $this->processBatchActuality($post);
+    $transaction->commit();
+} catch (\Exception $e) {
+    $transaction->rollBack();
+    throw $e;
+}
+```
+
+---
+
+### Q9: Как удалить все периоды актуальности для товара?
+
+**A**: Три способа:
+
+**Способ 1: Через UI**
+- Открыть `/index` с фильтром по товару
+- Удалить каждую запись через AJAX
+
+**Способ 2: SQL**
+```sql
+DELETE FROM products_1c_nomenclature_actuality WHERE guid = 'abc-123';
+```
+
+**Способ 3: PHP**
+```php
+Products1cNomenclatureActuality::deleteAll(['guid' => 'abc-123']);
+```
+
+---
+
+### Q10: Что делать, если товар актуален круглый год?
+
+**A**: Установить один широкий период:
+
+```php
+POST /index
+actuality[0][guid] = abc-123
+actuality[0][from] = 2024-01
+actuality[0][to] = 2024-12
+```
+
+Или на несколько лет:
+
+```php
+actuality[0][from] = 2024-01
+actuality[0][to] = 2026-12
+```
+
+---
+
+## Рекомендации
+
+### ✅ Best practices
+
+1. **Используйте фильтры** — избегайте загрузки всех товаров без фильтров
+2. **Массовое редактирование** — обрабатывайте товары пакетами через `actionIndex()`
+3. **Автоматизация** — настройте cron для еженедельного запуска `actionAddActivity()`
+4. **AJAX для удаления** — используйте `actionAjaxDelete()` для лучшего UX
+5. **Проверка актуальности** — периодически проверяйте товары с `onlyInactive=1`
+
+### ⚠️ Что избегать
+
+1. ❌ Не создавайте перекрывающиеся периоды вручную (они объединятся автоматически)
+2. ❌ Не удаляйте записи напрямую из БД (используйте контроллер для аудита)
+3. ❌ Не устанавливайте актуальность на далекое будущее (>5 лет)
+4. ❌ Не игнорируйте flash-сообщения об ошибках
+5. ❌ Не запускайте `actionAddActivity()` слишком часто (нагрузка на БД)
+
+### 🔧 Оптимизация
+
+1. **Индексы БД**:
+   ```sql
+   CREATE INDEX idx_actuality_guid ON products_1c_nomenclature_actuality(guid);
+   CREATE INDEX idx_actuality_dates ON products_1c_nomenclature_actuality(date_from, date_to);
+   ```
+
+2. **Кеширование списков**:
+   ```php
+   $categories = Yii::$app->cache->getOrSet('nomenclature_categories', function() {
+       return Products1cNomenclature::find()->select('category')->distinct()->column();
+   }, 3600);
+   ```
+
+3. **Пагинация**:
+   - Используйте пагинацию для больших выборок
+   - Ограничьте pageSize до 1000 записей
+
+---
+
+## Интеграции
+
+### С другими компонентами ERP
+
+1. **Планирование закупок** — использует актуальность для определения, какие товары закупать
+2. **Автопланограмма** — учитывает актуальность при расчете планов продаж
+3. **Аналитика продаж** — фильтрует товары по актуальности для отчетов
+4. **Складской учет** — предупреждает о неактуальных товарах на складе
+
+### С 1С
+
+- **Импорт номенклатуры** — данные из 1С в таблицу `products_1c_nomenclature`
+- **Синхронизация** — актуальность может экспортироваться обратно в 1С (опционально)
+- **GUID** — используется GUID из 1С для связи с номенклатурой
+
+---
+
+## Дополнительные ресурсы
+
+### Связанная документация
+
+- **ANALYSIS.md** — полный анализ контроллера с детальным описанием методов
+- **ACTIONS_TABLE.md** — таблица всех actions с параметрами и примерами
+- **Models**:
+  - `erp24/records/Products1cNomenclatureActuality.php`
+  - `erp24/records/Products1cNomenclature.php`
+  - `erp24/records/Products1cAdditionalCharacteristics.php`
+
+### Диаграммы в других документах
+
+- Архитектура контроллера → ANALYSIS.md
+- Процесс объединения периодов → ANALYSIS.md
+- Workflow actionAddActivity → ANALYSIS.md
+
+---
+
+## Заключение
+
+**Products1cNomenclatureActualityController** — мощный инструмент для управления актуальностью товаров:
+
+### Ключевые возможности:
+- ✅ Многоуровневая фильтрация номенклатуры
+- ✅ Массовое редактирование актуальности
+- ✅ Автоматическое определение по продажам
+- ✅ Умное объединение периодов
+- ✅ AJAX-операции для удобства
+
+### Бизнес-ценность:
+- 📊 Точное планирование закупок
+- 🎯 Учет сезонности товаров
+- ⚡ Автоматизация рутинных операций
+- 🔄 Актуальность данных на основе реальных продаж
+- 💾 Предотвращение дублирования и несогласованности
+
+Используйте этот контроллер для эффективного управления актуальностью номенклатуры в вашей ERP-системе!