catcodeio/amocrm

SDK для работы с API amoCRM

2.2.1 2021-06-27 09:19 UTC

README

Большая либа для всесторонней работы с amoCRM: API и различные хаки.

Тестирование

Для прогона тестов надо подтянуть зависиомсти и из вендеров можно запусктаь тесты:

$ composer install
$ ./vendor/bin/phpunit

Можно запускать тесты для конкретного файла или даже теста-метода:

./vendor/bin/phpunit --filter FieldProviderResponseTest
./vendor/bin/phpunit --filter FieldProviderResponseTest::testSaveOne

Фабрика

Есть фабрика \Amocrm\AmocrmFactory для создания объектов для работы с amoCRM и виджетами. При этом есть сразу с проверкой реквизитов (методы get) или без (методы create):

Получение объекта для работы с API:

$amocrmApi = \Amocrm\AmocrmFactory::createApi($domain, $email, $token);
// Или
try {
  $amocrmApi = \Amocrm\AmocrmFactory::getApi($domain, $email, $token);
} catch (\Amocrm\Exception\AmocrmApiException $e) {
    // ...
}

Получение объекта для работы с загрузкой виджетов черех хак:

$amocrmWidget = \Amocrm\AmocrmFactory::createWidget($domain, $email, $token);
// Или
try {
  $amocrmWidget = \Amocrm\AmocrmFactory::getWidget($domain, $email, $token);
} catch (\Amocrm\Exception\AmocrmApiException $e) {
    // ...
}

Получение объекта для недокументированной работы с аккаунтом amoCRM:

$amocrmAccount = \Amocrm\AmocrmFactory::createAccount($domain, $email, $token);
// Или
try {
  $amocrmAccount = \Amocrm\AmocrmFactory::getAccount($domain, $email, $token);
} catch (\Amocrm\Exception\AmocrmApiException $e) {
    // ...
}

Получение объекта для работы с API с проверкой прав доступа админа:

try {
    $amocrmApi = \Amocrm\AmocrmFactory::getApiWithAdmin($domain, $email, $token);
} catch (\Amocrm\Exception\ManagerPermissionException $e) {
    // ...
}

Получение доступов

$amocrmApi = \Amocrm\AmocrmFactory::createApi($domain);
# После получения токена он автоматически запоминается в библиотеке, потому явно указывать его не нужно после.
$accessToken = $amocrmApi->auth()->getAccessToken($code);

$amocrmApi->account()->getId();
$amocrmApi = \Amocrm\AmocrmFactory::createApi($domain, $accessToken);

$amocrmApi->account()->getId();

Исключения

В зависимости от кода возвращаемой ошибки amoCRM при неуспешном запросе в API (не при работе хаком виджетов) выбрасывается определённое исключение, чтобы можно было понять что конкретно пошло не так: виноваты просроченные реквизиты менеджера или просроченная оплата в аккаунте.

При этом все исключения при работе с API могут быть перехвачены одним родительским \Amocrm\Exception\AmocrmApiException.

Например:

use Amocrm\AmocrmFactory;
use Amocrm\Exception\AccountUnavailableException;
use Amocrm\Exception\ManagerPermissionException;
use Amocrm\Exception\AmocrmApiException;

try {
    $amocrmApi = AmocrmFactory::getApi($domain, $email, $token);
} catch (ManagerPermissionException $e) {
    // Реквизиты менеджера неверны.
} catch (AccountUnavailableException $e) {
    // Аккаунт заблокирован.
} catch (AmocrmApiException $e) {
    // Остальные ошибки.
}

Amocrm API

Большая либа для работы с API отходит от работы с массивами, предоставляя в замен коллекции и модель данных, где доступ к данным происходит с помощью геттеров, а установка данных через сеттеры. При это коллекции имеют многочисленные способы фильтрации списка по полям сущностей.

Некоторые нюансы:

  1. При добавление элементов в различные списки надо быть аккуратным, чтобы не перетереть уже существующие элементы. Например, добавляя новый тег в сделку надо в начале получить данные сущности с уже существующими тегам, чтобы добавить к ним, а потом уже всех вместе отправить на обновление. Тоже самое и со связанными сущностями ()контакты - сделка) и некоторыми доп. полями.

  2. При получении коллекции доп. полей у сущности, а потом выборке какого-то поля, оно запишется в массив modified даже если по факту после получения доп. поля не присваивали новые значения. Этот массив уйдёт в неизменённом виде в API. В этом ничего страшного т.к. данные не поменялись, просто лишний трафик. Может быть потом надо решить эту проблему, чтобы в конечный массив неизменённые данные не попадали.

  3. Именование методов имеет некоторый смысл:

    • В коллекциях есть методы filter (filter(), filterByField() и filterOneByField()) и их короткое гибкое дополнение get (get(), getBy{FieldName}(), getOneBy{FieldName}()).
    • В провайдерах есть как павило метод list, который отражает полностью поведение метода из API, а все остальные это обёртки над ним. Метод get() для получения данных тольк по id, а другие методы из группы find для поиска по каким-либо параметрам. Также в есть методы save() и saveOne(), их отличие в том, что первый вернёт коллекцию, а второй непосредственно модель или null (если не сохранилось).
    • У моделей есть методы из группы get и set для каждого поля (get{FieldName}(), set{FieldName}()). Если поле является массивом, то скорее всего для него есть класс-обёртка, который и будет возвращён геттером. Каждый класс-обёртка является такой же моделью и работает по тому же принципу.
    • Каждый объект модели или их коллекция имеет метод isModified(), который подскажет изменялась ли модель за время её жизни. Например, при получении сделки перед её сохранением можно проверить.
  4. Для работы с некоторыми константыми значения введены соответствующие константы:

    • Для обозначения типа сущности везде используются константы из хелпера EntityType. Например, EntityType::LEAD, а не 2, или leads, или lead.
    • Для обозначения стандартных типов задач используются константы из класса TaskType. Например, TaskType::FOLLOW_UP, а не 1 или FOLLOW_UP.
    • Для обозначения типа примечания используются константы из класса NoteType. Например, NoteType::COMMON, а не 4.
    • Для обозначения типа доп. поля при его создании используются константы из класса Field. Например, Field::Text, а не 1.
    • Для использование стандартных статусов 142 и 143 есть константы в классе Status.
  5. Для обращения к определённым доп. полям сущности или для генерации нового доп. поля (значений для него) есть метод-синоним getCF() у моделей и у основного объекта. Послу получения объекта доп. поля, надо проверить не явялесят ли он null, после этого уже определять его тип и работать с ним. Про верка на null нужна т.к. у сущности может быть не заполнено поле.

