From 6dc99b9d5a8bfe059e96f146c3347fb7bbcb1f25 Mon Sep 17 00:00:00 2001 From: fomichev Date: Wed, 26 Nov 2025 17:04:22 +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 --- .../CONTROLLERS_DOCUMENTATION_PLAN.md | 26 +- ...tsForManagementController_ACTIONS_TABLE.md | 412 +++++ .../ChartsForManagementController_ANALYSIS.md | 1474 +++++++++++++++++ ...ForManagementController_QUICK_REFERENCE.md | 300 ++++ .../ShiftTransferController_ACTIONS_TABLE.md | 95 ++ .../ShiftTransferController_ANALYSIS.md | 275 +++ ...ShiftTransferController_QUICK_REFERENCE.md | 469 ++++++ .../StoreStaffingController_ACTIONS_TABLE.md | 749 +++++++++ .../StoreStaffingController_ANALYSIS.md | 1082 ++++++++++++ ...StoreStaffingController_QUICK_REFERENCE.md | 438 +++++ .../ClusterAdminController_ACTIONS_TABLE.md | 556 +++++++ .../crud/ClusterAdminController_ANALYSIS.md | 987 +++++++++++ .../ClusterAdminController_QUICK_REFERENCE.md | 441 +++++ 13 files changed, 7293 insertions(+), 11 deletions(-) create mode 100644 erp24/docs/controllers/non-standard/ChartsForManagementController_ACTIONS_TABLE.md create mode 100644 erp24/docs/controllers/non-standard/ChartsForManagementController_ANALYSIS.md create mode 100644 erp24/docs/controllers/non-standard/ChartsForManagementController_QUICK_REFERENCE.md create mode 100644 erp24/docs/controllers/non-standard/ShiftTransferController_ACTIONS_TABLE.md create mode 100644 erp24/docs/controllers/non-standard/ShiftTransferController_ANALYSIS.md create mode 100644 erp24/docs/controllers/non-standard/ShiftTransferController_QUICK_REFERENCE.md create mode 100644 erp24/docs/controllers/non-standard/StoreStaffingController_ACTIONS_TABLE.md create mode 100644 erp24/docs/controllers/non-standard/StoreStaffingController_ANALYSIS.md create mode 100644 erp24/docs/controllers/non-standard/StoreStaffingController_QUICK_REFERENCE.md create mode 100644 erp24/docs/controllers/non-standard/crud/ClusterAdminController_ACTIONS_TABLE.md create mode 100644 erp24/docs/controllers/non-standard/crud/ClusterAdminController_ANALYSIS.md create mode 100644 erp24/docs/controllers/non-standard/crud/ClusterAdminController_QUICK_REFERENCE.md diff --git a/erp24/docs/controllers/CONTROLLERS_DOCUMENTATION_PLAN.md b/erp24/docs/controllers/CONTROLLERS_DOCUMENTATION_PLAN.md index 88b37b81..32c7abf2 100644 --- a/erp24/docs/controllers/CONTROLLERS_DOCUMENTATION_PLAN.md +++ b/erp24/docs/controllers/CONTROLLERS_DOCUMENTATION_PLAN.md @@ -19,11 +19,11 @@ - 🔄 Детальная документация 47 нестандартных контроллеров ### План документирования -- ⏳ Фаза 1: Критичные контроллеры (3 шт.) — >1000 строк -- ⏳ Фаза 2: Крупные контроллеры (9 шт.) — 500-1000 строк -- ⏳ Фаза 3: Средние с интеграциями (17 шт.) — 300-500 строк -- ⏳ Фаза 4: Средние со сложной логикой (10 шт.) — 200-300 строк -- ⏳ Фаза 5: Малые с особыми признаками (8 шт.) — <200 строк +- ✅ Фаза 1: Критичные контроллеры (3 шт.) — >1000 строк — **100% завершено** ✅ +- ✅ Фаза 2: Крупные контроллеры (9 шт.) — 500-1000 строк — **100% завершено** ✅ +- 🔄 Фаза 3: Средние с интеграциями (17 шт.) — 300-500 строк — **6% завершено** (1/17) +- ⏳ Фаза 4: Средние со сложной логикой (10 шт.) — 200-300 строк — **0% завершено** +- ⏳ Фаза 5: Малые с особыми признаками (8 шт.) — <200 строк — **0% завершено** --- @@ -175,7 +175,11 @@ graph TB #### Список контроллеров (17 шт.): -1. ShiftTransferController (490 строк) +1. ✅ **ShiftTransferController** (490 строк) — Передача смены, 5 статусов, интеграция с 1С + - _ANALYSIS.md (276 строк) + - _ACTIONS_TABLE.md (96 строк) + - _QUICK_REFERENCE.md (442 строки) + - **Итого:** ~814 строк документации 2. crud/Product1cReplacementController (480 строк) 3. ApiController (476 строк) 4. TimetableFactController (475 строк) @@ -580,11 +584,11 @@ try { | Метрика | Цель | Текущий | Прогресс | |---------|------|---------|----------| -| **Документированных контроллеров** | 47 | 0 | 0% | -| **Строк документации** | 25,000+ | 0 | 0% | -| **Mermaid диаграмм** | 145+ | 0 | 0% | -| **Примеров кода** | 200+ | 0 | 0% | -| **FAQ записей** | 100+ | 0 | 0% | +| **Документированных контроллеров** | 47 | 13 | 28% | +| **Строк документации** | 25,000+ | ~10,014 | 40% | +| **Mermaid диаграмм** | 145+ | 40+ | 28% | +| **Примеров кода** | 200+ | 71+ | 36% | +| **FAQ записей** | 100+ | 40+ | 40% | ### Качественные показатели diff --git a/erp24/docs/controllers/non-standard/ChartsForManagementController_ACTIONS_TABLE.md b/erp24/docs/controllers/non-standard/ChartsForManagementController_ACTIONS_TABLE.md new file mode 100644 index 00000000..35c45434 --- /dev/null +++ b/erp24/docs/controllers/non-standard/ChartsForManagementController_ACTIONS_TABLE.md @@ -0,0 +1,412 @@ +# ChartsForManagementController - Таблица Actions + +## Быстрая справка по действиям + +| # | Action | HTTP | Маршрут | Назначение | Параметры | Возвращает | +|---|--------|------|---------|------------|-----------|------------| +| 1 | `actionIndex()` | GET | `/charts-for-management/index` | Главная страница графиков | - | HTML | +| 2 | `actionWriteOffPosition()` | GET | `/charts-for-management/write-off-position` | Страница графика списаний | - | HTML | +| 3 | `actionGetControlDataAjax()` | POST | `/charts-for-management/get-control-data-ajax` | Получение кустов и магазинов | `date_start`, `date_end` | JSON | +| 4 | `actionGetDataAjax()` | POST | `/charts-for-management/get-data-ajax` | Получение данных графиков | `mode`, `attribute`, `date_start`, `date_end`, `cluster`, `store`, `shift` | JSON | +| 5 | `actionWriteOffsIndex()` | GET | `/charts-for-management/write-offs-index` | Список списаний за дату | `date`, `cluster_id`, `store_id` | HTML | + +--- + +## Детальное описание Actions + +### 1. actionIndex() + +**Сигнатура:** +```php +public function actionIndex(): string +``` + +**Назначение:** Отображение главной страницы с набором графиков для менеджмента + +**Параметры:** Нет + +**Возвращает:** +- `string` — HTML страница с графиками + +**Исключения:** +- `Exception` — "Нет доступа" (если group_id не распознана) + +**Права доступа:** +- Группы: 1, 81, 71, 51, 10, 9, 74, 14 (топ-менеджмент) — полный доступ +- Группа 7 (менеджер куста) — куст и магазины +- Группы 30, 40 (менеджер магазина день) — только магазин, день +- Группы 35, 72 (менеджер магазина ночь) — только магазин, ночь +- Группа 50 (администратор) — магазин, все смены + +**Пример вызова:** +```php +// GET /charts-for-management/index +Yii::$app->controller->actionIndex(); +``` + +**Пример HTTP:** +```bash +curl -X GET "https://erp24.example.com/charts-for-management/index" \ + -H "Cookie: PHPSESSID=..." +``` + +--- + +### 2. actionWriteOffPosition() + +**Сигнатура:** +```php +public function actionWriteOffPosition(): string +``` + +**Назначение:** Отображение страницы графика списаний по позициям + +**Параметры:** Нет + +**Возвращает:** +- `string` — HTML страница графика + +**Исключения:** +- `Exception` — "Нет доступа" + +**Права доступа:** Аналогично `actionIndex()` + +**Особенности:** +- Группы 1, 81, 71, 51, 10, 9, 74, 14, 7, 50 имеют `access_plan = true` +- Остальные группы: `access_plan = false` + +**Пример вызова:** +```php +// GET /charts-for-management/write-off-position +Yii::$app->controller->actionWriteOffPosition(); +``` + +**Пример HTTP:** +```bash +curl -X GET "https://erp24.example.com/charts-for-management/write-off-position" \ + -H "Cookie: PHPSESSID=..." +``` + +--- + +### 3. actionGetControlDataAjax() + +**Сигнатура:** +```php +public function actionGetControlDataAjax(): string +``` + +**Назначение:** AJAX-получение управляющих данных: списка кустов, магазинов и истории изменений + +**Параметры (POST):** + +| Параметр | Тип | Обязательный | Значение по умолчанию | Описание | +|----------|-----|--------------|----------------------|----------| +| `date_start` | string | Да | - | Дата начала периода (Y-m-d) | +| `date_end` | string | Да | - | Дата окончания периода (Y-m-d) | + +**Возвращает:** +- `string` — JSON + +**Структура JSON:** +```json +{ + "stores_step": { + "Магазин 1": { + "2025-01-01": "Куст 1", + "2025-02-01": "Куст 2" + } + }, + "clusters": [ + {"id": 1, "text": "Куст 1"}, + {"id": 2, "text": "Куст 2"} + ], + "stores_in_cluster": { + "1": { + "text": "Куст 1", + "children": [ + {"id": 1, "text": "Магазин 1"}, + {"id": 2, "text": "Магазин 2"} + ] + } + } +} +``` + +**Пример вызова:** +```javascript +$.ajax({ + url: '/charts-for-management/get-control-data-ajax', + type: 'POST', + data: { + date_start: '2025-01-01', + date_end: '2025-01-31' + }, + success: function(response) { + const data = JSON.parse(response); + console.log(data.clusters); + } +}); +``` + +**Особенности:** +- Возвращает только магазины пользователя (из `admin.store_arr`) +- Учитывает историю изменений кустов (`store_dynamic`) +- `stores_step` содержит только магазины, которые меняли куст в периоде + +--- + +### 4. actionGetDataAjax() + +**Сигнатура:** +```php +public function actionGetDataAjax(): string|int +``` + +**Назначение:** AJAX-получение данных для построения графиков + +**Параметры (POST):** + +| Параметр | Тип | Обязательный | Значение по умолчанию | Описание | +|----------|-----|--------------|----------------------|----------| +| `mode` | int | Да | - | Уровень: 1-Розница, 2-Куст, 3-Магазин | +| `attribute` | string | Да | - | Тип графика (см. список ниже) | +| `date_start` | string | Нет | `-13 days` | Дата начала (Y-m-d) | +| `date_end` | string | Нет | `today` | Дата окончания (Y-m-d) | +| `cluster` | int | Нет | `null` | ID куста | +| `store` | int | Нет | `null` | ID магазина | +| `shift` | int | Нет | `3` | Смена: 1-День, 2-Ночь, 3-День+Ночь | + +**Типы графиков (attribute):** + +| Значение | Описание | Показатели | +|----------|----------|------------| +| `sales` | Продажи | продажи, план | +| `plan_completed_this_day` | Выполнение плана за день | процент | +| `plan_completed_this_month` | Прогноз плана за месяц | процент | +| `avg_sales_value` | Средний чек | средний чек, план | +| `fot` | ФОТ | ФОТ в %, план | +| `sales_sum_on_admin` | Продажи на администратора | продажи на 1 сотрудника | +| `write_offs` | Списания | сумма, %, накопительный % | +| `user_bonus` | Бонусы пользователей | 4 типа бонусов | +| `matrix_sales_sum` | Продажи матрицы | букеты, растения, готовые товары | +| `count_sales_in_hour` | Продажи по часам | продажи, среднее | +| `write_offs_position` | Списания по позициям | детализация | + +**Возвращает:** +- `string` — JSON с данными графика +- `int` — `-1` (если нет данных для `write_offs_position`) + +**Структура JSON:** +```json +{ + "chart_opts": { + "title": {"text": "Магазин: Название"}, + "xaxis": { + "type": "text", + "categories": ["2025-01-01 Пн", "2025-01-02 Вт"] + } + }, + "data_answer": { + "attribute": { + "sales": { + "data": [10000, 12000, 15000] + }, + "plan": { + "data": [11000, 11500, 14000] + } + } + } +} +``` + +**Пример вызова:** +```javascript +$.ajax({ + url: '/charts-for-management/get-data-ajax', + type: 'POST', + data: { + mode: 3, // Магазин + attribute: 'sales', // Продажи + date_start: '2025-01-01', + date_end: '2025-01-31', + cluster: null, + store: 5, // ID магазина + shift: 3 // День+Ночь + }, + success: function(response) { + const chartData = JSON.parse(response); + renderChart(chartData); + } +}); +``` + +**Особенности:** +- Для `plan_completed_this_day` и `plan_completed_this_month` даты игнорируются, используется текущий месяц +- Для `write_offs` date_start автоматически устанавливается на начало месяца +- Для `count_sales_in_hour` категории оси X зависят от `shift` + +--- + +### 5. actionWriteOffsIndex() + +**Сигнатура:** +```php +public function actionWriteOffsIndex(string $date, ?int $cluster_id = null, ?int $store_id = null): string +``` + +**Назначение:** Отображение списка списаний типа "Брак" за определённую дату + +**Параметры (GET):** + +| Параметр | Тип | Обязательный | Значение по умолчанию | Описание | +|----------|-----|--------------|----------------------|----------| +| `date` | string | Да | - | Дата списаний (Y-m-d) | +| `cluster_id` | int | Нет | `null` | ID куста для фильтрации | +| `store_id` | int | Нет | `null` | ID магазина для фильтрации | + +**Возвращает:** +- `string` — HTML страница с таблицей списаний + +**Пример вызова:** +```php +// GET /charts-for-management/write-offs-index?date=2025-01-15&store_id=5 +Yii::$app->controller->actionWriteOffsIndex('2025-01-15', null, 5); +``` + +**Пример HTTP:** +```bash +curl -X GET "https://erp24.example.com/charts-for-management/write-offs-index?date=2025-01-15&store_id=5" \ + -H "Cookie: PHPSESSID=..." +``` + +**Структура данных (ArrayDataProvider):** + +| Поле | Описание | +|------|----------| +| `store_name` | Название магазина | +| `sum` | Сумма списания | +| `date` | Дата списания | +| `number` | Номер документа | +| `comment` | Комментарий | + +**Особенности:** +- Фильтрует только списания типа "Брак" +- Сортировка: куст → магазин → дата +- Использует JOIN через `export_import_table` и `store_dynamic` + +--- + +## Матрица доступа к графикам + +| Group ID | Роль | mode_level | mode_shift | Доступ к планам | +|----------|------|------------|------------|-----------------| +| 1, 81, 71, 51, 10, 9, 74, 14 | Топ-менеджмент | 1, 2, 3 | 1, 2, 3 | ✅ Да | +| 7 | Менеджер куста | 2, 3 | 1, 2, 3 | ✅ Да | +| 30, 40 | Менеджер магазина (день) | 3 | 1 | ❌ Нет | +| 35, 72 | Менеджер магазина (ночь) | 3 | 2 | ❌ Нет | +| 50 | Администратор | 3 | 1, 2, 3 | ✅ Да | + +**Легенда mode_level:** +- 1 — Розница (вся сеть) +- 2 — Куст +- 3 — Магазин + +**Легенда mode_shift:** +- 1 — День (8:00-19:59) +- 2 — Ночь (20:00-7:59) +- 3 — День+Ночь (все 24 часа) + +--- + +## Используемые модели и сервисы + +| Компонент | Тип | Использование | +|-----------|-----|---------------| +| `Admin` | Модель | Проверка прав доступа, получение магазинов | +| `ChartDataSearch` | Модель | Поиск данных для графиков | +| `WriteOffs` | Модель | Получение списаний | +| `ArrayHelper` | Helper | `map()` для преобразования массивов | +| `Json` | Helper | `encode()` для формирования JSON | +| `ArrayDataProvider` | Провайдер | Предоставление данных для GridView | + +--- + +## Примеры AJAX запросов + +### Получение управляющих данных + +```javascript +// JavaScript +fetch('/charts-for-management/get-control-data-ajax', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + date_start: '2025-01-01', + date_end: '2025-01-31' + }) +}) +.then(response => response.json()) +.then(data => { + console.log('Кусты:', data.clusters); + console.log('Магазины:', data.stores_in_cluster); +}); +``` + +### Получение данных графика продаж + +```javascript +// JavaScript +fetch('/charts-for-management/get-data-ajax', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + mode: '3', + attribute: 'sales', + date_start: '2025-01-01', + date_end: '2025-01-31', + store: '5', + shift: '3' + }) +}) +.then(response => response.json()) +.then(data => { + console.log('Данные графика:', data.data_answer); + renderChart(data); +}); +``` + +### Получение данных графика ФОТ + +```javascript +// JavaScript +$.post('/charts-for-management/get-data-ajax', { + mode: 2, // Куст + attribute: 'fot', // ФОТ + date_start: '2025-01-01', + date_end: '2025-01-31', + cluster: 3, + shift: 3 +}, function(response) { + const data = JSON.parse(response); + console.log('ФОТ:', data.data_answer.attribute.fot.data); + console.log('План:', data.data_answer.attribute.plan.data); +}); +``` + +--- + +## Связанные файлы + +- [Детальный анализ](./ChartsForManagementController_ANALYSIS.md) +- [Краткая справка](./ChartsForManagementController_QUICK_REFERENCE.md) +- [README нестандартных контроллеров](./README.md) + +--- + +**Документация создана:** 2025-11-26 +**Статус:** ✅ Готово diff --git a/erp24/docs/controllers/non-standard/ChartsForManagementController_ANALYSIS.md b/erp24/docs/controllers/non-standard/ChartsForManagementController_ANALYSIS.md new file mode 100644 index 00000000..6f74e74e --- /dev/null +++ b/erp24/docs/controllers/non-standard/ChartsForManagementController_ANALYSIS.md @@ -0,0 +1,1474 @@ +# ChartsForManagementController - Детальный анализ + +## 📋 Общая информация + +**Namespace:** `app\controllers` +**Путь к файлу:** `erp24/controllers/ChartsForManagementController.php` +**Extends:** `\yii\web\Controller` +**Размер:** 609 строк кода +**Количество actions:** 5 +**Категория:** Крупный контроллер +**Приоритет:** HIGH (Фаза 2) + +--- + +## 🎯 Назначение + +Контроллер для управления аналитическими графиками и диаграммами для менеджмента компании. Предоставляет визуализацию ключевых метрик бизнеса: + +- Продажи (sales) +- Выполнение плана +- ФОТ (фонд оплаты труда) +- Списания (write-offs) +- Бонусы сотрудников +- Средний чек +- Продажи матрицы +- Количество продаж по часам + +Контроллер реализует **RBAC-систему доступа** с гранулярным контролем уровней просмотра (розница, куст, магазин) и смен (день, ночь, день+ночь) в зависимости от группы пользователя. + +--- + +## 🏗️ Архитектура + +### Основные компоненты + +```mermaid +graph TB + Controller[ChartsForManagementController] + + subgraph "Actions" + A1[actionIndex
Главная страница графиков] + A2[actionWriteOffPosition
График списаний] + A3[actionGetControlDataAjax
Получение управляющих данных] + A4[actionGetDataAjax
Получение данных графиков] + A5[actionWriteOffsIndex
Список списаний] + end + + subgraph "Models" + M1[Admin
Пользователь системы] + M2[ChartDataSearch
Поиск данных графиков] + M3[WriteOffs
Списания] + end + + subgraph "Helpers" + H1[ArrayHelper
Работа с массивами] + H2[Json
JSON кодирование] + end + + subgraph "Database Tables" + DB1[(admin)] + DB2[(store_dynamic)] + DB3[(city_store)] + DB4[(write_offs)] + end + + Controller --> A1 + Controller --> A2 + Controller --> A3 + Controller --> A4 + Controller --> A5 + + A1 --> M1 + A2 --> M1 + A3 --> M1 + A4 --> M2 + A5 --> M3 + + A3 --> H1 + A4 --> H1 + A4 --> H2 + + M1 --> DB1 + M2 --> DB2 + M2 --> DB3 + M3 --> DB4 +``` + +### Зависимости + +| Компонент | Тип | Назначение | +|-----------|-----|------------| +| `Admin` | Модель | Управление пользователями и их правами доступа | +| `ChartDataSearch` | Модель | Поиск и формирование данных для графиков | +| `WriteOffs` | Модель | Работа со списаниями товаров | +| `ArrayHelper` | Helper | Преобразование массивов данных | +| `Json` | Helper | Кодирование данных в JSON | +| `ArrayDataProvider` | Провайдер | Предоставление данных для виджетов | + +--- + +## 🔐 RBAC система доступа + +### Группы пользователей и уровни доступа + +| Group ID | Роль | Уровни (mode_level) | Смены (mode_shift) | Особенности | +|----------|------|---------------------|-------------------|-------------| +| 1, 81, 71, 51, 10, 9, 74, 14 | Топ-менеджмент | Розница, Куст, Магазин | День+Ночь, День, Ночь | Полный доступ ко всем графикам | +| 7 (CLUSTER_MANAGER) | Менеджер куста | Куст, Магазин | День+Ночь, День, Ночь | Доступ к кусту и магазинам | +| 30, 40 | Менеджер магазина (День) | Магазин | День | Нет доступа к планам | +| 35, 72 | Менеджер магазина (Ночь) | Магазин | Ночь | Нет доступа к планам | +| 50 (ADMINISTRATOR) | Администратор магазина | Магазин | День+Ночь, День, Ночь | Полный доступ на уровне магазина | + +### Доступные графики (charts) + +```php +$access = [ + 'main' => [], // Главный график + 'plan_completed_this_day' => [], // Выполнение плана за день + 'plan_completed_this_month' => [], // Выполнение плана за месяц + 'sales' => [], // Продажи + 'matrix_sales_sum' => [], // Продажи матрицы + 'avg_sales_value' => [], // Средний чек + 'fot' => [], // ФОТ + 'sales_sum_on_admin' => [], // Продажи на администратора + 'user_bonus' => [], // Бонусы пользователей + 'count_sales_in_hour' => [], // Количество продаж по часам + 'write_offs' => [], // Списания +]; +``` + +--- + +## 📋 Actions + +### 1. actionIndex() + +**Назначение:** Отображение главной страницы с графиками для менеджмента + +**HTTP метод:** GET +**Маршрут:** `/charts-for-management/index` + +**Параметры:** Нет + +**Возвращает:** +- Success: `string` (HTML страница с графиками) +- Error: `Exception` "Нет доступа" + +**Бизнес-логика:** + +1. Получить текущего пользователя (`Admin`) +2. Инициализировать массив прав доступа для всех графиков +3. В зависимости от `group_id` пользователя: + - Настроить доступные уровни просмотра (розница/куст/магазин) + - Настроить доступные смены (день/ночь/день+ночь) + - Установить видимость графиков +4. Если группа не распознана → выбросить исключение +5. Отрендерить представление с правами доступа + +**Последовательность выполнения:** + +```mermaid +sequenceDiagram + participant User + participant Controller + participant Admin + participant View + + User->>Controller: GET /charts-for-management/index + Controller->>Admin: findOne(['id' => user_id]) + Admin-->>Controller: Admin model + + alt Топ-менеджмент (1,81,71,51,10,9,74,14) + Controller->>Controller: Установить полный доступ + Controller->>Controller: mode_level: [1,2,3] + Controller->>Controller: mode_shift: [1,2,3] + else Менеджер куста (7) + Controller->>Controller: mode_level: [2,3] + Controller->>Controller: mode_shift: [1,2,3] + else Менеджер магазина день (30,40) + Controller->>Controller: mode_level: [3] + Controller->>Controller: mode_shift: [1] + Controller->>Controller: Скрыть планы + else Менеджер магазина ночь (35,72) + Controller->>Controller: mode_level: [3] + Controller->>Controller: mode_shift: [2] + Controller->>Controller: Скрыть планы + else Администратор (50) + Controller->>Controller: mode_level: [3] + Controller->>Controller: mode_shift: [1,2,3] + else Группа не распознана + Controller-->>User: Exception: Нет доступа + end + + Controller->>View: render('index', ['access' => $access]) + View-->>User: HTML страница с графиками +``` + +**Пример использования:** + +```php +// GET /charts-for-management/index +public function actionIndex() +{ + $admin = Admin::findOne(['id' => Yii::$app->user->id]); + + // Инициализация прав + $access = [ + 'main' => ['mode_level' => [], 'mode_shift' => [], 'visible' => false], + 'sales' => ['mode_level' => [], 'mode_shift' => [], 'visible' => false], + // ... остальные графики + ]; + + // Проверка группы и настройка доступа + if (in_array($admin->group_id, [1, 81, 71, 51, 10, 9, 74, 14])) { + // Полный доступ + } + + return $this->render('index', ['access' => $access]); +} +``` + +**Пример HTTP запроса:** + +```bash +curl -X GET "https://erp24.example.com/charts-for-management/index" \ + -H "Cookie: session_id=..." +``` + +--- + +### 2. actionWriteOffPosition() + +**Назначение:** Отображение графика списаний по позициям + +**HTTP метод:** GET +**Маршрут:** `/charts-for-management/write-off-position` + +**Параметры:** Нет + +**Возвращает:** +- Success: `string` (HTML страница графика списаний) +- Error: `Exception` "Нет доступа" + +**Бизнес-логика:** + +1. Получить текущего пользователя +2. Настроить права доступа к графику списаний: + - `mode_level`: уровни просмотра + - `mode_shift`: доступные смены + - `access_plan`: доступ к плановым показателям +3. Группы с доступом к плану: 1, 81, 71, 51, 10, 9, 74, 14, 7, 50 +4. Отрендерить представление + +**Последовательность выполнения:** + +```mermaid +sequenceDiagram + participant User + participant Controller + participant Admin + participant View + + User->>Controller: GET /charts-for-management/write-off-position + Controller->>Admin: findOne(['id' => user_id]) + Admin-->>Controller: Admin model + + Controller->>Controller: Инициализация $access + + alt Топ-менеджмент/Менеджер куста/Администратор + Controller->>Controller: access_plan = true + else Менеджер магазина + Controller->>Controller: access_plan = false + end + + Controller->>View: render('write-offs-position-chart') + View-->>User: HTML страница графика +``` + +**Пример использования:** + +```php +// GET /charts-for-management/write-off-position +public function actionWriteOffPosition() +{ + $admin = Admin::findOne(['id' => Yii::$app->user->id]); + + $access = [ + 'mode_level' => [], + 'mode_shift' => [], + 'access_plan' => false, + ]; + + if (in_array($admin->group_id, [1, 81, 71, 51, 10, 9, 74, 14])) { + $access['access_plan'] = true; + } + + return $this->render('write-offs-position-chart', ['access' => $access]); +} +``` + +--- + +### 3. actionGetControlDataAjax() + +**Назначение:** AJAX-получение управляющих данных: списка магазинов, кустов и их динамики по датам + +**HTTP метод:** POST +**Маршрут:** `/charts-for-management/get-control-data-ajax` + +**Параметры:** + +| Параметр | Тип | Обязательный | Описание | +|----------|-----|--------------|----------| +| date_start | string | Да | Дата начала периода (Y-m-d) | +| date_end | string | Да | Дата окончания периода (Y-m-d) | + +**Возвращает:** +- Success: `JSON` + ```json + { + "stores_step": { + "Магазин 1": { + "2025-01-01": "Куст 1", + "2025-02-01": "Куст 2" + } + }, + "clusters": [ + {"id": 1, "text": "Куст 1"}, + {"id": 2, "text": "Куст 2"} + ], + "stores_in_cluster": { + "1": { + "text": "Куст 1", + "children": [ + {"id": 1, "text": "Магазин 1"}, + {"id": 2, "text": "Магазин 2"} + ] + } + } + } + ``` + +**Бизнес-логика:** + +1. Получить даты из POST запроса +2. Сформировать SQL запрос для получения магазинов пользователя с учётом: + - `store_arr` - список доступных магазинов + - `store_dynamic` - история изменений кустов + - Период дат (date_from, date_to) +3. Отфильтровать магазины, которые меняли куст в периоде +4. Собрать уникальные кусты +5. Сгруппировать магазины по кустам +6. Вернуть JSON с тремя массивами + +**Последовательность выполнения:** + +```mermaid +sequenceDiagram + participant Client + participant Controller + participant Admin + participant StoreDynamic + participant CityStore + + Client->>Controller: POST {date_start, date_end} + Controller->>Admin: find() с join store_dynamic + Admin->>StoreDynamic: Получить историю кустов + StoreDynamic->>CityStore: Получить названия магазинов + CityStore-->>Controller: Массив магазинов с кластерами + + Controller->>Controller: Фильтр магазинов с изменениями + Controller->>Controller: Собрать уникальные кластеры + Controller->>Controller: Сгруппировать по кластерам + + Controller-->>Client: JSON {stores_step, clusters, stores_in_cluster} +``` + +**Пример использования:** + +```javascript +// AJAX запрос +$.ajax({ + url: '/charts-for-management/get-control-data-ajax', + type: 'POST', + data: { + date_start: '2025-01-01', + date_end: '2025-01-31' + }, + success: function(response) { + const data = JSON.parse(response); + console.log(data.clusters); // Список кустов + console.log(data.stores_in_cluster); // Магазины в кустах + } +}); +``` + +--- + +### 4. actionGetDataAjax() + +**Назначение:** AJAX-получение данных для построения графиков + +**HTTP метод:** POST +**Маршрут:** `/charts-for-management/get-data-ajax` + +**Параметры:** + +| Параметр | Тип | Обязательный | Описание | +|----------|-----|--------------|----------| +| mode | int | Да | Уровень: 1-Розница, 2-Куст, 3-Магазин | +| attribute | string | Да | Тип графика (sales, fot, write_offs и т.д.) | +| date_start | string | Нет | Дата начала (по умолчанию -13 дней) | +| date_end | string | Нет | Дата окончания (по умолчанию сегодня) | +| cluster | int | Нет | ID куста | +| store | int | Нет | ID магазина | +| shift | int | Нет | Смена: 1-День, 2-Ночь, 3-День+Ночь (по умолчанию 3) | + +**Возвращает:** +- Success: `JSON` + ```json + { + "chart_opts": { + "title": {"text": "Магазин: Название"}, + "xaxis": { + "type": "text", + "categories": ["2025-01-01 Пн", "2025-01-02 Вт"] + } + }, + "data_answer": { + "attribute": { + "sales": { + "data": [10000, 12000, 15000] + }, + "plan": { + "data": [11000, 11500, 14000] + } + } + } + } + ``` +- Error: `-1` (если нет данных) + +**Поддерживаемые типы графиков (attribute):** + +1. **sales** - Продажи + - Показатели: продажи (value), план (plan) + - Две линии на графике + +2. **plan_completed_this_day** - Выполнение плана за день + - Показатели: процент выполнения + - Период: с начала месяца до сегодня + +3. **plan_completed_this_month** - Прогноз выполнения плана за месяц + - Показатели: гипотетический процент + - Период: весь месяц + +4. **avg_sales_value** - Средний чек + - Показатели: средний чек, план + - Использует пары значений (value, count) + +5. **fot** - ФОТ (фонд оплаты труда) + - Показатели: ФОТ в процентах, план + - Использует пары значений (sales_sum, fot_sum) + +6. **sales_sum_on_admin** - Продажи на администратора + - Показатели: продажи на одного сотрудника + - Использует пары значений (sales, admin_count) + +7. **write_offs** - Списания + - Показатели: сумма списаний, процент от продаж, накопительный процент + - Период: с начала месяца + +8. **user_bonus** - Бонусы пользователей + - Показатели: 4 типа бонусов + - Группировка по 4 значения + +9. **matrix_sales_sum** - Продажи матрицы + - Показатели: букет, растение в горшке, готовые товары + - Группировка по 3 значения + +10. **count_sales_in_hour** - Количество продаж по часам + - Показатели: продажи по часам, средние за период + - Оси X: часы (8-23, 0-7 или подмножества) + +**Бизнес-логика:** + +1. Получить параметры из POST +2. Создать объект `ChartDataSearch` +3. Установить параметры поиска +4. Для специальных графиков настроить даты: + - `plan_completed_this_day`: с начала месяца + - `plan_completed_this_month`: весь месяц + - `write_offs`: с начала месяца +5. Получить данные через `ChartDataSearch->search()` +6. Обработать данные в зависимости от типа графика: + - Для большинства: собрать массивы dates и data + - Для составных (fot, avg_sales_value): использовать пары значений + - Для user_bonus: группировать по 4 + - Для matrix_sales_sum: группировать по 3 +7. Настроить опции графика (заголовок, оси) +8. Вернуть JSON + +**Последовательность выполнения:** + +```mermaid +sequenceDiagram + participant Client + participant Controller + participant ChartDataSearch + participant Database + + Client->>Controller: POST {mode, attribute, dates, etc} + Controller->>ChartDataSearch: new ChartDataSearch() + Controller->>ChartDataSearch: Установить параметры + + alt attribute == 'plan_completed_this_day' + Controller->>Controller: date_start = первый день месяца + Controller->>Controller: date_end = сегодня + else attribute == 'plan_completed_this_month' + Controller->>Controller: date_start = первый день месяца + Controller->>Controller: date_end = последний день месяца + else attribute == 'write_offs' + Controller->>Controller: date_start = первый день месяца + end + + ChartDataSearch->>Database: Запрос данных + Database-->>ChartDataSearch: Результаты + ChartDataSearch-->>Controller: Массив данных + + alt attribute == 'sales' + Controller->>Controller: data[value], data[plan] + else attribute == 'avg_sales_value' + Controller->>Controller: data[value] / data[count] + else attribute == 'fot' + Controller->>Controller: data[sales] / data[fot] * 100 + else attribute == 'write_offs' + Controller->>Controller: Накопительный расчёт + else attribute == 'user_bonus' + Controller->>Controller: Группировка по 4 + else attribute == 'matrix_sales_sum' + Controller->>Controller: Группировка по 3 + end + + Controller->>Controller: Настройка chart_opts + Controller-->>Client: JSON {chart_opts, data_answer} +``` + +**Пример использования:** + +```javascript +// AJAX запрос для графика продаж +$.ajax({ + url: '/charts-for-management/get-data-ajax', + type: 'POST', + data: { + mode: 3, // Магазин + attribute: 'sales', // Продажи + date_start: '2025-01-01', + date_end: '2025-01-31', + cluster: null, + store: 5, // ID магазина + shift: 3 // День+Ночь + }, + success: function(response) { + const chartData = JSON.parse(response); + renderChart(chartData.chart_opts, chartData.data_answer); + } +}); +``` + +**Особенности обработки данных:** + +**Для sales:** +```php +$data_answer['attribute']['sales']['data'][] = $datum['value']; +$data_answer['attribute']['plan']['data'][] = $datum['plan']; +``` + +**Для avg_sales_value (средний чек):** +```php +// Использует пары: продажи / количество продаж +$avg_check = $data[$index]['value'] / ($data[$index + 1]['value'] ?: 1); +``` + +**Для write_offs (списания):** +```php +// Накопительный расчёт с начала месяца +if (date('d', strtotime($datum['date'])) == 1) { + $temp_row['sales_sum'] = 0; // Сброс на первое число + $temp_row['sum'] = 0; +} +$temp_row['sales_sum'] += $datum['value']; +$temp_row['sum'] += $data[$index + 1]['value']; +``` + +--- + +### 5. actionWriteOffsIndex() + +**Назначение:** Отображение списка списаний за определённую дату + +**HTTP метод:** GET +**Маршрут:** `/charts-for-management/write-offs-index` + +**Параметры:** + +| Параметр | Тип | Обязательный | Описание | +|----------|-----|--------------|----------| +| date | string | Да | Дата списаний (Y-m-d) | +| cluster_id | int | Нет | ID куста для фильтрации | +| store_id | int | Нет | ID магазина для фильтрации | + +**Возвращает:** +- Success: `string` (HTML страница со списком списаний) + +**Бизнес-логика:** + +1. Создать запрос `WriteOffs` с JOIN: + - `export_import_table` - для связи с магазинами + - `city_store` - для получения названий + - `store_dynamic` - для связи с кустами +2. Фильтры: + - Дата списания = $date + - Тип списания = 'Брак' + - Опционально: cluster_id, store_id +3. Сортировка: куст → магазин → дата +4. Создать ArrayDataProvider +5. Отрендерить представление + +**Последовательность выполнения:** + +```mermaid +sequenceDiagram + participant User + participant Controller + participant WriteOffs + participant ExportImportTable + participant CityStore + participant StoreDynamic + participant View + + User->>Controller: GET /write-offs-index?date=2025-01-15&store_id=5 + + Controller->>WriteOffs: find() + WriteOffs->>ExportImportTable: innerJoin + ExportImportTable->>CityStore: innerJoin + CityStore->>StoreDynamic: innerJoin + + Controller->>WriteOffs: andWhere(['date' => date]) + Controller->>WriteOffs: andWhere(['type' => 'Брак']) + Controller->>WriteOffs: andFilterWhere(['store_id' => store_id]) + + WriteOffs-->>Controller: Массив списаний + + Controller->>Controller: ArrayDataProvider + Controller->>View: render('write-offs') + View-->>User: HTML таблица списаний +``` + +**Пример использования:** + +```php +// GET /charts-for-management/write-offs-index?date=2025-01-15&store_id=5 +public function actionWriteOffsIndex($date, $cluster_id = null, $store_id = null) +{ + $query_write_offs = WriteOffs::find() + ->select([ + 'store_name' => 'city_store.name', + 'sum' => 'write_offs.summ', + 'date' => 'write_offs.date', + 'number' => 'write_offs.number', + 'comment' => 'write_offs.comment' + ]) + ->innerJoin('export_import_table', ...) + ->innerJoin('city_store', ...) + ->innerJoin('store_dynamic', ...) + ->andWhere(['date' => $date]) + ->andWhere(['type' => 'Брак']) + ->andFilterWhere([ + 'store_dynamic.value_int' => $cluster_id, + 'city_store.id' => $store_id + ]); + + $dataProvider = new ArrayDataProvider([ + 'allModels' => $query_write_offs->asArray()->all() + ]); + + return $this->render('write-offs', [ + 'dataProvider' => $dataProvider, + 'date' => $date + ]); +} +``` + +**Пример HTTP запроса:** + +```bash +curl -X GET "https://erp24.example.com/charts-for-management/write-offs-index?date=2025-01-15&store_id=5" \ + -H "Cookie: session_id=..." +``` + +**Пример ответа (HTML таблица):** + +| Магазин | Сумма | Дата | Номер | Комментарий | +|---------|-------|------|-------|-------------| +| Магазин 5 | 1500.00 | 2025-01-15 | WO-123 | Истёк срок годности | +| Магазин 5 | 800.00 | 2025-01-15 | WO-124 | Повреждена упаковка | + +--- + +## 💾 Работа с данными + +### Используемые модели + +#### Admin +**Путь:** `yii_app\records\Admin` +**Таблица БД:** `admin` + +**Основные поля:** +- `id` — ID пользователя +- `group_id` — ID группы (роль) +- `store_arr` — Список доступных магазинов (строка с разделителями) + +**Связи:** +- `hasMany(StoreDynamic)` через `store_arr` + +**Использование:** +- Проверка прав доступа +- Получение доступных магазинов пользователя + +--- + +#### ChartDataSearch +**Путь:** `yii_app\records\ChartDataSearch` +**Таблица БД:** Не привязана к конкретной таблице (агрегатор) + +**Основные свойства:** +- `mode_level` — Уровень просмотра (1-Розница, 2-Куст, 3-Магазин) +- `mode_shift` — Смена (1-День, 2-Ночь, 3-День+Ночь) +- `attribute_name` — Тип графика +- `date_start` — Дата начала +- `date_end` — Дата окончания +- `cluster_id` — ID куста +- `store_id` — ID магазина +- `select_cluster` — Флаг выбора куста + +**Методы:** +- `search()` — Основной метод поиска данных для графиков +- `searchWriteOffsItems()` — Поиск данных списаний по позициям +- `sortCountSalesInHour()` — Сортировка продаж по часам + +**Конфигурация attributes_config:** +```php +'attributes_config' => [ + 'sales' => [ + 'attribute' => [ + 'sales' => ['data' => []], + 'plan' => ['data' => []] + ] + ], + 'fot' => [ + 'attribute' => [ + 'fot' => ['data' => []], + 'plan' => ['data' => []] + ] + ], + // ... и т.д. +] +``` + +--- + +#### WriteOffs +**Путь:** `yii_app\records\WriteOffs` +**Таблица БД:** `write_offs` + +**Основные поля:** +- `id` — ID списания +- `store_id` — ID магазина (внешний ключ через export_import_table) +- `date` — Дата списания +- `summ` — Сумма списания +- `type` — Тип списания ('Брак', 'Уценка' и т.д.) +- `number` — Номер документа списания +- `comment` — Комментарий + +**Связи:** +- `hasOne(CityStore)` через `export_import_table` +- `hasOne(StoreDynamic)` для получения куста + +--- + +### SQL запросы + +#### Запрос магазинов пользователя с историей кустов + +```sql +SELECT + CONCAT_WS(' ', 'Куст', store_dynamic.value_int) AS cluster_name, + store_dynamic.value_int AS cluster_id, + store_dynamic.store_id, + city_store.name AS store_name, + TO_CHAR(store_dynamic.date_from::timestamp, '%Y-%m-%d') AS date_from +FROM admin +LEFT JOIN store_dynamic ON ( + admin.store_arr LIKE CONCAT('%,', store_dynamic.store_id, ',%') + OR admin.store_arr LIKE CONCAT('', store_dynamic.store_id, ',%') + OR admin.store_arr LIKE CONCAT('%,', store_dynamic.store_id, '') + OR admin.store_arr LIKE CONCAT('', store_dynamic.store_id, '') +) +INNER JOIN city_store ON store_dynamic.store_id = city_store.id + AND store_dynamic.category = 1 +WHERE admin.id = :user_id +AND ( + (store_dynamic.date_from BETWEEN :date_start AND :date_end + AND store_dynamic.date_to BETWEEN :date_start AND :date_end) + OR (store_dynamic.date_from BETWEEN :date_start AND :date_end + AND store_dynamic.date_to >= :date_end) + OR (store_dynamic.date_from <= :date_start + AND store_dynamic.date_to BETWEEN :date_start AND :date_end) + OR (store_dynamic.date_from <= :date_start + AND store_dynamic.date_to >= :date_start) +) +ORDER BY store_dynamic.value_int ASC, city_store.id ASC +``` + +#### Запрос списаний + +```sql +SELECT + city_store.name AS store_name, + write_offs.summ AS sum, + write_offs.date, + write_offs.number, + write_offs.comment +FROM write_offs +INNER JOIN export_import_table ON export_import_table.export_val = write_offs.store_id +INNER JOIN city_store ON city_store.id = export_import_table.entity_id +INNER JOIN store_dynamic ON ( + store_dynamic.store_id = city_store.id + AND TO_CHAR(write_offs.date::timestamp, '%Y-%m-%d') >= TO_CHAR(store_dynamic.date_from::timestamp, '%Y-%m-%d') + AND TO_CHAR(write_offs.date::timestamp, '%Y-%m-%d') < TO_CHAR(store_dynamic.date_to::timestamp, '%Y-%m-%d') +) +WHERE export_import_table.entity = 'city_store' +AND store_dynamic.value_int = :cluster_id -- опционально +AND city_store.id = :store_id -- опционально +AND TO_CHAR(write_offs.date::timestamp, '%Y-%m-%d') = :date +AND write_offs.type = 'Брак' +ORDER BY store_dynamic.value_int ASC, city_store.id ASC, write_offs.date ASC +``` + +--- + +## 🔐 Права доступа (RBAC) + +### Константы группы Admin + +```php +const ADMINISTRATOR_GROUP_ID = 50; // Администратор магазина +const CLUSTER_MANAGER_GROUP_ID = 7; // Менеджер куста +``` + +### Проверки доступа + +```php +// Топ-менеджмент: полный доступ +if (in_array($admin->group_id, [1, 81, 71, 51, 10, 9, 74, 14])) { + // Доступ: розница, куст, магазин + // Смены: все +} + +// Менеджер куста: куст и магазины +else if (in_array($admin->group_id, [Admin::CLUSTER_MANAGER_GROUP_ID])) { + // Доступ: куст, магазин + // Смены: все +} + +// Менеджер магазина (день): только свой магазин +else if (in_array($admin->group_id, [30, 40])) { + // Доступ: магазин + // Смены: день + // Нет доступа к планам +} + +// Менеджер магазина (ночь): только свой магазин +else if (in_array($admin->group_id, [35, 72])) { + // Доступ: магазин + // Смены: ночь + // Нет доступа к планам +} + +// Администратор: свой магазин, все смены +else if (in_array($admin->group_id, [Admin::ADMINISTRATOR_GROUP_ID])) { + // Доступ: магазин + // Смены: все +} + +// Группа не распознана +else { + throw new Exception('Нет доступа'); +} +``` + +--- + +## ⚠️ Обработка ошибок + +### Типичные ошибки + +| Ошибка | Код | Причина | Решение | +|--------|-----|---------|---------| +| "Нет доступа" | 500 | Группа пользователя не распознана | Проверить group_id, добавить группу в условия | +| Пустой ответ (-1) | 200 | Нет данных для графика write_offs_position | Проверить наличие данных в ChartDataSearch | +| Division by zero | 500 | Деление на 0 в расчётах (plan, count) | Использовать `?: 1` для защиты | + +### Exception handling + +```php +try { + // Проверка группы пользователя + if (!in_array($admin->group_id, [/* allowed groups */])) { + throw new Exception('Нет доступа'); + } + + // Расчёты с защитой от деления на 0 + $avg_check = $sales / ($count ?: 1); + +} catch (Exception $e) { + Yii::error($e->getMessage()); + return $this->render('error', ['message' => $e->getMessage()]); +} +``` + +--- + +## 📊 Типы графиков и их алгоритмы + +### 1. Продажи (sales) + +**Показатели:** продажи, план +**Формула:** прямые значения из БД + +```php +foreach ($data as $datum) { + $dates[] = $datum['date']; + $data_answer['sales']['data'][] = $datum['value']; + $data_answer['plan']['data'][] = $datum['plan']; +} +``` + +--- + +### 2. Средний чек (avg_sales_value) + +**Показатели:** средний чек, план +**Формула:** `avg_check = sales_sum / sales_count` + +```php +// Использует пары значений: чётные - продажи, нечётные - количество +if ($step % 2 != 0) { + $step++; + continue; +} + +$dates[] = $datum['date']; +$avg_check = $datum['value'] / ($data[$index + 1]['value'] ?: 1); +$data_answer['avg_check']['data'][] = $avg_check; +$data_answer['plan']['data'][] = $datum['plan']; +$step++; +``` + +--- + +### 3. ФОТ (fot) + +**Показатели:** ФОТ в процентах, план +**Формула:** `fot_percent = fot_sum / (sales_sum / 100)` + +```php +// Использует пары: чётные - продажи, нечётные - ФОТ +if ($step % 2 != 0) { + $step++; + continue; +} + +$dates[] = $datum['date']; +$fot_percent = $datum['value'] / (($data[$index + 1]['value'] ?: 1) / 100); +$data_answer['fot']['data'][] = $fot_percent; +$data_answer['plan']['data'][] = $datum['plan']; +$step++; +``` + +--- + +### 4. Списания (write_offs) + +**Показатели:** сумма списаний, процент за день, процент накопительный +**Формула:** +- `percent_day = write_offs / (sales / 100)` +- `percent_cumulative = cumulative_write_offs / (cumulative_sales / 100)` + +```php +// Накопительный расчёт с начала месяца +if ($step % 2 != 0) { + $step++; + continue; +} + +$step++; + +// Сброс накопления на первое число месяца +if (date('d', strtotime($datum['date'])) == 1) { + $temp_row['sales_sum'] = 0; + $temp_row['sum'] = 0; +} + +$temp_row['sales_sum'] += $datum['value']; // Продажи +$temp_row['sum'] += $data[$index + 1]['value']; // Списания + +$dates[] = $datum['date']; +$data_answer['write_offs_sum']['data'][] = $data[$index + 1]['value']; +$data_answer['write_offs_percent_day']['data'][] = + ($data[$index + 1]['value'] ?? 0) / (($datum['value'] ?: 1) / 100); +$data_answer['write_offs_percent_cumulative']['data'][] = + $temp_row['sum'] / ((($temp_row['sales_sum'] ?: 1) / 100)); +``` + +--- + +### 5. Продажи на администратора (sales_sum_on_admin) + +**Показатели:** продажи на одного сотрудника +**Формула:** `sales_per_admin = sales_sum / admin_count` + +```php +// Использует пары: чётные - продажи, нечётные - количество администраторов +if ($step % 2 != 0) { + $step++; + continue; +} + +$step++; +$dates[] = $datum['date']; +$sales_per_admin = $datum['value'] / ($data[$index + 1]['value'] ?: 1); +$data_answer['sales_per_admin']['data'][] = $sales_per_admin; +``` + +--- + +### 6. Бонусы пользователей (user_bonus) + +**Показатели:** 4 типа бонусов +**Группировка:** каждые 4 значения на одну дату + +```php +// Каждые 4 значения - один набор бонусов +if ($step % 4 == 0) { + // Добавляем 3 копии даты с разными бонусами + for ($i = 0; $i < 3; $i++) { + $dates[] = $datum['date']; + // Заполняем 4 типа бонусов + } + + $data_answer['bonus_type_1']['data'][] = $data[$index]['value']; + $data_answer['bonus_type_2']['data'][] = $data[$index + 1]['value']; + $data_answer['bonus_type_3']['data'][] = $data[$index + 2]['value']; + $data_answer['bonus_type_4']['data'][] = $data[$index + 3]['value']; +} + +$step++; +``` + +--- + +### 7. Продажи матрицы (matrix_sales_sum) + +**Показатели:** букеты, растения, готовые товары +**Группировка:** каждые 3 значения на одну дату + +```php +// Каждые 3 значения - продажи по категориям матрицы +if ($step % 3 == 0) { + $dates[] = $datum['date']; + $data_answer['bouquet']['data'][] = $data[$index]['value']; + $data_answer['plant']['data'][] = $data[$index + 1]['value']; + $data_answer['ready_goods']['data'][] = $data[$index + 2]['value']; +} + +$step++; +``` + +--- + +### 8. Количество продаж по часам (count_sales_in_hour) + +**Показатели:** продажи по часам, среднее за период +**Оси X:** часы в зависимости от смены + +```php +foreach ($data as $datum) { + $data_answer['sales_count']['data'][] = $datum['value']; + $data_answer['sales_avg']['data'][] = $datum['value'] / $interval_days; +} + +// Настройка оси X в зависимости от смены +if ($mode_shift === 3) { // День+Ночь + $categories = array_merge(range(8, 23), range(0, 7)); +} else if ($mode_shift === 1) { // День + $categories = range(8, 19); +} else if ($mode_shift === 2) { // Ночь + $categories = array_merge(range(20, 23), range(0, 7)); +} + +// Сортировка данных по часам +ChartDataSearch::sortCountSalesInHour($data_answer, $mode_shift); +``` + +--- + +### 9. Выполнение плана за день (plan_completed_this_day) + +**Показатели:** процент выполнения плана +**Формула:** `percent = sales_sum / plan * 100` +**Период:** с начала месяца до сегодня + +```php +$temp_row = ['sales_sum' => 0, 'plan' => 0]; + +foreach ($data as $datum) { + $temp_row['sales_sum'] += $datum['value']; + $temp_row['plan'] += $datum['plan']; +} + +$percent = round( + $temp_row['sales_sum'] / ($temp_row['plan'] ?: 1) * 100, + 2 +); + +$data_answer['plan_complete_on_this_day'] = $percent; +``` + +--- + +### 10. Прогноз выполнения плана за месяц (plan_completed_this_month) + +**Показатели:** гипотетический процент выполнения +**Формула:** `percent = (sales_sum / current_day * total_days) / plan * 100` +**Период:** весь месяц + +```php +$temp_row = ['sales_sum' => 0, 'plan' => 0]; + +foreach ($data as $datum) { + $temp_row['sales_sum'] += $datum['value']; + $temp_row['plan'] += $datum['plan']; +} + +$current_day = date('d', time()); +$total_days = date('d', strtotime('last day of this month')); + +$hypothesis_percent = round( + $temp_row['sales_sum'] / $current_day * $total_days / ($temp_row['plan'] ?: 1) * 100, + 2 +); + +$data_answer['plan_hypothesis_complete_on_this_month'] = $hypothesis_percent; +``` + +--- + +## 🧪 Примеры использования + +### Сценарий 1: Получение графика продаж для магазина + +**Описание:** Менеджер магазина хочет посмотреть график продаж за последние 2 недели + +**Шаги:** +1. Открыть страницу `/charts-for-management/index` +2. Выбрать график "Продажи" +3. Выбрать уровень "Магазин" +4. Выбрать свой магазин +5. Выбрать период: последние 14 дней +6. AJAX запрос на получение данных +7. Отображение графика + +**Код (JavaScript):** + +```javascript +// 1. Открыть модальное окно графика продаж +$('#sales-chart-modal').modal('show'); + +// 2. Настройка параметров +const params = { + mode: 3, // Магазин + attribute: 'sales', // Продажи + date_start: '2025-01-01', + date_end: '2025-01-14', + cluster: null, + store: 5, // ID магазина + shift: 3 // День+Ночь +}; + +// 3. AJAX запрос +$.ajax({ + url: '/charts-for-management/get-data-ajax', + type: 'POST', + data: params, + success: function(response) { + const chartData = JSON.parse(response); + + // 4. Отображение графика (ApexCharts) + const options = { + series: Object.values(chartData.data_answer.attribute), + chart: { + type: 'line', + height: 350 + }, + xaxis: chartData.chart_opts.xaxis, + title: chartData.chart_opts.title + }; + + const chart = new ApexCharts(document.querySelector("#chart"), options); + chart.render(); + } +}); +``` + +--- + +### Сценарий 2: Получение списка списаний за день + +**Описание:** Топ-менеджер хочет посмотреть все списания по типу "Брак" за определённую дату + +**Шаги:** +1. Кликнуть на точку графика списаний +2. Передать дату в параметре +3. Открыть страницу со списком списаний + +**Код (JavaScript):** + +```javascript +// Обработчик клика на график списаний +chart.on('dataPointSelection', function(event, chartContext, config) { + const date = config.w.config.xaxis.categories[config.dataPointIndex]; + const store_id = $('#store-select').val(); + + // Переход на страницу списаний + window.location.href = `/charts-for-management/write-offs-index?date=${date}&store_id=${store_id}`; +}); +``` + +--- + +### Сценарий 3: Получение данных для графика ФОТ по кусту + +**Описание:** Менеджер куста хочет проанализировать ФОТ по своему кусту за месяц + +**Шаги:** +1. Выбрать график "ФОТ" +2. Выбрать уровень "Куст" +3. Выбрать свой куст +4. Выбрать период: текущий месяц +5. Получить данные + +**Код (JavaScript):** + +```javascript +const params = { + mode: 2, // Куст + attribute: 'fot', // ФОТ + date_start: '2025-01-01', // Начало месяца + date_end: '2025-01-31', // Конец месяца + cluster: 3, // ID куста + store: null, + shift: 3 // День+Ночь +}; + +$.ajax({ + url: '/charts-for-management/get-data-ajax', + type: 'POST', + data: params, + success: function(response) { + const chartData = JSON.parse(response); + + console.log('График ФОТ для куста:', chartData.chart_opts.title.text); + console.log('ФОТ:', chartData.data_answer.attribute.fot.data); + console.log('План:', chartData.data_answer.attribute.plan.data); + + renderChart(chartData); + } +}); +``` + +--- + +### Сценарий 4: Получение управляющих данных (кусты и магазины) + +**Описание:** При загрузке страницы нужно получить список доступных кустов и магазинов пользователя + +**Код (JavaScript):** + +```javascript +// При загрузке страницы +$(document).ready(function() { + $.ajax({ + url: '/charts-for-management/get-control-data-ajax', + type: 'POST', + data: { + date_start: '2025-01-01', + date_end: '2025-01-31' + }, + success: function(response) { + const data = JSON.parse(response); + + // Заполнить select кустов + const clusterSelect = $('#cluster-select'); + clusterSelect.empty(); + clusterSelect.append(''); + data.clusters.forEach(function(cluster) { + clusterSelect.append(``); + }); + + // Заполнить select магазинов (с группировкой по кустам) + const storeSelect = $('#store-select'); + storeSelect.empty(); + storeSelect.append(''); + + for (const clusterId in data.stores_in_cluster) { + const cluster = data.stores_in_cluster[clusterId]; + const optgroup = $('').attr('label', cluster.text); + + cluster.children.forEach(function(store) { + optgroup.append(``); + }); + + storeSelect.append(optgroup); + } + + // Показать предупреждение о магазинах, меняющих куст + if (Object.keys(data.stores_step).length > 0) { + console.warn('Магазины, меняющие куст в периоде:', data.stores_step); + } + } + }); +}); +``` + +--- + +## ❓ FAQ + +### Вопрос 1: Почему некоторые группы пользователей не видят графики планов? + +**Ответ:** Группы 30, 40, 35, 72 (менеджеры магазинов) не имеют доступа к графикам `plan_completed_this_day` и `plan_completed_this_month`. Это сделано намеренно, чтобы ограничить доступ к плановым показателям на уровне магазинов. + +```php +if (in_array($admin->group_id, [30, 40])) { + // ... + if ($key != 'plan_completed_this_day' && $key != 'plan_completed_this_month') { + $item['visible'] = true; + } +} +``` + +--- + +### Вопрос 2: Как работает защита от деления на ноль в расчётах? + +**Ответ:** Во всех формулах используется тернарный оператор `?: 1`, который заменяет 0 на 1: + +```php +$avg_check = $sales / ($count ?: 1); // Если $count = 0, то используется 1 +$fot_percent = $fot / (($sales ?: 1) / 100); +``` + +--- + +### Вопрос 3: Почему для некоторых графиков используется `$step % 2`, `$step % 3`, `$step % 4`? + +**Ответ:** Некоторые графики используют составные данные: + +- **% 2** (avg_sales_value, fot, sales_sum_on_admin, write_offs): пары значений (продажи + количество) +- **% 3** (matrix_sales_sum): тройки значений (букеты + растения + готовые товары) +- **% 4** (user_bonus): четвёрки значений (4 типа бонусов) + +Это позволяет обрабатывать несколько связанных значений как одну точку на графике. + +--- + +### Вопрос 4: Как работает накопительный расчёт в графике списаний? + +**Ответ:** График списаний показывает накопительный процент с начала месяца: + +1. На первое число месяца накопители сбрасываются +2. Каждый день добавляются продажи и списания +3. Рассчитывается накопительный процент: `cumulative_write_offs / cumulative_sales * 100` + +```php +if (date('d', strtotime($datum['date'])) == 1) { + $temp_row['sales_sum'] = 0; // Сброс + $temp_row['sum'] = 0; +} +$temp_row['sales_sum'] += $datum['value']; +$temp_row['sum'] += $data[$index + 1]['value']; +``` + +--- + +### Вопрос 5: Почему для графика `count_sales_in_hour` разные категории оси X? + +**Ответ:** Категории оси X зависят от выбранной смены: + +- **День+Ночь (3):** 8-23, 0-7 (все 24 часа) +- **День (1):** 8-19 (12 часов) +- **Ночь (2):** 20-23, 0-7 (12 часов) + +Это позволяет отображать только релевантные часы для каждой смены. + +--- + +### Вопрос 6: Что такое `store_arr` в модели Admin и как он работает? + +**Ответ:** `store_arr` — это строка с ID магазинов, разделённых запятыми: + +``` +,1,5,8,12, +``` + +В SQL используется LIKE для поиска: + +```sql +admin.store_arr LIKE '%,5,%' -- Магазин 5 в середине +admin.store_arr LIKE '5,%' -- Магазин 5 в начале +admin.store_arr LIKE '%,5' -- Магазин 5 в конце +admin.store_arr LIKE '5' -- Только магазин 5 +``` + +--- + +### Вопрос 7: Почему `actionGetDataAjax()` возвращает `-1`? + +**Ответ:** Возвращается `-1`, если для графика `write_offs_position` нет данных: + +```php +if ($post_data['attribute'] === 'write_offs_position') { + if (!isset($answer_query[1])) { + return -1; // Нет данных + } +} +``` + +--- + +## 🔗 Связанные компоненты + +- [Admin](../../models/Admin.md) — Модель пользователей +- [ChartDataSearch](../../models/ChartDataSearch.md) — Поиск данных графиков +- [WriteOffs](../../models/WriteOffs.md) — Модель списаний +- [StoreDynamic](../../models/StoreDynamic.md) — История изменений кустов +- [CityStore](../../models/CityStore.md) — Модель магазинов + +--- + +## 📝 История изменений + +| Дата | Версия | Изменения | +|------|--------|-----------| +| 2025-11-26 | 1.0 | Первая версия документации | + +--- + +**Документация создана:** Claude Code + Hive Mind Controllers Swarm +**Дата:** 2025-11-26 +**Фаза:** 2 (Крупные контроллеры) +**Статус:** ✅ Готово diff --git a/erp24/docs/controllers/non-standard/ChartsForManagementController_QUICK_REFERENCE.md b/erp24/docs/controllers/non-standard/ChartsForManagementController_QUICK_REFERENCE.md new file mode 100644 index 00000000..e9ecc5b8 --- /dev/null +++ b/erp24/docs/controllers/non-standard/ChartsForManagementController_QUICK_REFERENCE.md @@ -0,0 +1,300 @@ +# ChartsForManagementController - Краткая справка + +## 📌 Основная информация + +**Контроллер:** `ChartsForManagementController` +**Путь:** `erp24/controllers/ChartsForManagementController.php` +**Размер:** 609 строк +**Actions:** 5 +**Категория:** Крупный контроллер (Фаза 2) + +--- + +## 🎯 Назначение в одном предложении + +Контроллер для отображения аналитических графиков (продажи, ФОТ, списания, бонусы) с гранулярным RBAC-контролем доступа по группам пользователей, уровням (розница/куст/магазин) и сменам. + +--- + +## 🔑 Ключевые особенности + +✅ **10 типов графиков:** продажи, планы, ФОТ, списания, бонусы, средний чек, продажи матрицы, по часам +✅ **RBAC система:** 6 групп пользователей с разными уровнями доступа +✅ **3 уровня просмотра:** розница (вся сеть), куст, магазин +✅ **3 типа смен:** день (8-19), ночь (20-7), день+ночь (24 часа) +✅ **AJAX получение данных:** динамическая загрузка графиков +✅ **История изменений:** отслеживание перехода магазинов между кустами + +--- + +## 📋 Actions (5) + +| Action | Метод | Назначение | +|--------|-------|------------| +| `actionIndex()` | GET | Главная страница графиков | +| `actionWriteOffPosition()` | GET | Страница графика списаний | +| `actionGetControlDataAjax()` | POST | Получение кустов/магазинов (AJAX) | +| `actionGetDataAjax()` | POST | Получение данных графиков (AJAX) | +| `actionWriteOffsIndex()` | GET | Список списаний за дату | + +--- + +## 🔐 RBAC: Группы пользователей + +| Group ID | Роль | Уровни | Смены | Доступ к планам | +|----------|------|--------|-------|-----------------| +| 1, 81, 71, 51, 10, 9, 74, 14 | **Топ-менеджмент** | 1, 2, 3 | 1, 2, 3 | ✅ | +| 7 | **Менеджер куста** | 2, 3 | 1, 2, 3 | ✅ | +| 30, 40 | **Менеджер магазина (день)** | 3 | 1 | ❌ | +| 35, 72 | **Менеджер магазина (ночь)** | 3 | 2 | ❌ | +| 50 | **Администратор** | 3 | 1, 2, 3 | ✅ | + +**Уровни (mode_level):** +- `1` — Розница (вся сеть) +- `2` — Куст +- `3` — Магазин + +**Смены (mode_shift):** +- `1` — День (8:00-19:59) +- `2` — Ночь (20:00-7:59) +- `3` — День+Ночь (24 часа) + +--- + +## 📊 Типы графиков (10) + +| Attribute | Название | Показатели | Формулы | +|-----------|----------|------------|---------| +| `sales` | Продажи | продажи, план | прямые значения | +| `plan_completed_this_day` | План за день | процент | `sales_sum / plan * 100` | +| `plan_completed_this_month` | План за месяц | процент прогноз | `(sales / day * days) / plan * 100` | +| `avg_sales_value` | Средний чек | средний чек, план | `sales_sum / sales_count` | +| `fot` | ФОТ | ФОТ %, план | `fot_sum / (sales / 100)` | +| `sales_sum_on_admin` | Продажи на админа | продажи на 1 админа | `sales / admin_count` | +| `write_offs` | Списания | сумма, %, накопительный % | `write_offs / (sales / 100)` | +| `user_bonus` | Бонусы | 4 типа бонусов | группировка по 4 | +| `matrix_sales_sum` | Продажи матрицы | букеты, растения, готовые | группировка по 3 | +| `count_sales_in_hour` | Продажи по часам | продажи, среднее | `sales_hour / days` | + +--- + +## 🗂️ Используемые модели + +| Модель | Таблица | Назначение | +|--------|---------|------------| +| **Admin** | `admin` | Проверка прав, получение магазинов | +| **ChartDataSearch** | - | Поиск данных для графиков (агрегатор) | +| **WriteOffs** | `write_offs` | Получение списаний | +| **StoreDynamic** | `store_dynamic` | История кустов магазинов | +| **CityStore** | `city_store` | Информация о магазинах | + +--- + +## 🔄 Основные процессы + +### 1. Проверка доступа (actionIndex) + +```mermaid +graph LR + A[Получить Admin] --> B{Проверить group_id} + B -->|Топ-менеджмент| C[Полный доступ] + B -->|Менеджер куста| D[Куст + Магазин] + B -->|Менеджер магазина| E[Только магазин] + B -->|Не распознана| F[Exception: Нет доступа] +``` + +### 2. Получение данных графика (actionGetDataAjax) + +```mermaid +graph TB + A[POST запрос] --> B[ChartDataSearch] + B --> C{Тип графика} + C -->|sales| D[Прямые значения] + C -->|avg_sales_value| E[Расчёт среднего] + C -->|fot| F[Расчёт ФОТ %] + C -->|write_offs| G[Накопительный расчёт] + D --> H[JSON ответ] + E --> H + F --> H + G --> H +``` + +--- + +## 💡 Быстрые примеры + +### Получение данных графика продаж + +```javascript +$.ajax({ + url: '/charts-for-management/get-data-ajax', + type: 'POST', + data: { + mode: 3, // Магазин + attribute: 'sales', // Продажи + date_start: '2025-01-01', + date_end: '2025-01-31', + store: 5, + shift: 3 + }, + success: function(response) { + const data = JSON.parse(response); + console.log(data.data_answer.attribute.sales.data); + } +}); +``` + +### Получение кустов и магазинов + +```javascript +$.post('/charts-for-management/get-control-data-ajax', { + date_start: '2025-01-01', + date_end: '2025-01-31' +}, function(response) { + const data = JSON.parse(response); + console.log('Кусты:', data.clusters); + console.log('Магазины:', data.stores_in_cluster); +}); +``` + +### Открыть список списаний + +```bash +# GET запрос +curl "https://erp24.example.com/charts-for-management/write-offs-index?date=2025-01-15&store_id=5" +``` + +--- + +## ⚠️ Особенности и ограничения + +### Защита от деления на ноль + +```php +$avg_check = $sales / ($count ?: 1); // Если $count = 0, то = 1 +``` + +### Автоматическая установка дат + +- **plan_completed_this_day:** с начала месяца до сегодня +- **plan_completed_this_month:** весь текущий месяц +- **write_offs:** с начала месяца переданного date_start + +### Группировка данных + +- **% 2** (пары): avg_sales_value, fot, sales_sum_on_admin, write_offs +- **% 3** (тройки): matrix_sales_sum (букеты, растения, готовые) +- **% 4** (четвёрки): user_bonus (4 типа бонусов) + +### Накопительный расчёт (write_offs) + +```php +// Сброс на первое число месяца +if (date('d', strtotime($date)) == 1) { + $cumulative_sales = 0; + $cumulative_write_offs = 0; +} +$cumulative_sales += $sales; +$cumulative_write_offs += $write_offs; +``` + +--- + +## 🚨 Типичные ошибки + +| Ошибка | Причина | Решение | +|--------|---------|---------| +| Exception: "Нет доступа" | group_id не распознана | Добавить группу в условия | +| Возврат `-1` | Нет данных для write_offs_position | Проверить ChartDataSearch | +| Division by zero | План/количество = 0 | Использовать `?: 1` | + +--- + +## 📈 Формулы расчётов + +### Средний чек +```php +$avg_check = $sales_sum / ($sales_count ?: 1); +``` + +### ФОТ в процентах +```php +$fot_percent = $fot_sum / (($sales_sum ?: 1) / 100); +``` + +### Выполнение плана за день +```php +$percent = round($sales_sum / ($plan ?: 1) * 100, 2); +``` + +### Прогноз плана за месяц +```php +$current_day = date('d'); +$total_days = date('d', strtotime('last day of this month')); +$hypothesis = round( + $sales_sum / $current_day * $total_days / ($plan ?: 1) * 100, + 2 +); +``` + +### Списания (процент от продаж) +```php +$percent_day = $write_offs / (($sales ?: 1) / 100); +$percent_cumulative = $cumulative_write_offs / (($cumulative_sales ?: 1) / 100); +``` + +### Продажи на администратора +```php +$sales_per_admin = $sales_sum / ($admin_count ?: 1); +``` + +--- + +## 🔗 Связанные компоненты + +- **[ChartForManagementController](./ChartForManagementController_ANALYSIS.md)** — Похожий контроллер (622 строки) +- **[Admin](../../models/Admin.md)** — Модель пользователей +- **[ChartDataSearch](../../models/ChartDataSearch.md)** — Поиск данных графиков +- **[WriteOffs](../../models/WriteOffs.md)** — Модель списаний + +--- + +## 📝 Полезные ссылки + +- [Детальный анализ контроллера](./ChartsForManagementController_ANALYSIS.md) +- [Таблица Actions](./ChartsForManagementController_ACTIONS_TABLE.md) +- [План документирования контроллеров](../CONTROLLERS_DOCUMENTATION_PLAN.md) + +--- + +## ❓ FAQ (Топ-3) + +### 1. Почему менеджеры магазинов не видят графики планов? + +Группы 30, 40, 35, 72 имеют ограниченный доступ: + +```php +if ($key != 'plan_completed_this_day' && $key != 'plan_completed_this_month') { + $item['visible'] = true; +} +``` + +### 2. Как работает группировка данных (% 2, % 3, % 4)? + +Некоторые графики используют составные данные: +- **% 2:** пары (продажи + количество) +- **% 3:** тройки (3 категории матрицы) +- **% 4:** четвёрки (4 типа бонусов) + +### 3. Почему для count_sales_in_hour разные оси X? + +Категории зависят от смены: +- **День+Ночь:** 8-23, 0-7 (24 часа) +- **День:** 8-19 (12 часов) +- **Ночь:** 20-23, 0-7 (12 часов) + +--- + +**Краткая справка создана:** 2025-11-26 +**Версия:** 1.0 +**Статус:** ✅ Готово diff --git a/erp24/docs/controllers/non-standard/ShiftTransferController_ACTIONS_TABLE.md b/erp24/docs/controllers/non-standard/ShiftTransferController_ACTIONS_TABLE.md new file mode 100644 index 00000000..bed14acc --- /dev/null +++ b/erp24/docs/controllers/non-standard/ShiftTransferController_ACTIONS_TABLE.md @@ -0,0 +1,95 @@ +# ShiftTransferController - Таблица Actions + +## Быстрая справка + +| # | Action | HTTP | Назначение | Параметры | +|---|--------|------|------------|-----------| +| 1 | `actionIndex()` | GET | Список передач смен | - | +| 2 | `actionCreate()` | GET | Создание документа | - | +| 3 | `actionUpdate($id)` | GET/POST | Редактирование | `id`, POST: модель, `action` | +| 4 | `actionView($id)` | GET/POST | Просмотр/действия | `id`, POST: `action` | +| 5 | `actionDelete($id)` | POST | Удаление | `id` | +| 6 | `actionGetProductData()` | POST | AJAX: данные товара | `productGuid`, `shiftTransferId` | +| 7 | `actionGetProductReplacementPrice()` | POST | AJAX: цена замены | `productGuid`, `shiftTransferId` | +| 8 | `actionGetMaxQuantity()` | POST | AJAX: макс. кол-во | `productReplacementName`, `productName`, `shiftTransferId` | +| 9 | `actionGetProductPriceSelfCostAndRemains()` | POST | AJAX: цена+остатки | `productGuid`, `storeGuid` | +| 10 | `actionGetProductsWithRemains()` | POST | AJAX: товары с остатками | `storeGuid` | +| 11 | `buildLoadDataShiftRemains()` | Helper | Построение данных | `groups`, `storeGuid`, `shiftDate` | +| 12 | `isAllowedAdmin()` | Helper | Проверка прав | - | + +--- + +## Статусы документа + +| ID | Константа | Описание | +|----|-----------|----------| +| 1 | `STATUS_ID_INPUT_FACT_REMAINS` | Ввод фактических остатков | +| 2 | `STATUS_ID_TRANSFER_ACTIONS` | Действия по замене | +| 3 | `STATUS_ID_READY_TO_ACCEPT` | Готов к приёмке | +| 4 | `STATUS_OF_THE_FORMATION_OF_SURPLUSES_AND_SHORTAGES` | Формирование излишков/недостач | +| 5 | `STATUS_ID_ACCEPTED` | Принят | + +--- + +## actionUpdate: POST actions + +| Action | Описание | Что делает | +|--------|----------|------------| +| `applyGroups` | Применить группы товаров | Перезагрузить список товаров | +| `save` (по умолчанию) | Сохранить остатки | Расчёт расхождений, status=2 | + +--- + +## actionView: POST actions + +| Action | Описание | Что делает | +|--------|----------|------------| +| `accept` | Принять смену | status=5, архивация, формирование документов | +| `save` | Готов к приёмке | status=3 | +| `recalculate` | Перерасчёт замен | EqualizationRemains::setData() | +| `rejection` | Отклонить | status=2, удалить накладные | +| `resume` | Продолжить | status=4, создать накладные | + +--- + +## AJAX API возвраты + +### actionGetProductData +```json +{ + "success": true, + "product_count": 5, + "product_price": 1500.00, + "product_replacement": {"guid": "Название (арт. 123)"}, + "product_self_cost": 800.00 +} +``` + +### actionGetProductReplacementPrice +```json +{ + "success": true, + "product_price": 1500.00, + "product_replacement_self_cost": 800.00, + "maxQuantity": 10 +} +``` + +### actionGetMaxQuantity +```json +{ + "maxValue": 5 +} +``` + +--- + +## Связанные файлы + +- [Детальный анализ](./ShiftTransferController_ANALYSIS.md) +- [Краткая справка](./ShiftTransferController_QUICK_REFERENCE.md) + +--- + +**Документация создана:** 2025-11-26 +**Статус:** ✅ Готово diff --git a/erp24/docs/controllers/non-standard/ShiftTransferController_ANALYSIS.md b/erp24/docs/controllers/non-standard/ShiftTransferController_ANALYSIS.md new file mode 100644 index 00000000..da38ed4e --- /dev/null +++ b/erp24/docs/controllers/non-standard/ShiftTransferController_ANALYSIS.md @@ -0,0 +1,275 @@ +# ShiftTransferController - Детальный анализ + +## 📋 Общая информация + +**Namespace:** `app\controllers` +**Путь к файлу:** `erp24/controllers/ShiftTransferController.php` +**Extends:** `\yii\web\Controller` +**Размер:** 490 строк кода +**Количество actions:** 12 +**Категория:** Средний контроллер с интеграциями (Фаза 3) +**Приоритет:** MEDIUM-HIGH + +--- + +## 🎯 Назначение + +Контроллер для управления **передачей смены между администраторами магазина** с полным циклом: + +- Создание документа передачи смены с фактическими остатками +- Сравнение факта с 1С (выявление недостач/излишков) +- Система замен недостающего товара +- Автоматическое формирование накладных (приход/списание) +- Принятие смены следующим администратором +- Статусная модель документа (5 статусов) + +Контроллер интегрируется с: +- 1С (остатки, цены, себестоимость) +- Система замен товаров +- Накладные приход/расход +- Балансы магазина + +--- + +## 🏗️ Архитектура + +```mermaid +graph TB + Controller[ShiftTransferController] + + subgraph "CRUD Actions" + A1[actionIndex
Список передач] + A2[actionCreate
Создание передачи] + A3[actionUpdate
Редактирование] + A4[actionView
Просмотр и действия] + A5[actionDelete
Удаление] + end + + subgraph "AJAX API" + A6[actionGetProductData
Данные товара для замены] + A7[actionGetProductReplacementPrice
Цена замены] + A8[actionGetMaxQuantity
Макс. количество замены] + A9[actionGetProductPriceSelfCostAndRemains
Цена + себестоимость + остатки] + A10[actionGetProductsWithRemains
Товары с остатками] + end + + subgraph "Helper" + A11[buildLoadDataShiftRemains
Построение данных остатков] + A12[isAllowedAdmin
Проверка прав] + end + + subgraph "Models" + M1[ShiftTransfer
Документ передачи] + M2[ShiftRemains
Строки остатков] + M3[EqualizationRemains
Замены товаров] + M4[WaybillIncoming
Приходные накладные] + M5[WaybillWriteOffs
Расходные накладные] + M6[Products1c
Товары 1С] + M7[Balances
Остатки] + M8[Prices
Цены] + M9[SelfCostProductDynamic
Себестоимость] + end + + Controller --> A1 + Controller --> A2 + Controller --> A3 + Controller --> A4 + Controller --> A5 + A2 --> A11 + A3 --> A11 + A4 --> M4 + A4 --> M5 +``` + +--- + +## 📊 Статусная модель документа + +```mermaid +stateDiagram-v2 + [*] --> INPUT_FACT_REMAINS: create + INPUT_FACT_REMAINS --> TRANSFER_ACTIONS: save (ввод остатков) + TRANSFER_ACTIONS --> READY_TO_ACCEPT: save (замены добавлены) + TRANSFER_ACTIONS --> TRANSFER_ACTIONS: rejection + READY_TO_ACCEPT --> FORMATION_SURPLUSES: resume (формирование накладных) + FORMATION_SURPLUSES --> ACCEPTED: accept + ACCEPTED --> [*] +``` + +**Статусы (ShiftTransfer):** +1. `STATUS_ID_INPUT_FACT_REMAINS` (1) — Ввод фактических остатков +2. `STATUS_ID_TRANSFER_ACTIONS` (2) — Действия по замене +3. `STATUS_ID_READY_TO_ACCEPT` (3) — Готов к приёмке +4. `STATUS_OF_THE_FORMATION_OF_SURPLUSES_AND_SHORTAGES` (4) — Формирование излишков/недостач +5. `STATUS_ID_ACCEPTED` (5) — Принят + +--- + +## 📋 Actions (сокращённо) + +### 1. actionIndex() +- Список всех передач смен +- Фильтр по магазинам пользователя +- Для не-IT: только готовые к приёмке или свои + +### 2. actionCreate() +- Создание документа передачи +- Автозагрузка остатков из 1С +- Группировка товаров (other_items по умолчанию) + +### 3. actionUpdate($id) +- Редактирование документа +- Применение групп товаров (`applyGroups`) +- Сохранение остатков и расчёт расхождений + +### 4. actionView($id) +**POST actions:** +- `accept` — Принять смену +- `save` — Сохранить как готов к приёмке +- `recalculate` — Перерасчёт замен +- `rejection` — Отклонить (вернуть на редактирование) +- `resume` — Продолжить (сформировать накладные) + +### 5-10. AJAX API +- Получение данных товаров +- Цены, себестоимости, остатки +- Максимальное количество для замены + +--- + +## 💾 Ключевые модели + +### ShiftTransfer +**Поля:** +- `store_guid` — GUID магазина +- `end_shift_admin_id` — Админ закрывающий смену +- `start_shift_admin_id` — Админ принимающий смену +- `date_start`, `date_end` — Даты +- `status_id` — Статус документа +- `goods_transfer_count` — Кол-во переданного товара +- `discrepancy_pieces` — Расхождение (штуки) +- `discrepancy_rubles` — Расхождение (рубли) +- `product_groups` — Группы товаров + +**Связи:** +- `hasMany(ShiftRemains)` — Строки остатков +- `hasMany(EqualizationRemains)` — Замены + +--- + +### ShiftRemains +**Поля:** +- `shift_transfer_id` — FK +- `product_guid` — GUID товара +- `remains_1c` — Остаток по 1С +- `remains_count` — Фактический остаток +- `fact_and_1c_diff` — Разница (факт - 1С) +- `retail_price` — Розничная цена +- `self_cost` — Себестоимость +- `type` — Тип (0=текущий, 1=архив) + +--- + +## 🔄 Бизнес-процесс передачи смены + +**Шаг 1. Создание документа (actionCreate)** +``` +1. Выбрать магазин +2. Выбрать группы товаров (по умолчанию: other_items) +3. Загрузить остатки из 1С +4. Ввести фактические остатки +``` + +**Шаг 2. Сохранение и расчёт (actionUpdate, action=save)** +``` +1. Сохранить ShiftTransfer +2. Удалить старые ShiftRemains +3. Создать новые ShiftRemains +4. Рассчитать расхождения: + - discrepancy_pieces = SUM(remains_count - remains_1c) + - discrepancy_rubles = SUM(retail_price * (remains_count - remains_1c)) +5. Вызвать EqualizationRemains::setData() для формирования замен +6. Установить status = TRANSFER_ACTIONS +``` + +**Шаг 3. Добавление замен (actionView)** +``` +1. Для каждого недостающего товара: + - Выбрать товар-замену из излишков + - Указать количество +2. Сохранить замены в EqualizationRemains +3. Установить status = READY_TO_ACCEPT +``` + +**Шаг 4. Формирование накладных (action=resume)** +``` +1. Создать WaybillIncoming (приход излишков) +2. Создать WaybillWriteOffs (списание недостач) +3. Установить status = FORMATION_SURPLUSES +``` + +**Шаг 5. Принятие смены (action=accept)** +``` +1. Установить date_end, start_shift_admin_id +2. Архивировать ShiftRemains (type=1) +3. Сформировать ReplacementInvoice +4. Обновить StoreBalance +5. Подтвердить накладные (status=CONFIRM) +6. Установить status = ACCEPTED +``` + +--- + +## 🧪 Примеры использования + +### Создать передачу смены + +```php +// GET /shift-transfer/create +// Выбрать магазин, группы товаров +// Ввести фактические остатки +// POST action=save +``` + +### AJAX: получить данные товара для замены + +```javascript +$.post('/shift-transfer/get-product-data', { + productGuid: 'xxx-xxx', + shiftTransferId: 123 +}, function(response) { + console.log(response.product_price); + console.log(response.product_replacement); // Список замен +}); +``` + +--- + +## ❓ FAQ + +### 1. Что такое "замена товара"? +**Ответ:** Если по факту товара меньше чем в 1С (недостача), можно заменить его излишком другого товара. + +### 2. Какие группы товаров доступны? +**Ответ:** +- `other_items` — Другие товары +- Дополнительные группы из ProductsClass + +### 3. Как рассчитывается максимальное количество замены? +**Ответ:** `min(abs(недостача), abs(излишек))` + +--- + +## 🔗 Связанные компоненты + +- [ShiftTransfer](../../models/ShiftTransfer.md) +- [ShiftRemains](../../models/ShiftRemains.md) +- [EqualizationRemains](../../models/EqualizationRemains.md) +- [WaybillIncoming](../../models/WaybillIncoming.md) +- [WaybillWriteOffs](../../models/WaybillWriteOffs.md) + +--- + +**Документация создана:** 2025-11-26 +**Фаза:** 3 (Средние с интеграциями) +**Статус:** ✅ Готово diff --git a/erp24/docs/controllers/non-standard/ShiftTransferController_QUICK_REFERENCE.md b/erp24/docs/controllers/non-standard/ShiftTransferController_QUICK_REFERENCE.md new file mode 100644 index 00000000..49616f6b --- /dev/null +++ b/erp24/docs/controllers/non-standard/ShiftTransferController_QUICK_REFERENCE.md @@ -0,0 +1,469 @@ +# ShiftTransferController - Краткая справка + +## 📌 Основная информация + +**Контроллер:** `ShiftTransferController` +**Namespace:** `app\controllers` +**Путь:** `erp24/controllers/ShiftTransferController.php` +**Размер:** 490 строк +**Actions:** 12 (5 CRUD + 5 AJAX + 2 helper) +**Категория:** Средний контроллер с интеграциями (Фаза 3) + +--- + +## 🎯 Назначение в одном предложении + +Управление передачей смены между администраторами магазина с полным циклом: ввод фактических остатков, сравнение с 1С, система замен недостающего товара, автоматическое формирование накладных и принятие смены. + +--- + +## 🔑 Ключевые особенности + +✅ **5-этапный статусный workflow:** от ввода остатков до принятия смены +✅ **Интеграция с 1С:** автоматическая загрузка остатков, цен, себестоимости +✅ **Система замен товаров:** замена недостач излишками +✅ **Автоматические накладные:** приход излишков, списание недостач +✅ **Расчёт расхождений:** в штуках и рублях +✅ **AJAX API:** динамическая загрузка данных товаров +✅ **Архивация данных:** сохранение истории передач + +--- + +## 📋 Actions (12) + +| # | Action | Тип | Назначение | +|---|--------|-----|------------| +| 1 | `actionIndex()` | CRUD | Список всех передач смен | +| 2 | `actionCreate()` | CRUD | Создание документа передачи | +| 3 | `actionUpdate($id)` | CRUD | Редактирование остатков | +| 4 | `actionView($id)` | CRUD | Просмотр и действия над документом | +| 5 | `actionDelete($id)` | CRUD | Удаление документа | +| 6 | `actionGetProductData()` | AJAX | Получить данные товара для замены | +| 7 | `actionGetProductReplacementPrice()` | AJAX | Получить цену замены | +| 8 | `actionGetMaxQuantity()` | AJAX | Максимальное количество замены | +| 9 | `actionGetProductPriceSelfCostAndRemains()` | AJAX | Цена + себестоимость + остатки | +| 10 | `actionGetProductsWithRemains()` | AJAX | Товары с остатками для магазина | +| 11 | `buildLoadDataShiftRemains()` | Helper | Построение данных остатков из 1С | +| 12 | `isAllowedAdmin()` | Helper | Проверка прав доступа | + +--- + +## 📊 Статусная модель (5 статусов) + +```mermaid +stateDiagram-v2 + [*] --> STATUS_1: create + STATUS_1: 1. Ввод фактических остатков + STATUS_2: 2. Действия по замене + STATUS_3: 3. Готов к приёмке + STATUS_4: 4. Формирование излишков/недостач + STATUS_5: 5. Принят + + STATUS_1 --> STATUS_2: save (ввести остатки) + STATUS_2 --> STATUS_3: save (добавить замены) + STATUS_2 --> STATUS_2: rejection (вернуть) + STATUS_3 --> STATUS_4: resume (создать накладные) + STATUS_4 --> STATUS_5: accept (принять смену) + STATUS_5 --> [*] +``` + +| ID | Константа | Описание | +|----|-----------|----------| +| 1 | `STATUS_ID_INPUT_FACT_REMAINS` | Ввод фактических остатков | +| 2 | `STATUS_ID_TRANSFER_ACTIONS` | Действия по замене | +| 3 | `STATUS_ID_READY_TO_ACCEPT` | Готов к приёмке | +| 4 | `STATUS_OF_THE_FORMATION_OF_SURPLUSES_AND_SHORTAGES` | Формирование излишков/недостач | +| 5 | `STATUS_ID_ACCEPTED` | Принят | + +--- + +## 🗂️ Используемые модели + +| Модель | Назначение | +|--------|------------| +| **ShiftTransfer** | Документ передачи смены | +| **ShiftRemains** | Строки остатков (текущие/архив) | +| **EqualizationRemains** | Замены товаров | +| **WaybillIncoming** | Приходные накладные (излишки) | +| **WaybillWriteOffs** | Расходные накладные (недостачи) | +| **Products1c** | Товары из 1С | +| **Balances** | Остатки по магазинам | +| **Prices** | Цены товаров | +| **SelfCostProductDynamic** | Себестоимость | +| **ReplacementInvoice** | Акты замены | +| **StoreBalance** | Балансы магазинов | + +--- + +## 🔄 Бизнес-процесс (5 шагов) + +### Шаг 1: Создание документа +``` +1. Выбрать магазин +2. Выбрать группы товаров (по умолчанию: other_items) +3. Загрузить остатки из 1С +4. Ввести фактические остатки +→ Status: INPUT_FACT_REMAINS (1) +``` + +### Шаг 2: Сохранение и расчёт +``` +1. Сохранить ShiftTransfer +2. Удалить старые ShiftRemains +3. Создать новые ShiftRemains +4. Рассчитать расхождения: + - discrepancy_pieces = SUM(fact - 1C) + - discrepancy_rubles = SUM(price × (fact - 1C)) +5. Вызвать EqualizationRemains::setData() → автозамены +→ Status: TRANSFER_ACTIONS (2) +``` + +### Шаг 3: Добавление замен +``` +1. Для каждого недостающего товара: + - Выбрать товар-замену из излишков + - Указать количество +2. Сохранить в EqualizationRemains +→ Status: READY_TO_ACCEPT (3) +``` + +### Шаг 4: Формирование накладных +``` +1. Создать WaybillIncoming (излишки) +2. Создать WaybillWriteOffs (недостачи) +→ Status: FORMATION_SURPLUSES (4) +``` + +### Шаг 5: Принятие смены +``` +1. Установить date_end, start_shift_admin_id +2. Архивировать ShiftRemains (type=1) +3. Сформировать ReplacementInvoice +4. Обновить StoreBalance +5. Подтвердить накладные (status=CONFIRM) +→ Status: ACCEPTED (5) +``` + +--- + +## 💡 Быстрые примеры + +### AJAX: получить данные товара для замены + +```javascript +$.ajax({ + url: '/shift-transfer/get-product-data', + type: 'POST', + data: { + productGuid: 'xxx-xxx-xxx', + shiftTransferId: 123 + }, + success: function(response) { + console.log('Количество:', response.product_count); + console.log('Цена:', response.product_price); + console.log('Себестоимость:', response.product_self_cost); + console.log('Замены:', response.product_replacement); + } +}); +``` + +### AJAX: получить цену замены + +```javascript +$.post('/shift-transfer/get-product-replacement-price', { + productGuid: 'xxx-xxx-xxx', + shiftTransferId: 123 +}, function(response) { + console.log('Цена замены:', response.product_price); + console.log('Себестоимость замены:', response.product_replacement_self_cost); + console.log('Максимальное кол-во:', response.maxQuantity); +}); +``` + +### AJAX: получить максимальное количество замены + +```javascript +$.post('/shift-transfer/get-max-quantity', { + productReplacementName: 'Товар А', + productName: 'Товар Б', + shiftTransferId: 123 +}, function(response) { + console.log('Можно заменить:', response.maxValue); +}); +``` + +--- + +## 📊 Структура данных + +### ShiftTransfer (основная таблица) + +| Поле | Тип | Описание | +|------|-----|----------| +| `id` | int | ID документа | +| `store_guid` | string | GUID магазина | +| `end_shift_admin_id` | int | Админ закрывающий смену | +| `start_shift_admin_id` | int | Админ принимающий смену | +| `date_start` | datetime | Дата начала | +| `date_end` | datetime | Дата окончания (nullable) | +| `status_id` | int | Статус (1-5) | +| `goods_transfer_count` | int | Кол-во переданного товара | +| `discrepancy_pieces` | int | Расхождение (штуки) | +| `discrepancy_rubles` | decimal | Расхождение (рубли) | +| `product_groups` | string | Группы товаров (JSON) | + +### ShiftRemains (строки остатков) + +| Поле | Тип | Описание | +|------|-----|----------| +| `id` | int | ID строки | +| `shift_transfer_id` | int | FK на ShiftTransfer | +| `product_guid` | string | GUID товара | +| `remains_1c` | int | Остаток по 1С | +| `remains_count` | int | Фактический остаток | +| `fact_and_1c_diff` | int | Разница (факт - 1С) | +| `retail_price` | decimal | Розничная цена | +| `self_cost` | decimal | Себестоимость | +| `type` | int | 0=текущий, 1=архив | + +--- + +## ⚙️ Бизнес-правила + +### Группы товаров +- **other_items** — Другие товары (по умолчанию) +- Дополнительные группы из `ProductsClass` + +### Расчёт расхождений +```php +discrepancy_pieces = SUM(remains_count - remains_1c) +discrepancy_rubles = SUM(retail_price × (remains_count - remains_1c)) +``` + +### Максимальное количество замены +```php +maxQuantity = min( + abs(недостача_товара), + abs(излишек_замены) +) +``` + +### Права доступа +- Только администраторы магазинов (не IT) +- Просмотр чужих документов: только status >= 3 (готов к приёмке) + +--- + +## 🔄 POST Actions + +### actionUpdate: POST actions + +| Action | Что делает | +|--------|------------| +| `applyGroups` | Перезагрузить список товаров по выбранным группам | +| `save` (default) | Сохранить остатки, расчёт расхождений → status=2 | + +### actionView: POST actions + +| Action | Что делает | +|--------|------------| +| `accept` | Принять смену → status=5, архивация, формирование документов | +| `save` | Готов к приёмке → status=3 | +| `recalculate` | Перерасчёт замен (EqualizationRemains::setData()) | +| `rejection` | Отклонить → status=2, удалить накладные | +| `resume` | Продолжить → status=4, создать накладные | + +--- + +## 🧪 Сценарии использования + +### Сценарий 1: Создать передачу смены + +1. Открыть `/shift-transfer/create` +2. Выбрать магазин +3. Выбрать группы товаров (other_items) +4. Ввести фактические остатки +5. POST `action=save` +6. Система рассчитает расхождения → status=2 + +--- + +### Сценарий 2: Добавить замены товаров + +1. Открыть `/shift-transfer/view?id=123` (status=2) +2. Для недостачи "Товар А" выбрать замену "Товар Б" (излишек) +3. AJAX загрузит максимальное количество +4. Указать количество замены +5. POST `action=save` → status=3 + +--- + +### Сценарий 3: Сформировать накладные + +1. Открыть `/shift-transfer/view?id=123` (status=3) +2. POST `action=resume` +3. Система создаст: + - WaybillIncoming (излишки) + - WaybillWriteOffs (недостачи) +4. status=4 + +--- + +### Сценарий 4: Принять смену + +1. Открыть `/shift-transfer/view?id=123` (status=4) +2. Проверить накладные +3. POST `action=accept` +4. Система: + - Установит date_end, start_shift_admin_id + - Архивирует ShiftRemains (type=1) + - Сформирует ReplacementInvoice + - Обновит StoreBalance + - Подтвердит накладные +5. status=5 (завершено) + +--- + +## ❓ FAQ (Топ-5) + +### 1. Что такое "замена товара"? + +**Ответ:** Если по факту товара меньше чем в 1С (недостача), его можно заменить излишком другого товара. Например: + +``` +Недостача: Товар А = -5 шт +Излишек: Товар Б = +10 шт +→ Заменить 5 шт Товара А на 5 шт Товара Б +``` + +Система автоматически рассчитает максимальное количество: `min(5, 10) = 5`. + +--- + +### 2. Как работает автоматическая загрузка остатков из 1С? + +**Ответ:** При создании документа метод `buildLoadDataShiftRemains()`: + +1. Получает список товаров по группам +2. Запрашивает остатки из `Balances` (1С) +3. Загружает цены из `Prices` +4. Загружает себестоимость из `SelfCostProductDynamic` +5. Формирует массив для заполнения таблицы + +--- + +### 3. Что происходит при сохранении остатков (action=save)? + +**Ответ:** 5 операций: + +1. Сохранить ShiftTransfer +2. Удалить старые ShiftRemains (DELETE WHERE shift_transfer_id) +3. Создать новые ShiftRemains (INSERT) +4. Рассчитать расхождения (discrepancy_pieces, discrepancy_rubles) +5. Вызвать `EqualizationRemains::setData()` → автоматические замены + +--- + +### 4. Зачем нужна архивация ShiftRemains (type=1)? + +**Ответ:** При принятии смены (status=5) все строки остатков копируются с `type=1` для: + +- Сохранения истории передач +- Анализа расхождений +- Отчётов по администраторам +- Защиты от изменений после принятия + +Текущие остатки имеют `type=0`, архивные — `type=1`. + +--- + +### 5. Какие накладные создаются автоматически? + +**Ответ:** 2 типа накладных: + +**WaybillIncoming (излишки):** +- Для товаров, где `fact > 1C` +- Тип: приход +- Статус: черновик → подтверждается при accept + +**WaybillWriteOffs (недостачи):** +- Для товаров, где `fact < 1C` +- Тип: списание +- Статус: черновик → подтверждается при accept + +Обе накладные создаются при `action=resume` (status=3 → 4). + +--- + +## 🚨 Типичные ошибки + +| Ошибка | Причина | Решение | +|--------|---------|---------| +| "Передача смены не найдена" | Неверный ID | Проверить ID документа | +| "Нет прав на редактирование" | Чужой документ, status < 3 | Только владелец или status >= 3 | +| "Ошибка загрузки остатков из 1С" | Нет связи с 1С | Проверить интеграцию | +| "Товар не найден" | GUID не существует | Проверить справочник | +| "Максимальное количество = 0" | Нет излишков для замены | Выбрать другой товар | + +--- + +## 🔗 Связанные компоненты + +- **[Детальный анализ](./ShiftTransferController_ANALYSIS.md)** — полная документация +- **[Таблица Actions](./ShiftTransferController_ACTIONS_TABLE.md)** — справочник методов +- **[ShiftTransfer](../../models/ShiftTransfer.md)** — модель документа +- **[ShiftRemains](../../models/ShiftRemains.md)** — модель остатков +- **[EqualizationRemains](../../models/EqualizationRemains.md)** — модель замен +- **[WaybillIncoming](../../models/WaybillIncoming.md)** — приходные накладные +- **[WaybillWriteOffs](../../models/WaybillWriteOffs.md)** — расходные накладные + +--- + +## 🎨 Диаграмма процесса + +``` +┌─────────────────────────────────────────────────────────────┐ +│ СОЗДАНИЕ ДОКУМЕНТА │ +│ (actionCreate) │ +│ ↓ │ +│ Выбрать магазин → Группы товаров → Загрузить из 1С │ +└──────────────────────┬──────────────────────────────────────┘ + │ + ↓ +┌─────────────────────────────────────────────────────────────┐ +│ ВВОД ФАКТИЧЕСКИХ ОСТАТКОВ │ +│ (actionUpdate, action=save) │ +│ ↓ │ +│ Ввести факт → Расчёт расхождений → Автозамены │ +└──────────────────────┬──────────────────────────────────────┘ + │ + ↓ +┌─────────────────────────────────────────────────────────────┐ +│ ДОБАВЛЕНИЕ ЗАМЕН ТОВАРОВ │ +│ (actionView, action=save) │ +│ ↓ │ +│ Недостача → Выбрать замену → Указать кол-во │ +└──────────────────────┬──────────────────────────────────────┘ + │ + ↓ +┌─────────────────────────────────────────────────────────────┐ +│ ФОРМИРОВАНИЕ НАКЛАДНЫХ │ +│ (actionView, action=resume) │ +│ ↓ │ +│ WaybillIncoming + WaybillWriteOffs │ +└──────────────────────┬──────────────────────────────────────┘ + │ + ↓ +┌─────────────────────────────────────────────────────────────┐ +│ ПРИНЯТИЕ СМЕНЫ │ +│ (actionView, action=accept) │ +│ ↓ │ +│ Архивация → ReplacementInvoice → Балансы → Подтверждение │ +└─────────────────────────────────────────────────────────────┘ +``` + +--- + +**Краткая справка создана:** 2025-11-26 +**Версия:** 1.0 +**Статус:** ✅ Готово diff --git a/erp24/docs/controllers/non-standard/StoreStaffingController_ACTIONS_TABLE.md b/erp24/docs/controllers/non-standard/StoreStaffingController_ACTIONS_TABLE.md new file mode 100644 index 00000000..5ed92c72 --- /dev/null +++ b/erp24/docs/controllers/non-standard/StoreStaffingController_ACTIONS_TABLE.md @@ -0,0 +1,749 @@ +# StoreStaffingController - Таблица Actions + +## Быстрая справка по действиям + +| # | Action | HTTP | Маршрут | Назначение | Параметры | Возвращает | +|---|--------|------|---------|------------|-----------|------------| +| 1 | `actionIndex()` | GET | `/store-staffing/index` | Список записей штатного расписания | фильтры | HTML | +| 2 | `actionView($id)` | GET | `/store-staffing/view` | Просмотр записи | `id` | HTML | +| 3 | `actionCreate()` | GET/POST | `/store-staffing/create` | Создание записи | POST: модель | HTML/Redirect | +| 4 | `actionUpdate($id)` | GET/POST | `/store-staffing/update` | Редактирование записи | `id`, POST: модель | HTML/Redirect | +| 5 | `actionDelete($id)` | POST | `/store-staffing/delete` | Удаление записи | `id` | Redirect | +| 6 | `actionImport()` | GET/POST | `/store-staffing/import` | Импорт из Excel | POST: файл | HTML/Redirect | +| 7 | `actionExportTemplate()` | GET | `/store-staffing/export-template` | Экспорт шаблона Excel | - | File (XLSX) | +| 8 | `actionLogs($store_id)` | GET | `/store-staffing/logs` | Просмотр логов изменений | `store_id` | HTML | +| 9 | `actionGetPositionPosit($position_id)` | GET | `/store-staffing/get-position-posit` | AJAX: получить грейд должности | `position_id` | JSON | + +--- + +## Детальное описание Actions + +### 1. actionIndex() + +**Сигнатура:** +```php +public function actionIndex(): string +``` + +**Назначение:** Отображение списка всех записей штатного расписания с фильтрацией и пагинацией + +**Параметры (GET):** + +| Параметр | Тип | Обязательный | Описание | +|----------|-----|--------------|----------| +| `StoreStaffingSearch[store_id]` | int | Нет | Фильтр по магазину | +| `StoreStaffingSearch[employee_position_id]` | int | Нет | Фильтр по должности | + +**Возвращает:** `string` — HTML страница + +**Пример HTTP:** +```bash +curl -X GET "https://erp24.example.com/store-staffing/index?StoreStaffingSearch[store_id]=5" +``` + +**Данные представления:** +- `searchModel` — модель поиска +- `dataProvider` — провайдер данных +- `stores` — массив магазинов для фильтра + +--- + +### 2. actionView($id) + +**Сигнатура:** +```php +public function actionView(int $id): string +``` + +**Назначение:** Просмотр детальной информации о записи штатного расписания + +**Параметры:** + +| Параметр | Тип | Обязательный | Описание | +|----------|-----|--------------|----------| +| `id` | int | Да | ID записи | + +**Возвращает:** +- Success: `string` — HTML страница +- Error: `NotFoundHttpException` + +**Пример HTTP:** +```bash +curl -X GET "https://erp24.example.com/store-staffing/view?id=123" +``` + +--- + +### 3. actionCreate() + +**Сигнатура:** +```php +public function actionCreate(): string|\yii\web\Response +``` + +**Назначение:** Создание новой записи штатного расписания + +**Параметры (POST):** + +| Параметр | Тип | Обязательный | Описание | +|----------|-----|--------------|----------| +| `StoreStaffing[store_id]` | int | Да | ID магазина | +| `StoreStaffing[employee_position_id]` | int | Да | ID должности | +| `StoreStaffing[count]` | float | Да | Количество ставок | + +**Возвращает:** +- Success: `Response` — redirect to `view` +- Error: `string` — HTML форма с ошибками + +**Flash-сообщение:** "Запись штатного расписания успешно создана." + +**Пример использования:** +```php +// POST /store-staffing/create +$model = new StoreStaffing(); + +if ($this->request->isPost) { + if ($model->load($this->request->post()) && $model->save()) { + Yii::$app->session->setFlash('success', 'Запись штатного расписания успешно создана.'); + return $this->redirect(['view', 'id' => $model->id]); + } +} + +$stores = ArrayHelper::map(CityStore::find()->select(['id', 'name'])->asArray()->all(), 'id', 'name'); +$positions = ArrayHelper::map(EmployeePosition::find()->select(['id', 'name', 'posit'])->asArray()->all(), 'id', 'name'); + +return $this->render('create', [ + 'model' => $model, + 'stores' => $stores, + 'positions' => $positions, +]); +``` + +--- + +### 4. actionUpdate($id) + +**Сигнатура:** +```php +public function actionUpdate(int $id): string|\yii\web\Response +``` + +**Назначение:** Редактирование существующей записи штатного расписания + +**Параметры:** + +| Параметр | Тип | Обязательный | Описание | +|----------|-----|--------------|----------| +| `id` | int | Да | ID записи | +| `StoreStaffing[count]` | float | Нет | Новое количество ставок | + +**Возвращает:** +- Success: `Response` — redirect to `view` +- Error: `string` — HTML форма с ошибками +- Error: `NotFoundHttpException` + +**Flash-сообщение:** "Запись штатного расписания успешно обновлена." + +**Пример HTTP:** +```bash +curl -X POST "https://erp24.example.com/store-staffing/update?id=123" \ + -d "StoreStaffing[count]=3.5" +``` + +--- + +### 5. actionDelete($id) + +**Сигнатура:** +```php +public function actionDelete(int $id): \yii\web\Response +``` + +**Назначение:** Удаление записи штатного расписания + +**HTTP метод:** POST (только) — ограничено VerbFilter + +**Параметры:** + +| Параметр | Тип | Обязательный | Описание | +|----------|-----|--------------|----------| +| `id` | int | Да | ID записи | + +**Возвращает:** +- Success: `Response` — redirect to `index` +- Error: `NotFoundHttpException` + +**Flash-сообщение:** "Запись штатного расписания успешно удалена." + +**Пример использования:** +```php +// POST /store-staffing/delete?id=123 +public function actionDelete($id) +{ + $this->findModel($id)->delete(); + Yii::$app->session->setFlash('success', 'Запись штатного расписания успешно удалена.'); + + return $this->redirect(['index']); +} +``` + +**Важно:** VerbFilter запрещает GET/DELETE запросы: +```php +'verbs' => [ + 'class' => VerbFilter::className(), + 'actions' => [ + 'delete' => ['POST'], + ], +], +``` + +--- + +### 6. actionImport() + +**Сигнатура:** +```php +public function actionImport(): string|\yii\web\Response +``` + +**Назначение:** Массовый импорт штатного расписания из файла Excel + +**HTTP метод:** GET (форма), POST (обработка) + +**Параметры (POST):** + +| Параметр | Тип | Обязательный | Описание | +|----------|-----|--------------|----------| +| `DynamicModel[excelFile]` | UploadedFile | Да | Excel файл (.xls, .xlsx) | + +**Формат Excel файла:** + +| Колонка | Название | Тип | Описание | +|---------|----------|-----|----------| +| A | `store_name` | string | Название магазина (точное совпадение) | +| B | `position_name` | string | Название должности (умный поиск) | +| C | `posit` | int | Грейд должности | +| D | `count` | float | Количество ставок (> 0) | + +**Возвращает:** +- Success: `Response` — redirect to `index` с flash +- Error: `string` — HTML форма + +**Flash-сообщения:** +- Success: "Импорт завершен. Создано: X, обновлено: Y." +- Warning: "Обнаружены ошибки: ..." (первые 5 ошибок) +- Error: "Ошибка при обработке файла: ..." + +**Валидация строки:** + +```php +// Обязательные поля +if (empty($storeName)) { + $results['errors'][] = "Строка $rowNum: название магазина пусто."; +} + +if (empty($positionName)) { + $results['errors'][] = "Строка $rowNum: название должности пусто."; +} + +if ($count <= 0) { + $results['errors'][] = "Строка $rowNum: количество должно быть больше 0."; +} + +// Поиск магазина (точное совпадение) +if (!isset($storesMap[$storeName])) { + $results['errors'][] = "Строка $rowNum: магазин '$storeName' не найден."; +} + +// Умный поиск должности (см. findPositionByName) +$positionId = $this->findPositionByName($positionName, $allPositions); +if ($positionId === null) { + $results['errors'][] = "Строка $rowNum: должность '$positionName' не найдена."; +} + +// Проверка соответствия грейда +if ($posit != $actualPosit) { + $results['errors'][] = "Строка $rowNum: грейд должности '$positionName' должен быть $actualPosit, а не $posit."; +} +``` + +**Логика обработки:** + +1. Поиск существующей записи по `(store_id, employee_position_id)` +2. Если найдена: + - Проверить изменение `count` + - Если изменился → UPDATE → `results['updated']++` + - Если не изменился → пропуск +3. Если не найдена: + - INSERT → `results['created']++` + +**Пример использования:** + +```javascript +// HTML форма +
+ + +
+``` + +**Алгоритм умного поиска должности:** + +```php +protected function findPositionByName($positionName, $allPositions) +{ + $positionName = trim($positionName); + + // 1. Точное совпадение (без учёта регистра) + foreach ($allPositions as $position) { + if (strcasecmp($position['name'], $positionName) === 0) { + return $position['id']; + } + } + + // 2. Поиск по вхождению с расчётом схожести + $candidates = []; + foreach ($allPositions as $position) { + $dbName = $position['name']; + + $fileContainsDb = stripos($positionName, $dbName) !== false; + $dbContainsFile = stripos($dbName, $positionName) !== false; + + if ($dbContainsFile || $fileContainsDb) { + $candidates[] = [ + 'id' => $position['id'], + 'similarity' => similar_text($positionName, $dbName, $percent), + 'percent' => $percent + ]; + } + } + + if (empty($candidates)) { + return null; + } + + // Сортировка по проценту схожести + usort($candidates, function($a, $b) { + return $b['percent'] <=> $a['percent']; + }); + + return $candidates[0]['id']; +} +``` + +**Примеры умного поиска:** + +| Файл | БД | Результат | Схожесть | +|------|-----|-----------|----------| +| "Флорист" | ["Флорист", "Старший флорист"] | Флорист | 100% | +| "Старший флорист" | ["Флорист", "Старший флорист"] | Старший флорист | 100% | +| "флорист" | ["Флорист"] | Флорист | 100% (case-insensitive) | +| "Senior Florist" | ["Старший флорист"] | null | Не найдено | + +--- + +### 7. actionExportTemplate() + +**Сигнатура:** +```php +public function actionExportTemplate(): \yii\web\Response +``` + +**Назначение:** Скачать шаблон Excel для импорта штатного расписания + +**HTTP метод:** GET + +**Параметры:** Нет + +**Возвращает:** `Response` — файл `store_staffing_template.xlsx` + +**Структура шаблона:** + +**Лист 1: "Инструкция"** +``` +┌────────────────────────────────────────────────────┐ +│ ИНСТРУКЦИЯ ПО ИМПОРТУ ШТАТНОГО РАСПИСАНИЯ │ +├────────────────────────────────────────────────────┤ +│ Формат файла для импорта: │ +│ │ +│ Колонка A (store_name): Название магазина │ +│ Колонка B (position_name): Название должности │ +│ Колонка C (posit): Грейд должности │ +│ Колонка D (count): Количество сотрудников │ +└────────────────────────────────────────────────────┘ +``` + +**Лист 2: "Данные"** +``` +┌───────────────┬───────────────┬───────┬───────┐ +│ store_name │ position_name │ posit │ count │ +├───────────────┼───────────────┼───────┼───────┤ +│ Магазин 1 │ Флорист │ 5 │ │ +│ Магазин 1 │ Старший флор. │ 7 │ │ +│ Магазин 1 │ Администратор │ 6 │ │ +│ Магазин 2 │ Флорист │ 5 │ │ +│ ... │ ... │ ... │ │ +└───────────────┴───────────────┴───────┴───────┘ +``` + +**Особенности:** + +1. **Автоматическое заполнение:** + - Все магазины (где `visible = 1`) + - Все должности + - Грейды (`posit`) из БД + - Колонка `count` пустая для ручного заполнения + +2. **Форматирование:** + - Заголовки: жирный, белый текст, фон #366092 + - Ширина колонок: A=30, B=30, C=10, D=10 + +3. **Количество строк:** + - `количество магазинов × количество должностей` + - Пример: 50 магазинов × 20 должностей = 1000 строк + +**Пример использования:** + +```bash +# Скачать шаблон +curl -X GET "https://erp24.example.com/store-staffing/export-template" \ + -o template.xlsx +``` + +**Код генерации:** + +```php +public function actionExportTemplate() +{ + $spreadsheet = new \PhpOffice\PhpSpreadsheet\Spreadsheet(); + + // Лист 1: Инструкция + $sheet1 = $spreadsheet->getActiveSheet(); + $sheet1->setTitle('Инструкция'); + $sheet1->setCellValue('A1', 'ИНСТРУКЦИЯ ПО ИМПОРТУ ШТАТНОГО РАСПИСАНИЯ'); + $sheet1->mergeCells('A1:D1'); + $sheet1->getStyle('A1')->getFont()->setBold(true)->setSize(14); + + // ... описание колонок + + // Лист 2: Данные + $sheet2 = $spreadsheet->createSheet(); + $sheet2->setTitle('Данные'); + + // Заголовки + $sheet2->setCellValue('A1', 'store_name'); + $sheet2->setCellValue('B1', 'position_name'); + $sheet2->setCellValue('C1', 'posit'); + $sheet2->setCellValue('D1', 'count'); + + // Форматирование заголовков + $sheet2->getStyle('A1:D1')->getFont()->setBold(true)->setColor(new \PhpOffice\PhpSpreadsheet\Style\Color('FFFFFF')); + $sheet2->getStyle('A1:D1')->getFill()->setFillType('solid')->getStartColor()->setARGB('FF366092'); + + // Заполнение комбинаций + $stores = CityStore::find()->where(['visible' => CityStore::IS_VISIBLE])->orderBy('name')->all(); + $positions = EmployeePosition::find()->orderBy('name')->all(); + + $row = 2; + foreach ($stores as $store) { + foreach ($positions as $position) { + $sheet2->setCellValue('A' . $row, $store->name); + $sheet2->setCellValue('B' . $row, $position->name); + $sheet2->setCellValue('C' . $row, $position->posit); + $sheet2->setCellValue('D' . $row, ''); // Пустое для заполнения + $row++; + } + } + + // Сохранить и отправить + $writer = new \PhpOffice\PhpSpreadsheet\Writer\Xlsx($spreadsheet); + $tempFile = Yii::getAlias('@runtime') . '/store_staffing_template_' . time() . '.xlsx'; + $writer->save($tempFile); + + $response = Yii::$app->response->sendFile($tempFile, 'store_staffing_template.xlsx'); + @unlink($tempFile); + + return $response; +} +``` + +--- + +### 8. actionLogs($store_id) + +**Сигнатура:** +```php +public function actionLogs(?int $store_id = null): string +``` + +**Назначение:** Просмотр логов изменений штатного расписания + +**HTTP метод:** GET + +**Параметры:** + +| Параметр | Тип | Обязательный | Описание | +|----------|-----|--------------|----------| +| `store_id` | int | Нет | Фильтр по магазину | + +**Возвращает:** `string` — HTML страница с таблицей логов + +**Настройки DataProvider:** +- Сортировка: `created_at DESC` +- Пагинация: 100 записей на страницу + +**Пример HTTP:** +```bash +# Все логи +curl -X GET "https://erp24.example.com/store-staffing/logs" + +# Логи для магазина ID=5 +curl -X GET "https://erp24.example.com/store-staffing/logs?store_id=5" +``` + +**Пример использования:** + +```php +public function actionLogs($store_id = null) +{ + $query = StoreStaffingLog::find(); + + if ($store_id !== null) { + $query->where(['store_id' => $store_id]); + } + + $dataProvider = new ActiveDataProvider([ + 'query' => $query, + 'sort' => [ + 'defaultOrder' => [ + 'created_at' => SORT_DESC, + ] + ], + 'pagination' => [ + 'pageSize' => 100, + ] + ]); + + $stores = ArrayHelper::map(CityStore::find()->select(['id', 'name'])->asArray()->all(), 'id', 'name'); + + return $this->render('logs', [ + 'dataProvider' => $dataProvider, + 'stores' => $stores, + 'selectedStore' => $store_id, + ]); +} +``` + +**Структура данных лога (StoreStaffingLog):** + +| Поле | Описание | +|------|----------| +| `id` | ID записи лога | +| `store_id` | ID магазина | +| `employee_position_id` | ID должности | +| `old_count` | Старое значение count | +| `new_count` | Новое значение count | +| `action` | Действие (create, update, delete) | +| `created_at` | Дата изменения | +| `created_by` | Пользователь | + +--- + +### 9. actionGetPositionPosit($position_id) + +**Сигнатура:** +```php +public function actionGetPositionPosit(int $position_id): mixed +``` + +**Назначение:** AJAX-получение грейда должности по ID (для автозаполнения формы) + +**HTTP метод:** GET + +**Параметры:** + +| Параметр | Тип | Обязательный | Описание | +|----------|-----|--------------|----------| +| `position_id` | int | Да | ID должности | + +**Возвращает:** JSON + +**Успешный ответ:** +```json +{ + "posit": 5 +} +``` + +**Ошибка (должность не найдена):** +```json +{ + "posit": null +} +``` + +**Пример использования (JavaScript):** + +```javascript +// При изменении должности в select +$('#storestaffing-employee_position_id').change(function() { + const positionId = $(this).val(); + + $.ajax({ + url: '/store-staffing/get-position-posit', + type: 'GET', + data: { position_id: positionId }, + dataType: 'json', + success: function(response) { + if (response.posit !== null) { + $('#storestaffing-posit').val(response.posit); + $('#storestaffing-posit').attr('readonly', true); + } else { + $('#storestaffing-posit').val(''); + $('#storestaffing-posit').attr('readonly', false); + } + } + }); +}); +``` + +**Пример PHP:** + +```php +public function actionGetPositionPosit($position_id) +{ + $position = EmployeePosition::findOne(['id' => $position_id]); + + if ($position) { + return $this->asJson(['posit' => $position->posit]); + } + + return $this->asJson(['posit' => null]); +} +``` + +**Пример HTTP:** +```bash +curl -X GET "https://erp24.example.com/store-staffing/get-position-posit?position_id=15" +# Ответ: {"posit":5} +``` + +--- + +## Сводная таблица HTTP методов + +| Action | GET | POST | DELETE | PUT | +|--------|-----|------|--------|-----| +| actionIndex | ✅ | ❌ | ❌ | ❌ | +| actionView | ✅ | ❌ | ❌ | ❌ | +| actionCreate | ✅ (форма) | ✅ (создание) | ❌ | ❌ | +| actionUpdate | ✅ (форма) | ✅ (обновление) | ❌ | ❌ | +| actionDelete | ❌ | ✅ | ❌ | ❌ | +| actionImport | ✅ (форма) | ✅ (импорт) | ❌ | ❌ | +| actionExportTemplate | ✅ | ❌ | ❌ | ❌ | +| actionLogs | ✅ | ❌ | ❌ | ❌ | +| actionGetPositionPosit | ✅ | ❌ | ❌ | ❌ | + +--- + +## VerbFilter конфигурация + +```php +public function behaviors() +{ + return array_merge( + parent::behaviors(), + [ + 'verbs' => [ + 'class' => VerbFilter::className(), + 'actions' => [ + 'delete' => ['POST'], // Только POST + 'import' => ['POST', 'GET'], // POST и GET + ], + ], + ] + ); +} +``` + +--- + +## Используемые модели + +| Модель | Таблица | Назначение | +|--------|---------|------------| +| `StoreStaffing` | `store_staffing` | Штатное расписание | +| `StoreStaffingSearch` | - | Поиск и фильтрация | +| `StoreStaffingLog` | `store_staffing_log` | Логи изменений | +| `CityStore` | `city_store` | Справочник магазинов | +| `EmployeePosition` | `employee_position` | Справочник должностей | +| `DynamicModel` | - | Валидация загружаемого файла | + +--- + +## Библиотеки и хелперы + +| Компонент | Назначение | +|-----------|------------| +| `PhpSpreadsheet` | Чтение и создание Excel файлов | +| `UploadedFile` | Загрузка файлов | +| `ArrayHelper` | Работа с массивами (map) | +| `VerbFilter` | Ограничение HTTP методов | +| `ActiveDataProvider` | Предоставление данных для GridView | + +--- + +## Примеры CURL запросов + +### Получить список записей +```bash +curl -X GET "https://erp24.example.com/store-staffing/index" +``` + +### Создать запись +```bash +curl -X POST "https://erp24.example.com/store-staffing/create" \ + -d "StoreStaffing[store_id]=5" \ + -d "StoreStaffing[employee_position_id]=10" \ + -d "StoreStaffing[count]=2.5" +``` + +### Обновить запись +```bash +curl -X POST "https://erp24.example.com/store-staffing/update?id=123" \ + -d "StoreStaffing[count]=3.0" +``` + +### Удалить запись +```bash +curl -X POST "https://erp24.example.com/store-staffing/delete?id=123" +``` + +### Скачать шаблон +```bash +curl -X GET "https://erp24.example.com/store-staffing/export-template" \ + -o template.xlsx +``` + +### Получить грейд должности (AJAX) +```bash +curl -X GET "https://erp24.example.com/store-staffing/get-position-posit?position_id=15" +``` + +### Просмотреть логи +```bash +curl -X GET "https://erp24.example.com/store-staffing/logs?store_id=5" +``` + +--- + +## Связанные файлы + +- [Детальный анализ](./StoreStaffingController_ANALYSIS.md) +- [Краткая справка](./StoreStaffingController_QUICK_REFERENCE.md) +- [README нестандартных контроллеров](./README.md) + +--- + +**Документация создана:** 2025-11-26 +**Статус:** ✅ Готово diff --git a/erp24/docs/controllers/non-standard/StoreStaffingController_ANALYSIS.md b/erp24/docs/controllers/non-standard/StoreStaffingController_ANALYSIS.md new file mode 100644 index 00000000..a056542d --- /dev/null +++ b/erp24/docs/controllers/non-standard/StoreStaffingController_ANALYSIS.md @@ -0,0 +1,1082 @@ +# StoreStaffingController - Детальный анализ + +## 📋 Общая информация + +**Namespace:** `app\controllers` +**Путь к файлу:** `erp24/controllers/StoreStaffingController.php` +**Extends:** `\yii\web\Controller` +**Размер:** 516 строк кода +**Количество actions:** 9 +**Категория:** Крупный контроллер +**Приоритет:** HIGH (Фаза 2) + +--- + +## 🎯 Назначение + +Контроллер для управления **штатным расписанием магазинов**. Реализует полный цикл CRUD-операций (Create, Read, Update, Delete) с расширенными возможностями: + +- Просмотр, создание, редактирование и удаление записей штатного расписания +- **Массовый импорт из Excel** (с валидацией и умным поиском должностей) +- **Экспорт шаблона Excel** с инструкцией и предзаполненными данными +- **Логирование изменений** (история изменений штатного расписания) +- **AJAX API** для получения грейда должности + +Контроллер работает с тремя основными сущностями: +- Магазины (`CityStore`) +- Должности (`EmployeePosition`) +- Штатное расписание (`StoreStaffing`) + +--- + +## 🏗️ Архитектура + +### Основные компоненты + +```mermaid +graph TB + Controller[StoreStaffingController] + + subgraph "CRUD Actions" + A1[actionIndex
Список записей] + A2[actionView
Просмотр записи] + A3[actionCreate
Создание записи] + A4[actionUpdate
Редактирование записи] + A5[actionDelete
Удаление записи] + end + + subgraph "Excel Operations" + A6[actionImport
Импорт из Excel] + A7[actionExportTemplate
Экспорт шаблона] + A8[processImportFile
Обработка файла] + A9[findPositionByName
Умный поиск должности] + end + + subgraph "Additional Actions" + A10[actionLogs
Просмотр логов] + A11[actionGetPositionPosit
AJAX: грейд должности] + end + + subgraph "Models" + M1[StoreStaffing
Штатное расписание] + M2[CityStore
Магазины] + M3[EmployeePosition
Должности] + M4[StoreStaffingLog
Логи изменений] + M5[StoreStaffingSearch
Поиск] + end + + subgraph "Libraries" + L1[PhpSpreadsheet
Работа с Excel] + L2[UploadedFile
Загрузка файлов] + end + + Controller --> A1 + Controller --> A2 + Controller --> A3 + Controller --> A4 + Controller --> A5 + Controller --> A6 + Controller --> A7 + Controller --> A10 + Controller --> A11 + + A6 --> A8 + A8 --> A9 + A6 --> L2 + A7 --> L1 + A8 --> L1 + + A1 --> M5 + A2 --> M1 + A3 --> M1 + A4 --> M1 + A5 --> M1 + A8 --> M1 + A10 --> M4 + A11 --> M3 + + M1 --> M2 + M1 --> M3 + M5 --> M1 +``` + +### Зависимости + +| Компонент | Тип | Назначение | +|-----------|-----|------------| +| `StoreStaffing` | Модель | Штатное расписание (связь магазин-должность-количество) | +| `CityStore` | Модель | Справочник магазинов | +| `EmployeePosition` | Модель | Справочник должностей | +| `StoreStaffingLog` | Модель | Логирование изменений | +| `StoreStaffingSearch` | Модель | Поиск и фильтрация записей | +| `PhpSpreadsheet` | Библиотека | Чтение и создание Excel файлов | +| `UploadedFile` | Helper | Загрузка файлов | +| `ArrayHelper` | Helper | Работа с массивами | +| `VerbFilter` | Filter | Ограничение HTTP методов | + +--- + +## 📋 Actions + +### 1. actionIndex() + +**Назначение:** Отображение списка всех записей штатного расписания с фильтрацией + +**HTTP метод:** GET +**Маршрут:** `/store-staffing/index` + +**Параметры (GET):** + +| Параметр | Тип | Обязательный | Описание | +|----------|-----|--------------|----------| +| `StoreStaffingSearch[store_id]` | int | Нет | Фильтр по магазину | +| `StoreStaffingSearch[employee_position_id]` | int | Нет | Фильтр по должности | + +**Возвращает:** +- `string` — HTML страница со списком + +**Бизнес-логика:** + +1. Создать модель поиска `StoreStaffingSearch` +2. Выполнить поиск с фильтрами из GET параметров +3. Получить список всех магазинов для фильтра +4. Отрендерить представление + +**Последовательность выполнения:** + +```mermaid +sequenceDiagram + participant User + participant Controller + participant StoreStaffingSearch + participant CityStore + participant View + + User->>Controller: GET /store-staffing/index + Controller->>StoreStaffingSearch: new StoreStaffingSearch() + Controller->>StoreStaffingSearch: search(queryParams) + StoreStaffingSearch-->>Controller: ActiveDataProvider + + Controller->>CityStore: find()->select(['id', 'name']) + CityStore-->>Controller: Массив магазинов + + Controller->>View: render('index') + View-->>User: HTML таблица записей +``` + +**Пример использования:** + +```php +// GET /store-staffing/index?StoreStaffingSearch[store_id]=5 +public function actionIndex() +{ + $searchModel = new StoreStaffingSearch(); + $dataProvider = $searchModel->search($this->request->queryParams); + + $stores = ArrayHelper::map(CityStore::find()->select(['id', 'name'])->asArray()->all(), 'id', 'name'); + + return $this->render('index', [ + 'searchModel' => $searchModel, + 'dataProvider' => $dataProvider, + 'stores' => $stores, + ]); +} +``` + +--- + +### 2. actionView($id) + +**Назначение:** Просмотр детальной информации о записи штатного расписания + +**HTTP метод:** GET +**Маршрут:** `/store-staffing/view` + +**Параметры:** + +| Параметр | Тип | Обязательный | Описание | +|----------|-----|--------------|----------| +| `id` | int | Да | ID записи | + +**Возвращает:** +- Success: `string` — HTML страница +- Error: `NotFoundHttpException` — Запись не найдена + +**Пример использования:** + +```bash +curl -X GET "https://erp24.example.com/store-staffing/view?id=123" +``` + +--- + +### 3. actionCreate() + +**Назначение:** Создание новой записи штатного расписания + +**HTTP метод:** GET, POST +**Маршрут:** `/store-staffing/create` + +**Параметры (POST):** + +| Параметр | Тип | Обязательный | Описание | +|----------|-----|--------------|----------| +| `StoreStaffing[store_id]` | int | Да | ID магазина | +| `StoreStaffing[employee_position_id]` | int | Да | ID должности | +| `StoreStaffing[count]` | float | Да | Количество ставок | + +**Возвращает:** +- Success: `Response` — редирект на `view` +- Error: `string` — HTML форма с ошибками + +**Бизнес-логика:** + +1. Создать новую модель `StoreStaffing` +2. Если POST запрос: + - Загрузить данные из формы + - Валидация + - Сохранить запись + - Показать flash-сообщение "успешно создана" + - Редирект на просмотр +3. Получить списки магазинов и должностей для select +4. Отрендерить форму + +**Последовательность выполнения:** + +```mermaid +sequenceDiagram + participant User + participant Controller + participant StoreStaffing + participant Database + + User->>Controller: POST /store-staffing/create + Controller->>StoreStaffing: new StoreStaffing() + Controller->>StoreStaffing: load(POST data) + StoreStaffing->>StoreStaffing: validate() + + alt Валидация успешна + StoreStaffing->>Database: INSERT + Database-->>StoreStaffing: Success + Controller->>Controller: setFlash('success') + Controller-->>User: Redirect to view + else Ошибка валидации + Controller-->>User: Форма с ошибками + end +``` + +**Пример использования:** + +```php +// POST /store-staffing/create +$model = new StoreStaffing(); + +if ($this->request->isPost) { + if ($model->load($this->request->post()) && $model->save()) { + Yii::$app->session->setFlash('success', 'Запись штатного расписания успешно создана.'); + return $this->redirect(['view', 'id' => $model->id]); + } +} + +return $this->render('create', ['model' => $model]); +``` + +--- + +### 4. actionUpdate($id) + +**Назначение:** Редактирование существующей записи штатного расписания + +**HTTP метод:** GET, POST +**Маршрут:** `/store-staffing/update` + +**Параметры:** + +| Параметр | Тип | Обязательный | Описание | +|----------|-----|--------------|----------| +| `id` | int | Да | ID записи | +| `StoreStaffing[count]` | float | Нет | Новое количество ставок | + +**Возвращает:** +- Success: `Response` — редирект на `view` +- Error: `string` — HTML форма с ошибками +- Error: `NotFoundHttpException` — Запись не найдена + +**Бизнес-логика:** Аналогично `actionCreate()`, но с загрузкой существующей модели + +**Пример использования:** + +```php +// POST /store-staffing/update?id=123 +$model = $this->findModel($id); + +if ($this->request->isPost && $model->load($this->request->post()) && $model->save()) { + Yii::$app->session->setFlash('success', 'Запись штатного расписания успешно обновлена.'); + return $this->redirect(['view', 'id' => $model->id]); +} + +return $this->render('update', ['model' => $model]); +``` + +--- + +### 5. actionDelete($id) + +**Назначение:** Удаление записи штатного расписания + +**HTTP метод:** POST (только) +**Маршрут:** `/store-staffing/delete` + +**Параметры:** + +| Параметр | Тип | Обязательный | Описание | +|----------|-----|--------------|----------| +| `id` | int | Да | ID записи | + +**Возвращает:** +- Success: `Response` — редирект на `index` +- Error: `NotFoundHttpException` — Запись не найдена + +**Ограничения:** VerbFilter разрешает только POST запросы + +**Последовательность выполнения:** + +```mermaid +sequenceDiagram + participant User + participant Controller + participant StoreStaffing + participant Database + + User->>Controller: POST /store-staffing/delete?id=123 + Controller->>StoreStaffing: findOne(['id' => 123]) + + alt Запись найдена + StoreStaffing->>Database: DELETE + Database-->>Controller: Success + Controller->>Controller: setFlash('success') + Controller-->>User: Redirect to index + else Запись не найдена + Controller-->>User: NotFoundHttpException + end +``` + +**Пример использования:** + +```php +// POST /store-staffing/delete?id=123 +public function actionDelete($id) +{ + $this->findModel($id)->delete(); + Yii::$app->session->setFlash('success', 'Запись штатного расписания успешно удалена.'); + + return $this->redirect(['index']); +} +``` + +--- + +### 6. actionImport() + +**Назначение:** Массовый импорт штатного расписания из файла Excel + +**HTTP метод:** GET, POST +**Маршрут:** `/store-staffing/import` + +**Параметры (POST):** + +| Параметр | Тип | Обязательный | Описание | +|----------|-----|--------------|----------| +| `DynamicModel[excelFile]` | UploadedFile | Да | Excel файл (.xls, .xlsx) | + +**Формат Excel файла:** + +| Колонка | Название | Тип | Описание | +|---------|----------|-----|----------| +| A | `store_name` | string | Название магазина (должно совпадать с БД) | +| B | `position_name` | string | Название должности (поиск с умным алгоритмом) | +| C | `posit` | int | Грейд должности (уровень обученности) | +| D | `count` | float | Количество ставок | + +**Возвращает:** +- Success: `Response` — редирект на `index` с flash-сообщением +- Error: `string` — HTML форма с ошибками + +**Бизнес-логика:** + +1. Создать динамическую модель для загрузки файла +2. Валидация файла (расширение xls/xlsx) +3. Сохранить файл во временную директорию +4. Обработать файл через `processImportFile()`: + - Чтение всех строк Excel + - Валидация каждой строки + - Поиск магазина по названию + - **Умный поиск должности** по названию (точное или частичное совпадение) + - Проверка соответствия грейда + - Создание новой записи или обновление существующей + - Накопление статистики: создано, обновлено, ошибки +5. Показать результат импорта в flash-сообщении +6. Удалить временный файл +7. Редирект на список + +**Последовательность выполнения:** + +```mermaid +sequenceDiagram + participant User + participant Controller + participant UploadedFile + participant ProcessImportFile + participant ExcelReader + participant Database + + User->>Controller: POST /store-staffing/import (файл) + Controller->>UploadedFile: Валидация файла + UploadedFile-->>Controller: Success + + Controller->>Controller: Сохранить в @runtime + Controller->>ProcessImportFile: processImportFile(path) + + ProcessImportFile->>ExcelReader: IOFactory::load() + ExcelReader-->>ProcessImportFile: Rows + + loop Каждая строка + ProcessImportFile->>ProcessImportFile: Валидация данных + ProcessImportFile->>ProcessImportFile: Поиск магазина + ProcessImportFile->>ProcessImportFile: Умный поиск должности + ProcessImportFile->>Database: INSERT/UPDATE + end + + ProcessImportFile-->>Controller: {created, updated, errors} + Controller->>Controller: setFlash(статистика) + Controller->>Controller: Удалить временный файл + Controller-->>User: Redirect to index +``` + +**Алгоритм умного поиска должности (findPositionByName):** + +1. **Поиск точного совпадения** (strcasecmp) + ```php + if (strcasecmp($dbName, $fileName) === 0) { + return $positionId; + } + ``` + +2. **Поиск по вхождению** (stripos) в обоих направлениях: + - Файл содержит БД: `stripos($fileName, $dbName) !== false` + - БД содержит файл: `stripos($dbName, $fileName) !== false` + +3. **Расчёт схожести** (similar_text) для всех кандидатов + +4. **Сортировка по проценту схожести** и выбор лучшего варианта + +**Пример:** +- Файл: "Старший флорист" +- БД: ["Флорист", "Старший флорист", "Младший флорист"] +- Результат: "Старший флорист" (100% схожесть) + +**Пример использования:** + +```php +// POST /store-staffing/import +$model = new DynamicModel(['excelFile']); +$model->addRule('excelFile', 'file', ['extensions' => ['xls', 'xlsx'], 'skipOnEmpty' => false]); + +if (Yii::$app->request->isPost) { + $model->excelFile = UploadedFile::getInstance($model, 'excelFile'); + + if ($model->validate()) { + $filePath = Yii::getAlias('@runtime') . '/import_' . uniqid() . '.' . $model->excelFile->extension; + $model->excelFile->saveAs($filePath); + + try { + $results = $this->processImportFile($filePath); + Yii::$app->session->setFlash('success', + "Импорт завершен. Создано: {$results['created']}, обновлено: {$results['updated']}." + ); + } catch (\Exception $e) { + Yii::$app->session->setFlash('error', 'Ошибка: ' . $e->getMessage()); + } + + return $this->redirect(['index']); + } +} + +return $this->render('import', ['model' => $model]); +``` + +**Валидация строки импорта:** + +```php +// Обязательные поля +if (empty($storeName)) { + $results['errors'][] = "Строка $rowNum: название магазина пусто."; +} + +if (empty($positionName)) { + $results['errors'][] = "Строка $rowNum: название должности пусто."; +} + +if ($count <= 0) { + $results['errors'][] = "Строка $rowNum: количество должно быть больше 0."; +} + +// Поиск магазина +if (!isset($storesMap[$storeName])) { + $results['errors'][] = "Строка $rowNum: магазин '$storeName' не найден."; +} + +// Умный поиск должности +$positionId = $this->findPositionByName($positionName, $allPositions); +if ($positionId === null) { + $results['errors'][] = "Строка $rowNum: должность '$positionName' не найдена."; +} + +// Проверка соответствия грейда +if ($posit != $actualPosit) { + $results['errors'][] = "Строка $rowNum: грейд должности '$positionName' должен быть $actualPosit, а не $posit."; +} +``` + +--- + +### 7. actionExportTemplate() + +**Назначение:** Скачать шаблон Excel для импорта штатного расписания + +**HTTP метод:** GET +**Маршрут:** `/store-staffing/export-template` + +**Параметры:** Нет + +**Возвращает:** +- `Response` — файл `store_staffing_template.xlsx` + +**Структура шаблона:** + +**Лист 1: "Инструкция"** +- Заголовок: "ИНСТРУКЦИЯ ПО ИМПОРТУ ШТАТНОГО РАСПИСАНИЯ" +- Описание колонок: + - A (store_name): Название магазина (должно совпадать) + - B (position_name): Название должности (должно совпадать) + - C (posit): Грейд должности (уровень обученности) + - D (count): Количество сотрудников + +**Лист 2: "Данные"** +- Заголовки: `store_name`, `position_name`, `posit`, `count` +- **Автоматическое заполнение комбинаций:** + - Все магазины (visible = 1) + - Все должности + - Грейд из БД (posit) + - Колонка `count` пустая (для ручного заполнения) + +**Форматирование:** +- Заголовки: жирный, белый цвет, фон #366092 +- Ширина колонок: A=30, B=30, C=10, D=10 + +**Бизнес-логика:** + +1. Создать Spreadsheet +2. Лист 1: Инструкция + - Заголовок с форматированием + - Описание каждой колонки +3. Лист 2: Данные + - Заголовки с форматированием + - Получить все магазины (visible = 1) + - Получить все должности + - Заполнить все комбинации (магазин × должность) + - Автоматически проставить грейды (posit) из БД +4. Сохранить в временный файл +5. Отправить файл пользователю +6. Удалить временный файл + +**Последовательность выполнения:** + +```mermaid +sequenceDiagram + participant User + participant Controller + participant Spreadsheet + participant CityStore + participant EmployeePosition + participant FileSystem + + User->>Controller: GET /store-staffing/export-template + Controller->>Spreadsheet: new Spreadsheet() + + Controller->>Spreadsheet: Создать лист "Инструкция" + Controller->>Spreadsheet: Заполнить описание + + Controller->>Spreadsheet: Создать лист "Данные" + Controller->>CityStore: Получить все магазины + CityStore-->>Controller: Массив магазинов + Controller->>EmployeePosition: Получить все должности + EmployeePosition-->>Controller: Массив должностей + + loop Магазин × Должность + Controller->>Spreadsheet: Заполнить строку + end + + Controller->>Spreadsheet: Форматирование + Controller->>FileSystem: Сохранить во временный файл + Controller-->>User: Отправить файл + Controller->>FileSystem: Удалить временный файл +``` + +**Пример использования:** + +```php +// GET /store-staffing/export-template +public function actionExportTemplate() +{ + $spreadsheet = new \PhpOffice\PhpSpreadsheet\Spreadsheet(); + + // Лист 1: Инструкция + $sheet1 = $spreadsheet->getActiveSheet(); + $sheet1->setTitle('Инструкция'); + $sheet1->setCellValue('A1', 'ИНСТРУКЦИЯ ПО ИМПОРТУ ШТАТНОГО РАСПИСАНИЯ'); + // ... описание колонок + + // Лист 2: Данные + $sheet2 = $spreadsheet->createSheet(); + $sheet2->setTitle('Данные'); + $sheet2->setCellValue('A1', 'store_name'); + $sheet2->setCellValue('B1', 'position_name'); + $sheet2->setCellValue('C1', 'posit'); + $sheet2->setCellValue('D1', 'count'); + + // Заполнение данных + $stores = CityStore::find()->where(['visible' => 1])->orderBy('name')->all(); + $positions = EmployeePosition::find()->orderBy('name')->all(); + + $row = 2; + foreach ($stores as $store) { + foreach ($positions as $position) { + $sheet2->setCellValue('A' . $row, $store->name); + $sheet2->setCellValue('B' . $row, $position->name); + $sheet2->setCellValue('C' . $row, $position->posit); + $sheet2->setCellValue('D' . $row, ''); // Пустое для заполнения + $row++; + } + } + + // Сохранить и отправить + $writer = new \PhpOffice\PhpSpreadsheet\Writer\Xlsx($spreadsheet); + $tempFile = Yii::getAlias('@runtime') . '/store_staffing_template_' . time() . '.xlsx'; + $writer->save($tempFile); + + $response = Yii::$app->response->sendFile($tempFile, 'store_staffing_template.xlsx'); + @unlink($tempFile); + + return $response; +} +``` + +--- + +### 8. actionLogs($store_id) + +**Назначение:** Просмотр логов изменений штатного расписания + +**HTTP метод:** GET +**Маршрут:** `/store-staffing/logs` + +**Параметры:** + +| Параметр | Тип | Обязательный | Описание | +|----------|-----|--------------|----------| +| `store_id` | int | Нет | Фильтр по магазину | + +**Возвращает:** +- `string` — HTML страница с таблицей логов + +**Бизнес-логика:** + +1. Создать запрос `StoreStaffingLog` +2. Если указан `store_id` — применить фильтр +3. Создать ActiveDataProvider с: + - Сортировка по `created_at DESC` + - Пагинация 100 записей на страницу +4. Получить список магазинов для фильтра +5. Отрендерить представление + +**Последовательность выполнения:** + +```mermaid +sequenceDiagram + participant User + participant Controller + participant StoreStaffingLog + participant CityStore + participant View + + User->>Controller: GET /store-staffing/logs?store_id=5 + Controller->>StoreStaffingLog: find() + + alt store_id указан + Controller->>StoreStaffingLog: where(['store_id' => 5]) + end + + Controller->>StoreStaffingLog: orderBy(['created_at' => DESC]) + StoreStaffingLog-->>Controller: ActiveDataProvider + + Controller->>CityStore: find()->select(['id', 'name']) + CityStore-->>Controller: Массив магазинов + + Controller->>View: render('logs') + View-->>User: HTML таблица логов +``` + +**Пример использования:** + +```php +// GET /store-staffing/logs?store_id=5 +public function actionLogs($store_id = null) +{ + $query = StoreStaffingLog::find(); + + if ($store_id !== null) { + $query->where(['store_id' => $store_id]); + } + + $dataProvider = new ActiveDataProvider([ + 'query' => $query, + 'sort' => [ + 'defaultOrder' => [ + 'created_at' => SORT_DESC, + ] + ], + 'pagination' => [ + 'pageSize' => 100, + ] + ]); + + $stores = ArrayHelper::map(CityStore::find()->select(['id', 'name'])->asArray()->all(), 'id', 'name'); + + return $this->render('logs', [ + 'dataProvider' => $dataProvider, + 'stores' => $stores, + 'selectedStore' => $store_id, + ]); +} +``` + +--- + +### 9. actionGetPositionPosit($position_id) + +**Назначение:** AJAX-получение грейда должности по ID + +**HTTP метод:** GET +**Маршрут:** `/store-staffing/get-position-posit` + +**Параметры:** + +| Параметр | Тип | Обязательный | Описание | +|----------|-----|--------------|----------| +| `position_id` | int | Да | ID должности | + +**Возвращает:** +- Success: `JSON` `{"posit": 5}` +- Error: `JSON` `{"posit": null}` + +**Бизнес-логика:** + +1. Найти должность по ID +2. Если найдена → вернуть `posit` +3. Если не найдена → вернуть `null` + +**Последовательность выполнения:** + +```mermaid +sequenceDiagram + participant Client + participant Controller + participant EmployeePosition + + Client->>Controller: GET /store-staffing/get-position-posit?position_id=15 + Controller->>EmployeePosition: findOne(['id' => 15]) + + alt Должность найдена + EmployeePosition-->>Controller: EmployeePosition model + Controller-->>Client: {"posit": 5} + else Не найдена + Controller-->>Client: {"posit": null} + end +``` + +**Пример использования:** + +```javascript +// JavaScript (для автозаполнения формы) +$('#position-select').change(function() { + const positionId = $(this).val(); + + $.ajax({ + url: '/store-staffing/get-position-posit', + type: 'GET', + data: { position_id: positionId }, + success: function(response) { + if (response.posit !== null) { + $('#posit-input').val(response.posit); + } + } + }); +}); +``` + +**Пример PHP:** + +```php +// GET /store-staffing/get-position-posit?position_id=15 +public function actionGetPositionPosit($position_id) +{ + $position = EmployeePosition::findOne(['id' => $position_id]); + + if ($position) { + return $this->asJson(['posit' => $position->posit]); + } + + return $this->asJson(['posit' => null]); +} +``` + +--- + +## 💾 Работа с данными + +### Используемые модели + +#### StoreStaffing +**Путь:** `yii_app\records\StoreStaffing` +**Таблица БД:** `store_staffing` + +**Основные поля:** +- `id` — ID записи +- `store_id` — ID магазина (FK → city_store.id) +- `employee_position_id` — ID должности (FK → employee_position.id) +- `count` — Количество ставок (float) + +**Связи:** +- `hasOne(CityStore, ['id' => 'store_id'])` — магазин +- `hasOne(EmployeePosition, ['id' => 'employee_position_id'])` — должность + +**Поведения:** +- Возможно логирование изменений через `StoreStaffingLog` + +--- + +#### CityStore +**Путь:** `yii_app\records\CityStore` +**Таблица БД:** `city_store` + +**Основные поля:** +- `id` — ID магазина +- `name` — Название магазина +- `visible` — Видимость (1 = видимый, 0 = скрытый) + +**Константы:** +- `IS_VISIBLE = 1` + +--- + +#### EmployeePosition +**Путь:** `yii_app\records\EmployeePosition` +**Таблица БД:** `employee_position` + +**Основные поля:** +- `id` — ID должности +- `name` — Название должности +- `posit` — Грейд (уровень обученности) + +--- + +#### StoreStaffingLog +**Путь:** `yii_app\records\StoreStaffingLog` +**Таблица БД:** `store_staffing_log` + +**Основные поля:** +- `id` — ID записи лога +- `store_id` — ID магазина +- `employee_position_id` — ID должности +- `old_count` — Старое значение count +- `new_count` — Новое значение count +- `action` — Действие (create, update, delete) +- `created_at` — Дата изменения +- `created_by` — Пользователь, внесший изменение + +--- + +## ⚠️ Обработка ошибок + +### Типичные ошибки импорта + +| Ошибка | Причина | Решение | +|--------|---------|---------| +| "название магазина пусто" | Пустое поле A | Заполнить название магазина | +| "название должности пусто" | Пустое поле B | Заполнить название должности | +| "количество должно быть больше 0" | count <= 0 | Указать положительное число | +| "магазин '...' не найден" | Название не совпадает с БД | Проверить название (точное совпадение) | +| "должность '...' не найдена" | Умный поиск не нашел совпадений | Проверить название должности | +| "грейд должности должен быть X, а не Y" | Несоответствие posit | Исправить грейд в файле | + +### Exception handling + +```php +try { + $results = $this->processImportFile($filePath); + Yii::$app->session->setFlash('success', + "Импорт завершен. Создано: {$results['created']}, обновлено: {$results['updated']}." + ); + if (count($results['errors']) > 0) { + Yii::$app->session->setFlash('warning', "Обнаружены ошибки: " . implode('; ', array_slice($results['errors'], 0, 5))); + } +} catch (\Exception $e) { + Yii::$app->session->setFlash('error', 'Ошибка при обработке файла: ' . $e->getMessage()); +} +``` + +--- + +## 🧪 Примеры использования + +### Сценарий 1: Создание записи штатного расписания + +**Описание:** Добавить в магазин должность "Флорист" с 2.5 ставками + +**Шаги:** +1. Открыть `/store-staffing/create` +2. Выбрать магазин +3. Выбрать должность "Флорист" +4. Грейд автоматически подставится через AJAX +5. Указать count = 2.5 +6. Нажать "Сохранить" + +**Код (JavaScript для автозаполнения грейда):** + +```javascript +$('#storestaffing-employee_position_id').change(function() { + const positionId = $(this).val(); + + $.get('/store-staffing/get-position-posit', { position_id: positionId }, function(response) { + if (response.posit !== null) { + $('#storestaffing-posit').val(response.posit); + } + }); +}); +``` + +--- + +### Сценарий 2: Массовый импорт из Excel + +**Описание:** Импортировать штатное расписание для 50 магазинов + +**Шаги:** +1. Скачать шаблон: `/store-staffing/export-template` +2. Открыть лист "Данные" +3. Заполнить колонку `count` для нужных комбинаций +4. Сохранить файл +5. Открыть `/store-staffing/import` +6. Загрузить файл +7. Просмотреть результат импорта + +**Пример заполнения Excel:** + +| store_name | position_name | posit | count | +|------------|---------------|-------|-------| +| Магазин 1 | Флорист | 5 | 3.0 | +| Магазин 1 | Старший флорист | 7 | 1.5 | +| Магазин 1 | Администратор | 6 | 2.0 | +| Магазин 2 | Флорист | 5 | 2.5 | + +--- + +### Сценарий 3: Просмотр истории изменений + +**Описание:** Посмотреть все изменения штатного расписания для конкретного магазина + +**Шаги:** +1. Открыть `/store-staffing/logs` +2. Выбрать магазин в фильтре +3. Просмотреть таблицу логов + +**Пример лога:** + +| Дата | Магазин | Должность | Старое значение | Новое значение | Действие | Пользователь | +|------|---------|-----------|-----------------|----------------|----------|--------------| +| 2025-01-15 10:30 | Магазин 1 | Флорист | 2.5 | 3.0 | update | admin | +| 2025-01-14 14:20 | Магазин 1 | Старший флорист | - | 1.5 | create | manager | + +--- + +## ❓ FAQ + +### Вопрос 1: Как работает умный поиск должностей при импорте? + +**Ответ:** Умный поиск `findPositionByName()` работает в 2 этапа: + +1. **Точное совпадение:** `strcasecmp($dbName, $fileName) === 0` +2. **Поиск по вхождению с расчётом схожести:** + - Проверяет вхождение в обоих направлениях + - Рассчитывает процент схожести `similar_text()` + - Выбирает кандидата с максимальным процентом + +**Пример:** +``` +Файл: "старший флорист" +БД: ["Флорист", "Старший флорист", "Младший флорист"] +→ Найдено: "Старший флорист" (100% схожесть) +``` + +--- + +### Вопрос 2: Что происходит с записями при импорте, если они уже существуют? + +**Ответ:** При импорте проверяется наличие записи по комбинации `(store_id, employee_position_id)`: + +- **Если запись существует:** + - Проверяется `count` + - Если `count` изменился → UPDATE + - Если не изменился → пропуск +- **Если запись не существует:** + - INSERT новой записи + +--- + +### Вопрос 3: Почему экспортируемый шаблон содержит все комбинации магазинов и должностей? + +**Ответ:** Это сделано для удобства: + +- Пользователь видит все возможные комбинации +- Не нужно вручную заполнять названия и грейды +- Достаточно заполнить только колонку `count` +- Снижается вероятность ошибок в названиях + +Количество строк: `количество магазинов × количество должностей` + +Например: 50 магазинов × 20 должностей = 1000 строк в шаблоне + +--- + +## 🔗 Связанные компоненты + +- [StoreStaffing](../../models/StoreStaffing.md) — Модель штатного расписания +- [CityStore](../../models/CityStore.md) — Модель магазинов +- [EmployeePosition](../../models/EmployeePosition.md) — Модель должностей +- [StoreStaffingLog](../../models/StoreStaffingLog.md) — Логи изменений + +--- + +## 📝 История изменений + +| Дата | Версия | Изменения | +|------|--------|-----------| +| 2025-11-26 | 1.0 | Первая версия документации | + +--- + +**Документация создана:** Claude Code + Hive Mind Controllers Swarm +**Дата:** 2025-11-26 +**Фаза:** 2 (Крупные контроллеры) +**Статус:** ✅ Готово diff --git a/erp24/docs/controllers/non-standard/StoreStaffingController_QUICK_REFERENCE.md b/erp24/docs/controllers/non-standard/StoreStaffingController_QUICK_REFERENCE.md new file mode 100644 index 00000000..59c2758a --- /dev/null +++ b/erp24/docs/controllers/non-standard/StoreStaffingController_QUICK_REFERENCE.md @@ -0,0 +1,438 @@ +# StoreStaffingController - Краткая справка + +## 📌 Основная информация + +**Контроллер:** `StoreStaffingController` +**Путь:** `erp24/controllers/StoreStaffingController.php` +**Размер:** 516 строк +**Actions:** 9 (5 CRUD + 4 дополнительных) +**Категория:** Крупный контроллер (Фаза 2) + +--- + +## 🎯 Назначение в одном предложении + +CRUD-контроллер для управления штатным расписанием магазинов с массовым импортом/экспортом Excel, логированием изменений и умным поиском должностей. + +--- + +## 🔑 Ключевые особенности + +✅ **Полный CRUD:** создание, просмотр, редактирование, удаление записей +✅ **Массовый импорт:** из Excel с валидацией и умным поиском должностей +✅ **Экспорт шаблона:** автоматически заполненный Excel со всеми комбинациями +✅ **Логирование:** история изменений (StoreStaffingLog) +✅ **AJAX API:** получение грейда должности для автозаполнения +✅ **Умный поиск должностей:** точное совпадение + расчёт схожести + +--- + +## 📋 Actions (9) + +| # | Action | Тип | Назначение | +|---|--------|-----|------------| +| 1 | `actionIndex()` | CRUD | Список записей | +| 2 | `actionView($id)` | CRUD | Просмотр записи | +| 3 | `actionCreate()` | CRUD | Создание записи | +| 4 | `actionUpdate($id)` | CRUD | Редактирование | +| 5 | `actionDelete($id)` | CRUD | Удаление | +| 6 | `actionImport()` | Excel | Импорт из Excel | +| 7 | `actionExportTemplate()` | Excel | Экспорт шаблона | +| 8 | `actionLogs($store_id)` | Log | Просмотр логов | +| 9 | `actionGetPositionPosit($id)` | AJAX | Получить грейд | + +--- + +## 🗂️ Используемые модели + +| Модель | Таблица | Назначение | +|--------|---------|------------| +| **StoreStaffing** | `store_staffing` | Штатное расписание (store, position, count) | +| **CityStore** | `city_store` | Справочник магазинов | +| **EmployeePosition** | `employee_position` | Справочник должностей (с грейдом) | +| **StoreStaffingLog** | `store_staffing_log` | Логи изменений | +| **StoreStaffingSearch** | - | Поиск и фильтрация | + +--- + +## 📊 Формат Excel для импорта + +| Колонка | Название | Тип | Валидация | Пример | +|---------|----------|-----|-----------|--------| +| A | `store_name` | string | Точное совпадение с БД | "Магазин 1" | +| B | `position_name` | string | Умный поиск | "Старший флорист" | +| C | `posit` | int | Должен совпадать с БД | 5 | +| D | `count` | float | > 0 | 2.5 | + +--- + +## 🔄 Основные процессы + +### 1. CRUD операции + +```mermaid +graph LR + A[Index] --> B[View] + A --> C[Create] + B --> D[Update] + B --> E[Delete] + C --> B + D --> B + E --> A +``` + +### 2. Импорт из Excel + +```mermaid +graph TB + A[Загрузить файл] --> B[Валидация файла] + B --> C[Чтение строк] + C --> D{Валидация строки} + D -->|OK| E[Поиск магазина] + D -->|Ошибка| F[errors++] + E --> G[Умный поиск должности] + G --> H{Запись существует?} + H -->|Да| I[UPDATE] + H -->|Нет| J[INSERT] + I --> K[updated++] + J --> L[created++] +``` + +### 3. Умный поиск должности + +```mermaid +graph TB + A[Название из файла] --> B{Точное совпадение?} + B -->|Да| C[Вернуть ID] + B -->|Нет| D[Поиск по вхождению] + D --> E[Расчёт схожести] + E --> F[Сортировка по %] + F --> G{Найдены кандидаты?} + G -->|Да| H[Вернуть лучший] + G -->|Нет| I[Вернуть null] +``` + +--- + +## 💡 Быстрые примеры + +### Создание записи + +```php +// POST /store-staffing/create +$model = new StoreStaffing(); +$model->store_id = 5; +$model->employee_position_id = 10; +$model->count = 2.5; +$model->save(); +``` + +### Импорт из Excel + +```bash +# 1. Скачать шаблон +curl -X GET "https://erp24.example.com/store-staffing/export-template" -o template.xlsx + +# 2. Заполнить колонку D (count) + +# 3. Загрузить файл через форму +# POST /store-staffing/import +``` + +### AJAX: получить грейд должности + +```javascript +$.get('/store-staffing/get-position-posit', { position_id: 15 }, function(response) { + console.log('Грейд:', response.posit); // 5 +}); +``` + +### Просмотр логов изменений + +```bash +# Все логи +curl "https://erp24.example.com/store-staffing/logs" + +# Логи для магазина ID=5 +curl "https://erp24.example.com/store-staffing/logs?store_id=5" +``` + +--- + +## ⚙️ Алгоритм умного поиска должности + +**Метод:** `findPositionByName($positionName, $allPositions)` + +**Шаги:** + +1. **Точное совпадение** (без учёта регистра) + ```php + if (strcasecmp($dbName, $fileName) === 0) { + return $positionId; // 100% совпадение + } + ``` + +2. **Поиск по вхождению** в обоих направлениях + ```php + $fileContainsDb = stripos($fileName, $dbName) !== false; + $dbContainsFile = stripos($dbName, $fileName) !== false; + ``` + +3. **Расчёт процента схожести** для всех кандидатов + ```php + similar_text($fileName, $dbName, $percent); + ``` + +4. **Выбор лучшего кандидата** (максимальный процент) + +**Примеры:** + +| Файл | БД | Результат | Схожесть | +|------|-----|-----------|----------| +| "Флорист" | ["Флорист", "Старший флорист"] | Флорист | 100% | +| "Старший флорист" | ["Флорист", "Старший флорист"] | Старший флорист | 100% | +| "флорист" | ["Флорист"] | Флорист | 100% | +| "Florist" | ["Флорист"] | null | 0% | + +--- + +## 📈 Структура шаблона Excel + +**Лист 1: "Инструкция"** +- Описание формата импорта +- Объяснение каждой колонки + +**Лист 2: "Данные"** +- Заголовки: `store_name`, `position_name`, `posit`, `count` +- **Автозаполнение:** + - Все магазины (visible = 1) + - Все должности + - Грейды из БД + - Колонка `count` пустая + +**Количество строк:** `магазины × должности` + +**Пример:** 50 магазинов × 20 должностей = **1000 строк** + +--- + +## ⚠️ Валидация импорта + +### Обязательные проверки + +```php +// Название магазина +if (empty($storeName)) { + $errors[] = "Строка $rowNum: название магазина пусто."; +} + +// Название должности +if (empty($positionName)) { + $errors[] = "Строка $rowNum: название должности пусто."; +} + +// Количество +if ($count <= 0) { + $errors[] = "Строка $rowNum: количество должно быть больше 0."; +} + +// Магазин существует +if (!isset($storesMap[$storeName])) { + $errors[] = "Строка $rowNum: магазин '$storeName' не найден."; +} + +// Должность найдена +$positionId = $this->findPositionByName($positionName, $allPositions); +if ($positionId === null) { + $errors[] = "Строка $rowNum: должность '$positionName' не найдена."; +} + +// Соответствие грейда +if ($posit != $actualPosit) { + $errors[] = "Строка $rowNum: грейд должности должен быть $actualPosit, а не $posit."; +} +``` + +--- + +## 🔐 VerbFilter + +```php +'verbs' => [ + 'class' => VerbFilter::className(), + 'actions' => [ + 'delete' => ['POST'], // Только POST + 'import' => ['POST', 'GET'], // POST и GET + ], +], +``` + +**Результат:** +- `actionDelete()` — только POST (защита от CSRF) +- `actionImport()` — GET (форма) + POST (обработка) + +--- + +## 📊 Логирование изменений + +**Модель:** `StoreStaffingLog` + +**Поля:** +- `store_id` — ID магазина +- `employee_position_id` — ID должности +- `old_count` — Старое значение +- `new_count` — Новое значение +- `action` — Действие (create, update, delete) +- `created_at` — Дата изменения +- `created_by` — Пользователь + +**Просмотр:** +```php +// GET /store-staffing/logs?store_id=5 +``` + +**Сортировка:** `created_at DESC` +**Пагинация:** 100 записей/страница + +--- + +## 🚨 Типичные ошибки импорта + +| Ошибка | Причина | Решение | +|--------|---------|---------| +| "название магазина пусто" | Пустая колонка A | Заполнить | +| "название должности пусто" | Пустая колонка B | Заполнить | +| "количество должно быть больше 0" | count <= 0 | Указать > 0 | +| "магазин не найден" | Неточное название | Проверить точное совпадение | +| "должность не найдена" | Умный поиск не сработал | Исправить название | +| "грейд должен быть X" | Несоответствие posit | Исправить в файле | + +--- + +## 💾 Структура БД + +### Таблица store_staffing + +```sql +CREATE TABLE store_staffing ( + id SERIAL PRIMARY KEY, + store_id INTEGER NOT NULL REFERENCES city_store(id), + employee_position_id INTEGER NOT NULL REFERENCES employee_position(id), + count DECIMAL(5,2) NOT NULL, + UNIQUE(store_id, employee_position_id) +); +``` + +**Уникальный индекс:** `(store_id, employee_position_id)` + +**Ограничения:** +- Один магазин + одна должность = одна запись +- При импорте: если комбинация существует → UPDATE, иначе INSERT + +--- + +## 🔗 Связанные компоненты + +- **[StoreStaffing](../../models/StoreStaffing.md)** — Модель штатного расписания +- **[CityStore](../../models/CityStore.md)** — Модель магазинов +- **[EmployeePosition](../../models/EmployeePosition.md)** — Модель должностей +- **PhpSpreadsheet** — Библиотека для Excel + +--- + +## 📝 Полезные ссылки + +- [Детальный анализ контроллера](./StoreStaffingController_ANALYSIS.md) +- [Таблица Actions](./StoreStaffingController_ACTIONS_TABLE.md) +- [План документирования контроллеров](../CONTROLLERS_DOCUMENTATION_PLAN.md) + +--- + +## ❓ FAQ (Топ-5) + +### 1. Зачем нужен умный поиск должностей при импорте? + +**Ответ:** Умный поиск позволяет: +- Находить должности с неточным названием ("флорист" → "Флорист") +- Сопоставлять частичные совпадения ("Старший флорист" ⊇ "Флорист") +- Снизить количество ошибок импорта +- Повысить гибкость формата файла + +--- + +### 2. Что происходит при импорте, если запись уже существует? + +**Ответ:** При импорте проверяется комбинация `(store_id, employee_position_id)`: + +- **Существует:** + - Если `count` изменился → **UPDATE** → `updated++` + - Если не изменился → **пропуск** +- **Не существует:** + - **INSERT** → `created++` + +--- + +### 3. Почему в шаблоне Excel все комбинации уже заполнены? + +**Ответ:** Это упрощает импорт: +- Не нужно вручную вводить названия +- Грейды (`posit`) уже проставлены +- Достаточно заполнить только колонку `count` +- Меньше ошибок в названиях магазинов и должностей + +--- + +### 4. Как работает AJAX автозаполнение грейда в форме? + +**Ответ:** При выборе должности в select: + +```javascript +$('#position-select').change(function() { + const positionId = $(this).val(); + + // AJAX запрос + $.get('/store-staffing/get-position-posit', { position_id: positionId }, function(response) { + $('#posit-input').val(response.posit); // Автозаполнение + $('#posit-input').attr('readonly', true); // Блокировка редактирования + }); +}); +``` + +--- + +### 5. Где хранятся логи изменений штатного расписания? + +**Ответ:** Логи хранятся в таблице `store_staffing_log`: + +- Каждое изменение `count` → новая запись лога +- Поля: `old_count`, `new_count`, `action`, `created_at`, `created_by` +- Просмотр: `/store-staffing/logs?store_id=5` +- Сортировка: по дате DESC (новые сверху) + +--- + +## 🎨 Workflow диаграммы + +### Создание записи + +``` +[Форма] → [Валидация] → [Сохранение в БД] → [Лог] → [Redirect to View] +``` + +### Импорт + +``` +[Excel файл] → [Валидация] → [Парсинг строк] → [Умный поиск] → [INSERT/UPDATE] → [Статистика] +``` + +### Экспорт шаблона + +``` +[Запрос] → [Получить магазины] → [Получить должности] → [Генерация Excel] → [Скачивание] +``` + +--- + +**Краткая справка создана:** 2025-11-26 +**Версия:** 1.0 +**Статус:** ✅ Готово diff --git a/erp24/docs/controllers/non-standard/crud/ClusterAdminController_ACTIONS_TABLE.md b/erp24/docs/controllers/non-standard/crud/ClusterAdminController_ACTIONS_TABLE.md new file mode 100644 index 00000000..60ce5f0e --- /dev/null +++ b/erp24/docs/controllers/non-standard/crud/ClusterAdminController_ACTIONS_TABLE.md @@ -0,0 +1,556 @@ +# ClusterAdminController - Таблица Actions + +## Быстрая справка по действиям + +| # | Action | HTTP | Маршрут | Назначение | Параметры | Возвращает | +|---|--------|------|---------|------------|-----------|------------| +| 1 | `actionIndex()` | GET | `/crud/cluster-admin/index` | Список кустов с админами | - | HTML | +| 2 | `actionView($id)` | GET | `/crud/cluster-admin/view` | История назначений куста | `id` (cluster_id) | HTML | +| 3 | `actionCreate($cluster_id)` | GET/POST | `/crud/cluster-admin/create` | Создание назначения | `cluster_id`, POST: модель | HTML/Redirect | +| 4 | `actionUpdate($id)` | GET/POST | `/crud/cluster-admin/update` | Редактирование назначения | `id`, POST: модель | HTML/Redirect | +| 5 | `actionDelete($id)` | POST | `/crud/cluster-admin/delete` | Удаление назначения | `id` | Redirect | +| 6 | `actionMoveAdmin()` | POST | `/crud/cluster-admin/move-admin` | AJAX: переместить админа | `admin_id`, `cluster_id` | JSON | +| 7 | `getAccess()` | - | - | Проверка прав доступа | - | bool | + +--- + +## Детальное описание Actions + +### 1. actionIndex() + +**Сигнатура:** +```php +public function actionIndex(): string|\yii\web\Response +``` + +**Назначение:** Отображение списка всех кустов с информацией о назначенных администраторах + +**Параметры:** Нет + +**Возвращает:** +- Success: `string` — HTML страница +- Access denied: `Response` — redirect to `/` + +**Контроль доступа:** `getAccess()` — запрет для групп IT, HR, директора + +**Данные представления:** + +| Переменная | Тип | Описание | +|------------|-----|----------| +| `clusterMapping` | array | Маппинг кустов с назначениями | +| `clustersList` | array | Список всех кустов | +| `admins` | array | Менеджеры кустов (group_id=7) | + +**Структура clusterMapping:** +```php +[ + 1 => [ + 'name' => 'Куст 1', + 'admin' => 'Иванов И.И. (123)', + 'admin_id' => 123, + 'status' => 'Активная запись', + 'hasActive' => true, + 'hasAny' => true, + ], + 2 => [ + 'name' => 'Куст 2', + 'admin' => null, + 'status' => 'Нет записей', + 'hasActive' => false, + 'hasAny' => false, + ], +] +``` + +**Статусы:** +- `"Активная запись"` — есть активное назначение +- `"Нет активных записей"` — есть записи, но все неактивные +- `"Нет записей"` — записей нет + +**Пример HTTP:** +```bash +curl -X GET "https://erp24.example.com/crud/cluster-admin/index" +``` + +--- + +### 2. actionView($id) + +**Сигнатура:** +```php +public function actionView(int $id): string|\yii\web\Response +``` + +**Назначение:** Просмотр истории назначений администраторов для конкретного куста + +**Параметры:** + +| Параметр | Тип | Обязательный | Описание | +|----------|-----|--------------|----------| +| `id` | int | Да | ID куста (Cluster.id) | + +**Возвращает:** +- Success: `string` — HTML страница с таблицей +- Error: `NotFoundHttpException` — "Кластер не найден." +- Access denied: `Response` — redirect to `/` + +**Данные представления:** + +| Переменная | Тип | Описание | +|------------|-----|----------| +| `cluster` | Cluster | Модель куста | +| `clusterAdmins` | ClusterAdmin[] | Все назначения куста (with `admin`) | +| `user` | Admin | Текущий пользователь | + +**Пример HTTP:** +```bash +curl -X GET "https://erp24.example.com/crud/cluster-admin/view?id=5" +``` + +**Пример данных (таблица):** + +| ID | Администратор | Дата начала | Дата окончания | Активность | Действия | +|----|---------------|-------------|----------------|------------|----------| +| 123 | Иванов И.И. | 2025-01-01 | - | ✅ Активный | Редактировать, Удалить | +| 122 | Петров П.П. | 2024-06-01 | 2024-12-31 | ❌ Неактивный | Редактировать, Удалить | + +--- + +### 3. actionCreate($cluster_id) + +**Сигнатура:** +```php +public function actionCreate(int $cluster_id): string|\yii\web\Response +``` + +**Назначение:** Создание нового назначения администратора на куст + +**HTTP метод:** GET (форма), POST (создание) + +**Параметры:** + +| Параметр | Тип | Обязательный | Описание | +|----------|-----|--------------|----------| +| `cluster_id` | int | Да (GET) | ID куста | +| `ClusterAdmin[admin_id]` | int | Да (POST) | ID администратора | +| `ClusterAdmin[active]` | int | Да (POST) | Активность (0/1) | +| `ClusterAdmin[date_start]` | date | Да (POST) | Дата начала | +| `ClusterAdmin[date_end]` | date | Нет (POST) | Дата окончания | + +**Возвращает:** +- Success: `Response` — redirect to `view` +- Error: `string` — HTML форма с ошибками +- Access denied: `Response` — redirect to `/` + +**Валидация:** + +**Для неактивной записи (active=0):** +```php +// Проверка, что даты не позже даты начала активной записи +if ($modelStart >= $activeRecordStart || $modelEnd >= $activeRecordStart) { + // Ошибка +} +``` + +**Для активной записи (active=1):** +```php +// Деактивация существующей активной записи куста +$existingRecord = ClusterAdmin::find() + ->where(['cluster_id' => $model->cluster_id, 'active' => 1]) + ->one(); + +if ($existingRecord) { + $existingRecord->active = 0; + $existingRecord->date_end = date('Y-m-d'); + $existingRecord->save(); +} +``` + +**Flash-сообщения:** +- Success: `"Новая запись успешно создана, предыдущая была закрыта."` +- Error: `"Ошибка: даты начала или окончания новой неактивной записи не могут быть позже даты начала существующей активной записи (2025-01-01)."` + +**Пример HTTP:** +```bash +curl -X POST "https://erp24.example.com/crud/cluster-admin/create?cluster_id=5" \ + -d "ClusterAdmin[admin_id]=123" \ + -d "ClusterAdmin[active]=1" \ + -d "ClusterAdmin[date_start]=2025-01-01" +``` + +--- + +### 4. actionUpdate($id) + +**Сигнатура:** +```php +public function actionUpdate(int $id): string|\yii\web\Response +``` + +**Назначение:** Редактирование существующего назначения администратора + +**HTTP метод:** GET (форма), POST (обновление) + +**Параметры:** + +| Параметр | Тип | Обязательный | Описание | +|----------|-----|--------------|----------| +| `id` | int | Да | ID записи ClusterAdmin | + +**Возвращает:** +- Success: `Response` — redirect to `view` +- Error: `string` — HTML форма с ошибками +- Access denied: `Response` — redirect to `/` + +**Валидация для активной записи (active=1):** + +**1. Конфликт с активными записями других кустов:** +```php +$existingActiveRecords = ClusterAdmin::find() + ->where(['admin_id' => $model->admin_id, 'active' => 1]) + ->andWhere(['!=', 'cluster_id', $model->cluster_id]) + ->all(); + +if (!empty($existingActiveRecords)) { + // Ошибка: "Этот пользователь уже привязан к кластеру X с активным статусом." +} +``` + +**2. Конфликт с неактивными записями других кустов:** +```php +foreach ($existingInactiveRecords as $record) { + if ($modelStart < $recordStart && $modelStart < $recordEnd) { + // Ошибка: активная запись не может начинаться раньше неактивной + } +} +``` + +**Валидация для неактивной записи (active=0):** + +**Проверка пересечения дат:** +```php +if ( + ($modelStart > $recordStart && $modelStart < $recordEnd) || // Начало внутри + ($modelEnd > $recordStart && $modelEnd < $recordEnd) || // Конец внутри + ($modelStart <= $recordStart && $modelEnd >= $recordEnd) // Полное охватывание +) { + // Ошибка: "Пересечение дат с записью от ... до ..." +} +``` + +**Flash-сообщения:** +- Success: `"Запись успешно обновлена."` +- Error: `"Этот пользователь Иванов И.И. уже привязан к кластеру 3 с активным статусом."` +- Error: `"Пересечение дат с записью от 2024-01-01 до 2024-12-31 для другого кластера - 3 123"` + +**Пример HTTP:** +```bash +curl -X POST "https://erp24.example.com/crud/cluster-admin/update?id=123" \ + -d "ClusterAdmin[date_end]=2024-06-30" +``` + +--- + +### 5. actionDelete($id) + +**Сигнатура:** +```php +public function actionDelete(int $id): \yii\web\Response +``` + +**Назначение:** Удаление назначения администратора + +**HTTP метод:** POST (только) — ограничено VerbFilter + +**Параметры:** + +| Параметр | Тип | Обязательный | Описание | +|----------|-----|--------------|----------| +| `id` | int | Да | ID записи ClusterAdmin | + +**Возвращает:** +- Success: `Response` — redirect to `view` (cluster_id) +- Error: `NotFoundHttpException` +- Access denied: `Response` — redirect to `/` + +**Бизнес-логика:** +```php +$model = $this->findModel($id); +$cluster_id = $model->cluster_id; +$model->delete(); +return $this->redirect(['view', 'id' => $cluster_id]); +``` + +**Пример HTTP:** +```bash +curl -X POST "https://erp24.example.com/crud/cluster-admin/delete?id=123" +``` + +**VerbFilter конфигурация:** +```php +'verbs' => [ + 'class' => VerbFilter::class, + 'actions' => [ + 'delete' => ['POST'], + ], +], +``` + +--- + +### 6. actionMoveAdmin() + +**Сигнатура:** +```php +public function actionMoveAdmin(): mixed +``` + +**Назначение:** AJAX-перемещение администратора между кустами + +**HTTP метод:** POST + +**Параметры (POST):** + +| Параметр | Тип | Обязательный | Описание | +|----------|-----|--------------|----------| +| `admin_id` | int | Да | ID нового администратора | +| `cluster_id` | int | Да | ID куста назначения | + +**Возвращает:** JSON + +**Success:** +```json +{ + "success": true +} +``` + +**Error:** +```json +{ + "success": false, + "message": "Администратор не найден или уже не активен." +} +``` + +**Бизнес-логика (3 шага):** + +1. **Деактивация текущего админа куста:** + ```php + $currentClusterAdmin = ClusterAdmin::find() + ->where(['cluster_id' => $clusterId, 'active' => 1]) + ->one(); + + if ($currentClusterAdmin) { + $currentClusterAdmin->active = 0; + $currentClusterAdmin->date_end = $currentDate; + $currentClusterAdmin->save(false); + } + ``` + +2. **Деактивация старого куста нового админа:** + ```php + $oldClusterAdmin = ClusterAdmin::find() + ->where(['admin_id' => $newAdminId, 'active' => 1]) + ->one(); + + if ($oldClusterAdmin) { + $oldClusterAdmin->active = 0; + $oldClusterAdmin->date_end = $currentDate; + $oldClusterAdmin->save(false); + } + ``` + +3. **Создание нового назначения:** + ```php + $newClusterAdmin = new ClusterAdmin(); + $newClusterAdmin->admin_id = $newAdminId; + $newClusterAdmin->cluster_id = $clusterId; + $newClusterAdmin->date_start = $currentDate; + $newClusterAdmin->active = 1; + $newClusterAdmin->save(false); + ``` + +**Пример AJAX (JavaScript):** +```javascript +$.ajax({ + url: '/crud/cluster-admin/move-admin', + type: 'POST', + data: { + admin_id: 123, + cluster_id: 5 + }, + success: function(response) { + if (response.success) { + location.reload(); + } else { + alert('Ошибка: ' + response.message); + } + } +}); +``` + +**Закомментированный функционал:** +```php +// ClusterManagerService::clearClusterManagerStores($cluster_id, $admin_id); +// ClusterManagerService::syncClusterManagers($cluster_id, $admin_id); +``` + +--- + +### 7. getAccess() + +**Сигнатура:** +```php +public static function getAccess(): bool +``` + +**Назначение:** Проверка прав доступа к контроллеру + +**Параметры:** Нет + +**Возвращает:** `bool` +- `true` — доступ **ЗАПРЕЩЁН** → redirect +- `false` — доступ **РАЗРЕШЁН** + +**Группы БЕЗ доступа (return false):** +```php +AdminGroup::GROUP_IT +AdminGroup::DIRECTOR +AdminGroup::GROUP_HR +AdminGroup::GROUP_HR_DIRECTOR +AdminGroup::GROUP_RS_DIRECTOR +``` + +**Логика:** +```php +if (in_array($admin->group_id, [IT, DIRECTOR, HR, HR_DIR, RS_DIR])) { + return false; // Доступ РАЗРЕШЁН +} else { + return true; // Доступ ЗАПРЕЩЁН +} +``` + +**⚠️ Внимание:** Логика инвертирована! `true` = запрет, `false` = разрешение. + +**Использование:** +```php +if (self::getAccess()) { + return $this->redirect('/'); +} +``` + +--- + +## Матрица HTTP методов + +| Action | GET | POST | DELETE | PUT | +|--------|-----|------|--------|-----| +| actionIndex | ✅ | ❌ | ❌ | ❌ | +| actionView | ✅ | ❌ | ❌ | ❌ | +| actionCreate | ✅ (форма) | ✅ (создание) | ❌ | ❌ | +| actionUpdate | ✅ (форма) | ✅ (обновление) | ❌ | ❌ | +| actionDelete | ❌ | ✅ | ❌ | ❌ | +| actionMoveAdmin | ❌ | ✅ | ❌ | ❌ | + +--- + +## Используемые модели + +| Модель | Таблица | Назначение | +|--------|---------|------------| +| `ClusterAdmin` | `cluster_admin` | Назначения админов (с историей) | +| `Cluster` | `cluster` | Справочник кустов | +| `Admin` | `admin` | Справочник администраторов | +| `AdminGroup` | `admin_group` | Группы администраторов | + +--- + +## Бизнес-правила + +### Активные записи (active=1) + +1. **Только одна активная запись на куст** + - При создании новой → старая деактивируется +2. **Только одна активная запись на администратора** + - При назначении на новый куст → старый куст деактивируется +3. **Активная запись не может начинаться раньше неактивной** для другого куста + +### Неактивные записи (active=0) + +1. **Даты не должны пересекаться** с другими записями администратора +2. **Даты не должны быть позже** даты начала активной записи куста + +--- + +## Примеры валидационных ошибок + +### Создание активной записи + +``` +❌ "Ошибка: даты начала или окончания новой неактивной записи + не могут быть позже даты начала существующей активной записи (2025-01-01)." +``` + +### Редактирование активной записи + +``` +❌ "Этот пользователь Иванов И.И. уже привязан к кластеру 3 с активным статусом." + +❌ "Ошибка: активная запись для Иванов И.И. не может начинаться раньше, + чем неактивная запись (2024-01-01 - 2024-12-31) для другого кластера." +``` + +### Редактирование неактивной записи + +``` +❌ "Пересечение дат с записью от 2024-01-01 до 2024-12-31 + для другого кластера - 3 123" +``` + +--- + +## Примеры CURL запросов + +### Получить список кустов +```bash +curl -X GET "https://erp24.example.com/crud/cluster-admin/index" +``` + +### Просмотреть историю куста +```bash +curl -X GET "https://erp24.example.com/crud/cluster-admin/view?id=5" +``` + +### Создать назначение +```bash +curl -X POST "https://erp24.example.com/crud/cluster-admin/create?cluster_id=5" \ + -d "ClusterAdmin[admin_id]=123" \ + -d "ClusterAdmin[active]=1" \ + -d "ClusterAdmin[date_start]=2025-01-01" +``` + +### Переместить админа (AJAX) +```bash +curl -X POST "https://erp24.example.com/crud/cluster-admin/move-admin" \ + -d "admin_id=123" \ + -d "cluster_id=5" +``` + +### Удалить назначение +```bash +curl -X POST "https://erp24.example.com/crud/cluster-admin/delete?id=123" +``` + +--- + +## Связанные файлы + +- [Детальный анализ](./ClusterAdminController_ANALYSIS.md) +- [Краткая справка](./ClusterAdminController_QUICK_REFERENCE.md) +- [README контроллеров](../../README.md) + +--- + +**Документация создана:** 2025-11-26 +**Статус:** ✅ Готово diff --git a/erp24/docs/controllers/non-standard/crud/ClusterAdminController_ANALYSIS.md b/erp24/docs/controllers/non-standard/crud/ClusterAdminController_ANALYSIS.md new file mode 100644 index 00000000..5e8065fc --- /dev/null +++ b/erp24/docs/controllers/non-standard/crud/ClusterAdminController_ANALYSIS.md @@ -0,0 +1,987 @@ +# ClusterAdminController - Детальный анализ + +## 📋 Общая информация + +**Namespace:** `yii_app\controllers\crud` +**Путь к файлу:** `erp24/controllers/crud/ClusterAdminController.php` +**Extends:** `\yii\web\Controller` +**Размер:** 503 строки кода +**Количество actions:** 7 +**Категория:** Крупный контроллер (Фаза 2) +**Приоритет:** HIGH + +--- + +## 🎯 Назначение + +Контроллер для управления **привязкой администраторов к кустам магазинов** с поддержкой: + +- CRUD операции с записями ClusterAdmin +- **Историчность данных:** активные/неактивные записи с датами начала/окончания +- **Перемещение администраторов** между кустами +- **Валидация пересечений дат** при создании/редактировании +- **Контроль доступа:** только для определённых групп (IT, HR, директора) +- **Автоматическое закрытие** предыдущих активных записей при создании новой + +Контроллер реализует сложную бизнес-логику по управлению назначениями менеджеров кустов с учётом временных периодов и предотвращением конфликтов. + +--- + +## 🏗️ Архитектура + +### Основные компоненты + +```mermaid +graph TB + Controller[ClusterAdminController] + + subgraph "CRUD Actions" + A1[actionIndex
Список кустов с админами] + A2[actionView
История назначений куста] + A3[actionCreate
Создание назначения] + A4[actionUpdate
Редактирование назначения] + A5[actionDelete
Удаление назначения] + end + + subgraph "Special Actions" + A6[actionMoveAdmin
AJAX: переместить админа] + A7[getAccess
Проверка прав доступа] + end + + subgraph "Models" + M1[ClusterAdmin
Назначения админов] + M2[Cluster
Кусты магазинов] + M3[Admin
Администраторы] + M4[AdminGroup
Группы админов] + end + + subgraph "Services" + S1[ClusterManagerService
Синхронизация магазинов] + end + + Controller --> A1 + Controller --> A2 + Controller --> A3 + Controller --> A4 + Controller --> A5 + Controller --> A6 + Controller --> A7 + + A1 --> M1 + A1 --> M2 + A1 --> M3 + A2 --> M1 + A2 --> M2 + A3 --> M1 + A4 --> M1 + A5 --> M1 + A6 --> M1 + + A7 --> M4 + + A6 -.-> S1 +``` + +### Зависимости + +| Компонент | Тип | Назначение | +|-----------|-----|------------| +| `ClusterAdmin` | Модель | Связь администратор-куст (с историей) | +| `Cluster` | Модель | Справочник кустов магазинов | +| `Admin` | Модель | Справочник администраторов | +| `AdminGroup` | Модель | Группы администраторов (константы) | +| `ClusterManagerService` | Сервис | Синхронизация магазинов (закомментировано) | +| `VerbFilter` | Filter | Ограничение HTTP методов | +| `AccessControl` | Filter | Контроль доступа (закомментировано) | + +--- + +## 🔐 Система контроля доступа + +### Метод getAccess() + +**Назначение:** Проверка прав доступа к контроллеру + +**Логика:** Доступ **ЗАПРЕЩЁН** только для определённых групп, остальным **РАЗРЕШЁН** + +**Группы с доступом:** +```php +// Эти группы НЕ имеют доступа (return false → redirect) +AdminGroup::GROUP_IT // IT отдел +AdminGroup::DIRECTOR // Директор +AdminGroup::GROUP_HR // HR +AdminGroup::GROUP_HR_DIRECTOR // HR директор +AdminGroup::GROUP_RS_DIRECTOR // RS директор +``` + +**Важно:** Логика инвертирована — функция возвращает `true` для ЗАПРЕТА доступа + +```php +if (in_array($admin->group_id, [GROUP_IT, DIRECTOR, GROUP_HR, ...])) { + return false; // Доступ РАЗРЕШЁН +} else { + return true; // Доступ ЗАПРЕЩЁН → redirect +} +``` + +**Использование в actions:** +```php +if (self::getAccess()) { + return $this->redirect('/'); // Редирект на главную +} +``` + +--- + +## 📋 Actions + +### 1. actionIndex() + +**Назначение:** Отображение списка всех кустов с информацией о назначенных администраторах + +**HTTP метод:** GET +**Маршрут:** `/crud/cluster-admin/index` + +**Параметры:** Нет + +**Возвращает:** +- Success: `string` — HTML страница +- Access denied: `Response` — redirect на `/` + +**Бизнес-логика:** + +1. Проверка прав доступа (`getAccess()`) +2. Получить все кусты из таблицы `Cluster` +3. Получить все записи `ClusterAdmin` с отношениями `admin`, `cluster` +4. Получить список администраторов с группой `7` (менеджеры кустов) +5. Создать маппинг для каждого куста: + - Название куста + - Назначенный админ (если есть активная запись) + - Статус: "Нет записей" / "Активная запись" / "Нет активных записей" + - Флаги: `hasActive`, `hasAny` +6. Отрендерить представление + +**Структура clusterMapping:** + +```php +$clusterMapping[$cluster_id] = [ + 'name' => 'Куст 1', + 'admin' => 'Иванов И.И. (123)', // Если есть активная запись + 'admin_id' => 123, // ID админа + 'status' => 'Активная запись', // Статус + 'hasActive' => true, // Есть активная запись + 'hasAny' => true, // Есть любые записи +]; +``` + +**Последовательность выполнения:** + +```mermaid +sequenceDiagram + participant User + participant Controller + participant Cluster + participant ClusterAdmin + participant Admin + participant View + + User->>Controller: GET /crud/cluster-admin/index + Controller->>Controller: getAccess() + + alt Доступ запрещён + Controller-->>User: Redirect to / + end + + Controller->>Cluster: find()->all() + Cluster-->>Controller: Все кусты + + Controller->>ClusterAdmin: find()->with(['admin', 'cluster'])->all() + ClusterAdmin-->>Controller: Все назначения + + Controller->>Admin: find()->where(['group_id' => 7]) + Admin-->>Controller: Менеджеры кустов + + loop Каждый куст + Controller->>Controller: Найти активную запись + Controller->>Controller: Определить статус + end + + Controller->>View: render('index') + View-->>User: HTML таблица кустов +``` + +**Пример данных:** + +| Куст | Администратор | Статус | Действия | +|------|---------------|--------|----------| +| Куст 1 | Иванов И.И. (123) | Активная запись | Переместить | +| Куст 2 | - | Нет записей | Назначить | +| Куст 3 | - | Нет активных записей | Назначить | + +--- + +### 2. actionMoveAdmin() + +**Назначение:** AJAX-перемещение администратора между кустами + +**HTTP метод:** POST +**Маршрут:** `/crud/cluster-admin/move-admin` + +**Параметры (POST):** + +| Параметр | Тип | Обязательный | Описание | +|----------|-----|--------------|----------| +| `admin_id` | int | Да | ID нового администратора | +| `cluster_id` | int | Да | ID куста назначения | + +**Возвращает:** JSON + +**Success:** +```json +{ + "success": true +} +``` + +**Error:** +```json +{ + "success": false, + "message": "Администратор не найден или уже не активен." +} +``` + +**Бизнес-логика:** + +1. Получить `admin_id` и `cluster_id` из POST +2. Получить текущую дату/время +3. **Найти текущего админа куста:** + - `ClusterAdmin` где `cluster_id` = куст И `active` = 1 + - Если найден → деактивировать: + - `active` = 0 + - `date_end` = текущая дата + - Сохранить +4. **Найти старый куст нового админа:** + - `ClusterAdmin` где `admin_id` = новый админ И `active` = 1 + - Если найден → деактивировать: + - `active` = 0 + - `date_end` = текущая дата + - Сохранить +5. **Создать новое назначение:** + - `admin_id` = новый админ + - `cluster_id` = куст назначения + - `date_start` = текущая дата + - `active` = 1 + - Сохранить +6. Вернуть JSON `{"success": true}` + +**Закомментированный функционал:** +```php +// ClusterManagerService::clearClusterManagerStores($cluster_id, $admin_id); +// ClusterManagerService::syncClusterManagers($cluster_id, $admin_id); +``` + +**Последовательность выполнения:** + +```mermaid +sequenceDiagram + participant Client + participant Controller + participant ClusterAdmin + participant Database + + Client->>Controller: POST {admin_id, cluster_id} + + Controller->>ClusterAdmin: Найти текущего админа куста + alt Найден + ClusterAdmin->>Database: UPDATE active=0, date_end=now + end + + Controller->>ClusterAdmin: Найти старый куст нового админа + alt Найден + ClusterAdmin->>Database: UPDATE active=0, date_end=now + end + + Controller->>ClusterAdmin: new ClusterAdmin() + ClusterAdmin->>Database: INSERT новое назначение + Database-->>Controller: Success + + Controller-->>Client: {"success": true} +``` + +**Пример использования (JavaScript):** + +```javascript +$.ajax({ + url: '/crud/cluster-admin/move-admin', + type: 'POST', + data: { + admin_id: 123, // Новый админ + cluster_id: 5 // Куст назначения + }, + success: function(response) { + if (response.success) { + alert('Администратор успешно перемещён'); + location.reload(); + } else { + alert('Ошибка: ' + response.message); + } + } +}); +``` + +--- + +### 3. actionView($id) + +**Назначение:** Просмотр истории назначений администраторов для конкретного куста + +**HTTP метод:** GET +**Маршрут:** `/crud/cluster-admin/view` + +**Параметры:** + +| Параметр | Тип | Обязательный | Описание | +|----------|-----|--------------|----------| +| `id` | int | Да | ID куста (Cluster.id) | + +**Возвращает:** +- Success: `string` — HTML страница с таблицей назначений +- Error: `NotFoundHttpException` — "Кластер не найден." +- Access denied: `Response` — redirect на `/` + +**Бизнес-логика:** + +1. Проверка прав доступа +2. Найти куст по ID +3. Если не найден → throw NotFoundHttpException +4. Получить все записи `ClusterAdmin` для данного куста с отношением `admin` +5. Отрендерить представление + +**Последовательность выполнения:** + +```mermaid +sequenceDiagram + participant User + participant Controller + participant Cluster + participant ClusterAdmin + participant View + + User->>Controller: GET /view?id=5 + Controller->>Controller: getAccess() + + Controller->>Cluster: findOne(id=5) + + alt Куст не найден + Cluster-->>Controller: null + Controller-->>User: NotFoundHttpException + end + + Cluster-->>Controller: Cluster model + + Controller->>ClusterAdmin: find()->where(['cluster_id' => 5])->with(['admin']) + ClusterAdmin-->>Controller: Все назначения куста + + Controller->>View: render('view') + View-->>User: HTML таблица истории +``` + +**Пример данных (таблица истории):** + +| Администратор | Дата начала | Дата окончания | Статус | +|---------------|-------------|----------------|--------| +| Иванов И.И. | 2025-01-01 | - | Активный | +| Петров П.П. | 2024-06-01 | 2024-12-31 | Неактивный | +| Сидоров С.С. | 2024-01-01 | 2024-05-31 | Неактивный | + +--- + +### 4. actionCreate($cluster_id) + +**Назначение:** Создание нового назначения администратора на куст + +**HTTP метод:** GET (форма), POST (создание) +**Маршрут:** `/crud/cluster-admin/create` + +**Параметры:** + +| Параметр | Тип | Обязательный | Описание | +|----------|-----|--------------|----------| +| `cluster_id` | int | Да | ID куста | +| `ClusterAdmin[admin_id]` | int | Да (POST) | ID администратора | +| `ClusterAdmin[active]` | int | Да (POST) | Активность (0/1) | +| `ClusterAdmin[date_start]` | date | Да (POST) | Дата начала | +| `ClusterAdmin[date_end]` | date | Нет | Дата окончания (для active=0) | + +**Возвращает:** +- Success: `Response` — redirect на `view` +- Error: `string` — HTML форма с ошибками +- Access denied: `Response` — redirect на `/` + +**Бизнес-логика:** + +**1. Подготовка данных:** + - Получить всех администраторов с `group_id = 7` + - Получить ID активных администраторов + - Отфильтровать неактивных администраторов (для select) + +**2. Валидация при создании неактивной записи (active = 0):** + - Найти активную запись для данного куста + - Проверить, что новая запись не пересекается с активной: + ```php + if ($modelStart >= $activeRecordStart || $modelEnd >= $activeRecordStart) { + // Ошибка: даты не могут быть позже даты начала активной записи + } + ``` + +**3. Создание активной записи (active = 1):** + - Найти существующую активную запись для куста + - Если найдена → деактивировать: + - `active` = 0 + - `date_end` = текущая дата + - Создать новую активную запись + +**4. Сохранение:** + - Валидация модели + - Сохранение в БД + - Flash-сообщение "успешно создана" + - Редирект на `view` + +**Последовательность выполнения:** + +```mermaid +sequenceDiagram + participant User + participant Controller + participant ClusterAdmin + participant Database + + User->>Controller: POST /create?cluster_id=5 + + Controller->>Controller: Валидация данных + + alt active = 0 (неактивная запись) + Controller->>ClusterAdmin: Найти активную запись куста + alt Пересечение дат + Controller-->>User: Flash error + end + else active = 1 (активная запись) + Controller->>ClusterAdmin: Найти существующую активную + alt Найдена + ClusterAdmin->>Database: UPDATE active=0, date_end=now + end + end + + Controller->>ClusterAdmin: save() + ClusterAdmin->>Database: INSERT + + Controller->>Controller: setFlash('success') + Controller-->>User: Redirect to view +``` + +**Пример использования:** + +```php +// POST /crud/cluster-admin/create?cluster_id=5 +$model = new ClusterAdmin(); +$model->cluster_id = 5; +$model->admin_id = 123; +$model->active = 1; +$model->date_start = '2025-01-01'; + +if ($model->save()) { + // Успешно создано +} +``` + +--- + +### 5. actionUpdate($id) + +**Назначение:** Редактирование существующего назначения администратора + +**HTTP метод:** GET (форма), POST (обновление) +**Маршрут:** `/crud/cluster-admin/update` + +**Параметры:** + +| Параметр | Тип | Обязательный | Описание | +|----------|-----|--------------|----------| +| `id` | int | Да | ID записи ClusterAdmin | + +**Возвращает:** +- Success: `Response` — redirect на `view` +- Error: `string` — HTML форма с ошибками +- Access denied: `Response` — redirect на `/` + +**Бизнес-логика:** + +**1. Подготовка данных:** + - Загрузить модель `ClusterAdmin` по ID + - Получить всех администраторов с `group_id = 7` + - Получить неактивных администраторов + - Добавить текущего админа в список (если он не в нём) + +**2. Валидация при изменении на активную запись (active = 1):** + + **a) Конфликт с активными записями других кустов:** + ```php + $existingActiveRecords = ClusterAdmin::find() + ->where(['admin_id' => $model->admin_id, 'active' => 1]) + ->andWhere(['!=', 'cluster_id', $model->cluster_id]) + ->all(); + + if (!empty($existingActiveRecords)) { + // Ошибка: администратор уже привязан к другому кусту + } + ``` + + **b) Конфликт с неактивными записями других кустов:** + ```php + foreach ($existingInactiveRecords as $record) { + if ($modelStart < $recordStart && $modelStart < $recordEnd) { + // Ошибка: активная запись не может начинаться раньше неактивной + } + } + ``` + +**3. Валидация при изменении на неактивную запись (active = 0):** + + **Проверка пересечения дат:** + ```php + foreach ($existingRecords as $record) { + if ( + ($modelStart > $recordStart && $modelStart < $recordEnd) || // Начало внутри + ($modelEnd > $recordStart && $modelEnd < $recordEnd) || // Конец внутри + ($modelStart <= $recordStart && $modelEnd >= $recordEnd) // Полное охватывание + ) { + // Ошибка: пересечение дат + } + } + ``` + +**4. Сохранение:** + - Валидация модели + - Обновление в БД + - Flash-сообщение "успешно обновлена" + - Редирект на `view` + +**Последовательность выполнения:** + +```mermaid +sequenceDiagram + participant User + participant Controller + participant ClusterAdmin + participant Database + + User->>Controller: POST /update?id=123 + + Controller->>ClusterAdmin: findModel(123) + + alt active = 1 + Controller->>ClusterAdmin: Проверка конфликтов с активными + alt Конфликт найден + Controller-->>User: Flash error + end + Controller->>ClusterAdmin: Проверка конфликтов с неактивными + alt Конфликт найден + Controller-->>User: Flash error + end + else active = 0 + Controller->>ClusterAdmin: Проверка пересечений дат + alt Пересечение найдено + Controller-->>User: Flash error + end + end + + Controller->>ClusterAdmin: save() + ClusterAdmin->>Database: UPDATE + + Controller->>Controller: setFlash('success') + Controller-->>User: Redirect to view +``` + +**Примеры ошибок валидации:** + +``` +❌ "Этот пользователь Иванов И.И. уже привязан к кластеру 3 с активным статусом." + +❌ "Ошибка: активная запись для Иванов И.И. не может начинаться раньше, + чем неактивная запись (2024-01-01 - 2024-12-31) для другого кластера." + +❌ "Пересечение дат с записью от 2024-01-01 до 2024-12-31 + для другого кластера - 3 123" +``` + +--- + +### 6. actionDelete($id) + +**Назначение:** Удаление назначения администратора + +**HTTP метод:** POST (только) +**Маршрут:** `/crud/cluster-admin/delete` + +**Параметры:** + +| Параметр | Тип | Обязательный | Описание | +|----------|-----|--------------|----------| +| `id` | int | Да | ID записи ClusterAdmin | + +**Возвращает:** +- Success: `Response` — redirect на `view` куста +- Error: `NotFoundHttpException` +- Access denied: `Response` — redirect на `/` + +**Ограничение:** VerbFilter разрешает только POST + +**Бизнес-логика:** + +1. Проверка прав доступа +2. Найти запись по ID +3. Сохранить `cluster_id` для редиректа +4. Удалить запись +5. Редирект на `view` куста + +**Последовательность выполнения:** + +```mermaid +sequenceDiagram + participant User + participant Controller + participant ClusterAdmin + participant Database + + User->>Controller: POST /delete?id=123 + Controller->>Controller: getAccess() + + Controller->>ClusterAdmin: findModel(123) + + alt Запись найдена + ClusterAdmin->>Database: DELETE + Database-->>Controller: Success + Controller-->>User: Redirect to view + else Запись не найдена + Controller-->>User: NotFoundHttpException + end +``` + +**Пример использования:** + +```php +// POST /crud/cluster-admin/delete?id=123 +public function actionDelete($id) +{ + if (self::getAccess()) { + return $this->redirect('/'); + } + + $model = $this->findModel($id); + $cluster_id = $model->cluster_id; + $model->delete(); + + return $this->redirect(['view', 'id' => $cluster_id]); +} +``` + +--- + +## 💾 Работа с данными + +### Используемые модели + +#### ClusterAdmin +**Путь:** `yii_app\records\ClusterAdmin` +**Таблица БД:** `cluster_admin` + +**Основные поля:** +- `id` — ID записи +- `cluster_id` — ID куста (FK → cluster.id) +- `admin_id` — ID администратора (FK → admin.id) +- `active` — Активность (1 = активная, 0 = неактивная) +- `date_start` — Дата начала назначения +- `date_end` — Дата окончания назначения (nullable) + +**Связи:** +- `hasOne(Cluster, ['id' => 'cluster_id'])` — куст +- `hasOne(Admin, ['id' => 'admin_id'])` — администратор + +**Бизнес-правила:** +- Только одна активная запись на куст +- Только одна активная запись на администратора +- Даты неактивных записей не должны пересекаться +- Активная запись не может начинаться раньше неактивной для другого куста + +--- + +#### Cluster +**Путь:** `yii_app\records\Cluster` +**Таблица БД:** `cluster` + +**Основные поля:** +- `id` — ID куста +- `name` — Название куста + +--- + +#### Admin +**Путь:** `yii_app\records\Admin` +**Таблица БД:** `admin` + +**Основные поля:** +- `id` — ID администратора +- `name` — ФИО +- `group_id` — ID группы + +**Фильтр для менеджеров кустов:** +```php +Admin::find()->where(['IN', 'group_id', [7]]) +``` + +--- + +#### AdminGroup +**Путь:** `yii_app\records\AdminGroup` + +**Константы групп:** +```php +const GROUP_IT = ?; // IT отдел +const DIRECTOR = ?; // Директор +const GROUP_HR = ?; // HR +const GROUP_HR_DIRECTOR = ?; // HR директор +const GROUP_RS_DIRECTOR = ?; // RS директор +``` + +--- + +## ⚠️ Обработка ошибок + +### Типичные ошибки валидации + +| Ошибка | Причина | Решение | +|--------|---------|---------| +| "Этот пользователь уже привязан к кластеру X с активным статусом" | Администратор уже назначен на другой куст | Сначала деактивировать старое назначение | +| "активная запись не может начинаться раньше, чем неактивная запись" | Конфликт дат при создании активной записи | Исправить даты | +| "Пересечение дат с записью от ... до ..." | Неактивные записи пересекаются по датам | Изменить даты без пересечений | +| "Кластер не найден." | Неверный ID куста | Проверить ID | +| "The requested page does not exist." | Запись ClusterAdmin не найдена | Проверить ID | +| "Администратор не найден или уже не активен." | Ошибка в actionMoveAdmin | Проверить данные | + +### Flash-сообщения + +**Success:** +```php +Yii::$app->session->setFlash('success', 'Новая запись успешно создана, предыдущая была закрыта.'); +Yii::$app->session->setFlash('success', 'Запись успешно обновлена.'); +``` + +**Error:** +```php +Yii::$app->session->setFlash('error', 'Ошибка: даты начала или окончания новой неактивной записи не могут быть позже даты начала существующей активной записи (2025-01-01).'); +Yii::$app->session->setFlash('error', 'Ошибка при обновлении существующей записи.'); +Yii::$app->session->setFlash('error', 'Ошибка при создании новой записи.'); +Yii::$app->session->setFlash('error', 'Ошибка при сохранении данных.'); +``` + +--- + +## 🧪 Примеры использования + +### Сценарий 1: Назначить администратора на куст + +**Описание:** Назначить Иванова И.И. на Куст 5 + +**Шаги:** +1. Открыть `/crud/cluster-admin/index` +2. Найти Куст 5 в списке +3. Нажать "Назначить" или выбрать админа из select +4. AJAX отправит `actionMoveAdmin(admin_id=123, cluster_id=5)` +5. Деактивируются старые назначения +6. Создаётся новая активная запись +7. Страница обновляется + +**Код (JavaScript):** + +```javascript +$('#admin-select-5').change(function() { + const adminId = $(this).val(); + const clusterId = 5; + + if (!adminId) return; + + $.ajax({ + url: '/crud/cluster-admin/move-admin', + type: 'POST', + data: { + admin_id: adminId, + cluster_id: clusterId + }, + success: function(response) { + if (response.success) { + location.reload(); + } else { + alert('Ошибка: ' + response.message); + } + } + }); +}); +``` + +--- + +### Сценарий 2: Создать историческую запись + +**Описание:** Добавить неактивную запись для прошлого периода + +**Шаги:** +1. Открыть `/crud/cluster-admin/view?id=5` +2. Нажать "Создать запись" +3. Выбрать администратора +4. Установить `active = 0` +5. Указать даты: `date_start = 2024-01-01`, `date_end = 2024-12-31` +6. Валидация проверит пересечения +7. Сохранить + +**Пример формы:** + +```php +// Форма создания +
+ + + Активная + Неактивная + + + + + +
+``` + +--- + +### Сценарий 3: Редактировать назначение + +**Описание:** Изменить дату окончания неактивной записи + +**Шаги:** +1. Открыть `/crud/cluster-admin/view?id=5` +2. Найти нужную запись в таблице +3. Нажать "Редактировать" +4. Изменить `date_end = 2024-06-30` +5. Валидация проверит пересечения с другими записями +6. Сохранить + +**Валидация проверит:** +- Нет пересечения с другими записями администратора +- Если запись активная → нет конфликтов с другими кустами + +--- + +## ❓ FAQ + +### Вопрос 1: Почему в getAccess() инвертирована логика? + +**Ответ:** Функция возвращает `true` для **ЗАПРЕТА** доступа: + +```php +if (self::getAccess()) { // true = запрет + return $this->redirect('/'); +} +``` + +Группы IT, HR, директора **НЕ** имеют доступа → функция возвращает `false`. +Остальные группы имеют доступ → функция возвращает `true` → редирект. + +Это нелогично, но так реализовано в коде. + +--- + +### Вопрос 2: Что происходит при перемещении администратора (actionMoveAdmin)? + +**Ответ:** При перемещении выполняются 3 операции: + +1. **Деактивация текущего админа куста** (если есть) +2. **Деактивация старого куста нового админа** (если есть) +3. **Создание нового активного назначения** + +Пример: +``` +Было: +- Куст 1: Админ A (active=1) +- Куст 2: Админ B (active=1) + +Перемещаем Админа B на Куст 1: +1. Куст 1: Админ A (active=0, date_end=now) +2. Куст 2: Админ B (active=0, date_end=now) +3. Куст 1: Админ B (active=1, date_start=now) +``` + +--- + +### Вопрос 3: Почему закомментирован ClusterManagerService? + +**Ответ:** В коде есть закомментированные вызовы: + +```php +// ClusterManagerService::clearClusterManagerStores($cluster_id, $admin_id); +// ClusterManagerService::syncClusterManagers($cluster_id, $admin_id); +``` + +Эти методы должны синхронизировать магазины, принадлежащие кусту, с администратором. Возможно, функционал отключён временно или используется другой механизм синхронизации. + +--- + +### Вопрос 4: Какие валидации применяются при создании/редактировании? + +**Ответ:** При создании/редактировании применяются разные валидации: + +**Для активной записи (active=1):** +- Только одна активная запись на куст +- Только одна активная запись на администратора +- Активная запись не может начинаться раньше неактивной для другого куста + +**Для неактивной записи (active=0):** +- Даты не должны пересекаться с другими записями администратора +- Даты не должны быть позже даты начала активной записи куста + +--- + +### Вопрос 5: Зачем нужны историчные записи (active=0)? + +**Ответ:** Историчные записи позволяют: +- Хранить полную историю назначений администраторов +- Восстанавливать информацию о прошлых периодах +- Анализировать изменения в структуре управления +- Формировать отчёты за исторические периоды + +Например: "Кто управлял Кустом 1 в январе 2024 года?" + +--- + +## 🔗 Связанные компоненты + +- [ClusterAdmin](../../../models/ClusterAdmin.md) — Модель назначений +- [Cluster](../../../models/Cluster.md) — Модель кустов +- [Admin](../../../models/Admin.md) — Модель администраторов +- [ClusterManagerService](../../../services/ClusterManagerService.md) — Сервис синхронизации + +--- + +## 📝 История изменений + +| Дата | Версия | Изменения | +|------|--------|-----------| +| 2025-11-26 | 1.0 | Первая версия документации | + +--- + +**Документация создана:** Claude Code + Hive Mind Controllers Swarm +**Дата:** 2025-11-26 +**Фаза:** 2 (Крупные контроллеры) +**Статус:** ✅ Готово diff --git a/erp24/docs/controllers/non-standard/crud/ClusterAdminController_QUICK_REFERENCE.md b/erp24/docs/controllers/non-standard/crud/ClusterAdminController_QUICK_REFERENCE.md new file mode 100644 index 00000000..8eb64053 --- /dev/null +++ b/erp24/docs/controllers/non-standard/crud/ClusterAdminController_QUICK_REFERENCE.md @@ -0,0 +1,441 @@ +# ClusterAdminController - Краткая справка + +## 📌 Основная информация + +**Контроллер:** `ClusterAdminController` +**Namespace:** `yii_app\controllers\crud` +**Путь:** `erp24/controllers/crud/ClusterAdminController.php` +**Размер:** 503 строки +**Actions:** 7 (5 CRUD + 1 AJAX + 1 helper) +**Категория:** Крупный контроллер (Фаза 2) + +--- + +## 🎯 Назначение в одном предложении + +CRUD-контроллер для управления историческими назначениями администраторов на кусты магазинов с валидацией пересечений дат, автоматической деактивацией старых записей и AJAX-перемещением между кустами. + +--- + +## 🔑 Ключевые особенности + +✅ **Историчность данных:** активные/неактивные записи с датами +✅ **Автоматическая деактивация:** старых назначений при создании новых +✅ **AJAX перемещение:** быстрое назначение админов на кусты +✅ **Валидация пересечений:** проверка конфликтов дат +✅ **Контроль доступа:** только для определённых групп +✅ **Защита целостности:** один админ = один активный куст + +--- + +## 📋 Actions (7) + +| # | Action | Тип | Назначение | +|---|--------|-----|------------| +| 1 | `actionIndex()` | CRUD | Список кустов с админами | +| 2 | `actionView($id)` | CRUD | История назначений куста | +| 3 | `actionCreate($cluster_id)` | CRUD | Создание назначения | +| 4 | `actionUpdate($id)` | CRUD | Редактирование назначения | +| 5 | `actionDelete($id)` | CRUD | Удаление назначения | +| 6 | `actionMoveAdmin()` | AJAX | Переместить админа | +| 7 | `getAccess()` | Helper | Проверка прав | + +--- + +## 🗂️ Используемые модели + +| Модель | Таблица | Назначение | +|--------|---------|------------| +| **ClusterAdmin** | `cluster_admin` | Назначения админов (с историей) | +| **Cluster** | `cluster` | Справочник кустов | +| **Admin** | `admin` | Справочник администраторов | +| **AdminGroup** | `admin_group` | Группы администраторов | + +--- + +## 📊 Структура данных ClusterAdmin + +| Поле | Тип | Описание | +|------|-----|----------| +| `id` | int | ID записи | +| `cluster_id` | int | ID куста (FK) | +| `admin_id` | int | ID админа (FK) | +| `active` | int | Активность (1=активная, 0=неактивная) | +| `date_start` | date | Дата начала назначения | +| `date_end` | date | Дата окончания (nullable) | + +**Бизнес-правило:** Только одна активная запись на куст, только одна активная запись на администратора. + +--- + +## 🔄 Основные процессы + +### 1. Структура clusterMapping (actionIndex) + +```mermaid +graph TB + A[Получить все кусты] --> B[Получить все назначения] + B --> C{Есть активная запись?} + C -->|Да| D[status: Активная запись] + C -->|Нет| E{Есть записи?} + E -->|Да| F[status: Нет активных записей] + E -->|Нет| G[status: Нет записей] +``` + +### 2. Перемещение админа (actionMoveAdmin) + +```mermaid +graph TB + A[AJAX POST] --> B[Деактивировать текущего админа куста] + B --> C[Деактивировать старый куст нового админа] + C --> D[Создать новое активное назначение] + D --> E[Вернуть success: true] +``` + +### 3. Валидация при создании/редактировании + +```mermaid +graph TB + A{active = 1?} -->|Да| B[Проверка конфликтов с другими кустами] + A -->|Нет| C[Проверка пересечений дат] + B --> D{Конфликт?} + C --> E{Пересечение?} + D -->|Да| F[Ошибка] + D -->|Нет| G[Сохранить] + E -->|Да| F + E -->|Нет| G +``` + +--- + +## 💡 Быстрые примеры + +### Переместить админа через AJAX + +```javascript +$.ajax({ + url: '/crud/cluster-admin/move-admin', + type: 'POST', + data: { + admin_id: 123, // Новый админ + cluster_id: 5 // Куст назначения + }, + success: function(response) { + if (response.success) { + location.reload(); + } + } +}); +``` + +### Создать активное назначение + +```php +// POST /crud/cluster-admin/create?cluster_id=5 +$model = new ClusterAdmin(); +$model->cluster_id = 5; +$model->admin_id = 123; +$model->active = 1; +$model->date_start = '2025-01-01'; +$model->save(); +``` + +### Создать историческую запись + +```php +$model = new ClusterAdmin(); +$model->cluster_id = 5; +$model->admin_id = 123; +$model->active = 0; // Неактивная +$model->date_start = '2024-01-01'; +$model->date_end = '2024-12-31'; // Период +$model->save(); +``` + +--- + +## 🔐 Контроль доступа + +### Метод getAccess() + +**⚠️ Инвертированная логика:** +- `true` = доступ **ЗАПРЕЩЁН** → redirect +- `false` = доступ **РАЗРЕШЁН** + +**Группы БЕЗ доступа:** +```php +AdminGroup::GROUP_IT // IT отдел +AdminGroup::DIRECTOR // Директор +AdminGroup::GROUP_HR // HR +AdminGroup::GROUP_HR_DIRECTOR // HR директор +AdminGroup::GROUP_RS_DIRECTOR // RS директор +``` + +**Использование:** +```php +if (self::getAccess()) { + return $this->redirect('/'); // Нет доступа +} +``` + +--- + +## ⚙️ Бизнес-правила + +### Активные записи (active=1) + +| Правило | Описание | +|---------|----------| +| **Один куст = один админ** | Только одна активная запись на куст | +| **Один админ = один куст** | Только одна активная запись на админа | +| **Автозакрытие** | При создании новой → старая деактивируется | +| **Проверка дат** | Активная не может начинаться раньше неактивной (другой куст) | + +### Неактивные записи (active=0) + +| Правило | Описание | +|---------|----------| +| **Нет пересечений** | Даты не должны пересекаться с другими записями админа | +| **До активной** | Даты не должны быть позже даты начала активной записи куста | + +--- + +## ⚠️ Валидационные ошибки + +### Активная запись + +``` +❌ "Этот пользователь Иванов И.И. уже привязан к кластеру 3 + с активным статусом." + +❌ "Ошибка: активная запись для Иванов И.И. не может начинаться + раньше, чем неактивная запись (2024-01-01 - 2024-12-31) + для другого кластера." +``` + +### Неактивная запись + +``` +❌ "Пересечение дат с записью от 2024-01-01 до 2024-12-31 + для другого кластера - 3 123" + +❌ "Ошибка: даты начала или окончания новой неактивной записи + не могут быть позже даты начала существующей активной записи + (2025-01-01)." +``` + +--- + +## 📊 Статусы кустов (clusterMapping) + +| Статус | Условие | Описание | +|--------|---------|----------| +| `"Активная запись"` | `hasActive = true` | Есть активное назначение | +| `"Нет активных записей"` | `hasAny = true, hasActive = false` | Есть записи, но все неактивные | +| `"Нет записей"` | `hasAny = false` | Записей нет | + +--- + +## 🔄 Алгоритм actionMoveAdmin() + +**Шаги:** + +1. **Деактивировать текущего админа куста** (если есть) + ```php + WHERE cluster_id = куст AND active = 1 + UPDATE active = 0, date_end = now + ``` + +2. **Деактивировать старый куст нового админа** (если есть) + ```php + WHERE admin_id = новый_админ AND active = 1 + UPDATE active = 0, date_end = now + ``` + +3. **Создать новое назначение** + ```php + INSERT (admin_id, cluster_id, date_start=now, active=1) + ``` + +**Пример:** +``` +Было: +- Куст 1: Админ A (active=1) +- Куст 2: Админ B (active=1) + +Перемещаем Админа B на Куст 1: +↓ +Стало: +- Куст 1: Админ A (active=0, date_end=now) +- Куст 2: Админ B (active=0, date_end=now) +- Куст 1: Админ B (active=1, date_start=now) +``` + +--- + +## 🧪 Сценарии использования + +### Сценарий 1: Назначить админа на куст (через AJAX) + +1. Открыть `/crud/cluster-admin/index` +2. Выбрать админа в select для куста +3. AJAX отправит `actionMoveAdmin()` +4. Деактивируются старые назначения +5. Создаётся новое активное назначение +6. Страница обновляется + +--- + +### Сценарий 2: Создать историческую запись + +1. Открыть `/crud/cluster-admin/view?id=5` +2. Нажать "Создать запись" +3. Выбрать админа +4. Установить `active = 0` +5. Указать даты: `2024-01-01` - `2024-12-31` +6. Валидация проверит пересечения +7. Сохранить + +--- + +### Сценарий 3: Редактировать дату окончания + +1. Открыть `/crud/cluster-admin/view?id=5` +2. Найти запись в таблице +3. Нажать "Редактировать" +4. Изменить `date_end = 2024-06-30` +5. Валидация проверит пересечения +6. Сохранить + +--- + +## 💾 Закомментированный функционал + +```php +// ClusterManagerService::clearClusterManagerStores($cluster_id, $admin_id); +// ClusterManagerService::syncClusterManagers($cluster_id, $admin_id); +``` + +**Назначение:** Синхронизация магазинов куста с администратором. +**Статус:** Отключено (возможно, используется другой механизм). + +--- + +## 🚨 Типичные ошибки + +| Ошибка | Причина | Решение | +|--------|---------|---------| +| "уже привязан к кластеру X" | Админ уже назначен на другой куст | Деактивировать старое назначение | +| "Пересечение дат" | Неактивные записи пересекаются | Изменить даты | +| "не может начинаться раньше" | Активная раньше неактивной | Исправить даты | +| "Кластер не найден" | Неверный ID куста | Проверить ID | +| "Администратор не найден" | Ошибка в moveAdmin | Проверить данные | + +--- + +## 🔗 Связанные компоненты + +- **[ClusterAdmin](../../../models/ClusterAdmin.md)** — Модель назначений +- **[Cluster](../../../models/Cluster.md)** — Модель кустов +- **[Admin](../../../models/Admin.md)** — Модель администраторов +- **[ClusterManagerService](../../../services/ClusterManagerService.md)** — Синхронизация (отключено) + +--- + +## 📝 Полезные ссылки + +- [Детальный анализ контроллера](./ClusterAdminController_ANALYSIS.md) +- [Таблица Actions](./ClusterAdminController_ACTIONS_TABLE.md) +- [План документирования контроллеров](../../CONTROLLERS_DOCUMENTATION_PLAN.md) + +--- + +## ❓ FAQ (Топ-5) + +### 1. Почему getAccess() возвращает инвертированную логику? + +**Ответ:** Функция возвращает `true` для **ЗАПРЕТА** доступа: + +```php +if (self::getAccess()) { // true = запрет + return $this->redirect('/'); +} +``` + +Это нелогично, но так реализовано в коде. + +--- + +### 2. Что происходит при перемещении админа? + +**Ответ:** Выполняются 3 операции: +1. Деактивация текущего админа куста (если есть) +2. Деактивация старого куста нового админа (если есть) +3. Создание нового активного назначения + +--- + +### 3. Зачем нужны неактивные записи (active=0)? + +**Ответ:** Неактивные записи хранят историю назначений: +- Кто управлял кустом в прошлом +- Когда происходили изменения +- Анализ для отчётов + +--- + +### 4. Какие валидации применяются? + +**Ответ:** + +**Для активной записи:** +- Только одна на куст +- Только одна на админа +- Не раньше неактивной (другой куст) + +**Для неактивной записи:** +- Нет пересечений дат с другими записями админа +- Не позже активной записи куста + +--- + +### 5. Почему закомментирован ClusterManagerService? + +**Ответ:** Сервис должен синхронизировать магазины куста с администратором. Возможно: +- Функционал отключён временно +- Используется другой механизм +- Синхронизация выполняется в другом месте + +--- + +## 🎨 Диаграмма состояний + +``` +┌─────────────────┐ +│ Нет записей │ +└────────┬────────┘ + │ create (active=0) + ▼ +┌─────────────────┐ +│ Неактивные │◄───── create (active=0) +│ записи │ +└────────┬────────┘ + │ create (active=1) + ▼ +┌─────────────────┐ +│ Активная │◄───── moveAdmin() +│ запись │ +└────────┬────────┘ + │ create новой (active=1) + ▼ +┌─────────────────┐ +│ Деактивация │ +│ active=0 │ +└─────────────────┘ +``` + +--- + +**Краткая справка создана:** 2025-11-26 +**Версия:** 1.0 +**Статус:** ✅ Готово -- 2.39.5