Александр Некрасов · Автономное руководство

PHP: полный мануал с примерами, функциями и ООП

Разделы этой страницы
  1. Свой B24-клиент: одна строка, все сделки и ядро сервиса
  2. Первый запуск готовых файлов: настройка → клиент → вызов → массив
  3. Первая полезная строка: получить все сделки для отчёта
  4. Что объект добавляет к обычным переменным и функциям
  5. Кто кого вызывает: listMethod, oneMethod, транспорт и JSON
  6. Как собрать все страницы и не копить старые ответы
  7. Guzzle через Composer: сохранить удобство клиента, отделить доставку
  8. Что проверено у транспорта и где заканчивается учебный пример
  9. Composer и src: как PHP находит класс
  10. Получили список — отобразили или передали дальше
  11. Следующий шаг: приложение вызывает ваш домен, а не четыре API
  12. Два банка: одинаковое действие, разные поля и ответы
  13. Сбой посередине: что уже произошло и что можно повторить
  14. Повтор с телефона: память объекта и сохранённое состояние
  15. Запуск лаборатории и маршрут чтения исходников
  16. Код и руководство с собой
PHP 8.5.11149 глав4458 карточек справочника
Дерево руководства

Глава 59 · От первого клиента к сервису

Свой B24-клиент: одна строка, все сделки и ядро сервиса

От понятного listMethod для Bitrix24 к собственному ядру: токены, Guzzle, страницы API, два банка, Telegram, отказы и повторные заявки.

Первый запуск готовых файлов: настройка → клиент → вызов → массив

Практическая цель первого захода — запустить учебный клиент на своём компьютере, получить одну понятную карточку и увидеть, куда пришли её поля. Не нужно сразу понимать банк, хранилище операций и очередь. Это отдельная локальная лаборатория: распакованный проект запускается PHP, а не устанавливается в WordPress.

Нужны установленный PHP и терминал, массив и ключ, перебор foreach и объект и вызов метода. Для первого запуска достаточно уметь сохранить файл и выполнить команду. Изучать внутренние интерфейсы до получения результата необязательно.

Какие данные проходят первый опыт
  1. bootstrap.php подключает определения; сценарий success выбирает вымышленные успешные ответы
  2. demoKernel создаёт настроенные объекты; из массива сборки берём клиента crm
  3. call получает имя метода crm.lead.get и параметр id = 1001
  4. Локальный транспорт возвращает данные; клиент отдаёт массив ID/TITLE
  5. Echo и foreach читают массив; никакого нового обращения к CRM при переборе нет

Шаг 1. Скачайте ZIP в конце этой главы и распакуйте его в отдельную папку. Откройте терминал в папке, где лежат bootstrap.php, README.md и каталоги bin/src/public. Проверьте php -v, затем выполните php bin/list-lab.php. Готовый файл должен сообщить: 52 сделки, первая «Учебная сделка 1», последняя «Учебная сделка 52», два запроса страниц, повторный вызов — 52, не 104.

Если вывод совпал, окружение и файлы уже работают. «Could not open input file» обычно означает, что команда запущена не из корня распакованного проекта; ошибка подключения bootstrap.php означает, что потеряна структура папок или скопирован только один файл. Не скачивайте случайные зависимости для исправления: основной проект специально работает без Composer и внешней сети.

Шаг 2. В корне рядом с bootstrap.php создайте first-call.php, вставьте следующий пример целиком и выполните php first-call.php. Папка src должна оставаться на месте. В примере подготовки и выполнения больше не спрятаны в одной длинной записи: каждую переменную можно прочитать сверху вниз.

Строка или имяРоль в готовом проекте
require __DIR__ . '/bootstrap.php';Подключить файл по пути от текущего скрипта. __DIR__ — каталог этого файла, точка соединяет строки пути. Подключение объявляет классы и функцию сборки.
IntegrationDemo\demoKernel($scenario)Вызвать функцию сборки из пространства имён IntegrationDemo. Она создаёт объекты с DemoTransport и заведомо ненастоящими настройками. Это не запрос к вашему порталу.
$kernel['crm']Достать готовый клиент из массива сборки. В $obj теперь объект с методами, а не данные сделки.
$method и $parametersВыбрать одну операцию и её аргументы. Поле id относится к этому вызову, а не к постоянным настройкам клиента.
$item = $obj->call(...)Выполнить операцию, дождаться её результата и сохранить возвращённый массив.
foreach ($item as $field => $value)На каждой итерации получить очередную пару: например TITLE и Учебный лид. Фигурные скобки задают повторяемый вывод.
Получить одну учебную карточку и перебрать возвращённый массивСамостоятельный пример
<?php
declare(strict_types=1);

require __DIR__ . '/bootstrap.php';

$scenario = 'success';
$kernel = IntegrationDemo\demoKernel($scenario);
$obj = $kernel['crm'];