Примеры

Настройка клиента на работу

use Amocrm\AmocrmFactory;

$amocrmApi = AmocrmFactory::createApi('catcode.amocrm.ru', 'support@catcode.io', 'ba86fc1b0efab077d8100b1ccc005bc1');

$amocrmApi
    ->client()
    // Таймаут для запроса.
    ->setTimeout(30)
    // Прокси для curl.
    ->setProxy('66.96.200.39:80')
    // Локаль для ответов от amoCRM, дефотная en.
    ->setLocale('ru')
    // Логер имплементирующий Psr\Log\LoggerInterface.
    ->setLogger($logger);
    // Искуственная задержка для каждого запроса, в данном случае каждый будет исполняться минимум 1 секунду.
    ->setUsleep(1000000);
;

Создание и удаление доп. поля в аккаунте

use Amocrm\Api\Helper\ElementType;
use Amocrm\Api\CustomField\Field;

$fieldProvider = $amocrmApi->field();

$fieldModel = $fieldProvider->create();

$fieldModel
    ->setName('Название')
    ->setType(Field::TEXT)
    ->setElementType(ElementType::LEAD);
                
$field = $fieldProvider->saveOne($fieldModel);

print_r($field->getId());

Непосредственно удаление:

$cf = $amocrmApi->account()->getCustomFields()->findOneByName('Название');

if (!$cf) {
    return;
}

$fieldProvider = $amocrmApi->field();

$field = $fieldProvider->deleteOne(
    $fieldProvider->createFromCustomField($cf)
);

print_r($field->getId());

// Или можно самому сгенерировать объект для удаления поля если знаешь ID.
// Указание name, type и element_type требуется для генерации origin, если 
// последний известен, то можно просто его вставить setOrigin().
$field = $fieldProvider->deleteOne(
    $fieldProvider
        ->create()
        ->setId(1234)
        ->setName('Название')
        ->setType(Field::TEXT)
        ->setElementType(ElementType::LEAD)
        // Или если каким-то образом сами генерировали origin
        //->setOrigin('asdf')
);

print_r($field->getId());

Создание и удаление вебхуков

use Amocrm\Api\Model\Webhook;

$webhookProvider = $amocrmApi->webhook();

$webhookModel = $webhookProvider->create();

$webhookModel
    ->setUrl('https://some.widget.catcode.io/webhook/handler')
    ->setEvents([Webhook::UPDATE_LEAD, Webhook::ADD_COMPANY]);
                
$webhookModel = $webhookProvider->subscribe($webhookModel);

print_r($webhookModel->getResult());
// Внимание! Желательно проверять результат добавления вебхука в getResult() т.к. если URL недоступен, то
// вебхук не будет добавлен и getResult() вернёт false. Это хотя бы можно в лог отписать или принять ещё какие-то меры.

// Добавляем ещё вебхук
$webhookProvider->subscribe(
    $webhookProvider->create()
        ->setUrl('https://some.widget.catcode.io/webhook/another_handler')
        ->setEvents([Webhook::ADD_LEAD])
);
// Или можно сразу несколько
$webhooks = $webhookProvider->subscribe([
    $webhookProvider->create()
        ->setUrl('https://some.widget.catcode.io/webhook/add_company')
        ->setEvents([Webhook::ADD_COMPANY]),
    $webhookProvider->create()
        ->setUrl('https://some.widget.catcode.io/webhook/update_company')
        ->setEvents([Webhook::UPDATE_COMPANY]),
]);

foreach ($webhooks as $webhook) {
    if (!$webhook->getResult()) {
        // Вебхук не добавился
    }
}

Можно найти все или только свои вебхуки:

$webhooks = $webhookProvider->list();
// Так мы увидем все вебхуки, в том числе и чужие
foreach ($webhooks as $webhook) {
    print_r($webhook->getUrl());
}
// Можно найти только те, которые совпадают с определённым паттерном, чтобы, например, найти только наши
$webhooks = $webhookProvider->findByUrl('https://some.widget.catcode.io/');

Удаление вебхуков можно производить по id, но их нужно в начале узнать с помощью list(), проще удалить по паттерну, как выше делался поиск:

$result = $provider->unsubscribeByUrl('https://some.widget.catcode.io/')

