Как сделать свою карточку редактирования компании
Scope:
crm, user_briefКто может выполнять методы: пользователи с правом «чтения» элементов объекта CRM, правом «добавления» элемента объекта CRM, правом «изменения» элементов объекта CRM и доступом к настройкам CRM
Выберите инструмент для разработки с AI-агентом:
- используйте Битрикс24 Вайбкод, чтобы создать приложение для Битрикс24 по описанию задачи без знания языков программирования. Агент напишет код и разместит приложение на сервере без ручной настройки хостинга
- используйте MCP-сервер, чтобы разрабатывать интеграцию через REST API в своем проекте. Агент будет обращаться к официальной REST-документации
Пример показывает, как создать отдельную веб-страницу для работы с компаниями из CRM. Такая страница пригодится, если вам нужно создавать и редактировать компании в собственном интерфейсе, например в приложении. Стандартная карточка CRM при этом не изменяется.
Набор полей формы не задан в коде вручную. Страница получает от Битрикс24 описание полей компании и по нему строит HTML-форму. За счет этого форма учитывает системные и пользовательские поля и их настройки.
Если открыть страницу без идентификатора компании, форма будет пустой, а после сохранения в Битрикс24 появится новая компания. Если передать идентификатор существующей компании, форма загрузит ее данные и сохранит изменения в этой компании.
Для каждого языка пример состоит из двух файлов:
- генератор получает описание полей и данные существующей компании, затем строит HTML-форму
- обработчик получает заполненные значения формы и сохраняет их в Битрикс24
Как работает сценарий
- Метод crm.item.fields возвращает описание полей компании в
result.fields - Для редактирования метод crm.item.get возвращает компанию в
result.item - Генератор сопоставляет каждому типу поля элемент HTML-формы
- Обработчик повторно получает описание полей и принимает только доступные для изменения поля
- Метод crm.item.add создает компанию, а crm.item.update обновляет ее
- Оба метода возвращают сохраненную компанию в
result.item
Форма получает дополнительные данные, чтобы показывать имя контакта, имя сотрудника и название валюты вместо служебных кодов. Для этого она использует методы crm.status.list, crm.currency.list, crm.item.list и user.get.
Из ответов и описания полей форма берет:
- из crm.status.list —
STATUS_IDиNAME - из crm.currency.list —
CURRENCYиFULL_NAME - из crm.item.list для контактов —
id,nameиlastName - из user.get —
NAMEиLAST_NAME - для типа
enumeration— значения изitemsв описании поля
1. Подготовим окружение
Создайте входящий вебхук с правами crm и user_brief. Для первого запуска можно создать вебхук от имени администратора. Это рекомендация, а не требование REST: пользователю вебхука нужны право «чтения» элементов объекта CRM, право «добавления» элемента объекта CRM, право «изменения» элементов объекта CRM и доступ к настройкам CRM.
Вебхук выполняет запросы с правами создавшего его пользователя. Не публикуйте файлы со значением вебхука в открытых репозиториях.
Выберите один язык, создайте для примера отдельную папку и откройте в ней терминал. Сохраните код из шагов 2 и 3 в два соответствующих файла, затем выполните команду запуска. Не закрывайте окно терминала, пока работаете с формой.
Перед установкой зависимостей проверьте версию PHP командой php -v, а список подключенных расширений — командой php -m. B24PhpSDK версии 3 требует PHP 8.4 или 8.5. B24PhpSDK и его зависимости требуют расширения bcmath, curl, intl и json.
- JavaScript
- файлы —
form.mjsиsave-form.mjs - зависимости —
npm install express @bitrix24/b24jssdk - запуск в Bash —
B24_HOOK='https://your-domain.bitrix24.ru/rest/USER_ID/TOKEN/' node form.mjs - запуск в PowerShell —
$env:B24_HOOK='https://your-domain.bitrix24.ru/rest/USER_ID/TOKEN/'; node form.mjs - адрес —
http://localhost:3000/
- файлы —
- PHP
- файлы —
index.phpиauto_form.php - зависимости —
composer require bitrix24/b24phpsdk:^3.0 - запуск —
php -S localhost:8000 - адрес —
http://localhost:8000/index.php
- файлы —
- Python
- файлы —
app.pyиsave_form.py - зависимости —
pip install b24pysdk flask - запуск —
python app.py - адрес —
http://localhost:5000/
- файлы —
В PHP форма отправляет данные обработчику auto_form.php по относительному пути.
В примерах замените значения:
your-domain.bitrix24.ru— домен вашего Битрикс24USER_ID/TOKEN— идентификатор пользователя и секретный код из URL вебхука
Для JavaScript передайте полный URL в переменной окружения B24_HOOK при запуске. Для PHP замените URL в вызовах initFromWebhook в обоих файлах. Для Python замените значения domain и webhook_token в обоих файлах.
2. Создадим форму компании
Методы crm.item.* получают тип объекта CRM в параметре entityTypeId. Для компании передайте значение 4.
Чтобы методы возвращали пользовательские поля с исходными именами вида UF_* и принимали эти же имена при сохранении, передайте useOriginalUfNames = Y. В Python SDK этому параметру соответствует use_original_uf_names = True.
Получите описание полей методом crm.item.fields. Для каждого поля используйте ключи:
type— тип поляisRequired— обязательностьisReadOnly— доступность для измененияisMultiple— множественность
Сокращенный ответ crm.item.fields показывает, как описаны поля разных типов:
{
"result": {
"fields": {
"title": {
"type": "string",
"isRequired": true,
"isReadOnly": false,
"isMultiple": false
},
"createdTime": {
"type": "datetime",
"isRequired": false,
"isReadOnly": true,
"isMultiple": false
},
"opened": {
"type": "boolean",
"isRequired": false,
"isReadOnly": false,
"isMultiple": false
}
}
}
}
Ответ сокращен до трех полей, чтобы показать формат описания. Набор полей отличается у разных типов объектов CRM.
При редактировании передайте идентификатор компании в параметре id метода crm.item.get. В ответе имена системных полей будут в camelCase, например title и contactIds.
Как форма работает с мультиполями
Телефоны, email, сайты и мессенджеры хранятся в едином поле fm. Каждый элемент result.item.fm содержит:
id— идентификатор существующего значенияtypeId— тип мультиполя, напримерPHONEилиEMAILvalueType— тип значения, напримерWORKилиMOBILEvalue— телефон, адрес или другое значение
Генератор выводит отдельную строку для каждого существующего значения и сохраняет его id в скрытом поле. В конце формы он добавляет три пустые строки для новых значений.
Для типов со специальной обработкой пример создает следующие элементы.
| Тип поля | Элемент формы |
|---|---|
crm_status |
Выпадающий список со значениями из crm.status.list |
enumeration |
Выпадающий список со значениями из items |
crm_multifield |
Строки для типа, вида и значения телефона, email, сайта или мессенджера |
crm_lead |
Текстовое поле для идентификатора и подпись с названием лида |
crm_contact |
Числовое поле для идентификатора контакта и подпись с его именем |
user |
Числовое поле для идентификатора и подпись с именем сотрудника из user.get |
date |
Поле выбора даты |
boolean, char |
Флажок |
money |
Числовое поле и список валют |
file, resourcebooking |
Сообщение о неподдерживаемом типе вместо поля ввода |
Полный код генератора формы
Как использовать примеры в документации
Сохраните код в файл генератора: JavaScript — form.mjs, PHP — index.php, Python — app.py.
// npm install express @bitrix24/b24jssdk
import express from 'express'
import { B24Hook } from '@bitrix24/b24jssdk'
import { saveForm } from './save-form.mjs'
const ENTITY_TYPE_ID = 4
const $b24 = B24Hook.fromWebhookUrl(process.env.B24_HOOK)
// B24_HOOK = 'https://your-domain.bitrix24.ru/rest/USER_ID/TOKEN/'
const app = express()
app.use(express.urlencoded({ extended: true }))
const SKIPPED_FIELDS = new Set(['contacts'])
async function call(method, params, requestId) {
const response = await $b24.actions.v2.call.make({ method, params, requestId })
if (!response.isSuccess) {
throw new Error(response.getErrorMessages().join('; '))
}
return response.getData().result
}
function escapeHtml(value) {
return String(value ?? '')
.replaceAll('&', '&')
.replaceAll('<', '<')
.replaceAll('>', '>')
.replaceAll('"', '"')
.replaceAll("'", ''')
}
function input(p) {
const current = p.MULTIPLE
? (Array.isArray(p.VALUE) ? p.VALUE : [p.VALUE ?? ''])
: [p.VALUE ?? '']
const values = p.MULTIPLE ? [...current, ''] : current
return values.map((value, index) => {
let html = '<input class="form-control"'
if (p.NAME) html += ` name="${escapeHtml(p.NAME)}${p.MULTIPLE ? '[]' : ''}"`
if (p.TYPE) html += ` type="${escapeHtml(p.TYPE)}"`
if (p.STEP) html += ` step="${escapeHtml(p.STEP)}"`
if (p.REQUIRED && index === 0) html += ' required'
if (p.DISABLE) html += ' disabled'
if (p.CHECKED) html += ' checked'
html += ` value="${escapeHtml(value)}">`
return html
}).join('')
}
function select(p, list) {
if (!list || !Object.keys(list).length) return ''
let html = '<select class="form-control"'
if (p.NAME) html += ` name="${escapeHtml(p.NAME)}${p.MULTIPLE ? '[]' : ''}"`
if (p.REQUIRED) html += ' required'
if (p.DISABLE) html += ' disabled'
if (p.MULTIPLE) html += ' multiple'
html += '>'
if (!p.REQUIRED && !p.MULTIPLE) html += '<option value="">-- Не выбрано --</option>'
const value = Array.isArray(p.VALUE) ? p.VALUE.map(String) : [String(p.VALUE ?? '')]
for (const [key, title] of Object.entries(list)) {
const selected = value.includes(String(key)) ? ' selected' : ''
html += `<option value="${escapeHtml(key)}"${selected}>${escapeHtml(title)}</option>`
}
return html + '</select>'
}
function multifields(values = []) {
const rows = [
...(Array.isArray(values) ? values : []),
...Array.from({ length: 3 }, () => ({ id: '', typeId: 'PHONE', valueType: 'WORK', value: '' })),
]
return rows.map((row, index) => {
const types = ['PHONE', 'EMAIL', 'WEB', 'IM']
.map((type) => `<option value="${type}"${row.typeId === type ? ' selected' : ''}>${type}</option>`)
.join('')
return `<div class="border rounded p-2 mb-2">
<input type="hidden" name="fm[${index}][id]" value="${escapeHtml(row.id)}">
<select class="form-control mb-1" name="fm[${index}][typeId]">${types}</select>
<input class="form-control mb-1" name="fm[${index}][valueType]" value="${escapeHtml(row.valueType || 'WORK')}" placeholder="WORK">
<input class="form-control mb-1" name="fm[${index}][value]" value="${escapeHtml(row.value)}" placeholder="Значение">
${row.id ? `<label><input type="checkbox" name="fm[${index}][delete]" value="Y"> Удалить</label>` : ''}
</div>`
}).join('')
}
app.get('/', async (req, res) => {
const id = parseInt(String(req.query.ID ?? '0'), 10) || 0
try {
const arResult = {}
const fieldResult = await call('crm.item.fields', {
entityTypeId: ENTITY_TYPE_ID,
useOriginalUfNames: 'Y',
}, 'company-fields')
arResult.FIELDS = fieldResult.fields
arResult.FIELD_VALUES_CURRENCY = await call('crm.currency.list', {}, 'currencies')
if (id > 0) {
const itemResult = await call('crm.item.get', {
entityTypeId: ENTITY_TYPE_ID,
id,
useOriginalUfNames: 'Y',
}, 'company')
arResult.ITEM = itemResult.item
}
let standard = ''
let custom = ''
for (const [key, field] of Object.entries(arResult.FIELDS)) {
if (SKIPPED_FIELDS.has(key)) continue
let value = arResult.ITEM?.[key] ?? ''
let listKey = arResult['FIELD_VALUES_' + key] ?? []
let list = {}
let ret = ''
const params = { NAME: `form[${key}]`, REQUIRED: field.isRequired, DISABLE: field.isReadOnly, MULTIPLE: field.isMultiple, VALUE: value }
switch (field.type) {
case 'crm_status':
if (!listKey.length) {
listKey = await call(
'crm.status.list',
{ filter: { ENTITY_ID: field.statusType } },
`status-${key}`,
)
}
list = Object.fromEntries(listKey.map((s) => [s.STATUS_ID, s.NAME]))
ret = select(params, list)
break
case 'crm_currency':
list = Object.fromEntries(arResult.FIELD_VALUES_CURRENCY.map((c) => [c.CURRENCY, c.FULL_NAME]))
ret = select(params, list)
break
case 'enumeration':
if (field.items) {
list = Object.fromEntries(field.items.map((it) => [it.ID ?? it.id, it.VALUE ?? it.value]))
}
ret = select(params, list)
break
case 'crm_multifield':
ret = multifields(value)
break
case 'crm_lead': {
ret = input({ ...params, TYPE: 'text' })
if (value) {
const leadResult = await call('crm.item.get', {
entityTypeId: 1,
id: Number(value),
}, `lead-${key}`)
ret += ` (${escapeHtml(leadResult.item.title)})`
}
break
}
case 'crm_contact': {
ret = input({ ...params, TYPE: 'number' })
const ids = (Array.isArray(value) ? value : [value])
.map(Number)
.filter((contactId) => contactId > 0)
if (ids.length) {
const contactResult = await call('crm.item.list', {
entityTypeId: 3,
filter: { '@id': ids },
select: ['id', 'name', 'lastName'],
}, `contacts-${key}`)
const contacts = contactResult.items
if (contacts.length) {
const names = contacts.map((contact) => [contact.name, contact.lastName].filter(Boolean).join(' '))
ret += ` (${escapeHtml(names.join(', '))})`
}
}
break
}
case 'file':
ret = 'Тип file в примере не поддерживается'
break
case 'date':
if (value) value = String(value).slice(0, 10)
ret = input({ ...params, VALUE: value, TYPE: 'date' })
break
case 'datetime':
if (value) value = String(value).slice(0, 19)
ret = input({ ...params, VALUE: value, TYPE: 'datetime-local' })
break
case 'char':
ret = input({ ...params, REQUIRED: false, VALUE: 'Y', CHECKED: value === 'Y', TYPE: 'checkbox' })
break
case 'boolean':
ret = input({ ...params, REQUIRED: false, VALUE: 'Y', CHECKED: value === 'Y', TYPE: 'checkbox' })
break
case 'double':
ret = input({ ...params, TYPE: 'number', STEP: 'any' })
break
case 'integer':
ret = input({ ...params, TYPE: 'number' })
break
case 'user': {
ret = input({ ...params, TYPE: 'number' })
if (value) {
const users = await call('user.get', { filter: { ID: value } }, `user-${key}`)
if (users.length) {
const names = users.map((user) => [user.NAME, user.LAST_NAME].filter(Boolean).join(' '))
ret += ` (${escapeHtml(names.join(', '))})`
}
}
break
}
case 'money': {
const [money, currency] = String(value).split('|')
ret = input({ ...params, VALUE: money, TYPE: 'number', STEP: 'any' })
list = Object.fromEntries(arResult.FIELD_VALUES_CURRENCY.map((c) => [c.CURRENCY, c.FULL_NAME]))
ret += select({ NAME: `form[${key}_CURRENCY]`, REQUIRED: field.isRequired, DISABLE: field.isReadOnly, MULTIPLE: field.isMultiple, VALUE: currency }, list)
break
}
case 'resourcebooking':
ret = 'Тип resourcebooking в примере не поддерживается'
break
default:
ret = input({ ...params, TYPE: 'text' })
break
}
const label = escapeHtml(field.formLabel || field.title || key)
const block = `<div class="col-4 mt-3">${label}: </div><div class="col-6 mt-3">${ret}</div>`
if (key.startsWith('UF_')) custom += block
else standard += block
}
res.send(`
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css" crossorigin="anonymous">
<div class="container">
<form id="auto_form" method="post">
${arResult.ITEM?.id ? `<input type="hidden" name="form[id]" value="${escapeHtml(arResult.ITEM.id)}">` : ''}
<h2>Системные поля</h2>
<div class="row">${standard}</div>
<h2>Пользовательские поля</h2>
<div class="row">${custom}</div>
<div class="row"><div class="col-sm-10 mt-5">
<input type="submit" class="btn btn-primary" value="Сохранить">
</div></div>
</form>
</div>
<script>
document.getElementById('auto_form').addEventListener('submit', async (event) => {
event.preventDefault()
const body = new URLSearchParams(new FormData(event.currentTarget))
const response = await fetch('/form', { method: 'POST', body })
const json = await response.json()
alert(json.message || json.error)
})
<\/script>
`)
} catch (error) {
res.status(500).send(escapeHtml(error.message))
}
})
app.post('/form', saveForm)
app.listen(3000)
<?php
// composer require bitrix24/b24phpsdk:"^3.0"
require_once 'vendor/autoload.php';
use Bitrix24\SDK\Services\ServiceBuilderFactory;
use Symfony\Component\EventDispatcher\EventDispatcher;
use Psr\Log\NullLogger;
$sb = (new ServiceBuilderFactory(new EventDispatcher(), new NullLogger()))
->initFromWebhook('https://your-domain.bitrix24.ru/rest/USER_ID/TOKEN/');
$crm = $sb->getCRMScope();
const ENTITY_TYPE_ID = 4;
$ID = (int)($_REQUEST['ID'] ?? 0);
class CPrintForm
{
public static function escape($value): string
{
return htmlspecialchars((string)$value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
public static function input($arParams)
{
$sResult = '';
$current = !empty($arParams['MULTIPLE'])
? (is_array($arParams['VALUE']) ? $arParams['VALUE'] : [$arParams['VALUE'] ?? ''])
: [$arParams['VALUE'] ?? ''];
if (!empty($arParams['MULTIPLE']))
{
$current[] = '';
}
foreach ($current as $index => $value)
{
$sResult .= '<input class="form-control"';
if (!empty($arParams['NAME']))
{
$sResult .= ' name="' . self::escape($arParams['NAME']) . (!empty($arParams['MULTIPLE']) ? '[]' : '') . '"';
}
if (!empty($arParams['TYPE']))
{
$sResult .= ' type="' . self::escape($arParams['TYPE']) . '"';
}
if (!empty($arParams['STEP']))
{
$sResult .= ' step="' . self::escape($arParams['STEP']) . '"';
}
if (!empty($arParams['REQUIRED']) && $index === 0)
{
$sResult .= ' required';
}
if (!empty($arParams['DISABLE']))
{
$sResult .= ' disabled';
}
if (!empty($arParams['CHECKED']))
{
$sResult .= ' checked';
}
$sResult .= ' value="' . self::escape($value) . '">';
}
return $sResult;
}
public static function select($arParams, $arList)
{
if (empty($arList) || !is_array($arList))
{
return '';
}
$sResult = '<select class="form-control"';
$sResult .= ' name="' . self::escape($arParams['NAME']) . (!empty($arParams['MULTIPLE']) ? '[]' : '') . '"';
$sResult .= !empty($arParams['REQUIRED']) ? ' required' : '';
$sResult .= !empty($arParams['DISABLE']) ? ' disabled' : '';
$sResult .= !empty($arParams['MULTIPLE']) ? ' multiple' : '';
$sResult .= '>';
if (empty($arParams['REQUIRED']) && empty($arParams['MULTIPLE']))
{
$sResult .= '<option value="">-- Не выбрано --</option>';
}
$values = array_map('strval', (array)($arParams['VALUE'] ?? []));
foreach ($arList as $k => $v)
{
$selected = in_array((string)$k, $values, true) ? ' selected' : '';
$sResult .= '<option value="' . self::escape($k) . '"' . $selected . '>' . self::escape($v) . '</option>';
}
$sResult .= '</select>';
return $sResult;
}
public static function multifields($values): string
{
$rows = is_array($values) ? $values : [];
for ($i = 0; $i < 3; $i++)
{
$rows[] = ['id' => '', 'typeId' => 'PHONE', 'valueType' => 'WORK', 'value' => ''];
}
$result = '';
foreach ($rows as $index => $row)
{
$options = '';
foreach (['PHONE', 'EMAIL', 'WEB', 'IM'] as $type)
{
$selected = ($row['typeId'] ?? '') === $type ? ' selected' : '';
$options .= '<option value="' . $type . '"' . $selected . '>' . $type . '</option>';
}
$id = $row['id'] ?? '';
$result .= '<div class="border rounded p-2 mb-2">';
$result .= '<input type="hidden" name="fm[' . $index . '][id]" value="' . self::escape($id) . '">';
$result .= '<select class="form-control mb-1" name="fm[' . $index . '][typeId]">' . $options . '</select>';
$result .= '<input class="form-control mb-1" name="fm[' . $index . '][valueType]" value="' . self::escape($row['valueType'] ?? 'WORK') . '" placeholder="WORK">';
$result .= '<input class="form-control mb-1" name="fm[' . $index . '][value]" value="' . self::escape($row['value'] ?? '') . '" placeholder="Значение">';
if ($id)
{
$result .= '<label><input type="checkbox" name="fm[' . $index . '][delete]" value="Y"> Удалить</label>';
}
$result .= '</div>';
}
return $result;
}
}
function callCore($sb, string $method, array $params): array
{
return $sb->core
->call($method, $params)
->getResponseData()
->getResult();
}
$arResult = [];
$fieldResult = callCore($sb, 'crm.item.fields', [
'entityTypeId' => ENTITY_TYPE_ID,
'useOriginalUfNames' => 'Y',
]);
$arResult['FIELDS'] = $fieldResult['fields'];
$arResult['FIELD_VALUES_CURRENCY'] = [];
foreach ($crm->currency()->list([])->getCurrencies() as $currency)
{
// CURRENCY в B24PhpSDK — объект Money\Currency, строковый код берем через getCode()
$arResult['FIELD_VALUES_CURRENCY'][$currency->CURRENCY->getCode()] = $currency->FULL_NAME;
}
if ($ID > 0)
{
$itemResult = callCore($sb, 'crm.item.get', [
'entityTypeId' => ENTITY_TYPE_ID,
'id' => $ID,
'useOriginalUfNames' => 'Y',
]);
$arResult['ITEM'] = $itemResult['item'];
}
$sResult = '';
$sResultCustom = '';
if (is_array($arResult['FIELDS'])):
foreach ($arResult['FIELDS'] as $key => $arField)
{
if ($key === 'contacts')
{
continue;
}
$value = '';
$return = '';
if (!empty($arResult['ITEM'][$key]))
{
$value = $arResult['ITEM'][$key];
}
$arList = (isset($arResult['FIELD_VALUES_' . $key])) ? $arResult['FIELD_VALUES_' . $key] : [];
switch ($arField['type'])
{
case 'crm_status':
if (empty($arList))
{
foreach ($crm->status()->list([], ['ENTITY_ID' => $arField['statusType']], [])->getStatuses() as $status)
{
$arList[$status->STATUS_ID] = $status->NAME;
}
}
$return = CPrintForm::select(
[
'NAME' => 'form[' . $key . ']',
'REQUIRED' => $arField['isRequired'],
'DISABLE' => $arField['isReadOnly'],
'MULTIPLE' => $arField['isMultiple'],
'VALUE' => $value
],
$arList
);
break;
case 'crm_currency':
$return = CPrintForm::select(
[
'NAME' => 'form[' . $key . ']',
'REQUIRED' => $arField['isRequired'],
'DISABLE' => $arField['isReadOnly'],
'MULTIPLE' => $arField['isMultiple'],
'VALUE' => $value
],
$arResult['FIELD_VALUES_CURRENCY']
);
break;
case 'enumeration':
foreach ($arField['items'] as $aItem)
{
$itemId = $aItem['ID'] ?? $aItem['id'];
$arList[$itemId] = $aItem['VALUE'] ?? $aItem['value'];
}
$return = CPrintForm::select(
[
'NAME' => 'form[' . $key . ']',
'REQUIRED' => $arField['isRequired'],
'DISABLE' => $arField['isReadOnly'],
'MULTIPLE' => $arField['isMultiple'],
'VALUE' => $value
],
$arList
);
break;
case 'crm_multifield':
$return = CPrintForm::multifields($value);
break;
case 'crm_lead':
$return = CPrintForm::input(
[
'NAME' => 'form[' . $key . ']',
'REQUIRED' => $arField['isRequired'],
'DISABLE' => $arField['isReadOnly'],
'MULTIPLE' => $arField['isMultiple'],
'VALUE' => $value,
'TYPE' => 'text',
]
);
if (!empty($value))
{
$lead = $crm->item()->get(1, (int)$value)->item();
$return .= ' (' . CPrintForm::escape($lead->title) . ')';
}
break;
case 'crm_contact':
$arContact = [];
$arContactIds = array_values(array_filter(array_map('intval', (array)$value)));
if (!empty($arContactIds))
{
foreach ($crm->item()->list(3, [], ['@id' => $arContactIds], ['id', 'name', 'lastName'], 0)->getItems() as $contact)
{
$arContact[] = trim(implode(' ', [$contact->name, $contact->lastName]));
}
}
$return = CPrintForm::input(
[
'NAME' => 'form[' . $key . ']',
'REQUIRED' => $arField['isRequired'],
'DISABLE' => $arField['isReadOnly'],
'MULTIPLE' => $arField['isMultiple'],
'VALUE' => $value,
'TYPE' => 'number',
]
);
if (!empty($arContact))
{
$return .= ' (' . CPrintForm::escape(implode(', ', $arContact)) . ')';
}
break;
case 'file':
$return = 'Тип file в примере не поддерживается';
break;
case 'date':
if (!empty($value))
{
$value = date('Y-m-d', strtotime($value));
}
$return = CPrintForm::input(
[
'NAME' => 'form[' . $key . ']',
'REQUIRED' => $arField['isRequired'],
'DISABLE' => $arField['isReadOnly'],
'MULTIPLE' => $arField['isMultiple'],
'VALUE' => $value,
'TYPE' => 'date',
]
);
break;
case 'datetime':
if (!empty($value))
{
$value = date('Y-m-d\TH:i:s', strtotime($value));
}
$return = CPrintForm::input(
[
'NAME' => 'form[' . $key . ']',
'REQUIRED' => $arField['isRequired'],
'DISABLE' => $arField['isReadOnly'],
'MULTIPLE' => $arField['isMultiple'],
'VALUE' => $value,
'TYPE' => 'datetime-local',
]
);
break;
case 'char':
$return = CPrintForm::input(
[
'NAME' => 'form[' . $key . ']',
'REQUIRED' => false,
'DISABLE' => $arField['isReadOnly'],
'MULTIPLE' => $arField['isMultiple'],
'VALUE' => 'Y',
'CHECKED' => ($value == 'Y') ? true : false,
'TYPE' => 'checkbox',
]
);
break;
case 'boolean':
$return = CPrintForm::input(
[
'NAME' => 'form[' . $key . ']',
'REQUIRED' => false,
'DISABLE' => $arField['isReadOnly'],
'MULTIPLE' => $arField['isMultiple'],
'VALUE' => 'Y',
'CHECKED' => ($value == 'Y') ? true : false,
'TYPE' => 'checkbox',
]
);
break;
case 'double':
$return = CPrintForm::input(
[
'NAME' => 'form[' . $key . ']',
'REQUIRED' => $arField['isRequired'],
'DISABLE' => $arField['isReadOnly'],
'MULTIPLE' => $arField['isMultiple'],
'VALUE' => $value,
'TYPE' => 'number',
'STEP' => 'any',
]
);
break;
case 'user':
$arUserNames = [];
if (!empty($value))
{
foreach ($sb->getUserScope()->user()->get([], ['ID' => $value], true)->getUsers() as $user)
{
$arUserNames[] = implode(' ', [$user->NAME, $user->LAST_NAME]);
}
}
$return = CPrintForm::input(
[
'NAME' => 'form[' . $key . ']',
'REQUIRED' => $arField['isRequired'],
'DISABLE' => $arField['isReadOnly'],
'MULTIPLE' => $arField['isMultiple'],
'VALUE' => $value,
'TYPE' => 'number'
]
);
if (!empty($arUserNames))
{
$return .= ' (' . CPrintForm::escape(implode(', ', $arUserNames)) . ')';
}
break;
case 'url':
$return = CPrintForm::input(
[
'NAME' => 'form[' . $key . ']',
'REQUIRED' => $arField['isRequired'],
'DISABLE' => $arField['isReadOnly'],
'MULTIPLE' => $arField['isMultiple'],
'VALUE' => $value,
'TYPE' => 'text',
]
);
break;
case 'integer':
$return = CPrintForm::input(
[
'NAME' => 'form[' . $key . ']',
'REQUIRED' => $arField['isRequired'],
'DISABLE' => $arField['isReadOnly'],
'MULTIPLE' => $arField['isMultiple'],
'VALUE' => $value,
'TYPE' => 'number',
]
);
break;
case 'money':
[$money, $currency] = array_pad(explode('|', (string)$value, 2), 2, '');
$return = CPrintForm::input(
[
'NAME' => 'form[' . $key . ']',
'REQUIRED' => $arField['isRequired'],
'DISABLE' => $arField['isReadOnly'],
'MULTIPLE' => $arField['isMultiple'],
'VALUE' => $money,
'TYPE' => 'number',
'STEP' => 'any',
]
);
$return .= CPrintForm::select(
[
'NAME' => 'form[' . $key . '_CURRENCY]',
'REQUIRED' => $arField['isRequired'],
'DISABLE' => $arField['isReadOnly'],
'MULTIPLE' => $arField['isMultiple'],
'VALUE' => $currency
],
$arResult['FIELD_VALUES_CURRENCY']
);
break;
case 'address':
$return = CPrintForm::input(
[
'NAME' => 'form[' . $key . ']',
'REQUIRED' => $arField['isRequired'],
'DISABLE' => $arField['isReadOnly'],
'MULTIPLE' => $arField['isMultiple'],
'VALUE' => $value,
'TYPE' => 'text',
]
);
break;
case 'resourcebooking':
$return = 'Тип resourcebooking в примере не поддерживается';
break;
default:
$return = CPrintForm::input(
[
'NAME' => 'form[' . $key . ']',
'REQUIRED' => $arField['isRequired'],
'DISABLE' => $arField['isReadOnly'],
'MULTIPLE' => $arField['isMultiple'],
'VALUE' => $value,
'TYPE' => 'text',
]
);
break;
}
$label = CPrintForm::escape($arField['formLabel'] ?? $arField['title'] ?? $key);
$block = '<div class="col-4 mt-3">' . $label . ': </div><div class="col-6 mt-3">' . $return . '</div>';
if (strpos($key, 'UF_') === 0)
{
$sResultCustom .= $block;
}
else
{
$sResult .= $block;
}
}
?>
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css" crossorigin="anonymous">
<script>
document.addEventListener('DOMContentLoaded', () => {
document.getElementById('auto_form').addEventListener('submit', async (event) => {
event.preventDefault();
const body = new URLSearchParams(new FormData(event.currentTarget));
const response = await fetch('auto_form.php', { method: 'POST', body });
const json = await response.json();
alert(json.message || json.error);
});
});
</script>
<div class="container">
<form id="auto_form" action="" method="post">
<?php if (!empty($arResult['ITEM']['id'])): ?>
<input type="hidden" name="form[id]" value="<?= CPrintForm::escape($arResult['ITEM']['id']) ?>">
<?php endif; ?>
<h2>Системные поля</h2>
<div class="row">
<?= $sResult ?>
</div>
<h2>Пользовательские поля</h2>
<div class="row">
<?= $sResultCustom ?>
</div>
<div class="row">
<div class="col-sm-10 mt-5">
<input type="submit" class="btn btn-primary" value="Сохранить">
</div>
</div>
</form>
</div>
<?php endif; ?>
# pip install b24pysdk flask
from html import escape
from flask import Flask, request
from b24pysdk import BitrixWebhook, Client
from save_form import save_form
app = Flask(__name__)
ENTITY_TYPE_ID = 4
client = Client(BitrixWebhook(
domain="your-domain.bitrix24.ru",
webhook_token="USER_ID/TOKEN", # только user_id/token, без https://
))
def input_field(params):
"""Строит input или набор input для множественного поля."""
if params.get("MULTIPLE"):
current = params.get("VALUE") if isinstance(params.get("VALUE"), list) else [params.get("VALUE") or ""]
values = [*current, ""]
else:
values = [params.get("VALUE") or ""]
html = ""
for index, value in enumerate(values):
html += '<input class="form-control"'
if params.get("NAME"):
html += f' name="{escape(params["NAME"])}{"[]" if params.get("MULTIPLE") else ""}"'
if params.get("TYPE"):
html += f' type="{escape(params["TYPE"])}"'
if params.get("STEP"):
html += f' step="{escape(params["STEP"])}"'
if params.get("REQUIRED") and index == 0:
html += " required"
if params.get("DISABLE"):
html += " disabled"
if params.get("CHECKED"):
html += " checked"
html += f' value="{escape(str(value))}">'
return html
def select_field(params, options):
"""Строит <select> по словарю «значение => подпись»."""
if not options:
return ""
html = '<select class="form-control"'
if params.get("NAME"):
html += f' name="{params["NAME"]}{"[]" if params.get("MULTIPLE") else ""}"'
if params.get("REQUIRED"):
html += " required"
if params.get("DISABLE"):
html += " disabled"
if params.get("MULTIPLE"):
html += " multiple"
html += ">"
if not params.get("REQUIRED") and not params.get("MULTIPLE"):
html += '<option value="">-- Не выбрано --</option>'
value = params.get("VALUE")
value = [str(v) for v in value] if isinstance(value, list) else [str(value or "")]
for key, title in options.items():
selected = " selected" if str(key) in value else ""
html += f'<option value="{escape(str(key))}"{selected}>{escape(str(title))}</option>'
return html + "</select>"
def multifields(values):
"""Строит строки fm и сохраняет id существующих значений."""
rows = list(values) if isinstance(values, list) else []
rows.extend({"id": "", "typeId": "PHONE", "valueType": "WORK", "value": ""} for _ in range(3))
html = ""
for index, row in enumerate(rows):
options = "".join(
f'<option value="{field_type}"{" selected" if row.get("typeId") == field_type else ""}>{field_type}</option>'
for field_type in ("PHONE", "EMAIL", "WEB", "IM")
)
item_id = row.get("id") or ""
delete = (
f'<label><input type="checkbox" name="fm[{index}][delete]" value="Y"> Удалить</label>'
if item_id else ""
)
html += f"""<div class="border rounded p-2 mb-2">
<input type="hidden" name="fm[{index}][id]" value="{escape(str(item_id))}">
<select class="form-control mb-1" name="fm[{index}][typeId]">{options}</select>
<input class="form-control mb-1" name="fm[{index}][valueType]" value="{escape(row.get('valueType') or 'WORK')}" placeholder="WORK">
<input class="form-control mb-1" name="fm[{index}][value]" value="{escape(row.get('value') or '')}" placeholder="Значение">
{delete}
</div>"""
return html
PAGE = """
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css" crossorigin="anonymous">
<div class="container">
<form id="auto_form" method="post">
%(hidden_id)s
<h2>Системные поля</h2>
<div class="row">%(standard)s</div>
<h2>Пользовательские поля</h2>
<div class="row">%(custom)s</div>
<div class="row"><div class="col-sm-10 mt-5">
<input type="submit" class="btn btn-primary" value="Сохранить">
</div></div>
</form>
</div>
<script>
document.getElementById('auto_form').addEventListener('submit', async (el) => {
el.preventDefault();
const body = new URLSearchParams(new FormData(el.currentTarget));
const response = await fetch('/form', { method: 'POST', body });
const json = await response.json();
alert(json.message || json.error);
});
</script>
"""
@app.route("/")
def form_page():
item_id = int(request.args.get("ID", 0) or 0)
ar_result = {}
field_response = client.crm.item.fields(
entity_type_id=ENTITY_TYPE_ID,
use_original_uf_names=True,
).response
ar_result["FIELDS"] = field_response.result["fields"]
ar_result["FIELD_VALUES_CURRENCY"] = client.crm.currency.list().response.result
if item_id > 0:
item_response = client.crm.item.get(
entity_type_id=ENTITY_TYPE_ID,
bitrix_id=item_id,
use_original_uf_names=True,
).response
ar_result["ITEM"] = item_response.result["item"]
item = ar_result.get("ITEM", {})
s_result = ""
s_result_custom = ""
for key, field in ar_result["FIELDS"].items():
if key == "contacts":
continue
value = item.get(key) or ""
params = {"NAME": f"form[{key}]", "REQUIRED": field.get("isRequired"),
"DISABLE": field.get("isReadOnly"), "MULTIPLE": field.get("isMultiple"), "VALUE": value}
ret = ""
field_type = field.get("type")
if field_type == "crm_status":
rows = ar_result.get("FIELD_VALUES_" + key) or client.crm.status.list(
filter={"ENTITY_ID": field["statusType"]}).response.result
ret = select_field(params, {r["STATUS_ID"]: r["NAME"] for r in rows})
elif field_type == "crm_currency":
options = {c["CURRENCY"]: c["FULL_NAME"] for c in ar_result["FIELD_VALUES_CURRENCY"]}
ret = select_field(params, options)
elif field_type == "enumeration":
options = {
it.get("ID", it.get("id")): it.get("VALUE", it.get("value"))
for it in field.get("items", [])
}
ret = select_field(params, options)
elif field_type == "crm_multifield":
ret = multifields(value)
elif field_type == "crm_lead":
ret = input_field({**params, "TYPE": "text"})
if value:
lead_response = client.crm.item.get(entity_type_id=1, bitrix_id=int(value)).response
ret += f" ({escape(lead_response.result['item']['title'])})"
elif field_type == "crm_contact":
ret = input_field({**params, "TYPE": "number"})
ids = [int(contact_id) for contact_id in (value if isinstance(value, list) else [value]) if contact_id]
if ids:
contact_response = client.crm.item.list(
entity_type_id=3,
filter={"@id": ids},
select=["id", "name", "lastName"],
).response
contacts = contact_response.result["items"]
if contacts:
names = [" ".join(filter(None, [contact.get("name"), contact.get("lastName")])) for contact in contacts]
ret += f" ({escape(', '.join(names))})"
elif field_type == "file":
ret = "Тип file в примере не поддерживается"
elif field_type == "date":
if value:
value = str(value)[:10]
ret = input_field({**params, "VALUE": value, "TYPE": "date"})
elif field_type == "datetime":
if value:
value = str(value)[:19]
ret = input_field({**params, "VALUE": value, "TYPE": "datetime-local"})
elif field_type == "char":
ret = input_field({**params, "REQUIRED": False, "VALUE": "Y", "CHECKED": value == "Y", "TYPE": "checkbox"})
elif field_type == "boolean":
ret = input_field({**params, "REQUIRED": False, "VALUE": "Y", "CHECKED": value == "Y", "TYPE": "checkbox"})
elif field_type == "double":
ret = input_field({**params, "TYPE": "number", "STEP": "any"})
elif field_type == "integer":
ret = input_field({**params, "TYPE": "number"})
elif field_type == "user":
ret = input_field({**params, "TYPE": "number"})
if value:
users = client.user.get(filter={"ID": value}).response.result
if users:
names = [" ".join(filter(None, [user.get("NAME"), user.get("LAST_NAME")])) for user in users]
ret += f" ({escape(', '.join(names))})"
elif field_type == "money":
money, _, currency = str(value).partition("|")
ret = input_field({**params, "VALUE": money, "TYPE": "number", "STEP": "any"})
options = {c["CURRENCY"]: c["FULL_NAME"] for c in ar_result["FIELD_VALUES_CURRENCY"]}
ret += select_field({"NAME": f"form[{key}_CURRENCY]", "REQUIRED": field.get("isRequired"),
"DISABLE": field.get("isReadOnly"), "MULTIPLE": field.get("isMultiple"),
"VALUE": currency}, options)
elif field_type == "resourcebooking":
ret = "Тип resourcebooking в примере не поддерживается"
else:
ret = input_field({**params, "TYPE": "text"})
label = escape(str(field.get("formLabel") or field.get("title") or key))
block = f'<div class="col-4 mt-3">{label}: </div><div class="col-6 mt-3">{ret}</div>'
if key.startswith("UF_"):
s_result_custom += block
else:
s_result += block
# Скрытое поле id добавляем только для формы редактирования
hidden_id = ""
if item.get("id"):
hidden_id = f'<input type="hidden" name="form[id]" value="{escape(str(item["id"]))}">'
return PAGE % {"hidden_id": hidden_id, "standard": s_result, "custom": s_result_custom}
app.add_url_rule("/form", view_func=save_form, methods=["POST"])
if __name__ == "__main__":
app.run(port=5000)
3. Сохраним данные формы
Обработчик повторно вызывает crm.item.fields. Это защищает от передачи неизвестных и недоступных для изменения полей. Поля file и resourcebooking обработчик пропускает, поэтому их текущие значения сохраняются.
Браузер не отправляет снятый флажок. Поэтому для полей boolean и char обработчик передает Y при установленном флажке и N при снятом.
Если другого доступного для изменения поля нет в отправленной форме, обработчик очищает его:
- одиночному полю передает пустую строку
- множественному полю передает пустой массив
Если убрать поле из HTML-формы, при сохранении его текущее значение в компании будет затерто.
Как обработчик собирает fm
Каждая строка fm содержит скрытый id и видимые typeId, valueType, value.
- для существующего непустого значения ключом массива становится его
id— так сервер обновляет ту же запись - для нового непустого значения обработчик создает ключ
n0,n1и так далее — так сервер добавляет запись - если существующее значение очищено или отмечено флажком Удалить, обработчик сохраняет его
id, но передает пустойvalue— так сервер удаляет запись - пустые строки без
idобработчик не передает
Например, объект fm перед обновлением может выглядеть так:
{
"451": { "typeId": "PHONE", "valueType": "WORK", "value": "+7 495 111-22-33" },
"452": { "typeId": "EMAIL", "valueType": "WORK", "value": "" },
"n0": { "typeId": "EMAIL", "valueType": "WORK", "value": "info@example.com" }
}
Запись 451 будет обновлена, 452 — удалена, n0 — добавлена.
Как обработчик выбирает метод сохранения
Если форма содержит id, обработчик вызывает crm.item.update. В остальных случаях он вызывает crm.item.add.
Полный код обработчика
Сохраните код в файл обработчика: JavaScript — save-form.mjs, PHP — auto_form.php, Python — save_form.py.
import { B24Hook } from '@bitrix24/b24jssdk'
const ENTITY_TYPE_ID = 4
const SKIPPED_FIELDS = new Set(['contacts'])
const $b24 = B24Hook.fromWebhookUrl(process.env.B24_HOOK)
// B24_HOOK = 'https://your-domain.bitrix24.ru/rest/USER_ID/TOKEN/'
async function call(method, params, requestId) {
const response = await $b24.actions.v2.call.make({ method, params, requestId })
if (!response.isSuccess) {
throw new Error(response.getErrorMessages().join('; '))
}
return response.getData().result
}
function asArray(value) {
if (Array.isArray(value)) return value
return value === undefined || value === null ? [] : [value]
}
function buildMultifields(rows) {
const result = {}
let newIndex = 0
for (const row of Object.values(rows ?? {})) {
const id = parseInt(String(row.id ?? '0'), 10) || 0
const value = String(row.value ?? '')
const shouldDelete = row.delete === 'Y' || value === ''
if (!id && shouldDelete) continue
const key = id ? String(id) : `n${newIndex++}`
result[key] = {
typeId: String(row.typeId || 'PHONE'),
valueType: String(row.valueType || 'WORK'),
value: shouldDelete ? '' : value,
}
}
return result
}
export async function saveForm(req, res) {
try {
const submitted = req.body.form ?? {}
const fieldResult = await call('crm.item.fields', {
entityTypeId: ENTITY_TYPE_ID,
useOriginalUfNames: 'Y',
}, 'company-fields-save')
const fields = {}
for (const [key, prop] of Object.entries(fieldResult.fields)) {
if (SKIPPED_FIELDS.has(key) || prop.isReadOnly || ['file', 'resourcebooking'].includes(prop.type)) continue
if (['boolean', 'char'].includes(prop.type)) {
fields[key] = key in submitted ? 'Y' : 'N'
continue
}
if (prop.type === 'crm_multifield') {
fields[key] = buildMultifields(req.body.fm)
continue
}
if (!(key in submitted)) {
fields[key] = prop.isMultiple ? [] : ''
continue
}
let value = submitted[key]
if (prop.type === 'money') {
value = `${value ?? ''}|${submitted[`${key}_CURRENCY`] ?? ''}`
} else if (prop.type === 'crm_contact') {
value = asArray(value).map(Number).filter((itemId) => itemId > 0)
} else if (prop.isMultiple) {
value = asArray(value).filter((item) => item !== '')
}
fields[key] = value
}
const id = parseInt(String(submitted.id ?? '0'), 10) || 0
const method = id > 0 ? 'crm.item.update' : 'crm.item.add'
const params = {
entityTypeId: ENTITY_TYPE_ID,
fields,
useOriginalUfNames: 'Y',
}
if (id > 0) params.id = id
const result = await call(method, params, `company-${id > 0 ? 'update' : 'add'}`)
res.json({ message: `Компания сохранена, ID: ${result.item.id}` })
} catch (error) {
res.status(400).json({ error: error.message })
}
}
<?php
// composer require bitrix24/b24phpsdk:"^3.0"
require_once 'vendor/autoload.php';
use Bitrix24\SDK\Services\ServiceBuilderFactory;
use Symfony\Component\EventDispatcher\EventDispatcher;
use Psr\Log\NullLogger;
$sb = (new ServiceBuilderFactory(new EventDispatcher(), new NullLogger()))
->initFromWebhook('https://your-domain.bitrix24.ru/rest/USER_ID/TOKEN/');
const ENTITY_TYPE_ID = 4;
header('Content-Type: application/json; charset=utf-8');
function callCore($sb, string $method, array $params): array
{
return $sb->core
->call($method, $params)
->getResponseData()
->getResult();
}
function buildMultifields(array $rows): array
{
$result = [];
$newIndex = 0;
foreach ($rows as $row)
{
$id = (int)($row['id'] ?? 0);
$value = (string)($row['value'] ?? '');
$shouldDelete = ($row['delete'] ?? '') === 'Y' || $value === '';
if ($id <= 0 && $shouldDelete)
{
continue;
}
$key = $id > 0 ? (string)$id : 'n' . $newIndex++;
$result[$key] = [
'typeId' => (string)($row['typeId'] ?? 'PHONE'),
'valueType' => (string)($row['valueType'] ?? 'WORK'),
'value' => $shouldDelete ? '' : $value,
];
}
return $result;
}
try
{
$submitted = is_array($_POST['form'] ?? null) ? $_POST['form'] : [];
$fieldResult = callCore($sb, 'crm.item.fields', [
'entityTypeId' => ENTITY_TYPE_ID,
'useOriginalUfNames' => 'Y',
]);
$fields = [];
foreach ($fieldResult['fields'] as $key => $prop)
{
if ($key === 'contacts' || !empty($prop['isReadOnly']) || in_array($prop['type'], ['file', 'resourcebooking'], true))
{
continue;
}
if (in_array($prop['type'], ['boolean', 'char'], true))
{
$fields[$key] = array_key_exists($key, $submitted) ? 'Y' : 'N';
continue;
}
if ($prop['type'] === 'crm_multifield')
{
$fields[$key] = buildMultifields(is_array($_POST['fm'] ?? null) ? $_POST['fm'] : []);
continue;
}
if (!array_key_exists($key, $submitted))
{
$fields[$key] = !empty($prop['isMultiple']) ? [] : '';
continue;
}
$value = $submitted[$key];
if ($prop['type'] === 'money')
{
$value = (string)$value . '|' . (string)($submitted[$key . '_CURRENCY'] ?? '');
}
elseif ($prop['type'] === 'crm_contact')
{
$value = array_values(array_filter(array_map('intval', (array)$value)));
}
elseif (!empty($prop['isMultiple']))
{
$value = array_values(array_filter((array)$value, static fn($item) => $item !== ''));
}
$fields[$key] = $value;
}
$id = (int)($submitted['id'] ?? 0);
$method = $id > 0 ? 'crm.item.update' : 'crm.item.add';
$params = [
'entityTypeId' => ENTITY_TYPE_ID,
'fields' => $fields,
'useOriginalUfNames' => 'Y',
];
if ($id > 0)
{
$params['id'] = $id;
}
$result = callCore($sb, $method, $params);
echo json_encode(
['message' => 'Компания сохранена, ID: ' . $result['item']['id']],
JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR
);
}
catch (Throwable $error)
{
http_response_code(400);
echo json_encode(['error' => $error->getMessage()], JSON_UNESCAPED_UNICODE);
}
# pip install b24pysdk flask
import re
from flask import request, jsonify
from b24pysdk import BitrixWebhook, Client
ENTITY_TYPE_ID = 4
FORM_KEY = re.compile(r"^form\[([^]]+)](\[\])?$")
FM_KEY = re.compile(r"^fm\[(\d+)]\[(id|typeId|valueType|value|delete)]$")
client = Client(BitrixWebhook(
domain="your-domain.bitrix24.ru",
webhook_token="USER_ID/TOKEN", # только user_id/token, без https://
))
def parse_form():
result = {}
for full_key in request.form:
match = FORM_KEY.match(full_key)
if not match:
continue
values = request.form.getlist(full_key)
result[match.group(1)] = values if match.group(2) else values[-1]
return result
def parse_multifield_rows():
rows = {}
for full_key in request.form:
match = FM_KEY.match(full_key)
if match:
rows.setdefault(int(match.group(1)), {})[match.group(2)] = request.form[full_key]
return rows
def build_multifields(rows):
result = {}
new_index = 0
for row in rows.values():
item_id = int(row.get("id") or 0)
value = str(row.get("value") or "")
should_delete = row.get("delete") == "Y" or value == ""
if not item_id and should_delete:
continue
key = str(item_id) if item_id else f"n{new_index}"
if not item_id:
new_index += 1
result[key] = {
"typeId": str(row.get("typeId") or "PHONE"),
"valueType": str(row.get("valueType") or "WORK"),
"value": "" if should_delete else value,
}
return result
def save_form():
try:
submitted = parse_form()
field_response = client.crm.item.fields(
entity_type_id=ENTITY_TYPE_ID,
use_original_uf_names=True,
).response
fields = {}
for key, prop in field_response.result["fields"].items():
if key == "contacts" or prop.get("isReadOnly") or prop.get("type") in ("file", "resourcebooking"):
continue
if prop.get("type") in ("boolean", "char"):
fields[key] = "Y" if key in submitted else "N"
continue
if prop.get("type") == "crm_multifield":
fields[key] = build_multifields(parse_multifield_rows())
continue
if key not in submitted:
fields[key] = [] if prop.get("isMultiple") else ""
continue
value = submitted[key]
if prop.get("type") == "money":
value = f"{value or ''}|{submitted.get(f'{key}_CURRENCY', '')}"
elif prop.get("type") == "crm_contact":
values = value if isinstance(value, list) else [value]
value = [int(item_id) for item_id in values if str(item_id).isdigit() and int(item_id) > 0]
elif prop.get("isMultiple"):
value = [item for item in (value if isinstance(value, list) else [value]) if item != ""]
fields[key] = value
item_id = int(submitted.get("id") or 0)
if item_id > 0:
response = client.crm.item.update(
entity_type_id=ENTITY_TYPE_ID,
bitrix_id=item_id,
fields=fields,
use_original_uf_names=True,
).response
else:
response = client.crm.item.add(
entity_type_id=ENTITY_TYPE_ID,
fields=fields,
use_original_uf_names=True,
).response
return jsonify(message=f"Компания сохранена, ID: {response.result['item']['id']}")
except Exception as error:
return jsonify(error=str(error)), 400
После успешного вызова crm.item.add или crm.item.update идентификатор сохраненной компании находится в result.item.id:
{
"result": {
"item": {
"id": 123
}
}
}
Перед проверкой создайте два тестовых контакта. Откройте карточку каждого контакта и возьмите число из URL. Например, из URL /crm/contact/details/456/ и /crm/contact/details/789/ получите идентификаторы 456 и 789. Список контактов также можно получить методом crm.item.list, передав entityTypeId со значением 3.
Проверим результат
- Откройте генератор без параметра
IDи заполните название компании - В первой свободной строке оставьте тип
PHONEи видWORK, введите телефон - Во второй свободной строке выберите тип
EMAIL, оставьте видWORK, введите адрес - В поле для контактов укажите два идентификатора из предыдущего абзаца
- Сохраните форму и убедитесь, что обработчик вернул идентификатор созданной компании
- Откройте генератор с параметром
IDи подставьте идентификатор компании, который обработчик вернул на предыдущем шаге, например/?ID=123 - В строке с существующим телефоном измените номер
- В строке с существующим email установите флажок Удалить, а в первой свободной строке выберите тип
EMAIL, оставьте видWORKи введите новый адрес - Сохраните форму и проверьте изменения в карточке компании
Отдельно проверьте связи с контактами: состав должен совпасть с формой, а первый идентификатор из contactIds станет основным контактом.
Ошибки и диагностика
Если форма не открывается или компания не сохраняется, найдите признак ошибки и выполните рекомендацию.
| Признак | Что проверить и исправить |
|---|---|
| Методы CRM возвращают ошибку доступа | Проверьте право crm у вебхука, а у пользователя вебхука — права на чтение, добавление и изменение элементов CRM и доступ к настройкам CRM |
| Ошибка возникает при выводе поля с ответственным, а остальные поля загружаются | Проверьте право user_brief, необходимое для вызова user.get |
crm.item.get возвращает NOT_FOUND |
Укажите идентификатор существующей компании, доступной пользователю вебхука, или удалите параметр ID, чтобы создать новую компанию |
| Первый запрос к Битрикс24 завершается ошибкой авторизации | Проверьте домен, USER_ID/TOKEN и статус вебхука: он не должен быть удален или отозван |
| При сохранении обработчик возвращает ошибку о незаполненном поле | Заполните все поля, для которых crm.item.fields вернул isRequired: true, и сохраните форму повторно |
| Сервер не запускается: порт занят другим процессом | Освободите порт или измените его в коде либо команде запуска, затем запустите сервер заново |
| При старте возникает ошибка импорта или отсутствия класса | Установите зависимости командой для выбранного языка из раздела подготовки и запустите сервер заново |
Браузер показывает localhost refused to connect |
Посмотрите первую ошибку в терминале, устраните ее и запустите сервер заново. Проверьте, что открываете правильный порт |
PHP возвращает Call to a member function error() on array |
Замените устаревшую функцию callCore на версию из кода генератора или обработчика и повторите запрос |
| Composer сообщает о несовместимой версии PHP | Используйте PHP 8.4 или 8.5. Проверьте версию командой php -v, затем повторно установите зависимости |
| PHP сообщает об отсутствующем расширении | Включите расширения bcmath, curl, intl и json, перезапустите PHP и проверьте список командой php -m |
Что важно учитывать
Форма управляет только составом связанных контактов через поле contactIds и не позволяет изменять их роли. При сохранении сервер заново устанавливает SORT по порядку идентификаторов, назначает первому контакту IS_PRIMARY=Y и сохраняет ROLE_ID существующей привязки.
Не используйте пример, если нужно сохранить расширенные свойства привязок без изменений.
В описании полей также есть служебное поле contacts. Пример пропускает его и работает только с contactIds, чтобы не передавать две версии одной связи.
Пример не поддерживает множественные поля типа money: для них значение сохранится в неверном формате.
Пример рассчитан на локальный или тестовый запуск. Обработчик выполняет запросы через вебхук с правами создавшего его пользователя и не проверяет, кто отправил форму. Не публикуйте страницу в открытом доступе без собственной аутентификации.
Продолжите изучение
Для другого объекта CRM меняется entityTypeId и набор полей, остальная логика остается той же: