From 931bf504c2d84d811b3162cfec61568a5ddece9c Mon Sep 17 00:00:00 2001 From: fomichev Date: Wed, 26 Nov 2025 10:41:28 +0300 Subject: [PATCH] =?utf8?q?=D0=9A=D0=BE=D0=BD=D1=82=D1=80=D0=BE=D0=BB=D0=BB?= =?utf8?q?=D0=B5=D1=80=D1=8B=20=D0=BF=D1=80=D0=BE=D0=B4=D0=BE=D0=BB=D0=B6?= =?utf8?q?=D0=B5=D0=BD=D0=B8=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=utf8 Content-Transfer-Encoding: 8bit --- .../CategoryPlanController_ACTIONS_TABLE.md | 1347 ++++++++++++++++ .../CategoryPlanController_ANALYSIS.md | 740 +++++++++ .../CategoryPlanController_QUICK_REFERENCE.md | 699 ++++++++ ...rtForManagementController_ACTIONS_TABLE.md | 913 +++++++++++ .../ChartForManagementController_ANALYSIS.md | 741 +++++++++ ...ClusterLinkEditController_ACTIONS_TABLE.md | 922 +++++++++++ .../ClusterLinkEditController_ANALYSIS.md | 486 ++++++ ...usterLinkEditController_QUICK_REFERENCE.md | 542 +++++++ ...ouquetActualityController_ACTIONS_TABLE.md | 663 ++++++++ ...trixBouquetActualityController_ANALYSIS.md | 1022 ++++++++++++ ...quetActualityController_QUICK_REFERENCE.md | 800 +++++++++ .../MatrixErpController_ACTIONS_TABLE.md | 1022 ++++++++++++ .../MatrixErpController_ANALYSIS.md | 1013 ++++++++++++ ...latureActualityController_ACTIONS_TABLE.md | 877 ++++++++++ ...omenclatureActualityController_ANALYSIS.md | 1436 +++++++++++++++++ ...tureActualityController_QUICK_REFERENCE.md | 1046 ++++++++++++ 16 files changed, 14269 insertions(+) create mode 100644 erp24/docs/controllers/non-standard/CategoryPlanController_ACTIONS_TABLE.md create mode 100644 erp24/docs/controllers/non-standard/CategoryPlanController_ANALYSIS.md create mode 100644 erp24/docs/controllers/non-standard/CategoryPlanController_QUICK_REFERENCE.md create mode 100644 erp24/docs/controllers/non-standard/ChartForManagementController_ACTIONS_TABLE.md create mode 100644 erp24/docs/controllers/non-standard/ChartForManagementController_ANALYSIS.md create mode 100644 erp24/docs/controllers/non-standard/ClusterLinkEditController_ACTIONS_TABLE.md create mode 100644 erp24/docs/controllers/non-standard/ClusterLinkEditController_ANALYSIS.md create mode 100644 erp24/docs/controllers/non-standard/ClusterLinkEditController_QUICK_REFERENCE.md create mode 100644 erp24/docs/controllers/non-standard/MatrixBouquetActualityController_ACTIONS_TABLE.md create mode 100644 erp24/docs/controllers/non-standard/MatrixBouquetActualityController_ANALYSIS.md create mode 100644 erp24/docs/controllers/non-standard/MatrixBouquetActualityController_QUICK_REFERENCE.md create mode 100644 erp24/docs/controllers/non-standard/MatrixErpController_ACTIONS_TABLE.md create mode 100644 erp24/docs/controllers/non-standard/MatrixErpController_ANALYSIS.md create mode 100644 erp24/docs/controllers/non-standard/Products1cNomenclatureActualityController_ACTIONS_TABLE.md create mode 100644 erp24/docs/controllers/non-standard/Products1cNomenclatureActualityController_ANALYSIS.md create mode 100644 erp24/docs/controllers/non-standard/Products1cNomenclatureActualityController_QUICK_REFERENCE.md 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 index 00000000..1e955b0d --- /dev/null +++ b/erp24/docs/controllers/non-standard/CategoryPlanController_ACTIONS_TABLE.md @@ -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 index 00000000..1d7e8f2d --- /dev/null +++ b/erp24/docs/controllers/non-standard/CategoryPlanController_ANALYSIS.md @@ -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 index 00000000..ca26590d --- /dev/null +++ b/erp24/docs/controllers/non-standard/CategoryPlanController_QUICK_REFERENCE.md @@ -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(''); + + $.each(data, function(i, item) { + $subcategory.append(''); + }); + + // Очистить виды + $('#species').empty().append(''); + } + }); +}); + +// Загрузка видов при выборе подкатегории +$('#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(''); + + $.each(data, function(i, item) { + $species.append(''); + }); + } + }); +}); +``` + +--- + +## 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 index 00000000..d40e77f8 --- /dev/null +++ b/erp24/docs/controllers/non-standard/ChartForManagementController_ACTIONS_TABLE.md @@ -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 + +
+
...
+
...
+ ... +
+``` + +--- + +### 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 + +
+
+
...
+
+``` + +--- + +### 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 +
+