if (!$result) {
    // Не удалились, а точнее скорее всего их и не нашли т.к. это происходит в два запроса: в начале ищим вебхуки, а потом удаляем.
}

Создание задачи

use Amocrm\Api\Model\Account\TaskType;

$taskProvider = $amocrmApi->task();

$task = $taskProvider->saveOne(
    $taskProvider
        ->create()
        ->setText('текст задачи')
        // Создаём для сделки. Для каждой из сущностей свой сеттер.
        ->setLeadId(3543)
        // Создаём задачу одного из дефолтных типов, но можно передать и ID кастомного.
        ->setTaskType(TaskType::MEETING)
        ->setCompleteTill(new DateTime('now'))
);

print_r($task->getId());

Создание примечаний

use Amocrm\Api\Model\Account\NoteType;

$noteProvider = $amocrmApi->note();

$note = $noteProvider->save(
    $noteProvider
        ->create()
        ->setText('текст примечания')
        // Создаём для сделки. Для каждой из сущностей свой сеттер.
        ->setLeadId(3543)
        // Создаём обычное примечание, но можно и любое другое, некоторые требуют особый формат поля text.
        ->setNoteType(NoteType::COMMON)
);

print_r($note->getId());

Получение различных данных аккаунта

$account = $amocrmApi->account();

$account->getUsers()->getByGroupId($groupId);
$account->getUsers()->getOneByEmail($email);
$account->getUsers()->getOneById($id);
$account->getUser($id);

$account->getPipelines()->getStatusesActive();
$account->getPipeline($pipelineId)->getStatus($statusId)->getName();

$account->getId();
$account->getCurrency();
$account->getTimezone();

use Amocrm\Api\Model\Account\Pipeline;
// Получаем ID активных статусов со всех воронок кроме 1234
$statusIds = $account
    ->getPipelines()
    ->filter(function (Pipeline $pipeline) {
       return $pipeline->getId() !== 1234;
    })
    ->getStatusesActive()
    ->getIds();

// Запросив один раз данные аккаунта больше запросов по API производиться не будет.
// Для получения данных аккаунта каждый раз актуальных надо передавать true.
$account = $amocrmApi->account(true);

Получение различных сущностей

Демонстрация получения зависимых друг от друга сущностей по цеполчке сделка -> контакты -> компании:

use Amocrm\Api\Model\Company;

$contactProvider = $amocrmApi->contact();
$leadProvider    = $amocrmApi->lead();
$companyProvider = $amocrmApi->company();

// Найдём одну сделку по ID.
$lead = $leadProvider->getOne(4123);

if ($lead) {
    var_dump('Найдена сделка', $lead->getId());
}
// Найдём все контакты этой стедки
$contacts = $contactProvider->findByLeads($lead->getId());

if ($contacts->count()) {
    // Найдём все компании, которые есть среди этих контактов.
    $companies = $companyProvider->findByContacts($contacts->getIds());
    // Отфильтруем только те, где нужный ответственный и заполнено какое-то поле.
    $companies = $companies->filter(function (Company $company) {
        return $company->getResponsibleUserId() == 1234 
            && $company->getCF(6543456);
            && $company->getCF(6543456)->text()->get();
    });
    
    if ($companies->count()) {
        var_dump('Найдена компания', $companies->first()->getName());
    }
}

Работа со статусами при поиске сделок:

$leadProvider = $amocrmApi->lead();

// Найдём IDs активных статусов нужной воронки.
$statusIds = $account
    ->getPipeline(1234)
    ->getStatusesActive()
    ->getIds();

// Можем найти все сделки на этих статусах.
$leads = $leadProvider->find(null, $statusIds);

// Можем найти сделки на этих статусах из определённого пула сделок.
$leads = $leadProvider
    ->get([1234123, 5454565, 7674545])
    ->filterByStatusIds($statusIds);

// Можем найти сделки на определённых статусах связанные с определёнными контактами. 
$leads = $leadProvider
    ->findByContacts([123423, 435454, 7654343])
    ->filterByStatusIds($statusIds);

foreach ($leads as $lead) {
    var_dump('Найдена сделка', $lead->getName());
}

Есть также много других методов, названия которых говорят сами за себя:

$leadProvider->findByContacts(432443);
$leadProvider->findOneByContacts(432443);
    
$contactProvider->findByLeads(12343);
$contactProvider->findOneByLeads(12343);
$contactProvider->findByPhone(79009090900);
$contactProvider->findOneByPhone('+7 (900) 90-90-900');
$contactProvider->findByEmail('asdf@asdf.ff');
$contactProvider->findOneByEmail('asdf@asdf.ff');

$companyProvider->findByContacts(666565);
$companyProvider->findOneByContacts(666565);
$companyProvider->findByLeads(12343);
$companyProvider->findOneByLeads(12343);
$companyProvider->findByPhone(79009090900);
$companyProvider->findOneByPhone('+7 (900) 90-90-900');
$companyProvider->findByEmail('asdf@asdf.ff');
$companyProvider->findOneByEmail('asdf@asdf.ff');

Создание и обновление сущностей

Находим контакта, создаём в нём задачу и два примечния в связанной сделке:

use Amocrm\Api\Model\Account\TaskType;
use Amocrm\Api\Model\Account\NoteType;

$contactProvider = $amocrmApi->contact();
$leadProvider    = $amocrmApi->lead();
$taskProvider    = $amocrmApi->task();
$noteProvider    = $amocrmApi->note();

$contact = $contactProvider->getOne(1343443);

