Постановка задачи
В марте 2025 года к нам обратился финансовый директор компании со следующей проблемой:
"К нам ежедневно поступают от 10–20 счетов большинство из которых приходят в виде картинки или фото с телефона. Сотрудники вручную переносят их в 1С. На один счёт уходит 5–10 минут. Нужна автоматизация".
Данная статья описывает опыт реализации этой задачи: архитектурные решения, технические детали и практические замечания, возникшие в процессе разработки.
Исходные данные
Инфраструктура заказчика
-
1С:Бухгалтерия 3.0, режим совместимости 8.3.20+
-
БСП 3.1.x с включённой подсистемой «Дополнительные обработки»
-
Сервис распознавания на стороне заказчика (Python/FastAPI), endpoint
POST /api/extract_invoice -
Доступ по локальной сети; в перспективе — HTTPS с самоподписанным сертификатом
Спецификация API
Запрос:
POST /api/extract_invoice
Content-Type: multipart/form-data; boundary=...
Body: поле "file" — файл счёта (PDF/JPG/PNG/DOCX/TXT/RTF/HTML)
Ответ:
{
"success": true,
"file_name": "schet_1245.pdf",
"data": {
"organization_name": "ООО «Поставщик»",
"inn": "7701234567",
"kpp": "770101001",
"ogrn": "1027701234567",
"address": "г. Москва, ул. Тверская, д. 1",
"phone": "+7 495 000-00-00",
"bank_name": "АО «Банк»",
"bik": "044525225",
"checking_account": "40702810000000000001",
"corr_account": "30101810000000000225",
"invoice_number": "Сч-1245",
"invoice_date": "2025-09-12",
"items": [
{ "name": "Услуга 1", "quantity": 1, "amount": 11800.00 }
],
"total_amount": 10000.00,
"total_vat": 1800.00,
"total_with_vat": 11800.00,
"has_vat": true,
"vat_rate": 20,
"requisites": { }
}
}
Договорённости с заказчиком
-
Обработка получает JSON и сохраняет его во временное хранилище, создание контрагента, банковского счёта и платёжного поручения — задачи следующих релизов.
-
Ошибки API должны отображаться пользователю в понятной форме, без выброса
RuntimeExceptionв служебное окно. -
Код — на русском языке, в едином стиле с остальными доработками заказчика (стандарты БСП).
Архитектура решения
Решение представляет собой внешнюю обработку для БП 3.0, регистрируемую через БСП («Дополнительные обработки» → «Заполнение объекта» → «ОткрытиеФормы»). Состоит из модуля объекта и управляемой формы.
Важно: в текущем релизе отсутствуют транзакции и записи в справочники. Серверная часть намеренно реализована в виде заглушек, чтобы в следующем релизе наполнить методы без переписывания обработки.
Реализация
Шаг 1. Каркас обработки
Минимальный состав:
-
ObjectModule.bsl— экспортная функцияСведенияОВнешнейОбработкедля БСП плюс серверные заглушки. -
Forms/Форма— клиентская часть: кнопка «Выбрать файл», кнопка «Распознать», поле с JSON.
Шаг 2. Регистрация через БСП
Наиболее частый источник ошибок — неверно указанный параметр Вид. Если поставить ПечатнаяФорма или СозданиеСвязанныхОбъектов, кнопка либо не появится, либо окажется не в том месте. Для сценария «открыть форму обработки и поработать с файлом» необходим ровно ЗаполнениеОбъекта.
Функция СведенияОВнешнейОбработке() Экспорт
ПараметрыРегистрации = Новый Структура;
ПараметрыРегистрации.Вставить("Вид", "ЗаполнениеОбъекта");
ПараметрыРегистрации.Вставить("Версия", "1.0.0");
ПараметрыРегистрации.Вставить("Наименование",
НСтр("ru = 'Распознавание счёта через API и получение JSON'"));
ПараметрыРегистрации.Вставить("Информация",
НСтр("ru = 'Отправляет файл счёта во внешний сервис распознавания, "
"получает JSON с реквизитами и сохраняет во временное хранилище.'"));
ПараметрыРегистрации.Вставить("БезопасныйРежим", Ложь);
Размещение = Новый Массив;
Размещение.Добавить("Документ.ПлатежноеПоручение");
ПараметрыРегистрации.Вставить("Назначение", Размещение);
Команды = Новый ТаблицаЗначений;
Команды.Колонки.Добавить("Идентификатор", Новый ОписаниеТипов("Строка"));
Команды.Колонки.Добавить("Представление", Новый ОписаниеТипов("Строка"));
Команды.Колонки.Добавить("Использование", Новый ОписаниеТипов("Строка"));
Команды.Колонки.Добавить("ПоказыватьОповещение", Новый ОписаниеТипов("Булево"));
Команды.Колонки.Добавить("Модификатор", Новый ОписаниеТипов("Строка"));
ПараметрыРегистрации.Вставить("Команды", Команды);
Команда = Команды.Добавить();
Команда.Идентификатор = "РаспознатьСчёт";
Команда.Представление = НСтр("ru = 'Распознать счёт через API...'");
Команда.Использование = "ОткрытиеФормы";
Команда.ПоказыватьОповещение = Ложь;
Команда.Модификатор = "";
Возврат ПараметрыРегистрации;
КонецФункции
Ключевые моменты:
-
Вид = "ЗаполнениеОбъекта"— обязательное условие появления кнопки в нужном месте. -
В
Размещениеуказывается полное имя объекта метаданных. Для нескольких видов документов — добавьте строки в массив. -
Использование = "ОткрытиеФормы"— обязательно. АльтернативаВызовКлиентскогоМетодатребует экспортной клиентской процедуры без формы и не подходит для данного сценария. -
Поле
Версияфункционально: БСП умеет показывать пользователю уведомление «Доступна новая версия обработки» при обновлении. При пустой версии уведомление не отображается.
Шаг 3. Серверная часть
Серверная часть обработки в текущем релизе минимальна, но структура заложена сразу, чтобы в следующем релизе можно было наполнить методы, а не переписывать обработку.
// ObjectModule.bsl
#Область ИнтеграцияСБСП
// СведенияОВнешнейОбработке — выше.
#КонецОбласти
#Область ПрограммныйИнтерфейс
// Получает распознанные данные по адресу во временном хранилище.
//
// Параметры:
// АдресДанныхВоВременномХранилище - Строка - адрес временного хранилища,
// в котором лежит Соответствие с распознанными реквизитами.
//
// Возвращаемое значение:
// Структура - {"Данные": Соответствие, "Ошибка": Строка}.
//
Функция ПолучитьРаспознанныеДанные(АдресДанныхВоВременномХранилище) Экспорт
Результат = Новый Структура;
Результат.Вставить("Данные", Новый Соответствие);
Результат.Вставить("Ошибка", "");
Если НЕ ЗначениеЗаполнено(АдресДанныхВоВременномХранилище) Тогда
Результат.Ошибка = НСтр("ru = 'Адрес временного хранилища не заполнен.'");
Возврат Результат;
КонецЕсли;
Данные = ПолучитьИзВременногоХранилища(АдресДанныхВоВременномХранилище);
Если ТипЗнч(Данные) <> Тип("Соответствие") Тогда
Результат.Ошибка = НСтр("ru = 'Не удалось получить данные из временного хранилища.'");
Возврат Результат;
КонецЕсли;
Результат.Данные = Данные;
Возврат Результат;
КонецФункции
#КонецОбласти
Выбор Соответствие, а не Структура, обусловлен тем, что заранее неизвестен полный набор полей, возвращаемых сервисом. У Соответствия нет требований к именам ключей и порядку — оптимально для «словаря из JSON».
Шаг 4. Клиентская часть
4.1. Помещение файла во временное хранилище
&НаКлиенте
Процедура ВыбратьФайл(Команда)
ДанныеJSON = "";
ДанныеЗагружены = Ложь;
АдресДанныхВоВременномХранилище = "";
ОписаниеОповещения = Новый ОписаниеОповещения("ВыбратьФайлЗавершение", ЭтотОбъект);
НачатьПомещениеФайла(ОписаниеОповещения, , , Истина, УникальныйИдентификатор);
КонецПроцедуры
&НаКлиенте
Процедура ВыбратьФайлЗавершение(Результат, Адрес, ПомещаемыйФайл, ДополнительныеПараметры) Экспорт
Если НЕ Результат Тогда
ПоказатьПредупреждение(, НСтр("ru = 'Не удалось поместить файл во временное хранилище.'"));
Возврат;
КонецЕсли;
АдресФайлаВоВременномХранилище = Адрес;
ИмяФайла = ПомещаемыйФайл;
КонецПроцедуры
Без параметра УникальныйИдентификатор в НачатьПомещениеФайла адрес живёт одну секунду, и следующий серверный вызов получит Неопределено. У формы 1С уникальный идентификатор выполняет роль «ручки», привязывающей временное хранилище к форме.
4.2. Формирование multipart/form-data
В 1С отсутствует встроенная поддержка multipart/form-data. Тело запроса собирается вручную — около 30 строк, работает надёжно.
&НаКлиенте
Функция СобратьТелоMultipart(ДвоичныеДанныеФайла, ИмяФайла, Разделитель)
ПереводСтроки = Символы.ВК + Символы.ПС;
Кодировка = КодировкаТекста.UTF8;
// Имя файла без пути — некоторые сервисы падают на "C:\..."
ИмяФайлаБезПути = ИмяФайла;
ПозицияРазделителя = СтрНайти(ИмяФайла, "\", НаправлениеПоиска.СКонца);
Если ПозицияРазделителя > 0 Тогда
ИмяФайлаБезПути = Сред(ИмяФайла, ПозицияРазделителя + 1);
КонецЕсли;
ПозицияРазделителя = СтрНайти(ИмяФайлаБезПути, "/", НаправлениеПоиска.СКонца);
Если ПозицияРазделителя > 0 Тогда
ИмяФайлаБезПути = Сред(ИмяФайлаБезПути, ПозицияРазделителя + 1);
КонецЕсли;
ЧастьЗаголовок =
"--" + Разделитель + ПереводСтроки
+ "Content-Disposition: form-data; name=""file""; filename=""" + ИмяФайлаБезПути + """"
+ ПереводСтроки
+ "Content-Type: application/octet-stream" + ПереводСтроки + ПереводСтроки;
ЧастьРазделитель = "--" + Разделитель + "--" + ПереводСтроки;
ЧастьЗаголовокДД = ПолучитьДвоичныеДанныеИзСтроки(ЧастьЗаголовок, Кодировка);
ПереводСтрокиДД = ПолучитьДвоичныеДанныеИзСтроки(ПереводСтроки, Кодировка);
ЧастьРазделительДД = ПолучитьДвоичныеДанныеИзСтроки(ЧастьРазделитель, Кодировка);
Части = Новый Массив;
Части.Добавить(ЧастьЗаголовокДД);
Части.Добавить(ДвоичныеДанныеФайла);
Части.Добавить(ПереводСтрокиДД);
Части.Добавить(ЧастьРазделительДД);
Возврат СоединитьДвоичныеДанные(Части);
КонецФункции
Практические замечания:
-
Boundary должен быть уникальным в пределах запроса. Используется
WmkBoundary+Новый УникальныйИдентификатор(). Если сервис не поддерживает дефисы в boundary — замените на простую строку. -
Двойной CRLF после заголовков — требование RFC 7578. Без него сервер либо вернёт 400, либо молча проглотит файл.
-
Content-Type: application/octet-stream — наиболее безопасный вариант. Если MIME-тип известен (PDF, JPG) — можно подставить реальный: некоторые OCR-движки выбирают модель по нему.
4.3. HTTP-вызов к сервису
&НаКлиенте
Функция ОтправитьЗапросРаспознавания(АдресСервиса, ИмяФайла, ДвоичныеДанныеФайла)
Результат = Новый Структура;
Результат.Вставить("Успех", Ложь);
Результат.Вставить("Данные", Новый Соответствие);
Результат.Вставить("Ошибка", "");
Результат.Вставить("ИмяФайла", "");
// ---------- Разбор адреса сервиса --------------------------------------
АдресСервисаБезСлеша = СокрЛП(АдресСервиса);
Если Прав(АдресСервисаБезСлеша, 1) = "/" Тогда
АдресСервисаБезСлеша = Лев(АдресСервисаБезСлеша, СтрДлина(АдресСервисаБезСлеша) - 1);
КонецЕсли;
ЗащищенноеСоединение = Неопределено;
ХостСПортом = АдресСервисаБезСлеша;
Если НРег(Лев(ХостСПортом, 8)) = "https://" Тогда
ХостСПортом = Сред(ХостСПортом, 9);
ЗащищенноеСоединение = Новый ЗащищенноеСоединениеOpenSSL(Неопределено, Неопределено);
ИначеЕсли НРег(Лев(ХостСПортом, 7)) = "http://" Тогда
ХостСПортом = Сред(ХостСПортом, 8);
КонецЕсли;
Хост = ХостСПортом;
Порт = 0;
ПозицияДвоеточия = СтрНайти(ХостСПортом, ":");
Если ПозицияДвоеточия > 0 Тогда
Хост = Лев(ХостСПортом, ПозицияДвоеточия - 1);
ПортСтрокой = Сред(ХостСПортом, ПозицияДвоеточия + 1);
Порт = Число(СокрЛП(ПортСтрокой));
КонецЕсли;
Разделитель = "WmkBoundary" + Строка(Новый УникальныйИдентификатор());
ТелоЗапроса = СобратьТелоMultipart(ДвоичныеДанныеФайла, ИмяФайла, Разделитель);
ШаблонОшибкиСоединения = НСтр("ru = 'Ошибка соединения с %1: %2'");
ШаблонОшибкиКода = НСтр("ru = 'Сервис вернул код состояния %1.'");
ШаблонОшибкиРазбора = НСтр("ru = 'Не удалось разобрать ответ сервиса: %1'");
// ---------- POST ------------------------------------------------------
Попытка
Соединение = Новый HTTPСоединение(Хост, Порт, , , , 120, ЗащищенноеСоединение);
Запрос = Новый HTTPЗапрос("/api/extract_invoice");
Запрос.Заголовки.Вставить("Content-Type", "multipart/form-data; boundary=" + Разделитель);
Запрос.Заголовки.Вставить("Accept", "application/json");
Запрос.УстановитьТелоИзДвоичныхДанных(ТелоЗапроса);
Ответ = Соединение.ВызватьHTTPМетод("POST", Запрос);
Исключение
Результат.Ошибка = СтрШаблон(ШаблонОшибкиСоединения,
АдресСервиса, ИнформацияОбОшибке().Описание);
Возврат Результат;
КонецПопытки;
Если Ответ.КодСостояния >= 300 Тогда
Результат.Ошибка = СтрШаблон(ШаблонОшибкиКода, Ответ.КодСостояния);
Возврат Результат;
КонецЕсли;
// ---------- Разбор JSON ----------------------------------------------
ТелоОтветаСтрокой = Ответ.ПолучитьТелоКакСтроку();
Попытка
Чтение = Новый ЧтениеJSON;
Чтение.УстановитьСтроку(ТелоОтветаСтрокой);
ОтветJSON = ПрочитатьJSON(Чтение, Истина); // ВСоответствие
Чтение.Закрыть();
Исключение
Результат.Ошибка = СтрШаблон(ШаблонОшибкиРазбора, ИнформацияОбОшибке().Описание);
Возврат Результат;
КонецПопытки;
Если ТипЗнч(ОтветJSON) <> Тип("Соответствие") Тогда
Результат.Ошибка = НСтр("ru = 'Некорректный формат ответа сервиса.'");
Возврат Результат;
КонецЕсли;
Успех = ОтветJSON.Получить("success");
Если Успех <> Истина Тогда
ТекстОшибки = ОтветJSON.Получить("error");
Если НЕ ЗначениеЗаполнено(ТекстОшибки) Тогда
ТекстОшибки = НСтр("ru = 'Сервис не смог распознать реквизиты.'");
КонецЕсли;
Результат.Ошибка = ТекстОшибки;
Возврат Результат;
КонецЕсли;
// ---------- Разворачиваем requisites ----------------------------------
// Провайдер возвращает часть реквизитов отдельным блоком.
// Поднимаем их на верхний уровень, чтобы серверная часть
// не знала про особенности API.
Данные = ОтветJSON.Получить("data");
Если ТипЗнч(Данные) = Тип("Соответствие") Тогда
Requisites = Данные.Получить("requisites");
Если ТипЗнч(Requisites) = Тип("Соответствие") Тогда
Для Каждого КлючЗначение Из Requisites Цикл
Если Данные.Получить(КлючЗначение.Ключ) = Неопределено Тогда
Данные.Вставить(КлючЗначение.Ключ, КлючЗначение.Значение);
КонецЕсли;
КонецЦикла;
КонецЕсли;
Результат.Данные = Данные;
КонецЕсли;
ИмяФайлаОтвета = ОтветJSON.Получить("file_name");
Если ЗначениеЗаполнено(ИмяФайлаОтвета) Тогда
Результат.ИмяФайла = ИмяФайлаОтвета;
Иначе
Результат.ИмяФайла = ИмяФайла;
КонецЕсли;
Результат.Успех = Истина;
Возврат Результат;
КонецФункции
Особенности HTTP-клиента:
-
Таймаут 120 секунд. По умолчанию — 30 секунд, чего недостаточно для тяжёлого PDF с OCR. При перспективе больших файлов — переход на асинхронный режим (
/jobs+ polling). -
ЗащищенноеСоединениеOpenSSL(Неопределено, Неопределено)— режим «принимаю любой сертификат сервера, включая самоподписанный». Для production с валидным сертификатом параметр можно не передавать — 1С создаст защищённое соединение с проверкой. -
ПрочитатьJSON(Чтение, Истина)— второй параметр «Читать в Соответствие» принципиален: при значении по умолчанию 1С соберёт ответ вСтруктуру, и любое новое поле в JSON от сервиса будет ронять обработку. -
>= 300как граница «ошибочного» ответа. RFC 7231 разрешает 1xx–3xx как успешные, на практике POST с 3xx не возвращают. Порог>= 300обеспечивает читаемость. -
ИнформацияОбОшибке().ОписаниевместоОписаниеОшибки(). Начиная с 8.3.14ОписаниеОшибки()помечен как устаревший; БСП прямо требуетИнформацияОбОшибке(). -
Чтение.Закрыть()— обязательное освобождение ресурсов: каждая утечка памяти в 1С лечится перезапуском сеанса.
4.4. Точка входа и сохранение результата
&НаКлиенте
Процедура Распознать(Команда)
Если НЕ ЗначениеЗаполнено(АдресСервиса) Тогда
АдресСервиса = "http://localhost:5000";
КонецЕсли;
Если НЕ ЗначениеЗаполнено(АдресФайлаВоВременномХранилище) Тогда
ПоказатьПредупреждение(, НСтр("ru = 'Сначала выберите файл.'"));
Возврат;
КонецЕсли;
Состояние(НСтр("ru = 'Отправка запроса в сервис распознавания…'"));
ДвоичныеДанныеФайла = ПолучитьИзВременногоХранилища(АдресФайлаВоВременномХранилище);
Если ДвоичныеДанныеФайла = Неопределено Тогда
ПоказатьПредупреждение(, НСтр("ru = 'Не удалось получить двоичные данные файла.'"));
Возврат;
КонецЕсли;
РезультатРаспознавания = ОтправитьЗапросРаспознавания(
АдресСервиса, ИмяФайла, ДвоичныеДанныеФайла);
Если НЕ РезультатРаспознавания.Успех Тогда
ДанныеJSON = "";
ДанныеЗагружены = Ложь;
АдресДанныхВоВременномХранилище = "";
ПоказатьПредупреждение(, РезультатРаспознавания.Ошибка);
Возврат;
КонецЕсли;
ДанныеЗагружены = Истина;
ИмяФайлаОтвета = РезультатРаспознавания.ИмяФайла;
Если ЗначениеЗаполнено(ИмяФайлаОтвета) Тогда
ИмяФайла = ИмяФайлаОтвета;
КонецЕсли;
// 1. Показываем JSON пользователю в виде текста
ДанныеJSON = ПредставлениеДанныхДляПоказа(РезультатРаспознавания.Данные);
// 2. Кладём Соответствие во временное хранилище для следующих этапов
АдресДанныхВоВременномХранилище = ПоместитьВоВременноеХранилище(
РезультатРаспознавания.Данные, УникальныйИдентификатор);
ПоказатьОповещениеПользователя(НСтр("ru = 'Распознавание завершено'"),
, НСтр("ru = 'JSON получен и сохранён во временное хранилище.'"));
КонецПроцедуры
4.5. Отображение JSON пользователю
Для визуальной проверки результата JSON выводится в текстовое поле формы. Вложенные блоки (requisites, items[]) разворачиваются аккуратно, чтобы не превращаться в нечитаемую «кашу».
&НаКлиенте
Функция ПредставлениеДанныхДляПоказа(Данные)
Строки = Новый Массив;
Для Каждого Элемент Из Данные Цикл
Если ТипЗнч(Элемент.Значение) = Тип("Соответствие")
ИЛИ ТипЗнч(Элемент.Значение) = Тип("Массив") Тогда
Продолжить;
КонецЕсли;
Строки.Добавить(Элемент.Ключ + ": " + Строка(Элемент.Значение));
КонецЦикла;
Requisites = Данные.Получить("requisites");
Если ТипЗнч(Requisites) = Тип("Соответствие") Тогда
Строки.Добавить("--- requisites ---");
Для Каждого Элемент Из Requisites Цикл
Строки.Добавить(Элемент.Ключ + ": " + Строка(Элемент.Значение));
КонецЦикла;
КонецЕсли;
ПозицииТоваров = Данные.Получить("items");
Если ТипЗнч(ПозицииТоваров) = Тип("Массив") Тогда
Строки.Добавить("--- items ---");
Для Каждого Элемент Из ПозицииТоваров Цикл
Если ТипЗнч(Элемент) = Тип("Соответствие") Тогда
Строки.Добавить(Строка(Элемент.Получить("name"))
+ " × " + Строка(Элемент.Получить("quantity"))
+ " = " + Строка(Элемент.Получить("amount")));
КонецЕсли;
КонецЦикла;
КонецЕсли;
Возврат СтрСоединить(Строки, Символы.ПС);
КонецФункции
Шаг 5. Пользовательский интерфейс
Форма обработки минимальна:
-
Поле «Адрес сервиса» (по умолчанию
http://localhost:5000). -
Имя файла и кнопка «Выбрать файл».
-
Кнопка «Распознать счёт».
-
Большое текстовое поле с JSON (только для чтения) — результат работы сервиса.
-
Скрытый реквизит
АдресДанныхВоВременномХранилище— для будущих этапов.
Ссылка на готовую обработку: ссылка
Готов ответить на все вопросы в комментариях.
Вступайте в нашу телеграмм-группу Инфостарт