]> gitweb.erp-flowers.ru Git - erp24_rep/yii-erp24/.git/commitdiff
ERP-499 Доработка системы оцистки вложений в док списания
authorAleksey Filippov <Aleksey.Filippov@erp-flowers.ru>
Tue, 9 Dec 2025 09:33:06 +0000 (12:33 +0300)
committerAleksey Filippov <Aleksey.Filippov@erp-flowers.ru>
Tue, 9 Dec 2025 09:33:06 +0000 (12:33 +0300)
27 files changed:
CLAUDE.md
erp24/commands/WriteOffsAttachmentsController.php
erp24/config/db.php
erp24/migrations/m251209_120000_add_attachment_cleared_to_write_offs_erp.php [new file with mode: 0644]
erp24/migrations/m251209_130000_add_attachment_cleared_at_to_write_offs_erp.php [new file with mode: 0644]
erp24/php_skills/01-php-basics.md [new file with mode: 0644]
erp24/php_skills/02-php-naming.md [new file with mode: 0644]
erp24/php_skills/03-php-methods.md [new file with mode: 0644]
erp24/php_skills/04-php-classes.md [new file with mode: 0644]
erp24/php_skills/05-php-collections.md [new file with mode: 0644]
erp24/php_skills/06-php-strings.md [new file with mode: 0644]
erp24/php_skills/07-php-flow-control.md [new file with mode: 0644]
erp24/php_skills/08-php-exceptions.md [new file with mode: 0644]
erp24/php_skills/09-php-closures.md [new file with mode: 0644]
erp24/php_skills/10-yii2-structure.md [new file with mode: 0644]
erp24/php_skills/11-yii2-models.md [new file with mode: 0644]
erp24/php_skills/12-yii2-controllers.md [new file with mode: 0644]
erp24/php_skills/13-yii2-views.md [new file with mode: 0644]
erp24/php_skills/14-yii2-routing.md [new file with mode: 0644]
erp24/php_skills/15-yii2-migrations.md [new file with mode: 0644]
erp24/php_skills/16-yii2-testing.md [new file with mode: 0644]
erp24/php_skills/17-yii2-security.md [new file with mode: 0644]
erp24/php_skills/18-yii2-performance.md [new file with mode: 0644]
erp24/php_skills/19-yii2-api.md [new file with mode: 0644]
erp24/php_skills/20-yii2-widgets.md [new file with mode: 0644]
erp24/php_skills/README.md [new file with mode: 0644]
erp24/records/WriteOffsErp.php

index 0ef08c6318f3f78d6f81e273581ad3f2e134fed0..588ec794aec4096d9542f205c7502eea902113b5 100644 (file)
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -329,6 +329,71 @@ erp24/docs/
 - сверять новые файлы с `erp24/docs/*`
 - предупреждать о конфликтующих материалах
 
+
+Вывод: готовая инструкция для разработки.
+
+## 
+Задачи:
+- проверять отсутствие дублирования
+- сверять новые файлы с `erp24/docs/*`
+- предупреждать о конфликтующих материалах
+
+---
+
+# === PHP & YII2 STYLE GUIDE (SKILLS) ===
+
+При написании и анализе PHP-кода для проекта ERP24 необходимо использовать следующие гайдлайны, расположенные в `erp24/php_skills/`:
+
+## Основы PHP
+
+| Файл | Описание |
+|------|----------|
+| [01-php-basics.md](erp24/php_skills/01-php-basics.md) | Базовые правила форматирования и синтаксиса |
+| [02-php-naming.md](erp24/php_skills/02-php-naming.md) | Соглашения об именовании переменных, классов, методов |
+| [03-php-methods.md](erp24/php_skills/03-php-methods.md) | Методы и функции: сигнатуры, возвращаемые типы |
+| [04-php-classes.md](erp24/php_skills/04-php-classes.md) | Классы, интерфейсы, трейты, абстракции |
+| [05-php-collections.md](erp24/php_skills/05-php-collections.md) | Работа с массивами и коллекциями |
+| [06-php-strings.md](erp24/php_skills/06-php-strings.md) | Работа со строками |
+| [07-php-flow-control.md](erp24/php_skills/07-php-flow-control.md) | Управление потоком выполнения (if, switch, loops) |
+| [08-php-exceptions.md](erp24/php_skills/08-php-exceptions.md) | Обработка исключений |
+| [09-php-closures.md](erp24/php_skills/09-php-closures.md) | Замыкания и колбэки |
+
+## Yii2-специфичные правила
+
+| Файл | Описание |
+|------|----------|
+| [10-yii2-structure.md](erp24/php_skills/10-yii2-structure.md) | Структура Yii2 приложения |
+| [11-yii2-models.md](erp24/php_skills/11-yii2-models.md) | Модели и ActiveRecord |
+| [12-yii2-controllers.md](erp24/php_skills/12-yii2-controllers.md) | Контроллеры и actions |
+| [13-yii2-views.md](erp24/php_skills/13-yii2-views.md) | Представления и шаблоны |
+| [14-yii2-routing.md](erp24/php_skills/14-yii2-routing.md) | Маршрутизация |
+| [15-yii2-migrations.md](erp24/php_skills/15-yii2-migrations.md) | Миграции базы данных |
+| [16-yii2-testing.md](erp24/php_skills/16-yii2-testing.md) | Тестирование |
+| [17-yii2-security.md](erp24/php_skills/17-yii2-security.md) | Безопасность |
+| [18-yii2-performance.md](erp24/php_skills/18-yii2-performance.md) | Производительность и оптимизация |
+| [19-yii2-api.md](erp24/php_skills/19-yii2-api.md) | REST API разработка |
+| [20-yii2-widgets.md](erp24/php_skills/20-yii2-widgets.md) | Виджеты и компоненты |
+
+## Правила применения Skills
+
+1. **При написании нового кода** — обязательно сверяться с соответствующим гайдлайном
+2. **При code review** — проверять соответствие кода описанным стандартам
+3. **При рефакторинге** — приводить код в соответствие с гайдлайнами
+4. **При документировании** — использовать примеры из skills как образцы
+
+## Стандарты
+
+Гайдлайны основаны на:
+- **PSR-1**: Basic Coding Standard
+- **PSR-4**: Autoloading Standard
+- **PSR-12**: Extended Coding Style Guide
+- **Yii2 Coding Standards**
+
+## Версии технологий
+
+- **PHP**: 8.1+
+- **Yii2**: 2.0.45+
+
 ---
 
 # === END OF CLAUDE.md ===
index a81a043a0c2c2278d74d95f4bd51ed2c5efc3cf7..089161fff44509c1a1edab8d20c9d164f1775ebb 100644 (file)
@@ -75,4 +75,106 @@ class WriteOffsAttachmentsController extends Controller
         $this->stdout($json . "\n");
         return ExitCode::OK;
     }
+
+    /**
+     * Проверяет вложения документов списания и помечает документы с недоступными файлами.
+     * Если все вложения документа недоступны на диске, устанавливает attachment_cleared = 1.
+     *
+     * Пример запуска: php yii write-offs-attachments/check-missing
+     * С флагом --dry-run только выводит информацию без изменений в БД.
+     *
+     * @param bool $dryRun Режим тестового запуска (без изменений в БД)
+     * @return int
+     */
+    public function actionCheckMissing(bool $dryRun = false): int
+    {
+        $this->stdout("Проверка вложений документов списания...\n");
+
+        if ($dryRun) {
+            $this->stdout("Режим dry-run: изменения в БД не будут сохранены\n\n");
+        }
+
+        $docs = WriteOffsErp::find()
+            ->andWhere(['status' => WriteOffsErp::STATUS_CREATED_1C])
+            ->andWhere(['attachment_cleared' => 0])
+            ->all();
+
+        $this->stdout("Найдено документов для проверки: " . count($docs) . "\n\n");
+
+        $markedCount = 0;
+        $checkedCount = 0;
+
+        foreach ($docs as $doc) {
+            $attachments = $doc->getAttachments();
+
+            if (empty($attachments)) {
+                continue;
+            }
+
+            $checkedCount++;
+            $allMissing = true;
+            $missingFiles = [];
+            $existingFiles = [];
+
+            foreach ($attachments as $attachment) {
+                $url = $attachment['url'] ?? null;
+                if (empty($url)) {
+                    continue;
+                }
+
+                $filePath = $this->getFilePathFromUrl($url);
+
+                if ($filePath && file_exists($filePath)) {
+                    $allMissing = false;
+                    $existingFiles[] = $url;
+                } else {
+                    $missingFiles[] = $url;
+                }
+            }
+
+            if ($allMissing && !empty($missingFiles)) {
+                $markedCount++;
+                $this->stdout("Документ #{$doc->id} ({$doc->number}): все вложения недоступны\n");
+                $this->stdout("  Недоступные файлы: " . count($missingFiles) . "\n");
+
+                if (!$dryRun) {
+                    $doc->attachment_cleared = 1;
+                    $doc->attachment_cleared_at = date('Y-m-d H:i:s');
+                    if (!$doc->save(false, ['attachment_cleared', 'attachment_cleared_at'])) {
+                        $this->stderr("  Ошибка сохранения: " . json_encode($doc->getErrors(), JSON_UNESCAPED_UNICODE) . "\n");
+                    } else {
+                        $this->stdout("  Установлен attachment_cleared = 1, attachment_cleared_at = {$doc->attachment_cleared_at}\n");
+                    }
+                }
+            }
+        }
+
+        $this->stdout("\n--- Итоги ---\n");
+        $this->stdout("Проверено документов с вложениями: {$checkedCount}\n");
+        $this->stdout("Помечено документов (attachment_cleared=1): {$markedCount}\n");
+
+        if ($dryRun && $markedCount > 0) {
+            $this->stdout("\nДля применения изменений запустите без --dry-run\n");
+        }
+
+        return ExitCode::OK;
+    }
+
+    /**
+     * Преобразует URL вложения в абсолютный путь к файлу на диске.
+     *
+     * @param string $url URL вида /uploads/images/xx/filename.jpg
+     * @return string|null Абсолютный путь к файлу или null
+     */
+    private function getFilePathFromUrl(string $url): ?string
+    {
+        if (strpos($url, '/uploads/') === 0) {
+            $relativePath = substr($url, 1);
+            $basePath = Yii::getAlias('@app');
+            $filePath = $basePath . '/' . $relativePath;
+            return $filePath;
+        }
+
+        return null;
+    }
 }
index 0e54fdd6b1994bc6065c579ef9419353f6877b7f..97c9d70072a493bd97f402c13225e301ebc17327 100644 (file)
@@ -15,7 +15,7 @@ return 1 == 1 ? [
             'defaultSchema' => 'erp24' //specify your schema here, public is the default schema
         ]
     ],
-   // 'on afterOpen' => function($event) { $event->sender->createCommand("SET search_path TO public, erp24;")->execute(); },
+    'on afterOpen' => function($event) { $event->sender->createCommand("SET search_path TO public, erp24;")->execute(); },
     // PostgreSQL
     'charset' => 'utf8',
     'enableSchemaCache' => true,
diff --git a/erp24/migrations/m251209_120000_add_attachment_cleared_to_write_offs_erp.php b/erp24/migrations/m251209_120000_add_attachment_cleared_to_write_offs_erp.php
new file mode 100644 (file)
index 0000000..6f4a8da
--- /dev/null
@@ -0,0 +1,49 @@
+<?php
+
+use yii\db\Migration;
+
+class m251209_120000_add_attachment_cleared_to_write_offs_erp extends Migration
+{
+    const TABLE_NAME = 'erp24.write_offs_erp';
+
+    /**
+     * {@inheritdoc}
+     */
+    public function safeUp()
+    {
+        $table = $this->db->schema->getTableSchema(self::TABLE_NAME);
+        if ($table === null) {
+            return;
+        }
+
+        if (!$this->db->schema->getTableSchema(self::TABLE_NAME, true)->getColumn('attachment_cleared')) {
+            $this->addColumn(
+                self::TABLE_NAME,
+                'attachment_cleared',
+                $this->smallInteger()->notNull()->defaultValue(0)->comment('Флаг очистки вложений')
+            );
+        }
+
+        if (!$this->db->schema->getTableSchema(self::TABLE_NAME, true)->getColumn('attachment_cleared_at')) {
+            $this->addColumn(
+                self::TABLE_NAME,
+                'attachment_cleared_at',
+                $this->timestamp()->null()->comment('Дата очистки вложений')
+            );
+        }
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function safeDown()
+    {
+        if ($this->db->schema->getTableSchema(self::TABLE_NAME, true)->getColumn('attachment_cleared_at')) {
+            $this->dropColumn(self::TABLE_NAME, 'attachment_cleared_at');
+        }
+
+        if ($this->db->schema->getTableSchema(self::TABLE_NAME, true)->getColumn('attachment_cleared')) {
+            $this->dropColumn(self::TABLE_NAME, 'attachment_cleared');
+        }
+    }
+}
diff --git a/erp24/migrations/m251209_130000_add_attachment_cleared_at_to_write_offs_erp.php b/erp24/migrations/m251209_130000_add_attachment_cleared_at_to_write_offs_erp.php
new file mode 100644 (file)
index 0000000..e00a9b2
--- /dev/null
@@ -0,0 +1,37 @@
+<?php
+
+use yii\db\Migration;
+
+class m251209_130000_add_attachment_cleared_at_to_write_offs_erp extends Migration
+{
+    const TABLE_NAME = 'erp24.write_offs_erp';
+
+    /**
+     * {@inheritdoc}
+     */
+    public function safeUp()
+    {
+        $table = $this->db->schema->getTableSchema(self::TABLE_NAME);
+        if ($table === null) {
+            return;
+        }
+
+        if (!$this->db->schema->getTableSchema(self::TABLE_NAME, true)->getColumn('attachment_cleared_at')) {
+            $this->addColumn(
+                self::TABLE_NAME,
+                'attachment_cleared_at',
+                $this->timestamp()->null()->comment('Дата очистки вложений')
+            );
+        }
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function safeDown()
+    {
+        if ($this->db->schema->getTableSchema(self::TABLE_NAME, true)->getColumn('attachment_cleared_at')) {
+            $this->dropColumn(self::TABLE_NAME, 'attachment_cleared_at');
+        }
+    }
+}
diff --git a/erp24/php_skills/01-php-basics.md b/erp24/php_skills/01-php-basics.md
new file mode 100644 (file)
index 0000000..17cdd4d
--- /dev/null
@@ -0,0 +1,462 @@
+# PHP Basics - Основы форматирования и синтаксиса
+
+## Кодировка и форматирование
+
+### Кодировка файлов
+```php
+<?php
+// Все файлы должны использовать UTF-8 без BOM
+// Объявление строгой типизации в начале файла
+declare(strict_types=1);
+```
+
+### Теги PHP
+```php
+<?php
+// ПРАВИЛЬНО - полный тег <?php
+
+// НЕПРАВИЛЬНО - короткий тег
+<? echo 'text'; ?>
+
+// ПРАВИЛЬНО для шаблонов - короткий echo тег
+<?= $variable ?>
+
+// НЕПРАВИЛЬНО - закрывающий тег в конце файла с чистым PHP
+?>
+```
+
+### Отступы
+```php
+<?php
+// ПРАВИЛЬНО - 4 пробела
+class User
+{
+    public function getName(): string
+    {
+        return $this->name;
+    }
+}
+
+// НЕПРАВИЛЬНО - 2 пробела или табы
+class User
+{
+  public function getName(): string
+  {
+    return $this->name;
+  }
+}
+```
+
+### Максимальная длина строки
+- **120 символов** - мягкое ограничение PSR-12
+- **80 символов** - рекомендуемая длина для читаемости
+- Настройте редактор для визуализации границы
+
+### Окончания строк
+- Используйте Unix-style (LF)
+- Каждый файл должен заканчиваться одной пустой строкой
+- Нет пробелов в конце строк
+
+## Пробелы и операторы
+
+### Вокруг операторов
+```php
+<?php
+// ПРАВИЛЬНО
+$sum = 1 + 2;
+$a = 1;
+$isEqual = ($a === $b);
+
+// НЕПРАВИЛЬНО
+$sum=1+2;
+$a=1;
+```
+
+### Унарные операторы
+```php
+<?php
+// ПРАВИЛЬНО - без пробела
+$i++;
+--$count;
+$result = !$flag;
+
+// НЕПРАВИЛЬНО
+$i ++;
+-- $count;
+```
+
+### Операторы конкатенации
+```php
+<?php
+// ПРАВИЛЬНО - пробелы вокруг точки
+$fullName = $firstName . ' ' . $lastName;
+
+// НЕПРАВИЛЬНО
+$fullName = $firstName.' '.$lastName;
+```
+
+### Тернарный оператор
+```php
+<?php
+// ПРАВИЛЬНО - пробелы вокруг ? и :
+$status = $isActive ? 'active' : 'inactive';
+
+// Для многострочных выражений
+$status = $isActive
+    ? 'active'
+    : 'inactive';
+```
+
+## Скобки и фигурные скобки
+
+### Управляющие конструкции
+```php
+<?php
+// ПРАВИЛЬНО - скобка на новой строке для классов/методов (Allman style)
+class User
+{
+    public function save(): bool
+    {
+        if ($this->validate()) {
+            // код
+        }
+
+        return true;
+    }
+}
+
+// ПРАВИЛЬНО для if/for/while - скобка на той же строке (K&R style)
+if ($condition) {
+    // код
+} elseif ($anotherCondition) {
+    // код
+} else {
+    // код
+}
+
+for ($i = 0; $i < 10; $i++) {
+    // код
+}
+
+while ($condition) {
+    // код
+}
+
+foreach ($items as $key => $value) {
+    // код
+}
+```
+
+### Вызов функций и методов
+```php
+<?php
+// ПРАВИЛЬНО - без пробела перед скобкой
+$result = someFunction($arg1, $arg2);
+$user->getName();
+
+// НЕПРАВИЛЬНО
+$result = someFunction ($arg1, $arg2);
+```
+
+### Массивы
+```php
+<?php
+// ПРАВИЛЬНО - короткий синтаксис
+$array = ['one', 'two', 'three'];
+
+$associative = [
+    'key1' => 'value1',
+    'key2' => 'value2',
+];
+
+// НЕПРАВИЛЬНО - длинный синтаксис (устаревший)
+$array = array('one', 'two', 'three');
+```
+
+## Пустые строки
+
+### Между методами
+```php
+<?php
+class User
+{
+    public function getName(): string
+    {
+        return $this->name;
+    }
+
+    public function setName(string $name): void
+    {
+        $this->name = $name;
+    }
+}
+```
+
+### После use statements
+```php
+<?php
+
+namespace App\Models;
+
+use Yii;
+use yii\db\ActiveRecord;
+use yii\behaviors\TimestampBehavior;
+
+class User extends ActiveRecord
+{
+    // код
+}
+```
+
+### Группировка use statements
+```php
+<?php
+
+namespace App\Controllers;
+
+// Встроенные PHP классы
+use DateTime;
+use RuntimeException;
+
+// Фреймворк
+use yii\web\Controller;
+use yii\web\Response;
+
+// Приложение
+use app\models\User;
+use app\services\AuthService;
+```
+
+## Комментарии
+
+### PHPDoc комментарии
+```php
+<?php
+/**
+ * Класс для работы с пользователями.
+ *
+ * Предоставляет методы для создания, обновления и удаления пользователей.
+ *
+ * @author John Doe <john@example.com>
+ * @since 1.0
+ */
+class User extends ActiveRecord
+{
+    /**
+     * Находит пользователя по email.
+     *
+     * @param string $email Email адрес для поиска
+     * @return User|null Найденный пользователь или null
+     * @throws InvalidArgumentException если email невалидный
+     */
+    public static function findByEmail(string $email): ?User
+    {
+        // код
+    }
+}
+```
+
+### Однострочные комментарии
+```php
+<?php
+// ПРАВИЛЬНО - пробел после //
+// Это комментарий
+$value = 10; // Inline комментарий
+
+// НЕПРАВИЛЬНО
+//Это комментарий
+$value = 10; //Inline комментарий
+
+# Не используйте # для комментариев
+```
+
+### TODO и FIXME
+```php
+<?php
+// TODO: добавить валидацию email
+// FIXME: исправить утечку памяти при больших выборках
+// HACK: временное решение до рефакторинга
+// NOTE: важное замечание о бизнес-логике
+```
+
+## Объявление типов (PHP 8+)
+
+### Типы параметров
+```php
+<?php
+// ПРАВИЛЬНО - строгая типизация
+declare(strict_types=1);
+
+function processUser(User $user, int $limit = 10): array
+{
+    // код
+}
+
+// Union types (PHP 8.0+)
+function process(int|string $value): void
+{
+    // код
+}
+
+// Nullable типы
+function findUser(int $id): ?User
+{
+    return User::findOne($id);
+}
+
+// Intersection types (PHP 8.1+)
+function handle(Countable&Iterator $collection): void
+{
+    // код
+}
+```
+
+### Типизированные свойства
+```php
+<?php
+class User
+{
+    public int $id;
+    public string $name;
+    public ?string $email = null;
+    public readonly string $createdAt; // PHP 8.1+
+
+    // PHP 8.2+ - свойства только для чтения в конструкторе
+    public function __construct(
+        public readonly int $id,
+        public readonly string $name,
+    ) {}
+}
+```
+
+## Константы
+
+### Константы класса
+```php
+<?php
+class Order
+{
+    // ПРАВИЛЬНО - SCREAMING_SNAKE_CASE
+    public const STATUS_PENDING = 'pending';
+    public const STATUS_COMPLETED = 'completed';
+    public const MAX_ITEMS = 100;
+
+    // PHP 8.1+ - final константы
+    final public const VERSION = '1.0';
+
+    // PHP 8.1+ - enum вместо констант
+}
+
+// Enum (PHP 8.1+)
+enum OrderStatus: string
+{
+    case Pending = 'pending';
+    case Completed = 'completed';
+    case Cancelled = 'cancelled';
+}
+```
+
+## Атрибуты (PHP 8.0+)
+
+```php
+<?php
+use Attribute;
+
+#[Attribute]
+class Route
+{
+    public function __construct(
+        public string $path,
+        public string $method = 'GET',
+    ) {}
+}
+
+class UserController
+{
+    #[Route('/users', 'GET')]
+    public function index(): array
+    {
+        // код
+    }
+
+    #[Route('/users/{id}', 'GET')]
+    #[Cache(ttl: 3600)]
+    public function show(int $id): User
+    {
+        // код
+    }
+}
+```
+
+## Именованные аргументы (PHP 8.0+)
+
+```php
+<?php
+// ПРАВИЛЬНО - именованные аргументы для ясности
+$user = new User(
+    name: 'John',
+    email: 'john@example.com',
+    isActive: true,
+);
+
+// Полезно когда много опциональных параметров
+htmlspecialchars(
+    string: $text,
+    flags: ENT_QUOTES,
+    encoding: 'UTF-8',
+);
+
+// НЕПРАВИЛЬНО - смешивание позиционных и именованных после именованного
+// someFunction($value, name: 'test', $another); // Ошибка!
+```
+
+## Match выражение (PHP 8.0+)
+
+```php
+<?php
+// ПРАВИЛЬНО - match вместо switch для возврата значений
+$statusText = match ($status) {
+    'pending' => 'В ожидании',
+    'active' => 'Активен',
+    'cancelled', 'deleted' => 'Недоступен',
+    default => 'Неизвестный статус',
+};
+
+// НЕПРАВИЛЬНО - громоздкий switch
+$statusText = '';
+switch ($status) {
+    case 'pending':
+        $statusText = 'В ожидании';
+        break;
+    case 'active':
+        $statusText = 'Активен';
+        break;
+    // ...
+}
+```
+
+## Nullsafe оператор (PHP 8.0+)
+
+```php
+<?php
+// ПРАВИЛЬНО - nullsafe оператор
+$city = $user?->getAddress()?->getCity();
+
+// НЕПРАВИЛЬНО - множественные проверки
+$city = null;
+if ($user !== null) {
+    $address = $user->getAddress();
+    if ($address !== null) {
+        $city = $address->getCity();
+    }
+}
+```
+
+## Рекомендации для Claude Code
+
+1. **Всегда используйте `declare(strict_types=1);`** в начале файлов
+2. **Указывайте типы** для всех параметров, возвращаемых значений и свойств
+3. **Используйте PSR-12** как основу форматирования
+4. **Не смешивайте** табы и пробелы
+5. **Используйте автоформатирование** через PHP-CS-Fixer
+6. **Предпочитайте** новый синтаксис PHP 8+ где возможно
diff --git a/erp24/php_skills/02-php-naming.md b/erp24/php_skills/02-php-naming.md
new file mode 100644 (file)
index 0000000..8f4e71d
--- /dev/null
@@ -0,0 +1,557 @@
+# PHP Naming - Соглашения об именовании
+
+## Общие правила
+
+### Язык идентификаторов
+```php
+<?php
+// ПРАВИЛЬНО - английский язык
+$salary = 1000;
+$userName = 'John';
+
+// НЕПРАВИЛЬНО - транслитерация или кириллица
+$zaplata = 1000;
+// $заплата = 1000; // вызовет ошибку
+```
+
+## Стили именования
+
+### camelCase для методов и свойств
+```php
+<?php
+class User
+{
+    // ПРАВИЛЬНО - camelCase для свойств
+    public string $firstName;
+    public string $lastName;
+    private int $accessLevel;
+
+    // ПРАВИЛЬНО - camelCase для методов
+    public function getFullName(): string
+    {
+        return $this->firstName . ' ' . $this->lastName;
+    }
+
+    public function setAccessLevel(int $level): void
+    {
+        $this->accessLevel = $level;
+    }
+}
+
+// НЕПРАВИЛЬНО - snake_case для методов/свойств
+class User
+{
+    public string $first_name;
+
+    public function get_full_name(): string
+    {
+        // ...
+    }
+}
+```
+
+### PascalCase для классов и интерфейсов
+```php
+<?php
+// ПРАВИЛЬНО - PascalCase
+class UserAccount
+{
+    // код
+}
+
+interface Authenticatable
+{
+    // код
+}
+
+trait HasTimestamps
+{
+    // код
+}
+
+// Аббревиатуры - как обычные слова
+class HttpClient  // не HTTPClient
+{
+    // код
+}
+
+class XmlParser  // не XMLParser
+{
+    // код
+}
+
+// НЕПРАВИЛЬНО
+class user_account
+{
+}
+
+class userAccount
+{
+}
+```
+
+### SCREAMING_SNAKE_CASE для констант
+```php
+<?php
+class Config
+{
+    // ПРАВИЛЬНО
+    public const MAX_LOGIN_ATTEMPTS = 5;
+    public const API_VERSION = 'v1';
+    public const DEFAULT_TIMEOUT = 30;
+
+    // НЕПРАВИЛЬНО
+    public const maxLoginAttempts = 5;
+    public const api_version = 'v1';
+}
+
+// Глобальные константы (избегайте)
+define('APP_DEBUG', true);
+```
+
+### snake_case для функций (вне классов)
+```php
+<?php
+// В Yii2 и PHP принято для helper-функций
+function array_key_exists_recursive(array $array, string $key): bool
+{
+    // код
+}
+
+// Но для автозагружаемых классов лучше методы
+class ArrayHelper
+{
+    public static function keyExistsRecursive(array $array, string $key): bool
+    {
+        // код
+    }
+}
+```
+
+## Файлы и директории
+
+### Имена файлов классов
+```php
+<?php
+// ПРАВИЛЬНО - совпадает с именем класса
+// Файл: User.php
+class User
+{
+}
+
+// Файл: UserController.php
+class UserController
+{
+}
+
+// Файл: UserService.php
+class UserService
+{
+}
+
+// НЕПРАВИЛЬНО
+// Файл: user.php или user_controller.php
+```
+
+### Структура директорий (PSR-4)
+```
+// ПРАВИЛЬНО
+src/
+├── Controllers/
+│   └── UserController.php
+├── Models/
+│   └── User.php
+├── Services/
+│   └── UserService.php
+└── Helpers/
+    └── StringHelper.php
+
+// Пространство имён соответствует директории
+namespace App\Controllers;
+
+class UserController
+{
+}
+```
+
+### Соответствие имени файла и namespace
+```php
+<?php
+// Файл: app/models/User.php
+namespace app\models;
+
+class User extends \yii\db\ActiveRecord
+{
+    // код
+}
+
+// Файл: app/services/user/RegistrationService.php
+namespace app\services\user;
+
+class RegistrationService
+{
+    // код
+}
+```
+
+## Методы
+
+### Предикаты (возвращают boolean)
+```php
+<?php
+class User
+{
+    // ПРАВИЛЬНО - префиксы is/has/can/should
+    public function isActive(): bool
+    {
+        return $this->status === self::STATUS_ACTIVE;
+    }
+
+    public function hasPermission(string $permission): bool
+    {
+        return in_array($permission, $this->permissions, true);
+    }
+
+    public function canEdit(): bool
+    {
+        return $this->role === 'admin';
+    }
+
+    public function shouldNotify(): bool
+    {
+        return $this->notificationsEnabled;
+    }
+
+    // НЕПРАВИЛЬНО
+    public function active(): bool // неясно, что это предикат
+    {
+        // ...
+    }
+
+    public function checkPermission(): bool // check не говорит о возврате bool
+    {
+        // ...
+    }
+}
+```
+
+### Геттеры и сеттеры
+```php
+<?php
+class Product
+{
+    private float $price;
+    private int $quantity;
+
+    // ПРАВИЛЬНО - стандартные get/set
+    public function getPrice(): float
+    {
+        return $this->price;
+    }
+
+    public function setPrice(float $price): void
+    {
+        $this->price = $price;
+    }
+
+    // Fluent интерфейс - возвращаем $this
+    public function setQuantity(int $quantity): self
+    {
+        $this->quantity = $quantity;
+        return $this;
+    }
+
+    // PHP 8 - можно использовать promoted properties
+    public function __construct(
+        private float $price,
+        private int $quantity = 0,
+    ) {}
+}
+```
+
+### Действия и команды
+```php
+<?php
+class OrderService
+{
+    // ПРАВИЛЬНО - глаголы для действий
+    public function create(array $data): Order
+    {
+        // создание
+    }
+
+    public function update(Order $order, array $data): Order
+    {
+        // обновление
+    }
+
+    public function delete(Order $order): void
+    {
+        // удаление
+    }
+
+    public function process(Order $order): void
+    {
+        // обработка
+    }
+
+    public function calculateTotal(Order $order): float
+    {
+        // вычисление
+    }
+
+    // НЕПРАВИЛЬНО - существительные для действий
+    public function orderCreation(array $data): Order
+    {
+        // ...
+    }
+}
+```
+
+## Переменные
+
+### Локальные переменные
+```php
+<?php
+function calculateTotal(array $items): float
+{
+    // ПРАВИЛЬНО - camelCase, описательные имена
+    $subtotal = 0.0;
+    $taxAmount = 0.0;
+    $discountPercent = 10;
+
+    foreach ($items as $item) {
+        $itemPrice = $item->getPrice();
+        $subtotal += $itemPrice;
+    }
+
+    // ПРАВИЛЬНО для счётчиков/итераторов - короткие имена
+    for ($i = 0; $i < count($items); $i++) {
+        // ...
+    }
+
+    // НЕПРАВИЛЬНО
+    $s = 0.0;  // неясно что это
+    $sub_total = 0.0;  // snake_case
+}
+```
+
+### Переменные в циклах
+```php
+<?php
+// ПРАВИЛЬНО - описательные имена для коллекций
+foreach ($users as $user) {
+    echo $user->getName();
+}
+
+foreach ($orderItems as $item) {
+    // ...
+}
+
+// ПРАВИЛЬНО - key => value
+foreach ($settings as $key => $value) {
+    // ...
+}
+
+foreach ($users as $userId => $userData) {
+    // ...
+}
+
+// Неиспользуемые переменные - с подчёркиванием (соглашение)
+foreach ($items as $_ => $item) {
+    // используем только $item
+}
+```
+
+## Параметры методов
+
+```php
+<?php
+class UserService
+{
+    // ПРАВИЛЬНО - описательные имена параметров
+    public function createUser(
+        string $email,
+        string $password,
+        string $firstName,
+        string $lastName,
+        bool $isAdmin = false,
+    ): User {
+        // ...
+    }
+
+    // PHP 8+ - именованные аргументы делают код чище
+    // Вызов: createUser(email: 'test@test.com', ...)
+
+    // НЕПРАВИЛЬНО - однобуквенные или неясные имена
+    public function createUser(string $e, string $p, string $f, string $l): User
+    {
+        // ...
+    }
+}
+```
+
+## Специальные случаи
+
+### Приватные/protected свойства
+```php
+<?php
+class User
+{
+    // Yii2 стиль - без префикса для private/protected
+    private string $password;
+    protected int $accessLevel;
+
+    // Некоторые стандарты используют _ префикс (избегайте в новом коде)
+    // private string $_password; // устаревший стиль
+}
+```
+
+### Статические свойства и методы
+```php
+<?php
+class Logger
+{
+    // Статические свойства - camelCase
+    private static array $instances = [];
+
+    // Статические методы - camelCase
+    public static function getInstance(string $channel): self
+    {
+        // ...
+    }
+
+    // Фабричные методы - create/from/make префиксы
+    public static function createFromArray(array $config): self
+    {
+        // ...
+    }
+
+    public static function fromJson(string $json): self
+    {
+        // ...
+    }
+}
+```
+
+### Интерфейсы и трейты
+```php
+<?php
+// Интерфейсы - *able, *Interface суффиксы
+interface Authenticatable
+{
+    public function authenticate(): bool;
+}
+
+interface UserRepositoryInterface
+{
+    public function findById(int $id): ?User;
+}
+
+// Трейты - *Trait суффикс или описательное имя
+trait HasTimestamps
+{
+    public int $createdAt;
+    public int $updatedAt;
+}
+
+trait LoggerAwareTrait
+{
+    protected ?LoggerInterface $logger = null;
+}
+```
+
+### Exceptions
+```php
+<?php
+// ПРАВИЛЬНО - *Exception суффикс
+class UserNotFoundException extends \Exception
+{
+}
+
+class InvalidCredentialsException extends \Exception
+{
+}
+
+class ValidationException extends \Exception
+{
+}
+
+// НЕПРАВИЛЬНО
+class UserNotFound extends \Exception
+{
+}
+```
+
+## Yii2-специфичные соглашения
+
+### Модели ActiveRecord
+```php
+<?php
+// ПРАВИЛЬНО - единственное число, PascalCase
+class User extends ActiveRecord
+{
+    public static function tableName(): string
+    {
+        return '{{%user}}';  // таблица users или user
+    }
+}
+
+class OrderItem extends ActiveRecord
+{
+    public static function tableName(): string
+    {
+        return '{{%order_item}}';  // таблица order_items
+    }
+}
+```
+
+### Контроллеры
+```php
+<?php
+// ПРАВИЛЬНО - *Controller суффикс
+class UserController extends Controller
+{
+    // Actions - actionХХХ
+    public function actionIndex(): string
+    {
+        // ...
+    }
+
+    public function actionView(int $id): string
+    {
+        // ...
+    }
+
+    public function actionCreate(): string|Response
+    {
+        // ...
+    }
+}
+```
+
+### Виджеты
+```php
+<?php
+// ПРАВИЛЬНО - описательные имена с Widget суффиксом (опционально)
+class UserCard extends Widget
+{
+}
+
+class DataTable extends Widget
+{
+}
+
+class LoginForm extends Widget
+{
+}
+```
+
+## Рекомендации для Claude Code
+
+1. **Консистентность** - придерживайтесь одного стиля во всём проекте
+2. **Описательность** - имена должны чётко описывать назначение
+3. **Длина** - избегайте слишком коротких (`$a`, `$x`) и слишком длинных имён
+4. **Контекст** - учитывайте контекст использования при выборе имени
+5. **PSR-12** - следуйте PSR-12 для новых проектов
+6. **Yii2 стиль** - в Yii2 проектах следуйте конвенциям фреймворка
diff --git a/erp24/php_skills/03-php-methods.md b/erp24/php_skills/03-php-methods.md
new file mode 100644 (file)
index 0000000..eb7e7a3
--- /dev/null
@@ -0,0 +1,610 @@
+# PHP Methods - Методы и функции
+
+## Определение методов
+
+### Сигнатура метода
+```php
+<?php
+declare(strict_types=1);
+
+class UserService
+{
+    // ПРАВИЛЬНО - полная типизация
+    public function createUser(string $email, string $password): User
+    {
+        // код
+    }
+
+    // С nullable типами
+    public function findUser(int $id): ?User
+    {
+        return User::findOne($id);
+    }
+
+    // С union types (PHP 8.0+)
+    public function process(int|string $value): int|float
+    {
+        // код
+    }
+
+    // Void для методов без возврата
+    public function delete(User $user): void
+    {
+        $user->delete();
+    }
+}
+```
+
+### Скобки фигурные - стиль размещения
+```php
+<?php
+class Calculator
+{
+    // ПРАВИЛЬНО (PSR-12) - открывающая скобка на новой строке для методов
+    public function add(int $a, int $b): int
+    {
+        return $a + $b;
+    }
+
+    // Многострочная сигнатура
+    public function complexOperation(
+        int $firstParameter,
+        string $secondParameter,
+        ?array $optionalParameter = null,
+    ): array {
+        // код
+    }
+}
+```
+
+### Длина методов
+```php
+<?php
+class OrderProcessor
+{
+    // ПРАВИЛЬНО - короткие методы (10-20 строк максимум)
+    public function process(Order $order): Result
+    {
+        $this->validate($order);
+        $this->calculateTotals($order);
+        $this->applyDiscounts($order);
+        $this->reserveInventory($order);
+
+        return $this->save($order);
+    }
+
+    // НЕПРАВИЛЬНО - слишком длинный метод
+    public function processOrder(Order $order): Result
+    {
+        // 100+ строк кода
+        // Разбейте на несколько методов
+    }
+}
+```
+
+### Однострочные методы
+```php
+<?php
+class User
+{
+    // ПРАВИЛЬНО - простые геттеры
+    public function getName(): string
+    {
+        return $this->name;
+    }
+
+    public function isActive(): bool
+    {
+        return $this->status === self::STATUS_ACTIVE;
+    }
+
+    // НЕПРАВИЛЬНО - несколько выражений в одной строке
+    public function process(): void { $this->validate(); $this->save(); }
+}
+```
+
+## Параметры методов
+
+### Порядок параметров
+```php
+<?php
+class Mailer
+{
+    // ПРАВИЛЬНО - обязательные первыми, опциональные в конце
+    public function send(
+        string $to,
+        string $subject,
+        string $body,
+        ?string $from = null,
+        array $attachments = [],
+    ): bool {
+        // код
+    }
+
+    // НЕПРАВИЛЬНО - опциональные перед обязательными
+    public function send(
+        ?string $from = null,  // опциональный
+        string $to,            // обязательный - ошибка!
+        string $subject,
+    ): bool {
+        // код
+    }
+}
+```
+
+### Максимальное количество параметров
+```php
+<?php
+class UserCreator
+{
+    // ПРАВИЛЬНО - до 3-4 параметров
+    public function create(string $email, string $password, string $name): User
+    {
+        // код
+    }
+
+    // ПРАВИЛЬНО - DTO/Value Object для большего числа
+    public function createFromRequest(CreateUserRequest $request): User
+    {
+        // код
+    }
+
+    // НЕПРАВИЛЬНО - слишком много параметров
+    public function create(
+        string $email,
+        string $password,
+        string $firstName,
+        string $lastName,
+        ?string $phone,
+        ?string $address,
+        bool $isActive,
+        int $roleId,
+    ): User {
+        // код
+    }
+}
+
+// DTO для группировки параметров
+readonly class CreateUserRequest
+{
+    public function __construct(
+        public string $email,
+        public string $password,
+        public string $firstName,
+        public string $lastName,
+        public ?string $phone = null,
+        public ?string $address = null,
+        public bool $isActive = true,
+        public int $roleId = 1,
+    ) {}
+}
+```
+
+### Именованные аргументы (PHP 8.0+)
+```php
+<?php
+class ReportGenerator
+{
+    public function generate(
+        string $type,
+        \DateTimeInterface $startDate,
+        \DateTimeInterface $endDate,
+        string $format = 'pdf',
+        bool $includeCharts = true,
+        ?int $limit = null,
+    ): Report {
+        // код
+    }
+}
+
+// Вызов с именованными аргументами - ясность
+$report = $generator->generate(
+    type: 'sales',
+    startDate: new DateTime('2024-01-01'),
+    endDate: new DateTime('2024-12-31'),
+    format: 'excel',
+    includeCharts: false,
+);
+```
+
+### Variadic параметры
+```php
+<?php
+class Calculator
+{
+    // ПРАВИЛЬНО - splat оператор для произвольного числа аргументов
+    public function sum(int|float ...$numbers): float
+    {
+        return array_sum($numbers);
+    }
+
+    public function formatMessage(string $template, mixed ...$args): string
+    {
+        return sprintf($template, ...$args);
+    }
+}
+
+// Использование
+$calc = new Calculator();
+$total = $calc->sum(1, 2, 3, 4, 5);
+
+// Распаковка массива
+$numbers = [1, 2, 3];
+$total = $calc->sum(...$numbers);
+```
+
+## Возвращаемые значения
+
+### Explicit return
+```php
+<?php
+class Validator
+{
+    // ПРАВИЛЬНО - явный return
+    public function validate(array $data): bool
+    {
+        if (empty($data)) {
+            return false;
+        }
+
+        foreach ($data as $item) {
+            if (!$this->isValid($item)) {
+                return false;
+            }
+        }
+
+        return true;
+    }
+
+    // Для void методов - return необязателен
+    public function log(string $message): void
+    {
+        // код
+        // return не нужен
+    }
+}
+```
+
+### Early return (Guard Clauses)
+```php
+<?php
+class OrderService
+{
+    // ПРАВИЛЬНО - ранний выход
+    public function process(Order $order): Result
+    {
+        if ($order === null) {
+            return Result::error('Order is null');
+        }
+
+        if (!$order->isValid()) {
+            return Result::error('Order is invalid');
+        }
+
+        if ($order->isEmpty()) {
+            return Result::error('Order is empty');
+        }
+
+        // Основная логика
+        return $this->doProcess($order);
+    }
+
+    // НЕПРАВИЛЬНО - глубокая вложенность
+    public function process(Order $order): Result
+    {
+        if ($order !== null) {
+            if ($order->isValid()) {
+                if (!$order->isEmpty()) {
+                    // Основная логика
+                    return $this->doProcess($order);
+                } else {
+                    return Result::error('Order is empty');
+                }
+            } else {
+                return Result::error('Order is invalid');
+            }
+        } else {
+            return Result::error('Order is null');
+        }
+    }
+}
+```
+
+### Возврат нескольких значений
+```php
+<?php
+class Parser
+{
+    // ПРАВИЛЬНО - кортеж через массив с деструктуризацией
+    public function parse(string $input): array
+    {
+        $success = true;
+        $data = [];
+        $errors = [];
+
+        // парсинг...
+
+        return [$success, $data, $errors];
+    }
+
+    // Лучше - использовать Result/DTO объект
+    public function parse(string $input): ParseResult
+    {
+        // парсинг...
+        return new ParseResult(
+            success: true,
+            data: $data,
+            errors: [],
+        );
+    }
+}
+
+// Использование деструктуризации
+[$success, $data, $errors] = $parser->parse($input);
+
+// Или с DTO
+$result = $parser->parse($input);
+if ($result->success) {
+    // работа с $result->data
+}
+```
+
+## Статические методы
+
+### Когда использовать
+```php
+<?php
+class StringHelper
+{
+    // ПРАВИЛЬНО - чистые функции без состояния
+    public static function truncate(string $text, int $length): string
+    {
+        if (mb_strlen($text) <= $length) {
+            return $text;
+        }
+
+        return mb_substr($text, 0, $length) . '...';
+    }
+
+    // Фабричные методы
+    public static function fromArray(array $data): self
+    {
+        // создание экземпляра из массива
+    }
+}
+
+class User
+{
+    // ПРАВИЛЬНО - альтернативные конструкторы
+    public static function createGuest(): self
+    {
+        $user = new self();
+        $user->role = 'guest';
+        return $user;
+    }
+
+    public static function createFromOAuth(array $oauthData): self
+    {
+        // создание из OAuth данных
+    }
+}
+```
+
+### Когда избегать
+```php
+<?php
+// НЕПРАВИЛЬНО - статические методы с зависимостями
+class OrderService
+{
+    // Сложно тестировать и мокать
+    public static function process(Order $order): Result
+    {
+        // Использует глобальное состояние или синглтоны
+        $db = Database::getInstance();
+        $mailer = Mailer::getInstance();
+        // ...
+    }
+}
+
+// ПРАВИЛЬНО - инъекция зависимостей
+class OrderService
+{
+    public function __construct(
+        private Database $db,
+        private Mailer $mailer,
+    ) {}
+
+    public function process(Order $order): Result
+    {
+        // использует $this->db и $this->mailer
+    }
+}
+```
+
+## Модификаторы доступа
+
+### Порядок в объявлении
+```php
+<?php
+class Example
+{
+    // ПРАВИЛЬНО порядок модификаторов (PSR-12):
+    // [visibility] [static] [abstract/final] function
+
+    public function publicMethod(): void {}
+
+    public static function staticMethod(): void {}
+
+    final public function finalMethod(): void {}
+
+    abstract public function abstractMethod(): void;
+
+    // PHP 8.1+ readonly
+    public readonly string $property;
+}
+```
+
+### Visibility
+```php
+<?php
+class User
+{
+    // Public - API класса
+    public function getFullName(): string
+    {
+        return $this->formatName($this->firstName, $this->lastName);
+    }
+
+    // Protected - для наследников
+    protected function beforeSave(): void
+    {
+        $this->updatedAt = time();
+    }
+
+    // Private - внутренняя реализация
+    private function formatName(string $first, string $last): string
+    {
+        return trim("$first $last");
+    }
+}
+```
+
+## Специальные методы
+
+### Конструктор
+```php
+<?php
+class User
+{
+    private string $email;
+    private string $name;
+    private array $roles;
+
+    // Классический конструктор
+    public function __construct(string $email, string $name, array $roles = [])
+    {
+        $this->email = $email;
+        $this->name = $name;
+        $this->roles = $roles;
+    }
+}
+
+// PHP 8.0+ - Constructor Property Promotion
+class User
+{
+    public function __construct(
+        private string $email,
+        private string $name,
+        private array $roles = [],
+    ) {}
+}
+
+// PHP 8.1+ - readonly properties
+class User
+{
+    public function __construct(
+        public readonly string $email,
+        public readonly string $name,
+        public readonly array $roles = [],
+    ) {}
+}
+```
+
+### Magic методы
+```php
+<?php
+class Config
+{
+    private array $data = [];
+
+    // __get - доступ к несуществующим свойствам
+    public function __get(string $name): mixed
+    {
+        return $this->data[$name] ?? null;
+    }
+
+    // __set - установка несуществующих свойств
+    public function __set(string $name, mixed $value): void
+    {
+        $this->data[$name] = $value;
+    }
+
+    // __isset - проверка isset() для несуществующих свойств
+    public function __isset(string $name): bool
+    {
+        return isset($this->data[$name]);
+    }
+
+    // __call - вызов несуществующих методов
+    public function __call(string $method, array $args): mixed
+    {
+        if (str_starts_with($method, 'get')) {
+            $property = lcfirst(substr($method, 3));
+            return $this->data[$property] ?? null;
+        }
+
+        throw new \BadMethodCallException("Method $method does not exist");
+    }
+
+    // __toString - преобразование в строку
+    public function __toString(): string
+    {
+        return json_encode($this->data);
+    }
+}
+```
+
+## Документирование методов
+
+### PHPDoc
+```php
+<?php
+class UserRepository
+{
+    /**
+     * Находит пользователя по ID.
+     *
+     * Выполняет поиск активного пользователя в базе данных.
+     * Если пользователь не найден или деактивирован, возвращает null.
+     *
+     * @param int $id Идентификатор пользователя
+     * @return User|null Найденный пользователь или null
+     * @throws DatabaseException При ошибке подключения к БД
+     *
+     * @example
+     * $user = $repository->findById(123);
+     * if ($user !== null) {
+     *     echo $user->getName();
+     * }
+     */
+    public function findById(int $id): ?User
+    {
+        // код
+    }
+
+    /**
+     * Находит пользователей по критериям.
+     *
+     * @param array<string, mixed> $criteria Критерии поиска
+     * @param array{
+     *     limit?: int,
+     *     offset?: int,
+     *     orderBy?: string
+     * } $options Опции запроса
+     * @return User[] Массив найденных пользователей
+     */
+    public function findByCriteria(array $criteria, array $options = []): array
+    {
+        // код
+    }
+}
+```
+
+## Рекомендации для Claude Code
+
+1. **Короткие методы** - каждый метод должен делать одну вещь (SRP)
+2. **Типизация** - всегда указывайте типы параметров и возвращаемых значений
+3. **Guard Clauses** - используйте ранний выход для уменьшения вложенности
+4. **Именование** - имя метода должно ясно говорить, что он делает
+5. **Документирование** - добавляйте PHPDoc для публичных методов API
+6. **Тестируемость** - избегайте static с зависимостями, предпочитайте DI
diff --git a/erp24/php_skills/04-php-classes.md b/erp24/php_skills/04-php-classes.md
new file mode 100644 (file)
index 0000000..8bbadbb
--- /dev/null
@@ -0,0 +1,702 @@
+# PHP Classes - Классы, интерфейсы и трейты
+
+## Структура класса
+
+### Стандартный порядок элементов
+```php
+<?php
+
+declare(strict_types=1);
+
+namespace App\Models;
+
+use App\Contracts\Authenticatable;
+use App\Traits\HasTimestamps;
+use yii\db\ActiveRecord;
+
+/**
+ * Класс пользователя системы.
+ *
+ * @property int $id
+ * @property string $email
+ * @property string $name
+ */
+class User extends ActiveRecord implements Authenticatable
+{
+    // 1. Трейты
+    use HasTimestamps;
+
+    // 2. Константы
+    public const STATUS_ACTIVE = 1;
+    public const STATUS_INACTIVE = 0;
+    public const MAX_NAME_LENGTH = 100;
+
+    // 3. Статические свойства
+    private static array $instances = [];
+
+    // 4. Свойства экземпляра (public, protected, private)
+    public int $id;
+    public string $email;
+    protected string $passwordHash;
+    private array $cachedPermissions = [];
+
+    // 5. Конструктор
+    public function __construct(
+        string $email,
+        string $name,
+    ) {
+        $this->email = $email;
+        $this->name = $name;
+    }
+
+    // 6. Статические методы (фабричные)
+    public static function createGuest(): self
+    {
+        return new self('guest@example.com', 'Guest');
+    }
+
+    // 7. Публичные методы
+    public function getFullName(): string
+    {
+        return $this->name;
+    }
+
+    public function activate(): void
+    {
+        $this->status = self::STATUS_ACTIVE;
+    }
+
+    // 8. Protected методы
+    protected function beforeSave(): bool
+    {
+        $this->updatedAt = time();
+        return parent::beforeSave();
+    }
+
+    // 9. Private методы
+    private function hashPassword(string $password): string
+    {
+        return password_hash($password, PASSWORD_DEFAULT);
+    }
+}
+```
+
+## Определение классов
+
+### Базовый класс
+```php
+<?php
+
+declare(strict_types=1);
+
+namespace App\Services;
+
+// ПРАВИЛЬНО - скобка на новой строке
+class UserService
+{
+    public function __construct(
+        private readonly UserRepository $repository,
+        private readonly Mailer $mailer,
+    ) {}
+
+    public function register(array $data): User
+    {
+        // код
+    }
+}
+```
+
+### Финальные классы
+```php
+<?php
+// ПРАВИЛЬНО - final для классов, которые не предназначены для наследования
+final class PaymentProcessor
+{
+    public function process(Payment $payment): Result
+    {
+        // код
+    }
+}
+
+// Используйте final по умолчанию, убирайте когда нужно наследование
+```
+
+### Абстрактные классы
+```php
+<?php
+
+abstract class BaseRepository
+{
+    abstract public function find(int $id): ?object;
+    abstract public function save(object $entity): void;
+    abstract public function delete(object $entity): void;
+
+    // Общая реализация
+    protected function buildQuery(): QueryBuilder
+    {
+        return new QueryBuilder($this->getTableName());
+    }
+
+    abstract protected function getTableName(): string;
+}
+
+class UserRepository extends BaseRepository
+{
+    public function find(int $id): ?User
+    {
+        return User::findOne($id);
+    }
+
+    public function save(object $entity): void
+    {
+        $entity->save();
+    }
+
+    public function delete(object $entity): void
+    {
+        $entity->delete();
+    }
+
+    protected function getTableName(): string
+    {
+        return 'users';
+    }
+}
+```
+
+### Readonly классы (PHP 8.2+)
+```php
+<?php
+// Все свойства автоматически readonly
+readonly class UserDto
+{
+    public function __construct(
+        public int $id,
+        public string $email,
+        public string $name,
+    ) {}
+}
+
+// Эквивалентно:
+class UserDto
+{
+    public function __construct(
+        public readonly int $id,
+        public readonly string $email,
+        public readonly string $name,
+    ) {}
+}
+```
+
+## Интерфейсы
+
+### Определение интерфейсов
+```php
+<?php
+
+namespace App\Contracts;
+
+// ПРАВИЛЬНО - маленькие, сфокусированные интерфейсы (ISP)
+interface Authenticatable
+{
+    public function getAuthIdentifier(): int|string;
+    public function getAuthPassword(): string;
+}
+
+interface Authorizable
+{
+    public function hasPermission(string $permission): bool;
+    public function hasRole(string $role): bool;
+}
+
+interface HasTimestamps
+{
+    public function getCreatedAt(): \DateTimeInterface;
+    public function getUpdatedAt(): \DateTimeInterface;
+}
+
+// НЕПРАВИЛЬНО - слишком большой интерфейс
+interface UserInterface
+{
+    public function getId(): int;
+    public function getEmail(): string;
+    public function getName(): string;
+    public function getPassword(): string;
+    public function getCreatedAt(): \DateTimeInterface;
+    public function hasPermission(string $permission): bool;
+    // ... ещё 20 методов
+}
+```
+
+### Реализация интерфейсов
+```php
+<?php
+
+class User implements Authenticatable, Authorizable
+{
+    public function getAuthIdentifier(): int|string
+    {
+        return $this->id;
+    }
+
+    public function getAuthPassword(): string
+    {
+        return $this->passwordHash;
+    }
+
+    public function hasPermission(string $permission): bool
+    {
+        return in_array($permission, $this->permissions, true);
+    }
+
+    public function hasRole(string $role): bool
+    {
+        return $this->role === $role;
+    }
+}
+```
+
+### Интерфейсы с константами
+```php
+<?php
+
+interface StatusCodes
+{
+    public const SUCCESS = 200;
+    public const NOT_FOUND = 404;
+    public const SERVER_ERROR = 500;
+}
+
+// PHP 8.1+ - interface с default реализацией через trait
+interface Loggable
+{
+    public function log(string $message): void;
+}
+
+trait LoggableTrait
+{
+    public function log(string $message): void
+    {
+        error_log(static::class . ': ' . $message);
+    }
+}
+
+class Service implements Loggable
+{
+    use LoggableTrait;
+}
+```
+
+## Трейты
+
+### Определение трейтов
+```php
+<?php
+
+trait HasTimestamps
+{
+    public ?int $createdAt = null;
+    public ?int $updatedAt = null;
+
+    public function touch(): void
+    {
+        $this->updatedAt = time();
+    }
+
+    protected function initTimestamps(): void
+    {
+        if ($this->createdAt === null) {
+            $this->createdAt = time();
+        }
+        $this->updatedAt = time();
+    }
+}
+
+trait SoftDeletes
+{
+    public ?int $deletedAt = null;
+
+    public function delete(): void
+    {
+        $this->deletedAt = time();
+        $this->save();
+    }
+
+    public function restore(): void
+    {
+        $this->deletedAt = null;
+        $this->save();
+    }
+
+    public function isDeleted(): bool
+    {
+        return $this->deletedAt !== null;
+    }
+
+    abstract public function save(): bool;
+}
+```
+
+### Использование трейтов
+```php
+<?php
+
+class Article extends ActiveRecord
+{
+    use HasTimestamps;
+    use SoftDeletes;
+
+    // Разрешение конфликтов
+    // use TraitA, TraitB {
+    //     TraitA::method insteadof TraitB;
+    //     TraitB::method as aliasMethod;
+    // }
+}
+```
+
+### Трейты с абстрактными методами
+```php
+<?php
+
+trait Searchable
+{
+    abstract public function getSearchableAttributes(): array;
+
+    public function toSearchArray(): array
+    {
+        $data = [];
+        foreach ($this->getSearchableAttributes() as $attribute) {
+            $data[$attribute] = $this->$attribute;
+        }
+        return $data;
+    }
+}
+
+class Product extends ActiveRecord
+{
+    use Searchable;
+
+    public function getSearchableAttributes(): array
+    {
+        return ['name', 'description', 'sku'];
+    }
+}
+```
+
+## Наследование vs Композиция
+
+### Предпочитайте композицию
+```php
+<?php
+// НЕПРАВИЛЬНО - глубокая иерархия наследования
+class Animal {}
+class Mammal extends Animal {}
+class Dog extends Mammal {}
+class GermanShepherd extends Dog {}
+
+// ПРАВИЛЬНО - композиция через интерфейсы и DI
+interface CanBark
+{
+    public function bark(): string;
+}
+
+interface CanRun
+{
+    public function run(): void;
+}
+
+class Dog implements CanBark, CanRun
+{
+    public function __construct(
+        private BarkBehavior $barkBehavior,
+        private RunBehavior $runBehavior,
+    ) {}
+
+    public function bark(): string
+    {
+        return $this->barkBehavior->execute();
+    }
+
+    public function run(): void
+    {
+        $this->runBehavior->execute();
+    }
+}
+```
+
+## Свойства класса
+
+### Типизированные свойства
+```php
+<?php
+
+class User
+{
+    // Типизированные свойства (PHP 7.4+)
+    public int $id;
+    public string $email;
+    public ?string $phone = null;
+
+    // Readonly (PHP 8.1+)
+    public readonly string $createdAt;
+
+    // Promoted properties в конструкторе (PHP 8.0+)
+    public function __construct(
+        public int $id,
+        public string $email,
+        private string $password,
+        public readonly string $createdAt = '',
+    ) {}
+}
+```
+
+### Visibility
+```php
+<?php
+
+class Account
+{
+    // Public - доступно везде
+    public float $balance = 0.0;
+
+    // Protected - доступно в классе и наследниках
+    protected int $accountNumber;
+
+    // Private - доступно только в этом классе
+    private string $secretKey;
+
+    // PHP 8.1+ asymmetric visibility (PER)
+    // public private(set) string $name; // читать публично, писать приватно
+}
+```
+
+## Константы класса
+
+### Определение констант
+```php
+<?php
+
+class Order
+{
+    // Публичные константы
+    public const STATUS_PENDING = 'pending';
+    public const STATUS_PROCESSING = 'processing';
+    public const STATUS_COMPLETED = 'completed';
+    public const STATUS_CANCELLED = 'cancelled';
+
+    // Группировка в массив
+    public const STATUSES = [
+        self::STATUS_PENDING,
+        self::STATUS_PROCESSING,
+        self::STATUS_COMPLETED,
+        self::STATUS_CANCELLED,
+    ];
+
+    // Private константы
+    private const INTERNAL_CODE = 'xyz';
+
+    // Final константы (PHP 8.1+)
+    final public const VERSION = '1.0';
+}
+```
+
+### Enum вместо констант (PHP 8.1+)
+```php
+<?php
+
+// ПРАВИЛЬНО - используйте Enum для связанных значений
+enum OrderStatus: string
+{
+    case Pending = 'pending';
+    case Processing = 'processing';
+    case Completed = 'completed';
+    case Cancelled = 'cancelled';
+
+    public function label(): string
+    {
+        return match ($this) {
+            self::Pending => 'В ожидании',
+            self::Processing => 'В обработке',
+            self::Completed => 'Завершён',
+            self::Cancelled => 'Отменён',
+        };
+    }
+
+    public function canTransitionTo(self $newStatus): bool
+    {
+        return match ($this) {
+            self::Pending => in_array($newStatus, [self::Processing, self::Cancelled]),
+            self::Processing => in_array($newStatus, [self::Completed, self::Cancelled]),
+            self::Completed, self::Cancelled => false,
+        };
+    }
+}
+
+// Использование
+$status = OrderStatus::Pending;
+echo $status->value; // 'pending'
+echo $status->label(); // 'В ожидании'
+
+if ($status->canTransitionTo(OrderStatus::Processing)) {
+    // можно перевести в обработку
+}
+```
+
+## Инициализация
+
+### Конструкторы
+```php
+<?php
+
+class User
+{
+    // PHP 8.0+ Constructor Property Promotion
+    public function __construct(
+        private string $email,
+        private string $name,
+        private array $roles = [],
+        private ?\DateTimeInterface $createdAt = null,
+    ) {
+        // Дополнительная инициализация
+        $this->createdAt ??= new \DateTimeImmutable();
+    }
+}
+
+// Фабричные методы для альтернативной инициализации
+class User
+{
+    private function __construct(
+        private string $email,
+        private string $name,
+    ) {}
+
+    public static function create(string $email, string $name): self
+    {
+        return new self($email, $name);
+    }
+
+    public static function createFromArray(array $data): self
+    {
+        return new self(
+            $data['email'] ?? throw new \InvalidArgumentException('Email required'),
+            $data['name'] ?? throw new \InvalidArgumentException('Name required'),
+        );
+    }
+
+    public static function createGuest(): self
+    {
+        return new self('guest@example.com', 'Guest');
+    }
+}
+```
+
+## SOLID принципы
+
+### Single Responsibility (SRP)
+```php
+<?php
+// ПРАВИЛЬНО - один класс = одна ответственность
+class UserValidator
+{
+    public function validate(array $data): array
+    {
+        // только валидация
+    }
+}
+
+class UserRepository
+{
+    public function save(User $user): void
+    {
+        // только сохранение
+    }
+}
+
+class UserMailer
+{
+    public function sendWelcome(User $user): void
+    {
+        // только отправка email
+    }
+}
+
+// НЕПРАВИЛЬНО - God Object
+class User
+{
+    public function validate(): bool { /* ... */ }
+    public function save(): void { /* ... */ }
+    public function sendEmail(): void { /* ... */ }
+    public function generateReport(): string { /* ... */ }
+    public function exportToCsv(): string { /* ... */ }
+}
+```
+
+### Open/Closed Principle (OCP)
+```php
+<?php
+// ПРАВИЛЬНО - открыт для расширения, закрыт для модификации
+interface PaymentGateway
+{
+    public function charge(float $amount): bool;
+}
+
+class StripeGateway implements PaymentGateway
+{
+    public function charge(float $amount): bool
+    {
+        // Stripe implementation
+    }
+}
+
+class PayPalGateway implements PaymentGateway
+{
+    public function charge(float $amount): bool
+    {
+        // PayPal implementation
+    }
+}
+
+class PaymentProcessor
+{
+    public function process(PaymentGateway $gateway, float $amount): bool
+    {
+        return $gateway->charge($amount);
+    }
+}
+```
+
+### Dependency Inversion (DIP)
+```php
+<?php
+// ПРАВИЛЬНО - зависимость от абстракций
+interface LoggerInterface
+{
+    public function log(string $message): void;
+}
+
+class UserService
+{
+    public function __construct(
+        private LoggerInterface $logger,
+    ) {}
+
+    public function createUser(array $data): User
+    {
+        $this->logger->log('Creating user...');
+        // создание пользователя
+    }
+}
+
+// Можно использовать любую реализацию
+$service = new UserService(new FileLogger());
+$service = new UserService(new DatabaseLogger());
+```
+
+## Рекомендации для Claude Code
+
+1. **Маленькие классы** - один класс = одна ответственность (SRP)
+2. **Final по умолчанию** - делайте классы final, пока не нужно наследование
+3. **Композиция > наследование** - используйте интерфейсы и DI
+4. **Типизация** - всегда указывайте типы свойств и методов
+5. **Readonly** - используйте readonly для иммутабельных данных
+6. **Enum** - используйте Enum вместо констант для связанных значений
diff --git a/erp24/php_skills/05-php-collections.md b/erp24/php_skills/05-php-collections.md
new file mode 100644 (file)
index 0000000..8870b9d
--- /dev/null
@@ -0,0 +1,519 @@
+# PHP Collections - Работа с массивами
+
+## Создание массивов
+
+### Литералы vs конструкторы
+```php
+<?php
+// ПРАВИЛЬНО - короткий синтаксис []
+$array = [];
+$numbers = [1, 2, 3, 4, 5];
+
+// ПРАВИЛЬНО - ассоциативный массив
+$user = [
+    'name' => 'John',
+    'email' => 'john@example.com',
+    'age' => 30,
+];
+
+// НЕПРАВИЛЬНО - устаревший синтаксис array()
+$array = array();
+$numbers = array(1, 2, 3);
+```
+
+### Многомерные массивы
+```php
+<?php
+// ПРАВИЛЬНО - отформатированная структура
+$config = [
+    'database' => [
+        'host' => 'localhost',
+        'port' => 5432,
+        'name' => 'myapp',
+    ],
+    'cache' => [
+        'driver' => 'redis',
+        'ttl' => 3600,
+    ],
+];
+
+// Доступ к вложенным элементам
+$host = $config['database']['host'];
+```
+
+### Trailing comma
+```php
+<?php
+// ПРАВИЛЬНО - запятая после последнего элемента
+$array = [
+    'first',
+    'second',
+    'third', // trailing comma - облегчает diff в git
+];
+
+$users = [
+    ['name' => 'John', 'age' => 30],
+    ['name' => 'Jane', 'age' => 25],
+];
+```
+
+## Доступ к элементам
+
+### Базовый доступ
+```php
+<?php
+$array = ['a', 'b', 'c', 'd', 'e'];
+
+// Доступ по индексу
+$first = $array[0];
+$last = $array[count($array) - 1];
+
+// PHP 7.4+ - отрицательные индексы в array_slice
+$lastTwo = array_slice($array, -2);
+
+// ПРАВИЛЬНО - проверка существования ключа
+if (isset($array['key'])) {
+    $value = $array['key'];
+}
+
+// Или null coalescing
+$value = $array['key'] ?? 'default';
+
+// array_key_exists vs isset
+// isset возвращает false для null значений
+$data = ['key' => null];
+isset($data['key']);            // false
+array_key_exists('key', $data); // true
+```
+
+### Безопасный доступ
+```php
+<?php
+// ПРАВИЛЬНО - null coalescing для отсутствующих ключей
+$name = $user['name'] ?? 'Anonymous';
+$city = $user['address']['city'] ?? 'Unknown';
+
+// ПРАВИЛЬНО - null coalescing assignment (PHP 7.4+)
+$user['role'] ??= 'guest';
+
+// НЕПРАВИЛЬНО - без проверки
+$name = $user['name']; // Warning если ключа нет
+```
+
+## Итерация
+
+### foreach
+```php
+<?php
+$users = [
+    ['name' => 'John', 'age' => 30],
+    ['name' => 'Jane', 'age' => 25],
+];
+
+// ПРАВИЛЬНО - foreach для перебора
+foreach ($users as $user) {
+    echo $user['name'];
+}
+
+// С ключом
+foreach ($users as $index => $user) {
+    echo "User $index: {$user['name']}";
+}
+
+// По ссылке (осторожно!)
+foreach ($users as &$user) {
+    $user['processed'] = true;
+}
+unset($user); // ВАЖНО - сбросить ссылку после цикла
+
+// НЕПРАВИЛЬНО - for для простого перебора (медленнее)
+for ($i = 0; $i < count($users); $i++) {
+    echo $users[$i]['name'];
+}
+```
+
+### Итерация с модификацией
+```php
+<?php
+// НЕПРАВИЛЬНО - модификация массива во время итерации
+foreach ($items as $key => $item) {
+    if ($item === null) {
+        unset($items[$key]); // Может привести к проблемам
+    }
+}
+
+// ПРАВИЛЬНО - используйте array_filter
+$items = array_filter($items, fn($item) => $item !== null);
+
+// Или итерируйте по копии
+foreach ($items as $key => $item) {
+    if ($item === null) {
+        unset($items[$key]);
+    }
+}
+$items = array_values($items); // Переиндексация если нужно
+```
+
+## Трансформация массивов
+
+### array_map
+```php
+<?php
+$numbers = [1, 2, 3, 4, 5];
+
+// ПРАВИЛЬНО - функциональный стиль
+$doubled = array_map(fn($n) => $n * 2, $numbers);
+
+// Несколько массивов
+$a = [1, 2, 3];
+$b = [10, 20, 30];
+$sum = array_map(fn($x, $y) => $x + $y, $a, $b);
+
+// Трансформация объектов
+$users = [new User('John'), new User('Jane')];
+$names = array_map(fn($user) => $user->getName(), $users);
+
+// С сохранением ключей
+$prices = ['apple' => 1.0, 'banana' => 0.5];
+$discounted = array_map(fn($price) => $price * 0.9, $prices);
+```
+
+### array_filter
+```php
+<?php
+$numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
+
+// Фильтрация с callback
+$even = array_filter($numbers, fn($n) => $n % 2 === 0);
+
+// Без callback - удаляет falsy значения
+$values = [0, 1, '', 'hello', null, false];
+$truthy = array_filter($values); // [1 => 1, 3 => 'hello']
+
+// С флагами
+$data = ['a' => 1, 'b' => 2, 'c' => 3];
+
+// ARRAY_FILTER_USE_KEY - только ключи
+$filtered = array_filter($data, fn($key) => $key !== 'b', ARRAY_FILTER_USE_KEY);
+
+// ARRAY_FILTER_USE_BOTH - ключи и значения
+$filtered = array_filter(
+    $data,
+    fn($value, $key) => $key !== 'a' && $value > 1,
+    ARRAY_FILTER_USE_BOTH
+);
+```
+
+### array_reduce
+```php
+<?php
+$numbers = [1, 2, 3, 4, 5];
+
+// Сумма
+$sum = array_reduce($numbers, fn($carry, $item) => $carry + $item, 0);
+
+// Произведение
+$product = array_reduce($numbers, fn($carry, $item) => $carry * $item, 1);
+
+// Построение строки
+$words = ['Hello', 'World'];
+$sentence = array_reduce(
+    $words,
+    fn($carry, $word) => $carry . ($carry ? ' ' : '') . $word,
+    ''
+);
+
+// Группировка
+$users = [
+    ['name' => 'John', 'role' => 'admin'],
+    ['name' => 'Jane', 'role' => 'user'],
+    ['name' => 'Bob', 'role' => 'admin'],
+];
+
+$grouped = array_reduce($users, function ($carry, $user) {
+    $carry[$user['role']][] = $user['name'];
+    return $carry;
+}, []);
+// ['admin' => ['John', 'Bob'], 'user' => ['Jane']]
+```
+
+### array_column
+```php
+<?php
+$users = [
+    ['id' => 1, 'name' => 'John', 'email' => 'john@test.com'],
+    ['id' => 2, 'name' => 'Jane', 'email' => 'jane@test.com'],
+];
+
+// Извлечение колонки
+$names = array_column($users, 'name'); // ['John', 'Jane']
+
+// С ключом
+$emailById = array_column($users, 'email', 'id');
+// [1 => 'john@test.com', 2 => 'jane@test.com']
+
+// Вся строка с ключом
+$usersById = array_column($users, null, 'id');
+// [1 => ['id' => 1, ...], 2 => ['id' => 2, ...]]
+```
+
+## Поиск и проверка
+
+### Проверка наличия
+```php
+<?php
+$array = ['apple', 'banana', 'orange'];
+
+// in_array - проверка значения
+if (in_array('apple', $array, true)) { // true для строгого сравнения
+    echo 'Found';
+}
+
+// array_key_exists - проверка ключа
+$data = ['name' => 'John', 'age' => null];
+if (array_key_exists('age', $data)) {
+    echo 'Key exists';
+}
+
+// isset - проверка ключа и что значение не null
+if (isset($data['name'])) {
+    echo 'Set and not null';
+}
+```
+
+### Поиск
+```php
+<?php
+$fruits = ['apple', 'banana', 'orange', 'apple'];
+
+// array_search - найти индекс
+$index = array_search('banana', $fruits, true); // 1
+
+// array_keys - все ключи со значением
+$indices = array_keys($fruits, 'apple'); // [0, 3]
+
+// Поиск в массиве объектов
+$users = [new User(1, 'John'), new User(2, 'Jane')];
+
+$found = null;
+foreach ($users as $user) {
+    if ($user->getId() === 2) {
+        $found = $user;
+        break;
+    }
+}
+
+// Или с array_filter
+$matches = array_filter($users, fn($u) => $u->getId() === 2);
+$found = reset($matches) ?: null;
+```
+
+### Проверки массива
+```php
+<?php
+$array = [1, 2, 3, 4, 5];
+
+// Проверка пустоты
+if (empty($array)) {
+    echo 'Empty';
+}
+
+// count / sizeof
+$count = count($array);
+
+// Проверка всех элементов (нет встроенного all, используйте reduce)
+$allPositive = array_reduce(
+    $array,
+    fn($carry, $item) => $carry && $item > 0,
+    true
+);
+
+// Проверка хотя бы одного (нет встроенного any)
+$hasNegative = count(array_filter($array, fn($n) => $n < 0)) > 0;
+
+// Или через loop с break
+$hasNegative = false;
+foreach ($array as $item) {
+    if ($item < 0) {
+        $hasNegative = true;
+        break;
+    }
+}
+```
+
+## Сортировка
+
+### Базовая сортировка
+```php
+<?php
+$numbers = [3, 1, 4, 1, 5, 9, 2, 6];
+
+// sort - по значению, переиндексирует
+sort($numbers); // [1, 1, 2, 3, 4, 5, 6, 9]
+
+// rsort - в обратном порядке
+rsort($numbers);
+
+// asort - сохраняет ключи
+$prices = ['apple' => 1.5, 'banana' => 0.5, 'orange' => 2.0];
+asort($prices); // ['banana' => 0.5, 'apple' => 1.5, 'orange' => 2.0]
+
+// ksort - по ключам
+ksort($prices);
+
+// SORT_NATURAL - натуральная сортировка
+$files = ['file1.txt', 'file10.txt', 'file2.txt'];
+sort($files, SORT_NATURAL); // ['file1.txt', 'file2.txt', 'file10.txt']
+```
+
+### Пользовательская сортировка
+```php
+<?php
+$users = [
+    ['name' => 'John', 'age' => 30],
+    ['name' => 'Jane', 'age' => 25],
+    ['name' => 'Bob', 'age' => 35],
+];
+
+// usort - пользовательская функция сравнения
+usort($users, fn($a, $b) => $a['age'] <=> $b['age']);
+
+// Spaceship operator <=> для сравнения
+// Возвращает -1, 0 или 1
+
+// Сортировка по нескольким полям
+usort($users, function ($a, $b) {
+    return $a['role'] <=> $b['role']
+        ?: $a['name'] <=> $b['name'];
+});
+
+// Сортировка объектов
+usort($users, fn($a, $b) => $a->getAge() <=> $b->getAge());
+```
+
+## Слияние и разделение
+
+### array_merge
+```php
+<?php
+$a = [1, 2, 3];
+$b = [4, 5, 6];
+
+// Слияние индексированных массивов
+$merged = array_merge($a, $b); // [1, 2, 3, 4, 5, 6]
+
+// Слияние ассоциативных массивов (поздние значения перезаписывают)
+$defaults = ['color' => 'red', 'size' => 'medium'];
+$options = ['size' => 'large'];
+$config = array_merge($defaults, $options);
+// ['color' => 'red', 'size' => 'large']
+
+// Spread operator (PHP 7.4+)
+$merged = [...$a, ...$b];
+$config = [...$defaults, ...$options];
+```
+
+### array_combine
+```php
+<?php
+$keys = ['name', 'email', 'age'];
+$values = ['John', 'john@test.com', 30];
+
+$user = array_combine($keys, $values);
+// ['name' => 'John', 'email' => 'john@test.com', 'age' => 30]
+```
+
+### array_chunk
+```php
+<?php
+$items = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
+
+// Разбиение на части
+$chunks = array_chunk($items, 3);
+// [[1,2,3], [4,5,6], [7,8,9], [10]]
+
+// С сохранением ключей
+$chunks = array_chunk($items, 3, true);
+```
+
+### array_slice
+```php
+<?php
+$array = ['a', 'b', 'c', 'd', 'e'];
+
+// Срез от индекса
+$slice = array_slice($array, 2); // ['c', 'd', 'e']
+
+// Срез с ограничением
+$slice = array_slice($array, 1, 2); // ['b', 'c']
+
+// С отрицательным индексом
+$slice = array_slice($array, -2); // ['d', 'e']
+
+// С сохранением ключей
+$slice = array_slice($array, 1, 2, true); // [1 => 'b', 2 => 'c']
+```
+
+## Операции над множествами
+
+```php
+<?php
+$a = [1, 2, 3, 4, 5];
+$b = [4, 5, 6, 7, 8];
+
+// Разность
+$diff = array_diff($a, $b); // [1, 2, 3]
+
+// Пересечение
+$intersect = array_intersect($a, $b); // [4, 5]
+
+// Уникальные значения
+$unique = array_unique([1, 2, 2, 3, 3, 3]); // [1, 2, 3]
+
+// Разность с проверкой ключей
+$a = ['x' => 1, 'y' => 2];
+$b = ['x' => 1, 'z' => 3];
+$diff = array_diff_key($a, $b); // ['y' => 2]
+```
+
+## Полезные функции
+
+```php
+<?php
+// array_flip - меняет местами ключи и значения
+$flipped = array_flip(['a' => 0, 'b' => 1]); // [0 => 'a', 1 => 'b']
+
+// array_reverse - обратный порядок
+$reversed = array_reverse([1, 2, 3]); // [3, 2, 1]
+
+// array_fill - создание с заполнением
+$zeros = array_fill(0, 5, 0); // [0, 0, 0, 0, 0]
+
+// array_fill_keys - создание с ключами
+$defaults = array_fill_keys(['a', 'b', 'c'], null);
+// ['a' => null, 'b' => null, 'c' => null]
+
+// range - диапазон значений
+$numbers = range(1, 10); // [1, 2, 3, ..., 10]
+$letters = range('a', 'z');
+
+// array_sum, array_product
+$sum = array_sum([1, 2, 3, 4, 5]); // 15
+$product = array_product([1, 2, 3, 4]); // 24
+
+// compact / extract
+$name = 'John';
+$age = 30;
+$data = compact('name', 'age'); // ['name' => 'John', 'age' => 30]
+
+extract(['foo' => 'bar']); // создаёт $foo = 'bar' (осторожно!)
+```
+
+## Рекомендации для Claude Code
+
+1. **Короткий синтаксис** - всегда используйте `[]` вместо `array()`
+2. **Строгое сравнение** - используйте `true` в `in_array` и `array_search`
+3. **Функциональный стиль** - предпочитайте `array_map/filter/reduce` циклам
+4. **Null coalescing** - используйте `??` для безопасного доступа
+5. **Spread operator** - используйте `...` для слияния массивов
+6. **Unset после &** - всегда сбрасывайте ссылочную переменную после foreach
diff --git a/erp24/php_skills/06-php-strings.md b/erp24/php_skills/06-php-strings.md
new file mode 100644 (file)
index 0000000..56dd095
--- /dev/null
@@ -0,0 +1,454 @@
+# PHP Strings - Работа со строками
+
+## Кавычки
+
+### Выбор типа кавычек
+```php
+<?php
+// ПРАВИЛЬНО - одинарные для простых строк (быстрее)
+$name = 'John';
+$message = 'Hello, World!';
+$path = '/var/www/html';
+
+// ПРАВИЛЬНО - двойные для интерполяции
+$greeting = "Hello, $name!";
+$fullGreeting = "Hello, {$user->getName()}!";
+
+// ПРАВИЛЬНО - двойные для escape-последовательностей
+$newline = "First line\nSecond line";
+$tab = "Column1\tColumn2";
+
+// ПРАВИЛЬНО - одинарные для строк с двойными кавычками
+$html = '<div class="container">Content</div>';
+
+// ПРАВИЛЬНО - двойные для строк с одинарными
+$sql = "SELECT * FROM users WHERE name = 'John'";
+```
+
+### Escape-последовательности
+```php
+<?php
+// Работают только в двойных кавычках и heredoc
+$escaped = "Line1\nLine2\tTabbed";
+
+// Основные последовательности:
+// \n - новая строка
+// \r - возврат каретки
+// \t - табуляция
+// \\ - обратный слэш
+// \$ - знак доллара
+// \" - двойная кавычка
+
+// В одинарных кавычках работают только:
+// \\ - обратный слэш
+// \' - одинарная кавычка
+$literal = 'This is \n literally';  // не будет новой строки
+```
+
+## Интерполяция
+
+### Базовая интерполяция
+```php
+<?php
+$name = 'John';
+$age = 30;
+
+// ПРАВИЛЬНО - простые переменные
+$message = "Hello, $name!";
+
+// ПРАВИЛЬНО - сложные выражения в {}
+$message = "User {$user->name} has {$user->ordersCount} orders";
+$message = "Value: {$array['key']}";
+$message = "Result: {$object->getResult()}";
+
+// НЕПРАВИЛЬНО - конкатенация для простых случаев
+$message = 'Hello, ' . $name . '!';  // менее читаемо
+```
+
+### Фигурные скобки
+```php
+<?php
+// ПРАВИЛЬНО - всегда используйте {} для свойств и методов
+$message = "Name: {$user->name}";
+$message = "Count: {$items[0]}";
+
+// ПРАВИЛЬНО - для устранения неоднозначности
+$noun = 'apple';
+echo "I have 3 {$noun}s";  // I have 3 apples
+
+// НЕПРАВИЛЬНО - неоднозначность
+echo "I have 3 $nouns";  // ищет переменную $nouns
+```
+
+## Heredoc и Nowdoc
+
+### Heredoc (с интерполяцией)
+```php
+<?php
+$name = 'John';
+$items = ['apple', 'banana'];
+
+// ПРАВИЛЬНО - для многострочного текста с переменными
+$html = <<<HTML
+<div class="user-card">
+    <h1>Welcome, {$name}!</h1>
+    <p>You have {$items[0]} in your cart.</p>
+</div>
+HTML;
+
+// Идентификатор в двойных кавычках (необязательно)
+$sql = <<<"SQL"
+SELECT *
+FROM users
+WHERE name = '{$name}'
+ORDER BY created_at DESC
+SQL;
+```
+
+### Nowdoc (без интерполяции)
+```php
+<?php
+// ПРАВИЛЬНО - когда переменные не нужны
+$code = <<<'PHP'
+<?php
+$variable = 'not interpolated';
+echo $variable;
+PHP;
+
+// Полезно для примеров кода, регулярок и т.д.
+$pattern = <<<'REGEX'
+/^[\w.%+-]+@[\w.-]+\.[a-zA-Z]{2,}$/
+REGEX;
+```
+
+## Форматирование строк
+
+### sprintf/printf
+```php
+<?php
+// ПРАВИЛЬНО - sprintf для форматирования
+$message = sprintf('User %s has %d orders', $name, $count);
+
+// Спецификаторы формата:
+// %s - строка
+// %d - целое число
+// %f - число с плавающей точкой
+// %02d - целое с ведущими нулями (минимум 2 цифры)
+// %.2f - число с 2 знаками после запятой
+// %% - символ процента
+
+// Форматирование чисел
+$price = sprintf('%.2f', 19.5);  // "19.50"
+$padded = sprintf('%05d', 42);   // "00042"
+
+// Позиционные аргументы
+$message = sprintf('%2$s has %1$d items', $count, $name);
+
+// printf - вывод напрямую
+printf('Total: %.2f', $total);
+```
+
+### number_format
+```php
+<?php
+$number = 1234567.891;
+
+// Базовое форматирование
+echo number_format($number);  // "1,234,568"
+echo number_format($number, 2);  // "1,234,567.89"
+
+// Русская локаль
+echo number_format($number, 2, ',', ' ');  // "1 234 567,89"
+```
+
+### Многострочные строки
+```php
+<?php
+// ПРАВИЛЬНО - heredoc для больших текстов
+$description = <<<TEXT
+This is a long description
+that spans multiple lines
+with proper formatting.
+TEXT;
+
+// ПРАВИЛЬНО - конкатенация для коротких строк
+$longString = 'This is a very long string that would be '
+    . 'too long to fit on a single line, so we split it '
+    . 'across multiple lines for readability.';
+
+// ПРАВИЛЬНО - implode для построения из частей
+$parts = [
+    'SELECT * FROM users',
+    'WHERE active = 1',
+    'ORDER BY name',
+];
+$sql = implode(' ', $parts);
+```
+
+## Операции со строками
+
+### Конкатенация
+```php
+<?php
+// ПРАВИЛЬНО - для построения больших строк используйте массив + implode
+$parts = [];
+$parts[] = '<h1>Title</h1>';
+$parts[] = '<p>Content</p>';
+$html = implode("\n", $parts);
+
+// Или .= для накопления (медленнее для больших строк)
+$html = '';
+$html .= '<h1>Title</h1>';
+$html .= '<p>Content</p>';
+
+// Для единичной конкатенации
+$fullName = $firstName . ' ' . $lastName;
+```
+
+### Базовые операции
+```php
+<?php
+$str = 'Hello, World!';
+
+// Длина строки (для UTF-8 используйте mb_strlen)
+$length = strlen($str);        // 13 (байты)
+$length = mb_strlen($str);     // 13 (символы)
+
+// Изменение регистра
+$upper = strtoupper($str);     // HELLO, WORLD!
+$lower = strtolower($str);     // hello, world!
+$ucfirst = ucfirst($lower);    // Hello, world!
+$ucwords = ucwords($lower);    // Hello, World!
+
+// Для UTF-8
+$upper = mb_strtoupper($str);
+$lower = mb_strtolower($str);
+```
+
+### Обрезка и дополнение
+```php
+<?php
+$str = '  Hello, World!  ';
+
+// Обрезка пробелов
+$trimmed = trim($str);         // 'Hello, World!'
+$ltrimmed = ltrim($str);       // 'Hello, World!  '
+$rtrimmed = rtrim($str);       // '  Hello, World!'
+
+// Обрезка конкретных символов
+$str = '###Hello###';
+$clean = trim($str, '#');      // 'Hello'
+
+// Дополнение
+$padded = str_pad('42', 5, '0', STR_PAD_LEFT);   // '00042'
+$padded = str_pad('Hi', 10, '-', STR_PAD_BOTH);  // '----Hi----'
+```
+
+### Подстроки
+```php
+<?php
+$str = 'Hello, World!';
+
+// Извлечение подстроки
+$sub = substr($str, 0, 5);     // 'Hello'
+$sub = substr($str, -6);       // 'World!'
+$sub = substr($str, 7, 5);     // 'World'
+
+// Для UTF-8
+$sub = mb_substr($str, 0, 5);
+
+// Замена подстроки
+$new = substr_replace($str, 'PHP', 7, 5);  // 'Hello, PHP!'
+```
+
+## Поиск и замена
+
+### Поиск
+```php
+<?php
+$str = 'Hello, World!';
+
+// Позиция подстроки
+$pos = strpos($str, 'World');  // 7
+$pos = stripos($str, 'world'); // 7 (без учёта регистра)
+
+// Проверка наличия (PHP 8.0+)
+if (str_contains($str, 'World')) {
+    echo 'Found!';
+}
+
+// Начинается/заканчивается (PHP 8.0+)
+if (str_starts_with($str, 'Hello')) {
+    echo 'Starts with Hello';
+}
+
+if (str_ends_with($str, '!')) {
+    echo 'Ends with !';
+}
+
+// До PHP 8.0
+$contains = strpos($str, 'World') !== false;
+$startsWith = strpos($str, 'Hello') === 0;
+$endsWith = substr($str, -1) === '!';
+```
+
+### Замена
+```php
+<?php
+$str = 'Hello, World!';
+
+// Простая замена
+$new = str_replace('World', 'PHP', $str);  // 'Hello, PHP!'
+
+// Без учёта регистра
+$new = str_ireplace('world', 'PHP', $str);
+
+// Множественная замена
+$search = ['Hello', 'World'];
+$replace = ['Hi', 'PHP'];
+$new = str_replace($search, $replace, $str);  // 'Hi, PHP!'
+
+// С подсчётом замен
+$count = 0;
+$new = str_replace('o', '0', $str, $count);
+echo "Replaced $count times";
+```
+
+### Регулярные выражения
+```php
+<?php
+$str = 'User: john@example.com';
+
+// Поиск
+if (preg_match('/[\w.]+@[\w.]+/', $str, $matches)) {
+    echo $matches[0];  // 'john@example.com'
+}
+
+// Все совпадения
+preg_match_all('/\d+/', 'a1b2c3', $matches);
+// $matches[0] = ['1', '2', '3']
+
+// Замена
+$new = preg_replace('/\d+/', '#', 'a1b2c3');  // 'a#b#c#'
+
+// С callback
+$new = preg_replace_callback(
+    '/\d+/',
+    fn($m) => $m[0] * 2,
+    'a1b2c3'
+);  // 'a2b4c6'
+```
+
+## Разбиение и объединение
+
+```php
+<?php
+// Разбиение
+$parts = explode(',', 'one,two,three');  // ['one', 'two', 'three']
+
+// С ограничением
+$parts = explode(',', 'one,two,three', 2);  // ['one', 'two,three']
+
+// По символам
+$chars = str_split('Hello');  // ['H', 'e', 'l', 'l', 'o']
+$chunks = str_split('Hello', 2);  // ['He', 'll', 'o']
+
+// Для UTF-8
+$chars = mb_str_split('Привет');  // ['П', 'р', 'и', 'в', 'е', 'т']
+
+// Объединение
+$str = implode(', ', ['one', 'two', 'three']);  // 'one, two, three'
+$str = implode('', ['H', 'e', 'l', 'l', 'o']);  // 'Hello'
+```
+
+## Сравнение строк
+
+```php
+<?php
+// Строгое сравнение
+if ($str1 === $str2) {
+    // точное совпадение
+}
+
+// strcmp - с учётом регистра
+$result = strcmp('abc', 'ABC');  // > 0
+
+// strcasecmp - без учёта регистра
+$result = strcasecmp('abc', 'ABC');  // 0
+
+// Натуральное сравнение
+$result = strnatcmp('file2', 'file10');  // < 0
+
+// similar_text - похожесть
+$percent = 0;
+similar_text('Hello', 'Hallo', $percent);
+echo "$percent%";
+
+// levenshtein - расстояние редактирования
+$distance = levenshtein('Hello', 'Hallo');  // 1
+```
+
+## Кодировка
+
+### UTF-8 операции
+```php
+<?php
+// Всегда используйте mb_* функции для UTF-8
+$str = 'Привет мир!';
+
+$length = mb_strlen($str);           // 11
+$upper = mb_strtoupper($str);        // ПРИВЕТ МИР!
+$sub = mb_substr($str, 0, 6);        // Привет
+$pos = mb_strpos($str, 'мир');       // 7
+
+// Установка кодировки по умолчанию
+mb_internal_encoding('UTF-8');
+
+// Конвертация кодировки
+$utf8 = mb_convert_encoding($str, 'UTF-8', 'Windows-1251');
+```
+
+### Проверка кодировки
+```php
+<?php
+// Проверка валидности UTF-8
+if (mb_check_encoding($str, 'UTF-8')) {
+    echo 'Valid UTF-8';
+}
+
+// Определение кодировки
+$encoding = mb_detect_encoding($str, ['UTF-8', 'Windows-1251', 'ISO-8859-1']);
+```
+
+## Полезные функции
+
+```php
+<?php
+// wordwrap - перенос по словам
+$wrapped = wordwrap($text, 80, "\n", true);
+
+// nl2br - новые строки в <br>
+$html = nl2br($text);
+
+// strip_tags - удаление HTML тегов
+$clean = strip_tags($html);
+$clean = strip_tags($html, '<p><br>');  // сохранить некоторые теги
+
+// htmlspecialchars - экранирование HTML
+$safe = htmlspecialchars($userInput, ENT_QUOTES, 'UTF-8');
+
+// html_entity_decode - декодирование
+$decoded = html_entity_decode('&amp;');  // '&'
+
+// json_encode/decode для Unicode
+$json = json_encode(['name' => 'Иван'], JSON_UNESCAPED_UNICODE);
+```
+
+## Рекомендации для Claude Code
+
+1. **Одинарные кавычки** - для строк без интерполяции (быстрее)
+2. **mb_* функции** - всегда для UTF-8 строк
+3. **sprintf** - для форматированного вывода
+4. **heredoc** - для многострочного текста с переменными
+5. **str_contains/starts_with/ends_with** - вместо strpos (PHP 8+)
+6. **htmlspecialchars** - всегда экранируйте пользовательский ввод
diff --git a/erp24/php_skills/07-php-flow-control.md b/erp24/php_skills/07-php-flow-control.md
new file mode 100644 (file)
index 0000000..31246dd
--- /dev/null
@@ -0,0 +1,447 @@
+# PHP Flow Control - Управление потоком выполнения
+
+## Условные операторы
+
+### if/elseif/else
+```php
+<?php
+// ПРАВИЛЬНО - стандартный формат
+if ($condition) {
+    // код
+} elseif ($anotherCondition) {
+    // код
+} else {
+    // код
+}
+
+// ПРАВИЛЬНО - elseif (одно слово, не else if)
+if ($a > $b) {
+    echo 'a больше';
+} elseif ($a < $b) {
+    echo 'a меньше';
+} else {
+    echo 'равны';
+}
+
+// НЕПРАВИЛЬНО - else if (два слова)
+if ($a > $b) {
+    // ...
+} else if ($a < $b) {  // допустимо, но не рекомендуется
+    // ...
+}
+```
+
+### Однострочные условия
+```php
+<?php
+// ПРАВИЛЬНО - простые однострочные присваивания
+$status = $isActive ? 'active' : 'inactive';
+
+// ПРАВИЛЬНО - всегда используйте скобки даже для одной строки
+if ($condition) {
+    return $value;
+}
+
+// НЕПРАВИЛЬНО - без скобок (даже если работает)
+if ($condition)
+    return $value;
+
+// ПРАВИЛЬНО - ранний выход
+if ($user === null) {
+    return null;
+}
+```
+
+### Тернарный оператор
+```php
+<?php
+// ПРАВИЛЬНО - для простых условий
+$status = $user->isActive() ? 'active' : 'inactive';
+$name = $user->getName() ?: 'Anonymous';  // Elvis operator
+
+// ПРАВИЛЬНО - null coalescing
+$name = $user->name ?? 'Anonymous';
+$config = $options['timeout'] ?? 30;
+
+// НЕПРАВИЛЬНО - вложенные тернарные операторы
+$result = $a ? $b : $c ? $d : $e;  // непонятно!
+
+// ПРАВИЛЬНО - разбить на if/else
+if ($a) {
+    $result = $b;
+} elseif ($c) {
+    $result = $d;
+} else {
+    $result = $e;
+}
+```
+
+### Null coalescing и null coalescing assignment
+```php
+<?php
+// Null coalescing (??)
+$username = $_GET['user'] ?? 'guest';
+$city = $user?->address?->city ?? 'Unknown';
+
+// Вложенный null coalescing
+$value = $a ?? $b ?? $c ?? 'default';
+
+// Null coalescing assignment (??=) PHP 7.4+
+$options['timeout'] ??= 30;
+// Эквивалентно:
+// $options['timeout'] = $options['timeout'] ?? 30;
+```
+
+### Nullsafe оператор (PHP 8.0+)
+```php
+<?php
+// ПРАВИЛЬНО - nullsafe для цепочек
+$city = $user?->getAddress()?->getCity();
+
+// Вместо множества проверок
+$city = null;
+if ($user !== null) {
+    $address = $user->getAddress();
+    if ($address !== null) {
+        $city = $address->getCity();
+    }
+}
+
+// Комбинирование с ?? для default значений
+$city = $user?->getAddress()?->getCity() ?? 'Unknown';
+```
+
+## Switch и Match
+
+### Switch
+```php
+<?php
+// ПРАВИЛЬНО - с фигурными скобками для case
+switch ($status) {
+    case 'pending':
+        $this->processPending();
+        break;
+
+    case 'active':
+    case 'verified':  // fall-through
+        $this->processActive();
+        break;
+
+    case 'blocked':
+        $this->processBlocked();
+        break;
+
+    default:
+        throw new InvalidStatusException($status);
+}
+
+// ВАЖНО - не забывайте break!
+```
+
+### Match (PHP 8.0+)
+```php
+<?php
+// ПРАВИЛЬНО - match для возврата значений
+$statusText = match ($status) {
+    'pending' => 'В ожидании',
+    'active', 'verified' => 'Активен',
+    'blocked' => 'Заблокирован',
+    default => throw new InvalidStatusException($status),
+};
+
+// Match со строгим сравнением (===)
+$result = match (true) {
+    $age < 18 => 'minor',
+    $age < 65 => 'adult',
+    default => 'senior',
+};
+
+// Match с выражениями
+$discount = match (true) {
+    $total >= 1000 => 0.2,
+    $total >= 500 => 0.1,
+    $total >= 100 => 0.05,
+    default => 0,
+};
+```
+
+### Когда использовать switch vs match
+```php
+<?php
+// Используйте match когда:
+// - Нужно вернуть значение
+// - Достаточно строгого сравнения
+// - Каждый case - одно выражение
+
+// Используйте switch когда:
+// - Нужно выполнить несколько операций
+// - Нужен fall-through
+// - Нужно нестрогое сравнение
+```
+
+## Циклы
+
+### foreach
+```php
+<?php
+$users = ['John', 'Jane', 'Bob'];
+
+// ПРАВИЛЬНО - базовый foreach
+foreach ($users as $user) {
+    echo $user;
+}
+
+// С ключом
+foreach ($users as $index => $user) {
+    echo "$index: $user";
+}
+
+// Модификация значений (по ссылке)
+foreach ($users as &$user) {
+    $user = strtoupper($user);
+}
+unset($user);  // ВАЖНО - сбросить ссылку!
+
+// Деструктуризация (PHP 7.1+)
+$points = [
+    ['x' => 1, 'y' => 2],
+    ['x' => 3, 'y' => 4],
+];
+
+foreach ($points as ['x' => $x, 'y' => $y]) {
+    echo "Point: $x, $y";
+}
+```
+
+### for
+```php
+<?php
+// ПРАВИЛЬНО - когда нужен индекс или счётчик
+for ($i = 0; $i < 10; $i++) {
+    echo $i;
+}
+
+// ПРАВИЛЬНО - кэширование count
+$count = count($items);
+for ($i = 0; $i < $count; $i++) {
+    // работа с $items[$i]
+}
+
+// НЕПРАВИЛЬНО - count в условии (вызывается каждую итерацию)
+for ($i = 0; $i < count($items); $i++) {
+    // ...
+}
+```
+
+### while и do-while
+```php
+<?php
+// while - проверка перед итерацией
+while ($row = $result->fetch()) {
+    processRow($row);
+}
+
+// do-while - хотя бы одна итерация
+do {
+    $input = readline('Enter value: ');
+} while (!isValid($input));
+
+// Чтение файла построчно
+$handle = fopen('file.txt', 'r');
+while (($line = fgets($handle)) !== false) {
+    echo $line;
+}
+fclose($handle);
+```
+
+### break и continue
+```php
+<?php
+// break - выход из цикла
+foreach ($items as $item) {
+    if ($item === null) {
+        break;  // выход из foreach
+    }
+    process($item);
+}
+
+// continue - переход к следующей итерации
+foreach ($items as $item) {
+    if (!$item->isValid()) {
+        continue;  // пропустить невалидные
+    }
+    process($item);
+}
+
+// break/continue с уровнем
+foreach ($rows as $row) {
+    foreach ($row as $cell) {
+        if ($cell === 'stop') {
+            break 2;  // выход из обоих циклов
+        }
+    }
+}
+```
+
+## Guard Clauses (Защитные условия)
+
+### Ранний выход
+```php
+<?php
+class OrderService
+{
+    // ПРАВИЛЬНО - guard clauses в начале
+    public function process(Order $order): Result
+    {
+        if ($order === null) {
+            return Result::error('Order is null');
+        }
+
+        if (!$order->isValid()) {
+            return Result::error('Order is invalid');
+        }
+
+        if ($order->isEmpty()) {
+            return Result::error('Order is empty');
+        }
+
+        // Основная логика
+        return $this->doProcess($order);
+    }
+
+    // НЕПРАВИЛЬНО - глубокая вложенность
+    public function process(Order $order): Result
+    {
+        if ($order !== null) {
+            if ($order->isValid()) {
+                if (!$order->isEmpty()) {
+                    // Основная логика
+                    return $this->doProcess($order);
+                } else {
+                    return Result::error('Order is empty');
+                }
+            } else {
+                return Result::error('Order is invalid');
+            }
+        } else {
+            return Result::error('Order is null');
+        }
+    }
+}
+```
+
+### В циклах
+```php
+<?php
+// ПРАВИЛЬНО - continue вместо вложенных if
+foreach ($users as $user) {
+    if (!$user->isActive()) {
+        continue;
+    }
+
+    if ($user->isBlocked()) {
+        continue;
+    }
+
+    sendNewsletter($user);
+}
+
+// НЕПРАВИЛЬНО
+foreach ($users as $user) {
+    if ($user->isActive()) {
+        if (!$user->isBlocked()) {
+            sendNewsletter($user);
+        }
+    }
+}
+```
+
+## Логические операторы
+
+### && и || vs and и or
+```php
+<?php
+// ПРАВИЛЬНО - && и || для условий (высокий приоритет)
+if ($user->isActive() && $user->hasPermission('edit')) {
+    // ...
+}
+
+$result = $value1 || $value2;
+
+// and/or имеют НИЗКИЙ приоритет - осторожно!
+$result = doSomething() or die('Error');  // работает
+$result = doSomething() || die('Error');  // присваивание!
+
+// ПРАВИЛЬНО - явные скобки для ясности
+$result = ($a && $b) || ($c && $d);
+```
+
+### Короткое замыкание
+```php
+<?php
+// && - второй операнд вычисляется только если первый true
+$user !== null && $user->isActive();  // безопасно
+
+// || - второй операнд вычисляется только если первый false
+$value = $cache->get($key) || $database->get($key);
+
+// Практическое применение
+$user && $user->notify();  // notify только если $user не null
+$config = loadFromFile() ?: loadDefaults();  // fallback
+```
+
+## Исключения в control flow
+
+### throw expressions (PHP 8.0+)
+```php
+<?php
+// ПРАВИЛЬНО - throw в выражениях
+$user = $repository->find($id) ?? throw new NotFoundException();
+
+$name = $data['name'] ?? throw new InvalidArgumentException('Name required');
+
+$result = match ($type) {
+    'user' => new UserProcessor(),
+    'order' => new OrderProcessor(),
+    default => throw new InvalidTypeException($type),
+};
+```
+
+## Альтернативный синтаксис (для шаблонов)
+
+```php
+<!-- ПРАВИЛЬНО - в шаблонах -->
+<?php if ($user->isAdmin()): ?>
+    <div class="admin-panel">
+        <!-- контент -->
+    </div>
+<?php endif; ?>
+
+<?php foreach ($items as $item): ?>
+    <li><?= htmlspecialchars($item->name) ?></li>
+<?php endforeach; ?>
+
+<?php switch ($status): ?>
+    <?php case 'active': ?>
+        <span class="badge-success">Active</span>
+        <?php break; ?>
+    <?php case 'pending': ?>
+        <span class="badge-warning">Pending</span>
+        <?php break; ?>
+<?php endswitch; ?>
+
+<!-- НЕПРАВИЛЬНО - фигурные скобки в шаблонах -->
+<?php if ($condition) { ?>
+    <!-- плохо смешивать -->
+<?php } ?>
+```
+
+## Рекомендации для Claude Code
+
+1. **Guard clauses** - используйте для раннего выхода и уменьшения вложенности
+2. **match > switch** - используйте match для возврата значений (PHP 8+)
+3. **Nullsafe (?->)** - вместо множественных проверок на null
+4. **Null coalescing (??)** - для значений по умолчанию
+5. **Всегда break** - не забывайте break в switch
+6. **unset после &** - сбрасывайте ссылку после foreach по ссылке
+7. **Скобки** - всегда используйте {} даже для однострочных блоков
diff --git a/erp24/php_skills/08-php-exceptions.md b/erp24/php_skills/08-php-exceptions.md
new file mode 100644 (file)
index 0000000..0edd0a4
--- /dev/null
@@ -0,0 +1,466 @@
+# PHP Exceptions - Обработка исключений
+
+## Основные принципы
+
+### Создание исключений
+```php
+<?php
+// ПРАВИЛЬНО - информативное сообщение
+throw new InvalidArgumentException('Email cannot be empty');
+
+// С кодом ошибки
+throw new RuntimeException('Database connection failed', 500);
+
+// С предыдущим исключением (цепочка)
+try {
+    $db->query($sql);
+} catch (PDOException $e) {
+    throw new DatabaseException('Query failed: ' . $sql, 0, $e);
+}
+```
+
+### Throw expressions (PHP 8.0+)
+```php
+<?php
+// ПРАВИЛЬНО - throw в выражениях
+$user = $repository->find($id) ?? throw new UserNotFoundException($id);
+
+$name = $data['name'] ?? throw new InvalidArgumentException('Name required');
+
+// В тернарном операторе
+$value = $input > 0
+    ? $input
+    : throw new InvalidArgumentException('Value must be positive');
+
+// В match
+$handler = match ($type) {
+    'email' => new EmailHandler(),
+    'sms' => new SmsHandler(),
+    default => throw new UnsupportedTypeException($type),
+};
+```
+
+## Обработка исключений
+
+### try/catch/finally
+```php
+<?php
+try {
+    // Опасный код
+    $result = $this->riskyOperation();
+
+} catch (InvalidArgumentException $e) {
+    // Обработка конкретного типа
+    $this->logger->warning('Invalid argument', ['error' => $e->getMessage()]);
+    return null;
+
+} catch (RuntimeException | LogicException $e) {
+    // PHP 7.1+ - несколько типов
+    $this->logger->error('Runtime or logic error', ['error' => $e->getMessage()]);
+    throw $e;
+
+} catch (Exception $e) {
+    // Общий обработчик
+    $this->logger->error('Unexpected error', [
+        'class' => get_class($e),
+        'message' => $e->getMessage(),
+        'trace' => $e->getTraceAsString(),
+    ]);
+    throw $e;
+
+} finally {
+    // Выполняется ВСЕГДА
+    $this->cleanup();
+}
+```
+
+### Порядок catch блоков
+```php
+<?php
+// ПРАВИЛЬНО - от специфичных к общим
+try {
+    $this->processFile($path);
+} catch (FileNotFoundException $e) {
+    // Самое специфичное
+} catch (IOException $e) {
+    // Более общее
+} catch (Exception $e) {
+    // Самое общее
+}
+
+// НЕПРАВИЛЬНО - Exception первым поймает всё
+try {
+    $this->processFile($path);
+} catch (Exception $e) {
+    // Поймает все исключения!
+} catch (FileNotFoundException $e) {
+    // Никогда не выполнится
+}
+```
+
+## Кастомные исключения
+
+### Иерархия исключений
+```php
+<?php
+
+namespace App\Exceptions;
+
+/**
+ * Базовое исключение приложения.
+ */
+class ApplicationException extends \Exception
+{
+    protected array $context = [];
+
+    public function __construct(
+        string $message = '',
+        int $code = 0,
+        ?\Throwable $previous = null,
+        array $context = []
+    ) {
+        parent::__construct($message, $code, $previous);
+        $this->context = $context;
+    }
+
+    public function getContext(): array
+    {
+        return $this->context;
+    }
+}
+
+/**
+ * Исключения валидации.
+ */
+class ValidationException extends ApplicationException
+{
+    protected array $errors = [];
+
+    public function __construct(array $errors, string $message = 'Validation failed')
+    {
+        parent::__construct($message, 422);
+        $this->errors = $errors;
+    }
+
+    public function getErrors(): array
+    {
+        return $this->errors;
+    }
+}
+
+/**
+ * Исключения для ненайденных ресурсов.
+ */
+class NotFoundException extends ApplicationException
+{
+    public function __construct(string $resource, int|string $id)
+    {
+        parent::__construct(
+            sprintf('%s with ID %s not found', $resource, $id),
+            404,
+            null,
+            ['resource' => $resource, 'id' => $id]
+        );
+    }
+}
+
+class UserNotFoundException extends NotFoundException
+{
+    public function __construct(int|string $id)
+    {
+        parent::__construct('User', $id);
+    }
+}
+
+/**
+ * Исключения авторизации.
+ */
+class AuthorizationException extends ApplicationException
+{
+    public function __construct(string $message = 'Access denied')
+    {
+        parent::__construct($message, 403);
+    }
+}
+
+/**
+ * Исключения аутентификации.
+ */
+class AuthenticationException extends ApplicationException
+{
+    public function __construct(string $message = 'Authentication required')
+    {
+        parent::__construct($message, 401);
+    }
+}
+```
+
+### Использование кастомных исключений
+```php
+<?php
+
+class UserService
+{
+    public function find(int $id): User
+    {
+        $user = $this->repository->find($id);
+
+        if ($user === null) {
+            throw new UserNotFoundException($id);
+        }
+
+        return $user;
+    }
+
+    public function update(int $id, array $data): User
+    {
+        $user = $this->find($id);
+
+        $errors = $this->validate($data);
+        if (!empty($errors)) {
+            throw new ValidationException($errors);
+        }
+
+        return $this->repository->update($user, $data);
+    }
+
+    public function delete(int $id, User $actor): void
+    {
+        $user = $this->find($id);
+
+        if (!$actor->canDelete($user)) {
+            throw new AuthorizationException('Cannot delete this user');
+        }
+
+        $this->repository->delete($user);
+    }
+}
+```
+
+## Антипаттерны
+
+### Не скрывайте исключения
+```php
+<?php
+// НЕПРАВИЛЬНО - тихое проглатывание
+try {
+    $this->riskyOperation();
+} catch (Exception $e) {
+    // Ничего не делаем - плохо!
+}
+
+// ПРАВИЛЬНО - хотя бы логирование
+try {
+    $this->riskyOperation();
+} catch (Exception $e) {
+    $this->logger->error('Operation failed', [
+        'exception' => $e,
+    ]);
+    // Можно вернуть default или re-throw
+}
+```
+
+### Не используйте исключения для control flow
+```php
+<?php
+// НЕПРАВИЛЬНО - исключения для обычной логики
+try {
+    $user = $this->findUser($id);
+} catch (UserNotFoundException $e) {
+    return $this->createUser($data);
+}
+
+// ПРАВИЛЬНО - проверка условия
+$user = $this->findUser($id);
+if ($user === null) {
+    return $this->createUser($data);
+}
+```
+
+### Не перехватывайте Exception/Throwable без необходимости
+```php
+<?php
+// НЕПРАВИЛЬНО - ловит всё подряд
+try {
+    $this->process();
+} catch (Throwable $e) {
+    // Ловит даже Error (TypeError, ParseError и т.д.)
+}
+
+// ПРАВИЛЬНО - ловите конкретные исключения
+try {
+    $this->process();
+} catch (ProcessingException $e) {
+    // Обработка известных ошибок
+} catch (Exception $e) {
+    // Обработка неожиданных ошибок
+    $this->logger->error('Unexpected exception', ['exception' => $e]);
+    throw $e;
+}
+```
+
+## Повторные попытки (Retry)
+
+```php
+<?php
+class RetryableOperation
+{
+    private int $maxAttempts = 3;
+    private int $delayMs = 100;
+
+    public function execute(callable $operation): mixed
+    {
+        $attempt = 0;
+        $lastException = null;
+
+        while ($attempt < $this->maxAttempts) {
+            try {
+                return $operation();
+            } catch (RetryableException $e) {
+                $lastException = $e;
+                $attempt++;
+
+                if ($attempt < $this->maxAttempts) {
+                    // Экспоненциальная задержка
+                    usleep($this->delayMs * 1000 * (2 ** $attempt));
+                }
+            }
+        }
+
+        throw new MaxRetriesExceededException(
+            "Failed after {$this->maxAttempts} attempts",
+            0,
+            $lastException
+        );
+    }
+}
+
+// Использование
+$operation = new RetryableOperation();
+$result = $operation->execute(function () use ($api) {
+    return $api->request('/endpoint');
+});
+```
+
+## Логирование исключений
+
+```php
+<?php
+class ExceptionHandler
+{
+    public function __construct(
+        private LoggerInterface $logger,
+    ) {}
+
+    public function handle(Throwable $e): void
+    {
+        $context = [
+            'class' => get_class($e),
+            'message' => $e->getMessage(),
+            'code' => $e->getCode(),
+            'file' => $e->getFile(),
+            'line' => $e->getLine(),
+            'trace' => $e->getTraceAsString(),
+        ];
+
+        // Добавляем контекст из кастомных исключений
+        if ($e instanceof ApplicationException) {
+            $context['custom_context'] = $e->getContext();
+        }
+
+        // Добавляем предыдущие исключения
+        if ($previous = $e->getPrevious()) {
+            $context['previous'] = [
+                'class' => get_class($previous),
+                'message' => $previous->getMessage(),
+            ];
+        }
+
+        // Уровень логирования зависит от типа
+        match (true) {
+            $e instanceof ValidationException => $this->logger->notice('Validation error', $context),
+            $e instanceof NotFoundException => $this->logger->info('Resource not found', $context),
+            $e instanceof AuthorizationException => $this->logger->warning('Access denied', $context),
+            default => $this->logger->error('Exception occurred', $context),
+        };
+    }
+}
+```
+
+## Стандартные исключения PHP
+
+```php
+<?php
+// Иерархия исключений PHP
+
+// Throwable (интерфейс)
+// ├── Error (ошибки PHP, не ловите обычно)
+// │   ├── TypeError
+// │   ├── ParseError
+// │   ├── ArithmeticError
+// │   │   └── DivisionByZeroError
+// │   └── AssertionError
+// └── Exception
+//     ├── LogicException (ошибки в логике программы)
+//     │   ├── BadFunctionCallException
+//     │   │   └── BadMethodCallException
+//     │   ├── DomainException
+//     │   ├── InvalidArgumentException
+//     │   ├── LengthException
+//     │   └── OutOfRangeException
+//     └── RuntimeException (ошибки во время выполнения)
+//         ├── OutOfBoundsException
+//         ├── OverflowException
+//         ├── RangeException
+//         ├── UnderflowException
+//         └── UnexpectedValueException
+
+// Когда использовать:
+throw new InvalidArgumentException('Invalid email format');
+throw new RuntimeException('Cannot connect to database');
+throw new LogicException('Method must be overridden');
+throw new BadMethodCallException('Method not implemented');
+throw new OutOfBoundsException('Index out of bounds');
+```
+
+## finally и возврат значений
+
+```php
+<?php
+function riskyOperation(): string
+{
+    try {
+        // Какая-то операция
+        return 'success';
+    } catch (Exception $e) {
+        return 'error';
+    } finally {
+        // ВСЕГДА выполняется перед возвратом
+        cleanup();
+        // НЕ используйте return в finally - перезапишет результат!
+    }
+}
+
+// ОСТОРОЖНО с return в finally
+function badExample(): string
+{
+    try {
+        throw new Exception('Error');
+    } catch (Exception $e) {
+        return 'from catch';
+    } finally {
+        return 'from finally';  // Перезапишет 'from catch'!
+    }
+}
+echo badExample();  // 'from finally'
+```
+
+## Рекомендации для Claude Code
+
+1. **Кастомные исключения** - создавайте иерархию для вашего приложения
+2. **Информативные сообщения** - включайте контекст в сообщения
+3. **Цепочка исключений** - передавайте $previous для отладки
+4. **Логирование** - всегда логируйте исключения
+5. **Не скрывайте** - никогда не проглатывайте исключения молча
+6. **Конкретные типы** - ловите конкретные исключения, не Exception
+7. **finally** - используйте для очистки, но без return
diff --git a/erp24/php_skills/09-php-closures.md b/erp24/php_skills/09-php-closures.md
new file mode 100644 (file)
index 0000000..4d9a648
--- /dev/null
@@ -0,0 +1,516 @@
+# PHP Closures - Замыкания и колбэки
+
+## Анонимные функции (Closures)
+
+### Базовый синтаксис
+```php
+<?php
+// Анонимная функция
+$greet = function (string $name): string {
+    return "Hello, $name!";
+};
+
+echo $greet('World');  // Hello, World!
+
+// С переменными из внешней области (use)
+$prefix = 'Mr.';
+$greet = function (string $name) use ($prefix): string {
+    return "Hello, $prefix $name!";
+};
+
+echo $greet('Smith');  // Hello, Mr. Smith!
+```
+
+### Захват переменных
+```php
+<?php
+// По значению (копия)
+$counter = 0;
+$increment = function () use ($counter) {
+    $counter++;  // Изменяет только локальную копию
+    return $counter;
+};
+
+echo $increment();  // 1
+echo $counter;      // 0 - оригинал не изменился
+
+// По ссылке
+$counter = 0;
+$increment = function () use (&$counter) {
+    $counter++;
+    return $counter;
+};
+
+echo $increment();  // 1
+echo $increment();  // 2
+echo $counter;      // 2 - оригинал изменён
+```
+
+### Множественный захват
+```php
+<?php
+$prefix = 'User';
+$suffix = '!';
+$counter = 0;
+
+$format = function (string $name) use ($prefix, $suffix, &$counter): string {
+    $counter++;
+    return "$prefix: $name$suffix ($counter)";
+};
+
+echo $format('John');  // User: John! (1)
+echo $format('Jane');  // User: Jane! (2)
+```
+
+## Arrow функции (PHP 7.4+)
+
+### Базовый синтаксис
+```php
+<?php
+// ПРАВИЛЬНО - для простых выражений
+$double = fn(int $n): int => $n * 2;
+$sum = fn(int $a, int $b): int => $a + $b;
+
+// Автоматический захват переменных (по значению)
+$multiplier = 3;
+$multiply = fn(int $n): int => $n * $multiplier;
+
+echo $multiply(5);  // 15
+```
+
+### Arrow vs традиционные замыкания
+```php
+<?php
+$numbers = [1, 2, 3, 4, 5];
+$multiplier = 2;
+
+// Arrow функция - короче и автозахват
+$doubled = array_map(fn($n) => $n * $multiplier, $numbers);
+
+// Традиционная - нужен явный use
+$doubled = array_map(function ($n) use ($multiplier) {
+    return $n * $multiplier;
+}, $numbers);
+
+// Arrow - только одно выражение
+// Традиционная - для сложной логики
+$process = function ($item) use ($config) {
+    if (!$item->isValid()) {
+        return null;
+    }
+
+    $result = $item->transform($config);
+    $this->log($result);
+
+    return $result;
+};
+```
+
+### Ограничения Arrow функций
+```php
+<?php
+// НЕЛЬЗЯ - несколько выражений
+// $fn = fn($x) => { $a = $x; return $a * 2; }; // Syntax error!
+
+// НЕЛЬЗЯ - захват по ссылке (всегда по значению)
+$counter = 0;
+$increment = fn() => $counter++;  // $counter не изменится
+
+// Для этих случаев используйте традиционные замыкания
+$increment = function () use (&$counter) {
+    return $counter++;
+};
+```
+
+## Использование с array функциями
+
+### array_map
+```php
+<?php
+$users = [
+    ['name' => 'John', 'age' => 30],
+    ['name' => 'Jane', 'age' => 25],
+];
+
+// Извлечение значений
+$names = array_map(fn($u) => $u['name'], $users);
+
+// Трансформация
+$formatted = array_map(
+    fn($u) => sprintf('%s (%d)', $u['name'], $u['age']),
+    $users
+);
+
+// Несколько массивов
+$a = [1, 2, 3];
+$b = [10, 20, 30];
+$sum = array_map(fn($x, $y) => $x + $y, $a, $b);
+```
+
+### array_filter
+```php
+<?php
+$numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
+
+// Фильтрация чётных
+$even = array_filter($numbers, fn($n) => $n % 2 === 0);
+
+// Фильтрация объектов
+$users = [/* ... */];
+$activeUsers = array_filter($users, fn($u) => $u->isActive());
+
+// С ключами
+$data = ['a' => 1, 'b' => 2, 'c' => 3];
+$filtered = array_filter(
+    $data,
+    fn($value, $key) => $key !== 'b' && $value > 1,
+    ARRAY_FILTER_USE_BOTH
+);
+```
+
+### array_reduce
+```php
+<?php
+$numbers = [1, 2, 3, 4, 5];
+
+// Сумма
+$sum = array_reduce($numbers, fn($carry, $n) => $carry + $n, 0);
+
+// Группировка
+$users = [
+    ['name' => 'John', 'role' => 'admin'],
+    ['name' => 'Jane', 'role' => 'user'],
+    ['name' => 'Bob', 'role' => 'admin'],
+];
+
+$grouped = array_reduce($users, function ($carry, $user) {
+    $carry[$user['role']][] = $user['name'];
+    return $carry;
+}, []);
+// ['admin' => ['John', 'Bob'], 'user' => ['Jane']]
+```
+
+### usort и сортировка
+```php
+<?php
+$users = [
+    ['name' => 'John', 'age' => 30],
+    ['name' => 'Jane', 'age' => 25],
+    ['name' => 'Bob', 'age' => 35],
+];
+
+// Сортировка по возрасту
+usort($users, fn($a, $b) => $a['age'] <=> $b['age']);
+
+// Сортировка по нескольким полям
+usort($users, fn($a, $b) =>
+    $a['role'] <=> $b['role']
+    ?: $a['name'] <=> $b['name']
+);
+
+// uasort - сохраняет ключи
+uasort($users, fn($a, $b) => $a['age'] <=> $b['age']);
+```
+
+## Callable и типы
+
+### Callable type hint
+```php
+<?php
+class Processor
+{
+    /**
+     * @param callable(mixed): mixed $transformer
+     */
+    public function process(array $items, callable $transformer): array
+    {
+        return array_map($transformer, $items);
+    }
+}
+
+$processor = new Processor();
+
+// Анонимная функция
+$result = $processor->process([1, 2, 3], fn($n) => $n * 2);
+
+// Функция по имени
+$result = $processor->process(['a', 'b'], 'strtoupper');
+
+// Метод объекта
+$result = $processor->process($items, [$object, 'transform']);
+
+// Статический метод
+$result = $processor->process($items, [MyClass::class, 'transform']);
+
+// Invokable объект
+$result = $processor->process($items, new Transformer());
+```
+
+### Closure type hint
+```php
+<?php
+class EventDispatcher
+{
+    private array $listeners = [];
+
+    // Принимает только Closure, не любой callable
+    public function on(string $event, \Closure $listener): void
+    {
+        $this->listeners[$event][] = $listener;
+    }
+
+    public function dispatch(string $event, mixed $data = null): void
+    {
+        foreach ($this->listeners[$event] ?? [] as $listener) {
+            $listener($data);
+        }
+    }
+}
+
+$dispatcher = new EventDispatcher();
+
+// Работает
+$dispatcher->on('user.created', fn($user) => sendEmail($user));
+
+// Не работает - не Closure
+// $dispatcher->on('user.created', 'sendEmail');  // TypeError
+// $dispatcher->on('user.created', [$mailer, 'send']);  // TypeError
+```
+
+## Closure методы
+
+### Closure::bind и bindTo
+```php
+<?php
+class User
+{
+    private string $name = 'John';
+    private int $age = 30;
+}
+
+$getPrivateData = function () {
+    return [$this->name, $this->age];
+};
+
+$user = new User();
+
+// Привязка к объекту и классу
+$bound = Closure::bind($getPrivateData, $user, User::class);
+[$name, $age] = $bound();  // ['John', 30]
+
+// Или через bindTo
+$bound = $getPrivateData->bindTo($user, User::class);
+```
+
+### Closure::fromCallable
+```php
+<?php
+class Calculator
+{
+    public function add(int $a, int $b): int
+    {
+        return $a + $b;
+    }
+}
+
+$calc = new Calculator();
+
+// Преобразование метода в Closure
+$closure = Closure::fromCallable([$calc, 'add']);
+echo $closure(2, 3);  // 5
+
+// First-class callable syntax (PHP 8.1+)
+$closure = $calc->add(...);
+echo $closure(2, 3);  // 5
+```
+
+### First-class callables (PHP 8.1+)
+```php
+<?php
+class Formatter
+{
+    public function format(string $value): string
+    {
+        return strtoupper($value);
+    }
+
+    public static function staticFormat(string $value): string
+    {
+        return strtolower($value);
+    }
+}
+
+$formatter = new Formatter();
+
+// Получение замыкания из метода
+$format = $formatter->format(...);
+$staticFormat = Formatter::staticFormat(...);
+$strlen = strlen(...);
+
+// Использование
+$names = ['John', 'Jane'];
+$upper = array_map($format, $names);
+$lower = array_map($staticFormat, $names);
+$lengths = array_map($strlen, $names);
+```
+
+## Invokable объекты
+
+### Магический метод __invoke
+```php
+<?php
+class Greeter
+{
+    public function __construct(
+        private string $greeting = 'Hello'
+    ) {}
+
+    public function __invoke(string $name): string
+    {
+        return "{$this->greeting}, {$name}!";
+    }
+}
+
+$greeter = new Greeter('Hi');
+echo $greeter('World');  // Hi, World!
+
+// Можно использовать как callable
+$names = ['John', 'Jane'];
+$greetings = array_map(new Greeter('Hello'), $names);
+```
+
+### Паттерн Handler/Action
+```php
+<?php
+interface Handler
+{
+    public function __invoke(Request $request): Response;
+}
+
+class CreateUserHandler implements Handler
+{
+    public function __construct(
+        private UserRepository $repository,
+        private Validator $validator,
+    ) {}
+
+    public function __invoke(Request $request): Response
+    {
+        $data = $request->all();
+
+        if (!$this->validator->validate($data)) {
+            return new ValidationErrorResponse($this->validator->errors());
+        }
+
+        $user = $this->repository->create($data);
+
+        return new JsonResponse($user, 201);
+    }
+}
+
+// Использование
+$handler = new CreateUserHandler($repository, $validator);
+$response = $handler($request);
+```
+
+## Паттерны использования
+
+### Callbacks и hooks
+```php
+<?php
+class Pipeline
+{
+    private array $pipes = [];
+
+    public function pipe(callable $callback): self
+    {
+        $this->pipes[] = $callback;
+        return $this;
+    }
+
+    public function process(mixed $payload): mixed
+    {
+        foreach ($this->pipes as $pipe) {
+            $payload = $pipe($payload);
+        }
+        return $payload;
+    }
+}
+
+$pipeline = (new Pipeline())
+    ->pipe(fn($data) => array_filter($data))
+    ->pipe(fn($data) => array_map('trim', $data))
+    ->pipe(fn($data) => array_unique($data));
+
+$result = $pipeline->process($input);
+```
+
+### Lazy evaluation
+```php
+<?php
+class Lazy
+{
+    private mixed $value = null;
+    private bool $evaluated = false;
+
+    public function __construct(
+        private \Closure $initializer
+    ) {}
+
+    public function get(): mixed
+    {
+        if (!$this->evaluated) {
+            $this->value = ($this->initializer)();
+            $this->evaluated = true;
+        }
+        return $this->value;
+    }
+}
+
+// Использование
+$expensive = new Lazy(fn() => heavyComputation());
+
+// Вычисляется только при первом вызове
+$value = $expensive->get();
+$value = $expensive->get();  // Возвращает кэшированное
+```
+
+### Event listeners
+```php
+<?php
+class EventEmitter
+{
+    private array $listeners = [];
+
+    public function on(string $event, callable $callback): void
+    {
+        $this->listeners[$event][] = $callback;
+    }
+
+    public function emit(string $event, mixed ...$args): void
+    {
+        foreach ($this->listeners[$event] ?? [] as $callback) {
+            $callback(...$args);
+        }
+    }
+}
+
+$emitter = new EventEmitter();
+
+$emitter->on('user.created', function (User $user) {
+    sendWelcomeEmail($user);
+});
+
+$emitter->on('user.created', fn(User $user) => logActivity($user));
+
+$emitter->emit('user.created', $newUser);
+```
+
+## Рекомендации для Claude Code
+
+1. **Arrow функции** - для простых однострочных выражений
+2. **Традиционные замыкания** - для сложной логики или захвата по ссылке
+3. **Типизация** - используйте callable или Closure в параметрах
+4. **First-class callables** - используйте `$obj->method(...)` синтаксис (PHP 8.1+)
+5. **Invokable** - для объектов-обработчиков с одной ответственностью
+6. **unset после &** - помните, что захват по ссылке может вызвать утечки
diff --git a/erp24/php_skills/10-yii2-structure.md b/erp24/php_skills/10-yii2-structure.md
new file mode 100644 (file)
index 0000000..ae2bb1d
--- /dev/null
@@ -0,0 +1,743 @@
+# Yii2 Structure - Структура приложения
+
+## Структура директорий
+
+### Стандартная структура Yii2 Basic
+```
+project/
+├── assets/              # Asset bundles
+├── commands/            # Console команды
+├── config/              # Конфигурационные файлы
+│   ├── console.php      # Конфиг консоли
+│   ├── db.php           # Подключение к БД
+│   ├── params.php       # Параметры приложения
+│   └── web.php          # Конфиг веб-приложения
+├── controllers/         # Контроллеры
+├── mail/                # Шаблоны писем
+├── models/              # Модели
+├── runtime/             # Временные файлы
+├── views/               # Представления
+│   ├── layouts/         # Макеты
+│   └── site/            # Views для SiteController
+├── web/                 # Document root
+│   ├── assets/          # Опубликованные assets
+│   ├── css/             # CSS файлы
+│   └── index.php        # Entry script
+├── widgets/             # Виджеты
+└── tests/               # Тесты
+```
+
+### Структура Yii2 Advanced
+```
+project/
+├── backend/             # Административная часть
+│   ├── assets/
+│   ├── config/
+│   ├── controllers/
+│   ├── models/
+│   ├── runtime/
+│   ├── views/
+│   └── web/
+├── common/              # Общий код
+│   ├── config/
+│   ├── mail/
+│   ├── models/
+│   └── tests/
+├── console/             # Консольные команды
+│   ├── config/
+│   ├── controllers/
+│   ├── migrations/
+│   └── runtime/
+├── frontend/            # Клиентская часть
+│   ├── assets/
+│   ├── config/
+│   ├── controllers/
+│   ├── models/
+│   ├── runtime/
+│   ├── views/
+│   └── web/
+├── environments/        # Окружения
+│   ├── dev/
+│   └── prod/
+└── vendor/              # Зависимости Composer
+```
+
+## Модульная структура
+
+### Организация для больших проектов
+```
+project/
+├── api/                 # REST API модуль
+│   ├── config/
+│   ├── controllers/
+│   │   ├── v1/
+│   │   │   ├── UserController.php
+│   │   │   └── OrderController.php
+│   │   └── v2/
+│   ├── models/
+│   └── modules/
+├── modules/             # Модули приложения
+│   ├── admin/
+│   │   ├── Module.php
+│   │   ├── controllers/
+│   │   ├── models/
+│   │   └── views/
+│   ├── user/
+│   │   ├── Module.php
+│   │   ├── controllers/
+│   │   ├── models/
+│   │   └── views/
+│   └── payment/
+├── services/            # Сервисные классы
+│   ├── payment/
+│   │   ├── PaymentService.php
+│   │   └── StripeService.php
+│   └── notification/
+│       └── EmailService.php
+├── repositories/        # Репозитории
+│   └── UserRepository.php
+├── components/          # Компоненты приложения
+│   ├── AuthManager.php
+│   └── Notifier.php
+├── helpers/             # Вспомогательные классы
+│   ├── DateHelper.php
+│   └── StringHelper.php
+├── validators/          # Кастомные валидаторы
+│   └── PhoneValidator.php
+└── behaviors/           # Behaviors
+    ├── TimestampBehavior.php
+    └── BlameableBehavior.php
+```
+
+## Конфигурация
+
+### config/web.php (Basic)
+```php
+<?php
+$params = require __DIR__ . '/params.php';
+$db = require __DIR__ . '/db.php';
+
+$config = [
+    'id' => 'basic',
+    'name' => 'My Application',
+    'basePath' => dirname(__DIR__),
+    'bootstrap' => ['log'],
+    'language' => 'ru-RU',
+    'sourceLanguage' => 'en-US',
+    'timeZone' => 'Europe/Moscow',
+    'aliases' => [
+        '@bower' => '@vendor/bower-asset',
+        '@npm' => '@vendor/npm-asset',
+    ],
+    'components' => [
+        'request' => [
+            'cookieValidationKey' => 'your-secret-key-here',
+            'parsers' => [
+                'application/json' => \yii\web\JsonParser::class,
+            ],
+        ],
+        'cache' => [
+            'class' => \yii\caching\FileCache::class,
+        ],
+        'user' => [
+            'identityClass' => \app\models\User::class,
+            'enableAutoLogin' => true,
+            'loginUrl' => ['site/login'],
+        ],
+        'errorHandler' => [
+            'errorAction' => 'site/error',
+        ],
+        'mailer' => [
+            'class' => \yii\symfonymailer\Mailer::class,
+            'viewPath' => '@app/mail',
+            'useFileTransport' => true,
+        ],
+        'log' => [
+            'traceLevel' => YII_DEBUG ? 3 : 0,
+            'targets' => [
+                [
+                    'class' => \yii\log\FileTarget::class,
+                    'levels' => ['error', 'warning'],
+                ],
+            ],
+        ],
+        'db' => $db,
+        'urlManager' => [
+            'enablePrettyUrl' => true,
+            'showScriptName' => false,
+            'rules' => [
+                '' => 'site/index',
+                '<controller:\w+>/<action:\w+>/<id:\d+>' => '<controller>/<action>',
+                '<controller:\w+>/<action:\w+>' => '<controller>/<action>',
+            ],
+        ],
+    ],
+    'params' => $params,
+];
+
+if (YII_ENV_DEV) {
+    $config['bootstrap'][] = 'debug';
+    $config['modules']['debug'] = [
+        'class' => \yii\debug\Module::class,
+        'allowedIPs' => ['127.0.0.1', '::1'],
+    ];
+
+    $config['bootstrap'][] = 'gii';
+    $config['modules']['gii'] = [
+        'class' => \yii\gii\Module::class,
+        'allowedIPs' => ['127.0.0.1', '::1'],
+    ];
+}
+
+return $config;
+```
+
+### config/db.php
+```php
+<?php
+return [
+    'class' => \yii\db\Connection::class,
+    'dsn' => 'pgsql:host=localhost;dbname=myapp',
+    'username' => 'myapp',
+    'password' => getenv('DB_PASSWORD'),
+    'charset' => 'utf8',
+    'schemaMap' => [
+        'pgsql' => [
+            'class' => \yii\db\pgsql\Schema::class,
+            'defaultSchema' => 'public',
+        ],
+    ],
+    'enableSchemaCache' => !YII_DEBUG,
+    'schemaCacheDuration' => 3600,
+    'schemaCache' => 'cache',
+];
+```
+
+### config/params.php
+```php
+<?php
+return [
+    'adminEmail' => 'admin@example.com',
+    'supportEmail' => 'support@example.com',
+    'senderEmail' => 'noreply@example.com',
+    'senderName' => 'My Application',
+    'user.passwordResetTokenExpire' => 3600,
+    'user.passwordMinLength' => 8,
+    'pagination' => [
+        'defaultPageSize' => 20,
+        'pageSizeLimit' => [1, 100],
+    ],
+];
+```
+
+## Окружения
+
+### Переменные окружения
+```php
+<?php
+// web/index.php
+defined('YII_DEBUG') or define('YII_DEBUG', getenv('YII_DEBUG') === 'true');
+defined('YII_ENV') or define('YII_ENV', getenv('YII_ENV') ?: 'prod');
+
+require __DIR__ . '/../vendor/autoload.php';
+require __DIR__ . '/../vendor/yiisoft/yii2/Yii.php';
+
+$config = require __DIR__ . '/../config/web.php';
+
+(new yii\web\Application($config))->run();
+```
+
+### Конфигурация по окружению
+```php
+<?php
+// config/web.php
+$config = [
+    // ... базовая конфигурация
+];
+
+// Настройки для разработки
+if (YII_ENV_DEV) {
+    $config['components']['cache'] = [
+        'class' => \yii\caching\DummyCache::class,
+    ];
+    $config['components']['mailer']['useFileTransport'] = true;
+}
+
+// Настройки для продакшена
+if (YII_ENV_PROD) {
+    $config['components']['cache'] = [
+        'class' => \yii\redis\Cache::class,
+        'redis' => [
+            'hostname' => getenv('REDIS_HOST'),
+            'port' => 6379,
+            'database' => 0,
+        ],
+    ];
+    $config['components']['log']['targets'][] = [
+        'class' => \yii\log\DbTarget::class,
+        'levels' => ['error'],
+    ];
+}
+
+return $config;
+```
+
+## Модули
+
+### Создание модуля
+```php
+<?php
+// modules/admin/Module.php
+namespace app\modules\admin;
+
+class Module extends \yii\base\Module
+{
+    public $controllerNamespace = 'app\modules\admin\controllers';
+
+    public string $defaultRoute = 'dashboard';
+
+    public function init(): void
+    {
+        parent::init();
+
+        // Кастомная конфигурация модуля
+        \Yii::configure($this, require __DIR__ . '/config/main.php');
+    }
+
+    public function beforeAction($action): bool
+    {
+        if (!parent::beforeAction($action)) {
+            return false;
+        }
+
+        // Проверка прав доступа к модулю
+        if (\Yii::$app->user->isGuest) {
+            \Yii::$app->response->redirect(['/site/login']);
+            return false;
+        }
+
+        return true;
+    }
+}
+```
+
+### Регистрация модуля
+```php
+<?php
+// config/web.php
+return [
+    'modules' => [
+        'admin' => [
+            'class' => \app\modules\admin\Module::class,
+            'layout' => 'admin',
+        ],
+        'api' => [
+            'class' => \app\modules\api\Module::class,
+        ],
+    ],
+];
+```
+
+### Вложенные модули
+```php
+<?php
+// modules/admin/Module.php
+public function init(): void
+{
+    parent::init();
+
+    $this->modules = [
+        'users' => [
+            'class' => \app\modules\admin\modules\users\Module::class,
+        ],
+        'reports' => [
+            'class' => \app\modules\admin\modules\reports\Module::class,
+        ],
+    ];
+}
+```
+
+## Компоненты приложения
+
+### Создание кастомного компонента
+```php
+<?php
+// components/Notifier.php
+namespace app\components;
+
+use yii\base\Component;
+
+class Notifier extends Component
+{
+    public string $defaultChannel = 'email';
+    public array $channels = [];
+
+    public function init(): void
+    {
+        parent::init();
+
+        // Инициализация каналов
+        foreach ($this->channels as $name => $config) {
+            $this->channels[$name] = \Yii::createObject($config);
+        }
+    }
+
+    public function send(string $message, ?string $channel = null): bool
+    {
+        $channel ??= $this->defaultChannel;
+
+        if (!isset($this->channels[$channel])) {
+            throw new \InvalidArgumentException("Channel '$channel' not found");
+        }
+
+        return $this->channels[$channel]->send($message);
+    }
+}
+```
+
+### Регистрация компонента
+```php
+<?php
+// config/web.php
+return [
+    'components' => [
+        'notifier' => [
+            'class' => \app\components\Notifier::class,
+            'defaultChannel' => 'telegram',
+            'channels' => [
+                'email' => [
+                    'class' => \app\components\channels\EmailChannel::class,
+                ],
+                'telegram' => [
+                    'class' => \app\components\channels\TelegramChannel::class,
+                    'botToken' => getenv('TELEGRAM_BOT_TOKEN'),
+                ],
+            ],
+        ],
+    ],
+];
+
+// Использование
+Yii::$app->notifier->send('Hello!');
+Yii::$app->notifier->send('Hello!', 'email');
+```
+
+## Сервисные классы
+
+### Структура сервиса
+```php
+<?php
+// services/payment/PaymentService.php
+namespace app\services\payment;
+
+use app\models\Order;
+use app\models\Payment;
+use yii\base\Component;
+use yii\base\InvalidConfigException;
+
+class PaymentService extends Component
+{
+    public string $defaultGateway = 'stripe';
+
+    private array $gateways = [];
+
+    /**
+     * Создание платежа
+     */
+    public function createPayment(Order $order, string $gateway = null): Payment
+    {
+        $gateway ??= $this->defaultGateway;
+
+        $payment = new Payment([
+            'order_id' => $order->id,
+            'amount' => $order->total,
+            'gateway' => $gateway,
+            'status' => Payment::STATUS_PENDING,
+        ]);
+
+        if (!$payment->save()) {
+            throw new \RuntimeException('Failed to create payment');
+        }
+
+        return $payment;
+    }
+
+    /**
+     * Обработка платежа
+     */
+    public function processPayment(Payment $payment): bool
+    {
+        $gateway = $this->getGateway($payment->gateway);
+
+        $transaction = \Yii::$app->db->beginTransaction();
+
+        try {
+            $result = $gateway->charge($payment);
+
+            $payment->status = $result->isSuccess()
+                ? Payment::STATUS_SUCCESS
+                : Payment::STATUS_FAILED;
+            $payment->transaction_id = $result->getTransactionId();
+            $payment->save(false);
+
+            if ($result->isSuccess()) {
+                $payment->order->markAsPaid();
+            }
+
+            $transaction->commit();
+
+            return $result->isSuccess();
+        } catch (\Throwable $e) {
+            $transaction->rollBack();
+            \Yii::error($e->getMessage(), __METHOD__);
+            throw $e;
+        }
+    }
+
+    private function getGateway(string $name): PaymentGatewayInterface
+    {
+        if (!isset($this->gateways[$name])) {
+            throw new InvalidConfigException("Gateway '$name' not configured");
+        }
+
+        return $this->gateways[$name];
+    }
+}
+```
+
+## Консольные команды
+
+### Создание команды
+```php
+<?php
+// commands/CleanupController.php
+namespace app\commands;
+
+use yii\console\Controller;
+use yii\console\ExitCode;
+use yii\helpers\Console;
+
+class CleanupController extends Controller
+{
+    /**
+     * @var int Количество дней для хранения
+     */
+    public int $days = 30;
+
+    public function options($actionID): array
+    {
+        return array_merge(parent::options($actionID), [
+            'days',
+        ]);
+    }
+
+    public function optionAliases(): array
+    {
+        return array_merge(parent::optionAliases(), [
+            'd' => 'days',
+        ]);
+    }
+
+    /**
+     * Очистка устаревших логов
+     *
+     * @return int Exit code
+     */
+    public function actionLogs(): int
+    {
+        $this->stdout("Cleaning logs older than {$this->days} days...\n", Console::FG_YELLOW);
+
+        $cutoff = date('Y-m-d', strtotime("-{$this->days} days"));
+
+        $count = \Yii::$app->db->createCommand()
+            ->delete('{{%log}}', ['<', 'created_at', $cutoff])
+            ->execute();
+
+        $this->stdout("Deleted $count log entries.\n", Console::FG_GREEN);
+
+        return ExitCode::OK;
+    }
+
+    /**
+     * Очистка временных файлов
+     */
+    public function actionTemp(): int
+    {
+        $path = \Yii::getAlias('@runtime/temp');
+
+        if (!is_dir($path)) {
+            $this->stderr("Directory not found: $path\n", Console::FG_RED);
+            return ExitCode::UNSPECIFIED_ERROR;
+        }
+
+        $files = glob($path . '/*');
+        $deleted = 0;
+
+        foreach ($files as $file) {
+            if (filemtime($file) < strtotime("-{$this->days} days")) {
+                unlink($file);
+                $deleted++;
+            }
+        }
+
+        $this->stdout("Deleted $deleted temp files.\n", Console::FG_GREEN);
+
+        return ExitCode::OK;
+    }
+}
+```
+
+### Запуск команд
+```bash
+# Базовое использование
+php yii cleanup/logs
+
+# С параметрами
+php yii cleanup/logs --days=7
+php yii cleanup/logs -d 7
+
+# Справка
+php yii help cleanup
+```
+
+## Alias и пути
+
+### Стандартные alias
+```php
+<?php
+// Встроенные alias
+Yii::getAlias('@app');      // /path/to/project
+Yii::getAlias('@runtime');  // /path/to/project/runtime
+Yii::getAlias('@webroot');  // /path/to/project/web
+Yii::getAlias('@web');      // /
+Yii::getAlias('@vendor');   // /path/to/project/vendor
+
+// Кастомные alias
+Yii::setAlias('@uploads', '@webroot/uploads');
+Yii::setAlias('@storage', '/var/storage/app');
+
+// В конфигурации
+return [
+    'aliases' => [
+        '@bower' => '@vendor/bower-asset',
+        '@npm' => '@vendor/npm-asset',
+        '@uploads' => '@webroot/uploads',
+        '@storage' => '/var/storage/app',
+    ],
+];
+```
+
+## Events и Behaviors
+
+### Глобальные события
+```php
+<?php
+// config/web.php
+return [
+    'on beforeRequest' => function ($event) {
+        // Логика перед обработкой запроса
+        \Yii::info('Request started', 'application');
+    },
+    'on afterRequest' => function ($event) {
+        // Логика после обработки запроса
+    },
+];
+```
+
+### Behaviors в конфигурации
+```php
+<?php
+// config/web.php
+return [
+    'as access' => [
+        'class' => \yii\filters\AccessControl::class,
+        'rules' => [
+            [
+                'allow' => true,
+                'roles' => ['@'],
+            ],
+        ],
+        'except' => ['site/login', 'site/error'],
+    ],
+];
+```
+
+## Логирование
+
+### Конфигурация логов
+```php
+<?php
+// config/web.php
+return [
+    'components' => [
+        'log' => [
+            'traceLevel' => YII_DEBUG ? 3 : 0,
+            'targets' => [
+                // Файловый лог ошибок
+                [
+                    'class' => \yii\log\FileTarget::class,
+                    'levels' => ['error', 'warning'],
+                    'logFile' => '@runtime/logs/error.log',
+                    'maxFileSize' => 10240, // 10MB
+                    'maxLogFiles' => 10,
+                ],
+                // Файловый лог приложения
+                [
+                    'class' => \yii\log\FileTarget::class,
+                    'levels' => ['info'],
+                    'categories' => ['application'],
+                    'logFile' => '@runtime/logs/app.log',
+                ],
+                // Лог в БД
+                [
+                    'class' => \yii\log\DbTarget::class,
+                    'levels' => ['error'],
+                    'logTable' => '{{%log}}',
+                ],
+                // Email для критических ошибок
+                [
+                    'class' => \yii\log\EmailTarget::class,
+                    'levels' => ['error'],
+                    'categories' => ['yii\db\*'],
+                    'message' => [
+                        'from' => ['log@example.com'],
+                        'to' => ['admin@example.com'],
+                        'subject' => 'Database error',
+                    ],
+                ],
+            ],
+        ],
+    ],
+];
+```
+
+### Использование логов
+```php
+<?php
+// Разные уровни
+Yii::error('Critical error', 'application');
+Yii::warning('Warning message', 'application');
+Yii::info('Info message', 'application');
+Yii::debug('Debug data', 'application');
+
+// С контекстом
+Yii::info([
+    'message' => 'User logged in',
+    'user_id' => $user->id,
+    'ip' => Yii::$app->request->userIP,
+], 'auth');
+
+// Профилирование
+Yii::beginProfile('slow-operation');
+// ... операция
+Yii::endProfile('slow-operation');
+```
+
+## Рекомендации для Claude Code
+
+1. **Следуйте структуре Yii2** - используйте стандартные директории
+2. **Модульность** - разбивайте большие приложения на модули
+3. **Компоненты** - выносите переиспользуемую логику в компоненты
+4. **Сервисы** - изолируйте бизнес-логику от контроллеров
+5. **Конфигурация** - используйте переменные окружения для секретов
+6. **Логирование** - настраивайте разные targets для разных уровней
diff --git a/erp24/php_skills/11-yii2-models.md b/erp24/php_skills/11-yii2-models.md
new file mode 100644 (file)
index 0000000..27fbc30
--- /dev/null
@@ -0,0 +1,983 @@
+# Yii2 Models - Модели и ActiveRecord
+
+## Структура модели
+
+### Порядок элементов в модели
+```php
+<?php
+namespace app\models;
+
+use yii\db\ActiveRecord;
+use yii\behaviors\TimestampBehavior;
+use yii\behaviors\BlameableBehavior;
+
+class User extends ActiveRecord
+{
+    // 1. Константы
+    public const STATUS_INACTIVE = 0;
+    public const STATUS_ACTIVE = 1;
+    public const STATUS_BLOCKED = 2;
+
+    public const ROLE_USER = 'user';
+    public const ROLE_MODERATOR = 'moderator';
+    public const ROLE_ADMIN = 'admin';
+
+    // 2. Публичные свойства (не из БД)
+    public ?string $password = null;
+    public ?string $passwordConfirm = null;
+
+    // 3. Приватные свойства
+    private ?Profile $_profile = null;
+
+    // 4. tableName()
+    public static function tableName(): string
+    {
+        return '{{%users}}';
+    }
+
+    // 5. behaviors()
+    public function behaviors(): array
+    {
+        return [
+            TimestampBehavior::class,
+            [
+                'class' => BlameableBehavior::class,
+                'createdByAttribute' => 'created_by',
+                'updatedByAttribute' => 'updated_by',
+            ],
+        ];
+    }
+
+    // 6. rules()
+    public function rules(): array
+    {
+        return [
+            [['email', 'name'], 'required'],
+            ['email', 'email'],
+            ['email', 'unique'],
+            ['status', 'default', 'value' => self::STATUS_INACTIVE],
+            ['status', 'in', 'range' => [self::STATUS_INACTIVE, self::STATUS_ACTIVE, self::STATUS_BLOCKED]],
+            ['role', 'default', 'value' => self::ROLE_USER],
+            [['password', 'passwordConfirm'], 'required', 'on' => 'create'],
+            ['passwordConfirm', 'compare', 'compareAttribute' => 'password'],
+        ];
+    }
+
+    // 7. attributeLabels()
+    public function attributeLabels(): array
+    {
+        return [
+            'id' => 'ID',
+            'email' => 'Email',
+            'name' => 'Имя',
+            'status' => 'Статус',
+            'role' => 'Роль',
+            'created_at' => 'Дата создания',
+        ];
+    }
+
+    // 8. Связи (relations)
+    public function getProfile(): ActiveQuery
+    {
+        return $this->hasOne(Profile::class, ['user_id' => 'id']);
+    }
+
+    public function getPosts(): ActiveQuery
+    {
+        return $this->hasMany(Post::class, ['user_id' => 'id']);
+    }
+
+    public function getComments(): ActiveQuery
+    {
+        return $this->hasMany(Comment::class, ['user_id' => 'id'])
+            ->via('posts');
+    }
+
+    // 9. Scopes (статические методы запросов)
+    public static function find(): UserQuery
+    {
+        return new UserQuery(static::class);
+    }
+
+    // 10. Events
+    public function beforeSave($insert): bool
+    {
+        if (!parent::beforeSave($insert)) {
+            return false;
+        }
+
+        if ($this->password) {
+            $this->password_hash = \Yii::$app->security->generatePasswordHash($this->password);
+        }
+
+        return true;
+    }
+
+    public function afterSave($insert, $changedAttributes): void
+    {
+        parent::afterSave($insert, $changedAttributes);
+
+        if ($insert) {
+            $this->createDefaultProfile();
+        }
+    }
+
+    // 11. Публичные методы
+    public function getFullName(): string
+    {
+        return trim("{$this->first_name} {$this->last_name}");
+    }
+
+    public function isActive(): bool
+    {
+        return $this->status === self::STATUS_ACTIVE;
+    }
+
+    public function activate(): bool
+    {
+        $this->status = self::STATUS_ACTIVE;
+        return $this->save(false, ['status']);
+    }
+
+    // 12. Приватные методы
+    private function createDefaultProfile(): void
+    {
+        $profile = new Profile(['user_id' => $this->id]);
+        $profile->save(false);
+    }
+}
+```
+
+## Именование
+
+### Модели и таблицы
+```php
+<?php
+// ПРАВИЛЬНО - имя класса в единственном числе, CamelCase
+class User extends ActiveRecord {}
+class OrderItem extends ActiveRecord {}
+class ProductCategory extends ActiveRecord {}
+
+// Таблица - множественное число, snake_case
+public static function tableName(): string
+{
+    return '{{%users}}';       // users
+    return '{{%order_items}}'; // order_items
+}
+
+// НЕПРАВИЛЬНО
+class Users extends ActiveRecord {}  // множественное число
+class orderItem extends ActiveRecord {} // не CamelCase
+```
+
+## Связи (Relations)
+
+### hasOne
+```php
+<?php
+class User extends ActiveRecord
+{
+    // Один к одному
+    public function getProfile(): ActiveQuery
+    {
+        return $this->hasOne(Profile::class, ['user_id' => 'id']);
+    }
+
+    // С условием
+    public function getActiveSubscription(): ActiveQuery
+    {
+        return $this->hasOne(Subscription::class, ['user_id' => 'id'])
+            ->andWhere(['status' => Subscription::STATUS_ACTIVE]);
+    }
+
+    // Обратная связь (inverse)
+    public function getAccount(): ActiveQuery
+    {
+        return $this->hasOne(Account::class, ['user_id' => 'id'])
+            ->inverseOf('user');
+    }
+}
+```
+
+### hasMany
+```php
+<?php
+class User extends ActiveRecord
+{
+    // Один ко многим
+    public function getPosts(): ActiveQuery
+    {
+        return $this->hasMany(Post::class, ['user_id' => 'id']);
+    }
+
+    // С сортировкой
+    public function getRecentPosts(): ActiveQuery
+    {
+        return $this->hasMany(Post::class, ['user_id' => 'id'])
+            ->orderBy(['created_at' => SORT_DESC])
+            ->limit(5);
+    }
+
+    // С условием
+    public function getPublishedPosts(): ActiveQuery
+    {
+        return $this->hasMany(Post::class, ['user_id' => 'id'])
+            ->andWhere(['status' => Post::STATUS_PUBLISHED]);
+    }
+
+    // onCondition для join-запросов
+    public function getActiveOrders(): ActiveQuery
+    {
+        return $this->hasMany(Order::class, ['user_id' => 'id'])
+            ->onCondition(['status' => Order::STATUS_ACTIVE]);
+    }
+}
+```
+
+### Many-to-Many через связующую таблицу
+```php
+<?php
+class User extends ActiveRecord
+{
+    // Через промежуточную модель
+    public function getRoles(): ActiveQuery
+    {
+        return $this->hasMany(Role::class, ['id' => 'role_id'])
+            ->viaTable('{{%user_roles}}', ['user_id' => 'id']);
+    }
+
+    // Через via() с моделью
+    public function getUserRoles(): ActiveQuery
+    {
+        return $this->hasMany(UserRole::class, ['user_id' => 'id']);
+    }
+
+    public function getRolesViaModel(): ActiveQuery
+    {
+        return $this->hasMany(Role::class, ['id' => 'role_id'])
+            ->via('userRoles');
+    }
+
+    // С доп. данными из связующей таблицы
+    public function getRolesWithPivot(): ActiveQuery
+    {
+        return $this->hasMany(Role::class, ['id' => 'role_id'])
+            ->viaTable('{{%user_roles}}', ['user_id' => 'id'])
+            ->select(['roles.*', 'user_roles.assigned_at']);
+    }
+}
+
+// Связующая модель
+class UserRole extends ActiveRecord
+{
+    public static function tableName(): string
+    {
+        return '{{%user_roles}}';
+    }
+
+    public function getUser(): ActiveQuery
+    {
+        return $this->hasOne(User::class, ['id' => 'user_id']);
+    }
+
+    public function getRole(): ActiveQuery
+    {
+        return $this->hasOne(Role::class, ['id' => 'role_id']);
+    }
+}
+```
+
+## Валидации
+
+### Встроенные валидаторы
+```php
+<?php
+public function rules(): array
+{
+    return [
+        // Обязательные поля
+        [['email', 'name'], 'required'],
+        [['email'], 'required', 'message' => 'Email обязателен для заполнения'],
+
+        // Типы данных
+        ['age', 'integer', 'min' => 0, 'max' => 150],
+        ['price', 'number', 'min' => 0],
+        ['is_active', 'boolean'],
+        ['birthday', 'date', 'format' => 'php:Y-m-d'],
+
+        // Строки
+        ['name', 'string', 'max' => 255],
+        ['bio', 'string', 'max' => 1000],
+        ['slug', 'match', 'pattern' => '/^[a-z0-9-]+$/'],
+
+        // Email и URL
+        ['email', 'email'],
+        ['website', 'url', 'defaultScheme' => 'https'],
+
+        // Уникальность
+        ['email', 'unique'],
+        ['slug', 'unique', 'targetAttribute' => ['slug', 'category_id']],
+
+        // Существование
+        ['category_id', 'exist', 'targetClass' => Category::class, 'targetAttribute' => 'id'],
+
+        // Диапазоны
+        ['status', 'in', 'range' => [self::STATUS_DRAFT, self::STATUS_PUBLISHED]],
+        ['role', 'in', 'range' => array_keys(self::getRoleList())],
+
+        // Сравнение
+        ['password_confirm', 'compare', 'compareAttribute' => 'password'],
+        ['end_date', 'compare', 'compareAttribute' => 'start_date', 'operator' => '>='],
+
+        // Значения по умолчанию
+        ['status', 'default', 'value' => self::STATUS_DRAFT],
+        ['created_at', 'default', 'value' => time()],
+
+        // Фильтры
+        ['email', 'filter', 'filter' => 'trim'],
+        ['email', 'filter', 'filter' => 'strtolower'],
+        ['name', 'filter', 'filter' => fn($value) => strip_tags($value)],
+
+        // Безопасные атрибуты
+        [['description', 'meta'], 'safe'],
+
+        // Файлы
+        ['avatar', 'file', 'extensions' => ['png', 'jpg', 'jpeg'], 'maxSize' => 5 * 1024 * 1024],
+        ['documents', 'file', 'extensions' => ['pdf', 'doc'], 'maxFiles' => 5],
+        ['image', 'image', 'minWidth' => 100, 'minHeight' => 100],
+    ];
+}
+```
+
+### Условные валидации
+```php
+<?php
+public function rules(): array
+{
+    return [
+        // when условие
+        ['phone', 'required', 'when' => function ($model) {
+            return $model->contact_method === 'phone';
+        }, 'whenClient' => "function(attribute, value) {
+            return $('#user-contact_method').val() === 'phone';
+        }"],
+
+        // skipOnEmpty
+        ['website', 'url', 'skipOnEmpty' => true],
+
+        // skipOnError
+        ['email', 'unique', 'skipOnError' => true],
+
+        // on сценарий
+        ['password', 'required', 'on' => 'create'],
+        ['password', 'string', 'min' => 8, 'on' => ['create', 'update-password']],
+
+        // except сценарий
+        ['email', 'required', 'except' => 'import'],
+    ];
+}
+```
+
+### Кастомные валидаторы
+```php
+<?php
+// Inline валидатор
+public function rules(): array
+{
+    return [
+        ['phone', 'validatePhone'],
+        ['code', function ($attribute, $params, $validator) {
+            if (!preg_match('/^[A-Z]{2}\d{4}$/', $this->$attribute)) {
+                $this->addError($attribute, 'Неверный формат кода');
+            }
+        }],
+    ];
+}
+
+public function validatePhone(string $attribute, ?array $params): void
+{
+    $phone = preg_replace('/\D/', '', $this->$attribute);
+
+    if (strlen($phone) !== 11) {
+        $this->addError($attribute, 'Телефон должен содержать 11 цифр');
+    }
+}
+
+// Класс валидатора
+// validators/PhoneValidator.php
+namespace app\validators;
+
+use yii\validators\Validator;
+
+class PhoneValidator extends Validator
+{
+    public string $pattern = '/^7\d{10}$/';
+    public string $message = 'Неверный формат телефона';
+
+    public function validateAttribute($model, $attribute): void
+    {
+        $value = preg_replace('/\D/', '', $model->$attribute);
+
+        if (!preg_match($this->pattern, $value)) {
+            $this->addError($model, $attribute, $this->message);
+        }
+    }
+
+    protected function validateValue($value): ?array
+    {
+        $value = preg_replace('/\D/', '', $value);
+
+        if (!preg_match($this->pattern, $value)) {
+            return [$this->message, []];
+        }
+
+        return null;
+    }
+}
+
+// Использование
+public function rules(): array
+{
+    return [
+        ['phone', PhoneValidator::class],
+    ];
+}
+```
+
+## Behaviors
+
+### Встроенные behaviors
+```php
+<?php
+use yii\behaviors\TimestampBehavior;
+use yii\behaviors\BlameableBehavior;
+use yii\behaviors\AttributeBehavior;
+use yii\behaviors\SluggableBehavior;
+
+public function behaviors(): array
+{
+    return [
+        // Автоматические метки времени
+        TimestampBehavior::class,
+
+        // С настройками
+        [
+            'class' => TimestampBehavior::class,
+            'createdAtAttribute' => 'created_at',
+            'updatedAtAttribute' => 'updated_at',
+            'value' => new \yii\db\Expression('NOW()'),
+        ],
+
+        // Автор записи
+        [
+            'class' => BlameableBehavior::class,
+            'createdByAttribute' => 'created_by',
+            'updatedByAttribute' => 'updated_by',
+        ],
+
+        // Slug из названия
+        [
+            'class' => SluggableBehavior::class,
+            'attribute' => 'title',
+            'slugAttribute' => 'slug',
+            'ensureUnique' => true,
+            'immutable' => true,
+        ],
+
+        // Кастомный атрибут
+        [
+            'class' => AttributeBehavior::class,
+            'attributes' => [
+                ActiveRecord::EVENT_BEFORE_INSERT => 'token',
+            ],
+            'value' => fn() => \Yii::$app->security->generateRandomString(32),
+        ],
+    ];
+}
+```
+
+### Кастомный behavior
+```php
+<?php
+// behaviors/StatusBehavior.php
+namespace app\behaviors;
+
+use yii\base\Behavior;
+use yii\db\ActiveRecord;
+
+class StatusBehavior extends Behavior
+{
+    public string $statusAttribute = 'status';
+    public string $statusChangedAtAttribute = 'status_changed_at';
+
+    public function events(): array
+    {
+        return [
+            ActiveRecord::EVENT_BEFORE_UPDATE => 'beforeUpdate',
+        ];
+    }
+
+    public function beforeUpdate(): void
+    {
+        $owner = $this->owner;
+
+        if ($owner->isAttributeChanged($this->statusAttribute)) {
+            $owner->{$this->statusChangedAtAttribute} = time();
+        }
+    }
+
+    public function changeStatus(int $status): bool
+    {
+        $this->owner->{$this->statusAttribute} = $status;
+        return $this->owner->save(false, [$this->statusAttribute, $this->statusChangedAtAttribute]);
+    }
+}
+
+// Использование
+$model->changeStatus(User::STATUS_ACTIVE);
+```
+
+## Сценарии (Scenarios)
+
+```php
+<?php
+class User extends ActiveRecord
+{
+    public const SCENARIO_CREATE = 'create';
+    public const SCENARIO_UPDATE = 'update';
+    public const SCENARIO_PROFILE = 'profile';
+
+    public function scenarios(): array
+    {
+        return [
+            self::SCENARIO_CREATE => ['email', 'password', 'name', 'role'],
+            self::SCENARIO_UPDATE => ['email', 'name', 'role'],
+            self::SCENARIO_PROFILE => ['name', 'bio', 'avatar'],
+        ];
+    }
+
+    public function rules(): array
+    {
+        return [
+            [['email', 'name'], 'required'],
+            ['password', 'required', 'on' => self::SCENARIO_CREATE],
+            ['password', 'string', 'min' => 8],
+            ['bio', 'string', 'max' => 500],
+        ];
+    }
+}
+
+// Использование
+$user = new User(['scenario' => User::SCENARIO_CREATE]);
+$user->load($data);
+$user->save();
+
+$user->scenario = User::SCENARIO_PROFILE;
+$user->load($profileData);
+$user->save();
+```
+
+## ActiveQuery и Scopes
+
+### Кастомный ActiveQuery
+```php
+<?php
+// models/query/UserQuery.php
+namespace app\models\query;
+
+use yii\db\ActiveQuery;
+
+class UserQuery extends ActiveQuery
+{
+    public function active(): self
+    {
+        return $this->andWhere(['status' => User::STATUS_ACTIVE]);
+    }
+
+    public function admins(): self
+    {
+        return $this->andWhere(['role' => User::ROLE_ADMIN]);
+    }
+
+    public function createdAfter(string $date): self
+    {
+        return $this->andWhere(['>=', 'created_at', $date]);
+    }
+
+    public function withPosts(): self
+    {
+        return $this->with('posts');
+    }
+
+    public function withProfile(): self
+    {
+        return $this->with('profile');
+    }
+
+    // Переопределение all() и one() для типизации
+    public function all($db = null): array
+    {
+        return parent::all($db);
+    }
+
+    public function one($db = null): ?User
+    {
+        return parent::one($db);
+    }
+}
+
+// В модели
+class User extends ActiveRecord
+{
+    public static function find(): UserQuery
+    {
+        return new UserQuery(static::class);
+    }
+}
+
+// Использование
+$users = User::find()
+    ->active()
+    ->admins()
+    ->createdAfter('2024-01-01')
+    ->withProfile()
+    ->all();
+```
+
+### Default scope
+```php
+<?php
+class Article extends ActiveRecord
+{
+    public static function find(): ArticleQuery
+    {
+        return (new ArticleQuery(static::class))
+            ->andWhere(['deleted_at' => null]); // Soft delete
+    }
+
+    // Запрос без default scope
+    public static function findWithDeleted(): ArticleQuery
+    {
+        return new ArticleQuery(static::class);
+    }
+}
+```
+
+## Events
+
+### Использование событий
+```php
+<?php
+class Order extends ActiveRecord
+{
+    public const EVENT_STATUS_CHANGED = 'statusChanged';
+    public const EVENT_PAID = 'paid';
+
+    public function init(): void
+    {
+        parent::init();
+
+        $this->on(self::EVENT_STATUS_CHANGED, [$this, 'onStatusChanged']);
+        $this->on(self::EVENT_PAID, [OrderService::class, 'handlePayment']);
+    }
+
+    public function beforeSave($insert): bool
+    {
+        if (!parent::beforeSave($insert)) {
+            return false;
+        }
+
+        if (!$insert && $this->isAttributeChanged('status')) {
+            $this->trigger(self::EVENT_STATUS_CHANGED, new StatusChangedEvent([
+                'oldStatus' => $this->getOldAttribute('status'),
+                'newStatus' => $this->status,
+            ]));
+        }
+
+        return true;
+    }
+
+    public function afterSave($insert, $changedAttributes): void
+    {
+        parent::afterSave($insert, $changedAttributes);
+
+        if (isset($changedAttributes['status']) && $this->status === self::STATUS_PAID) {
+            $this->trigger(self::EVENT_PAID);
+        }
+    }
+
+    protected function onStatusChanged(StatusChangedEvent $event): void
+    {
+        \Yii::info("Order {$this->id} status changed: {$event->oldStatus} -> {$event->newStatus}");
+    }
+}
+
+// Кастомный Event
+class StatusChangedEvent extends \yii\base\Event
+{
+    public ?int $oldStatus = null;
+    public ?int $newStatus = null;
+}
+```
+
+## Query Interface
+
+### Эффективные запросы
+```php
+<?php
+// Базовые запросы
+$user = User::findOne(1);
+$user = User::findOne(['email' => 'user@example.com']);
+$users = User::findAll(['status' => User::STATUS_ACTIVE]);
+
+// Query Builder
+$users = User::find()
+    ->where(['status' => User::STATUS_ACTIVE])
+    ->andWhere(['>=', 'created_at', '2024-01-01'])
+    ->orderBy(['created_at' => SORT_DESC])
+    ->limit(10)
+    ->all();
+
+// Сложные условия
+$users = User::find()
+    ->where(['or',
+        ['status' => User::STATUS_ACTIVE],
+        ['and',
+            ['status' => User::STATUS_INACTIVE],
+            ['role' => User::ROLE_ADMIN],
+        ],
+    ])
+    ->all();
+
+// LIKE поиск
+$users = User::find()
+    ->where(['like', 'name', $search])
+    ->orWhere(['like', 'email', $search])
+    ->all();
+
+// IN условие
+$users = User::find()
+    ->where(['id' => [1, 2, 3, 4, 5]])
+    ->all();
+
+// BETWEEN
+$orders = Order::find()
+    ->where(['between', 'created_at', $startDate, $endDate])
+    ->all();
+```
+
+### Eager Loading (избегаем N+1)
+```php
+<?php
+// НЕПРАВИЛЬНО - N+1 запросов
+$posts = Post::find()->all();
+foreach ($posts as $post) {
+    echo $post->user->name; // Запрос для каждого поста
+}
+
+// ПРАВИЛЬНО - 2 запроса
+$posts = Post::find()
+    ->with('user')
+    ->all();
+foreach ($posts as $post) {
+    echo $post->user->name; // Без дополнительных запросов
+}
+
+// Множественные связи
+$posts = Post::find()
+    ->with(['user', 'comments', 'tags'])
+    ->all();
+
+// Вложенные связи
+$posts = Post::find()
+    ->with(['user.profile', 'comments.user'])
+    ->all();
+
+// С условиями
+$posts = Post::find()
+    ->with([
+        'comments' => function ($query) {
+            $query->andWhere(['status' => Comment::STATUS_APPROVED])
+                  ->orderBy(['created_at' => SORT_DESC]);
+        },
+    ])
+    ->all();
+
+// joinWith для фильтрации
+$posts = Post::find()
+    ->joinWith('user')
+    ->where(['users.status' => User::STATUS_ACTIVE])
+    ->all();
+```
+
+### Агрегация
+```php
+<?php
+// Подсчёт
+$count = User::find()->where(['status' => User::STATUS_ACTIVE])->count();
+
+// Сумма
+$total = Order::find()->where(['user_id' => $userId])->sum('amount');
+
+// Среднее
+$avg = Product::find()->where(['category_id' => $categoryId])->average('price');
+
+// Минимум/Максимум
+$min = Product::find()->min('price');
+$max = Product::find()->max('price');
+
+// Группировка
+$stats = Order::find()
+    ->select(['status', 'COUNT(*) as count', 'SUM(amount) as total'])
+    ->groupBy('status')
+    ->asArray()
+    ->all();
+```
+
+### Batch обработка
+```php
+<?php
+// Для больших объёмов данных
+foreach (User::find()->batch(100) as $users) {
+    foreach ($users as $user) {
+        // Обработка пачками по 100
+    }
+}
+
+// each() - по одной записи, но эффективно
+foreach (User::find()->each(100) as $user) {
+    // Обработка по одному
+}
+
+// С eager loading
+foreach (User::find()->with('profile')->each(100) as $user) {
+    echo $user->profile->bio;
+}
+```
+
+## CRUD операции
+
+### Создание
+```php
+<?php
+// Способ 1 - через свойства
+$user = new User();
+$user->email = 'user@example.com';
+$user->name = 'John';
+$user->save();
+
+// Способ 2 - через конструктор
+$user = new User([
+    'email' => 'user@example.com',
+    'name' => 'John',
+]);
+$user->save();
+
+// Способ 3 - load из формы
+$user = new User();
+if ($user->load(\Yii::$app->request->post()) && $user->save()) {
+    return $this->redirect(['view', 'id' => $user->id]);
+}
+
+// Без валидации (осторожно!)
+$user->save(false);
+
+// Только определённые атрибуты
+$user->save(true, ['name', 'email']);
+```
+
+### Обновление
+```php
+<?php
+// Способ 1 - load + save
+$user = User::findOne($id);
+$user->name = 'New Name';
+$user->save();
+
+// Способ 2 - updateAttributes (без событий!)
+$user->updateAttributes(['name' => 'New Name']);
+
+// Способ 3 - updateAll (массовое обновление)
+User::updateAll(
+    ['status' => User::STATUS_INACTIVE],
+    ['<', 'last_login_at', strtotime('-1 year')]
+);
+
+// updateAllCounters
+Post::updateAllCounters(
+    ['view_count' => 1],
+    ['id' => $postId]
+);
+```
+
+### Удаление
+```php
+<?php
+// Удаление записи
+$user = User::findOne($id);
+$user->delete();
+
+// Массовое удаление
+User::deleteAll(['status' => User::STATUS_DELETED]);
+
+// Soft delete
+public function softDelete(): bool
+{
+    $this->deleted_at = time();
+    return $this->save(false, ['deleted_at']);
+}
+```
+
+## Транзакции
+
+```php
+<?php
+// Способ 1 - через Connection
+$transaction = \Yii::$app->db->beginTransaction();
+
+try {
+    $order = new Order($orderData);
+    if (!$order->save()) {
+        throw new \RuntimeException('Order save failed');
+    }
+
+    foreach ($items as $itemData) {
+        $item = new OrderItem($itemData);
+        $item->order_id = $order->id;
+        if (!$item->save()) {
+            throw new \RuntimeException('Item save failed');
+        }
+    }
+
+    $transaction->commit();
+} catch (\Throwable $e) {
+    $transaction->rollBack();
+    throw $e;
+}
+
+// Способ 2 - через callback
+\Yii::$app->db->transaction(function ($db) use ($orderData, $items) {
+    $order = new Order($orderData);
+    $order->save();
+
+    foreach ($items as $itemData) {
+        $item = new OrderItem($itemData);
+        $item->order_id = $order->id;
+        $item->save();
+    }
+});
+
+// Уровни изоляции
+$transaction = \Yii::$app->db->beginTransaction(
+    \yii\db\Transaction::SERIALIZABLE
+);
+```
+
+## Рекомендации для Claude Code
+
+1. **Тонкие модели** - выносите сложную бизнес-логику в сервисы
+2. **Используйте behaviors** - для повторяющейся логики (timestamps, slugs)
+3. **Кастомные ActiveQuery** - для переиспользуемых scope-ов
+4. **Eager loading** - всегда используйте with() для связей в циклах
+5. **Валидация на уровне БД** - добавляйте constraints в миграциях
+6. **Транзакции** - оборачивайте связанные операции
diff --git a/erp24/php_skills/12-yii2-controllers.md b/erp24/php_skills/12-yii2-controllers.md
new file mode 100644 (file)
index 0000000..4c914f9
--- /dev/null
@@ -0,0 +1,998 @@
+# Yii2 Controllers - Контроллеры
+
+## Структура контроллера
+
+### Базовый контроллер
+```php
+<?php
+namespace app\controllers;
+
+use app\models\User;
+use yii\web\Controller;
+use yii\web\NotFoundHttpException;
+use yii\filters\AccessControl;
+use yii\filters\VerbFilter;
+use yii\data\ActiveDataProvider;
+
+class UserController extends Controller
+{
+    // 1. Behaviors (фильтры)
+    public function behaviors(): array
+    {
+        return [
+            'access' => [
+                'class' => AccessControl::class,
+                'rules' => [
+                    [
+                        'actions' => ['index', 'view'],
+                        'allow' => true,
+                    ],
+                    [
+                        'actions' => ['create', 'update', 'delete'],
+                        'allow' => true,
+                        'roles' => ['@'],
+                    ],
+                ],
+            ],
+            'verbs' => [
+                'class' => VerbFilter::class,
+                'actions' => [
+                    'delete' => ['POST'],
+                ],
+            ],
+        ];
+    }
+
+    // 2. Actions
+    public function actionIndex(): string
+    {
+        $dataProvider = new ActiveDataProvider([
+            'query' => User::find()->active(),
+            'pagination' => [
+                'pageSize' => 20,
+            ],
+            'sort' => [
+                'defaultOrder' => ['created_at' => SORT_DESC],
+            ],
+        ]);
+
+        return $this->render('index', [
+            'dataProvider' => $dataProvider,
+        ]);
+    }
+
+    public function actionView(int $id): string
+    {
+        $model = $this->findModel($id);
+
+        return $this->render('view', [
+            'model' => $model,
+        ]);
+    }
+
+    public function actionCreate(): string|\yii\web\Response
+    {
+        $model = new User(['scenario' => User::SCENARIO_CREATE]);
+
+        if ($model->load(\Yii::$app->request->post()) && $model->save()) {
+            \Yii::$app->session->setFlash('success', 'Пользователь успешно создан');
+            return $this->redirect(['view', 'id' => $model->id]);
+        }
+
+        return $this->render('create', [
+            'model' => $model,
+        ]);
+    }
+
+    public function actionUpdate(int $id): string|\yii\web\Response
+    {
+        $model = $this->findModel($id);
+        $model->scenario = User::SCENARIO_UPDATE;
+
+        if ($model->load(\Yii::$app->request->post()) && $model->save()) {
+            \Yii::$app->session->setFlash('success', 'Пользователь успешно обновлён');
+            return $this->redirect(['view', 'id' => $model->id]);
+        }
+
+        return $this->render('update', [
+            'model' => $model,
+        ]);
+    }
+
+    public function actionDelete(int $id): \yii\web\Response
+    {
+        $this->findModel($id)->delete();
+        \Yii::$app->session->setFlash('success', 'Пользователь удалён');
+
+        return $this->redirect(['index']);
+    }
+
+    // 3. Вспомогательные методы
+    protected function findModel(int $id): User
+    {
+        $model = User::findOne($id);
+
+        if ($model === null) {
+            throw new NotFoundHttpException('Пользователь не найден');
+        }
+
+        return $model;
+    }
+}
+```
+
+## Наследование контроллеров
+
+### BaseController
+```php
+<?php
+// controllers/BaseController.php
+namespace app\controllers;
+
+use yii\web\Controller;
+use yii\filters\AccessControl;
+
+abstract class BaseController extends Controller
+{
+    public function behaviors(): array
+    {
+        return [
+            'access' => [
+                'class' => AccessControl::class,
+                'rules' => [
+                    [
+                        'allow' => true,
+                        'roles' => ['@'],
+                    ],
+                ],
+            ],
+        ];
+    }
+
+    public function beforeAction($action): bool
+    {
+        if (!parent::beforeAction($action)) {
+            return false;
+        }
+
+        // Общая логика для всех actions
+        \Yii::$app->language = \Yii::$app->session->get('language', 'ru-RU');
+
+        return true;
+    }
+
+    protected function setFlash(string $type, string $message): void
+    {
+        \Yii::$app->session->setFlash($type, $message);
+    }
+}
+```
+
+### Модульные контроллеры
+```php
+<?php
+// modules/admin/controllers/BaseController.php
+namespace app\modules\admin\controllers;
+
+use yii\web\Controller;
+use yii\web\ForbiddenHttpException;
+
+class BaseController extends Controller
+{
+    public $layout = 'admin';
+
+    public function init(): void
+    {
+        parent::init();
+
+        if (\Yii::$app->user->isGuest || !\Yii::$app->user->identity->isAdmin()) {
+            throw new ForbiddenHttpException('Доступ запрещён');
+        }
+    }
+}
+
+// modules/admin/controllers/UserController.php
+namespace app\modules\admin\controllers;
+
+class UserController extends BaseController
+{
+    // Наследует layout и проверку прав
+}
+```
+
+## Behaviors (Фильтры)
+
+### AccessControl
+```php
+<?php
+public function behaviors(): array
+{
+    return [
+        'access' => [
+            'class' => AccessControl::class,
+            'only' => ['create', 'update', 'delete'],
+            'rules' => [
+                // Гости
+                [
+                    'actions' => ['login', 'signup'],
+                    'allow' => true,
+                    'roles' => ['?'],
+                ],
+                // Авторизованные
+                [
+                    'actions' => ['logout', 'profile'],
+                    'allow' => true,
+                    'roles' => ['@'],
+                ],
+                // По ролям RBAC
+                [
+                    'actions' => ['admin'],
+                    'allow' => true,
+                    'roles' => ['admin'],
+                ],
+                // По callback
+                [
+                    'actions' => ['update'],
+                    'allow' => true,
+                    'roles' => ['@'],
+                    'matchCallback' => function ($rule, $action) {
+                        return \Yii::$app->user->id === \Yii::$app->request->get('id');
+                    },
+                ],
+                // По IP
+                [
+                    'actions' => ['debug'],
+                    'allow' => true,
+                    'ips' => ['127.0.0.1', '192.168.*'],
+                ],
+            ],
+            'denyCallback' => function ($rule, $action) {
+                throw new ForbiddenHttpException('Доступ запрещён');
+            },
+        ],
+    ];
+}
+```
+
+### VerbFilter
+```php
+<?php
+public function behaviors(): array
+{
+    return [
+        'verbs' => [
+            'class' => VerbFilter::class,
+            'actions' => [
+                'index' => ['GET'],
+                'view' => ['GET'],
+                'create' => ['GET', 'POST'],
+                'update' => ['GET', 'POST', 'PUT'],
+                'delete' => ['POST', 'DELETE'],
+            ],
+        ],
+    ];
+}
+```
+
+### ContentNegotiator
+```php
+<?php
+use yii\filters\ContentNegotiator;
+use yii\web\Response;
+
+public function behaviors(): array
+{
+    return [
+        'contentNegotiator' => [
+            'class' => ContentNegotiator::class,
+            'formats' => [
+                'application/json' => Response::FORMAT_JSON,
+                'application/xml' => Response::FORMAT_XML,
+            ],
+        ],
+    ];
+}
+```
+
+### Cors
+```php
+<?php
+use yii\filters\Cors;
+
+public function behaviors(): array
+{
+    return [
+        'cors' => [
+            'class' => Cors::class,
+            'cors' => [
+                'Origin' => ['http://localhost:3000', 'https://example.com'],
+                'Access-Control-Request-Method' => ['GET', 'POST', 'PUT', 'DELETE'],
+                'Access-Control-Request-Headers' => ['*'],
+                'Access-Control-Allow-Credentials' => true,
+                'Access-Control-Max-Age' => 86400,
+            ],
+        ],
+    ];
+}
+```
+
+### RateLimiter
+```php
+<?php
+use yii\filters\RateLimiter;
+
+public function behaviors(): array
+{
+    return [
+        'rateLimiter' => [
+            'class' => RateLimiter::class,
+            'enableRateLimitHeaders' => true,
+        ],
+    ];
+}
+
+// Модель должна реализовать RateLimitInterface
+class User extends ActiveRecord implements \yii\filters\RateLimitInterface
+{
+    public function getRateLimit($request, $action): array
+    {
+        return [100, 600]; // 100 запросов за 600 секунд
+    }
+
+    public function loadAllowance($request, $action): array
+    {
+        return [$this->allowance, $this->allowance_updated_at];
+    }
+
+    public function saveAllowance($request, $action, $allowance, $timestamp): void
+    {
+        $this->allowance = $allowance;
+        $this->allowance_updated_at = $timestamp;
+        $this->save(false);
+    }
+}
+```
+
+## Actions
+
+### Inline Actions
+```php
+<?php
+class SiteController extends Controller
+{
+    // Стандартный action
+    public function actionIndex(): string
+    {
+        return $this->render('index');
+    }
+
+    // С параметрами из GET
+    public function actionView(int $id, string $format = 'html'): string
+    {
+        // /site/view?id=1&format=json
+        return $this->render('view', ['id' => $id, 'format' => $format]);
+    }
+
+    // С nullable параметром
+    public function actionSearch(?string $query = null): string
+    {
+        if ($query === null) {
+            return $this->render('search-form');
+        }
+
+        $results = $this->searchService->search($query);
+        return $this->render('search-results', ['results' => $results]);
+    }
+}
+```
+
+### Standalone Actions
+```php
+<?php
+// actions/CreateAction.php
+namespace app\actions;
+
+use yii\base\Action;
+use yii\db\ActiveRecord;
+
+class CreateAction extends Action
+{
+    public string $modelClass;
+    public string $scenario = 'default';
+    public string $view = 'create';
+
+    public function run(): string|\yii\web\Response
+    {
+        /** @var ActiveRecord $model */
+        $model = new $this->modelClass(['scenario' => $this->scenario]);
+
+        if ($model->load(\Yii::$app->request->post()) && $model->save()) {
+            \Yii::$app->session->setFlash('success', 'Запись создана');
+            return $this->controller->redirect(['view', 'id' => $model->id]);
+        }
+
+        return $this->controller->render($this->view, ['model' => $model]);
+    }
+}
+
+// Использование в контроллере
+class PostController extends Controller
+{
+    public function actions(): array
+    {
+        return [
+            'create' => [
+                'class' => \app\actions\CreateAction::class,
+                'modelClass' => Post::class,
+                'scenario' => Post::SCENARIO_CREATE,
+            ],
+            'error' => [
+                'class' => \yii\web\ErrorAction::class,
+            ],
+            'captcha' => [
+                'class' => \yii\captcha\CaptchaAction::class,
+                'fixedVerifyCode' => YII_ENV_TEST ? 'testme' : null,
+            ],
+        ];
+    }
+}
+```
+
+### ViewAction для статических страниц
+```php
+<?php
+public function actions(): array
+{
+    return [
+        'page' => [
+            'class' => \yii\web\ViewAction::class,
+            'viewPrefix' => 'pages',
+        ],
+    ];
+}
+
+// URL: /site/page?view=about -> views/site/pages/about.php
+```
+
+## Request и Response
+
+### Работа с Request
+```php
+<?php
+public function actionProcess(): \yii\web\Response
+{
+    $request = \Yii::$app->request;
+
+    // GET параметры
+    $id = $request->get('id');
+    $page = $request->get('page', 1); // со значением по умолчанию
+
+    // POST параметры
+    $name = $request->post('name');
+    $data = $request->post(); // все POST данные
+
+    // Любой параметр
+    $value = $request->getQueryParam('key') ?? $request->getBodyParam('key');
+
+    // JSON body
+    if ($request->isAjax || $request->contentType === 'application/json') {
+        $data = $request->bodyParams;
+    }
+
+    // Проверка метода
+    if ($request->isPost) {
+        // ...
+    }
+    if ($request->isAjax) {
+        // ...
+    }
+    if ($request->isPjax) {
+        // ...
+    }
+
+    // Информация о запросе
+    $url = $request->absoluteUrl;
+    $method = $request->method;
+    $ip = $request->userIP;
+    $host = $request->userHost;
+    $headers = $request->headers;
+    $cookies = $request->cookies;
+
+    return $this->asJson(['success' => true]);
+}
+```
+
+### Работа с Response
+```php
+<?php
+public function actionDownload(): \yii\web\Response
+{
+    $response = \Yii::$app->response;
+
+    // JSON ответ
+    $response->format = Response::FORMAT_JSON;
+    $response->data = ['status' => 'ok', 'data' => $data];
+    return $response;
+
+    // Или через хелпер
+    return $this->asJson(['status' => 'ok']);
+
+    // XML ответ
+    return $this->asXml(['status' => 'ok']);
+
+    // Отправка файла
+    return $response->sendFile('/path/to/file.pdf', 'download.pdf');
+
+    // Отправка содержимого как файл
+    return $response->sendContentAsFile($content, 'report.csv', [
+        'mimeType' => 'text/csv',
+    ]);
+
+    // Stream
+    return $response->sendStreamAsFile($stream, 'large-file.zip');
+
+    // Установка заголовков
+    $response->headers->set('X-Custom-Header', 'value');
+    $response->headers->add('Cache-Control', 'no-cache');
+
+    // Cookies
+    $response->cookies->add(new \yii\web\Cookie([
+        'name' => 'token',
+        'value' => $token,
+        'expire' => time() + 3600,
+        'httpOnly' => true,
+        'secure' => true,
+    ]));
+
+    // Статус код
+    $response->statusCode = 201;
+
+    return $response;
+}
+```
+
+### Редиректы
+```php
+<?php
+public function actionRedirects(): \yii\web\Response
+{
+    // На action
+    return $this->redirect(['view', 'id' => 1]);
+
+    // На URL
+    return $this->redirect('https://example.com');
+
+    // Назад
+    return $this->goBack();
+
+    // На главную
+    return $this->goHome();
+
+    // С кодом
+    return $this->redirect(['index'], 301); // Permanent redirect
+
+    // Refresh
+    return $this->refresh();
+}
+```
+
+## AJAX и JSON
+
+### AJAX обработка
+```php
+<?php
+public function actionAjaxUpdate(int $id): array
+{
+    \Yii::$app->response->format = Response::FORMAT_JSON;
+
+    if (!\Yii::$app->request->isAjax) {
+        throw new BadRequestHttpException('Только AJAX запросы');
+    }
+
+    $model = $this->findModel($id);
+
+    if ($model->load(\Yii::$app->request->post()) && $model->save()) {
+        return [
+            'success' => true,
+            'message' => 'Сохранено',
+            'data' => $model->attributes,
+        ];
+    }
+
+    return [
+        'success' => false,
+        'errors' => $model->errors,
+    ];
+}
+
+public function actionValidate(): array
+{
+    \Yii::$app->response->format = Response::FORMAT_JSON;
+
+    $model = new User();
+    $model->load(\Yii::$app->request->post());
+
+    return \yii\widgets\ActiveForm::validate($model);
+}
+```
+
+### Pjax поддержка
+```php
+<?php
+public function actionIndex(): string
+{
+    $dataProvider = new ActiveDataProvider([
+        'query' => Post::find(),
+    ]);
+
+    // Pjax автоматически обрабатывается
+    return $this->render('index', [
+        'dataProvider' => $dataProvider,
+    ]);
+}
+
+// В view
+<?php \yii\widgets\Pjax::begin(['id' => 'posts-pjax']); ?>
+    <?= \yii\grid\GridView::widget([
+        'dataProvider' => $dataProvider,
+    ]) ?>
+<?php \yii\widgets\Pjax::end(); ?>
+```
+
+## REST API контроллеры
+
+### ActiveController
+```php
+<?php
+// controllers/api/v1/UserController.php
+namespace app\controllers\api\v1;
+
+use yii\rest\ActiveController;
+use yii\filters\auth\HttpBearerAuth;
+
+class UserController extends ActiveController
+{
+    public $modelClass = \app\models\User::class;
+
+    // Сериализация
+    public $serializer = [
+        'class' => \yii\rest\Serializer::class,
+        'collectionEnvelope' => 'items',
+    ];
+
+    public function behaviors(): array
+    {
+        $behaviors = parent::behaviors();
+
+        // Аутентификация
+        $behaviors['authenticator'] = [
+            'class' => HttpBearerAuth::class,
+            'except' => ['index', 'view'],
+        ];
+
+        // Rate limiting
+        $behaviors['rateLimiter'] = [
+            'class' => \yii\filters\RateLimiter::class,
+        ];
+
+        return $behaviors;
+    }
+
+    public function actions(): array
+    {
+        $actions = parent::actions();
+
+        // Кастомизация действий
+        unset($actions['delete']); // Отключить удаление
+
+        $actions['index']['prepareDataProvider'] = [$this, 'prepareDataProvider'];
+
+        return $actions;
+    }
+
+    public function prepareDataProvider(): ActiveDataProvider
+    {
+        return new ActiveDataProvider([
+            'query' => User::find()->active(),
+            'pagination' => [
+                'pageSize' => 20,
+            ],
+        ]);
+    }
+
+    // Кастомный action
+    public function actionMe(): array
+    {
+        return \Yii::$app->user->identity->toArray();
+    }
+
+    protected function verbs(): array
+    {
+        return [
+            'index' => ['GET', 'HEAD'],
+            'view' => ['GET', 'HEAD'],
+            'create' => ['POST'],
+            'update' => ['PUT', 'PATCH'],
+            'delete' => ['DELETE'],
+            'me' => ['GET'],
+        ];
+    }
+}
+```
+
+### Кастомный REST контроллер
+```php
+<?php
+namespace app\controllers\api\v1;
+
+use yii\rest\Controller;
+use yii\filters\auth\CompositeAuth;
+use yii\filters\auth\HttpBearerAuth;
+use yii\filters\auth\QueryParamAuth;
+
+class BaseApiController extends Controller
+{
+    public function behaviors(): array
+    {
+        $behaviors = parent::behaviors();
+
+        $behaviors['authenticator'] = [
+            'class' => CompositeAuth::class,
+            'authMethods' => [
+                HttpBearerAuth::class,
+                [
+                    'class' => QueryParamAuth::class,
+                    'tokenParam' => 'access_token',
+                ],
+            ],
+        ];
+
+        $behaviors['contentNegotiator']['formats'] = [
+            'application/json' => Response::FORMAT_JSON,
+        ];
+
+        return $behaviors;
+    }
+
+    protected function success(mixed $data = null, int $statusCode = 200): array
+    {
+        \Yii::$app->response->statusCode = $statusCode;
+
+        return [
+            'success' => true,
+            'data' => $data,
+        ];
+    }
+
+    protected function error(string $message, array $errors = [], int $statusCode = 400): array
+    {
+        \Yii::$app->response->statusCode = $statusCode;
+
+        return [
+            'success' => false,
+            'message' => $message,
+            'errors' => $errors,
+        ];
+    }
+}
+
+class OrderController extends BaseApiController
+{
+    public function actionCreate(): array
+    {
+        $model = new Order();
+
+        if ($model->load(\Yii::$app->request->bodyParams, '') && $model->save()) {
+            return $this->success($model->toArray(), 201);
+        }
+
+        return $this->error('Ошибка валидации', $model->errors, 422);
+    }
+}
+```
+
+## Обработка ошибок
+
+### ErrorHandler
+```php
+<?php
+class SiteController extends Controller
+{
+    public function actions(): array
+    {
+        return [
+            'error' => [
+                'class' => \yii\web\ErrorAction::class,
+                'view' => 'error',
+            ],
+        ];
+    }
+}
+
+// views/site/error.php
+<?php
+use yii\helpers\Html;
+
+$this->title = $name;
+?>
+<div class="site-error">
+    <h1><?= Html::encode($this->title) ?></h1>
+    <div class="alert alert-danger">
+        <?= nl2br(Html::encode($message)) ?>
+    </div>
+    <?php if (YII_DEBUG): ?>
+        <pre><?= Html::encode($exception->getTraceAsString()) ?></pre>
+    <?php endif; ?>
+</div>
+```
+
+### Кастомная обработка исключений
+```php
+<?php
+class BaseController extends Controller
+{
+    public function beforeAction($action): bool
+    {
+        try {
+            return parent::beforeAction($action);
+        } catch (\Throwable $e) {
+            $this->handleException($e);
+            return false;
+        }
+    }
+
+    protected function handleException(\Throwable $e): void
+    {
+        \Yii::error($e->getMessage(), __METHOD__);
+
+        if (\Yii::$app->request->isAjax) {
+            \Yii::$app->response->format = Response::FORMAT_JSON;
+            \Yii::$app->response->data = [
+                'success' => false,
+                'message' => YII_DEBUG ? $e->getMessage() : 'Произошла ошибка',
+            ];
+            \Yii::$app->response->statusCode = $this->getStatusCode($e);
+            \Yii::$app->response->send();
+        }
+    }
+
+    private function getStatusCode(\Throwable $e): int
+    {
+        if ($e instanceof \yii\web\HttpException) {
+            return $e->statusCode;
+        }
+        return 500;
+    }
+}
+```
+
+## Sessions и Cookies
+
+### Sessions
+```php
+<?php
+public function actionSession(): void
+{
+    $session = \Yii::$app->session;
+
+    // Запись
+    $session->set('user_preferences', ['theme' => 'dark']);
+    $session['cart'] = $cartItems;
+
+    // Чтение
+    $preferences = $session->get('user_preferences', []);
+    $cart = $session['cart'] ?? [];
+
+    // Проверка
+    if ($session->has('user_preferences')) {
+        // ...
+    }
+
+    // Удаление
+    $session->remove('cart');
+
+    // Flash сообщения
+    $session->setFlash('success', 'Операция выполнена успешно');
+    $session->setFlash('error', 'Произошла ошибка');
+    $session->addFlash('info', 'Информация 1');
+    $session->addFlash('info', 'Информация 2');
+
+    // В view
+    // Yii::$app->session->getFlash('success')
+    // Yii::$app->session->getAllFlashes()
+}
+```
+
+### Cookies
+```php
+<?php
+public function actionCookies(): void
+{
+    $cookies = \Yii::$app->request->cookies;
+    $responseCookies = \Yii::$app->response->cookies;
+
+    // Чтение
+    $token = $cookies->getValue('remember_token');
+    if ($cookies->has('remember_token')) {
+        $cookie = $cookies->get('remember_token');
+    }
+
+    // Запись
+    $responseCookies->add(new \yii\web\Cookie([
+        'name' => 'remember_token',
+        'value' => $token,
+        'expire' => time() + 86400 * 30, // 30 дней
+        'httpOnly' => true,
+        'secure' => !YII_DEBUG,
+        'sameSite' => \yii\web\Cookie::SAME_SITE_LAX,
+    ]));
+
+    // Удаление
+    $responseCookies->remove('remember_token');
+}
+```
+
+## Layout и рендеринг
+
+### Управление layout
+```php
+<?php
+class AdminController extends Controller
+{
+    public $layout = 'admin'; // views/layouts/admin.php
+
+    public function actionDashboard(): string
+    {
+        // Использует layout 'admin'
+        return $this->render('dashboard');
+    }
+
+    public function actionPrint(int $id): string
+    {
+        // Без layout
+        $this->layout = false;
+        return $this->render('print', ['model' => $this->findModel($id)]);
+    }
+
+    public function actionPopup(): string
+    {
+        // Другой layout
+        $this->layout = 'popup';
+        return $this->render('popup-content');
+    }
+}
+```
+
+### Методы рендеринга
+```php
+<?php
+public function actionRender(): string
+{
+    // С layout
+    return $this->render('view', ['model' => $model]);
+
+    // Без layout
+    return $this->renderPartial('_item', ['item' => $item]);
+
+    // Для AJAX (без layout, с asset bundles)
+    return $this->renderAjax('_form', ['model' => $model]);
+
+    // Из другой директории
+    return $this->render('@app/modules/admin/views/user/view', ['model' => $model]);
+
+    // Содержимое в layout
+    return $this->renderContent('<h1>Hello</h1>');
+
+    // Файл
+    return $this->renderFile('@app/views/site/test.php', ['var' => $value]);
+}
+```
+
+## Рекомендации для Claude Code
+
+1. **Тонкие контроллеры** - логику выносите в модели и сервисы
+2. **RESTful** - следуйте REST конвенциям для API
+3. **Фильтры** - используйте behaviors для access control и validation
+4. **Типизация** - указывайте типы параметров и возвращаемых значений
+5. **DRY** - общий код выносите в базовые контроллеры и standalone actions
+6. **Безопасность** - всегда проверяйте права доступа через AccessControl
diff --git a/erp24/php_skills/13-yii2-views.md b/erp24/php_skills/13-yii2-views.md
new file mode 100644 (file)
index 0000000..eae981c
--- /dev/null
@@ -0,0 +1,708 @@
+# Yii2 Views - Представления и шаблоны
+
+## Структура views
+
+### Организация директорий
+```
+views/
+├── layouts/              # Макеты
+│   ├── main.php          # Основной layout
+│   ├── admin.php         # Админский layout
+│   └── blank.php         # Пустой layout
+├── site/                 # Views для SiteController
+│   ├── index.php
+│   ├── login.php
+│   ├── error.php
+│   └── _search.php       # Partial (начинается с _)
+├── user/                 # Views для UserController
+│   ├── index.php
+│   ├── view.php
+│   ├── create.php
+│   ├── update.php
+│   └── _form.php         # Partial формы
+└── widgets/              # Views для виджетов
+    └── menu.php
+```
+
+## Layouts
+
+### Базовый layout
+```php
+<?php
+// views/layouts/main.php
+use app\assets\AppAsset;
+use yii\bootstrap5\Html;
+use yii\bootstrap5\Nav;
+use yii\bootstrap5\NavBar;
+use yii\bootstrap5\Breadcrumbs;
+use yii\widgets\Alert;
+
+/** @var yii\web\View $this */
+/** @var string $content */
+
+AppAsset::register($this);
+
+$this->registerCsrfMetaTags();
+$this->registerMetaTag(['charset' => Yii::$app->charset], 'charset');
+$this->registerMetaTag(['name' => 'viewport', 'content' => 'width=device-width, initial-scale=1']);
+$this->registerLinkTag(['rel' => 'icon', 'type' => 'image/x-icon', 'href' => '/favicon.ico']);
+?>
+<?php $this->beginPage() ?>
+<!DOCTYPE html>
+<html lang="<?= Yii::$app->language ?>" class="h-100">
+<head>
+    <title><?= Html::encode($this->title) ?></title>
+    <?php $this->head() ?>
+</head>
+<body class="d-flex flex-column h-100">
+<?php $this->beginBody() ?>
+
+<header>
+    <?php
+    NavBar::begin([
+        'brandLabel' => Yii::$app->name,
+        'brandUrl' => Yii::$app->homeUrl,
+        'options' => ['class' => 'navbar navbar-expand-md navbar-dark bg-dark fixed-top'],
+    ]);
+    echo Nav::widget([
+        'options' => ['class' => 'navbar-nav ms-auto'],
+        'items' => [
+            ['label' => 'Главная', 'url' => ['/site/index']],
+            ['label' => 'О нас', 'url' => ['/site/about']],
+            ['label' => 'Контакты', 'url' => ['/site/contact']],
+            Yii::$app->user->isGuest
+                ? ['label' => 'Войти', 'url' => ['/site/login']]
+                : '<li class="nav-item">'
+                    . Html::beginForm(['/site/logout'], 'post', ['class' => 'd-flex'])
+                    . Html::submitButton(
+                        'Выйти (' . Yii::$app->user->identity->username . ')',
+                        ['class' => 'btn btn-link nav-link']
+                    )
+                    . Html::endForm()
+                    . '</li>',
+        ],
+    ]);
+    NavBar::end();
+    ?>
+</header>
+
+<main role="main" class="flex-shrink-0">
+    <div class="container">
+        <?= Breadcrumbs::widget([
+            'links' => $this->params['breadcrumbs'] ?? [],
+        ]) ?>
+        <?php foreach (Yii::$app->session->getAllFlashes() as $type => $messages): ?>
+            <?php foreach ((array)$messages as $message): ?>
+                <?= Alert::widget([
+                    'options' => ['class' => "alert alert-{$type}"],
+                    'body' => Html::encode($message),
+                ]) ?>
+            <?php endforeach ?>
+        <?php endforeach ?>
+        <?= $content ?>
+    </div>
+</main>
+
+<footer class="footer mt-auto py-3 text-muted">
+    <div class="container">
+        <p class="float-start">&copy; <?= Html::encode(Yii::$app->name) ?> <?= date('Y') ?></p>
+        <p class="float-end"><?= Yii::powered() ?></p>
+    </div>
+</footer>
+
+<?php $this->endBody() ?>
+</body>
+</html>
+<?php $this->endPage() ?>
+```
+
+### Вложенные layouts
+```php
+<?php
+// views/layouts/admin.php
+/** @var yii\web\View $this */
+/** @var string $content */
+
+$this->beginContent('@app/views/layouts/main.php');
+?>
+
+<div class="row">
+    <div class="col-md-3">
+        <?= $this->render('_sidebar') ?>
+    </div>
+    <div class="col-md-9">
+        <?= $content ?>
+    </div>
+</div>
+
+<?php $this->endContent() ?>
+```
+
+## View файлы
+
+### Базовый view
+```php
+<?php
+// views/user/index.php
+use yii\helpers\Html;
+use yii\grid\GridView;
+
+/** @var yii\web\View $this */
+/** @var yii\data\ActiveDataProvider $dataProvider */
+/** @var app\models\UserSearch $searchModel */
+
+$this->title = 'Пользователи';
+$this->params['breadcrumbs'][] = $this->title;
+?>
+
+<div class="user-index">
+    <h1><?= Html::encode($this->title) ?></h1>
+
+    <p>
+        <?= Html::a('Создать пользователя', ['create'], ['class' => 'btn btn-success']) ?>
+    </p>
+
+    <?= $this->render('_search', ['model' => $searchModel]) ?>
+
+    <?= GridView::widget([
+        'dataProvider' => $dataProvider,
+        'filterModel' => $searchModel,
+        'columns' => [
+            ['class' => 'yii\grid\SerialColumn'],
+            'id',
+            'username',
+            'email:email',
+            'status',
+            'created_at:datetime',
+            [
+                'class' => 'yii\grid\ActionColumn',
+                'template' => '{view} {update} {delete}',
+            ],
+        ],
+    ]) ?>
+</div>
+```
+
+### View с формой
+```php
+<?php
+// views/user/create.php
+use yii\helpers\Html;
+
+/** @var yii\web\View $this */
+/** @var app\models\User $model */
+
+$this->title = 'Создание пользователя';
+$this->params['breadcrumbs'][] = ['label' => 'Пользователи', 'url' => ['index']];
+$this->params['breadcrumbs'][] = $this->title;
+?>
+
+<div class="user-create">
+    <h1><?= Html::encode($this->title) ?></h1>
+
+    <?= $this->render('_form', ['model' => $model]) ?>
+</div>
+```
+
+### Partial (_form.php)
+```php
+<?php
+// views/user/_form.php
+use yii\helpers\Html;
+use yii\bootstrap5\ActiveForm;
+
+/** @var yii\web\View $this */
+/** @var app\models\User $model */
+/** @var yii\bootstrap5\ActiveForm $form */
+?>
+
+<div class="user-form">
+    <?php $form = ActiveForm::begin([
+        'id' => 'user-form',
+        'enableAjaxValidation' => true,
+        'options' => ['enctype' => 'multipart/form-data'],
+    ]) ?>
+
+    <?= $form->field($model, 'username')->textInput(['maxlength' => true]) ?>
+
+    <?= $form->field($model, 'email')->input('email') ?>
+
+    <?= $form->field($model, 'password')->passwordInput() ?>
+
+    <?= $form->field($model, 'status')->dropDownList(
+        User::getStatusList(),
+        ['prompt' => 'Выберите статус']
+    ) ?>
+
+    <?= $form->field($model, 'role')->radioList(User::getRoleList()) ?>
+
+    <?= $form->field($model, 'is_active')->checkbox() ?>
+
+    <?= $form->field($model, 'avatar')->fileInput(['accept' => 'image/*']) ?>
+
+    <?= $form->field($model, 'bio')->textarea(['rows' => 6]) ?>
+
+    <div class="form-group">
+        <?= Html::submitButton(
+            $model->isNewRecord ? 'Создать' : 'Сохранить',
+            ['class' => $model->isNewRecord ? 'btn btn-success' : 'btn btn-primary']
+        ) ?>
+    </div>
+
+    <?php ActiveForm::end() ?>
+</div>
+```
+
+## Html Helper
+
+### Основные методы
+```php
+<?php
+use yii\helpers\Html;
+
+// Экранирование
+echo Html::encode($unsafeText);
+echo Html::decode($encodedText);
+
+// Теги
+echo Html::tag('p', 'Параграф', ['class' => 'text-muted']);
+echo Html::tag('div', $content, ['id' => 'container', 'data-id' => 123]);
+echo Html::beginTag('div', ['class' => 'wrapper']);
+echo Html::endTag('div');
+
+// Ссылки
+echo Html::a('Текст ссылки', ['controller/action', 'id' => 1]);
+echo Html::a('Внешняя', 'https://example.com', ['target' => '_blank']);
+echo Html::mailto('Email', 'user@example.com');
+
+// Изображения
+echo Html::img('/images/logo.png', ['alt' => 'Логотип', 'class' => 'img-fluid']);
+
+// Списки
+echo Html::ul(['Первый', 'Второй', 'Третий'], ['class' => 'list-group']);
+echo Html::ol($items, ['class' => 'list-group']);
+
+// CSS и JS
+echo Html::cssFile('/css/style.css');
+echo Html::jsFile('/js/app.js');
+echo Html::style('.red { color: red; }');
+echo Html::script('alert("Hello");');
+```
+
+### Формы
+```php
+<?php
+use yii\helpers\Html;
+
+// Форма
+echo Html::beginForm(['site/search'], 'get', ['class' => 'form-inline']);
+echo Html::endForm();
+
+// Поля формы
+echo Html::textInput('name', $value, ['class' => 'form-control']);
+echo Html::passwordInput('password', '', ['class' => 'form-control']);
+echo Html::textarea('message', $text, ['rows' => 5]);
+echo Html::hiddenInput('token', $token);
+
+// Select
+echo Html::dropDownList('status', $selected, $items, ['prompt' => 'Выберите']);
+echo Html::listBox('roles', $selected, $items, ['multiple' => true, 'size' => 5]);
+
+// Radio и Checkbox
+echo Html::radio('gender', false, ['label' => 'Мужской', 'value' => 'male']);
+echo Html::checkbox('agree', true, ['label' => 'Согласен']);
+echo Html::radioList('gender', 'male', ['male' => 'Мужской', 'female' => 'Женский']);
+echo Html::checkboxList('hobbies', [], ['sport' => 'Спорт', 'music' => 'Музыка']);
+
+// Файл
+echo Html::fileInput('document', null, ['accept' => '.pdf,.doc']);
+
+// Кнопки
+echo Html::submitButton('Отправить', ['class' => 'btn btn-primary']);
+echo Html::resetButton('Сбросить', ['class' => 'btn btn-secondary']);
+echo Html::button('Кнопка', ['class' => 'btn btn-info', 'onclick' => 'doSomething()']);
+
+// CSRF
+echo Html::csrfMetaTags();
+```
+
+### Active поля (с моделью)
+```php
+<?php
+use yii\helpers\Html;
+
+// Active поля связаны с моделью
+echo Html::activeTextInput($model, 'username', ['class' => 'form-control']);
+echo Html::activePasswordInput($model, 'password');
+echo Html::activeTextarea($model, 'bio', ['rows' => 5]);
+echo Html::activeDropDownList($model, 'status', $statusList);
+echo Html::activeCheckbox($model, 'is_active');
+echo Html::activeRadioList($model, 'role', $roleList);
+echo Html::activeFileInput($model, 'avatar');
+echo Html::activeHiddenInput($model, 'id');
+
+// Label и Error
+echo Html::activeLabel($model, 'username');
+echo Html::error($model, 'username', ['class' => 'text-danger']);
+```
+
+## ActiveForm
+
+### Базовое использование
+```php
+<?php
+use yii\bootstrap5\ActiveForm;
+use yii\bootstrap5\Html;
+
+$form = ActiveForm::begin([
+    'id' => 'contact-form',
+    'action' => ['site/contact'],
+    'method' => 'post',
+    'options' => [
+        'class' => 'form-horizontal',
+        'enctype' => 'multipart/form-data',
+    ],
+    'fieldConfig' => [
+        'template' => "{label}\n{input}\n{hint}\n{error}",
+        'labelOptions' => ['class' => 'form-label'],
+        'inputOptions' => ['class' => 'form-control'],
+        'errorOptions' => ['class' => 'invalid-feedback'],
+        'hintOptions' => ['class' => 'form-text text-muted'],
+    ],
+]);
+?>
+
+<?= $form->field($model, 'name')->textInput(['autofocus' => true]) ?>
+
+<?= $form->field($model, 'email')->input('email') ?>
+
+<?= $form->field($model, 'subject')->textInput(['maxlength' => true]) ?>
+
+<?= $form->field($model, 'body')->textarea(['rows' => 6]) ?>
+
+<?= $form->field($model, 'verifyCode')->widget(\yii\captcha\Captcha::class) ?>
+
+<div class="form-group">
+    <?= Html::submitButton('Отправить', ['class' => 'btn btn-primary']) ?>
+</div>
+
+<?php ActiveForm::end() ?>
+```
+
+### Расширенные поля формы
+```php
+<?php
+// Различные типы полей
+<?= $form->field($model, 'birthday')->input('date') ?>
+<?= $form->field($model, 'time')->input('time') ?>
+<?= $form->field($model, 'datetime')->input('datetime-local') ?>
+<?= $form->field($model, 'color')->input('color') ?>
+<?= $form->field($model, 'range')->input('range', ['min' => 0, 'max' => 100]) ?>
+
+// Dropdown с группами
+<?= $form->field($model, 'category')->dropDownList([
+    'Фрукты' => [
+        'apple' => 'Яблоко',
+        'orange' => 'Апельсин',
+    ],
+    'Овощи' => [
+        'tomato' => 'Помидор',
+        'potato' => 'Картофель',
+    ],
+]) ?>
+
+// Radio и Checkbox списки
+<?= $form->field($model, 'role')->radioList([
+    'user' => 'Пользователь',
+    'admin' => 'Администратор',
+], [
+    'item' => function ($index, $label, $name, $checked, $value) {
+        return Html::radio($name, $checked, [
+            'value' => $value,
+            'label' => $label,
+            'class' => 'form-check-input',
+        ]);
+    },
+]) ?>
+
+<?= $form->field($model, 'permissions')->checkboxList($permissionList, [
+    'separator' => '<br>',
+]) ?>
+
+// Inline форма
+<?= $form->field($model, 'agree', ['options' => ['class' => 'form-check']])
+    ->checkbox(['class' => 'form-check-input']) ?>
+
+// Кастомный template
+<?= $form->field($model, 'price', [
+    'template' => '<div class="input-group">{label}<span class="input-group-text">$</span>{input}{error}</div>',
+])->textInput() ?>
+
+// С hint
+<?= $form->field($model, 'password')
+    ->passwordInput()
+    ->hint('Минимум 8 символов') ?>
+
+// С label
+<?= $form->field($model, 'email')
+    ->input('email')
+    ->label('Электронная почта') ?>
+
+// Без label
+<?= $form->field($model, 'search', ['options' => ['class' => 'mb-0']])
+    ->textInput(['placeholder' => 'Поиск...'])
+    ->label(false) ?>
+```
+
+### AJAX валидация
+```php
+<?php
+// В контроллере
+public function actionCreate()
+{
+    $model = new User();
+
+    if (Yii::$app->request->isAjax && $model->load(Yii::$app->request->post())) {
+        Yii::$app->response->format = Response::FORMAT_JSON;
+        return ActiveForm::validate($model);
+    }
+
+    if ($model->load(Yii::$app->request->post()) && $model->save()) {
+        return $this->redirect(['view', 'id' => $model->id]);
+    }
+
+    return $this->render('create', ['model' => $model]);
+}
+
+// В view
+$form = ActiveForm::begin([
+    'id' => 'user-form',
+    'enableAjaxValidation' => true,
+    'validationUrl' => ['validate'],
+]);
+```
+
+## Url Helper
+
+### Создание URL
+```php
+<?php
+use yii\helpers\Url;
+
+// Относительный URL
+echo Url::to(['site/index']);                    // /site/index
+echo Url::to(['user/view', 'id' => 1]);          // /user/view?id=1
+echo Url::to(['post/index', 'page' => 2]);       // /post/index?page=2
+
+// Абсолютный URL
+echo Url::to(['site/index'], true);              // http://example.com/site/index
+echo Url::to(['site/index'], 'https');           // https://example.com/site/index
+
+// Текущий URL
+echo Url::to('');                                // Текущий URL
+echo Url::current();                             // Текущий URL
+echo Url::current(['page' => 2]);                // Текущий URL + параметр
+
+// Внешний URL
+echo Url::to('https://example.com');
+
+// Домашняя страница
+echo Url::home();
+echo Url::home(true);                            // Абсолютный
+
+// Предыдущий URL
+echo Url::previous();
+
+// Запоминание URL
+Url::remember();
+Url::remember(['user/profile'], 'profile');
+echo Url::previous('profile');
+
+// Канонический URL
+$this->registerLinkTag(['rel' => 'canonical', 'href' => Url::canonical()]);
+
+// Base URL
+echo Url::base();                                // /
+echo Url::base(true);                            // http://example.com
+```
+
+## Регистрация скриптов и стилей
+
+### В View
+```php
+<?php
+/** @var yii\web\View $this */
+
+// CSS файлы
+$this->registerCssFile('/css/custom.css', ['depends' => [\app\assets\AppAsset::class]]);
+$this->registerCssFile('https://cdn.example.com/lib.css');
+
+// Inline CSS
+$this->registerCss('.highlight { background: yellow; }');
+
+// JS файлы
+$this->registerJsFile('/js/custom.js', [
+    'depends' => [\yii\web\JqueryAsset::class],
+    'position' => \yii\web\View::POS_END,
+]);
+
+// Inline JS
+$this->registerJs('
+    $(function() {
+        $(".delete-btn").click(function() {
+            return confirm("Удалить?");
+        });
+    });
+', \yii\web\View::POS_READY);
+
+// JS в начале body
+$this->registerJs('console.log("loaded");', \yii\web\View::POS_BEGIN);
+
+// JS в конце body
+$this->registerJs('initApp();', \yii\web\View::POS_END);
+
+// Мета-теги
+$this->registerMetaTag(['name' => 'description', 'content' => 'Описание страницы']);
+$this->registerMetaTag(['name' => 'keywords', 'content' => 'ключевые, слова']);
+$this->registerMetaTag(['property' => 'og:title', 'content' => $this->title]);
+
+// Link теги
+$this->registerLinkTag(['rel' => 'alternate', 'hreflang' => 'en', 'href' => $enUrl]);
+```
+
+### Asset Bundles
+```php
+<?php
+// assets/AppAsset.php
+namespace app\assets;
+
+use yii\web\AssetBundle;
+
+class AppAsset extends AssetBundle
+{
+    public $basePath = '@webroot';
+    public $baseUrl = '@web';
+
+    public $css = [
+        'css/site.css',
+    ];
+
+    public $js = [
+        'js/app.js',
+    ];
+
+    public $depends = [
+        \yii\web\YiiAsset::class,
+        \yii\bootstrap5\BootstrapAsset::class,
+    ];
+
+    // Опции публикации
+    public $publishOptions = [
+        'forceCopy' => YII_DEBUG,
+    ];
+}
+
+// В layout или view
+AppAsset::register($this);
+```
+
+## Блоки контента
+
+### Определение блоков
+```php
+<?php
+// В view
+$this->beginBlock('sidebar');
+?>
+<div class="sidebar">
+    <h3>Боковая панель</h3>
+    <ul>
+        <li>Пункт 1</li>
+        <li>Пункт 2</li>
+    </ul>
+</div>
+<?php
+$this->endBlock();
+?>
+
+// В layout
+<?php if (isset($this->blocks['sidebar'])): ?>
+    <aside><?= $this->blocks['sidebar'] ?></aside>
+<?php else: ?>
+    <aside><?= $this->render('_default_sidebar') ?></aside>
+<?php endif; ?>
+```
+
+### Content placeholders
+```php
+<?php
+// В layout
+<!DOCTYPE html>
+<html>
+<head>
+    <?php $this->beginContent('@app/views/layouts/_header.php'); ?>
+    <?php $this->endContent(); ?>
+</head>
+<body>
+    <?= $content ?>
+
+    <?php $this->beginContent('@app/views/layouts/_footer.php'); ?>
+    <?php $this->endContent(); ?>
+</body>
+</html>
+```
+
+## ViewRenderer и темы
+
+### Темы
+```php
+<?php
+// config/web.php
+return [
+    'components' => [
+        'view' => [
+            'theme' => [
+                'basePath' => '@app/themes/basic',
+                'baseUrl' => '@web/themes/basic',
+                'pathMap' => [
+                    '@app/views' => '@app/themes/basic/views',
+                    '@app/modules' => '@app/themes/basic/modules',
+                    '@app/widgets' => '@app/themes/basic/widgets',
+                ],
+            ],
+        ],
+    ],
+];
+```
+
+### View Events
+```php
+<?php
+// В контроллере или config
+\Yii::$app->view->on(\yii\base\View::EVENT_BEGIN_PAGE, function ($event) {
+    // Перед началом страницы
+});
+
+\Yii::$app->view->on(\yii\base\View::EVENT_END_PAGE, function ($event) {
+    // После окончания страницы
+});
+
+\Yii::$app->view->on(\yii\base\View::EVENT_BEFORE_RENDER, function ($event) {
+    // Перед рендерингом view
+    $event->viewFile; // Путь к файлу
+});
+
+\Yii::$app->view->on(\yii\base\View::EVENT_AFTER_RENDER, function ($event) {
+    // После рендеринга
+    $event->output; // Результат рендеринга
+});
+```
+
+## Рекомендации для Claude Code
+
+1. **Escaping** - всегда используйте `Html::encode()` для пользовательских данных
+2. **Partials** - выносите повторяющиеся элементы в partials с префиксом `_`
+3. **Asset Bundles** - группируйте CSS/JS в asset bundles
+4. **PHPDoc** - документируйте переменные в начале view
+5. **Layouts** - используйте вложенные layouts для модульности
+6. **ActiveForm** - предпочитайте ActiveForm для форм с валидацией
diff --git a/erp24/php_skills/14-yii2-routing.md b/erp24/php_skills/14-yii2-routing.md
new file mode 100644 (file)
index 0000000..5fc1f05
--- /dev/null
@@ -0,0 +1,609 @@
+# Yii2 Routing - Маршрутизация
+
+## Конфигурация UrlManager
+
+### Базовая настройка
+```php
+<?php
+// config/web.php
+return [
+    'components' => [
+        'urlManager' => [
+            'class' => \yii\web\UrlManager::class,
+            'enablePrettyUrl' => true,
+            'showScriptName' => false,  // Убрать index.php из URL
+            'enableStrictParsing' => false,
+            'suffix' => '',  // Суффикс URL (.html, /)
+            'rules' => [
+                // Правила маршрутизации
+            ],
+        ],
+    ],
+];
+```
+
+### Nginx конфигурация
+```nginx
+location / {
+    try_files $uri $uri/ /index.php$is_args$args;
+}
+
+location ~ \.php$ {
+    include fastcgi_params;
+    fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
+    fastcgi_pass unix:/var/run/php/php8.1-fpm.sock;
+}
+
+location ~ /\.(ht|svn|git) {
+    deny all;
+}
+```
+
+## Правила маршрутизации
+
+### Базовые правила
+```php
+<?php
+'rules' => [
+    // Точное соответствие
+    '' => 'site/index',                        // / -> site/index
+    'about' => 'site/about',                   // /about -> site/about
+
+    // С параметрами
+    'post/<id:\d+>' => 'post/view',           // /post/123 -> post/view?id=123
+    'user/<username:\w+>' => 'user/profile',  // /user/john -> user/profile?username=john
+
+    // Несколько параметров
+    'posts/<year:\d{4}>/<month:\d{2}>' => 'post/archive',  // /posts/2024/01
+
+    // Опциональные параметры
+    'posts/<page:\d+>' => 'post/index',
+    'posts' => 'post/index',                   // page необязателен
+
+    // Wildcard
+    'doc/<path:.*>' => 'doc/view',            // /doc/any/path/here
+],
+```
+
+### RESTful правила
+```php
+<?php
+'rules' => [
+    // Полный REST набор
+    [
+        'class' => \yii\rest\UrlRule::class,
+        'controller' => 'api/user',
+        'pluralize' => true,
+    ],
+    // Создаёт:
+    // GET    /users         -> api/user/index
+    // GET    /users/123     -> api/user/view?id=123
+    // POST   /users         -> api/user/create
+    // PUT    /users/123     -> api/user/update?id=123
+    // DELETE /users/123     -> api/user/delete?id=123
+    // HEAD   /users         -> api/user/index
+    // HEAD   /users/123     -> api/user/view?id=123
+    // OPTIONS /users        -> api/user/options
+    // OPTIONS /users/123    -> api/user/options
+
+    // Несколько контроллеров
+    [
+        'class' => \yii\rest\UrlRule::class,
+        'controller' => ['api/user', 'api/post', 'api/comment'],
+    ],
+
+    // С кастомными actions
+    [
+        'class' => \yii\rest\UrlRule::class,
+        'controller' => 'api/user',
+        'extraPatterns' => [
+            'POST login' => 'login',           // POST /users/login
+            'GET me' => 'me',                  // GET /users/me
+            'PUT {id}/activate' => 'activate', // PUT /users/123/activate
+        ],
+    ],
+
+    // Только определённые actions
+    [
+        'class' => \yii\rest\UrlRule::class,
+        'controller' => 'api/user',
+        'only' => ['index', 'view'],
+    ],
+
+    // Исключая actions
+    [
+        'class' => \yii\rest\UrlRule::class,
+        'controller' => 'api/user',
+        'except' => ['delete'],
+    ],
+],
+```
+
+### Правила с HTTP методами
+```php
+<?php
+'rules' => [
+    // Конкретный метод
+    'POST users' => 'user/create',
+    'PUT users/<id:\d+>' => 'user/update',
+    'DELETE users/<id:\d+>' => 'user/delete',
+
+    // Массив с verb
+    [
+        'pattern' => 'users/<id:\d+>',
+        'route' => 'user/view',
+        'verb' => ['GET', 'HEAD'],
+    ],
+
+    // Разные routes для разных методов
+    [
+        'pattern' => 'users',
+        'route' => 'user/index',
+        'verb' => 'GET',
+    ],
+    [
+        'pattern' => 'users',
+        'route' => 'user/create',
+        'verb' => 'POST',
+    ],
+],
+```
+
+### Правила с хостом
+```php
+<?php
+'rules' => [
+    // По поддомену
+    [
+        'pattern' => '',
+        'route' => 'admin/dashboard/index',
+        'host' => 'admin.<domain>',
+    ],
+    [
+        'pattern' => '',
+        'route' => 'api/v1/default/index',
+        'host' => 'api.<domain>',
+    ],
+
+    // Параметр из хоста
+    [
+        'pattern' => '',
+        'route' => 'site/index',
+        'host' => '<subdomain:\w+>.<domain>',
+    ],
+],
+```
+
+## Группировка правил
+
+### GroupUrlRule
+```php
+<?php
+'rules' => [
+    // Группа для API v1
+    [
+        'class' => \yii\web\GroupUrlRule::class,
+        'prefix' => 'api/v1',
+        'rules' => [
+            'users' => 'user/index',
+            'users/<id:\d+>' => 'user/view',
+            'posts' => 'post/index',
+            'posts/<id:\d+>' => 'post/view',
+        ],
+    ],
+    // api/v1/users -> api/v1/user/index
+
+    // Группа для админки
+    [
+        'class' => \yii\web\GroupUrlRule::class,
+        'prefix' => 'admin',
+        'routePrefix' => 'admin',
+        'rules' => [
+            '' => 'dashboard/index',
+            'users' => 'user/index',
+            'settings' => 'settings/index',
+        ],
+    ],
+    // admin/users -> admin/user/index
+],
+```
+
+### Модульные правила
+```php
+<?php
+// config/web.php
+return [
+    'modules' => [
+        'api' => [
+            'class' => \app\modules\api\Module::class,
+        ],
+        'admin' => [
+            'class' => \app\modules\admin\Module::class,
+        ],
+    ],
+    'components' => [
+        'urlManager' => [
+            'rules' => [
+                // Правила для модулей
+                [
+                    'class' => \yii\web\GroupUrlRule::class,
+                    'prefix' => 'api/v1',
+                    'routePrefix' => 'api/v1',
+                    'rules' => [
+                        [
+                            'class' => \yii\rest\UrlRule::class,
+                            'controller' => 'user',
+                        ],
+                    ],
+                ],
+            ],
+        ],
+    ],
+];
+```
+
+## Кастомные правила
+
+### Класс UrlRule
+```php
+<?php
+// components/SlugUrlRule.php
+namespace app\components;
+
+use yii\web\UrlRule;
+use yii\web\UrlRuleInterface;
+use app\models\Post;
+
+class SlugUrlRule extends UrlRule implements UrlRuleInterface
+{
+    public function parseRequest($manager, $request): array|false
+    {
+        $pathInfo = $request->pathInfo;
+
+        // Проверяем формат URL
+        if (preg_match('/^post\/([a-z0-9-]+)$/i', $pathInfo, $matches)) {
+            $slug = $matches[1];
+
+            // Ищем пост по slug
+            $post = Post::find()->where(['slug' => $slug])->one();
+
+            if ($post !== null) {
+                return ['post/view', ['id' => $post->id]];
+            }
+        }
+
+        return false;
+    }
+
+    public function createUrl($manager, $route, $params): string|false
+    {
+        if ($route === 'post/view' && isset($params['id'])) {
+            $post = Post::findOne($params['id']);
+
+            if ($post !== null) {
+                return 'post/' . $post->slug;
+            }
+        }
+
+        return false;
+    }
+}
+
+// Использование
+'rules' => [
+    [
+        'class' => \app\components\SlugUrlRule::class,
+    ],
+],
+```
+
+### Категории и вложенные URL
+```php
+<?php
+// components/CategoryUrlRule.php
+namespace app\components;
+
+use yii\base\BaseObject;
+use yii\web\UrlRuleInterface;
+use app\models\Category;
+
+class CategoryUrlRule extends BaseObject implements UrlRuleInterface
+{
+    public function parseRequest($manager, $request): array|false
+    {
+        $pathInfo = $request->pathInfo;
+
+        // catalog/electronics/phones -> category/view?path=electronics/phones
+        if (preg_match('/^catalog\/(.+)$/', $pathInfo, $matches)) {
+            $path = $matches[1];
+
+            $category = Category::find()
+                ->where(['path' => $path])
+                ->one();
+
+            if ($category) {
+                return ['category/view', ['id' => $category->id]];
+            }
+        }
+
+        return false;
+    }
+
+    public function createUrl($manager, $route, $params): string|false
+    {
+        if ($route === 'category/view' && isset($params['id'])) {
+            $category = Category::findOne($params['id']);
+
+            if ($category) {
+                return 'catalog/' . $category->path;
+            }
+        }
+
+        return false;
+    }
+}
+```
+
+## Создание URL
+
+### В контроллере
+```php
+<?php
+use yii\helpers\Url;
+
+class SiteController extends Controller
+{
+    public function actionExample(): \yii\web\Response
+    {
+        // Абсолютный URL
+        $url = Url::to(['user/view', 'id' => 1], true);
+        // https://example.com/user/view?id=1
+
+        // С протоколом
+        $url = Url::to(['user/view', 'id' => 1], 'https');
+
+        // Редирект
+        return $this->redirect(['user/profile', 'username' => 'john']);
+
+        // toRoute (аналог Url::to с массивом)
+        $url = Url::toRoute(['post/index', 'page' => 2]);
+    }
+}
+```
+
+### В представлениях
+```php
+<?php
+use yii\helpers\Url;
+use yii\helpers\Html;
+
+// Ссылки
+echo Html::a('Профиль', ['user/profile', 'id' => $user->id]);
+echo Html::a('Главная', Url::home());
+echo Html::a('Назад', Url::previous());
+
+// Формы
+echo Html::beginForm(['site/search'], 'get');
+echo Html::beginForm(Url::to(['user/update', 'id' => $id]), 'post');
+
+// Изображения с URL
+echo Html::img(Url::to('@web/images/logo.png', true));
+
+// Текущий URL с изменением параметров
+echo Url::current(['page' => 2]);
+echo Url::current(['sort' => 'name'], true);
+
+// Канонический URL
+$this->registerLinkTag(['rel' => 'canonical', 'href' => Url::canonical()]);
+```
+
+## Нормализация URL
+
+### UrlNormalizer
+```php
+<?php
+// config/web.php
+return [
+    'components' => [
+        'urlManager' => [
+            'enablePrettyUrl' => true,
+            'showScriptName' => false,
+            'normalizer' => [
+                'class' => \yii\web\UrlNormalizer::class,
+                // Нормализация trailing slash
+                'normalizeTrailingSlash' => true,
+                // Действие: REDIRECT_TEMPORARY (302) или REDIRECT_PERMANENT (301)
+                'action' => \yii\web\UrlNormalizer::ACTION_REDIRECT_PERMANENT,
+            ],
+            'rules' => [
+                // ...
+            ],
+        ],
+    ],
+];
+```
+
+### Отключение нормализации для правил
+```php
+<?php
+'rules' => [
+    [
+        'pattern' => 'api/<action>',
+        'route' => 'api/default/<action>',
+        'normalizer' => false,  // Отключить нормализацию
+    ],
+    [
+        'pattern' => 'legacy/<path:.*>',
+        'route' => 'legacy/redirect',
+        'normalizer' => [
+            'normalizeTrailingSlash' => false,
+        ],
+    ],
+],
+```
+
+## Кэширование правил
+
+```php
+<?php
+// config/web.php
+return [
+    'components' => [
+        'urlManager' => [
+            'enablePrettyUrl' => true,
+            'showScriptName' => false,
+            'cache' => 'cache',  // Использовать компонент cache
+            'rules' => [
+                // Правила кэшируются
+            ],
+        ],
+    ],
+];
+
+// Очистка кэша правил
+Yii::$app->urlManager->cache->delete('UrlManager');
+```
+
+## Языковые URL
+
+### Локализованные URL
+```php
+<?php
+// components/LocaleUrlManager.php
+namespace app\components;
+
+use yii\web\UrlManager;
+
+class LocaleUrlManager extends UrlManager
+{
+    public array $languages = ['ru', 'en', 'de'];
+    public string $defaultLanguage = 'ru';
+
+    public function createUrl($params): string
+    {
+        $url = parent::createUrl($params);
+
+        $language = \Yii::$app->language;
+
+        if ($language !== $this->defaultLanguage) {
+            $url = '/' . substr($language, 0, 2) . $url;
+        }
+
+        return $url;
+    }
+
+    public function parseRequest($request): array|false
+    {
+        $pathInfo = $request->pathInfo;
+
+        // Извлекаем язык из URL
+        if (preg_match('/^(en|de)\/(.*)$/', $pathInfo, $matches)) {
+            \Yii::$app->language = $matches[1];
+            $request->pathInfo = $matches[2];
+        } else {
+            \Yii::$app->language = $this->defaultLanguage;
+        }
+
+        return parent::parseRequest($request);
+    }
+}
+
+// config/web.php
+return [
+    'components' => [
+        'urlManager' => [
+            'class' => \app\components\LocaleUrlManager::class,
+            'enablePrettyUrl' => true,
+            'showScriptName' => false,
+            'languages' => ['ru', 'en', 'de'],
+            'defaultLanguage' => 'ru',
+        ],
+    ],
+];
+```
+
+### hreflang теги
+```php
+<?php
+// В layout или view
+$languages = ['ru' => 'ru-RU', 'en' => 'en-US', 'de' => 'de-DE'];
+
+foreach ($languages as $lang => $locale) {
+    $this->registerLinkTag([
+        'rel' => 'alternate',
+        'hreflang' => $locale,
+        'href' => Url::to(array_merge(
+            [$this->context->route],
+            Yii::$app->request->queryParams,
+            ['language' => $lang]
+        ), true),
+    ]);
+}
+```
+
+## Версионирование API
+
+```php
+<?php
+// config/web.php
+return [
+    'components' => [
+        'urlManager' => [
+            'rules' => [
+                // API v1
+                [
+                    'class' => \yii\rest\UrlRule::class,
+                    'controller' => ['v1/user' => 'api/v1/user'],
+                    'prefix' => 'api',
+                ],
+                // API v2
+                [
+                    'class' => \yii\rest\UrlRule::class,
+                    'controller' => ['v2/user' => 'api/v2/user'],
+                    'prefix' => 'api',
+                ],
+            ],
+        ],
+    ],
+];
+
+// Или через модули
+'modules' => [
+    'v1' => [
+        'class' => \app\modules\api\v1\Module::class,
+    ],
+    'v2' => [
+        'class' => \app\modules\api\v2\Module::class,
+    ],
+],
+```
+
+## Отладка маршрутов
+
+```php
+<?php
+// Посмотреть все правила
+$rules = Yii::$app->urlManager->rules;
+foreach ($rules as $rule) {
+    echo get_class($rule) . ': ' . $rule->name . "\n";
+}
+
+// Проверить парсинг URL
+$request = new \yii\web\Request();
+$request->pathInfo = 'user/123';
+$result = Yii::$app->urlManager->parseRequest($request);
+var_dump($result); // ['user/view', ['id' => '123']]
+
+// Проверить создание URL
+$url = Yii::$app->urlManager->createUrl(['user/view', 'id' => 123]);
+echo $url; // /user/123
+```
+
+## Рекомендации для Claude Code
+
+1. **Pretty URLs** - всегда включайте enablePrettyUrl для чистых URL
+2. **REST правила** - используйте `yii\rest\UrlRule` для API
+3. **Группировка** - используйте `GroupUrlRule` для модулей
+4. **Кэширование** - включайте кэш для production
+5. **Нормализация** - используйте UrlNormalizer для консистентности
+6. **Версионирование** - добавляйте версию в API URL (/api/v1/)
diff --git a/erp24/php_skills/15-yii2-migrations.md b/erp24/php_skills/15-yii2-migrations.md
new file mode 100644 (file)
index 0000000..8164137
--- /dev/null
@@ -0,0 +1,650 @@
+# Yii2 Migrations - Миграции базы данных
+
+## Основы миграций
+
+### Создание миграции
+```bash
+# Базовая миграция
+php yii migrate/create create_user_table
+
+# С namespace
+php yii migrate/create app\\migrations\\CreateUserTable --namespace=app\\migrations
+
+# В модуле
+php yii migrate/create create_order_table --migrationPath=@app/modules/shop/migrations
+```
+
+### Структура файла миграции
+```php
+<?php
+// migrations/m241201_120000_create_user_table.php
+use yii\db\Migration;
+
+class m241201_120000_create_user_table extends Migration
+{
+    public function safeUp(): bool
+    {
+        $this->createTable('{{%users}}', [
+            'id' => $this->primaryKey(),
+            'username' => $this->string(255)->notNull()->unique(),
+            'email' => $this->string(255)->notNull()->unique(),
+            'password_hash' => $this->string(255)->notNull(),
+            'status' => $this->smallInteger()->notNull()->defaultValue(0),
+            'created_at' => $this->integer()->notNull(),
+            'updated_at' => $this->integer()->notNull(),
+        ]);
+
+        $this->createIndex('idx-users-status', '{{%users}}', 'status');
+
+        return true;
+    }
+
+    public function safeDown(): bool
+    {
+        $this->dropTable('{{%users}}');
+
+        return true;
+    }
+}
+```
+
+## Типы колонок
+
+### Базовые типы
+```php
+<?php
+public function safeUp(): bool
+{
+    $this->createTable('{{%products}}', [
+        // Целые числа
+        'id' => $this->primaryKey(),
+        'tiny_int' => $this->tinyInteger(),           // TINYINT
+        'small_int' => $this->smallInteger(),         // SMALLINT
+        'medium_int' => $this->integer(),             // INTEGER
+        'big_int' => $this->bigInteger(),             // BIGINT
+
+        // С размером
+        'count' => $this->integer(11)->notNull(),
+
+        // Auto increment
+        'id2' => $this->bigPrimaryKey(),              // BIGSERIAL
+
+        // Числа с плавающей точкой
+        'price' => $this->decimal(10, 2),             // DECIMAL(10,2)
+        'rating' => $this->float(),                   // FLOAT
+        'score' => $this->double(),                   // DOUBLE
+
+        // Денежные
+        'amount' => $this->money(10, 2),              // DECIMAL(10,2)
+
+        // Строки
+        'code' => $this->char(10),                    // CHAR(10)
+        'name' => $this->string(255),                 // VARCHAR(255)
+        'description' => $this->text(),               // TEXT
+
+        // Дата и время
+        'birth_date' => $this->date(),                // DATE
+        'start_time' => $this->time(),                // TIME
+        'created_at' => $this->dateTime(),            // DATETIME
+        'updated_at' => $this->timestamp(),           // TIMESTAMP
+
+        // Бинарные
+        'data' => $this->binary(),                    // BLOB
+
+        // Булевы
+        'is_active' => $this->boolean(),              // BOOLEAN
+
+        // JSON (PostgreSQL, MySQL 5.7+)
+        'metadata' => $this->json(),                  // JSON
+
+        // UUID (PostgreSQL)
+        'uuid' => 'uuid DEFAULT gen_random_uuid()',
+    ]);
+
+    return true;
+}
+```
+
+### Модификаторы колонок
+```php
+<?php
+$this->createTable('{{%orders}}', [
+    'id' => $this->primaryKey(),
+
+    // NOT NULL
+    'user_id' => $this->integer()->notNull(),
+
+    // DEFAULT
+    'status' => $this->smallInteger()->notNull()->defaultValue(0),
+    'created_at' => $this->timestamp()->defaultExpression('CURRENT_TIMESTAMP'),
+
+    // UNIQUE
+    'order_number' => $this->string(50)->notNull()->unique(),
+
+    // COMMENT
+    'notes' => $this->text()->comment('Примечания к заказу'),
+
+    // NULL
+    'deleted_at' => $this->timestamp()->null(),
+
+    // UNSIGNED (MySQL)
+    'quantity' => $this->integer()->unsigned()->notNull(),
+
+    // Комбинации
+    'email' => $this->string(255)
+        ->notNull()
+        ->unique()
+        ->comment('Email пользователя'),
+]);
+```
+
+## Работа с таблицами
+
+### Создание таблицы
+```php
+<?php
+public function safeUp(): bool
+{
+    // Простое создание
+    $this->createTable('{{%posts}}', [
+        'id' => $this->primaryKey(),
+        'title' => $this->string(255)->notNull(),
+        'content' => $this->text(),
+        'user_id' => $this->integer()->notNull(),
+        'created_at' => $this->timestamp()->defaultExpression('CURRENT_TIMESTAMP'),
+    ]);
+
+    // С опциями таблицы (MySQL)
+    $tableOptions = null;
+    if ($this->db->driverName === 'mysql') {
+        $tableOptions = 'CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci ENGINE=InnoDB';
+    }
+
+    $this->createTable('{{%comments}}', [
+        'id' => $this->primaryKey(),
+        'content' => $this->text()->notNull(),
+    ], $tableOptions);
+
+    return true;
+}
+```
+
+### Изменение таблицы
+```php
+<?php
+public function safeUp(): bool
+{
+    // Добавление колонки
+    $this->addColumn('{{%users}}', 'phone', $this->string(20)->after('email'));
+
+    // Добавление колонки в начало (MySQL)
+    $this->addColumn('{{%users}}', 'uuid', $this->string(36)->first());
+
+    // Удаление колонки
+    $this->dropColumn('{{%users}}', 'old_field');
+
+    // Переименование колонки
+    $this->renameColumn('{{%users}}', 'name', 'full_name');
+
+    // Изменение типа колонки
+    $this->alterColumn('{{%users}}', 'status', $this->integer()->notNull()->defaultValue(1));
+
+    // Переименование таблицы
+    $this->renameTable('{{%old_name}}', '{{%new_name}}');
+
+    // Удаление таблицы
+    $this->dropTable('{{%temp_table}}');
+
+    // Очистка таблицы
+    $this->truncateTable('{{%logs}}');
+
+    return true;
+}
+```
+
+## Индексы
+
+### Создание индексов
+```php
+<?php
+public function safeUp(): bool
+{
+    // Обычный индекс
+    $this->createIndex(
+        'idx-posts-user_id',
+        '{{%posts}}',
+        'user_id'
+    );
+
+    // Уникальный индекс
+    $this->createIndex(
+        'idx-users-email',
+        '{{%users}}',
+        'email',
+        true  // unique
+    );
+
+    // Составной индекс
+    $this->createIndex(
+        'idx-posts-user_status',
+        '{{%posts}}',
+        ['user_id', 'status']
+    );
+
+    // Частичный индекс (PostgreSQL)
+    $this->execute('CREATE INDEX idx_active_users ON {{%users}} (email) WHERE status = 1');
+
+    // GIN индекс для JSONB (PostgreSQL)
+    $this->execute('CREATE INDEX idx_metadata ON {{%products}} USING GIN (metadata)');
+
+    // Полнотекстовый индекс (PostgreSQL)
+    $this->execute('CREATE INDEX idx_search ON {{%posts}} USING GIN (to_tsvector(\'russian\', title || \' \' || content))');
+
+    return true;
+}
+
+public function safeDown(): bool
+{
+    $this->dropIndex('idx-posts-user_id', '{{%posts}}');
+    $this->dropIndex('idx-users-email', '{{%users}}');
+    $this->dropIndex('idx-posts-user_status', '{{%posts}}');
+
+    return true;
+}
+```
+
+## Внешние ключи
+
+### Создание FK
+```php
+<?php
+public function safeUp(): bool
+{
+    $this->createTable('{{%posts}}', [
+        'id' => $this->primaryKey(),
+        'user_id' => $this->integer()->notNull(),
+        'category_id' => $this->integer(),
+        'title' => $this->string(255)->notNull(),
+    ]);
+
+    // Внешний ключ с каскадным удалением
+    $this->addForeignKey(
+        'fk-posts-user_id',           // Имя ключа
+        '{{%posts}}',                  // Таблица
+        'user_id',                     // Колонка
+        '{{%users}}',                  // Связанная таблица
+        'id',                          // Связанная колонка
+        'CASCADE',                     // ON DELETE
+        'CASCADE'                      // ON UPDATE
+    );
+
+    // FK с SET NULL при удалении
+    $this->addForeignKey(
+        'fk-posts-category_id',
+        '{{%posts}}',
+        'category_id',
+        '{{%categories}}',
+        'id',
+        'SET NULL',
+        'CASCADE'
+    );
+
+    // FK с RESTRICT
+    $this->addForeignKey(
+        'fk-orders-user_id',
+        '{{%orders}}',
+        'user_id',
+        '{{%users}}',
+        'id',
+        'RESTRICT',
+        'RESTRICT'
+    );
+
+    return true;
+}
+
+public function safeDown(): bool
+{
+    // Сначала удаляем FK, потом таблицу
+    $this->dropForeignKey('fk-posts-user_id', '{{%posts}}');
+    $this->dropForeignKey('fk-posts-category_id', '{{%posts}}');
+    $this->dropTable('{{%posts}}');
+
+    return true;
+}
+```
+
+## Данные
+
+### Вставка данных
+```php
+<?php
+public function safeUp(): bool
+{
+    // Одна запись
+    $this->insert('{{%users}}', [
+        'username' => 'admin',
+        'email' => 'admin@example.com',
+        'password_hash' => Yii::$app->security->generatePasswordHash('admin'),
+        'status' => 1,
+        'created_at' => time(),
+        'updated_at' => time(),
+    ]);
+
+    // Массовая вставка
+    $this->batchInsert('{{%roles}}', ['name', 'description'], [
+        ['admin', 'Администратор'],
+        ['moderator', 'Модератор'],
+        ['user', 'Пользователь'],
+    ]);
+
+    return true;
+}
+```
+
+### Обновление и удаление
+```php
+<?php
+public function safeUp(): bool
+{
+    // Обновление
+    $this->update(
+        '{{%users}}',
+        ['status' => 1],               // Новые значения
+        ['status' => 0]                // Условие
+    );
+
+    // Удаление
+    $this->delete('{{%users}}', ['status' => -1]);
+
+    // Upsert (INSERT ON CONFLICT)
+    $this->upsert(
+        '{{%settings}}',
+        [
+            'key' => 'site_name',
+            'value' => 'My Site',
+        ],
+        [
+            'value' => 'My Site',      // UPDATE при конфликте
+        ]
+    );
+
+    return true;
+}
+```
+
+## Raw SQL
+
+### Выполнение SQL
+```php
+<?php
+public function safeUp(): bool
+{
+    // Простой SQL
+    $this->execute('ALTER TABLE {{%users}} ADD COLUMN age INTEGER');
+
+    // С параметрами (PostgreSQL специфичный синтаксис)
+    $this->execute('
+        CREATE OR REPLACE FUNCTION update_modified_column()
+        RETURNS TRIGGER AS $$
+        BEGIN
+            NEW.updated_at = NOW();
+            RETURN NEW;
+        END;
+        $$ language \'plpgsql\'
+    ');
+
+    // Триггер
+    $this->execute('
+        CREATE TRIGGER update_users_modtime
+        BEFORE UPDATE ON {{%users}}
+        FOR EACH ROW
+        EXECUTE FUNCTION update_modified_column()
+    ');
+
+    // Представление
+    $this->execute('
+        CREATE OR REPLACE VIEW {{%active_users}} AS
+        SELECT * FROM {{%users}} WHERE status = 1
+    ');
+
+    // Материализованное представление (PostgreSQL)
+    $this->execute('
+        CREATE MATERIALIZED VIEW {{%user_stats}} AS
+        SELECT user_id, COUNT(*) as posts_count
+        FROM {{%posts}}
+        GROUP BY user_id
+    ');
+
+    return true;
+}
+
+public function safeDown(): bool
+{
+    $this->execute('DROP TRIGGER IF EXISTS update_users_modtime ON {{%users}}');
+    $this->execute('DROP FUNCTION IF EXISTS update_modified_column()');
+    $this->execute('DROP VIEW IF EXISTS {{%active_users}}');
+    $this->execute('DROP MATERIALIZED VIEW IF EXISTS {{%user_stats}}');
+
+    return true;
+}
+```
+
+## Транзакции
+
+### Безопасные миграции
+```php
+<?php
+// safeUp/safeDown автоматически оборачиваются в транзакцию
+public function safeUp(): bool
+{
+    $this->createTable('{{%orders}}', [
+        'id' => $this->primaryKey(),
+        // ...
+    ]);
+
+    $this->createTable('{{%order_items}}', [
+        'id' => $this->primaryKey(),
+        // ...
+    ]);
+
+    // Если что-то упадёт - откатится всё
+    return true;
+}
+
+// up/down - без транзакции
+public function up(): void
+{
+    // Для операций, которые нельзя в транзакции
+    // (CREATE INDEX CONCURRENTLY в PostgreSQL)
+}
+```
+
+### Ручное управление транзакциями
+```php
+<?php
+public function up(): void
+{
+    // Операция вне транзакции
+    $this->execute('CREATE INDEX CONCURRENTLY idx_large ON {{%large_table}} (column)');
+
+    // Операции в транзакции
+    $transaction = $this->db->beginTransaction();
+    try {
+        $this->insert('{{%settings}}', ['key' => 'value']);
+        $this->update('{{%config}}', ['updated' => true]);
+        $transaction->commit();
+    } catch (\Exception $e) {
+        $transaction->rollBack();
+        throw $e;
+    }
+}
+```
+
+## Команды миграций
+
+### Основные команды
+```bash
+# Применить все новые миграции
+php yii migrate
+
+# Применить N миграций
+php yii migrate 3
+
+# Откатить последнюю миграцию
+php yii migrate/down
+
+# Откатить N миграций
+php yii migrate/down 3
+
+# Откатить все миграции
+php yii migrate/down all
+
+# Переприменить последние N миграций
+php yii migrate/redo 3
+
+# Показать статус миграций
+php yii migrate/history
+php yii migrate/history 10
+
+# Показать непримененные миграции
+php yii migrate/new
+php yii migrate/new 10
+
+# Пометить миграцию как примененную (без выполнения)
+php yii migrate/mark m241201_120000_create_user_table
+
+# Применить без подтверждения
+php yii migrate --interactive=0
+```
+
+### Namespace миграции
+```bash
+# Создать миграцию с namespace
+php yii migrate/create create_user_table --namespace=app\\migrations
+
+# Применить миграции из namespace
+php yii migrate --migrationNamespaces=app\\migrations
+
+# config/console.php
+return [
+    'controllerMap' => [
+        'migrate' => [
+            'class' => \yii\console\controllers\MigrateController::class,
+            'migrationNamespaces' => [
+                'app\migrations',
+                'app\modules\user\migrations',
+            ],
+        ],
+    ],
+];
+```
+
+## Множественные базы данных
+
+### Миграции для разных БД
+```php
+<?php
+// config/console.php
+return [
+    'controllerMap' => [
+        'migrate' => [
+            'class' => \yii\console\controllers\MigrateController::class,
+            'migrationPath' => '@app/migrations',
+            'db' => 'db',
+        ],
+        'migrate-analytics' => [
+            'class' => \yii\console\controllers\MigrateController::class,
+            'migrationPath' => '@app/migrations/analytics',
+            'migrationTable' => '{{%migration_analytics}}',
+            'db' => 'dbAnalytics',
+        ],
+    ],
+];
+
+// Использование
+// php yii migrate           - основная БД
+// php yii migrate-analytics - аналитика
+```
+
+## Best Practices
+
+### Именование
+```php
+<?php
+// ПРАВИЛЬНО - описательные имена
+m241201_120000_create_user_table
+m241201_120001_add_email_to_users
+m241201_120002_create_posts_table
+m241201_120003_add_foreign_key_to_posts
+
+// НЕПРАВИЛЬНО
+m241201_120000_migration1
+m241201_120001_update
+```
+
+### Идемпотентность
+```php
+<?php
+public function safeUp(): bool
+{
+    // Проверка существования таблицы
+    if ($this->db->schema->getTableSchema('{{%users}}') === null) {
+        $this->createTable('{{%users}}', [
+            'id' => $this->primaryKey(),
+            // ...
+        ]);
+    }
+
+    // Проверка существования колонки
+    $table = $this->db->schema->getTableSchema('{{%users}}');
+    if (!isset($table->columns['phone'])) {
+        $this->addColumn('{{%users}}', 'phone', $this->string(20));
+    }
+
+    // Проверка существования индекса
+    $indexes = $this->db->schema->findIndexes('{{%users}}');
+    if (!isset($indexes['idx-users-email'])) {
+        $this->createIndex('idx-users-email', '{{%users}}', 'email', true);
+    }
+
+    return true;
+}
+```
+
+### Большие миграции
+```php
+<?php
+public function safeUp(): bool
+{
+    // Для больших таблиц - batch обработка
+    $batchSize = 1000;
+    $offset = 0;
+
+    while (true) {
+        $rows = (new \yii\db\Query())
+            ->from('{{%old_table}}')
+            ->limit($batchSize)
+            ->offset($offset)
+            ->all();
+
+        if (empty($rows)) {
+            break;
+        }
+
+        $this->batchInsert('{{%new_table}}', array_keys($rows[0]), $rows);
+        $offset += $batchSize;
+    }
+
+    return true;
+}
+```
+
+## Рекомендации для Claude Code
+
+1. **Используйте safeUp/safeDown** - для автоматических транзакций
+2. **Описательные имена** - миграция должна описывать что она делает
+3. **Обратимость** - всегда реализуйте safeDown для отката
+4. **Префикс таблиц** - используйте `{{%table}}` для поддержки префиксов
+5. **Индексы для FK** - всегда создавайте индексы для внешних ключей
+6. **Атомарность** - одна миграция = одно логическое изменение
diff --git a/erp24/php_skills/16-yii2-testing.md b/erp24/php_skills/16-yii2-testing.md
new file mode 100644 (file)
index 0000000..6662e87
--- /dev/null
@@ -0,0 +1,691 @@
+# Yii2 Testing - Тестирование
+
+## Настройка окружения
+
+### Codeception конфигурация
+```yaml
+# codeception.yml
+namespace: tests
+actor_suffix: Tester
+paths:
+    tests: tests
+    output: tests/_output
+    data: tests/_data
+    support: tests/_support
+settings:
+    shuffle: false
+    lint: true
+bootstrap: _bootstrap.php
+params:
+    - tests/params.php
+```
+
+### Конфигурация unit тестов
+```yaml
+# tests/unit.suite.yml
+suite_namespace: tests\unit
+actor: UnitTester
+modules:
+    enabled:
+        - Yii2:
+            part: [orm, fixtures]
+            configFile: 'config/test.php'
+        - Asserts
+```
+
+### Тестовая конфигурация приложения
+```php
+<?php
+// config/test.php
+$params = require __DIR__ . '/params.php';
+$db = require __DIR__ . '/test_db.php';
+
+return [
+    'id' => 'basic-tests',
+    'basePath' => dirname(__DIR__),
+    'aliases' => [
+        '@bower' => '@vendor/bower-asset',
+        '@npm' => '@vendor/npm-asset',
+    ],
+    'language' => 'en-US',
+    'components' => [
+        'db' => $db,
+        'mailer' => [
+            'class' => \yii\symfonymailer\Mailer::class,
+            'useFileTransport' => true,
+        ],
+        'urlManager' => [
+            'showScriptName' => true,
+        ],
+        'user' => [
+            'identityClass' => \app\models\User::class,
+        ],
+        'request' => [
+            'cookieValidationKey' => 'test',
+            'enableCsrfValidation' => false,
+        ],
+    ],
+    'params' => $params,
+];
+```
+
+## Unit тесты
+
+### Базовый unit тест
+```php
+<?php
+// tests/unit/models/UserTest.php
+namespace tests\unit\models;
+
+use app\models\User;
+use Codeception\Test\Unit;
+
+class UserTest extends Unit
+{
+    protected UnitTester $tester;
+
+    // Вызывается перед каждым тестом
+    protected function _before(): void
+    {
+        // Подготовка
+    }
+
+    // Вызывается после каждого теста
+    protected function _after(): void
+    {
+        // Очистка
+    }
+
+    public function testValidation(): void
+    {
+        $user = new User();
+
+        // Пустая модель невалидна
+        $this->assertFalse($user->validate());
+        $this->assertArrayHasKey('email', $user->errors);
+        $this->assertArrayHasKey('username', $user->errors);
+
+        // Заполняем обязательные поля
+        $user->email = 'test@example.com';
+        $user->username = 'testuser';
+        $user->password = 'password123';
+
+        $this->assertTrue($user->validate());
+    }
+
+    public function testEmailValidation(): void
+    {
+        $user = new User();
+        $user->username = 'testuser';
+        $user->password = 'password123';
+
+        // Невалидный email
+        $user->email = 'invalid-email';
+        $this->assertFalse($user->validate(['email']));
+
+        // Валидный email
+        $user->email = 'test@example.com';
+        $this->assertTrue($user->validate(['email']));
+    }
+
+    public function testPasswordHashing(): void
+    {
+        $user = new User();
+        $user->setPassword('mypassword');
+
+        $this->assertNotEmpty($user->password_hash);
+        $this->assertTrue($user->validatePassword('mypassword'));
+        $this->assertFalse($user->validatePassword('wrongpassword'));
+    }
+
+    /**
+     * @dataProvider statusProvider
+     */
+    public function testStatusValidation(int $status, bool $expected): void
+    {
+        $user = new User([
+            'email' => 'test@example.com',
+            'username' => 'testuser',
+            'status' => $status,
+        ]);
+
+        $this->assertEquals($expected, $user->validate(['status']));
+    }
+
+    public static function statusProvider(): array
+    {
+        return [
+            'active status' => [User::STATUS_ACTIVE, true],
+            'inactive status' => [User::STATUS_INACTIVE, true],
+            'invalid status' => [999, false],
+        ];
+    }
+}
+```
+
+### Тестирование с fixtures
+```php
+<?php
+// tests/unit/models/PostTest.php
+namespace tests\unit\models;
+
+use app\models\Post;
+use app\models\User;
+use tests\fixtures\PostFixture;
+use tests\fixtures\UserFixture;
+use Codeception\Test\Unit;
+
+class PostTest extends Unit
+{
+    public function _fixtures(): array
+    {
+        return [
+            'users' => UserFixture::class,
+            'posts' => PostFixture::class,
+        ];
+    }
+
+    public function testFindPublished(): void
+    {
+        $posts = Post::find()->published()->all();
+
+        $this->assertCount(2, $posts);
+        foreach ($posts as $post) {
+            $this->assertEquals(Post::STATUS_PUBLISHED, $post->status);
+        }
+    }
+
+    public function testBelongsToUser(): void
+    {
+        /** @var Post $post */
+        $post = $this->tester->grabFixture('posts', 'post1');
+
+        $this->assertInstanceOf(User::class, $post->user);
+        $this->assertEquals('admin', $post->user->username);
+    }
+}
+```
+
+### Fixture файлы
+```php
+<?php
+// tests/fixtures/UserFixture.php
+namespace tests\fixtures;
+
+use yii\test\ActiveFixture;
+
+class UserFixture extends ActiveFixture
+{
+    public $modelClass = \app\models\User::class;
+    public $dataFile = '@tests/_data/user.php';
+    public $depends = [];
+}
+
+// tests/_data/user.php
+return [
+    'admin' => [
+        'id' => 1,
+        'username' => 'admin',
+        'email' => 'admin@example.com',
+        'password_hash' => '$2y$13$...',
+        'status' => 1,
+        'created_at' => 1234567890,
+        'updated_at' => 1234567890,
+    ],
+    'user' => [
+        'id' => 2,
+        'username' => 'user',
+        'email' => 'user@example.com',
+        'password_hash' => '$2y$13$...',
+        'status' => 1,
+        'created_at' => 1234567890,
+        'updated_at' => 1234567890,
+    ],
+];
+```
+
+## Функциональные тесты
+
+### Тестирование контроллеров
+```php
+<?php
+// tests/functional/SiteCest.php
+namespace tests\functional;
+
+use tests\FunctionalTester;
+
+class SiteCest
+{
+    public function _before(FunctionalTester $I): void
+    {
+        // Перед каждым тестом
+    }
+
+    public function checkHomePage(FunctionalTester $I): void
+    {
+        $I->amOnPage('/');
+        $I->see('Welcome');
+        $I->seeResponseCodeIs(200);
+    }
+
+    public function checkAboutPage(FunctionalTester $I): void
+    {
+        $I->amOnRoute('site/about');
+        $I->see('About');
+        $I->seeResponseCodeIs(200);
+    }
+
+    public function checkLoginForm(FunctionalTester $I): void
+    {
+        $I->amOnRoute('site/login');
+        $I->see('Login', 'h1');
+        $I->seeElement('input', ['name' => 'LoginForm[username]']);
+        $I->seeElement('input', ['name' => 'LoginForm[password]']);
+    }
+
+    public function checkLoginWithEmptyCredentials(FunctionalTester $I): void
+    {
+        $I->amOnRoute('site/login');
+        $I->submitForm('#login-form', []);
+        $I->seeValidationError('Username cannot be blank.');
+        $I->seeValidationError('Password cannot be blank.');
+    }
+
+    public function checkLoginWithWrongCredentials(FunctionalTester $I): void
+    {
+        $I->amOnRoute('site/login');
+        $I->submitForm('#login-form', [
+            'LoginForm[username]' => 'wrong',
+            'LoginForm[password]' => 'wrong',
+        ]);
+        $I->seeValidationError('Incorrect username or password.');
+    }
+}
+```
+
+### Тестирование форм
+```php
+<?php
+// tests/functional/ContactCest.php
+namespace tests\functional;
+
+use tests\FunctionalTester;
+
+class ContactCest
+{
+    public function _before(FunctionalTester $I): void
+    {
+        $I->amOnRoute('site/contact');
+    }
+
+    public function checkContactFormSubmit(FunctionalTester $I): void
+    {
+        $I->submitForm('#contact-form', [
+            'ContactForm[name]' => 'John Doe',
+            'ContactForm[email]' => 'john@example.com',
+            'ContactForm[subject]' => 'Test Subject',
+            'ContactForm[body]' => 'Test message body',
+            'ContactForm[verifyCode]' => 'testme',
+        ]);
+
+        $I->seeEmailIsSent();
+        $I->see('Thank you for contacting us');
+    }
+
+    public function checkContactFormValidation(FunctionalTester $I): void
+    {
+        $I->submitForm('#contact-form', [
+            'ContactForm[name]' => '',
+            'ContactForm[email]' => 'invalid-email',
+            'ContactForm[subject]' => '',
+            'ContactForm[body]' => '',
+        ]);
+
+        $I->seeValidationError('Name cannot be blank.');
+        $I->seeValidationError('Email is not a valid email address.');
+        $I->seeValidationError('Subject cannot be blank.');
+        $I->seeValidationError('Body cannot be blank.');
+    }
+}
+```
+
+### Тестирование с аутентификацией
+```php
+<?php
+// tests/functional/UserCest.php
+namespace tests\functional;
+
+use app\models\User;
+use tests\FunctionalTester;
+use tests\fixtures\UserFixture;
+
+class UserCest
+{
+    public function _fixtures(): array
+    {
+        return [
+            'users' => UserFixture::class,
+        ];
+    }
+
+    public function checkProfilePageRequiresAuth(FunctionalTester $I): void
+    {
+        $I->amOnRoute('user/profile');
+        $I->seeCurrentUrlEquals('/site/login');
+    }
+
+    public function checkProfilePageAsUser(FunctionalTester $I): void
+    {
+        // Логин пользователя
+        $I->amLoggedInAs(User::findOne(['username' => 'user']));
+
+        $I->amOnRoute('user/profile');
+        $I->see('Profile');
+        $I->see('user@example.com');
+    }
+
+    public function checkAdminPageAccessDenied(FunctionalTester $I): void
+    {
+        $I->amLoggedInAs(User::findOne(['username' => 'user']));
+
+        $I->amOnRoute('admin/dashboard');
+        $I->seeResponseCodeIs(403);
+    }
+
+    public function checkAdminPageAsAdmin(FunctionalTester $I): void
+    {
+        $I->amLoggedInAs(User::findOne(['username' => 'admin']));
+
+        $I->amOnRoute('admin/dashboard');
+        $I->seeResponseCodeIs(200);
+        $I->see('Dashboard');
+    }
+}
+```
+
+## API тесты
+
+### Тестирование REST API
+```php
+<?php
+// tests/api/UserCest.php
+namespace tests\api;
+
+use tests\ApiTester;
+use tests\fixtures\UserFixture;
+use Codeception\Util\HttpCode;
+
+class UserCest
+{
+    public function _fixtures(): array
+    {
+        return [
+            'users' => UserFixture::class,
+        ];
+    }
+
+    public function getUsers(ApiTester $I): void
+    {
+        $I->sendGet('/api/users');
+        $I->seeResponseCodeIs(HttpCode::OK);
+        $I->seeResponseIsJson();
+        $I->seeResponseMatchesJsonType([
+            'id' => 'integer',
+            'username' => 'string',
+            'email' => 'string:email',
+        ]);
+    }
+
+    public function getUser(ApiTester $I): void
+    {
+        $I->sendGet('/api/users/1');
+        $I->seeResponseCodeIs(HttpCode::OK);
+        $I->seeResponseContainsJson([
+            'id' => 1,
+            'username' => 'admin',
+        ]);
+    }
+
+    public function getUserNotFound(ApiTester $I): void
+    {
+        $I->sendGet('/api/users/999');
+        $I->seeResponseCodeIs(HttpCode::NOT_FOUND);
+    }
+
+    public function createUser(ApiTester $I): void
+    {
+        $I->haveHttpHeader('Content-Type', 'application/json');
+        $I->sendPost('/api/users', [
+            'username' => 'newuser',
+            'email' => 'newuser@example.com',
+            'password' => 'password123',
+        ]);
+
+        $I->seeResponseCodeIs(HttpCode::CREATED);
+        $I->seeResponseContainsJson([
+            'username' => 'newuser',
+            'email' => 'newuser@example.com',
+        ]);
+    }
+
+    public function createUserValidationError(ApiTester $I): void
+    {
+        $I->haveHttpHeader('Content-Type', 'application/json');
+        $I->sendPost('/api/users', [
+            'username' => '',
+            'email' => 'invalid-email',
+        ]);
+
+        $I->seeResponseCodeIs(HttpCode::UNPROCESSABLE_ENTITY);
+        $I->seeResponseContainsJson([
+            'field' => 'username',
+            'message' => 'Username cannot be blank.',
+        ]);
+    }
+
+    public function updateUser(ApiTester $I): void
+    {
+        $I->amBearerAuthenticated('valid-token');
+        $I->haveHttpHeader('Content-Type', 'application/json');
+        $I->sendPut('/api/users/1', [
+            'username' => 'updated',
+        ]);
+
+        $I->seeResponseCodeIs(HttpCode::OK);
+        $I->seeResponseContainsJson([
+            'username' => 'updated',
+        ]);
+    }
+
+    public function deleteUser(ApiTester $I): void
+    {
+        $I->amBearerAuthenticated('admin-token');
+        $I->sendDelete('/api/users/2');
+        $I->seeResponseCodeIs(HttpCode::NO_CONTENT);
+    }
+}
+```
+
+## Тестирование с моками
+
+### Мокирование компонентов
+```php
+<?php
+namespace tests\unit\services;
+
+use app\services\PaymentService;
+use app\components\PaymentGateway;
+use Codeception\Test\Unit;
+use PHPUnit\Framework\MockObject\MockObject;
+
+class PaymentServiceTest extends Unit
+{
+    private PaymentService $service;
+    private MockObject $gateway;
+
+    protected function _before(): void
+    {
+        // Создаём мок
+        $this->gateway = $this->createMock(PaymentGateway::class);
+
+        // Конфигурируем мок
+        $this->gateway->method('charge')
+            ->willReturn(['success' => true, 'transaction_id' => 'TX123']);
+
+        $this->service = new PaymentService($this->gateway);
+    }
+
+    public function testProcessPayment(): void
+    {
+        // Ожидаем один вызов charge
+        $this->gateway->expects($this->once())
+            ->method('charge')
+            ->with(
+                $this->equalTo(100.00),
+                $this->equalTo('USD')
+            );
+
+        $result = $this->service->processPayment(100.00, 'USD');
+
+        $this->assertTrue($result['success']);
+        $this->assertEquals('TX123', $result['transaction_id']);
+    }
+
+    public function testPaymentFailure(): void
+    {
+        $this->gateway->method('charge')
+            ->willThrowException(new \Exception('Payment failed'));
+
+        $this->expectException(\Exception::class);
+        $this->expectExceptionMessage('Payment failed');
+
+        $this->service->processPayment(100.00, 'USD');
+    }
+}
+```
+
+### Мокирование Yii компонентов
+```php
+<?php
+namespace tests\unit\services;
+
+use app\services\NotificationService;
+use Codeception\Test\Unit;
+
+class NotificationServiceTest extends Unit
+{
+    public function testSendEmail(): void
+    {
+        // Мокируем mailer
+        $mailer = $this->createMock(\yii\mail\MailerInterface::class);
+        $message = $this->createMock(\yii\mail\MessageInterface::class);
+
+        $message->method('setTo')->willReturnSelf();
+        $message->method('setSubject')->willReturnSelf();
+        $message->method('setTextBody')->willReturnSelf();
+        $message->expects($this->once())->method('send')->willReturn(true);
+
+        $mailer->method('compose')->willReturn($message);
+
+        // Подменяем компонент
+        \Yii::$app->set('mailer', $mailer);
+
+        $service = new NotificationService();
+        $result = $service->sendEmail('test@example.com', 'Subject', 'Body');
+
+        $this->assertTrue($result);
+    }
+
+    protected function _after(): void
+    {
+        // Восстанавливаем оригинальный компонент
+        \Yii::$app->set('mailer', [
+            'class' => \yii\symfonymailer\Mailer::class,
+            'useFileTransport' => true,
+        ]);
+    }
+}
+```
+
+## Acceptance тесты
+
+### Selenium/WebDriver тесты
+```php
+<?php
+// tests/acceptance/LoginCest.php
+namespace tests\acceptance;
+
+use tests\AcceptanceTester;
+
+class LoginCest
+{
+    public function _before(AcceptanceTester $I): void
+    {
+        $I->amOnPage('/');
+    }
+
+    public function testLoginPage(AcceptanceTester $I): void
+    {
+        $I->click('Login');
+        $I->see('Login', 'h1');
+    }
+
+    public function testSuccessfulLogin(AcceptanceTester $I): void
+    {
+        $I->amOnPage('/site/login');
+        $I->fillField('Username', 'admin');
+        $I->fillField('Password', 'admin123');
+        $I->click('Login');
+
+        $I->waitForText('Welcome, admin');
+        $I->see('Logout');
+    }
+
+    public function testAjaxValidation(AcceptanceTester $I): void
+    {
+        $I->amOnPage('/site/login');
+        $I->fillField('Username', 'admin');
+        $I->click('body'); // Trigger blur
+
+        $I->waitForElement('.field-loginform-password.has-error');
+        $I->see('Password cannot be blank.');
+    }
+}
+```
+
+## Команды тестирования
+
+```bash
+# Все тесты
+vendor/bin/codecept run
+
+# Конкретный suite
+vendor/bin/codecept run unit
+vendor/bin/codecept run functional
+vendor/bin/codecept run api
+
+# Конкретный тест
+vendor/bin/codecept run unit/models/UserTest
+vendor/bin/codecept run unit/models/UserTest:testValidation
+
+# С покрытием кода
+vendor/bin/codecept run --coverage --coverage-html
+
+# Debug режим
+vendor/bin/codecept run unit -vvv
+
+# С отчётом
+vendor/bin/codecept run --html
+
+# Генерация fixtures
+vendor/bin/codecept generate:fixture User --namespace=tests\fixtures
+```
+
+## Рекомендации для Claude Code
+
+1. **Изоляция** - каждый тест должен быть независим
+2. **Fixtures** - используйте fixtures для тестовых данных
+3. **Моки** - мокируйте внешние зависимости
+4. **Покрытие** - стремитесь к 80%+ покрытию критического кода
+5. **CI/CD** - запускайте тесты в CI pipeline
+6. **Именование** - тесты должны описывать ожидаемое поведение
diff --git a/erp24/php_skills/17-yii2-security.md b/erp24/php_skills/17-yii2-security.md
new file mode 100644 (file)
index 0000000..e9c53eb
--- /dev/null
@@ -0,0 +1,655 @@
+# Yii2 Security - Безопасность
+
+## Аутентификация
+
+### Identity Interface
+```php
+<?php
+// models/User.php
+namespace app\models;
+
+use yii\db\ActiveRecord;
+use yii\web\IdentityInterface;
+
+class User extends ActiveRecord implements IdentityInterface
+{
+    public const STATUS_INACTIVE = 0;
+    public const STATUS_ACTIVE = 1;
+
+    public static function tableName(): string
+    {
+        return '{{%users}}';
+    }
+
+    // Поиск по ID
+    public static function findIdentity($id): ?static
+    {
+        return static::findOne(['id' => $id, 'status' => self::STATUS_ACTIVE]);
+    }
+
+    // Поиск по токену (для REST API)
+    public static function findIdentityByAccessToken($token, $type = null): ?static
+    {
+        return static::findOne(['access_token' => $token, 'status' => self::STATUS_ACTIVE]);
+    }
+
+    public function getId(): int
+    {
+        return $this->id;
+    }
+
+    public function getAuthKey(): string
+    {
+        return $this->auth_key;
+    }
+
+    public function validateAuthKey($authKey): bool
+    {
+        return $this->auth_key === $authKey;
+    }
+
+    // Дополнительные методы
+    public static function findByUsername(string $username): ?static
+    {
+        return static::findOne(['username' => $username, 'status' => self::STATUS_ACTIVE]);
+    }
+
+    public static function findByEmail(string $email): ?static
+    {
+        return static::findOne(['email' => $email, 'status' => self::STATUS_ACTIVE]);
+    }
+
+    public function validatePassword(string $password): bool
+    {
+        return \Yii::$app->security->validatePassword($password, $this->password_hash);
+    }
+
+    public function setPassword(string $password): void
+    {
+        $this->password_hash = \Yii::$app->security->generatePasswordHash($password);
+    }
+
+    public function generateAuthKey(): void
+    {
+        $this->auth_key = \Yii::$app->security->generateRandomString();
+    }
+
+    public function generateAccessToken(): void
+    {
+        $this->access_token = \Yii::$app->security->generateRandomString(32);
+    }
+
+    public function generatePasswordResetToken(): void
+    {
+        $this->password_reset_token = \Yii::$app->security->generateRandomString() . '_' . time();
+    }
+
+    public function removePasswordResetToken(): void
+    {
+        $this->password_reset_token = null;
+    }
+
+    public static function findByPasswordResetToken(string $token): ?static
+    {
+        if (!static::isPasswordResetTokenValid($token)) {
+            return null;
+        }
+
+        return static::findOne([
+            'password_reset_token' => $token,
+            'status' => self::STATUS_ACTIVE,
+        ]);
+    }
+
+    public static function isPasswordResetTokenValid(?string $token): bool
+    {
+        if ($token === null) {
+            return false;
+        }
+
+        $timestamp = (int) substr($token, strrpos($token, '_') + 1);
+        $expire = \Yii::$app->params['user.passwordResetTokenExpire'] ?? 3600;
+
+        return $timestamp + $expire >= time();
+    }
+}
+```
+
+### Конфигурация User компонента
+```php
+<?php
+// config/web.php
+return [
+    'components' => [
+        'user' => [
+            'identityClass' => \app\models\User::class,
+            'enableAutoLogin' => true,
+            'loginUrl' => ['site/login'],
+            'identityCookie' => [
+                'name' => '_identity',
+                'httpOnly' => true,
+                'secure' => !YII_DEBUG,
+                'sameSite' => \yii\web\Cookie::SAME_SITE_LAX,
+            ],
+            'authTimeout' => 3600, // 1 час
+            'absoluteAuthTimeout' => 86400, // 24 часа
+        ],
+    ],
+];
+```
+
+### Форма логина
+```php
+<?php
+// models/LoginForm.php
+namespace app\models;
+
+use yii\base\Model;
+
+class LoginForm extends Model
+{
+    public ?string $username = null;
+    public ?string $password = null;
+    public bool $rememberMe = false;
+
+    private ?User $_user = null;
+
+    public function rules(): array
+    {
+        return [
+            [['username', 'password'], 'required'],
+            ['rememberMe', 'boolean'],
+            ['password', 'validatePassword'],
+        ];
+    }
+
+    public function validatePassword(string $attribute): void
+    {
+        if (!$this->hasErrors()) {
+            $user = $this->getUser();
+
+            if ($user === null || !$user->validatePassword($this->password)) {
+                $this->addError($attribute, 'Неверный логин или пароль');
+            }
+        }
+    }
+
+    public function login(): bool
+    {
+        if (!$this->validate()) {
+            return false;
+        }
+
+        return \Yii::$app->user->login(
+            $this->getUser(),
+            $this->rememberMe ? 3600 * 24 * 30 : 0
+        );
+    }
+
+    protected function getUser(): ?User
+    {
+        if ($this->_user === null) {
+            $this->_user = User::findByUsername($this->username);
+        }
+
+        return $this->_user;
+    }
+}
+```
+
+## RBAC (Role-Based Access Control)
+
+### Конфигурация
+```php
+<?php
+// config/console.php и config/web.php
+return [
+    'components' => [
+        'authManager' => [
+            'class' => \yii\rbac\DbManager::class,
+            'cache' => 'cache',
+        ],
+    ],
+];
+```
+
+### Миграция для RBAC
+```bash
+php yii migrate --migrationPath=@yii/rbac/migrations
+```
+
+### Создание ролей и разрешений
+```php
+<?php
+// console/controllers/RbacController.php
+namespace console\controllers;
+
+use yii\console\Controller;
+
+class RbacController extends Controller
+{
+    public function actionInit(): void
+    {
+        $auth = \Yii::$app->authManager;
+        $auth->removeAll();
+
+        // Разрешения
+        $createPost = $auth->createPermission('createPost');
+        $createPost->description = 'Создание постов';
+        $auth->add($createPost);
+
+        $updatePost = $auth->createPermission('updatePost');
+        $updatePost->description = 'Редактирование постов';
+        $auth->add($updatePost);
+
+        $deletePost = $auth->createPermission('deletePost');
+        $deletePost->description = 'Удаление постов';
+        $auth->add($deletePost);
+
+        $manageUsers = $auth->createPermission('manageUsers');
+        $manageUsers->description = 'Управление пользователями';
+        $auth->add($manageUsers);
+
+        // Правило для проверки авторства
+        $authorRule = new \app\rbac\AuthorRule();
+        $auth->add($authorRule);
+
+        // Разрешение на редактирование своих постов
+        $updateOwnPost = $auth->createPermission('updateOwnPost');
+        $updateOwnPost->description = 'Редактирование своих постов';
+        $updateOwnPost->ruleName = $authorRule->name;
+        $auth->add($updateOwnPost);
+        $auth->addChild($updateOwnPost, $updatePost);
+
+        // Роли
+        $user = $auth->createRole('user');
+        $user->description = 'Пользователь';
+        $auth->add($user);
+        $auth->addChild($user, $createPost);
+        $auth->addChild($user, $updateOwnPost);
+
+        $moderator = $auth->createRole('moderator');
+        $moderator->description = 'Модератор';
+        $auth->add($moderator);
+        $auth->addChild($moderator, $user);
+        $auth->addChild($moderator, $updatePost);
+        $auth->addChild($moderator, $deletePost);
+
+        $admin = $auth->createRole('admin');
+        $admin->description = 'Администратор';
+        $auth->add($admin);
+        $auth->addChild($admin, $moderator);
+        $auth->addChild($admin, $manageUsers);
+
+        echo "RBAC инициализирован\n";
+    }
+}
+```
+
+### Правило (Rule)
+```php
+<?php
+// rbac/AuthorRule.php
+namespace app\rbac;
+
+use yii\rbac\Rule;
+use app\models\Post;
+
+class AuthorRule extends Rule
+{
+    public $name = 'isAuthor';
+
+    public function execute($user, $item, $params): bool
+    {
+        if (!isset($params['post'])) {
+            return false;
+        }
+
+        $post = $params['post'];
+
+        if ($post instanceof Post) {
+            return $post->user_id === $user;
+        }
+
+        return Post::find()
+            ->where(['id' => $post, 'user_id' => $user])
+            ->exists();
+    }
+}
+```
+
+### Использование RBAC
+```php
+<?php
+// В контроллере
+class PostController extends Controller
+{
+    public function behaviors(): array
+    {
+        return [
+            'access' => [
+                'class' => AccessControl::class,
+                'rules' => [
+                    [
+                        'actions' => ['index', 'view'],
+                        'allow' => true,
+                    ],
+                    [
+                        'actions' => ['create'],
+                        'allow' => true,
+                        'roles' => ['createPost'],
+                    ],
+                    [
+                        'actions' => ['update'],
+                        'allow' => true,
+                        'roles' => ['updatePost'],
+                    ],
+                    [
+                        'actions' => ['delete'],
+                        'allow' => true,
+                        'roles' => ['deletePost'],
+                    ],
+                ],
+            ],
+        ];
+    }
+
+    public function actionUpdate(int $id): string|\yii\web\Response
+    {
+        $post = Post::findOne($id);
+
+        // Проверка с параметрами (для правила)
+        if (!\Yii::$app->user->can('updatePost', ['post' => $post])) {
+            throw new ForbiddenHttpException('Нет доступа');
+        }
+
+        // ...
+    }
+}
+
+// Проверка разрешений
+if (\Yii::$app->user->can('createPost')) {
+    // Может создавать
+}
+
+// Назначение роли
+$auth = \Yii::$app->authManager;
+$userRole = $auth->getRole('user');
+$auth->assign($userRole, $userId);
+
+// Отзыв роли
+$auth->revoke($userRole, $userId);
+
+// Получение ролей пользователя
+$roles = $auth->getRolesByUser($userId);
+```
+
+## Защита от атак
+
+### CSRF Protection
+```php
+<?php
+// config/web.php
+return [
+    'components' => [
+        'request' => [
+            'enableCsrfValidation' => true,
+            'csrfParam' => '_csrf-frontend',
+            'csrfCookie' => [
+                'httpOnly' => true,
+                'secure' => !YII_DEBUG,
+            ],
+        ],
+    ],
+];
+
+// В форме (автоматически добавляется ActiveForm)
+<?= Html::csrfMetaTags() ?>
+<?= Html::hiddenInput(\Yii::$app->request->csrfParam, \Yii::$app->request->csrfToken) ?>
+
+// Отключение CSRF для отдельных actions
+public function beforeAction($action): bool
+{
+    if ($action->id === 'webhook') {
+        $this->enableCsrfValidation = false;
+    }
+    return parent::beforeAction($action);
+}
+```
+
+### XSS Protection
+```php
+<?php
+use yii\helpers\Html;
+use yii\helpers\HtmlPurifier;
+
+// Экранирование вывода
+echo Html::encode($userInput);
+
+// Очистка HTML
+echo HtmlPurifier::process($htmlContent);
+
+// Настройка HtmlPurifier
+echo HtmlPurifier::process($htmlContent, [
+    'HTML.Allowed' => 'p,b,i,u,a[href],ul,ol,li',
+    'AutoFormat.RemoveEmpty' => true,
+]);
+
+// Content Security Policy
+\Yii::$app->response->headers->set('Content-Security-Policy',
+    "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'"
+);
+```
+
+### SQL Injection Protection
+```php
+<?php
+// ПРАВИЛЬНО - параметризованные запросы
+$users = User::find()
+    ->where(['status' => $status])
+    ->andWhere(['like', 'name', $search])
+    ->all();
+
+$users = \Yii::$app->db->createCommand(
+    'SELECT * FROM users WHERE status = :status AND name LIKE :name',
+    [':status' => $status, ':name' => "%{$search}%"]
+)->queryAll();
+
+// НЕПРАВИЛЬНО - конкатенация
+$users = \Yii::$app->db->createCommand(
+    "SELECT * FROM users WHERE status = {$status}"  // SQL Injection!
+)->queryAll();
+```
+
+### Защита от Mass Assignment
+```php
+<?php
+// В модели - только разрешённые атрибуты
+public function rules(): array
+{
+    return [
+        [['name', 'email'], 'required'],
+        [['name', 'email', 'bio'], 'safe'],  // Можно массово присваивать
+        // role, status - нельзя массово присваивать
+    ];
+}
+
+// Или через сценарии
+public function scenarios(): array
+{
+    return [
+        self::SCENARIO_CREATE => ['name', 'email', 'password'],
+        self::SCENARIO_UPDATE => ['name', 'email', 'bio'],
+        self::SCENARIO_ADMIN => ['name', 'email', 'role', 'status'],
+    ];
+}
+
+// Явная загрузка
+$model->load($data);  // Использует scenarios/rules
+$model->load($data, '');  // Без формы, напрямую
+
+// Явное присваивание (безопасно)
+$model->role = 'admin';  // Работает всегда
+```
+
+## Шифрование и хеширование
+
+### Работа с паролями
+```php
+<?php
+$security = \Yii::$app->security;
+
+// Хеширование пароля
+$hash = $security->generatePasswordHash('mypassword');
+
+// Проверка пароля
+$isValid = $security->validatePassword('mypassword', $hash);
+
+// С настройкой cost
+$hash = $security->generatePasswordHash('mypassword', 13);
+```
+
+### Генерация токенов
+```php
+<?php
+$security = \Yii::$app->security;
+
+// Случайная строка
+$token = $security->generateRandomString(32);
+
+// Случайные байты
+$bytes = $security->generateRandomKey(32);
+
+// UUID
+$uuid = sprintf('%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
+    random_int(0, 0xffff), random_int(0, 0xffff),
+    random_int(0, 0xffff),
+    random_int(0, 0x0fff) | 0x4000,
+    random_int(0, 0x3fff) | 0x8000,
+    random_int(0, 0xffff), random_int(0, 0xffff), random_int(0, 0xffff)
+);
+```
+
+### Шифрование данных
+```php
+<?php
+$security = \Yii::$app->security;
+$secretKey = \Yii::$app->params['encryptionKey'];
+
+// Шифрование
+$encrypted = $security->encryptByKey($sensitiveData, $secretKey);
+$encryptedBase64 = base64_encode($encrypted);
+
+// Дешифрование
+$decrypted = $security->decryptByKey(base64_decode($encryptedBase64), $secretKey);
+
+// С паролем
+$encrypted = $security->encryptByPassword($data, $password);
+$decrypted = $security->decryptByPassword($encrypted, $password);
+```
+
+### HMAC
+```php
+<?php
+$security = \Yii::$app->security;
+
+// Генерация HMAC
+$data = json_encode(['user_id' => 123, 'expires' => time() + 3600]);
+$hash = $security->hashData($data, $secretKey);
+
+// Проверка и извлечение
+$originalData = $security->validateData($hash, $secretKey);
+if ($originalData === false) {
+    throw new \Exception('Data tampered');
+}
+```
+
+## Безопасные headers
+
+```php
+<?php
+// config/web.php
+return [
+    'components' => [
+        'response' => [
+            'on beforeSend' => function ($event) {
+                $response = $event->sender;
+                $headers = $response->headers;
+
+                // Security headers
+                $headers->set('X-Content-Type-Options', 'nosniff');
+                $headers->set('X-Frame-Options', 'SAMEORIGIN');
+                $headers->set('X-XSS-Protection', '1; mode=block');
+                $headers->set('Referrer-Policy', 'strict-origin-when-cross-origin');
+
+                // HSTS (только для production)
+                if (!YII_DEBUG) {
+                    $headers->set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
+                }
+
+                // CSP
+                $headers->set('Content-Security-Policy', implode('; ', [
+                    "default-src 'self'",
+                    "script-src 'self' 'unsafe-inline' cdn.example.com",
+                    "style-src 'self' 'unsafe-inline' fonts.googleapis.com",
+                    "font-src 'self' fonts.gstatic.com",
+                    "img-src 'self' data: https:",
+                    "connect-src 'self' api.example.com",
+                ]));
+            },
+        ],
+    ],
+];
+```
+
+## Rate Limiting
+
+```php
+<?php
+// behaviors/RateLimiter.php
+use yii\filters\RateLimiter;
+
+public function behaviors(): array
+{
+    return [
+        'rateLimiter' => [
+            'class' => RateLimiter::class,
+            'enableRateLimitHeaders' => true,
+            'errorMessage' => 'Слишком много запросов',
+        ],
+    ];
+}
+
+// Модель должна реализовать RateLimitInterface
+class User extends ActiveRecord implements \yii\filters\RateLimitInterface
+{
+    public function getRateLimit($request, $action): array
+    {
+        // 100 запросов за 60 секунд
+        return [100, 60];
+    }
+
+    public function loadAllowance($request, $action): array
+    {
+        return [$this->allowance, $this->allowance_updated_at];
+    }
+
+    public function saveAllowance($request, $action, $allowance, $timestamp): void
+    {
+        $this->updateAttributes([
+            'allowance' => $allowance,
+            'allowance_updated_at' => $timestamp,
+        ]);
+    }
+}
+```
+
+## Рекомендации для Claude Code
+
+1. **Валидация** - всегда валидируйте входные данные
+2. **Экранирование** - используйте Html::encode() для вывода
+3. **Параметризация** - никогда не конкатенируйте SQL
+4. **CSRF** - включайте для всех форм
+5. **RBAC** - используйте для контроля доступа
+6. **HTTPS** - включайте в production
+7. **Секреты** - храните в переменных окружения
diff --git a/erp24/php_skills/18-yii2-performance.md b/erp24/php_skills/18-yii2-performance.md
new file mode 100644 (file)
index 0000000..2d6e461
--- /dev/null
@@ -0,0 +1,584 @@
+# Yii2 Performance - Производительность
+
+## Кэширование
+
+### Конфигурация кэша
+```php
+<?php
+// config/web.php
+return [
+    'components' => [
+        // File cache (для разработки)
+        'cache' => [
+            'class' => \yii\caching\FileCache::class,
+            'cachePath' => '@runtime/cache',
+        ],
+
+        // Redis cache (для production)
+        'cache' => [
+            'class' => \yii\redis\Cache::class,
+            'redis' => [
+                'hostname' => 'localhost',
+                'port' => 6379,
+                'database' => 0,
+            ],
+            'defaultDuration' => 3600,
+        ],
+
+        // Memcached
+        'cache' => [
+            'class' => \yii\caching\MemCache::class,
+            'servers' => [
+                ['host' => 'localhost', 'port' => 11211, 'weight' => 100],
+            ],
+        ],
+
+        // APCu
+        'cache' => [
+            'class' => \yii\caching\ApcCache::class,
+        ],
+    ],
+];
+```
+
+### Работа с кэшем
+```php
+<?php
+$cache = \Yii::$app->cache;
+
+// Базовые операции
+$cache->set('key', $value, 3600);  // 1 час
+$value = $cache->get('key');
+$cache->delete('key');
+$cache->flush();  // Очистить весь кэш
+
+// Проверка существования
+if ($cache->exists('key')) {
+    // ...
+}
+
+// getOrSet - получить или вычислить
+$users = $cache->getOrSet('all-users', function () {
+    return User::find()->all();
+}, 3600);
+
+// С dependency
+$users = $cache->getOrSet('users-count', function () {
+    return User::find()->count();
+}, 3600, new \yii\caching\DbDependency([
+    'sql' => 'SELECT MAX(updated_at) FROM users',
+]));
+
+// Множественные операции
+$values = $cache->multiGet(['key1', 'key2', 'key3']);
+$cache->multiSet([
+    'key1' => $value1,
+    'key2' => $value2,
+], 3600);
+
+// Теги (TagDependency)
+$dependency = new \yii\caching\TagDependency(['tags' => ['users']]);
+$cache->set('user-1', $user, 3600, $dependency);
+$cache->set('user-2', $user2, 3600, $dependency);
+
+// Инвалидация по тегу
+\yii\caching\TagDependency::invalidate($cache, ['users']);
+```
+
+### Кэширование запросов
+```php
+<?php
+// Кэширование отдельного запроса
+$users = User::find()
+    ->where(['status' => User::STATUS_ACTIVE])
+    ->cache(3600)  // 1 час
+    ->all();
+
+// С dependency
+$posts = Post::find()
+    ->where(['status' => Post::STATUS_PUBLISHED])
+    ->cache(3600, new \yii\caching\DbDependency([
+        'sql' => 'SELECT MAX(updated_at) FROM posts WHERE status = 1',
+    ]))
+    ->all();
+
+// Глобальное кэширование запросов
+\Yii::$app->db->cache(function ($db) {
+    $users = User::find()->all();
+    $posts = Post::find()->all();
+    return [$users, $posts];
+}, 3600);
+```
+
+### Schema Cache
+```php
+<?php
+// config/db.php
+return [
+    'class' => \yii\db\Connection::class,
+    'dsn' => 'pgsql:host=localhost;dbname=myapp',
+    'enableSchemaCache' => true,
+    'schemaCacheDuration' => 86400,  // 24 часа
+    'schemaCache' => 'cache',
+];
+
+// Очистка schema cache
+\Yii::$app->db->schema->refresh();
+\Yii::$app->cache->delete(\Yii::$app->db->schema->getCacheKey('tableName'));
+```
+
+### Fragment Caching
+```php
+<?php
+// views/site/index.php
+<?php if ($this->beginCache('sidebar', [
+    'duration' => 3600,
+    'dependency' => [
+        'class' => \yii\caching\DbDependency::class,
+        'sql' => 'SELECT MAX(updated_at) FROM posts',
+    ],
+    'variations' => [
+        \Yii::$app->language,
+        \Yii::$app->user->id ?? 0,
+    ],
+])): ?>
+
+    <?= $this->render('_sidebar', ['posts' => $posts]) ?>
+
+<?php $this->endCache(); endif; ?>
+```
+
+### Page Caching
+```php
+<?php
+// controllers/SiteController.php
+public function behaviors(): array
+{
+    return [
+        'pageCache' => [
+            'class' => \yii\filters\PageCache::class,
+            'only' => ['index', 'about'],
+            'duration' => 3600,
+            'variations' => [
+                \Yii::$app->language,
+            ],
+            'dependency' => [
+                'class' => \yii\caching\DbDependency::class,
+                'sql' => 'SELECT MAX(updated_at) FROM pages',
+            ],
+        ],
+    ];
+}
+```
+
+### HTTP Caching
+```php
+<?php
+public function behaviors(): array
+{
+    return [
+        'httpCache' => [
+            'class' => \yii\filters\HttpCache::class,
+            'only' => ['view'],
+            'lastModified' => function ($action, $params) {
+                $post = Post::findOne(\Yii::$app->request->get('id'));
+                return $post ? $post->updated_at : null;
+            },
+            'etagSeed' => function ($action, $params) {
+                $post = Post::findOne(\Yii::$app->request->get('id'));
+                return $post ? serialize([
+                    $post->id,
+                    $post->updated_at,
+                    \Yii::$app->language,
+                ]) : null;
+            },
+            'cacheControlHeader' => 'public, max-age=3600',
+        ],
+    ];
+}
+```
+
+## Оптимизация запросов
+
+### Eager Loading
+```php
+<?php
+// НЕПРАВИЛЬНО - N+1 запросов
+$posts = Post::find()->all();
+foreach ($posts as $post) {
+    echo $post->user->name;  // Запрос для каждого поста
+    foreach ($post->comments as $comment) {  // Ещё N запросов
+        echo $comment->text;
+    }
+}
+
+// ПРАВИЛЬНО - 3 запроса
+$posts = Post::find()
+    ->with(['user', 'comments'])
+    ->all();
+
+// Вложенные связи
+$posts = Post::find()
+    ->with(['user.profile', 'comments.user'])
+    ->all();
+
+// С условиями
+$posts = Post::find()
+    ->with([
+        'comments' => function ($query) {
+            $query->andWhere(['status' => Comment::STATUS_APPROVED])
+                  ->orderBy(['created_at' => SORT_DESC])
+                  ->limit(5);
+        },
+    ])
+    ->all();
+
+// joinWith для фильтрации
+$posts = Post::find()
+    ->joinWith('user')
+    ->where(['users.status' => User::STATUS_ACTIVE])
+    ->all();
+```
+
+### Индексация
+```php
+<?php
+// Миграция - создание индексов
+$this->createIndex('idx-posts-user_id', '{{%posts}}', 'user_id');
+$this->createIndex('idx-posts-status', '{{%posts}}', 'status');
+$this->createIndex('idx-posts-created_at', '{{%posts}}', 'created_at');
+
+// Составной индекс
+$this->createIndex('idx-posts-user_status', '{{%posts}}', ['user_id', 'status']);
+
+// Частичный индекс (PostgreSQL)
+$this->execute('CREATE INDEX idx_active_posts ON posts (created_at) WHERE status = 1');
+
+// Покрывающий индекс
+$this->execute('CREATE INDEX idx_posts_covering ON posts (user_id) INCLUDE (title, status)');
+
+// Анализ запроса
+$sql = Post::find()
+    ->where(['status' => 1])
+    ->orderBy(['created_at' => SORT_DESC])
+    ->createCommand()
+    ->rawSql;
+
+// EXPLAIN
+$explain = \Yii::$app->db->createCommand("EXPLAIN ANALYZE {$sql}")->queryAll();
+```
+
+### Batch обработка
+```php
+<?php
+// Для больших наборов данных
+foreach (User::find()->batch(1000) as $users) {
+    foreach ($users as $user) {
+        // Обработка партиями по 1000
+    }
+}
+
+// each() для экономии памяти
+foreach (User::find()->each(100) as $user) {
+    // По одной записи, но эффективно
+}
+
+// С eager loading
+foreach (User::find()->with('profile')->each(100) as $user) {
+    echo $user->profile->bio;
+}
+```
+
+### Асинхронные запросы
+```php
+<?php
+// Параллельные запросы через cURL multi
+$urls = ['http://api1.com', 'http://api2.com', 'http://api3.com'];
+
+$mh = curl_multi_init();
+$handles = [];
+
+foreach ($urls as $i => $url) {
+    $handles[$i] = curl_init($url);
+    curl_setopt($handles[$i], CURLOPT_RETURNTRANSFER, true);
+    curl_multi_add_handle($mh, $handles[$i]);
+}
+
+do {
+    curl_multi_exec($mh, $running);
+    curl_multi_select($mh);
+} while ($running > 0);
+
+$results = [];
+foreach ($handles as $i => $handle) {
+    $results[$i] = curl_multi_getcontent($handle);
+    curl_multi_remove_handle($mh, $handle);
+}
+
+curl_multi_close($mh);
+```
+
+## Профилирование
+
+### Debug Toolbar
+```php
+<?php
+// config/web.php
+if (YII_ENV_DEV) {
+    $config['bootstrap'][] = 'debug';
+    $config['modules']['debug'] = [
+        'class' => \yii\debug\Module::class,
+        'allowedIPs' => ['127.0.0.1', '::1'],
+        'panels' => [
+            'db' => ['class' => \yii\debug\panels\DbPanel::class],
+            'profiling' => ['class' => \yii\debug\panels\ProfilingPanel::class],
+        ],
+    ];
+}
+```
+
+### Профилирование кода
+```php
+<?php
+// Начало профилирования
+\Yii::beginProfile('heavy-operation');
+
+// Тяжёлая операция
+$result = $this->heavyOperation();
+
+// Конец профилирования
+\Yii::endProfile('heavy-operation');
+
+// С категорией
+\Yii::beginProfile('db-query', 'database');
+$users = User::find()->all();
+\Yii::endProfile('db-query', 'database');
+
+// Логирование времени
+$start = microtime(true);
+$result = $this->process();
+$time = microtime(true) - $start;
+\Yii::info("Processing took {$time} seconds", 'performance');
+```
+
+### Логирование запросов
+```php
+<?php
+// config/web.php
+return [
+    'components' => [
+        'log' => [
+            'targets' => [
+                [
+                    'class' => \yii\log\FileTarget::class,
+                    'categories' => ['yii\db\Command::*'],
+                    'logFile' => '@runtime/logs/sql.log',
+                    'logVars' => [],
+                ],
+            ],
+        ],
+    ],
+];
+```
+
+## Asset оптимизация
+
+### Asset Bundle compression
+```php
+<?php
+// config/console.php
+return [
+    'controllerMap' => [
+        'asset' => [
+            'class' => \yii\console\controllers\AssetController::class,
+            'bundles' => [
+                \app\assets\AppAsset::class,
+            ],
+            'targets' => [
+                'all' => [
+                    'class' => \yii\web\AssetBundle::class,
+                    'basePath' => '@webroot/assets',
+                    'baseUrl' => '@web/assets',
+                    'js' => 'js/all-{hash}.js',
+                    'css' => 'css/all-{hash}.css',
+                ],
+            ],
+            'jsCompressor' => 'java -jar compiler.jar --js {from} --js_output_file {to}',
+            'cssCompressor' => 'java -jar yuicompressor.jar --type css {from} -o {to}',
+        ],
+    ],
+];
+
+// Команда
+// php yii asset assets.php config/assets-prod.php
+```
+
+### Lazy loading assets
+```php
+<?php
+// assets/AppAsset.php
+class AppAsset extends AssetBundle
+{
+    public $basePath = '@webroot';
+    public $baseUrl = '@web';
+
+    public $css = [
+        'css/site.css',
+    ];
+
+    public $js = [
+        'js/app.js',
+    ];
+
+    // Асинхронная загрузка
+    public $jsOptions = [
+        'defer' => true,
+    ];
+
+    public $cssOptions = [
+        'media' => 'print',
+        'onload' => "this.media='all'",
+    ];
+}
+```
+
+## Фоновые задачи
+
+### Очереди (yii2-queue)
+```php
+<?php
+// config/console.php
+return [
+    'components' => [
+        'queue' => [
+            'class' => \yii\queue\redis\Queue::class,
+            'redis' => 'redis',
+            'channel' => 'queue',
+        ],
+    ],
+];
+
+// Задача
+class SendEmailJob extends \yii\base\BaseObject implements \yii\queue\JobInterface
+{
+    public string $to;
+    public string $subject;
+    public string $body;
+
+    public function execute($queue): void
+    {
+        \Yii::$app->mailer->compose()
+            ->setTo($this->to)
+            ->setSubject($this->subject)
+            ->setTextBody($this->body)
+            ->send();
+    }
+}
+
+// Добавление в очередь
+\Yii::$app->queue->push(new SendEmailJob([
+    'to' => 'user@example.com',
+    'subject' => 'Hello',
+    'body' => 'World',
+]));
+
+// С задержкой
+\Yii::$app->queue->delay(3600)->push($job);  // Через 1 час
+
+// Запуск воркера
+// php yii queue/listen
+```
+
+## Масштабирование
+
+### Database Sharding
+```php
+<?php
+// config/web.php
+return [
+    'components' => [
+        'db' => [
+            'class' => \yii\db\Connection::class,
+            'dsn' => 'pgsql:host=master;dbname=app',
+            'username' => 'root',
+            'password' => '',
+            'slaveConfig' => [
+                'username' => 'root',
+                'password' => '',
+            ],
+            'slaves' => [
+                ['dsn' => 'pgsql:host=slave1;dbname=app'],
+                ['dsn' => 'pgsql:host=slave2;dbname=app'],
+            ],
+        ],
+    ],
+];
+
+// Master для записи, slaves для чтения автоматически
+$user = User::findOne(1);  // Читает из slave
+$user->name = 'New Name';
+$user->save();  // Пишет в master
+```
+
+### Session Storage
+```php
+<?php
+// Redis sessions
+return [
+    'components' => [
+        'session' => [
+            'class' => \yii\redis\Session::class,
+            'redis' => 'redis',
+            'keyPrefix' => 'session:',
+        ],
+    ],
+];
+
+// Database sessions
+return [
+    'components' => [
+        'session' => [
+            'class' => \yii\web\DbSession::class,
+            'sessionTable' => '{{%session}}',
+        ],
+    ],
+];
+```
+
+## Мониторинг
+
+### Пользовательские метрики
+```php
+<?php
+// components/MetricsCollector.php
+class MetricsCollector
+{
+    public function recordRequestTime(float $time): void
+    {
+        \Yii::$app->redis->lpush('metrics:request_times', $time);
+        \Yii::$app->redis->ltrim('metrics:request_times', 0, 9999);
+    }
+
+    public function recordDbQuery(string $sql, float $time): void
+    {
+        if ($time > 0.1) {  // Медленные запросы > 100ms
+            \Yii::warning("Slow query ({$time}s): {$sql}", 'performance');
+        }
+    }
+
+    public function getAverageResponseTime(): float
+    {
+        $times = \Yii::$app->redis->lrange('metrics:request_times', 0, -1);
+        return $times ? array_sum($times) / count($times) : 0;
+    }
+}
+```
+
+## Рекомендации для Claude Code
+
+1. **Кэширование** - кэшируйте дорогие операции
+2. **Eager loading** - используйте with() для связей
+3. **Индексы** - создавайте для часто используемых полей
+4. **Batch** - обрабатывайте большие данные партиями
+5. **Очереди** - выносите тяжёлые операции в фон
+6. **Профилирование** - используйте debug toolbar для анализа
diff --git a/erp24/php_skills/19-yii2-api.md b/erp24/php_skills/19-yii2-api.md
new file mode 100644 (file)
index 0000000..b65540c
--- /dev/null
@@ -0,0 +1,1069 @@
+# Yii2 REST API - Построение API
+
+## Базовая конфигурация
+
+### ActiveController
+```php
+<?php
+// controllers/api/UserController.php
+namespace app\controllers\api;
+
+use yii\rest\ActiveController;
+use yii\filters\auth\HttpBearerAuth;
+use yii\filters\RateLimiter;
+
+class UserController extends ActiveController
+{
+    public $modelClass = \app\models\User::class;
+
+    public function behaviors(): array
+    {
+        $behaviors = parent::behaviors();
+
+        // Формат ответа
+        $behaviors['contentNegotiator']['formats']['application/json'] = \yii\web\Response::FORMAT_JSON;
+
+        // Аутентификация
+        $behaviors['authenticator'] = [
+            'class' => HttpBearerAuth::class,
+            'except' => ['index', 'view'],  // Публичные actions
+        ];
+
+        // Rate limiting
+        $behaviors['rateLimiter'] = [
+            'class' => RateLimiter::class,
+            'enableRateLimitHeaders' => true,
+        ];
+
+        return $behaviors;
+    }
+
+    // Переопределение actions
+    public function actions(): array
+    {
+        $actions = parent::actions();
+
+        // Отключить delete
+        unset($actions['delete']);
+
+        // Кастомизация index
+        $actions['index']['prepareDataProvider'] = [$this, 'prepareDataProvider'];
+
+        return $actions;
+    }
+
+    public function prepareDataProvider(): \yii\data\ActiveDataProvider
+    {
+        return new \yii\data\ActiveDataProvider([
+            'query' => \app\models\User::find()
+                ->where(['status' => \app\models\User::STATUS_ACTIVE]),
+            'pagination' => [
+                'pageSize' => 20,
+            ],
+            'sort' => [
+                'defaultOrder' => ['created_at' => SORT_DESC],
+            ],
+        ]);
+    }
+}
+```
+
+### Конфигурация приложения
+```php
+<?php
+// config/web.php
+return [
+    'components' => [
+        'urlManager' => [
+            'enablePrettyUrl' => true,
+            'showScriptName' => false,
+            'rules' => [
+                [
+                    'class' => \yii\rest\UrlRule::class,
+                    'controller' => 'api/user',
+                    'pluralize' => true,
+                ],
+            ],
+        ],
+        'request' => [
+            'parsers' => [
+                'application/json' => \yii\web\JsonParser::class,
+            ],
+        ],
+        'response' => [
+            'formatters' => [
+                \yii\web\Response::FORMAT_JSON => [
+                    'class' => \yii\web\JsonResponseFormatter::class,
+                    'prettyPrint' => YII_DEBUG,
+                    'encodeOptions' => JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE,
+                ],
+            ],
+        ],
+    ],
+];
+```
+
+## Сериализация
+
+### Кастомный Serializer
+```php
+<?php
+// components/ApiSerializer.php
+namespace app\components;
+
+use yii\rest\Serializer;
+
+class ApiSerializer extends Serializer
+{
+    public $collectionEnvelope = 'data';
+    public $linksEnvelope = 'links';
+    public $metaEnvelope = 'meta';
+
+    protected function serializeDataProvider($dataProvider): array
+    {
+        $models = $this->serializeModels($dataProvider->getModels());
+
+        $result = [
+            $this->collectionEnvelope => $models,
+        ];
+
+        if ($this->metaEnvelope !== false) {
+            $result[$this->metaEnvelope] = [
+                'totalCount' => $dataProvider->getTotalCount(),
+                'pageCount' => $dataProvider->getPagination()->getPageCount(),
+                'currentPage' => $dataProvider->getPagination()->getPage() + 1,
+                'perPage' => $dataProvider->getPagination()->getPageSize(),
+            ];
+        }
+
+        if ($this->linksEnvelope !== false) {
+            $result[$this->linksEnvelope] = $this->serializeLinks($dataProvider);
+        }
+
+        return $result;
+    }
+
+    protected function serializeLinks($dataProvider): array
+    {
+        $pagination = $dataProvider->getPagination();
+
+        return [
+            'self' => $pagination->createUrl($pagination->getPage()),
+            'first' => $pagination->createUrl(0),
+            'last' => $pagination->createUrl($pagination->getPageCount() - 1),
+            'prev' => $pagination->getPage() > 0
+                ? $pagination->createUrl($pagination->getPage() - 1)
+                : null,
+            'next' => $pagination->getPage() < $pagination->getPageCount() - 1
+                ? $pagination->createUrl($pagination->getPage() + 1)
+                : null,
+        ];
+    }
+}
+
+// Использование в контроллере
+public $serializer = [
+    'class' => \app\components\ApiSerializer::class,
+    'collectionEnvelope' => 'items',
+];
+```
+
+### fields() и extraFields() в модели
+```php
+<?php
+// models/User.php
+class User extends ActiveRecord
+{
+    // Базовые поля (всегда возвращаются)
+    public function fields(): array
+    {
+        return [
+            'id',
+            'username',
+            'email',
+            'full_name' => function ($model) {
+                return $model->first_name . ' ' . $model->last_name;
+            },
+            'avatar_url' => function ($model) {
+                return $model->getAvatarUrl();
+            },
+            'created_at' => function ($model) {
+                return date('c', $model->created_at);
+            },
+        ];
+    }
+
+    // Дополнительные поля (запрашиваются через ?expand=)
+    public function extraFields(): array
+    {
+        return [
+            'profile',      // Связь
+            'posts',        // Связь
+            'postsCount' => function ($model) {
+                return $model->getPosts()->count();
+            },
+            'statistics' => function ($model) {
+                return [
+                    'posts' => $model->getPosts()->count(),
+                    'comments' => $model->getComments()->count(),
+                    'likes' => $model->getLikesReceived()->count(),
+                ];
+            },
+        ];
+    }
+}
+
+// Запрос: GET /api/users/1?expand=profile,postsCount
+```
+
+## Аутентификация
+
+### Bearer Token
+```php
+<?php
+// Модель с токеном
+class User extends ActiveRecord implements \yii\web\IdentityInterface
+{
+    public static function findIdentityByAccessToken($token, $type = null): ?self
+    {
+        // Проверка срока действия токена
+        $tokenData = \Yii::$app->security->validateData($token, \Yii::$app->params['tokenSecret']);
+
+        if ($tokenData === false) {
+            return null;
+        }
+
+        $data = json_decode($tokenData, true);
+
+        if ($data['expires'] < time()) {
+            return null;
+        }
+
+        return static::findOne(['id' => $data['user_id'], 'status' => self::STATUS_ACTIVE]);
+    }
+
+    public function generateAccessToken(int $duration = 3600): string
+    {
+        $data = json_encode([
+            'user_id' => $this->id,
+            'expires' => time() + $duration,
+        ]);
+
+        return \Yii::$app->security->hashData($data, \Yii::$app->params['tokenSecret']);
+    }
+}
+
+// controllers/api/AuthController.php
+class AuthController extends \yii\rest\Controller
+{
+    public function actionLogin(): array
+    {
+        $request = \Yii::$app->request;
+        $email = $request->post('email');
+        $password = $request->post('password');
+
+        $user = User::findByEmail($email);
+
+        if ($user === null || !$user->validatePassword($password)) {
+            throw new \yii\web\UnauthorizedHttpException('Invalid credentials');
+        }
+
+        return [
+            'access_token' => $user->generateAccessToken(),
+            'token_type' => 'Bearer',
+            'expires_in' => 3600,
+            'user' => $user,
+        ];
+    }
+
+    public function actionRefresh(): array
+    {
+        $user = \Yii::$app->user->identity;
+
+        return [
+            'access_token' => $user->generateAccessToken(),
+            'token_type' => 'Bearer',
+            'expires_in' => 3600,
+        ];
+    }
+}
+```
+
+### OAuth2 / JWT
+```php
+<?php
+// С использованием JWT (firebase/php-jwt)
+use Firebase\JWT\JWT;
+use Firebase\JWT\Key;
+
+class JwtAuth
+{
+    private string $secret;
+    private string $algorithm = 'HS256';
+
+    public function __construct(string $secret)
+    {
+        $this->secret = $secret;
+    }
+
+    public function generateToken(User $user): string
+    {
+        $payload = [
+            'iss' => \Yii::$app->request->hostInfo,
+            'aud' => \Yii::$app->request->hostInfo,
+            'iat' => time(),
+            'exp' => time() + 3600,
+            'sub' => $user->id,
+            'data' => [
+                'username' => $user->username,
+                'email' => $user->email,
+            ],
+        ];
+
+        return JWT::encode($payload, $this->secret, $this->algorithm);
+    }
+
+    public function validateToken(string $token): ?object
+    {
+        try {
+            return JWT::decode($token, new Key($this->secret, $this->algorithm));
+        } catch (\Exception $e) {
+            return null;
+        }
+    }
+}
+
+// Аутентификатор
+class JwtBearerAuth extends \yii\filters\auth\AuthMethod
+{
+    public function authenticate($user, $request, $response): ?\yii\web\IdentityInterface
+    {
+        $authHeader = $request->getHeaders()->get('Authorization');
+
+        if ($authHeader !== null && preg_match('/^Bearer\s+(.*?)$/', $authHeader, $matches)) {
+            $token = $matches[1];
+            $jwt = new JwtAuth(\Yii::$app->params['jwtSecret']);
+            $payload = $jwt->validateToken($token);
+
+            if ($payload !== null) {
+                $identity = User::findOne($payload->sub);
+                if ($identity !== null) {
+                    $user->login($identity);
+                    return $identity;
+                }
+            }
+        }
+
+        return null;
+    }
+}
+```
+
+### Rate Limiting
+```php
+<?php
+// Модель пользователя с RateLimitInterface
+class User extends ActiveRecord implements
+    \yii\web\IdentityInterface,
+    \yii\filters\RateLimitInterface
+{
+    public function getRateLimit($request, $action): array
+    {
+        // [количество запросов, период в секундах]
+        return match ($action->id) {
+            'create' => [10, 60],      // 10 запросов в минуту
+            'update' => [30, 60],      // 30 запросов в минуту
+            default => [100, 60],      // 100 запросов в минуту
+        };
+    }
+
+    public function loadAllowance($request, $action): array
+    {
+        $key = $this->getRateLimitKey($action);
+        $data = \Yii::$app->cache->get($key);
+
+        return $data ? $data : [$this->getRateLimit($request, $action)[0], time()];
+    }
+
+    public function saveAllowance($request, $action, $allowance, $timestamp): void
+    {
+        $key = $this->getRateLimitKey($action);
+        \Yii::$app->cache->set($key, [$allowance, $timestamp], 3600);
+    }
+
+    private function getRateLimitKey($action): string
+    {
+        return "rate_limit:{$this->id}:{$action->uniqueId}";
+    }
+}
+```
+
+## Кастомные Actions
+
+### Дополнительные endpoints
+```php
+<?php
+// controllers/api/UserController.php
+class UserController extends ActiveController
+{
+    public $modelClass = \app\models\User::class;
+
+    public function actions(): array
+    {
+        $actions = parent::actions();
+
+        // Кастомизация create
+        $actions['create']['scenario'] = \app\models\User::SCENARIO_API_CREATE;
+
+        return $actions;
+    }
+
+    // POST /api/users/{id}/follow
+    public function actionFollow(int $id): array
+    {
+        $user = $this->findModel($id);
+        $currentUser = \Yii::$app->user->identity;
+
+        if ($currentUser->isFollowing($user)) {
+            throw new \yii\web\BadRequestHttpException('Already following');
+        }
+
+        $currentUser->follow($user);
+
+        return [
+            'success' => true,
+            'message' => 'Now following ' . $user->username,
+        ];
+    }
+
+    // DELETE /api/users/{id}/follow
+    public function actionUnfollow(int $id): array
+    {
+        $user = $this->findModel($id);
+        $currentUser = \Yii::$app->user->identity;
+
+        if (!$currentUser->isFollowing($user)) {
+            throw new \yii\web\BadRequestHttpException('Not following');
+        }
+
+        $currentUser->unfollow($user);
+
+        return [
+            'success' => true,
+            'message' => 'Unfollowed ' . $user->username,
+        ];
+    }
+
+    // GET /api/users/{id}/posts
+    public function actionPosts(int $id): \yii\data\ActiveDataProvider
+    {
+        $user = $this->findModel($id);
+
+        return new \yii\data\ActiveDataProvider([
+            'query' => $user->getPosts()->andWhere(['status' => Post::STATUS_PUBLISHED]),
+        ]);
+    }
+
+    // POST /api/users/bulk-create
+    public function actionBulkCreate(): array
+    {
+        $request = \Yii::$app->request;
+        $users = $request->post('users', []);
+        $created = [];
+        $errors = [];
+
+        $transaction = \Yii::$app->db->beginTransaction();
+
+        try {
+            foreach ($users as $index => $userData) {
+                $user = new User();
+                $user->load($userData, '');
+
+                if ($user->save()) {
+                    $created[] = $user;
+                } else {
+                    $errors[$index] = $user->errors;
+                }
+            }
+
+            if (!empty($errors)) {
+                $transaction->rollBack();
+                throw new \yii\web\UnprocessableEntityHttpException(json_encode($errors));
+            }
+
+            $transaction->commit();
+
+            return [
+                'success' => true,
+                'created' => count($created),
+                'users' => $created,
+            ];
+        } catch (\Exception $e) {
+            $transaction->rollBack();
+            throw $e;
+        }
+    }
+
+    protected function findModel(int $id): User
+    {
+        $model = User::findOne($id);
+
+        if ($model === null) {
+            throw new \yii\web\NotFoundHttpException('User not found');
+        }
+
+        return $model;
+    }
+}
+
+// Маршруты для кастомных actions
+'rules' => [
+    [
+        'class' => \yii\rest\UrlRule::class,
+        'controller' => 'api/user',
+        'extraPatterns' => [
+            'POST {id}/follow' => 'follow',
+            'DELETE {id}/follow' => 'unfollow',
+            'GET {id}/posts' => 'posts',
+            'POST bulk-create' => 'bulk-create',
+        ],
+    ],
+],
+```
+
+## Версионирование API
+
+### Структура модулей
+```php
+<?php
+// modules/api/v1/Module.php
+namespace app\modules\api\v1;
+
+class Module extends \yii\base\Module
+{
+    public $controllerNamespace = 'app\modules\api\v1\controllers';
+}
+
+// modules/api/v2/Module.php
+namespace app\modules\api\v2;
+
+class Module extends \yii\base\Module
+{
+    public $controllerNamespace = 'app\modules\api\v2\controllers';
+}
+
+// config/web.php
+return [
+    'modules' => [
+        'v1' => [
+            'class' => \app\modules\api\v1\Module::class,
+        ],
+        'v2' => [
+            'class' => \app\modules\api\v2\Module::class,
+        ],
+    ],
+    'components' => [
+        'urlManager' => [
+            'rules' => [
+                [
+                    'class' => \yii\rest\UrlRule::class,
+                    'controller' => ['v1/user' => 'v1/user'],
+                    'prefix' => 'api',
+                ],
+                [
+                    'class' => \yii\rest\UrlRule::class,
+                    'controller' => ['v2/user' => 'v2/user'],
+                    'prefix' => 'api',
+                ],
+            ],
+        ],
+    ],
+];
+```
+
+### Версионирование через заголовки
+```php
+<?php
+// components/ApiVersionNegotiator.php
+namespace app\components;
+
+use yii\base\ActionFilter;
+
+class ApiVersionNegotiator extends ActionFilter
+{
+    public string $defaultVersion = 'v1';
+    public array $supportedVersions = ['v1', 'v2'];
+
+    public function beforeAction($action): bool
+    {
+        $version = $this->detectVersion();
+
+        if (!in_array($version, $this->supportedVersions)) {
+            throw new \yii\web\BadRequestHttpException(
+                "API version '$version' is not supported"
+            );
+        }
+
+        \Yii::$app->params['apiVersion'] = $version;
+
+        return parent::beforeAction($action);
+    }
+
+    protected function detectVersion(): string
+    {
+        $request = \Yii::$app->request;
+
+        // Accept header: application/vnd.api.v2+json
+        $accept = $request->getHeaders()->get('Accept');
+        if (preg_match('/application\/vnd\.api\.(v\d+)\+json/', $accept, $matches)) {
+            return $matches[1];
+        }
+
+        // Custom header: X-API-Version: v2
+        $customHeader = $request->getHeaders()->get('X-API-Version');
+        if ($customHeader && in_array($customHeader, $this->supportedVersions)) {
+            return $customHeader;
+        }
+
+        return $this->defaultVersion;
+    }
+}
+```
+
+## Обработка ошибок
+
+### Кастомный ErrorHandler
+```php
+<?php
+// components/ApiErrorHandler.php
+namespace app\components;
+
+use yii\web\ErrorHandler;
+use yii\web\Response;
+
+class ApiErrorHandler extends ErrorHandler
+{
+    protected function renderException($exception): void
+    {
+        $response = \Yii::$app->response;
+        $response->format = Response::FORMAT_JSON;
+
+        if ($exception instanceof \yii\web\HttpException) {
+            $response->setStatusCode($exception->statusCode);
+            $response->data = [
+                'error' => [
+                    'code' => $exception->statusCode,
+                    'message' => $exception->getMessage(),
+                    'type' => $this->getErrorType($exception->statusCode),
+                ],
+            ];
+        } elseif ($exception instanceof \yii\base\UserException) {
+            $response->setStatusCode(400);
+            $response->data = [
+                'error' => [
+                    'code' => 400,
+                    'message' => $exception->getMessage(),
+                    'type' => 'bad_request',
+                ],
+            ];
+        } else {
+            $response->setStatusCode(500);
+            $response->data = [
+                'error' => [
+                    'code' => 500,
+                    'message' => YII_DEBUG ? $exception->getMessage() : 'Internal server error',
+                    'type' => 'internal_error',
+                ],
+            ];
+
+            if (YII_DEBUG) {
+                $response->data['error']['trace'] = explode("\n", $exception->getTraceAsString());
+            }
+        }
+
+        $response->send();
+    }
+
+    private function getErrorType(int $code): string
+    {
+        return match ($code) {
+            400 => 'bad_request',
+            401 => 'unauthorized',
+            403 => 'forbidden',
+            404 => 'not_found',
+            405 => 'method_not_allowed',
+            422 => 'validation_error',
+            429 => 'rate_limit_exceeded',
+            default => 'error',
+        };
+    }
+}
+
+// config/web.php
+return [
+    'components' => [
+        'errorHandler' => [
+            'class' => \app\components\ApiErrorHandler::class,
+        ],
+    ],
+];
+```
+
+### Валидация с детальными ошибками
+```php
+<?php
+// Кастомный action для create с подробными ошибками
+class CreateAction extends \yii\rest\CreateAction
+{
+    public function run(): \yii\db\ActiveRecordInterface
+    {
+        if ($this->checkAccess) {
+            call_user_func($this->checkAccess, $this->id);
+        }
+
+        $model = new $this->modelClass([
+            'scenario' => $this->scenario,
+        ]);
+
+        $model->load(\Yii::$app->getRequest()->getBodyParams(), '');
+
+        if (!$model->validate()) {
+            \Yii::$app->response->setStatusCode(422);
+
+            return [
+                'error' => [
+                    'code' => 422,
+                    'message' => 'Validation failed',
+                    'type' => 'validation_error',
+                    'details' => $this->formatErrors($model->errors),
+                ],
+            ];
+        }
+
+        if (!$model->save(false)) {
+            throw new \yii\web\ServerErrorHttpException('Failed to create the object');
+        }
+
+        \Yii::$app->response->setStatusCode(201);
+
+        return $model;
+    }
+
+    private function formatErrors(array $errors): array
+    {
+        $result = [];
+
+        foreach ($errors as $field => $messages) {
+            $result[] = [
+                'field' => $field,
+                'messages' => $messages,
+            ];
+        }
+
+        return $result;
+    }
+}
+```
+
+## Фильтрация и поиск
+
+### DataProvider с фильтрами
+```php
+<?php
+// models/UserSearch.php
+namespace app\models;
+
+use yii\data\ActiveDataProvider;
+
+class UserSearch extends User
+{
+    public ?string $search = null;
+    public ?string $role = null;
+    public ?string $created_from = null;
+    public ?string $created_to = null;
+
+    public function rules(): array
+    {
+        return [
+            [['search', 'role', 'status'], 'safe'],
+            [['created_from', 'created_to'], 'date', 'format' => 'php:Y-m-d'],
+        ];
+    }
+
+    public function search(array $params): ActiveDataProvider
+    {
+        $query = User::find()->with(['profile']);
+
+        $dataProvider = new ActiveDataProvider([
+            'query' => $query,
+            'pagination' => [
+                'pageSize' => $params['per_page'] ?? 20,
+            ],
+            'sort' => [
+                'defaultOrder' => ['created_at' => SORT_DESC],
+                'attributes' => ['id', 'username', 'email', 'created_at'],
+            ],
+        ]);
+
+        $this->load($params, '');
+
+        if (!$this->validate()) {
+            return $dataProvider;
+        }
+
+        // Поиск по тексту
+        if ($this->search) {
+            $query->andWhere(['or',
+                ['ilike', 'username', $this->search],
+                ['ilike', 'email', $this->search],
+                ['ilike', 'first_name', $this->search],
+                ['ilike', 'last_name', $this->search],
+            ]);
+        }
+
+        // Фильтр по статусу
+        if ($this->status !== null) {
+            $query->andWhere(['status' => $this->status]);
+        }
+
+        // Фильтр по роли
+        if ($this->role) {
+            $query->innerJoin('auth_assignment', 'auth_assignment.user_id = users.id')
+                  ->andWhere(['auth_assignment.item_name' => $this->role]);
+        }
+
+        // Фильтр по дате
+        if ($this->created_from) {
+            $query->andWhere(['>=', 'created_at', strtotime($this->created_from)]);
+        }
+        if ($this->created_to) {
+            $query->andWhere(['<=', 'created_at', strtotime($this->created_to . ' 23:59:59')]);
+        }
+
+        return $dataProvider;
+    }
+}
+
+// Использование в контроллере
+public function actionIndex(): ActiveDataProvider
+{
+    $searchModel = new UserSearch();
+    return $searchModel->search(\Yii::$app->request->queryParams);
+}
+
+// Запрос: GET /api/users?search=john&status=1&created_from=2024-01-01&per_page=10
+```
+
+## Документация API
+
+### OpenAPI/Swagger аннотации
+```php
+<?php
+/**
+ * @OA\Info(
+ *     title="ERP24 API",
+ *     version="1.0.0",
+ *     description="REST API для ERP24"
+ * )
+ *
+ * @OA\Server(
+ *     url="https://api.erp24.com/v1",
+ *     description="Production"
+ * )
+ *
+ * @OA\SecurityScheme(
+ *     securityScheme="bearerAuth",
+ *     type="http",
+ *     scheme="bearer",
+ *     bearerFormat="JWT"
+ * )
+ */
+
+/**
+ * @OA\Get(
+ *     path="/users",
+ *     summary="Список пользователей",
+ *     tags={"Users"},
+ *     security={{"bearerAuth":{}}},
+ *     @OA\Parameter(
+ *         name="page",
+ *         in="query",
+ *         description="Номер страницы",
+ *         @OA\Schema(type="integer", default=1)
+ *     ),
+ *     @OA\Parameter(
+ *         name="per_page",
+ *         in="query",
+ *         description="Записей на странице",
+ *         @OA\Schema(type="integer", default=20, maximum=100)
+ *     ),
+ *     @OA\Response(
+ *         response=200,
+ *         description="Успешный ответ",
+ *         @OA\JsonContent(
+ *             @OA\Property(property="data", type="array",
+ *                 @OA\Items(ref="#/components/schemas/User")
+ *             ),
+ *             @OA\Property(property="meta", ref="#/components/schemas/PaginationMeta")
+ *         )
+ *     ),
+ *     @OA\Response(response=401, description="Не авторизован")
+ * )
+ */
+public function actionIndex(): ActiveDataProvider
+{
+    // ...
+}
+
+/**
+ * @OA\Schema(
+ *     schema="User",
+ *     @OA\Property(property="id", type="integer", example=1),
+ *     @OA\Property(property="username", type="string", example="john_doe"),
+ *     @OA\Property(property="email", type="string", format="email"),
+ *     @OA\Property(property="created_at", type="string", format="date-time")
+ * )
+ */
+```
+
+## CORS
+
+### Настройка CORS
+```php
+<?php
+// controllers/api/BaseController.php
+namespace app\controllers\api;
+
+use yii\rest\Controller;
+use yii\filters\Cors;
+
+class BaseController extends Controller
+{
+    public function behaviors(): array
+    {
+        $behaviors = parent::behaviors();
+
+        // CORS должен быть перед authenticator
+        $behaviors['cors'] = [
+            'class' => Cors::class,
+            'cors' => [
+                'Origin' => ['https://app.example.com', 'https://admin.example.com'],
+                'Access-Control-Request-Method' => ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
+                'Access-Control-Request-Headers' => ['*'],
+                'Access-Control-Allow-Credentials' => true,
+                'Access-Control-Max-Age' => 86400,
+                'Access-Control-Expose-Headers' => [
+                    'X-Pagination-Current-Page',
+                    'X-Pagination-Page-Count',
+                    'X-Pagination-Per-Page',
+                    'X-Pagination-Total-Count',
+                    'X-Rate-Limit-Limit',
+                    'X-Rate-Limit-Remaining',
+                    'X-Rate-Limit-Reset',
+                ],
+            ],
+        ];
+
+        return $behaviors;
+    }
+}
+```
+
+## Тестирование API
+
+### Codeception API тесты
+```php
+<?php
+// tests/api/UserCest.php
+class UserCest
+{
+    private string $token;
+
+    public function _before(ApiTester $I): void
+    {
+        $I->haveHttpHeader('Content-Type', 'application/json');
+        $I->haveHttpHeader('Accept', 'application/json');
+
+        // Получение токена
+        $I->sendPost('/auth/login', [
+            'email' => 'test@example.com',
+            'password' => 'password',
+        ]);
+        $this->token = $I->grabDataFromResponseByJsonPath('$.access_token')[0];
+    }
+
+    public function testGetUsers(ApiTester $I): void
+    {
+        $I->amBearerAuthenticated($this->token);
+        $I->sendGet('/users');
+
+        $I->seeResponseCodeIs(200);
+        $I->seeResponseIsJson();
+        $I->seeResponseContainsJson(['success' => true]);
+        $I->seeResponseMatchesJsonType([
+            'data' => 'array',
+            'meta' => [
+                'totalCount' => 'integer',
+                'pageCount' => 'integer',
+            ],
+        ]);
+    }
+
+    public function testCreateUser(ApiTester $I): void
+    {
+        $I->amBearerAuthenticated($this->token);
+        $I->sendPost('/users', [
+            'username' => 'newuser',
+            'email' => 'newuser@example.com',
+            'password' => 'SecurePass123!',
+        ]);
+
+        $I->seeResponseCodeIs(201);
+        $I->seeResponseContainsJson([
+            'username' => 'newuser',
+            'email' => 'newuser@example.com',
+        ]);
+    }
+
+    public function testValidationError(ApiTester $I): void
+    {
+        $I->amBearerAuthenticated($this->token);
+        $I->sendPost('/users', [
+            'username' => '',  // Пустое имя
+            'email' => 'invalid-email',  // Невалидный email
+        ]);
+
+        $I->seeResponseCodeIs(422);
+        $I->seeResponseContainsJson([
+            'error' => [
+                'code' => 422,
+                'type' => 'validation_error',
+            ],
+        ]);
+    }
+
+    public function testUnauthorized(ApiTester $I): void
+    {
+        $I->sendGet('/users/me');
+
+        $I->seeResponseCodeIs(401);
+        $I->seeResponseContainsJson([
+            'error' => [
+                'code' => 401,
+                'type' => 'unauthorized',
+            ],
+        ]);
+    }
+}
+```
+
+## Рекомендации для Claude Code
+
+1. **ActiveController** - используйте для стандартных CRUD операций
+2. **Сериализация** - настраивайте fields() и extraFields() в моделях
+3. **Аутентификация** - используйте JWT или Bearer tokens
+4. **Версионирование** - используйте префикс /api/v1/, /api/v2/
+5. **Ошибки** - возвращайте структурированные JSON ответы
+6. **Rate Limiting** - реализуйте RateLimitInterface
+7. **CORS** - настраивайте для cross-origin запросов
+8. **Документация** - используйте OpenAPI/Swagger аннотации
diff --git a/erp24/php_skills/20-yii2-widgets.md b/erp24/php_skills/20-yii2-widgets.md
new file mode 100644 (file)
index 0000000..1f3036d
--- /dev/null
@@ -0,0 +1,1044 @@
+# Yii2 Widgets - Виджеты и компоненты
+
+## Базовые виджеты
+
+### GridView
+```php
+<?php
+use yii\grid\GridView;
+use yii\grid\ActionColumn;
+use yii\grid\CheckboxColumn;
+use yii\helpers\Html;
+
+echo GridView::widget([
+    'dataProvider' => $dataProvider,
+    'filterModel' => $searchModel,
+    'tableOptions' => ['class' => 'table table-striped table-bordered'],
+    'layout' => "{summary}\n{items}\n{pager}",
+    'columns' => [
+        // Checkbox колонка
+        ['class' => CheckboxColumn::class],
+
+        // Serial column (№ п/п)
+        ['class' => \yii\grid\SerialColumn::class],
+
+        // Простые колонки
+        'id',
+        'username',
+        'email:email',  // Формат email
+
+        // Кастомная колонка
+        [
+            'attribute' => 'status',
+            'label' => 'Статус',
+            'format' => 'html',
+            'value' => function ($model) {
+                $statusLabels = [
+                    0 => '<span class="badge bg-secondary">Неактивен</span>',
+                    1 => '<span class="badge bg-success">Активен</span>',
+                    2 => '<span class="badge bg-danger">Заблокирован</span>',
+                ];
+                return $statusLabels[$model->status] ?? 'Неизвестно';
+            },
+            'filter' => [
+                0 => 'Неактивен',
+                1 => 'Активен',
+                2 => 'Заблокирован',
+            ],
+        ],
+
+        // Дата
+        [
+            'attribute' => 'created_at',
+            'format' => ['datetime', 'php:d.m.Y H:i'],
+            'filter' => \yii\jui\DatePicker::widget([
+                'model' => $searchModel,
+                'attribute' => 'created_at',
+                'dateFormat' => 'php:Y-m-d',
+                'options' => ['class' => 'form-control'],
+            ]),
+        ],
+
+        // Связанные данные
+        [
+            'attribute' => 'department_id',
+            'label' => 'Отдел',
+            'value' => 'department.name',  // Связь hasOne
+            'filter' => \yii\helpers\ArrayHelper::map(
+                \app\models\Department::find()->all(),
+                'id',
+                'name'
+            ),
+        ],
+
+        // Кнопки действий
+        [
+            'class' => ActionColumn::class,
+            'template' => '{view} {update} {delete} {activate}',
+            'buttons' => [
+                'activate' => function ($url, $model, $key) {
+                    if ($model->status === 0) {
+                        return Html::a(
+                            '<i class="fas fa-check"></i>',
+                            ['activate', 'id' => $model->id],
+                            [
+                                'title' => 'Активировать',
+                                'data-method' => 'post',
+                                'data-confirm' => 'Активировать пользователя?',
+                            ]
+                        );
+                    }
+                    return '';
+                },
+            ],
+            'visibleButtons' => [
+                'delete' => function ($model) {
+                    return \Yii::$app->user->can('admin');
+                },
+            ],
+        ],
+    ],
+]);
+```
+
+### ListView
+```php
+<?php
+use yii\widgets\ListView;
+
+echo ListView::widget([
+    'dataProvider' => $dataProvider,
+    'itemView' => '_item',  // views/post/_item.php
+    'itemOptions' => ['class' => 'post-item'],
+    'layout' => "{summary}\n{items}\n{pager}",
+    'emptyText' => 'Записи не найдены',
+    'emptyTextOptions' => ['class' => 'alert alert-info'],
+    'summary' => 'Показано {begin}-{end} из {totalCount}',
+    'pager' => [
+        'class' => \yii\widgets\LinkPager::class,
+        'maxButtonCount' => 5,
+        'firstPageLabel' => 'Первая',
+        'lastPageLabel' => 'Последняя',
+    ],
+    'viewParams' => [
+        'showAuthor' => true,
+    ],
+]);
+
+// views/post/_item.php
+<?php
+/** @var app\models\Post $model */
+/** @var int $index */
+/** @var yii\widgets\ListView $widget */
+/** @var bool $showAuthor */
+?>
+<div class="post-card">
+    <h3><?= Html::encode($model->title) ?></h3>
+    <?php if ($showAuthor): ?>
+        <p class="author">Автор: <?= Html::encode($model->user->username) ?></p>
+    <?php endif; ?>
+    <p><?= Html::encode($model->excerpt) ?></p>
+    <?= Html::a('Читать далее', ['post/view', 'id' => $model->id]) ?>
+</div>
+```
+
+### DetailView
+```php
+<?php
+use yii\widgets\DetailView;
+
+echo DetailView::widget([
+    'model' => $model,
+    'options' => ['class' => 'table table-striped detail-view'],
+    'attributes' => [
+        'id',
+        'username',
+        'email:email',
+        'phone:text',
+
+        // Кастомный атрибут
+        [
+            'attribute' => 'status',
+            'format' => 'html',
+            'value' => function ($model) {
+                return $model->status
+                    ? '<span class="badge bg-success">Активен</span>'
+                    : '<span class="badge bg-danger">Неактивен</span>';
+            },
+        ],
+
+        // Без атрибута (вычисляемое значение)
+        [
+            'label' => 'Полное имя',
+            'value' => $model->first_name . ' ' . $model->last_name,
+        ],
+
+        // Связанные данные
+        [
+            'attribute' => 'department_id',
+            'value' => $model->department->name ?? 'Не указан',
+        ],
+
+        // Изображение
+        [
+            'attribute' => 'avatar',
+            'format' => ['image', ['width' => 100, 'height' => 100]],
+            'value' => $model->getAvatarUrl(),
+        ],
+
+        // Дата
+        [
+            'attribute' => 'created_at',
+            'format' => ['datetime', 'php:d.m.Y H:i:s'],
+        ],
+
+        // Boolean
+        [
+            'attribute' => 'is_verified',
+            'format' => 'boolean',
+        ],
+
+        // URL
+        [
+            'attribute' => 'website',
+            'format' => 'url',
+        ],
+    ],
+]);
+```
+
+## Виджеты форм
+
+### ActiveForm
+```php
+<?php
+use yii\widgets\ActiveForm;
+use yii\helpers\Html;
+
+$form = ActiveForm::begin([
+    'id' => 'user-form',
+    'options' => ['class' => 'form-horizontal', 'enctype' => 'multipart/form-data'],
+    'enableAjaxValidation' => true,
+    'enableClientValidation' => true,
+    'validateOnBlur' => true,
+    'validateOnChange' => true,
+    'fieldConfig' => [
+        'template' => "{label}\n{input}\n{hint}\n{error}",
+        'labelOptions' => ['class' => 'form-label'],
+        'inputOptions' => ['class' => 'form-control'],
+        'errorOptions' => ['class' => 'invalid-feedback'],
+    ],
+]);
+?>
+
+<?= $form->field($model, 'username')->textInput(['maxlength' => true]) ?>
+
+<?= $form->field($model, 'email')->input('email') ?>
+
+<?= $form->field($model, 'password')->passwordInput() ?>
+
+<?= $form->field($model, 'status')->dropDownList([
+    0 => 'Неактивен',
+    1 => 'Активен',
+], ['prompt' => 'Выберите статус']) ?>
+
+<?= $form->field($model, 'roles')->checkboxList([
+    'admin' => 'Администратор',
+    'manager' => 'Менеджер',
+    'user' => 'Пользователь',
+]) ?>
+
+<?= $form->field($model, 'gender')->radioList([
+    'male' => 'Мужской',
+    'female' => 'Женский',
+]) ?>
+
+<?= $form->field($model, 'avatar')->fileInput(['accept' => 'image/*']) ?>
+
+<?= $form->field($model, 'description')->textarea(['rows' => 5]) ?>
+
+<?= $form->field($model, 'agree')->checkbox() ?>
+
+<?= $form->field($model, 'department_id')->widget(\yii\widgets\Select2::class, [
+    'data' => \yii\helpers\ArrayHelper::map(
+        \app\models\Department::find()->all(),
+        'id',
+        'name'
+    ),
+    'options' => ['placeholder' => 'Выберите отдел'],
+    'pluginOptions' => ['allowClear' => true],
+]) ?>
+
+<div class="form-group">
+    <?= Html::submitButton('Сохранить', ['class' => 'btn btn-primary']) ?>
+    <?= Html::resetButton('Сбросить', ['class' => 'btn btn-secondary']) ?>
+</div>
+
+<?php ActiveForm::end(); ?>
+```
+
+### Pjax
+```php
+<?php
+use yii\widgets\Pjax;
+
+// Обёртка для Ajax-обновления
+Pjax::begin([
+    'id' => 'users-pjax',
+    'timeout' => 5000,
+    'enablePushState' => true,
+    'enableReplaceState' => false,
+    'clientOptions' => [
+        'method' => 'GET',
+    ],
+]);
+?>
+
+<?= GridView::widget([
+    'dataProvider' => $dataProvider,
+    'filterModel' => $searchModel,
+    // ...
+]) ?>
+
+<?php Pjax::end(); ?>
+
+<!-- JavaScript для программного обновления -->
+<script>
+// Обновить содержимое
+$.pjax.reload({container: '#users-pjax'});
+
+// С параметрами
+$.pjax.reload({
+    container: '#users-pjax',
+    url: '/users?status=1',
+    push: false
+});
+</script>
+```
+
+## Создание виджетов
+
+### Простой виджет
+```php
+<?php
+// widgets/Alert.php
+namespace app\widgets;
+
+use yii\base\Widget;
+use yii\helpers\Html;
+
+class Alert extends Widget
+{
+    public string $type = 'info';  // success, warning, danger, info
+    public string $message = '';
+    public bool $dismissible = true;
+
+    public function run(): string
+    {
+        if (empty($this->message)) {
+            return '';
+        }
+
+        $class = "alert alert-{$this->type}";
+
+        if ($this->dismissible) {
+            $class .= ' alert-dismissible fade show';
+        }
+
+        $content = Html::encode($this->message);
+
+        if ($this->dismissible) {
+            $content .= Html::button('&times;', [
+                'class' => 'btn-close',
+                'data-bs-dismiss' => 'alert',
+            ]);
+        }
+
+        return Html::tag('div', $content, [
+            'class' => $class,
+            'role' => 'alert',
+        ]);
+    }
+}
+
+// Использование
+echo \app\widgets\Alert::widget([
+    'type' => 'success',
+    'message' => 'Операция выполнена успешно!',
+]);
+```
+
+### Виджет с представлением
+```php
+<?php
+// widgets/UserCard.php
+namespace app\widgets;
+
+use yii\base\Widget;
+use app\models\User;
+
+class UserCard extends Widget
+{
+    public ?User $user = null;
+    public bool $showEmail = true;
+    public bool $showAvatar = true;
+    public string $size = 'medium';  // small, medium, large
+
+    public function init(): void
+    {
+        parent::init();
+
+        if ($this->user === null) {
+            throw new \yii\base\InvalidConfigException('User is required');
+        }
+    }
+
+    public function run(): string
+    {
+        return $this->render('user-card', [
+            'user' => $this->user,
+            'showEmail' => $this->showEmail,
+            'showAvatar' => $this->showAvatar,
+            'size' => $this->size,
+        ]);
+    }
+}
+
+// widgets/views/user-card.php
+<?php
+use yii\helpers\Html;
+
+/** @var app\models\User $user */
+/** @var bool $showEmail */
+/** @var bool $showAvatar */
+/** @var string $size */
+
+$sizeClass = match ($size) {
+    'small' => 'user-card-sm',
+    'large' => 'user-card-lg',
+    default => 'user-card-md',
+};
+?>
+
+<div class="user-card <?= $sizeClass ?>">
+    <?php if ($showAvatar): ?>
+        <div class="user-avatar">
+            <?= Html::img($user->getAvatarUrl(), ['alt' => $user->username]) ?>
+        </div>
+    <?php endif; ?>
+
+    <div class="user-info">
+        <h4><?= Html::encode($user->username) ?></h4>
+        <?php if ($showEmail): ?>
+            <p><?= Html::mailto($user->email) ?></p>
+        <?php endif; ?>
+    </div>
+</div>
+
+// Использование
+echo \app\widgets\UserCard::widget([
+    'user' => $user,
+    'showEmail' => false,
+    'size' => 'large',
+]);
+```
+
+### Виджет с Asset Bundle
+```php
+<?php
+// widgets/assets/ChartAsset.php
+namespace app\widgets\assets;
+
+use yii\web\AssetBundle;
+
+class ChartAsset extends AssetBundle
+{
+    public $sourcePath = '@app/widgets/assets/dist';
+
+    public $css = [
+        'css/chart.css',
+    ];
+
+    public $js = [
+        'js/chart.min.js',
+        'js/chart-widget.js',
+    ];
+
+    public $depends = [
+        \yii\web\JqueryAsset::class,
+    ];
+}
+
+// widgets/Chart.php
+namespace app\widgets;
+
+use yii\base\Widget;
+use yii\helpers\Json;
+use app\widgets\assets\ChartAsset;
+
+class Chart extends Widget
+{
+    public string $type = 'line';  // line, bar, pie, doughnut
+    public array $data = [];
+    public array $options = [];
+    public int $width = 400;
+    public int $height = 300;
+
+    public function run(): string
+    {
+        $this->registerAssets();
+
+        $id = $this->getId();
+        $config = Json::encode([
+            'type' => $this->type,
+            'data' => $this->data,
+            'options' => $this->options,
+        ]);
+
+        $js = "new ChartWidget('#{$id}', {$config});";
+        $this->view->registerJs($js);
+
+        return "<canvas id=\"{$id}\" width=\"{$this->width}\" height=\"{$this->height}\"></canvas>";
+    }
+
+    protected function registerAssets(): void
+    {
+        ChartAsset::register($this->view);
+    }
+}
+
+// Использование
+echo \app\widgets\Chart::widget([
+    'type' => 'bar',
+    'data' => [
+        'labels' => ['Январь', 'Февраль', 'Март'],
+        'datasets' => [
+            [
+                'label' => 'Продажи',
+                'data' => [100, 150, 200],
+            ],
+        ],
+    ],
+    'options' => [
+        'responsive' => true,
+    ],
+]);
+```
+
+### Виджет с буферизацией контента
+```php
+<?php
+// widgets/Panel.php
+namespace app\widgets;
+
+use yii\base\Widget;
+use yii\helpers\Html;
+
+class Panel extends Widget
+{
+    public string $title = '';
+    public string $type = 'default';  // default, primary, success, warning, danger
+    public array $headerOptions = [];
+    public array $bodyOptions = [];
+    public array $footerOptions = [];
+    public ?string $footer = null;
+
+    public function init(): void
+    {
+        parent::init();
+        ob_start();
+    }
+
+    public function run(): string
+    {
+        $content = ob_get_clean();
+
+        $header = '';
+        if ($this->title) {
+            $headerOptions = array_merge(['class' => 'card-header'], $this->headerOptions);
+            $header = Html::tag('div', Html::encode($this->title), $headerOptions);
+        }
+
+        $bodyOptions = array_merge(['class' => 'card-body'], $this->bodyOptions);
+        $body = Html::tag('div', $content, $bodyOptions);
+
+        $footer = '';
+        if ($this->footer !== null) {
+            $footerOptions = array_merge(['class' => 'card-footer'], $this->footerOptions);
+            $footer = Html::tag('div', $this->footer, $footerOptions);
+        }
+
+        return Html::tag('div', $header . $body . $footer, [
+            'class' => "card border-{$this->type}",
+        ]);
+    }
+}
+
+// Использование
+<?php \app\widgets\Panel::begin([
+    'title' => 'Информация о пользователе',
+    'type' => 'primary',
+]); ?>
+
+<p>Здесь содержимое панели</p>
+<?= DetailView::widget(['model' => $user, 'attributes' => ['username', 'email']]) ?>
+
+<?php \app\widgets\Panel::end(); ?>
+```
+
+## Стандартные виджеты Yii2
+
+### Menu
+```php
+<?php
+use yii\widgets\Menu;
+
+echo Menu::widget([
+    'items' => [
+        ['label' => 'Главная', 'url' => ['site/index']],
+        ['label' => 'О нас', 'url' => ['site/about']],
+        [
+            'label' => 'Продукты',
+            'url' => ['product/index'],
+            'items' => [
+                ['label' => 'Категория 1', 'url' => ['product/category', 'id' => 1]],
+                ['label' => 'Категория 2', 'url' => ['product/category', 'id' => 2]],
+            ],
+        ],
+        [
+            'label' => 'Админка',
+            'url' => ['admin/index'],
+            'visible' => \Yii::$app->user->can('admin'),
+        ],
+        [
+            'label' => 'Войти',
+            'url' => ['site/login'],
+            'visible' => \Yii::$app->user->isGuest,
+        ],
+        [
+            'label' => 'Выйти (' . \Yii::$app->user->identity?->username . ')',
+            'url' => ['site/logout'],
+            'visible' => !\Yii::$app->user->isGuest,
+            'linkOptions' => ['data-method' => 'post'],
+        ],
+    ],
+    'options' => ['class' => 'nav nav-pills'],
+    'itemOptions' => ['class' => 'nav-item'],
+    'linkTemplate' => '<a class="nav-link{active}" href="{url}">{label}</a>',
+    'submenuTemplate' => "\n<ul class=\"dropdown-menu\">\n{items}\n</ul>\n",
+    'activateParents' => true,
+]);
+```
+
+### Breadcrumbs
+```php
+<?php
+use yii\widgets\Breadcrumbs;
+
+echo Breadcrumbs::widget([
+    'links' => isset($this->params['breadcrumbs']) ? $this->params['breadcrumbs'] : [],
+    'homeLink' => [
+        'label' => '<i class="fas fa-home"></i> Главная',
+        'url' => ['/'],
+        'encode' => false,
+    ],
+    'options' => ['class' => 'breadcrumb'],
+    'itemTemplate' => "<li class=\"breadcrumb-item\">{link}</li>\n",
+    'activeItemTemplate' => "<li class=\"breadcrumb-item active\">{link}</li>\n",
+]);
+
+// В контроллере или представлении
+$this->params['breadcrumbs'][] = ['label' => 'Пользователи', 'url' => ['index']];
+$this->params['breadcrumbs'][] = ['label' => $model->username, 'url' => ['view', 'id' => $model->id]];
+$this->params['breadcrumbs'][] = 'Редактирование';
+```
+
+### LinkPager
+```php
+<?php
+use yii\widgets\LinkPager;
+
+echo LinkPager::widget([
+    'pagination' => $dataProvider->pagination,
+    'options' => ['class' => 'pagination justify-content-center'],
+    'linkContainerOptions' => ['class' => 'page-item'],
+    'linkOptions' => ['class' => 'page-link'],
+    'disabledListItemSubTagOptions' => ['class' => 'page-link'],
+    'maxButtonCount' => 7,
+    'firstPageLabel' => '«',
+    'lastPageLabel' => '»',
+    'prevPageLabel' => '‹',
+    'nextPageLabel' => '›',
+    'activePageCssClass' => 'active',
+    'disabledPageCssClass' => 'disabled',
+]);
+```
+
+### MaskedInput
+```php
+<?php
+use yii\widgets\MaskedInput;
+
+// Телефон
+echo $form->field($model, 'phone')->widget(MaskedInput::class, [
+    'mask' => '+7 (999) 999-99-99',
+]);
+
+// Дата
+echo $form->field($model, 'birth_date')->widget(MaskedInput::class, [
+    'mask' => '99.99.9999',
+]);
+
+// ИНН
+echo $form->field($model, 'inn')->widget(MaskedInput::class, [
+    'mask' => '9999999999',  // 10 цифр
+]);
+
+// Паспорт
+echo $form->field($model, 'passport')->widget(MaskedInput::class, [
+    'mask' => '99 99 999999',
+]);
+
+// Кредитная карта
+echo $form->field($model, 'card_number')->widget(MaskedInput::class, [
+    'mask' => '9999 9999 9999 9999',
+]);
+```
+
+## Bootstrap виджеты
+
+### Modal
+```php
+<?php
+use yii\bootstrap5\Modal;
+
+Modal::begin([
+    'id' => 'user-modal',
+    'title' => '<h4>Добавить пользователя</h4>',
+    'size' => Modal::SIZE_LARGE,
+    'centerVertical' => true,
+    'scrollable' => true,
+    'footer' => Html::button('Закрыть', [
+        'class' => 'btn btn-secondary',
+        'data-bs-dismiss' => 'modal',
+    ]) . Html::button('Сохранить', [
+        'class' => 'btn btn-primary',
+        'id' => 'save-btn',
+    ]),
+]);
+?>
+
+<div id="modal-content">
+    <!-- Контент загружается через Ajax -->
+</div>
+
+<?php Modal::end(); ?>
+
+<!-- Кнопка открытия -->
+<?= Html::button('Добавить', [
+    'class' => 'btn btn-success',
+    'data-bs-toggle' => 'modal',
+    'data-bs-target' => '#user-modal',
+]) ?>
+
+<script>
+$('#user-modal').on('show.bs.modal', function () {
+    $.get('/user/create-form', function(data) {
+        $('#modal-content').html(data);
+    });
+});
+</script>
+```
+
+### Tabs
+```php
+<?php
+use yii\bootstrap5\Tabs;
+
+echo Tabs::widget([
+    'items' => [
+        [
+            'label' => 'Основное',
+            'content' => $this->render('_tab_main', ['model' => $model]),
+            'active' => true,
+        ],
+        [
+            'label' => 'Контакты',
+            'content' => $this->render('_tab_contacts', ['model' => $model]),
+        ],
+        [
+            'label' => 'Документы',
+            'content' => $this->render('_tab_documents', ['model' => $model]),
+            'visible' => \Yii::$app->user->can('viewDocuments'),
+        ],
+        [
+            'label' => 'Настройки',
+            'url' => ['user/settings', 'id' => $model->id],
+            'linkOptions' => ['data-pjax' => 0],
+        ],
+    ],
+    'options' => ['class' => 'nav-tabs-custom'],
+    'itemOptions' => ['class' => 'nav-item'],
+    'encodeLabels' => false,
+]);
+```
+
+### Accordion
+```php
+<?php
+use yii\bootstrap5\Accordion;
+
+echo Accordion::widget([
+    'items' => [
+        [
+            'label' => 'Раздел 1',
+            'content' => 'Содержимое первого раздела...',
+            'contentOptions' => ['class' => 'accordion-body'],
+        ],
+        [
+            'label' => 'Раздел 2',
+            'content' => $this->render('_section2'),
+            'options' => ['class' => 'accordion-item'],
+        ],
+        [
+            'label' => 'Раздел 3 (свёрнут)',
+            'content' => 'Содержимое третьего раздела...',
+            'expand' => false,
+        ],
+    ],
+    'options' => ['class' => 'accordion', 'id' => 'faq-accordion'],
+    'autoCloseItems' => true,
+]);
+```
+
+### Progress
+```php
+<?php
+use yii\bootstrap5\Progress;
+
+// Простой прогресс
+echo Progress::widget([
+    'percent' => 75,
+    'barOptions' => ['class' => 'bg-success'],
+    'label' => '75%',
+]);
+
+// Стриповый прогресс
+echo Progress::widget([
+    'percent' => 60,
+    'barOptions' => ['class' => 'progress-bar-striped progress-bar-animated bg-info'],
+]);
+
+// Множественные бары
+echo Progress::widget([
+    'bars' => [
+        ['percent' => 30, 'options' => ['class' => 'bg-success']],
+        ['percent' => 20, 'options' => ['class' => 'bg-warning']],
+        ['percent' => 10, 'options' => ['class' => 'bg-danger']],
+    ],
+]);
+```
+
+## Ajax виджеты
+
+### Динамическая загрузка виджета
+```php
+<?php
+// widgets/AjaxWidget.php
+namespace app\widgets;
+
+use yii\base\Widget;
+use yii\helpers\Json;
+use yii\helpers\Url;
+
+class AjaxWidget extends Widget
+{
+    public string $url;
+    public string $loadingText = 'Загрузка...';
+    public int $refreshInterval = 0;  // 0 = без автообновления
+    public array $ajaxOptions = [];
+
+    public function run(): string
+    {
+        $id = $this->getId();
+        $url = Url::to($this->url);
+        $options = Json::encode($this->ajaxOptions);
+
+        $js = <<<JS
+(function() {
+    var container = $('#{$id}');
+
+    function loadContent() {
+        $.ajax($.extend({
+            url: '{$url}',
+            success: function(data) {
+                container.html(data);
+            },
+            error: function() {
+                container.html('<div class="alert alert-danger">Ошибка загрузки</div>');
+            }
+        }, {$options}));
+    }
+
+    loadContent();
+
+    if ({$this->refreshInterval} > 0) {
+        setInterval(loadContent, {$this->refreshInterval} * 1000);
+    }
+})();
+JS;
+
+        $this->view->registerJs($js);
+
+        return "<div id=\"{$id}\">{$this->loadingText}</div>";
+    }
+}
+
+// Использование
+echo \app\widgets\AjaxWidget::widget([
+    'url' => ['dashboard/stats'],
+    'refreshInterval' => 30,  // Обновлять каждые 30 секунд
+]);
+```
+
+### Infinite Scroll
+```php
+<?php
+// widgets/InfiniteScroll.php
+namespace app\widgets;
+
+use yii\base\Widget;
+use yii\data\Pagination;
+use yii\helpers\Url;
+
+class InfiniteScroll extends Widget
+{
+    public Pagination $pagination;
+    public string $container = '.items';
+    public string $item = '.item';
+    public string $loadingText = 'Загрузка...';
+
+    public function run(): string
+    {
+        if (!$this->pagination->getPage() < $this->pagination->getPageCount() - 1) {
+            return '';
+        }
+
+        $nextPage = $this->pagination->getPage() + 2;
+        $nextUrl = Url::current(['page' => $nextPage]);
+
+        $js = <<<JS
+(function() {
+    var loading = false;
+    var page = {$nextPage};
+    var maxPage = {$this->pagination->getPageCount()};
+
+    $(window).scroll(function() {
+        if (loading || page > maxPage) return;
+
+        if ($(window).scrollTop() + $(window).height() > $(document).height() - 200) {
+            loading = true;
+
+            var loader = $('<div class="infinite-loader">{$this->loadingText}</div>');
+            $('{$this->container}').append(loader);
+
+            $.get('{$nextUrl}'.replace('page=' + (page), 'page=' + page), function(data) {
+                loader.remove();
+                var items = $(data).find('{$this->item}');
+                $('{$this->container}').append(items);
+                page++;
+                loading = false;
+            });
+        }
+    });
+})();
+JS;
+
+        $this->view->registerJs($js);
+
+        return '';
+    }
+}
+```
+
+## Кастомизация стандартных виджетов
+
+### Расширение GridView
+```php
+<?php
+// widgets/CustomGridView.php
+namespace app\widgets;
+
+use yii\grid\GridView;
+
+class CustomGridView extends GridView
+{
+    public $layout = "{toolbar}\n{summary}\n{items}\n{pager}";
+    public $tableOptions = ['class' => 'table table-striped table-hover'];
+    public $summaryOptions = ['class' => 'summary text-muted small'];
+    public $pager = [
+        'class' => \yii\bootstrap5\LinkPager::class,
+        'maxButtonCount' => 7,
+    ];
+    public array $toolbar = [];
+
+    public function run(): string
+    {
+        // Регистрация CSS
+        $this->view->registerCss(<<<CSS
+.grid-view .summary { margin-bottom: 10px; }
+.grid-view .toolbar { margin-bottom: 15px; }
+CSS
+        );
+
+        return parent::run();
+    }
+
+    public function renderSection($name): string|false
+    {
+        if ($name === '{toolbar}') {
+            return $this->renderToolbar();
+        }
+
+        return parent::renderSection($name);
+    }
+
+    protected function renderToolbar(): string
+    {
+        if (empty($this->toolbar)) {
+            return '';
+        }
+
+        $buttons = [];
+        foreach ($this->toolbar as $button) {
+            $buttons[] = $button;
+        }
+
+        return '<div class="toolbar btn-group">' . implode('', $buttons) . '</div>';
+    }
+}
+
+// Использование
+echo \app\widgets\CustomGridView::widget([
+    'dataProvider' => $dataProvider,
+    'filterModel' => $searchModel,
+    'toolbar' => [
+        Html::a('<i class="fas fa-plus"></i> Добавить', ['create'], ['class' => 'btn btn-success']),
+        Html::a('<i class="fas fa-download"></i> Экспорт', ['export'], ['class' => 'btn btn-info']),
+    ],
+    'columns' => [
+        // ...
+    ],
+]);
+```
+
+## Рекомендации для Claude Code
+
+1. **GridView** - используйте для табличного отображения данных с сортировкой и фильтрацией
+2. **ListView** - для карточного или списочного представления
+3. **DetailView** - для отображения одной записи
+4. **ActiveForm** - для всех форм с валидацией
+5. **Pjax** - для Ajax-обновления без перезагрузки страницы
+6. **AssetBundle** - регистрируйте CSS/JS через бандлы
+7. **begin()/end()** - используйте для виджетов с буферизацией контента
+8. **Кастомные виджеты** - выносите повторяющийся UI в виджеты
diff --git a/erp24/php_skills/README.md b/erp24/php_skills/README.md
new file mode 100644 (file)
index 0000000..36bddef
--- /dev/null
@@ -0,0 +1,85 @@
+# PHP 8 и Yii2 Style Guide
+
+Современный набор гайдлайнов для разработки на PHP 8 и Yii2, оптимизированный для использования с Claude Code и другими AI-ассистентами.
+
+## Структура документации
+
+### Основы PHP
+- [`01-php-basics.md`](./01-php-basics.md) - Базовые правила форматирования и синтаксиса
+- [`02-php-naming.md`](./02-php-naming.md) - Соглашения об именовании
+- [`03-php-methods.md`](./03-php-methods.md) - Методы и функции
+- [`04-php-classes.md`](./04-php-classes.md) - Классы, интерфейсы и трейты
+- [`05-php-collections.md`](./05-php-collections.md) - Работа с массивами
+- [`06-php-strings.md`](./06-php-strings.md) - Работа со строками
+- [`07-php-flow-control.md`](./07-php-flow-control.md) - Управление потоком выполнения
+- [`08-php-exceptions.md`](./08-php-exceptions.md) - Обработка исключений
+- [`09-php-closures.md`](./09-php-closures.md) - Замыкания и колбэки
+
+### Yii2-специфичные правила
+- [`10-yii2-structure.md`](./10-yii2-structure.md) - Структура Yii2 приложения
+- [`11-yii2-models.md`](./11-yii2-models.md) - Модели и ActiveRecord
+- [`12-yii2-controllers.md`](./12-yii2-controllers.md) - Контроллеры
+- [`13-yii2-views.md`](./13-yii2-views.md) - Представления и шаблоны
+- [`14-yii2-routing.md`](./14-yii2-routing.md) - Маршрутизация
+- [`15-yii2-migrations.md`](./15-yii2-migrations.md) - Миграции базы данных
+- [`16-yii2-testing.md`](./16-yii2-testing.md) - Тестирование
+- [`17-yii2-security.md`](./17-yii2-security.md) - Безопасность
+- [`18-yii2-performance.md`](./18-yii2-performance.md) - Производительность
+- [`19-yii2-api.md`](./19-yii2-api.md) - REST API
+- [`20-yii2-widgets.md`](./20-yii2-widgets.md) - Виджеты и компоненты
+
+## Цель
+
+Эти гайдлайны созданы для:
+1. Обеспечения консистентности кода в PHP 8 / Yii2 проектах
+2. Оптимизации работы с AI-ассистентами (Claude Code)
+3. Следования современным best practices (PSR-12, Yii2 Coding Standards)
+4. Упрощения code review и командной работы
+
+## Как использовать
+
+### С Claude Code
+Поместите эти файлы в корень вашего проекта в папку `.claude/` или `docs/style-guide/`:
+```bash
+project/
+├── .claude/
+│   └── style-guide/
+│       ├── README.md
+│       ├── 01-php-basics.md
+│       ├── 02-php-naming.md
+│       └── ...
+```
+
+### Приоритеты
+1. **Читаемость** превыше всего
+2. **Консистентность** внутри проекта важнее идеального следования гайду
+3. **Практичность** - если правило мешает решению задачи, его можно нарушить с обоснованием
+
+## Версии
+
+- PHP: 8.1+
+- Yii2: 2.0.45+
+- Обновлено: Декабрь 2024
+
+## Стандарты
+
+Данный гайд основан на:
+- PSR-1: Basic Coding Standard
+- PSR-4: Autoloading Standard
+- PSR-12: Extended Coding Style Guide
+- Yii2 Coding Standards
+- PHP-FIG рекомендации
+
+## Конфигурация
+
+Рекомендуется использовать PHP_CodeSniffer и PHP-CS-Fixer с конфигурацией, соответствующей PSR-12 и Yii2 стандартам.
+
+```bash
+# Установка инструментов
+composer require --dev squizlabs/php_codesniffer
+composer require --dev friendsofphp/php-cs-fixer
+
+# Проверка кода
+./vendor/bin/phpcs --standard=PSR12 src/
+./vendor/bin/php-cs-fixer fix --dry-run --diff
+```
index f24df3a5e16a6a1e788a856cc1603f4dbf73ffbe..2ebdce425070c3ae25468f15896743060c2cf98c 100644 (file)
@@ -41,6 +41,8 @@ use yii\helpers\Html;
  * @property float $quantity количество
  * @property integer|null $type_id id типа списания
  * @property string|null $type_guid guid типа списания
+ * @property bool|int $attachment_cleared Флаг очистки вложений
+ * @property string|null $attachment_cleared_at Дата очистки вложений
  * @property WriteOffsProductsErp[] $writeOffsProductsErps
  */
 class WriteOffsErp extends \yii\db\ActiveRecord
@@ -68,8 +70,8 @@ class WriteOffsErp extends \yii\db\ActiveRecord
         return [
             [['guid', 'created_admin_id', 'store_id', 'store_guid', 'number', 'date', 'write_offs_type', 'created_at'], 'required'],
             [['quantity'], 'required', 'message' => 'Поле Количество обязательно для заполнения'],
-            [['status', 'created_admin_id', 'updated_admin_id', 'cause_id', 'confirm_admin_id', 'cause_group_id', 'store_id', 'active', 'deleted_admin_id'], 'integer'],
-            [['date', 'created_at', 'send_at', 'updated_at', 'confirm_at', 'deleted_at', 'modelsProducts'], 'safe'],
+            [['status', 'created_admin_id', 'updated_admin_id', 'cause_id', 'confirm_admin_id', 'cause_group_id', 'store_id', 'active', 'deleted_admin_id', 'attachment_cleared'], 'integer'],
+            [['date', 'created_at', 'send_at', 'updated_at', 'confirm_at', 'deleted_at', 'modelsProducts', 'attachment_cleared_at'], 'safe'],
             [['based_on', 'comment', 'error_text', 'write_offs_type'], 'string'],
             [['summ', 'summ_retail', 'quantity', 'type_id'], 'number'],
             [['guid', 'store_guid', 'type_guid', 'number', 'number_1c', 'date', 'based_on', 'write_offs_type', 'created_at', 'updated_at', 'deleted_at', 'confirm_at', 'send_at'], 'string', 'max' => 100],
@@ -107,6 +109,8 @@ class WriteOffsErp extends \yii\db\ActiveRecord
             'summ_retail' => 'Summ Retail',
             'type_id' => 'id типа списания',
             'type_guid' => 'guid типа списания',
+            'attachment_cleared' => 'Вложения очищены',
+            'attachment_cleared_at' => 'Дата очистки вложений',
             'send_at' => 'Дата отправки в 1с',
             'created_at' => 'Created At',
             'updated_at' => 'Updated At',
@@ -867,6 +871,7 @@ class WriteOffsErp extends \yii\db\ActiveRecord
      * Возвращает вложения для всех документов, дата которых старше одного месяца.
      * Можно передать свою граничную дату в $borderDate (формат Y-m-d или Y-m-d H:i:s).
      * Структура ответа: массив элементов с ключами document_id, date, attachments.
+     * Возвращает только документы с attachment_cleared = 0 (не очищенные).
      */
     public static function getAttachmentsOlderThanMonth(?string $borderDate = null): array
     {
@@ -874,6 +879,7 @@ class WriteOffsErp extends \yii\db\ActiveRecord
 
         $docs = self::find()
             ->andWhere(['status' => self::STATUS_CREATED_1C])
+            ->andWhere(['attachment_cleared' => 0])
             ->andWhere(['<', 'date', $borderDate])
             ->all();
 
@@ -895,6 +901,7 @@ class WriteOffsErp extends \yii\db\ActiveRecord
      * Возвращает вложения для всех документов, дата которых старше одного месяца.
      * Можно передать свою граничную дату в $borderDate (формат Y-m-d или Y-m-d H:i:s).
      * Структура ответа: массив элементов с ключами document_id, date, attachments.
+     * Возвращает только документы с attachment_cleared = 0 (не очищенные).
      */
     public static function getAttachmentsOlderThanMonthList(?string $borderDate = null): array
     {
@@ -902,6 +909,7 @@ class WriteOffsErp extends \yii\db\ActiveRecord
 
         $docs = self::find()
             ->andWhere(['status' => self::STATUS_CREATED_1C])
+            ->andWhere(['attachment_cleared' => 0])
             ->andWhere(['<', 'date', $borderDate])
             ->all();