if ($contact) {
    $task = $taskProvider->saveOne(
        $taskProvider
            ->create()
            ->setText('Перезвонить клиенту, поступила заявка с сайта')
            ->setTaskType(TaskType::FOLLOW_UP)
            ->setContactId($contact->getId())
            ->setUserId($contact->getResponsibleUserId())
            ->setCompleteTill((new \DateTime('now', new \DateTimeZone('Europe/Moscow')))->setTime(23, 59, 59))
    );
    
    var_dump('Создали задачу', $task->getId());
    
    $lead = $leadProvider->findOneByContacts($contact->getId());
    
    if ($lead) {
        $note1 = $noteProvider
            ->create()
            ->setText('Комментарий к заказу')
            ->setLeadId($lead->getId())
            ->setNoteType(NoteType::COMMON);
            
        $notе2 = $noteProvider
            ->create()
            ->setText('Пожелание клеинта')
            ->setLeadId($lead->getId())
            ->setNoteType(NoteType::COMMON);
            
        $notes = $noteProvider->save([$note1, $note2]);
    
        if ($notes->count()) {
            var_dump('Создали примечания', implode(', ', $notes->getIds()));
        }
    }
}

Сокращённая версия для интеграции с сайтом: ищем контакт ищем сделку, если нет – создаём.

use Amocrm\Api\Model\Account\TaskType;

$contactProvider = $amocrmApi->contact();
$leadProvider    = $amocrmApi->lead();
$taskProvider    = $amocrmApi->task();

$responsibleUserId = null;
$contact           = null;

$contact = $amocrmApi->findOneByPhone($phone);

if ($contact) {
    $responsibleUserId = $contact->getResponsibleUserId();

    $lead = $leadProvider->findOneByContacts($contact->getId());
    
    if ($lead) {
        $taskProvider->saveOne(
            $taskProvider
                ->create()
                ->setText('Перезвонить клиенту, поступила заявка с сайта')
                ->setTaskType(TaskType::CALL)
                ->setContactId($contact->getId())
                ->setUserId($responsibleUserId)
                ->setCompleteTill((new \DateTime('now', new \DateTimeZone('Europe/Moscow')))->setTime(23, 59, 59))
        );
    }
}

$lead = $leadProvider->saveOne(
    $leadProvider
        ->create()
        ->setName($leadName)
        ->setStatusId($leadStatus)
        ->setUserId($responsibleUserId)
        ->addTag('Заявка')
        ->setCF($amocrmApi->cf(1234)->text()->set($utmType))
        ->setCF($amocrmApi->cf(4444)->text()->set($utmSource))
        ->setCF($amocrmApi->cf(5434)->text()->set($googleId))
);

if (!$contact) {
    $name = $name!= '' ? $name : 'Новый контакт ' . $phone;

    $contact = $contactProvider->saveOne(
        $contactProvider
            ->create()
            ->setName($name)
            ->setUserId($responsibleUserId)
            ->addLeadId($lead->getId())
            // Генерируем значения для доп. полей.
            ->setCF($amocrmApi->cf(3333)->phone()->add($phone))
            ->setCF($amocrmApi->cf(6677)->email()->add($email))
    );
} else {
    // Тут добавляем новые телефон и мыло к уже имеющимся или создаём новые поля.
    $cfPhone = $contact->getCF(3333);
    $cfEmail = $contact->getCF(6677);
    
    $contactProvider->saveOne(
        $contact
            ->addLeadId($lead->getId())
            ->setCF($contact->getCF(3333)->phone()->add($phone))
            ->setCF($contact->getCF(6677)->email()->add($email))
    );
}

Работа со значениями в доп. поле

Если требуется удалить содержимое любого поля в сущности в amoCRM, то при заполнении сущности надо это указать с помощью метода clearCF(). Метод принимает как просто ID доп. поля, так и базовый объект CustomField (возаращается при получени полей из объекта сущности), так и конкретный тип поля (имплементация TypeInterface):

use Amocrm\Api\Helper\ElementType;
// Так можно отчистить одно конкретное поле.
$amocrmApi->lead()->saveOne(
    $amocrmApi->lead()->create()
        ->setId(7014720)
        ->clearCF(12312312);
);

// Так можно отчистить все поля в сущности, получив ID доп. полей из аккаунта.
$lead = $amocrmApi->lead()->create()->setId(7014720);

$amocrmApi->account()->getCustomFields()
    ->getByElementType(ElementType::LEAD)->map(function ($item) use ($lead, $amocrmApi) {
        $lead->clearCF($item->getId());
        // Также можно передать внутри CustomField или TypeInterface, но зачастую это не нужно.
        // $lead->clearCF($amocrmApi->cf($item->getId()));
        // $lead->clearCF($amocrmApi->cf($item->getId())->text());
    });

$amocrmApi->lead()->saveOne($lead);

Если трубется обновить или дополнить значение в доп. поле, то надо использовать метод setCF() у сущности. Внутрь нужно передать конкретный тип-обеъкт доп. поля (имплементация TypeInterface). Для получения данных есть метод getCF(), который возвращает CustomField в любом случае, даже если доп. поле в сущности пустое (из API amoCRM не пришло), а пустое оно или нет можно проверить вызовом isEmpty(). Последнее значит, что если запросить у сущности точно несуществующее поле, то CustomField вернётся, в неёго можно передать значение и даже отправить сущность на обновление, но ничего не обновится. Примеры:

// Получим сущность с доп. полями.
$lead = $amocrmApi->lead()->get(52345);

$cfSelect = $lead->getCF(55554);
// Поле пустое, в сущности ничего не выделено.
if ($cfSelect->isEmpty()) {
    $cfSelect->select()->set('Опция');
}

$lead->setCF($cfSelect);

// Есть более локаничный вариант.
$lead->setCF($lead->getCF(55554)->select()->set('Опция'));

// Также будет работать и, например, с телефонами контакта, чтобы не проверять что уже есть там.
$contact = $amocrmApi->contact()->get(453523);
$contact->setCF($contact->getCF(774564)->phone()->add('67676564563'));

Если требуется получить подтип конкретного телефона или мыла (и др.), то нужно учесть нюансы. Если сущность получена из amoCRM, то enum там возвращается как числовой ID. Получается в начале надо узнать к какому типу, например, телефона относиться ID и уже потом пытаться получить этот телефон:

$enumId = $amocrmApi->account()->getCustomField(1111)->getEnumId('MOB');

$phone = $lead->getCustomField(1111)->getByEnum($enumId);

Работа с покупателями

Функционал работы с покупателями немного кривой т.к. похоже amoCRM разрабатывало "как получилось" API покупателей, а от того и наша обёртка не очень прямая.

Для работы с покупателями может потребоваться сразу несколько разных провайдеров:

// Провайдер для поиска (https://developers.amocrm.com/rest_api/customers/list.php), создания, удаления и обновления покупателей (https://developers.amocrm.com/rest_api/customers/set.php).
$amocrmApi->customer();
// Провайдер для поиска(https://developers.amocrm.com/rest_api/transactions/list.php), создания, удаления (https://developers.amocrm.com/rest_api/transactions/set.php) и обновления (https://developers.amocrm.com/rest_api/transactions/comment.php) транзакций. 
$amocrmApi->transaction();
// Провайдер для одновременного создания покупателей, транзакций и контактов (но не компаний) и связывания их. Также можно удалять транзакции и покупателей и обновлять покупателей. Соответствует, методу element/sync (https://developers.amocrm.com/rest_api/elements/sync.php).
$amocrmApi->element();
// Провайдер для связывания уже существующих покупателей, контактов и компаний (https://developers.amocrm.com/rest_api/links/set.php) и для поиска уже существующих связей (https://developers.amocrm.com/rest_api/links/list.php).
$amocrmApi->link();

Далее буду приводить примеры, как можно работать с покупателями через эти провайдеры.

Простые действия без сложного связывания делать просто:

// Создать покупателя. При создании надо понимать, что в зависимости от типа покупателя 
// поле next_date может быть обязательным (Периодические покупки) или вообще ненужным (Динамическая сегментация). 
// При этом цена и название всегда необходимы.
$amocrmApi->customer()->addOne(
    $amocrmApi->customer()->create()
        ->setName('Новый покупатель')
        ->setNextPrice(100)
        ->setNextDate(new DateTime())
        ->setCF($amocrmApi->cf(342601)->text()->set(444))
        ->addTag('Проверочный тег')
);
// Обновить покупателя.
$amocrmApi->customer()->updateOne(
    $amocrmApi->customer()->create()
        ->setId(1234)
        ->setName('Новое название покупателя')
);

// Получить существующего покупателя.
// По ID.
$amocrmApi->customer()->getOne(115183);
// По дате создания.
$amocrmApi->customer()->find('create', (new DateTime())->modify('-1 day'), (new DateTime())->modify('+1 day')));
// По дате модификации.
$amocrmApi->customer()->find('modify', (new DateTime())->modify('-1 day'), (new DateTime())->modify('+1 day')));
// По дате следующей покупки.
$amocrmApi->customer()->find(null, null, null, (new DateTime())->modify('-1 day'), (new DateTime())->modify('+1 day'));
// По значению доп. поля.
$amocrmApi->customer()->find(null, null, null, null, null, [$amocrmApi->cf(342601)->text()->set(444)]);
// По значению доп. поля типа дата (тоже задаётся диапазон).
$amocrmApi->customer()->find(null, null, null, null, null, null, ['from' => $amocrmApi->cf(342601)->date()->set(new DateTime())]);
// По ответственному пользователю.
$amocrmApi->customer()->find(null, null, null, null, null, null, null, 2291701);
// По текущим задачам.
$amocrmApi->customer()->find(null, null, null, null, null, null, null, null, ['today']);

// Создать транзацию. Обязательно customer_id и price. Соответственно, покупатель уже должен существовать.
$amocrmApi->transaction()->addOne(
    $amocrmApi->transaction()->create()
        ->setPrice(12344444)
        ->setCustomerId(114555)
);

// Найти транзакции определённого покупателя.
$amocrmApi->transaction()->find(115183);
// Получить данные определённой транзакции.
$amocrmApi->transaction()->getOne(5234234);