$method = 'crm.lead.get';
$parameters = ['id' => 1001];
$item = $obj->call($method, $parameters);

echo 'Название: ', $item['TITLE'], "\n";
foreach ($item as $field => $value) {
    echo $field, ': ', $value, "\n";
}
Ожидаемый результат
Название: Учебный лид
ID: 1001
TITLE: Учебный лид

Call вернул массив полей одной карточки. Echo прочитал конкретное поле TITLE; foreach затем по очереди положил ключ в $field, значение в $value и выполнил вывод. Перебор не отправляет повторных запросов: ответ уже находится в $item. В этой известной фикстуре оба значения строки; произвольный вложенный ответ нельзя целиком выводить таким echo без проверки структуры.

Шаг 3. Поменяйте только id на 1002: локальная фикстура вернёт ID 1002 с тем же учебным названием. Это способ наблюдать передачу аргумента, а не подтверждение существования карточки в реальном Bitrix24. Затем вернитесь к bin/list-lab.php: listMethod отличается тем, что возвращает список карточек. В foreach по такому списку текущая переменная будет целым массивом сделки, из которого читают TITLE.

Параметры конструктора, URL и получение токена не смешиваются с полями одной выборки. В этом старте bootstrap.php намеренно выбирает только DemoTransport; реальный URL или ключ туда вводить не нужно. Позже пример создания клиента раскроет четыре отдельных new и покажет счётчики: создание не запрашивает данные и ещё не читает токен.

Что открыть после первого успехаКакой вопрос решает файл
bin/list-lab.phpКак настроить выборку и получить 52 строки без ручного цикла страниц в отчёте.
src/Clients.php → B24ClientЧем call, oneMethod и listMethod различаются по возвращаемым данным.
src/DemoTransport.phpОткуда взялись учебные ответы; почему здесь нет сети и настоящей CRM.
bootstrap.phpГде клиент получает API-объект, транспорт и источник токена. Детали читать после простого примера передачи помощника.
public/index.php и bin/demo.phpСледующий этап: HTTP-заявка либо CLI-сценарий вызывает общее ядро. Сначала прочитайте переход от одной CRM к ядру.

Первый результат не требует запуска веб-сервера. Когда захотите проследить мобильное приложение → HTTP → массив → сервис → JSON, используйте команды из раздела запуска: php -S 127.0.0.1:8772 -t public и отдельный POST. Там описаны точные адрес, поля, ошибки и повтор заявки. Не начинайте с подключения Guzzle и настоящего банка: сначала установите границу между работающим вызовом, полученным массивом и его обработкой.

Первая полезная строка: получить все сделки для отчёта

Наглядная модель

Свой домен принимает заявку, ядро координирует клиентов

Мобильному приложению нужен один понятный запрос. Внутри ядра остаются проверка заявки, разные API и сохранение исхода.

  1. Вход: JSON заявки

    Ключ запроса, SKU, количество и банк проверяются; сумму ядро получает из своего каталога.

  2. IntakeService и сохранённая попытка

    До внешних шагов фиксируется намерение. Тот же ключ и вход позволяют вернуть сохранённый результат.

  3. Последовательность внутри ядра

    Каждый клиент знает свой договор. Следующий шаг зависит от результата предыдущего.

    • 1. B24Client → заявка в CRM

      createLead создаёт заявку. Для отдельного отчёта listMethod собирает страницы 50 + 2 сделки.

    • 2. BankGateway → счёт

      Выбран North или South; разные поля адаптеров превращаются в единый Invoice.

    • 3. TelegramClient → уведомление

      Сообщение отправляется после получения счёта; сбой уведомления не отменяет уже созданный счёт.

  4. Ответ и состояние в хранилище

    completed означает создание счёта и уведомление. bank_unknown требует выяснить исход, а не повторять вслепую.

Учебный проект использует локальные двойники без реальных токенов и банковских запросов. completed не означает «оплачено». После HTTP-запроса данные сохраняет хранилище, а не само существование объекта клиента.

Представьте знакомую задачу: утром нужны все сделки из CRM для отчёта. Вы знаете поля, которые хотите получить. Но API отдаёт данные страницами; нужно собрать адрес, добавить авторизацию, отправить HTTP, разобрать JSON, проверить ошибку, перейти к следующей странице и ничего не продублировать. Если каждый отчёт снова пишет эти действия, исправлять их придётся во многих местах.

Именно для этого нужен собственный клиент. Вы один раз настраиваете объект, а в рабочем коде читаете намерение: $result = $obj->listMethod($arrToReq, 'crm.deal.list');. Идея продолжает авторскую заготовку для Bitrix24: сложная внутренняя работа превращается в короткую понятную операцию. Ниже новая самостоятельная реализация с локальными ответами; реальные ключи не нужны.

