ФГИС «Сатурн» из 1С:БП: HTTP 500 вместо своих данных, одноразовый refresh-токен и пустые ПАТ
У нас в 1С:Бухгалтерии предприятия КОРП 3.0 на платформе 8.5 работает собственный блок защиты растений: планы и факты обработок садов, справочник препаратов, нормы расхода. ФГИС «Сатурн» понадобился по двум причинам. Применение пестицидов и агрохимикатов оформляется в госсистеме актами и планами применения, и вводить их второй раз руками бухгалтеру не хочется. А в реестре Сатурна по каждому препарату лежат регламенты: культура, вредный объект, норма, кратность, срок ожидания. Их удобно сверять с тем, что агроном на самом деле сделал в саду.
Интеграция с Сатурном в БП типовая. Подключили, ввели ключи, запросили места хранения своей организации и получили ошибку. На поиск причины ушел день, и большая его часть ушла на ложный след. Ниже то, что подтвердилось на практике: запросы, ответы и обходы. Код писали вместе с ИИ-агентом в EDT, как и все остальное в проекте (об этом отдельная серия), но эта статья про Сатурн.
Как устроен обмен
Типовой модуль ИнтерфейсСАТУРН ходит на два адреса. Ключи обновляются на fgis-saturn.ru по ресурсу backend/role/newaccesstoken, данные читаются и пишутся на api.fgis-saturn.ru по ресурсу probeInnerArm/innerArm/seapi/. Для тестового контура есть отдельный хост demo-hs.fgis-saturn.ru, он включается константой РежимРаботыСТестовымКонтуромСАТУРН.
Все запросы к данным устроены одинаково: POST на один и тот же ресурс, а что именно нужно, говорит тело. Это SE-API.
{
"com": "execOperation",
"otype": "Warehouse",
"op": "static/getList()",
"opargs": {
"pos": 0,
"size": 100,
"getFullCards": 0,
"filters": [ { "column": "ownerId", "condition": "=", "value": ["<ИД_организации>"] } ]
}
}
otype задает тип объекта: Warehouse (места хранения), Acreage (места применения), PatProduct (реестр ПАТ), Contractor (организации), PATBatch (партии) и так далее. Список типов отдает команда getRegisteredEtityNames (имя команды именно такое, с опечаткой). При getFullCards = 0 ответ приходит таблицей resData.attrTable: первая строка содержит имена колонок, следующие строки содержат данные. При getFullCards = 1 приходят полные карточки в resData.objList._OBJ_ARRAY, и только в них есть вложенные данные, например регламенты препарата.
Поверх типовой подсистемы мы построили свое:
- препарат блока СЗР связан с элементом реестра ПАТ;
- сад связан с местами применения Сатурна (отдельный регистр сведений);
- склад СЗР связан с местом хранения через типовой классификатор организаций;
- регламенты препаратов загружаются в свой регистр раз в месяц регламентным заданием;
- из факта обработки создается акт применения, из плана обработки создается план применения.
Отправляет документы в Сатурн пользователь, из типового рабочего места. Наш код их только создает, заполняет и пытается провести.

