git / code.ach.gov.ru / kabakov_iv / bitrix_php_code_standarts
commit 3db856f3cc2ed5e3bdf0a8a9f3b01092ef05bf3d
author kabakov_iv <kabakov.i@yandex.ru>
date 2026-05-06 14:09:38 +0300
parents 75dd313c
message
разбивка на отдельные файлы
files
| file | add | del |
|---|---|---|
| README.md | +46 | -1592 |
| src/01-naming.md | +54 | -0 |
| src/02-functions.md | +107 | -0 |
| src/03-classes.md | +122 | -0 |
| src/04-controllers.md | +101 | -0 |
| src/05-arrays.md | +63 | -0 |
| src/06-conditions.md | +124 | -0 |
| src/07-typing.md | +92 | -0 |
| src/08-errors.md | +96 | -0 |
| src/09-security.md | +71 | -0 |
| src/10-database.md | +138 | -0 |
| src/11-performance.md | +83 | -0 |
| src/12-testing.md | +78 | -0 |
| src/13-comments.md | +49 | -0 |
| src/14-general.md | +67 | -0 |
| src/15-events.md | +60 | -0 |
| src/16-logging.md | +68 | -0 |
| src/17-agents.md | +296 | -0 |
| src/index.md | +71 | -0 |
patch
diff --git a/README.md b/README.md
index 6e009576e879a0e8a6776c4a24d6cf86e491fdc5..d55f19371d6bac82fd63428aacf09f7d64c68575 100644
--- a/README.md
+++ b/README.md
@@ -1,1615 +1,69 @@
-# Правила написания кода PHP-команды
-
-> Битрикс24 · ORM · SOLID · DDD
-> Полное руководство — версия 2.0
-
-**Платформа:** Битрикс24
-**PHP:** 8.1+ / 8.4
-**Версия документа:** 2.0 (исправленная и дополненная)
-**Год:** 2026
-
-SOLID · DRY · KISS · YAGNI · ORM Bitrix · DTO · Repository · PSR-3 · PSR-12 · PHP 8.1+ · AbstractAgent
-
----
-
-## Содержание
-
-01. Именование и читаемость
-02. Функции и методы
-03. Классы и объекты (SOLID, DI, ServiceLocator)
-04. Контроллеры
-05. Работа с массивами и коллекциями
-06. Условия и управляющие конструкции
-07. Типизация и строгость
-08. Обработка ошибок и Result Object
-09. Безопасность
-10. Базы данных и ORM Битрикс
-11. Производительность: кеш и TaggedCache
-12. Тестирование
-13. Комментирование и документация
-14. Общие практики (DRY, KISS, YAGNI)
-15. События Bitrix (EventManager)
-16. Логирование (PSR-3)
-17. Агенты — AbstractAgent (полный паттерн)
-
----
-
-## РАЗДЕЛ 01. Именование и читаемость
-
-### Осмысленные имена переменных, методов, классов
-
-Избегайте сокращений (кроме общепринятых: id, url, json), однобуквенных имён и абстрактных названий. Имя должно отражать суть сущности или действия.
-
-**ПЛОХО**
-```php
-function calc($d) {
- $r = [];
- foreach ($d as $x) {
- if ($x['UF_ACTIVE']) {
- $r[] = $x['UF_NAME'];
- }
- }
- return $r;
-}
-```
-
-**ХОРОШО**
-```php
-/**
- * Возвращает имена активных клиентов.
- * @param array $clients
- * @return string[]
- */
-function getActiveClientNames(array $clients): array {
- $names = [];
- foreach ($clients as $client) {
- if ($client['UF_ACTIVE'] === 'Y') {
- $names[] = $client['UF_FULL_NAME'];
- }
- }
- return $names;
-}
-```
-
-### Единый стиль именования (PSR-12 + Bitrix-соглашения)
-
-| Сущность | Стиль | Пример |
-|---|---|---|
-| Классы | PascalCase | `ClientService` |
-| Методы / функции | camelCase | `getActiveClients()` |
-| Константы класса | UPPER_CASE | `MAX_RETRY_COUNT` |
-| Свойства | camelCase | `$firstName` |
-| Поля ORM Bitrix | snake_case (как в БД) | `STAGE_ID`, `UF_ACTIVE` |
-
-Пользовательские поля `UF_*` — как есть: `UF_FULL_NAME`
-
----
-
-## РАЗДЕЛ 02. Функции и методы
-
-### Одна функция — одна ответственность (SRP)
-
-**ПЛОХО**
-```php
-function processLeadAndSendNotification(array $data): void {
- // валидация, сохранение в CRM, отправка письма, логирование — всё в одном месте
-}
-```
-
-**ХОРОШО**
-```php
-function validateLeadData(array $data): array { /* ... */ }
-function createLead(array $validatedData): \Bitrix\Crm\Item { /* ... */ }
-function sendLeadNotification(\Bitrix\Crm\Item $lead): void { /* ... */ }
-```
-
-### Избегайте флаговых аргументов (boolean trap)
-
-**ПЛОХО**
-```php
-function addProductToDeal(int $dealId, array $productRow, bool $recalculate = true): void {
- // добавление товара
- if ($recalculate) {
- \CCrmDeal::Recalculate($dealId);
- }
-}
-```
-
-**ХОРОШО — ДВА ЯВНЫХ МЕТОДА**
-```php
-function addProductToDeal(int $dealId, array $productRow): void {
- // только добавление товара
-}
-
-function addProductToDealAndRecalculate(int $dealId, array $productRow): void {
- $this->addProductToDeal($dealId, $productRow);
- \CCrmDeal::Recalculate($dealId);
-}
-```
-
-### Количество параметров ≤ 3–4, иначе — DTO
-
-**ПЛОХО**
-```php
-function updateCompany(int $id, string $title, string $phone, string $email, ?string $address, bool $isActive) { /* ... */ }
-```
-
-**ХОРОШО — READONLY DTO (PHP 8.2+)**
-```php
-readonly class UpdateCompanyDto {
- public function __construct(
- public string $title,
- public string $phone,
- public string $email,
- public ?string $address = null,
- public bool $isActive = true,
- ) {}
-}
-
-function updateCompany(int $id, UpdateCompanyDto $dto): void { /* ... */ }
-```
-
-### Ранний возврат (early return)
-
-**ПЛОХО — ГЛУБОКАЯ ВЛОЖЕННОСТЬ**
-```php
-function getDiscountForDeal(\Bitrix\Crm\Item $deal): float {
- $discount = 0.0;
- if ($deal->getStageId() === 'EXECUTING') {
- $client = $deal->getClient();
- if ($client && $client->hasActiveSubscription()) {
- if ($deal->getOpportunity() > 100000) {
- $discount = 0.15;
- } else {
- $discount = 0.05;
- }
- }
- }
- return $discount;
-}
-```
-
-**ХОРОШО — FLAT-СТРУКТУРА**
-```php
-function getDiscountForDeal(\Bitrix\Crm\Item $deal): float {
- if ($deal->getStageId() !== 'EXECUTING') {
- return 0.0;
- }
- $client = $deal->getClient();
- if (!$client || !$client->hasActiveSubscription()) {
- return 0.0;
- }
- return $deal->getOpportunity() > 100000 ? 0.15 : 0.05;
-}
-```
-
----
-
-## РАЗДЕЛ 03. Классы и объекты (SOLID, DI, ServiceLocator)
-
-### Принцип единственной ответственности (SRP)
-
-**ПЛОХО — КЛАСС ДЕЛАЕТ ВСЁ СРАЗУ**
-```php
-class LeadManager {
- public function create(array $data): void { /* ... */ }
- public function sendEmail(string $message): void { /* ... */ }
- public function log(string $action): void { /* ... */ }
-}
-```
-
-**ХОРОШО — РАЗДЕЛЕНИЕ ОТВЕТСТВЕННОСТЕЙ**
-```php
-class LeadCreationService {
- public function __construct(
- private \Bitrix\Crm\Service\Container $crmContainer,
- private LeadNotificationService $notificationService,
- private \Psr\Log\LoggerInterface $logger,
- ) {}
-
- public function createFromArray(array $data): \Bitrix\Crm\Item {
- // только логика создания лида
- }
-}
-```
-
-### Предпочитайте композицию наследованию
-
-**ПЛОХО — НАСЛЕДОВАНИЕ ОТ УСТАРЕВШЕГО КЛАССА**
-```php
-class CustomDealProcessor extends \CCrmDeal {
- public function process($dealId) {
- $this->Update($dealId, ['STAGE_ID' => 'WON']);
- }
-}
-```
-
-**ПРАВИЛЬНО — ЧЕРЕЗ CONTAINER**
-```php
-class DealProcessor {
- public function __construct(
- private \Bitrix\Crm\Service\Container $container,
- ) {}
-
- public function markAsWon(int $dealId): void {
- $factory = $this->container->getFactory(\CCrmOwnerType::Deal);
- $deal = $factory->getItem($dealId);
- if ($deal === null) {
- throw new \Bitrix\Main\ObjectNotFoundException("Deal #{$dealId} not found");
- }
- $deal->setStageId('WON');
- $result = $factory->getUpdateOperation($deal)->launch();
- if (!$result->isSuccess()) {
- throw new \RuntimeException(implode(', ', $result->getErrorMessages()));
- }
- }
-}
-```
-
-### ServiceLocator Bitrix для внедрения зависимостей
-
-**ПЛОХО — РУЧНОЕ СОЗДАНИЕ ЗАВИСИМОСТЕЙ ПОВСЮДУ**
-```php
-// В каждом месте использования:
-$logger = new FileLogger('/local/logs/app.log');
-$factory = \Bitrix\Crm\Service\Container::getInstance()
- ->getFactory(\CCrmOwnerType::Lead);
-$service = new LeadCreationService($factory, new LeadNotificationService(), $logger);
-```
-
-**ХОРОШО — РЕГИСТРАЦИЯ ОДИН РАЗ, ПОЛУЧЕНИЕ ВЕЗДЕ**
-```php
-use Bitrix\Main\DI\ServiceLocator;
-
-// Один раз:
-$serviceLocator = ServiceLocator::getInstance();
-$serviceLocator->addInstanceLazy(
- LeadCreationService::class,
- static function () use ($serviceLocator): LeadCreationService {
- return new LeadCreationService(
- \Bitrix\Crm\Service\Container::getInstance(),
- $serviceLocator->get(LeadNotificationService::class),
- $serviceLocator->get(\Psr\Log\LoggerInterface::class),
- );
- }
-);
-
-// Получение в любом месте приложения:
-$service = ServiceLocator::getInstance()->get(LeadCreationService::class);
-```
-
-### Избегайте статических методов для бизнес-логики
-
-**ПЛОХО — ПРЯМОЙ ВЫЗОВ УСТАРЕВШЕГО API**
-```php
-$leadId = \CCrmLead::Add(['TITLE' => 'Новый лид']);
-```
-
-**ХОРОШО — ОБОРАЧИВАЕМ В СЕРВИС**
-```php
-class LeadService {
- public function __construct(private \Bitrix\Crm\Service\Factory $factory) {}
-
- public function create(string $title): \Bitrix\Crm\Item {
- $lead = $this->factory->createItem();
- $lead->setTitle($title);
- return $this->factory->getCreateOperation($lead)->launch()->getItem();
- }
-}
-```
-
----
-
-## РАЗДЕЛ 04. Контроллеры
-
-### Тонкие контроллеры — вся логика в сервисах
-
-**ПЛОХО — ЛОГИКА ПРЯМО В КОМПОНЕНТЕ**
-```php
-// result_modifier.php
-if ($arParams['USE_FILTER'] == 'Y') {
- $filter = ['ACTIVE' => 'Y'];
- if ($_REQUEST['search']) {
- $filter['%NAME'] = $_REQUEST['search'];
- }
- $res = \CIBlockElement::GetList([], $filter, false, false, ['ID','NAME']);
- while ($el = $res->Fetch()) {
- $arResult['ITEMS'][] = $el;
- }
-}
-```
-
-**ХОРОШО — СЕРВИС С КОРРЕКТНЫМ ORM API**
-```php
-class NewsService {
- public function getFilteredNews(array $criteria): array {
- $query = \Bitrix\Iblock\Elements\ElementNewsTable::query()
- ->setFilter(['=ACTIVE' => 'Y'])
- ->setSelect(['ID', 'NAME']);
-
- if (!empty($criteria['search'])) {
- $query->whereLike('NAME', '%' . $criteria['search'] . '%');
- }
- return $query->exec()->fetchAll();
- }
-}
-
-// В компоненте:
-$arResult['ITEMS'] = (new NewsService())->getFilteredNews([
- 'search' => $_REQUEST['search'] ?? '',
-]);
-```
-
-### DTO для валидации входящих данных
-
-**ПЛОХО — СЫРАЯ ВАЛИДАЦИЯ В КОНТРОЛЛЕРЕ**
-```php
-$title = $_POST['TITLE'] ?? '';
-if (strlen($title) < 3) {
- $error = 'Заголовок слишком короткий';
-} else {
- \CCrmLead::Add(['TITLE' => $title]);
-}
-```
-
-**ХОРОШО — DTO + КОНТРОЛЛЕР**
-```php
-readonly class CreateLeadDto {
- public function __construct(
- public string $title,
- public ?string $phone = null,
- public ?string $email = null,
- ) {}
-
- /** @throws \InvalidArgumentException */
- public static function fromArray(array $data): self {
- if (empty($data['TITLE']) || mb_strlen($data['TITLE']) < 3) {
- throw new \InvalidArgumentException('Заголовок — не менее 3 символов');
- }
- return new self(
- title: trim($data['TITLE']),
- phone: $data['PHONE'] ?? null,
- email: $data['EMAIL'] ?? null,
- );
- }
-}
-
-class LeadController {
- public function __construct(private LeadCreationService $leadService) {}
-
- public function createAction(\Bitrix\Main\Request $request): \Bitrix\Main\Response {
- try {
- $dto = CreateLeadDto::fromArray($request->getPostList()->toArray());
- $lead = $this->leadService->create($dto);
- return new \Bitrix\Main\Response(
- json_encode(['id' => $lead->getId()]),
- 'application/json'
- );
- } catch (\InvalidArgumentException $e) {
- return new \Bitrix\Main\Response(
- json_encode(['error' => $e->getMessage()]),
- 'application/json',
- 400
- );
- }
- }
-}
-```
-
----
-
-## РАЗДЕЛ 05. Работа с массивами и коллекциями
-
-### Встроенные функции вместо foreach-циклов
-
-**ПЛОХО**
-```php
-$activeClientIds = [];
-foreach ($clients as $client) {
- if ($client['UF_ACTIVE'] === 'Y') {
- $activeClientIds[] = $client['ID'];
- }
-}
-```
-
-**ХОРОШО**
-```php
-$activeClients = array_filter($clients, fn($c) => $c['UF_ACTIVE'] === 'Y');
-$activeClientIds = array_column($activeClients, 'ID');
-```
-
-### Вложенные массивы — разворачивание через array_merge / array_column
-
-**ПЛОХО — ВЛОЖЕННЫЕ ЦИКЛЫ**
-```php
-$allPhones = [];
-foreach ($contacts as $contact) {
- foreach ($contact['PHONE'] as $phone) {
- $allPhones[] = $phone['VALUE'];
- }
-}
-```
-
-**ХОРОШО**
-```php
-$allPhones = array_merge(...array_column($contacts, 'PHONE'));
-$phoneNumbers = array_column($allPhones, 'VALUE');
-```
-
-### Коллекции объектов для сложных структур
-
-**ПЛОХО — РУЧНОЙ ЦИКЛ ДЛЯ АГРЕГАЦИИ**
-```php
-$sum = 0;
-foreach ($dealProducts as $row) {
- $sum += $row['PRICE'] * $row['QUANTITY'];
-}
-```
-
-**ХОРОШО — ILLUMINATE/COLLECTIONS**
-```php
-use Illuminate\Support\Collection;
-
-$totalSum = Collection::make($dealProducts)
- ->sum(fn($row) => $row['PRICE'] * $row['QUANTITY']);
-```
-
----
-
-## РАЗДЕЛ 06. Условия и управляющие конструкции
-
-### Длинные условия — выносите в методы
-
-**ПЛОХО — НЕЧИТАЕМОЕ УСЛОВИЕ В IF**
-```php
-if ($deal->getStageId() === 'EXECUTING' && $deal->getOpportunity() > 10000 && $deal->getAssignedById() === $currentUserId && !$deal->getClosed()) {
- // разрешить действие
-}
-```
-
-**ХОРОШО — ИНКАПСУЛИРОВАННОЕ УСЛОВИЕ**
-```php
-private function canCurrentUserEditDeal(\Bitrix\Crm\Item $deal): bool {
- return $deal->getStageId() === 'EXECUTING'
- && $deal->getOpportunity() > 10000
- && $deal->getAssignedById() === \CCrmSecurityHelper::GetCurrentUserID()
- && !$deal->getClosed();
-}
-
-if ($this->canCurrentUserEditDeal($deal)) {
- // разрешить действие
-}
-```
-
-### Именованные константы и Enum вместо магических значений
-
-**ПЛОХО — МАГИЧЕСКИЕ ЧИСЛА И СТРОКИ**
-```php
-if ($lead->getStatusId() === '1') { // что за статус?
- $action = 'call';
-}
-if ($product['QUANTITY'] < 10) { /* неясно откуда 10 */ }
-```
-
-**ХОРОШО — PHP 8.1+ ENUM**
-```php
-enum LeadStatus: string {
- case NEW = '1';
- case IN_PROCESS = '2';
- case CONVERTED = '3';
-}
-
-const LOW_STOCK_THRESHOLD = 10;
-
-if ($lead->getStatusId() === LeadStatus::NEW->value) {
- $action = 'call';
-}
-if ($product['QUANTITY'] < LOW_STOCK_THRESHOLD) { /* ... */ }
-```
-
-### Полиморфизм вместо switch / if-elseif
-
-**ПЛОХО — РАЗРАСТАЮЩИЙСЯ SWITCH**
-```php
-class ActivityHandler {
- public function process($activityType, $data) {
- if ($activityType === 'CALL') {
- // логика звонка
- } elseif ($activityType === 'EMAIL') {
- // логика письма
- } elseif ($activityType === 'MEETING') {
- // логика встречи — и так далее...
- } else {
- throw new \Exception('Unknown type');
- }
- }
-}
-```
-
-**ХОРОШО — ПАТТЕРН STRATEGY + REGISTRY**
-```php
-interface ActivityProcessor {
- public function process(array $data): void;
-}
-
-class CallProcessor implements ActivityProcessor { /* ... */ }
-class EmailProcessor implements ActivityProcessor { /* ... */ }
-
-class ActivityProcessorRegistry {
- private array $processors = [];
-
- public function register(string $type, ActivityProcessor $p): void {
- $this->processors[$type] = $p;
- }
-
- public function get(string $type): ActivityProcessor {
- return $this->processors[$type]
- ?? throw new \InvalidArgumentException("Unknown activity type: {$type}");
- }
-}
-```
-
-### match вместо if-elseif (PHP 8+)
-
-**ПЛОХО**
-```php
-if ($stage === 'NEW') {
- $label = 'Новый';
-} elseif ($stage === 'IN_PROGRESS') {
- $label = 'В работе';
-} else {
- $label = 'Завершён';
-}
-```
-
-**ХОРОШО — MATCH СО СТРОГИМ СРАВНЕНИЕМ**
-```php
-$label = match ($stage) {
- 'NEW' => 'Новый',
- 'IN_PROGRESS' => 'В работе',
- default => 'Завершён',
-};
-```
-
----
-
-## РАЗДЕЛ 07. Типизация и строгость
-
-### declare(strict_types=1) — обязательно в каждом файле
-
-**ПЛОХО — НЕЯВНОЕ ПРИВЕДЕНИЕ ТИПОВ**
-```php
-<?php
-// нет strict_types — PHP молча приведёт "5" к int
-function add(int $a, int $b): int {
- return $a + $b;
-}
-echo add("5", "10"); // 15 — ошибка замаскирована
-```
-
-**ХОРОШО — ЯВНЫЙ TYPEERROR ПРИ НЕВЕРНЫХ ТИПАХ**
-```php
-<?php
-declare(strict_types=1);
-
-function add(int $a, int $b): int {
- return $a + $b;
-}
-echo add("5", "10"); // TypeError — сразу видим проблему
-```
-
-### Типизация свойств, аргументов и возвращаемых значений
-
-**ПЛОХО — PHPDOC ВМЕСТО НАТИВНОЙ ТИПИЗАЦИИ**
-```php
-class ClientInfo {
- /** @var string|null */
- private $name;
-
- public function setName($name) {
- $this->name = $name;
- }
-
- public function getName() {
- return $this->name;
- }
-}
-```
-
-**ХОРОШО — НАТИВНАЯ ТИПИЗАЦИЯ PHP 8**
-```php
-class ClientInfo {
- private ?string $name = null;
-
- public function setName(string $name): void {
- $this->name = $name;
- }
-
- public function getName(): ?string {
- return $this->name;
- }
-}
-```
-
-### readonly классы для DTO (PHP 8.2+)
-
-**ПЛОХО — ИЗМЕНЯЕМЫЙ МАССИВ ВМЕСТО DTO**
-```php
-function updateClient(int $id, array $data): void {
- $client = $this->getById($id);
- $client['NAME'] = $data['NAME'] ?? $client['NAME'];
- // легко ошибиться с ключом, нет типобезопасности
-}
-```
-
-**ХОРОШО — НЕИЗМЕНЯЕМЫЙ READONLY DTO**
-```php
-readonly class UpdateClientDto {
- public function __construct(
- public string $name,
- public ?string $phone = null,
- public ?string $email = null,
- ) {}
-}
-
-function updateClient(int $id, UpdateClientDto $dto): void {
- $client = $this->getById($id);
- $client->setName($dto->name);
-}
-```
-
----
-
-## РАЗДЕЛ 08. Обработка ошибок и Result Object
-
-### Исключения вместо возврата false/null
-
-**ПЛОХО — СТАРЫЙ ПОДХОД BITRIX С ГЛОБАЛЬНЫМ СОСТОЯНИЕМ**
-```php
-$leadId = \CCrmLead::Add(['TITLE' => '']);
-if (!$leadId) {
- $error = \CCrmLead::GetLastError(); // глобальное состояние
-}
-```
-
-**ХОРОШО — ДОМЕННЫЕ ИСКЛЮЧЕНИЯ**
-```php
-class LeadCreationException extends \RuntimeException {}
-
-try {
- $lead = $this->leadService->createFromDto($dto);
-} catch (LeadCreationException $e) {
- $this->logger->error($e->getMessage());
-}
-```
-
-### Иерархия доменных исключений
-
-**ПЛОХО — БАЗОВЫЙ \EXCEPTION БЕЗ КОНТЕКСТА**
-```php
-throw new \Exception('Неверный email');
-// caller не знает, какое именно исключение ловить
-```
-
-**ХОРОШО — ИЕРАРХИЯ ДОМЕННЫХ ИСКЛЮЧЕНИЙ**
-```php
-class MyModuleException extends \RuntimeException {}
-class InvalidEmailException extends \DomainException {}
-class LeadNotFoundException extends \Bitrix\Main\ObjectNotFoundException {}
-class LeadValidationException extends MyModuleException {}
-
-throw new InvalidEmailException('Email имеет неверный формат: ' . $email);
-```
-
-### Нативный Result Object Bitrix
-
-Result Object — нативный паттерн Bitrix для операций уровня сервиса. Вы можете использовать его там, где нужно вернуть данные или набор ошибок без исключений.
-
-**ПЛОХО — ВОЗВРАТ NULL ПРИ ОШИБКЕ, НЕТ ДЕТАЛЕЙ**
-```php
-public function create(CreateLeadDto $dto): ?int {
- $lead = $factory->createItem();
- $lead->setTitle($dto->title);
- $result = $factory->getCreateOperation($lead)->launch();
- if (!$result->isSuccess()) {
- return null; // caller не знает, что именно пошло не так
- }
- return $lead->getId();
-}
-```
-
-**ХОРОШО — RESULT С ДЕТАЛЯМИ ОШИБОК**
-```php
-public function create(CreateLeadDto $dto): \Bitrix\Main\Result {
- $result = new \Bitrix\Main\Result();
- try {
- $factory = \Bitrix\Crm\Service\Container::getInstance()
- ->getFactory(\CCrmOwnerType::Lead);
- $lead = $factory->createItem();
- $lead->setTitle($dto->title);
- $opResult = $factory->getCreateOperation($lead)->launch();
- if (!$opResult->isSuccess()) {
- return $result->addErrors($opResult->getErrors());
- }
- $result->setData(['id' => $lead->getId()]);
- } catch (\Throwable $e) {
- $result->addError(new \Bitrix\Main\Error($e->getMessage(), $e->getCode()));
- }
- return $result;
-}
-
-// Использование:
-$result = $leadService->create($dto);
-if ($result->isSuccess()) {
- $leadId = $result->getData()['id'];
-} else {
- foreach ($result->getErrors() as $error) {
- $logger->error($error->getMessage());
- }
-}
-```
-
----
-
-## РАЗДЕЛ 09. Безопасность
-
-### ORM вместо raw SQL — никаких переменных в строке запроса
-
-**ПЛОХО — SQL-ИНЪЕКЦИЯ ДАЖЕ С FORSQL**
-```php
-global $DB;
-$email = $_GET['email'];
-$sql = "SELECT * FROM b_user WHERE EMAIL = '" . $DB->ForSql($email) . "'";
-$res = $DB->Query($sql);
-```
-
-**ХОРОШО — ORM С ПАРАМЕТРИЗОВАННЫМ ЗАПРОСОМ**
-```php
-use Bitrix\Main\UserTable;
-
-$email = $_GET['email'] ?? '';
-$user = UserTable::query()
- ->setSelect(['ID', 'NAME', 'LAST_NAME'])
- ->where('EMAIL', $email) // параметр передаётся безопасно
- ->setLimit(1)
- ->exec()
- ->fetch();
-```
-
-### Экранирование вывода в HTML
-
-**ПЛОХО — XSS УЯЗВИМОСТЬ**
-```php
-echo "Привет, " . $arUser['NAME'];
-```
-
-**ХОРОШО — HTMLFILTER::ENCODE()**
-```php
-use Bitrix\Main\Text\HtmlFilter;
-
-echo "Привет, " . HtmlFilter::encode($arUser['NAME']);
-```
-
-### Валидация входящих данных
-
-`FILTER_SANITIZE_SPECIAL_CHARS` и `FILTER_SANITIZE_STRING` удалены в PHP 8.1–8.2. Используйте явную санитизацию.
-
-**ПЛОХО — УДАЛЁННЫЕ КОНСТАНТЫ PHP**
-```php
-// FILTER_SANITIZE_SPECIAL_CHARS удалён в PHP 8.2!
-$phone = filter_input(INPUT_POST, 'PHONE', FILTER_SANITIZE_SPECIAL_CHARS);
-```
-
-**ХОРОШО — ЯВНАЯ САНИТИЗАЦИЯ ДЛЯ PHP 8.2+**
-```php
-$phone = trim((string)($_POST['PHONE'] ?? ''));
-$phone = strip_tags($phone);
-if (!preg_match('/^\+?\d{10,15}$/', $phone)) {
- throw new \InvalidArgumentException('Некорректный номер телефона');
-}
-
-// Для email — нативный PHP-фильтр (не удалён):
-$email = filter_input(INPUT_POST, 'EMAIL', FILTER_VALIDATE_EMAIL);
-if ($email === false || $email === null) {
- throw new \InvalidArgumentException('Некорректный email');
-}
-```
-
----
-
-## РАЗДЕЛ 10. Базы данных и ORM Битрикс
-
-### Репозиторий инкапсулирует работу с данными
-
-**ПЛОХО — ORM-ЗАПРОСЫ РАЗБРОСАНЫ ПО СЕРВИСАМ**
-```php
-class DealReportService {
- public function getWonDealsSum(): float {
- // прямой вызов ORM из сервиса без абстракции
- $result = \Bitrix\Crm\DealTable::getList([
- 'filter' => ['=STAGE_SEMANTIC_ID' => 'S'],
- 'select' => ['OPPORTUNITY_SUM'],
- 'runtime' => [
- new \Bitrix\Main\Entity\ExpressionField(
- 'OPPORTUNITY_SUM',
- 'SUM(%s)',
- 'OPPORTUNITY'
- )
- ]
- ])->fetch();
- return (float)($result['OPPORTUNITY_SUM'] ?? 0);
- }
-}
-```
+# PHP Team Code Standards
-**ХОРОШО — ПАТТЕРН REPOSITORY**
-```php
-interface DealRepository {
- public function getTotalWonOpportunity(): float;
-}
+> Корпоративные правила написания кода для команды Битрикс24-разработки
-class OrmDealRepository implements DealRepository {
- public function getTotalWonOpportunity(): float {
- $result = \Bitrix\Crm\DealTable::query()
- ->addSelect(
- new \Bitrix\Main\Entity\ExpressionField(
- 'SUM_OPP',
- 'SUM(%s)',
- 'OPPORTUNITY'
- )
- )
- ->where('STAGE_SEMANTIC_ID', 'S')
- ->exec()->fetch();
- return (float)($result['SUM_OPP'] ?? 0);
- }
-}
-
-class DealReportService {
- public function __construct(private DealRepository $dealRepo) {}
-
- public function getWonDealsSum(): float {
- return $this->dealRepo->getTotalWonOpportunity();
- }
-}
-```
-
-### Транзакции для согласованности данных
-
-**ПЛОХО — НЕТ ТРАНЗАКЦИИ, ДАННЫЕ МОГУТ РАССОГЛАСОВАТЬСЯ**
-```php
-\Bitrix\Crm\DealTable::update($dealId, ['STAGE_ID' => 'WON']);
-\Bitrix\Crm\InvoiceTable::add([/* ... */]); // если упадёт — сделка уже обновлена
-```
-
-**ХОРОШО — АТОМАРНОЕ ОБНОВЛЕНИЕ С ROLLBACK**
-```php
-$connection = \Bitrix\Main\Application::getConnection();
-$connection->startTransaction();
-try {
- \Bitrix\Crm\DealTable::update($dealId, ['STAGE_ID' => 'WON']);
- \Bitrix\Crm\InvoiceTable::add([/* ... */]);
- $connection->commitTransaction();
-} catch (\Exception $e) {
- $connection->rollbackTransaction();
- throw $e;
-}
-```
-
-### Решение проблемы N+1 через JOIN в ORM
-
-**ПЛОХО — N+1 ЗАПРОСОВ**
-```php
-$deals = \Bitrix\Crm\DealTable::getList(['select' => ['ID','CONTACT_ID']])->fetchAll();
-foreach ($deals as $deal) {
- // N дополнительных запросов!
- $contact = \Bitrix\Crm\ContactTable::getByPrimary($deal['CONTACT_ID'])->fetch();
-}
-```
-
-**ХОРОШО — ПРЕДЗАГРУЗКА ЧЕРЕЗ REGISTERRUNTIMEFIELD**
-```php
-$deals = \Bitrix\Crm\DealTable::query()
- ->setSelect(['ID', 'TITLE', 'CONTACT.NAME', 'CONTACT.LAST_NAME'])
- ->registerRuntimeField(
- new \Bitrix\Main\Entity\ReferenceField(
- 'CONTACT',
- \Bitrix\Crm\ContactTable::class,
- \Bitrix\Main\ORM\Query\Join::on('this.CONTACT_ID', 'ref.ID'),
- )
- )
- ->where('STAGE_ID', 'EXECUTING')
- ->setOrder(['DATE_CREATE' => 'DESC'])
- ->exec()->fetchCollection();
-
-foreach ($deals as $deal) {
- $name = $deal->get('CONTACT.NAME'); // уже загружен, доп. запросов нет
-}
-```
-
-### fetchCollection() и работа с объектами
-
-**ПЛОХО — НЕСУЩЕСТВУЮЩИЙ МЕТОД КОЛЛЕКЦИИ**
-```php
-$leadCollection = \Bitrix\Crm\LeadTable::getList(['select' => ['*']])->fetchCollection();
-$titles = $leadCollection->getTitleList(); // метод не существует!
-```
-
-**ХОРОШО — ГЕТТЕРЫ ЧЕРЕЗ GETALL()**
-```php
-$leadCollection = \Bitrix\Crm\LeadTable::query()
- ->setSelect(['ID', 'TITLE'])
- ->exec()->fetchCollection();
-
-$titles = array_map(
- fn($lead) => $lead->getTitle(),
- $leadCollection->getAll(),
-);
-```
+[](https://www.php.net/)
+[](https://www.bitrix24.ru/)
+[](https://www.php-fig.org/psr/psr-12/)
+[](./src/index.md)
---
-## РАЗДЕЛ 11. Производительность: кеш и TaggedCache
-
-### Кеширование через Bitrix\Main\Data\Cache
-
-**ПЛОХО — ДОРОГОЙ ЗАПРОС БЕЗ КЕША**
-```php
-// Выполняется на каждый запрос страницы
-$elements = \CIBlockElement::GetList(
- [], ['IBLOCK_ID' => $iblockId], false, false,
- ['ID','NAME']
-)->FetchAll();
-```
-
-**ХОРОШО — РЕЗУЛЬТАТ КЕШИРУЕТСЯ**
-```php
-$cache = \Bitrix\Main\Data\Cache::createInstance();
-$cacheId = 'iblock_elements_' . $iblockId;
-
-if ($cache->initCache(3600, $cacheId, '/my_module/')) {
- $elements = $cache->getVars();
-} elseif ($cache->startDataCache()) {
- $elements = \Bitrix\Iblock\Elements\ElementNewsTable::getList([
- 'filter' => ['IBLOCK_ID' => $iblockId],
- ])->fetchAll();
- $cache->endDataCache($elements);
-}
-```
-
-### Тегированный кеш (TaggedCache)
-
-TaggedCache позволяет инвалидировать кеш при изменении конкретных сущностей. Предпочтительный подход для данных CRM и Iblock.
-
-**ПЛОХО — СБРОС ВСЕГО КЕША ПРИ ИЗМЕНЕНИИ ОДНОГО ЛИДА**
-```php
-// При любом изменении сбрасываем весь кеш модуля
-\Bitrix\Main\Data\Cache::createInstance()->cleanDir('/crm/leads/');
-```
+## О репозитории
-**ХОРОШО — ТОЧЕЧНАЯ ИНВАЛИДАЦИЯ ПО ТЕГУ**
-```php
-$taggedCache = \Bitrix\Main\Application::getInstance()->getTaggedCache();
-$cache = \Bitrix\Main\Data\Cache::createInstance();
+Этот репозиторий содержит **официальный стандарт написания кода PHP-команды**. Документация охватывает весь стек разработки на платформе Битрикс24: от соглашений по именованию до архитектурных паттернов, ORM, кеширования, агентов и логирования.
-if ($cache->initCache(3600, 'lead_' . $leadId, '/crm/leads/')) {
- $data = $cache->getVars();
-} elseif ($cache->startDataCache()) {
- $taggedCache->startTagCache('/crm/leads/');
- $taggedCache->registerTag('crm_lead_' . $leadId);
- $taggedCache->registerTag('crm_leads_list');
- $data = $this->leadRepository->getById($leadId);
- $taggedCache->endTagCache();
- $cache->endDataCache($data);
-}
-
-// При изменении лида — только его кеш:
-$taggedCache->clearByTag('crm_lead_' . $leadId);
-
-// При массовом изменении:
-$taggedCache->clearByTag('crm_leads_list');
-```
-
-### Выбирайте только нужные поля
-
-**ПЛОХО — SELECT \* БЕЗ НЕОБХОДИМОСТИ**
-```php
-$leads = \Bitrix\Crm\LeadTable::getList(['select' => ['*']])->fetchAll();
-```
-
-**ХОРОШО — ТОЛЬКО НЕОБХОДИМЫЕ ПОЛЯ**
-```php
-$leads = \Bitrix\Crm\LeadTable::query()
- ->setSelect(['ID', 'TITLE', 'STATUS_ID', 'DATE_CREATE'])
- ->setLimit(50)
- ->exec()->fetchAll();
-```
+Руководство опирается на принципы **SOLID, DRY, KISS, YAGNI** и адаптировано под особенности Битрикс24-разработки (ORM Bitrix, CRM API, EventManager, AbstractAgent).
----
+### Для кого
-## РАЗДЕЛ 12. Тестирование
+- **Разработчики** — базовый ориентир при написании и ревью кода
+- **Тимлиды** — чеклист для код-ревью и онбординга новых участников
+- **Новые члены команды** — точка входа в стандарты проекта
-### Код должен быть тестируемым — изолируйте зависимости
+### Структура документации
-**ПЛОХО — ПРЯМОЙ ВЫЗОВ API, НЕЛЬЗЯ ПРОТЕСТИРОВАТЬ**
-```php
-class LeadService {
- public function create(array $data): int {
- return \CCrmLead::Add($data); // нельзя замокать
- }
-}
```
-
-**ХОРОШО — РЕПОЗИТОРИЙ ЧЕРЕЗ ИНТЕРФЕЙС**
-```php
-interface LeadRepository {
- public function save(Lead $lead): void;
-}
-
-class LeadService {
- public function __construct(private LeadRepository $repo) {}
-
- public function create(LeadDto $dto): Lead {
- $lead = new Lead($dto);
- $this->repo->save($lead);
- return $lead;
- }
-}
-
-// PHPUnit тест:
-class LeadServiceTest extends \PHPUnit\Framework\TestCase {
- public function testCreateLead(): void {
- $mockRepo = $this->createMock(LeadRepository::class);
- $mockRepo->expects($this->once())->method('save');
- $service = new LeadService($mockRepo);
- $lead = $service->create(new LeadDto(title: 'Test Lead'));
- $this->assertSame('Test Lead', $lead->getTitle());
- }
-}
+README.md ← описание репозитория (этот файл)
+src/
+ index.md ← вступление и полное оглавление
+ 01-naming.md
+ 02-functions.md
+ 03-classes.md
+ 04-controllers.md
+ 05-arrays.md
+ 06-conditions.md
+ 07-typing.md
+ 08-errors.md
+ 09-security.md
+ 10-database.md
+ 11-performance.md
+ 12-testing.md
+ 13-comments.md
+ 14-general.md
+ 15-events.md
+ 16-logging.md
+ 17-agents.md
+ tips/
+ index.md ← список тематических статей
+ isset-vs-empty.md
```
-### Data Providers для параметризованных тестов
+### Быстрый старт
-**ПЛОХО — ДУБЛИРОВАНИЕ ТЕСТОВЫХ МЕТОДОВ**
-```php
-public function testSmallOrderDiscount(): void {
- $this->assertSame(0.0, (new DiscountCalculator())->calculate(5000.0));
-}
-
-public function testMediumOrderDiscount(): void {
- $this->assertSame(0.05, (new DiscountCalculator())->calculate(50000.0));
-}
-// и так далее...
-```
-
-**ХОРОШО — @DATAPROVIDER**
-```php
-class DiscountCalculatorTest extends \PHPUnit\Framework\TestCase {
- /** @dataProvider discountProvider */
- public function testCalculate(float $amount, float $expected): void {
- $this->assertSame($expected, (new DiscountCalculator())->calculate($amount));
- }
-
- public static function discountProvider(): array {
- return [
- 'small order' => [5000.0, 0.0],
- 'medium order' => [50000.0, 0.05],
- 'large order' => [150000.0, 0.15],
- ];
- }
-}
-```
+📖 **[Читать документацию → src/index.md](./src/index.md)**
---
-## РАЗДЕЛ 13. Комментирование и документация
+## Версии
-### Код должен быть самодокументированным
-
-Комментарии объясняют *«почему»*, а не *«что»*. Используйте выразительные имена.
-
-**ПЛОХО — КОММЕНТАРИЙ ОПИСЫВАЕТ ОЧЕВИДНОЕ**
-```php
-// умножаем цену на количество
-$sum = $price * $qty;
-```
-
-**ХОРОШО — ИМЯ ГОВОРИТ САМО ЗА СЕБЯ, КОММЕНТАРИЙ ОБЪЯСНЯЕТ БИЗНЕС-ПРАВИЛО**
-```php
-$totalPrice = $product->getPrice() * $product->getQuantity();
-// Скидка для VIP-клиентов введена с Q1 2024 (см. задачу PROJ-1234)
-if ($client->isVip()) {
- $totalPrice *= (1 - self::VIP_DISCOUNT_RATE);
-}
-```
-
-### PHPDoc для публичных API
-
-**ПЛОХО — НЕТ ДОКУМЕНТАЦИИ, НЕЯСНЫ ТИПЫ И ИСКЛЮЧЕНИЯ**
-```php
-public function find($id) {
- // ...
-}
-```
-
-**ХОРОШО — ПОЛНЫЙ PHPDOC**
-```php
-/**
- * Находит сделку по идентификатору.
- *
- * @param positive-int $id Идентификатор сделки в CRM
- * @return \Bitrix\Crm\Item
- *
- * @throws \Bitrix\Main\ObjectNotFoundException если сделка не найдена
- * @throws \Bitrix\Main\SystemException при ошибке базы данных
- */
-public function find(int $id): \Bitrix\Crm\Item { /* ... */ }
-```
+| Версия | Дата | Изменения |
+|--------|------|-----------|
+| 2.0 | 2026 | Исправленная и дополненная редакция |
+| 1.0 | — | Первая версия |
---
-## РАЗДЕЛ 14. Общие практики (DRY, KISS, YAGNI, Закон Деметры)
-
-### DRY — не дублируйте знания
-
-**ПЛОХО — ОДНА И ТА ЖЕ ЛОГИКА В ДВУХ МЕСТАХ**
-```php
-// В компоненте A:
-$lead = \Bitrix\Crm\LeadTable::getByPrimary($id)->fetchObject();
-if (!$lead) {
- throw new \Exception('Лид не найден');
-}
-
-// В компоненте B:
-$lead = \Bitrix\Crm\LeadTable::getByPrimary($id)->fetchObject();
-if (!$lead) {
- throw new \Exception('Лид не найден');
-}
-```
-
-**ХОРОШО — ОБЩАЯ ЛОГИКА В LEADPROVIDER**
-```php
-class LeadProvider {
- public function getById(int $id): \Bitrix\Crm\Item {
- $lead = \Bitrix\Crm\LeadTable::getByPrimary($id)->fetchObject();
- if (!$lead) {
- throw new \Bitrix\Main\ObjectNotFoundException("Лид #{$id} не найден");
- }
- return $lead;
- }
-}
-```
+## Вклад в документацию
-### Закон Деметры — не разговаривай с незнакомцами
-
-**ПЛОХО — ЦЕПОЧКА ЧЕРЕЗ ГРАНИЦЫ ОБЪЕКТОВ**
-```php
-$city = $deal->getContact()->getCompany()->getAddress()->getCity();
-```
-
-**ХОРОШО — ДЕЛЕГИРУЮЩИЙ МЕТОД В DEAL**
-```php
-// Вызов:
-$city = $deal->getContactCompanyCity();
-
-// Реализация внутри класса Deal:
-public function getContactCompanyCity(): ?string {
- return $this->getContact()?->getCompany()?->getCity();
-}
-```
-
-### KISS — простое решение лучше сложного
-
-Не усложняйте без необходимости. Если задачу можно решить тремя строками — не пишите тридцать.
-
-### YAGNI — не добавляйте функциональность «на будущее»
-
-Реализуйте только то, что требуется прямо сейчас. Код, написанный «на перспективу», часто не используется и засоряет проект.
-
----
-
-## РАЗДЕЛ 15. События Bitrix (EventManager)
-
-События — ключевой механизм расширения Битрикс без изменения ядра. Тонкие подписчики и делегирование в сервис — обязательный стандарт.
-
-### Тонкий обработчик — логика в сервисе
-
-**ПЛОХО — ВСЯ ЛОГИКА В ТЕЛЕ ОБРАБОТЧИКА**
-```php
-AddEventHandler('crm', 'OnAfterCrmLeadAdd', function(array &$fields): void {
- // 50+ строк: отправка писем, запись в лог, вызов внешних API...
- $mailer = new \PHPMailer\PHPMailer\PHPMailer();
- $mailer->setFrom(['no-reply@example.com']);
- $mailer->addAddress($fields['EMAIL']);
- // ...
-});
-```
-
-**ХОРОШО — ПОДПИСЧИК ДЕЛЕГИРУЕТ В СЕРВИС**
-```php
-class LeadEventSubscriber {
- /** Обработчик намеренно тонкий — вся логика в сервисе. */
- public static function onAfterLeadAdd(array &$fields): void {
- \Bitrix\Main\DI\ServiceLocator::getInstance()
- ->get(LeadNotificationService::class)
- ->notifyOnLeadCreated((int)$fields['ID']);
- }
-}
-
-// Регистрация в init.php:
-\Bitrix\Main\EventManager::getInstance()->addEventHandler(
- 'crm',
- 'OnAfterCrmLeadAdd',
- [LeadEventSubscriber::class, 'onAfterLeadAdd'],
-);
-```
-
-### Современный API событий (Bitrix\Main\Event)
-
-**ОБЪЕКТНО-ОРИЕНТИРОВАННЫЙ EVENT API**
-```php
-\Bitrix\Main\EventManager::getInstance()->addEventHandlerCompatible(
- 'crm',
- '\Bitrix\Crm\Service\Operation\Add::onAfterRun',
- static function (\Bitrix\Main\Event $event): \Bitrix\Main\EventResult {
- $lead = $event->getParameter('ITEM');
- if ($lead instanceof \Bitrix\Crm\Item\Lead) {
- \Bitrix\Main\DI\ServiceLocator::getInstance()
- ->get(LeadNotificationService::class)
- ->notifyOnLeadCreated($lead->getId());
- }
- return new \Bitrix\Main\EventResult(\Bitrix\Main\EventResult::SUCCESS);
- }
-);
-```
-
----
-
-## РАЗДЕЛ 16. Логирование (PSR-3)
-
-Инжектируйте PSR-3 LoggerInterface. Глобальные функции `AddMessage2Log()` — устаревший подход без уровней и контекста.
-
-### PSR-3 логирование через Bitrix\Main\Diag\Logger
-
-**ПЛОХО — УСТАРЕВШИЕ ФУНКЦИИ БЕЗ УРОВНЕЙ**
-```php
-AddMessage2Log('Ошибка создания лида: ' . $message, 'my_module');
-// нет уровней severity, нет структурированного контекста, сложно искать
-```
-
-**ХОРОШО — PSR-3 С УРОВНЯМИ И КОНТЕКСТОМ**
-```php
-$logger->info('Lead created', ['leadId' => $lead->getId(), 'userId' => \CUser::GetID()]);
-
-$logger->error('Lead creation failed', [
- 'message' => $e->getMessage(),
- 'code' => $e->getCode(),
- 'trace' => $e->getTraceAsString(),
-]);
-```
-
-### Инжектируйте LoggerInterface, а не конкретную реализацию
-
-**ПЛОХО — КОНКРЕТНАЯ РЕАЛИЗАЦИЯ В КОНСТРУКТОРЕ**
-```php
-class LeadCreationService {
- private \Bitrix\Main\Diag\Logger $logger;
-
- public function __construct() {
- // нельзя подменить в тестах, привязка к реализации
- $this->logger = \Bitrix\Main\Diag\Logger::create('crm', '/local/logs/crm.log');
- }
-}
-```
-
-**ХОРОШО — PSR-3 ИНТЕРФЕЙС ЧЕРЕЗ DI**
-```php
-use Psr\Log\LoggerInterface;
-
-class LeadCreationService {
- public function __construct(
- private \Bitrix\Crm\Service\Factory $factory,
- private LoggerInterface $logger,
- ) {}
-
- public function create(CreateLeadDto $dto): \Bitrix\Main\Result {
- $this->logger->debug('Creating lead', ['title' => $dto->title]);
- // ...
- }
-}
-
-// Регистрация в ServiceLocator:
-ServiceLocator::getInstance()->addInstanceLazy(
- LoggerInterface::class,
- fn() => \Bitrix\Main\Diag\Logger::create(
- 'app',
- '/local/logs/app_' . date('Y-m-d') . '.log'
- )
-);
-```
-
----
-
-## РАЗДЕЛ 17. Агенты — AbstractAgent (полный паттерн)
-
-В проекте используется абстрактный базовый класс **AbstractAgent**, который инкапсулирует всю инфраструктуру агентов: регистрацию, запуск, обработку ошибок и повторный вызов. Конкретные агенты реализуют только метод `execute()`.
-
-### Архитектура: AbstractAgent
-
-Базовый класс решает все инфраструктурные задачи — конкретный агент содержит только бизнес-логику.
-
-**ABSTRACTAGENT — БАЗОВЫЙ КЛАСС (APP\AGENTS)**
-```php
-<?php
-declare(strict_types=1);
-
-namespace App\Agents;
-
-use App\Logger;
-use Bitrix\Main\SystemException;
-use Bitrix\Main\Type\DateTime;
-use CAgent;
-use DateTimeZone;
-use Exception;
-use Psr\Log\LoggerInterface;
-use RuntimeException;
-
-/**
- * Абстрактный класс для создания агентов Битрикс24.
- * Конкретный агент обязан реализовать только метод execute().
- */
-abstract class AbstractAgent
-{
- protected const DEFAULT_PRIORITY = 100;
- protected const DEFAULT_INTERVAL = 86400; // 24 часа
- protected const MODULE_ID = 'main';
- protected const EXEC_DATETIME_HOUR = 4;
- protected const EXEC_DATETIME_MINUTE = 0;
- protected const IS_PERIODICAL_AGENT = 'Y';
-
- protected LoggerInterface $logger;
-
- /**
- * Выполнение основной логики агента.
- * Должен вернуть строку следующего вызова.
- *
- * @throws Exception
- */
- abstract public function execute(): string;
-
- /** Интервал выполнения в секундах (можно переопределить). */
- protected function getInterval(): int {
- return static::DEFAULT_INTERVAL;
- }
-
- /** Приоритет агента. */
- protected function getPriority(): int {
- return static::DEFAULT_PRIORITY;
- }
-
- /** Модуль, к которому привязан агент. */
- protected function getModuleId(): string {
- return static::MODULE_ID;
- }
-
- /** Тип агента (периодический / разовый). */
- protected function getType(): string {
- return static::IS_PERIODICAL_AGENT;
- }
-
- /** Время первого запуска агента (по московскому времени). */
- protected function getExecDateTime(): int
- {
- $dateTime = new DateTime();
- $dateTime->setTimeZone(new DateTimeZone('Europe/Moscow'));
- return $dateTime
- ->setTime(static::EXEC_DATETIME_HOUR, static::EXEC_DATETIME_MINUTE)
- ->getTimestamp();
- }
-
- /**
- * Проверка предусловий.
- * Переопределите в конкретном агенте при необходимости.
- *
- * @throws RuntimeException
- */
- protected function checkConditions(): void {}
-
- /** Логирование ошибки выполнения. */
- protected function handleError(Exception $e): void
- {
- $this->logger = Logger::getInstance(module: $this->getModuleId());
- $this->logger->error($e->getMessage(), $e->getTrace());
- }
-
- /** Строка повторного вызова агента (регистрируется в Битрикс). */
- protected function createNextCall(): string
- {
- return static::class . '::agent();';
- }
-
- /**
- * Статический точка входа агента — именно эта строка регистрируется в CAgent.
- * Не содержит бизнес-логики: только запуск и перехват исключений.
- */
- public static function agent(): string
- {
- $instance = new static();
- try {
- $instance->checkConditions();
- return $instance->execute();
- } catch (Exception $e) {
- $instance->handleError($e);
- return $instance->createNextCall();
- }
- }
-
- /**
- * Регистрация агента в системе Битрикс.
- * Вызывается при установке модуля (DoInstall).
- *
- * @throws SystemException
- */
- public static function register(): bool
- {
- $instance = new static();
- $execTime = $instance->getExecDateTime();
- static::unregister(); // удаляем дубли
-
- $agentId = CAgent::AddAgent(
- $instance->createNextCall(),
- $instance->getModuleId(),
- $instance->getType(),
- $instance->getInterval(),
- ConvertTimeStamp($execTime, 'FULL'),
- 'Y',
- ConvertTimeStamp($execTime, 'FULL'),
- $instance->getPriority(),
- );
-
- if (!$agentId) {
- throw new SystemException(
- 'Ошибка регистрации агента: ' . static::class
- );
- }
-
- return true;
- }
-
- /** Удаление агента из системы (вызывается при деинсталляции). */
- public static function unregister(): void
- {
- $instance = new static();
- CAgent::RemoveAgent($instance->createNextCall(), $instance->getModuleId());
- }
-
- /** Проверка, зарегистрирован ли агент. */
- public static function isRegistered(): bool
- {
- $instance = new static();
- $agent = CAgent::GetList(
- [],
- ['NAME' => $instance->createNextCall(), 'MODULE_ID' => $instance->getModuleId()]
- )->Fetch();
- return $agent !== false;
- }
-}
-```
-
-### Пример конкретного агента
-
-Конкретный агент наследует AbstractAgent и реализует только метод `execute()`. Вся инфраструктура — регистрация, перехват ошибок, повторный вызов — унаследована.
-
-**ПЛОХО — ПРОЦЕДУРНЫЙ АГЕНТ, ВСЯ ЛОГИКА В ОДНОЙ ФУНКЦИИ**
-```php
-// agents.php в модуле
-function DaSkud_ImportAgent(): string {
- // 80 строк: создание клиентов, запросы к API, запись в БД...
- $skudClient = new SkudV2Client(/* параметры хардкодом */);
- $end = new DateTime();
- $start = (clone $end)->modify('-1 day');
- $res = $skudClient->getAttendance($start->format('Y-m-d'), $end->format('Y-m-d'));
- foreach ($res as $record) {
- \CIBlockElement::Add([/* ... */]);
- }
- return __FUNCTION__ . '();';
-}
-```
-
-**ХОРОШО — КОНКРЕТНЫЙ АГЕНТ С МИНИМАЛЬНОЙ РЕАЛИЗАЦИЕЙ**
-```php
-<?php
-declare(strict_types=1);
-
-namespace Da\Skud\Agents;
-
-use App\Agents\AbstractAgent;
-use Bitrix\Main\DI\ServiceLocator;
-use Da\Skud\ImportCommand;
-use Da\Skud\IntegrationInvoker;
-use Da\Skud\Services\BitrixService;
-use Da\Skud\Services\SkudV2Service;
-use Da\Skud\SkudV2Client;
-use DateTime;
-
-class SkudV2Agent extends AbstractAgent
-{
- // Переопределяем MODULE_ID для привязки к конкретному модулю
- public const MODULE_ID = 'da.skud';
-
- /**
- * Вся бизнес-логика инкапсулирована в сервисах.
- * Агент только оркестрирует вызов.
- */
- public function execute(): string
- {
- /** @var SkudV2Client $skudClient */
- $skudClient = ServiceLocator::getInstance()->get('da.skud.client');
- $end = new DateTime();
- $start = (clone $end)->modify('-1 day');
- $skudService = new SkudV2Service(
- $skudClient,
- $start->format('Y-m-d'),
- $end->format('Y-m-d'),
- );
- $bitrixService = new BitrixService($start->format('Y-m-d'));
- $command = new ImportCommand($skudService, $bitrixService);
- $invoker = new IntegrationInvoker();
- $invoker->submit($command);
- return $this->createNextCall(); // ОБЯЗАТЕЛЬНО
- }
-}
-```
-
-### Регистрация и управление агентом
-
-**ПЛОХО — РУЧНАЯ РЕГИСТРАЦИЯ С ДУБЛИРОВАНИЕМ ПАРАМЕТРОВ**
-```php
-// В DoInstall() — параметры хардкодом, легко ошибиться
-\CAgent::AddAgent(
- 'DaSkud_ImportAgent();',
- 'da.skud',
- 'Y',
- 86400,
- '',
- 'Y',
- \ConvertTimeStamp(time() + 86400, 'FULL'),
- 100
-);
-```
-
-**ХОРОШО — ЧЕРЕЗ ABSTRACTAGENT::REGISTER()**
-```php
-// install/index.php — метод DoInstall():
-use Da\Skud\Agents\SkudV2Agent;
-
-// Все параметры определены константами в классе агента
-SkudV2Agent::register();
-
-// Проверка перед регистрацией:
-if (!SkudV2Agent::isRegistered()) {
- SkudV2Agent::register();
-}
-
-// install/index.php — метод DoUninstall():
-SkudV2Agent::unregister();
-```
-
-### Обработка исключений в агентах — критическое правило
-
-**ВАЖНО:** Агент НЕ должен пробрасывать исключение наружу. Если `execute()` бросит необработанное исключение, Битрикс удалит агент из очереди и он перестанет выполняться. AbstractAgent перехватывает все исключения в методе `agent()` и логирует их — конкретный агент должен либо обрабатывать их внутри `execute()`, либо позволить AbstractAgent сделать это.
-
-**КАК ABSTRACTAGENT ЗАЩИЩАЕТ ОЧЕРЕДЬ АГЕНТОВ**
-```php
-// AbstractAgent::agent() — точка входа:
-public static function agent(): string
-{
- $instance = new static();
- try {
- $instance->checkConditions();
- return $instance->execute();
- } catch (Exception $e) {
- $instance->handleError($e);
- return $instance->createNextCall(); // НЕ удаляем агент из очереди
- }
-}
-```
\ No newline at end of file
+Нашли неточность или хотите добавить пример? Создайте PR или issue. Новые тематические статьи по конкретным конструкциям и паттернам добавляются в папку [`src/tips/`](./src/tips/index.md).
\ No newline at end of file
diff --git a/src/01-naming.md b/src/01-naming.md
new file mode 100755
index 0000000000000000000000000000000000000000..fc6f5302cc93dacb97c1ea0ffc613258326d3447
--- /dev/null
+++ b/src/01-naming.md
@@ -0,0 +1,54 @@
+# 01. Именование и читаемость
+
+[← Оглавление](./index.md)
+
+---
+
+## Осмысленные имена переменных, методов, классов
+
+Избегайте сокращений (кроме общепринятых: id, url, json), однобуквенных имён и абстрактных названий. Имя должно отражать суть сущности или действия.
+
+**ПЛОХО**
+```php
+function calc($d) {
+ $r = [];
+ foreach ($d as $x) {
+ if ($x['UF_ACTIVE']) {
+ $r[] = $x['UF_NAME'];
+ }
+ }
+ return $r;
+}
+```
+
+**ХОРОШО**
+```php
+/**
+ * Возвращает имена активных клиентов.
+ * @param array $clients
+ * @return string[]
+ */
+function getActiveClientNames(array $clients): array {
+ $names = [];
+ foreach ($clients as $client) {
+ if ($client['UF_ACTIVE'] === 'Y') {
+ $names[] = $client['UF_FULL_NAME'];
+ }
+ }
+ return $names;
+}
+```
+
+---
+
+## Единый стиль именования (PSR-12 + Bitrix-соглашения)
+
+| Сущность | Стиль | Пример |
+|---|---|---|
+| Классы | PascalCase | `ClientService` |
+| Методы / функции | camelCase | `getActiveClients()` |
+| Константы класса | UPPER_CASE | `MAX_RETRY_COUNT` |
+| Свойства | camelCase | `$firstName` |
+| Поля ORM Bitrix | snake_case (как в БД) | `STAGE_ID`, `UF_ACTIVE` |
+
+Пользовательские поля `UF_*` — как есть: `UF_FULL_NAME`
diff --git a/src/02-functions.md b/src/02-functions.md
new file mode 100755
index 0000000000000000000000000000000000000000..c875281af409b30c7e088332461c497292aec988
--- /dev/null
+++ b/src/02-functions.md
@@ -0,0 +1,107 @@
+# 02. Функции и методы
+
+[← Оглавление](./index.md)
+
+---
+
+## Одна функция — одна ответственность (SRP)
+
+**ПЛОХО**
+```php
+function processLeadAndSendNotification(array $data): void {
+ // валидация, сохранение в CRM, отправка письма, логирование — всё в одном месте
+}
+```
+
+**ХОРОШО**
+```php
+function validateLeadData(array $data): array { /* ... */ }
+function createLead(array $validatedData): \Bitrix\Crm\Item { /* ... */ }
+function sendLeadNotification(\Bitrix\Crm\Item $lead): void { /* ... */ }
+```
+
+---
+
+## Избегайте флаговых аргументов (boolean trap)
+
+**ПЛОХО**
+```php
+function addProductToDeal(int $dealId, array $productRow, bool $recalculate = true): void {
+ // добавление товара
+ if ($recalculate) {
+ \CCrmDeal::Recalculate($dealId);
+ }
+}
+```
+
+**ХОРОШО — ДВА ЯВНЫХ МЕТОДА**
+```php
+function addProductToDeal(int $dealId, array $productRow): void {
+ // только добавление товара
+}
+
+function addProductToDealAndRecalculate(int $dealId, array $productRow): void {
+ $this->addProductToDeal($dealId, $productRow);
+ \CCrmDeal::Recalculate($dealId);
+}
+```
+
+---
+
+## Количество параметров ≤ 3–4, иначе — DTO
+
+**ПЛОХО**
+```php
+function updateCompany(int $id, string $title, string $phone, string $email, ?string $address, bool $isActive) { /* ... */ }
+```
+
+**ХОРОШО — READONLY DTO (PHP 8.2+)**
+```php
+readonly class UpdateCompanyDto {
+ public function __construct(
+ public string $title,
+ public string $phone,
+ public string $email,
+ public ?string $address = null,
+ public bool $isActive = true,
+ ) {}
+}
+
+function updateCompany(int $id, UpdateCompanyDto $dto): void { /* ... */ }
+```
+
+---
+
+## Ранний возврат (early return)
+
+**ПЛОХО — ГЛУБОКАЯ ВЛОЖЕННОСТЬ**
+```php
+function getDiscountForDeal(\Bitrix\Crm\Item $deal): float {
+ $discount = 0.0;
+ if ($deal->getStageId() === 'EXECUTING') {
+ $client = $deal->getClient();
+ if ($client && $client->hasActiveSubscription()) {
+ if ($deal->getOpportunity() > 100000) {
+ $discount = 0.15;
+ } else {
+ $discount = 0.05;
+ }
+ }
+ }
+ return $discount;
+}
+```
+
+**ХОРОШО — FLAT-СТРУКТУРА**
+```php
+function getDiscountForDeal(\Bitrix\Crm\Item $deal): float {
+ if ($deal->getStageId() !== 'EXECUTING') {
+ return 0.0;
+ }
+ $client = $deal->getClient();
+ if (!$client || !$client->hasActiveSubscription()) {
+ return 0.0;
+ }
+ return $deal->getOpportunity() > 100000 ? 0.15 : 0.05;
+}
+```
diff --git a/src/03-classes.md b/src/03-classes.md
new file mode 100755
index 0000000000000000000000000000000000000000..0e36763282b3833506b79f6cecf134480699324e
--- /dev/null
+++ b/src/03-classes.md
@@ -0,0 +1,122 @@
+# 03. Классы и объекты (SOLID, DI, ServiceLocator)
+
+[← Оглавление](./index.md)
+
+---
+
+## Принцип единственной ответственности (SRP)
+
+**ПЛОХО — КЛАСС ДЕЛАЕТ ВСЁ СРАЗУ**
+```php
+class LeadManager {
+ public function create(array $data): void { /* ... */ }
+ public function sendEmail(string $message): void { /* ... */ }
+ public function log(string $action): void { /* ... */ }
+}
+```
+
+**ХОРОШО — РАЗДЕЛЕНИЕ ОТВЕТСТВЕННОСТЕЙ**
+```php
+class LeadCreationService {
+ public function __construct(
+ private \Bitrix\Crm\Service\Container $crmContainer,
+ private LeadNotificationService $notificationService,
+ private \Psr\Log\LoggerInterface $logger,
+ ) {}
+
+ public function createFromArray(array $data): \Bitrix\Crm\Item {
+ // только логика создания лида
+ }
+}
+```
+
+---
+
+## Предпочитайте композицию наследованию
+
+**ПЛОХО — НАСЛЕДОВАНИЕ ОТ УСТАРЕВШЕГО КЛАССА**
+```php
+class CustomDealProcessor extends \CCrmDeal {
+ public function process($dealId) {
+ $this->Update($dealId, ['STAGE_ID' => 'WON']);
+ }
+}
+```
+
+**ПРАВИЛЬНО — ЧЕРЕЗ CONTAINER**
+```php
+class DealProcessor {
+ public function __construct(
+ private \Bitrix\Crm\Service\Container $container,
+ ) {}
+
+ public function markAsWon(int $dealId): void {
+ $factory = $this->container->getFactory(\CCrmOwnerType::Deal);
+ $deal = $factory->getItem($dealId);
+ if ($deal === null) {
+ throw new \Bitrix\Main\ObjectNotFoundException("Deal #{$dealId} not found");
+ }
+ $deal->setStageId('WON');
+ $result = $factory->getUpdateOperation($deal)->launch();
+ if (!$result->isSuccess()) {
+ throw new \RuntimeException(implode(', ', $result->getErrorMessages()));
+ }
+ }
+}
+```
+
+---
+
+## ServiceLocator Bitrix для внедрения зависимостей
+
+**ПЛОХО — РУЧНОЕ СОЗДАНИЕ ЗАВИСИМОСТЕЙ ПОВСЮДУ**
+```php
+// В каждом месте использования:
+$logger = new FileLogger('/local/logs/app.log');
+$factory = \Bitrix\Crm\Service\Container::getInstance()
+ ->getFactory(\CCrmOwnerType::Lead);
+$service = new LeadCreationService($factory, new LeadNotificationService(), $logger);
+```
+
+**ХОРОШО — РЕГИСТРАЦИЯ ОДИН РАЗ, ПОЛУЧЕНИЕ ВЕЗДЕ**
+```php
+use Bitrix\Main\DI\ServiceLocator;
+
+// Один раз:
+$serviceLocator = ServiceLocator::getInstance();
+$serviceLocator->addInstanceLazy(
+ LeadCreationService::class,
+ static function () use ($serviceLocator): LeadCreationService {
+ return new LeadCreationService(
+ \Bitrix\Crm\Service\Container::getInstance(),
+ $serviceLocator->get(LeadNotificationService::class),
+ $serviceLocator->get(\Psr\Log\LoggerInterface::class),
+ );
+ }
+);
+
+// Получение в любом месте приложения:
+$service = ServiceLocator::getInstance()->get(LeadCreationService::class);
+```
+
+---
+
+## Избегайте статических методов для бизнес-логики
+
+**ПЛОХО — ПРЯМОЙ ВЫЗОВ УСТАРЕВШЕГО API**
+```php
+$leadId = \CCrmLead::Add(['TITLE' => 'Новый лид']);
+```
+
+**ХОРОШО — ОБОРАЧИВАЕМ В СЕРВИС**
+```php
+class LeadService {
+ public function __construct(private \Bitrix\Crm\Service\Factory $factory) {}
+
+ public function create(string $title): \Bitrix\Crm\Item {
+ $lead = $this->factory->createItem();
+ $lead->setTitle($title);
+ return $this->factory->getCreateOperation($lead)->launch()->getItem();
+ }
+}
+```
diff --git a/src/04-controllers.md b/src/04-controllers.md
new file mode 100755
index 0000000000000000000000000000000000000000..b7d7b6d53a66fb57d7b99136b35fdf679467b574
--- /dev/null
+++ b/src/04-controllers.md
@@ -0,0 +1,101 @@
+# 04. Контроллеры
+
+[← Оглавление](./index.md)
+
+---
+
+## Тонкие контроллеры — вся логика в сервисах
+
+**ПЛОХО — ЛОГИКА ПРЯМО В КОМПОНЕНТЕ**
+```php
+// result_modifier.php
+if ($arParams['USE_FILTER'] == 'Y') {
+ $filter = ['ACTIVE' => 'Y'];
+ if ($_REQUEST['search']) {
+ $filter['%NAME'] = $_REQUEST['search'];
+ }
+ $res = \CIBlockElement::GetList([], $filter, false, false, ['ID','NAME']);
+ while ($el = $res->Fetch()) {
+ $arResult['ITEMS'][] = $el;
+ }
+}
+```
+
+**ХОРОШО — СЕРВИС С КОРРЕКТНЫМ ORM API**
+```php
+class NewsService {
+ public function getFilteredNews(array $criteria): array {
+ $query = \Bitrix\Iblock\Elements\ElementNewsTable::query()
+ ->setFilter(['=ACTIVE' => 'Y'])
+ ->setSelect(['ID', 'NAME']);
+
+ if (!empty($criteria['search'])) {
+ $query->whereLike('NAME', '%' . $criteria['search'] . '%');
+ }
+ return $query->exec()->fetchAll();
+ }
+}
+
+// В компоненте:
+$arResult['ITEMS'] = (new NewsService())->getFilteredNews([
+ 'search' => $_REQUEST['search'] ?? '',
+]);
+```
+
+---
+
+## DTO для валидации входящих данных
+
+**ПЛОХО — СЫРАЯ ВАЛИДАЦИЯ В КОНТРОЛЛЕРЕ**
+```php
+$title = $_POST['TITLE'] ?? '';
+if (strlen($title) < 3) {
+ $error = 'Заголовок слишком короткий';
+} else {
+ \CCrmLead::Add(['TITLE' => $title]);
+}
+```
+
+**ХОРОШО — DTO + КОНТРОЛЛЕР**
+```php
+readonly class CreateLeadDto {
+ public function __construct(
+ public string $title,
+ public ?string $phone = null,
+ public ?string $email = null,
+ ) {}
+
+ /** @throws \InvalidArgumentException */
+ public static function fromArray(array $data): self {
+ if (empty($data['TITLE']) || mb_strlen($data['TITLE']) < 3) {
+ throw new \InvalidArgumentException('Заголовок — не менее 3 символов');
+ }
+ return new self(
+ title: trim($data['TITLE']),
+ phone: $data['PHONE'] ?? null,
+ email: $data['EMAIL'] ?? null,
+ );
+ }
+}
+
+class LeadController {
+ public function __construct(private LeadCreationService $leadService) {}
+
+ public function createAction(\Bitrix\Main\Request $request): \Bitrix\Main\Response {
+ try {
+ $dto = CreateLeadDto::fromArray($request->getPostList()->toArray());
+ $lead = $this->leadService->create($dto);
+ return new \Bitrix\Main\Response(
+ json_encode(['id' => $lead->getId()]),
+ 'application/json'
+ );
+ } catch (\InvalidArgumentException $e) {
+ return new \Bitrix\Main\Response(
+ json_encode(['error' => $e->getMessage()]),
+ 'application/json',
+ 400
+ );
+ }
+ }
+}
+```
diff --git a/src/05-arrays.md b/src/05-arrays.md
new file mode 100755
index 0000000000000000000000000000000000000000..0f3f9f25b00eb998d432c4ce930bf4ef5a776191
--- /dev/null
+++ b/src/05-arrays.md
@@ -0,0 +1,63 @@
+# 05. Работа с массивами и коллекциями
+
+[← Оглавление](./index.md)
+
+---
+
+## Встроенные функции вместо foreach-циклов
+
+**ПЛОХО**
+```php
+$activeClientIds = [];
+foreach ($clients as $client) {
+ if ($client['UF_ACTIVE'] === 'Y') {
+ $activeClientIds[] = $client['ID'];
+ }
+}
+```
+
+**ХОРОШО**
+```php
+$activeClients = array_filter($clients, fn($c) => $c['UF_ACTIVE'] === 'Y');
+$activeClientIds = array_column($activeClients, 'ID');
+```
+
+---
+
+## Вложенные массивы — разворачивание через array_merge / array_column
+
+**ПЛОХО — ВЛОЖЕННЫЕ ЦИКЛЫ**
+```php
+$allPhones = [];
+foreach ($contacts as $contact) {
+ foreach ($contact['PHONE'] as $phone) {
+ $allPhones[] = $phone['VALUE'];
+ }
+}
+```
+
+**ХОРОШО**
+```php
+$allPhones = array_merge(...array_column($contacts, 'PHONE'));
+$phoneNumbers = array_column($allPhones, 'VALUE');
+```
+
+---
+
+## Коллекции объектов для сложных структур
+
+**ПЛОХО — РУЧНОЙ ЦИКЛ ДЛЯ АГРЕГАЦИИ**
+```php
+$sum = 0;
+foreach ($dealProducts as $row) {
+ $sum += $row['PRICE'] * $row['QUANTITY'];
+}
+```
+
+**ХОРОШО — ILLUMINATE/COLLECTIONS**
+```php
+use Illuminate\Support\Collection;
+
+$totalSum = Collection::make($dealProducts)
+ ->sum(fn($row) => $row['PRICE'] * $row['QUANTITY']);
+```
diff --git a/src/06-conditions.md b/src/06-conditions.md
new file mode 100755
index 0000000000000000000000000000000000000000..c173849aad9bf10dfc3dd3b54ab670e82e6384c8
--- /dev/null
+++ b/src/06-conditions.md
@@ -0,0 +1,124 @@
+# 06. Условия и управляющие конструкции
+
+[← Оглавление](./index.md)
+
+---
+
+## Длинные условия — выносите в методы
+
+**ПЛОХО — НЕЧИТАЕМОЕ УСЛОВИЕ В IF**
+```php
+if ($deal->getStageId() === 'EXECUTING' && $deal->getOpportunity() > 10000 && $deal->getAssignedById() === $currentUserId && !$deal->getClosed()) {
+ // разрешить действие
+}
+```
+
+**ХОРОШО — ИНКАПСУЛИРОВАННОЕ УСЛОВИЕ**
+```php
+private function canCurrentUserEditDeal(\Bitrix\Crm\Item $deal): bool {
+ return $deal->getStageId() === 'EXECUTING'
+ && $deal->getOpportunity() > 10000
+ && $deal->getAssignedById() === \CCrmSecurityHelper::GetCurrentUserID()
+ && !$deal->getClosed();
+}
+
+if ($this->canCurrentUserEditDeal($deal)) {
+ // разрешить действие
+}
+```
+
+---
+
+## Именованные константы и Enum вместо магических значений
+
+**ПЛОХО — МАГИЧЕСКИЕ ЧИСЛА И СТРОКИ**
+```php
+if ($lead->getStatusId() === '1') { // что за статус?
+ $action = 'call';
+}
+if ($product['QUANTITY'] < 10) { /* неясно откуда 10 */ }
+```
+
+**ХОРОШО — PHP 8.1+ ENUM**
+```php
+enum LeadStatus: string {
+ case NEW = '1';
+ case IN_PROCESS = '2';
+ case CONVERTED = '3';
+}
+
+const LOW_STOCK_THRESHOLD = 10;
+
+if ($lead->getStatusId() === LeadStatus::NEW->value) {
+ $action = 'call';
+}
+if ($product['QUANTITY'] < LOW_STOCK_THRESHOLD) { /* ... */ }
+```
+
+---
+
+## Полиморфизм вместо switch / if-elseif
+
+**ПЛОХО — РАЗРАСТАЮЩИЙСЯ SWITCH**
+```php
+class ActivityHandler {
+ public function process($activityType, $data) {
+ if ($activityType === 'CALL') {
+ // логика звонка
+ } elseif ($activityType === 'EMAIL') {
+ // логика письма
+ } elseif ($activityType === 'MEETING') {
+ // логика встречи — и так далее...
+ } else {
+ throw new \Exception('Unknown type');
+ }
+ }
+}
+```
+
+**ХОРОШО — ПАТТЕРН STRATEGY + REGISTRY**
+```php
+interface ActivityProcessor {
+ public function process(array $data): void;
+}
+
+class CallProcessor implements ActivityProcessor { /* ... */ }
+class EmailProcessor implements ActivityProcessor { /* ... */ }
+
+class ActivityProcessorRegistry {
+ private array $processors = [];
+
+ public function register(string $type, ActivityProcessor $p): void {
+ $this->processors[$type] = $p;
+ }
+
+ public function get(string $type): ActivityProcessor {
+ return $this->processors[$type]
+ ?? throw new \InvalidArgumentException("Unknown activity type: {$type}");
+ }
+}
+```
+
+---
+
+## match вместо if-elseif (PHP 8+)
+
+**ПЛОХО**
+```php
+if ($stage === 'NEW') {
+ $label = 'Новый';
+} elseif ($stage === 'IN_PROGRESS') {
+ $label = 'В работе';
+} else {
+ $label = 'Завершён';
+}
+```
+
+**ХОРОШО — MATCH СО СТРОГИМ СРАВНЕНИЕМ**
+```php
+$label = match ($stage) {
+ 'NEW' => 'Новый',
+ 'IN_PROGRESS' => 'В работе',
+ default => 'Завершён',
+};
+```
diff --git a/src/07-typing.md b/src/07-typing.md
new file mode 100755
index 0000000000000000000000000000000000000000..10761681f0e99063616254c9747cfa66db6ffe01
--- /dev/null
+++ b/src/07-typing.md
@@ -0,0 +1,92 @@
+# 07. Типизация и строгость
+
+[← Оглавление](./index.md)
+
+---
+
+## declare(strict_types=1) — обязательно в каждом файле
+
+**ПЛОХО — НЕЯВНОЕ ПРИВЕДЕНИЕ ТИПОВ**
+```php
+<?php
+// нет strict_types — PHP молча приведёт "5" к int
+function add(int $a, int $b): int {
+ return $a + $b;
+}
+echo add("5", "10"); // 15 — ошибка замаскирована
+```
+
+**ХОРОШО — ЯВНЫЙ TYPEERROR ПРИ НЕВЕРНЫХ ТИПАХ**
+```php
+<?php
+declare(strict_types=1);
+
+function add(int $a, int $b): int {
+ return $a + $b;
+}
+echo add("5", "10"); // TypeError — сразу видим проблему
+```
+
+---
+
+## Типизация свойств, аргументов и возвращаемых значений
+
+**ПЛОХО — PHPDOC ВМЕСТО НАТИВНОЙ ТИПИЗАЦИИ**
+```php
+class ClientInfo {
+ /** @var string|null */
+ private $name;
+
+ public function setName($name) {
+ $this->name = $name;
+ }
+
+ public function getName() {
+ return $this->name;
+ }
+}
+```
+
+**ХОРОШО — НАТИВНАЯ ТИПИЗАЦИЯ PHP 8**
+```php
+class ClientInfo {
+ private ?string $name = null;
+
+ public function setName(string $name): void {
+ $this->name = $name;
+ }
+
+ public function getName(): ?string {
+ return $this->name;
+ }
+}
+```
+
+---
+
+## readonly классы для DTO (PHP 8.2+)
+
+**ПЛОХО — ИЗМЕНЯЕМЫЙ МАССИВ ВМЕСТО DTO**
+```php
+function updateClient(int $id, array $data): void {
+ $client = $this->getById($id);
+ $client['NAME'] = $data['NAME'] ?? $client['NAME'];
+ // легко ошибиться с ключом, нет типобезопасности
+}
+```
+
+**ХОРОШО — НЕИЗМЕНЯЕМЫЙ READONLY DTO**
+```php
+readonly class UpdateClientDto {
+ public function __construct(
+ public string $name,
+ public ?string $phone = null,
+ public ?string $email = null,
+ ) {}
+}
+
+function updateClient(int $id, UpdateClientDto $dto): void {
+ $client = $this->getById($id);
+ $client->setName($dto->name);
+}
+```
diff --git a/src/08-errors.md b/src/08-errors.md
new file mode 100755
index 0000000000000000000000000000000000000000..4d5eccf1a9ef759a5a90a14dc7a1e511b6b19674
--- /dev/null
+++ b/src/08-errors.md
@@ -0,0 +1,96 @@
+# 08. Обработка ошибок и Result Object
+
+[← Оглавление](./index.md)
+
+---
+
+## Исключения вместо возврата false/null
+
+**ПЛОХО — СТАРЫЙ ПОДХОД BITRIX С ГЛОБАЛЬНЫМ СОСТОЯНИЕМ**
+```php
+$leadId = \CCrmLead::Add(['TITLE' => '']);
+if (!$leadId) {
+ $error = \CCrmLead::GetLastError(); // глобальное состояние
+}
+```
+
+**ХОРОШО — ДОМЕННЫЕ ИСКЛЮЧЕНИЯ**
+```php
+class LeadCreationException extends \RuntimeException {}
+
+try {
+ $lead = $this->leadService->createFromDto($dto);
+} catch (LeadCreationException $e) {
+ $this->logger->error($e->getMessage());
+}
+```
+
+---
+
+## Иерархия доменных исключений
+
+**ПЛОХО — БАЗОВЫЙ \EXCEPTION БЕЗ КОНТЕКСТА**
+```php
+throw new \Exception('Неверный email');
+// caller не знает, какое именно исключение ловить
+```
+
+**ХОРОШО — ИЕРАРХИЯ ДОМЕННЫХ ИСКЛЮЧЕНИЙ**
+```php
+class MyModuleException extends \RuntimeException {}
+class InvalidEmailException extends \DomainException {}
+class LeadNotFoundException extends \Bitrix\Main\ObjectNotFoundException {}
+class LeadValidationException extends MyModuleException {}
+
+throw new InvalidEmailException('Email имеет неверный формат: ' . $email);
+```
+
+---
+
+## Нативный Result Object Bitrix
+
+Result Object — нативный паттерн Bitrix для операций уровня сервиса. Используйте его там, где нужно вернуть данные или набор ошибок без исключений.
+
+**ПЛОХО — ВОЗВРАТ NULL ПРИ ОШИБКЕ, НЕТ ДЕТАЛЕЙ**
+```php
+public function create(CreateLeadDto $dto): ?int {
+ $lead = $factory->createItem();
+ $lead->setTitle($dto->title);
+ $result = $factory->getCreateOperation($lead)->launch();
+ if (!$result->isSuccess()) {
+ return null; // caller не знает, что именно пошло не так
+ }
+ return $lead->getId();
+}
+```
+
+**ХОРОШО — RESULT С ДЕТАЛЯМИ ОШИБОК**
+```php
+public function create(CreateLeadDto $dto): \Bitrix\Main\Result {
+ $result = new \Bitrix\Main\Result();
+ try {
+ $factory = \Bitrix\Crm\Service\Container::getInstance()
+ ->getFactory(\CCrmOwnerType::Lead);
+ $lead = $factory->createItem();
+ $lead->setTitle($dto->title);
+ $opResult = $factory->getCreateOperation($lead)->launch();
+ if (!$opResult->isSuccess()) {
+ return $result->addErrors($opResult->getErrors());
+ }
+ $result->setData(['id' => $lead->getId()]);
+ } catch (\Throwable $e) {
+ $result->addError(new \Bitrix\Main\Error($e->getMessage(), $e->getCode()));
+ }
+ return $result;
+}
+
+// Использование:
+$result = $leadService->create($dto);
+if ($result->isSuccess()) {
+ $leadId = $result->getData()['id'];
+} else {
+ foreach ($result->getErrors() as $error) {
+ $logger->error($error->getMessage());
+ }
+}
+```
diff --git a/src/09-security.md b/src/09-security.md
new file mode 100755
index 0000000000000000000000000000000000000000..e94befade41528df954873f7603bc92758201aae
--- /dev/null
+++ b/src/09-security.md
@@ -0,0 +1,71 @@
+# 09. Безопасность
+
+[← Оглавление](./index.md)
+
+---
+
+## ORM вместо raw SQL — никаких переменных в строке запроса
+
+**ПЛОХО — SQL-ИНЪЕКЦИЯ ДАЖЕ С FORSQL**
+```php
+global $DB;
+$email = $_GET['email'];
+$sql = "SELECT * FROM b_user WHERE EMAIL = '" . $DB->ForSql($email) . "'";
+$res = $DB->Query($sql);
+```
+
+**ХОРОШО — ORM С ПАРАМЕТРИЗОВАННЫМ ЗАПРОСОМ**
+```php
+use Bitrix\Main\UserTable;
+
+$email = $_GET['email'] ?? '';
+$user = UserTable::query()
+ ->setSelect(['ID', 'NAME', 'LAST_NAME'])
+ ->where('EMAIL', $email) // параметр передаётся безопасно
+ ->setLimit(1)
+ ->exec()
+ ->fetch();
+```
+
+---
+
+## Экранирование вывода в HTML
+
+**ПЛОХО — XSS УЯЗВИМОСТЬ**
+```php
+echo "Привет, " . $arUser['NAME'];
+```
+
+**ХОРОШО — HTMLFILTER::ENCODE()**
+```php
+use Bitrix\Main\Text\HtmlFilter;
+
+echo "Привет, " . HtmlFilter::encode($arUser['NAME']);
+```
+
+---
+
+## Валидация входящих данных
+
+`FILTER_SANITIZE_SPECIAL_CHARS` и `FILTER_SANITIZE_STRING` удалены в PHP 8.1–8.2. Используйте явную санитизацию.
+
+**ПЛОХО — УДАЛЁННЫЕ КОНСТАНТЫ PHP**
+```php
+// FILTER_SANITIZE_SPECIAL_CHARS удалён в PHP 8.2!
+$phone = filter_input(INPUT_POST, 'PHONE', FILTER_SANITIZE_SPECIAL_CHARS);
+```
+
+**ХОРОШО — ЯВНАЯ САНИТИЗАЦИЯ ДЛЯ PHP 8.2+**
+```php
+$phone = trim((string)($_POST['PHONE'] ?? ''));
+$phone = strip_tags($phone);
+if (!preg_match('/^\+?\d{10,15}$/', $phone)) {
+ throw new \InvalidArgumentException('Некорректный номер телефона');
+}
+
+// Для email — нативный PHP-фильтр (не удалён):
+$email = filter_input(INPUT_POST, 'EMAIL', FILTER_VALIDATE_EMAIL);
+if ($email === false || $email === null) {
+ throw new \InvalidArgumentException('Некорректный email');
+}
+```
diff --git a/src/10-database.md b/src/10-database.md
new file mode 100755
index 0000000000000000000000000000000000000000..46e51add48d932413f53bb8b871ee43846c3d6ff
--- /dev/null
+++ b/src/10-database.md
@@ -0,0 +1,138 @@
+# 10. Базы данных и ORM Битрикс
+
+[← Оглавление](./index.md)
+
+---
+
+## Репозиторий инкапсулирует работу с данными
+
+**ПЛОХО — ORM-ЗАПРОСЫ РАЗБРОСАНЫ ПО СЕРВИСАМ**
+```php
+class DealReportService {
+ public function getWonDealsSum(): float {
+ // прямой вызов ORM из сервиса без абстракции
+ $result = \Bitrix\Crm\DealTable::getList([
+ 'filter' => ['=STAGE_SEMANTIC_ID' => 'S'],
+ 'select' => ['OPPORTUNITY_SUM'],
+ 'runtime' => [
+ new \Bitrix\Main\Entity\ExpressionField(
+ 'OPPORTUNITY_SUM',
+ 'SUM(%s)',
+ 'OPPORTUNITY'
+ )
+ ]
+ ])->fetch();
+ return (float)($result['OPPORTUNITY_SUM'] ?? 0);
+ }
+}
+```
+
+**ХОРОШО — ПАТТЕРН REPOSITORY**
+```php
+interface DealRepository {
+ public function getTotalWonOpportunity(): float;
+}
+
+class OrmDealRepository implements DealRepository {
+ public function getTotalWonOpportunity(): float {
+ $result = \Bitrix\Crm\DealTable::query()
+ ->addSelect(
+ new \Bitrix\Main\Entity\ExpressionField(
+ 'SUM_OPP',
+ 'SUM(%s)',
+ 'OPPORTUNITY'
+ )
+ )
+ ->where('STAGE_SEMANTIC_ID', 'S')
+ ->exec()->fetch();
+ return (float)($result['SUM_OPP'] ?? 0);
+ }
+}
+
+class DealReportService {
+ public function __construct(private DealRepository $dealRepo) {}
+
+ public function getWonDealsSum(): float {
+ return $this->dealRepo->getTotalWonOpportunity();
+ }
+}
+```
+
+---
+
+## Транзакции для согласованности данных
+
+**ПЛОХО — НЕТ ТРАНЗАКЦИИ, ДАННЫЕ МОГУТ РАССОГЛАСОВАТЬСЯ**
+```php
+\Bitrix\Crm\DealTable::update($dealId, ['STAGE_ID' => 'WON']);
+\Bitrix\Crm\InvoiceTable::add([/* ... */]); // если упадёт — сделка уже обновлена
+```
+
+**ХОРОШО — АТОМАРНОЕ ОБНОВЛЕНИЕ С ROLLBACK**
+```php
+$connection = \Bitrix\Main\Application::getConnection();
+$connection->startTransaction();
+try {
+ \Bitrix\Crm\DealTable::update($dealId, ['STAGE_ID' => 'WON']);
+ \Bitrix\Crm\InvoiceTable::add([/* ... */]);
+ $connection->commitTransaction();
+} catch (\Exception $e) {
+ $connection->rollbackTransaction();
+ throw $e;
+}
+```
+
+---
+
+## Решение проблемы N+1 через JOIN в ORM
+
+**ПЛОХО — N+1 ЗАПРОСОВ**
+```php
+$deals = \Bitrix\Crm\DealTable::getList(['select' => ['ID','CONTACT_ID']])->fetchAll();
+foreach ($deals as $deal) {
+ // N дополнительных запросов!
+ $contact = \Bitrix\Crm\ContactTable::getByPrimary($deal['CONTACT_ID'])->fetch();
+}
+```
+
+**ХОРОШО — ПРЕДЗАГРУЗКА ЧЕРЕЗ REGISTERRUNTIMEFIELD**
+```php
+$deals = \Bitrix\Crm\DealTable::query()
+ ->setSelect(['ID', 'TITLE', 'CONTACT.NAME', 'CONTACT.LAST_NAME'])
+ ->registerRuntimeField(
+ new \Bitrix\Main\Entity\ReferenceField(
+ 'CONTACT',
+ \Bitrix\Crm\ContactTable::class,
+ \Bitrix\Main\ORM\Query\Join::on('this.CONTACT_ID', 'ref.ID'),
+ )
+ )
+ ->where('STAGE_ID', 'EXECUTING')
+ ->setOrder(['DATE_CREATE' => 'DESC'])
+ ->exec()->fetchCollection();
+
+foreach ($deals as $deal) {
+ $name = $deal->get('CONTACT.NAME'); // уже загружен, доп. запросов нет
+}
+```
+
+---
+
+## fetchCollection() и работа с объектами
+
+**ПЛОХО — НЕСУЩЕСТВУЮЩИЙ МЕТОД КОЛЛЕКЦИИ**
+```php
+$leadCollection = \Bitrix\Crm\LeadTable::getList(['select' => ['*']])->fetchCollection();
+$titles = $leadCollection->getTitleList(); // метод не существует!
+```
+
+**ХОРОШО — ГЕТТЕРЫ ЧЕРЕЗ GETALL()**
+```php
+$leadCollection = \Bitrix\Crm\LeadTable::query()
+ ->setSelect(['ID', 'TITLE'])
+ ->exec()->fetchCollection();
+
+$titles = array_map(
+ fn($lead) => $lead->getTitle(),
+ $leadCollection->getAll(),
+);
+```
diff --git a/src/11-performance.md b/src/11-performance.md
new file mode 100755
index 0000000000000000000000000000000000000000..e68900cc226cfd9ec2ae67c96266b16ff126a2a2
--- /dev/null
+++ b/src/11-performance.md
@@ -0,0 +1,83 @@
+# 11. Производительность: кеш и TaggedCache
+
+[← Оглавление](./index.md)
+
+---
+
+## Кеширование через Bitrix\Main\Data\Cache
+
+**ПЛОХО — ДОРОГОЙ ЗАПРОС БЕЗ КЕША**
+```php
+// Выполняется на каждый запрос страницы
+$elements = \CIBlockElement::GetList(
+ [], ['IBLOCK_ID' => $iblockId], false, false,
+ ['ID','NAME']
+)->FetchAll();
+```
+
+**ХОРОШО — РЕЗУЛЬТАТ КЕШИРУЕТСЯ**
+```php
+$cache = \Bitrix\Main\Data\Cache::createInstance();
+$cacheId = 'iblock_elements_' . $iblockId;
+
+if ($cache->initCache(3600, $cacheId, '/my_module/')) {
+ $elements = $cache->getVars();
+} elseif ($cache->startDataCache()) {
+ $elements = \Bitrix\Iblock\Elements\ElementNewsTable::getList([
+ 'filter' => ['IBLOCK_ID' => $iblockId],
+ ])->fetchAll();
+ $cache->endDataCache($elements);
+}
+```
+
+---
+
+## Тегированный кеш (TaggedCache)
+
+TaggedCache позволяет инвалидировать кеш при изменении конкретных сущностей. Предпочтительный подход для данных CRM и Iblock.
+
+**ПЛОХО — СБРОС ВСЕГО КЕША ПРИ ИЗМЕНЕНИИ ОДНОГО ЛИДА**
+```php
+// При любом изменении сбрасываем весь кеш модуля
+\Bitrix\Main\Data\Cache::createInstance()->cleanDir('/crm/leads/');
+```
+
+**ХОРОШО — ТОЧЕЧНАЯ ИНВАЛИДАЦИЯ ПО ТЕГУ**
+```php
+$taggedCache = \Bitrix\Main\Application::getInstance()->getTaggedCache();
+$cache = \Bitrix\Main\Data\Cache::createInstance();
+
+if ($cache->initCache(3600, 'lead_' . $leadId, '/crm/leads/')) {
+ $data = $cache->getVars();
+} elseif ($cache->startDataCache()) {
+ $taggedCache->startTagCache('/crm/leads/');
+ $taggedCache->registerTag('crm_lead_' . $leadId);
+ $taggedCache->registerTag('crm_leads_list');
+ $data = $this->leadRepository->getById($leadId);
+ $taggedCache->endTagCache();
+ $cache->endDataCache($data);
+}
+
+// При изменении лида — только его кеш:
+$taggedCache->clearByTag('crm_lead_' . $leadId);
+
+// При массовом изменении:
+$taggedCache->clearByTag('crm_leads_list');
+```
+
+---
+
+## Выбирайте только нужные поля
+
+**ПЛОХО — SELECT * БЕЗ НЕОБХОДИМОСТИ**
+```php
+$leads = \Bitrix\Crm\LeadTable::getList(['select' => ['*']])->fetchAll();
+```
+
+**ХОРОШО — ТОЛЬКО НЕОБХОДИМЫЕ ПОЛЯ**
+```php
+$leads = \Bitrix\Crm\LeadTable::query()
+ ->setSelect(['ID', 'TITLE', 'STATUS_ID', 'DATE_CREATE'])
+ ->setLimit(50)
+ ->exec()->fetchAll();
+```
diff --git a/src/12-testing.md b/src/12-testing.md
new file mode 100755
index 0000000000000000000000000000000000000000..10ba149f2c11fa15cd3e25a1a198b7c3deddb3e6
--- /dev/null
+++ b/src/12-testing.md
@@ -0,0 +1,78 @@
+# 12. Тестирование
+
+[← Оглавление](./index.md)
+
+---
+
+## Код должен быть тестируемым — изолируйте зависимости
+
+**ПЛОХО — ПРЯМОЙ ВЫЗОВ API, НЕЛЬЗЯ ПРОТЕСТИРОВАТЬ**
+```php
+class LeadService {
+ public function create(array $data): int {
+ return \CCrmLead::Add($data); // нельзя замокать
+ }
+}
+```
+
+**ХОРОШО — РЕПОЗИТОРИЙ ЧЕРЕЗ ИНТЕРФЕЙС**
+```php
+interface LeadRepository {
+ public function save(Lead $lead): void;
+}
+
+class LeadService {
+ public function __construct(private LeadRepository $repo) {}
+
+ public function create(LeadDto $dto): Lead {
+ $lead = new Lead($dto);
+ $this->repo->save($lead);
+ return $lead;
+ }
+}
+
+// PHPUnit тест:
+class LeadServiceTest extends \PHPUnit\Framework\TestCase {
+ public function testCreateLead(): void {
+ $mockRepo = $this->createMock(LeadRepository::class);
+ $mockRepo->expects($this->once())->method('save');
+ $service = new LeadService($mockRepo);
+ $lead = $service->create(new LeadDto(title: 'Test Lead'));
+ $this->assertSame('Test Lead', $lead->getTitle());
+ }
+}
+```
+
+---
+
+## Data Providers для параметризованных тестов
+
+**ПЛОХО — ДУБЛИРОВАНИЕ ТЕСТОВЫХ МЕТОДОВ**
+```php
+public function testSmallOrderDiscount(): void {
+ $this->assertSame(0.0, (new DiscountCalculator())->calculate(5000.0));
+}
+
+public function testMediumOrderDiscount(): void {
+ $this->assertSame(0.05, (new DiscountCalculator())->calculate(50000.0));
+}
+// и так далее...
+```
+
+**ХОРОШО — @DATAPROVIDER**
+```php
+class DiscountCalculatorTest extends \PHPUnit\Framework\TestCase {
+ /** @dataProvider discountProvider */
+ public function testCalculate(float $amount, float $expected): void {
+ $this->assertSame($expected, (new DiscountCalculator())->calculate($amount));
+ }
+
+ public static function discountProvider(): array {
+ return [
+ 'small order' => [5000.0, 0.0],
+ 'medium order' => [50000.0, 0.05],
+ 'large order' => [150000.0, 0.15],
+ ];
+ }
+}
+```
diff --git a/src/13-comments.md b/src/13-comments.md
new file mode 100755
index 0000000000000000000000000000000000000000..2405d42a962dfa62fb936d344494488fd3cc14dd
--- /dev/null
+++ b/src/13-comments.md
@@ -0,0 +1,49 @@
+# 13. Комментирование и документация
+
+[← Оглавление](./index.md)
+
+---
+
+## Код должен быть самодокументированным
+
+Комментарии объясняют *«почему»*, а не *«что»*. Используйте выразительные имена.
+
+**ПЛОХО — КОММЕНТАРИЙ ОПИСЫВАЕТ ОЧЕВИДНОЕ**
+```php
+// умножаем цену на количество
+$sum = $price * $qty;
+```
+
+**ХОРОШО — ИМЯ ГОВОРИТ САМО ЗА СЕБЯ, КОММЕНТАРИЙ ОБЪЯСНЯЕТ БИЗНЕС-ПРАВИЛО**
+```php
+$totalPrice = $product->getPrice() * $product->getQuantity();
+// Скидка для VIP-клиентов введена с Q1 2024 (см. задачу PROJ-1234)
+if ($client->isVip()) {
+ $totalPrice *= (1 - self::VIP_DISCOUNT_RATE);
+}
+```
+
+---
+
+## PHPDoc для публичных API
+
+**ПЛОХО — НЕТ ДОКУМЕНТАЦИИ, НЕЯСНЫ ТИПЫ И ИСКЛЮЧЕНИЯ**
+```php
+public function find($id) {
+ // ...
+}
+```
+
+**ХОРОШО — ПОЛНЫЙ PHPDOC**
+```php
+/**
+ * Находит сделку по идентификатору.
+ *
+ * @param positive-int $id Идентификатор сделки в CRM
+ * @return \Bitrix\Crm\Item
+ *
+ * @throws \Bitrix\Main\ObjectNotFoundException если сделка не найдена
+ * @throws \Bitrix\Main\SystemException при ошибке базы данных
+ */
+public function find(int $id): \Bitrix\Crm\Item { /* ... */ }
+```
diff --git a/src/14-general.md b/src/14-general.md
new file mode 100755
index 0000000000000000000000000000000000000000..142d2c6aea60f493be9e145a32a5d9015cb3d2dc
--- /dev/null
+++ b/src/14-general.md
@@ -0,0 +1,67 @@
+# 14. Общие практики (DRY, KISS, YAGNI, Закон Деметры)
+
+[← Оглавление](./index.md)
+
+---
+
+## DRY — не дублируйте знания
+
+**ПЛОХО — ОДНА И ТА ЖЕ ЛОГИКА В ДВУХ МЕСТАХ**
+```php
+// В компоненте A:
+$lead = \Bitrix\Crm\LeadTable::getByPrimary($id)->fetchObject();
+if (!$lead) {
+ throw new \Exception('Лид не найден');
+}
+
+// В компоненте B:
+$lead = \Bitrix\Crm\LeadTable::getByPrimary($id)->fetchObject();
+if (!$lead) {
+ throw new \Exception('Лид не найден');
+}
+```
+
+**ХОРОШО — ОБЩАЯ ЛОГИКА В LEADPROVIDER**
+```php
+class LeadProvider {
+ public function getById(int $id): \Bitrix\Crm\Item {
+ $lead = \Bitrix\Crm\LeadTable::getByPrimary($id)->fetchObject();
+ if (!$lead) {
+ throw new \Bitrix\Main\ObjectNotFoundException("Лид #{$id} не найден");
+ }
+ return $lead;
+ }
+}
+```
+
+---
+
+## Закон Деметры — не разговаривай с незнакомцами
+
+**ПЛОХО — ЦЕПОЧКА ЧЕРЕЗ ГРАНИЦЫ ОБЪЕКТОВ**
+```php
+$city = $deal->getContact()->getCompany()->getAddress()->getCity();
+```
+
+**ХОРОШО — ДЕЛЕГИРУЮЩИЙ МЕТОД В DEAL**
+```php
+// Вызов:
+$city = $deal->getContactCompanyCity();
+
+// Реализация внутри класса Deal:
+public function getContactCompanyCity(): ?string {
+ return $this->getContact()?->getCompany()?->getCity();
+}
+```
+
+---
+
+## KISS — простое решение лучше сложного
+
+Не усложняйте без необходимости. Если задачу можно решить тремя строками — не пишите тридцать.
+
+---
+
+## YAGNI — не добавляйте функциональность «на будущее»
+
+Реализуйте только то, что требуется прямо сейчас. Код, написанный «на перспективу», часто не используется и засоряет проект.
diff --git a/src/15-events.md b/src/15-events.md
new file mode 100755
index 0000000000000000000000000000000000000000..7a69e70a2e36231006d59beeae17e781f984f9b8
--- /dev/null
+++ b/src/15-events.md
@@ -0,0 +1,60 @@
+# 15. События Bitrix (EventManager)
+
+[← Оглавление](./index.md)
+
+---
+
+События — ключевой механизм расширения Битрикс без изменения ядра. Тонкие подписчики и делегирование в сервис — обязательный стандарт.
+
+## Тонкий обработчик — логика в сервисе
+
+**ПЛОХО — ВСЯ ЛОГИКА В ТЕЛЕ ОБРАБОТЧИКА**
+```php
+AddEventHandler('crm', 'OnAfterCrmLeadAdd', function(array &$fields): void {
+ // 50+ строк: отправка писем, запись в лог, вызов внешних API...
+ $mailer = new \PHPMailer\PHPMailer\PHPMailer();
+ $mailer->setFrom(['no-reply@example.com']);
+ $mailer->addAddress($fields['EMAIL']);
+ // ...
+});
+```
+
+**ХОРОШО — ПОДПИСЧИК ДЕЛЕГИРУЕТ В СЕРВИС**
+```php
+class LeadEventSubscriber {
+ /** Обработчик намеренно тонкий — вся логика в сервисе. */
+ public static function onAfterLeadAdd(array &$fields): void {
+ \Bitrix\Main\DI\ServiceLocator::getInstance()
+ ->get(LeadNotificationService::class)
+ ->notifyOnLeadCreated((int)$fields['ID']);
+ }
+}
+
+// Регистрация в init.php:
+\Bitrix\Main\EventManager::getInstance()->addEventHandler(
+ 'crm',
+ 'OnAfterCrmLeadAdd',
+ [LeadEventSubscriber::class, 'onAfterLeadAdd'],
+);
+```
+
+---
+
+## Современный API событий (Bitrix\Main\Event)
+
+**ОБЪЕКТНО-ОРИЕНТИРОВАННЫЙ EVENT API**
+```php
+\Bitrix\Main\EventManager::getInstance()->addEventHandlerCompatible(
+ 'crm',
+ '\Bitrix\Crm\Service\Operation\Add::onAfterRun',
+ static function (\Bitrix\Main\Event $event): \Bitrix\Main\EventResult {
+ $lead = $event->getParameter('ITEM');
+ if ($lead instanceof \Bitrix\Crm\Item\Lead) {
+ \Bitrix\Main\DI\ServiceLocator::getInstance()
+ ->get(LeadNotificationService::class)
+ ->notifyOnLeadCreated($lead->getId());
+ }
+ return new \Bitrix\Main\EventResult(\Bitrix\Main\EventResult::SUCCESS);
+ }
+);
+```
diff --git a/src/16-logging.md b/src/16-logging.md
new file mode 100755
index 0000000000000000000000000000000000000000..d85f1be476e338a40a5a1becf8db69d751d5a065
--- /dev/null
+++ b/src/16-logging.md
@@ -0,0 +1,68 @@
+# 16. Логирование (PSR-3)
+
+[← Оглавление](./index.md)
+
+---
+
+Инжектируйте PSR-3 `LoggerInterface`. Глобальные функции `AddMessage2Log()` — устаревший подход без уровней и контекста.
+
+## PSR-3 логирование через Bitrix\Main\Diag\Logger
+
+**ПЛОХО — УСТАРЕВШИЕ ФУНКЦИИ БЕЗ УРОВНЕЙ**
+```php
+AddMessage2Log('Ошибка создания лида: ' . $message, 'my_module');
+// нет уровней severity, нет структурированного контекста, сложно искать
+```
+
+**ХОРОШО — PSR-3 С УРОВНЯМИ И КОНТЕКСТОМ**
+```php
+$logger->info('Lead created', ['leadId' => $lead->getId(), 'userId' => \CUser::GetID()]);
+
+$logger->error('Lead creation failed', [
+ 'message' => $e->getMessage(),
+ 'code' => $e->getCode(),
+ 'trace' => $e->getTraceAsString(),
+]);
+```
+
+---
+
+## Инжектируйте LoggerInterface, а не конкретную реализацию
+
+**ПЛОХО — КОНКРЕТНАЯ РЕАЛИЗАЦИЯ В КОНСТРУКТОРЕ**
+```php
+class LeadCreationService {
+ private \Bitrix\Main\Diag\Logger $logger;
+
+ public function __construct() {
+ // нельзя подменить в тестах, привязка к реализации
+ $this->logger = \Bitrix\Main\Diag\Logger::create('crm', '/local/logs/crm.log');
+ }
+}
+```
+
+**ХОРОШО — PSR-3 ИНТЕРФЕЙС ЧЕРЕЗ DI**
+```php
+use Psr\Log\LoggerInterface;
+
+class LeadCreationService {
+ public function __construct(
+ private \Bitrix\Crm\Service\Factory $factory,
+ private LoggerInterface $logger,
+ ) {}
+
+ public function create(CreateLeadDto $dto): \Bitrix\Main\Result {
+ $this->logger->debug('Creating lead', ['title' => $dto->title]);
+ // ...
+ }
+}
+
+// Регистрация в ServiceLocator:
+ServiceLocator::getInstance()->addInstanceLazy(
+ LoggerInterface::class,
+ fn() => \Bitrix\Main\Diag\Logger::create(
+ 'app',
+ '/local/logs/app_' . date('Y-m-d') . '.log'
+ )
+);
+```
diff --git a/src/17-agents.md b/src/17-agents.md
new file mode 100755
index 0000000000000000000000000000000000000000..ba7a9c6277f4f1288196e96e352c3db6ad84bd8d
--- /dev/null
+++ b/src/17-agents.md
@@ -0,0 +1,296 @@
+# 17. Агенты — AbstractAgent (полный паттерн)
+
+[← Оглавление](./index.md)
+
+---
+
+В проекте используется абстрактный базовый класс **AbstractAgent**, который инкапсулирует всю инфраструктуру агентов: регистрацию, запуск, обработку ошибок и повторный вызов. Конкретные агенты реализуют только метод `execute()`.
+
+## Архитектура: AbstractAgent
+
+Базовый класс решает все инфраструктурные задачи — конкретный агент содержит только бизнес-логику.
+
+**ABSTRACTAGENT — БАЗОВЫЙ КЛАСС (APP\AGENTS)**
+```php
+<?php
+declare(strict_types=1);
+
+namespace App\Agents;
+
+use App\Logger;
+use Bitrix\Main\SystemException;
+use Bitrix\Main\Type\DateTime;
+use CAgent;
+use DateTimeZone;
+use Exception;
+use Psr\Log\LoggerInterface;
+use RuntimeException;
+
+/**
+ * Абстрактный класс для создания агентов Битрикс24.
+ * Конкретный агент обязан реализовать только метод execute().
+ */
+abstract class AbstractAgent
+{
+ protected const DEFAULT_PRIORITY = 100;
+ protected const DEFAULT_INTERVAL = 86400; // 24 часа
+ protected const MODULE_ID = 'main';
+ protected const EXEC_DATETIME_HOUR = 4;
+ protected const EXEC_DATETIME_MINUTE = 0;
+ protected const IS_PERIODICAL_AGENT = 'Y';
+
+ protected LoggerInterface $logger;
+
+ /**
+ * Выполнение основной логики агента.
+ * Должен вернуть строку следующего вызова.
+ *
+ * @throws Exception
+ */
+ abstract public function execute(): string;
+
+ /** Интервал выполнения в секундах (можно переопределить). */
+ protected function getInterval(): int {
+ return static::DEFAULT_INTERVAL;
+ }
+
+ /** Приоритет агента. */
+ protected function getPriority(): int {
+ return static::DEFAULT_PRIORITY;
+ }
+
+ /** Модуль, к которому привязан агент. */
+ protected function getModuleId(): string {
+ return static::MODULE_ID;
+ }
+
+ /** Тип агента (периодический / разовый). */
+ protected function getType(): string {
+ return static::IS_PERIODICAL_AGENT;
+ }
+
+ /** Время первого запуска агента (по московскому времени). */
+ protected function getExecDateTime(): int
+ {
+ $dateTime = new DateTime();
+ $dateTime->setTimeZone(new DateTimeZone('Europe/Moscow'));
+ return $dateTime
+ ->setTime(static::EXEC_DATETIME_HOUR, static::EXEC_DATETIME_MINUTE)
+ ->getTimestamp();
+ }
+
+ /**
+ * Проверка предусловий.
+ * Переопределите в конкретном агенте при необходимости.
+ *
+ * @throws RuntimeException
+ */
+ protected function checkConditions(): void {}
+
+ /** Логирование ошибки выполнения. */
+ protected function handleError(Exception $e): void
+ {
+ $this->logger = Logger::getInstance(module: $this->getModuleId());
+ $this->logger->error($e->getMessage(), $e->getTrace());
+ }
+
+ /** Строка повторного вызова агента (регистрируется в Битрикс). */
+ protected function createNextCall(): string
+ {
+ return static::class . '::agent();';
+ }
+
+ /**
+ * Статический точка входа агента — именно эта строка регистрируется в CAgent.
+ * Не содержит бизнес-логики: только запуск и перехват исключений.
+ */
+ public static function agent(): string
+ {
+ $instance = new static();
+ try {
+ $instance->checkConditions();
+ return $instance->execute();
+ } catch (Exception $e) {
+ $instance->handleError($e);
+ return $instance->createNextCall();
+ }
+ }
+
+ /**
+ * Регистрация агента в системе Битрикс.
+ * Вызывается при установке модуля (DoInstall).
+ *
+ * @throws SystemException
+ */
+ public static function register(): bool
+ {
+ $instance = new static();
+ $execTime = $instance->getExecDateTime();
+ static::unregister(); // удаляем дубли
+
+ $agentId = CAgent::AddAgent(
+ $instance->createNextCall(),
+ $instance->getModuleId(),
+ $instance->getType(),
+ $instance->getInterval(),
+ ConvertTimeStamp($execTime, 'FULL'),
+ 'Y',
+ ConvertTimeStamp($execTime, 'FULL'),
+ $instance->getPriority(),
+ );
+
+ if (!$agentId) {
+ throw new SystemException(
+ 'Ошибка регистрации агента: ' . static::class
+ );
+ }
+
+ return true;
+ }
+
+ /** Удаление агента из системы (вызывается при деинсталляции). */
+ public static function unregister(): void
+ {
+ $instance = new static();
+ CAgent::RemoveAgent($instance->createNextCall(), $instance->getModuleId());
+ }
+
+ /** Проверка, зарегистрирован ли агент. */
+ public static function isRegistered(): bool
+ {
+ $instance = new static();
+ $agent = CAgent::GetList(
+ [],
+ ['NAME' => $instance->createNextCall(), 'MODULE_ID' => $instance->getModuleId()]
+ )->Fetch();
+ return $agent !== false;
+ }
+}
+```
+
+---
+
+## Пример конкретного агента
+
+Конкретный агент наследует AbstractAgent и реализует только метод `execute()`. Вся инфраструктура — регистрация, перехват ошибок, повторный вызов — унаследована.
+
+**ПЛОХО — ПРОЦЕДУРНЫЙ АГЕНТ, ВСЯ ЛОГИКА В ОДНОЙ ФУНКЦИИ**
+```php
+// agents.php в модуле
+function DaSkud_ImportAgent(): string {
+ // 80 строк: создание клиентов, запросы к API, запись в БД...
+ $skudClient = new SkudV2Client(/* параметры хардкодом */);
+ $end = new DateTime();
+ $start = (clone $end)->modify('-1 day');
+ $res = $skudClient->getAttendance($start->format('Y-m-d'), $end->format('Y-m-d'));
+ foreach ($res as $record) {
+ \CIBlockElement::Add([/* ... */]);
+ }
+ return __FUNCTION__ . '();';
+}
+```
+
+**ХОРОШО — КОНКРЕТНЫЙ АГЕНТ С МИНИМАЛЬНОЙ РЕАЛИЗАЦИЕЙ**
+```php
+<?php
+declare(strict_types=1);
+
+namespace Da\Skud\Agents;
+
+use App\Agents\AbstractAgent;
+use Bitrix\Main\DI\ServiceLocator;
+use Da\Skud\ImportCommand;
+use Da\Skud\IntegrationInvoker;
+use Da\Skud\Services\BitrixService;
+use Da\Skud\Services\SkudV2Service;
+use Da\Skud\SkudV2Client;
+use DateTime;
+
+class SkudV2Agent extends AbstractAgent
+{
+ // Переопределяем MODULE_ID для привязки к конкретному модулю
+ public const MODULE_ID = 'da.skud';
+
+ /**
+ * Вся бизнес-логика инкапсулирована в сервисах.
+ * Агент только оркестрирует вызов.
+ */
+ public function execute(): string
+ {
+ /** @var SkudV2Client $skudClient */
+ $skudClient = ServiceLocator::getInstance()->get('da.skud.client');
+ $end = new DateTime();
+ $start = (clone $end)->modify('-1 day');
+ $skudService = new SkudV2Service(
+ $skudClient,
+ $start->format('Y-m-d'),
+ $end->format('Y-m-d'),
+ );
+ $bitrixService = new BitrixService($start->format('Y-m-d'));
+ $command = new ImportCommand($skudService, $bitrixService);
+ $invoker = new IntegrationInvoker();
+ $invoker->submit($command);
+ return $this->createNextCall(); // ОБЯЗАТЕЛЬНО
+ }
+}
+```
+
+---
+
+## Регистрация и управление агентом
+
+**ПЛОХО — РУЧНАЯ РЕГИСТРАЦИЯ С ДУБЛИРОВАНИЕМ ПАРАМЕТРОВ**
+```php
+// В DoInstall() — параметры хардкодом, легко ошибиться
+\CAgent::AddAgent(
+ 'DaSkud_ImportAgent();',
+ 'da.skud',
+ 'Y',
+ 86400,
+ '',
+ 'Y',
+ \ConvertTimeStamp(time() + 86400, 'FULL'),
+ 100
+);
+```
+
+**ХОРОШО — ЧЕРЕЗ ABSTRACTAGENT::REGISTER()**
+```php
+// install/index.php — метод DoInstall():
+use Da\Skud\Agents\SkudV2Agent;
+
+// Все параметры определены константами в классе агента
+SkudV2Agent::register();
+
+// Проверка перед регистрацией:
+if (!SkudV2Agent::isRegistered()) {
+ SkudV2Agent::register();
+}
+
+// install/index.php — метод DoUninstall():
+SkudV2Agent::unregister();
+```
+
+---
+
+## Обработка исключений в агентах — критическое правило
+
+> ⚠️ **Агент НЕ должен пробрасывать исключение наружу.** Если `execute()` бросит необработанное исключение, Битрикс удалит агент из очереди и он перестанет выполняться.
+
+AbstractAgent перехватывает все исключения в методе `agent()` и логирует их — конкретный агент должен либо обрабатывать их внутри `execute()`, либо позволить AbstractAgent сделать это.
+
+**КАК ABSTRACTAGENT ЗАЩИЩАЕТ ОЧЕРЕДЬ АГЕНТОВ**
+```php
+// AbstractAgent::agent() — точка входа:
+public static function agent(): string
+{
+ $instance = new static();
+ try {
+ $instance->checkConditions();
+ return $instance->execute();
+ } catch (Exception $e) {
+ $instance->handleError($e);
+ return $instance->createNextCall(); // НЕ удаляем агент из очереди
+ }
+}
+```
diff --git a/src/index.md b/src/index.md
new file mode 100755
index 0000000000000000000000000000000000000000..fd4b9dcf9065ce68e6d6317305e9cc45abc51bf7
--- /dev/null
+++ b/src/index.md
@@ -0,0 +1,71 @@
+# Правила написания кода PHP-команды
+
+> Битрикс24 · ORM · SOLID · DDD
+> Полное руководство — версия 2.0
+
+**Платформа:** Битрикс24
+**PHP:** 8.1+ / 8.4
+**Версия документа:** 2.0 (исправленная и дополненная)
+**Год:** 2026
+
+`SOLID` · `DRY` · `KISS` · `YAGNI` · `ORM Bitrix` · `DTO` · `Repository` · `PSR-3` · `PSR-12` · `PHP 8.1+` · `AbstractAgent`
+
+---
+
+## О руководстве
+
+Это руководство описывает стандарты написания кода, принятые в PHP-команде. Оно опирается на принципы **SOLID, DRY, KISS, YAGNI** и адаптировано под специфику разработки на платформе **Битрикс24**.
+
+Каждый раздел построен по принципу «плохо / хорошо»: вы видите конкретный антипаттерн и его корректную альтернативу с объяснением. Правила применяются ко всему новому коду и должны учитываться при рефакторинге существующего.
+
+---
+
+## Содержание
+
+### Основные разделы
+
+| № | Раздел | Ключевые темы |
+|---|--------|---------------|
+| 01 | [Именование и читаемость](./01-naming.md) | PSR-12, Bitrix-соглашения, UF_* поля |
+| 02 | [Функции и методы](./02-functions.md) | SRP, ранний возврат, DTO, флаговые аргументы |
+| 03 | [Классы и объекты](./03-classes.md) | SOLID, DI, ServiceLocator, композиция |
+| 04 | [Контроллеры](./04-controllers.md) | тонкие контроллеры, DTO-валидация |
+| 05 | [Работа с массивами и коллекциями](./05-arrays.md) | array_filter, array_column, Collections |
+| 06 | [Условия и управляющие конструкции](./06-conditions.md) | match, Enum, Strategy, полиморфизм |
+| 07 | [Типизация и строгость](./07-typing.md) | strict_types, readonly DTO, нативная типизация |
+| 08 | [Обработка ошибок и Result Object](./08-errors.md) | доменные исключения, Bitrix Result |
+| 09 | [Безопасность](./09-security.md) | ORM vs raw SQL, XSS, валидация входных данных |
+| 10 | [Базы данных и ORM Битрикс](./10-database.md) | Repository, транзакции, N+1, fetchCollection |
+| 11 | [Производительность: кеш и TaggedCache](./11-performance.md) | Cache, TaggedCache, select нужных полей |
+| 12 | [Тестирование](./12-testing.md) | моки, DataProvider, изолируемые зависимости |
+| 13 | [Комментирование и документация](./13-comments.md) | самодокументируемый код, PHPDoc |
+| 14 | [Общие практики](./14-general.md) | DRY, KISS, YAGNI, Закон Деметры |
+| 15 | [События Bitrix (EventManager)](./15-events.md) | тонкие подписчики, Event API |
+| 16 | [Логирование (PSR-3)](./16-logging.md) | LoggerInterface, DI, уровни и контекст |
+| 17 | [Агенты — AbstractAgent](./17-agents.md) | полный паттерн, регистрация, обработка ошибок |
+
+### Тематические статьи (Tips)
+
+Отдельные глубокие разборы конкретных конструкций и паттернов PHP/Bitrix:
+
+| Статья | Описание |
+|--------|----------|
+| [Tips — все статьи](./tips/index.md) | Полный список тематических статей |
+| [isset() vs empty()](./tips/isset-vs-empty.md) | Детальный разбор, таблица поведения, подводные камни |
+
+---
+
+## Принципы, которыми руководствуемся
+
+**SOLID**
+- **S** — Single Responsibility: каждый класс/функция — одна ответственность
+- **O** — Open/Closed: открыт для расширения, закрыт для изменения
+- **L** — Liskov Substitution: подтипы взаимозаменяемы
+- **I** — Interface Segregation: маленькие специализированные интерфейсы
+- **D** — Dependency Inversion: зависим от абстракций, не от реализаций
+
+**Другие принципы**
+- **DRY** — Don't Repeat Yourself: не дублируй знания
+- **KISS** — Keep It Simple: простое решение лучше сложного
+- **YAGNI** — You Aren't Gonna Need It: реализуй только то, что нужно сейчас
+- **Закон Деметры** — не разговаривай с незнакомцами