52 сделки одной строкой вызова клиентаСамостоятельный пример
<?php
declare(strict_types=1);

require __DIR__ . '/bootstrap.php';

$obj = IntegrationDemo\demoKernel()['crm'];
$arrToReq = ['select' => ['ID', 'TITLE'], 'order' => ['ID' => 'ASC']];
$result = $obj->listMethod($arrToReq, 'crm.deal.list');

echo 'Сделок: ', count($result), "\n";
echo $result[0]['TITLE'], "\n";
echo $result[array_key_last($result)]['TITLE'], "\n";
Ожидаемый результат
Сделок: 52
Учебная сделка 1
Учебная сделка 52

Скачиваемый пример включает bootstrap.php и src: запускайте его после распаковки всего ZIP. Локальный транспорт возвращает 50 записей, затем ещё 2. Это настоящая работа нашего цикла и массивов, но вымышленные ответы CRM. Фильтрация фикстуры не реализована: этот маленький внешний мир проверяет пагинацию, а не подменяет весь Bitrix24.

Первые три переменные имеют разные роли. $obj — уже настроенный инструмент с методами. $arrToReq — настройки одной выборки: какие поля, какой порядок, какие условия. $result — готовые данные, которые можно перебрать и превратить в HTML, CSV или JSON. Вы меняете условия отчёта, а не переписываете сетевой код. Оператор -> означает обращение к методу конкретного объекта.

Из того же объекта можно вызвать $page = $obj->oneMethod($arrToReq, 'crm.deal.list');. Тогда получите одну страницу: $page['result'] содержит первые50 записей, а $page['next'] подсказывает продолжение. Нужна текущая страница интерфейса — oneMethod. Нужен весь ограниченный список для массовой обработки — listMethod. Выбор понятен в месте вызова; реализация авторизации остаётся общей.

Что объект добавляет к обычным переменным и функциям

Сравнить одну и ту же задачу в трёх вариантах — копирование, нормальные функции с явными зависимостями, настроенный объект — можно в главе «От процедурного к объектному коду». Процедурное решение не обязано копировать HTTP-код: функции также можно вынести в файл, загрузить через Composer и повторно использовать.

Без класса можно написать loadDeals($http, $portal, $token, $filter). Это рабочий вариант. Проблема появляется, когда десять функций должны каждый раз получать согласованные URL, токен, обработчик ошибок и политику повторов. Один вызывающий код передаст токен другого портала, другой забудет тайм-аут, третий обработает только первую страницу.

Объект клиента связывает настройки с действиями. Один экземпляр может обслуживать ваш портал, другой — учебный портал. Их параметры не лежат в общей глобальной переменной и не перезаписывают друг друга. При этом общий алгоритм остаётся в классе. Исправление listMethod сразу действует в отчёте продаж, ночном экспорте и административной странице, если они используют этот класс.

Класс похож на описание инструмента: какие зависимости нужны и какие операции доступны. new создаёт конкретный экземпляр и вызывает конструктор. В данном проекте конструктор только сохраняет зависимости; список сделок он пока не запрашивает. Создание стандартного Guzzle Client также само по себе не выполняет HTTP. Это важная граница: подготовка клиента и выполнение команды — разные события.

Создание клиента ещё не читает токен и не делает запросСамостоятельный пример
<?php
declare(strict_types=1);

require __DIR__ . '/bootstrap.php';

$transport = new IntegrationDemo\DemoTransport();
$tokens = new IntegrationDemo\DemoTokenProvider('bitrix');
$api = new IntegrationDemo\ApiClient('bitrix', $transport, $tokens);
$obj = new IntegrationDemo\B24Client($api);
echo count($transport->calls()), ':', $tokens->reads, "\n";
$item = $obj->item(1001);
echo count($transport->calls()), ':', $tokens->reads, ':', $item['TITLE'], "\n";
Ожидаемый результат
0:0
1:1:Учебный лид

Первое число — вызовы транспорта, второе — чтения TokenProvider. private ограничивает доступ к внутренней сборке клиента, а public item() предоставляет понятное действие. Конструктор не обязан быть без I/O по правилам языка; это осознанный выбор этого проекта.

Попробуйте. Нужен второй портал для тестов. Почему недостаточно поменять глобальный $token перед вызовом старого объекта?

Разбор решения

Клиент уже получил свои зависимости. Создайте второй экземпляр с другим TokenProvider и транспортной конфигурацией. Так выбор портала виден в сборке приложения, а не зависит от того, какая строка последней переписала глобальное состояние.

Кто кого вызывает: listMethod, oneMethod, транспорт и JSON

От одной строки в отчёте до массива сделок
  1. Отчёт вызывает B24Client::listMethod(params, method)
  2. listMethod задаёт start и вызывает oneMethod
  3. oneMethod просит ApiClient вызвать API
  4. ApiClient получает текущий токен у TokenProvider
  5. Transport возвращает массив envelope
  6. oneMethod проверяет ошибку, listMethod добавляет result и читает next
  7. Отчёт получает массив всех элементов в пределах бюджета
СлойЧто знаетЧто возвращает
B24Client::oneMethodФорму ответа Bitrix24: result, error, nextПроверенный envelope целиком
B24Client::callУдобный доступ к одному resultРезультат операции; тип зависит от метода
B24Client::listMethodПроход next/start и ограничения выборкиМассив записей или исключение, без тихой обрезки
B24Client::item / createLeadНазвания полей конкретной операцииПроверенный массив карточки / integer ID
ApiClientКакой сервис и TokenProvider использоватьМассив ответа от транспорта
TransportКак доставить запрос: локально или через HTTPДекодированный envelope; ошибки доставки отдельно

JSON приходит по сети как текст. После json_decode($body, true, flags: JSON_THROW_ON_ERROR) объекты JSON становятся ассоциативными массивами, а списки — массивами с числовыми индексами. $page['result'][0]['TITLE'] означает: полезный результат → первая запись → название. В нашем DemoTransport фикстура уже является массивом; настоящий Guzzle-адаптер явно выполняет декодирование.

Не всякий HTTP200 означает успех API. Bitrix может вернуть error, Telegram — ok:false. Поэтому проверки делаются в адаптере конкретного сервиса. Не надо заставлять общий HTTP-транспорт угадывать, что result значит у банка и в CRM. В частности, наличие error проверяется array_key_exists: пустая строка тоже остаётся полем ошибки.

Метод oneMethod сохраняет next/total для тех, кому нужна одна страница и её метаданные. call возвращает только result и удобен для одиночной операции. listMethod для отчёта намеренно берёт на себя сбор нескольких страниц. Это три разных договора, а не три случайных названия одной функции.

Как собрать все страницы и не копить старые ответы

В listMethod массив $items = [] создаётся при каждом входе в метод. Он не является свойством клиента. Поэтому два вызова на одном объекте дают два независимых результата. Свойство-накопитель потребовало бы отдельного cleanArr и сделало результат зависимым от истории использования объекта. Ошибка особенно неприятна в длинном воркере, который обслуживает много задач.

Алгоритм использует обычный цикл: запросить страницу, проверить result, добавить элементы foreach, посмотреть next. Если next отсутствует — закончить. Если next не продвигается вперёд — остановиться с ошибкой. Рекурсия здесь не даёт преимуществ: цикл яснее отражает повтор одной операции и удобнее считает бюджеты. Полная реализация находится в src/Clients.php.

Повторная выборка не удваивает массивСамостоятельный пример
<?php
declare(strict_types=1);

require __DIR__ . '/bootstrap.php';

$kernel = IntegrationDemo\demoKernel();
$obj = $kernel['crm'];
foreach ([1, 2] as $run) {
    $rows = $obj->listMethod([], 'crm.deal.list');
    echo $run, ': ', count($rows), "\n";
}
echo 'Вызовов: ', count($kernel['transport']->calls()), "\n";
try { $obj->listMethod([], 'crm.deal.list', maxPages: 1); }
catch (LengthException) { echo "Лимит страниц: отчёт не выдан частично\n"; }
Ожидаемый результат
1: 52
2: 52
Вызовов: 4
Лимит страниц: отчёт не выдан частично

Две страницы на выборку означают четыре обращения к локальному транспорту. maxPages и maxItems защищают память и время. Если бюджет закончился, читатель получает понятную ошибку; число записей в отчёте не становится случайно неполным.

Собрать миллион записей в одном массиве дорого по памяти: сложность хранения растёт с числом элементов. Для больших экспортов нужен другой договор — генератор страниц или обработчик каждой страницы, который пишет CSV частями и сохраняет checkpoint. Это отдельный метод вроде iteratePages, а не неожиданное изменение listMethod. Само использование ООП не ускоряет сеть.

Пауза sleep(1) может быть частью простого последовательного CLI-экспорта, но не доказывает соблюдение всех лимитов Bitrix24. Несколько процессов делят общий лимит портала; возможны разные ограничения методов. В проекте повторяется только чтение списка при явном RateLimited: не более трёх попыток страницы и пяти секунд суммарного ожидания. Для тестов Pause записывает секунды без сна. В настоящем воркере BlockingPause вызывает sleep, а длинный retry_after означает перенос задачи, не удержание HTTP-запроса пользователя.

Стабильный order по ID полезен, но offset-пагинация не даёт снимка базы: сделки могут изменяться во время выгрузки. Для важных отчётов продумайте границу периода, повторную сверку, ключевую пагинацию при поддержке API и возобновление после сбоя. Количество страниц и состояние внешней CRM — разные гарантии.

Guzzle через Composer: сохранить удобство клиента, отделить доставку

В авторской заготовке B24 наследовался от GuzzleHttp\Client: это давало знакомый request рядом с собственными методами. Практический выигрыш — всё тот же короткий вызов для читателя. В новой реализации тот же результат достигается композицией: B24 использует свой ApiClient, а адаптер транспорта хранит GuzzleHttp\ClientInterface. Так можно заменить транспорт фикстурой, не переписывая отчёт и не делать все публичные методы общего HTTP-клиента частью договора CRM.

Команда composer require guzzlehttp/guzzle:^7.0 устанавливает зависимость; затем приложение подключает vendor/autoload.php. Composer скачивает код библиотеки, не выполняет вашу операцию CRM. Основной архив этого урока не требует Composer и интернета. Опциональный GuzzleB24Transport лежит отдельно и не подключается bootstrap.php.

Как подставить Guzzle после настройки настоящей авторизацииПример для указанного окружения
<?php
declare(strict_types=1);

require __DIR__ . '/vendor/autoload.php';
require __DIR__ . '/bootstrap.php';
require __DIR__ . '/optional/GuzzleB24Transport.php';

// $tokens implements TokenProvider and reads your protected OAuth store.
// The trusted portal address is configuration, never a mobile request field.
$http = new GuzzleHttp\Client();
$transport = new IntegrationDemo\GuzzleB24Transport(
    $http, 'https://portal.example/rest/', allowNetwork: false,
);
// Keep false while studying. No real HTTP is enabled by this example.
// After implementing $tokens, wiring preserves the same client methods:
// $obj = new IntegrationDemo\B24Client(
//     new IntegrationDemo\ApiClient('bitrix', $transport, $tokens),
//     new IntegrationDemo\BlockingPause(),
// );

Контекстный пример: нужен Composer и файлы проекта, настоящий TokenProvider здесь намеренно не подставлен. Адаптер по умолчанию запрещает сеть. Он показывает структуру подключения и не является готовым OAuth-приложением.

Внутри адаптер вызывает request с тремя аргументами: HTTP-метод, адрес и массив options. В options находятся json, timeout, connect_timeout, http_errors:false и allow_redirects:false. Токен OAuth Bitrix передаётся полем auth внутри JSON; опция Guzzle auth означает HTTP Basic/Digest/NTLM и не заменяет этот протокол. Пользовательские параметры не могут подменить зарезервированное поле auth.

Webhook из первоначальной статьи — отдельная схема, где секрет входит в путь адреса. Нельзя приклеить webhook URL к OAuth-адаптеру и надеяться на автоматическое определение. Конфигурация, TokenProvider и адаптер должны явно знать выбранную схему. Обновление access token требует реализации refresh, защищённого хранения и координации параллельных запросов; интерфейс с названием TokenProvider сам этого не делает.

При ошибке Guzzle сообщение исключения, request/response и previous могут содержать URL или тело с токеном. Адаптер выдаёт безопасное доменное исключение без исходного объекта. В рабочий журнал допускают ограниченный набор полей: провайдер, операция, безопасный код, длительность и correlationId. History middleware и debug полезны в локальном тесте с фиктивными ключами, но сами по себе ничего не маскируют.

Что проверено у транспорта и где заканчивается учебный пример

У опционального Guzzle-адаптера проверен синтаксис и проведён разбор кода. Настоящие CRM-запросы и OAuth refresh не выполнялись; основной запускаемый опыт использует DemoTransport. Не считайте наличие интерфейса доказательством доступности внешнего сервиса. Для подключения реального портала нужен отдельный этап настройки, тесты с MockHandler, затем разрешённый тестовый контур и проверка его договоров.

Ограничение1МБ в исходнике применяется при чтении уже полученного response body. В показанной конфигурации Guzzle мог ранее скачать и буферизовать больше данных; это защита последующего разбора, а не жёсткий предел сетевого трафика/общей памяти. Для недоверенных больших ответов нужен отдельный streaming/sink-вариант с лимитом записи и проверенными тайм-аутами конкретного handler. Не добавляйте stream:true механически: меняются моменты ожидания и обработки ошибок.

Длительный Retry-After не сокращается до удобной одной секунды: если ожидание больше пяти секунд либо передано HTTP-date, адаптер требует отложенной обработки. Пять секунд — учебный бюджет, не лимит Bitrix. Операции записи не повторяются автоматически, потому что потеря ответа может скрывать уже выполненный эффект.

Composer и src: как PHP находит класс

В первом опыте bootstrap.php явно подключает несколько небольших учебных файлов. Это позволяет запустить архив без Composer. В обычном проекте каждый класс чаще лежит в отдельном файле: App\Integration\B24Client — в src/Integration/B24Client.php. Правило PSR-4 "App\\": "src/" сопоставляет префикс пространства имён с каталогом; оставшаяся часть имени превращается в путь.