Запрос без фильтра уходит не туда
Первый же вызов типовой функции на чтение мест хранения вернул вот что (внутренний адрес сокращен):
{"status": 500, "error": "Internal Server Error",
"message": "401 Unauthorized from POST http://<внутренний-хост>/internal/warehouses/seapi-by-filter/<ИД_организации>",
"path": "/probeInnerArm/innerArm/seapi/"}
HTTP 500, а внутри 401 от какого-то внутреннего сервиса. Первая гипотеза была про ключи: в поле iss токена стоит адрес с поддоменом demo-auth, и мы решили, что ключи выданы для тестового контура. Переключили константу на тестовый контур и получили уже честное «Ошибка авторизации. Проверьте ключи авторизации и обновления». Ключи были боевые, а demo-auth в издателе токена ни о чем не говорит. На этом месте ушло полдня.
Дальше пошли сырыми запросами мимо типовой библиотеки. Прототипов на Python здесь не было: короткие BSL-скрипты, которые читают ключ из регистра настроек, отправляют тело в HTTPСоединение и пишут ответ в файл. Сам ключ в вывод не попадает.
Соединение = Новый HTTPСоединение("api.fgis-saturn.ru", 443, , , , 60, Новый ЗащищенноеСоединениеOpenSSL);
Запрос = Новый HTTPЗапрос("/probeInnerArm/innerArm/seapi/");
Запрос.Заголовки.Вставить("Authorization", "Bearer " + КлючАвторизации);
Запрос.Заголовки.Вставить("Content-Type", "application/json; charset=utf-8");
Тело = "{""com"":""execOperation"",""otype"":""Warehouse"",""op"":""static/getList()"","
+ """opargs"":{""pos"":0,""size"":100,""getFullCards"":0,"
+ """filters"":[{""column"":""ownerId"",""condition"":""="",""value"":[""<ИД_организации>""]}]}}";
Запрос.УстановитьТелоИзСтроки(Тело, КодировкаТекста.UTF8, ИспользованиеByteOrderMark.НеИспользовать);
Ответ = Соединение.ВызватьHTTPМетод("POST", Запрос);
Данные = ОбщегоНазначения.JSONВЗначение(Ответ.ПолучитьТелоКакСтроку(КодировкаТекста.UTF8), , Истина);
Таблица = Данные.Получить("resData").Получить("attrTable"); // [0] - имена колонок
Сравнение с тем, что отправляет типовая, показало разницу. Когда условий поиска нет, типовая не передает в теле фильтр вовсе, и Сатурн отвечает HTTP 500 с ошибкой 401 внутри: такой запрос он маршрутизирует во внутренний сервис «данные моей организации», а тот отказывает. С фильтром по своей организации (ownerId, значение массивом) тот же запрос возвращает места хранения своей организации.
Типовая процедура ИнтерфейсСАТУРН.ДобавитьУсловиеОтбора сама заворачивает значение в массив, поэтому лечится одной строкой: всегда передавать идентификатор организации.
ПараметрыПоиска = Новый Структура("ИдентификаторОрганизации", ИдОрганизацииСатурн);
Ответ = ИнтерфейсСАТУРН.СписокМестХранения(ПараметрыПоиска, 1, 100);
Ответ = ИнтерфейсСАТУРН.СписокМестПрименения(ПараметрыПоиска, 1, 100);
Колонка фильтра зависит от типа: для Warehouse это ownerId, для Acreage уже managingContractorId, типовая подставляет нужную сама. Идентификатор организации есть в поле contractorid токена, его же возвращает ИнтерфейсСАТУРН.СписокКонтрагентов по ИНН. По одному ИНН может прийти несколько записей: у нас их было две, действующая и архивная, с другим наименованием. Брать нужно ту, у которой lcState = actual.
И общий урок про ошибки. Сатурн почти все отдает как HTTP 500, а настоящая причина лежит в поле message: там бывает и 401 от внутреннего сервиса, и Could not resolve attribute 'ownerId', и исключение Java. Логировать нужно тело ответа целиком, код состояния почти ничего не говорит.
Токены: одна пара на одну базу
Пару ключей пользователь вводит в форме «Настройки подключения САТУРН». Оба ключа являются JWT. По полю exp у нас вышло: ключ авторизации живет 14 дней, ключ обновления 28.
Типовая обновляет пару сама. Если ключу авторизации осталось меньше 7 дней (или он уже истек), а ключ обновления еще действует, она отправляет GET на fgis-saturn.ru/backend/role/newaccesstoken с заголовком refresh-token, получает access_token и refresh_token и под исключительной блокировкой перезаписывает пару в регистре НастройкиПодключенияСАТУРН. Старый ключ обновления после этого мертв: он одноразовый.
Отсюда главное правило. Пара ключей принадлежит одной базе. Если ввести одну и ту же пару в рабочую и в разработческую базу, первая же база, которая обновит ключ, оставит вторую без доступа. По логике одноразового ключа вторая база при своем следующем обновлении получит ошибку авторизации, и связать ее с вводом ключей в соседнюю базу непросто. Для рабочей и тестовой баз нужны разные пары. По той же логике копия рабочей базы, развернутая для тестов, уносит с собой ее ключи, поэтому в копии их лучше очистить до первого обмена.