// Также можно сразу по много покупателей добавлять, обновлять и удалять.
$amocrmApi->customer()->save([
    'add' => [
        $amocrmApi->customer()->create()
            ->setName('Новый покупатель 1')
            ->setNextPrice(100)
            ->setNextDate(new DateTime()),
        $amocrmApi->customer()->create()
            ->setName('Новый покупатель 2')
            ->setNextPrice(200)
            ->setNextDate(new DateTime()),
    ],
    'update' => [
        $amocrmApi->customer()->create()
            ->setId(1234)
            ->setName('Новое название покупателя')
    ],
    'delete' => [1234, 6543, 534534],
])
// Также можно сразу по много транзакций добавлять, обновлять и удалять. При этом внутри обновление происходит через 
// другой метод нежели добавление и удаление т.к. обновлять в транзакции можно только комментарий (и ничего более)!
$amocrmApi->transaction()->save([
    'add' => [
        $amocrmApi->transaction()->create()
            ->setCustomerId(1234)
            ->setPrice(100)
            ->setDate(new DateTime()),
        $amocrmApi->transaction()->create()
            ->setCustomerId(1234)
            ->setPrice(400),
    ],
    'update' => [
        $amocrmApi->transaction()->create()
            ->setId(41234)
            ->setComment('Новый комментарий')
    ],
    'delete' => [11234, 16543, 1534534],
])

Получается с помощью провайдеров customer и transaction можно работать с соответсвующими сущностями, связывать транзакции с покупателями (во время создания), но никак нельзя покупателей связывать с контактами и компаниями.

Допустим нужно добавить полный набор за раз: компанию, контакт, покупателя и две транзакции (одна была давно, другая проводится в данный момент):

// Посольку с помощью провайдера element нельзя добавлять компаниий, а только контакты, то в любом случае компанию нужно добавить заранее.
$company = $amocrmApi->company()->addOne(
    $amocrmApi->company()->create()
        ->setName('Название компании')
);
// Далее добавляем разом всё остальное, перелинковывая их друг с другом. Обратите внимание, что перелинковка происходит с ещё несозданными сущносями с помощью request_id.
$result = $amocrmApi->element()->save(
    [
        'add' => [
            $amocrmApi->customer()->create()
                ->setNextPrice(12344444)
                ->setNextDate(new DateTime())
                ->setName('Проверка в работе 1')
                ->setRequestId(1),
        ]
    ],
    [
        'add' => [
            $amocrmApi->transaction()->create()
                ->setPrice(333)
                ->setDate((new DateTime())->modify('-10 days'))
                ->setComment('Старая траназакция к покупателю')
                ->setCustomerRequestId(1),
            $amocrmApi->transaction()->create()
                ->setPrice(333)
                ->setComment('Новая траназакция к покупателю')
                ->setCustomerRequestId(1),
        ]
    ],
    [
        'link' => [
            $amocrmApi->link()->create()
                ->setFrom(ElementType::CUSTOMER)
                ->setFromRequestId(1)
                ->setTo(ElementType::CONTACT)
                ->setToRequestId(1),
            // Тут связываем ещё не созданную сущность и уже существующую! 
            $amocrmApi->link()->create()
                ->setFrom(ElementType::CUSTOMER)
                ->setFromRequestId(1)
                ->setTo(ElementType::COMPANY)
                ->setToId($company->getId()),
        ]
    ],
    [
        'add' => [
            $amocrmApi->contact()->create()
                ->setName('Контакт Проверка в работе 1')
                ->setRequestId(1),
        ]
    ]
);
// В ответ возвращается большой массив, где под соответствующими ключами сущности.
foreach ($result['customers']['add'] as $customer) {
    echo sprintf("%s – %s\n", $customer->getName(), $customer->getId());
}
// Можно получить перебором список сущностей.
foreach ($result['transactions']['add'] as $transaction) {
    echo sprintf("%s – %s\n", $transaction->getComment(), $transaction->getId());
}
// Можно получить только первый из списка сущностей.
$contact = $result['contacts']['add']->first();
echo sprintf("%s – %s\n", $contact->getName(), $contact->getId());
// Ссылки скорее всего не понадобятся, но они также возвращаются с уже нормальными id сущностями. 
print_r($result['links']['link'][0]);

Скорее всего все за раз создавтаь сущности не понадобится, потому в методе save() контакты добавляются последним параметром. Скорее всего контакт и компания уже будут существовать и надо будет добавить покупателя с транзакцией (или только покупателя) и связать их с контактами и компанией:

$amocrmApi->element()->save(
    [
        'add' => [
            $amocrmApi->customer()->create()
                ->setNextPrice(12344444)
                ->setNextDate(new DateTime())
                ->setName('Проверка в работе 1')
                ->setRequestId(1),
        ]
    ],
    [
        'add' => [
            $amocrmApi->transaction()->create()
                ->setPrice(333)
                ->setCustomerRequestId(1),
        ]
    ],
    [
        'link' => [
            $amocrmApi->link()->create()
                ->setFrom(ElementType::CUSTOMER)
                ->setFromRequestId(1)
                ->setTo(ElementType::CONTACT)
                ->setToId(222222),
            // Тут связываем ещё не созданную сущность и уже существующую! 
            $amocrmApi->link()->create()
                ->setFrom(ElementType::CUSTOMER)
                ->setFromRequestId(1)
                ->setTo(ElementType::COMPANY)
                ->setToId(111111),
        ]
    ],
);
// Либо добавить только покупателя и связать с сущносями. 
$result = $amocrmApi->element()->save(
    [
        'add' => [
            $amocrmApi->customer()->create()
                ->setNextPrice(12344444)
                ->setNextDate(new DateTime())
                ->setName('Проверка в работе 1')
                ->setRequestId(1),
        ]
    ],
    null,
    [
        'link' => [
            $amocrmApi->link()->create()
                ->setFrom(ElementType::CUSTOMER)
                ->setFromRequestId(1)
                ->setTo(ElementType::CONTACT)
                ->setToId(222222),
            // Тут связываем ещё не созданную сущность и уже существующую! 
            $amocrmApi->link()->create()
                ->setFrom(ElementType::CUSTOMER)
                ->setFromRequestId(1)
                ->setTo(ElementType::COMPANY)
                ->setToId(111111),
        ]
    ],
);
// Потом позже будут добавляться транзакции.
$customer = $result['customers']['add']->first();
$amocrmApi->transaction()->addOne(
    $amocrmApi->transaction()->create()
        ->setPrice(12344444)
        ->setCustomerId($customer->getId())
);
// Или если не требуется связывание с контактом или компаний, то можно сделать как двумя запросами (добавить покупателя, а потом транзакцию), так и одним:
$amocrmApi->element()->save(
    [
        'add' => [
            $amocrmApi->customer()->create()
                ->setNextPrice(12344444)
                ->setNextDate(new DateTime())
                ->setName('Проверка в работе 1')
                ->setRequestId(1),
        ]
    ],
    [
        'add' => [
            $amocrmApi->transaction()->create()
                ->setPrice(333)
                ->setCustomerRequestId(1),
        ]
    ],
);