После изменения autoload в composer.json выполняют composer dump-autoload. Подключение require __DIR__ . '/vendor/autoload.php'; регистрирует загрузчик. Оно не исполняет заранее все классы проекта: когда PHP впервые потребует B24Client, загрузчик найдёт нужный файл. Этот механизм работает и для процедурного кода через autoload.files: перечисленные файлы функций, наоборот, подключаются при загрузке Composer. Подробности и команды — в главе о Composer.

Начинать с собственного клиента полезнее, чем сразу раскладывать один отчёт на десятки каталогов. Но когда появляются два портала, тестовый транспорт и отдельный разработчик банковской интеграции, границы файлов отражают ответственность. HTTP-контроллер не читает файл токена в каждой ветке; bootstrap собирает объекты, а сервис вызывает доступные ему действия.

Получили список — отобразили или передали дальше

Ваш клиент не должен знать, где читатель покажет сделки: в обычной странице, интерфейсе приложения внутри iframe или консольном отчёте. Он возвращает массив. HTML-представление экранирует TITLE, JSON-ответ кодирует массив, обработчик массовой задачи преобразует нужные поля и вызывает следующий клиент. Секреты остаются на сервере; наличие iframe не делает безопасной передачу webhook в JavaScript.

Один клиент получает сделки, второй отправляет подготовленный текстСамостоятельный пример
<?php
declare(strict_types=1);

require __DIR__ . '/bootstrap.php';

$kernel = IntegrationDemo\demoKernel();
$deals = $kernel['crm']->listMethod(['select' => ['ID', 'TITLE']], 'crm.deal.list');
foreach (array_slice($deals, 0, 2) as $deal) {
    $text = 'Отчёт: ' . $deal['TITLE'];
    $kernel['telegram']->send($text);
    echo $text, "\n";
}
echo 'Вызовов: ', count($kernel['transport']->calls()), "\n";
Ожидаемый результат
Отчёт: Учебная сделка 1
Отчёт: Учебная сделка 2
Вызовов: 4

Два чтения страниц и два fake-уведомления. Ограничение до двух записей выбрано специально: пример учит преобразованию, не рекомендует синхронно рассылать каждую сделку в реальный Telegram. Для массовой отправки нужна очередь, лимиты и учёт уже обработанных записей.

Обратите внимание на экономию контекста: вызывающий код видит получить → преобразовать → отправить. Исправление разбора страниц происходит внутри B24Client и сразу помогает этому сценарию, отчёту в iframe и CLI-экспорту. Изменение формата Telegram происходит в его клиенте. Сам порядок двух действий по-прежнему виден в сценарии — объект не должен скрывать внезапную рассылку внутри обычного получения списка.

Следующий шаг: приложение вызывает ваш домен, а не четыре API

Теперь у мобильного приложения появилась кнопка «Оформить». Ему не нужны токен Telegram, webhook CRM и ключи двух банков. Оно отправляет вашему ядру заявку: request_id, sku, quantity, bank. Сервер проверяет пользователя и права в рабочем варианте, валидирует форму сообщения и берёт цену из своего каталога. Отправленная клиентом сумма не является доверенной ценой.

Учебный endpoint public/index.php доступен только на localhost. Он читает ограниченное JSON-тело, проверяет типы и передаёт Intake в один и тот же сервис, который вызывается из CLI. Поэтому сценарий оформления не размножается в мобильном контроллере, веб-форме и консольном импорте. Изменился вход — договор ядра остался тем же.

Вход приложения превращается в проверенный запросСамостоятельный пример
<?php
declare(strict_types=1);

require __DIR__ . '/bootstrap.php';

$json = '{"request_id":"mobile-1042","sku":"lesson","quantity":2,"bank":"north"}';
$data = json_decode($json, true, 16, JSON_THROW_ON_ERROR);
$request = IntegrationDemo\Intake::fromArray($data);
echo $request->sku, ': ', $request->quantity, ': ', $request->amountMinor, "\n";
try { IntegrationDemo\Intake::fromArray($data + ['amount_minor' => 1]); }
catch (IntegrationDemo\InvalidInput) { echo "Цена от клиента не принята\n"; }
Ожидаемый результат
lesson: 2: 259800
Цена от клиента не принята

JSON number 2 становится integer, строка "2" отклоняется этим договором. Цена за lesson находится на сервере; amountMinor вычисляется в копейках. Полный HTTP-код дополнительно ограничивает размер, Content-Type, метод и допустимый Origin.

Одна заявка через собственное ядро
  1. Приложение → POST JSON на ваш домен
  2. Контроллер → проверенный Intake
  3. IntakeService → CRM, затем выбранный BankGateway
  4. После подтверждённого счёта → TelegramClient
  5. Хранилище фиксирует результат → JSON приложению