Ключи мы никогда не выводим и не передаем через скрипты или переписку, их вводит человек в форме. А проверить состояние пары можно и не показывая ее, типовая умеет декодировать токен:
Данные = ИнтеграцияСАТУРНСлужебный.ДекодироватьТокенJWT(Настройки.КлючОбновления);
Сообщить(СтрШаблон("ошибка декодирования %1, тип %2, действует до %3",
Данные.ОшибкаДекодирования, Данные.Тип, Формат(Данные.ДействуетДо, "ДЛФ=DT")));
На рабочей базе эта проверка сразу показала, что в поле ключа авторизации лежит не JWT, а ключа обновления нет вовсе. Это быстрее, чем разбирать сообщение об ошибке обмена.
Справочники: пустые ПАТ, участки и препараты
ПАТ «<Требуется загрузка>». Функция Справочники.КлассификаторПАТСАТУРН.ПАТ(Идентификатор, ПараметрыОбмена) находит элемент по идентификатору реестра, а если его нет, создает пустышку с наименованием <Требуется загрузка> и признаком ТребуетсяЗагрузка. Карточку она не загружает. Если сопоставлять препараты по идентификаторам, таких пустышек набирается много (у нас было 68). Дозагрузка:
Выборка = ... // Справочник.КлассификаторПАТСАТУРН ГДЕ ТребуетсяЗагрузка
ПараметрыОбмена = ИнтеграцияСАТУРН.ПараметрыОбмена();
Пока Выборка.Следующий() Цикл
Отбор = Новый Структура("Идентификатор, РасширенныйСоставДанных", Выборка.Ид, Истина);
Ответ = ИнтерфейсСАТУРН.СписокПАТ(Отбор, 1, 1);
Если ЗначениеЗаполнено(Ответ.ТекстОшибки) ИЛИ Ответ.Список = Неопределено Тогда
Ответ = ИнтерфейсСАТУРН.СписокПАТ(Отбор, 1, 1); // реестр отвечает ошибкой через раз
КонецЕсли;
Если ЗначениеЗаполнено(Ответ.ТекстОшибки) ИЛИ Ответ.Список.Количество() = 0 Тогда
Продолжить; // в протокол
КонецЕсли;
Данные = Справочники.КлассификаторПАТСАТУРН.ДанныеОбъекта(Ответ.Список[0]);
Справочники.КлассификаторПАТСАТУРН.ЗагрузитьОбъект(Данные, ПараметрыОбмена, Выборка.Ссылка.ПолучитьОбъект(), Ложь);
КонецЦикла;
Повтор там не для красоты: реестр время от времени отвечает ошибкой на запрос, который сразу после этого проходит.
Места применения. В Сатурне это земельные участки, перенесенные из другой системы: в описании так и написано «обновлено из Cerber», а в наименовании стоят номер площадки, кадастровый номер и метка вида «(сад №N)». Участков 120, садов около десятка. Сопоставление по кадастровому номеру дало 6 совпадений из 120: у сада в 1С один номер, а участков у него десятки. Сработала метка в наименовании, 117 из 120, оставшиеся три без метки разобрал человек. Попался и участок, который в Сатурне записан дважды с разными метками сада, одна из них ошибочная. Такие случаи скрипт должен выводить, а не решать сам.
Площади участка в типовом справочнике мест применения нет: в ответе SE-API поле area приходит, но реквизита для него в справочнике нет. Поэтому при создании акта расход препарата по саду мы делим поровну между его участками, а последний участок получает остаток от округления.
Препараты. В реестре наименование хранится без препаративной формы («Кораген», а форма лежит отдельным полем), у нас с ней («Кораген, КС»). Перед поиском форму отрезаем, сравниваем по нормализованному ключу: без пробелов и знаков препинания, латинские буквы-двойники заменены кириллицей. Сплошной проход по 128 препаратам дал 71 однозначное совпадение, 4 случая с несколькими кандидатами, 14 только с похожими названиями и 39 без совпадений. Среди ненайденных удобрения, адъюванты, опечатки и одно название с латинской «C» в начале. Автоматически связали только однозначные, остальные решал человек.
И одно предупреждение про интерфейс. Кнопку «Создать» в классификаторе организаций Сатурна не нажимайте ради проверки: она регистрирует организацию в госсистеме.
Регламенты и документы применения
Поле razresheniya в карточке ПАТ у нас было пустым. Регламенты лежат в массиве regulations, и только в полной карточке (getFullCards = 1, в типовой это параметр РасширенныйСоставДанных). Одна запись выглядит так:
{
"harmfulObject": "Яблонная плодожорка",
"cropName": "Яблоня",
"processingMethod": "Опрыскивание в период вегетации",
"applicationRateOfPreparationMin": "0,4",
"applicationRateOfPreparationMax": "0,5",
"applicationRateOfPreparationUnit": "л/га",
"waitingPeriod": "10",
"treatFreq": "3",
"timeForManualWork": "-",
"timeForMechWork": "3",
"allowForAviation": false
}
Числа приходят строками, с запятой, иногда с пояснением в скобках («3(1)»), иногда прочерком. Разбирать их нужно своей функцией: берем первое число, запятую считаем десятичным разделителем, прочерк и пустую строку считаем нулем.
За 21 секунду 68 препаратов дали 11 709 регламентов. Это все культуры, на которые зарегистрирован препарат, включая декоративную яблоню и подвои. Для проверки мы берем регламенты своей культуры без декоративных посадок и по ним самые строгие значения: наибольший срок ожидания, наименьшую ненулевую кратность. У 24 наших препаратов регламента на яблоню нет вовсе, в основном это удобрения. Регистр перезаполняется регламентным заданием первого числа каждого месяца в 03:00. Проверки показываются в форме факта и плана обработки, плюс отдельный отчет о соблюдении регламентов.
С документами всплыли две особенности типовой.
Первая: программная запись документа в режиме проведения не выполняет проверку заполнения. Акт без партий и номенклатуры спокойно проводится. Поэтому перед проведением вызываем ПроверитьЗаполнение() сами. Если проверка не прошла, документ остается записанным, а сообщения сворачиваются в одну строку на колонку с числом строк: «Не заполнена колонка "Партия" списка "Препараты" (строк: 30)».
Вторая: если заполнить ДокументОснование плана применения строкой, типовой расчет статусов оформления вызывает у основания Метаданные() и падает. Основание мы не заполняем, связь «документ блока СЗР и документ Сатурна» храним в своем регистре и в комментарии.
Грабли одной таблицей
| Грабля | Симптом | Решение |
|---|---|---|
| Запрос списка без фильтра | HTTP 500, в message 401 от seapi-by-filter/<ИД> |
всегда передавать ИдентификаторОрганизации |
demo-auth в издателе токена |
ложная мысль о тестовых ключах | смотреть, на какой хост идут запросы, а не на iss |
| Две записи организации по одному ИНН | берется архивная | выбирать lcState = actual |
| Одноразовый ключ обновления | при следующем обновлении одна из баз теряет доступ | отдельная пара на каждую базу, очищать ключи в копиях |
| ПАТ «<Требуется загрузка>» | пустые элементы реестра | дозагрузка СписокПАТ по идентификатору |
| Реестр отвечает ошибкой через раз | случайные отказы в цикле | один повтор запроса |
| Нет площади участка | расход не распределить по площади | делить поровну с остатком на последний участок |
| Регламенты только в полной карточке | razresheniya пустое |
getFullCards = 1, массив regulations |
| Числа регламента строками | «0,4», «3(1)», «-» | своя функция разбора |
| Проведение без проверки заполнения | проводится неполный акт | ПроверитьЗаполнение() перед проведением |
Строковый ДокументОснование |
падает расчет статусов | основание не заполнять, связь в своем регистре |
Что не решено
Прочитать свои партии, остатки и сведения о применении сырыми запросами SE-API не получилось. Для PATBatch фильтр по владельцу отвечает Could not resolve attribute 'ownerId' (то же для contractorId и managingContractorId), WarehouseStates падает с исключением Java на любом фильтре, PatUsage уходит в тот же внутренний сервис и получает 401. Партии в документах подбирает типовой механизм, а для него нужны сопоставления склада с местом хранения и номенклатуры с ПАТ. Их делает бухгалтер вручную, и номенклатура пока указана у 19 препаратов из 128. Поэтому созданные акты сейчас в основном остаются записанными, но не проведенными, и их дозаполняет человек.
На рабочую базу сопоставления переносятся скриптом по идентификаторам Сатурна: препараты там другие элементы, их ищем по тому же нормализованному наименованию. Проверочный прогон без записи нашел все 73 препарата и все 117 мест применения, запись запускается только после того, как в рабочей базе введена ее собственная пара ключей.
Сатурн выглядит как обычный REST, но ведет себя иначе: один ресурс на все, а ошибки упакованы в HTTP 500, и настоящую причину приходится искать в теле ответа. Главное правило, которое снимает основную часть проблем: каждый запрос списка делать с идентификатором своей организации, через типовые функции. Если вы интегрировали Сатурн, напишите в комментариях: читаются ли у вас партии и остатки своей организации через SE-API, каким способом, и пробовали ли вы поле area мест применения. Каждый такой ответ экономит кому-то день.
Вступайте в нашу телеграмм-группу Инфостарт
Вы можете заказать платную адаптацию этой статьи под ваши задачи на «Бирже заказов».
- 0% комиссии — оплата напрямую исполнителю;
- Исполнители любого масштаба — от отдельных специалистов до команд под проект;
- Прямой обмен контактами между заказчиком и исполнителем;
- Безопасная сделка — при необходимости;
- Рейтинги, кейсы и прозрачная система откликов.