Также есть странная возможность обновлять и удалять сущности с помощью этого метода. С трудом могу представить когда это понадобиться, но скорее всего будет выглядить как-то так:

$amocrmApi->element()->save(
    [
        'add' => [
            $amocrmApi->customer()->create()
                ->setNextPrice(12344444)
                ->setNextDate(new DateTime())
                ->setName('Проверка в работе 1')
                ->setRequestId(1),
        ],
        'udapte' => [
            $amocrmApi->customer()->create()
                ->setId(23423423)
                ->setNextPrice(333),
        ],
        'delete' => [44454],
    ],
    // Транзакции можно только добавлять и удалять, но не обновлять.
    [
        'add' => [
            $amocrmApi->transaction()->create()
                ->setPrice(333)
                ->setCustomerRequestId(1),
            $amocrmApi->transaction()->create()
                ->setPrice(111)
                ->setCustomerId(23423423),
        ],
        'delete' => [4234234, 65645645, 7567674],
    ],
    [
        'link' => [
            // Связываем с существующим контактом.
            $amocrmApi->link()->create()
                ->setFrom(ElementType::CUSTOMER)
                ->setFromRequestId(1)
                ->setTo(ElementType::CONTACT)
                ->setToId(1523423),
        ]
    ],
    // Контакты можно только добавлять или обновлять.
    [
        'udapte' => [
            $amocrmApi->contact()->create()
                ->setId(1523423)
                ->setName('Новое имя контакта'),
        ]
    ]
);

Также можно потребоваться связывать все существующие уже сущности. Допустим покупатель, контакт и компания уже существуют, но ещё не связаны друг с другом. Нужно добавить как компанию и контакт в покупателя, так и контакт в компанию:

$amocrmApi->link()->link([
    $amocrmApi->link()->create()
        ->setFrom(ElementType::CUSTOMER)
        ->setFromId(115183)
        ->setTo(ElementType::CONTACT)
        ->setToId(16963517),
    $amocrmApi->link()->create()
        ->setFrom(ElementType::CUSTOMER)
        ->setFromId(115183)
        ->setTo(ElementType::COMPANY)
        ->setToId(1234123),
    $amocrmApi->link()->create()
        ->setFrom(ElementType::CONTACT)
        ->setFromId(16963517)
        ->setTo(ElementType::COMPANY)
        ->setToId(1234123),
]);

Помимо этого иногда может потребоваться узнать какие компании и контакты связанны с покупателем:

// Получить список ID связанных контактов с покупателем.
$amocrmApi->link()->findFromCustomerToContact(115183)->getToIds();
// Получить список ID связанных компаний с покупателем.
$amocrmApi->link()->findFromCustomerToCompany(115183)->getToIds();
// Получить список ID покупателей контакта.
$amocrmApi->link()->findFromContactToCustomer(543545)->getToIds();
// Получить список ID покупателей компании.
$amocrmApi->link()->findFromCompanyToCustomer(563345)->getToIds();

// Помимо этого можно узнать через этот метод и другие связи.
$amocrmApi->link()->findFromLeadToCompany(2134)->getToIds();
$amocrmApi->link()->findFromLeadToContact(2134)->getToIds();
$amocrmApi->link()->findFromContactToLead(2134)->getToIds();
$amocrmApi->link()->findFromContactToCompany(2134)->getToIds();
$amocrmApi->link()->findFromCompanyToLead(2134)->getToIds();
// и т.д.

Хак загрузки виджитов

С помощью незакрытой дыры в amoCRM можно заливать пользовательские виджеты удалённо. Ниже приведён довольно сложный пример с использованием некоторых компонентов Symfony, которые при желании могут быть заменены стандартными аналогами самого PHP (пример не факт что рабочий).

Изначально предположим, что у нас есть метод, который формирует и выдаёт архив с готовым виджетом, где manifest.json заполнен нужными данными:

/**
 * Возвращает контент с архивом подготовленного для загрузки виджета, либо
 * false в случае неудачи. Работает через создание временных файлов для
 * архива, но всё за собой подтирает.
 *
 * @param string  $path
 * @param string  $code
 * @param string  $secretKey
 *
 * @return string|false
 */