Объект PHP не живёт автоматически между двумя HTTP-запросами. Обычный request заново создаёт kernel и клиентов; локальные переменные исчезают после завершения. Результат предыдущей заявки читается из хранилища. В долгоживущем worker объекты могут сохраняться дольше, и именно поэтому нельзя держать текущего пользователя или накопленные сделки в случайном общем свойстве.

Два банка: одинаковое действие, разные поля и ответы

Вымышленный North принимает amount_minor=259800 и возвращает invoice_id. Вымышленный South принимает строку sum="2598.00" и возвращает bill.reference. Если контроллер знает оба формата, каждый новый банк заставляет править код входа. Интерфейс BankGateway задаёт действие createInvoice(Intake): Invoice; каждый адаптер переводит собственный формат в общий результат.

Строка для South собирается через intdiv и остаток, без float. Это не правило всех банков: настоящая документация может требовать иной масштаб валюты, подпись или тип суммы. Оба договора здесь специально вымышлены, чтобы сравнить адаптеры без иллюзии готового платёжного SDK.

Смена банка не меняет вызов сервисаСамостоятельный пример
<?php
declare(strict_types=1);

require __DIR__ . '/bootstrap.php';

$kernel = IntegrationDemo\demoKernel();
foreach (['north', 'south'] as $bank) {
    $input = IntegrationDemo\demoInput('demo-' . $bank, $bank);
    $result = $kernel['service']->submit(IntegrationDemo\Intake::fromArray($input));
    echo $result['bank'], ': ', $result['invoice_id'], ': ', $result['amount_minor'], "\n";
}
Ожидаемый результат
north: N-demo-north: 259800
south: S-demo-south: 259800

Сервис вызывает один договор BankGateway. Выбор адаптера происходит по серверной карте банков. В показанной операции создаётся счёт, а не списываются деньги; completed не означает paid.

Третий разработчик может реализовать новый адаптер по этому интерфейсу, добавить фикстуры и зарегистрировать его в bootstrap. Основной submit не меняется из-за названия поля суммы. Но подключение всё равно требует продуктовой работы: кому банк доступен, какие валюты, как сверять статус, как проверить подпись webhook. Интерфейс убирает лишние связи в коде, не отменяет особенности бизнеса.

Общее журналирование можно разобрать в практическом примере B24 и Telegram с одним трейтом. Даже если B24 и Telegram используют один метод log, смысл ответа у них различен; безопасная форма события должна очищать секреты и персональные данные до записи. Повторное использование кода не является разрешением сохранить весь ответ API в открытый файл.

Сбой посередине: что уже произошло и что можно повторить

Четыре исхода одной заявкиСамостоятельный пример
<?php
declare(strict_types=1);

require __DIR__ . '/bootstrap.php';

foreach (['success', 'crm-failure', 'bank-unknown', 'telegram-failure'] as $scenario) {
    $kernel = IntegrationDemo\demoKernel($scenario);
    $result = $kernel['service']->submit(IntegrationDemo\Intake::fromArray(IntegrationDemo\demoInput()));
    echo $scenario, ': ', $result['status'], ', calls=', count($kernel['transport']->calls()), "\n";
}
Ожидаемый результат
success: completed, calls=3
crm-failure: crm_rejected, calls=1
bank-unknown: bank_unknown, calls=2
telegram-failure: notification_pending, calls=3

Считаются реальные вызовы локального транспорта. При отказе CRM банк не вызывается. При неизвестном исходе банка уведомление об успехе не отправляется. Если Telegram отказал, уже созданный счёт сохраняется.

Тайм-аут — это отсутствие подтверждения, а не доказательство отказа. Банк мог создать счёт до обрыва ответа. Если сразу повторить создание с новым ключом, появятся два счёта. В проекте такой исход хранится как bank_unknown и не вызывает автоматический повтор. В рабочем сервисе требуется запрос статуса по устойчивому merchant reference и документированная идемпотентность именно этого провайдера.

Перед возможным эффектом сохраняется намерение: crm_pending, bank_pending, notification_pending. Если процесс погибнет между внешним успехом и локальной записью результата, следующий вызов увидит незавершённое состояние и остановится для сверки. Это безопаснее слепого повтора, но не является полной системой восстановления: отдельный worker и reconciliation здесь не реализованы.

Отказ уведомления не откатывает созданный счёт. Отмена счёта — отдельная бизнес-операция, а не побочный эффект catch. Для Telegram нужно различать явный 429 с retry_after и потерянный ответ после возможной доставки. Простой повтор sendMessage не обладает гарантией exactly-once. В учебном ядре оба случая остаются pending без автоматической отправки; логика очереди должна быть спроектирована отдельно.

Повтор с телефона: память объекта и сохранённое состояние

Новые объекты узнают прежнюю заявку из файлаСамостоятельный пример
<?php
declare(strict_types=1);

require __DIR__ . '/bootstrap.php';

