From: fomichev Date: Mon, 24 Nov 2025 08:04:13 +0000 (+0300) Subject: Документация модулей X-Git-Url: https://gitweb.erp-flowers.ru/?a=commitdiff_plain;h=fa97e4958eb35fe37fd3c0015e8d7f60762f66bc;p=erp24_rep%2Fyii-erp24%2F.git Документация модулей --- diff --git a/erp24/docs/modules/grade/README.md b/erp24/docs/modules/grade/README.md index 9c1a0b1d..ae49a33a 100644 --- a/erp24/docs/modules/grade/README.md +++ b/erp24/docs/modules/grade/README.md @@ -2,56 +2,2093 @@ ## 📋 Описание -**Grade** - модуль управления системой грейдов (уровней) сотрудников и должностей. Определяет иерархию позиций, уровни оплаты и требования к квалификации. +**Grade** - комплексный модуль управления системой грейдов (уровней сотрудников), должностей, навыков и расчета окладов в системе ERP24. Модуль реализует две параллельные, но интегрированные системы: систему грейдов с ценообразованием по городам и детальную систему должностей с иерархией карьерного роста и требованиями к навыкам. ### Основные возможности -- 📊 Система грейдов (уровней сотрудников) -- 💼 Управление должностями -- 💰 Связь с окладами и ставками -- 📈 Карьерное развитие -- 🎯 Требования к квалификации +- 📊 Управление грейдами (уровнями сотрудников) +- 💼 Иерархия должностей с карьерным ростом +- 💰 Гибкое ценообразование (по городам, грейдам, индивидуальные ставки) +- 🎓 Система навыков с требованиями и сроками действия +- 📈 Карьерное развитие сотрудников +- 📝 История изменений грейдов и должностей +- 🔄 Временное управление изменениями (со следующего месяца) +- 👥 Специальная логика для операционных групп (флористы, администраторы) -## 🏗️ Архитектура +## 🏗️ Архитектура модуля -**Контроллеры:** 2 -- `GradeController` - управление грейдами -- `EmployeePositionController` - управление должностями +```mermaid +graph TB + subgraph "Controllers" + GC[GradeController
8 actions] + MGC[MyGradeController
Self-service] + EPC[EmployeePositionController
CRUD] + end -**Модели (4):** -- `Grade` - грейды/уровни -- `EmployeePosition` - должности -- `GradePosition` - связь грейдов и должностей -- `GradeRequirements` - требования к грейдам + subgraph "Actions" + IA[IndexAction
Список сотрудников] + CA[CreateAction
Создание грейда] + UA[UpdateAction
Обновление позиции/навыков] + AUA[AdminUpdateAction
Редактирование сотрудника] + AHA[AdminHistoryAction
История грейдов] + PA[PriceAction
Управление ценами] + SPA[SkillForPositionAction
Навыки для должностей] + end -## 💼 Основные сущности + subgraph "Grade System" + G[Grade
Грейды] + GP[GradePrice
Цены по городам] + GG[GradeGroup
Связь грейд-группа] + AGH[AdminGradeHistory
История грейдов] + end -**Грейды:** -- Junior (Стажер) -- Middle (Флорист) -- Senior (Старший флорист) -- Lead (Администратор) -- Expert (Супервайзер) + subgraph "Position System" + EP[EmployeePosition
Должности] + EPS[EmployeePositionStatus
Текущие должности] + EPSK[EmployeePositionSkill
Требуемые навыки] + end -**Должности:** -- Флорист -- Администратор магазина -- Кустовой директор -- Менеджер отдела -- И другие + subgraph "Skill System" + ES[EmployeeSkill
Справочник навыков] + ESS[EmployeeSkillStatus
Полученные навыки] + end -**Связь с зарплатой:** -- Каждый грейд имеет базовую ставку -- Должность определяет коэффициент -- Итоговый оклад = ставка × коэффициент + subgraph "Salary System" + EPAY[EmployeePayment
Индивидуальные ставки] + 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[Флорист стажёр
posit=1] -->|next_position_id| B[Флорист
posit=2] + B -->|next_position_id| C[Флорист-поддержка
posit=3] + C -->|next_position_id| D[Администратор
posit=4] + D -->|next_position_id| E[Старший администратор
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) diff --git a/erp24/docs/modules/lesson/README.md b/erp24/docs/modules/lesson/README.md index 1d923acd..d8e87b82 100644 --- a/erp24/docs/modules/lesson/README.md +++ b/erp24/docs/modules/lesson/README.md @@ -2,75 +2,372 @@ ## 📋 Описание -**Lesson** - модуль системы обучения и развития сотрудников. Позволяет создавать обучающие курсы, уроки, тесты и отслеживать прогресс обучения персонала. +**Lesson** - полнофункциональная система корпоративного обучения и развития сотрудников в ERP24. Модуль позволяет создавать обучающие курсы, уроки, тесты с различными типами вопросов, отслеживать прогресс обучения персонала и управлять процессом адаптации новых сотрудников. ### Основные возможности -- 📚 Создание обучающих курсов и уроков -- 📝 Тесты и проверка знаний -- 👥 Назначение обучения сотрудникам -- ✅ Отслеживание прогресса -- 📊 Статистика прохождения -- 🎓 Сертификаты об окончании -- 🔔 Уведомления о новых уроках +- 📚 Создание одиночных уроков и групп уроков (курсов) +- 📝 Тесты с различными типами вопросов (закрытые и открытые) +- ✍️ Открытые вопросы с ручной проверкой администратором +- 👥 Массовое назначение обучения сотрудникам +- ✅ Отслеживание прогресса (7 статусов) +- 📊 Детальная статистика и аналитика прохождения +- ⏰ Контроль обязательных и рекомендуемых сроков +- 🔄 Управление попытками прохождения тестов +- 🔔 Автоматические уведомления сотрудникам и руководителям +- 🎯 Начисление баллов за успешное/неуспешное прохождение +- 🎲 Перемешивание вопросов (shuffle) +- 📑 Последовательный и параллельный режимы обучения в группах -## 🏗️ Архитектура +## 🏗️ Архитектура модуля -**Контроллеры:** 1 (LessonController) +```mermaid +graph TB + subgraph "Контроллеры" + LC[LessonController
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
3 метода] + LPS[LessonPollService
7 методов] + end + + subgraph "Модели" + L[Lessons
20 полей] + LG[LessonsGroup
14 полей] + LP[LessonsPoll
7 полей] + LPA[LessonPollAnswers
5 полей] + LPD[LessonsPassed
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): свободный текстовый ответ, требует ручной проверки администратором -**Урок:** -- Теоретический материал (видео, текст, презентации) -- Практические задания -- Тест для проверки -- Минимальный балл для прохождения +**Характеристики:** +- Привязан к уроку (lesson_id) +- Имеет позицию (pos) +- Картинка вопроса (lessons_image_id) +- Правильный ответ хранится в связанных 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 index 00000000..249739bd --- /dev/null +++ b/erp24/docs/modules/lesson/actions.md @@ -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' => '

