]> gitweb.erp-flowers.ru Git - erp24_rep/yii-erp24/.git/commitdiff
Документация модулей
authorfomichev <vladimir.fomichev@erp-flowers.ru>
Mon, 24 Nov 2025 08:04:13 +0000 (11:04 +0300)
committerfomichev <vladimir.fomichev@erp-flowers.ru>
Mon, 24 Nov 2025 08:04:13 +0000 (11:04 +0300)
erp24/docs/modules/grade/README.md
erp24/docs/modules/lesson/README.md
erp24/docs/modules/lesson/actions.md [new file with mode: 0644]
erp24/docs/modules/lesson/examples.md [new file with mode: 0644]
erp24/docs/modules/lesson/faq.md [new file with mode: 0644]
erp24/docs/modules/lesson/models.md [new file with mode: 0644]
erp24/docs/modules/lesson/services.md [new file with mode: 0644]
erp24/docs/modules/lesson/workflows.md [new file with mode: 0644]
erp24/docs/modules/write-offs/README.md

index 9c1a0b1de8dd7d43c733737a41684e740e7b7711..ae49a33adaae8e9b6f9def5a920f9dc2b48e92c8 100644 (file)
 
 ## 📋 Описание
 
-**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)
index 1d923acdce29724fc765dd84f656ab6d2f310c0d..d8e87b822ff892bcbda3652b03b6ac7160ea1a00 100644 (file)
 
 ## 📋 Описание
 
-**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 (полная документация)
diff --git a/erp24/docs/modules/lesson/actions.md b/erp24/docs/modules/lesson/actions.md
new file mode 100644 (file)
index 0000000..249739b
--- /dev/null
@@ -0,0 +1,887 @@
+# 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
diff --git a/erp24/docs/modules/lesson/examples.md b/erp24/docs/modules/lesson/examples.md
new file mode 100644 (file)
index 0000000..d9f1f85
--- /dev/null
@@ -0,0 +1,770 @@
+# Примеры использования модуля 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
diff --git a/erp24/docs/modules/lesson/faq.md b/erp24/docs/modules/lesson/faq.md
new file mode 100644 (file)
index 0000000..45725f1
--- /dev/null
@@ -0,0 +1,615 @@
+# 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
diff --git a/erp24/docs/modules/lesson/models.md b/erp24/docs/modules/lesson/models.md
new file mode 100644 (file)
index 0000000..a0f5091
--- /dev/null
@@ -0,0 +1,749 @@
+# Модели модуля 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
diff --git a/erp24/docs/modules/lesson/services.md b/erp24/docs/modules/lesson/services.md
new file mode 100644 (file)
index 0000000..c6e04e6
--- /dev/null
@@ -0,0 +1,728 @@
+# Сервисы модуля 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
diff --git a/erp24/docs/modules/lesson/workflows.md b/erp24/docs/modules/lesson/workflows.md
new file mode 100644 (file)
index 0000000..ec88ae0
--- /dev/null
@@ -0,0 +1,919 @@
+# Бизнес-процессы модуля 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
index 89625db521663e4dd25fd3d58b77e50f15992ffe..238137ee95438a2cc0dfe49dd6e96a4eb3fd08f6 100644 (file)
 
 ## 📋 Описание
 
-**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)