Списания за 2024-01-15

+ + + + + + + + + + + + + + + + + + + ... + +
МагазинСуммаВремяНомерКомментарий
Магазин Центральный150.50 руб14:30:00WO-2024-001Брак товара при приемке
+
+``` + +##### Пример 2: Списания по конкретному магазину + +**Request:** +``` +GET /chart-for-management/write-offs-index?date=2024-01-15&store_id=42 +``` + +**Response:** +```html + +``` + +##### Пример 3: Списания по кусту + +**Request:** +``` +GET /chart-for-management/write-offs-index?date=2024-01-15&cluster_id=5 +``` + +**Response:** +```html + +``` + +--- + +## Матрица доступов к 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 index 00000000..f39b564b --- /dev/null +++ b/erp24/docs/controllers/non-standard/ChartForManagementController_ANALYSIS.md @@ -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: Парная/групповая обработка
Расчет агрегатов
Формирование осей + + 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 index 00000000..66d4a977 --- /dev/null +++ b/erp24/docs/controllers/non-standard/ClusterLinkEditController_ACTIONS_TABLE.md @@ -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{Интервал попадает
в неделю?} + 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
AND date_to > 2024-09-12] + C --> E[date_from <= requestDate
AND date_to > requestDate
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{Есть активная запись
для магазина?} + B -->|Да| C[Закрыть старую запись:
date_to = NOW, active = 0] + B -->|Нет| D[Создать новую запись] + C --> D + D --> E[Установить параметры:
cluster_id, store_id,
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 +
+ + + +
+``` + +--- + +### 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[Парсинг ключей
store_X__month_Y__weekMonth_Z] + B --> C[Группировка по магазинам] + C --> D[Определение интервалов:
объединение последовательных
недель с одним кластером] + D --> E[Конвертация недель
в даты date_from/date_to] + E --> F[Удаление старых данных
ClusterCalendar] + F --> G[Пакетная вставка
новых записей] + 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 + + field($model, 'name')->textInput(['maxlength' => true]) ?> + field($model, 'active')->checkbox() ?> + +
+ 'btn btn-success']) ?> +
+ +``` + +--- + +### 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 index 00000000..3d14c211 --- /dev/null +++ b/erp24/docs/controllers/non-standard/ClusterLinkEditController_ANALYSIS.md @@ -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{Найти активную запись
StoreDynamic} + B -->|Найдена| C[Закрыть текущую запись:
date_to = NOW, active = 0] + C --> D[Создать новую запись:
новый cluster_id,
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{Есть активная запись
для магазина?} + B -->|Да| C[Закрыть старую запись] + C --> D[Создать новую запись
с новым 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 index 00000000..0204d149 --- /dev/null +++ b/erp24/docs/controllers/non-standard/ClusterLinkEditController_QUICK_REFERENCE.md @@ -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
для фильтрации] + C --> E[date_from <= дата
AND date_to > дата
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
{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 index 00000000..479c55f5 --- /dev/null +++ b/erp24/docs/controllers/non-standard/MatrixBouquetActualityController_ACTIONS_TABLE.md @@ -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
GET/POST] + View[actionView
GET] + Create[actionCreate
GET/POST] + Update[actionUpdate
GET/POST] + Delete[actionDelete
POST] + AjaxDelete[actionAjaxDelete
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 index 00000000..a71d2a55 --- /dev/null +++ b/erp24/docs/controllers/non-standard/MatrixBouquetActualityController_ANALYSIS.md @@ -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{Найти пересекающиеся
записи} + FindNeighbors -->|Нет соседей| End[Завершение] + FindNeighbors -->|Есть соседи| ExtendMaster[Расширить мастер до
границ соседей] + + 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
Фильтрация и массовое
редактирование] + View[actionView
Просмотр] + Create[actionCreate
Создание] + Update[actionUpdate
Обновление] + Delete[actionDelete
Удаление] + AjaxDelete[actionAjaxDelete
AJAX удаление] + + CheckAccess[checkAccess
Проверка прав] + FindModel[findModel
Поиск модели] + ProcessBatch[processBatchActuality
Массовое сохранение] + GetDescendants[getMatrixTypeDescendantsIds
Получение потомков] + 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 index 00000000..5215adde --- /dev/null +++ b/erp24/docs/controllers/non-standard/MatrixBouquetActualityController_QUICK_REFERENCE.md @@ -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[Пользователь открывает
страницу] --> CheckAccess{Проверка
прав доступа} + CheckAccess -->|Нет доступа| Redirect[Редирект на
главную страницу] + CheckAccess -->|Доступ разрешен| ApplyFilters[Применение
фильтров] + + ApplyFilters --> LoadData{Фильтры
применены?} + LoadData -->|Нет| LoadAll[Загрузка всех
букетов] + LoadData -->|Да| LoadFiltered[Загрузка
отфильтрованных
букетов] + + LoadAll --> DisplayGrid[Отображение
таблицы] + LoadFiltered --> 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[Flash-сообщение +
обновление страницы] + Refresh --> DisplayGrid + + style CheckAccess fill:#ffe1e1 + style BatchSave fill:#fff4e1 + style MergeLoop fill:#e1f5ff + style Success fill:#e1ffe1 +``` + +--- + +### Диаграмма 2: Алгоритм объединения периодов + +```mermaid +flowchart TD + Start[Начало объединения
для букета] --> SetMaster[Установить
мастер-запись] + SetMaster --> ExpandBounds[Расширить границы
на ±1 секунду] + + ExpandBounds --> FindNeighbors{Найти
пересекающиеся
записи} + FindNeighbors -->|Нет соседей| End[Завершение] + FindNeighbors -->|Есть соседи| ExtendMaster[Расширить мастер
до границ соседей] + + ExtendMaster --> CheckBounds{Границы
изменились?} + CheckBounds -->|Нет| DeleteNeighbors[Удалить соседние
записи] + CheckBounds -->|Да| UpdateMaster[Сохранить
мастер-запись] + + UpdateMaster --> DeleteNeighbors + DeleteNeighbors --> ExpandBounds + + style SetMaster fill:#e1f5ff + style ExtendMaster fill:#fff4e1 + style End fill:#e1ffe1 +``` + +--- + +### Диаграмма 3: Фильтрация по иерархии групп + +```mermaid +graph TD + User[Пользователь выбирает
группу MatrixType ID=1] --> Controller[actionIndex] + Controller --> GetDescendants[getMatrixTypeDescendantsIds
rootId=1] + + GetDescendants --> LoadAll[Загрузить все типы
MatrixType] + LoadAll --> BuildTree[Построить массив
byParent] + + BuildTree --> Traverse[Обход дерева
через стек] + Traverse --> CollectIDs[Собрать все ID
потомков] + + CollectIDs --> Result[Результат:
1, 2, 3, 4, 5] + + Result --> FindHistory[Найти историю
BouquetCompositionMatrixTypeHistory
для ID 1,2,3,4,5] + FindHistory --> ExtractBouquets[Извлечь уникальные
bouquet_id] + + ExtractBouquets --> FilterBouquets[Фильтровать
BouquetComposition
по bouquet_id] + + FilterBouquets --> DisplayResults[Отобразить
отфильтрованные букеты] + + 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
actuality[] data + Controller->>Controller: checkAccess() + Controller->>Batch: processBatchActuality($post) + + loop Для каждой записи в $post + Batch->>Batch: Валидация дат + alt Даты невалидны + Batch->>Batch: continue (пропустить) + else Даты валидны + Batch->>Batch: Преобразовать месяцы
в полные диапазоны + + 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 index 00000000..6d2d0513 --- /dev/null +++ b/erp24/docs/controllers/non-standard/MatrixErpController_ACTIONS_TABLE.md @@ -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 index 00000000..435e6029 --- /dev/null +++ b/erp24/docs/controllers/non-standard/MatrixErpController_ANALYSIS.md @@ -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{Товар существует
в MatrixErp
с group_name='marketplace'?} + G -->|Нет| H[Создать новый MatrixErp] + H --> I[guid, name, articule, code
components, parent_id
из 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
существует?} + B -->|Нет| C[Создать новый
MatrixErpProperty] + B -->|Да| D[Загрузить существующий] + C --> E[Установить created_at,
created_admin_id] + D --> F[Установить updated_at,
updated_admin_id] + E --> G[Скачать главное изображение
downloadAsUploadedFile] + F --> G + G --> H{Файл получен?} + H -->|Нет| I[Ошибка: не указана ссылка
return false] + H -->|Да| J{Файл - изображение?} + J -->|Нет| K[Ошибка: неверный тип
return false] + J -->|Да| L[Загрузить в Images] + L --> M[Установить image_id,
external_image_url] + M --> N[Удалить старый файл] + N --> O{Есть image_urls?} + O -->|Да| P[purgeMatrixMedia foto] + P --> Q[Для каждого URL:
скачать и сохранить] + 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
из parsed data] + X --> Y[product_url через
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 index 00000000..9725a797 --- /dev/null +++ b/erp24/docs/controllers/non-standard/Products1cNomenclatureActualityController_ACTIONS_TABLE.md @@ -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
запрос?} + + CheckPost -->|Да| ProcessBatch[processBatchActuality
массовое сохранение] + ProcessBatch --> FlashSuccess[Flash: успех] + FlashSuccess --> Refresh[Redirect refresh] + Refresh --> End([Конец]) + + CheckPost -->|Нет| HasFilters{Фильтры
заполнены?} + + HasFilters -->|Нет| EmptyProvider[Пустой DataProvider
query 0=1] + EmptyProvider --> PrepareDropdowns[Подготовка списков
для фильтров] + PrepareDropdowns --> RenderView[Render index view] + RenderView --> End + + HasFilters -->|Да| BuildQuery[Построение запроса
Products1cNomenclature] + + BuildQuery --> FilterCategory[Фильтр по категории,
подкатегории, виду] + FilterCategory --> FilterCharacteristics[Фильтр по характеристикам
через Additional Characteristics] + FilterCharacteristics --> FilterActuality[Фильтр по наличию
актуальности] + FilterActuality --> FilterDates[Фильтр по датам
актуальности] + + FilterDates --> LoadProducts[Загрузка товаров
with actualities] + LoadProducts --> BuildRows[Формирование строк
product + actuality] + + BuildRows --> CreateProvider[ArrayDataProvider
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{Параметры
указаны?} + + CheckParams -->|Нет| RenderForm[Render формы
со значениями по умолчанию] + RenderForm --> End([Конец]) + + CheckParams -->|Да| CalcPeriod[Расчет периода анализа
startDate = startFrom - historyDays
endDate = startFrom] + + CalcPeriod --> QuerySales[SQL: поиск товаров
с продажами за период] + + QuerySales --> HasProducts{Есть
товары?} + + HasProducts -->|Нет| FlashInfo[Flash: Нет товаров] + FlashInfo --> RenderFormWithParams[Render формы
с параметрами] + RenderFormWithParams --> End + + HasProducts -->|Да| CalcActuality[Расчет актуальности
from = startFrom - intervalMonths
to = startFrom + intervalMonths] + + CalcActuality --> PrepareRows[Формирование массива
[guid, from, to]] + + PrepareRows --> ProcessBatch[processBatchActuality
массовое сохранение] + + 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 +
+ + +
+``` + +**Результат**: +- Запись удалена из БД +- 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 + +``` + +#### Пример 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 index 00000000..92008738 --- /dev/null +++ b/erp24/docs/controllers/non-standard/Products1cNomenclatureActualityController_ANALYSIS.md @@ -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
Фильтрация + массовое редактирование] + AddActivity[actionAddActivity
Автоопределение по продажам] + CRUD[CRUD actions
view, create, update, delete] + AjaxDelete[actionAjaxDelete
AJAX-удаление] + end + + subgraph "Helper Methods" + ProcessBatch[processBatchActuality
Объединение периодов] + FindModel[findModel
Поиск по ID] + end + + subgraph "Models" + Actuality[Products1cNomenclatureActuality
Периоды актуальности] + Nomenclature[Products1cNomenclature
Номенклатура 1С] + Characteristics[Products1cAdditionalCharacteristics
Характеристики] + PropType[Products1cPropType
Типы свойств] + Sales[Sales
Продажи] + 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{Для каждой
записи} + + Loop -->|Запись| ValidateDates[Валидация дат
Y-m → datetime] + ValidateDates --> CheckDates{Даты
валидны?} + + CheckDates -->|Нет| Loop + CheckDates -->|Да| CheckLogic{from <= to?} + + CheckLogic -->|Нет| Loop + CheckLogic -->|Да| CalcBounds[Расчет границ
fromAdj = from - 1s
toAdj = to + 1s] + + CalcBounds --> HasID{id
указан?} + + HasID -->|Да| UpdateExisting[Обновить
существующую запись] + UpdateExisting --> FindNeighborsUpdate[Найти пересекающихся
соседей] + FindNeighborsUpdate --> HasNeighborsUpdate{Есть
соседи?} + + HasNeighborsUpdate -->|Нет| Loop + HasNeighborsUpdate -->|Да| MergeUpdate[Объединить границы
minFrom, maxTo] + MergeUpdate --> DeleteNeighborsUpdate[Удалить
поглощенные записи] + DeleteNeighborsUpdate --> RecursiveUpdate{Есть новые
пересечения?} + + RecursiveUpdate -->|Да| MergeUpdate + RecursiveUpdate -->|Нет| Loop + + HasID -->|Нет| FindHits[Найти пересечения
для guid] + FindHits --> HasHits{Есть
пересечения?} + + HasHits -->|Нет| CreateNew[Создать новую запись] + CreateNew --> Loop + + HasHits -->|Да| CalcMinMax[Расчет общих границ
minFrom, maxTo] + CalcMinMax --> SelectMaster[Выбрать master
первая запись] + SelectMaster --> UpdateMaster[Обновить master
новыми границами] + UpdateMaster --> DeleteDups[Удалить дубликаты] + DeleteDups --> RecursiveCreate{Есть новые
пересечения?} + + 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: Расчет периода анализа
startDate = now - 14 days
endDate = now + + Controller->>Sales: Запрос товаров с продажами
за период startDate-endDate + Sales-->>Controller: Список product_id + + alt Нет товаров + Controller-->>User: Flash: "Нет товаров" + else Есть товары + Controller->>Controller: Расчет периода актуальности
from = now - 4 months (начало месяца)
to = now + 4 months (конец месяца) + + Controller->>Controller: Формирование массива
[{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 + +``` + +**Ответ сервера**: +```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 index 00000000..92f314fa --- /dev/null +++ b/erp24/docs/controllers/non-standard/Products1cNomenclatureActualityController_QUICK_REFERENCE.md @@ -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 + +``` + +--- + +## Диаграммы + +### 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
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: Рекурсивное объединение
(while есть пересечения) + end + + ProcessBatch-->>Controller: Готово + Controller->>Controller: Flash: "Успешно сохранено" + Controller-->>Browser: Redirect /index (refresh) + Browser-->>User: Обновленная таблица +``` + +--- + +### 2. Workflow - Автоматическое добавление + +```mermaid +flowchart TD + Start([User: actionAddActivity]) --> InputParams[Ввод параметров:
historyDays=7
intervalMonths=3
startFrom=2024-03-31] + + InputParams --> CalcPeriod[Расчет периода анализа:
startDate = 2024-03-24
endDate = 2024-03-31] + + CalcPeriod --> QuerySales[SQL: поиск товаров
с продажами за период] + + QuerySales --> HasProducts{Найдены
товары?} + + HasProducts -->|Нет| FlashInfo[Flash: Нет товаров] + FlashInfo --> End([Конец]) + + HasProducts -->|Да| CalcActuality[Расчет актуальности:
from = 2023-12-01 -4 months
to = 2024-06-30 +3 months] + + CalcActuality --> PrepareData[Формирование массива:
[{guid, from, to}, ...]] + + PrepareData --> ProcessBatch[processBatchActuality] + + ProcessBatch --> Loop{Для каждого
товара} + + Loop -->|Запись| FindIntersections[Найти пересечения
для guid] + + FindIntersections --> HasIntersections{Есть
пересечения?} + + HasIntersections -->|Нет| CreateNew[Создать новую запись] + CreateNew --> Loop + + HasIntersections -->|Да| MergeRanges[Объединить периоды:
minFrom, maxTo] + MergeRanges --> UpdateMaster[Обновить master-запись] + UpdateMaster --> DeleteDups[Удалить дубликаты] + DeleteDups --> RecursiveMerge{Есть новые
пересечения?} + + 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:
2024-01-01 to 2024-03-31] + P2[Период 2:
2024-02-15 to 2024-04-30] + P3[Период 3:
2024-05-01 to 2024-06-30] + end + + subgraph "Проверка пересечений" + Check1{P1 и P2
пересекаются?} + Check2{P2 и P3
пересекаются?} + end + + subgraph "Объединение" + Merge1[Объединить P1 + P2:
2024-01-01 to 2024-04-30] + Keep[Оставить P3:
2024-05-01 to 2024-06-30] + end + + subgraph "Результат в БД" + Result1[Период A:
2024-01-01 to 2024-04-30] + Result2[Период B:
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 +
+ + + $product): ?> +
+ + + + + + + +
+ + + +
+``` + +--- + +### Пример 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-системе! -- 2.39.5