Контент

', + '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 index 00000000..d9f1f85a --- /dev/null +++ b/erp24/docs/modules/lesson/examples.md @@ -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 = <<Введение в CRM +

CRM (Customer Relationship Management) - система управления взаимоотношениями с клиентами.

+

Основные функции

+ +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 = "

Техники работы с возражениями

...

"; +$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 = "

Изучаем интерфейс...

"; +$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' => '

История компании

...

', + 'min_correct_percentage' => 70, + 'success_ball' => 50, + ], + [ + 'title' => 'Правила внутреннего распорядка', + 'content' => '

Правила

...

', + 'min_correct_percentage' => 80, + 'success_ball' => 75, + ], + [ + 'title' => 'Обучение CRM', + 'content' => '

CRM система

...

', + 'min_correct_percentage' => 85, + 'success_ball' => 100, + ], + [ + 'title' => 'Техника безопасности', + 'content' => '

ТБ на рабочем месте

...

', + '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 = <<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 index 00000000..45725f18 --- /dev/null +++ b/erp24/docs/modules/lesson/faq.md @@ -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 index 00000000..a0f5091d --- /dev/null +++ b/erp24/docs/modules/lesson/models.md @@ -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 = "

Введение

CRM система позволяет...

"; +$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 index 00000000..c6e04e64 --- /dev/null +++ b/erp24/docs/modules/lesson/services.md @@ -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 index 00000000..ec88ae02 --- /dev/null +++ b/erp24/docs/modules/lesson/workflows.md @@ -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
(entity='lesson', status=0) + end + loop Для каждой группы + Edit2->>DB: Создать LessonsPassed
(entity='group', status=0) + Edit2->>DB: Создать LessonsPassed
для каждого урока группы + 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
status=ATTACHED] + G_ATTACHED --> L1[Урок 1
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
доступен] + + 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
доступен] + + L3 --> L3_SUCCESS[Урок 3: SUCCESS] + + L3_SUCCESS --> CHECK_GROUP{Все уроки
завершены?} + CHECK_GROUP -->|Да| GROUP_SUCCESS[Группа: SUCCESS
Начислены баллы] + 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
status=ATTACHED] + + G_ATTACHED --> L1[Урок 1
ДОСТУПЕН] + G_ATTACHED --> L2[Урок 2
ДОСТУПЕН] + G_ATTACHED --> L3[Урок 3
ДОСТУПЕН] + + 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: Ответить на открытый вопрос
("Опишите процесс...") + Test->>DB: Сохранить ответ в сессии/БД + Test->>DB: Обновить статус на NEED_CHECK + Test->>Employee: "Ожидает проверки администратора" + + Admin->>Check: Открыть /lesson/check-open-poll + Check->>DB: Получить ответы сотрудника + Check->>Admin: Показать форму проверки + + Admin->>Check: Выставить оценки:
Вопрос 1: Зачет
Вопрос 2: Незачет + Check->>DB: Проверить все ли зачтены + + alt Все вопросы зачтены + Check->>DB: Обновить статус на PASS_CHECK + Check->>Notif: Отправить уведомление
"Ваши ответы зачтены" + Check->>DB: Начислить баллы (success_ball) + else Есть незачтенные + Check->>DB: Обновить статус на FAIL_CHECK + Check->>Notif: Отправить уведомление
"Ответы не зачтены, попробуйте снова" + 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 +
+
+

