catcodeio/amocrm

用于与 amoCRM API 交互的 SDK

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 请求失败时(不包括通过小部件操作),抛出特定的异常,以便可以了解具体出了什么问题:是经理的凭据已过期,还是账户中的支付已过期。

在此期间,所有 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}())。
    • 在提供者中,有反映 API 方法完整行为的 list 方法,而其他所有方法都是它的封装。方法 get() 用于通过 id 获取数据,而其他 find 组的方法用于搜索某些参数。还有 save()saveOne() 方法,它们的区别在于第一个返回集合,而第二个返回模型或 null(如果没有保存)。
    • 模型有针对每个字段的 get 和 set 方法组(get{FieldName}()set{FieldName}())。如果字段是数组,则很可能有一个类封装器,它将被获取器返回。每个类封装器都是相同的模型,并按相同的原则工作。
    • 每个模型对象或其集合都有 isModified() 方法,它可以在其生命期内提示模型是否已被修改。例如,在保存交易之前,可以检查交易。
  4. 为某些常量值引入了相应的常量

    • 为了表示实体类型,在所有地方都使用了来自帮助器的 EntityType 常量。例如,EntityType::LEAD,而不是 2leadslead
    • 为了表示标准任务类型,使用TaskType类中的常量。例如,TaskType::FOLLOW_UP,而不是1FOLLOW_UP
    • 为了表示注释类型,使用NoteType类中的常量。例如,NoteType::COMMON,而不是4
    • 在创建额外字段时,使用Field类中的常量表示字段类型。例如,Field::Text,而不是1
    • 在Status类中存在常量来表示标准状态142和143。
  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());

创建和删除Webhook

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()) {
        // Вебхук не добавился
    }
}

可以找到所有或仅自己的Webhook

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

可以通过id删除Webhook,但首先需要使用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,即使额外字段在实体中为空(来自amoCRM API的响应),可以通过调用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获取的,则枚举返回为数字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;
}