$file = __DIR__ . '/operations.json';
$input = IntegrationDemo\Intake::fromArray(IntegrationDemo\demoInput());
$first = IntegrationDemo\demoKernel('success', new IntegrationDemo\JsonFileStore($file));
$first['service']->submit($input);
$second = IntegrationDemo\demoKernel('success', new IntegrationDemo\JsonFileStore($file));
$result = $second['service']->submit($input);
echo $result['status'], ': replayed=', (int) $result['replayed'], ': calls=', count($second['transport']->calls()), "\n";
Ожидаемый результат
completed: replayed=1: calls=0

Этот самостоятельный пример пишет operations.json рядом с распакованным кодом; повторный запуск продолжает то же учебное состояние. В автоматической проверке весь пример запускается в изолированном временном каталоге. HTTP-проект хранит файл в var вне public.

request_id остаётся одним и тем же при повторе той же операции. Хранилище сравнивает отпечаток нормализованного запроса: sku, quantity, bank. Другая заявка со старым ключом даёт конфликт. Сохранённая сумма берётся из первоначального результата: изменение каталога не должно задним числом менять уже созданный счёт. В рабочем сервисе ключ дополнительно привязывают к владельцу/аккаунту; глобальный ключ без авторизации неприемлем.

JsonFileStore нужен для видимого опыта на одной машине. Блокировка держится во время всего короткого fake-сценария. Если заменить fake настоящим HTTP, такая общая блокировка задержит все заявки: требуется транзакционная БД и отдельное управление выполняющейся операцией, а не flock вокруг сети. Файл также не даёт распределённых гарантий или обещания сохранности при отключении питания. Повреждение JSON считается ошибкой сервера, хранилище не обнуляется.

Для production добавляют уникальный ключ в БД, короткие транзакции, outbox для намерения отправки, worker с lease и повторными попытками, проверку webhook и наблюдаемость. Эти механизмы связаны с транзакциями, webhook и ограничениями API. Сначала важно увидеть, какую проблему решает каждый слой, а не просто перенести список модных названий в проект.

Запуск лаборатории и маршрут чтения исходников

Скачать учебный B24-клиент и ядро сервиса (ZIP)

Нужен PHP8.2+. Распакуйте весь проект. Запустите php bin/list-lab.php, затем php tests/run.php. Сценарии ядра запускаются командой php bin/demo.php success; замените success на two-bank, crm-failure, bank-unknown, telegram-failure или repeat-local. Общий README содержит ожидаемые результаты, команды HTTP и границы демо.

Чтобы увидеть вход от приложения, запустите php -S 127.0.0.1:8772 -t public. GET покажет JSON-подсказку; POST с ней создаст учебную заявку. Во второй отправке того же JSON calls_this_request будет0. Данные сохраняются только в локальном var. В этой лаборатории нет формы, реальных личных данных, банковских ключей и сторонних отправок.

Что прочитатьНа какой вопрос отвечает
bin/list-lab.phpКак пользоваться удобным объектом в обычном отчёте?
B24Client в src/Clients.phpЧто скрывается за одной строкой listMethod?
ApiClient + TokenProvider + DemoTransportКогда нужна конфигурация и кто фактически выполняет вызов?
public/index.phpКак JSON из мобильного приложения становится PHP-массивом?
src/Application.phpПочему нельзя повторить всю цепочку при сбое последнего шага?
src/Stores.php и tests/run.phpЧто переживает новый запрос и чем подтверждаются гарантии?
optional/GuzzleB24Transport.phpГде подключается настоящий HTTP после разработки авторизации?

Попробуйте. Добавьте сценарий: CRM создана, South вернул незнакомый объект вместо bill.reference. Нужно ли отправлять Telegram и повторять создание счёта?

Разбор решения

Адаптер должен дать OutcomeUnknown. Сервис сохранит bank_unknown, Telegram не вызывается. В тесте ожидайте два вызова транспорта и ноль новых вызовов при повторе request_id. Для разрешения состояния нужен отдельный запрос статуса, которого в учебном контракте пока нет.

Попробуйте. Отчёт должен выгрузить 100000 сделок. Просто увеличить maxItems до100000?

Разбор решения

Сначала оцените память, время, лимиты и требование целостности. Предпочтите отдельный метод обхода страниц с записью CSV и checkpoint. Не меняйте обещание listMethod незаметно: его удобный массив хорош для ограниченной выборки, не для любого объёма.

Источники и обсуждения

Код и руководство с собой

Скачайте самостоятельные примеры с исходными данными, инструкциями и ожидаемыми результатами. Примеры HTTP и фрагменты для проекта помечены отдельно: им нужно указанное окружение.

Код не исполняется на сайте. Скачайте нужный пример и запускайте его в своём локальном окружении.

Материалы сверены 2026-10-12. Примеры и объяснения написаны для этого руководства; источники и обсуждения доступны в каждой главе.