Вопрос: text ?>

+

Ответ сотрудника:

+
+ + + + + +
+ + +
+``` + +**Шаг 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[Сохранить дату в
LessonsPassed.created] + + Record --> Wait[Сотрудник проходит...] + + Wait --> Complete[Урок завершен] + + Complete --> CheckTime{Проверка времени} + + CheckTime --> Calc[Расчет:] + Calc --> CalcFormula["elapsed = now - created
obligatory = days_for_obligatory * 24h
recommended = days_for_recommended * 24h"] + + CalcFormula --> Compare{elapsed vs deadlines} + + Compare -->|elapsed <= recommended| Bonus[✅ Отлично!
Начислить бонус] + Compare -->|recommended < elapsed <= obligatory| OK[✅ Вовремя
Без бонуса] + Compare -->|elapsed > obligatory| Penalty[❌ Просрочено
Штраф/блокировка] + + 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
PASS_CHECK| Success[Успешно] + CheckStatus -->|PASS_FAIL
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 diff --git a/erp24/docs/modules/write-offs/README.md b/erp24/docs/modules/write-offs/README.md index 89625db5..238137ee 100644 --- a/erp24/docs/modules/write-offs/README.md +++ b/erp24/docs/modules/write-offs/README.md @@ -2,62 +2,2235 @@ ## 📋 Описание -**Write-offs** - модуль учета списания товаров и продукции в магазинах. Система отслеживает все списания, причины, ответственных и интегрируется с 1С для синхронизации данных о порче, браке и других списаниях. +**Write-offs** - комплексный модуль учета списания товаров из магазинов в системе ERP24. Система обеспечивает полный цикл документирования списаний: от создания документа с причинами и фото до интеграции с 1С, а также включает планирование списаний и автоматическое формирование накладных по недостачам. ### Основные возможности -- 📊 Учет списаний по магазинам -- 🏷️ Классификация по причинам (брак, порча, пересорт и др.) -- 📅 Временной анализ списаний -- 💰 Учет сумм списаний -- 📈 Метрики и аналитика -- 🔄 Синхронизация с 1С -- 💬 Комментарии к списаниям +- 📄 Создание и управление документами списания +- 📊 Учет списаний по магазинам и причинам +- 🔄 Полная интеграция с 1С (двусторонняя синхронизация) +- 📸 Прикрепление фото и видео к каждой позиции списания +- ✅ Workflow с подтверждением и статусами +- 💰 Расчет сумм по закупочным и розничным ценам +- 📦 Автоматическое создание накладных по недостачам +- 📈 Планирование списаний и продаж +- 🗂️ Иерархический справочник причин списания +- 🔍 Проверка остатков товаров при создании -## 🏗️ Архитектура +## 🏗️ Архитектура модуля -**Контроллеры:** 1 (WriteOffsController) -- `index` - список списаний -- `comments` - комментарии к списаниям +```mermaid +graph TB + subgraph "Controllers" + WOC[WriteOffsController
2 actions] + WOEC[WriteOffsErpController
12 methods] + WWOC[WaybillWriteOffsController
2 actions] + WOCDC[WriteOffsErpCauseDictController
CRUD] + SWOPC[SalesWriteOffsPlanController
2 actions] + end -**Модели (9):** -- `WriteOffs` - основная таблица списаний -- `WriteOffsErp` - списания из ERP -- `WriteOffsProducts` - товары в списании -- `WriteOffsProductsErp` - товары из ERP -- `WriteOffsErpCauseDict` - справочник причин -- `WriteOffsMetrics` - метрики списаний + subgraph "Services" + WOS[WriteOffsService
Заглушка] + end -## 💼 Основные сущности + subgraph "Actions" + IA[IndexAction] + CA[CommentsAction] + end -**Причины списаний:** -- Брак -- Порча/Увядание -- Пересорт -- Недостача -- Бой/Повреждение -- Истечение срока годности + subgraph "Forms" + WOF[WriteOffsForm] + WOPF[WriteOffsProductsForm
с валидацией остатков] + end + + subgraph "Models/Records" + WO[WriteOffs
Списания 1C] + WOE[WriteOffsErp
ERP документы] + WOP[WriteOffsProducts] + WOPE[WriteOffsProductsErp] + WOECD[WriteOffsErpCauseDict] + WWO[WaybillWriteOffs] + WWOP[WaybillWriteOffsProducts] + SWOP[SalesWriteOffsPlan] + end + + subgraph "External Systems" + 1C[1C System] + BAL[Balances
Остатки товаров] + PRICES[PricesDynamic
Цены] + 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 ""; + } elseif ($file['type'] === 'video') { + echo ""; + } +} +``` + +--- + +```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 + +``` + +--- + +```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 ""; +``` + +--- + +### 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)