private function getWidgetArchive($path, $code, $secretKey)
{
    $archive = file_get_contents($path);

    $tempDir    = sys_get_temp_dir() . '/' . uniqid(mt_rand());
    $tempWidget = $tempDir . '/widget.zip';
    $filesystem = new Filesystem();

    try {
        $filesystem->mkdir($tempDir);
    } catch (IOExceptionInterface $e) {
        $this->logger->error(
            'При создании временной директории "' . $e->getPath() . '" произошла ошибка',
            ['exception' => $e]
        );

        return false;
    }

    $zipArchive     = new ZipArchive();
    $archiveContent = false;

    try {
        // Открываем архив проекта с Gitlab.
        if (!$zipArchive->open($tempWidget)) {
            throw new Exception('При открытии архива произошла ошибка');
        }

        if (!$zipArchive->extractTo($tempDir)) {
            throw new Exception('При разархивировании произошла ошибка');
        }

        $zipArchive->close();

        // Открываем новый архив поверх проекта с Gitlab, чтобы туда уже виджет записать.
        if (!$zipArchive->open($tempWidget, ZipArchive::OVERWRITE)) {
            throw new Exception('При открытии с перезаписью архива произошла ошибка');
        }

        $finder = new Finder();
        // Архив распакуется в директорию вида <projectname>-<branch>-<commit>,
        // а потому будем искать по маске со звёздочкой.
        $finder->files()->in($tempDir . '/' . $projectName . '*/' . $path);

        foreach ($finder as $file) {
            $filePath = $file->getRealPath();
            $filename = $file->getRelativePathname();

            // Если это манифест виджета, то его надо немного изменить и записать.
            if ($filename == 'manifest.json') {
                $manifest = json_decode(file_get_contents($filePath), true);

                $manifest['widget']['code']       = $code;
                $manifest['widget']['secret_key'] = $secretKey;

                $zipArchive->addFromString($filename, json_encode($manifest));
            } else {
                $zipArchive->addFile($filePath, $filename);
            }
        }

        $zipArchive->close();

        $archiveContent = file_get_contents($tempWidget);
    } catch (Exception $e) {
        $this->logger->warning($e->getMessage());
    } finally {
        try {
            $filesystem->remove($tempDir);
        } catch (IOExceptionInterface $e) {
            $this->logger->error(
                'При удалении временной директории "' . $e->getPath() . '" произошла ошибка',
                ['exception' => $e]
            );
        }

        return $archiveContent;
    }
}

Для загрузки необходимы только реквизиты доступа менеджера с правами админа:

use Amocrm\AmocrmFactory;
use Amocrm\Exception\WidgetException;
use Amocrm\Exception\AmocrmApiException;
use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\Filesystem\Exception\IOExceptionInterface;
use Symfony\Component\Finder\Finder;

$code = 'widget_name';

try {
    $amocrmWidget = AmocrmFactory::getWidget($domain, $email, $token);
} catch (AmocrmApiException $e) {
     // ...
     return false;
}

// Создаём виджет в аккаунте.
try {
    $widgetData = $amocrmWidget->create($code);
} catch (WidgetException $e) {
    // ...
    return false;
}

// В полученной структуре в $widgetData следующие параметры:
// [id] => name_widget
// [code] => name_widget
// [secret_key] => c02fcc8b1e2a64fcae4e711ae421121e8a1546e11d9cc3e90666b2d4d2795eef
// [version] => 1.0.0
// [installs] => 0

$secretKey = $widgetData['secret_key'];

// Нужен sleep т.к. в amoCRM предыдущий шаг аснхронно происходит и медленно.
usleep(500000);

// Теперь загружаем архив с готовым Виджетом.
$widgetArchive = $this->getWidgetArchive('/tmp/widget.zip', $code, $secretKey);

try {
    if ($widgetArchive === false) {
        throw new Exception('Архив с подготовленным виджетом получить не удалось');
    }

    $response = $amocrmWidget->upload($code, $secretKey, $widgetArchive);

    $this->logger->notice(
        'Ответ amoCRM при загрузке виджета',
        [$response]
    );
} catch (Exception $e) {
    // ...
    return false;
}

Для обновления виджета уже необхождимо знать его код и секретный ключ помимо реквизитов доступа админа:

use Amocrm\AmocrmFactory;
use Amocrm\Exception\WidgetException;
use Amocrm\Exception\AmocrmApiException;

$code      = 'widget_name';
$secretKey = 'secret_key';

try {
    $amocrmWidget = AmocrmFactory::getWidget($domain, $email, $token);
} catch (AmocrmApiException $e) {
     // ...
     return false;
}

// Теперь загружаем архив с готовым Виджетом.
$widgetArchive = $this->getWidgetArchive('/tmp/widget.zip', $code, $secretKey);

try {
    if ($widgetArchive === false) {
        throw new Exception('Архив с подготовленным виджетом получить не удалось');
    }

    $response = $amocrmWidget->upload($code, $secretKey, $widgetArchive);

    $this->logger->notice(
        'Ответ amoCRM при обновлении виджета',
        [$response]
    );
} catch (WidgetException $e) {
    // ...
    return false;
}

Для удаления виджета всё также:

use Amocrm\AmocrmFactory;
use Amocrm\Exception\WidgetException;
use Amocrm\Exception\AmocrmApiException;

$code      = 'widget_name';
$secretKey = 'secret_key';

try {
    $amocrmWidget = AmocrmFactory::getWidget($domain, $email, $token);
} catch (AmocrmApiException $e) {
     // ...
     return false;
}

try {
    $amocrmWidget->remove($code, $secretKey);
} catch (WidgetException $e) {
    // ...
    return false;
}