git / code.ach.gov.ru / kabakov_iv / bitrix_php_code_standarts
commit 8d7155d1773270361acf1d5a495caa0f808815f2
author Кабаков Иван <kabakov_iv@da.gov.ru>
date 2026-04-16 15:03:04 +0300
parents 9229887d
message
Update README.md
files
| file | add | del |
|---|---|---|
| README.md | +1579 | -57 |
patch
diff --git a/README.md b/README.md
index 6b9ff1f5ddc4511c2f0a861448f67881a59b67b4..6e009576e879a0e8a6776c4a24d6cf86e491fdc5 100644
--- a/README.md
+++ b/README.md
@@ -1,93 +1,1615 @@
-# bitrix_php_code_standards
+# Правила написания кода 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);
+ }
+}
+```
-## Getting started
+**ХОРОШО — ДВА ЯВНЫХ МЕТОДА**
+```php
+function addProductToDeal(int $dealId, array $productRow): void {
+ // только добавление товара
+}
-To make it easy for you to get started with GitLab, here's a list of recommended next steps.
+function addProductToDealAndRecalculate(int $dealId, array $productRow): void {
+ $this->addProductToDeal($dealId, $productRow);
+ \CCrmDeal::Recalculate($dealId);
+}
+```
-Already a pro? Just edit this README.md and make it your own. Want to make it easy? [Use the template at the bottom](#editing-this-readme)!
+### Количество параметров ≤ 3–4, иначе — DTO
-## Add your files
+**ПЛОХО**
+```php
+function updateCompany(int $id, string $title, string $phone, string $email, ?string $address, bool $isActive) { /* ... */ }
+```
-- [ ] [Create](https://docs.gitlab.com/ee/user/project/repository/web_editor.html#create-a-file) or [upload](https://docs.gitlab.com/ee/user/project/repository/web_editor.html#upload-a-file) files
-- [ ] [Add files using the command line](https://docs.gitlab.com/ee/gitlab-basics/add-file.html#add-a-file-using-the-command-line) or push an existing Git repository with the following command:
+**ХОРОШО — 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 { /* ... */ }
```
-cd existing_repo
-git remote add origin https://code.ach.gov.ru/kabakov_iv/bitrix_php_code_standards.git
-git branch -M main
-git push -uf origin main
+
+### Ранний возврат (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;
+}
```
-## Integrate with your tools
+**ХОРОШО — 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;
+}
+```
+
+---
-- [ ] [Set up project integrations](https://code.ach.gov.ru/kabakov_iv/bitrix_php_code_standards/-/settings/integrations)
+## РАЗДЕЛ 03. Классы и объекты (SOLID, DI, ServiceLocator)
-## Collaborate with your team
+### Принцип единственной ответственности (SRP)
-- [ ] [Invite team members and collaborators](https://docs.gitlab.com/ee/user/project/members/)
-- [ ] [Create a new merge request](https://docs.gitlab.com/ee/user/project/merge_requests/creating_merge_requests.html)
-- [ ] [Automatically close issues from merge requests](https://docs.gitlab.com/ee/user/project/issues/managing_issues.html#closing-issues-automatically)
-- [ ] [Enable merge request approvals](https://docs.gitlab.com/ee/user/project/merge_requests/approvals/)
-- [ ] [Set auto-merge](https://docs.gitlab.com/ee/user/project/merge_requests/merge_when_pipeline_succeeds.html)
+**ПЛОХО — КЛАСС ДЕЛАЕТ ВСЁ СРАЗУ**
+```php
+class LeadManager {
+ public function create(array $data): void { /* ... */ }
+ public function sendEmail(string $message): void { /* ... */ }
+ public function log(string $action): void { /* ... */ }
+}
+```
-## Test and Deploy
+**ХОРОШО — РАЗДЕЛЕНИЕ ОТВЕТСТВЕННОСТЕЙ**
+```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 {
+ // только логика создания лида
+ }
+}
+```
-Use the built-in continuous integration in GitLab.
+### Предпочитайте композицию наследованию
-- [ ] [Get started with GitLab CI/CD](https://docs.gitlab.com/ee/ci/quick_start/index.html)
-- [ ] [Analyze your code for known vulnerabilities with Static Application Security Testing (SAST)](https://docs.gitlab.com/ee/user/application_security/sast/)
-- [ ] [Deploy to Kubernetes, Amazon EC2, or Amazon ECS using Auto Deploy](https://docs.gitlab.com/ee/topics/autodevops/requirements.html)
-- [ ] [Use pull-based deployments for improved Kubernetes management](https://docs.gitlab.com/ee/user/clusters/agent/)
-- [ ] [Set up protected environments](https://docs.gitlab.com/ee/ci/environments/protected_environments.html)
+**ПЛОХО — НАСЛЕДОВАНИЕ ОТ УСТАРЕВШЕГО КЛАССА**
+```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()));
+ }
+ }
+}
+```
-# Editing this README
+### ServiceLocator Bitrix для внедрения зависимостей
-When you're ready to make this README your own, just edit this file and use the handy template below (or feel free to structure it however you want - this is just a starting point!). Thanks to [makeareadme.com](https://www.makeareadme.com/) for this template.
+**ПЛОХО — РУЧНОЕ СОЗДАНИЕ ЗАВИСИМОСТЕЙ ПОВСЮДУ**
+```php
+// В каждом месте использования:
+$logger = new FileLogger('/local/logs/app.log');
+$factory = \Bitrix\Crm\Service\Container::getInstance()
+ ->getFactory(\CCrmOwnerType::Lead);
+$service = new LeadCreationService($factory, new LeadNotificationService(), $logger);
+```
-## Suggestions for a good README
+**ХОРОШО — РЕГИСТРАЦИЯ ОДИН РАЗ, ПОЛУЧЕНИЕ ВЕЗДЕ**
+```php
+use Bitrix\Main\DI\ServiceLocator;
-Every project is different, so consider which of these sections apply to yours. The sections used in the template are suggestions for most open source projects. Also keep in mind that while a README can be too long and detailed, too long is better than too short. If you think your README is too long, consider utilizing another form of documentation rather than cutting out information.
+// Один раз:
+$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),
+ );
+ }
+);
-## Name
-Choose a self-explaining name for your project.
+// Получение в любом месте приложения:
+$service = ServiceLocator::getInstance()->get(LeadCreationService::class);
+```
-## Description
-Let people know what your project can do specifically. Provide context and add a link to any reference visitors might be unfamiliar with. A list of Features or a Background subsection can also be added here. If there are alternatives to your project, this is a good place to list differentiating factors.
+### Избегайте статических методов для бизнес-логики
-## Badges
-On some READMEs, you may see small images that convey metadata, such as whether or not all the tests are passing for the project. You can use Shields to add some to your README. Many services also have instructions for adding a badge.
+**ПЛОХО — ПРЯМОЙ ВЫЗОВ УСТАРЕВШЕГО API**
+```php
+$leadId = \CCrmLead::Add(['TITLE' => 'Новый лид']);
+```
-## Visuals
-Depending on what you are making, it can be a good idea to include screenshots or even a video (you'll frequently see GIFs rather than actual videos). Tools like ttygif can help, but check out Asciinema for a more sophisticated method.
+**ХОРОШО — ОБОРАЧИВАЕМ В СЕРВИС**
+```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();
+ }
+}
+```
-## Installation
-Within a particular ecosystem, there may be a common way of installing things, such as using Yarn, NuGet, or Homebrew. However, consider the possibility that whoever is reading your README is a novice and would like more guidance. Listing specific steps helps remove ambiguity and gets people to using your project as quickly as possible. If it only runs in a specific context like a particular programming language version or operating system or has dependencies that have to be installed manually, also add a Requirements subsection.
+---
-## Usage
-Use examples liberally, and show the expected output if you can. It's helpful to have inline the smallest example of usage that you can demonstrate, while providing links to more sophisticated examples if they are too long to reasonably include in the README.
+## РАЗДЕЛ 04. Контроллеры
-## Support
-Tell people where they can go to for help. It can be any combination of an issue tracker, a chat room, an email address, etc.
+### Тонкие контроллеры — вся логика в сервисах
-## Roadmap
-If you have ideas for releases in the future, it is a good idea to list them in the README.
+**ПЛОХО — ЛОГИКА ПРЯМО В КОМПОНЕНТЕ**
+```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;
+ }
+}
+```
-## Contributing
-State if you are open to contributions and what your requirements are for accepting them.
+**ХОРОШО — СЕРВИС С КОРРЕКТНЫМ 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();
+ }
+}
-For people who want to make changes to your project, it's helpful to have some documentation on how to get started. Perhaps there is a script that they should run or some environment variables that they need to set. Make these steps explicit. These instructions could also be useful to your future self.
+// В компоненте:
+$arResult['ITEMS'] = (new NewsService())->getFilteredNews([
+ 'search' => $_REQUEST['search'] ?? '',
+]);
+```
-You can also document commands to lint the code or run tests. These steps help to ensure high code quality and reduce the likelihood that the changes inadvertently break something. Having instructions for running tests is especially helpful if it requires external setup, such as starting a Selenium server for testing in a browser.
+### DTO для валидации входящих данных
-## Authors and acknowledgment
-Show your appreciation to those who have contributed to the project.
+**ПЛОХО — СЫРАЯ ВАЛИДАЦИЯ В КОНТРОЛЛЕРЕ**
+```php
+$title = $_POST['TITLE'] ?? '';
+if (strlen($title) < 3) {
+ $error = 'Заголовок слишком короткий';
+} else {
+ \CCrmLead::Add(['TITLE' => $title]);
+}
+```
-## License
-For open source projects, say how it is licensed.
+**ХОРОШО — 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,
+ );
+ }
+}
-## Project status
-If you have run out of energy or time for your project, put a note at the top of the README saying that development has slowed down or stopped completely. Someone may choose to fork your project or volunteer to step in as a maintainer or owner, allowing your project to keep going. You can also make an explicit request for maintainers.
+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);
+ }
+}
+```
+
+**ХОРОШО — ПАТТЕРН 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(),
+);
+```
+
+---
+
+## РАЗДЕЛ 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();
+
+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();
+```
+
+---
+
+## РАЗДЕЛ 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());
+ }
+}
+```
+
+### 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],
+ ];
+ }
+}
+```
+
+---
+
+## РАЗДЕЛ 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 { /* ... */ }
+```
+
+---
+
+## РАЗДЕЛ 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