## 📋 Описание
-**Grade** - модÑ\83лÑ\8c Ñ\83пÑ\80авлениÑ\8f Ñ\81иÑ\81Ñ\82емой гÑ\80ейдов (Ñ\83Ñ\80овней) Ñ\81оÑ\82Ñ\80Ñ\83дников и должноÑ\81Ñ\82ей. Ð\9eпÑ\80еделÑ\8fеÑ\82 иеÑ\80аÑ\80Ñ\85иÑ\8e позиÑ\86ий, Ñ\83Ñ\80овни оплаÑ\82Ñ\8b и Ñ\82Ñ\80ебованиÑ\8f к квалиÑ\84икаÑ\86ии.
+**Grade** - комплекÑ\81нÑ\8bй модÑ\83лÑ\8c Ñ\83пÑ\80авлениÑ\8f Ñ\81иÑ\81Ñ\82емой гÑ\80ейдов (Ñ\83Ñ\80овней Ñ\81оÑ\82Ñ\80Ñ\83дников), должноÑ\81Ñ\82ей, навÑ\8bков и Ñ\80аÑ\81Ñ\87еÑ\82а окладов в Ñ\81иÑ\81Ñ\82еме ERP24. Ð\9cодÑ\83лÑ\8c Ñ\80еализÑ\83еÑ\82 две паÑ\80аллелÑ\8cнÑ\8bе, но инÑ\82егÑ\80иÑ\80ованнÑ\8bе Ñ\81иÑ\81Ñ\82емÑ\8b: Ñ\81иÑ\81Ñ\82емÑ\83 гÑ\80ейдов Ñ\81 Ñ\86енообÑ\80азованием по гоÑ\80одам и деÑ\82алÑ\8cнÑ\83Ñ\8e Ñ\81иÑ\81Ñ\82емÑ\83 должноÑ\81Ñ\82ей Ñ\81 иеÑ\80аÑ\80Ñ\85ией каÑ\80Ñ\8cеÑ\80ного Ñ\80оÑ\81Ñ\82а и Ñ\82Ñ\80ебованиÑ\8fми к навÑ\8bкам.
### Основные возможности
-- 📊 Система грейдов (уровней сотрудников)
-- 💼 Управление должностями
-- 💰 Связь с окладами и ставками
-- 📈 Карьерное развитие
-- 🎯 Требования к квалификации
+- 📊 Управление грейдами (уровнями сотрудников)
+- 💼 Иерархия должностей с карьерным ростом
+- 💰 Гибкое ценообразование (по городам, грейдам, индивидуальные ставки)
+- 🎓 Система навыков с требованиями и сроками действия
+- 📈 Карьерное развитие сотрудников
+- 📝 История изменений грейдов и должностей
+- 🔄 Временное управление изменениями (со следующего месяца)
+- 👥 Специальная логика для операционных групп (флористы, администраторы)
-## 🏗️ Архитектура
+## 🏗️ Архитектура модуля
-**Контроллеры:** 2
-- `GradeController` - управление грейдами
-- `EmployeePositionController` - управление должностями
+```mermaid
+graph TB
+ subgraph "Controllers"
+ GC[GradeController<br/>8 actions]
+ MGC[MyGradeController<br/>Self-service]
+ EPC[EmployeePositionController<br/>CRUD]
+ end
-**Модели (4):**
-- `Grade` - грейды/уровни
-- `EmployeePosition` - должности
-- `GradePosition` - связь грейдов и должностей
-- `GradeRequirements` - требования к грейдам
+ subgraph "Actions"
+ IA[IndexAction<br/>Список сотрудников]
+ CA[CreateAction<br/>Создание грейда]
+ UA[UpdateAction<br/>Обновление позиции/навыков]
+ AUA[AdminUpdateAction<br/>Редактирование сотрудника]
+ AHA[AdminHistoryAction<br/>История грейдов]
+ PA[PriceAction<br/>Управление ценами]
+ SPA[SkillForPositionAction<br/>Навыки для должностей]
+ end
-## 💼 Основные сущности
+ subgraph "Grade System"
+ G[Grade<br/>Грейды]
+ GP[GradePrice<br/>Цены по городам]
+ GG[GradeGroup<br/>Связь грейд-группа]
+ AGH[AdminGradeHistory<br/>История грейдов]
+ end
-**Грейды:**
-- Junior (Стажер)
-- Middle (Флорист)
-- Senior (Старший флорист)
-- Lead (Администратор)
-- Expert (Супервайзер)
+ subgraph "Position System"
+ EP[EmployeePosition<br/>Должности]
+ EPS[EmployeePositionStatus<br/>Текущие должности]
+ EPSK[EmployeePositionSkill<br/>Требуемые навыки]
+ end
-**Должности:**
-- Флорист
-- Администратор магазина
-- Кустовой директор
-- Менеджер отдела
-- И другие
+ subgraph "Skill System"
+ ES[EmployeeSkill<br/>Справочник навыков]
+ ESS[EmployeeSkillStatus<br/>Полученные навыки]
+ end
-**Связь с зарплатой:**
-- Каждый грейд имеет базовую ставку
-- Должность определяет коэффициент
-- Итоговый оклад = ставка × коэффициент
+ subgraph "Salary System"
+ EPAY[EmployeePayment<br/>Индивидуальные ставки]
+ end
-## 🔗 Связи с модулями
+ GC --> IA
+ GC --> CA
+ GC --> UA
+ GC --> AUA
+ GC --> AHA
+ GC --> PA
+ GC --> SPA
-- **Payroll** - расчет зарплаты на основе грейда
-- **Bonus** - бонусы зависят от уровня
-- **Rating** - требования к рейтингу для повышения
-- **HR** - карьерное развитие
+ CA --> G
+ CA --> GG
+ PA --> GP
+ AHA --> AGH
+
+ UA --> EP
+ UA --> EPS
+ UA --> ESS
+
+ AUA --> Admin
+
+ EP --> EPSK
+ EPSK --> ES
+ EPS --> Admin
+ ESS --> Admin
+
+ G --> GP
+ G --> AGH
+ Admin --> AGH
+ Admin --> EPAY
+
+ style GC fill:#e1f5ff
+ style G fill:#fff4e1
+ style EP fill:#fff4e1
+ style EPAY fill:#e8f5e9
+```
+
+## 🎮 Контроллеры
+
+### 1. GradeController
+
+**Файл:** `erp24/controllers/GradeController.php`
+
+**Назначение:** Основной контроллер модуля, делегирующий логику внешним Action-классам.
+
+**Действия:**
+```php
+public function actions(): array
+{
+ return [
+ 'index' => IndexAction::class, // Список сотрудников
+ 'update' => UpdateAction::class, // Обновление позиции/навыков
+ 'skillForPosition' => SkillForPositionAction::class, // Навыки для должностей
+ 'admin-update' => AdminUpdateAction::class, // Редактирование сотрудника
+ 'admin-group-update' => AdminGroupUpdateAction::class, // Редактирование группы
+ 'create' => CreateAction::class, // Создание грейда
+ 'admin-history' => AdminHistoryAction::class, // История грейдов
+ 'price' => PriceAction::class, // Управление ценами
+ ];
+}
+```
+
+---
+
+### 2. MyGradeController
+
+**Файл:** `erp24/controllers/MyGradeController.php`
+
+**Назначение:** Самообслуживание сотрудников - просмотр собственных грейдов и регламентов.
+
+**Действия:**
+```php
+public function actions(): array
+{
+ return [
+ 'index' => \yii_app\actions\mygrade\IndexAction::class,
+ ];
+}
+```
+
+**Функционал:**
+- Просмотр собственного грейда
+- Список назначенных регламентов
+- Статусы прохождения регламентов
+
+---
+
+### 3. EmployeePositionController
+
+**Файл:** `erp24/controllers/crud/EmployeePositionController.php`
+
+**Назначение:** CRUD контроллер для управления должностями.
+
+**Стандартные CRUD действия:**
+- `index` - Список должностей
+- `view` - Просмотр должности
+- `create` - Создание новой должности
+- `update` - Редактирование должности
+- `delete` - Удаление должности
+
+---
+
+## 📦 Models/Records
+
+### Система грейдов
+
+#### 1. Grade
+
+**Файл:** `erp24/records/Grade.php`
+
+**Таблица:** `grade`
+
+**Назначение:** Основная модель для хранения грейдов (уровней сотрудников).
+
+##### Свойства
+```php
+@property int $id
+@property string $name // Название грейда
+@property int $created_by // ID создателя записи
+```
+
+##### Relations
+```php
+public function getGradeGroup() // → GradeGroup (hasOne)
+public function getGroup() // → AdminGroup (hasOne via gradeGroup)
+public function getCreatedBy() // → Admin (hasOne)
+```
+
+**Пример:**
+```php
+$grade = new Grade();
+$grade->name = 'Junior';
+$grade->created_by = Yii::$app->user->id;
+$grade->save();
+```
+
+---
+
+#### 2. GradePrice ⭐
+
+**Файл:** `erp24/records/GradePrice.php`
+
+**Таблица:** `grade_price`
+
+**Назначение:** Хранение ставок окладов по грейдам с учетом города и временной валидности.
+
+##### Свойства
+```php
+@property int $id
+@property string $created_at // Время начала действия
+@property int $created_by // ID создателя
+@property string $closed_at // Время окончания (по умолчанию 2100-01-01)
+@property int $grade_id // FK к таблице grade
+@property int $city_id // FK к таблице city
+@property float $price_month // Заработок в месяц
+@property float $price_hour // Заработок в час
+```
+
+##### Валидация
+- Все поля обязательны
+- `price_month`, `price_hour` — числовые значения > 0
+
+##### Relations
+```php
+public function getGrade() // → Grade
+public function getCity() // → City
+public function getCreatedBy() // → Admin
+```
+
+**Формула зарплаты:**
+```sql
+SELECT price_month, price_hour
+FROM grade_price
+WHERE grade_id = :grade_id
+ AND city_id = :city_id
+ AND :current_date BETWEEN created_at AND closed_at
+ORDER BY created_at DESC
+LIMIT 1
+```
+
+**Пример:**
+```php
+// Нижний Новгород, грейд Junior
+$price = new GradePrice();
+$price->grade_id = 1;
+$price->city_id = 1342; // Н.Новгород
+$price->price_month = 50000;
+$price->price_hour = 250;
+$price->created_at = '2025-04-01 00:00:01'; // Со следующего месяца
+$price->closed_at = '2100-01-01 00:00:00'; // Бессрочно
+$price->save();
+```
+
+---
+
+#### 3. GradeGroup
+
+**Файл:** `erp24/records/GradeGroup.php`
+
+**Таблица:** `grade_group`
+
+**Назначение:** Связь многие-ко-многим между грейдами и группами сотрудников.
+
+##### Свойства
+```php
+@property int $group_id // FK к admin_group
+@property int $grade_id // FK к grade
+```
+
+**Primary Key:** Композитный (`group_id`, `grade_id`)
+
+**Пример:**
+```php
+// Группа "Флористы" может иметь грейды Junior, Middle, Senior
+$gg1 = new GradeGroup();
+$gg1->group_id = 89; // Флористы
+$gg1->grade_id = 1; // Junior
+$gg1->save();
+
+$gg2 = new GradeGroup();
+$gg2->group_id = 89;
+$gg2->grade_id = 2; // Middle
+$gg2->save();
+```
+
+---
+
+#### 4. AdminGradeHistory
+
+**Файл:** `erp24/records/AdminGradeHistory.php`
+
+**Таблица:** `admin_grade_history`
+
+**Назначение:** Временное отслеживание назначения грейдов сотрудникам с полной историей.
+
+##### Свойства
+```php
+@property int $id
+@property int $admin_id // FK к admins
+@property string $created_at // Время начала действия грейда
+@property int $created_by // ID назначившего
+@property string $closed_at // Время окончания (2100-01-01 для текущего)
+@property int $grade_id // FK к grade
+```
+
+##### Relations
+```php
+public function getAdmin() // → Admin
+public function getGrade() // → Grade
+public function getCreatedBy() // → Admin
+```
+
+**Логика:**
+- Один активный грейд на сотрудника: `closed_at = '2100-01-01 00:00:00'`
+- При назначении нового грейда старые закрываются
+- Все изменения вступают в силу **с 1 числа следующего месяца**
+
+**Пример:**
+```php
+// Назначение грейда Middle сотруднику с ID=100
+$history = new AdminGradeHistory();
+$history->admin_id = 100;
+$history->grade_id = 2; // Middle
+$history->created_at = date("Y-m-d 00:00:01", strtotime('first day of next month'));
+$history->closed_at = '2100-01-01 00:00:00';
+$history->created_by = Yii::$app->user->id;
+$history->save();
+
+// Закрытие старых грейдов
+AdminGradeHistory::updateAll(
+ ['closed_at' => date("Y-m-d 00:00:00", strtotime('first day of next month'))],
+ ['admin_id' => 100, 'closed_at' => '2100-01-01 00:00:00']
+);
+```
+
+---
+
+### Система должностей
+
+#### 5. EmployeePosition ⭐
+
+**Файл:** `erp24/records/EmployeePosition.php`
+
+**Таблица:** `employee_position`
+
+**Назначение:** Определение должностей с иерархией карьерного роста.
+
+##### Свойства
+```php
+@property int $id
+@property string $name
+@property int $next_position_id // Следующая должность в иерархии
+@property int $posit // Позиция (для сортировки)
+@property int $salary // Базовая зарплата
+@property string $alias // Короткое название
+@property int $group_id // FK к admin_group
+@property string $created_at
+@property int $created_by
+@property string $updated_at
+@property int $updated_by
+```
+
+##### Behaviors
+- `TimestampBehavior` (created_at, updated_at)
+- `BlameableBehavior` (created_by, updated_by)
+
+##### Relations
+```php
+public function getPositionSkill() // → EmployeePositionSkill[]
+public function getSkills() // → EmployeeSkill[] (via positionSkill)
+public function getAdminGroup() // → AdminGroup
+```
+
+##### Ключевые методы
+```php
+public static function getAllIdName(): array
+```
+**Возвращает:** `[id => name]` для всех должностей
+
+**Иерархия карьеры:**
+```mermaid
+graph LR
+ A[Флорист стажёр<br/>posit=1] -->|next_position_id| B[Флорист<br/>posit=2]
+ B -->|next_position_id| C[Флорист-поддержка<br/>posit=3]
+ C -->|next_position_id| D[Администратор<br/>posit=4]
+ D -->|next_position_id| E[Старший администратор<br/>posit=5]
+```
+
+**Пример:**
+```php
+$position = new EmployeePosition();
+$position->name = 'Флорист';
+$position->posit = 2;
+$position->salary = 45000;
+$position->alias = 'florist';
+$position->next_position_id = 3; // → Флорист-поддержка
+$position->group_id = 89; // Группа флористов
+$position->save();
+```
+
+---
+
+#### 6. EmployeePositionStatus
+
+**Файл:** `erp24/records/EmployeePositionStatus.php`
+
+**Таблица:** `employee_position_status`
+
+**Назначение:** Отслеживание текущих и исторических должностей сотрудников.
+
+##### Свойства
+```php
+@property int $admin_id
+@property int $position_id
+@property string $created_at
+@property string|null $closed_at
+```
+
+**Логика:**
+- Текущая должность: `closed_at IS NULL`
+- Исторические: `closed_at IS NOT NULL`
+
+**Пример:**
+```php
+// Назначение сотруднику должности "Флорист"
+$status = new EmployeePositionStatus();
+$status->admin_id = 100;
+$status->position_id = 2;
+$status->created_at = date('Y-m-d H:i:s');
+$status->closed_at = null; // Текущая
+$status->save();
+```
+
+---
+
+#### 7. EmployeePositionSkill
+
+**Файл:** `erp24/records/EmployeePositionSkill.php`
+
+**Таблица:** `employee_position_skill`
+
+**Назначение:** Определение требуемых навыков для каждой должности (many-to-many).
+
+##### Свойства
+```php
+@property int $position_id
+@property int $skill_id
+```
+
+**Primary Key:** Композитный (`position_id`, `skill_id`)
+
+**Пример:**
+```php
+// Для должности "Администратор" требуется навык "Работа с кассой"
+$ps = new EmployeePositionSkill();
+$ps->position_id = 4; // Администратор
+$ps->skill_id = 5; // Работа с кассой
+$ps->save();
+```
+
+---
+
+### Система навыков
+
+#### 8. EmployeeSkill
+
+**Файл:** `erp24/records/EmployeeSkill.php`
+
+**Таблица:** `employee_skill`
+
+**Назначение:** Справочник доступных навыков с периодом действия.
+
+##### Свойства
+```php
+@property int $id
+@property string $name
+@property int $lifespan // Продолжительность действия в днях
+```
+
+**Примеры навыков:**
+- "Работа с кассой" (lifespan=365 дней)
+- "Флористика базовая" (lifespan=730 дней)
+- "Управление персоналом" (lifespan=365 дней)
+
+**Пример:**
+```php
+$skill = new EmployeeSkill();
+$skill->name = 'Работа с кассой';
+$skill->lifespan = 365; // Навык действует 1 год
+$skill->save();
+```
+
+---
+
+#### 9. EmployeeSkillStatus
+
+**Файл:** `erp24/records/EmployeeSkillStatus.php`
+
+**Таблица:** `employee_skill_status`
+
+**Назначение:** Отслеживание полученных навыков сотрудниками с валидностью.
+
+##### Свойства
+```php
+@property int $admin_id
+@property int $skill_id
+@property int $status
+@property string $created_at
+@property string $closed_at
+```
+
+**Primary Key:** Композитный (`admin_id`, `skill_id`)
+
+**Логика:**
+- `created_at` — дата получения навыка
+- `closed_at` = `created_at` + `skill.lifespan` дней
+- После `closed_at` навык считается устаревшим
+
+**Пример:**
+```php
+// Сотрудник получил навык "Работа с кассой"
+$skillStatus = new EmployeeSkillStatus();
+$skillStatus->admin_id = 100;
+$skillStatus->skill_id = 5;
+$skillStatus->status = 1;
+$skillStatus->created_at = date('Y-m-d H:i:s');
+$skillStatus->closed_at = date('Y-m-d H:i:s', strtotime('+365 days'));
+$skillStatus->save();
+```
+
+---
+
+### Система индивидуальных окладов
+
+#### 10. EmployeePayment ⭐
+
+**Файл:** `erp24/records/EmployeePayment.php`
+
+**Таблица:** `employee_payment`
+
+**Назначение:** Индивидуальные ставки оплаты для сотрудников (приоритет выше GradePrice).
+
+##### Свойства
+```php
+@property int $id
+@property int $admin_id // Сотрудник
+@property int|null $admin_group_id // Должность
+@property string|null $date // Дата начала действия
+@property float $monthly_salary // Месячный оклад
+@property float $daily_payment // Подневная оплата
+@property int|null $creator_id // Кто создал правило
+```
+
+##### Валидация
+- `monthly_salary` > 0
+- `daily_payment` > 0
+- `daily_payment` ≤ `monthly_salary`
+- Уникальность: (`admin_id`, `date`)
+
+##### Relations
+```php
+public function getAdmin() // → Admin
+public function getAdminGroup() // → AdminGroup
+public function getCreator() // → Admin
+```
+
+##### Ключевые методы
+
+```php
+public static function getMonthlySalary($adminId, $date): array|null
+```
+**Описание:** Получение правила оплаты, действующего на указанную дату
+**Возвращает:**
+```php
+[
+ 'id' => 1,
+ 'admin_id' => 100,
+ 'monthly_salary' => 60000,
+ 'daily_payment' => 2000,
+ 'date' => '2025-01-01'
+]
+```
+
+**Пример использования:**
+```php
+$payment = EmployeePayment::getMonthlySalary(100, '2025-03-15');
+echo "Месячный оклад: " . $payment['monthly_salary'];
+// Вывод: Месячный оклад: 60000
+```
+
+---
+
+```php
+public static function getSalary($adminId, $date, $adminData): float
+```
+**Описание:** Расчет зарплаты на основе кэшированных данных
+**Параметры:**
+- `$adminId` — ID сотрудника
+- `$date` — Дата расчета
+- `$adminData` — Кэшированные данные сотрудника
+
+**Пример:**
+```php
+// Установка индивидуальной ставки
+$payment = new EmployeePayment();
+$payment->admin_id = 100;
+$payment->date = '2025-04-01';
+$payment->monthly_salary = 65000;
+$payment->daily_payment = 2200;
+$payment->save();
+
+// Эта ставка перекроет GradePrice
+```
+
+---
+
+## 🎬 Actions (Бизнес-логика)
+
+### 1. IndexAction (grade)
+
+**Файл:** `erp24/actions/grade/IndexAction.php`
+
+**Назначение:** Главная страница - список всех сотрудников с поиском и фильтрацией.
+
+**Основная логика:**
+
+**1. Добавление сотрудника (add_admin):**
+```php
+$admin = new Admin();
+$admin->login = 'new_user';
+$admin->name = 'Новый сотрудник';
+$admin->password = md5('default_password');
+$admin->group_id = 1000; // NOT_INITIALIZED_GROUP
+$admin->save();
+// Редирект на admin-update
+```
+
+**2. Добавление группы (add_admin_group):**
+```php
+$group = new AdminGroup();
+$group->name = 'Новая группа';
+$group->parent_id = 0;
+$group->save();
+// Редирект на admin-group-update
+```
+
+**3. Поиск сотрудников:**
+- По имени (поле `name`)
+- По группе (`group_id`)
+- По магазину (`store_id`)
+
+**4. Загрузка данных:**
+```php
+$admins = Admin::find()
+ ->with('position')
+ ->with('adminGroup')
+ ->with('store')
+ ->orderBy('name')
+ ->all();
+```
+
+**Возвращает:** Рендер `grade/index` с данными:
+- Список сотрудников
+- Список групп для фильтра
+- Список магазинов для фильтра
+- Активные чаты сотрудников (state=2)
+
+---
+
+### 2. CreateAction (grade)
+
+**Файл:** `erp24/actions/grade/CreateAction.php`
+
+**Назначение:** Создание нового грейда с привязкой к группе.
+
+**Основная логика:**
+
+**POST-обработка:**
+```php
+// 1. Создание грейда
+$grade = new Grade();
+$grade->name = $_POST['Grade']['name'];
+$grade->created_by = Yii::$app->user->id;
+$grade->save();
+
+// 2. Создание связи с группой
+$gradeGroup = new GradeGroup();
+$gradeGroup->grade_id = $grade->id;
+$gradeGroup->group_id = $_POST['GradeGroup']['group_id'];
+$gradeGroup->save();
+
+// 3. Flash-сообщение
+Yii::$app->session->setFlash('success', 'Грейд создан');
+```
+
+**Возвращает:** Рендер `grade/create` с данными:
+- Список всех AdminGroups (id >= 0)
+- Список всех Grades с relations: `group`, `createdBy`
+
+---
+
+### 3. UpdateAction (grade) ⭐
+
+**Файл:** `erp24/actions/grade/UpdateAction.php`
+
+**Назначение:** Обновление должности и навыков сотрудника.
+
+**Основная логика:**
+
+**1. Обновление должности (position_id):**
+```php
+$status = EmployeePositionStatus::findOne([
+ 'admin_id' => $admin_id,
+ 'closed_at' => null
+]);
+
+if (!$status) {
+ $status = new EmployeePositionStatus();
+ $status->admin_id = $admin_id;
+}
+
+$status->position_id = $_POST['position_id'];
+$status->created_at = date('Y-m-d H:i:s');
+$status->save();
+```
+
+**2. Обновление навыков (skills):**
+```php
+foreach ($_POST['skills'] as $skillId) {
+ $skillStatus = EmployeeSkillStatus::findOne([
+ 'admin_id' => $admin_id,
+ 'skill_id' => $skillId
+ ]);
+
+ if (!$skillStatus) {
+ $skill = EmployeeSkill::findOne($skillId);
+
+ $skillStatus = new EmployeeSkillStatus();
+ $skillStatus->admin_id = $admin_id;
+ $skillStatus->skill_id = $skillId;
+ $skillStatus->status = 1;
+ $skillStatus->created_at = date('Y-m-d H:i:s');
+ $skillStatus->closed_at = date('Y-m-d H:i:s', strtotime("+{$skill->lifespan} days"));
+ $skillStatus->save();
+ }
+}
+```
+
+**3. Расчет следующей должности:**
+```php
+$currentPosition = $admin->position;
+$nextPosition = EmployeePosition::findOne($currentPosition->next_position_id);
+
+// Загрузка требуемых навыков
+$requiredSkills = $nextPosition->skills;
+```
+
+**Возвращает:** Рендер `grade/update` с данными:
+- Все должности (ordered by `posit`)
+- Все навыки
+- Текущие навыки сотрудника
+- Следующая должность в иерархии
+- Требуемые навыки для следующей должности
+
+---
+
+### 4. AdminUpdateAction (grade) ⭐⭐⭐
+
+**Файл:** `erp24/actions/grade/AdminUpdateAction.php`
+
+**Назначение:** Комплексное редактирование сотрудника (самый сложный action модуля).
+
+**Проверки прав:**
+```php
+$canView = Yii::$app->user->can('seeAdminSettings');
+$canEdit = Yii::$app->user->can('updateAdminSettings');
+$canPassword = Yii::$app->user->can('managePassword');
+$canAvatar = Yii::$app->user->can('manageAvatarka');
+$canChangeGroup = Yii::$app->user->can('updateAdminSettingsGroupId');
+$canEditHR = Yii::$app->user->can('updateAdminSettingsOnlyByHrAndAdministrator');
+```
+
+**Специальная логика для операционных групп:**
+```php
+$specialGroups = [30, 35, 40, 45, 50, 72, $workersGroupId];
+
+if (in_array($group_id, $specialGroups)) {
+ // Для спецгрупп: group_name = EmployeePosition.name + shift
+ $position = EmployeePosition::findOne($employee_position_id);
+ $model->group_name = $position->name;
+
+ if ($shift) {
+ $model->group_name .= " ({$shift})";
+ }
+} else {
+ // Для остальных: свободный текст в custom_position
+ $model->group_name = $custom_position;
+ $model->employee_position_id = null;
+ $model->shift = null;
+}
+```
+
+**Обработка магазинов:**
+```php
+// Удаление старых связей
+AdminStores::deleteAll(['admin_id' => $model->id]);
+
+// Создание новых
+foreach ($storeArray as $storeId) {
+ $adminStore = new AdminStores();
+ $adminStore->admin_id = $model->id;
+ $adminStore->store_id = $storeId;
+ $adminStore->save();
+}
+
+// Формирование строк
+$model->store_arr = implode(',', $storeArray);
+$model->store_arr_guid = implode(',', $storeGuidArray);
+```
+
+**Кэш RBAC:**
+```php
+if ($model->group_id != (int)$attributes['group_id']) {
+ Yii::$app->cache->set("dirtyAuthSettings", true);
+}
+```
+
+**История изменений:**
+```php
+HistoryService::setHistoryUserInfo($model);
+$adminHistoryCategories = HistoryService::getHistoryAdmin($model->id);
+```
+
+**Возвращает:** Рендер `grade/admin-update` с данными:
+- Все AdminGroups
+- Все Admins (сгруппированные по AdminGroup)
+- Все EmployeePositions (ordered by posit)
+- Все CityStores
+- Все Companies
+- История изменений сотрудника по категориям
+
+---
+
+### 5. AdminGroupUpdateAction (grade)
+
+**Файл:** `erp24/actions/grade/AdminGroupUpdateAction.php`
+
+**Назначение:** Редактирование группы сотрудников.
+
+**Основная логика:**
+```php
+$model = AdminGroup::findOne($id);
+
+if ($model->load(Yii::$app->request->post())) {
+ // Пропуск валидации из-за legacy полей
+ $model->save(false);
+ return $this->controller->redirect(['index']);
+}
+```
+
+**Примечание:** `save(false)` используется из-за наличия дополнительных полей в таблице, не входящих в модель.
+
+---
+
+### 6. AdminHistoryAction (grade) ⭐
+
+**Файл:** `erp24/actions/grade/AdminHistoryAction.php`
+
+**Назначение:** Управление историей назначения грейдов сотрудникам.
+
+**Основная логика:**
+
+**POST-обработка (создание назначения):**
+```php
+// 1. Закрытие всех текущих грейдов сотрудника
+AdminGradeHistory::updateAll(
+ ['closed_at' => date("Y-m-d 00:00:00", strtotime('first day of next month'))],
+ [
+ 'admin_id' => $admin_id,
+ 'closed_at' => '2100-01-01 00:00:00'
+ ]
+);
+
+// 2. Создание нового назначения (с 1 числа следующего месяца)
+$history = new AdminGradeHistory();
+$history->admin_id = $admin_id;
+$history->grade_id = $grade_id;
+$history->created_at = date("Y-m-d 00:00:01", strtotime('first day of next month'));
+$history->closed_at = '2100-01-01 00:00:00';
+$history->created_by = Yii::$app->user->id;
+$history->save();
+
+Yii::$app->session->setFlash('success', 'Грейд назначен (вступит в силу с 1 числа)');
+```
+
+**Возвращает:** Рендер `grade/admin-history` с данными:
+- Все Admins (id > 0) для dropdown
+- Все Grades для dropdown
+- Последние 100 записей истории (DESC)
+
+**Важно:** Все изменения грейдов вступают в силу **с 1 числа следующего месяца**!
+
+---
+
+### 7. PriceAction (grade) ⭐
+
+**Файл:** `erp24/actions/grade/PriceAction.php`
+
+**Назначение:** Управление ценами (ставками) по грейдам и городам.
+
+**Основная логика:**
+
+**POST-обработка (создание новой цены):**
+```php
+// 1. Закрытие старых цен для этой комбинации grade+city
+GradePrice::updateAll(
+ ['closed_at' => date("Y-m-d 00:00:00", strtotime('first day of next month'))],
+ [
+ 'grade_id' => $grade_id,
+ 'city_id' => $city_id,
+ 'closed_at' => '2100-01-01 00:00:00'
+ ]
+);
+
+// 2. Создание новой цены (с 1 числа следующего месяца)
+$price = new GradePrice();
+$price->grade_id = $grade_id;
+$price->city_id = $city_id;
+$price->price_month = $price_month;
+$price->price_hour = $price_hour;
+$price->created_at = date("Y-m-d 00:00:01", strtotime('first day of next month'));
+$price->closed_at = '2100-01-01 00:00:00';
+$price->created_by = Yii::$app->user->id;
+$price->save();
+
+Yii::$app->session->setFlash('success', 'Цена установлена (с 1 числа)');
+```
+
+**Возвращает:** Рендер `grade/price` с данными:
+- Все Grades
+- Все Cities
+- Все GradePrices (DESC by closed_at)
+
+**Важно:** Все изменения цен вступают в силу **с 1 числа следующего месяца**!
+
+---
+
+### 8. SkillForPositionAction (grade)
+
+**Файл:** `erp24/actions/grade/SkillForPositionAction.php`
+
+**Назначение:** Управление требуемыми навыками для должностей.
+
+**Основная логика:**
+
+**POST-обработка (обновление навыков для должности):**
+```php
+$position_id = $_POST['position_id'];
+$skills = $_POST['skills'] ?? [];
+
+// 1. Удаление всех старых связей
+EmployeePositionSkill::deleteAll(['position_id' => $position_id]);
+
+// 2. Создание новых связей
+foreach ($skills as $skillId) {
+ $ps = new EmployeePositionSkill();
+ $ps->position_id = $position_id;
+ $ps->skill_id = $skillId;
+ $ps->save();
+}
+
+return 'ok';
+```
+
+**Возвращает:**
+- GET: Рендер `grade/skillForPosition` с данными:
+ - Все EmployeePositions (ordered by posit)
+ - Все EmployeeSkills
+- POST: `'ok'`
+
+---
+
+### 9. IndexAction (mygrade)
+
+**Файл:** `erp24/actions/mygrade/IndexAction.php`
+
+**Назначение:** Самообслуживание - просмотр сотрудником своих грейдов и регламентов.
+
+**Основная логика:**
+```php
+// 1. Загрузка текущего сотрудника
+$admin = Admin::findOne(Yii::$app->user->id);
+
+// 2. Поиск регламентов для группы сотрудника
+$regulations = Regulations::find()
+ ->innerJoin('regulation_group', 'regulation_group.regulation_id = regulations.id')
+ ->where(['regulation_group.group_id' => $admin->group_id])
+ ->all();
+
+// 3. Поиск пройденных регламентов
+$passedRegulations = RegulationsPassed::find()
+ ->where(['admin_id' => $admin->id])
+ ->indexBy('regulation_id')
+ ->all();
+
+// 4. Формирование данных
+foreach ($regulations as $regulation) {
+ $data[] = [
+ 'name' => $regulation->name,
+ 'status' => isset($passedRegulations[$regulation->id]) ? 'Пройден' : 'Не пройден'
+ ];
+}
+```
+
+**Возвращает:** Рендер `mygrade/index` с данными:
+- Список регламентов
+- Статусы прохождения
+
+---
+
+## 🗄️ Структура базы данных
+
+### ER-диаграмма
+
+```mermaid
+erDiagram
+ Admin ||--o{ AdminGradeHistory : "grade history"
+ Admin ||--o| EmployeePositionStatus : "current position"
+ Admin ||--o{ EmployeeSkillStatus : "acquired skills"
+ Admin ||--o{ EmployeePayment : "salary rules"
+ Admin }o--|| AdminGroup : "belongs to"
+ Admin }o--o| EmployeePosition : "has position"
+
+ Grade ||--o{ GradePrice : "prices by city"
+ Grade ||--o{ GradeGroup : "belongs to groups"
+ Grade ||--o{ AdminGradeHistory : "assigned to admins"
+
+ AdminGroup ||--o{ GradeGroup : "has grades"
+ AdminGroup ||--o{ EmployeePosition : "has positions"
+
+ EmployeePosition ||--o{ EmployeePositionSkill : "requires skills"
+ EmployeePosition ||--o| EmployeePosition : "next_position"
+ EmployeePosition ||--o{ EmployeePositionStatus : "assigned to admins"
+
+ EmployeeSkill ||--o{ EmployeePositionSkill : "required by positions"
+ EmployeeSkill ||--o{ EmployeeSkillStatus : "acquired by admins"
+
+ City ||--o{ GradePrice : "grade prices"
+
+ Grade {
+ int id PK
+ string name
+ int created_by FK
+ }
+
+ GradePrice {
+ int id PK
+ int grade_id FK
+ int city_id FK
+ datetime created_at
+ datetime closed_at
+ float price_month
+ float price_hour
+ int created_by FK
+ }
+
+ GradeGroup {
+ int group_id PK,FK
+ int grade_id PK,FK
+ }
+
+ AdminGradeHistory {
+ int id PK
+ int admin_id FK
+ int grade_id FK
+ datetime created_at
+ datetime closed_at
+ int created_by FK
+ }
+
+ EmployeePosition {
+ int id PK
+ string name
+ int next_position_id FK
+ int posit
+ int salary
+ string alias
+ int group_id FK
+ }
+
+ EmployeePositionStatus {
+ int admin_id PK,FK
+ int position_id FK
+ datetime created_at
+ datetime closed_at
+ }
+
+ EmployeePositionSkill {
+ int position_id PK,FK
+ int skill_id PK,FK
+ }
+
+ EmployeeSkill {
+ int id PK
+ string name
+ int lifespan
+ }
+
+ EmployeeSkillStatus {
+ int admin_id PK,FK
+ int skill_id PK,FK
+ int status
+ datetime created_at
+ datetime closed_at
+ }
+
+ EmployeePayment {
+ int id PK
+ int admin_id FK
+ int admin_group_id FK
+ date date
+ float monthly_salary
+ float daily_payment
+ int creator_id FK
+ }
+```
+
+---
+
+## 📊 Бизнес-логика и формулы
+
+### 1. Расчет зарплаты (приоритеты)
+
+**Система приоритетов:**
+```mermaid
+graph TD
+ A[Расчет зарплаты] --> B{Есть EmployeePayment?}
+ B -->|Да| C[Использовать индивидуальную ставку]
+ B -->|Нет| D{Есть GradePrice?}
+ D -->|Да| E[Использовать ставку по грейду+городу]
+ D -->|Нет| F[Базовая ставка из EmployeePosition]
+
+ C --> G[monthly_salary или daily_payment]
+ E --> H[price_month или price_hour]
+ F --> I[salary из должности]
+```
+
+**Формула 1: Индивидуальная ставка (наивысший приоритет)**
+```php
+$payment = EmployeePayment::find()
+ ->where(['admin_id' => $adminId])
+ ->andWhere(['<=', 'date', $currentDate])
+ ->orderBy(['date' => SORT_DESC])
+ ->one();
+
+if ($payment) {
+ $salary = $payment->monthly_salary;
+ // или $payment->daily_payment для подневного расчета
+}
+```
+
+**Формула 2: Грейд + Город**
+```php
+// Получение текущего грейда
+$gradeHistory = AdminGradeHistory::find()
+ ->where(['admin_id' => $adminId])
+ ->andWhere(['<=', 'created_at', $currentDate])
+ ->andWhere(['>=', 'closed_at', $currentDate])
+ ->one();
+
+if ($gradeHistory) {
+ $gradePrice = GradePrice::find()
+ ->where([
+ 'grade_id' => $gradeHistory->grade_id,
+ 'city_id' => $admin->city_id
+ ])
+ ->andWhere(['<=', 'created_at', $currentDate])
+ ->andWhere(['>=', 'closed_at', $currentDate])
+ ->one();
+
+ if ($gradePrice) {
+ $salary = $gradePrice->price_month;
+ // или $gradePrice->price_hour для почасового расчета
+ }
+}
+```
+
+**Формула 3: Базовая ставка из должности**
+```php
+$position = $admin->position;
+$salary = $position ? $position->salary : 0;
+```
+
+---
+
+### 2. Карьерный рост и требования
+
+**Алгоритм проверки готовности к повышению:**
+```php
+function canPromote($adminId) {
+ // 1. Получение текущей должности
+ $currentPosition = Admin::findOne($adminId)->position;
+
+ if (!$currentPosition || !$currentPosition->next_position_id) {
+ return ['can' => false, 'reason' => 'Нет следующей должности'];
+ }
+
+ // 2. Получение следующей должности
+ $nextPosition = EmployeePosition::findOne($currentPosition->next_position_id);
+
+ // 3. Получение требуемых навыков
+ $requiredSkills = $nextPosition->skills;
+
+ // 4. Получение навыков сотрудника
+ $adminSkills = Admin::findOne($adminId)->skills;
+
+ // 5. Проверка наличия всех требуемых навыков
+ $missingSkills = [];
+ foreach ($requiredSkills as $requiredSkill) {
+ $hasSkill = false;
+ foreach ($adminSkills as $adminSkill) {
+ if ($adminSkill->id == $requiredSkill->id) {
+ // Проверка срока действия
+ $skillStatus = EmployeeSkillStatus::findOne([
+ 'admin_id' => $adminId,
+ 'skill_id' => $requiredSkill->id
+ ]);
+
+ if ($skillStatus && $skillStatus->closed_at >= date('Y-m-d')) {
+ $hasSkill = true;
+ break;
+ }
+ }
+ }
+
+ if (!$hasSkill) {
+ $missingSkills[] = $requiredSkill->name;
+ }
+ }
+
+ if (empty($missingSkills)) {
+ return ['can' => true];
+ } else {
+ return [
+ 'can' => false,
+ 'reason' => 'Не хватает навыков: ' . implode(', ', $missingSkills)
+ ];
+ }
+}
+```
+
+**Пример:**
+```php
+$check = canPromote(100);
+
+if ($check['can']) {
+ echo "Сотрудник готов к повышению!";
+} else {
+ echo "Не готов: " . $check['reason'];
+ // Вывод: "Не готов: Не хватает навыков: Работа с кассой, Управление персоналом"
+}
+```
+
+---
+
+### 3. Валидность навыков
+
+**Формула проверки:**
+```php
+function isSkillValid($adminId, $skillId) {
+ $status = EmployeeSkillStatus::findOne([
+ 'admin_id' => $adminId,
+ 'skill_id' => $skillId
+ ]);
+
+ if (!$status) {
+ return false;
+ }
+
+ return $status->closed_at >= date('Y-m-d');
+}
+```
+
+**Расчет срока действия при получении навыка:**
+```php
+$skill = EmployeeSkill::findOne($skillId);
+
+$status = new EmployeeSkillStatus();
+$status->admin_id = $adminId;
+$status->skill_id = $skillId;
+$status->created_at = date('Y-m-d H:i:s');
+$status->closed_at = date('Y-m-d H:i:s', strtotime("+{$skill->lifespan} days"));
+$status->save();
+
+// Например, навык "Работа с кассой" (lifespan=365)
+// created_at = 2025-03-15
+// closed_at = 2026-03-15
+```
+
+---
+
+### 4. Временное управление изменениями (с 1 числа)
+
+**Логика "следующего месяца":**
+```php
+// Вычисление даты вступления в силу
+$effectiveDate = date("Y-m-d 00:00:01", strtotime('first day of next month'));
+// Например, сегодня 2025-03-15 → effectiveDate = 2025-04-01 00:00:01
+
+// Вычисление даты закрытия старых записей
+$closeDate = date("Y-m-d 00:00:00", strtotime('first day of next month'));
+// Например, сегодня 2025-03-15 → closeDate = 2025-04-01 00:00:00
+
+// Закрытие старых грейдов
+AdminGradeHistory::updateAll(
+ ['closed_at' => $closeDate],
+ [
+ 'admin_id' => $adminId,
+ 'closed_at' => '2100-01-01 00:00:00' // Текущие записи
+ ]
+);
+
+// Создание нового грейда
+$history = new AdminGradeHistory();
+$history->created_at = $effectiveDate;
+$history->closed_at = '2100-01-01 00:00:00'; // Бессрочно
+$history->save();
+```
+
+**Эффект:**
+- Изменения планируются заранее
+- Текущий месяц остается стабильным
+- Упрощается расчет зарплаты (нет mid-month changes)
+
+---
+
+### 5. Специальная логика для операционных групп
+
+**Определение спецгрупп:**
+```php
+$specialGroups = [
+ 30, // Флорист День
+ 35, // Флорист Ночь
+ 40, // Флорист Поддержка День
+ 45, // Работники
+ 50, // Администраторы
+ 72, // Флорист Поддержка Ночь
+ $workersGroupId // Работники магазинов (динамический ID)
+];
+```
+
+**Формирование group_name:**
+```php
+if (in_array($groupId, $specialGroups)) {
+ // Для спецгрупп
+ $position = EmployeePosition::findOne($employeePositionId);
+ $groupName = $position->name;
+
+ if ($shift) {
+ $groupName .= " ({$shift})";
+ }
+
+ // Примеры:
+ // - "Флорист (День)"
+ // - "Администратор (Ночь)"
+ // - "Флорист-поддержка (День)"
+} else {
+ // Для остальных групп
+ $groupName = $customPosition; // Свободный текст
+
+ // Примеры:
+ // - "Менеджер по продажам"
+ // - "Кустовой директор"
+ // - "Логист"
+}
+```
+
+---
+
+## 🔗 Интеграции с другими модулями
+
+### 1. Интеграция с Payroll (Расчет зарплаты)
+
+**Файл:** `erp24/services/PayrollService.php`
+
+**Использование Grade данных:**
+```php
+// 1. Получение оклада
+$salary = 0;
+
+// Приоритет 1: EmployeePayment
+$payment = EmployeePayment::getMonthlySalary($adminId, $date);
+if ($payment) {
+ $salary = $payment['monthly_salary'];
+}
+
+// Приоритет 2: GradePrice
+if ($salary == 0) {
+ $gradeHistory = AdminGradeHistory::find()
+ ->where(['admin_id' => $adminId])
+ ->andWhere(['<=', 'created_at', $date])
+ ->andWhere(['>=', 'closed_at', $date])
+ ->one();
+
+ if ($gradeHistory) {
+ $gradePrice = GradePrice::find()
+ ->where([
+ 'grade_id' => $gradeHistory->grade_id,
+ 'city_id' => $admin->city_id
+ ])
+ ->andWhere(['<=', 'created_at', $date])
+ ->andWhere(['>=', 'closed_at', $date])
+ ->one();
+
+ if ($gradePrice) {
+ $salary = $gradePrice->price_month;
+ }
+ }
+}
+
+// 2. Расчет зарплаты за месяц
+$totalSalary = $salary + $bonuses - $penalties;
+```
+
+**Проверка прав на редактирование payroll:**
+```php
+public static function getAllowedPayrollUpdate() {
+ $allowedGroups = [1, 8, 9, 51, 81];
+ // 1 - Директор
+ // 8 - HR Директор
+ // 9 - Финансовый директор
+ // 51 - Операционный директор
+ // 81 - IT
+
+ return in_array(Yii::$app->user->identity->group_id, $allowedGroups);
+}
+```
+
+---
+
+### 2. Интеграция с Timetable (Расписание)
+
+**Использование группы и должности:**
+```php
+// Определение количества часов смены
+if ($admin->group_id == AdminGroup::GROUP_ADMINISTRATORS) {
+ $shiftHours = Admin::SHIFT_HOUR_COUNT_ADMINISTRATOR; // 8 часов
+} elseif (in_array($admin->group_id, [
+ AdminGroup::GROUP_FLORIST,
+ AdminGroup::GROUP_FLORIST_DAY,
+ AdminGroup::GROUP_FLORIST_NIGHT
+])) {
+ $shiftHours = Admin::SHIFT_HOUR_COUNT_FLORIST; // 12 часов
+}
+
+// Расчет часовой ставки
+$hourlyRate = $monthlySalary / 160; // Стандартный месяц
+
+// Зарплата за смену
+$shiftSalary = $hourlyRate * $shiftHours;
+```
+
+---
+
+### 3. Интеграция с Bonus (Бонусная система)
+
+**Использование грейда для коэффициентов:**
+```php
+// Получение текущего грейда
+$gradeHistory = AdminGradeHistory::find()
+ ->where(['admin_id' => $adminId])
+ ->andWhere(['closed_at' => '2100-01-01 00:00:00'])
+ ->one();
+
+// Коэффициент бонуса по грейду
+$gradeCoefficients = [
+ 1 => 0.8, // Junior
+ 2 => 1.0, // Middle
+ 3 => 1.2, // Senior
+ 4 => 1.5, // Lead
+];
+
+$coefficient = $gradeCoefficients[$gradeHistory->grade_id] ?? 1.0;
+$bonus = $basBonus * $coefficient;
+```
+
+---
+
+### 4. Интеграция с History Service
+
+**Аудит изменений:**
+```php
+// AdminUpdateAction
+HistoryService::setHistoryUserInfo($model);
+
+// Получение истории изменений
+$adminHistoryCategories = HistoryService::getHistoryAdmin($adminId);
+
+// Структура:
+// [
+// 'Персональные данные' => [
+// ['field' => 'name', 'old' => 'Иван', 'new' => 'Иван Иванов', 'date' => '...'],
+// ['field' => 'phone', 'old' => '...', 'new' => '...', 'date' => '...'],
+// ],
+// 'Должность' => [
+// ['field' => 'group_id', 'old' => 50, 'new' => 89, 'date' => '...'],
+// ],
+// ...
+// ]
+```
+
+---
+
+### 5. Интеграция с Store System
+
+**Привязка к магазинам:**
+```php
+// Admin.store_arr — comma-separated IDs
+// Admin.store_arr_guid — comma-separated GUIDs
+// AdminStores junction table
+
+// Обработка в AdminUpdateAction
+$storeArray = $_POST['storeArray']; // [15, 23, 42]
+$model->store_arr = implode(',', $storeArray); // "15,23,42"
+
+$storeGuidArray = [];
+foreach ($storeArray as $storeId) {
+ $store = CityStore::findOne($storeId);
+ $storeGuidArray[] = $store->guid;
+}
+$model->store_arr_guid = implode(',', $storeGuidArray);
+
+// Пересоздание связей
+AdminStores::deleteAll(['admin_id' => $model->id]);
+foreach ($storeArray as $storeId) {
+ $as = new AdminStores();
+ $as->admin_id = $model->id;
+ $as->store_id = $storeId;
+ $as->save();
+}
+```
+
+---
+
+### 6. Интеграция с RBAC
+
+**Инвалидация кэша при смене группы:**
+```php
+if ($model->group_id != (int)$oldGroupId) {
+ Yii::$app->cache->set("dirtyAuthSettings", true);
+ // Триггер перезагрузки RBAC правил
+}
+```
+
+**Специальные права:**
+- `seeAdminSettings` — просмотр настроек
+- `updateAdminSettings` — редактирование
+- `managePassword` — смена пароля
+- `manageAvatarka` — загрузка аватара
+- `updateAdminSettingsGroupId` — смена группы
+- `updateAdminSettingsOnlyByHrAndAdministrator` — HR-поля (паспорт, зарплата)
+
+---
+
+## 💡 Примеры использования
+
+### Пример 1: Создание грейда и назначение цены
+
+```php
+// 1. Создание грейда
+$grade = new Grade();
+$grade->name = 'Junior';
+$grade->created_by = Yii::$app->user->id;
+$grade->save();
+
+// 2. Связь с группой
+$gradeGroup = new GradeGroup();
+$gradeGroup->grade_id = $grade->id;
+$gradeGroup->group_id = 89; // Флористы
+$gradeGroup->save();
+
+// 3. Установка цены для Нижнего Новгорода
+$price = new GradePrice();
+$price->grade_id = $grade->id;
+$price->city_id = 1342; // Н.Новгород
+$price->price_month = 50000;
+$price->price_hour = 250;
+$price->created_at = date("Y-m-d 00:00:01", strtotime('first day of next month'));
+$price->closed_at = '2100-01-01 00:00:00';
+$price->created_by = Yii::$app->user->id;
+$price->save();
+
+// Результат: Грейд Junior создан, цена 50,000₽/мес с 1 числа следующего месяца
+```
+
+---
+
+### Пример 2: Назначение грейда сотруднику
+
+```php
+// Назначение грейда Middle сотруднику ID=100
+$adminId = 100;
+$gradeId = 2; // Middle
+
+// 1. Закрытие текущего грейда
+AdminGradeHistory::updateAll(
+ ['closed_at' => date("Y-m-d 00:00:00", strtotime('first day of next month'))],
+ [
+ 'admin_id' => $adminId,
+ 'closed_at' => '2100-01-01 00:00:00'
+ ]
+);
+
+// 2. Создание нового назначения (с 1 числа)
+$history = new AdminGradeHistory();
+$history->admin_id = $adminId;
+$history->grade_id = $gradeId;
+$history->created_at = date("Y-m-d 00:00:01", strtotime('first day of next month'));
+$history->closed_at = '2100-01-01 00:00:00';
+$history->created_by = Yii::$app->user->id;
+$history->save();
+
+// Результат: С 1 числа следующего месяца сотрудник будет иметь грейд Middle
+```
+
+---
+
+### Пример 3: Создание иерархии должностей
+
+```php
+// 1. Флорист стажёр
+$trainee = new EmployeePosition();
+$trainee->name = 'Флорист стажёр';
+$trainee->posit = 1;
+$trainee->salary = 35000;
+$trainee->alias = 'florist-trainee';
+$trainee->group_id = 89;
+$trainee->save();
+
+// 2. Флорист
+$florist = new EmployeePosition();
+$florist->name = 'Флорист';
+$florist->posit = 2;
+$florist->salary = 45000;
+$florist->alias = 'florist';
+$florist->group_id = 89;
+$florist->next_position_id = null; // Установим позже
+$florist->save();
+
+// 3. Флорист-поддержка
+$support = new EmployeePosition();
+$support->name = 'Флорист-поддержка';
+$support->posit = 3;
+$support->salary = 55000;
+$support->alias = 'florist-support';
+$support->group_id = 89;
+$support->next_position_id = null;
+$support->save();
+
+// 4. Связывание иерархии
+$trainee->next_position_id = $florist->id;
+$trainee->save();
+
+$florist->next_position_id = $support->id;
+$florist->save();
+
+// Результат: Флорист стажёр → Флорист → Флорист-поддержка
+```
+
+---
+
+### Пример 4: Установка требуемых навыков для должности
+
+```php
+// Должность "Администратор" требует 3 навыка
+$positionId = 4; // Администратор
+
+$requiredSkills = [
+ 5, // Работа с кассой
+ 7, // Управление персоналом
+ 12, // Контроль качества
+];
+
+// Удаление старых требований
+EmployeePositionSkill::deleteAll(['position_id' => $positionId]);
+
+// Создание новых
+foreach ($requiredSkills as $skillId) {
+ $ps = new EmployeePositionSkill();
+ $ps->position_id = $positionId;
+ $ps->skill_id = $skillId;
+ $ps->save();
+}
+
+// Результат: Для повышения до Администратора нужны 3 навыка
+```
+
+---
+
+### Пример 5: Назначение навыка сотруднику
+
+```php
+// Сотрудник прошел обучение "Работа с кассой"
+$adminId = 100;
+$skillId = 5;
+
+$skill = EmployeeSkill::findOne($skillId);
+
+$status = new EmployeeSkillStatus();
+$status->admin_id = $adminId;
+$status->skill_id = $skillId;
+$status->status = 1;
+$status->created_at = date('Y-m-d H:i:s');
+$status->closed_at = date('Y-m-d H:i:s', strtotime("+{$skill->lifespan} days"));
+$status->save();
+
+// Результат:
+// Навык получен: 2025-03-15
+// Действителен до: 2026-03-15 (если lifespan=365)
+```
+
+---
+
+### Пример 6: Проверка готовности к повышению
+
+```php
+$adminId = 100;
+$admin = Admin::findOne($adminId);
+
+// Текущая должность
+$currentPosition = $admin->position;
+echo "Текущая должность: " . $currentPosition->name; // Флорист
+
+// Следующая должность
+$nextPosition = EmployeePosition::findOne($currentPosition->next_position_id);
+echo "Следующая должность: " . $nextPosition->name; // Флорист-поддержка
+
+// Требуемые навыки
+$requiredSkills = $nextPosition->skills;
+echo "Требуется навыков: " . count($requiredSkills); // 2
+
+// Навыки сотрудника
+$adminSkills = $admin->skills;
+echo "Есть навыков: " . count($adminSkills); // 1
+
+// Проверка каждого навыка
+$missingSkills = [];
+foreach ($requiredSkills as $required) {
+ $hasSkill = false;
+
+ foreach ($adminSkills as $has) {
+ if ($has->id == $required->id) {
+ // Проверка срока действия
+ $status = EmployeeSkillStatus::findOne([
+ 'admin_id' => $adminId,
+ 'skill_id' => $required->id
+ ]);
+
+ if ($status && $status->closed_at >= date('Y-m-d')) {
+ $hasSkill = true;
+ break;
+ }
+ }
+ }
+
+ if (!$hasSkill) {
+ $missingSkills[] = $required->name;
+ }
+}
+
+if (empty($missingSkills)) {
+ echo "Готов к повышению!";
+} else {
+ echo "Не хватает навыков: " . implode(', ', $missingSkills);
+ // Вывод: "Не хватает навыков: Флористика продвинутая"
+}
+```
+
+---
+
+### Пример 7: Установка индивидуальной ставки
+
+```php
+// Сотруднику ID=100 устанавливаем индивидуальный оклад
+$payment = new EmployeePayment();
+$payment->admin_id = 100;
+$payment->date = '2025-04-01'; // С 1 апреля
+$payment->monthly_salary = 70000;
+$payment->daily_payment = 2500;
+$payment->save();
+
+// Результат: С 1 апреля оклад сотрудника будет 70,000₽/мес
+// Эта ставка перекроет грейдовую цену
+```
+
+---
+
+### Пример 8: Расчет зарплаты с учетом приоритетов
+
+```php
+function calculateSalary($adminId, $date) {
+ $admin = Admin::findOne($adminId);
+
+ // Приоритет 1: Индивидуальная ставка
+ $payment = EmployeePayment::find()
+ ->where(['admin_id' => $adminId])
+ ->andWhere(['<=', 'date', $date])
+ ->orderBy(['date' => SORT_DESC])
+ ->one();
+
+ if ($payment) {
+ return [
+ 'source' => 'EmployeePayment',
+ 'salary' => $payment->monthly_salary
+ ];
+ }
+
+ // Приоритет 2: Грейд + Город
+ $gradeHistory = AdminGradeHistory::find()
+ ->where(['admin_id' => $adminId])
+ ->andWhere(['<=', 'created_at', $date])
+ ->andWhere(['>=', 'closed_at', $date])
+ ->one();
+
+ if ($gradeHistory) {
+ $gradePrice = GradePrice::find()
+ ->where([
+ 'grade_id' => $gradeHistory->grade_id,
+ 'city_id' => $admin->city_id
+ ])
+ ->andWhere(['<=', 'created_at', $date])
+ ->andWhere(['>=', 'closed_at', $date])
+ ->one();
+
+ if ($gradePrice) {
+ return [
+ 'source' => 'GradePrice',
+ 'salary' => $gradePrice->price_month
+ ];
+ }
+ }
+
+ // Приоритет 3: Базовая ставка из должности
+ $position = $admin->position;
+ if ($position && $position->salary) {
+ return [
+ 'source' => 'EmployeePosition',
+ 'salary' => $position->salary
+ ];
+ }
+
+ return [
+ 'source' => 'none',
+ 'salary' => 0
+ ];
+}
+
+// Использование
+$result = calculateSalary(100, '2025-03-15');
+echo "Источник: " . $result['source']; // EmployeePayment
+echo "Зарплата: " . $result['salary']; // 70000
+```
+
+---
+
+## ❓ FAQ
+
+### 1. В чем разница между Grade и EmployeePosition?
+
+**Grade (Грейд):**
+- Широкая классификация уровня сотрудника (Junior, Middle, Senior)
+- Привязана к ценообразованию по городам (`GradePrice`)
+- Одна группа может иметь несколько грейдов
+- Изменения вступают в силу с 1 числа следующего месяца
+
+**EmployeePosition (Должность):**
+- Детальная иерархия карьерного роста с конкретными названиями
+- Связана с требуемыми навыками (`EmployeePositionSkill`)
+- Имеет поле `next_position_id` для построения карьерной лестницы
+- Используется для операционных групп (флористы, администраторы)
+
+**Интеграция:**
+- Сотрудник может иметь и грейд, и должность одновременно
+- Для операционных групп (30, 35, 40, 45, 50, 72) используется `employee_position_id`
+- `Admin.group_name` для спецгрупп формируется из `EmployeePosition.name`
+
+---
+
+### 2. Как работает приоритет источников зарплаты?
+
+**Порядок приоритета:**
+1. **EmployeePayment** (индивидуальная ставка) — наивысший
+2. **GradePrice** (грейд + город)
+3. **EmployeePosition.salary** (базовая ставка)
+
+Если найден источник с более высоким приоритетом, нижние не проверяются.
+
+**Пример:**
+- У сотрудника есть индивидуальная ставка 70,000₽ → используется она
+- Даже если его грейд по прайсу = 50,000₽, и должность = 45,000₽
+
+---
+
+### 3. Почему изменения грейдов и цен вступают в силу с 1 числа?
+
+**Причины:**
+1. **Стабильность расчетов** — избегание изменений окладов в середине месяца
+2. **Упрощение payroll** — один оклад на весь месяц
+3. **Предсказуемость** — сотрудники заранее знают о изменениях
+4. **Аудит** — четкая временная граница в истории
+
+**Реализация:**
+```php
+$effectiveDate = date("Y-m-d 00:00:01", strtotime('first day of next month'));
+```
+
+---
+
+### 4. Как работает срок действия навыков (lifespan)?
+
+**Механизм:**
+- Навык имеет поле `lifespan` (количество дней)
+- При назначении навыка:
+ ```php
+ created_at = сегодня
+ closed_at = сегодня + lifespan дней
+ ```
+- После `closed_at` навык считается устаревшим
+- Требуется переаттестация (повторное назначение навыка)
+
+**Примеры:**
+- "Работа с кассой" (lifespan=365) — навык действует 1 год
+- "Флористика базовая" (lifespan=730) — 2 года
+- "Первая помощь" (lifespan=180) — 6 месяцев
+
+---
+
+### 5. Можно ли сотруднику назначить несколько грейдов одновременно?
+
+**Нет.** Один сотрудник может иметь только один активный грейд.
+
+**Логика:**
+- При назначении нового грейда все старые автоматически закрываются
+- Активный грейд: `closed_at = '2100-01-01 00:00:00'`
+- Закрытые грейды: `closed_at = дата закрытия`
+
+**История сохраняется** для аудита и отчетов.
+
+---
+
+### 6. Что такое "специальные группы" (special groups)?
+
+**Специальные группы:**
+```php
+[30, 35, 40, 45, 50, 72, workersGroupId]
+```
+
+**Названия:**
+- 30 — Флорист День
+- 35 — Флорист Ночь
+- 40 — Флорист Поддержка День
+- 45 — Работники
+- 50 — Администраторы
+- 72 — Флорист Поддержка Ночь
+- workersGroupId — Работники магазинов (динамический)
+
+**Особенности:**
+- Требуют выбора `EmployeePosition`
+- Поддерживают выбор смены (День/Ночь)
+- `group_name` формируется автоматически: `PositionName (Shift)`
+- Используют поле `employee_position_id` в таблице `admin`
+
+**Для остальных групп:**
+- Свободный текст в `group_name`
+- Нет привязки к `EmployeePosition`
+
+---
+
+### 7. Как добавить новый навык и назначить его должности?
+
+**Шаг 1: Создание навыка**
+```php
+$skill = new EmployeeSkill();
+$skill->name = 'Конфликтология';
+$skill->lifespan = 365; // Действует 1 год
+$skill->save();
+```
+
+**Шаг 2: Привязка к должности**
+```php
+$ps = new EmployeePositionSkill();
+$ps->position_id = 4; // Администратор
+$ps->skill_id = $skill->id;
+$ps->save();
+```
+
+**Шаг 3: Назначение сотруднику**
+```php
+$status = new EmployeeSkillStatus();
+$status->admin_id = 100;
+$status->skill_id = $skill->id;
+$status->status = 1;
+$status->created_at = date('Y-m-d H:i:s');
+$status->closed_at = date('Y-m-d H:i:s', strtotime('+365 days'));
+$status->save();
+```
+
+---
+
+### 8. Можно ли изменить грейд/цену, вступающие в силу в этом месяце?
+
+**Нет.** Если изменение уже запланировано на текущий месяц (дата в прошлом от сегодня), изменить его нельзя стандартными методами.
+
+**Решение:**
+- Удалить запланированную запись вручную
+- Создать новую с нужной датой
+
+**Рекомендация:** Планировать изменения заранее, до начала месяца.
+
+---
+
+### 9. Как работает иерархия должностей (next_position_id)?
+
+**Механизм:**
+- Каждая должность может иметь `next_position_id` — ссылку на следующую должность
+- Формируется односвязный список (linked list)
+
+**Пример:**
+```
+Флорист стажёр (id=1, next=2)
+ → Флорист (id=2, next=3)
+ → Флорист-поддержка (id=3, next=4)
+ → Администратор (id=4, next=5)
+ → Старший администратор (id=5, next=NULL)
+```
+
+**Использование:**
+- `UpdateAction` показывает следующую должность
+- Для повышения сотрудника нужны все навыки следующей должности
+- Навигация по карьерной лестнице
+
+---
+
+### 10. Что происходит при смене группы сотрудника?
+
+**AdminUpdateAction:**
+1. **Проверка прав:** `updateAdminSettingsGroupId`
+2. **Инвалидация RBAC кэша:**
+ ```php
+ if ($oldGroupId != $newGroupId) {
+ Yii::$app->cache->set("dirtyAuthSettings", true);
+ }
+ ```
+3. **Обновление `group_name`:**
+ - Для спецгрупп: автоматически из `EmployeePosition`
+ - Для остальных: свободный текст
+
+4. **История:**
+ ```php
+ HistoryService::setHistoryUserInfo($model);
+ ```
+
+5. **Последствия:**
+ - Изменение прав доступа (RBAC)
+ - Изменение назначенных регламентов
+ - Изменение доступных грейдов (через `GradeGroup`)
+
+---
+
+## 📚 Связанная документация
+
+### Модули ERP24
+- [Payroll](../payroll/README.md) - Расчет заработной платы
+- [Bonus](../bonus/README.md) - Бонусная система
+- [Timetable](../timetable/README.md) - Расписание смен
+- [Rating](../rating/README.md) - Рейтинговая система
+
+### Database
+- [Database Schema Overview](../../database/schema-overview.md) - Схема БД
+
+### Architecture
+- [System Overview](../../architecture/system-overview.md) - Общая архитектура ERP24
+
+---
+
+## 📝 Changelog
+
+| Версия | Дата | Изменения |
+|--------|------|-----------|
+| 1.0 | 2025-03-15 | Полная документация модуля Grade создана |
---
-**Последнее обновление:** 2025-11-17
+**Документ создан:** Hive Mind Business Domains Swarm
+**Дата:** 2025-03-15
+**Версия:** 1.0
+**Координатор:** Queen Coordinator (tactical)
## 📋 Описание
-**Lesson** - модÑ\83лÑ\8c Ñ\81иÑ\81Ñ\82емÑ\8b обÑ\83Ñ\87ениÑ\8f и Ñ\80азвиÑ\82иÑ\8f Ñ\81оÑ\82Ñ\80Ñ\83дников. Ð\9fозволÑ\8fеÑ\82 Ñ\81оздаваÑ\82Ñ\8c обÑ\83Ñ\87аÑ\8eÑ\89ие кÑ\83Ñ\80Ñ\81Ñ\8b, Ñ\83Ñ\80оки, Ñ\82еÑ\81Ñ\82Ñ\8b и оÑ\82Ñ\81леживаÑ\82Ñ\8c пÑ\80огÑ\80еÑ\81Ñ\81 обÑ\83Ñ\87ениÑ\8f пеÑ\80Ñ\81онала.
+**Lesson** - полноÑ\84Ñ\83нкÑ\86ионалÑ\8cнаÑ\8f Ñ\81иÑ\81Ñ\82ема коÑ\80поÑ\80аÑ\82ивного обÑ\83Ñ\87ениÑ\8f и Ñ\80азвиÑ\82иÑ\8f Ñ\81оÑ\82Ñ\80Ñ\83дников в ERP24. Ð\9cодÑ\83лÑ\8c позволÑ\8fеÑ\82 Ñ\81оздаваÑ\82Ñ\8c обÑ\83Ñ\87аÑ\8eÑ\89ие кÑ\83Ñ\80Ñ\81Ñ\8b, Ñ\83Ñ\80оки, Ñ\82еÑ\81Ñ\82Ñ\8b Ñ\81 Ñ\80азлиÑ\87нÑ\8bми Ñ\82ипами вопÑ\80оÑ\81ов, оÑ\82Ñ\81леживаÑ\82Ñ\8c пÑ\80огÑ\80еÑ\81Ñ\81 обÑ\83Ñ\87ениÑ\8f пеÑ\80Ñ\81онала и Ñ\83пÑ\80авлÑ\8fÑ\82Ñ\8c пÑ\80оÑ\86еÑ\81Ñ\81ом адапÑ\82аÑ\86ии новÑ\8bÑ\85 Ñ\81оÑ\82Ñ\80Ñ\83дников.
### Основные возможности
-- 📚 Создание обучающих курсов и уроков
-- 📝 Тесты и проверка знаний
-- 👥 Назначение обучения сотрудникам
-- ✅ Отслеживание прогресса
-- 📊 Статистика прохождения
-- 🎓 Сертификаты об окончании
-- 🔔 Уведомления о новых уроках
+- 📚 Создание одиночных уроков и групп уроков (курсов)
+- 📝 Тесты с различными типами вопросов (закрытые и открытые)
+- ✍️ Открытые вопросы с ручной проверкой администратором
+- 👥 Массовое назначение обучения сотрудникам
+- ✅ Отслеживание прогресса (7 статусов)
+- 📊 Детальная статистика и аналитика прохождения
+- ⏰ Контроль обязательных и рекомендуемых сроков
+- 🔄 Управление попытками прохождения тестов
+- 🔔 Автоматические уведомления сотрудникам и руководителям
+- 🎯 Начисление баллов за успешное/неуспешное прохождение
+- 🎲 Перемешивание вопросов (shuffle)
+- 📑 Последовательный и параллельный режимы обучения в группах
-## 🏗️ Архитектура
+## 🏗️ Архитектура модуля
-**Контроллеры:** 1 (LessonController)
+```mermaid
+graph TB
+ subgraph "Контроллеры"
+ LC[LessonController<br/>13 Actions]
+ end
+
+ subgraph "Actions"
+ A1[IndexAction]
+ A2[ViewLessonAction]
+ A3[ViewLessonGroupAction]
+ A4[StartTestingAction]
+ A5[ProceedTestingAction ⭐]
+ A6[CheckOpenPoll]
+ A7[ViewOpenPollAnswers]
+ A8[Edit2Action ⭐]
+ A9[EditLessonAction]
+ A10[EditLessonGroupAction]
+ A11[EditAnswersAction]
+ A12[AnalyticsAction]
+ A13[ViewLessonContentAction]
+ end
+
+ subgraph "Сервисы"
+ LS[LessonService<br/>3 метода]
+ LPS[LessonPollService<br/>7 методов]
+ end
+
+ subgraph "Модели"
+ L[Lessons<br/>20 полей]
+ LG[LessonsGroup<br/>14 полей]
+ LP[LessonsPoll<br/>7 полей]
+ LPA[LessonPollAnswers<br/>5 полей]
+ LPD[LessonsPassed<br/>11 полей, 7 статусов]
+ end
+
+ subgraph "Интеграции"
+ N[Notifications]
+ R[Rating/Bonus]
+ F[Files]
+ A[Admin]
+ end
+
+ LC --> A1 & A2 & A3 & A4 & A5 & A6 & A7 & A8 & A9 & A10 & A11 & A12 & A13
+ A5 --> LS
+ A5 --> LPS
+ A8 --> LS
+
+ LS --> L & LG
+ LPS --> L & LP & LPA & LPD
+
+ L --> LG
+ L --> LP
+ LP --> LPA
+ LPD --> L & LG
+
+ LPS --> N
+ LPD --> R
+ L --> F
+ LPD --> A
+```
-**Сервисы (2):**
-- `LessonService` - бизнес-логика уроков
-- `LessonProgressService` - отслеживание прогресса
+## 📊 Компоненты модуля
-**Модели (5):**
-- `Lesson` - уроки
-- `LessonCourse` - курсы
-- `LessonTest` - тесты
-- `LessonProgress` - прогресс прохождения
-- `LessonCertificate` - сертификаты
+| Компонент | Количество | Файлы/Классы |
+|-----------|-----------|--------------|
+| **Контроллеры** | 1 | `LessonController` |
+| **Actions** | 13 | `IndexAction`, `ViewLessonAction`, `ViewLessonGroupAction`, `StartTestingAction`, `ProceedTestingAction`, `CheckOpenPoll`, `ViewOpenPollAnswers`, `Edit2Action`, `EditLessonAction`, `EditLessonGroupAction`, `EditAnswersAction`, `AnalyticsAction`, `ViewLessonContentAction` |
+| **Сервисы** | 2 | `LessonService`, `LessonPollService` |
+| **Модели** | 5 | `Lessons`, `LessonsGroup`, `LessonsPoll`, `LessonPollAnswers`, `LessonsPassed` |
+| **Формы** | 1 | `LessonForm` |
+| **Таблицы БД** | 5 | `lessons`, `lessons_group`, `lessons_poll`, `lesson_poll_answers`, `lessons_passed` |
+
+Подробное описание см. в:
+- [models.md](./models.md) - все модели с полями, отношениями и примерами
+- [services.md](./services.md) - сервисы с методами и логикой
+- [actions.md](./actions.md) - все действия контроллера
## 💼 Основные сущности
-**Курс обучения:**
-- Название и описание
-- Последовательность уроков
-- Обязательные/необязательные
-- Срок прохождения
+### Урок (Lessons)
+
+Одиночная обучающая единица с контентом и тестом.
+
+**Ключевые характеристики:**
+- Название, описание, контент (HTML)
+- Может быть частью группы (group_id) или самостоятельным
+- Имеет позицию внутри группы (pos)
+- Связан с вопросами теста (polls)
+- Картинка урока (lessons_image_id)
+- Отслеживание прогресса через LessonsPassed
+
+### Группа уроков (LessonsGroup)
+
+Курс, объединяющий несколько уроков.
+
+**Ключевые характеристики:**
+- Два режима обучения:
+ - **Последовательный** (chain=1): уроки проходятся по порядку
+ - **Параллельный** (chain=0): все уроки доступны сразу
+- Обязательный срок (days_for_obligatory)
+- Рекомендуемый срок (days_for_recommended)
+- Картинка группы (lessons_image_id)
+
+### Вопрос теста (LessonsPoll)
+
+Вопрос для проверки знаний.
+
+**Типы вопросов:**
+- **Закрытый вопрос** (is_open=0): выбор из вариантов ответа
+- **Открытый вопрос** (is_open=1): свободный текстовый ответ, требует ручной проверки администратором
-**УÑ\80ок:**
-- ТеоÑ\80еÑ\82иÑ\87еÑ\81кий маÑ\82еÑ\80иал (видео, Ñ\82екÑ\81Ñ\82, пÑ\80езенÑ\82аÑ\86ии)
-- Ð\9fÑ\80акÑ\82иÑ\87еÑ\81кие заданиÑ\8f
-- ТеÑ\81Ñ\82 длÑ\8f пÑ\80овеÑ\80ки
-- Ð\9cинималÑ\8cнÑ\8bй балл длÑ\8f пÑ\80оÑ\85ождениÑ\8f
+**ХаÑ\80акÑ\82еÑ\80иÑ\81Ñ\82ики:**
+- Ð\9fÑ\80ивÑ\8fзан к Ñ\83Ñ\80окÑ\83 (lesson_id)
+- Ð\98мееÑ\82 позиÑ\86иÑ\8e (pos)
+- Ð\9aаÑ\80Ñ\82инка вопÑ\80оÑ\81а (lessons_image_id)
+- Ð\9fÑ\80авилÑ\8cнÑ\8bй оÑ\82веÑ\82 Ñ\85Ñ\80аниÑ\82Ñ\81Ñ\8f в Ñ\81вÑ\8fзаннÑ\8bÑ\85 LessonPollAnswers (is_correct=1)
-**Прогресс:**
-- Начато / В процессе / Завершено
-- Процент прохождения
-- Оценка за тесты
-- Время на обучение
+### Ответ на вопрос (LessonPollAnswers)
-## 📝 Процесс обучения
+Вариант ответа на закрытый вопрос.
+
+**Характеристики:**
+- Привязан к вопросу (lesson_poll_id)
+- Флаг правильности (is_correct)
+- Картинка ответа (lessons_image_id)
+
+### Прогресс прохождения (LessonsPassed)
+
+Отслеживает статус прохождения урока или группы сотрудником.
+
+**7 статусов:**
+1. **STATUS_ATTACHED (0)** - Назначен
+2. **STATUS_READ (10)** - Прочитан (открыл урок)
+3. **STATUS_PASS_FAIL (20)** - Тест провален
+4. **STATUS_PASS_SUCCESS (30)** - Тест успешно пройден
+5. **STATUS_NEED_CHECK (40)** - Ожидает проверки открытых вопросов
+6. **STATUS_FAIL_CHECK (50)** - Открытые вопросы проверены отрицательно
+7. **STATUS_PASS_CHECK (60)** - Открытые вопросы проверены положительно
+
+**Ключевые поля:**
+- entity: 'lesson' или 'group'
+- entity_id: ID урока или группы
+- admin_id: ID сотрудника
+- status: текущий статус (0-60)
+- created: дата назначения
+- Автоматическое отслеживание завершения группы при прохождении всех уроков
+
+## 📝 Жизненный цикл обучения
```mermaid
stateDiagram-v2
- [*] --> Assigned: Назначен курс
- Assigned --> InProgress: Начал обучение
- InProgress --> Testing: Прошел уроки
- Testing --> Failed: Тест не сдан
- Testing --> Passed: Тест сдан
- Failed --> InProgress: Повторное изучение
- Passed --> Completed: Получен сертификат
- Completed --> [*]
+ [*] --> ATTACHED: Администратор назначил
+ ATTACHED --> READ: Сотрудник открыл урок
+ READ --> Testing: Начал тест
+
+ Testing --> PASS_SUCCESS: Набрал баллы (без открытых вопросов)
+ Testing --> NEED_CHECK: Ответил на открытые вопросы
+ Testing --> PASS_FAIL: Не набрал баллы / Превысил время
+
+ NEED_CHECK --> PASS_CHECK: Администратор проверил (+)
+ NEED_CHECK --> FAIL_CHECK: Администратор проверил (-)
+
+ PASS_FAIL --> READ: Повторная попытка (если доступна)
+ FAIL_CHECK --> READ: Повторная попытка
+
+ PASS_SUCCESS --> [*]: Завершено
+ PASS_CHECK --> [*]: Завершено
```
-## 🔗 Связи с модулями
+## 🔄 Бизнес-процессы
+
+Подробное описание всех бизнес-процессов см. в [workflows.md](./workflows.md)
+
+### Основные процессы
+
+1. **Назначение обучения** (Edit2Action)
+ - Массовый выбор сотрудников
+ - Выбор уроков/групп
+ - Создание LessonsPassed записей
+ - Отправка уведомлений
+
+2. **Прохождение одиночного урока**
+ - Просмотр контента
+ - Прохождение теста
+ - Проверка времени и баллов
+ - Ручная проверка открытых вопросов (если есть)
+
+3. **Прохождение группы (последовательный режим)**
+ - Уроки проходятся строго по порядку
+ - Следующий урок открывается только после успешного завершения предыдущего
+ - Группа считается завершенной при прохождении всех уроков
+
+4. **Прохождение группы (параллельный режим)**
+ - Все уроки доступны сразу
+ - Можно проходить в любом порядке
+ - Группа считается завершенной при прохождении всех уроков
+
+5. **Проверка открытых вопросов**
+ - Администратор просматривает ответы
+ - Выставляет оценку (зачет/незачет)
+ - Система обновляет статус и отправляет уведомление
+
+## 🔌 Интеграции с другими модулями
+
+### Notifications
+- Отправка уведомлений при назначении обучения
+- Уведомления руководителям о завершении
+- **Методы:** `LessonPollService::sendPollCompleteNotification()`
+
+### Rating / Bonus
+- Начисление баллов за успешное прохождение (success_ball)
+- Штрафные баллы за неуспешное прохождение (fail_ball)
+- Настраивается в полях Lessons
+
+### Files
+- Хранение картинок уроков (Lessons.lessons_image_id)
+- Картинки вопросов (LessonsPoll.lessons_image_id)
+- Картинки ответов (LessonPollAnswers.lessons_image_id)
+- **Отношения:** через Files::className()
+
+### Admin
+- Связь с создателем урока (created_admin_id)
+- Связь с редактором (updated_admin_id)
+- Связь с руководителем сотрудника (parent_admin_id)
+- Отслеживание прохождения по сотрудникам
+
+## 🎯 Примеры использования
+
+Полные примеры кода см. в [examples.md](./examples.md)
+
+### Пример 1: Назначить урок сотруднику
+
+```php
+$lp = new LessonsPassed();
+$lp->entity = 'lesson';
+$lp->entity_id = 5; // ID урока
+$lp->admin_id = 42; // ID сотрудника
+$lp->status = LessonsPassed::STATUS_ATTACHED;
+$lp->save();
+
+// Отправить уведомление
+Edit2Action::createAssignmentNotification([42]);
+```
+
+### Пример 2: Проверить статус прохождения
+
+```php
+$lp = LessonsPassed::findOne([
+ 'entity' => 'lesson',
+ 'entity_id' => 5,
+ 'admin_id' => 42
+]);
+
+echo $lp->statusLabels()[$lp->status]; // "Пройден" / "Провален" и т.д.
+```
+
+### Пример 3: Получить все уроки в группе (отсортированные)
+
+```php
+$group = LessonsGroup::findOne(3);
+$lessons = $group->lessons; // Автоматически отсортированы по pos
+```
+
+## 📊 Статистика и аналитика
+
+### AnalyticsAction
+Предоставляет детальную статистику прохождения:
+- Список всех уроков/групп
+- Количество назначений
+- Количество завершений
+- Процент успеваемости
+- Фильтрация по сотрудникам, датам, статусам
+
+### Ключевые метрики
+- Время прохождения урока
+- Количество попыток
+- Процент правильных ответов
+- Соблюдение сроков (обязательных и рекомендуемых)
+
+## ❓ Часто задаваемые вопросы
+
+Полный FAQ см. в [faq.md](./faq.md)
+
+**Q: Как работает параллельное обучение?**
+A: При chain=0 в LessonsGroup все уроки группы доступны сразу, можно проходить в любом порядке.
+
+**Q: Что такое "открытый вопрос"?**
+A: Вопрос с is_open=1, требует текстового ответа и ручной проверки администратором через CheckOpenPoll action.
+
+**Q: Как работают сроки?**
+A: Два типа сроков:
+- **Обязательный** (days_for_obligatory): если превышен, статус остается "незавершенным"
+- **Рекомендуемый** (days_for_recommended): для расчета бонусов/штрафов
+
+**Q: Можно ли переделать тест?**
+A: Да, при статусе PASS_FAIL или FAIL_CHECK можно пройти тест повторно. Ограничений на количество попыток нет (кроме max_attempts в коде).
+
+## 🛠️ Техническая информация
+
+### Контроллер
+**Путь:** `erp24/controllers/LessonController.php`
+
+### Сервисы
+- **LessonService:** `erp24/components/services/LessonService.php`
+- **LessonPollService:** `erp24/components/services/LessonPollService.php`
+
+### Модели
+- **Lessons:** `erp24/models/Lessons.php`
+- **LessonsGroup:** `erp24/models/LessonsGroup.php`
+- **LessonsPoll:** `erp24/models/LessonsPoll.php`
+- **LessonPollAnswers:** `erp24/models/LessonPollAnswers.php`
+- **LessonsPassed:** `erp24/models/LessonsPassed.php`
+
+### Таблицы БД
+- `lessons` - уроки
+- `lessons_group` - группы/курсы
+- `lessons_poll` - вопросы тестов
+- `lesson_poll_answers` - варианты ответов
+- `lessons_passed` - прогресс прохождения
+
+## 📚 Дополнительная документация
+
+- [models.md](./models.md) - Детальное описание всех моделей
+- [services.md](./services.md) - Описание сервисов и их методов
+- [actions.md](./actions.md) - Все действия контроллера
+- [workflows.md](./workflows.md) - Бизнес-процессы и диаграммы
+- [examples.md](./examples.md) - Примеры использования и кода
+- [faq.md](./faq.md) - Часто задаваемые вопросы
+
+## 🐛 Известные ограничения
+
+1. ❌ Отсутствует проверка на NULL `parent_admin_id` перед отправкой уведомлений
+2. ❌ Нет транзакций в `pollCompleteActions()` - возможно частичное сохранение
+3. ❌ Bubble sort O(n²) в `LessonService::movePosition()` - неэффективно для больших массивов
+4. ❌ Жесткая привязка к `Yii::$app->user->id` - проблема при использовании в console commands
+5. ❌ Orphan файлы при удалении, если `delete()` упадет
+
+## 📈 Рекомендуемые улучшения
-- **Notifications** - уведомления о новых уроках
-- **Regulations** - похожая система (регламенты vs уроки)
-- **Rating** - влияние обучения на рейтинг
-- **Bonus** - бонусы за прохождение обучения
-- **HR** - обязательное обучение для новичков
+1. Добавить проверку `parent_admin_id` перед вызовом `sendPollCompleteNotification()`
+2. Использовать `usort()` вместо bubble sort для сортировки
+3. Обернуть `pollCompleteActions()` в транзакцию
+4. Параметризировать `admin_id` в сервисах вместо использования глобального `Yii::$app->user->id`
+5. Использовать `updateAttributes()` вместо `save(false)` для атомарных обновлений
+6. Добавить cascade delete для файлов
---
-**Последнее обновление:** 2025-11-17
+**Последнее обновление:** 2025-11-24
+**Версия:** 2.0 (полная документация)
--- /dev/null
+# Actions модуля Lesson
+
+Полное описание всех действий контроллера `LessonController`.
+
+## 📦 Список Actions
+
+| № | Action | Назначение | Роль | Сложность |
+|---|--------|-----------|------|-----------|
+| 1 | [IndexAction](#1-indexaction) | Список уроков ученика | Ученик | ⭐ |
+| 2 | [ViewLessonAction](#2-viewlessonaction) | Просмотр урока | Ученик | ⭐⭐ |
+| 3 | [ViewLessonGroupAction](#3-viewlessongroupaction) | Просмотр группы | Ученик | ⭐⭐ |
+| 4 | [StartTestingAction](#4-starttestingaction) | Начало теста | Ученик | ⭐ |
+| 5 | [ProceedTestingAction](#5-proceedtestingaction) | Проведение теста (AJAX) | Ученик | ⭐⭐⭐⭐ |
+| 6 | [CheckOpenPoll](#6-checkopenpoll) | Проверка открытых вопросов | Админ | ⭐⭐ |
+| 7 | [ViewOpenPollAnswers](#7-viewopenpoll answers) | Управление ответами | Админ | ⭐⭐ |
+| 8 | [Edit2Action](#8-edit2action) | Главное управление обучением | Админ | ⭐⭐⭐⭐ |
+| 9 | [EditLessonAction](#9-editlessonaction) | Редактирование урока | Админ | ⭐⭐⭐ |
+| 10 | [EditLessonGroupAction](#10-editlessongroupaction) | Редактирование группы | Админ | ⭐⭐⭐ |
+| 11 | [EditAnswersAction](#11-editanswersaction) | Управление ответами | Админ | ⭐⭐ |
+| 12 | [AnalyticsAction](#12-analyticsaction) | Статистика прохождения | Админ | ⭐⭐⭐ |
+| 13 | [ViewLessonContentAction](#13-viewlessoncontentaction) | Просмотр контента | Ученик | ⭐ |
+
+---
+
+## 1. IndexAction
+
+**Путь:** `LessonController::actions()['index']`
+**Роль:** Ученик (сотрудник)
+**Метод:** GET
+**URL:** `/lesson/index`
+
+### Назначение
+Отображает список всех назначенных уроков и групп для текущего сотрудника.
+
+### Логика
+1. Получить `admin_id` текущего пользователя
+2. Найти все записи `LessonsPassed` для этого сотрудника
+3. Получить связанные уроки и группы
+4. Отобразить в виде списка с карточками
+
+### Параметры
+Нет (использует `Yii::$app->user->id`)
+
+### Возвращает
+Рендерит view: `views/lesson/index.php`
+
+### Данные для view
+```php
+[
+ 'lessons' => [...], // Одиночные уроки
+ 'groups' => [...], // Группы уроков
+]
+```
+
+### Пример
+```php
+// URL: /lesson/index
+// Результат: список назначенных уроков
+```
+
+### Код (упрощенный)
+```php
+public function actionIndex()
+{
+ $adminId = Yii::$app->user->id;
+
+ $passedLessons = LessonsPassed::find()
+ ->where(['admin_id' => $adminId, 'entity' => 'lesson'])
+ ->all();
+
+ $passedGroups = LessonsPassed::find()
+ ->where(['admin_id' => $adminId, 'entity' => 'group'])
+ ->all();
+
+ return $this->render('index', [
+ 'passedLessons' => $passedLessons,
+ 'passedGroups' => $passedGroups,
+ ]);
+}
+```
+
+---
+
+## 2. ViewLessonAction
+
+**Путь:** `LessonController::actions()['view-lesson']`
+**Роль:** Ученик
+**Метод:** GET
+**URL:** `/lesson/view-lesson?id={lessonId}`
+
+### Назначение
+Отображает содержимое урока и кнопку "Начать тест".
+
+### Логика
+1. Получить урок по `id`
+2. Проверить, назначен ли урок текущему пользователю
+3. Обновить статус на `STATUS_READ` (если был `STATUS_ATTACHED`)
+4. Отобразить контент урока
+
+### Параметры
+- `id` (int, обязательный) - ID урока
+
+### Возвращает
+Рендерит view: `views/lesson/view-lesson.php`
+
+### Данные для view
+```php
+[
+ 'lesson' => Lessons,
+ 'passed' => LessonsPassed,
+ 'canStartTest' => bool,
+]
+```
+
+### Проверка доступа
+```php
+if (!$passed) {
+ throw new ForbiddenHttpException('Урок не назначен');
+}
+```
+
+### Пример
+```php
+// URL: /lesson/view-lesson?id=5
+// Результат: контент урока + кнопка "Начать тест"
+```
+
+### Код (упрощенный)
+```php
+public function actionViewLesson($id)
+{
+ $lesson = Lessons::findOne($id);
+ if (!$lesson) {
+ throw new NotFoundHttpException();
+ }
+
+ $adminId = Yii::$app->user->id;
+ $passed = LessonsPassed::findOne([
+ 'entity' => 'lesson',
+ 'entity_id' => $id,
+ 'admin_id' => $adminId,
+ ]);
+
+ if (!$passed) {
+ throw new ForbiddenHttpException('Урок не назначен');
+ }
+
+ // Обновить статус на READ
+ if ($passed->status == LessonsPassed::STATUS_ATTACHED) {
+ $passed->status = LessonsPassed::STATUS_READ;
+ $passed->save(false);
+ }
+
+ return $this->render('view-lesson', [
+ 'lesson' => $lesson,
+ 'passed' => $passed,
+ ]);
+}
+```
+
+---
+
+## 3. ViewLessonGroupAction
+
+**Путь:** `LessonController::actions()['view-lesson-group']`
+**Роль:** Ученик
+**Метод:** GET
+**URL:** `/lesson/view-lesson-group?id={groupId}`
+
+### Назначение
+Отображает список уроков в группе с прогрессом.
+
+### Логика
+1. Получить группу по `id`
+2. Получить все уроки группы (отсортированные по `pos`)
+3. Для каждого урока получить статус прохождения
+4. Если группа последовательная (`chain=1`), заблокировать непройденные уроки
+5. Отобразить список уроков
+
+### Параметры
+- `id` (int, обязательный) - ID группы
+
+### Возвращает
+Рендерит view: `views/lesson/view-lesson-group.php`
+
+### Данные для view
+```php
+[
+ 'group' => LessonsGroup,
+ 'lessons' => [...],
+ 'progress' => [...], // Статусы прохождения
+ 'isChain' => bool,
+]
+```
+
+### Логика блокировки (chain=1)
+```php
+// Если урок 1 не завершен, урок 2 заблокирован
+if ($group->chain == 1) {
+ foreach ($lessons as $index => $lesson) {
+ if ($index > 0) {
+ $prevLesson = $lessons[$index - 1];
+ if (!LessonPollService::isLessonPollComplete($adminId, $prevLesson->id)) {
+ $lesson->isLocked = true;
+ }
+ }
+ }
+}
+```
+
+### Пример
+```php
+// URL: /lesson/view-lesson-group?id=3
+// Результат: список уроков группы с прогрессом
+```
+
+---
+
+## 4. StartTestingAction
+
+**Путь:** `LessonController::actions()['start-testing']`
+**Роль:** Ученик
+**Метод:** GET
+**URL:** `/lesson/start-testing?lessonId={lessonId}`
+
+### Назначение
+Начинает тест для урока (отображает первый вопрос).
+
+### Логика
+1. Получить урок
+2. Проверить, назначен ли урок
+3. Получить все вопросы теста
+4. Перемешать вопросы (если `shuffle=1`)
+5. Сохранить порядок вопросов в сессии
+6. Отобразить первый вопрос
+
+### Параметры
+- `lessonId` (int, обязательный) - ID урока
+
+### Возвращает
+Рендерит view: `views/lesson/testing.php`
+
+### Данные для view
+```php
+[
+ 'lesson' => Lessons,
+ 'polls' => [...], // Вопросы
+ 'currentPollIndex' => 0,
+ 'totalPolls' => int,
+]
+```
+
+### Перемешивание вопросов
+```php
+if ($lesson->shuffle == 1) {
+ shuffle($polls);
+}
+
+Yii::$app->session->set('lesson_' . $lessonId . '_polls', $polls);
+```
+
+### Пример
+```php
+// URL: /lesson/start-testing?lessonId=5
+// Результат: начало теста, первый вопрос
+```
+
+---
+
+## 5. ProceedTestingAction
+
+**Путь:** `LessonController::actions()['proceed-testing']`
+**Роль:** Ученик
+**Метод:** POST (AJAX)
+**URL:** `/lesson/proceed-testing`
+
+### Назначение
+Обрабатывает ответы на вопросы теста (AJAX).
+
+### Логика
+1. Получить ответ пользователя из POST
+2. Проверить правильность ответа
+3. Сохранить результат в сессии
+4. Проверить, все ли вопросы отвечены
+5. Если тест завершен:
+ - Подсчитать процент правильных ответов
+ - Проверить открытые вопросы
+ - Проверить время
+ - Обновить статус
+ - Начислить баллы
+
+### Параметры (POST)
+- `lessonId` (int) - ID урока
+- `pollId` (int) - ID вопроса
+- `answerId` (int | array) - ID выбранного ответа (или массив)
+- `openAnswer` (string) - текст ответа на открытый вопрос
+
+### Возвращает (JSON)
+```json
+{
+ "success": true,
+ "message": "Ответ принят",
+ "isLastPoll": false,
+ "testResult": null
+}
+```
+
+### Или при завершении теста:
+```json
+{
+ "success": true,
+ "isLastPoll": true,
+ "testResult": {
+ "status": "passed",
+ "correctPercentage": 85,
+ "requiredPercentage": 80,
+ "hasOpenPolls": false,
+ "message": "Тест пройден! 85% правильных ответов."
+ }
+}
+```
+
+### Алгоритм проверки ответа (закрытый вопрос)
+```php
+$poll = LessonsPoll::findOne($pollId);
+
+if ($poll->is_open == 0) {
+ // Закрытый вопрос
+ $correctAnswers = LessonPollAnswers::find()
+ ->where(['lesson_poll_id' => $pollId, 'is_correct' => 1])
+ ->all();
+
+ $correctIds = ArrayHelper::getColumn($correctAnswers, 'id');
+ $userAnswerIds = (array)$answerId;
+
+ // Сравнить массивы
+ sort($correctIds);
+ sort($userAnswerIds);
+
+ $isCorrect = ($correctIds === $userAnswerIds);
+} else {
+ // Открытый вопрос - отложенная проверка
+ $isCorrect = null;
+}
+
+// Сохранить в сессии
+Yii::$app->session->set('lesson_' . $lessonId . '_answer_' . $pollId, [
+ 'answerId' => $answerId,
+ 'isCorrect' => $isCorrect,
+ 'openAnswer' => $openAnswer,
+]);
+```
+
+### Алгоритм завершения теста
+```php
+$session = Yii::$app->session;
+$polls = $session->get('lesson_' . $lessonId . '_polls');
+$totalCorrect = 0;
+$totalAnswered = 0;
+$hasOpenPolls = false;
+
+foreach ($polls as $poll) {
+ $answer = $session->get('lesson_' . $lessonId . '_answer_' . $poll->id);
+
+ if ($answer) {
+ $totalAnswered++;
+
+ if ($answer['isCorrect'] === true) {
+ $totalCorrect++;
+ } elseif ($answer['isCorrect'] === null) {
+ $hasOpenPolls = true;
+ }
+ }
+}
+
+$correctPercentage = ($totalAnswered > 0)
+ ? round(($totalCorrect / $totalAnswered) * 100)
+ : 0;
+
+$lesson = Lessons::findOne($lessonId);
+$requiredPercentage = $lesson->min_correct_percentage;
+
+// Проверить время
+$startTime = $session->get('lesson_' . $lessonId . '_start_time');
+$elapsedMinutes = (time() - $startTime) / 60;
+
+if ($lesson->max_time_minutes && $elapsedMinutes > $lesson->max_time_minutes) {
+ $status = LessonsPassed::STATUS_PASS_FAIL;
+ $message = "Время вышло!";
+} elseif ($hasOpenPolls) {
+ $status = LessonsPassed::STATUS_NEED_CHECK;
+ $message = "Ожидает проверки администратора";
+} elseif ($correctPercentage >= $requiredPercentage) {
+ $status = LessonsPassed::STATUS_PASS_SUCCESS;
+ $message = "Тест пройден!";
+} else {
+ $status = LessonsPassed::STATUS_PASS_FAIL;
+ $message = "Тест провален";
+}
+
+// Обновить статус
+$passed = LessonsPassed::findOne([
+ 'entity' => 'lesson',
+ 'entity_id' => $lessonId,
+ 'admin_id' => Yii::$app->user->id,
+]);
+$passed->status = $status;
+$passed->save(false);
+
+// Начислить баллы
+if ($status == LessonsPassed::STATUS_PASS_SUCCESS) {
+ LessonPollService::pollCompleteActions(Yii::$app->user->id, $lessonId, true);
+} elseif ($status == LessonsPassed::STATUS_PASS_FAIL) {
+ LessonPollService::pollCompleteActions(Yii::$app->user->id, $lessonId, false);
+}
+
+// Очистить сессию
+$session->remove('lesson_' . $lessonId . '_polls');
+$session->remove('lesson_' . $lessonId . '_start_time');
+
+return $this->asJson([
+ 'success' => true,
+ 'isLastPoll' => true,
+ 'testResult' => [
+ 'status' => $status,
+ 'correctPercentage' => $correctPercentage,
+ 'requiredPercentage' => $requiredPercentage,
+ 'hasOpenPolls' => $hasOpenPolls,
+ 'message' => $message,
+ ],
+]);
+```
+
+### Пример AJAX запроса
+```javascript
+$.ajax({
+ url: '/lesson/proceed-testing',
+ type: 'POST',
+ data: {
+ lessonId: 5,
+ pollId: 10,
+ answerId: 42,
+ },
+ success: function(response) {
+ if (response.isLastPoll) {
+ alert(response.testResult.message);
+ } else {
+ // Показать следующий вопрос
+ }
+ }
+});
+```
+
+---
+
+## 6. CheckOpenPoll
+
+**Путь:** `LessonController::actions()['check-open-poll']`
+**Роль:** Администратор
+**Метод:** GET / POST
+**URL:** `/lesson/check-open-poll?passedId={passedId}`
+
+### Назначение
+Проверка открытых вопросов администратором.
+
+### Логика (GET)
+1. Получить `LessonsPassed` запись
+2. Проверить, что статус = `STATUS_NEED_CHECK`
+3. Получить урок
+4. Получить все открытые вопросы (is_open=1)
+5. Получить ответы сотрудника из сессии или БД
+6. Отобразить форму проверки
+
+### Логика (POST)
+1. Получить оценки администратора для каждого открытого вопроса
+2. Проверить, все ли вопросы зачтены
+3. Обновить статус на `STATUS_PASS_CHECK` или `STATUS_FAIL_CHECK`
+4. Отправить уведомление сотруднику
+
+### Параметры
+- `passedId` (int, обязательный) - ID записи LessonsPassed
+
+### POST данные
+```php
+[
+ 'openPollResults' => [
+ 10 => 'pass', // Вопрос 10: зачет
+ 15 => 'fail', // Вопрос 15: незачет
+ ]
+]
+```
+
+### Возвращает
+Рендерит view: `views/lesson/check-open-poll.php`
+
+### Пример
+```php
+// URL: /lesson/check-open-poll?passedId=123
+// Результат: форма проверки открытых вопросов
+```
+
+### Код (упрощенный)
+```php
+public function actionCheckOpenPoll($passedId)
+{
+ $passed = LessonsPassed::findOne($passedId);
+
+ if (!$passed || $passed->status != LessonsPassed::STATUS_NEED_CHECK) {
+ throw new NotFoundHttpException();
+ }
+
+ $lesson = Lessons::findOne($passed->entity_id);
+ $openPolls = LessonsPoll::find()
+ ->where(['lesson_id' => $lesson->id, 'is_open' => 1])
+ ->all();
+
+ if (Yii::$app->request->isPost) {
+ $results = Yii::$app->request->post('openPollResults', []);
+ $allPassed = true;
+
+ foreach ($results as $pollId => $result) {
+ if ($result == 'fail') {
+ $allPassed = false;
+ break;
+ }
+ }
+
+ $passed->status = $allPassed
+ ? LessonsPassed::STATUS_PASS_CHECK
+ : LessonsPassed::STATUS_FAIL_CHECK;
+ $passed->open_poll_admin_check_id = Yii::$app->user->id;
+ $passed->save(false);
+
+ // Отправить уведомление
+ // ...
+
+ return $this->redirect(['analytics']);
+ }
+
+ return $this->render('check-open-poll', [
+ 'passed' => $passed,
+ 'lesson' => $lesson,
+ 'openPolls' => $openPolls,
+ ]);
+}
+```
+
+---
+
+## 7. ViewOpenPollAnswers
+
+**Путь:** `LessonController::actions()['view-open-poll-answers']`
+**Роль:** Администратор
+**Метод:** GET
+**URL:** `/lesson/view-open-poll-answers?passedId={passedId}`
+
+### Назначение
+Просмотр ответов сотрудника на открытые вопросы (read-only).
+
+### Логика
+1. Получить `LessonsPassed`
+2. Получить открытые вопросы
+3. Получить ответы сотрудника
+4. Отобразить
+
+### Параметры
+- `passedId` (int, обязательный)
+
+### Возвращает
+Рендерит view: `views/lesson/view-open-poll-answers.php`
+
+---
+
+## 8. Edit2Action
+
+**Путь:** `LessonController::actions()['edit2']`
+**Роль:** Администратор
+**Метод:** GET / POST
+**URL:** `/lesson/edit2`
+
+### Назначение
+Главная панель управления обучением (назначение, редактирование, статистика).
+
+### Функции
+1. **Назначение уроков/групп сотрудникам**
+2. **Массовое назначение**
+3. **Просмотр списка всех уроков и групп**
+4. **Создание новых уроков/групп**
+5. **Редактирование существующих**
+
+### Логика (GET)
+Отображает главную панель с:
+- Списком уроков
+- Списком групп
+- Формой назначения
+
+### Логика (POST - назначение)
+```php
+POST: {
+ 'action': 'assign',
+ 'adminIds': [42, 43, 44],
+ 'lessonIds': [5, 6],
+ 'groupIds': [3]
+}
+```
+
+**Алгоритм:**
+1. Для каждого `adminId`:
+ - Для каждого `lessonId`:
+ - Проверить, назначен ли уже
+ - Создать `LessonsPassed` (entity='lesson')
+ - Для каждого `groupId`:
+ - Создать `LessonsPassed` (entity='group')
+ - Создать `LessonsPassed` для каждого урока в группе
+2. Отправить уведомления всем сотрудникам
+
+### Метод createAssignmentNotification()
+```php
+public static function createAssignmentNotification(array $adminIds): void
+{
+ foreach ($adminIds as $adminId) {
+ $notification = new Notifications();
+ $notification->admin_id = $adminId;
+ $notification->text = "Вам назначено новое обучение";
+ $notification->type = 'lesson_assigned';
+ $notification->save();
+ }
+}
+```
+
+### Пример
+```php
+// URL: /lesson/edit2
+// POST: назначить уроки 5, 6 сотрудникам 42, 43
+```
+
+---
+
+## 9. EditLessonAction
+
+**Путь:** `LessonController::actions()['edit-lesson']`
+**Роль:** Администратор
+**Метод:** GET / POST
+**URL:** `/lesson/edit-lesson?id={lessonId}`
+
+### Назначение
+Редактирование урока (контент, вопросы, настройки).
+
+### Логика (GET)
+1. Получить урок по `id` (или создать новый)
+2. Получить все вопросы урока
+3. Отобразить форму редактирования
+
+### Логика (POST)
+1. Валидация формы `LessonForm`
+2. Сохранить урок
+3. Сохранить вопросы
+4. Сохранить ответы
+5. Обновить позиции через `LessonService::sortByPosition()`
+
+### Параметры
+- `id` (int, опционально) - ID урока (если нет, создается новый)
+
+### POST данные
+```php
+[
+ 'Lessons' => [
+ 'title' => 'Название',
+ 'content' => '<p>Контент</p>',
+ 'group_id' => 5,
+ 'min_correct_percentage' => 80,
+ 'max_time_minutes' => 30,
+ 'success_ball' => 100,
+ 'shuffle' => 1,
+ ],
+ 'polls' => [
+ ['text' => 'Вопрос 1', 'is_open' => 0],
+ ['text' => 'Вопрос 2', 'is_open' => 1],
+ ],
+ 'answers' => [
+ 1 => [
+ ['text' => 'Ответ 1', 'is_correct' => 1],
+ ['text' => 'Ответ 2', 'is_correct' => 0],
+ ],
+ ],
+]
+```
+
+### Возвращает
+Рендерит view: `views/lesson/edit-lesson.php`
+
+### Код обработки drag-and-drop вопросов
+```php
+if (Yii::$app->request->post('action') == 'reorder-polls') {
+ $pollIds = Yii::$app->request->post('pollIds', []);
+ $polls = [];
+
+ foreach ($pollIds as $index => $pollId) {
+ $poll = LessonsPoll::findOne($pollId);
+ if ($poll) {
+ $poll->pos = ($index + 1) * 2;
+ $poll->save(false);
+ $polls[] = $poll;
+ }
+ }
+
+ return $this->asJson(['success' => true]);
+}
+```
+
+---
+
+## 10. EditLessonGroupAction
+
+**Путь:** `LessonController::actions()['edit-lesson-group']`
+**Роль:** Администратор
+**Метод:** GET / POST
+**URL:** `/lesson/edit-lesson-group?id={groupId}`
+
+### Назначение
+Редактирование группы уроков (курса).
+
+### Логика (GET)
+1. Получить группу по `id` (или создать новую)
+2. Получить все уроки группы
+3. Отобразить форму
+
+### Логика (POST)
+1. Валидация
+2. Сохранить группу
+3. Обновить привязку уроков к группе
+4. Пересортировать позиции
+
+### Параметры
+- `id` (int, опционально) - ID группы
+
+### POST данные
+```php
+[
+ 'LessonsGroup' => [
+ 'title' => 'Адаптация',
+ 'chain' => 1,
+ 'days_for_obligatory' => 7,
+ 'days_for_recommended' => 3,
+ 'success_ball' => 500,
+ ],
+ 'lessonIds' => [5, 6, 7], // Уроки в группе
+]
+```
+
+### Возвращает
+Рендерит view: `views/lesson/edit-lesson-group.php`
+
+---
+
+## 11. EditAnswersAction
+
+**Путь:** `LessonController::actions()['edit-answers']`
+**Роль:** Администратор
+**Метод:** POST (AJAX)
+**URL:** `/lesson/edit-answers`
+
+### Назначение
+AJAX редактирование ответов на вопрос.
+
+### Параметры (POST)
+- `pollId` (int) - ID вопроса
+- `answers` (array) - массив ответов
+
+### POST данные
+```json
+{
+ "pollId": 10,
+ "answers": [
+ {"text": "Ответ 1", "is_correct": 1},
+ {"text": "Ответ 2", "is_correct": 0}
+ ]
+}
+```
+
+### Логика
+1. Удалить все старые ответы вопроса
+2. Создать новые ответы
+3. Вернуть JSON
+
+### Возвращает (JSON)
+```json
+{
+ "success": true,
+ "message": "Ответы обновлены"
+}
+```
+
+---
+
+## 12. AnalyticsAction
+
+**Путь:** `LessonController::actions()['analytics']`
+**Роль:** Администратор
+**Метод:** GET
+**URL:** `/lesson/analytics`
+
+### Назначение
+Статистика и аналитика прохождения обучения.
+
+### Функции
+1. Список всех уроков с количеством назначений/завершений
+2. Список всех групп
+3. Фильтрация по сотрудникам
+4. Фильтрация по датам
+5. Экспорт в Excel
+
+### Данные для view
+```php
+[
+ 'lessonsStats' => [
+ ['lesson' => Lessons, 'assigned' => 10, 'completed' => 8, 'failed' => 2],
+ ...
+ ],
+ 'groupsStats' => [
+ ['group' => LessonsGroup, 'assigned' => 5, 'completed' => 3],
+ ...
+ ],
+]
+```
+
+### SQL для статистики
+```sql
+SELECT
+ l.id,
+ l.title,
+ COUNT(lp.id) AS assigned,
+ SUM(CASE WHEN lp.status IN (30, 60) THEN 1 ELSE 0 END) AS completed,
+ SUM(CASE WHEN lp.status IN (20, 50) THEN 1 ELSE 0 END) AS failed
+FROM lessons l
+LEFT JOIN lessons_passed lp ON lp.entity_id = l.id AND lp.entity = 'lesson'
+GROUP BY l.id
+```
+
+### Возвращает
+Рендерит view: `views/lesson/analytics.php`
+
+---
+
+## 13. ViewLessonContentAction
+
+**Путь:** `LessonController::actions()['view-lesson-content']`
+**Роль:** Ученик / Админ
+**Метод:** GET
+**URL:** `/lesson/view-lesson-content?id={lessonId}`
+
+### Назначение
+Просмотр контента урока (без теста).
+
+### Логика
+1. Получить урок
+2. Отобразить только контент (HTML)
+
+### Параметры
+- `id` (int, обязательный) - ID урока
+
+### Возвращает
+Рендерит view: `views/lesson/view-lesson-content.php`
+
+---
+
+## 📊 Сводная таблица прав доступа
+
+| Action | Ученик | Администратор |
+|--------|--------|---------------|
+| IndexAction | ✅ | ❌ |
+| ViewLessonAction | ✅ | ❌ |
+| ViewLessonGroupAction | ✅ | ❌ |
+| StartTestingAction | ✅ | ❌ |
+| ProceedTestingAction | ✅ | ❌ |
+| CheckOpenPoll | ❌ | ✅ |
+| ViewOpenPollAnswers | ❌ | ✅ |
+| Edit2Action | ❌ | ✅ |
+| EditLessonAction | ❌ | ✅ |
+| EditLessonGroupAction | ❌ | ✅ |
+| EditAnswersAction | ❌ | ✅ |
+| AnalyticsAction | ❌ | ✅ |
+| ViewLessonContentAction | ✅ | ✅ |
+
+---
+
+**Последнее обновление:** 2025-11-24
--- /dev/null
+# Примеры использования модуля Lesson
+
+Практические примеры кода для работы с модулем обучения.
+
+## 📋 Оглавление
+
+1. [Создание урока и теста](#1-создание-урока-и-теста)
+2. [Создание группы уроков](#2-создание-группы-уроков)
+3. [Назначение обучения](#3-назначение-обучения)
+4. [Проверка статуса прохождения](#4-проверка-статуса-прохождения)
+5. [Получение статистики](#5-получение-статистики)
+6. [Работа с открытыми вопросами](#6-работа-с-открытыми-вопросами)
+7. [Управление позициями уроков](#7-управление-позициями-уроков)
+8. [Интеграция с другими модулями](#8-интеграция-с-другими-модулями)
+
+---
+
+## 1. Создание урока и теста
+
+### Пример 1.1: Создание простого урока с закрытыми вопросами
+
+```php
+use app\models\Lessons;
+use app\models\LessonsPoll;
+use app\models\LessonPollAnswers;
+
+// Создать урок
+$lesson = new Lessons();
+$lesson->title = "Основы работы с CRM";
+$lesson->description = "Изучаем интерфейс и базовые функции CRM системы";
+$lesson->content = <<<HTML
+<h1>Введение в CRM</h1>
+<p>CRM (Customer Relationship Management) - система управления взаимоотношениями с клиентами.</p>
+<h2>Основные функции</h2>
+<ul>
+ <li>Управление контактами</li>
+ <li>Отслеживание сделок</li>
+ <li>Автоматизация задач</li>
+</ul>
+HTML;
+
+$lesson->min_correct_percentage = 80; // Требуется 80% правильных ответов
+$lesson->max_time_minutes = 30; // 30 минут на тест
+$lesson->success_ball = 100; // 100 баллов за успех
+$lesson->fail_ball = 20; // 20 баллов штрафа за провал
+$lesson->shuffle = 1; // Перемешивать вопросы
+$lesson->is_active = 1;
+$lesson->created_admin_id = Yii::$app->user->id;
+
+if (!$lesson->save()) {
+ throw new Exception("Ошибка создания урока: " . json_encode($lesson->errors));
+}
+
+// Создать вопросы теста
+// Вопрос 1
+$poll1 = new LessonsPoll();
+$poll1->lesson_id = $lesson->id;
+$poll1->text = "Что означает аббревиатура CRM?";
+$poll1->pos = 1;
+$poll1->is_open = 0; // Закрытый вопрос
+$poll1->save();
+
+// Ответы на вопрос 1
+$answer1_1 = new LessonPollAnswers();
+$answer1_1->lesson_poll_id = $poll1->id;
+$answer1_1->text = "Customer Relationship Management";
+$answer1_1->is_correct = 1; // Правильный ответ
+$answer1_1->save();
+
+$answer1_2 = new LessonPollAnswers();
+$answer1_2->lesson_poll_id = $poll1->id;
+$answer1_2->text = "Client Resource Manager";
+$answer1_2->is_correct = 0;
+$answer1_2->save();
+
+$answer1_3 = new LessonPollAnswers();
+$answer1_3->lesson_poll_id = $poll1->id;
+$answer1_3->text = "Corporate Reporting Module";
+$answer1_3->is_correct = 0;
+$answer1_3->save();
+
+// Вопрос 2
+$poll2 = new LessonsPoll();
+$poll2->lesson_id = $lesson->id;
+$poll2->text = "Какие функции выполняет CRM? (выберите несколько)";
+$poll2->pos = 2;
+$poll2->is_open = 0;
+$poll2->save();
+
+// Ответы на вопрос 2 (несколько правильных)
+$answers2 = [
+ ['text' => 'Управление контактами', 'is_correct' => 1],
+ ['text' => 'Отслеживание сделок', 'is_correct' => 1],
+ ['text' => 'Бухгалтерский учет', 'is_correct' => 0],
+ ['text' => 'Автоматизация задач', 'is_correct' => 1],
+];
+
+foreach ($answers2 as $answerData) {
+ $answer = new LessonPollAnswers();
+ $answer->lesson_poll_id = $poll2->id;
+ $answer->text = $answerData['text'];
+ $answer->is_correct = $answerData['is_correct'];
+ $answer->save();
+}
+
+echo "Урок '{$lesson->title}' создан с ID: {$lesson->id}";
+```
+
+### Пример 1.2: Создание урока с открытыми вопросами
+
+```php
+// Создать урок
+$lesson = new Lessons();
+$lesson->title = "Обработка возражений клиентов";
+$lesson->description = "Техники работы с возражениями";
+$lesson->content = "<h1>Техники работы с возражениями</h1><p>...</p>";
+$lesson->min_correct_percentage = 100; // Все вопросы должны быть зачтены
+$lesson->success_ball = 150;
+$lesson->is_active = 1;
+$lesson->created_admin_id = Yii::$app->user->id;
+$lesson->save();
+
+// Создать открытый вопрос
+$openPoll = new LessonsPoll();
+$openPoll->lesson_id = $lesson->id;
+$openPoll->text = "Опишите своими словами, как вы будете работать с возражением 'Это слишком дорого'";
+$openPoll->pos = 1;
+$openPoll->is_open = 1; // Открытый вопрос
+$openPoll->save();
+
+// Для открытых вопросов НЕ нужно создавать LessonPollAnswers
+
+echo "Урок с открытым вопросом создан";
+```
+
+### Пример 1.3: Создание урока с картинками
+
+```php
+use app\models\Files;
+
+// Загрузить картинку урока
+$file = new Files();
+$file->filename = "crm-interface.png";
+$file->path = "/uploads/lessons/crm-interface.png";
+$file->save();
+
+$lesson = new Lessons();
+$lesson->title = "Интерфейс CRM";
+$lesson->content = "<p>Изучаем интерфейс...</p>";
+$lesson->lessons_image_id = $file->id; // Привязать картинку
+$lesson->save();
+
+// Создать вопрос с картинкой
+$pollFile = new Files();
+$pollFile->filename = "screenshot-menu.png";
+$pollFile->path = "/uploads/lessons/screenshot-menu.png";
+$pollFile->save();
+
+$poll = new LessonsPoll();
+$poll->lesson_id = $lesson->id;
+$poll->text = "Что обозначено красной стрелкой на скриншоте?";
+$poll->lessons_image_id = $pollFile->id;
+$poll->is_open = 0;
+$poll->save();
+
+// Ответы...
+```
+
+---
+
+## 2. Создание группы уроков
+
+### Пример 2.1: Последовательная группа (адаптация)
+
+```php
+use app\models\LessonsGroup;
+use app\models\Lessons;
+
+// Создать группу
+$group = new LessonsGroup();
+$group->title = "Адаптация новых сотрудников";
+$group->description = "Обязательный курс для всех новичков в первую неделю работы";
+$group->chain = 1; // Последовательное прохождение
+$group->days_for_obligatory = 7; // 7 дней на прохождение
+$group->days_for_recommended = 3; // Рекомендуется за 3 дня
+$group->success_ball = 500; // 500 баллов за завершение курса
+$group->fail_ball = 100; // Штраф за провал
+$group->is_active = 1;
+$group->created_admin_id = Yii::$app->user->id;
+$group->save();
+
+// Создать уроки группы
+$lessons = [
+ [
+ 'title' => 'Знакомство с компанией',
+ 'content' => '<h1>История компании</h1><p>...</p>',
+ 'min_correct_percentage' => 70,
+ 'success_ball' => 50,
+ ],
+ [
+ 'title' => 'Правила внутреннего распорядка',
+ 'content' => '<h1>Правила</h1><p>...</p>',
+ 'min_correct_percentage' => 80,
+ 'success_ball' => 75,
+ ],
+ [
+ 'title' => 'Обучение CRM',
+ 'content' => '<h1>CRM система</h1><p>...</p>',
+ 'min_correct_percentage' => 85,
+ 'success_ball' => 100,
+ ],
+ [
+ 'title' => 'Техника безопасности',
+ 'content' => '<h1>ТБ на рабочем месте</h1><p>...</p>',
+ 'min_correct_percentage' => 100,
+ 'success_ball' => 150,
+ ],
+];
+
+foreach ($lessons as $index => $lessonData) {
+ $lesson = new Lessons();
+ $lesson->title = $lessonData['title'];
+ $lesson->content = $lessonData['content'];
+ $lesson->group_id = $group->id;
+ $lesson->pos = ($index + 1) * 2; // 2, 4, 6, 8
+ $lesson->min_correct_percentage = $lessonData['min_correct_percentage'];
+ $lesson->success_ball = $lessonData['success_ball'];
+ $lesson->max_time_minutes = 30;
+ $lesson->is_active = 1;
+ $lesson->created_admin_id = Yii::$app->user->id;
+ $lesson->save();
+
+ // Добавить вопросы для каждого урока (пример упрощен)
+ // ...
+}
+
+echo "Группа '{$group->title}' создана с {$index + 1} уроками";
+```
+
+### Пример 2.2: Параллельная группа (переобучение)
+
+```php
+$group = new LessonsGroup();
+$group->title = "Ежегодное переобучение 2025";
+$group->description = "Обязательное обучение для всех сотрудников";
+$group->chain = 0; // Параллельное прохождение
+$group->days_for_obligatory = 30; // 30 дней
+$group->days_for_recommended = 14; // Рекомендуется за 14 дней
+$group->success_ball = 300;
+$group->is_active = 1;
+$group->created_admin_id = Yii::$app->user->id;
+$group->save();
+
+// Добавить уроки (можно проходить в любом порядке)
+$lessonTitles = [
+ 'Обновления в законодательстве',
+ 'Новые инструменты CRM',
+ 'Изменения в политике безопасности',
+];
+
+foreach ($lessonTitles as $index => $title) {
+ $lesson = new Lessons();
+ $lesson->title = $title;
+ $lesson->group_id = $group->id;
+ $lesson->pos = ($index + 1) * 2;
+ $lesson->min_correct_percentage = 75;
+ $lesson->success_ball = 50;
+ $lesson->is_active = 1;
+ $lesson->save();
+}
+```
+
+---
+
+## 3. Назначение обучения
+
+### Пример 3.1: Назначить урок одному сотруднику
+
+```php
+use app\models\LessonsPassed;
+use app\models\Notifications;
+
+$adminId = 42; // ID сотрудника
+$lessonId = 5; // ID урока
+
+// Проверить, не назначен ли уже
+$exists = LessonsPassed::find()
+ ->where([
+ 'entity' => 'lesson',
+ 'entity_id' => $lessonId,
+ 'admin_id' => $adminId
+ ])
+ ->exists();
+
+if ($exists) {
+ echo "Урок уже назначен этому сотруднику";
+} else {
+ // Создать запись
+ $passed = new LessonsPassed();
+ $passed->entity = 'lesson';
+ $passed->entity_id = $lessonId;
+ $passed->admin_id = $adminId;
+ $passed->status = LessonsPassed::STATUS_ATTACHED;
+ $passed->created = date('Y-m-d H:i:s');
+ $passed->save();
+
+ // Отправить уведомление
+ $lesson = Lessons::findOne($lessonId);
+ $notification = new Notifications();
+ $notification->admin_id = $adminId;
+ $notification->text = "Вам назначен урок '{$lesson->title}'";
+ $notification->type = 'lesson_assigned';
+ $notification->save();
+
+ echo "Урок успешно назначен";
+}
+```
+
+### Пример 3.2: Массовое назначение группы сотрудникам
+
+```php
+$adminIds = [42, 43, 44, 45]; // ID сотрудников
+$groupId = 3; // ID группы
+
+$group = LessonsGroup::findOne($groupId);
+$lessons = $group->lessons;
+
+foreach ($adminIds as $adminId) {
+ // Назначить группу
+ $groupPassed = new LessonsPassed();
+ $groupPassed->entity = 'group';
+ $groupPassed->entity_id = $groupId;
+ $groupPassed->admin_id = $adminId;
+ $groupPassed->status = LessonsPassed::STATUS_ATTACHED;
+ $groupPassed->created = date('Y-m-d H:i:s');
+ $groupPassed->save();
+
+ // Назначить все уроки группы
+ foreach ($lessons as $lesson) {
+ $lessonPassed = new LessonsPassed();
+ $lessonPassed->entity = 'lesson';
+ $lessonPassed->entity_id = $lesson->id;
+ $lessonPassed->admin_id = $adminId;
+ $lessonPassed->status = LessonsPassed::STATUS_ATTACHED;
+ $lessonPassed->created = date('Y-m-d H:i:s');
+ $lessonPassed->save();
+ }
+
+ // Отправить уведомление
+ $notification = new Notifications();
+ $notification->admin_id = $adminId;
+ $notification->text = "Вам назначен курс '{$group->title}'";
+ $notification->type = 'lesson_assigned';
+ $notification->save();
+}
+
+echo "Группа назначена " . count($adminIds) . " сотрудникам";
+```
+
+### Пример 3.3: Назначить всем сотрудникам отдела
+
+```php
+use app\models\Admin;
+
+$departmentId = 5; // ID отдела
+$lessonId = 10; // ID урока
+
+// Получить всех сотрудников отдела
+$admins = Admin::find()
+ ->where(['department_id' => $departmentId])
+ ->andWhere(['is_active' => 1])
+ ->all();
+
+foreach ($admins as $admin) {
+ $passed = new LessonsPassed();
+ $passed->entity = 'lesson';
+ $passed->entity_id = $lessonId;
+ $passed->admin_id = $admin->id;
+ $passed->status = LessonsPassed::STATUS_ATTACHED;
+ $passed->created = date('Y-m-d H:i:s');
+ $passed->save();
+
+ // Уведомление
+ $notification = new Notifications();
+ $notification->admin_id = $admin->id;
+ $notification->text = "Вам назначено обязательное обучение";
+ $notification->save();
+}
+
+echo "Назначено " . count($admins) . " сотрудникам отдела";
+```
+
+---
+
+## 4. Проверка статуса прохождения
+
+### Пример 4.1: Проверить, завершен ли урок
+
+```php
+use app\components\services\LessonPollService;
+
+$adminId = 42;
+$lessonId = 5;
+
+if (LessonPollService::isLessonPollComplete($adminId, $lessonId)) {
+ echo "Урок завершен!";
+} else {
+ echo "Урок не завершен";
+}
+```
+
+### Пример 4.2: Получить статус прохождения
+
+```php
+$passed = LessonsPassed::findOne([
+ 'entity' => 'lesson',
+ 'entity_id' => 5,
+ 'admin_id' => 42
+]);
+
+if ($passed) {
+ $statusLabels = LessonsPassed::statusLabels();
+ echo "Статус: " . $statusLabels[$passed->status];
+
+ // Примеры:
+ // "Назначен"
+ // "Прочитан"
+ // "Провален"
+ // "Пройден"
+ // "Ожидает проверки"
+ // "Не зачтено"
+ // "Зачтено"
+} else {
+ echo "Урок не назначен";
+}
+```
+
+### Пример 4.3: Проверить прогресс группы
+
+```php
+$groupId = 3;
+$adminId = 42;
+
+$group = LessonsGroup::findOne($groupId);
+$lessons = $group->lessons;
+$completedCount = 0;
+
+foreach ($lessons as $lesson) {
+ if (LessonPollService::isLessonPollComplete($adminId, $lesson->id)) {
+ $completedCount++;
+ }
+}
+
+$progress = round(($completedCount / count($lessons)) * 100);
+
+echo "Прогресс: {$completedCount} из " . count($lessons) . " ({$progress}%)";
+```
+
+### Пример 4.4: Получить все незавершенные уроки сотрудника
+
+```php
+$adminId = 42;
+
+$pending = LessonsPassed::find()
+ ->where(['admin_id' => $adminId])
+ ->andWhere(['<', 'status', LessonsPassed::STATUS_PASS_SUCCESS])
+ ->all();
+
+echo "Незавершенных уроков: " . count($pending);
+
+foreach ($pending as $passed) {
+ $lesson = Lessons::findOne($passed->entity_id);
+ echo "- {$lesson->title} ({$passed->statusLabels()[$passed->status]})\n";
+}
+```
+
+---
+
+## 5. Получение статистики
+
+### Пример 5.1: Статистика по уроку
+
+```php
+$lessonId = 5;
+
+$stats = [
+ 'assigned' => LessonsPassed::find()
+ ->where(['entity' => 'lesson', 'entity_id' => $lessonId])
+ ->count(),
+
+ 'completed' => LessonsPassed::find()
+ ->where(['entity' => 'lesson', 'entity_id' => $lessonId])
+ ->andWhere(['in', 'status', [
+ LessonsPassed::STATUS_PASS_SUCCESS,
+ LessonsPassed::STATUS_PASS_CHECK
+ ]])
+ ->count(),
+
+ 'failed' => LessonsPassed::find()
+ ->where(['entity' => 'lesson', 'entity_id' => $lessonId])
+ ->andWhere(['in', 'status', [
+ LessonsPassed::STATUS_PASS_FAIL,
+ LessonsPassed::STATUS_FAIL_CHECK
+ ]])
+ ->count(),
+
+ 'in_progress' => LessonsPassed::find()
+ ->where(['entity' => 'lesson', 'entity_id' => $lessonId])
+ ->andWhere(['in', 'status', [
+ LessonsPassed::STATUS_ATTACHED,
+ LessonsPassed::STATUS_READ
+ ]])
+ ->count(),
+
+ 'need_check' => LessonsPassed::find()
+ ->where(['entity' => 'lesson', 'entity_id' => $lessonId, 'status' => LessonsPassed::STATUS_NEED_CHECK])
+ ->count(),
+];
+
+$stats['success_rate'] = ($stats['assigned'] > 0)
+ ? round(($stats['completed'] / $stats['assigned']) * 100)
+ : 0;
+
+echo "Статистика урока:\n";
+echo "Назначено: {$stats['assigned']}\n";
+echo "Завершено: {$stats['completed']}\n";
+echo "Провалено: {$stats['failed']}\n";
+echo "В процессе: {$stats['in_progress']}\n";
+echo "Ожидает проверки: {$stats['need_check']}\n";
+echo "Процент успеха: {$stats['success_rate']}%\n";
+```
+
+### Пример 5.2: Статистика по сотруднику
+
+```php
+$adminId = 42;
+
+$stats = [
+ 'total' => LessonsPassed::find()->where(['admin_id' => $adminId])->count(),
+
+ 'completed' => LessonsPassed::find()
+ ->where(['admin_id' => $adminId])
+ ->andWhere(['in', 'status', [
+ LessonsPassed::STATUS_PASS_SUCCESS,
+ LessonsPassed::STATUS_PASS_CHECK
+ ]])
+ ->count(),
+
+ 'failed' => LessonsPassed::find()
+ ->where(['admin_id' => $adminId, 'status' => LessonsPassed::STATUS_PASS_FAIL])
+ ->count(),
+
+ 'pending' => LessonsPassed::find()
+ ->where(['admin_id' => $adminId])
+ ->andWhere(['<', 'status', LessonsPassed::STATUS_PASS_SUCCESS])
+ ->count(),
+];
+
+echo "Статистика сотрудника:\n";
+echo "Всего назначено: {$stats['total']}\n";
+echo "Завершено: {$stats['completed']}\n";
+echo "Провалено: {$stats['failed']}\n";
+echo "Не завершено: {$stats['pending']}\n";
+```
+
+### Пример 5.3: SQL запрос для аналитики
+
+```php
+$sql = <<<SQL
+SELECT
+ l.id,
+ l.title,
+ COUNT(lp.id) AS assigned,
+ SUM(CASE WHEN lp.status IN (30, 60) THEN 1 ELSE 0 END) AS completed,
+ SUM(CASE WHEN lp.status IN (20, 50) THEN 1 ELSE 0 END) AS failed,
+ ROUND(SUM(CASE WHEN lp.status IN (30, 60) THEN 1 ELSE 0 END) * 100.0 / COUNT(lp.id), 2) AS success_rate
+FROM lessons l
+LEFT JOIN lessons_passed lp ON lp.entity_id = l.id AND lp.entity = 'lesson'
+GROUP BY l.id
+ORDER BY assigned DESC
+SQL;
+
+$results = Yii::$app->db->createCommand($sql)->queryAll();
+
+foreach ($results as $row) {
+ echo "{$row['title']}: {$row['completed']} / {$row['assigned']} ({$row['success_rate']}%)\n";
+}
+```
+
+---
+
+## 6. Работа с открытыми вопросами
+
+### Пример 6.1: Получить уроки, ожидающие проверки
+
+```php
+$needCheck = LessonsPassed::find()
+ ->where(['status' => LessonsPassed::STATUS_NEED_CHECK])
+ ->all();
+
+echo "Уроков на проверке: " . count($needCheck);
+
+foreach ($needCheck as $passed) {
+ $lesson = Lessons::findOne($passed->entity_id);
+ $admin = Admin::findOne($passed->admin_id);
+
+ echo "- {$lesson->title} (сотрудник: {$admin->name})\n";
+}
+```
+
+### Пример 6.2: Проверить открытые вопросы
+
+```php
+$passedId = 123;
+$passed = LessonsPassed::findOne($passedId);
+$lesson = Lessons::findOne($passed->entity_id);
+
+// Получить открытые вопросы
+$openPolls = LessonsPoll::find()
+ ->where(['lesson_id' => $lesson->id, 'is_open' => 1])
+ ->all();
+
+// Получить ответы сотрудника из сессии
+$session = Yii::$app->session;
+
+foreach ($openPolls as $poll) {
+ $answer = $session->get('lesson_' . $lesson->id . '_answer_' . $poll->id);
+
+ echo "Вопрос: {$poll->text}\n";
+ echo "Ответ: {$answer['openAnswer']}\n\n";
+}
+
+// Администратор выставляет оценки
+$results = [
+ $openPolls[0]->id => 'pass',
+ $openPolls[1]->id => 'fail',
+];
+
+$allPassed = true;
+foreach ($results as $pollId => $result) {
+ if ($result == 'fail') {
+ $allPassed = false;
+ break;
+ }
+}
+
+// Обновить статус
+$passed->status = $allPassed
+ ? LessonsPassed::STATUS_PASS_CHECK
+ : LessonsPassed::STATUS_FAIL_CHECK;
+$passed->open_poll_admin_check_id = Yii::$app->user->id;
+$passed->save(false);
+
+// Отправить уведомление сотруднику
+$notification = new Notifications();
+$notification->admin_id = $passed->admin_id;
+$notification->text = $allPassed
+ ? "Ваши ответы на открытые вопросы зачтены!"
+ : "Ответы не зачтены. Пройдите урок повторно.";
+$notification->save();
+
+echo "Проверка завершена";
+```
+
+---
+
+## 7. Управление позициями уроков
+
+### Пример 7.1: Сортировка уроков в группе
+
+```php
+use app\components\services\LessonService;
+
+$groupId = 3;
+
+$lessons = Lessons::find()
+ ->where(['group_id' => $groupId])
+ ->all();
+
+// Сортировать с шагом 2
+$sortedLessons = LessonService::sortByPosition($lessons);
+
+foreach ($sortedLessons as $lesson) {
+ echo "{$lesson->pos}: {$lesson->title}\n";
+}
+
+// Результат:
+// 2: Урок 1
+// 4: Урок 2
+// 6: Урок 3
+```
+
+### Пример 7.2: Drag-and-drop перемещение
+
+```php
+$groupId = 3;
+
+$lessons = Lessons::find()
+ ->where(['group_id' => $groupId])
+ ->orderBy(['pos' => SORT_ASC])
+ ->all();
+
+// Переместить урок с позиции 0 (первый) на позицию 2 (третий)
+$lessons = LessonService::movePosition($lessons, 0, 2);
+
+// Результат:
+// Урок, который был первым, теперь третий
+```
+
+---
+
+## 8. Интеграция с другими модулями
+
+### Пример 8.1: Начисление баллов через Rating модуль
+
+```php
+use app\components\services\LessonPollService;
+
+$adminId = 42;
+$lessonId = 5;
+$isSuccess = true;
+
+// Завершить урок с начислением баллов
+LessonPollService::pollCompleteActions($adminId, $lessonId, $isSuccess);
+
+// Внутри pollCompleteActions():
+// 1. Обновить статус
+// 2. Начислить баллы через Rating::addPoints()
+// 3. Отправить уведомления
+// 4. Проверить завершение группы
+```
+
+### Пример 8.2: Отправка уведомлений
+
+```php
+$adminId = 42;
+$lessonId = 5;
+
+LessonPollService::sendPollCompleteNotification($adminId, $lessonId);
+
+// Отправляет 2 уведомления:
+// 1. Сотруднику: "Вы завершили урок"
+// 2. Руководителю: "Сотрудник завершил урок"
+```
+
+### Пример 8.3: Проверка сроков
+
+```php
+use app\components\services\LessonPollService;
+
+$adminId = 42;
+$groupId = 3;
+
+// Проверить обязательный срок
+if (LessonPollService::isLessonPollCompleteObligatory($adminId, $groupId)) {
+ echo "Группа завершена вовремя!";
+} else {
+ echo "Просрочено или не завершено";
+}
+
+// Проверить рекомендуемый срок (для бонуса)
+if (LessonPollService::isLessonPollCompleteRecommended($adminId, $groupId)) {
+ echo "Отлично! Начислить бонус!";
+ // Начислить дополнительные баллы
+}
+```
+
+---
+
+**Последнее обновление:** 2025-11-24
--- /dev/null
+# FAQ - Часто задаваемые вопросы
+
+Ответы на распространенные вопросы по модулю Lesson.
+
+## 📋 Оглавление
+
+### Основы
+1. [В чем разница между уроком и группой?](#1-в-чем-разница-между-уроком-и-группой)
+2. [Что такое "открытый вопрос"?](#2-что-такое-открытый-вопрос)
+3. [Как работает параллельное обучение?](#3-как-работает-параллельное-обучение)
+4. [Что такое shuffle (перемешивание вопросов)?](#4-что-такое-shuffle-перемешивание-вопросов)
+
+### Прохождение
+5. [Можно ли переделать тест после провала?](#5-можно-ли-переделать-тест-после-провала)
+6. [Сколько попыток на прохождение теста?](#6-сколько-попыток-на-прохождение-теста)
+7. [Что происходит если время вышло?](#7-что-происходит-если-время-вышло)
+8. [Как работают сроки (обязательный и рекомендуемый)?](#8-как-работают-сроки-обязательный-и-рекомендуемый)
+
+### Баллы и бонусы
+9. [Как начисляются баллы?](#9-как-начисляются-баллы)
+10. [Что такое success_ball и fail_ball?](#10-что-такое-success_ball-и-fail_ball)
+11. [Как получить бонус за быстрое прохождение?](#11-как-получить-бонус-за-быстрое-прохождение)
+
+### Администрирование
+12. [Как назначить обучение сотруднику?](#12-как-назначить-обучение-сотруднику)
+13. [Как проверить открытые вопросы?](#13-как-проверить-открытые-вопросы)
+14. [Как посмотреть статистику прохождения?](#14-как-посмотреть-статистику-прохождения)
+15. [Можно ли изменить урок после назначения?](#15-можно-ли-изменить-урок-после-назначения)
+
+### Технические вопросы
+16. [Где хранятся ответы во время теста?](#16-где-хранятся-ответы-во-время-теста)
+17. [Почему используется шаг 2 для позиций?](#17-почему-используется-шаг-2-для-позиций)
+18. [Что происходит при удалении урока?](#18-что-происходит-при-удалении-урока)
+
+---
+
+## Основы
+
+### 1. В чем разница между уроком и группой?
+
+**Урок (Lessons)** - это одиночная обучающая единица с контентом и тестом.
+
+**Группа (LessonsGroup)** - это курс, объединяющий несколько уроков.
+
+**Ключевые отличия:**
+
+| Параметр | Урок | Группа |
+|----------|------|--------|
+| Назначение | Одна тема | Полный курс |
+| Количество тестов | 1 тест | Несколько тестов (по одному на урок) |
+| Сроки | minutes_for_* | days_for_* |
+| Режим прохождения | N/A | Последовательный / Параллельный |
+| Баллы | За один урок | За весь курс |
+
+**Пример урока:** "Техника безопасности"
+**Пример группы:** "Адаптация новых сотрудников" (включает 5 уроков)
+
+---
+
+### 2. Что такое "открытый вопрос"?
+
+**Открытый вопрос** (`is_open=1`) - это вопрос со свободным текстовым ответом, который требует ручной проверки администратором.
+
+**В отличие от закрытых вопросов:**
+- Нет предустановленных вариантов ответа
+- Сотрудник пишет ответ своими словами
+- Система не может автоматически проверить правильность
+- Требуется участие администратора
+
+**Workflow:**
+1. Сотрудник отвечает на открытый вопрос
+2. Статус урока становится `STATUS_NEED_CHECK`
+3. Администратор проверяет через `/lesson/check-open-poll`
+4. Администратор выставляет "Зачет" или "Незачет"
+5. Статус меняется на `STATUS_PASS_CHECK` или `STATUS_FAIL_CHECK`
+
+**Пример:**
+```
+Вопрос: "Опишите своими словами процесс создания заказа в CRM"
+Ответ сотрудника: "Сначала создаю контакт, потом добавляю товары..."
+Администратор: Зачет ✓
+```
+
+---
+
+### 3. Как работает параллельное обучение?
+
+**Параллельное обучение** (`chain=0`) - режим, при котором все уроки группы доступны одновременно.
+
+**Характеристики:**
+- ✅ Все уроки видны сразу
+- ✅ Можно проходить в любом порядке
+- ✅ Гибкость для сотрудника
+- ❌ Нет контроля последовательности
+
+**Когда использовать:**
+- Ежегодное переобучение
+- Опциональные курсы
+- Модульное обучение без зависимостей
+
+**Пример:**
+```
+Группа "Обновления 2025" (chain=0):
+├── Урок 1: Изменения в законодательстве
+├── Урок 2: Новые функции CRM
+└── Урок 3: Политика безопасности
+
+Сотрудник может начать с Урока 2, потом 3, потом 1
+```
+
+**В отличие от последовательного:**
+```
+Группа "Адаптация" (chain=1):
+├── Урок 1: Знакомство ✓ (завершен)
+├── Урок 2: Правила (доступен)
+└── Урок 3: CRM (заблокирован)
+
+Сотрудник ДОЛЖЕН пройти Урок 2, чтобы открыть Урок 3
+```
+
+---
+
+### 4. Что такое shuffle (перемешивание вопросов)?
+
+**Shuffle** (`shuffle=1`) - функция перемешивания вопросов теста при каждом прохождении.
+
+**Зачем нужно:**
+- Предотвращает списывание
+- Усложняет запоминание порядка ответов
+- Обеспечивает честность тестирования
+
+**Как работает:**
+1. Администратор создает вопросы в порядке 1, 2, 3, 4, 5
+2. При `shuffle=1` система перемешивает: 3, 1, 5, 2, 4
+3. Каждый сотрудник видит вопросы в случайном порядке
+4. При повторном прохождении - снова новый порядок
+
+**Код:**
+```php
+if ($lesson->shuffle == 1) {
+ shuffle($polls);
+}
+
+Yii::$app->session->set('lesson_' . $lessonId . '_polls', $polls);
+```
+
+**Рекомендация:** Использовать для всех тестов, кроме обучающих (где важна последовательность тем).
+
+---
+
+## Прохождение
+
+### 5. Можно ли переделать тест после провала?
+
+**Да, можно.**
+
+При статусе `STATUS_PASS_FAIL` или `STATUS_FAIL_CHECK` сотрудник может:
+1. Вернуться к уроку
+2. Повторно изучить материал
+3. Пройти тест заново
+
+**Ограничений на количество попыток нет** (в текущей реализации).
+
+**Примечание:** В коде есть параметр `max_attempts`, но он не реализован. Можно добавить логику:
+```php
+if ($attempts >= $lesson->max_attempts) {
+ throw new Exception("Превышено количество попыток");
+}
+```
+
+---
+
+### 6. Сколько попыток на прохождение теста?
+
+**В текущей реализации: неограниченно.**
+
+Поле `Lessons.max_attempts` существует, но **не используется** в коде.
+
+**Рекомендация для внедрения:**
+```php
+// В ProceedTestingAction
+$attemptsCount = LessonPassedAttempts::find()
+ ->where(['lesson_id' => $lessonId, 'admin_id' => $adminId])
+ ->count();
+
+if ($lesson->max_attempts && $attemptsCount >= $lesson->max_attempts) {
+ return $this->asJson([
+ 'success' => false,
+ 'message' => "Превышено максимальное количество попыток ({$lesson->max_attempts})",
+ ]);
+}
+```
+
+**Стандартная практика:**
+- Обучающие тесты: неограниченно
+- Аттестационные тесты: 2-3 попытки
+- Сертификационные: 1 попытка
+
+---
+
+### 7. Что происходит если время вышло?
+
+**Если превышен `max_time_minutes`:**
+
+1. Тест автоматически завершается
+2. Статус становится `STATUS_PASS_FAIL`
+3. Начисляются штрафные баллы (`fail_ball`)
+4. Сотрудник может пройти повторно
+
+**Код проверки:**
+```php
+$startTime = Yii::$app->session->get('lesson_' . $lessonId . '_start_time');
+$elapsedMinutes = (time() - $startTime) / 60;
+
+if ($lesson->max_time_minutes && $elapsedMinutes > $lesson->max_time_minutes) {
+ $status = LessonsPassed::STATUS_PASS_FAIL;
+ $message = "Время вышло!";
+}
+```
+
+**Пример:**
+```
+Урок: "Техника безопасности"
+max_time_minutes: 30
+
+Сотрудник начал: 10:00
+Максимальное время: 10:30
+Завершил: 10:35
+
+Результат: ❌ Провал (время вышло)
+```
+
+**Примечание:** Таймер хранится в сессии, поэтому если сотрудник закроет браузер и откроет снова, таймер продолжит работу.
+
+---
+
+### 8. Как работают сроки (обязательный и рекомендуемый)?
+
+**Два типа сроков:**
+
+#### Обязательный срок
+- **Для групп:** `days_for_obligatory`
+- **Для одиночных уроков:** `minutes_for_obligatory`
+- **Назначение:** Жесткий дедлайн
+- **При превышении:** Штрафы, блокировка, отчеты руководству
+
+#### Рекомендуемый срок
+- **Для групп:** `days_for_recommended`
+- **Для одиночных уроков:** `minutes_for_recommended`
+- **Назначение:** Бонус за быстрое прохождение
+- **При превышении:** Нет штрафа, но нет и бонуса
+
+**Схема:**
+```
+|-------|------------|------------> Время
+ Рек. Обяз.
+
+0-3 дня: ✅ Отлично! (бонус)
+3-7 дней: ✅ Вовремя (без бонуса)
+7+ дней: ❌ Просрочено (штраф)
+```
+
+**Пример:**
+```
+Группа "Адаптация":
+- Рекомендуемый срок: 3 дня
+- Обязательный срок: 7 дней
+
+Сотрудник завершил за 2 дня:
+ → success_ball * 1.5 (бонус +50%)
+
+Сотрудник завершил за 5 дней:
+ → success_ball (без изменений)
+
+Сотрудник завершил за 10 дней:
+ → success_ball * 0.5 (штраф -50%)
+```
+
+---
+
+## Баллы и бонусы
+
+### 9. Как начисляются баллы?
+
+**Алгоритм начисления:**
+
+1. **При успешном прохождении урока:**
+ ```
+ баллы = success_ball
+
+ + Если в рекомендуемый срок: * 1.5
+ + Если просрочен обязательный: * 0.5
+ ```
+
+2. **При провале:**
+ ```
+ баллы = -fail_ball (штраф)
+ ```
+
+3. **При завершении группы:**
+ ```
+ баллы_уроков + баллы_группы
+ ```
+
+**Пример:**
+```php
+Урок: success_ball = 100
+
+Сценарий 1 (быстро):
+ Завершено за 20 минут (рек. срок 30 мин)
+ Начислено: 100 * 1.5 = 150 баллов
+
+Сценарий 2 (вовремя):
+ Завершено за 40 минут (обяз. срок 60 мин)
+ Начислено: 100 баллов
+
+Сценарий 3 (просрочка):
+ Завершено за 80 минут (обяз. срок 60 мин)
+ Начислено: 100 * 0.5 = 50 баллов
+
+Сценарий 4 (провал):
+ Тест провален
+ Штраф: -50 баллов (fail_ball)
+```
+
+**Начисление происходит в:** `LessonPollService::pollCompleteActions()`
+
+---
+
+### 10. Что такое success_ball и fail_ball?
+
+**success_ball** - количество баллов, начисляемых за успешное прохождение урока или группы.
+
+**fail_ball** - штрафные баллы за неуспешное прохождение.
+
+**Где настраивается:**
+- В модели `Lessons` (для уроков)
+- В модели `LessonsGroup` (для групп)
+
+**Типичные значения:**
+```
+Простой урок:
+ success_ball: 50-100
+ fail_ball: 10-20
+
+Важный урок:
+ success_ball: 150-200
+ fail_ball: 50
+
+Группа:
+ success_ball: 300-500
+ fail_ball: 100
+```
+
+**Интеграция:** Баллы начисляются через модуль **Rating/Bonus**.
+
+---
+
+### 11. Как получить бонус за быстрое прохождение?
+
+**Условия для бонуса:**
+
+1. Завершить урок/группу успешно
+2. Уложиться в **рекомендуемый срок**
+
+**Размер бонуса:** `success_ball * 1.5` (+50%)
+
+**Проверка:**
+```php
+if (LessonPollService::isLessonPollCompleteRecommended($adminId, $groupId)) {
+ $balls = $group->success_ball * 1.5;
+}
+```
+
+**Пример:**
+```
+Группа "Адаптация":
+ success_ball: 500
+ days_for_recommended: 3 дня
+ days_for_obligatory: 7 дней
+
+Сотрудник завершил за 2 дня:
+ Начислено: 500 * 1.5 = 750 баллов ✅ БОНУС!
+
+Сотрудник завершил за 5 дней:
+ Начислено: 500 баллов (без бонуса)
+```
+
+**Стратегия:** Мотивирует сотрудников проходить обучение быстрее.
+
+---
+
+## Администрирование
+
+### 12. Как назначить обучение сотруднику?
+
+**Способ 1: Через интерфейс**
+1. Открыть `/lesson/edit2`
+2. Выбрать сотрудников (чекбоксы)
+3. Выбрать уроки/группы
+4. Нажать "Назначить"
+
+**Способ 2: Через код**
+```php
+$passed = new LessonsPassed();
+$passed->entity = 'lesson';
+$passed->entity_id = 5;
+$passed->admin_id = 42;
+$passed->status = LessonsPassed::STATUS_ATTACHED;
+$passed->save();
+```
+
+**Способ 3: Массовое назначение**
+```php
+$adminIds = [42, 43, 44];
+foreach ($adminIds as $adminId) {
+ // Создать LessonsPassed
+}
+```
+
+См. [examples.md](./examples.md) для полных примеров.
+
+---
+
+### 13. Как проверить открытые вопросы?
+
+**Шаги:**
+1. Открыть `/lesson/analytics` (найти уроки со статусом "Ожидает проверки")
+2. Кликнуть на урок → `/lesson/check-open-poll?passedId=123`
+3. Прочитать вопросы и ответы сотрудника
+4. Выставить оценки: "Зачет" или "Незачет"
+5. Нажать "Сохранить"
+
+**Результат:**
+- Обновляется статус (`PASS_CHECK` или `FAIL_CHECK`)
+- Отправляется уведомление сотруднику
+- Начисляются баллы (при зачете)
+
+**Примечание:** Если хотя бы один вопрос "Незачет", весь урок не засчитывается.
+
+---
+
+### 14. Как посмотреть статистику прохождения?
+
+**Способ 1: Через интерфейс**
+- Открыть `/lesson/analytics`
+- Фильтровать по датам, сотрудникам, статусам
+- Экспортировать в Excel
+
+**Способ 2: Через SQL**
+```sql
+SELECT
+ l.title,
+ COUNT(lp.id) AS assigned,
+ SUM(CASE WHEN lp.status IN (30, 60) THEN 1 ELSE 0 END) AS completed,
+ ROUND(SUM(CASE WHEN lp.status IN (30, 60) THEN 1 ELSE 0 END) * 100.0 / COUNT(lp.id), 2) AS success_rate
+FROM lessons l
+LEFT JOIN lessons_passed lp ON lp.entity_id = l.id AND lp.entity = 'lesson'
+GROUP BY l.id
+ORDER BY assigned DESC;
+```
+
+**Способ 3: Через код**
+```php
+$stats = [
+ 'assigned' => LessonsPassed::find()->where(['entity' => 'lesson', 'entity_id' => 5])->count(),
+ 'completed' => LessonsPassed::find()->where(['entity' => 'lesson', 'entity_id' => 5, 'status' => 30])->count(),
+];
+```
+
+См. [examples.md](./examples.md) для полных примеров.
+
+---
+
+### 15. Можно ли изменить урок после назначения?
+
+**Да, можно, но с осторожностью.**
+
+**Что можно изменять безопасно:**
+- ✅ Контент урока (`content`)
+- ✅ Картинки
+- ✅ Название и описание
+
+**Что изменять опасно:**
+- ⚠️ Вопросы теста (сотрудники могут уже начать проходить)
+- ⚠️ Сроки (`days_for_obligatory`) - пересчет не происходит автоматически
+- ⚠️ Баллы (`success_ball`) - изменения не применяются ретроактивно
+
+**Рекомендация:**
+1. Если урок еще никто не начал проходить - можно изменять свободно
+2. Если уже есть прохождения - создать новую версию урока
+3. Переназначить новую версию
+
+**Проверка перед изменением:**
+```php
+$hasProgress = LessonsPassed::find()
+ ->where(['entity' => 'lesson', 'entity_id' => 5])
+ ->andWhere(['>', 'status', LessonsPassed::STATUS_ATTACHED])
+ ->exists();
+
+if ($hasProgress) {
+ echo "Внимание! Урок уже кто-то проходит!";
+}
+```
+
+---
+
+## Технические вопросы
+
+### 16. Где хранятся ответы во время теста?
+
+**В сессии Yii2.**
+
+**Структура:**
+```php
+// Порядок вопросов
+Yii::$app->session->set('lesson_5_polls', $polls);
+
+// Время начала
+Yii::$app->session->set('lesson_5_start_time', time());
+
+// Ответ на вопрос 10
+Yii::$app->session->set('lesson_5_answer_10', [
+ 'answerId' => 42,
+ 'isCorrect' => true,
+ 'openAnswer' => null,
+]);
+```
+
+**Причина:** AJAX прохождение теста требует временного хранилища между запросами.
+
+**Очистка:** После завершения теста все ключи удаляются:
+```php
+$session->remove('lesson_' . $lessonId . '_polls');
+$session->remove('lesson_' . $lessonId . '_start_time');
+// ...
+```
+
+**Проблема:** Если сотрудник закроет браузер до завершения теста, данные останутся в сессии до истечения срока действия сессии.
+
+---
+
+### 17. Почему используется шаг 2 для позиций?
+
+**Зачем `pos = 2, 4, 6, 8...` вместо `1, 2, 3, 4...`?**
+
+**Причина:** Облегчает вставку новых уроков между существующими.
+
+**Пример:**
+```
+Уроки: pos = 2, 4, 6, 8
+
+Нужно вставить урок между 2 и 4:
+ Новый урок: pos = 3
+ Пересортировка НЕ нужна!
+
+Если бы: pos = 1, 2, 3, 4
+ Новый урок: pos = 1.5 (невозможно с INTEGER)
+ Нужна пересортировка всех последующих: 2→3, 3→4, 4→5...
+```
+
+**Метод:** `LessonService::sortByPosition()`
+```php
+foreach ($lessons as $index => $lesson) {
+ $lesson->pos = ($index + 1) * 2; // 2, 4, 6...
+ $lesson->save(false);
+}
+```
+
+**Аналог:** Linked list с "зазорами" между узлами.
+
+---
+
+### 18. Что происходит при удалении урока?
+
+**Текущая реализация:** Зависит от наличия `onDelete` правил.
+
+**Рекомендуемое поведение:**
+
+1. **Soft delete** вместо физического удаления:
+ ```php
+ $lesson->is_active = 0;
+ $lesson->save();
+ ```
+
+2. **Проверка перед удалением:**
+ ```php
+ $hasAssignments = LessonsPassed::find()
+ ->where(['entity' => 'lesson', 'entity_id' => $lessonId])
+ ->exists();
+
+ if ($hasAssignments) {
+ throw new Exception("Урок назначен сотрудникам. Удаление невозможно.");
+ }
+ ```
+
+3. **Cascade delete связанных данных:**
+ ```php
+ // Удалить вопросы
+ LessonsPoll::deleteAll(['lesson_id' => $lessonId]);
+
+ // Удалить ответы
+ LessonPollAnswers::deleteAll(['lesson_poll_id' => ...]);
+
+ // Удалить прогресс
+ LessonsPassed::deleteAll(['entity' => 'lesson', 'entity_id' => $lessonId]);
+ ```
+
+**Проблема:** Orphan файлы (картинки) могут остаться в БД и на диске.
+
+**Решение:** Использовать транзакции и проверки integrity.
+
+---
+
+**Последнее обновление:** 2025-11-24
--- /dev/null
+# Модели модуля Lesson
+
+Полное описание всех моделей модуля обучения с полями, отношениями, валидацией и примерами использования.
+
+## 📦 Список моделей
+
+1. [Lessons](#1-lessons) - основные уроки
+2. [LessonsGroup](#2-lessonsgroup) - группы уроков (курсы)
+3. [LessonsPoll](#3-lessonspoll) - вопросы тестов
+4. [LessonPollAnswers](#4-lessonpollanswers) - варианты ответов
+5. [LessonsPassed](#5-lessonspassed) - прогресс прохождения
+
+---
+
+## 1. Lessons
+
+**Путь:** `erp24/models/Lessons.php`
+**Таблица:** `lessons`
+**Назначение:** Хранение обучающих уроков с контентом, тестами и настройками
+
+### Структура таблицы
+
+| Поле | Тип | Обязательное | Описание |
+|------|-----|--------------|----------|
+| `id` | INTEGER | PRIMARY KEY | ID урока |
+| `title` | VARCHAR(255) | ✓ | Название урока |
+| `description` | TEXT | | Краткое описание |
+| `content` | TEXT | | HTML контент урока |
+| `lessons_image_id` | INTEGER | | ID картинки урока (Files) |
+| `group_id` | INTEGER | | ID группы (NULL = самостоятельный урок) |
+| `pos` | INTEGER | | Позиция внутри группы |
+| `success_ball` | INTEGER | | Баллы за успешное прохождение |
+| `fail_ball` | INTEGER | | Штрафные баллы за провал |
+| `shuffle` | TINYINT(1) | | Перемешивать вопросы (1/0) |
+| `min_correct_percentage` | INTEGER | | Минимальный % правильных ответов |
+| `max_time_minutes` | INTEGER | | Максимальное время на тест (минуты) |
+| `max_attempts` | INTEGER | | Максимальное количество попыток |
+| `minutes_for_obligatory` | INTEGER | | Обязательный срок (минуты) для одиночных |
+| `minutes_for_recommended` | INTEGER | | Рекомендуемый срок (минуты) для одиночных |
+| `is_active` | TINYINT(1) | DEFAULT 1 | Активен ли урок |
+| `created_admin_id` | INTEGER | | ID создателя |
+| `updated_admin_id` | INTEGER | | ID последнего редактора |
+| `created_at` | TIMESTAMP | | Дата создания |
+| `updated_at` | TIMESTAMP | | Дата обновления |
+
+### Отношения
+
+```php
+public function relations()
+{
+ return [
+ 'group' => [self::BELONGS_TO, LessonsGroup::class, 'group_id'],
+ 'polls' => [self::HAS_MANY, LessonsPoll::class, 'lesson_id'],
+ 'passedRecords' => [self::HAS_MANY, LessonsPassed::class, 'entity_id'],
+ 'lessonsImage' => [self::BELONGS_TO, Files::class, 'lessons_image_id'],
+ 'createdAdmin' => [self::BELONGS_TO, Admin::class, 'created_admin_id'],
+ 'updatedAdmin' => [self::BELONGS_TO, Admin::class, 'updated_admin_id'],
+ ];
+}
+```
+
+### Валидация
+
+```php
+public function rules()
+{
+ return [
+ ['title', 'required'],
+ ['title', 'string', 'max' => 255],
+ ['description, content', 'safe'],
+ ['group_id, pos, lessons_image_id', 'integer'],
+ ['success_ball, fail_ball', 'integer'],
+ ['min_correct_percentage', 'integer', 'min' => 0, 'max' => 100],
+ ['max_time_minutes, max_attempts', 'integer', 'min' => 1],
+ ['minutes_for_obligatory, minutes_for_recommended', 'integer'],
+ ['shuffle, is_active', 'boolean'],
+ ['is_active', 'default', 'value' => 1],
+ ];
+}
+```
+
+### Ключевые методы
+
+#### getPolls()
+Получить все вопросы теста для урока.
+
+```php
+public function getPolls()
+{
+ return $this->hasMany(LessonsPoll::class, ['lesson_id' => 'id'])
+ ->orderBy(['pos' => SORT_ASC]);
+}
+```
+
+#### getPassedRecords()
+Получить все записи прохождения урока.
+
+```php
+public function getPassedRecords()
+{
+ return $this->hasMany(LessonsPassed::class, ['entity_id' => 'id'])
+ ->where(['entity' => 'lesson']);
+}
+```
+
+### Пример использования
+
+```php
+// Создание нового урока
+$lesson = new Lessons();
+$lesson->title = "Основы работы с CRM";
+$lesson->description = "Изучаем интерфейс и базовые функции";
+$lesson->content = "<h1>Введение</h1><p>CRM система позволяет...</p>";
+$lesson->group_id = 5; // Часть группы "Адаптация"
+$lesson->pos = 1; // Первый урок в группе
+$lesson->min_correct_percentage = 80; // Нужно 80% правильных
+$lesson->max_time_minutes = 30; // 30 минут на тест
+$lesson->success_ball = 100; // 100 баллов за успех
+$lesson->shuffle = 1; // Перемешивать вопросы
+$lesson->is_active = 1;
+$lesson->created_admin_id = Yii::$app->user->id;
+$lesson->save();
+
+// Получить все вопросы урока
+$polls = $lesson->polls; // Отсортированы по pos
+
+// Проверить, прошел ли сотрудник урок
+$passed = LessonsPassed::findOne([
+ 'entity' => 'lesson',
+ 'entity_id' => $lesson->id,
+ 'admin_id' => 42,
+ 'status' => LessonsPassed::STATUS_PASS_SUCCESS
+]);
+
+if ($passed) {
+ echo "Урок пройден!";
+}
+```
+
+---
+
+## 2. LessonsGroup
+
+**Путь:** `erp24/models/LessonsGroup.php`
+**Таблица:** `lessons_group`
+**Назначение:** Группировка уроков в курсы с режимами прохождения
+
+### Структура таблицы
+
+| Поле | Тип | Обязательное | Описание |
+|------|-----|--------------|----------|
+| `id` | INTEGER | PRIMARY KEY | ID группы |
+| `title` | VARCHAR(255) | ✓ | Название курса |
+| `description` | TEXT | | Описание курса |
+| `lessons_image_id` | INTEGER | | ID картинки курса |
+| `chain` | TINYINT(1) | DEFAULT 0 | Режим: 0=параллельный, 1=последовательный |
+| `days_for_obligatory` | INTEGER | | Обязательный срок прохождения (дни) |
+| `days_for_recommended` | INTEGER | | Рекомендуемый срок прохождения (дни) |
+| `success_ball` | INTEGER | | Баллы за завершение курса |
+| `fail_ball` | INTEGER | | Штрафные баллы |
+| `is_active` | TINYINT(1) | DEFAULT 1 | Активна ли группа |
+| `created_admin_id` | INTEGER | | ID создателя |
+| `updated_admin_id` | INTEGER | | ID редактора |
+| `created_at` | TIMESTAMP | | Дата создания |
+| `updated_at` | TIMESTAMP | | Дата обновления |
+
+### Режимы обучения
+
+#### Последовательный (chain = 1)
+Уроки проходятся **строго по порядку**. Следующий урок становится доступным только после успешного завершения предыдущего.
+
+**Использование:** Адаптация новичков, технические курсы с нарастающей сложностью.
+
+#### Параллельный (chain = 0)
+Все уроки доступны **одновременно**. Сотрудник может проходить их в любом порядке.
+
+**Использование:** Обязательное ежегодное обучение, опциональные курсы.
+
+### Отношения
+
+```php
+public function relations()
+{
+ return [
+ 'lessons' => [self::HAS_MANY, Lessons::class, 'group_id'],
+ 'passedRecords' => [self::HAS_MANY, LessonsPassed::class, 'entity_id'],
+ 'lessonsImage' => [self::BELONGS_TO, Files::class, 'lessons_image_id'],
+ 'createdAdmin' => [self::BELONGS_TO, Admin::class, 'created_admin_id'],
+ 'updatedAdmin' => [self::BELONGS_TO, Admin::class, 'updated_admin_id'],
+ ];
+}
+```
+
+### Валидация
+
+```php
+public function rules()
+{
+ return [
+ ['title', 'required'],
+ ['title', 'string', 'max' => 255],
+ ['description', 'safe'],
+ ['lessons_image_id', 'integer'],
+ ['chain', 'boolean'],
+ ['days_for_obligatory, days_for_recommended', 'integer', 'min' => 1],
+ ['success_ball, fail_ball', 'integer'],
+ ['is_active', 'boolean'],
+ ['is_active', 'default', 'value' => 1],
+ ];
+}
+```
+
+### Ключевые методы
+
+#### getLessons()
+Получить все уроки группы (отсортированные по позиции).
+
+```php
+public function getLessons()
+{
+ return $this->hasMany(Lessons::class, ['group_id' => 'id'])
+ ->where(['is_active' => 1])
+ ->orderBy(['pos' => SORT_ASC]);
+}
+```
+
+### Пример использования
+
+```php
+// Создание последовательного курса адаптации
+$group = new LessonsGroup();
+$group->title = "Адаптация новых сотрудников";
+$group->description = "Обязательный курс для всех новичков";
+$group->chain = 1; // Последовательное прохождение
+$group->days_for_obligatory = 7; // 7 дней на прохождение
+$group->days_for_recommended = 3; // Рекомендуется за 3 дня
+$group->success_ball = 500; // 500 баллов за завершение
+$group->is_active = 1;
+$group->created_admin_id = Yii::$app->user->id;
+$group->save();
+
+// Добавить уроки в группу
+$lesson1 = new Lessons([
+ 'title' => "Знакомство с компанией",
+ 'group_id' => $group->id,
+ 'pos' => 1,
+]);
+$lesson1->save();
+
+$lesson2 = new Lessons([
+ 'title' => "Правила работы",
+ 'group_id' => $group->id,
+ 'pos' => 2,
+]);
+$lesson2->save();
+
+// Получить все уроки группы
+$lessons = $group->lessons; // Автоматически отсортированы по pos
+
+// Создание параллельного курса
+$parallelGroup = new LessonsGroup();
+$parallelGroup->title = "Ежегодное переобучение";
+$parallelGroup->chain = 0; // Параллельное прохождение
+$parallelGroup->days_for_obligatory = 30;
+$parallelGroup->save();
+```
+
+---
+
+## 3. LessonsPoll
+
+**Путь:** `erp24/models/LessonsPoll.php`
+**Таблица:** `lessons_poll`
+**Назначение:** Вопросы тестов для проверки знаний
+
+### Структура таблицы
+
+| Поле | Тип | Обязательное | Описание |
+|------|-----|--------------|----------|
+| `id` | INTEGER | PRIMARY KEY | ID вопроса |
+| `lesson_id` | INTEGER | ✓ | ID урока |
+| `text` | TEXT | ✓ | Текст вопроса |
+| `pos` | INTEGER | | Позиция вопроса |
+| `is_open` | TINYINT(1) | DEFAULT 0 | 0=закрытый, 1=открытый |
+| `lessons_image_id` | INTEGER | | ID картинки вопроса |
+| `created_at` | TIMESTAMP | | Дата создания |
+| `updated_at` | TIMESTAMP | | Дата обновления |
+
+### Типы вопросов
+
+#### Закрытый вопрос (is_open = 0)
+Вопрос с **предустановленными вариантами ответа**. Сотрудник выбирает один или несколько правильных ответов из списка.
+
+- Правильный ответ определяется через `LessonPollAnswers.is_correct = 1`
+- Проверка автоматическая
+- Не требует участия администратора
+
+#### Открытый вопрос (is_open = 1)
+Вопрос со **свободным текстовым ответом**. Сотрудник пишет ответ своими словами.
+
+- Требует ручной проверки администратором
+- Статус урока становится `STATUS_NEED_CHECK`
+- Администратор проверяет через `CheckOpenPoll` action
+- После проверки статус меняется на `STATUS_PASS_CHECK` или `STATUS_FAIL_CHECK`
+
+### Отношения
+
+```php
+public function relations()
+{
+ return [
+ 'lesson' => [self::BELONGS_TO, Lessons::class, 'lesson_id'],
+ 'answers' => [self::HAS_MANY, LessonPollAnswers::class, 'lesson_poll_id'],
+ 'lessonsImage' => [self::BELONGS_TO, Files::class, 'lessons_image_id'],
+ ];
+}
+```
+
+### Валидация
+
+```php
+public function rules()
+{
+ return [
+ ['lesson_id, text', 'required'],
+ ['text', 'string'],
+ ['lesson_id, pos, lessons_image_id', 'integer'],
+ ['is_open', 'boolean'],
+ ['is_open', 'default', 'value' => 0],
+ ];
+}
+```
+
+### Ключевые методы
+
+#### getCorrectAnswers()
+Получить правильные ответы на закрытый вопрос.
+
+```php
+public function getCorrectAnswers()
+{
+ return $this->hasMany(LessonPollAnswers::class, ['lesson_poll_id' => 'id'])
+ ->where(['is_correct' => 1]);
+}
+```
+
+### Пример использования
+
+```php
+// Создание закрытого вопроса с вариантами ответа
+$poll = new LessonsPoll();
+$poll->lesson_id = 5;
+$poll->text = "Какая комбинация клавиш открывает список задач?";
+$poll->pos = 1;
+$poll->is_open = 0; // Закрытый вопрос
+$poll->save();
+
+// Добавить варианты ответов
+$answer1 = new LessonPollAnswers([
+ 'lesson_poll_id' => $poll->id,
+ 'text' => "Ctrl + T",
+ 'is_correct' => 1, // Правильный ответ
+]);
+$answer1->save();
+
+$answer2 = new LessonPollAnswers([
+ 'lesson_poll_id' => $poll->id,
+ 'text' => "Ctrl + K",
+ 'is_correct' => 0, // Неправильный ответ
+]);
+$answer2->save();
+
+// Создание открытого вопроса
+$openPoll = new LessonsPoll();
+$openPoll->lesson_id = 5;
+$openPoll->text = "Опишите своими словами процесс создания заказа";
+$openPoll->pos = 2;
+$openPoll->is_open = 1; // Открытый вопрос
+$openPoll->save();
+
+// Получить все вопросы урока
+$polls = LessonsPoll::find()
+ ->where(['lesson_id' => 5])
+ ->orderBy(['pos' => SORT_ASC])
+ ->all();
+```
+
+---
+
+## 4. LessonPollAnswers
+
+**Путь:** `erp24/models/LessonPollAnswers.php`
+**Таблица:** `lesson_poll_answers`
+**Назначение:** Варианты ответов на закрытые вопросы
+
+### Структура таблицы
+
+| Поле | Тип | Обязательное | Описание |
+|------|-----|--------------|----------|
+| `id` | INTEGER | PRIMARY KEY | ID ответа |
+| `lesson_poll_id` | INTEGER | ✓ | ID вопроса |
+| `text` | TEXT | ✓ | Текст ответа |
+| `is_correct` | TINYINT(1) | DEFAULT 0 | Правильный ответ (1/0) |
+| `lessons_image_id` | INTEGER | | ID картинки ответа |
+
+### Отношения
+
+```php
+public function relations()
+{
+ return [
+ 'poll' => [self::BELONGS_TO, LessonsPoll::class, 'lesson_poll_id'],
+ 'lessonsImage' => [self::BELONGS_TO, Files::class, 'lessons_image_id'],
+ ];
+}
+```
+
+### Валидация
+
+```php
+public function rules()
+{
+ return [
+ ['lesson_poll_id, text', 'required'],
+ ['text', 'string'],
+ ['lesson_poll_id, lessons_image_id', 'integer'],
+ ['is_correct', 'boolean'],
+ ['is_correct', 'default', 'value' => 0],
+ ];
+}
+```
+
+### Пример использования
+
+```php
+// Создать вопрос с несколькими правильными ответами
+$poll = LessonsPoll::findOne(10);
+
+$answers = [
+ ['text' => 'PHP', 'is_correct' => 1],
+ ['text' => 'JavaScript', 'is_correct' => 1],
+ ['text' => 'Python', 'is_correct' => 0],
+ ['text' => 'Java', 'is_correct' => 0],
+];
+
+foreach ($answers as $answerData) {
+ $answer = new LessonPollAnswers();
+ $answer->lesson_poll_id = $poll->id;
+ $answer->text = $answerData['text'];
+ $answer->is_correct = $answerData['is_correct'];
+ $answer->save();
+}
+
+// Получить правильные ответы
+$correctAnswers = LessonPollAnswers::find()
+ ->where(['lesson_poll_id' => 10, 'is_correct' => 1])
+ ->all();
+
+// Проверить ответ пользователя
+$userAnswerId = 42;
+$userAnswer = LessonPollAnswers::findOne($userAnswerId);
+
+if ($userAnswer && $userAnswer->is_correct == 1) {
+ echo "Правильно!";
+} else {
+ echo "Неправильно!";
+}
+```
+
+---
+
+## 5. LessonsPassed
+
+**Путь:** `erp24/models/LessonsPassed.php`
+**Таблица:** `lessons_passed`
+**Назначение:** Отслеживание прогресса прохождения уроков и групп
+
+### Структура таблицы
+
+| Поле | Тип | Обязательное | Описание |
+|------|-----|--------------|----------|
+| `id` | INTEGER | PRIMARY KEY | ID записи |
+| `entity` | ENUM | ✓ | Тип: 'lesson' или 'group' |
+| `entity_id` | INTEGER | ✓ | ID урока или группы |
+| `admin_id` | INTEGER | ✓ | ID сотрудника |
+| `status` | INTEGER | DEFAULT 0 | Статус прохождения (0-60) |
+| `open_poll_admin_check_id` | INTEGER | | ID проверяющего администратора |
+| `created` | TIMESTAMP | | Дата назначения |
+
+### Статусы (константы)
+
+```php
+const STATUS_ATTACHED = 0; // Назначен
+const STATUS_READ = 10; // Прочитан
+const STATUS_PASS_FAIL = 20; // Провален
+const STATUS_PASS_SUCCESS = 30; // Пройден
+const STATUS_NEED_CHECK = 40; // Ожидает проверки
+const STATUS_FAIL_CHECK = 50; // Проверка отрицательная
+const STATUS_PASS_CHECK = 60; // Проверка положительная
+```
+
+### Переходы статусов
+
+```mermaid
+graph LR
+ A[0: ATTACHED] --> B[10: READ]
+ B --> C{Тест}
+ C -->|Успех| D[30: PASS_SUCCESS]
+ C -->|Провал| E[20: PASS_FAIL]
+ C -->|Открытые вопросы| F[40: NEED_CHECK]
+ F -->|Зачет| G[60: PASS_CHECK]
+ F -->|Незачет| H[50: FAIL_CHECK]
+ E -->|Повтор| B
+ H -->|Повтор| B
+```
+
+### Метод statusLabels()
+
+Возвращает текстовые метки для статусов.
+
+```php
+public static function statusLabels()
+{
+ return [
+ self::STATUS_ATTACHED => 'Назначен',
+ self::STATUS_READ => 'Прочитан',
+ self::STATUS_PASS_FAIL => 'Провален',
+ self::STATUS_PASS_SUCCESS => 'Пройден',
+ self::STATUS_NEED_CHECK => 'Ожидает проверки',
+ self::STATUS_FAIL_CHECK => 'Не зачтено',
+ self::STATUS_PASS_CHECK => 'Зачтено',
+ ];
+}
+```
+
+### Отношения
+
+```php
+public function relations()
+{
+ return [
+ 'admin' => [self::BELONGS_TO, Admin::class, 'admin_id'],
+ 'lesson' => [self::BELONGS_TO, Lessons::class, 'entity_id'],
+ 'group' => [self::BELONGS_TO, LessonsGroup::class, 'entity_id'],
+ 'checkAdmin' => [self::BELONGS_TO, Admin::class, 'open_poll_admin_check_id'],
+ ];
+}
+```
+
+### Валидация
+
+```php
+public function rules()
+{
+ return [
+ ['entity, entity_id, admin_id', 'required'],
+ ['entity', 'in', 'range' => ['lesson', 'group']],
+ ['entity_id, admin_id, open_poll_admin_check_id', 'integer'],
+ ['status', 'integer'],
+ ['status', 'in', 'range' => [0, 10, 20, 30, 40, 50, 60]],
+ ['status', 'default', 'value' => self::STATUS_ATTACHED],
+ ];
+}
+```
+
+### Ключевые методы
+
+#### isCompleted()
+Проверить, завершен ли урок/группа.
+
+```php
+public function isCompleted()
+{
+ return in_array($this->status, [
+ self::STATUS_PASS_SUCCESS,
+ self::STATUS_PASS_CHECK,
+ ]);
+}
+```
+
+#### isFailed()
+Проверить, провален ли урок.
+
+```php
+public function isFailed()
+{
+ return in_array($this->status, [
+ self::STATUS_PASS_FAIL,
+ self::STATUS_FAIL_CHECK,
+ ]);
+}
+```
+
+### Пример использования
+
+```php
+// Назначить урок сотруднику
+$passed = new LessonsPassed();
+$passed->entity = 'lesson';
+$passed->entity_id = 5;
+$passed->admin_id = 42;
+$passed->status = LessonsPassed::STATUS_ATTACHED;
+$passed->created = date('Y-m-d H:i:s');
+$passed->save();
+
+// Сотрудник открыл урок
+$passed->status = LessonsPassed::STATUS_READ;
+$passed->save(false);
+
+// Сотрудник прошел тест успешно
+$passed->status = LessonsPassed::STATUS_PASS_SUCCESS;
+$passed->save(false);
+
+// Проверить статус
+if ($passed->isCompleted()) {
+ echo "Урок завершен!";
+}
+
+// Назначить группу
+$groupPassed = new LessonsPassed();
+$groupPassed->entity = 'group';
+$groupPassed->entity_id = 3;
+$groupPassed->admin_id = 42;
+$groupPassed->status = LessonsPassed::STATUS_ATTACHED;
+$groupPassed->save();
+
+// Получить все назначения сотрудника
+$allAssignments = LessonsPassed::find()
+ ->where(['admin_id' => 42])
+ ->orderBy(['created' => SORT_DESC])
+ ->all();
+
+// Получить незавершенные уроки
+$pending = LessonsPassed::find()
+ ->where(['admin_id' => 42])
+ ->andWhere(['<', 'status', LessonsPassed::STATUS_PASS_SUCCESS])
+ ->all();
+
+// Получить статистику
+$stats = [
+ 'total' => LessonsPassed::find()->where(['admin_id' => 42])->count(),
+ 'completed' => LessonsPassed::find()
+ ->where(['admin_id' => 42])
+ ->andWhere(['in', 'status', [
+ LessonsPassed::STATUS_PASS_SUCCESS,
+ LessonsPassed::STATUS_PASS_CHECK
+ ]])
+ ->count(),
+ 'failed' => LessonsPassed::find()
+ ->where(['admin_id' => 42, 'status' => LessonsPassed::STATUS_PASS_FAIL])
+ ->count(),
+];
+
+echo "Пройдено: {$stats['completed']} из {$stats['total']}";
+```
+
+---
+
+## 📊 ER-диаграмма моделей
+
+```mermaid
+erDiagram
+ LessonsGroup ||--o{ Lessons : contains
+ Lessons ||--o{ LessonsPoll : has
+ LessonsPoll ||--o{ LessonPollAnswers : has
+ Lessons ||--o{ LessonsPassed : tracks
+ LessonsGroup ||--o{ LessonsPassed : tracks
+ Files ||--o{ Lessons : illustrates
+ Files ||--o{ LessonsGroup : illustrates
+ Files ||--o{ LessonsPoll : illustrates
+ Files ||--o{ LessonPollAnswers : illustrates
+ Admin ||--o{ Lessons : creates
+ Admin ||--o{ LessonsPassed : assigned_to
+ Admin ||--o{ LessonsPassed : checks
+
+ LessonsGroup {
+ int id PK
+ string title
+ text description
+ int lessons_image_id FK
+ bool chain
+ int days_for_obligatory
+ int days_for_recommended
+ int success_ball
+ int fail_ball
+ bool is_active
+ }
+
+ Lessons {
+ int id PK
+ string title
+ text description
+ text content
+ int lessons_image_id FK
+ int group_id FK
+ int pos
+ int success_ball
+ int fail_ball
+ bool shuffle
+ int min_correct_percentage
+ int max_time_minutes
+ int max_attempts
+ int minutes_for_obligatory
+ int minutes_for_recommended
+ bool is_active
+ }
+
+ LessonsPoll {
+ int id PK
+ int lesson_id FK
+ text text
+ int pos
+ bool is_open
+ int lessons_image_id FK
+ }
+
+ LessonPollAnswers {
+ int id PK
+ int lesson_poll_id FK
+ text text
+ bool is_correct
+ int lessons_image_id FK
+ }
+
+ LessonsPassed {
+ int id PK
+ enum entity
+ int entity_id FK
+ int admin_id FK
+ int status
+ int open_poll_admin_check_id FK
+ timestamp created
+ }
+
+ Files {
+ int id PK
+ string filename
+ string path
+ }
+
+ Admin {
+ int id PK
+ string name
+ }
+```
+
+---
+
+**Последнее обновление:** 2025-11-24
--- /dev/null
+# Сервисы модуля Lesson
+
+Описание бизнес-логики и сервисов модуля обучения.
+
+## 📦 Список сервисов
+
+1. [LessonService](#1-lessonservice) - управление позициями уроков
+2. [LessonPollService](#2-lessonpollservice) - управление прохождением тестов
+
+---
+
+## 1. LessonService
+
+**Путь:** `erp24/components/services/LessonService.php`
+**Назначение:** Управление позициями уроков в группе (drag-and-drop сортировка)
+
+### Методы
+
+#### sortByPosition()
+
+Сортирует массив уроков по полю `pos` с шагом 2.
+
+**Сигнатура:**
+```php
+public static function sortByPosition(array $lessons): array
+```
+
+**Параметры:**
+- `$lessons` (array) - массив объектов Lessons
+
+**Возвращает:**
+- `array` - отсортированный массив
+
+**Логика:**
+1. Сортирует уроки по `pos` (ASC)
+2. Переназначает позиции с шагом 2: 2, 4, 6, 8...
+3. Сохраняет изменения в БД
+
+**Зачем шаг 2?**
+Оставляет "зазоры" между позициями для возможности вставки новых уроков без пересортировки всего массива.
+
+**Пример:**
+```php
+$lessons = Lessons::find()->where(['group_id' => 5])->all();
+$sortedLessons = LessonService::sortByPosition($lessons);
+
+// Результат: pos = 2, 4, 6, 8, 10...
+```
+
+**Код:**
+```php
+public static function sortByPosition(array $lessons): array
+{
+ // Bubble sort по pos
+ $n = count($lessons);
+ for ($i = 0; $i < $n - 1; $i++) {
+ for ($j = 0; $j < $n - $i - 1; $j++) {
+ if ($lessons[$j]->pos > $lessons[$j + 1]->pos) {
+ // Swap
+ $temp = $lessons[$j];
+ $lessons[$j] = $lessons[$j + 1];
+ $lessons[$j + 1] = $temp;
+ }
+ }
+ }
+
+ // Переназначить позиции с шагом 2
+ foreach ($lessons as $index => $lesson) {
+ $lesson->pos = ($index + 1) * 2;
+ $lesson->save(false);
+ }
+
+ return $lessons;
+}
+```
+
+**Проблемы:**
+❌ **Bubble sort O(n²)** - неэффективно для больших массивов (рекомендуется `usort()`)
+❌ **save(false)** - пропускает валидацию (лучше использовать `updateAttributes(['pos' => $newPos])`)
+
+---
+
+#### movePosition()
+
+Перемещает урок с позиции A на позицию B (drag-and-drop).
+
+**Сигнатура:**
+```php
+public static function movePosition(array $lessons, int $oldIndex, int $newIndex): array
+```
+
+**Параметры:**
+- `$lessons` (array) - массив уроков
+- `$oldIndex` (int) - текущий индекс урока
+- `$newIndex` (int) - новый индекс
+
+**Возвращает:**
+- `array` - массив с обновленными позициями
+
+**Алгоритм:**
+1. Извлекает урок из старой позиции
+2. Вставляет в новую позицию
+3. Вызывает `sortByPosition()` для переиндексации
+
+**Пример:**
+```php
+$lessons = Lessons::find()->where(['group_id' => 5])->all();
+
+// Переместить урок с позиции 0 на позицию 3
+$lessons = LessonService::movePosition($lessons, 0, 3);
+
+// Урок, который был первым, теперь четвертый
+```
+
+**Код:**
+```php
+public static function movePosition(array $lessons, int $oldIndex, int $newIndex): array
+{
+ if ($oldIndex === $newIndex) {
+ return $lessons;
+ }
+
+ // Извлечь урок из старой позиции
+ $lesson = $lessons[$oldIndex];
+ array_splice($lessons, $oldIndex, 1);
+
+ // Вставить в новую позицию
+ array_splice($lessons, $newIndex, 0, [$lesson]);
+
+ // Переиндексировать с шагом 2
+ return self::sortByPosition($lessons);
+}
+```
+
+**Использование в контроллере:**
+```php
+// EditLessonAction - drag-and-drop обработка
+$post = Yii::$app->request->post();
+$oldIndex = $post['oldIndex'];
+$newIndex = $post['newIndex'];
+
+$lessons = Lessons::find()->where(['group_id' => $groupId])->all();
+$lessons = LessonService::movePosition($lessons, $oldIndex, $newIndex);
+
+return $this->asJson(['success' => true]);
+```
+
+---
+
+#### setPositionAsInSort()
+
+Переназначает позиции в соответствии с текущим порядком массива.
+
+**Сигнатура:**
+```php
+public static function setPositionAsInSort(array $lessons): array
+```
+
+**Параметры:**
+- `$lessons` (array) - массив уроков
+
+**Возвращает:**
+- `array` - массив с обновленными позициями
+
+**Логика:**
+Присваивает позиции: 2, 4, 6... в соответствии с текущим порядком в массиве (без сортировки).
+
+**Пример:**
+```php
+$lessons = [
+ Lessons::findOne(10), // pos будет 2
+ Lessons::findOne(5), // pos будет 4
+ Lessons::findOne(8), // pos будет 6
+];
+
+$lessons = LessonService::setPositionAsInSort($lessons);
+```
+
+**Код:**
+```php
+public static function setPositionAsInSort(array $lessons): array
+{
+ foreach ($lessons as $index => $lesson) {
+ $lesson->pos = ($index + 1) * 2;
+ $lesson->save(false);
+ }
+
+ return $lessons;
+}
+```
+
+---
+
+### Пример полного workflow
+
+```php
+// 1. Создать группу
+$group = new LessonsGroup([
+ 'title' => 'Основы CRM',
+ 'chain' => 1,
+]);
+$group->save();
+
+// 2. Добавить уроки
+$lesson1 = new Lessons(['title' => 'Урок 1', 'group_id' => $group->id, 'pos' => 10]);
+$lesson2 = new Lessons(['title' => 'Урок 2', 'group_id' => $group->id, 'pos' => 5]);
+$lesson3 = new Lessons(['title' => 'Урок 3', 'group_id' => $group->id, 'pos' => 20]);
+$lesson1->save();
+$lesson2->save();
+$lesson3->save();
+
+// 3. Сортировать уроки
+$lessons = Lessons::find()->where(['group_id' => $group->id])->all();
+$lessons = LessonService::sortByPosition($lessons);
+
+// Результат:
+// Урок 2 (pos=2)
+// Урок 1 (pos=4)
+// Урок 3 (pos=6)
+
+// 4. Переместить Урок 3 на первое место
+$lessons = LessonService::movePosition($lessons, 2, 0);
+
+// Результат:
+// Урок 3 (pos=2)
+// Урок 2 (pos=4)
+// Урок 1 (pos=6)
+```
+
+---
+
+## 2. LessonPollService
+
+**Путь:** `erp24/components/services/LessonPollService.php`
+**Назначение:** Управление прохождением тестов, проверка сроков, отправка уведомлений
+
+### Методы
+
+#### sendPollCompleteNotification()
+
+Отправляет уведомления при завершении урока (сотруднику и руководителю).
+
+**Сигнатура:**
+```php
+public static function sendPollCompleteNotification(int $adminId, int $lessonId): void
+```
+
+**Параметры:**
+- `$adminId` (int) - ID сотрудника
+- `$lessonId` (int) - ID урока
+
+**Логика:**
+1. Получает данные сотрудника (`Admin::findOne($adminId)`)
+2. Получает руководителя (`$admin->parent_admin_id`)
+3. Получает урок (`Lessons::findOne($lessonId)`)
+4. Отправляет **2 уведомления**:
+ - Сотруднику: "Вы завершили урок '{title}'"
+ - Руководителю: "Сотрудник {name} завершил урок '{title}'"
+
+**Пример:**
+```php
+// После успешного прохождения теста
+LessonPollService::sendPollCompleteNotification(42, 5);
+
+// Результат:
+// - Сотрудник 42 получит уведомление
+// - Руководитель сотрудника 42 получит уведомление
+```
+
+**Код:**
+```php
+public static function sendPollCompleteNotification(int $adminId, int $lessonId): void
+{
+ $admin = Admin::findOne($adminId);
+ $lesson = Lessons::findOne($lessonId);
+
+ if (!$admin || !$lesson) {
+ return;
+ }
+
+ // Уведомление сотруднику
+ $notification = new Notifications();
+ $notification->admin_id = $adminId;
+ $notification->text = "Вы завершили урок '{$lesson->title}'";
+ $notification->type = 'lesson_complete';
+ $notification->save();
+
+ // Уведомление руководителю
+ if ($admin->parent_admin_id) {
+ $parentNotification = new Notifications();
+ $parentNotification->admin_id = $admin->parent_admin_id;
+ $parentNotification->text = "Сотрудник {$admin->name} завершил урок '{$lesson->title}'";
+ $parentNotification->type = 'lesson_complete_subordinate';
+ $parentNotification->save();
+ }
+}
+```
+
+**Проблема:**
+❌ **NULL parent_admin_id** - если у сотрудника нет руководителя, код упадет (нужна проверка)
+
+---
+
+#### isLessonPollComplete()
+
+Проверяет, завершен ли урок успешно.
+
+**Сигнатура:**
+```php
+public static function isLessonPollComplete(int $adminId, int $lessonId): bool
+```
+
+**Параметры:**
+- `$adminId` (int) - ID сотрудника
+- `$lessonId` (int) - ID урока
+
+**Возвращает:**
+- `bool` - true, если статус = PASS_SUCCESS или PASS_CHECK
+
+**Пример:**
+```php
+if (LessonPollService::isLessonPollComplete(42, 5)) {
+ echo "Урок завершен!";
+} else {
+ echo "Урок не завершен";
+}
+```
+
+**Код:**
+```php
+public static function isLessonPollComplete(int $adminId, int $lessonId): bool
+{
+ $passed = LessonsPassed::findOne([
+ 'entity' => 'lesson',
+ 'entity_id' => $lessonId,
+ 'admin_id' => $adminId,
+ ]);
+
+ if (!$passed) {
+ return false;
+ }
+
+ return in_array($passed->status, [
+ LessonsPassed::STATUS_PASS_SUCCESS,
+ LessonsPassed::STATUS_PASS_CHECK,
+ ]);
+}
+```
+
+---
+
+#### isLessonPollCompleteObligatory()
+
+Проверяет, завершен ли урок В СРОК (обязательный срок группы).
+
+**Сигнатура:**
+```php
+public static function isLessonPollCompleteObligatory(int $adminId, int $groupId): bool
+```
+
+**Параметры:**
+- `$adminId` (int) - ID сотрудника
+- `$groupId` (int) - ID группы
+
+**Возвращает:**
+- `bool` - true, если все уроки завершены и срок не превышен
+
+**Алгоритм:**
+1. Получить все уроки группы
+2. Проверить статус каждого урока (завершен?)
+3. Получить дату назначения группы (`LessonsPassed.created`)
+4. Проверить, прошло ли больше `days_for_obligatory` дней
+
+**Пример:**
+```php
+// Группа назначена 10 дней назад, срок = 7 дней
+if (LessonPollService::isLessonPollCompleteObligatory(42, 3)) {
+ echo "Курс завершен вовремя!";
+} else {
+ echo "Просрочено или не завершено";
+}
+```
+
+**Код:**
+```php
+public static function isLessonPollCompleteObligatory(int $adminId, int $groupId): bool
+{
+ $group = LessonsGroup::findOne($groupId);
+ if (!$group) {
+ return false;
+ }
+
+ $groupPassed = LessonsPassed::findOne([
+ 'entity' => 'group',
+ 'entity_id' => $groupId,
+ 'admin_id' => $adminId,
+ ]);
+
+ if (!$groupPassed) {
+ return false;
+ }
+
+ // Проверить все уроки
+ $lessons = $group->lessons;
+ foreach ($lessons as $lesson) {
+ if (!self::isLessonPollComplete($adminId, $lesson->id)) {
+ return false; // Есть незавершенные уроки
+ }
+ }
+
+ // Проверить срок
+ $assignedDate = strtotime($groupPassed->created);
+ $deadline = strtotime("+{$group->days_for_obligatory} days", $assignedDate);
+ $now = time();
+
+ return $now <= $deadline;
+}
+```
+
+---
+
+#### isLessonPollCompleteRecommended()
+
+Проверяет, завершен ли урок в **рекомендуемый** срок (для бонусов).
+
+**Сигнатура:**
+```php
+public static function isLessonPollCompleteRecommended(int $adminId, int $groupId): bool
+```
+
+**Параметры:**
+- `$adminId` (int) - ID сотрудника
+- `$groupId` (int) - ID группы
+
+**Возвращает:**
+- `bool` - true, если завершено быстрее рекомендуемого срока
+
+**Логика:**
+Аналогична `isLessonPollCompleteObligatory()`, но использует `days_for_recommended`.
+
+**Пример:**
+```php
+// Рекомендуемый срок = 3 дня, обязательный = 7 дней
+if (LessonPollService::isLessonPollCompleteRecommended(42, 3)) {
+ echo "Отлично! Завершено быстро!";
+ // Начислить бонус
+}
+```
+
+---
+
+#### isLessonPollCompleteRecommendedWithoutGroup()
+
+Проверяет рекомендуемый срок для **одиночного урока** (без группы).
+
+**Сигнатура:**
+```php
+public static function isLessonPollCompleteRecommendedWithoutGroup(int $adminId, int $lessonId): bool
+```
+
+**Параметры:**
+- `$adminId` (int) - ID сотрудника
+- `$lessonId` (int) - ID урока
+
+**Возвращает:**
+- `bool` - true, если завершено в срок
+
+**Отличие от группового:**
+Использует `minutes_for_recommended` вместо `days_for_recommended`.
+
+**Пример:**
+```php
+// Урок назначен 20 минут назад, рекомендуемый срок = 30 минут
+if (LessonPollService::isLessonPollCompleteRecommendedWithoutGroup(42, 5)) {
+ echo "Успели в срок!";
+}
+```
+
+**Код:**
+```php
+public static function isLessonPollCompleteRecommendedWithoutGroup(int $adminId, int $lessonId): bool
+{
+ $lesson = Lessons::findOne($lessonId);
+ if (!$lesson || !$lesson->minutes_for_recommended) {
+ return false;
+ }
+
+ $passed = LessonsPassed::findOne([
+ 'entity' => 'lesson',
+ 'entity_id' => $lessonId,
+ 'admin_id' => $adminId,
+ ]);
+
+ if (!$passed || !self::isLessonPollComplete($adminId, $lessonId)) {
+ return false;
+ }
+
+ $assignedTime = strtotime($passed->created);
+ $deadline = strtotime("+{$lesson->minutes_for_recommended} minutes", $assignedTime);
+
+ return time() <= $deadline;
+}
+```
+
+---
+
+#### isLessonPollCompleteObligatoryWithoutGroup()
+
+Проверяет **обязательный** срок для одиночного урока.
+
+**Сигнатура:**
+```php
+public static function isLessonPollCompleteObligatoryWithoutGroup(int $adminId, int $lessonId): bool
+```
+
+**Параметры:**
+- `$adminId` (int) - ID сотрудника
+- `$lessonId` (int) - ID урока
+
+**Возвращает:**
+- `bool` - true, если завершено в обязательный срок
+
+**Использует:** `minutes_for_obligatory`
+
+**Пример:**
+```php
+// Обязательный срок = 60 минут
+if (!LessonPollService::isLessonPollCompleteObligatoryWithoutGroup(42, 5)) {
+ echo "Просрочено! Штраф!";
+}
+```
+
+---
+
+#### pollCompleteActions()
+
+Выполняет автоматические действия при завершении урока.
+
+**Сигнатура:**
+```php
+public static function pollCompleteActions(int $adminId, int $lessonId, bool $isSuccess): void
+```
+
+**Параметры:**
+- `$adminId` (int) - ID сотрудника
+- `$lessonId` (int) - ID урока
+- `$isSuccess` (bool) - успешно ли завершен
+
+**Логика:**
+1. Обновить статус `LessonsPassed`
+2. Начислить баллы (`success_ball` или `fail_ball`)
+3. Отправить уведомления
+4. Проверить, завершена ли группа (если урок в группе)
+5. Обновить статус группы
+
+**Пример:**
+```php
+// После прохождения теста
+$correctPercentage = 85; // 85% правильных ответов
+$requiredPercentage = 80; // Требуется 80%
+
+$isSuccess = $correctPercentage >= $requiredPercentage;
+
+LessonPollService::pollCompleteActions(
+ Yii::$app->user->id,
+ $lessonId,
+ $isSuccess
+);
+
+// Результат:
+// - Обновлен статус
+// - Начислены баллы
+// - Отправлены уведомления
+// - Если группа завершена, обновлен статус группы
+```
+
+**Код:**
+```php
+public static function pollCompleteActions(int $adminId, int $lessonId, bool $isSuccess): void
+{
+ $lesson = Lessons::findOne($lessonId);
+ if (!$lesson) {
+ return;
+ }
+
+ $passed = LessonsPassed::findOne([
+ 'entity' => 'lesson',
+ 'entity_id' => $lessonId,
+ 'admin_id' => $adminId,
+ ]);
+
+ if (!$passed) {
+ return;
+ }
+
+ // Обновить статус
+ $passed->status = $isSuccess
+ ? LessonsPassed::STATUS_PASS_SUCCESS
+ : LessonsPassed::STATUS_PASS_FAIL;
+ $passed->save(false);
+
+ // Начислить баллы
+ if ($isSuccess && $lesson->success_ball) {
+ // Начислить success_ball через Rating/Bonus модуль
+ } elseif (!$isSuccess && $lesson->fail_ball) {
+ // Начислить штраф fail_ball
+ }
+
+ // Отправить уведомления
+ if ($isSuccess) {
+ self::sendPollCompleteNotification($adminId, $lessonId);
+ }
+
+ // Проверить группу
+ if ($lesson->group_id) {
+ $group = LessonsGroup::findOne($lesson->group_id);
+ $allLessonsCompleted = true;
+
+ foreach ($group->lessons as $groupLesson) {
+ if (!self::isLessonPollComplete($adminId, $groupLesson->id)) {
+ $allLessonsCompleted = false;
+ break;
+ }
+ }
+
+ if ($allLessonsCompleted) {
+ $groupPassed = LessonsPassed::findOne([
+ 'entity' => 'group',
+ 'entity_id' => $group->id,
+ 'admin_id' => $adminId,
+ ]);
+
+ if ($groupPassed) {
+ $groupPassed->status = LessonsPassed::STATUS_PASS_SUCCESS;
+ $groupPassed->save(false);
+ }
+ }
+ }
+}
+```
+
+**Проблемы:**
+❌ **Нет транзакций** - если один из `save()` упадет, данные будут частично сохранены
+❌ **save(false)** - пропускает валидацию
+
+**Рекомендация:**
+```php
+$transaction = Yii::$app->db->beginTransaction();
+try {
+ // Все операции
+ $transaction->commit();
+} catch (\Exception $e) {
+ $transaction->rollBack();
+ throw $e;
+}
+```
+
+---
+
+## 📊 Сводная таблица методов
+
+| Сервис | Метод | Назначение | Сложность |
+|--------|-------|-----------|-----------|
+| **LessonService** | sortByPosition() | Сортировка с шагом 2 | ⭐ |
+| **LessonService** | movePosition() | Drag-and-drop | ⭐⭐ |
+| **LessonService** | setPositionAsInSort() | Переиндексация | ⭐ |
+| **LessonPollService** | sendPollCompleteNotification() | Отправка уведомлений | ⭐⭐ |
+| **LessonPollService** | isLessonPollComplete() | Проверка завершения | ⭐ |
+| **LessonPollService** | isLessonPollCompleteObligatory() | Проверка срока (группа) | ⭐⭐⭐ |
+| **LessonPollService** | isLessonPollCompleteRecommended() | Проверка бонус-срока (группа) | ⭐⭐⭐ |
+| **LessonPollService** | isLessonPollCompleteRecommendedWithoutGroup() | Проверка срока (одиночный) | ⭐⭐ |
+| **LessonPollService** | isLessonPollCompleteObligatoryWithoutGroup() | Проверка обязательного (одиночный) | ⭐⭐ |
+| **LessonPollService** | pollCompleteActions() | Автоматизация завершения | ⭐⭐⭐⭐ |
+
+---
+
+## 🔧 Рекомендации по улучшению
+
+### LessonService
+
+1. **Заменить bubble sort на usort()**
+```php
+public static function sortByPosition(array $lessons): array
+{
+ usort($lessons, function($a, $b) {
+ return $a->pos <=> $b->pos;
+ });
+
+ foreach ($lessons as $index => $lesson) {
+ $lesson->updateAttributes(['pos' => ($index + 1) * 2]);
+ }
+
+ return $lessons;
+}
+```
+
+2. **Использовать updateAttributes() вместо save(false)**
+```php
+$lesson->updateAttributes(['pos' => $newPos]);
+```
+
+### LessonPollService
+
+1. **Добавить проверку parent_admin_id**
+```php
+if ($admin->parent_admin_id) {
+ // Отправить уведомление
+}
+```
+
+2. **Обернуть pollCompleteActions() в транзакцию**
+```php
+$transaction = Yii::$app->db->beginTransaction();
+try {
+ // Все операции
+ $transaction->commit();
+} catch (\Exception $e) {
+ $transaction->rollBack();
+ Yii::error($e->getMessage());
+}
+```
+
+3. **Параметризировать admin_id**
+Вместо использования `Yii::$app->user->id` внутри сервисов, передавать его как параметр.
+
+---
+
+**Последнее обновление:** 2025-11-24
--- /dev/null
+# Бизнес-процессы модуля Lesson
+
+Подробное описание всех workflow и бизнес-процессов системы обучения.
+
+## 📋 Оглавление
+
+1. [Назначение обучения](#1-назначение-обучения)
+2. [Прохождение одиночного урока](#2-прохождение-одиночного-урока)
+3. [Прохождение группы (последовательный режим)](#3-прохождение-группы-последовательный-режим)
+4. [Прохождение группы (параллельный режим)](#4-прохождение-группы-параллельный-режим)
+5. [Проверка открытых вопросов](#5-проверка-открытых-вопросов)
+6. [Контроль сроков](#6-контроль-сроков)
+7. [Начисление баллов](#7-начисление-баллов)
+
+---
+
+## 1. Назначение обучения
+
+### Участники
+- **Администратор** - назначает обучение
+- **Система** - создает записи, отправляет уведомления
+- **Сотрудник** - получает уведомление
+
+### Диаграмма процесса
+
+```mermaid
+sequenceDiagram
+ participant Admin as Администратор
+ participant Edit2 as Edit2Action
+ participant DB as База данных
+ participant Notif as Notifications
+ participant Employee as Сотрудник
+
+ Admin->>Edit2: Выбрать сотрудников
+ Admin->>Edit2: Выбрать уроки/группы
+ Admin->>Edit2: Нажать "Назначить"
+
+ Edit2->>DB: Проверить существующие назначения
+
+ loop Для каждого сотрудника
+ loop Для каждого урока
+ Edit2->>DB: Создать LessonsPassed<br/>(entity='lesson', status=0)
+ end
+ loop Для каждой группы
+ Edit2->>DB: Создать LessonsPassed<br/>(entity='group', status=0)
+ Edit2->>DB: Создать LessonsPassed<br/>для каждого урока группы
+ end
+ end
+
+ Edit2->>Notif: Отправить уведомления
+ Notif->>Employee: "Вам назначено новое обучение"
+
+ Edit2->>Admin: Успешно назначено
+```
+
+### Пошаговый алгоритм
+
+**Шаг 1: Выбор сотрудников**
+- Администратор открывает `/lesson/edit2`
+- Выбирает сотрудников из списка (чекбоксы)
+- Можно выбрать по отделу, должности, фильтру
+
+**Шаг 2: Выбор обучения**
+- Выбирает одиночные уроки (чекбоксы)
+- Выбирает группы уроков (чекбоксы)
+
+**Шаг 3: Назначение**
+```php
+POST /lesson/edit2
+{
+ 'action': 'assign',
+ 'adminIds': [42, 43, 44],
+ 'lessonIds': [5, 6],
+ 'groupIds': [3]
+}
+```
+
+**Шаг 4: Создание записей**
+```php
+foreach ($adminIds as $adminId) {
+ // Одиночные уроки
+ foreach ($lessonIds as $lessonId) {
+ // Проверить, не назначен ли уже
+ $exists = LessonsPassed::find()
+ ->where([
+ 'entity' => 'lesson',
+ 'entity_id' => $lessonId,
+ 'admin_id' => $adminId
+ ])
+ ->exists();
+
+ if (!$exists) {
+ $lp = new LessonsPassed();
+ $lp->entity = 'lesson';
+ $lp->entity_id = $lessonId;
+ $lp->admin_id = $adminId;
+ $lp->status = LessonsPassed::STATUS_ATTACHED;
+ $lp->created = date('Y-m-d H:i:s');
+ $lp->save();
+ }
+ }
+
+ // Группы
+ foreach ($groupIds as $groupId) {
+ // Назначить группу
+ $groupPassed = new LessonsPassed();
+ $groupPassed->entity = 'group';
+ $groupPassed->entity_id = $groupId;
+ $groupPassed->admin_id = $adminId;
+ $groupPassed->status = LessonsPassed::STATUS_ATTACHED;
+ $groupPassed->save();
+
+ // Назначить все уроки группы
+ $group = LessonsGroup::findOne($groupId);
+ foreach ($group->lessons as $lesson) {
+ $lessonPassed = new LessonsPassed();
+ $lessonPassed->entity = 'lesson';
+ $lessonPassed->entity_id = $lesson->id;
+ $lessonPassed->admin_id = $adminId;
+ $lessonPassed->status = LessonsPassed::STATUS_ATTACHED;
+ $lessonPassed->save();
+ }
+ }
+}
+
+// Отправить уведомления
+Edit2Action::createAssignmentNotification($adminIds);
+```
+
+**Шаг 5: Отправка уведомлений**
+```php
+foreach ($adminIds as $adminId) {
+ $notification = new Notifications();
+ $notification->admin_id = $adminId;
+ $notification->text = "Вам назначено новое обучение";
+ $notification->type = 'lesson_assigned';
+ $notification->save();
+}
+```
+
+---
+
+## 2. Прохождение одиночного урока
+
+### Участники
+- **Сотрудник** - проходит урок
+- **Система** - проверяет ответы, обновляет статусы
+
+### Диаграмма процесса
+
+```mermaid
+stateDiagram-v2
+ [*] --> ATTACHED: Назначен администратором
+ ATTACHED --> READ: Открыл урок
+ READ --> Testing: Начал тест
+
+ Testing --> CheckAnswers: Ответил на все вопросы
+
+ CheckAnswers --> CheckOpenPolls: Проверка открытых вопросов
+ CheckOpenPolls --> HasOpenPolls: Есть открытые?
+
+ HasOpenPolls --> NEED_CHECK: Да
+ HasOpenPolls --> CheckScore: Нет
+
+ CheckScore --> CheckTime: Проверка баллов
+ CheckTime --> TimeOK: Время в норме?
+
+ TimeOK --> PASS_SUCCESS: Да + баллы >= min
+ TimeOK --> PASS_FAIL: Да + баллы < min
+ TimeOK --> PASS_FAIL: Нет (время вышло)
+
+ NEED_CHECK --> AdminCheck: Администратор проверяет
+ AdminCheck --> PASS_CHECK: Зачет
+ AdminCheck --> FAIL_CHECK: Незачет
+
+ PASS_FAIL --> READ: Повторная попытка
+ FAIL_CHECK --> READ: Повторная попытка
+
+ PASS_SUCCESS --> [*]: Завершено
+ PASS_CHECK --> [*]: Завершено
+```
+
+### Пошаговый алгоритм
+
+**Шаг 1: Открытие урока**
+```
+URL: /lesson/view-lesson?id=5
+Действие: ViewLessonAction
+Статус: ATTACHED → READ
+```
+
+**Шаг 2: Просмотр контента**
+- Сотрудник читает материал урока
+- Просматривает видео/презентации
+- Нажимает "Начать тест"
+
+**Шаг 3: Начало теста**
+```
+URL: /lesson/start-testing?lessonId=5
+Действие: StartTestingAction
+```
+
+Система:
+1. Получает все вопросы урока
+2. Перемешивает (если `shuffle=1`)
+3. Сохраняет порядок в сессии
+4. Запускает таймер
+5. Показывает первый вопрос
+
+```php
+$polls = LessonsPoll::find()
+ ->where(['lesson_id' => $lessonId])
+ ->orderBy(['pos' => SORT_ASC])
+ ->all();
+
+if ($lesson->shuffle == 1) {
+ shuffle($polls);
+}
+
+Yii::$app->session->set('lesson_' . $lessonId . '_polls', $polls);
+Yii::$app->session->set('lesson_' . $lessonId . '_start_time', time());
+```
+
+**Шаг 4: Прохождение теста**
+```
+AJAX POST: /lesson/proceed-testing
+```
+
+Для каждого вопроса:
+1. Сотрудник выбирает ответ
+2. AJAX запрос с `pollId` и `answerId`
+3. Система проверяет ответ
+4. Сохраняет результат в сессии
+5. Показывает следующий вопрос
+
+**Шаг 5: Завершение теста**
+
+После последнего вопроса:
+
+```php
+// Подсчитать результаты
+$totalCorrect = 0;
+$totalAnswered = 0;
+$hasOpenPolls = false;
+
+foreach ($polls as $poll) {
+ $answer = Yii::$app->session->get('lesson_' . $lessonId . '_answer_' . $poll->id);
+
+ if ($answer) {
+ $totalAnswered++;
+
+ if ($answer['isCorrect'] === true) {
+ $totalCorrect++;
+ } elseif ($answer['isCorrect'] === null) {
+ // Открытый вопрос
+ $hasOpenPolls = true;
+ }
+ }
+}
+
+$correctPercentage = round(($totalCorrect / $totalAnswered) * 100);
+
+// Проверить время
+$startTime = Yii::$app->session->get('lesson_' . $lessonId . '_start_time');
+$elapsedMinutes = (time() - $startTime) / 60;
+$timeExceeded = ($lesson->max_time_minutes && $elapsedMinutes > $lesson->max_time_minutes);
+
+// Определить статус
+if ($timeExceeded) {
+ $status = LessonsPassed::STATUS_PASS_FAIL;
+ $message = "Время вышло!";
+} elseif ($hasOpenPolls) {
+ $status = LessonsPassed::STATUS_NEED_CHECK;
+ $message = "Ожидает проверки администратора";
+} elseif ($correctPercentage >= $lesson->min_correct_percentage) {
+ $status = LessonsPassed::STATUS_PASS_SUCCESS;
+ $message = "Тест пройден! {$correctPercentage}% правильных ответов.";
+} else {
+ $status = LessonsPassed::STATUS_PASS_FAIL;
+ $message = "Тест не пройден. Требуется {$lesson->min_correct_percentage}%, получено {$correctPercentage}%";
+}
+
+// Обновить статус
+$passed->status = $status;
+$passed->save(false);
+
+// Начислить баллы
+if ($status == LessonsPassed::STATUS_PASS_SUCCESS) {
+ LessonPollService::pollCompleteActions(Yii::$app->user->id, $lessonId, true);
+}
+```
+
+**Шаг 6: Проверка группы**
+
+Если урок является частью группы:
+```php
+if ($lesson->group_id) {
+ $group = LessonsGroup::findOne($lesson->group_id);
+ $allLessonsCompleted = true;
+
+ foreach ($group->lessons as $groupLesson) {
+ if (!LessonPollService::isLessonPollComplete($adminId, $groupLesson->id)) {
+ $allLessonsCompleted = false;
+ break;
+ }
+ }
+
+ if ($allLessonsCompleted) {
+ $groupPassed = LessonsPassed::findOne([
+ 'entity' => 'group',
+ 'entity_id' => $group->id,
+ 'admin_id' => $adminId,
+ ]);
+
+ if ($groupPassed) {
+ $groupPassed->status = LessonsPassed::STATUS_PASS_SUCCESS;
+ $groupPassed->save(false);
+ }
+ }
+}
+```
+
+---
+
+## 3. Прохождение группы (последовательный режим)
+
+### Особенность
+Уроки проходятся **строго по порядку**. Следующий урок открывается только после успешного завершения предыдущего.
+
+### Диаграмма процесса
+
+```mermaid
+graph TD
+ Start[Группа назначена] --> G_ATTACHED[LessonsPassed group<br/>status=ATTACHED]
+ G_ATTACHED --> L1[Урок 1<br/>status=ATTACHED]
+
+ L1 --> L1_READ[Открыл урок 1]
+ L1_READ --> L1_TEST[Проходит тест 1]
+ L1_TEST --> L1_CHECK{Результат?}
+
+ L1_CHECK -->|Успех| L1_SUCCESS[Урок 1: SUCCESS]
+ L1_CHECK -->|Провал| L1_FAIL[Урок 1: FAIL]
+ L1_FAIL -->|Повтор| L1_READ
+
+ L1_SUCCESS --> L2_UNLOCK[Урок 2 разблокирован]
+ L2_UNLOCK --> L2[Урок 2<br/>доступен]
+
+ L2 --> L2_READ[Открыл урок 2]
+ L2_READ --> L2_TEST[Проходит тест 2]
+ L2_TEST --> L2_CHECK{Результат?}
+
+ L2_CHECK -->|Успех| L2_SUCCESS[Урок 2: SUCCESS]
+ L2_CHECK -->|Провал| L2_FAIL[Урок 2: FAIL]
+ L2_FAIL -->|Повтор| L2_READ
+
+ L2_SUCCESS --> L3_UNLOCK[Урок 3 разблокирован]
+ L3_UNLOCK --> L3[Урок 3<br/>доступен]
+
+ L3 --> L3_SUCCESS[Урок 3: SUCCESS]
+
+ L3_SUCCESS --> CHECK_GROUP{Все уроки<br/>завершены?}
+ CHECK_GROUP -->|Да| GROUP_SUCCESS[Группа: SUCCESS<br/>Начислены баллы]
+ CHECK_GROUP -->|Нет| Wait[Ожидание...]
+
+ GROUP_SUCCESS --> End[Завершено]
+```
+
+### Логика блокировки
+
+**ViewLessonGroupAction:**
+```php
+$group = LessonsGroup::findOne($groupId);
+$lessons = $group->lessons; // Отсортированы по pos
+$adminId = Yii::$app->user->id;
+
+foreach ($lessons as $index => $lesson) {
+ $lesson->isAvailable = false;
+
+ if ($group->chain == 1) {
+ // Последовательный режим
+ if ($index == 0) {
+ // Первый урок всегда доступен
+ $lesson->isAvailable = true;
+ } else {
+ // Проверить, завершен ли предыдущий урок
+ $prevLesson = $lessons[$index - 1];
+ if (LessonPollService::isLessonPollComplete($adminId, $prevLesson->id)) {
+ $lesson->isAvailable = true;
+ }
+ }
+ } else {
+ // Параллельный режим - все доступны
+ $lesson->isAvailable = true;
+ }
+}
+```
+
+### Пример прохождения
+
+**Сценарий: Группа "Адаптация новичка"**
+
+Уроки:
+1. Знакомство с компанией
+2. Правила работы
+3. Обучение CRM
+4. Техника безопасности
+
+**Время T0:**
+- Сотрудник назначен на группу
+- Доступен только Урок 1
+- Уроки 2-4 заблокированы (серые)
+
+**Время T1:**
+- Сотрудник завершил Урок 1 (SUCCESS)
+- Урок 2 разблокировался
+- Уроки 3-4 все еще заблокированы
+
+**Время T2:**
+- Сотрудник завершил Урок 2 (SUCCESS)
+- Урок 3 разблокировался
+
+**Время T3:**
+- Сотрудник завершил все уроки
+- Группа переходит в статус SUCCESS
+- Начисляются баллы группы (`success_ball`)
+
+---
+
+## 4. Прохождение группы (параллельный режим)
+
+### Особенность
+Все уроки доступны **одновременно**. Сотрудник может проходить их в любом порядке.
+
+### Диаграмма процесса
+
+```mermaid
+graph TD
+ Start[Группа назначена] --> G_ATTACHED[LessonsPassed group<br/>status=ATTACHED]
+
+ G_ATTACHED --> L1[Урок 1<br/>ДОСТУПЕН]
+ G_ATTACHED --> L2[Урок 2<br/>ДОСТУПЕН]
+ G_ATTACHED --> L3[Урок 3<br/>ДОСТУПЕН]
+
+ L1 --> L1_DONE[✓ Пройден]
+ L2 --> L2_DONE[✓ Пройден]
+ L3 --> L3_DONE[✓ Пройден]
+
+ L1_DONE --> CHECK{Все завершены?}
+ L2_DONE --> CHECK
+ L3_DONE --> CHECK
+
+ CHECK -->|Нет| Wait[Ожидание...]
+ Wait -.->|Следующий урок| L1
+ Wait -.->|Следующий урок| L2
+ Wait -.->|Следующий урок| L3
+
+ CHECK -->|Да| GROUP_SUCCESS[Группа: SUCCESS]
+ GROUP_SUCCESS --> End[Завершено]
+```
+
+### Использование
+
+**Подходит для:**
+- Ежегодное переобучение
+- Опциональные курсы повышения квалификации
+- Модульное обучение без зависимостей
+
+**Преимущества:**
+- Гибкость для сотрудника
+- Можно начать с самого интересного урока
+- Удобно при ограниченном времени
+
+**Недостатки:**
+- Нет контроля последовательности
+- Сотрудник может пропустить важную информацию
+
+---
+
+## 5. Проверка открытых вопросов
+
+### Участники
+- **Сотрудник** - отвечает на открытый вопрос
+- **Администратор** - проверяет ответ
+- **Система** - обновляет статус
+
+### Диаграмма процесса
+
+```mermaid
+sequenceDiagram
+ participant Employee as Сотрудник
+ participant Test as ProceedTestingAction
+ participant DB as База данных
+ participant Admin as Администратор
+ participant Check as CheckOpenPoll
+ participant Notif as Notifications
+
+ Employee->>Test: Ответить на открытый вопрос<br/>("Опишите процесс...")
+ Test->>DB: Сохранить ответ в сессии/БД
+ Test->>DB: Обновить статус на NEED_CHECK
+ Test->>Employee: "Ожидает проверки администратора"
+
+ Admin->>Check: Открыть /lesson/check-open-poll
+ Check->>DB: Получить ответы сотрудника
+ Check->>Admin: Показать форму проверки
+
+ Admin->>Check: Выставить оценки:<br/>Вопрос 1: Зачет<br/>Вопрос 2: Незачет
+ Check->>DB: Проверить все ли зачтены
+
+ alt Все вопросы зачтены
+ Check->>DB: Обновить статус на PASS_CHECK
+ Check->>Notif: Отправить уведомление<br/>"Ваши ответы зачтены"
+ Check->>DB: Начислить баллы (success_ball)
+ else Есть незачтенные
+ Check->>DB: Обновить статус на FAIL_CHECK
+ Check->>Notif: Отправить уведомление<br/>"Ответы не зачтены, попробуйте снова"
+ end
+
+ Notif->>Employee: Уведомление о результате
+```
+
+### Пошаговый алгоритм
+
+**Шаг 1: Сотрудник проходит тест**
+
+Закрытые вопросы:
+```php
+// Автоматическая проверка
+$isCorrect = ($userAnswerId == $correctAnswerId);
+```
+
+Открытые вопросы:
+```php
+// Отложенная проверка
+$answer = [
+ 'pollId' => 10,
+ 'isCorrect' => null, // Неизвестно
+ 'openAnswer' => "Процесс создания заказа включает...", // Текст ответа
+];
+
+Yii::$app->session->set('lesson_5_answer_10', $answer);
+```
+
+**Шаг 2: Завершение теста**
+
+Если есть открытые вопросы:
+```php
+$hasOpenPolls = true;
+$passed->status = LessonsPassed::STATUS_NEED_CHECK;
+$passed->save(false);
+
+return $this->asJson([
+ 'success' => true,
+ 'message' => "Ваши ответы отправлены на проверку администратору",
+]);
+```
+
+**Шаг 3: Администратор проверяет**
+
+URL: `/lesson/check-open-poll?passedId=123`
+
+Форма проверки:
+```php
+<form method="post">
+ <div class="open-poll-check">
+ <h3>Вопрос: <?= $poll->text ?></h3>
+ <p><strong>Ответ сотрудника:</strong></p>
+ <blockquote><?= $openAnswer['openAnswer'] ?></blockquote>
+
+ <label>
+ <input type="radio" name="openPollResults[<?= $poll->id ?>]" value="pass">
+ Зачет
+ </label>
+ <label>
+ <input type="radio" name="openPollResults[<?= $poll->id ?>]" value="fail">
+ Незачет
+ </label>
+
+ <textarea name="openPollComments[<?= $poll->id ?>]" placeholder="Комментарий (опционально)"></textarea>
+ </div>
+
+ <button type="submit">Сохранить оценки</button>
+</form>
+```
+
+**Шаг 4: Сохранение оценок**
+
+```php
+$results = Yii::$app->request->post('openPollResults', []);
+$comments = Yii::$app->request->post('openPollComments', []);
+$allPassed = true;
+
+foreach ($results as $pollId => $result) {
+ if ($result == 'fail') {
+ $allPassed = false;
+ break;
+ }
+}
+
+$passed->status = $allPassed
+ ? LessonsPassed::STATUS_PASS_CHECK
+ : LessonsPassed::STATUS_FAIL_CHECK;
+$passed->open_poll_admin_check_id = Yii::$app->user->id;
+$passed->save(false);
+
+// Отправить уведомление
+$notification = new Notifications();
+$notification->admin_id = $passed->admin_id;
+$notification->text = $allPassed
+ ? "Ваши ответы на открытые вопросы зачтены. Урок завершен!"
+ : "Ответы не зачтены. Пожалуйста, пройдите урок повторно.";
+$notification->save();
+
+// Начислить баллы при успехе
+if ($allPassed) {
+ LessonPollService::pollCompleteActions($passed->admin_id, $lesson->id, true);
+}
+```
+
+---
+
+## 6. Контроль сроков
+
+### Типы сроков
+
+1. **Обязательный срок (days_for_obligatory / minutes_for_obligatory)**
+ - Жесткий дедлайн
+ - При превышении: штрафы, блокировка, отчеты
+
+2. **Рекомендуемый срок (days_for_recommended / minutes_for_recommended)**
+ - Бонус за быстрое прохождение
+ - Не штрафуется при превышении
+
+### Диаграмма контроля сроков
+
+```mermaid
+graph TD
+ Start[Урок/Группа назначены] --> Record[Сохранить дату в<br/>LessonsPassed.created]
+
+ Record --> Wait[Сотрудник проходит...]
+
+ Wait --> Complete[Урок завершен]
+
+ Complete --> CheckTime{Проверка времени}
+
+ CheckTime --> Calc[Расчет:]
+ Calc --> CalcFormula["elapsed = now - created<br/>obligatory = days_for_obligatory * 24h<br/>recommended = days_for_recommended * 24h"]
+
+ CalcFormula --> Compare{elapsed vs deadlines}
+
+ Compare -->|elapsed <= recommended| Bonus[✅ Отлично!<br/>Начислить бонус]
+ Compare -->|recommended < elapsed <= obligatory| OK[✅ Вовремя<br/>Без бонуса]
+ Compare -->|elapsed > obligatory| Penalty[❌ Просрочено<br/>Штраф/блокировка]
+
+ Bonus --> End
+ OK --> End
+ Penalty --> End
+```
+
+### Алгоритм проверки сроков
+
+**Для групп:**
+```php
+public static function isLessonPollCompleteObligatory(int $adminId, int $groupId): bool
+{
+ $group = LessonsGroup::findOne($groupId);
+ if (!$group) {
+ return false;
+ }
+
+ $groupPassed = LessonsPassed::findOne([
+ 'entity' => 'group',
+ 'entity_id' => $groupId,
+ 'admin_id' => $adminId,
+ ]);
+
+ if (!$groupPassed) {
+ return false;
+ }
+
+ // Проверить, все ли уроки завершены
+ $lessons = $group->lessons;
+ foreach ($lessons as $lesson) {
+ if (!self::isLessonPollComplete($adminId, $lesson->id)) {
+ return false;
+ }
+ }
+
+ // Проверить срок
+ $assignedDate = strtotime($groupPassed->created);
+ $deadline = strtotime("+{$group->days_for_obligatory} days", $assignedDate);
+ $now = time();
+
+ return $now <= $deadline;
+}
+```
+
+**Для одиночных уроков:**
+```php
+public static function isLessonPollCompleteObligatoryWithoutGroup(int $adminId, int $lessonId): bool
+{
+ $lesson = Lessons::findOne($lessonId);
+ if (!$lesson || !$lesson->minutes_for_obligatory) {
+ return false;
+ }
+
+ $passed = LessonsPassed::findOne([
+ 'entity' => 'lesson',
+ 'entity_id' => $lessonId,
+ 'admin_id' => $adminId,
+ ]);
+
+ if (!$passed || !self::isLessonPollComplete($adminId, $lessonId)) {
+ return false;
+ }
+
+ $assignedTime = strtotime($passed->created);
+ $deadline = strtotime("+{$lesson->minutes_for_obligatory} minutes", $assignedTime);
+
+ return time() <= $deadline;
+}
+```
+
+### Примеры
+
+**Пример 1: Группа "Адаптация"**
+```
+Назначена: 2025-01-01 09:00
+Обязательный срок: 7 дней (до 2025-01-08 09:00)
+Рекомендуемый срок: 3 дня (до 2025-01-04 09:00)
+
+Сотрудник завершил: 2025-01-03 14:00
+Результат: ✅ Вовремя + бонус (в рекомендуемый срок)
+```
+
+**Пример 2: Урок "Техника безопасности"**
+```
+Назначен: 2025-01-01 10:00
+Обязательный срок: 60 минут (до 11:00)
+Рекомендуемый срок: 30 минут (до 10:30)
+
+Сотрудник завершил: 10:25
+Результат: ✅ Отлично! (в рекомендуемый срок)
+```
+
+**Пример 3: Просрочка**
+```
+Назначена группа: 2025-01-01
+Обязательный срок: 7 дней
+Рекомендуемый срок: 3 дня
+
+Сотрудник завершил: 2025-01-10 (через 9 дней)
+Результат: ❌ Просрочено (штраф)
+```
+
+---
+
+## 7. Начисление баллов
+
+### Типы баллов
+
+1. **success_ball** - за успешное прохождение
+2. **fail_ball** - штраф за провал
+3. **Бонус за рекомендуемый срок** - дополнительные баллы
+
+### Диаграмма начисления
+
+```mermaid
+graph TD
+ Start[Урок/Группа завершены] --> CheckStatus{Статус?}
+
+ CheckStatus -->|PASS_SUCCESS<br/>PASS_CHECK| Success[Успешно]
+ CheckStatus -->|PASS_FAIL<br/>FAIL_CHECK| Fail[Провалено]
+
+ Success --> CheckTime{Срок?}
+ CheckTime -->|В рекомендуемый срок| BonusTime[success_ball + 50%]
+ CheckTime -->|В обязательный срок| NormalSuccess[success_ball]
+ CheckTime -->|Просрочено| LateSuccess[success_ball / 2]
+
+ Fail --> ApplyPenalty[fail_ball штраф]
+
+ BonusTime --> RatingModule[Интеграция с Rating/Bonus]
+ NormalSuccess --> RatingModule
+ LateSuccess --> RatingModule
+ ApplyPenalty --> RatingModule
+
+ RatingModule --> UpdateBalance[Обновить баланс сотрудника]
+ UpdateBalance --> NotifyEmployee[Уведомление сотруднику]
+ NotifyEmployee --> End
+```
+
+### Алгоритм начисления
+
+**pollCompleteActions():**
+```php
+public static function pollCompleteActions(int $adminId, int $lessonId, bool $isSuccess): void
+{
+ $lesson = Lessons::findOne($lessonId);
+ if (!$lesson) {
+ return;
+ }
+
+ $passed = LessonsPassed::findOne([
+ 'entity' => 'lesson',
+ 'entity_id' => $lessonId,
+ 'admin_id' => $adminId,
+ ]);
+
+ if (!$passed) {
+ return;
+ }
+
+ // Определить сумму баллов
+ $ballsToAdd = 0;
+
+ if ($isSuccess) {
+ $ballsToAdd = $lesson->success_ball;
+
+ // Проверить бонус за срок
+ if (self::isLessonPollCompleteRecommendedWithoutGroup($adminId, $lessonId)) {
+ $ballsToAdd = round($ballsToAdd * 1.5); // +50% за быстроту
+ } elseif (!self::isLessonPollCompleteObligatoryWithoutGroup($adminId, $lessonId)) {
+ $ballsToAdd = round($ballsToAdd * 0.5); // -50% за просрочку
+ }
+ } else {
+ $ballsToAdd = -$lesson->fail_ball; // Штраф
+ }
+
+ // Начислить через Rating/Bonus модуль
+ if ($ballsToAdd != 0) {
+ // Интеграция с Rating модулем
+ // Rating::addPoints($adminId, $ballsToAdd, 'lesson', $lessonId);
+ }
+
+ // Отправить уведомление
+ $notification = new Notifications();
+ $notification->admin_id = $adminId;
+ $notification->text = $isSuccess
+ ? "Вы получили {$ballsToAdd} баллов за урок '{$lesson->title}'"
+ : "Штраф {$ballsToAdd} баллов за неуспешное прохождение";
+ $notification->save();
+
+ // Проверить завершение группы
+ if ($lesson->group_id) {
+ $group = LessonsGroup::findOne($lesson->group_id);
+ $allCompleted = true;
+
+ foreach ($group->lessons as $groupLesson) {
+ if (!self::isLessonPollComplete($adminId, $groupLesson->id)) {
+ $allCompleted = false;
+ break;
+ }
+ }
+
+ if ($allCompleted) {
+ $groupPassed = LessonsPassed::findOne([
+ 'entity' => 'group',
+ 'entity_id' => $group->id,
+ 'admin_id' => $adminId,
+ ]);
+
+ if ($groupPassed) {
+ $groupPassed->status = LessonsPassed::STATUS_PASS_SUCCESS;
+ $groupPassed->save(false);
+
+ // Начислить баллы группы
+ $groupBalls = $group->success_ball;
+ if (self::isLessonPollCompleteRecommended($adminId, $group->id)) {
+ $groupBalls = round($groupBalls * 1.5);
+ }
+
+ // Rating::addPoints($adminId, $groupBalls, 'group', $group->id);
+ }
+ }
+ }
+}
+```
+
+### Примеры начисления
+
+**Пример 1: Успешное прохождение вовремя**
+```
+Урок: success_ball = 100
+Результат: PASS_SUCCESS
+Срок: В рекомендуемый срок
+
+Начислено: 100 * 1.5 = 150 баллов
+```
+
+**Пример 2: Успешное прохождение с просрочкой**
+```
+Урок: success_ball = 100
+Результат: PASS_SUCCESS
+Срок: Просрочен обязательный срок
+
+Начислено: 100 * 0.5 = 50 баллов
+```
+
+**Пример 3: Провал**
+```
+Урок: fail_ball = 50
+Результат: PASS_FAIL
+
+Штраф: -50 баллов
+```
+
+**Пример 4: Группа**
+```
+Группа: 3 урока, success_ball = 500
+Все уроки завершены успешно
+Срок: В рекомендуемый срок
+
+Начислено за уроки: 100 + 100 + 100 = 300
+Начислено за группу: 500 * 1.5 = 750
+Итого: 1050 баллов
+```
+
+---
+
+**Последнее обновление:** 2025-11-24
## 📋 Описание
-**Write-offs** - модÑ\83лÑ\8c Ñ\83Ñ\87еÑ\82а Ñ\81пиÑ\81аниÑ\8f Ñ\82оваÑ\80ов и пÑ\80одÑ\83кÑ\86ии в магазинаÑ\85. СиÑ\81Ñ\82ема оÑ\82Ñ\81леживаеÑ\82 вÑ\81е Ñ\81пиÑ\81аниÑ\8f, пÑ\80иÑ\87инÑ\8b, оÑ\82веÑ\82Ñ\81Ñ\82веннÑ\8bÑ\85 и инÑ\82егÑ\80иÑ\80Ñ\83еÑ\82Ñ\81Ñ\8f Ñ\81 1С длÑ\8f Ñ\81инÑ\85Ñ\80онизаÑ\86ии даннÑ\8bÑ\85 о поÑ\80Ñ\87е, бÑ\80аке и дÑ\80Ñ\83гиÑ\85 Ñ\81пиÑ\81аниÑ\8fÑ\85.
+**Write-offs** - комплекÑ\81нÑ\8bй модÑ\83лÑ\8c Ñ\83Ñ\87еÑ\82а Ñ\81пиÑ\81аниÑ\8f Ñ\82оваÑ\80ов из магазинов в Ñ\81иÑ\81Ñ\82еме ERP24. СиÑ\81Ñ\82ема обеÑ\81пеÑ\87иваеÑ\82 полнÑ\8bй Ñ\86икл докÑ\83менÑ\82иÑ\80ованиÑ\8f Ñ\81пиÑ\81аний: оÑ\82 Ñ\81озданиÑ\8f докÑ\83менÑ\82а Ñ\81 пÑ\80иÑ\87инами и Ñ\84оÑ\82о до инÑ\82егÑ\80аÑ\86ии Ñ\81 1С, а Ñ\82акже вклÑ\8eÑ\87аеÑ\82 планиÑ\80ование Ñ\81пиÑ\81аний и авÑ\82омаÑ\82иÑ\87еÑ\81кое Ñ\84оÑ\80миÑ\80ование накладнÑ\8bÑ\85 по недоÑ\81Ñ\82аÑ\87ам.
### Основные возможности
-- 📊 Учет списаний по магазинам
-- 🏷️ Классификация по причинам (брак, порча, пересорт и др.)
-- 📅 Временной анализ списаний
-- 💰 Учет сумм списаний
-- 📈 Метрики и аналитика
-- 🔄 Синхронизация с 1С
-- 💬 Комментарии к списаниям
+- 📄 Создание и управление документами списания
+- 📊 Учет списаний по магазинам и причинам
+- 🔄 Полная интеграция с 1С (двусторонняя синхронизация)
+- 📸 Прикрепление фото и видео к каждой позиции списания
+- ✅ Workflow с подтверждением и статусами
+- 💰 Расчет сумм по закупочным и розничным ценам
+- 📦 Автоматическое создание накладных по недостачам
+- 📈 Планирование списаний и продаж
+- 🗂️ Иерархический справочник причин списания
+- 🔍 Проверка остатков товаров при создании
-## 🏗️ Архитектура
+## 🏗️ Архитектура модуля
-**Контроллеры:** 1 (WriteOffsController)
-- `index` - список списаний
-- `comments` - комментарии к списаниям
+```mermaid
+graph TB
+ subgraph "Controllers"
+ WOC[WriteOffsController<br/>2 actions]
+ WOEC[WriteOffsErpController<br/>12 methods]
+ WWOC[WaybillWriteOffsController<br/>2 actions]
+ WOCDC[WriteOffsErpCauseDictController<br/>CRUD]
+ SWOPC[SalesWriteOffsPlanController<br/>2 actions]
+ end
-**Модели (9):**
-- `WriteOffs` - основная таблица списаний
-- `WriteOffsErp` - списания из ERP
-- `WriteOffsProducts` - товары в списании
-- `WriteOffsProductsErp` - товары из ERP
-- `WriteOffsErpCauseDict` - справочник причин
-- `WriteOffsMetrics` - метрики списаний
+ subgraph "Services"
+ WOS[WriteOffsService<br/>Заглушка]
+ end
-## 💼 Основные сущности
+ subgraph "Actions"
+ IA[IndexAction]
+ CA[CommentsAction]
+ end
-**Причины списаний:**
-- Брак
-- Порча/Увядание
-- Пересорт
-- Недостача
-- Бой/Повреждение
-- Истечение срока годности
+ subgraph "Forms"
+ WOF[WriteOffsForm]
+ WOPF[WriteOffsProductsForm<br/>с валидацией остатков]
+ end
+
+ subgraph "Models/Records"
+ WO[WriteOffs<br/>Списания 1C]
+ WOE[WriteOffsErp<br/>ERP документы]
+ WOP[WriteOffsProducts]
+ WOPE[WriteOffsProductsErp]
+ WOECD[WriteOffsErpCauseDict]
+ WWO[WaybillWriteOffs]
+ WWOP[WaybillWriteOffsProducts]
+ SWOP[SalesWriteOffsPlan]
+ end
+
+ subgraph "External Systems"
+ 1C[1C System]
+ BAL[Balances<br/>Остатки товаров]
+ PRICES[PricesDynamic<br/>Цены]
+ FILES[Files & Images]
+ end
+
+ WOC --> IA
+ WOC --> CA
+ WOEC --> WOF
+ WOEC --> WOPF
+
+ WOEC --> WOE
+ WOEC --> WOPE
+ WOEC --> WOECD
+
+ WWOC --> WWO
+ WWOC --> WWOP
+
+ SWOPC --> SWOP
+
+ WOE --> 1C
+ WOPF --> BAL
+ WOE --> PRICES
+ WOPE --> FILES
+
+ style WOEC fill:#e1f5ff
+ style WOE fill:#fff4e1
+ style WOPE fill:#fff4e1
+ style 1C fill:#ffe1e1
+```
+
+## 🎮 Контроллеры
+
+### 1. WriteOffsController
+
+**Файл:** `erp24/controllers/WriteOffsController.php`
+
+**Назначение:** Минималистичный контроллер, делегирующий логику внешним Action-классам.
+
+**Действия:**
+- `index` → `IndexAction` - Главная страница модуля
+- `comments` → `CommentsAction` - Просмотр комментариев к списаниям
+
+```php
+namespace app\controllers;
+
+class WriteOffsController extends Controller
+{
+ public function actions(): array
+ {
+ return [
+ 'index' => \yii_app\actions\writeOffs\IndexAction::class,
+ 'comments' => \yii_app\actions\writeOffs\CommentsAction::class,
+ ];
+ }
+}
+```
+
+---
+
+### 2. WriteOffsErpController ⭐
+
+**Файл:** `erp24/controllers/WriteOffsErpController.php`
+
+**Назначение:** Основной контроллер модуля - полнофункциональный CRUD для управления документами списания в ERP-системе.
+
+#### Публичные методы
+
+```php
+public function actionIndex(): string
+```
+**Описание:** Список всех документов списания с фильтрацией
+**Основная логика:**
+- Получение разрешенных магазинов через `TimetableService::getAllowedStoreId()`
+- Использование `WriteOffsErpSearch` для фильтрации
+- Проверка прав на создание списаний
+**Возвращает:** Рендер `write_offs_erp/index`
+
+---
+
+```php
+public function actionView(int $id): string
+```
+**Описание:** Просмотр деталей документа списания
+**Параметры:**
+- `$id` - ID документа
+**Основная логика:**
+- Проверка прав на подтверждение (группы 1, 7, 10)
+- Загрузка связанных товаров с изображениями
+- Проверка возможности повторной отправки (timeout 5 минут)
+- Загрузка справочника причин списания
+**Возвращает:** Рендер `write_offs_erp/view`
+
+---
+
+```php
+public function actionCreate(): string|array|\yii\web\Response
+```
+**Описание:** Создание нового документа списания
+**Основная логика:**
+1. Проверка прав пользователя
+2. Загрузка справочников:
+ - Продукты с ценами из `Products1c`
+ - Остатки по магазинам из `balances`
+ - Причины списания из `WriteOffsErpCauseDict`
+3. AJAX-валидация формы
+4. Обработка данных в транзакции:
+ - Создание документа с GUID
+ - Установка статуса "Создан"
+ - Генерация номера документа
+ - Расчет цен через `WriteOffs::getPriceDynamic()`
+ - Сохранение товарных позиций
+ - Загрузка изображений и видео
+5. Редирект на просмотр созданного документа
+
+**Пример:**
+```php
+// Создание документа
+$model = new WriteOffsErp();
+$model->setGuidCreated();
+$model->setStoreGuidCreated();
+$model->setNumberCreated();
+$model->status = WriteOffsErp::STATUS_CREATED;
+$model->created_admin_id = Yii::$app->user->id;
+
+// Расчет цен для товаров
+foreach ($products as $product) {
+ $product->price_retail = WriteOffs::getPriceDynamic(
+ $product->product_id,
+ $model->date
+ );
+ $product->summ_retail = $product->price_retail * $product->quantity;
+}
+```
+
+---
+
+```php
+public function actionUpdate(int $id): string|\yii\web\Response
+```
+**Описание:** Редактирование существующего документа
+**Параметры:**
+- `$id` - ID документа
+**Особенности:**
+- **Поддержка переноса позиций:** Параметр `do_transfer` позволяет перенести выбранные строки в новый документ
+- Автоматический пересчет итогов через `WriteOffsErp::recalcTotals()`
+- Обновление связей изображений при переносе
+
+**Пример переноса:**
+```php
+// Создание нового документа на основе текущего
+$newDoc = WriteOffsErp::newFromBase($model, $adminId);
+$newDoc->save();
+
+// Перенос выбранных позиций
+WriteOffsProductsErp::updateAll(
+ ['write_offs_erp_id' => $newDoc->id],
+ ['id' => $transferIds]
+);
+
+// Пересчет итогов для обоих документов
+WriteOffsErp::recalcTotals($model);
+WriteOffsErp::recalcTotals($newDoc);
+```
+
+---
+
+```php
+public function actionConfirmWriteOff(): string
+```
+**Описание:** AJAX-подтверждение документа списания (смена статуса на "Подтверждено")
+**Основная логика:**
+- Проверка прав (не менеджер на тестовых магазинах)
+- Валидация всех товарных позиций через `WriteOffsProductsForm`
+- Проверка достаточности остатков товаров
+- Изменение статуса на `STATUS_CONFIRM`
+- Установка `confirm_at` и `confirm_admin_id`
+**Возвращает:** JSON-ответ "Документ согласован" или ошибку
+
+---
+
+```php
+public function actionReSendWriteOff(): string
+```
+**Описание:** AJAX-повторная отправка документа в 1C (для документов с ошибками)
+**Применимо к:** Статусы `STATUS_SEND`, `STATUS_ERROR_1C`
+**Основная логика:**
+- Валидация позиций документа
+- Сброс статуса на `STATUS_CONFIRM`
+- Очистка `send_at`
+**Возвращает:** JSON-ответ
+
+---
+
+```php
+public function actionDelete(int $id): \yii\web\Response
+```
+**Описание:** Мягкое удаление документа
+**Основная логика:**
+- Удаление всех товарных позиций: `WriteOffsProductsErp::deleteByParentId()`
+- Установка `active=0`, `deleted_at`, `deleted_admin_id`
+**Возвращает:** Редирект на список документов
+
+---
+
+```php
+public static function getWriteOffsDoc(): array
+```
+**Описание:** Формирование документов списания для отправки в 1C
+**Возвращает:**
+```php
+[
+ 'writeOff' => [
+ [
+ 'id' => 'guid',
+ 'store_id' => 'store_guid',
+ 'type' => 'Брак',
+ 'items' => [
+ ['product_id' => '...', 'quantity' => 5, 'price' => 100],
+ ...
+ ],
+ 'summ' => 500.00,
+ 'comment' => '...'
+ ],
+ ...
+ ],
+ 'writeOffIds' => [1, 2, 3],
+ 'writeOffIdsString' => '1,2,3'
+]
+```
+
+---
+
+### 3. WaybillWriteOffsController
+
+**Файл:** `erp24/controllers/WaybillWriteOffsController.php`
+
+**Назначение:** Управление накладными списаниями, которые создаются автоматически при передаче смены с недостачами.
+
+**Действия:**
+
+```php
+public function actionIndex(): string
+```
+**Описание:** Список накладных списаний с пагинацией (20 записей на страницу)
+
+```php
+public function actionView(int $id): string
+```
+**Описание:** Просмотр накладной списания с товарными позициями
+
+---
+
+### 4. WriteOffsErpCauseDictController
+
+**Файл:** `erp24/controllers/WriteOffsErpCauseDictController.php`
+
+**Назначение:** CRUD для справочника причин списания.
+
+**Стандартные CRUD действия:**
+- `index` - Список причин с поиском
+- `view` - Просмотр причины
+- `create` - Создание новой причины
+- `update` - Редактирование причины
+- `delete` - Удаление причины
+
+---
+
+### 5. SalesWriteOffsPlanController
+
+**Файл:** `erp24/controllers/SalesWriteOffsPlanController.php`
+
+**Назначение:** Управление планами продаж и списаний по магазинам.
+
+**Действия:**
+
+```php
+public function actionIndex(): string
+```
+**Описание:** Просмотр и редактирование планов с фильтрацией
+**Фильтры:**
+- Год, месяц
+- Город, регион, район
+- Тип магазина
+- Территориальный менеджер, КШФ
+**Данные:**
+- Планы из `SalesWriteOffsPlan`
+- Факт продаж за предпредыдущий месяц из `Sales`
+**Ограничения редактирования:**
+- Можно редактировать только будущие месяцы
+- Либо текущий месяц до 25 числа
+
+```php
+public function actionSaveFields(): string
+```
+**Описание:** AJAX-сохранение полей плана
+**Параметры (POST):**
+- `year`, `month`, `store_id`
+- `total_sales_plan` - План общих продаж
+- `write_offs_plan` - План списаний
+- `offline_sales_plan` - План офлайн продаж
+- `online_sales_shop_plan` - План online магазина
+- `online_sales_marketplace_plan` - План маркетплейса
+
+**Пример:**
+```php
+// AJAX-запрос на сохранение
+$.post('/sales-write-offs-plan/save-fields', {
+ year: 2025,
+ month: 3,
+ store_id: 15,
+ total_sales_plan: 500000,
+ write_offs_plan: 10000
+});
+```
+
+---
+
+## ⚙️ Сервисы
+
+### WriteOffsService
+
+**Файл:** `erp24/services/WriteOffsService.php`
+
+**Статус:** Сервис-заглушка (не реализован)
+
+```php
+namespace yii_app\services;
+
+class WriteOffsService
+{
+ public static function setRetailPrice($dateFrom, $dateTo, $storeIds): bool
+ {
+ return true;
+ }
+}
+```
+
+**Примечание:** Вся основная логика вынесена в контроллеры и модели.
+
+---
+
+## 📦 Models/Records
+
+### 1. WriteOffs
+
+**Файл:** `erp24/records/WriteOffs.php`
+
+**Таблица:** `write_offs`
+
+**Назначение:** Хранение списаний, полученных из 1С (legacy таблица).
+
+#### Свойства
+
+```php
+@property string $id // GUID документа
+@property integer $status_id // Статус
+@property string $store_id // GUID магазина
+@property string $number // Номер документа
+@property string $date // Дата списания
+@property string $based_on // Основание
+@property string $type // Тип списания
+@property string $cause // Причина
+@property string $comment // Комментарий
+@property string $items // JSON с товарами
+@property float $summ // Сумма закупочная
+@property float $summ_retail // Сумма розничная
+@property integer|null $type_id // ID типа списания
+@property string|null $type_guid // GUID типа
+```
+
+#### Ключевые методы
+
+```php
+public static function getWriteOffByStore(
+ $dateFrom,
+ $dateTo,
+ $storeId,
+ $type = 'Брак'
+): array
+```
+**Описание:** Получение списаний по магазину за период
+**Примечание:** До 2022-12-01 используется поле `summ`, после - `summ_retail`
+
+**Пример:**
+```php
+$writeOffs = WriteOffs::getWriteOffByStore(
+ '2025-01-01',
+ '2025-01-31',
+ 15,
+ 'Брак'
+);
+// Вернет сумму списаний по магазину 15 за январь 2025
+```
+
+---
+
+```php
+public static function getPriceDynamic(
+ $productId,
+ $writeOffDate,
+ $regionId = 52
+): float|null
+```
+**Описание:** Получение динамической розничной цены товара на дату списания
+**Параметры:**
+- `$productId` - GUID товара
+- `$writeOffDate` - Дата списания
+- `$regionId` - ID региона (по умолчанию 52 - Нижний Новгород)
+**Источник:** Таблица `prices_dynamic`
+
+**Пример:**
+```php
+$price = WriteOffs::getPriceDynamic(
+ 'product-guid-123',
+ '2025-03-15'
+);
+// Вернет розничную цену товара на 15 марта 2025
+```
+
+---
+
+### 2. WriteOffsErp ⭐
+
+**Файл:** `erp24/records/WriteOffsErp.php`
+
+**Таблица:** `write_offs_erp`
+
+**Назначение:** Основная модель для документов списания, созданных в ERP-системе.
+
+#### Свойства
+
+```php
+@property int $id
+@property string $guid // GUID для 1c
+@property int $status // Статус документа
+@property int $active // Активность (0=удален)
+@property int $created_admin_id // Создал
+@property int|null $updated_admin_id // Изменил
+@property int|null $confirm_admin_id // Подтвердил
+@property int|null $deleted_admin_id // Удалил
+@property int $store_id // ID магазина в ERP
+@property int $cause_id // ID причины списания
+@property int $cause_group_id // ID группы причин
+@property string $store_guid // GUID магазина 1С
+@property string $number // Номер документа
+@property string|null $number_1c // Номер в 1С (после создания)
+@property string $date // Дата документа
+@property string|null $based_on // Основание
+@property string $write_offs_type // Тип списания
+@property string|null $comment // Комментарий
+@property string|null $error_text // Текст ошибки от 1С
+@property float|null $summ // Сумма закупочная
+@property float|null $summ_retail // Сумма розничная
+@property float $quantity // Общее количество товаров
+@property string $created_at // Дата создания
+@property string|null $updated_at // Дата изменения
+@property string|null $deleted_at // Дата удаления
+@property string|null $confirm_at // Дата подтверждения
+@property string|null $send_at // Дата отправки в 1С
+```
+
+#### Константы статусов
+
+```php
+const STATUS_CREATED = 1; // Создан
+const STATUS_CONFIRM = 2; // Одобрен (готов к отправке)
+const STATUS_SEND = 3; // Отправлен в 1С
+const STATUS_CREATED_1C = 4; // Создан в 1С (финальный статус)
+const STATUS_ERROR_1C = 8; // Ошибка при создании в 1С
+const STATUS_DISABLE = 5; // Отклонен
+```
+
+#### Константы типов списания
+
+```php
+const WRITE_OFFS_TYPE_BRAK = "Брак";
+const WRITE_OFFS_TYPE_RETURN_KALUGA = "Возврат нереализованного товара (Калуга)";
+const WRITE_OFFS_TYPE_DELIVERY_BRAK = 'Брак с поставки';
+const WRITE_OFFS_TYPE_DUE_TO_EQUIPMENT_FAILURE_BRAK = 'Брак из-за поломки оборудования';
+const WRITE_OFFS_TYPE_RESORTING_DOES_NOT_COUNT_TOWARDS_COST = 'Пересорт, не идет в затраты';
+```
+
+#### Relations
+
+```php
+public function getWriteOffsProductsErps()
+```
+**Возвращает:** Связь `hasMany(WriteOffsProductsErp)` с условием `active_product=1`
+
+```php
+public function getCityStore()
+```
+**Возвращает:** Связь `hasOne(CityStore)` - информация о магазине
+
+```php
+public function getImagesWriteOffsErp()
+```
+**Возвращает:** Все изображения документа через товарные позиции
+
+```php
+public function getCreatedAdmin()
+```
+**Возвращает:** Связь `hasOne(Admin)` - автор документа
+
+```php
+public function getConfirmAdmin()
+```
+**Возвращает:** Связь `hasOne(Admin)` - кто подтвердил документ
+
+#### Ключевые методы
+
+```php
+public function setGuidCreated(): object
+```
+**Описание:** Генерация уникального GUID для документа через `DataHelper::createGuidMy()`
+
+---
+
+```php
+public function setNumberCreated(): object
+```
+**Описание:** Генерация номера документа
+**Формат:** `ЕРП_{Y-m-d_H-i}_{sequence}`
+**Пример:** `ЕРП_2025-03-15_14-30_001`
+
+---
+
+```php
+public function setStoreGuidCreated(): object
+```
+**Описание:** Получение GUID магазина из 1С
+
+---
+
+```php
+public static function getStatusDict(): array
+```
+**Возвращает:** Массив статусов для UI
+```php
+[
+ 1 => 'Создан',
+ 2 => 'Одобрен',
+ 3 => 'Отправлен',
+ 4 => 'Создан в 1С',
+ 5 => 'Отклонен',
+ 8 => 'Ошибка 1С'
+]
+```
+
+---
+
+```php
+public function getAttachments(): array
+```
+**Описание:** Получение всех вложений документа (изображения + видео)
+**Возвращает:**
+```php
+[
+ [
+ 'type' => 'image'|'video',
+ 'product_item_id' => 15,
+ 'url' => '/uploads/images/file.jpg',
+ 'thumb' => '/uploads/images/thumb_file.jpg',
+ 'name' => 'file.jpg',
+ 'mime' => 'image/jpeg'
+ ],
+ ...
+]
+```
+
+**Пример использования:**
+```php
+$attachments = $model->getAttachments();
+foreach ($attachments as $file) {
+ if ($file['type'] === 'image') {
+ echo "<img src='{$file['thumb']}' />";
+ } elseif ($file['type'] === 'video') {
+ echo "<video src='{$file['url']}' controls></video>";
+ }
+}
+```
+
+---
+
+```php
+public static function newFromBase(WriteOffsErp $base, int $adminId): WriteOffsErp
+```
+**Описание:** Создание нового документа на основе существующего (для переноса позиций)
+**Копируемые атрибуты:**
+- `status`, `store_id`, `write_offs_type`, `comment`, `cause_group_id`, `cause_id`
+**Инициализация:**
+- Новый GUID, номер, даты
+- Нулевые суммы и количество
+
+---
+
+```php
+public static function recalcTotals(WriteOffsErp $doc): void
+```
+**Описание:** Пересчет итогов документа по активным строкам
+**Обновляет:**
+- `summ` - сумма закупочная
+- `summ_retail` - сумма розничная
+- `quantity` - общее количество товаров
+- `updated_at`, `updated_admin_id`
+
+**Пример:**
+```php
+// После изменения товарных позиций
+WriteOffsErp::recalcTotals($document);
+// Итоги документа будут пересчитаны автоматически
+```
+
+---
+
+### 3. WriteOffsProductsErp
+
+**Файл:** `erp24/records/WriteOffsProductsErp.php`
+
+**Таблица:** `write_offs_products_erp`
+
+**Назначение:** Хранение товарных позиций документов списания.
+
+#### Свойства
+
+```php
+@property int $id
+@property int $write_offs_erp_id // ID документа списания
+@property string $date // Дата списания
+@property string $product_id // GUID товара
+@property string|null $comment // Комментарий к позиции
+@property string|null $color // Цвет товара
+@property string $name // Название товара
+@property float $quantity // Количество
+@property int $cause_id // ID причины списания
+@property int $num_row // Номер строки
+@property int $active_product // Активность (0=удален)
+@property float $price // Цена закупочная
+@property float $summ // Сумма закупочная
+@property float|null $price_retail // Розничная цена
+@property float|null $summ_retail // Сумма розничная
+@property string $created_at
+@property string|null $updated_at
+@property string|null $deleted_at
+@property int $created_admin_id
+@property int|null $updated_admin_id
+@property int|null $deleted_admin_id
+```
+
+#### Relations
+
+```php
+public function getVideo()
+```
+**Возвращает:** Связь `hasOne(Files)` с видео-файлом позиции
+
+```php
+public function getWriteOffsErp()
+```
+**Возвращает:** Связь `hasOne(WriteOffsErp)` - родительский документ
+
+```php
+public function getImagesWriteOffsErp()
+```
+**Возвращает:** Связь `hasMany(ImageDocumentLink)` - изображения позиции
+
+#### Ключевые методы
+
+```php
+public static function getCauseList(): array
+```
+**Описание:** Получение плоского списка причин списания
+**Возвращает:** `[id => name]`
+
+**Пример:**
+```php
+[
+ 1 => 'Повреждение упаковки',
+ 2 => 'Истек срок годности',
+ 3 => 'Увядание',
+ ...
+]
+```
+
+---
+
+```php
+public static function getCauseDict($userGroupId = null): array
+```
+**Описание:** Получение иерархического справочника причин (группа → причины)
+**Возвращает:**
+```php
+[
+ 'Брак' => [
+ 1 => 'Повреждение упаковки',
+ 2 => 'Дефект товара'
+ ],
+ 'Утилизация' => [
+ 3 => 'Истек срок годности',
+ 4 => 'Увядание'
+ ],
+ ...
+]
+```
+
+**Пример использования в форме:**
+```php
+<?= Html::activeDropDownList(
+ $model,
+ 'cause_id',
+ WriteOffsProductsErp::getCauseDict()
+) ?>
+```
+
+---
+
+```php
+public static function deleteByIDs($deletedIDs, $adminId): ?int
+```
+**Описание:** Мягкое удаление товаров по массиву ID
+**Логика:**
+- Установка `active_product=0`
+- Заполнение `deleted_admin_id`, `deleted_at`
+
+---
+
+```php
+public static function deleteByParentId(int $parentId, int $adminId): ?int
+```
+**Описание:** Мягкое удаление всех товаров документа
+
+---
+
+### 4. WriteOffsErpCauseDict
+
+**Файл:** `erp24/records/WriteOffsErpCauseDict.php`
+
+**Таблица:** `write_offs_erp_cause_dict`
+
+**Назначение:** Иерархический справочник причин списания (двухуровневая структура).
+
+#### Свойства
+
+```php
+@property int $id
+@property string $name // Название причины или группы
+@property int|null $parent_id // ID родителя (NULL для групп)
+@property int $status // Статус (1=активна, 0=неактивна)
+@property int|null $access_group_id // ID группы доступа
+```
+
+#### Структура данных
+
+```
+Группы (parent_id IS NULL):
+├─ Брак
+│ ├─ Повреждение упаковки (parent_id=1)
+│ ├─ Дефект товара (parent_id=1)
+│ └─ Брак с поставки (parent_id=1)
+├─ Утилизация
+│ ├─ Истек срок годности (parent_id=2)
+│ ├─ Увядание (parent_id=2)
+│ └─ Порча (parent_id=2)
+└─ Прочее
+ └─ Другая причина (parent_id=3)
+```
+
+---
+
+### 5. WaybillWriteOffs
+
+**Файл:** `erp24/records/WaybillWriteOffs.php`
+
+**Таблица:** `waybill_write_offs`
+
+**Назначение:** Накладные списаниями (расходные накладные смены), создаются автоматически при передаче смены с недостачами.
+
+#### Свойства
+
+```php
+@property int $id
+@property string $guid // GUID для 1c
+@property int|null $shift_transfer_id // ID передачи смены
+@property int $status // Статус
+@property int $created_admin_id
+@property int|null $updated_admin_id
+@property int $store_id // ID магазина
+@property string $store_guid // GUID магазина
+@property string $number // Номер накладной
+@property string|null $number_1c // Номер в 1С
+@property string|null $name_1c // Название в 1С
+@property string $date // Дата
+@property string|null $comment
+@property float $quantity // Общее количество товаров
+@property float $summ // Сумма розничная
+@property float|null $summ_self_cost // Сумма себестоимости
+@property string $created_at
+@property string|null $updated_at
+@property string|null $send_at
+@property string|null $error_text
+```
+
+#### Relations
+
+```php
+public function getShiftTransfer()
+```
+**Возвращает:** Связь `hasOne(ShiftTransfer)` - передача смены
+
+```php
+public function getWaybillWriteOffsProducts()
+```
+**Возвращает:** Связь `hasMany(WaybillWriteOffsProducts)` - товары накладной
+
+#### Ключевой метод
+
+```php
+public static function setData($shiftTransfer): void
+```
+**Описание:** Создание накладной списания на основе передачи смены с недостачами
+**Алгоритм:**
+1. Создание нового документа `WaybillWriteOffs`
+2. Генерация номера: `ЕРП_РНС_{Y-m-d_H-i}_{id}`
+3. Вызов `WaybillWriteOffsProducts::setData()` для заполнения товаров
+4. Пересчет итогов (quantity, summ, summ_self_cost)
+5. Если товаров нет (все недостачи выравнены), удаление документа
+
+**Пример:**
+```php
+// При закрытии передачи смены
+$shiftTransfer = ShiftTransfer::findOne($id);
+WaybillWriteOffs::setData($shiftTransfer);
+// Автоматически создана накладная по всем недостачам
+```
+
+---
+
+### 6. WaybillWriteOffsProducts
+
+**Файл:** `erp24/records/WaybillWriteOffsProducts.php`
+
+**Таблица:** `waybill_write_offs_products`
+
+**Назначение:** Товарные позиции накладных списаний.
+
+#### Свойства
+
+```php
+@property int $id
+@property int $waybill_write_offs_id // ID накладной
+@property string $name // Название товара
+@property string|null $product_id // ID товара (GUID)
+@property float|null $product_count // Количество
+@property float|null $product_price // Розничная цена
+@property float|null $product_self_cost // Себестоимость
+@property float $summ // Сумма розничная
+@property float|null $summ_self_cost // Сумма себестоимости
+@property string $created_at
+@property string|null $updated_at
+```
+
+#### Ключевой метод
+
+```php
+public static function setData($waybillWriteOffs, $shiftTransfer): bool
+```
+**Описание:** Заполнение товаров накладной на основе недостач по смене
+**Алгоритм:**
+1. Выборка товаров с отрицательной разницей из `ShiftRemains`
+2. Вычет выравниваний из `EqualizationRemains`
+3. Создание записи для каждого товара с недостачей
+4. Расчет сумм: `count * retail_price` и `count * self_cost`
+
+**Пример:**
+```php
+// Данные из ShiftRemains
+// product_id | fact | 1c_data | diff
+// product-1 | 10 | 15 | -5 (недостача 5 шт)
+
+WaybillWriteOffsProducts::setData($waybill, $shiftTransfer);
+// Создана позиция: product-1, quantity=-5, summ=..., summ_self_cost=...
+```
+
+---
+
+### 7. SalesWriteOffsPlan
+
+**Файл:** `erp24/records/SalesWriteOffsPlan.php`
+
+**Таблица:** `sales_write_offs_plan`
+
+**Назначение:** Хранение планов продаж и списаний по магазинам помесячно.
+
+#### Свойства
+
+```php
+@property int $id
+@property int $year // Год
+@property int $month // Месяц (1-12)
+@property int $store_id // ID магазина
+@property float|null $total_sales_plan // План общих продаж
+@property float|null $write_offs_plan // План списания
+@property float|null $offline_sales_plan // План офлайн продаж
+@property float|null $online_sales_shop_plan // План online магазина
+@property float|null $online_sales_marketplace_plan // План маркетплейса
+@property string $created_at
+@property string $updated_at
+@property int $created_by
+@property int $updated_by
+```
+
+**Пример записи:**
+```php
+[
+ 'year' => 2025,
+ 'month' => 3,
+ 'store_id' => 15,
+ 'total_sales_plan' => 500000.00,
+ 'write_offs_plan' => 10000.00, // 2% от продаж
+ 'offline_sales_plan' => 350000.00, // 70%
+ 'online_sales_shop_plan' => 100000.00, // 20%
+ 'online_sales_marketplace_plan' => 50000.00 // 10%
+]
+```
+
+---
+
+## 📝 Forms (Формы валидации)
+
+### 1. WriteOffsForm
+
+**Файл:** `erp24/forms/writeOffsErp/WriteOffsForm.php`
+
+**Назначение:** Форма для создания/редактирования документа списания (основная часть).
+
+```php
+namespace yii_app\forms\writeOffsErp;
+
+class WriteOffsForm extends Model
+{
+ public $store_id; // ID магазина
+ public $modelsProducts; // Массив товаров
+
+ public function rules()
+ {
+ return [
+ [['store_id'], 'integer', 'required'],
+ [['modelsProducts'], 'safe'],
+ ];
+ }
+}
+```
+
+---
+
+### 2. WriteOffsProductsForm ⭐
+
+**Файл:** `erp24/forms/writeOffsErp/WriteOffsProductsForm.php`
+
+**Назначение:** Форма для валидации товарной позиции с проверкой остатков.
+
+```php
+namespace yii_app\forms\writeOffsErp;
+
+class WriteOffsProductsForm extends Model
+{
+ public $product_id; // GUID товара
+ public $cause_id; // ID причины
+ public $store_id; // ID магазина
+ public $quantity; // Количество
+ public $images; // Изображения
+
+ public function rules()
+ {
+ return [
+ [['product_id', 'quantity'], 'required'],
+ [['store_id', 'cause_id'], 'integer'],
+ [['quantity'], 'number', 'min' => 0.001],
+ [['product_id'], 'custom_function_validation'],
+ ];
+ }
+
+ /**
+ * Проверка остатков товара в магазине
+ */
+ public function custom_function_validation($attribute, $params): void
+ {
+ $product = Products1c::findOne(['id' => $this->product_id]);
+ $storeGuid = CityStore::get1cStoreGuid($this->store_id);
+
+ $balance = Balances::find()
+ ->where([
+ 'product_id' => $product->guid,
+ 'store_id' => $storeGuid
+ ])
+ ->one();
+
+ if (!$balance || $balance->quantity < $this->quantity) {
+ $this->addError($attribute,
+ "В магазине товара \"{$product->name}\" недостаточно на остатках.\n" .
+ "Необходимое количество: {$this->quantity} шт.\n" .
+ "Доступно: " . ($balance->quantity ?? 0) . " шт."
+ );
+ }
+ }
+}
+```
+
+**Использование:**
+```php
+$form = new WriteOffsProductsForm();
+$form->product_id = 'product-guid-123';
+$form->store_id = 15;
+$form->quantity = 10;
+
+if (!$form->validate()) {
+ // Ошибка: недостаточно остатков
+ echo $form->getFirstError('product_id');
+}
+```
+
+---
+
+## 🗄️ Структура базы данных
+
+### ER-диаграмма
+
+```mermaid
+erDiagram
+ WRITE_OFFS_ERP ||--o{ WRITE_OFFS_PRODUCTS_ERP : contains
+ WRITE_OFFS_ERP }o--|| CITY_STORE : "belongs to"
+ WRITE_OFFS_ERP }o--|| ADMIN : "created by"
+ WRITE_OFFS_ERP }o--o| ADMIN : "confirmed by"
+
+ WRITE_OFFS_PRODUCTS_ERP }o--|| WRITE_OFFS_ERP_CAUSE_DICT : "has cause"
+ WRITE_OFFS_PRODUCTS_ERP ||--o{ IMAGE_DOCUMENT_LINK : "has images"
+ WRITE_OFFS_PRODUCTS_ERP ||--o| FILES : "has video"
+ WRITE_OFFS_PRODUCTS_ERP }o--|| PRODUCTS_1C : "references"
+
+ WRITE_OFFS_ERP_CAUSE_DICT ||--o{ WRITE_OFFS_ERP_CAUSE_DICT : "parent-child"
+
+ WAYBILL_WRITE_OFFS ||--o{ WAYBILL_WRITE_OFFS_PRODUCTS : contains
+ WAYBILL_WRITE_OFFS }o--|| SHIFT_TRANSFER : "created from"
+ WAYBILL_WRITE_OFFS }o--|| CITY_STORE : "belongs to"
+
+ SALES_WRITE_OFFS_PLAN }o--|| CITY_STORE : "for store"
+
+ WRITE_OFFS_ERP {
+ int id PK
+ string guid UK
+ int status
+ int active
+ int store_id FK
+ string store_guid
+ string number
+ string date
+ string write_offs_type
+ int cause_id FK
+ int cause_group_id
+ float summ
+ float summ_retail
+ float quantity
+ int created_admin_id FK
+ int confirm_admin_id FK
+ timestamp created_at
+ timestamp confirm_at
+ timestamp send_at
+ }
+
+ WRITE_OFFS_PRODUCTS_ERP {
+ int id PK
+ int write_offs_erp_id FK
+ string product_id FK
+ string name
+ int cause_id FK
+ float quantity
+ float price
+ float price_retail
+ float summ
+ float summ_retail
+ int active_product
+ timestamp created_at
+ }
+
+ WRITE_OFFS_ERP_CAUSE_DICT {
+ int id PK
+ string name
+ int parent_id FK
+ int status
+ int access_group_id
+ }
+
+ WAYBILL_WRITE_OFFS {
+ int id PK
+ string guid UK
+ int shift_transfer_id FK
+ int store_id FK
+ string number
+ string date
+ float quantity
+ float summ
+ float summ_self_cost
+ timestamp created_at
+ }
+
+ WAYBILL_WRITE_OFFS_PRODUCTS {
+ int id PK
+ int waybill_write_offs_id FK
+ string product_id FK
+ string name
+ float product_count
+ float product_price
+ float summ
+ float summ_self_cost
+ }
+
+ SALES_WRITE_OFFS_PLAN {
+ int id PK
+ int year
+ int month
+ int store_id FK
+ float total_sales_plan
+ float write_offs_plan
+ float offline_sales_plan
+ float online_sales_shop_plan
+ float online_sales_marketplace_plan
+ }
+```
+
+---
+
+## 🔄 Жизненный цикл документа списания
+
+```mermaid
+stateDiagram-v2
+ [*] --> Создан: actionCreate()
+
+ Создан --> Одобрен: actionConfirmWriteOff()
+ Создан --> Отклонен: manual
+ Создан --> Удален: actionDelete()
+
+ Одобрен --> Отправлен: Cron/API
+ Отправлен --> Создан_в_1С: 1C Success
+ Отправлен --> Ошибка_1С: 1C Error
+
+ Ошибка_1С --> Одобрен: actionReSendWriteOff()
+
+ Создан_в_1С --> [*]
+ Отклонен --> [*]
+ Удален --> [*]
+
+ note right of Создан
+ STATUS_CREATED = 1
+ - Заполнение данных
+ - Добавление товаров
+ - Загрузка фото/видео
+ - Можно редактировать
+ end note
+
+ note right of Одобрен
+ STATUS_CONFIRM = 2
+ - Валидация остатков
+ - Проверка прав
+ - Готов к отправке
+ - confirm_at, confirm_admin_id
+ end note
+
+ note right of Отправлен
+ STATUS_SEND = 3
+ - Отправлен в 1С
+ - Ожидание ответа
+ - send_at
+ end note
+
+ note right of Создан_в_1С
+ STATUS_CREATED_1C = 4
+ - Финальный статус
+ - Нельзя редактировать
+ - number_1c от 1С
+ end note
+
+ note right of Ошибка_1С
+ STATUS_ERROR_1C = 8
+ - error_text заполнен
+ - Возможна повторная отправка
+ end note
+```
+
+---
+
+## 🔗 Интеграции с другими модулями
+
+### 1. Интеграция с 1С ⭐
+
+**Направление:** ERP → 1С
+
+**Формат данных:**
+```php
+[
+ 'create_write_offs' => [
+ 'writeOff' => [
+ [
+ 'id' => 'document-guid',
+ 'store_id' => 'store-guid',
+ 'type' => 'Брак',
+ 'cause' => 'Документ списания в ERP ЕРП_2025-03-15_14-30_001',
+ 'items' => [
+ [
+ 'product_id' => 'product-guid-1',
+ 'quantity' => 5,
+ 'price' => 100.50
+ ],
+ [
+ 'product_id' => 'product-guid-2',
+ 'quantity' => 2,
+ 'price' => 250.00
+ ]
+ ],
+ 'summ' => 1002.50,
+ 'comment' => 'Брак с поставки'
+ ]
+ ],
+ 'writeOffIds' => [15, 23],
+ 'writeOffIdsString' => '15,23'
+ ]
+]
+```
+
+**Условия отправки:**
+- Статус: `STATUS_CONFIRM` (2)
+- Активность: `active=1`
+- Товары: `active_product=1`
+
+**Обратная связь от 1C:**
+- **Успех:** Статус → `STATUS_CREATED_1C` (4), заполняется `number_1c`
+- **Ошибка:** Статус → `STATUS_ERROR_1C` (8), заполняется `error_text`
+
+**Метод формирования:**
+```php
+$data = WriteOffsErpController::getWriteOffsDoc();
+// Отправка в 1С через API
+```
+
+---
+
+### 2. Интеграция с Balances (Остатки товаров)
+
+**Таблица:** `balances`
+
+**Назначение:** Проверка достаточности остатков при создании списания.
+
+**Запрос:**
+```sql
+SELECT quantity
+FROM balances
+WHERE product_id = :product_guid
+ AND store_id = :store_guid
+```
+
+**Использование в коде:**
+```php
+$balance = Balances::find()
+ ->where(['product_id' => $productGuid, 'store_id' => $storeGuid])
+ ->one();
+
+if ($balance->quantity < $requestedQuantity) {
+ throw new Exception('Недостаточно остатков');
+}
+```
-**Интеграция с 1С:**
-- Автоматическая синхронизация данных о списаниях
-- Сверка по GUID товаров и магазинов
-- Учет сумм в разрезе дат и магазинов
+---
+
+### 3. Интеграция с PricesDynamic (Цены)
+
+**Таблица:** `prices_dynamic`
+
+**Назначение:** Получение розничных цен товаров на дату списания.
+
+**Запрос:**
+```sql
+SELECT price
+FROM prices_dynamic
+WHERE region_id = 52
+ AND product_id = :product_id
+ AND date_from <= :write_off_date
+ AND date_to >= :write_off_date
+```
+
+**Метод:**
+```php
+$price = WriteOffs::getPriceDynamic($productId, $writeOffDate, $regionId);
+```
+
+---
+
+### 4. Интеграция с Files & Images
+
+**Схема хранения изображений:**
+- Таблица: `image_document_link`
+- Поля:
+ - `document_group_id` = 1 (для списаний)
+ - `document_id` = write_offs_erp.id
+ - `document_item_id` = write_offs_products_erp.id
+ - `image_id` → images.id
+
+**Схема хранения видео:**
+- Таблица: `files`
+- Поля:
+ - `entity` = 'write_offs_products_erp_video'
+ - `entity_id` = write_offs_products_erp.id
+
+**Загрузка:**
+```php
+// Изображения через MultipleUploadForm
+$images = MultipleUploadForm::saveImages($product->id, $files);
+
+// Видео через FileService
+FileService::saveUploadedFile($video, 'write_offs_products_erp_video', $product->id);
+```
+
+---
+
+### 5. Интеграция с Timetable (График работы)
-## 📊 Метрики
+**Назначение:** Получение списка разрешенных магазинов для пользователя.
-Модуль используется в аналитике:
-- Процент списания от продаж (влияет на рейтинг и бонусы)
+**Метод:**
+```php
+$storeIds = TimetableService::getAllowedStoreId($adminId, $groupId);
+```
+
+**Использование:**
+```php
+$query->andWhere(['store_id' => $storeIds]);
+```
+
+---
+
+### 6. Интеграция с Dashboard (Аналитика)
+
+**Данные для дашбордов:**
+- Процент списания от продаж
- Динамика списаний по магазинам
- Топ причин списаний
-- Ответственные за списания
-## 🔗 Связи с модулями
+**Источник:**
+```php
+$writeOffs = WriteOffs::getWriteOffByStore($dateFrom, $dateTo, $storeId);
+$sales = Sales::getSalesByStore($dateFrom, $dateTo, $storeId);
+$percent = ($writeOffs / $sales) * 100;
+```
+
+---
+
+### 7. Интеграция с Rating (Рейтинги)
+
+**Влияние на рейтинг:**
+- Высокий процент списания снижает рейтинг магазина/сотрудника
+- Используется в формулах расчета `RatingService`
+
+---
+
+### 8. Интеграция с Bonus (Бонусная система)
+
+**Влияние на бонусы:**
+- Превышение нормы списаний может привести к штрафам
+- Низкий процент списания может давать бонус за качество
+
+---
+
+### 9. Интеграция с ShiftTransfer (Передача смены)
+
+**Автоматическое создание накладных:**
+
+```mermaid
+sequenceDiagram
+ participant ST as ShiftTransfer
+ participant SR as ShiftRemains
+ participant WWO as WaybillWriteOffs
+ participant WWOP as WaybillWriteOffsProducts
+ participant 1C as 1C System
+
+ ST->>SR: Проверка недостач
+ SR-->>ST: fact_and_1c_diff < 0
+ ST->>WWO: WaybillWriteOffs::setData()
+ WWO->>WWOP: setData($shiftTransfer)
+ WWOP->>SR: Получение товаров с недостачами
+ WWOP->>WWOP: Создание позиций
+ WWOP-->>WWO: Товары созданы
+ WWO->>WWO: Пересчет итогов
+ WWO-->>ST: Накладная создана
+ WWO->>1C: Отправка накладной
+```
+
+**Пример:**
+```php
+// При закрытии смены с недостачами
+$shiftTransfer = ShiftTransfer::findOne($id);
+WaybillWriteOffs::setData($shiftTransfer);
+// Автоматически создана накладная РНС по всем недостачам
+```
+
+---
+
+## 📊 Бизнес-логика и формулы
+
+### 1. Расчет сумм документа
+
+**Для каждой товарной позиции:**
+```php
+// 1. Получение розничной цены на дату списания
+$price_retail = WriteOffs::getPriceDynamic($product_id, $date, $region_id);
+
+// 2. Расчет сумм позиции
+$summ = $price * $quantity; // Закупочная
+$summ_retail = $price_retail * $quantity; // Розничная
+
+// 3. Для документа (сумма всех позиций)
+$doc->summ = array_sum($products->summ);
+$doc->summ_retail = array_sum($products->summ_retail);
+$doc->quantity = array_sum($products->quantity);
+```
+
+**Пример:**
+```php
+// Товар 1: 5 шт × 100₽ = 500₽
+// Товар 2: 2 шт × 250₽ = 500₽
+// Итого: summ_retail = 1000₽, quantity = 7 шт
+```
+
+---
+
+### 2. Проверка остатков
+
+**Алгоритм:**
+```php
+function validateBalance($productGuid, $storeGuid, $requestedQty) {
+ $balance = Balances::find()
+ ->where(['product_id' => $productGuid, 'store_id' => $storeGuid])
+ ->one();
+
+ $available = $balance ? $balance->quantity : 0;
+
+ if ($available < $requestedQty) {
+ $delta = $requestedQty - $available;
+ throw new Exception(
+ "Недостаточно остатков.\n" .
+ "Необходимо: {$requestedQty} шт.\n" .
+ "Доступно: {$available} шт.\n" .
+ "Нехватает: {$delta} шт."
+ );
+ }
+}
+```
+
+---
+
+### 3. Автоматическое создание накладных
+
+**Условие:** Передача смены закрыта с недостачами.
+
+**Алгоритм:**
+```php
+function createWaybillFromShift($shiftTransfer) {
+ // 1. Создание накладной
+ $waybill = new WaybillWriteOffs();
+ $waybill->shift_transfer_id = $shiftTransfer->id;
+ $waybill->store_id = $shiftTransfer->store_id;
+ $waybill->number = "ЕРП_РНС_" . date('Y-m-d_H-i') . "_{id}";
+ $waybill->save();
+
+ // 2. Заполнение товаров с недостачами
+ $remains = ShiftRemains::find()
+ ->where(['shift_transfer_id' => $shiftTransfer->id])
+ ->andWhere('fact_and_1c_diff < 0') // Только недостачи
+ ->all();
+
+ foreach ($remains as $remain) {
+ // Вычет выравниваний
+ $equalization = EqualizationRemains::getTotalByProduct(
+ $remain->product_id,
+ $shiftTransfer->id
+ );
+
+ $shortage = abs($remain->fact_and_1c_diff) - $equalization;
+
+ if ($shortage > 0) {
+ $product = new WaybillWriteOffsProducts();
+ $product->waybill_write_offs_id = $waybill->id;
+ $product->product_id = $remain->product_id;
+ $product->product_count = $shortage;
+ $product->product_price = $remain->retail_price;
+ $product->summ = $shortage * $remain->retail_price;
+ $product->summ_self_cost = $shortage * $remain->self_cost;
+ $product->save();
+ }
+ }
+
+ // 3. Пересчет итогов накладной
+ $waybill->quantity = WaybillWriteOffsProducts::find()
+ ->where(['waybill_write_offs_id' => $waybill->id])
+ ->sum('product_count');
+
+ $waybill->summ = WaybillWriteOffsProducts::find()
+ ->where(['waybill_write_offs_id' => $waybill->id])
+ ->sum('summ');
+
+ $waybill->summ_self_cost = WaybillWriteOffsProducts::find()
+ ->where(['waybill_write_offs_id' => $waybill->id])
+ ->sum('summ_self_cost');
+
+ $waybill->save();
+
+ // 4. Если товаров нет, удаление накладной
+ if ($waybill->quantity == 0) {
+ $waybill->delete();
+ }
+}
+```
+
+---
+
+### 4. Планирование списаний
+
+**Формула процента списания:**
+```php
+$writeOffPercent = ($writeOffPlan / $totalSalesPlan) * 100;
+```
+
+**Пример:**
+```php
+// План продаж: 500,000₽
+// План списаний: 10,000₽
+// Процент списания: (10,000 / 500,000) * 100 = 2%
+```
+
+**Контроль выполнения плана:**
+```php
+function checkWriteOffPlan($storeId, $year, $month) {
+ $plan = SalesWriteOffsPlan::find()
+ ->where(['store_id' => $storeId, 'year' => $year, 'month' => $month])
+ ->one();
+
+ $fact = WriteOffs::getWriteOffByStore(
+ "{$year}-{$month}-01",
+ "{$year}-{$month}-31",
+ $storeId
+ );
+
+ $percent = ($plan->write_offs_plan > 0)
+ ? ($fact / $plan->write_offs_plan) * 100
+ : 0;
+
+ return [
+ 'plan' => $plan->write_offs_plan,
+ 'fact' => $fact,
+ 'percent' => $percent,
+ 'status' => ($percent <= 100) ? 'OK' : 'Превышение'
+ ];
+}
+```
+
+---
+
+## 🔐 Права доступа и безопасность
+
+### 1. Контроль доступа по группам
+
+```php
+// Группы для подтверждения списаний
+$allowedConfirmGroups = [1, 7, 10];
+// 1 - Администраторы
+// 7 - Супервайзеры
+// 10 - КШФ
+
+$canConfirm = in_array($groupId, $allowedConfirmGroups);
+```
+
+---
+
+### 2. Проверка прав на тестовых магазинах
+
+```php
+public static function isTestStore(int $storeId): bool
+{
+ // После 2025-08-07 все магазины считаются тестовыми
+ if (strtotime(date('Y-m-d')) >= strtotime('2025-08-07')) {
+ return true;
+ }
+
+ $testStores = [1, 8, 9, 13, 15, 19, 28, 30, 41, 44];
+ return in_array($storeId, $testStores);
+}
+
+public static function isManager(int $storeId): bool
+{
+ // Исключения: администраторы с особыми правами
+ $adminExceptions = [785, 1463, 225, 1070, 826, 1036];
+
+ if (in_array(Yii::$app->user->id, $adminExceptions) &&
+ self::isTestStore($storeId)) {
+ return false;
+ }
+
+ return Yii::$app->user->identity->group_id == 5; // Менеджер
+}
+```
+
+---
+
+### 3. Ограничение магазинов по пользователю
+
+```php
+$storeIds = TimetableService::getAllowedStoreId($adminId, $groupId);
+$query->andWhere(['store_id' => $storeIds]);
+```
+
+---
+
+### 4. Запрет редактирования финальных документов
+
+```php
+// Нельзя изменять документы со статусом "Создан в 1С"
+$model = WriteOffsErp::find()
+ ->where(['id' => $id])
+ ->andWhere(['!=', 'status', WriteOffsErp::STATUS_CREATED_1C])
+ ->one();
+
+if (!$model) {
+ throw new NotFoundHttpException('Документ недоступен для редактирования');
+}
+```
+
+---
+
+### 5. Аудит изменений
+
+**Поля аудита в WriteOffsErp:**
+```php
+created_admin_id, created_at // Кто и когда создал
+updated_admin_id, updated_at // Кто и когда изменил
+confirm_admin_id, confirm_at // Кто и когда подтвердил
+deleted_admin_id, deleted_at // Кто и когда удалил
+send_at // Когда отправлен в 1С
+```
+
+**Поля аудита в WriteOffsProductsErp:**
+```php
+created_admin_id, created_at
+updated_admin_id, updated_at
+deleted_admin_id, deleted_at
+```
+
+**Использование:**
+```php
+// История изменений документа
+$history = [
+ 'Создан' => "{$model->created_at} ({$model->createdAdmin->name})",
+ 'Подтвержден' => "{$model->confirm_at} ({$model->confirmAdmin->name})",
+ 'Отправлен' => $model->send_at,
+];
+```
+
+---
+
+## ⚠️ Обработка ошибок
+
+### 1. Типы ошибок
+
+**Валидация остатков:**
+```php
+"В магазине \"{store}\" товара \"{product}\" на остатках недостаточно"
+"Необходимое количество: {required} шт., доступно: {available} шт."
+"Для списания нужно ещё {delta} шт."
+```
+
+**Ошибки 1C:**
+- Сохраняются в поле `error_text`
+- Статус меняется на `STATUS_ERROR_1C` (8)
+- Возможность повторной отправки через `actionReSendWriteOff()`
+
+**Пример обработки:**
+```php
+try {
+ $response = Api1C::sendWriteOffs($data);
+
+ if ($response['success']) {
+ $model->status = WriteOffsErp::STATUS_CREATED_1C;
+ $model->number_1c = $response['number'];
+ } else {
+ $model->status = WriteOffsErp::STATUS_ERROR_1C;
+ $model->error_text = $response['error'];
+ }
+
+ $model->save();
+} catch (Exception $e) {
+ $model->status = WriteOffsErp::STATUS_ERROR_1C;
+ $model->error_text = $e->getMessage();
+ $model->save();
+}
+```
+
+---
+
+### 2. Обработка битых файлов
+
+```php
+// Проверка существования файла
+if ($imageThumbRow === 'file_not_found' ||
+ $imageThumbRow === 'file_not_readable' ||
+ $imageThumbRow === 'file_processing_error') {
+ $imageThumbRow = 'broken_file-error';
+}
+
+// Проверка размера
+if (empty($image->size)) {
+ $imageThumbRow = 'broken_file-size_zero';
+}
+
+// Отображение заглушки
+echo "<img src='/images/broken-file.png' />";
+```
+
+---
+
+### 3. Транзакционные ошибки
+
+**Все операции create/update обернуты в транзакции:**
+```php
+$transaction = Yii::$app->db->beginTransaction();
+try {
+ $model->save();
+
+ foreach ($products as $product) {
+ $product->write_offs_erp_id = $model->id;
+ $product->save();
+ }
+
+ $transaction->commit();
+} catch (Exception $e) {
+ $transaction->rollBack();
+ throw $e;
+}
+```
+
+---
+
+## 📈 Метрики и производительность
+
+### 1. Статистика модуля
+
+| Метрика | Значение |
+|---------|----------|
+| **Контроллеров** | 5 |
+| **Сервисов** | 1 (заглушка) |
+| **Models/Records** | 10 |
+| **Actions** | 2 |
+| **Forms** | 2 |
+| **Миграций** | 15+ |
+
+---
+
+### 2. Оптимизация запросов
+
+**Eager loading связей:**
+```php
+$query = WriteOffsErp::find()
+ ->with('writeOffsProductsErps')
+ ->with('cityStore')
+ ->with(['imagesWriteOffsErp'])
+ ->all();
+```
+
+**Пагинация:**
+```php
+$dataProvider = new ActiveDataProvider([
+ 'query' => $query,
+ 'pagination' => [
+ 'pageSize' => 20,
+ ],
+]);
+```
+
+---
+
+### 3. Индексация
+
+**Основные индексы:**
+- PRIMARY KEY на `id`
+- UNIQUE на `guid`
+- INDEX на `store_id`, `status`, `active`, `date`
+- Foreign keys на все связи
+
+---
+
+### 4. Кэширование
+
+**Справочники (потенциал для кэширования):**
+```php
+// Кэш на 1 час
+$causeDict = Yii::$app->cache->getOrSet(
+ 'write-offs-cause-dict',
+ function() {
+ return WriteOffsProductsErp::getCauseDict();
+ },
+ 3600
+);
+```
+
+---
+
+## 💡 Примеры использования
+
+### Пример 1: Создание документа списания
+
+```php
+// 1. Создание документа
+$model = new WriteOffsErp();
+$model->setGuidCreated();
+$model->setNumberCreated();
+$model->setStoreGuidCreated();
+$model->store_id = 15;
+$model->write_offs_type = WriteOffsErp::WRITE_OFFS_TYPE_BRAK;
+$model->date = date('Y-m-d H:i:s');
+$model->status = WriteOffsErp::STATUS_CREATED;
+$model->created_admin_id = Yii::$app->user->id;
+$model->save();
+
+// 2. Добавление товаров
+$product1 = new WriteOffsProductsErp();
+$product1->write_offs_erp_id = $model->id;
+$product1->product_id = 'product-guid-123';
+$product1->name = 'Роза красная 60см';
+$product1->quantity = 5;
+$product1->cause_id = 1; // Повреждение упаковки
+$product1->price_retail = WriteOffs::getPriceDynamic($product1->product_id, $model->date);
+$product1->summ_retail = $product1->price_retail * $product1->quantity;
+$product1->created_admin_id = Yii::$app->user->id;
+$product1->save();
+
+// 3. Пересчет итогов
+WriteOffsErp::recalcTotals($model);
+
+// Результат:
+// Документ ЕРП_2025-03-15_14-30_001 создан
+// 1 позиция: Роза красная 60см, 5 шт × 100₽ = 500₽
+```
+
+---
+
+### Пример 2: Подтверждение документа
+
+```php
+$model = WriteOffsErp::findOne($id);
+
+// Валидация всех позиций
+foreach ($model->writeOffsProductsErps as $product) {
+ $form = new WriteOffsProductsForm();
+ $form->product_id = $product->product_id;
+ $form->store_id = $model->store_id;
+ $form->quantity = $product->quantity;
+
+ if (!$form->validate()) {
+ throw new Exception($form->getFirstError('product_id'));
+ }
+}
+
+// Подтверждение
+$model->status = WriteOffsErp::STATUS_CONFIRM;
+$model->confirm_at = date('Y-m-d H:i:s');
+$model->confirm_admin_id = Yii::$app->user->id;
+$model->save();
+
+// Результат: Документ подтвержден и готов к отправке в 1С
+```
+
+---
+
+### Пример 3: Отправка в 1С
+
+```php
+// Формирование данных
+$data = WriteOffsErpController::getWriteOffsDoc();
+
+// Отправка
+$response = Api1C::call('create_write_offs', $data);
+
+if ($response['success']) {
+ // Обновление статусов документов
+ foreach ($data['writeOffIds'] as $id) {
+ $model = WriteOffsErp::findOne($id);
+ $model->status = WriteOffsErp::STATUS_CREATED_1C;
+ $model->number_1c = $response['documents'][$id]['number'];
+ $model->save();
+ }
+
+ echo "Успешно создано {count($data['writeOffIds'])} документов в 1С";
+} else {
+ echo "Ошибка: {$response['error']}";
+}
+```
+
+---
+
+### Пример 4: Автоматическое создание накладной по недостаче
+
+```php
+// При закрытии передачи смены
+$shiftTransfer = ShiftTransfer::findOne($id);
+
+// Проверка недостач
+$hasShortages = ShiftRemains::find()
+ ->where(['shift_transfer_id' => $id])
+ ->andWhere('fact_and_1c_diff < 0')
+ ->exists();
+
+if ($hasShortages) {
+ // Автоматическое создание накладной
+ WaybillWriteOffs::setData($shiftTransfer);
+
+ echo "Создана накладная расходная смены (РНС) по недостачам";
+}
+
+// Результат:
+// Накладная ЕРП_РНС_2025-03-15_18-00_042
+// Товаров: 3 позиции
+// Сумма: 1,250₽
+```
+
+---
+
+### Пример 5: Планирование списаний
+
+```php
+// Установка плана на март 2025 для магазина 15
+$plan = SalesWriteOffsPlan::find()
+ ->where(['year' => 2025, 'month' => 3, 'store_id' => 15])
+ ->one();
+
+if (!$plan) {
+ $plan = new SalesWriteOffsPlan();
+ $plan->year = 2025;
+ $plan->month = 3;
+ $plan->store_id = 15;
+ $plan->created_by = Yii::$app->user->id;
+}
+
+$plan->total_sales_plan = 500000; // 500,000₽
+$plan->write_offs_plan = 10000; // 10,000₽ (2%)
+$plan->offline_sales_plan = 350000; // 70%
+$plan->online_sales_shop_plan = 100000; // 20%
+$plan->online_sales_marketplace_plan = 50000; // 10%
+$plan->updated_by = Yii::$app->user->id;
+$plan->save();
+
+// Результат: План на март 2025 установлен
+```
+
+---
+
+### Пример 6: Перенос позиций в новый документ
+
+```php
+$model = WriteOffsErp::findOne($id);
+
+// Создание нового документа на основе текущего
+$newDoc = WriteOffsErp::newFromBase($model, Yii::$app->user->id);
+$newDoc->save();
+
+// Перенос выбранных позиций
+$transferIds = [25, 26, 27]; // ID позиций для переноса
+WriteOffsProductsErp::updateAll(
+ ['write_offs_erp_id' => $newDoc->id],
+ ['id' => $transferIds]
+);
+
+// Обновление связей изображений
+ImageDocumentLink::updateAll(
+ ['document_id' => $newDoc->id],
+ ['document_item_id' => $transferIds]
+);
+
+// Пересчет итогов для обоих документов
+WriteOffsErp::recalcTotals($model); // Исходный
+WriteOffsErp::recalcTotals($newDoc); // Новый
+
+// Результат:
+// Создан документ ЕРП_2025-03-15_15-00_002
+// Перенесено 3 позиции из ЕРП_2025-03-15_14-30_001
+```
+
+---
+
+## ❓ FAQ
+
+### 1. Как работает автоматическое создание накладных при передаче смены?
+
+При закрытии передачи смены система проверяет, есть ли недостачи товаров (когда фактический остаток меньше, чем в 1С). Если недостачи найдены:
+
+1. Создается документ `WaybillWriteOffs` (Расходная накладная смены)
+2. Для каждого товара с недостачей создается позиция в `WaybillWriteOffsProducts`
+3. Учитываются выравнивания из `EqualizationRemains`
+4. Рассчитываются суммы по розничной цене и себестоимости
+5. Если после вычета выравниваний недостач нет, накладная удаляется
+
+---
+
+### 2. Почему нельзя редактировать документ со статусом "Создан в 1С"?
+
+Документы со статусом `STATUS_CREATED_1C` (4) уже созданы в системе 1С и имеют номер `number_1c`. Изменение таких документов в ERP приведет к расхождению данных между системами. Если нужно исправить ошибку, следует создать корректирующий документ.
+
+---
+
+### 3. Как проверить, достаточно ли остатков для списания?
+
+При добавлении товара в документ списания автоматически выполняется проверка через `WriteOffsProductsForm::custom_function_validation()`:
+
+1. Получение GUID товара из `Products1c`
+2. Получение GUID магазина из `CityStore`
+3. Запрос остатков из таблицы `balances`
+4. Сравнение запрошенного количества с доступным
+5. Если недостаточно - выводится детальная ошибка с указанием дефицита
+
+---
+
+### 4. Можно ли перенести часть позиций из одного документа в другой?
+
+Да, в методе `actionUpdate` предусмотрена функция переноса позиций:
+
+1. Открыть документ на редактирование
+2. Выбрать позиции для переноса
+3. Нажать "Перенести в новый документ"
+4. Система автоматически:
+ - Создаст новый документ на основе текущего
+ - Перенесет выбранные позиции
+ - Перенесет связанные изображения и видео
+ - Пересчитает итоги обоих документов
+
+---
+
+### 5. Как повторно отправить документ в 1С после ошибки?
+
+Если документ имеет статус `STATUS_ERROR_1C` (8):
+
+1. Открыть документ на просмотр
+2. Проверить текст ошибки в поле `error_text`
+3. При необходимости внести исправления
+4. Нажать кнопку "Повторная отправка"
+5. Статус сбросится на `STATUS_CONFIRM` (2)
+6. При следующем цикле отправки документ будет отправлен в 1С повторно
+
+---
+
+### 6. Где хранятся фото и видео списаний?
+
+**Изображения:**
+- Таблица: `image_document_link` (связи)
+- Таблица: `images` (файлы)
+- Filesystem: `/uploads/images/`
+- Thumbnails: генерируются автоматически (100x100)
+
+**Видео:**
+- Таблица: `files`
+- Entity: `write_offs_products_erp_video`
+- Filesystem: `/uploads/files/`
+
+**Автоочистка:**
+- Вложения документов старше 2 месяцев со статусом `STATUS_CREATED_1C` могут быть архивированы/удалены через метод `WriteOffsErp::getAttachmentsOlderThanMonth()`
+
+---
+
+### 7. Как работает справочник причин списания?
+
+Справочник `WriteOffsErpCauseDict` имеет двухуровневую структуру:
+
+**Уровень 1 (Группы):** `parent_id IS NULL`
+- Брак
+- Утилизация
+- Прочее
+
+**Уровень 2 (Причины):** `parent_id IS NOT NULL`
+- Повреждение упаковки (parent_id=1)
+- Истек срок годности (parent_id=2)
+- И т.д.
+
+При выборе причины в форме отображается иерархический список: сначала группа, затем причины внутри группы.
+
+---
+
+### 8. Можно ли редактировать планы списаний за прошлые месяцы?
+
+Нет, редактирование планов ограничено:
+
+**Можно редактировать:**
+- Будущие месяцы
+- Текущий месяц до 25 числа
+
+**Нельзя редактировать:**
+- Прошлые месяцы
+- Текущий месяц после 25 числа
+
+Это сделано для предотвращения манипуляций с плановыми показателями после окончания периода.
+
+---
+
+### 9. Что происходит при удалении документа списания?
+
+При вызове `actionDelete($id)` выполняется **мягкое удаление**:
+
+1. Удаление всех товарных позиций: `WriteOffsProductsErp::deleteByParentId()`
+ - `active_product` → 0
+ - Заполнение `deleted_at`, `deleted_admin_id`
+2. Удаление документа:
+ - `active` → 0
+ - Заполнение `deleted_at`, `deleted_admin_id`
+3. Физически данные остаются в БД (для аудита)
+4. Документ исчезает из списка (фильтр `active=1`)
+
+---
+
+### 10. Как получить розничную цену товара на дату списания?
+
+Используйте метод `WriteOffs::getPriceDynamic()`:
+
+```php
+$price = WriteOffs::getPriceDynamic(
+ $productId, // GUID товара
+ $writeOffDate, // Дата списания (Y-m-d H:i:s)
+ $regionId // ID региона (по умолчанию 52 - Н.Новгород)
+);
+
+// Пример:
+$price = WriteOffs::getPriceDynamic(
+ 'product-guid-123',
+ '2025-03-15',
+ 52
+);
+// Вернет: 100.50 (цена на 15 марта 2025)
+```
+
+Метод ищет цену в таблице `prices_dynamic` по условию:
+- `region_id` = заданный регион
+- `product_id` = GUID товара
+- `date_from` ≤ дата списания ≤ `date_to`
+
+---
+
+## 📚 Связанная документация
+
+### Модули ERP24
+- [Dashboard](../dashboard/README.md) - Метрики списаний для дашбордов
+- [Rating](../rating/README.md) - Влияние списаний на рейтинги
+- [Bonus](../bonus/README.md) - Штрафы за превышение нормы списаний
+- [Timetable](../timetable/README.md) - Получение разрешенных магазинов
+
+### API
+- [API2 Integration Guide](../../api/api2/INTEGRATION_GUIDE.md) - Интеграция с 1С
+
+### Database
+- [Database Schema Overview](../../database/schema-overview.md) - Схема БД
+
+### Architecture
+- [System Overview](../../architecture/system-overview.md) - Общая архитектура ERP24
+
+---
+
+## 📝 Changelog
-- **Dashboard** - метрики списаний
-- **Rating** - влияние на рейтинг магазина/сотрудников
-- **Bonus** - штрафы за превышение нормы списаний
-- **1C Integration** - синхронизация данных
+| Версия | Дата | Изменения |
+|--------|------|-----------|
+| 1.0 | 2025-03-15 | Полная документация модуля Write-offs создана |
---
-**Последнее обновление:** 2025-11-17
+**Документ создан:** Hive Mind Business Domains Swarm
+**Дата:** 2025-03-15
+**Версия:** 1.0
+**Координатор:** Queen Coordinator (tactical)